Python-集成学习实用指南-三-

82 阅读51分钟

Python 集成学习实用指南(三)

原文:annas-archive.org/md5/681e70f53a5cd12054ae0d01b7b855ea

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:在 Twitter 上评估情感

Twitter 是一个非常受欢迎的社交网络,拥有超过 3 亿月活跃用户。该平台围绕简短的帖子(字符数量有限,目前限制为 280 个字符)开发。帖子本身称为推文。平均每秒发布 6000 条推文,相当于每年约 2000 亿条推文。这构成了一个庞大的数据量,包含了大量信息。显然,手动分析如此大量的数据是不可能的。因此,Twitter 和第三方都采用了自动化解决方案。最热门的话题之一是推文的情感分析,或者说用户对他们发布的主题的情感。情感分析有很多种形式。最常见的方法是对每条推文进行正面或负面分类。其他方法则涉及更复杂的正负面情感分析,如愤怒、厌恶、恐惧、快乐、悲伤和惊讶等。在本章中,我们将简要介绍一些情感分析工具和实践。接下来,我们将介绍构建一个利用集成学习技术进行推文分类的分类器的基础知识。最后,我们将看到如何通过使用 Twitter 的 API 实时分类推文。

本章将涵盖以下主题:

  • 情感分析工具

  • 获取 Twitter 数据

  • 创建模型

  • 实时分类推文

技术要求

你需要具备基本的机器学习技术和算法知识。此外,还需要了解 Python 的约定和语法。最后,熟悉 NumPy 库将极大帮助读者理解一些自定义算法实现。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter11

查看以下视频,了解代码的实际应用:bit.ly/2XSLQ5U

情感分析工具

情感分析可以通过多种方式实现。最容易实现和理解的方法是基于词典的方法。这些方法利用了极性单词和表达的词典列表。给定一个句子,这些方法会计算正面和负面单词及表达的数量。如果正面单词/表达的数量更多,则该句子被标记为正面。如果负面单词/表达比正面更多,则该句子被标记为负面。如果正面和负面单词/表达的数量相等,则该句子被标记为中性。虽然这种方法相对容易编码,并且不需要任何训练,但它有两个主要缺点。首先,它没有考虑单词之间的相互作用。例如,not bad,实际上是一个正面的表达,但可能被分类为负面,因为它由两个负面单词组成。即使该表达在词典中被归为正面,表达not that bad也可能没有包含在内。第二个缺点是整个过程依赖于良好和完整的词典。如果词典遗漏了某些单词,结果可能会非常糟糕。

另一种方法是训练一个机器学习模型来分类句子。为此,必须创建一个训练数据集,其中一些句子由人工专家标记为正面或负面。这个过程间接揭示了情感分析中的一个隐藏问题(也表明了其难度)。人类分析师在 80%到 85%的情况下达成一致。这部分是由于许多表达的主观性。例如,句子今天天气很好,昨天很糟糕,可以是正面、负面或中性。这取决于语调。假设粗体部分有语调,今天天气很好,昨天很糟糕是正面的,今天天气很好,昨天很糟糕是负面的,而今天天气很好,昨天很糟糕实际上是中性的(只是简单地观察天气变化)。

你可以在此链接阅读更多关于人类分析师在情感分类中分歧的问题:www.lexalytics.com/lexablog/sentiment-accuracy-quick-overview

为了从文本数据中创建机器学习特征,通常会创建 n-grams。N-grams 是从每个句子中提取的n个词的序列。例如,句子"Hello there, kids"包含以下内容:

  • 1-grams: "Hello","there,","kids"

  • 2-grams: "Hello there,","there, kids"

  • 3-grams: "Hello there, kids"

为了为数据集创建数值特征,为每个唯一的 N-gram 创建一个特征。对于每个实例,特征的值取决于它在句子中出现的次数。例如,考虑以下玩具数据集:

句子极性
我的头很痛正面
食物很好吃负面
刺痛很严重正面
那是一个很棒的时光负面

一个情感玩具数据集

假设我们只使用 1-gram(单字)。数据集中包含的唯一单字有:“My”,“head”,“hurts”,“The”,“food”,“was”,“good”,“sting”,“That”,“a”和“time”。因此,每个实例有 11 个特征。每个特征对应一个单元词(在本例中是单字)。每个特征的值等于该单元词在该实例中的出现次数。最终的数据集如下所示:

我的食物很好刺痛时间极性
11100000000正面
00012110000负面
00110001000正面
00000110111负面

提取的特征数据集

通常,每个实例会被归一化,因此每个特征表示的是每个单元词的相对频率,而不是绝对频率(计数)。这种方法被称为词频TF)。TF 数据集如下所示:

我的食物很好刺痛时间极性
0.330.330.3300000000正面
0000.20.40.20.20000负面
000.330.330000.33000正面
000000.20.200.20.20.2负面

TF 数据集

在英语中,一些词语的出现频率非常高,但对表达情感的贡献很小。为了考虑这一事实,采用了逆文档频率IDF)。IDF 更加关注不常见的词语。对于N个实例和K个唯一的单词,单词u的 IDF 值计算公式如下:

以下表格显示了 IDF 转换后的数据集:

我的食物很好刺痛时间极性
0.60.60.300000000正面
0000.30.60.30.30000负面
000.30.30000.6000正面
000000.30.300.60.60.6负面

IDF 数据集

词干提取

词干提取是情感分析中常用的另一种做法。它是将单词还原为词根的过程。这使得我们可以将来源于相同词根的单词作为同一个单元词处理。例如,lovelovingloved都会作为相同的单元词,love来处理。

获取 Twitter 数据

收集 Twitter 数据有多种方式。从网页抓取到使用自定义库,每种方式都有不同的优缺点。对于我们的实现,由于我们还需要情感标注,我们将使用 Sentiment140 数据集(cs.stanford.edu/people/alecmgo/trainingandtestdata.zip)。我们不收集自己的数据,主要是因为需要标注数据的时间。在本章的最后部分,我们将看到如何收集自己的数据并实时分析。该数据集包含 160 万条推文,包含以下 6 个字段:

  • 推文的情感极性

  • 数字 ID

  • 推文的日期

  • 用于记录推文的查询

  • 用户的名字

  • 推文的文本内容

对于我们的模型,我们只需要推文的文本和情感极性。如以下图表所示,共有 80 万个正面推文(情感极性为 4)和 80 万个负面推文(情感极性为 0):

情感极性分布

在这里,我们还可以验证我们之前关于单词频率的说法。以下图表展示了数据集中最常见的 30 个单词。显然,它们没有表现出任何情感。因此,IDF 转换对我们的模型将更有帮助:

数据集中最常见的 30 个单词及其出现次数

创建模型

情感分析中最重要的步骤(就像大多数机器学习问题一样)是数据的预处理。以下表格包含从数据集中随机抽取的 10 条推文:

id文本
44@JonathanRKnight 哎呀,我真希望我能在那儿看到...
143873胃部翻腾……天啊,我讨厌这个...
466449为什么他们拒绝把好东西放进我们的 v...
1035127@KrisAllenmusic 访问这里
680337Rafa 退出温布尔登,因 BLG 感情失控...
31250官方宣布,打印机讨厌我,准备沉沦...
1078430@Enigma_ 很高兴听到这个
1436972亲爱的 Photoshop CS2. 我爱你,我想你!
401990我的男朋友今天出了车祸!
1053169生日快乐,威斯康星州!161 年前,你...

数据集中的 10 个随机样本大纲

