Python-集成学习实用指南-全-
Python 集成学习实用指南(全)
原文:
annas-archive.org/md5/681e70f53a5cd12054ae0d01b7b855ea译者:飞龙
前言
集成学习是一种将两种或更多相似或不相似的机器学习算法结合起来,以创建具有更强预测能力的模型的技术。本书将展示如何使用多种弱算法来构建强预测模型。
本书通过动手实践的方法,您不仅能快速掌握基本理论,还能了解各种集成学习技术的应用。通过示例和实际数据集,您将能够构建更好的机器学习模型,解决分类和回归等监督学习问题。随着书本的进展,您将进一步利用集成学习技术,如聚类,来生成无监督的机器学习模型。在后续章节中,您将学习广泛应用于实际世界中的各种机器学习算法,用于做出预测和分类。您还将掌握如何使用 Python 库,如 scikit-learn 和 Keras,实现不同的集成模型。
本书结束时,您将熟练掌握集成学习,并具备理解哪种集成方法适用于哪种问题的能力,以便在实际场景中成功实施它们。
本书适合谁阅读
本书面向数据分析师、数据科学家、机器学习工程师以及其他希望使用集成技术生成高级模型的专业人士。
本书内容简介
第一章,机器学习复习,概述了机器学习的基本概念,包括训练集/测试集、性能评估、监督学习和无监督学习、机器学习算法以及基准数据集等内容。
第二章,集成学习入门,介绍了集成学习的概念,重点介绍了它解决的问题以及它带来的挑战。
第三章,投票法,介绍了最简单的集成学习技术——投票法,同时解释了硬投票和软投票之间的区别。您将学习如何实现自定义分类器,并使用 scikit-learn 的硬/软投票实现。
第四章,堆叠,介绍了元学习(堆叠),这是一种更高级的集成学习方法。阅读完本章后,您将能够在 Python 中实现堆叠分类器,并与 scikit-learn 分类器一起使用。
第五章,袋装法,介绍了自助重采样以及第一种生成性集成学习技术——袋装法。此外,本章还将指导您如何在 Python 中实现该技术,并使用 scikit-learn 的实现。
第六章,提升方法,涉及集成学习中的更高级主题。本章解释了流行的提升算法如何工作以及如何实现它们。此外,本章还介绍了 XGBoost,这是一种成功的分布式提升库。
第七章,随机森林,讲解了通过对数据集的实例和特征进行子抽样来创建随机决策树的过程。此外,本章还解释了如何利用随机树集成创建随机森林。最后,本章介绍了 scikit-learn 的实现及其使用方法。
第八章,聚类,介绍了如何使用集成方法进行无监督学习任务,例如聚类。此外,还介绍了 OpenEnsembles Python 库,并提供了使用该库的指导。
第九章,分类欺诈交易,展示了如何使用前面章节介绍的集成学习技术,对一个真实世界数据集进行分类应用。该数据集涉及信用卡欺诈交易。
第十章,预测比特币价格,介绍了如何使用前面章节中介绍的集成学习技术对一个真实世界数据集进行回归分析。该数据集涉及流行加密货币比特币的价格。
第十一章,评估 Twitter 情感,展示了如何使用真实世界数据集评估各种推文的情感。
第十二章,使用 Keras 推荐电影,介绍了如何利用神经网络集成方法创建推荐系统的过程。
第十三章,聚类世界幸福指数,介绍了如何使用集成学习方法对 2018 年世界幸福报告的数据进行聚类。
为了最大限度地发挥本书的作用
本书面向分析师、数据科学家、工程师以及对生成高级模型、描述并概括与其相关数据集的专业人士。假设读者具有基本的 Python 编程经验,并且熟悉基础的机器学习模型。此外,假设读者具备基本的统计学知识,尽管关键点和更高级的概念会简单介绍。熟悉 Python 的 scikit-learn 模块会非常有帮助,尽管不是严格要求。需要安装标准的 Python 环境。Anaconda 发行版(www.anaconda.com/distribution/)极大地简化了安装和管理各种 Python 包的任务,尽管它不是必须的。最后,一个好的集成开发环境(IDE)对于管理代码和调试非常有用。在我们的示例中,我们通常使用 Spyder IDE,您可以通过 Anaconda 轻松安装它。
下载示例代码文件
您可以从您的账户下载本书的示例代码文件,访问www.packt.com。如果您在其他地方购买了本书,可以访问www.packt.com/support,注册后将文件直接发送到您的邮箱。
您可以通过以下步骤下载代码文件:
-
登录或注册 www.packt.com。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并按照屏幕上的说明操作。
文件下载后,请确保使用以下最新版本的工具解压或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
macOS 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
本书的代码包也托管在 GitHub 上,网址是github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python。如果代码有更新,它会在现有的 GitHub 仓库中更新。
我们还有其他代码包,来自我们丰富的书籍和视频目录,您可以在github.com/PacktPublishing/找到。快来查看吧!
下载彩色图片
我们还提供了一个 PDF 文件,里面包含本书中使用的截图/图表的彩色图片。您可以在这里下载: static.packt-cdn.com/downloads/9781789612851_ColorImages.pdf。
代码实战
访问以下链接,查看代码运行的视频:bit.ly/2GfnRrv。
使用的约定
本书中使用了若干文本约定。
CodeInText:表示文本中的代码字、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号。例如:“将下载的WebStorm-10*.dmg磁盘映像文件挂载为您系统中的另一个磁盘。”
一块代码设置如下:
# --- SECTION 6 ---
# Accuracy of hard voting
print('-'*30)
print('Hard Voting:', accuracy_score(y_test, hard_predictions))
粗体:表示一个新术语、一个重要词汇或您在屏幕上看到的词汇。例如,菜单或对话框中的词汇会以这种方式显示在文本中。以下是一个示例:“因此,首选的方法是利用K 折交叉验证。”
警告或重要提示通常以这种形式出现。
提示和技巧通常以这种形式出现。
联系我们
我们总是欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提到书名,并通过customercare@packtpub.com联系我们。
勘误:尽管我们已尽一切努力确保内容的准确性,但难免会出现错误。如果您发现本书中的错误,我们将不胜感激您能向我们报告。请访问www.packt.com/submit-errata,选择您的书籍,点击“勘误提交表单”链接并输入详细信息。
盗版:如果您在互联网上发现任何我们作品的非法复制品,无论形式如何,我们将感激您提供相关地址或网站名称。请通过copyright@packt.com联系我们,并附上该材料的链接。
如果您有兴趣成为作者:如果您在某个领域拥有专业知识,并且有意撰写或参与编写书籍,请访问authors.packtpub.com。
评论
请留下评论。阅读并使用本书后,为什么不在您购买的站点上留下评论呢?潜在读者可以查看并利用您的公正意见做出购买决定,我们也可以了解您对我们产品的看法,我们的作者将看到您对其书籍的反馈。谢谢!
有关 Packt 的更多信息,请访问packt.com。
第一部分:简介与所需的软件工具
本节回顾了基本的机器学习概念,并介绍了集成学习。我们将概述机器学习及其相关概念,例如训练集和测试集、监督学习与非监督学习等。我们还将学习集成学习的概念。
本节包含以下章节:
-
第一章,机器学习基础回顾
-
第二章,开始使用集成学习
第一章:机器学习复习
机器学习是人工智能(AI)的一个子领域,致力于开发使计算机能够从海量数据中学习的算法和技术。随着数据产生速度的不断增加,机器学习在近年来在解决复杂问题中发挥了关键作用。这一成功是许多优秀机器学习库的资金支持和发展的主要推动力,这些库利用数据来构建预测模型。此外,企业也开始意识到机器学习的潜力,推动了对数据科学家和机器学习工程师的需求飙升,以设计性能更优的预测模型。
本章旨在复习主要概念和术语,并介绍将在本书中使用的框架,以便以扎实的基础接触集成学习。
本章覆盖的主要内容如下:
-
各种机器学习问题和数据集
-
如何评估预测模型的性能
-
机器学习算法
-
Python 环境设置及所需库
技术要求
你需要具备基本的机器学习技术和算法知识。此外,还需要了解 Python 语法和约定。最后,熟悉 NumPy 库将极大帮助读者理解一些自定义算法的实现。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter01
查看以下视频,观看代码实例:bit.ly/30u8sv8。
从数据中学习
数据是机器学习的原材料。处理数据可以生成信息;例如,测量一部分学生的身高(数据),并计算他们的平均值(处理),可以帮助我们了解整个学校的身高(信息)。如果我们进一步处理数据,例如,将男性和女性分组并计算每个组的平均值,我们将获得更多信息,因为我们将能够知道学校男性和女性的平均身高。机器学习旨在从任何给定的数据中提取尽可能多的信息。在这个例子中,我们生成了一个非常基础的预测模型。通过计算两个平均值,我们只需知道学生是男性还是女性,就能预测任何学生的平均身高。
机器学习算法处理的数据集合被称为问题的数据集。在我们的例子中,数据集包括身高测量(单位为厘米)和孩子的性别(男性/女性)。在机器学习中,输入变量称为特征,输出变量称为目标。在这个数据集中,我们预测模型的特征仅由学生的性别组成,而我们的目标是学生的身高(以厘米为单位)。从现在开始,除非另有说明,否则生成的预测模型将简单地称为模型。每个数据点称为一个实例。在这个问题中,每个学生都是数据集的一个实例。
当目标是一个连续变量(即数字)时,问题是回归问题,因为目标是根据特征进行回归。当目标是一个类别集时,问题是分类问题,因为我们试图将每个实例分配到一个类别或类中。
在分类问题中,目标类可以用一个数字表示;这并不意味着它是回归问题。判断是否为回归问题的最有用的方法是思考是否可以通过目标对实例进行排序。在我们的例子中,目标是身高,因此我们可以将学生按身高从高到低排序,因为 100 厘米小于 110 厘米。举个反例,如果目标是他们最喜欢的颜色,我们可以用数字表示每种颜色,但无法对其进行排序。即使我们把红色表示为一,蓝色表示为二,我们也不能说红色是“在前”或“比”蓝色小。因此,这个反例是一个分类问题。
流行的机器学习数据集
机器学习依赖于数据来生成高性能模型。没有数据,甚至无法创建模型。在本节中,我们将介绍一些流行的机器学习数据集,我们将在本书中使用这些数据集。
糖尿病
糖尿病数据集涉及 442 名糖尿病患者及其在基准测量后一年内病情的进展。数据集包括 10 个特征,包括患者的年龄、性别、体重指数(bmi)、平均血压(bp)以及六个血清测量值。数据集的目标是基准测量后一年内病情的进展。这是一个回归数据集,因为目标是一个数字。
在本书中,数据集的特征已经进行了均值中心化和缩放处理,使得每个特征的数据集平方和等于 1。下表展示了糖尿病数据集的一个样本:
| 年龄 | 性别 | bmi | 血压 | s1 | s2 | s3 | s4 | s5 | s6 | 目标 |
|---|---|---|---|---|---|---|---|---|---|---|
| 0.04 | 0.05 | 0.06 | 0.02 | -0.04 | -0.03 | -0.04 | 0.00 | 0.02 | -0.02 | 151 |
| 0.00 | -0.04 | -0.05 | -0.03 | -0.01 | -0.02 | 0.07 | -0.04 | -0.07 | -0.09 | 75 |
| 0.09 | 0.05 | 0.04 | -0.01 | -0.05 | -0.03 | -0.03 | 0.00 | 0.00 | -0.03 | 141 |
| -0.09 | -0.04 | -0.01 | -0.04 | 0.01 | 0.02 | -0.04 | 0.03 | 0.02 | -0.01 | 206 |
乳腺癌
乳腺癌数据集涉及 569 例恶性和良性肿瘤的活检。该数据集提供了从细针穿刺活检图像中提取的 30 个特征,这些特征描述了细胞核的形状、大小和纹理。此外,对于每个特征,还提供了三个不同的值:均值、标准误差以及最差或最大值。这确保了每个图像中的细胞群体得到充分描述。
数据集的目标是诊断,即肿瘤是恶性还是良性。因此,这是一个分类数据集。可用的特征如下所列:
-
平均半径
-
平均纹理
-
平均周长
-
平均面积
-
平均平滑度
-
平均紧密度
-
平均凹度
-
平均凹点
-
平均对称性
-
平均分形维度
-
半径误差
-
纹理误差
-
周长误差
-
面积误差
-
平滑度误差
-
紧密度误差
-
凹度误差
-
凹点误差
-
对称性误差
-
分形维度误差
-
最差半径
-
最差纹理
-
最差周长
-
最差面积
-
最差平滑度
-
最差紧密度
-
最差凹度
-
最差凹点
-
最差对称性
-
最差分形维度
手写数字
MNIST 手写数字数据集是最著名的图像识别数据集之一。它由 8 x 8 像素的方形图像组成,每个图像包含一个手写数字。因此,数据集的特征是一个 8 x 8 的矩阵,包含每个像素的灰度色值。目标包括 10 个类别,分别对应数字 0 到 9。这个数据集是一个分类数据集。下图是手写数字数据集中的一个样本:

手写数字数据集样本
有监督学习与无监督学习
机器学习可以分为多个子类别;其中两个大类是有监督学习和无监督学习。这些类别包含了许多流行且广泛应用的机器学习方法。在本节中,我们将介绍这些方法,并给出一些有监督学习和无监督学习的小例子。
有监督学习
在上一节的例子中,数据包含了一些特征和一个目标;无论目标是定量的(回归)还是分类的(分类)。在这种情况下,我们将数据集称为标注数据集。当我们尝试从标注数据集中生成一个模型,以便对看不见的或未来的数据做出预测(例如,诊断新的肿瘤病例)时,我们使用有监督学习。在简单的情况下,有监督学习模型可以被视为一条线。这条线的目的是根据目标(分类)将数据分开,或者紧密跟随数据(回归)。
下图展示了一个简单的回归示例。在这里,y是目标,x是数据集特征。我们的模型由简单的方程y=2x-5 组成。显然,直线紧密地跟随数据。为了估算一个新的、未见过的点的y值,我们使用上述公式计算其值。下图显示了一个使用y=2x-5 作为预测模型的简单回归:

使用预测模型 y=2x-5 的简单回归
在下图中,展示了一个简单的分类问题。在这里,数据集特征是x和y,目标是实例的颜色。再次,虚线是y=2x-5,但这次我们测试点是位于线的上方还是下方。如果该点的y值低于预期(较小),我们期望它是橙色的。如果它较高(较大),我们期望它是蓝色的。下图是使用y=2x-5 作为边界的简单分类:

使用 y=2x-5 作为边界的简单分类
无监督学习
在回归和分类中,我们清楚地理解数据的结构或行为。我们的目标只是对该结构或行为进行建模。在某些情况下,我们不知道数据的结构。在这些情况下,我们可以利用无监督学习来发现数据中的结构,从而获得信息。无监督学习的最简单形式是聚类。顾名思义,聚类技术尝试将数据实例分组(或聚类)。因此,属于同一聚类的实例在特征上有很多相似之处,而与属于不同聚类的实例则有很大差异。下图展示了一个具有三个聚类的简单例子。在这里,数据集特征是x和y,没有目标。
聚类算法发现了三个不同的组,分别以点(0, 0)、(1, 1)和(2, 2)为中心:

具有三个不同组的聚类
降维
另一种无监督学习的形式是降维。数据集中存在的特征数量等于数据集的维度。通常,许多特征可能是相关的、有噪声的,或者根本没有提供太多信息。然而,存储和处理数据的成本与数据集的维度是相关的。因此,通过减少数据集的维度,我们可以帮助算法更好地建模数据。
降维的另一个用途是高维数据集的可视化。例如,使用 t-分布随机邻居嵌入(t-SNE)算法,我们可以将乳腺癌数据集降至两个维度或组件。尽管可视化 30 个维度并不容易,但可视化两个维度却相当简单。
此外,我们还可以通过可视化测试数据集中包含的信息是否能够用来区分数据集的各个类别。下图展示了y轴和x轴上的两个组件,而颜色则代表实例的类别。尽管我们无法绘制所有维度,但通过绘制这两个组件,我们可以得出类之间存在一定可分性的结论:

使用 t-SNE 降维乳腺癌数据集
性能指标
机器学习是一个高度定量的领域。虽然我们可以通过绘制模型如何区分类别以及它如何紧密地跟随数据来衡量模型的性能,但为了评估模型的表现,还需要更多的定量性能指标。在本节中,我们将介绍代价函数和评估指标。它们都用于评估模型的表现。
代价函数
机器学习模型的目标是对我们的数据集进行建模。为了评估每个模型的表现,我们定义了目标函数。这些函数通常表示一个代价,或者说是模型距离完美的程度。这些代价函数通常使用损失函数来评估模型在每个单独数据实例上的表现。
以下几节描述了一些最常用的代价函数,假设数据集有n个实例,实例i的目标真实值为t[i],而模型的输出为y[i]。
平均绝对误差
平均绝对误差(MAE)或 L1 损失是目标真实值与模型输出之间的均绝对距离。它的计算公式如下:

均方误差
均方误差(MSE)或 L2 损失是目标真实值与模型输出之间的均方距离。它的计算公式如下:

交叉熵损失
交叉熵损失用于输出介于 0 和 1 之间的概率的模型,通常用来表示一个实例属于特定类别的概率。当输出概率偏离实际标签时,损失值会增加。在一个简单的例子中,假设数据集由两个类别组成,计算方法如下:

指标
代价函数在我们尝试通过数值优化模型时非常有用。但作为人类,我们需要一些既有用又直观的指标来理解和报告。因此,有许多可以提供模型性能洞察的指标。以下几节将介绍最常用的指标。
分类准确率
所有指标中最简单且最容易掌握的分类准确率,指的是正确预测的百分比。为了计算准确率,我们将正确预测的数量除以总实例数:

为了使准确率具有实质性的意义,数据集必须包含相等数量的属于每个类别的实例。如果数据集不平衡,准确率将受到影响。例如,如果一个数据集由 90%的 A 类和 10%的 B 类组成,那么一个将每个实例预测为 A 类的模型将有 90%的准确率,尽管它的预测能力为零。
混淆矩阵
为了解决前面的问题,可以使用混淆矩阵。混淆矩阵呈现正确或错误预测为每个可能类别的实例数量。在只有两个类别(“是”和“否”)的数据集中,混淆矩阵具有以下形式:
| n = 200 | 预测: 是 | 预测: 否 |
|---|---|---|
| 目标: 是 | 80 | 70 |
| 目标: 否 | 20 | 30 |
这里有四个单元格,每个对应以下之一:
-
真正例 (TP): 当目标属于“是”类别且模型预测为“是”
-
真负例 (TN): 当目标属于“否”类别且模型预测为“否”
-
假正例 (FP): 当目标属于“否”类别且模型预测为“是”
-
假负例 (FN): 当目标属于“是”类别且模型预测为“否”
混淆矩阵提供有关真实类别和预测类别平衡的信息。为了从混淆矩阵中计算准确率,我们将 TP 和 TN 的总和除以实例的总数:

灵敏度、特异性和曲线下面积
曲线下面积(AUC)关注二分类数据集,它描述了模型正确排名任何给定实例的概率。为了定义 AUC,我们首先必须定义灵敏度和特异性:
- 灵敏度 (真正例率): 灵敏度是指相对于所有正例,正确预测为正例的正例比例。其计算方法如下:

- 特异性 (假正例率): 特异性是指相对于所有负例,错误预测为正例的负例比例。其计算方法如下:

通过在特定的间隔(例如,每 0.05 增加一次)计算(1-特异性)和灵敏度,我们可以观察模型的表现。间隔与模型对每个实例的输出概率相关;例如,首先我们计算所有估计属于“是”类别的概率小于 0.05 的实例。然后,重新计算所有估计概率小于 0.1 的实例,以此类推。结果如下所示:

接收者操作特征曲线
直线表示正确或错误地对实例进行排名的概率相等:一个随机模型。橙色线(ROC 曲线)描绘了模型的概率。如果 ROC 曲线位于直线下方,意味着模型的表现比随机的、没有信息的模型差。
精确度、召回率和 F1 分数
精确度衡量模型的表现,通过量化正确分类为特定类别的实例的百分比,相对于所有预测为同一类别的实例。计算公式如下:

召回率也叫做敏感度。精确度和召回率的调和平均数称为 F1 分数,计算公式如下:

使用调和平均数而不是简单平均数的原因是,调和平均数受两者(精确度和召回率)之间不平衡的影响很大。因此,如果精确度或召回率显著小于另一个,F1 分数将反映这种不平衡。
评估模型
尽管有各种各样的指标来表明模型的表现,但仔细设置测试环境是非常重要的。最重要的事情之一是将数据集分成两部分。数据集的一部分将由算法用于生成模型;另一部分将用于评估模型。这通常被称为训练集和测试集。
训练集可以被算法用来生成和优化模型,使用任何代价函数。算法完成后,生成的模型将在测试集上进行测试,以评估其对未见数据的预测能力。虽然算法可能在训练集上生成表现良好的模型(样本内表现),但它可能无法泛化并在测试集上表现同样好(样本外表现)。这可以归因于许多因素——将在下一章中讨论。一些出现的问题可以通过使用集成方法来解决。然而,如果算法面对的是低质量的数据,几乎无法改进样本外的表现。
为了获得一个公正的估计,我们有时会将数据集的不同部分迭代地分成固定大小的训练集和测试集,比如 90%的训练集和 10%的测试集,直到对整个数据集进行了测试。这被称为 K 折交叉验证。在 90%与 10%的分割情况下,它被称为 10 折交叉验证,因为我们需要进行 10 次以便获得整个数据集的估计。
机器学习算法
有许多机器学习算法,适用于监督学习和无监督学习。在本书中,我们将介绍一些最受欢迎的算法,这些算法可以在集成方法中使用。本章将介绍每个算法背后的关键概念、基本算法以及实现这些算法的 Python 库。
Python 包
为了充分发挥任何编程语言的强大功能,库是必不可少的。它们提供了许多算法的便捷且经过测试的实现。在本书中,我们将使用 Python 3.6,并结合以下库:NumPy,因其出色的数值运算符和矩阵实现;Pandas,因其便捷的数据操作方法;Matplotlib,用于可视化我们的数据;scikit-learn,因其出色的各种机器学习算法实现;Keras,用于构建神经网络,利用其 Pythonic、直观的接口。Keras 是其他框架的接口,如 TensorFlow、PyTorch 和 Theano。本书中使用的每个库的具体版本如下:
-
numpy==1.15.1
-
pandas==0.23.4
-
scikit-learn==0.19.1
-
matplotlib==2.2.2
-
Keras==2.2.4
监督学习算法
最常见的机器学习算法类别是监督学习算法。这类算法涉及数据具有已知结构的问题。这意味着每个数据点都有一个与之相关的特定值,我们希望对其进行建模或预测。
回归
回归是最简单的机器学习算法之一。普通最小二乘法(OLS)回归形式为 y=ax+b,旨在优化 a 和 b 参数,以便拟合数据。它使用均方误差(MSE)作为其代价函数。顾名思义,它能够解决回归问题。
我们可以使用 scikit-learn 实现的 OLS 来尝试建模糖尿病数据集(该数据集随库一起提供):
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.datasets import load_diabetes
from sklearn.linear_model import LinearRegression
from sklearn import metrics
diabetes = load_diabetes()
第一部分处理导入库和加载数据。我们使用 linear_model 包中的 LinearRegression 实现:
# --- SECTION 2 ---
# Split the data into train and test set
train_x, train_y = diabetes.data[:400], diabetes.target[:400]
test_x, test_y = diabetes.data[400:], diabetes.target[400:]
第二部分将数据划分为训练集和测试集。在这个示例中,我们使用前 400 个实例作为训练集,另外 42 个作为测试集:
# --- SECTION 3 ---
# Instantiate, train and evaluate the model
ols = LinearRegression()
ols.fit(train_x, train_y)
err = metrics.mean_squared_error(test_y, ols.predict(test_x))
r2 = metrics.r2_score(test_y, ols.predict(test_x))
接下来的部分通过 ols = LinearRegression() 创建一个线性回归对象。然后,它通过使用 ols.fit(train_x, train_y) 在训练实例上优化参数,或者说拟合模型。最后,通过使用 metrics 包,我们使用第四部分中的测试数据计算模型的 MSE 和 R²:
# --- SECTION 4 ---
# Print the model
print('---OLS on diabetes dataset.---')
print('Coefficients:')
print('Intercept (b): %.2f'%ols.intercept_)
for i in range(len(diabetes.feature_names)):
print(diabetes.feature_names[i]+': %.2f'%ols.coef_[i])
print('-'*30)
print('R-squared: %.2f'%r2, ' MSE: %.2f \n'%err)
代码的输出如下:
---OLS on diabetes dataset.---
Coefficients:
Intercept (b): 152.73
age: 5.03
sex: -238.41
bmi: 521.63
bp: 299.94
s1: -752.12
s2: 445.15
s3: 83.51
s4: 185.58
s5: 706.47
s6: 88.68
------------------------------
R-squared: 0.70 MSE: 1668.75
另一种回归形式,逻辑回归,试图建模一个实例属于两个类之一的概率。同样,它试图优化 a 和 b 参数,以便建模 p=1/(1+e^(-(ax+b)))。同样,使用 scikit-learn 和乳腺癌数据集,我们可以创建并评估一个简单的逻辑回归。以下代码部分与前面的类似,不过这次我们将使用分类准确率和混淆矩阵,而不是 R² 作为度量:
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.linear_model import LogisticRegression
from sklearn.datasets import load_breast_cancer
from sklearn import metrics
bc = load_breast_cancer()
# --- SECTION 2 ---
# Split the data into train and test set
train_x, train_y = bc.data[:400], bc.target[:400]
test_x, test_y = bc.data[400:], bc.target[400:]
# --- SECTION 3 ---
# Instantiate, train and evaluate the model
logit = LogisticRegression()
logit.fit(train_x, train_y)
acc = metrics.accuracy_score(test_y, logit.predict(test_x))
# --- SECTION 4 ---
# Print the model
print('---Logistic Regression on breast cancer dataset.---')
print('Coefficients:')
print('Intercept (b): %.2f'%logit.intercept_)
for i in range(len(bc.feature_names)):
print(bc.feature_names[i]+': %.2f'%logit.coef_[0][i])
print('-'*30)
print('Accuracy: %.2f \n'%acc)
print(metrics.confusion_matrix(test_y, logit.predict(test_x)))
该模型实现的测试分类准确率为 95%,表现相当不错。此外,下面的混淆矩阵表明该模型并没有试图利用类别不平衡的问题。在本书的后续章节中,我们将学习如何通过集成方法进一步提高分类准确率。以下表格展示了逻辑回归模型的混淆矩阵:
| n = 169 | 预测:恶性 | 预测:良性 |
|---|---|---|
| 目标:恶性 | 38 | 1 |
| 目标:良性 | 8 | 122 |
支持向量机
支持向量机(SVM)使用训练数据的一个子集,特别是每个类别边缘附近的数据点,用以定义一个分隔超平面(在二维中为一条直线)。这些边缘数据点称为支持向量。SVM 的目标是找到一个最大化支持向量之间间距(距离)的超平面(如下图所示)。为了分类非线性可分的类别,SVM 使用核技巧将数据映射到更高维的空间,在该空间中数据可以变得线性可分:

SVM 边界和支持向量
如果你想了解更多关于核技巧的内容,这是一个很好的起点:en.wikipedia.org/wiki/Kernel_method#Mathematics:_the_kernel_trick。
在 scikit-learn 中,SVM 被实现为 sklearn.svm,支持回归(使用 sklearn.svm.SVR)和分类(使用 sklearn.svm.SVC)。我们将再次使用 scikit-learn 测试算法的潜力,并使用回归示例中的代码。使用带有线性核的 SVM 对乳腺癌数据集进行测试,结果为 95% 的准确率,混淆矩阵如下:
| n = 169 | 预测:恶性 | 预测:良性 |
|---|---|---|
| 目标:恶性 | 39 | 0 |
| 目标:良性 | 9 | 121 |
在糖尿病数据集上,通过在 (svr = SVR(kernel='linear', C=1e3)) 对象实例化过程中将 C 参数调整为 1000,我们能够实现一个 R2 值为 0.71 和 MSE 值为 1622.36,略微优于逻辑回归模型。
神经网络
神经网络,灵感来自于生物大脑的连接方式,由许多神经元或计算模块组成,这些模块按层组织。数据从输入层提供,预测结果由输出层产生。所有中间层称为隐藏层。属于同一层的神经元之间没有直接连接,只与属于其他层的神经元连接。每个神经元可以有多个输入,每个输入都与特定权重相乘,乘积的和被传递到激活函数,激活函数决定神经元的输出。常见的激活函数包括以下几种:
| Sigmoid | Tanh | ReLU | 线性 |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
网络的目标是优化每个神经元的权重,以使成本函数最小化。神经网络可以用于回归问题,其中输出层由一个神经元组成,或者用于分类问题,其中输出层由多个神经元组成,通常与类别数相等。神经网络有多种优化算法或优化器可供选择,最常见的是随机梯度下降(SGD)。其基本思想是,根据误差梯度的方向和大小(即一阶导数),乘以一个称为学习率的因子来更新权重。
已提出了多种变体和扩展,考虑了二阶导数,调整了学习率,或利用前一个权重变化的动量来更新权重。
尽管神经网络的概念已经存在很长时间,但随着深度学习的出现,它们的受欢迎程度最近大大增加。现代架构由卷积层组成,每个层的权重是矩阵,输出通过将权重矩阵滑动到输入上来计算。另一种类型的层是最大池化层,它通过将固定大小的窗口滑动到输入上来计算输出,输出为最大输入元素。递归层则保留了关于前一个状态的信息。
最后,全连接层是传统神经元,如前所述。
Scikit-learn 实现了传统的神经网络,位于 sklearn.neural_network 包下。再次使用之前的示例,我们将尝试对糖尿病和乳腺癌数据集进行建模。在糖尿病数据集中,我们将使用 MLPRegressor,并选择随机梯度下降(SGD)作为优化器,代码为 mlpr = MLPRegressor(solver='sgd')。在没有进一步微调的情况下,我们达到了 0.64 的 R² 和 1977 的均方误差(MSE)。在乳腺癌数据集上,我们使用有限记忆 Broyden–Fletcher–Goldfarb–Shanno(LBFGS)优化器,代码为 mlpc = MLPClassifier(solver='lbfgs'),我们得到了 93% 的分类准确率,并得到了一个合格的混淆矩阵。下表展示了乳腺癌数据集的神经网络混淆矩阵:
| n = 169 | 预测:恶性 | 预测:良性 |
|---|---|---|
| 目标:恶性 | 35 | 4 |
| 目标:良性 | 8 | 122 |
关于神经网络的一个非常重要的说明:网络的初始权重是随机初始化的。因此,如果多次执行相同的代码,结果可能会不同。为了确保非随机(非随机性)执行,必须固定网络的初始随机状态。两个 scikit-learn 类通过对象构造器中的 random_state 参数实现了这一功能。为了将随机状态设置为特定的种子值,构造器应按以下方式调用:mlpc = MLPClassifier(solver='lbfgs', random_state=12418)。
决策树
决策树相比其他机器学习算法,更不具有黑箱性质。它们可以轻松解释如何产生预测,这被称为可解释性。其主要概念是通过使用提供的特征分割训练集来生成规则。通过迭代地分割数据,形成了一棵树,因此它们的名字由此而来。我们考虑一个数据集,其中的实例是每个个体在决定度假地点时的选择。
数据集的特征包括个人的年龄和可用资金,而目标是他们偏好的度假地点,可能的选择为 夏令营、湖泊 或 巴哈马。以下是一个可能的决策树模型:

