机器学习的集成方法-全-

机器学习的集成方法(全)

原文:Ensemble Methods for Machine Learning

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

从前,我是一名研究生,在充满不尽人意的科研方向和不确定的未来中迷失方向。然后,我偶然发现了一篇题为“支持向量机:炒作还是赞美?”的杰出文章。在 21 世纪初,支持向量机(SVMs)当然是当时领先的机器学习技术。

在这篇文章中,作者们(其中一位后来成为我的博士导师)采取了一种相当简约的方法来解释相当复杂的话题——SVMs,将直觉和几何与理论和应用交织在一起。这篇文章给我留下了深刻的印象,它不仅点燃了我对机器学习终身的好奇心,还让我着迷于理解这些方法在底层是如何工作的。确实,第一章的标题是对那篇对我的生活产生深远影响的文章的致敬。

就像 SVMs 一样,集成方法今天被广泛认为是领先的机器学习技术。但许多人没有意识到的是,几十年来,某种集成方法或另一种方法一直被认为是最佳水平:20 世纪 90 年代的 bagging,21 世纪初的随机森林和 boosting,2010 年代的梯度提升,以及 2020 年代的 XGBoost。在最佳机器学习模型不断变化的世界上,集成方法似乎确实值得这样的炒作。

我很幸运,在过去十年中,我训练了许多种集成模型,将它们应用于工业,并撰写了关于它们的学术论文。在这本书中,我试图展示尽可能多的这些集成方法:一些你肯定听说过的,以及一些你应该真正了解的新方法。

这本书的初衷并不是仅仅是一本带有逐步指令和剪切粘贴代码的教程(尽管你也可以这样使用它)。网上有数十个这样的优秀教程,它们可以让你瞬间开始处理你的数据集。相反,我使用一种沉浸式的方法来讨论每种新的方法,这种方法灵感来源于我第一次阅读的那篇机器学习论文,并在大学课堂中作为研究生讲师期间进行了完善。

我一直觉得,要深入理解一个技术主题,将其拆解、拆分并尝试重新组合是很有帮助的。在这本书中,我采用了同样的方法:我们将拆解集成方法并(重新)自己创造它们。我们将调整它们并探究它们的变化。通过这样做,我们将确切地看到是什么让它们运转!

我希望这本书能帮助你揭开那些技术和算法细节的神秘面纱,并让你进入集成思维模式,无论是为了你的课堂项目、Kaggle 竞赛,还是生产质量的软件应用。

致谢

我从未想过一本关于集成方法的书籍本身会变成一个由家人、朋友、同事和合作者组成的集成努力,他们从构思到完成都与这本书有很大关系。

致 Brian Sawyer,感谢你让我提出这本书的想法,感谢你相信这个项目,感谢你的耐心,以及保持我按计划进行:感谢你给我这个机会去做我一直想做的事情。

致我的第一位发展编辑 Katherine Olstein、第二位发展编辑 Karen Miller 和技术发展编辑 Alain Couniot:当我开始时,我对这本书的样貌有一个愿景,你们帮助让它变得更好。感谢你们细致入微的审阅,感谢你们敏锐的编辑,以及你们总是挑战我成为一个更好的作家。你们的努力与这本书的最终质量有很大关系。

致 Manish Jain:感谢你逐行仔细校对代码。致 Marija Tudor:感谢你设计这个绝对出色的封面(我认为这是这本书最好的部分),按照我的要求将其设计成橙色,并且从封面到封底进行了排版。感谢 Manning 出版社的校对和生产团队:感谢你们卓越的工艺——这本书看起来完美——审稿编辑 Mihaela Batinic、生产编辑 Kathleen Rossland、校对编辑 Julie McNamee 和校对员 Katie Tennant。

致我的审稿人,Al Krinker、Alain Lompo、Biswanath Chowdhury、Chetan Saran Mehra、Eric Platon、Gustavo A. Patino、Joaquin Beltran、Lucian Mircea Sasu、Manish Jain、McHugson Chambers、Ninoslav Cerkez、Noah Flynn、Oliver Korten、Or Golan、Peter V. Henstock、Philip Best、Sergio Govoni、Simon Seyag、Stephen John Warnett、Subhash Talluri、Todd Cook 和 Xiangbo Mao:感谢你们精彩的反馈,以及一些真正出色的洞察和评论。我尽量吸收了你们的所有建议(我真的这么做了),其中许多已经融入到这本书中。

致在早期访问期间阅读这本书并留下许多评论、更正和鼓励话语的读者——你知道你是谁——感谢你的支持!

致我的导师,Kristin Bennett、Jong-Shi Pang、Jude Shavlik、Sriraam Natarajan 和 Maneesh Singh,他们在我的学生、博士后、教授和专业人士的不同阶段深刻地塑造了我的思考:感谢你们教我如何用机器学习思考,如何用机器学习说话,以及如何用机器学习构建。你们的大部分智慧和许多教训都体现在这本书中。Kristin,我希望你喜欢第一章的标题。

致 Jenny 和 Guilherme de Oliveira,感谢你们多年来建立的友谊,尤其是在这场大流行期间,当时这本书的大部分内容都在撰写中:感谢你们让我保持理智。我永远珍视我们 2020 年那个夏天和秋天的下午和晚上,在你们的小后院里,我们的避难所。

致我的父母,Vijaya 和 Shivakumar,以及我的兄弟,Anupam:感谢你们一直相信我,并一直支持我,即使相隔数万公里。我知道你们为我感到骄傲。这本书终于完成了,现在我们可以做那些我们一直谈论的其他事情了……至少在我开始写下一本书之前。

致我的妻子、最好的朋友和最大的支持者,Kristine:你是我无尽的安慰和鼓励的源泉,尤其是在事情变得艰难的时候。感谢你和我一起头脑风暴,感谢你和我一起校对,感谢茶点和零食,感谢 Gus,感谢你牺牲所有周末(有时甚至是工作日夜晚)来陪我写作。感谢你一直支持我,一直在我身边,从未怀疑过我能做到这一点。我爱你!

关于本书

学习集成方法从未有过更好的时机。本书涵盖的模型分为三大类:

  • 基础集成方法——大家耳熟能详的经典方法,包括历史性的集成技术,如 Bagging、随机森林和 AdaBoost

  • 最先进的集成方法——现代集成时代的经过检验的强大工具,是许多真实世界、在生产中的预测、推荐和搜索系统的核心

  • 新兴集成方法——最新方法,刚刚从研究熔炉中出炉,用于处理新的需求,如可解释性和可理解性

每一章都将介绍不同的集成技术,采用三管齐下的方法。首先,通过逐步可视化学习过程,你将了解每个集成方法的直觉。其次,你将实现每个集成方法的基本版本,以全面理解算法的细节。第三,你将学习如何实际应用强大的集成库和工具。

大多数章节还附带了自己在真实世界数据上的案例研究,这些数据来自手写数字预测、推荐系统、情感分析、需求预测等领域。这些案例研究在适当的时候解决了几种真实世界问题,包括预处理和特征工程、超参数选择、高效训练技术和有效模型评估。

应该阅读这本书的人

本书面向广泛的读者:

  • 对使用集成方法从数据中获取最佳实际应用效果感兴趣的数据科学家

  • 构建、评估和部署基于集成、生产就绪的应用程序和管道的 MLOps 和 DataOps 工程师

  • 希望将本书作为学习资源或作为补充教科书的实际参考的数据科学和机器学习的学生

  • 使用本书作为学习关于使用集成方法探索无限建模可能性的入门点的 Kagglers 和数据科学爱好者

本书不是机器学习和数据科学的入门书。本书假设你有一些基本的机器学习工作知识,并且你已经使用或尝试过至少一种基本的学习技术(例如,决策树)。

假设读者具备基本的 Python 工作知识。示例、可视化和章节案例研究都使用 Python 和 Jupyter Notebooks。了解其他常用 Python 包,如 NumPy(用于数学计算)、pandas(用于数据处理)和 Matplotlib(用于可视化)是有用的,但不是必需的。实际上,你可以通过示例和案例研究来学习如何使用这些包。

本书是如何组织的:一个路线图

本书分为三部分,共九章。第一部分是对集成方法的温和介绍,第二部分介绍并解释了几个重要的集成方法,第三部分涵盖了高级主题。

第一部分,“集成的基础”,介绍了集成方法以及为什么你应该关注它们。本部分还包含本书其余部分涵盖的集成方法的路线图:

  • 第一章讨论了集成方法和基本集成术语。它还介绍了拟合与复杂度权衡(或称为偏差-方差权衡,更为正式的称呼)。你将在本章构建你的第一个集成。

第二部分,“基本集成方法”,涵盖了几个重要的集成方法家族,其中许多被认为是“基本”的,并且在现实世界的应用中得到了广泛使用。在每一章中,你将学习如何从头开始实现不同的集成方法,了解它们的工作原理以及如何将它们应用于现实世界问题:

  • 第二章以并行集成方法开始我们的旅程,具体来说是并行同质集成。涵盖的集成方法包括 bagging、随机森林、pasting、随机子空间、随机补丁和 Extra Trees。

  • 第三章继续旅程,引入了更多的并行集成,但本章的重点在于并行异构集成。涵盖的集成方法包括通过多数投票组合基础模型、通过加权组合、Dempster-Shafer 预测融合以及通过堆叠进行元学习。

  • 第四章介绍了另一类集成方法——顺序自适应集成,特别是将许多弱模型提升为一个强大模型的基本概念。涵盖的集成方法包括 AdaBoost 和 LogitBoost。

  • 第五章建立在提升的基础概念之上,并涵盖了另一个基本的顺序集成方法——梯度提升,它将梯度下降与提升相结合。本章讨论了如何使用 scikit-learn 和 LightGBM 训练梯度提升集成。

  • 第六章继续探讨使用牛顿提升的顺序集成方法,牛顿提升是梯度提升的一个高效且有效扩展,它结合了牛顿下降和提升。本章讨论了如何使用 XGBoost 训练牛顿提升集成。

第三部分,“野外的集成:将集成方法应用于您的数据”,展示了如何将集成方法应用于许多场景,包括具有连续和计数值标签的数据集以及具有分类特征的数据集。您还将学习如何解释您的集成并解释其预测:

  • 第七章展示了我们如何为不同类型的回归问题和广义线性模型训练集成,其中训练标签是连续值或计数值。本章涵盖了线性回归、泊松回归、伽马回归和 Tweedie 回归的并行和顺序集成。

  • 第八章确定了使用非数值特征学习时的挑战,特别是分类特征和编码方案,这些方案将帮助我们为这类数据训练有效的集成。本章还讨论了两个重要的实际问题:数据泄露和预测偏移。最后,我们将看到如何使用有序提升和 CatBoost 克服这些问题。

  • 第九章从集成方法的角度覆盖了新兴且非常重要的可解释人工智能(Explainable AI)主题。本章介绍了可解释性的概念以及为什么它很重要。还讨论了几种常见的黑盒可解释性方法,包括排列特征重要性、部分依赖图、代理方法、局部可解释模型无关解释(Locally Interpretable Model-Agnostic Explanation)、Shapley 值和 SHapley 加性解释(SHapley Additive exPlanations)。还介绍了玻璃盒集成方法、可解释提升机(Explainable Boosting Machines)和 InterpretML 包。

  • 序言以进一步探索和阅读的额外主题结束了我们的旅程。

虽然本书的大部分章节可以独立阅读,但第七章、第八章和第九章建立在本书的第二部分基础上。

关于代码

本书中的所有代码和示例都是用 Python 3 编写的。代码组织成 Jupyter Notebooks,可在在线 GitHub 仓库(github.com/gkunapuli/ensemble-methods-notebooks)和 Manning 网站(www.manning.com/books/ensemble-methods-for-machine-learning)上下载。您可以从本书的 liveBook(在线)版本中获取可执行的代码片段,网址为livebook.manning.com/book/ensemble-methods-for-machine-learning

本书还使用了几个 Python 科学和可视化库,包括 NumPy (numpy.org/)、SciPy (scipy.org/)、pandas (pandas.pydata.org/)和 Matplotlib (matplotlib.org/)。代码还使用了几个 Python 机器学习和集成方法库,包括 scikit-learn (scikit-learn.org/stable/)、LightGBM (lightgbm.readthedocs.io/)、XGBoost (xgboost.readthedocs.io/)、CatBoost (catboost.ai/)和 InterpretML (interpret.ml/)。

本书包含许多源代码示例,既有编号列表中的,也有与普通文本并行的。在这两种情况下,源代码都使用固定宽度字体格式化,如这样,以将其与普通文本区分开来。在许多情况下,原始源代码已被重新格式化;我们添加了换行并重新调整了缩进,以适应书中的可用页面空间。此外,当代码在文本中描述时,源代码中的注释通常已被从列表中删除。许多列表都伴随着代码注释,突出显示重要概念。

liveBook 讨论论坛

购买《机器学习集成方法》包括免费访问 Manning 的在线阅读平台 liveBook。使用 liveBook 的独家讨论功能,您可以在全球范围内或针对特定章节或段落添加评论。为自己做笔记、提问和回答技术问题以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问livebook.manning.com/book/ensemble-methods-for-machine-learning/discussion。您还可以在livebook.manning.com/discussion了解更多关于 Manning 论坛和行为准则的信息。

Manning 对我们读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量活动的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他的兴趣转移!只要本书有售,论坛和先前讨论的存档将可通过出版社的网站访问。

关于作者

FM_UN01_Kunapuli

戈塔姆·库纳普利在学术界和机器学习行业拥有超过 15 年的经验。他的工作专注于人机交互学习、基于知识和采纳建议的学习算法,以及针对困难机器学习问题的可扩展学习。戈塔姆为多个应用领域开发了几个新颖的算法,包括社交网络分析、文本和自然语言处理、计算机视觉、行为挖掘、教育数据挖掘、保险和金融分析以及生物医学应用。他还发表了关于关系域和失衡数据中集成方法的论文。

关于封面插图

《机器学习集成方法》封面上的图像是“胡安或中国音乐家”,或“胡安或中国音乐家”,来自雅克·格拉塞·德·圣索沃尔的收藏,1788 年出版。每一幅插图都是手工精细绘制和着色的。

在那些日子里,仅凭人们的服饰就能轻易识别出他们居住的地方以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地域文化的书封面来庆祝计算机行业的创新精神和主动性,这些文化通过如这一系列图片的图片被重新带回生活。

第一部分 集成的基础

你可能听说过很多关于“随机森林”、“XGBoost”或“梯度提升”的事情。似乎总有人在使用其中之一来构建酷炫的应用或赢得 Kaggle 竞赛。你有没有想过这究竟是怎么回事?

事实上,所有的喧嚣都是关于集成方法,这是一种强大的机器学习范式,它已经进入到了医疗保健、金融、保险、推荐系统、搜索以及许多其他领域的各种应用中。

这本书将带你进入集成方法的广阔世界,这一部分将帮助你入门。用《音乐之声》中无可比拟的朱莉·安德鲁斯的话来说,

让我们从一开始,

一个非常好的开始地方。

当你阅读时,你从 A-B-C 开始。

当你进行集成时,你从拟合与复杂度开始。

这本书的第一部分将温和地介绍集成方法,结合一些关于拟合与复杂度(或更正式地称为偏差-方差权衡)的直觉和理论。然后,你将从头开始构建你的第一个集成模型。

当你完成这本书的这一部分后,你会明白为什么集成模型通常比单个模型更好,为什么你应该关注它们。

1 集成方法:炒作还是赞歌?

本章节涵盖

  • 定义和构建集成学习问题

  • 在不同应用中阐述对集成方法的需求

  • 理解集成方法如何处理拟合与复杂度

  • 实现我们的第一个集成:集成多样性和模型聚合

2006 年 10 月,Netflix 宣布了一项 100 万美元的奖金,奖励能够通过 Netflix 自有的专有推荐系统 CineMatch 将电影推荐准确率提高 10%的团队。Netflix 大奖赛是历史上第一个开放数据科学竞赛,吸引了成千上万的团队。

训练集由 480,000 名用户对 17,000 部电影给出的 1 亿条评分组成。在短短三周内,已有 40 个团队击败了 CineMatch 的结果。到 2007 年 9 月,已有超过 40,000 个团队参加了比赛,AT&T 实验室的团队通过将 CineMatch 的准确率提高了 8.42%,赢得了 2007 年的进步奖。

随着竞赛的进行,10%的目标仍然遥不可及,竞争者中出现了一种奇特的现象。团队开始合作,分享关于有效特征工程、算法和技术方面的知识。不可避免地,他们开始结合自己的模型,将个别方法融合成许多模型的强大而复杂的集成。这些集成结合了各种不同模型和特征的最佳之处,并且证明比任何单个模型都更有效。

在比赛开始近两年后的 2009 年 6 月,BellKor 的 Pragmatic Chaos 团队(由三个不同的团队合并而成)在另一个合并团队 The Ensemble(由超过 30 个团队合并而成)之前,通过将基准提高了 10%,赢得了 100 万美元的奖金。“勉强领先”这个说法有点低估了 BellKor 的 Pragmatic Chaos 团队,因为他们几乎在 The Ensemble 提交模型的前 20 分钟内提交了他们的最终模型(mng.bz/K08O)。最终,两队都实现了 10.06%的最终性能提升。

尽管 Netflix 竞赛吸引了全球数据科学家、机器学习者和普通数据科学爱好者的想象力,但其持久的影响在于确立了集成方法作为构建大规模、实际应用中强大且稳健模型的一种有效方式。其中使用的个别算法中,有几个已经成为今天协同过滤和推荐系统的基础:k 近邻算法、矩阵分解和受限玻尔兹曼机。然而,BigChaos 的 Andreas Töscher 和 Michael Jahrer,Netflix 大奖赛的共同获奖者,总结了¹他们成功的关键:

在近 3 年的 Netflix 竞赛中,有两个主要因素提高了整体准确度:个别算法的质量和集成理念。……集成理念从一开始就是比赛的一部分,并随着时间的推移而发展。一开始,我们使用了不同参数化的不同模型,并采用线性混合。……[最终]线性混合被非线性混合所取代。

自那以后,集成方法的使用急剧增加,它们已经成为机器学习领域的一项尖端技术。

接下来的两个部分将温和地介绍集成方法是什么,为什么它们有效,以及它们的应用领域。然后,我们将探讨所有机器学习算法普遍存在的微妙但重要的挑战:拟合与复杂度之间的权衡

最后,我们将着手训练我们第一个集成方法,以便直观地了解集成方法是如何克服拟合与复杂度之间的权衡,并提高整体性能的。在这个过程中,你将熟悉到几个关键术语,这些术语构成了集成方法的词汇表,并在整本书中都会用到。

1.1 集成方法:众人的智慧

集成方法究竟是什么?让我们通过考虑兰迪·福雷斯特博士的寓言案例来获得对集成方法及其工作原理的直观理解。然后,我们可以继续构建集成学习问题。

兰迪·福雷斯特博士是一位著名且成功的诊断专家,就像他崇拜的电视名人格雷戈里·豪斯博士一样。然而,他的成功并不仅仅是因为他超越的礼貌(与他的愤世嫉俗且脾气暴躁的偶像不同),还因为他诊断方法上的相当不寻常。

你看,福雷斯特博士在一家教学医院工作,并受到众多实习医生的尊敬。福雷斯特博士特别注意组建了一个具有技能多样性的团队(这非常重要,我们很快就会看到原因)。他的住院医生在各个专业领域都很出色:一个擅长心脏病学(心脏),另一个擅长肺病学(肺部),还有一个擅长神经学(神经系统),等等。总的来说,这个团队是一个相当多样化的技能组合,每个人都拥有自己的优势。

每当福雷斯特博士接诊一个新病例时,他会征求住院医生的意见,并收集他们所有人的可能诊断(见图 1.1)。然后,他民主地选择所有提出的诊断中最常见的一个作为最终的诊断。

CH01_F01_Kunapuli

图 1.1 每次福雷斯特博士接诊新病例时,他都会询问所有住院医生对该病例的意见。他的住院医生会提供诊断:病人是否有癌症。然后,福雷斯特博士选择多数答案作为他团队提出的最终诊断。

弗罗斯特博士体现了一个诊断集成:他将他的住院医生的诊断汇总成一个代表团队集体智慧的单一诊断。结果证明,弗罗斯特博士比任何单个住院医生都正确,因为他知道他的住院医生都很聪明,而且大量聪明的住院医生不太可能都犯同样的错误。在这里,弗罗斯特博士依赖于模型聚合模型平均化的力量:他知道平均答案最有可能是一个好的答案。

然而,弗罗斯特博士如何知道他的所有住院医生都没有错呢?当然,他不能确定这一点。然而,他仍然防范了这种不希望的结果。记住,他的住院医生都有不同的专业。由于他们不同的背景、培训、专业和技能,所有他的住院医生都可能是错的,但这可能性非常小。在这里,弗罗斯特博士依赖于集成多样性,即他集成中个体组件的多样性。

当然,兰迪·弗罗斯特博士是一个集成方法,他的住院医生(正在接受培训)是构成集成的机器学习算法。他成功的关键,以及集成方法的成功,在于

  • 集成多样性——他有各种各样的意见可供选择。

  • 模型聚合——他可以将这些意见合并成一个最终的看法。

任何机器学习算法的集合都可以用来构建一个集成,字面上讲,就是一个机器学习者的群体。但为什么它们会起作用呢?詹姆斯·苏罗维基在《群体的智慧》一书中这样描述人类集成或明智的群体:

如果你让足够多、不同且独立的人群做出预测或估计概率,这些答案的平均值将抵消个别估计中的错误。可以说,每个人的猜测都有两个组成部分:信息和错误。减去错误,你剩下的是信息。

这也正是学习者集成背后的直觉:通过聚合单个学习者,可以构建一个明智的机器学习集成。

集成方法

形式上,集成学习方法是一种机器学习算法,旨在通过聚合多个估计器或模型的预测来提高任务上的预测性能。以这种方式,集成学习方法学习一个元估计器

集成方法成功的关键是集成多样性,也被称为模型互补性或模型正交性的其他术语。非正式地说,集成多样性指的是集成组件或机器学习模型之间彼此不同的事实。在集成学习中训练这样的多样性个体模型集是一个关键挑战,不同的集成方法以不同的方式实现这一点。

1.2 为什么你应该关注集成学习

你可以用集成方法做什么?它们真的是炒作,还是真的值得赞美?正如我们在本节中看到的,它们可以用于训练和部署针对许多不同应用的稳健和有效的预测模型。

集成方法的一个显著成功是它们在数据科学竞赛(与深度学习并驾齐驱)中的主导地位,在这些竞赛中,它们在多种机器学习任务和应用领域上通常都取得了成功。

Kaggle 的首席执行官 Anthony Goldbloom 在 2015 年透露,对于结构化问题最成功的三个算法是 XGBoost、随机森林和梯度提升,它们都是集成方法。确实,如今解决数据科学竞赛最流行的方式是将特征工程与集成方法相结合。结构化数据通常以表格、关系数据库和其他我们大多数人熟悉的格式组织,集成方法已经证明在这种类型的数据上非常成功。

相比之下,非结构化数据并不总是具有表格结构。图像、音频、视频、波形和文本数据通常是未结构化的,深度学习方法——包括自动特征生成——在这些类型的数据上已经非常成功。虽然我们在这本书的大部分内容中关注结构化数据,但集成方法也可以与深度学习结合来解决非结构化问题。

除了竞赛之外,集成方法在多个领域推动数据科学的发展,包括金融和商业分析、医学和医疗保健、网络安全、教育、制造业、推荐系统、娱乐等。

在 2018 年,Olson 等人²对 14 种流行的机器学习算法及其变体进行了全面分析。他们评估了每个算法在 165 个分类基准数据集上的性能。他们的目标是模拟标准的机器学习流程,以提供有关如何选择机器学习算法的建议。

这些综合结果被汇总到图 1.2 中。每一行显示了在所有 165 个数据集中,一个模型相对于其他模型的表现频率。例如,XGBoost 在 165 个基准数据集中有 34 个胜过梯度提升(第一行,第二列),而梯度提升在 165 个基准数据集中有 12 个胜过 XGBoost(第二行,第一列)。在剩余的 119 个数据集中,它们的性能非常相似,这意味着两个模型在 119 个数据集上的表现相当。

CH01_F02_Kunapuli

图 1.2 我应该为我的数据集使用哪种机器学习算法?这里展示了几个不同的机器学习算法在 165 个基准数据集上的性能,相对于彼此。根据它们在所有基准数据集上的性能相对于所有其他方法的排名(从上到下,从左到右),最终训练好的模型被排序。在评估中,Olson 等人认为,如果两种方法的预测准确率相差在 1% 以内,则认为它们在数据集上的性能相同。此图使用作者编译的代码库和综合实验结果重新生成,这些结果包含在公开可用的 GitHub 仓库中(github.com/rhiever/sklearn-benchmarks),并包括作者对 XGBoost 的评估。

相比之下,XGBoost 在 165 个数据集中击败了多项式朴素贝叶斯(MNB)中的 157 个(第一行,最后一列),而 MNB 只在 165 个数据集中的 2 个(最后一行,第一列)上击败了 XGBoost,并且只能在 165 个数据集中的 6 个上与 XGBoost 匹配!

通常情况下,集成方法(1:XGBoost,2:梯度提升,3:Extra Trees,4:随机森林,8:AdaBoost)在性能上明显优于其他方法。这些结果恰好说明了为什么集成方法(特别是基于树的集成方法)被认为是当前最先进的。

如果你的目标是开发从你的数据中提取最先进的分析,或者是为了提高性能并改进你已有的模型,这本书适合你。如果你的目标是为了在数据科学竞赛中更有效地竞争以获得名利或者只是提高你的数据科学技能,这本书也适合你。如果你对将强大的集成方法添加到你的机器学习工具箱中感到兴奋,这本书绝对适合你。

为了强调这一点,我们将构建我们的第一个集成方法:一个简单模型组合集成。在我们这样做之前,让我们深入了解大多数机器学习方法必须处理的拟合和复杂性的权衡,这将帮助我们理解为什么集成方法如此有效。

1.3 单个模型中的拟合与复杂度

在本节中,我们将探讨两种流行的机器学习方法:决策树和支持向量机(SVMs)。在探讨过程中,我们将了解它们在学习越来越复杂的模型时,其拟合和预测行为是如何变化的。本节还作为我们在建模过程中通常遵循的训练和评估实践的复习。

机器学习任务通常是

  • 监督学习任务—这些任务有一个带有标记示例的数据集,其中数据已经被标注。例如,在癌症诊断中,每个示例将是一个单独的患者,带有标签/注释“有癌症”或“没有癌症”。标签可以是 0-1(二元分类)、分类(多类分类)或连续(回归)。

  • 无监督学习任务——这些任务具有一个未标记示例的数据集,其中数据缺乏注释。这包括诸如通过某种“相似性”概念将示例分组在一起(聚类)或识别不符合预期模式的异常数据(异常检测)等任务。

我们将创建一个简单、人工生成的、监督回归数据集,以说明训练机器学习模型的关键挑战,并激发对集成方法的需求。使用这个数据集,我们将训练越来越复杂的机器学习模型,这些模型在训练过程中拟合数据,并最终过度拟合数据。正如我们将看到的,训练过程中的过度拟合并不一定会产生泛化能力更好的模型。

1.3.1 决策树回归

最受欢迎的机器学习模型之一是决策树,³,它可以用于分类以及回归任务。决策树由决策节点和叶节点组成,每个决策节点测试当前示例的特定条件。

例如,在图 1.3 中,我们使用决策树分类器对一个具有两个特征的数据集进行二元分类任务,这两个特征是x[1]和x[2]。第一个节点测试每个输入示例,看第二个特征x[2]是否大于 5,然后根据结果将示例引导到决策树的右侧或左侧分支。这个过程一直持续到输入示例达到叶节点;在这个时候,返回与叶节点对应的预测。对于分类任务,叶值是一个类标签,而对于回归任务,叶节点返回一个回归值。

CH01_F03_Kunapuli

图 1.3 决策树将特征空间划分为轴平行的矩形。当用于分类时,树在决策节点上检查特征的条件,在每个测试后引导示例向左或向右。最终,示例过滤到一个叶节点,该节点将给出其分类标签。根据此决策树对特征空间的划分如图左侧所示。

深度为 1 的决策树称为决策桩,是最简单的树。决策桩包含一个决策节点和两个叶节点。一个浅决策树(例如,深度为 2 或 3)将具有少量决策节点和叶节点,是一个简单的模型。因此,它只能表示简单的函数。

另一方面,一个更深的决策树是一个更复杂的模型,具有更多的决策节点和叶节点。因此,一个更深的决策树可以表示更丰富和更复杂的函数。

决策树中的拟合与复杂度

我们将在一个名为Friedman-1的合成数据集的背景下探索模型拟合和表示复杂度之间的这种权衡,该数据集最初由杰罗姆·弗里德曼于 1991 年创建,用于探索他的新多元自适应回归样条(MARS)算法在拟合高维数据方面的表现。

这个数据集是精心生成的,用于评估回归方法仅能从数据集中提取真实特征依赖关系的能力,并忽略其他关系。更具体地说,数据集生成了 15 个随机生成的特征,其中只有前 5 个特征与目标变量相关:

CH01_F03_Kunapuli-E01

scikit-learn 包含一个内置函数,我们可以使用它来生成尽可能多的数据:

from sklearn.datasets import make_friedman1
X, y = make_friedman1(n_samples=500,           ❶
                      n_features=15,           ❷
                      noise=0.3,               ❸
                      random_state=23)

❶ 生成包含 500 个示例的数据集

❷ 每个示例将包含 15 个特征。

❸ 对每个标签添加高斯噪声,使其更真实

我们将随机将数据集分成训练集(包含 67%的数据)和测试集(包含 33%的数据),以更清楚地说明复杂度与拟合之间的效应。

TIP 在建模过程中,我们通常需要将数据分成训练集和测试集。这些集应该有多大?如果构成训练集的数据比例太小,模型将没有足够的数据进行训练。如果构成测试集的数据比例太小,我们关于模型在未来的数据上表现如何的泛化估计将会有更高的变化。对于中等或大型数据集(称为帕累托原则),一个好的经验法则是从 80%-20%的训练-测试分割开始。对于小型数据集,另一个好的规则是使用留一法,每次评估时留出一个示例,并针对每个示例重复整体训练和评估过程。

对于不同的深度 d = 1 到 10,我们在训练集上训练一棵树,并在测试集上评估它。当我们查看不同深度的训练误差和测试误差时,我们可以确定“最佳树”的深度。我们用评估指标来定义“最佳”。对于回归问题,有几个评估指标:均方误差(MSE)、平均绝对偏差(MAD)、决定系数等。

我们将使用决定系数,也称为R² 得分,它衡量标签(y)中可从特征(x)预测的方差比例。

决定系数

决定系数(R²)是回归性能的一个度量。R² 是从特征中可预测的真实标签方差的比例。R² 取决于两个量:1) 真实标签中的总方差,或称 总平方和(TSS);2) 真实标签与预测标签之间的均方误差(MSE),或称 残差平方和(RSS)。我们有 R² = 1 - RSS / TSS。一个完美的模型将没有预测误差,即 RSS = 0,其对应的 R² = 1。真正好的模型其 R² 值接近 1。一个真正差的模型将会有高的预测误差和高 RSS。这意味着对于真正差的模型,我们可能会有负的 R²。

最后要注意的一点是,我们正在将数据随机分割为训练集和测试集,这意味着我们的分割可能会非常幸运或非常不幸。为了避免随机性的影响,我们重复实验 K = 5 次,并在运行之间平均结果。为什么是 5?这个选择通常是相当任意的,你将不得不决定你是否想要测试误差的更小变化(K 的较大值)或更少的计算时间(K 的较小值)。

我们的实验伪代码如下:

for run = 1:5
    (Xtrn, ytrn), (Xtst, ytst) = split data (X), labels (y) into 
                                 training & test subsets randomly
    for depth d = 1:10
        tree[d] = train decision tree of depth d on the                   training subset (Xtrn, ytrn)
        train_scores[run, d] = compute R2 score of tree[d] on the 
                               training set (Xtrn, ytrn)
        test_scores[run, d]  = compute R2 score of tree[d] on the 
                               test set (Xtst, ytst)
mean_train_score = average train_scores across runs
mean_test_score = average test_scores across runs

以下代码片段正是这样做的,然后它绘制了训练和测试分数。而不是明确实现前面的伪代码,以下代码使用 scikit-learn 函数 sklearn.model_selection.ShuffleSplit 自动将数据分割成五个不同的训练集和测试集,并使用 sklearn.model_selection.validation_curve 确定不同决策树深度的 R² 分数:

import numpy as np
from sklearn.tree import DecisionTreeRegressor
from sklearn.model_selection import ShuffleSplit
from sklearn.model_selection import validation_curve

subsets = ShuffleSplit(n_splits=5, test_size=0.33, 
                       random_state=23)                            ❶

model = DecisionTreeRegressor()
trn_scores, tst_scores = validation_curve(model, X, y,             ❷
                                          param_name='max_depth', 
                                          param_range=range(1, 11),
                                          cv=subsets, scoring='r2')
mean_train_score = np.mean(trn_scores, axis=1) 
mean_test_score = np.mean(tst_scores, axis=1)  

❶ 设置了五种不同的数据随机分割为训练集和测试集

❷ 对于每个分割,训练深度从 1 到 10 的决策树,然后在测试集上进行评估

记住,我们的最终目标是构建一个能够很好地 泛化 的机器学习模型,也就是说,一个在 未来未见数据 上表现良好的模型。因此,我们的第一个本能将是训练一个达到最小训练误差的模型。这样的模型通常会很复杂,以便尽可能多地拟合训练示例。毕竟,一个复杂的模型可能会很好地拟合我们的训练数据并具有小的训练误差。自然而然地,我们会假设一个达到最小训练误差的模型在未来的泛化能力也应该很好,并且能够同样好地预测未见示例。

现在,让我们看看图 1.4 中的训练和测试分数,看看这是否如此。记住,R² 分数接近 1 表示一个非常好的回归模型,而分数远离 1 表示模型更差。

深度更大的决策树更复杂,具有更强的表达能力,因此看到深度更大的树更好地拟合训练数据并不奇怪。这从图 1.4 中很明显:随着树深度(模型复杂度)的增加,训练分数接近 R² = 1。因此,更复杂的模型在训练数据上达到更好的拟合。

CH01_F04_Kunapuli

图 1.4 使用 R² 作为评估指标,比较了不同深度的决策树在 Friedman-1 回归数据集上的表现。更高的 R² 分数意味着模型达到更低的误差并更好地拟合数据。一个接近 1 的 R² 分数意味着模型达到几乎零误差。使用非常深的决策树几乎可以完美地拟合训练数据,但这样的过度复杂模型实际上过度拟合了训练数据,并且对未来数据的泛化能力不好,如测试分数所示。

然而,令人惊讶的是,测试 R² 分数并没有随着复杂性的增加而相应地持续增加。事实上,超过 max_depth=4 后,测试分数保持相当稳定。这表明深度为 8 的树可能比深度为 4 的树更适合训练数据,但这两棵树在尝试泛化和对新数据进行预测时表现大致相同!

随着决策树的深度增加,它们变得更加复杂,并实现更低的训练误差。然而,它们对未来数据(通过测试分数估计)的泛化能力并没有持续下降。这是一个相当反直觉的结果:在训练集上拟合最好的模型不一定是在实际应用中用于预测的最佳模型。

我们可能会认为我们在随机划分训练集和测试集时运气不好。然而,我们用五个不同的随机划分进行了实验,并平均了结果以避免这种情况。为了确保,让我们用另一种众所周知的机器学习方法重复这个实验:支持向量回归。⁴

1.3.2 使用支持向量机进行回归

与决策树一样,支持向量机(SVMs)是一种很好的现成建模方法,大多数软件包都提供了 SVMs 的稳健实现。你可能已经使用过 SVMs 进行分类,在这种情况下,可以使用径向基函数(RBF)核或多项式核等核学习相当复杂的非线性模型。SVMs 也已被用于回归,并且与分类情况一样,它们在训练过程中试图找到一个在正则化和拟合度之间进行权衡的模型。具体来说,SVM 训练试图找到一个模型来最小化

CH01_F04_Kunapuli-E02

正则化项衡量模型的平坦度:它被最小化的程度越高,学习到的模型就越线性、越简单。损失项通过 损失函数(通常是均方误差 MSE)衡量对训练数据的拟合度:它被最小化的程度越高,对训练数据的拟合度就越好。正则化 参数 C 在这两个相互竞争的目标之间进行权衡:

  • C 的值较小意味着模型将更多地关注正则化和简单性,而较少关注训练误差,这导致模型具有更高的训练误差和 欠拟合

  • C的值较大意味着模型将更多地关注训练错误,并学习更复杂的模型,这导致模型具有更低的训练错误率,并可能过拟合

我们可以在图 1.5 中看到增加C值对学习模型的影响。特别是,我们可以可视化拟合与复杂度之间的权衡。

CH01_F05_Kunapuli

图 1.5 使用 RBF 核的支持向量机,核参数 gamma = 0.75。小的C值导致更线性(更平坦)且复杂度更低的模型,这些模型欠拟合数据,而大的C值导致更非线性(更弯曲)且更复杂的模型,这些模型过拟合数据。选择合适的C值对于训练一个好的 SVM 模型至关重要。

注意:SVMs 识别支持向量,这是一个较小的训练示例集合,模型依赖于它。计算支持向量的数量并不是衡量模型复杂性的有效方法,因为小的C值会限制模型更多,迫使它在最终模型中使用更多的支持向量。

支持向量机中的拟合与复杂度

与 DecisionTreeRegressor()中的 max_depth 类似,支持向量回归(SVR)中的参数 C 可以调整以获得具有不同行为的模型。同样,我们面临着相同的问题:哪个是最好的模型?为了回答这个问题,我们可以重复与决策树相同的实验:

from sklearn.svm import SVR

model = SVR(kernel='rbf', gamma=0.1)
trn_scores, tst_scores = validation_curve(model, X, y.ravel(),    
                                          param_name='C',  
                                          param_range=np.logspace(-2, 4, 7), 
                                          cv=subsets, scoring='r2')

mean_train_score = np.mean(trn_scores, axis=1) 
mean_test_score = np.mean(tst_scores, axis=1)  

在这个代码片段中,我们使用三阶多项式核训练了一个 SVM。我们尝试了七个C的值——10^-3, 10^-2, 10^-1, 1, 10, 10², 和 10³——并像之前一样,在图 1.6 中可视化训练和测试分数。

CH01_F06_Kunapuli

图 1.6 使用R²作为评估指标,比较了不同复杂度的 SVM 回归器在 Friedman-1 回归数据集上的表现。与决策树一样,高度复杂的模型(对应于更高的C值)似乎在训练数据上实现了惊人的拟合,但实际上泛化能力并不强。这意味着随着C的增加,过拟合的可能性也增加。

再次,出人意料的是,在训练集上拟合最好的模型并不一定是当部署到现实世界中的最佳预测模型。实际上,每个机器学习算法都表现出这种行为:

  • 过度简单的模型往往不能正确拟合训练数据,并且对未来数据的泛化能力较差;一个在训练和测试数据上表现不佳的模型是欠拟合

  • 过度复杂的模型虽然可以达到非常低的训练错误率,但往往在未来的数据上泛化能力较差;一个在训练数据上表现良好,但在测试数据上表现不佳的模型是过拟合

  • 最佳模型在复杂度和拟合之间进行权衡,在训练过程中牺牲一点每一项,以便在部署时能够最有效地泛化。

正如我们将在下一节中看到的,集成方法是解决拟合与复杂度问题的有效方法。

偏差-方差权衡

我们之前非正式讨论的拟合与复杂度权衡问题更正式地被称为偏差-方差权衡。模型的偏差是指由建模假设(例如对简单模型的偏好)引起的误差。模型的方差是指由对数据集微小变化的敏感性引起的误差。

高度复杂的模型(低偏差)会过度拟合数据并对噪声更敏感(高方差),而简单的模型(高偏差)会欠拟合数据并对噪声不敏感(低方差)。这种权衡是每个机器学习算法固有的。集成方法通过结合几个低偏差模型来减少它们的方差,或者结合几个低方差模型来减少它们的偏差,以克服这个问题。

1.4 我们的第一个集成

在本节中,我们将通过训练我们的第一个集成来克服单个模型的拟合与复杂度问题。回想一下寓言中的福雷斯特博士,一个有效的集成在一系列组件模型上执行模型聚合,如下所示:

  • 我们使用不同的基础学习算法在相同的数据集上训练一组基础估计器(也称为基础学习器)。也就是说,我们依赖每个学习算法的显著差异来产生一组多样化的基础估计器。

  • 对于回归问题(例如,前一小节中引入的 Friedman-1 数据),单个基础估计器的预测是连续的。我们可以通过简单平均单个预测结果来汇总结果,形成一个最终的集成预测。

我们使用以下回归算法从我们的数据集中生成基础估计器:核岭回归、支持向量回归、决策树回归、k 最近邻回归、高斯过程和多层感知器(神经网络)。

一旦我们有了训练好的模型,我们使用每个模型进行单个预测,然后将单个预测汇总成一个最终的预测,如图 1.7 所示。

CH01_F07_Kunapuli

图 1.7 我们的第一个集成方法通过平均六个不同的回归模型的预测来集成预测。这个简单的集成说明了集成两个关键原则:(1)模型多样性,在本例中通过使用六个不同的基础机器学习模型实现;(2)模型聚合,在本例中通过简单平均预测实现。

训练单个基础估计器的代码如下所示。

列表 1.1 训练多样化的基础估计器

from sklearn.model_selection import train_test_split
from sklearn.datasets import make_friedman1

X, y = make_friedman1(n_samples=500, n_features=15, 
                      noise=0.3, random_state=23)        ❶
Xtrn, Xtst, ytrn, ytst = train_test_split(
                             X, y, test_size=0.25)       ❷

from sklearn.kernel_ridge import KernelRidge
from sklearn.svm import SVR
from sklearn.tree import DecisionTreeRegressor
from sklearn.neighbors import KNeighborsRegressor
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.neural_network import MLPRegressor

estimators = {'krr': KernelRidge(kernel='rbf', 
                                 gamma=0.25),            ❸
              'svr': SVR(gamma=0.5),
              'dtr': DecisionTreeRegressor(max_depth=3),
              'knn': KNeighborsRegressor(n_neighbors=4),
              'gpr': GaussianProcessRegressor(alpha=0.1),
              'mlp': MLPRegressor(alpha=25, max_iter=10000)}

for name, estimator in estimators.items():
    estimator = estimator.fit(Xtrn, ytrn)               ❹

❶ 生成包含 500 个示例和 15 个特征的合成 Friedman-1 数据集

❷ 将数据集分为训练集(包含 75%的数据)和测试集(包含剩余的 25%)

❸ 初始化每个单个基础估计器的超参数

❹ 训练单个基础估计器

我们现在已经使用六种不同的基础学习算法训练了六个不同的基础估计器。给定新的数据,我们可以将单个预测聚合成一个最终的预测,如下面的列表所示。

列表 1.2 聚合基础估计器的预测

import numpy as np
n_estimators, n_samples = len(estimators), Xtst.shape[0]
y_individual = np.zeros((n_samples, n_estimators))           ❶
for i, (model, estimator) in enumerate(estimators.items()): 
    y_individual[:, i] = estimator.predict(Xtst)             ❷

y_final = np.mean(y_individual, axis=1)                      ❸

❶ 初始化单个预测

❷ 使用基础估计器进行单个预测

❸ 聚合(平均)单个预测

理解集成好处的一种方式是查看所有可能的模型预测组合。也就是说,我们一次查看一个模型的性能,然后查看所有可能的两个模型的集成(有 15 种这样的组合),然后查看所有可能的三个模型的集成(有 20 种组合),以此类推。对于集成大小为 1 到 6 的情况,我们在图 1.8 中绘制了所有这些集成组合的测试集性能。

CH01_F08_Kunapuli

图 1.8 预测性能与集成大小的关系。当集成大小为 1 时,我们可以看到各个模型的性能相当多样。当大小为 2 时,我们平均不同对模型的结果(在这种情况下,15 个集成)。当大小为 3 时,我们一次平均 3 个模型的结果(在这种情况下,20 个集成),以此类推,直到大小为 6,此时我们将所有 6 个模型的结果平均到一个单一的、庞大的集成中。

随着我们聚合越来越多的模型,我们看到集成泛化得越来越好。然而,我们实验中最引人注目的结果是,所有六个估计器的集成性能通常比每个单个估计器的性能都要好。

最后,拟合与复杂性的关系如何?描述集成的复杂性是困难的,因为我们的集成中不同类型的估计器具有不同的复杂性。然而,我们可以描述集成的方差

回想一下,估计器的方差反映了其对数据的敏感性。高方差估计器非常敏感且鲁棒性较差,通常是因为它过度拟合。在图 1.9 中,我们显示了图 1.8 中集成的方差,即带宽的宽度。

CH01_F09_Kunapuli

图 1.9 集成组合的平均性能增加,表明更大的集成表现更好。性能组合的标准差(方差的平方根)降低,表明整体方差降低!

随着集成大小的增加,集成的方差减小!这是模型聚合或平均化的结果。我们知道平均“可以平滑粗糙的边缘。”在我们的集成中,平均单个预测可以平滑掉单个基础估计器所犯的错误,取而代之的是集成的智慧:从多到一。整体集成对错误的鲁棒性更强,并且不出所料,比任何单个基础估计器泛化得更好。

集成中的每个组件估计器都是独立的,就像福雷斯特博士的住院医生之一一样,每个估计器都根据自己的经验(在学习过程中引入)进行预测。在预测时间,当我们有六个个体时,我们将有六个预测,或六个观点。对于“简单例子”,个体之间将大部分达成一致。对于“困难例子”,个体之间会有所不同,但平均而言,更可能接近正确答案。⁵

在这个简单的场景中,我们通过使用六种不同的学习算法训练了六个“多样化”的模型。集成多样性对于集成成功至关重要,因为它确保了各个估计器彼此不同,并且不会犯相同的错误。

正如我们在每一章中都会反复看到的,不同的集成方法采用不同的方法来训练多样化的集成。在我们结束这一章之前,让我们来看看各种集成技术的广泛分类,其中许多将在接下来的几章中介绍。

1.5 集成方法的术语和分类法

所有集成都是由称为基础模型基础学习器基础估计器(这些术语在本书中可以互换使用)的单独机器学习模型组成的,并且使用基础机器学习算法进行训练。基础模型通常用它们的复杂性来描述。足够复杂(例如,深层决策树)并且具有“良好”预测性能(例如,对于二元分类任务的准确率超过 80%)的基础模型通常被称为强学习器或强模型

与之相反,那些相当简单(例如,浅层决策树)且仅能实现勉强可接受性能(例如,对于二元分类任务的准确率约为 51%)的基础模型被称为弱学习器弱模型。更正式地说,弱学习器只需比随机机会略好,或者对于二元分类任务来说,就是 50%。正如我们很快就会看到的,集成方法要么使用弱学习器,要么使用强学习器作为基础模型。

更广泛地说,根据它们的训练方式,集成方法可以分为两种类型:并行顺序集成。这是我们将在本书中采用的分类法,因为它为我们提供了一个整洁的方式来分组大量现有的集成方法(见图 1.10)。

CH01_F10_Kunapuli

图 1.10 本书涵盖的集成方法分类

如其名所示,并行集成方法独立于其他模型训练每个组件基础模型,这意味着它们可以并行训练。并行集成通常由强学习器构成,并且可以进一步分为以下几类:

  • 同质并行集成——所有基学习器都是同一类型(例如,所有决策树)并且使用相同的基学习算法进行训练。一些著名的集成方法,如 bagging、随机森林和极端随机树(Extra Trees),都是并行集成方法。这些内容在第二章中有详细说明。

  • 异质并行集成——基学习器使用不同的基学习算法进行训练。通过堆叠进行元学习是这种集成技术的一个著名例子。这些内容在第三章中有详细说明。

与并行集成方法不同,顺序集成方法利用基学习器之间的依赖关系。更具体地说,在训练过程中,顺序集成以这种方式训练新的基学习器,即最小化前一步训练的基学习器所犯的错误。这些方法按阶段顺序构建集成,通常使用弱学习器作为基模型。它们还可以进一步分为以下几类:

  • 自适应提升集成——也称为普通提升,这些集成通过自适应地重新加权示例来训练后续的基学习器,以纠正前一轮迭代中的错误。AdaBoost,所有提升方法的鼻祖,是这种集成方法的例子。这些内容在第四章中有详细说明。

  • 梯度提升集成——这些集成扩展并推广了自适应提升的想法,旨在模拟梯度下降,这是在底层实际训练机器学习模型时经常使用的。一些最强大的现代集成学习包实现了某种形式的梯度提升(LightGBM,第五章)、牛顿提升(XGBoost,第六章)或有序提升(CatBoost,第八章)。

摘要

  • 集成学习方法旨在通过训练多个模型并将它们组合成一个元估计器来提高预测性能。集成中的组件模型被称为基估计器或基学习器。

  • 集成方法利用“群体智慧”的力量,这一原则基于集体意见比群体中任何单个个体更有效的观点。

  • 集成方法在多个应用领域得到广泛应用,包括金融和商业分析、医疗保健、网络安全、教育、制造、推荐系统、娱乐等。

  • 大多数机器学习算法都面临着拟合与复杂度(也称为偏差-方差)之间的权衡,这影响了它们对未来数据的泛化能力。集成方法使用多个组件模型来克服这种权衡。

  • 一个有效的集成需要两个关键要素:(1) 集成多样性和(2) 对最终预测的模型聚合。


(1.) 安德烈亚斯·托舍尔(Andreas Töscher)、迈克尔·亚雷尔(Michael Jahrer)和罗伯特·M·贝尔(Robert M. Bell),《Netflix 大奖赛的 BigChaos 解决方案》(mng.bz/9V4r)。

(2.) Randal S. Olson, William La Cava, Zairah Mustahsan, Akshay Varik, 和 Jason H. Moore,将机器学习应用于生物信息学问题的数据驱动建议,太平洋机器学习研讨会(2018);arXiv 预印本:arxiv.org/abs/1708.05070

(3.) 关于使用决策树进行学习的更多细节,请参阅 Peter Harrington 所著的《机器学习实战》第三章(分类)和第九章(回归)(Manning, 2012)。

(4.) 关于分类中的 SVM 的更多细节,请参阅 Peter Harrington 所著的《机器学习实战》第六章(Manning, 2012)。关于回归中的 SVM,请参阅 Alex J. Smola 和 Bernhard Scholköpf 的《支持向量回归教程》(Statistics and Computing, 2004),以及 sklearn.SVM.SVR()的文档页面。

(5.) 有时候这种情况会出现问题。在英国版的《谁想成为百万富翁?》中,一位参赛者成功晋级到 125,000 英镑(约合 16 万美元),当被问到哪本小说以“5 月 3 日。比斯特里茨。晚上 8:35 离开慕尼黑。”开头时,他使用了 50/50 的生命线,只剩下两个选择:《间谍之影》和《德古拉》。知道如果答错可能会损失 93,000 英镑,他向现场观众求助。作为回应,81%的观众投票选择了《间谍之影》。观众们信心满满——不幸的是,他们错了。正如你在书中看到的,我们通过做出关于“观众”的某些假设来避免这种情况,在我们的案例中,这些假设是基于基础估计的。

第二部分 基本集成方法

这一部分的书将介绍几种“基本”的集成方法。在每一章中,你将学习如何(1)从头开始实现集成方法的基本版本,以获得底层理解;(2)逐步可视化学习过程;(3)使用复杂的现成实现,以最终从你的模型中获得最佳效果。

第二章和第三章涵盖了不同类型的知名并行集成方法,包括 bagging、随机森林、stacking 及其变体。第四章介绍了一种基本的顺序集成技术,称为提升,以及另一种知名的集成方法,称为 AdaBoost(及其变体)。

第五章和第六章都是关于梯度提升的,这是一种在撰写本文时非常流行的集成技术,并被广泛认为是当前最先进的技术。第五章涵盖了梯度提升的基本原理和内部工作方式。你还将学习如何开始使用 LightGBM,这是一个强大的梯度提升框架,你可以用它来构建可扩展且有效的梯度提升应用。第六章涵盖了梯度提升的一个重要变体,称为牛顿提升。你还将学习如何开始使用 XGBoost,另一个知名且强大的梯度提升框架。

这一部分的书主要涵盖了使用基于树的集成方法进行分类任务集成方法的应用。完成这本书的这一部分后,你将对许多集成技术有更深入和更广泛的理解,包括为什么它们有效以及它们的局限性是什么。

2 同质并行集成:Bagging 和随机森林

本章涵盖

  • 训练同质并行集成

  • 实现和理解 Bagging

  • 实现和理解随机森林的工作原理

  • 使用粘贴、随机子空间、随机补丁和 Extra Trees 训练变体

  • 在实践中使用 Bagging 和随机森林

在第一章中,我们介绍了集成学习并创建了我们的第一个基本的集成。为了回顾,集成方法依赖于“群众智慧”的概念:许多模型的综合答案通常比任何单个答案都要好。我们真正开始我们的集成学习方法之旅,从并行集成方法开始。我们之所以从这种集成方法开始,是因为从概念上讲,并行集成方法易于理解和实现。

如同其名所示,并行集成方法独立于其他组件基估计器进行训练,这意味着它们可以并行训练。正如我们将看到的,并行集成方法可以根据它们使用的不同学习算法进一步区分为同质并行集成和异质并行集成。

在本章中,你将了解同质并行集成,其组件模型都是使用相同的机器学习算法进行训练。这与下一章中介绍的异质并行集成形成对比,其组件模型使用不同的机器学习算法进行训练。同质并行集成方法类包括两种流行的机器学习方法,其中之一或两者你可能已经接触过,甚至之前使用过:Bagging 和随机森林

回想一下,集成方法的两个关键组成部分是集成多样性和模型聚合。因为同质集成方法在相同的数据集上使用相同的学习算法,你可能想知道它们如何生成一组多样化的基础估计器。它们通过随机采样来实现,无论是训练样本(如 Bagging 所做的那样),特征(如某些 Bagging 变体所做的那样),还是两者(如随机森林所做的那样)。

本章介绍的一些算法,如随机森林,在医学和生物信息学应用中得到了广泛应用。事实上,由于它的效率(它可以在多个处理器上轻松并行化或分布式),随机森林仍然是尝试新数据集的一个强大的现成基线算法。

我们将从最基本的并行同质集成开始:Bagging。一旦你理解了 Bagging 如何通过采样实现集成多样性,我们将探讨 Bagging 最强大的变体:随机森林。

你还将了解 Bagging 的其他变体(粘贴、随机子空间、随机补丁)和随机森林(Extra Trees)。这些变体通常对大数据或高维数据的应用非常有效。

2.1 并行集成

首先,我们具体定义并行集成的概念。这将帮助我们将这些章节和下一章的算法置于一个单一的环境中,这样我们就可以轻松地看到它们的相似之处和不同之处。

回想一下第一章中提到的我们的集成诊断专家兰迪·福雷斯特博士。每当福雷斯特博士接诊一个新病例时,他会征求所有住院医师的意见。然后,他从住院医师提出的诊断中确定最终的诊断(图 2.1,顶部)。福雷斯特博士的诊断技术之所以成功,有两个原因:

  • 他组建了一个多样化的住院医师团队,拥有不同的医学专业,这意味着他们每个人对病例的看法都不同。这对福雷斯特博士来说是个好事,因为它为他提供了多个不同的视角供他考虑。

  • 他将住院医师的独立意见汇总成一个最终的诊断。在这里,他是民主的,选择了多数意见。然而,他也可以以其他方式汇总住院医师的意见。例如,他可以提高经验更丰富的住院医师的意见权重。这反映了他比其他人更信任某些住院医师,基于诸如经验或技能等因素,这意味着他们比团队中的其他住院医师更经常正确。

福雷斯特博士和他的住院医师构成一个并行集成(图 2.1,底部)。在先前的例子中,每位住院医师都是一个必须训练的基估计器(或基学习器)。基估计器可以使用不同的基算法进行训练(导致异质集成)或使用相同的基算法(导致同质集成)。

CH02_F01_Kunapuli

图 2.1 福雷斯特博士的诊断过程是并行集成方法的类比。

如果我们想要组建一个类似于福雷斯特博士的有效集成,我们必须解决两个问题:

  • 我们如何从一个数据集中创建一组具有不同意见的基估计器?也就是说,我们如何在训练过程中确保集成多样性?

  • 我们如何将每个个体基估计器的决策或预测汇总成一个最终的预测?也就是说,我们如何在预测过程中执行模型集成?

你将在下一节中看到如何做到这两点。

2.2 Bagging: Bootstrap aggregating

Bagging,即bootstrap aggregating,由 Leo Breiman 于 1996 年提出。这个名字指的是 Bagging 如何通过自助采样实现集成多样性,并通过模型集成执行集成预测。

Bagging 是我们能构建的最基本的同质并行集成方法。理解 Bagging 将有助于理解本章中其他集成方法。这些方法以不同的方式进一步增强了基本的 Bagging 方法:要么提高集成多样性,要么提高整体计算效率。

Bagging 使用相同的基机器学习算法来训练基本估计器。那么我们如何从一个数据集和一个学习算法中得到多个基本估计器,更不用说多样性了?这通过在数据集的副本上训练基本估计器来实现。Bagging 包括两个步骤,如图 2.2 所示:

  1. 在训练过程中,自举抽样或有放回抽样被用来生成与彼此不同但来自原始数据集的重复数据集副本。这确保了在每个副本上训练的基本学习器彼此也不同。

  2. 在预测期间,模型集成被用来将单个基本学习器的预测组合成一个集成预测。对于分类任务,我们可以使用多数投票来组合单个预测。对于回归任务,我们可以使用简单平均来组合单个预测。

CH02_F02_Kunapuli

图 2.2 Bagging 示意图。Bagging 使用自举抽样从单个数据集中生成相似但不完全相同的子集(观察这里的副本)。在这些子集上训练模型,结果得到相似但不完全相同的基本估计器。对于给定的测试示例,单个基本估计器的预测被聚合成一个最终的集成预测。同时观察,训练示例可能在重复的子集中重复;这是自举抽样的结果。

2.2.1 直觉:重抽样和模型集成

集成多样性面临的关键挑战是我们需要使用相同的学习算法和相同的数据集来创建(并使用)不同的基本估计器。现在我们将看到如何(1)生成数据集的副本,这些副本可以用来训练基本估计器;(2)结合基本估计器的预测。

自举抽样:有放回抽样

我们将使用随机抽样来轻松地从原始数据集中生成较小的子集。为了生成相同大小的数据集副本,我们需要进行有放回抽样,也称为自举抽样。

当进行有放回的抽样时,一些已经被抽样的对象因为被替换而有机会再次被抽样(甚至第三次、第四次等)。实际上,一些对象可能会被多次抽样,而一些对象可能永远不会被抽样。有放回的抽样在图 2.3 中得到了说明,我们可以看到抽样后允许替换会导致重复。

CH02_F03_Kunapuli

图 2.3 在六个示例的数据集上展示了自举抽样。通过有放回抽样,我们可以得到一个包含六个对象的自举样本大小,其中只有四个独特的对象但有重复。进行多次自举抽样会产生原始数据集的几个副本——它们都包含重复。

因此,重抽样自然地将数据集分为两部分:一个重抽样样本(包含至少被抽样一次的训练示例)和一个袋外(OOB)样本(包含从未被抽样过的训练示例)。

我们可以使用每个重抽样样本来训练不同的基础估计器。因为不同的重抽样样本将包含不同数量的重复示例,所以每个基础估计器将与其他估计器略有不同。

随机袋样本

仅仅丢弃 OOB 样本似乎相当浪费。然而,如果我们使用重抽样样本训练基础估计器,OOB 样本将被保留,在学习的整个过程中都不会被基础估计器看到。听起来熟悉吗?

OOB 样本实际上是一个保留的测试集,可以用来评估集成,而无需单独的验证集或交叉验证过程。这很棒,因为它允许我们在训练期间更有效地利用数据。使用 OOB 实例计算的错误估计称为OOB 误差OOB 分数

使用 numpy.random.choice 生成带替换的重抽样样本非常简单。假设我们有一个包含 50 个训练示例的数据集(比如说,具有独特 ID 的患者记录,从 0 到 49)。我们可以生成一个大小为 50 的重抽样样本(与原始数据集大小相同),用于训练(replace=True 表示带替换抽样):

import numpy as np
bag = np.random.choice(range(0, 50), size=50, replace=True)
np.sort(bag)

这会产生以下输出:

array([ 1,  3,  4,  6,  7,  8,  9, 11, 12, 12, 14, 14, 15, 15, 21, 21, 21,
       24, 24, 25, 25, 26, 26, 29, 29, 31, 32, 32, 33, 33, 34, 34, 35, 35,
       37, 37, 39, 39, 40, 43, 43, 44, 46, 46, 48, 48, 48, 49, 49, 49])

你能在这个重抽样样本中找到重复项吗?这个重抽样样本现在作为原始数据集的一个重复样本,可以用来训练。相应的 OOB 样本是所有不在重抽样样本中的示例:

oob = np.setdiff1d(range(0, 50), bag)
oob

这会产生以下输出:

array([ 0,  2,  5, 10, 13, 16, 17, 18, 19, 20, 22, 23, 27, 28, 30, 36, 38,
       41, 42, 45, 47])

很容易验证重抽样子集和 OOB 子集之间没有重叠。这意味着 OOB 样本可以用作“测试集”。总结一下:经过一轮重抽样,我们得到一个重抽样样本(用于训练基础估计器)和一个相应的 OOB 样本(用于评估那个基础估计器)。

注意:带替换的抽样会丢弃某些项目,但更重要的是,会复制其他项目。当应用于数据集时,带替换的抽样可以用来创建包含重复项的训练集。你可以将这些重复项视为加权训练示例。例如,如果一个特定的示例在重抽样样本中重复了四次,当用于训练基础估计器时,这四个重复项将相当于使用一个权重为 4 的单个训练示例。以这种方式,不同的随机重抽样样本实际上是从随机抽样和加权训练集中抽取的。

当我们多次重复这一步骤时,我们将训练多个基础估计器,并且也会通过单个 OOB 误差来估计它们的个别泛化性能。平均 OOB 误差是对整体集成性能的良好估计。

0.632 重抽样

当进行有放回的抽样时,自助样本将包含大约 63.2%的数据集,而 OOB 样本将包含数据集的其余 36.8%。我们可以通过计算数据点被抽样的概率来展示这一点。如果我们的数据集有n个训练示例,则在自助样本中选中特定数据点x的概率是(1/n)。在自助样本中未选中x的概率(即,在 OOB 样本中选中x)是 1 - (1/n)。

对于n个数据点,被选入 OOB 样本的整体概率是

CH02_F03_Kunapuli_E03

(对于足够大的n)。因此,每个 OOB 样本将包含(大约)36.8%的训练示例,相应的自助样本将包含(大约)剩余的 63.2%的实例。

模型聚合

自助抽样生成数据集的多样化副本,这使得我们可以独立地训练多样化的模型。一旦训练完成,我们可以使用这个集成进行预测。关键是将它们有时不同的意见结合成一个单一的最终答案。

我们已经看到了两种模型聚合的例子:多数投票和模型平均。对于分类任务,多数投票用于聚合单个基础学习器的预测。多数投票也被称为统计众数。众数简单地是最频繁出现的元素,是一种类似于均值或中位数的统计量。

我们可以将模型聚合视为平均:它平滑了合唱团中的不完美之处,并产生一个反映多数的单一答案。如果我们有一组稳健的基础估计器,模型聚合将平滑掉单个估计器犯的错误。

集成方法根据任务使用各种聚合技术,包括多数投票、均值、加权均值、组合函数,甚至另一个机器学习模型!在本章中,我们将坚持使用多数投票作为我们的聚合器。我们将在第三章中探索一些其他用于分类的聚合技术。

2.2.2 实现多重抽样

我们可以轻松实现自己的版本的多重抽样。这说明了多重抽样的简单性,并为本章中其他集成方法的工作提供了一个通用模板。在我们的多重抽样集成中,每个基础估计器的训练都是独立地按照以下步骤进行的:

  1. 从原始数据集中生成一个自助样本。

  2. 将基础估计器拟合到自助样本。

在这里,“独立地”意味着每个单个基础估计器的训练阶段在没有考虑其他基础估计器的情况下进行。

我们使用决策树作为基估计器;最大深度可以使用 max_depth 参数设置。我们还需要两个其他参数:n_estimators,即集成大小,和 max_samples,即 bootstrap 子集的大小,即每个估计器要采样的训练示例数量(有放回)。

我们的天真实现按顺序训练每个基决策树,如列表 2.1 所示。如果训练单个决策树需要 10 秒,而我们正在训练一个包含 100 棵树的集成,那么我们的实现将需要 10 秒 × 100 = 1000 秒的总训练时间。

列表 2.1 使用决策树的 Bagging:训练

import numpy as np
from sklearn.tree import DecisionTreeClassifier

rng = np.random.RandomState(seed=4190)                               ❶
def bagging_fit(X, y, n_estimators, max_depth=5, max_samples=200):
    n_examples = len(y)   
    estimators = [DecisionTreeClassifier(max_depth=max_depth)  
                  for _ in range(n_estimators)]                      ❷

    for tree in estimators:
        bag = np.random.choice(n_examples, max_samples, 
                               replace=True)                         ❸
        tree.fit(X[bag, :], y[bag])                                  ❹

    return estimators

❶ 初始化一个随机种子

❷ 创建一个未训练的基估计器列表

❸ 生成一个 bootstrap 样本

❹ 将树拟合到 bootstrap 样本

此函数将返回一个 DecisionTreeClassifier 对象的列表。我们可以使用这个集成进行预测,这已在以下列表中实现。

列表 2.2 使用决策树的 Bagging:预测

from scipy.stats import mode

def bagging_predict(X, estimators):
    all_predictions = np.array([tree.predict(X)            ❶
                                for tree in estimators])  
ypred, _ = mode(all_predictions, axis=0, 
                    keepdims=False)                        ❷
    return np.squeeze(ypred)

❶ 使用集成中的每个估计器预测每个测试示例

❷ 通过多数投票进行最终预测

我们可以在二维数据上测试我们的实现并可视化结果,如下面的代码片段所示。我们的 Bagging 集成包含 500 棵决策树,每棵树的深度为 12,并在大小为 300 的 bootstrap 样本上训练。

from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

X, y = make_moons(n_samples=300, noise=.25, 
                  random_state=rng)                           ❶
Xtrn, Xtst, ytrn, ytst = train_test_split(X, y, test_size=0.33,
                                          random_state=rng)
bag_ens = bagging_fit(Xtrn, ytrn, n_estimators=500,           ❷
                      max_depth=12, max_samples=300)
ypred = bagging_predict(Xtst, bag_ens)                        ❸

print(accuracy_score(ytst, ypred))

❶ 创建一个二维数据集

❷ 训练一个 Bagging 集成

❸ 通过多数投票进行最终预测

这段代码将产生以下输出:

0.898989898989899

我们的 Bagging 实现达到了测试集准确率 89.90%。现在我们可以看到 Bagging 集成与单个树相比的样子,单个树的测试集准确率为 83.84%(图 2.4)。

CH02_F04_Kunapuli

图 2.4 单个决策树(左)对训练集过拟合,并且可能对异常值敏感。Bagging 集成(右)平滑了几个此类基估计器的过拟合效应和误分类,通常返回一个鲁棒的答案。

Bagging 可以学习相当复杂和非线性的决策边界。即使单个决策树(以及通常的基估计器)对异常值敏感,基学习器的集成将平滑个别变化并更加鲁棒。

Bagging 的这种平滑行为是由于模型聚合。当我们有许多高度非线性的分类器,每个分类器都在略微不同的训练数据副本上训练时,每个分类器可能会过拟合,但它们不会以相同的方式过拟合。更重要的是,聚合导致平滑,这有效地减少了过拟合的影响!因此,当我们聚合预测时,它平滑了错误,提高了集成性能!就像一个管弦乐队一样,最终结果是平滑的交响乐,可以轻松克服其中任何个别音乐家的错误。

2.2.3 使用 scikit-learn 进行 Bagging

现在我们已经了解了 Bagging 的工作原理,让我们看看如何使用 scikit-learn 的 BaggingClassifier 包,如下所示。scikit-learn 的实现提供了额外的功能,包括对并行化的支持,能够使用除决策树之外的其他基础学习算法,最重要的是 OOB 评估。

列表 2.3 使用 scikit-learn 的 Bagging

from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import BaggingClassifier

base_estimator = DecisionTreeClassifier(max_depth=10)           ❶
bag_ens = BaggingClassifier(base_estimator=base_estimator, 
                            n_estimators=500,                   ❷
                            max_samples=100,                    ❸
                            oob_score=True,                     ❹
                            random_state=rng)
bag_ens.fit(Xtrn, ytrn)
ypred = bag_ens.predict(Xtst)

❶ 设置基学习算法及其超参数

❷ 训练 500 个基估计器

❸ 每个基估计器将在大小为 100 的自助样本上训练。

❹ 使用 OOB 样本来估计泛化误差

BaggingClassifier 支持 OOB 评估,如果我们设置 oob_score=True,它将返回 OOB 准确率。回想一下,对于每个自助样本,我们还有一个相应的 OOB 样本,它包含在采样过程中未选中的所有数据点。

因此,每个 OOB 样本都是“未来数据”的替代品,因为它没有用于训练相应的基估计器。训练后,我们可以查询学习到的模型以获取 OOB 分数:

bag_ens.oob_score_
0.9658792650918635

OOB 分数是对 Bagging 集成预测(泛化)性能的估计(此处为 96.6%)。除了 OOB 样本外,我们还保留了一个测试集。我们计算了该模型在测试集上的泛化性能的另一个估计:

accuracy_score(ytst, ypred)
0.9521276595744681

测试准确率为 95.2%,与 OOB 分数非常接近。我们使用了最大深度为 10 的决策树作为基础估计器。更深的决策树更复杂,这使得它们能够拟合(甚至过度拟合)训练数据。

TIP Bagging 对于复杂和非线性分类器最有效,这些分类器倾向于过度拟合数据。这样的复杂、过度拟合的模型是不稳定的,也就是说,对训练数据中的微小变化非常敏感。为了了解原因,考虑一下,袋装集成中的单个决策树具有大致相同的复杂性。然而,由于自助采样,它们已经在数据集的不同副本上进行了训练,并且过度拟合的程度不同。换句话说,它们都以大致相同的程度过度拟合,但在不同的地方。Bagging 之所以与这样的模型配合得最好,是因为其模型聚合减少了过度拟合,最终导致更稳健和稳定的集成。

我们可以通过比较 BaggingClassifier 的决策边界与其组件基础决策树分类器,如图 2.5 所示,来可视化 BaggingClassifier 的平滑行为。

CH02_F05_Kunapuli

图 2.5 自助采样导致不同的基估计器以不同的方式过度拟合,而模型聚合平均了个体错误并产生了更平滑的决策边界。

2.2.4 使用并行化加速训练

Bagging 是一种并行集成算法,因为它独立于其他基础学习器训练每个基础学习器。这意味着如果可以访问如多个核心或集群等计算资源,则可以并行化训练 Bagging 集成。

BaggingClassifier 通过 n_jobs 参数支持训练和预测的加速。默认情况下,此参数设置为 1,袋装法将在一个 CPU 上运行,并逐个顺序训练模型。

或者,您可以通过设置 n_jobs 来指定 BaggingClassifier 应使用的并发进程数。如果 n_jobs 设置为-1,则所有可用的 CPU 都将用于训练,每个 CPU 训练一个集成。这当然允许通过同时和并行训练更多模型来加快训练速度。

BaggingClassifier(base_estimator=DecisionTreeClassifier(),  
                            n_estimators=100, max_samples=100, 
                            oob_score=True, n_jobs=-1)           ❶

❶ 如果 n_jobs 设置为-1,BaggingClassifier 将使用所有可用的 CPU。

CH02_F06_Kunapuli

图 2.6 袋装法可以并行化以提高训练效率。

图 2.6 比较了在具有六个核心的机器上使用 1 个 CPU(n_jobs=1)与多个 CPU(n_jobs=-1)训练袋装法的训练效率。这种比较表明,如果我们可以访问足够的计算资源,袋装法可以有效地并行化,并且训练时间可以显著减少。

2.3 随机森林

我们已经看到了如何使用带替换的随机抽样,即自助抽样,来增加集成多样性。现在,让我们看看随机森林,它是袋装法的特殊扩展,引入了额外的随机化来进一步促进集成多样性。

直到梯度提升(见第五章和第六章)的出现,随机森林是当时最先进的,并且被广泛使用。它们仍然是许多应用的流行首选方法,尤其是在生物信息学中。随机森林可以作为您数据的优秀现成基线,因为它们训练起来计算效率高。它们还可以按重要性对数据特征进行排序,这使得它们特别适合于高维数据分析。

2.3.1 随机决策树

“随机森林”特指使用袋装法构建的随机决策树集成。随机森林执行自助抽样以生成训练子集(与袋装法完全一样),然后使用随机决策树作为基估计器。

随机决策树使用修改后的决策树学习算法进行训练,该算法在生长我们的树时引入随机性。这种额外的随机性增加了集成多样性,通常会导致更好的预测性能。

标准决策树与随机决策树之间的关键区别在于决策节点的构建方式。在标准决策树构建中,所有可用的特征都会被彻底评估以确定最佳的分割特征。由于决策树学习是一个贪婪算法,它将选择得分最高的特征进行分割。

在袋装法中,这种穷举搜索(与贪婪学习相结合)意味着在多个树中可能会反复使用相同的小数量主导特征。这使得集成多样性降低。

为了克服标准决策树学习的这一局限性,随机森林在树学习中引入了额外的随机元素。具体来说,不是考虑所有特征来识别最佳分割,而是评估一个随机特征子集以确定最佳的分割特征。

因此,随机森林使用了一种修改后的树学习算法,该算法首先随机采样特征,然后创建决策节点。生成的树是一个随机决策树,这是一种新的基估计器。

正如您将看到的,随机森林通过使用随机决策树作为基估计器来扩展 bagging。因此,随机森林包含两种类型的随机化:(1)与 bagging 类似的 bootstrap 采样;(2)用于学习随机决策树的随机特征采样。

示例:树学习中的随机化

考虑在具有六个特征的数据集上进行树学习(这里,指{f[1],f[2],f[3],f[4],f[5],f[6]})。在标准树学习中,所有六个特征都会被评估,并确定最佳分割特征(例如,f[3])。

在随机决策树学习中,我们首先随机采样一个特征子集(例如,f[2],f[4],f[5]}),然后从中选择最佳特征(例如,f[5])。这意味着特征 f[3] 在这一阶段的树学习中不再可用。因此,随机化本质上迫使树学习在不同的特征上进行分割。随机化对树学习过程中下一个最佳分割选择的影響在图 2.7 中得到了说明。

CH02_F07_Kunapuli

图 2.7 展示了随机森林使用了一种修改后的树学习算法,在该算法中,首先选择一个随机特征子集,然后确定每个决策节点的最佳分割标准。无阴影的列表示已排除的特征;浅阴影的列表示可用于选择最佳特征的可用特征,这些特征在深阴影的列中显示。

最终,这种随机化会在构建每个决策节点时发生。因此,即使我们使用相同的数据集,每次训练时也会得到不同的随机化树。当随机树学习(带有特征随机采样的随机树学习)与自助采样(带有训练样本随机采样的自助采样)相结合时,我们得到一个随机决策树集合,称为随机决策森林或简称为随机森林。

随机森林集合将比仅执行自助采样的 bagging 更加多样化。接下来,我们将看到如何在实践中使用随机森林。

2.3.2 使用 scikit-learn 的随机森林

scikit-learn 提供了一个高效的随机森林实现,它还支持 OOB 估计和并行化。由于随机森林专门使用决策树作为基础学习器,RandomForestClassifier 也接受 DecisionTreeClassifier 参数,如 max_leaf_nodes 和 max_depth 来控制树复杂性。以下列表演示了如何调用 RandomForestClassifier。

列表 2.4 使用 scikit-learn 的随机森林

from sklearn.ensemble import RandomForestClassifier

rf_ens = RandomForestClassifier(n_estimators=500, 
                                max_depth=10, 
                                oob_score=True,      ❶
                                n_jobs=-1,           ❷
                                random_state=rng)
rf_ens.fit(Xtrn, ytrn)
ypred = rf_ens.predict(Xtst)                         ❸

❶ 控制基础决策树的复杂性

❷ 如果可能,并行化

❸ 使用 OOB 样本估计泛化误差

图 2.8 展示了随机森林分类器,以及几个组件基础估计器。

CH02_F08_Kunapuli

图 2.8 随机森林(左上角)与单个基础学习器(随机决策树)的比较。与袋装法类似,随机森林集成也产生平滑且稳定的决策边界。同时观察随机化对单个树的影响,这些树比常规决策树更尖锐。

2.3.3 特征重要性

使用随机森林的一个好处是它们还提供了一种基于其重要性的自然机制来评分特征。这意味着我们可以对特征进行排序,以识别最重要的特征,并删除效果较差的特征,从而执行特征选择!

特征选择

特征选择,也称为变量子集选择,是一种识别最有影响力或相关数据特征/属性的过程。特征选择是建模过程中的重要步骤,尤其是在高维数据中。

删除最不相关的特征通常可以提高泛化性能并最小化过拟合。这也有助于提高训练的计算效率。这些问题是维度诅咒的结果,其中大量的特征可能会抑制模型有效泛化的能力。

参考 Pablo Duboue 的《特征工程的艺术:机器学习必备》(剑桥大学出版社,2020 年)以了解更多关于特征选择和工程的信息。

我们可以使用查询 rf_ens.feature_importances_ 为简单的二维数据集获取特征重要性:

for i, score in enumerate(rf_ens.feature_importances_):
    print('Feature x{0}: {1:6.5f}'.format(i, score))

这会产生以下输出:

Feature x0: 0.50072
Feature x1: 0.49928

对于简单的二维数据集,特征分数表明两个特征的重要性大致相等。在章节末尾的案例研究中,我们将计算并可视化来自真实任务的特性重要性:乳腺癌诊断。我们还将回顾并深入探讨第九章中的特性重要性主题。

注意,特征重要性之和为 1,并且实际上是特征权重。不重要的特征具有较低的权重,通常可以删除而不会显著影响最终模型的整体质量,同时提高训练和预测时间。

具有关联特征的特性重要性

如果两个特征高度相关或依赖,那么直观上我们知道,在模型中使用其中任何一个就足够了。然而,特征使用的顺序可能会影响特征重要性。例如,在分类鲍鱼(海蜗牛)时,大小和重量特征高度相关(不出所料,因为更大的蜗牛会更重)。这意味着将它们包含在决策树中会增加大约相同的信息量,并导致整体错误(或熵)大致以相同的方式减少。因此,我们预计它们的平均错误减少分数将是相同的。

然而,假设我们首先选择权重作为分割变量。将此特征添加到树中会移除大小和权重特征中包含的信息。这意味着大小的特征重要性降低,因为我们通过在模型中包含大小来减少错误的可能性已经被包含权重时减少。因此,相关特征有时会被分配不平衡的特征重要性。随机特征选择可以稍微减轻这个问题,但并不一致。

通常,在存在特征相关性的情况下解释特征重要性时,你必须谨慎行事,以免错过数据中的整个故事。

2.4 更同质化的并行集成

我们已经看到了两种重要的并行同质集成方法:Bagging 和随机森林。现在让我们探索一些为大型数据集(例如,推荐系统)或高维数据(例如,图像或文本数据库)开发的变体。这些包括 Bagging 变体,如粘贴、随机子空间和随机补丁,以及一个称为 Extra Trees 的极端随机森林变体。所有这些方法都以不同的方式引入随机化,以确保集成多样性。

2.4.1 粘贴

Bagging 使用自助采样,或带替换的采样。如果我们不替换地采样训练子集,我们就有了 Bagging 的一个变体,称为粘贴。粘贴是为非常大的数据集设计的,其中不需要带替换的采样。相反,由于在如此规模的数据集上训练完整模型是困难的,粘贴旨在通过不替换地采样来获取数据的小部分。

粘贴利用了这样一个事实,即使用非常大的数据集进行不替换的采样可以固有地生成多样化的数据子集,这反过来又导致集成多样性。粘贴还确保每个训练子样本是整体数据集的小部分,并且可以用来有效地训练基学习器。

模型聚合仍然用于做出最终的集成预测。然而,由于每个基学习器都是在大型数据集的小部分上训练的,我们可以将模型聚合视为将基学习器的预测粘贴在一起以做出最终预测。

TIP BaggingClassifier 可以通过设置 bootstrap=False 并设置 max_samples 为一个小分数(例如 max_samples=0.05)来轻松扩展以执行粘贴,从而通过训练小子集进行采样。

2.4.2 随机子空间和随机补丁

我们还可以通过随机采样特征(见图 2.9)来使基学习器更加多样化。如果我们通过采样特征(带或不带替换)来生成子集,而不是采样训练示例,我们得到一种称为随机子空间的 bagging 变体。

CH02_F09_Kunapuli

图 2.9 与随机子空间和随机补丁相比的 Bagging。未着色的行和列分别代表被留下的训练示例和特征。

BaggingClassifier 通过两个参数支持特征的 bootstrap 采样:bootstrap_features(默认:False)和 max_features(默认:1.0,即所有特征),它们分别类似于采样训练示例的参数 bootstrap(默认:False)和 max_samples。要实现随机子空间,我们只随机采样特征:

bag_ens = BaggingClassifier(
    base_estimator=SVC(), n_estimators=100, 
    max_samples=1.0, bootstrap=False,             ❶
    max_features=0.5, bootstrap_features=True)    ❷

❶ 使用所有训练样本

❷ 从特征中抽取 50%的 Bootstrap 样本

如果我们随机采样所有的训练示例和特征(带或不带替换),我们得到一种称为随机补丁的 bagging 变体:

bag_ens = BaggingClassifier(
    base_estimator=SVC(), n_estimators=100, 
    max_samples=0.75, bootstrap=True,            ❶
    max_features=0.5, bootstrap_features=True)   ❷

❶ 从示例中抽取 75%的 Bootstrap 样本

❷ 从特征中抽取 50%的 Bootstrap 样本

注意,在前面的例子中,基估计器是支持向量分类器,sklearn.svm.SVC。一般来说,随机子空间和随机补丁可以应用于任何基学习器以提高估计器的多样性。

TIP 实际上,这些 bagging 变体对于大数据特别有效。例如,因为随机子空间和随机补丁采样特征,它们可以用于更有效地训练具有许多特征的数据的基估计器,例如图像数据。或者,因为粘贴执行无替换的采样,当您有一个具有大量训练实例的大数据集时,它可以用于更有效地训练基估计器。

随机森林与随机子空间和随机补丁等 bagging 变体之间的关键区别在于特征采样的位置。随机森林仅使用随机决策树作为基估计器。具体来说,每次它们使用决策节点生长树时,都会在树学习算法中进行特征采样。

另一方面,随机子空间和随机补丁不仅限于树学习,可以使用任何学习算法作为基估计器。它们在调用基学习算法之前,对每个基估计器进行一次外部的特征采样。

2.4.3 额外树

极端随机树将随机决策树的想法推向了极致,不仅从随机特征子集中选择分裂变量(见图 2.9),还选择分裂阈值!为了更清楚地理解这一点,请回忆决策树中的每个节点都测试一种形式为“is f[k] < threshold?”的条件,其中 f[k] 是第 k 个特征,threshold 是分裂值(参见 2.3.1 节)。

标准决策树学习会查看所有特征以确定最佳 f[k],然后查看该特征的所有值以确定阈值。随机决策树学习会查看随机子集特征以确定最佳 f[k],然后查看该特征的所有值以确定阈值。

极端随机决策树学习也会查看随机特征子集以确定最佳 f[k]。但为了更加高效,它选择一个随机的分裂阈值。请注意,极端随机树是另一种用于集成的基学习器。

这种极端随机化实际上非常有效,以至于我们可以直接从原始数据集构建极端随机树集成而不进行自助采样!这意味着我们可以非常高效地构建 Extra Trees 集成。

提示 在实践中,Extra Trees 集成非常适合具有大量连续特征的高维数据集。

scikit-learn 提供了一个支持 OOB 估计和并行化的 ExtraTreesClassifier,与 BaggingClassifier 和 RandomForestClassifier 类似。请注意,Extra Trees 通常执行自助采样(默认为 bootstrap=False),因为我们能够通过极端随机化实现基估计器的多样性。

警告 scikit-learn 提供了两个非常相似命名的类:sklearn.tree.ExtraTreeClassifier 和 sklearn.ensemble.ExtraTreesClassifier。tree.ExtraTreeClassifier 类是一个基学习算法,应用于学习单个模型或作为集成方法的基估计器。ensemble.ExtraTreesClassifier 是本节讨论的集成方法。区别在于“Extra Tree”的单数用法(ExtraTreeClassifier 是基学习器)与复数用法“Extra Trees”(ExtraTreesClassifier 是集成方法)。

2.5 案例研究:乳腺癌诊断

我们的第一项案例研究探讨了医疗决策任务:乳腺癌诊断。我们将看到如何在实际中应用 scikit-learn 的同质并行集成模块。具体来说,我们将训练并评估三种同质并行算法的性能,每种算法的特点是随机性逐渐增加:决策树的自举、随机森林和 Extra Trees。

医生每天都会就患者护理做出许多决定:例如诊断(患者患有何种疾病?)、预后(疾病将如何进展?)、治疗计划(如何治疗疾病?)等。他们基于患者的健康记录、病史、家族史、检测结果等进行这些决定。

我们将使用的具体数据集是威斯康星诊断乳腺癌(WDBC)数据集,这是机器学习中常用的基准数据集。自 1993 年以来,WDBC 数据已被用于评估数十种机器学习算法的性能。

机器学习任务是训练一个分类模型,可以诊断患有乳腺癌的患者。按照现代标准和大数据时代,这是一个小数据集,但它非常适合展示我们迄今为止看到的集成方法。

2.5.1 加载和预处理

WDBC 数据集最初是通过在患者活检医学图像上应用特征提取技术创建的。更具体地说,对于每位患者,数据描述了活检过程中提取的细胞核的大小和纹理。

WDBC 在 scikit-learn 中可用,如图 2.10 所示进行加载。此外,我们还创建了一个 RandomState,以便我们可以以可重复的方式生成随机化:

from sklearn.datasets import load_breast_cancer
dataset = load_breast_cancer()  
X, y = dataset['data'], dataset['target']
rng=np.random.RandomState(seed=4190)

CH02_F10_Kunapuli

图 2.10 WDBC 数据集包含 569 个训练示例,每个示例由 30 个特征描述。这里展示了少量患者的一些 30 个特征,以及每位患者的诊断(训练标签)。诊断=1 表示恶性,诊断=0 表示良性。

2.5.2 Bagging、随机森林和 Extra Trees

一旦我们预处理完数据集,我们将使用决策树、随机森林和 Extra Trees 进行 bagging 的训练和评估,以回答以下问题:

  • 集成性能如何随着集成大小的变化而变化?也就是说,当我们的集成变得越来越大时会发生什么?

  • 集成性能如何随着基学习器的复杂性而变化?也就是说,当我们的单个基估计器变得越来越复杂时会发生什么?

在这个案例研究中,由于考虑的所有三种集成方法都使用决策树作为基估计器,因此复杂性的一个“度量”是树深度,深度越深的树越复杂。

集成大小与集成性能

首先,让我们通过比较随着参数 n_estimators 增加时三个算法的行为,来查看训练和测试性能如何随着集成大小而变化。一如既往,我们遵循良好的机器学习实践,随机将数据集分为训练集和保留测试集。我们的目标将在训练集上学习一个诊断模型,并使用测试集评估该诊断模型的效果。

记住,由于测试集在训练期间被保留出来,因此测试错误通常是我们对未来数据表现的一个有用估计,即泛化。然而,由于我们不希望我们的学习和评估受随机性的摆布,我们将重复此实验 20 次并平均结果。在下面的列表中,我们将看到集成大小如何影响模型性能。

列表 2.5 随着集成大小增加的训练和测试错误

max_leaf_nodes = 8                                                         ❶
n_runs = 20
n_estimator_range = range(2, 20, 1)

bag_trn_error = \ 
    np.zeros((n_runs, len(n_estimator_range)))                             ❷
rf_trn_error = \                                                           ❷
    np.zeros((n_runs, len(n_estimator_range)))                             ❷
xt_trn_error = \                                                           ❷
    np.zeros((n_runs, len(n_estimator_range)))                             ❷

bag_tst_error = \
    np.zeros((n_runs, len(n_estimator_range)))                             ❸
rf_tst_error = \                                                           ❸
    np.zeros((n_runs, len(n_estimator_range)))                             ❸
xt_tst_error =                                                             ❸
    np.zeros((n_runs, len(n_estimator_range)))                             ❸

for run in range(0, n_runs):
    X_trn, X_tst, y_trn, y_tst = train_test_split(                         ❹
                                     X, y, test_size=0.25,random_state=rng)

    for j, n_estimators in enumerate(n_estimator_range):

        tree = DecisionTreeClassifier(                                     ❺
                   max_leaf_nodes=max_leaf_nodes) 
        bag = BaggingClassifier(base_estimator=tree,
                                n_estimators=n_estimators,
                                max_samples=0.5, n_jobs=-1,
                                random_state=rng)
        bag.fit(X_trn, y_trn)
        bag_trn_error[run, j] = 1 - accuracy_score(y_trn, bag.predict(X_trn))
        bag_tst_error[run, j] = 1 - accuracy_score(y_tst, bag.predict(X_tst))

        rf = RandomForestClassifier(                                       ❻
                 max_leaf_nodes=max_leaf_nodes, n_estimators=n_estimators, 
                 n_jobs=-1, random_state=rng)

        rf.fit(X_trn, y_trn)
        rf_trn_error[run, j] = 1 - accuracy_score(y_trn, rf.predict(X_trn))
        rf_tst_error[run, j] = 1 - accuracy_score(y_tst, rf.predict(X_tst))

        xt = ExtraTreesClassifier(                                         ❼
                 max_leaf_nodes=max_leaf_nodes, n_estimators=n_estimators,
                 bootstrap=True, n_jobs=-1, random_state=rng)

        xt.fit(X_trn, y_trn) 
        xt_trn_error[run, j] = 1 - accuracy_score(y_trn, xt.predict(X_trn))
        xt_tst_error[run, j] = 1 - accuracy_score(y_tst, xt.predict(X_tst))

❶ 每个集成中的每个基决策树最多有八个叶节点。

❷ 初始化数组以存储训练错误

❸ 初始化数组以存储测试错误

❹ 执行 20 次运行,每次运行具有不同的训练/测试数据分割

❺ 训练并评估此运行和迭代的 bagging

❻ 训练并评估此运行和迭代的随机森林

❼ 训练并评估此运行和迭代的 Extra Trees

我们现在可以可视化 WDBC 数据集上平均的训练和测试错误,如图 2.11 所示。正如预期的那样,随着估计器数量的增加,所有方法的训练错误稳步下降。测试错误也随着集成大小的增加而下降,然后稳定下来。由于测试错误是泛化误差的估计,我们的实验证实了我们对这些集成方法在实际性能上的直觉。

CH02_F11_Kunapuli

图 2.11 随着集成大小增加,bagging、随机森林和 Extra Trees 的训练和测试性能。Bagging 使用决策树作为基估计器,随机森林使用随机决策树,Extra Trees 使用极端随机树。

最后,所有三种方法都大大优于单个决策树(如图所示)。这表明,在实践中,即使单个决策树不稳定,决策树的集成也是稳健的,并且可以很好地泛化。

基学习器复杂度与集成性能

接下来,我们比较随着基学习器复杂度的增加,三种算法的行为(如图 2.12)。控制基决策树复杂度的方法有:最大深度、最大叶节点数、不纯度标准等。在这里,我们比较了三种集成方法在基学习器复杂度由 max_leaf_nodes 确定的性能。

这种比较可以以类似于之前的方式执行。为了允许每个集成方法使用越来越复杂的基学习器,我们可以逐步增加每个基决策树的最大叶节点数。也就是说,在 BaggingClassifier、RandomForestClassifier 和 ExtraTreesClassifier 中,我们依次设置 max_leaf_nodes = 2, 4, 8, 16 和 32,通过以下参数:

base_estimator=DecisionTreeClassifier(max_leaf_nodes=32)

CH02_F12_Kunapuli

图 2.12 随着基学习器复杂性的增加,bagging、随机森林和 Extra Trees 的训练和测试性能。Bagging 使用决策树作为基估计器,随机森林使用随机决策树,Extra Trees 使用极端随机树。

回想一下,高度复杂的树在本质上是不稳定的,并且对数据中的微小扰动非常敏感。这意味着,一般来说,如果我们增加基学习器的复杂性,我们需要更多的它们来成功减少集成整体的变异性。然而,在这里,我们已将 n_estimators 设置为 10。

在确定基决策树深度的一个关键考虑因素是计算效率。训练越来越深的树将花费更多的时间,而不会在预测性能上产生显著的改进。例如,深度为 24 和 32 的基决策树表现大致相同。

2.5.3 随机森林的特征重要性

最后,让我们看看我们如何可以使用特征重要性来识别使用随机森林集成对乳腺癌诊断最有预测性的特征。这种分析增加了模型的可解释性,并且在向领域专家如医生解释此类模型时非常有帮助。

标签相关性特征重要性

首先,让我们查看数据集,看看我们是否可以发现特征和诊断之间的一些有趣关系。当我们得到一个新的数据集时,这种分析是典型的,因为我们试图更多地了解它。在这里,我们的分析将尝试确定哪些特征彼此之间以及与诊断(标签)最相关,这样我们就可以检查随机森林是否能做类似的事情。在下面的列表中,我们使用 pandas 和 seaborn 包来可视化特征和标签的相关性。

列表 2.6 可视化特征与标签之间的相关性

import pandas as pd
import seaborn as sea

df = pd.DataFrame(data=dataset['data'],                       ❶
                  columns=dataset['feature_names'])  
df['diagnosis'] = dataset['target'] 

fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(8, 8))
cor = np.abs(df.corr())                                       ❷
sea.heatmap(cor, annot=False, cbar=False, cmap=plt.cm.Greys, ax=ax)  
fig.tight_layout()

❶ 将数据转换为 pandas DataFrame

❷ 计算并绘制一些选定的特征与标签(诊断)之间的相关性

此列表的输出显示在图 2.13 中。一些特征彼此之间高度相关,例如,平均半径、平均周长和平均面积。一些特征也与标签高度相关,即良性或恶性的诊断。让我们确定与诊断标签最相关的 10 个特征:

label_corr = cor.iloc[:, -1]
label_corr.sort_values(ascending=False)[1:11]

这产生了以下前 10 个特征排名:

worst concave points    0.793566
worst perimeter         0.782914
mean concave points     0.776614
worst radius            0.776454
mean perimeter          0.742636
worst area              0.733825
mean radius             0.730029
mean area               0.708984
mean concavity          0.696360
worst concavity         0.659610

CH02_F13_Kunapuli

图 2.13 所有 30 个特征与标签(诊断)之间的绝对特征相关性

因此,我们的相关性分析告诉我们,这 10 个特征与诊断的相关性最高;也就是说,这些特征在乳腺癌诊断中可能最有帮助。

请记住,相关性并不总是识别有效变量的可靠手段,尤其是如果特征和标签之间存在高度非线性关系时。然而,只要我们了解其局限性,它通常是一个合理的指南。

使用随机森林的特征重要性

随机森林还可以提供特征重要性,如下所示。

列表 2.7 使用随机森林在 WDBC 数据集中的特征重要性

X_trn, X_tst, y_trn, y_tst = train_test_split(X, y, test_size=0.15)
n_features = X_trn.shape[1]

rf = RandomForestClassifier(max_leaf_nodes=24,                    ❶
                            n_estimators=50, n_jobs=-1) 
rf.fit(X_trn, y_trn)
err = 1 - accuracy_score(y_tst, rf.predict(X_tst))
print('Prediction Error = {0:4.2f}%'.format(err*100))

importance_threshold = 0.02                                       ❷
for i, (feature, importance) in enumerate(zip(dataset['feature_names'],
                                              rf.feature_importances_)):

    if importance > importance_threshold:
        print('[{0}] {1} (score={2:4.3f})'.
            format(i, feature, importance))                       ❸

❶ 训练一个随机森林集成

❷ 设置一个重要性阈值,其中所有高于阈值的特征都是重要的

❸ 打印“重要”的特征,即那些高于重要性阈值的特征

列表 2.7 依赖于一个重要性阈值,这里设置为 0.02。通常,这样的阈值是通过检查来设置的,以便我们得到一个目标特征集,或者使用一个单独的验证集来识别重要特征,这样整体性能就不会下降。

对于 WDBC 数据集,随机森林识别以下特征为重要。观察发现,通过相关性分析和随机森林识别的重要特征之间存在相当大的重叠,尽管它们的相对排名不同:

[2] mean perimeter (score=0.055)
[3] mean area (score=0.065)
[6] mean concavity (score=0.071)
[7] mean concave points (score=0.138)
[13] area error (score=0.065)
[20] worst radius (score=0.080)
[21] worst texture (score=0.023)
[22] worst perimeter (score=0.067)
[23] worst area (score=0.131)
[26] worst concavity (score=0.029)
[27] worst concave points (score=0.149)

最后,随机森林集成识别的特征重要性在图 2.14 中可视化。

CH02_F14_Kunapuli

图 2.14 随机森林集成可以通过特征的重要性来评分。这允许我们仅使用得分最高的特征进行特征选择。

注意:由于在树构建过程中的随机化,特征重要性通常会在运行之间发生变化。注意,如果两个特征高度相关,随机森林通常会在这两个特征之间分配特征重要性,导致它们的总体权重看起来比实际要小。为了集成可解释性的目的,还有其他更稳健的方法来计算特征重要性,我们将在第九章中探讨。

摘要

  • 平行同质集成通过随机化促进集成多样性:随机采样训练示例和特征,甚至可以在基学习算法中引入随机化。

  • Bagging 是一种简单的集成方法,它依赖于(1)自助采样(或带替换的采样)来生成数据集的多样复制品并训练不同的模型,以及(2)模型聚合来从一组单个基学习器预测中产生集成预测。

  • Bagging 及其变体与任何不稳定的估计器(未剪枝决策树、支持向量机[SVMs]、深度神经网络等)工作得最好,这些是更高复杂性和/或非线性的模型。

  • 随机森林是指一种特别设计用来使用随机决策树作为基学习器的 bagging 变体。增加随机性可以显著提高集成多样性,从而使集成减少变异性并平滑预测。

  • 粘贴(Pasting),作为袋装法的变体,在不放回的情况下对训练示例进行采样,对于具有大量训练示例的数据集可能非常有效。

  • 袋装法的其他变体,例如随机子空间(采样特征)和随机补丁(同时采样特征和训练示例),对于高维数据集可能非常有效。

  • 额外树(Extra Trees)是另一种类似于袋装法的集成方法,它专门设计用来使用极端随机树作为基学习器。然而,额外树不使用自助采样,因为额外的随机化有助于生成集成多样性。

  • 随机森林提供了特征重要性,从预测的角度对最重要的特征进行排序。

3 异构并行集成:结合强大学习者

本章涵盖了

  • 通过基于性能的加权结合基础学习模型

  • 通过堆叠和混合结合基础学习模型与元学习

  • 通过交叉验证集成避免过拟合

  • 探索一个大规模、真实世界的文本挖掘案例研究,使用异构集成

在上一章中,我们介绍了两种并行集成方法:bagging 和随机森林。这些方法(及其变体)训练的是同质集成,其中每个基础估计器都使用相同的基学习算法进行训练。例如,在 bagging 分类中,所有基础估计器都是决策树分类器。

在本章中,我们继续探索并行集成方法,但这次专注于异构集成。异构集成方法使用不同的基学习算法,直接确保集成多样性。例如,一个异构集成可以由三个基础估计器组成:一个决策树、一个支持向量机(SVM)和一个人工神经网络(ANN)。这些基础估计器仍然是相互独立地训练的。

最早期的异构集成方法,如堆叠,早在 1992 年就已经开发出来。然而,这些方法直到 2005 年左右的 Netflix Prize 竞赛中才真正崭露头角。前三名团队,包括最终赢得 100 万美元奖金的团队,都是集成团队,他们的解决方案是数百个不同基础模型的复杂混合。这一成功是对我们将在本章讨论的许多方法有效性的显著和公开的证明。

受此成功启发,堆叠和混合变得非常流行。在有足够的基础估计器多样性的情况下,这些算法通常可以提高数据集的性能,并成为任何数据分析师工具箱中的强大集成工具。

另一个它们受欢迎的原因是它们可以轻松地结合现有模型,这使得我们可以使用先前训练的模型作为基础估计器。例如,假设你和一位朋友独立地在一个 Kaggle 竞赛的数据集上工作。你训练了一个支持向量机(SVM),而你的朋友训练了一个逻辑回归模型。虽然你的个人模型表现不错,但你俩都认为如果你们把头(和模型)放在一起,可能会做得更好。你们无需重新训练这些现有模型,就可以构建一个异构集成。你只需要找出一种方法来结合你的两个模型。异构集成有两种类型,这取决于它们如何将个别基础估计器的预测组合成最终预测(见图 3.1):

  • 加权方法—这些方法为每个基础估计器的预测分配一个与其强度相对应的权重。更好的基础估计器被分配更高的权重,对最终预测的整体影响更大。个别基础估计器的预测被输入到一个预定的组合函数中,从而生成最终预测。

  • 元学习方法—这些方法使用学习算法来组合基础估计器的预测;个别基础估计器的预测被视为元数据并传递给第二级元学习器,该学习器被训练以做出最终预测。图 3.1(第二章)中的同质集成(如 bagging 和随机森林)使用相同的学习算法来训练基础估计器,并通过随机采样实现集成多样性。异构集成(本章)使用不同的学习算法来实现集成多样性。

CH03_F01_Kunapuli

图 3.1 同质集成(第二章),如 bagging 和随机森林,使用相同的学习算法来训练基础估计器,并通过随机采样实现集成多样性。异构集成(本章)使用不同的学习算法来实现集成多样性。

我们首先介绍加权方法,这些方法通过根据每个分类器的有效性加权其贡献来组合分类器。

3.1 异构集成的基础估计器

在本节中,我们将设置一个学习框架来调整异构基础估计器并从中获取预测。这是构建任何应用的异构集成的第一步,对应于图 3.1 底部之前显示的H[1],H[2],...H[m]的个别基础估计器的训练。

我们将使用一个简单的二维数据集来训练我们的基础估计器,这样我们可以明确地可视化每个基础估计器的决策边界和行为以及估计器的多样性。一旦训练完成,我们可以使用加权方法(第 3.2 节)或元学习方法(第 3.3 节)来构建异构集成:

from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
X, y = make_moons(600, noise=0.25, random_state=13)      
X, Xval, y, yval = train_test_split(X, y, 
                                    test_size=0.25)          ❶
Xtrn, Xtst, ytrn, ytst = train_test_split(X, y,                                     test_size=0.25)    ❷

❶ 将 25%的数据留作验证

❷ 将另外 25%的数据留作留出测试

这段代码片段生成了 600 个等量分布到两个类别的合成训练示例,如图 3.2 所示,这些示例被可视化成圆圈和正方形。

CH03_F02_Kunapuli

图 3.2 包含两个类别的合成数据集:类别 0(圆圈)和类别 1(正方形)各有 300 个示例

3.1.1 调整基础估计器

我们的首要任务是训练个别的基础估计器。与同质集成不同,我们可以使用任意数量的不同学习算法和参数设置来训练基础估计器。关键是确保我们选择足够不同的学习算法,以产生多样化的估计器集合。我们的基础估计器集合越多样化,最终的集成效果就越好。对于这种情况,我们使用了六个流行的机器学习算法,它们都在 scikit-learn 中可用:DecisionTreeClassifier、SVC、GaussianProcessClassifier、KNeighborsClassifier、RandomForestClassifier 和 GaussianNB(见图 3.3)。

CH03_F03_Kunapuli

图 3.3 使用 scikit-learn 拟合六个基础估计器

下面的列表初始化了图 3.3 中显示的六个基础估计器,并对其进行了训练。注意用于初始化每个基础估计器的个别参数设置(例如,DecisionTreeClassifier 的 max_depth=5 或 KNeighborsClassifier 的 n_neighbors=3)。在实际应用中,这些参数必须仔细选择。对于这个简单的数据集,我们可以猜测或者直接使用默认参数推荐。

列表 3.1 拟合不同的基础估计器

from sklearn.tree import DecisionTreeClassifier
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.gaussian_process import GaussianProcessClassifier
from sklearn.gaussian_process.kernels import RBF
from sklearn.ensemble import RandomForestClassifier
from sklearn.naive_bayes import GaussianNB

estimators = [
    ('dt', DecisionTreeClassifier (max_depth=5)),      ❶
    ('svm', SVC(gamma=1.0, C=1.0, probability=True)),
    ('gp', GaussianProcessClassifier(RBF(1.0))),
    ('3nn', KNeighborsClassifier(n_neighbors=3)),
    ('rf',RandomForestClassifier(max_depth=3, n_estimators=25)), 
    ('gnb', GaussianNB())]

def fit(estimators, X, y):
    for model, estimator in estimators:
        estimator.fit(X, y)                            ❷
    return estimators

❶ 初始化几个基础学习算法

❷ 使用这些不同的学习算法在训练数据上拟合基础估计器

我们在训练数据上训练我们的基础估计器:

estimators = fit(estimators, Xtrn, ytrn)

一旦训练完成,我们还可以可视化每个基础估计器在我们数据集上的行为。看起来我们能够产生一些相当多样化的基础估计器。

除了集成多样性之外,从单个基础估计器的可视化中立即显而易见的一个方面是,它们在保留的测试集上的表现并不相同。在图 3.4 中,3-最近邻(3nn)在测试集上表现最佳,而高斯朴素贝叶斯(gnb)表现最差。

CH03_F04_Kunapuli

图 3.4 我们异构集成中的基础估计器。每个基础估计器都使用不同的学习算法进行训练,这通常会导致多样化的集成。

例如,DecisionTreeClassifier (dt) 通过使用轴平行边界(因为树中的每个决策节点都基于一个变量进行分割)将特征空间划分为决策区域来生成分类器。另一方面,svm 分类器 SVC 使用径向基函数(RBF)核,这导致决策边界更加平滑。因此,虽然这两种学习算法都可以学习非线性分类器,但它们以不同的方式非线性。

核方法

SVM 是核方法的一个例子,核方法是一种可以使用核函数的机器学习算法。核函数可以在高维空间中隐式地高效测量两个数据点之间的相似性,而无需显式地将数据转换到该空间。通过用核函数替换内积计算,可以将线性估计器转换为非线性估计器。常用的核包括多项式核和高斯核(也称为 RBF 核)。有关详细信息,请参阅 Trevor Hastie、Robert Tibshirani 和 Jerome Friedman 所著的《统计学习的要素:数据挖掘、推理和预测》第 2 版第十二章(Springer,2016 年)。

3.1.2 基础估计器的个体预测

给定用于预测的测试数据(Xtst),我们可以使用每个基础估计器来获取每个测试示例的预测。在我们的场景中,因为我们有六个基础估计器,所以每个测试示例将有六个预测,每个对应一个基础估计器(见图 3.5)。

CH03_F05_Kunapuli

图 3.5 展示了 scikit-learn 中六个训练基础估计器的测试集的个体预测

我们现在的任务是收集每个训练基础估计器的每个测试示例的预测到一个数组中。在列表 3.2 中,变量 y 是存储预测的结构,其大小为 n_samples * n_estimators。也就是说,y[15, 1]将表示第 2 个分类器(SVC)对第 16 个测试示例的预测(记住 Python 中的索引从 0 开始)。

列表 3.2 基础估计器的个体预测

import numpy as np

def predict_individual(X, estimators, proba=False):      ❶
    n_estimators = len(estimators)
    n_samples = X.shape[0] 

    y = np.zeros((n_samples, n_estimators))
    for i, (model, estimator) in enumerate(estimators):
        if proba:
            y[:, i] = estimator.predict_proba(X)[:, 1]   ❷
        else:
            y[:, i] = estimator.predict(X)               ❸
    return y

❶ “proba”标志允许我们预测标签或标签的概率。

❷ 如果为真,则预测第 1 类的概率(返回一个介于 0 和 1 之间的浮点概率值)

❸ 否则,直接预测第 1 类(返回整数类标签 0 或 1)

注意到我们的函数 predict_individual 有 proba 标志。当我们设置 proba=False 时,predict_individual 根据每个估计器返回预测的标签。预测的标签取值为y[pred] = 0 或y[pred] = 1,这告诉我们估计器预测该示例属于类 0 或类 1。

然而,当我们设置 proba=True 时,每个估计器将通过每个基础估计器的 predict_proba()函数返回类预测概率:

y[:, i] = estimator.predict_proba(X)[:, 1] 

注意

scikit-learn 中的大多数分类器可以返回标签的概率而不是直接返回标签。其中一些,如 SVC,需要明确告知这样做(注意我们在初始化 SVC 时设置了 probability=True),而其他一些则是自然的概率分类器,可以表示和推理类概率。这些概率代表了每个基础估计器对其预测的置信度

我们可以使用此函数来预测测试示例:

y_individual = predict_individual(Xtst, estimators, proba=False)

这将产生以下输出:

[[0\. 0\. 0\. 0\. 0\. 0.]
 [1\. 1\. 1\. 1\. 1\. 1.]
 [1\. 1\. 1\. 1\. 1\. 1.]
 ...
 [0\. 0\. 0\. 0\. 0\. 0.]
 [1\. 1\. 1\. 1\. 1\. 1.]
 [0\. 0\. 0\. 0\. 0\. 0.]]

每一行包含六个预测,每个预测对应于每个基估计器的预测。我们检查我们的预测:Xtst 有 113 个测试示例,y_individual 为每个示例提供六个预测,这给我们一个 113 × 6 的预测数组。

当 proba=True 时,predict_individual 返回一个示例属于类别 1 的概率,我们用P(y[pred] = 1)表示。对于像这样二分类(二元)分类问题,示例属于类别 0 的概率简单地是 1 - P(y[pred] = 1),因为示例只能属于一个或另一个,所有可能性的概率之和为 1。我们按以下方式计算它们:

y_individual = predict_individual(Xtst, estimators, proba=True)

这会产生以下输出:

array([[0\.  , 0.01, 0.08, 0\.  , 0.04, 0.01],
       [1\.  , 0.99, 0.92, 1\.  , 0.92, 0.97],
       [0.98, 0.89, 0.76, 1\.  , 0.89, 0.95],
       ...,
       [0\.  , 0.03, 0.15, 0\.  , 0.11, 0.07],
       [1\.  , 0.97, 0.87, 1\.  , 0.72, 0.62],
       [0\.  , 0\.  , 0.05, 0\.  , 0.1 , 0.12]])

在这个输出的第三行中,第三个条目是 0.76,这表明我们的第三个基估计器,GaussianProcessClassifier,有 76%的信心认为第三个测试示例属于类别 1。另一方面,第三行的第一个条目是 0.98,这意味着 DecisionTreeClassifier 有 98%的信心认为第一个测试示例属于类别 1。

这样的预测概率通常被称为软预测。通过简单地选择具有最高概率的类别标签,可以将软预测转换为硬(0-1)预测;在这个例子中,根据 GaussianProcessClassifier,硬预测将是y = 0,因为P(y = 0) > P(y = 1)。

为了构建异构集成,我们可以直接使用预测,或者使用它们的概率。使用后者通常会产生更平滑的输出。

注意:刚刚讨论的预测函数是专门为二分类,即二元分类问题编写的。如果注意存储每个类别的预测概率,它可以扩展到多分类问题。也就是说,对于多分类问题,您需要在大小为 n_samples * n_estimators * n_classes 的数组中存储个别预测概率。

我们现在已经建立了创建异构集成所需的基本基础设施。我们已经训练了六个分类器,并且有一个函数可以给我们提供它们对新例子的个别预测。当然,最后也是最重要的步骤是如何组合这些个别预测:通过加权或通过元学习。

3.2 通过加权组合预测

加权方法的目标是什么?让我们回到 3nn 和 gnb 分类器在我们简单的 2D 数据集上的性能(见图 3.6)。想象一下,我们试图使用这两个分类器作为基估计器构建一个非常简单的异构分类器。

CH03_F06_Kunapuli

图 3.6 两个基估计器在相同的数据集上可能会有非常不同的行为。加权策略应该通过加权表现更好的分类器来反映它们的性能。

假设我们使用测试错误作为评估指标来比较这两个分类器的行为。测试错误可以使用 Xtst 中的示例来评估,Xtst 在训练期间被保留出来;这为我们提供了一个很好的估计,即模型在未来的未见数据上的行为。

3nn 分类器的测试错误率为 3.54%,而 gnb 的测试错误率为 11.5%。直观上,我们会在这个数据集上更信任 3nn 分类器而不是 gnb 分类器。然而,这并不意味着 gnb 是无用的,应该被丢弃。对于许多示例,它可以加强 3nn 做出的决策。我们不希望它在没有信心的情况下与 3nn 产生矛盾。

这种基础估计量置信度的概念可以通过分配权重来捕捉。当我们想要为基分类器分配权重时,我们应该以与这种直觉一致的方式去做,使得最终预测更多地受到强大分类器的影响,而较少受到较弱分类器的影响。

假设我们得到了一个新的数据点 x,并且个别预测是 y[3nn] 和 y[gnb]。一种简单的方法是根据它们的性能来加权它们。3nn 的测试集准确率 a[3nn] = 1 - 0.0354 = 0.9646,gnb 的测试集准确率 a[gnb] = 1 - 0.115 = 0.885。最终的预测可以计算如下:

CH03_F06_Kunapuli-eqs-0x

估计量权重 w[3nn] 和 w[gnb] 与它们各自的准确性成比例,准确性更高的分类器将具有更高的权重。在这个例子中,我们有 w[3nn] = 0.522 和 w[gnb] = 0.478。我们使用一个简单的线性组合函数(技术上,是一个凸组合,因为所有权重都是正的,且总和为 1)将两个基础估计量结合起来。

让我们继续进行对 2D 双月数据集进行分类的任务,并探索各种加权组合策略。这通常包括两个步骤(见图 3.7):

  1. 以某种方式为每个分类器(clf)分配权重(w[clf]),反映其重要性。

  2. 使用组合函数 h[c] 将加权预测(w[clf] ⋅ y[clf])结合起来。

CH03_F07_Kunapuli

图 3.7 每个基础分类器都被分配了一个重要性权重,它反映了其意见对最终决策的贡献程度。使用组合函数将每个基础分类器的加权决策结合起来。

现在我们来看几种这样的策略,这些策略概括了预测和预测概率的这种直觉。许多这些策略都非常容易实现,并且在融合多个模型的预测中常用。

3.2.1 多数投票

你已经熟悉了前一章中的一种加权组合类型:多数投票。在这里我们简要回顾多数投票,以表明它只是众多组合方案中的一种,并将其纳入组合方法的通用框架中。

多数投票可以看作是一种加权组合方案,其中每个基本估计器被分配一个相等的权重;也就是说,如果我们有 m 个基本估计器,每个基本估计器的权重为 w[clf] = 1/m。个体基本估计器的(加权)预测通过多数投票相结合。

与 bagging 类似,这种策略也可以扩展到异质集成。在图 3.8 中展示的通用组合方案中,为了实现这种加权策略,我们设置 w[clf] = 1/mh[c] = 多数投票,这是统计上的众数。

CH03_F08_Kunapuli

图 3.8 多数投票组合。Bagging 可以看作是应用于同质集成的简单加权方法。所有分类器具有相等的权重,组合函数是多数投票。我们也可以为异质集成采用多数投票策略。

以下列表使用多数投票将来自异质基本估计器集的个体预测 y_individual 结合起来。请注意,由于基本估计器的权重都是相等的,所以我们没有显式地计算它们。

列表 3.3 使用多数投票组合预测

from scipy.stats import mode

def combine_using_majority_vote(X, estimators):
    y_individual = predict_individual(X, estimators, proba=False)
    y_final = mode(y_individual, axis=1, keepdims=False)
    return y_final[0].reshape(-1, )       ❶

❶ 重塑向量以确保每个示例返回一个预测

我们可以使用此函数使用我们之前训练的基本估计器对测试数据集 Xtst 进行预测:

from sklearn.metrics import accuracy_score
ypred = combine_using_majority_vote(Xtst, estimators)
tst_err = 1 - accuracy_score(ytst, ypred)

这产生了以下测试错误:

0.06194690265486724

这种加权策略产生了一个具有 6.19% 测试错误的异质集成。

3.2.2 准确率加权

回想本节开头我们讨论的激励示例,其中我们试图使用 3nn 和 gnb 作为基本估计器构建一个非常简单的异质分类器。在那个例子中,我们直观的集成策略是按每个估计器的性能对其进行加权,具体来说,是准确率分数。这是一个非常简单的准确率加权示例。

在这里,我们将此过程推广到两个以上估计器,如图 3.8 所示。为了获得基本分类器的 无偏性能估计,我们将使用一个 验证集

为什么我们需要一个验证集?

当我们生成数据集时,我们将它划分为训练集、验证集和保留的测试集。这三个子集是互斥的;也就是说,它们没有任何重叠的示例。那么,我们应该使用这三个中的哪一个来获得每个个体基本分类器性能的无偏估计?

总是好的机器学习实践是重用训练集来估计性能,因为我们已经看到了这些数据,所以性能估计将是有偏的。这就像在期末考试中看到之前分配的作业问题一样。这并不能真正告诉教授你表现良好,因为你已经学会了概念;它只是表明你擅长那个特定的问题。同样,使用训练数据来估计性能并不能告诉我们分类器是否可以很好地泛化;它只是告诉我们它在已经看到的例子上的表现如何。为了得到有效且无偏的估计,我们需要在模型从未见过的数据上评估性能。

我们可以使用验证集或保留的测试集来获得无偏估计。然而,测试集通常用于评估最终模型性能,即整体集成的性能。

在这里,我们感兴趣的是估计每个基本分类器的性能。因此,我们将使用验证集来获得每个基本分类器性能的无偏估计:准确率。

使用验证集进行准确度加权

一旦我们训练了每个基本分类器(clf),我们将在验证集上评估其性能。令 α[t] 为第 t 个分类器 H[t] 的验证准确率。然后,每个基本分类器的权重计算如下:

CH03_F08_Kunapuli-eqs-2x

分母是一个归一化项:所有单个验证准确率的总和。这个计算确保了分类器的权重与其准确性成比例,并且所有权重之和为 1。

给定一个新示例来预测 x,我们可以得到单个分类器的预测,y[t](使用 predict_individual)。现在,最终预测可以计算为单个预测的加权总和:

CH03_F08_Kunapuli-eqs-3x

该过程如图 3.9 所示。

CH03_F09_Kunapuli

图 3.9 通过性能加权进行组合。每个分类器被分配一个与其准确度成比例的权重。最终预测是单个预测的加权组合。

列表 3.4 实现了通过准确度加权的组合。请注意,尽管单个分类器的预测值将为 0 或 1,但最终的整体预测将是一个介于 0 和 1 之间的实数,因为权重是分数。可以通过在 0.5 阈值上对加权预测进行阈值处理,轻松地将这种分数预测转换为 0-1 的最终预测。

例如,y_final=0.75 的联合预测将被转换为 y_final=1(因为 0.75 大于 0.5 阈值),而 y_final=0.33 的联合预测将被转换为 y_final=0(因为 0.33 小于 0.5 阈值)。虽然平局非常罕见,但可以任意打破。

列表 3.4 使用准确度加权进行组合

def combine_using_accuracy_weighting(X, estimators, 
                                     Xval, yval):        ❶
    n_estimators = len(estimators)
    yval_individual = predict_individual(Xval, 
                          estimators, proba=False)       ❷

    wts = [accuracy_score(yval, yval_individual[:, i]) 
           for i in range(n_estimators)]                 ❸

wts /= np.sum(wts)                                       ❹

ypred_individual = predict_individual(X, estimators, proba=False)
y_final = np.dot(ypred_individual, wts)                  ❺

return np.round(y_final)                                 ❻

❶ 将验证集作为输入

❷ 在验证集上获取单个预测

❸ 为每个基本分类器设置其准确率分数作为权重

❹ 归一化权重

❺ 高效地计算单个标签的加权组合

❻ 通过四舍五入将组合预测转换为 0-1 标签

我们可以使用这个函数使用我们之前训练的基本估计器对测试数据集 Xtst 进行预测:

ypred = combine_using_accuracy_weighting(Xtst, estimators, Xval, yval)
tst_err = 1 - accuracy_score(ytst, ypred)

这会产生以下输出:

0.03539823008849563

这种加权策略产生了一个异质集成,测试错误率为 3.54%。

3.2.3 熵加权

熵加权方法是一种基于性能的加权方法,但它使用熵作为评估指标来判断每个基本估计器的价值。熵是集合中不确定性杂质的度量;一个更无序的集合将具有更高的熵。

熵,或更确切地说,信息熵,最初由克劳德·香农提出,用于量化一个变量所传递的“信息量”。这取决于两个因素:(1)变量可以取的不同值的数量,(2)与每个值相关的不确定性。

考虑有三个病人——Ana、Bob 和 Cam——在医生的办公室等待医生的疾病诊断。Ana 被告知有 90%的信心她很健康(即有 10%的可能性她生病)。Bob 被告知有 95%的信心他生病了(即有 5%的可能性他健康)。Cam 被告知他的检测结果不明确(即 50%/50%)。

Ana 收到了好消息,她的诊断几乎没有不确定性。尽管 Bob 收到了坏消息,但他的诊断几乎没有不确定性。Cam 的情况具有最高的不确定性:他没有收到好坏消息,需要进行更多的测试。

熵量化了这种关于各种结果的不确定性概念。基于熵的度量在决策树学习期间通常用于贪婪地识别最佳分割变量,并在深度神经网络中用作损失函数。

我们不是用准确率来权衡分类器,而是可以使用熵。然而,由于较低的熵是可取的,我们需要确保基本分类器的权重与其对应的熵成反比

计算预测的熵

假设我们有一个测试示例,一个由 10 个基本估计器组成的集成返回了一个预测标签的向量:[1, 1, 1, 0, 0, 1, 1, 1, 0, 0]。这个集合有六个预测的 y = 1 和四个预测的 y = 0。这些 标签计数 可以等价地表示为 标签概率:预测 y = 1 的概率是 P(y = 1) = 6/10 = 0.6,预测 y = 0 的概率是 P(y = 0) = 4/10 = 0.4。有了这些标签概率,我们可以计算这个基本估计器预测集合的熵:

CH03_F09_Kunapuli-eqs-6x

在这种情况下,我们将有 E = -0.4 log0.4 - 0.6 log0.6 = 0.971。

或者,考虑第二个测试示例,其中 10 个基础估计器返回了一个预测标签的向量:[1, 1, 1, 1, 0, 1, 1, 1, 1, 1]。这个集合有九个预测 y = 1 和一个预测 y = 0。在这种情况下,标签概率P(y = 1) = 9/10 = 0.9 和 P(y = 0) = 1/10 = 0.1。在这种情况下,熵将是 E = -0.1 log0.1 - 0.9 log0.9 = 0.469。这个预测集合的熵较低,因为它更纯净(大多数预测都是 y = 1)。另一种看法是,10 个基础估计器对第二个示例的预测更不确定。以下列表可以用来计算离散值集合的熵。

列表 3.5 计算熵

def entropy(y):
    _, counts = np.unique(y, return_counts=True)    ❶
    p = np.array(counts.astype('float') / len(y))   ❷
    ent = -p.T @ np.log2(p)                         ❸

    return ent

❶ 计算标签计数

❷ 将计数转换为概率

❸ 计算熵作为点积

使用验证集的熵权重

E[t] 为第 t 个分类器的验证熵 H[t]。每个基础分类器的权重为

CH03_F09_Kunapuli-eqs-9x

熵权重和准确度权重之间有两个主要区别:

  • 基础分类器的准确度使用真实标签 ytrue 和预测标签 ypred 计算。这样,准确度度量指标衡量分类器的性能。准确度高的分类器更好。

  • 基础分类器的熵仅使用预测标签 ypred 计算,熵度量指标衡量分类器对其预测的不确定性。熵(不确定性)低的分类器更好。因此,单个基础分类器权重与其对应的熵成反比。

与准确度权重一样,最终预测需要在 0.5 处进行阈值处理。以下列表实现了使用熵权重的组合。

列表 3.6 使用熵权重的组合

def combine_using_entropy_weighting(X, estimators, 
                                    Xval):                ❶
    n_estimators = len(estimators)
    yval_individual = predict_individual(Xval, 
                          estimators, proba=False)        ❷

    wts = [1/entropy(yval_individual[:, i])               ❸
           for i in range(n_estimators)]
    wts /= np.sum(wts)                                    ❹

    ypred_individual = predict_individual(X, estimators, proba=False)
    y_final = np.dot(ypred_individual, wts)               ❺

    return np.round(y_final)                              ❻

❶ 仅取验证示例

❷ 在验证集上获取单个预测

❸ 将每个基础分类器的权重设置为它的逆熵

❹ 归一化权重

❺ 高效地计算单个标签的加权组合

❻ 返回四舍五入的预测

我们可以使用此函数使用先前训练的基础估计器对测试数据集 Xtst 进行预测:

ypred = combine_using_entropy_weighting(Xtst, estimators, Xval)
tst_err = 1 - accuracy_score(ytst, ypred)

这会产生以下输出:

0.03539823008849563

这种加权策略产生了一个具有 3.54% 测试错误的异构集成。

3.2.4 Dempster-Shafer 组合

我们之前看到的方法直接组合了单个基础估计器的预测(注意,我们在调用 predict_ individual 时设置了标志 proba=False)。当我们设置 proba=True 在 predict_individual 中时,每个分类器都会返回其属于类别 1 的概率的个体估计。也就是说,当 proba=True 时,而不是返回 y[pred] = 0 或 y[pred] = 1,每个估计器将返回 P(y[pred] = 1)。

这个概率反映了分类器对预测应该是什么的信念,并提供了对预测的更细致的看法。虽然本节中描述的方法也可以与概率一起工作,但 Dempster-Shafer 理论(DST)方法是将这些基估计器信念融合成一个整体最终信念或预测概率的另一种方式。

DST 用于标签融合

DST 是概率论的一种推广,它支持在不确定性和不完整知识下的推理。虽然 DST 的基础超出了本书的范围,但该理论本身提供了一种将来自多个来源的信念和证据融合成一个单一信念的方法。

DST 使用介于 0 和 1 之间的数字来表示对命题的信念,例如“测试示例 x 属于类别 1”。这个数字被称为基本概率分配(BPA),它表达了文本示例 x 属于类别 1 的确定性。接近 1 的 BPA 值表示更确定的决策。BPA 允许我们将估计器的置信度转换为对真实标签的信念。

假设使用 3nn 分类器对测试示例 x 进行分类,并返回 P(y[pred] = 1 | 3nn) = 0.75。现在,gnb 也被用来对相同的测试示例进行分类,并返回 P(y[pred] = 1 | gnb) = 0.6。根据 DST,我们可以计算命题“测试示例 x 根据两者 3nn 和 gnb 都属于类别 1”的 BPA。我们通过融合它们的个别预测概率来完成这项工作:

CH03_F09_Kunapuli-eqs-10x

我们还可以计算命题“测试示例 x 根据 3nn 和 gnb 都属于类别 0”的 BPA:

CH03_F09_Kunapuli-eqs-11x

根据这些分数,我们更有信心认为测试示例 x 属于类别 1。BPAs 可以被视为置信度分数,我们可以用它们来计算属于类别 0 或类别 1 的最终信念。

BPAs 用于计算信念。未归一化的信念(表示为 Bel)是“测试示例 x 属于类别 1”的计算如下

CH03_F09_Kunapuli-eqs-12x

CH03_F09_Kunapuli-eqs-13x

这些未归一化的信念可以使用归一化因子 Z = Bel(y[pred] = 1) + Bel(y[pred] = 0) +1 进行归一化,以得到 Bel(y[pred] = 1) = 0.80 和 Bel(y[pred] = 0) = 0.11。最后,我们可以使用这些信念来得到最终的预测:信念最高的类别。对于这个测试示例,DST 方法产生了最终的预测 y[pred] = 1。

结合使用 DST

下面的列表实现了这种方法。

列表 3.7 结合使用 Dempster-Shafer

def combine_using_Dempster_Schafer(X, estimators):
    p_individual = predict_individual(X, 
                       estimators, proba=True)      ❶
    bpa0 = 1.0 - np.prod(p_individual, axis=1)
    bpa1 = 1.0 - np.prod(1 - p_individual, axis=1)

    belief = np.vstack([bpa0 / (1 - bpa0), 
                        bpa1 / (1 - bpa1)]).T       ❷
    y_final = np.argmax(belief, axis=1)             ❸
    return y_final

❶ 在验证集上获取个别预测

❷ 将每个测试示例的类别 0 和类别 1 的信念并排堆叠

❸ 选择最终标签为信念最高的类别

我们可以使用这个函数来使用我们之前训练的基础估计器对测试数据集 Xtst 进行预测:

ypred = combine_using_Dempster_Schafer(Xtst, estimators)
tst_err = 1 - accuracy_score(ytst, ypred)

这会产生以下输出:

0.053097345132743334

这个输出意味着 DST 达到了 5.31%的准确率。

我们已经看到了将预测组合成一个最终预测的四种方法。两种直接使用预测,而另外两种使用预测概率。我们可以可视化这些加权方法产生的决策边界,如图 3.10 所示。

CH03_F10_Kunapuli

图 3.10 不同加权方法的决策边界

3.3 通过元学习组合预测

在上一节中,我们看到了构建分类器异构集成的另一种方法:加权。我们根据每个分类器的性能对每个分类器进行加权,并使用一个预定的组合函数来组合每个分类器的预测。在这样做的时候,我们必须仔细设计组合函数,以反映我们的性能优先级。

现在,我们将探讨构建异构集成的一种另一种方法:元学习。我们不会精心设计一个组合函数来组合预测,而是会在单个预测上训练一个组合函数。也就是说,基础估计器的预测被作为输入提供给第二级学习算法。因此,我们不会自己设计,而是训练一个第二级的元分类函数

元学习方法已经在化学计量分析、推荐系统、文本分类和垃圾邮件过滤等众多任务中得到了广泛和成功的应用。对于推荐系统,堆叠和混合的元学习方法在 Netflix 奖项竞赛中由几个顶级团队使用后,被带到了显眼的位置。

3.3.1 堆叠

堆叠是最常见的元学习方法,其名称来源于它在其基础估计器之上堆叠第二个分类器。一般的堆叠过程有两个步骤:

  1. 第一级:在训练数据上拟合基础估计器。这一步与之前相同,目的是创建一个多样化、异构的基础分类器集。

  2. 第二级:从基础分类器的预测中构建一个新的数据集,这些预测成为元特征。元特征可以是预测本身或预测的概率。

让我们回到我们的例子,我们从一个 3nn 分类器和 gnb 分类器在我们的 2D 合成数据集上构建一个简单的异构集成。在训练分类器(3nn 和 gnb)之后,我们创建了新的特征,称为分类元特征,这些特征来自这两个分类器(见图 3.11)。

CH03_F11_Kunapuli

图 3.11 根据 3nn 和 gnb 的预测概率,每个训练样本的预测概率被用作新分类器的元特征。较暗区域的点表示高置信度的预测。每个训练样本现在有两个元特征,分别来自 3nn 和 gnb。

由于我们有两个基础分类器,我们可以使用每个分类器生成我们元示例中的一个元特征。在这里,我们使用 3nn 和 gnb 的预测概率作为元特征。因此,对于每个训练示例,比如说x[i],我们获得两个元特征:yi[3nn]和*y*i[gnb],它们分别是 3nn 和 gnb 根据x[i]进行的预测概率。

这些元特征成为第二级分类器的元数据。将这种堆叠方法与加权组合进行对比。对于这两种方法,我们使用函数 predict_individual 获得单个预测。对于加权组合,我们直接将这些预测用于某些预定的组合函数。在堆叠中,我们使用这些预测作为新的训练集来训练一个组合函数

堆叠可以使用任意数量的第一级基础估计器。我们的目标,一如既往,将是确保这些基础估计器之间存在足够的多样性。图 3.12 显示了用于探索通过加权组合的六个先前使用的流行算法的堆叠示意图:DecisionTreeClassifier,SVC,GaussianProcess Classifier,KNeighborsClassifier,RandomForestClassifier,和 GaussianNB。

CH03_F12_Kunapuli

图 3.12 使用六个第一级基础估计器的堆叠产生了一个包含六个元特征的元数据集,这些特征可以用来训练第二级元分类器(此处为逻辑回归)。

此处的第二级估计器可以使用任何基础学习算法进行训练。历史上,线性模型,如线性回归和逻辑回归,已被使用。在第二级使用此类线性模型的集成方法称为线性堆叠。线性堆叠通常很受欢迎,因为它速度快:学习线性模型通常计算效率高,即使是对于大型数据集。通常,线性堆叠也可以是分析数据集的有效探索步骤。

然而,堆叠也可以在其第二级使用强大的非线性分类器,包括 SVMs 和 ANNs。这允许集成以复杂的方式组合元特征,尽管这牺牲了线性模型固有的可解释性。

注意:scikit-learn(v1.0 及以上版本)包含 StackingClassifier 和 StackingRegressor,可以直接用于训练。在以下小节中,我们将实现自己的堆叠算法,以了解元学习在底层如何工作的更详细细节。

让我们回顾一下对二维双月数据集进行分类的任务。我们将实现一个线性堆叠过程,该过程包括以下步骤:(1)训练单个基础估计器(第一级),(2a)构建元特征,和(2b)训练一个线性回归模型(第二级)。

我们已经开发出了快速实现线性堆叠所需的大部分框架。我们可以使用 fit(参见图表 3.1)来训练单个基础估计器,并从 predict_individual(参见图表 3.2)中获取元特征。以下列表使用这些函数来拟合任何二级估计器的堆叠模型。由于二级估计器使用生成的特征或元特征,它也被称为元估计器

列表 3.8 使用二级估计器的堆叠

def fit_stacking(level1_estimators, level2_estimator, 
use_probabilities=False):

    fit(level1_estimators, X, y)                      ❶

    X_meta = predict_individual(X, estimators=level1_estimators,
                 proba=use_probabilities)             ❷

    level2_estimator.fit(X_meta, y)                   ❸

    final_model = {'level-1': level1_estimators, 
                   'level-2': level2_estimator,       ❹
                   'use-proba': use_probabilities}    
    return final_model

❶ 训练一级基础估计器

❷ 获取元特征作为单个预测或预测概率(proba=True/False)

❸ 训练二级元估计器

❹ 将一级估计器和二级估计器保存在字典中

此函数可以通过直接使用预测(use_probabilities=False)或使用预测概率(use_probabilities=True)来学习,如图 3.13 所示。

CH03_F13_Kunapuli

图 3.13 使用逻辑回归堆叠并使用预测(左侧)或预测概率(右侧)作为元特征的最终模型

此处的二级估计器可以是任何分类模型。逻辑回归是一个常见的选择,它导致集成使用线性模型堆叠一级预测。

也可以使用非线性模型作为二级估计器。一般来说,任何学习算法都可以用于在元特征上训练二级估计器。例如,使用 RBF 核的 SVM 或 ANN 这样的学习算法可以在第二级学习强大的非线性模型,并可能进一步提高性能。

预测分为两个步骤:

  1. 对于每个测试示例,使用训练好的一级估计器获取元特征,并创建相应的测试元示例。

  2. 对于每个元示例,使用二级估计器获取最终预测。

使用堆叠模型进行预测也可以轻松实现,如图 3.9 所示。

列表 3.9 使用堆叠模型进行预测

def predict_stacking(X, stacked_model):
    level1_estimators = stacked_model['level-1']       ❶
    use_probabilities = stacked_model['use-proba']

    X_meta = predict_individual(X, estimators=level1_estimators,
                 proba=use_probabilities)              ❷

    level2_estimator = stacked_model['level-2']
    y = level2_estimator.predict(X_meta)               ❸

    return y

❶ 获取一级基础估计器

❷ 使用一级基础估计器获取元特征

❸ 获取二级估计器并使用它对元特征进行最终预测

在以下示例中,我们使用上一节中相同的六个基础估计器作为第一级,并使用逻辑回归作为第二级元估计器:

from sklearn.linear_model import LogisticRegression
meta_estimator = LogisticRegression(C=1.0, solver='lbfgs')
stacking_model = fit_stacking(estimators, meta_estimator, 
                              Xtrn, ytrn, use_probabilities=True)
ypred = predict_stacking(Xtst, stacking_model)
tst_err = 1 - accuracy_score(ytst, ypred)

这会产生以下输出:

0.06194690265486724

在前面的代码片段中,我们使用了预测概率作为元特征。此线性堆叠模型获得了 6.19%的测试误差。

这种简单的堆叠过程通常很有效。然而,它确实存在一个显著的缺点:过拟合,尤其是在存在噪声数据的情况下。过拟合的影响可以在图 3.14 中观察到。在堆叠的情况下,过拟合发生是因为我们使用了相同的数据集来训练所有基础估计器。

CH03_F14_Kunapuli

图 3.14 堆叠可能会过拟合数据。这里有过拟合的证据:决策边界在分类器尝试拟合单个、有噪声的示例的地方非常锯齿状。

为了防止过拟合,我们可以结合k折交叉验证(CV),这样每个基础估计器就不会在完全相同的数据集上训练。你可能之前遇到过并使用 CV 进行参数选择和模型评估。

在这里,我们使用交叉验证(CV)将数据集划分为子集,以便不同的基础估计器在不同的子集上训练。这通常会导致更多样化和鲁棒性,同时降低过拟合的风险。

3.3.2 带有交叉验证的堆叠

CV 是一种模型验证和评估过程,通常用于模拟样本外测试、调整模型超参数以及测试机器学习模型的有效性。前缀“k-fold”用于描述我们将数据集划分为多少个子集。例如,在 5 折交叉验证中,数据(通常是随机地)被划分为五个非重叠的子集。这产生了五个折,或组合,用于训练和验证,如图 3.15 所示。

CH03_F15_Kunapuli

图 3.15 中的 k 折交叉验证(此处,k=5)将数据集分成k个不同的训练集和验证集。这模拟了训练过程中的样本外验证。

更具体地说,在 5 折交叉验证中,假设数据集D被划分为五个子集:D[1],D[2],D[3],D[4],和D[5]。这些子集是互斥的,也就是说,数据集中的任何示例只出现在这些子集中的一个中。第三个折将包含训练集 trn[3] = {D[1],D[2],D[4],D[5]}(除了D[3]的所有子集)和验证集 val[3] = {D[3]}(只有D[3])。这个折允许我们训练和验证一个模型。总体而言,5 折交叉验证将允许我们训练和验证五个模型。

在我们的案例中,我们将以略微不同的方式使用交叉验证过程,以确保我们二级估计器的鲁棒性。我们不会使用验证集 val[k]进行评估,而是将它们用于为二级估计器生成元特征。将堆叠与 CV 结合的精确步骤如下:

  1. 将数据随机划分为k个大小相等的子集。

  2. 使用对应第 k 个折的训练数据 trn[k],为每个基础估计器训练k个模型。

  3. 使用对应第 k 个折的验证数据val[k],从每个训练好的基础估计器生成k组元示例。

  4. 在完整数据集上重新训练每个一级基础估计器。

该过程的头三个步骤在图 3.16 中进行了说明。

CH03_F16_Kunapuli

图 3.16 使用 k 折 CV 的堆叠。每个第一层基础估计器的k个版本在每个折的训练集中训练,并为第二层估计器从每个折的验证集中生成k个子元示例。

使用 CV 进行堆叠的一个关键部分是将数据集分割为每个折的训练集和验证集。scikit-learn 包含许多用于执行此操作的实用工具,我们将使用的一个称为 model_selection.StratifiedKFold。StratifiedKFold 类是 model_selection.KFold 类的变体,它返回分层折。这意味着在生成折时,折保留了数据集中的类别分布。

例如,如果我们的数据集中正例与负例的比例是 2:1,StratifiedKFold 将确保这个比例在折中也被保留。最后,应该注意的是,StratifiedKFold 实际上返回的是每个折的训练集和验证集数据点的索引,而不是为每个折创建数据集的多个副本(这在存储方面非常浪费)。下面的列表展示了如何执行带有交叉验证的堆叠。

列表 3.10 使用交叉验证的堆叠

from sklearn.model_selection import StratifiedKFold

def fit_stacking_with_CV(level1_estimators, level2_estimator, 
                         X, y, n_folds=5, use_probabilities=False):
    n_samples = X.shape[0]
    n_estimators = len(level1_estimators)
    X_meta = np.zeros((n_samples, n_estimators))                     ❶

    splitter = StratifiedKFold(n_splits=n_folds, shuffle=True)

    for trn, val in splitter.split(X, y):                            ❷
        level1_estimators = fit(level1_estimators, X[trn, :], y[trn])
        X_meta[val, :] = predict_individual(X[val, :],
                                            estimators=level1_estimators,  
                                            proba=use_probabilities)

    level2_estimator.fit(X_meta, y)                                  ❸

    level1_estimators = fit(level1_estimators, X, y)

    final_model = {'level-1': level1_estimators,                     ❹
                   'level-2': level2_estimator, 
                   'use-proba': use_probabilities}

    return final_model

❶ 初始化元数据矩阵

❷ 训练第一层估计器,然后使用单个预测为第二层估计器生成元特征

❸ 训练第二层元估计器

❹ 将第一层估计器和第二层估计器保存在字典中

我们可以使用此函数使用 CV 训练堆叠模型:

stacking_model = fit_stacking_with_CV(estimators, meta_estimator, 
                                      Xtrn, ytrn, 
                                      n_folds=5, use_probabilities=True)
ypred = predict_stacking(Xtst, stacking_model)
tst_err = 1 - accuracy_score(ytst, ypred)

这会产生以下输出:

0.053097345132743334

使用 CV,堆叠获得了 5.31%的测试误差。和之前一样,我们可以可视化我们的堆叠模型,如图 3.17 所示。我们看到决策边界更平滑,更少锯齿状,整体上更不容易过拟合。

CH03_F17_Kunapuli

图 3.17 使用 CV 进行堆叠对过拟合更稳健。

TIP 在我们的示例场景中,我们有六个基础估计器;如果我们选择使用 5 折交叉验证进行堆叠,我们总共需要训练 6 × 5 = 30 个模型。每个基础估计器都在数据集的(k - 1)/k部分上训练。对于较小的数据集,相应的训练时间增加是适度的,并且通常值得付出代价。对于较大的数据集,这种训练时间可能是显著的。如果基于完整交叉验证的堆叠模型训练成本过高,那么通常保留一个单独的验证集,而不是几个交叉验证子集,就足够了。这个程序被称为混合

现在我们可以通过我们的下一个案例研究:情感分析,看到元学习在大型、真实世界分类任务中的实际应用。

3.4 案例研究:情感分析

情感分析是一种自然语言处理(NLP)任务,广泛应用于识别和分析文本中的观点。在其最简单的形式中,它主要关注识别观点的效果极性,即正面、中性或负面。这种“客户之声”分析是品牌监控、客户服务和市场研究的关键部分。

本案例研究探讨了针对电影评论的监督情感分析任务。我们将使用的数据集是大型电影评论数据集,它最初由斯坦福大学的一个小组收集和整理,用于 NLP 研究,并来自 IMDB.com。¹ 这个大型、公开可用的数据集在过去几年已成为文本挖掘/机器学习的基准,并出现在几个 Kaggle 竞赛中(www.kaggle.com/c/word2vec-nlp-tutorial)。

数据集包含 50,000 条电影评论,分为训练集(25,000)和测试集(25,000)。每条评论还关联一个从 1 到 10 的数值评分。然而,这个数据集只考虑了强烈观点的标签,即对电影强烈正面(7-10)或强烈负面(1-4)的评论。这些标签被压缩为二进制情感极性标签:强烈正面情感(类别 1)和强烈负面情感(类别 0)。以下是从数据集中一个正面评论(标签=1)的例子:

这是一部多么令人愉快的电影。角色不仅活泼,而且充满生命力,反映了家庭中的真实日常生活和冲突。每个角色都给故事带来了独特的个性,观众可以轻易地将其与他们自己家庭或亲密朋友圈子中认识的人联系起来。

下面是一个负面评论(标签=0)的例子:

这是电影史上最糟糕的续集。再一次,它没有任何意义。杀手仍然为了乐趣而杀人。但这次他杀的是那些在拍关于第一部电影的电影的人。这意味着这是史上最愚蠢的电影。不要看这部电影。如果你珍视这部电影中的宝贵一小时,那就不要看它。

注意上面“sense”拼写为“since”的错误。由于这样的拼写、语法和语言上的特殊性,现实世界的文本数据可能非常嘈杂,这使得这些问题对机器学习来说非常具有挑战性。首先,下载并解压这个数据集。

3.4.1 预处理

数据集经过预处理,将每个评论从非结构化的自由文本形式转换为结构化的向量表示。换句话说,预处理的目标是将这个文本文件集合(语料库)转换为词-文档矩阵表示。

这通常涉及以下步骤:去除特殊符号、分词(将其分割成标记,通常是单个单词)、词形还原(识别同一单词的不同用法,例如 organize, organizes, organizing)和计数向量化(计算每个文档中出现的单词)。最后一步产生语料库的词袋模型(BoW)表示。在我们的情况下,数据集的每一行(示例)将是一个评论,每一列(特征)将是一个单词。

图 3.18 中的示例说明了当句子“this is a terrible terrible movie”转换为包含单词{this, is, a, brilliant, terrible, movie}的词汇表时的 BoW 表示。

由于单词“brilliant”在评论中未出现,其计数为 0,而大多数其他条目为 1,对应于它们在评论中只出现一次的事实。这位评论者显然认为这部电影非常糟糕——在我们的计数特征中,对于“terrible”特征的条目是 2。

CH03_F18_Kunapuli

图 3.18 文本被转换为词-文档矩阵,其中每一行是一个示例(对应单个评论),每一列是一个特征(对应评论中的单词)。条目是单词计数,使得每个示例成为一个计数向量。去除停用词可以改善表示,并且通常也会提高性能。

幸运的是,这个数据集已经通过计数向量化进行了预处理。这些预处理过的词-文档计数特征,即我们的数据集,可以在/train/labeledBow.feat 和/test/labeledBow.feat 中找到。训练集和测试集的大小都是 25,000 × 89,527。因此,大约有 90,000 个特征(即单词),这意味着整个评论集使用了大约 90,000 个独特的单词。我们进一步通过以下小节中讨论的两个附加步骤对数据进行预处理。

停用词去除

此步骤旨在移除诸如“the”、“is”、“a”和“an”等常见词汇。传统上,停用词去除可以降低数据的维度(使处理更快),并且可以提高分类性能。这是因为像“the”这样的词通常对信息检索和文本挖掘任务并不真正具有信息性。

警告:在处理某些停用词,如“not”时,应谨慎,因为这个常见词会显著影响潜在的语义和情感。例如,如果我们不考虑否定并应用停用词去除到句子“not a good movie”上,我们得到“good movie”,这完全改变了情感。在这里,我们不选择性地考虑这样的停用词,而是依靠其他表达性强的单词,如“awful”、“brilliant”和“mediocre”,来捕捉情感。然而,通过基于对词汇以及剪枝(或甚至增强)如何影响您的任务的理解进行仔细的特征工程,可以在您自己的数据集上提高性能。

自然语言处理工具包(NLTK)是一个强大的 Python 包,它提供了许多 NLP 工具。在列表 3.11 中,我们使用 NLTK 的标准停用词删除工具。IMDB 数据集的整个词汇表都可在文件 imdb.vocab 中找到,按频率排序,从最常见到最少见。

我们可以直接应用停用词删除来识别我们将保留哪些单词。此外,我们只保留最常见的 5000 个单词,以便我们的运行时间更加可控。

列表 3.11 从词汇表中删除停用词

import nltk
import numpy as np

def prune_vocabulary(data_path, max_features=5000):
    with open('{0}/imdb.vocab'.format(data_path), 'r', encoding='utf8') \
        as vocab_file:
        vocabulary = vocab_file.read().splitlines()    ❶

    nltk.download('stopwords') 

    stopwords = set(
        nltk.corpus.stopwords.words("english"))        ❷

    to_keep = [True if word not in stopwords           ❸
                    else False for word in vocabulary]
    feature_ind = np.where(to_keep)[0]

    return feature_ind[:max_features]                  ❹

❶ 加载词汇文件

❷ 将停用词列表转换为集合以加快处理速度

❸ 从词汇表中删除停用词

❹ 保留前 5000 个单词

TF-IDF 转换

我们的第二个预处理步骤将计数特征转换为词频-逆文档频率(TF-IDF)特征。TF-IDF 表示一个统计量,它根据每个特征在文档中的出现频率(在我们的情况下,单个评论)以及在整个语料库中的出现频率(在我们的情况下,所有评论)来加权每个文档中的特征。

直观地说,TF-IDF 通过单词在文档中出现的频率来加权单词,同时也调整了它们在整体中出现的频率,并考虑到了某些单词通常比其他单词使用得更频繁的事实。我们可以使用 scikit-learn 的预处理工具箱,通过 TfidfTransformer 将我们的计数特征转换为 TF-IDF 特征。列表 3.12 创建并保存了训练集和测试集,每个集包含 25,000 条评论×5000 个 TF-IDF 特征。

列表 3.12 提取 TF-IDF 特征并保存数据集

import h5py
from sklearn.datasets import load_svmlight_files
from scipy.sparse import csr_matrix as sp
from sklearn.feature_extraction.text import TfidfTransformer

def preprocess_and_save(data_path, feature_ind):
    data_files = ['{0}/{1}/labeledBow.feat'.format(data_path, data_set) 
                  for data_set in ['train', 'test']]                       ❶
    [Xtrn, ytrn, Xtst, ytst] = load_svmlight_files(data_files)
    n_features = len(feature_ind)

    ytrn[ytrn <= 5], ytst[ytst <= 5] = 0, 0                                ❷
    ytrn[ytrn > 5], ytst[ytst > 5] = 1, 1

    tfidf = TfidfTransformer()
    Xtrn = tfidf.fit_transform(Xtrn[:, feature_ind])                       ❸
    Xtst = tfidf.transform(Xtst[:, feature_ind])

    filename = '{0}/imdb-{1}k.h5'.format(data_path, round(n_features/1000))
    with h5py.File(filename, 'w') as db:                                   ❹
        db.create_dataset('Xtrn', data=sp.todense(Xtrn), compression='gzip')
        db.create_dataset('ytrn', data=ytrn, compression='gzip')
        db.create_dataset('Xtst', data=sp.todense(Xtst), compression='gzip')
        db.create_dataset('ytst',  data =ytst, compression='gzip')

❶ 加载训练和测试数据

❷ 将情感转换为二进制标签

❸ 将计数特征转换为 TF-IDF 特征

❹ 以 HDF5 二进制数据格式保存预处理后的数据集

3.4.2 降维

我们继续使用降维处理数据,其目的是更紧凑地表示数据。应用降维的主要目的是避免“维度诅咒”,即随着数据维度的增加,算法性能会下降。

我们采用流行的降维方法主成分分析(PCA),其目的是以尽可能保留尽可能多的可变性(使用标准差或方差来衡量)的方式压缩和嵌入数据到低维特征空间中。这确保了我们能够在不损失太多信息的情况下提取低维表示。

此数据集包含数千个示例以及特征,这意味着对整个数据集应用 PCA 可能会非常计算密集且非常慢。为了避免将整个数据集加载到内存中并更有效地处理数据,我们执行增量 PCA(IPCA)。

IPCA 将数据集分解成可以轻松加载到内存中的块。然而,请注意,尽管这种分块大大减少了加载到内存中的样本(行)数量,但它仍然为每一行加载了所有特征(列)。

scikit-learn 提供了 sklearn.decomposition.IncrementalPCA 类,它具有更高的内存效率。以下列表执行 PCA 以将数据的维度降低到 500 维。

列表 3.13 使用 IPCA 进行降维

from sklearn.decomposition import IncrementalPCA

def transform_sentiment_data(data_path, n_features=5000, n_components=500):
    db = h5py.File('{0}/imdb-{1}k.h5'.format(                              ❶
             data_path, round(n_features/1000)), 'r')

    pca = IncrementalPCA(n_components=n_components)
    chunk_size = 1000
    n_samples = db['Xtrn'].shape[0]                                        ❷
    for i in range(0, n_samples // chunk_size):
        pca.partial_fit(db['Xtrn'][i*chunk_size:(i+1) * chunk_size])

    Xtrn = pca.transform(db['Xtrn'])                                       ❸
    Xtst = pca.transform(db['Xtst'])

    with h5py.File('{0}/imdb-{1}k-pca{2}.h5'.format(data_path,
             round(n_features/1000), n_components), 'w') as db2:
        db2.create_dataset('Xtrn', data=Xtrn, compression='gzip')
        db2.create_dataset('ytrn', data=db['ytrn'], compression='gzip')
        db2.create_dataset('Xtst', data=Xtst, compression='gzip')
        db2.create_dataset('ytst', data=db['ytst'],
                           compression='gzip')                             ❹

❶ 加载预处理后的训练和测试数据

❷ 将 IPCA 应用到可管理的数据块中

❸ 降低训练和测试示例的维度

❹ 将预处理后的数据集保存为 HDF5 二进制数据格式

注意,IncrementalPCA 仅使用训练集进行拟合。回想一下,测试数据必须始终保留,并且只能用于提供我们管道如何泛化到未来未见数据的准确估计。这意味着我们无法在预处理或训练的任何部分使用测试数据,而只能用于评估。

3.4.3 混合分类器

我们现在的目标是使用元学习训练一个异构集成。具体来说,我们将通过混合几个基础估计器来构建集成。回想一下,混合是堆叠的一种变体,其中我们不是使用交叉验证(CV),而是使用单个验证集。

首先,我们使用以下函数加载数据:

def load_sentiment_data(data_path,n_features=5000, n_components=1000):

    with h5py.File('{0}/imdb-{1}k-pca{2}.h5'.format(data_path,
                 round(n_features/1000), n_components), 'r') as db:
        Xtrn = np.array(db.get('Xtrn'))
        ytrn = np.array(db.get('ytrn'))
        Xtst = np.array(db.get('Xtst'))
        ytst = np.array(db.get('ytst'))

    return Xtrn, ytrn, Xtst, ytst

接下来,我们使用五个基础估计器:具有 100 个随机决策树的 RandomForestClassifier,具有 100 个极端随机树的 ExtraTreesClassifier,逻辑回归,伯努利朴素贝叶斯(BernoulliNB),以及使用随机梯度下降(SGDClassifier)训练的线性 SVM:

from sklearn.ensemble import RandomForestClassifier, ExtraTreesClassifier
from sklearn.linear_model import LogisticRegression, SGDClassifier
from sklearn.naive_bayes import BernoulliNB

estimators = [('rf', RandomForestClassifier(n_estimators=100, n_jobs=-1)),
              ('xt', ExtraTreesClassifier(n_estimators=100, n_jobs=-1)),
              ('lr', LogisticRegression(C=0.01, solver='lbfgs')),
              ('bnb', BernoulliNB()),
              ('svm', SGDClassifier(loss='hinge', penalty='l2', alpha=0.01,
                                    n_jobs=-1, max_iter=10, tol=None))]

伯努利朴素贝叶斯分类器学习线性模型,但特别适用于来自文本挖掘任务(如我们的任务)的基于计数的文本数据。逻辑回归和 SGDClassifier 的 SVM 都学习线性模型。随机森林和 Extra Trees 是两种同质集成,它们使用决策树作为基础估计器产生高度非线性分类器。这是一个多样化的基础估计器集合,包含线性和非线性分类器的良好混合。

为了将这些基础估计器混合成一个具有元学习的异构集成,我们使用以下程序:

  1. 将训练数据分为一个包含 80% 数据的训练集(Xtrn, ytrn)和一个包含剩余 20% 数据的验证集(Xval, yval)。

  2. 在训练集(Xtrn, ytrn)上训练每个一级估计器。

  3. 使用训练估计器通过 Xval 生成元特征 Xmeta。

  4. 使用元特征增强验证数据:[Xval, Xmeta];这个增强的验证集将包含 500 个原始特征 + 5 个元特征。

  5. 使用增强的验证集([Xval, Xmeta], yval)训练二级估计器。

我们通过元学习组合程序的关键是元特征增强:我们使用基础估计器产生的元特征增强验证集。

这留下了最后一个决定:选择第二级估计器。之前,我们使用了简单的线性分类器。对于这个分类任务,我们使用神经网络。

神经网络和深度学习

神经网络是机器学习算法中最古老的之一。由于在许多应用中取得了广泛的成功,人们对神经网络,尤其是深度神经网络,的兴趣显著复苏。

为了快速回顾神经网络和深度学习,请参阅 Oliver Dürr、Beate Sick 和 Elvis Murina(Manning, 2020)所著的《Python 概率深度学习》、《Keras》和《TensorFlow Probability》的第二章。

我们将使用浅层神经网络作为我们的第二级估计器。这将产生一个高度非线性的元估计器,可以结合第一级分类器的预测:

from sklearn.neural_network import MLPClassifier
meta_estimator = MLPClassifier(hidden_layer_sizes=(128, 64, 32),
                               alpha=0.001)

下面的列表实现了我们的策略。

列表 3.14 使用验证集混合模型

from sklearn.model_selection import train_test_split

def blend_models(level1_estimators, level2_estimator, 
                 X, y , use_probabilities=False):    
    Xtrn, Xval, ytrn, yval = train_test_split(X, y, 
                                 test_size=0.2)     ❶

    n_estimators = len(level1_estimators)
    n_samples = len(yval)
    Xmeta = np.zeros((n_samples, n_estimators))
    for i, (model, estimator) in 
        enumerate(level1_estimators):               ❷
        estimator.fit(Xtrn, ytrn)
        Xmeta[:, i] = estimator.predict(Xval)

    Xmeta = np.hstack([Xval, Xmeta])                ❸

    level2_estimator.fit(Xmeta, yval)               ❹

    final_model = {'level-1': level1_estimators, 
                   'level-2': level2_estimator, 
                   'use-proba': use_probabilities}

    return final_model

❶ 分割为训练集和验证集

❷ 在训练数据上初始化和拟合基础估计器

❸ 使用新生成的元特征增强验证集

❹ 符合第二级元估计器的级别

我们现在可以在训练数据上拟合一个异构集成:

stacked_model = blend_models(estimators, meta_estimator, Xtrn, ytrn)

然后,我们在训练数据和测试数据上评估它,以计算训练和测试错误。首先,我们使用以下方法计算训练错误

ypred = predict_stacking(Xtrn, stacked_model)
trn_err = (1 - accuracy_score(ytrn, ypred)) * 100
print(trn_err)

这给我们带来了 7.84%的训练错误:

7.8359999999999985

接下来,我们使用以下方法计算测试错误

ypred = predict_stacking(Xtst, stacked_model)
tst_err = (1 - accuracy_score(ytst, ypred)) * 100
print(tst_err)

这给我们带来了 17.2%的测试错误:

17.196

我们实际上做得怎么样?我们的集成过程是否有所帮助?为了回答这些问题,我们将集成性能与集成中每个基础估计器的性能进行比较。

图 3.19 显示了各个基础估计器的训练和测试错误,以及堆叠/混合集成。一些个别分类器达到了 0%的训练错误,这意味着它们很可能是过度拟合了训练数据。这影响了它们的性能,正如测试错误所证明的那样。

CH03_F19_Kunapuli

图 3.19 比较了每个个别基础分类器与元分类器集成的性能。堆叠/混合通过集成多样化的基础分类器提高了分类性能。

总体而言,堆叠/混合这些异构模型产生了 17.2%的测试错误,这比所有其他模型都要好。特别是,让我们将这个结果与测试错误为 18%的逻辑回归进行比较。回想一下,测试集包含 25,000 个示例,这意味着我们的堆叠模型正确分类(大约)另外 200 个示例!

总体而言,异构集成的性能优于许多为其做出贡献的基础估计器。这是异构集成如何提高底层个别基础估计器整体性能的一个例子。

TIP 记住,任何线性或非线性分类器都可以用作元估计器。常见的选择包括决策树、核支持向量机(SVMs),甚至是其他集成方法!

摘要

  • 异构集成方法通过异构性促进集成多样性;也就是说,它们使用不同的基础学习算法来训练基础估计器。

  • 加权方法为每个基础估计器的预测分配一个与其性能相对应的权重;更好的基础估计器被分配更高的权重,对最终预测的影响更大。

  • 加权方法使用预定义的组合函数来组合单个基础估计器的加权预测。线性组合函数(例如,加权求和)通常有效且易于解释。也可以使用非线性组合函数,尽管增加的复杂性可能导致过拟合。

  • 元学习方法从数据中学习一个组合函数,与加权方法不同,后者我们必须自己想出一个。

  • 元学习方法创建了多个估计器层。最常用的元学习方法是堆叠(stacking),这个名字来源于它实际上是在一种金字塔式的学习方案中堆叠学习算法。

  • 简单的堆叠创建了两个估计器层。基础估计器在第一层进行训练,它们的输出用于训练第二层估计器,称为元估计器。更复杂的堆叠模型,具有更多估计器层,也是可能的。

  • 堆叠往往会导致过拟合,尤其是在存在噪声数据的情况下。为了避免过拟合,堆叠与交叉验证(CV)结合使用,以确保不同的基础估计器看到数据集的不同子集,从而增加集成多样性。

  • 虽然带有交叉验证(CV)的堆叠可以减少过拟合,但它也可能计算密集,导致训练时间过长。为了在防止过拟合的同时加快训练速度,可以使用单个验证集。这个过程被称为混合。

  • 任何机器学习算法都可以用作堆叠中的元估计器。逻辑回归是最常见的,它导致线性模型。显然,非线性模型具有更大的代表性能力,但它们也面临着更大的过拟合风险。

  • 加权和元学习方法都可以直接使用基础估计器的预测或预测概率。后者通常导致更平滑、更细腻的模型。


^(1.)Andrew L. Maas, Raymond E. Daly, Peter T. Pham, Dan Huang, Andrew Y. Ng, 和 Christopher Potts, “用于情感分析的学习词向量,”2011 年,mng.bz/nJRe

4 序列集成:自适应提升

本章涵盖

  • 训练弱学习者的序列集成

  • 实现并理解 AdaBoost 的工作原理

  • 实际应用中的 AdaBoost

  • 实现并理解 LogitBoost 的工作原理

我们迄今为止看到的集成策略都是并行集成。这包括同质集成,如 Bagging 和随机森林(使用相同的基学习算法来训练基估计器),以及异质集成方法,如 Stacking(使用不同的基学习算法来训练基估计器)。

现在,我们将探索一种新的集成方法族:序列集成。与利用每个基础估计器独立性的并行集成不同,序列集成利用基础估计器之间的依赖性。更具体地说,在学习的阶段,序列集成以这种方式训练新的基础估计器,即最小化前一步训练的基础估计器所犯的错误。

我们将要研究的第一个序列集成方法是提升。提升的目标是结合弱学习器,或简单的基估计器。换句话说,提升实际上旨在提升一组弱学习器的性能。

这与诸如 Bagging 之类的算法形成对比,这些算法结合了复杂的基估计器,也称为强学习器。提升通常指的是 AdaBoost,或自适应提升。这种方法由 Freund 和 Schapire 于 1995 年提出,¹,他们最终因其卓越的理论计算机科学论文而获得了声望极高的哥德尔奖。

自 1995 年以来,提升(Boosting)已成为机器学习的一个核心方法。提升方法实现起来非常简单,计算效率高,并且可以与多种基础学习算法结合使用。在 2010 年代中期深度学习重新兴起之前,提升方法被广泛应用于计算机视觉任务,如目标分类,以及自然语言处理任务,如文本过滤。

在本章的大部分内容中,我们关注 AdaBoost,这是一种流行的提升算法,也是序列集成方法一般框架的很好的说明。通过改变这个框架的某些方面,如损失函数,可以推导出其他提升算法。这些变体通常不在包中提供,必须实现。我们还实现了一个这样的变体:LogitBoost。

4.1 弱学习者的序列集成

并行和序列集成之间有两个关键区别:

  • 在并行集成中,基估计器通常可以独立训练,而在序列集成中,当前迭代的基估计器依赖于前一个迭代的基估计器。这如图 4.1 所示,其中(在迭代t)基估计器M[t-1]的行为影响了样本 S[t],以及下一个模型M[t]。

  • 并行集成中的基估计器通常是强学习器,而在顺序集成中,它们是弱学习器。顺序集成旨在将多个弱学习器组合成一个强学习器。

CH04_F01_Kunapuli

图 4.1 并行和顺序集成的区别:(1)并行集成中的基估计器是相互独立训练的,而在顺序集成中,它们被训练以改进前一个基估计器的预测;(2)顺序集成通常使用弱学习器作为基估计器。

直观地,我们可以将强学习器视为专业人士:高度自信且独立,对自己的答案确信无疑。另一方面,弱学习器则像业余爱好者:不太自信,对自己的答案不确定。我们如何能让一群不太自信的业余爱好者团结起来呢?当然是通过提升他们。在我们具体了解之前,先让我们来描述一下弱学习器的特点。

弱学习器

虽然学习器强度的精确定义根植于机器学习理论,但就我们的目的而言,强学习器是一个好的模型(或估计器)。相比之下,弱学习器是一个非常简单的模型,表现并不好。弱学习器(对于二元分类)的唯一要求是它必须比随机猜测表现得更好。换句话说,它的准确率只需要略高于 50%。决策树通常用作顺序集成的基估计器。提升算法通常使用决策桩,或深度为 1 的决策树(见图 4.2)。

CH04_F02_Kunapuli

图 4.2 决策桩(深度为 1 的树,左侧)在顺序集成方法,如提升法中常用作弱学习器。随着树深度的增加,决策桩会成长为一个决策树,成为一个更强的分类器,其性能也会提高。然而,不能随意增加分类器的强度,因为它们在训练过程中会开始过拟合,这会降低它们部署时的预测性能。

顺序集成方法,如提升法,旨在将多个弱学习器组合成一个单一强学习器。这些方法实际上是将弱学习器“提升”为强学习器。

提示:弱学习器是一个简单且易于训练的分类器,但通常表现远不如强学习器(尽管比随机猜测要好)。顺序集成通常对底层基学习算法不敏感,这意味着你可以使用任何分类算法作为弱学习器。在实践中,弱学习器,如浅层决策树和浅层神经网络,很常见。

回想一下第一章和第二章中提到的兰迪·福雷斯特博士的实习生团队。在一个由知识渊博的医疗人员组成的并行团队中,每个实习生都可以被视为一个强大的学习者。为了理解顺序集成团队的哲学有多么不同,我们转向弗里德曼和沙皮雷,他们把提升描述为“一个由傻瓜组成的委员会,但不知何故能做出高度合理的决策。”²

这就像是兰迪·福雷斯特博士让他的实习生离开,决定采用众包医疗诊断的策略。虽然这当然是一种(且不可靠的)诊断患者的策略,但“从一群傻瓜那里汲取智慧”³在机器学习中表现得出奇地好。这是弱学习者顺序集成的潜在动机。

4.2 AdaBoost:自适应提升

在本节中,我们首先介绍一个重要的顺序集成:AdaBoost。AdaBoost 易于实现,使用起来计算效率高。只要 AdaBoost 中每个弱学习者的性能略好于随机猜测,最终模型就会收敛到一个强学习者。然而,除了应用之外,理解 AdaBoost 的工作原理也是理解我们将在下一章中探讨的两个最先进的顺序集成方法——梯度提升和牛顿提升——的关键。

提升的简要历史

提升的起源在于计算学习理论,当学习理论家莱斯利·瓦利亚恩特和迈克尔·基恩斯在 1988 年提出了以下问题:能否将弱学习者提升为强学习者?两年后,罗布·沙皮雷在他的现在已成为里程碑式的论文《弱学习能力的强度》中肯定地回答了这个问题。

最早的提升算法受到限制,因为弱学习者没有适应来纠正先前迭代中训练的弱学习者犯的错误。弗里德曼和

沙皮雷的 AdaBoost,或称自适应提升算法,于 1994 年提出,最终解决了这些限制。他们的原始算法至今仍在使用,并在多个应用领域得到广泛应用,包括文本挖掘、计算机视觉和医学信息学。

4.2.1 直觉:使用加权示例进行学习

AdaBoost 是一种自适应算法:在每次迭代中,它训练一个新的基估计器来纠正前一个基估计器犯的错误。因此,它需要某种方式来确保基学习算法优先考虑被错误分类的训练示例。AdaBoost 通过维持单个训练示例的权重来实现这一点。直观地说,权重反映了训练示例的相对重要性。被错误分类的示例具有更高的权重,而正确分类的示例具有较低的权重。

当我们按顺序训练下一个基估计器时,权重将允许学习算法优先考虑(并希望修复)前一次迭代的错误。这是 AdaBoost 的自适应组件,最终导致一个强大的集成。

注意:所有机器学习框架都使用 损失函数(在某些情况下,也使用 似然函数)来描述性能,而训练本质上是根据损失函数找到最佳拟合模型的过程。损失函数可以平等地对待所有训练示例(通过将它们都赋予相同的权重)或关注一些特定的示例(通过将特定示例赋予更高的权重以反映其增加的优先级)。当实现使用训练示例权重的集成方法时,必须注意确保基学习算法实际上可以使用这些权重。大多数加权分类算法使用修改后的损失函数来优先考虑具有更高权重的示例的正确分类。

让我们可视化提升的前几次迭代。每次迭代执行相同的步骤:

  1. 训练一个弱学习器(这里是一个决策树),以确保具有更高权重的训练示例被优先考虑。

  2. 更新训练示例的权重,使得错误分类的示例被赋予更高的权重;错误越严重,权重越高。

初始时(迭代 t - 1),所有示例都使用 相等权重 初始化。第 1 次迭代中训练的决策树(如图 4.3 所示)是一个简单、轴平行的分类器,错误率为 15%。被错误分类的点比正确分类的点画得更大。

下一个要训练的决策树(在第 2 次迭代中,如图 4.4 所示)必须正确分类前一个决策树(第 1 次迭代中)错误分类的示例。因此,错误被赋予更高的权重,这使得决策树算法能够在学习过程中优先考虑它们。

CH04_F03_Kunapuli

图 4.3 初始时(第 1 次迭代),所有训练示例都赋予相等的权重(因此左图中以相同大小绘制)。在此数据集上学习到的决策树显示在右侧。与错误分类的示例相比,正确分类的示例用较小的标记绘制,而错误分类的示例用较大的标记绘制。

CH04_F04_Kunapuli

图 4.4 在第 2 次迭代的开始时,第 1 次迭代中错误分类的训练示例(如图 4.3 右侧所示,用较大的标记表示)被赋予更高的权重。这在上图中可视化,其中每个示例的大小与其权重成比例。由于加权示例具有更高的优先级,序列中的新决策树(右侧)确保这些现在被正确分类。观察右侧的新决策树正确分类了左侧大多数错误分类的示例(用较大的标记表示)。

第二次迭代中训练的决策树确实正确分类了具有更高权重的训练示例,尽管它也有自己的错误。在迭代 3 中,可以训练第三个决策树,旨在纠正这些错误(见图 4.5)。

CH04_F05_Kunapuli

图 4.5 在迭代 3 的开始时,迭代 2 中错误分类的训练示例(在图 4.4 的右侧以较大的标记显示)被分配了更高的权重。请注意,错误分类的点也有不同的权重。在这个迭代中训练的新决策树(右侧)确保这些现在被正确分类。

经过三次迭代后,我们可以将三个单独的弱学习器组合成一个强学习器,如图 4.6 所示。以下是一些需要注意的有用点:

  • 观察在三个迭代中训练的弱估计器。它们彼此不同,并以多种不同的方式对问题进行分类。回想一下,在每次迭代中,基估计器都在相同的训练集上训练,但具有不同的权重。重新加权允许 AdaBoost 在每次迭代中训练不同的基估计器,通常与之前迭代中训练的估计器不同。因此,自适应重新加权或自适应更新,促进了集成多样性。

  • 结果的弱(和线性)决策树集成更强(和非线性)。更确切地说,每个基估计器的训练错误率分别为 15%、20%和 25%,而它们的集成错误率为 9%。

CH04_F06_Kunapuli

图 4.6 前几个图中显示的三个弱决策树可以通过提升变成一个更强的集成。

如前所述,这个提升算法得名于提升弱学习者的性能,使其成为一个更强大、更复杂的集成,即强学习器。

4.2.2 实现 AdaBoost

首先,我们将实现自己的 AdaBoost 版本。在这个过程中,我们将牢记 AdaBoost 的以下关键特性:

  • AdaBoost 使用决策树作为基估计器,即使有大量特征,也可以非常快速地进行训练。决策树是弱学习器。这与使用更深决策树的 bagging 方法形成对比,后者是强学习器

  • AdaBoost 跟踪各个训练示例的权重。这允许 AdaBoost 通过重新加权训练示例来确保集成多样性。我们在前一小节的可视化中看到了重新加权如何帮助 AdaBoost 学习不同的基估计器。这与使用训练示例重采样的 bagging 和随机森林形成对比。

  • AdaBoost 跟踪各个基估计器的权重。这类似于组合方法,它们对每个分类器进行不同的加权。

AdaBoost 的实现相当直接。第t次迭代的算法基本框架可以描述为以下步骤:

  1. 使用加权训练示例(x[i],y[i],D[i])训练弱学习器ht。

  2. 计算弱学习器ht 的训练错误ϵ[t]。

  3. 计算依赖于ϵ[t]的弱学习器α[t]的权重。

  4. 按照以下方式更新训练示例的权重:

    • 通过D[i]e^(α[t])增加被错误分类的示例的权重。

    • 通过D[i]/e^(α[i])减少被错误分类的示例的权重。

T次迭代结束时,我们有了弱学习器h[t]以及相应的弱学习器权重α[t]。经过t次迭代的整体分类器只是一个加权集成:

CH04_F06_Kunapuli-eqs-2x

这种形式是基础估计器的加权线性组合,类似于我们之前看到的并行集成中使用的线性组合,例如 bagging、组合方法或 stacking。与这些方法的主要区别在于,AdaBoost 使用的基础估计器是弱学习器。现在,我们需要回答两个关键问题:

  • 我们如何更新训练示例的权重,D[i]?

  • 我们如何计算每个基础估计器的权重,α[t]?

AdaBoost 使用与我们在第三章中看到的组合方法相同的直觉。回想一下,权重是计算来反映基础估计器性能的:表现更好的基础估计器(例如,准确率)应该比表现较差的估计器具有更高的权重。

弱学习器权重

在每个迭代t中,我们训练一个基础估计器ht。每个基础估计器(也是弱学习器)都有一个相应的权重α[t],它取决于其训练错误。ht 的训练错误ϵ[t]是其性能的一个简单直接的度量。AdaBoost 按照以下方式计算估计器ht 的权重:

CH04_F06_Kunapuli-eqs-3x

为什么是这个特定的公式?让我们通过可视化α[t]如何随着错误ϵ[t]的增加而变化来观察α[t]与错误ϵ[t]之间的关系(图 4.7)。回想一下我们的直觉:表现更好的基础估计器(那些错误率更低的)必须被赋予更高的权重,以便它们对集成预测的贡献更高。

相反,最弱的学习器表现最差。有时,它们几乎与随机猜测一样好。换句话说,在二元分类问题中,最弱的学习器仅略好于掷硬币来决定答案。

CH04_F07_Kunapuli

图 4.7 AdaBoost 将更强的学习器(具有更低的训练错误)赋予更高的权重,并将较弱的学习器(具有更高的训练错误)赋予较低的权重。

具体来说,最弱的学习器的错误率仅略好于 0.5(或 50%)。这些最弱的学习器具有最低的权重,α[t] ≈ 0. 最强的学习器达到的训练错误接近 0.0(或 0%)。这些学习器具有最高的权重。

训练示例权重

基础估计器权重(α[t])也可以用来更新每个训练样本的权重。AdaBoost 按照以下方式更新样本权重:

CH04_F07_Kunapuli-eqs-4x

当样本被正确分类时,新的权重会减少e^(α[t]) : D[i]^(t+1) = D[i](*t*)/*e*(α[t])。更强的基估计器会减少更多的权重,因为它们对自己的正确分类更有信心。同样,当样本被错误分类时,新的权重会增加e^(α[t]) : D[i]^(t+1) = D[i]^(t) ⋅ e^(α[t])。

以这种方式,AdaBoost 确保错误分类的训练样本获得更高的权重,这将使它们在下一个迭代t+1 中更好地被分类。例如,假设我们有两个训练样本x[1]和x[2],它们的权重都是D^t[1]= D^t[2] = 0.75。当前的弱学习器h[t]的权重是α[t] = 1.5。假设x[1]被h[t]正确分类;因此,其权重应减少一个因子e^(α[t])。下一个迭代t+1 的新权重将是D[i]^(t+1) = D[1]/e^(α[t]) = 0.75/e^(1.5) - 0.17。

相反,如果x[1]被h[t]错误分类,其权重应增加一个因子e^(α[t])。新的权重将是 D[i]^(t+1) = D[2] ⋅ e^(α[t]) = 0.75 ⋅ e^(1.5) = 3.36。这如图 4.8 所示。

CH04_F08_Kunapuli

图 4.8 在迭代t中,两个训练样本x[1]和x[2]具有相同的权重。x[1]被正确分类,而x[2]被当前的基础估计器h[t]错误分类。由于下一个迭代的目标是学习一个分类器h[t+1],它可以纠正h[t]的错误,AdaBoost 增加了错误分类样本x[2]的权重,同时减少了正确分类样本x[1]的权重。这允许基础学习算法在迭代t+1 中优先考虑x[2]。

AdaBoost 训练

AdaBoost 算法易于实现。以下列表展示了提升的训练过程。

列表 4.1 使用 AdaBoost 训练弱学习器集成

from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score
import numpy as np

def fit_boosting(X, y, n_estimators=10):
    n_samples, n_features = X.shape
    D = np.ones((n_samples, ))                         ❶
    estimators = []                                    ❷

    for t in range(n_estimators):
        D = D / np.sum(D)                              ❸

        h = DecisionTreeClassifier(max_depth=1)  
        h.fit(X, y, sample_weight=D)                   ❹

        ypred = h.predict(X)   
        e = 1 - accuracy_score(y, ypred,               ❺
                               sample_weight=D)  
        a = 0.5 * np.log((1 - e) / e)               

        m = (y == ypred) * 1 + (y != ypred) * -1       ❻
        D *= np.exp(-a * m)

        estimators.append((a, h))                      ❼

    return estimators

❶ 非负权重,初始化为 1

❷ 初始化一个空的集成

❸ 将权重归一化,使它们的总和为 1

❹ 使用加权样本训练弱学习器(h[t])

❺ 计算训练误差(ε[t])和弱学习器的权重(α[t])

❻ 更新样本权重:错误分类的样本增加,正确分类的样本减少

❷ 保存弱学习器和其权重

一旦我们有一个训练好的集成,我们就可以用它来进行预测。列表 4.2 展示了如何使用提升集成来预测新的测试样本。观察发现,这与使用其他加权集成方法(如堆叠)进行预测是相同的。

列表 4.2 使用 AdaBoost 进行预测

def predict_boosting(X, estimators):
    pred = np.zeros((X.shape[0], ))        ❶

    for a, h in estimators:
        pred += a * h.predict(X)           ❷

    y = np.sign(pred)                      ❸

    return y

❶ 将所有预测初始化为 0

❷ 对每个样本进行加权预测

❸ 将加权预测转换为-1/1 标签

我们可以使用这些函数来拟合和预测数据集:

from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split

X, y = make_moons(                                                         ❶
           n_samples=200, noise=0.1, random_state=13)
y  = (2 * y) - 1                                                           ❷
Xtrn, Xtst, ytrn, ytst = train_test_split(X, y,                            ❸
                                          test_size=0.25, random_state=13)

estimators = fit_boosting(Xtrn, ytrn)                                      ❹
ypred = predict_boosting(Xtst, estimators)                                 ❺

❶ 生成一个包含 200 个点的合成分类数据集

❷ 将 0/1 标签转换为-1/1 标签

❸ 将数据集分为训练集和测试集

❹ 使用列表 4.1 训练 AdaBoost 模型

❺ 使用列表 4.2 中的 AdaBoost 进行预测

我们做得怎么样?我们可以计算我们模型的总体测试集准确率:

from sklearn.metrics import accuracy_score
tst_err = 1 - accuracy_score(ytst, ypred)
print(tst_err)

这会产生以下输出:

0.020000000000000018

我们使用 10 个弱树桩通过实现学习得到的集成测试错误率为 2%。

二元分类的训练标签:0/1 还是-1/1?

我们实现的提升算法要求负样本和正样本分别标记为-1 和 1。函数 make_moons 创建带有负样本标记为 0 和正样本标记为 1 的标签y。我们手动将它们从 0 和 1 转换为-1 和 1,即y[converted] = 2 ⋅ y[original] - 1。

抽象地说,二元分类任务中每个类的标签可以是任何我们喜欢的,只要标签有助于清楚地区分两个类别。从数学上讲,这个选择取决于损失函数。例如,如果使用交叉熵损失,则类别需要是 0 和 1,以便损失函数能够正确工作。相比之下,如果使用 SVM 中的 hinge 损失,则类别需要是-1 和 1。

AdaBoost 使用指数损失(更多内容请见第 4.5 节),并且要求类标签为-1 和 1,以便后续训练在数学上合理且收敛。

幸运的是,当我们使用 scikit-learn 等大多数机器学习包时,我们不必担心这个问题,因为它们会自动预处理各种训练标签,以满足底层训练算法的需求。

我们在图 4.9 中可视化 AdaBoost 的性能,随着基学习器的数量增加。随着我们添加越来越多的弱学习器,整体集成不断增强,成为一个更强大、更复杂、非线性更强的分类器。

CH04_F09_Kunapuli

图 4.9 随着弱学习器的数量增加,整体分类器被提升为强模型,该模型变得越来越非线性,能够拟合(并可能过度拟合)训练数据。

虽然 AdaBoost 通常对过拟合有更强的抵抗力,但像许多其他分类器一样,过度训练提升算法也可能导致过拟合,尤其是在存在噪声的情况下。我们将在第 4.3 节中看到如何处理这种情况。

4.2.3 使用 scikit-learn 的 AdaBoost

现在我们已经理解了 AdaBoost 分类算法的直觉,我们可以看看如何使用 scikit-learn 的 AdaBoostClassifier 包。scikit-learn 的实现提供了额外的功能,包括对多类分类的支持,以及决策树以外的其他基学习算法。

AdaBoostClassifier 包针对二分类和多分类任务接受以下三个重要参数:

  • base_estimator—AdaBoost 用于训练弱学习者的基础学习算法。在我们的实现中,我们使用了决策树桩。然而,也可以使用其他弱学习者,例如浅层决策树、浅层人工神经网络和基于随机梯度下降的分类器。

  • n_estimators—AdaBoost 将按顺序训练的弱学习者的数量。

  • learning_rate—一个额外的参数,它逐步减少每个连续训练的弱学习者在集成中的贡献。

    • 学习率(learning_rate)的较小值会使弱学习者的权重 α[t] 较小。较小的 α[t] 意味着示例权重 D[i] 的变化减小,并且弱学习者更加单一。学习率的较大值则产生相反的效果,并增加弱学习者的多样性。

学习率参数与 n_estimators(本质上,每次迭代训练一个估计器的迭代次数)之间存在自然的相互作用和权衡。增加 n_estimators(即迭代次数)可能导致训练示例权重 D[i] 持续增长。可以通过学习率来控制示例权重的无约束增长。

以下示例展示了 AdaBoostClassifier 在二元分类数据集上的实际应用。首先,我们加载乳腺癌数据并将其分为训练集和测试集:

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
X, y = load_breast_cancer(return_X_y=True)
Xtrn, Xtst, ytrn, ytst = train_test_split(X, y, 
                                          test_size=0.25, random_state=13)

我们将使用深度为 2 的浅层决策树作为训练的基础估计器:

from sklearn.ensemble import AdaBoostClassifier
shallow_tree = DecisionTreeClassifier(max_depth=2)
ensemble = AdaBoostClassifier(base_estimator=shallow_tree, 
                              n_estimators=20, learning_rate=0.75)
ensemble.fit(Xtrn, ytrn)

训练后,我们可以使用增强集成在测试集上进行预测:

ypred = ensemble.predict(Xtst)
err = 1 - accuracy_score(ytst, ypred)
print(err)

AdaBoost 在乳腺癌数据集上实现了 5.59% 的测试错误率:

0.05594405594405594

多分类

scikit-learn 的 AdaBoostClassifier 也支持多分类,其中数据属于两个以上的类别。这是因为 scikit-learn 包含了多分类 AdaBoost 的实现,称为使用多类指数损失的阶跃式添加建模(Stagewise Additive Modeling using Multiclass Exponential loss,或 SAMME)。SAMME 是 Freund 和 Schapire 的自适应提升算法(在第 4.2.2 节中实现)从二类推广到多类的泛化。除了 SAMME 之外,AdaBoostClassifier 还提供了一个名为 SAMME.R 的变体。这两种算法之间的关键区别在于,SAMME.R 可以处理来自基础估计器算法的实值预测(即类概率),而原始的 SAMME 处理离散预测(即类标签)。

这听起来熟悉吗?回想第三章,存在两种类型的组合函数:那些直接使用预测类标签的,以及那些可以使用预测类概率的。这正是 SAMME 和 SAMME.R 之间的区别。

以下示例展示了 AdaBoostClassifier 在名为 iris 的多类分类数据集上的实际应用,其中分类任务是区分三种鸢尾花物种,基于它们花瓣和萼片的尺寸。首先,我们加载鸢尾花数据,并将数据分为训练集和测试集:

from sklearn.datasets import load_iris
from sklearn.utils.multiclass import unique_labels
X, y = load_iris(return_X_y=True)
Xtrn, Xtst, ytrn, ytst = train_test_split(X, y, 
                                          test_size=0.25, random_state=13)

我们检查这个数据集有三个不同的标签,具有唯一的 _labels(y),这产生数组([0, 1, 2]),这意味着这是一个三分类问题。与之前一样,我们可以在这个多类数据集上训练和评估 AdaBoost:

ensemble = AdaBoostClassifier(base_estimator=shallow_tree,
                              n_estimators=20,              
                              learning_rate=0.75, algorithm='SAMME.R')
ensemble.fit(Xtrn, ytrn)
ypred = ensemble.predict(Xtst)
err = 1 - accuracy_score(ytst, ypred)
print(err)

AdaBoost 在三个类别的鸢尾花数据集上实现了 7.89%的测试错误率:

0.07894736842105265

4.3 AdaBoost 的实际应用

在本章中,我们将探讨在使用 AdaBoost 时可能会遇到的一些实际挑战,以及确保我们训练鲁棒模型的策略。AdaBoost 的适应性程序使其容易受到异常值的影响,即极其嘈杂的数据点。在本节中,我们将看到这个问题的例子,以及我们可以采取哪些措施来减轻它。

AdaBoost 的核心是其适应先前弱学习器所犯错误的能力。然而,当存在异常值时,这种适应性也可能是一个缺点。

异常值

异常值是极其嘈杂的数据点,通常是测量或输入错误的结果,在真实数据中普遍存在,程度不同。标准预处理技术,如归一化,通常只是重新缩放数据,并不能去除异常值,这允许它们继续影响算法性能。这可以通过预处理数据来专门检测和去除异常值来解决。

对于某些任务(例如,检测网络网络攻击),我们需要检测和分类(网络攻击)的东西本身就是一个异常值,也称为异常,并且极其罕见。在这种情况下,我们学习任务的目标本身将是异常检测。

AdaBoost 特别容易受到异常值的影响。异常值通常会被弱学习器错误分类。回想一下,AdaBoost 会增加错误分类示例的权重,因此分配给异常值的权重会持续增加。当训练下一个弱学习器时,它会执行以下操作之一:

  • 继续错误地分类异常值,在这种情况下,AdaBoost 将进一步增加其权重,这反过来又导致后续的弱学习器错误分类、失败并继续增加其权重。

  • 正确地分类异常值,在这种情况下,AdaBoost 刚刚过度拟合了数据,如图 4.10 所示。

CH04_F10_Kunapuli

图 4.10 考虑一个包含异常值(圆圈标注,左上角)的数据集。在迭代 1 中,它与所有示例具有相同的权重。随着 AdaBoost 继续依次训练新的弱学习器,其他数据点的权重最终会随着它们被正确分类而降低。异常值的权重持续增加,最终导致过拟合。

异常值迫使 AdaBoost 在训练示例上投入不成比例的努力。换句话说,异常值往往会混淆 AdaBoost,使其变得不那么鲁棒。

4.3.1 学习率

现在,让我们看看如何使用 AdaBoost 训练鲁棒模型。我们可以控制的第一方面是学习率,它调整每个估计器对集成模型的贡献。例如,学习率为 0.75 表示 AdaBoost 将每个基础估计器的整体贡献减少到 0.75 倍。当存在异常值时,高学习率会导致它们的影响成比例地迅速增长,这绝对会损害你模型的性能。因此,减轻异常值影响的一种方法就是降低学习率。

降低学习率会缩小每个基础估计器的贡献,因此控制学习率也被称为收缩,这是一种模型正则化的形式,用于最小化过拟合。具体来说,在迭代t时,集成模型F[t]更新为F[t+1]。

CH04_F10_Kunapuli-eqs-10x

在这里,α[t]是 AdaBoost 计算出的弱学习器h[t]的权重,η是学习率。学习率是一个用户定义的学习参数,其范围在 0 < η ≤ 1 之间。

较慢的学习率意味着构建一个有效的集成模型通常需要更多的迭代(因此,更多的基础估计器)。更多的迭代也意味着更多的计算努力和更长的训练时间。然而,较慢的学习率可能会产生一个鲁棒性更好的模型,并且可能值得付出努力。

选择最佳学习率的一个有效方法是使用验证集或交叉验证(CV)。列表 4.3 使用 10 折交叉验证来识别范围[0.1, 0.2, ..., 1.0]内的最佳学习率。我们可以观察到收缩在乳腺癌数据上的有效性:

from sklearn.datasets import load_breast_cancer
X, y = load_breast_cancer(return_X_y=True)

我们使用分层 k 折交叉验证,就像我们在堆叠中做的那样。回想一下,“分层”意味着折叠是以一种方式创建的,使得类分布在整个折叠中保持不变。这也帮助处理不平衡的数据集,因为分层确保了所有类别的数据都得到代表。

列表 4.3 交叉验证选择最佳学习率

from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import AdaBoostClassifier
from sklearn.metrics import accuracy_score
from sklearn.model_selection import StratifiedKFold
import numpy as np

n_learning_rate_steps, n_folds = 10, 10
learning_rates = np.linspace(0.1, 1.0,                                  ❶
                             num=n_learning_rate_steps) 
splitter = StratifiedKFold(n_splits=n_folds, shuffle=True)
trn_err = np.zeros((n_learning_rate_steps, n_folds))
val_err = np.zeros((n_learning_rate_steps, n_folds))
stump = DecisionTreeClassifier(max_depth=1)                             ❷

for i, rate in enumerate(learning_rates):                               ❸
    for j, (trn, val) \                                                 ❹
        in enumerate(splitter.split(X, y)):    

        model = AdaBoostClassifier(algorithm='SAMME', base_estimator=stump,
                                   n_estimators=10, learning_rate=rate)

        model.fit(X[trn, :], y[trn])                                    ❺

        trn_err[i, j] = 1 - accuracy_score(y[trn],                      ❻
                                           model.predict(X[trn, :]))    ❻
        val_err[i, j] = 1 - accuracy_score(y[val],                      ❻
                                           model.predict(X[val, :]))    ❻

trn_err = np.mean(trn_err, axis=1)                                      ❼
val_err = np.mean(val_err, axis=1)                                      ❼

❶ 设置分层 10 折交叉验证并初始化搜索空间

❷ 使用决策树桩作为弱学习器

❸ 对于所有学习率的选取

❹ 对于训练和验证集

❺ 在这个折叠中拟合训练数据模型

❻ 计算这个折叠的训练和验证误差

❼ 在折叠间平均训练和验证误差

我们在图 4.11 中绘制了此参数搜索的结果,该图显示了随着学习率的增加,训练和验证误差如何变化。基础学习器的数量固定为 10。虽然平均训练误差随着学习率的增加而继续下降,但最佳平均验证误差是在学习率率为 _rate=0.8 时达到的。

CH04_F11_Kunapuli

图 4.11:不同学习率下的平均训练和验证误差。学习率=0.6 的验证误差最低,实际上低于默认的学习率=1.0。

4.3.2 提前停止和剪枝

除了学习率之外,对于实际的提升来说,另一个重要的考虑因素是基学习器的数量,即 n_estimators。尝试构建一个包含大量弱学习器的集成可能很有吸引力,但这并不总是转化为最佳泛化性能。实际上,我们通常可以用比我们想象的更少的基估计器实现大致相同的性能。确定构建有效集成所需的最少基估计器数量被称为提前停止。保持较少的基估计器可以帮助控制过拟合。此外,提前停止还可以减少训练时间,因为我们最终需要训练的基估计器更少。列表 4.4 使用与列表 4.3 中相同的 CV 过程来识别最佳估计器数量。这里的学习率固定为 1.0。

列表 4.4:交叉验证以选择最佳弱学习器数量

n_estimator_steps, n_folds = 5, 10

number_of_stumps = np.arange(5, 50, n_estimator_steps)           ❶
splitter = StratifiedKFold(n_splits=n_folds, shuffle=True)

trn_err = np.zeros((len(number_of_stumps), n_folds))
val_err = np.zeros((len(number_of_stumps), n_folds))

stump = DecisionTreeClassifier(max_depth=1)                      ❷
for i, n_stumps in enumerate(number_of_stumps):                  ❸
    for j, (trn, val) \                                          ❹
        in enumerate(splitter.split(X, y)): 

        model = AdaBoostClassifier(algorithm='SAMME', base_estimator=stump,
                                   n_estimators=n_stumps, learning_rate=1.0)
        model.fit(X[trn, :], y[trn])                             ❺

        trn_err[i, j] = \                                        ❻
            1 - accuracy_score(
                    y[trn], model.predict(X[trn, :]))

        val_err[i, j] = \                                        ❻
            1 - accuracy_score(
                    y[val], model.predict(X[val, :]))

trn_err = np.mean(trn_err, axis=1)
val_err = np.mean(val_err, axis=1)                               ❼

❶ 设置分层 10 折交叉验证并初始化搜索空间

❷ 使用决策树桩作为弱学习器

❸ 对于所有估计器大小

❹ 对于训练和验证集

❺ 将模型拟合到本折叠的训练数据

❻ 计算此折叠的训练和验证误差

❼ 对各折叠的平均误差

搜索最佳估计器数量的结果如图 4.12 所示。平均验证误差表明,使用多达 30 个决策树就足以在这组数据集上实现良好的预测性能。在实践中,一旦验证集的性能达到可接受的水平,我们就可以提前停止训练。

CH04_F12_Kunapuli

图 4.12:不同数量基估计器(在这种情况下为决策树桩)的平均训练和验证误差。n_estimators=20 的验证误差最低。

提前停止也称为预剪枝,因为我们在大规模拟合基估计器之前终止训练,这通常会导致更快的训练时间。如果我们不关心训练时间,但想更谨慎地选择基估计器的数量,我们也可以考虑后剪枝。后剪枝意味着我们训练一个非常大的集成,然后移除最差的基估计器。

对于 AdaBoost,后剪枝会移除所有权重(α[t])低于某个阈值的弱学习器。我们可以在训练了 AdaBoostClassifier 之后,通过 model.estimators_ 和 model.estimator_weights_ 字段访问单个弱学习器及其权重。为了剪枝最不显著的弱学习器的贡献(那些权重低于某个阈值的),我们可以简单地将它们的权重设置为 0:

model.estimator_weights_[model.estimator_weights_ <= threshold] = 0.0

如前所述,交叉验证可以用来选择一个好的阈值。始终记住,AdaBoost 的学习率(learning_rate)和 n_estimators 参数之间通常存在权衡。较低的学习率通常需要更多的迭代(因此,更多的弱学习器),而较高的学习率则需要较少的迭代(和较少的弱学习器)。

为了最有效地进行,应使用网格搜索与交叉验证相结合来识别这些参数的最佳值。案例研究中展示了这一示例,我们将在下一节中讨论。

异常值检测和移除

虽然这里描述的程序在处理噪声数据集时通常有效,但含有大量噪声(即异常值)的训练示例仍然可能引起重大问题。在这种情况下,通常建议预处理数据集以完全删除这些异常值。

4.4 案例研究:手写数字分类

机器学习最早的几个应用之一是手写数字分类。实际上,自 1990 年代初以来,这项任务已经被广泛研究,我们可能会将其视为对象识别的“Hello World!”

这个任务起源于美国邮政服务尝试自动化数字识别,以通过快速识别 ZIP 码来加速邮件处理。从那时起,已经创建了几个不同的手写数据集,并被广泛用于基准测试和评估各种机器学习算法。

在这个案例研究中,我们将使用 scikit-learn 的数字数据集来说明 AdaBoost 的有效性。该数据集包含来自 1,797 张扫描的手写数字图像。

0 到 9。每个数字都与一个唯一的标签相关联,这使得这是一个 10 类分类问题。每个类别大约有 180 个数字。我们可以直接从 scikit-learn 加载数据集:

from sklearn.datasets import load_digits
X, y = load_digits(return_X_y=True)

这些数字本身被表示为 16 x 16 的归一化灰度位图(见图 4.13),当展开时,每个手写数字将形成一个 64 维(64D)向量。训练集包含 1,797 个示例 × 64 个特征。

CH04_F13_Kunapuli

图 4.13 本案例研究中使用的数字数据集快照

4.4.1 使用 t-SNE 进行降维

虽然 AdaBoost 可以有效地处理数字数据集的维度(64 个特征),但我们将(相当激进地)将其维度降低到 2。这样做的主要原因是为了能够可视化数据以及 AdaBoost 学习到的模型。

我们将使用一种称为 t 分布随机邻域嵌入(t-SNE)的非线性降维技术。t-SNE 是数字数据集的一种非常有效的预处理技术,并在二维空间中提取嵌入。

t-SNE

随机邻域嵌入,正如其名所示,使用邻域信息来构建低维嵌入。具体来说,它利用两个示例之间的相似性:x[i]和x[j]。在我们的案例中,x[i]和x[j]是从数据集中提取的两个示例数字,它们是 64D。两个数字之间的相似度可以测量为

CH04_F13_Kunapuli-eqs-11x

其中||x[i] - x[j]||²是x[i]和x[j]之间的平方距离,σ²[i]是一个相似度参数。你可能在其他机器学习的领域中见过这种相似度函数的形式,特别是在支持向量机的上下文中,它被称为径向基函数(RBF)核或高斯核。

x[i]和x[j]之间的相似度可以转换为x[j]是x[i]邻居的概率p[j|i]。这个概率只是一个归一化的相似度度量,其中我们通过数据集x[k]中所有点与x[i]的相似度之和进行归一化:

CH04_F13_Kunapuli-eqs-12x

假设这两个数字的 2D 嵌入由z[i]和z[j]给出。那么,自然地预期两个相似的数字x[i]和x[j]在嵌入到z[i]和z[j]后仍然会是邻居。测量z[j]是z[i]邻居的概率的方法是类似的:

CH04_F13_Kunapuli-eqs-14x

在这里,我们假设 2D(z 空间)指数分布的方差是 1/2。然后,我们可以通过确保 2D 嵌入空间(z 空间)中的概率q[j|i]与 64D 原始数字空间(x 空间)中的p[j|i]良好对齐来识别所有点的嵌入。从数学上讲,这是通过最小化分布q[j|i]和p[j|i]之间的 KL 散度(一个差异或距离的统计度量)来实现的。使用 scikit-learn,嵌入可以非常容易地计算:

from sklearn.manifold import TSNE
Xemb = TSNE(n_components=2, init='pca').fit_transform(X)

图 4.14 显示了当数据集嵌入到 2D 空间时的样子。

CH04_F14_Kunapuli

图 4.14 t-SNE 生成的数字数据集的 2D 嵌入可视化,它能够嵌入并分离数字,有效地将它们聚类

训练-测试分割

和以往一样,保留一部分训练数据用于评估,并量化我们的模型在未来的数据上的预测性能是很重要的。我们将低维数据 Xemb 和标签分为训练集和测试集:

from sklearn.model_selection import train_test_split
Xtrn, Xtst, ytrn, ytst = train_test_split(Xemb, y, 
                                          test_size=0.2, 
                                          stratify=y, 
                                          random_state=13)

观察到使用 stratify=y 确保训练集和测试集中不同数字的比例相同。

4.4.2 提升法

我们现在将为这个数字分类任务训练一个 AdaBoost 模型。回想一下我们之前的讨论,AdaBoost 要求我们首先选择基估计器的类型。我们继续使用决策树桩,如下所示:

from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import AdaBoostClassifier

stump = DecisionTreeClassifier(max_depth=2)
ensemble = AdaBoostClassifier(algorithm='SAMME', base_estimator=stump)

在上一节中,我们看到了如何使用交叉验证(CV)来分别选择学习率(learning_rate)和 n_estimators 的最佳值。在实践中,我们必须确定学习率和 n_estimators 的最佳组合。为此,我们将结合使用 k 折交叉验证和网格搜索。

基本思路是考虑学习率和 n_estimators 的不同组合,并通过交叉验证(CV)评估它们的性能。首先,我们选择想要探索的各种参数值:

parameters_to_search = {'n_estimators': [200, 300, 400, 500],
                        'learning_rate': [0.6, 0.8, 1.0]}

接下来,我们创建一个评分函数来评估每个参数组合的性能。对于这个任务,我们使用平衡准确率,这本质上就是每个类别的准确率加权。这个评分标准对于像这样的多类分类问题以及不平衡数据集都是有效的:

from sklearn.metrics import balanced_accuracy_score, make_scorer
scorer = make_scorer(balanced_accuracy_score, greater_is_better=True)

现在,我们设置并运行网格搜索,使用 GridSearchCV 类来识别最佳参数组合。GridSearchCV 的几个参数对我们来说很有兴趣。参数 cv=5 指定了 5 折交叉验证,n_jobs=-1 指定了该作业应使用所有可用的核心进行并行处理(见第二章):

from sklearn.model_selection import GridSearchCV
search = GridSearchCV(ensemble, param_grid=parameters_to_search,
                      scoring=scorer, cv=5, n_jobs=-1, refit=True)
search.fit(Xtrn, ytrn)

GridSearchCV 的最后一个参数设置为 refit=True。这告诉 GridSearchCV 使用所有可用的训练数据,使用它已识别的最佳参数组合来训练一个最终模型。

TIP 对于许多数据集,使用 GridSearchCV 穷尽地探索和验证所有可能的超参数选择可能计算效率不高。对于这种情况,使用 RandomizedSearchCV 可能更有效,它只采样一个更小的超参数组合子集进行验证。

训练完成后,我们可以查看每个参数组合的分数,甚至提取最佳结果:

best_combo = search.cv_results_['params'][search.best_index_]
best_score = search.best_score_
print('The best parameter settings are {0}, with score = \
      {1}.'.format(best_combo, best_score))

这些结果打印出以下内容:

The best parameter settings are {'learning_rate': 0.6, 'n_estimators': 200}, with score = 0\. 0.9826321839080459.

最佳模型也可用(因为我们设置了 refit=True)。请注意,这个模型是使用最佳 combo 参数,通过 GridSearchCV 使用全部训练数据(Xtrn, ytrn)训练的。这个模型在 search.best_estimator 中可用,可用于对测试数据进行预测:

ypred = search.best_estimator_.predict(Xtst)

这个模型做得怎么样?我们可以首先查看分类报告:

from sklearn.metrics import classification_report
print('Classification report:\n{0}\n'.format(
    classification_report(ytst, ypred)))

分类报告包含类别的性能指标,包括每个数字的精确率和召回率。精确率是预测为正的任何事物中真正正例的比例,包括假正例。它计算为TP / (TP + FP),其中TP是真正例的数量,FP是假正例的数量。

召回率是应该预测为正的所有事物中真正正例的比例,包括假负例。它计算为TP / (TP + FN),其中FN是假负例的数量。分类报告如下:

Classification report:
              precision    recall  f1-score   support

           0       1.00      0.97      0.99        36
           1       1.00      1.00      1.00        37
           2       1.00      0.97      0.99        35
           3       1.00      1.00      1.00        37
           4       0.97      1.00      0.99        36
           5       0.72      1.00      0.84        36
           6       1.00      1.00      1.00        36
           7       1.00      1.00      1.00        36
           8       0.95      1.00      0.97        35
           9       1.00      0.58      0.74        36

    accuracy                           0.95       360
   macro avg       0.96      0.95      0.95       360
weighted avg       0.96      0.95      0.95       360

AdaBoost 在大多数数字上表现相当好。它似乎在与 5 和 9 这两个数字上有点吃力,它们的 F1 分数较低。我们还可以查看 混淆矩阵,这将给我们一个很好的想法,哪些字母被混淆了:

from sklearn.metrics import confusion_matrix
print("Confusion matrix: \n {0}".format(confusion_matrix(ytst, ypred)))

混淆矩阵使我们能够可视化模型在每个类别上的表现:

[[35  0  0  0  1  0  0  0  0  0]
 [ 0 37  0  0  0  0  0  0  0  0]
 [ 0  0 34  0  0  0  0  0  1  0]
 [ 0  0  0 37  0  0  0  0  0  0]
 [ 0  0  0  0 36  0  0  0  0  0]
 [ 0  0  0  0  0 36  0  0  0  0]
 [ 0  0  0  0  0  0 36  0  0  0]
 [ 0  0  0  0  0  0  0 36  0  0]
 [ 0  0  0  0  0  0  0  0 35  0]
 [ 0  0  0  0  0 14  0  0  1 21]]

混淆矩阵的每一行对应于真实标签(数字从 0 到 9),每一列对应于预测标签。混淆矩阵中 (9, 5) 的条目(第 10 行,第 6 列,因为我们从 0 开始索引)表示 AdaBoost 将几个 9 错误地分类为 5。最后,我们可以绘制训练好的 AdaBoost 模型的决策边界,如图 4.15 所示。

CH04_F15_Kunapuli

图 4.15 AdaBoost 在数字数据集嵌入上学习到的决策边界

本案例研究说明了 AdaBoost 如何将弱学习者的性能提升到强大的强学习者,从而在复杂任务上实现良好的性能。在我们结束本章之前,让我们看看另一种自适应提升算法,即 LogitBoost。

4.5 LogitBoost:使用逻辑损失进行提升

我们现在转向第二种提升算法,称为逻辑提升(LogitBoost)。LogitBoost 的发展是由将损失函数从已建立的分类模型(例如,逻辑回归)引入 AdaBoost 框架的愿望所激发的。以这种方式,通用的提升框架可以应用于特定的分类设置,以训练具有类似这些分类器特性的提升集成。

4.5.1 逻辑损失与指数损失函数

回想第 4.2.2 节,AdaBoost 使用以下方式更新弱学习者的权重 α[t]:

CH04_F15_Kunapuli-eqs-15xa

这种加权方案从何而来?这个表达式是 AdaBoost 优化指数损失的事实结果。特别是,AdaBoost 优化了示例 (x,y) 相对于弱学习器 ht 的指数损失,如下所示:

CH04_F15_Kunapuli-eqs-15x

其中 y 是真实标签,ht 是弱学习器 h[t] 的预测。

我们能否使用其他损失函数来推导 AdaBoost 的变体?我们绝对可以!LogitBoost 实质上是一种类似于 AdaBoost 的集成方法,其加权方案使用不同的损失函数。只是当我们改变底层损失函数时,我们也需要做一些小的调整,以使整体方法能够工作。

LogitBoost 与 AdaBoost 在三个方面有所不同。首先,LogitBoost 优化的是逻辑损失:

CH04_F15_Kunapuli-eqs-16x

你可能在其他机器学习公式中见过逻辑损失,最著名的是逻辑回归。逻辑损失对错误的惩罚方式与指数损失不同(见图 4.16)。

CH04_F16_Kunapuli

图 4.16 比较指数损失函数和对数损失函数

精确的 0-1 损失(也称为误分类损失)是一个理想化的损失函数,对于正确分类的示例返回 0,对于错误分类的示例返回 1。然而,由于它不连续,这种损失函数难以优化。为了构建可行的机器学习算法,不同的方法使用不同的替代品,例如指数和对数损失。

指数损失函数和对数损失函数都对正确分类的示例进行类似的惩罚。以更高置信度正确分类的训练示例对应的损失接近零。指数损失函数对错误分类的示例的惩罚比对数损失函数更为严厉,这使得它更容易受到异常值和噪声的影响。对数损失函数更为稳健。

4.5.2 回归作为分类问题的弱学习算法

第二个关键区别在于,AdaBoost 使用预测,而 LogitBoost 使用预测概率。更确切地说,AdaBoost 使用整体集成F(x)的预测,而 LogitBoost 使用预测概率P(x)。

预测训练示例 x 为正例的概率为

CH04_F16_Kunapuli-eqs-17x

而预测 x 为负例的概率由P(y = 0 | x) = 1 - P(y = 1 | x)给出。这一事实直接影响我们选择基估计器的选择。

第三个关键区别在于,因为 AdaBoost 直接与离散预测(-1 或 1,用于负例和正例)工作,它可以使用任何分类算法作为基学习算法。相反,LogitBoost 使用连续预测概率。因此,它使用任何回归算法作为基学习算法。

4.5.3 实现 LogitBoost

将所有这些放在一起,LogitBoost 算法在每个迭代中执行以下步骤。以下简写为P[i]的概率P(y[i] = 1 | x[i]):

1\. Compute the working response, or how much the prediction probability
   differs from the true label: 

CH04_F16_Kunapuli-eqs-18x

2\. Update the example weights, *D*i = *P*i(1 - *P*i)
3\. Train a weak regression stump *h*t(*x*) on the weighted examples (*x*i,*z*i,*D*i)
4\. Update the ensemble,*F*t+1(*x*) = *F*t(*x*) + *h*t(*x*)
5\. Update the example probabilities

CH04_F16_Kunapuli-eqs-19x

如第 4 步所示,LogitBoost 与 AdaBoost 一样,是一种加性集成。这意味着 LogitBoost 集成基估计器并加性组合它们的预测。此外,任何弱回归器都可以在第 3 步中使用,在那里我们使用回归树桩,即浅层回归树。LogitBoost 算法也易于实现,如下面的列表所示。

列表 4.5 LogitBoost 用于分类

import numpy as np
from sklearn.tree import DecisionTreeRegressor
from sklearn.metrics import accuracy_score
from scipy.special import expit

def fit_logitboosting(X, y, n_estimators=10):
    n_samples, n_features = X.shape
    D = np.ones((n_samples, )) / n_samples            
    p = np.full((n_samples, ), 0.5)                    ❶
    estimators = []                                   

    for t in range(n_estimators):
        z = (y - p) / (p * (1 - p))                    ❷
        D = p * (1 - p)                                ❸

        h = DecisionTreeRegressor(max_depth=1)         ❹
        h.fit(X, z, sample_weight=D)                
        estimators.append(h)                           ❺

        if t == 0:
            margin = np.array([h.predict(X)
                               for h in estimators]).reshape(-1, )
        else:
            margin = np.sum(np.array([h.predict(X)
                                      for h in estimators]), axis=0)
        p = expit(margin)                              ❻

    return estimators

❶ 初始化示例权重,“pred”概率

❷ 计算工作响应

❸ 计算新示例权重

❹ 使用决策树回归作为分类问题的基估计器

❺ 将弱学习器附加到集成 Ft+1 = Ft + ht

❻ 更新预测概率

列表 4.2 中描述的 predict_boosting 函数也可以用于使用 LogitBoost 集成进行预测,并在列表 4.6 中实现。

然而,LogitBoost 需要训练标签以 0/1 形式存在,而 AdaBoost 需要以 -1/1 形式存在。因此,我们稍微修改了该函数以返回 0/1 标签。

列表 4.6 LogitBoost 预测

def predict_logit_boosting(X, estimators):
    pred = np.zeros((X.shape[0], ))

    for h in estimators:
        pred += h.predict(X)

    y = (np.sign(pred) + 1) / 2    ❶

    return y

❶ 将 -1/1 预测转换为 0/1

与 AdaBoost 一样,我们可以通过图 4.17 视觉化 LogitBoost 在多次迭代中训练的集成如何演变。将此图与早期图 4.9 进行对比,该图显示了 AdaBoost 在多次迭代中训练的集成演变。

CH04_F17_Kunapuli

图 4.17 LogitBoost 使用决策树回归来训练回归树桩作为弱学习器,以顺序优化逻辑损失。

我们已经看到了两种处理不同损失函数的 boosting 算法。有没有一种方法可以将 boosting 推广到不同的损失函数和不同的任务,如回归?

这个问题的答案是肯定的,只要损失函数是可微分的(并且你可以计算其梯度)。这就是 梯度提升 的直觉,我们将在接下来的两章中探讨。

摘要

  • 自适应提升(AdaBoost)是一种使用弱学习器作为基估计器的顺序集成算法。

  • 在分类中,弱学习器是一个简单的模型,其表现仅略好于随机猜测,即 50% 的准确率。决策树桩和浅层决策树是弱学习器的例子。

  • AdaBoost 在训练示例上维护和更新权重。它使用重新加权来优先考虑误分类示例并促进集成多样性。

  • AdaBoost 也是一个加性集成,因为它通过其基估计器的预测的加性(线性)组合来做出最终预测。

  • AdaBoost 通常对过拟合具有鲁棒性,因为它集成了多个弱学习器。然而,由于自适应重新加权策略,AdaBoost 对异常值敏感,该策略在迭代过程中反复增加异常值的权重。

  • AdaBoost 的性能可以通过在学习率和基估计器的数量之间找到一个良好的权衡来提高。

  • 使用网格搜索进行交叉验证通常用于确定学习率和估计器数量之间的最佳参数权衡。

  • 在底层,AdaBoost 优化指数损失函数。

  • LogitBoost 是另一种优化逻辑损失函数的 boosting 算法。它在两个方面与 AdaBoost 不同:(1) 通过处理预测概率,以及(2) 使用任何分类算法作为基学习算法。


(1.) Yoav Freund 和 Robert E. Schapire. “在线学习的决策理论推广及其在提升中的应用”,《计算机与系统科学杂志》,第 55 卷第 1 期,第 119-139 页,1997 年。

(2.) 同上。

(3.) 同上。

5 顺序集成:梯度提升

本章涵盖

  • 使用梯度下降优化训练模型的损失函数

  • 实现梯度提升

  • 高效训练直方图梯度提升模型

  • 在 LightGBM 框架中使用梯度提升

  • 使用 LightGBM 避免过拟合

  • 使用 LightGBM 自定义损失函数

上一章介绍了提升,其中我们按顺序训练弱学习器并将它们“提升”为强大的集成模型。在第四章中介绍的一个重要顺序集成方法是自适应提升(AdaBoost)。

AdaBoost 是一种基础的提升模型,它通过训练一个新的弱学习器来纠正前一个弱学习器的误分类。它是通过维护和自适应更新训练样本上的权重来做到这一点的。这些权重反映了误分类的程度,并指示给基础学习算法优先训练的样本。

在本章中,我们探讨了一种在训练样本上使用权重作为向基础学习算法传递误分类信息的替代方案:损失函数梯度。回想一下,我们使用损失函数来衡量模型在数据集中每个训练样本上的拟合程度。单个示例的损失函数梯度被称为 残差,正如我们将很快看到的,它捕捉了真实标签和预测标签之间的偏差。这个错误,或残差,当然衡量了误分类的程度。

与使用权重作为残差的代理的 AdaBoost 相比,梯度提升直接使用这些残差!因此,梯度提升是另一种旨在在残差(即梯度)上训练弱学习器的顺序集成方法。

梯度提升的框架可以应用于任何损失函数,这意味着任何分类、回归或排序问题都可以使用弱学习器进行“提升”。这种灵活性是梯度提升作为最先进的集成方法出现和普及的关键原因。有几个强大的梯度提升包和实现(LightGBM、CatBoost、XGBoost)可用,并能够通过并行计算和 GPU 高效地在大数据上训练模型。

本章的组织如下。为了更深入地理解梯度提升,我们需要更深入地理解梯度下降。因此,我们以一个可以用来训练机器学习模型的梯度下降示例(第 5.1 节)开始本章。

第 5.2 节旨在提供使用残差进行学习的直观理解,这是梯度提升的核心。然后,我们实现自己的梯度提升版本,并逐步了解它是如何结合梯度下降和提升的每一步来训练顺序集成的。本节还介绍了基于直方图的梯度提升,它本质上将训练数据分箱,从而显著加速树学习,并允许扩展到更大的数据集。

第 5.3 节介绍了 LightGBM,这是一个免费且开源的梯度提升包,也是构建和部署现实世界机器学习应用的重要工具。在第 5.4 节中,我们将看到如何通过早期停止和调整学习率等策略来避免过拟合,以及如何将 LightGBM 扩展到自定义损失函数。

所有这些都引导我们演示如何在现实世界任务中使用梯度提升:文档检索,这将是本章案例研究的重点(第 5.5 节)。文档检索作为一种信息检索形式,在许多应用中都是一个关键任务,我们都在某个时候使用过(例如,网络搜索引擎)。

要理解梯度提升,我们首先必须理解梯度下降,这是一种简单而有效的方法,广泛用于训练许多机器学习算法。这将帮助我们理解梯度下降在梯度提升中扮演的角色,无论是从概念上还是从算法上。

5.1 最小化梯度下降

我们现在深入探讨梯度下降,这是许多训练算法核心的优化方法。理解梯度下降将使我们能够理解梯度提升框架如何巧妙地将这种优化过程与集成学习相结合。优化,或寻找“最佳”,是许多应用的核心。确实,寻找最佳模型是所有机器学习的核心。

注意:学习问题通常被表述为优化问题。例如,训练本质上是在给定数据的情况下找到最佳拟合模型。如果“最佳”的概念由损失函数来表征,那么训练就被表述为最小化问题,因为最佳模型对应于最低的损失。或者,如果“最佳”的概念由似然函数来表征,那么训练就被表述为最大化问题,因为最佳模型对应于最高的似然(或概率)。除非指定,我们将使用损失函数来表征模型质量或拟合度,这将要求我们进行最小化。

损失函数明确衡量模型在数据集上的拟合度。通常,我们通过量化预测标签和真实标签之间的误差来衡量损失。因此,最佳模型将具有最低的误差或损失。

你可能熟悉诸如交叉熵(用于分类)或均方误差(用于回归)之类的损失函数。我们将在第 5.4.3 节中回顾交叉熵,在第七章中回顾均方误差。给定一个损失函数,训练是寻找最小化损失的最优模型的过程,如图 5.1 所示。

CH05_F01_Kunapuli

图 5.1 寻找最佳模型的最优化过程。机器学习算法在所有可能的候选模型中寻找最佳模型。最佳的概念通过损失函数来量化,该函数使用标签和数据评估所选候选者的质量。因此,机器学习算法本质上是最优化过程。在这里,最优化过程依次识别越来越好的模型 f[1],f[2],以及最终模型 f[3]。

你可能熟悉的一个这样的搜索例子是在训练决策树时进行参数选择的网格搜索。在网格搜索中,我们系统地、详尽地在参数网格上选择许多建模选择:叶子数、最大树深度等。

另一种更有效的优化技术是梯度下降,它使用一阶导数信息,即梯度,来引导我们的搜索。在本节中,我们查看两个梯度下降的例子。第一个是一个简单的说明性例子,用于理解和可视化梯度下降的基本工作原理。第二个例子演示了如何使用实际损失函数和数据来训练机器学习模型。

5.1.1 带有示例的梯度下降

我们将使用 Branin 函数,这是一个常用的示例函数,来展示梯度下降的工作原理,然后再转向一个更具体的基于机器学习的案例(第 5.1.2 节)。Branin 函数是两个变量(w[1]和w[2])的函数,定义为

CH05_F01_Kunapuli-ch5-eqs-0x

其中 a = 1, b = 5.1/4π², c = 5/π, r = 6, s = 10, 和 t = 1/8π 是固定的常数,我们不必担心。我们可以通过绘制 w[1] 与 w[2] 相对于 f(w[1],w[2]) 的 3D 图来可视化这个函数。图 5.2 展示了 3D 表面图以及等高线图(即从上方观看的表面图)。

CH05_F02_Kunapuli

图 5.2 Branin 函数的表面图(左)和等高线图(右)。我们可以直观地验证这个函数有四个最小值,这些最小值是等高线图中等椭圆区域的中心。

Branin 函数的可视化显示它在四个不同的位置取最小值,这些位置被称为局部最小值或最小值。那么我们如何识别这些局部最小值呢?总是有暴力方法:我们可以在变量 w[1] 和 w[2] 上建立一个网格,并穷尽地评估每个可能组合的 f(w[1],w[2])。然而,这种方法有几个问题。首先,我们的网格应该有多粗或多细?如果我们的网格太粗,我们可能会错过搜索中的最小值。如果我们的网格太细,那么我们将有大量的网格点要搜索,这将使我们的最优化过程非常缓慢。

其次,并且更令人担忧的是,这种方法忽略了函数本身固有的所有额外信息,这些信息可能对我们的搜索非常有帮助。例如,一阶导数,即 f(w[1],w[2]) 关于 w[1] 和 w[2] 的变化率,可能非常有帮助。

理解和实现梯度下降

一阶导数信息被称为 f(w[1],w[2]) 的梯度,它是函数表面(局部)斜率的度量。更重要的是,梯度指向最陡上升的方向;也就是说,沿着最陡上升方向移动将导致 f(w[1],w[2]) 的更大值。

如果我们想使用梯度信息来找到最小值,那么我们必须沿着梯度的反方向前进!这正是梯度下降简单而高效的原则:继续沿着负梯度方向前进,最终你会到达一个(局部)最小值。

我们可以用以下伪代码形式化这种直觉,它描述了梯度下降的步骤。如图所示,梯度下降是一个迭代过程,通过沿着最陡下降方向(即负梯度)移动,稳步向局部最小值移动:

: wold = some initial guess, converged=False
while not converged:
1\. compute the direction (d) as negative gradient at wold and normalize 
     to unit length 
2\. compute the step length using line search (distance, α)
3\. update the solution: wnew = wold + distance * direction = wold + α ⋅ d
4\. if change between wnew and wold is below some specified tolerance:
     converged=True, so break
5. else set wnew = wold, get ready for the next iteration

梯度下降过程相当直接。首先,我们初始化我们的解(并称之为 w[old]);这可以是随机初始化,或者可能是一个更复杂的猜测。从这个初始猜测开始,我们计算负梯度,这告诉我们想要前进的方向。

接下来,我们计算步长,这告诉我们沿着负梯度方向移动的距离或距离有多远。计算步长很重要,因为它确保我们不会超过我们的解。

步长计算是另一个优化问题,我们的目标是找到一个正标量 α > 0,使得沿着梯度 g 移动距离 α 可以使损失函数的最大减少。形式上,这被称为 线搜索问题,通常用于优化过程中高效地选择步长。

注意:许多优化包和工具(例如,本章中使用的 scipy.optimize)提供了精确和近似的线搜索函数,可用于识别步长。或者,步长也可以根据某些预定的策略设置,通常是为了效率。在机器学习中,步长通常被称为 学习率,用希腊字母 η (η) 表示。

有了一个方向和距离,我们可以采取这一步,并将我们的解猜测更新为 w[new]。一旦到达那里,我们检查收敛性。有几种收敛性测试;这里,我们假设在连续迭代之间解变化不大时收敛。如果收敛,那么我们就找到了一个局部最小值。如果没有,那么我们从 w[new] 再次迭代。以下列表显示了如何执行梯度下降。

列表 5.1 梯度下降

import numpy as np
from scipy.optimize import line_search
def gradient_descent(f, g, x_init,                          ❶
                     max_iter=100, args=()):
    converged = False                                       ❷
    n_iter = 0

    x_old, x_new = np.array(x_init), None
    descent_path = np.full((max_iter + 1, 2), fill_value=np.nan)   
    descent_path[n_iter] = x_old

    while not converged:
        n_iter += 1
        gradient = -g(x_old, *args)                         ❸
        direction = gradient / np.linalg.norm(gradient)     ❹

        step = line_search(f, g, x_old, 
                           direction, args=args)            ❺

        if step[0] is None:                                 ❻
            distance = 1.0
        else:
            distance = step[0]

        x_new = x_old + distance * direction                ❼
        descent_path[n_iter] = x_new

        err = np.linalg.norm(x_new - x_old)                 ❽
        if err <= 1e-3 or n_iter >= max_iter:                      
            converged = True                                ❾

        x_old = x_new                                       ❿

    return x_new, descent_path

❶ 梯度下降需要一个函数 f 和其梯度 g。

❷ 将梯度下降初始化为“未收敛”

❸ 计算负梯度

❹ 将梯度归一化到单位长度

❺ 使用线搜索计算步长

❻ 如果线搜索失败,则将其设置为 1.0。

❼ 计算更新

❽ 计算与前一次迭代的改变

❾ 当变化很小或达到最大迭代次数时收敛

❿ 准备进行下一次迭代

我们可以在 Branin 函数上测试这个梯度下降过程。为此,除了函数本身之外,我们还需要其梯度。我们可以通过挖掘微积分的基础(如果记忆中还有的话)来显式地计算梯度。

梯度是一个有两个分量的向量:fw[1] 和 w[2] 的梯度,分别。有了这个梯度,我们可以计算在每处的最大增加方向:

CH05_F02_Kunapuli-eqs-4x

我们可以像下面这样实现 Branin 函数及其梯度:

def branin(w, a, b, c, r, s, t):
    return a * (w[1] - b * w[0] ** 2 + c * w[0] - r) ** 2 + \
           s * (1 - t) * np.cos(w[0]) + s

def branin_gradient(w, a, b, c, r, s, t):
    return np.array([2 * a * (w[1] - b * w[0] ** 2 + c * w[0] - r) * 
                     (-2 * b * w[0] + c) - s * (1 - t) * np.sin(w[0]),
                     2 * a * (w[1] - b * w[0] ** 2 + c * w[0] - r)])

除了函数和梯度之外,列表 5.1 还需要一个初始猜测 x_init。在这里,我们将使用 w_ini=[-4,-5]' (转置,因为这些是列向量,从数学的角度讲) 来初始化梯度下降。现在,我们可以调用梯度下降过程:

a, b, c, r, s, t = 1, 5.1/(4 * np.pi**2), 5/np.pi, 6, 10, 1/(8 * np.pi)
w_init = np.array([-4, -5])
w_optimal, w_path = gradient_descent(branin, branin_gradient, 
                                     w_init, args=(a, b, c, r, s, t))

梯度下降返回一个最优解 w_optimal=[3.14, 2.27] 和优化路径 w_path,这是在达到最优解的过程中,程序迭代通过的中间解的序列。

哇!在图 5.3 中,我们看到梯度下降能够达到 Branin 函数的四个局部最小值之一。关于梯度下降,有几个重要的事情需要注意,我们将在下面讨论。

CH05_F03_Kunapuli

图 5.3 左图显示了梯度下降的完整下降路径,从 [-4,-5]' (正方形) 开始,收敛到局部最小值之一 (圆形)。右图显示了当梯度下降接近解时,相同下降路径的放大版本。请注意,梯度步骤变得越小,下降算法在接近解时倾向于曲折。

梯度下降的性质

首先,观察当我们接近一个最小值时,梯度步骤变得越来越小。这是因为梯度在最小值处消失。更重要的是,梯度下降表现出曲折行为,因为梯度并不指向局部最小值本身;相反,它指向最陡上升(或下降,如果为负)的方向。

在某一点的梯度本质上捕捉了局部信息,即该点附近函数的性质。梯度下降通过连续几个这样的梯度步骤来达到最小值。当梯度下降必须穿过陡峭的山谷时,它倾向于使用局部信息,导致它在移动向最小值的过程中在山谷两侧弹跳。

第二,梯度下降收敛到了 Branin 函数的四个局部最小值之一。通过改变初始化,你可以让它收敛到不同的最小值。图 5.4 展示了不同初始化下的各种梯度下降路径。

图 5.4 展示了梯度下降对初始化的敏感性,其中不同的随机初始化导致梯度下降收敛到不同的局部最小值。这种行为对于那些使用过 k-means 聚类的人来说可能很熟悉:不同的初始化通常会产生不同的聚类,每个聚类都是一个不同的局部解。

CH05_F04_Kunapuli

图 5.4 不同的初始化会导致梯度下降达到不同的局部最小值。

梯度下降的一个有趣挑战在于确定适当的初始化,因为不同的初始化会导致梯度下降收敛到不同的局部最小值。从优化的角度来看,事先确定正确的初始化并不总是容易的。

然而,从机器学习的角度来看,不同的局部解可能表现出相同的一般化行为。也就是说,局部最优的学习模型都具有相似的预测性能。这种情况在神经网络和深度学习中很常见,这也是为什么许多深度模型的训练过程都是从预训练的解决方案开始的。

提示:梯度下降对初始化的敏感性取决于被优化的函数类型。如果函数在所有地方都是凸的或杯状的,那么梯度下降识别的任何局部最小值也将总是全局最小值!这是支持向量机(SVM)优化器学习模型的情况。然而,一个好的初始猜测仍然很重要,因为它可能会使算法更快地收敛。许多现实世界的问题通常是非凸的,并且有几个局部最小值。梯度下降将收敛到其中之一,这取决于初始化和初始猜测局部函数的形状。k-means 聚类的目标函数是非凸的,这就是为什么不同的初始化会产生不同的聚类。参见 Mykel Kochenderfer 和 Tim Wheeler 所著的《优化算法》(MIT Press,2019),这是一本关于优化的扎实且实用的入门书籍。

5.1.2 梯度下降在训练损失函数上的应用

现在我们已经理解了梯度下降在简单示例(Branin 函数)上的基本工作原理,让我们从头开始构建一个分类任务,并使用我们自己的损失函数。然后,我们将使用梯度下降来训练模型。首先,我们创建一个如下所示的 2D 分类问题:

from sklearn.datasets import make_blobs
X, y = make_blobs(n_samples=200, n_features=2, 
                  centers=[[-1.5, -1.5], [1.5, 1.5]], random_state=42)

这个合成分类数据集在图 5.5 中进行了可视化。

CH05_F05_Kunapuli

图 5.5 一个(几乎)线性可分的两类数据集,我们将在此数据集上训练一个分类器。正例的标签为 y = 1,而负例的标签为 y = 0。

我们特别创建了一个线性可分的数据集(当然,其中包含一些噪声),这样我们就可以训练一个线性分离器或分类函数。这将使我们的损失函数公式简单,并使我们的梯度易于计算。

我们想要训练的分类器 hw 接受 2D 数据点 x = [x[1],x[2]]' 并使用线性函数返回一个预测:

CH05_F05_Kunapuli-eqs-5x

分类器由 w = [w[1], w[2]]' 参数化,我们必须使用训练示例来学习它。为了学习,我们需要一个关于真实标签和预测标签的损失函数。我们将使用熟悉的平方损失(或平方误差),它衡量单个标记训练示例 (x,y) 的成本:

CH05_F05_Kunapuli-eqs-6x

平方损失函数计算当前候选模型 (h[w]) 在单个训练示例 (x) 上的预测与其真实标签 (y) 之间的损失。对于数据集中的 n 个训练示例,整体损失可以表示如下:

CH05_F05_Kunapuli-eqs-7x

整体损失的表达式只是数据集中 n 个训练示例的个别损失的求和。

表达式 1/2(yXw)’(yXw) 是整体损失的 向量化 版本,它使用点积而不是循环。在向量化版本中,粗体的 y 是一个 n × 1 的真实标签向量;x 是一个 n × 2 的数据矩阵,其中每一行是一个 2D 训练示例;而 w 是一个我们想要学习的 2 × 1 模型向量。

如前所述,我们需要损失函数的梯度:

CH05_F05_Kunapuli-eqs-9x

我们实现向量化版本,因为它们更紧凑、更高效,避免了显式的循环求和:

def squared_loss(w, X, y):
    return 0.5 * np.sum((y - np.dot(X, w))**2)

def squared_loss_gradient(w, X, y):
    return -np.dot(X.T, (y - np.dot(X, w)))

提示:如果你对手动计算梯度感到担忧,不要绝望;有其他方法可以数值近似梯度,并且被用于训练许多机器学习模型,包括深度学习和梯度提升。这些替代方案依赖于有限差分近似或自动微分(它基于数值计算和线性代数的基本原理)来有效地计算梯度。一个易于使用的工具是 scipy 科学包中可用的函数 scipy.optimize.approx_fprime。一个更强大的工具是 JAX (github.com/google/jax),它是免费且开源的。JAX 旨在计算表示具有许多层的深度神经网络的复杂函数的梯度。JAX 可以通过循环、分支甚至递归进行微分,并且它支持大规模梯度计算的 GPU。

我们的损失函数看起来是什么样子?我们可以像之前一样可视化它,如图 5.6 所示。这个损失函数是碗形的,且是凸的,它有一个全局最小值,这就是我们的最优分类器,w

CH05_F06_Kunapuli

图 5.6 整个训练集上的整体平方损失可视化

如前所述,我们执行梯度下降,这次初始化为 w = [0.0,-0.99]',使用以下代码片段,梯度下降路径如图 5.7 所示:

w_init = np.array([0.0, -0.99])
w, path = gradient_descent(squared_loss, squared_loss_gradient, 
                           w_init, args=(X, y))
print(w)
[0.17390066 0.11937649]

梯度下降已经学习了一个最终的学习模型:w^* = [0.174,0.119]'. 通过我们的梯度下降过程学习到的线性分类器在图 5.7(右)中进行了可视化。除了通过视觉确认梯度下降过程学习到了有用的模型外,我们还可以计算训练准确率。

回想一下,线性分类器 hw = w[1]x[1] + w[2]x[2] 返回的是实数值预测,我们需要将其转换为 0 或 1。这是直截了当的:我们只需将所有正预测(几何上位于线上的示例)分配给类别 y[pred] = 1,并将负预测(几何上位于线下的示例)分配给类别 y[pred] = 0:

ypred = (np.dot(X, w) >= 0).astype(int)
from sklearn.metrics import accuracy_score
accuracy_score(y, ypred)
0.995

成功!我们实现的梯度下降学习到的训练准确率为 99.5%。

CH05_F07_Kunapuli

图 5.7 左:从 w_init(正方形)开始,在最优解(圆形)处收敛的梯度下降过程。右:学习到的模型 w^* = [0.174,0.119]' 是一个线性分类器,它很好地拟合了训练数据,因为它将两个类别分开。

现在我们已经了解了梯度下降如何在训练过程中使用梯度信息依次最小化损失函数,让我们看看我们如何通过提升(boosting)来扩展它以训练一个序列集成。

5.2 梯度提升:梯度下降 + 提升法

在梯度提升中,我们的目标是训练一系列弱学习器,在每个迭代中逼近梯度。梯度提升及其继任者牛顿提升目前被认为是最先进的集成方法,并且在多个应用领域的多个任务中得到了广泛实现和部署。

我们首先将探讨梯度提升的直观理解,并将其与另一种熟悉的提升方法:AdaBoost 进行比较。有了这种直观理解,就像之前一样,我们将实现我们自己的梯度提升版本,以可视化底层真正发生的事情。

然后,我们将探讨 scikit-learn 中可用的两种梯度提升方法:GradientBoostingClassifier 及其更可扩展的对应版本 HistogramGradientBoostingClassifer。这将为我们学习 LightGBM 打下良好的基础,LightGBM 是一种强大且灵活的梯度提升实现,广泛用于实际应用。

5.2.1 直观理解:使用残差进行学习

序列集成方法,如 AdaBoost 和梯度提升的关键组成部分是,它们旨在每个迭代中训练一个新的弱估计器来纠正前一个迭代中弱估计器所犯的错误。然而,AdaBoost 和梯度提升在训练新的弱估计器上对分类不良的例子有相当不同的方式。

AdaBoost 与梯度提升的比较

AdaBoost 通过给误分类的例子赋予比正确分类的例子更高的权重来识别高优先级的训练例子。这样,AdaBoost 可以告诉基础学习算法在当前迭代中应该关注哪些训练例子。相比之下,梯度提升使用残差或误差(真实标签和预测标签之间的差异)来告诉基础学习算法在下一个迭代中应该关注哪些训练例子。

残差究竟是什么?对于一个训练例子,它仅仅是真实标签和相应预测之间的误差。直观上,一个正确分类的例子必须有一个小的残差,而一个误分类的例子必须有一个大的残差。更具体地说,如果一个分类器h对一个训练例子x做出预测h(x),计算残差的一个简单方法就是直接测量它们之间的差异:

CH05_F07_Kunapuli-eqs-10x

回想一下我们之前使用的平方损失函数:f损失 = ½(yh(x))²。这个损失函数f相对于我们的模型h的梯度如下:

CH05_F07_Kunapuli-eqs-12x

平方损失的负梯度正好与我们的残差相同!这意味着损失函数的梯度是误分类的度量,也是残差。

严重误分类的训练例子将会有大的梯度(残差),因为真实标签和预测标签之间的差距会很大。正确分类的训练例子将会有小的梯度。

这在图 5.8 中很明显,其中残差的幅度和符号指示了需要最多关注的训练示例。因此,类似于 AdaBoost,我们有衡量每个训练示例错误分类程度的一个指标。我们如何利用这个信息来训练一个弱学习器?

CH05_F08_Kunapuli

图 5.8 比较 AdaBoost(左)与梯度提升(右)。两种方法都训练弱估计器,以改善对错误分类示例的分类性能。AdaBoost 使用权重,错误分类的示例被分配更高的权重。梯度提升使用残差,错误分类的示例具有更高的残差。残差不过是负损失梯度。

使用弱学习器来近似梯度

继续我们的 AdaBoost 类比,回想一下,一旦它为所有训练示例分配了权重,我们就得到了一个带有加权示例的权重增强数据集(x[i], y[i], D[i]),其中 i = 1, ..., n。因此,在 AdaBoost 中训练弱学习器是加权分类问题的一个实例。使用适当的基础分类算法,AdaBoost 训练一个弱分类器。

在梯度提升中,我们不再有权重 D[i]。相反,我们有残差(或负损失梯度)r[i]和一个残差增强数据集(x[i,] r[i])。而不是分类标签(y[i] = 0 或 1)和示例权重(D[i]),每个训练示例现在都有一个相关的残差,这可以被视为一个实值标签。

因此,在梯度提升中训练弱学习器是回归问题的一个实例,这需要一个如决策树回归的基础学习算法。当训练后,梯度提升中的弱估计器可以被视为近似梯度。

图 5.9 说明了梯度下降与梯度提升的不同之处,以及梯度提升在概念上与梯度下降的相似之处。这两种方法的关键区别在于,梯度下降直接使用负梯度,而梯度提升通过训练一个弱回归器来近似负梯度。我们现在拥有了形式化梯度提升算法步骤的所有要素。

CH05_F09_Kunapuli

图 5.9 比较梯度下降(左)与梯度提升(右)。在迭代t时,梯度下降使用负梯度-g[t]更新模型。在迭代t时,梯度提升通过在负残差-r^t[i]上训练弱回归器h[t]来近似负梯度。梯度下降中的步长α[t]相当于序列集成中每个基础估计器的假设权重。

注意:梯度提升旨在将弱估计器拟合到残差,这些残差是实值。因此,梯度提升将始终需要使用回归算法作为基础学习算法,并学习回归器作为弱估计器。即使损失函数对应于二元或多元分类、回归或排序,也是如此。

梯度提升是梯度下降 + 提升树

总结来说,梯度提升结合了梯度下降和提升:

  • 与 AdaBoost 类似,梯度提升训练一个弱学习器来纠正前一个弱学习器犯的错误。AdaBoost 使用示例权重来关注错误分类的示例,而梯度提升使用示例残差来完成同样的任务。

  • 与梯度下降类似,梯度提升使用梯度信息更新当前模型。梯度下降直接使用负梯度,而梯度提升在负残差上训练一个弱回归器来近似梯度。

最后,梯度下降和梯度提升都是加性算法;也就是说,它们生成一系列中间项,这些中间项通过加性组合来产生最终模型。这在图 5.10 中很明显。

CH05_F10_Kunapuli

图 5.10 梯度下降(左)和梯度提升(右)都产生一系列更新。在梯度下降中,每次迭代通过添加新的负梯度(-g[t])来更新当前模型。在梯度提升中,每次迭代通过添加新的近似弱梯度估计(回归树,h[t])来更新当前模型。

在每次迭代中,AdaBoost、梯度下降和梯度提升都使用以下形式的加性表达式来更新当前模型:

CH05_F10_Kunapuli-eqs-13xa

更正式地,这表现为以下形式:

CH05_F10_Kunapuli-eqs-13xa

我们可以将这个表达式展开到迭代 tt - 1、t - 2、...、0,以获得 AdaBoost、梯度下降和梯度提升产生的整体更新序列:

CH05_F10_Kunapuli-eqs-14x

三种算法之间的关键区别在于我们如何计算更新 h[t] 和假设权重(也称为步长)α[t]。我们可以在表 5.1 中总结所有三种算法的更新步骤。

表 5.1 比较 AdaBoost、梯度下降和梯度提升

算法 损失函数 基础学习算法 更新方向 ht 步长 α[t]
AdaBoost 分类 指数 加权示例分类 弱分类器 闭式计算
梯度下降 用户指定 梯度向量 线搜索
梯度提升 用户指定 带有示例和残差的回归 弱回归器 线搜索

梯度提升 = 梯度下降 + 提升的原因是它泛化了从 AdaBoost 使用的指数损失函数到任何用户指定的损失函数的提升过程。为了使梯度提升能够灵活地适应各种损失函数,它采用了两个通用程序:(1) 使用弱回归器近似梯度,(2) 使用线搜索计算假设权重(或步长)。

5.2.2 实现梯度提升

如前所述,我们将通过实现我们自己的梯度提升版本来将我们的直觉付诸实践。基本算法可以用以下伪代码概述:

initialize: F = f0, some constant value
for t = 1 to T:
1\. compute the negative residuals for each example, *r*ti = (*∂L*/*∂F*)(*x*i)  
2\. fit a weak decision tree regressor *h*t(*x*) using the training set (*x*i, *r*i)*n**i*=1
3\. compute the step length (*α*t) using line search 
4\. update the model: *F*t = *F* + *α*t ⋅ *h*t(*x*)

这种训练过程几乎与梯度下降相同,只是有几个不同之处:(1) 我们不是使用负梯度,而是使用在负残差上训练的近似梯度,(2) 我们不是检查收敛性,而是在有限的最大迭代次数 T 后算法终止。以下列表具体实现了这个伪代码,它使用一种称为黄金分割搜索的线搜索类型来找到最佳步长。

列表 5.2 平方损失梯度提升

from scipy.optimize import minimize_scalar
from sklearn.tree import DecisionTreeRegressor

def fit_gradient_boosting(X, y, n_estimators=10):
    n_samples, n_features = X.shape                        ❶
    n_estimators = 10
    estimators = []                                        ❷
    F = np.full((n_samples, ), 0.0)                        ❸

    for t in range(n_estimators):
        residuals = y - F                                  ❹
        h = DecisionTreeRegressor(max_depth=1)
        h.fit(X, residuals)                                ❺

        hreg = h.predict(X)                                ❻
        loss = lambda a: np.linalg.norm(
                             y - (F + a * hreg))**2        ❼
        step = minimize_scalar(loss, method='golden')      ❽
        a = step.x

        F += a * hreg                                      ❾
        estimators.append((a, h))                          ❿

    return estimators

❶ 获取数据集的维度

❷ 初始化一个空的集成

❸ 在训练集上预测集成

❹ 计算平方损失的负梯度作为残差

❺ 将弱回归树(h[t])拟合到示例和残差

❻ 获取弱学习器的预测,h[t]

❼ 设置线搜索问题

❽ 使用黄金分割搜索找到最佳步长

❾ 更新集成预测

❿ 更新集成

模型训练完成后,我们可以像 AdaBoost 集成一样进行预测(见以下列表)。请注意,就像我们之前的 AdaBoost 实现一样,此模型返回-1/1 的预测而不是 0/1。

列表 5.3 使用梯度提升模型进行预测

def predict_gradient_boosting(X, estimators):
    pred = np.zeros((X.shape[0], ))            ❶

    for a, h in estimators:
        pred += a * h.predict(X)               ❷

    y = np.sign(pred)                          ❸

    return y

❶ 将所有预测初始化为 0

❷ 聚合每个回归器的单个预测

❸ 将加权预测转换为-1/1 标签

我们可以在一个简单的两月亮分类示例上测试这个实现。请注意,我们将训练标签从 0/1 转换为-1/1,以确保我们正确学习和预测:

from sklearn.datasets import make_moons
X, y = make_moons(n_samples=200, noise=0.15, random_state=13)
y = 2 * y - 1                                                  ❶
from sklearn.model_selection import train_test_split
Xtrn, Xtst, ytrn, ytst = train_test_split(X, y,                ❷
                                          test_size=0.25, random_state=11)

estimators = fit_gradient_boosting(Xtrn, ytrn)
ypred = predict_gradient_boosting(Xtst, estimators)

from sklearn.metrics import accuracy_score
tst_err = 1 - accuracy_score(ytst, ypred)                      ❸
tst_err
0.06000000000000005

❶ 将训练标签转换为-1/1

❷ 将数据集分为训练集和测试集

❸ 训练并获取测试错误

该模型的错误率为 6%,相当不错。

可视化梯度提升迭代

最后,为了全面巩固我们对梯度提升的理解,让我们逐步分析前几次迭代,看看梯度提升是如何使用残差来提升分类的。在我们的实现中,我们初始化预测为 F(x[i]) = 0。这意味着在第一次迭代中,类别 1 的示例的残差将为 r[i] = 1 - 0 = 1,而类别 0 的示例的残差将为 r[i] = -1 - 0 = -1。这如图 5.11 所示。

在第一次迭代中,所有训练样本都有较高的残差(要么是+1,要么是-1),基学习算法(决策树回归)必须训练一个考虑所有这些残差的弱回归器。训练的回归树(h[1])如图 5.11(右)所示。

CH05_F11_Kunapuli

图 5.11 第 1 次迭代:残差(左)和基于残差训练的弱回归器(右)

当前集成仅包含一个回归树:F = α[1]h[1]。我们还可以可视化分类预测的h[1]和集成F。如图 5.12 所示,这些分类的整体错误率为 16%。

CH05_F12_Kunapuli

图 5.12 第 1 次迭代:弱学习器(h[1])和整个集成(F)的预测。由于这是第一次迭代,集成仅包含一个弱回归器。

在第 2 次迭代中,我们再次计算残差。现在,残差开始显示出更多的分离,这反映了它们被当前集成分类得有多好。决策树回归器试图再次拟合残差(见图 5.13,右),尽管这次它专注于之前被错误分类的示例。

CH05_F13_Kunapuli

图 5.13 第 2 次迭代:残差(左)和基于残差训练的弱回归器(右)

现在集成包含两个回归树:F = α[1]h[1] + α[2]h[2]。我们现在可以可视化新训练的回归器h[2]和整体集成F(见图 5.14)。

CH05_F14_Kunapuli

图 5.14 第 2 次迭代:弱学习器(h[2])和整体集成(F)的预测

在第 2 次迭代中训练的弱学习器整体错误率为 39.5%。然而,前两个弱学习器已经将集成性能提升至 91%的准确率,即 9%的错误率。这个过程在第 3 次迭代中继续,如图 5.15 所示。

CH05_F15_Kunapuli

图 5.15 第 3 次迭代:残差(左)和基于残差训练的弱回归器(右)

以这种方式,梯度提升法继续按顺序训练并添加基回归器到集成中。图 5.16 显示了经过 10 次迭代后训练的模型;集成包含 10 个弱回归估计器,并将整体训练准确率提升至 97.5%!

CH05_F16_Kunapuli

图 5.16 经过 10 次迭代后的最终梯度提升集成

有几种公开且高效的梯度提升实现可供您在机器学习任务中使用。在本节的其余部分,我们将重点关注最熟悉的:scikit-learn。

5.2.3 使用 scikit-learn 进行梯度提升

我们现在将探讨如何使用两个 scikit-learn 类:GradientBoostingClassifier 和一个新版本,称为 HistogramGradientBoostingClassifier。后者以精确度为代价换取速度,可以比 GradientBoostingClassifier 快得多地训练模型,使其非常适合大型数据集。

scikit-learn 的 GradientBoostingClassifier 实质上实现了我们在本节中自己实现的相同的梯度提升算法。它的使用方式与其他 scikit-learn 分类器(如 AdaBoostClassifier)类似。然而,与 AdaBoostClassifier 有两个关键的区别:

  • 与支持多种不同类型基估计器的 AdaBoostClassifier 不同,GradientBoostingClassifier 只支持基于树的集成。因此,它始终使用决策树作为基估计器,并且没有机制来指定其他类型的基学习算法。

  • AdaBoostClassifier 通过设计优化指数损失。GradientBoostingClassifier 允许用户选择逻辑回归或指数损失函数。逻辑损失(也称为交叉熵)是二分类中常用的损失函数(也有多类变体)。

注意:使用指数损失函数训练 GradientBoostingClassifier 与训练 AdaBoostClassifier 非常相似(但并不完全相同)。

除了选择损失函数外,我们还可以设置其他学习参数。这些参数通常通过交叉验证(CV)选择,就像任何其他机器学习算法一样(参见 AdaBoostClassifier 中的 4.3 节以了解参数选择):

  • 我们可以直接通过 max_depth 和 max_leaf_nodes 控制基树估计器的复杂性。更高的值意味着基树学习算法在训练更复杂的树时具有更大的灵活性。当然,这里的警告是,更深层次的树或具有更多叶节点的树往往会对训练数据进行过拟合。

  • n_estimators 限制了 GradientBoostingClassifier 将按顺序训练的弱学习器的数量,本质上就是算法迭代的次数。

  • 与 AdaBoost 类似,梯度提升也按顺序训练弱学习器(迭代 t 中的 h[t])并逐步和递增地构建集成:Ft = Ft-1 + ηα[t] ⋅ ht。在这里,α[t] 是弱学习器 h[t] 的权重(或步长),而 η 是学习率。学习率是一个用户定义的学习参数,其范围在 0 < η ≤ 1 之间。回想一下,较慢的学习率意味着训练集成通常需要更多的迭代。可能需要选择较慢的学习率,以便使后续的弱学习器对异常值和噪声更加鲁棒。学习率由 learning_rate 参数控制。

让我们看看在乳腺癌数据集上梯度提升的实例。我们使用这个数据集来训练和评估一个 GradientBoostingClassifier 模型:

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
X, y = load_breast_cancer(return_X_y=True)

Xtrn, Xtst, ytrn, ytst = train_test_split(                          ❶
                             X, y, test_size=0.25, random_state=13)

from sklearn.ensemble import GradientBoostingClassifier
ensemble = GradientBoostingClassifier(max_depth=1,                  ❷
                                      n_estimators=20, 
                                      learning_rate=0.75)
ensemble.fit(Xtrn, ytrn)

❶ 加载数据集并将其分为训练集和测试集

❷ 使用这些学习参数训练梯度提升模型

这个模型表现如何?这个梯度提升分类器达到了 4.9%的测试误差,这相当不错:

ypred = ensemble.predict(Xtst)
err = 1 - accuracy_score(ytst, ypred)
print(err)
0.04895104895104896 

然而,GradientBoostingClassifier 的一个关键限制是其速度;虽然有效,但在大数据集上它确实会相对较慢。实际上,效率瓶颈在于树学习。回想一下,梯度提升在每个迭代中都必须学习一个回归树作为基估计器。对于大数据集,树学习器必须考虑的分割数量变得过于庞大。这导致了基于直方图的梯度提升的出现,其目的是加快基估计器树学习,使梯度提升能够扩展到更大的数据集。

5.2.4 基于直方图的梯度提升

要理解基于直方图的树学习的必要性,我们必须回顾决策树算法是如何学习回归树的。在树学习中,我们自上而下地学习一棵树,一次一个决策节点。标准的方法是通过预排序特征值,枚举所有可能的分割,然后评估所有这些分割以找到最佳的分割。假设我们有 1,000,000(10⁶)个训练示例,每个示例的维度为 100。标准树学习将枚举并评估(大约)10 亿个分割(10⁶ × 100 = 10⁸)以识别决策节点!这显然是不可行的。

一种替代方案是将特征值重新组织成少数几个桶。在这个假设的例子中,假设我们将每个特征列分桶到 100 个桶中。现在,为了找到最佳的分割,我们只需要在 10,000 个分割中进行搜索(100 × 100 = 10⁴),这可以显著加快训练速度!

当然,这意味着我们是在精确度和速度之间进行权衡。然而,在许多(大数据)集中通常存在大量冗余或重复信息,我们通过将数据分桶到更小的桶中来进行压缩。图 5.17 说明了这种权衡。

CH05_F17_Kunapuli

图 5.17 左:一个简单的 1D 回归问题,有 50 个数据点。中:标准树学习评估每个可能的分割,这通过每对数据点之间的线表示。最佳的分割是具有最低分割标准(此处为平方损失)的分割。右:基于直方图的分桶首先将数据放入五个桶中,然后评估每个数据桶之间的分割。同样,最佳的分割是具有最低分割标准(也是平方损失)的分割。

在图 5.17 中,我们对比了标准树学习和基于直方图的树学习的行为。在标准树学习中,每个考虑的分割都是在两个连续的数据点之间(图 5.17,中心);对于 50 个数据点,我们必须评估 49 个分割。

在基于直方图的分割中,我们首先将数据(图 5.17,右)分为五个桶。现在,每个考虑的分割都是在两个连续的数据桶之间;对于五个桶,我们只需要评估四个分割!现在想象一下,如果数据点达到数百万,这将如何扩展。

scikit-learn 0.21 引入了一种名为 HistogramGradientBoostingClassifier 的梯度提升版本,它实现了基于直方图的梯度提升,从而显著提高了训练时间。以下代码片段展示了如何在乳腺癌数据集上训练和评估 HistogramGradientBoostingClassifier:

from sklearn.ensemble import HistGradientBoostingClassifier   

ensemble = HistGradientBoostingClassifier(max_depth=2,        ❶
                                          max_iter=20, 
                                          learning_rate=0.75)
ensemble.fit(Xtrn, ytrn)                                      ❷

ypred = ensemble.predict(Xtst)
err = 1 - accuracy_score(ytst, ypred)
print(err)
0.04195804195804198

❶ 初始化基于直方图的梯度提升分类器

❷ 训练集成

在乳腺癌数据集上,HistGradientBoostingClassifier 实现了 4.2%的测试错误率。scikit-learn 的基于直方图的提升实现本身是受到另一个流行的梯度提升包 LightGBM 的启发。

5.3 LightGBM:梯度提升框架

光梯度提升机器(LightGBM)¹是一个开源的梯度提升框架,最初由微软开发和发布。在其核心,LightGBM 本质上是一种基于直方图的梯度提升方法。然而,它还具有几个建模和算法特性,使其能够处理大规模数据。特别是,LightGBM 提供了以下优势:

  • 算法加速,如基于梯度的单侧采样和独家特征捆绑,这些可以导致更快的训练和更低的内存使用(这些在 5.3.1 节中描述得更详细)

  • 支持大量用于分类、回归和排序的损失函数,以及特定应用的自定义损失函数(参见第 5.3.2 节)

  • 支持并行和 GPU 学习,这使得 LightGBM 能够处理大规模数据集(本书不涉及基于并行/GPU 的机器学习)

我们还将深入探讨如何将 LightGBM 应用于一些实际学习场景以避免过拟合(第 5.4.1 节),以及最终在一个真实世界数据集上的案例研究(第 5.5 节)。当然,在这个有限的空间内不可能详细描述 LightGBM 的所有功能。相反,本节和下一节介绍了 LightGBM,并展示了其在实际环境中的应用和用法。这应该能够帮助你通过其文档进一步深入到 LightGBM 的高级用例。

5.3.1 LightGBM“轻量”的原因是什么?

回想我们之前的讨论,将梯度提升扩展到大型(具有许多训练示例)或高维(具有许多特征)数据集的最大计算瓶颈是树学习,特别是识别回归树基估计器的最佳分割。正如我们在上一节中看到的,基于直方图的梯度提升试图解决这个问题。这对于中等大小的数据集来说效果相当好。然而,如果我们有大量数据点、大量特征或两者兼而有之,直方图桶的构建本身可能很慢。

在本节中,我们将探讨 LightGBM 实现的两个关键概念改进,这些改进在实践中通常会导致训练时间的显著加快。第一个是梯度基于单侧采样(GOSS),旨在减少训练示例的数量,而第二个是独占特征捆绑(EFB),旨在减少特征的数量。

基于梯度的单侧采样

处理大量训练示例的一个众所周知的方法是下采样数据集,即随机采样数据集的一个较小的子集。我们已经在其他集成方法中看到了这种方法的例子,例如粘贴(这是不重复的 bagging;见第二章,第 2.4.1 节)。

随机下采样数据集有两个问题。首先,并非所有示例都同等重要;就像 AdaBoost 一样,一些训练示例的重要性高于其他示例,这取决于它们错误分类的程度。因此,下采样不应丢弃高重要性的训练示例。

其次,采样还应确保包含一定比例的正确分类示例。这对于防止基本学习算法因错误分类示例过多而过度拟合至关重要。

这可以通过使用基于梯度的单侧采样(GOSS)程序智能地下采样数据来解决。简要来说,GOSS 执行以下步骤:

  1. 使用梯度幅度,类似于 AdaBoost,它使用样本权重。记住,梯度表示预测可以改进多少:训练良好的示例具有小的梯度,而训练不足(通常是错误分类或混淆)的示例具有大的梯度。

  2. 选择具有最大梯度的前a%个示例;称这个子集为 top。

  3. 随机采样剩余示例的b%;称这个子集为 rand。

  4. 为两个集合中的示例分配权重:w[top] = 1,w[rand] = (100 – a)/b

  5. 在此采样数据(示例、残差、权重)上训练一个基本回归器。

第 4 步计算出的权重确保了训练不足和训练良好的样本之间有一个良好的平衡。总体而言,这种采样还促进了集成多样性,这最终导致更好的集成。

独占特征捆绑

除了大量的训练示例外,大数据还常常带来非常高的维度挑战,这可能会对直方图构建产生不利影响并减慢整体训练过程。类似于对训练示例进行下采样,如果我们能够对特征也进行下采样,就有可能获得(有时是非常大的)训练速度的提升。这在特征空间稀疏且特征相互排斥时尤其如此。

这种特征空间的一个常见例子是我们对分类变量应用 one-hot 向量化。例如,考虑一个具有 10 个唯一值的分类变量。当进行 one-hot 向量化时,这个变量扩展为 10 个二进制变量,其中只有一个是非零的,其余都是零。这使得对应于这个特征的 10 列非常稀疏。

独立特征捆绑(EFB)的工作方式相反,利用这种稀疏性,旨在将相互排斥的列压缩为一列以减少有效特征的数量。从高层次来看,EFB 执行两个步骤:

  1. 通过测量冲突或两个特征同时非零的次数来识别可以捆绑在一起的特征。这里的直觉是,如果两个特征经常相互排斥,它们是低冲突的,可以捆绑在一起。

  2. 将识别出的低冲突特征合并成一个特征包。这里的想法是在合并非零值时仔细保留信息,这通常是通过向特征值添加偏移量来防止重叠来实现的。

直观来说,这就像有两个特征:通过和失败。由于一个人不能同时通过和失败考试,我们可以将它们合并成一个特征(即,将数据集中的两列合并为一列)。

通过和失败,当然是零冲突特征,永远不会重叠。更常见的是,两个或更多特征可能不是完全零冲突,但与一些小的重叠具有低冲突。在这种情况下,EFB 仍然会将这些特征捆绑在一起,这会将几个数据列压缩为一列!通过这种方式合并特征,EFB 有效地减少了整体特征数量,这通常会使训练速度更快。

5.3.2 使用 LightGBM 进行梯度提升

LightGBM 适用于各种平台,包括 Windows、Linux 和 macOS,它可以从头开始构建或使用 pip 等工具安装。它的使用语法与 scikit-learn 非常相似。

继续使用第 5.2.3 节中的乳腺癌数据集,我们可以使用 LightGBM 训练一个梯度提升模型,如下所示:

from lightgbm import LGBMClassifier
gbm = LGBMClassifier(boosting_type='gbdt', n_estimators=20, max_depth=1)
gbm.fit(Xtrn, ytrn)

在这里,我们实例化一个 LGBMClassifier 实例,并将其设置为训练一个包含 20 个回归树(即,基估计器将是深度为 1 的回归树)的集成。这里的一个重要规范是 boosting_type。LightGBM 可以在四种模式下进行训练:

  • boosting_type='rf'—训练传统的随机森林集成(参见第二章,第 2.3 节)

  • boosting_type='gbdt'—使用传统的梯度提升(参考第 5.2 节)训练集成模型

  • boosting_type='goss'—使用 GOSS(参考第 5.3.1 节)训练集成模型

  • boosting_type='dart'—使用 Dropouts meet Multiple Additive Regression Trees (DART;参考第 5.5 节)训练集成模型

最后三个梯度提升模式基本上是在训练速度和预测性能之间进行权衡,我们将在案例研究中探讨这一点。现在,让我们看看我们刚刚使用 boosting_type='gbdt'训练的模型表现如何:

from sklearn.metrics import accuracy_score
ypred = gbm.predict(Xtst)
accuracy_score(ytst, ypred)
0.9473684210526315

我们第一个 LightGBM 分类器在从乳腺癌数据集中保留的测试集上达到了 94.7%的准确率。现在我们已经熟悉了 LightGBM 的基本功能,让我们看看如何使用 LightGBM 为实际用例训练模型。

5.4 LightGBM 的实际应用

在本节中,我们描述了如何使用 LightGBM 在实际中训练模型。一如既往,这意味着确保 LightGBM 模型具有良好的泛化能力,并且不会过拟合。与 AdaBoost 类似,我们寻求设置学习率(第 5.4.1 节)或采用提前停止(第 5.4.2 节)作为控制过拟合的手段:

  • 学习率—通过选择一个有效的学习率,我们试图控制模型学习的速率,使其不会快速拟合,然后过拟合训练数据。我们可以将这视为一种主动建模方法,其中我们试图确定一个好的训练策略,以便它能够导致一个好的模型。

  • 提前停止—通过实施提前停止,我们试图在观察到模型开始过拟合时立即停止训练。我们可以将这视为一种反应式建模方法,其中我们考虑在认为我们有一个好模型时立即终止训练。

最后,我们还探索了 LightGBM 最强大的功能之一:它对自定义损失函数的支持。回想一下,梯度提升的主要好处之一是它是一个通用过程,广泛适用于许多损失函数。

尽管 LightGBM 支持许多标准损失函数,用于分类、回归和排序,但有时可能需要使用特定应用的损失函数进行训练。在第 5.4.3 节中,我们将看到如何使用 LightGBM 精确地做到这一点。

5.4.1 学习率

在使用梯度提升时,与其他机器学习算法一样,我们可能在训练数据上过拟合。这意味着,虽然我们实现了非常好的训练集性能,但这并没有导致类似的测试集性能。也就是说,我们训练的模型泛化能力不佳。LightGBM,就像 scikit-learn 一样,为我们提供了在过拟合之前控制模型复杂性的手段。

通过交叉验证学习率

LightGBM 允许我们通过 learning_rate 训练参数(一个默认值为 0.1 的正数)来控制学习率。此参数还有几个别名,shrinkage_rate 和 eta,它们是机器学习文献中常用的学习率的术语。尽管所有这些参数都有相同的效果,但必须注意只设置其中一个。

我们如何确定我们问题的有效学习率?与其他任何学习参数一样,我们可以使用交叉验证。回想一下,在前一章中,我们也使用了交叉验证来选择 AdaBoost 的学习率。

LightGBM 与 scikit-learn 有着良好的兼容性,我们可以结合这两个包的相关功能来执行模型学习。在列表 5.4 中,我们结合了 scikit-learn 的 StratifiedKFold 类来将训练数据分割成 10 个训练和验证集。StratifiedKFold 确保我们保留了类别分布,即不同类别在各个折中的比例。一旦设置好 CV 折,我们就可以在这些 10 个折上针对不同选择的学习率(0.1、0.2、...、1.0)进行模型训练和验证。

列表 5.4 LightGBM 和 scikit-learn 的交叉验证

from sklearn.model_selection import StratifiedKFold
import numpy as np

n_learning_rate_steps, n_folds = 10, 10                                    ❶
learning_rates = np.linspace(0.1, 1.0, num=n_learning_rate_steps)

splitter = StratifiedKFold(                                                ❷
               n_splits=n_folds, shuffle=True, random_state=42)

trn_err = np.zeros((n_learning_rate_steps, n_folds))                       ❸
val_err = np.zeros((n_learning_rate_steps, n_folds))                       ❸

for i, rate in enumerate(learning_rates):                                  ❹
    for j, (trn, val) in enumerate(splitter.split(X, y)):
        gbm = LGBMClassifier(boosting_type='gbdt', n_estimators=10,
                             max_depth=1, learning_rate=rate)
        gbm.fit(X[trn, :], y[trn])
        trn_err[i, j] = (1 - accuracy_score(y[trn],                        ❺
                                            gbm.predict(X[trn, :]))) * 100 ❺
        val_err[i, j] = (1 - accuracy_score(y[val],                        ❺
                                            gbm.predict(X[val, :]))) * 100 ❺

trn_err = np.mean(trn_err, axis=1)                                         ❻
val_err = np.mean(val_err, axis=1)                                         ❻

❶ 初始化学习率和交叉验证折数

❷ 将数据分割成训练和验证折

❸ 保存训练和验证误差

❹ 对每个折使用不同学习率训练 LightGBM 分类器

❺ 保存训练和验证误差

❻ 在各个折之间平均训练和验证误差

我们可以在图 5.18 中可视化不同学习率下的训练和验证误差。

CH05_F18_Kunapuli

图 5.18 LightGBM 在乳腺癌数据集的 10 折中的平均训练和验证误差

出乎意料的是,随着学习率的增加,训练误差持续下降,这表明模型首先拟合,然后开始过度拟合训练数据。验证误差没有显示出相同的趋势。它最初下降,然后上升;学习率为 0.4 产生了最低的验证误差。这就是最佳的学习率选择。

LightGBM 的交叉验证

LightGBM 提供了通过名为 cv 的函数执行具有给定参数选择的交叉验证的功能,如下面的列表所示。

列表 5.5 LightGBM 的交叉验证

from lightgbm import cv, Dataset

trn_data = Dataset(Xtrn, label=ytrn)            ❶
params = {'boosting_type': 'gbdt', 'objective': 'cross_entropy',
          'learning_rate': 0.25,                ❷
          'max_depth': 1}          
cv_results = cv(params, trn_data, 
                num_boost_round=100,
                nfold=5,                        ❸
                stratified=True, shuffle=True)

❶ 将数据放入 LightGBM 的“Dataset”对象中

❷ 指定学习参数

❸ 执行 5 折交叉验证,每个折包含 100 个估计器

在列表 5.5 中,我们对 100 个提升轮次进行了 5 折交叉验证(因此最终训练了 100 个基础估计器)。设置 stratified=True 确保我们保留了类别分布,即不同类别在各个折中的比例。设置 shuffle=True 在将数据分割成折之前随机打乱训练数据。

我们可以随着训练的进行来可视化训练目标。在列表 5.5 中,我们通过设置'objective': 'cross_entropy'来优化交叉熵,以训练我们的分类模型。如图 5.19 所示,随着我们将更多的基础估计器添加到我们的顺序集成中,平均 5 折交叉熵目标降低。

CH05_F19_Kunapuli

图 5.19 随着迭代次数的增加,跨折平均交叉熵降低,因为我们向集成中添加更多的基础估计器。

5.4.2 提前停止

另一种控制过拟合行为的方法是通过提前停止。正如我们在 AdaBoost 中看到的,提前停止的想法非常简单。当我们训练顺序集成时,我们在每次迭代中训练一个基础估计器。这个过程一直持续到我们达到用户指定的集成大小(在 LightGBM 中,有几个别名可以指定这一点:n_estimators、num_trees、num_rounds)。

随着集成中基础估计器数量的增加,集成的复杂性也增加,这最终会导致过拟合。为了避免这种情况,我们采用提前停止;也就是说,我们不在达到集成大小极限之前停止训练模型。我们通过验证集来跟踪过拟合行为。然后,我们训练,直到我们观察到验证性能在预指定的迭代次数内没有改善。

例如,假设我们已经开始训练一个包含 500 个基础估计器的集成,并将提前停止迭代次数设置为 5。这是提前停止的工作原理:在训练过程中,我们密切关注随着集成增长而增长的验证错误,当验证错误在五个迭代窗口或提前停止轮次内没有改善时,我们终止训练。

在 LightGBM 中,如果我们为参数 early_stopping_rounds 指定一个值,我们可以实现提前停止。只要整体验证分数(例如,准确率)在最后的 early_stopping_rounds 内有所改善,LightGBM 将继续训练。然而,如果在 early_stopping_rounds 之后分数没有改善,LightGBM 将终止。

与 AdaBoost 一样,LightGBM 也需要我们明确指定验证集以及提前停止的评分指标。在列表 5.6 中,我们使用接收器操作特征曲线下的面积(AUC)作为评分指标来确定提前停止。

AUC 是分类问题的重要评估指标,可以解释为模型将随机选择的正例排名高于随机选择的负例的概率。因此,较高的 AUC 值更受欢迎,因为这表明模型更具区分性。

列表 5.6 LightGBM 中的提前停止

from sklearn.model_selection import train_test_split
Xtrn, Xval, ytrn, yval = train_test_split(
                             X, y, test_size=0.2,               ❶
                             shuffle=True, random_state=42)

gbm = LGBMClassifier(boosting_type='gbdt', n_estimators=50, 
                     max_depth=1, early_stopping=5)             ❷

gbm.fit(Xtrn, ytrn, 
        eval_set=[(Xval, yval)], eval_metric='auc')             ❸

❶ 将数据分为训练集和验证集

❷ 在经过五轮后,如果验证分数没有变化则执行提前停止

❸ 使用 AUC 作为提前停止的验证评分指标

让我们看看 LightGBM 产生的输出。在列表 5.6 中,我们设置了 n_estimators=50,这意味着每次迭代都会添加一个基础估计器:

Training until validation scores don't improve for 5 rounds
[1]  valid_0's auc: 0.885522      valid_0's binary_logloss: 0.602321
[2]  valid_0's auc: 0.961022      valid_0's binary_logloss: 0.542925
...
[27] valid_0's auc: 0.996069      valid_0's binary_logloss: 0.156152
[28] valid_0's auc: 0.996069      valid_0's binary_logloss: 0.153942
[29] valid_0's auc: 0.996069      valid_0's binary_logloss: 0.15031
[30] valid_0's auc: 0.996069      valid_0's binary_logloss: 0.145113
[31] valid_0's auc: 0.995742      valid_0's binary_logloss: 0.143901
[32] valid_0's auc: 0.996069      valid_0's binary_logloss: 0.139801
Early stopping, best iteration is:
[27] valid_0's auc: 0.996069      valid_0's binary_logloss: 0.156152

首先,观察训练在 32 次迭代后终止,这意味着 LightGBM 确实在训练完整的 50 个基础估计器之前就终止了。接下来,注意最佳迭代是第 27 次,得分为 0.996069(在这种情况下,是 AUC)。

在接下来的 5(early_stopping_rounds)次迭代中,从 28 到 32,LightGBM 观察到添加额外的估计器并没有显著提高验证分数。这触发了提前停止标准,导致 LightGBM 终止,并返回一个包含 32 个基础估计器的集成。

注意:在 LightGBM 的输出中,它报告了两个指标:AUC,我们指定为评估指标,以及二进制逻辑损失,这是其默认评估指标。由于我们针对 AUC 指定了提前停止,即使二进制逻辑损失持续下降,算法也会终止。换句话说,如果我们使用二进制逻辑损失作为评估指标,提前停止不会这么早终止,而会继续进行。在实际情况下,此类指标通常取决于任务,并且应仔细选择,考虑到下游应用。

我们还可视化了不同 early_stopping_rounds 选择下的训练和验证误差以及集成大小。

early_stopping_rounds 的值较小会使 LightGBM 非常“不耐烦”和“激进”,因为它不会等待太久,看看在停止学习之前是否有任何改进。这会导致欠拟合;例如,在图 5.20 中,将 early_stopping_rounds 设置为 1 会导致只有五个基础估计器的集成,几乎不足以正确地拟合训练数据!

CH05_F20_Kunapuli

图 5.20 左:不同 early_stopping_rounds 值下的训练和验证误差。右:不同 early_stopping_rounds 值下的集成大小。

early_stopping_rounds 的值较大会使 LightGBM 过于“被动”,它会等待更长的时间,看看是否有任何改进。early_stopping_rounds 的选择最终取决于你的问题:它有多大,你的性能指标是什么,以及你愿意容忍的模型复杂性。

5.4.3 自定义损失函数

记住,梯度提升法最强大的特性之一是它适用于广泛的损失函数。这意味着我们也可以设计自己的、针对特定问题的损失函数来处理我们数据集和任务的特定属性。也许我们的数据集是不平衡的,这意味着不同的类别有不同的数据量。在这种情况下,我们可能需要高召回率(例如,在医疗诊断中更少的误判)或高精确度(例如,在垃圾邮件检测中更少的误判)。在许多这样的场景中,通常需要设计我们自己的针对特定问题的损失函数。

注意:有关精确度、召回率等评估指标以及回归和排名等其他机器学习任务的指标等更多详细信息,请参阅 Alice Zheng 所著的《评估机器学习模型》(O’Reilly,2015 年)。

在梯度提升法中,特别是 LightGBM 中,一旦我们有一个损失函数,我们就可以快速训练和评估针对我们问题的模型。在本节中,我们将探讨如何使用 LightGBM 来实现一个名为 焦点损失 的自定义损失函数。

焦点损失

焦点损失是为了密集目标检测或图像中大量密集排列的窗口中的目标检测问题而引入的。最终,这样的目标检测任务归结为前景与背景的分类问题,由于通常背景窗口比感兴趣的前景对象多得多,因此这个问题高度不平衡。

焦点损失通常是为具有此类类别不平衡的分类问题设计的,并且非常适合这些问题。它是对经典交叉熵损失的修改,它更关注难以分类的示例,而忽略容易分类的示例。

更正式地说,记住,真实标签和预测标签之间的标准交叉熵损失可以计算如下:

CH05_F20_Kunapuli-eqs-20x

其中 p[pred] 是预测类别 1 的概率,即 prob(y[pred] = 1) = p[pred]。请注意,对于二元分类问题,由于唯一的其他标签是 0,负预测的概率将是 prob(y[pred] = 0) = 1 - p[pred]。

焦点损失在交叉熵损失的每一项中引入了一个 调节因子

CH05_F20_Kunapuli-eqs-23x

调节因子会抑制分类良好的示例的贡献,迫使学习算法专注于分类不良的示例。这种关注的程度由一个用户可控制的参数 γ > 0 决定。为了了解调节是如何工作的,让我们比较 γ = 2 时的交叉熵损失与焦点损失:

  • 分类良好的示例——假设真实标签是 y[true] = 1,具有高预测标签概率 p[pred] = 0.95。交叉熵损失是 L[ce] = -1 ⋅ log0.95 - 0 ⋅ log0.05 = 0.0513,而焦点损失是 L[fo] = -1 ⋅ log0.95 ⋅ 0.05² - 0 ⋅ log0.05 ⋅ 0.95² = 0.0001。因此,焦点损失中的调制因子会降低高置信度分类的损失。

  • 分类不良的示例——假设真实标签是 y[true] = 1,具有低预测标签概率 p[pred] = 0.05。交叉熵损失是 L[ce] = -1 ⋅ log0.05 - 0 ⋅ log0.95 = 2.9957,而焦点损失是 L[fo] = -1 ⋅ log0.05 ⋅ 0.95² - 0 ⋅ log0.95 ⋅ 0.05² = 2.7036。调制因子对这一示例的影响远小于低置信度分类。

这种效果可以在图 5.21 中看到,其中绘制了不同 γ 值的焦点损失。对于更大的 γ 值,分类良好的示例(y = 1 的概率高)具有更低的损失,而分类不良的示例具有更高的损失。

CH05_F21_Kunapuli

图 5.21 对于不同的 γ 值,可视化焦点损失。当 γ = 0 时,恢复原始交叉熵损失。随着 γ 的增加,对应于分类良好的示例的曲线部分变得更长,反映了损失函数对不良分类的关注。

带焦点损失的梯度提升

要使用焦点损失来训练梯度提升决策树(GBDT),我们必须向 LightGBM 提供两个函数:

  • 实际的损失函数本身,它将在学习过程中的函数评估和评分中使用

  • 损失函数的第一导数(梯度)和第二导数(海森矩阵),这些将被用于学习构成基础估计树

LightGBM 使用海森矩阵信息在叶节点进行学习。目前,我们可以暂时忽略这个细节,因为下一章我们将重新讨论它。

列表 5.7 展示了如何定义自定义损失函数。focal_loss 函数是损失本身,实现方式与本节开头定义的完全一致。focal_loss_metric 函数将 focal_loss 转换为 LightGBM 使用的评分指标。

focal_loss_objective 函数返回损失函数的梯度和海森矩阵,供 LightGBM 在树学习中使用。这个函数很不直观地带有“objective”后缀,以与 LightGBM 的用法保持一致,这一点很快就会变得明显。

列表 5.7 定义自定义损失函数

from scipy.misc import derivative

def focal_loss(ytrue, ypred, gamma=2.0):                     ❶
    p = 1 / (1 + np.exp(-ypred))
    loss = -(1 - ytrue) * p**gamma * np.log(1 - p) - \
           ytrue * (1 - p)**gamma * np.log(p)
    return loss

def focal_loss_metric(ytrue, ypred):                         ❷
    return 'focal_loss_metric', np.mean(focal_loss(ytrue, ypred)), False

def focal_loss_objective(ytrue, ypred):
    func = lambda z: focal_loss(ytrue, z)
    grad = derivative(func, ypred, n=1, dx=1e-6)           ❸
    hess = derivative(func, ypred, n=2, dx=1e-6)           ❸
    return grad, hess

❶ 定义焦点损失函数

❷ 返回 LightGBM 兼容评分指标的包装函数

❸ 自动微分计算梯度和海森矩阵导数

必须注意确保损失函数、指标和目标都是向量兼容的;也就是说,它们可以接受类似数组的对象 ytrue 和 ypred 作为输入。在列表 5.7 中,我们使用了 scipy 的导数功能来近似一阶和二阶导数。对于某些损失函数,也有可能通过解析推导并实现一阶和二阶导数。一旦我们定义了我们的自定义损失函数,就可以直接与 LightGBM 一起使用:

gbm_focal_loss = \
    LGBMClassifier(
        objective=focal_loss_objective,                   ❶
        learning_rate=0.25, n_estimators=20,
        max_depth=1)
gbm_focal_loss.fit(Xtrn, ytrn, 
                   eval_set=[(Xval, yval)], 
                   eval_metric=focal_loss_metric)         ❷

from scipy.special import expit                           ❸
probs = expit(gbm_focal_loss.predict(Xval, 
                                     raw_score=True))     ❹
ypred = (probs > 0.5).astype(float)                       ❺

accuracy_score(yval, ypred)
0.9649122807017544

❶ 设置目标以确保 LightGBM 使用焦点损失的梯度进行学习

❷ 设置指标以确保 LightGBM 使用焦点损失进行评估

❸ 从“scipy”导入 sigmoid 函数

❹ 获取原始分数,然后使用 sigmoid 函数计算类别=1 的概率

❺ 将预测转换为 0/1 标签,其中如果概率 > 0.5,则预测类别=1,否则类别=0

带有焦点损失的 GBDT 在乳腺癌数据集上实现了 96.5% 的验证分数。

5.5 案例研究:文档检索

文档检索是从数据库中检索文档以匹配用户查询的任务。例如,一家律师事务所的律师助理可能需要从法律档案中搜索有关先前案例的信息,以建立先例和研究案例法。或者,也许研究生在特定领域的文献综述过程中需要从期刊数据库中搜索文章。你可能也见过许多网站上有一个名为“相关文章”的功能,列出可能与当前阅读的文章相关的文章。在广泛的领域中,有许多这样的文档检索用例,其中用户搜索特定术语,系统必须返回与搜索相关的文档列表。

这个具有挑战性的问题有两个关键组成部分:首先,找到与用户查询匹配的文档,其次,根据对用户的某种相关性概念对文档进行排序。在本案例研究中,问题被设定为一个三分类问题,即根据查询-文档对识别相关性排名/类别(最不相关、中等相关或高度相关)。我们将探讨不同 LightGBM 分类器在此任务上的性能。

5.5.1 LETOR 数据集

我们将用于本案例研究的数据集称为 LEarning TO Rank (LETOR) v4.0,它本身是由一个名为 GOV2 的大型网页语料库创建的。GOV2 数据集 (mng.bz/41aD) 是从 .gov 域提取的大约 2500 万个网页的集合。

LETOR 4.0 数据集 (mng.bz/Q8DR) 是从 GOV2 语料库派生出来的,并由微软研究院免费提供。该集合包含几个数据集,我们将使用最初为 2008 年文本检索会议 (TREC) 的百万查询赛道开发的数据集,具体为 MQ2008.rar。

MQ2008 数据集中的每个训练示例对应一个查询-文档。数据本身是 LIBSVM 格式,本节展示了几个示例。数据集中的每一行都是一个标记的训练示例,格式如下:

<relevance label> qid:<query id> 1:<feature 1 value> 2:<feature 2 value>
3:<feature 3 value> ... 46:<feature 46 value> # meta-information

每个示例都有 46 个特征,这些特征是从查询-文档对中提取的,还有一个相关性标签。特征包括以下内容:

  • 从正文、锚点、标题和 URL 中提取的低级内容特征。这些包括在文本挖掘中常用的特征,如词频、逆文档频率、文档长度以及各种组合。

  • 从正文、锚点和标题中提取的高级内容特征。这些特征使用两个著名的检索系统提取:Okapi BM25 和用于信息检索的语言模型方法(LMIR)。

  • 从超链接中提取的超链接特征,使用了几种工具,如 Google PageRank 及其变体。

  • 包含内容和超链接信息的混合特征。

每个查询-文档示例的标签是一个相关性排名,有三个独特的值:0(最不相关)、1(中等相关)和 2(高度相关)。在我们的案例研究中,这些被视为类别标签,这使得这是一个三分类问题的实例。以下是一些数据示例:

0 qid:10032 1:0.130742 2:0.000000 3:0.333333 4:0.000000 5:0.134276 ...
45:0.750000 46:1.000000 
#docid = GX140-98-13566007 inc = 1 prob = 0.0701303

1 qid:10032 1:0.593640 2:1.000000 3:0.000000 4:0.000000 5:0.600707 ...
45:0.500000 46:0.000000 
#docid = GX256-43-0740276 inc = 0.0136292023050293 prob = 0.400738

2 qid:10032 1:0.056537 2:0.000000 3:0.666667 4:1.000000 5:0.067138 ...
45:0.000000 46:0.076923 
#docid = GX029-35-5894638 inc = 0.0119881192468859 prob = 0.139842

更多详细信息可以在 LETOR 4.0 数据收集提供的文档和参考中找到。我们将用于案例研究的数据集的一部分可在配套的 GitHub 存储库中找到。我们首先加载数据集并将其分为训练集和测试集:

from sklearn.datasets import load_svmlight_file
from sklearn.model_selection import train_test_split

query_data_file = './data/ch05/MQ2008/Querylevelnorm.txt'
X, y = load_svmlight_file(query_data_file)

Xtrn, Xtst, ytrn, ytst = train_test_split(X, y, 
                                          test_size=0.2, random_state=42)

print(Xtrn.shape, Xtst.shape)
(12168, 46) (3043, 46)

我们现在有一个包含 12,000 个示例的训练集和一个包含 3,000 个示例的测试集。

5.5.2 使用 LightGBM 进行文档检索

我们将使用 LightGBM 学习四个模型。这些模型代表了速度和准确度之间的权衡:

  • 随机森林——我们熟悉的并行同质集成随机决策树。这种方法将作为基线方法。

  • 梯度提升决策树(GBDT)——这是梯度提升的标准方法,代表了具有良好泛化性能和训练速度的模型之间的平衡。

  • 基于梯度的单侧采样(GOSS)——这种梯度提升的变体对训练数据进行下采样,非常适合大型数据集;由于下采样,它可能会失去泛化能力,但通常训练速度非常快。

  • Dropouts meets Multiple Additive Regression Trees (DART)——这种变体结合了深度学习中的 dropout 概念,在反向传播迭代过程中随机且临时地丢弃神经单元以减轻过拟合。同样,DART 在梯度拟合迭代过程中随机且临时地丢弃整体集成中的基础估计器以减轻过拟合。DART 通常是 LightGBM 中所有梯度提升选项中最慢的。

我们将使用以下学习超参数使用这四种方法中的每一种来训练模型。具体来说,注意所有模型都是使用多类逻辑损失进行训练的,这是逻辑回归中使用的二进制逻辑损失函数的推广。early_stopping_rounds 的数量设置为 25:

fixed_params = {'early_stopping_rounds': 25, 
                'eval_metric' : 'multi_logloss', 
                'eval_set' : [(Xtst, ytst)],
                'eval_names': ['test set'],
                'verbose': 100}

除了这些所有模型都共有的参数之外,我们还需要确定其他模型特有的超参数,例如学习率(用于控制学习速率)或叶节点数量(用于控制基估计器树的复杂度)。这些超参数使用 scikit-learn 的随机交叉验证模块 RandomizedSearchCV 进行选择。具体来说,我们在各种参数选择的网格上执行 5 折交叉验证;然而,与 GridSearchCV 完全评估所有可能的参数组合不同,RandomizedSearchCV 采样更少的模型组合以加快参数选择:

num_random_iters = 20
num_cv_folds = 5

下面的代码片段用于使用 LightGBM 训练随机森林:

from scipy.stats import randint, uniform
from sklearn.model_selection import RandomizedSearchCV
import lightgbm as lgb

rf_params = {'bagging_fraction': [0.4, 0.5, 0.6, 0.7, 0.8],
             'bagging_freq': [5, 6, 7, 8],
             'num_leaves': randint(5, 50)}

ens = lgb.LGBMClassifier(boosting='rf', n_estimators=1000, 
                         max_depth=-1,
                         random_state=42)
cv = RandomizedSearchCV(estimator=ens, 
                        param_distributions=rf_params, 
                        n_iter=num_random_iters, 
                        cv=num_cv_folds, 
                        refit=True,
                        random_state=42, verbose=True)
cv.fit(Xtrn, ytrn, **fixed_params)

类似地,LightGBM 也使用 boosting='gbdt'、boosting='goss'和 boosting='dart'进行训练,代码与以下类似:

gbdt_params = {'num_leaves': randint(5, 50), 
               'learning_rate': [0.25, 0.5, 1, 2, 4, 8, 16],
               'min_child_samples': randint(100, 500), 
               'min_child_weight': [1e-2, 1e-1, 1, 1e1, 1e2],
               'subsample': uniform(loc=0.2, scale=0.8), 
               'colsample_bytree': uniform(loc=0.4, scale=0.6),
               'reg_alpha': [0, 1e-1, 1, 10, 100],
               'reg_lambda': [0, 1e-1, 1, 10, 100]}

ens = lgb.LGBMClassifier(boosting='gbdt', n_estimators=1000, 
                         max_depth=-1,
                         random_state=42)
cv = RandomizedSearchCV(estimator=ens, 
                        param_distributions=gbdt_params, 
                        n_iter=num_random_iters, 
                        cv=num_cv_folds, 
                        refit=True,
                        random_state=42, verbose=True)

cv.fit(Xtrn, ytrn, **fixed_params)

基于交叉验证的学习参数选择过程探索以下参数的几个不同值:

  • num_leaves,限制叶节点的数量,从而限制基估计器复杂度以控制过拟合

  • min_child_samples 和 min_child_weight,通过大小或 Hessian 值的和限制每个叶节点,以控制过拟合

  • subsample 和 colsample_bytree,分别指定从训练数据中采样训练示例和特征的分数,以加速训练

  • reg_alpha 和 reg_lambda,指定叶节点值的正则化程度,以控制过拟合

  • top_rate 和其他 _rate,GOSS(特别是)的采样率

  • drop_rate,DART(特别是)的 dropout 率

CH05_F22_Kunapuli

图 5.22 使用 LightGBM 训练的所有算法。左:比较随机森林、GBDT、GOSS 和 DART 的测试集准确率;右:比较随机森林、GBDT、GOSS 和 DART 的整体训练时间。GBDT 以 19.71 秒的速度最快,其他方法如所示,比 GBDT 慢。

对于这些方法中的每一个,我们感兴趣的是查看两个性能指标:测试集准确率和整体模型开发时间,这包括参数选择和训练时间。这些在图 5.22 中显示。关键要点如下:

  • GOSS 和 GBDT 的表现相似,包括整体建模时间。这种差异将在数据集越来越大时变得更加明显,特别是那些有数十万个训练示例的数据集。

  • DART 实现了最佳性能。然而,这代价是显著增加的训练时间。例如,DART 的运行时间接近 20 分钟,而随机森林为 3 分钟,GBDT 和 GOSS 不到 30 秒。

  • 注意,LightGBM 支持多 CPU 以及 GPU 处理,这可能会显著提高运行时间。

摘要

  • 梯度下降通常用于最小化损失函数以训练机器学习模型。

  • 残差,即真实标签与模型预测之间的误差,可以用来描述正确分类和错误分类的训练示例。这与 AdaBoost 使用权重的方式类似。

  • 梯度提升结合了梯度下降和提升来学习一个弱学习者的顺序集成。

  • 梯度提升中的弱学习器是训练在训练示例残差上的回归树,并近似梯度。

  • 梯度提升可以应用于分类、回归或排序任务中产生的各种损失函数。

  • 基于直方图的树学习在精确性和效率之间进行权衡,使我们能够非常快速地训练梯度提升模型,并扩展到更大的数据集。

  • 通过智能地采样训练示例(基于梯度的单侧采样,GOSS)或智能地捆绑特征(独家特征捆绑,EFB),可以进一步加快学习速度。

  • LightGBM 是一个强大的、公开可用的梯度提升框架,它结合了 GOSS 和 EFB。

  • 与 AdaBoost 一样,我们可以通过选择有效的学习率或通过提前停止来避免梯度提升中的过拟合。LightGBM 提供了对这两种方法的支持。

  • 除了分类、回归和排序的广泛损失函数之外,LightGBM 还提供了支持,以便在训练中结合我们自己的定制、特定问题的损失函数。


^ (1.) LightGBM 可用于 Python、R 以及许多其他平台。请参阅 LightGBM 安装指南,以获取有关安装的详细说明,请访问 mng.bz/v1K1

6 序列集成:牛顿提升法

本章涵盖

  • 使用牛顿下降法优化训练模型的损失函数

  • 实现并理解牛顿提升法的工作原理

  • 使用正则化损失函数进行学习

  • 介绍 XGBoost 作为牛顿提升法强大框架的引入

  • 使用 XGBoost 避免过拟合

在前两章中,我们看到了构建序列集成的两种方法:在第四章中,我们介绍了一种新的集成方法,称为自适应提升(AdaBoost),它使用权重来识别最被错误分类的示例。在第五章中,我们介绍了一种另一种集成方法,称为梯度提升,它使用梯度(残差)来识别最被错误分类的示例。这两种提升方法背后的基本直觉是在每次迭代中针对最被错误分类的(本质上,表现最差的)示例来提高分类。

在本章中,我们介绍了一种第三种提升方法——牛顿提升法,它结合了 AdaBoost 和梯度提升的优点,并使用加权梯度(或加权残差)来识别最被错误分类的示例。与梯度提升一样,牛顿提升法的框架可以应用于任何损失函数,这意味着任何分类、回归或排序问题都可以使用弱学习器进行提升。除了这种灵活性之外,如 XGBoost 之类的包现在可以并行化扩展牛顿提升法以处理大数据。不出所料,牛顿提升法目前被许多从业者认为是领先的集成方法。

由于牛顿提升法建立在牛顿下降法的基础上,我们本章首先通过牛顿下降法的示例及其如何用于训练机器学习模型(第 6.1 节)来开启本章。第 6.2 节旨在为使用加权残差进行学习提供直观理解,这是牛顿提升法背后的关键直觉。一如既往,我们实现了自己版本的牛顿提升法,以理解它是如何结合梯度下降和提升法来训练序列集成。

第 6.3 节介绍了 XGBoost,这是一个免费且开源的梯度提升和牛顿提升包,它被广泛用于构建和部署现实世界的机器学习应用。在第 6.4 节中,我们看到如何通过早期停止和调整学习率等策略使用 XGBoost 来避免过拟合。最后,在第 6.5 节中,我们将重新使用第五章中关于文档检索的实例研究,以比较 XGBoost 与 LightGBM、其变体和随机森林的性能。

设计牛顿提升法的起源和动机与梯度提升算法类似:损失函数的优化。梯度提升基于的梯度下降是一种一阶优化方法,它在优化过程中使用一阶导数。

牛顿法,或称牛顿下降法,是一种二阶优化方法,因为它同时使用一阶和二阶导数信息来计算牛顿步。当与提升法结合时,我们得到牛顿提升的集成方法。我们本章首先讨论牛顿法如何激发一种强大且广泛使用的集成方法。

6.1 牛顿法求最小值

迭代优化方法,如梯度下降法和牛顿法,在每个迭代步骤中都会进行更新:next = current + (step × direction)。在梯度下降法(图 6.1 左侧)中,仅使用一阶导数信息最多只能构建一个局部线性近似。虽然这给出了下降方向,但不同的步长可以给出截然不同的估计,并可能最终减慢收敛速度。

通过引入二阶导数信息,正如牛顿下降法所做的那样,我们可以构建一个局部二次近似!这个额外信息导致更好的局部近似,从而产生更好的步骤和更快的收敛。

CH06_F01_Kunapuli

图 6.1 比较梯度下降法(左)和牛顿法(右)。梯度下降法仅使用当前解附近的局部一阶信息,这导致对被优化函数的线性近似。不同的步长将导致不同的下一步。牛顿法使用当前解附近的局部一阶和二阶信息,导致对被优化函数的二次(抛物线)近似。这提供了对下一步的更好估计。

注意:本章中描述的方法,即牛顿优化法,是从一个更一般的根查找方法推导出来的,也称为牛顿法。我们经常使用牛顿下降来指牛顿法求最小值。

更正式地说,梯度下降法计算下一个更新为

CH06_F01_Kunapuli-eqs-0x

其中 α[t] 是步长,(-f'(w[t])) 是负梯度,即一阶导数的相反数。牛顿法计算下一个更新为

CH06_F01_Kunapuli-eqs-2x

其中 f''(w[t]) 是二阶导数,步长 α[t] 为 1。

注意:与梯度下降法不同,牛顿下降法计算精确的步骤,不需要步长计算。然而,我们将明确包括步长,原因有两个:(1)使我们能够立即比较和理解梯度下降法和牛顿下降法之间的差异;(2)更重要的是,与牛顿下降法不同,牛顿提升法只能近似步骤,并将需要我们指定一个类似于梯度下降法和梯度提升法的步长。正如我们将看到的,牛顿提升法中的这个步长不过是学习率。

二阶导数和海森矩阵

对于一元函数(即,一个变量的函数),二阶导数的计算很简单:我们只需对函数求导两次。例如,对于函数 f(w) = x⁵,其第一导数是 f’(x) = ∂f/∂x = 5x⁴,而第二导数是 f’’(x) = ∂f/∂x∂y = 20x³。

对于多元函数,或者多个变量的函数,二阶导数的计算稍微复杂一些。这是因为我们现在必须考虑相对于变量对的多元函数的求导。

要看到这一点,考虑一个三变量的函数:f(x,y,z)。这个函数的梯度很容易计算:我们对每个变量 xyz(其中 w.r.t. 是“相对于”的意思)求导:

CH06_F01_Kunapuli-eqs-5x

要计算二阶导数,我们必须再次对梯度的每个元素相对于 xyz 求导。这产生了一个称为 Hessian 的矩阵:

CH06_F01_Kunapuli-eqs-6xa

Hessian 是一个对称矩阵,因为求导的顺序不会改变结果,这意味着

CH06_F01_Kunapuli-eqs-8x

以此类推,对于 f 中的所有变量对。在多元情况下,牛顿法的扩展由

CH06_F01_Kunapuli-eqs-9x

其中 ▽f(w[t]) 是多元函数 f 的梯度向量,而 -▽²(w[t])^(-1) 是 Hessian 矩阵的逆。求逆二阶导数 Hessian 矩阵是相对于除以项 f''(w[t]) 的多元等价。

对于具有许多变量的大型问题,求逆 Hessian 矩阵可能变得相当计算密集,从而减慢整体优化速度。正如我们将在第 6.2 节中看到的,牛顿加速通过为单个示例计算二阶导数来避免求逆 Hessian,从而绕过这个问题。

现在,让我们继续探讨梯度下降法和牛顿法之间的差异。我们回到第 5.1 节中使用的两个例子:简单的 Branin 函数和平方损失函数。我们将使用这些例子来说明梯度下降法和牛顿下降法之间的差异。

6.1.1 牛顿法的一个示例

回想第五章,Branin 函数包含两个变量 (w[1] 和 w[2]),定义为

CH06_F01_Kunapuli-eqs-13x

其中 α = 1,b = 5.1/4π²,c = 5/πr = 6,s = 10,t = 1/8π 是固定的常数。这个函数在图 6.2 中显示,并在椭圆区域的中心有四个最小值点。

CH06_F02_Kunapuli

图 6.2 Branin 函数的表面图(左)和等高线图(右)。我们可以直观地验证该函数有四个最小值,这些最小值是等高线图中的椭圆区域的中心。

我们将从上一节中的梯度下降实现开始,并对其进行修改以实现牛顿法。有两个关键区别:(1) 我们使用梯度和 Hessian 来计算下降方向,即使用一阶和二阶导数信息;(2) 我们省略了步长的计算,即我们假设步长为 1。修改后的伪代码如下所示:

initialize: wold = some initial guess, converged=False
while not converged:
1\. compute the gradient vector g and Hessian matrix H at the current
   estimate, wold
2\. compute the descent direction d = -H-1g 
3\. set step length α = 1
4\. update the solution: c + distance * direction = wold + α ⋅ d
5\. if change between wnew and wold is below some specified tolerance:
   converged=True, so break
6\. wnew = wold, get ready for the next iteration

该伪代码中的关键步骤是步骤 1 和 2,其中下降方向是使用逆 Hessian 矩阵(二阶导数)和梯度(一阶导数)计算的。请注意,与梯度下降一样,牛顿的下降方向是取反的。

步骤 3 包括以明确说明,与梯度下降不同,牛顿法不需要计算步长。相反,步长可以预先设置,就像学习率一样。一旦确定了下降方向,步骤 4 就实现了牛顿更新:w[t+1] = w[t] + (-▽²f(w[t])^(-1)▽f(w[t]))。

在我们计算每个更新后,类似于梯度下降,我们检查收敛性;在这里,我们的收敛性测试是查看 w[t+1] 和 w[t] 之间的距离有多近。如果它们足够接近,我们就终止;如果不,我们继续进行下一次迭代。以下列表实现了牛顿法。

列表 6.1 牛顿下降

import numpy as np
def newton_descent(f, g, h,                                   ❶
                   x_init, max_iter=100, args=()):
    converged = False                                         ❷
    n_iter = 0

    x_old, x_new = np.array(x_init), None
    descent_path = np.full((max_iter + 1, 2), fill_value=np.nan)   
    descent_path[n_iter] = x_old

    while not converged:
        n_iter += 1

        gradient = g(x_old, *args)                            ❸
        hessian = h(x_old, *args)                             ❸

        direction = -np.dot(np.linalg.inv(hessian),           ❹
                            gradient)                         ❹

        distance = 1                                          ❺
        x_new = x_old + distance * direction                  ❻
        descent_path[n_iter] = x_new

        err = np.linalg.norm(x_new - x_old)                   ❼
        if err <= 1e-3 or n_iter >= max_iter:                      
            converged = True                                  ❽

        x_old = x_new                                         ❾

    return x_new, descent_path 

❶ 牛顿下降需要一个函数 f,其梯度 g 和其 Hessian h。

❷ 初始化牛顿下降为未收敛

❸ 计算梯度和 Hessian

❹ 计算牛顿方向

❺ 将步长设置为 1,以简化

❻ 计算更新

❼ 计算与前一次迭代的差异

❽ 如果变化很小或达到最大迭代次数则收敛

❾ 准备下一次迭代

注意,步长被设置为 1,尽管对于牛顿加速,正如我们将看到的,步长将变为学习率。

让我们运行一下我们的牛顿下降实现。我们已经在上一节中实现了 Branin 函数及其梯度。此实现再次显示如下:

def branin(w, a, b, c, r, s, t):
    return a * (w[1] - b * w[0] ** 2 + c * w[0] - r) ** 2 + \
           s * (1 - t) * np.cos(w[0]) + s

def branin_gradient(w, a, b, c, r, s, t):
    return np.array([2 * a * (w[1] - b * w[0] ** 2 + c * w[0] - r) * 
                    (-2 * b * w[0] + c) - s * (1 - t) * np.sin(w[0]),
                    2 * a * (w[1] - b * w[0] ** 2 + c * w[0] - r)])

我们还需要牛顿下降的 Hessian(二阶导数)矩阵。我们可以通过解析微分梯度(一阶导数)向量来计算它:

CH06_F02_Kunapuli-eqs-19xa

这也可以像下面这样实现:

def branin_hessian(w, a, b, c, r, s, t):
    return np.array([[2 * a * (- 2 * b * w[0] + c)** 2 -
                      4 * a * b * (w[1] - b * w[0] ** 2 + c * w[0] - r) - 
                      s * (1 - t) * np.cos(w[0]), 
                      2 * a * (- 2 * b * w[0] + c)],
                     [2 * a * (- 2 * b * w[0] + c), 
                     2 * a]])

与梯度下降一样,牛顿下降(参见表格 6.1)也需要一个初始猜测 x_init。在这里,我们将梯度下降初始化为 w[init] = [2,-5]'. 现在,我们可以调用牛顿下降过程:

a, b, c, r, s, t = 1, 5.1/(4 * np.pi**2), 5/np.pi, 6, 10, 1/(8 * np.pi)
w_init = np.array([2, -5])
w_optimal, w_newton_path = newton_descent(branin, branin_gradient,
                                          branin_hessian, 
                                          w_init, args=(a, b, c, r, s, t))

牛顿下降返回一个最优解 w_optimal(即[3.142, 2.275]')和解决方案路径 w_path。那么牛顿下降与梯度下降相比如何呢?在图 6.3 中,我们同时绘制了两种优化算法的解决方案路径。

这种比较的结果非常引人注目:牛顿下降能够利用 Hessian 矩阵提供的关于函数曲率的额外局部信息,以更直接的方式到达解。相比之下,梯度下降只有一阶梯度信息可以工作,并采取迂回的路径到达相同的解。

CH06_F03_Kunapuli

图 6.3 我们比较了从[2,-5](正方形)开始的牛顿下降和梯度下降的解决方案路径,它们都收敛到局部最小值之一(圆形)。与梯度下降(虚线)相比,牛顿下降(实线)以更直接的方式向局部最小值前进。这是因为牛顿下降在每个更新中使用更具有信息量的二阶局部近似,而梯度下降只使用一阶局部近似。

牛顿下降的性质

我们注意到关于牛顿下降及其与梯度下降相似性的几个重要事项。首先,与梯度下降不同,牛顿法精确地计算下降步长,不需要步长。记住,我们的目的是将牛顿下降扩展到牛顿提升。从这个角度来看,步长可以解释为学习率。

选择一个有效的学习率(例如,像我们在 AdaBoost 或梯度提升中那样使用交叉验证)与选择一个好的步长非常相似。在提升算法中,我们选择学习率来帮助我们避免过拟合,并更好地泛化到测试集和未来的数据,而不是选择学习率来加速收敛。

第二个需要记住的重要点是,像梯度下降一样,牛顿下降也对我们的初始点选择很敏感。不同的初始化会导致牛顿下降收敛到不同的局部最小值。

除了局部最小值之外,更大的问题是我们的初始点选择也可能导致牛顿下降收敛到鞍点。这是所有下降算法面临的问题,如图 6.4 所示。

鞍点模仿局部最小值:在两个位置,函数的梯度都变为零。然而,鞍点并不是真正的局部最小值:鞍形状意味着它在某个方向上向上弯曲,在另一个方向上向下弯曲。这与局部最小值形成对比,局部最小值是碗状的。然而,局部最小值和鞍点都具有零梯度。这意味着下降算法无法区分两者,有时会收敛到鞍点而不是最小值。

CH06_F04_Kunapuli

图 6.4 Branin 函数的鞍点位于两个最小化点之间,就像最小化点一样,它在位置处具有零梯度。这导致所有下降方法都收敛到鞍点。

鞍点和局部最小值的存在取决于被优化的函数,当然。对于我们的目的,大多数常见的损失函数都是凸的且“形状良好”,这意味着我们可以安全地使用牛顿下降和牛顿提升。然而,在创建和使用自定义损失函数时,应小心确保凸性。处理这种非凸损失函数是一个活跃且持续的研究领域。

6.1.2 牛顿下降在训练损失函数中的应用

那么,牛顿下降在机器学习任务中的表现如何?为了看到这一点,我们可以回顾第五章第 5.1.2 节中的简单 2D 分类问题,我们之前已经使用梯度下降训练了一个模型。这个任务是一个二元分类问题,数据生成方式如下所示:

from sklearn.datasets import make_blobs
X, y = make_blobs(n_samples=200, n_features=2, 
                  centers=[[-1.5, -1.5], [1.5, 1.5]])

我们在图 6.5 中可视化这个合成数据集。

CH06_F05_Kunapuli

图 6.5 一个(几乎)线性可分的两类数据集,我们将在此数据集上训练分类器。正例的标签 y = 1,而负例的标签 y = 0。

回想一下,我们想要训练一个线性分类器 hw = w[1]x[1] + w[2]x[2]。这个分类器接受 2D 数据点 x = [x[1],x[2]]' 并返回一个预测。正如第五章第 5.1.2 节中所述,我们将使用平方损失函数来完成这个任务。

线性分类器由权重 w = [w[1],w[2]]' 参数化。当然,这些权重必须通过学习来最小化数据上的某些损失,以实现最佳的训练拟合。

平方损失衡量了真实标签 y[i] 与其对应的预测 hw 之间的误差,如下所示:

CH06_F05_Kunapuli-eqs-20x

在这里,X 是一个 n × d 的数据矩阵,包含 n 个训练示例,每个示例有 d 个特征,而 y 是一个 d × 1 的真实标签向量。右侧的表达式是使用向量矩阵符号表示整个数据集损失的紧凑方式。

对于牛顿下降,我们需要这个损失函数的梯度和 Hessian。这些可以通过对损失函数进行解析微分获得,就像对 Branin 函数做的那样。在向量矩阵符号中,这些也可以紧凑地写成

CH06_F05_Kunapuli-eqs-22x

CH06_F05_Kunapuli-eqs-23x

注意,Hessian 是一个 2 × 2 矩阵。损失函数、其梯度以及 Hessian 的实现如下:

def squared_loss(w, X, y):
    return 0.5 * np.sum((y - np.dot(X, w))**2)

def squared_loss_gradient(w, X, y):
    return -np.dot(X.T, (y - np.dot(X, w)))

def squared_loss_hessian(w, X, y):
    return np.dot(X.T, X)

现在我们已经拥有了损失函数的所有组成部分,我们可以使用牛顿下降法来计算一个最优解,即“学习一个模型”。我们可以将牛顿下降法学习的模型与梯度下降法(我们在第五章中实现的方法)学习的模型进行比较。我们用w = [0,0,0.99]初始化梯度下降法和牛顿下降法:

w_init = np.array([0.0, -0.99])
w_gradient, path_gradient = gradient_descent(squared_loss,
                                             squared_loss_gradient, 
                                             w_init, args=(X, y))
w_newton, path_newton = newton_descent(squared_loss, 
                                       squared_loss_gradient,
                                       squared_loss_hessian, 
                                       w_init, args=(X, y))
print(w_gradient)
[0.13643511 0.13862275]

print(w_newton)
[0.13528094 0.13884772]

我们正在优化的平方损失函数是凸的,并且只有一个最小值。梯度下降法和牛顿下降法本质上学习的是同一个模型,尽管它们在达到阈值 10-3 时就会停止,大约是第三位小数。我们可以很容易地验证这个学习到的模型达到了 99.5%的训练准确率:

ypred = (np.dot(X, w_newton) >= 0).astype(int)
from sklearn.metrics import accuracy_score
accuracy_score(y, ypred)
0.995

虽然梯度下降法和牛顿下降法学习的是同一个模型,但它们到达那里的方式截然不同,如图 6.6 所示。

CH06_F06_Kunapuli

图 6.6 牛顿下降法(实线)与梯度下降法(虚线)的解路径,以及牛顿下降法和梯度下降法产生的模型。梯度下降法需要 20 次迭代来学习这个模型,而牛顿下降法只需要 12 次迭代。

关键要点是牛顿下降法是下降法家族中的一种强大优化方法。因为它在构建下降方向时考虑了局部二阶导数信息(本质上就是曲率),所以它比其他方法更快地收敛到解。

关于优化(或损失)函数形状的额外信息极大地促进了收敛。然而,这也带来了计算成本:随着变量的增加,二阶导数或 Hessian(包含二阶信息)变得越来越难以管理,尤其是当它需要求逆时。

正如我们在下一节中将要看到的,牛顿提升法通过使用带有点 wise 二阶导数的近似来避免计算或求逆整个 Hessian 矩阵,这实际上是在每个训练示例上计算和求逆的二阶导数,从而保持训练效率。

6.2 牛顿提升法:牛顿法 + 提升法

我们通过获得牛顿提升法与梯度提升法差异的直观理解开始深入研究牛顿提升法。我们将对比这两种方法,以确切了解牛顿提升法为每次迭代添加了什么。

6.2.1 直觉:使用加权残差进行学习

与其他提升法一样,牛顿提升法在每次迭代中都学习一个新的弱估计器,以便纠正前一次迭代中犯的错误或错误。AdaBoost 通过给它们分配权重来识别和描述需要关注的误分类示例:错误分类严重的示例被分配更高的权重。在这样加权的示例上训练的弱分类器会在学习过程中更加关注它们。

梯度提升通过残差来表征需要关注的误分类示例。残差简单地是衡量误分类程度的一种方法,其计算方式为损失函数的梯度。

牛顿提升既使用加权残差!牛顿提升中的残差计算方式与梯度提升完全相同:使用损失函数的梯度(一阶导数)。另一方面,权重是通过损失函数的 Hessian 矩阵(二阶导数)来计算的。

牛顿提升是牛顿下降 + 提升法

正如我们在第五章中看到的,每次梯度提升迭代都模拟梯度下降。在迭代 t 时,梯度下降使用损失函数的梯度(▽L(f[t]) = g[t])来更新模型 f[t]:

CH06_F06_Kunapuli-eqs-25x

与直接计算整体梯度 g[t] 不同,梯度提升学习在单个梯度(也是残差)上学习一个弱估计器 (h[t]^(GB))。也就是说,弱估计器在数据及其对应的残差 (x[i] – g**i)^(n)[i=1] 上进行训练。然后,模型按照以下方式更新:

CH06_F06_Kunapuli-eqs-28x

同样地,牛顿提升模拟了牛顿下降。在迭代 t 时,牛顿下降使用损失函数的梯度(▽L(f[t]) = g[t])(与早期的梯度下降完全一样)和损失函数的 Hessian 矩阵(▽²L(f[t]) = He[t])来更新模型 f[t]:

CH06_F06_Kunapuli-eqs-31x

计算 Hessian 矩阵通常可能非常计算量大。牛顿提升通过在单个梯度上学习一个弱估计器来避免计算梯度或 Hessian 的开销。

对于每个训练示例,除了梯度残差外,我们还要结合 Hessian 信息,同时确保我们想要训练的整体弱估计器近似牛顿下降。我们如何做到这一点?

注意到牛顿更新中 Hessian 矩阵被求逆(He[t]^(-1))。对于单个训练示例,二阶(函数)导数将是一个标量(一个数字而不是矩阵)。这意味着项 He[t]^(-1)g[t] 变成了 (g**t)/(He**t);这些是简单的残差 gt,由 Hessian 矩阵加权(g**t)/(He**t)。

因此,对于牛顿提升,我们使用 Hessian 加权的梯度残差(即 (x[i], -(g**i)/(He**i))^(n)[i=1] 来训练一个弱估计器 (h[t]^(NB)),然后,我们可以像梯度提升一样更新我们的集成:

CH06_F06_Kunapuli-eqs-37x

总结来说,牛顿提升使用 Hessian 加权的残差,而梯度提升使用未加权的残差。

汉萨军有什么作用?

那么,这些基于海森矩阵的权重为提升法增加了什么样的额外信息?从数学上讲,海森矩阵或二阶导数对应于曲率或函数的“弯曲程度”。在牛顿提升法中,我们通过每个训练例子x[i]的二阶导数信息来加权梯度:

CH06_F06_Kunapuli-eqs-38x

二阶导数Het 的大值意味着在x[i]处函数的曲率很大。在这些弯曲区域,海森矩阵的权重会降低梯度,这反过来又导致牛顿提升法采取更小、更保守的步骤。

相反,如果二阶导数Het 很小,那么在x[i]处的曲率就很小,这意味着函数相当平坦。在这种情况下,海森矩阵的权重允许牛顿下降法采取更大、更大胆的步骤,以便更快地穿越平坦区域。

因此,二阶导数与一阶导数残差相结合可以非常有效地捕捉“误分类”的概念。让我们通过一个常用的损失函数——逻辑损失函数来观察这一过程,逻辑损失函数衡量了误分类的程度:

CH06_F06_Kunapuli-eqs-39x

逻辑损失与平方损失函数在图 6.7(左侧)中进行了比较。

CH06_F07_Kunapuli

图 6.7 左:逻辑损失与平方损失函数;中:逻辑损失的负梯度及海森矩阵;右:逻辑损失的海森矩阵缩放负梯度

在图 6.7(中央),我们观察逻辑损失函数及其相应的梯度(一阶导数)和海森矩阵(二阶导数)。所有这些都是误分类边界的函数:真实标签(y)和预测(f(x))的乘积。如果yf(x)具有相反的符号,那么我们就有yf(x) < 0。在这种情况下,真实标签与预测标签不匹配,我们有一个误分类。因此,逻辑损失曲线的左侧部分(具有负边界)对应于误分类的例子,并衡量了误分类的程度。同样,逻辑损失曲线的右侧部分(具有正边界)对应于正确分类的例子,其损失接近 0,正如我们所期望的。

二阶导数在其最高值大约在 0 处,这对应于逻辑损失函数的拐点。这并不令人惊讶,因为我们可以看到逻辑损失函数在拐点附近是最弯曲的,而在拐点的左右两侧则是平坦的。

在图 6.7(右侧),我们可以看到加权梯度的效果。对于正确分类的例子(yf(x) > 0),整体梯度和加权梯度都是 0。这意味着这些例子不会参与提升迭代。

另一方面,对于被错误分类的例子(yf(x) < 0),整体加权梯度 (g**i)/(He**i) 会随着错误分类而急剧增加。一般来说,它比未加权的梯度增加得更加陡峭。

现在,我们可以回答 Hessian 的作用问题:它们通过引入局部曲率信息,确保错误分类严重的训练示例获得更高的权重。这如图 6.8 所示。

CH06_F08_Kunapuli

图 6.8 梯度提升法使用的未加权残差(左)与牛顿提升法使用的 Hessian 加权残差(右)的比较。错误分类边缘的正值(yf(x) > 0)表示正确分类。对于错误分类,我们有 yf(x) < 0。对于错误分类严重的例子,Hessian 加权的梯度比未加权的梯度更有效地捕捉这一概念。

训练示例的错误分类越严重,它在图 6.8 中的位置就越靠左。残差的 Hessian 加权确保了更靠左的训练示例将获得更高的权重。这与梯度提升法形成鲜明对比,因为梯度提升法无法像使用未加权残差那样有效地区分训练示例。

总结来说,牛顿提升旨在使用一阶导数(梯度)信息和二阶导数(Hessian)信息,以确保根据错误分类的程度,错误分类的训练示例得到关注。

6.2.2 直觉:使用正则化损失函数进行学习

在继续之前,让我们引入正则化损失函数的概念。正则化损失函数包含一个额外的平滑项和损失函数,使其更加凸形,或像碗一样。

正则化损失函数为学习问题引入了额外的结构,这通常可以稳定并加速由此产生的学习算法。正则化还允许我们控制正在学习的模型复杂度,并提高模型的总体鲁棒性和泛化能力。

实际上,正则化损失函数明确地捕捉了大多数机器学习模型中固有的拟合与复杂度之间的权衡(参见第一章,第 1.3 节)。

正则化损失函数具有以下形式:

CH06_F08_Kunapuli-eqs-41x

正则化项衡量模型的平坦度(“曲率”的相反面):它越被最小化,学习到的模型就越简单。

损失项通过损失函数衡量对训练数据的拟合程度:它越被最小化,对训练数据的拟合就越好。正则化参数 α 在这两个相互竞争的目标之间进行权衡(在第 1.3 节中,这种权衡是通过参数 C 实现的,它实际上是 α 的倒数):

  • α的较大值意味着模型将更多地关注正则化和简洁性,而较少关注训练误差,这导致模型具有更高的训练误差和欠拟合。

  • α的较小值意味着模型将更多地关注训练误差,并学习更复杂的模型,这导致模型具有更低的训练误差,并可能过拟合。

因此,正则化损失函数使我们能够在学习过程中在拟合和复杂度之间进行权衡,最终导致在实践中具有良好泛化能力的模型。

正如我们在第一章第 1.3 节中看到的,在学习过程中有几种方法可以引入正则化并控制模型复杂度。例如,限制树的最大深度或节点数可以防止树过拟合。

另一种常见的方法是通过 L2 正则化,这相当于直接对模型引入惩罚。也就是说,如果我们有一个模型f(x),L2 正则化通过f(x)²对模型引入惩罚:

CH06_F08_Kunapuli-eqs-42x

许多常见机器学习方法的损失函数都可以用这种形式表示。在第五章中,我们实现了对未正则化的平方损失函数的梯度提升算法,如下所示:

CH06_F08_Kunapuli-eqs-43x

在真实标签y和预测标签f(x)之间。在这个设置中,未正则化的损失函数简单地具有正则化参数α = 0.1。

我们已经看到了一个正则化损失函数的例子(第一章第 1.3.2 节),即支持向量机(SVMs),它们使用正则化截断损失函数:

CH06_F08_Kunapuli-eqs-44x

在本章中,我们考虑正则化逻辑损失函数,它在逻辑回归中常用,如下所示:

CH06_F08_Kunapuli-eqs-45x

这增加了标准逻辑损失 log(1 + e^(-yf(x))),并引入了一个正则化项α ⋅ f(x)²。图 6.9 展示了α = 0.1 时的正则化逻辑损失。观察正则化项如何使整体损失函数的轮廓更加弯曲,更像碗形。

正则化参数α在拟合和复杂度之间进行权衡:随着α的增加,正则化效果会增加,使整体表面更加凸起,并忽略损失函数的贡献。因为损失函数影响拟合,过度正则化模型(通过设置高α值)会导致欠拟合。

CH06_F09_Kunapuli

图 6.9 标准逻辑损失函数(左)与正则化逻辑损失函数(右)的比较,后者更弯曲,具有更明确的极小值

正则化逻辑损失函数的梯度和对 Hessian 的计算可以表示为对模型预测(f(x))的一阶和二阶导数:

CH06_F09_Kunapuli-eqs-47x

CH06_F09_Kunapuli-eqs-48x

以下列表实现了计算正则化逻辑损失的函数,参数α的值为 0.1。

列表 6.2 正则化逻辑损失、梯度和 Hessian,λ = 0.1

def log_loss_func(y, F):
    return np.log(1 + np.exp(-y * F)) + 0.1 * F**2

def log_loss_grad(y, F):
    return -y / (1 + np.exp(y * F)) + 0.2 * F

def log_loss_hess(y, F):
    return np.exp(y * F) / (1 + np.exp(y * F))**2 + 0.2

这些函数现在可以用来计算残差和相应的 Hessian 权重,这些是我们将用于牛顿提升所需的。

6.2.3 实现牛顿提升

在本节中,我们将开发我们自己的牛顿提升实现。基本算法可以用以下伪代码概述:

initialize: F = f0, some constant value
for t = 1 to T:
1\. compute first and second derivatives for each example, 

CH06_F09_Kunapuli-eqs-49x

2\. compute the weighted residuals for each example *r**t**i* = -(*g**t**i*/*He**t**i*)
3\. fit a weak decision tree regressor ***h***t(***x***) using the training set (*x**i*, *r**t**i*)*n**i*=1
4\. compute the step length (αt) using line search
5\. update the model: *F*t+1 = *F*t + *α*t*h*t(***x***)

毫不奇怪,这个训练过程与梯度提升相同,唯一的区别在于步骤 1 和 2 中计算 Hessian 加权的残差。因为梯度和牛顿提升的通用算法框架是相同的,我们可以将它们合并并一起实现。以下列表扩展了列表 5.2 以包含牛顿提升,它使用以下标志仅用于训练:use_Newton=True。

列表 6.3 正则化逻辑损失的牛顿提升

from sklearn.tree import DecisionTreeRegressor
from scipy.optimize import minimize_scalar

def fit_gradient_boosting(X, y, n_estimators=10, use_newton=True):
    n_samples, n_features = X.shape                                 ❶
    estimators = []                                                 ❷
    F = np.full((n_samples, ), 0.0)                                 ❸

    for t in range(n_estimators):
        if use_newton:                                              ❹
            residuals = -log_loss_grad(y, F) / log_loss_hess(y, F)
        else:
            residuals = -log_loss_grad(y, F)                        ❺

        h = DecisionTreeRegressor(max_depth=1)
        h.fit(X, residuals)                                         ❻

        hreg = h.predict(X)                                         ❼
        loss = lambda a: \                                          ❽
                   np.linalg.norm(y - (F + a * hreg))**2  
        step = minimize_scalar(
                   loss, method='golden')                           ❾
        a = step.x

        F += a * hreg                                               ❿

        estimators.append((a, h))                                   ⓫

    return estimators

❶ 获取数据集的维度

❷ 初始化一个空的集成

❸ 训练集上的集成预测

❹ 如果是牛顿提升,则计算 Hessian 加权的残差

❺ 否则计算梯度提升的无权残差

❻ 将弱回归树(h[t])拟合到示例和残差

❼ 获取弱学习者的预测,h[t]

❽ 将损失函数设置为线搜索问题

❾ 使用黄金分割搜索找到最佳步长

❿ 更新集成预测

⓫ 更新集成

一旦模型被学习,我们就可以像 AdaBoost 或梯度提升一样进行预测,因为学到的集成是一个顺序集成。以下列表是这些先前介绍的方法使用的相同预测函数,在此重复以方便起见。

列表 6.4 牛顿提升的预测

def predict_gradient_boosting(X, estimators):
    pred = np.zeros((X.shape[0], ))            ❶

    for a, h in estimators:
        pred += a * h.predict(X)               ❷

    y = np.sign(pred)                          ❸

    return y

❶ 将所有预测初始化为 0

❷ 聚合每个回归器的单个预测

❸ 将加权预测转换为-1/1 标签

让我们比较我们实现的梯度提升(来自上一章)和牛顿提升的性能:

from sklearn.datasets import make_moons
X, y = make_moons(n_samples=200, noise=0.15, random_state=13)
y = 2 * y - 1                                                     ❶

from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

Xtrn, Xtst, ytrn, ytst = \                                        ❷
    train_test_split(X, y, test_size=0.25, random_state=11)

estimators_nb = fit_gradient_boosting(Xtrn, ytrn, n_estimators=25,
                                      use_newton=True)            ❸
ypred_nb = predict_gradient_boosting(Xtst, estimators_nb)
print('Newton boosting test error = {0}'.
              format(1 - accuracy_score(ypred_nb, ytst)))

estimators_gb = fit_gradient_boosting(Xtrn, ytrn, n_estimators=25, 
                                      use_newton=False)           ❹
ypred_gb = predict_gradient_boosting(Xtst, estimators_gb)
print('Gradient boosting test error = {0}'.
              format(1 - accuracy_score(ypred_gb, ytst)))

❶ 将训练标签转换为-1/1

❷ 分割为训练集和测试集

❸ 牛顿提升

❹ 梯度提升

我们可以看到,与梯度提升相比,牛顿提升的测试误差约为 8%,而梯度提升达到 12%:

Newton boosting test error = 0.07999999999999996
Gradient boosting test error = 0.12

可视化梯度提升迭代

现在我们有了我们的联合梯度提升和牛顿提升实现(列表 6.3),我们可以比较这两个算法的行为。首先,注意它们都以大致相同的方式训练和增长它们的集成。它们之间的关键区别在于它们用于集成训练的残差:梯度提升直接使用负梯度作为残差,而牛顿提升使用负 Hessian 加权的梯度。

让我们逐步查看前几次迭代,看看 Hessian 加权的效应。在第一次迭代中,梯度提升和牛顿提升都初始化为F(x[i]) = 0。

梯度提升和牛顿提升都使用残差作为衡量错误分类程度的手段,以便在当前迭代中,最被错误分类的训练示例能够获得更多关注。在图 6.10 的第一个迭代中,Hessian 加权的效应立即可见。使用二阶导数信息对残差进行加权增加了两个类之间的分离,使得它们更容易被分类。

CH06_F10_Kunapuli

图 6.10 迭代 1:梯度提升中的负梯度(左)作为残差与牛顿提升中的 Hessian 加权的负梯度(右)作为残差

这种行为也可以在第二次(图 6.11)和第三次(图 6.12)迭代中看到,其中 Hessian 加权使得错误分类的分层更加明显,使得弱学习算法能够构建更有效的弱学习器。

CH06_F11_Kunapuli

图 6.11 迭代 2:梯度提升中的负梯度(左)作为残差与牛顿提升中的 Hessian 加权的负梯度(右)作为残差

CH06_F12_Kunapuli

图 6.12 迭代 3:梯度提升中的负梯度(左)作为残差与牛顿提升中的 Hessian 加权的负梯度(右)作为残差

总结来说,牛顿提升旨在同时使用一阶导数(梯度)信息和二阶导数(Hessian)信息,以确保被错误分类的训练示例根据错误分类的程度获得更多关注。图 6.12 展示了牛顿提升如何在连续迭代中逐步增长集成并降低误差。

我们可以在图 6.13 中观察到牛顿提升分类器在许多迭代中的进展,随着越来越多的基估计器被添加到集成中。

CH06_F13_Kunapuli

图 6.13 Newton boosting 经过 20 次迭代

6.3 XGBoost:牛顿提升框架

XGBoost,或极端梯度提升,是一个开源的梯度提升框架(起源于陈天奇的研究项目)。它在希格斯玻色子机器学习挑战赛中的成功之后,在数据科学竞赛社区中获得了广泛的认可和采用。

XGBoost 已经发展成为一个功能强大的提升框架,它提供了并行化和分布式处理能力,使其能够扩展到非常大的数据集。今天,XGBoost 可用在多种语言中,包括 Python、R 和 C/C++,并且部署在多个数据科学平台,如 Apache Spark 和 H2O。

XGBoost 具有几个关键特性,使其适用于各种领域以及大规模数据:

  • 在正则化损失函数上使用牛顿提升来直接控制构成集成(6.3.1 节)的回归树函数(弱学习器)的复杂性

  • 算法加速,如加权分位数草图,这是基于直方图分割查找算法(LightGBM 使用的)的一种变体,用于更快地训练(6.3.1 节)

  • 支持大量用于分类、回归和排序的损失函数,以及类似 LightGBM 的应用特定自定义损失函数

  • 基于块的系统设计,将数据存储在内存中称为块的小单元中;这允许并行学习、更好的缓存和有效的多线程(这些细节超出了本书的范围)

由于在这个有限的空间中无法详细说明 XGBoost 的所有功能,本节和下一节将介绍 XGBoost、其实际应用中的使用和应用程序。这将使您能够通过其文档进一步深入到 XGBoost 的高级用例。

6.3.1 XGBoost“极端”的原因是什么?

简而言之,XGBoost 之所以极端,是因为它使用了正则化损失函数的牛顿提升、高效的树学习以及可并行化的实现。特别是,XGBoost 的成功在于其提升实现中的概念和算法改进,这些改进是专门针对基于树的学习的。在本节中,我们将重点关注 XGBoost 如何高效地提高基于树的集成模型的鲁棒性和泛化能力。

用于学习的正则化损失函数

在 6.2.2 节中,我们看到了几个以下形式的 L2 正则化损失函数的例子:

CH06_F13_Kunapuli-eqs-52x

如果我们只考虑我们的集成中弱模型的基于树的学习者,还有其他方法可以直接在训练期间控制树的复杂性。XGBoost 通过引入另一个正则化项来限制叶子节点的数量来实现这一点:

CH06_F13_Kunapuli-eqs-53x

这是如何控制树的复杂性的?通过限制叶子节点的数量,这个附加项将迫使树学习训练更浅的树,这反过来又使得树变得更弱和更简单。

XGBoost 以多种方式使用这个正则化目标函数。例如,在树学习期间,XGBoost 不是使用 Gini 标准或熵等评分函数来寻找分割,而是使用之前描述的正则化学习目标。因此,这个标准用于确定单个树的结构,这些树是集成中的弱学习器。

XGBoost 也使用这个目标函数来计算叶子节点的值,这些值本质上就是梯度提升法聚合的回归值。因此,这个标准也被用来确定单个树的参数

在我们继续之前的一个重要注意事项:额外的正则化项允许直接控制模型复杂度和下游泛化。然而,这也有代价,我们现在有一个额外的参数 γ 需要关注。由于 γ 是用户定义的参数,我们必须设置此值,以及 α 和许多其他参数。这些通常需要通过交叉验证来选择,可能会增加整体模型开发的时间和努力。

基于加权分位数的牛顿提升

即使有正则化的学习目标,最大的计算瓶颈在于将学习扩展到大型数据集,特别是识别用于回归树基估计器学习过程中的最佳分割。

树学习的标准方法会穷举数据中所有可能的分割。正如我们在第五章第 5.2.4 节中看到的,对于大型数据集来说这不是一个好主意。有效的改进,如基于直方图的分割,将数据分箱,从而评估远少于分割的数量。

如 LightGBM 这样的实现进一步集成了改进,例如采样和特征捆绑,以加快树学习。XGBoost 也旨在将其概念引入其实现中。然而,XGBoost 有一个独特的考虑因素。例如,LightGBM 实现了梯度提升,而 XGBoost 实现了牛顿提升。这意味着 XGBoost 的树学习必须考虑 Hessian 权重的训练示例,而 LightGBM 中所有示例都是等权重的!

XGBoost 的近似分割查找算法,加权分位数草图,旨在使用特征中的分位数来找到理想的分割点。这与基于直方图的分割类似,后者使用梯度提升算法使用的桶。

加权分位数草图及其实现的细节相当多,由于空间有限,这里无法全部涵盖。然而,以下是我们的一些关键要点:

  • 从概念上讲,XGBoost 也使用近似分割查找算法;这些算法考虑了牛顿提升特有的额外信息(例如,Hessian 权重)。最终,它们与基于直方图的算法类似,旨在对数据进行分箱。与将数据分入均匀大小分箱的其他基于直方图的算法不同,XGBoost 将数据分入特征相关的桶中。最终,XGBoost 通过采用巧妙的分割查找策略,以效率为代价来权衡精确度。

  • 从实现的角度来看,XGBoost 在内存和磁盘上对数据进行预排序和组织。一旦完成,XGBoost 通过缓存访问模式、使用块压缩和数据分块为易于访问的碎片来进一步利用这种组织。这些步骤显著提高了牛顿提升的效率,使其能够扩展到非常大的数据集。

6.3.2 XGBoost 的牛顿提升

我们以乳腺癌数据集开始对 XGBoost 的探索,这是我们过去多次用作教学数据集的数据集:

from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
X, y = load_breast_cancer(return_X_y=True)
Xtrn, Xtst, ytrn, ytst = train_test_split(X, y, test_size=0.2, 
                                          shuffle=True, random_state=42)

注意:XGBoost 适用于 Python、R 和许多平台。有关安装的详细说明,请参阅 XGBoost 安装指南,网址为mng.bz/61eZ

对于 Python 用户,尤其是那些熟悉 scikit-learn 的用户,XGBoost 提供了一个熟悉的接口,该接口设计得看起来和感觉就像 scikit-learn。使用此接口,设置和训练 XGBoost 模型非常容易:

from xgboost import XGBClassifier
ens = XGBClassifier(n_estimators=20, max_depth=1,  
                    objective='binary:logistic')
ens.fit(Xtrn, ytrn)

我们将损失函数设置为逻辑损失,将迭代次数(每次迭代训练 1 个估计器)设置为 20,并将最大树深度设置为 1。这导致了一个由 20 个决策树(深度为 1 的树)组成的集成。

在测试数据上预测标签和评估模型性能同样简单:

from sklearn.metrics import accuracy_score
ypred = ens.predict(Xtst)
accuracy_score(ytst, ypred)
0.9649122807017544

或者,我们可以使用 XGBoost 的本地接口,该接口最初是为了读取 LIBSVM 格式的数据而设计的,这种格式非常适合高效地存储包含大量零的稀疏数据。

在 LIBSVM 格式(在第五章第 5.5.1 节的案例研究中介绍)中,数据文件的每一行都包含一个单独的训练示例,表示如下:

<label> qid:<example id> 1:<feature 1 value> 2:<feature 2 value> ...
k:<feature k value> ... # other information as comments

XGBoost 使用一个名为 DMatrix 的数据对象来将数据和相应的标签组合在一起。DMatrix 对象可以通过直接从文件或其他类似数组的对象中读取数据来创建。在这里,我们创建了两个名为 trn 和 tst 的 DMatrix 对象来表示训练和测试数据矩阵:

import xgboost as xgb
trn = xgb.DMatrix(Xtrn, label=ytrn)
tst = xgb.DMatrix(Xtst, label=ytst)

我们还使用字典设置训练参数,并使用 trn 和参数训练 XGBoost 模型:

params = {'max_depth': 1, 'objective':'binary:logistic'}
ens2 = xgb.train(params, trn, num_boost_round=20)

然而,在使用此模型进行预测时必须小心。使用某些损失函数训练的模型将返回预测概率而不是直接预测。逻辑损失函数就是这样一种情况。

这些预测概率可以通过在 0.5 处阈值化转换为二进制分类标签 0/1。也就是说,所有预测概率≥0.5 的测试示例都被分类为类别 1,其余的都被分类为类别 0:

ypred_proba = ens2.predict(tst)
ypred = (ypred_proba >= 0.5).astype(int)
accuracy_score(ytst, ypred)
0.9649122807017544

最后,XGBoost 支持三种不同的提升方法,可以通过 booster 参数进行设置:

  • booster='gbtree'是默认设置,它通过使用基于树的回归训练的树作为弱学习器实现牛顿提升。

  • booster='gblinear'通过使用线性回归训练的线性函数作为弱学习器实现牛顿提升。

  • booster='dart'使用 Dropouts meet Multiple Additive Regression Trees (DART)训练集成,如第五章第 5.4 节中所述。

我们也可以通过仔细设置训练参数来使用 XGBoost 训练(并行)随机森林集成,以确保训练示例和特征子采样。这通常只在你想使用 XGBoost 的并行和分布式训练架构来显式训练并行集成时才有用。

6.4 XGBoost 的实际应用

在本节中,我们描述了如何使用 XGBoost 在实际中训练模型。与 AdaBoost 和梯度提升一样,我们寻求设置学习率(第 6.4.1 节)或采用提前停止(第 6.4.2 节)作为控制过度拟合的手段,如下所示:

  • 通过选择一个有效的学习率,我们试图控制模型学习的速率,使其不会快速拟合并过度拟合训练数据。我们可以将其视为一种主动建模方法,其中我们试图确定一个好的训练策略,以便它能够导致一个好的模型。

  • 通过实施提前停止,我们试图在观察到模型开始过度拟合时立即停止训练。我们可以将其视为一种反应式建模方法,其中我们考虑在认为我们有一个好模型时立即终止训练。

6.4.1 学习率

回想第 6.1 节,步长与学习率类似,是每个弱学习器对整个集成贡献的度量。学习率允许我们更好地控制集成复杂性的增长速度。因此,在实践中确定我们数据集的最佳学习率至关重要,这样我们就可以避免过度拟合,并在训练后很好地泛化。

通过交叉验证确定学习率

如前所述,XGBoost 提供了一个与 scikit-learn 兼容的接口。本小节展示了我们如何结合这两个包的功能,有效地使用交叉验证进行参数选择。虽然我们在这里使用交叉验证来设置学习率,但交叉验证可以用于选择其他学习参数,如最大树深度、叶子节点数,甚至损失函数特定的参数。

我们结合 scikit-learn 的 StratifiedKFold 类将训练数据分成 10 个训练和验证数据集。StratifiedKFold 确保我们保留类别分布,即不同类别在各个数据集中的比例。

首先,我们初始化我们感兴趣探索的学习率:

import numpy as np
learning_rates = np.concatenate([np.linspace(0.02, 0.1, num=5),
                                 np.linspace(0.2, 1.8, num=9)])
n_learning_rate_steps = len(learning_rates)
print(learning_rates)
[0.02 0.04 0.06 0.08 0.1  0.2  0.4  0.6  0.8  1\.  1.2  1.4  1.6  1.8 ]

接下来,我们设置 StratifiedKFold 将训练数据分成 10 个数据集:

from sklearn.model_selection import StratifiedKFold
n_folds = 10
splitter = StratifiedKFold(n_splits=n_folds, shuffle=True, random_state=42)

在下面的列表中,我们通过在每个 10 个数据集中使用 XGBoost 训练和评估模型来进行交叉验证。

列表 6.5 使用 XGBoost 和 scikit-learn 进行交叉验证

trn_err = np.zeros((n_learning_rate_steps, n_folds))
val_err = np.zeros((n_learning_rate_steps, n_folds))           ❶

for i, rate in enumerate(learning_rates):                      ❷
    for j, (trn, val) in enumerate(splitter.split(X, y)):
        gbm = XGBClassifier(n_estimators=10, max_depth=1,
                            learning_rate=rate, verbosity=0)
        gbm.fit(X[trn, :], y[trn])

        trn_err[i, j] = (1 - accuracy_score(y[trn],            ❸
                                            gbm.predict(X[trn, :]))) * 100
        val_err[i, j] = (1 - accuracy_score(y[val], 
                                            gbm.predict(X[val, :]))) * 100

trn_err = np.mean(trn_err, axis=1)                             ❹
val_err = np.mean(val_err, axis=1)                             ❹

❶ 保存训练和验证误差

❷ 使用不同的学习率在每个数据集上训练 XGBoost 分类器

❸ 保存训练和验证误差

❹ 在数据集的各个数据集间平均训练和验证误差

当应用于乳腺癌数据集(见第 6.3.2 节),我们获得该数据集的平均训练和验证误差。我们在图 6.14 中可视化不同学习率下的这些误差。

CH06_F14_Kunapuli

图 6.14 XGBoost 在乳腺癌数据集的 10 个数据集上的平均训练和验证误差

随着学习率的降低,XGBoost 的性能随着提升过程变得越来越保守并表现出欠拟合行为而下降。当学习率增加时,XGBoost 的性能再次下降,因为提升过程变得越来越激进并表现出过拟合行为。在我们的参数选择中,学习率=1.2 似乎是最好的值,这通常位于 1.0 和 1.5 之间。

XGBoost 的交叉验证

除此之外,交叉验证(CV)还可以用来描述模型性能。在列表 6.6 中,我们使用 XGBoost 内置的 CV 功能来描述随着我们在集成中增加估计器的数量,XGBoost 的性能如何变化。

我们使用 XGBoost.cv 函数执行 10 折交叉验证,如下所示。观察发现,xgb.cv 的调用方式几乎与上一节中的 xgb.fit 相同。

列表 6.6 XGBoost 的交叉验证

import xgboost as xgb
trn = xgb.DMatrix(Xtrn, label=ytrn)
tst = xgb.DMatrix(Xtst, label=ytst)

params = {'learning_rate': 0.25, 'max_depth': 2, 
          'objective': 'binary:logistic'}
cv_results = xgb.cv(params, trn, num_boost_round=60, 
                    nfold=10, metrics={'error'}, seed=42) 

在这个列表中,模型性能通过误差来描述,该误差通过 metrics={'error'}参数传递给 XGBoost.cv,如图 6.15 所示。

CH06_F15_Kunapuli

图 6.15 随着迭代次数的增加,折平均误差逐渐降低,因为我们不断地将更多的基估计器添加到集成中。

从图 6.15 中还可以观察到另一个有趣的观察结果:在约 35 次迭代时,训练和验证性能不再有显著提升。这表明,在此之后延长训练时间不会带来显著的性能提升。这很自然地引出了早期停止的概念,我们之前在 AdaBoost 和梯度提升中已经遇到过。

6.4.2 早期停止

随着集成中基估计器数量的增加,集成的复杂性也增加,这最终会导致过拟合。为了避免这种情况,我们可以在达到集成大小极限之前停止训练模型。

XGBoost 的早期停止与 LightGBM 非常相似,其中我们为参数 early_stopping_rounds 指定一个值。在每次迭代后,集成性能在验证集上评分,该验证集是从训练集中分割出来的,目的是确定一个好的早期停止点。

只要整体分数(例如,准确率)在最后的早期停止轮次(early_ stopping_rounds)之后有所提高,XGBoost 将继续训练。然而,当分数在早期停止轮次之后没有提高时,XGBoost 将终止训练。

以下列表展示了使用 XGBoost 进行早期停止的示例。请注意,train_test_split 用于创建一个独立的验证集,该验证集被 XGBoost 用来确定早期停止点。

列表 6.7 XGBoost 的早期停止

from sklearn.model_selection import train_test_split 
Xtrn, Xval, ytrn, yval = train_test_split(X, y, test_size=0.2,
                                          shuffle=True, random_state=42)
ens = XGBClassifier(n_estimators=50, max_depth=2,  
                    objective='binary:logistic')
ens.fit(Xtrn, ytrn, early_stopping_rounds=5, 
        eval_set=[(Xval, yval)], eval_metric='auc')

上述提前停止的三个关键参数是提前停止轮数和评估集:early_stopping_rounds=5 和 eval_set=[(Xval, yval)],以及评估指标 eval_metric='auc'。有了这些参数,训练在 13 轮后终止,尽管 XGBClassifier 中的 n_estimators 被初始化为 50:

[0] validation_0-auc:0.95480
[1]     validation_0-auc:0.96725
[2]     validation_0-auc:0.96757
[3]     validation_0-auc:0.99017
[4]     validation_0-auc:0.99099
[5]     validation_0-auc:0.99181
[6]     validation_0-auc:0.99410
[7]     validation_0-auc:0.99640
[8]     validation_0-auc:0.99476
[9]     validation_0-auc:0.99148
[10]    validation_0-auc:0.99050
[11]    validation_0-auc:0.99050
[12]    validation_0-auc:0.98985

因此,提前停止可以大大提高训练时间,同时确保模型性能不会过度下降。

6.5 案例研究重述:文档检索

为了结束本章,我们回顾了第五章中的案例研究,该研究解决了文档检索任务,即从数据库中识别和检索文档以匹配用户的查询。在第五章中,我们比较了 LightGBM 中可用的几个梯度提升方法。

在本章中,我们将使用 XGBoost 在文档检索任务上训练牛顿提升模型,并比较 XGBoost 和 LightGBM 的性能。除了这个比较之外,这个案例研究还说明了如何在 XGBoost 中设置随机交叉验证以在大数据集上进行有效的参数选择。

6.5.1 LETOR 数据集

我们使用由微软研究院免费提供的 LEarning TO Rank (LETOR) v4.0 数据集。每个训练示例对应一个查询-文档对,其中包含描述查询、文档以及它们之间匹配的特征。每个训练标签是一个相关性排名:最少相关、适度相关或高度相关。

这个问题被设定为一个三分类问题,即根据一个训练示例(查询-文档对)识别相关性类别(最少、适度或高度相关)。为了方便和一致性,我们将使用 XGBoost 的 scikit-learn 包装器以及 scikit-learn 本身的模块。首先,让我们加载 LETOR 数据集:

from sklearn.datasets import load_svmlight_file
from sklearn.model_selection import train_test_split
import numpy as np

query_data_file = './data/ch05/MQ2008/Querylevelnorm.txt'
X, y = load_svmlight_file(query_data_file)

接下来,让我们将数据集分为训练集和测试集:

Xtrn, Xtst, ytrn, ytst = train_test_split(X, y, 
                                          test_size=0.2, random_state=42)

6.5.2 使用 XGBoost 进行文档检索

由于我们有一个三分类(多分类)问题,我们使用 softmax 损失函数训练了一个基于树的 XGBoost 分类器。Softmax 损失函数是 logistic 损失函数在多分类中的推广,并且在许多多分类学习算法中常用,包括多项式逻辑回归和深度神经网络。

我们将训练的损失函数设置为 objective='multi:softmax',测试的评估函数设置为 eval_metric='merror'。评估函数是一个多分类错误,即从二分类到多分类的 0-1 误分类错误的推广。我们不使用 merror 作为训练目标,因为它不可微,不便于计算梯度和 Hessian:

xgb = XGBClassifier(booster='gbtree', objective='multi:softmax', 
                    eval_metric='merror', use_label_encoder=False, 
                    n_jobs=-1)

我们还设置 n_jobs=-1 以启用 XGBoost 使用所有可用的 CPU 核心,通过并行化加速训练。

与 LightGBM 一样,XGBoost 也需要我们设置几个训练超参数,例如学习率(用于控制学习速率)或叶节点数(用于控制基估计树复杂度)。这些超参数是通过 scikit-learn 的随机 CV 模块 RandomizedSearchCV 来选择的:具体来说,我们在各种参数选择的网格上执行 5 折 CV;然而,与 GridSearchCV 一样,RandomizedSearchCV 并不彻底评估所有可能的参数组合,而是随机采样更少的模型组合以加快参数选择:

num_random_iters = 20
num_cv_folds = 5

我们可以探索一些关键参数的不同值,如下所述:

  • learning_rate — 控制每个树对集成整体贡献的大小

  • max_depth — 限制树深度以加速训练并降低复杂度

  • min_child_weight — 通过 Hessian 值之和限制每个叶节点,以控制过拟合

  • colsample_bytree — 指定从训练数据中采样的特征比例,分别用于加速训练(类似于随机森林或随机子空间中执行的特征子采样)

  • reg_alpha 和 reg_lambda — 指定叶节点值的正则化量,以控制过拟合

以下代码指定了我们感兴趣搜索的参数值的范围,以确定有效的训练参数组合:

from scipy.stats import randint, uniform
xgb_params = {'max_depth': randint(2, 10), 
              'learning_rate': 2**np.linspace(-6, 2, num=5),
              'min_child_weight': [1e-2, 1e-1, 1, 1e1, 1e2],
              'colsample_bytree': uniform(loc=0.4, scale=0.6),
              'reg_alpha': [0, 1e-1, 1, 10, 100],
              'reg_lambda': [0, 1e-1, 1, 10, 100]}

如前所述,这些参数的网格产生了太多的组合,无法有效地评估。因此,我们采用带有 CV 的随机搜索,并随机采样大量更小的参数组合:

cv = RandomizedSearchCV(estimator=xgb, 
                        param_distributions=xgb_params,
                        n_iter=num_random_iters,
                        cv=num_cv_folds,  
                        refit=True,
                        random_state=42, verbose=1)
cv.fit(Xtrn, ytrn, eval_metric='merror', verbose=False)

注意,我们在 RandomizedSearchCV 中设置了 refit=True,这允许使用 RandomizedSearchCV 确定的最佳参数组合训练一个最终模型。

训练后,我们比较了 XGBoost 与第五章第 5.5 节中由 LightGBM 训练的四个模型的表现:

  • 随机森林 — 随机决策树的并行同质集成。

  • 梯度提升决策树 (GBDT) — 这是一种标准的梯度提升方法,代表了具有良好泛化性能和训练速度的模型之间的平衡。

  • 基于梯度的单侧采样 (GOSS) — 这种梯度提升的变体对训练数据进行下采样,非常适合大型数据集。由于下采样,它可能会在泛化方面有所损失,但通常训练速度非常快。

  • Dropouts 与多个加性回归树 (DART) — 这种变体结合了深度学习中的 dropout 概念,其中神经单元在反向传播迭代中随机且临时地被丢弃,以减轻过拟合。DART 通常是在 LightGBM 中所有梯度提升选项中最慢的。

XGBoost 使用正则化损失函数和牛顿提升。相比之下,随机森林集成不使用任何梯度信息,而 GBDT、GOSS 和 DART 使用梯度提升。

如前所述,我们使用测试集准确率(图 6.16,左)和整体模型开发时间(图 6.16,右)来比较所有算法的性能,这包括基于交叉验证的参数选择以及训练时间。

CH06_F16_Kunapuli

图 6.16 左:比较随机森林、GBDT、GOSS 和 DART 的测试集准确率;右:比较随机森林、GBDT、GOSS 和 DART 的整体训练时间(所有使用 LightGBM 训练)

以下是从这次实验中得出的关键结论(见图 6.16):

  • 在训练性能上,XGBoost 的表现与 DART、GOSS 和 GBDT 相当,优于随机森林。在测试集性能上,XGBoost 仅次于 DART。

  • 在训练时间上,XGBoost 的整体模型开发时间明显短于 DART。这表明,在这里需要在额外的性能提升需求和伴随的计算开销之间做出应用相关的权衡。

  • 最后,这些结果取决于建模过程中做出的各种选择,例如学习参数范围和随机化。通过仔细的特征工程、损失函数选择和使用分布式处理以提高效率,可以获得进一步的性能提升。

摘要

  • 牛顿下降法是另一种优化算法,类似于梯度下降法。

  • 牛顿下降法使用二阶(海森)信息来加速优化,与仅使用一阶(梯度)信息的梯度下降法相比。

  • 牛顿提升法结合了牛顿下降法和提升法来训练一个弱学习者的序列集成。

  • 牛顿提升法使用加权残差来表征正确分类和错误分类的训练样本。这与 AdaBoost 使用权重和梯度提升法使用残差的方式类似。

  • 牛顿提升法中的弱学习者是回归树,它们在训练样本的加权残差上训练,并近似牛顿步。

  • 与梯度提升法类似,牛顿提升法可以应用于来自分类、回归或排序任务的广泛损失函数。

  • 优化正则化损失函数有助于控制学习集中弱学习者的复杂性,防止过拟合,并提高泛化能力。

  • XGBoost 是一个强大的、公开可用的基于树的牛顿提升框架,它集成了牛顿提升、高效的分割查找和分布式学习。

  • XGBoost 优化了一个正则化学习目标,该目标由损失函数(用于拟合数据)和两个正则化函数组成:L2 正则化和叶子节点数量。

  • 与 AdaBoost 和梯度提升法一样,我们可以在牛顿提升法中通过选择有效的学习率或通过提前停止来避免过拟合。XGBoost 支持这两种方法。

  • XGBoost 实现了一个称为加权分位数草图的大致分割查找算法,该算法类似于基于直方图的分割查找,但经过调整和优化,以适应高效的牛顿提升。

  • 除了提供用于分类、回归和排序的广泛损失函数外,XGBoost 还支持在训练过程中集成我们自己的定制、特定问题的损失函数。

第三部分:野外的集成:将集成方法适应你的数据

数据科学家的世界是一个充满狂野和危险的领域。我们必须应对不同类型的数据,例如计数、类别和字符串,这些数据中充满了缺失值和噪声。我们被要求为不同类型的任务构建预测模型:二元分类、多类分类、回归和排名。

我们必须谨慎构建我们的机器学习管道并预处理我们的数据,以避免数据泄露。它们必须准确、快速、健壮,并且值得传播(好吧,最后一个可能不是必需的)。经过这一切,我们最终得到的模型可能确实能完成它们被训练的任务,但最终是没有人理解的黑盒。

在本书的最后一部分,你将学习如何应对这些挑战,凭借上一部分书中提供的集成方法武器库,以及一些新的集成方法。这是你从正在训练的集成者到经验丰富的野外数据世界集成者的最后一站。

第七章涵盖了回归任务的集成学习方法,你将学习如何适应不同的集成方法来处理连续和计数标签。

第八章涵盖了具有非数值特征的集成学习方法,你将学习在集成之前或期间如何编码类别和字符串值特征。你还将了解在预处理过程中(有时以其他方式)出现的两个普遍问题——数据泄露和预测偏移——以及它们如何经常干扰我们准确评估模型性能的能力。此外,第八章还介绍了一种名为有序提升的梯度提升变体,以及一个名为 CatBoost 的强大梯度提升包,它与 LightGBM 和 XGBoost 类似。

第九章涵盖了令人兴奋的新领域——可解释人工智能,它寻求创建人类可以理解和信任的模型。虽然本章从集成方法的角度进行介绍,但本章涵盖的许多可解释方法(例如,代理模型、LIME 和 SHAP)可以应用于任何机器学习模型。第九章还介绍了可解释提升机,这是一种专门设计为直接可解释的集成方法类型。

本书这一部分涵盖了集成方法的进阶主题,并基于第二部分的一些关键概念,特别是梯度提升。如有需要,不要犹豫,随时回到第二部分进行复习或参考。

7 使用连续和计数标签进行学习

本章涵盖

  • 机器学习中的回归

  • 回归的损失和似然函数

  • 何时使用不同的损失和似然函数

  • 将并行和顺序集成应用于回归问题

  • 在实际设置中使用集成进行回归

许多现实世界的建模、预测和预测问题最好以回归问题的形式提出和解决。回归有着悠久的历史,早于机器学习的出现,并且长期以来一直是标准统计学家工具箱的一部分。

回归技术在许多领域得到了开发和应用。以下只是几个例子:

  • 天气预报——使用今天的数据预测明天的降水量,包括温度、湿度、云量、风速等

  • 保险分析——根据各种车辆和驾驶员属性,预测一段时间内的汽车保险索赔数量

  • 金融预测——使用历史股票数据和趋势预测股票价格

  • 需求预测——使用历史、人口统计和天气数据预测未来三个月的住宅能源负荷

而第 2-6 章介绍了用于分类问题的集成技术,在本章中,我们将了解如何将集成技术应用于回归问题。

考虑检测欺诈信用卡交易的任务。这是一个分类问题,因为我们旨在区分两种类型的交易:欺诈(例如,具有类别标签 1)和非欺诈(例如,具有类别标签 0)。在分类中,我们想要预测的标签(或目标)是分类的(0,1,……)并代表不同的类别。

另一方面,考虑预测持卡人每月信用卡余额的任务。这是一个回归任务的例子。与分类不同,我们想要预测的标签(或目标)是连续值(例如,$650.35)。

考虑另一个预测持卡人每周使用其卡次数的任务。这也是一个回归任务的例子,尽管存在细微的差别。我们想要预测的标签或目标是计数。我们通常区分连续回归计数回归,因为将计数建模为连续值并不总是有意义的(例如,预测持卡人将使用其卡 7.62 次是什么意思呢?)。

在本章中,我们将了解这些类型的问题以及其他可以用回归建模的问题,以及我们如何训练回归集成。7.1 节正式介绍了回归,展示了常用的回归模型,并解释了如何在广义线性模型(GLM)的单一框架下使用回归来建模连续和计数值标签(甚至分类标签)。7.2 和 7.3 节展示了如何将集成方法应用于回归问题。7.3 节介绍了连续和计数值目标的损失和似然函数,以及何时以及如何使用它们的指南。我们以第 7.4 节的案例研究结束,这次来自需求预测领域。

7.1 回归的简要回顾

本节回顾了回归的术语和背景材料。我们首先从更熟悉和传统的回归框架开始,即连续标签的学习。然后我们将讨论泊松回归,这是一种重要的学习计数标签的技术,以及逻辑回归,这是另一种重要的学习分类标签的技术。

尤其是我们会看到线性、泊松和逻辑回归都是 GLM 框架内的个别变体。我们还将简要回顾两种重要的非线性回归方法——决策树回归和人工神经网络(ANNs),因为它们通常被用作集成方法中的基础估计器或元估计器。

7.1.1 连续标签的线性回归

最基本的回归方法是 线性回归,其中要训练的模型是输入特征的线性、加权组合。

00-kunapuli-ch7-eqs-0xa

线性回归模型 f(x) 以一个示例 x 作为输入,并由特征权重 w 和截距(也称为偏差)w[0] 参数化。该模型通过识别最小化所有 n 个训练示例中真实标签 (y[i]) 和预测标签 (f(x[i])) 之间 均方误差 (MSE) 的权重进行训练。

00-kunapuli-ch7-eqs-1x

均方误差(MSE)不过是(平均)平方损失。由于我们通过最小化损失函数来学习模型,线性回归也被称为你可能熟悉的其他名称:普通最小二乘(OLS)回归。

回想第六章,第 6.2 节(以及第一章),大多数机器学习问题都可以表示为正则化函数和损失函数的组合,其中正则化函数控制模型复杂度,损失函数控制模型拟合度:

00-kunapuli-ch7-eqs-3x

α 当然是权衡拟合和复杂度的正则化参数。它必须由用户确定并设置,通常通过交叉验证(CV)等实践来完成。

优化(特别是,最小化)这个学习目标本质上等同于训练一个模型。从这个角度来看,普通最小二乘回归可以被视为一个 未正则化的学习问题,其中只优化了平方损失函数:

00-kunapuli-ch7-eqs-4x

是否可以使用不同的正则化函数来提出其他线性回归方法?绝对可以,这正是统计界在过去几十年中一直在做的事情。

常见的线性回归方法

让我们通过 scikit-learn 的 linear_model 子包中实现的几个线性回归模型,看看一些常见的线性回归方法在实际中的应用。我们将使用一个合成数据集,其中真实的基础函数由 f(x) = -2.5x + 3.2 给出。这是一个一元函数,或一个变量的函数(对我们来说,一个特征)。在实践中,我们当然通常不知道真实的基础函数。以下代码片段生成一个包含 100 个训练示例的小型、噪声数据集:

import numpy as np
n = 100

rng = np.random.default_rng(seed=42)
X = rng.uniform(low=-4.0, high=4.0, size=(n, 1))     ❶

f = lambda x: -2.5 * x + 3.2                         ❷
y = f(X)                                             ❷
y += rng.normal(scale=0.15 * np.max(y), size=(n, 1)) ❷

❶ 在 NumPy 中创建一个有种子(seeded)的随机数生成器

❷ 根据此(线性)函数生成噪声标签

我们可以在图 7.1 中可视化这个数据集。

CH07_F01_Kunapuli

图 7.1 我们拟合了几个线性回归模型,以拟合一个合成回归问题的数据,这些数据由一维(1D)、噪声函数 f(x) = -2.5x + 3.2 生成,该函数由覆盖在数据点上的线表示。

不同的正则化方法服务于不同的建模需求,可以处理不同类型的数据问题。线性回归模型必须应对的最常见数据问题是 多重共线性

数据中的多重共线性出现时,一个特征依赖于其他特征,即特征之间 相关。例如,在医疗数据中,患者体重和血压通常高度相关。在实践中,这意味着这两个特征几乎传达了 相同信息,并且应该可以通过选择和使用其中之一来训练一个更简单的模型。

为了理解不同正则化方法的影响,我们将使用我们最近生成的单变量数据显式创建一个具有多重共线性(multicollinearity)的数据集。具体来说,我们将创建一个包含两个特征的数据集,其中一个特征依赖于另一个特征:

X = np.concatenate([X, 3*X + 0.25*np.random.uniform(size=(n, 1))], axis=1)

这产生了一个包含两个特征的数据集,其中第二个特征是第一个特征的 3 倍(添加了一些随机噪声以使其更真实)。现在我们有一个二维数据集,其中第二个特征与第一个特征高度相关。与之前一样,我们将数据集分为训练集(75%)和测试集(25%):

from sklearn.model_selection import train_test_split
Xtrn, Xtst, ytrn, ytst = train_test_split(X, y, test_size=0.25,
                                          random_state=42)

现在我们训练四种常用的线性回归模型:

  • OLS 回归,无正则化

  • 岭回归,使用 L2 正则化

  • 最小绝对收缩和选择算子(LASSO),使用 L1 正则化

  • 弹性网络,它结合了 L1 和 L2 正则化

下面的列表初始化并训练所有 4 个模型。

列表 7.1 线性回归模型

from sklearn.linear_model import LinearRegression, Ridge, Lasso, ElasticNet
from sklearn.metrics import mean_squared_error, mean_absolute_error

models = ['OLS Regression', 'Ridge Regression', 'LASSO', 'Elastic Net']
regressors = [LinearRegression(),                           ❶
              Ridge(alpha=0.5),                             ❶
              Lasso(alpha=0.5),                             ❶
              ElasticNet(alpha=0.5, l1_ratio=0.5)]          ❶

for (model, regressor) in zip(models, regressors):
    regressor.fit(Xtrn, ytrn)                               ❷
    ypred = regressor.predict(Xtst)                         ❸
    mse = mean_squared_error(ytst, ypred)                   ❹
    mad = mean_absolute_error(ytst, ypred)                  ❹

    print('{0}\'s test set performance: MSE = {1:4.3f}, MAD={2:4.3f}'.
          format(model, mse, mad))
    print('{0} model: {1} * x + {2}\n'.                     ❺
          format(model, regressor.coef_, regressor.intercept_))

❶ 初始化四个常见的线性回归模型

❷ 训练回归模型

❸ 在测试集上获取预测

❹ 使用 MSE 和 MAD 计算测试误差

❺ 打印回归权重

未正则化的 OLS 模型将作为我们比较其他模型的基准:

OLS Regression's test set performance: MSE = 2.786, MAD=1.300
OLS Regression model: [[-1.46397043 -0.32220113]] * x + [3.3541317]

我们将使用两个指标来评估每个模型的表现:均方误差(MSE)和平均绝对偏差(MAD)。该模型具有 MSE 2.786 和 MAD 1.3。下一个线性回归模型,岭回归,使用 L2 正则化,即权重的平方和,

CH07_F01_Kunapuli-eqs-5x

那么,L2 正则化做什么呢?学习涉及最小化学习目标;当正则化项或平方和最小化时,它将单个权重推到零。这被称为模型权重的 收缩,它减少了模型复杂度。

目标函数中的平方损失项至关重要,因为没有它,我们将训练一个所有权重都为零的退化模型。因此,岭回归模型在复杂性和拟合度之间进行权衡,这种平衡由适当地设置参数 α > 0. 列表 7.1 生成以下岭回归模型(α > 0.5):

Ridge Regression's test set performance: MSE = 2.760, MAD=1.301
Ridge Regression model: [[-0.34200341 -0.69592603]] * x + [3.39572877]

当我们将 L2 正则化的岭回归学习到的权重 [-0.34, -0.7] 与未正则化的 OLS 回归学习到的权重 [-1.46, -0.322] 进行比较时,正则化和由此产生的收缩效应立即显现。

如前所述,另一种流行的线性回归方法是最小绝对收缩和选择算子(LASSO),它与岭回归相当相似,只是它使用 L1 正则化来控制模型复杂度。也就是说,L1 回归的学习目标变为

CH07_F01_Kunapuli-eqs-6x

L1 正则化是权重绝对值的总和,而不是 L2 正则化中的平方和。总体效果与 L2 正则化相似,但 L1 正则化会缩小预测能力较弱的特征的权重。相比之下,L2 正则化会均匀地缩小所有特征的权重。

换句话说,L1 正则化将信息量较小的特征的权重推到零,这使得它非常适合特征选择。L2 正则化将所有特征的权重一起推到零,这使得它非常适合处理相关和协变的特征。

列表 7.1 生成以下 LASSO 模型(α > 0.5):

LASSO's test set performance: MSE = 2.832, MAD=1.304
LASSO model: [-0\.         -0.79809073] * x + [3.41650036]

将 LASSO 模型的权重 [0, -0.798] 与岭回归学习到的权重 [-0.34, -0.7] 进行对比:LASSO 实际上为第一个特征学习了零权重!

我们可以看到,L1 正则化诱导了模型稀疏性。也就是说,LASSO 在学习过程中进行隐式特征选择,以识别构建更简单模型所需的一小组特征,同时保持或提高性能。

换句话说,这个 LASSO 模型只依赖于一个特征,而 OLS 模型需要两个特征。这使得 LASSO 模型比 OLS 模型更简单。虽然这对于这个玩具数据集可能意义不大,但当应用于具有数千个特征的数据库时,这具有显著的扩展性影响。

回想一下,我们的合成数据集是精心构建的,具有两个高度相关的特征。LASSO 已经识别出这一点,确定不需要两者,因此学习了一个零权重,有效地消除了其对最终模型贡献。

我们将要研究的最后一个线性回归模型称为弹性网络,这是一个著名、广泛使用且研究深入的模型。弹性网络回归使用 L1 和 L2 正则化的组合:

CH07_F01_Kunapuli-eqs-7x

L1 和 L2 正则化在整体正则化中的比例由 L1 比率控制,0 ≤ ρ ≤ 1,而参数α > 0 仍然控制整体正则化和损失函数之间的权衡。

L1 比率允许我们调整 L1 和 L2 目标函数的贡献。例如,如果ρ = 0,弹性网络目标函数变为岭回归目标函数。相反,如果ρ = 1,弹性网络目标函数变为 LASSO 目标函数。对于 0 和 1 之间的所有其他值,弹性网络目标函数是岭回归和 LASSO 的某种组合。

列表 7.1 生成了以下弹性网络模型,其中α = 0.5,ρ = 0.5,弹性网络测试集性能为 MSE = 2.824,MAD = 1.304:

Elastic Net model: [-0\.         -0.79928498] * x + [3.41567834]

从结果中我们可以看到,弹性网络模型仍然具有 LASSO 的稀疏性诱导特征(注意第一个学习的权重为零),同时结合了岭回归对数据相关性的鲁棒性(比较岭回归和弹性网络在测试集上的性能)。

表 7.1 总结了几个常见的线性回归模型,所有这些模型都可以转换为之前讨论的平方损失+正则化框架。

表 7.1:四种流行的线性回归方法,它们都使用平方损失函数,但采用不同的正则化方法来提高模型的鲁棒性和稀疏性

模型 损失函数 正则化 备注
OLS 回归 平方损失(y - f(x))² 经典线性回归;在高度相关特征下变得不稳定
岭回归 平方损失(y - f(x))² L2 惩罚 1/2(w[1]²+ ⋅⋅⋅ + w[d]²) 缩小权重以控制模型复杂度,并鼓励对高度相关特征的鲁棒性
LASSO 平方损失(y - f(x))² L1 惩罚|w[1]| + ⋅⋅⋅ + |w[d]| 进一步缩小权重,鼓励稀疏模型,执行隐式特征选择
弹性网络 平方损失(y - f(x))² ρL1 + (1 - ρ)L20 ≤ ρ ≤ 1 两种正则化器的加权组合,以平衡稀疏性和鲁棒性

在模型训练过程中,这些正则化损失函数通常通过梯度下降、牛顿下降或其变体进行优化,如第五章第 5.1 节和第六章第 6.1 节所述。

表 7.1 中的所有线性回归方法都使用平方损失。其他回归方法可以使用不同的损失函数推导出来。我们将在第 7.3 节中看到示例,并在第 7.4 节的案例研究中再次看到。

7.1.2 计数标签的泊松回归

前一节介绍了回归作为一种适合于建模具有连续值目标(标签)问题的机器学习方法。然而,常常存在这样的情况,我们必须开发标签为计数的模型。

例如,在健康信息学中,我们可能想要构建一个模型来预测给定特定患者数据的医生就诊次数(本质上,是计数)。在保险定价中,一个常见问题是建模索赔频率,以预测不同类型保险政策的预期索赔次数。城市规划是另一个例子,我们可能想要为人口普查区域建模不同的计数变量,例如家庭规模、犯罪数量、出生和死亡数量等。在所有这些问题中,我们仍然感兴趣的是构建形式为y = f(x)的回归模型;然而,目标标签y不再是连续值,而是一个计数。

连续值回归模型的假设

一种方法是将计数简单地视为连续值,但这并不总是有效。首先,计数变量的连续值预测并不总是可以有意义地解释。例如,如果我们预测每位患者的就诊次数,那么预测 2.41 次就诊并不是很有帮助,因为它不清楚是两次还是三次。更糟糕的是,连续值预测器甚至可能预测负值,这可能完全没有意义。医生就诊-4.7 次是什么意思?这个讨论表明,连续值和计数值目标意味着完全不同的事情,应该被不同地对待。

首先,让我们看看线性回归如何拟合连续值目标。图 7.2(左)显示了一个(有噪声的)一元数据集,其中连续值标签(y)依赖于单个特征(x)。

线性回归模型假设对于一个输入x,预测误差或残差y = f(x)是按照正态分布分布的。在图 7.2(左)中,我们在数据、标签和线性回归模型(虚线)上叠加了几个这样的正态分布。

简单来说,线性回归试图拟合一个线性模型,使得残差具有正态分布。正态分布,也称为高斯分布,是一种概率分布,或者是对一个(随机)变量可能取的值的分布和形状的数学描述。如图 7.2(右)所示,正态分布是一个连续值分布,对于连续值标签来说是一个合理的选择。

CH07_F02_Kunapuli

图 7.2(左)的线性回归通过假设目标的分布可以通过连续值正态分布(右)来建模,从而拟合连续值目标。更确切地说,线性回归假设对于示例 x 的预测f(x)是按照正态分布分布的。

但对于计数数据呢?在图 7.3 中,我们可视化了图 7.2 中连续值目标的数据集(左)和具有计数值目标的数据集(右)之间的差异。

CH07_F03_Kunapuli

图 7.3(左)和(右)展示了连续值目标(左)和计数值目标(右)之间的差异可视化,这表明线性回归不会很好地工作,因为计数标签的分布(分布和形状)与连续标签的分布相当不同。

我们开始看到连续值标签和计数值标签之间的一些相当明显的差异。直观上,为连续目标设计的回归模型在构建计数值目标的有效模型时会遇到困难。

这是因为针对连续目标的回归模型假设残差具有某种形状:正态分布。正如我们将要看到的,计数值目标并不是正态分布的,但实际上通常遵循泊松分布。由于计数值标签与连续值标签在本质上不同,因此为连续值标签设计的回归方法通常不会很好地适用于计数值标签。

计数值回归模型的新假设

我们能否保持线性回归的一般框架,然后将其扩展以能够处理计数值数据?实际上我们可以,通过一些建模变化:

  • 我们将不得不改变我们将标签(预测目标)与输入特征相连接的方式。线性回归通过线性函数将标签与特征相关联:y = β[0] + β'x。对于计数标签,我们将引入链接函数 g(y) 到模型 g(y) = β[0] + β'x;特别是,我们将使用对数链接函数——log(y) = β[0] + β'x——或者等价地,通过取对数的逆作为 y = e^(β[0]+β'x)。链接函数通常基于两个关键因素来选择:(1)我们认为最适合数据及其行为的潜在概率分布,以及(2)任务和应用相关的考虑。

  • 我们将不得不改变我们对预测 f(x) 分布的假设。线性回归假设连续值标签服从正态分布。对于计数值标签,我们需要泊松分布,这是可以用来模拟计数的几种分布之一。

  • 泊松分布是一个离散概率分布,因此非常适合处理离散的计数值标签,并表达了在固定时间间隔内可能发生多少事件的概率。在这种情况下,对数链接函数是泊松分布和其他具有指数形式的分布的自然选择。

图 7.4 说明了在开发计数值数据的回归模型时需要对数链接函数以及泊松分布:

  • 观察图 7.4(左)中计数标签(y)相对于回归数据(x)的平均(平均)趋势,由虚线表示。直观上看,这是一个温和的指数趋势,展示了特征(x)如何与标签(y)相联系。

  • 观察泊松分布在可视化模型上如何更好地模拟计数(离散)的性质以及它们的分布,比正态分布要好得多。

这些变化后的回归模型使我们能够模拟计数值目标,并适当地称为泊松回归

总结一下,泊松回归仍然使用线性模型来捕捉示例中的各种输入特征的效果。然而,它引入了对数链接函数和泊松分布假设,以有效地对计数标签数据进行建模。

如前所述的泊松回归方法是对普通线性回归的扩展,这意味着它没有正则化。然而,不出所料,我们可以添加不同的正则化项以诱导鲁棒性或稀疏性,正如我们在第 7.1 节中看到的。

CH07_F04_Kunapuli

图 7.4 泊松回归(左)通过假设目标的分布可以通过离散值的泊松分布(右)来模拟,来拟合计数值目标。更精确地说,泊松回归假设示例 x 的预测 f(x) 是按照泊松分布分布的。

scikit-learn 对泊松回归的实现是 sklearn.linear_ 模型子包的一部分。它实现了带有 L2 正则化的泊松回归,其中正则化的效果可以通过 alpha 参数来控制。

因此,超参数 alpha 是正则化参数,类似于岭回归中的正则化参数。设置 alpha=0 会导致模型学习一个未正则化的泊松回归器,就像未正则化的线性回归一样,不能有效地处理特征相关性。

在以下示例中,我们使用 alpha=0.01 调用泊松回归,这训练了一个用于计数标签的回归模型,并且对数据中的特征相关性具有鲁棒性:

from sklearn.linear_model import PoissonRegressor
poiss_reg = PoissonRegressor(alpha=0.01)
poiss_reg.fit(Xtrn, ytrn)
ypred = poiss_reg.predict(Xtst)
mse = mean_squared_error(ytst, ypred)  
mad = mean_absolute_error(ytst, ypred)
print('Poisson regression test set performance: MSE={0:4.3f}, MAD={1:4.3f}'.
      format(mse, mad))

在图 7.4 中的数据上执行此代码片段(请参阅生成此数据的配套 Python 代码),结果如下:

Poisson regression test set performance: MSE = 3.963, MAD=1.594

我们可以在具有计数特征的这个合成数据集上训练岭回归模型。记住,岭回归使用 MSE 作为损失函数,这对于计数变量来说是不合适的,如下所示:

Ridge regression test set performance: MSE = 4.219, MAD=1.610

7.1.3 逻辑回归用于分类标签

在上一节中,我们看到了可以通过适当选择链接函数和目标分布来将线性回归扩展到计数标签。我们还能处理哪些标签类型?这个想法(添加链接函数和引入其他类型的分布)能否扩展到分类标签?分类(或类别)标签用于描述二元分类问题(0 或 1)或多类分类问题(0,1,2)中的类别。

那么,问题来了,我们能否将回归框架应用于分类问题?令人惊讶的是,是的!为了简单起见,让我们关注二元分类,其中标签只能取两个值,0 或 1:

  • 我们将不得不改变如何将目标标签与输入特征链接。对于类别/分类标签,我们使用 logit 链接函数 g(y) = ln(y/1 – y)。因此,我们将学习的模型将是 ln(y/(1 – y)) = β[0] + βx。这种选择可能一开始看起来相当随意,但稍微深入一点就可以消除这种选择的神秘性。

    首先,通过反转 logit 函数,我们得到了标签y与数据x之间的等效链接y = 1/(1+e^(-(β[0]+βx)))。也就是说,y是通过 sigmoid 函数建模的,也称为逻辑函数!因此,在回归模型中使用 logit 链接函数将其转换为逻辑回归,这是一种众所周知的分类算法!

    其次,我们可以将y/(1 – y)视为y : (1 - y)的比率,我们将其解释为 y 属于类别 0 相对于属于类别 1 的概率。这些概率与赌博和投注中提供的概率完全相同。logit 链接函数仅仅是概率的对数,或称为对数概率。这个链接函数本质上提供了类别为 0 或 1 的似然度度量。

  • 线性回归假设连续值标签服从正态分布,泊松回归假设计数值标签服从泊松分布。逻辑回归假设二元类别标签服从伯努利分布。

    伯努利分布,就像泊松分布一样,是另一种离散概率分布。然而,伯努利分布并不是描述事件的数量,而是模型化是/否问题的结果。这非常适合二分类情况,其中我们提出问题:“这个例子属于类别 0 还是类别 1?”

将所有这些放在一起,我们在图 7.5 中将逻辑回归类比为线性回归或泊松回归。

图 7.5(左)显示了一个二元分类数据集,其中数据只有一个特征,目标属于两个类别之一。在这种情况下,二元标签遵循伯努利分布,而 Sigmoid 链接函数(虚线)使我们能够很好地将数据(x)与标签(y)联系起来。图 7.5(右)展示了伯努利分布的更详细视图。

CH07_F05_Kunapuli

图 7.5(左)的逻辑回归通过假设目标值的分布可以由离散值的伯努利分布(右)来建模,拟合 0/1 值的目标。观察类别 0 和类别 1 的预测概率(条形的高度)如何随着数据的变化而变化。

逻辑回归当然是许多不同的分类算法之一,尽管它与回归有密切的联系。这种过渡到分类问题只是为了强调通用回归框架可以处理的各种类型的问题。

7.1.4 广义线性模型

广义线性模型(GLM)框架包括不同组合的链接函数和概率分布(以及许多其他模型),以创建特定问题的回归变体。线性回归、泊松回归、逻辑回归以及许多其他模型都是不同的 GLM 变体。一个(正则化的)GLM 回归模型有四个组成部分:

  • 概率分布(形式上,来自指数分布族)

  • 线性模型 η = β[0] + β'x

  • 链接函数 g(y) = η

  • 正则化函数 R(β)

我们为什么关心 GLM?首先,它们显然是一种酷的建模方法,允许我们在一个统一的框架中处理几种不同类型的回归问题。其次,更重要的是,GLM 通常被用作序列模型中的弱学习器,特别是在许多梯度提升包中,如 XGBoost。第三,最重要的是,GLM 允许我们以原则性的方式思考问题;在实践中,这意味着在数据集分析过程中,当我们开始对标签及其分布有一个良好的感觉时,我们可以看到哪个 GLM 变体最适合当前的问题。

表 7.2 展示了不同的 GLM 变体、链接函数-分布组合以及它们最适合的标签类型。其中一些方法,如 Tweedie 回归,可能对你来说是新的,我们将在第 7.3 节和第 7.4 节中更详细地介绍它们。

表 7.2 不同类型标签的 GLMs

模型 链接函数 分布 标签类型
线性回归 Identityg(y) = y 正态 实数值
Gamma 回归 负逆 g(y) = -(1/y) Gamma 正实数值
Poisson 回归 Log g(y) = log(y) 泊松 计数/发生次数;整数值
逻辑回归 Logit g(y) = (y/(1-y)) 伯努利 0-1;二进制类标签;是/否结果
多类逻辑回归 多类 logit g(y) = (y/(K-y)) 二项式 0-K;多类标签;多选结果
Tweedie 回归 Log g(y) = ln(y) Tweedie 标签中有很多零,目标向右偏斜

最后一种方法,Tweedie 回归,是一种特别重要的 GLM 变体,在农业、保险、天气和许多其他领域的回归建模中得到广泛应用。

7.1.5 非线性回归

与线性回归不同,线性回归中的模型被表示为特征的加权求和,即 f(w) = w[0] + w[1]x[1] + ⋅⋅⋅ + w[d]x[d],而非线性回归中要学习的模型可以由任何特征和特征函数的组合构成。例如,一个由三个特征构成的多项式回归模型可以通过所有可能的特征交互的加权组合来构建:

CH07_F05_Kunapuli-eqs-16x

从建模的角度来看,非线性回归提出了两个挑战:

  • 我们应该使用哪些特征组合? 在前面的例子中,有三个特征,我们有 2³ = 8 种特征组合,每种组合都有自己的权重。一般来说,有d个特征,我们将有 2^d 个特征组合要考虑,以及同样数量的权重要学习。这样做可能会非常耗费计算资源,尤其是在例子中没有包括任何高阶项(例如,x²[2]x[3]),这些高阶项通常也被包括在内以构建非线性模型!

  • 我们应该使用哪些非线性函数? 除了多项式之外,所有类型的函数和组合都是可接受的:三角函数、指数函数、对数函数以及许多其他函数,以及更多的组合。在这个函数空间中全面搜索在计算上是不可行的。

尽管已经提出了许多不同的非线性回归技术,进行了研究和应用,但在现代背景下,两种方法尤其相关:决策树和神经网络。我们将简要讨论这两种方法,尽管我们将更多地关注决策树,因为它们是大多数集成方法的基础。

基于树的方法使用决策树来定义非线性函数的空间以进行探索。在学习过程中,决策树使用与之前描述相同的损失函数进行生长,例如平方损失。每次添加一个新的决策节点时,都会将一个新的特征交互/组合引入树中。

因此,决策树通过损失函数作为评分指标,在学习的贪婪和递归过程中诱导特征组合。随着树的生长,其非线性(或复杂性)也增加。决策树的学习目标可以写成以下形式:

CH07_F05_Kunapuli-eqs-18x

另一方面,ANN 使用神经元层来逐层诱导越来越复杂的特征组合。神经网络的非线性随着网络深度的增加而增加,这直接影响必须学习的网络权重的数量:

CH07_F05_Kunapuli-eqs-19x

scikit-learn 包提供了许多非线性回归方法。让我们快速看一下我们如何为简单问题训练决策树和神经网络回归器。

如前所述,让我们生成一个简单的一元数据集来可视化这两种回归方法。数据是通过 f(x) = e^(-0.5x)sin (1.25πx - 1.414) 生成的,这是数据 x 和连续标签 y 之间真实的潜在非线性关系:

n = 150
X = rng.uniform(low=-1.0, high=5.0, size=(n, 1))
g = lambda x: np.exp(-0.5*x) * np.sin(1.25 * np.pi * x - 1.414)
y = g(X)  # Generate labels according to this nonlinear function
y += rng.normal(scale=0.08 * np.max(y), size=(n, 1))  
y = y.reshape(-1, )

将数据分为训练集和测试集:

Xtrn, Xtst, ytrn, ytst = train_test_split(X, y, test_size=0.25,
                                          random_state=42)

现在,训练一个最大深度为 5 的决策树回归器:

from sklearn.tree import DecisionTreeRegressor
dt = DecisionTreeRegressor(max_depth=5)
dt.fit(Xtrn, ytrn)

ypred_dt = dt.predict(Xtst)
mse = mean_squared_error(ytst, ypred_dt)
mad = mean_absolute_error(ytst, ypred_dt)
print('Decision Tree''s test set performance: MSE = {0:4.3f}, MAD={1:4.3f}'.
      format(mse, mad))

学习到的决策树函数如图 7.6(右)所示。具有一元(单变量)分割函数的决策树学习到轴平行的拟合,这在图中的决策树模型中得到了反映:模型由与 x 轴或 y 轴平行的段组成。

CH07_F06_Kunapuli

图 7.6 左:与数据相关的真实函数(实线)和生成数据样本。右:拟合到这个合成数据集的两个非线性回归模型,决策树和神经网络回归器。

以类似的方式,我们可以训练一个用于回归的 ANN,也称为多层感知器(MLP)回归器:

from sklearn.neural_network import MLPRegressor
ann = MLPRegressor(hidden_layer_sizes=(50, 50, 50), 
                   alpha=0.001, max_iter=1000)
ann.fit(Xtrn, ytrn.reshape(-1, ))
ypred_ann = ann.predict(Xtst)
mse = mean_squared_error(ytst, ypred_ann)
mad = mean_absolute_error(ytst, ypred_ann)

print('Neural Network''s test set performance: MSE = {0:4.3f}, MAD={1:4.3f}'.
      format(mse, mad))

这个神经网络由三个隐藏层组成,每个隐藏层包含 50 个神经元,这些神经元在通过 hidden_layer_sizes=(50, 50, 50)指定网络初始化时被指定。

MLPRegressor 使用分段线性整流函数(relu(x) = max(x,0))作为每个神经元的激活函数。神经网络学习的回归函数如图 7.6(右)所示。由于神经网络激活函数是分段线性的,因此最终学习的神经网络模型是非线性的,尽管由多个线性组件组成(因此是分段的)。比较两个网络的性能,我们发现它们相当相似:

Decision Trees test set performance: MSE = 0.027, MAD=0.131
Neural Networks test set performance: MSE = 0.043, MAD=0.164

最后,回归的集成方法通常训练非线性回归模型(除非有特定的基础估计器选择),这与本小节讨论的类似。

7.2 平行回归集成

在本节中,我们回顾了平行集成,包括同质(第二章)和异质(第三章)集成,并探讨它们如何应用于回归问题。在我们深入探讨之前,让我们先了解一下平行集成的工作原理。图 7.7 说明了通用平行集成,其中基础估计器是回归器。

CH07_F07_Kunapuli

图 7.7 展示了平行集成独立于彼此训练多个基础估计器,然后将它们的预测组合成一个联合集成预测。平行回归集成简单地使用回归算法,如决策树回归作为基础学习算法。

平行集成方法独立于其他组件估计器训练每个组件估计器,这意味着它们可以并行训练。平行集成通常使用强学习器,或高复杂度、高拟合度学习器作为基础学习器。这与通常使用弱学习器,或低复杂度、低拟合度学习器作为基础学习器的顺序集成形成对比。

与所有集成方法一样,组件基础估计器之间的集成多样性是关键。平行集成通过两种方式实现这一点:

  • 同质集成——基础学习算法保持不变,但训练数据被随机子采样以诱导集成多样性。在第 7.2.1 节中,我们探讨了两种这样的方法:随机森林和 Extra Trees。

  • 异质集成——为了多样性而改变基础学习算法,而训练数据保持不变。在第 7.2.2 节和 7.2.3 节中,我们探讨了两种这样的方法:将基础估计器的预测与组合函数(或聚合器)融合,以及通过学习二级估计器(或元估计器)来堆叠基础估计器的预测。

我们关注一个具有连续值标签的问题,称为 AutoMPG,这是一个流行的回归数据集,常被用作评估回归方法的基准。回归任务是预测各种汽车模型的燃油效率或每加仑英里数(MPG)。特征包括各种与发动机相关的属性,如气缸数、排量、马力、重量和加速度。数据集可以从 UCI 机器学习仓库(mng.bz/Y6Yo)以及本书的源代码中获得。

列表 7.2 展示了如何加载数据并将其分为训练集和测试集。列表还包括一个预处理步骤,其中数据被居中和缩放,使得每个特征的平均值为 0,标准差为 1。这一步称为归一化或标准化,确保所有特征都在相同的数值范围内,并提高了下游学习算法的性能。

列出 7.2 加载和预处理 AutoMPG 数据集

import pandas as pd
data = pd.read_csv('./data/ch07/autompg.csv')                        ❶

labels = data.columns.get_loc('MPG') 
features = np.setdiff1d(np.arange(0, len(data.columns), 1), 
                        labels)                                      ❷

from sklearn.model_selection import train_test_split
trn, tst = train_test_split(data, test_size=0.2, 
                            random_state=42)                         ❸

from sklearn.preprocessing import StandardScaler
preprocessor = StandardScaler().fit(trn)                             ❹
trn, tst = preprocessor.transform(trn), preprocessor.transform(tst)

Xtrn, ytrn = trn[:, features], trn[:, labels]                        ❺
Xtst, ytst = tst[:, features], tst[:, labels]

❶ 使用 pandas 加载数据集

❷ 获取标签和特征的列索引

❸ 将数据集分为训练集和测试集

❹ 数据预处理:归一化训练和测试数据以及标签。

❺ 进一步将训练和测试数据分为 Xtrn, Xtst(特征)和 ytrn, ytst(标签)。

我们将使用此数据集作为本节和下一节的运行示例。

7.2.1 随机森林和 Extra Trees

同质并行集成是一些最古老的集成方法,通常是袋装的变体。第二章在分类的背景下介绍了同质集成方法。为了回顾,并行集成方法(如袋装)中的每个基估计器可以独立使用以下步骤进行训练:

  1. 从原始数据集中生成一个自助样本(通过有放回地采样,这意味着一个示例可以被多次采样)。

  2. 将基估计器拟合到自助样本;由于每个自助样本都将不同,基估计器将是多样化的。

我们可以对回归集合采取同样的方法。唯一的区别在于如何聚合单个基估计器的预测。对于分类,我们使用多数投票;对于回归,我们使用平均值(本质上,是平均预测),尽管也可以使用其他方法(例如,中位数)。

注意:袋装中的每个基估计器都是一个完全训练的强估计器;因此,如果袋装集成包含 10 个基回归器,则训练时间将是 10 倍。当然,此训练过程可以并行化到多个 CPU 核心;然而,全功能袋装所需的总体计算资源通常具有威慑力。

由于袋装方法在训练时可能相当计算密集,因此使用了两种重要的基于树和随机化的变体:

  • 随机森林—这本质上是以随机决策树作为基估计器的袋装。换句话说,随机森林执行自助采样以生成训练子集(就像袋装一样),然后使用随机决策树作为基估计器。

    使用修改后的决策树学习算法训练随机决策树,该算法在生长树时引入随机性。具体来说,不是考虑所有特征来识别最佳分裂,而是评估一个随机特征子集以识别最佳的分裂特征。

  • Extra Trees(极端随机树)—这些随机树将随机决策树的想法推向了极致,不仅从特征随机子集中选择分裂变量,还选择分裂阈值。这种极端随机化实际上非常有效,以至于我们可以直接从原始数据集构建极端随机树集合,而无需进行自助采样!

随机化有两个重要且有益的后果。一方面,正如我们所期望的,它提高了训练效率并减少了计算需求。另一方面,它提高了集成多样性!随机森林和 Extra Trees 可以通过修改底层学习算法来适应回归,使其训练回归树进行连续值预测而不是分类树。

与分类树相比,回归树在训练过程中使用不同的分割标准。原则上,任何回归的损失函数都可以用作分割标准。然而,两种常见的分割标准是均方误差(MSE)和平均绝对误差(MAE)。我们将在第 7.3 节中查看其他回归的损失函数。

列表 7.3 展示了我们如何使用 scikit-learn 的 RandomForestRegressor 和 ExtraTreesRegressor 来训练 AutoMPG 数据集的回归集成。每种方法都训练了两个版本:一个使用 MSE 作为训练标准,另一个使用 MAE 作为训练标准。

列表 7.3 随机森林和 Extra Trees 回归

from sklearn.ensemble import RandomForestRegressor, ExtraTreesRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error

ensembles = {                                                              ❶
    'Random Forest MSE': RandomForestRegressor(criterion='squared_error'),
    'Random Forest MAE': RandomForestRegressor(criterion='absolute_error'),
    'ExtraTrees MSE': ExtraTreesRegressor(criterion='squared_error'),
    'ExtraTrees MAE': ExtraTreesRegressor(criterion='absolute_error')} 

results = pd.DataFrame()                                                   ❷
ypred_trn = {}
ypred_tst = {}

for method, ensemble in ensembles.items():    
    ensemble.fit(Xtrn, ytrn)                                               ❸

    ypred_trn[method] = ensemble.predict(Xtrn)                             ❹
    ypred_tst[method] = ensemble.predict(Xtst)

    res = {'Method-Loss': method,                                          ❺
            'Train MSE': mean_squared_error(ytrn, ypred_trn[method]),
            'Train MAE': mean_absolute_error(ytrn, ypred_trn[method]), 
            'Test MSE': mean_squared_error(ytst, ypred_tst[method]),
            'Test MAE': mean_absolute_error(ytst, ypred_tst[method])}

    results = pd.concat([results,   
                         pd.DataFrame.from_dict([res])], ignore_index=True)❻

❶ 初始化集成

❷ 创建数据结构以存储模型预测和评估结果

❸ 训练集成

❹ 获取训练集和测试集上的集成预测

❺ 使用 MAE 和 MSE 评估训练集和测试集的性能

❻ 保存结果

所有模型也使用 MSE 和 MAE 作为评估标准进行评估。这些评估指标被添加到结果变量中:

  Package-Method-Loss  Train MSE  Train MAE  Test MSE  Test MAE
0   Random Forest MSE     0.0176     0.0919    0.0872    0.2061
1   Random Forest MAE     0.0182     0.0964    0.0998    0.2293
2      ExtraTrees MSE     0.0000     0.0000    0.0806    0.2030
3      ExtraTrees MAE     0.0000     0.0000    0.0702    0.1914

在前面的例子中,我们使用了 RandomForestRegressor 和 ExtraTreesRegressor 的默认参数设置。例如,每个训练的集成大小默认为 100,因为 n_estimators 默认为 100。

与任何其他机器学习算法一样,我们必须通过网格搜索或随机搜索来识别最佳模型超参数(例如,n_estimators)。在第 7.4 节中的案例研究中有几个这样的例子。

7.2.2 结合回归模型

另一种经典的集成方法,尤其是在我们拥有不同类型的模型时,就是简单地结合它们的预测。这本质上是最简单的异构并行集成方法之一。

为什么要结合回归模型?在数据探索阶段,尝试不同的机器学习算法是很常见的。这意味着我们通常有多个不同的模型可供集成。例如,在第 7.2.1 节中,我们训练了四个不同的回归模型。因为我们有四个不同模型的预测,我们可以愉快地将它们组合成一个集成预测——但是我们应该使用什么组合函数呢?

  • 对于连续值目标——使用加权平均、中位数、最小值或最大值等组合函数/聚合器。特别是,当结合异构预测且模型之间差异较大时,中位数特别有效。

    例如,如果我们有五个模型组成的集成预测值为[0.29, 0.3, 0.32, 0.35, 0.85],那么大多数模型意见一致,尽管有一个异常值 0.85。这些预测的平均值为 0.42,而中位数为 0.32。因此,中位数倾向于忽略异常值的影响(并且行为类似于多数投票),而平均值则倾向于包含它们。这是因为中位数仅仅是(字面上)中间的值,而平均值是平均值。

  • 对于计数值目标——使用如众数和中位数之类的组合函数/聚合器。我们可以特别将众数视为将多数投票推广到计数。众数仅仅是出现最频繁的答案。

    例如,如果我们有五个模型组成的集成预测值为[12, 15, 15, 15, 16],众数是 15。如果有冲突,在计数相等的情况下,我们可以使用随机选择来打破平局。

列表 7.4 说明了使用四个简单的聚合器对连续值数据进行处理的用法。在这个列表中,我们使用列表 7.3 中训练的四个回归器作为(异构的)基础估计器,我们将组合它们的值:RandomForestRegressor 和 ExtraTreesRegressor,每个都使用 MSE 和 MAE 作为损失函数/分割标准。

列表 7.4 连续值标签的聚合器

import numpy as np
agg_methods = ['Mean', 'Median', 'Max', 'Min']
aggregators = [np.mean, np.median, np.max, np.min]         ❶

results = pd.DataFrame()                                   ❷
ypred_trn_values = np.array(list(ypred_trn.values()))      ❸
ypred_tst_values = np.array(list(ypred_tst.values()))

for method, aggregate in zip(agg_methods, aggregators):
    yagg_trn = aggregate(ypred_trn_values, axis=0)         ❹
    yagg_tst = aggregate(ypred_tst_values, axis=0)

    res = {'Aggregator': method,                           ❺
           'Train MSE': mean_squared_error(ytrn, yagg_trn),
           'Train MAE': mean_absolute_error(ytrn, yagg_trn), 
           'Test MSE': mean_squared_error(ytst, yagg_tst),
           'Test MAE': mean_absolute_error(ytst, yagg_tst)}
    results = pd.concat([results, 
                         pd.DataFrame.from_dict([res])], ignore_index=True)

❶ 连续值预测的不同组合函数

❷ 数据结构模型预测和评估结果

❸ 收集列表 7.3 中训练的四个集成模型的预测结果

❹ 聚合列表 7.3 中训练的四个集成模型的预测结果

❺ 收集并保存结果

再次强调,所有模型也使用 MSE 和 MAE 作为评估标准进行评估。这些评估指标被添加到结果变量中:

  Aggregator  Train MSE  Train MAE  Test MSE  Test MAE
0       Mean     0.0044     0.0466    0.0805    0.2044
1     Median     0.0035     0.0392    0.0809    0.2024
2        Max     0.0091     0.0557    0.0993    0.2247
3        Min     0.0128     0.0541    0.0737    0.1981

7.2.3 堆叠回归模型

结合不同(异构)回归器预测的另一种方式是通过堆叠或元学习。我们不是自己编写一个函数(例如,平均值或中位数),而是训练一个二级模型来学习如何组合基础估计器的预测。这个二级回归器被称为元学习器或元估计器。

元估计器通常是一个非线性模型,可以有效地以非线性方式组合基础估计器的预测。我们为此增加的复杂性所付出的代价是,堆叠往往容易过拟合,尤其是在存在噪声数据的情况下。

为了防止过拟合,堆叠通常与 k 折交叉验证结合使用,这样每个基础估计器就不会在完全相同的数据集上训练。这通常会导致更多样化和鲁棒性,同时降低过拟合的可能性。

在第三章,列表 3.1 中,我们从零开始实现了一个用于分类的堆叠模型。另一种实现方式是使用 scikit-learn 的 StackingClassifier 和 StackingRegressor。这在列表 7.5 中的回归问题中得到了说明。

在这里,我们训练了四种非线性回归器:核岭回归(岭回归的非线性扩展)、支持向量回归、k-最近邻回归和 Extra Trees。我们使用一个人工神经网络(ANN)作为元学习器,这使得我们能够以可学习和高度非线性的方式结合各种异构回归模型的预测。

列表 7.5 堆叠回归模型

from sklearn.ensemble import StackingRegressor
from sklearn.neural_network import MLPRegressor
from sklearn.kernel_ridge import KernelRidge
from sklearn.svm import SVR
from sklearn.tree import DecisionTreeRegressor
from sklearn.neighbors import KNeighborsRegressor
from sklearn.gaussian_process import GaussianProcessRegressor

estimators = \                                                    ❶
    [('Kernel Ridge', KernelRidge(kernel='rbf', gamma=0.1)),   
     ('Support Vector Machine', SVR(kernel='rbf', gamma=0.1)),
     ('K-Nearest Neighbors', KNeighborsRegressor(n_neighbors=3)),
     ('ExtraTrees', ExtraTreesRegressor(criterion='absolute_error'))]

meta_learner = MLPRegressor(hidden_layer_sizes=(50, 50, 50),     
                            max_iter=1000)                        ❷

stack = StackingRegressor(estimators, final_estimator=meta_learner, cv=3)
stack.fit(Xtrn, ytrn)                                             ❸

ypred_trn = stack.predict(Xtrn)                                   ❹
ypred_tst = stack.predict(Xtst)
print('Train MSE = {0:5.4f}, Train MAE = {1:5.4f}\n' \
      'Test MSE = {2:5.4f}, Test MAE = {3:5.4f}'.format(
      mean_squared_error(ytrn, ypred_trn),
      mean_absolute_error(ytrn, ypred_trn),
      mean_squared_error(ytst, ypred_tst),
      mean_absolute_error(ytst, ypred_tst))) 

❶ 初始化第一级(基础)回归器

❷ 初始化第二级(元)回归器

❸ 使用 3 折交叉验证训练堆叠回归器

❹ 计算训练和测试误差

堆叠回归产生以下输出:

Train MSE = 0.0427, Train MAE = 0.1478
Test MSE = 0.0861, Test MAE = 0.2187

应当注意的是,这里使用了各个基础回归器的默认参数。通过有效调整基础估计器模型的超参数,可以进一步提高这种堆叠集成的方法的性能,从而提高每个集成组件以及整个集成的性能。

7.3 用于回归的顺序集成

在本节中,我们回顾顺序集成,特别是梯度提升(使用 LightGBM;参见第五章)和牛顿提升(使用 XGBoost;参见第六章),并探讨它们如何适应回归问题。

这两种方法都非常通用,因为它们可以在广泛的损失函数上训练。这意味着它们可以很容易地适应不同类型的问题设置,允许对连续值和计数值标签进行特定问题的建模。在我们深入探讨如何之前,让我们先了解一下顺序集成的工作原理。图 7.8 说明了具有回归器作为基础估计器的通用顺序集成。与并行集成不同,顺序集成一次只增长一个估计器,连续的估计器旨在改进前一个估计器的预测。

CH07_F08_Kunapuli

图 7.8 与并行集成不同,并行集成在训练基础估计器时是 独立 的,而顺序集成,如提升(boosting),是分阶段训练连续的基础估计器,以识别和最小化前一个基础估计器所犯的错误。

每个连续的基础估计器使用 残差 作为识别当前迭代中需要关注的训练示例的手段。在回归问题中,残差告诉基础估计器模型低估或高估预测的程度(参见图 7.9)。

CH07_F09_Kunapuli

图 7.9 一个线性回归模型及其预测(正方形)拟合到数据集(圆形)。残差是真实标签(y[i])和预测标签 f(x[i])之间 误差 的度量。每个训练示例的残差大小表示拟合误差的程度,而残差的符号表示模型是低估还是高估。

更具体地说,回归残差向基学习器传达了两个重要的信息。对于每个训练示例,残差的幅度可以以直接的方式解释:更大的残差意味着更多的错误。

残差的符号也传达了重要信息。正残差表明当前模型的预测是低估真实值;也就是说,模型必须增加其预测。负残差表明当前模型的预测是高估真实值;也就是说,模型必须减少其预测。

损失函数及其导数允许我们测量当前模型预测与真实标签之间的残差。通过改变损失函数,我们实际上是在改变我们优先考虑不同示例的方式。

梯度提升和牛顿提升都使用浅层回归树作为弱基学习器。弱学习器(与使用强学习器的 bagging 及其变体相对比)本质上是非常低复杂度、低拟合度的模型。通过训练一系列弱学习器来纠正先前学习到的弱学习器的错误,这两种方法都在各个阶段提升了集成性能。

  • 梯度提升——使用损失函数的负梯度作为残差来识别需要关注的训练示例。

  • 牛顿提升——使用损失函数的 Hessian 加权梯度作为残差来识别需要关注的训练示例。损失函数的 Hessian(二阶导数)包含了局部“曲率”信息,以增加具有更高损失值的训练示例的权重。

因此,损失函数是开发有效序列集成的一个关键组成部分。

7.3.1 回归的损失和似然函数

在本节中,我们将探讨不同类型标签(连续值、连续值但为正数和计数值)的一些常见(和不常见)的损失函数。每个损失函数对错误的惩罚方式不同,将导致具有不同特性的学习模型,这与不同正则化函数产生具有不同特性的模型(在 7.1 节中)类似。

许多损失函数最终是从我们如何假设残差分布得来的。我们已经在 7.1 节中看到了这一点,其中我们假设连续值目标的残差可以用高斯分布来建模,计数值目标可以用泊松分布来建模,等等。

在这里,我们正式化这个概念。请注意,一些损失函数没有封闭形式的表达式。在这种情况下,可视化底层分布的负对数是有用的。这个术语称为负对数似然,有时它被优化而不是损失函数,最终在最终模型中具有相同的效果。

我们考虑三种类型的标签及其相应的损失函数。这些在图 7.10 中进行了可视化。

CH07_F10_Kunapuli

图 7.10 三种不同类型目标的损失和 log-似然函数:连续值(左),正连续值(中),和计数值(右)

连续值标签

对于连续值目标,存在几个著名的损失函数。以下是最常见的两个:

  • 平方误差(SE),1/2⋅(yf(x))²——直接对应于假设残差上的高斯分布

  • 绝对误差(AE),|y - f(x)|——对应于假设残差上的拉普拉斯分布

SE 对错误的惩罚远比 AE 严重,如图 7.10 中的损失值在极端情况所示。这使得 SE 对异常值非常敏感。SE 也是一个双可微的损失函数,这意味着我们可以计算一阶和二阶导数。因此,我们可以将其用于梯度提升(使用残差)和牛顿提升(使用 Hessian 提升的残差)。AE 不是双可微的,这意味着它不能用于牛顿提升。

Huber 损失是 SE 和 AE 的混合体,并在某个用户指定的阈值τ之间切换其行为:

CH07_F10_Kunapuli-eqs-21x

对于小于τ的残差,Huber 损失的行为类似于 SE,超过阈值,它表现为缩放后的 AE(参见图 7.10)。这使得 Huber 损失在希望限制异常值影响的情况下非常理想。

注意,由于 Huber 损失包含 AE 作为其组成部分之一,因此不能直接与牛顿提升法一起使用。因此,牛顿提升法的实现使用了一个平滑近似,称为伪 Huber 损失

CH07_F10_Kunapuli-eqs-22x

伪 Huber 损失的行为类似于 Huber 损失,尽管它是一个近似版本,对于接近零的残差(y - f(x)),它输出 1/2⋅(yf(x))²。

连续值正标签

在某些领域,例如保险索赔分析,我们想要预测的目标标签只取正值。例如,索赔金额是连续值,但只能为正。

在这种情况下,当高斯分布不合适时,我们可以使用伽马分布。伽马分布是一个高度灵活的分布,可以拟合许多目标分布形状。这使得它非常适合建模问题,其中目标分布具有长尾——即不能忽略的异常值。

伽马分布不对应于一个封闭形式的损失函数。如图 7.10(中心)所示,我们之前绘制的是负对数似然,它充当了一个代理损失函数。

首先,观察损失函数仅对正实数值(x 轴)有定义。接下来,观察对数似然函数如何仅对更右侧的错误进行轻微惩罚。这允许基础模型拟合右偏斜的数据。

计数值标签

除了连续值标签之外,一些回归问题需要我们拟合计数值目标。我们已经在第 7.1 节中看到了这样的例子,我们了解到计数(离散值)可以使用泊松分布进行建模。

与伽马分布一样,泊松分布也不对应于闭式损失函数。图 7.10(右)说明了泊松分布的负对数似然,它可以用于构建回归模型(称为泊松回归)。

混合标签

在某些问题中,基础标签不能由单个分布来建模。例如,在天气分析中,如果我们想建模降雨,我们可以预期(1)在大多数日子里,我们根本不会下雨;(2)在某些日子里,会有不同程度的降雨;(3)在少数情况下,会有非常严重的降雨。

图 7.11 显示了降雨数据的分布,其中在 0 处有一个大的“点质量”或尖峰(对应于大多数无雨的日子)。此外,这个分布也是右偏斜的,因为有少数几天降雨量非常高。

CH07_F11_Kunapuli

图 7.11 有效地建模某些类型的标签需要分布的组合,称为复合分布。其中一种复合分布是 Tweedie 分布。

为了建模这个问题,我们需要一个与混合分布相对应的损失函数,具体来说是一个泊松-伽马分布:泊松分布用于建模 0 处的大点质量,伽马分布用于建模右偏斜、正的连续数据。对于这样的标签,我们可以使用一个强大的概率分布族,称为 Tweedie 分布,它由 Tweedie 力参数 p 参数化。不同的 p 值会产生不同的分布:

  • p = 0: 高斯(正态)分布

  • p = 1: 泊松分布

  • 1 < p < 2: 不同 p 的泊松-伽马分布

  • p = 2: 伽马分布

  • p = 3: 反高斯分布

其他 p 的选择会产生许多其他分布。对我们来说,我们主要对使用 1 < p < 2 的值感兴趣,以创建混合泊松-伽马损失函数。

LightGBM 和 XGBoost 都支持 Tweedie 分布,这导致了它们在天气分析、保险分析和健康信息学等领域的广泛应用。我们将在第 7.4 节中看到如何在我们的案例研究中使用它。

7.3.2 使用 LightGBM 和 XGBoost 进行梯度提升

现在,掌握了各种损失函数的知识,让我们看看如何将梯度提升回归器应用于 AutoMPG 数据集。

使用 LightGBM 进行梯度提升

首先,让我们应用标准的梯度提升,即 LightGBM 的 LGBMRegressor 与 Huber 损失函数。我们还需要选择几个 XGBoost 的超参数。这些参数控制 LightGBM 的各个组件:

  • 损失函数参数alpha是 Huber 损失参数,是它从行为类似于 MSE 转换为行为类似于 MAE 损失的阈值。

  • 学习控制参数learning_rate用于控制模型学习的速率,以便它不会快速拟合并过度拟合训练数据;subsample用于在训练过程中随机采样数据的一个较小部分,以诱导额外的集成多样性和提高训练效率。

  • 正则化参数lambda_l1lambda_l2分别是 L1 和 L2 正则化函数的权重;它们对应于弹性网目标函数中的ab(参见表 7.1)。

  • 树学习参数max_depth限制了集成中每个弱树的深度最大值。

每个类别中还有其他超参数,也可以提供对训练的更精细控制。我们通过组合随机搜索(因为穷举网格搜索会太慢)和交叉验证来选择超参数。列表 7.6 展示了使用 LightGBM 的示例。

除了超参数选择之外,列表还实现了早停,如果在评估集上没有观察到性能改进,则终止训练。

列表 7.6 使用 Huber 损失的 LightGBM

from lightgbm import LGBMRegressor
from sklearn.model_selection import RandomizedSearchCV

parameters = {'alpha': [0.3, 0.9, 1.8],                          ❶
              'max_depth': np.arange(2, 5, step=1), 
              'learning_rate': 2**np.arange(-8., 2., step=2),
              'subsample': [0.6, 0.7, 0.8],
              'lambda_l1': [0.01, 0.1, 1],
              'lambda_l2': [0.01, 0.1, 1e-1, 1]}

lgb = LGBMRegressor(objective='huber', n_estimators=100)         ❷
param_tuner = RandomizedSearchCV(lgb, parameters, 
                                 n_iter=20, cv=5,                ❸
                                 refit=True, verbose=1)

param_tuner.fit(Xtrn, ytrn,                                      ❹
                eval_set=[(Xtst, ytst)], eval_metric='mse', verbose=False)

ypred_trn = param_tuner.best_estimator_.predict(Xtrn)            ❺
ypred_tst = param_tuner.best_estimator_.predict(Xtst)
print('Train MSE = {0:5.4f}, Train MAE = {1:5.4f}\n' \
      'Test MSE = {2:5.4f}, Test MAE = {3:5.4f}'.format(
      mean_squared_error(ytrn, ypred_trn), 
      mean_absolute_error(ytrn, ypred_trn),
      mean_squared_error(ytst, ypred_tst), 
      mean_absolute_error(ytst, ypred_tst)))

❶ 我们想要搜索的超参数范围

❷ 初始化一个 LightGBM 回归器

❸ 由于 GridSearchCV 会较慢,因此使用 5 折交叉验证搜索超过 20 种随机参数组合

❹ 使用早停法拟合回归器

❺ 计算训练和测试误差

这会产生以下输出:

Fitting 5 folds for each of 20 candidates, totalling 100 fits
Train MSE = 0.0476, Train MAE = 0.1497
Test MSE = 0.0951, Test MAE = 0.2250

使用 Huber 损失训练的 LightGBM(梯度提升)模型在测试中达到 MSE 为 0.0951,在前面代码片段中用粗体突出显示。

使用 XGBoost 的牛顿提升

我们可以使用 XGBoost 的 XGBRegressor 重复此训练和评估。由于牛顿提升需要二阶导数,而 Huber 损失无法计算,XGBoost 不直接提供此损失。相反,XGBoost 提供了一个伪 Huber 损失,这是在第 7.3.1 节中引入的 Huber 损失的微分近似。同样,与 LightGBM 一样,我们还需要设置几个不同的超参数。XGBoost 的许多参数与 LightGBM 的参数完全对应,尽管它们的名称不同:

  • 学习控制参数learning_rate用于控制模型学习的速率,以便它不会快速拟合并过度拟合训练数据;colsample_bytree用于在训练过程中随机采样特征的一个较小部分(类似于随机森林),以诱导额外的集成多样性和提高训练效率。

  • 正则化参数——reg_alpha 和 reg_lambda 分别是 L1 和 L2 正则化函数的权重;这些对应于弹性网络目标函数中的ab(参见表 7.1)。

  • 树学习参数——max_depth 限制了集成中每个弱树的深度。

以下列表显示了如何训练一个 XGBRegressor,包括随机超参数搜索。

列表 7.7 使用 XGBoost 和伪 Huber 损失

from xgboost import XGBRegressor
parameters = {'max_depth': np.arange(2, 5, step=1),           ❶
              'learning_rate': 2**np.arange(-8., 2., step=2),
              'colsample_bytree': [0.6, 0.7, 0.8],
              'reg_alpha': [0.01, 0.1, 1],
              'reg_lambda': [0.01, 0.1, 1e-1, 1]}

xgb = XGBRegressor(objective='reg:pseudohubererror')          ❷

param_tuner = RandomizedSearchCV(xgb, parameters, 
                                 n_iter=20, cv=5,             ❸
                                 refit=True, verbose=1)

param_tuner.fit(Xtrn, ytrn, eval_set=[(Xtst, ytst)],          ❹
                eval_metric='rmse', verbose=False)

ypred_trn = param_tuner.best_estimator_.predict(Xtrn)         ❺
ypred_tst = param_tuner.best_estimator_.predict(Xtst)
print('Train MSE = {0:5.4f}, Train MAE = {1:5.4f}\n' \
      'Test MSE = {2:5.4f}, Test MAE = {3:5.4f}'.format(
      mean_squared_error(ytrn, ypred_trn), 
      mean_absolute_error(ytrn, ypred_trn),
      mean_squared_error(ytst, ypred_tst), 
      mean_absolute_error(ytst, ypred_tst)))

❶ 我们想要搜索的超参数范围

❷ 初始化 XGBoost 回归器

❸ 由于 GridSearchCV 会较慢,因此使用 5 折交叉验证搜索超过 20 种随机参数组合

❹ 使用早停法拟合回归器

❺ 计算训练和测试误差

这产生了以下输出:

Fitting 5 folds for each of 20 candidates, totalling 100 fits
Train MSE = 0.0451, Train MAE = 0.1572
Test MSE = 0.0947, Test MAE = 0.2244

使用伪 Huber 损失训练的 XGBoost 模型在测试中实现了 0.0947 的均方误差(在列表 7.7 的输出中被加粗)。这与实现了 0.0951 测试均方误差的 LightGBM 模型的性能相似(参见列表 7.6 产生的输出)。

这说明当需要时,伪 Huber 损失是 Huber 损失的合理替代。我们很快就会看到如何使用 LightGBM 和 XGBoost 在本章案例研究中讨论的其他损失函数来预测自行车需求。

7.4 案例研究:需求预测

需求预测是在许多商业环境中出现的一个重要问题,当目标是预测某种产品或商品的需求时。准确预测需求对于下游供应链管理和优化至关重要:确保有足够的供应来满足需求,而又不会过多导致浪费。

需求预测通常被表述为使用历史数据和趋势构建模型来预测未来需求的回归问题。目标标签可以是连续的或计数的。

例如,在能源需求预测中,要预测的标签(以吉瓦时计的能源需求)是连续值。或者,在产品需求预测中,要预测的标签(要运输的项目数量)是计数值。

在本节中,我们研究自行车租赁预测问题。正如我们在这个节中所看到的,问题的性质(尤其是目标/标签)与气象预测和分析、保险和风险分析、健康信息学、能源需求预测、商业智能等领域中出现的性质相当相似。

我们分析数据集,然后构建越来越复杂的模型,从单个线性模型开始,然后转向集成非线性模型。在每一个阶段,我们将执行超参数调整以选择最佳的超参数组合。

7.4.1 UCI 自行车共享数据集

[Bike Sharing 数据集]¹是第一个跟踪主要大都市区自行车共享服务使用情况的几个类似公开数据集之一。这些数据集通过 UCI 机器学习库公开提供(mng.bz/GRrM)。

此数据集首次于 2013 年提供,追踪了华盛顿特区 Capital Bike Sharing 的休闲骑行者和注册会员的每小时和每日自行车租赁情况。此外,数据集还包含描述天气以及一天中时间和年份的几个特征。

本案例研究中的问题总体目标是根据一天中的时间、季节和天气预测休闲骑行者的租车需求。需求以总用户数衡量——一个计数!

为什么只针对休闲骑行者建模?注册用户数量似乎在全年中相当稳定,因为这些用户可能将自行车共享作为常规交通选择,而不是娱乐活动。这类似于有月度/年度公交通行证的通勤者,而不是只按需购买公交票的游客。

记住这一点,我们为案例研究构建了一个派生数据集,该数据集可用于构建模型来预测休闲用户的租车需求。本案例研究的(修改后)数据集与本书的代码一起提供,可以按以下方式加载:

import pandas as pd
data = pd.read_csv('./data/ch07/bikesharing.csv')

我们可以使用以下方式查看数据集的统计信息:

data.describe()

这将计算数据集中所有特征的各项统计信息,如图 7.12 所示,这有助于了解各种特征及其值在高级别上的分布情况。

CH07_F12_Kunapuli

图 7.12 Bike Sharing 数据集的统计信息。其中“casual”列是预测目标(标签)。

数据集包含几个连续的天气特征:temp(归一化温度)、atemp(归一化“体感”温度)、hum(湿度)和 windspeed。分类特征 weathersit 描述了当时看到的天气类型,有四个类别:

  • 1:晴朗,少量云,部分多云

  • 2:雾+多云,雾+破碎云,雾+少量云,雾

  • 3:小雪,小雨+雷暴+散云,雨+散云

  • 4:大雨+冰雹+雷暴+雾,雪+雾

数据集还包含离散特征:season(1:冬季,2:春季,3:夏季,4:秋季)、mnth(1 到 12 代表 1 月到 12 月)和 hr(从 0 到 23 的小时)来描述时间。此外,二元特征 holiday、weekday 和 workingday 编码了问题中的天是假日、工作日还是工作日。

特征预处理

让我们通过归一化特征来预处理这个数据集,也就是说,确保每个特征具有零均值和单位标准差。归一化并不总是处理离散特征的最好方法。不过,现在我们先使用这种简单的预处理,并专注于回归的集成方法。在第八章中,我们将更深入地探讨这些类型特征的预处理策略。

列表 7.8 展示了我们的预处理步骤:它将数据分为训练集(数据量的 80%)和测试集(剩余的 20% 数据),并对特征应用归一化。一如既往,我们将测试集从训练过程中排除,以便我们可以在测试集上评估每个训练模型的性能。

列表 7.8 预处理共享单车数据集

labels = data.columns.get_loc('casual')                      ❶
features = np.setdiff1d(np.arange(0, len(data.columns), 1), 
                        labels)                              ❷

from sklearn.model_selection import train_test_split
trn, tst = train_test_split(data, test_size=0.2,             ❸
                            random_state=42)
Xtrn, ytrn = trn.values[:, features], trn.values[:, labels]
Xtst, ytst = tst.values[:, features], tst.values[:, labels]

from sklearn.preprocessing import StandardScaler
preprocessor = StandardScaler().fit(Xtrn)                    ❹
Xtrn, Xtst = preprocessor.transform(Xtrn), preprocessor.transform(Xtst)

❶ 获取标签的列索引

❷ 获取特征的列索引

❸ 分割为训练集和测试集

❹ 通过归一化预处理特征

计数型目标

我们想要预测的目标标签是偶然的,即偶然用户数量,这是一个计数型值,范围从 0 到 367。我们在图 7.13(左)中绘制了这些目标的直方图。这个数据集在 0 处有一个大的点质量,表明在许多日子里,没有偶然用户。此外,我们可以看到这个分布有一个 长尾,这使得它 右偏斜

我们可以通过应用对数变换进一步分析这些标签,即把每个计数标签 y 转换为 log(1 + y),其中我们加 1 以避免对零计数数据进行对数运算。这如图 7.13(右)所示。

CH07_F13_Kunapuli

图 7.13 计数型目标直方图,偶然用户数量(左);对数变换后的计数目标直方图(右)

这给我们提供了两个关于我们如何建模问题的深刻见解:

  • 使用 Tweedie 分布——对数变换后的计数目标分布看起来与之前图 7.11 中显示的降雨量直方图非常相似,这表明 Tweedie 分布可能适合建模这个问题。回想一下,参数 1 < p < 2 的 Tweedie 分布可以模拟复合泊松-伽马分布:泊松分布用于模拟 0 处的大点质量,伽马分布用于模拟右偏斜的正连续数据。

  • 使用 GLM——对数变换本身表明目标与特征之间存在联系。如果我们把这个回归任务建模为 GLM,我们就必须使用对数连接函数。我们希望将这个概念扩展到集成方法(通常是非线性)。

如我们很快将看到的,LightGBM 和 XGBoost 支持建模对数连接(以及其他连接函数)和泊松、伽马和 Tweedie 等分布。这使得它们能够模仿 GLM 的直觉来捕捉数据集的细微差别,同时超越 GLM 只能学习线性模型的限制。

7.4.2 GLM 和堆叠

让我们先训练个别的一般线性回归模型,以捕捉之前获得的直觉。此外,我们还将堆叠这些个别模型以结合它们的预测。我们将训练三个个别回归器:

  • 带有对数连接函数的 Tweedie 回归—使用 Tweedie 分布来模拟正偏斜的目标。我们使用 scikit-learn 的 Tweedie 回归器,它要求我们选择两个参数:alpha,L2 正则化项的参数,以及 power,它应该在 1 到 2 之间。

  • 带有对数连接函数的泊松回归—使用泊松分布来模拟计数变量。我们使用 scikit-learn 的 PoissonRegressor,它要求我们选择仅一个参数:alpha,L2 正则化项的参数。需要注意的是,在 TweedieRegressor 中将 power 设置为 1 相当于使用 PoissonRegressor。

  • 岭回归—使用正态分布来模拟连续变量。一般来说,这种方法并不适合这些数据,但它被包括作为基线,因为它是我们将在野外遇到的最常见方法之一。

以下列表展示了我们如何通过穷举网格搜索和结合交叉验证来训练这些回归器。

列表 7.9:为自行车租赁预测训练 GLM

from sklearn.model_selection import GridSearchCV
from sklearn.metrics import (
    mean_squared_error, mean_absolute_error, r2_score)
from sklearn.linear_model import Ridge, PoissonRegressor, TweedieRegressor

parameters = {                                               ❶
    'GLM: Linear': {'alpha': 10 ** np.arange(-4., 1.)},
    'GLM: Poisson': {'alpha': 10 ** np.arange(-4., 1.)},
    'GLM: Tweedie': {
        'alpha': 10 ** np.arange(-4., 1.), 
        'power': np.linspace(1.1, 1.9, num=5)}}              ❷

glms = {'GLM: Linear': Ridge(),                              ❸
        'GLM: Poisson': PoissonRegressor(max_iter=1000), 
        'GLM: Tweedie': TweedieRegressor(max_iter=1000)}

best_glms = {}                                               ❹
results = pd.DataFrame()

for glm_type, glm in glms.items():
    param_tuner = GridSearchCV(                              ❺
                      glm, parameters[glm_type],     
                      cv=5, refit=True, verbose=2)

    param_tuner.fit(Xtrn, ytrn)

    best_glms[glm_type] = param_tuner.best_estimator_        ❻
    ypred_trn = best_glms[glm_type].predict(Xtrn)
    ypred_tst = best_glms[glm_type].predict(Xtst)

    res = {'Method': glm_type,                               ❼
           'Train MSE': mean_squared_error(ytrn, ypred_trn),
           'Train MAE': mean_absolute_error(ytrn, ypred_trn), 
           'Train R2': r2_score(ytrn, ypred_trn), 
           'Test MSE': mean_squared_error(ytst, ypred_tst),
           'Test MAE': mean_absolute_error(ytst, ypred_tst),
           'Test R2': r2_score(ytst, ypred_tst)}
    results = pd.concat([results, 
                         pd.DataFrame.from_dict([res])], ignore_index=True)

❶ 岭回归、泊松回归和 Tweedie 回归的超参数范围

❷ Tweedie 回归有一个额外的参数:power。

❸ 初始化 GLM

❹ 在交叉验证(CV)后保存单个广义线性模型(GLM)

❺ 对每个 GLM 进行网格搜索,使用 5 折交叉验证

❻ 获取最终的重新拟合 GLM 并计算训练和测试预测

❼ 为每个 GLM 计算并保存三个指标:MAE、MSE 和R²分数

如果我们使用 print(results),我们将看到三个模型学到了什么。我们使用这些指标来评估训练集和测试集的性能:MSE、MAE 和R²分数。回想一下,R²分数(或决定系数)是可从数据中解释的目标方差的比例。

R² 分数范围从负无穷大到 1,分数越高表示性能越好。均方误差(MSE)和平均绝对误差(MAE)的范围从 0 到无穷大,误差越低表示性能越好:

        Method  Train MSE  Train MAE  Train R2  Test MSE  Test MAE  Test R2
   GLM: Linear  1,368.677     24.964     0.444 1,270.174    23.985    0.447
  GLM: Poisson  1,354.006     21.726     0.450 1,228.898    20.641    0.465
  GLM: Tweedie  1,383.374     21.755     0.438 1,254.304    20.661    0.454

测试集的性能立即证实了我们的一种直觉:假设数据具有正态分布的经典回归方法表现最差。泊松或 Tweedie 分布则显示出希望。

我们现在已经训练了三个机器学习模型:让我们通过堆叠它们来集成它们。以下列表显示了如何使用 ANN 回归来完成此操作。虽然我们训练的 GLM 是线性的,但这个堆叠模型将是非线性的!

列表 7.10:为自行车租赁预测堆叠 GLM

from sklearn.neural_network import MLPRegressor
from sklearn.ensemble import StackingRegressor

base_estimators = list(best_glms.items())                   ❶
meta_learner = MLPRegressor(                                ❷
                   hidden_layer_sizes=(25, 25, 25),    
                   max_iter=1000, activation='relu')

stack = StackingRegressor(base_estimators, final_estimator=meta_learner)
stack.fit(Xtrn, ytrn)                                       ❸

ypred_trn = stack.predict(Xtrn)                             ❹
ypred_tst = stack.predict(Xtst)

res = {'Method': 'GLM Stack',                               ❺
       'Train MSE': mean_squared_error(ytrn, ypred_trn),
       'Train MAE': mean_absolute_error(ytrn, ypred_trn), 
       'Train R2': r2_score(ytrn, ypred_trn), 
       'Test MSE': mean_squared_error(ytst, ypred_tst),
       'Test MAE': mean_absolute_error(ytst, ypred_tst),
       'Test R2': r2_score(ytst, ypred_tst)}
results = pd.concat([results, 
                     pd.DataFrame.from_dict([res])], ignore_index=True)

❶ 列表 7.9 中最佳参数设置的 GLM 是基估计器。

❷ 三层神经网络是元估计器。

❸ 训练堆叠集成

❹ 进行训练和测试预测

❺ 为此模型计算并保存三个指标:MAE、MSE 和R²分数

现在,我们可以比较堆叠与单个模型的结果

   Method   Train MSE  Train MAE  Train R2  Test MSE  Test MAE  Test R2
GLM Stack     975.428     19.011     0.604   927.214    18.199    0.596

堆叠的 GLM 集成已经明显提高了测试集的性能,这表明非线性模型是可行的方向。

7.4.3 随机森林和 Extra Trees

现在,让我们使用 scikit-learn 的 RandomForestRegressor 和 ExtraTreesRegressor 来训练更多并行集成,用于自行车租赁预测任务。这两个模块都支持 MSE、MAE 和 Poisson 作为损失函数。然而,与 GLMs 不同,随机森林和 Extra Trees 不使用对数连接函数。我们将训练两个不同的集成:一个用于 MSE 损失函数,另一个用于 Poisson 损失函数,并且每个都进行类似的超参数搜索。

对于这两种方法,我们都在寻找两个超参数的最佳选择:集成大小(n_estimators)和每个基础估计器的最大深度(max_depth)。我们可以通过每个方法的 criterion 参数设置损失函数为'squared_error'或'poisson'。

以下列表展示了我们如何通过穷举网格搜索和结合 CV 来训练这些回归器,这与我们为 GLMs 所做的方法类似。

列表 7.11 随机森林和 Extra Trees 用于自行车租赁预测

from sklearn.ensemble import RandomForestRegressor
from sklearn.ensemble import ExtraTreesRegressor

parameters = {                                                 ❶
    'n_estimators': np.arange(200, 600, step=100),
    'max_depth': np.arange(4, 7, step=1)}

ensembles = {                                                  ❷
    'RF: Squared Error': RandomForestRegressor(criterion='squared_error'),
    'RF: Poisson': RandomForestRegressor(criterion='poisson'),
    'XT: Squared Error': ExtraTreesRegressor(criterion='squared_error'), 
    'XT: Poisson': ExtraTreesRegressor(criterion='poisson')}

for ens_type, ensemble in ensembles.items():
    param_tuner = GridSearchCV(ensemble, parameters,           ❸
                               cv=5, refit=True, verbose=2)
    param_tuner.fit(Xtrn, ytrn)

    ypred_trn = \                                              ❹
        param_tuner.best_estimator_.predict(Xtrn)
    ypred_tst = param_tuner.best_estimator_.predict(Xtst)

    res = {'Method': ens_type,                                 ❺
           'Train MSE': mean_squared_error(ytrn, ypred_trn),
           'Train MAE': mean_absolute_error(ytrn, ypred_trn), 
           'Train R2': r2_score(ytrn, ypred_trn), 
           'Test MSE': mean_squared_error(ytst, ypred_tst),
           'Test MAE': mean_absolute_error(ytst, ypred_tst),
           'Test R2': r2_score(ytst, ypred_tst)}
    results = pd.concat([results, 
                         pd.DataFrame.from_dict([res])], ignore_index=True)

❶ 随机森林和 Extra Trees 的超参数范围

❷ 两个集成都使用 MSE 作为训练标准。

❸ 使用网格搜索和 5 折交叉验证进行超参数调整

❹ 获取每个集成的训练和测试预测

❺ 为每个集成计算并保存三个指标:MAE、MSE 和R²得分

将这些并行集成模型的成果与堆叠和单个 GLMs 进行比较。特别是,观察与单个模型相比性能的显著提升,这证明了集成方法的力量,即使是在次优损失函数上训练也是如此:


        Method  Train MSE  Train MAE  Train R2  Test MSE  Test MAE  Test R2
RF: Squared Error 497.514     12.530     0.798   487.923    12.264    0.788
   RF: Poisson    566.552     13.081     0.770   549.014    12.684    0.761
XT: Squared Error 567.141     13.911     0.770   559.725    13.700    0.756
   XT: Poisson    576.096     13.946     0.766   566.706    13.754    0.753

我们能否通过梯度提升和牛顿提升方法获得相似或更好的性能?让我们来看看。

7.4.4 XGBoost 和 LightGBM

最后,让我们使用 XGBoost 和 LightGBM 在这个数据集上训练序列集成。这两个包都支持广泛的损失函数:

  • XGBoost 支持的损失和似然函数包括 MSE、伪 Huber、Poisson 和 Tweedie 损失,带有对数连接函数。请注意,XGBoost 实现了牛顿提升,这需要计算二阶导数;这意味着 XGBoost 不能直接实现 MAE 或 Huber 损失。相反,XGBoost 提供了对伪 Huber 损失的支持。

  • 与 XGBoost 类似,LightGBM 支持 MSE、Poisson 和 Tweedie 损失,带有对数连接函数。然而,由于它实现了梯度提升,这只需要一阶导数,它直接支持 MAE 和 Huber 损失。

对于这两个模型,我们需要调整控制集成各个方面(例如,学习率和早期停止)的几个超参数,正则化(例如,L1 和 L2 正则化的权重),以及树学习(例如,最大树深度)。

我们之前训练的许多模型只需要调整少量超参数,这使得我们能够通过网格搜索过程识别它们。网格搜索耗时,计算成本变得过高,因此在这种情况下应避免使用。与其进行详尽的网格搜索,随机搜索可以是一个有效的替代方案。

在随机超参数搜索中,我们从完整列表中采样较少的随机超参数组合。一旦我们确定了一个好的组合,我们就可以进行更精细的调整,以进一步优化我们的结果。

下面的列表显示了使用不同损失函数的 XGBoost 进行随机参数搜索和集成训练的步骤。

列表 7.12 XGBoost 用于自行车租赁预测

from xgboost import XGBRegressor
from sklearn.model_selection import RandomizedSearchCV

parameters = {'max_depth': np.arange(2, 7, step=1),                 ❶
              'learning_rate': 2**np.arange(-8., 2., step=2),
              'colsample_bytree': [0.4, 0.5, 0.6, 0.7, 0.8],
              'reg_alpha': [0, 0.01, 0.1, 1, 10],
              'reg_lambda': [0, 0.01, 0.1, 1e-1, 1, 10]}
print(parameters)

ensembles = {                                                       ❷
    'XGB: Squared Error': XGBRegressor(objective='reg:squarederror', 
                                       eval_metric='poisson-nloglik'),
    'XGB: Pseudo Huber': XGBRegressor(objective='reg:pseudohubererror',
                                      eval_metric='poisson-nloglik'),
    'XGB: Poisson': XGBRegressor(objective='count:poisson', 
                                 eval_metric='poisson-nloglik'),
    'XGB: Tweedie': XGBRegressor(objective='reg:tweedie',
                                 eval_metric='poisson-nloglik')}

for ens_type, ensemble in ensembles.items():
    if ens_type == 'XGB: Tweedie':                                  ❸
        parameters['tweedie_variance_power'] = np.linspace(1.1, 1.9, num=9)

    param_tuner = RandomizedSearchCV(                               ❹
                      ensemble, parameters, n_iter=50, 
                      cv=5, refit=True, verbose=2)
    param_tuner.fit(Xtrn, ytrn,                                     ❺
                    eval_set=[(Xtst, ytst)], verbose=False)

    ypred_trn = \                                                   ❻
        param_tuner.best_estimator_.predict(Xtrn)
    ypred_tst = param_tuner.best_estimator_.predict(Xtst)

    res = {'Method': ens_type,                                      ❼
           'Train MSE': mean_squared_error(ytrn, ypred_trn),
           'Train MAE': mean_absolute_error(ytrn, ypred_trn), 
           'Train R2': r2_score(ytrn, ypred_trn), 
           'Test MSE': mean_squared_error(ytst, ypred_tst),
           'Test MAE': mean_absolute_error(ytst, ypred_tst),
           'Test R2': r2_score(ytst, ypred_tst)}

    results = pd.concat([results, pd.DataFrame([res])], ignore_index=True)

❶ 所有 XGBoost 损失函数的超参数范围

❷ 初始化具有不同损失函数的 XGBoost 模型

❸ 对于 Tweedie 损失,我们还有一个额外的超参数:power。

❹ 使用 5 折交叉验证进行随机搜索的超参数调整

❺ 使用负泊松对数似然函数选择最佳模型

❻ 获取每个集成模型的训练和测试预测

❼ 为每个 XGBoost 集成计算并保存三个指标:MAE、MSE 和R²分数

注意:列表 7.12 使用提前停止来提前终止训练,如果评估集上没有明显的性能提升。当我们最后一次在 AutoMPG 数据集上使用提前停止(参考列表 7.6)时,我们使用 MSE 作为评估指标来跟踪性能提升。在这里,我们使用负泊松对数似然(eval_metric='poisson-nloglik')。回想一下我们在 7.3.1 节中的讨论,负对数似然通常用作没有闭式形式的损失函数的替代。在这种情况下,因为我们正在对计数目标(遵循泊松分布)进行建模,所以使用负泊松对数似然来衡量模型性能可能更合适。当然,也可以将这个指标与 MSE、MAE 和R²一起比较,就像我们一直在做的那样。然而,这个指标并不总是可用或公开在大多数包中。

这里展示了 XGBoost 在不同损失函数下的性能:

        Method  Train MSE  Train MAE  Train R2  Test MSE  Test MAE  Test R2
XGB: Squared Err  134.926      7.227     0.945   254.099     9.475    0.889
XGB: Pseudo Huber 335.578      9.999     0.864   360.987    11.274    0.843
  XGB: Poisson    181.602      7.958     0.926   250.034     8.958    0.891
  XGB: Tweedie    139.167      6.958     0.944   231.110     8.648    0.899

这些结果显著改善,使用泊松和 Tweedie 损失训练的 XGBoost 表现最佳。

我们可以用类似的方法对 LightGBM 进行实验。这个实现(可以在配套代码中找到)与我们训练 LightGBM 模型的方法非常相似,如列表 7.6 中的 AutoMPG 数据集和列表 7.11 中的 Bike Sharing 数据集。这里展示了 LightGBM 在 MSE、MAE、Huber、泊松和 Tweedie 损失下的性能:

        Method  Train MSE  Train MAE  Train R2  Test MSE  Test MAE  Test R2
LGBM: Squared Err 184.264      8.293     0.925   260.745     9.535    0.887
LGBM: Absolute Er 302.753      9.071     0.877   321.206     9.756    0.860
   LGBM: Huber    744.769     12.485     0.698   702.736    12.204    0.694
LGBM: Quantile    852.409     18.726     0.654   815.393    18.671    0.645
 LGBM: Poisson    223.913      8.776     0.909   264.663     9.215    0.885
 LGBM: Tweedie    182.309      8.035     0.926   245.714     8.939    0.893

LightGBM 的性能与 XGBoost 相似,在泊松和 Tweedie 损失下,再次表现出最佳性能,而 XGBoost 略优于 LightGBM。

图 7.14 总结了我们对自行车租赁需求预测任务训练的所有模型的测试集性能(使用 R²分数)。我们注意到以下几点:

  • 单个广义线性模型(GLM)的性能远低于任何集成方法。这并不令人惊讶,因为集成方法将多个单个模型的力量结合成一个最终的预测。此外,许多集成回归器是非线性的,并且更好地拟合数据,而所有 GLM 都是线性的并且有限。

  • 选择合适的损失函数对于训练一个好的模型至关重要。在这种情况下,使用 Tweedie 拟合的 LightGBM 和 XGBoost 模型训练效果最佳。这是因为 Tweedie 损失函数捕捉了自行车需求分布,它是一个计数值目标。

  • 如 LightGBM 和 XGBoost 之类的包提供了 Tweedie 等损失函数,而 scikit-learn 的集成方法实现(随机森林、Extra Trees)仅支持 MSE 和 MAE 损失(在撰写本文时)。通过采用 Tweedie 等损失函数,可以进一步提高这些方法的性能,但这需要自定义损失实现。

CH07_F14_Kunapuli

图 7.14 在我们的分析和建模过程中,回归的各种集成方法的测试集性能(使用 R²分数指标)。梯度提升(LightGBM)和牛顿提升(XGBoost)集成是目前最先进的技术。在这些方法中,通过谨慎选择损失函数和系统性地选择参数,性能可以进一步提高。

摘要

  • 回归可以用来建模连续值、计数值,甚至离散值的目标。

  • 传统的线性模型,如普通最小二乘法(OLS)、岭回归、最小绝对收缩和选择(LASSO)和弹性网络,都使用平方损失函数,但它们使用不同的正则化函数。

  • 泊松回归使用带有对数连接函数的线性模型,并在目标上假设泊松分布,以有效地建模计数标签数据。

  • 伽马回归使用带有对数连接函数的线性模型,并在目标上假设伽马分布,以有效地建模连续、正值且右偏的数据。

  • Tweedie 回归使用带有对数连接函数的线性模型,并假设 Tweedie 分布来对在许多实际应用中出现的数据建模复合分布,例如保险、天气和健康分析。

  • 经典的均方回归、泊松回归、伽马回归、Tweedie 回归,甚至逻辑回归都是广义线性模型(GLM)的不同变体。

  • 随机森林和 Extra Trees 使用随机回归树学习来诱导集成多样性。

  • 常见的统计量,如平均值和中位数,可以用来结合连续目标的预测,而众数和中位数可以用来结合计数目标的预测。

  • 在学习堆叠集成时,人工神经网络(ANN)回归器是元估计器的良好选择。

  • 损失函数,如均方误差(MSE)、平均绝对偏差(MAD)和 Huber 损失,非常适合连续值标签。

  • 伽马似然函数非常适合连续值但为正的标签(即它们不取负值)。

  • 泊松似然函数非常适合计数值标签。

  • 一些问题包含这些标签的混合,可以用 Tweedie 似然函数进行建模。

  • LightGBM 和 XGBoost 提供了对建模对数链接(以及其他链接函数)以及泊松、伽马和 Tweedie 等分布的支持。

  • 在实践中,超参数选择,无论是通过穷举网格搜索(慢但彻底)还是随机搜索(快但近似),对于良好的集成开发至关重要。


(1.) “结合集成检测器和背景知识的活动标注”,作者为 H. Fanaee-T 和 J. Gama。人工智能进展 2,第 113-127 页(2014 年)。

8 使用分类特征进行学习

本章涵盖

  • 在机器学习中引入分类特征

  • 使用监督和未监督编码对分类特征进行预处理

  • 理解有序提升

  • 使用 CatBoost 对分类变量进行处理

  • 处理高基数分类特征

监督机器学习的数据集由描述对象的特征和描述我们感兴趣建模的目标的标签组成。在较高层次上,特征,也称为属性或变量,通常分为两种类型:连续型和分类型。

一个分类特征是指从一个有限且非数值的值集中取值的特征,这些值被称为类别。分类特征无处不在,几乎出现在每个数据集和每个领域中。例如:

  • 人口统计特征—这些特征,如性别或种族,是医学、保险、金融、广告、推荐系统等许多建模问题中的常见属性。例如,美国人口普查局的种族属性是一个允许五种选择或类别的分类特征:(1)美国印第安人或阿拉斯加原住民,(2)亚洲,(3)黑人或非裔美国人,(4)夏威夷原住民或其他太平洋岛民,(5)白人。

  • 地理特征—这些特征,如美国州或 ZIP 代码,也是分类特征。特征“美国州”是一个有 50 个类别的分类变量。特征“ZIP 代码”也是一个分类变量,在美国有 41,692 个独特的类别(!)从纽约霍尔特斯维尔的美国国内税务局的 00501 到阿拉斯加凯奇坎的 99950。

分类特征通常表示为字符串或特定格式(例如,ZIP 代码,必须恰好五位数字长,可以以零开头)。

由于大多数机器学习算法需要数值输入,因此在训练之前必须将分类特征编码或转换为数值形式。这种编码的性质必须仔细选择,以捕捉分类特征的真正潜在性质。

集成设置有两种处理分类特征的方法:

  • 方法 1—使用 scikit-learn 等库中提供的几种标准或通用编码技术对分类特征进行预处理,然后使用 LightGBM 或 XGBoost 等包用预处理后的特征训练集成模型。

  • 方法 2—使用如 CatBoost 这样的集成方法,该方法专为处理分类特征而设计,可以直接且仔细地训练集成模型。

第 8.1 节涵盖了方法 1。它介绍了用于分类特征的常用预处理方法以及如何在实践中使用它们(使用 category_encoders 包)与任何机器学习算法一起使用,包括集成方法。第 8.1 节还讨论了两个常见问题:训练集到测试集泄露和训练集到测试集分布偏移,或预测偏移,这些问题影响我们评估模型泛化能力到未来未见数据的能力。

第 8.2 节涵盖了方法 2,并介绍了一种新的集成方法,称为有序提升,它是我们之前看到的提升方法的扩展,但特别修改以解决分类特征的泄露和偏移问题。本节还介绍了 CatBoost 包,并展示了我们如何使用它来在具有分类特征的数据集上训练集成方法。我们在第 8.3 节中的实际案例研究中探讨了这两种方法,其中我们比较了随机森林、LightGBM、XGBoost 和 CatBoost 在收入预测任务上的表现。

最后,许多通用方法在处理高基数分类特征(如邮编,类别数量非常高)或存在噪声,或所谓的“脏”分类变量时,扩展性不好。第 8.4 节展示了我们如何使用 dirty_cat 包有效地处理这类高基数类别。

8.1 编码分类特征

本节回顾了不同类型的分类特征,并介绍了处理它们的两种标准方法类别:无监督编码(特别是有序和独热编码)和监督编码(特别是使用目标统计)。

编码技术,就像机器学习方法一样,可以是无监督监督的。无监督编码方法仅使用特征来编码类别,而监督编码方法使用特征和目标。

我们还将看到监督编码技术如何因称为目标泄露的现象而导致实际性能下降。这将帮助我们理解开发有序提升方法背后的动机,我们将在第 8.3 节中探讨该方法。

8.1.1 分类特征的类型

分类特征包含有关训练示例所属类别或组的信息。构成此类变量的值或类别通常使用字符串或其他非数字标签表示。

广义上,分类特征分为两种类型:有序,其中类别之间存在顺序,和无序,其中类别之间不存在顺序。让我们在假设的时尚任务背景下仔细看看无序和有序的分类特征,该任务的目的是训练一个机器学习算法来预测 T 恤的成本。每件 T 恤由两个属性描述:颜色和尺寸(图 8.1)。

CH08_F01_Kunapuli

图 8.1 在这个示例数据集中,T 恤是通过两个类别特征(颜色和尺寸)描述的。类别特征可以是(1)名义的,其中各种类别之间没有顺序,或(2)有序的,其中类别之间存在顺序。本数据集中的第三个特征,成本,是一个连续的数值变量。

特征颜色有三个离散值:红色、蓝色和绿色。这些类别之间不存在顺序,这使得颜色成为一个名义特征。由于颜色的值排序无关紧要,红色-蓝色-绿色的排序与其他排序排列,如蓝色-红色-绿色或绿色-红色-蓝色,是等价的。

特征尺寸有四个离散值:S、M、L 和 XL。然而,与颜色不同,尺寸之间存在隐含的顺序:S < M < L < XL。这使得尺寸成为一个有序特征。虽然我们可以以任何方式对尺寸进行排序,但按尺寸递增顺序排序,S-M-L-XL,或按尺寸递减顺序排序,XL-L-M-S,是最合理的。理解每个类别特征的领域和性质是决定如何编码它们的重要部分。

8.1.2 有序和独热编码

类别变量,如颜色和尺寸,必须在训练机器学习模型之前进行编码,即转换为某种数值表示。编码是一种特征工程,必须谨慎进行,因为编码选择不当可能会影响模型性能和可解释性。

在本节中,我们将探讨两种常用的无监督类别变量编码方法:有序编码独热编码。它们是无监督的,因为它们在编码时不使用目标(标签)。

有序编码

有序编码简单地给每个类别分配一个数字。例如,名义特征颜色可以通过分配{'red': 0, 'blue': 1, 'green': 2}进行编码。由于类别没有隐含的顺序,我们也可以通过分配其他排列,如{'red': 2, 'blue': 0, 'green': 1}进行编码。

另一方面,由于尺寸已经是有序变量,因此为保持这种顺序而分配数值是有意义的。对于尺寸,可以使用{'S': 0, 'M': 1, 'L': 2, 'XL': 3}(递增)或{'S': 3, 'M': 2, 'L': 1, 'XL': 0}(递减)进行编码,以保持尺寸类别之间的固有关系。

scikit-learn 的 OrdinalEncoder 可以用来创建有序编码。让我们对图 8.1 中的数据集(用 X 表示)中的两个类别特征(颜色和尺寸)进行编码:

import numpy as np
X = np.array([['red', 'M'],
              ['green', 'L'],
              ['red', 'S'],
              ['blue', 'XL'],
              ['blue', 'S'],
              ['green', 'XL'],
              ['blue', 'M'],
              ['red', 'L']])

我们将指定颜色的编码,假设它可以取四个值:红色、黄色、绿色、蓝色(尽管我们只看到红色、绿色和蓝色在我们的数据中)。我们还将指定尺寸的排序为 XL、L、M、S:

from sklearn.preprocessing import OrdinalEncoder
encoder = OrdinalEncoder(categories=[
              ['red', 'yellow', 'green', 'blue'],   ❶
              ['XL', 'L', 'M', 'S']])               ❷
Xenc = encoder.fit_transform(X)                     ❸

❶ 指定有四种可能的颜色

❷ 指定尺寸应按递减顺序组织

❸ 仅使用此规范对类别特征进行编码

现在,我们可以查看这些特征的编码:

encoder.categories_
[array(['red', 'yello', 'green', 'blue'], dtype='<U5'),
 array(['XL', 'L', 'M', 'S'], dtype='<U5')]

这种编码将数字值分配给颜色,如 {'red': 0, 'yellow': 1, 'blue': 2, 'green': 3},并将尺寸分配为 {'XL': 0, 'L': 1, 'M': 2, 'S': 3}。这种编码将这些分类特征转换为数值:

Xenc
array([[0., 2.],
       [2., 1.],
       [0., 3.],
       [3., 0.],
       [3., 3.],
       [2., 0.],
       [3., 2.],
       [0., 1.]])

将编码的颜色(Xenc 的第一列)与原始数据(X 的第一列)进行比较。所有红色条目都被编码为 0,绿色为 2,蓝色为 3。由于没有黄色条目,所以在这个列中没有值 1 的编码。

注意,顺序编码在变量之间施加了固有的顺序。虽然这对于顺序分类特征是理想的,但对于名义分类特征可能并不总是有道理。

独热编码

独热编码是一种编码分类特征的方法,它不对其值之间的顺序施加任何限制,更适合用于名义特征。为什么使用独热编码?如果我们对名义特征使用顺序编码,它将引入一个在现实世界中类别之间不存在的顺序,从而误导学习算法认为存在一个。与顺序编码不同,顺序编码使用单个数字来编码每个类别,而独热编码使用 0 和 1 的向量来编码每个类别。向量的长度取决于类别的数量。

例如,如果我们假设颜色是一个三值类别(红色、蓝色、绿色),它将被编码为长度为 3 的向量。一个这样的独热编码可以是 {'red': [1, 0, 0], 'blue': [0, 1, 0], 'green': [0, 0, 1]}。观察 1 的位置:红色对应于第一个编码条目,蓝色对应于第二个,绿色对应于第三个。

如果我们假设颜色是一个四值类别(红色、黄色、蓝色、绿色),独热编码将为每个类别生成长度为 4 的向量。在本章的其余部分,我们将假设颜色是一个三值类别。

因为尺寸有四个独特的值,独热编码也为每个尺寸类别生成长度为 4 的向量。一个这样的独热编码可以是 {'S': [1, 0, 0, 0], 'M': [0, 1, 0, 0], 'L': [0, 0, 1, 0], 'XL': [0, 0, 0, 1]}。

scikit-learn 的 OneHotEncoder 可以用来创建独热编码。和之前一样,让我们将数据集(图 8.1)中的两个分类特征(颜色和尺寸)进行编码:

from sklearn.preprocessing import OneHotEncoder
encoder = OneHotEncoder(categories=[
              ['red', 'green', 'blue'],    ❶
              ['XL', 'L', 'M', 'S']])      ❷
Xenc = encoder.fit_transform(X)            ❸

❶ 指定有三种可能的颜色

❷ 指定有四种可能的尺寸

❸ 仅使用此规范对分类特征进行编码

现在,我们可以查看这些特征的编码:

encoder.categories_
[array(['red', 'green', 'blue'], dtype='<U5'),
 array(['S', 'M', 'L', 'XL'], dtype='<U5')]

这种编码将引入三个独热特征(Xenc 中的前三列)来替换颜色特征(X 中的第一列)和四个独热特征(Xenc 中的最后四列)来替换尺寸特征(X 中的最后一列):

Xenc.toarray()
array([[1., 0., 0., 0., 1., 0., 0.],
       [0., 1., 0., 0., 0., 1., 0.],
       [1., 0., 0., 1., 0., 0., 0.],
       [0., 0., 1., 0., 0., 0., 1.],
       [0., 0., 1., 1., 0., 0., 0.],
       [0., 1., 0., 0., 0., 0., 1.],
       [0., 0., 1., 0., 1., 0., 0.],
       [1., 0., 0., 0., 0., 1., 0.]])

每个单独的类别现在都有自己的列(每个颜色类别三个,每个尺寸类别四个),并且它们之间的任何顺序都已丢失。

注意:由于独热编码消除了类别之间的任何固有顺序,因此它是编码名义特征的理想选择。然而,这种选择也伴随着成本:我们往往倾向于扩大数据集的大小,因为我们不得不用一个包含大量二进制特征列的列来替换一个类别列,每个类别一个。

我们原始的时尚数据集是 8 个示例×2 个特征。使用有序编码,它仍然是 8×2,尽管对名义特征,即颜色,施加了一个强制排序。使用独热编码,尺寸变为 8×7,有序特征尺寸的固有顺序被移除。

8.1.3 使用目标统计量进行编码

我们现在将重点转向使用目标统计量进行编码,或称为目标编码,这是一种监督编码技术的例子。与无监督编码方法相比,监督编码方法使用标签来编码分类特征。

使用目标统计量进行编码背后的思想相当简单:对于每个类别,我们计算一个统计量,如目标(即标签)的平均值,并用这个新计算的数值统计量替换类别。使用标签信息进行编码通常有助于克服无监督编码方法的缺点。

与独热编码不同,目标编码不会创建任何额外的列,这意味着编码后的整体数据集的维度保持不变。与有序编码不同,目标编码不会在类别之间引入虚假的关系。

贪婪目标编码

在上一节原始的时尚数据集中,回想一下,每个训练示例是一件 T 恤,有两个属性——颜色和尺寸——以及要预测的目标是成本。假设我们想要用目标统计量来编码颜色特征。这个特征有三个类别——红色、蓝色和绿色——需要编码。

图 8.2 说明了使用目标统计量进行编码对红色类别的工作方式。

CH08_F02_Kunapuli

图 8.2 特征颜色的类别红色被其目标统计量替换,即所有目标值(成本)的平均值(均值),对应于颜色为红色的示例。这被称为贪婪目标编码,因为所有训练标签都已用于编码。

有三件 T 恤,x[1],x[5],和x[8],它们的颜色是红色。它们对应的目标值(成本)分别是 8.99,9.99 和 25.00。目标统计量是这些值的平均值:(8.99 + 9.99 + 25.00) / 3 = 14.66。因此,每个红色的实例都被其对应的目标统计量 14.66 所替换。其他两个类别,蓝色和绿色,可以类似地用它们对应的目标统计量 16.82 和 13.99 进行编码。

更正式地说,第j个特征的第k个类别的目标统计量可以使用以下公式计算:

CH08_F02_Kunapuli-eqs-0x

在这里,符号 I(x^(j[i]) = k)表示一个指示函数,当括号内的条件为真时返回 1,为假时返回 0。例如,在我们的时尚数据集中,I(x[1](color) = red)因为第一个示例对应于中红色 T 恤,而 I(x[4](color) = red)因为第四个示例对应于 XL 蓝色 T 恤。

这个计算目标统计的公式实际上计算的是一个平滑平均值而不是简单的平均值。通过向分母添加一个参数a > 0 来执行平滑。这是为了确保具有少量值(因此分母较小)的类别不会得到与其它类别不同缩放的目标统计。分子中的常数p通常是整个数据集的平均目标值,它作为先验,或者作为正则化目标统计的手段。

通常,先验是我们拥有的任何额外知识,我们可以将其传递给学习算法以改进其训练。例如,在贝叶斯学习中,通常指定一个先验概率分布来表达我们对数据集分布的信念。在这种情况下,先验指定了如何对非常罕见出现的类别应用编码:简单地用一个接近p的值替换。

这种目标编码方法被称为贪婪目标编码,因为它使用所有可用的训练数据来计算编码。正如我们将看到的,贪婪编码方法会从训练数据泄露信息到测试集。这种“泄露”是有问题的,因为在一个训练和测试过程中被识别为高性能的模型,在实际部署和生产中往往表现不佳。

信息泄露和分布偏移

许多预处理方法都受到两个常见实际问题的其中一个或两个的影响:从训练到测试集的信息泄露从训练到测试集的分布偏移。这两个问题都会影响我们评估训练模型的能力,以及准确估计它在未来未见数据上的行为,即它如何泛化的能力。

机器学习模型开发的关键步骤之一是创建一个保留测试集,该集用于评估训练好的模型。测试集必须完全从建模的每个阶段(包括预处理、训练和验证)中分离出来,并且仅用于评估模型性能,以模拟模型在未见数据上的性能。为了有效地做到这一点,我们必须确保训练数据中的任何部分都不会进入测试数据。当这种情况在建模过程中发生时,被称为“从训练到测试集的信息泄露”。

当特征信息泄露到测试集时,发生数据泄露;而当目标(标签)信息泄露到测试集时,发生目标泄露。贪婪目标编码导致目标泄露,如图 8.3 所示。在这个例子中,一个包含 12 个数据点的数据集被划分为训练集和测试集。训练集被用来对特征颜色中的类别红色进行贪婪目标编码。更具体地说,从训练集的目标编码被用来转换训练集和测试集。这导致从训练集到测试集的目标信息泄露,使得这是一个目标泄露的实例。

CH08_F03_Kunapuli

图 8.3 展示了从训练集到测试集的目标泄露。训练集中的所有目标(标签)都被贪婪地用来为红色创建编码,这个编码被用来在训练集和测试集中编码这个类别,导致目标泄露。

对于训练-测试集划分的另一个考虑因素是确保训练集和保留的测试集具有相似的分布;也就是说,它们具有相似的统计特性。这通常是通过从整体集中随机采样保留的测试集来实现的。

然而,预处理技术如贪婪目标编码可能会在训练集和测试集之间引入差异,导致训练集和测试集之间的预测偏移,如图 8.4 所示。与之前一样,特征颜色中的类别红色使用贪婪目标统计进行编码。这种编码是通过计算训练数据中颜色等于红色的示例对应的目标的平均值来计算的,其值为 14.66。

然而,如果我们只计算测试数据中颜色等于红色对应的目标的平均值,平均值为 10.47。这种训练集和测试集之间的差异是贪婪目标编码的副产品,它导致测试集的分布相对于训练集分布发生偏移。换句话说,测试集的统计特性现在不再与训练集相似,这对我们的模型评估产生了不可避免的连锁影响。

目标泄露和预测偏移都会将统计偏差引入我们用来评估训练模型泛化性能的性能指标中。通常,它们高估了泛化性能,使得训练模型看起来比实际情况更好,当这种模型部署并无法按预期执行时,这会引发问题。

CH08_F04_Kunapuli

图 8.4 展示了训练集和测试集之间的分布偏移。由于测试集的目标编码是使用训练集计算的,这可能导致测试集(黄色)相对于训练集(红色)的分布和统计特性发生偏移。

保留和留一法目标编码

消除目标泄露和预测偏差的最好(也是最简单)的方法是保留一部分训练数据用于编码。因此,除了训练集和保留测试集之外,我们还需要创建一个保留编码集!

这种方法称为保留目标编码,如图 8.5 所示。在这里,我们的数据集来自图 8.3 和图 8.4,被分为三个集合——一个训练集、一个保留编码集和一个保留测试集——每个集合包含四个数据点。

保留编码集用于计算训练集和测试集的目标编码。这确保了训练集和测试集的独立性,消除了目标泄露。此外,因为训练集和测试集使用相同的目标统计,它也避免了预测偏差。

保留目标编码的一个主要缺点是其数据效率低下。为了避免泄露,一旦使用保留编码集来计算编码,就需要将其丢弃,这意味着可用于建模的大量数据可能会被浪费。

CH08_F05_Kunapuli

图 8.5 中,保留编码将可用数据分为三个集合:训练集和测试集,如常规操作,以及第三个仅用于使用目标统计进行编码的保留测试集。这避免了目标泄露和分布偏移。

避免数据效率低下的一个(不完美)的替代方案是使用留一法(LOO)目标编码,如图 8.6 所示。LOO 编码的工作原理与 LOO 交叉验证(LOO CV)类似,不同之处在于被排除的示例是用于编码而不是验证。

在图 8.6 中,我们看到为了对红色示例 x[5]执行 LOO 目标编码,我们使用其他两个红色训练示例 x[1]和 x[8]来计算目标统计,同时排除 x[5]。然后,依次对其他两个红色训练示例 x[1]和 x[8]重复此过程。不幸的是,LOO 编码不能包括测试集中的示例,因为我们想避免泄露。因此,我们可以像之前一样,对测试集应用贪婪目标编码。

CH08_F06_Kunapuli

图 8.6 中,LOO 目标编码应用于训练数据,以避免创建一个浪费的保留编码集。而不是保留数据的一个子集,只有正在编码的示例被保留。测试数据使用之前的方法,即贪婪目标编码进行编码。

如我们所见,LOO 目标编码过程旨在模拟保留目标编码,同时显著提高数据效率。然而,应该注意的是,这个整体过程并没有完全消除目标泄露和预测偏差问题。

正如我们在第 8.2 节中将要看到的,另一种编码策略称为有序目标统计,旨在进一步缓解目标泄露和预测偏差的问题,同时确保数据和计算效率。

8.1.4 category_encoders 包

本节提供了如何为具有分类特征的数据集组合端到端编码和训练管道的示例。子包 sklearn .preprocessing 提供了一些常见的编码器,如 OneHotEncoder 和 OrdinalEncoder。

然而,我们将使用 category_encoders (mng.bz/41aQ)包,它提供了许多更多的编码策略,包括贪婪和 LOO 目标编码。category_encoders 与 scikit-learn 兼容,这意味着它可以与其他提供 sklearn 兼容接口的集成方法实现一起使用(例如,本书中讨论的 LightGBM 和 XGBoost)。

我们将使用来自 UCI 机器学习仓库的澳大利亚信用批准数据集 (mng.bz/Q8D4)。此数据集的干净版本以及本书的源代码都可用,我们将使用此版本来演示实际中的分类编码。该数据集包含六个连续特征、四个二进制特征和四个分类特征,任务是确定是否批准或拒绝信用卡申请,即二元分类。

首先,让我们加载数据集并查看特征名称和前几行:

import pandas as pd
df = pd.read_csv('./data/ch08/australian-credit.csv')
df.head()

此代码片段以表格形式打印数据集的前几行,如图 8.7 所示。

CH08_F07_Kunapuli

图 8.7 来自 UCI 机器学习仓库的澳大利亚信用批准数据集。属性名称已被更改,以保护数据集中代表个人的隐私。

特征名称的形式为 f1-bin、f2-cont 或 f5-cat,表示列索引以及特征是否为二进制、连续或分类。为了保护申请人的隐私,类别字符串和名称已被替换为整数值;也就是说,分类特征已经使用序数编码处理过!

让我们将列分为特征和标签,然后像往常一样进一步分割为训练集和测试集:

X, y = df.drop('target', axis=1), df['target']
from sklearn.model_selection import train_test_split
Xtrn, Xtst, ytrn, ytst = train_test_split(X, y, test_size=0.2,
                                          random_state=13)

此外,让我们明确识别我们感兴趣的预处理中的分类和连续特征:

cat_features = ['f4-cat', 'f5-cat', 'f6-cat', 'f12-cat']
cont_features = ['f2-cont', 'f3-cont', 'f7-cont', 'f10-cont', 
                 'f13-cont', 'f14-cont']

我们将以不同的方式预处理连续和分类特征。连续特征将被标准化;也就是说,连续特征的每一列将被重新缩放,以具有零均值和单位标准差。这种缩放确保不同的列不会具有截然不同的尺度,这可能会破坏下游学习算法。

分类特征将使用独热编码进行预处理。为此,我们将使用来自 category_encoders 包的 OneHotEncoder。我们将创建两个独立的预处理管道,一个用于连续特征,另一个用于分类特征:

import category_encoders as ce
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline

preprocess_continuous = Pipeline(steps=[('scaler', StandardScaler())]) 
preprocess_categorical = Pipeline(steps=[('encoder', 
                                  ce.OneHotEncoder(cols=cat_features))])  

注意,ce.OneHotEncoder 需要我们明确指定对应于分类特征的列,否则它将对所有列应用编码。

现在我们有了两个独立的管道,我们需要将这些管道组合起来,以确保正确的预处理应用于正确的特征类型。我们可以使用 scikit-learn 的 ColumnTransformer 来实现这一点,它允许我们将不同的步骤应用于不同的列:

from sklearn.compose import ColumnTransformer
ct = ColumnTransformer(
         transformers=[('continuous',                                ❶
                            preprocess_continuous, cont_features),
                       ('categorical',                               ❷
                            preprocess_categorical, cat_features)], 
                       remainder='passthrough')                      ❸

❶ 在这里预处理连续特征

❷ 在这里预处理分类特征

❸ 保持剩余特征不变

现在,我们可以在训练集上拟合一个预处理程序,并将转换应用于训练集和测试集:

Xtrn_one_hot = ct.fit_transform(Xtrn, ytrn)
Xtst_one_hot = ct.transform(Xtst)

观察到测试集没有被用于拟合预处理管道。这是一个微妙但重要的实际步骤,以确保测试集被保留,并且由于预处理而没有意外数据或目标泄漏。现在,让我们看看独热编码对我们特征集大小做了什么:

print('Num features after ONE HOT encoding = {0}'.format(
                                                   Xtrn_one_hot.shape[1]))
Num features after ONE HOT encoding = 38

由于独热编码为每个分类特征的每个类别引入了一个新列,因此列的总数从 14 增加到 38!现在让我们在预处理后的数据集上训练和评估 RandomForestClassifier:

from sklearn.ensemble import RandomForestClassifier
model = RandomForestClassifier(n_estimators=200, 
                               max_depth=6, criterion='entropy')
model.fit(Xtrn_one_hot, ytrn)

from sklearn.metrics import accuracy_score
ypred = model.predict(Xtst_one_hot)
print('Model Accuracy using ONE HOT encoding = {0:5.3f}%'.
       format(100 * accuracy_score(ypred, ytst)))

Model Accuracy using ONE HOT encoding = 89.855%

我们的独热编码策略学习了一个模型,其保留测试准确率为 89.9%。除了 OneHotEncoder 和 OrdinalEncoder 之外,category_encoders 包还提供了许多其他编码器。对我们感兴趣的编码器有两个:贪婪目标编码器(TargetEncoder)和 LeaveOneOutEncoder,它们可以像 OneHotEncoder 一样使用。具体来说,我们只需在以下代码中将 OneHotEncoder 替换为 TargetEncoder:

preprocess_categorical = \ 
    Pipeline(steps=[('encoder', ce.TargetEncoder(cols=cat_features, 
                                                 smoothing=10.0))])  

TargetEncoder 取一个额外的参数,即平滑度,这是一个正值,它结合了平滑和应用先验的效果(参见第 8.1.2 节)。更高的值会强制进行更高的平滑,并可以对抗过拟合。预处理和训练后,我们有以下结果:

Num features after GREEDY TARGET encoding = 14
Model Accuracy using GREEDY TARGET encoding = 91.304%

与独热编码不同,贪婪目标编码不会添加任何新列,这意味着数据集的整体维度保持不变。我们可以以类似的方式使用 LeaveOneOutEncoder:

preprocess_categorical = Pipeline(steps=[('encoder',
                             ce.LeaveOneOutEncoder(cols=cat_features,
                                                   sigma=0.4))]) 

sigma 参数是一个噪声参数,旨在减少过拟合。用户手册建议使用 0.05 到 0.6 之间的值。预处理和训练后,我们再次得到以下结果:

Num features after LEAVE-ONE-OUT TARGET encoding = 14
Model Accuracy using LEAVE-ONE-OUT TARGET encoding = 90.580%

与 TargetEncoder 一样,由于预处理,特征数量保持不变。

8.2 CatBoost:有序提升框架

CatBoost 是由 Yandex 开发的另一个开源梯度提升框架。CatBoost 对经典的牛顿提升方法进行了三项主要改进:

  • 它专门针对分类特征,与其他更通用的提升方法不同。

  • 它使用有序提升作为其底层集成学习方法,这使得它在训练过程中可以隐式地解决目标泄漏和预测偏移问题。

  • 它使用无知的决策树作为基估计器,这通常会导致更快的训练时间。

注意:CatBoost 在许多平台上都可用,适用于 Python。有关安装的详细说明,请参阅 CatBoost 安装指南,网址为mng.bz/X5xE。在撰写本文时,CatBoost 仅支持 64 位版本的 Python。

8.2.1 有序目标统计量和有序提升

CatBoost 以两种方式处理分类特征:(1) 通过使用目标统计量将分类特征编码,如之前所述,以及(2) 通过巧妙地创建特征的分类组合(并将它们也用目标统计量进行编码)。虽然这些修改使 CatBoost 能够无缝处理分类特征,但它们确实引入了一些必须解决的问题。

正如我们之前看到的,使用目标统计量进行编码会引入目标泄漏,更重要的是,在测试集中会产生预测偏移。处理这个问题最理想的方法是创建一个保留编码集。

仅为了编码而保留训练示例,而不做其他任何事情,这相当浪费数据,这意味着这种方法在实践中很少使用。另一种方法,LOO 编码,更节省数据,但并不能完全缓解预测偏移。

除了编码特征的问题外,梯度提升和牛顿提升都在迭代之间重用数据,导致梯度分布偏移,这最终会导致进一步的预测偏移。换句话说,即使我们没有分类特征,我们仍然会有预测偏移问题,这会偏误我们对模型泛化的估计!

CatBoost 通过使用排列对训练示例进行排序来解决预测偏移这一核心问题,以(1)计算编码分类变量(称为有序目标统计量)的目标统计量,以及(2)训练其弱估计器(称为有序提升)。

有序目标统计量

在本质上,排序原则简单而优雅,包括两个步骤:

  1. 根据随机排列重新排序训练示例。

  2. 为了计算第i个训练示例的目标统计量,根据这个随机排列使用前i - 1 个训练示例。

这在图 8.8 中用八个训练示例进行了说明。首先,示例被随机排列成随机顺序:4、7、1、8、2、6、5、3。现在,为了计算每个训练示例的目标统计量,我们假设这些示例是按顺序到达的。

例如,为了计算示例 2 的目标统计量,我们只能使用我们之前“看到”的序列中的示例:4、7、1 和 8。然后,为了计算示例 6 的目标统计量,我们只能使用我们之前已经看到的序列中的示例,现在:4、7、1、8、以及 2,依此类推。

CH08_F08_Kunapuli

图 8.8 展示了有序目标统计量首先将示例随机排列成随机序列,仅使用有序序列中的先前示例来计算目标统计量。

因此,为了计算第i个训练示例的编码,有序目标统计从不使用其自己的目标值;这种行为类似于 LOO 目标编码。两者之间的关键区别是有序目标统计使用它已经看到的示例的“历史”概念。

这种方法的缺点之一是,在随机序列中较早出现的训练示例编码的示例数量要少得多。为了在实践中补偿这一点并增加鲁棒性,CatBoost 维护几个序列(即历史记录),这些序列反过来又是随机选择的。这意味着 CatBoost 在每个迭代中重新计算分类变量的目标统计。

有序提升

CatBoost 本质上是一种牛顿提升算法(参见第六章);也就是说,它使用损失函数的一阶和二阶导数来训练其组成部分的弱估计器。

如前所述,预测偏移有两个来源:变量编码和梯度计算本身。为了避免由于梯度引起的预测偏移,CatBoost 将排序的想法扩展到训练其弱学习器。另一种思考方式是牛顿提升+排序=CatBoost。

图 8.9 说明了有序提升,类似于有序目标统计。例如,为了计算示例 2 的残差和梯度,有序提升仅使用它之前看到的序列中的示例来训练模型:4, 7, 1, 和 8。与有序目标统计一样,CatBoost 使用多个排列来增加鲁棒性。这些残差现在用于训练其弱估计器。

CH08_F09_Kunapuli

图 8.9 有序提升也随机排列示例,并仅使用有序序列中的先前示例来计算梯度(残差)。这里展示了在第 4 次迭代(使用估计器 M4 示例x[2])时如何计算残差,在第 5 次迭代(使用估计器 M5 示例x[6])时如何计算,依此类推。

8.2.2 无意识决策树

与 XGBoost 和 CatBoost 等牛顿提升实现之间的另一个关键区别是基估计器。XGBoost 使用标准决策树作为弱估计器,而 CatBoost 使用无意识决策树

无意识决策树在整个树的某个级别(深度)的所有节点中使用相同的分割标准。这如图 8.10 所示,比较了具有四个叶子节点的标准决策树和无意识决策树。

CH08_F10_Kunapuli

图 8.10 比较标准决策树和无意识决策树,每个都有四个叶子节点。观察发现,无意识决策树深度 2 的决策节点都是相同的(大小<15)。这是无意识决策树的关键特性:每个深度只学习一个分割标准。

在这个例子中,观察第二层无知的树(右侧)在每个节点都使用相同的决策标准,即大小 < 15。虽然这是一个简单的例子,但请注意,我们只需要为无知的树学习两个分割标准,而不是标准的决策树。这使得无知的树更容易且更高效地训练,从而加快了整体训练速度。此外,无知的树是平衡且对称的,这使得它们更简单,更不容易过度拟合。

8.2.3 CatBoost 的实际应用

本节展示了如何使用 CatBoost 创建训练管道。我们还将查看如何设置学习率并采用早期停止作为控制过度拟合的手段的示例:

  • 通过选择一个有效的学习率,我们试图控制模型学习的速率,使其不会快速拟合并过度拟合训练数据。我们可以将其视为一种主动建模方法,其中我们试图确定一个好的训练策略,以便它能够导致一个好的模型。

  • 通过实施早期停止,我们试图在观察到模型开始过度拟合时立即停止训练。我们可以将其视为一种反应式建模方法,其中我们考虑在认为我们有一个好模型时立即终止训练。

我们将使用在 8.1.4 节中使用的澳大利亚信用批准数据集。以下列表提供了一个如何使用 CatBoost 的简单示例。

列表 8.1 使用 CatBoost

import pandas as pd
df = pd.read_csv('./data/ch08/australian-credit.csv')       ❶
cat_features = ['f4-cat', 'f5-cat', 'f6-cat', 'f12-cat']    ❷

X, y = df.drop('target', axis=1), df['target']

from sklearn.model_selection import train_test_split
Xtrn, Xtst, ytrn, ytst = train_test_split(                  ❸
                             X, y, test_size=0.2)

from catboost import CatBoostClassifier
ens = CatBoostClassifier(iterations=5, depth=3,             ❹
                         cat_features=cat_features)         ❺
ens.fit(Xtrn, ytrn)
ypred = ens.predict(Xtst)
print('Model Accuracy using CATBOOST = {0:5.3f}%'.
      format(100 * accuracy_score(ypred, ytst)))

❶ 将数据集作为 pandas DataFrame 加载

❷ 明确识别分类特征

❸ 准备训练和评估数据

❹ 训练一个由五个深度为 3 的无知的树组成的集成

❺ 确保 CatBoost 知道哪些特征是分类的

此列表如下训练和评估 CatBoost 模型:

Model Accuracy using CATBOOST = 83.333%

使用 CatBoost 进行交叉验证

CatBoost 为回归和分类任务提供了许多损失函数,以及许多用于控制训练各个方面(包括通过控制集成的复杂性(每次迭代训练一棵树)和基估计器的复杂性(无知的决策树深度))以控制过度拟合的超参数)的功能。

除了这些之外,另一个关键的超参数是学习率。回想一下,学习率允许我们更好地控制集成复杂性的增长速度。因此,在实践中为我们的数据集确定一个最佳的学习率可以帮助避免过度拟合,并在训练后很好地泛化。

与之前的集成方法一样,我们将使用 5 折交叉验证来搜索几个不同的超参数组合,以确定最佳模型。以下列表说明了如何使用 CatBoost 进行交叉验证。

列表 8.2 使用 CatBoost 的交叉验证

params = {'depth': [1, 3],
          'iterations': [5, 10, 15], 
          'learning_rate': [0.01, 0.1]}                 ❶

ens = CatBoostClassifier(cat_features=cat_features)     ❷
grid_search = ens.grid_search(params, Xtrn, ytrn,       ❸
                              cv=5, refit=True)         ❹

print('Best parameters: ', grid_search['params'])
ypred = ens.predict(Xtst)
print('Model Accuracy using CATBOOST = {0:5.3f}%'.
      format(100 * accuracy_score(ypred, ytst)))

❶ 创建可能的参数组合的网格

❷ 明确识别分类特征

❸ 使用 CatBoost 内置的网格搜索功能

❹ 执行 5 折交叉验证,然后使用网格搜索后确定的最佳参数重新拟合模型

此列表使用 5 折交叉验证评估了在参数中指定的(2 x 3 x 2 = 12)超参数组合,以确定最佳参数组合,并使用它重新拟合(即重新训练)最终模型:

Best parameters:  {'depth': 3, 'iterations': 15, 'learning_rate': 0.1}
Model Accuracy using CATBOOST = 82.609%

使用 CatBoost 进行早期停止

与其他集成方法一样,CatBoost 在每次迭代中都会向集成中添加一个新的基估计器。这导致整个集成在训练过程中的复杂性稳步增加,直到模型开始过拟合训练数据。与其他集成方法一样,可以使用 CatBoost 的早期停止,通过评估集监控 CatBoost 的性能,一旦性能没有显著改进,就立即停止训练。

在列表 8.3 中,我们初始化 CatBoost 以训练 100 棵树。通过 CatBoost 的早期停止,可以提前终止训练,从而确保模型质量以及训练效率,类似于 LightGBM 和 XGBoost。

列表 8.3 使用 CatBoost 进行早期停止

ens = CatBoostClassifier(iterations=100, depth=3,         ❶
                         cat_features=cat_features,
                         loss_function='Logloss')

from catboost import Pool
eval_set = Pool(Xtst, ytst, cat_features=cat_features)    ❷

ens.fit(Xtrn, ytrn, eval_set=eval_set, 
        early_stopping_rounds=5,                          ❸
        verbose=False, plot=True)                         ❹

ypred = ens.predict(Xtst)
print('Model Accuracy using CATBOOST = {0:5.3f}%'.
       format(100 * accuracy_score(ypred, ytst)))

❶ 初始化一个具有 100 个集成大小的 CatBoostClassifier

❷ 通过合并“Xtst”和“ytst”创建一个评估集

❸ 如果在五轮之后没有检测到改进,则停止训练

❹ 将 CatBoost 的绘图设置为“true”以绘制训练和评估曲线

此代码生成了如图 8.11 所示的训练和曲线,其中可以观察到过拟合的影响。大约在第 80 次迭代时,训练曲线(虚线)仍在持续下降,而评估曲线已经开始变平。

CH08_F11_Kunapuli

图 8.11 CatBoost 生成的训练(虚线)和评估(实线)曲线。第 88 次迭代的点表示早期停止点。

这意味着训练错误在继续下降,而我们的验证集没有相应的下降,表明过拟合。CatBoost 观察到这种行为持续了五次迭代(因为 early_stopping_rounds=5),然后终止了训练。

最终模型报告了测试集性能为 82.61%,经过 88 轮迭代后达到,通过早期停止避免了按照最初指定的 100 次迭代进行训练:

Model Accuracy using CATBOOST = 82.609%

8.3 案例研究:收入预测

在本节中,我们研究从人口统计数据中预测收入的问题。人口统计数据通常包含许多不同类型的特征,包括分类和连续特征。我们将探讨两种训练集成方法的途径:

  • 方法 1(第 8.3.2 节和第 8.3.3 节)—使用 category_encoders 包预处理分类特征,然后使用 scikit-learn 的随机森林、LightGBM 和 XGBoost 对预处理后的特征进行集成训练。

  • 方法 2(第 8.3.4 节)—使用 CatBoost 在训练过程中直接处理分类特征,通过有序目标统计和有序提升。

8.3.1 成人数据集

本案例研究使用了 UCI 机器学习仓库中的成人数据集。任务是预测个人每年收入是否超过或低于 50,000 美元,基于教育、婚姻状况、种族和性别等几个人口统计指标。

该数据集包含了一系列分类和连续特征,这使得它成为本案例研究的理想选择。数据集和源代码都可用。让我们加载数据集并可视化它(见图 8.12):

import pandas as pd
df = pd.read_csv('./data/ch08/adult.csv')
df.head()

CH08_F12_Kunapuli

图 8.12 成人数据集包含分类和连续特征。

该数据集包含几个分类特征:

  • workclass—描述了就业类型的分类,包含八个类别:私营、自营非营利、自营营利、联邦政府、地方政府、州政府、无报酬、从未工作过。

  • education—描述了达到的最高教育水平,包含 16 个类别:学士学位、一些大学、11 年级、高中毕业、专业学校、大专、专科、9 年级、7-8 年级、12 年级、硕士学位、1-4 年级、10 年级、博士学位、5-6 年级、学前教育。

  • marital-status—描述了婚姻状况,有七个类别:已婚平民配偶、离婚、未婚、分居、丧偶、已婚配偶缺席、已婚 AF 配偶。

  • occupation—描述了职业领域的分类,包含 14 个类别:技术支持、工艺维修、其他服务、销售、执行管理、专业特长、搬运清洁工、机器操作检查员、行政文员、农业渔业、运输搬运、私人家庭服务、保护服务、武装部队。

  • relationship—描述了关系状态,有六个类别:妻子、亲生子女、丈夫、非家庭、其他亲属、未婚。

  • sex—描述性别,有两个类别:男性、女性。

  • native-country—这是一个高(ish)基数分类变量,描述了原籍国,包含 30 个独特的国家。

此外,该数据集还包含几个连续特征,如年龄、教育年限、每周工作时间、资本收益和损失等。

公平性、偏见和成人数据集

该数据集最初由美国人口普查局于 1994 年进行的 1994 年当前人口调查创建,此后已被用于数百篇研究论文、机器学习教程和课堂项目,既作为基准数据集,也作为教学工具。

近年来,它也已成为人工智能领域公平性研究的重要数据集,也称为算法公平性,该研究探索确保机器学习算法不会加强现实世界的偏见并努力追求公平结果的方法。

例如,假设我们正在训练一个集成模型,用于根据历史数据筛选并接受或拒绝软件工程职位的简历。历史招聘数据显示,男性比女性更有可能获得这些职位。如果我们使用这样的有偏数据来训练,机器学习模型(包括集成方法)将在学习过程中捕捉到这种偏见,并在部署时做出有偏的招聘决策,从而导致现实世界的歧视性结果!

成人数据集也存在类似的偏见,这种偏见是微妙的,因为预测目标(“个人一年内是否会赚得超过或低于 50,000 美元?”)和数据特征都不成比例地歧视女性和少数族裔。这意味着使用此数据集训练的模型也将具有歧视性,不应在实际的数据驱动决策中使用。有关这个令人着迷且极其重要的机器学习领域的更多详细信息,请参阅 Ding 等人^a 的文章。

最后,应该注意的是,这个数据集在这里仅作为教学工具,用于说明处理具有分类变量的数据集的不同方法。

^a 《退休成年人:公平机器学习的新数据集》,作者:Frances Ding、Moritz Hardt、John Miller 和 Ludwig Schmidt。第 32 届国际神经网络信息处理系统会议论文集(2021)(mng.bz/ydWe)。

在以下列表中,我们使用 seaborn 包探索了一些分类特征,该包提供了一些方便的功能,用于快速探索和可视化数据集。

列表 8.4 成人数据集中的分类特征

import matplotlib.pyplot as plt
import seaborn as sns

fig, ax = plt.subplots(nrows=3, ncols=1, figsize=((12, 6)))
fig.suptitle('Category counts of select features in the adult data set')

sns.countplot(x='workclass', hue='salary', data=df, ax=ax[0])
ax[0].set(yscale='log')

sns.countplot(x='marital-status', hue='salary', data=df, ax=ax[1])
ax[1].set(yscale='log')

sns.countplot(x='race', hue='salary', data=df, ax=ax[2])
ax[2].set(yscale='log')
fig.tight_layout()

此列表生成图 8.13。

CH08_F13_Kunapuli

图 8.13 在成人数据集中可视化三个分类特征的类别计数:workclass、marital-status 和 race。注意,所有的 y 轴都是对数尺度(基数为 10)。

8.3.2 创建预处理和建模管道

列表 8.5 描述了如何准备数据。特别是,我们使用 sklearn .preprocessing.LabelEncoder 将目标标签从字符串(<=50k, >50k)转换为数值(0/1)。LabelEncoder 与 OrdinalEncoder 相同,但它专门设计用于处理 1D 数据(目标)。

列表 8.5 准备成人数据集

X, y = df.drop('salary', axis=1), df['salary']               ❶

from sklearn.preprocessing import LabelEncoder
y = LabelEncoder().fit_transform(y)                          ❷

from sklearn.model_selection import train_test_split
Xtrn, Xtst, ytrn, ytst = \                                   ❸
    train_test_split(X, y, test_size=0.2)   

features = X.columns
cat_features = ['workclass', 'education', 'marital-status',
                'occupation', 'relationship', 'race', 'sex',
                'native-country']                            ❹
cont_features = features.drop(cat_features).tolist()

❶ 将数据分为特征和目标

❷ 编码标签

❸ 将数据分为训练集和测试集

❹ 明确识别分类和连续特征

回想一下,任务是预测收入是否超过 50,000 美元(标签 y=1)或低于 50,000 美元(标签 y=0)。关于这个数据集,有一点需要注意,那就是它是不平衡的;也就是说,它包含两个类别的不同比例:

import numpy as np
n_pos, n_neg = np.sum(y > 0)/len(y), np.sum(y <= 0)/len(y)
print(n_pos, n_neg)
0.24081695331695332 0.7591830466830467

在这里,我们看到正负分布为 24.1%到 75.9%(不平衡),而不是 50%到 50%(平衡)。这意味着评估指标如准确率可能会无意中歪曲我们对模型性能的看法,因为它们假设数据集是平衡的。

接下来,我们定义一个预处理函数,它可以与不同类型的类别编码器一起重复使用。这个函数有两个预处理管道,一个用于仅应用于连续特征,另一个用于类别特征。连续特征使用 StandardScaler 进行预处理,将每个特征列标准化为零均值和单位标准差。

此外,两个管道都有一个 SimpleImputer 来填充缺失值。缺失的连续值用相应的中值特征值填充,而缺失的类别特征在编码之前填充为一个新的类别,称为'missing'。

例如,特征 workclass 有缺失值(用'?'表示),在建模目的上被视为一个单独的类别。下面的列表实现了针对连续和类别特征的单独预处理管道,并返回一个 ColumnTransformer,可以直接应用于该领域的任何训练数据子集。

列表 8.6 预处理管道

from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer

import category_encoders as ce

def create_preprocessor(encoder):
    preprocess_continuous = \                                    ❶
        Pipeline(steps=[     
            ('impute_missing', SimpleImputer(strategy='median')),
            ('normalize', StandardScaler())])

    preprocess_categorical = \ 
        Pipeline(steps=[                                         ❷
            ('impute_missing', SimpleImputer(strategy='constant', 
                                             fill_value='missing')),
            ('encode', encoder())])

    transformations = \
        ColumnTransformer(transformers=[                         ❸
            ('continuous', preprocess_continuous, cont_features),
            ('categorical', preprocess_categorical, cat_features)])

    return transformations

❶ 连续特征的预处理管道

❷ 类别特征的预处理管道

❸ 用于组合管道的“ColumnTransformer”对象

此列表将创建并返回一个 scikit-learn ColumnTransformer 对象,它可以应用于训练集和测试集的类似预处理策略,确保一致性并最小化数据泄露。

最后,我们定义一个函数来训练和评估不同类型的集成,将它们与各种类型的类别编码相结合。这将使我们能够通过将集成学习包与各种类型的类别编码器相结合来创建不同的集成模型。

列表 8.7 中的函数允许我们传递一个集成以及集成参数的网格,用于集成参数选择。它使用 k 折交叉验证结合随机搜索来识别最佳集成参数,然后在训练最终模型之前使用这些最佳参数。

一旦训练完成,该函数将使用三个指标在测试集上评估最终模型性能:准确率、平衡准确率和 F1 分数。当数据集不平衡时,平衡准确率和 F1 分数是特别有用的指标,因为它们通过根据每个类别在标签中出现的频率来加权模型在每个类上的性能来考虑标签不平衡。

列表 8.7 训练和评估编码器和集成组合

from sklearn.model_selection import RandomizedSearchCV
from sklearn.metrics import accuracy_score, f1_score, balanced_accuracy_score

def train_and_evaluate_models(ensemble, parameters,              ❶
                              n_iter=25,                         ❷
                              cv=5):                             ❸
    results = pd.DataFrame()

    for encoder in [ce.OneHotEncoder,                            ❹
                    ce.OrdinalEncoder,                           ❹
                    ce.TargetEncoder]:                           ❹
        preprocess_pipeline = \                                  ❺
            create_preprocessor(encoder)    

        model = Pipeline(steps=[
                         ('preprocess', preprocess_pipeline),                           
                         ('crossvalidate', 
                           RandomizedSearchCV(
                                ensemble, parameters,                    
                                n_iter=n_iter, cv=cv,            ❻
                                refit=True,                      ❼
                                verbose=2))])
        model.fit(Xtrn, ytrn)

        ypred_trn = model.predict(Xtrn)
        ypred_tst = model.predict(Xtst)    

        res = {'Encoder': encoder.__name__,                      ❽
               'Ensemble': ensemble.__class__.__name__, 
               'Train Acc': accuracy_score(ytrn, ypred_trn),
               'Train B Acc': balanced_accuracy_score(ytrn,
                                                      ypred_trn), 
               'Train F1': f1_score(ytrn, ypred_trn), 
               'Test Acc': accuracy_score(ytst, ypred_tst),
               'Test B Acc': balanced_accuracy_score(ytst,
                                                     ypred_tst),
               'Test F1': f1_score(ytst, ypred_tst)}
        results = pd.concat([results,
                             pd.DataFrame.from_dict([res])], ignore_index=True)

    return results

❶ 指定集成和参数网格

❷ 随机网格搜索的最大参数组合数

❸ 参数选择的 CV 折叠数

❹ 要尝试的不同类别编码策略

❺ 初始化预处理管道(参见表 8.6)

❻ 使用随机网格搜索进行参数选择

❼ 使用最佳参数重新拟合最终集成

❽ 评估最终集成性能并保存结果

8.3.3 类别编码和集成

在本节中,我们将训练各种编码器和集成方法的组合。特别是,我们考虑以下内容:

  • 编码器——One-hot、序数和贪婪目标编码(来自 category_encoders 包)

  • 集成——scikit-learn 的随机森林、LightGBM 的梯度提升和 XGBoost 的牛顿提升

对于编码器和集成组合的每一种组合,我们遵循与列表 8.6 和 8.7 中实现相同的步骤:预处理特征,执行集成参数选择以获得最佳集成参数,使用最佳参数组合重新拟合最终集成模型,并评估最终模型。

随机森林

以下列表训练并评估了类别编码(one-hot、序数和贪婪目标)和随机森林的最佳组合。

列表 8.8:类别编码后使用随机森林进行集成

from sklearn.ensemble import RandomForestClassifier

ensemble = RandomForestClassifier(n_jobs=-1)
parameters = {'n_estimators': [25, 50, 100, 200],               ❶
              'max_depth': [3, 5, 7, 10],                       ❷
              'max_features': [0.2, 0.4, 0.6, 0.8]}             ❸

rf_results = train_and_evaluate_models(ensemble, parameters, 
                                       n_iter=25, cv=5)         ❹

❶ 随机森林集成中的树的数量

❷ 集成中单个树的最大深度

❸ 树学习期间使用的特征/列的分数

❹ 使用 25 个参数组合和 5 折交叉验证的随机网格搜索

此列表返回以下结果(编辑以适应页面):

 Encoder  Test Acc  Test B Acc  Test F1  Train Acc  Train B Acc  Train F1
  OneHot     0.862       0.766    0.669      0.875        0.783       0.7
 Ordinal     0.861       0.756    0.657      0.874        0.773     0.688
  Target     0.864       0.774    0.679      0.881        0.797      0.72

观察训练集和测试集的纯准确率(Acc)和平衡准确率(B Acc)或 F1 分数(F1)之间的差异。由于平衡准确率明确考虑了类别不平衡,它比准确率提供了更好的模型性能估计。这说明了使用正确的指标来评估我们的模型的重要性。

虽然所有编码方法在以纯准确率作为评估指标时看起来同样有效,但使用目标统计信息进行编码似乎在区分正例和负例方面最为有效。

LightGBM

接下来,我们使用 LightGBM 重复这一训练和评估过程,其中我们训练了一个包含 200 棵树的集成,如下所示。其他几个集成超参数将使用 5 折交叉验证进行选择:最大树深度、学习率、袋装分数和正则化参数。

列表 8.9:类别编码后使用 LightGBM 进行集成

from lightgbm import LGBMClassifier

ensemble = LGBMClassifier(n_estimators=200, n_jobs=-1)

parameters = {
    'max_depth': np.arange(3, 10, step=1),              ❶
    'learning_rate': 2.**np.arange(-8, 2, step=2),      ❷
    'bagging_fraction': [0.4, 0.5, 0.6, 0.7, 0.8],      ❸
    'lambda_l1': [0, 0.01, 0.1, 1, 10],                 ❹
    'lambda_l2': [0, 0.01, 0.1, 1e-1, 1, 10]}

lgbm_results = train_and_evaluate_models(ensemble, parameters, 
                                         n_iter=50, cv=5)

❶ 集成中单个树的最大深度

❷ 梯度提升的学习率

❸ 树学习期间使用的示例分数

❹ 权重正则化参数

此列表返回以下结果(编辑以适应页面):

Encoder  Test Acc  Test B Acc  Test F1  Train Acc  Train B Acc  Train F1
  OneHot    0.874       0.802    0.716      0.891        0.824     0.754
Ordinal     0.874       0.802    0.717      0.892        0.825     0.757
 Target     0.873       0.796     0.71      0.886        0.815     0.741

使用 LightGBM,所有三种编码方法都导致具有大致相似泛化性能的集成,这由测试集的平衡准确率和 F1 分数所证明。整体性能也优于随机森林。

XGBoost

最后,我们同样使用 XGBoost 重复了这一训练和评估过程,其中我们再次训练了一个包含 200 棵树的集成,如下所示。

列表 8.10:类别编码后使用 XGBoost 进行集成

from xgboost import XGBClassifier

ensemble = XGBClassifier(n_estimators=200, n_jobs=-1)
parameters = {
    'max_depth': np.arange(3, 10, step=1),                  ❶
    'learning_rate': 2.**np.arange(-8., 2., step=2),        ❷
    'colsample_bytree': [0.4, 0.5, 0.6, 0.7, 0.8],          ❸
    'reg_alpha': [0, 0.01, 0.1, 1, 10],                     ❹
    'reg_lambda': [0, 0.01, 0.1, 1e-1, 1, 10]}

xgb_results = train_and_evaluate_models(ensemble, parameters, 
                                        n_iter=50, cv=5)

❶ 集成中单个树的最大深度

❷ 牛顿提升的学习率

❸ 树学习期间的特征/列的分数

❹ 权重正则化的参数

该列表返回以下结果(编辑以适应页面):

Encoder  Test Acc  Test B Acc  Test F1  Train Acc  Train B Acc  Train F1
 OneHot     0.875       0.799    0.715      0.896        0.829     0.764
Ordinal     0.873       0.799    0.712      0.891        0.823     0.753
 Target     0.875       0.802    0.717      0.898        0.834     0.771

与 LightGBM 一样,所有三种编码方法都导致 XGBoost 集成具有大致相似的一般化性能。XGBoost 的整体性能与 LightGBM 相似,但优于随机森林。

8.3.4 使用 CatBoost 进行有序编码和提升

最后,我们探索了 CatBoost 在此数据集上的性能。与之前的方法不同,我们不会使用 category_encoders 包。这是因为 CatBoost 使用有序目标统计信息以及有序提升。因此,只要我们清楚地识别出需要使用有序目标统计信息进行编码的类别特征,CatBoost 就会处理其余部分,无需任何额外的预处理!以下列表执行了基于 CV 的随机参数搜索的有序提升。

列表 8.11 使用 CatBoost 进行有序目标编码和有序提升

from catboost import CatBoostClassifier

ensemble = CatBoostClassifier(cat_features=cat_features)
parameters = {
    'iterations': [25, 50, 100, 200],                            ❶
    'depth': np.arange(3, 10, step=1),                           ❷
    'learning_rate': 2**np.arange(-5., 0., step=1),              ❸
    'l2_leaf_reg': [0, 0.01, 0.1, 1e-1, 1, 10]}                  ❹

search = ensemble.randomized_search(parameters, Xtrn, ytrn, 
                                    n_iter=50, cv=5, refit=True, 
                                    verbose=False)               ❺
ypred_trn = ensemble.predict(Xtrn)
ypred_tst = ensemble.predict(Xtst)    

res = {'Encoder': '',
       'Ensemble': ensemble.__class__.__name__, 
       'Train Acc': accuracy_score(ytrn, ypred_trn),
       'Train B Acc': balanced_accuracy_score(ytrn, ypred_trn), 
       'Train F1': f1_score(ytrn, ypred_trn), 
       'Test Acc': accuracy_score(ytst, ypred_tst),
       'Test B Acc': balanced_accuracy_score(ytst, ypred_tst),
       'Test F1': f1_score(ytst, ypred_tst)}

cat_results = pd.DataFrame()
cat_results = pd.concat([cat_results,
                         pd.DataFrame.from_dict([res])], ignore_index=True)

❶ 随机森林集成中的树的数量

❷ 集成中单个树的最大深度

❸ 牛顿提升的学习率

❹ 权重正则化的参数

❺ 使用 CatBoost 的随机搜索功能

CatBoost 提供自己的 randomized_search 功能,其初始化和调用方式类似于我们在上一节中使用的 scikit-learn 的 RandomizedGridCV:

Ensemble  Test Acc  Test B Acc  Test F1  Train Acc  Train B Acc  Train F1
CatBoost      0.87       0.796    0.708      0.888         0.82     0.747

CatBoost 在此数据集上的性能与 LightGBM 和 XGBoost 相当,且优于随机森林。

现在,让我们将所有方法的结果并排放置;在图 8.14 中,我们查看每种方法在测试集上根据平衡准确率评估的性能。

CH08_F14_Kunapuli

图 8.14 各种编码和集成方法组合的测试集性能(使用平衡准确率指标)

在分析这些结果时,始终牢记没有免费的午餐,没有一种方法总是表现最佳。然而,CatBoost 确实享有两个关键优势:

  • 与其他必须使用两步编码+集成方法的集成方法不同,CatBoost 允许对编码和类别特征的处理采取统一的方法。

  • 按设计,CatBoost 减轻了数据泄漏和目标泄漏以及分布偏移问题,这些问题在其他集成方法中通常需要更多的关注。

8.4 编码高基数字符串特征

我们通过探索高基数类别特征的编码技术来结束本章。类别特征的基数简单地是该特征中唯一类别的数量。类别的数量在类别编码中是一个重要的考虑因素。

现实世界的数据集通常包含分类字符串特征,其中特征值是字符串。例如,考虑一个组织中的职位头衔的分类特征。这个特征可以包含从“Intern”到“President and CEO”的几十到几百个职位头衔,每个头衔都有其独特的角色和责任。

这样的特征包含大量类别,并且本质上就是高基数特征。这使诸如独热编码(因为它显著增加了特征维度)或顺序编码(因为自然顺序可能并不总是存在)这样的编码方法不适用。

更重要的是,在现实世界的数据集中,这样的高基数特征也是“脏”的,因为同一个类别有几种不同的变体:

  • 自然变异可能是因为数据是从不同的来源编译的。例如,同一组织中的两个部门可能对完全相同的角色有不同的头衔:“Lead Data Scientist” 和 “Senior Data Scientist。”

  • 许多此类数据集都是手动输入到数据库中的,这会由于拼写错误和其他错误而引入噪声。例如,“Data Scientsit” [原样] 与 “Data Scientist。”

因为两个(或更多!)这样的变体不完全匹配,它们被视为它们自己的独特类别,尽管常识表明它们应该被清理和/或合并。这通过向已经很大的类别集中添加新类别,给高基数字符串特征带来了额外的问题。

为了解决这个问题,我们需要通过 字符串相似度 而不是通过精确匹配来确定类别(以及如何编码它们)。这种方法的直觉是将相似的类别一起编码,就像人类可能做的那样,以确保下游学习算法将它们视为相似(正如它应该做的那样)。

例如,基于相似度的编码会将“Data Scientsit” [原样] 和 “Data Scientist” 编码为具有相似特征的,以便它们在学习算法中看起来几乎相同。基于相似度的编码方法使用 字符串相似度 的概念来识别相似的类别。

这样的字符串相似度度量或指标在自然语言和文本应用中得到了广泛的应用,例如在自动纠错应用、数据库检索或在语言翻译中。

字符串相似度度量

相似度度量 是一个函数,它接受两个对象并返回它们之间的数值相似度。值越高意味着两个对象彼此越相似。字符串相似度度量在字符串上操作。

测量字符串之间的相似度具有挑战性,因为字符串的长度可能不同,并且可能在不同的位置有相似的子字符串。要确定两个字符串是否相似,可能需要匹配所有可能的长度和位置的字符和子序列。这种组合复杂性意味着计算字符串相似度可能非常耗时。

存在几种计算不同长度字符串之间相似度的有效方法。两种常见类型是 基于字符 的字符串相似度和 基于标记 的字符串相似度,这取决于比较的字符串组件的粒度。

基于字符的方法通过在字符级别(插入、删除或替换)需要的操作数量来衡量字符串相似度,以将一个字符串转换为另一个字符串。这些方法非常适合短字符串。

较长的字符串通常被分解成标记,通常是子字符串或单词,称为 n-gram。基于标记的方法在标记级别衡量字符串相似度。

不论你使用哪种字符串相似度指标,相似度分数都可以用来编码高基数特征(通过将相似的字符串类别分组在一起)和脏特征(通过“清理”错误)。

The dirty_cat package

The dirty_cat package (dirty-cat.github.io/stable/index.xhtml) 提供了现成的类别相似度度量,并且可以无缝地用于建模管道。该包提供了三个专门的编码器来处理所谓的“脏类别”,这些类别本质上是有噪声的/或高基数的字符串类别:

  • SimilarityEncoder—使用字符串相似度构建的一元编码版本

  • GapEncoder—通过考虑频繁共现的子字符串组合来编码类别

  • MinHashEncoder—通过应用哈希技术到子字符串来编码类别

我们使用另一个薪资数据集来了解如何在实践中使用 dirty_cat 包。这个数据集是 Data.gov 公开可用的 Employee Salaries 数据集的一个修改版本,目标是预测个人的薪资,给定他们的职位和部门。

首先,我们加载数据集(与源代码一起提供)并可视化前几行:

import pandas as pd
df = pd.read_csv('./data/ch08/employee_salaries.csv')
df.head()

图 8.15 展示了该数据集的前几行。

CH08_F15_Kunapuli

图 8.15 员工薪资数据集主要包含字符串类别。

“salary”列是目标变量,这使得这是一个回归问题。我们将这个数据框拆分为特征和标签:

X, y = df.drop('salary', axis=1), df['salary']
print(X.shape)
(9211, 6)

我们可以通过计算每列的独特类别或值的数量来感知哪些是高基数特征:

for col in X.columns:
    print('{0}: {1} categories'.format(col, df[col].nunique()))

gender: 2 categories
department_name: 37 categories
assignment_category: 2 categories
employee_position_title: 385 categories
underfilled_job_title: 83 categories
year_first_hired: 51 categories

我们看到特征 employee_position_title 有 385 个独特的字符串类别,这使得这是一个高基数特征。直接使用一元编码,比如,会向我们的数据集中引入 385 个新列,从而大大增加列的数量!

相反,让我们看看如何使用 dirty_cat 包来训练 XGBoost 集成模型。首先,让我们明确地识别数据集中不同类型的特征:

lo_card = ['gender', 'department_name', 'assignment_category']
hi_card = ['employee_position_title']
continuous = ['year_first_hired']

接下来,让我们初始化我们想要使用的不同 dirty_cat 编码器:

from dirty_cat import SimilarityEncoder, MinHashEncoder, GapEncoder
encoders = [SimilarityEncoder(),                    ❶
            MinHashEncoder(n_components=100),       ❷
            GapEncoder(n_components=100)]

❶ 指定要使用的字符串相似度度量

❷ 编码维度

对于所有编码方法来说,最重要的编码参数是 is n_components,也称为 编码维度

SimilarityEncoder 测量两个字符串之间的 n-gram 相似性。n-gram 简单地是一个连续的 n 个单词序列。例如,字符串 “I love ensemble methods.” 包含三个 2-gram:“I love”,“love ensemble” 和 “ensemble methods”。两个字符串之间的 n-gram 相似性首先计算每个字符串中所有可能的 n-gram,然后对 n-gram 计算相似性。默认情况下,SimilarityEncoder 构建所有 2-gram、3-gram 和 4-gram,然后使用独热编码对所有相似字符串进行编码。这意味着它将确定自己的编码维度。

要理解编码维度,考虑我们正在对包含 385 个唯一类别的特征 employee_position_title 进行独热编码,这些类别可以通过相似性度量分组为 225 个“相似”类别。独热编码将每个类别值转换为 225 维向量,使得编码维度为 225。

MinHashEncoder 和 GapEncoder 另一方面,可以接受用户指定的编码维度并创建指定大小的编码。在这种情况下,编码维度被指定为 100,对于两者来说,这比独热编码强制使用的要小得多。

实际上,编码维度(n_components)是一个建模选择,最佳值应通过 k 折交叉验证来确定,这取决于模型训练时间与模型性能之间的权衡。

我们将这些内容组合到以下列表中,该列表训练了三个不同的 XGBoost 模型,每个模型对应一种 dirty_cat 编码类型。

列表 8.12 使用高基数特征的编码和集成

from sklearn.preprocessing import OneHotEncoder, MinMaxScaler
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from dirty_cat import SimilarityEncoder, MinHashEncoder, GapEncoder
from xgboost import XGBRegressor
from sklearn.metrics import r2_score

lo_card = ['gender', 'department_name', 
           'assignment_category']                           ❶
hi_card = ['employee_position_title']                       ❷
continuous = ['year_first_hired']                           ❸

encoders = [SimilarityEncoder,
            MinHashEncoder(n_components=100),
            GapEncoder(n_components=100)]

from sklearn.model_selection import train_test_split
Xtrn, Xtst, ytrn, ytst = \
    train_test_split(X, y, test_size=0.2)                   ❹

for encoder in encoders:
    ensemble = XGBRegressor(                                ❺
        objective='reg:squarederror',learning_rate=0.1, 
        n_estimators=100, max_depth=3)
    preprocess = ColumnTransformer(transformers=[
        ('continuous', 
            MinMaxScaler(), continuous),                    ❻
        ('onehot', 
            OneHotEncoder(sparse=False), lo_card),          ❼
        ('dirty', 
            encoder, hi_card)],                             ❽
        remainder='drop')    
    pipe = Pipeline(steps=[('preprocess', preprocess), 
                           ('train', ensemble)])            ❾
    pipe.fit(Xtrn, ytrn)

    ypred = pipe.predict(Xtst)
    print('{0}: {1}'.format(encoder.__class__.__name__, 
                            r2_score(ytst, ypred)))         ❿

❶ 识别低基数特征

❷ 识别高基数特征

❸ 识别连续特征

❹ 将数据集分为训练集和测试集

❺ 使用 XGBoost 作为集成方法

❻ 将连续特征缩放到 [0, 1] 范围内

❼ 对低基数特征进行独热编码

❽ 使用 dirty_cat 编码对高基数特征进行编码

❾ 创建预处理和训练管道

❿ 使用 R² 分数来评估整体性能

在本例中,我们识别了三种不同类型的特征,每种特征我们都会进行不同的预处理:

  • 低基数特征,例如性别(2 个类别)和 department_name(37 个类别),进行独热编码。

  • 高基数特征,例如 employee_position_title,使用 dirty_cat 编码器进行编码。

  • 连续特征,例如 year_first_hired,使用 MinMaxScaler 缩放到 0 到 1 的范围内。

在编码后,我们使用标准均方误差(MSE)损失函数训练了一个 XGBoost 回归器,包含 100 棵树,每棵树的最大深度为 3。训练好的模型使用回归指标 R² 分数进行评估(详见第一章,第 1.3.1 节,以获取详细信息),该分数范围从 -∞ 到 1,数值越接近 1 表示回归器的性能越好:

SimilarityEncoder: 0.8995625658800894
MinHashEncoder: 0.8996750692009536
GapEncoder: 0.8895356402510632

与其他监督方法一样,通常需要使用交叉验证(CV)来确定哪些编码参数能产生针对当前数据集的最佳结果。

摘要

  • 分类别特征是一种数据属性类型,它取离散值,称为类别或类别。因此,类别特征也被称为离散特征。

  • 名义特征是一个没有彼此之间关系的值的类别变量(例如,猫,狗,猪,牛)。

  • 有序特征是一个有序的类别变量,其值是按顺序排列的,要么是递增的,要么是递减的(例如,大一新生,大二,大三,大四)。

  • 独热向量化/编码和有序编码是常用的无监督编码方法。

  • 独热编码为数据集中的每个类别引入了二进制(0-1)列,当特征具有大量类别时可能会效率低下。有序编码为每个类别按顺序引入整数值。

  • 使用目标统计是针对类别特征的有监督编码方法;而不是一个预定的或学习到的编码步骤,类别特征被替换为一个描述类别的统计量(例如,平均值)。

  • 贪婪目标统计使用所有训练数据用于编码,导致训练到测试目标泄漏和分布偏移问题,这些问题会影响我们评估模型泛化性能的方式。

  • 保留法目标统计除了保留测试集外,还使用一个特殊的保留编码集。这消除了泄漏和偏移,但会浪费数据。

  • 留一法(LOO)目标统计和有序目标统计是数据高效地减轻泄漏和偏移的方法。

  • 梯度提升技术使用训练数据用于残差计算和模型训练,这会导致预测偏移和过拟合。

  • 有序提升是对牛顿提升的一种修改,它使用基于排列的集成方法来进一步减少预测偏移。有序提升通过在不同的排列和数据子集上训练一系列模型来解决预测偏移。

  • CatBoost 是一个公开可用的提升库,实现了有序目标统计和有序提升。

  • 虽然 CatBoost 非常适合类别特征,但它也可以应用于常规特征。

  • CatBoost 使用无知的决策树作为弱学习器。无知的决策树在整个树的整个层级/深度上的所有节点都使用相同的分割标准。无知的树是平衡的,不太容易过拟合,并且在测试时可以显著加快执行速度。

  • 高基数特征包含许多独特的类别;对高基数特征进行独热编码会引入大量新的数据列,其中大部分是稀疏的(包含许多零),这会导致学习效率低下。

  • dirty_cat 是一个包,它为离散值特征生成更紧凑的编码,并使用字符串和子字符串相似性以及哈希来创建有效的编码。

9 解释你的集成

本章涵盖

  • 理解玻璃箱模型与黑箱模型,以及全局解释性与局部解释性

  • 使用全局黑箱方法来理解预训练集成行为

  • 使用局部黑箱方法解释预训练集成预测

  • 从零开始训练和使用可解释的全局和局部玻璃箱集成

在训练和部署模型时,我们通常关注模型预测的是什么。然而,同样重要的是,我们也需要了解模型做出预测的原因。理解模型的预测是构建稳健机器学习管道的关键组成部分。这在机器学习模型应用于高风险应用(如医疗保健或金融)时尤其如此。

例如,在糖尿病诊断等医疗诊断任务中,理解模型为何做出特定诊断可以为用户(在这种情况下,医生)提供额外的见解,指导他们做出更好的处方、预防性护理或姑息性护理。这种增加的透明度反过来又增加了对机器学习系统的信任,使得模型的开发用户可以自信地使用它们。

理解模型预测背后的原因对于模型调试、识别失败案例和找到提高模型性能的方法也极为有用。此外,模型调试还可以帮助识别数据本身的偏差和问题。

机器学习模型可以被描述为黑箱模型和玻璃箱模型。由于复杂性(例如,深度神经网络),黑箱模型通常难以理解。这类模型的预测需要专门的工具来使其可解释。本书中涵盖的许多集成(如随机森林和梯度提升)都是黑箱机器学习模型。

玻璃箱模型更直观,更容易理解(例如,决策树)。这类模型的架构使它们本质上具有可解释性。在本章中,我们将从集成方法的角度探讨可解释性和可理解性的概念。

可解释性方法也被描述为全局或局部。全局方法试图广泛地解释模型在不同类型示例中的特征和决策的相关性。局部方法试图具体解释模型针对单个示例和预测的决策过程。

9.1 节介绍了黑箱和玻璃箱机器学习模型的基础知识。本节还从可解释性的角度重新介绍了两个著名的机器学习模型:决策树和广义线性模型(GLMs)。

9.2 节介绍了本章的案例研究:数据驱动营销。本节其余部分将使用该应用来说明可解释性和可理解性的技术。

第 9.3 节介绍了三种用于全局黑盒可解释性的技术:排列特征重要性、部分依赖图和全局代理模型。第 9.4 节介绍了两种用于局部黑盒可解释性的方法:LIME 和 SHAP。第 9.3 节和第 9.4 节中介绍的黑盒方法是模型无关的;也就是说,它们可以用于任何机器学习黑盒。在这些章节中,我们特别关注它们如何用于集成方法。第 9.5 节介绍了一种称为可解释提升机(explainable boosting machines)的玻璃盒方法,这是一种旨在直接可解释的新集成方法,它提供了全局和局部可解释性。

9.1 什么是可解释性?

我们首先介绍机器学习模型的可解释性和可解释性的基础知识,然后转向这些概念如何具体应用于集成方法。机器学习模型的可解释性和可解释性与其结构(例如,树、网络或线性模型)及其参数(例如,树中的分割和叶值、神经网络中的层权重、线性模型中的特征权重)相关。我们的目标是根据输入特征、输出预测和模型内部(即结构和参数)来理解模型的行为。

9.1.1 黑盒与玻璃盒模型

黑盒机器学习模型在描述其模型内部结构方面比较困难。这可能是因为我们没有访问内部模型结构和参数(例如,如果它是由其他人训练的)。即使在我们可以访问模型内部的情况下,模型本身可能足够复杂,以至于分析其输入和输出之间的关系并建立直观理解并不容易(见图 9.1)。

CH09_F01_Kunapuli

图 9.1 在黑盒机器学习模型中,我们只能使用输入-输出对来分析和解释模型行为。黑盒中的模型内部要么不可用,要么不能直接解释。在玻璃盒机器学习模型中,除了输入-输出对之外,模型内部也是直观可解释的。

神经网络和深度学习模型通常被引用为黑盒模型的例子,这归因于它们多层结构和大量网络参数带来的相当复杂性。

这些模型本质上作为黑盒运行:给定一个输入示例,它们提供预测,但它们的内部工作原理对我们来说是透明的。这使得解释模型行为相当困难。

我们迄今为止看到的大多数集成方法——随机森林、AdaBoost、梯度提升和牛顿提升——对我们来说都是有效的黑盒模型。这是因为,尽管单个基估计器本身可能直观且可解释,但集成过程引入了特征之间的复杂交互,这使得解释集成及其预测变得困难。黑盒模型通常需要黑盒解释器,这些解释模型旨在仅使用模型输入和输出,而不是其内部结构来解释模型行为。

相反,玻璃盒机器学习模型更容易理解。这通常是因为它们的模型结构对人类来说是立即直观或可理解的。

例如,考虑一个简单的任务,即仅从两个特征:年龄和血糖测试结果(glc)中进行糖尿病诊断。假设我们已经学习了两个具有相同预测性能的机器学习模型:一个四次多项式分类器和一棵决策树分类器。

此示例的数据集如图 9.2 所示,其中没有糖尿病(类别=-1)的患者用方块表示,有糖尿病(类别=+1)的患者用圆圈表示。两个分类模型也显示在图中。

CH09_F02_Kunapuli

图 9.2 需要被分类为有糖尿病(圆圈)或无糖尿病(方块)的糖尿病患者的问题空间基于两个特征:年龄和 glc。两个机器学习模型——一个四次多项式分类器和一棵决策树分类器——被训练以具有大致相似的预测性能。然而,它们模型内部结构(结构和参数)的性质意味着决策树对于解释和理解模型行为来说更直观(参见 9.1.2 节)。

第一个模型是一个四次多项式分类器。这个分类器由加权特征幂的加性结构组成,权重是模型参数:

CH09_F02_Kunapuli-eqs-0x

此函数返回+1(糖尿病=TRUE)或-1(糖尿病=FALSE)。即使我们有完整的模型,给定一个新患者和相应的诊断预测(例如,糖尿病=TRUE),也不立即清楚模型为何做出这样的决定。是因为患者的年龄?他们的血糖测试结果?这两个因素?这些信息隐藏在复杂的数学计算中,我们仅通过观察模型、其结构和参数很难推断出来。

现在,让我们考虑第二个模型,一个具有单个决策节点形式的决策树

CH09_F02_Kunapuli-eqs-1x

此函数也返回+1(糖尿病=TRUE)或-1(糖尿病=FALSE)。然而,这个决策树的结构很容易解释为

if age > 45 AND glc > 140 then diabetes = TRUE else diabetes = FALSE. 

该模型的解释相当直接:任何年龄超过 45 岁且血糖测试结果超过 140 的患者将被诊断为糖尿病。

总结来说,尽管多项式分类器的完整模型内部结构对我们来说是可用的,但由于模型内部不直观或不可解释,模型可能仍然像一个黑盒。另一方面,决策树如何表示其学习到的知识的天生性质使得它更容易解释,使其成为一个玻璃盒模型。

在本节的其余部分,我们将探讨两种熟悉的机器学习模型,它们也是玻璃盒模型:决策树(和决策规则)以及广义线性模型(GLMs)。这将使我们更好地理解集成模型的解释性和可解释性概念,因为 GLMs 和决策树通常被用作许多集成方法中的基础估计器。

9.1.2 决策树(和决策规则)

决策树可以说是机器学习模型中最可解释的,因为它们将决策实现为一个询问和回答问题的连续过程。决策树的结构及其基于特征的分割函数很容易解释,正如我们将看到的。这使得决策树成为玻璃盒模型。

让我们从在著名的 Iris 数据集上训练决策树开始,该数据集在 scikit-learn 中可用。任务是按照四个特征:花瓣高度、花瓣宽度、花瓣宽度和花瓣宽度,将鸢尾花分为三种物种:Iris setosaIris versicolourIris virginica。这个极其简单的数据集只有 150 个训练示例,将作为可视化概念的优良教学示例。

实践中解释决策树

下面的代码示例加载数据集,训练决策树分类器,并可视化它。一旦可视化,我们就可以解释学习到的决策树模型。

列表 9.1 训练和解释决策树

from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
iris = load_iris()                                                  ❶
Xtrn, Xtst, ytrn, ytst = train_test_split(iris.data, iris.target,
                                          test_size=0.15)

from sklearn import tree
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score
model = DecisionTreeClassifier(                                     ❷
           min_samples_leaf=40, criterion='entropy')
model.fit(Xtrn, ytrn)                                               ❸
ypred = model.predict(Xtst)
print('Accuracy = {0:4.3}%'.format(accuracy_score(ytst, ypred) * 100))

import graphviz, re, pydotplus
dot = tree.export_graphviz(model, feature_names=iris.feature_names, 
                           class_names=['Iris-Setosa', 
                                        'Iris-Versicolour', 
                                        'Iris-Virginica'],
                           filled=True, impurity=False)
graphviz.Source(dot, format="png")                                  ❹

❶ 加载 Iris 数据集并将数据分为训练集和测试集

❷ 在学习过程中使用熵作为衡量分割质量的标准

❸ 训练决策树分类器并评估其测试集性能

❹ 将树内部结构导出为 dot 格式,然后使用 graphviz 进行渲染

生成的决策树在 Iris 数据集上达到了 91.3% 的准确率。请注意,由于 Iris 是一个非常简单的数据集,可以训练出许多不同的高准确率决策树,

其中之一在此处展示。我们使用开源的图形可视化软件 graph-viz 包(见图 9.3)来可视化它,该软件用于渲染列表、树、图和网络。

CH09_F03_Kunapuli

图 9.3 在 Iris 数据集上学习用于将鸢尾花分类为三种物种(Iris setosaIris versicolourIris virginica)的决策树。这里遵循了标准分裂惯例:如果分裂函数评估为真,则继续向右分支;如果评估为假,则向左分支。

我们首先注意到,只有四个特征中的两个,即花瓣宽度和长度,就足以达到超过 90%的准确率。因此,这个决策树通过仅使用特征子集就学习了一个稀疏模型。但我们能从中得到的信息远不止这些。

决策树的一个良好特性是,从根节点到叶节点的每一条路径都代表了它。在每次分裂时,由于一个示例只能向左或向右移动,因此示例只能最终结束在三个叶节点之一。这意味着每个叶节点(以及由此扩展的从根到叶的每条路径,即每条规则)将总体人口划分为一个子人口。让我们实际看看这是如何运作的。

由于有三个叶节点,因此有三个决策规则,我们可以用 Python 语法来写出它们,以便更容易理解:

if petal_width <= 0.8: 
    class = 'Iris-Setosa'
elif (petal_width > 0.8) and (petal_length <= 4.85):
    class = 'Iris-Versicolour'
elif (petal_width > 0.8) and (petal_length > 4.85):
    class = 'Iris-Virginica'
else:
    Can never reach here as all possibilities are covered above

通常情况下,每个决策树都可以表示为一组决策规则,由于它们的 if-then 结构,这些规则对人类来说更容易理解。

注意:决策树的解释性可能是主观的,并且取决于树的深度和叶节点的数量。深度为中小型(例如,深度为 3 或 4)且节点数约为 8-15 的树通常更直观,更容易理解。随着树深和叶节点数量的增加,我们需要处理和解释的决策规则的数量和长度也会增加。这使得深度和复杂的决策树更像黑盒,并且也相当难以解释。

记住,每个通过决策树的示例都必须最终结束在唯一的叶节点上。因此,从根到叶的路径集将完全覆盖所有示例。更重要的是,树/规则将将所有鸢尾花的空间划分为三个非重叠的子人口,每个对应于三种物种之一。这在可视化和理解方面非常有帮助,如图 9.4 所示。

CH09_F04_Kunapuli

图 9.4 决策树将特征空间划分为非重叠的子空间,其中每个子空间表示示例的子人口。

特征重要性

从树中我们知道使用了两个特征:花瓣长度和花瓣宽度。但每个特征对模型贡献了多少?这就是特征重要性的概念,我们根据每个特征对模型整体决策影响的大小为每个特征分配一个分数。在决策树中,特征重要性可以非常容易地计算出来!

让我们计算在图 9.3 中较早显示的树中每个特征的特性重要性,同时记住一些重要的细节。首先,训练集由 127 个训练示例(根节点中的样本 = 127)组成。其次,此树使用熵作为分裂质量标准进行训练(参见图 9.1)。

因此,为了衡量特征重要性,我们只需计算每个特征在分裂后整体熵减少的程度。为了避免因示例比例非常小或非常大而扭曲我们对分裂的感知,我们还将对熵减少进行加权。

更确切地说,对于每个分裂节点,我们计算分裂后其(加权)熵相对于其子节点的减少程度:

CH09_F04_Kunapuli-eqs-2x

对于节点 [petal_width <= 0.8]:

CH09_F04_Kunapuli-eqs-3x

对于节点 [petal_length <= 4.85]:

CH09_F04_Kunapuli-eqs-4x

由于其他两个特征在模型中没有使用,它们的特征重要性将为零。最后一步是将特征重要性归一化,使它们的总和为 1:

CH09_F04_Kunapuli-eqs-5x

在实践中,我们不必自己计算特征重要性,因为大多数决策树学习的实现都这样做。例如,我们可以直接从模型中获取我们刚刚从列表 9.1 中训练的决策树的特征重要性(与我们的先前计算进行比较):

model.feature_importances_
array([0\.        , 0\.        , 0.33708016, 0.66291984])

最后,前面的例子展示了决策树在分类问题中的可解释性。决策树回归器也可以以相同的方式进行解释;唯一的区别是叶子节点将是回归值而不是类标签。

9.1.3 广义线性模型

我们现在重新审视 GLMs,它们最初在第七章第 7.1.4 节中介绍。回想一下,GLMs 通过一个(非线性)链接函数 g(y) 扩展线性模型。例如,线性回归使用恒等链接将回归值 y 与数据 x 相关联:

CH09_F04_Kunapuli-eqs-7x

在这里,数据点 x = [x[1], ⋅⋅⋅ ,x[d]]' 由 d 个特征描述,线性模型由线性系数 β[1], ⋅⋅⋅ ,β[d] 和截距(有时称为偏差)β[0] 参数化。GLM 的另一个例子是逻辑回归,它使用 logit 链接将类概率 p 与数据 x 相关联:

CH09_F04_Kunapuli-eqs-8x

GLMs 由于其线性加性结构而具有可解释性。线性参数本身为我们提供了对每个特征对整体预测贡献的直观感觉。加性结构确保整体预测依赖于每个特征的个别贡献。

例如,假设我们已经为之前讨论的糖尿病诊断任务训练了一个逻辑回归模型,用于根据年龄和血糖测试结果(glc)来分类病人是否患有糖尿病。让我们说学到的模型是(p(y = 1)作为p):

CH09_F04_Kunapuli-eqs-9x

回想一下,如果p是阳性诊断的概率,那么*p/(1 – p)是病人有诊断的几率。因此,逻辑回归将阳性糖尿病诊断的对数几率表示为年龄和 glc 特征的加权组合。

特征权重

我们如何解释特征权重?如果年龄增加 1,log(p/(1 – p))将增加 0.5(因为模型是线性和可加的)。因此,对于一个年龄增加一岁的病人,他们患有糖尿病的 log 几率是 log(p/(1 – p)) = 0.5。因此,他们患有糖尿病的几率是(*p/(1 – p)) = *e^(0.5) = 1.65,即增加 65%。

类似地,如果 glc 增加 1,log(p/(1 – p))将减少 0.29(注意权重中的负号,表示减少)。因此,对于一个 glc 增加 1 的病人,他们患有糖尿病的几率是(p/(1 – p)) = *e^(0.29) = 0.75,即减少 25%。

让我们利用这种直觉来探讨如何解释一个更现实的逻辑回归模型。我们首先在第二章案例研究中首次引入的乳腺癌数据集上训练一个逻辑回归模型。任务是乳腺癌诊断的二分类。数据集中的每个例子都由从乳腺肿块图像中提取的 30 个特征来表征。这些特征代表乳腺肿块的属性,如半径、周长、面积、凹凸性等。

实际应用中解释广义线性模型

下一个列表加载数据集,训练逻辑回归分类器,并可视化每个特征阳性乳腺癌诊断几率的变化或减少。

列表 9.2 训练和解释逻辑回归

from sklearn.datasets import load_breast_cancer
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
import numpy as np

bc = load_breast_cancer()                                              ❶
X = StandardScaler().fit_transform(bc.data)                            ❷
y = bc.target                                                          ❷

Xtrn, Xtst, ytrn, ytst = train_test_split(X, y, test_size=0.15)
model = LogisticRegression(max_iter=1000, solver='saga', penalty='l1')
model.fit(Xtrn, ytrn)                                                  ❸
ypred = model.predict(Xtst)
print('Accuracy = {0:5.3}%'.format(accuracy_score(ytst, ypred) * 100))

fig, ax = plt.subplots(figsize=(12, 4))

odds = np.exp(model.coef_[0]) - 1\.                                     ❹
ax.bar(height=odds,                                                    ❺
       x=np.arange(0, Xtrn.shape[1])
for i, feature in enumerate(bc.feature_names):
    ax.text(i-0.25, 0, feature, rotation=90)

❶ 加载乳腺癌数据集并将数据分为训练集和测试集

❷ 预处理特征以确保它们具有相同的尺度

❸ 训练逻辑回归分类器并评估其测试集性能

❹ 计算几率的变化为“exp(weight) - 1”

❺ 将几率的变化以条形图的形式可视化

每个特征i的几率是根据权重计算的,即odds[i] = e^(w[i])。几率的变化计算为change*[i] = odds[i] - 1 = *e^(w[i]) - 1,并在图 9.5 中可视化。

CH09_F05_Kunapuli

图 9.5 解释逻辑回归,即用于乳腺癌诊断的线性分类模型。正特征权重会导致乳腺癌的概率增加,负特征权重会导致乳腺癌的概率减少,而零特征权重不会影响乳腺癌的概率。

如果特征权重 w[i] 大于 0,则 odds[i] 大于 1,这将增加阳性诊断的概率 (change[i] 大于 0)。如果特征权重 w[i] 小于 0,则 odds[i] 小于 1,这将减少阳性诊断的概率 (change[i] 小于 0)。如果特征权重 w[i] 等于 0,则 odds[i] 等于 1,并且该特征不会影响诊断 (change[i] 等于 0)。

这最后一部分是学习稀疏线性模型的重要组件,其中我们将模型训练为零和零特征权重的混合体。零特征权重意味着该特征不会对模型做出贡献,并且可以被有效地删除。这反过来又允许具有更稀疏的特征集和更精简、更可解释的模型!

注意:线性模型的可解释性取决于特征之间的相对缩放。例如,年龄可能在 18 到 65 岁之间,而薪水可能在 30,000 美元到 90,000 美元之间。这种特征差异会影响底层权重学习,具有更高权重范围的特征(在本例中为薪水)将主导模型。当我们解释此类模型时,我们可能会错误地将更大的重要性归因于这些特征。为了训练一个在训练过程中平等考虑所有特征的稳健模型,必须小心地预处理数据,以确保所有特征都在相同的数值范围内。

线性回归模型也可以以类似的方式进行解释。在这种情况下,我们不是计算概率,而是可以直接计算每个特征对回归值贡献的大小,因为回归值 y = β[0] + β[1]x[1] + ⋅⋅⋅ + β[d]x[d]。

9.2 案例研究:数据驱动营销

在本章的其余部分,我们将探讨如何在数据驱动营销领域的机器学习任务中训练黑盒和玻璃盒集成。数据驱动营销旨在利用客户和社会经济信息来识别最有可能对某些类型的营销策略做出积极响应的客户。这允许企业以最优化和个性化的方式针对特定客户进行广告、优惠和销售。

9.2.1 银行营销数据集

我们将考虑来自 UCI 机器学习仓库的银行营销数据集¹(mng.bz/VpXP),其中数据来自一家葡萄牙银行的基于电话的直接营销活动。任务是预测客户是否会订阅定期存款。

此数据集也附带源代码。对于数据集中的每位客户,都有四种类型的特征:人口统计属性、上次电话联系详情、与该客户相关的整体营销活动信息,以及一般社会经济指标。详细信息见表 9.1。

表 9.1 按特征、类型和来源分组展示银行营销数据集的特征和目标

特征 类型 特征描述
客户人口统计属性和金融指标
age 连续 客户年龄
job 分类 职业类型(12 个类别,例如,蓝领、退休、自雇、学生、服务行业等,以及未知)
marital 分类 婚姻状况(离婚、已婚、单身、未知)
education 分类 最高教育程度(8 个类别,例如,高中、大学学位、专业课程,以及未知)
default 分类 客户是否有信用违约?(是、否、未知)
housing 分类 客户是否有住房贷款?(是、否、未知)
loan 分类 客户是否有个人贷款?(是、否、未知)
上次营销接触的日期和时间条件
contact 二元 联系沟通类型(手机、电话)
month 分类 上次联系月份(12 个类别:一月至十二月)
day-of-week 分类 上次联系的工作日(5 个类别:周一至周五)
当前和上次营销活动的营销活动详情
campaign 连续 在此次营销活动中的总联系次数
pdays 连续 上次营销活动以来与上次联系的天数
previous 连续 在此次营销活动之前的联系次数
poutcome 分类 上次营销活动的结果(3 个类别:失败、不存在、成功)
一般社会和经济指标
emp.var.rate 连续 就业变化率:季度指标
cons.price.idx 连续 消费者价格指数:月度指标
cons.conf.idx 连续 消费者信心指数:月度指标
euribor3m 连续 欧洲银行同业拆借利率三个月期:日度指标
nr.employed 连续 员工人数:季度指标
预测目标
subscribed? 二元 客户是否订阅了定期存款?

需要注意的是,这个数据集极度不平衡:在此营销活动下,只有 10%的客户订阅了定期存款。

列表 9.3 加载数据集,将数据分为训练集和测试集,并进行预处理。连续特征使用 scikit-learn 的 MinMaxEncoder 缩放到 0 到 1 之间,分类特征使用 OrdinalEncoder 进行编码。

列表 9.3 加载和预处理银行营销数据集。

import pandas as pd
data_file = './data/ch09/bank-additional-full.csv'
df = pd.read_csv(data_file, sep=';')                               ❶
df = df.drop('duration', axis=1)                                   ❷

from sklearn.model_selection import train_test_split
y = df['y']
X = df.drop('y', axis=1)                                           ❸

Xtrn, Xtst, ytrn, ytst = \                                         ❹
    train_test_split(X, y, stratify=y, test_size=0.25)     

from sklearn.preprocessing import LabelEncoder                     ❺
preprocess_labels = LabelEncoder()    
ytrn = preprocess_labels.fit_transform(ytrn).astype(float)
ytst = preprocess_labels.transform(ytst)

from sklearn.preprocessing import MinMaxScaler, OrdinalEncoder    
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer

cat_features = ['default', 'housing', 'loan', 'contact', 'poutcome', 
                'job', 'marital', 'education', 'month', 'day_of_week']
cntnous_features = ['age', 'campaign', 'pdays', 'previous', 'emp.var.rate', 
                    'cons.price.idx', 'cons.conf.idx', 'nr.employed', 
                    'euribor3m']    

preprocess_categorical = Pipeline(steps=[('encoder', OrdinalEncoder())])
preprocess_numerical = Pipeline(steps=[('scaler', MinMaxScaler())])
data_transformer = \                                               ❻
    ColumnTransformer(transformers=[
        ('categorical', preprocess_categorical, cat_features),
        ('numerical', preprocess_numerical, cntnous_features)])
all_features = cat_features + cntnous_features

Xtrn = pd.DataFrame(data_transformer.fit_transform(Xtrn),
                    columns=all_features)
Xtst = pd.DataFrame(data_transformer.transform(Xtst), columns=all_features)

❶ 加载数据集

❷ 删除“duration”列(见注释以获取更详细的解释)

❸ 将数据帧分为特征和标签

❹ 使用分层抽样将数据分为训练集和测试集以保持类别平衡

❺ 使用“LabelEncoder”对标签进行预处理

❻ 使用“MinMaxEncoder”对连续特征进行预处理,使用“OrdinalEncoder”对分类特征进行预处理

为了防止数据与目标泄露(见第八章),我们确保在应用于测试集之前,缩放和编码函数仅适用于训练集。

注意:原始数据集包含一个名为duration的特征,它指的是最后一次电话的持续时间。较长的通话与通话的结果高度相关,因为较长的通话表明客户更加投入,更有可能订阅。然而,与其他特征不同,这些特征在拨打电话之前是已知的,我们不可能提前知道通话的持续时间。因此,持续时间特征本质上就像一个目标变量,因为通话后持续时间与订阅状态都将立即知晓。为了构建一个可以在实践中部署的、在拨打电话前包含所有特征的现实预测模型,我们从我们的模型中删除了这个特征。

9.2.2 训练集成

现在,我们将在这个数据集上训练两个集成(来自不同的包):xgboost.XGBoostClassifier 和 sklearn.RandomForestClassifier。这两个模型都将是由 200 个决策树组成的复杂集成(在 XGBoost 的情况下是加权集成),并且实际上是黑盒。一旦训练完成,我们将在第 9.3 节中探讨如何使这些黑盒可解释。

列表 9.4 展示了我们如何在这个数据集上训练 XGBoost 集成。我们使用随机网格搜索结合 5 折交叉验证(CV)和提前停止(见第六章以获取更多详细信息)来选择各种超参数,如学习率和正则化参数。

列表 9.4 在银行营销数据集上训练 XGBoost

from xgboost import XGBClassifier
from sklearn.model_selection import RandomizedSearchCV

xgb_params = {                                       ❶
    'learning_rate': [0.001, 0.01, 0.1],  
    'n_estimators': [100],                
    'max_depth': [3, 5, 7, 9],            
    'lambda': [0.001, 0.01, 0.1, 1],      
    'alpha': [0, 0.001, 0.01, 0.1],       
    'subsample': [0.6, 0.7, 0.8, 0.9],    
    'colsample_bytree': [0.5, 0.6, 0.7],  
    'scale_pos_weight': [5, 10, 50, 100]}    

fit_params = {'early_stopping_rounds': 15,           ❷
              'eval_metric': 'aucpr',
              'eval_set': [(Xtst, ytst)],
              'verbose': 0}

xgb = XGBClassifier(objective='binary:logistic',     ❸
                    use_label_encoder=False)
xgb_search = RandomizedSearchCV(xgb, xgb_params, cv=5, n_iter=40, 
                                verbose=2, n_jobs=-1)
xgb_search.fit(X=Xtrn, y=ytrn.ravel(), **fit_params)
xgb = xgb_search.best_estimator_                     ❹

❶ 为 XGBoost 创建超参数网格

❷ 初始化提前停止并设置提前停止轮数为 15

❸ 将 XGBoost 的分类损失设置为逻辑损失

❹ 在 CV 后保存最佳 XGBoost 模型

还要注意,其中一个超参数是 scale_pos_weight,它允许我们以不同的方式对正负训练示例进行加权。这是必要的,因为银行营销数据集是不平衡的(正负例比例为 10%:90%)。通过更多地加权正例,我们可以确保它们的贡献不会被更大的负例比例所淹没。在这里,我们使用交叉验证从 5、10、50 和 100 中确定正例的权重。

此列表训练了一个 XGBoostClassifier,在测试集上实现了大约 87.24%的准确率和 74.67%的平衡准确率。我们可以使用类似的程序来训练这个数据集上的随机森林。主要区别在于我们为正例设置了类别权重为 10。

列表 9.5 在银行营销数据集上训练随机森林

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import RandomizedSearchCV

rf_params = {                                         ❶
    'max_depth': [3, 5, 7],                
    'max_samples': [0.5, 0.6, 0.7, 0.8],   
    'max_features': [0.5, 0.6, 0.7, 0.8]}

rf = RandomForestClassifier(
         class_weight={0: 1, 1: 10},                  ❷
         n_estimators=200)
rf_search = RandomizedSearchCV(rf, rf_params, cv=5, n_iter=30, 
                               verbose=2, n_jobs=-1)
rf_search.fit(X=Xtrn, y=ytrn)
rf = rf_search.best_estimator_                        ❸

❶ 为“RandomForestClassifier”创建一个超参数网格

❷ 将正负样本的权重设置为 1:10

❸ 在交叉验证后保存最佳随机森林

此列表训练了一个 RandomForestClassifier,在测试集上达到了大约 84%的准确率。

9.2.3 树集成中的特征重要性

本书中的大多数集成(包括在前一小节中训练的 XGBoostClassifier 和 RandomForestClassifier)都是树集成,因为它们使用决策树作为其基本估计器。计算集成特征重要性的一个方法就是简单地平均各个基本决策树的特征重要性!

实际上,随机森林(在 scikit-learn 中)和 XGBoost 的实现已经这样做了,我们可以使用以下方法获得集成特征重要性:

xgb_search.best_estimator_.feature_importances_
rf_search.best_estimator_.feature_importances_

我们在图 9.6 中可视化和比较这两个集成的特征重要性,以解释和理解它们的决策过程。

CH09_F06_Kunapuli

图 9.6 XGBoost(左柱)和随机森林(右柱)分类器学习的集成特征重要性

这两个集成都赋予了社会经济指标变量(尤其是 nr.employed 和 emp.var.rate,它们表示失业率)、euribor3m(银行间利率,表示宏观经济稳定性)和 cons.conf.idx(表示消费者对其预期财务状况的乐观程度)重要的权重。

然而,XGBoost 模型却高度依赖于其中一个变量,即 nr.employed。从对这一点的解读中可以得出的总体结论是,当整体经济形势没有不确定性或波动,且乐观时,人们更有可能订阅定期存款账户。

特征重要性使我们能够理解模型在整体上以及在不同类型的示例上做了什么。也就是说,特征重要性是一种全局可解释性方法。

9.3 全局可解释性的黑盒方法

机器学习模型可解释性的方法可以分为两种类型:

  • 全局方法试图一般性地解释模型的决策过程以及哪些因素是广泛相关的。

  • 局部方法试图具体解释模型针对单个示例和预测的决策过程。

全局可解释性涉及模型在部署或实际使用时在大量示例上的合理行为,而局部可解释性则涉及模型对单个示例的个别预测,这使用户能够决定下一步该做什么。

在本节中,我们探讨一些黑盒模型的全球可解释性方法。这些方法只考虑模型的输入和输出,不使用模型内部结构(因此,称为黑盒)来解释模型行为。因此,它们可以用于任何机器学习方法的全球可解释性,也被称为模型无关方法

9.3.1 排列特征重要性

在机器学习模型中,特征重要性是指一个分数,表示特征在模型中的好坏,即特征在模型决策过程中的有效性。

我们已经看到了如何计算决策树的特征重要性,以及通过聚合,对于使用决策树作为基估计器的基于树的集成方法。对于基于树的方法,特征重要性计算使用模型内部信息,如树结构和分割参数。但如果这些模型内部信息不可用呢?在这种情况下,是否有黑盒等效方法来获取特征重要性?

确实存在:排列特征重要性。回想一下,决策树特征重要性分数是通过评估每个特征降低分割标准(例如,用于分类的基尼不纯度或熵,用于回归的平方误差)的程度来对每个特征进行评分的。相比之下,排列特征重要性是通过评估我们在排列(打乱)该特征的值之后,该特征增加测试错误的程度来对每个特征进行评分的。

这里的直觉很简单:如果一个特征更重要,那么“干扰”它会影响其对预测的贡献,并增加测试错误。如果一个特征不太重要,那么干扰它对模型预测的影响不会很大,也不会影响测试错误。

我们通过随机排列特征值来“干扰”一个特征。这有效地切断了该特征与其预测之间的任何关系。排列特征重要性的过程在图 9.7 中进行了说明。

CH09_F07_Kunapuli

图 9.7 计算排列特征重要性的过程,以第三个特征为例。这个过程会重复应用于所有特征。排列特征重要性仅使用输入和输出估计特征重要性,不使用模型内部信息(这使得它成为一个模型无关的方法)。

排列特征重要性在如何评分特征而不访问模型内部信息方面既优雅又简单。尽管如此,这里有一些重要的技术细节需要记住:

  • 排列特征重要性是一个前后评分。它试图估计在我们打乱(排列)特征之后,模型的预测性能如何从之前到之后发生变化。为了得到一个稳健且无偏的模型性能前后估计,使用保留的测试集是至关重要的!

  • 根据任务(分类或回归)、数据集以及我们的建模目标,有许多方法可以评估模型的预测性能。对于这个任务,例如,考虑以下性能指标:

    • 平衡准确率——由于这是一个分类任务,准确率自然成为模型评估指标的一个选择。然而,这个数据集正负样本的比例为 1:10,是不平衡的。为了解决这个问题,我们可以使用平衡准确率,它通过按类别大小加权预测来确保这种偏差被考虑在内。

    • 召回率——这个模型的目的在于识别那些会订阅定期存款的高价值客户。从这个角度来看,我们希望最小化错误否定,即我们的模型认为不会订阅但实际上会订阅的客户!这种类型的错误预测会损失我们客户,而召回率是一个很好的指标来最小化这种错误否定。

  • 这个过程随机打乱特征值。与任何随机方法一样,重复这个过程几次并平均结果是一个好主意(类似于我们在交叉验证中使用 k 折的方法)。

实践中的排列特征重要性

下面的列表计算了在上一节中训练的 XGBClassifier 的排列特征重要性,使用的是平衡准确率。

列表 9.6 计算排列特征重要性

from sklearn.inspection import permutation_importance
pfi = permutation_importance(
          xgb, Xtst, ytst,               ❶
          scoring='balanced_accuracy',   ❷
          n_repeats=30)                  ❸

❶ 使用保留的测试集来计算特征重要性

❷ 可以使用不同的指标来评估模型性能和特征重要性。

❸ 重复随机打乱特征

图 9.8 比较了 XGBoost 模型的特征重要性以及使用平衡准确率和召回率计算出的排列重要性,并可视化每种方法识别的前 10 个特征。

CH09_F08_Kunapuli

图 9.8 XGBoost 计算的特征重要性与使用两个不同指标(平衡准确率和召回率)计算的 XGBoost 模型的黑盒排列特征重要性进行了比较。

有趣的是,尽管三种方法都认同 nr.employed(雇员数量)的重要性,但在使用平衡准确率或召回率来评分特征时,euribor3m(银行间借贷利率)成为了一个关键指标。稍微深入思考一下,可能会揭示其中的原因。在一个更健康的经济中,更好的银行间借贷利率允许更好的利率,这反过来又有利于吸引客户订阅定期存款账户。

除了社会经济指标外,其他特征,如联系(手机与电话联系)和活动(在此活动中联系的总数),也成为了客户是否会订阅定期存款的重要指标。

一些人口统计特征,如婚姻状况、年龄和教育程度,在召回率评分中也开始变得重要,我们的目标是减少错误否定,并尽可能多地识别高价值客户。再次强调,有效地识别高价值客户依赖于他们的个人人口统计指标。

注意:必须小心处理相关特征,因为它们包含相似的信息。例如,当两个特征相关时,其中一个被置换,模型仍然可以使用未置换的其他特征而不会降低性能(因为它们都包含相似的信息)。由于置换前后的分数相似,相关特征的置换特征重要性分数将很小。从这个结果中,我们可能会错误地得出结论,认为两个特征都不重要,而实际上它们可能都很重要。当我们有三个、四个或是一组相关特征时,这种情况会更糟。处理这种情况的一种方法是通过聚类将特征分组,并使用每个特征组的一个代表性特征。

9.3.2 部分依赖图

部分依赖图(PDPs)是另一种有用的黑盒方法,它帮助我们识别特征与目标之间的关系的本质。与使用随机化来激发特征重要性的置换特征重要性不同,部分依赖关系是通过边缘化或求和来识别的。

假设我们感兴趣的是计算目标 y 与第 k 个特征 X[k] 之间的部分依赖。设剩余特征的数据集为 X[rest]。我们有一个黑盒模型 y = f([X[k],X[rest]])。

要从这个黑盒中获得部分依赖函数 CH09_F08_Kunapuli-eqs-13x,我们只需对所有其他特征 X[rest] 的所有可能值进行求和;也就是说,我们边缘化其他特征。从数学上讲,对所有其他特征的可能的值进行求和等同于对它们进行积分:

CH09_F08_Kunapuli-eqs-14x

然而,由于计算这个积分实际上并不可行,我们需要对其进行近似。我们可以使用一组 n 个例子非常容易地做到这一点:

CH09_F08_Kunapuli-eqs-15x

这个公式为我们计算特征 X[k] 的部分依赖函数提供了一种直接的方法。

对于不同的 a 值,我们只需将整个列替换为 a。因此,对于每个 a,我们创建一个新的数据集 X^([a]),其中第 k 个特征在每一个例子中都取值为 a。使用我们的黑盒模型对这个修改后的数据集进行预测的结果将是 y^([a]) = f(X^([a]))。预测向量 y^([a]) 是一个长度为 n 的向量,包含修改后的数据集中每个测试例子的预测。我们现在可以对这些预测进行平均,以得到一对点:

CH09_F08_Kunapuli-eqs-16x

我们重复这个步骤,对不同的 α 值进行操作,以生成完整的 PDP。这在图 9.9 中用两个值 a = 0,1 和 a = 0.4 进行了说明。

PDPs 易于创建和使用,尽管它们可能有些耗时,因为必须为依赖图中的每个点创建和评估数据集的新修改版本。以下是一些需要记住的重要技术细节:

  • 部分依赖试图将模型的输出与输入特征联系起来,即从模型学习到的行为。因此,最好使用训练集创建和可视化 PDP。

  • 记住,整体部分依赖函数是通过平均 n 个示例创建的;也就是说,每个训练示例都可以用来创建一个特定于示例的部分依赖函数。这种特定示例与其输出之间的部分依赖称为个体条件期望(ICE)。

CH09_F09_Kunapuli

图 9.9 展示了在 X[3] = 0.1 和 X[3] = 0.4 处计算的第三个特征的部分依赖图中的两个点。观察我们发现,我们将第三列(特征)分别设置为 0.1 和 0.3,以获得两个数据集。这些数据集各自产生两组预测,这些预测被平均以在 PDP 上产生两个点。

实践中的部分依赖图

下一个列表说明了如何为在 9.2 节中较早训练的 Bank Marketing 数据集上的 XGBoostClassifier 构建 PDPs。

列表 9.7 创建 PDPs

from sklearn.inspection import PartialDependenceDisplay as pdp
import matplotlib.pyplot as plt

fig, ax = plt.subplots(nrows=2, ncols=2, figsize=(10, 6)) 
pdp.from_estimator(
    xgb, Xtrn, 
    features=['euribor3m', 'nr.employed',
              'contact', 'emp.var.rate'],   ❶
    feature_names=list(Xtrn.columns),       ❷
    kind='average',                         ❸
    response_method='predict_proba',        ❹
    ax=ax)   

❶ 我们想要计算 PDPs 的特征

❷ 数据集中所有特征的列表

❸ 为每个示例或平均部分依赖图绘制单个条件期望

❹ 设置我们是否想要带有预测或预测概率的部分依赖

图 9.10 显示了银行营销数据集中四个高得分变量的部分依赖函数:euribor3m、nr.employed、contact 和 emp.var.rate。

CH09_F10_Kunapuli

图 9.10 展示了银行营销数据集中四个变量的 PDPs

PDPs 让我们进一步了解不同变量如何表现以及它们如何影响预测。注意,在列表 9.7 中,我们将 response_method 设置为'predict_proba'。因此,图 9.10 中的图表显示了每个变量(部分)如何影响客户订阅定期存款账户的预测概率。更高的预测概率表明这些属性在识别高价值客户方面更有帮助。

例如,euribor3m 的值较低(例如,在 0-0.5 的范围内)通常对应着更高的订阅可能性。如前所述,这是有道理的,因为较低的银行借款利率通常意味着较低的客户利率,这对潜在客户来说是有吸引力的。

类似的结论——较低的失业率也可能影响潜在客户开设定期存款账户——也可以从变量 emp.var.rate 和 nr.employed 中得出。

注意:与排列特征重要性类似,PDPs(部分依赖图)过程的一个关键假设是我们感兴趣的特性,X[k],与剩余特性,X[rest],不相关。这个独立性假设使我们能够通过求和来边缘化剩余特性。如果特性 X[rest] 是相关的,那么对它们的边缘化将破坏 X[k] 的某些组成部分,我们就无法准确了解 X[k] 对预测的贡献程度。

PDPs(部分依赖图)的一个重要限制是,只能创建一个变量的部分依赖函数(曲线)、两个变量的(等高线)或三个变量的(表面图)的图表。超过三个变量,在不将特性分解成两个或三个更小组的情况下,就无法可视化多变量的部分依赖。

9.3.3 全局代理模型

黑盒解释,如特征重要性和部分依赖,试图识别单个特性或特性组对预测的影响。在本节中,我们探讨一种更全面的方法,旨在以可解释的方式近似黑盒模型的行为。

代理模型的想法极其简单:我们训练第二个模型来模仿黑盒模型的行为。然而,代理模型本身是一个玻璃盒,本质上是可以解释的。

一旦训练完成,我们可以使用代理玻璃盒模型来解释黑盒模型的预测,如图 9.11 所示:

  • 一个代理数据集 (Xs[trn],*y*s[trn])用于训练代理模型。用于训练黑盒模型的原始数据也可以用来训练代理模型,如果它可用的话。如果不可用,则使用来自原始问题空间的替代数据样本。关键是确保代理数据集与用于训练黑盒模型的原始数据集具有相同的分布。

  • 代理模型是在原始黑盒模型的预测上训练的。这是因为我们的想法是拟合一个代理模型来模仿黑盒模型的行为,这样我们就可以使用代理来解释黑盒。一旦训练完成,如果代理预测 (y^s[pred]) 与黑盒预测 (y^b[pred]) 匹配,那么代理模型就可以用来解释预测。

  • 任何玻璃盒模型都可以用作代理模型。这包括决策树和 GLMs(广义线性模型),然后可以像在 9.11 节中之前展示的那样进行解释。

CH09_F11_Kunapuli

图 9.11 从黑盒模型的预测中训练全局代理模型的流程。两个模型都在相同的代理训练示例上进行了训练。然而,代理模型是在黑盒模型的预测上进行训练的,以便它可以学习模仿其预测。如果黑盒和代理做出相同的预测,那么代理就可以用来解释黑盒模型的预测。

保真度-可解释性权衡

让我们训练一个代理决策树来解释在 Bank Marketing 数据集上最初训练的 XGBoost 模型的行为。原始训练集也被用作代理训练集。

请记住,在训练模型时,我们希望在两个标准之间进行权衡:代理模型对黑盒模型的保真度和代理模型的可解释性。代理模型的保真度衡量它模仿黑盒模型预测行为的能力有多好。更精确地说,我们衡量代理模型预测(ys[pred])与黑盒模型预测(*y*b[pred])的相似程度。

对于二元分类问题,我们可以使用准确度或R²分数(见第一章)等指标来完成这项工作。对于回归问题,我们可以使用均方误差(MSE)或R²等指标。更高的R²分数表明黑盒模型与其代理之间的保真度更好。

代理模型的可解释性取决于其复杂性。假设我们想要训练一个决策树代理模型。回想一下我们在第 9.1 节中的讨论,我们需要限制代理模型中叶节点的数量,以便它能够被人类解释,因为过多的叶节点可能会导致模型复杂化并使解释者感到不知所措。

实际上训练全局代理模型

为了训练一个有用的代理模型,我们需要在保真度-可解释性权衡中找到最佳平衡点。这个最佳平衡点将是一个能够很好地近似黑盒预测但又不至于过于复杂以至于无法进行解释(可能通过检查)的代理模型。

图 9.12 显示了为 XGBoost 模型训练的决策树代理模型的保真度-可解释性权衡。代理模型是在第 9.1 节中用于训练 XGBoost 模型的相同 Bank Marketing 训练集上训练的。

CH09_F12_Kunapuli

图 9.12 Bank Marketing 数据集的保真度-可解释性权衡。黑盒模型是一个 XGBoost 集成,而代理模型是在黑盒预测上训练的决策树。

我们在保持对黑盒预测和代理预测之间保真度(R²分数)的关注的同时,增加代理模型的复杂性(以叶节点数量表征)。一个具有 14 个叶节点的决策树代理模型似乎在保真度和复杂性之间实现了可解释性的理想权衡。列表 9.8 展示了具有这些规格的代理决策树模型的训练过程。

列表 9.8 训练代理模型

from sklearn.tree import DecisionTreeClassifier
surrogate = \
    DecisionTreeClassifier(criterion='gini',
                           max_leaf_nodes=14,       ❶
                           min_samples_leaf=20,     ❷
                           class_weight ={0: 1,     ❸
                                          1: 10})   ❸

surrogate.fit(Xtrn, xgb.predict(Xtrn))

❶ 将最大可能的叶子节点数设置为 14

❷ 将叶子节点中的最小样本数设置为 20,以避免过拟合

❸ 将负例的类别权重设置为 1,将正例的类别权重设置为 10,以解决类别不平衡问题

图 9.13 显示了 XGBoost 模型的决策树代理

CH09_F13_Kunapuli

图 9.13 从原始在 Bank Marketing 数据集上训练的 XGBoost 模型的预测中训练得到的代理模型。此树有 14 个叶子节点。检查和分析此树可以得出许多见解,例如从根节点到叶子节点的突出路径(带有虚线边框的节点)。

几个变量出现在从根节点到叶子节点的突出路径中。这些变量描述了一个高价值子群体,并提供了关于可能成功的策略的见解。

例如,社会经济变量,如 nr.employed 和 euribor3m,识别出有利于发起成功活动的有利社会环境。此外,[day_of_week <= 1.5]表明,在周一(day_of_week = 0)或周二(day_of_week = 1)联系这些高价值客户是一个好策略。

我们还可以查看其他路径和节点以获得更深入的见解。节点 age <= 0.147 是在预处理数据上获得的,其中 0.147 对应于缩放前的 40。这表明年龄在 40 岁以下的客户是高价值的。

另一个有用的节点是[default <= 0.5],这表明没有先前违约记录的客户是高价值的。您可能能够识别出识别高价值客户和策略的其他可行策略。

9.4 用于局部可解释性的黑盒方法

前一节介绍了全局可解释性的方法,这些方法的目的是解释模型在不同类型的输入示例和子群体中的全局行为趋势。在本节中,我们将探讨局部可解释性的方法,这些方法的目的是解释模型的个别预测。这些解释允许用户(例如,使用诊断系统的医生)信任预测并据此采取行动。这与用户理解模型为何做出特定决定的能力相关。

9.4.1 使用 LIME 的局部代理模型

我们将要探讨的第一个方法是称为局部可解释的模型无关解释(LIME)。正如其名相当明显地暗示的那样,LIME 是(1)一个模型无关的方法,这意味着它可以与任何机器学习模型黑盒一起使用;并且(2)一个用于解释模型个别预测的局部可解释性方法。

LIME 实际上是一种 局部代理方法。它使用线性模型来近似我们感兴趣解释的示例的局部黑盒模型。这种直觉在图 9.14 中显示,其中有一个黑盒模型的复杂表面和一个可解释的线性代理模型,该模型近似单个感兴趣示例周围的黑盒行为。

CH09_F14_Kunapuli

图 9.14 LIME 在需要解释预测的示例的局部创建了一个代理训练示例集。这些示例根据它们的距离进一步加权。这通过代理示例的大小表示,较近的示例获得更高的权重(并显示更大)。使用加权损失函数来拟合线性代理模型,这提供了局部解释。

保真度-可解释性权衡再次出现

对于我们想要解释预测的训练示例,LIME 训练一个局部代理,使其成为在保真度和可解释性之间具有最佳权衡的模型。在上一节中,我们训练了一个决策树代理来优化保真度-可解释性权衡。

让我们更正式地写下这一点。首先,我们用 ƒb 表示黑盒模型,用 ƒs 表示代理模型。我们使用 R² 分数来衡量黑盒 (ƒ[b]) 和代理 (ƒ[s]) 预测之间的保真度。我们通过树中的叶子节点数量来衡量代理模型的可解释性:叶子节点越少,通常可解释性越好。

假设我们想要解释黑盒在示例 x 上的预测。对于决策树代理训练,我们试图找到一个决策树,以优化以下内容:

CH09_F14_Kunapuli-eqs-20x

类似地,LIME 通过优化以下内容来训练线性代理:

CH09_F14_Kunapuli-eqs-21x

在这里,我们将使用称为代理训练示例的 x' 来训练代理模型。用于衡量保真度的损失函数是一个简单的加权均方误差(MSE),它衡量黑盒和代理预测之间的差异:

CH09_F14_Kunapuli-eqs-22x

代理是一个线性模型,形式为 ƒs = β[0] + β[1]x[1]'+ ⋅⋅⋅ + β[d]x[d]', 其中 x' 是代理示例。正如我们在第 9.1 节中看到的,线性模型的可解释性取决于特征的数量。特征越少,分析相应的参数 β[k] 就越容易。因此,LIME 试图训练具有更多零参数的稀疏线性模型,以促进可解释性(记住在第七章中提到的 L1 正则化可以帮助做到这一点)。

但是什么让 LIME 变得局部?我们如何训练一个局部的代理模型?我们如何获得代理示例 x'? 以及前一个方程中的这些局部权重 (π[x]) 是什么?答案在于 LIME 如何创建和使用代理示例。

为局部可解释性采样代理示例

我们现在有一个明确的保真度-可解释性标准来训练我们的代理模型。如果我们使用整个训练集,我们将获得一个全局代理模型。

为了训练一个局部代理模型,我们需要接近或相似于我们感兴趣示例的数据点。LIME 通过采样和平滑创建一个局部的代理训练集。

假设我们感兴趣的是解释具有五个特征的示例上黑盒的预测:x = [x[1],x[2],x[3],x[4],x[5]]。LIME 如下在 x 的邻域中采样数据:

  • 扰动——为每个特征随机生成扰动。对于连续特征,扰动从正态分布中随机采样,ϵ~N(0,1)。对于分类特征,这些是从 K 个类别值的多变量分布中随机采样,ϵ~Cat(K)。这生成一个代理示例 x' = [x[1] + ϵ[1], x[2] + ϵ[2], x[3] + ϵ[3], x[4] + ϵ[4], x[5] + ϵ[5]]。现在这个示例也可以使用黑盒进行标记,y = f[b](**x')。这个过程会继续,直到我们在 x 的局部区域内获得一个代理集 Z

  • 平滑——每个代理训练示例也使用指数平滑核分配一个权重:πx = exp(-γD(x,x')²)。在这里,D(x,x') 是需要解释的示例 x 与扰动样本 x' 之间的距离。距离 x 更远的代理训练示例将获得更小的权重,而距离 x 更近的示例将获得更高的权重。因此,这个函数鼓励代理模型在训练线性近似时优先考虑更局部的代理示例。平滑参数 γ > 0 控制核的宽度。增加 γ 允许 LIME 考虑更大的邻域,使模型更不局部。

现在我们已经在示例 x 的局部区域内拥有了一个代理训练集,我们可以训练一个线性模型。目标是训练它以产生稀疏性(尽可能多的零参数)。LIME 支持使用 L1 正则化训练稀疏线性模型,例如最小绝对收缩和选择(LASSO)或弹性网络。这些模型在第七章中介绍,可以很容易地扩展到用于分类的逻辑回归。

注意:敏锐的观察者可能会注意到,以欧几里得距离 D 为条件的指数核与支持向量机和其他核方法中使用的径向基函数(RBF)核是相同的。从这个角度来看,指数平滑核本质上是一个相似度函数。距离较近的点被认为是更相似的,并将具有更高的权重。

实践中的 LIME

LIME 可以通过 Python 最流行的两个包管理器:pip 和 conda 获得。该包的 GitHub 页面(github.com/marcotcr/lime)还包含额外的文档和多个示例,说明如何使用 LIME 进行分类、回归以及在文本和图像分析中的应用。

在列表 9.9 中,我们使用 LIME 来解释来自银行营销数据集的测试集示例的预测。测试示例 3104 是一位已经订阅的客户,XGBoost 模型以 64%的置信度将其识别为真阳性示例。

列表 9.9 使用 LIME 解释 XGBoost 预测

cat_features = ['default', 'housing', 'loan', 'contact', 'poutcome', 
                'job', 'marital', 'education', 'month', 'day_of_week']
cat_idx = np.array(                                                     ❶
              [cat_features.index(f) for f in cat_features])

from lime import lime_tabular
explainer = lime_tabular.LimeTabularExplainer(
    Xtrn.values,                                                        ❷
    feature_names=list(Xtrn.columns),                                   ❸
    class_names=['Sub?=NO', 'Sub?=YES'], 
    categorical_names=cat_features,
    categorical_features=cat_idx,                                       ❶
    kernel_width=75.0,                                                  ❹
    discretize_continuous=False)

exp = explainer.explain_instance(                                       ❺
          Xtst.iloc[3104], xgb.predict_proba)    
fig = exp.as_pyplot_figure()                                            ❻

❶ 明确识别分类特征及其索引(用于可视化)

❷ 通过训练集,有时用于采样,特别是连续特征

❸ 明确识别特征名称和类别名称(用于可视化)

❹ 为此数据集设置核宽度(通过试错法确定)

❺ 解释测试示例 3104 的预测

❻ 将解释可视化为条形图

图 9.15 可视化了 LIME 为解释此示例而识别的局部权重。

CH09_F15_Kunapuli

图 9.15 展示了 LIME 为测试示例 3104(一个真阳性预测)生成的解释。对负面预测(不会订阅)有贡献的特征将是负值,位于零的左侧。对正面预测(会订阅)有贡献的特征将是正值,位于零的右侧。

被解释示例的特征和特征值(在 y 轴上显示)。x 轴显示 LIME 特征重要性。

除了社会经济趋势之外,让我们看看这位客户的个性化特征。影响最大的变量是联系(=0),他们是否被手机或固定电话联系(在这里,0 = 手机);以及违约,他们是否有之前的银行违约历史(在这里,0 = 他们没有之前的违约)。

这些解释对非 AI 用户来说也将是直观的,例如在销售和营销领域,他们可能会进一步分析它们以微调未来的营销活动。

9.4.2 使用 SHAP 的局部可解释性

在本节中,我们将介绍另一种广泛使用的局部可解释性方法:Shapley 加性解释(SHAP)。SHAP 是另一个类似于 LIME 的模型无关黑盒解释器,用于通过特征重要性解释单个预测(因此,局部可解释性)。

SHAP 是一种特征归因技术,它根据每个特征对整体预测的贡献来计算特征重要性。SHAP 建立在 Shapley 值的概念之上,该概念来自合作博弈论领域。在本节中,我们将学习 Shapley 值是什么,它们如何应用于计算特征重要性,以及如何在实践中高效地计算它们。

理解 Shapley 值

假设一组四个数据科学家(Ava、Ben、Cam 和 Dev)在 Kaggle 挑战中合作并赢得了一等奖,总奖金为 20,000 美元。作为一个公平的团队,他们决定根据他们的贡献来分割奖金。他们通过尝试了解他们在各种组合中的工作效果来实现这一点。由于他们过去合作了很多,他们记录了他们单独工作以及以两人和三人小组工作时的效果。这些表示每种组合有效性的值在图 9.16 中显示。

CH09_F16_Kunapuli

图 9.16 展示了 Ava、Ben、Cam 和 Dev 的所有可能的联盟及其对应的价值(以 1,000 美元为单位)。最后一个联盟包含所有四个朋友,价值为 20,000 美元,即总奖金。有一个大小为 0 的联盟,四个大小为 1 的联盟,六个大小为 2 的联盟,四个大小为 3 的联盟,以及一个大小为 4 的联盟。这个表称为特征函数。

此表列出了 Ava、Ben、Cam 和 Dev 的每一种可能的组合,也称为联盟。与每个联盟相关联的是其价值(以 1,000 美元为单位),这表明如果他们是这个项目唯一的工作者,每个联盟的价值是多少。

例如,仅 Ava 的联盟价值为 7,000 美元,而 Ava、Ben 和 Dev 的联盟价值为 13,000 美元。所有四个人的最后一个联盟,称为大联盟,价值为 20,000 美元,即总奖金。

Shapley 值使我们能够将总奖金分配给所有可能的联盟中的这四个团队成员。它实际上帮助我们确定团队成员对整体协作的重要性,并帮助我们确定分割整体价值(在这种情况下,奖金)的公平方式。

每个团队成员p(也称为玩家)的 Shapley 值是以非常直观的方式计算的:我们观察在有和没有该团队成员的情况下,每个联盟的价值是如何变化的。更正式地说:

CH09_F16_Kunapuli-eqs-25x

这个方程一开始可能看起来令人畏惧,但实际上非常简单。图 9.17 展示了在计算 Dev(团队成员 4)的 Shapley 值时,该方程的组成部分:(1)第一行中的包含 Dev 的联盟,(2)第二行中对应的没有 Dev 的联盟,以及(3)第三行中两者之间的加权差异。

CH09_F17_Kunapuli

图 9.17 计算 Dev 的 Shapley 值。最上面一行是所有包含 Dev 的联盟。中间一行显示的是没有 Dev 的对应联盟。最后一行显示了联盟价值中的个体加权差异。将最后一行相加,我们得到 Dev 的 Shapley 值:φ[Dev] = 6。

权重是使用 n,团队成员总数(在这种情况下,为四个),和 n[s],联盟大小来计算的。例如,对于联盟 S = {Ava, Cam},n[s] = 2。没有 Dev 的联盟(S)和包含 Dev 的联盟(S ∪ Dev)的权重都将为 (1!2!)/4! = (1/12)。其他权重可以类似地计算。

将图 9.17 最后一行的所有加权差异相加,我们得到 Dev 的 Shapley 值,φ[Dev] = 6。同样,我们也可以得到φ[Ava] = 4.667,φ[Ben] = 4.333,和φ[Cam] = 5。这表明,根据图 9.16 中的特征函数,一个公平的方式来根据贡献分配奖金是 Ava、Ben、Cam 和 Dev 分别获得 4,667 美元、4,333 美元、5,000 美元和 6,000 美元。

Shapley 值有一些有趣的理论属性。首先,观察φ[Ava] + φ[Ben] + φ[Cam] + φ[Dev] = 20。也就是说,Shapley 值的总和等于大联盟的价值:

CH09_F17_Kunapuli-eqs-27x

Shapley 值的这个属性,称为 效率,确保整体协作的价值正好分解并归因于协作中的每个团队成员。

另一个重要的属性是 可加性,它确保如果我们有两个值函数,使用联合值函数计算的整体 Shapley 值等于各个 Shapley 值的总和。这对于集成方法有一些重要的含义,因为它允许我们将各个基础估计器的 Shapley 值相加,以获得整个集成中的 Shapley 值。

那么,Shapley 值与可解释性有什么关系呢?类似于四个数据科学家朋友的情况,机器学习问题中的特征协同工作以做出预测。Shapley 值允许我们分配每个特征对整体预测的贡献程度。

Shapley 值作为特征重要性

假设我们想要解释黑盒模型 ƒ 在示例 x 上的预测。特征 j 的 Shapley 值计算如下:

CH09_F17_Kunapuli-eqs-29x

我们将黑盒模型作为特征/值函数。与之前一样,我们考虑了包含和不包含特征 j 的所有可能的联盟。

现在,我们可以计算所有特征的特征值。与之前一样,特征重要性估计的特征值计算是高效的,并将整体预测的一部分归因于每个特征:

CH09_F17_Kunapuli-eqs-30x

Shapley 值在理论上得到了很好的论证,并且具有一些非常吸引人的属性,使其成为特征重要性的稳健度量。直接在实践中使用此程序有一个显著的局限性:可扩展性。

Shapley 计算使用训练好的模型来评分特征重要性。实际上,它将需要为每个特征联盟使用一个训练好的模型。例如,对于之前提到的具有两个特征——年龄和 glc 的糖尿病诊断模型,我们将需要训练三个模型,每个联盟一个:ƒ[1](年龄),ƒ[2](glc),和ƒ[3](年龄,glc)。

通常,如果我们有d个特征,我们将有 2^d 个总联盟,并且我们需要训练 2^d - 1 个模型(我们不训练空联盟的模型)。例如,银行营销数据集有 19 个特征,将需要训练 2¹⁹ - 1 = 524,287 个模型!这在实践中是荒谬的。

SHAP

面对这样的组合不可行性,我们能做什么?我们总是这样做:近似和采样。受 LIME 的启发,SHAP 方法旨在学习一个线性代理函数,其参数是每个特征的 Shapley 值。

类似于 LIME,给定一个黑盒模型ƒb,SHAP 也使用与 LIME 形式相同的损失函数训练一个代理模型ƒs。然而,与 LIME 不同的是,我们必须在损失函数中考虑联盟的概念:

CH09_F17_Kunapuli-eqs-31x

让我们通过观察它与 LIME(也见图 9.18)的相似之处和不同之处来理解这个损失函数和 SHAP。就像之前一样,假设我们感兴趣的是解释一个具有五个特征x = [x[1],x[2],x[3],x[4],x[5]]的示例上的黑盒模型的预测:

  • LIME 通过随机扰动原始示例 x 来创建代理示例 x'。SHAP 使用一个复杂的两步方法来创建代理示例:

    • SHAP 生成一个随机联盟向量z,它是一个 0-1 向量,表示一个特征是否应该包含在联盟中。例如,z = [1,1,0,0,1]表示包含第一个、第二个和第五个特征的联盟。

    • SHAP 通过使用映射函数x' = hx 从z创建一个代理示例。当z[j] = 1 时,我们设置x'[j]= x[j],即从感兴趣的示例x中获取的原始特征值。当z[j] = 0 时,我们设置x'[j]= x[j](rand),即从另一个随机选择的示例*x*(rand)中获取的特征值。根据上述 z 的选择,我们的代理示例将是x' = [x[1],x[2],x[3]^(rand), x[4]^(rand),x[5]]。

CH09_F18_Kunapuli

图 9.18 SHAP 在需要解释预测的示例的局部创建了一个代理训练示例集。

因此,每个代理示例都是原始训练示例中我们想要解释的特征和另一个随机训练示例中特征的混合体。这个想法是,属于联盟的特征从感兴趣的示例中获取特征值,而不属于联盟的特征从数据集中的其他示例中获取随机的“现实特征值”。

  • LIME 使用 RBF/指数核根据代理示例 x' 与 x 的距离成反比地为其加权。SHAP 使用 Shapley 核为代理示例 x' 加权,这仅仅是 Shapley 计算中的权重,πx = ((d - n[z] - 1)! n[z]!)/d!),其中 d 是特征总数,n[z] 是联盟大小(z 中 1 的数量)。直观地说,这个权重反映了具有相似数量零和非零特征的类似联盟的数量。

现在我们已经有一个在示例 x 附近的代理训练集,我们可以训练一个线性模型。一种名为 KernelSHAP 的 SHAP 版本使用线性回归进行训练。这个线性模型的权重将是每个特征的近似 Shapley 值。

SHAP 在实践中的应用

与 LIME 类似,SHAP 也作为 Python 两个最受欢迎的包管理器:pip 和 conda 中的包提供。请参阅 SHAP 的 GitHub 页面 (github.com/slundberg/shap) 以获取文档和许多示例,说明如何将其用于分类、回归以及文本、图像甚至基因组数据的应用。

在本节中,我们将使用一种名为 TreeSHAP 的 SHAP 版本,该版本专门设计用于树模型,包括单个决策树和集成。TreeSHAP 是 SHAP 的一个特殊变体,它利用决策树的独特结构来有效地计算 Shapley 值。

如前所述,Shapley 值有一个称为可加性的良好属性。对我们来说,这意味着如果我们有一个是树的可加组合的模型,即树集成(例如,bagging、随机森林、梯度提升和牛顿提升等),那么集成 Shapley 值就是单个树 Shapley 值的总和。

由于 TreeSHAP 可以有效地计算集成中每个单个树中每个特征的 Shapley 值,我们可以有效地得到整个集成的 Shapley 值。最后,与 LIME 不同,TreeSHAP 不需要我们提供代理数据集,因为树本身包含所有所需的信息(特征分割、叶值/预测、示例计数等)。

TreeSHAP 支持本书中讨论的许多集成方法,包括 XGBoost。以下列表展示了如何使用 XGBoost 模型计算和解释 Bank Marketing 数据集测试示例 3104 的 Shapley 值。

列表 9.10 使用 TreeSHAP 解释 XGBoost 预测

import shap

explainer = shap.TreeExplainer(xgb, feature_names=list(Xtrn.columns))    

shap_values = explainer(                                  ❶
                  Xtst.iloc[3104].values.reshape(1, -1))

shap.plots.waterfall(shap_values[0])                      ❷

shap.initjs()
shap.plots.force(shap_values[0])                          ❸

❶ 解释测试示例 3104 的预测

❷ 使用瀑布图可视化 Shapley 值

❸ 使用力图可视化 Shapley 值

此列表以两种方式可视化 Shapley 值:作为瀑布图(图 9.19)和作为力图(图 9.20)。请记住,SHAP 以预测概率(置信度)来解释分类器模型。对于分类器,x 轴的值将是 log-odds,其中 0.0 代表分类的平等概率(1:1),或 50% 预测概率作为一个正面例子。

图 9.19 中的瀑布图显示了每个特征对示例 3104 的整体预测的个体贡献。正如我们所见,每个特征的个体预测贡献加起来等于最终的预测值:0.518。这是 SHAP 解释加性特性的一个清晰的视觉说明。

CH09_F19_Kunapuli

图 9.19 中的瀑布图用于可视化 Shapley 值。图中左侧的值显示测试示例 3104 的特征值,而条形中的文本显示它们的 Shapley 值。

图 9.20 中的力图允许更直观地查看特征如何对预测做出贡献。该图以预测(0.518)为中心,并可视化特征通过正或负解释推动预测的程度。

CH09_F20_Kunapuli

图 9.20 中的力图用于可视化 Shapley 值。指向右侧的特征将此示例(0.52)的预测推高,高于平均预测(-0.194)。指向左侧的特征将预测推低,并接近基值。贡献加起来等于整体预测,对于此示例,整体预测高于基值。解释示例的特征值以及力图下的特征一起显示。

注意:LIME 和 SHAP 都是 加性 本地可解释方法。这意味着它们可以以相当直接的方式扩展到全局可解释性:可以通过对与任务相关的数据集计算出的本地特征重要性进行平均,从任一方法中获得全局特征重要性。

LIME 和 SHAP 的一个缺点是它们仅设计用于计算和评估单个特征的重要性,而不是特征交互。SHAP 提供了一些支持,以类似于 PDPs 的方式可视化特征交互。

然而,与 PDPs 一样,SHAP 没有任何机制可以自动识别重要的交互特征组,并迫使我们可视化所有成对的特征,这可能会令人不知所措。例如,在 Bank Marketing 数据集中有 19 个特征,我们将有 171 对特征交互。

在现实世界的应用中,由于许多特征相互依赖,了解特征交互在决策中的作用也很重要。在下一节中,我们将了解一种这样的方法:可解释提升机。

9.5 玻璃盒集成:训练以实现可解释性

我们已经了解了模型无关的可解释性方法。这些方法可以接受已经训练好的模型(例如,由 XGBoost 等集成学习器训练)并尝试解释模型本身(全局)或其预测(局部)。

但我们是否可以训练一个可解释的集成从零开始?这种集成方法是否仍然可以表现良好并且是可解释的?这些问题是推动可解释提升机(EBMs)发展的动力,这是一种玻璃盒集成方法。EBMs 的一些关键亮点如下:

  • EBMs 可以用于全局可解释性和单个示例的局部可解释性!

  • EBMs 学习一个完全分解的模型;也就是说,模型组件只依赖于单个特征或特征对。这些组件直接提供可解释性,EBMs 不需要额外的计算(如 SHAP 或 LIME)来生成解释。

  • EBMs 是一种广义加性模型(GAM),是本章以及书中其他地方讨论的 GLMs 的非线性扩展。与 GLMs 类似,GAM 的每个组件只依赖于一个特征。

  • EBMs 也可以检测重要的成对特征交互。因此,EBMs 将 GAMs 扩展到包括两个特征的组件。

  • EBMs 使用一种循环的训练方法,其中通过重复遍历所有特征来训练大量基础估计器。这种方法也是可并行的,这使得 EBMs 成为一个高效的训练方法。

在接下来的两个部分中,我们将了解 EBMs 的概念工作方式,以及如何在实践中训练和使用它们。

9.5.1 可解释提升机

EBMs 有两个关键组件:它们是广义加性模型(GAMs),并且它们具有特征交互。这允许模型表示分解成更小的组件,从而实现更好的解释。

具有特征交互的 GAMs

我们熟悉 GLM 的概念,它使用链接函数 g(y) 将目标与特征上的线性模型相关联:

CH09_F20_Kunapuli-eqs-33x

GLM 的每个组件 β[j]x[j] 只依赖于一个特征 x[j]。GAM 将这个非线性模型扩展到特征上:

CH09_F20_Kunapuli-eqs-34x

与 GLM 类似,GAM 的每个组件 ƒ(x[j]) 也只依赖于一个特征 x[j]。记住,GLMs 和 GAMs 都可以被视为集成,其中集成的每个组件只依赖于一个特征!这对训练有重要影响。

EBMs 进一步扩展 GAMs 以包括成对组件。然而,由于特征对的数量可能非常大,EBMs 只包括少量重要的特征对:

CH09_F20_Kunapuli-eqs-35x

这也在图 9.21 中展示了,这是之前提到的糖尿病诊断问题,但增加了三个变量:年龄、血糖水平(glc)和体质指数(bmi)。这个例子中的 EBM 包含所有三个特征的单个组件,以及一个成对组件而不是所有三个组合。

CH09_F21_Kunapuli

图 9.21 一个 EBM 是一个广义加性模型,由仅依赖于一个特征的非线性组件以及依赖于特征对的非线性组件组成。这个例子展示了依赖于三个变量(年龄、glc 和 bmi)的糖尿病诊断 EBM。尽管有三个变量对(年龄-glc、glc-bmi、年龄-bmi),但这个 EBM 只包括它认为有意义的其中一个。可解释的增强模型也是一个集成。

由于每个组件仅是单个或两个变量的函数,一旦学习,我们就可以立即可视化每个变量(或变量对)与目标之间的依赖关系。此外,EBM 避免了包含所有成对组件,并且只选择最有效的组件。这避免了模型膨胀并提高了可解释性。通过仔细选择 EBM 的结构,我们可以训练一个可解释的集成,这使得它成为一个玻璃盒方法。

但模型性能如何?是否可以有效地训练一个 EBM,使其与现有的集成方法一样表现良好?

训练 EBMs

与 GLMs 和 GAMs 一样,EBM 也是基于单个特征以及特征对的基组件的集成。这很重要,因为它允许我们通过简单修改我们最喜欢的集成学习器:梯度提升法来顺序训练 EBM。EBM 也使用两阶段程序进行类似训练:

  • 在第一阶段,EBM 为每个特征 ƒj 配置组件。这是通过在数千次迭代中循环和顺序训练过程来完成的,每次只处理一个特征。在迭代 t 中,对于特征 j,我们使用梯度提升法拟合一个非常浅的树 tj。一旦我们循环遍历了迭代中所有特征,我们就进入下一个迭代。这个过程在图 9.22 中得到了说明。

CH09_F22_Kunapuli

图 9.22 展示了 EBM 训练过程的第一阶段,其中每个特征的模型都是顺序和循环训练的,每个迭代中每个特征一个模型。训练的树很浅,学习率非常低。然而,在非常大量的迭代中,可以为每个特征学习到一个足够复杂的非线性模型。对于 EBM 训练的第二阶段,也遵循了类似的程序,其中对特征对的交互模型进行训练。

  • 部分训练好的 EBM g(y) = ƒ1 + ⋅⋅⋅ + ƒd 现在已被冻结,并用于评估和评分所有可能的特征对 (x[i],x[j])。这使得 EBM 能够确定数据中至关重要的特征交互对 (x[a],x[b]) ⋅⋅⋅ (x[u],x[v])。选择了一小部分相关的特征对。

  • 在第二阶段,EBM 以与第一阶段相同的方式为每个特征对 ƒjk 拟合浅层树树^t[jk]。这产生了一个完全训练好的 EBM:g(y) = ƒ1 + ⋅⋅⋅ + ƒd + ƒab + ⋅⋅⋅ + ƒuv。

从图 9.22 中,我们可以看到每个单独的组件 ƒj 实际上是由成千上万的浅层树组成的集合:

CH09_F21_Kunapuli-eqs-36x

类似地,每个特征交互组件也是一个集合:

CH09_F22_Kunapuli-eqs-37x

那么,这个 EBM 究竟是如何成为一个透明的“玻璃箱”的呢?有三种方式:

  • 局部可解释性——对于一个分类问题,给定一个特定的例子,如果我们想解释 x,我们可以从 EBM 中获取预测的对数几率作为 ƒ1 + ⋅⋅⋅ + ƒd + ƒab + ⋅⋅⋅ + ƒuv。由于 EBM 已经是一个完全分解和加性模型,我们可以简单地获取每个特征 ƒj 或特征对 ƒjk 的贡献。对于回归,我们可以以类似的方式获取对整体回归值的贡献。在这两种情况下,都没有像 LIME 或 SHAP 这样的额外程序,也不需要使用线性模型进行近似!

  • 全局可解释性——由于我们有每个组件 ƒj 或 ƒjk,我们还可以在 x[j] 和/或 x[k] 的特征范围内绘制它们。这将产生一个 依赖图,显示所有可能的值下 x[j] 和/或 x[k] 的特征。这告诉我们模型的整体行为。

  • 特征交互——与 SHAP 或 LIME 不同,该模型也通过设计内在地识别关键特征交互。这提供了对模型行为的额外见解,并有助于更好地解释预测。

9.5.2 实践中的 EBMs

EBMs 作为 InterpretML 包的一部分可用。除了 EBMs,InterpretML 包还提供了 LIME 和 SHAP 的包装器,使我们能够在同一个框架中使用它们。InterpretML 还包括一些用于可视化的良好功能。然而,在本节中,我们只将探索如何使用 InterpretML 训练、可视化和解释 EBMs。

注意:InterpretML 可以通过 pip 和 Anaconda 安装。该包的文档页面 (interpret.ml/) 包含有关如何使用各种透明和黑盒模型的额外信息。

列表 9.11 展示了如何在 Bank Marketing 数据集上训练 EBM。与第 9.2 节中训练的随机森林和 XGBoost 模型一样,我们必须考虑数据中的类别不平衡。我们在训练过程中通过将正面示例加权重 5.0 和负面示例加权重 1.0 来实现这一点。此列表还创建了两个可视化:一个用于局部可解释性(测试示例 3104)和另一个用于全局可解释性(使用特征重要性和依赖图)。

列表 9.11 使用 InterpretML 训练和可视化 EBM

from interpret.glassbox import ExplainableBoostingClassifier

wts = np.full_like(ytrn, fill_value=1.0)     
wts[ytrn > 0] = 5.0                                                     ❶

feature_names = list(Xtrn.columns)
feature_types = np.full_like(feature_names, fill_value='continuous')
cat_features = ['default', 'housing', 'loan', 'contact', 'poutcome', 
                'job', 'marital', 'education', 'month', 'day_of_week']
feature_types = ['categorical' if f in cat_features else 'continuous' 
                 for f in feature_names]                                ❷

ebm = ExplainableBoostingClassifier(feature_names=feature_names, 
                                    feature_types=feature_types)
ebm.fit(Xtrn, ytrn, sample_weight=wts)                                  ❸

from interpret import set_visualize_provider                            ❹
from interpret.provider import InlineProvider
set_visualize_provider(InlineProvider())

from interpret import show                                              ❺
x = Xtst.iloc[3104, :].values.reshape(1, -1)
y = ytst[3104].astype(float).reshape((1, 1))

local_explainer = ebm.explain_local(x, y)                               ❻
show(local_explainer)

ebm_global = ebm.explain_global()                                       ❼
show(ebm_global)

❶ 以 1:5 的比例加权重以考虑类别不平衡

❷ 为 EBM 识别特征类型:分类、连续

❸ 使用这些权重初始化和训练 EBM

❹ 初始化 InterpretML 可视化器

❺ 测试示例 3104 解释

❻ 测试示例 3104 的局部解释

❼ 全局解释(特征重要性和依赖图)

ExplainableBoostingClassifier 默认训练 5,000 轮,支持提前停止。ExplainableBoostingClassifier 还限制成对交互的数量为 10(默认情况下,尽管这可以由用户设置)。由于此数据集有 19 个特征,总共有 171 个成对交互,模型将选择前 10 个。

训练好的 EBM 模型整体准确率为 86.69%,平衡准确率为 74.59%。第 9.2 节中训练的 XGBoost 模型整体准确率为 87.24%,平衡准确率为 74.67%。EBM 模型与 XGBoost 模型相当!关键区别在于 XGBoost 模型是一个黑盒,而 EBM 是一个玻璃盒。

那么,我们能从这个玻璃盒中得到什么?图 9.23 展示了测试示例 3104 的局部解释。局部解释显示了模型中每个特征及其特征交互对对整体正面或负面预测的贡献程度。

CH09_F23_Kunapuli

图 9.23 展示了测试示例 3104 的局部可解释性,包括单个特征(例如,euribor3m 和 poutcome)和成对特征(例如,月份×星期几)。每个 EBM 组件的值及其对整体预测的贡献(Sub? = YES)显示出来。

测试示例 3104 是一个正面示例(即 Sub?=YES,表示客户确实订阅了定期存款账户)。EBM 模型已正确分类此示例,置信度(预测概率)为 66.1%。

训练好的 EBM 模型使用了一些特征,例如 nr.employed,这些特征我们知道是重要的,与其他方法类似。这个训练好的 EBM 还使用了三个成对特征来预测 3104:月份 x 星期几,星期几 x cons.conf.idx,default x 月份。最高的成对特征交互是月份 x 星期几,它对整体预测贡献了正值。这与 LIME 和 SHAP 对 XGBoost 黑盒的解释形成对比,因为它们只能识别月份,因为它们不支持显式地处理特征交互。EBM 模型能够学习使用更细粒度的特征,并解释其重要性!这里的启示是,EBM 模型明确地构建了包含特征交互并解释它们的结构。

EBMs 还可以在特征重要性方面提供全局可解释性。整体重要性是通过在整个训练集上平均(绝对值)个体特征重要性来获得的。

整个模型包含 30 个组件:19 个个体特征组件,10 个成对特征组件,以及 1 个截距。图 9.24 显示了前 15 个特征和成对特征的重要性。这些结果与使用其他方法(如 SHAP 和 LIME)计算出的先前特征重要性度量总体上是一致的。

CH09_F24_Kunapuli

图 9.24 展示了训练好的 EBM 模型的全局可解释性,显示了特征重要性分数

最后,我们还可以直接从 EBM(如前文图 9.22 所述)获得依赖图。图 9.25 显示了年龄的依赖图以及它如何影响某人是否会订阅定期存款账户。

CH09_F25_Kunapuli

图 9.25 展示了年龄的依赖图。预处理期间,代表年龄的 x 轴区间被缩放到 0-1 的范围。原始年龄在 17-98 岁之间。对于 0.2-0.4 范围内的分数是负值,这对应于 33-49 岁。这表明,如果没有其他信息,这个年龄范围内的人通常不太可能订阅定期存款账户。

摘要

  • 黑盒模型由于其复杂性,通常难以理解。这类模型的预测需要专门的工具才能解释。

  • 白盒模型更直观,更容易理解。这类模型的结构使它们本质上具有可解释性。

  • 大多数集成方法通常是黑盒方法。

  • 全局方法试图一般性地解释模型的整体决策过程和广泛相关的因素。

  • 局部方法试图具体解释模型针对单个示例和预测的决策过程。

  • 特征重要性是一种可解释性方法,它根据特征对目标变量正确预测的贡献来分配分数。

  • 决策树是常用的玻璃盒模型,可以用一组决策规则表示,这些规则对人类来说很容易理解。

  • 决策树的可解释性取决于它们的复杂性(深度和叶节点数)。更复杂的树更不直观,更难理解。

  • 广义线性模型(GLMs)是另一种常用的玻璃盒模型。它们的特征权重可以解释为特征重要性,因为它们决定了每个特征对整体决策的贡献程度。

  • 重新排列特征重要性是一种用于全局可解释性的黑盒方法。它试图估计在重新排列/重新排列特征之前和之后,模型的预测性能如何变化。

  • 局部依赖性图(PDPs)是另一种用于全局可解释性的黑盒方法。局部依赖性是通过边缘化或求和其他变量来确定的。

  • 代理模型通常用于模拟或近似黑盒模型的行为。代理模型是玻璃盒,本质上可解释。

  • 全局代理模型,如决策树,训练模型以优化保真度-可解释性权衡。

  • 局部可解释模型无关解释(LIME)是一种局部代理模型,在我们要解释的示例的邻域内训练一个线性模型。

  • LIME 还优化了保真度-可解释性权衡,并通过通过扰动要解释的示例的局部邻域中的特征来生成的代理训练集来实现这一点。

  • Shapley 值允许我们通过考虑所有可能的特征组合中的贡献来分配单个特征的总体贡献(特征重要性)。

  • 对于具有许多特征和示例的真实世界数据集,Shapley 值直接计算是不可行的。

  • SHapley Additive exPlanations (SHAP)是一种局部代理模型,它训练一个局部线性模型来近似 Shapley 值。

  • 对于基于树的模型,使用特别设计的变体 TreeSHAP 来有效地计算 Shapley 值。

  • Shapley 值和 SHAP 都具有可加性属性,这允许我们在集成单个模型时聚合 Shapley 值。

  • LIME 和 SHAP 的一个缺点是它们本质上只设计用来计算和评估单个特征的相对重要性,而不是特征之间的相互作用。

  • 可解释的增强机器(EBM)是一种玻璃盒模型,可用于全局可解释性和单个示例的局部可解释性。

  • EBMs 学习一个完全分解的模型;也就是说,模型组件只依赖于单个特征或特征对。这些组件直接提供可解释性,EBMs 不需要额外的计算(如 SHAP 或 LIME)来生成解释。

  • EBMs 是一种广义加性模型(GAM),是 GLM 的非线性扩展。

  • EBMs 还可以检测重要的成对特征交互。因此,EBMs 将 GAMs 扩展到包括两个特征的组件。

  • EBMs 使用循环训练方法,通过重复遍历所有特征来训练大量的基础估计器。这种方法也是可并行的,这使得使用 EBMs 成为一个高效的训练方法。


(1.) S. Moro, P. Cortez 和 P. Rita, “基于数据驱动预测银行电话营销成功的方法,” 决策支持系统, 第 62 卷,第 22-31 页,2014 年 6 月。

序言

您做到了!无论您是希望使用集成来构建企业模型的数据科学家,还是参与构建基于机器学习应用的工程师,或者是希望在与竞争者竞争中取得优势的 Kaggler,学生,或者是普通爱好者,我都希望您在集成方法上有所新收获!

这本书的初衷不仅仅是成为成百上千个教程中的一个,简单地进行 Google 搜索就能找到。相反,目标是培养直觉和更深入地理解集成是什么,是什么激发了不同集成方法的设计和开发,以及我们如何从中获得最佳效果。

我们将不同的集成方法拆解并重新组合(在许多情况下,从头开始!)以真正了解是什么使它们运转。我们学习了几个流行集成方法的复杂现成工具和包。最后,通过案例研究,我们学习了如何在实践中使用集成方法来解决具有挑战性的现实世界应用。

我希望这种沉浸式的方法在概念上和视觉上帮助您揭开了技术和算法细节的神秘面纱。凭借这个基础和集成思维,您现在可以继续构建更好的应用程序并创建您自己的集成方法。表 E.1 是对我们所学到的各种集成方法的回顾。

表 E.1 本书涵盖的集成方法

集成方法
第二章 同构并行集成:bagging、随机森林、pasting、随机子空间、随机补丁、Extra Trees
第三章 异构并行集成:多数投票、加权、Dempster-Shafer 集成、堆叠和元学习
第四章 顺序自适应提升集成:AdaBoost、LogitBoost
第五章 顺序梯度提升集成:梯度提升(以及 LightGBM)
第六章 顺序梯度提升集成:牛顿提升(以及 XGBoost)
第八章 顺序梯度提升集成:有序提升(以及 CatBoost)
第九章 可解释的集成:可解释提升机(EBMs)

E. 进一步阅读

集成方法是任何数据科学家工具箱中的关键部分,通过它可以训练强学习器、弱学习器,甚至是其他集成方法的集成!随着您继续探索这个丰富而迷人的领域,以下资源将帮助您更深入地研究该领域的专业子主题和未来方向。

E.1.1 实践中的集成方法

  • Corey Wade,动手实践梯度提升:使用 XGBoost 和 scikit-learn 进行易于理解的机器学习和极端梯度提升(Packt Publishing,2020)

  • Dipayan Sarkar 和 Vijayalakshmi Natarajan,集成机器学习食谱(Packt Publishing,2019)

E.1.2 集成方法的理论和基础

  • Robert E. Schapire 和 Yoav Freund,提升:基础与算法(麻省理工学院出版社,2012 年)

  • Zhi-Hua Zhou,*集成方法:基础与算法,第 1 版**。(查普曼与霍尔/CRC,2012 年)

  • Lior Rokach,使用集成方法的模式分类(世界科学出版社,2010 年)

E.2 一些更高级的主题

在结束之前,我将向您介绍两个其他机器学习和人工智能框架,它们在集成方法上增加了研究重点。本书中涵盖的集成方法针对的是“经典机器学习问题”,其中数据通常以表格形式表示。然而,数据远比表格丰富得多,可以具有许多更多模态和结构,包括对象级表示、图像、视频、文本、音频、图、网络,甚至这些组合的多模态数据!

关系学习(也称为符号机器学习)框架使用对象、概念及其之间关系的高级符号表示。然后可以使用不同的方法,包括集成方法,在这个表示中构建机器学习问题并进行训练。关系学习通常非常适合推理问题(例如,社交网络中的链接预测)。

深度学习(也称为神经机器学习)框架使用对象及其之间概念的低级神经连接主义表示。人工神经网络和深度学习模型都基于这种表示。深度学习通常非常适合感知问题(例如,视频中的目标检测)。

集成方法在这两种学习框架中得到了不同程度的成功应用,并且在机器学习社区中是活跃的研究主题。

E.2.1 统计关系学习中的集成方法

如前所述,本书涵盖的方法是为表格数据设计的,其中每个示例都是一个具有多个属性或特征的独立对象。例如,在糖尿病诊断中,每个示例都是一个具有多个属性的患者,如血糖、年龄等。

然而,数据通常更为复杂,难以轻易地挤进表格中。例如,在糖尿病诊断中,存在许多不同类型的对象,如患者、医疗测试、处方和药物。每个对象都有其自身的属性集。不同的对象之间存在着复杂的关系:不同的患者有不同的医疗测试,有独特的结果,特定的处方等。

简而言之,数据通常是关系型的。在关系数据库术语中,此类数据不能总是由单个表来捕捉,但现实中需要多个表,它们之间有复杂的交互和交叉引用。

统计关系学习(SRL)是机器学习的一个子领域,它关注于在这些领域中训练模型。SRL 模型实际上是概率数据库,并且可以回答比简单的 SQL 类数据库查询更复杂的查询。

SRL 模型非常适合建模诸如链接预测、实体解析、群体检测和聚类、集体分类以及其他类似基于图的预测任务。SRL 模型已应用于文本挖掘、自然语言处理、社交网络分析、生物信息学、网络和文档搜索,以及需要推理的更复杂应用中。

SRL 是一个高级主题,需要一阶逻辑、图模型和概率的背景知识。以下是一些关于这些主题和 SRL 的良好资源:

  • Lise Getoor 和 Ben Taskar 编著,统计关系学习导论(麻省理工学院出版社,2009 年)

  • Luc De Raedt、Kristian Kersting、Sriraam Natarajan 和 David Poole 编著,统计关系人工智能:逻辑、概率和计算(摩根和克莱泼出版社,2016 年)

SRL(统计关系学习)的一个突出集成方法是 BoostSRL (starling.utdallas.edu/software/boostsrl/)),它是一个用于不同 SRL 模型的梯度提升框架。以下参考文献是深入了解 SRL 模型集成方法的良好起点:

  • Sriraam Natarajan、Kristian Kersting、Tushar Khot 和 Jude Shavlik 编著,增强统计关系学习:从基准到数据驱动医学(斯普林格,2015 年)

E.2.2 深度学习的集成方法

在过去十年中,神经网络经历了复兴并获得了相当大的流行度,在处理大规模学习任务(如文本、图像、视频和音频)方面取得了巨大成功。本书中讨论的许多集成技术可以通过使用深度神经网络作为基础估计器来创建深度学习集成。这些技术包括袋装、自适应提升和堆叠等技术。

主要的缺点是与训练深度学习集成相关的计算成本。单个深度学习模型在训练时计算成本很高,并且需要大量数据。由于集成方法依赖于多个基础模型的集成多样性,一个有效的深度学习集成将需要训练许多这样的网络!

深度学习集成技术通常试图通过训练单个深度神经网络来避免训练成本,并依赖于诸如 DropOut(在网络中随机丢弃神经元)或 DropConnect(随机丢弃连接)等技术,以更有效地从单个预训练网络中创建多样化的变体。以下是一些有用的参考文献:

  • (原始 DropOut 论文)Geoffrey Hinton、Nitish Srivastava、Alex Krizhevsky、Ilya Sutskever 和 Ruslan Salakhutdinov,“通过防止特征检测器的共适应来改进神经网络”(2012 年)

  • (DropOut 作为神经集成)Pierre Baldi 和 Peter Sadowski,理解 Dropout(NeurIPS,2013)

另一种称为快照集成的方法,在训练过程中保存模型权重的快照,以创建一个无需额外训练成本的集成:

  • Gao Huang, Yixuan Li, Geoff Pleiss, Zhuang Liu, John E. Hopcroft 和 Kilian Q. Weinberger,快照集成:训练 1 次,免费获得 M 个(ICLR,2017)

另一种专门针对表格数据深度学习模型的途径是神经无关决策集成(NODE),它使用可微分的无关决策树(类似于 CatBoost),但像神经网络一样使用反向传播进行训练:

  • Sergei Popov, Stanislav Morozov, 和 Artem Babenko,用于表格数据的深度学习神经无关决策集成(ICLR,2020)

深度学习集成是一个活跃的研究领域。

E.3 感谢!

最后,亲爱的读者,感谢您阅读这本书,并坚持读到了最后!我希望您在学习集成方法的过程中感到愉快,并且您会发现这本书对您的项目有所帮助,或者也许仅仅作为一个有用的参考。祝您好运!

posted @ 2025-11-21 09:08  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报