我们可以立即得出以下观察结果。首先,有对其他用户的引用,例如@KrisAllenmusic。这些引用并没有提供有关推文情感的信息。因此,在预处理过程中,我们将删除它们。其次,有数字和标点符号。这些也没有贡献推文的情感,因此它们也必须被删除。第三,部分字母是大写的,而其他字母则不是。由于大小写不会改变单词的情感,我们可以选择将所有字母转换为小写或大写。这确保像LOVEloveLove这样的词将被视为相同的单元词。如果我们再取样更多推文,可以识别出更多问题。有话题标签(例如#summer),这些同样不贡献推文的情感。此外,还有网址链接(例如www.packtpub.com/eu/)和 HTML 属性(如&amp对应&)。这些在预处理中也将被删除。

为了对数据进行预处理,首先,我们必须导入所需的库。我们将使用 pandas,Python 内置的正则表达式库restring中的punctuation,以及自然语言工具包NLTK)。可以通过pipconda轻松安装nltk库,方法如下:

import pandas as pd
import re
from nltk.corpus import stopwords
from nltk.stem import PorterStemmer
from string import punctuation

加载完库后,我们加载数据,将极性从*[0, 4]更改为[0, 1]*,并丢弃除了文本内容和极性之外的所有字段:

# Read the data and assign labels
labels = ['polarity', 'id', 'date', 'query', 'user', 'text']
data = pd.read_csv("sent140.csv", names=labels)

# Keep only text and polarity, change polarity to 0-1
data = data[['text', 'polarity']]
data.polarity.replace(4, 1, inplace=True)

正如我们之前所看到的,许多单词并不对推文的情感产生影响,尽管它们在文本中经常出现。搜索引擎通过去除这些单词来处理此问题,这些单词被称为停用词。NLTK 提供了最常见的停用词列表,我们将利用该列表。此外,由于有一些停用词是缩写词(如"you're"和"don't"),而且推文中通常省略缩写词中的单引号,因此我们将扩展该列表,以包括没有单引号的缩写词(如"dont")。

# Create a list of stopwords
stops = stopwords.words("english")
# Add stop variants without single quotes
no_quotes = []
for word in stops:
    if "'" in word:
        no_quotes.append(re.sub(r'\'', '', word))
stops.extend(no_quotes)

然后我们定义了两个不同的函数。第一个函数clean_string通过删除我们之前讨论过的所有元素(如引用、话题标签等)来清理推文。第二个函数通过使用 NLTK 的PorterStemmer去除所有标点符号或停用词,并对每个单词进行词干化处理:

def clean_string(string):
    # Remove HTML entities
    tmp = re.sub(r'\&\w*;', '', string)
    # Remove @user
    tmp = re.sub(r'@(\w+)', '', tmp)
    # Remove links
    tmp = re.sub(r'(http|https|ftp)://[a-zA-Z0-9\\./]+', '', tmp)
    # Lowercase
    tmp = tmp.lower()
    # Remove Hashtags
    tmp = re.sub(r'#(\w+)', '', tmp)
    # Remove repeating chars
    tmp = re.sub(r'(.)\1{1,}', r'\1\1', tmp)
    # Remove anything that is not letters
    tmp = re.sub("[^a-zA-Z]", " ", tmp)
    # Remove anything that is less than two characters
    tmp = re.sub(r'\b\w{1,2}\b', '', tmp)
    # Remove multiple spaces
    tmp = re.sub(r'\s\s+', ' ', tmp)
    return tmp

def preprocess(string):
    stemmer = PorterStemmer()
    # Remove any punctuation character
    removed_punc = ''.join([char for char in string if char not in punctuation])
    cleaned = []
    # Remove any stopword
    for word in removed_punc.split(' '):
        if word not in stops:
            cleaned.append(stemmer.stem(word.lower()))
    return ' '.join(cleaned)

由于我们希望比较集成模型与基学习器本身的性能,我们将定义一个函数,用于评估任何给定的分类器。定义我们数据集的两个最重要因素是我们将使用的 n-gram 和特征数量。Scikit-learn 提供了一个 IDF 特征提取器实现,即 TfidfVectorizer 类。这使得我们可以仅使用 M 个最常见的特征,并通过 max_featuresngram_range 参数定义我们将使用的 n-gram 范围。它创建了稀疏特征数组,这节省了大量内存,但结果必须在被 scikit-learn 分类器处理之前转换为普通数组。这可以通过调用 toarray() 函数来实现。我们的 check_features_ngrams 函数接受特征数量、最小和最大 n-gram 的元组,以及命名分类器的列表(名称,分类器元组)。它从数据集中提取所需的特征,并将其传递给嵌套的 check_classifier。该函数训练并评估每个分类器,并将结果导出到指定的文件 outs.txt

def check_features_ngrams(features, n_grams, classifiers):
    print(features, n_grams)

    # Create the IDF feature extractor
    tf = TfidfVectorizer(max_features=features, ngram_range=n_grams,
                         stop_words='english')

    # Create the IDF features
    tf.fit(data.text)
    transformed = tf.transform(data.text)
    np.random.seed(123456)

    def check_classifier(name, classifier):
        print('--'+name+'--')

        # Train the classifier
        x_data = transformed[:train_size].toarray()
        y_data = data.polarity[:train_size].values
        classifier.fit(x_data, y_data)
        i_s = metrics.accuracy_score(y_data, classifier.predict(x_data))

        # Evaluate on the test set
        x_data = transformed[test_start:test_end].toarray()
        y_data = data.polarity[test_start:test_end].values
        oos = metrics.accuracy_score(y_data, classifier.predict(x_data))

        # Export the results
        with open("outs.txt","a") as f:
            f.write(str(features)+',')
            f.write(str(n_grams[-1])+',')
            f.write(name+',')
            f.write('%.4f'%i_s+',')
            f.write('%.4f'%oos+'\n')

    for name, classifier in classifiers:
        check_classifier(name, classifier)
Finally, we test for n-grams in the range of [1, 3] and for the top 500, 1000, 5000, 10000, 20000, and 30000 features.

# Create csv header
with open("outs.txt","a") as f:
    f.write('features,ngram_range,classifier,train_acc,test_acc')
# Test all features and n-grams combinations
for features in [500, 1000, 5000, 10000, 20000, 30000]:
    for n_grams in [(1, 1), (1, 2), (1, 3)]:
    # Create the ensemble
        voting = VotingClassifier([('LR', LogisticRegression()),
                                   ('NB', MultinomialNB()),
                                    ('Ridge', RidgeClassifier())])
    # Create the named classifiers
    classifiers = [('LR', LogisticRegression()),
                    ('NB', MultinomialNB()),
                    ('Ridge', RidgeClassifier()),
                    ('Voting', voting)]
     # Evaluate them
     check_features_ngrams(features, n_grams, classifiers)

结果如下面的图表所示。如图所示,随着特征数量的增加,所有分类器的准确率都有所提高。此外,如果特征数量相对较少,单一的 unigram 优于 unigram 与 bigram/trigram 的组合。这是因为最常见的表达式往往没有情感色彩。最后,尽管投票法的表现相对令人满意,但仍未能超过逻辑回归:

投票法与基学习器的结果

实时分类推文

我们可以利用我们的模型通过 Twitter 的 API 实时分类推文。为了简化操作,我们将使用一个非常流行的 API 封装库 tweepygithub.com/tweepy/tweepy)。安装可以通过 pip install tweepy 很容易地完成。通过编程访问 Twitter 的第一步是生成相关的凭证。这可以通过访问 apps.twitter.com/ 并选择“创建应用”来实现。申请过程简单,通常很快就会被接受。