度假目的地问题的决策树模型
正如可以看到的,模型能够解释它是如何产生任何预测的。模型本身的构建方式是通过选择能够最大化信息量的特征和阈值。大致而言,这意味着模型会尝试通过迭代的方式分割数据集,从而最大程度地分离剩余的实例。
尽管决策树直观易懂,但它们也可能产生不合理的模型,极端情况下会生成过多的规则,最终每个规则组合都会指向一个单独的实例。为了避免此类模型,我们可以通过要求模型的深度不超过特定的最大值(即连续规则的最大数量),或者要求每个节点在进一步分裂之前,至少包含一定数量的实例来限制模型。
在 scikit-learn 中,决策树是通过 sklearn.tree 包实现的,包含 DecisionTreeClassifier 和 DecisionTreeRegressor。在我们的示例中,使用 DecisionTreeRegressor 和 dtr = DecisionTreeRegressor(max_depth=2),我们得到了 R² 为 0.52,均方误差(MSE)为 2655。在乳腺癌数据集上,使用 dtc = DecisionTreeClassifier(max_depth=2),我们得到了 89% 的准确率,并且得到了以下的混淆矩阵:
| n = 169 | 预测: 恶性 | 预测: 良性 |
|---|---|---|
| 目标: 恶性 | 37 | 2 |
| 目标: 良性 | 17 | 113 |
虽然这个算法迄今为止表现得不是最好,但我们可以清晰地看到每个个体是如何被分类的,通过将树导出为graphviz格式,使用export_graphviz(dtc, feature_names=bc.feature_names, class_names=bc.target_names, impurity=False):