使用 tweepy 的 StreamListener,我们将定义一个监听器类,当推文到达时,它会立即对其进行分类,并打印原始文本和预测的极性。首先,我们将加载所需的库。作为分类器,我们将使用之前训练的投票集成模型。首先,加载所需的库。我们需要 json 库,因为推文以 JSON 格式接收;还需要部分 tweepy 库以及之前使用过的 scikit-learn 组件。此外,我们将 API 密钥存储在变量中:

import pandas as pd
import json
from sklearn.ensemble import VotingClassifier
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression, RidgeClassifier
from sklearn.naive_bayes import MultinomialNB
from tweepy import OAuthHandler, Stream, StreamListener
# Please fill your API keys as strings
consumer_key="HERE,"
consumer_secret="HERE,"

access_token="HERE,"
access_token_secret="AND HERE"

接下来,我们创建并训练我们的TfidfVectorizerVotingClassifier,使用 30,000 个特征和范围为*[1, 3]*的 n-gram:

# Load the data
data = pd.read_csv('sent140_preprocessed.csv')
data = data.dropna()
# Replicate our voting classifier for 30.000 features and 1-3 n-grams
train_size = 10000
tf = TfidfVectorizer(max_features=30000, ngram_range=(1, 3),
                         stop_words='english')
tf.fit(data.text)
transformed = tf.transform(data.text)
x_data = transformed[:train_size].toarray()
y_data = data.polarity[:train_size].values
voting = VotingClassifier([('LR', LogisticRegression()),
                           ('NB', MultinomialNB()),
                           ('Ridge', RidgeClassifier())])
voting.fit(x_data, y_data)

接下来,我们定义StreamClassifier类,负责监听到达的推文并对其进行分类。它继承自tweepyStreamListener类。通过重写on_data函数,我们可以在推文通过流到达时对其进行处理。推文以 JSON 格式到达,因此我们首先使用json.loads(data)解析它们,返回一个字典,然后使用"text"键提取文本。我们可以使用拟合好的vectorizer提取特征,并利用这些特征预测其情感:

# Define the streaming classifier
class StreamClassifier(StreamListener):
    def __init__(self, classifier, vectorizer, api=None):
        super().__init__(api)
        self.clf = classifier
        self.vec = vectorizer
    # What to do when a tweet arrives
    def on_data(self, data):
        # Create a json object
        json_format = json.loads(data)
        # Get the tweet's text
        text = json_format['text']
        features = self.vec.transform([text]).toarray()
        print(text, self.clf.predict(features))
        return True
    # If an error occurs, print the status
    def on_error(self, status):
        print(status)

最后,我们实例化StreamClassifier,并将训练好的投票集成和TfidfVectorizer作为参数传入,使用OAuthHandler进行身份验证。为了启动流,我们实例化一个Stream对象,将OAuthHandlerStreamClassifier对象作为参数,并定义我们想要追踪的关键字filter(track=['Trump'])。在这个例子中,我们追踪包含关键字“特朗普”的推文,如下所示:

# Create the classifier and authentication handlers
classifier = StreamClassifier(classifier=voting, vectorizer=tf)
auth = OAuthHandler(consumer_key, consumer_secret)
auth.set_access_token(access_token, access_token_secret)

# Listen for specific hashtags
stream = Stream(auth, classifier)
stream.filter(track=['Trump'])

就是这样!前面的代码现在可以实时追踪任何包含“特朗普”关键字的推文,并预测其情感。下表显示了一些简单的推文及其分类结果:

文本情感
RT @BillyBaldwin: 比我兄弟模仿特朗普还要好笑的只有两件事。你女儿模仿一个诚实正直的...消极
RT @danpfeiffer: 这是民主党人必须阅读的一篇非常重要的文章。媒体报道特朗普的不当行为只是开始。这是...积极
RT @BillKristol: "换句话说,特朗普把自己逼到了死角,而不是墨西哥。他们抓住了他。他不得不妥协。而且他确实妥协了。他去...积极
RT @SenJeffMerkley: 尽管没有被提名,肯·库奇内利今天还是开始工作了,这是无法接受的。特朗普正在绕过参议院...消极

推文分类示例

总结

在本章中,我们讨论了使用集成学习对推文进行分类的可能性。虽然简单的逻辑回归可能优于集成学习技术,但它是自然语言处理领域的一个有趣入门,并且涉及到数据预处理和特征提取的技术。总的来说,我们介绍了 n-gram、IDF 特征提取、词干化和停用词移除的概念。我们讨论了清理数据的过程,并且训练了一个投票分类器,使用 Twitter 的 API 进行实时推文分类。

在下一章中,我们将看到如何在推荐系统的设计中利用集成学习,目的是向特定用户推荐电影。

第十二章:使用 Keras 进行电影推荐

推荐系统是一种宝贵的工具。它们能够提升客户体验并增加公司的盈利能力。此类系统通过基于用户已喜欢的其他物品,推荐用户可能喜欢的物品。例如,在亚马逊上购买智能手机时,系统会推荐该手机的配件。这样既提高了客户体验(因为他们无需再寻找配件),也增加了亚马逊的盈利(例如,如果用户并不知道有配件在售)。

在本章中,我们将讨论以下主题:

  • 解密推荐系统

  • 神经网络推荐系统

  • 使用 Keras 进行电影推荐

在本章中,我们将使用 MovieLens 数据集(可在files.grouplens.org/datasets/movielens/ml-latest-small.zip下载),利用 Keras 深度学习框架和集成学习技术创建一个电影推荐系统。

我们要感谢 GroupLens 团队授权我们在本书中使用他们的数据。有关数据的更多信息,请阅读以下相关论文:

F. Maxwell Harper 和 Joseph A. Konstan. 2015. The MovieLens Datasets: History and Context. ACM Transactions on Interactive Intelligent Systems (TiiS) 5, 4, Article 19 (2015 年 12 月),第 19 页。

论文可在以下链接获取:http://dx.doi.org/10.1145/2827872

技术要求

你需要具备基本的机器学习技术和算法知识。此外,了解 Python 的约定和语法也是必需的。最后,熟悉 NumPy 库将大大帮助读者理解一些自定义算法实现。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter12

查看以下视频,看看代码是如何执行的:bit.ly/2NXZqVE

解密推荐系统

尽管推荐系统的内部机制一开始看起来可能令人畏惧,但其实它们非常直观。让我们以一些电影和用户为例。每个用户可以根据 1 到 5 的评分标准评价电影。推荐系统会尝试找到与新用户兴趣相似的其他用户,并根据这些相似用户喜欢的电影,向新用户推荐可能喜欢的电影。我们来看一个简单的例子,包含四个用户和六部电影:

用户星际穿越2001 太空漫游黑客帝国全金属外壳海湾战争壮志凌云
U05421
U11443
U2441
U34554

每部电影每个用户的评分

如图所示,每个用户都评分了若干部电影,尽管并非所有用户都观看了相同的电影,并且每个用户的喜好各不相同。如果我们想向用户二U2)推荐一部电影,我们必须首先找到最相似的用户。然后,我们可以通过k-最近邻k-NN)的方式,使用K个最相似的用户来进行预测。当然,我们可以看到该用户可能喜欢科幻电影,但我们需要一种量化的方法来衡量这一点。如果我们将每个用户的偏好看作一个向量,我们就有四个六维的向量。然后,我们可以计算任意两个向量之间的余弦值。如果两个向量完全对齐,余弦值为 1,表示完全相同。如果向量完全相反,余弦值为 -1,表示两个用户的偏好完全相反。唯一的问题是,并非所有用户都评分了每部电影。为了计算余弦相似度,我们可以将空缺项填充为零。下图显示了用户之间的余弦相似度:

用户之间的余弦相似度

我们注意到,U0 和 U3 与 U2 展现出较高的相似度。问题是,U0 也与 U1 展现出较高的相似度,尽管他们的评分完全相反。这是因为我们将任何未评分的电影填充为 0,这意味着所有未观看电影的用户都同意他们不喜欢这部电影。这可以通过首先从每个用户的评分中减去其平均值来解决。这样可以将值归一化并将其集中在 0 附近。接下来,对于用户尚未评分的任何电影,我们将其赋值为 0。这表示用户对该电影没有偏好,并且用户的平均评分不会被改变。通过计算居中余弦相似度,我们得到以下值:

用户之间的居中余弦相似度

我们现在可以看到,U2 与 U0 和 U3 相似,而 U1 和 U0 则相差较大。为了计算 U2 未看过的电影的预测评分,但最近的K个邻居已经看过,我们将使用余弦相似度作为权重,计算每部电影的加权平均值。我们只对所有相似用户已经评分,但目标用户尚未评分的电影进行此操作。这为我们提供了以下预测评分。如果我们要向 U2 推荐一部电影,我们将推荐2001:太空漫游,一部科幻电影,正如我们之前所推测的:

星际穿越2001:太空漫游黑客帝国全金属外壳瓶中信壮志凌云
-4.00-3.322.32-

U2 的预测评分

这种推荐方法被称为协同过滤。当我们像这个小示例一样寻找相似用户时,这称为用户-用户过滤。我们也可以将这种方法应用于通过转置评分表来寻找相似项,这被称为物品-物品过滤,在实际应用中通常表现得更好。这是因为物品通常属于更明确的类别,相较于用户。例如,一部电影可以是动作片、惊悚片、纪录片或喜剧片,类型之间几乎没有重叠。一个用户可能喜欢这些类别的某种混合;因此,找到相似的电影比找到相似的用户要容易。

神经网络推荐系统

我们可以利用深度学习技术,而不是显式定义相似度度量,来学习特征空间的良好表示和映射。神经网络有多种方法可以用于构建推荐系统。在本章中,我们将展示两种最简单的方法,以展示如何将集成学习融入到系统中。我们将在网络中使用的最重要部分是嵌入层。这些层类型接受整数索引作为输入,并将其映射到 n 维空间。例如,二维映射可以将 1 映射到[0.5, 0.5]。通过这些层,我们将能够将用户的索引和电影的索引输入到网络中,网络将预测特定用户-电影组合的评分。

我们将测试的第一个架构由两个嵌入层组成,在这两个嵌入层的输出上进行点积操作,以预测用户对电影的评分。该架构如下图所示。虽然它不是传统的神经网络,但我们将利用反向传播来训练这两个嵌入层的参数:

简单的点积架构

第二个架构是一个更传统的神经网络。我们将不再依赖预定义的操作来结合嵌入层的输出(点积操作),而是允许网络找到将它们结合的最佳方式。我们将不使用点积,而是将嵌入层的输出馈送到一系列全连接(密集)层。该架构如下图所示:

全连接架构

为了训练网络,我们将使用 Adam 优化器,并使用 均方误差MSE)作为损失函数。我们的目标是尽可能准确地预测任何给定用户的电影评分。由于嵌入层具有预定的输出维度,我们将使用具有不同维度的多个网络来创建堆叠集成。每个单独的网络将是一个独立的基础学习器,并将使用相对简单的机器学习算法来组合各个预测。

使用 Keras 进行电影推荐

在本节中,我们将使用 Keras 作为深度学习框架来构建我们的模型。Keras 可以通过 pippip install keras)或 condaconda install -c conda-forge keras)轻松安装。为了构建神经网络,我们首先需要理解我们的数据。MovieLens 数据集包含了近 100,000 个样本和 4 个不同的变量:

  • userId:与特定用户对应的数字索引

  • movieId:与特定电影对应的数字索引

  • rating:一个介于 0 和 5 之间的值

  • timestamp:用户评分电影的具体时间

数据集中的一个示例如下表所示。显然,数据集是按照 userId 列排序的。这可能会导致我们的模型出现过拟合问题。因此,我们将在数据分割之前对数据进行洗牌。此外,我们不会在模型中使用 timestamp 变量,因为我们并不关心电影评分的顺序:

userIdmovieIdratingtimestamp
114964982703
134964981247
164964982224
1475964983815
1505964982931

数据集示例

通过查看下图中评分的分布情况,我们可以看到大多数电影的评分为 3.5,超过了评分范围的中间值(2.5)。此外,分布图显示出左偏尾,表明大多数用户给出的评分都比较慷慨。事实上,评分的第一四分位数范围是从 0.5 到 3,而其余 75% 的评分则在 3 到 5 的范围内。换句话说,用户只有在评分低于 3 的电影中,才会选择 1 部电影:

评分分布

创建点模型

我们的第一个模型将包括两个嵌入层,一个用于电影索引,另一个用于用户索引,以及它们的点积。我们将使用 keras.layers 包,它包含了所需的层实现,以及 keras.models 包中的 Model 实现。我们将使用的层如下:

  • Input 层,负责将更传统的 Python 数据类型转换为 Keras 张量

  • Embedding 层,这是嵌入层的实现

  • Flatten 层,将任何 Keras n 维张量转换为一维张量

  • Dot 层,实现点积

此外,我们将使用 train_test_splitsklearnmetrics

from keras.layers import Input, Embedding, Flatten, Dot, Dense, Concatenate
from keras.models import Model
from sklearn.model_selection import train_test_split
from sklearn import metrics

import numpy as np
import pandas as pd

除了设置 numpy 的随机种子外,我们定义了一个函数来加载和预处理数据。我们从 .csv 文件中读取数据,去除时间戳,并利用 pandas 的 shuffle 函数打乱数据。此外,我们创建了一个 80%/20% 的训练集/测试集划分。然后,我们重新映射数据集的索引,使其成为连续的整数索引:

def get_data():
    # Read the data and drop timestamp
    data = pd.read_csv('ratings.csv')
    data.drop('timestamp', axis=1, inplace=True)

    # Re-map the indices
    users = data.userId.unique()
    movies = data.movieId.unique()
    # Create maps from old to new indices
    moviemap={}
    for i in range(len(movies)):
        moviemap[movies[i]]=i
    usermap={}
    for i in range(len(users)):
        usermap[users[i]]=i

    # Change the indices
    data.movieId = data.movieId.apply(lambda x: moviemap[x]) 
    data.userId = data.userId.apply(lambda x: usermap[x]) 

    # Shuffle the data
    data = data.sample(frac=1.0).reset_index(drop=True)

    # Create a train/test split
    train, test = train_test_split(data, test_size=0.2)

    n_users = len(users)
    n_movies = len(movies)

    return train, test, n_users, n_movies
train, test, n_users, n_movies = get_data()

为了创建网络,我们首先定义输入的电影部分。我们创建一个 Input 层,它将作为我们 pandas 数据集的接口,通过接收数据并将其转换为 Keras 张量。接着,层的输出被输入到 Embedding 层,用于将整数映射到五维空间。我们将可能的索引数量定义为 n_movies(第一个参数),特征的数量定义为 fts(第二个参数)。最后,我们展平输出。用户部分重复相同的过程:

fts = 5

# Movie part. Input accepts the index as input
# and passes it to the Embedding layer. Finally,
# Flatten transforms Embedding's output to a
# one-dimensional tensor.
movie_in = Input(shape=[1], name="Movie")
mov_embed = Embedding(n_movies, fts, name="Movie_Embed")(movie_in)
flat_movie = Flatten(name="FlattenM")(mov_embed)

# Repeat for the user.
user_in = Input(shape=[1], name="User")
user_inuser_embed = Embedding(n_users, fts, name="User_Embed")(user_in)
flat_user = Flatten(name="FlattenU")(user_inuser_embed)

最后,我们定义点积层,以两个展平的嵌入向量作为输入。然后,我们通过指定 user_inmovie_inInput)层作为输入,prodDot)层作为输出,来定义 Model。在定义模型后,Keras 需要对其进行编译,以创建计算图。在编译过程中,我们定义优化器和损失函数:

# Calculate the dot-product of the two embeddings
prod = Dot(name="Mult", axes=1)([flat_movie, flat_user])

# Create and compile the model
model = Model([user_in, movie_in], prod)
model.compile('adam', 'mean_squared_error')

通过调用 model.summary(),我们可以看到模型大约有 52,000 个可训练参数。所有这些参数都在 Embedding 层中。这意味着网络将只学习如何将用户和电影的索引映射到五维空间。函数的输出如下:

模型的摘要

最后,我们将模型拟合到训练集,并在测试集上评估它。我们训练网络十个周期,以观察其行为,以及它需要多少时间来训练。以下代码展示了网络的训练进度:

# Train the model on the train set
model.fit([train.userId, train.movieId], train.rating, epochs=10, verbose=1)

# Evaluate on the test set
print(metrics.mean_squared_error(test.rating, 
      model.predict([test.userId, test.movieId])))

看一下下面的截图:

点积网络的训练进度

该模型在测试集上能够达到 1.28 的均方误差(MSE)。为了提高模型的性能,我们可以增加每个 Embedding 层能够学习的特征数量,但主要的限制是点积层。我们不会增加特征数量,而是让模型自由选择如何组合这两层。

创建密集模型

为了创建密集模型,我们将用一系列Dense层替代Dot层。Dense层是经典的神经元,每个神经元都会接收来自上一层的所有输出作为输入。在我们的例子中,由于我们有两个Embedding层,我们首先需要使用Concatenate层将它们连接起来,然后将其传递给第一个Dense层。这两层也包含在keras.layers包中。因此,我们的模型定义现在将如下所示:

# Movie part. Input accepts the index as input
# and passes it to the Embedding layer. Finally,
# Flatten transforms Embedding's output to a
# one-dimensional tensor.
movie_in = Input(shape=[1], name="Movie")
mov_embed = Embedding(n_movies, fts, name="Movie_Embed")(movie_in)
flat_movie = Flatten(name="FlattenM")(mov_embed)

# Repeat for the user.
user_in = Input(shape=[1], name="User")
user_inuser_embed = Embedding(n_users, fts, name="User_Embed")(user_in)
flat_user = Flatten(name="FlattenU")(user_inuser_embed)

# Concatenate the Embedding layers and feed them 
# to the Dense part of the network
concat = Concatenate()([flat_movie, flat_user])
dense_1 = Dense(128)(concat)
dense_2 = Dense(32)(dense_1)
out = Dense(1)(dense_2)

# Create and compile the model
model = Model([user_in, movie_in], out)
model.compile('adam', 'mean_squared_error')

通过添加这三个Dense层,我们将可训练参数的数量从接近 52,000 增加到接近 57,200(增加了 10%)。此外,现在每一步的时间需要大约 210 微秒,较之前的 144 微秒增加了 45%,这一点从训练进度和总结中可以明显看出,具体表现如以下图所示:

密集模型的总结

密集模型的训练进度

尽管如此,该模型现在的均方误差为 0.77,约为原始点积模型的 60%。因此,由于该模型表现优于之前的模型,我们将利用此架构构建我们的堆叠集成模型。此外,由于每个网络具有更高的自由度,它具有更高的概率与其他基础学习器进行多样化。

创建堆叠集成模型

为了创建我们的堆叠集成模型,我们将使用三个密集网络,其中嵌入层包含 5、10 和 15 个特征作为基础学习器。我们将在原始训练集上训练所有网络,并利用它们在测试集上进行预测。此外,我们将训练一个贝叶斯岭回归模型作为元学习器。为了训练回归模型,我们将使用测试集中的所有样本,除了最后的 1,000 个样本。最后,我们将在这最后的 1,000 个样本上评估堆叠集成模型。

首先,我们将创建一个函数,用于创建和训练一个具有n个嵌入特征的密集网络,以及一个接受模型作为输入并返回其在测试集上预测结果的函数:

def create_model(n_features=5, train_model=True, load_weights=False):
    fts = n_features

    # Movie part. Input accepts the index as input
    # and passes it to the Embedding layer. Finally,
    # Flatten transforms Embedding's output to a
    # one-dimensional tensor.
    movie_in = Input(shape=[1], name="Movie")
    mov_embed = Embedding(n_movies, fts, name="Movie_Embed")(movie_in)
    flat_movie = Flatten(name="FlattenM")(mov_embed)

    # Repeat for the user.
    user_in = Input(shape=[1], name="User")
    user_inuser_embed = Embedding(n_users, fts, name="User_Embed")(user_in)
    flat_user = Flatten(name="FlattenU")(user_inuser_embed)

    # Concatenate the Embedding layers and feed them 
    # to the Dense part of the network
    concat = Concatenate()([flat_movie, flat_user])
    dense_1 = Dense(128)(concat)
    dense_2 = Dense(32)(dense_1)
    out = Dense(1)(dense_2)

    # Create and compile the model
    model = Model([user_in, movie_in], out)
    model.compile('adam', 'mean_squared_error')
    # Train the model
    model.fit([train.userId, train.movieId], train.rating, epochs=10, verbose=1)

    return model

def predictions(model):
    preds = model.predict([test.userId, test.movieId])
    return preds

接下来,我们将创建并训练我们的基础学习器和元学习器,以便对测试集进行预测。我们将三种模型的预测结果组合成一个数组:

# Create base and meta learner
model5 = create_model(5)
model10 = create_model(10)
model15 = create_model(15)
meta_learner = BayesianRidge()

# Predict on the test set
preds5 = predictions(model5)
preds10 = predictions(model10)
preds15 = predictions(model15)
# Create a single array with the predictions
preds = np.stack([preds5, preds10, preds15], axis=-1).reshape(-1, 3)

最后,我们在除了最后 1,000 个测试样本之外的所有样本上训练元学习器,并在这最后的 1,000 个样本上评估基础学习器以及整个集成模型:

# Fit the meta learner on all but the last 1000 test samples
meta_learner.fit(preds[:-1000], test.rating[:-1000])

# Evaluate the base learners and the meta learner on the last
# 1000 test samples
print('Base Learner 5 Features')
print(metrics.mean_squared_error(test.rating[-1000:], preds5[-1000:]))
print('Base Learner 10 Features')
print(metrics.mean_squared_error(test.rating[-1000:], preds10[-1000:]))
print('Base Learner 15 Features')
print(metrics.mean_squared_error(test.rating[-1000:], preds15[-1000:]))
print('Ensemble')
print(metrics.mean_squared_error(test.rating[-1000:], meta_learner.predict(preds[-1000:])))