为乳腺癌数据集生成的决策树
K-最近邻
k-最近邻(k-NN)是一个相对简单的机器学习算法。每个实例通过与其 K 个最近的样本进行比较来分类,采用多数类作为分类结果。在回归中,使用邻居的平均值。Scikit-learn 的实现位于库的sklearn.neighbors包中。根据库的命名规范,KNeighborsClassifier实现了分类版本,KNeighborsRegressor实现了回归版本。通过在我们的示例中使用它们,回归器生成的 R²为 0.58,均方误差(MSE)为 2342,而分类器达到了 93%的准确率。下表显示了乳腺癌数据集的 k-NN 混淆矩阵:
| n = 169 | 预测:恶性 | 预测:良性 |
|---|---|---|
| 目标:恶性 | 37 | 2 |
| 目标:良性 | 9 | 121 |
K-means
K-means 是一种聚类算法,与 k-NN 有相似之处。它生成多个聚类中心,并将每个实例分配给其最近的聚类。所有实例分配到聚类后,聚类的中心点变成新的中心,直到算法收敛到稳定的解。在 scikit-learn 中,该算法通过sklearn.cluster.KMeans实现。我们可以尝试对乳腺癌数据集的前两个特征进行聚类:平均半径和 FNA 成像的纹理。
首先,我们加载所需的数据和库,同时只保留数据集的前两个特征:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_breast_cancer
from sklearn.cluster import KMeans
bc = load_breast_cancer()
bc.data=bc.data[:,:2]
然后,我们将聚类拟合到数据上。注意,我们不需要将数据拆分为训练集和测试集:
# --- SECTION 2 ---
# Instantiate and train
km = KMeans(n_clusters=3)
km.fit(bc.data)
接下来,我们创建一个二维网格并对每个点进行聚类,以便绘制聚类区域和边界:
# --- SECTION 3 ---
# Create a point mesh to plot cluster areas
# Step size of the mesh.
h = .02
# Plot the decision boundary. For that, we will assign a color to each
x_min, x_max = bc.data[:, 0].min() - 1, bc.data[:, 0].max() + 1
y_min, y_max = bc.data[:, 1].min() - 1, bc.data[:, 1].max() + 1
# Create the actual mesh and cluster it
xx, yy = np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h))
Z = km.predict(np.c_[xx.ravel(), yy.ravel()])
# Put the result into a color plot
Z = Z.reshape(xx.shape)
plt.figure(1)
plt.clf()
plt.imshow(Z, interpolation='nearest',
extent=(xx.min(), xx.max(), yy.min(), yy.max()),
aspect='auto', origin='lower',)
最后,我们绘制实际数据,并将其颜色映射到相应的聚类:
--- SECTION 4 ---
# Plot the actual data
c = km.predict(bc.data)
r = c == 0
b = c == 1
g = c == 2
plt.scatter(bc.data[r, 0], bc.data[r, 1], label='cluster 1')
plt.scatter(bc.data[b, 0], bc.data[b, 1], label='cluster 2')
plt.scatter(bc.data[g, 0], bc.data[g, 1], label='cluster 3')
plt.title('K-means')
plt.xlim(x_min, x_max)
plt.ylim(y_min, y_max)
plt.xticks(())
plt.yticks(())
plt.xlabel(bc.feature_names[0])
plt.ylabel(bc.feature_names[1])
`()
plt.show()
结果是一个二维图像,显示了每个聚类的彩色边界,以及各个实例:

乳腺癌数据集前两个特征的 K-means 聚类
总结
在本章中,我们介绍了将在整本书中使用的基本数据集、算法和指标。我们讨论了回归和分类问题,其中数据集不仅包含特征,还包含目标。我们称这些为标注数据集。我们还讨论了无监督学习,包括聚类和降维。我们介绍了成本函数和模型指标,这些将用于评估我们生成的模型。此外,我们介绍了将在大多数示例中使用的基本学习算法和 Python 库。
在下一章中,我们将介绍偏差和方差的概念,以及集成学习的概念。以下是一些关键点:
-
当目标变量是一个连续数值且其值具有某种大小意义时,例如速度、成本、血压等,我们尝试解决回归问题。分类问题的目标可能会以数字编码,但我们不能将其视为数值来处理。在问题编码时,尝试根据所分配的数字对颜色或食物进行排序是没有意义的。
-
成本函数是一种量化预测模型与完美建模数据之间差距的方法。指标提供的信息更容易让人理解和报告。
-
本章中介绍的所有算法在 scikit-learn 中都有分类和回归问题的实现。有些算法更适合某些特定任务,至少在不调整其超参数的情况下,决策树生成的模型易于人类解释。
第二章:开始使用集成学习
集成学习涉及将多种技术结合在一起,允许多个机器学习模型(称为基本学习器或有时称为弱学习器)整合它们的预测,并在给定各自的输入和输出的情况下输出一个单一的、最优的预测。
本章将概述集成学习尝试解决的主要问题,即偏差和方差,以及它们之间的关系。这将帮助我们理解识别表现不佳的模型的根本原因,并使用集成学习来解决该问题的动机。此外,我们还将介绍可用方法的基本类别,以及在实施集成学习时可能遇到的困难。
本章涵盖的主要主题如下:
-
偏差、方差以及两者之间的权衡
-
使用集成学习的动机
-
识别表现不佳的模型的根本原因
-
集成学习方法
-
成功应用集成学习的难点
技术要求
你需要具备基本的机器学习技术和算法知识。此外,还需要了解 Python 语言的约定和语法。最后,熟悉 NumPy 库将大大有助于读者理解一些自定义算法的实现。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter02
请查看以下视频,查看代码实践:bit.ly/2JKkWYS。
偏差、方差及其权衡
机器学习模型并不完美;它们容易出现许多错误。最常见的两种错误来源是偏差和方差。尽管这两者是不同的问题,但它们是相互关联的,并且与模型的自由度或复杂性有关。
什么是偏差?
偏差是指方法无法正确估计目标。这不仅仅适用于机器学习。例如,在统计学中,如果我们想要测量一个人群的平均值,但没有仔细采样,那么估算出的平均值就会存在偏差。简单来说,方法(采样)估算的结果与实际目标(平均值)之间存在差距。
在机器学习中,偏差指的是预期预测与目标之间的差异。偏差模型无法正确拟合训练数据,导致在样本内和样本外的表现都很差。一个偏差模型的经典例子是当我们尝试用简单的线性回归来拟合一个正弦函数时。该模型无法拟合正弦函数,因为它缺乏所需的复杂度。因此,它无法在样本内或样本外表现良好。这个问题被称为欠拟合。下图提供了一个图形示例:

对正弦函数数据的有偏线性回归模型
偏差的数学公式是目标值与期望预测值之间的差异:

什么是方差?
方差指的是个体在一个群体中的差异程度。同样,方差是统计学中的一个概念。从一个群体中抽取样本时,方差表示每个个体的数值与平均值的偏差程度。
在机器学习中,方差指的是模型对数据变化的敏感性或变动性。这意味着,高方差模型通常能够很好地拟合训练数据,从而在训练集上取得较高的表现,但在测试集上表现较差。这是由于模型的复杂性。例如,如果决策树为训练数据集中的每一个实例都创建一条规则,那么该决策树可能会有较高的方差。这被称为过拟合。下图展示了在前述数据集上训练的决策树。蓝色点代表训练数据,橙色点代表测试数据。
如图所示,模型能够完美拟合训练数据,但在测试数据上表现较差:

高方差的决策树模型在正弦函数上的表现
方差的数学公式如下所示:

本质上,这就是人口方差的标准公式,假设我们的人口是由模型组成的,因为这些模型是由机器学习算法生成的。例如,正如我们在第一章《机器学习基础》中看到的,神经网络的训练结果可能不同,这取决于它们的初始权重。如果我们考虑所有具有相同架构但初始权重不同的神经网络,通过训练它们,我们将得到一组不同的模型。
权衡
偏差和方差是组成模型误差的三个主要组成部分中的两个。第三个是不可减少的误差,通常归因于数据中的固有随机性或变异性。模型的总误差可以分解如下:

正如我们之前所见,偏差和方差都源自同一个因素:模型复杂度。偏差源于模型复杂度过低和自由度不足,而方差则在复杂模型中表现得更为突出。因此,不可能在不增加方差的情况下减少偏差,反之亦然。然而,存在一个复杂度的最优点,在这个点上,偏差和方差达到了最优的权衡,误差最小。当模型的复杂度达到这个最优点(下图中的红色虚线)时,模型在训练集和测试集上的表现都是最佳的。正如下图所示,误差永远不可能被减少到零。
此外,尽管有些人可能认为减少偏差,即使以增加方差为代价会更好,但显然即便模型没有偏差,由于方差不可避免地引起的误差,模型也不会表现得更好:

偏差-方差权衡及其对误差的影响
下图展示了完美模型,具有最小的偏差和方差的结合,或者说是可减少的误差。尽管该模型并没有完全拟合数据,但这是由于数据集中的噪声。如果我们尝试更好地拟合训练数据,将会引起过拟合(方差)。如果我们进一步简化模型,将会引起欠拟合(偏差):

对于我们的数据,完美的模型是一个正弦函数
集成学习
集成学习涉及一组机器学习方法,旨在通过结合多个模型来提高算法的预测性能。我们将分析使用这些方法来解决高偏差和方差问题的动机。此外,我们还将介绍识别机器学习模型中偏差和方差的方法,以及集成学习方法的基本分类。
动机
集成学习旨在解决偏差和方差的问题。通过结合多个模型,我们可以减少集成的误差,同时保留各个独立模型的复杂性。如前所述,每个模型误差都有一定的下限,这与模型的复杂性有关。
此外,我们提到同一个算法由于初始条件、超参数和其他因素的不同,可能会产生不同的模型。通过结合不同且多样化的模型,我们可以减少群体的预期误差,同时每个独立的模型保持不变。这是由于统计学原理,而非纯粹的学习。
为了更好地展示这一点,假设我们有一个由 11 个基学习器组成的集成,用于分类,每个学习器的误分类(误差)概率为err=0.15 或 15%。现在,我们想要创建一个简单的集成。我们始终假设大多数基学习器的输出是正确的。假设它们是多样化的(在统计学中是无关的),那么大多数学习器出错的概率是 0.26%:

正如显而易见的,随着我们向集成中添加更多的基学习器,集成的准确度也会提高,前提是每个学习器之间彼此独立。当然,这一点越来越难以实现。此外,还存在递减收益法则。每增加一个不相关的基学习器,减少的整体误差会比之前添加的基学习器少。下图展示了多个不相关基学习器的集成误差百分比。显然,添加两个不相关基学习器时,误差的减少最大:

基学习器数量与集成误差之间的关系
识别偏差和方差
尽管偏差和方差有理论公式,但很难计算它们的实际值。估算它们的一个简单方法是使用学习曲线和验证曲线进行经验计算。
验证曲线
验证曲线指的是在不同超参数条件下,算法的实际表现。对于每个超参数值,我们执行 K 折交叉验证,并存储样本内表现和样本外表现。然后,我们计算并绘制每个超参数值的样本内和样本外表现的均值和标准差。通过检查相对和绝对表现,我们可以评估模型的偏差和方差水平。
借用来自第一章的KNeighborsClassifier示例,《机器学习复习》中,我们对其进行了修改,以便尝试不同的邻居数。我们首先加载所需的库和数据。请注意,我们从sklearn.model_selection导入了validation_curve,这是 scikit-learn 自带的验证曲线实现:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import validation_curve
from sklearn.neighbors import KNeighborsClassifier
bc = load_breast_cancer()
接下来,我们定义我们的特征和目标(x和y),以及我们的基学习器。此外,我们使用param_range = [2,3,4,5]定义参数搜索空间,并使用validation_curve。为了使用它,我们必须定义基学习器、特征、目标、我们希望测试的参数名称以及待测试的参数值。此外,我们还使用cv=10定义交叉验证的 K 折数,并设置我们希望计算的度量标准为scoring="accuracy":
# --- SECTION 2 ---
# Create in-sample and out-of-sample scores
x, y = bc.data, bc.target
learner = KNeighborsClassifier()
param_range = [2,3,4,5]
train_scores, test_scores = validation_curve(learner, x, y,
param_name='n_neighbors',
param_range=param_range,
cv=10,
scoring="accuracy")
然后,我们计算样本内表现(train_scores)和样本外表现(test_scores)的均值和标准差:
# --- SECTION 3 ---
# Calculate the average and standard deviation for each hyperparameter
train_scores_mean = np.mean(train_scores, axis=1)
train_scores_std = np.std(train_scores, axis=1)
test_scores_mean = np.mean(test_scores, axis=1)
test_scores_std = np.std(test_scores, axis=1)
最后,我们绘制均值和标准差。我们使用plt.plot绘制均值曲线。为了绘制标准差,我们创建一个透明矩形,包围这些曲线,矩形的宽度等于每个超参数值点的标准差。这是通过使用plt.fill_between实现的,方法是将值点作为第一个参数,最低矩形点作为第二个参数,最高点作为第三个参数。此外,alpha=0.1指示matplotlib使矩形透明(将矩形的颜色与背景按照 10%-90%的比例进行混合):
第 3 和第四部分改编自scikit-learn.org/stable/auto_examples/model_selection/plot_validation_curve.html中的 scikit-learn 示例。
# --- SECTION 4 ---
# Plot the scores
plt.figure()
plt.title('Validation curves')
# Plot the standard deviations
plt.fill_between(param_range, train_scores_mean - train_scores_std,
train_scores_mean + train_scores_std, alpha=0.1,
color="C1")
plt.fill_between(param_range, test_scores_mean - test_scores_std,
test_scores_mean + test_scores_std, alpha=0.1, color="C0")
# Plot the means
plt.plot(param_range, train_scores_mean, 'o-', color="C1",
label="Training score")
plt.plot(param_range, test_scores_mean, 'o-', color="C0",
label="Cross-validation score")
plt.xticks(param_range)
plt.xlabel('Number of neighbors')
plt.ylabel('Accuracy')
plt.legend(loc="best")
plt.show()
脚本最终输出以下结果。当曲线之间的距离缩小时,方差通常会减少。当它们远离期望的准确度时(考虑到不可减少的误差),偏差增加。
此外,相对标准差也是方差的一个指标:

K-近邻算法的验证曲线,邻居数量从 2 到 5
以下表格展示了基于验证曲线的偏差和方差识别:
| 大 | 小 | |
|---|---|---|
| 曲线之间的距离 | 高方差 | 低方差 |
| 与期望准确度的距离 | 高偏差 | 低偏差 |
| 相对矩形面积比 | 高方差 | 低方差 |
基于验证曲线的偏差和方差识别
学习曲线
另一种识别偏差和方差的方法是生成学习曲线。与验证曲线类似,我们通过交叉验证生成一系列的样本内和样本外性能统计数据。我们不再尝试不同的超参数值,而是利用不同量的训练数据。通过检查样本内和样本外性能的均值和标准差,我们可以了解模型中固有的偏差和方差。
Scikit-learn 在sklearn.model_selection模块中实现了学习曲线,名为learning_curve。我们将再次使用第一章中机器学习复习的KNeighborsClassifier示例。首先,我们导入所需的库并加载乳腺癌数据集:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_breast_cancer
from sklearn.neighbors import KNeighborsClassifier
from sklearn.model_selection import learning_curve
bc = load_breast_cancer()
接下来,我们定义每个交叉验证集将使用的训练实例数量为train_sizes = [50, 100, 150, 200, 250, 300],实例化基础学习器,并调用learning_curve。该函数返回一个包含训练集大小、样本内性能得分和样本外性能得分的元组。该函数接受基础学习器、数据集特征和目标,以及训练集大小作为参数,其中train_sizes=train_sizes,并且交叉验证的折数为cv=10:
# --- SECTION 2 ---
# Create in-sample and out-of-sample scores
x, y = bc.data, bc.target
learner = KNeighborsClassifier()
train_sizes = [50, 100, 150, 200, 250, 300]
train_sizes, train_scores, test_scores = learning_curve(learner, x, y, train_sizes=train_sizes, cv=10)
再次,我们计算样本内和样本外性能的均值和标准差:
# --- SECTION 3 ---
# Calculate the average and standard deviation for each hyperparameter
train_scores_mean = np.mean(train_scores, axis=1)
train_scores_std = np.std(train_scores, axis=1)
test_scores_mean = np.mean(test_scores, axis=1)
test_scores_std = np.std(test_scores, axis=1)
最后,我们像之前一样,将均值和标准差绘制为曲线和矩形:
# --- SECTION 4 ---
# Plot the scores
plt.figure()
plt.title('Learning curves')
# Plot the standard deviations
plt.fill_between(train_sizes, train_scores_mean - train_scores_std,
train_scores_mean + train_scores_std, alpha=0.1,
color="C1")
plt.fill_between(train_sizes, test_scores_mean - test_scores_std,
test_scores_mean + test_scores_std, alpha=0.1, color="C0")
# Plot the means
plt.plot(train_sizes, train_scores_mean, 'o-', color="C1",
label="Training score")
plt.plot(train_sizes, test_scores_mean, 'o-', color="C0",
label="Cross-validation score")
plt.xticks(train_sizes)
plt.xlabel('Size of training set (instances)')
plt.ylabel('Accuracy')
plt.legend(loc="best")
plt.show()
最终输出如下图所示。模型似乎在前 200 个训练样本中降低了其方差。之后,均值似乎开始发散,同时交叉验证得分的标准差增加,从而表明方差的增加。
请注意,尽管两条曲线在训练集(至少包含 150 个实例)的准确率都超过 90%,但这并不意味着低偏差。高度可分的数据集(良好的质量数据,噪声低)往往会产生这样的曲线——无论我们选择什么样的算法组合和超参数。此外,噪声数据集(例如,具有相同特征但目标不同的实例)将无法生成高准确率的模型——无论我们使用什么技术。
因此,偏差应通过将学习曲线和验证曲线与期望的准确率(考虑到数据集质量认为可以达到的准确率)进行比较来衡量,而不是通过其绝对值:

K 近邻的学习曲线,50 到 300 个训练实例
集成方法
集成方法分为两大类或分类法:生成方法和非生成方法。非生成方法侧重于组合一组预训练模型的预测。这些模型通常是独立训练的,集成算法决定如何组合它们的预测。基础分类器不受其存在于集成中的影响。
本书将涵盖两种主要的非生成方法:投票和堆叠。如其名所示(参见第三章,投票),投票指的是允许模型投票以产生单一答案的技术,类似于个人在国家选举中的投票方式。最受欢迎(投票最多)的答案被选为赢家。第四章,堆叠,则指的是利用一个模型(元学习器)来学习如何最好地组合基础学习器的预测。尽管堆叠涉及生成一个新模型,但它不影响基础学习器,因此它是一种非生成方法。
另一方面,生成方法能够生成并影响它们所使用的基本学习器。它们可以调整其学习算法或用于训练它们的数据集,以确保多样性和高模型性能。此外,一些算法可以在模型中引入随机性,从而进一步加强多样性。
本书中我们将介绍的主要生成方法包括袋装(bagging)、提升(boosting)和随机森林(random forests)。提升是一种主要针对偏差模型的技术,其基本思想是顺序生成模型,使得每个新模型都能解决前一个模型中的偏差。因此,通过迭代修正之前的错误,最终的集成模型偏差会显著降低。袋装旨在减少方差。袋装算法对训练数据集中的实例进行重采样,创建许多源自同一数据集的独立且多样化的数据集。随后,在每个采样的数据集上训练单独的模型,从而强制集成模型之间的多样性。最后,随机森林与袋装相似,都是对训练数据集进行重采样。不同的是,它采样的是特征,而不是实例,这样可以生成更多样化的树,因为与目标高度相关的特征可能在许多树中缺失。
集成学习中的难点
虽然集成学习可以显著提高机器学习模型的性能,但它也有成本。正确实现集成学习存在一些困难和缺点,接下来将讨论其中的一些困难和缺点。
弱数据或噪声数据
一个成功模型最重要的要素是数据集。如果数据中包含噪声或不完整信息,那么没有任何一种机器学习技术能够生成一个高性能的模型。
让我们通过一个简单的例子来说明这一点。假设我们研究的是汽车的种群(统计学意义上的),并收集了关于颜色、形状和制造商的数据。对于任一变量,生成非常准确的模型都是困难的,因为很多汽车颜色和形状相同,但制造商不同。以下表格展示了这个样本数据集。
任何模型能够做到的最好结果是达到 33%的分类准确率,因为对于任一给定的特征组合,都有三种可能的选择。向数据集添加更多特征可以显著提高模型的性能。而向集成中添加更多模型则无法提高性能:
| 颜色 | 形状 | 制造商 |
|---|---|---|
| 黑色 | 轿车 | 宝马 |
| 黑色 | 轿车 | 奥迪 |
| 黑色 | 轿车 | 阿尔法·罗密欧 |
| 蓝色 | 两厢车 | 福特 |
| 蓝色 | 两厢车 | 欧宝 |
| 蓝色 | 两厢车 | 菲亚特 |
汽车数据集
理解可解释性
通过使用大量模型,模型的可解释性大大降低。例如,单个决策树可以通过简单地跟随每个节点的决策,轻松地解释它是如何产生预测的。另一方面,很难解释为什么一个由 1000 棵树组成的集成预测了一个单一的值。此外,根据集成方法的不同,可能需要解释的不仅仅是预测过程本身。集成是如何以及为什么选择训练这些特定的模型的?为什么它没有选择训练其他模型?为什么它没有选择训练更多的模型?
当模型的结果需要向观众呈现时,尤其是面对技术水平不高的观众时,简单且易于解释的模型可能是更好的解决方案。
此外,当预测结果还需要包括概率(或置信度)时,一些集成方法(如提升法)往往会给出较差的概率估计:

单棵树与 1000 棵树的可解释性
计算成本
集成模型的另一个缺点是它们带来的计算成本。训练单个神经网络的计算成本很高。训练 1000 个神经网络则需要 1000 倍的计算资源。此外,一些方法本质上是顺序的。这意味着无法利用分布式计算的优势。相反,每个新的模型必须在前一个模型完成后才开始训练。这不仅增加了计算成本,还对模型开发过程带来了时间上的惩罚。
计算成本不仅会阻碍开发过程;当集成模型投入生产时,推理时间也会受到影响。如果集成由 1000 个模型组成,那么所有这些模型必须输入新的数据,生成预测,然后将这些预测结合起来,才能产生集成输出。在延迟敏感的环境中(如金融交易、实时系统等),通常期望亚毫秒的执行时间,因此几微秒的延迟增加可能会造成巨大的差异。
选择合适的模型
最后,组成集成模型的模型必须具备一定的特征。没有意义从多个相同的模型中创建集成。生成方法可以生成自己的模型,但所使用的算法以及其初始超参数通常由分析师选择。此外,模型的可实现多样性取决于多个因素,例如数据集的大小和质量,以及学习算法本身。
一个与数据生成过程类似行为的单一模型通常会在准确性和延迟方面优于任何集成模型。在我们的偏差-方差示例中,简单的正弦函数始终会优于任何集成模型,因为数据是从同一个函数生成的,只是添加了一些噪声。许多线性回归的集成可能能够逼近正弦函数,但它们总是需要更多时间来训练和执行。此外,它们将无法像正弦函数那样很好地泛化(预测样本外数据)。
概要
在本章中,我们介绍了偏差和方差的概念及其之间的权衡。这些对于理解模型可能在样本内或样本外表现不佳的原因至关重要。然后,我们介绍了集成学习的概念和动机,以及如何在模型中识别偏差和方差,以及集成学习方法的基本类别。我们介绍了使用 scikit-learn 和 matplotlib 测量和绘制偏差和方差的方法。最后,我们讨论了实施集成学习方法的困难和缺点。记住的一些关键点如下。
高偏差模型通常在样本内表现不佳。这也被称为欠拟合。这是由于模型的简单性(或缺乏复杂性)导致的。高方差模型通常在样本内表现良好,但在样本外泛化或表现良好较难,这被称为过拟合。这通常是由于模型的不必要复杂性引起的。偏差-方差权衡指的是随着模型复杂性的增加,其偏差减少,而方差增加的事实。集成学习旨在通过结合许多不同模型的预测来解决高偏差或方差问题。这些模型通常被称为基学习器。对于模型选择,验证曲线指示了模型在给定一组超参数的情况下在样本内和样本外的表现。学习曲线与验证曲线相同,但它们使用不同的训练集大小而不是一组超参数。训练曲线和测试曲线之间的显著距离表示高方差。测试曲线周围的大矩形区域也表示高方差。两条曲线与目标准确率之间的显著距离表示高偏差。生成方法可以控制其基学习器的生成和训练;非生成方法则不能。当数据质量差或模型相关时,集成学习对性能可能会产生微乎其微或负面影响。它可能会对模型的解释能力和所需的计算资源产生负面影响。
在下一章中,我们将介绍投票集成,以及如何将其用于回归和分类问题。
第二部分:非生成方法
在本节中,我们将介绍集成学习的最简单方法。
本节包括以下章节:
-
第三章,投票
-
第四章,堆叠
第三章:投票
所有集成学习方法中最直观的就是多数投票。它之所以直观,是因为其目标是输出基学习器预测中最流行(或得票最多)的结果。本章将介绍关于多数投票的基本理论及实际实现。通过本章学习后,你将能够做到以下几点:
-
理解多数投票
-
理解硬投票和软投票的区别,以及它们各自的优缺点
-
在 Python 中实现两种版本
-
使用投票技术来提高分类器在乳腺癌数据集上的表现
技术要求
你需要具备基本的机器学习技术和算法知识。此外,还要求了解 Python 语法规范,最后,熟悉 NumPy 库将大大帮助读者理解一些自定义算法的实现。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter03
查看以下视频以查看代码实例:bit.ly/2M52VY7。
硬投票和软投票
多数投票是最简单的集成学习技术,它允许将多个基学习器的预测结果结合起来。类似于选举的工作原理,算法假设每个基学习器是一个选民,每个类别是一个竞争者。算法根据投票情况来选举获胜的竞争者。结合多个预测的投票方法主要有两种:一种是硬投票,另一种是软投票。我们在这里介绍这两种方法。
硬投票
硬投票通过假设得票最多的类别为胜者来结合多个预测。在一个简单的两类三基学习者的情况下,如果某个目标类别至少有两个投票,它就成为集成模型的输出,如下图所示。实现一个硬投票分类器就像是统计每个目标类别的投票数一样简单:

使用两类和三个基学习者进行投票
例如,假设有三个不同的基学习者,他们在预测一个样本是否属于三个类别中的某一个,并给出相应的概率(表 1)。
在下表中,每个学习者预测实例属于某个类别的概率:
| 类别 A | 类别 B | 类别 C | |
|---|---|---|---|
| 学习者 1 | 0.5 | 0.3 | 0.2 |
| 学习者 2 | 0 | 0.48 | 0.52 |
| 学习者 3 | 0.4 | 0.3 | 0.3 |
分配的类别概率
在此示例中,类别 A 有两个投票,而类别 C 只有一个投票。根据硬投票,类别 A 将成为集成的预测结果。这是一种非常稳健的基学习者合并方法,尽管它没有考虑到某些类别可能仅因略微优于其他类别而被某个基学习者选择。
软投票
软投票考虑了预测类别的概率。为了合并预测,软投票计算每个类别的平均概率,并假设胜者是具有最高平均概率的类别。在三个基学习者和两个类别的简单情况下,我们必须考虑每个类别的预测概率,并在三个学习者中求其平均:

软投票:两个类别和三个基学习者
使用我们之前的例子,并通过对表 1中每一列的平均值求平均,我们可以扩展该表,添加一行用于显示平均概率。
以下表格显示了每个学习者对每个类别的预测概率,以及平均概率:
| 类别 A | 类别 B | 类别 C | |
|---|---|---|---|
| 学习者 1 | 0.5 | 0.3 | 0.2 |
| 学习者 2 | 0 | 0.48 | 0.52 |
| 学习者 3 | 0.4 | 0.3 | 0.3 |
| 平均 | 0.3 | 0.36 | 0.34 |
每个学习者对每个类别的预测概率,以及平均概率
如我们所见,类别 A 的平均概率为 0.3,类别 B 的平均概率为 0.36,类别 C 的平均概率为 0.34,因此类别 B 获胜。注意,类别 B 并不是由任何基学习者选作预测类别,但通过合并预测概率,类别 B 成为预测中最好的折中选择。
为了让软投票比硬投票更有效,基分类器必须提供关于样本属于特定类别的概率的良好估计。如果这些概率没有意义(例如,如果它们总是对于某一类别为 100%,而对于所有其他类别为 0%),那么软投票可能会比硬投票更糟糕。
关于投票的说明:正如 Kenneth Arrow 博士通过其不可能性定理所证明的那样,完美的投票系统是不可实现的。然而,某些类型的投票系统能够更好地反映一个群体的偏好。软投票更能反映个体学习者的偏好,因为它考虑的是评分(概率),而不是排名(预测类别)。
有关不可能性定理的更多内容,请参见《社会福利概念中的困难》Arrow, K.J., 1950。政治经济学杂志,58(4),第 328-346 页。
Python 实现
在 Python 中实现硬投票的最简单方法是使用scikit-learn来创建基学习者,训练它们以适应某些数据,并将它们的预测结果结合起来应用于测试数据。为此,我们将按照以下步骤进行:
-
加载数据并将其拆分为训练集和测试集
-
创建一些基础学习器
-
在训练数据上训练它们
-
为测试数据生成预测
-
使用硬投票合并预测结果
-
将各个学习器的预测结果与合并后的预测结果与实际的正确类别(ground truth)进行比较
尽管 scikit-learn 提供了投票的实现,通过创建自定义实现,我们可以更容易理解算法的工作原理。此外,这还将帮助我们更好地理解如何处理和分析基础学习器的输出。
自定义硬投票实现
为了实现自定义硬投票解决方案,我们将使用三个基础学习器:感知机(一个单神经元的神经网络)、支持向量机(SVM)和最近邻。它们分别包含在sklearn.linear_model、sklearn.svm和sklearn.neighbors包中。此外,我们将使用 NumPy 的argmax函数。此函数返回数组(或类数组数据结构)中最大值元素的索引。最后,accuracy_score将计算每个分类器在我们测试数据上的准确性:
# --- SECTION 1 ---
# Import the required libraries
from sklearn import datasets, linear_model, svm, neighbors
from sklearn.metrics import accuracy_score
from numpy import argmax
# Load the dataset
breast_cancer = datasets.load_breast_cancer()
x, y = breast_cancer.data, breast_cancer.target
然后,我们实例化我们的基础学习器。我们精心挑选了它们的超参数,以确保它们在多样性上有所体现,从而能够产生一个表现良好的集成模型。由于breast_cancer是一个分类数据集,我们使用SVC,即 SVM 的分类版本,以及KNeighborsClassifier和Perceptron。此外,我们将Perceptron的随机状态设置为 0,以确保示例的可复现性:
# --- SECTION 2 ---
# Instantiate the learners (classifiers)
learner_1 = neighbors.KNeighborsClassifier(n_neighbors=5)
learner_2 = linear_model.Perceptron(tol=1e-2, random_state=0)
learner_3 = svm.SVC(gamma=0.001)
我们将数据拆分为训练集和测试集,使用 100 个实例作为测试集,并在训练集上训练我们的基础学习器:
# --- SECTION 3 ---
# Split the train and test samples
test_samples = 100
x_train, y_train = x[:-test_samples], y[:-test_samples]
x_test, y_test = x[-test_samples:], y[-test_samples:]
# Fit learners with the train data
learner_1.fit(x_train, y_train)
learner_2.fit(x_train, y_train)
learner_3.fit(x_train, y_train)
通过将每个基础学习器的预测存储在predictions_1、predictions_2和predictions_3中,我们可以进一步分析并将它们合并成我们的集成模型。请注意,我们分别训练了每个分类器;此外,每个分类器都会独立地对测试数据进行预测。正如在第二章《集成学习入门》中提到的那样,这是非生成性集成方法的主要特点:
#--- SECTION 4 ---
# Each learner predicts the classes of the test data
predictions_1 = learner_1.predict(x_test)
predictions_2 = learner_2.predict(x_test)
predictions_3 = learner_3.predict(x_test)
根据预测结果,我们将每个基学习器对每个测试实例的预测结果进行合并。hard_predictions 列表将包含集成模型的预测结果(输出)。通过 for i in range(test_samples) 遍历每个测试样本,我们统计每个类别从三个基学习器收到的投票总数。由于数据集仅包含两个类别,我们需要一个包含两个元素的列表:counts = [0 for _ in range(2)]。在 # --- SECTION 3 --- 中,我们将每个基学习器的预测结果存储在一个数组中。该数组的每个元素包含实例预测类别的索引(在我们这里是 0 和 1)。因此,我们通过将 counts[predictions_1[i]] 中相应元素的值加 1 来统计基学习器的投票数。接着,argmax(counts) 会返回获得最多投票的元素(类别):
# --- SECTION 5 ---
# We combine the predictions with hard voting
hard_predictions = []
# For each predicted sample
for i in range(test_samples):
# Count the votes for each class
counts = [0 for _ in range(2)]
counts[predictions_1[i]] = counts[predictions_1[i]]+1
counts[predictions_2[i]] = counts[predictions_2[i]]+1
counts[predictions_3[i]] = counts[predictions_3[i]]+1
# Find the class with most votes
final = argmax(counts)
# Add the class to the final predictions
hard_predictions.append(final)
最后,我们通过 accuracy_score 计算每个基学习器以及集成模型的准确度,并将结果打印在屏幕上:
# --- SECTION 6 ---
# Accuracies of base learners
print('L1:', accuracy_score(y_test, predictions_1))
print('L2:', accuracy_score(y_test, predictions_2))
print('L3:', accuracy_score(y_test, predictions_3))
# Accuracy of hard voting
print('-'*30)
print('Hard Voting:', accuracy_score(y_test, hard_predictions))
最终输出如下:
L1: 0.94
L2: 0.93
L3: 0.88
------------------------------
Hard Voting: 0.95
使用 Python 分析我们的结果
最终的准确度比三种分类器中最好的分类器(k-最近邻 (k-NN) 分类器)高出 1%。我们可以通过可视化学习器的错误来分析集成模型为何以这种特定方式表现。
首先,我们 import matplotlib 并使用特定的 seaborn-paper 绘图风格,方法是 mpl.style.use('seaborn-paper'):
# --- SECTION 1 ---
# Import the required libraries
import matplotlib as mpl
import matplotlib.pyplot as plt
mpl.style.use('seaborn-paper')
然后,我们通过从预测结果中减去实际目标值来计算错误。因此,每次学习器预测为正类(1),而真实类别为负类(0)时,我们得到 -1;每次学习器预测为负类(0),而真实类别为正类(1)时,我们得到 1。如果预测正确,我们得到 0:
# --- SECTION 2 ---
# Calculate the errors
errors_1 = y_test-predictions_1
errors_2 = y_test-predictions_2
errors_3 = y_test-predictions_3
对于每个基学习器,我们绘制其预测错误的实例。我们的目标是绘制 x 和 y 列表的散点图。这些列表将包含实例编号(x 列表)和错误类型(y 列表)。通过 plt.scatter,我们可以使用上述列表来指定点的坐标,并且可以指定这些点的表现方式。这一点非常重要,因为我们可以同时可视化所有分类器的错误及其相互关系。
每个点的默认形状是圆形。通过指定 marker 参数,我们可以改变这个形状。此外,通过 s 参数,我们可以指定标记的大小。因此,第一个学习器(k-NN)将具有大小为 120 的圆形,第二个学习器(感知器)将具有大小为 60 的 x 形状,而第三个学习器(SVM)将具有大小为 20 的圆形。if not errors_*[i] == 0 的保护条件确保我们不会存储正确分类的实例:
# --- SECTION 3 ---
# Discard correct predictions and plot each learner's errors
x=[]
y=[]
for i in range(len(errors_1)):
if not errors_1[i] == 0:
x.append(i)
y.append(errors_1[i])
plt.scatter(x, y, s=120, label='Learner 1 Errors')
x=[]
y=[]
for i in range(len(errors_2)):
if not errors_2[i] == 0:
x.append(i)
y.append(errors_2[i])
plt.scatter(x, y, marker='x', s=60, label='Learner 2 Errors')
x=[]
y=[]
for i in range(len(errors_3)):
if not errors_3[i] == 0:
x.append(i)
y.append(errors_3[i])
plt.scatter(x, y, s=20, label='Learner 3 Errors')
最后,我们指定图表的标题和标签,并绘制图例:
plt.title('Learner errors')
plt.xlabel('Test sample')
plt.ylabel('Error')
plt.legend()
plt.show()
如下所示,有五个样本至少有两个学习器预测了错误的类别。这五个案例是 100 个样本中集成预测错误的 5 个,因为最投票的类别是错的,从而导致 95%的准确性。在所有其他情况下,三个学习器中有两个预测了正确的类别,因此集成模型预测了正确的类别,因为它是最投票的:

学习器在测试集上的错误
使用 scikit-learn
scikit-learn 库包含许多集成学习算法,包括投票。为了实现硬投票,我们将遵循与之前相同的程序,不过这次我们不再自己实现个别的拟合、预测和投票过程。而是使用提供的实现,这使得训练和测试变得快速而简单。
硬投票实现
与我们自定义实现类似,我们导入所需的库,划分训练和测试数据,并实例化我们的基础学习器。此外,我们从sklearn.ensemble包中导入 scikit-learn 的VotingClassifier投票实现,如下所示:
# --- SECTION 1 ---
# Import the required libraries
from sklearn import datasets, linear_model, svm, neighbors
from sklearn.ensemble import VotingClassifier
from sklearn.metrics import accuracy_score
# Load the dataset
breast_cancer = datasets.load_breast_cancer()
x, y = breast_cancer.data, breast_cancer.target
# Split the train and test samples
test_samples = 100
x_train, y_train = x[:-test_samples], y[:-test_samples]
x_test, y_test = x[-test_samples:], y[-test_samples:]
# --- SECTION 2 ---
# Instantiate the learners (classifiers)
learner_1 = neighbors.KNeighborsClassifier(n_neighbors=5)
learner_2 = linear_model.Perceptron(tol=1e-2, random_state=0)
learner_3 = svm.SVC(gamma=0.001)
在上述代码之后,我们实例化VotingClassifier类,传入一个包含基础分类器名称和对象的元组列表作为参数。请注意,如果将参数传递在列表外部,将会导致错误:
# --- SECTION 3 ---
# Instantiate the voting classifier
voting = VotingClassifier([('KNN', learner_1),
('Prc', learner_2),
('SVM', learner_3)])
现在,实例化了分类器后,我们可以像使用任何其他分类器一样使用它,而无需单独处理每个基础学习器。接下来的两部分执行了所有基础学习器的拟合和预测,以及为每个测试实例计算最投票的类别:
# --- SECTION 4 ---
# Fit classifier with the training data
voting.fit(x_train, y_train)
# --- SECTION 5 ---
# Predict the most voted class
hard_predictions = voting.predict(x_test)
最后,我们可以打印集成模型的准确性:
# --- SECTION 6 ---
# Accuracy of hard voting
print('-'*30)
print('Hard Voting:', accuracy_score(y_test, hard_predictions))
这与我们自定义实现相同:
------------------------------
Hard Voting: 0.95
请注意,VotingClassifier不会拟合作为参数传入的对象,而是会克隆它们并拟合克隆的对象。因此,如果你尝试打印每个基础学习器在测试集上的准确性,你将得到NotFittedError,因为你访问的对象实际上并没有被拟合。这是使用 scikit-learn 的实现而非自定义实现的唯一缺点。
软投票实现
Scikit-learn 的实现也支持软投票。唯一的要求是基础学习器必须实现predict_proba函数。在我们的示例中,Perceptron完全没有实现该函数,而SVC仅在传递probability=True参数时才会生成概率。考虑到这些限制,我们将Perceptron替换为sklearn.naive_bayes包中实现的朴素贝叶斯分类器。
要实际使用软投票,VotingClassifier对象必须使用voting='soft'参数进行初始化。除了这里提到的更改外,大部分代码保持不变。加载库和数据集,并按如下方式进行训练/测试集划分:
# --- SECTION 1 ---
# Import the required libraries
from sklearn import datasets, naive_bayes, svm, neighbors
from sklearn.ensemble import VotingClassifier
from sklearn.metrics import accuracy_score
# Load the dataset
breast_cancer = datasets.load_breast_cancer()
x, y = breast_cancer.data, breast_cancer.target
# Split the train and test samples
test_samples = 100
x_train, y_train = x[:-test_samples], y[:-test_samples]
x_test, y_test = x[-test_samples:], y[-test_samples:]
实例化基学习器和投票分类器。我们使用一个高斯朴素贝叶斯分类器,命名为GaussianNB。注意,我们使用probability=True,以便GaussianNB对象能够生成概率:
# --- SECTION 2 ---
# Instantiate the learners (classifiers)
learner_1 = neighbors.KNeighborsClassifier(n_neighbors=5)
learner_2 = naive_bayes.GaussianNB()
learner_3 = svm.SVC(gamma=0.001, probability=True)
# --- SECTION 3 ---
# Instantiate the voting classifier
voting = VotingClassifier([('KNN', learner_1),
('NB', learner_2),
('SVM', learner_3)],
voting='soft')
我们拟合VotingClassifier和单独的学习器。我们希望分析我们的结果,正如前面提到的,分类器不会拟合我们传入的对象,而是会克隆它们。因此,我们需要手动拟合我们的学习器,如下所示:
# --- SECTION 4 ---
# Fit classifier with the training data
voting.fit(x_train, y_train)
learner_1.fit(x_train, y_train)
learner_2.fit(x_train, y_train)
learner_3.fit(x_train, y_train)
我们使用投票集成和单独的学习器预测测试集的目标:
# --- SECTION 5 ---
# Predict the most probable class
hard_predictions = voting.predict(x_test)
# --- SECTION 6 ---
# Get the base learner predictions
predictions_1 = learner_1.predict(x_test)
predictions_2 = learner_2.predict(x_test)
predictions_3 = learner_3.predict(x_test)
最后,我们打印每个基学习器的准确率以及软投票集成的准确率:
# --- SECTION 7 ---
# Accuracies of base learners
print('L1:', accuracy_score(y_test, predictions_1))
print('L2:', accuracy_score(y_test, predictions_2))
print('L3:', accuracy_score(y_test, predictions_3))
# Accuracy of hard voting
print('-'*30)
print('Hard Voting:', accuracy_score(y_test, hard_predictions))
最终输出如下:
L1: 0.94
L2: 0.96
L3: 0.88
------------------------------
Hard Voting: 0.94
分析我们的结果
如图所示,软投票的准确率比最佳学习器低 2%,并与第二最佳学习器持平。我们希望像分析硬投票自定义实现的性能一样分析我们的结果。但由于软投票考虑了预测的类别概率,我们不能使用相同的方法。相反,我们将绘制每个基学习器预测的每个实例作为正类的概率,以及集成模型的平均概率。
再次,我们import matplotlib并设置绘图样式:
# --- SECTION 1 ---
# Import the required libraries
import matplotlib as mpl
import matplotlib.pyplot as plt
mpl.style.use('seaborn-paper')
我们通过errors = y_test-hard_predictions计算集成模型的误差,并使用predict_proba(x_test)函数获取每个基学习器的预测概率。所有基学习器都实现了这个函数,因为这是在软投票集成中使用它们的要求:
# --- SECTION 2 ---
# Get the wrongly predicted instances
# and the predicted probabilities for the whole test set
errors = y_test-hard_predictions
probabilities_1 = learner_1.predict_proba(x_test)
probabilities_2 = learner_2.predict_proba(x_test)
probabilities_3 = learner_3.predict_proba(x_test)
之后,对于每个错误分类的实例,我们存储该实例属于类 0 的预测概率。我们也对每个基学习器以及它们的平均值实现此功能。每个probabilities_*数组是一个二维数组,每行包含对应实例属于类 0 或类 1 的预测概率。因此,存储其中一个就足够了。如果数据集有N个类别,我们至少需要存储N-1 个概率,才能获得清晰的视图:
# --- SECTION 2 ---
# Store the predicted probability for
# each wrongly predicted instance, for each base learner
# as well as the average predicted probability
#
x=[]
y_1=[]
y_2=[]
y_3=[]
y_avg=[]
for i in range(len(errors)):
if not errors[i] == 0:
x.append(i)
y_1.append(probabilities_1[i][0])
y_2.append(probabilities_2[i][0])
y_3.append(probabilities_3[i][0])
y_avg.append((probabilities_1[i][0]+
probabilities_2[i][0]+probabilities_3[i][0])/3)
最后,我们使用plt.bar将概率绘制为不同宽度的条形图。这确保了任何重叠的条形图仍然可以被看到。第三个plt.bar参数决定了条形图的宽度。我们使用散点图标记平均概率为黑色“X”,并确保它绘制在任何条形图之上,使用zorder=10。最后,我们绘制一条在 0.5 概率处的阈值线,使用plt.plot(y, c='k', linestyle='--'),确保它为黑色虚线,c='k', linestyle='--'。如果平均概率高于该线,样本将被分类为正类,如下所示:
# --- SECTION 3 ---
# Plot the predicted probaiblity of each base learner as
# a bar and the average probability as an X
plt.bar(x, y_1, 3, label='KNN')
plt.bar(x, y_2, 2, label='NB')
plt.bar(x, y_3, 1, label='SVM')
plt.scatter(x, y_avg, marker='x', c='k', s=150,
label='Average Positive', zorder=10)
y = [0.5 for x in range(len(errors))]
plt.plot(y, c='k', linestyle='--')
plt.title('Positive Probability')
plt.xlabel('Test sample')
plt.ylabel('probability')
plt.legend()
plt.show()
上述代码输出如下:

测试集的预测和平均概率
如我们所见,只有两个样本具有极端的平均概率(样本 22 的 p = 0.98 和样本 67 的 p = 0.001)。其余四个样本的概率接近 50%。在这四个样本中,有三个样本 SVM 似乎给出了一个错误类别的极高概率,从而大大影响了平均概率。如果 SVM 没有对这些样本的概率进行如此高估,集成模型可能会比每个单独的学习器表现得更好。对于这两个极端情况,无法采取任何措施,因为所有三个学习器都一致地将其分类错误。我们可以尝试用另一个邻居数显著更多的 k-NN 替换 SVM。在这种情况下,(learner_3 = neighbors.KNeighborsClassifier(n_neighbors=50)),我们可以看到集成模型的准确率大幅提高。集成模型的准确率和错误如下:
L1: 0.94
L2: 0.96
L3: 0.95
------------------------------
Hard Voting: 0.97
看一下以下截图:

使用两个 k-NN 的测试集的预测值和平均概率
总结
在本章中,我们介绍了最基本的集成学习方法:投票法。虽然它相当简单,但它可以证明是有效的,并且是结合多个机器学习模型的一种简便方法。我们介绍了硬投票和软投票、硬投票的自定义实现,以及 scikit-learn 中硬投票和软投票的实现。最后,我们展示了通过使用matplotlib绘制每个基学习器的错误来分析集成模型性能的方法。以下是本章的关键点总结。
硬投票假设得票最多的类别是赢家。软投票假设具有最高平均概率的类别是赢家。软投票要求基学习器以较高的准确度预测每个实例的每个类别的概率。Scikit-learn 通过VotingClassifier类实现投票集成。一个元组数组,格式为[(learner_name, learner_object), …],被传递给VotingClassifier。VotingClassifier并不直接训练作为参数传递的对象,而是生成并训练一个副本。VotingClassifier的默认模式实现硬投票。要使用软投票,可以将voting='soft'参数传递给构造函数。软投票要求基学习器返回每个预测的概率。如果基学习器大幅高估或低估了概率,集成模型的预测能力将受到影响。
在下一章中,我们将讨论另一种非生成方法——堆叠法(Stacking),以及它如何应用于回归和分类问题。
第四章:堆叠
堆叠是我们将要研究的第二种集成学习技术。与投票一起,它属于非生成方法类别,因为它们都使用单独训练的分类器作为基础学习器。
在本章中,我们将介绍堆叠的主要思想、优缺点,以及如何选择基础学习器。此外,我们还将介绍如何使用 scikit-learn 实现回归和分类问题的堆叠过程。
本章涵盖的主要主题如下:
-
堆叠的方法论及使用元学习器组合预测
-
使用堆叠的动机
-
堆叠的优缺点
-
选择集成的基础学习器
-
实现堆叠回归和分类问题
技术要求
你需要具备基本的机器学习技术和算法知识。此外,还需要了解 Python 的约定和语法。最后,熟悉 NumPy 库将大大帮助读者理解一些自定义算法的实现。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter04
查看以下视频,查看代码的实际应用:bit.ly/2XJgyD2。
元学习
元学习是一个广泛的机器学习术语。它有多重含义,但通常指的是利用特定问题的元数据来解决该问题。它的应用范围从更高效地解决问题,到设计全新的学习算法。它是一个日益发展的研究领域,最近通过设计新颖的深度学习架构取得了令人瞩目的成果。
堆叠
堆叠是一种元学习形式。其主要思想是,我们使用基础学习器生成问题数据集的元数据,然后利用另一种学习器——元学习器,来处理这些元数据。基础学习器被视为 0 级学习器,而元学习器则被视为 1 级学习器。换句话说,元学习器堆叠在基础学习器之上,因此得名堆叠。
一种更直观的集成描述方式是通过投票类比。在投票中,我们结合多个基础学习器的预测,以提高它们的性能。在堆叠中,我们不是明确地定义组合规则,而是训练一个模型,学习如何最好地结合基础学习器的预测。元学习器的输入数据集由基础学习器的预测(元数据)组成,如下图所示:

堆叠集成数据流,从原始数据到基础学习器,生成元学习器的元数据
创建元数据
如前所述,我们需要元数据来训练和操作我们的集成模型。在操作阶段,我们仅需传递基本学习器的数据。另一方面,训练阶段稍微复杂一些。我们希望元学习器能够发现基本学习器之间的优缺点。尽管有人认为我们可以在训练集上训练基本学习器,对其进行预测,并使用这些预测来训练我们的元学习器,但这会引入方差。我们的元学习器将发现已经被基本学习器“看到”的数据的优缺点。由于我们希望生成具有良好预测(样本外)性能的模型,而不是描述性(样本内)能力,因此必须采用另一种方法。
另一种方法是将训练集分为基本学习器训练集和元学习器训练(验证)集。这样,我们仍然可以保留一个真实的测试集,用于衡量集成模型的表现。此方法的缺点是我们必须将部分实例分配给验证集。此外,验证集的大小和训练集的大小都会小于原始训练集的大小。因此,首选方法是使用K 折交叉验证。对于每个K,基本学习器将在K-1 个折上进行训练,并在第K个折上进行预测,生成最终训练元数据的 100/K百分比。通过将该过程重复K次,每次针对一个折,我们将为整个训练数据集生成元数据。该过程在以下图表中有所展示。最终结果是为整个数据集生成的元数据,其中元数据是基于样本外数据生成的(从基本学习器的角度来看,对于每个折):

使用五折交叉验证创建元数据
决定集成模型的组成
我们将堆叠描述为一种高级的投票形式。与投票(以及大多数集成学习技术)类似,堆叠依赖于基本学习器的多样性。如果基本学习器在问题的整个领域中表现相同,那么元学习器将很难显著提升它们的集体表现。此外,可能需要一个复杂的元学习器。如果基本学习器具有多样性,并且在问题的不同领域中表现出不同的性能特征,即使是一个简单的元学习器也能大大提升它们的集体表现。
选择基本学习器
通常,混合不同的学习算法是个好主意,以便捕捉特征之间以及特征与目标变量之间的线性和非线性关系。例如,考虑以下数据集,其中特征(x)与目标变量(y)之间既有线性关系也有非线性关系。显然,单一的线性回归或单一的非线性回归都无法完全建模数据。而使用线性和非线性回归的堆叠集成将大大超越这两种模型。即使不使用堆叠,通过手工制定一个简单的规则(例如“如果 x 在 [0, 30] 或 [60, 100] 区间内,使用线性模型,否则使用非线性模型”),我们也能大大超过这两个模型:

示例数据集中的 x=5 和 x 的平方的组合
选择元学习器
通常,元学习器应该是一个相对简单的机器学习算法,以避免过拟合。此外,还应采取额外的步骤来正则化元学习器。例如,如果使用决策树,则应限制树的最大深度。如果使用回归模型,应该首选正则化回归(如弹性网或岭回归)。如果需要更复杂的模型以提高集成的预测性能,可以使用多级堆叠,其中每个层级的模型数量和每个模型的复杂度会随着堆叠层级的增加而减少:

层级堆叠集成。每一层的模型比上一层更简单。
元学习器的另一个非常重要的特性是能够处理相关输入,特别是不能像朴素贝叶斯分类器那样对特征间的独立性做出假设。元学习器的输入(元数据)将高度相关。这是因为所有基学习器都被训练来预测相同的目标。因此,它们的预测将来自对相同函数的近似。尽管预测值会有所不同,但它们会非常接近。
Python 实现
尽管 scikit-learn 实现了本书中涵盖的大多数集成方法,但堆叠(stacking)并不包括在内。在这一部分,我们将为回归和分类问题实现自定义的堆叠解决方案。
回归的堆叠
在这里,我们将尝试为糖尿病回归数据集创建一个堆叠集成。该集成将包含一个 5 邻居的k-最近邻(k-NN)、一个最大深度限制为四的决策树,以及一个岭回归(最小二乘回归的正则化形式)。元学习器将是一个简单的普通最小二乘法(OLS)线性回归。
首先,我们需要导入所需的库和数据。Scikit-learn 提供了一个便捷的方法,可以使用sklearn.model_selection模块中的KFold类将数据拆分为 K 个子集。与之前的章节一样,我们使用前 400 个实例进行训练,剩余的实例用于测试:
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.datasets import load_diabetes
from sklearn.neighbors import KNeighborsRegressor
from sklearn.tree import DecisionTreeRegressor
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.model_selection import KFold
from sklearn import metrics
import numpy as np
diabetes = load_diabetes()
train_x, train_y = diabetes.data[:400], diabetes.target[:400]
test_x, test_y = diabetes.data[400:], diabetes.target[400:]
在以下代码中,我们实例化了基础学习器和元学习器。为了方便后续访问每个基础学习器,我们将每个基础学习器存储在一个名为base_learners的列表中:
# --- SECTION 2 ---
# Create the ensemble's base learners and meta-learner
# Append base learners to a list for ease of access
base_learners = []
knn = KNeighborsRegressor(n_neighbors=5)
base_learners.append(knn)
dtr = DecisionTreeRegressor(max_depth=4 , random_state=123456)
base_learners.append(dtr)
ridge = Ridge()
base_learners.append(ridge)
meta_learner = LinearRegression()
在实例化我们的学习器之后,我们需要为训练集创建元数据。我们通过首先创建一个KFold对象,指定分割数(K),即KFold(n_splits=5),然后调用KF.split(train_x)将训练集拆分成五个子集。这将返回一个生成器,用于获取这五个子集的训练集和测试集索引。对于每个拆分,我们使用train_indices(四个子集)对应的数据来训练我们的基础学习器,并为与test_indices对应的数据创建元数据。此外,我们将每个分类器的元数据存储在meta_data数组中,将相应的目标存储在meta_targets数组中。最后,我们转置meta_data,以获得一个(实例,特征)的形状:
# --- SECTION 3 ---
# Create the training metadata
# Create variables to store metadata and their targets
meta_data = np.zeros((len(base_learners), len(train_x)))
meta_targets = np.zeros(len(train_x))
# Create the cross-validation folds
KF = KFold(n_splits=5)
meta_index = 0
for train_indices, test_indices in KF.split(train_x):
# Train each learner on the K-1 folds
# and create metadata for the Kth fold
for i in range(len(base_learners)):
learner = base_learners[i]
learner.fit(train_x[train_indices], train_y[train_indices])
predictions = learner.predict(train_x[test_indices])
meta_data[i][meta_index:meta_index+len(test_indices)] = \
predictions
meta_targets[meta_index:meta_index+len(test_indices)] = \
train_y[test_indices]
meta_index += len(test_indices)
# Transpose the metadata to be fed into the meta-learner
meta_data = meta_data.transpose()
对于测试集,我们不需要将其拆分成多个子集。我们仅需在整个训练集上训练基础学习器,并在测试集上进行预测。此外,我们会评估每个基础学习器并存储评估指标,以便与集成模型的表现进行比较。由于这是一个回归问题,我们使用 R 平方和均方误差(MSE)作为评估指标:
# --- SECTION 4 ---
# Create the metadata for the test set and evaluate the base learners
test_meta_data = np.zeros((len(base_learners), len(test_x)))
base_errors = []
base_r2 = []
for i in range(len(base_learners)):
learner = base_learners[i]
learner.fit(train_x, train_y)
predictions = learner.predict(test_x)
test_meta_data[i] = predictions
err = metrics.mean_squared_error(test_y, predictions)
r2 = metrics.r2_score(test_y, predictions)
base_errors.append(err)
base_r2.append(r2)
test_meta_data = test_meta_data.transpose()
现在,既然我们已经获得了训练集和测试集的元数据,我们就可以在训练集上训练我们的元学习器,并在测试集上进行评估:
# --- SECTION 5 ---
# Fit the meta-learner on the train set and evaluate it on the test set
meta_learner.fit(meta_data, meta_targets)
ensemble_predictions = meta_learner.predict(test_meta_data)
err = metrics.mean_squared_error(test_y, ensemble_predictions)
r2 = metrics.r2_score(test_y, ensemble_predictions)
# --- SECTION 6 ---
# Print the results
print('ERROR R2 Name')
print('-'*20)
for i in range(len(base_learners)):
learner = base_learners[i]
print(f'{base_errors[i]:.1f} {base_r2[i]:.2f} {learner.__class__.__name__}')
print(f'{err:.1f} {r2:.2f} Ensemble')
我们得到如下输出:
ERROR R2 Name
--------------------
2697.8 0.51 KNeighborsRegressor
3142.5 0.43 DecisionTreeRegressor
2564.8 0.54 Ridge
2066.6 0.63 Ensemble
如图所示,R 平方从最佳基础学习器(岭回归)提高了超过 16%,而 MSE 几乎提高了 20%。这是一个相当可观的改进。
分类任务的堆叠方法
堆叠方法既适用于回归问题,也适用于分类问题。在这一节中,我们将使用堆叠方法对乳腺癌数据集进行分类。我们依然会使用三个基础学习器:一个 5 邻居的 k-NN,一个最大深度为 4 的决策树,和一个带有 1 个隐藏层、100 个神经元的简单神经网络。对于元学习器,我们使用一个简单的逻辑回归模型。
再次加载所需的库,并将数据拆分为训练集和测试集:
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.datasets import load_breast_cancer
from sklearn.neighbors import KNeighborsClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.neural_network import MLPClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import KFold
from sklearn import metrics
import numpy as np
bc = load_breast_cancer()
train_x, train_y = bc.data[:400], bc.target[:400]
test_x, test_y = bc.data[400:], bc.target[400:]
我们实例化了基础学习器和元学习器。请注意,MLPClassifier具有一个hidden_layer_sizes =(100,)参数,用来指定每个隐藏层的神经元数量。这里,我们只有一个隐藏层,包含 100 个神经元:
# --- SECTION 2 ---
# Create the ensemble's base learners and meta-learner
# Append base learners to a list for ease of access
base_learners = []
knn = KNeighborsClassifier(n_neighbors=2)
base_learners.append(knn)
dtr = DecisionTreeClassifier(max_depth=4, random_state=123456)
base_learners.append(dtr)
mlpc = MLPClassifier(hidden_layer_sizes =(100, ),
solver='lbfgs', random_state=123456)
base_learners.append(mlpc)
meta_learner = LogisticRegression(solver='lbfgs')
同样,使用KFolds,我们将训练集拆分成五个折叠,以便在四个折叠上进行训练并为剩余的折叠生成元数据,重复五次。请注意,我们使用learner.predict_proba(train_x[test_indices])[:,0]来获取实例属于第一类的预测概率。鉴于我们只有两个类别,这已经足够了。如果是N个类别,我们必须保存N-1 个特征,或者简单地使用learner.predict,以便保存预测的类别:
# --- SECTION 3 ---
# Create the training metadata
# Create variables to store metadata and their targets
meta_data = np.zeros((len(base_learners), len(train_x)))
meta_targets = np.zeros(len(train_x))
# Create the cross-validation folds
KF = KFold(n_splits=5)
meta_index = 0
for train_indices, test_indices in KF.split(train_x):
# Train each learner on the K-1 folds and create
# metadata for the Kth fold
for i in range(len(base_learners)):
learner = base_learners[i]
learner.fit(train_x[train_indices], train_y[train_indices])
predictions = learner.predict_proba(train_x[test_indices])[:,0]
meta_data[i][meta_index:meta_index+len(test_indices)] = predictions
meta_targets[meta_index:meta_index+len(test_indices)] = \
train_y[test_indices]
meta_index += len(test_indices)
# Transpose the metadata to be fed into the meta-learner
meta_data = meta_data.transpose()
然后,我们在训练集上训练基础分类器,并为测试集创建元数据,同时使用metrics.accuracy_score(test_y, learner.predict(test_x))评估它们的准确度:
# --- SECTION 4 ---
# Create the metadata for the test set and evaluate the base learners
test_meta_data = np.zeros((len(base_learners), len(test_x)))
base_acc = []
for i in range(len(base_learners)):
learner = base_learners[i]
learner.fit(train_x, train_y)
predictions = learner.predict_proba(test_x)[:,0]
test_meta_data[i] = predictions
acc = metrics.accuracy_score(test_y, learner.predict(test_x))
base_acc.append(acc)
test_meta_data = test_meta_data.transpose()
最后,我们在训练元数据上拟合元学习器,评估其在测试数据上的表现,并打印出集成模型和单个学习器的准确度:
# --- SECTION 5 ---
# Fit the meta-learner on the train set and evaluate it on the test set
meta_learner.fit(meta_data, meta_targets)
ensemble_predictions = meta_learner.predict(test_meta_data)
acc = metrics.accuracy_score(test_y, ensemble_predictions)
# --- SECTION 6 ---
# Print the results
print('Acc Name')
print('-'*20)
for i in range(len(base_learners)):
learner = base_learners[i]
print(f'{base_acc[i]:.2f} {learner.__class__.__name__}')
print(f'{acc:.2f} Ensemble')
最终输出如下:
Acc Name
--------------------
0.86 KNeighborsClassifier
0.88 DecisionTreeClassifier
0.92 MLPClassifier
0.93 Ensemble
在这里,我们可以看到,元学习器仅能将集成模型的表现提高 1%,与表现最好的基础学习器相比。如果我们尝试利用learner.predict方法生成元数据,我们会发现集成模型实际上表现不如神经网络:
Acc Name
--------------------
0.86 KNeighborsClassifier
0.88 DecisionTreeClassifier
0.92 MLPClassifier
0.91 Ensemble
为 scikit-learn 创建一个堆叠回归器类
我们可以利用前面的代码来创建一个可重用的类,用于协调集成模型的训练和预测。所有 scikit-learn 分类器都使用标准的fit(x, y)和predict(x)方法,分别用于训练和预测。首先,我们导入所需的库,并声明类及其构造函数。构造函数的参数是一个包含 scikit-learn 分类器子列表的列表。每个子列表包含该层的学习器。因此,构建一个多层堆叠集成模型非常容易。例如,可以使用StackingRegressor([ [l11, l12, l13], [ l21, l22], [l31] ])来构建一个三层集成模型。我们创建一个包含每个堆叠层大小(学习器数量)的列表,并且还创建基础学习器的深拷贝。最后一个列表中的分类器被视为元学习器:
以下所有代码,直到(不包括)第五部分(注释标签),都是StackingRegressor类的一部分。如果将其复制到 Python 编辑器中,应正确缩进。
# --- SECTION 1 ---
# Libraries
import numpy as np
from sklearn.model_selection import KFold
from copy import deepcopy
class StackingRegressor():
# --- SECTION 2 ---
# The constructor
def __init__(self, learners):
# Create a list of sizes for each stacking level
# And a list of deep copied learners
self.level_sizes = []
self.learners = []
for learning_level in learners:
self.level_sizes.append(len(learning_level))
level_learners = []
for learner in learning_level:
level_learners.append(deepcopy(learner))
self.learners.append(level_learners)
在跟随构造函数定义的过程中,我们定义了fit函数。与我们在前一部分展示的简单堆叠脚本的唯一区别在于,我们不再为元学习器创建元数据,而是为每个堆叠层创建一个元数据列表。我们将元数据和目标保存到meta_data, meta_targets列表中,并使用data_z, target_z作为每个层的对应变量。此外,我们在上一层的元数据上训练该层的学习器。我们使用原始训练集和目标初始化元数据列表:
# --- SECTION 3 ---
# The fit function. Creates training metadata for every level
# and trains each level on the previous level's metadata
def fit(self, x, y):
# Create a list of training metadata, one for each stacking level
# and another one for the targets. For the first level,
# the actual data is used.
meta_data = [x]
meta_targets = [y]
for i in range(len(self.learners)):
level_size = self.level_sizes[i]
# Create the metadata and target variables for this level
data_z = np.zeros((level_size, len(x)))
target_z = np.zeros(len(x))
train_x = meta_data[i]
train_y = meta_targets[i]
# Create the cross-validation folds
KF = KFold(n_splits=5)
meta_index = 0
for train_indices, test_indices in KF.split(x):
# Train each learner on the K-1 folds and create
# metadata for the Kth fold
for j in range(len(self.learners[i])):
learner = self.learners[i][j]
learner.fit(train_x[train_indices],
train_y[train_indices])
predictions = learner.predict(train_x[test_indices])
data_z[j][meta_index:meta_index+len(test_indices)] = \
predictions
target_z[meta_index:meta_index+len(test_indices)] = \
train_y[test_indices]
meta_index += len(test_indices)
# Add the data and targets to the metadata lists
data_z = data_z.transpose()
meta_data.append(data_z)
meta_targets.append(target_z)
# Train the learner on the whole previous metadata
for learner in self.learners[i]:
learner.fit(train_x, train_y)
最后,我们定义了predict函数,该函数为提供的测试集创建每个层的元数据,使用与fit中相同的逻辑(存储每个层的元数据)。该函数返回每个层的元数据,因为它们也是每个层的预测结果。集成输出可以通过meta_data[-1]访问:
# --- SECTION 4 ---
# The predict function. Creates metadata for the test data and returns
# all of them. The actual predictions can be accessed with
# meta_data[-1]
def predict(self, x):
# Create a list of training metadata, one for each stacking level
meta_data = [x]
for i in range(len(self.learners)):
level_size = self.level_sizes[i]
data_z = np.zeros((level_size, len(x)))
test_x = meta_data[i]
# Create the cross-validation folds
KF = KFold(n_splits=5)
for train_indices, test_indices in KF.split(x):
# Train each learner on the K-1 folds and create
# metadata for the Kth fold
for j in range(len(self.learners[i])):
learner = self.learners[i][j]
predictions = learner.predict(test_x)
data_z[j] = predictions
# Add the data and targets to the metadata lists
data_z = data_z.transpose()
meta_data.append(data_z)
# Return the meta_data the final layer's prediction can be accessed
# With meta_data[-1]
return meta_data
如果我们用与回归示例中相同的元学习器和基础学习器实例化StackingRegressor,我们可以看到它的表现完全相同!为了访问中间预测,我们必须访问该层的索引加一,因为meta_data[0]中的数据是原始的测试数据:
# --- SECTION 5 ---
# Use the classifier
from sklearn.datasets import load_diabetes
from sklearn.neighbors import KNeighborsRegressor
from sklearn.tree import DecisionTreeRegressor
from sklearn.linear_model import LinearRegression, Ridge
from sklearn import metrics
diabetes = load_diabetes()
train_x, train_y = diabetes.data[:400], diabetes.target[:400]
test_x, test_y = diabetes.data[400:], diabetes.target[400:]
base_learners = []
knn = KNeighborsRegressor(n_neighbors=5)
base_learners.append(knn)
dtr = DecisionTreeRegressor(max_depth=4, random_state=123456)
base_learners.append(dtr)
ridge = Ridge()
base_learners.append(ridge)
meta_learner = LinearRegression()
# Instantiate the stacking regressor
sc = StackingRegressor([[knn,dtr,ridge],[meta_learner]])
# Fit and predict
sc.fit(train_x, train_y)
meta_data = sc.predict(test_x)
# Evaluate base learners and meta-learner
base_errors = []
base_r2 = []
for i in range(len(base_learners)):
learner = base_learners[i]
predictions = meta_data[1][:,i]
err = metrics.mean_squared_error(test_y, predictions)
r2 = metrics.r2_score(test_y, predictions)
base_errors.append(err)
base_r2.append(r2)
err = metrics.mean_squared_error(test_y, meta_data[-1])
r2 = metrics.r2_score(test_y, meta_data[-1])
# Print the results
print('ERROR R2 Name')
print('-'*20)
for i in range(len(base_learners)):
learner = base_learners[i]
print(f'{base_errors[i]:.1f} {base_r2[i]:.2f}
{learner.__class__.__name__}')
print(f'{err:.1f} {r2:.2f} Ensemble')
结果与我们之前示例中的结果一致:
ERROR R2 Name
--------------------
2697.8 0.51 KNeighborsRegressor
3142.5 0.43 DecisionTreeRegressor
2564.8 0.54 Ridge
2066.6 0.63 Ensemble
为了进一步澄清meta_data与self.learners列表之间的关系,我们通过图示方式展示它们的交互关系。为了代码简洁,我们初始化了meta_data[0]。虽然将实际输入数据存储在meta_data列表中可能会误导,但它避免了需要以不同于其他层的方式处理基础学习器第一层:

每一层meta_data与self.learners之间的关系
总结
本章介绍了一种名为堆叠(或堆叠泛化)的集成学习方法。它可以视为一种更高级的投票方法。我们首先介绍了堆叠的基本概念,如何正确创建元数据,以及如何决定集成的组成。我们为堆叠提供了回归和分类的实现。最后,我们展示了一个集成类的实现(类似于scikit-learn类的实现),使得多层堆叠集成更易于使用。以下是本章的一些关键要点:
堆叠可以由多个层组成。每一层都会为下一层生成元数据。你应该通过将训练集划分为K 折并迭代地在 K-1 折上训练,同时为第 K 折创建元数据来创建每一层的元数据。创建元数据后,你应该在整个训练集上训练当前层。基础学习器必须具有多样性。元学习器应该是一个相对简单的算法,并能抵抗过拟合。如果可能的话,尽量在元学习器中引入正则化。例如,如果使用决策树,则限制其最大深度,或使用正则化回归。元学习器应该能够相对较好地处理相关输入。你不应该害怕将表现不佳的模型添加到集成中,只要它们为元数据引入了新的信息(即,它们以不同于其他模型的方式处理数据集)。在下一章中,我们将介绍第一个生成式集成方法——袋装(Bagging)。
第三部分:生成方法
在本节中,我们将涵盖更高级的集成学习方法。
本节包括以下章节:
-
第五章,装袋法
-
第六章,提升法
-
第七章,随机森林
第五章:装袋法(Bagging)
装袋法,或称为自助聚合(Bootstrap Aggregating),是本书介绍的第一个生成性集成学习技术。它可以作为减少方差的有用工具,通过对原始训练集进行子抽样来创建多个基础学习器。在本章中,我们将讨论装袋法所基于的统计方法——自助法。接下来,我们将介绍装袋法的优缺点,并最终用 Python 实现该方法,同时使用 scikit-learn 实现解决回归和分类问题。
本章涵盖的主要内容如下:
-
计算统计学中的自助法方法
-
装袋法的工作原理
-
装袋法的优缺点
-
实现自定义的装袋集成方法
-
使用 scikit-learn 实现
技术要求
你需要具备基本的机器学习技术和算法知识。此外,还需要了解 Python 的规范和语法。最后,熟悉 NumPy 库将极大地帮助读者理解一些自定义算法实现。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter05
查看以下视频,查看代码的实际操作:bit.ly/2JKcokD。
自助法(Bootstrapping)
自助法是一种重抽样方法。在统计学中,重抽样是指使用从原始样本生成的多个样本。在机器学习术语中,样本即为我们的训练数据。其主要思想是将原始样本视为总体(问题的整个领域),而将生成的子样本视为样本。
本质上,我们在模拟如果我们从原始总体中收集多个样本,统计量将如何表现,正如以下图示所示:

自助法如何工作的示意图
创建自助样本
为了创建自助样本,我们使用有放回抽样(每个实例可能被多次选择)从原始样本中抽取数据。这意味着一个实例可以被多次选择。假设我们有 100 个人的数据,数据中包含每个人的体重和身高。如果我们从 1 到 100 生成随机数字,并将对应的数据添加到一个新数据集中,那么我们基本上就创建了一个自助样本。
在 Python 中,我们可以使用 numpy.random.choice 来创建给定大小的子样本。我们可以尝试创建自助样本并估算糖尿病数据集的均值和标准差。首先,我们加载数据集和库,并打印样本的统计信息,如下例所示:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_diabetes
diabetes = load_diabetes()
# --- SECTION 2 ---
# Print the original sample's statistics
target = diabetes.target
print(np.mean(target))
print(np.std(target))
接下来,我们创建自助样本和统计量,并将其存储在bootstrap_stats中。我们本可以存储整个自助样本,但这样做会消耗过多内存。而且,我们只关心统计量,因此只存储它们更有意义。在这里,我们创建了 10,000 个自助样本和统计量:
# --- SECTION 3 ---
# Create the bootstrap samples and statistics
bootstrap_stats = []
for _ in range(10000):
bootstrap_sample = np.random.choice(target, size=len(target))
mean = np.mean(bootstrap_sample)
std = np.std(bootstrap_sample)
bootstrap_stats.append((mean, std))
bootstrap_stats = np.array(bootstrap_stats)
现在我们可以绘制平均值和标准差的直方图,并计算每个值的标准误差(即统计量分布的标准差):
# --- SECTION 4 ---
# plot the distributions
plt.figure()
plt.subplot(2,1,1)
std_err = np.std(bootstrap_stats[:,0])
plt.title('Mean, Std. Error: %.2f'%std_err)
plt.hist(bootstrap_stats[:,0], bins=20)
plt.subplot(2,1,2)
std_err = np.std(bootstrap_stats[:,1])
plt.title('Std. Dev, Std. Error: %.2f'%std_err)
plt.hist(bootstrap_stats[:,1], bins=20)
plt.show()
我们得到如下图所示的输出:

平均值和标准差的自助分布
注意,由于该过程的固有随机性(每个自助样本将选择哪些实例),每次执行时结果可能会有所不同。增加自助样本的数量有助于稳定结果。尽管如此,这仍然是一种非常有用的技术,可以在不做假设的情况下计算标准误差、置信区间和其他统计量,而无需假设底层分布。
集成法(Bagging)
集成法利用自助采样(bootstrap sampling)训练一系列基础学习器,然后通过投票方式合并它们的预测结果。这种方法的动机是通过多样化训练集,产生多样化的基础学习器。在本节中,我们讨论这种方法的动机、优势与劣势。
创建基础学习器
集成法对训练集应用自助采样,创建多个N个自助样本。接着,使用相同的机器学习算法创建相同数量N的基础学习器。每个基础学习器都在相应的训练集上进行训练,所有基础学习器通过投票合并(分类时使用硬投票,回归时使用平均值)。该过程如下所示:

通过集成法创建基础学习器
通过使用与原始训练集大小相同的自助样本,每个实例在任何给定的自助样本中出现的概率为 0.632。因此,在许多情况下,这种自助估计被称为 0.632 自助估计。在我们的案例中,这意味着我们可以使用原始训练集中剩余的 36.8%实例来估算单个基础学习器的性能。这被称为袋外得分(out-of-bag score),而这 36.8%的实例则被称为袋外实例(out-of-bag instances)。
优势与劣势
Bagging 通常使用决策树作为基本学习器,但它可以与任何机器学习算法一起使用。Bagging 大大减少了方差,并且已被证明在使用不稳定的基本学习器时最为有效。不稳定的学习器生成的模型具有较大的模型间方差,即使训练集仅略微变化。此外,随着基本学习器数量的增加,bagging 会收敛。类似于估计自助法统计量,通过增加基本学习器的数量,我们也增加了自助样本的数量。最后,bagging 允许轻松并行化,因为每个模型都是独立训练的。
Bagging 的主要缺点是模型的可解释性和透明度的丧失。例如,使用单个决策树可以提供很好的可解释性,因为每个节点的决策都是可直接获取的。使用 100 棵树的 bagging 集成模型会使得单个决策变得不那么重要,而是集体预测定义了集成模型的最终输出。
Python 实现
为了更好地理解集成模型的创建过程及其优点,我们将使用决策树在 Python 中实现它。在这个示例中,我们将尝试对手写数字的 MNIST 数据集进行分类。虽然我们之前一直使用癌症数据集作为分类示例,但它只有两个类别,并且样本数量相对较少,不适合有效的自助法。数字数据集包含大量样本,且更加复杂,因为它总共有 10 个类别。
实现
在这个示例中,我们将使用 1500 个实例作为训练集,剩余的 297 个作为测试集。我们将生成 10 个自助样本,因此会得到 10 个决策树模型。接着,我们将通过硬投票将基本预测结果结合起来:
- 我们加载库和数据,如下所示:
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.datasets import load_digits
from sklearn.tree import DecisionTreeClassifier
from sklearn import metrics
import numpy as np
digits = load_digits()
train_size = 1500
train_x, train_y = digits.data[:train_size], digits.target[:train_size]
test_x, test_y = digits.data[train_size:], digits.target[train_size:]
- 然后我们创建自助样本并训练相应的模型。请注意,我们没有使用
np.random.choice,而是使用np.random.randint(0, train_size, size=train_size)生成一个索引数组,这样我们可以为每个自助样本选择特征和相应的目标。为了后续方便访问,我们将每个基本学习器存储在base_learners列表中:
# --- SECTION 2 ---
# Create our bootstrap samples and train the classifiers
ensemble_size = 10
base_learners = []
for _ in range(ensemble_size):
# We sample indices in order to access features and targets
bootstrap_sample_indices = np.random.randint(0, train_size, size=train_size)
bootstrap_x = train_x[bootstrap_sample_indices]
bootstrap_y = train_y[bootstrap_sample_indices]
dtree = DecisionTreeClassifier()
dtree.fit(bootstrap_x, bootstrap_y)
base_learners.append(dtree)
- 接下来,我们使用每个基本学习器预测测试集的目标,并存储它们的预测结果以及评估后的准确性,如下方代码块所示:
# --- SECTION 3 ---
# Predict with the base learners and evaluate them
base_predictions = []
base_accuracy = []
for learner in base_learners:
predictions = learner.predict(test_x)
base_predictions.append(predictions)
acc = metrics.accuracy_score(test_y, predictions)
base_accuracy.append(acc)
- 现在我们已经在
base_predictions中得到了每个基本学习器的预测,我们可以像在第三章中做的那样,使用硬投票将它们结合起来,投票,用于个体基本学习器的预测。此外,我们还评估了集成模型的准确性:
# Combine the base learners' predictions
ensemble_predictions = []
# Find the most voted class for each test instance
for i in range(len(test_y)):
counts = [0 for _ in range(10)]
for learner_predictions in base_predictions:
counts[learner_predictions[i]] = counts[learner_predictions[i]]+1
# Find the class with most votes
final = np.argmax(counts)
# Add the class to the final predictions
ensemble_predictions.append(final)
ensemble_acc = metrics.accuracy_score(test_y, ensemble_predictions)
- 最后,我们打印每个基本学习器的准确性以及集成模型的准确性,并按升序排序:
# --- SECTION 5 ---
# Print the accuracies
print('Base Learners:')
print('-'*30)
for index, acc in enumerate(sorted(base_accuracy)):
print(f'Learner {index+1}: %.2f' % acc)
print('-'*30)
print('Bagging: %.2f' % ensemble_acc)
最终输出如下所示:
Base Learners:
------------------------------
Learner 1: 0.72
Learner 2: 0.72
Learner 3: 0.73
Learner 4: 0.73
Learner 5: 0.76
Learner 6: 0.76
Learner 7: 0.77
Learner 8: 0.77
Learner 9: 0.79
Learner 10: 0.79
------------------------------
Bagging: 0.88
显然,集成模型的准确率比表现最佳的基模型高出近 10%。这是一个相当大的改进,特别是如果我们考虑到该集成模型由相同的基学习器组成(考虑到所使用的机器学习方法)。
并行化实现
我们可以通过 from concurrent.futures import ProcessPoolExecutor 来轻松并行化我们的袋装实现。这个执行器允许用户生成多个任务并在并行进程中执行。它只需要传入一个目标函数及其参数。在我们的例子中,我们只需要将代码块(第 2 和第三部分)封装成函数:
def create_learner(train_x, train_y):
# We sample indices in order to access features and targets
bootstrap_sample_indices = np.random.randint(0, train_size, size=train_size)
bootstrap_x = train_x[bootstrap_sample_indices]
bootstrap_y = train_y[bootstrap_sample_indices]
dtree = DecisionTreeClassifier()
dtree.fit(bootstrap_x, bootstrap_y)
return dtree
def predict(learner, test_x):
return learner.predict(test_x)
接着,在原始的第 2 和第三部分中,我们将代码修改如下:
# Original Section 2
with ProcessPoolExecutor() as executor:
futures = []
for _ in range(ensemble_size):
future = executor.submit(create_learner, train_x, train_y)
futures.append(future)
for future in futures:
base_learners.append(future.result())
# Original Section 3
base_predictions = []
base_accuracy = []
with ProcessPoolExecutor() as executor:
futures = []
for learner in base_learners:
future = executor.submit(predict, learner, test_x)
futures.append(future)
for future in futures:
predictions = future.result()
base_predictions.append(predictions)
acc = metrics.accuracy_score(test_y, predictions)
base_accuracy.append(acc)
executor 返回一个对象(在我们的例子中是 future),其中包含了我们函数的结果。其余代码保持不变,唯一的变化是它被封装在 if __name__ == '__main__' 的保护代码块中,因为每个新进程都会导入整个脚本。这个保护代码块防止它们重新执行其余的代码。由于我们的示例较小,且有六个进程可用,因此我们需要至少 1,000 个基学习器才能看到执行时间的显著加速。有关完整的工作版本,请参考提供的代码库中的 'bagging_custom_parallel.py'。
使用 scikit-learn
Scikit-learn 为回归和分类问题提供了出色的袋装(bagging)实现。在本节中,我们将通过使用提供的实现,创建数字和糖尿病数据集的集成模型。
分类问题的袋装(Bagging)
Scikit-learn 的袋装实现位于 sklearn.ensemble 包中。BaggingClassifier 是分类问题的相应类。它有许多有趣的参数,提供了更大的灵活性。通过指定 base_estimator,它可以使用任何 scikit-learn 估计器。此外,n_estimators 决定了集成模型的大小(也就是说,决定了生成的自助样本数量),而 n_jobs 则决定了在训练和预测每个基学习器时将使用多少个作业(进程)。最后,如果设置为 True,oob_score 会计算基学习器的袋外得分。
使用实际的分类器是非常简单的,与所有其他 scikit-learn 估计器类似。首先,我们加载所需的数据和库,如以下示例所示:
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.datasets import load_digits
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import BaggingClassifier
from sklearn import metrics
digits = load_digits()
train_size = 1500
train_x, train_y = digits.data[:train_size], digits.target[:train_size]
test_x, test_y = digits.data[train_size:], digits.target[train_size:]
然后,我们创建、训练并评估估计器:
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 10
ensemble = BaggingClassifier(base_estimator=DecisionTreeClassifier(),
n_estimators=ensemble_size,
oob_score=True)
# --- SECTION 3 ---
# Train the ensemble
ensemble.fit(train_x, train_y)
# --- SECTION 4 ---
# Evaluate the ensemble
ensemble_predictions = ensemble.predict(test_x)
ensemble_acc = metrics.accuracy_score(test_y, ensemble_predictions)
# --- SECTION 5 ---
# Print the accuracy
print('Bagging: %.2f' % ensemble_acc)
最终的准确率为 88%,与我们自己的实现相同。此外,我们可以通过 ensemble.oob_score_ 访问袋外得分,在我们的例子中,它等于 89.6%。通常情况下,袋外得分略微高估了集成模型的样本外预测能力,这在这个示例中得到了体现。
在我们的示例中,我们选择了ensemble_size为10。假设我们希望测试不同集成模型大小如何影响集成模型的表现。由于 bagging 分类器接受该大小作为构造函数的参数,我们可以使用第二章中的验证曲线,开始使用集成学习,来进行该测试。我们测试了 1 到 39 个基础学习器,步长为 2。我们观察到偏差和方差的初始下降。对于具有超过 20 个基础学习器的集成模型,似乎增加集成模型的大小并没有带来任何好处。结果在下图中显示:

1 到 39 个基础学习器的验证曲线
用于回归的 Bagging
对于回归目的,我们将使用来自相同sklearn.ensemble包的BaggingRegressor类。我们还将实例化一个单独的DecisionTreeRegressor以比较结果。我们按惯例开始加载库和数据:
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.datasets import load_diabetes
from sklearn.tree import DecisionTreeRegressor
from sklearn.ensemble import BaggingRegressor
from sklearn import metrics
import numpy as np
diabetes = load_diabetes()
np.random.seed(1234)
train_x, train_y = diabetes.data[:400], diabetes.target[:400]
test_x, test_y = diabetes.data[400:], diabetes.target[400:]
我们实例化了单个决策树和集成模型。请注意,我们通过指定max_depth=6来允许相对较深的决策树。这允许创建多样化且不稳定的模型,极大地有利于 bagging。如果我们将最大深度限制为 2 或 3 层,我们会看到 bagging 的表现不比单一模型更好。训练和评估集成模型和单一模型的过程遵循标准程序:
# --- SECTION 2 ---
# Create the ensemble and a single base learner for comparison
estimator = DecisionTreeRegressor(max_depth=6)
ensemble = BaggingRegressor(base_estimator=estimator,
n_estimators=10)
# --- SECTION 3 ---
# Train and evaluate both the ensemble and the base learner
ensemble.fit(train_x, train_y)
ensemble_predictions = ensemble.predict(test_x)
estimator.fit(train_x, train_y)
single_predictions = estimator.predict(test_x)
ensemble_r2 = metrics.r2_score(test_y, ensemble_predictions)
ensemble_mse = metrics.mean_squared_error(test_y, ensemble_predictions)
single_r2 = metrics.r2_score(test_y, single_predictions)
single_mse = metrics.mean_squared_error(test_y, single_predictions)
# --- SECTION 4 ---
# Print the metrics
print('Bagging r-squared: %.2f' % ensemble_r2)
print('Bagging MSE: %.2f' % ensemble_mse)
print('-'*30)
print('Decision Tree r-squared: %.2f' % single_r2)
print('Decision Tree MSE: %.2f' % single_mse)
集成模型可以显著优于单一模型,通过产生更高的 R 平方值和更低的均方误差(MSE)。如前所述,这是因为基础学习器可以创建深度和不稳定的模型。以下是两个模型的实际结果:
Bagging r-squared: 0.52
Bagging MSE: 2679.12
------------------------------
Decision Tree r-squared: 0.15
Decision Tree MSE: 4733.35
摘要
本章介绍了创建自助法样本和估算自助法统计量的主要概念。在此基础上,我们介绍了自助法聚合(或称为 bagging),它使用多个自助法样本来训练许多基础学习器,这些学习器使用相同的机器学习算法。随后,我们提供了用于分类的自定义 bagging 实现,并介绍了如何并行化它。最后,我们展示了 scikit-learn 自身实现的 bagging 在回归和分类问题中的应用。
本章可以总结如下:自助法样本是通过从原始数据集进行有放回的重采样来创建的。其主要思想是将原始样本视为总体,将每个子样本视为原始样本。如果原始数据集和自助法数据集的大小相同,则每个实例有63.2%的概率被包含在自助法数据集中(样本)。自助法方法对于计算统计量(如置信区间和标准误差)非常有用,无需对潜在的分布做假设。集成自助法(Bagging)通过生成多个自助法样本来训练每个独立的基学习器。集成自助法对于不稳定学习器很有帮助,因为训练集中的小变化可能会导致生成的模型发生较大变化。集成自助法是减少方差的适合的集成学习方法。
集成自助法支持并行化,因为每个自助法样本和基学习器都可以独立生成、训练和测试。与所有集成学习方法一样,使用集成自助法会降低单个预测的可解释性和动机。
在下一章,我们将介绍第二种生成性方法——提升法(Boosting)。
第六章:提升
我们将讨论的第二种生成方法是提升。提升旨在将多个弱学习器结合成一个强大的集成。它能够减少偏差,同时也能降低方差。在这里,弱学习器是指那些表现略好于随机预测的独立模型。例如,在一个包含两个类别且每个类别的实例数量相等的分类数据集上,弱学习器的准确率会稍微高于 50%。
在本章中,我们将介绍两种经典的提升算法:梯度提升(Gradient Boosting)和 AdaBoost。此外,我们还将探讨使用 scikit-learn 实现进行分类和回归。最后,我们将实验一种近期的提升算法及其实现——XGBoost。
本章涵盖的主要主题如下:
-
使用提升集成的动机
-
各种算法
-
利用 scikit-learn 在 Python 中创建提升集成
-
使用 XGBoost 库进行 Python 编程
技术要求
你需要具备基本的机器学习技术和算法知识。此外,还需要了解 Python 语法和约定。最后,熟悉 NumPy 库将有助于读者理解一些自定义算法实现。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter06
查看以下视频以观看代码演示:bit.ly/2ShWstT。
AdaBoost
AdaBoost 是最流行的提升算法之一。与袋装法类似,该算法的主要思想是创建若干个无关的弱学习器,然后将它们的预测结果结合起来。与袋装法的主要区别在于,算法不是创建多个独立的自助法训练集,而是顺序地训练每一个弱学习器,给所有实例分配权重,基于实例的权重采样下一组训练集,然后重复整个过程。作为基学习器算法,通常使用由单一节点构成的决策树。这些深度为一层的决策树被称为决策桩。
加权采样
加权采样是指每个候选者都有一个相应的权重,这个权重决定了该候选者被采样的概率。权重经过归一化处理,使得它们的总和为 1。然后,归一化后的权重对应每个候选者被选中的概率。以下表格展示了一个简单示例,其中有三个候选者,权重分别为 1、5 和 10,并展示了归一化权重以及相应的候选者被选中的概率。
| 候选者 | 权重 | 归一化权重 | 概率 |
|---|---|---|---|
| 1 | 1 | 0.0625 | 6.25% |
| 2 | 5 | 0.3125 | 31.25% |
| 3 | 10 | 0.625 | 62.50% |
实例权重转为概率
创建集成模型
假设是一个分类问题,AdaBoost 算法可以从其基本步骤高层次地描述。对于回归问题,步骤类似:
-
初始化所有训练集实例的权重,使它们的总和等于 1。
-
根据权重进行有放回的采样,生成一个新的数据集。
-
在采样集上训练弱学习器。
-
计算它在原始训练集上的错误率。
-
将弱学习器添加到集成模型中并保存其错误率。
-
调整权重,增加错误分类实例的权重,减少正确分类实例的权重。
-
从 步骤 2 重复。
-
弱学习器通过投票组合,每个学习器的投票按其错误率加权。
整个过程如下面的图所示:

为第 n 个学习器创建集成模型的过程
本质上,这使得每个新的分类器都专注于前一个学习器无法正确处理的实例。假设是一个二分类问题,我们可以从如下图所示的数据集开始:

我们的初始数据集
这里,所有权重都相等。第一个决策树桩决定按如下方式划分问题空间。虚线代表决策边界。两个黑色的 + 和 - 符号表示决策树桩将每个实例分类为正类或负类的子空间。这留下了两个错误分类的实例。它们的实例权重将被增加,而其他所有权重将被减少:

第一个决策树桩的空间划分和错误
通过创建另一个数据集,其中两个错误分类的实例占主导地位(由于我们进行有放回的采样并且它们的权重大于其他实例,它们可能会被多次包含),第二个决策树桩按如下方式划分空间:

第二个决策树桩的空间划分和错误
最后,在重复第三个决策树桩的过程后,最终的集成模型按如下图所示划分了空间:

最终集成模型的空间划分
在 Python 中实现 AdaBoost
为了更好地理解 AdaBoost 是如何工作的,我们将展示一个基本的 Python 实现。我们将使用乳腺癌分类数据集作为示例。像往常一样,我们首先加载库和数据:
# --- SECTION 1 ---
# Libraries and data loading
from copy import deepcopy
from sklearn.datasets import load_breast_cancer
from sklearn.tree import DecisionTreeClassifier
from sklearn import metrics
import numpy as np
bc = load_breast_cancer()
train_size = 400
train_x, train_y = bc.data[:train_size], bc.target[:train_size]
test_x, test_y = bc.data[train_size:], bc.target[train_size:]
np.random.seed(123456)
然后我们创建集成模型。首先,声明集成模型的大小和基础学习器类型。如前所述,我们使用决策树桩(决策树仅有一层)。
此外,我们为数据实例的权重、学习器的权重和学习器的错误创建了一个 NumPy 数组:
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 3
base_classifier = DecisionTreeClassifier(max_depth=1)
# Create the initial weights
data_weights = np.zeros(train_size) + 1/train_size
# Create a list of indices for the train set
indices = [x for x in range(train_size)]
base_learners = []
learners_errors = np.zeros(ensemble_size)
learners_weights = np.zeros(ensemble_size)
对于每个基本学习器,我们将创建一个原始分类器的deepcopy,在一个样本数据集上训练它,并进行评估。首先,我们创建副本并根据实例的权重,从原始测试集中进行有放回的抽样:
# Create each base learner
for i in range(ensemble_size):
weak_learner = deepcopy(base_classifier)
# Choose the samples by sampling with replacement.
# Each instance's probability is dictated by its weight.
data_indices = np.random.choice(indices, train_size, p=data_weights)
sample_x, sample_y = train_x[data_indices], train_y[data_indices]
然后,我们在采样数据集上拟合学习器,并在原始训练集上进行预测。我们使用predictions来查看哪些实例被正确分类,哪些实例被误分类:
# Fit the weak learner and evaluate it
weak_learner.fit(sample_x, sample_y)
predictions = weak_learner.predict(train_x)
errors = predictions != train_y
corrects = predictions == train_y
在下面,权重误差被分类。errors和corrects都是布尔值列表(True或False),但 Python 将它们处理为 1 和 0。这使得我们可以与data_weights进行逐元素相乘。然后,学习器的误差通过加权误差的平均值计算得出:
# Calculate the weighted errors
weighted_errors = data_weights*errors
# The base learner's error is the average of the weighted errors
learner_error = np.mean(weighted_errors)
learners_errors[i] = learner_error
最后,学习器的权重可以通过加权准确率与加权误差的自然对数的一半来计算。接下来,我们可以使用学习器的权重来计算新的数据权重。对于误分类的实例,新权重等于旧权重乘以学习器权重的自然指数。对于正确分类的实例,则使用负倍数。最后,新的权重进行归一化,基本学习器被添加到base_learners列表中:
# The learner's weight
learner_weight = np.log((1-learner_error)/learner_error)/2
learners_weights[i] = learner_weight
# Update the data weights
data_weights[errors] = np.exp(data_weights[errors] * learner_weight)
data_weights[corrects] = np.exp(-data_weights[corrects] * learner_weight)
data_weights = data_weights/sum(data_weights)
# Save the learner
base_learners.append(weak_learner)
为了使用集成进行预测,我们通过加权多数投票将每个单独的预测结果结合起来。由于这是一个二分类问题,如果加权平均值大于0.5,则实例被分类为0;否则,它被分类为1:
# --- SECTION 3 ---
# Evaluate the ensemble
ensemble_predictions = []
for learner, weight in zip(base_learners, learners_weights):
# Calculate the weighted predictions
prediction = learner.predict(test_x)
ensemble_predictions.append(prediction*weight)
# The final prediction is the weighted mean of the individual predictions
ensemble_predictions = np.mean(ensemble_predictions, axis=0) >= 0.5
ensemble_acc = metrics.accuracy_score(test_y, ensemble_predictions)
# --- SECTION 4 ---
# Print the accuracy
print('Boosting: %.2f' % ensemble_acc)
该集成方法最终实现的准确率为 95%。
优势与劣势
提升算法能够同时减少偏差和方差。长期以来,它们被认为能够免疫过拟合,但事实上,它们也有可能过拟合,尽管它们非常健壮。一种可能的解释是,基本学习器为了分类异常值,创建了非常强大且复杂的规则,这些规则很少能适应其他实例。在下面的图示中,给出了一个示例。集成方法生成了一组规则来正确分类异常值,但这些规则如此强大,以至于只有一个完全相同的例子(即,具有完全相同特征值)才能适应由规则定义的子空间:

为异常值生成的规则
许多提升算法的一个缺点是它们难以并行化,因为模型是顺序生成的。此外,它们还存在集成学习技术的常见问题,例如可解释性的降低和额外的计算成本。
梯度提升
梯度提升是另一种提升算法。与 AdaBoost 相比,它是一个更广泛的提升框架,这也使得它更复杂且需要更多数学推导。梯度提升不是通过分配权重并重新采样数据集来强调有问题的实例,而是通过构建每个基本学习器来纠正前一个学习器的误差。此外,梯度提升使用不同深度的决策树。在这一部分,我们将介绍梯度提升,而不深入探讨其中的数学原理。相反,我们将介绍基本概念以及一个自定义的 Python 实现。
创建集成模型
梯度提升算法(用于回归目的)从计算训练集目标变量的均值开始,并将其作为初始预测值。然后,计算每个实例目标与预测值(均值)的差异,以便计算误差。这些误差也称为伪残差。
接下来,它创建一个决策树,尝试预测伪残差。通过重复这个过程若干次,整个集成模型被构建出来。类似于 AdaBoost,梯度提升为每棵树分配一个权重。与 AdaBoost 不同的是,这个权重并不依赖于树的表现,而是一个常数项,这个常数项称为学习率。它的目的是通过限制过拟合的能力来提高集成模型的泛化能力。算法的步骤如下:
-
定义学习率(小于 1)和集成模型的大小。
-
计算训练集的目标均值。
-
使用均值作为非常简单的初始预测,计算每个实例目标与均值的差异。这些误差称为伪残差。
-
使用原始训练集的特征和伪残差作为目标,构建决策树。
-
使用决策树对训练集进行预测(我们尝试预测伪残差)。将预测值乘以学习率。
-
将乘积值加到之前存储的预测值上,使用新计算的值作为新的预测。
-
使用计算得到的预测值来计算新的伪残差。
-
从步骤 4开始重复,直到达到所需的集成模型大小。
请注意,为了产生最终集成模型的预测,每个基本学习器的预测值会乘以学习率,并加到前一个学习器的预测上。计算出的均值可以视为第一个基本学习器的预测值。
在每一步 s 中,对于学习率 lr,预测值计算如下:

残差计算为实际目标值 t 与预测值的差异:

整个过程如下面的图所示:

创建梯度提升集成模型的步骤
进一步阅读
由于这是一本实战书籍,我们不会深入探讨算法的数学方面。然而,对于数学上有兴趣的人,我们推荐以下论文。第一篇是更具体的回归框架,而第二篇则更加一般化:
-
Friedman, J.H., 2001. 贪婪函数逼近:梯度提升机。《统计学年鉴》,pp.1189-1232。
-
Mason, L., Baxter, J., Bartlett, P.L. 和 Frean, M.R., 2000. 提升算法作为梯度下降方法。在《神经信息处理系统进展》中(第 512-518 页)。
在 Python 中实现梯度提升
尽管梯度提升可能很复杂且需要数学知识,但如果我们专注于传统的回归问题,它可以变得非常简单。为了证明这一点,我们在 Python 中使用标准的 scikit-learn 决策树实现了一个自定义的例子。对于我们的实现,我们将使用糖尿病回归数据集。首先,加载库和数据,并设置 NumPy 的随机数生成器的种子:
# --- SECTION 1 ---
# Libraries and data loading
from copy import deepcopy
from sklearn.datasets import load_diabetes
from sklearn.tree import DecisionTreeRegressor
from sklearn import metrics
import numpy as np
diabetes = load_diabetes()
train_size = 400
train_x, train_y = diabetes.data[:train_size], diabetes.target[:train_size]
test_x, test_y = diabetes.data[train_size:], diabetes.target[train_size:]
np.random.seed(123456)
接下来,我们定义集成模型的大小、学习率和决策树的最大深度。此外,我们创建一个列表来存储各个基础学习器,以及一个 NumPy 数组来存储之前的预测。
如前所述,我们的初始预测是训练集的目标均值。除了定义最大深度外,我们还可以通过将 max_leaf_nodes=3 参数传递给构造函数来定义最大叶节点数:
# --- SECTION 2 ---
# Create the ensemble
# Define the ensemble's size, learning rate and decision tree depth
ensemble_size = 50
learning_rate = 0.1
base_classifier = DecisionTreeRegressor(max_depth=3)
# Create placeholders for the base learners and each step's prediction
base_learners = []
# Note that the initial prediction is the target variable's mean
previous_predictions = np.zeros(len(train_y)) + np.mean(train_y)
下一步是创建和训练集成模型。我们首先计算伪残差,使用之前的预测。然后我们创建基础学习器类的深层副本,并在训练集上使用伪残差作为目标进行训练:
# Create the base learners
for _ in range(ensemble_size):
# Start by calculating the pseudo-residuals
errors = train_y - previous_predictions
# Make a deep copy of the base classifier and train it on the
# pseudo-residuals
learner = deepcopy(base_classifier)
learner.fit(train_x, errors)
predictions = learner.predict(train_x)
最后,我们使用训练好的基础学习器在训练集上预测伪残差。我们将预测乘以学习率,加到之前的预测上。最后,我们将基础学习器追加到 base_learners 列表中:
# Multiply the predictions with the learning rate and add the results
# to the previous prediction
previous_predictions = previous_predictions + learning_rate*predictions
# Save the base learner
base_learners.append(learner)
为了使用我们的集成模型进行预测和评估,我们使用测试集的特征来预测伪残差,将其乘以学习率,然后加到训练集的目标均值上。重要的是要使用原始训练集的均值作为起始点,因为每棵树都预测相对于那个原始均值的偏差:
# --- SECTION 3 ---
# Evaluate the ensemble
# Start with the train set's mean
previous_predictions = np.zeros(len(test_y)) + np.mean(train_y)
# For each base learner predict the pseudo-residuals for the test set and
# add them to the previous prediction,
# after multiplying with the learning rate
for learner in base_learners:
predictions = learner.predict(test_x)
previous_predictions = previous_predictions + learning_rate*predictions
# --- SECTION 4 ---
# Print the metrics
r2 = metrics.r2_score(test_y, previous_predictions)
mse = metrics.mean_squared_error(test_y, previous_predictions)
print('Gradient Boosting:')
print('R-squared: %.2f' % r2)
print('MSE: %.2f' % mse)
该算法能够通过这种特定设置实现 0.59 的 R 平方值和 2253.34 的均方误差。
使用 scikit-learn
尽管出于教育目的编写自己的算法很有用,但 scikit-learn 在分类和回归问题上有一些非常好的实现。在本节中,我们将介绍这些实现,并看看如何提取生成的集成模型的信息。
使用 AdaBoost
Scikit-learn 中的 AdaBoost 实现位于 sklearn.ensemble 包中的 AdaBoostClassifier 和 AdaBoostRegressor 类中。
与所有 scikit-learn 分类器一样,我们使用fit和predict函数来训练分类器并在测试集上进行预测。第一个参数是算法将使用的基本分类器。algorithm="SAMME"参数强制分类器使用离散提升算法。对于这个例子,我们使用手写数字识别问题:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
from sklearn.datasets import load_digits
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import AdaBoostClassifier
from sklearn import metrics
digits = load_digits()
train_size = 1500
train_x, train_y = digits.data[:train_size], digits.target[:train_size]
test_x, test_y = digits.data[train_size:], digits.target[train_size:]
np.random.seed(123456)
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 200
ensemble = AdaBoostClassifier(DecisionTreeClassifier(max_depth=1),
algorithm="SAMME",
n_estimators=ensemble_size)
# --- SECTION 3 ---
# Train the ensemble
ensemble.fit(train_x, train_y)
# --- SECTION 4 ---
# Evaluate the ensemble
ensemble_predictions = ensemble.predict(test_x)
ensemble_acc = metrics.accuracy_score(test_y, ensemble_predictions)
# --- SECTION 5 ---
# Print the accuracy
print('Boosting: %.2f' % ensemble_acc)
这导致了在测试集上准确率为 81% 的集成。使用提供的实现的一个优势是,我们可以访问并绘制每个单独的基本学习器的误差和权重。我们可以通过ensemble.estimator_errors_和ensemble.estimator_weights_分别访问它们。通过绘制权重,我们可以评估集成在哪些地方停止从额外的基本学习器中获益。通过创建一个由 1,000 个基本学习器组成的集成,我们可以看到大约从 200 个基本学习器开始,权重已经稳定。因此,再增加超过 200 个基本学习器几乎没有意义。通过事实也得到了进一步证实:1,000 个基本学习器的集成达到了 82% 的准确率,比使用 200 个基本学习器时提高了 1%。

1,000 个基本学习器的集成基本学习器权重
回归实现遵循相同的原理。这里,我们在糖尿病数据集上测试该算法:
# --- SECTION 1 ---
# Libraries and data loading
from copy import deepcopy
from sklearn.datasets import load_diabetes
from sklearn.ensemble import AdaBoostRegressor
from sklearn.tree import DecisionTreeRegressor
from sklearn import metrics
import numpy as np
diabetes = load_diabetes()
train_size = 400
train_x, train_y = diabetes.data[:train_size], diabetes.target[:train_size]
test_x, test_y = diabetes.data[train_size:], diabetes.target[train_size:]
np.random.seed(123456)
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 1000
ensemble = AdaBoostRegressor(n_estimators=ensemble_size)
# --- SECTION 3 ---
# Evaluate the ensemble
ensemble.fit(train_x, train_y)
predictions = ensemble.predict(test_x)
# --- SECTION 4 ---
# Print the metrics
r2 = metrics.r2_score(test_y, predictions)
mse = metrics.mean_squared_error(test_y, predictions)
print('Gradient Boosting:')
print('R-squared: %.2f' % r2)
print('MSE: %.2f' % mse)
集成生成的 R 平方为 0.59,均方误差(MSE)为 2256.5。通过绘制基本学习器的权重,我们可以看到算法由于预测能力的改进微不足道,在第 151 个基本学习器之后提前停止。这可以通过图中的零权重值看出。此外,通过打印ensemble.estimators_的长度,我们观察到它的长度仅为 151。这与我们实现中的base_learners列表等效:

回归 Adaboost 的基本学习器权重
使用梯度提升
Scikit-learn 还实现了梯度提升回归和分类。这两者也被包含在ensemble包中,分别为GradientBoostingRegressor和GradientBoostingClassifier。这两个类在每一步存储误差,保存在对象的train_score_属性中。这里,我们展示了一个糖尿病回归数据集的例子。训练和验证过程遵循 scikit-learn 的标准,使用fit和predict函数。唯一需要指定的参数是学习率,它通过learning_rate参数传递给GradientBoostingRegressor构造函数:
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.datasets import load_diabetes
from sklearn.ensemble import GradientBoostingRegressor
from sklearn import metrics
import numpy as np
diabetes = load_diabetes()
train_size = 400
train_x, train_y = diabetes.data[:train_size], diabetes.target[:train_size]
test_x, test_y = diabetes.data[train_size:], diabetes.target[train_size:]
np.random.seed(123456)
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 200
learning_rate = 0.1
ensemble = GradientBoostingRegressor(n_estimators=ensemble_size,
learning_rate=learning_rate)
# --- SECTION 3 ---
# Evaluate the ensemble
ensemble.fit(train_x, train_y)
predictions = ensemble.predict(test_x)
# --- SECTION 4 ---
# Print the metrics
r2 = metrics.r2_score(test_y, predictions)
mse = metrics.mean_squared_error(test_y, predictions)
print('Gradient Boosting:')
print('R-squared: %.2f' % r2)
print('MSE: %.2f' % mse)
集成模型达到了 0.44 的 R 平方值和 3092 的均方误差(MSE)。此外,如果我们使用 matplotlib 绘制ensemble.train_score_,可以看到大约在 20 个基学习器之后,收益递减现象出现。如果进一步分析误差,通过计算改进(基学习器之间的差异),我们发现,在 25 个基学习器之后,添加新的基学习器可能会导致性能下降。
尽管平均性能持续提高,但在使用 50 个基学习器后,性能没有显著改进。因此,我们重复实验,设定ensemble_size = 50,得到了 0.61 的 R 平方值和 2152 的均方误差(MSE):

梯度提升回归的误差与差异
对于分类示例,我们使用手写数字分类数据集。同样,我们定义了n_estimators和learning_rate参数:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
from sklearn.datasets import load_digits
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import GradientBoostingClassifier
from sklearn import metrics
digits = load_digits()
train_size = 1500
train_x, train_y = digits.data[:train_size], digits.target[:train_size]
test_x, test_y = digits.data[train_size:], digits.target[train_size:]
np.random.seed(123456)
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 200
learning_rate = 0.1
ensemble = GradientBoostingClassifier(n_estimators=ensemble_size,
learning_rate=learning_rate)
# --- SECTION 3 ---
# Train the ensemble
ensemble.fit(train_x, train_y)
# --- SECTION 4 ---
# Evaluate the ensemble
ensemble_predictions = ensemble.predict(test_x)
ensemble_acc = metrics.accuracy_score(test_y, ensemble_predictions)
# --- SECTION 5 ---
# Print the accuracy
print('Boosting: %.2f' % ensemble_acc)
使用特定集成大小达到的准确率为 89%。通过绘制误差及其差异,我们再次看到收益递减现象,但没有出现性能显著下降的情况。因此,我们不期待通过减少集成大小来提高预测性能。
XGBoost
XGBoost 是一个支持并行、GPU 和分布式执行的提升库。它帮助许多机器学习工程师和数据科学家赢得了 Kaggle.com 的竞赛。此外,它提供了一个类似于 scikit-learn 接口的 API。因此,已经熟悉该接口的人可以快速利用这个库。此外,它允许对集成的创建进行非常精细的控制。它支持单调约束(即,预测值应当根据特定特征只增加或减少),以及特征交互约束(例如,如果一个决策树创建了一个按年龄分裂的节点,那么它不应当对该节点的所有子节点使用性别作为分裂特征)。最后,它增加了一个额外的正则化参数 gamma,进一步减少了生成集成模型的过拟合能力。相关论文为 Chen, T. 和 Guestrin, C., 2016 年 8 月,Xgboost: A scalable tree boosting system. 见《第 22 届 ACM SIGKDD 国际知识发现与数据挖掘大会论文集》,(第 785-794 页)。ACM。
使用 XGBoost 进行回归
我们将使用糖尿病数据集展示一个简单的回归示例。正如所示,其使用方法非常简单,类似于 scikit-learn 的分类器。XGBoost 通过XGBRegressor实现回归。该构造函数包含大量参数,并且在官方文档中有详细的说明。在我们的示例中,我们将使用n_estimators、n_jobs、max_depth和learning_rate参数。按照 scikit-learn 的约定,它们分别定义了集成的大小、并行处理的数量、树的最大深度以及学习率:
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.datasets import load_diabetes
from xgboost import XGBRegressor
from sklearn import metrics
import numpy as np
diabetes = load_diabetes()
train_size = 400
train_x, train_y = diabetes.data[:train_size], diabetes.target[:train_size]
test_x, test_y = diabetes.data[train_size:], diabetes.target[train_size:]
np.random.seed(123456)
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 200
ensemble = XGBRegressor(n_estimators=ensemble_size, n_jobs=4,
max_depth=1, learning_rate=0.1,
objective ='reg:squarederror')
其余的代码评估生成的ensemble,与之前的任何示例类似:
# --- SECTION 3 ---
# Evaluate the ensemble
ensemble.fit(train_x, train_y)
predictions = ensemble.predict(test_x)
# --- SECTION 4 ---
# Print the metrics
r2 = metrics.r2_score(test_y, predictions)
mse = metrics.mean_squared_error(test_y, predictions)
print('Gradient Boosting:')
print('R-squared: %.2f' % r2)
print('MSE: %.2f' % mse)
XGBoost 的 R-squared 为 0.65,MSE 为 1932.9,是我们在本章中测试和实现的所有提升方法中表现最好的。此外,我们并未对其任何参数进行微调,这进一步显示了它的建模能力。
使用 XGBoost 进行分类
对于分类任务,相应的类是 XGBClassifier。构造函数的参数与回归实现相同。以我们的示例为例,我们使用的是手写数字分类问题。我们将 n_estimators 参数设置为 100,n_jobs 设置为 4。其余的代码遵循常规模板:
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.datasets import load_digits
from xgboost import XGBClassifier
from sklearn import metrics
import numpy as np
digits = load_digits()
train_size = 1500
train_x, train_y = digits.data[:train_size], digits.target[:train_size]
test_x, test_y = digits.data[train_size:], digits.target[train_size:]
np.random.seed(123456)
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 100
ensemble = XGBClassifier(n_estimators=ensemble_size, n_jobs=4)
# --- SECTION 3 ---
# Train the ensemble
ensemble.fit(train_x, train_y)
# --- SECTION 4 ---
# Evaluate the ensemble
ensemble_predictions = ensemble.predict(test_x)
ensemble_acc = metrics.accuracy_score(test_y, ensemble_predictions)
# --- SECTION 5 ---
# Print the accuracy
print('Boosting: %.2f' % ensemble_acc)
该集成方法以 89% 的准确率正确分类了测试集,也是所有提升算法中表现最好的。
其他提升方法库
另外两个越来越流行的提升方法库是微软的 LightGBM 和 Yandex 的 CatBoost。在某些情况下,这两个库的性能可以与 XGBoost 相媲美(甚至超过)。尽管如此,XGBoost 在所有三者中仍然是最优秀的,无需微调和特殊的数据处理。
总结
本章介绍了最强大的集成学习技术之一——提升方法。我们介绍了两种流行的提升算法,AdaBoost 和梯度提升。我们提供了这两种算法的自定义实现,以及 scikit-learn 实现的使用示例。此外,我们还简要介绍了 XGBoost,这是一个专注于正则化和分布式提升的库。XGBoost 在回归和分类问题中都能超越所有其他方法和实现。
AdaBoost 通过使用弱学习器(略优于随机猜测)来创建多个基础学习器。每个新的基础学习器都在来自原始训练集的加权样本上进行训练。数据集的加权抽样为每个实例分配一个权重,然后根据这些权重从数据集中抽样,以计算每个实例被抽样的概率。
数据权重是基于前一个基础学习器的错误计算的。基础学习器的错误还用于计算学习器的权重。通过投票的方式结合基础学习器的预测结果,投票时使用每个学习器的权重。梯度提升通过训练每个新的基础学习器,使用前一次预测的错误作为目标,来构建其集成方法。初始预测是训练数据集的目标均值。与袋装方法相比,提升方法无法在相同程度上并行化。尽管提升方法对过拟合具有较强的鲁棒性,但它们仍然可能会过拟合。
在 scikit-learn 中,AdaBoost 的实现存储了各个学习器的权重,这些权重可以用来识别额外的基学习器不再对整体集成的预测能力有贡献的点。梯度提升实现在每一步(基学习器)都存储了集成的误差,这也有助于确定最佳的基学习器数量。XGBoost 是一个专注于提升(boosting)的库,具有正则化能力,进一步减少集成模型的过拟合能力。XGBoost 经常成为许多 Kaggle 竞赛中获胜的机器学习模型的一部分。
第七章:随机森林
Bagging 通常用于降低模型的方差。它通过创建一个基础学习器的集成,每个学习器都在原始训练集的独特自助样本上进行训练,从而实现这一目标。这迫使基础学习器之间保持多样性。随机森林在 Bagging 的基础上进行扩展,不仅在每个基础学习器的训练样本上引入随机性,还在特征选择上也引入了随机性。此外,随机森林的性能类似于提升方法,尽管它们不像提升方法那样需要进行大量的精调。
在本章中,我们将提供关于随机森林的基本背景,并讨论该方法的优缺点。最后,我们将展示使用 scikit-learn 实现的使用示例。本章涵盖的主要内容如下:
-
随机森林如何构建基础学习器
-
如何利用随机性来构建更好的随机森林集成模型
-
随机森林的优缺点
-
使用 scikit-learn 实现进行回归和分类
技术要求
你需要具备基本的机器学习技术和算法知识。此外,还需要了解 Python 的约定和语法。最后,熟悉 NumPy 库将极大地帮助读者理解一些自定义算法的实现。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter07
查看以下视频,查看代码的实际应用:bit.ly/2LY5OJR。
理解随机森林树
在本节中,我们将介绍构建基本随机森林树的方法论。虽然有其他方法可以使用,但它们的目标都是一致的:构建多样化的树,作为集成模型的基础学习器。
构建树
如第一章《机器学习回顾》所述,在每个节点选择一个特征和分割点来创建一棵树,以便最佳地划分训练集。当创建一个集成模型时,我们希望基础学习器尽可能地不相关(多样化)。
Bagging 通过引导采样使每棵树的训练集多样化,从而能够生成合理不相关的树。但 bagging 仅通过一个轴进行树的多样化:每个集合的实例。我们仍然可以在第二个轴上引入多样性,即特征。在训练过程中通过选择可用特征的子集,生成的基学习器可以更加多样化。在随机森林中,对于每棵树和每个节点,在选择最佳特征/分裂点组合时,仅考虑可用特征的一个子集。选择的特征数量可以通过手动优化,但回归问题通常选用所有特征的三分之一,而所有特征的平方根被认为是一个很好的起点。
算法的步骤如下:
-
选择在每个节点上将要考虑的特征数量 m
-
对于每个基学习器,执行以下操作:
-
创建引导训练样本
-
选择要拆分的节点
-
随机选择 m 个特征
-
从 m 中选择最佳特征和分裂点
-
将节点拆分为两个节点
-
从步骤 2-2 开始重复,直到满足停止准则,如最大树深度
-
示例说明
为了更好地展示过程,我们考虑以下数据集,表示第一次肩部脱位后是否发生了第二次肩部脱位(复发):
| 年龄 | 手术 | 性别 | 复发 |
|---|---|---|---|
| 15 | y | m | y |
| 45 | n | f | n |
| 30 | y | m | y |
| 18 | n | m | n |
| 52 | n | f | y |
肩部脱位复发数据集
为了构建一个随机森林树,我们必须首先决定在每次分裂时将考虑的特征数量。由于我们有三个特征,我们将使用 3 的平方根,约为 1.7。通常,我们使用该数字的下取整(将其四舍五入到最接近的整数),但为了更好地展示过程,我们将使用两个特征。对于第一棵树,我们生成一个引导样本。第二行是从原始数据集中被选择了两次的实例:
| 年龄 | 手术 | 性别 | 复发 |
|---|---|---|---|
| 15 | y | m | y |
| 15 | y | m | y |
| 30 | y | m | y |
| 18 | n | m | n |
| 52 | n | f | y |
引导样本
接下来,我们创建根节点。首先,我们随机选择两个特征进行考虑。我们选择手术和性别。在手术特征上进行最佳分裂,结果得到一个准确率为 100%的叶子节点和一个准确率为 50%的节点。生成的树如下所示:

第一次分裂后的树
接下来,我们再次随机选择两个特征,并选择提供最佳分裂的特征。我们现在选择手术和年龄。由于两个误分类的实例均未进行手术,因此最佳分裂通过年龄特征来实现。
因此,最终的树是一个具有三个叶子节点的树,其中如果某人做了手术,他们会复发;如果他们没有做手术并且年龄超过 18 岁,则不会复发:
请注意,医学研究表明,年轻男性肩膀脱位复发的几率最高。这里的数据集是一个玩具示例,并不反映现实。

最终的决策树
Extra Trees
创建随机森林集成中的另一种方法是 Extra Trees(极度随机化树)。与前一种方法的主要区别在于,特征和分割点的组合不需要是最优的。相反,多个分割点会被随机生成,每个可用特征生成一个。然后选择这些生成的分割点中的最佳点。该算法构造树的步骤如下:
-
选择每个节点将要考虑的特征数m以及分割节点所需的最小样本数n
-
对于每个基础学习器,执行以下操作:
-
创建一个自助法训练样本
-
选择要分割的节点(该节点必须至少包含n个样本)
-
随机选择m个特征
-
随机生成m个分割点,值介于每个特征的最小值和最大值之间
-
选择这些分割点中的最佳点
-
将节点分割成两个节点,并从步骤 2-2 开始重复,直到没有可用节点为止
-
创建森林
通过使用任何有效的随机化方法创建多棵树,我们基本上就创建了一个森林,这也是该算法名称的由来。在生成集成的树之后,必须将它们的预测结果结合起来,才能形成一个有效的集成。这通常通过分类问题的多数投票法和回归问题的平均法来实现。与随机森林相关的超参数有许多,例如每个节点分割时考虑的特征数、森林中的树木数量以及单棵树的大小。如前所述,考虑的特征数量的一个良好起始点如下:
-
对于分类问题,选择总特征数的平方根
-
对于回归问题,选择总特征数的三分之一
总树的数量可以手动微调,因为随着该数量的增加,集成的误差会收敛到一个极限。可以利用袋外误差来找到最佳值。最后,每棵树的大小可能是过拟合的决定性因素。因此,如果观察到过拟合,应减小树的大小。
分析森林
随机森林提供了许多其他方法无法轻易提供的关于底层数据集的信息。一个突出的例子是数据集中每个特征的重要性。估计特征重要性的一种方法是使用基尼指数计算每棵树的每个节点,并比较每个特征的累计值。另一种方法则使用袋外样本。首先,记录所有基学习器的袋外准确度。然后,选择一个特征,并在袋外样本中打乱该特征的值。这会导致袋外样本集具有与原始集相同的统计特性,但任何可能与目标相关的预测能力都会被移除(因为此时所选特征的值与目标之间的相关性为零)。通过比较原始数据集与部分随机化数据集之间的准确度差异,可以作为评估所选特征重要性的标准。
关于偏差与方差,尽管随机森林似乎能够很好地应对这两者,但它们显然并非完全免疫。当可用特征数量很大,但只有少数与目标相关时,可能会出现偏差。在使用推荐的每次划分时考虑的特征数量(例如,总特征数的平方根)时,相关特征被选中的概率可能较小。以下图表展示了作为相关特征和无关特征函数的情况下,至少选中一个相关特征的概率(当每次划分时考虑总特征数的平方根):

选择至少一个相关特征的概率与相关特征和无关特征数量的关系
基尼指数衡量错误分类的频率,假设随机抽样的实例会根据特定节点所规定的标签分布进行分类。
方差在随机森林中也可能出现,尽管该方法对其有足够的抵抗力。通常,当允许单个树完全生长时,会出现方差。我们之前提到过,随着树木数量的增加,误差会接近某个极限。虽然这一说法依然成立,但该极限本身可能会过拟合数据。在这种情况下,限制树的大小(例如,通过增加每个叶节点的最小样本数或减少最大深度)可能会有所帮助。
优势与劣势
随机森林是一种非常强大的集成学习方法,能够减少偏差和方差,类似于提升方法。此外,该算法的性质使得它在训练和预测过程中都可以完全并行化。这相较于提升方法,尤其是在处理大数据集时,是一个显著的优势。此外,与提升技术(尤其是 XGBoost)相比,随机森林需要更少的超参数微调。
随机森林的主要弱点是它们对类别不平衡的敏感性,以及我们之前提到的问题,即训练集中相关特征和无关特征的比例较低。此外,当数据包含低级非线性模式(例如原始高分辨率图像识别)时,随机森林通常会被深度神经网络超越。最后,当使用非常大的数据集并且树深度没有限制时,随机森林的计算成本可能非常高。
使用 scikit-learn
scikit-learn 实现了传统的随机森林树和 Extra Trees。在本节中,我们将提供使用 scikit-learn 实现的两种算法的基本回归和分类示例。
随机森林分类
随机森林分类类在 RandomForestClassifier 中实现,位于 sklearn.ensemble 包下。它有许多参数,例如集成的大小、最大树深度、构建或拆分节点所需的样本数等。
在这个示例中,我们将尝试使用随机森林分类集成来对手写数字数据集进行分类。像往常一样,我们加载所需的类和数据,并为随机数生成器设置种子:
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.datasets import load_digits
from sklearn.ensemble import RandomForestClassifier
from sklearn import metrics
import numpy as np
digits = load_digits()
train_size = 1500
train_x, train_y = digits.data[:train_size], digits.target[:train_size]
test_x, test_y = digits.data[train_size:], digits.target[train_size:]
np.random.seed(123456)
接下来,我们通过设置 n_estimators 和 n_jobs 参数来创建集成模型。这些参数决定了将生成的树的数量和将要运行的并行作业数。我们使用 fit 函数训练集成,并通过测量其准确率在测试集上进行评估:
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 500
ensemble = RandomForestClassifier(n_estimators=ensemble_size, n_jobs=4)
# --- SECTION 3 ---
# Train the ensemble
ensemble.fit(train_x, train_y)
# --- SECTION 4 ---
# Evaluate the ensemble
ensemble_predictions = ensemble.predict(test_x)
ensemble_acc = metrics.accuracy_score(test_y, ensemble_predictions)
# --- SECTION 5 ---
# Print the accuracy
print('Random Forest: %.2f' % ensemble_acc)
该分类器能够实现 93% 的准确率,甚至高于之前表现最好的方法 XGBoost(见第六章,Boosting)。我们可以通过绘制验证曲线(来自第二章,Getting Started with Ensemble Learning),来可视化我们之前提到的误差极限的近似值。我们测试了 10、50、100、150、200、250、300、350 和 400 棵树的集成大小。曲线如下图所示。我们可以看到,集成模型的 10 倍交叉验证误差接近 96%:

不同集成大小的验证曲线
随机森林回归
Scikit-learn 还在 RandomForestRegressor 类中实现了用于回归的随机森林。它也具有高度的可参数化性,具有与集成整体以及单个树相关的超参数。在这里,我们将生成一个集成模型来对糖尿病回归数据集进行建模。代码遵循加载库和数据、创建集成模型并调用 fit 和 predict 方法的标准过程,同时计算 MSE 和 R² 值:
# --- SECTION 1 ---
# Libraries and data loading
from copy import deepcopy
from sklearn.datasets import load_diabetes
from sklearn.ensemble import RandomForestRegressor
from sklearn import metrics
import numpy as np
diabetes = load_diabetes()
train_size = 400
train_x, train_y = diabetes.data[:train_size], diabetes.target[:train_size]
test_x, test_y = diabetes.data[train_size:], diabetes.target[train_size:]
np.random.seed(123456)
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 100
ensemble = RandomForestRegressor(n_estimators=ensemble_size, n_jobs=4)
# --- SECTION 3 ---
# Evaluate the ensemble
ensemble.fit(train_x, train_y)
predictions = ensemble.predict(test_x)
# --- SECTION 4 ---
# Print the metrics
r2 = metrics.r2_score(test_y, predictions)
mse = metrics.mean_squared_error(test_y, predictions)
print('Random Forest:')
print('R-squared: %.2f' % r2)
print('MSE: %.2f' % mse)
该集成方法能够在测试集上实现 0.51 的 R 方和 2722.67 的 MSE。由于训练集上的 R 方和 MSE 分别为 0.92 和 468.13,因此可以合理推断该集成方法存在过拟合。这是一个误差限制过拟合的例子,因此我们需要调节单个树木以获得更好的结果。通过减少每个叶节点所需的最小样本数(将其从默认值 2 增加到 20)通过 min_samples_leaf=20,我们能够将 R 方提高到 0.6,并将 MSE 降低到 2206.6。此外,通过将集成大小增加到 1000,R 方进一步提高到 0.61,MSE 进一步降低到 2158.73。
Extra Trees 用于分类
除了传统的随机森林,scikit-learn 还实现了 Extra Trees。分类实现位于 ExtraTreesClassifier,在 sklearn.ensemble 包中。这里,我们重复手写数字识别的例子,使用 Extra Trees 分类器:
# --- SECTION 1 ---
# Libraries and data loading
from sklearn.datasets import load_digits
from sklearn.ensemble import ExtraTreesClassifier
from sklearn import metrics
import numpy as np
digits = load_digits()
train_size = 1500
train_x, train_y = digits.data[:train_size], digits.target[:train_size]
test_x, test_y = digits.data[train_size:], digits.target[train_size:]
np.random.seed(123456)
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 500
ensemble = ExtraTreesClassifier(n_estimators=ensemble_size, n_jobs=4)
# --- SECTION 3 ---
# Train the ensemble
ensemble.fit(train_x, train_y)
# --- SECTION 4 ---
# Evaluate the ensemble
ensemble_predictions = ensemble.predict(test_x)
ensemble_acc = metrics.accuracy_score(test_y, ensemble_predictions)
# --- SECTION 5 ---
# Print the accuracy
print('Extra Tree Forest: %.2f' % ensemble_acc)
如您所见,唯一的不同之处在于将 RandomForestClassifier 切换为 ExtraTreesClassifier。尽管如此,该集成方法仍然实现了更高的测试准确率,达到了 94%。我们再次为多个集成大小创建了验证曲线,结果如下所示。该集成方法的 10 折交叉验证误差限制大约为 97%,进一步确认了它优于传统的随机森林方法:

Extra Trees 在多个集成大小下的验证曲线
Extra Trees 回归
最后,我们展示了 Extra Trees 的回归实现,位于 ExtraTreesRegressor 中。在以下代码中,我们重复之前展示的使用 Extra Trees 回归版本对糖尿病数据集建模的示例:
# --- SECTION 1 ---
# Libraries and data loading
from copy import deepcopy
from sklearn.datasets import load_diabetes
from sklearn.ensemble import ExtraTreesRegressor
from sklearn import metrics
import numpy as np
diabetes = load_diabetes()
train_size = 400
train_x, train_y = diabetes.data[:train_size], diabetes.target[:train_size]
test_x, test_y = diabetes.data[train_size:], diabetes.target[train_size:]
np.random.seed(123456)
# --- SECTION 2 ---
# Create the ensemble
ensemble_size = 100
ensemble = ExtraTreesRegressor(n_estimators=ensemble_size, n_jobs=4)
# --- SECTION 3 ---
# Evaluate the ensemble
ensemble.fit(train_x, train_y)
predictions = ensemble.predict(test_x)
# --- SECTION 4 ---
# Print the metrics
r2 = metrics.r2_score(test_y, predictions)
mse = metrics.mean_squared_error(test_y, predictions)
print('Extra Trees:')
print('R-squared: %.2f' % r2)
print('MSE: %.2f' % mse)
与分类示例类似,Extra Trees 通过实现 0.55 的测试 R 方(比随机森林高 0.04)和 2479.18 的 MSE(差异为 243.49)来超越传统的随机森林。不过,集成方法似乎仍然出现过拟合,因为它能够完美预测样本内数据。通过设置 min_samples_leaf=10 和将集成大小设置为 1000,我们能够使 R 方达到 0.62,MSE 降低到 2114。
总结
在本章中,我们讨论了随机森林,这是一种利用决策树作为基本学习器的集成方法。我们介绍了两种构建树的基本方法:传统的随机森林方法,其中每次分裂时考虑特征的子集,以及 Extra Trees 方法,在该方法中,分裂点几乎是随机选择的。我们讨论了集成方法的基本特征。此外,我们还展示了使用 scikit-learn 实现的随机森林和 Extra Trees 的回归和分类示例。本章的关键点总结如下。
随机森林使用装袋技术来为其基学习器创建训练集。在每个节点,每棵树只考虑一部分可用特征,并计算最佳特征/分割点组合。每个点考虑的特征数量是一个必须调整的超参数。良好的起点如下:
-
分类问题的总参数平方根
-
回归问题的总参数的三分之一
极端随机树和随机森林对每个基学习器使用整个数据集。在极端随机树和随机森林中,每个特征子集的每个节点不再计算最佳特征/分割点组合,而是为子集中的每个特征生成一个随机分割点,并选择最佳的。随机森林可以提供关于每个特征重要性的信息。虽然相对抗过拟合,但随机森林并非免疫。当相关特征与不相关特征的比例较低时,随机森林可能表现出高偏差。随机森林可能表现出高方差,尽管集成规模并不会加剧问题。在下一章中,我们将介绍可以应用于无监督学习方法(聚类)的集成学习技术。
第四部分:聚类
本节将介绍集成方法在聚类应用中的使用。
本节包含以下章节:
- 第八章,聚类
第八章:聚类
其中一种最广泛使用的无监督学习方法是聚类。聚类旨在揭示未标记数据中的结构。其目标是将数据实例分组,使得同一聚类中的实例之间相似度高,而不同聚类之间的实例相似度低。与有监督学习方法类似,聚类也能通过结合多个基本学习器来受益。在本章中,我们将介绍 K-means 聚类算法;这是一种简单且广泛使用的聚类算法。此外,我们还将讨论如何通过集成方法来提升该算法的性能。最后,我们将使用 OpenEnsembles,这是一个兼容 scikit-learn 的 Python 库,能够实现集成聚类。本章的主要内容如下:
-
K-means 算法的工作原理
-
优势与劣势
-
集成方法如何提升其性能
-
使用 OpenEnsembles 创建聚类集成方法
技术要求
你需要具备机器学习技术和算法的基础知识。此外,还需要了解 Python 的约定和语法。最后,熟悉 NumPy 库将极大帮助读者理解一些自定义算法实现。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter08
查看以下视频,了解代码的实际操作:bit.ly/2YYzniq。
共识聚类
共识聚类是集成学习在聚类方法中应用时的别名。在聚类中,每个基本学习器都会为每个实例分配一个标签,尽管这个标签并不依赖于特定的目标。相反,基本学习器会生成多个聚类,并将每个实例分配到一个聚类中。标签就是聚类本身。如后面所示,由同一算法生成的两个基本学习器可能会生成不同的聚类。因此,将它们的聚类预测结果结合起来并不像回归或分类预测结果的结合那么直接。
层次聚类
层次聚类最初会根据数据集中实例的数量创建相同数量的聚类。每个聚类仅包含一个实例。之后,算法会反复找到两个距离最小的聚类(例如,欧几里得距离),并将它们合并为一个新的聚类。直到只剩下一个聚类时,过程结束。该方法的输出是一个树状图,展示了实例是如何按层次组织的。以下图示为例:

树状图示例
K-means 聚类
K-means 是一种相对简单有效的聚类数据的方法。其主要思想是,从K个点开始作为初始聚类中心,然后将每个实例分配给最近的聚类中心。接着,重新计算这些中心,作为各自成员的均值。这个过程会重复,直到聚类中心不再变化。主要步骤如下:
-
选择聚类的数量,K
-
选择K个随机实例作为初始聚类中心
-
将每个实例分配给最近的聚类中心
-
重新计算聚类中心,作为每个聚类成员的均值
-
如果新的中心与上一个不同,则返回到步骤 3
如下所示是一个图形示例。经过四次迭代,算法收敛:

对一个玩具数据集进行的前四次迭代。星号表示聚类中心
优势与劣势
K-means 是一个简单的算法,既容易理解,也容易实现。此外,它通常会比较快速地收敛,所需的计算资源较少。然而,它也有一些缺点。第一个缺点是对初始条件的敏感性。根据选择作为初始聚类中心的样本,可能需要更多的迭代才能收敛。例如,在以下图示中,我们呈现了三个初始点,它使得算法处于不利位置。事实上,在第三次迭代中,两个聚类中心恰好重合:

一个不幸的初始聚类中心的示例
因此,算法不会确定性地生成聚类。另一个主要问题是聚类的数量。这是一个需要数据分析师选择的参数。通常这个问题有三种不同的解决方案。第一种是针对一些有先验知识的问题。例如,数据集需要揭示一些已知事物的结构,比如,如何根据运动员的统计数据,找出哪些因素导致他们在一个赛季中表现的提高?在这个例子中,运动教练可能会建议,运动员的表现实际上要么大幅提升,要么保持不变,要么恶化。因此,分析师可以选择 3 作为聚类的数量。另一种可能的解决方案是通过实验不同的K值,并衡量每个值的适用性。这种方法不需要关于问题领域的任何先验知识,但引入了衡量每个解决方案适用性的问题。我们将在本章的其余部分看到如何解决这些问题。
使用 scikit-learn
scikit-learn 提供了多种可用的聚类技术。在这里,我们简要介绍如何使用 K-means 算法。该算法在 KMeans 类中实现,属于 sklearn.cluster 包。该包包含了 scikit-learn 中所有可用的聚类算法。在本章中,我们将主要使用 K-means,因为它是最直观的算法之一。此外,本章中使用的技术可以应用于几乎所有聚类算法。在本次实验中,我们将尝试对乳腺癌数据进行聚类,以探索区分恶性病例和良性病例的可能性。为了更好地可视化结果,我们将首先进行 t-分布随机邻域嵌入(t-SNE)分解,并使用二维嵌入作为特征。接下来,我们先加载所需的数据和库,并设置 NumPy 随机数生成器的种子:
你可以在 lvdmaaten.github.io/tsne/ 阅读更多关于 t-SNE 的内容。
import matplotlib.pyplot as plt
import numpy as np
from sklearn.cluster import KMeans
from sklearn.datasets import load_breast_cancer
from sklearn.manifold import TSNE
np.random.seed(123456)
bc = load_breast_cancer()
接下来,我们实例化 t-SNE,并转换我们的数据。我们绘制数据,以便直观检查和审视数据结构:
data = tsne.fit_transform(bc.dataa)
reds = bc.target == 0
blues = bc.target == 1
plt.scatter(data[reds, 0], data[reds, 1], label='malignant')
plt.scatter(data[blues, 0], data[blues, 1], label='benign')
plt.xlabel('1st Component')
plt.ylabel('2nd Component')
plt.title('Breast Cancer dataa')
plt.legend()
上述代码生成了以下图表。我们观察到两个不同的区域。蓝色点所代表的区域表示嵌入值,暗示着肿瘤是恶性的风险较高:

乳腺癌数据的两个嵌入(成分)图
由于我们已经识别出数据中存在某些结构,因此我们将尝试使用 K-means 聚类来进行建模。直觉上,我们假设两个簇就足够了,因为我们试图分离两个不同的区域,并且我们知道数据集有两个类别。尽管如此,我们还将尝试使用四个和六个簇,因为它们可能能提供更多的数据洞察。我们将通过衡量每个类别在每个簇中的分布比例来评估簇的质量。为此,我们通过填充 classified 字典来实现。每个键对应一个簇。每个键还指向一个二级字典,记录了特定簇中恶性和良性病例的数量。此外,我们还会绘制簇分配图,因为我们想看到数据在簇之间的分布情况:
plt.figure()
plt.title('2, 4, and 6 clusters.')
for clusters in [2, 4, 6]:
km = KMeans(n_clusters=clusters)
preds = km.fit_predict(data)
plt.subplot(1, 3, clusters/2)
plt.scatter(*zip(*data), c=preds)
classified = {x: {'m': 0, 'b': 0} for x in range(clusters)}
for i in range(len(data)):
cluster = preds[i]
label = bc.target[i]
label = 'm' if label == 0 else 'b'
classified[cluster][label] = classified[cluster][label]+1
print('-'*40)
for c in classified:
print('Cluster %d. Malignant percentage: ' % c, end=' ')
print(classified[c], end=' ')
print('%.3f' % (classified[c]['m'] /
(classified[c]['m'] + classified[c]['b'])))
结果显示在下表和图中:
| 簇 | 恶性 | 良性 | 恶性百分比 |
|---|---|---|---|
| 2 个簇 | |||
| 0 | 206 | 97 | 0.68 |
| 1 | 6 | 260 | 0.023 |
| 4 个簇 | |||
| 0 | 2 | 124 | 0.016 |
| 1 | 134 | 1 | 0.993 |
| 2 | 72 | 96 | 0.429 |
| 3 | 4 | 136 | 0.029 |
| 6 个簇 | |||
| 0 | 2 | 94 | 0.021 |
| 1 | 81 | 10 | 0.89 |
| 2 | 4 | 88 | 0.043 |
| 3 | 36 | 87 | 0.0293 |
| 4 | 0 | 78 | 0 |
| 5 | 89 | 0 | 1 |
恶性和良性病例在簇中的分布
我们观察到,尽管算法没有关于标签的信息,它仍然能够相当有效地分离属于每个类别的实例:

每个实例的簇分配;2、4 和 6 个簇
此外,我们看到,随着簇数的增加,分配到恶性或良性簇的实例数量没有增加,但这些区域的分离性更强。这使得粒度更细,可以更准确地预测所选实例属于哪个类别的概率。如果我们在不转换数据的情况下重复实验,得到以下结果:
| 簇 | 恶性 | 良性 | 恶性百分比 |
|---|---|---|---|
| 2 个簇 | |||
| 0 | 82 | 356 | 0.187 |
| 1 | 130 | 1 | 0.992 |
| 4 个簇 | |||
| 0 | 6 | 262 | 0.022 |
| 1 | 100 | 1 | 0.99 |
| 2 | 19 | 0 | 1 |
| 3 | 87 | 94 | 0.481 |
| 6 个簇 | |||
| 0 | 37 | 145 | 0.203 |
| 1 | 37 | 0 | 1 |
| 2 | 11 | 0 | 1 |
| 3 | 62 | 9 | 0.873 |
| 4 | 5 | 203 | 0.024 |
| 5 | 60 | 0 | 1 |
没有 t-SNE 转换的数据聚类结果
还有两种度量可以用来确定聚类质量。对于已知真实标签的数据(本质上是有标签的数据),同质性度量每个簇中由单一类别主导的比例。对于没有已知真实标签的数据,轮廓系数度量簇内的凝聚力和簇间的可分离性。这些度量在 scikit-learn 的 metrics 包中由 silhouette_score 和 homogeneity_score 函数实现。每种方法的两个度量如以下表格所示。同质性对于转换后的数据较高,但轮廓系数较低。
这是预期的,因为转换后的数据只有两个维度,因此实例之间的可能距离变小:
| 度量 | 簇 | 原始数据 | 转换后的数据 |
|---|---|---|---|
| 同质性 | 2 | 0.422 | 0.418 |
| 4 | 0.575 | 0.603 | |
| 6 | 0.620 | 0.648 | |
| 轮廓系数 | 2 | 0.697 | 0.500 |
| 4 | 0.533 | 0.577 | |
| 6 | 0.481 | 0.555 |
原始数据和转换数据的同质性与轮廓系数
使用投票
投票可以用来结合同一数据集的不同聚类结果。这类似于监督学习中的投票,每个模型(基础学习器)通过投票对最终结果做出贡献。在此出现了一个问题:如何链接来自两个不同聚类的簇。由于每个模型会生成不同的簇和不同的中心,我们需要将来自不同模型的相似簇链接起来。通过将共享最多实例的簇链接在一起,可以实现这一点。例如,假设对于某个特定数据集,发生了以下的聚类表格和图形:

三种不同的聚类结果
下表展示了每个实例在三种不同聚类中的簇分配情况。
| 实例 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| 聚类 1 | 0 | 0 | 2 | 2 | 2 | 0 | 0 | 1 | 0 | 2 |
| 聚类 2 | 1 | 1 | 2 | 2 | 2 | 1 | 0 | 1 | 1 | 2 |
| 聚类 3 | 0 | 0 | 2 | 2 | 2 | 1 | 0 | 1 | 1 | 2 |
每个实例的簇成员资格
使用之前的映射,我们可以计算每个实例的共现矩阵。该矩阵表示一对实例被分配到同一个簇的次数:
| 实例 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 3 | 3 | 0 | 0 | 0 | 2 | 2 | 1 | 2 | 0 |
| 2 | 3 | 3 | 0 | 0 | 0 | 2 | 2 | 1 | 2 | 0 |
| 3 | 0 | 0 | 3 | 3 | 3 | 0 | 0 | 0 | 0 | 3 |
| 4 | 0 | 0 | 3 | 3 | 3 | 0 | 0 | 0 | 0 | 3 |
| 5 | 0 | 0 | 3 | 3 | 3 | 0 | 0 | 0 | 0 | 3 |
| 6 | 2 | 2 | 0 | 0 | 0 | 3 | 1 | 0 | 3 | 0 |
| 7 | 2 | 2 | 0 | 0 | 0 | 1 | 3 | 0 | 1 | 0 |
| 8 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 3 | 2 | 0 |
| 9 | 2 | 2 | 0 | 0 | 0 | 3 | 1 | 2 | 3 | 0 |
| 10 | 0 | 0 | 3 | 3 | 3 | 0 | 0 | 0 | 0 | 3 |
上述示例的共现矩阵
通过将每个元素除以基础学习器的数量,并将值大于 0.5 的样本聚集在一起,我们得到了以下的簇分配:
| 实例 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
|---|---|---|---|---|---|---|---|---|---|---|
| 投票聚类 | 0 | 0 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 1 |
投票聚类成员资格
如所示,聚类结果更为稳定。进一步观察可以发现,两个簇对于这个数据集来说是足够的。通过绘制数据及其簇成员资格,我们可以看到有两个明显的组别,这正是投票集成所能建模的,尽管每个基础学习器生成了三个不同的聚类中心:

投票集成的最终簇成员资格
使用 OpenEnsembles
OpenEnsembles 是一个专注于聚类集成方法的 Python 库。在本节中,我们将展示如何使用它并利用它对我们的示例数据集进行聚类。为了安装该库,必须在终端执行 pip install openensembles 命令。尽管它依赖于 scikit-learn,但其接口不同。一个主要的区别是数据必须作为 data 类传递,该类由 OpenEnsembles 实现。构造函数有两个输入参数:一个包含数据的 pandas DataFrame 和一个包含特征名称的列表:
# --- SECTION 1 ---
# Libraries and data loading
import openensembles as oe
import pandas as pd
import sklearn.metrics
from sklearn.datasets import load_breast_cancer
bc = load_breast_cancer()
# --- SECTION 2 ---
# Create the data object
cluster_data = oe.data(pd.DataFrame(bc.data), bc.feature_names)
为了创建一个cluster集成,创建一个cluster类对象,并将数据作为参数传入:
ensemble = oe.cluster(cluster_data)
在这个例子中,我们将计算多个K值和集成大小的同质性得分。为了将一个基学习器添加到集成中,必须调用cluster类的cluster方法。该方法接受以下参数:source_name,表示源数据矩阵的名称,algorithm,决定基学习器将使用的算法,output_name,将作为字典键来访问特定基学习器的结果,和K,表示特定基学习器的簇数。最后,为了通过多数投票计算最终的簇成员,必须调用finish_majority_vote方法。唯一必须指定的参数是threshold值:
# --- SECTION 3 ---
# Create the ensembles and calculate the homogeneity score
for K in [2, 3, 4, 5, 6, 7]:
for ensemble_size in [3, 4, 5]:
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_majority_vote(threshold=0.5)
print(f'K: {K}, size {ensemble_size}:', end=' ')
print('%.2f' % sklearn.metrics.homogeneity_score(
bc.target, preds.labels['majority_vote']))
显然,五个簇对于所有三种集成大小来说都产生了最佳结果。结果总结如下表所示:
| K | 大小 | 同质性 |
|---|---|---|
| 2 | 3 | 0.42 |
| 2 | 4 | 0.42 |
| 2 | 5 | 0.42 |
| 3 | 3 | 0.45 |
| 3 | 4 | 0.47 |
| 3 | 5 | 0.47 |
| 4 | 3 | 0.58 |
| 4 | 4 | 0.58 |
| 4 | 5 | 0.58 |
| 5 | 3 | 0.6 |
| 5 | 4 | 0.61 |
| 5 | 5 | 0.6 |
| 6 | 3 | 0.35 |
| 6 | 4 | 0.47 |
| 6 | 5 | 0.35 |
| 7 | 3 | 0.27 |
| 7 | 4 | 0.63 |
| 7 | 5 | 0.37 |
OpenEnsembles 胸癌数据集的多数投票簇同质性
如果我们将数据转换为两个 t-SNE 嵌入,并重复实验,则得到以下同质性得分:
| K | 大小 | 同质性 |
|---|---|---|
| 2 | 3 | 0.42 |
| 2 | 4 | 0.42 |
| 2 | 5 | 0.42 |
| 3 | 3 | 0.59 |
| 3 | 4 | 0.59 |
| 3 | 5 | 0.59 |
| 4 | 3 | 0.61 |
| 4 | 4 | 0.61 |
| 4 | 5 | 0.61 |
| 5 | 3 | 0.61 |
| 5 | 4 | 0.61 |
| 5 | 5 | 0.61 |
| 6 | 3 | 0.65 |
| 6 | 4 | 0.65 |
| 6 | 5 | 0.65 |
| 7 | 3 | 0.66 |
| 7 | 4 | 0.66 |
| 7 | 5 | 0.66 |
转换后的胸癌数据集的多数投票簇同质性
使用图闭合和共现链路
还有两种可以用来组合簇结果的方法,分别是图闭合和共现链路。这里,我们展示了如何使用 OpenEnsembles 创建这两种类型的集成。
图闭合
图闭包通过共现矩阵创建图形。每个元素(实例对)都被视为一个节点。具有大于阈值的对将通过边连接。接下来,根据指定的大小(由团内节点的数量指定),会发生团的形成。团是图的节点的子集,每两个节点之间都有边连接。最后,团会组合成独特的聚类。在 OpenEnsembles 中,它通过finish_graph_closure函数在cluster类中实现。clique_size参数确定每个团中的节点数量。threshold参数确定一对实例必须具有的最小共现值,以便通过图中的边连接。与之前的示例类似,我们将使用图闭包来对乳腺癌数据集进行聚类。请注意,代码中唯一的变化是使用finish_graph_closure,而不是finish_majority_vote。首先,我们加载库和数据集,并创建 OpenEnsembles 数据对象:
# --- SECTION 1 ---
# Libraries and data loading
import openensembles as oe
import pandas as pd
import sklearn.metrics
from sklearn.datasets import load_breast_cancer
bc = load_breast_cancer()
# --- SECTION 2 ---
# Create the data object
cluster_data = oe.data(pd.DataFrame(bc.data), bc.feature_names)
然后,我们创建集成并使用graph_closure来组合聚类结果。请注意,字典的键也更改为'graph_closure':
# --- SECTION 3 ---
# Create the ensembles and calculate the homogeneity score
for K in [2, 3, 4, 5, 6, 7]:
for ensemble_size in [3, 4, 5]:
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_majority_vote(threshold=0.5)
print(f'K: {K}, size {ensemble_size}:', end=' ')
print('%.2f' % sklearn.metrics.homogeneity_score(
bc.target, preds.labels['majority_vote']))
K和集成大小对聚类质量的影响类似于多数投票,尽管它没有达到相同的性能水平。结果如以下表所示:
| K | 大小 | 同质性 |
|---|---|---|
| 2 | 3 | 0.42 |
| 2 | 4 | 0.42 |
| 2 | 5 | 0.42 |
| 3 | 3 | 0.47 |
| 3 | 4 | 0 |
| 3 | 5 | 0.47 |
| 4 | 3 | 0.58 |
| 4 | 4 | 0.58 |
| 4 | 5 | 0.58 |
| 5 | 3 | 0.6 |
| 5 | 4 | 0.5 |
| 5 | 5 | 0.5 |
| 6 | 3 | 0.6 |
| 6 | 4 | 0.03 |
| 6 | 5 | 0.62 |
| 7 | 3 | 0.63 |
| 7 | 4 | 0.27 |
| 7 | 5 | 0.27 |
图闭包聚类在原始乳腺癌数据上的同质性
共现矩阵连接
共现矩阵连接将共现矩阵视为实例之间的距离矩阵,并利用这些距离执行层次聚类。当矩阵中没有元素的值大于阈值时,聚类过程停止。再次,我们重复示例。我们使用finish_co_occ_linkage函数,利用threshold=0.5执行共现矩阵连接,并使用'co_occ_linkage'键来访问结果:
# --- SECTION 1 ---
# Libraries and data loading
import openensembles as oe
import pandas as pd
import sklearn.metrics
from sklearn.datasets import load_breast_cancer
bc = load_breast_cancer()
# --- SECTION 2 ---
# Create the data object
cluster_data = oe.data(pd.DataFrame(bc.data), bc.feature_names)
# --- SECTION 3 ---
# Create the ensembles and calculate the homogeneity score
for K in [2, 3, 4, 5, 6, 7]:
for ensemble_size in [3, 4, 5]:
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=' ')
print('%.2f' % sklearn.metrics.homogeneity_score(
bc.target, preds.labels['co_occ_linkage']))
以下表总结了结果。请注意,它优于其他两种方法。此外,结果更加稳定,并且所需执行时间比其他两种方法少:
| K | 大小 | 同质性 |
|---|---|---|
| 2 | 3 | 0.42 |
| 2 | 4 | 0.42 |
| 2 | 5 | 0.42 |
| 3 | 3 | 0.47 |
| 3 | 4 | 0.47 |
| 3 | 5 | 0.45 |
| 4 | 3 | 0.58 |
| 4 | 4 | 0.58 |
| 4 | 5 | 0.58 |
| 5 | 3 | 0.6 |
| 5 | 4 | 0.6 |
| 5 | 5 | 0.6 |
| 6 | 3 | 0.59 |
| 6 | 4 | 0.62 |
| 6 | 5 | 0.62 |
| 7 | 3 | 0.62 |
| 7 | 4 | 0.63 |
| 7 | 5 | 0.63 |
原始乳腺癌数据集上共现聚类连接的同质性结果
小结
本章介绍了 K-means 聚类算法和聚类集成方法。我们解释了如何使用多数投票方法来结合集成中的聚类分配,并如何使其超越单个基础学习器。此外,我们还介绍了专门用于聚类集成的 OpenEnsembles Python 库。本章可以总结如下。
K-means 创建 K 个聚类,并通过迭代地将每个实例分配到各个聚类中,使得每个聚类的中心成为其成员的均值。它对初始条件和选定的聚类数目敏感。多数投票可以帮助克服该算法的缺点。多数投票 将具有高共现的实例聚集在一起。共现矩阵 显示了一对实例被同一基础学习器分配到同一聚类的频率。图闭包 使用共现矩阵来创建图,并基于团簇对数据进行聚类。共现连接 使用一种特定的聚类算法——层次聚类(聚合型),将共现矩阵视为成对距离矩阵。在下一章中,我们将尝试利用本书中介绍的所有集成学习技术,以对欺诈信用卡交易进行分类。
第五部分:现实世界的应用
在本节中,我们将介绍集成学习在各种实际机器学习任务中的应用。
本节包括以下章节:
-
第九章,分类欺诈交易
-
第十章,预测比特币价格
-
第十一章,评估 Twitter 上的情感
-
第十二章,使用 Keras 推荐电影
-
第十三章,聚类世界幸福感
第九章:分类欺诈交易
在本章中,我们将尝试对 2013 年 9 月期间发生的欧洲信用卡持有者的信用卡交易数据集进行欺诈交易分类。该数据集的主要问题是,欺诈交易的数量非常少,相比数据集的规模几乎可以忽略不计。这类数据集被称为不平衡数据集,因为每个标签的百分比不相等。我们将尝试创建能够分类我们特定数据集的集成方法,该数据集包含极少数的欺诈交易。
本章将涵盖以下主题:
-
熟悉数据集
-
探索性分析
-
投票法
-
堆叠
-
自助法
-
提升法
-
使用随机森林
-
集成方法的比较分析
技术要求
你需要具备机器学习技术和算法的基础知识。此外,还需要了解 Python 的约定和语法。最后,熟悉 NumPy 库将有助于读者理解一些自定义算法的实现。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter09
请查看以下视频,了解代码的实际应用:bit.ly/2ShwarF.
熟悉数据集
该数据集最初在 Andrea Dal Pozzolo 的博士论文《用于信用卡欺诈检测的自适应机器学习》中使用,现已由其作者公开发布供公众使用(www.ulb.ac.be/di/map/adalpozz/data/creditcard.Rdata)。该数据集包含超过 284,000 个实例,但其中只有 492 个欺诈实例(几乎为 0.17%)。
目标类别值为 0 时表示交易不是欺诈,1 时表示交易是欺诈。该数据集的特征是一些主成分,因为数据集已通过主成分分析(PCA)进行转化,以保留数据的机密性。数据集的特征包含 28 个 PCA 组件,以及交易金额和从数据集中的第一次交易到当前交易的时间。以下是数据集的描述性统计:
| 特征 | 时间 | V1 | V2 | V3 | V4 |
|---|---|---|---|---|---|
| 计数 | 284,807 | 284,807 | 284,807 | 284,807 | 284,807 |
| 均值 | 94,813.86 | 1.17E-15 | 3.42E-16 | -1.37E-15 | 2.09E-15 |
| 标准差 | 47,488.15 | 1.96 | 1.65 | 1.52 | 1.42 |
| 最小值 | 0.00 | -56.41 | -72.72 | -48.33 | -5.68 |
| 最大值 | 172,792.00 | 2.45 | 22.06 | 9.38 | 16.88 |
| 特征 | V5 | V6 | V7 | V8 | V9 |
| 计数 | 284,807 | 284,807 | 284,807 | 284,807 | 284,807 |
| 均值 | 9.60E-16 | 1.49E-15 | -5.56E-16 | 1.18E-16 | -2.41E-15 |
| 标准差 | 1.38 | 1.33 | 1.24 | 1.19 | 1.10 |
| 最小值 | -113.74 | -26.16 | -43.56 | -73.22 | -13.43 |
| 最大值 | 34.80 | 73.30 | 120.59 | 20.01 | 15.59 |
| 特征 | V10 | V11 | V12 | V13 | V14 |
| 计数 | 284,807 | 284,807 | 284,807 | 284,807 | 284,807 |
| 均值 | 2.24E-15 | 1.67E-15 | -1.25E-15 | 8.18E-16 | 1.21E-15 |
| 标准差 | 1.09 | 1.02 | 1.00 | 1.00 | 0.96 |
| 最小值 | -24.59 | -4.80 | -18.68 | -5.79 | -19.21 |
| 最大值 | 23.75 | 12.02 | 7.85 | 7.13 | 10.53 |
| 特征 | V15 | V16 | V17 | V18 | V19 |
| 计数 | 284,807 | 284,807 | 284,807 | 284,807 | 284,807 |
| 均值 | 4.91E-15 | 1.44E-15 | -3.80E-16 | 9.57E-16 | 1.04E-15 |
| 标准差 | 0.92 | 0.88 | 0.85 | 0.84 | 0.81 |
| 最小值 | -4.50 | -14.13 | -25.16 | -9.50 | -7.21 |
| 最大值 | 8.88 | 17.32 | 9.25 | 5.04 | 5.59 |
| 特征 | V20 | V21 | V22 | V23 | V24 |
| 计数 | 284,807 | 284,807 | 284,807 | 284,807 | 284,807 |
| 均值 | 6.41E-16 | 1.66E-16 | -3.44E-16 | 2.58E-16 | 4.47E-15 |
| 标准差 | 0.77 | 0.73 | 0.73 | 0.62 | 0.61 |
| 最小值 | -54.50 | -34.83 | -10.93 | -44.81 | -2.84 |
| 最大值 | 39.42 | 27.20 | 10.50 | 22.53 | 4.58 |
| 特征 | V25 | V26 | V27 | V28 | 金额 |
| 计数 | 284,807 | 284,807 | 284,807 | 284,807 | 284,807 |
| 均值 | 5.34E-16 | 1.69E-15 | -3.67E-16 | -1.22E-16 | 88.34962 |
| 标准差 | 0.52 | 0.48 | 0.40 | 0.33 | 250.12 |
| 最小值 | -10.30 | -2.60 | -22.57 | -15.43 | 0.00 |
| 最大值 | 7.52 | 3.52 | 31.61 | 33.85 | 25,691.16 |
信用卡交易数据集的描述性统计
探索性分析
数据集的一个重要特点是没有缺失值,这一点可以从计数统计中看出。所有特征都有相同数量的值。另一个重要方面是大多数特征已经进行了归一化处理。这是因为数据应用了 PCA(主成分分析)。PCA 在将数据分解为主成分之前会先进行归一化处理。唯一两个没有进行归一化的特征是时间和金额。以下是每个特征的直方图:

数据集特征的直方图
通过更加细致地观察每笔交易的时间和金额,我们发现,在第一次交易后的 75,000 秒到 125,000 秒之间,交易频率出现了突然下降(大约 13 小时)。这很可能是由于日常时间周期(例如,夜间大多数商店关闭)。每笔交易金额的直方图如下所示,采用对数尺度。可以明显看出,大部分交易金额较小,平均值接近 88.00 欧元:

金额直方图,对数尺度的y-轴
为了避免特征之间的权重分布不均问题,我们将对金额和时间这两个特征进行标准化。比如使用距离度量的算法(如 K 最近邻算法)在特征未正确缩放时,性能可能会下降。以下是标准化特征的直方图。请注意,标准化将变量转换为均值接近 0,标准差为 1:

标准化金额直方图
以下图表显示了标准化时间的直方图。我们可以看到,它并未影响夜间交易量的下降:

标准化时间直方图
评估方法
由于我们的数据集高度倾斜(即具有较高的类别不平衡),我们不能仅仅通过准确率来评估模型的表现。因为如果将所有实例都分类为非欺诈行为,我们的准确率可以达到 99.82%。显然,这个数字并不代表一个可接受的表现,因为我们根本无法检测到任何欺诈交易。因此,为了评估我们的模型,我们将使用召回率(即我们检测到的欺诈行为的百分比)和 F1 得分,后者是召回率和精确度的加权平均值(精确度衡量的是预测为欺诈的交易中,实际为欺诈的比例)。
投票
在这一部分,我们将尝试通过使用投票集成方法来对数据集进行分类。对于我们的初步集成方法,我们将利用朴素贝叶斯分类器、逻辑回归和决策树。这个过程将分为两部分,首先测试每个基础学习器本身,然后将这些基础学习器组合成一个集成模型。
测试基础学习器
为了测试基础学习器,我们将单独对基础学习器进行基准测试,这将帮助我们评估它们单独表现的好坏。为此,首先加载库和数据集,然后将数据划分为 70%的训练集和 30%的测试集。我们使用pandas来轻松导入 CSV 文件。我们的目标是在训练和评估整个集成模型之前,先训练和评估每个单独的基础学习器:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
import pandas as pd
from sklearn.tree import DecisionTreeClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.naive_bayes import GaussianNB
from sklearn.model_selection import train_test_split
from sklearn import metrics
np.random.seed(123456)
data = pd.read_csv('creditcard.csv')
data.Time = (data.Time-data.Time.min())/data.Time.std()
data.Amount = (data.Amount-data.Amount.mean())/data.Amount.std()
# Train-Test slpit of 70%-30%
x_train, x_test, y_train, y_test = train_test_split(
data.drop('Class', axis=1).values, data.Class.values, test_size=0.3)
在加载库和数据后,我们训练每个分类器,并打印出来自sklearn.metrics包的必要指标。F1 得分通过f1_score函数实现,召回率通过recall_score函数实现。为了避免过拟合,决策树的最大深度被限制为三(max_depth=3):
# --- SECTION 2 ---
# Base learners evaluation
base_classifiers = [('DT', DecisionTreeClassifier(max_depth=3)),
('NB', GaussianNB()),
('LR', LogisticRegression())]
for bc in base_classifiers:
lr = bc[1]
lr.fit(x_train, y_train)
predictions = lr.predict(x_test)
print(bc[0]+' f1', metrics.f1_score(y_test, predictions))
print(bc[0]+' recall', metrics.recall_score(y_test, predictions))
结果在以下表格中有所展示。显然,决策树的表现优于其他三个学习器。朴素贝叶斯的召回率较高,但其 F1 得分相较于决策树要差得多:
| 学习器 | 指标 | 值 |
|---|---|---|
| 决策树 | F1 | 0.770 |
| 召回率 | 0.713 | |
| 朴素贝叶斯 | F1 | 0.107 |
| 召回率 | 0.824 | |
| 逻辑回归 | F1 | 0.751 |
| 召回率 | 0.632 |
我们还可以实验数据集中包含的特征数量。通过绘制它们与目标的相关性,我们可以过滤掉那些与目标相关性较低的特征。此表格展示了每个特征与目标的相关性:

每个变量与目标之间的相关性
通过过滤掉任何绝对值小于 0.1 的特征,我们希望基本学习器能够更好地检测欺诈交易,因为数据集的噪声将会减少。
为了验证我们的理论,我们重复实验,但删除 DataFrame 中任何绝对相关性低于 0.1 的列,正如fs = list(correlations[(abs(correlations)>threshold)].index.values)所示。
在这里,fs包含了所有与指定阈值相关性大于的列名:
# --- SECTION 3 ---
# Filter features according to their correlation to the target
np.random.seed(123456)
threshold = 0.1
correlations = data.corr()['Class'].drop('Class')
fs = list(correlations[(abs(correlations)>threshold)].index.values)
fs.append('Class')
data = data[fs]
x_train, x_test, y_train, y_test = train_test_split(data.drop('Class', axis=1).values, data.Class.values, test_size=0.3)
for bc in base_classifiers:
lr = bc[1]
lr.fit(x_train, y_train)
predictions = lr.predict(x_test)
print(bc[0]+' f1', metrics.f1_score(y_test, predictions))
print(bc[0]+' recall', metrics.recall_score(y_test, predictions))
再次,我们展示了以下表格中的结果。正如我们所看到的,决策树提高了其 F1 得分,同时降低了召回率。朴素贝叶斯在两个指标上都有所提升,而逻辑回归模型的表现大幅下降:
| 学习器 | 指标 | 值 |
|---|---|---|
| 决策树 | F1 | 0.785 |
| 召回率 | 0.699 | |
| 朴素贝叶斯 | F1 | 0.208 |
| 召回率 | 0.846 | |
| 逻辑回归 | F1 | 0.735 |
| 召回率 | 0.610 |
过滤数据集上三个基本学习器的性能指标
优化决策树
我们可以尝试优化树的深度,以最大化 F1 或召回率。为此,我们将在训练集上尝试深度范围为* [3, 11] *的不同深度。
以下图表展示了不同最大深度下的 F1 得分和召回率,包括原始数据集和过滤后的数据集:

不同树深度的测试指标
在这里,我们观察到,对于最大深度为 5 的情况,F1 和召回率在过滤后的数据集上得到了优化。此外,召回率在原始数据集上也得到了优化。我们将继续使用最大深度为 5,因为进一步优化这些指标可能会导致过拟合,尤其是在与这些指标相关的实例数量极其少的情况下。此外,使用最大深度为 5 时,在使用过滤后的数据集时,F1 和召回率都有所提高。
创建集成模型
我们现在可以继续创建集成模型。再次,我们将首先在原始数据集上评估集成模型,然后在过滤后的数据集上进行测试。代码与之前的示例相似。首先,我们加载库和数据,并按以下方式创建训练集和测试集的划分:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
import pandas as pd
from sklearn.ensemble import VotingClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.naive_bayes import GaussianNB
from sklearn.model_selection import train_test_split
from sklearn import metrics
np.random.seed(123456)
data = pd.read_csv('creditcard.csv')
data.Time = (data.Time-data.Time.min())/data.Time.std()
data.Amount = (data.Amount-data.Amount.mean())/data.Amount.std()
# Train-Test slpit of 70%-30%
x_train, x_test, y_train, y_test = train_test_split(
data.drop('Class', axis=1).values, data.Class.values, test_size=0.3)
在加载所需的库和数据后,我们创建集成模型,然后对其进行训练和评估。最后,我们按照以下方式通过过滤掉与目标变量相关性较低的特征来减少特征,从而重复实验:
# --- SECTION 2 ---
# Ensemble evaluation
base_classifiers = [('DT', DecisionTreeClassifier(max_depth=5)),
('NB', GaussianNB()),
('ensemble', LogisticRegression())]
ensemble = VotingClassifier(base_classifiers)
ensemble.fit(x_train, y_train)
print('Voting f1', metrics.f1_score(y_test, ensemble.predict(x_test)))
print('Voting recall', metrics.recall_score(y_test, ensemble.predict(x_test)))
# --- SECTION 3 ---
# Filter features according to their correlation to the target
np.random.seed(123456)
threshold = 0.1
correlations = data.corr()['Class'].drop('Class')
fs = list(correlations[(abs(correlations)>threshold)].index.values)
fs.append('Class')
data = data[fs]
x_train, x_test, y_train, y_test = train_test_split(
data.drop('Class', axis=1).values, data.Class.values, test_size=0.3)
ensemble = VotingClassifier(base_classifiers)
ensemble.fit(x_train, y_train)
print('Voting f1', metrics.f1_score(y_test, ensemble.predict(x_test)))
print('Voting recall', metrics.recall_score(y_test, ensemble.predict(x_test)))
以下表格总结了结果。对于原始数据集,投票模型提供了比任何单一分类器更好的 F1 和召回率的组合。
然而,最大深度为五的决策树在 F1 分数上稍微超越了它,而朴素贝叶斯能够回忆起更多的欺诈交易:
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始 | F1 | 0.822 |
| 召回率 | 0.779 | |
| 过滤后 | F1 | 0.828 |
| 召回率 | 0.794 |
对两个数据集的投票结果
我们可以通过添加两个额外的决策树,分别具有最大深度为三和八,进一步多样化我们的集成模型。这将集成模型的性能提升至以下数值。
尽管在过滤数据集上的性能保持不变,但该集成模型在原始数据集上的表现有所提升。特别是在 F1 指标上,它能够超越所有其他数据集/模型组合:
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始 | F1 | 0.829 |
| 召回率 | 0.787 | |
| 过滤后 | F1 | 0.828 |
| 召回率 | 0.794 |
对两个数据集使用额外两个决策树的投票结果
堆叠
我们也可以尝试将基本学习器堆叠,而不是使用投票。首先,我们将尝试堆叠一个深度为五的决策树,一个朴素贝叶斯分类器和一个逻辑回归模型。作为元学习器,我们将使用逻辑回归。
以下代码负责加载所需的库和数据、训练和评估原始数据集和过滤数据集上的集成模型。我们首先加载所需的库和数据,并创建训练集和测试集分割:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
import pandas as pd
from stacking_classifier import Stacking
from sklearn.tree import DecisionTreeClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.naive_bayes import GaussianNB
from sklearn.svm import LinearSVC
from sklearn.model_selection import train_test_split
from sklearn import metrics
np.random.seed(123456)
data = pd.read_csv('creditcard.csv')
data.Time = (data.Time-data.Time.min())/data.Time.std()
data.Amount = (data.Amount-data.Amount.mean())/data.Amount.std()
# Train-Test slpit of 70%-30%
x_train, x_test, y_train, y_test = train_test_split(
data.drop('Class', axis=1).values, data.Class.values, test_size=0.3)
在创建训练集和测试集分割后,我们在原始数据集以及减少特征的数据集上训练并评估集成模型,如下所示:
# --- SECTION 2 ---
# Ensemble evaluation
base_classifiers = [DecisionTreeClassifier(max_depth=5),
GaussianNB(),
LogisticRegression()]
ensemble = Stacking(learner_levels=[base_classifiers,
[LogisticRegression()]])
ensemble.fit(x_train, y_train)
print('Stacking f1', metrics.f1_score(y_test, ensemble.predict(x_test)))
print('Stacking recall', metrics.recall_score(y_test, ensemble.predict(x_test)))
# --- SECTION 3 ---
# Filter features according to their correlation to the target
np.random.seed(123456)
threshold = 0.1
correlations = data.corr()['Class'].drop('Class')
fs = list(correlations[(abs(correlations) > threshold)].index.values)
fs.append('Class')
data = data[fs]
x_train, x_test, y_train, y_test = train_test_split(data.drop('Class', axis=1).values,
data.Class.values, test_size=0.3)
ensemble = Stacking(learner_levels=[base_classifiers,
[LogisticRegression()]])
ensemble.fit(x_train, y_train)
print('Stacking f1', metrics.f1_score(y_test, ensemble.predict(x_test)))
print('Stacking recall', metrics.recall_score(y_test, ensemble.predict(x_test)))
如下表所示,该集成模型在原始数据集上取得了略高的 F1 分数,但召回率较差,相比之下,投票集成模型使用相同的基本学习器表现较好:
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始 | F1 | 0.823 |
| 召回率 | 0.750 | |
| 过滤后 | F1 | 0.828 |
| 召回率 | 0.794 |
使用三个基本学习器的堆叠集成模型表现
我们可以进一步尝试不同的基本学习器。通过添加两个分别具有最大深度为三和八的决策树(与第二次投票配置相同),观察堆叠模型表现出相同的行为。在原始数据集上,堆叠模型在 F1 分数上超越了其他模型,但在召回率上表现较差。
在过滤数据集上,性能与投票模型持平。最后,我们尝试第二层次的基本学习器,由一个深度为二的决策树和一个线性支持向量机组成,其表现不如五个基本学习器的配置:
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始 | F1 | 0.844 |
| 召回率 | 0.757 | |
| 过滤后 | F1 | 0.828 |
| 召回率 | 0.794 |
使用五个基本学习器的性能
下表展示了堆叠集成的结果,增加了一个基础学习器层次。显然,它的表现不如原始集成。
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始 | F1 | 0.827 |
| 召回率 | 0.757 | |
| 过滤 | F1 | 0.827 |
| 召回率 | 0.772 |
五个基础学习器在第 0 层,两个基础学习器在第 1 层的表现
袋装法
在本节中,我们将使用袋装法对数据集进行分类。正如我们之前所示,最大深度为五的决策树是最优的,因此我们将使用这些树来进行袋装法示例。
我们希望优化集成的大小。我们将通过在【5,30】范围内测试不同大小来生成原始训练集的验证曲线。实际的曲线如下图所示:

原始训练集的验证曲线,针对不同集成大小
我们观察到,集成大小为 10 时方差最小,因此我们将使用大小为 10 的集成。
以下代码加载数据和库(第一部分),将数据拆分为训练集和测试集,并在原始数据集(第二部分)和减少特征的数据集(第三部分)上拟合并评估集成:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
import pandas as pd
from sklearn.ensemble import BaggingClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
from sklearn import metrics
np.random.seed(123456)
data = pd.read_csv('creditcard.csv')
data.Time = (data.Time-data.Time.min())/data.Time.std()
data.Amount = (data.Amount-data.Amount.mean())/data.Amount.std()
# Train-Test slpit of 70%-30%
x_train, x_test, y_train, y_test = train_test_split(
data.drop('Class', axis=1).values, data.Class.values, test_size=0.3)
在创建了训练集和测试集划分后,我们在原始数据集和减少特征的数据集上训练并评估我们的集成,如下所示:
# --- SECTION 2 ---
# Ensemble evaluation
ensemble = BaggingClassifier(n_estimators=10,
base_estimator=DecisionTreeClassifier(max_depth=5))
ensemble.fit(x_train, y_train)
print('Bagging f1', metrics.f1_score(y_test, ensemble.predict(x_test)))
print('Bagging recall', metrics.recall_score(y_test, ensemble.predict(x_test)))
# --- SECTION 3 ---
# Filter features according to their correlation to the target
np.random.seed(123456)
threshold = 0.1
correlations = data.corr()['Class'].drop('Class')
fs = list(correlations[(abs(correlations)>threshold)].index.values)
fs.append('Class')
data = data[fs]
x_train, x_test, y_train, y_test = train_test_split(
data.drop('Class', axis=1).values, data.Class.values, test_size=0.3)
ensemble = BaggingClassifier(n_estimators=10,
base_estimator=DecisionTreeClassifier(max_depth=5))
ensemble.fit(x_train, y_train)
print('Bagging f1', metrics.f1_score(y_test, ensemble.predict(x_test)))
print('Bagging recall', metrics.recall_score(y_test, ensemble.predict(x_test)))
使用最大深度为 5 且每个集成有 10 棵树的袋装法集成,我们能够在以下 F1 和召回率得分中取得较好成绩。在所有度量上,它在两个数据集上均优于堆叠法和投票法,唯一的例外是,原始数据集的 F1 分数略逊于堆叠法(0.843 对比 0.844):
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始 | F1 | 0.843 |
| 召回率 | 0.787 | |
| 过滤 | F1 | 0.831 |
| 召回率 | 0.794 |
原始数据集和过滤数据集的袋装性能
尽管我们已得出最大深度为 5 对于单一决策树是最优的结论,但这确实限制了每棵树的多样性。通过将最大深度增加到 8,我们能够在过滤数据集上获得 0.864 的 F1 分数和 0.816 的召回率,这也是迄今为止的最佳表现。
然而,原始数据集上的性能有所下降,这确认了我们移除的特征确实是噪声,因为现在决策树能够拟合样本内的噪声,因此它们的样本外表现下降:
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始 | F1 | 0.840 |
| 召回率 | 0.772 | |
| 过滤 | F1 | 0.864 |
| 召回率 | 0.816 |
提升法
接下来,我们将开始使用生成方法。我们将实验的第一个生成方法是提升法。我们将首先尝试使用 AdaBoost 对数据集进行分类。由于 AdaBoost 根据误分类重新采样数据集,因此我们预期它能够相对较好地处理我们不平衡的数据集。
首先,我们必须决定集成的大小。我们生成了多个集成大小的验证曲线,具体如下所示:

AdaBoost 的不同集成大小验证曲线
如我们所见,70 个基学习器提供了偏差与方差之间的最佳权衡。因此,我们将继续使用 70 个基学习器的集成。
以下代码实现了 AdaBoost 的训练和评估:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
import pandas as pd
from sklearn.ensemble import AdaBoostClassifier
from sklearn.model_selection import train_test_split
from sklearn.utils import shuffle
from sklearn import metrics
np.random.seed(123456)
data = pd.read_csv('creditcard.csv')
data.Time = (data.Time-data.Time.min())/data.Time.std()
data.Amount = (data.Amount-data.Amount.mean())/data.Amount.std()
# Train-Test slpit of 70%-30%
x_train, x_test, y_train, y_test = train_test_split(
data.drop('Class', axis=1).values, data.Class.values, test_size=0.3)
然后我们使用 70 个基学习器和学习率 1.0 来训练和评估我们的集成方法:
# --- SECTION 2 ---
# Ensemble evaluation
ensemble = AdaBoostClassifier(n_estimators=70, learning_rate=1.0)
ensemble.fit(x_train, y_train)
print('AdaBoost f1', metrics.f1_score(y_test, ensemble.predict(x_test)))
print('AdaBoost recall', metrics.recall_score(y_test, ensemble.predict(x_test)))
我们通过选择与目标高度相关的特征来减少特征数量。最后,我们重复训练和评估集成的方法:
# --- SECTION 3 ---
# Filter features according to their correlation to the target
np.random.seed(123456)
threshold = 0.1
correlations = data.corr()['Class'].drop('Class')
fs = list(correlations[(abs(correlations)>threshold)].index.values)
fs.append('Class')
data = data[fs]
x_train, x_test, y_train, y_test = train_test_split(
data.drop('Class', axis=1).values, data.Class.values, test_size=0.3)
ensemble = AdaBoostClassifier(n_estimators=70, learning_rate=1.0)
ensemble.fit(x_train, y_train)
print('AdaBoost f1', metrics.f1_score(y_test, ensemble.predict(x_test)))
print('AdaBoost recall', metrics.recall_score(y_test, ensemble.predict(x_test)))
结果如下表所示。显而易见,它的表现不如我们之前的模型:
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始数据 | F1 | 0.778 |
| 召回率 | 0.721 | |
| 过滤后数据 | F1 | 0.794 |
| 召回率 | 0.721 |
AdaBoost 的表现
我们可以尝试将学习率增加到 1.3,这似乎能提高整体表现。如果我们再将其增加到 1.4,则会发现性能下降。如果我们将基学习器的数量增加到 80,过滤后的数据集性能有所提升,而原始数据集则似乎在召回率和 F1 表现之间做出了权衡:
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始数据 | F1 | 0.788 |
| 召回率 | 0.765 | |
| 过滤后数据 | F1 | 0.815 |
| 召回率 | 0.743 |
AdaBoost 的表现,学习率=1.3
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始数据 | F1 | 0.800 |
| 召回率 | 0.765 | |
| 过滤后数据 | F1 | 0.800 |
| 召回率 | 0.735 |
AdaBoost 的表现,学习率=1.4
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始数据 | F1 | 0.805 |
| 召回率 | 0.757 | |
| 过滤后数据 | F1 | 0.805 |
| 召回率 | 0.743 |
AdaBoost 的表现,学习率=1.4,集成大小=80
事实上,我们可以观察到一个 F1 和召回率的帕累托前沿,它直接与学习率和基学习器数量相关。这个前沿如下图所示:

AdaBoost 的 F1 和召回率的帕累托前沿
XGBoost
我们还将尝试使用 XGBoost 对数据集进行分类。由于 XGBoost 的树最大深度为三,我们预期它会在没有任何微调的情况下超越 AdaBoost。的确,XGBoost 在两个数据集上,以及所有指标方面(如下表所示),都能表现得比大多数先前的集成方法更好:
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始数据 | F1 | 0.846 |
| 召回率 | 0.787 | |
| 过滤后数据 | F1 | 0.849 |
| 召回率 | 0.809 |
XGBoost 的开箱即用表现
通过将每棵树的最大深度增加到五,集成方法的表现得到了进一步提升,结果如下:
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始数据 | F1 | 0.862 |
| 召回率 | 0.801 | |
| 过滤后 | F1 | 0.862 |
| 召回率 | 0.824 |
最大深度为 5 时的性能
使用随机森林
最后,我们将使用随机森林集成方法。再次通过验证曲线,我们确定最佳的集成大小。从下图可以看出,50 棵树提供了模型的最小方差,因此我们选择集成大小为 50:

随机森林的验证曲线
我们提供以下训练和验证代码,并给出两个数据集的性能表现。以下代码负责加载所需的库和数据,并在原始数据集和过滤后的数据集上训练和评估集成模型。我们首先加载所需的库和数据,同时创建训练集和测试集:
# --- SECTION 1 ---
# Libraries and data loading
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.utils import shuffle
from sklearn import metrics
np.random.seed(123456)
data = pd.read_csv('creditcard.csv')
data.Time = (data.Time-data.Time.min())/data.Time.std()
data.Amount = (data.Amount-data.Amount.mean())/data.Amount.std()
np.random.seed(123456)
data = pd.read_csv('creditcard.csv')
data.Time = (data.Time-data.Time.min())/data.Time.std()
data.Amount = (data.Amount-data.Amount.mean())/data.Amount.std()
# Train-Test slpit of 70%-30%
x_train, x_test, y_train, y_test = train_test_split(
data.drop('Class', axis=1).values, data.Class.values, test_size=0.3)
然后,我们在原始数据集和过滤后的数据集上训练和评估集成模型:
# --- SECTION 2 ---
# Ensemble evaluation
ensemble = RandomForestClassifier(n_jobs=4)
ensemble.fit(x_train, y_train)
print('RF f1', metrics.f1_score(y_test, ensemble.predict(x_test)))
print('RF recall', metrics.recall_score(y_test, ensemble.predict(x_test)))
# --- SECTION 3 ---
# Filter features according to their correlation to the target
np.random.seed(123456)
threshold = 0.1
correlations = data.corr()['Class'].drop('Class')
fs = list(correlations[(abs(correlations)>threshold)].index.values)
fs.append('Class')
data = data[fs]
x_train, x_test, y_train, y_test = train_test_split(
data.drop('Class', axis=1).values, data.Class.values, test_size=0.3)
ensemble = RandomForestClassifier(n_jobs=4)
ensemble.fit(x_train, y_train)
print('RF f1', metrics.f1_score(y_test, ensemble.predict(x_test)))
print('RF recall', metrics.recall_score(y_test, ensemble.predict(x_test)))
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始 | F1 | 0.845 |
| 召回率 | 0.743 | |
| 过滤后 | F1 | 0.867 |
| 召回率 | 0.794 |
随机森林性能
由于我们的数据集高度偏斜,我们可以推测,改变树的分割标准为熵会对我们的模型有帮助。事实上,通过在构造函数中指定criterion='entropy'(ensemble = RandomForestClassifier(n_jobs=4)),我们能够将原始数据集的性能提高到F1得分为0.859和召回率得分为0.786,这是原始数据集的两个最高得分:
| 数据集 | 指标 | 值 |
|---|---|---|
| 原始 | F1 | 0.859 |
| 召回率 | 0.787 | |
| 过滤后 | F1 | 0.856 |
| 召回率 | 0.787 |
使用熵作为分割标准的性能
集成方法的对比分析
在实验中,我们使用了一个减少特征的数据集,其中去除了与目标变量相关性较弱的特征,我们希望提供每种方法最佳参数下的最终得分。在以下图表中,结果按升序排列。Bagging 在应用于过滤后的数据集时似乎是最稳健的方法。XGBoost 是第二好的选择,在应用于过滤后的数据集时也能提供不错的 F1 和召回率得分:

F1 得分
召回率得分,如下图所示,清楚地显示了 XGBoost 在该指标上相较于其他方法的明显优势,因为它能够在原始数据集和过滤后的数据集上都超过其他方法:

召回率得分
总结
在本章中,我们探讨了使用各种集成学习方法检测欺诈交易的可能性。虽然一些方法表现优于其他方法,但由于数据集的特点,在某种程度上对数据集进行重采样(过采样或欠采样)是很难得到好结果的。
我们展示了如何使用每种集成学习方法,以及如何探索调整其各自参数的可能性,以实现更好的性能。在下一章,我们将尝试利用集成学习技术来预测比特币价格。
第十章:预测比特币价格
多年来,比特币和其他加密货币吸引了许多方的关注,主要是由于其价格水平的爆炸性增长以及区块链技术所提供的商业机会。在本章中,我们将尝试使用历史数据预测第二天的比特币(BTC)价格。有许多来源提供加密货币的历史价格数据。我们将使用来自雅虎财经的数据,地址为finance.yahoo.com/quote/BTC-USD/history/。本章将重点预测未来价格,并利用这些知识进行比特币投资。
本章将涵盖以下主题:
-
时间序列数据
-
投票
-
堆叠
-
装袋法
-
提升法
-
随机森林
技术要求
你需要具备基本的机器学习技术和算法知识。此外,还需要了解 Python 的语法和惯例。最后,熟悉 NumPy 库将极大帮助读者理解一些自定义算法实现。
本章的代码文件可以在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Ensemble-Learning-with-Python/tree/master/Chapter10
查看以下视频,观看代码示例:bit.ly/2JOsR7d。
时间序列数据
时间序列数据关注的是每个实例与特定时间点或时间间隔相关的数据实例。我们选择测量变量的频率定义了时间序列的采样频率。例如,大气温度在一天中以及一整年中都有不同。我们可以选择每小时测量一次温度,从而得到每小时的频率,或者选择每天测量一次温度,从而得到每天的频率。在金融领域,采样频率介于主要时间间隔之间并不罕见;例如,可以是每 10 分钟一次(10 分钟频率)或每 4 小时一次(4 小时频率)。时间序列的另一个有趣特点是,通常相邻时间点之间的数据实例存在相关性。
这叫做自相关。例如,大气温度在连续的几分钟内不能发生很大的变化。此外,这使我们能够利用早期的数据点来预测未来的数据点。下面是 2016 年至 2019 年期间雅典和希腊的温度(3 小时平均)示例。请注意,尽管温度有所变化,但大多数温度都相对接近前一天的温度。此外,我们看到热月和冷月(季节)的重复模式,这就是所谓的季节性:

2016–2019 年雅典,希腊的温度
为了检查不同时间点之间的相关性水平,我们利用自相关函数(ACF)。ACF 衡量数据点与前一个数据点之间的线性相关性(称为滞后)。下图展示了温度数据(按月平均重新采样)的自相关函数(ACF)。它显示出与第一个滞后的强正相关性。这意味着一个月的温度不太可能与前一个月相差太大,这是合乎逻辑的。例如,12 月和 1 月是寒冷的月份,它们的平均温度通常比 12 月和 3 月更接近。此外,第 5 和第 6 滞后之间存在强烈的负相关性,表明寒冷的冬季导致炎热的夏季,反之亦然:

温度数据的自相关函数(ACF)
比特币数据分析
比特币数据与温度数据有很大不同。温度在每年的相同月份基本保持相似值。这表明温度的分布随着时间变化并未发生改变。表现出这种行为的时间序列被称为平稳时间序列。这使得使用时间序列分析工具,如自回归(AR)、滑动平均(MA)和自回归积分滑动平均(ARIMA)模型进行建模相对简单。财务数据通常是非平稳的,正如下图中所示的每日比特币收盘数据所示。这意味着数据在其历史的整个过程中并未表现出相同的行为,而是行为在变化。
财务数据通常提供开盘价(当天的第一笔价格)、最高价(当天的最高价格)、最低价(当天的最低价格)和收盘价(当天的最后一笔价格)。
数据中存在明显的趋势(价格在某些时间间隔内平均上升或下降),以及异方差性(随时间变化的可变方差)。识别平稳性的一种方法是研究自相关函数(ACF)。如果存在非常强的高阶滞后之间的相关性且不衰减,则该时间序列很可能是非平稳的。下图展示了比特币数据的自相关函数(ACF),显示出相关性衰减较弱:

2014 年中期至今的比特币/USD 价格
下图展示了比特币的自相关函数(ACF)。我们可以清楚地看到,在非常高的滞后值下,相关性并没有显著下降:

比特币数据的自相关函数(ACF)
请看以下公式:

其中 p 是百分比变化,t[n] 是时间 n 时的价格,tn-1 是时间 n-1 时的价格。通过对数据进行转化,我们得到一个平稳但相关性较弱的时间序列。
下图展示了数据的图形,并提供了自相关函数(ACF)和平均 30 天标准差:

转换后的数据

滚动 30 天标准差和转换数据的自相关函数(ACF)
建立基准
为了建立基准,我们将尝试使用线性回归建模数据。虽然这是一个时间序列,我们不会直接考虑时间。相反,我们将使用大小为S的滑动窗口在每个时间点生成特征,并利用这些特征预测下一个数据点。接下来,我们将时间窗口向前移动一步,以包含我们预测的真实数据点的值,并丢弃窗口中的最旧数据点。我们将继续这个过程,直到所有数据点都被预测。这叫做向前验证。一个缺点是我们无法预测前S个数据点,因为我们没有足够的数据来生成它们的特征。另一个问题是我们需要重新训练模型L-S次,其中L是时间序列中的数据点总数。以下图展示了前两个步骤的图示:

向前验证程序的前两个步骤。该程序会继续应用于整个时间序列。
首先,我们从BTC-USD.csv文件加载所需的库和数据。我们还设置了 NumPy 随机数生成器的种子:
import numpy as np
import pandas as pd
from simulator import simulate
from sklearn import metrics
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
np.random.seed(123456)
lr = LinearRegression()
data = pd.read_csv('BTC-USD.csv')
然后,我们通过使用data.dropna()删除包含 NaN 值的条目,使用pd.to_datetime解析日期,并将日期设置为索引,来清理数据。最后,我们计算Close值的百分比差异(并丢弃第一个值,因为它是 NaN),并保存 Pandas 系列的长度:
data = data.dropna()
data.Date = pd.to_datetime(data.Date)
data.set_index('Date', drop=True, inplace=True)
diffs = (data.Close.diff()/data.Close).values[1:]
diff_len = len(diffs)
我们创建了一个函数,用于在每个数据点生成特征。特征本质上是前几个滞后的不同百分比。因此,为了用值填充数据集的特征,我们只需将数据向前移动滞后点数即可。任何没有可用数据计算滞后的特征,其值将为零。以下图示了一个包含数字 1、2、3 和 4 的时间序列的示例:

滞后特征的填充方式
实际的函数,填充滞后t,选择时间序列中的所有数据,除了最后的t,并将其放入相应的特征中,从索引t开始。我们选择使用过去 20 天的数据,因为在那之后似乎没有显著的线性相关性。此外,我们将特征和目标缩放 100 倍,并四舍五入到 8 位小数。这一点很重要,因为它确保了结果的可重复性。如果数据没有四舍五入,溢出错误会给结果带来随机性,如下所示:
def create_x_data(lags=1):
diff_data = np.zeros((diff_len, lags))
for lag in range(1, lags+1):
this_data = diffs[:-lag]
diff_data[lag:, lag-1] = this_data
return diff_data
# REPRODUCIBILITY
x_data = create_x_data(lags=20)*100
y_data = diffs*100
最后,我们执行前向验证。我们选择了 150 个点的训练窗口,大约相当于 5 个月。考虑到数据的特性和波动性,这提供了一个良好的折衷,既能保证训练集足够大,又能捕捉到近期的市场行为。更大的窗口将包括不再反映现实的市场条件。更短的窗口则提供的数据过少,容易导致过拟合。我们通过利用预测值与原始百分比差异之间的均方误差来衡量我们模型的预测质量:
window = 150
preds = np.zeros(diff_len-window)
for i in range(diff_len-window-1):
x_train = x_data[i:i+window, :]
y_train = y_data[i:i+window]
lr.fit(x_train, y_train)
preds[i] = lr.predict(x_data[i+window+1, :].reshape(1, -1))
print('Percentages MSE: %.2f'%metrics.mean_absolute_error(y_data[window:], preds))
简单线性回归可能产生一个均方误差(MSE)为 18.41。我们还可以尝试通过将每个数据点乘以(1 + 预测值)来重建时间序列,以获得下一个预测值。此外,我们可以尝试利用数据集的特点来模拟交易活动。每次预测值大于+0.5%的变化时,我们投资 100 美元购买比特币。如果我们持有比特币并且预测值低于-0.5%,则在当前市场收盘时卖出比特币。为了评估我们模型作为交易策略的质量,我们使用简化的夏普比率,计算方式是将平均回报率(百分比利润)与回报的标准差之比。较高的夏普值表示更好的交易策略。这里使用的公式如下。通常,预期回报会减去一个替代的安全回报百分比,但由于我们仅希望比较我们将生成的模型,因此将其省略:

作为交易策略使用时,线性回归能够产生 0.19 的夏普值。下图显示了我们的模型生成的交易和利润。蓝色三角形表示策略购买 100 美元比特币的时间点,红色三角形表示策略卖出之前购买的比特币的时间点:

我们模型的利润和进出点
在本章的其余部分,我们将通过利用前几章介绍的集成方法来改进均方误差(MSE)和夏普值。
模拟器
在这里,我们将简要解释模拟器的工作原理。它作为一个函数实现,接受我们的标准 Pandas 数据框和模型预测作为输入。首先,我们将定义买入阈值和投资额度(我们在每次买入时投资的金额),以及占位符变量。这些变量将用于存储真实和预测的时间序列,以及我们模型的利润(balances)。此外,我们定义了buy_price变量,它存储我们购买比特币时的价格。如果价格为0,我们假设我们没有持有任何比特币。buy_points和sell_points列表表示我们买入或卖出比特币的时间点,仅用于绘图。此外,我们还存储了起始索引,这相当于滑动窗口的大小,如以下示例所示:
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn import metrics
def simulate(data, preds):
# Constants and placeholders
buy_threshold = 0.5
stake = 100
true, pred, balances = [], [], []
buy_price = 0
buy_points, sell_points = [], []
balance = 0
start_index = len(data)-len(preds)-1
接下来,对于每个点,我们存储实际值和预测值。如果预测值大于 0.5 且我们没有持有任何比特币,我们将买入价值 100 美元的比特币。如果预测值小于-0.5 且我们已经购买了比特币,我们将以当前的收盘价将其卖出。我们将当前的利润(或亏损)添加到我们的余额中,将真实值和预测值转换为 NumPy 数组,并生成图表:
# Calculate predicted values
for i in range(len(preds)):
last_close = data.Close[i+start_index-1]
current_close = data.Close[i+start_index]
# Save predicted values and true values
true.append(current_close)
pred.append(last_close*(1+preds[i]/100))
# Buy/Sell according to signal
if preds[i] > buy_threshold and buy_price == 0:
buy_price = true[-1]
buy_points.append(i)
elif preds[i] < -buy_threshold and not buy_price == 0:
profit = (current_close - buy_price) * stake/buy_price
balance += profit
buy_price = 0
sell_points.append(i)
balances.append(balance)
true = np.array(true)
pred = np.array(pred)
# Create plots
plt.figure()
plt.subplot(2, 1, 1)
plt.plot(true, label='True')
plt.plot(pred, label='pred')
plt.scatter(buy_points, true[buy_points]+500, marker='v',
c='blue', s=5, zorder=10)
plt.scatter(sell_points, true[sell_points]-500, marker='^'
, c='red', s=5, zorder=10)
plt.title('Trades')
plt.subplot(2, 1, 2)
plt.plot(balances)
plt.title('Profit')
print('MSE: %.2f'%metrics.mean_squared_error(true, pred))
balance_df = pd.DataFrame(balances)
pct_returns = balance_df.diff()/stake
pct_returns = pct_returns[pct_returns != 0].dropna()
print('Sharpe: %.2f'%(np.mean(pct_returns)/np.std(pct_returns)))
投票
我们将尝试通过投票将三种基本回归算法结合起来,以提高简单回归的 MSE。为了组合这些算法,我们将利用它们预测值的平均值。因此,我们编写了一个简单的类,用于创建基本学习器的字典,并处理它们的训练和预测平均。主要逻辑与我们在第三章实现的自定义投票分类器Voting相同:
import numpy as np
from copy import deepcopy
class VotingRegressor():
# Accepts a list of (name, classifier) tuples
def __init__(self, base_learners):
self.base_learners = {}
for name, learner in base_learners:
self.base_learners[name] = deepcopy(learner)
# Fits each individual base learner
def fit(self, x_data, y_data):
for name in self.base_learners:
learner = self.base_learners[name]
learner.fit(x_data, y_data)
预测结果存储在一个 NumPy 矩阵中,其中每一行对应一个实例,每一列对应一个基本学习器。行平均值即为集成的输出,如下所示:
# Generates the predictions
def predict(self, x_data):
# Create the predictions matrix
predictions = np.zeros((len(x_data), len(self.base_learners)))
names = list(self.base_learners.keys())
# For each base learner
for i in range(len(self.base_learners)):
name = names[i]
learner = self.base_learners[name]
# Store the predictions in a column
preds = learner.predict(x_data)
predictions[:,i] = preds
# Take the row-average
predictions = np.mean(predictions, axis=1)
return predictions
我们选择了支持向量机、K 近邻回归器和线性回归作为基本学习器,因为它们提供了多样的学习范式。为了使用这个集成,我们首先导入所需的模块:
import numpy as np
import pandas as pd
from simulator import simulate
from sklearn import metrics
from sklearn.neighbors import KNeighborsRegressor
from sklearn.linear_model import LinearRegression
from sklearn.svm import SVR
from voting_regressor import VotingRegressor
接下来,在我们之前展示的代码中,我们将lr = LinearRegression()这一行替换为以下代码:
base_learners = [('SVR', SVR()),
('LR', LinearRegression()),
('KNN', KNeighborsRegressor())]
lr = VotingRegressor(base_learners)
通过增加两个额外的回归器,我们能够将 MSE 减少到 16.22,并产生 0.22 的夏普值。
改进投票
尽管我们的结果优于线性回归,但我们仍然可以通过去除线性回归进一步改善结果,从而仅保留基本学习器,如下所示:
base_learners = [('SVR', SVR()), ('KNN', KNeighborsRegressor())]
这进一步改善了 MSE,将其减少到 15.71。如果我们将这个模型作为交易策略使用,可以实现 0.21 的夏普值;比简单的线性回归要好得多。下表总结了我们的结果:
| Metric | SVR-KNN | SVR-LR-KNN |
|---|---|---|
| MSE | 15.71 | 16.22 |
| Sharpe | 0.21 | 0.22 |
投票集成结果
堆叠
接下来,我们将使用堆叠法来更有效地结合基本回归器。使用第四章中的StackingRegressor,堆叠,我们将尝试与投票法一样组合相同的算法。首先,我们修改我们的集成的predict函数(以允许单实例预测),如下所示:
# Generates the predictions
def predict(self, x_data):
# Create the predictions matrix
predictions = np.zeros((len(x_data), len(self.base_learners)))
names = list(self.base_learners.keys())
# For each base learner
for i in range(len(self.base_learners)):
name = names[i]
learner = self.base_learners[name]
# Store the predictions in a column
preds = learner.predict(x_data)
predictions[:,i] = preds
# Take the row-average
predictions = np.mean(predictions, axis=1)
return predictions
再次,我们修改代码以使用堆叠回归器,如下所示:
base_learners = [[SVR(), LinearRegression(), KNeighborsRegressor()],
[LinearRegression()]]
lr = StackingRegressor(base_learners)
在这种设置下,集成方法生成的模型具有 16.17 的均方误差(MSE)和 0.21 的夏普比率。
改进堆叠法
我们的结果略逊于最终的投票集成,因此我们将尝试通过去除线性回归来改进它,就像我们在投票集成中做的那样。通过这样做,我们可以稍微改善模型,达到 16.16 的均方误差(MSE)和 0.22 的夏普比率。与投票法相比,堆叠法作为一种投资策略略好一些(相同的夏普比率和略微更好的 MSE),尽管它无法达到相同水平的预测准确度。其结果总结在下表中:
| 指标 | SVR-KNN | SVR-LR-KNN |
|---|---|---|
| MSE | 16.17 | 16.16 |
| 夏普比率 | 0.21 | 0.22 |
堆叠法结果
自助法(Bagging)
通常,在将预测模型拟合到金融数据时,方差是我们面临的主要问题。自助法是对抗方差的非常有用的工具;因此,我们希望它能够比简单的投票法和堆叠法生成表现更好的模型。为了利用自助法,我们将使用 scikit 的BaggingRegressor,该方法在第五章中介绍,自助法。为了在我们的实验中实现它,我们只需使用lr = BaggingRegressor()来替代之前的回归器。这样做的结果是均方误差(MSE)为 19.45,夏普比率为 0.09。下图展示了我们的模型所生成的利润和交易:

自助法的利润和交易
改进自助法
我们可以进一步改进自助法,因为它的表现比任何之前的模型都差。首先,我们可以尝试浅层决策树,这将进一步减少集成中的方差。通过使用最大深度为3的决策树,使用lr = BaggingRegressor(base_estimator=DecisionTreeRegressor(max_depth=3)),我们可以改进模型的性能,得到 16.17 的均方误差(MSE)和 0.15 的夏普比率。进一步将树的生长限制为max_depth=1,可以使模型达到 16.7 的 MSE 和 0.27 的夏普比率。如果我们检查模型的交易图,我们会观察到交易数量的减少,以及在比特币价格大幅下跌的时期性能的显著改善。这表明该模型能够更有效地从实际信号中过滤噪声。
方差的减少确实帮助了我们的模型提高了性能:

最终自助法的利润和交易
下表总结了我们测试的各种袋装模型的结果:
| 指标 | DT_max_depth=1 | DT_max_depth=3 | DT |
|---|---|---|---|
| MSE | 16.70 | 17.59 | 19.45 |
| Sharpe | 0.27 | 0.15 | 0.09 |
表 3:袋装结果
提升
最强大的集成学习技术之一是提升。它能够生成复杂的模型。在本节中,我们将利用 XGBoost 来建模我们的时间序列数据。由于在使用 XGBoost 建模时有许多自由度(超参数),我们预计需要一些微调才能取得令人满意的结果。通过将示例中的回归器替换为lr = XGBRegressor(),我们可以使用 XGBoost 并将其拟合到我们的数据上。这将产生一个 MSE 为 19.20,Sharpe 值为 0.13 的结果。
图表展示了模型生成的利润和交易。虽然 Sharpe 值低于其他模型,但我们可以看到,即使在比特币价格下跌的时期,它仍然能持续生成利润:

由提升模型生成的交易
改进提升方法
由于样本外的表现以及提升模型的买入和卖出频率,我们可以假设它在训练数据上发生了过拟合。因此,我们将尝试对其学习进行正则化。第一步是限制单个树的最大深度。我们首先施加一个上限为 2 的限制,使用max_depth=2。这略微改善了我们的模型,得到了一个 MSE 值为 19.14,Sharpe 值为 0.17。通过进一步限制模型的过拟合能力,仅使用 10 个基学习器(n_estimators=10),模型进一步得到了提升。
模型的 MSE 降低至 16.39,Sharpe 值提高至 0.21。添加一个 L1 正则化项 0.5(reg_alpha=0.5)只将 MSE 减少至 16.37。我们已经到了一个点,进一步的微调不会对模型性能贡献太大。在这个阶段,我们的回归模型如下所示:
lr = XGBRegressor(max_depth=2, n_estimators=10, reg_alpha=0.5)
鉴于 XGBoost 的能力,我们将尝试增加模型可用的信息量。我们将把可用特征滞后增加到 30,并将之前 15 个滞后的滚动均值添加到特征中。为此,我们将修改代码中的特征创建部分,如下所示:
def create_x_data(lags=1):
diff_data = np.zeros((diff_len, lags))
ma_data = np.zeros((diff_len, lags))
diff_ma = (data.Close.diff()/data.Close).rolling(15).mean().fillna(0).values[1:]
for lag in range(1, lags+1):
this_data = diffs[:-lag]
diff_data[lag:, lag-1] = this_data
this_data = diff_ma[:-lag]
ma_data[lag:, lag-1] = this_data
return np.concatenate((diff_data, ma_data), axis=1)
x_data = create_x_data(lags=30)*100
y_data = diffs*100
这增加了我们模型的交易表现,达到了 0.32 的 Sharpe 值——所有模型中最高,同时 MSE 也增加到了 16.78。此模型生成的交易如图和后续的表格所示。值得注意的是,买入的次数大大减少,这种行为也是袋装方法在我们改进其作为投资策略时所展现的:

最终提升模型性能
| 指标 | md=2/ne=10/reg=0.5+data | md=2/ne=10/reg=0.5 | md=2/ne=10 | md=2 | xgb |
|---|---|---|---|---|---|
| MSE | 16.78 | 16.37 | 16.39 | 19.14 | 19.20 |
| Sharpe | 0.32 | 0.21 | 0.21 | 0.17 | 0.13 |
所有增强模型的度量
随机森林
最后,我们将使用随机森林来建模数据。尽管我们预计集成方法能够利用额外滞后期和滚动平均的所有信息,但我们将首先仅使用 20 个滞后期和回报百分比作为输入。因此,我们的初始回归器仅为RandomForestRegressor()。这导致了一个表现不太理想的模型,MSE 为 19.02,Sharpe 值为 0.11。
下图展示了模型生成的交易:

随机森林模型的交易
改进随机森林
为了改进我们的模型,我们尝试限制其过拟合能力,给每棵树设置最大深度为3。这大大提高了模型的性能,模型达到了 MSE 值为 17.42 和 Sharpe 值为 0.17。进一步将最大深度限制为2,虽然使 MSE 得分稍微提高到 17.13,但 Sharpe 值下降至 0.16。最后,将集成模型的大小增加到 50,使用n_estimators=50,生成了一个性能大幅提升的模型,MSE 为 16.88,Sharpe 值为 0.23。由于我们仅使用了原始特征集(20 个回报百分比的滞后期),我们希望尝试在增强部分使用的扩展数据集。通过添加 15 日滚动平均值,并将可用滞后期数量增加到 30,模型的 Sharpe 值提高到 0.24,尽管 MSE 也上升到 18.31。模型生成的交易如图所示:

使用扩展数据集的随机森林结果
模型的结果总结如下表:
| Metric | md=2/ne=50+data | md=2/ne=50 | md=2 | md=3 | RF |
|---|---|---|---|---|---|
| MSE | 18.31 | 16.88 | 17.13 | 17.42 | 19.02 |
| Sharpe | 0.24 | 0.23 | 0.16 | 0.17 | 0.11 |
所有随机森林模型的度量
总结
在本章中,我们尝试使用本书前几章介绍的所有集成方法来建模历史比特币价格。与大多数数据集一样,模型质量受多种决策的影响。数据预处理和特征工程是其中最重要的因素,尤其是当数据集的性质不允许直接建模时。时间序列数据集就属于这种情况,需要构建适当的特征和目标。通过将我们的非平稳时间序列转化为平稳序列,我们提高了算法对数据建模的能力。
为了评估我们模型的质量,我们使用了收益百分比的均方误差(MSE),以及夏普比率(我们假设模型被用作交易策略)。在涉及到 MSE 时,表现最佳的集成模型是简单投票集成。该集成包括一个 SVM 和 KNN 回归器,没有进行超参数调优,实现了一个 MSE 值为 15.71。作为交易策略,XGBoost 被证明是最佳集成模型,实现了一个夏普值为 0.32。尽管不全面,本章探索了使用集成学习方法进行时间序列建模的可能性和技术。
在接下来的章节中,我们将利用集成学习方法的能力,以预测各种推特的情感。
第十一章:在 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 个特征。每个特征对应一个单元词(在本例中是单字)。每个特征的值等于该单元词在该实例中的出现次数。最终的数据集如下所示:
| 我的 | 头 | 疼 | 这 | 食物 | 很好 | 刺痛 | 那 | 一 | 时间 | 极性 |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 0 | 0 | 0 | 1 | 2 | 1 | 1 | 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 1 | 0 | 0 | 0 | 1 | 0 | 0 | 0 |
| 0 | 0 | 0 | 0 | 0 | 1 | 1 | 0 | 1 | 1 | 1 |
提取的特征数据集
通常,每个实例会被归一化,因此每个特征表示的是每个单元词的相对频率,而不是绝对频率(计数)。这种方法被称为词频(TF)。TF 数据集如下所示:
| 我的 | 头 | 疼 | 这 | 食物 | 很好 | 刺痛 | 那 | 一 | 时间 | 极性 |
|---|---|---|---|---|---|---|---|---|---|---|
| 0.33 | 0.33 | 0.33 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 0 | 0 | 0 | 0.2 | 0.4 | 0.2 | 0.2 | 0 | 0 | 0 | 0 |
| 0 | 0 | 0.33 | 0.33 | 0 | 0 | 0 | 0.33 | 0 | 0 | 0 |
| 0 | 0 | 0 | 0 | 0 | 0.2 | 0.2 | 0 | 0.2 | 0.2 | 0.2 |
TF 数据集
在英语中,一些词语的出现频率非常高,但对表达情感的贡献很小。为了考虑这一事实,采用了逆文档频率(IDF)。IDF 更加关注不常见的词语。对于N个实例和K个唯一的单词,单词u的 IDF 值计算公式如下:

以下表格显示了 IDF 转换后的数据集:
| 我的 | 头 | 疼 | 这 | 食物 | 很好 | 刺痛 | 那 | 一 | 时间 | 极性 |
|---|---|---|---|---|---|---|---|---|---|---|
| 0.6 | 0.6 | 0.3 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 0 | 0 | 0 | 0.3 | 0.6 | 0.3 | 0.3 | 0 | 0 | 0 | 0 |
| 0 | 0 | 0.3 | 0.3 | 0 | 0 | 0 | 0.6 | 0 | 0 | 0 |
| 0 | 0 | 0 | 0 | 0 | 0.3 | 0.3 | 0 | 0.6 | 0.6 | 0.6 |
IDF 数据集
词干提取
词干提取是情感分析中常用的另一种做法。它是将单词还原为词根的过程。这使得我们可以将来源于相同词根的单词作为同一个单元词处理。例如,love、loving和loved都会作为相同的单元词,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 访问这里 |
| 680337 | Rafa 退出温布尔登,因 BLG 感情失控... |
| 31250 | 官方宣布,打印机讨厌我,准备沉沦... |
| 1078430 | @Enigma_ 很高兴听到这个 |
| 1436972 | 亲爱的 Photoshop CS2. 我爱你,我想你! |
| 401990 | 我的男朋友今天出了车祸! |
| 1053169 | 生日快乐,威斯康星州!161 年前,你... |
数据集中的 10 个随机样本大纲
我们可以立即得出以下观察结果。首先,有对其他用户的引用,例如@KrisAllenmusic。这些引用并没有提供有关推文情感的信息。因此,在预处理过程中,我们将删除它们。其次,有数字和标点符号。这些也没有贡献推文的情感,因此它们也必须被删除。第三,部分字母是大写的,而其他字母则不是。由于大小写不会改变单词的情感,我们可以选择将所有字母转换为小写或大写。这确保像LOVE、love和Love这样的词将被视为相同的单元词。如果我们再取样更多推文,可以识别出更多问题。有话题标签(例如#summer),这些同样不贡献推文的情感。此外,还有网址链接(例如www.packtpub.com/eu/)和 HTML 属性(如&对应&)。这些在预处理中也将被删除。
为了对数据进行预处理,首先,我们必须导入所需的库。我们将使用 pandas,Python 内置的正则表达式库re,string中的punctuation,以及自然语言工具包(NLTK)。可以通过pip或conda轻松安装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_features 和 ngram_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 封装库 tweepy(github.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"
接下来,我们创建并训练我们的TfidfVectorizer和VotingClassifier,使用 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类,负责监听到达的推文并对其进行分类。它继承自tweepy的StreamListener类。通过重写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对象,将OAuthHandler和StreamClassifier对象作为参数,并定义我们想要追踪的关键字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 太空漫游 | 黑客帝国 | 全金属外壳 | 海湾战争 | 壮志凌云 |
|---|---|---|---|---|---|---|
| U0 | 5 | 4 | 2 | 1 | ||
| U1 | 1 | 4 | 4 | 3 | ||
| U2 | 4 | 4 | 1 | |||
| U3 | 4 | 5 | 5 | 4 |
每部电影每个用户的评分
如图所示,每个用户都评分了若干部电影,尽管并非所有用户都观看了相同的电影,并且每个用户的喜好各不相同。如果我们想向用户二(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.32 | 2.32 | - |
U2 的预测评分
这种推荐方法被称为协同过滤。当我们像这个小示例一样寻找相似用户时,这称为用户-用户过滤。我们也可以将这种方法应用于通过转置评分表来寻找相似项,这被称为物品-物品过滤,在实际应用中通常表现得更好。这是因为物品通常属于更明确的类别,相较于用户。例如,一部电影可以是动作片、惊悚片、纪录片或喜剧片,类型之间几乎没有重叠。一个用户可能喜欢这些类别的某种混合;因此,找到相似的电影比找到相似的用户要容易。
神经网络推荐系统
我们可以利用深度学习技术,而不是显式定义相似度度量,来学习特征空间的良好表示和映射。神经网络有多种方法可以用于构建推荐系统。在本章中,我们将展示两种最简单的方法,以展示如何将集成学习融入到系统中。我们将在网络中使用的最重要部分是嵌入层。这些层类型接受整数索引作为输入,并将其映射到 n 维空间。例如,二维映射可以将 1 映射到[0.5, 0.5]。通过这些层,我们将能够将用户的索引和电影的索引输入到网络中,网络将预测特定用户-电影组合的评分。
我们将测试的第一个架构由两个嵌入层组成,在这两个嵌入层的输出上进行点积操作,以预测用户对电影的评分。该架构如下图所示。虽然它不是传统的神经网络,但我们将利用反向传播来训练这两个嵌入层的参数:

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

全连接架构
为了训练网络,我们将使用 Adam 优化器,并使用 均方误差(MSE)作为损失函数。我们的目标是尽可能准确地预测任何给定用户的电影评分。由于嵌入层具有预定的输出维度,我们将使用具有不同维度的多个网络来创建堆叠集成。每个单独的网络将是一个独立的基础学习器,并将使用相对简单的机器学习算法来组合各个预测。
使用 Keras 进行电影推荐
在本节中,我们将使用 Keras 作为深度学习框架来构建我们的模型。Keras 可以通过 pip(pip install keras)或 conda(conda install -c conda-forge keras)轻松安装。为了构建神经网络,我们首先需要理解我们的数据。MovieLens 数据集包含了近 100,000 个样本和 4 个不同的变量:
-
userId:与特定用户对应的数字索引 -
movieId:与特定电影对应的数字索引 -
rating:一个介于 0 和 5 之间的值 -
timestamp:用户评分电影的具体时间
数据集中的一个示例如下表所示。显然,数据集是按照 userId 列排序的。这可能会导致我们的模型出现过拟合问题。因此,我们将在数据分割之前对数据进行洗牌。此外,我们不会在模型中使用 timestamp 变量,因为我们并不关心电影评分的顺序:
| userId | movieId | rating | timestamp |
|---|---|---|---|
| 1 | 1 | 4 | 964982703 |
| 1 | 3 | 4 | 964981247 |
| 1 | 6 | 4 | 964982224 |
| 1 | 47 | 5 | 964983815 |
| 1 | 50 | 5 | 964982931 |
数据集示例
通过查看下图中评分的分布情况,我们可以看到大多数电影的评分为 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_split 和 sklearn 的 metrics:
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_in 和 movie_in(Input)层作为输入,prod(Dot)层作为输出,来定义 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) |
|---|---|
| 基础学习器 5 | 0.7609 |
| 基础学习器 10 | 0.7727 |
| 基础学习器 15 | 0.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 年,才趋于稳定:
| 年份 | 国家数量 |
|---|---|
| 2005 | 27 |
| 2006 | 89 |
| 2007 | 102 |
| 2008 | 110 |
| 2009 | 114 |
| 2010 | 124 |
| 2011 | 146 |
| 2012 | 142 |
| 2013 | 137 |
| 2014 | 145 |
| 2015 | 143 |
| 2016 | 142 |
| 2017 | 147 |
| 2018 | 136 |
每年调查的国家数量
如果我们只考虑最初的 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值小于或等于六,似乎存在一定的稳定性。尽管如此,我们的数据未经任何预处理直接输入到聚类集成中。因此,距离度量可能会受到值较大的特征的支配:
| SizeK | 5 | 10 | 20 | 50 |
|---|---|---|---|---|
| 2 | 0.618 | 0.618 | 0.618 | 0.618 |
| 4 | 0.533 | 0.533 | 0.533 | 0.533 |
| 6 | 0.475 | 0.475 | 0.475 | 0.475 |
| 8 | 0.396 | 0.398 | 0.264 | 0.243 |
| 10 | 0.329 | 0.248 | 0.282 | 0.287 |
| 12 | 0.353 | 0.315 | 0.327 | 0.350 |
| 14 | 0.333 | 0.309 | 0.343 | 0.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值和集成大小。如下表所示,结果与原始实验非常相似:
| SizeK | 5 | 10 | 20 | 50 |
|---|---|---|---|---|
| 2 | 0.618 | 0.618 | 0.618 | 0.618 |
| 4 | 0.533 | 0.533 | 0.533 | 0.533 |
| 6 | 0.475 | 0.475 | 0.475 | 0.475 |
| 8 | 0.393 | 0.396 | 0.344 | 0.264 |
| 10 | 0.311 | 0.355 | 0.306 | 0.292 |
| 12 | 0.346 | 0.319 | 0.350 | 0.350 |
| 14 | 0.328 | 0.327 | 0.326 | 0.314 |
归一化数据的轮廓系数
最后,我们使用 t-SNE 作为预处理步骤重复实验。首先,我们通过from sklearn.manifold import t_sne导入 t-SNE。为了对数据进行预处理,我们调用TSNE的fit_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 来获取数据的洞察:
| SizeK | 5 | 10 | 20 | 50 |
|---|---|---|---|---|
| 2 | 0.537 | 0.537 | 0.537 | 0.537 |
| 4 | 0.466 | 0.466 | 0.466 | 0.466 |
| 6 | 0.405 | 0.405 | 0.405 | 0.405 |
| 8 | 0.343 | 0.351 | 0.351 | 0.351 |
| 10 | 0.349 | 0.348 | 0.350 | 0.349 |
| 12 | 0.282 | 0.288 | 0.291 | 0.288 |
| 14 | 0.268 | 0.273 | 0.275 | 0.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 的情况下进行分析后,我们对集群分配进行了分析。我们发现报告相同幸福水平的国家实际上可能有不同的特征。这些最不幸福的国家通常是发展中国家,它们需要克服许多问题,既包括经济问题,也包括在某些情况下的战争问题。有趣的是,这些国家对政府最有信心,尽管这些政府被认为是功能失调的。属于中等幸福度集群的国家,要么有强大的经济但自由度较低,要么则反之。
经济强大、生活质量高的发达国家,尽管认为政府腐败,却未能获得最高的幸福得分。最后,唯一认为自己政府不腐败的国家,拥有最强的经济、民主与交付质量以及最长的预期寿命。这些国家大多属于欧盟或欧洲经济区,包括加拿大、澳大利亚、新西兰、日本、韩国、哥斯达黎加、以色列、香港和冰岛。
本书涵盖了大多数集成学习技术。在简短的机器学习回顾后,我们讨论了机器学习模型中出现的主要问题。这些问题是偏差和方差。集成学习技术通常试图通过生成方法和非生成方法来解决这些问题。我们讨论了非生成方法,如投票法和堆叠法,以及生成方法,如自助法、提升法和随机森林。此外,我们还介绍了可以用于创建聚类集成的方法,如多数投票法、图闭包法和共现链接法。最后,我们专门花了一些章节介绍了具体应用,以展示如何处理一些现实世界中的问题。如果这本书有需要强调的地方,那就是数据质量对模型性能的影响大于所使用的算法。因此,集成学习技术,如同任何机器学习技术一样,应当用于解决算法的弱点(即之前生成模型的弱点),而非数据质量差的问题。






浙公网安备 33010602011771号