结果如以下表所示。从中可以看出,集成模型能够在未见数据上超越单独的基础学习器,达到了比任何单一基础学习器更低的均方误差(MSE):

模型均方误差(MSE)
基础学习器 50.7609
基础学习器 100.7727
基础学习器 150.7639
集成模型0.7596

单独基础学习器和集成模型的结果

总结

本章中,我们简要介绍了推荐系统的概念以及协同过滤是如何工作的。然后,我们展示了如何利用神经网络来避免明确地定义规则,来决定用户对未评级项目的评分,使用嵌入层和点积。接着,我们展示了如何通过允许网络学习如何自行组合嵌入层,从而提高这些模型的性能。这使得模型拥有更高的自由度,而不会显著增加参数数量,从而显著提高了性能。最后,我们展示了如何利用相同的架构——具有不同数量嵌入特征——来创建堆叠集成的基学习器。为了组合这些基学习器,我们采用了贝叶斯岭回归,这比任何单一的基学习器都取得了更好的结果。

本章作为使用集成学习技术来构建深度推荐系统的概述,而非完全详细的指南。其实还有很多其他选项可以显著提高系统的性能。例如,使用用户描述(而非索引)、每部电影的附加信息(如类型)以及不同的架构,都能大大提升性能。不过,所有这些概念都可以通过使用集成学习技术来获益,本章已充分展示了这一点。

在接下来的最后一章中,我们将使用集成学习技术来对《世界幸福报告》中的数据进行聚类,以期发现数据中的模式。

第十三章:聚类世界幸福

在本书的最后一章,我们将利用集成聚类分析来探索全球幸福感的关系。为此,我们将使用 OpenEnsembles 库。首先,我们将展示数据及其目的。然后,我们将构建我们的集成模型。最后,我们将尝试深入了解数据中的结构和关系。

以下是本章将涵盖的主题:

  • 理解《世界幸福报告》

  • 创建集成模型

  • 获得洞察

技术要求

你需要具备基本的机器学习技术和算法知识。此外,还需要了解 Python 语言的约定和语法。最后,熟悉 NumPy 库将大大有助于读者理解一些自定义算法实现。

本章的代码文件可以在 GitHub 上找到:

github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter13

查看以下视频,了解代码的实际应用:bit.ly/2ShFsUm.

理解《世界幸福报告》

《世界幸福报告》是对各个国家幸福状况的调查。该报告源自联合国关于全球福祉和幸福感的会议。调查通过使用来自盖洛普世界调查的数据生成幸福排名,受访者会评估他们的整体生活质量(包含评价的变量是生活阶梯变量)。数据可以在世界幸福报告网站的下载部分找到(worldhappiness.report/ed/2019/)。除了生活阶梯之外,数据集还包含许多其他因素。我们将关注的因素如下:

  • 人均 GDP 对数

  • 社会支持

  • 出生时的健康预期寿命

  • 选择生活方式的自由

  • 慷慨

  • 腐败感知

  • 积极情绪(幸福、笑声和享受的平均值)

  • 负面情绪(担忧、悲伤和愤怒的平均值)

  • 对国家政府的信任

  • 民主质量(政府的民主程度)

  • 政府效能(政府的执行力)

我们可以通过在散点图上查看每个因素如何影响生活阶梯。下图展示了每个因素(x 轴)与生活阶梯(y 轴)之间的散点图:

各种因素与生活阶梯的散点图

如图所示,人均 GDP 对数出生时的健康预期寿命与生活阶梯的相关性最强且呈线性正相关。民主质量交付质量选择生活方式的自由积极情感社会支持也与生活阶梯呈正相关。消极情感腐败感知显示负相关,而对国家政府的信任则未显示出显著的相关性。通过检查每个因素与生活阶梯的皮尔逊相关系数 (r),我们可以确认我们的视觉发现:

因素相关系数 (r)
人均 GDP 对数0.779064
社会支持0.702461
出生时的健康预期寿命0.736797
选择生活方式的自由0.520988
慷慨0.197423
腐败感知-0.42075
积极情感0.543377
消极情感-0.27933
对国家政府的信任-0.09205
民主质量0.614572
交付质量0.70794

每个因素与生活阶梯的相关系数

多年来,共有 165 个国家参与了调查。根据地理位置,这些国家被分为 10 个不同的区域。最新报告中各区域的国家分布可以通过以下饼图看到。显然,撒哈拉以南非洲、西欧以及中东欧地区包含的国家最多。这并不意味着这些地区人口最多,而仅仅是表示这些地区的独立国家数量最多:

2018 年各区域国家分布

最后,观察生活阶梯在各年中的进展会非常有趣。下图展示了 2005 年到 2018 年生活阶梯的变化情况。我们注意到,2005 年是一个得分异常高的年份,而其他年份的得分大致相同。考虑到没有全球性事件能够解释这一异常,我们推测数据收集过程中的某些因素可能影响了这一结果:

不同年份的生活阶梯箱线图

事实上,如果我们检查每年调查的国家数量,就会发现 2005 年的国家数量相较于其他年份非常少。2005 年仅有 27 个国家,而 2006 年有 89 个国家。这个数字一直增加,直到 2011 年,才趋于稳定:

年份国家数量
200527
200689
2007102
2008110
2009114
2010124
2011146
2012142
2013137
2014145
2015143
2016142
2017147
2018136

每年调查的国家数量

如果我们只考虑最初的 27 个国家,箱线图显示了预期的结果。均值和偏差结果有一些波动;然而,平均而言,生活阶梯值围绕相同的数值分布。此外,如果我们将这些平均值与前一个箱线图的结果进行比较,我们会发现,平均来看,这 27 个国家比后来加入数据集的其他国家更幸福:

仅使用最初 2005 年数据集中包含的 27 个国家的箱线图

创建集成模型

为了创建集成模型,我们将使用我们在第八章中介绍的openensembles库,聚类。由于我们的数据集没有标签,我们无法使用同质性评分来评估我们的聚类模型。相反,我们将使用轮廓系数(silhouette score),它评估每个聚类的凝聚性以及不同聚类之间的分离度。首先,我们必须加载数据集,这些数据存储在WHR.csv文件中。第二个文件Regions.csv包含每个国家所属的区域。我们将使用 2017 年的数据,因为 2018 年的数据缺失较多(例如,交付质量民主质量完全缺失)。我们将使用数据集的中位数填充任何缺失的数据。对于我们的实验,我们将使用前面介绍的因素。为了便于引用,我们将它们存储在columns变量中。然后,我们继续生成 OpenEnsembles 的data对象:

import matplotlib.pyplot as plt
import numpy as np
import openensembles as oe 
import pandas as pd

from sklearn import metrics

# Load the datasets
data = pd.read_csv('WHR.csv')
regs = pd.read_csv('Regions.csv')

# DATA LOADING SECTION START #
# Use the 2017 data and fill any NaNs
recents = data[data.Year == 2017]
recents = recents.dropna(axis=1, how="all")
recents = recents.fillna(recents.median())

# Use only these specific features
columns = ['Log GDP per capita',
 'Social support', 'Healthy life expectancy at birth',
 'Freedom to make life choices', 'Generosity',
 'Perceptions of corruption','Positive affect', 'Negative affect',
 'Confidence in national government', 'Democratic Quality',
 'Delivery Quality']

# Create the data object
cluster_data = oe.data(recents[columns], columns)
# DATA LOADING SECTION END #

为了创建我们的 K-means 集成模型,我们将测试多个K值和多个集成大小。我们将测试K值为 2、4、6、8、10、12 和 14,集成大小为 5、10、20 和 50。为了合并各个基础聚类,我们将使用共现连接(co-occurrence linkage),因为这是第八章中三种算法中最稳定的一种,聚类。我们将把结果存储在结果字典中,便于后续处理。最后,我们将从结果字典创建一个 pandas DataFrame,并将其排列成一个二维数组,其中每一行对应某个K值,每一列对应某个集成大小:

np.random.seed(123456)
results = {'K':[], 'size':[], 'silhouette': []}
# Test different ensemble setups
Ks = [2, 4, 6, 8, 10, 12, 14]
sizes = [5, 10, 20, 50]
for K in Ks:
    for ensemble_size in sizes:
        ensemble = oe.cluster(cluster_data)
        for i in range(ensemble_size):
            name = f'kmeans_{ensemble_size}_{i}'
            ensemble.cluster('parent', 'kmeans', name, K)

        preds = ensemble.finish_co_occ_linkage(threshold=0.5)
        print(f'K: {K}, size {ensemble_size}:', end=' ')
        silhouette = metrics.silhouette_score(recents[columns], 
        preds.labels['co_occ_linkage'])
        print('%.2f' % silhouette)
        results['K'].append(K)
        results['size'].append(ensemble_size)
        results['silhouette'].append(silhouette)

results_df = pd.DataFrame(results)
cross = pd.crosstab(results_df.K, results_df['size'], 
results_df['silhouette'], aggfunc=lambda x: x)

结果如下面的表格所示。显而易见,随着K的增加,轮廓系数逐渐下降。此外,对于K值小于或等于六,似乎存在一定的稳定性。尽管如此,我们的数据未经任何预处理直接输入到聚类集成中。因此,距离度量可能会受到值较大的特征的支配:

SizeK5102050
20.6180.6180.6180.618
40.5330.5330.5330.533
60.4750.4750.4750.475
80.3960.3980.2640.243
100.3290.2480.2820.287
120.3530.3150.3270.350
140.3330.3090.3430.317

来自不同 K 值和集成大小实验的结果

为了排除某些特征主导其他特征的可能性,我们将通过使用归一化特征以及t-分布随机邻域嵌入t-SNE)变换后的特征重复实验。首先,我们将测试归一化特征。我们必须先减去均值,然后除以每个特征的标准差。使用标准的 pandas 函数可以轻松实现,如下所示:

# DATA LOADING SECTION START #

# Use the 2017 data and fill any NaNs
recents = data[data.Year == 2017]
recents = recents.dropna(axis=1, how="all")
recents = recents.fillna(recents.median())

# Use only these specific features
columns = ['Log GDP per capita',
 'Social support', 'Healthy life expectancy at birth',
 'Freedom to make life choices', 'Generosity',
 'Perceptions of corruption','Positive affect', 'Negative affect',
 'Confidence in national government', 'Democratic Quality',
 'Delivery Quality']

# Normalize the features by subtracting the mean
# and dividing by the standard deviation
normalized = recents[columns]
normalized = normalized - normalized.mean()
normalized = normalized / normalized.std()

# Create the data object
cluster_data = oe.data(recents[columns], columns)
# DATA LOADING SECTION END #

然后,我们测试相同的K值和集成大小。如下表所示,结果与原始实验非常相似:

SizeK5102050
20.6180.6180.6180.618
40.5330.5330.5330.533
60.4750.4750.4750.475
80.3930.3960.3440.264
100.3110.3550.3060.292
120.3460.3190.3500.350
140.3280.3270.3260.314

归一化数据的轮廓系数

最后,我们使用 t-SNE 作为预处理步骤重复实验。首先,我们通过from sklearn.manifold import t_sne导入 t-SNE。为了对数据进行预处理,我们调用TSNEfit_transform函数,以下代码片段展示了这一过程。需要注意的是,oe.data现在的列名为[0, 1],因为 t-SNE 默认只创建两个组件。因此,我们的数据将只有两列:

# DATA LOADING SECTION START #

# Use the 2017 data and fill any NaNs
recents = data[data.Year == 2017]
recents = recents.dropna(axis=1, how="all")
recents = recents.fillna(recents.median())

# Use only these specific features
columns = ['Log GDP per capita',
 'Social support', 'Healthy life expectancy at birth',
 'Freedom to make life choices', 'Generosity',
 'Perceptions of corruption','Positive affect', 'Negative affect',
 'Confidence in national government', 'Democratic Quality',
 'Delivery Quality']

# Transform the data with TSNE
tsne = t_sne.TSNE()
transformed = pd.DataFrame(tsne.fit_transform(recents[columns]))
# Create the data object
cluster_data = oe.data(transformed, [0, 1])

# DATA LOADING SECTION END #

结果如下表所示。我们可以看到,对于某些值,t-SNE 的表现优于其他两种方法。我们特别关注K值为 10 的情况,因为数据集中有 10 个区域。在下一部分中,我们将尝试使用K值为 10 和集成大小为 20 来获取数据的洞察:

SizeK5102050
20.5370.5370.5370.537
40.4660.4660.4660.466
60.4050.4050.4050.405
80.3430.3510.3510.351
100.3490.3480.3500.349
120.2820.2880.2910.288
140.2680.2730.2750.272

t-SNE 变换数据的轮廓系数

获取洞察

为了深入了解我们数据集的结构和关系,我们将使用 t-SNE 方法,集成大小为 20,基本的k-近邻k-NN)聚类器,K 值设为 10。首先,我们创建并训练聚类。然后,我们将聚类结果添加到 DataFrame 中作为额外的 pandas 列。接着,我们计算每个聚类的均值,并为每个特征绘制柱状图:

# DATA LOADING SECTION START #

# Use the 2017 data and fill any NaNs
recents = data[data.Year == 2017]
recents = recents.dropna(axis=1, how="all")
recents = recents.fillna(recents.median())

# Use only these specific features
columns = ['Log GDP per capita',
 'Social support', 'Healthy life expectancy at birth',
 'Freedom to make life choices', 'Generosity',
 'Perceptions of corruption','Positive affect', 'Negative affect',
 'Confidence in national government', 'Democratic Quality',
 'Delivery Quality']

# Transform the data with TSNE
tsne = t_sne.TSNE()
transformed = pd.DataFrame(tsne.fit_transform(recents[columns]))
# Create the data object
cluster_data = oe.data(transformed, [0, 1])

# DATA LOADING SECTION END #

# Create the ensemble
ensemble = oe.cluster(cluster_data)
for i in range(20):
    name = f'kmeans_{i}-tsne'
    ensemble.cluster('parent', 'kmeans', name, 10)

# Create the cluster labels
preds = ensemble.finish_co_occ_linkage(threshold=0.5)

# Add Life Ladder to columns
columns = ['Life Ladder', 'Log GDP per capita',
 'Social support', 'Healthy life expectancy at birth',
 'Freedom to make life choices', 'Generosity',
 'Perceptions of corruption','Positive affect', 'Negative affect',
 'Confidence in national government', 'Democratic Quality',
 'Delivery Quality']
# Add the cluster to the dataframe and group by the cluster
recents['Cluster'] = preds.labels['co_occ_linkage']
grouped = recents.groupby('Cluster')
# Get the means
means = grouped.mean()[columns]
# Create barplots
def create_bar(col, nc, nr, index):
    plt.subplot(nc, nr, index)    
    values = means.sort_values('Life Ladder')[col]
    mn = min(values) * 0.98
    mx = max(values) * 1.02
    values.plot(kind='bar', ylim=[mn, mx])
    plt.title(col[:18])

# Plot for each feature
plt.figure(1)
i = 1
for col in columns:
    create_bar(col, 4, 3, i)
    i += 1
plt.show()

条形图如下所示。聚类按照它们的平均生活阶梯值进行排序,以便于在各个特征之间做比较。如我们所见,聚类 3、2 和 4 的平均幸福感(生活阶梯)相当接近。同样,聚类 6、8、9、7 和 5 也有类似的情况。我们可以认为,聚类的集合只需要 5 个聚类,但通过仔细检查其他特征,我们发现情况并非如此:

每个特征的聚类均值条形图

通过观察健康预期寿命生活选择自由度,我们可以看到,聚类 3 和 4 明显优于聚类 2。事实上,如果我们检查其他特征,会发现聚类 3 和 4 在平均水平上要比聚类 2 更幸运。也许可以有趣地看到每个国家如何分布在各个聚类中。下表显示了聚类分配情况。实际上,我们看到聚类 2、3 和 4 涉及的国家,近期不得不克服我们特征中没有体现的困难。事实上,这些国家是世界上最常遭受战争摧残的地区之一。从社会学角度来看,极为有趣的是,尽管这些战火纷飞、困境重重的地区展现出极其消极的民主和治理特质,它们似乎仍然对政府保持着极高的信任:

N国家
1柬埔寨、埃及、印度尼西亚、利比亚、蒙古、尼泊尔、菲律宾和土库曼斯坦
2阿富汗、布基纳法索、喀麦隆、中非共和国、乍得、刚果(金)、几内亚、象牙海岸、莱索托、马里、莫桑比克、尼日尔、尼日利亚、塞拉利昂和南苏丹
3贝宁、冈比亚、加纳、海地、利比里亚、马拉维、毛里塔尼亚、纳米比亚、南非、坦桑尼亚、多哥、乌干达、也门、赞比亚和津巴布韦
4博茨瓦纳、刚果(布拉柴维尔)、埃塞俄比亚、加蓬、印度、伊拉克、肯尼亚、老挝、马达加斯加、缅甸、巴基斯坦、卢旺达和塞内加尔
5阿尔巴尼亚、阿根廷、巴林、智利、中国、克罗地亚、捷克共和国、爱沙尼亚、黑山、巴拿马、波兰、斯洛伐克、美国和乌拉圭
6阿尔及利亚、阿塞拜疆、白俄罗斯、巴西、多米尼加共和国、萨尔瓦多、伊朗、黎巴嫩、摩洛哥、巴勒斯坦地区、巴拉圭、沙特阿拉伯、土耳其和委内瑞拉
7保加利亚、匈牙利、科威特、拉脱维亚、立陶宛、毛里求斯、罗马尼亚、中国台湾省
8亚美尼亚、波斯尼亚和黑塞哥维那、哥伦比亚、厄瓜多尔、洪都拉斯、牙买加、约旦、马其顿、墨西哥、尼加拉瓜、秘鲁、塞尔维亚、斯里兰卡、泰国、突尼斯、阿联酋和越南
9孟加拉国、玻利维亚、格鲁吉亚、危地马拉、哈萨克斯坦、科索沃、吉尔吉斯斯坦、摩尔多瓦、俄罗斯、塔吉克斯坦、特立尼达和多巴哥、乌克兰和乌兹别克斯坦
10澳大利亚、奥地利、比利时、加拿大、哥斯达黎加、塞浦路斯、丹麦、芬兰、法国、德国、希腊、中国香港特别行政区、冰岛、爱尔兰、以色列、意大利、日本、卢森堡、马耳他、荷兰、新西兰、挪威、葡萄牙、新加坡、斯洛文尼亚、韩国、西班牙、瑞典、瑞士和英国

聚类分配

从聚类 1 开始,我们可以看到这些国家的人们幸福感明显优于之前的聚类。这可以归因于更高的预期寿命(战争较少)、更高的人均 GDP、社会支持、慷慨程度和对生活变动做出选择的自由。然而,这些国家的幸福感仍未达到最大化,主要是因为民主质量和交付质量存在问题。尽管如此,他们对政府的信任仅次于我们之前讨论的聚类组。聚类 6、8 和 9 的幸福感大体相当,它们的差异主要体现在人均 GDP、预期寿命、自由、慷慨和信任度上。我们可以看到,聚类 6 的经济和预期寿命相对较强,但自由度、慷慨程度以及政府效率似乎有所欠缺。聚类 8 和 9 的经济较弱,但似乎拥有更多的自由和运作更为高效的政府。此外,它们的慷慨程度,平均来说,超过了聚类 6。接下来是聚类 7 和 5,我们看到它们在幸福感方面也较为接近。这些国家的民主质量和交付质量较为积极,具备足够的自由、经济实力、社会支持和健康的预期寿命。这些国家是发达国家,人民普遍过着富裕的生活,不必担心因经济、政治或军事原因而死亡。这些国家的问题主要集中在对腐败的感知、人们对政府的信任以及政府的效率上。最后,聚类 10 包含了与世界其他地方相比几乎在各方面都更优秀的国家。这些国家的平均人均 GDP、预期寿命、慷慨程度和自由度都位居世界前列,同时对国家政府的信任度很高,腐败感知较低。如果有相符的文化背景,这些国家可以被视为理想的居住地。

总结

在这一章节中,我们介绍了《世界幸福报告》数据,提供了数据目的的描述,并描述了数据的属性。为了进一步深入理解数据,我们利用了集群分析,并结合了集成技术。我们使用了共现矩阵链接法来结合不同基础集群的集群分配。我们测试了不同的设置,包括不同的集成大小和邻居数量,以提供一个 k-NN 集成。在确定可以利用 t-SNE 分解,并且K值为 10 且基础集群数为 20 的情况下进行分析后,我们对集群分配进行了分析。我们发现报告相同幸福水平的国家实际上可能有不同的特征。这些最不幸福的国家通常是发展中国家,它们需要克服许多问题,既包括经济问题,也包括在某些情况下的战争问题。有趣的是,这些国家对政府最有信心,尽管这些政府被认为是功能失调的。属于中等幸福度集群的国家,要么有强大的经济但自由度较低,要么则反之。

经济强大、生活质量高的发达国家,尽管认为政府腐败,却未能获得最高的幸福得分。最后,唯一认为自己政府不腐败的国家,拥有最强的经济、民主与交付质量以及最长的预期寿命。这些国家大多属于欧盟或欧洲经济区,包括加拿大、澳大利亚、新西兰、日本、韩国、哥斯达黎加、以色列、香港和冰岛。

本书涵盖了大多数集成学习技术。在简短的机器学习回顾后,我们讨论了机器学习模型中出现的主要问题。这些问题是偏差和方差。集成学习技术通常试图通过生成方法和非生成方法来解决这些问题。我们讨论了非生成方法,如投票法和堆叠法,以及生成方法,如自助法、提升法和随机森林。此外,我们还介绍了可以用于创建聚类集成的方法,如多数投票法、图闭包法和共现链接法。最后,我们专门花了一些章节介绍了具体应用,以展示如何处理一些现实世界中的问题。如果这本书有需要强调的地方,那就是数据质量对模型性能的影响大于所使用的算法。因此,集成学习技术,如同任何机器学习技术一样,应当用于解决算法的弱点(即之前生成模型的弱点),而非数据质量差的问题。