表格数据的机器学习-全-

表格数据的机器学习(全)

原文:zh.annas-archive.org/md5/7c92abc8663909ba4bdf951baf02d310

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分:表格数据的机器学习介绍

本书的开篇部分旨在为你提供理解如何处理表格数据的基础。本部分章节探讨了表格数据的独特特征、不同的建模方法(机器学习与深度学习),以及探索性数据分析和准备的最佳实践。通过阅读这些章节,你将获得对处理表格数据特性的良好理解。你将为在后续部分处理更高级的技术做好准备。

第一章介绍了表格数据。本章解释了什么是表格数据,为什么它很重要,以及它与图像、音频和文本等其他类型数据的不同之处。我们还介绍了机器学习和深度学习概念,并试图解开关于在表格数据上使用深度学习方法的争议。本章最后通过回顾表格数据的独特特征,这些特征需要特定的分析方法来分析和建模。

第二章探讨了表格数据的结构和特征,突出了现实数据中常见问题的潜在问题和解决方案。我们提供了如何在在线和离线来源中找到表格数据的指导,特别是在商业组织中。本章还展示了如何进行有效的探索性数据分析的完整演示。

第三章基于三个关键标准——简单性、透明性和有效性——比较和对比了机器学习和深度学习方法,突出了每种方法的优点和缺点。我们还介绍了本书中将使用的 Airbnb 数据集。

完成第一部分后,你将准备好探索表格数据的经典和高级机器学习方法!

第一章:理解表格数据

本章涵盖

  • 表格数据是什么

  • 为什么表格数据很重要

  • 深度学习和非深度学习方法在处理表格数据之间的区别

  • 人们对于使用深度学习处理表格数据的看法

  • 区分表格数据与其他类型数据(如图像、声音或文本数据)的特征

表格数据是我们现代生活以及对于我们大多数人来说,是工作生活的重要组成部分。表格数据存在于电子表格中,如 CSV 文件,以及关系数据库的表中,它填充了分析和报告,还可以作为训练机器学习模型的燃料。在您的表格业务上训练的机器学习模型可以成功解决许多有用的问题,例如预测零售店的库存需求或预测市场商品的价格。

在本章中,我们介绍了为表格数据问题选择适当建模方法的过程。我们介绍了两种主要方法:深度学习和经典机器学习。然后,从数据的角度来看,我们探讨了在使用机器学习模型处理表格数据时,你面临的一些独特考虑因素。

1.1 什么是表格数据?

为了本书的目的,“表格数据”仅仅是按行和列组织的数据。一组表格数据可以被称为表格数据集表格。一行中的所有条目都与一个共同的数据点或观察相关。每一行与其他行是独立的,并完全描述了特定条件。列代表该数据点的属性,它们通常被称为变量(一个更统计学的术语)或特征(一个更典型的机器学习术语)。列中的所有条目具有共同的数据类型,如整数、字符串或浮点数。表格中的列通常具有相同的类型。

考虑一个包含一组国家使用的货币信息的表格,如图 1.1 所示。

图 1.1 表格数据的示例

该表中的列包含不同类型的值:

  • 国家、货币名称、货币符号和 ISO 4217 代码都是分类列,因为这些列的有效值来自一个有限且相对较小的值集。

  • 货币昵称是自由文本列,因为它们可以包含一系列值或根本没有值,这取决于国家。

  • 每美元单位是一个连续列,因为它包含实数值。

我们将在第二章中探讨更多关于表格数据特性的细节。

表格数据可以存在于各种物理格式中:

注意:你可能已经听到“结构化数据”这个术语与“表格数据”交替使用。然而,这两个术语并不相同。例如,人们有时将具有一定结构但不是表格形式的数据(如嵌套 JSON)称为结构化数据。结构化数据还包括关系数据、时间序列数据、图数据和空间数据,这些数据中的任何一种也可能以表格形式表示。为了避免任何混淆,我们将在本书中仅使用“表格数据”这个术语。

既然我们已经确定了什么是表格数据,那么什么不是表格数据呢?这是一个重要的问题,因为表格数据与非表格数据之间的差异有助于解释本书回答的关键问题之一:是否存在应该使用深度学习处理表格数据的情况?以下是一些非表格数据的例子:

  • 图片

  • 视频

  • 音频

  • 文本

  • JSON 格式的传感器数据,例如由物联网设备生成

  • 社交媒体流数据

你能想到所有这些非表格数据类型有什么共同点吗?如果你回答,“它们都已被非常成功地用于训练深度学习模型”,你就完全正确了。确实,在过去的 10 年里,一个又一个突破性的模型都是使用各种非表格数据集创建的。在本书中,我们将探讨为什么深度学习没有像对非表格数据那样在表格数据的世界中引起轰动,以及在什么情况下将深度学习应用于表格数据是有意义的。

1.2 世界运行在表格数据上

根据文章《结构化数据与非结构化数据》(mng.bz/5g7Z),世界上所有数字数据中高达 90%是非表格数据,并且每年非表格数据的比例都在增加。如果这是真的,那么为什么还要读一本关于将机器学习方法应用于表格数据的书呢?虽然可能只有世界上很小一部分数据是表格数据,但这一部分数据绝对是至关重要的。每个银行、每家保险公司、每个政府机构、每个零售商、每个制造商——它们的所有核心活动都是基于表格数据运行的。这种主导地位首先是因为其以行和列排列的表格格式使得表格数据易于输入、检索、管理和分析。其次,表格数据得到了许多商业软件和应用程序的支持,例如电子表格、数据库和商业智能工具。

除了它们的核心活动之外,这些组织还依赖于表格数据来监控他们的进展和发现问题。作为现代生活中的消费者、雇员和公民,你的日常活动会在数百甚至数千个表格中产生更新。

在过去的三年里,本书的一位作者有幸负责世界上最大的关系型数据库产品之一的全球支持功能。一周七天,一天 24 小时,这项工作揭示了依赖于表格数据的组织的广度和深度。它还揭示了当表格数据系统失败时会发生什么。整个大陆的购物者无法使用他们的信用卡,卡车在边境上排起了长队,货运列车停止运行,零售网站在黑色星期五崩溃,制造人工心脏的工厂也停止了运转。这并非夸张——世界运行在表格数据以及结构化数据上。

表格数据无处不在,并且至关重要。对我们中的许多人来说,我们的工作围绕着表格数据。正因为如此,了解如何有效地将机器学习(以及在适当的情况下,深度学习)应用于表格数据是一项非常有用的技能。在这本书中,你将学习解锁表格数据潜力的技术。

1.3 机器学习与深度学习比较

深度学习和经典机器学习方法都旨在将输入数据映射到预测。然而,它们采取不同的方法,因为深度学习方法被设计来模拟生物大脑的行为,而其他机器学习技术通常基于统计优化或相似度比较。然而,除了采取不同的方法之外,它们还暗示了一种深刻不同的利用数据的方式。

在经典机器学习方法中,特征转换和工程至关重要,因为无论你采用哪种模型,你都需要根据数据特征和数据的来源领域(这是商业数据吗?它代表任何社会、经济或物理现象?)对数据进行适当的转换。以下是特征工程在经典机器学习中如此重要的几个原因:

  • 相关信息提取——并非所有原始数据对特定任务都同等相关。特征工程有助于识别和提取数据中最具信息量的方面,丢弃无关或噪声的部分。通过关注相关特征,模型可以专注于学习基本模式,从而实现更好的泛化并提高性能。

  • 数据表示——不同的模型在数据表示方面有不同的要求。特征工程允许你将数据转换为适合模型假设和限制的合适格式。这一步骤保证了模型可以从数据中有效地学习并做出准确的预测。

  • 处理非线性—在许多现实世界问题中,特征与目标变量之间的关系可能不是线性的。特征工程可以帮助转换数据以处理非线性,使线性模型更容易近似复杂关系。

  • 特定领域知识—在某些情况下,领域专家可能对数据有宝贵的见解,这些见解可以用来构建相关的特征。引入领域知识可以显著提高模型在特定应用中的性能。

另一方面,深度学习方法依赖于表示学习,这是它们内部和自动将数据处理成有意义形式以解决手头问题的能力。深度学习模型的表示学习能力使它们能够将数据转换成更紧凑和有意义的形式,从而捕捉特定任务的相关特征和模式。实际上,在学习过程中,由于所有输入特征都以非线性方式与其他特征相互作用,深度学习模型能够自行发现数据中的复杂模式和依赖关系,这些模式和依赖关系可能无法通过手动特征工程显现出来,并且它们能够开发出从基本特征开始,逐步构建到更复杂和抽象的输入数据层次表示。

因此,经典机器学习主要侧重于彻底和有效的特征工程,而表格数据的深度学习模型则更多地关注神经元层级的排列架构以及单个神经元的特性。这种二分法构成了我们书籍的一个基本方面:接下来的章节不仅强调经典机器学习模型和深度学习模型之间的区别,而且还介绍了不同的方法来构建数据问题并基于这种区别找到解决方案。

虽然以这种方式简化术语可能不完全正统,但在整本书中,我们使用通用术语机器学习经典机器学习来指代所有机器学习方法,除了神经网络,我们使用深度学习来指代基于神经网络的方法。

我们将介绍基于流行包(如)的基本和更高级的机器学习模型:

  • 可在 Scikit-learn (scikit-learn.org/) 和 GPU 专用库(如 NVIDIA Rapids (developer.nvidia.com/rapids))中找到的基本机器学习模型:

    • 线性回归

    • 逻辑回归

    • 广义线性模型

  • Scikit-learn 中可用的基于树的某些方法包括

    • 弱预测器的 Bagging 集成

    • 随机森林

    • 极端随机树

  • 基于直方图的梯度提升方法,包括

对于深度学习,我们将介绍在 TensorFlow 或 PyTorch 深度学习框架中实现的、对表格数据有效的各种架构:

在我们探索这两种方法并建议何时使用每种方法来解决表格数据问题时,你将在这本书中看到机器学习和深度学习之间的这种区别。

1.4 表格数据有何不同?

我们知道,由于深度学习方法在解决涉及多种类型的数据问题时占主导地位,这些数据我们可能定义为“非表格”或“非结构化”,因为它们的特征、大小和模态的多样性,你无法在行/列数据模型中约束。深度学习成功解决的典型非结构化数据示例包括

  • 音频

  • 视频

  • 图片

  • 文本

在这里,与结构化表格数据问题相反,你没有任何接近典型矩阵形状的格式,而是不同文件或实例,以无序的方式包含大量信息。在深度学习革命改变非结构化数据建模方式之前,我们想要用于预测模型的非结构化数据必须通过仔细创建定义良好且具体的特征(称为特征工程)来转换成结构化数据格式。对于每种非结构化数据问题,研究人员和从业者花费了数年时间来找到从数据中提取的最佳特征,以供机器学习模型使用,并最终获得令人满意的预测结果。

由于它们的表示能力,深度学习模型可以处理所有必要的转换,将非结构化数据转换为可行的预测,以端到端的方式,直接从输入到解决方案。基于这个背景,你可能会预期深度学习模型在表格数据上会更加有效,但到目前为止并非如此。

实际上,有许多原因可以解释深度学习在处理表格数据问题时面临的挑战。第一个原因是学术研究和新技术的私人投资的实际方向。正如我们提到的,过去,研究人员花费时间和精力寻找将非结构化数据转换为结构化数据以适应当时机器学习范式的最佳方法。如今,同样的努力被用于推进深度学习,特别关注非结构化数据,因为它们在公共仓库中更容易获取,并且比表格数据“更统一”,因此带来了更多的研究成功。

图像库,如 ImageNet (image-net.org/index.php) 和开放文本语料库,如维基百科或 Common Crawl 的网页存档 (commoncrawl.org/),对学术研究人员和从业者来说都很容易获取,用于训练或改进他们的深度学习模型。至于表格数据,在公共开源数据仓库方面没有等效的。相反,表格数据分散在众多私有数据库中,每个数据库的变异性甚至比非结构化数据还要高,因为每个数据库都有自己的数据收集规则和特征结构。

除了开源的表格数据集通常更难找到这一事实之外,你还必须考虑第二个原因。开源的表格数据集通常规模较小,并且往往与企业和政府拥有的私有数据有很大不同。因此,数据的缺乏通常会导致神经网络性能不佳。此外,没有黄金法则可以用来衡量一个人的进步,因为使用特定类型的数据仅限于表格数据问题广泛领域中的特定问题。对于任何研究人员来说,从表格数据集或有限的选项中概括最佳实践,比使用普遍可用和接受的图像、音频或文本作为参考基准来做同样的事情要更具挑战性。

由于难以获取且包含的信息类型极其多样,表格数据集对深度学习解决方案提出了进一步的限制:你无法想到任何预训练的解决方案,因为你无法掌握所有类型的表格问题。一旦你为图像和文本问题开发了一个深度学习模型,你就可以将其公开,并期望其他学者或从业者稍作调整后,将其用于他们的问题。这在技术上被称为迁移学习,因为你可以成功地将在一个问题上训练的深度学习网络,以有限的额外建模努力,应用于另一个相似的任务。这种机会在近年来强烈推动了以预训练形式扩散深度学习模型。

总之,缺乏可推广的表格示例、表格数据类型的多样性以及学者对非结构化数据的更多关注,导致了机器学习和深度学习之间特征工程扮演着不同的角色:

  • 在机器学习中,特征工程可以为表格数据提供比算法本身更强的预测能力,并且通常被认为更像是艺术而非科学。

  • 在深度学习中,相反,学者和从业者往往过于依赖表示学习,让网络处理一切,而不是自己使用特征工程,并展示深度学习如何,在给定与机器学习算法相同的数据框架的情况下,以不同且有用的方式学习解决方案。

确实,正如最近的研究所证明的,表格数据特征,如冗余特征、偏斜分布和预测目标的非规律模式,对神经网络构成了挑战。我们将在第五章中更详细地讨论这一点,当具体处理梯度提升模型时。尽管如此,我们断言,机器学习和深度学习模型都是解决表格数据问题的可行方法,随着从业者和研究者在测试比今天更容易获得的表格数据上的架构和解决方案上投入更多努力,深度学习的重要性将会增长。

1.5 生成式 AI 和表格数据

生成式 AI——特别是大型语言模型(LLMs)——是各种与文本生产和处理相关的任务的得力助手。一般来说,LLMs 已被证明是某些范围内任务的突破性解决方案,例如

  • 生成——生成文本,如下一个标记、完成短语所需的单词,甚至从指导提示生成文本

  • 提取—命名实体识别、句子分割、关键词提取、主题建模、命名实体识别、语义关系提取

  • 分类—语言、意图、情感、语义,甚至包括讽刺、反语、否定等棘手问题

  • 文本的转换——翻译、校正、风格修改、释义、总结

  • 理解——导致问答、推理、知识补全

许多这些任务可以扩展到数据科学家或数据工程师的工作。LLMs 可以在特征工程、编写函数和可视化指令(例如,使用 matplotlib 包的命令)等活动上支持用户,提供分析建议,帮助解释结果,并将它们综合性地呈现为图表和报告。LLMs 在表格数据中的一个显著的实际应用是自动化文本数据相关任务。当处理文本变量字段时,它们可以通过总结、分类和识别关键主题来构建新的特征。它们还可以开发用于在 Python 中处理相同文本的代码,例如,通过创建函数或确定文本处理的正确正则表达式。

除了支持用户和作为有用的助手外,大型语言模型(LLMs)还可以在分析中扮演更加直接和活跃的角色。最近 ChatGPT(高级数据分析 API)的应用也提供了直接的数据分析,以 CSV 格式呈现,随后是其他与数据相关的任务,包括总结、预处理、分析、可视化和报告生成。在每一步,该工具都可以提供执行并获得相同结果的 Python 代码,它为您运行代码并提供一些图表和表格形式的可视化。这与 TableGPT 或其他工具(如 MediTab)预期的功能相一致。TableGPT(arxiv.org/pdf/2307.08674.pdf)是一个新的框架,它利用 LLMs 来增强人类与表格数据的交互。它使用户能够使用自然语言表达的命令与表格交互,并执行各种任务,如问答、数据处理、数据可视化和报告生成以及自动预测。相反,MediTab(arxiv.org/pdf/2305.12081.pdf)通过整合表格样本、将域外数据与目标任务对齐以及扩展训练数据,专注于处理医学表格数据。面对基于文本数据的预测任务,它甚至展现了优于 XGBoost 等经典机器学习算法的性能。

通常来说,大型语言模型(LLMs)在表格数据的预测任务上并不提供可比的性能,正如 TABLET 基准测试(arxiv.org/pdf/2304.13188.pdf)所示。在评估 LLMs 相对于全监督模型的表现时,该论文通过 4 次示例比较了 Flan-T5 11b 和 ChatGPT,与在整个数据集上训练的 XGBoost 进行了比较。当 XGBoost 模型应用于所有数据时,在预测任务上实现了平均 F1 分数为 0.94。相比之下,ChatGPT 的平均得分为 0.68,而 Flan-T5 11b 使用 F1 分数实现了 0.66。这一分析突出了在涉及具有多模态类型(文本和数字)的表格数据的 LLMs 预测任务中,仍有很大的改进空间,并且这些工具在执行指令方面继续表现出色,尤其是在处理文本输入和产生文本输出时。例如,llm-classifier(github.com/lamini-ai/llm-classifier)这样的工具因其能够使用已使用的 LLM 中包含的信息而令人惊讶,但它无法获取表格问题典型的许多额外信息。

总的来说,在处理表格数据时,生成式 AI 尚未成为解决方案,这不仅是因为性能原因,还因为其他关键方面,例如

  • 成本—生成式 AI 模型通常需要大量的 GPU 资源,导致更高的运营成本。

  • 可扩展性—生成式 AI 模型资源密集型的特性,尤其是它们对 GPU 的依赖,可能阻碍可扩展性。

  • 延迟和吞吐量—较大的模型往往会增加每个请求的处理时间,影响延迟和吞吐量。

  • 偏差—生成式 AI 模型可能会从它们训练的数据中继承偏差,可能加剧或放大现有偏差。

  • 灵活性—将生成式 AI 模型适应定制任务通常需要大量的重新训练,这限制了它们的灵活性。

  • 决定论—生成式 AI 模型的固有复杂性可能使其难以控制和预测其输出,从而影响决定论。

  • 可解释性—生成式 AI 模型的复杂性可能阻碍可解释性,使得理解它们如何运作和产生结果变得困难。

承认生成式 AI 处理表格数据问题的现有局限性,我们将重点关注从表格数据中学习的核心经典机器学习和深度学习技术,以及如何正确和恰当地准备这些数据以进行分析。然而,我们也将留出一些空间来处理生成式 AI 工具,如 ChatGPT、Google Gemini 和为 Google Cloud 提供的 Gemini,因为我们认识到生成式 AI 在表格数据领域的变革力量。根据我们在该领域的经验,我们预计 LLM 或其他生成工具不会取代经典的机器学习算法或专门针对表格数据的深度学习架构,因为这些综合工具在性能和控制方面都提供了优势。相反,我们认识到 LLM 和其他生成 AI 模型如何支持并增强表格数据处理、分析和建模,帮助从业者在其表格数据项目中变得更加熟练和高效。

摘要

  • 表格数据是有行和列组织的数据,例如 CSV 文件或关系型数据库表中的数据。

  • 结构化数据有时被用作表格数据的同义词,但它是一个更广泛的概念,包括 JSON 格式的数据。

  • 表格数据仅占全球所有数字数据的一小部分,但它对我们生活的影响是巨大的。

  • 与其他类型的数据(例如图像、视频、文本、音频)相比,大多数工作围绕的数据类型是表格数据,因此学习如何高效地将机器学习/深度学习应用于表格数据是一项有用的技能,许多人可以将这项技能应用于他们的工作中。

  • 在这本书中,我们简单地称不包括神经网络(从线性回归到梯度提升方法)的机器学习方法为经典机器学习或简称为机器学习,以区分它们与深度学习。

  • 与其他类型的数据(例如图像、视频、文本、音频)的深度学习相比,表格数据的深度学习在学术研究者中得到的关注较少。

  • 传统观点是使用类似于 XGBoost 的梯度提升方法来处理表格数据。

  • 在社交媒体上,关于深度学习是否在解决涉及表格数据的问题中有一席之地,存在热烈的讨论。在这本书中,我们并不在这场辩论中站队。相反,我们试图客观地描述为什么你会选择机器学习或深度学习来解决特定的表格数据问题,以及每种方法的最佳实践。

  • 表格数据具有一些其他类型数据(如图像、视频、文本)所不具备的独特特征。这些特征包括缺乏代表现实世界业务问题中表格数据集的大型开源数据集。

  • 生成式 AI,尤其是大型语言模型(LLMs),显著影响了人们对 AI 的认知、在个人和组织中的传播以及应用。LLMs 可以帮助自动化与表格数据分析建模相关的各种任务,尤其是在涉及文本输入和输出的情况下。

第二章:探索表格数据集

本章涵盖

  • 表格数据集中的行和列特征

  • 表格数据集的可能病理和补救措施

  • 在互联网上和机构内部寻找表格数据

  • 探索数据以解决表格数据中的常见问题

表格数据可能包含几乎所有内容——从低级科学研究到网站上的消费者行为,再到你幻想体育联盟中的统计数据。然而,最终,表格数据中的共性胜过差异,你只需应用标准方法和工具,即使没有太多的领域专业知识,也能完成大部分数据分析工作。

在本章中,我们将探讨如何收集和准备表格数据集。我们还将进行一项实际的数据分析探索,展示你可以采取的步骤来从不同的视角查看数据:按行、按列、在特征之间关系的背景下,以及考虑它们在数据集中的整体分布。为此示例,我们将使用一个简单的玩具数据集,即汽车燃油效率数据集(Auto MPG Data Set),这是一个在 UCI 机器学习网站上免费可用的数据集(archive.ics.uci.edu/dataset/9/auto+mpg)。

2.1 行和列特征

根据领域不同,你会在表格数据中发现多么丰富的多样性,这真是令人难以置信。这是因为表格数据在数据世界中是规则而非例外,这种情况从一开始就是如此。数千年来,表格数据被收集到表格和记录中,从古埃及的谷物会计到中世纪欧洲的教区出生、婚礼和死亡记录,一直到我们现代国家的官僚机构。直到 20 世纪 60 年代,我们才开始在计算机化的数据库中收集数据,这给“表格”这个词赋予了更多的电子含义。自 20 世纪 70 年代以来,关系数据库的广泛应用普及了表格数据,使其无处不在,并用于各种可能的应用。在关系数据库中,可以通过作为连接键的特定列的值来组合数据表。这种创新使得计算机能够在更少的磁盘空间中存储更多信息,保证了技术的成功和广泛传播。

你可以查看公开数据存储库或数据科学竞赛,例如 Kaggle,它通常会举办表格数据竞赛,以给你一个关于当前表格数据多样性的概念。例如,在过去的两年中,Kaggle 组织了表格游乐场系列赛(mng.bz/pK2z),这是一系列受最常见的机器学习问题启发的竞赛,这些问题涉及表格数据集和由生成式 AI 设计的合成数据,我们将在本章后面进一步讨论。尽管表格游乐场竞赛使用生成数据,但它们从中汲取灵感的例子和原始数据是从现实世界的例子中精选出来的,例如

  • 保险索赔的概率和金额

  • 银行领域的贷款违约

  • 产品测试

  • 电子商务销售

  • 环境传感器数据

  • 生物和基因组数据

  • 生态测量

然而,尽管有各种各样的现实应用和潜在的知识领域,每个表格数据集都共享相同的结构,即行和列的矩阵,其中包含数值、时间和日期以及文本形式的数据。这适用于所有表格数据集,无论它们的特征如何。虽然领域知识对于设计预测算法的最佳特征工程至关重要,但表格数据的基本结构在所有领域都是一致的。这种普遍性证明了编写关于表格数据书籍的必要性,因为示例和技术可以轻松地在领域之间转移。

深入细节,在一个数据表中,也称为数据集,你有值行,每一行代表你的分析单位,在统计学上,这可以称为统计单位或观察。例如,如果你正在分析 DNA 样本,那么你表中的每一行代表一个样本。如果你正在分析工业产品,每一行将代表一个产品。原则是相同的,而所代表单位的性质可以显著不同。

2.1.1 表格行的理想标准

在表格数据集中,你需要记住的唯一限制是,除非你正在处理时间预测(时间序列分析)或其他与时间相关的问题,否则示例应该是独立的。这就是你可能之前听说过的 IID 原则。IID 是“独立且同分布”的缩写,意味着你的样本应该是独立抽取的,其中每一次抽取都不影响或携带后续抽取的信息。同样,这意味着你总是以相同的方式从相同的数据分布中抽取。

让我们考虑一个简单的 IID 例子:抛硬币。每次我们抛硬币,结果都与所有之前的抛掷无关,每次抛掷得到正面或反面的概率都是相同的。换句话说,硬币抛掷是同分布的,根据伯努利分布。如果我们通过反复抛掷同一枚硬币来生成数据集,那么得到的数据将是 IID。同分布的性质允许我们简化数据的建模和分析。例如,它允许我们在创建交叉验证折时随机采样数据。它允许我们假设预测算法不会记住并复制呈现的数据的顺序。非 IID 的典型例子包括来自多个商店的销售数据,其中同一商店的销售往往高度相关,并且不一定与其他商店的动态相关,或者学校调查数据,由于共同的兴趣或经验,每个班级都表现出相似的特征,这会将非 IID 特征引入数据中。在这些情况下,数据表现出与独立同分布假设相偏离的独特特征,因为样本来自特定的群体(商店或学校班级),但在数据分层组织时,当你处理重复测量时(基本上是多次以不同但相关的方式测量相同的例子),或者当存在任何时间依赖性时,例如典型的时间序列,其中非 IID 不是一个问题。

在图 2.1 中,我们比较了在将分布作为绘制的序列进行比较时,非 IID 和 IID 情况如何不同。你可以在左面板上观察到 IID 行为:它基于随机组合骰子滚动和抛硬币。在右面板上,你可以检查非 IID 行为:只需注意在一定的样本数之后出现的跳跃,这表明在生成分布或采样其值的方式上发生了变化。

图片

图 2.1 IID 和非 IID 数据序列的比较。左图显示了 IID 数据,而右图说明了非 IID 数据,突出了序列相关性模式。

即使我们对从其抽取的分布的细节一无所知,这可以安全地想象为某种未知过程的结果,为了得到独立同分布(IID)数据,我们始终应该选择彼此无关的例子,以及我们认为来自相同情境或过程的例子。非独立同分布(Non-IID)数据可以以不同的方式影响你的分析。特别是,它可能会影响

  • 重采样(Bootstrapping),即从样本中重复采样,直到我们获得与原始样本相同大小的新样本,以及子采样(subsampling),即从样本中无重复地采样,直到获得所需大小的新样本。这两种采样过程都会受到影响,因为你可能会过度采样或扭曲某些数据信号,依赖结构也会被打乱。我们将在第四章讨论的一些学习算法中再次回到这样的采样过程,因为这些算法使用了重采样或子采样。

  • 你的模型是如何学习的,因为它可能会选择在预测时间无用的样本之间的不必要关系,当测试样本与训练样本不同且与它们无关时。例如,所有基于随机梯度下降(SGD)和迷你批梯度下降的学习算法,包括深度学习,都会受到你向算法展示样本顺序的影响。考虑一下非独立同分布数据,它具有内在的、隐藏的顺序,可能会如何影响类似算法的结果。

  • 交叉验证(Cross-validation),这是验证你的机器学习模型的最有效测试方法,当数据是非独立同分布时,可能会提供夸大的估计,因为你的算法可能会学会根据它们在训练集中的特定关系来聚类案例。

可以通过分析数据生成过程和探索来检测非独立同分布的数据。

  • 在非独立同分布(non-IID)数据中,每个样本很可能与同一数据集中至少另一个样本存在某种形式的关联。这种情况通常发生是因为你在思考分析不同的单元,但实际上这些单元是相同的。例如,在医学分析中,你可能正在分析多个医疗记录,并认为你的分析单元是这些记录本身,它们是不同的。实际上,你正在分析来自同一患者在不同时间的健康数据:实际的分析单元应该是患者,而不是记录。安德鲁·吴教授的团队也遇到了一个非常类似的情况(详情见mng.bz/OBGE),当他们准备一篇论文的数据时,使用了来自 30,000 名患者的 10 万张 X 光片的数据集,在分割数据以分离训练集和测试集时,他们没有考虑到同一患者的 X 光片可能会同时出现在训练集和测试集中,从而夸大了所有结果并扭曲了从分析中得出的所有见解。

  • 您数据中的度量代表异质分布。这种情况通常随着时间推移及其在多个层面上反映出的变化而发生。例如,在分析不同公司的资产负债表时,重要的是要注意,即使公司不同,如果它们来自不同的年份,它们可能不代表相同的分布。这是因为宏观经济状况是可变的,并且随着时间的推移可以改变公司及其行业的特征(即您想要表示的分布的特征)。

由于每次分析都是基于一些预期的分布假设,因此数据是独立同分布(IID)对于统计和回归分析中的正确估计至关重要。在机器学习中,即使方法是数据驱动和非参数的,但强烈建议使用 IID 数据,尽管在实践中,在现实世界的数据集中几乎找不到。机器学习算法的一个基本限制是它们知道特征与目标之间的关系,但无法找出行与行之间的关系。机器学习算法是列感知但不是行感知的。因此,有必要充分提供补充特征以支持机器学习算法的工作。因此,如果两个特征相关,算法将期望这种相关性源于它们之间的关系,而不是其他特征的干扰,不是由于时间或由于影响抽样的另一个隐藏特征。换句话说,当数据不是 IID 时,学习算法将根据时间或样本之间的关系学习模式,就像它们是某种基于特征的关系一样。

时间序列和代表随时间推移的现象的任何类型的纵向数据通常不是 IID。在时间序列中,每个观测值都是自相关的,这意味着每个目标值都与前一个值以及可以解释目标的特征(在统计学中称为协变量)相关。鉴于您的业务数据问题和您收集或组装数据的方式,您必须了解时间如何影响您的观测值,并尝试使用基于时间的特征来控制其影响。多亏了基于时间的特征,您的模型可以确定时间如何影响其他特征和您的目标值。通常,一个时间特征和基于时间的时间滞后特征将解决这种情况。本质上,您在这里可以有两种情况:

  • 每一行都有一个日期或时间间隔。您可以使用它作为特征,经过适当的转换后——例如,将日期转换为 Unix 时间,一个连续的数值特征。在这种情况下,您正在进行时间序列分析,需要使用适当的时间序列交叉验证策略。

  • 你将涉及不同时间的多行数据旋转,创建基于时间的特征。例如,你可以有特征在不同时间点的值,并为这样的每个时刻创建一个单独的特征(例如,sales_month_1、sales_month_2 等)。在这种情况下,你只是回到了具有 IID 数据的情况,可以继续分析而无需更多的正式性。

横截面数据,通常以表格形式呈现,即使是从同一时期抽取,也可能不是自然独立的同分布(IID)。这是因为分析单元之间的相互作用以及它们在特定群体中的成员资格可能会在观测值之间产生微妙的依赖关系,通常称为“信息泄露”。这种泄露可能导致在训练过程中,从特征到目标的不预期的预测信息传输,并且在预测时,当表示的情况不同时,可能不会以相同的方式工作。为了解决这个问题,有必要创建显式捕捉行之间不同成员资格及其关系的特征。例如,如果你正在处理来自不同公司的数据,未能提供关于它们的起源国或运营以及它们所属行业的信息,可能会为你的机器学习算法造成泄露,该算法可能会学习并利用数据中隐含的顺序,而这种顺序在预测时是不可复制的。此外,即使你可以在训练数据中为示例贴上正确的标签,如果你不能在测试数据中复制同样的情况,那么在交叉验证中隔离群体就很重要,以确保每个群体只出现在训练或验证折中。未能这样做可能会导致预测交叉验证估计值膨胀,因为机器学习算法可以利用组内的自相关性。

当使用横截面数据比较不同的时间段时,即使单元和群体之间的相互作用不存在,数据也可能不是独立的。在这种情况下,观测值的顺序很重要,我们需要考虑观测值之间的时序依赖关系。这意味着数据是非 IID 的,通常的独立性假设可能不成立。为了处理这种情况,我们可以使用时序模型来处理观测值之间的时序依赖关系。时序模型认为在不同时间点进行的观测值很可能是相关的,并且它们可以利用这种相关性来做出更好的预测。使用适当的时间序列模型和技术,我们可以在处理非 IID 数据时获得准确的预测。

作为关于如何从行角度准备你的数据的最后一条一般性建议,考虑以下内容:

  • 验证时间如何影响你的数据。考虑在分析中使用时间特征并对时间进行建模,使用滞后和移动平均,如时间序列分析中通常所做的那样,以控制仅由时间引起的变化。

  • 注意你在行中代表的内容,因为组和它们之间的关系可能会影响你的模型在训练、验证和测试中的结果。

  • 如果你的数据中存在隐藏的组,请在特征中明确表示任何分组变量。

  • 在交叉验证中,如果你的数据包含组,则优先选择组交叉验证,这样它们就不会在训练和验证折之间分割(mng.bz/YDgA)。

  • 如果时间是你的模型中的一个决定因素,请使用基于时间的验证(mng.bz/GeGO)。

在下一节中,我们将讨论应该表征表格列的理想标准以及每种数据类型在数据处理方面的含义。

2.1.2 表格列的理想标准

如果按行排列的案例采样存在一些挑战,请记住它位于你的数据表列中,这些列也称为特征,其中大部分信息都驻留于此,并且你接下来应该集中注意力的地方。一列的特点是携带与其所代表的行相关的同质信息。例如,如果你正在构建一个房地产评估数据集,并且你的行代表房屋,则列可能与房屋的面积相关(即,由最近的销售价格表示的平方米和市场评估)。由于同质性,你可以预期这些列将只携带它们指定的信息。例如,你不应该在面积列中找到销售价格或其他信息。此外,你还应预期列值与每一行所代表的单位唯一相关。

然而,在深入研究适合处理行和列的数组的结构之前,你需要首先识别可以填充列的五种数据类型以及处理每种类型的最佳方式(见表 2.1)。

表 2.1 表格数据集中的数据类型

类型 描述
数值特征 用于计数数据的整数,例如按日列举产品的销售额:[105, 122, 91, … 124]用于测量的浮点数,例如按日列举产品的销售额:[1000.50, 1230.00, 950.80, …, 1200.00 ]
序数特征 用于排名或有序级别的整数(或无小数的浮点数):[0, 1, 2, 3, …, 999]有时序数特征可以用字符串表达,并带有某种有序效果,例如使用类似 Likert 的同意或偏好量表:[“强烈不同意”,“不同意”,“既不同意也不反对”,“同意”,“强烈同意”]
低级分类特征 由于它们表达了一种质量,它们可以是字符串(称为“标签”)或与原始标签关联的整数;在这种情况下,你有一个转换字典可用:
高级分类特征 与低级分类特征相同,但具有大量标签(例如,美国区号或邮政编码:postal-codes.net/united-states
日期 它们可以是字符串,也可以是已经编码在数据格式中的,例如“2022-03-04”、“Feb 15, 1957 11:45 PM”或“6/12/2022”(存在许多日期标准和惯例)

了解每一种都是构建数据集和正确处理数据以便输入机器学习算法的基本步骤。每种算法都需要对每种类型的数据进行特殊准备,以便其最佳理解和后续的预测性能。

当你的数据以浮点数或表示某些值计数的整数表示时,你就有数值特征。根据统计学,表示为浮点数的特征通常被区分成比例尺度或区间尺度。区别在于比例尺度,代表对某个真实事物的测量,如单位销售或金钱,有一个绝对的真实零点,并且只有正值。区间尺度则是抽象或任意的测量;它们的单位可以代表任何事物。在区间尺度中,零值是任意的,值可以是负数。例如,区间尺度是华氏度或摄氏度温度的测量。你可以通过简单的转换,如减去平均值(这是一个中心化操作)和除以标准差(这是一个标准化操作),将比例尺度转换为区间尺度。以这种方式,你使零值以及尺度变得任意。

整数数字,当特征是数值时,仅是比例尺度。然而,整数也可以用于顺序特征甚至分类特征。如果你需要更多关于用整数表示的数值特征含义的澄清,开始检查这些数字是否不连续,以及是否存在许多不同的值。当这些条件得到满足时,你几乎可以确信你正在处理一个数值特征。数值特征不需要太多处理,对于机器学习目的而言,你不会太在意它们是比例尺度还是区间尺度。你必须考虑的是,它们代表了一种单一的测量类型。例如,如果你在你的数据集中测量货币价值,你不能创建一个列来表示美元、欧元和英镑:为了你的数值特征可使用,你需要为每个列提供一个测量类型。此外,你需要确认你的数值特征中缺失的数字不多,并且它们的值变化足够大,以便有用。你应该避免数据中的低方差或恒定特征。

序数特征总是由整数组成,通常表示排名、评分或分数。例如,作为一个序数特征的例子,只需考虑电子商务平台上产品的星级评分。作为数字,序数特征与数值非常相似,但它们需要不同的处理方式,你不能以完全相同的方式处理它们。首先,如果数值是一个每个值与下一个和前一个值等距离的刻度,那么在序数刻度中,值点之间的距离并不总是恒定的。例如,在长跑比赛中,第一名和第二名可能只差几秒。然而,第二名和第三名可能相差几分钟。

同样地,在一个表示某些潜在数值测量的序数刻度中,一些相邻点在数值上可能非常接近,而其他点可能非常遥远。序数刻度只是告诉你关于一个顺序,这意味着一个值在下一个值之前,在之前一个值之后。因此,不进行重大近似,你不能像处理纯数值一样处理它,例如,通过计算平均值和标准差。从某个角度来看,你可以将序数视为具有有意义排序的分类特征。

分类特征是由标签构成的特性:分类特征中的每个唯一值代表示例中提到的某种质量。我们可以将这些标签表示为字符串,在这种情况下,我们明确地处理的是分类特征,或者作为整数,在这种情况下,我们不应将其误认为是序数。即使是缺失值也可以被视为其他标签之一。因此,在分类特征中处理缺失值更为容易。我们根据列中找到的唯一值的数量区分低基数高基数分类特征。这种区分是有帮助的,因为高基数分类特征对于深度学习和机器学习算法来说处理起来更具挑战性,并且需要比低基数特征更复杂的处理。没有明确的阈值来分类分类特征为低基数或高基数。然而,拥有超过十个唯一值通常会给分析师带来挑战。在整本书中,我们将讨论处理分类特征的专门策略,称为编码。目前,只需记住一种特殊的分类特征——只有一个标签且值表示标签的存在或不存在,通常用 0 表示不存在,用 1 表示存在该质量。这些特征被称为二元特征,或者使用更统计学的术语,称为二项变量。

日期在许多商业数据和数据库中非常常见,因为时间是确保业务正常运作和资源有效利用的关键信息。日期也变化很大,因为多种日期格式取决于国家、业务应用类型或特定标准的采用。因此,已经做出了一些努力来标准化日期——例如,提出 ISO 8601 标准(mng.bz/zZQQ),但我们离一个共同的标准还有很长的路要走。重要的是要知道,日期可能作为列报告,因为它们已经被处理,以便更清晰地包含它们的周期性成分,如日、月、小时、星期几,以及非周期性成分——例如,年份。它们也可以转换为表示时间流的数值连续值;Unix 时间是最佳例子,表示自 1970 年 1 月 1 日 00:00:00 UTC 以来经过的秒数,不包括闰秒。最后,日期需要了解用于处理缺失信息的约定,因为在某些应用中,避免空或空值是惯例,而更倾向于应用一个遥远的过去或未来的日期来表示时间值缺失或未知。

2.1.3 表示行和列

在组织表格数据集时,确保每一行可识别并使用适当的数据类型对于数值、序数、分类和日期值非常重要,这些数据类型可能包括浮点数、整数、字符串、日期时间,有时甚至包括布尔值用于二进制特征。为了有效地处理这种数据类型的混合,pandas DataFrame 是 Python 中最好的数据结构。它是一个有序的列集合,提供了一种灵活且高效的方式来管理和操作表格数据。您可以在mng.bz/0Qw6了解更多关于 pandas DataFrame 的信息。

在列表 2.1 中,我们从头开始创建一个包含四行的小型表格数据集,代表四个个体,以及四列,代表描述他们的数值和分类特征,使用数据字典。我们还定义了一个包含他们名字的列表,用作访问他们信息的参考。然后我们使用 pandas 将字典转换为 DataFrame,这是一个类似二维表的复杂数据结构,并分配标签。

列表 2.1 创建一个简单的表格数据集

import pandas as pd
data = {'gender': ['male', 'female', 'male', 'female'],
        'age': [25, 30, 27, 29],
        'education_level': ['Bachelor', 'Master', 'Bachelor', 'PhD'],
        'income': [50000, 60000, 55000, 70000]}              ①
index = [ 'Bob ', 'Alice', 'Charlie', 'Emily']               ②
df = pd.DataFrame(data, index=index)                         ③
print(df)
print(df.iloc[1])                                            ④
print(df.loc['Alice'])                                       ⑤

创建一个数据字典

创建一个行索引

从字典创建一个 pandas DataFrame

打印第 1 行

打印标签为 Alice 的行

print(df)命令的输出应如下所示:

         gender  age education_level  income
Bob        male   25        Bachelor   50000
Alice    female   30          Master   60000
Charlie    male   27        Bachelor   55000
Emily    female   29             PhD   70000

注意,行用示例名称标记,列用特征名称标记。你可以通过它们的标签或索引号(从零开始)访问行和列。因此,我们可以通过其行索引(1)或其名称标签来访问爱丽丝的信息:

gender             female
age                    30
education_level    Master
income              60000
Name: Alice, type: object

这是由 pandas (pandas.pydata.org/) 实现的,这是一个专门用于数据处理的 Python 包,它允许你快速而顺畅地从多个来源加载数据;基于列或行或同时基于两者进行切片(这种操作称为 dicing);处理缺失值;添加、重命名、计算、分组和聚合特征;以及旋转、重塑并最终可视化处理后的数据。除了其超级有用的数据处理功能外,它还因其数据结构(Series 和 DataFrame)而特别闻名,这些是 Python 中处理表格数据最广泛使用的格式。

在 pandas DataFrame 中,除了数据表之外,你还有一个用于列的索引,这样你可以给它们命名,以及用于行的索引,这有助于你在识别和过滤操作中。此外,你可以高效地进行选择和多种操作,例如合并列或替换缺失值。最近,甚至流行的机器学习包 Scikit-learn,它长期以来一直接受 pandas DataFrame 作为其算法的输入,也采取了步骤来在整个管道中维护这种数据结构。现在,所有输出,而不是将转换后的数据转换为类型同质的 Numpy 数组,都可以保持为 pandas DataFrame。有关如何实现以及它如何影响你使用该包的方式的更多详细信息,请参阅mng.bz/nR54。pandas DataFrame 作为表格数据的事实标准,将在整本书中得到广泛使用,我们将展示如何应用更有用的转换来处理常见的表格数据特性和问题。

在讨论表格数据集中行和列的理想特性之后,下一节将探讨可能出错的情况以及其影响和补救措施。

2.2 病态与补救措施

作为一条一般规则,你必须始终努力避免你特征中的一些条件,无论它们的类型如何,我们在讨论数据集中可能具有的每种类型的列时简要提到了这些条件。

数据科学竞赛的先前数据可以帮助我们了解表格数据集可能出什么问题。例如,Madelon 数据集(archive.ics.uci.edu/ml/datasets/Madelon),由于其特定的特性使得预测变得困难,因此多年后仍然被记住作为一个非常具有挑战性的数据问题。Madelon 数据集是一个使用由 Isabel Guyon(guyon.chalearn.org/)开发的专门算法生成的合成数据集,她于 2022 年加入谷歌大脑担任总监。这些数据在 2003 年 NIPS 会议(第七届神经信息处理系统年会)的比赛中展出。她通过翻转部分标签将错误数据以随机噪声和目标的形式呈现,添加了冗余和高度相关的特征,在五维超立方的顶点处聚类观察结果而不提供信息,并最终插入无关信息。这使得许多数据科学家当时都努力去理解这个问题。如果您对合成数据集的生成过程感兴趣,请参阅mng.bz/ga4V以获取更多详细信息。

最近,Kaggle 竞赛如 Don’t Overfit(www.kaggle.com/competitions/overfitting)、Don’t Overfit II(www.kaggle.com/competitions/dont-overfit-ii)、Categorical Feature Encoding Challenge I and II(www.kaggle.com/competitions/cat-in-the-datmng.bz/jpyP),将问题扩展到类别特征和缺失数据,证明了当数据中存在太多问题时,即使是最强大的机器学习算法也几乎无能为力。

从这样的实际例子开始,一般来说,在表格数据中你必须最关心的条件是

  • 避免常数或准常数列

  • 避免重复或高度相关的列

  • 避免无关特征,并优先考虑具有高预测力的特征

  • 处理稀有类别或具有过多标签的类别

  • 查找不一致性、错位、翻转或扭曲的值

  • 避免列中有太多缺失值,并处理现有的缺失值

  • 排除泄漏特征

让我们从一个理论和实践的角度,使用一些人工数据来逐一分析这些内容。在章节结尾的段落中,我们将通过示例展示如何检测和解决此类条件的一些真实世界示例和命令。对于每一个都提供示例或找到一个包含所有这些不良数据示例的单一真实世界数据集(实际上,这样的例子在私人存储库中很丰富)是非常具有挑战性的。公共数据集通常已经足够精心制作,大多数这样的数据陷阱已经清除。

2.2.1 常数或准常数列

避免常数或准常数列。作为一个经验法则,对于数值特征,方差不应该接近零,对于分类特征,多数类不应该超过 99.9%。所有这些都是至关重要的,因为机器学习算法只能从您的目标相对于特征的实证条件期望的变化中学习。特征的任何变化都意味着没有条件变化,从而无法从中学习。常数特征会导致学习算法背后的数值过程处理起来更加繁琐,而准常数特征甚至可能导致一些过拟合,因为最小非常数部分可能与某些目标输出确定性相关联。解决方案是删除常数或准常数列。

列表 2.2 删除零方差特征

import pandas as pd
from sklearn.preprocessing import OrdinalEncoder
from sklearn.feature_selection import VarianceThreshold

data = pd.DataFrame({"feature_1":['A' for i in range(15)],
                     "feature_2":['B' if i%2==0 else 'C' 
                                  for i in range(15)],
                     "feature_3":[i**2 for i in range(15)]})
ord_enc = OrdinalEncoder()                                  ①
data[data.columns] = ord_enc.fit_transform(data)
var_threshold = VarianceThreshold(threshold=0)              ②

clean_data = var_threshold.fit_transform(data)
print(var_threshold.variances_)                             ③
print(clean_data.shape)

① 有序编码器会将您的数据从字符串标签转换为有序数值。

② VarianceThreshold 类将过滤掉所有方差等于或低于所选阈值的特征。

③ 您可以通过 .variances_ 属性来表示所有特征的方差,

2.2.2 重复和高度相关的特征

避免重复或高度相关的列是有帮助的,因为如果信息冗余使您的学习更加鲁棒,也有一些注意事项。最重要的是,它使得学习更加复杂和计算成本更高。首先,重复的特征作为常数是无用的,应该立即丢弃,因为它们只是浪费内存空间和计算时间。对于高度相关的特征,情况不同。无论我们是在谈论数值特征,其中您通过相关性来衡量共线性,还是分类特征,其中您通过基于卡方统计的关联度量来衡量共线性,当一组特征非常强地相关时,这是因为

  • 一个是另一个的因果特征。例如,在一个与学术成绩相关的数据集中,学习时间和考试成绩高度相关,因为学习时间是良好考试成绩的一个决定因素,正如许多研究已经确定的。

  • 它们都反映了导致或影响它们的潜在特征。例如,在一个关于汽车的数据库中,性能和排放部分由使用的燃料类型决定。即使燃料的特性没有记录在数据中,它们之间的关系也不明显,性能和排放特征将强烈相关。

在第一种情况下,解决方案很简单,因为你只需要保留因果特征并丢弃所有其他特征。数据本身不会告诉你哪个是因果特征,你基本上只能通过领域知识、理论推导或因果分析来找出它是哪一个,这需要额外的建模和实验。后一种情况稍微复杂一些,因为即使你能找出因果特征,它可能不在你的数据中,你必须决定保留哪个特征。再次强调,领域知识可以帮助你做出决定。数据分析也可以通过基于目标与特征之间的关联以及手头特征的 数据质量来提供提示,使用更少的错误、更少的缺失数据和更少的异常值。只保留与目标最相关的特征或质量最高的特征是最好的选择。

列表 2.3 寻找多重共线性数值特征

import numpy as np
from sklearn.datasets import make_classification
from statsmodels.stats.outliers_influence import variance_inflation_factor

np.random.seed(0)                                                   ①
X, _ = make_classification(n_redundant=0,
                           n_repeated=0,
                           random_state=0)                          ②
X = np.hstack([X, X[:,:5] + np.random.random((X.shape[0],5))])      ③

vif = [variance_inflation_factor(X, i) for i in range(X.shape[1])]  ④
print(np.round(vif,2))

for a in range(X.shape[1]):                                         ⑤
    for b in range(X.shape[1]):
        if a < b:
            r = np.corrcoef(X[:, a], X[:, b])[0][1]                 ⑥
            if np.abs(r) > 0.90:                                    ⑦
                print(f"feature {a} and {b} have r={r:0.3f}")

① 设置随机种子以确保可重复性

② 创建一个合成数据集:make_classification 命令将创建一个包含二十个略微相关的特征的样本数据集(见mng.bz/eynQ

③ 向数据集中添加更多相关特征:我们选择前五个特征,并在添加了一些噪声后,将它们复制到数据集的末尾,这样它们就与原始特征不同了。

④ 计算方差膨胀因子以识别贡献最少的特征

⑤ 通过迭代相关矩阵来检查相关特征,我们只计算矩阵下三角的相关系数。

⑥ 使用 NumPy 函数 np.corcoef 计算相关系数(见mng.bz/8Olw)。

⑦ 报告共线性的阈值按绝对值评估(如果特征被反转,相关系数将是负值)。此外,根据分析阶段的不同,你可以将其设置为 0.90、0.95 或甚至 0.99。

在前面的例子中,我们处理了一个合成数据集中高度相关的数值特征。我们采用了两种方法:方差膨胀因子和皮尔逊相关系数。方差膨胀因子(VIF)是线性模型准备分析的典型方法,线性模型是一系列将在第四章讨论的模型,其目的是量化一个特征的信息含量实际上在其它特征中可以找到的程度。因此,VIF 的范围比相关系数更广泛,相关系数是一种双变量分析,即一次分析两个变量。相反,VIF 试图分析一个特征相对于所有其他特征的作用。VIF 在突出可能贡献较小的特征方面非常强大。然而,它们不会揭示哪些特征与其它特征相关或哪些必须删除。VIF 的值越高,从 1 开始到无穷大,考虑的特征的多重共线性就越高。在我们的例子中,由于较高的 VIF 值表明特征贡献的独特性较低,我们可以迅速发现数据集的前五个和最后五个特征更有问题:

[14.98 13.64 11.85 13.12 15.75  1.2   1.41  1.19  1.46  1.31  1.38  1.3  1.19  1.24  1.63  1.28  1.45  1.23  1.15  1.16 18.86 16.79 15.39 16.45 17.82]

然而,你需要通过相关性分析逐一比较,以确定你可以缩小问题的特征。通过将阈值设置为 0.99,你将只能发现几乎完全相同的功能。通过将标准降低到 0.90 或 0.95,你将揭示那些独特贡献最小的特征:

feature 0 and 20 have r=0.966
feature 1 and 21 have r=0.963
feature 2 and 22 have r=0.947
feature 3 and 23 have r=0.958
feature 4 and 24 have r=0.964

到目前为止,你需要评估在两个高度相关的特征之间保留哪个特征。在任何情况下,即使你必须根据共线性来决定要删除哪些特征,基于质量选择特征始终是你考虑的,以便以后只处理有用的特征。一般来说,当面对无法纠正的多个错误,如记录或测量错误、大量缺失数据和异常值时,只有当特征显示出与目标有趣的独特关联时,才应保留该特征。

2.2.3 无关特征

对于与问题无关的特征,它们对你的问题意义不大,并且与目标几乎没有或没有关联。在这个阶段,你只需要排除那些根据领域知识或简单的单变量统计测试(如相关分析或卡方分析)可知不会对你的问题提供任何信息的可能性较大的特征,正如我们将在本章的后续部分所展示的。

在训练过程中选择特征已被广泛用于减少计算需求,有助于拥有更可解释的模型,并提高测试数据上的预测性能,因为无关特征会为机器学习算法(包括经典和神经网络)产生虚假信号和干扰。本书的第六章将深入讨论特征选择,并介绍适合选择初始特征子集的工具。

我们将特别关注基于测试特征重新排列或随机化(通过随机替换其值)行为的工具。想法是为你的问题构建一个模型,然后对每个特征进行随机化以检查结果是否变化太大,预测性能是否下降。那些随机化不会影响模型性能的特征,很可能是无关的或携带其他特征中可用的冗余信息。因此,它们可以安全地删除,从而提高内存处理和训练/预测时间。

2.2.4 缺失数据

缺失数据是一个必须考虑的关键因素,因为它可能影响任何数据源,并为许多学习算法带来麻烦。深度学习模型和许多机器学习算法不能直接处理缺失数据。然而,某些专门的机器学习算法,如 XGBoost 和 LightGBM,可以在不要求任何干预的情况下合理地管理缺失信息。这些算法在遇到缺失值时,会假设在类似情况下被认为更有用的值。有关更多信息,请参阅mng.bz/EaxO

除了这些专门的算法之外,缺失值通常通过一个称为插补的过程来解决。这涉及到使用同一列中存在的(简单单变量插补)或所有其他可用列中的信息来确定一个合理的替代值或类别。对于 XGBoost 和 LightGBM 算法,多变量插补有时甚至更有效。我们将在本书的第六章中更详细地讨论多变量插补。

请注意,如果在数据准备的早期阶段处理缺失数据,缺失性本身可能是一条信息。例如,如果你使用关系数据库查询,并且进行左连接或右连接(或全外连接)表,当案例不匹配时,你会产生缺失案例。在这种情况下,缺失案例意味着与某个数据库表中表达的条件不匹配,这可能是一条宝贵的信息。

在其他情况下,缺失的案例将相对于数据生成方式具有特定的含义,例如在人口普查数据中,如果回答者过于富有或贫穷,那么你将无法得到关于收入问题的答案,因为社会可接受性的必要性影响了回答。创建一个表示值是否缺失的二进制特征可以帮助跟踪这种模式。有关此类处理的更多详细信息,请参阅 Scikit-learn 包中的 MissingIndicator:mng.bz/EaxO

2.2.5 稀有类别

在分类变量的方面,处理一个特征中的太多标签或稀少类别是必须尽快解决的问题,可能在提取时解决。我们已经在本章中提到了高基数分类特征。相反,在分类变量的上下文中,当特定类别在数据集中出现频率很低或实例很少时,你会有稀少类别。

稀少类别在训练时增加了过拟合的可能性,通常可以通过聚合它们来处理,从而形成一个更大的类别。领域知识可能指导这样的聚合,建议将相似的稀少类别聚合到更大的类别中。

当问题相反,即存在太多的类别时,最合适的解决方案是目标编码,这对于梯度提升法通常非常有效,或者使用嵌入,这对于深度学习方法效果最好。我们将在第六章和第七章中讨论这些方法的工作原理以及如何有效地实现它们。

2.2.6 数据错误

发现不一致、放置不当、翻转或扭曲的值实际上是一个独立的话题,因为解决这个问题主要取决于你对应用领域和数据记录程序的了解。当在现实世界中观察到某种现象并将其记录在数据中时,可能会发生许多错误。错误可能从由于我们使用的仪器和传感器而产生的不可避免的测量误差,到可能使记录完全不可靠的广泛可能的错误、低估和过度估计的目录。除了这里和那里的单个错误之外,你应该寻找系统性的错误。这些错误几乎总是在某些情况下发生,并偏颇部分信息。了解数据模式和你要处理的数据的含义是解决此类问题的唯一合理的补救措施。这听起来可能非常普遍和模糊,因为实际上就是这样,但你只能尽力理解你的数据,不要对任何东西视而不见。不要相信机器学习算法是鲁棒的,可以修复数据中的所有错误:系统性的错误可能会限制你模型泛化的能力,并提供可靠的预测。

2.2.7 泄漏特征

最后,你应该处理的最后一个关注点是泄漏特征的存在。当你有一些不应该参与模型训练的预测信息暂时夸大结果时,就会发生泄漏,导致后续预测时间结果不佳。泄漏可能发生在行和特征级别。我们已经讨论了处理非-IID 行时的行泄漏,这发生在一些样本由于时间中介或某些其他特征的中介而与其他样本相关联时。现在,是时候讨论特征级别的泄漏了,这在从业务数据库提取数据时比预期的更常见。

关键原则是确保用于建模的特征在时间上的一致性。理想情况下,特征应该与目标变量在时间上保持一致或先于目标变量。在最佳情况下,不应在目标变量的时间点之后创建或生成任何特征。这种时间上的对齐有助于避免潜在的数据泄露,并确保模型是基于预测时可能可用的信息进行预测。你需要一个能够预测现在或未来的模型,但如果你训练模型所必需的特征是在预测本身之后的时间点产生的,那么你就无法做到这一点。

在训练过程中,很容易打破这个约束,因为你正在从过去获取一切,而你用于构建训练数据的信息源可能没有正确记录创建或修改时间。以一个使用机器学习算法提供贷款的商业为例,了解支付延迟可能会在训练时提供一个非常强大的预测特征,但推理这样一个特征在批准贷款或拒绝贷款时将不可用——因为它是一种接近目标变量定义时间点的未来行为——由于未来泄露,这使得它成为一个无用且具有误导性的特征。作为这个问题的解决方案,我们建议尽可能密切地验证特征和目标变量的生成时间,并检查目标是否在特征之后或反之亦然。通常,这种信息很容易获得,这取决于你公司中数据存储的组织方式。例如,你的数据可能具有特定的元数据,显示插入或更新发生的日期和时间,或者数据库管理员可能已经设置了一个特定的时间戳字段来快照某些变化发生的时刻。

在下一节中,在讨论表格数据的理想条件以及当这些条件不满足时可能发生的情况之后,我们将通过讨论如何在互联网和你的组织中找到表格数据,来更接近现实世界的数据。

2.3 寻找外部和内部数据

假设你需要为机器学习项目找到一些数据集。按照本节中的说明,如果你需要的数据集在互联网上可用且可访问,你一定能找到它。事实上,随着时间的推移,数据仓库的数量和质量都在增加,现在我们有了新的搜索和聚合工具,如 Google Dataset Search 和 Kaggle Datasets,这些工具允许我们具体说明我们正在寻找的内容,并获得一个可供选择的列表。但让我们从最基本的地方开始。

找到所需数据总是在定义项目和其目的之后。无论您的目标是商业相关还是学术相关,只有当您明确地设定了目标后,您才能决定需要收集哪种类型的数据。这个过程不是线性的,因为您通常需要在目标、数据和可用资源之间多次迭代。尽管如此,数据总是在明确目的之后,在构建管道(从数据存储库到计算执行的序列数据处理步骤)或任何进一步的建模行动之前。必然的是,在提供数据和处理数据之间,存在数据理解阶段,这是我们章节的最后一个主题。

由于机器学习模型需要一个目标——即预测的内容,比如回归问题中的一个数字或分类问题中的一个类别或标签——您首先需要关注的是为您的问题找到一个或多个输出。然后,所有机器学习模型都需要一些其他信息——一组称为预测器的预测因子或特征——用于预测您的目标输出。这是一个将一些数据(预测器)映射到其他数据(输出)的过程。由于没有目标,您甚至无法开始项目,而在另一端,您可能从一个不完整的预测器集合开始,并在过程中增加它们,因此始终首先定位您的目标,然后再关注您的预测器。

为您的机器学习模型找到合适的数据,并在数据准备和数据管道阶段正确处理它,是数据科学家花费更多时间和精力的活动之一——实际上也是他们最常遇到瓶颈的地方。在这个时候,您需要投入精力来获取数据,这些数据存储在您已经了解其组织方式(数据架构)以及如何将其全部整合成一个可用于您项目的单一数据集的方式。

2.3.1 使用 pandas 访问数据存储

通常,你可以在同一个数据存储中找到目标和预测变量,如果不是在同一个表格或文件中。然而,有时你可能发现所有必要的数据都散布在各个地方,在它们被使用之前需要适当组装。你可能会在组织内部或外部找到这样的数据。在组织内部定位数据资源的一个常见场景是在 Excel 文件中找到它们,这些文件以数据仓库(DWH)中的规范化形式存储,或者在不管理的数据流中留下。至于 DWH,信息被组织到多个表格中,以最小化冗余并提高数据完整性、可扩展性、性能和维护的简便性。这种情况被称为规范化模式,其中信息已被分解成包含单一主题区域或实体的更小、更专注的表格。通常,在这种情况下,你可以找到三种类型的表格需要组装:

  • 事件表(事务表或事实表) 设计用于存储特定业务事件在特定时刻的记录。此类事件的例子可能包括在电子商务平台上下的订单、银行中的信用卡交易、医疗保健中的医生访问或互联网上的用户点击流。这些表格的形式可能因业务类型和跟踪的具体事件而异。

  • 项目表(也称为产品表)是 DWH 中的一种表格,提供有关特定业务产品或事件的详细信息。例如,项目表可能包含有关单个产品项目的描述、价格和库存水平等信息,这些产品项目是客户订单的一部分,或者在患者-医生访问期间出现的药物处方问题。项目表的目的在于提供更深入的数据,用于分析和决策。

  • 维度表 存储额外的描述性信息,以增加对其他表中数据的上下文,例如一个人的出生日期或地点,或一个产品的类别。在使用维度表时,必须谨慎,因为其信息可能随着时间的推移而改变或更新。数据更新可以通过两种方式发生:通过维护更改的历史记录,为每次修改创建一个新的记录并附上日期,或者简单地用新数据覆盖旧数据。在后一种情况下,你可能会使用那些可以充当模型噪声的数据,因为其行在时间上是不对齐的,或者它可能会泄露未来的信息,影响模型正确预测的能力。

类似地,当处理 Excel 文件时,你经常会遇到分散和非规范化的数据。在这种情况下,就像在 DWH 中一样,你面临着处理类似表和将它们合并成一个连贯结构的挑战。对于事件表、项目表和维度表之间的这种组装任务,市场上有很多不同的工具可用——其中一些甚至是零代码类型的,例如,你无需编写脚本,而是使用图标和点击操作来操作。从历史上看,SQL 一直是程序员收集和组合多个数据源的首选工具。然而,当所有数据都驻留在关系型数据库中时,SQL 仍然是最佳选择。它可以帮助你构建一系列查询和临时表,直到你到达一个最终的数据表以下载或直接输入到机器学习算法中。然而,当存在多个来源并且我们需要检查和可视化中间结果时,我们首先选择用于处理多种数据类型的 pandas 工具,再次是我们建议掌握的工具。Pandas 有很多功能模仿并扩展了 SQL 查询的功能。选择、过滤、聚合、排序和处理在 pandas 上与在关系型数据库上一样简单,尽管有时基于不同的术语。例如,DataFrames 中的索引可以与关系型数据库中的主键相提并论。此外,pandas DataFrames 还呈现了使它们比 SQL 在数据科学任务上更灵活和强大的进一步特征:

  • 一个与其他工具通用的 API 使得从专为单核 CPU 使用而创建的 pandas 切换到多进程或分布式计算变得容易。

  • 你可以同时实现处理和数据分析。特别是,绘制你准备好的数据非常简单,正如我们在本章末尾将看到的。

  • 你对所有的更改和应该在数据上发生的必要操作步骤有更多的控制,这也意味着更容易出错,允许你存储中间结果并保留一些数据特征,如数据类型和数据排序,这在 SQL 中有时很难实现。

让我们想象在一个数据仓库中有三张表,每张表都包含关于公司产品不同信息。这些表需要被连接起来以创建分析或训练机器学习模型,如图 2.2 所示。

这是一个相当简单的示例,需要 SQL 查询来返回一个结合这三个表的组合数据集:

SELECT df1.product_id, df1.product_name, df1.price, 
df2.product_description, df2.category, df3.manufacturer, df3.weight
FROM df1
JOIN df2 ON df1.product_id = df2.product_id
JOIN df3 ON df1.product_id = df3.product_id;

图片

图 2.2 描述产品特征的三个简单表

然而,使用 pandas 的 merge 函数同样可以轻松实现这一点,同时保持对数据合并各个阶段的控制,并使其准备好进行进一步的转换以适应机器学习。

列表 2.4 在 pandas 中合并数据集

import pandas as pd
df1 = pd.DataFrame({'product_id': [1, 2, 3, 4],
                    'product_name': ['Product A', 
                                     'Product B', 
                                     'Product C', 
                                     'Product D'],
                    'price': [10.99, 20.99, 15.99, 8.99]})           ①
df2 = pd.DataFrame({'product_id': [1, 2, 3, 4],
                 'product_description': ['A great product', 
                                         'A high-quality product', 
                                         'A reliable product', 
                                         'An affordable product'],
                 'category': ['Category A', 'Category B', 
                              'Category C', 'Category D']})          ②
df3 = pd.DataFrame({'product_id': [1, 2, 3, 4],
                    'manufacturer': ['Manufacturer A', 'Manufacturer B', 
                                     'Manufacturer C', 'Manufacturer D'],
                    'weight': [1.5, 2.0, 1.8, 1.2]})                 ③
merged_df = pd.merge(df1, df2, on='product_id')                      ④
merged_df = pd.merge(merged_df, df3, on='product_id')                ⑤
print(merged_df)

① 包含价格的第一个表格

② 包含描述的第二个表格

③ 包含制造商和特性的第三个表格

④ 合并前两个表格

⑤ 将前两个已连接的表格与第三个表格合并

在 pandas 中进行此类操作的局限性之一是计算效率,因为该包运行较慢。例如,请参阅以下 Stack Overflow 答案:mng.bz/N1x1。由于 pandas 无法处理比计算机可用内存更大的数据,因此也存在可扩展性问题。这些限制存在是因为该包在设计时考虑了功能,而不是性能。因此,pandas 上大多数功能都是用纯 Python 编写的,并且它们很少访问优化编译例程——主要是 Fortran 和 C++ 编写的例程,例如 NumPy,另一个流行的矩阵和数组操作包。然而,由于其流行的 API,您可以使用 pandas 学习并开始您的项目,然后扩展到更强大的工具。正如前一点所述,不同的产品与 pandas 的兼容性或多或少:

  • Dask (www.dask.org/) 是一个开源的 Python 库,为用户提供低级和高级接口。它设计用于在计算机集群上运行。Dask 还可以在多个 CPU 处理器上以多进程模式工作,并且可以轻松处理离核任务。在这些任务中,您通过在磁盘上工作的数据块处理比 RAM 可以处理更多的数据。这种数据结构复制了 pandas DataFrame API,但能够处理更多的行。

  • Ray (www.ray.io/) 是一个低级框架,可以在处理器或集群之间并行化 Python 代码。它非常适合作为其他高端解决方案的后端,例如 Modin。

  • Modin (github.com/modin-project/modin) 可能是与 Pandas API 最兼容的工具。它只需简单的替换即可工作,例如 import modin.pandas as pd,并且以 Ray 作为后端时表现最佳。

  • Vaex (vaex.io/) 是一个用于懒加载离核 DataFrame 的 Python 库(类似于 pandas)。您还可以在您的独立机器或服务器上可视化和探索大型表格数据集。由于其懒加载特性,它可以优化其操作,达到每秒处理高达十亿个对象/行的性能。

  • RAPIDS (rapids.ai/) 是一组用于在大型矩阵上使用 GPU 计算能力的库。它提供了 cuDF,这是 pandas 的部分替代品。数据处理可以提高效率(计算时间),而不是可扩展性,因为 GPU 必须访问您的内存以确定要计算的内容。

  • Spark (spark.apache.org/) 是一种用于 map-reduce 的解决方案,这是一种大数据处理技术,适用于图算法、流数据以及单节点机器或集群上的 SQL 查询。它提供了各种包和一个类似于 pandas 的 DataFrame 数据结构。它是处理大量表格数据最合适的解决方案。

  • Polars (www.pola.rs/) 被设计为一个高性能的 DataFrame 库。它是用 Rust 编写的,这使得执行速度更快,与 C/C++ 相当,并且可以进行分布式计算。Polars 还具有更好的内存管理和处理大数据集的能力,并使用列式存储格式。列式存储格式在存储和访问密集型数据时更为高效,这是表格数据最常见的类型。相比之下,基于行的存储格式在存储和访问主要是稀疏数据时更为高效(pandas 使用基于行的存储格式)。Polars 和 pandas 具有相似的 API。然而,它们之间也存在差异,因为 Polars 可以在即时执行模式(命令立即执行)或懒执行模式(命令在特定命令下执行)下运行。尽管仍在开发中,Polars 正在迅速获得数据科学社区的青睐。

根据您对 pandas 的使用以及您表格数据的维度,您可能会发现这些项目中的每一个都很有趣。作为一个一般性的建议,我们建议您检查之前提到的每个产品是否都提供了您需要的 pandas 函数,这样您就不必重构您的代码,然后评估最适合您数据规模的解决方案。

2.3.2 互联网数据

现在我们已经为您从各种数据存储源(如关系数据库、DWH 和数据湖)获取和组装数据制定了计划,我们需要向您提供指导,告诉您在哪里可以找到互联网上的更多信息来源。这些来源将帮助您应用本书中概述的深度学习和机器学习算法,并允许您在不同的数据集上测试这些算法,甚至基准测试您的模型。换句话说,我们将帮助您找到可以用来增加和增强您已经获取的数据的在线资源。

关于寻找数据来源的地方,有一些专门网站收集并定期由研究人员和实践者使用表格数据。最好的例子是 UCI 机器学习仓库(archive.ics.uci.edu/)。机器学习社区长期以来一直使用这个网站进行机器学习算法的教育和研究。我们还可以引用 OpenML(www.openml.org/),Scikit-learn 使用的仓库,作为其示例的来源。它由一个非营利组织 Open Machine Learning Foundation 支持,其使命是使机器学习变得简单、易于访问、协作和开放。它还由像亚马逊这样的私营公司和大学赞助。在 UCI 和 OpenML 的两种情况下,您可以根据提供的说明或直接访问数据的 URL 下载每个数据集。当我们使用这些来源的数据集时,我们将提供 Python 代码片段,以便您可以直接下载并工作。

除了这些公开的仓库之外,许多其他网站提供了更短的开源数据选择——这些数据免费提供给学术、科学甚至商业使用(但您必须检查它们附带的使用许可)。政府、科学界有时甚至私营公司会将此类数据授予公众。通过咨询哈佛大学数据仓库免费数据仓库dataverse.harvard.edu/或浏览 Dataset 子版块www.reddit.com/r/datasets/,您可能会幸运地找到一些有趣的数据集用于您的工作。在两种情况下,这都是偶然发现有趣事物的好方法,但如果您寻求特定的数据并需要帮助找到它,可能会感到失望。假设您的搜索围绕与公众或宏观经济领域相关的数据,例如交通、能源、政治参与、商业、工业生产、消费等,在这种情况下,开源数据门户应提供有关查找开源数据集的提示。两个最好的门户网站是数据门户(dataportals.org/),它覆盖全球,以及开放数据监控器(opendatamonitor.eu/),它专注于欧洲地区。另一个好来源是国家统计局。您可以在美国人口普查局提供的这个综合列表中浏览特定国家(mng.bz/eynQ)。

除了这些具体的例子,无疑通过 Google 提供的数据集搜索引擎接触到你搜索的开放数据类型是最佳方式,该搜索引擎可以在datasetsearch.research.google.com/找到。Google 数据集搜索,包含许多可能的来源和分散的存储库,应该可以通过其搜索结果为你提供所需的内容。

图 2.3 显示了 Google 数据集搜索的结果,我们请求了与信用评分数据相关的数据集。

图片

图 2.3 Google 数据集搜索返回的信用评分数据。搜索输入框(页面顶部):在这里输入你的搜索字符串,就像在搜索引擎中做的那样。结果显示面板(左侧面板):在这里你可以浏览搜索引擎找到的结果。结果显示(页面中心右侧):在这里显示数据集的关键信息。你将提供链接以访问数据。

当你的系统中没有可用的相似数据,或者想要尝试一些想法或机器学习模型时,寻找外部数据来启动一个项目并不罕见。例如,假设你需要为你的项目寻找外部的信用评分数据,以便从头开始基于外部数据构建模型。信用评分是对个人或企业信用价值的计算量化。构建信用评分模型可以帮助你成功运营任何业务,例如抵押贷款、汽车贷款、信用卡和私人贷款,在这些业务中,你必须提供、扩展或拒绝信用。信用评分可以用于基于风险的定价——也就是说,在考虑到不偿还的风险的情况下,为信用设定一个公平的价格。

例如,像在 Google 搜索引擎中搜索信用评分数据一样,将在浏览器左侧打开一个面板,列出一系列可能满足你要求的数据集。你可以通过数据的最新程度、格式、许可(如果你想要商业用途,这是一个需要解决的问题)、成本和感兴趣的分类来过滤结果。在右侧面板中,将出现所选数据集关键特征的描述,以及一个链接,允许你访问存储和记录数据的原始存储库。要下载数据,请遵循你将被引导到的每个着陆页上的说明。

据我们所知,Google Dataset Search 是寻找您所需或正在寻找的数据的最佳工具。此外,我们还想指出谷歌通过 Kaggle 提供的一项其他资源,Kaggle 是一家专注于数据科学竞赛的公司,于 2017 年被谷歌收购。您肯定会注意到,搜索引擎经常使用 Google Dataset Search 返回托管在 Kaggle 平台上的数据集。除了提供竞赛平台外,Kaggle 还提供了一种名为 Kaggle 数据集的数据集托管服务,位于www.kaggle.com/datasets

Kaggle 数据集是提供给平台用户的一项服务的一部分,允许他们免费下载数据(有时因为它们是开源的),以换取 Kaggle 游戏化系统中的积分和排名。游戏化公式也推动数据尽快上传。因此,您会发现最新的数据,并且可以期待它们经常更新。结果是令人印象深刻的数据集集合,这些数据集在其他地方并不容易找到,从经济统计数据到有助于从头开始构建推荐系统的交易集合。

如果您能找到您想要的东西,您可以在注册网站后,使用图 2.4 中显示的下载按钮直接从 Kaggle 数据集下载数据。注册还允许您安装和使用kaggle-api命令(github.com/Kaggle/kaggle-api)从 shell 中下载数据集。例如,安装后,您可以通过以下命令下载德国信用风险数据:

kaggle datasets download -d uciml/german-credit

图片

图 2.4 Kaggle 数据集页面上的德国信用风险选项(右侧菜单)。您可以通过从上到下的菜单使用 Kaggle API 命令轻松下载整个数据集。您可以通过在页面关联的菜单中直接要求它来找到该命令,如图所示。

2.3.3 合成数据

在您已经成功获取了一些数据集但需要增加可用的表格数据的情况下,我们必须提到合成数据生成,这不会从头创建任何数据,但可以有效地增加和改进您手头可用的现有数据集,使得运行需要更多示例才能正常工作的机器学习和深度学习模型成为可能。合成数据生成是一个生成式 AI 应用,并且是一个不断发展的领域,因为它可以

  • 通过增加可用的表格示例数量来克服数据稀缺问题

  • 通过增强数据的一部分来提供数据多样性,例如在不平衡分类问题中的少数类情况

  • 生成边缘情况,以便它可以为你提供你不太可能遇到的数据示例,你可以使用这些示例来测试你设计的系统

  • 保护隐私,因为它有助于生成具有与原始数据相同特征但无隐私问题的表格数据,因为所有表示的数据都是虚构的

近年来,数据生成取得了巨大进步,这得益于与生成对抗网络(GANs)和变分自编码器(VAEs)的第一批生成性 AI 实验,这两种深度学习架构几年前是生成性 AI 的先锋。

GANs 由 Ian Goodfellow 及其同事在 2014 年提出。它们由一对深度神经网络组成——即生成器和判别器——同时训练并相互交互,通过挑战判别器猜测生成器对原始示例的生成工作。尽管这是一个无监督过程。给定足够的时间和计算,生成器和判别器之间的持续比较应该使生成器模仿判别器用于比较的原始示例的真实世界分布。如果你考虑到生成器从未看到任何它应该相似的数据实例,因为它从纯随机噪声中构建其作品,这个过程确实既聪明又神奇。这个过程对于图像和表格数据都工作得非常好。

VAEs 由编码器和解码器组成。它们的功能是使用编码器将输入数据压缩到一个称为潜在空间的状态,其中数据信息高度浓缩。随后,解码器以高保真度解压缩数据并重建原始数据。其基本思想是,如果压缩-解压缩过程有效,那么潜在空间封装了数据的核心分布信息,从而能够生成新的数据。VAEs 的架构设计使得输入被编码器接收并通过一系列层传递,这些层可能包含相同数量的神经元或递减的数量,直到达到代表潜在空间的层。这一层作为解码器的起点。

这些深度学习方法如何生成优质数据,可以通过像 Kaggle 这样的数据科学竞赛平台如何使用合成数据来证明其竞赛。由于缺乏表格数据竞赛,Kaggle 最近推出了一系列基于合成数据的竞赛,这些竞赛与传统基于精选原始数据的竞赛相比,没有任何可羡慕之处。表格游乐场系列是 Kaggle 过去两年的一个特色,至今仍在继续(www.kaggle.com/competitions)。

在列表 2.5 中,我们使用 sdv 包从原始的德国信用风险数据集生成 10,000 个额外的示例,该数据集由我们在上一节中展示的 1,000 个样本组成。要在您的系统上安装 sdv 包,请使用以下 shell 命令:

pip install sdv

sdv 包是由麻省理工学院发起的一个开源工具集,旨在帮助个人和企业从某些原始数据生成合成数据。这些工具使用经典统计方法,如高斯 Copulas,可以同时复制多个变量的分布,以及深度学习。深度学习工具基于 GAN 和 VAE。在我们的例子中,我们发现 VAE 架构可以更好地、更简单地模拟手头的数据。您可以在项目的 GitHub 页面(github.com/sdv-dev/SDV)上找到有关 sdv 包的更多信息,或者通过阅读说明该方法的参考论文:Neha Patki,Roy Wedge,Kalyan Veeramachaneni,“合成数据宝库”,IEEE DSAA 2016 (mng.bz/ga4V)。

列表 2.5 生成合成数据集

import pandas as pd
from sdv.metadata import SingleTableMetadata                       ①
from sdv.single_table import TVAESynthesizer                       ②

data = pd.read_csv("./german_credit_data.csv", index_col=0)        ③
data = data.reset_index()
print(data.shape)
print(data.head)

metadata = SingleTableMetadata()                                   ④
metadata.detect_from_dataframe(data)                               ⑤
print(metadata.to_dict())                                          ⑥
metadata.update_column(column_name="Saving accounts", 
                       sdtype="categorical")                       ⑦
synthesizer = TVAESynthesizer(metadata, epochs=10_000)             ⑧
synthesizer.fit(data)                                              ⑨
synthetic_data = synthesizer.sample(num_rows=10_000)               ⑩
synthetic_data.head()

① 从 sdv 导入生成元数据的函数

② 从 sdv 导入 VAE 以生成合成数据

③ 加载德国信用风险数据集

④ 实例化元数据检测器

⑤ 从原始数据中检测元数据

⑥ 检查每列的元数据是否已正确检测

⑦ 修正“储蓄账户”列的元数据

⑧ 实例化 VAE 模型并指示其训练 10,000 个周期

⑨ 使用原始数据拟合 VAE 模型

⑩ 生成包含 10,000 个示例的新合成数据集

脚本需要一段时间才能完成,因为 VAE 需要一定次数的原始数据迭代来发展出对原始数据特征和分布的良好潜在表示。一旦完成,从新合成数据中抽取的示例与原始数据的样本相似,但如果您需要更正式的证明,您必须求助于对抗验证评估,其中机器学习算法被挑战区分原始数据和生成数据。机器学习算法更擅长检测数据中的微妙模式;因此,如果它在区分原始和生成数据时被混淆,则假定生成数据质量良好。在下面的列表中,我们展示了如何为从德国信用风险数据派生的合成数据设置对抗验证。

列表 2.6 使用对抗验证测试合成数据集

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import cross_val_predict
from sklearn.metrics import roc_auc_score

X = pd.concat([synthetic_data, data])                               ①
categorical_columns = [
    "Sex", "Housing", "Saving accounts",
    "Checking account", "Purpose"]
X_encoded = pd.get_dummies(X, columns=categorical_columns)          ②
X= X.drop("index", axis="columns") 
y = [0] * len(synthetic_data) + [1] * len(data)

model = RandomForestClassifier()
cv_preds = cross_val_predict(
    model,                                                          ③
    X_encoded,                                                      ③
    y,                                                              ③
    cv=5,                                                           ③
    n_jobs=-1,                                                      ③
    method="predict_proba"                                          ③
)                                                                   ③
roc_adv_score = roc_auc_score(y_true=y, y_score=cv_preds[:, 1])     ④
print(f"roc auc adv score: {roc_adv_score:0.3f}")

① 将原始数据和合成数据合并在一起

② 将分类特征编码为虚拟变量

③ 判断示例是原始的还是合成的

④ 计算分类的 ROC AUC 分数

接收器操作特征(ROC AUC)分数下的面积,也称为曲线下的面积(AUC),是用于评估机器学习算法在二元分类问题中性能的指标。它衡量算法根据分配给每个观察值的预测概率区分正例和负例的能力。得分为 1 表示完美的分类器,而得分为 0.5 则表明模型的表现不如随机猜测。你可以通过查阅mlu-explain.github.io/roc-auc/(机器学习大学的一部分,是亚马逊的一项教育倡议,旨在教授机器学习理论)来了解更多关于这个指标的信息。

在这种情况下,ROC AUC 分数大约为 0.567,这表明合成数据几乎与原始数据无法区分。

在下一节中,我们将通过探索从 UCI 机器学习仓库和 Kaggle 数据集检索到的表格数据集,来完成我们对表格数据的概述。在我们分析数据的过程中,许多在前几段中提到的 问题将会被发现,并且一些补救措施将会被实施。

2.4 探索性数据分析

将数据集组装成按行和列排列的数据矩阵只是更长过程的开端。然后,数据将被检查、探索、转换,并最终输入到模型中。研究和探索数据是这个过程的关键阶段,因为它们能让你了解在组装阶段可能遗漏了什么,以及针对你特定的预测问题可以做什么。探索性数据分析(EDA)通常与特征工程阶段联系更为紧密——在表格数据中使用经典机器学习方法时更是如此。通常,当使用深度学习时,EDA 几乎完全被忽视。我们想强调的是,EDA 不应该仅限于帮助创建特征。它是一个整体探索过程,旨在发现数据如何被最佳地使用。

EDA 确实带有统计学的味道,因为它是 20 世纪最杰出的统计学家之一,约翰·W·图基在 1977 年的杰作《探索性数据分析》中提出的。在他的工作中,图基声称统计工作并不仅仅来自基于理论假设的建模和假设检验。根据图基的说法,通过 EDA 的数据也可以告诉您在科学和工程问题中可能发生的事情,并提示您整理数据的最佳方式。没有预定义的 EDA 蓝图。然而,智能使用统计描述和测试以及特征及其相互关系的图形表示可以告诉您如何对数据进行操作。一般来说,首先,使用描述性统计和图表,您检查每个单独的特征,这在统计学中被称为单变量方法。然后,您检查特征之间的关系,这被称为双变量方法。最后,您尝试使用多元技术和降维技术,如 t-SNE 和 UMAP,来一瞥所有特征。

在这个阶段,在将您的数据组装成表格矩阵之后,EDA 可以执行以下操作:

  • 通知您有关数据特征的信息,例如数值、有序、高/低类别和日期特征。

  • 给您每个特征的值以及它们如何分布的印象。这在与神经网络一起工作时特别有用,因为值尺度很重要。

  • 告诉您是否存在缺失值,因为神经网络和一些机器学习算法被认为在存在缺失数据的情况下会失败。

  • 定位由于提取和组装中的错误和错误导致的异常值和值簇。

  • 查找可以消除或合并的罕见类别。

我们将向您展示如何使用简单的 pandas 命令来实现这种探索,因为我们认为由分析师的推理完全指导的 EDA 流程比自动化的流程更有效。然而,如果您需要探索大量特征并节省时间,您也可以使用自动数据探索工具。稍后,您可以集成自动 EDA 与特定和专注的定制数据探索。在自动 EDA 的开源软件包中,有一些值得推荐的,我们希望您尝试,它们都是易于学习和使用的有效解决方案:

在以下章节中,我们将重新审视 EDA,以进一步探索您可能需要的全景,这取决于您想要应用于数据以进行预测目的的技术(机器学习或深度学习)。

2.4.1  加载 Auto MPG 示例数据集

现在,作为一个简单的 EDA 如何提示进一步处理您的数据以及适应您想要使用的模型时采取必要的补救步骤的例子,我们可以选择 Auto MPG 数据集,这是一个在 UCI 机器学习存储库上免费可用的数据集(archive.ics.uci.edu/ml/datasets/auto+mpg)。该数据集由 Ernesto Ramos 和 David Donoho 汇编,源自卡内基梅隆大学维护的 StatLib 库。之前,它曾在 1983 年美国统计协会展览(mng.bz/lYa8)和 Ross Quinlan 的作品中展出。Ross Quinlan 是决策树算法发展的主要贡献者,也是 C4.5 ID3 算法的创造者。他在 1993 年的论文“结合实例学习和基于模型学习”(mng.bz/BXx8)中引用了此数据集,这是一篇关于机器学习中回归问题的真正里程碑论文。对于我们来说,由于它具有混合的特征集和一些需要处理在每加仑油耗和马力特征中的缺失数据,它是一个简单、易于管理的例子。

以下是在数据集中可用的功能列表:

  • 每加仑油耗:连续

  • 气缸数:多值离散

  • 排量:连续

  • 马力:连续

  • 重量:连续

  • 加速:连续

  • 模型年份:多值离散

  • 原产地:多值离散

  • 车辆名称:字符串(每个示例都是唯一的,可以用作 pandas DataFrame 中的索引)

在我们的示例中,列表很短,但在大多数复杂的表格数据集中,可用的功能可能长达数十页。

要上传我们的示例数据集,我们可以参考以下列表中的代码,该代码将连接到 UCI 存储库,并将数据导入 pandas DataFrame。

列表 2.7 从 UCI 存储库下载 Auto MPG 数据集

from io import StringIO                                             ①
import requests                                                     ②
import pandas as pd

url = "https://archive.ics.uci.edu/ml/machine-learning-databases/auto-mpg/"
data = "auto-mpg.data-original"
columns = ["mpg", "cylinders", "displacement", "horsepower", "weight", 
           "acceleration", "model_year", "origin", "car_name"]      ③

colspecs = [(0, 4), (6, 9), (12, 17), (23, 28), (34, 39), 
            (45, 49), (52, 55), (57, 59), (61, -2)]                 ④

data_ingestion = StringIO
(requests.get(url + data).text)                                     ⑤

data = pd.read_fwf(data_ingestion, 
colspecs=colspecs, 
names=columns)                                                      ⑥

① StringIO 读取和写入内存中的字符串缓冲区。

② Requests 是一个 HTTP 库,可以帮助您从网络中恢复数据。

③ 该文档来源于 UCI 机器学习存储库的文档

④ 固定宽度数据需要为每个特征提供其在输入中的起始和结束位置。

⑤ 数据集通过 requests.get()从网络读取,然后转换为字符串缓冲区。

⑥ pd.read_fwf 将固定宽度的格式化行表读入 DataFrame。

Auto MPG 数据集存储在一个固定宽度的文本文件中,其中值位于同一行,由一定数量的空格或制表符分隔。行尾的回车符表示示例的结束。以下是从 UCI 机器学习存储库中请求库为我们重试的文本文件的样本:

18.0  8\.  307.0  130.0  3504\.  12.0  70\.  1\.  "chevrolet chevelle malibu"
15.0  8\.  350.0  165.0  3693\.  11.5  70\.  1\.  "buick skylark 320"
18.0  8\.  318.0  150.0  3436\.  11.0  70\.  1\.  "plymouth satellite"
…
31.0  4\.  119.0  82.00  2720\.  19.4  82\.  1\.  "chevy s-10"

这解释了为什么我们必须使用固定宽度的读取器,如 pandas read_fwf,并指定我们期望每个值开始和结束的字符位置。更常见的是,你可以找到其他以 CSV 文件组织的数据集。如果相同的数据集以 CSV 格式存储,它将看起来像以下这样:

"mpg","cylinders","displacement","horsepower","weight","acceleration",
"model_year","origin","car_name"
18.0,8.0,307.0,130.0,3504.0,12.0,70.0,1.,"chevrolet chevelle malibu"
15.0,8.0,350.0,165.0,3693.0,11.5,70.0,1.,"buick skylark 320"
18.0,8.0,318.0,150.0,3436.0,11.0,70.0,1.,"plymouth satellite"
…
31.0,4.0,119.0,82.0,2720.0,19.4,82.0,1.,"chevy s-10"

在 CSV 文件中,特征的值不是放在行的固定位置;它们的位置变化,但顺序不变,你之所以能找到它们,是因为有一个称为分隔符的特殊字符。在我们的例子中,它是逗号。请注意,文本数据,如字符串或日期,通常用引号分隔,以避免文本包含分隔符时的混淆。

在这种情况下,你必须使用 pandas 的 read_csv 读取器,它可以处理 CSV 文件。根据情况,你的数据可能存储为 JSON 文件或 XML 文件,但在 pandas 中也有针对这些格式的读取器(见表 2.2)。

表 2.2 pandas 中常见的数据读取方法

函数 描述
read_csv 从文件、URL 或类似文件的对象中加载分隔数据,默认分隔符为逗号
read_json 从 JSON 字符串表示形式中读取数据
read_xml 将浅 XML 文档导入为 DataFrame
read_html 读取给定 HTML 文档中找到的所有表格
read_excel 从 Excel XLS 或 XLSX 文件中读取表格数据

2.4.2  检查标签、值、分布

你需要做的就是理解你正在从互联网或本地磁盘读取什么类型的数据,并使用 pandas 提供的适当读取器:每个都会由适当的函数自动处理。

由于我们已经有数据集的数据手册(只需阅读 archive.ics.uci.edu/ml/datasets/Auto%2BMPG 上的“属性信息”),我们可以在特定变量中立即注意哪些特征是数值的、序数的和分类的:

numeric_feats = ["mpg", "displacement", "horsepower", "weight", "acceleration"]
ordinal_feats = ["cylinders", "model_year"]
categorical_feats = ["origin", "car_name"]

在其他数据集的不同情况下,你必须通过数据探索来发现此类信息。变量将由你的发现来填充。因此,如果我们不知道特征的特征,我们应该通过首先进行数据探索来发现它们,这可以通过一些手动 pandas 命令和一些推理快速完成。例如,我们可以请求前几行的样本,并立即了解我们正在处理的数据。我们使用 .head(n) 方法只获取前五行:

data.head(5)

结果显示在图 2.5 中。

图片

图 2.5 data.head(5) 命令的结果

在这个例子中,许多特征都是没有小数部分的浮点数,这表明它们可能是整数。整数也可能表示数值、序数或分类特征。在我们的例子中,要求每个特征的唯一值数量是第二步:

data.nunique()

返回的结果是

mpg             129
cylinders         5
displacement     84
horsepower       92
weight          356
acceleration     96
model_year       14
origin            4
car_name        312
dtype: int64

使用少数几个不同的数字,圆柱体和原点特征可以被当作分类变量处理。因此,它们可以被嵌入,这是一个将它们转换为连续数值特征的过程,或者通过独热编码将每个值转换为二进制。领域知识可以帮助我们将圆柱体分类为有序特征,因为不同车型中的圆柱体可能有不同的体积,而且通常来说,圆柱体数量越多,发动机功率也越大。同样,与年份相关的特征也可以被视为表示时间进程的有序特征。

另一个基于检查每个数值特征标准差的快速探索性检查,可以帮助你进一步了解你的数据,甚至选择它:

data[numeric_feats].std()

返回的结果是

mpg               7.815984
displacement    105.207362
horsepower       38.522063
weight          849.827166
acceleration      2.820984
dtype: float64

通过这样做,你可以了解数据中的常数或准常数特征。排除缺失值后,如果一个特征被设置为零或方差太低,可以安全地从数据中排除,因为它可能不会带来任何实质性的优势来进一步处理。然而,如果存在大量缺失值,你可能想创建一个指示变量来跟踪该特征的缺失模式,因为它们可能是预测性的。考虑使用命令检查缺失案例的数量:

(data.isna()
     .sum(axis=0)         
)                   

.isna() 方法将返回一个布尔值,告诉我们是否有任何特征样本缺失。我们可以通过计算一个特征中缺失样本的数量来统计缺失样本的数量。True 值等于 1,False 等于 0。得到的结果是

mpg             8
cylinders       0
displacement    0
horsepower      6
weight          0
acceleration    0
model_year      0
origin          0
car_name        0
dtype: int64

.isna 方法可以帮助你跟踪特征中的 NaN 值。正如讨论的那样,具有大量缺失数字的特征可能隐藏一些有趣的预测模式,并且可能被转换为缺失的二进制指示变量。你可以使用 Scikit-learn 中的 MissingIndicator 来实现这个目的:mng.bz/dXoO。此外,根据你的预测算法,你总是需要处理缺失值,无论是大量还是只有少数几个。如果你使用深度学习解决方案,你必须用数值来填充缺失值。通常,你使用平均值、中位数或众数作为缺失值的替代。然而,更复杂的方法也基于对最佳替代值的迭代估计。Scikit-learn 中的 IterativeImputer 就是这样一个例子:mng.bz/rKaD

记住,用零值填充缺失值是当你的数值特征标准化(移除均值并除以标准差)时,对于神经网络和广义线性模型的一种好策略:在这种情况下,零值对应于所有数值特征的均值。对于大多数机器学习算法来说也是如此,但对于最先进的梯度提升实现(如 XGBoost 和 LightGBM)来说,方法不同。这样的算法可以适当地处理缺失数据,而无需你进行任何进一步的操作。

有时在表格数据集中使用传统的数字(如-999)。如果你知道某个值表示缺失值,你必须修改你的命令来考虑这种信息:

(data[numeric_feats]==-999).sum(axis=0)

在之前的代码片段中,你将所有数值特征与值-999 进行比较,-999 是一个表示缺失数据的标记。基本上,任何数字都可以用作标记,你必须事先知道你的数据中使用了什么来设置缺失值。虽然是一个数字,但在训练模型时不会引起任何错误,但它会严重误导其学习。因此,缺失标记应该始终被视为 NaN 缺失值,以防止深度学习模型和某些机器学习算法的潜在误解。基于树的模型,如梯度提升或随机森林模型,更能处理这种情况,尤其是在标记位于特征数据分布的极端端时。

在检查了缺失数据和标准差之后,你现在开始检查特征的分布,以发现其他有用的信息,这些信息可以指导你对数据集的处理:

data.describe()

图 2.6 显示了 describe 方法的输出。

图 2.6 data.describe()命令的结果

pandas 的 describe 方法允许你表示所有数值特征的基礎描述性統計。请注意,缺失数据被忽略。在检查缺失值和方差或标准差后,你应该关注与平均值相关的最小值和最大值。过大或过小的值应该引起你对异常值(数据分布中的极端值)或过于倾斜的分布的注意。异常值和倾斜分布在数据分析中并不罕见,有时它们需要被移除。有时,它们应该被当作原始值,而不采取任何纠正措施,这取决于你分析的目的。在这个阶段,异常值和倾斜分布可能表明数据收集和组装中存在问题,纠正措施意味着移除或纠正它们。例如,一个异常值是由于数据错误造成的。此外,使用不同方法记录的堆叠数据可能会产生倾斜分布。例如,在一个表格中,你的测量值是以米为单位的,而在另一个表格中,它们被表示为厘米。

数据中所有此类错误的原因各不相同,建议你使用描述性统计和图表仔细检查你的数据,然后找出纠正错误的方法。此类错误不仅限于数值特征,但在分类特征中也很容易找到。现在,让我们通过分割其元素来检查可用的唯一字符串特征,即car_name

words = (data.car_name
             .apply(lambda x: x.split()) 
             .explode()
             .value_counts()  
        )
words.head(15)

apply函数中的 lambda 函数会将car_name实例分割成单个单词。由于结果将是一系列列表,使用.explore()方法,我们将所有列表展开成一个单一的特征。通过计算这个新特征中的单个值,我们得到每个单词的频率结果:

ford          53
chevrolet     44
plymouth      32
(sw)          32
amc           29
dodge         28
toyota        25
datsun        23
custom        18
buick         17
pontiac       16
volkswagen    16
honda         13
mercury       11
brougham      10
Name: car_name, dtype: int64

初看似乎没问题,因为大多数实例包含品牌名称和一些关于车型类型的信息。然而,在最常见的标签中,你注意到一些不一致的信息,例如"(sw)",它代表运动旅行车,而custom则指代汽车中的定制配件。在更仔细的检查中,你最终注意到,即使在经过筛选的数据集中,也存在问题,因为许多品牌被拼写错误:

(words.index
      .sort_values()
)[-50:]

返回的结果是

Index(['seville', 'sj', 'skyhawk', 'skylark', 'special', 'spirit', 'sport',
'sportabout', 'squire', 'sst', 'st.', 'stanza', 'starfire', 'starlet',
'strada', 'subaru', 'suburb', 'sunbird', 'super', 'supreme', 'sx', 'tc',
'tc3', 'tercel', 'thunderbird', 'torino', 'town', 'toyota', 'toyouta',
'tr7', 'triumph', 'turbo', 'type', 'v6', 'v8', 'valiant', 'vega',
'ventura', 'vista', 'vokswagen', 'volare', 'volkswagen', 'volvo', 'vw',
'wagon', 'woody', 'x1.9', 'xe', 'yorker', 'zephyr'], dtype='object')

在包含组成分类特征的单词的数据切片中,我们注意到一些品牌被拼写错误(例如,Toyota 和 Toyouta,Volkswagen 和 Vokswagen,但还有 VW 作为缩写)。拼写错误会将与真实类别(在这个例子中是 Volkswagen 和 Toyota)相关的示例分割成多个类别,使你的模型面临收集噪声证据或丢弃较弱信号的风险。同样,其他呈现错误额外类别的分类和有序特征也会发生这种情况:

(data.origin
     .value_counts()
)

数据来源的返回结果是

1\.    253
3\.     79
2\.     73
.       1
Name: origin, dtype: int64

让我们对model_year也进行相同的操作,根据年份对结果进行排序:

(data.model_year
     .value_counts()
     .reset_index()
     .rename(columns={'index':'model_year', 
                      'model_year':'counts'})
     .sort_values(by="model_year")
)

图 2.7 展示了model_year的有序值结果将如何显示。

图片

图 2.7 有序值计数

并非所有这些单独的数据问题都会对结果产生很大影响。然而,当你的数据集由数十个具有小问题的特征组成时,它们的共同存在可能会以明显的方式影响你的模型性能。补救措施很简单:只需移除具有错误类别标签的案例,除非你可以合理地纠正它们或将它们视为缺失案例并进行插补。由于它们需要对每个特征进行审查和推理,这些行动可能需要相当的时间和精力。尽管如此,你进行 EDA 正是为了这个目的,而且后来,一个在预测中更有信心且表现更好的模型将完全回报你的努力。与深度学习处理的无结构数据相比,表格数据对数据清洁度的要求更强,尤其是在应用经典机器学习模型时,通常期望的比无结构数据要高,在深度学习中,错误有时被认为是有用的噪声,有助于避免过拟合。

在检查了分类特征之后,现在应该检查数值特征,以寻找更多数据错误。在这里,箱线图和直方图取代了类别计数,关注的焦点也从单个特征(所谓的统计单变量方法)扩展到更多特征。一个示例的特征中的每个值本身可能并不错误。然而,如果将这些值综合考虑,你可能会意识到它们的组合非常不可能,如果不是某些错误的结果。你开始通过箱线图检查分布。箱线图,也称为箱线和须线图,使用箱子的边界绘制分布的关键特征:你可以看到第一四分位数(Q1)和第三四分位数(Q3)作为箱子的顶部和底部边缘(中位数,Q2,表示在箱子内部)。它的须线代表两个方向上最远的点,不超过 Q3 + 1.5 × IQR 和 Q1 – 1.5 × IQR(作为一个提醒,IQR 是 Q3 和 Q1 之间的差值)。超出这些边界的异常观测值被绘制为单独的点,你可以立即发现是否存在任何异常以及有多少个。由于箱线图缩放到你表示的特征的单位度量,如果你要比较多个箱线图以比较数值特征的分布,你需要首先通过减去平均值并除以标准差来标准化它们:

standardized = ((data[numeric_feats] - data[numeric_feats].mean()) 
                / data[numeric_feats].std())
standardized.boxplot(column=numeric_feats, figsize= (12, 4))

图 2.8 展示了图表输出。

图片

图 2.8 Auto MPG 数据集的数值特征的箱线图

目的是确定数据是否因为你的收集或聚合过程出现错误。具有异常值(如 mpg、马力和加速度)的特征需要通过直方图进行更仔细的检查,以排除存在无关或错误值的情况:

data.horsepower.round().hist(bins=64)

图 2.9 显示了结果的 64 个桶直方图。

图片

图 2.9:马力的特征直方图

对于马力特征,似乎没有其他相关内容,除了在约 150 值附近的奇特峰值:由于财政原因导致的一些示例的集中。实际上,由于拥有超过一定马力阈值的汽车需要支付更多的税款,许多汽车的发动机设计者只是保持在那个阈值以下,以便使他们的汽车更具市场竞争力。

对于加速度特征,一切正常。只是两侧都有长尾:

data.acceleration.hist(bins=24)

图 2.10 显示了结果的 24 个桶直方图。

图片

图 2.10:加速度特征的直方图

直方图,作为值桶频率的条形图,适用于数值变量。对于有序和分类变量,简单的值计数条形图可以获得相同的信息:

(data.cylinders
     .value_counts()
     .reset_index()
     .rename(columns={'index':'counts'})
     .sort_values(by="counts")
     .plot.bar(x="counts")
)

图 2.11 显示了结果的条形图。

图片

图 2.11:气缸特征的条形图

在这里,你应该寻找较少见的类别,因为它们可能存在错误或异常的罕见观测值,你必须决定是否保留。领域知识应该帮助你做出这些决定。在我们的气缸例子中,我们实际上应该保留三缸和五缸类别,即使它们很少见,因为快速检查可以揭示我们有三缸汽车的数量很少——安装这种类型发动机的汽车较小,并且往往比更常见的四缸车辆市场份额要小得多。此外,汽车设计师对五缸布局的使用不足,类似于其他奇数布局,因为它具有与六缸布局相似的成本,但在许多工程方面却意味着更多的复杂性,并且在性能方面存在更多的不足。

2.4.3 探索双变量和多变量关系

通过使用直方图和条形图来检查值的分布情况,将为你提供捕捉可能影响你工作的错误信息。即使每个特征的错误可能只有最小的影响,但你将使用到的所有特征上的错误总和可能会对你的预测算法产生显著影响。一旦你完成了对单个特征的错误识别,就需要检查它们之间的关系。特征如何与你的目标相关联,以及你如何排除其中的一部分,而不会修改或甚至改善你的结果,这是我们将在讨论特征选择时讨论的话题。目前你的首要任务是避免在数据集中出现冗余特征。对于冗余特征,我们指的是重复的特征或极其相似的特征:

  • 不同名称的重复特征。

  • 高度相关的数值特征。

  • 类似的分类特征,表面上由于级别聚合或使用的标签不同而显得不同。

  • 相似的数值和分类特征,它们都来自同一来源。例如,在金融领域,当你有一个违约概率和相应的违约评级时,这些通常用 AAA 或 BB 等字母标签表示。

我们开始探讨如何发现数值特征中的重复和高共线性。我们用于这项调查的有利工具是二元相关性:特征之间的相关性。当数值特征的二元相关性以对称矩阵的形式排列时,我们就有了相关性矩阵,如果特征不多,可以立即将其可视化为一个图表,用于发现共线性:

import seaborn as sns
corr = data[numeric_feats].corr()
sns.heatmap(corr, cmap="Blues",annot=True)

结果图如图 2.12 所示。

图片

图 2.12 相关性热图

在我们的例子中,我们只涉及少数几个特征,矩阵的可读性很高。当特征数量较多时,最好只列出超过一定阈值的二元相关性。在两种情况下,都必须注意超过 0.98 到 0.99 绝对值的关联性。此外,相关性可能是负的,接近负一的负相关性是另一种共线性的情况。当你有特征之间如此高的相关性时,你必须试图理解原因,然后决定保留哪一个。删除一些共线性的特征将减少你的数据集,并避免使用所用的特征选择方法或学习算法时可能出现的后续问题。实际上,在共线性或多重共线性中,如果涉及两个以上的特征,你可能会在数据集的收敛、导致次优结果以及解决方案的可解释性方面遇到问题。

共线性,尽管与相关性相关,但也影响分类特征,有时甚至需要在分类和数值特征之间找到它。在这种情况下,你不能使用相关性。即使为了这个目的相关性相当稳健,即使你在比较有序和二元特征时,当处理分类特征时,你必须使用称为标签编码的程序将它们转换为数字,该程序意味着为类别分配任意数字。由于标签编码基于任意值分配,如果你的编码应用于同一特征的方式不同,甚至不能使用相关性来建立相同编码的分类特征的同一性。

在类似情况下,当相关性不适用时,我们可以求助于基于卡方统计或克拉美尔 V 测量的关联度量。克拉美尔 V 是一种基于比较特征标签数量的卡方值统计量。此操作将特征上的记录值进行归一化,使其在整个数据集特征中具有可比性。结果的平方根范围从 0 到 1,提供关系强度的度量,但不提供方向性。就强度而言,接近 0 的克拉美尔 V 值表示特征之间无关,而接近 1 的值表示特征之间存在高度关联和共线性。

如果您之前使用基于十分位数的转换将数值特征离散化为分类特征,则可以应用克拉美尔 V,通过比较分类特征和数值特征。列表 2.8 是我们示例数据集中两个特征的比较。在示例中,我们首先创建一个函数来从表格的卡方分数计算cramerV。在将数值特征使用十分位数离散化后,我们将其应用于分类特征和数值特征的比较。

列表 2.8 使用克拉美尔 V 检测关联

from scipy.stats import chi2_contingency                       ①

def cramerV(chi2, table):                                      ②

    n = table.values.sum()                                     ③
    minimum_dimension = min(table.shape)-1                     ④

    result = ((chi2 / n) / minimum_dimension)**0.5             ⑤
    return result

deciles = pd.qcut(data.mpg, q=10, labels=False)                ⑥

table = pd.crosstab(data.model_year, deciles)                  ⑦

chi2, p, dof, expected = chi2_contingency(observed=table)      ⑧
print(cramerV(chi2, table))                                    ⑨

① 从 Scipy 导入计算卡方独立性检验的函数

② 准备一个函数,该函数以卡方独立性检验的分数和由此得到的表格作为输入来计算克拉美尔 V

③ 计算表格中的所有元素数量

④ 确定表格的最小维度

⑤ 结果是卡方分数除以表格中的元素数量及其最小维度

⑥ 将数值特征 mpg 转换为十分位数,以便它们可以适应表格

⑦ 在模型年份和 mpg 十分位数之间创建表格以估计它们之间的关联

⑧ 返回基于边缘概率的分数、p 值、自由度和期望表格

⑨ 打印结果中的克拉美尔 V

在代码中,通过提供卡方分数和表格本身,我们调用克拉美尔 V 函数,得到比较 MPGs 和模型年份的克拉美尔 V 值为 0.855。克拉美尔 V 是一种互反度量,用于识别在预测中具有相似角色的特征。与方向性度量不同,克拉美尔 V 对特征交换不敏感。这一特性允许识别可以从中移除的分析的高度相关特征。特征之间非常高的正或负相关性可能表明冗余,其中两个特征之一可以被删除。

在你的探索性数据分析(EDA)处理了单变量和双变量探索之后,正如我们指出的,你需要从多变量的角度检查你的数据集:这将帮助你检测是否存在任何不适合数据的数据块。再次强调,你进行这种检查不是为了找到更精确地拟合你的模型的方法,而是作为一个初步操作来验证你的表格数据是否适合你想要表示的问题。多变量方法要求所有特征都是数值型的,正如之前在 Cramer 的 V 中看到的;你可以为了这个目的对数值特征进行离散化,并将所有数据投影到低维,无论其多么复杂,从而可以可视化它并轻松地发现异常值。

该过程包括将你的数据简化为几个综合性的总结特征维度,并在图表上绘制它们,以直观地发现模式和孤立的数据点集群,这些可能是异常值。常见的用于获得低维投影的多变量方法有:

PCA 和 SVD 的根源在于统计分析,PCA 在用于 EDA 目的方面有着悠久的历史。然而,它们都是基于特征线性组合的方法(你的减少的总结维度是你的数据的加权求和),因此,它们只在某些情况下适合捕捉在现实世界数据中经常发现的非线性模式。较新的 t-SNE 和 UMAP 是能够非常有效地降低数据维度的方法,允许你绘制出可靠的数据表示,其中原始数据特征得到保留。尽管如此,也有一些注意事项,因为调整这两种方法的超参数可能会导致从相同数据中产生完全不同的投影。这是由于数据从多个特征传递到几个可能产生不同结果的特征时信息丢失所导致的——一些更,一些更少地代表数据本身。此外,生成的图表可能只在某些情况下容易解释,特别是当数据点很多或数据高度聚集时。话虽如此,t-SNE 和 UMAP 是数据探索和可视化的宝贵工具,因为它们可以帮助识别复杂数据集中通过其他方法可能不易察觉的模式和集群。当然,EDA 的一个核心原则并不唯一地依赖于它们,而是将它们的输出与其他多变量、双变量和单变量方法进行比较,如图所示。

在开始使用这些方法之前,阅读像“如何有效地使用 t-SNE”(distill.pub/2016/misread-tsne/))或“理解 UMAP”(mng.bz/AQxK)这样的文章,这些文章将为你提供在使用方法时采取适当预防措施所需的额外信心。另一个注意事项是,这两种方法都是计算密集型的,从大型和复杂的数据集中获得降维可能需要相当长的时间。然而,最近,NVIDIA 还基于 CUDA 和 GPU 技术开发了其 RAPIDS 套件(developer.nvidia.com/rapids),这可以显著减少从 UMAP 和 t-SNE 获得结果所需的时间,使它们在密集的 EDA 探索中更加有效。列表 2.9 展示了使用 Scikit-learn 包中的 t-SNE 实现分析示例数据集的代码。我们将在本书的后面部分介绍 NVIDIA RAPIDS 的实现,以及它们用于处理表格数据的其他工具。

列表 2.9 绘制数据集的 t-SNE 低维投影

from sklearn.manifold import TSNE                           ①
import matplotlib.pyplot as plt                             ②

tsne = TSNE(n_components=2, 
            perplexity=30., 
            init="random", 
            learning_rate="auto",
            random_state=42)                                ③

X = data[numeric_feats + ordinal_feats].fillna(
            data[numeric_feats + ordinal_feats].mean())     ④
projection_2D = tsne.fit_transform(X)                       ⑤

plt.figure(figsize=(15, 15))
plt.scatter(projection_2D[:, 0], projection_2D[:, 1],
            edgecolor='none', 
            alpha=0.80, 
            s=10) 
plt.show()                                                  ⑥

① 导入 Scikit-learn 中可用的 t-SNE 类

② 导入 matplotlib 的 pyplot 用于图表绘制

③ 将 t-SNE 设置为在二维投影结果;其他参数保持默认设置。

④ 仅使用数值和有序特征;缺失值用均值替换,因为 t-SNE 类需要完整的输入数据矩阵。

⑤ 通过 fit_transform,投影基于提供的数据创建,并应用于数据本身。

⑥ 将 t-SNE 变换后的数据绘制为二维散点图。

图 2.13 展示了 t-SNE 变换后的数据结果图。

图 2.13 t-SNE 二维变换后的点分布

代码片段仅适用于数值特征,在所有缺失值被均值替换后(t-SNE 和 UMAP 都不能处理缺失数据),将数据压缩成散点图可以绘制的二维表示。在这个数据集中,结果看起来非常规律,如果我们认为这是为特定目的收集的精选示例集合,那么就不会有太多惊喜:所有数据点最终都排列在一个曲线云中,整体给人一种根据特定标准逐步、规律选择示例的印象。

这标志着我们对 Auto MPG 数据集的探索结束。下一章将讨论机器学习算法,并探讨通常应用于表格数据集的基本经典模型。通过举例,我们将指出每个算法在数据以及你可能在项目中遇到的特征方面的优势和劣势。

摘要

  • 尽管在领域和组织之间存在巨大差异,但表格数据集中的共同特征使得可以概述最佳的数据处理和建模实践。

  • 表格数据集中的行关联所表示的单位,当它们不是独立同分布(non-IID)时,会有局限性和机会需要注意。非独立同分布数据可能会影响数据科学中常用的程序,如自助法、子采样和交叉验证。

  • 在列中可以找到不同类型的数据:数值型(包括浮点数和整数)、序数型(整数)、分类型(低和高基数,即具有低或高数量的不同标签),以及日期:每种类型都需要不同的数据处理和分析方法。我们建议掌握 pandas 包(及其 DataFrame 数据结构)来处理表格数据中发现的这些不同类型的特征。

  • 不同的数据异常与列中数据的类型有关:常数或准常数特征、重复或高度共线性特征、无关特征、稀有类别和其他不一致性、缺失数据和信息泄露。对于这些中的每一个,都有特定的补救措施。

  • 如果您寻找公开存储库(如 UCI 机器学习存储库或 Kaggle 数据集)或咨询 Google 数据集搜索引擎,找到和获取表格数据相对容易。

  • 探索性数据分析(EDA)在帮助您清理组织内部或从网络收集的数据方面发挥着重要作用。您可以使用值计数和描述、直方图、箱线图、相关矩阵和低维投影(如 t-SNE)来揭示数据的结构和问题。

第三章:机器学习与深度学习对比

本章涵盖

  • 将机器学习和深度学习作为解决表格数据问题的方法的比较

  • 从简洁性方面比较机器学习和深度学习

  • 从透明度方面比较机器学习和深度学习

  • 从效果方面比较机器学习和深度学习

在数据科学界,关于表格数据的最佳机器学习方法的公开辩论正在进行。有些人断言,像 XGBoost 或 LightGBM 这样的梯度提升技术对于大多数表格数据问题来说是优越的。其他人则主张在分析工具包中包含深度学习。在本章中,我们将通过两个具体的例子来检验这两种方法:

  • 预测纽约市 Airbnb 列表的价格。在这个例子中,我们使用一个真实的 Airbnb 列表数据集来训练模型,预测新的列表价格是否会高于或低于该市场的平均列表价格。我们将使用这个例子来检验简洁性、透明度和效果。

  • 预测一个地区房地产市场中的房产在售出之前在市场上的时间长度。在这个例子中,我们使用一个虚构的房地产列表数据集来说明透明度的可解释性方面。

我们将关注三个在解释科学和商业数据方面具有特别价值的标准:

  • 简洁性—在应用代码和框架核心 API 方面越简单,越好。

  • 透明度—一种可解释且能轻易向商业利益相关者解释的解决方案是最好的。

  • 效果—提供最佳结果且训练和实施时间更短的解决方案更可取。此外,随着新方法被发现,研究兴趣可以导致更有效的结果。

3.1 预测纽约市 Airbnb 的价格

为了比较机器学习和深度学习的简洁性,我们将对比两种针对特定表格数据分类问题的解决方案:预测纽约市 Airbnb 列表的价格是否会高于或低于该市场的平均价格。我们将比较的两种解决方案是

  • 机器学习—由使用 XGBoost(一种流行的基于梯度的方法)的解决方案表示。

  • 深度学习—由使用 Keras 功能 API 的解决方案表示。

我们将比较这些解决方案的代码复杂性,并回顾这两个解决方案告诉我们关于机器学习和深度学习方法相对简洁性的整体问题的信息。

3.1.1 纽约市 Airbnb 数据集

为了解决预测纽约市 Airbnb 列表的价格是否高于或低于平均价格的问题,我们使用了一个包含纽约市 Airbnb 列表详细信息的表格数据集。图 3.1 包括了纽约市 Airbnb 数据集列的描述以及每列的数据类型,而您可以在图 3.2 中看到的数据集样本已在 Kaggle 上共享:mng.bz/avJ7

数据集中的每一行都包含单个列表的信息,而数据集中的每一列都包含给定特征的列表的所有值。

图片

图 3.1 Airbnb 纽约市数据集中列的详细信息

图片

图 3.2 Airbnb 纽约市数据集的行样本

Airbnb 纽约市数据集具有使其成为比较解决表格数据问题方法的好选择的特征:

  • 它具有方便的大小。大约有 49,000 条记录,它足够大以引起兴趣,但又不至于庞大到需要像 Spark 这样的特殊“大数据”工具来处理。

  • 它拥有足够多的列来进行机器学习和深度学习的比较。正如我们将在后续章节中发现的那样,只有三到四列的数据集会立即倾向于经典的机器学习方法。拥有数百列的数据集将很难进行审查。Airbnb 纽约市数据集拥有“恰到好处”的列数——足够让深度学习有机会发光,但又不会让数据集对人类来说难以理解。

  • 由于数据集有合理数量的行和列,因此很容易在电子表格中快速查看它,这意味着我们不必每次想要回答有关数据集的问题时都编写 Python 代码。使用电子表格,您可以快速排序、筛选和计数数据集的各个方面,并利用 Excel 或 Google Sheets 的脚本功能进行更详细的调查。Airbnb 纽约市数据集适合在电子表格中进行审查,这意味着它可以以最小的努力进行审查。

  • 数据集包括一系列有趣的列类型,包括几种连续列(minimum_nights是整数值,pricereviews_per_month是浮点值,而latitudelongitude是地理空间值),分类列(neighbourhood_groupneighbourhoodroom_type),以及自由文本列(namehost_name)。

  • 数据集有一些瑕疵——例如,某些列中存在缺失值——但它并不那么混乱,以至于在使用它来训练模型之前需要进行大规模的清理。这使得围绕这个数据集构建应用程序变得方便,而不会因为清理而分心太多。

  • 它是开源的,并且基于真实业务的真实数据。正如我们将在本书的后续章节中看到的那样,使用表格数据探索机器学习和深度学习的一个挑战是缺乏代表真实业务问题的实质性开源表格数据集。Airbnb 数据集是非平凡表格数据集的罕见例子,其中包含来自运营业务的信息。

  • 使用这个数据集,模型的目标是显而易见的:价格。在我们的案例中,我们根据价格来推导目标——即给定的列表价格是否高于或低于数据集中列表的中位数价格。

在本小节中,我们初步了解了 Airbnb 纽约数据集。在下一个小节中,我们将查看用于训练模型的该数据集的代码。

3.1.2 代码简介

现在我们已经介绍了 Airbnb 纽约数据集,让我们来看看解决方案的代码。在本节中,我们不会深入探讨代码的所有细节,但了解各个部分如何组合在一起是很重要的。

图 3.3 总结了构成两个解决方案的文件。

图 3.3 Airbnb 纽约解决方案的构成文件

以下是对构成解决方案的文件的更多细节:

  • 输入 CSV (mng.bz/avJ7)。

  • 数据清理笔记本 (mng.bz/gawV)。请注意,虽然 XGBoost 和 Keras 版本的代码使用相同的清理笔记本,但 XGBoost 具有内置功能,例如处理缺失值,这意味着仅 XGBoost 版本的清理笔记本可能比通用版本更简单。

  • 清理配置文件 (mng.bz/ey7Q)。

  • XGBoost 训练配置文件 (mng.bz/pKOz)。

  • Keras 训练配置文件 (mng.bz/vKpr)。

  • XGBoost 训练笔记本 (mng.bz/YDGA)。在梯度提升解决方案中,我们选择了 XGBoost,因为它非常受欢迎,而且如果遇到任何问题,网上关于 XGBoost 的指导资料非常丰富。

  • Keras 训练笔记本 (mng.bz/JYwP)。我们选择 Keras 作为代表性的深度学习方法,因为它与 PyTorch 或在其之上运行的 fastai 等替代方案不同,Keras 与原始 TensorFlow 一样,在商业应用中最常用。我们选择 Keras 而不是在“比较梯度提升方法与深度学习的研究成果”部分介绍的表格深度学习库之一,因为 Keras 比任何这些库都更广泛使用,并且其 API 可以与 XGBoost 的 API 进行更“苹果对苹果”的比较,而不是将专门为表格数据设计的深度学习库与 XGBoost 的一般功能进行比较。

为了您的方便,XGBoost 和 Keras 解决方案被共享在两个单独的文件夹中,但两个解决方案之间的大部分代码是通用的:

  • 你可以在mng.bz/GeEO找到 XGBoost 解决方案的代码。

  • 你可以在mng.bz/zZ4Q找到 Keras 解决方案的代码。

这两个仓库之间的差异仅限于训练笔记本和训练配置文件。

3.1.3 使用 Keras 的深度学习解决方案

在深入挖掘 Airbnb 数据集和解决方案中使用的代码的细节之前,让我们将 Keras 解决方案置于其软件堆栈的上下文中。图 3.4 显示了 Keras 解决方案解决 Airbnb 价格预测问题的堆栈。

图 3.4 Airbnb NYC Keras 解决方案的堆栈

在第八章中,我们将更详细地介绍图 3.4 中显示的堆栈层。目前,我们可以观察到 Keras 是我们用来实现 Airbnb 问题深度学习解决方案的高级深度学习 API。有两个低级深度学习框架,我们将在本章中考察的深度学习解决方案依赖于 TensorFlow 低级框架,因为 Keras 就是建立在它之上的。

3.1.4 训练特征

两个解决方案的目标都是预测给定的 Airbnb 列表的价格是否高于或低于输入数据集的平均价格。为了实现这个目标,两个模型都在同一组特征上进行了训练。我们将用于训练模型的特征子集在模型训练的配置文件中定义:mng.bz/OBoE。以下是指定用于训练模型特征的配置文件的部分:

categorical: # categorical columns
      - 'neighbourhood_group'
      - 'neighbourhood'
      - 'room_type'
continuous: # continuous columns
      - 'minimum_nights'
      - 'number_of_reviews'
      - 'reviews_per_month'
      - 'calculated_host_listings_count'

配置文件还包括一个列表,其中明确排除了从训练过程中排除的特征:

excluded: # columns that are not used as input features for training
      - 'price'
      - 'id'
      - 'latitude'
      - 'longitude'
      - 'host_id'
      - 'last_review'
      - 'name'
      - 'host_name'
      - 'availability_365'

这些列没有被用作训练模型的特征的原因如下:

  • price没有被包括为训练模型的特征,因为它定义了模型的目标,即列表的价格是否高于或低于输入数据集的平均价格。

  • 两个 ID 列没有被包括为特征,因为它们不携带任何关于列表价格的信息,因为它们只是分配给列表和房东的数字 ID。

  • 我们不使用latitudelongitude作为特征,因为列表的地理位置已经编码在用于训练模型的neighbourhood_groupneighbourhood特征中。如果我们没有这些特征来用于列表的地理位置,我们可以使用latitudelongitude值(或从它们导出的极坐标:mng.bz/0Q66)来根据位置对列表进行聚类或将它们转换为极坐标。使用每个列表的原始纬度和经度作为特征可能会导致过拟合,因为每个列表都会有一对独特的值(latitudelongitude)。

  • namehost_name列不作为特征,因为它们是某种任意的标记集,允许人类读者识别列表。一个有趣的练习是将host_name作为特征,看看它是否提供了与具有相同主人的列表价格相关的某种信号。

  • 我们决定不将availabiltiy_365包含在特征集中,因为该列难以解释。

我们已经检查了我们将用于训练模型以预测 Airbnb 列表价格是否高于或低于平均价格的特征。在下一节中,我们将比较基于此数据集训练的梯度提升和深度学习模型的代码简单性。

3.1.5 比较梯度提升和深度学习解决方案

正如我们之前提到的,解决 Airbnb 纽约问题的两种解决方案仅在少数地方有所不同。图 3.5 再次显示了解决方案的文件结构,其中突出显示了两种方法之间的差异文件。

图片

图 3.5 XGBoost 和 Keras 解决方案之间不同的 Airbnb 解决方案文件

如果解决方案仅在四个文件中有所不同,我们如何使用这个例子来对比 XGBoost 与 Keras 深度学习的简单性?表 3.1 比较了应用程序的多个方面的代码复杂性。

表 3.1 比较 XGBoost 和 Keras 在三个领域的代码复杂性

代码复杂性方面 XGBoost Keras 深度学习模型
数据准备 需要代码块将 numpy 数组列表转换为 numpy 数组列表 NA——数据准备是为 Keras 深度学习解决方案设计的
模型定义 单个语句,与 Scikit-learn 模式一致 需要代码块来定义模型的层,每个列类型(连续、分类、文本)都有独特的层集
模型训练 单个语句,与 Scikit-learn 模式一致 需要的代码块,以允许回调控制训练过程,避免提供无益的训练迭代,并确保训练过程输出性能最佳的训练模型
模型保存 单个语句,与 Scikit-learn 模式一致 作为模型保存回调的一部分包含在内
模型加载 代码块——需要安装最新版本的 XGBoost 或加载的模型将因错误AttributeError: 'XGBClassifier'对象没有属性'_le'而失败 单个语句

让我们看看 Airbnb 纽约解决方案中这些代码复杂性的各个方面。

XGBoost 解决方案有一些额外的数据准备代码。Airbnb 纽约解决方案最初是用 Keras 深度学习编写的,然后使用 Keras 解决方案作为起点创建了 XGBoost 解决方案。原始的 Keras 模型需要以 numpy 数组列表的形式提供训练输入。XGBoost 需要以列表的 numpy 数组形式提供输入。以下列表包含 XGBoost 训练笔记本中转换原始数据格式为 XGBoost 所需格式的代码。

列表 3.1 XGBoost 的数据准备代码

list_of_lists_train = []
list_of_lists_test = []
for i in range(0,7):                                     ①
    list_of_lists_train.append(X_train_list[i].tolist())
    list_of_lists_test.append(X_test_list[i].tolist())
# convert lists of lists to numpy arrays of lists
xgb_X_train = np.array(list_of_lists_train).T            ②
xgb_X_test = np.array(list_of_lists_test).T              ③

① 定义训练和测试数据集的列表的列表(每个特征一个列表)

② 将列表的列表转换为 numpy 数组的列表

③ 将测试列表的列表转换为 numpy 数组的列表

注意,虽然这是一个 XGBoost 代码相对于 Keras 代码具有一些额外复杂性的真实示例,但这种额外的代码并非 XGBoost 固有的需求,而是由于 XGBoost 解决方案是从 Keras 解决方案改编而来的方式所必需的。

要进一步确认 XGBoost 数据准备代码的作用,请访问 Gemini(gemini.google.com),将代码粘贴到输入字段中,并附带提示“这段代码做什么?”然后点击提交按钮,如图 3.6 所示。

图 3.6 在 Gemini 中输入解释代码的请求

您将获得一个解释代码功能以及以下摘要的响应:

In essence, the code does the following:
It extracts specific elements from 
two input lists (X_train_list and X_test_list).
It arranges those elements into a specific format (lists of lists).
It converts those lists into NumPy arrays, 
preparing the data for further processing 
or model training.

现在我们已经比较了 XGBoost 和 Keras 在数据准备方面的差异,让我们比较两种解决方案的模型定义代码。

以下是为 Airbnb 纽约问题定义的 XGBoost 模型——遵循 Scikit-learn 模式的单行代码:

model = XGBClassifier()

让我们看看 Keras 解决方案的模型定义代码是什么样的。图 3.7 显示了定义 Airbnb 纽约问题深度学习模型的函数的初始部分。

图 3.7 Airbnb 纽约问题的 Keras 深度学习解决方案的模型定义(第一部分)

图 3.8 展示了定义 Airbnb 纽约问题深度学习模型的其余函数。

图 3.8 Airbnb 纽约问题的 Keras 深度学习解决方案的模型定义(第二部分)

图 3.9 展示了 Airbnb 问题的模型可视化。

图 3.9 Airbnb 问题的 Keras 模型可视化

这是一种极端的对比——深度学习模型本可以定义得更简单。这个模型定义指定了每个列类型(连续、分类和文本)的不同 Keras 层。这不是表格数据模型可能的最小层定义,但它非常灵活。它将能够处理具有各种连续、分类和文本列组合的表格数据集。此外,这个模型定义包括指定文本列层的代码,而我们没有选择任何文本列来训练 Airbnb 模型,因此这段代码可以被省略,而不会影响 Airbnb 纽约解决方案的 Keras 版本。尽管如此,XGBoost 模型定义的简洁性与 Keras 模型定义的复杂性之间的差异突出了 XGBoost 的一个优势:XGBoost 中的模型定义代码比 Keras 中的更简单。

我们已经比较了数据准备代码和模型定义代码。接下来,让我们比较 XGBoost 和 Keras 解决方案中的模型训练代码。在 XGBoost 解决方案中,通过一行代码完成训练,并接受所有具有默认值的参数的默认值:

model.fit(xgb_X_train, dtrain.target)

以下列表显示,对于 Keras 解决方案,需要设置两种不同的拟合语句版本和几个额外的参数(包括批量大小和训练过程中将运行的默认 epoch 数)。

列表 3.2 Keras 的拟合语句

if early_stop:
       modelfit = model.fit(X_train_list, dtrain.target, 
epochs=epochs, batch_size=batch_size
        , validation_data=(X_valid_list, dvalid.target),
verbose=1,callbacks=callback_list)                         ①
else:
    modelfit = model.fit(X_train_list, 
dtrain.target, epochs=epochs, batch_size=batch_size
         , validation_data=(X_valid_list, 
dvalid.target), verbose=1)                                 ②

① 包含一个参数的拟合语句,该参数列出用于早期停止和模型保存的回调

② 不包含回调的拟合语句

XGBoost 和 Keras 在训练代码的复杂性上没有太大的差异。然而,为了使 Keras 的训练过程高效,我们需要使用回调来避免浪费训练周期,并在训练过程结束时得到一个次优化的训练模型。有关使用 Keras 回调优化训练过程的详细信息,请参阅《结构化数据深度学习》第六章 (mng.bz/KGx0)。

在查看 Keras 解决方案的回调语句之前,让我们看看 Gemini 能告诉我们关于 fit 语句的什么信息。同样,让我们将此代码提交给 Gemini,并在前面加上提示“这段代码做什么?”如果你第一次没有得到满意的答案,点击“重新生成草稿”以获取另一个答案。在第二次使用 Gemini 时,我们得到了对代码的详细描述,包括以下对 fit 语句参数的描述:

In both cases, it trains the model using model.fit(), 
which is a common method for training models in 
machine learning libraries like TensorFlow or Keras.
Key arguments passed to model.fit():
X_train_list: Training data features.
dtrain.target: Training data targets (labels).
epochs: Number of training iterations.
batch_size: Number of samples per training step.
validation_data: Validation data for monitoring performance (optional).
verbose: Level of output during training (1 for progress bars).
callbacks: List of callbacks to be executed during 
training (only in the early_stop case) 

Gemini 还提供了以下摘要:

The code conditionally trains a model with or without early stopping.
Early stopping is a technique to prevent overfitting 
and improve model generalization.
The specific implementation of early stopping depends 
on the contents of callback_list.
To fully understand its purpose, more context about the 
model, training process, and early stopping criteria 
is needed.

注意在最后一行,Gemini 通过正确地指出,仅给定训练片段,它无法推断出完整解决方案的所有细节,从而对其分析的限制进行了说明。

列表 3.3 显示了定义用于 Keras 模型训练步骤的回调的代码。这段代码在 Keras 版本的 Airbnb NYC 解决方案中增加了额外的复杂性。

列表 3.3 Keras 的回调语句

   callback_list = []
    es = EarlyStopping(monitor=es_monitor, 
mode=es_mode, verbose=1,
patience = patience_threshold)                             ①
    callback_list.append(es)                               ②
    model_path = get_model_path()
    save_model_path = 
os.path.join(model_path,'scmodel'+modifier+"_"+str(experiment_number)+'.h5')
    mc = ModelCheckpoint(save_model_path, 
monitor=es_monitor, mode=es_mode, 
verbose=1, save_best_only=True)                            ③
    callback_list.append(mc)                               ④

① 定义了一个早期停止回调对象,指定一旦性能停止提高,训练过程就应该停止

② 将第一个回调添加到训练过程中要使用的回调列表中

③ 定义一个模型保存回调对象,以确保在整个训练运行中性能最优的模型是在训练运行结束时保存的模型

④ 将第二个回调添加到训练过程中要使用的回调列表中

一旦我们训练了模型,我们希望将其保存到文件中,以便我们可以在另一个会话或作为模型部署的一部分加载和练习它。在我们的简单示例中,我们将模型保存和重新加载到用于训练模型的同一个笔记本中。保存 XGBoost 模型的语句是

model.save_model(xgb_save_model_path)

对于 Keras,我们不需要显式保存模型的语句,因为模型会自动随着模型保存回调一起保存。

下面的列表显示了在 XGBoost 中加载模型的代码。

列表 3.4 XGBoost 的模型加载语句

loaded_saved_model =  xgb.XGBClassifier()                 ①
loaded_saved_model.load_model(xgb_save_model_path)        ②

① 定义了一个新的 XGBoost 分类器对象

② 使用 save_model 语句保存的模型加载新的 XGBoost 分类器对象

加载 Keras 模型的语句是

saved_model = load_model(save_model_path)

XGBoost 和 Keras 之间一个额外的区别是,如果你尝试加载一个保存的 XGBoost 分类器模型(例如我们为 Airbnb NYC 问题训练的模型)并运行预测,如果你不在非常新的 XGBoost 版本上,你会得到一个错误。为了解决这个问题,XGBoost 模型训练笔记本中包含了以下语句,以确保 XGBoost 是最新版本:

!pip install --upgrade XGBoost

在本节中,我们比较了 XGBoost 和 Keras 在数据准备、模型定义、模型训练和模型保存方面的代码简洁性。接下来,我们将讨论从这个比较中可以得出的结论。

3.1.6 结论

对于数据科学项目来说,最好的建议之一就是采取尽可能简单的方法。将奥卡姆剃刀原则应用于数据科学项目,以简化任务。如果解决问题有多种方法,请选择最简单的方法。如果线性回归可以解决问题,那么为什么还要使用支持向量机?如果传统的编码方法可以解决问题,那么为什么还要使用机器学习呢?如果你采取最简单的方法,你可能会更快地获得初步结果,更快地完成整个解决方案的开发,并且在部署后维护系统也会更加容易。

为了回答经典机器学习和深度学习在简单性方面的比较问题,我们比较了使用每种方法解决一个具体问题的解决方案:Airbnb 纽约市价格预测问题。通过回答这个问题,我们可以将“保持简单”的原则应用于表格数据问题。

在前面的子节中,我们回顾了 Airbnb 纽约市表格数据集,并比较了使用该数据集训练的两个解决方案的复杂性——一个使用梯度提升方法(XGBoost),另一个使用深度学习(Keras)。XGBoost 解决方案需要一些额外的数据准备代码,但这些额外的代码是 XGBoost 解决方案从深度学习解决方案中改编而来的产物,而不是 XGBoost 的直接需求。对于 XGBoost 解决方案,模型定义只需要一行代码,模型训练也只需要一行代码。而对于 Keras 来说,模型定义需要很多行代码,模型训练需要的代码行数比 XGBoost 多,特别是为了利用 Keras 回调确保高效的训练过程。保存和加载模型的代码复杂性在 XGBoost 解决方案和 Keras 解决方案中大致相同。虽然 Airbnb 纽约市问题不能代表所有表格数据问题,但它为我们提供了将梯度提升方法和深度学习方法进行苹果对苹果比较的机会,并且比较的结论是 XGBoost 代码更简单。XGBoost 解决方案的代码行数更少,模型定义和模型训练的语句更简单,在 XGBoost 中比在 Keras 中需要的非默认参数值更少。

在我们得出关于梯度提升和深度学习比较简单性的结论之前,我们需要注意,Keras 并不是唯一可用的深度学习方法。还有其他处理表格数据的深度学习方法,其中一些方法定义和训练模型的代码比我们为 Airbnb 纽约问题所检查的 Keras 代码更简单。例如,使用 fastai 框架(docs.fast.ai/),我们将在第九章中更详细地介绍,你可以定义一个用于处理表格数据的模型,训练它,并用不到 10 行代码来获取预测。Tensorflow 预置估算器(mng.bz/9Y51)是另一种简单的表格数据深度学习方法。使用这些预置估算器,你可以在表格数据集上训练一个模型,并通过与 XGBoost API 一样简单的 API 从模型中获取预测。这些只是两个代码比 Keras 更简单的深度学习方法的例子。Keras 的优点是它非常灵活,其灵活性是企业在生产系统中经常使用 Keras 而较少在生产部署中看到简单方法(如 fastai)的原因之一。

现在我们已经从简单性的角度比较了机器学习和深度学习,在下一节中,我们将从透明度的角度比较这两种方法。

3.2 透明度

当比较梯度提升技术与深度学习时,有两个与透明度相关的方面:可解释性(即解释模型工作原理的难易程度)和特征重要性(即确定哪个特征影响最大的难易程度)。在本节中,我们将根据这两个透明度方面比较梯度提升与深度学习。

为了比较梯度提升和深度学习的可解释性,我们将考虑一个简单、人为构造的数据集,以及在这个数据集上训练的模型如何进行解释。

我们将使用的数据集包含特定房地产市场的房屋信息,如表 3.2 所示。

表 3.2 房屋上市时间数据集

上市时间(周) 城市 出售价格(千美元) 到交通站距离(公里)
6 Kitchener 600 10
5 Waterloo 700 5
12 Kitchener 900 20
6 Waterloo 700 15
1 Waterloo 500 5
4 Waterloo 600 5
8 Waterloo 750 5
2 Kitchener 500 5
9 Kitchener 1000 5
4 Waterloo 750 10

我们将在下一节回到 Airbnb 数据集。目前,这个房地产数据集足够简单,可以很容易地说明可解释性。

该数据集包括房屋所在的城镇、房屋的挂牌价格以及房屋到最近的交通站的距离,以及房屋售出前在市场上停留的周数。我们想要使用这个数据集训练一个模型,该模型将预测一个新挂牌的房屋将在市场上停留超过一个月还是不到一个月。

3.2.1 可解释性

假设我们想要给非专业人士、商业观众一个关于决策树模型如何用于解决这个数据集的“上市时间”问题的想法。我们可以创建一个如图 3.10 所示的插图,以给出这样一个决策树如何工作的粗略概念。

图 3.10 决策树插图

注意,这个插图不包含任何术语,也不假设读者有任何机器学习背景。决策树的目的很明显。注意这个决策树是一个粗略的简化,与这种简单的决策树和梯度提升模型之间存在重要的技术差异。例如,XGBoost 使用多个决策树,所以这个插图本身不足以解释 XGBoost 模型。尽管如此,它表明,对于一些经典的机器学习算法,可以在不强迫非专业人士学习它们的详细技术信息的情况下,给非专业人士一个关于算法如何工作的直观理解。

如果我们想要给同样是非专业人士的观众一个关于深度学习模型如何被训练来解决相同的“上市时间”问题的总体感觉呢?我们可以从一个如图 3.11 所示的通用神经网络示意图开始。

图 3.11 神经网络插图

这样的插图可能有助于解释“深度学习”中的“深度”,但它并没有提供关于模型实际是如何训练的任何见解。如果我们放大以展示网络中单个节点的工作,如图 3.12 所示,会有帮助吗?

图 3.12 神经网络中节点的简单插图

大多数非专业人士会发现解读图 3.12 很困难。如果放大神经网络细节并不能提高可解释性,那么如果我们采取不同的方法,借鉴神经网络与生物神经元之间的类比,如图 3.13 所示,会怎么样呢?这个插图试图将整个神经网络与网络中单个节点(一个“神经元”)的工作联系起来,然后将该节点与生物神经元联系起来。

图 3.13 将神经网络与生物神经元联系起来

引用生物学类比提出了两个问题。首先,这个类比本身是有争议的。一些行业专家认为神经网络的工作方式与生物神经元完全不同(例如,mng.bz/jp2P)。即使你接受神经网络与生物神经元之间的类比是有效的,使用这个类比来解释深度学习系统也可能导致严重的误解,如果非专业人士从类比中推断出简单的深度学习系统具有类似大脑的能力。其次,这个类比并没有真正阐明深度学习模型是如何训练的。大多数人知道什么是生物神经元,但他们不知道生物神经元实际上是如何工作的。如果类比的基础本身就是一个谜,那么类比是没有帮助的。总的来说,神经网络节点与生物神经元之间的类比,充其量是一个孤立的奇思妙想,并不能帮助非专业人士理解深度学习实际上是如何工作的。

从这个例子中我们可以得出什么结论?尽管有成百上千万的人已经对深度学习有了足够的了解,能够欣赏其所能和不能做到的事情,但当试图向商业受众解释深度学习时,深度学习仍然存在一些难以克服的障碍。与可以简单用易懂的插图解释其基本原理的决策树不同,深度学习不适合用一张简单的图片来解释。即使今天,有了像 fastai 这样的易于访问的深度学习框架和数百个关于深度学习的免费在线资源,那些在线性代数、微积分和编程方面有相当背景的人仍然需要几个月的时间来获得对深度学习工作原理的深刻理解。我们断言,不可能通过一个简单的插图将这种直觉传递给非专业人士,更不用说创建一个能够立即解释特定数据集如何工作的深度学习工作原理的即时可访问的解释。

3.2.2 特征重要性

在上一节中,我们根据透明度的一个方面比较了机器学习和深度学习方法:向非专业人士解释方法有多容易。在本节中,我们将探讨透明度的另一个方面:确定给定特征对整个模型性能的重要性有多容易。

回到 Airbnb 纽约市的例子,我们可以看到 XGBoost 解决方案使用了 XGBoost 内置的 API 来确定特征重要性:

model.feature_importances_

XGBoost 提供了几种不同的选项来计算特征重要性。默认的是增益,它是在特征被使用的所有分割中的平均增益,其中增益是指特征根据目标(在我们的案例中,是列表价格是否高于或低于中位数)将输入示例(在我们的案例中,是 Airbnb 列表)区分的程度。

在 Airbnb 纽约的例子中,该 API 的输出显示了每个特征的增益值。用于训练模型的第三个特征(room_type)影响最大,其次是第一个特征(neighbourhood_group):

array([0.10064548, 0.0438753 , 0.7586573 , 0.01957352, 0.02225152,
       0.01597736, 0.03901952], dtype=float32)

我们可以使用以下语句以图表形式展示特征重要性值:

plt.barh(np.array(final_features), model.feature_importances_)

该语句的输出是一个图表,显示了每个特征的相对重要性,如图 3.14 所示。

图 3.14 根据 XGBoost 的 Airbnb 纽约问题特征重要性

此图表清楚地表明,根据 XGBoost 内置的特征重要性,room_type是最重要的特征,其次是neighbourhood_group,其他所有特征对模型行为的重要性相对较小。

room_type特征值的取值范围是

  • 整个住宅/公寓

  • 私人房间

  • 共享房间

直观上,room_type对列表价格的影响应该是显著的。我们预计整个住宅列表和共享房间列表之间的价格差异会很大。

现在我们已经了解了如何获取 XGBoost 模型的特征重要性,让我们考虑如何获取 Airbnb 纽约问题的 Keras 解决方案的特征重要性。与 XGBoost 不同,Keras(以及深度学习框架)没有内置的确定特征重要性的方法。然而,你可以应用外部方法来获取与 XGBoost 内置特征重要性类似的特征重要性。例如,你可以使用像 lime (github.com/marcotcr/lime) 或 shap (github.com/slundberg/shap) 这样的工具来获取 Keras 深度学习模型的特征重要性。探讨这些特征重要性分析方法超出了本章的范围。目前,我们只需注意,使用 XGBoost,你可以通过几行代码就获得特征重要性的基本概念,而对于像 Keras 这样的深度学习框架,这种简单的方法是不可用的。

3.2.3 结论

在本节中,我们根据以下两个方面的透明度比较了机器学习和深度学习:

  • 可解释性——解释模型工作原理有多容易,尤其是向业务利益相关者或其他非数据科学专家解释?与看似黑盒的模型相比,业务利益相关者对可以通过某种直观方式理解的模型会更有信心。更重要的是,对于像汽车保险这样的监管行业,透明度不仅仅是用模型工作原理的易于理解的抽象来安慰业务利益相关者的问题。这些行业的监管者期望获得关于模型如何工作以及模型行为如何随着模型新版本的部署而变化的详细且易于理解的解释。

  • 特征重要性—确定哪些特征对模型行为影响最大的难易程度如何?

我们已经看到,机器学习比深度学习更具可解释性,XGBoost 提供了内置的特征重要性 API,而 Keras 没有内置的确定特征重要性的功能。现在,我们已经从透明度的角度比较了机器学习和深度学习,在下一节中,我们将从效果的角度比较这两种方法。

3.3 效率

我们已经从简单性和透明度的角度比较了机器学习和深度学习。现在,让我们看看这两种方法在效率方面的比较。我们将查看效率的两个方面:

  • 性能—我们将回到 Airbnb NYC 的例子,比较应用程序的 XGBoost 版本与 Keras 版本的相对性能。在 Airbnb NYC 问题中,我们训练一个模型来预测新的列表价格是否高于或低于平均价格。我们将比较每种方法产生的预测准确率以及运行每种方法代码所需的时间。

  • 研究—我们将比较支持与反对将深度学习应用于表格数据这一想法的研究数量。

3.3.1 评估性能

首先,我们将查看 Airbnb NYC 应用程序的 XGBoost 和 Keras 版本的性能。我们将比较每种方法“即开即用”的结果。

一旦我们训练了 XGBoost 模型,我们可以使用以下语句获取训练模型在测试数据集上的准确率:

y_pred = model.predict(xgb_X_test)
xgb_predictions = [round(value) for value in y_pred]
xgb_accuracy = accuracy_score(test.target, xgb_predictions)
print("Accuracy: %.2f%%" % (xgb_accuracy * 100.0))

对于训练笔记本的一次运行,我们得到以下结果:

Accuracy: 79.24%

在训练配置文件(mng.bz/pKOz)中的原始参数设置下,重复运行代码,我们得到 79% 到 81% 的准确率。在标准的 Colab 环境中运行笔记本的时间在 3 到 4 秒之间。

对于 Keras 模型,在标准的 Colab 环境中,使用训练配置文件(mng.bz/vKpr)中的原始参数设置运行,模型具有以下关键性能特征:

  • 准确率—测试准确率在 80% 到 81% 之间

  • 运行笔记本的耗时—在 10 到 15 秒之间。这是在没有使用 Colab 上的 GPU 的情况下。作为一个练习,你可以尝试在 Colab 上使用和没有 GPU 运行 Keras 训练笔记本,并比较运行笔记本所需的时间。

如果我们将 XGBoost 和 Keras 在 Airbnb NYC 问题上的性能进行比较,XGBoost 在训练速度方面表现更优。这个例子并不能完全说明问题,我们将在后续章节中看到,通过一些耐心和调整,深度学习解决方案可以与 XGBoost 相媲美,甚至在某些情况下超越其性能。这个简单的性能比较的目的是为了说明 XGBoost 无需大量调整和微调就能提供良好的性能。

3.4 深入挖掘

本书范围之外,无法提供所有近期关于表格数据深度学习支持与反对的详细研究综述,但在这个部分,我们将更深入地研究这些研究,并试图了解哪一方在研究竞赛中“获胜”。

文章“表格数据深度学习简史”(mng.bz/W2x1)是近期学术工作的优秀总结,也是深入了解深度学习和表格数据研究的良好起点。

以下是一些当前支持使用深度学习进行表格数据研究的文献。与发表在非表格数据(包括文本和图像)上的数千篇深度学习研究论文相比,关于深度学习在表格数据上应用的研究论文数量非常少。

除了为表格数据的深度学习提出论点外,以下论文还介绍了用于表格数据深度学习的库。这些库提供了将深度学习应用于表格数据集的便捷方式。在第八章中,我们将通过更多示例应用深度学习于表格数据,并探讨本章用于 Airbnb NYC 问题的基于 Keras 方法的替代方案。

  • SAINT:通过行注意力和对比预训练改进的表格数据神经网络 (arxiv.org/abs/2106.01342)。我们将在后续章节中更详细地探讨这个框架。

  • TabNet:通过行注意力和对比预训练改进的表格数据神经网络 (arxiv.org/abs/1908.07442)。我们将在后续章节中更详细地探讨这个框架。

  • PyTorch Tabular:一个用于表格数据的深度学习框架 (arxiv.org/abs/2104.13638)。本文介绍了一个基于 PyTorch 的库,这是我们将在后续章节中再次回顾的另一个库。

  • fastai:一个基于 PyTorch 的深度学习分层 API (arxiv.org/abs/2002.04688)。本文介绍了 fastai,这是一个构建在 PyTorch 之上的高级框架。该框架包括对表格数据的显式支持。

  • Deep Tables (deeptables.readthedocs.io/en/latest/)。

  • DANets:用于表格数据分类和回归的深度抽象网络 (arxiv.org/abs/2112.02962)。

这些论文中的每一篇都包含我们可以用来验证研究结果的代码,更重要的是,可以确定库的鲁棒性。如果我们想使用这些库中的任何一个来解决使用深度学习的现实世界表格数据问题,我们需要评估这些库是否易于使用,并且是否与当前的深度学习框架兼容。图 3.15 显示了这些库中一些库的相对流行度,基于它们的论文获得的引用数量以及它们的仓库获得的星标数量。

图片

图 3.15 表格数据深度学习库的流行度

库的流行度很重要。如果一个库被广泛使用,它更有可能在各种环境中工作。我们不能想当然地认为一个库会在每个环境中工作。正如我们在第八章中将要看到的,一些库在 Colab 中无法工作,例如,这意味着评估它们很困难。此外,如果你使用的是数百或数千名其他机器学习实践者都在使用的库,你更有可能找到问题的答案和问题的解决方案。如果你是少数几个使用该库的人之一,你可能会成为第一个遇到给定问题的人,你需要花时间解决问题,而不是简单地找到 Stack Overflow 上的现有解决方案。

对表格数据深度学习的批评者已经为他们的论点贡献了研究,包括以下论文:

这个列表,以及之前列出的支持深度学习的论文列表,绝对不是详尽的。然而,可以说,支持使用表格数据深度学习的研究比反对使用表格数据深度学习的研究更多。

在所有关于深度学习的研究论文中,有多少比例涉及表格数据?很难得到一个确切的比率,但考虑图 3.16,它显示了 2018 年之前十五年内发布的深度学习论文数量。

图片

图 3.16 按年份发布的深度学习文章数量。文章数量是通过在 Scopus 和 Google Scholar 上使用查询“深度学习”得到的搜索结果。

此图显示,近年来每年都有数以万计的论文发表在深度学习主题上。在过去的几年里,每年关于深度学习和表格数据的论文发表量不到 100 篇。

了解深度学习研究中处理表格数据比例的一种方法是在 Google Scholar(scholar.google.com/)上做一些搜索。考虑符合以下搜索条件的 Google Scholar 搜索结果数量:

  • “深度学习”:约 1.6 百万篇

  • “深度学习”与“表格数据”或“结构化数据”相关,排除“图结构”:约 34,500 条

  • “深度神经网络”:约 530,000 篇

  • “深度神经网络”与“表格数据”或“结构化数据”相关,排除“图结构”和“深度学习”:约 1,500 篇

确定深度学习研究中处理表格数据比例的另一种方法是,在 arXiv(arxiv.org/)上做一些搜索。考虑符合以下搜索条件的 arXiv 论文数量:

  • 标题中包含“深度学习”:约 32,000 条

  • “深度学习”与“表格数据”或“结构化数据”相关:约 200 条

  • 标题中包含“深度神经网络”:约 17,500 条

  • “深度神经网络”与“表格数据”或“结构化数据”相关:约 11 条

从 Google Scholar 和 arXiv 的搜索结果来看,很清楚,在深度学习研究中,只有极小比例的研究涉及表格数据。

总结来说,我们可以得出以下关于深度学习与表格数据研究的结论:

  • 支持深度学习与表格数据的出版物比反对的出版物要多。

  • 几篇关于深度学习与表格数据的研究论文包括了实现论文中描述方法的库。到目前为止,这些库还没有成为对深度学习与表格数据感兴趣的数据科学家们的明确首选。

  • 在所有关于深度学习的研究中,处理深度学习和表格数据的研究仅占极小部分。

基于这些结论,可以清楚地看出,在研究领域,机器学习和深度学习都不是明确的赢家。在关于深度学习与表格数据的辩论中,当涉及到研究时,陪审团仍然没有定论。

我们已经从三个标准:简单性、透明度和有效性,比较了机器学习和深度学习。图 3.17 总结了这两种方法在这三个标准上的比较情况。

图片

图 3.17 机器学习和深度学习与表格数据比较的总结

这就结束了我们对机器学习和深度学习的比较。在下一章中,我们将超越简单的 Airbnb NYC 示例,使用 XGBoost 深入探讨机器学习与表格数据的细节。

摘要

  • 我们可以用三个特征来比较机器学习和深度学习:简单性、透明性和有效性。

  • 代码的相对简单性让我们可以预见到哪个解决方案最初更容易构建,长期来看也更容易维护。

  • 解决方案的可透明性包括向非专业人士解释模型有多容易,以及评估用于训练模型的特征的相对重要性有多容易。

  • 有效性包括每种方法在商业应用和研究中的成功。

  • 当从代码简单性方面比较机器学习和深度学习时,机器学习表现得更为出色。

  • 当从透明性方面比较机器学习和深度学习时,机器学习再次表现得更为出色。

  • 当从有效性方面比较机器学习和深度学习时,两种方法非常接近,难以一概而论“出类拔萃”,尽管额外的调整可能会揭示两种方法结果之间的更多差异。

  • 对于其他成功指标,包括在 Kaggle 竞赛中的成功、商业上的成功以及研究重点,目前还没有定论。

第二部分:表格数据的机器学习和梯度提升

第四章、第五章和第六章全面概述了经典机器学习算法,并帮助您掌握最先进的梯度提升技术,如 XGBoost 和 LightGBM。您将学习如何使用每个算法并将其应用于合适的表格数据。第七章通过一个实际示例帮助您巩固所学知识,展示了涉及表格数据时的完整分析过程。

具体来说,第四章介绍了 Scikit-learn 以及各种经典机器学习方法,如线性回归、逻辑回归和广义线性模型。您将从实际角度掌握数据管道的工作原理,并学习如何验证结果以及在不同模型之间进行比较。然后我们进入第五章,探讨决策树及其集成,包括 bagging、随机森林和梯度提升决策树。接着,我们详细解释了梯度提升算法如何运作以及在表格数据上的优势。最后,第五章总结了从 Scikit-learn 到 XGBoost 和 LightGBM 的不同实现方法。

第六章致力于帮助您在机器学习中使用表格数据的最佳实践,特别强调梯度提升方法。我们涉及了许多高级技术,以选择最优的特征集、优化模型超参数以及获得更好的性能。我们还讨论了如何从实际角度处理缺失数据以及如何正确地转换分类数据。第二部分以第七章结束,这是一章说明性章节,您将学习如何使用梯度提升在真实世界数据上构建端到端示例。通过多个段落引导,您将掌握如何应用我们之前讨论的最佳实践和方法论。之后,您将准备好将这些经典和高级方法与深度学习方法进行比较,后者是以下结论部分的重点。

第四章:表格数据的经典算法

本章涵盖

  • Scikit-learn 简介

  • 探索和处理 Airbnb 纽约市数据集的特征

  • 一些经典的机器学习技术

根据问题不同,经典机器学习算法通常是处理表格数据的最实用方法。这些工具和算法背后有着数十年的研究和实践,提供了丰富的解决方案供选择。

在本章中,我们将介绍用于使用表格数据进行预测的经典机器学习中的基本算法。我们专注于线性模型,因为它们仍然是挑战性基线和生产中稳健模型的常见解决方案。此外,讨论线性模型有助于我们构建可以在深度学习架构和更高级的机器学习算法(如梯度提升决策树,这是下一章的主题之一)中找到的概念和思想。

我们还将为您快速介绍 Scikit-learn,这是一个强大且多功能的机器学习库,我们将使用它继续探索 Airbnb 纽约市数据集。我们将避免冗长的数学定义和教科书细节,而是提供示例和实际建议,以将这些模型应用于表格数据问题。

4.1 Scikit-learn 简介

Scikit-learn 是一个用于经典机器学习的开源库。它始于 2007 年,是 David Cournapeau 的一个 Google Summer of Code 项目,后来成为 SciKits(Scipy 工具包的简称:projects.scipy.org/scikits.html)的一部分,直到 INRIA(法国国家信息与自动化研究所)及其基金会接管了项目及其开发。我们提供了一个简短的例子,说明 Scikit-learn 如何快速解决大多数机器学习问题。在我们的起始示例中,

  1. 我们创建了一个用于分类问题的合成数据集,目标具有二进制平衡标签,一半为正标签,一半为负标签。

  2. 我们设置了一个管道,标准化特征并将它们传递给逻辑回归模型,这是分类问题中最简单和最有效的基于统计的机器学习算法之一。

  3. 我们使用交叉验证来评估其性能。

  4. 最后,在交叉验证结果确保我们处理问题的方法正确后,我们在所有可用数据上训练了一个模型。

列表 4.1 展示了基于合成数据的简单分类问题中 Scikit-learn 提供的完整列表和大多数功能。在创建数据后,我们定义了一个管道,将统计标准化与基本模型、逻辑回归相结合用于分类。所有内容首先被送入一个函数,该函数自动估计其在评估指标(准确率)上的性能以及预测正确的时间。最后,考虑到其评估的性能是合适的,我们使用所有数据重新拟合相同的机器学习算法。

列表 4.1 使用 Scikit-learn 解决分类问题的示例

import numpy as np
from sklearn.datasets import make_classification
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_validate
from sklearn.pipeline import Pipeline

X, y = make_classification(n_features=32,
                           n_redundant=0,
                           n_informative=24,
                           random_state=1,
                           n_clusters_per_class=1
                           )                            ①

model = LogisticRegression()                            ②

pipeline = Pipeline(
    [('processing', StandardScaler()),
     ('modeling', model)])                              ③

cv_scores = cross_validate(estimator=pipeline, 
                           X=X, 
                           y=y,
                           scoring="accuracy",
                           cv=5)                        ④

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
print(f"accuracy: {mean_cv:0.3f} ({std_cv:0.3f})")      ⑤

model.fit(X, y) 

① 生成具有指定特性的合成数据集

② 创建 LogisticRegression 模型的实例

③ 创建一个管道,按顺序应用标准缩放和逻辑回归模型

④ 使用定义的管道执行五折交叉验证,计算准确率得分

⑤ 打印交叉验证测试准确率得分的平均值和标准差

⑥ 将逻辑回归模型拟合到整个数据集 X 及其对应的标签 y

生成的输出报告了分类中获得的交叉验证准确率:

accuracy 0.900 (0.032)

这里关键的不是模型,而是做事的程序,这对于所有表格问题都是标准的,无论你使用的是经典机器学习模型还是前沿的深度学习算法。Scikit-learn 完美地将这样的程序嵌入到其 API 中,从而证明了它是表格数据问题的多才多艺且不可或缺的工具。在接下来的章节中,我们将探讨其特性和工作原理,因为我们将在本书的示例中多次重用其程序。

4.1.1 Scikit-learn 包的常见特性

Scikit-learn 包的关键特性是

  • 它提供了广泛的分类和回归模型,以及聚类、降维、预处理和模型选择的功能。大多数模型在数据在计算机内存中处理时将在内存中工作,当数据无法适应内存并从磁盘访问时将在内存外工作,从而允许从超出可用计算机内存的数据中学习。

  • 在其模型范围内,它提供了一个一致的 API(类方法如fitpartial_fitpredictpredict_probatransform),这些 API 可以快速学习和重用,并且专注于模型从数据中学习和预测所必需的转换和处理过程。Scikit-learn 的 API 还提供了自动分离训练数据和测试数据的功能,能够在数据管道中链式使用其元素,并通过检查使用的类的公共属性来访问其参数。

  • 最初 Scikit-learn 专注于 NumPy 数组和无序矩阵,后来扩展到了 pandas DataFrame,使得实践者可以将它们作为输入使用。在后续版本中(自 1.1.3 版本起),您可以保留关键 DataFrame 特征,例如列名以及 Scikit-learn 函数和类所进行的转换。Scikit-learn 最近为 pandas DataFrame 提供的支持一直备受期待,确实对于本书的主题——表格数据,是至关重要的。

  • 要定义每个 Scikit-learn 类的工作参数,您只需使用标准的 Python 类型和类(字符串、浮点数、列表)。此外,所有此类参数的默认值已经设置为适当的值,以便您可以从一个基线开始创建,并在此基础上进行改进。

  • 感谢一群顶尖的贡献者(如 Andreas Mueller、Oliver Grisel、Fabian Pedregosa、Gael Varoquaux 和 Gilles Loupe),Scikit-learn 一直在持续发展中。团队不断进行调试,并会根据算法的稳健性和可扩展性,定期添加新功能和新模型,或淘汰旧模型。

  • 该包还提供了广泛且易于访问的文档,其中包含您可以在线咨询的示例(scikit-learn.org/stable/user_guide.html)或使用help()命令离线查看。

根据您的操作系统和安装偏好,如果您想安装 Scikit-learn,只需遵循scikit-learn.org/stable/install.html上的说明。与 pandas (pandas.pydata.org/)一起,Scikit-learn 是表格数据分析与建模的核心库。它提供了一系列专门针对结构化数据的机器学习和统计算法;实际上,输入必须是一个 pandas DataFrame、NumPy 数组或无序矩阵。这些算法都得到了良好的建立,因为 Scikit-learn 团队决定根据“自发表以来至少三年、200+引用、广泛使用和实用性”的标准将任何算法包含在包中。有关 Scikit-learn 中算法包含要求的更多详细信息,请参阅mng.bz/8OMw

4.1.2 常见 Scikit-learn 接口

Scikit-learn 的另一个关键特性是它非常适合表格数据问题,那就是它当前的估计器 API,即fit, predict/transform接口。这样的估计器 API 不仅限于 Scikit-learn,而且被广泛认为是处理训练和测试数据最有效的方法。许多其他项目都采用了它(见mng.bz/EaWO)。实际上,遵循 Scikit-learn API,你自动将数据科学项目中所有最佳实践融入其中。特别是,你严格区分训练数据、验证数据和测试数据,这是任何表格数据建模成功不可或缺的一步,我们将在下一节通过重新介绍 Airbnb NYC 数据集来展示这一点。

在深入探讨更多实际例子之前,我们提供一些关于 Scikit-learn 估计器的基础知识。首先,我们在 Scikit-learn 中区分四种类型的对象,每种对象都有不同的接口。一个类可以同时实现多个对象。估计器只是其中之一,尽管它们是最重要的,因为 Scikit-learn 的大多数类都是估计器。在下面的例子中,我们定义了一个机器学习估计器,即使用 Scikit-learn 提供的 LogisticRegression 类进行分类的逻辑回归(将在本章稍后讨论):

from sklearn.linear_model import LogisticRegression
model = LogisticRegression(C=1.0)

估计器是一个对象,它通过.fit方法从数据中学习。它可以应用于监督学习,将数据与目标相关联,或者应用于仅涉及数据的无监督学习:

  • 对于监督学习:estimator = estimator.fit(data, targets)

  • 对于无监督学习:estimator = estimator.fit(data)

在内部,估计器使用数据来估计一些参数,这些参数用于后续将数据映射回预测或转换它。在过程中收集的参数和其他信息作为对象属性提供。

Scikit-learn 的其他典型对象包括以下内容:

  • 转换器是一个对象,它专注于对数据进行转换:
transformed_data = transformer.transform(data)
  • 预测器是一个对象,它通过.predict方法(预测一般结果)和.predict_proba方法(预测概率)来映射给定数据的一些预测响应:
prediction = predictor.predict(data) 
probability = predictor.predict_proba(data)
  • 模型是一个对象,它通过.score方法提供一些数据的拟合优度,这在许多统计方法中很典型:
score = model.score(data)

无论你需要估计器还是转换器,每个类都是通过将其分配给一个变量并指定其参数来实例化的。

在幕后,所有这些类都存储着它们任务的参数。一些参数直接从数据中学习,通常被称为模型的权重或参数。你可以将这些视为数学公式中的系数:由数据和计算确定的未知值。其他的是用户在实例化时提供的,可以是配置或初始化设置,或者影响算法如何从数据中学习的参数。我们通常将后者称为超参数。它们往往根据机器学习模型的不同而有所不同;因此,当解释每个算法时,我们将讨论最重要的那些。

所有算法的配置和设置参数都是相似的。例如,random_state设置有助于定义一个随机种子,以便在用相同的数据使用模型时复制模型的确切行为。由于设置了随机种子,结果在不同的运行中不会改变。配置参数n_jobs将允许你设置在计算中想要使用的 CPU 处理器数量,从而加快模型完成工作所需的时间,但同时也防止你同时进行其他计算机操作。根据算法的不同,其他可用的类似设置可能定义模型使用的容限或内存缓存。

正如我们提到的,这些超参数中的一些影响模型的操作方式,而另一些则影响模型从数据中学习的方式。让我们回顾一下我们之前的例子:

from sklearn.linear_model import LogisticRegression
model = LogisticRegression(C=1.0)

在影响模型从数据中学习过程的超参数中,在我们的例子中,我们可以引用 C 参数,该参数通过取不同的值,指导机器学习算法在从数据中提炼模式时应用一些约束。当我们介绍每个机器学习算法时,我们将解决每个算法需要固定的所有参数。重要的是要注意,你通常在实例化类的时候设置超参数。

在类实例化之后,你通常提供用于学习的数据以及一些关于如何处理它的有限指令——例如,通过给每个数据实例赋予不同的权重。在这个阶段,我们说你在数据上训练或调整类的参数。这个阶段通常被称为“调整估计器”,是通过将数据作为 NumPy 数组、稀疏矩阵或 pandas DataFrame 传递给.fit方法来完成的:

X = [[-1, -1], [-2, -1], [1, 1], [2, 1]]
y = [1, 1, 0, 0]
model.fit(X, y)

由于训练模型需要将答案映射到某些数据上,.fit方法需要输入数据矩阵和答案向量。这种行为对于模型来说是典型的,因为 Scikit-learn 中的某些其他类也会输入数据。.fit方法在 Scikit-learn 中的所有转换类中也很常见。例如,仅对数据进行拟合是所有处理预处理的类的典型做法,正如你可以在mng.bz/N161中检查的那样,因为转换也需要从特征中学习一些信息。例如,如果你需要标准化数据,你必须首先学习数据中每个数值特征的均值和标准差。Scikit-learn 的 StandardScaler(mng.bz/DMgw)正是这样做的:

from sklearn.preprocessing import StandardScaler
processing = StandardScaler().fit(X)

在我们的例子中,我们实例化了用于标准化数据的类(StandardScaler),然后立即拟合数据本身。由于.fit方法返回用于拟合过程的实例化类,你可以通过结合这两个步骤安全地获取包含所有学习参数的类。这种做法在构建数据管道和训练模型时非常有用,因为它帮助你将学习数据中的某些活动与将所学内容应用于新数据的行为分开。这样,你就不会混淆训练、验证或测试数据中的信息。

根据底层操作的复杂性和提供的数据量,拟合模型或处理数据的函数可能需要一些时间。拟合完成后,将会有更多属性可供你使用,具体取决于你使用的算法。

对于一个训练好的模型,你可以通过应用.predict方法,基于任何新数据获得预测的响应向量。这既适用于分类问题,也适用于回归问题:

X_test = [[-1, 1], [2, -1]]
model.predict(X_test)

假设你正在处理一个分类任务;相反,你必须获取一个特定类别对新样本进行正确预测的概率。在这种情况下,你需要使用.predict_proba方法,这个方法仅适用于某些模型:

model.predict_proba(X_test)

处理数据的类没有.predict方法。然而,它们使用.transform方法,如果该类已经通过一些训练数据实例化和拟合以学习转换所需的关键参数,它将返回转换后的数据:

processing.transform(X)

由于转换通常应用于提供关键参数的相同数据,.fit_transform方法,它结合了拟合和转换两个阶段,将导致一个方便的快捷方式:

processing.fit_transform(X)

4.1.3 Scikit-learn 管道简介

你还可以使用 Scikit-learn 提供的实用函数,如将一系列转换和预测包装起来,选择性决定要转换的内容,并通过这些函数将不同的转换序列连接起来:

Pipeline 命令允许你创建一系列 Scikit-learn 类,这些类将导致数据的一系列转换,并且最终可以结束于一个模型及其预测。通过这种方式,你可以将任何模型与其所需的数据转换集成在一起,并一次性处理所有相关参数——转换的参数和模型本身的参数。Pipeline 命令是 Scikit-learn 包中将表格数据从源头移动到预测的核心命令。在实例化时设置它,你只需要提供一个包含步骤名称和要执行的 Scikit-learn 类或模型的元组的列表。一旦实例化,你可以按照 Scikit-learn 的常见 API 规范(fit,transform/predict)使用它。管道将按顺序执行所有预定义的步骤,返回最终结果。当然,你可以访问、检查和调整管道序列的单个步骤以获得更好的结果和性能,但你也可以将管道作为一个单独的宏命令来处理。

然而,表格列可能具有不同的类型,需要相当不同的转换序列,或者你可能已经设计了两种不同的数据处理方式,你希望将它们结合起来。ColumnTransformer 和 FeatureUnion 是 Scikit-learn 命令,可以帮助你在这种情况下。ColumnTransformer 允许你仅对某些列(你可以通过它们的名称或列序列中的位置索引来定义)应用特定的转换或转换序列。该命令接受一个元组的列表,就像 Pipeline 命令一样,但它需要一个转换的名称,一个执行它的 Scikit-learn 类,以及一个要应用转换的列名称或索引列表。由于它只是一个转换命令,其理想的使用方式是在管道内部,其中其转换可以是模型数据输入的一部分。FeatureUnion,相反,只是将两个不同管道的结果连接起来的简单方法。你可以使用简单的 NumPy 命令,如 np.hstack (mng.bz/rKJD) 来实现相同的结果。然而,当使用 FeatureUnion 时,你有优势,即该命令可以适应 Scikit-learn 管道,因此可以自动作为模型数据输入的一部分使用。

Scikit-learn 及其 Pipeline、ColumnTransformer 和 FeatureUnion 提供的操作模块化和 API 一致性将允许您轻松创建复杂的数据转换,将其作为一个单独的命令处理,从而使您的代码高度可读、紧凑且易于维护。在下一节中,我们将回到我们使用的 Airbnb 纽约市数据集。我们将创建一系列 Scikit-learn 中的转换序列,以展示 Scikit-learn 及其管道函数是如何正确处理您的表格数据问题的。我们还将指出,通过一个定义良好的管道,您如何轻松地在表格数据的机器学习不同选项之间切换。

4.2 探索和处理 Airbnb 纽约市数据集的特征

之前介绍的 Airbnb 纽约市数据集是演示目的的一个完美例子,因为它是一个代表现实世界问题的数据集,并且由于其各种类型的列。我们将不得不创建和组合不同的管道来处理不同的特征,接下来的章节将给我们一个机会来展示比本章中可以找到的更高级的处理技术。

目前,我们将我们将要处理的特征放入一个名为excluding_list的列表中。它们是需要特殊处理的特征,例如纬度和经度度数或最后评论的日期(last_review)。此外,数据集还展示了一些可能作为目标的列:价格、列出物业的可用性(availability_365)和评论数量(number_of_reviews)。就我们的目的而言,我们更倾向于使用价格。因为它是一个大于零的连续值集合,我们可以立即将其用作回归目标。此外,通过在平均值或中位数上进行拆分,或将值分箱到十分位,我们可以快速将价格变量转换为二元或多类分类目标。除了价格之外,我们使用所有其他特征作为预测特征或进行更高级的特征工程。

在以下子节中,我们将展示一种逐步探索数据集的方法,基于有用的列过滤数据集,并设置我们的目标变量。原则上,我们将遵循第二章中讨论探索性数据分析(EDA)时提供的提示和示例。在下一节中,我们将利用我们的发现,准备合适的数据管道,这些管道将在以下段落中修订表格数据的机器学习不同选项时被重用。

4.2.1 数据集探索

在探索数据集的第一步中,我们导入相关的包(NumPy 和 pandas),定义要排除的特征列表,以及基于我们在上一章中建立的知识,为分类和连续特征定义单独的列表,并从当前工作目录加载数据。要执行的代码是

import numpy as np
import pandas as pd
excluding_list = ['price', 'id', 'latitude', 
                  'longitude', 'host_id', 
                  'last_review', 'name', 
                  'host_name']                       ①
categorical = ['neighbourhood_group',
               'neighbourhood',
               'room_type']                          ②
continuous = ['minimum_nights',
              'number_of_reviews',
              'reviews_per_month',
              'Calculated_host_listings_count']      ③
data = pd.read_csv("./AB_NYC_2019.csv")

① 列出要排除在分析之外的列名

② 列出数据集中可能代表分类变量的列名

③ 列出数据集中代表连续数值变量的列名

一旦代码片段完成数据的加载,我们首先检查数据框中返回了多少行和列:

data.shape

我们将获得 48,895 行可用——对于一个表格问题来说是一个合理的数量,允许我们使用任何可用的学习算法——以及 16 列。由于我们只对一些列感兴趣——那些我们在名为分类和连续变量的变量中定义的列——我们首先根据它们具有的唯一值数量对分类特征进行低基数和高基数的分类:

data[categorical].nunique()

命令产生了以下输出:

neighbourhood_group      5
neighbourhood          221
room_type                3

我们处理分类特征的标准方法是对其应用独热编码,为原始特征中的每个唯一值创建一个二进制变量。然而,使用独热编码,具有超过 20 个唯一值的特征会导致数据集中列数过多和数据稀疏。当你的数据主要是零值时,你就有数据稀疏的问题,这是一个问题,特别是对于神经网络和通常对于在线算法来说,因为学习变得更加困难。在第六章中,我们将介绍处理具有过多唯一值特征的技术,这些特征被称为高基数分类特征。对于本章的示例,我们将区分低基数和高基数分类特征,并仅处理低基数特征:

low_card_categorical = ['neighbourhood_group', 'room_type']
high_card_categorical = ['neighbourhood']

接下来,在定义了这一点(目前,我们将只使用数值和低基数分类特征)之后,我们需要弄清楚我们的数据中是否存在任何缺失情况。以下命令要求标记真实缺失值,然后计算它们在特征中的数量:

data[low_card_categorical + continuous].isna().sum()

我们得到了以下结果,指出只有reviews_per_month特征存在问题:

neighbourhood_group                   0
room_type                             0
minimum_nights                        0
number_of_reviews                     0
reviews_per_month                 10052
calculated_host_listings_count        0
availability_365                      0

正如我们在第二章中提到的,处理缺失值不应该是一个自动化的过程;相反,它需要数据科学家对数据进行一些反思,以确定是否存在某些原因导致它们缺失。在这种情况下,很明显,数据源存在处理问题,因为如果你检查最小值,这将导致一个大于零的值:

data.reviews_per_month.min()

报告的最小值是 0.01。在这里,当没有足够的评论来制作统计数据时,我们会遇到一个缺失值。因此,我们可以用零值来替换这个特征的缺失值。在过滤了用于预测的特征并检查了缺失值之后,因为大多数机器学习算法在存在缺失输入数据的情况下无法工作,除了少数如梯度提升实现 XGBoost 或 LightGBM(将在下一章讨论)之外,我们可以继续检查我们的目标。EDA 的这一部分,即目标分析,通常被忽视,但它是相当重要的,因为在表格问题中,并非所有机器学习算法都能处理相同类型的目标。例如,具有许多零值、厚尾和多个众数的目标对于某些模型来说很难处理,并导致你的模型欠拟合。让我们先检查价格特征的分布。直方图,即绘制值落在值范围(称为箱)中的频率,对于了解你的数据如何分布特别有帮助。例如,直方图可以告诉你你的数据是否类似于已知的分布,如正态分布,或者突出显示在哪些值附近有峰值以及数据在哪里更密集(见图 4.1)。如果你使用的是 pandas DataFrame,可以通过调用hist方法来绘制直方图,该方法通过绘制值落在值范围(箱)中的频率来描述数据分布:

data[["price"]].hist(bins=10)

图 4.1 描述价格特征分布的直方图

图 4.1 中显示的分布极度向右倾斜,因为有许多异常值,因为绘制的值范围达到 10,000。然而,在 2,000 之前,很难区分任何表示频率的条形。通过绘制箱线图,这一点变得更加明显,箱线图是当一个人想要可视化一个变量的分布的核心部分所在位置时非常有用的工具。一个变量的箱线图是一个图表,其中分布的关键测量值被描绘为一个带有“胡须”的箱:两条延伸到变量分布预期极限之外的线条。箱由四分位数范围(IQR)界定,由第 25 和第 75 百分位数确定,并由中位数线分为两部分。胡须向上向下延伸到 IQR 的 1.5 倍。胡须边缘之上或之下的一切都被认为是异常值:一个不寻常或意外的值。让我们再次使用 pandas DataFrame 中内置的方法,即箱线图方法,绘制价格变量的箱线图(见图 4.2):

data[["price"]].boxplot()

图 4.2 一个箱线图,突出了价格特征的分布及其在大价格值上的右重尾

毫不奇怪,箱线和须线在图表的下半部分被挤压,几乎无法区分。箱线图上部的上限处延伸出一个长队列的异常值。这是一个明显的右偏分布案例。在这种情况下,一个标准的解决方案是使用对数变换来转换目标变量。通常的做法是添加一个常数以将值偏移到正数域,以处理零和以下的值。在我们的案例中,这是不必要的,因为所有值都是正数且大于零。在下面的代码片段中,我们通过应用对数变换来表示变换后的价格特征(参见图 4.3 和图 4.4):

np.log1p(data["price"]).hist(bins=20)
data[["price"]].apply(lambda x: np.log1p(x)).boxplot()

图片

图 4.3 对数变换后的价格特征的直方图,显示出更加对称

图片

图 4.4 对数变换后的价格特征的箱线图,显示出分布两端的极端值持续存在

现在,分布,由新的直方图和箱线图表示,更加对称,尽管很明显分布两侧都有异常观测值。由于我们的目标是说明性的,我们可以忽略原始分布,而专注于有意义的目标表示。例如,我们可以只保留低于 1,000 的价格值(参见图 4.5)。在下面的代码片段中,我们生成一个仅关注低于 1,000 的价格值的直方图:

data[["price"]][data.price <= 1000].hist(bins=20)

图片

图 4.5 对于低于 1,000 的价格特征的直方图,仍然显示出右偏的长尾

这里表示的分布仍然是右偏的,但它更类似于在电子商务或其他长尾产品销售中常见的分布。此外,如果我们关注 50 到 200 的范围,分布将看起来更加均匀(参见图 4.6)。在下面的代码片段中,我们进一步将焦点仅限于 50 到 200 之间的价格,并绘制相应的直方图:

data[["price"]][(data.price >= 50) & (data.price <= 200)].hist(bins=20)

图片

图 4.6 对于 50 到 200 之间的价格特征的直方图,显示出在整个范围内的分布值

因此,我们可以创建两个由布尔值组成的掩码变量,这可以帮助我们根据我们想要测试的算法类型来过滤目标。price_capped变量在演示某些机器学习算法如何轻松处理长尾时将非常有用:

price_capped = data.price <= 1000
price_window = (data.price >= 50) & (data.price <= 200)

图 4.7 显示了相对于上限价格箱线图,呈现右侧异常值,但至少箱线图是可见的。

图片

图 4.7 对于低于 1,000 的价格特征的箱线图,其右尾显示出极端值的长尾

图 4.8 显示了相对于窗口价格箱线图,没有显示出异常值:

data[["price"]][price_window].boxplot()

图片

图 4.8 展示了价格特征的箱线图,对于 50 到 200 之间的值,显示出略微右偏的分布,没有极端值

在完成我们对预测因子和目标以及一些基本特征选择的探索之后,我们准备四个不同的目标,这些目标将与我们的一些示例一起使用:

target_mean = (data["price"] > data["price"].mean()).astype(int)
target_median = (data["price"] > data["price"].median()).astype(int)
target_multiclass = pd.qcut(data["price"], q=5, labels=False)
target_regression = data["price"]

我们准备了两个二元目标,target_meantarget_median,以及一个基于百分位数的多类目标,有五个不同的类别,用于分类目的。

特别是,重要的是要注意我们的 target_median 是一个二元平衡目标。因此,我们可以安全地使用准确率作为良好的性能度量。作为测试,如果你尝试计数值,你会得到正负类别的案例几乎相等:

target_median.value_counts()

你会得到结果

0    24472
1    24423

相反,如果你尝试在 target_mean 目标变量上做同样的事情,你会得到

target_mean.value_counts()

你将获得一个不平衡的分布,倾向于负案例;也就是说,由于我们之前观察到的偏斜分布,均值以下的案例更多:

0    34016
1    14879

在这种情况下,当评估机器学习分类器的结果时,我们更倾向于使用如接收者操作特征曲线下面积(ROC-AUC)或平均精度等指标——两者都非常适合排序。最后,对于多类目标,计算五个类别中的每一个案例的数量显示,它们的分布也是平衡的:

target_multiclass.value_counts()

此命令返回以下结果

0    10063
1     9835
2     9804
3    10809
4     8384

对于回归目标,target_regression 是未经变换的原始目标。然而,我们将使用它的子集,并根据我们将要展示的机器学习算法相应地进行变换。

在完成我们对数据、目标和下一段中的一些基本特征选择的探索之后,我们将使用积木方法准备几个管道,以伴随我们对表格数据问题不同机器学习选项的发现。

4.2.2 管道准备

我们将使用之前看到的 Scikit-learn 中的 Pipeline 和 ColumnTransformer 类来准备管道。在积木方法中,我们首先创建应用于其他数据类型的不同操作,这些数据类型表征了表格数据集中的特征。

以下代码定义了三个核心过程,这些过程将在本章中多次重复使用:

  • 分类独热编码——分类特征被转换为二进制形式。如果一个值之前从未见过,它将被忽略。

  • 数值直接通过——使用零作为值来填充数值特征。

  • 数值标准化——在填充缺失值后,数值特征通过减去它们的平均值并除以它们的标准差进行缩放

定义这些过程的代码如下所示。

列表 4.2 设置表格学习管道的积木

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OneHotEncoder
from sklearn.preprocessing import OrdinalEncoder
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler

categorical_onehot_encoding = OneHotEncoder(
       handle_unknown='ignore')                       ①
numeric_passthrough = SimpleImputer(
       strategy="constant", fill_value=0)             ②
numeric_standardization = Pipeline([
       ("imputation", SimpleImputer(strategy="constant", fill_value=0)),
       ("standardizing", StandardScaler())
       ])                                             ③

① 将分类特征转换为独热编码格式

② 用零替换缺失的数值

③ 管道用零替换缺失的数值并标准化特征

在这个阶段,我们可以根据每个机器学习算法的需求,编写特定的转换管道来处理数据。例如,在这个例子中,我们设置了一个管道,该管道将低类别特征进行独热编码,并将数值特征的缺失值简单地填充为零。这样的管道是通过 ColumnTransformer 函数创建的,这是一个粘合函数,它同时结合了对不同特征集应用的操作。这是一种非常适合大多数机器学习模型的优秀转换策略:

column_transform = ColumnTransformer(
                [('categories', 
                  categorical_onehot_encoding, 
                  low_card_categorical),              ①
                 ('numeric', 
                  numeric_passthrough, 
                  continuous),                        ②
                ],
    remainder='drop',                                 ③
    verbose_feature_names_out=False,                  ④
    sparse_threshold=0.0                              ⑤
)

① 管道的第一步:独热编码类别特征

② 管道的第二步:处理数值特征

③ 管道未处理的特征将从结果中删除。

④ 特征的名称保持与原始名称相同。

⑤ 结果始终是一个密集矩阵(即 NumPy 数组)

我们可以立即运行此代码片段并检查该管道如何转换我们的 Airbnb NYC 数据:

X = column_transform.fit_transform(data)
print(type(X), X.dtype, X.shape)

结果是输出现在是一个由浮点数组成的 NumPy 数组,其形状增加到 13 列。实际上,由于独热编码,类别特征中的每个值都变成了一个单独的特征:

<class 'numpy.ndarray'> float64 (48895, 13)

下一节将探讨表格数据的主要机器学习技术。每个技术都将伴随其列转换类,这些类将被集成到包含模型的管道中。

4.3 经典机器学习

为了解释经典机器学习技术对于表格数据的差异,我们首先将介绍算法的核心特性,然后通过一个代码片段展示其工作,在参考的表格问题上,即 Airbnb NYC 数据集上运行。以下是我们将在示例中使用的一些最佳实践,以允许不同方法的可重复性和可比性:

  • 我们定义了一个包含数据转换和建模的管道。

  • 我们设置一个错误度量,例如回归的均方根误差(RMSE)或分类的准确度,并使用相同的交叉验证策略进行测量。

  • 我们报告交叉验证估计误差的平均值和标准差——这对于确定模型在不同数据样本上是否具有恒定的性能至关重要。

在上一节中,我们介绍了 Scikit-learn 提供的不同工具,用于构建集成特征处理和机器学习模型的管道。在本节中,我们将介绍推荐的评估指标以及 Scikit-learn cross_validate命令如何进行交叉验证估计。

让我们先回顾一下评估指标。我们决定使用 RMSE,这是回归任务中常用的一个度量,以及准确率,这是当类别具有大致相同的样本大小时,平衡二元和多类分类问题的另一个标准度量。在随后的章节中,我们还将使用适合不平衡分类问题的度量,例如 ROC-AUC 和平均精度。

交叉验证是数据科学中当您打算估计机器学习模型在除训练数据外,从相同数据分布中抽取的任何数据上的预期性能时的实际标准。需要注意的是,交叉验证基于这样的想法,即您的数据可能会在未来发生变化,但不会发生根本性的变化,来估计您模型未来的性能。为了正确工作,模型期望您未来将使用相同的特征,并且它们将具有相同的唯一值(如果是一个分类特征),具有相似的分布(对于分类和数值特征),最重要的是,特征将与您的目标变量保持相同的关系。

假设数据分布在未来将保持一致这一假设通常并不成立,因为在现实世界中,经济动态、消费者市场以及社会和政治情况变化非常快。在现实世界中,您的模型可能会遇到概念漂移,即特征与目标之间的建模关系不再代表现实。因此,当处理新数据时,您的模型将表现不佳。交叉验证是评估模型在创建时的最佳工具,因为它基于您当时可用的信息,并且因为,如果设计得当,它不会受到您的机器学习模型过度拟合训练数据的能力的影响。即使与未来的性能相比,交叉验证的结果被证明是错误的,其有用性仍然成立,通常是因为潜在的数据分布已经发生变化。此外,如留一法或自助法等替代方法,随着计算成本的提高,提供了更好的估计,而如训练/测试集分割等更直接的方法在估计上则不太可靠。

在其最简单的形式中,k 折交叉验证(在 Scikit-learn 中通过 KFold 函数实现:mng.bz/VVM0)基于将你的可用训练数据分成 k 个分区,并构建 k 个版本的你的模型,每次由不同的 k-1 个分区提供数据,然后在剩下的一个未使用的分区上进行测试(样本外性能)。k 个分数的平均值和标准差将提供一个估计及其不确定性水平的量化,用作对未来未见数据的预期性能的模型估计。图 4.9 展示了当 k 设置为 5 时的 k 折验证:每一行代表每个折叠中的数据分区。一个折叠的验证部分总是与其他的不同,而训练部分总是不同地组成。

图 4.9 五折交叉验证策略中数据在训练和验证之间的分布

设置 k 的正确值取决于你有多少可用训练数据,在它上训练模型需要多少计算成本,你收到的样本是否捕捉到了你想要建模的数据分布的所有可能变化,以及你打算为什么目的获取性能估计。作为一个一般性的经验法则,k 的值如 5 或 10 是最佳选择,其中 k = 10 更适合精确的性能评估,而 k = 5 是一个在模型、特征和超参数评估等活动中的精度和计算成本之间取得良好平衡的好值(因此它将用于我们的示例)。

为了对你的模型进行一般性能估计,你可以通过在 KFold 函数(或其变体,提供样本分层或对时间维度的控制:mng.bz/xKne)上进行一系列迭代来构建必要的交叉验证迭代,或者依赖 cross_validate 过程(mng.bz/AQyK),它会为你处理所有事情并只返回结果。对于测试不同算法的目的,cross_validate 非常方便,因为,给定适当的参数,它将生成一系列指标:

  • 交叉验证测试分数(样本外性能)

  • 交叉验证训练分数(样本内性能)

  • 适应时间和预测时间(用于评估计算成本)

  • 不同交叉验证折叠上的训练估计器

我们需要做的就是提供一个估计器,它可以是一个具有 fit 方法的 Scikit-learn 对象,包括预测器和目标,交叉验证策略,以及列表中的单个或多个评分函数。这个估计器应以可调用的形式提供,使用make_scorer命令创建(mng.bz/ZlAO)。在下一节中,我们将开始看到如何使用这些输入获取交叉验证的性能估计,从经典的机器学习算法如线性回归和逻辑回归开始。

4.3.1 线性回归和逻辑回归

在线性回归中,这是一种统计方法,通过拟合线性方程到观测数据来模拟因变量与一个或多个自变量之间的关系。你首先需要将所有特征转换为数值型,并将它们放入一个矩阵中,包括独热编码的类别特征。算法的目标是在列向量(系数)中找到最优的权重值,这样当乘以特征矩阵时,可以得到一个结果向量,最好地近似你的目标(预测)。换句话说,算法试图最小化目标与通过乘以权重向量得到的预测之间的残差平方和。在这个过程中,你可以考虑使用预测基线(所谓的截距或偏差)或对权重值施加约束,使它们只能是正数。

由于线性回归算法只是一个加权求和,你必须注意三个关键方面:

  • 确保没有缺失值,因为除非你将它们填充为某个值,否则它们不能用于乘法或加法运算。

  • 确保你已经处理了异常值,因为它们会影响算法在训练和预测中的工作。

  • 验证特征和目标尽可能线性相关(即,它们有良好的皮尔逊相关系数):与目标弱相关的特征往往会给模型增加噪声,并且它们往往会使其欠拟合,甚至在数量多时,甚至过拟合。

由于加权特征的求和给出了预测,因此很容易确定对预测输出影响最大的因素以及每个特征如何对其做出贡献。观察每个特征的系数可以让你了解算法的行为。这种理解在你必须向监管机构或利益相关者解释模型如何工作时非常有价值,以及当你想从假设或领域专家知识的角度检查预测是否合理时。

然而,回归模型在幕后如何工作的简单方式也存在隐藏的陷阱。当数据中的两个或多个特征高度相关时,这在统计学上被称为“多重共线性”,回归模型中的解释可能会变得非常复杂,即使这两个特征都有效地对预测做出了贡献。通常,只有一个许多中的显著系数,而其他则取很小的值,好像它们与目标无关。实际上,情况往往相反,理解特征在回归预测中的作用相对容易,这可能导致重要的概念误解。

线性回归算法的另一个巨大优势是,由于它只是些乘法和加法,因此在任何软件平台上实现它都非常容易,甚至可以通过在脚本中手动编码来实现。其他机器学习算法更复杂,因此比线性回归更复杂的算法从头开始实现可能会容易出错和出现 bug。然而,尽管手动编码任何机器学习模型对于交付项目来说是不切实际的,但我们必须指出,手动编码任何机器学习模型可以是一种宝贵的学习经历,让你更深入地理解算法的内部工作原理,并使你更有能力在未来解决和优化类似模型的性能。我们在第五章中展示了某些算法的一些可管理的从头开始实现,以供学习之用。

我们将从一个线性回归模型的例子开始,该模型从头到尾应用于我们的 Airbnb 纽约市数据。这个例子遵循图 4.10 中提出的方案,这个方案我们将为每个经典机器学习算法进行复制,它基于 Scikit-learn 的管道和交叉验证评估函数。

图片

图 4.10 经典机器学习算法示例组织方案

该方案相当线性。来自逗号分隔值文件的输入首先通过 ColumnTransformer,这是数据准备部分,它根据列名对数据进行转换、丢弃数据或让它原样通过,然后是一个机器学习模型。这两个部分都被一个由cross_validate函数测试的管道所包裹,该函数执行交叉验证并记录计算时间、训练模型和一定数量的折叠上的性能。最后,选择结果来展示模型是如何工作的。此外,我们可以通过管道访问模型系数和权重,以获得更多关于我们测试的算法功能的见解。

应用此架构,我们只需在列表 4.3 中使用纯线性回归模型,因为此算法通常不需要指定任何参数。对于与模型可解释性相关的特殊应用,你可以指定 fit_intercept 为 false 以从模型中移除截距并仅从特征中得出所有预测,或者将正参数设置为 true 以仅获得正系数。

列表 4.3 线性回归

from sklearn.linear_model import LinearRegression
from sklearn.metrics import make_scorer, mean_squared_error
from sklearn.model_selection import KFold
from sklearn.model_selection import cross_validate

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_passthrough, continuous)],
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)                                   ①

model = LinearRegression()                                  ②

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)]                                   ③
)

cv = KFold(5, shuffle=True, random_state=0)                 ④
rmse =  make_scorer(mean_squared_error, 
                    squared=False)                          ⑤

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data[price_window], 
                           y=target_regression[price_window],
                           scoring=rmse,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)           ⑥

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f}", 
      f"secs pred: {score_time:0.2f} secs")                 ⑦

① ColumnTransformer,将数据转换为数值特征并填充缺失数据

② 纯线性回归模型

③ 组装 ColumnTransformer 和模型的管道

④ 基于 5 折交叉验证和随机抽样的交叉验证策略

⑤ 从均方误差派生的评估指标函数

⑥ 自动交叉验证过程

⑦ 以评估指标、标准差、拟合度和预测时间为单位报告结果

运行列出的代码将产生以下 RMSE 结果:

33.949 (0.274) fit: 0.06 secs pred: 0.01 secs

这是一个很好的结果,在极短的时间内获得(使用标准的 Google Colab 实例或 Kaggle 笔记本),可以作为更复杂尝试的基准。例如,如果你尝试运行列表 4.4 中的代码,你会意识到你可以通过更少但准确准备的特征获得类似的结果。这被称为 特征工程,做这件事的有趣之处在于你可以获得更好的结果,或者用更少的特征获得相同的结果,这些特征对领域或商业专家来说更有意义。例如,我们在代码列表中通过生成与特定值相关的二进制特征、组合特征和使用对数函数转换它们来创建各种新的特征。

列表 4.4 线性回归的定制数据准备

data_2 = data[[]].copy()                                            ①
data_2['neighbourhood_group_Manhattan'] = ( 
   (data['neighbourhood_group']=='Manhattan')
   .astype(int))                                                    ②
data_2['neighbourhood_group_Queens'] = (

(data['neighbourhood_group']=='Queens').astype(int))                ③
data_2['room_type_Entire home/apt'] = (
                           (data['room_type']=='Entire 
home/apt').astype(int))                                             ④
data_2['minimum_nights_log'] = np.log1p(
                        data["minimum_nights"])                     ⑤
data_2['number_of_reviews_log'] = np.log1p(
                        data["number_of_reviews"])                  ⑥
label1 = 'neighbourhood_group_Manhattan*room_type_Entire home/apt'
data_2[label1] = (
   data_2['neighbourhood_group_Manhattan'] *
   data_2['room_type_Entire home/apt'])                             ⑦
label2 = 'availability_365*neighbourhood_group_Manhattan'
data_2[label2] = (data['availability_365'] *
   data_2['neighbourhood_group_Manhattan'])                         ⑧
label3 = 'availability_365*room_type_Entire home/apt'
data_2[label3] = (data['availability_365'] *
   data_2['room_type_Entire home/apt'])                             ⑨

rmse = make_scorer(mean_squared_error, squared=False)
cv = KFold(5, shuffle=True, random_state=0)

cv_scores = cross_validate(estimator=LinearRegression(), 
                           X=data_2[price_window], 
                           y=target_regression[price_window],
                           scoring=rmse,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
print(f"{mean_cv:0.5f}, {std_cv:0.5f}")

① 创建一个空的 DataFrame

② 一个二进制列,指示 'neighbourhood_group' 是否为 'Manhattan'

③ 一个二进制列,指示 'neighbourhood_group' 是否为 'Queens'

④ 一个二进制列,指示 'room_type' 是否为 'Entire home/apt'

⑤ 包含 'minimum_nights' 列中值自然对数加 1 的列

⑥ 包含 'number_of_reviews' 列中值自然对数加 1 的列

⑦ 二进制 'neighbourhood_group_Manhattan' 和 'room_type_Entire home/apt' 列的乘积

⑧ 'availability_365' 与二进制 'neighbourhood_group_Manhattan' 列的乘积

⑨ 'availability_365' 与二进制 'room_type_Entire home/apt' 列的乘积

结果 RMSE 为

33.937 (0.240)

尽管结果与之前的实验相当,但这次你使用的是具有更少特征的数据集,这些特征是通过特定的转换创建的,例如分类特征的 one-hot 编码,对数值特征应用特定的函数(即立方、平方、对数或平方根转换)以及通过将特征相乘。根据我们的经验,由合理的特征工程和领域专业知识生成的具有更少、更有意义的特征的模型通常更受商业用户接受,即使它的预测性能与纯粹的数据驱动模型相当甚至更差。

将特征相乘是一个仅在处理线性回归模型时才会遇到的操作;得到的结果被称为特征之间的交互。交互通过将两个或多个特征相乘得到一个新的特征来实现。所有这些对特征的转换都是为了尽可能地将每个特征与目标之间的关系线性化。好的结果可以通过自动方法或基于你对数据和问题的了解来获得。将此类转换应用于特征是线性回归模型家族的典型特征。它们对我们在本章和随后的章节中将要探索的更复杂的算法影响很小或没有影响。在定义特征应该如何表达上投入时间,是线性回归模型的一个优点也是一个缺点。然而,我们可以通过正则化自动执行它,正如我们将在下一节中提出的。

下一节将讨论线性模型(线性回归和逻辑回归)中的正则化。当你有很多特征并且它们的相互多重共线性(当你有两个预测变量高度相关时)不允许线性回归模型找到预测的最佳系数,因为它们不稳定且不可靠——例如,在符号和大小方面显示出你未曾预期的系数时,正则化是最佳解决方案。

4.3.2 正则化方法

线性回归模型通常足够简单,以至于人类可以直接将其理解为应用于特征的系数公式。这意味着,当应用于现实世界问题时,它们可能只是复杂动态的粗略近似,从而系统地错过正确的预测。技术上,它们是具有高偏差的模型。对此的补救措施是通过添加越来越多的特征及其转换(对数、平方、根转换等)以及通过使特征与许多其他特征(通过乘法)相互作用,使它们的公式更加复杂。这样,线性回归模型可以减少其偏差,成为一个更好的预测器。然而,与此同时,模型的方差也会增加,它可能会开始过度拟合。

奥卡姆剃刀原则,即认为在竞争假设中,应该选择假设最少的那个(mng.bz/RV40),对于线性模型来说效果完美,而对于应用于表格数据的神经网络来说则无关紧要,因为越复杂越好。因此,线性模型应该尽可能简单,以满足问题的需求。正是在这里,正则化登场,帮助你降低线性模型的复杂性,直到它适合问题。正则化是一种通过限制模型的复杂性来减少机器学习中的过拟合的技术,从而有效地提高其泛化性能。正则化之所以有效,是因为线性回归模型在寻找最佳预测系数时会受到惩罚。所使用的惩罚基于系数的求和。因此,回归模型被激励保持系数尽可能小,如果不是将其设置为零。将回归系数约束在限制其幅度有两个显著的影响:

  • 它避免了任何形式的数据记忆和过拟合(即,当特征数量远大于可用示例时,需要采取某些特定的系数值)。

  • 随着系数收缩的发生,估计值会稳定下来,因为多重共线性特征将调整其系数的值或集中在仅一个特征上。

在优化过程中,系数会多次更新,这些步骤被称为迭代。在每一步中,每个回归系数都会朝着其最优值进行修正。最优值由梯度决定,可以理解为表示在该步骤中极大改善系数方向的数字。更详细的解释将在本章结束。惩罚是一种约束形式,迫使模型优化得到的权重具有特定的特征。我们有两种正则化的变体:

  • 第一种变体是通过对系数的绝对值求和来计算惩罚:这被称为 L1 正则化。它使系数变得稀疏,因为它可以将某些系数推到零,使相关的特征变得无关紧要。

  • 第二种选项是通过求和系数的平方来计算惩罚:这被称为 L2 正则化,其效果通常是减小系数的大小(计算速度也相对较快)。

L1 正则化(或 Lasso 回归)将许多系数推向零值,从而隐式地选择了有用的特征(将系数设为零意味着该特征在预测中不起任何作用)。此外,系数总是以相同的强度推向零(技术上,指向解的梯度总是+1 或-1)。因此,通过优化步骤,与目标关联度较低的特征会迅速被分配一个零系数,并在预测中变得完全无关。简而言之,如果有两个或更多特征是多重共线的,并且都具有很强的预测性,通过应用 L1 正则化,你将只有一个特征的系数与零不同。

相反,在 L2 正则化(或 Ridge 回归)中,系数平方的事实阻止了负值和正值在惩罚中相互抵消,并使较大的系数具有更大的权重。结果是系数集通常较小,多重共线特征往往具有相似的系数值。所有涉及的特征都包含在求和中。你可以注意到更重要的特征,因为与标准回归不同,特征在预测中的作用不会被其与其他特征的关联所隐藏。L2 正则化倾向于衰减系数。它在优化步骤中按比例进行;技术上,指向解的梯度倾向于越来越小。因此,系数可以达到零值或接近零。尽管如此,即使特征必须与预测完全无关,也需要许多优化迭代,并且相当耗时。因此,在 L2 正则化中重新考虑两个或更多多重共线特征的先例,与只保留一个非零系数的 L1 回归不同,所有特征都将具有非零、相似大小的系数。

在我们的例子中,我们首先尝试通过我们可用特征之间的系统交互来创建新的特征,然后执行 L2 和 L1 惩罚回归来比较它们的结果和结果系数。PolynomialFeatures 是 Scikit-learn 函数(mng.bz/2ynd),它通过将特征与其他特征以及自身多次相乘来自动创建特征之间的乘积。这个过程让人联想到数学中的多项式展开,其中幂的和被表达为其单个项:

(a + b)² = a² + 2ab + b²

Scikit-learn 使这个过程变得更容易,因为当你声明一个度数时,函数会自动创建到该度数的多项式展开。你可以决定是否只保留交互项。这个过程对于回归模型来说很有趣,

  • 交互作用有助于回归模型更好地考虑更多特征的联合值,因为特征通常不会单独与目标相关,而是在与其他特征的协同作用下相关。

  • 一个特征的集合有助于将其建模为曲线。例如,a + a²是一个抛物线形状的曲线。

虽然使用多项式展开可以避免为您的特定问题创建特定特征的繁重任务,但它有一个缺点,因为它会显著增加模型使用的特征数量。更多的特征通常提供更多的预测能力,但它们也意味着更多的噪声、更多的多重共线性以及模型只是记住示例并过度拟合问题的更多机会。应用惩罚可以帮助我们使用 L2 惩罚解决这个问题,并使用 L1 惩罚选择要保留的特征。

在列表 4.5 中的代码中,我们测试了应用 L2 正则化,并在列表 4.6 中,依次应用 L1 正则化到相同的多项式展开。注意每种正则化的影响很重要。在这个第一个例子中,我们应用 L2 正则化(Ridge)。由于正则化在您有大量预测特征时才有意义,我们使用多项式展开从旧特征中创建新特征。然后,我们将 ridge 模型设置为高 alpha 值以处理增加的共线性特征数量。

列表 4.5 L2 正则化线性回归

from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import Ridge, Lasso

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_passthrough, continuous)],
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)

polynomial_expansion = PolynomialFeatures(degree=2)              ①

model = Ridge(alpha=2500.0)                                      ②

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('polynomial_expansion', polynomial_expansion),
     ('standardizing', numeric_standardization),
     ('modeling', model)]
)                                                                ③

cv = KFold(5, shuffle=True, random_state=0)
rmse =  make_scorer(mean_squared_error, squared=False)

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data[price_window], 
                           y=target_regression[price_window],
                           scoring=rmse,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)                ④

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f} secs pred: {score_time:0.2f} secs") ⑤

① 对特征进行二次多项式展开的 PolynomialFeatures 实例

② 一个具有正则化强度(alpha)为 2,500 的 Ridge 回归模型实例

③ 用于列转换、多项式展开、标准化和 Ridge 回归建模的管道

④ 使用定义的管道进行五折交叉验证并计算 RMSE 分数

⑤ 打印交叉验证测试 RMSE 分数的均值和标准差

脚本产生以下输出:

33.738 (0.275) fit: 0.13 secs pred: 0.03 secs

如果我们计算非零系数的数量(四舍五入到小数点后五位以排除极小的值),我们得到

(cv_scores['estimator'][0]['modeling'].coef_.round(5)!=0).sum()

在 105 个系数中有 91 个具有非零值。

在下一个例子中,我们应用 L1 正则化,并将结果与上一个例子进行比较。虽然程序与最后一个代码列表相同,但我们这次求助于 lasso 模型。

列表 4.6 L1 正则化线性回归

model = Lasso(alpha=0.1)                                         ①

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('polynomial_expansion', polynomial_expansion),
     ('standardizing', numeric_standardization),
     ('modeling', model)]
)                                                                ②

cv = KFold(5, shuffle=True, random_state=0)
rmse =  make_scorer(mean_squared_error, squared=False)

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data[price_window], 
                           y=target_regression[price_window],
                           scoring=rmse,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)                ③

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f} secs pred: {score_time:0.2f} secs") ④

① 一个具有正则化强度(alpha)为 0.1 的 Lasso 回归模型实例

② 应用列转换、多项式展开、标准化和 Lasso 回归建模的管道

③ 使用定义的管道进行五折交叉验证并计算 RMSE 分数

④ 打印交叉验证测试 RMSE 分数的均值和标准差

结果输出为

33.718 (0.269) fit: 0.64 secs pred: 0.03 secs

如果我们通过检查由交叉验证周期构建的第一个模型中的非零系数数量,这次我们得到的更少:

(cv_scores['estimator'][0]['modeling'].coef_.round(5) !=0).sum()

有 53 个非零系数,工作系数的数量已经减半。通过增加 Lasso 调用的 alpha 参数,我们可以获得使用系数的更大幅度的减少,尽管代价是更高的计算时间。在某个点上,应用更高的 L1 惩罚并不会改善预测结果。为了预测目的,你必须通过试错或使用方便的自动函数(如 LassoCV mng.bz/1XoV 或 RidgeCV mng.bz/Pdn9)来找到正确的 alpha,这些函数将为你进行实验。

有趣的是,正则化也被用于神经网络中。神经网络使用基于系数矩阵的顺序矩阵乘法从特征过渡到预测,这是线性回归工作的扩展。尽管神经网络有更多的复杂性;然而,在矩阵乘法的这个方面,它们与回归模型相似。基于类似的工作原理,你可能发现为你的表格数据问题拟合深度学习架构并在此过程中应用 L2 惩罚是有益的,这样网络的系数就会衰减并分布,或者应用 L1 惩罚,这样系数就会变得稀疏,其中许多被设置为 0。在下一节中,我们将继续讨论线性模型,通过发现如何解决分类问题来继续我们的讨论。

4.3.3 逻辑回归

线性回归模型可以有效地扩展到分类。在二元分类问题中,你拥有两个类别(一个正类和一个负类),你使用与回归相同的方法(特征矩阵、系数向量、偏置)。然而,你使用对数几率函数(关于这种统计分布的详细信息,请参阅mng.bz/JY20)来转换目标。这种转换函数被称为链接函数。在优化方面,算法使用伯努利条件分布(关于修订这种分布,请参阅mng.bz/wJoq)作为参考,而不是正态分布。因此,你得到从 0 到 1 的范围内的输出值,表示样本属于正类的概率。这被称为逻辑回归。逻辑回归是解决二元分类问题以及多类和多标签问题的相当直观和实用的方法。

在列表 4.7 中,我们复制了与线性回归相同的方法——这次尝试构建一个模型来猜测一个示例的目标值是否高于中位数。请注意,变换是相同的,尽管这次我们使用了逻辑回归模型。我们的目标是判断目标值是否高于中位数的类别。这样的目标是一个二元平衡结果,其中标签的一半是正的,一半是负的。

列表 4.7 逻辑回归

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import make_scorer, accuracy_scorehttps://mng.bz/JY20

accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = LogisticRegression(solver="saga",
                           penalty=None,
                           max_iter=1_000)                       ①

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_standardization, continuous)],
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)                                        ②

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)])                                       ③

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data, 
                           y=target_median,
                           scoring=accuracy,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)                ④

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f} secs pred: {score_time:0.2f} secs") ⑤

① 使用“saga”求解器、无惩罚和最多 1,000 次迭代的逻辑回归模型实例

② 应用独热编码到分类特征的列转换器,并对数值特征进行标准化

③ 依次应用列转换和逻辑回归建模的管道

④ 使用定义的管道进行五折交叉验证并计算准确率分数

⑤ 打印交叉验证测试准确率分数的均值和标准差

脚本产生以下分数:

0.821 (0.004) fit: 3.00 secs pred: 0.02 secs

由于特征处理相同,我们只需关注注意逻辑回归相对于线性回归有一些特定的参数。特别是,你可以直接设置惩罚而不改变算法,并决定将使用什么优化器(使用参数求解器)。每个优化器允许特定的惩罚,并且它可以根据你的数据特性更有效或更无效:

  • lbfgs 用于 L2 或无惩罚。

  • liblinear 用于 L1 和 L2 惩罚——更适合小型数据集,对于多类问题仅限于一对一方案。

  • newton-cg 用于 L2 或无惩罚。

  • newton-cholesky 用于 L2 或无惩罚。

  • saga 用于 L2 或无惩罚——非常适合大型数据集。它需要标准化特征(或所有特征都具有相似尺度/标准差)。

  • saga 无惩罚,L1,L2,弹性网络(L1 和 L2 惩罚的混合)——非常适合大型数据集,它需要标准化特征(或所有特征都具有相似尺度/标准差)。

在列表 4.8 中,我们使用 L2 惩罚对多类目标进行测试,以查看如何使用multi_class参数设置为“ovr”(一对一)轻松处理多个目标,这是一个将多类问题转换为为每个要预测的类构建二进制模型的解决方案。在预测时,所有类的预测概率被归一化以总和为 1.0,并取对应最高概率的类作为预测类。这种方法类似于神经网络中使用的 softmax 函数方法,其中任意实数值向量被转换为概率分布,其中所有元素的总和为 1(有关 softmax 的更详细解释,请参阅mng.bz/qxYw)。一对一方法的替代方案是多项式选项,其中单个回归模型直接对所有类的概率分布进行建模。

当类间关系很重要时(例如,用于排名或基于置信度的决策)或当需要紧凑的单模型解决方案时,多项式方法更受欢迎。

列表 4.8 L2 正则化的多类线性回归

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import make_scorer, accuracy_score

accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = LogisticRegression(penalty="l2", C=0.1, solver="sag", 
multi_class="ovr", max_iter=1_000)                               ①

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_standardization, continuous)],
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)                                        ②

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)])                                       ③

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data, 
                           y=target_multiclass,
                           scoring=accuracy,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)                ④

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f}",
      f"secs pred: {score_time:0.2f} secs")                      ⑤

① 使用 L2 惩罚、正则化 C=0.1、“sag”求解器和“ovr”多类策略的逻辑回归模型实例

② 应用独热编码到分类特征的列转换器,并对数值特征进行标准化

③ 依次应用列转换和逻辑回归建模的管道

④ 使用定义的管道进行交叉验证并计算准确度分数

⑤ 打印交叉验证测试准确度分数的平均值和标准差

将目标预测为类别肯定比猜测目标价格是否超过阈值更复杂:

0.435 (0.002) fit: 31.08 secs pred: 0.02 secs

从这个输出中,重要的是要注意交叉验证折叠的训练时间已经飙升了 10 倍。原因是应用惩罚系数涉及到算法优化过程更多的迭代,以达到一个稳定的结果,并且现在正在为每个类别构建一个模型。一般来说,考虑惩罚需要更长的 L2 惩罚计算时间,甚至 L1 惩罚需要更长的时间。通过设置max_iter参数,你可以对算法的迭代次数进行限制,但请注意,通过截断算法收敛所需的时间所得到的结果不一定是最优的。

4.3.4 广义线性方法

通过对数变换将线性回归扩展到二元分类的想法可以应用于除了伯努利条件分布之外的分布。这是由目标决定的,目标可能代表分类数据、计数数据或其他已知分布不是正态分布的数据。正如我们在上一段中看到的,多类问题可以使用伯努利分布(多个逻辑回归的 one-versus-rest 策略)和多项式分布进行建模。其他问题,如金融或保险等领域的典型问题,需要不同的方法。例如,Scikit-learn 包提到了一些现实世界的应用及其最佳拟合分布(参考,见mng.bz/7py9):

  • 气候建模—每年降雨事件的数量(计数数据的泊松分布和离散事件)。泊松分布用于建模事件,如呼叫中心的呼叫次数或餐厅的顾客数量),每次事件的降雨量(使用伽马分布,一个具有偏斜和长尾的理论分布,对于建模很有用),或每年的总降雨量(Tweedie 分布,这是一个泊松分布和伽马分布的复合分布)。

  • 风险建模或保险政策定价—每年索赔事件的数量或保单持有人数量(泊松分布),每次事件的成本(伽马分布),每年每个保单持有人的总成本(Tweedie 分布)。

  • 预测性维护—每年生产中断事件的数量(泊松分布),中断的持续时间(伽马分布),以及每年的总中断时间(Tweedie 分布)。

图 4.11 显示了三种分布——泊松、Tweedie 和伽马——在不同平均值下的情况。Tweedie 分布的幂等于 1.5,是泊松和伽马分布的混合。

图 4.11 比较泊松、Tweedie 和伽马分布在不同的分布平均值(mu)下

当然,你可以尝试任何你想要的分布——甚至是一个普通的回归模型——对于任何这种情况。然而,使用适合优化特定分布的适当广义线性模型来处理每个分布,通常能保证最佳结果。

我们不深入探讨每种分布的细节;你只需要知道,通用线性模型的瑞士军刀是TweedieRegressor (mng.bz/mGOr)。这个 Scikit-learn 实现,根据幂参数的不同,可以让你快速测试正态分布(常规回归)、泊松分布 (mng.bz/4a4w)、伽马分布 (mng.bz/QDvG)、逆高斯分布(用于非负正偏斜数据),以及伽马和泊松的混合(Tweedie 分布)(见表 4.1)。

表 4.1 幂值及其对应的统计分布

功率 分布
0 正态
1 泊松
(1,2) 复合泊松伽马
2 伽马
3 逆高斯

在列表 4.9 中,我们测试了 TweedieRegressor 提供的不同分布在整个 Airbnb 纽约市数据集价格分布上的效果,这是一个我们之前因为 EDA 显示的重尾分布而避免的模型拟合。我们通过逐个测试这些分布在整个价格值范围内来完成,因为我们相信使用这种专业分布将解决我们的目标重尾问题。重要的是要记住,由于它们的公式,这些分布存在局限性:

  • 正常——任何类型的值

  • 泊松——零或正值

  • Tweedie、伽马、逆高斯——只有非零正值

这意味着,如果你有负值或零值,你必须通过添加偏移值来调整你的数据。因此,根据所建模的分布,我们根据上述限制将目标值裁剪到下限。

列表 4.9 Tweedie 回归

from sklearn.linear_model import TweedieRegressor
from sklearn.metrics import make_scorer, mean_squared_error
from sklearn.model_selection import KFold
from sklearn.model_selection import cross_validate

experiments = [
     ['normal', 0, float('-inf')],
     ['poisson', 1, 0.0],
     ['tweedie', 1.5, 0.1],
     ['gamma', 2, 0.1],
     ['inverse gaussian', 3, 0.1]]                           ①

for experiment, power, min_val in experiments:               ②

    column_transform = ColumnTransformer(
        [('categories', categorical_onehot_encoding, low_card_categorical),
         ('numeric', numeric_standardization, continuous)],
        remainder='drop',
        verbose_feature_names_out=False,
        sparse_threshold=0.0)

    model = TweedieRegressor(power=power, 
                             max_iter=1_000)                 ③

    model_pipeline = Pipeline(
        [('processing', column_transform),
         ('modeling', model)])

    cv = KFold(5, shuffle=True, random_state=0)
    rmse =  make_scorer(mean_squared_error, squared=False)

    cv_scores = cross_validate(estimator=model_pipeline, 
                   X=data, 
                            y=target_regression.clip(
                                   lower=min_val),           ④
                               scoring=rmse,
                               cv=cv, 
                               return_train_score=True,
                               return_estimator=True)

    mean_cv = np.mean(cv_scores['test_score'])
    std_cv = np.std(cv_scores['test_score'])
    fit_time = np.mean(cv_scores['fit_time'])
    score_time = np.mean(cv_scores['score_time'])
    print(f"{experiment:18}: {mean_cv:0.3f} ({std_cv:0.3f})", 
          f"fit: {fit_time:0.2f}",
          f"secs pred: {score_time:0.2f} secs")              ⑤

① 一个由分布名称、幂参数和最小目标值组成的实验列表

② 遍历实验列表中的分布名称和幂参数

③ 当前实验指定幂参数的 TweedieRegressor 模型实例

④ 根据使用的分布将目标回归数据裁剪到最小值

⑤ 打印实验名称以及交叉验证的结果

结果的最佳拟合是泊松和幂为 1.5 的 Tweedie 分布:

normal            : 233.858 (15.826) fit: 0.13 secs pred: 0.03 secs
poisson           : 229.189 (16.075) fit: 0.66 secs pred: 0.03 secs
tweedie           : 229.607 (16.047) fit: 0.46 secs pred: 0.03 secs
gamma             : 233.991 (15.828) fit: 0.22 secs pred: 0.03 secs
inverse gaussian  : 239.577 (15.453) fit: 0.18 secs pred: 0.03 secs

记住,广义线性模型性能的秘密在于它们在优化阶段努力模拟的特定分布。当面临类似问题时,我们可以在比广义线性模型更先进的算法上使用类似的分布,尤其是在梯度提升实现上,如 XGBoost 或 LightGBM,这些将在下一章中讨论。在下一节中,我们将处理与大数据集相关的一种不同方法。

4.3.5 使用随机梯度下降处理大型数据集

当你的表格数据集无法适应你的系统内存时,无论是云实例还是你的台式电脑,你在建模方面的选择就会减少。除了将在本书第三部分讨论的深度学习解决方案之外,另一个选择,即使用经典机器学习,是求助于离核学习。在离核学习中,你将数据保留在其存储中(例如,你的数据仓库),并且让模型逐步从中学习,使用从你的数据中提取的小样本,称为批次。这在实际中是可行的,因为现代数据存储允许以一定的延迟成本选择特定的数据样本:即从数据相关操作开始到完成或响应的时间间隔。此外,还有处理和实时处理数据的工具(例如,Apache Kafka 或 Amazon Kinetics),可以将数据重定向到离核学习算法。

这也是由于线性/逻辑回归模型算法上可行的。这两个模型都是由你用于学习的特征的相关系数的加法组成的。离核学习首先使用数据的一些小样本来估计这些系数,然后使用从你的数据中提取的越来越多的批次来更新这些系数。最终,尽管这个过程特别长,但你最终估计的系数不会与你能够将所有数据拟合到内存中时获得的系数有很大不同。

对于你的离核建模需要使用多少这样的批次,以及你是否需要多次重复使用它们,这是一个经验实验的问题:它取决于问题和你要使用的数据。尽管提供新的未见数据批次可能会简单地延长你的训练阶段,但让算法再次看到相同的批次可能会导致它过拟合。不幸的是,在大多数情况下,你需要多次重复相同的批次,因为离核学习不像优化那样简单;它需要很长时间,即使我们谈论的是大量数据,你也可能需要在同一数据上做更多的遍历。幸运的是,你可以依靠正则化技术,如 L1 和 L2 正则化,来避免过拟合。

在列表 4.10 中,我们重新审视了我们的逻辑回归示例,并将其转换为离线处理。首先,我们将数据分割成训练集和测试集,因为在使用离线学习策略时创建交叉验证过程很复杂。在实际的离线学习设置中,交叉验证不仅复杂,而且往往不可行,因为在这样的设置中,你通常只处理一次示例。毕竟,它们是从源中流出的,并且通常被丢弃。通常的验证策略是收集一个用于测试目的的示例列表,或者使用每 n 个中的一个批次作为离样本测试批次。在我们的例子中,我们更喜欢保留一个测试集。

列表 4.10 带 L2 正则化的离线逻辑回归

from sklearn.linear_model import SGDClassifier
from sklearn.model_selection import train_test_split
from sklearn.utils import gen_batches
from sklearn.metrics import accuracy_score

def generate_batches(X, 
                     y, 
                     batch_size, 
                     random_state):                                 ①
    """split dataset into batches """
    examples = len(X)
    batches = gen_batches(n=examples, 
                          batch_size=batch_size)                    ②
    sequence = np.arange(examples)
    if random_state:
        np.random.seed(random_state)                                ③
        np.random.shuffle(sequence)

    for batch in batches:
        items = sequence[batch]
        yield(X.iloc[items], y.iloc[items])                         ④

model = SGDClassifier(loss="log_loss", 
                      average=True,
                      penalty='l2', 
                      alpha=0.001)                                  ⑤

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_standardization, continuous)], 
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)

X_train, X_test, y_train, y_test = train_test_split(data, target_median, 
                                                    test_size=0.20, 
                                                    random_state=0) ⑥
iterations = 10
for j in range(iterations):                                         ⑦
    generator = generate_batches(X_train, y_train, batch_size=256, 
random_state=j)
    for k, (Xt, yt) in enumerate(generator):
        if k == 0:
            column_transform.fit(Xt)
                    Xt = column_transform.transform(Xt)
        if k == 0:
            model.partial_fit(Xt, yt, classes=(0, 1))               ⑧
        else:
            model.partial_fit(Xt, yt)                               ⑨

predictions = model.predict(column_transform.transform(X_test)) 
score = accuracy_score(y_true=y_test, y_pred=predictions)
print(f"Accuracy on test set: {score:0.3f}")                        ⑩

① 定义一个函数以生成用于训练的数据批次

② 生成用于处理的数据索引批次

③ 如果提供了随机状态,则对示例序列进行洗牌

④ 生成输入特征及其对应标签的批次

⑤ 创建一个具有逻辑损失、平均、L2 惩罚和 alpha 正则化的 SGDClassifier 模型实例

⑥ 使用 80-20 的比例将数据集和目标划分为训练集和测试集

⑦ 遍历训练数据批次,在第一个批次上拟合列转换器

⑧ 使用部分拟合在第一个批次上训练模型,指定类别

⑨ 使用部分拟合在后续批次上进一步训练模型

⑩ 打印测试数据预测的准确度得分

然后将训练数据分割成多个批次,并将每个批次提交给随机梯度下降(SGD)算法进行学习。SGD 不是一个独立的算法,而是线性模型的优化过程,通过迭代地从数据的小批次或甚至单独的示例中学习模型权重来优化模型权重。它基于梯度下降优化过程,也用于深度学习。梯度下降从模型权重的初始猜测开始,计算误差。下一步涉及计算误差的梯度,该梯度是通过取误差相对于模型权重的偏导数的向量的负值获得的。由于梯度可以解释为在误差表面上取最陡下降,梯度下降的一个常见例子就是将其想象为从山的高处沿着最陡的下坡路下降到最低的山谷。在这个类比中,“山脉”代表误差表面,“最低的山谷”代表误差函数的最小值。图 4.12 通过从随机的高点逐渐下降到碗形误差曲线的最低点来直观地表示这个过程。

除了类比之外,重要的是要记住,梯度决定了在这一步应该如何调整权重以减少误差。通过重复迭代,可以通过调整模型的权重来最小化误差。然而,权重的更新方式可能会显著影响结果。如果更新过大且果断,算法可能会采取过宽的步骤,可能导致模型超过目标并爬上误差曲线。在最坏的情况下,这可能导致误差持续恶化,没有恢复的可能。相反,采取较小的步骤通常更安全,但可能计算负担较重。这种步骤的大小由学习率决定,这是一个调节更新如何进行的参数。

图片

图 4.12 在简单优化景观中的梯度下降优化过程

线性模型可以通过梯度下降法轻松优化,因为它们的误差表面简单且呈碗状。然而,像梯度提升(将在下一章讨论)和深度学习架构等更复杂的模型,由于它们的复杂性更高,参数相互关联且误差景观更复杂,可能在优化过程中遇到挑战。根据起始点,如图 4.13 所示,这些模型在优化过程中可能会陷入局部最小值或平台期,导致次优结果。

图片

图 4.13 在复杂误差景观中的梯度下降,展示了局部最小值和平台期如何导致次优解

使用 Scikit-learn 的partial_fit方法可以通过 SGD 学习线性模型,该方法在经过有信息量的起始(算法需要知道目标标签)后,可以通过逐批部分拟合来学习。相同的程序会重复多次,称为迭代或周期,以巩固和改进学习,尽管重复相同的例子太多次也可能导致过拟合。算法会看到相同的例子,尽管顺序不同,每次都会更新其系数。为了避免系数发生突然变化,这在批次中出现异常值时经常发生,更新的系数不会替换现有的系数。相反,它们会被平均在一起,允许更渐进的过渡。

完成所有学习过程后,你将得到以下结果:

Accuracy on test set: 0.818

结果与核心学习逻辑回归相当。虽然离核学习仅限于最简单的机器学习算法,如线性或逻辑回归,但当样本太多无法装入内存时,它是一种在表格数据上训练的有效方法。所有深度学习解决方案也使用批次的流式处理思想,这将在关于表格数据的深度神经网络方法章节中再次讨论,包括如早期停止这样的策略,这是一种在必要时中断数据迭代的迭代技术,以避免由于算法对先前迭代中看到的示例过度暴露而导致数据过拟合。

我们现在可以预见,这种学习策略的基本配方是示例顺序的随机化。由于优化是渐进的,如果你的数据以特定的方式排序,它将导致偏差优化,这可能导致次优学习。以相同顺序重复相同的批次可能会对你的结果产生负面影响。因此,随机化顺序对于训练更好的算法至关重要。然而,另一个与随机梯度下降(SGD)相关的重要点是数据准备阶段。在这个阶段,你应该包括所有特征缩放操作,因为优化过程对特征的规模敏感,以及所有特征工程和特征交互计算,并且尽可能将其设置为确定性,因为当你的数据被分成多个批次时,使用全局参数,如特征的最大/最小值或平均值和标准差可能很困难。

4.3.6 选择你的算法

作为一般规则,你应该首先考虑机器学习算法根据你拥有的行数和列数以不同的方式缩放。从可用的行数开始,当操作大约或少于 10²行数据时,你必须严格使用基于规则或基于统计的简单算法。对于多达 10³行,基于线性组合的模型,如线性回归和逻辑回归,是最适合的,因为它们往往不会过拟合少量数据。从大约 10³行到 10⁴-10⁵行,通常无法判断哪种算法效果更好。因此,这完全取决于测试和实验。在这里,深度学习解决方案只有在存在可以利用的结构时才能超越其他选择,例如有序的信息系列或层次结构。多达 10⁹行,梯度提升家族的解决方案可能是最有效的。再次强调,你可能会发现,对于特定问题,如广告行业,其中你需要估计许多固定的交互,例如显示设备、网站和广告之间的交互,离核学习可能是一个更好的解决方案。

核外学习是指某些机器学习算法在从数据学习时可以采用的学习策略:不是一次性地从数据中学习,而是从较小的数据样本、批次或甚至从单个示例中一点一点地学习,这也被称作在线学习。最后,根据我们的经验,在数据集超过 10⁹行的情况下,深度学习解决方案和一些核外学习算法往往表现更好,因为它们可以有效地处理如此大量的数据,而其他机器学习算法可能被迫从数据的子样本中学习或找到其他次优解。

关于列,我们发现一些算法需要更好地与具有多个列的数据集进行缩放,特别是如果它们呈现稀疏信息——也就是说,许多二元特征。数据集越稀疏,可以通过数据集中总数值中零值的百分比来衡量,你可能就越早需要应用在线学习算法或深度学习。

然而,除了可扩展性原因,这与内存和计算复杂度相关之外,每个机器学习解决方案在模型控制、开放性和解决方案的可理解性方面也满足不同的需求。因此,表格问题中的需求多样性和机器学习中的模型多样性反驳了“一个最好的算法就足够了”的观念,这个观念是你工作所需的全部。换句话说,你之所以需要尝试更多的机器学习模型,不仅仅是因为“没有免费的午餐”,正如大卫·沃尔珀特和威廉·麦克雷德提出的著名定理所述(更多详情请见www.no-free-lunch.org)。出乎意料的是,往往存在一些情况下,劣势算法意外地击败了同类最佳算法。需要更多算法的原因主要是,当你作为一个工匠/艺术家从不同的角度看待你的问题时,你可能需要不同的工具来完成这项任务。

在下一章中,我们将介绍一类更强大的机器学习算法,即集成算法,最后,梯度提升家族及其成功且流行的实现,如 XGBoost 和 LightGBM。

摘要

  • 确定机器学习算法涉及多个因素:示例和特征的数量、预期的性能、预测速度和可解释性。作为一个一般性的规则,

    • 统计机器学习适用于案例数量较少的数据集。

    • 经典机器学习适用于具有适度案例数量的数据集。

    • 梯度提升算法对于具有适度到大量案例的数据集特别有效。

    • 对于大量数据的数据集,深度学习解决方案是最可行和最有效的。

  • Scikit-learn 是一个开源的机器学习库,提供了广泛的分类和回归模型,以及聚类、降维、预处理和模型选择等功能。我们可以将其核心优势总结如下:

    • 模型间一致的 API

    • 支持内存和离核学习

    • 支持与 pandas DataFrame 一起工作

    • 适用于表格问题

    • 易于安装

    • 丰富的文档

  • 线性回归是转换为数值(对于分类特征使用 one-hot 编码)的加权特征的加权和:

    • 算法找到最优的权重值(系数),以最小化目标值和预测值之间的残差平方和。

    • 线性回归易于解释和理解每个特征如何对最终结果产生影响。

    • 特征之间的高相关性(多重共线性)可能导致概念上的误解。

    • 线性回归在计算上简单且易于实现。

    • 线性回归在拟合具有非线性数据的复杂问题方面有限,除非在事先通过特征工程(如创建多项式特征)仔细准备特征,否则无法捕捉非线性关系。

  • 正则化通过降低回归模型的复杂性和提高其泛化性能来防止过拟合。有两种类型的正则化:

    • L1 正则化(或 Lasso 回归)将许多系数推向零值,从而使得模型中的一些特征变得无关紧要。

    • L2 正则化通常减小系数的大小。

    • L1 正则化对于特征选择有帮助,而 L2 正则化在许多特征的情况下减少过拟合,同时计算速度更快。

  • 线性回归可以通过使用 logit 函数转换目标值和伯努利条件分布来扩展到分类问题,从而优化算法。这导致了一个逻辑回归模型,可用于二元分类、多类和多标签问题。逻辑回归易于实现和理解,但与线性回归具有相同的局限性。

  • 将目标值转换的方法也可以应用于其他分布,例如泊松和伽马分布,具体取决于数据的性质。由此产生的广义线性模型可用于各种现实世界的应用,如气候建模、风险评估和预测性维护。然而,需要注意的是,如果没有正确理解每个情况下所应用的特定分布,结果可能不是最优的。

第五章:决策树和梯度提升

本章涵盖

  • 决策树及其集成

  • 梯度提升决策树

  • Scikit-learn 的梯度提升决策树选项

  • XGBoost 算法及其创新

  • LightGBM 算法的工作原理

到目前为止,我们已经探讨了基于线性模型的机器学习算法,因为它们可以处理来自只有几行几列的数据集的表格问题,并找到一种方法来扩展到有数百万行和许多列的问题。此外,线性模型训练和获取预测的速度快。此外,它们相对容易理解、解释和调整。线性模型还有助于我们理解本书中将要构建的许多概念,例如 L1 和 L2 正则化和梯度下降。

本章将讨论一种不同的经典机器学习算法:决策树。决策树是随机森林和提升等集成模型的基础。我们将特别关注一种机器学习集成算法——梯度提升,以及其实现 eXtreme Gradient Boosting (XGBoost)和 Light Gradient Boosted Machines (LightGBM),它们被认为是表格数据的最佳解决方案。

5.1 树方法简介

树模型是一系列不同种类的集成算法,由于性能良好和数据预处理要求低,因此是处理表格数据的首选方法。集成算法是一组机器学习模型,它们共同贡献于单个预测。所有基于树的集成模型都是基于决策树,这是一种自 20 世纪 60 年代以来流行的算法。决策树背后的基本思想,无论它们是用于分类还是回归,都是你可以将训练集分割成子集,在这些子集中,你的预测更有利,因为有一个占主导地位的目标类(在分类问题中)或者目标值的变异性降低(即它们都非常接近;这指的是回归问题)。

图 5.1 展示了构成决策树的关键元素方案。决策树试图解决的问题是根据腿和眼睛的数量对动物进行分类。你从树的根开始,这对应于你拥有的整个数据集,并设置一个分割条件。条件通常是真/假——所谓的二分分割。尽管如此,一些决策树的变体允许在同一个节点应用多个条件,从而产生多个分割,每个分割基于特征的不同值或标签来决定。每个分支都导向另一个节点,在那里可以应用新的条件,或者导向一个终端节点,该节点用于基于终止于此的实例进行预测。

图片

图 5.1 构成决策树的关键元素,如根、分支和叶子,根据腿和眼睛的数量对动物进行分类

分割是基于算法在特征中进行的故意搜索,以及在特征内部,其观察到的值。在分类问题中,决策树算法寻找最佳特征和特征值组合,将数据分割成具有同质目标的子集。在分类问题中,子集中目标的同质性通常使用熵、信息增益或基尼不纯度等标准来衡量:

  • 衡量子集中标签分布的无序度或随机度。

  • 信息增益,从熵中衍生出来,衡量通过基于特定特征的分割数据来减少关于数据类别标签的不确定性。

  • 基尼不纯度衡量在子集中随机选择一个元素被随机标记为子集中标签分布的概率。

如果决策树用于回归,它将采用与分类不同的分割标准。在回归中,目标是分割数据以最小化结果均方误差、平均绝对误差或简单地每个子集中目标变量的方差。在训练过程中,会自动选择最佳特征,决策树的大部分计算都是为了确定最佳特征分割。然而,一旦树构建完成,预测新数据的类别标签或目标值相对快速且直接,涉及从根开始遍历树,根据有限特征集的值结束于叶子。

决策树易于计算,也相对容易可视化。它们不需要缩放或建模非线性,或者以其他方式转换你的特征或输出目标,因为它们可以单独考虑其分布的单个部分来近似目标与预测变量之间的任何非线性关系。基本上,它们是将曲线切割成部分,使得每一部分看起来像一条线。另一方面,决策树容易过拟合,最终导致过多的分割来拟合你正在工作的训练数据。随着时间的推移,已经设计了不同的策略来避免过拟合:

  • 限制树中的分割数量

  • 在构建后,通过剪枝分割节点来减少它们的过拟合

图 5.2 展示了决策树的不同视角。图 5.1 基于两个特征将树可视化为一个图,而图 5.2 则从数据本身分区的角度来可视化决策树。树的每个分割在图表中都是一条线,对于这个树,有七条垂直线(因此是 x 轴上特征的二进制条件的结果)和三条水平线(因此是 y 轴上的特征),总共 10 个分割。你可以认为决策树是成功的,因为每个类别都很好地分离到其分区中(每个分区最终都是一个终端节点)。然而,通过观察,也变得明显的是,某些分区只是为了适应空间中某个位置上的示例而被划分出来。有几个分区只包含一个单独的案例。任何新的示例如果与训练分布不完全匹配(一个过拟合的情况),都有可能被错误分类。

图片

图 5.2 如何将一个完全成长的决策树分支也解释为一系列数据集分割

图 5.3 展示了使用更少的分割来可视化相同的问题——每个特征两个分割。你可以通过向后剪枝之前的树分割,移除包含训练样本过少的那些分割,或者你通过一开始就限制树的成长来实现这一点——例如,通过施加最大分割数。如果你使用更少的分区,树可能不会像以前那样完美地拟合训练数据。然而,一个更简单的方法提供了更多的信心,即新实例很可能会被正确分类,因为解决方案明确依赖于训练集中的单个点。

图片

图 5.3 通过剪枝或限制其增长获得的一个更简单的决策树处理的问题

从欠拟合和过拟合的角度考虑这个算法,它是一个高方差算法,因为其复杂性总是倾向于超过给定的问题和数据应有的复杂度。通过调整很难找到它的最佳点。实际上,使用决策树实现更准确预测的最好方法不是作为单一模型,而是作为模型集合的一部分。在接下来的小节中,我们将探讨集合方法,如 bagging、随机森林和基于决策树的梯度提升,这是一种高级方法。

在本章中,我们将回到 Airbnb 纽约市数据集,以说明核心梯度提升决策树实现以及该技术是如何工作的。以下列表中的代码重新审视了之前用来展示其他经典机器学习算法的数据和一些关键函数和类。

列表 5.1 重新审视 Airbnb 纽约市数据集

import numpy as np
import pandas as pd
from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer

data = pd.read_csv("./AB_NYC_2019.csv")
excluding_list = ['price', 'id', 'latitude', 'longitude', 'host_id', 
                  'last_review', 'name', 'host_name']              ①
low_card_categorical = ['neighbourhood_group',
   _    _               'room_type']                               ②
high_card_categorical = ['neighbourhood']                          ③
continuous = ['minimum_nights', 'number_of_reviews', 'reviews_per_month', 
              'calculated_host_listings_count', 'availability_365']
target_mean = (
    (data["price"] > data["price"].mean())
    .astype(int))                                                  ④
target_median = (
    (data["price"] > data["price"].median())
    .astype(int))                                                  ⑤
target_multiclass = pd.qcut(
    data["price"], q=5, labels=False)                              ⑥
target_regression = data["price"]                                  ⑦
categorical_onehot_encoding = OneHotEncoder(handle_unknown='ignore')
categorical_ord_encoding = 
OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=np.nan)
numeric_passthrough = SimpleImputer(strategy=”constant", fill_value=0)

column_transform = ColumnTransformer(
    [('low_card_categories', 
      categorical_onehot_encoding, 
      low_card_categorical),
     ('high_card_categories', 
      categorical_ord_encoding, 
      high_card_categorical),
     ('numeric', 
      numeric_passthrough, 
      continuous),
    ],
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)                                          ⑧

① 需要从数据处理中排除的特征列表

② 需要独热编码的低基数分类特征列表

③ 需要按顺序编码的高基数分类特征列表

④ 创建一个二元目标,表示价格是否高于平均值(不平衡的二元目标)

⑤ 创建一个二元目标,表示价格是否高于中位数(平衡的二元目标)

⑥ 通过将价格划分为五个类别来创建一个多类目标

⑦ 将回归目标设置为价格列

⑧ 创建一个列转换器,对不同的特征组应用不同的转换

代码使用 pandas 库读取包含 2019 年纽约市 Airbnb 列表数据的 CSV 文件。然后定义了几个列表,将数据的特征分类到不同的类型:

  • excluding_list—应从分析中排除的特征列表,例如唯一标识符和文本特征

  • low_card_categorical—具有低基数(少量唯一值)的分类特征子集,将进行独热编码

  • high_card_categorical—具有高基数(许多唯一值)的分类特征子集,将使用序数编码进行编码

  • continuous—一个连续数值特征的列表,这些特征将被标准化以进行分析

然后,代码根据数据的“价格”特征创建几个目标变量:

  • target_mean—一个二元变量,表示价格是否高于所有列表的平均价格

  • target_median—一个二元变量,表示价格是否高于所有列表的中位数价格

  • target_multiclass—一个基于价格分布分位数划分的五个类别的变量

  • target_regression—实际的价格值,这些值将被用于回归分析

所有这些目标使我们能够处理不同的回归和分类问题,从而测试机器学习算法。在本章中,我们将始终使用target_median,但你可以通过在代码中做小的改动来实验所有其他目标。

接下来,代码设置了几种转换器,以预处理本章分析中的数据:

  • categorical_onehot_encoding—用于低基数分类特征的独热编码转换器

  • categorical_ord_encoding—用于高基数分类特征的序数编码转换器

  • numeric_passthrough—一个简单传递连续数值特征的转换器

最后,代码设置了一个ColumnTransformer对象,该对象将根据特征类型对每个特征子集应用适当的转换器。它对低基数分类特征应用独热编码,并将连续数值特征传递通过。转换器被设置为丢弃未在转换步骤中明确包含的任何特征,并输出简洁的特征名称。sparse_threshold参数设置为零,以确保转换器始终返回密集数组。

列表 5.2 展示了如何将标准决策树模型应用于我们的示例问题。正如在上一章中看到的示例一样,我们从 Scikit-learn 库中导入必要的模块,定义一个基于准确率的自定义评分指标,并设置五折交叉验证策略。然后我们定义一个名为column_transform的列转换器,它负责数据预处理。它包括

  • 使用函数categorical_onehot_encoding对特定的低基数分类列进行分类变量转换

  • 使用函数numeric_passthrough对连续变量进行数值特征传递

  • 丢弃任何剩余未处理的列(remainder='drop'

  • 设置一些选项,如抑制详细特征名称和未应用稀疏矩阵表示

在这一点上,使用交叉验证测试结合列转换器和决策树分类器模型的管道,返回准确度分数以及平均拟合时间和评分时间。

在交叉验证过程和数据管道的底层,数据集在训练过程中被决策树分类器根据特征的一个分割值多次分割。这个过程可以从算法上解释为“贪婪”,因为决策树在每一步都选择具有最佳分割的特征,而不考虑其他替代方案是否可能带来更好的结果。尽管这种方法很简单,但决策树是有效的机器学习算法。这个过程会一直进行,直到没有更多的分割可以改善训练,如下面的列表所示。

列表 5.2 一个决策树分类器

from sklearn.tree import DecisionTreeClassifier
from sklearn.pipeline import Pipeline
from sklearn.metrics import make_scorer, accuracy_score
from sklearn.model_selection import KFold, cross_validate

accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_passthrough, continuous)],              ①
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)

model = DecisionTreeClassifier(random_state=0)                   ②

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)])                                       ③

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data, 
                           y=target_median,
                           scoring=accuracy,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)                ④

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f}",
      f"secs pred: {score_time:0.2f} secs")                      ⑤

① 创建一个列转换器,对分类和数值特征应用不同的转换

② 决策树分类器的一个实例

③ 依次应用列转换和决策树模型的管道

④ 使用定义的管道进行五折交叉验证,计算准确度分数,并返回附加信息

⑤ 打印交叉验证准确度分数的均值和标准差

我们在准确度方面获得的结果是

0.761 (0.005) fit: 0.22 secs pred: 0.01 secs

与其他机器学习算法的先前实验结果进行比较后,结果可能会更好。我们可以确定这一点,因为决策树已经过拟合,最终构建了太多的分支。我们可以通过尝试和错误来限制其增长以获得更好的性能(你必须声明max_depth参数来这样做)。然而,还有更好的方法可以从这个算法中获得改进的结果。在下一小节中,我们将检查这些方法中的第一个,它基于基于示例和使用的特征的变体构建的多个决策树。

5.1.1 折叠和采样

我们已经检查了所有基于决策树的单一学习算法。同类型的集成算法是下一步可以帮助你在你的问题上实现更多预测能力的步骤。这个想法是直观的:如果一个单一算法可以在某个水平上表现,使用多个模型或链式连接它们的见解(这样可以使一个从另一个的结果和错误中学习)应该会产生更好的结果。有两种核心的集成策略:

  • 平均法—通过平均多个模型的预测来获得预测。模型构建方式的不同,例如通过粘贴、袋装、随机子空间和随机补丁(我们将在本节中看到),会导致不同的结果。这种类型集成模型的最好例子是随机森林算法,它是基于类似随机补丁的方法构建的。

  • 提升法—预测是通过链式模型的加权平均来构建的,这些模型是依次建立在先前模型结果之上的。提升算法的最好例子是梯度提升机,如 XGBoost 和 LightGBM。

在接下来的子节中,我们将探讨随机森林。在深入研究随机森林算法之前,有必要花一些时间在其他平均方法上,这不仅因为随机补丁方法建立在它们之上,而且因为它们指出了在需要减少估计的方差时始终值得应用于表格数据的解决方案,从而获得更可靠的预测,无论你希望在你的数据上使用哪种机器学习模型。

粘贴是首先要考虑的方法。随机森林算法的创造者 Leo Breiman 建议,粘贴包括创建一组不同的模型,这些模型是在通过不重复抽样的子样本上训练的,这些子样本是从你的训练数据中获得的。在回归问题的情况下,通过平均来汇总模型的预测,在分类任务的情况下,通过多数投票来汇总。

粘贴的优点是

  • 通过仅部分增加偏差来减少预测的方差,从而提高结果,偏差是衡量模型预测值与真实值之间距离的指标。

  • 预测结果更稳健,受异常值影响较小。

  • 减少训练时需要学习的数据量,从而减少内存需求。

缺点是

  • 减少可用的数据量,这会增加偏差,因为有可能通过采样排除数据分布中的重要部分。

  • 非常计算密集,具有复杂的算法

最后一个缺点取决于你的时间限制或可用资源。从历史上看,建议使用弱模型(即由于它们的简单性而非常快就能训练的机器学习模型,如线性回归或 k 最近邻模型)应用平均方法。从业者观察到,结合多个弱模型可以击败单个更复杂算法的结果。然而,弱模型通常存在高偏差问题,通过子采样,你只在其估计中引入了一些方差,但它们的偏差问题基本上没有改变。使用平均方法的主要优势在于,它通过以略微增加的偏差为代价来减少估计的方差。由于弱模型本质上携带大量的偏差,它们可能无法实现与应用于更复杂模型相同的方法的可比结果。在需要通过减少估计的方差来获得更显著改进的情况下,使用平均策略可以与更复杂的模型更有效地结合。

Bagging,也被 Leo Breimar 提出作为一种更好的解决方案,与粘贴不同,因为你从子采样切换到自助采样。自助采样包括多次从数据样本中带替换地采样,以近似统计量的总体分布。自助采样是一种常用的统计技术,它允许我们估计相对于从我们的样本中抽取的潜在数据总体,统计量的变异性与不确定性。通过使用从可用样本中获取的信息,通过多次重采样来模拟原始总体行为,自助采样模拟了总体的行为,而无需显式了解其统计分布。

在机器学习中使用自助采样的原因是为了估计模型性能的不确定性或评估统计量的分布。此外,自助采样有助于创建更多样化的原始数据集变体,用于训练和集成目的。这是基于观察,如果所使用的模型的预测较少相关(即更多样化),则平均多个模型可以减少方差。子采样创建多样化的数据集进行训练。然而,它有局限性,因为如果你进行积极的子采样——例如,选择不到原始数据的 50%——你往往会引入偏差。

相比之下,如果你以更有限的方式进行子采样,例如使用 90%的数据,得到的子样本将倾向于相关。相反,自助法更有效,因为平均而言,你会在每个自助法中使用大约 63.2%的原始数据。有关此类计算比例的详细统计解释,请参阅详细交叉验证答案mng.bz/zZ0w。此外,带替换的采样往往会产生模仿原始数据分布的结果。自助法创建了一组更多样化的数据集来学习,从而产生一组更多样化的预测,可以更有效地进行集成,减少方差。

事实上,由于在平均过程中我们正在构建一个预测分布,并将分布的中心作为我们的预测,平均预测越接近随机分布,分布中心受模型(如过拟合)收集到的数据问题的影响就越小。

相比之下,通过随机子空间,由 T. Ho [“The Random Subspace Method for Constructing Decision Forests,” Pattern Analysis and Machine Intelligence, 20(8), 832-844, 1998]引入,采样仅限于特征。这是因为用于集成的模型是决策树,这种模型通过仅使用每个模型的一部分特征来显著降低估计的高方差。改进的结果是因为在特征子样本上训练的模型往往会产生不相关的预测——所有的决策树都会过拟合数据,但相对于彼此而言,方式不同。

最后,通过随机补丁 [G. Louppe 和 P. Geurts, “Ensembles on Random Patches,” in Machine Learning and Knowledge Discovery in Databases (2012): 346–361],同时使用样本和特征的采样,以实现更多不相关的预测,从而可以更有效地进行平均。

粘贴、Bagging、随机子空间和随机补丁都可以使用 Scikit-learn 的 Bagging 函数实现。通过以下参数,可以控制BaggingClassifier对分类任务和BaggingRegressor对回归任务的训练数据的行为:

  • bootstrap

  • max_sample

  • max_features

通过根据每种平均方法的规范组合它们,你可以获得我们描述的所有四种平均策略(见表 5.1)。

表 5.1 Bagging 和采样策略

平均策略 数据会发生什么 BaggingClassifier/BaggingRegressor 的参数
Pasting 使用不替换方式对训练示例进行采样 bootstrap = False max_samples < 1.0 max_features = 1.0
Bagging 使用替换方式(自助法)对训练示例进行采样 bootstrap = True max_samples = 1.0 max_features = 1.0
随机子空间 采样特征(不替换) bootstrap = False max_samples = 1.0 max_features < 1.0
随机补丁 在不替换的情况下采样训练示例和特征 bootstrap = False max_samples < 1.0 max_features < 1.0

通过在参数估计器中输入所需的 Scikit-learn 模型类,您可以决定用于构建集成所使用的算法。默认情况下是决策树,但您可以选择您更喜欢弱或强模型。在以下示例中,我们应用了一个 Bagged 分类器,将决策树模型的数量设置为 300。以下列表显示了所有模型共同贡献以改善低性能,正如我们从列表 5.2 中看到的那样,决策树往往会产生这种问题的低性能。

列表 5.3 基于 Bagged 树的分类器

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

accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = BaggingClassifier(
    estimator=DecisionTreeClassifier(),                     ①
    n_estimators=300, 
    bootstrap=True,                                         ②
    max_samples=1.0,                                        ③
    max_features=1.0,                                       ④
    random_state=0)

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_passthrough, continuous)],
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)                                   ⑤

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)])                                  ⑥

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data, 
                           y=target_median,
                           scoring=accuracy,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)           ⑦

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f}", 
      f"secs pred: {score_time:0.2f} secs")                 ⑧

① 基于决策树创建 BaggingClassifier 集成模型

② 为 BaggingClassifier 设置自助采样

③ 为 BaggingClassifier 设置不进行特征采样

④ 为 BaggingClassifier 设置不进行数据采样

⑤ 应用不同转换到分类和数值特征的列转换器

⑥ 依次应用列转换和 Bagging 分类器模型的管道

⑦ 使用定义的管道进行五折交叉验证并计算准确度分数

⑧ 打印交叉验证准确度分数的均值和标准差

结果需要更多一些时间,但看起来很有希望,但它们仍然不足以与我们的基于支持向量机和逻辑回归的先前解决方案竞争:

0.809 (0.004) fit: 37.93 secs pred: 0.83 secs

在下一小节中,我们通过重新审视随机森林来进一步探讨集成,随机森林利用 Bagging 中的随机补丁是有充分理由的。

5.1.2 使用随机森林进行预测

随机森林的工作原理与 Bagging 类似。然而,它同时应用随机补丁(在不替换的情况下采样训练示例和特征):在训练每个模型之前对样本进行自助采样,并在建模期间对特征进行子采样。由于随机森林集成中使用的基本算法是由二分分割构建的决策树,因此在采样一组特征作为分割本身的潜在候选时,特征采样发生在每个树的分割处。

允许集成中的每个决策树生长到其极限可能会导致数据过拟合和估计的高方差;采用自助抽样和特征抽样可能有助于缓解这些问题。自助抽样确保模型在来自同一分布的不同数据样本上训练,而在每个分割处的特征抽样保证了不同的树结构。这种组合有助于生成一组彼此相当不同的模型。不同的模型产生非常不同的预测(因此,我们可以说它们的预测相当不相关),这对于平均技术来说是一个巨大的优势,因为当集成到一个单一的预测向量时,结果将更加可靠和准确。

图 5.4 展示了随机森林的工作原理。该图说明了具有两个类别的数据集的二元分类问题。数据集使用多棵决策树建模,采用自助抽样和特征抽样技术。这些技术导致数据集的不同分区,如图中顶部部分所示,由三个示例结果表示。这些树以不同的方式划分数据集空间,展示了它们分割策略的变异性。为了简化表示,只显示了两个特征,从而更清楚地理解这个过程。

最后,当所有结果通过多数投票合并在一起时,即选择出现频率更高的分类作为预测类别,随机森林将提供由所有树的结果得出的更好预测。这如图中底部部分所示,其中不同的阴影表示在特定分区中一个或另一个类的普遍性。多数投票中的类之间的最终边界以黑色多边形线表示。当使用多棵树时,这条线甚至可以更平滑,类似于曲线。如果给集成足够多的模型,集成方法可以近似任何曲线。

图片

图 5.4 随机森林如何通过多数投票结合其决策树的不同数据分区来得出结果

该算法最初由 Leo Breiman 和 Adele Cutler(mng.bz/0Qlp)设计,尽管商业上受到保护,但该算法已被开源——因此其实现有许多不同的名称。除了更好的预测外,随机森林还开辟了更多有趣的可能性,因为你可以使用该算法来确定特征重要性并测量数据集中案例相似度的大小。

在列表 5.4 的例子中,我们测试了随机森林在 Airbnb NYC 数据集上如何处理我们的分类问题。在应用决策树时,除了算法外,与我们的标准数据处理没有区别。One-hot 编码将低类别特征转换为二进制,而数值特征保持不变。

列表 5.4 随机森林分类器

from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score

accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = RandomForestClassifier(n_estimators=300, 
                               min_samples_leaf=3,
                               random_state=0)              ①

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_passthrough, continuous)],
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)                                   ②

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)])                                  ③

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data, 
                           y=target_median,
                           scoring=accuracy,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)           ④

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f}", 
      f"secs pred: {score_time:0.2f} secs")                 ⑤

① 具有 300 个估计器和在叶子节点上的最小样本数设置为 3 的 RandomForestClassifier

② 一种对分类特征和数值特征应用不同转换的列转换器

③ 依次应用列转换和随机森林分类器模型的管道

④ 使用定义的管道进行五折交叉验证并计算准确度分数

⑤ 打印交叉验证准确度分数的均值和标准差

运行脚本后,你将获得以下结果,这实际上是本章中针对此问题的最佳性能:

0.826 (0.004) fit: 12.29 secs pred: 0.68 secs

随机森林获得良好结果的关键在于明智地选择其少数超参数。尽管随机森林算法在默认参数下表现良好,无需多想,但微调它将带来更好的结果。首先,算法的目的是减少估计的方差,这通过设置足够高的n_estimators数量来实现。原理是,如果你有很多树,你就有结果分布,如果结果是随机抽取的,由于大数定律,你会有回归到平均值(最佳预测)的效果。通常,示例的重新抽样和考虑分割的特征的抽样足以使森林中的树足够不同,可以被认为是“随机抽取”。然而,你需要足够的抽取次数才能有适当的回归到平均值。

你需要一些测试来微调你将构建多少棵树,因为总有一个最佳点:在达到一定数量的树之后,你不会获得任何更多的改进,有时性能的下降反而会出现。此外,算法构建过多的树会增加其计算成本,训练和推理所需的时间将更多。然而,无论你训练多少棵树,如果你的方差在随机森林模型的默认设置下开始很高,你几乎无法减少它。在这里,方差和偏差之间的权衡就出现了;也就是说,你可以通过一些偏差来换取一些方差,这意味着你正在过度拟合数据。

你可以通过以下调整来为随机森林设置适当的偏差:

  • 通过设置max_features参数来降低在寻找最佳分割时考虑的特征数量

  • 通过设置max_depth参数来设置每个树的最大分割数,从而限制其增长到预定义的某个程度

  • 通过设置min_samples_leaf参数(数值大于 1)来设置树终端叶子中的最小示例数,从而限制其增长

在下一节中,我们探讨极端随机树(ERT),这是随机森林的一个变体,当数据更大且噪声较多时非常有用。

5.1.3 退回到极端随机树

ERT(在 Scikit-learn 中也称为 extra-trees)是一种更随机的随机森林算法。原因是集成中单个树进行分割时候选者的选择。在随机森林中,算法为每个分割采样其候选者,然后从候选者中选择最佳特征。相反,ERT 中要分割的特征不是在可能的候选者中进行评估,而是随机选择。之后,算法在随机选择的特征中评估最佳的分割点。这有一些后果。首先,由于生成的树更加不相关,ERT 的预测方差更小——但代价是更高的偏差。随机分割特征对预测的准确性有影响。其次,ERT 在计算上更高效,因为它不需要测试特征集,而是每次只测试一个特征以找到最佳的分割。所有这些特性使 ERT 最适合处理

  • 高维数据 因为它将比任何其他决策树集成算法更快地分割特征

  • 噪声数据 因为随机特征和样本选择过程可以帮助减少噪声数据点的影响,使模型对极端值更加鲁棒

  • 不平衡数据 因为,由于随机特征选择,数据少数子集的信号不会系统地排除,以利于数据多数子集

以下列表通过替换列表 5.4 中的随机森林来测试 ERT,在列表 5.4 中,你使用 Airbnb NYC 数据集构建了一个模型,以确定列表价格是否高于或低于中位数。

列表 5.5 ERTs 分类器

from sklearn.ensemble import ExtraTreesClassifier

accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = ExtraTreesClassifier(n_estimators=300, 
                             min_samples_leaf=3,
                             random_state=0)                  ①

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_passthrough, continuous)],
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)                                     ②

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)])                                    ③

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data, 
                           y=target_median,
                           scoring=accuracy,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)             ④

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f}",
      f"secs pred: {score_time:0.2f} secs")                   ⑤

① 一个具有 300 个估计器和在叶节点上的最小样本数设置为 3 的 ExtraTreesClassifier

② 一个列转换器,对分类和数值特征应用不同的转换

③ 一个管道,按顺序应用列转换和随机森林分类器模型

④ 使用定义的管道进行五折交叉验证并计算准确度分数

⑤ 打印交叉验证中准确度分数的平均值和标准差

你获得的结果比使用随机森林略好一些:

0.823 (0.004) fit: 4.99 secs pred: 0.42 secs

如果你运行这个示例,你会看到,在给定相同的 dataset 和构建相同数量的树的情况下,使用 ETR 训练比使用随机森林要快得多。当你的 dataset 更大(更多案例)甚至更宽(更多特征)时,ETR 成为一个有趣的替代方案,因为它节省了大量选择分割特征的时间,因为它是随机决定的。相比之下,随机森林算法必须在选择中寻找最佳特征。

当许多共线性噪声特征与目标相关时,随机决定分割是一个巨大的优势。该算法避免像由寻找最佳拟合目标的特征驱动的算法那样选择相同的信号。此外,您还可以通过观察 ETR 的特征分割的工作动态,将其视为另一种以方差与偏差进行交易的方式。对于算法来说,随机分割是一个限制,因为它会导致生成的树集非常不相关。

在下一节中,我们通过考察梯度提升来完善我们对基于树的集成方法的概述。这种略有不同的集成方法通常比袋装或随机补丁对表格数据问题更有效。

5.2 梯度提升

在近年来,梯度提升决策树(GBDT)已牢固地确立了自己在表格数据问题上的尖端方法地位。GBDT 通常被认为是在多个领域广泛问题上的最先进机器学习方法,包括多类分类、广告点击预测和搜索引擎排名。当应用于标准表格问题时,您可以期望 GBDT 的表现优于神经网络、支持向量机、随机森林和袋装集成。

首先,GBDT 处理异构特征的能力以及它在选择损失函数和评估指标方面的灵活性,使该算法最适合表格数据预测建模任务。总的来说,GBDT 为表格数据问题提供了以下好处:

  • 通过适当的超参数调整,它可以在所有其他技术中实现最佳性能。

  • 不需要对特征进行缩放或其他单调变换。

  • 它自动捕捉数据中的非线性关系。

  • 它对异常值和噪声数据具有鲁棒性。

  • 它自动处理缺失数据。

  • 它自动选择最佳特征,并可以报告它们的重要性。

所有这些特性都取决于算法的工作方式,结合决策树序列进行梯度下降优化。实际上,在梯度提升中,从常数开始,您按顺序向集成中添加树模型,每个模型都以前一个模型的误差为依据进行校正,类似于梯度下降优化。梯度提升代表了原始提升方法的演变。在 Adaboost 等模型中使用原始提升时,您只需对基于前一个模型残差的模型进行平均。

在 Adaboost 中,算法对一系列弱学习器进行拟合,任何持续击败随机猜测的机器学习算法(有关如何选择弱学习器的解释,请参阅mng.bz/KG9P)。然后,它对错误预测赋予更多权重,对正确预测赋予较少权重。加权有助于算法更多地关注难以预测的观测值。在分类中通过多数投票或在回归中通过预测的平均值后,这个过程通过多次修正而结束。

相比之下,在梯度提升中,你依赖于双重优化:首先是单个树根据其优化函数努力减少误差的优化,然后是总体优化,涉及计算提升模型的求和误差,其形式模仿梯度下降,其中你逐渐修正模型的预测。由于你还有一个基于第二层的优化,即基于整个集成过程误差的优化,因此梯度提升比之前看到的树集成更灵活,因为它允许在计算模型预测求和与预期结果差异时使用任意损失函数。

图 5.5 可视化地表示了添加新树后训练误差如何降低。每一棵树都参与梯度下降风格的优化,有助于预测前一棵树残差误差的修正。

图 5.5 梯度下降法与提升树的工作原理

如果梯度下降在优化中提供最优结果和灵活性,那么使用决策树作为基学习(如所见,集成并不限于决策树)提供了各种优势。这是因为它自动选择所需的特征。它不需要指定函数形式(如回归中的公式)、缩放或特征与目标之间的线性关系。

在下一节中,在看到具体实现(Scikit-learn、XGBoost、LightGBM)之前,我们将尝试构建我们自己的简单梯度提升实现,以了解如何使用这个强大的算法。

5.2.1 梯度提升法的工作原理

所有 GBDT 的实现都提供了一系列超参数,需要设置以在您试图解决的数据问题上获得最佳结果。弄清楚每个设置的作用是一个挑战,而且对自动调整过程保持无知并将任务留给它并不会帮助太多,因为您将面临挑战告诉调整算法要调整什么以及如何调整。

根据我们的经验,写下简单的实现是理解算法工作原理的最佳方式,并找出超参数如何与预测性能和结果相关。列表 5.6 显示了一个 GradientBoosting 类,它可以解决任何二元分类问题,例如我们作为示例处理的 Airbnb NYC 数据集,使用梯度下降过程的两参数和 Scikit-learn 提供的决策树模型的参数。

代码创建了一个 GradientBoosting 类,它包含用于拟合、预测概率和预测类的方法。内部,它将拟合的决策树序列存储在一个列表中,可以从那里按顺序访问以重建以下求和公式:

图片

在公式中,

  • H(X) 是应用于预测变量 X 的梯度提升模型

  • M 对应于使用的树估计器的数量

  • ν 代表学习率

  • w^m 代替表示来自先前树的校正,这些校正需要被预测

  • h^m 符号指的是第 m 个决策树

有趣的是,梯度提升树总是回归树(即使是对于分类问题)——因此我们选择使用 Scikit-learn 的 DecisionTreeRegressor。这也解释了为什么 GBDT 在预测概率方面比其他基于树的集成模型更好:梯度提升树直接对类别概率的对数几率进行回归,从而以一种与逻辑回归不太不同的方式优化。另一方面,像随机森林这样的算法是针对纯度指标进行优化的,它们通过计算终端节点中一个类的比例来估计概率,这并不是真正的概率估计。一般来说,GBDT 输出的概率是正确的,并且很少需要后续的概率校准,这是一种后处理步骤,用于调整预测概率以提高其在概率估计至关重要的应用中的准确性和可靠性,例如医疗诊断(例如,疾病检测)、欺诈检测或信用风险评估。

在我们的代码实现中,我们允许传递任何参数给 DecisionTreeRegressor(见 mng.bz/9YQx),尽管最有用的是与树发展复杂度相关的参数,例如 max_depth(固定树的深度最大值),或 min_samples_splitmin_samples_leaf(分别表示分割内部节点或成为叶节点所需的最小样本数)。

每个树回归器的角色是提供一个 w 向量,其中包含要加到先前估计中的学习率加权的学习修正。每个 w 向量都依赖于前一个,因为它是由一个训练在梯度上的树回归器产生的,这些梯度是必要的,以纠正估计以匹配真实的分类标签。链式向量 w 类似于一系列梯度修正——最初很大,然后越来越精细,趋向于最优输出预测。这种梯度下降与我们在第四章中介绍的梯度下降优化过程完全相似。此外,通过更改基于其梯度计算的成本函数,您可以要求 GBDT 优化不同的损失函数。

列表 5.6 构建梯度提升分类器

from sklearn.tree import DecisionTreeRegressor
import numpy as np

class GradientBoosting():
    def __init__(self, learning_rate=0.1, n_estimators=10, **params):
        self.learning_rate = learning_rate
        self.n_estimators = n_estimators
        self.params = params
        self.trees = list()

    def sigmoid(self, x):
        x = np.clip(x, -100, 100)
        return 1 / (1 + np.exp(-x))                          ①

    def logit(self, x, eps=1e-6):
        xp = np.clip(x, eps, 1-eps)
        return np.log(xp / (1 - xp))                         ②

    def gradient(self, y_true, y_pred):
        gradient =  y_pred - y_true                          ③
        return gradient

    def fit(self, X, y):
        self.init = self.logit(np.mean(y))                   ④
        y_pred = self.init * np.ones((X.shape[0],))
        for k in range(self.n_estimators):
            gradient = self.gradient(self.logit(y), y_pred)
            tree = DecisionTreeRegressor(**self.params)
            tree.fit(X, -gradient)                           ⑤
            self.trees.append(tree)
            y_pred += (
                self.learning_rate * tree.predict(X)
            )                                                ⑥

    def predict_proba(self, X):
        y_pred = self.init * np.ones((X.shape[0],))
        for tree in self.trees:
            y_pred += (
                self.learning_rate * tree.predict(X)
            )                                                ⑦
        return self.sigmoid(y_pred)

    def predict(self, X, threshold=0.5):
        proba = self.predict_proba(X)
        return np.where(proba >= threshold, 1, 0)

① 用于概率转换的 Sigmoid 函数实现,将 logits 转换回概率

② 用于将概率转换为 logits 的 Logit 函数实现

③ 计算损失函数(负对数似然)相对于预测的梯度

④ 使用目标值的对数变换均值初始化模型

⑤ 将决策树回归器拟合到对数似然转换目标的负梯度

⑥ 使用学习率因子更新拟合的树的输出预测值

⑦ 预测需要累积来自所有树的预测

正如我们在应用于线性模型的梯度下降中看到的,您依赖于使过程随机化以避免优化陷入次优解,这是通过在训练每个决策树之前采样行或列来实现的。此外,您使用提前停止来防止 GBDT 顺序使用过多的决策树并过度适应训练数据。我们将在下一章中演示提前停止。

现在我们已经解释了我们的 GradientBoosting 类的内部工作原理,我们现在可以对其进行实验。我们将使用 Airbnb 纽约数据集,并首先将其分为训练集和测试集。这需要创建两个行索引列表——一个用于训练集,一个用于测试集——使用 Scikit-learn 函数 train_test_split (mng.bz/jp1z)。我们实例化我们的 GradientBoosting 类,它需要一个学习率为 0.1 和 300 个决策树,最大深度为四个分支,终端叶子节点至少有三个示例。在通过处理数值和分类特征转换训练数据后,我们拟合模型,预测测试集,并评估结果。

列表 5.7 测试我们的梯度提升类

from sklearn.model_selection import train_test_split

train, test = train_test_split(range(len(data)), test_size=0.2, 
random_state=0)                                              ①

cls = GradientBoosting(n_estimators=300, 
                       learning_rate=0.1,
                       max_depth=4,
                       min_samples_leaf=3, 
                       random_state=0)                       ②

X = column_transform.fit_transform(data.iloc[train])         ③
y = target_median[train]                                     ④

cls.fit(X, y)

Xt = column_transform.transform(data.iloc[test])             ⑤
yt = target_median[test]                                     ⑥

preds = cls.predict(Xt)
score = accuracy_score(y_true=yt, y_pred=preds)              ⑦
print(f"Accuracy: {score:0.5f}")                             ⑧

① 使用固定随机种子将数据集索引分割为训练集和测试集

② 使用指定的超参数初始化 GradientBoosting 模型

③ 将列转换应用于训练数据

④ 提取与训练数据对应的目标值

⑤ 将相同的列转换应用于测试数据

⑥ 提取与测试数据对应的目标值

⑦ 通过比较预测标签与实际测试标签来计算准确度得分

⑧ 打印计算出的准确度得分

在我们的测试集上的评估准确度是

Accuracy: 0.82503

这是一个非常好的结果,表明即使是我们基本的实现也能在我们的数据上做得很好。在下一节中,我们将调查获得的结果,并观察 GBDT 模型的一个关键特征,这个特征使它们区别于其他决策树集成。

5.2.2 使用梯度提升进行外推

在我们从头开始实现的 GBDT 中,我们可以通过预测相同的训练集来可视化模型如何拟合数据。图 5.6 中所示的可视化,由列表 5.8 中的小代码片段创建,是一个归一化密度直方图。在归一化密度直方图中,每个柱子的高度代表落在特定区间内的数据点的相对频率,直方图下的总面积等于 1。结果描绘了一个值分布,主要偏向 0-1 边界的极端,表明模型在分类示例时非常果断。

列表 5.8 绘制梯度提升预测概率图

import matplotlib.pyplot as plt

proba = cls.predict_proba(Xt)                              ①
plt.figure(figsize=(8, 6))
plt.hist(proba, 
         bins=30,
         density=True,
         color='blue',
         alpha=0.7)                                        ②
plt.xlabel('Predicted Probabilities')
plt.ylabel('Density')
plt.title('Histogram of Predicted Probabilities')
plt.grid(True)
plt.show()

① 使用训练模型为测试数据生成预测概率

② 创建具有指定区间和归一化密度的预测概率直方图

图片

图 5.6 描述梯度提升分类拟合概率的直方图,展示了模型在大多数情况下如何强烈决定它们是正还是负

我们的实现底层使用回归损失,即平方损失,其梯度等于将概率转换为 logit 的残差。关于 logit 的定义,请参阅 mng.bz/W214

概率 p 的 logit 计算如下

图片

这种定义的优势在于 logit 函数将概率映射到对数几率尺度,这是一个无界的尺度,范围从负无穷大到正无穷大,使我们能够将我们的问题视为回归问题。

这意味着在每次迭代中,梯度提升算法都会将回归模型拟合到损失函数相对于 logit 值的梯度,这对应于真实目标值的 logit 与当前预测(以 logit 表示)之间的差异。这种方法允许算法通过调整预测以沿着损失函数的最陡下降方向迭代改进预测,并最终通过 logit 的逆函数 sigmoid,得到一个在 0 到 1 之间有界的 logit 预测。sigmoid 是一种数学函数,它将输入映射到 0 到 1 之间的值,提供一条平滑且连续的曲线。

sigmoid 的公式为

其中

  • σ(x)表示将 sigmoid 函数应用于输入值 x。

  • exp(–x)是指数函数,其中 exp 表示欧拉数(约等于 2.71828)的–x 次幂。

  • 1 + exp(–x)是分母,这确保了 sigmoid 函数的输出始终为正。

  • 1 / (1 + exp(–x))表示分母的倒数,结果是 sigmoid 函数的输出值。

它在机器学习和统计模型中常用,用于将 logit 预测转换为概率。

如果我们将问题视为回归问题会怎样?在列表 5.9 中,我们通过构建我们的GradientBoosting类并覆盖 fit 和 predict 方法(通过移除 logit 和 sigmoid 转换)来定义一个GradientBoostingRegression类。

列表 5.9 测试梯度提升回归类

class GradientBoostingRegression(GradientBoosting):

    def fit(self, X, y):
        self.init = np.mean(y)                              ①
        y_pred = self.init * np.ones((X.shape[0],))

        for k in range(self.n_estimators):
            gradient = self.gradient(y, y_pred)
            tree = DecisionTreeRegressor(**self.params)
            tree.fit(X, -gradient)                          ②
            self.trees.append(tree)
            y_pred += (
                self.learning_rate * tree.predict(X)
            )                                               ③

    def predict(self, X):
        y_pred = self.init * np.ones((X.shape[0],))
        for tree in self.trees:
            y_pred += (
                self.learning_rate * tree.predict(X)
            )                                               ④
        return y_pred

reg = GradientBoostingRegression(n_estimators=300,
                                 learning_rate=0.1,
                                 max_depth=4,
                                 min_samples_leaf=3, 
                                 random_state=0)

reg.fit(X, y)

proba = reg.predict(Xt)
plt.figure(figsize=(8, 6))
plt.hist(proba, 
         bins=10,
         density=True,
         color='blue',
         alpha=0.7)                                         ⑤
plt.xlabel('Predicted Probabilities')
plt.ylabel('Density')
plt.title('Histogram of Predicted Probabilities')
plt.grid(True)
plt.show()

① 使用 y 的均值初始化预测

② 将树拟合到负梯度

③ 使用学习率缩放树预测更新预测

④ 预测回溯需要累积所有树的预测。

⑤ 绘制回归预测概率的直方图

当运行列表 5.9 中的代码时,它将生成拟合预测的直方图,如图 5.7 所示。图 5.7 显示了拟合概率如何超过 0-1 边界。与基于特征的加权组合的线性回归一样,基于链式序列模型结果的加权组合梯度提升可以超出学习目标的范围。与其他基于决策树的集成(如随机森林)相比,这种外推是不可能的。回归中的决策树无法预测超出训练中看到的值,因为预测是基于训练子样本的均值。GBDT(梯度提升决策树)的外推潜力,基于它们是加性集成的事实,是它们在时间序列中成功的基础,在时间序列中,你外推可能非常不同于过去的未来结果。

然而,需要注意的是,GBDTs 的外推能力不能像使用线性模型那样延伸得那么远。在时间序列预测场景中,如果预测的值远远超出了你为训练提供的目标值,例如在异常值的情况下,外推将受到限制,无法进行正确的估计。在这种情况下,直接将输入数据与预测关联的线性模型可能更合适。线性模型能够处理完全未见的异常数据点的极端预测。为了在这种情况下提供决策树作为基学习器的替代方案,许多 GBDT 实现提供了通过简单集成线性模型(如 XGBoost 实现)或应用分段线性梯度提升树来实现线性提升,其中线性模型建立在决策树的终端节点上(如 LightGBM 实现)。

图像描述

图 5.7 使用梯度提升回归模型拟合概率的直方图,其中一些概率超过了 0-1 边界

此外,GBDTs 在时间序列问题中的优势在于它们在预测信息选择上的自动性,设置的超参数非常少。你所需要做的就是拥有足够的示例,至少在数千个数据点的范围内,并对时间序列特征进行一些仔细的工程化设计,例如不同时间跨度的滞后值和移动平均。对于较短的序列,经典的时间序列方法,如 ARIMA 或指数平滑,仍然是推荐的选择。对于如层次结构序列等复杂问题,GBDTs 甚至可以超越为时间序列数据专门设计的最复杂的深度学习架构。例如,GBDTs 在解决超市网络中的问题方面表现出色,在这些网络中既销售慢销品也销售快销品。

最近在 Kaggle 上举行的 M5 预测竞赛中,GBDTs 在时间序列分析中的优势得到了明确的展示(github.com/Mcompetitions/M5-methods),在该竞赛中,LightGBM 算法提出的解决方案优于为预测层次结构序列任务设计的深度学习架构,如 DeepAR (arxiv.org/abs/1704.04110)或 NBEATS (arxiv.org/abs/1905.10437)。在 Tim Januschowski 等人撰写的论文“使用树进行预测”中,可以找到对竞赛和基于树的方法在时间序列分析实践中的成功和普遍性的清晰而深入的分析。[国际预测杂志* 38.4 (2022): 1473–1481: mng.bz/8O4Z]。

5.2.3 解释梯度提升的有效性

今天,尽管在图像和文本识别与生成方面取得了显著成果,但神经网络在表格数据上的性能并不匹配梯度提升解决方案(如 XGBoost 和 LightGBM)。实践者和数据科学竞赛的参与者都倾向于这些解决方案。例如,参见关于表格竞赛的“竞争性机器学习状态”报告,mlcontests.com/tabular-data/。但梯度提升决策树(GBDTs)相对于深度神经网络(DNNs)的优势究竟来自何处?从我们构建梯度提升分类器的经验来看,我们可以欣赏到该算法如何将梯度下降与异构数据的决策树的灵活性相结合。这足以解释为什么 GBDTs 在表格数据上如此有效吗?

由 Leo Grinsztajn、Edouard Oyallon 和 Gael Varoquaux 撰写的《为什么基于树的方法在典型的表格数据上仍然优于深度学习?》(第三十六届神经信息处理系统会议数据集和基准测试轨道,2022 年:hal.science/hal-03723551v2/document)是一篇近期的研究,试图揭示深度学习架构和梯度提升决策树的不同性能。研究表明,基于树的方法在表格数据上实现良好预测方面优于深度学习方法(即使是现代架构)。作者明确关注区分表格数据与仅具有连续特征的集合(我们可以称之为同质表格数据集)的列异质性,并使用 45 个公开数据集定义了一个标准基准。他们只考虑了大约 10,000 个样本的数据,包括不同类型的列,包括具有不同单位的数值特征和分类特征,因为这被认为是表格数据集的典型情况。

尝试了各种深度学习模型,包括多层感知器(MLPs)、ResNets、SAINT 和 FTtransformer,但发现基于树的方法在更少的超参数调整下表现出更好的性能。即使只考虑数值特征,基于树的方法也优于深度学习方法。当考虑到拟合时间时,这种优势更为明显,尽管所使用的硬件(包括 GPU)也影响了结果。在大数据集上,这两种方法之间的差距更小,而大数据集对于表格数据来说并不典型。

作者还研究了表格数据特征,这些特征解释了基于树和深度学习方法之间的性能差异。他们发现,在特征空间中平滑结果缩小了差距,因为深度架构难以处理不规则模式,而平滑性不会影响树模型。移除无信息特征对于类似 MLP(多层感知器)的神经网络架构缩小差距更为明显。然而,只有在将数据应用随机旋转之后,深度架构才优于树模型。

随机旋转是指在将数据集的输入特征馈送到机器学习模型之前,对这些特征应用一个随机旋转矩阵。这个旋转矩阵是一个方阵,它保持向量的长度和它们之间的角度,确保旋转后的数据与原始数据等效。随机旋转在机器学习中用于各种目的,包括增强集成方法的多样性、提高模型的鲁棒性,以及解决计算机视觉和量子化学等任务中的旋转不变性问题。然而,这种完全可逆的技术往往会使基于树的算法中预测变量与目标之间的关系变得模糊,而深度学习模型则不受影响,这得益于它们强大的学习能力,能够学习到应用的旋转。

这个结果并不一定表明 DNNs(深度神经网络)的优势,而更可能是 GBDTs 的局限性。深度架构具有旋转不变性,这意味着它们可以检测旋转信号,如在图像识别中,某些图像可以无论其方向如何都能被识别。相比之下,GBDTs 不具有旋转不变性,只能检测始终以相同方式定向的信号,因为它们基于分割规则进行操作。因此,对数据进行任何类型的旋转,如主成分分析或奇异值分解,都可能对 GBDTs 产生不利影响。不受旋转影响的 DNNs 在这些情况下可以迎头赶上。

目前,这项研究加强了我们对于 GBDTs(梯度提升决策树)及其感知优势的经验:

  • 即使在中等规模的数据集(1,000–5,000 个案例)上也能表现良好,但根据我们的经验,在 10,000 到 100,000 个样本的情况下,其表现优于其他算法

  • 倾向于在本质上异质的数据集上表现出色

  • 对目标数据中的噪声和不规则性具有鲁棒性

  • 由于其自动特征选择过程,可以过滤掉噪声或不相关的特征

除了这里提到的优势之外,还应注意的是,在某些场景下,GBDTs(梯度提升决策树)通常比 DNNs(深度神经网络)更受欢迎。一个原因是 GBDTs 需要更少的数据预处理,这使得它们更高效且易于实现。此外,在目标函数方面,GBDTs 与 DNNs 一样灵活。在两种情况下,都有许多可供选择,这在具有复杂优化目标的领域中特别有用。GBDTs 的另一个好处是,它们提供了更多控制决策树规则构建的方式,为用户提供了一定的透明度和可解释性。最后,GBDTs 在大多数情况下比 DNNs 训练得更快,并且根据它们的复杂性,它们也可以在合理的时间内进行预测,这在实时应用或时间敏感的任务中可能是一个关键因素。

现在你已经了解了梯度提升背后的基本概念及其在解决表格数据问题上的有效性,与深度学习相比,下一节将探讨一些其实现,从 Scikit-learn 提供的实现开始。

5.3 Scikit-learn 中的提升

Scikit-learn 为回归和分类任务提供了梯度提升算法。这些算法可以通过 GradientBoostingClassifier (mng.bz/Ea9o) 和 GradientBoostingRegressor (mng.bz/N1VN) 类分别访问。

Scikit-learn 对梯度提升的实现是数据科学领域 Python 用户最早可用的选项之一。这个实现与 1999 年 Jerome Friedman 提出的算法原始提案非常相似[“Greedy Function Approximation: A Gradient Boosting Machine,” Annals of Statistics (2001): 1189–1232]。让我们在接下来的代码列表中看看实现的效果,我们将对 Airbnb NY 数据集上的分类器性能进行交叉验证,以预测列表价格是否高于或低于中位数。

列表 5.10 Scikit-learn 梯度提升分类器

from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import accuracy_score
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = GradientBoostingClassifier(
    n_estimators=300,
    learning_rate=0.1,
    max_depth=4,
    min_samples_leaf=3, 
    random_state=0
)                                                            ①

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)])                                   ②

cv_scores = cross_validate(
    estimator=model_pipeline, 
    X=data, 
    y=target_median,
    scoring=accuracy,
    cv=cv, 
    return_train_score=True,
    return_estimator=True
)                                                            ③

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f}",
      f"secs pred: {score_time:0.2f} secs")                  ④

① 具有指定超参数的 GradientBoostingClassifier 模型

② 首先使用 column_transform 进行数据处理,然后拟合模型的管道

③ 使用定义的管道进行五折交叉验证,计算准确度分数,并返回额外信息

④ 打印交叉验证中准确度分数的平均值和标准差

使用之前使用的相同参数集,我们获得的效果略好于我们实现中获得的:

0.826 (0.004) fit: 16.48 secs pred: 0.07 secs

Scikit-learn 实现的一个显著特点是训练过程可能需要相当长的时间,瓶颈可以归因于决策树,这是 Scikit-learn 本身使用的唯一支持构建序列集成模型。作为交换,你在参数上拥有一些灵活性,并且可以控制 GBDT 使用单个决策树的方式。例如,Scikit-learn 的梯度提升允许你

  • 定义 init 函数。在我们的实现中,我们使用平均值作为第一个估计器。在这里,你可以使用任何你想要的估计器作为起点。由于梯度提升基于梯度下降,而梯度下降优化过程对起点敏感,因此在解决更复杂的数据问题时,这可能是一个优势。

  • 通过训练具有指数损失的 GradientBoostingClassifierloss="exponential")将算法回退到 Adaboost,这是启发序列集成的原始算法。

  • 详细控制所使用的决策树的复杂性,这意味着你可以通过诸如

    • min_samples_split 用于分裂内部节点所需的最小样本数

    • min_sample_leaf 对于成为叶子节点所需的最小样本数

    • min_weight_fraction_leaf 对于在叶子节点所需的输入样本总权重中加权分数的最小值

    • max_depth 对于树的深度

    • min_impurity_decrease 作为不纯度降低的阈值,用于决定是否分裂或停止树的生长

    • max_leaf_nodes 作为停止生长树之前可以达到的最大最终节点数

  • 如果仅仅控制决策树的生长还不够,一旦它们生长出来,你可以使用 ccp_alpha 参数来减少它们的复杂性。此参数通过移除未通过复杂性测试的节点来从最终节点回退树(有关详细信息,请参阅mng.bz/DM9n)。

  • 对行(subsample)和列(max_features)进行子采样,这是一种特别有效的减少过拟合并提高训练模型泛化能力的方法。

此外,该实现还提供了对稀疏数据和支持早停的支持,这是一种防止 GBDT 和神经网络过拟合的程序,将在下一章中详细讨论。

此外,在提供的输出方面,此版本提供的支持相当令人印象深刻,使其成为理解并解释为什么你的 GBDT 模型做出某些预测的完美工具。例如,你可以访问所有使用的决策树,并要求从集成树中预测的原始值,无论是作为整体(decision_function 方法)还是作为一系列步骤(staged_decision_function 方法)。

最近,由于 XGBoost、LightGBM 和 Scikit-learn 的 HistGradientBoosting 提供的更快、更高效的解决方案,这种实现已被从业者较少使用。然而,如果您想控制梯度提升过程的某些方面,它仍然是一个有趣的选择。在下一节中,我们将探讨 XGBoost,并确定它如何成为解决您的表格数据问题的更强大选择。

5.3.1 应用提前停止以避免过拟合

原始 Scikit-learn 类梯度提升的展示提供了一个机会,介绍一个可以帮助控制过拟合的程序。该程序是提前停止,这是一种最初用于梯度下降的方法,用于限制在优化系数的进一步调整不会带来增强或导致解决方案泛化不良时迭代的数量。该方法也已被用于训练神经网络。在梯度提升中,它将梯度下降作为其优化过程的一部分,该方法可以帮助解决相同的问题:限制添加的决策树模型数量以减少计算负担并避免可能的过拟合。

提前停止的工作步骤如下:

  1. 将训练数据集的一部分留出以形成验证集。

  2. 在训练过程的每次迭代中,使用验证集评估产生的部分模型。

  3. 记录并比较部分模型在验证集上的性能与之前的结果。

  4. 如果模型的表现没有提高,算法会增加其自上次改进以来的迭代计数(通常称为耐心)。否则,它将重置计数。

  5. 如果在一定的迭代次数内没有改进,则停止训练过程。

  6. 否则,训练过程将继续进行另一轮迭代,除非所有指定的待提升决策树都已完成。此时,训练过程将停止。

图 5.8 展示了这个过程在流程图中的表示。

图片

图 5.8 描述了 GBDT 中提前停止如何工作的流程图。

图 5.9 从验证指标的角度展示了完全相同的过程,该指标可以是损失或其他您希望获得最佳结果的指标。随着迭代的进行,观察到训练误差通常会降低。另一方面,验证误差在算法开始过拟合之前往往有一个最佳点。提前停止有助于捕捉验证误差的增加,监控验证误差的动态变化,让您能够回溯到任何过拟合发生之前的迭代。

图片

图 5.9 展示了在多次迭代使用 GBDT 时,验证误差和训练误差通常如何表现。

在具有早停法的交叉验证过程中,针对每个折叠训练的模型可能会找到不同的停止点。当在整个数据集上训练时,您仍然可以依赖使用基于验证样本的早停法。因此,您只需设置一个高迭代次数,并观察训练何时停止。否则,您可以使用基于交叉验证中观察到的停止迭代次数的固定迭代次数。在这种情况下,您可以通过计算交叉验证中看到的所有停止点的平均值或中位数来确定要使用的提升树数量。然而,没有固定的规则,您可以选择使用第二大的值来实现激进的停止策略,或者使用第二小的值来实现保守的停止策略。

此外,考虑到训练数据比交叉验证期间更多,因此可以考虑增加提升树的数量。作为一个一般性指南,可以通过将提升树的数量增加一个百分比来达到这个目的,这个百分比等于交叉验证中使用的折叠数目的倒数。然而,由于没有一种适合所有情况的解决方案,可能需要进行实验以找到最适合您问题的最佳方法。

例如,我们再次运行之前的代码,这次设置更高的基本估计器数量以及两个参数,validation_fractionn_iter_no_change,这些参数激活了早停过程。参数 validation_fraction 确定了用于验证的训练数据比例,并且仅在 n_iter_no_change 设置为一个整数时有效,该整数表示在停止过程之前,在验证集上测试模型时,应该经过多少次迭代而没有改进。

列表 5.11 使用 GradientBoostingClassifier 应用早停法

model = GradientBoostingClassifier(
    n_estimators=1000,                                      ①
    learning_rate=0.1,
    validation_fraction=0.2,                                ②
    n_iter_no_change=10,                                    ③
    max_depth=4,
    min_samples_leaf=3, 
    random_state=0
)

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)])

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data, 
                           y=target_median,
                           scoring=accuracy,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f} secs pred: {score_time:0.2f} secs")
iters = [cv_scores["estimator"][i].named_steps["modeling"].n_estimators_ 
         for i in range(5)]                                  ④
print(iters)                                                 ⑤

① 将迭代次数从之前的 300 提高到 1,000 的 GradientBoostingClassifier 模型

② 作为验证分数,GradientBoostingClassifier 使用 20% 的训练数据用于验证。

③ 当验证集上没有改进时,GradientBoostingClassifier 的训练将在 10 次迭代后停止。

④ 提取每个折叠估计器在训练期间使用的估计器数量

⑤ 打印每个折叠估计器的估计器数量列表

输出是

0.826 (0.005) fit: 6.24 secs pred: 0.04 secs
[145, 109, 115, 163, 159]

由于交叉验证折叠的平均迭代次数为 268 次迭代,根据经验法则,在训练阶段使用所有可用数据时,我们建议将迭代次数增加 20%,固定为 322 次迭代。

在接下来的章节中,我们将介绍梯度提升的新实现,例如 XGBoost 和 LightGBM。我们还将展示如何使用它们实现早停法。

5.4 使用 XGBoost

XGBoost 在 Kaggle 竞赛中取得成功后获得了关注,例如在希格斯玻色子机器学习挑战赛(www.kaggle.com/c/higgs-boson)中,XGBoost 在竞赛论坛中被提出作为一种比 Scikit-learn 的梯度提升更快速、更准确的解决方案。自那时起,XGBoost 已被广泛应用于许多其他数据科学竞赛中,证明了其有效性和 Kaggle 竞赛作为介绍颠覆性能基准的创新的好场所的作用。Keras 是另一个在 Kaggle 竞赛中取得成功后被广泛采用的创新例子。在撰写本文时,XGBoost 软件包已更新并达到了 2.0.3 版本的里程碑,这是我们在这本书中使用的版本。

XGBoost 最初是由陈天奇作为研究项目提出的,后来在 Carlos Guestrin 的贡献下进一步发展,XGBoost 是一个作为开源软件提供的梯度提升框架。值得注意的是,与由微软赞助的 LightGBM 和其他由谷歌赞助的 Yggdrasil 决策森林(见mng.bz/lYV6)等倡议不同,XGBoost 保持了完全独立,由分布式(深度)机器学习共同社区(dmlc.github.io)维护。

随着时间的推移,该框架经历了重大改进,现在提供了用于分布式处理和并行化的高级功能,使其能够处理大规模数据集。同时,XGBoost 也得到了广泛的应用,目前可在包括 C/C++、Python 和 R 在内的多种编程语言中使用。此外,该框架在多个数据科学平台上得到支持,如 H2O.ai 和 Apache Spark。

作为数据科学用户,你将立即注意到这个框架的几个关键特性,包括

  • 处理各种输入数据类型的能力

  • 支持自定义目标函数和评估函数

  • 自动处理缺失值

  • 简单支持 GPU 训练

  • 适应单调性和特征交互约束

  • 优化独立计算机上的多核和缓存

从系统性能的角度来看,显著的特点包括

  • 网络并行训练,允许在机器集群上实现分布式计算

  • 在构建树的过程中利用所有可用的 CPU 核心以实现并行化

  • 处理不适合内存的大型数据集时的离核计算

然而,将“极端”XGBoost 算法与其他算法区分开来的是其创新的优化算法细节,包括被称为牛顿下降的梯度下降的变体、正则化项,以及其独特的特征分割和稀疏数据处理方法。以下部分简要总结了这些推动 XGBoost 性能的突破性技术。

现在,让我们尝试使用 Airbnb NYC 数据集上的分类任务来运行这个算法。在这种情况下,使用 XGBoost 的XGBClassifier。对于回归问题,你可以使用XGBRegressor类。然而,首先,你需要在你的系统上安装 XGBoost。要安装 XGBoost,你可以直接使用 pip 安装:

pip install XGBoost

或者使用 conda 来完成这项工作:

conda install -c conda-forge py-XGBoost

安装命令应该为你完成所有必要的步骤;如果可能的话,在你的系统上安装算法的 CPU 和 GPU 版本。有关安装过程和详细说明,请参阅mng.bz/BXd0

在以下列表中,我们复制了我们之前使用 Scikit-learn 的 GradientBoostingClassifier 所采用的方法:我们增强 300 棵树,将决策树的深度限制为四个级别,并接受至少三个示例的节点。

列表 5.12 XGBoost 分类器

from XGBoost import XGBClassifier

accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
xgb = XGBClassifier(booster='gbtree',                        ①
                    objective='reg:logistic',                ②
                    n_estimators=300,
                    max_depth=4,
                    min_child_weight=3)                      ③

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('XGBoost', xgb)])

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data, 
                           y=target_median,
                           scoring=accuracy,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f}",
      f"secs pred: {score_time:0.2f} secs")                  ④

① 创建一个具有指定超参数的 XGBClassifier 模型,包括增强器类型

② 学习目标,相当于 Scikit-learn 的损失

min_child_weight相当于 Scikit-learn 的min_samples_leaf

④ 打印交叉验证测试分数的均值和标准差

获得的结果是目前为止的最佳结果,而且训练拟合只是之前 Scikit-learn 实现所需时间的很小一部分:

0.826 (0.004) fit: 0.84 secs pred: 0.05 secs

在下一小节中,我们将探讨运行此示例所使用的核心参数,并讨论在成功运行任何使用 XGBoost 的表格数据项目时,你需要算法提供的众多参数中的哪些(参见mng.bz/dX6N以获取完整列表)。

5.4.1 XGBoost 的关键参数

让我们回顾一下我们在列表 5.12 中之前示例中决定的特定选项,从n_estimators参数开始,该参数指定了构建集成中涉及的决策树数量,也是我们之前在本章中讨论的梯度下降过程的一部分。

XGBoost 中的n_estimators参数决定了用于生成输出的决策树数量。在标准的表格问题中,此参数的常用值介于 10 到 10,000 之间。虽然增加此值可以通过涉及更多的弱学习器来提高预测性能,但它也可能减慢训练时间。值得注意的是,存在一个理想数量的树,它可以在未见过的数据上的预测任务中最大化性能,而找到这个最佳点取决于其他 XGBoost 参数,例如学习率。为了实现高性能的 XGBoost 模型,根据手头的问题选择适当的树的数量,同时正确设置其他参数,包括学习率,是非常重要的。

与 Scikit-learn 仅限于决策树不同,XGBoost 通过其增强器参数提出了更多的选择:

  • gbtree—决策树,正如你在梯度提升中所期望的那样

  • gblinear—线性回归模型

  • dart——决策树,但优化过程更加规范化

gblinear提升器产生了一系列链式线性模型的和。由于线性组合的和是线性的,您最终会为每个使用的特征得到一个系数,类似于线性模型。您可以使用.coef方法访问这些系数。这是一种以可解释性为重点的 GBDT 模型拟合方法,因为模型可以简化为线性组合,这与您直接拟合的线性组合不同,因为使用了不同的复杂度惩罚和优化方法。最显著的区别是,您不能像线性回归或广义线性模型产生的系数那样解释系数。此外,gblinear提升器生成的截距的解释与经典线性模型不同,因为它受到学习率和提升器使用的初始估计的影响。

dart提升器与其他提升器不同,因为它结合了基于梯度下降的优化与类似于 dropout 的方法,dropout 是一种在深度学习中使用的技巧。由加州大学伯克利分校的研究员和微软研究员在 Rashmi Korlakai Vinayak 和 Ran Gilad-Bachrach 发表的论文“DART: Dropouts Meet Multiple Additive Regression Trees”中提出,该论文发表在《人工智能与统计》(Artificial Intelligence and Statistics. PMLR, 2015)。DART 专注于由于每个决策树的估计依赖于前一个决策树而导致的过拟合问题。研究人员随后从深度学习中的 dropout 理念中汲取灵感,其中 dropout 掩码随机且部分地清除了神经网络层。神经网络不能总是依赖于特定层中的某些信号来确定下一层的权重。在 DART 中,与所有先前构建的树的残差之和相比,梯度不是通过计算得到的。相反,算法在每次迭代中随机选择先前树的一个子集,并将它们的叶子节点按 1/k 的比例缩放,其中 k 是丢弃的树的数量。

gblineardart是唯一可用的替代提升器。例如,没有提升器可以模仿随机森林(如另一个 GBDT 实现 LightGBM 中那样)。然而,尽管XGBClassifierXGBRegression尚未支持随机森林提升器,您可以通过调整 XGBoost 参数和函数来获得类似的结果:

  • 使用num_parallel_tree参数并将其设置为大于 1 的数字。在优化的每一步中,梯度估计不是来自单个决策树,而是来自决策树的集成袋,从而创建了一个提升随机森林模型。在某些情况下,这种方法可能比梯度提升方法提供更好的结果,因为它将以增加的计算成本为代价来减少估计的方差。

  • 使用XGBRFClassifierXGBRFRegressor,这两个类来自 XGBoost,实现了随机森林方法。这些类仍然是实验性的。更多详情请参阅mng.bz/rKVB,但请注意,与 Scikit-learn 提供的随机森林算法存在一些差异,因为 XGBoost 计算了一个由二阶导数组成的矩阵,称为 Hessian(有关数学定义,请参阅brilliant.org/wiki/hessian-matrix/),用于加权梯度,并且它没有自助能力。因此,你的结果可能会有所不同。

对于损失函数,由参数objective控制,我们选择了reg:logistic,但也可以选择binary:logistic,两者都与二分类中的 log-loss 相当。在 XGBoost 中,损失函数被组织成六个类别:

  • reg用于回归问题(但其中也包括逻辑回归选项)。

  • binary用于二分类问题。

  • multi用于多分类。

  • count用于计数数据——即离散事件。

  • survival用于生存分析,这是一种统计分析技术,用于分析感兴趣事件发生的时间数据,例如机械部件的故障。它考虑了截尾情况,即研究中的某些个体感兴趣的事件尚未发生。

  • rank用于排名问题,例如估计一个网站在结果中应该有的排名。

除了泊松分布,用于建模事件的频率外,XGBoost 还提供了reg:gammareg:tweedie,优化用于保险索赔金额建模的两个分布,如第四章在讨论广义线性模型时所述。

各种目标函数的存在展示了 XGBoost 在不同领域可能具有的多种可能应用。关于损失函数的全面概述,请参阅mng.bz/dX6N。损失函数在梯度提升中至关重要,因为它们定义了优化目标。相比之下,评估指标在梯度提升中不用于优化梯度下降。然而,它们在监控训练过程、优化特征选择、超参数优化甚至启用早期停止以停止不再提供益处的训练中起着至关重要的作用。XGBoost 中与 Scikit-learn 的min_samples_leaf等效的是min_child_weight。这两个参数都控制决策树叶子节点所需的最小样本数。因此,它们通过限制生成的树的深度来正则化决策树。然而,由于min_child_weight指的是子节点中所需的 Hessian 权重的最小总和,而min_samples_leaf指的是叶子中所需的最小样本数,因此这两个参数在 XGBoost 和 Scikit-learn 中的使用方式不同,因此它们并不完全可比。

作为一般规则,min_child_weight影响单个决策树的构建方式,该参数的值越大,生成的树就越保守。通常要测试的值范围从 0(表示没有限制叶子节点的大小)到 10。在 2015 年纽约市数据科学学院的一次演讲中,标题为“赢得数据科学竞赛”,前 Kaggle 顶级竞争者 Owen Zhang 建议通过将 3 除以要预测的数据中稀有事件的百分比来计算此参数的最佳值。例如,按照这个经验法则,由于我们的类别是 50%/50%分割,理想值应该是 3/0.5,结果为 6。

我们在示例中没有使用的其他重要 XGBoost 参数如下:

  • 学习率,也称为eta,是 XGBoost 中的一个参数,它决定了模型学习的速率。较低的学习率允许模型以更慢的速度但更精确地收敛,这可能导致更好的预测准确性。然而,这将导致迭代次数更多,训练时间更长。另一方面,设置值过高可以加快过程,但会导致模型性能更差,因为当学习参数过高时,优化会超过其目标,就像梯度下降中发生的那样。

  • alphalambda分别是 L1 和 L2 正则化器。它们都贡献于避免 XGBoost 梯度下降优化部分中的过拟合。

  • XGBoost 中的max_depth参数控制算法的复杂度。如果此值设置得太低,模型可能无法识别出许多模式(称为欠拟合)。然而,如果设置得太高,模型可能会变得过于复杂,并识别出对新数据不具良好泛化能力的模式(称为过拟合)。理想情况下,此值应在 1 到 16 之间。

  • XGBoost 中的gamma,或称min_split_loss参数,是一个介于 0 到无穷大的正则化参数,将此值设置得更高会增加正则化的强度,从而降低过拟合的风险,但如果值过大,可能会导致欠拟合。此外,此参数还控制着决策树的结果复杂度。我们建议从 0 或低值开始,然后在设置完所有其他参数后测试增加此值。

  • XGBoost 中的colsample_bytree参数控制训练过程中给定树使用的特征或预测器的总数比例。将此值设置为小于 1 意味着每个树可能使用不同的特征子集进行预测,这可能会降低过拟合的风险或减少对单个特征的过度依赖。它还通过不在每个树中使用所有特征来提高训练速度。此参数的允许值范围在 0 到 1 之间。

  • XGBoost 中的subsample参数控制训练过程中给定树使用的实例数比例。类似于colsample_bytree,此参数可以帮助减少过拟合并提高训练时间。通过为每个树使用案例的一部分,模型可以在数据中识别出更通用的模式。subsample的默认值为 1.0,这意味着每个树都使用所有实例。

在许多情况下,你可能只需要 XGBoost 提供的或在此讨论的一些参数来满足你的项目需求。简单地调整learning_rate,设置优化步数,并将min_child_weight设置为防止梯度提升过程中单个决策树过拟合,通常就足够了。此外,设置objectivemax_depthcolsample_bytreesubsample参数可能会带来好处,但调整大量其他可用参数不太可能带来显著的改进。这一点不仅适用于 XGBoost,也适用于梯度提升的不同实现。

接下来,我们将解释是什么使得 XGBoost 在计算和预测方面表现更优。

5.4.2 XGBoost 的工作原理

如 Tianqi Chen 和 Carlos Guestrin 在 2016 年发表的论文“Xgboost: A Scalable Tree Boosting System”中所述(《第 22 届 ACM SIGKDD 国际知识发现和数据挖掘会议论文集》,2016 年),XGBoost 卓越的性能归功于几个在其他实现中不存在的创新:

  • 并行学习列块

  • 二阶近似以加快优化速度

  • 改进的分割查找算法

  • 稀疏感知的分割查找

列块是并行学习中使用的一种技术,涉及将数据集划分为列块或特征子集。这允许在多个处理器上并行训练,显著减少整体训练时间。当您训练 XGBoost 模型并查找指向多个不同核心使用的 CPU 利用率时,您可以看到它的实际效果。XGBoost 不能像其他集成模型(如随机森林)那样同时使用多个核心训练多个模型,因为梯度提升是一个序列模型,每个模型都是在另一个模型的结果之后训练的。相反,XGBoost 的每个单独模型的训练过程被分配到多个核心,以提高效率和速度。

目前,XGBoost 可以通过两个不同的 API 在 Python 中使用:原生 API 和 Scikit-learn API。在这本书中,我们将仅使用 Scikit-learn API,因为它在最佳建模实践方面的优势,以及能够轻松利用 Scikit-learn 库中各种工具的额外好处,如模型选择和管道,正如第四章所述。

当使用原生 API 时,用户需要将他们的数据转换为DMatrix,这是一个针对内存效率和训练速度优化的 XGBoost 内部数据结构(mng.bz/VVxP)。使用 DMatrix 格式使得列块技术成为可能。然而,当使用 Scikit-learn API 时,用户可以将他们的数据作为 pandas DataFrame 或 Numpy 数组输入,无需显式转换为 DMatrix 格式。这是因为 XGBoost 在底层执行转换,使过程更加流畅。因此,可以安全地选择最适合您偏好的 API,因为这两个 API 提供相同的性能,只是在一些参数、默认值和选项上有所不同。

为了加快优化速度,采用包含二阶导数(从一阶导数派生的梯度)的二阶近似,基于更全面的根查找技术,即牛顿法。在最小化的上下文中,我们通常将牛顿法称为牛顿下降而不是梯度下降。列表 5.13 展示了它作为一个新的类实现,即NewtonianGradientBoosting类,它继承自原始的 GradientBoosting 类,并对其现有方法和属性进行了一些添加和修改。特别是,我们添加了 Hessian 计算以平衡梯度步骤,以加速收敛,并添加了一个正则化项以防止过拟合。

列表 5.13 XGBoost 的工作原理

class NewtonianGradientBoosting(GradientBoosting):              ①
    """the Newton-Raphson method is used to update the predictions"""

    reg_lambda = 0.25                                           ②

    def hessian(self, y_true, y_pred):
        hessian = np.ones_like(y_true)                          ③
        return hessian

    def fit(self, X, y):
        self.init = self.logit(np.mean(y))
        y_pred = self.init * np.ones((X.shape[0],))

        for k in range(self.n_estimators):
            gradient = self.gradient(self.logit(y), y_pred)
            hessian = self.hessian(self.logit(y), y_pred)
            tree = DecisionTreeRegressor(**self.params)
            tree.fit(
                X, 
                -gradient / (
                    hessian + self.reg_lambda
                )
            )                                                   ④
            self.trees.append(tree)
            y_pred += self.learning_rate * tree.predict(X)

cls = NewtonianGradientBoosting(n_estimators=300,
                                learning_rate=0.1,
                                max_depth=4,
                                min_samples_leaf=3, 
                                random_state=0)                 ⑤

cls.fit(X, y)                                                   ⑥
preds = cls.predict(Xt)
score = accuracy_score(y_true=yt, y_pred=preds)
print(f"Accuracy: {score:0.5f}")                                ⑦

① 定义一个新的类 NewtonianGradientBoosting 作为 GradientBoosting 的子类

② 设置正则化参数 reg_lambda

③ 使用全为 1 的常数 Hessian 矩阵初始化

④ 通过将负梯度除以 Hessian 和正则化参数之和来拟合决策树

⑤ 创建一个具有指定超参数的 NewtonianGradientBoosting 类实例

⑥ 将 NewtonianGradientBoosting 模型拟合到训练数据

⑦ 使用拟合的模型预测目标值,并计算准确度评分以进行评估

结果的准确性略好于我们从原始 GradientBoosting 类中获得的结果:

Accuracy: 0.82514

在我们这个例子中,Hessian 矩阵可能并不特别有用,因为它对于所有数据都是相同的,这是因为我们使用的目标函数类型:平方误差。然而,在优化其他目标函数的上下文中,Hessian 矩阵提供了关于函数曲率的信息,这可以用来确定函数的方向和变化率。直观上,你可以推断出,随着曲率的增大,Hessian 的值也会增大,这会减少梯度的作用,起到学习率制动的作用。相反,较小的曲率会导致学习率的加速。使用 Hessian 的信息,你可以为每个训练样本获得一个自适应的学习率。然而,作为副作用,计算二阶导数通常可能很复杂或难以处理,需要大量的计算。确定二阶导数的解析表达式和数值方法需要大量的计算努力。在下一章,我们将提供有关如何通过计算梯度和大阵的解析和数值方法来构建自定义目标函数的更多信息。

XGBoost 使用的 Newton 优化中也扮演着正则化项的角色,这些项在 Hessian 中汇总并进一步降低目标——即由基学习器估计的调整。XGBoost 还借鉴了梯度下降的另一个想法,即采用正则化,如我们在示例中实现的那样,以及 L1 正则化。额外的正则化项有助于平滑最终学习的权重,并通过直接修改 Newton 下降步骤来避免过拟合。因此,考虑如何调整 L1 和 L2 值(在 XGBoost 和 LightGBM 实现中称为 lambda 和 alpha)作为重要的超参数,以改善优化结果并减少过拟合非常重要。这些正则化值确保 Newton 下降在优化过程中采取较小的步骤。

在下一节中,我们将继续通过检查分割查找算法对算法提供的加速性能的贡献,来探索 XGBoost 引入的新功能。

5.4.3 使用直方图分割加速

梯度提升基于二叉树,通过将数据分区以在结果分割中获得比原始集合更好的优化目标度量。由于梯度提升将所有特征视为数值,它有独特的方式来决定如何分区。为了找到用于分割的特征和分割规则,二叉树决策应遍历所有特征,对每个特征进行排序,并评估每个分割点。最终,决策树应选择导致相对于目标有更好改进的特征及其分割点。

随着大数据集的出现,决策树中的分割过程对基于序列模型的原始 GBDT 架构提出了严重的可扩展性和计算问题。从计算角度来看,GBDT 的主要成本在于学习决策树,而学习决策树中最耗时的工作是找到最佳分割点。

持续寻找最佳分割点需要相当长的时间,这使得在大量特征和实例上进行训练时算法变得非常耗时。直方图分割通过用直方图的分割点来替换每个特征的值,以总结其值,从而有助于减少时间。列表 5.14 模拟了在我们的数据问题上的分割搜索。为此,我们定义了一个目标函数和一个分割函数,这两个函数既可以作为原始决策树分割算法运行,也可以通过基于直方图的更快分割运行。

列表 5.14 直方图分割

import numpy as np

def gini_impurity(y):
    _, counts = np.unique(y, return_counts=True)
    probs = counts / len(y)
    return 1 - np.sum(probs**2)                              ①

def histogram_split(x, y, use_histogram, n_bins=256):
    if use_histogram:
        hist, thresholds = np.histogram(
            x, bins=n_bins, density=False
        )                                                    ②
    else:
        thresholds = np.unique(x)                            ③
    best_score = -1
    best_threshold = None                                    ④
    for threshold in thresholds:                             ⑤
        left_mask = x <= threshold
        right_mask = x > threshold
        left_y = y[left_mask]
        right_y = y[right_mask]                              ⑥
        score = (
            gini_impurity(left_y) * len(left_y) 
            + gini_impurity(right_y) * len(right_y)
        )                                                    ⑦
        if score > best_score: q                             ⑧
            best_threshold = threshold
            best_score = score
    return best_threshold, best_score                        ⑨

① 计算并返回标签集 y 的基尼不纯度的函数

② 如果 use_histogram 为 true,则计算所选特征的直方图

③ 如果 use_histogram 为 false,则仅枚举特征中的所有唯一值

④ 初始化最佳得分和阈值

⑤ 遍历所有可能的阈值

⑥ 根据 selected threshold 将 y 分割成左右子集

⑦ 计算左右子集的基尼不纯度得分

⑧ 如果当前分割的基尼不纯度得分高于先前最佳分割,则更新最佳得分和阈值

⑨ 返回最佳阈值及其对应的基尼不纯度得分

在列表 5.14 中的代码中,在定义评分函数和基尼不纯度之后,我们定义了一个函数,该函数选择一个特征并枚举其潜在分割的值以进行评估。如果我们使用基本方法,则考虑所有唯一值。相反,使用直方图方法,计算一个 256 个分箱的直方图,我们使用分隔值以作为潜在分割候选者的分箱来探索。如果我们的特征有超过 256 个唯一值,使用直方图将节省我们在迭代所有分割候选者并使用评分函数评估它们时的大量时间。

现在我们已经解释了示例函数的工作原理,我们准备进行测试。我们决定在预测宿主是否位于高价或低价范围内的分类任务中,最优地分割纬度。由于纬度特征有许多独特的值可以作为分割候选者,因为曼哈顿是一个长而窄的南北岛屿,房地产价值随着纬度变化,因此这应该是一个困难的任务,因为我们预计会有许多不同的纬度需要与目标进行比较。

在我们的第一次测试中,我们试图仅通过评估特征呈现的所有唯一值来找到最佳分割:

%%time
histogram_split(x=data.latitude, y=target_median, use_histogram=False)

CPU times: user 46.9 s, sys: 10.1 ms, total: 46.9 s
Wall time: 46.9 s
(40.91306, 24447.475447387256)

在我们的第二次测试中,我们依赖于评估基于特征的 256 个箱直方图找到的分割点:

%%time
histogram_split(
    x=data.latitude,
    y=target_median,
    use_histogram=True,
    n_bins=256
)

CPU times: user 563 ms, sys: 0 ns, total: 563 ms
Wall time: 562 ms
(40.91306, 24447.475447387256)

在直方图分割的底层,我们发现分组,其中变量的值被分组到离散的箱中,每个箱被分配一个唯一的整数以保持箱之间的顺序。分组也常被称为 k-箱,其中名称中的 k 指的是将数值变量重新排列成多少组,它用于直方图绘图,其中你可以声明 k 的值或自动设置它以总结和表示你的数据分布。

加速不仅是因为要评估的分割点数量较少,这些分割点可以并行测试,从而使用多核架构,而且还因为直方图是基于整数的结构,比连续值向量处理得更快。

XGBoost 使用一种算法来计算最佳分割,该算法基于对值进行预排序和直方图的用法。预排序分割的工作原理如下:

  • 对于每个节点,枚举特征

  • 对于每个特征,按其值对实例进行排序

  • 使用线性扫描和直方图,确定特征的最好分割并计算信息增益

  • 在所有特征及其最佳分割中挑选最佳解决方案

XGBoost 还有其他概念改进:传统的分割查找算法用exact表示,作为tree_method参数的值。加权分位数草图,在 XGBoost API 中称为approx,是 XGBoost 独有的特性。这种分割查找技术利用近似和利用梯度统计信息衍生出的信息。通过使用分位数,该方法在候选者中定义潜在的分割点。值得注意的是,分位数是加权的,以优先选择能够减轻高梯度、减少重大预测错误的候选者。

使用直方图的加权分位数草图现在作为tree_method="hist"可用,自 2.0.0 版本发布以来,这是默认方法。相比之下,approx树方法为每个迭代生成一组新的箱,而hist方法则重用多个迭代中的箱。

算法的另一个特性与 DMatrices 中的数据存储有关。树学习的最耗时部分是对数据进行排序。为了减少排序成本,我们提出将数据存储在内存单元中:一个块。这允许我们线性扫描预排序条目并并行处理,从而为我们提供了一个高效的并行算法用于分割查找。

在 LightGBM 中成功实现直方图聚合后,XGBoost 采用了它。直方图聚合也是HistGradientBoosting的主要特性,这是 Scikit-learn 基于直方图的梯度提升,我们将在 LightGBM 之后介绍。

5.4.4 对 XGBoost 应用早期停止

5.3.1 节说明了早期停止如何在 Scikit-learn 的梯度提升中工作。XGBoost 也支持早期停止。您可以通过在实例化 XGBClassifier 或 XGBRegressor 模型时添加一些参数来指定早期停止:

  • early_stopping_rounds—这是在停止训练之前等待验证分数没有改进的轮数。如果您将其设置为正整数,则当验证集的性能在该轮数内没有改进时,训练将停止。

  • eval_metric—这是用于早期停止的评估指标。默认情况下,XGBoost 使用rmse作为回归的均方根误差和error作为分类的准确率。不过,您也可以从长长的列表(可在mng.bz/xK2W)中指定任何其他指标,以及指定您自己的指标(将在下一章关于高级机器学习主题的章节中讨论)。

除了设置这些参数外,在拟合时,您还必须指定一个包含其目标的样本,用于监控评估指标。这是通过parameter eval_set完成的,它包含一个包含所有验证样本及其响应的元组列表。在我们的例子中,我们只使用一个验证集。尽管如此,如果有多个样本需要监控,XGBoost 将只考虑数据响应的最后元组用于停止目的。

在列表 5.15 中,我们通过将数据分为训练集和测试集来复制我们之前实验过的相同方法。然而,为了正确监控评估指标,我们进一步将训练集分割以从中提取验证集。

列表 5.15 对 XGBoost 应用早期停止

train, test = train_test_split(
    range(len(data)),
    test_size=0.2,
    random_state=0
)                                                          ①
train, validation = train_test_split(
    train,
    test_size=0.2,
    random_state=0
)                                                          ②

xgb = XGBClassifier(booster='gbtree',
                    objective='reg:logistic',
                    n_estimators=1000,
                    max_depth=4,
                    min_child_weight=3,
                    early_stopping_rounds=100,             ③
                    eval_metric='error')                   ④

X = column_transform.fit_transform(data.iloc[train])
y = target_median[train]

Xv = column_transform.transform(data.iloc[validation])
yv = target_median[validation]

xgb.fit(X, y, eval_set=[(Xv, yv)], verbose=False)          ⑤

Xt = column_transform.transform(data.iloc[test])
yt = target_median[test]

preds = xgb.predict(Xt)
score = accuracy_score(y_true=yt, y_pred=preds)
print(f"Accuracy: {score:0.5f}")                           ⑥

① 使用固定的随机种子将数据索引分为训练集和测试集

② 使用相同的随机种子进一步将训练集分为训练集和验证集

③ 初始化一个具有 100 轮早期停止耐心的 XGBoost 分类器

④ 使用'error'参数,相当于准确率,作为评估指标

⑤ 将 XGBoost 分类器拟合到训练数据 X 和标签 y,以及验证数据 Xv 和 yv 的性能

⑥ 在将预测标签与真实标签比较后打印准确率得分

训练完成后,我们成功获得了这个准确度指标,它在我们的先前交叉验证结果中表现略逊一筹,因为它是在更少的示例上训练得到的——也就是说,是可用数据的 64%,因为我们保留了 20%用于测试和 16%用于验证:

Accuracy: 0.82657

在训练过程中,评估指标会不断被检查,如果迭代次数没有超过由early_stopping_rounds指定的次数,拟合过程将被终止。最佳迭代会被自动记录并在预测时使用。因此,你无需对模型做任何事情。如果你需要验证停止前的迭代次数,可以通过查询模型的best_iteration属性来获取。在我们的例子中,xgb.best_iteration返回 200。

5.5 LightGBM 简介

LightGBM 首次在 2017 年一篇题为“LightGBM: A Highly Efficient Gradient Boosting Decision Tree”的论文中被介绍,该论文由微软的 Guolin Ke 及其团队撰写(mng.bz/AQdz)。最近,该软件包达到了 4.3.0 版本,这是我们在这本书中测试的版本。根据作者的说法,LightGBM 中的“light”一词强调了该算法比传统的梯度提升决策树训练更快、内存使用更低。论文通过在多个公共数据集上的实验,证明了该算法的有效性及其通过超过 20 倍的速度加快传统梯度提升决策树的训练过程,同时保持几乎相同的准确率。LightGBM 作为开源软件在 GitHub 上提供(github.com/microsoft/LightGBM/),迅速在数据科学家和机器学习从业者中获得了人气。

在理论上,LightGBM 与 XGBoost 有许多相似的特征,例如支持缺失值、原生处理分类变量、GPU 训练、网络并行训练以及单调性约束。我们将在下一章中详细介绍这些内容。此外,LightGBM 还支持稀疏数据。然而,它的主要优势在于速度,因为它在许多任务上比 XGBoost 快得多,这使得它在 Kaggle 竞赛和实际应用中都变得非常流行。Kaggle 社区很快注意到了 LightGBM,并将其与已经流行的 XGBoost 一起纳入他们的竞赛作品中。实际上,跟踪数据科学竞赛场景的网站 mlcontests.com 在 2022 年报告称,LightGBM 已经成为竞赛获胜者的首选工具,在受欢迎程度上超过了 XGBoost。令人印象深刻的是,25%的表格问题解决方案都是基于 LightGBM 的。虽然 LightGBM 在数据科学从业者中取得了相当的成功,但 XGBoost 在整体上仍然更受欢迎。例如,XGBoost 仓库在 GitHub 上的星标比 LightGBM 仓库多得多。

LightGBM 是一个跨平台的机器学习库,适用于 Windows、Linux 和 MacOS。可以使用 pip 或 conda 等工具进行安装,或从源代码构建(请参阅完整的安装指南mng.bz/ZlEP)。它的使用语法与 Scikit-learn 类似,使得熟悉 Scikit-learn 的用户可以轻松过渡到 LightGBM。在优化梯度下降时,LightGBM 遵循 XGBoost 的步伐,通过使用牛顿-拉夫森更新来控制梯度下降,这涉及到将梯度除以海森矩阵。GitHub 上 Guolin Ke 的回答确认了这一点(请参阅github.com/microsoft/LightGBM/issues/5233)。

让我们在之前使用 ScikitLearn 的 GradientBoosting 和 XGBoost 考察的相同问题上测试这个算法。

列表 5.16 LightGBM 分类器

from lightgbm import LGBMClassifier

accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
lgbm = LGBMClassifier(boosting_type='gbdt',            ①
                      n_estimators=300,
                      max_depth=-1,
                      min_child_samples=3,
                      force_col_wise=True,             ②
                      verbosity=0) 

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('lightgbm', lgbm)])                              ③

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data, 
                           y=target_median,
                           scoring=accuracy,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)      ④

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"CV Accuracy {mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f}",
      f"secs pred: {score_time:0.2f} secs")            ⑤

① 初始化一个具有估计器数量、最大树深度和最小子样本数量的 LGBMClassifier

② 强制按列构建直方图

③ 创建一个包含列转换步骤和 LGBMClassifier 步骤的模型管道

④ 使用模型管道执行五折交叉验证,并使用准确率评分

⑤ 打印交叉验证期间获得的测试平均分数和标准差

以下是在准确率、训练和预测时间方面的令人印象深刻的结果:

0.826 (0.004) fit: 1.16 secs pred: 0.16 secs

与 XGBoost 类似,LightGBM 使用n_estimatorslearning_ratelambda_l1lambda_l2(L1 和 L2 正则化)等参数来控制梯度下降。帮助 LightGBM 控制其复杂性的最重要的参数如下:

  • max_depth—此参数控制集成中每个树的最大深度。较高的值会增加模型的复杂度,使其更容易过拟合。如果设置为-1,则表示不对树的生长设置限制。

  • num_leaves—此参数指定树中最大叶子节点数,因此也决定了模型的复杂度。为了避免过拟合,应将其设置为小于2**(max_depth)

  • min_data_in_leaf—此参数控制每个叶子节点中必须存在的最小样本数。较高的值可以防止树生长过深和过拟合,但如果设置得太高,也可能导致欠拟合。默认值为 20。我们建议尝试较低的值,例如 10,然后进行测试,将值增加到 300。

参数feature_fractionbagging_fraction控制 LightGBM 从特征和示例中采样的方式:

  • feature_fraction—此参数控制每个分割时考虑的特征比例。类似于 XGBoost 中的colsample_bytree参数,它可以通过防止模型过度依赖任何特征来帮助减少过拟合。

  • bagging_fraction—此参数控制每个树使用的数据的分数。类似于 XGBoost 中的 subsample 参数,它可以通过从数据中随机采样来帮助减少过拟合并提高训练速度。

  • bagging_freq—此参数在 XGBoost 中不存在,它决定了 bagging 应该应用的频率。当设置为 0 时,即使指定了bagging_fraction,也会关闭 bagging 示例。值为 n 表示每 n 次迭代进行一次 bagging。例如,值为 2 表示每两次(一半的时间)进行一次 bagged 迭代。

与 LightGBM 在训练过程中的执行方式相关,verbosity控制训练过程中的输出信息量,而force_col_wise表示在树构建期间基于列构建特征分割的直方图。LightGBM 可以按列或按行构建直方图。按列构建直方图通常更快,但可能需要更多内存,特别是对于具有大量列的数据集。按行构建直方图较慢,但处理具有大量列的数据集时可能更节省内存。LightGBM 将自动选择为数据集构建直方图的最佳方法。然而,您也可以通过设置force_col_wiseforce_row_wise参数来强制 LightGBM 使用特定方法。

对于 XGBoost,LightGBM 也可以通过指定boosting参数来使用不同的基础学习器:

  • gbdt—默认选项,使用决策树作为基础学习器

  • rf—实现了随机森林算法

  • dart—实现了“Dropouts meet Multiple Additive Regression Trees”算法

此外,将参数linear_tree设置为 true,因为你正在使用默认的boosting=gbdt,将拟合一个分段线性梯度提升树——即终端节点具有线性模型的决策树。这是一个折衷方案,它同时使用决策树的非线性学习能力和线性模型对未见、异常案例的推演能力。

在下一节中,我们将仔细检查区分 LightGBM 和 XGBoost 的所有创新。

5.5.1 LightGBM 如何生长树

让我们检查区分 LightGBM 和 XGBoost 的每个特征,从 LightGBM 如何生长决策树开始。与 XGBoost 按层次增加树(也称为深度优先)不同,LightGBM 按叶节点增长树(也称为最佳优先)。这意味着算法选择提供最大增益的叶节点,然后进一步分割它,直到不再有利可图。相比之下,层次方法同时分割同一深度的所有节点。

总结来说,在 XGBoost 的层向增长方法中,算法将所有树叶生长到同一水平。然后它同时将它们分割,这可能会导致许多无意义的叶子节点,这些节点对最终预测的贡献不大。相比之下,LightGBM 的叶向增长方法在每一步都分割具有最大损失减少的叶子,从而产生更少的叶子节点但准确率更高。叶向方法允许 LightGBM 只关注对目标变量影响最大的重要特征。这意味着算法可以通过更少的分割和更少的树快速收敛到最优解。

图 5.10 展示了两种方法的表示:左侧是层向方法,右侧是叶向方法,两者都限制最多有四个终端节点。这两种方法在决定应用哪些规则以及如何分割数据方面采取了完全不同的路径。

图片

图 5.10 层向(左侧)和叶向(右侧)树增长的不同

需要指出的是,如果你允许两棵树使用相同的数据生长,一棵使用叶向方法,另一棵使用完全的层向方法,它们将定义相同的终端叶子和预测。区别在于它们的构建方式,叶向方法在首先分割提供最大信息增益的节点方面更为激进。

这意味着在基于达到一定数量的终端节点或树分割的特定深度应用停止规则时,叶向和层向方法会有所不同。在这种情况下,叶向方法可能导致更小的树、更快的训练时间和更高的准确率,但也伴随着过拟合风险增加。为了控制树叶的深度增长并解决过拟合问题,你可以在 LightGBM 中控制最大深度参数。

5.5.2 通过独家特征捆绑和基于梯度的单侧采样获得速度

为了进一步减少训练时间,梯度提升和其他许多机器学习算法的基本策略是减少处理示例的数量。减少处理值数量的最简单方法是使用随机抽样(即行减少)和/或降维技术,如列抽样或主成分分析(即列减少)。尽管抽样可以在数据存在噪声的情况下提高准确性,但过度抽样可能会损害训练过程并降低预测性能。在 LightGBM 中,基于梯度的单侧抽样算法(GOSS)决定了抽样的方式和程度。降维技术依赖于识别数据中的冗余,并通过线性组合(通常是加权求和)将它们结合起来。然而,线性组合可能会破坏数据中的非线性关系。通过丢弃罕见信号进行降维可能会导致模型精度降低,如果数据问题的成功解决依赖于这些弱信号。在 LightGBM 中,降维是通过独家特征捆绑(EFB)来处理的,这是一种在不丢失信息的情况下减少列维度的方法。

让我们先解释 LightGBM 中的两个主要速度提升,从 EFB 的工作原理开始。EFB 是一种技术,它有效地减少了特征数量,同时不损害数据完整性。当广泛使用 one-hot 编码和二进制特征时,许多特征变得稀疏,值少而零多。你可以通过求和这些特征并编码一些值来保留所有非零值而不会丢失。LightGBM 通过将这些特征分组到独家特征捆绑(Exclusive Feature Bundles)中来优化计算和数据维度,确保预测准确性得到保持。

图 5.11 展示了如何有效地将两个特征捆绑在一起。该解决方案涉及仅在特征 A 非零值时添加特征 B 到特征 A,使用特征 A 中存在的最大值。这种组合特征将保留原始特征的顺序,因为特征 A 的值与特征 B 的值是分开的,并将位于值分布的不同部分。

图 5.11

图 5.11 演示了 EFB 在结合两个特征时的工作原理

寻找捆绑独家特征的最佳方式是一个复杂的问题,被归类为 NP-hard。然而,根据 Guolin Ke 及其团队撰写的 LightGBM 论文,贪婪算法可以通过自动捆绑许多特征来提供一个良好的近似。特征捆绑算法按顺序工作,选择具有最少重叠值的特征并将它们捆绑在一起。如果它找到另一个具有最小重叠的特征,它将继续捆绑。否则,它开始一个新的捆绑,直到找不到更多的捆绑为止。停止规则由两个特征之间的冲突程度提供。如果它们的冲突多于某个 gamma 阈值,则无法创建捆绑,如果没有更好的候选者,整个过程可能停止。尽管从这个贪婪过程中产生的捆绑不保证是最佳的,但该算法在合理的时间内提供了一个可接受的解决方案。

论文中提出的其他性能改进是 GOSS。正如我们提到的,如果 EFB 旨在减少列维度,GOSS 则通过有效采样行来工作,而不带偏见。

GOSS 基于以下观察:某些数据实例不太可能为找到分割点提供有用的信息。搜索精心选择的训练集子集可以节省计算时间,而不会影响预测精度。此外,在梯度提升决策树中,算法在为每个数据实例的梯度进行优化时隐式指定数据实例的权重。确定权重对于计算先前估计的纠正至关重要,但这也可以用于采样可能更有趣学习的数据实例。

GOSS 估计具有更大梯度的数据示例对信息增益的贡献更大。专注于具有更大梯度的示例,并忽略其中一部分具有较小梯度的示例,应该可以减少处理的数据实例数量,同时仍然优化算法以进行预测。GOSS 的流程如下:

  • GOSS 首先根据梯度绝对值对数据示例进行排序。

  • 它选择顶部 a × 100%的数据示例。

  • 它从剩余数据中随机抽取 b × 100%的数据示例。

  • 它使用 1 的权重对顶部数据示例进行训练,并使用(1 – a) / b 的权重对随机抽取的数据示例进行训练。

最终加权是必要的,以保持数据集的原始数据分布,并避免其表示中的任何不希望的变化。

GOSS 可以加速梯度提升决策树的训练,尤其是在处理大型数据集和复杂树时。原始论文的作者证明了与传统方法相比,GOSS 的采样近似误差对于大型数据集来说变得可以忽略不计。在我们使用 GOSS 的经验中,最佳情况下,你得到的结果与标准 LightGBM 训练相似。尽管如此,速度提升是显著的,这使得 GOSS 在寻找正确的超参数或选择与你的问题最相关的特征时,是一个更快实验的好选择。

与我们提出的其他加速方法不同,GOSS 不是默认使用的:你必须指定你想要使用它。

5.5.3 将早期停止应用于 LightGBM

LightGBM 支持早期停止,控制它的参数与 XGBoost 实现中使用的参数相似。在列表 5.17 的例子中,我们使用 LightGBM 进行训练,并在训练阶段使用测试集来评估算法的性能。如果在测试集上 100 次迭代内没有性能提升,算法将停止训练过程。它选择迄今为止在测试集上性能最高的迭代轮次。

列表 5.17 将早期停止应用于 LightGBM

from lightgbm import LGBMClassifier, log_evaluation

train, test = train_test_split(range(len(data)), test_size=0.2, 
random_state=0)                                             ①
train, validation = train_test_split(
    train, 
    test_size=0.2,
    random_state=0
)                                                           ②

lgbm = LGBMClassifier(boosting_type='gbdt', 
                      early_stopping_round=150,
                      n_estimators=1000, 
                      max_depth=-1,
                      min_child_samples=3,
                      force_col_wise=True,
                      verbosity=0)                          ③

X = column_transform.fit_transform(data.iloc[train])
y = target_median[train]

Xv = column_transform.transform(data.iloc[validation])
yv = target_median[validation]

lgbm.fit(X, y, eval_set=[(Xv, yv)],                         ④
         eval_metric='accuracy',                                ⑤
         callbacks=[log_evaluation(period=0)])                  ⑥

Xt = column_transform.transform(data.iloc[test])
yt = target_median[test]

preds = lgbm.predict(Xt)
score = accuracy_score(y_true=yt, y_pred=preds)
print(f"Test accuracy: {score:0.5f}")                       ⑦

① 使用固定的随机种子将数据索引分割为训练集和测试集

② 使用相同的随机种子进一步将训练集分割为训练集和验证集

③ 使用估计器数量、最大深度和最小子样本数初始化 LightGBM 分类器

④ 将 LightGBM 分类器拟合到训练数据 X 和标签 y,以及在验证数据 Xv 和 yv 上的性能

⑤ 将准确度设置为评估指标

⑥ 设置一个回调以抑制评估(周期=0)

⑦ 在比较预测标签与真实标签后打印准确度分数

即使在这种情况下,结果也受到我们只训练可用数据的 64%这一事实的惩罚:

Accuracy: 0.82585

然而,与 XGBoost 实现相比,你可以在代码中注意到一些细微的差异。eval_metric使用不同的名称(你可以在mng.bz/RVZK上检查)并且,为了在训练期间抑制评估的打印,你不需要像在 XGBoost 中那样使用 verbose 参数;相反,你必须指定一个回调函数(log_evaluation),该函数必须在拟合时在回调函数列表中声明。

最近,早期停止也被实现为一个回调函数(见mng.bz/2yK0)。在模型实例化期间保持早期停止轮次的声明只是为了保持与 XGBoost 的 API 兼容性。如果你将早期停止作为回调使用,你将更有控制权来决定 LightGBM 停止训练的方式:

  • first_metric_only允许你指示是否只使用第一个指标进行早期停止或使用你指定的任何指标。

  • min_delta表示保持训练的最小指标改进,通常设置为零(任何改进),但可以提高到对集成增长施加更严格的控制。

在前面的例子中,你只需从 LGBMClassifier 实例化中删除early_stopping_rounds,并将适当的回调添加到 fit 方法中的回调列表中,即可获得相同的结果:

early_stopping(
    stopping_rounds=150,
    first_metric_only=True,
    verbose=False,
    min_delta=0.0
)

无论你使用什么方法,导致最佳验证分数的迭代索引都将存储在模型的best_iteration属性中,并且当预测时将自动使用该迭代。

5.5.4 使 XGBoost 模仿 LightGBM

自从 LightGBM 及其不平衡决策树的出色应用引入以来,XGBoost 也开始支持除了其原始的层策略之外,还支持叶策略。在 XGBoost 中,原始的层策略被称为depthwise,而叶策略被称为lossguide。通过使用grow_policy参数设置一个或另一个,你可以让 XGBoost 表现得像 LightGBM 一样。此外,XGBoost 的作者建议,在使用 lossguide 增长策略时,应设置以下参数以避免过拟合:

  • max_leaves——设置要添加的最大节点数,并且仅与 lossguide 策略相关。

  • max_depth——设置树的最大深度。如果grow_policy设置为depthwise,则max_depth的行为与往常一样。然而,如果grow_policy设置为lossguide,则max_depth可以设置为零,表示没有深度限制。

顺便说一下,你也有相同的参数在 LightGBM 中使用,用于相同的目的(max_leaves是别名——即参数num_leaves的另一个工作名称)。

5.5.5 LightGBM 如何启发 Scikit-learn

在 Scikit-learn 0.21 版本中,添加了两种基于梯度提升树的创新实现:HistGradientBoostingClassifierHistGradientBoostingRegressor,灵感来自 LightGBM。你可能想知道,如果当前的 LightGBM 和 XGBoost 版本可以提供你需要的一切来开发基于梯度提升的最佳性能表格解决方案,你为什么要费心于这种新的实现。它们还确保与 Scikit-learn API 完全兼容。花时间看看它是值得的,因为基于直方图的实现,尽管现在还在进行中,预计将取代原始实现,提供对学习过程和决策树构建的相同控制。此外,它在某些特定应用中甚至比 XGBoost 和 LightGBM 表现出更好的预测性能。因此,对于你的特定问题进行测试可能是值得的。

与原始的 Scikit-learn 梯度提升实现相比,新的基于直方图的实现具有以下新特性:

  • 分箱

  • 多核(初始实现是单核)

  • 不支持稀疏数据

  • 内置对缺失值的支持

  • 单调性和交互约束

  • 原生分类变量

目前,在谈论差异时,新的基于直方图的实现中不支持稀疏数据。因此,如果您的数据在稀疏矩阵中,您应该首先将数据矩阵密集化。此外,一些典型的 GradientBoostingClassifierGradientBoostingRegressor 特征仍需要支持——例如,一些损失函数。

在 API 方面,大多数参数与 GradientBoosting ClassifierGradientBoostingRegressor 相同。一个例外是 max_iter 参数,它取代了 n_estimators。以下列表显示了将 HistGradientBoostingClassifier 应用于我们的分类问题,并使用 Airbnb NYC 数据集对高于中值市场价值的列表进行分类的示例。

列表 5.18 新的 Scikit-learn 的直方图梯度提升

from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import accuracy_score

accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)

model = HistGradientBoostingClassifier(learning_rate=0.1,
                                       max_iter=300,
                                       max_depth=4,
                                       min_samples_leaf=3,
                                       random_state=0)      ①

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)])                                  ②

cv_scores = cross_validate(estimator=model_pipeline, 
                           X=data, 
                           y=target_median,
                           scoring=accuracy,
                           cv=cv, 
                           return_train_score=True,
                           return_estimator=True)           ③

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})", 
      f"fit: {fit_time:0.2f}",
      f"secs pred: {score_time:0.2f} secs")                 ④

① 使用提升算法的特定超参数初始化 HistGradientBoostingClassifier

② 创建一个结合数据预处理(column_transform)和模型的模型管道

③ 在模型管道上执行五折交叉验证,返回分数和训练估计器

④ 打印交叉验证准确度分数的均值和标准差

结果是

0.827 (0.005) fit: 1.71 secs pred: 0.13 secs

与我们之前使用 XGBoost 和 LightGBM 的示例相比,这个示例在使用的命令和 max_iter 参数上有所不同,以 n_estimators 代替了通常的 max_iter。此外,Scikit-learn 的新提升算法是一个直方图算法。您只需设置 max_bins 参数来改变初始默认值 255(因为 1 被保留用于缺失情况,所以是 256 个箱子)。

该算法仍在开发中,并且缺乏对稀疏数据的支持。这意味着在存在许多 one-hot 编码特征的情况下,无论您如何准备数据,它都无法像 XGBoost 或 LightGBM 那样快速运行。

摘要

  • 集成算法通过使用多个模型或将它们链接在一起来提高单个模型的预测能力:

    • 集成算法通常基于决策树。

    • 有两种核心集成策略:平均和提升。

    • 平均策略,如随机森林,倾向于减少预测的方差,同时仅略微增加偏差。

    • 粘贴是一种平均方法,涉及创建一组不同的模型,这些模型在数据的子样本上训练,并将预测结果汇总在一起。

    • Bagging 与平均类似,但使用 bootstrapping 而不是子采样。

    • 平均方法可能计算密集,并通过采样排除数据分布的重要部分来增加偏差。

  • 随机森林是一种集成学习算法,通过在建模过程中使用 bootstrapping 样本和子采样特征来结合决策树(随机补丁):

    • 它创建了一组彼此不同的模型,并产生更可靠和准确的预测。

    • 它可以用来确定特征重要性并测量数据集中案例的相似性。

    • 算法需要对其少数超参数进行微调,如使用的树的数量,并通过设置用于分裂的最大特征数、树的最大深度和终端分支的最小大小来调整偏差-方差权衡。

    • 如果树的数量设置得太高,计算成本可能会很高。

  • ERT(极端随机树)是随机森林算法的一种变体:

    • 它在每个决策树节点随机选择特征进行分裂,导致方差较小(因为树更多样化),但偏差更大(随机化牺牲了一些决策树的预测精度,导致更高的偏差)。

    • 对于具有许多共线性和噪声特征的的大型数据集,它计算效率更高且更有用。

    • 通过使生成的树集相关性较低来降低方差。

  • GBDT 是一种高度有效的机器学习方法,用于处理表格数据问题。它已成为多个领域的领先方法,包括多类分类、广告点击预测和搜索引擎排名。与其他方法,如神经网络、支持向量机、随机森林和 bagging 集成相比,GBDT 通常在标准表格问题中表现更好。

  • 梯度提升之所以有效,是因为它结合了梯度下降,这是一种典型的线性模型和神经网络的优化过程,以及基于先前决策树总和的梯度训练的决策树。

  • Scikit-learn 为回归和分类任务提供了梯度提升算法的最早选项之一。最近,原始算法被一个基于直方图的更快版本所取代,该版本仍在开发中。

  • XGBoost 是一种梯度提升决策树算法,在 Kaggle 的希格斯玻色子机器学习挑战赛中成功应用后获得了流行。它基于基于牛顿下降的更复杂优化,并提供了以下优势:

    • 处理各种输入数据类型的能力

    • 支持自定义目标函数和评估函数

    • 自动处理缺失值

    • 简单支持 GPU 训练

    • 适应单调性和特征交互约束

    • 独立计算机上的多核心和缓存优化

  • LightGBM 是一种高效的梯度提升决策树算法,由微软的 Guolin Ke 及其团队在 2017 年的一篇论文中提出。该算法旨在比传统的梯度提升决策树更快、更节省内存,这在多个公共数据集的实验中得到了证明。LightGBM 算法通过其叶节点分裂策略和 EFB 实现了这一点。

第六章:高级特征处理方法

本章涵盖

  • 使用更高级的方法处理特征

  • 选择有用的特征以创建更轻便、更易于理解的模型

  • 优化超参数以使您的模型在性能上更加出色

  • 掌握梯度提升决策树的具体特性和选项

我们现在已经讨论了决策树,它们的特性,它们的局限性,以及所有它们的集成模型,无论是基于随机重采样的,如随机森林,还是基于提升的,如梯度提升。由于提升解决方案被认为是表格数据建模的当前最佳状态,我们详细解释了它是如何工作的以及如何优化其预测。特别是,我们介绍了几种可靠的梯度提升实现,XGBoost 和 LightGBM,它们正在证明是处理表格数据的科学家所能获得的最佳解决方案。

本章将涉及有关经典机器学习的更一般主题。然而,我们将专注于梯度提升决策树(GBDTs),特别是 XGBoost。在本章中,我们将讨论更高级的特征处理方法,例如多元缺失值插补、将高基数分类特征转换为简单数值的目标编码,以及根据它们与目标的关系来确定如何转换或细化特征的一般方法。我们将提出几种方法来减少特征数量到基本要素,并根据可用的计算资源和您选择的模型来优化超参数。然后,本章将以仅与 GBDTs 相关的先进方法和选项部分结束。

6.1 特征处理

在处理现实世界的表格数据集时,您可能会遇到各种问题,如果我们不调整技术以应对数据的现实情况,我们之前讨论的所有方法都将产生不理想的结果。在这里,我们将考虑一些这样的问题,例如以最智能的方式处理缺失值,转换具有大量唯一值的分类特征,以及找到在训练模型后重新处理特征以挤出更多性能的方法。这当然不是详尽的列表,但它应该能帮助您练习发现问题和规划适当的方案。

如前一章所述,为了解释和举例,我们再次将依赖于 Airbnb 纽约市数据集来展示处理表格数据问题中最具挑战性的任务的实用示例。以下列表回顾了我们将再次在本章中使用的数据和一些关键函数和类。

列表 6.1 回顾 Airbnb 纽约市数据集

import numpy as np
import pandas as pd
from sklearn.preprocessing import (
    StandardScaler,
    OneHotEncoder,
    OrdinalEncoder
)
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline

data = pd.read_csv("./AB_NYC_2019.csv")
excluding_list = ['price', 'id', 'latitude', 'longitude', 'host_id', 
                  'last_review', 'name', 'host_name']            ①
low_card_categorical = [
    'neighbourhood_group',
    'room_type'
]                                                                ②
high_card_categorical = ['neighbourhood']                        ③
continuous = [
    'minimum_nights',
    'number_of_reviews',
    'reviews_per_month', 
    'calculated_host_listings_count',
    'availability_365'
]                                                                ④
target_mean = (
    (data["price"] > data["price"].mean())
    .astype(int)
)                                                                ⑤
target_median = (
    (data["price"] > data["price"].median())
    .astype(int)
)                                                                ⑥
target_multiclass = pd.qcut(
    data["price"], q=5, labels=False
)                                                                ⑦
target_regression = data["price"]                                ⑧
categorical_onehot_encoding = OneHotEncoder(handle_unknown='ignore')
categorical_ord_encoding = 
OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=np.nan)
numeric_standardization = Pipeline([('StandardScaler', StandardScaler()), 
                                    ('Imputer', 
SimpleImputer(strategy="constant", fill_value=0))])

column_transform = ColumnTransformer(
    [
        ('low_card_categories', 
         categorical_onehot_encoding, 
         low_card_categorical),
        ('high_card_categories', 
         categorical_ord_encoding, 
         high_card_categorical),
        ('numeric', 
         numeric_standardization, 
         continuous)
    ],
    remainder='drop',
    verbose_feature_names_out=True,
    sparse_threshold=0.0)                                        ⑨

lm_column_transform = ColumnTransformer(
    [
        ('low_card_categories', 
         categorical_onehot_encoding, 
         low_card_categorical),
        ('numeric', 
         numeric_standardization, 
         continuous)
    ],
    remainder='drop',
    verbose_feature_names_out=True,
    sparse_threshold=0.0)                                        ⑩

① 特征处理中排除的列列表

② 需要一元编码的低基数分类列列表

③ 需要顺序编码的高基数分类列列表

④ 列出连续特征列

⑤ 创建一个二元目标,指示价格是否高于平均值(不平衡的二元目标)

⑥ 创建一个二元目标,指示价格是否高于中位数(平衡的二元目标)

⑦ 通过将价格分箱为五个类别来创建多类目标

⑧ 将回归的目标设置为价格列

⑨ 创建一个列转换器,对不同的特征组应用不同的转换

⑩ 创建一个适合线性模型的列转换器

我们参考前一章中提供的代码解释的所有细节。唯一的补充是一个专门为线性模型设计的列转换器。这个转换器仅通过执行独热编码处理低基数分类特征,而将高基数分类特征排除在外。

6.1.1 多变量缺失数据填充

在你的表格数据集中存在缺失数据是一个阻碍问题,因为除了 GBDTs 解决方案,如 XGBoost、LightGBM 和 Scikit-learn 的 HistGradientBoosting 之外,经典的机器学习算法没有对缺失值的原生支持。此外,即使你选择的 GBDTs 算法可以处理缺失值,正如下一节所解释的,你仍然可能发现直接填充缺失值更有效,因为你可以在事先检查每个特征或特定案例是如何处理的。

在第二章中,我们讨论了简单的填充方法,例如使用平均值或中位数,以及构建缺失指示器的有用性,从而使得算法更容易发现存在的缺失模式。本节将提供更多关于这些技术和多元填充的细节。

首先,除非缺失案例依赖于未观测变量,例如你无法访问的特征,否则缺失数据可以归类为

  • 完全随机缺失(MCAR)——在这种情况下,数据缺失与观测到的和未观测到的变量无关。缺失发生在数据集的随机位置。

  • 随机缺失(MAR)——MAR 假设观测变量,而不是未观测变量,可以解释缺失。换句话说,缺失的概率完全取决于观测数据。

当缺失案例依赖于缺失数据的未观测值时,你陷入了“缺失非随机”(MNAR)的情况,这需要相当专业的处理,但这本书不是主题。然而,假设你理解某些缺失非随机缺失数据背后的机制,例如当你没有在人口普查中获得关于太富有人(由于隐私)或太穷的人(由于普遍缺乏访问)的信息时。在这种情况下,你可以尝试收集一些暗示他们财富的新特征,以添加到你的数据集中,并回到“随机缺失”(MAR)的情况。

通常,你经常会遇到缺失数据是 MCAR 或 MAR 的情况。在这两种情况下,除了使用与 MCAR 完美配合的期望值进行简单填充外,你还可以通过多元填充更好地重建缺失数据。多元填充是一种使用数据集中预测变量之间的相关性来填充缺失值的方法。它涉及构建一系列模型,根据变量之间的关系来估计缺失值。在此方法中,每个模型将具有缺失值的特征视为目标变量(通过仅对其已知值进行建模),并使用剩余的特征作为预测变量。然后,使用得到的模型来确定用哪些值替换目标中的缺失值。你可以设置算法如何循环特征进行填充。你通常使用默认设置,从缺失数据较少的特征开始,逐步过渡到缺失值较多的特征,这是首选且最有效的方法。

为了处理预测变量中的缺失值,首先使用简单均值或其他基本填充方法进行初步填充。然后,通过多次迭代,通过结合填充模型的成果来细化初始估计。这种迭代过程持续进行,直到填充值达到稳定状态,进一步的迭代不会导致显著变化。Scikit-learn 通过IterativeImputer实现多元填充(mng.bz/MDZQ)。受 R MICE 包(通过链式方程进行多元填充:mng.bz/avEj)的启发,它允许进行多元填充和多次填充,这是统计学和社会科学中常见的方法,在这种方法中,你得到的不是单个填充值,而是一系列可能的替代值的分布。通过将sample_posterior参数设置为 True 并使用不同的随机种子多次运行IterativeImputer,可以实现多次填充。

然而,在数据科学中的表格数据应用中,多元填充是首选的选择,因为它允许基于单一但精确的估计来构建模型。在我们的例子中,我们取 Airbnb NYC 数据集的连续特征,并随机删除 5%的数据,从而模拟 MCAR 情况。之后,我们运行SimpleImputer,用均值和IterativeImputer替换缺失值。最后,我们使用平均绝对误差(MAE)比较每种方法重建的特征与原始值。

列表 6.2 多元填充

from sklearn.experimental import (
    enable_iterative_imputer
)                                                             ①
from sklearn.impute import SimpleImputer, IterativeImputer
from sklearn.ensemble import RandomForestRegressor

Xm = data[continuous].copy()                                  ②
missing_percentage = 0.05
np.random.seed(0)
mask = np.random.rand(*Xm.shape) < missing_percentage         ③
Xm[mask] = np.nan

simple_imputer = SimpleImputer()
Xm_si = simple_imputer.fit_transform(Xm)                      ④

rf = RandomForestRegressor(random_state=0, n_jobs=-1)         ⑤
multivariate_imputer = IterativeImputer(
    estimator=rf,
    max_iter=1,
    tol=0.01
)                                                             ⑥
Xm_mi = multivariate_imputer.fit_transform(Xm)                ⑦

mae = pd.DataFrame(
    {
        "simple": np.mean(
            np.abs(data[continuous] - Xm_si), axis=0
        ),
        "multivariate": np.mean(
            np.abs(data[continuous] - Xm_mi), axis=0
        )
     },
     index = continuous
)                                                             ⑧
print(mae)

① 导入 IterativeImputer,它在 Scikit-learn 中仍然是实验性的,并且正在改进中

② 创建连续特征数据的副本

③ 创建一个掩码以随机标记缺失值

④ 使用具有均值填充策略的 SimpleImputer 实例

⑤ 实例化 RandomForestRegressor 进行迭代填充

⑥ 创建一个具有最大迭代次数 max_iter 和容忍度 tol 作为停止标准的 IterativeImputer 实例

⑦ 使用迭代插补来插补缺失数据

⑧ 计算插补数据和原始数据的平均绝对误差(MAE)

命令 print(mae) 提供的结果是一个表格,它比较了简单插补和多变量插补方法:

                                       Simple        Multivariate
minimum_nights                         0.347355      0.260156
number_of_reviews                      1.327776      0.858506
reviews_per_month                      0.057980      0.036876
calculated_host_listings_count         0.579423      0.368567
availability_365                       6.025748      4.62264

比较结果表明,多变量方法,特别是 IterativeImputer,在单次迭代后始终产生比简单插补方法更低的 MAE 值。这表明 IterativeImputer 在用更少的错误替换缺失值方面更有效。为了获得更好的估计,你可以将 max_iter 增加到更高的数值,并让算法根据容忍度值(用于检查结果是否稳定的容忍度阈值)来决定是否提前停止。增加 max_iter 将导致更长的插补时间,因为作为一个插补模型,我们正在使用随机森林算法。随机森林通常是处理多变量估计(在 R 社区中称为 MissForest 的方法)的最有效方式:rpubs.com/lmorgan95/MissForest。然而,你可以通过简单地替换 IterativeImputer 中的 estimator 来选择基于线性模型或 k 近邻的更快方法:

  • BayesianRidge—简单地使用 BayesianRidge()

  • RandomForestRegressor—对于随机树回归的森林,你可以设置 n_estimatorsmax_depthmax_features 来创建更浅的树,从而加速插补过程,例如 RandomForestRegressor(n_estimators=30, max_depth=6, max_samples=0.5)

  • Nystroem + Ridge—一个通过组合不同的 Scikit-learn 命令(make_pipeline(Nystroem(kernel="polynomial", degree=2, random_state=0), Ridge(alpha=1e3)))来扩展二次多项式核和正则化线性回归的管道

  • KNeighborsRegressor—一个 k 近邻插补方法,你可以决定要考虑的邻居数量,例如 KNeighbors-Regressor(n_neighbors=5)

你使用的估计器将影响你获得的结果的质量和计算时间。作为起点,BayesianRidge 是默认选择,也是最快的。如果你有更多时间,RandomForestRegressor 将为你提供更好的估计。通过联合输入多个变量,IterativeImputer 以更多的计算和编写代码为代价,更准确地捕捉变量之间的依赖关系。对于简单直接、即插即用的解决方案,一些 GBDT 实现提供了处理缺失值的原生支持,我们将在下一节中了解到这一点。

6.1.2 使用 GBDT 处理缺失数据

XGBoost 和 LightGBM 算法(以及 Scikit-learn 的 HistGradientBoosting)通过在每个分割点将缺失值分配给最大化损失函数最小值的那个分支来相似地处理缺失值。XGBoost 通过其稀疏度感知分割查找算法引入了这项技术,该算法在数据缺失时提供了一个默认的方向,无论是由于缺失还是存储在只保留非零值的稀疏矩阵中。

因此,别忘了 XGBoost 会将稀疏矩阵中的零视为缺失值,并应用其特定的算法来处理缺失数据。因此,一方面,当你分析具有高基数分类变量的 one-hot 编码矩阵时,将其创建为稀疏矩阵可能会很方便,因为这将节省你大量的内存和计算。另一方面,你可能会注意到,如果你分析的数据以密集矩阵或稀疏矩阵的形式表示,XGBoost 返回的模型可能完全不同。

差异在于当 XGBoost 遇到缺失示例时会发生什么。在训练过程中,算法在每个分割点学习,具有缺失值的样本应该根据结果增益分配到左分支或右分支。在做出预测时,具有缺失值的样本将相应地分配到适当的子节点。这允许算法根据特征值的缺失模式进行分割,如果它是预测性的。如果在训练过程中给定特征没有缺失值,则具有缺失值的样本将被分配到具有最多样本的子节点。

你可以使用缺失参数来指定 XGBoost 将考虑为缺失的值。此参数默认设置为 NaN,但你可以选择任何你想要的值。

关于 XGBoost 的另一个重要事项是,使用线性模型作为基学习者的gblinear增强器将缺失值视为零。假设你标准化了你的数值特征,正如在线性模型中常用那样。在这种情况下,gblinear增强器将缺失值视为该特征的平均值,因为平均数会取标准化变量中的零值。

LightGBM 采用类似的方法(见github.com/microsoft/LightGBM/issues/2921),使用特定的参数:

  • LightGBM 默认启用处理缺失值的功能。通过设置use_missing=false来关闭它。

  • LightGBM 默认使用 NA(NaN)来表示缺失值。通过设置zero_as_missing=true将其更改为使用零。

  • zero_as_missing=false(默认值)时,稀疏矩阵(和 LightSVM)中的未记录值被视为零。

  • zero_as_missing=true时,NA 和零(包括稀疏矩阵[和 LightSVM]中的未记录值)被视为缺失。

这种处理缺失数据的方法在平均情况下效果很好,特别是如果你的数据是 MCAR(完全随机缺失)。这意味着缺失实例的模式是完全随机的,并且与任何其他特征或隐藏的潜在过程无关。当缺失与其他特征值相关,但与特征本身的值无关时,情况就不同了,这就是 MAR(相关缺失)。在 NMAR(系统缺失)的情况下,存在与特征本身和其他特征相关的缺失值的系统模式。在 MAR 和 NMAR 的情况下,最佳解决方案是尝试通过其他方式尝试填充这些值,因为 XGBoost 和 LightGBM 的缺失数据策略可能表现出性能不足。

然而,对于缺失数据的填充,也有一些替代方案。例如,你可以创建缺失数据指示器,这些是二进制特征,其值对应于变量中缺失实例。如果数据不是完全随机缺失,缺失数据指示器可能非常有价值,并且它们可以与任何经典机器学习算法一起工作。另一个与决策树相关的流行解决方案是将缺失值分配给数据集中任何变量都没有使用的极端值(通常是负极端值)。如果你使用的是精确分割,而不是基于直方图的分割,其中值被归入桶中,那么用极端值替换缺失数据可以证明是一个高效且简单的方法。

6.1.3 目标编码

分类特征,通常在数据集中以字符串的形式表示,可以通过不同的策略有效地处理。我们已经在第二章和第四章中提到了独热编码。与独热编码一样,所有其他用于分类特征的策略,无论其基数是高还是低,都需要进行编码,这是一种将数据数值化并转换为适合机器学习算法的合适格式的程序。尽管有一些相似之处,但编码不应与嵌入混淆,嵌入是一种将高维数据(如文本或图像)降低到较低维空间的过程,同时保留原始数据的一些特征或关系。嵌入通常通过基于神经网络的模型学习,并在我们的书中简要介绍。

Scikit learn 包提供了一些编码解决方案:

  • OneHotEncoder—用于独热编码(即将每个唯一的字符串值转换为二进制特征),这是我们迄今为止使用的解决方案

  • OrdinalEncoder—用于顺序编码(即,将特征中的字符串值转换为有序的数值;还有一个LabelEncoder,它的工作方式相同,主要用于将分类目标转换为数值)

通常,一热编码对于线性模型和基于树的模型都适用,有序编码对于更复杂的基于树的模型,如随机森林和 GBDTs,也适用,因为树可以递归地根据类别特征进行分割,并最终找到一组对预测有用的分区。然而,当使用一热编码或有序编码时,高基数类别会引发问题。高基数是线性模型和基于树的模型的弱点。当进行一热编码时,高基数类别会产生稀疏矩阵,由于内存限制,这些矩阵不能轻易转换为密集矩阵。此外,具有许多分支级别的决策树可能需要帮助将有序编码的高基数类别特征分割成对预测有意义的分区。

没有一个普遍固定的标准来声明何时一个类别具有高基数,因为这还取决于你的数据集有多少行以及你的计算机内存可以处理多少个一热编码的特征。然而,高基数类别特征通常包括 ID、邮编以及具有许多唯一值的产品或地理名称。例如,一个合理的阈值可能是超过 512,但根据数据集可能更低。根据经验法则,一个特征中的类别数量不应超过数据集总行数的 5%–10%,对于较小的数据集来说,512 可能过高。在这种情况下,标准做法,尤其是来自像 Kaggle 这样的数据科学竞赛,建议求助于目标编码(也称为均值编码)。

目标编码首次在 Micci-Barreca 的论文中提出,“用于分类和预测问题中高基数属性的预处理方案”(ACM SIGKDD Explorations Newsletter 3.1,2001),目标编码简单地将类别特征中的值转换为它们对应的预期目标值。如果你的问题是回归,目标编码将使用与数据集中该值相对应的平均目标值,对于分类问题:条件概率或优势比。这个过程,当数据集中该类别示例较少时,可能会给模型带来过拟合的风险,可以通过使用该类别预期值(目标的后验概率)与所有数据集的平均预期值(所有训练数据中目标的前验概率)之间的加权平均来减轻这种风险。

目标编码在 category-encoders 包中可用(mng.bz/gave),这是一个与 Scikit-learn 兼容的项目,作为目标 TargetEncoder 类(mng.bz/5glq),您可以通过在 shell 中运行pip install category_encoders命令来安装它。在 TargetEncoder 类中,您必须指定一个平滑参数(应固定在零以上的值)以在目标的后验概率和整个训练数据中的先验概率之间进行平衡。您数据中最佳的平滑参数必须通过实验来找到,或者您可以依赖另一个类似的编码器,James Steiner 编码器,它根据您想要编码的类别的条件方差猜测平滑预期目标值的最优方式(mng.bz/5glq)。James Stenier 编码器对您的数据做出了更强的假设。您必须决定通过模型参数(对于回归问题,建议使用“独立”,对于分类问题,使用“二元”)来估计条件方差的不同方法。尽管如此,它还是让您免于像超参数一样尝试不同的混合阈值。

在我们的例子中,我们使用neighborhood特征,它有超过 200 个唯一值,以及将纬度和经度坐标映射到 100 x 100 网格空间后的坐标。映射返回一个具有超过 2,000 个不同值的特征,毫无疑问,它是一个高基数分类特征。在列表 6.3 中,我们首先对纬度和经度进行分箱,然后通过将它们相加以产生每个纬度和经度分箱组合的唯一代码来组合它们。分箱是通过将特征的最小值和最大值之间的范围分成相等的部分来获得的。此外,代码片段对两个不同的特征执行分箱,为每个特征生成整数值集合。一个特征的值乘以一个大于另一个特征最大值的 10 的幂,这确保了当将两组值相加时,总是获得一个唯一值,无论相加的具体值是什么。

列表 6.3 创建高基数分类特征

def bin_2_cat(feature, bins=100):
    min_value = feature.min()
    bin_size = (feature.max() - min_value) / bins
    bin_values = (feature - min_value) / bin_size
    return bin_values.astype(int)                         ①

data['coordinates'] = (
    bin_2_cat(data['latitude']) * 1000 
    + bin_2_cat(data['longitude']
)                                                         ②
high_card_categorical += ['coordinates']

print(data[high_card_categorical].nunique())              ③

① 函数将数值数据转换为分类箱

② 将纬度和经度转换为分类坐标

③ 打印高基数分类特征中的唯一值数量

代码片段以检查高基数特征中每个特征的唯一值数量结束:

neighbourhood     221
coordinates      2259

考虑到有两个分类特征被认为是高基数,我们可以在我们的预处理管道中添加 category-encoders 的TargetEncoder

列表 6.4 在管道中使用目标编码

from category_encoders.target_encoder import TargetEncoder
from XGBoost import XGBClassifier
from sklearn.model_selection import KFold, cross_validate
from sklearn.metrics import accuracy_score, make_scorer

target_encoder = TargetEncoder(cols=high_card_categorical,  ①
                               smoothing=0.5)               ②
accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
xgb = XGBClassifier(booster='gbtree',
                    objective='reg:logistic',
                    n_estimators=300,
                    max_depth=4,
                    min_child_weight=3)                     ③

column_transform = ColumnTransformer(
    [
        ('low_card_categories', 
         categorical_onehot_encoding, 
         low_card_categorical),
       ('high_card_categories', 
        target_encoder, 
        high_card_categorical),
       ('numeric', 
        numeric_standardization, 
        continuous)
    ],
    remainder='drop',
    verbose_feature_names_out=True,
    sparse_threshold=0.0)                                   ④

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('model', xgb)])                                       ⑤

cv_scores = cross_validate(estimator=model_pipeline,
                          X=data,
                          y=target_median,
                          scoring=accuracy,
                          cv=cv,
                          return_train_score=True,
                          return_estimator=True)        ⑥

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
      f"fit: {fit_time:0.2f}",
      f"secs pred: {score_time:0.2f} secs")                 ⑦

① 为高基数分类特征初始化 TargetEncoder

② 平滑值以融合先验和后验概率

③ 使用特定超参数初始化 XGBoost 分类器

④ 定义 ColumnTransformer 以使用 TargetEncoder 对高基数分类特征进行预处理

⑤ 创建一个结合预处理和建模的管道

⑥ 执行五折交叉验证并获取评估指标

⑦ 从交叉验证中打印出平均准确率、拟合时间和预测时间

当执行时,代码过程将运行 XGBoost 的结果,并额外帮助处理高基数分类特征的问题。结果指向准确率的一点点提升。在本章的后面部分,我们将调查在检查可解释性时目标编码贡献的权重:

0.840 (0.004) fit: 4.52 secs pred: 0.06 secs

尽管目标编码是一个方便的程序,因为它可以快速将任何分类特征转换为数值特征,但在这样做的时候,你必须注意保留数据中的所有重要信息。目标编码使得对特征之间任何交互的进一步建模成为不可能。比如说,如果你正在处理一个广告响应数据集,其中包含许多网站和广告格式的点击结果。如果你对两个特征进行编码,将两个可能具有数千个值的高的基数分类特征转换,你可能会轻松地创建任何类型的经典模型。然而,在编码之后,你的模型,无论是线性的还是基于树的,将无法理解编码特征之间任何可能的交互。在这种情况下,解决方案是在事先创建一个新特征,结合这两个高基数分类特征,然后对它们的组合进行目标编码。

因此,对于其他工具,我们也应该考虑这种高级编码技术的利弊。根据我们的经验,在求助于目标编码之前,对于经典机器学习算法和梯度提升算法处理高基数分类特征,有几个选项:

  • 直接删除有问题的分类特征

  • 使用 OneHotEncoder

  • 使用 OrdinalEncoder 并将类别视为有序等距的数量

  • 使用 OrdinalEncoder 并依赖梯度提升直方图算法的本地类别支持

  • 作为最后的手段使用目标编码

删除特征只被考虑在少数情况下。然而,我们在第二章中已经提到,你可以如何利用 Cramer 的 V 相关度度量来评估一个名义特征如何有助于预测目标。

当面对高基数分类特征时,对于线性模型来说,选择 one-hot 编码几乎是必要的。当处理其他模型,如决策树及其集成时,可能存在更合适的方案。这是因为 one-hot 编码为分类特征的每个分类值创建了一个额外的特征。这导致树模型在拟合过程中必须考虑的分割点数量增加。因此,使用 one-hot 编码的数据需要在决策树中具有更多的深度,以实现等效的分割,这可以通过使用处理分类特征的不同方式通过单个分割点实现。

对于有序编码器,分类被编码为 0,1,2 等等,将它们视为连续特征。虽然这种方法对于线性模型来说可能会误导,但它对于决策树来说非常有效。决策树可以根据目标变量的关系准确地对数据进行分割,根据分类分离。这在 XGBoost 中发生,它将所有特征视为数值,连续特征。

如果我们决定使用对分类特征的本地支持,这个选项在 LightGBM 以及由 H2O.ai 库提供的 XGBoost 版本中都是可用的(mng.bz/6e75)。本地分类支持使得这些模型能够更有效地处理分类特征,而无需将它们转换为数值。在这种情况下,由于本地处理需要排序分类,我们预计在使用本地处理分类特征相对于将分类视为有序数时,算法会稍微慢一些。在本地分类支持中,一个特征的分类排序是基于每个分类的关联目标方差。一旦排序完成,该特征就可以用作连续的数值属性。

6.1.4 转换数值数据

决策树可以自动处理数据中的非线性性和交互作用。这是因为它们可以在任何点上将变量分割成两部分,然后反复进一步分割。这种特性在处理数据中的微妙和深层交互时特别有用,但有一个前提,因为决策树是相当粗糙的近似器。从精确建模数据中的复杂关系的角度来看,具有足够示例的神经网络是更好的近似器。

图 6.1 展示了如何通过决策树集成来近似非线性函数。结果是通过对空间进行递归分割的一系列 if-then-else 决策规则构建的近似。然而,数据中的噪声可能导致空间某些部分的不准确。相比之下,具有与袋装决策树中使用的树相同数量的节点的神经网络可以提供更平滑、更准确的曲线估计。

图 6.1 随机数据集上神经网络与袋装树集成预测的比较,数据集包含有噪声的正弦函数

由于 GBDT(梯度提升决策树)也是基于决策树的,它可能在用二分分裂来塑造非线性函数时遇到类似的困难。因此,当使用 GBDT,并且你知道特定的非线性或交互时,通过使用向线性形式转换、分箱或离散化以及特征之间的预计算交互来明确地定义它们,对你是有益的。对于非线性性,转换有助于减少分裂的数量。此外,事先计算特定的交互也可以减少在更好的分裂点发生的分裂数量。

然而,在应用这些转换之前,你需要了解你的数据。线性性和非线性性,即使与目标没有关系,也可以在通过部分依赖图(PDP)完成训练数据的拟合后轻松发现。这种模型无关的图表技术解释了特征和目标是如何通过训练的模型相互关联的。

PDPs(部分依赖图)显示了目标输出如何根据特定的输入特征变化,同时忽略其他输入特征的影响。换句话说,它展示了如果我们为特定输入特征的每个数据点设置一个特定的值,我们将得到的平均预期预测。分析背后的假设是,我们通过 PDP 表示的输入与其他特征是独立的。在这种条件下,PDP 表示输入特征如何直接影响目标。然而,在实践中,这个假设通常被违反,这意味着我们正在检查的输入特征通常并不完全独立于其他特征。因此,图表通常显示了目标值如何随着输入特征值的改变而变化,同时也反映了模型中其他特征的总体影响。

在列表 6.5 中,我们探讨了 PDPs 的可能用途和局限性。针对我们在 Airbnb NYC 数据集上训练的 XGBoost 模型,我们展示了我们的目标如何根据我们的数值特征变化,试图发现任何非线性或其他模型数据的特征。四个生成的图表使用 matplotlib 轴绘制,并进行分析。

列表 6.5 部分依赖图

from XGBoost import XGBClassifier
import matplotlib.pyplot as plt
from sklearn.inspection import PartialDependenceDisplay

xgb = XGBClassifier(booster='gbtree', 
                    objective='reg:logistic', 
                    n_estimators=300, 
                    max_depth=4,
                    min_child_weight=3)

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('XGBoost', xgb)])                                 ①

model_pipeline.fit(X=data, y=target_median)

fig, axes = plt.subplots(
   nrows=2,
   ncols=2,
   figsize=(8, 4)
)                                                       ②
fig.subplots_adjust(hspace=0.4, wspace=0.2) 

PartialDependenceDisplay.from_estimator(
    model_pipeline, 
    X=data, 
    kind='average',                                     ③
    features=[
        'minimum_nights',
        'number_of_reviews', 
        'calculated_host_listings_count',
        'availability_365'
    ],                                                  ④
    ax=axes
)
for ax in axes.flatten():
    ax.axhline(y=0.5, color='red', linestyle='--')      ⑤

plt.show()

① 创建一个结合数据处理和 XGBoost 分类器的模型管道

② 创建一个 2×2 的子图布局

③ 创建平均效应的部分依赖图

④ 指定用于图表的特征列表

⑤ 在每个子图上添加一条 y=0.5 的红色虚线,作为解释的参考线

图 6.2 显示了四个图表。虚线标记了一个(大于或等于 0.5)和零(小于 0.5)的分类阈值。实线描述了 x 轴上的特征值与 y 轴上的目标概率之间的关系。x 轴上的刻度标记指出特征的分布十分位数,暗示着值密集(刻度彼此相邻)的区域和值稀疏(刻度彼此远离)的区域。值稀疏的区域在估计上不太可靠。例如,minimum_nightscalculated_host_listings_count显示非线性模式,而number_of_reviewsavailability_365则表现出稳定的振荡。

图片

图 6.2 数字特征的 PDP 面板

在得到这样的结果后,你可以尝试通过试错法使用变换函数来评估minimum_nightscalculated_host_listings_count,例如

  • 平方或立方变换

  • 平方根或立方根

  • 对数或指数变换

  • 切线、正弦和余弦变换

  • 逆变换、平方逆变换、立方逆变换、平方根逆变换、立方根逆变换

  • 对数逆变换、指数逆变换、切线逆变换、正弦逆变换、余弦逆变换

然而,在匆忙进行变换测试之前,重要的是要验证获得的 PDP 平均曲线是否代表该特征在所有情况下的行为。你可以使用个体条件期望(ICE)图来验证这一点。ICE 图是 PDP 曲线的单个组成部分。你可以通过稍微修改之前的代码来获得 ICE 图。

列表 6.6 ICE 图

import matplotlib.pyplot as plt
from sklearn.inspection import PartialDependenceDisplay

fig, axes = plt.subplots(nrows=2, ncols=2, figsize=(8, 4))
fig.subplots_adjust(hspace=0.4, wspace=0.2)

PartialDependenceDisplay.from_estimator(model_pipeline, 
                                        X=data, 
                                        kind='both',               ①
                                        subsample=30,              ②
                                        features=['minimum_nights', 
                                                  'number_of_reviews', 

                                                  'calculated_host_listings_count', 
                                                  'availability_365'],
                                       ax=axes)

for ax in axes.flatten():
    ax.axhline(y=0.5, color='red', linestyle='--')
    ax.legend().set_visible(False)

plt.show()

① 创建一个部分依赖图,显示个体和平均效应

② 使用 30%的数据随机子集进行绘图以提高效率

运行代码后,你可以检查结果,如图 6.3 所示。你可以看到与之前相同的 PDP 平均曲线,用虚线表示,以及从样本中随机抽取的 30 条曲线。假设你可以验证这些抽样曲线是聚集在一起的,大致复制了平均曲线的形状。在这种情况下,你可以确认平均 PDP 曲线代表了特征相对于目标的行为。否则,就像我们的例子一样,如果单条曲线看起来不同且分散,其他特征由于共线性或交互作用而在某种程度上调节了特征与特征的关系,你无法从变换特征中获得太多好处。

图片

图 6.3 数字特征的 ICE 图面板

到目前为止,我们只是使用了 PDP(部分依赖图)来处理数值特征。然而,在通过独热编码进行编码后,你也可以将它们应用于二进制和分类特征。在这种情况下,你首先必须使用独立的函数partial_dependence计算曲线值,然后以条形图(用于 PDP 平均曲线)或箱线图(用于 PDP 和 ICE 曲线一起)的形式表示获得的价值。在以下列表中,我们提取了必要的值,并为neighbourhood_group的单个级别创建了一个箱线图表示。

列表 6.7 二进制特征的 PDP 图

from sklearn.inspection import partial_dependence         ①
import matplotlib.pyplot as plt

pd_ice = partial_dependence(model_pipeline, X=data, 
                            features=['neighbourhood_group'], 
                            kind='both')

fig = plt.figure(figsize=(8, 5))
ax = fig.add_subplot(1, 1, 1)
labels = np.ravel(pd_ice['values'])
plt.boxplot(
    pd_ice["individual"].squeeze(),
    labels=labels
)                                                         ②
ax.axhline(y=0.5, color='red', linestyle='--')            ③
plt.show()

① 导入计算曲线值的部分依赖函数

② 创建单个 ICE 曲线的箱线图

③ 在每个子图中添加一条红色虚线,y = 0.5,作为解释的参考线

图 6.4 显示了结果,提供了关于曼哈顿公寓位置通常与更高价格相关联的见解。根据模型,其他位置与较低价格相关联。然而,布鲁克林显示出最大的变异性,有时价格较高,类似于曼哈顿,这显然取决于与公寓的确切位置或特征相关的其他因素。

图 6.4 对于每个二进制特征,PDP 获得的相关目标值的箱线图

与数值特征一样,PDP 曲线也提供了关于如何增强你的模型的有用见解。例如,它们可以用来聚合表现相同的分类特征的级别——在我们的例子中,布朗克斯、斯塔滕岛,也许还有皇后区。

PDP(部分依赖图)向我们展示了基于我们感兴趣的输入特征我们可以期待的目标输出。它们还帮助我们理解目标响应与感兴趣输入特征之间的关系,无论是线性的还是非线性的。通过观察分析绘制的曲线形状,我们还可以找出可以使其线性化的转换。当将特征元组作为PartialDependenceDisplay函数的features参数提供时,该函数将输出一个等高线图,显示两个特定特征的联合效应。以这种方式发现交互是漫长而繁琐的,尤其是如果你有很多特征要探索。一个解决方案是自动发现潜在的交互,然后使用 PDP 联合图表进行测试。通过使用 XGBoost Feature Interactions Reshaped(XGBFIR;github.com/limexp/xgbfir)这样的项目,自动检测交互是直截了当的。以下列表显示了在命令行中通过pip install xgbfir安装包后可以运行的示例。

列表 6.8 通过 XGBFIR 发现交互

import xgbfir
xgbfir.saveXgbFI(
    model_pipeline['XGBoost'],
    feature_names=(
        model_pipeline['processing']
        .get_feature_names_out()
    ),
    OutputXlsxFile='fir.xlsx')                                    ①
fir = pd.read_excel('fir.xlsx', sheet_name='Interaction Depth 1') ②
result = fir[["Interaction", "Gain"]].sort_values(by="Gain", 

ascending=False).head(10).round(2)                                ③
for index, row in result.iterrows():
    print(f"{row['Interaction']}")

PartialDependenceDisplay.from_estimator(
    model_pipeline,
    X=data,
    kind='average',
    features=[(
        'minimum_nights',
        'calculated_host_listings_count')])                       ④

① 使用 xgbfir 生成报告并将其保存到 Excel 文件中

② 读取之前步骤中创建的 Excel 文件

③ 从特征交互报告中提取并按分裂增益排序“交互”和“增益”列

④ 为“minimum_nights”和“calculated_host_listings_count”这两个特征生成部分依赖图

代码将打印一系列交互。如果你使用线性模型,应该测试 XGBFIR 返回的每个交互,因为它们可能会提高你的模型性能。如果你使用决策树,可以忽略涉及二进制特征的交互,只关注数值特征。一个例子是minimum_nightscalculated_host_listings_count之间的交互。图 6.5 显示了将它们与特定值结合如何与正目标响应强烈相关。

图 6.5 两个数值特征的联合部分依赖图

在这种情况下,通过乘法组合数值特征将优化你的 GDBT 模型的速度更快、更有效。

6.2 选择特征

特征选择并不总是必要的。尽管如此,当需要时,它在识别现有特征集中对训练最有价值的特征方面发挥着至关重要的作用,无论这些特征是否直接来自数据提取,还是你特征工程工作的产物。通过采用有效的特征选择技术,你可以精确地识别并保留对机器学习过程贡献显著的最重要的特征。

在第二章的第 2.2.3 节中,我们讨论了根据你对问题的了解和探索性数据分析来避免收集无关和冗余特征。在随后的章节中,我们讨论了处理无关和冗余特征的机器学习算法。

在经典机器学习中,我们有一大批算法,包括线性模型系列,它们特别容易受到无关和冗余特征的影响,这会降低性能和准确性。被认为无关的信息和无用的噪声特征,因为它们与学习任务的靶子缺乏有意义的关联,可能会给线性模型带来重大挑战。这是由于特征值与靶子之间可能存在随机对齐的可能性,这可能会误导算法并赋予这些特征过多的重视。线性模型利用所有提供的特征,这使得它们特别容易受到噪声特征的影响,因为噪声特征越多,结果就会越差。基于决策树的集成方法则较少受到无关和冗余特征的影响,因为它们会自动选择使用哪些特征并忽略其他特征。这种情况也适用于深度学习。然而,当处理表格数据中的噪声或不相关特征时,深度学习可能不如决策树集成方法鲁棒。在这种情况下,为了获得最佳性能,需要大量数据,以及仔细选择架构,例如使用 dropout、正则化或批量归一化层,以及调整学习率。

特征选择对经典机器学习算法,如线性模型,有益。然而,在基于决策树集成和深度学习架构的情况下,它同样有价值,不应忽视的是,由于处理列数减少,它使得机器学习过程更快。通过在训练前选择特征,这些复杂算法可以通过提炼最相关和最有信息量的特征,并使对模型所捕获的潜在模式和关系的理解更加清晰,从而实现提高清晰度和易于解释。这种简化增强了可解释性,并促进了算法决策过程的沟通。

在以下章节中,我们讨论和测试了一些解决方案,这些解决方案可以独立或顺序使用,以选择仅对解决您的表格数据问题至关重要的特征,并取得最佳结果。我们讨论了确定相关特征(所有相关集)的算法,这些特征可能导致冗余但有用的特征集,以及选择特征最小子集(非冗余集)的算法,这些子集产生的模型与相关特征集相当,但增加了由于特征数量减少而提高的可解释性优势。

6.2.1 线性模型的稳定性选择

稳定性选择基于这样的观点:如果你使用变量选择程序,由于过程本身的变异性,你不会总是得到相同的结果,因为子采样或自助法可能会改变数据。例如,如果你在线性模型中使用 L1 正则化进行特征选择,你可能会发现不同的样本可能会返回不同的非零系数,尤其是对于高度相关的特征。

正如我们所讨论的,L1 正则化惩罚导致系数估计的稀疏性。它是通过向损失函数添加一个惩罚项来实现的,即系数绝对值的总和。这样的惩罚项对系数绝对值的总和施加了约束,促使某些系数变为正好为零。因此,L1 正则化可以通过将某些系数缩小到零并排除相应的特征来有效地选择特征。在高度相关的特征存在的情况下,由于它们对目标变量的贡献相似,L1 正则化可能难以选择一组独特的特征。在这里,机会在某种程度上起着作用,即某些特征根据你在样本中的数据得到非零系数。然而,这可以成为我们的优势。

通过数据采样引入随机性,稳定性选择旨在识别在多个子集中始终出现的重要特征,这表明它们的鲁棒性,并减少随机或噪声选择特征的可能性。稳定性选择将提供一组有用的特征,而不是最小的一组。通过排除不重要的特征,稳定性选择确保识别出所有相关特征,因此它是一个完美的算法,用于减少特征数量的第一步。

如 Meinshausen 和 Büehlmann 在论文中提出(arxiv.org/abs/0809.2932),稳定性选择在一段时间内已被作为 Scikit-learn 的一部分提供,并在 Scikit-learn 兼容的项目中维护。我们可以使用 Scikit-learn 的BaggingClassifier和具有 L1 正则化的LogisticRegression来轻松复制其过程,以解决分类问题。您还可以为回归问题采用相同的代码,使用BaggingRegressor和 L1 回归类Lasso

在我们的实现中,我们对 L1 逻辑回归进行了一系列 C 值的测试,以对抗 bootstrap 重采样。该过程创建了一系列逻辑回归系数,我们可以对它们求和、平均或计算它们与零不同的次数。鉴于我们使用的是二进制和连续特征的混合,我们发现计算与变量相关的系数的绝对值超过阈值的次数更有用。因此,我们可以最终认为,大多数情况下,倾向于具有相关系数的特征是相关的,这可能会影响最终的预测。

列表 6.9 稳定性选择

import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import BaggingClassifier

lambda_grid=np.logspace(-4, -1, 10)                       ①
sparse_coef = list()

for modeling_c in lambda_grid:
    estimator = LogisticRegression(
        solver='liblinear',
        penalty='l1',
        C=modeling_c
    )                                                     ②
    model = BaggingClassifier(
        estimator, 
        n_estimators=100,
        bootstrap=True
    )                                                     ③
    model_pipeline = Pipeline(
        [('processing', lm_column_transform),
         ('standardize', StandardScaler()),               ④
         ('modeling', model)])
    model_pipeline.fit(data, target_median)
    sparse_coef += [estimator.coef_.ravel() for estimator in 
model_pipeline["modeling"].estimators_]

epsilon = 1e-2                                            ⑤
threshold = 0.5                                           ⑥

non_zero = (np.abs(sparse_coef) > epsilon).mean(axis=0)
feature_names = model_pipeline["processing"].get_feature_names_out()
print(non_zero)
print(feature_names[non_zero > threshold])

① 使用对数尺度生成一个 lambda 值的网格,用于 L1 正则化

② 创建一个具有 L1(Lasso)惩罚的逻辑回归估计器

③ 创建一个使用逻辑回归估计器作为其基础模型的 BaggingClassifier

④ 数据处理标准化后,所有系数均可比较,无论其规模如何

⑤ 将一个小的值作为 epsilon 设置为一个阈值

⑥ 设置一个阈值值以选择显著的系数

输出突出了相关系数的分布和选定的特征:

[0.635 0\.    0.9   0.7   0.592 1\.    0\.    0.6   0.593 0.444 0.6   0.506 0.7  ]
['low_card_categories__neighbourhood_group_Bronx'
 'low_card_categories__neighbourhood_group_Manhattan'
 'low_card_categories__neighbourhood_group_Queens'
 'low_card_categories__neighbourhood_group_Staten Island'
 'low_card_categories__room_type_Entire home/apt'
 'low_card_categories__room_type_Shared room' 'numeric__minimum_nights'
 'numeric__reviews_per_month' 'numeric__calculated_host_listings_count'
 'Numeric__availability_365']

稳定性选择提供了一些优势。它可以处理高维数据,通过引入随机性来避免过拟合,并提供一个考虑选择过程稳定性的特征重要性度量。它通常用于具有大量特征的复杂应用中,如基因组学、文本挖掘或图像分析。另一方面,选择算法仅限于使用 L1 正则化的经典机器学习算法,并返回一组系数,这些系数是我们之前讨论过的:逻辑回归和 Lasso 回归。您可以通过使用特征重要性(许多集成模型估计特征重要性)来扩展稳定性选择的概念,例如 Scikit-learn 中的 SelectFromModel 命令 (mng.bz/oKej),但事情会变得复杂,因为您需要弄清楚什么使重要性估计相关,以及使用什么选择阈值。在下一节中,我们将回顾特征重要性是如何工作的,并介绍 Boruta。使用一个可靠的自动特征选择过程,该算法可以确定决策树集成(如随机森林或梯度提升)的相关特征。

6.2.2 影子特征与 Boruta

Boruta 是一种智能过程,通过依赖模型内部参数(如线性模型中的系数或基于增益的重要性值,例如在决策树及其集成中)来确定特征在机器学习问题中的相关性。它首次发表在 Miron B. Kursa 和 Witold R. Rudnicki 的《使用 Boruta 包进行特征选择》[《统计软件杂志》36 (2010): 1-13]一文中;欲获取文章副本,请参阅 www.jstatsoft.org/article/view/v036i11

虽然 Boruta 具有创新性,但它与稳定性选择有许多相似之处。它只能与基于决策树的集成一起使用。为了测量特征的相关性,就像在稳定性选择中一样,我们寻找非零系数。在 Boruta 中,我们计算特征重要性超过影子特征获得的最重要性的次数。我们称之为“击中”。影子特征是特征自身的随机版本(基本上是打乱顺序的特征),鉴于它们是随机的,应该仅通过偶然获得任何重要性。如果任何特征不能超过影子特征相同的 Importance,则不能认为它比任何随机值序列更具预测性。

在 Boruta 中,通过将击中次数转换为二项分布来确定选择的阈值,通常是一个非零系数在稳定性选择中的最小出现次数。如果击中次数可以证明特征始终优于任何随机结构,则根据分布保留或删除特征测试的显著性阈值。

列表 6.10 展示了一个使用 Boruta 在 Airbnb 纽约数据集上对 XGBoost 分类选择所有相关特征的示例。在 BorutaPy 实现(github.com/scikit-learn-contrib/boruta_py)中,Boruta 有一些限制,因为除了只能与基于树的模型(如随机森林或梯度提升,无论实现方式如何)一起工作之外,它还不能与管道一起工作。因此,我们首先必须转换数据,然后在训练最终模型时对转换后的特征运行 Boruta。Boruta 的关键参数包括估计器——即你想要使用的模型、集成中的决策树数量以及 n_estimators 超参数,该参数可以留空、设置为整数或设置为“auto”,此时树的数量将根据数据集的大小来决定。Boruta 的其他重要参数包括 max_iter,测试轮数,通常设置为 100,以及二项式检验的 alpha 阈值,该阈值可以从 0.05 增加以允许保留更多特征,或减少以丢弃更多特征。

列表 6.10 Boruta 选择

from XGBoost import XGBClassifier
from boruta import BorutaPy

xgb = XGBClassifier(booster='gbtree', 
                    objective='reg:logistic', 
                    n_estimators=300, 
                    max_depth=4,
                    min_child_weight=3)

X = column_transform.fit_transform(data, target_median)                   ①
boruta_selector = BorutaPy(estimator=xgb, n_estimators='auto', verbose=2) ②
boruta_selector.fit(X, target_median)                                     ③
selected_features = boruta_selector.support_                              ④
selected_data = column_transform.get_feature_names_out()[selected_features]
print(selected_data)

① 转换输入数据,执行任何必要的预处理步骤

② 使用 XGBoost 分类器初始化一个 BorutaPy 特征选择对象

③ 调整 Boruta 特征选择器

④ 获取由 Boruta 特征选择器确定的所选特征的布尔掩码

经过几次迭代后,你应该只得到一个被丢弃为与问题不相关的特征的结论:

Iteration:    50 / 100
Confirmed:    13
Tentative:    0
Rejected:    1
['low_card_categories__neighbourhood_group_Bronx'
 'low_card_categories__neighbourhood_group_Brooklyn'
 'low_card_categories__neighbourhood_group_Manhattan'
 'low_card_categories__neighbourhood_group_Queens'
 'low_card_categories__room_type_Entire home/apt'
 'low_card_categories__room_type_Private room'
 'low_card_categories__room_type_Shared room'
 'high_card_categories__neighbourhood' 'numeric__minimum_nights'
 'numeric__number_of_reviews' 'numeric__reviews_per_month'
 'numeric__calculated_host_listings_count' 'numeric__availability_365']

可以使用 LightGBM 作为预测器执行相同的程序,而不是 XGBoost:

from lightgbm import LGBMClassifier

lgbm = LGBMClassifier(boosting_type='gbdt', 
                      n_estimators=300, 
                      max_depth=4,
                      min_child_samples=3)

boruta_selector = BorutaPy(estimator=lgbm, n_estimators='auto', verbose=2) ①
boruta_selector.fit(X, target_median)
selected_features = boruta_selector.support_
selected_data = column_transform.get_feature_names_out()[selected_features]
print(selected_data)

① 使用提供的 LightGBM 分类器初始化一个 BorutaPy 特征选择对象

结果在仅 9 次迭代后达到,这次我们有一个增加的被拒绝特征数量:

Iteration:     9 / 100
Confirmed:     8
Tentative:     0
Rejected:      6
['low_card_categories__neighbourhood_group_Manhattan'
 'low_card_categories__room_type_Entire home/apt'
 'high_card_categories__neighbourhood' 'numeric__minimum_nights'
 'numeric__number_of_reviews' 'numeric__reviews_per_month'
 'numeric__calculated_host_listings_count' 'numeric__availability_365']

LightGBM 不仅收敛速度更快,而且其分割方式允许在这个问题中创建一个性能良好的模型,其特征数量比 XGBoost 少得多。

在我们的示例中,我们在所有可用数据上进行了训练。尽管如此,你仍然可以使用 Boruta,即使在交叉验证循环中,你可以在所有折叠中使用所有选定的特征或仅使用至少在折叠中至少被选中一定次数的特征来巩固数据集的结果。

6.2.3 前向和后向选择

Boruta 的一个局限性是它选择了你问题的所有相关特征,但不是必要的特征。这意味着你最终可能得到一个包含冗余和高度相关特征的列表,这些特征可以被缩短选择。在应用 Boruta 之后,我们建议回到序列特征选择过程,如 Scikit-learn 函数SequentialFeatureSelector中实现的那样。此过程通过正向选择添加或通过反向消除根据它们在预测中的性能以贪婪的方式从你的选择中添加或删除特征——也就是说,总是根据交叉验证分数选择最佳性能的选择,无论是添加还是丢弃。该技术依赖于学习算法及其目标函数。因此,其选择将始终在最佳可能的选择之中。由于它是一个贪婪过程,总是存在选择局部最优集的风险。

序列选择是一种非常有效的减少需要处理的特征数量的方法。然而,它相当耗时,因为算法必须在每一轮评估所有候选者。在正向过程中,随着你继续进行,这会变得越来越慢,因为尽管每一轮评估的候选者数量减少,但使用的特征数量增加会减慢训练速度。然而,在反向过程中,你开始较慢,并在丢弃一定数量的特征后倾向于加速。如果你从许多特征开始评估且训练非常缓慢,反向过程可能不切实际。

作为该过程的停止规则,你可以设置一定数量的特征,或者你可以让选择算法找出添加或删除特征不再对预测带来改进的点。容忍度阈值有助于给算法提供一定的自由度来决定是否继续:容忍度越大,算法在其操作中继续的可能性就越大,即使添加或删除特征在某种程度上降低了性能。

在列表 6.11 中,我们对在 Airbnb 纽约数据集上训练的 XGBoost 模型应用了正向选择。选择算法被设置为自由确定要添加的正确特征数量,并且低容忍度(在 0 到 1 的准确度度量上设置为 0.0001)应该在其预测性能开始下降的第一个迹象时停止。

列表 6.11 正向选择

from sklearn.feature_selection import SequentialFeatureSelector
from sklearn.metrics import accuracy_score, make_scorer
from XGBoost import XGBClassifier

xgb = XGBClassifier(booster='gbtree', 
                    objective='reg:logistic', 
                    n_estimators=300, 
                    max_depth=4,
                    min_child_weight=3)

cv = KFold(5, shuffle=True, random_state=0)                  ①
accuracy = make_scorer(accuracy_score)                       ②
X = column_transform.fit_transform(data, target_median)
selector = SequentialFeatureSelector(
     estimator=xgb,
     n_features_to_select="auto",
     tol=0.0001,                                             ③
     direction="forward",                                    ④
     scoring=accuracy,
     cv=cv
)
selector.fit(X, target_median)
selected_features = selector.support_                        ⑤
selected_data = column_transform.get_feature_names_out()[selected_features]
print(selected_data)

① 使用五个折点初始化一个 KFold 交叉验证分割对象

② 为特征选择过程创建一个评分函数

③ 设置序列特征选择器用于在搜索过程中确定收敛的容忍度值

④ 指定特征选择的方向(在本例中为“正向”)

⑤ 获取所选特征的布尔掩码

获得的结果指出需要使用六个特征:三个二元特征,一个高基数分类特征,以及两个数值特征:

['low_card_categories__neighbourhood_group_Bronx'
 'low_card_categories__room_type_Entire home/apt'
 'low_card_categories__room_type_Shared room'
 'high_card_categories__neighbourhood' 'numeric__minimum_nights'
 'numeric__number_of_reviews' 'numeric__reviews_per_month'
 'numeric__calculated_host_listings_count' 'numeric__availability_365']

我们可以通过运行以下命令以反向方式复制实验:

selector = SequentialFeatureSelector(
     estimator=xgb,
     n_features_to_select="auto",
     tol=0.0001,
     direction="backward",                                  ①
     scoring=accuracy,
     cv=cv
)
selector.fit(X, target_median)
selected_features = selector.support_
selected_data = column_transform.get_feature_names_out()[selected_features]
print(selected_data)

① 指定特征选择的方向(在这种情况下是“向后”)

结果选择由九个特征组成,其中许多已经在正向选择的结果集中出现过:

['low_card_categories__neighbourhood_group_Bronx'
 'low_card_categories__neighbourhood_group_Manhattan'
 'low_card_categories__neighbourhood_group_Queens'
 'low_card_categories__neighbourhood_group_Staten Island'
 'low_card_categories__room_type_Entire home/apt'
 'low_card_categories__room_type_Shared room'
 'high_card_categories__neighbourhood' 'numeric__minimum_nights'
 'numeric__number_of_reviews' 'numeric__reviews_per_month'
 'numeric__calculated_host_listings_count' 'numeric__availability_365']

根据我们自己的经验,选择正向或反向选择取决于你可能需要承担的风险,即从所选集中遗漏一些稍微重要的特征。使用正向添加,你可以确保只保留基本特征,但风险遗漏一些边际相关的特征。使用反向消除,你可以确保所有关键特征都在集合中,允许一些冗余。

除了选择正向或反向过程之外,顺序选择可以帮助你在训练和预测中更快地构建模型,并且由于涉及的特征数量有限,它将更容易解释和维护。

6.3 优化超参数

特征工程可以提高你从经典机器学习模型中获得的结果。创建新的特征可以揭示数据中模型由于局限性而无法把握的潜在模式和关系。通过移除对于问题无用的和冗余的特征,特征选择可以提高你的模型结果,从而减少数据中的噪声和虚假信号。最后,通过优化超参数,你可以获得另一个性能提升,并让你的经典机器学习模型在处理表格数据问题时更加出色。

如第四章所述,超参数是所有机器学习算法幕后工作的设置,决定了它们可以如何具体工作。从抽象的角度来看,每个机器学习算法可能提供有限的、但仍然很宽的范围的功能形式——即你可以用数学方式将预测变量与结果相关联的方式。直接从盒子里出来,机器学习算法可能更少或更多符合你特定机器学习问题所需的功能形式。

例如,如果你正在使用梯度提升算法来解决分类问题,可能默认的迭代次数或其树的生长方式并不符合问题的要求。你可能需要比默认值更少或更多的迭代和树生长。通过恰当地设置其超参数,你可以找到与你的问题更好地配合的最佳设置。

然而,这不仅仅是调整算法提供的所有许多旋钮直到得到你期望的结果的问题。有时旋钮太多,无法一起测试,即使你设法测试了足够多的旋钮,如果操作不当,可能会导致数据过拟合,并且相反,得到更差的结果。在定义一个或多个评估指标之后,你需要一个系统性的方法:

  • 定义一个包含你想要探索的超参数及其要测试的值边界的搜索空间

  • 建立一个适当的交叉验证方案,以确保你发现的是一个可以推广到你所拥有数据的解决方案

  • 选择一个搜索算法,通过适当的策略,可以在更短的时间内以更低的成本(例如,从计算的角度来看)找到你需要的解决方案

在以下小节中,根据不同的搜索策略,我们讨论了如何调整我们迄今为止所看到的某些经典机器学习算法的方法。

6.3.1 系统性搜索

网格搜索通过所有超参数值的可能组合。对于你想要测试的每个超参数,你选择一个值序列,并彻底迭代它们的所有组合。最后,你选择返回最佳结果的组合。

在列表 6.12 中,我们将其应用于逻辑回归模型,帮助选择正则化的类型以及 L1 和 L2 正则化值的设置。代码中最重要的一部分是搜索网格,它是一个包含一个或多个字典的列表。每个字典是一个搜索空间,一个与值生成器列表关联的超参数序列(字典的键),这些值是你想要测试的可能值(字典的值)。在所有优化方法中,结构一个或多个搜索空间是一种常见的做法,无论它们是否来自 Scikit-learn。只需注意超参数的名称是如何以model__name_of_the_hyperparameter的形式制定的,因为我们正在优化一个管道,并解决管道内部和模型参数。我们将在下一小节中对此进行更多解释。

列表 6.12 网格搜索

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.model_selection import KFold, GridSearchCV
from sklearn.metrics import make_scorer

accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)
model = LogisticRegression(solver="saga", max_iter=5_000)

model_pipeline = Pipeline(
    [('processing', lm_column_transform),
     ('model', model)])

search_grid = [
    {"model__penalty": [None]},
    {"model__penalty": ["l1", "l2"], "model__C": np.logspace(-4, 4, 10)},
    {"model__penalty": ["elasticnet"], "model__C": np.logspace(-4, 4, 10), 
     "model__l1_ratio": [.1, .3, .5, .7, .9, .95, .99]},
]                                                             ①

search_func = GridSearchCV(estimator=model_pipeline,          ②
                           param_grid=search_grid, 
                           scoring=accuracy, 
                           n_jobs=-1, 
                           cv=cv)

search_func.fit(X=data, y=target_median)
print (search_func.best_params_)                              ③
print (search_func.best_score_)                               ④

① 一个字典列表,指定逻辑回归模型的超参数搜索网格

② 使用定义的搜索网格初始化一个 GridSearchCV 对象

③ 打印网格搜索找到的最佳超参数

④ 打印模型在网格搜索过程中找到的最佳超参数所达到的最佳得分

测试所有组合后,网格搜索过程返回最佳的超参数组合就是根本不使用任何惩罚。它返回支持其报告的最佳交叉验证得分:

{'model__penalty': None}
0.8210860006135597

当你的超参数很少时,网格搜索是有效的;它们取离散值,并且你可以并行化内存中的测试操作,因为你的数据集不是太大。

首先,组合越多,你必须进行的测试就越多,你需要花费更长的时间和更多的计算。如果你需要测试许多超参数,并且怀疑其中一些对正确调整你的算法无关紧要,这可能会成为一个严重的问题。当你将一个超参数添加到网格搜索中时,你必须让所有其他超参数通过它循环,这可能会在测试的超参数无关紧要时变成浪费能量。

此外,如果一个参数取连续值,你必须决定如何将其连续的搜索空间转换为离散的。通常,这是通过将值的连续体均匀地划分为离散值来完成的,但这样做而没有了解算法相对于该超参数及其值的行为,其值可能再次变成在测试值上浪费多次计算,而这些测试值无法提高算法性能。

需要考虑的最后一个方面是使用多个核心并并行化它们的操作。网格搜索完全不知道每个测试的结果。结果只有在最后才会排名,你只能得到最佳结果。因此,如果你的算法自然地工作在单个核心上,网格搜索是可行的。然而,如果你的算法使用多个线程和核心,例如随机森林或 XGBoost,那么你必须在算法以全速运行和优化过程并行化以加快速度之间进行权衡。通常,最佳选择是使用并行运行来推动算法更快地运行。无论你决定利用算法的并行化能力还是搜索过程的并行化能力,当与多核算法一起工作时,网格搜索都不是性能最佳的选择。

根据我们的经验和网格搜索策略的局限性,我们认为它最适合测试线性模型,因为它们易于并行化,并且参数有限,通常以布尔值或离散值的形式出现。

6.3.2 使用随机试验

使用网格搜索时的重要限制包括:

  • 你需要将连续的超参数离散化。

  • 如果一个超参数与问题无关,你将浪费很多试验,因为它们测试了无关特征的搜索空间。

由于这些原因,随机采样搜索空间的想法在机器学习社区中根深蒂固。正如 James Bergstra 和 Yoshua Bengio 在论文“随机搜索用于超参数优化”中所述(*《机器学习研究杂志》;mng.bz/nRg8),当有多个超参数且你不知道它们如何影响结果或如何协同工作时,随机搜索优化成为标准优化方法。

在我们的例子中,我们使用 XGBoost 分类器重新审视我们的分类问题。XGBoost 与其他梯度提升实现一样,具有几个可以被认为是重要的超参数,您应该尝试测试它们以检查您的模型性能是否可以改进。在示例中,我们还使事情变得更加复杂,因为我们通过将 XGBoost 模型包装到管道中来操作,因此需要特定的方式来处理超参数。由于管道中的每个元素都有一个名称,您必须通过管道中元素的名称、两个下划线和超参数的名称来指定管道中的每个参数。例如,在我们的例子中,XGBoost 位于名为“xgb”的管道部分中。要指定 XGBoost 的n_estimators超参数,只需在搜索空间中使用标签xgb__n_estimators即可。这个想法是展示如何在不测试所有可能影响模型预测性能的选择的情况下优化模型及其管道。

列表 6.13 随机搜索

from sklearn.utils.fixes import loguniform
from sklearn.model_selection import KFold, RandomizedSearchCV
from sklearn.metrics import accuracy_score
from sklearn.metrics import make_scorer
from XGBoost import XGBClassifier

accuracy = make_scorer(accuracy_score)
cv = KFold(5, shuffle=True, random_state=0)

xgb = XGBClassifier(booster='gbtree', objective='reg:logistic')
model_pipeline = Pipeline(
    [('processing', column_transform), ('xgb', xgb)]
)                                                               ①

search_dict = {                                                 ②
    'xgb__n_estimators': np.arange(100, 2000, 100), 
    'xgb__learning_rate': loguniform(0.01, 1),
    'xgb__max_depth': np.arange(1, 8),
    'xgb__subsample': np.arange(0.1, 0.9, 0.05),
    'xgb__colsample_bytree': np.arange(0.1, 0.9, 0.05),
    'xgb__reg_lambda': loguniform(1e-9, 100),
    'xgb__reg_alpha': loguniform(1e-9, 100)
}

search_func = RandomizedSearchCV(estimator=model_pipeline, 
                                 param_distributions=search_dict, 
                                 n_iter=60,                     ③
                                 scoring=accuracy, 
                                 n_jobs=1,                      ④
                                 cv=cv, 
                                 random_state=0)

search_func.fit(X=data, y=target_median)
print (search_func.best_params_)                                ⑤
print (search_func.best_score_)                                 ⑥

① 创建一个结合数据预处理和 XGBoost 分类器的管道

② 包含各种超参数及其搜索空间的字典,用于 RandomizedSearchCV

③ 指定随机搜索过程的迭代次数

④ 指定用于搜索的并行作业数量

⑤ 打印由 RandomizedSearchCV 找到的最佳超参数

⑥ 打印在随机搜索过程中使用最佳超参数获得的最佳分数

过了一段时间(在 Google Colab 实例中代码运行大约需要一小时),我们得到了最佳参数集和通过交叉验证得到的分数:

{'xgb__colsample_bytree': 0.3500000000000001,
 'xgb__learning_rate': 0.020045491299569684,
 'xgb__max_depth': 6,
 'xgb__n_estimators': 1800,
 'xgb__reg_alpha': 3.437821898520205e-08,
 'xgb__reg_lambda': 0.021708909914764426,
 'xgb__subsample': 0.1}
0.8399836384088353

尽管随机搜索优化简单且依赖于随机性,但它确实有效,并且在许多情况下提供了最佳的优化。许多 AutoML 系统在需要调整许多超参数时依赖于这种优化策略(例如,参见 D. Golovinb 等人于 2017 年发表的“Google Vizier:一个黑盒优化服务”,mng.bz/8OrZ)。与网格搜索相比,当您有一组有限的超参数预期会产生影响,以及一组有限的值要测试时,随机搜索在您有太多值要调整且没有先验知识了解它们如何工作时效果最佳。

你所需要做的就是依靠足够的随机测试,以便出现良好的组合,这可能不会花费很长时间。根据我们的经验,30 到 60 次随机抽取通常足以进行良好的优化。随机搜索优化的一个优点是它适用于复杂问题,并且不受无关超参数的影响。相关参数的数量决定了你可以多快找到一个好的解决方案。该算法也适用于在不同计算机或实例上的并行搜索(你从所有结果中选择最佳结果)。然而,这个积极点也有局限性,因为测试是独立的,它们不会相互告知结果。

6.3.3 减少计算负担

网格搜索和随机搜索都不利用先前实验的结果。网格搜索严格遵循预定义的程序,而随机搜索进行一系列独立的测试。在这两种情况下,先前结果在搜索过程中都没有被考虑或使用。连续减半,这两种策略的包装器,可以利用知道先前结果的优势。想法就像一个锦标赛,你首先进行多轮比赛,投入少量资源来测试不同的超参数值。然后,随着你前进并淘汰表现不佳的值,你将更多资源投入到剩余值的彻底测试中。通常,你最初稀释然后后来集中的资源是训练样本的数量。更多的例子意味着来自超参数测试的某些结果,但这需要更多的计算能力。

在 Scikit-learn 中作为HalvingGridSearchCVHalvingRandomSearchCV提供,在列表 6.14 中,我们测试了随机搜索变体,以验证我们是否可以在一小部分时间内获得类似的优化结果。如前所述,我们使用样本数量作为稀缺资源进行优化,仅使用初始的 30%。此外,我们指示算法从 20 个初始候选者开始,并在每一轮中将候选者数量减少到原来的三分之一(从 20 减少到 6,再减少到 2)。

列表 6.14 减半随机搜索

from sklearn.experimental import (
    enable_halving_search_cv
)                                                           ①
from sklearn.model_selection import HalvingRandomSearchCV

search_func = HalvingRandomSearchCV(
    estimator=model_pipeline,
    param_distributions=search_dict,
    resource='n_samples',                                   ②
    n_candidates=20,                                        ③
    factor=3,                                               ④
    min_resources=int(len(data) * 0.3),                     ⑤
    max_resources=len(data),                                ⑥
    scoring=accuracy,
    n_jobs=1,
    cv=cv,
    random_state=0
)
search_func.fit(X=data, y=target_median)
print (search_func.best_params_)
print (search_func.best_score_)

① 启用实验性的 HalvingRandomSearchCV 模块

② 指定用于减半的资源是样本数量

③ 设置在第一次迭代中将采样和评估的候选者数量

④ 确定每次迭代中候选者数量减少的因子

⑤ 设置在减半过程中将使用的最小资源(样本)数量

⑥ 设置在减半过程中将使用的最大资源(样本)数量

以下是在所需时间的一小部分内获得的结果(在 Google Colab 中,该过程大约需要 10 分钟):

{'xgb__colsample_bytree': 0.6500000000000001,
 'xgb__learning_rate': 0.02714215181104359,
 'xgb__max_depth': 7,
 'xgb__n_estimators': 400,
 'xgb__reg_alpha': 3.281921389446602,
 'xgb__reg_lambda': 0.00039687940902191534,
 'xgb__subsample': 0.8000000000000002}
0.8398409090909091

在我们使用这种优化策略的经验中,策略是设置初始轮次,使其能够捕捉到一些好的超参数。因此,拥有尽可能多的候选者以最低的资源运行是非常重要的,尽管这不会太低以至于影响优化的结果。如果减少因子参数,那么至少 1,000 个起始样本应该足够好。这决定了每个后续迭代选择的候选者比例从三个变为两个,从而进行更多的轮次。

6.3.4 使用贝叶斯方法扩展搜索

另一种做出明智选择的优化策略是贝叶斯优化。这一策略由 Snoek、Larochelle 和 Adams 在论文“Practical Bayesian Optimization of Machine Learning Algorithms”(arxiv.org/abs/1206.2944)中提出,其背后的理念是通过构建模型来理解模型超参数的工作方式。该算法通过优化一个代理函数,即代理函数,来提高算法的性能。当然,代理函数会根据优化中的机器学习模型的目标函数的反馈进行更新。然而,贝叶斯优化算法的决策完全基于代理函数。

尤其是另一种交替探索与利用的策略:获取函数。获取函数根据代理函数报告了探索特定参数组合的潜力有多大以及不确定性有多大。探索意味着尝试从未尝试过的参数组合,这发生在存在很多不确定性和因此希望至少尝试改进代理函数的搜索空间区域时。相反,当获取函数确保算法在尝试特定一组超参数时可以提高性能时,就会发生利用。

正如名字中的“贝叶斯”所暗示的,以及我们对贝叶斯优化内部工作原理的简要描述,这个过程受到先验期望的影响,并在微调周期中通过后验观察进行修正。在这个过程中,代理函数不过是我们模型的一个模型。通常,高斯过程被选为代理函数的模型。尽管如此,还有其他替代方案,例如使用随机森林或树结构 Parzen 估计器等树算法,这些是多变量分布,能够描述我们模型中超参数的行为。Scikit-optimize (scikit-optimize.github.io/stable/) 或 KerasTuner (keras.io/keras_tuner/) 等包使用高斯过程,Scikit-optimize 还能够在使用树集成的同时,KerasTuner 则使用多臂老丨虎丨机。Optuna,由日本人工智能研发公司 Preferred Networks 开发的优化框架,则使用树结构 Parzen 估计器。Optuna 最初于 2019 年 5 月作为一个开源项目发布,由于其简单性、多功能性和与 TensorFlow、PyTorch 和 Scikit-learn 等流行机器学习库的集成,在 Python 机器学习社区中特别受欢迎。

在我们的例子中,我们使用 Optuna 来改进我们的 XGBoost 分类器。当使用 Optuna 时,你只需设置一个研究并为其提供运行参数,例如试验次数n_trials以及如果你想要最小化或最大化目标函数的方向参数。在幕后,所有繁重的工作都是由目标函数完成的,你定义的目标函数返回一个评估结果。目标函数期望只有一个输入参数,即试验,这是 Optuna 提供的。通过试验参数,你定义要测试的超参数的值。然后,你只需按照你的喜好进行测试,因为是否应用交叉验证、对样本进行简单测试或其他任何事情,完全取决于你在目标函数内部的决定。这种灵活性还允许你运行复杂的优化,其中某些超参数被使用或依赖于其他超参数及其值。你需要编写你想要的程序代码。

列表 6.15 使用 Optuna 进行贝叶斯搜索

import optuna
from XGBoost import XGBClassifier
from sklearn.model_selection import cross_validate

def objective(trial):

    params = {
        'n_estimators': trial.suggest_int('n_estimators', 100, 2000),
        'learning_rate': trial.suggest_float(
            'learning_rate', 0.01, 1.0, log=True
        ),
        'subsample': trial.suggest_float('subsample', 0.1, 1.0),
        'colsample_bytree': trial.suggest_float(
            'colsample_bytree', 0.1, 1.0
        ),
        'max_depth': trial.suggest_int('max_depth', 1, 7),
        'min_child_weight': trial.suggest_int('min_child_weight', 1, 7),
        'reg_lambda': trial.suggest_float(
            'reg_lambda', 1e-9, 100.0, log=True
         ),
        'reg_alpha': trial.suggest_float(
            'reg_alpha', 1e-9, 100.0, log=True
         ),
    }                                                          ①

    xgb = XGBClassifier(
        booster='gbtree', 
        objective='reg:logistic',
        **params
    )                                                          ②
    model_pipeline = Pipeline(
        [('processing', column_transform), ('xgb', xgb)]
    )
    accuracy = make_scorer(accuracy_score)
    cv = KFold(5, shuffle=True, random_state=0)

    cv_scores = cross_validate(estimator=model_pipeline, 
                               X=data, 
                               y=target_median,
                               scoring=accuracy,
                               cv=cv)                          ③

    cv_accuracy = np.mean(cv_scores['test_score'])
    return cv_accuracy                                         ④

study = optuna.create_study(direction="maximize")              ⑤
study.optimize(objective, n_trials=60)                         ⑥
print(study.best_value)                                        ⑦
print(study.best_params)                                       ⑧

① 定义 Optuna 超参数搜索空间的字典

② 创建一个由 Optuna 建议超参数的 XGBoost 分类器

③ 使用超参数执行交叉验证以评估模型的性能

④ 一个作为优化目标值的函数,通过返回交叉验证的平均准确率得分

⑤ 创建一个 Optuna 研究对象,目标是最大化目标函数

⑥ 使用定义的目标函数和最多 60 次试验启动优化过程

⑦ 打印目标函数的最佳实现值

⑧ 打印 Optuna 找到的最佳超参数

在 Google Colab 实例上,这个过程可能需要长达两小时,但就超参数优化而言,结果无疑是同类中你能获得的最佳:

{'n_estimators': 1434, 
 'learning_rate': 0.013268588739778429,
 'subsample': 0.782534239551612,
 'colsample_bytree': 0.9427647573058971,
 'max_depth': 7,
 'min_child_weight': 2,
 'reg_lambda': 2.3123673571345327e-06,
 'reg_alpha': 1.8176941971395193e-05}
0.8419879333265161

作为 Optuna 提供的额外功能,通过在之前的代码中添加几个简单的修改,你可以将你的研究存储在项目数据库中,并在任何时间重新启动优化。如果在创建研究时声明了研究的名称和目标数据库,Optuna 可以将其优化过程与 SQLite 集成:

sqlite_db = "sqlite:///sqlite.db"                                      ①
study_name = "optimize_XGBoost"                                        ②
study = optuna.create_study(storage=sqlite_db, study_name=study_name, 
                            direction="maximize", load_if_exists=True) ③
study.optimize(objective, n_trials=60)

print(study.best_params)
print(study.best_value)

① 定义 Optuna 将存储研究相关信息的 SQLite 数据库的路径

② 为 Optuna 研究提供一个名称

③ 创建一个 Optuna 研究对象并将其连接到 SQLite 数据库

关于 SQLite 存储数据库的指定,sqlite:// 是一个统一资源标识符(URI)方案,用于指定连接到 SQLite 数据库的协议或机制。在 URI 方案中,sqlite:// 表示数据库连接将通过 SQLite 数据库引擎建立。当使用此 URI 方案时,sqlite://+ 部分后面跟着 SQLite 数据库文件的路径。在你的例子中,sqlite:///sqlite.db 指定 SQLite 数据库文件名为 sqlite.db,并且位于当前目录。sqlite: 后面的三个斜杠(///)是可选的,表示路径是相对于当前目录的。

一旦研究完成,你还可以获得有关迭代结果的实用可视化,并在后续运行相同的搜索中获得有价值的见解。例如,你可以探索优化历史记录,检查你是否已经达到了优化的平台期,或者继续进行更多迭代是否可取:

fig = optuna.visualization.plot_optimization_history(study)
fig.show()

图 6.6 展示了我们的优化过程。经过几次迭代后,优化达到了一个良好的结果,但随后在剩余的可用迭代中进展缓慢。在这种情况下,进一步优化进展的可能性很小,因为此时的任何收益都将微乎其微。

图 6.6 展示了优化结果的历史记录

另一个有用的图表描绘了超参数如何决定最终的优化设置:

fig = optuna.visualization.plot_param_importances(study)
fig.show()

图 6.7 展示了我们优化 XGBoost 算法估计的重要性。结果显示,max_depth 超参数占据主导地位,同时某种程度上也受到子样本值的影响。这样的结果表明,算法对树的深度很敏感,并且增加深度会显著影响优化结果。这可能表明数据中包含复杂的模式,需要更深的树来捕捉,而优化中找到的七这个最佳点标志着算法开始过拟合的点。

图 6.7 Optuna 优化过程中超参数估计重要性的图表

理解为什么你的 XGBoost(或 LightGBM)在特定条件下表现更好,这因问题而异。然而,能够理解原因,向他人(如利益相关者)解释原因,并采取措施调整你的数据或优化设置,确实是 Optuna 相较于其他优化方法提供的一项宝贵功能。

在完成优化技术的全景图示后,我们面临的情况是,你可能不想设置任何复杂的东西来让你的机器学习算法工作,但你又需要一些指导,了解如何通过试错快速调整。

6.3.5 手动设置超参数

尽管之前描述的优化策略效率很高,但你可能不会惊讶地读到我们知道许多从业者仍然通过直觉和试错来调整他们模型的设置。这种程序在实验阶段似乎特别有根据,当你试图让一切合理工作,寻找改进解决方案的各种迭代方法时。因此,彻底的优化是在处理和实验迭代完成后留下的。

本书附录提供了对迄今为止涵盖的机器学习算法关键参数的全面指南。我们首先从线性模型开始,如线性或逻辑回归,由于参数数量有限且离散化容易,它们可以通过网格搜索有效地调整。一个表格涵盖了随机森林和极端随机树,因为它们具有相似的超参数,都是基于相同的自助集成方法。

关于 GBDTs,根据具体实现,我们有不同的超参数集。为了您的方便,我们选择了最关键的几个。您可以根据建议的范围手动或自动优化它们。指南从 HistGradientBoosting 开始,然后涵盖 XGBoost 和 LightGBM。重要的是要注意,XGBoost 有一组更大的相关超参数(你可以在mng.bz/6e7e找到完整列表)。最后,我们还包括了 LightGBM 的超参数列表,它与 XGBoost 略有不同(你可以在mng.bz/vK8q找到完整列表)。这份全面的指南将帮助您有效地调整机器学习算法,并根据特定的超参数设置优化它们的性能。

至于手动调整 GBDT,模型通常在默认设置下表现最差,因此你应该了解一些行业技巧。让我们从 Jerome Friedman 于 1999 年发表的一篇题为“贪婪函数逼近:梯度提升机”的论文开始。在这篇论文中,Friedman 讨论了树的数量和学习率之间的权衡。观察到较低的学习率往往会导致更高的最优树的数量。此外,当增加模型中决策树的最大深度时,建议降低学习率。这种预防措施是因为更深的树引入了更多的复杂性,可能导致过拟合。过拟合发生在模型过度定制于训练数据,在未见过的数据上表现不佳时。通过同时降低学习率,可以减轻这种风险。这是因为较低的学习率意味着模型更新更小、更谨慎。这种渐进的学习过程允许进行更精细的调整,帮助模型在捕捉复杂关系和避免过拟合之间取得更好的平衡。

另一个关于在 GBDT 中手动调整参数的宝贵资源是 Owen Zhang 在 2015 年向纽约市数据科学学院发表的题为“赢得数据科学竞赛”的演讲。Owen 之前是 Kaggle 的顶尖竞争者,提供了一些有趣的建议:

  • 根据数据集大小(通常在 100 到 1,000 之间)决定使用的树的数量,并在优化过程中保持固定。更倾向于使用较少的树而不是更多的树。

  • 在 2 到 10 之间除以树的数量范围内测试学习率。因此,对于 1,000 棵树,测试学习率在 0.002 到 0.01 的区间内。

  • 在 0.5、0.75、1.0 的值上测试行采样。

  • 在 0.4、0.6、0.8、1.0 的值上测试列采样。

  • 在 4、6、8、10 的值上测试最大树深度。

  • 将最小叶子权重/计数调整为相对于预测的最稀疏类百分比的平方根的 3 倍的大约比例。因此,如果需要预测的类在数据中的覆盖率为 10%,则应将最小叶子权重/计数设置为约 9。这个数字是通过将 3 除以 0.1 的平方根(因为 10%的覆盖率为小数 0.1)计算得出的。

在总结部分,我们继续探讨一些想法和技巧,以在解决表格数据问题时更好地掌握 GBDT。

6.4 精通梯度提升

在讨论了梯度提升的工作原理及其实现之后,我们以关于如何最佳使用梯度提升、理解其内部工作原理以及加快训练和预测速度的建议来结束本章。

6.4.1 在 XGBoost 和 LightGBM 之间做出选择

当考虑使用梯度提升来解决您的数据问题时,XGBoost 和 LightGBM(以及 HistGradientBoosting)是直方图梯度提升机的最流行和性能最高的实现之一。尽管它们如此强大,但根据我们的经验,您永远不能事先选择 XGBoost 或 LightGBM,或者一般地偏好 GBDT 相对于其他经典或深度学习解决方案,因为机器学习中的“没有免费午餐定理”:没有一种通用的学习算法对所有可能的问题都表现最佳。因此,声称“XGBoost 是您需要的所有东西”对于表格数据问题来说确实是一个吸引人的说法,但它可能并不总是适合您特定的数据问题或情况。GBDT 通常倾向于在表格数据问题上优于其他解决方案。因此,从它们开始,但不仅限于它们,是一个不错的选择。回到具体的实现,虽然始终建议在您的数据上测试任何算法并做出自己的决定,但在决定是否首先尝试一个实现而不是另一个时,还有一些其他标准要考虑。我们已经根据我们的经验验证了它们。它们总结在表 6.1 中。

表 6.1 使用 GBDT 时考虑的标准

类型 描述
数据量 XGBoost 对所有表格问题都表现良好;由于 LightGBM 的叶状分割方法可以创建更深的树,因此在使用较小的数据集时,它更容易过拟合。
可扩展性 XGBoost 的可扩展性和 GPU 就绪性更强;LightGBM 面临更多挑战。
实验速度 在 CPU 上,LightGBM 无疑比 XGBoost 快。

大量数据的可用性是首先要考虑的标准。LightGBM 使用叶状(垂直)增长,这可能导致过拟合。对可用数据进行过拟合的趋势很好地解释了该算法在 Kaggle 竞赛中的成功。因此,当您有大量可用数据时,LightGBM 表现得更好。相比之下,XGBoost 在较小的数据样本上构建的模型比 LightGBM 更稳健。

另一个要考虑的标准是您是否有权访问多个 GPU 和强大的 CPU,或者只能有限地访问计算资源。如果您有大量资源,XGBoost 的可扩展性更强,使其成为在机构或商业环境中使用的更好选择。然而,如果您更倾向于专注于实验和特征工程,并且无法访问 GPU,那么由于 LightGBM 的训练时间更快,它更有意义。您可以使用节省下来的训练时间来提高最终模型的稳健性。如果您资源有限,例如只有一台独立的计算机,您应该考虑 XGBoost 的训练时间会随着样本大小的增加而线性增加,而 LightGBM 所需的训练时间则小得多。

6.4.2 探索树结构

如前所述,GBDTs 是复杂的算法,并非无法解释或无法复制的。你只需要以更高效的方式重现它们所组成的各种决策树,并将它们结合起来以获得快速的预测。XGboost 和 LightGBM 都允许探索和提取它们的模型结构。在列表 6.16 中,我们采取了一些步骤来展示这一点。在将 XGBoost 简单解决方案导出到 JSON 文件后,我们使用深度优先搜索策略在其结构内部导航,就像在图中一样。在深度优先搜索中,算法在回溯之前尽可能深入地探索每个分支。

仔细查看列表 6.16 中的代码,你可以在traverse_xgb_tree函数中注意到,代码通过首先遍历左子树(tree['children'][0])然后遍历右子树(tree['children'][1])来递归地探索树。这从递归调用traverse_xgb_tree(tree['children'][0])traverse_xgb_tree(tree['children'][1])中可以明显看出。

列表 6.16 提取 XGBoost 树结构

import json
import matplotlib.pyplot as plt
from XGBoost import XGBClassifier, plot_tree
from collections import namedtuple

xgb = XGBClassifier(booster='gbtree',
                    objective='reg:logistic',
                    n_estimators=10,
                    max_depth=3)                           ①

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('XGBoost', xgb)])                                    ②

model_pipeline.fit(X=data, y=target_median)
model = model_pipeline["XGBoost"]
tree_info = model.get_booster().dump_model(
    "xgb_model.json",
    with_stats=True,
    dump_format="json"
)                                                          ③

fig, ax = plt.subplots(figsize=(12, 15), dpi=300)
ax = plot_tree(
    model, num_trees=0, ax=ax, rankdir='LR
)                                                          ④
plt.show()

with open("xgb_model.json", "r") as f:
    json_model = json.loads(f.read())                      ⑤

print(f"Number of trees: {len(json_model)}")
tree_structure = json_model[0]                             ⑥

Split = namedtuple("SplitNode", "feature origin gain count threshold")
Leaf = namedtuple("LeafNode", "index origin count")

def extract_xgb_node_info(tree):
    return [tree['split'], tree['origin'], tree['gain'],
            tree['cover'], tree['split_condition']]        ⑦

def extract_xgb_leaf_info(tree):
    return (
        [tree['nodeid'], 
         tree['origin'], 
         tree['cover']
        ]
)                                                          ⑧

def traverse_xgb_tree(tree):                               ⑨
    if not 'origin' in tree:
        tree['origin'] = "="
    if not 'children' in tree:
        return [[Leaf(*extract_xgb_leaf_info(tree))]]
    left_branch = tree['children'][0]
    right_branch = tree['children'][1]
    left_branch['origin'] = '<'
    right_branch['origin'] = '>='
    left_paths = traverse_xgb_tree(left_branch)
    right_paths = traverse_xgb_tree(right_branch)
    node_info = [Split(*extract_xgb_node_info(tree))]
    return [node_info + path for path in left_paths + right_paths]

paths = traverse_xgb_tree(tree_structure)

print(f"Number of paths on tree: {len(paths)}")
print("Path 0:", paths[0])

① 创建一个限制为 10 个估计量和三个级别的树的 XGBoost 分类器

② 从管道中提取 XGBoost 模型

③ 将 XGBoost 模型的信息(增强器)导出到一个 JSON 文件中

④ 创建集成中第一棵树的图表

⑤ 从磁盘检索包含模型信息的 JSON 结构

⑥ 打印模型中的树的数量并提取第一棵树的结构

⑦ 函数从树结构中的分割节点提取各种信息

⑧ 函数从树结构中的叶节点提取信息

⑨ 函数递归遍历树结构以提取路径

该代码训练一个 XGBoost 模型,保存其树结构,将结构处理成可读的方式,并将结果呈现给用户:

Number of trees: 10
Number of paths on tree: 8
Path 0: [SplitNode(
             feature='f5', 
             origin='=', 
             gain=19998.9316, 
             count=12223.75,
             threshold=0.5),
             SplitNode(
                 feature='f2',
                 origin='<',
                 gain=965.524414,
                 count=5871.5,
                 threshold=0.5
             ), 
            SplitNode(
                 feature='f13',
                 origin='<',
                 gain=66.1962891,
                 count=3756,
                 threshold=1.88965869
            ), 
            LeafNode(
                 index=7,
                 origin='<',
                 count=3528)
]

图 6.8 将获得的输出与 XGBoost 包本身提供的完整树的图形表示进行了比较。

图 6.8 XGBoost 的plot_tree输出

在模型中构建的 10 棵树中,代码展示了第一棵树,并在从样本到预测叶节点的 8 条不同路径中,表示了第一条路径。从视觉上看,这条路径是最左侧的。路径由一系列不同的节点组成。代码报告了使用的特征名称、从上一个节点(在 XGBoost 中,小分支总是代表左分支,而大分支等于右分支)的分割分支起源、分割阈值、相对于目标函数的增益以及根据数据集的分割导致的样本减少。所有这些信息都允许你完美地复制 XGBoost 模型中每棵树的结果。

我们也可以从 LightGBM 中提取相同的树结构,尽管方法略有不同,因为 LightGBM 包遵循一些略微不同的约定。例如,XGBoost 总是先在左边的阈值上分割;而 LightGBM 则相反,对于每个节点,使用减号或大于等于和阈值定义一个规则,如果规则为真则在左边分割,如果为假则在右边分割。

列表 6.17 提取 LightGBM 树结构

from lightgbm import LGBMClassifier, plot_tree

lgbm = LGBMClassifier(boosting_type='gbdt', 
                      n_estimators=10, 
                      max_depth=3)

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('lightgbm', lgbm)])

model_pipeline.fit(X=data, y=target_median)
model = model_pipeline["lightgbm"]

tree_info = model._Booster.dump_model()["tree_info"]           ①
tree_structure = tree_info[0]['tree_structure']                ②
plot_tree(
    booster=model._Booster,
    tree_index=0,
    dpi=600
)                                                              ③

Split = namedtuple(
    "SplitNode",
    "feature origin decision_type threshold gain count"
)
Leaf = namedtuple("LeafNode", "index origin count value")

def extract_lgbm_node_info(tree):                              ④
    return [tree['split_feature'], tree['origin'], tree['decision_type'],
            tree['threshold'], tree['split_gain'], tree['internal_count']]

def extract_lgbm_leaf_info(tree):                              ⑤
    return [
         tree['leaf_index'],
         tree['origin'],
         tree['leaf_count'], 
         tree['leaf_value']
    ]

def traverse_lgbm_tree(tree):                                  ⑥
    if not 'origin' in tree:
        tree['origin'] = ""
    if not 'left_child' in tree and not 'right_child' in tree:
        return [[Leaf(*extract_lgbm_leaf_info(tree))]]
    left_branch = tree['left_child']
    right_branch = tree['right_child']
    left_branch['origin'] = 'yes'
    right_branch['origin'] = 'no'
    left_paths = traverse_lgbm_tree(left_branch)
    right_paths = traverse_lgbm_tree(right_branch)
    node_info = [Split(*extract_lgbm_node_info(tree))]
    return [node_info + path for path in left_paths + right_paths]

paths = traverse_lgbm_tree(tree_structure)
print(paths[0])

① 从 LightGBM 模型增强器中提取树信息

② 从树信息中提取第一个树的结构

③ 使用 plot_tree 函数绘制集成中的第一个树

④ 从 LightGBM 树结构中的分割节点提取各种信息的函数

⑤ 从 LightGBM 树结构中的叶节点提取信息的函数

⑥ 递归遍历 LightGBM 树结构以提取路径的函数

本探索报告的结果报告了从集成中第一个决策树的结构:

[SplitNode(
    feature=5, 
    origin='',
    decision_type='<=',
    threshold=1.0000000180025095e-35,
    gain=20002.19921875,
    count=48895),
 SplitNode(
    feature=2,
    origin='yes',
    decision_type='<=',
    threshold=1.0000000180025095e-35,
    gain=967.0560302734375,
    count=23486),
 SplitNode(
    feature=13,
    origin='yes',
    decision_type='<=',
    threshold=1.8896587976897459,
    gain=67.53350067138672,
    count=15024), 
 LeafNode(
    index=0,
    origin='yes',
    count=14112,
    value=-0.16892421857257725)
]

图 6.9 显示了plot_tree函数绘制的整个树,这次是从 LightGBM 包中。

图片

图 6.9 LightGBM 的plot_tree输出

树从左到右水平绘制。我们可以检查代码返回的路径是最高路径,以叶节点 0 结束。

6.4.3 通过 GBDT 和编译加速

当案例数量或可用特征很多时,即使是较快的 LightGBM 也可能需要很长时间来训练此类数据上的模型。在训练时,你可以通过减小参数subsample的值来减少处理的案例和特征,以限制每个决策树中涉及的案例数量,以及参数colsample_bytree来限制在树分割时考虑的特征数量,从而克服长时间的等待。然而,减少案例或特征可能不是从你的模型中获得最佳结果的最佳选择。另一种选择是使用 GPU,因为它们广泛用于深度学习模型。GPU 可以加速训练操作,特别是与 XGBoost 一起,以及在 LightGBM 模型中,虽然程度较小但仍然显著。

使用 XGBoost,从建模的角度来看,使用你的 GPU 相当简单:你只需将tree_method参数的值指定为"gpu_hist"。然而,在新 2.0.0 版本中,这种方法已被弃用,用户现在可以通过device参数指定使用的设备。你可以将其设置为"cpu"以让 XGBoost 在 CPU 上执行,或者设置为device="cuda"以及device="gpu"以使其在 CUDA 支持的 GPU 上运行,目前这是唯一的选择,但将来将支持更多 GPU 类型。如果你有多个 GPU,你可以指定它们的序号来选择特定的一个;例如,device="cuda:1"将在你的第二个 GPU 设备上执行。

为了使 XGBoost 运行,您至少需要安装 CUDA 11.00 以及具有 5.0 计算能力的 GPU。如果您有更多的 GPU 可用,您可以通过gpu_id参数指定使用哪一个,该参数代表 CUDA 运行时报告的 GPU 设备序号(如果您只有一个 GPU,通常设置为 0)。这样,XGBoost 将决策树的生长移动到 GPU 内存和处理器中,从而获得相关的操作速度,特别是特征直方图,如 Mitchell 和 Frank 在论文“Using GPU Computing to Accelerate the XGBoost Algorithm”中所述(peerj.com/articles/cs-127/)。

一旦 GPU 训练了一个模型,它就可以在具有 GPU 的机器上进行预测。您只需设置predictor参数为gpu_predictor或如果您想使用 CPU,则设置为cpu_predictor。当您需要计算模型可解释性的 SHAP 值和 SHAP 交互值时,选择 GPU 作为预测器参数也可以加快速度:

model.set_param({"predictor": "gpu_predictor"})
shap_values = model.predict(X, pred_contribs=True)
shap_interaction_values = model.predict(X, pred_interactions=True)

虽然使用 GPU 与 XGBoost 一起使用很简单,但与 LightGBM 一起使用就变得有点复杂。LightGBM 没有 GPU 运行的选项,而是需要为其编译一个特殊版本。根据您的操作系统(Windows、Linux/Ubuntu、MacOS),编译可能更具挑战性。对于 POSIX 系统,请参阅mng.bz/nRg5的说明,对于 Windows 系统,请参阅mng.bz/vK8p的说明。然而,如果您已按照mng.bz/4aJg中的说明准备好所有先决条件,您可以直接在 shell 或命令提示符中使用 pip install 指令进行安装:

pip install lightgbm --install-option=--gpu

一切安装完成后,您需要将参数device设置为gpu。不过,不要期待惊人的性能提升。正如 LightGBM 作者所述(见mng.bz/vK8p),在大型和密集数据集上可以获得最佳结果,因为不高效的数据周转会导致在处理小型数据集时产生延迟。此外,为直方图算法设置更少的 bins 数量将使 GPU 与 LightGBM 更有效地工作。建议将max_bin=15和单精度,gpu_use_dp=false设置为最佳性能。

GPU 对于加速训练非常有用,但在预测时还有更多选项。正如我们在上一节中看到的,由于树结构如此容易获得,一些特定项目已经可以使用这种信息来重建预测树,使用性能更好的编程语言,如 C、JAVA 或 LLVM,这些语言可以将你的模型转换为纯汇编代码。这样的树编译项目旨在实现快速预测和更容易部署。例如,Treelite (github.com/dmlc/treelite) 可以读取由 XGBoost、LightGBM 甚至 Scikit-learn 生成的模型,还有 lleaves (github.com/siboehm/lleaves),这是一个仅针对 LightGBM 的项目。

从 Treelite 开始,这个项目致力于成为决策树森林的通用模型交换和序列化格式。它将你的 GBDT 编译成 C 或 Java,依赖性尽可能少,因此你可以轻松地将它部署到任何系统。为了进行测试,你必须在命令行中安装几个包:pip install tl2cgen treelite treelite_runtime

列表 6.18 Treelite 加速 XGBoost 预测

import treelite
import treelite_runtime
import tl2cgen

xgb = XGBClassifier(booster='gbtree',
                      objective='reg:logistic',
                      n_estimators=10,
                      max_depth=3)

model_pipeline = Pipeline(
     [('processing', column_transform),
     ('XGBoost', xgb)])

model_pipeline.fit(X=data, y=target_median)
model = model_pipeline["XGBoost"]

model.save_model("./xgb_model.json")                          ①
treelite_model = treelite.Model.load("./xgb_model.json", 
model_format="XGBoost_json")                                  ②
tl2cgen.generate_c_code(treelite_model, dirpath="./", 
params={"parallel_comp": 4})
tl2cgen.export_lib(treelite_model, toolchain="gcc", 
libpath="./xgb_model.so",                                     ③
                   params={"parallel_comp": 4})

predictor = tl2cgen.Predictor("./xgb_model.so")
X = model_pipeline["processing"].transform(data)              ④
dmat = tl2cgen.DMatrix(X)                                     ⑤
predictor.predict(dmat) 

① 将 XGBoost 模型保存到 JSON 文件

② 从 JSON 文件加载 Treelite 格式的 XGBoost 模型

③ 从 Treelite 模型生成 C 代码并将其导出为共享库

④ 使用管道中定义的预处理步骤转换输入数据

⑤ 从转换后的数据创建与导出 Treelite 模型兼容的 Treelite DMatrix

结果是一个编译后的模型,在 Python 脚本中可以以更快的速度返回预测。预测器必须在转换之前进行转换,因为管道不是编译的一部分。只有模型是。此外,你还需要在将数据发送到编译模型之前将其转换为 DMatrix 格式,这是 XGBoost 的本地数据格式。

由 Simon Boehm 开发,lleaves 通过基于可以从 LightGBM 模型输出的文本树结构使用 LLVM 编译到汇编,承诺实现 x10 的速度提升。通过在命令行上使用pip install leaves指令安装包后,你可以按照以下步骤获得加速。

列表 6.19 lleaves加速 LightGBM 预测

import lleaves

lgbm = LGBMClassifier(boosting_type='gbdt',
                      n_estimators=10,
                      max_depth=3)

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('lightgbm', lgbm)])

model_pipeline.fit(X=data, y=target_median)
model = model_pipeline["lightgbm"]

model.booster_.save_model('lgb_model.txt')                     ①

llvm_model = lleaves.Model(model_file="lgb_model.txt")         ②
llvm_model.compile()                                           ③
X = model_pipeline["processing"].transform(data)               ④
llvm_model.predict(X)

① 将 LightGBM 模型保存到文本文件

② 使用 lleaves 库加载 LightGBM 模型

③ 将加载的 LightGBM 模型编译成 LLVM 表示

④ 使用管道中定义的预处理步骤转换输入数据

在这个例子中,模型被编译,可以在 Python 脚本中以更快的速度进行预测。从一般的角度来看,尽管lleaves仅限于 LightGBM,但它是一个需要用户设置和指定更少的编译解决方案,从而实现更简单、更直接的使用。

摘要

  • 在处理问题中,缺失数据是其中最棘手的问题之一。如果你的数据是 MCR 或只是 MAR(因为缺失模式与其他特征相关),多元插补可以使用数据集中预测变量的相关性来插补缺失值。

  • XGBoost 和 LightGBM 算法自动处理缺失数据,通过将它们分配到每个分割中损失函数最小化的那一侧。

  • 当一个分类特征由于许多标签而呈现高基数时,你可以使用目标编码,这在 Kaggle 竞赛中变得流行。目标编码是一种将分类特征中的值转换为它们对应的预期目标值的方法。

  • PDP 是一种模型无关的图表技术,它通过你所训练的模型解释特征和目标之间的关系。它是有益的,因为它可以帮助你更好地建模预测特征和目标之间的关系,如果你注意到它是非线性且复杂的。

  • XGBoost,得益于 XGBFIR 等包,可以告诉你预测特征之间最重要的交互。

  • 通过采用有效的特征选择技术,你可以确定并保留对机器学习过程贡献显著的最重要的特征。处理特征选择的标准技术是基于 L1 正则化的稳定性选择(用于线性模型)、迭代选择和 Boruta(用于树集成):

    • 基于 L1 正则化,稳定性选择旨在识别在多个子集中始终出现为重要的特征,这表明它们的鲁棒性,并减少随机或噪声选择特征的可能性。

    • Boruta 是一种通过依赖于模型内部参数(如线性模型中的系数或基于增益的重要性值,如决策树及其集成)来确定特征在机器学习问题中是否相关的程序。

    • 通过前向选择或后向消除,迭代选择添加或删除特征,基于它们在预测中的性能,以贪婪的方式从你的选择中提取特征,只留下对预测至关重要的特征。

  • 通过优化超参数,你可以给你的经典机器学习模型带来另一个性能提升。除了手动设置超参数外,根据你正在工作的模型,网格搜索、随机搜索、连续减半和贝叶斯优化是数据科学社区中流行的优化方法:

    • 网格搜索通过遍历所有可能的超参数值组合来简单工作。对于你想要测试的每个超参数,你选择一个值序列,并彻底迭代它们的所有组合。

    • 随机搜索优化通过从搜索空间中随机抽取值来决定要测试的值。如果你对超参数了解不多,如果有很多超参数,以及如果某些参数无关紧要但你不知道是哪些,这种技术特别有效。

    • 连续减半是之前讨论策略的包装器。它作为一个超参数集之间的锦标赛工作,首先,它们使用少量计算资源进行测试。然后,只有最好的部分进一步使用更多资源进行测试。最后,将只剩下一组幸存的超参数。

    • 贝叶斯优化通过有信息搜索来寻找最佳的超参数集。它基于对超参数在数据问题上的工作原理的先验知识,构建了超参数行为的模型。然后,它设置一系列实验来进一步探索并完善其内部模型,利用之前的试验,并验证解决方案的实际性能。

  • XGBoost 和 LightGBM 都有特定的设置和选项,这在其他机器学习算法中并不常见,例如提取和表示它们的内部结构以及通过 GPU 使用和编译来加速它们的执行。

第七章:使用 XGBoost 的端到端示例

本章涵盖

  • 从互联网收集和准备数据,使用生成式 AI 来帮助

  • 起草一个基线模型和第一个尝试优化的模型

  • 理解模型的工作原理并检查它

本章总结了我们对表格数据的经典机器学习的概述。为了总结,我们将通过数据新闻领域的完整示例进行操作。在这个过程中,我们将总结到目前为止我们使用过的所有概念和技术。我们还将使用生成式 AI 工具 ChatGPT 来帮助你完成任务,并展示一些大型语言模型(LLM)如何改善你处理表格数据的工作的用例。

我们最终将构建一个预测价格的模型,这次使用基于回归的方法。这样做将帮助我们理解模型的工作原理以及为什么它以特定的方式表现,从而进一步深入了解 Airbnb 列表的定价动态,并挑战我们对短期租赁定价发生的初始假设。

7.1 准备和探索你的数据

要开始,我们将专注于不同的数据集,因为我们继续分析纽约市的短期和长期 Airbnb 租赁列表。这个数据集直接来自 Inside Airbnb Network 倡议(insideairbnb.com/),“一个以使命为导向的项目,提供关于 Airbnb 对住宅社区影响的数据和倡导。”我们还将使用来自其他在线服务的公共数据,例如 Foursquare(foursquare.com),社交网络和地理位置技术公司。

在数据收集阶段之后,我们将根据相关的业务假设进行组织和执行综合的特征工程,以从我们的建模阶段提取有价值的见解。在这个过程中,我们还将对我们的预测变量和目标变量进行基本的探索性分析,对示例和特征进行必要的调整或排除,以确保我们获得项目最优的数据。

7.1.1 使用生成式 AI 来帮助准备数据

ChatGPT 是由 OpenAI 开发的高级语言模型。为了创建和训练一个像 ChatGPT 这样的生成预训练转换器(GPT)模型,OpenAI 使用了大量的多样化互联网文本,帮助模型通过预测一系列词语中的下一个词语来学习理解和生成类似人类的文本,这是基于其对上下文的理解。这种预训练使得 ChatGPT 能够捕捉语法、上下文,甚至细微的信息,但这并不足以使其在所有情况下都成为一个有用的助手。实际上,这些模型有可能产生不准确或有害的输出,或者包含有毒内容。这是因为训练数据集——即互联网——包含的文本是多样化的,有时是不可靠的。为了提高 ChatGPT 模型的安全性、实用性和一致性,采用了名为基于人类反馈的强化学习的方法。在基于人类反馈的强化学习过程中,人类标注者提供反馈,说明首选的模型行为,并通过排名评估模型产生的多个输出。随后,这些数据被用来进一步微调 GPT-3.5,根据人类反馈来优化其响应。

要使用 ChatGPT 的免费版本(目前是 ChatGPT 3.5,信息更新至 2022 年 1 月),您必须首先在chat.openai.com创建一个账户。一旦您有了账户,您只需输入一个提示就可以开始使用 ChatGPT。在一个像 ChatGPT 这样的 LLM(大型语言模型)的上下文中,提示是一个提供给模型的书面指令或输入,以生成特定的输出。它作为一个查询或请求,引导模型产生相关的响应。提示的复杂度各不相同,从简单的命令到更详细的描述或询问,它们在塑造语言模型输出性质方面发挥着关键作用。提示的质量和清晰度在很大程度上影响着生成内容的准确性和相关性,但选择正确的提示并不总是那么简单。

不同提示语的有效性可能因它们针对的具体语言模型而异。每种语言模型都有其自身的优势、劣势和细微差别,因此根据需要调整提示语至关重要。例如,在与 ChatGPT 合作时,我们从简单的提示语开始,评估其结果,然后通过添加更多具体说明到提示语中,以细化结果,使其更符合我们的预期。ChatGPT 在被告知“写”、“创建”、“展示如何”或“总结”时往往表现更好。有时展示示例以及你期望 ChatGPT 如何展开这些示例是非常有帮助的。此外,让 ChatGPT 了解你对答案的期望,例如响应的长度、应包含的信息、是否只希望得到代码或文本结果,以及答案应该如何结构化返回;例如,你可以使用 JSON 格式或 Python 风格列表来提问。

如 ChatGPT 等大型语言模型,以及 GitHub 中的相关 Copilot 功能,已被证明是各种编程任务的实用助手。这种实用性适用于表格数据应用。你可以向这些模型提出各种问题或请求在编码任务中的帮助,他们可以通过提供代码片段、解释代码的工作原理或指导使用特定命令或算法来协助你。然而,尽管如 ChatGPT 等大型语言模型可以通过生成代码片段来协助用户进行数据处理、清洗和转换任务,并提供适用于表格数据集的各种统计和机器学习技术的解释和指导,但本章的目的是展示一些精选且不太明显的语言模型能力,这些能力可用于你的表格数据分析建模。

7.1.2 获取和准备你的数据

作为起点,我们将导航到 Inside Airbnb Network 网站(insideairbnb.com/),并找到所需的数据。我们的目标是探索一个完全不同的城市:东京。首先,你必须手动下载数据并将其存储在你的计算机或云实例的工作目录中。为此,在 Inside Airbnb Network 倡议的首页上,如图 7.1 所示,在数据菜单中选择“获取数据”。

图片

图 7.1 从数据菜单中选择

一旦选择了菜单项,你将被带到一个新的页面,包含数据下载部分,展示了各种城市及其要下载的数据文件。滚动页面,直到找到东京市。

图 7.2 显示了在撰写本书时包含东京数据文件的页面部分。

图片

图 7.2 东京数据下载部分

对于我们的分析,我们需要从页面获取两个文件:listings.csv,其中包含东京 Airbnb 住宿的摘要列表和其他信息,以及calendar.csv.gz,这是一个包含calendar.csv的压缩文件,其中包含每个列表给定年份的占用和价格信息。将鼠标悬停在链接上,右键单击,并选择将它们保存到工作目录中。例如,在 Google Chrome 中,您需要选择“另存为”,而在 Mozilla Firefox 中,您必须选择“另存目标为”。在此阶段,您只需将文件提取到工作目录中。一旦我们需要的文件在我们的本地目录中解压缩,我们可以使用read_csv命令将它们导入到 pandas DataFrame 中:

import pandas as pd
summary_listings = pd.read_csv("listings.csv")

通过列的列表和类型,我们可以了解我们将要处理的数据类型:

summary_listings.dtypes

该列表包含 18 列,与第三章中介绍的 Airbnb 纽约市数据集大致相同。这里我们描述每个字段:

  • id—Airbnb 上每个列表的唯一标识符。它是一个int64数据类型,意味着它是一个数值 ID 表示。在其他表中,它可以被称为listing_id

  • name—Airbnb 列表的描述。它属于object数据类型,通常表示字符串或文本。

  • host_id—Airbnb 上每个房东的唯一标识符。它是一个int64数据类型。

  • host_name—拥有列表的房东的姓名。它属于object数据类型。

  • neighbourhood_group—表示该社区所属的更广泛的区域或地区。它存储为float64数据类型,但需要注意的是,使用浮点数据类型来表示组或类别是不常见的。在这种情况下,浮点值的存在表明该字段的全部数据都是缺失值。

  • neighbourhood—列表所在的特定社区。它属于object数据类型。

  • latitude—列表位置的纬度坐标。它属于float64数据类型。

  • longitude—列表位置的经度坐标。它属于float64数据类型。

  • room_type—列表中提供的房间或住宿类型(例如,整个房子/公寓,私人房间,共享房间)。它属于object数据类型。

  • price—每晚出租该列表的价格。它属于int64数据类型,表示一个整数值的价格。

  • minimum_nights—预订列表所需的最少夜晚数。它属于int64数据类型。

  • number_of_reviews—列表收到的总评论数。它属于int64数据类型。

  • last_review—列表收到的最后一条评论的日期。它属于object数据类型,可能代表日期和时间信息,但可能需要进一步解析才能有效使用。

  • reviews_per_month—列表每月的平均评论数。它属于float64数据类型。

  • calculated_host_listings_count—房东在 Airbnb 上的列表总数。它是int64数据类型。

  • availability_365—一年中可供预订的天数(365 天中的天数)。它是int64数据类型。

  • number_of_reviews_ltm—过去 12 个月内收到的评论数量。它是int64数据类型。

  • license—列表的许可证号码或相关信息。它是object数据类型,通常表示字符串或文本。

我们可以安全地忽略诸如host_idhost_nameneighbourhood_group(因为它们完全缺失)或license(基于房东许可证的一种标识符)之类的特征。

至于其他特征,虽然大多数都是数值型的,但name特征是一个字符串,包含根据数据组织方式提取的各种信息。通过可视化其中的一个示例,我们可以了解其组织结构:

summary_listings['name'].iloc[0]

字符串被安排成五个不同的部分,由传统的符号分隔,并包含某种部分结构化和重复的内容:

'Rental unit in Sumida · ★4.78 · 1 bedroom · 2 beds · 1 bath'

字符串的前一部分是单元类型和位置的描述。第二部分是客人给出的平均评分。第三部分是卧室数量,第四部分是床的数量,最后一部分是浴室数量。

除了数值之外,我们还可以提取一些与提供的住宿或服务类型相关的特定信息——例如,如果公寓是工作室,如果浴室是共享的,以及如果它是半浴室(一个有厕所和洗脸盆但没有浴缸或淋浴的房间)。我们可以通过创建简单的字符串对应检查来处理此类信息,并获取一个二进制特征,指出特征的存在或不存在,或者使用正则表达式命令。正则表达式(缩写为 regex)命令是一系列字符,构成搜索模式。它们用于字符串内的模式匹配。表 7.1 显示了我们对描述字段应用的转换,并突出显示了我们试图匹配的字符串,使用的正则表达式命令以及获得的结果特征。

表 7.1 特征工程的正则表达式命令

匹配描述 正则表达式 结果特征
以“in”开头,后跟一个空格,然后捕获任何字符直到另一个空格,并以点结束的文本 r'in\s(.*?)\s·' 列表的东京区域
以字符“★”(星号)开头,后跟一个或多个数字,一个点,以及一个或多个额外的数字(例如,★4.5)的文本 r'(\d+\.\d+)' 星级评分
包含数值值后跟零个或多个空白字符和单词“bedroom”或“bedrooms”(带或不带末尾的“s”)的文本 r'(\d+)\s*(?:bedroom&#124;bedrooms)' 卧室数量
包含一个数值后跟一个或多个空格字符和单词“bed”或“beds”(带或不带末尾的“s”)的文本 r'(\d+)\s+(?:beds?\b)' 床的数量
包含表示浴次数的数值文本 r'(?P<baths>\d+)\s*(shared\s+)?(?:half-)?baths?\b' 浴次数

使用正则表达式命令有点复杂。因此,这是生成式 AI 可以提供帮助的第一个应用。大多数 LLMs,如 ChatGPT,对不同的编程语言有很好的了解(特别是,它们中的大多数在 Python 方面都很强),因为它们是在从互联网上提取的文本和信息上训练的,那里有大量关于如何编码甚至非常具体问题的信息。在我们的情况下,展示提示中的字符串示例并请求提取所需的信息应该可以解决问题:

I have a series of strings in this format: 'Rental unit in Sumida · ★4.78
 · 1 bedroom · 2 beds · 1 bath' Show me a series of regex commands in 
order to extract the following information: 1) the area of Tokyo of the 
listing 2) the star ratings expressed as floats 3) the number of bedrooms 
4) the number of beds 5) the number of baths.

输出应该是已经适合使用的,以代码片段块的形式排列,并附带一些关于提取规则的说明,如图 7.3 所示。

图 7.3 ChatGPT 3.5 上正则表达式处理的提示结果

在没有被询问的情况下,语言模型应该简单地决定提出一个基于 Python 的解决方案,然后你只需点击代码列表顶部的“复制代码”图标,将代码片段复制到剪贴板,然后将其粘贴到你的笔记本或 IDE 编辑器中。

通常,提供的解决方案可能因查询而异,与我们表格中提供的解决方案不同。这是因为 LLMs 最终是概率机器。温度是通常设置以影响模型输出随机性的参数。在文本生成过程中,它用于控制生成内容的创造力。简单来说,温度影响模型选择序列中下一个词的可能性。低温值导致更确定性和预期的输出。相反,高温值在生成的输出中引入更多的随机性和创造力,因为模型倾向于选择不太可能的词。

在使用 LLM 提出的解决方案之前,一个重要的步骤是在比提示中显示的一个或两个示例更多的示例上测试它们。这一步骤可能会揭示代码运行不正常,你也可能需要告诉模型示例不起作用,或者通过更详细的指令向模型发出你遇到的问题的信号。例如,我们发现有时如果输入字符串的某些部分有大小写字母,命令可能无法正常工作。因此,我们必须找到补充解决方案。所有这些正则表达式命令都操作小写,多亏了re.IGNORECASE标志,这使得匹配操作不区分大小写。在下面的列表中,我们继续使用我们在 ChatGPT 上找到的正则表达式命令从文本描述中提取信息。

列表 7.1 从文本描述中提取信息

import pandas as pd
import re

classification_list = [
   'aparthotel', 'barn', 'bed and breakfast', 'boutique hotel',
   'bungalow', 'cabin', 'camper/rv', 'chalet', 'condo', 'cottage',
   'earthen home', 'farm stay', 'guest suite', 'guesthouse', 'home',
   'hostel', 'hotel', 'houseboat', 'hut', 'loft', 'place to stay',
   'rental unit', 'resort', 'ryokan', 'serviced apartment',
   'tiny home', 'townhouse', 'treehouse', 'vacation home', 'villa']

summary_listings = summary_listings.assign(
    type_of_accommodation=(
        summary_listings['name']
        .str.extract(
            f"({'|'.join(classification_list)})", 
            flags=re.IGNORECASE)),                          ①
    area_of_tokyo=(
        summary_listings['name']
        .str.extract(
            r'in\s(.*?)\s·', 
            flags=re.IGNORECASE)),                          ②
    score=(
        summary_listings['name']
        .str.extract(
            r'★(\d+\.\d+)',
            flags=re.IGNORECASE)
        .astype(float)),                                    ③
    number_of_bedrooms=(
         summary_listings['name']
         .str.extract(
            r'(\d+)\s*(?:bedroom|bedrooms)', 
            flags=re.IGNORECASE)
         .fillna(0)
         .astype(int)),                                     ④
    number_of_beds=(
         summary_listings['name']
         .str.extract(
             r'(\d+)\s+(?:beds?\b)', 
             flags=re.IGNORECASE)
         .fillna(0)
         .astype(int)),                                     ⑤
    number_of_baths=(
         summary_listings['name']
         .str.extract(
             r'(?P<baths>\d+)\s*(shared\s+)?' +
             r'(?:half-)?baths?\b', 
             flags=re.IGNORECASE)["baths"]
         .fillna(0)
         .astype(int)),                                     ⑥
)

① 从选项列表中提取住宿类型

② 从列表名称中提取提到的东京地区

③ 从带有数值的星号符号中提取评分

④ 从列表名称中提取卧室数量

⑤ 从列表名称中提取床的数量

⑥ 从列表名称中提取浴室数量

列表 7.2 通过在名称列中基于特定关键词创建额外的布尔列来完成特征提取工作。它根据告诉我们今天日期和 last_review 日期之间天数差异的差异以及表示过去一年中评论数量与总评论数量之间关系的比率来计算两个计算特征。这样的比率可以揭示大部分评论是否是最近的,或者列表是否主要在过去取得成功。

列表 7.2 提取二进制标志和时间信息

import numpy as np
import pandas as pd
from datetime import datetime

summary_listings = summary_listings.assign(
    is_new=(summary_listings['name']
               .str.contains('new', case=False)
               .astype(int)),                              ①
    is_studio=(summary_listings['name']
               .str.contains('studio', case=False)
               .astype(int)),                              ②
    has_shared_bath=(summary_listings['name']
                     .str.contains('shared', case=False)
                     .astype(int)),                        ③
    has_half_bath=(summary_listings['name']
                   .str.contains('half', case=False)
                   .astype(int)),                          ④
)

summary_listings['days_since_last_review'] = (
    datetime.today() – 
    pd.to_datetime(
        summary_listings['last_review'])
).dt.days
summary_listings['days_since_last_review'] = (
    summary_listings['days_since_last_review'] -
    summary_listings['days_since_last_review'].min()
)                                                          ⑤

zero_reviews = summary_listings['number_of_reviews'] == 0
ratio = summary_listings['number_of_reviews_ltm'] / 
summary_listings['number_of_reviews']
summary_listings['number_of_reviews_ltm_ratio'] = (
    np.where(zero_reviews, 0, ratio)
)                                                          ⑥

① 检查名称中是否包含单词“new”(不区分大小写)

② 检查名称中是否包含单词“studio”(不区分大小写)

③ 检查名称中是否包含单词“shared”(不区分大小写)

④ 检查名称中是否包含单词“half”(不区分大小写)

⑤ 计算今天日期和 last_review 日期之间的天数

⑥ 计算每条列表的 number_of_reviews_ltm 与 number_of_reviews 之间的比率

summary_listings 数据也具有一个价格特征,我们可以将其用作目标。然而,我们更倾向于通过聚合 calendar.csv 数据来创建它,以决定是选择所有价格的平均值、最小值还是最大值。calendar.csv 包含有关每天住宿可用性、其价格(也考虑了折扣)以及当时允许预订的最小和最大夜数的详细信息。我们感兴趣的是将调整后的价格作为目标,代表住宿的有效市场价格。

列表 7.3 从每日列表创建目标

calendar = pd.read_csv("calendar.csv")

calendar["adjusted_price"] = (
    calendar["adjusted_price"]
    .apply(lambda x: float(
        x.replace('$', '')
         .replace(',', ''))
         )
)                                                         ①

price_stats = (
    calendar.groupby('listing_id')['adjusted_price']      ②
    .agg(['mean', 'min', 'max'])
)                                                         ③

① 从值中删除美元符号($)和逗号(,),然后将其转换为浮点数

② 基于 listing_id 列在日历 DataFrame 上执行分组操作

③ 计算调整后价格列的三个统计数据:平均值、最小值和最大值

完成聚合后,我们可以通过要求查看新创建数据集的前五行来检查结果:

price_stats.head()

图 7.4 验证我们现在既有每条列表的平均价格,也有最大和最小价格。

图 7.4 Airbnb 列表的定价统计

我们将保存这个 price_stats DataFrame,并在下一节集中讨论提高特征的数量和有效性。

7.1.3 工程更复杂的功能

房地产资产有一种相当奇特的行为,与其他市场上找到的产品或服务不同。房地产业的一个谚语提到,在处理建筑和设施时,最重要的是“位置,位置,位置。”一个公寓在城市或道路上的位置可能会影响房产的价值。我们将采用这个谚语来处理 Airbnb 列表,并基于位置开发一些特征工程。

作为第一步,我们将重述前一章中的例子,其中我们创建了小的地理子区域,后来对这些区域进行了目标编码。通过这种方法,你应该能够捕捉到特定地区的特定特征,尽管解释为什么在某个特定地点租房比其他地方贵将会有困难。我们应该准备更具体的特征,为东京的列表提供一些可解释性。这里又是生成式 AI 可以帮助表格数据从业者的地方,通过提供建议和创意生成帮助。大型语言模型已经处理了比你想象的更多的数据,如果你提供足够的细节(以及一些角色扮演,即要求它们扮演一个特定领域的专家),它们可以提供可能需要你花费数小时在网络上研究和阅读的提示和反思。

我们为 ChatGPT 的提示是

|

你是一位数据科学专家,你已经下载了一个包含总结的 Airbnb 列表数据的集。这是数据集的结构:id(int64),名称(对象),社区(对象),纬度(float64),经度(float64),房间类型(对象),价格(int64),最低入住夜数(int64),评论数(int64),最后评论(对象),每月评论数(float64),计算出的房东列表数量(int64),365 天可用性(int64),过去 12 个月的评论数(int64)。你正在训练一个机器学习模型来预测列表的价格。你应该设计哪些特征来提高模型的性能?

我们的策略是设定一个角色(“你是一位数据科学家专家”)并提供一些有关可用特征(移除我们实际上已经决定不使用的特征)和目标变量的更多信息。在这里,我们也使用了这样一个事实,即我们预计 ChatGPT 已经对我们使用的数据集有所了解(“包含总结的 Airbnb 列表的数据集”),但你也可以通过简要描述数据集(包括特征类型及其相互关系)向 LLM 提出不太为人所知的问题。此外,如果您对数据集有任何相关的领域专业知识,例如关于数据集中特征可能与目标变量相关联的任何假设,请与 LLM 分享。这些信息可以帮助它识别对任务可能重要的特征。此外,提供数据集中现有特征的列表,就像我们这样做,可能对于更具挑战性的任务还不够。提供每个变量的解释或描述,并清楚地说明您的机器学习模型的目标,例如预测客户流失、估计房地产价值或预测销售额,将帮助 LLM 识别创建新精心设计的特征的差距或机会。

以下是我们根据 ChatGPT 的说明,为了在我们的数据集和问题类型中获得最佳结果而应努力生成的建议功能或功能类型:

  • 距离关键地点—计算每个列表与城市中关键地点或地标之间的距离。这可以通过使用经纬度信息来完成。

  • 社区统计数据—对每个社区进行汇总统计,例如平均价格、最低和最高价格以及每个社区中的列表数量。

  • 房间类型编码—使用独热编码或标签编码将分类变量room_type转换为数值。

  • 时间特征—从last_review列中提取相关特征,例如最后评论的月份或季节或自最后评论以来经过的时间。

  • 房东经验—结合calculated_host_listings_countnumber_of_reviews_ltm的信息,创建一个表示房东经验或受欢迎程度的特征。

  • 预订密度—计算number_of_reviewsavailability_365的比率,以表示列表相对于其可用性的预订频率。

  • 评论情感—如果您可以访问实际的评论文本,您可以使用自然语言处理技术来分析情感并提取基于情感的特征。

  • 交互项—创建相关特征之间的交互项,例如minimum_nightsavailability_365的乘积,以捕捉较长住宿的整体可用性。

  • 价格分箱—将连续的价格变量转换为箱或类别,创建一个表示不同价格范围的新分类特征。

  • 地理聚类——使用聚类算法根据地理位置将列表分组。这可以捕捉到价格的空间模式。

在提出的要点中,有一些与我们的经验相呼应,例如从关键位置计算距离和邻里统计数据,或创建地理聚类。此外,我们已经在之前的关于文本字段和正则表达式命令的工作中提供了房间类型编码。除了评论情感和价格分箱(价格实际上是我们的目标)之外,剩下的是一套可以指导或启发你改进工作的想法。利用 LLM 的输出,但不要忘记,你以前的知识、经验、敏感性和批判性思维在表格数据分析的任务中可以发挥更重要的作用,而 LLM 只是你工作中的辅助工具。

考虑到 ChatGPT 提出的建议,我们继续创建其中的一些。关于地理聚类,你可以在列表 7.4 中找到所有你需要的内容,从坐标创建高基数地理特征。稍后,在数据管道中,我们将使用代码对即将生成的具有高基数分类特征进行目标编码。

列表 7.4 创建高基数地理特征

def bin_2_cat(feature, bins=32):
    min_value = feature.min()
    bin_size = (feature.max() - min_value) / bins
    return ((feature - min_value) / bin_size).astype(int) ①

summary_listings['coordinates'] = (
    bin_2_cat(summary_listings['latitude']) * 
    1000 +   
    bin_2_cat(summary_listings['longitude'])
)                                                         ②

print(summary_listings['coordinates'].nunique())

① 通过网格大小对纬度和经度进行离散化

② 通过对离散化的纬度和经度求和来组成新的坐标特征

此代码生成一个具有 317 个唯一值的特征。我们在一个 32 × 32 的网格中覆盖了所有东京自治市,这意味着可能有 1,024 个值。只有 317 个这些坐标包含一个列表,这意味着如果未来有一个新的列表落在之前定义的 317 个槽位之一,我们的模型可以仅基于这个特征有效地进行预测。如果出现新的区域,多亏了目标编码器的创新处理能力(请参阅mng.bz/oK1phandle_unknown参数的描述),我们可以简单地使用设置handle_unknown="value"将未知值插补到目标均值。

东京房地产市场的关键方面是,由于文化和历史原因,存在一个重要的地理中心,即皇宫。靠近这个地点的位置往往有更高的房地产估值,日本一些最昂贵的公寓就位于皇宫附近。我们试图通过创建一个将我们的 Airbnb 住宿位置与皇宫区域(可以从 latlong.net 等网站获取:mng.bz/nRW2)进行比较的特征来反映这一现实。对于距离,我们使用涉及辐射角的余弦乘以一个转换系数的公式将值转换为米,以便人类检查时可以理解。我们还采用曼哈顿距离来更好地表示城市中的距离,这是纬度和经度的绝对值之差的和。

列表 7.5 从市中心计算距离度量

imperial_palace_lat = 35.6841822633
imperial_palace_lon = 139.751471994

def degrees_to_meters(distance_degrees, latitude):
    conversion_factor = 111000                              ①
    distance_meters = (distance_degrees * conversion_factor 
                       * np.cos(np.radians(latitude)))      ②
    return distance_meters 

distance_degrees = (
    np.abs(
        summary_listings['latitude'] 
        imperial_palace_lat) + 
    np.abs(
        summary_listings['longitude'] 
        imperial_palace_lon)
)                                                           ③

summary_listings['imperial_palace_distance'] = (
    degrees_to_meters(distance_degrees,
    summary_listings['latitude']
)

① 表示每度纬度大约有多少米的转换系数

② 通过将基于度的距离乘以转换系数并调整纬度的余弦值来计算距离(以米为单位)

③ 通过从数据集中的值中减去故宫的纬度和经度来计算绝对距离(以度为单位)

当处理城市内部的坐标时,选择欧几里得距离和曼哈顿距离作为机器学习特征取决于具体的上下文和你要解决的问题:

  • 欧几里得距离基于欧几里得空间中两点之间的直线距离。它假设两点之间存在直接路径,在考虑物理距离时可能更合适。你也可能听到它被称为 L2 范数,这是一个数学概念,指的是向量与向量空间原点之间的距离。由于 L2 范数基于欧几里得距离公式,因此它可以互换使用,因为它是一个紧密相关的数学概念。

  • 曼哈顿距离,也称为街区距离或出租车距离,通过计算两点坐标的绝对差值之和来衡量两点之间的距离。它只考虑水平和垂直移动,忽略对角线路径。与欧几里得距离类似,当操作向量和向量空间时,你可能会听到曼哈顿距离被称为 L1 范数

当考虑城市中的实际移动或导航时,曼哈顿距离可能更合适,因为旅行通常沿着街道和道路网络进行。考虑到坐标位于城市内部,道路网络结构和街道导航很重要,曼哈顿距离可能更适合捕捉地点之间的移动和可达性。它与遵循道路和进行直角转弯的概念相符。

在计算完 imperial_palace_distance 特征之后,我们可以使用以下代码来检查其平均值,以米为单位:

summary_listings.imperial_palace_distance.mean()

结果显示,到故宫的平均距离大约为 7.9 公里。

接下来,我们可以识别出距离故宫最近的列表。为了实现这一点,我们可以使用 idxmin() 函数找到距离最小的列表的索引,然后访问其相应的详细信息:

(summary_listings[
    ['id', 'name', 'neighbourhood', 'imperial_palace_distance']
].iloc[np.argmin(summary_listings['imperial_palace_distance'])])

结果如下,这有点令人惊讶:

id                                                       874407512426725982
name                        Home in Shibuya City · ★New · 3 bedrooms · ...
neighbourhood                                                    Chiyoda Ku
imperial_palace_distance                                         137.394271
Name: 10255, dtype: object

事实上,该列表并未位于皇宫附近,这强调了列表地理位置可能存在的误导性错误。在精心整理的数据集中遇到类似问题并不罕见。正如第二章所讨论的,从一般的角度来看,在进行一些数据质量检查后,你将找到一些可行的选项,以下按从要求高到要求低列出:

  • 地理编码(从地址到坐标)和反向地理编码(从坐标到地址)——为了确定位置信息是否与提供的经纬度坐标匹配,并决定是否信任提供的地址或坐标

  • 数据插补——将可疑值作为缺失值处理,使用默认位置的坐标

  • 逐行删除——删除所有包含可疑值的行

  • 交给 XGBoost 处理——基于树的算法通常受错误和可疑值的影响较小,对数据中的异常值和噪声具有鲁棒性

在我们的例子中,我们决定将情况交给 XGBoost 处理,因为我们的模型并不那么关键,不需要彻底的数据质量检查。对于你自己的项目来说,情况可能不同,你可能需要评估一个需要更多数据清理工作的解决方案。

从地标和服务点的距离在房地产建模中作为特征效果良好。因此,我们不仅计算从东京中心——皇宫的距离,在 Dingqi Yang、Daqing Zhang、Vincent W. Zheng 和 Zhiyong Yu 的论文“通过利用 LBSN 中的用户时空特征建模用户活动偏好”(IEEE Transactions on Systems, Man, and Cybernetics: Systems,45(1),129-142,2015)中,作者们收集了来自纽约市和东京的 Foursquare 签到数据集,包括它们的地理坐标和所引用的位置类型;我们可以从 Kaggle 数据集(mng.bz/4aDj)中获取数据。

Foursquare 是一个基于地理位置的社交网络。得益于其移动应用,它允许用户发现附近的场所,如餐厅和商店、交通方式,并分享他们访问地点的信息。该应用的特点之一是签到,当在场所使用该平台时发生。签到指的是用户在特定地点的存在。当用户签到时,他们将与他们的 Foursquare 朋友分享他们的定位,他们还可能有在 Facebook 和 X 等社交媒体平台上发布他们访问信息的选项。为了从便利性的角度映射列表的价值,我们拥有机场、公交、火车和地铁站等常见的签到场所。与便利商店一起,这是一种主要向客户销售广泛日常物品和产品的零售店,这些场所的邻近性可以为住宿增加价值。

因此,为了丰富我们的数据集,我们首先直接从 Kaggle 提取了这些东京地点的 GPS 坐标。代码和提取的数据集可在mng.bz/QDPv找到,您可以从mng.bz/XxNa页面下载处理后的文件到您的工作目录,在那里您可以获取文件relevant_spots_Tokyo.csv。该文件包含关于 3,560 家便利店位置、1,878 个公交站和停靠点、439 个地铁站以及 264 个与机场相关的地点的信息。然后,使用列表 7.6,我们可以将我们的 Airbnb 东京列表的位置与每个这些场所进行比较,并报告每个场所的最近距离。我们的想法是,列表与便利店和交通方式的距离越近,预期的价格就越高。

在列表 7.6 中,我们没有将每个住宿与我们所收集的所有可能的场所进行比较,因为这会花费太多时间和计算。相反,我们利用 Scikit-learn 中的 k 维树(KDTree)数据结构,这是一个优化算法,旨在高效地找到给定位置附近的最接近点。Scikit-learn 用于 K 最近邻等算法,在这些情况下,您必须找到训练集中与测试样本最相似的示例。在我们的案例中,训练集是场所集合,该算法被训练基于曼哈顿距离找到给定位置最近的场所。

列表 7.6 寻找最近的设施和交通

from sklearn.neighbors import KDTree

relevant_spots = pd.read_csv("relevant_spots_Tokyo.csv")

venue_categories = ['Convenience Store', 'Train Station', 
                    'Airport', 'Bus Station', 'Subway']
min_distances = {'listing_id': summary_listings['id']}      ①

for venue in venue_categories:
    venue_filter = relevant_spots['venueCategory'] == venue
    venues = relevant_spots[
        ['latitude', 'longitude']
    ][venue_filter]                                         ②
    tree = KDTree(venues, metric='manhattan')               ③
    distance, index = tree.query(
        summary_listings[['latitude', 'longitude']],
        k=1
     )                                                      ④
    min_distances[
        'nearest_' + 
        venue.lower().replace(" ", "_")
    ] = degrees_to_meters(
            np.ravel(distance), 
            summary_listings['latitude']
        )
min_distances = pd.DataFrame(min_distances)                 ⑤

① 在字典中存储最小距离

② 过滤相关场所位置

③ 使用曼哈顿距离创建 KDTree,以快速进行最近邻搜索

④ 查询 KDTree 以找到最近点及其与每个 Airbnb 列表的距离(k=1 返回最近的点)

⑤ 将每种类型场所的最小距离字典转换为 DataFrame。

通过遍历每种类型的场所,在选定场所的位置上进行训练,并使用 KDTree 找到每个住宿地点最近的地点,以及自动计算距离,代码将运行得相当快。最终,我们只需将距离(曼哈顿距离,以度为单位)通过之前看到的函数degrees_to_meters转换为米,然后将结果汇总到一个 pandas DataFrame 中。我们可以通过检查结果数据集的前五行来验证结果:

min_distances.head()

图 7.5 显示了结果,表示从创建的min_distances DataFrame 中的内容。

图片

图 7.5 min_distances DataFrame 的前五行

在每个数据集的列表中有了我们选择场所的最小距离后,我们现在继续将这些新特征组合成一个最终的预测变量数据集,并提取一个用于建模的目标序列或向量。

7.1.4 最终化你的数据

在创建了一些额外的特征之后,我们可以最终确定我们的预测特征及其目标。在列表 7.7 中,我们将summary_listing数据集与到我们选定地标(机场、地铁、火车、公交车站、便利店)的最小距离连接起来。然后,我们根据目标重新排列连接的数据:在price_stats_ordered数据集中计算的平均价格。

列表 7.7 组装数据

summary_listings_features = [
    'neighbourhood',
    'coordinates',
    'room_type',
    'minimum_nights', 'number_of_reviews', 'days_since_last_review',
    'reviews_per_month', 'calculated_host_listings_count',
    'availability_365', 'number_of_reviews_ltm', 
'number_of_reviews_ltm_ratio',
    'number_of_bedrooms', 'number_of_beds', 'number_of_baths',
    'type_of_accommodation', 'score', 'is_new',
    'is_studio', 'has_shared_bath', 'has_half_bath',
    'imperial_palace_distance'
]

summarized = summary_listings[['id'] + 
summary_listings_features].rename({'id': 'listing_id'}, axis=1)

X = summarized.merge(min_distances, on='listing_id').set_index('listing_id')

X = X.reindex(price_stats.index)                              ①
price_stats_ordered = price_stats.reindex(X.index)            ②
y = price_stats_ordered['mean'].copy()                        ③

① 将 X 重新索引以匹配 price_stats 的索引

② 重新索引 price_stats 以匹配 X 的索引,确保重新索引的价格统计数据与 X 中的列表相匹配

③ 将“平均”价格列作为目标变量

一旦我们完成了脚本,我们可以可视化我们的数据集:

X.head()

作为索引,我们有listing_id,在列中,有我们为问题准备的所有特征,如图 7.6 所示。

图片

图 7.6 预测变量数据集的前几行

在这个阶段,我们可以开始详细检查数据,并找出是否有需要修复的额外问题或可以发现的见解,这些见解可能在如何开发我们的 XGBoost 模型中发挥关键作用。

7.1.5 探索和修复你的数据

在将所有预测变量组装到一个单一数据集之后,下一步是探索它以检测可能影响机器学习算法性能的问题,如缺失数据或极端值。因此,我们的第一个行动是使用以下命令检查任何缺失数据:

X.isna().sum()

结果列表指出有三个特征存在一些缺失数据:

days_since_last_review            1252
reviews_per_month                 1252
score                             2381

正如我们在第二章和上一章中讨论的那样,在存在缺失数据的情况下,调查为什么存在缺失值以及这些缺失值是否可以被视为完全随机缺失、随机缺失或非随机缺失的情况至关重要。在这个特定案例中,缺失值根本不是随机分布的,而是取决于没有评论或评论不足以致计算分数的事实。实际上,通过检查有多少住宿没有评论,你会注意到图形如何与两个具有缺失值的特征的缺失案例数量相匹配:

(X.number_of_reviews==0).sum()

如预期的那样,结果是 1,252,与缺失值的数量相匹配。在这种情况下,最好避免使用 XGBoost 和其他 GBDT 实现处理缺失数据的特性,因为其行为将模拟平均情况。当住宿刚刚进入市场或很少被选择时,缺失评论是一个极端但合法的情况。在这里,你需要直接输入一个数字,这可能有助于任何机器学习算法确定没有评论,因此存在缺失值。一种常见的策略是使用现有分布边界上的值,通常如果我们表示计数,则通常是一个负数;如果我们表示正值,则通常是一个正数。快速检查可以确保我们是否有实施这种缺失值策略的先决条件:

X[["days_since_last_review", "reviews_per_month", "score"]].describe()

因此,我们确认了所有三个考虑的特征的最小值始终大于零。这意味着我们可以简单地使用-1 值来替换缺失值(我们不能使用零,因为days_since_last_review的最小值是零),这将作为线性模型(它位于现有分布的较低极端)和基于树的集成(它们将仅在该负数上分割)的解决方案:

X.fillna(-1, inplace=True)

作为下一步,我们将密切关注我们的数值特征中的极端值。正如第二章中讨论的那样,寻找异常值和极端值的一个直接方法是为每个数值特征绘制箱线图,这些特征排列在子图面板中,或者如果它们具有可比的尺度,则在一个单独的图形中。在我们的案例中,在下面的列表中,我们已经准备了一个箱线图面板,以检查极端值。

列表 7.8 为数值特征绘制箱线图

import matplotlib.pyplot as plt

numeric = ['minimum_nights', 'number_of_reviews', 
           'days_since_last_review', 'reviews_per_month',
           'calculated_host_listings_count', 
           'availability_365', 'score', 
           'number_of_reviews_ltm', 
           'number_of_reviews_ltm_ratio', 
           'number_of_bedrooms', 'number_of_beds', 
           'number_of_baths', 'imperial_palace_distance', 
           'nearest_convenience_store',
           'nearest_train_station', 'nearest_airport', 
           'nearest_bus_station', 'nearest_subway']

num_plots = len(numeric)
num_rows = (num_plots + 2) // 3                          ①
num_cols = min(num_plots, 3)                             ②

fig, axes = plt.subplots(
    num_rows,
    num_cols,
    figsize=(8, 12)
)                                                        ③
axes = axes.flatten()                                    ④

for i, feat in enumerate(numeric):
    X[[feat]].boxplot(ax=axes[i])

fig.tight_layout()
plt.show()

① 估计排列子图所需的行数

② 计算排列子图所需的列数

③ 创建包含子图的图形

④ 将轴数组展平为 1D 数组,以便可以迭代

图 7.7 展示了图表化的结果。通过检查图表外的值,即表示为空点的值,我们立即注意到几乎所有分布都有右侧的重尾,值明显大于平均值。对于基于距离的特征,由于东京都市区的扩展,这样的极端值可能听起来是合理的,但对于如minimum_nightnumber_of_reviews这样的特征,这样的极端值可能代表远离分布核心的异常值。我们可以使用 winsorizing 来解决这个问题,使用我们在第二章中提出的解决方案。这种数据转换技术将数据集中的极端值替换为不太极端的值,以减少异常值对统计分析建模的影响。

在列表 7.9 中,使用 Scipy 包中的winsorize函数,我们对minimum_nights特征的分布的 0.1%的上部分进行 winsorize 处理。所有高于 0.999 百分位的值将被改变为 0.999 百分位的值,从而消除任何极端值。

图片

图 7.7 展示了大多数数值特征的偏斜分布的箱线图面板

列表 7.9 Winsorizing 极端值

from scipy.stats.mstats import winsorize

lower_cut_percentile = 0.00                           ①
upper_cut_percentile = 0.001                          ②

X['minimum_nights'] = winsorize(X['minimum_nights'].values, 
                                limits=(lower_cut_percentile, 
upper_cut_percentile))

X[['minimum_nights']].boxplot()

① 表示 winsorization 过程中不会改变的值的下限百分位数

② 表示 winsorization 过程中不会改变的值的上限百分位数

图 7.8 显示最高值现在是 120,而不是之前的超过 1000。

图片

图 7.8 展示了 winsorized minimum_nights特征的箱线图

我们对number_of_reviews特征也进行了相同的处理:

X['number_of_reviews'] = winsorize(X['number_of_reviews'].values, 
                                limits=(lower_cut_percentile, 
upper_cut_percentile))

X[['number_of_reviews']].boxplot()

图 7.9 显示,现在该特征仍然有重右尾。然而,极端值已被压缩到 500 以下。

图片

图 7.9 展示了number_of_reviews特征的 winsorized 箱线图

在完成检查和修复预测数据集中的缺失值和极端值后,我们可以在下一小节中继续查看目标本身。

7.1.6 探索目标

在处理探索性数据分析(EDA)时,检查预测值和目标值,有时甚至同时检查预测值和目标值以及它们之间的关系至关重要。对于我们的例子,作为一个回归问题,我们首先简单地确定目标值的平均值和范围:

print(f»minimum: {y.min()}»)
print(f"average: {y.mean().round(2)}")
print(f"maximum: {y.max()}")

这些命令将打印目标变量 y 中的最小值、平均值和最大值:

minimum: 1450.0
average: 36573.1
maximum: 1306500.0

我们立即注意到最大值与平均值和最小值的尺度完全不同。在目标中存在极端值或偏斜分布问题时,估计百分位数可以帮助更好地理解。通过要求一系列百分位数,重点关注分布的极端部分,可以更好地理解极端值的存在:

perc = [1, 5, 10, 25, 50, 75, 90, 95, 99]
for p in perc:
    print(f"percentile {p:2}: {np.percentile(y, p).round(2)}")

下面的输出百分位数表明,分布右侧存在极端值,因为即使是第 99 百分位数也相当远离我们之前报告的最大值:

percentile  1: 3000.0
percentile  5: 5198.02
percentile 10: 7315.67
percentile 25: 11870.07
percentile 50: 19830.78
percentile 75: 37741.64
percentile 90: 83936.03
percentile 95: 84857.11
percentile 99: 304531.4

不仅最大值与第 99 百分位数相当遥远,而且第 95 百分位数和第 99 百分位数之间似乎也存在显著的差距。我们的决定是通过删除 10%的分布来关注分布的核心:下部分 5%,上部分 5%。我们通过布尔选择变量进行选择来管理这一点:

valid_samples = (y >= 5200) & (y <=84857)

在确定应用选择之前,我们绘制了结果分布。

列表 7.10 绘制目标分布图

import matplotlib.pyplot as plt
import seaborn as sns

valid_y = y[valid_samples]                                    ①
sns.kdeplot(valid_y, fill=True)

median = np.median(valid_y)                                   ②
plt.axvline(median, color='r', linestyle='--', linewidth=2, label='Median')

plt.xlabel('Values')
plt.ylabel('Density')
plt.title('Distribution Curve with Median')
plt.legend()
plt.show()

① 仅选择我们认为需要建模的目标分布的一部分

② 表示分布的中位数

之前的代码片段将输出一个密度图,揭示基于我们刚刚定义的选择变量的目标分布和中间值。图 7.10 显示了结果图。

图 7.10 目标变量的密度分布

图 7.10 中显示的结果分布明显向右偏斜,这种情况也称为正偏斜。特别是,你可以观察到数据在开始处的一个块状分布,以及随后向右延伸的较长递减尾端,尽管到结尾时,我们还可以注意到另一个小块状分布,可能是一个高端住宿的独立集群。然而,现在目标变量的范围和分布都很合适。因此,我们将使用之前定义的布尔选择变量来选择目标和数据:

X = X[valid_samples]
y = y[valid_samples]

在下一节中,我们将继续定义验证过程的两方面以及建模所需的数据管道。之后,我们将尝试使用经典的机器学习线性模型和一个初步的 XGBoost 模型作为基线模型,并在训练我们针对东京 Airbnb 数据集问题的最终模型之前对其进行优化。

7.2 构建和优化你的模型

在本节中,我们将使用我们准备好的数据来构建一个模型。在得到完整的最终模型之前,我们将解决与定义交叉验证策略、准备数据管道以及首先构建基线模型然后构建初步的 XGBoost 模型相关的各种挑战。

7.2.1 准备交叉验证策略

通常,K 折交叉验证策略在大多数情况下都工作得相当好,但在我们特定的情境中,我们处理的是房地产单元,其价值受到其位置的影响很大。在我们的案例中,分层 K 折交叉验证更为合适,可以控制位置的影响。尽管分层 K 折交叉验证与 K 折交叉验证类似,但在分层 K 折交叉验证中有一个关键的区别:数据集中我们选择的一个特征的类别分布在每个折叠中都被保留。这种分层抽样将允许折叠具有与完整数据集相似的领土混合。然而,在事先检查某些领土是否因为数量少而难以在折叠之间分割是很重要的。如果我们计算数据中表示的不同邻里,我们会得到一个包含许多位置的冗长列表,其中一些展示了相当数量的列表,而其他则只有少数几个:

X['neighbourhood'].value_counts()

显然,你不能接受考虑所有少于一定数量的示例的邻里,因为如果你打算将数据分割成折叠,你几乎不可能很好地代表它们。由于区域是空间分布的,仅仅将它们聚集到一个额外的类别中是不够的,因为你会混合非常不同的区域情况,这些区域相距很远。在列表 7.11 中,我们通过聚合少于 30 个示例的区域(如果我们使用五折验证分割,则意味着每个折叠大约有 6 个示例)及其最近的较大邻里来解决这个问题。为了实现这一点,我们再次使用 KDTree 数据结构。因此,我们可以将每个少于 30 间住宿的区域与其最近的超过 30 间住宿的区域相匹配。

列表 7.11 聚合附近的邻里区域

neighbourhoods = (
    summary_listings[
        ['neighbourhood', 'latitude', 'longitude']
    ]
    .groupby('neighbourhood')
    .agg({'latitude': 'mean', 
          'longitude': 'mean',
          'neighbourhood': 'count'})
)                                                              ①

less_than_30 = (
    neighbourhoods[neighbourhoods['neighbourhood'] < 30]
)
more_than_30 = (
    neighbourhoods[neighbourhoods['neighbourhood'] > 30]
)                                                              ②

kdtree = KDTree(
    more_than_30[['latitude', 'longitude']]
)                                                              ③
change_list = {}                                               ④

for i in range(len(less_than_30)):
    row = less_than_30.iloc[[i]]
    _, idx = kdtree.query(
        row[['latitude', 'longitude']]
    )                                                          ⑤
    change_list[row.index[0]] = more_than_30.index[idx[0, 0]]

X["neighbourhood_more_than_30"] = (
    X["neighbourhood"].replace(change_list)
)                                                              ⑥

① 计算每个邻里的平均纬度、平均经度和列表数量

② 根据列表数量将邻里分为两组

③ 使用计数大于 30 的邻里的平均纬度和经度值创建一个 KDTree

④ 初始化一个空字典以存储邻里的映射

⑤ 遍历计数少于 30 的每个邻里,并查询 KDTree 以找到计数大于 30 的最近邻里

⑥ 根据 change_list 中的映射替换原始邻里值

在运行代码后,你可以通过发出以下命令来检查映射是如何执行的,以及是否产生了具有少于 30 个列表的邻里区域聚合:

print(change_list)
print(X["neighbourhood_more_than_30"].value_counts())

在详细阐述了合适的区域细分之后,我们现在可以继续定义以下列表中的分层交叉验证策略。

列表 7.12 定义分层策略

from sklearn.model_selection import StratifiedKFold

cv = StratifiedKFold(5, shuffle=True, random_state=0)          ①
cv_splits = cv.split(
    X, y=X["neighbourhood_more_than_30"]
)                                                              ②

① 定义五折分层随机分割

② 在保持每个折叠中超过 30 个列表的邻里分布相同的同时生成交叉验证分割

生成的cv_splits是一个生成器,你可以使用以下命令来检查它:

print(cv_splits)

输出是对象的类型:

<generator object _BaseKFold.split at 0x78356223c660>

由于cv_splits是一个生成器,它只能使用一次,但你可以通过简单地重新执行列表 7.12 中的命令来重新实例化一个相同的生成器。在下一小节中,我们将处理数据管道,并确定对数据应用哪些转换。

7.2.2 准备您的管道

第二个准备步骤是定义一个管道,以最合适的方式转换我们的预测器,以便通常与所有经典机器学习算法一起运行,而不仅仅是梯度提升。理想情况下,最好根据每个模型如何处理不同特征来拥有多个管道。例如,在我们的管道中,我们将对几个分类特征进行顺序编码,尽管这种编码适合基于树的模型,但并不总是与线性模型正确工作。然而,尽管拥有独特的管道可以带来更好的性能,但它也可能成为一个维护噩梦。创建和管理多个管道所需的人力可能超过了为特定模型定制每个管道所获得的边际性能提升。因此,如果你有证据表明这样做值得,那么决定使用多个管道会更好。

让我们先对我们将要使用的不同类型的特征进行分类,分为分类特征、数值特征和二进制特征:

categorical = [
    'room_type',
    'neighbourhood_more_than_30', 
    'type_of_accommodation',
    'coordinates'
]
numeric = [
    'minimum_nights',
    'number_of_reviews', 
    'days_since_last_review',
    'reviews_per_month',
    'calculated_host_listings_count', 
    'availability_365',
    'score', 
    'number_of_reviews_ltm', 
    'number_of_reviews_ltm_ratio', 
    'number_of_bedrooms', 
    'number_of_beds',
    'number_of_baths', 
    'imperial_palace_distance', 
    'nearest_convenience_store',
    'nearest_train_station',
    'nearest_airport', 
    'nearest_bus_station',
    'nearest_subway'
]
binary = [
    'is_new',
    'is_studio',
    'has_shared_bath',
    'has_half_bath'
]

进一步检查分类特征是必要的,因为我们需要了解是否将它们视为高基数特征。在计算每个分类特征的唯一值数量后,我们可以更好地理解应该怎么做:

for feat in categorical:
    print(f"{feat} has {X[feat].nunique()} unique values")

从结果中,我们可以确定可能唯一可以考虑为高基数分类的特征是coordinates特征,它有近 300 个唯一值。至于neighbourhood_more_than_30type_of_accomodation,我们可以对它们应用顺序编码以进行基于树的建模,而对于线性模型,最好对这些特征应用独热编码(从而产生大约 50 个新的二进制特征)或目标编码:

room_type has 4 unique values
neighbourhood_more_than_30 has 24 unique values
type_of_accommodation has 29 unique values
coordinates has 296 unique values

由于我们的示例围绕 XGBoost,并展示它如何处理类似问题,我们决定仅对room_type进行独热编码,对neighbourhood_more_than_30type_of_accomodation进行顺序编码,对coordinates进行目标编码:

onehot_encoding = ['room_type']
ordinal_encoding = ['neighbourhood_more_than_30', 'type_of_accommodation']
target_encoding = ['coordinates']

我们选择使用基于树的模型 XGBoost,这也解释了为什么我们保留所有数值特征不变。使用线性模型、统计标准化,以在使用正则化或广义线性模型时获得更好的收敛性,以及特征转换,以更好地拟合非线性,通常是标准做法。

在列表 7.13 中,我们定义了所有必要的特征转换,并将它们集成到 Scikit-learn 的列转换器中,这将作为管道的一部分,该管道还将包含我们选择的机器学习模型。还应注意,我们在定义列转换器时采取步骤来处理测试时可能意外出现的未知类别和缺失值。我们单热编码的策略是忽略新的未知类别。对于有序编码,分配给参数unknown_value的值,默认为np.nan,将用于编码未知类别。这意味着 XGBoost 模型将使用最频繁的分割来处理此类情况。其他机器学习算法可能会在类似情况下中断,这是 XGBoost 的优势。至于目标编码器,未知类别用目标均值替换。不要忘记安装category_encoders包。如果您的系统上不可用,请使用pip install category_encoders命令。

列表 7.13 定义列转换

from sklearn.preprocessing import OneHotEncoder, OrdinalEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline

from category_encoders.target_encoder import TargetEncoder

onehot_encoder = OneHotEncoder(handle_unknown='ignore')          ①
ordinal_enconder = OrdinalEncoder(handle_unknown="use_encoded_value",
                                  unknown_value=np.nan)          ②
target_encoder = TargetEncoder(
    cols=target_encoding, 
    handle_unknown="value", 
    smoothing=0.5
)                                                                ③

column_transform = ColumnTransformer(
    [('onehot_encoding', onehot_encoder, onehot_encoding),
     ('ordinal_encoding', ordinal_enconder, ordinal_encoding),
     ('target_encoding', target_encoder, target_encoding),
     ('numeric', 'passthrough', numeric),
     ('binary', 'passthrough', binary)],                         ④
    remainder='drop',                                            ⑤
    verbose_feature_names_out=True,                              ⑥
    sparse_threshold=0.0)                                        ⑦

① 创建一个单热编码器对象,具有在编码过程中忽略未知类别的选项

② 创建一个具有处理未知类别和将未知值替换为 np.nan 的有序编码器对象

③ 创建一个目标编码器对象,通过使用平均目标值进行编码并应用平滑参数为 0.5 来处理未知值

④ 创建一个列转换器对象,将指定的编码器应用于相应的列

⑤ 删除转换器中未指定的剩余列

⑥ 保留转换列的详细特征名称

⑦ 确保转换后的数据保持为密集数组

在运行代码列表后,我们可以立即测试转换我们拥有的数据并检查转换后的列名:

Xt = column_transform.fit_transform(X, y)
column_transform.get_feature_names_out()

输出显示,现在特征前面有一个前缀,指出它们经历了什么转换。由单热编码创建的二进制特征也跟随着它们所代表的类别:

array(['onehot_encoding__room_type_Entire home/apt',
       'onehot_encoding__room_type_Hotel room',
       'onehot_encoding__room_type_Private room',
       'onehot_encoding__room_type_Shared room',
       'ordinal_encoding__neighbourhood_more_than_30',
       'ordinal_encoding__type_of_accommodation',
       'target_encoding__coordinates', 'numeric__minimum_nights',
       'numeric__number_of_reviews', 'numeric__days_since_last_review',
       'numeric__reviews_per_month',
       'numeric__calculated_host_listings_count',
       'numeric__availability_365', 'numeric__score',
       'numeric__number_of_reviews_ltm',
       'numeric__number_of_reviews_ltm_ratio',
       'numeric__number_of_bedrooms', 'numeric__number_of_beds',
       'numeric__number_of_baths', 'numeric__imperial_palace_distance',
       'numeric__nearest_convenience_store',
       'numeric__nearest_train_station', 'numeric__nearest_airport',
       'numeric__nearest_bus_station', 'numeric__nearest_subway',
       'binary__is_new', 'binary__is_studio', 'binary__has_shared_bath',
       'binary__has_half_bath'], dtype=object)

作为最后一步,我们将处理后的特征和目标值存储到一个单独的 CSV 文件中。我们将在第十二章再次使用这些数据,那时我们将测试一个深度学习解决方案,并将其性能与本章训练的 XGBoost 模型进行比较。

data = pd.DataFrame(
    Xt, 
    columns=column_transform.get_feature_names_out(),
    index=y.index
)
data = data.assign(target=y).reset_index()
data.to_csv("airbnb_tokyo.csv", index=False)

现在我们有了管道的数据处理部分,我们可以继续定义基线模型,然后最终定义 XGBoost 回归器。

7.2.3 构建基线模型

在机器学习中拥有基线模型有几个重要原因:

  • 比较性能——基线模型作为基准,用于比较更复杂模型的性能,帮助你理解更多的复杂性是否真的增加了价值。

  • 检测过拟合——通过比较你的高级模型与基线在未见数据上的性能,你可以确定高级模型是否过拟合,因为基线模型的表现会好得多。

  • 理解问题——创建一个简单的基线模型,尤其是如果它是一个线性模型,迫使你更好地理解数据和问题。

  • 调试和验证——基线模型可以帮助你验证你的数据预处理流程是否正确,因为变量的影响不会被模型的复杂性所掩盖。

  • 提供最小可行模型——基线模型为当前问题提供了一个最小可行解决方案。

由于所有这些原因,我们不会立即跳入使用梯度提升模型训练我们的模型,因为我们期望它在问题上表现良好,但我们退一步,测试一个简单的线性模型。此外,在此期间,我们将尝试从一种我们可以轻松评估和比较的模型类型中获得预测。我们不仅通过交叉验证评估指标,还会采用交叉验证预测。这种方法通过在交叉验证的验证折叠中进行预测,为所有训练案例提供无偏预测。

在交叉验证过程中,评估指标会为每个折叠单独计算。这些指标代表模型在每个折叠上的性能。从交叉验证中报告的最终评估指标通常是单个折叠指标的均值(平均值或中位数)。这个聚合指标提供了模型在未见数据上的泛化性能估计。然而,如果我们使用交叉验证预测,我们则专注于模型在现有数据上的表现能力。实际上,交叉验证预测的主要用途是分析模型在不同数据部分上的预测。使用这些预测有助于我们了解模型在不同数据子集上的表现如何,并确定模型是否过拟合或欠拟合,因为我们可以将预测与预期的目标值进行比较。

列表 7.14 带诊断图的线性回归基线模型

import matplotlib.pyplot as plt
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import cross_val_predict
from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error

lm = LinearRegression(fit_intercept=False)                  ①
lm.fit(Xt, y)                                               ②

cv_splits = cv.split(
    X,
    y = X["neighbourhood_more_than_30"]
)                                                           ③
y_pred_cv = cross_val_predict(
    lm, Xt, y, cv=cv_splits
)                                                           ④
prediction_range = y_pred_cv.min()} - {y_pred_cv.max()
print(f"prediction range: {prediction_range}")              ⑤

r2 = r2_score(y, y_pred_cv)
rmse = np.sqrt(mean_squared_error(y, y_pred_cv))
mae = mean_absolute_error(y, y_pred_cv)                     ⑥

print(f'R-squared: {r2:.3f}')
print(f'RMSE: {rmse:.3f}')
print(f'MAE: {mae:.3f}')

plt.scatter(y, y_pred_cv)                                   ⑦
plt.plot([y.min(), y.max()], [y.min(), y.max()], 'r--', label='Ideal Fit')
plt.axhline(
   0, color='orange', linestyle='--', label='Zero Line'
)                                                           ⑧
plt.xlabel('Actual')
plt.ylabel('Predicted')
plt.title('Linear Regression - Fitted Results')
plt.legend()
plt.show()

① 初始化一个没有截距的线性回归模型

② 将线性回归模型拟合到转换后的训练数据

③ 根据超过 30 个计数的邻域创建分层交叉验证分割

④ 执行交叉验证预测

⑤ 打印交叉验证预测的范围

⑥ 计算 R 平方、均方根误差和平均绝对误差评估指标以评估模型性能

⑦ 创建实际值与预测值之间的散点图

⑧ 在图表上绘制一条虚线橙色零线作为理想拟合的参考

运行代码后,我们获得了评估结果,我们可以立即注意到一些预测是负数。由于线性回归模型的预测没有界限,平均绝对误差(MAE)相当高(超过 12,000 日元),而 R 平方,一个典型的拟合度度量,衡量模型捕获目标方差的比例,仅为 0.32:

prediction range: -34929.50241836217 - 136479.60736257263
R-squared: 0.320
RMSE: 17197.323
MAE: 12568.371

显然,模型的拟合并不特别令人印象深刻,我们可以在图 7.11 中得到证实,其中我们在 y 轴上表示交叉验证预测的散点图,与 x 轴上的预期目标值相对应。除了目标分布开始处的一些负预测外,我们还可以注意到预测如何偏离理想的拟合虚线,显示出平坦的趋势,这是欠拟合的明显迹象,以及如何有一些异常的预测。

图 7.11 基线线性回归拟合结果与其理想值的对比图

在检查结果的第一步中,我们要求预测值低于或等于零的百分比,这是一个不可行的预测,因为列表应该是正数:

(y_pred_cv <= 0).sum() / len(y_pred_cv)

结果是极低的百分比,大约 0.5%:

0.005178767055074196

理想情况下,我们的预测应该大于零,在一个线性模型中,这可以通过目标转换来实现——例如,对数转换。然而,基线模型的作用并不是成为一个完美的模型,而只是一个用来突出数据中的挑战,并为更复杂的模型提供有益比较的模型。

现在我们继续定位正异常值的行:

print(np.where(y_pred_cv > 100_000))

我们收到了两个案例:5509 和 8307:

(array([5509, 8307]),)

我们还调查了负异常值:

print(np.where(y_pred_cv < -25_000))

这里我们得到一个单一案例,182:

(array([182]),)

在列表 7.15 中,我们定义了一个函数,可以帮助我们检查异常值。对于每个预测特征,这个函数打印出系数以及该案例中系数与特征值的乘积,从而明确每个特征对预测的贡献。

列表 7.15 系数检查

def report_case(model, data, feature_names, case_no):
    case_values = data[case_no]                             ①
    coef_values = case_values * model.coef_                 ②
    for feature_name, value, coef_value in zip(
            feature_names, case_values, coef_values):
        print(f"{feature_name:50s}" +
              f"({value:10.2f}) : " +
              f"{coef_value:+0.2f}")                        ③
    print("-" * 80)
    print(" "*66 + f"{np.sum(coef_values):+0.2f}")          ④

① 从数据数组中提取指定案例编号的特征值

② 通过将每个特征的值与其模型中的相应系数相乘来计算每个特征的系数值

③ 遍历并打印特征名称、它们的值以及相应的系数值

④ 打印出计算出的系数值的总和

在我们的检查函数准备就绪后,我们可以开始检查案例 8307,它代表了一个预测中过于正大的异常值:

report_case(model=lm, 
            data=Xt, 
            feature_names=column_transform.get_feature_names_out(), 
            case_no=8307)

以下是对案例 8307 的结果,很明显,使预测成为异常值的额外贡献是由于卧室数量(暗示这可能是宿舍)。这个高值将最终预测列表推高:

onehot_encoding__room_type_Entire home/apt         (      1.00) : -8295.89
onehot_encoding__room_type_Hotel room              (      0.00) : -0.00
onehot_encoding__room_type_Private room            (      0.00) : -0.00
onehot_encoding__room_type_Shared room             (      0.00) : -0.00
ordinal_encoding__neighbourhood_more_than_30       (     12.00) : +576.48
ordinal_encoding__type_of_accommodation            (     20.00) : +2377.99
target_encoding__coordinates                       (  29649.71) : +26556.25
numeric__minimum_nights                            (      1.00) : -268.05
numeric__number_of_reviews                         (      0.00) : -0.00
numeric__days_since_last_review                    (     -1.00) : -0.66
numeric__reviews_per_month                         (     -1.00) : -172.50
numeric__calculated_host_listings_count            (     15.00) : +1470.92
numeric__availability_365                          (    354.00) : +16503.11
numeric__score                                     (     -1.00) : +524.08
numeric__number_of_reviews_ltm                     (      0.00) : -0.00
numeric__number_of_reviews_ltm_ratio               (      0.00) : +0.00
numeric__number_of_bedrooms                        (     18.00) : +64407.67
numeric__number_of_beds                            (     18.00) : +31283.70
numeric__number_of_baths                           (      2.00) : -1787.41
numeric__imperial_palace_distance                  (   2279.80) : -859.33
numeric__nearest_convenience_store                 (    149.84) : +549.07
numeric__nearest_train_station                     (    545.08) : -1043.20
numeric__nearest_airport                           (    389.85) : -137.44
numeric__nearest_bus_station                       (    322.04) : -266.55
numeric__nearest_subway                            (    221.93) : -17.29
binary__is_new                                     (      0.00) : -0.00
binary__is_studio                                  (      0.00) : +0.00
binary__has_shared_bath                            (      0.00) : -0.00
binary__has_half_bath                              (      0.00) : -0.00
----------------------------------------------------------------------------
                                                                  +131400.95

类似的问题是由于每个特征都是线性建模的。因此,特征的预测贡献是无界的,没有最大值或最小值,而是根据特征值增加或减少。通常,将非线性性和交互作用引入模型中的非线性性和交互作用可以缓解这些问题。现在让我们检查唯一的负异常值:

report_case(model=lm, 
            data=Xt, 
            feature_names=column_transform.get_feature_names_out(), 
            case_no=182)

这里问题表现为夜数的最小值,这又太高,拉低了估计值。事实上,一些列表作为季节性住宿,通常为工人或学生,而不仅仅是短期住宿。模型确实过于简单,无法捕捉到这样的细微差别,而且,引入非线性性和交互作用可能会有所帮助:

onehot_encoding__room_type_Entire home/apt         (      0.00) : -0.00
onehot_encoding__room_type_Hotel room              (      0.00) : -0.00
onehot_encoding__room_type_Private room            (      1.00) : -11573.69
onehot_encoding__room_type_Shared room             (      0.00) : -0.00
ordinal_encoding__neighbourhood_more_than_30       (      6.00) : +288.24
ordinal_encoding__type_of_accommodation            (     14.00) : +1664.59
target_encoding__coordinates                       (  27178.66) : +24343.02
numeric__minimum_nights                            (    120.00) : -32166.38
numeric__number_of_reviews                         (    122.00) : -1241.88
numeric__days_since_last_review                    (    132.00) : +87.20
numeric__reviews_per_month                         (      1.33) : +229.43
numeric__calculated_host_listings_count            (      4.00) : +392.25
numeric__availability_365                          (      0.00) : +0.00
numeric__score                                     (      4.98) : -2609.92
numeric__number_of_reviews_ltm                     (      4.00) : -18.33
numeric__number_of_reviews_ltm_ratio               (      0.03) : +54.17
numeric__number_of_bedrooms                        (      1.00) : +3578.20
numeric__number_of_beds                            (      0.00) : +0.00
numeric__number_of_baths                           (      0.00) : -0.00
numeric__imperial_palace_distance                  (  32506.70) : -12252.79
numeric__nearest_convenience_store                 (   5020.81) : +18397.51
numeric__nearest_train_station                     (   5689.32) : -10888.48
numeric__nearest_airport                           (  11438.81) : -4032.76
numeric__nearest_bus_station                       (   4999.17) : -4137.76
numeric__nearest_subway                            (  16976.69) : -1322.52
binary__is_new                                     (      0.00) : -0.00
binary__is_studio                                  (      0.00) : +0.00
binary__has_shared_bath                            (      0.00) : -0.00
binary__has_half_bath                              (      0.00) : -0.00
----------------------------------------------------------------------------
                                                                  -31209.90

总之,我们的基线模型已经向我们表明,成功解决东京 Airbnb 数据集提出的问题需要一个更好的拟合模式,该模式可以处理正预测(它们应该是必要的正数)并且可以表示住宿的特定特征(如大量卧室表示旅舍,高最低住宿夜数表示季节性租户)之间的非线性关系和交互作用。在下一小节中,我们将通过使用 XGBoost 模型一次性解决所有这些问题,该模型应该能够以更复杂和智能的方式处理这些数据。

7.2.4 构建第一个尝试性模型

首先,我们选择了一个 XGBoost 回归器,试图结合我们从之前的 EDA 和基线模型检查中获得的一些见解。我们决定使用 gamma 目标函数,这在回归问题中常用,用于建模正的连续变量,这些变量是正偏斜的。当目标变量始终为正,并且包含许多小值和一些较大值时,gamma 特别有用,因为它很好地处理了这种分布特征。

此外,由于我们的基线模型已经显示出欠拟合的迹象,并且没有正确处理交互作用或线性关系,我们决定对于组成增强集的决策树的最大深度不超过六,从而允许有足够数量的分割来处理大多数常见的数据特征。

在以下列表中,与之前的列表训练线性回归基线类似,我们训练了一个 XGBoost 回归器,并测试了其折叠交叉验证的预测结果。

列表 7.16 第一个 XGBoost 模型

from sklearn.model_selection import cross_validate
from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error
from XGBoost import XGBRegressor

xgb = XGBRegressor(booster='gbtree',                              ①
                   objective='reg:gamma',                         ②
                   n_estimators=300,
                   max_depth=6)

cv_splits = cv.split(
    X, y=X["neighbourhood_more_than_30"]
)                                                                 ③
y_pred_cv = cross_val_predict(
    xgb, Xt, y, cv=cv_splits
)                                                                 ④
prediction_range = y_pred_cv.min()} - {y_pred_cv.max()
print(f"prediction range: {prediction_range}")                    ⑤

r2 = r2_score(y, y_pred_cv)
rmse = np.sqrt(mean_squared_error(y, y_pred_cv))
mae = mean_absolute_error(y, y_pred_cv)                           ⑥

print(f'R-squared: {r2:.3f}')
print(f'RMSE: {rmse:.3f}')
print(f'MAE: {mae:.3f}')

plt.scatter(y, y_pred_cv)                                         ⑦
plt.plot([y.min(), y.max()], [y.min(), y.max()], 'r--', label='Ideal Fit')
plt.axhline(0, color='orange', linestyle='--', label='Zero Line') ⑧
plt.xlabel('Actual')
plt.ylabel('Predicted')
plt.title('XGBoost - Fitted Results')
plt.legend()
plt.show()

① 使用特定超参数设置 XGBoost 回归器

② 将“reg:gamma”定义为目标函数

③ 根据 neighbourhood_more_than_30 特征生成交叉验证分割

④ 执行交叉验证预测

⑤ 打印预测值的范围

⑥ 计算 R 平方、均方根误差和 MAE 评估指标以评估模型性能

⑦ 创建实际值与预测值之间的散点图

⑧ 在图中添加理想拟合和零线的参考线

这次,预测范围严格在正数范围内,正如我们所预期的那样。平均绝对误差(MAE)几乎是基线线性模型的一半,R-squared 得分接近 0.7,这是一个相当好的结果,显示了模型现在能够拦截目标中大部分的方差:

prediction range: 3291.401123046875 - 123069.8828125
R-squared: 0.693
RMSE: 11562.836
MAE: 7227.435

进一步检查拟合结果,如图 7.12 所示的散点图,横轴为交叉验证预测(y 轴),纵轴为预期目标值,显示点现在与我们的理想拟合稍微更一致。此外,重要的是要注意 XGBoost 模型如何倾向于外推预测:图表最右侧的预测列表明,我们的模型有时预测的值高于目标中观察到的最大值,而在图表最左侧,没有非正估计。

图片

图 7.12 XGBoost 模型拟合结果与其理想值的对比图

通常情况下,在数据科学项目中,你不应该只限于使用单一模型,就像我们的例子那样。由于空间限制,我们只关注 XGBoost 模型。然而,在一个实际项目中尝试更多样化的经典机器学习算法是明智的,例如其他梯度提升实现、更多的树集成、广义线性模型,甚至是现在越来越不常见的经典机器学习模型(如 k-最近邻或支持向量机)。在机器学习中没有免费的午餐,你可能会发现即使使用不同的算法,也可能找到适合你需求的合理解决方案,这些需求包括性能、推理速度、内存占用以及移植到其他系统。

在以下小节中,我们使用贝叶斯优化来优化我们的 XGBoost 解决方案,力求在我们的问题上表现最佳。

7.2.5 优化你的模型

由于 XGBoost 对于这个问题表现相当好,我们将花一些时间来细化其参数,并测试不同的提升方法和目标。我们将使用 Optuna,这是在前一章中介绍的一种贝叶斯优化器,因为它可以有效地探索 GBDT 超参数搜索空间,根据先前实验的结果,在短时间内自适应地选择下一组要评估的超参数。

如果你系统上没有 Optuna,你可以在 shell 或笔记本单元中运行pip install optuna命令来安装它。

列表 7.17 使用 Optuna 对之前测试过的 XGBoost 回归器模型进行超参数优化,以找到最佳超参数,最小化东京 Airbnb 数据集上模型的 MAE。列表的核心是目标函数,它使用 trial.suggest_... 方法向 Optuna 建议不同的超参数值。特别是,它测试了经典的 gbtree 增强器(梯度提升)和 gblinear。这个增强器使用线性模型作为其基学习器,结合 L1 和 L2 正则化,而不是使用决策树。关于目标函数,它测试了经典平方误差、gamma 目标和 Tweedie,结合了 gamma 和泊松分布的方面。在选择 gblinear 增强器或 Tweedie 目标时,代码覆盖了所选参数。它对它们进行修改和添加,以适应 gblinear 增强器或 Tweedie 目标的要求。最后,它在每个测试中创建具有建议超参数的 XGBoost 回归器,并执行交叉验证以评估 MAE。这个过程重复进行指定次数的试验(本例中为 60 次)。优化完成后,打印出最佳 MAE 和相应的最佳超参数。

列表 7.17 优化 XGBoost 回归器

import optuna

def objective(trial):                                       ①

    params = {                                              ②
        'booster': trial.suggest_categorical(
            'booster', 
            ['gbtree', 'gblinear']
        ),
        'objective': trial.suggest_categorical(
            'objective', 
            ['reg:squarederror', 'reg:gamma', 'reg:tweedie']
        ),
        'n_estimators': trial.suggest_int(
            'n_estimators', 100, 1000
        ),
        'learning_rate': trial.suggest_float(
            'learning_rate', 0.01, 1.0, log=True
        ),
        'subsample': trial.suggest_float(
            'subsample', 0.3, 1.0
        ),
        'colsample_bytree': trial.suggest_float(
            'colsample_bytree', 0.3, 1.0
        ),
        'max_depth': trial.suggest_int('max_depth', 1, 7),
        'min_child_weight': trial.suggest_int('min_child_weight', 1, 7),
        'reg_lambda': trial.suggest_float(
             'reg_lambda', 1e-9, 100.0, log=True
        ),
        'reg_alpha': trial.suggest_float(
             'reg_alpha', 1e-9, 100.0, log=True
        ),
    }

    if params['booster'] == 'gblinear':                      ③
        keys_to_remove = [
            "colsample_bytree", "max_depth", 
            "min_child_weight", "subsample"
        ]
        params = {
            key:value for key, value in params.items()
            if key not in keys_to_remove
        }

    if params['objective'] == 'reg:tweedie':                 ④
        # Must be between in range [1, 2) : 1=poisson 2=gamma
        params['tweedie_variance_power'] = trial.suggest_float(
            'tweedie_variance_power', 1.01, 1.99
        )

    xgb = XGBRegressor(**params)                             ⑤
    model_pipeline = Pipeline(
        [('processing', column_transform), 
         ('xgb', xgb)]
    )
    cv_splits = cv.split(X, y=X["neighbourhood_more_than_30"])

    cv_scores = cross_validate(
        estimator=model_pipeline, 
        X=X, 
        y=y,
        scoring='neg_mean_absolute_error',
        cv=cv_splits
    )                                                        ⑥
    cv_evaluation = np.mean(
        np.abs(cv_scores['test_score'])
    )                                                        ⑦
    return cv_evaluation                                     ⑧

sqlite_db = "sqlite:///sqlite.db"
study_name = "optimize_XGBoost_tokyo_airbnb"
study = optuna.create_study(
    storage=sqlite_db, 
    study_name=study_name, 
    direction="minimize",
    load_if_exists=True
)                                                            ⑨

study.optimize(objective, n_trials=100)                      ⑩
print(study.best_value)                                      ⑪
print(study.best_params)                                     ⑫

① 使用 Optuna 库定义一个优化目标函数

② 包含优化超参数的字典,包括增强器类型、目标和其他内容

③ 根据所选增强器类型调整超参数

④ 为 tweedie 目标建议额外的参数 'tweedie_variance_power'

⑤ 使用建议的超参数初始化 XGBoost 回归器

⑥ 使用定义的管道执行交叉验证,并优化 MAE

⑦ 从负 MAE 分数计算 MAE

⑧ 返回要最小化的计算评估指标值

⑨ 创建一个 Optuna 研究,存储在 SQLite 数据库中以进行优化

⑩ 对指定次数的试验进行优化

⑪ 打印优化过程中达到的最佳评估指标值

⑫ 打印优化过程中找到的最佳超参数

经过一段时间的优化运行后,我们获得了相对于第一次尝试的降低 MAE 和一组合适的超参数,显示最大深度为七级,大约 900 个估计量,以及具有 1.5 的方差功率的 Tweedie 目标函数,表明 Poisson 和 gamma 之间的混合分布:

6616.859370931483
{'booster': 'gbtree', 
 'colsample_bytree': 0.946407058507176,
 'learning_rate': 0.06867015067874482,
 'max_depth': 7,
 'min_child_weight': 5,
 'n_estimators': 901,
 'objective': 'reg:tweedie',
 'reg_alpha': 0.0006368936493084075,
 'reg_lambda': 3.8302865696045996,
 'subsample': 0.8956307610431394,
 'tweedie_variance_power': 1.560801988491813
}

在这一点上,我们还可以绘制一些诊断图表,以更好地了解优化过程。例如,我们可以首先绘制优化如何在我们最初设置的 60 次试验中进行的:

fig = optuna.visualization.plot_optimization_history(study)
fig.show()

图 7.13 显示了最佳值在 20 次试验之前就已经实现,之后并没有改善。这是重要信息,因为如果最佳优化可以在之后实现,你可能会怀疑在更多轮次的超参数探索中还有进一步的改进空间。实际上,你可以通过重新运行命令study.optimize(objective, n_trials=100)来实现这一点,设置你想要的额外试验次数而不是最初的 100 次。由于我们设置为将试验存储在 SQLite 数据库中,你可以在停止的点重新开始优化(这是使用 Optuna 而不是其他优化选项的优势之一)。从图表中还可以得出另一个重要的事实,那就是有相当多的超参数集是最佳或几乎最佳的。这意味着对于这个问题没有单一的优化方法。这允许你探索不同的设置,并选择适合你需求的解决方案。例如,你可能会选择一个近似最佳解决方案,它采样更多特征或需要更少的估计量,因为它们在推理时间上更快。

图片

图 7.13 Optuna 在优化过程中逐步进行的测试

观察到优化过程后,图表还提供了关于超参数重要性的另一条重要信息,因为如果它们在优化过程中证明非常重要,这可能会提示扩大这些超参数的搜索空间:

fig = optuna.visualization.plot_param_importances(study)
fig.show()

在我们的案例中,最关键的因素证明是colsample_bytreemin_child_weight,这些超参数在结果中造成了最大的变化,如图 7.14 所示。

图片

图 7.14 调优过程中超参数的重要性

现在我们已经有一组很好的超参数。在下一小节中,我们将通过使用交叉验证来测试模型,这是一种用于泛化目的的评估,并使用所有可用数据训练最终模型,来完成我们的训练阶段。

7.2.6 训练最终模型

完成优化后,我们可以通过直接进行交叉验证来测试结果,然后在所有可用数据上训练模型。列表 7.18 中展示的代码与我们之前使用的代码变化不大。请注意,现在,为了我们的估计,我们使用的是交叉验证过程,而不是交叉验证预测,因为我们更感兴趣的是了解我们模型的泛化能力,而不是它如何精确地拟合手头的数据。

列表 7.18 使用全部数据训练模型

best_params = study.best_params
print(best_params)

xgb = XGBRegressor(**best_params)                               ①
model_pipeline = Pipeline([('processing', column_transform), ('xgb', xgb)])

cv_splits = cv.split(X, y=X["neighbourhood_more_than_30"])      ②

r2_scores = []
rmse_scores = []
mae_scores = []

for train_index, test_index in cv_splits:                       ③
    X_train, X_test = X.iloc[train_index], X.iloc[test_index]
    y_train, y_test = y.iloc[train_index], y.iloc[test_index]

    model_pipeline.fit(X_train, y_train)
    y_pred = model_pipeline.predict(X_test)

    r2_scores.append(r2_score(y_test, y_pred))
    rmse_scores.append(np.sqrt(mean_squared_error(y_test, y_pred)))
    mae_scores.append(mean_absolute_error(y_test, y_pred))

print(f"Mean cv R-squared: {np.mean(r2_scores):.3f}")
print(f"Mean cv RMSE: {np.mean(rmse_scores):.3f}")
print(f"Mean cv MAE: {np.mean(mae_scores):.3f}")

model_pipeline.fit(X, y)                                        ④

① 使用从 Optuna 研究中获得的最佳超参数初始化 XGBoost 回归器

② 使用指定的 StratifiedKFold 策略分割数据

③ 迭代交叉验证折来测试模型

④ 在整个数据集上训练最终模型

以下是在运行代码时收到的输出,包含使用的参数和评估指标,所有这些均基于我们的交叉验证策略:

{'booster': 'gbtree', 
 'colsample_bytree': 0.946407058507176,
 'learning_rate': 0.06867015067874482,
 'max_depth': 7,
 'min_child_weight': 5,
 'n_estimators': 901,
 'objective': 'reg:tweedie',
 'reg_alpha': 0.0006368936493084075,
 'reg_lambda': 3.8302865696045996,
 'subsample': 0.8956307610431394,
 'tweedie_variance_power': 1.560801988491813
}

Mean cv R-squared: 0.727
Mean cv RMSE: 10886.568
Mean cv MAE: 6667.187

我们还可以可视化,如图 7.15 所示,完整的管道,包括列转换器,接受不同的特征进行其独特的转换操作,以及接收来自列转换器的所有组装数据的 XGBoost 模型。

图 7.15 由列转换和 XGBoost 模型组成的管道

在彻底训练我们的模型后,我们可以说我们已经完成了。实际上,这可能是多个迭代周期的第一个循环,因为模型需要经常重新训练以避免所谓的概念漂移,正如我们在第二章中解释的那样,预测变量与目标变量之间的关系会随时间变化,使得过去的模型在一段时间后变得无效。

此外,通常,机器学习工程师和数据科学家的工作并不随着一个工作模型的完成而结束,因为能够弄清楚它是如何工作的以及预测变量实际上是如何与目标变量相关联的至关重要,这有助于了解模型是如何得出其预测的。解释模型的工作原理有助于建立信任,便于调试,有助于合规性,并使人类能够理解、验证和改进人工智能系统的决策过程,这是本章最后一节的主题。

7.3 使用 SHAP 解释你的模型

总结来说,我们花了一些时间来尝试理解我们的 XGBoost 模型是如何工作的,因为,正如 EDA 帮助你理解模型如何使用数据一样,可解释性技术,如 SHAP(SHapley Additive exPlanations)或部分依赖图(在上一章中描述),可以帮助你了解模型是如何使用数据来做出预测的。可解释性可以提供有价值的见解,帮助你更好地准备数据,修正先前的假设,并丢弃无用或有害的特征。

此外,可解释性在数据科学项目中还扮演着其他更软性的角色,除了提供模型如何使用其特征和生成预测的见解之外:

  • 人机协作——当处理表格数据时,数据科学家会与领域专家或业务利益相关者合作,他们可能对复杂模型不太熟悉。可解释性允许数据科学家有效地与非技术受众沟通模型见解。

  • 建立信任——在医疗保健或金融等特定领域,模型的可解释性对于与利益相关者和监管机构建立信任至关重要。

  • 合规性和法规——在某些地理区域和行业中,对模型透明度和可解释性有监管要求,例如在欧洲联盟,通用数据保护条例强调了自动化决策系统的“解释权”。

  • 偏差检测和缓解——可解释性可以帮助识别数据和模型决策过程中的偏差,突出模型决策过程是否可能对任何合理的群体造成不利。

由于所有这些原因,我们决定生成 SHAP 值,这些值可以通过 SHAP 包(github.com/shap/shap;使用pip install shap安装)及其 TreeSHAP 算法为基于树的模型生成,也可以通过 XGBoost、LightGBM 等原生且更高效的方式通过一个简单的程序生成。

SHAP 值是一种可以解释机器学习模型预测构建方式的方法。它们基于 Shapley 值,这是一个合作博弈论概念,在模型对特定数据实例的预测中公平地分配每个特征的“信用”或“重要性”。换句话说,SHAP 值通过一个简单的加法公式将每个特征的贡献分配给模型输出。

Shapley 值考虑了特征在所有可能的特征组合中的贡献,这可以被视为模型中的“游戏”。这些“游戏”涉及在不同的特征子集上训练模型。SHAP 值通过重采样策略来近似 Shapley 值,以避免为模型和特征集计算所有可能的“游戏”。通过使用 SHAP 值,我们可以深入了解每个特征如何影响模型对特定实例的预测。这些信息对于模型调试、特征工程以及增强机器学习模型的整体可解释性和可信度非常有价值。

我们在列表 7.19 中实现了 SHAP 值,以深入了解我们之前构建的 XGBoost 模型。在代码中,我们首先从管道中检索训练好的 XGBoost 模型。特别是,我们获取其 booster,这是 XGBoost 模型的核心组件,负责实现梯度提升算法。然后我们两次转换训练数据:首先是因为我们不能直接使用管道将数据喂给 booster。因此,我们手动预处理并提取其特征名称以供参考。其次,我们将数据转换为一个 DMatrix 数据结构(参见 XGBoost 文档mng.bz/yWQd),这是 XGBoost 的一个特定数据结构,用于高效处理,这对于直接喂给 booster 是必需的。在此阶段,我们通过设置参数pred_contribs为 true 的 predict 命令来计算 SHAP 值。另一个简单的 predict 命令只提供了模型用于比较的预测。

列表 7.19 SHAP 值作为 XGBoost 输出

from XGBoost import DMatrix

booster = model_pipeline['xgb'].get_booster()              ①

Xt = model_pipeline['processing'].transform(X)             ②
feature_names = (
    model_pipeline['processing']
    .get_feature_names_out()
)                                                          ③
Xd = DMatrix(Xt)                                           ④

shap_values = booster.predict(Xd, pred_contribs=True)      ⑤
preds = booster.predict(Xd)                                ⑥

① 从管道的训练 XGBoost 模型中检索训练好的 XGBoost booster 对象

② 使用处理管道转换输入数据 X

③ 获取处理管道转换后的转换后特征名称

④ 从转换后的输入数据创建一个 DMatrix

⑤ 使用增强器的 predict 函数计算 SHAP 值,带有 pred_contribs=True 参数

⑥ 获取输入数据的原始预测值

仅为了比较,我们必须指出,LightGBM 也能够做到同样的事情,使用相同的预测方法,将pred_contribs参数设置为 true。唯一的区别是您不需要从训练好的 LightGBM 模型中提取任何增强器。您只需直接使用该模型即可。

注意,无论您是在进行分类还是回归,通过此方法获得的 SHAP 值都是乘法模型的对数变换。这意味着如果您想重新创建原始预测,您首先必须对值进行指数化,然后将它们相乘,如下面的代码片段所示,重建第一个示例的预测与有效预测进行比较:

np.prod(np.exp(shap_values[0])), preds[0]

(10627.659, 10627.469)

如您所见,在重建过程中存在细微的差异,这些差异可以归因于近似和小误差。然而,总的来说,SHAP 值提供了对预测本身的良好近似。当将相同的方法应用于整个训练集,并使用皮尔逊相关系数评估其对原始预测的遵循程度时,它显示了 SHAP 值与预测之间有很强的拟合度:

np.corrcoef(preds, np.prod(np.exp(shap_values), axis=1))

array([[1., 1.],
       [1., 1.]])

作为直接输出 SHAP 值作为 XGBoost 预测的替代方案,您可以使用 SHAP 包中的TreeExplainer函数(mng.bz/pKXR)。该函数虽然声明是用快速的 C++实现构建的,但比 XGBoost 的直接预测要慢得多。然而,使用TreeExplainer,您可以指定更多的输出选项,特别是输出类型和计算方法,这可以使您重建如前所述的原始预测(使用参数feature_perturbation="tree_path_dependent")或使用一种“根据因果推理规则打破特征之间依赖关系”的方法,从而在特征之间存在强共线性时提供更可靠的见解(使用参数feature_perturbation="interventional")。您可以使用以下代码片段获取干预 SHAP 值:

from shap import TreeExplainer

explainer = TreeExplainer(model_pipeline['xgb'], data=Xt, model_output='raw', feature_perturbation='interventional')
interventional_shap_values = explainer.shap_values(Xt)

结果的 SHAP 值矩阵对原始数据不太忠实,无法重建如前所述的预测。尽管如此,这种方法可能提供更可靠的“忠实于模型”的贡献估计,如以下 GitHub 问题中用技术术语解释:github.com/shap/shap/issues/1098。根据我们的经验,我们建议使用TreeExplainer和干预方法,尽管在处理具有高度多重共线性特征的数据时,这可能会需要更长的计算时间。

到目前为止,我们已将 SHAP 值用作解释单个样本的方法。通过检查特征贡献,我们研究了为何做出某种预测的原因。然而,我们可以将所有 SHAP 值综合考虑,并对其进行分析,以找出整个模型的一般解释。在这种情况下,与其他方法一样,我们可以绘制一些总结和诊断图表来更好地理解这一点。我们提出的第一个列表通过 SHAP 值的平均值来量化特征的相对重要性。在这里,我们使用来自 shap 包的绘图功能。您可以通过在 shell 或笔记本的单元格中运行命令 pip install shap 来安装此包。

列表 7.20 SHAP 重要性图

import shap

shap.summary_plot(
    shap_values[:,:-1],
    Xt,
    plot_type="bar",
    feature_names=feature_names,
    max_display=10,
    show=False
)                                                          ①
plt.xticks(fontsize=8)
plt.yticks(fontsize=8)
plt.xlabel("SHAP Importance", fontsize=10)
plt.show()

① 生成 SHAP 特征重要性的前 10 个最重要特征的总结图

图 7.16 显示了生成的图表,您可以立即确定有四个特征倾向于主导预测,这些特征是可用性,这同时也是某种住宿市场供需动态的代理(可用性较低可能意味着共享使用或对该住宿的需求较低);目标编码坐标(即住宿在城市中的位置);卧室数量,这是住宿大小的代理;以及床位数,这有助于与前面的图一起区分类似旅舍的列表,这些列表通常价格较低。所有其他特征的作用较小,这可以从图表的规模中看出:前 10 个最重要特征中最后一个的重要性是顶级重要特征的五分之一。

图 7.16 SHAP 重要性

然而,重要性只是故事的一部分。我们还需要方向性。因此,小提琴图可以提供关于模型行为的更多信息。在由 shap 包生成的小提琴图中,您可以从以下细节中获得提示:

  • 特征重要性——小提琴图的宽度表示 SHAP 值的密度。较宽的部分代表具有相似 SHAP 值的实例更多。因此,具有更宽小提琴图的特征在模型的预测中通常更重要。

  • 小提琴形状——小提琴的形状表示对应特征的 SHAP 值分布。如果小提琴是对称的,则表明 SHAP 值均匀分布在均值周围,这意味着对预测的影响是平衡的。不对称表示偏斜,并表明某些特征值对预测的影响比其他特征值更大。

  • 正负贡献——小提琴图的中心线(均值)通常是零。小提琴的左右两侧分别代表具有正 SHAP 值和负 SHAP 值的特征的贡献。正 SHAP 值将预测推向更高,而负 SHAP 值将预测推向更低。

  • 与特征值的相关性—小提琴图的颜色可以帮助您将蓝色区域(特征值较低)和红色区域(特征值较高)与特定的 SHAP 贡献关联起来。这有助于理解特征通常如何与结果相关。

  • 异常值—异常值或超出小提琴图范围的外部 SHAP 值表明,相应的特征对预测有异常强烈的影响。

在以下列表中,小提琴图提供了关于分布和每个特征在模型预测中作用的宝贵见解。

列表 7.21 SHAP 小提琴图

shap.summary_plot(shap_values[:,:-1], Xt, 
                  plot_type="violin", 
                  feature_names=feature_names,
                  show=False)                              ①
plt.yticks(fontsize=8)
plt.show()

① 使用小提琴图创建 SHAP 摘要图,以可视化每个特征的 SHAP 值分布

图 7.17 显示了结果的小提琴图。至于我们最重要的特征,我们可以得出以下结论:

  • Numeric__availability_365—更高的可用性对应着对价格的正影响。可用性较低的列表通常会受到惩罚。

  • Target_encoding__coordinates—由于其值与特定方向性无关,难以解释。我们可以观察到两侧都有长尾,且对住宿定价的负贡献更为普遍。

  • Numeric__number_of_bedrooms—卧室数量越多,价格越高,右侧有一个长的偏斜尾。

  • Numeric__number_of_beds—同样,床位数越多,价格越高,右侧有一个长的偏斜尾。

图片

图 7.17 SHAP 小提琴图

简单看一下其他特征,可以了解模型直观上的行为。例如,住宿地点离故宫或机场越近,价格就越高。

这就结束了我们使用梯度提升的端到端示例。在下一章中,我们将回到 Airbnb 纽约问题,并回顾一系列深度学习堆栈(低级框架、高级 API 和表格数据深度学习库),并使用这三个堆栈(fastai、PyTorch 与 TabNet、Lightning Flash)来解决问题,并比较不同的解决方案。

在这个阶段,一个生成式 AI 工具,如 ChatGPT,可以用来创建解释每个示例分配的 SHAP 值的叙述。能够为每个预测创建简单的解释,在展示模型潜力或试图说服客户和利益相关者时可能是一个优势。此外,在欧盟等法规下,解释模型对数据集预测的需求至关重要。透明度和可解释性是旨在数据保护和保护隐私的法规的必要组成部分,例如欧盟的通用数据保护条例。根据这些法规,个人有权理解对其生活产生重大影响的自动化决策过程背后的逻辑。为特定预测提供清晰易懂的解释确保了透明度和问责制,并促进了公平:它赋予个人寻求澄清、挑战不公平决定并最终保护其权利的能力。

实际上,您可以通过单个提示生成这些叙述,以实时创建解释,或者使用 ChatGPT API,让模型处理您稍后可以回忆的关于特定预测原因的解释批次。然而,无论是实时处理还是批量处理方法,配方都是相同的:您必须告诉 LLM 通过提供特征列表(如有必要,详细说明其描述或含义)以及数据集中的原始值和相对于特征的 SHAP 值来解释。当然,有必要提及最终的预测。将所有这些信息粘合在一起,以便 LLM 使用 JSON(字典的字典)进行处理可能是理想的。在列表 7.22 中,我们提供了一个准备 JSON 结构的解决方案,以方便向 ChatGPT 提出提示请求,解释数据集中的特定示例,该示例由其行索引标识。该代码生成一个包含构建连贯叙述解释所需所有信息的数据结构。

列表 7.22 将 SHAP 解释作为提示的一部分构建 JSON

def generate_prediction_explanation(
    index,
    X,
    feature_names, 
    shapley_values,
    predictions
):
    explanation = {}                                        ①
    explanation["prediction"] = predictions[index]          ②
    for feature, original_value, shap_value in zip(
        feature_names, 
        X[index],
        shapley_values[index, :]
    ):                                                      ③
        explanation[feature] = {
            "original_value": original_value, 
            "shap_value": shap_value
        }
    return explanation

index_to_explain = 5                                        ④
explanation_json = generate_prediction_explanation(
    index_to_explain, 
    feature_names, 
    Xt,
    shap_values,
    preds
)
print(explanation_json)

① 将 JSON 数据结构实例化为 Python 字典

② 包含要解释的预测值到 JSON 中

③ 遍历检查行的特征、原始值和 SHAP 值

④ 要解释的预测的索引

在我们的例子中,我们需要描述为什么模型预测了数据集第 5 行的特定值。然后可以将打印的 JSON 包含在如下提示中

You are an expert data scientist, and you need to interpret the predictions
 of a regression model based on the shape values provided in a JSON file.
 You build the explanations as a narration of how the most important 
variables contribute to the prediction. Here is the JSON file:
{'prediction': 55225.176, 'onehot_encoding__room_type_Entire home/apt': 
{'original_value': 1.0, 'shap_value': 0.03404991}, 
'onehot_encoding__room_type_Hotel room': {'original_value': 0.0, 
'shap_value': 0.00020163489}, … }

当您将此提示提交给 ChatGPT 时,您将收到一个按变量类型分类的以项目符号组织的文本。此文本描述了每个单独的变量或变量组对结果的影响。以下是针对第 5 行表示的具体实例所获得的见解摘录:

  • 房间类型—“整个家庭/公寓”的房间类型对预测价格有积极影响,贡献了 0.034 的 SHAP 值。这表明,将整个家庭/公寓作为房间类型的房源往往价格更高。

  • 其他房间类型(“酒店房间”、“私人房间”和“共享房间”)—它们有较小的正贡献或可忽略不计的贡献,表明它们对价格的影响并不那么显著。

  • 社区—特征neighbourhood_more_than_30具有 0.083 的正 SHAP 值,表明位于拥有超过 30 个房源的社区对价格有积极影响。

  • 住宿类型type_of_accommodation特征具有-0.008 的小负面影响,这表明某些类型的住宿可能价格略低。

完整的文本实际上涉及了所有特征,如果你愿意,你可以提示 LLM 将结果缩减到只有前 5 个或 10 个最具影响力的特征。当然,使用语言模型来完成这项工作可以使一个困难的任务变得简单,并且轻松自动化。

摘要

  • 获取和准备数据需要下载、重构并将所有内容组装在一起。这通常是端到端项目中漫长而繁重的工作部分,但它是不可或缺的,为后续工作的成功奠定基础。

  • 特征工程不仅仅是魔法或随机组合特征;大多数情况下,它是在特征中嵌入关于问题及其解决方法的知识。探索与问题相关的领域知识集合是有效地建模数据的第一步。

  • 在数据探索性分析(EDA)阶段探索你的预测和目标是你建模表格问题的日程安排中的一个重要部分。寻找异常值和极端值、缺失数据以及数据中的任何其他特殊性。如果你不确定它们是否能为你的模型提供真正的价值,请随意删除示例。

  • 在深入建模之前,检查你的验证策略,这可能需要额外的工作,数据探索性分析(EDA)以及你的数据处理流程。这两者都可能对建模阶段产生影响。理想情况下,为你要测试的每种类型的模型准备一个管道,因为每种模型处理表格数据集中的各种类型数据的方式都不同。在我们的例子中,作为一种简化,我们尝试了一种一刀切的方法。请记住,这样的例子在书中效果很好,但在现实世界的项目中还有更好的方法。

  • 在表格数据问题建模中,建立基线模型是一个经常被忽视的阶段。然而,通过检查模型如何欠拟合或过拟合数据及其内部系数,它可以提供有价值的见解。基线模型必然是简单的,这意味着线性回归和逻辑回归是回归和分类问题的最佳候选者。

  • 在从基线模型中获得洞察之后,您可以继续使用更复杂的模型,例如 XGBoost。在设置关键超参数的第一个尝试值时,应考虑欠拟合、非线性、交互作用、目标以及预测者的特征等提示。

  • 如果您将搜索空间设置为包含您迄今为止关于模型如何处理数据和问题的洞察和假设,那么使用 Optuna 优化您的模型可以为您节省大量时间。一旦优化完成,您可以通过观察超参数重要性和优化路径图表来获得更多洞察。

  • 使用 XGBoost 和 LightGBM,通过将pred_contribs参数设置为 true 的 predict 方法,可以轻松解释您的训练模型。一旦获得了与预测有效相关的乘数 SHAP 值,您可以使用shap包中的标准图表,例如重要性图或小提琴图。

第三部分:表格数据的深度学习

第三部分是您应用深度学习解决表格数据问题的知识和实用见解指南。作为一个独立的解决方案或与梯度提升集成,当您知道如何使用其独特的解决预测任务的方法时,深度学习可以针对表格数据获得良好的结果。

第八章探讨了用于处理表格数据的各种深度学习堆栈和框架,包括像 TensorFlow 和 PyTorch 这样的低级框架,以及像 fastai 和 Lightning Flash 这样的高级 API。它介绍了几个专门为表格深度学习任务设计的库,例如 TabNet、PyTorch Tabular、SAINT 和 DeepTables。我们比较了不同的堆栈,并讨论了每个堆栈的优缺点。第九章将讨论扩展到最佳实践。我们使用吉隆坡房地产数据集来说明这些表格数据深度学习的最佳实践,包括数据准备、模型架构设计和模型训练。一个基于 Keras 的项目,该示例强调简单、易懂和有效的数据管道,以及促进代码重用的模块化方法。

第十章探讨了如何使用 Flask(一个在 Web 界面和 API 服务方面表现卓越的 Python 框架)使训练好的深度学习模型在现实世界环境中可用。第十一章通过指导您在 Google Cloud 的 Vertex AI 环境中定义用于训练和部署模型的管道步骤,扩展了讨论,包括创建 Docker 容器来封装模型代码和依赖项,定义管道步骤,并在 Vertex AI 上运行管道。然后我们继续讨论 Gemini 在 Google Cloud 中的功能,例如回答有关 Google Cloud 的问题、从文本生成代码、解释代码和总结日志条目。所有这些功能都可以应用于您的流程,以创建您自己的机器学习管道。

本书以第十二章结束,你将学习如何将深度学习与机器学习相结合,以在预测任务中实现最先进的结果。阅读完这本书后,模型设计、训练、部署和可解释性将不再对你有任何秘密!

第八章:使用表格数据开始深度学习

本章涵盖

  • 使用表格数据堆栈的深度学习简介—深度学习的低级框架和高级 API

  • PyTorch 与 fastai 堆栈

  • PyTorch 与 TabNet 堆栈

  • PyTorch 与 Lightning Flash 堆栈

  • 我们没有练习的堆栈以及为什么没有练习它们

  • 深度学习表格数据堆栈的优缺点比较

到目前为止,我们一直专注于经典机器学习工具和算法来分析表格数据。从传统的回归算法到更复杂的梯度提升技术,这些方法在简单性、透明性和有效性方面具有优势。尽管如此,深度学习工具的获取和使用变得更加容易,它们也为处理表格数据提供了一个强大的替代方案。

在本章中,我们将回顾一系列深度学习堆栈(低级框架、高级 API 和表格数据深度学习库),并使用这三个堆栈——fastai、PyTorch 与 TabNet 和 Lightning Flash 来解决 Airbnb NYC 问题。我们将用每个堆栈解决同样的问题三次。目标是展示深度学习方法的通用形式,并突出我们选择的三种工具的独特特性。

8.1 使用表格数据的深度学习堆栈

在我们检查用于深度学习表格数据的通用堆栈之前,让我们看看一个具体的例子:第三章中描述的基于 Keras 的深度学习解决方案,用于解决 Airbnb NYC 价格预测问题。

Keras 解决方案与 XGBoost 解决方案的比较

专属于 Keras 解决方案的代码包含在训练笔记本中。特别是,第三章中描述的 Keras 解决方案与 XGBoost 解决方案之间的关键区别包括

  • 模型定义—Keras 模型有一个大函数来定义构成模型的层,每个列类(连续、分类和文本)都得到一组特定的层。

  • 模型训练—Keras 模型包含额外的代码来定义使训练过程高效的回调函数,包括一个回调函数,如果训练不再使模型变得更好,则提前停止训练过程,以及一个回调函数以确保在训练过程中保存最佳模型。

第三章中我们检查的 Keras 解决方案为我们提供了一个具体的基线,我们可以用它来比较本章我们将检查的其他堆栈。在本章中,我们将练习一系列其他堆栈,以便您可以看到每个选择的优缺点。

我们还将讨论一组我们没有能够练习的堆栈,并解释这一经历告诉我们关于这些选择的信息。了解堆栈选择及其优缺点非常重要,这样您就可以选择最适合您需求的深度学习表格数据堆栈。

让我们简要回顾第三章中的 Keras 解决方案。图 8.1 显示了构成 Keras 解决方案的文件,其中训练笔记本被突出显示。

图片

图 8.1 解决 Airbnb 问题的 Keras 解决方案文件

训练笔记本包含在 Keras 解决方案和其他本章将要探讨的解决方案之间有所不同的代码。其他文件在所有深度学习解决方案中保持一致,除了训练配置文件中的某些设置。

图 8.2 显示了构成此解决方案堆栈的组件。这些组件在训练笔记本中使用。

图片

图 8.2 第三章中 Airbnb NYC 解决方案的堆栈

在这个堆栈中,底层低级深度学习框架是 TensorFlow。由于 Keras 作为 TensorFlow 的一部分提供,并且是 TensorFlow 推荐的高级 API,因此单独谈论 TensorFlow 和 Keras 可能听起来有点多余,但保持它们独立将使一般堆栈选择的描述更清晰。在第三章的深度学习解决方案中,我们使用了自定义编写的代码来定义模型本身。例如,列表 8.1 显示了第三章深度学习解决方案中定义分类列层的自定义代码。列表还显示了get_model()函数中定义分类列层的语句。

列表 8.1 get_model()函数中定义分类列层语句

    for col in collist:
        catinputs[col] = Input(shape=[1],name=col)                   ①
        inputlayerlist.append(catinputs[col])                        ②
        embeddings[col] = \
(Embedding(max_dict[col],catemb) (catinputs[col]))                   ③
        embeddings[col]=(BatchNormalization()(embeddings[col]))      ④
        collistfix.append(embeddings[col])                           ⑤

① 为当前列定义一个模型输入层

② 将刚刚定义的输入层添加到输入层列表中

③ 为当前列定义一个嵌入层

④ 为当前列定义一个批量归一化层

⑤ 将定义的层集添加到整体层列表中

get_model()函数指定了模型中三种类型输入列的 Keras 层:分类、连续和文本。列表 8.1 中显示的get_model()函数还包含定义连续层和文本层模型层的语句。请注意,此模型有多个输入(每个选定的用于训练模型的列都是一个输入)和一个输出:预测给定 Airbnb 列表的价格是否高于或低于中位数。每个输入列层定义的细节超出了本章的范围,所以我们现在不会详细说明。

既然我们在第三章中已经回顾了深度学习 Airbnb NYC 解决方案的堆栈结构,那么让我们将其推广到其他表格数据的深度学习方法。图 8.3 显示了表格数据问题深度学习堆栈的选择。

图片

图 8.3 表格数据的深度学习堆栈

让我们更详细地检查堆栈的每一层:

  • 低级框架—有两个主要的低级深度学习框架。在工业界,TensorFlow 被使用得最为频繁。PyTorch 是研究人员最流行的选择。

  • 高级 API—为了使初学者更容易创建深度学习应用,并抽象化一些经验丰富的开发者的复杂性,在 2010 年代中期,人们发现了对深度学习高级 API 的需求。最初,您可以使用 Keras 作为几个低级框架的前端。到 2019 年,Keras 被集成到 TensorFlow 生态系统中,并被确定为 TensorFlow 推荐的高级框架。在 PyTorch 世界中,没有 Keras 的精确对应物。PyTorch 的整体设计旨在使其比 TensorFlow 更易于使用,并减少对高级 API 的需求。尽管如此,有两个高级 API 抽象了 PyTorch 的不同方面。fastai 专门针对来自其他学科的人士,他们希望使用深度学习来解决他们学科中的问题,其核心伦理是只需几行代码就能定义、训练和练习深度学习模型。相比之下,Lightning 抽象了 PyTorch 的单个方面,即训练循环。建立在 Lightning 之上的 Lightning Flash,根据其文档,是“一个用于快速原型设计、基准测试、微调和解决深度学习问题的通用深度学习框架。”虽然 fastai 和 Lightning 都有专门的用户社区,但它们在 PyTorch 世界中的受欢迎程度并没有像 Keras 在 TensorFlow 世界中的那样。

  • 表格数据库—低级框架和高级 API 为深度学习提供了一个通用环境。深度学习库提供了专门用于处理表格数据的功能。正如我们在第三章中用深度学习解决方案解决 Airbnb 纽约市价格预测问题所展示的,您不需要使用表格数据库来对表格数据进行深度学习。

关于表格数据库的两个需要注意的细节是

  • 表格数据库可能同时支持 TensorFlow 和 PyTorch。TabNet 是支持这两个低级深度学习框架的库的例子。

  • fastai 是一个通用型、高级 API,同时也是表格数据库。fastai 符合这两个类别,因为它将 PyTorch 的一些复杂性抽象化,使其更容易在多种数据类型(包括图像和文本)上构建和训练模型,同时也提供了专门针对表格数据的设施(例如,自动处理表格数据集中分类特征所需的基本操作)。

现在我们已经检查了表格数据的深度学习堆栈,让我们看看在本章中我们将应用以解决 Airbnb 纽约市价格预测问题的堆栈:

  • PyTorch with fastai—这是最“传统”的方法,因为 fastai 是一个成熟的框架,有数万名开发者在使用它。根据仓库星级,fastai 是一个明确支持表格数据的最受欢迎的框架。fastai 特别受那些正在学习深度学习和爱好者们的欢迎。

  • PyTorch with TabNet—TabNet 是根据仓库星级在 fastai 之后最受欢迎的表格数据库。TabNet 是一个由 Google 在其文档中突出的表格数据库(mng.bz/av1m)。这个堆栈展示了如何使用专门的表格数据库来创建在表格数据上训练的模型。

  • Lightning Flash—PyTorch Lightning 是一个流行的框架,它抽象了一些 PyTorch 的复杂性。Lightning Flash 是建立在 PyTorch Lightning 之上的,提供了一个创建深度学习应用的便捷方式。它还包括对表格数据的显式支持,因此是本章中我们审查的其他堆栈的一个有趣的比较点。

本章接下来的三个部分描述了使用这三个堆栈中的每一个来解决 Airbnb 纽约价格预测问题的解决方案。在每个部分中,我们将审查解决方案的代码,并比较解决方案的优缺点与第三章中我们的基线,即 Keras 解决方案的优缺点。

8.2 使用 fastai 的 PyTorch

现在,让我们看看其中一个工具包:PyTorch/fastai。因为我们正在考虑与刚才讨论的相同的数据集和问题,所以我们在这里不会重复。大部分解决方案在不同工具包之间非常相似。在这里,我们将专注于 PyTorch 代码的独特部分。您可以在本书的代码仓库中找到完整的解决方案:mng.bz/gaBv

8.2.1 审查 fastai 解决方案的关键代码方面

现在,让我们深入探讨 fastai 解决方案来解决 Airbnb 纽约列表价格预测问题。首先,fastai 有一个独特的导入集合,如列表 8.2 所示,它获取在 Jupyter notebook 中使用 fastai 所需的库。

列表 8.2 fastai 解决方案的导入语句

!pip install -Uqq fastbook                               ①
import fastbook
from fastbook import *                                   ②
from fastai.tabular.all import *                         ③

① 安装用于在 Jupyter notebook 中使用 fastai 的库

② 导入用于在 Jupyter notebook 中使用 fastai 的库

③ 导入用于在 fastai 中处理表格数据集的库

如列表 8.2 所示,导入这些库后,您就有了在 Jupyter notebook 中运行 fastai 表格数据应用所需的库。

接下来,fastai 需要定义表格数据集的特征,包括包含模型目标列(在 fastai 中称为因变量)的列以及分类和连续列的列表,如下所示。

列表 8.3 fastai 解决方案的表格定义语句

dep_var = 'target'                                        ①
cat = ['neighbourhood_group','neighbourhood','room_type'] ②
cont = \
['minimum_nights','number_of_reviews',\
'reviews_per_month','calculated_host_listings_count']     ③

① 指定包含目标值的列,即训练模型预测的值

② 指定数据集中哪些列是分类的

③ 指定数据集中哪些列是连续的

当我们定义此模型的 TabularDataloaders (mng.bz/5gwO) 对象时,我们将使用列表 8.3 中定义的值。TabularDataloaders 对象封装了数据集的样本,包括标签,以便于与数据集一起工作。

接下来,我们需要确保目标列包含字符串值:

merged_data['target'] =merged_data.target.astype(str)

如果我们不这样做,我们将遇到一个微妙的问题。为了亲自尝试,请注释掉此语句并运行 fastai 训练笔记本。您将看到训练产生了一些奇怪的结果,如图 8.4 所示。

图 8.4 当目标列未显式转换为字符串值时的 fastai 训练结果

图 8.4 显示了训练过程的每个时期的训练结果,包括训练损失、验证损失和准确率。图 8.4 中显示的准确率值明显低于第三章中 XGBoost 和 Keras 深度学习解决方案的准确率(在 79% 到 81% 之间),并且准确率从一个时期到另一个时期没有提高。训练更多时期没有帮助;准确率保持不变。为什么 fastai 会产生如此令人失望的结果?在 learn.loss_func 语句的输出中有一个线索,如下所示。

列表 8.4 显示模型训练中使用的损失函数的语句

learn.loss_func                                        ①
FlattenedLoss of MSELoss()                             ②

① 返回用于训练 fastai 模型的损失函数的语句

② 显示用于训练 fastai 模型的损失函数的语句输出

列表 8.4 中显示的输出显示了用于模型的损失函数。如果您没有为 fastai 模型指定损失函数,fastai 将根据目标列中的值选择一个损失函数。我们想要训练一个分类模型,因此损失函数应该是交叉熵。然而,看起来 fastai 选择了一个用于回归问题的损失函数而不是分类问题。这就是为什么列表 8.4 中显示的训练结果不佳——fastai 正在尝试解决一个分类问题(预测一个连续值)而不是我们想要的分类问题(预测一个 0 或 1 来指示列表的价格是否高于或低于中位数价格)。

dls.valid.show_batch() 的输出,如图 8.5 所示,提供了另一个线索,因为当 target 列中的值应该是“0”或“1”时,它们是浮点数。

图 8.5 当目标列未显式转换为字符串值时的样本批次值

如果我们使用 merged_data.head() 回到数据集查看,如图 8.6 所示,target 列中的值看起来都是 0 或 1。

这些 target 列中的值实际上是数值,这意味着如果我们没有明确地将它们转换为字符串,那么默认情况下,fastai 将假设如果我们使用此数据集来训练模型,所需的模型是回归模型。

图片

图 8.6 当目标列明确转换为字符串值时的样本批处理值

既然我们已经探讨了为什么将 target 列中的值转换为字符串值是至关重要的,那么让我们回顾一下创建训练好的 fastai 模型的其余代码。列表 8.5 显示了定义 TabularDataLoaders 对象的代码块。该对象是围绕 PyTorch DataLoader (mng.bz/6eDe) 对象的表格数据特定包装器,它是对数据集中的样本和标签的可迭代封装。

列表 8.5 定义 TabularDataLoaders 对象

path = '.'                                                ①
procs = [FillMissing,Categorify, Normalize]               ②
dls = TabularDataLoaders.from_df(dtrain,                  ③
                                 path,
                                 procs= procs,            ④
                                 cat_names= cat,          ⑤
                                 cont_names = cont,       ⑥
                                 y_names = dep_var,       ⑦
                                 valid_idx=\
list(range((merged_data.shape[0]-10000),
merged_data.shape[0])),                                   ⑧
                                 bs=32)                   ⑨

① 为路径对象设置占位符值

② 定义在隐式管道中应用于数据集的转换过程

③ 指定 TabularDataLoaders 对象基于合并后的数据框 merged_data

④ 指定使用 TabularDataLoaders 对象应用的转换列表

⑤ 指定分类特征

⑥ 指定连续特征

⑦ 指定目标特征

⑧ 指定在训练过程中用于验证的数据集子集

⑨ 指定批处理大小

我们将使用列表 8.5 中定义的 TabularDataLoaders 对象来定义列表 8.7 中显示的 fastai 模型。

fastai 的一个特点是提供了一套便利函数,这使得通过训练阶段检查数据集变得容易。以下列表中显示的 show_batch() 语句是此类便利函数的一个例子。

列表 8.6 显示训练数据批次的语句

dls.valid.show_batch()

列表 8.6 中的语句使得可以轻松地看到在 TabularDataLoaders 定义中 procs 参数指定的转换之后,训练模型的数据看起来像什么。图 8.7 显示了此语句的输出。

图片

图 8.7 show_batch() 语句的输出

现在我们已经指定了将用于训练模型的数据,是时候定义和训练模型了。

列表 8.7 定义和拟合 fastai 模型

learn = tabular_learner(dls, metrics=accuracy)             ①
learn.fit_one_cycle(3)                                     ②

① 使用 TabularDataLoaders 对象 dls 将模型定义为 tabular_learner 对象,并使用准确率作为训练的性能度量

② 使用三个周期训练模型

注意,列表 8.7 中定义和拟合模型的语句比我们在第三章中看到的 Keras 模型的定义和拟合语句要简单得多。从这个意义上说,fastai 解决方案的代码与我们在第三章中看到的 XGBoost 解决方案的代码相似。

图 8.8 展示了 fit 语句的输出。对于每个 epoch,列出了训练损失、验证损失和准确率。如果我们比较图 8.4(目标列未显式转换为字符串值时的训练结果)和图 8.8(目标列转换为字符串值时的训练结果)所示的训练结果,很明显,当 fastai 将问题视为分类问题而不是回归问题时,我们得到更好的结果。

图片

图 8.8 fit 语句的输出

在继续讨论 fastai 解决方案的其余部分之前,让我们花点时间讨论训练损失、验证损失和测试损失之间的关系。图 8.8 展示了每个 epoch 的训练和验证损失。训练损失低于验证损失表明模型可能欠拟合,或者仅应用于训练的正则化技术(如 dropout)产生了过大的影响。图 8.8 显示,在第一个 epoch 中,验证损失低于训练损失。对于后续的 epoch,训练损失下降速度比验证损失快,直到最终 epoch 低于验证损失。

以下列表确认了 fastai 将问题视为分类问题,因为损失函数是 CrossEntropyLoss(),这是一个适合分类问题的损失函数。

列表 8.8 显示模型训练中使用的损失函数的语句

learn.loss_func                                         ①
FlattenedLoss of CrossEntropyLoss()                     ②

① 返回用于训练 fastai 模型的损失函数的语句

② 显示用于训练 fastai 模型的损失函数的语句输出

列表 8.8 所示的输出表明,在将目标列设置为包含字符串值后,我们现在得到了所需的损失函数。现在让我们看看 fastai 为模型定义了哪些层。以下列表显示了 summary() 语句,它让我们可以看到构成 fastai 模型的层。

列表 8.9 获取 fastai 模型摘要的语句

learn.summary()

列表 8.9 中语句的输出显示在图 8.9 中,它显示了 summary() 语句的输出,包括构成模型的层以及模型中的参数数量和使用的回调。

图片

图 8.9 summary() 语句的输出

现在,我们已经检查了 fastai 解决方案中的关键代码区域,让我们回顾堆栈图,看看 fastai 堆栈适合的位置。图 8.10 显示了本节示例中的表格数据深度学习堆栈。请注意,该图显示 fastai 既是高级 API 也是表格数据库,因为 fastai 在堆栈中扮演这两个角色。

图片

图 8.10 PyTorch 与 fastai 的堆栈

现在,我们已经审查了 fastai 解决方案中的代码,在下一节中,我们将比较本章 3 节中看到的 Keras 解决方案。

8.2.2 比较 fastai 解决方案与 Keras 解决方案

我们现在已经看到了两个解决 Airbnb 纽约市列表价格预测问题的深度学习解决方案:Keras 解决方案和 fastai 解决方案。在本节中,我们将比较这两个解决方案,并回顾每个解决方案的优缺点。

fastai 和 Keras 的解决方案是很有趣的比较点,因为它们非常不同。Keras 的解决方案包含大量的自定义代码,所有细节都一目了然。fastai 框架从数据集中推断模型的细节,并对默认值做出假设,以便我们不需要指定太多参数就能得到一个可工作的模型。这种做法的好处是 fastai 的代码比 Keras 的代码更加紧凑。特别是,Keras 的解决方案需要多行代码来指定管道和构成模型的层的细节。在 fastai 的解决方案中,我们只需简单地指定要应用于输入数据的转换(如列表 8.6 所示),就可以免费获得管道,而且我们不需要指定构成模型的层。fastai 解决方案紧凑性的缺点是,如果我们不小心,可能会引入微妙的问题。在前一节中,我们看到了如果我们没有明确地将目标列转换为字符串值,那么 fastai 将将目标列中的值解释为连续值,并假设我们想要训练一个回归模型而不是分类模型。

表 8.1 显示了 Keras 和 fastai 解决方案解决 Airbnb 纽约市问题的优缺点总结。如果我们比较两个解决方案的性能,Keras 模型的准确率在 70% 到 74% 之间,而 fastai 模型始终保持在约 81% 的准确率。

表 8.1 Keras 和 fastai 解决方案的优缺点总结

Keras fastai
优点 模型细节透明。使用该框架的大型社区意味着可以轻松找到常见问题的解决方案 框架包括对表格数据模型的显式支持,这意味着代码更加紧凑。框架自动定义管道。框架包括方便的函数,使检查数据集变得容易。
缺点 没有内置对表格数据的支持,这意味着我们需要定义自定义代码来定义模型的管道和层。 框架做出的假设可能导致难以调试的棘手问题。用户社区较小,在部署生产应用程序方面不如 Keras 社区活跃,这意味着可能更难找到问题的解决方案。

让我们再比较一下 Keras 解决方案和 fastai 解决方案的一个方面:底层低级深度学习框架。对于 Keras,底层框架是 TensorFlow,而 fastai 建立在 PyTorch 之上。这意味着我们现在已经审查了使用两个主要深度学习框架的表格数据问题的深度学习解决方案。

Keras 和 fastai 之间的一个相似之处是,它们都是通用的高级深度学习 API。我们已经看到它们都可以用于表格数据问题,但它们也被设计来处理各种数据类型,而不仅仅是表格数据。在下一节中,我们将探讨一个专门为表格数据问题设计的深度学习库:TabNet。我们将检查一个使用 TabNet 解决 Airbnb NYC 问题的解决方案,并将其与 Keras 解决方案进行对比。

8.3 PyTorch 与 TabNet

我们到目前为止考虑的两个工具被设计为通用深度学习库。现在,我们将尝试一个专门为表格数据设计的库:TabNet。同样,我们将跳过问题的介绍,只集中讨论与之前示例不同的解决方案部分。您可以在mng.bz/oK1Z找到此解决方案的代码。

8.3.1 TabNet 解决方案的关键代码方面

在本节中,我们将通过组成 TabNet 解决 Airbnb NYC 列表价格预测问题的关键代码部分。

TabNet 解决方案需要一系列导入,如下所示。

列表 8.10 TabNet 的导入语句

! pip install pytorch-tabnet                                ①
import torch                                                ②
from pytorch_tabnet.tab_model import TabNetClassifier       ③

① 安装 TabNet 的 PyTorch 实现

② 导入 torch 张量库

③ 导入 TabNetClassifier 库。我们将使用这个库来定义模型。

注意,与 fastai 的导入语句不同,列表 8.10 中 TabNet 的导入语句包含一个显式语句来导入 PyTorch 库torch

与没有显式代码定义管道且具有定义数据集的独特代码的 fastai 解决方案不同,TabNet 解决方案使用与 Keras 和 XGBoost 解决方案相同的代码,包括管道的定义。在管道定义之后,TabNet 解决方案使用类似于 XGBoost 的代码将管道输出的 NumPy 数组列表转换为 NumPy 数组列表,如下所示。

列表 8.11 生成 NumPy 数组列表的语句

list_of_lists_train = []
list_of_lists_test = []
list_of_lists_valid = []
for i in range(0,7):                                              ①
    list_of_lists_train.append(X_train_list[i].tolist())
    list_of_lists_valid.append(X_valid_list[i].tolist())
    list_of_lists_test.append(X_test_list[i].tolist())
X_train = np.array(list_of_lists_train).T                         ②
X_valid = np.array(list_of_lists_valid).T                         ③
X_test = np.array(list_of_lists_test).T                           ③
y_train = dtrain.target                                           ④
y_valid = dvalid.target
y_test = test.target

① 定义训练、验证和测试数据集的列表列表(每个特征一个列表)

② 将训练列表的列表转换为 NumPy 数组列表

③ 将验证列表的列表转换为 NumPy 数组列表

④ 定义训练、验证和测试目标集的变量

列表 8.11 中显示的转换是必需的,因为 TabNet 解决方案期望模型输入的形式是 NumPy 数组列表。接下来,TabNet 解决方案包括定义模型的代码。

列表 8.12 定义 TabNet 模型的语句

tb_cls = TabNetClassifier(optimizer_fn=torch.optim.Adam,           ①
                    optimizer_params=dict(lr=1e-3),                ②
                    scheduler_params={"step_size":10,"gamma":0.9}, ③
                    scheduler_fn=torch.optim.lr_scheduler.StepLR,
                    mask_type='entmax' # "sparsemax"
                    )

① 将 TabNetClassifier 对象定义为解决方案的模型并指定 adam 优化器

② 设置模型的学习率

③ 设置学习率调度器的参数

列表 8.12 中显示的模型定义指定了一组超参数,包括优化器和学习率。接下来,TabNet 解决方案包括训练模型的代码。

列表 8.13 训练 TabNet 模型的语句

tb_cls.fit(X_train, y_train,                                       ①
               eval_set=[(X_train, y_train),(X_valid, y_valid)],   ②
               eval_name=['train', 'valid'],                       ③
               eval_metric=['accuracy'],                           ④
               max_epochs=10 , patience=3,                         ⑤
               batch_size=28, drop_last=False)                     ⑥

① 指定训练数据集

② 指定验证数据集

③ 指定训练和验证结果的标签

④ 指定用于跟踪训练性能的指标

⑤ 指定训练运行中的 epoch 数以及模型停止改进后运行多少个 epoch

⑥ 指定批量大小

列表 8.13 中的语句指定了 TabNet 模型的训练,包括早期停止设置,包括patience参数,该参数表示模型停止改进后训练将继续多少个 epoch。

训练语句的输出显示了每个 epoch 的结果,包括损失、训练准确率和验证准确率,以及早期停止的效果,如图 8.11 所示。

图片

图 8.11 TabNet fit 语句的输出

在图 8.11 所示的训练运行输出中,运行了最大 epoch 数(10),因为验证准确率在达到最大 epoch 数之前没有超过 2 个 epoch 的改进。这意味着在fit语句中设置的patience阈值 3 从未被越过,因此训练运行进行了最大数量的 epoch。图 8.12 显示了本节示例中的表格数据深度学习堆栈。

图片

图 8.12 PyTorch 与 TabNet 的堆栈

现在我们已经审查了 TabNet 解决方案中的代码,在下一节中,我们将比较这个解决方案与我们在第三章中看到的 Keras 解决方案。

8.3.2 比较 TabNet 解决方案与 Keras 解决方案

我们现在已经看到了三个解决 Airbnb 纽约市列表价格预测问题的深度学习解决方案:Keras 解决方案、fastai 解决方案和 TabNet 解决方案。在本节中,我们将比较 Keras 解决方案与 TabNet 解决方案,并回顾每个解决方案的优缺点。

Keras 解决方案和 TabNet 解决方案值得比较,因为它们展示了它们底层框架 TensorFlow 和 PyTorch 的一些优点和缺点。Keras 解决方案受益于简单的summary()语句,该语句提供了一个紧凑的列表,列出了构成模型的层。PyTorch 缺乏这个优雅的功能,因此 TabNet 解决方案也缺少这个好处。另一方面,Keras 不提供内置的训练过程控制,因此您必须定义回调以确保您最终从训练运行中获得最佳模型,并且当模型停止改进时,您不会浪费资源运行 epochs。另一方面,PyTorch 默认包含早期停止和保存最佳模型的功能,因此 TabNet 解决方案不需要包含代码来显式定义回调以优化训练过程。表 8.2 显示了 Keras 和 TabNet 与 PyTorch 解决方案对 Airbnb 纽约市问题的优缺点总结。

表 8.2 Keras 和 TabNet 解决方案的优缺点总结

Keras TabNet
优点 使用该框架的大众意味着可以轻松找到常见问题的解决方案简单的总结语句来显示模型中的层 简单的语句来定义和训练模型不需要显式定义的回调来获得早期停止的好处
缺点 没有内置对表格数据的支持,这意味着模型定义需要手动编写。 训练过程要慢得多。Keras 模型训练笔记本运行大约需要 20 秒。TabNet 训练笔记本运行超过 4 分钟。没有一站式总结语句来查看模型的架构

在本节中,我们回顾了 PyTorch TabNet 的 Airbnb 纽约市定价预测解决方案。在下一节中,我们将回顾我们针对 Airbnb 问题的最终方法:使用 Lightning Flash 的 PyTorch。

8.4 使用 Lightning Flash 的 PyTorch

到目前为止,我们已经考虑了 Keras、fastai 和 TabNet PyTorch 解决方案来解决 Airbnb 纽约市房价预测问题。现在让我们转向我们的最终堆栈:Lightning Flash。作为一个用于快速原型设计、基准测试和微调的平台,它还拥有清晰的 API 和出色的文档,Lightning Flash 可能比我们迄今为止探索的堆栈具有潜在优势。

您可以在mng.bz/vKnp找到此解决方案的代码。图 8.13 显示了 fastai 和 Tabnet 在 PyTorch 堆栈上的情况。

图 8.13 fastai 和 Tabnet 在 PyTorch 堆栈上

8.4.1 Lightning Flash 解决方案的关键代码方面

Lightning Flash 解决方案的代码有许多方面与我们迄今为止看到的解决方案不同。在本节中,我们通过模型训练笔记本(mng.bz/4aDR)来突出显示此解决方案最有趣的部分。

要在 Colab 中工作,Lightning Flash 解决方案需要按照特定的顺序执行一系列安装,如列表 8.14 所示。此列表的来源为mng.bz/QDP6

列表 8.14 在 Colab 中使 Lightning Flash 工作所需的安装

!pip install torch==1.8.1+cu102 -f 
https://download.pytorch.org/whl/torch_stable.html          ①
!pip install icevision #==0.9.0a1
!pip install effdet 
!pip install lightning-flash[image]
!pip install git+https://github.com/PyTorchLightning/lightning-flash.git
!pip install torchtext==0.9.1
!pip uninstall fastai -y                                    ②
!curl https://raw.githubusercontent.com/airctic/ \
icevision/944b47c5694243ba3f3c8c11a6ef56f05fb111eb/ \
icevision/core/record_components.py –output \
    /usr/local/lib/python3.7/dist- \
packages/icevision/core/record_components.py                ③

① 系列 pip 安装以获取所需的 PyTorch Lightning 级别

② 为了消除 fastai 和 Lightning Flash 之间可能存在的冲突,需要卸载 fastai。

③ 手动修复当前发布版本 icevision 中的一个 bug

列表 8.14 包含了一系列安装(以及一个卸载)以使 Lightning Flash 在 Colab 中工作。根据经验,我们知道这个非常具体的安装列表是必需的,否则 Lightning Flash 所需的库级别与 Colab 的默认库级别之间将存在冲突。

接下来,需要导入 Lightning Flash 所需的库。

列表 8.15 Lightning Flash 所需的库导入

import torch                                                ①
import flash                                                ②
from flash.tabular import TabularClassificationData, 
from flash.tabular import TabularClassifier                 ③

① 导入 torch 张量库

② 导入 flash 库

③ 导入用于表格分类模型的所需对象

注意,列表 8.15 中的 torch 导入语句与列表 8.11 中用于 TabNet 导入 torch 的语句相同。

接下来,我们定义用于训练模型的 dataset 的参数。

列表 8.16 设置数据集参数

dep_var = 'target'                                          ①
cat=['neighbourhood_group','neighbourhood','room_type']     ②
cont = ['minimum_nights','number_of_reviews',
'reviews_per_month','calculated_host_listings_count']       ③

① 将目标字段设置为训练模型将预测的值

② 定义分类特征的列表

③ 定义连续特征的列表

列表 8.16 中的定义应该会让你想起 fastai 解决方案(列表 8.3)中类似的一块代码,在那里我们定义了目标特征以及分类和连续特征的列表。

接下来,我们使用刚刚定义的值来定义一个TabularClassificationData对象。此对象指定了我们用于训练模型的 dataset 的最小特征。

列表 8.17 定义TabularClassificationData

datamodule = TabularClassificationData.from_csv(
    categorical_fields=cat,                                ①
    numerical_fields=cont,                                 ②
    target_fields="target",                                ③
    train_file='../data/train.csv',                        ④
    val_file='../data/valid.csv',                          ⑤
    predict_file='../data/test.csv',                       ⑥
    batch_size=64
)

① 定义分类特征

② 定义连续特征

③ 定义目标特征

④ 定义训练数据集

⑤ 定义验证数据集

⑥ 定义测试数据集

我们应该解释为什么列表 8.17 中显示的TabularClassificationData对象的定义使用单独的 CSV 文件来存储训练、验证和测试数据集。对于所有其他解决方案,数据集是从一个 pickle 文件中加载的,这个 pickle 文件是数据清理笔记本的输出(mng.bz/XxN9),然后在模型训练笔记本中将数据集分割成训练、验证和测试数据集。Lightning Flash 解决方案是不同的,因为它为数据集的每个部分都有独立的 CSV 文件。这是因为列表 8.14 中显示的非常具体的安装要求与加载包含数据清理笔记本输出数据框的 pickle 文件不兼容。作为权宜之计,我们在一个单独的笔记本中将那个 pickle 文件加载到一个 pandas DataFrame 中,并保存了训练、验证和测试的单独 CSV 文件,这些文件在列表 8.17 中TabularClassificationData对象的定义中可以看到。作为一个练习,你可以更新 Lightning Flash 解决方案的数据清理笔记本,使其将清理后的数据集保存为三个单独的 CSV 文件,而不是一个单独的 pickle 文件。

现在我们已经指定了数据集的详细信息,我们就可以定义和训练模型了,如下面的列表所示。

列表 8.18 设置数据集参数

model = TabularClassifier.from_data(datamodule, 
learning_rate=0.1)                                          ①
trainer = flash.Trainer(max_epochs=3, 
gpus=torch.cuda.device_count())                             ②
trainer.fit(model, datamodule=datamodule)                   ③

① 使用列表 8.18 中定义的TabularClassifierData对象定义模型

② 定义一个 Trainer 对象

③ 拟合模型

列表 8.18 中的训练代码生成了图 8.14 所示的输出。

图片

图 8.14 Lightning Flash 训练过程的输出

注意输出包括模型的验证准确率和训练准确率。图 8.15 显示了本节示例中的表格数据深度学习堆栈。

图片

图 8.15 PyTorch with Lightning Flash 堆栈

Lightning Flash 解决方案的代码包含了一些非常优雅的想法,例如能够在定义数据集整体特性的同一个对象中指定训练、验证和测试数据集。总的来说,Lightning Flash 的 API 易于理解。不幸的是,由于 Lightning Flash 在 Colab 中运行需要如此具体的条件,这些好处被削弱了。否则,Lightning Flash 可能会成为最受欢迎的解决方案,结合了 fastai 的简单性和 Keras 的直观性。

8.4.2 比较 Lightning Flash 解决方案与 Keras 解决方案

我们已经看到了四种解决 Airbnb 纽约市列表价格预测问题的深度学习解决方案:Keras 解决方案、fastai 解决方案、TabNet 解决方案,最后是 Lightning Flash 解决方案。在本节中,我们将比较 Keras 解决方案与 Lightning Flash 解决方案,并回顾每个解决方案的优缺点。

我们已经看到 Lightning Flash 在推出快速、简单的解决方案方面有一些真正的优势。然而,在 Colab 中使用 Lightning Flash 的缺乏明确路径令人担忧,而且人们不禁要问,展示在列表 8.15 中的复杂安装组合将如何继续使在 Colab 中运行 Lightning Flash 实验成为可能。表 8.3 显示了 Keras 和 Lightning Flash 解决方案对 Airbnb 纽约市问题的优缺点总结。

表 8.3 Keras 和 TabNet 解决方案的优缺点总结

Keras Lightning Flash
Pro 使用该框架的大众社区意味着可以轻松找到常见问题的解决方案。简单的总结语句以显示模型中的层 简单的语句来定义和训练模型不需要显式定义管道——只需识别分类和连续列。
Con 没有内置对表格数据的支持,这意味着模型定义需要手动编码。要在 Colab 中运行,需要非常具体的安装顺序和级别。似乎不太被广泛使用,至少在 Colab 中不是。开箱即用的测试准确率不如其他解决方案。

到目前为止,在本章中,我们已经应用了三种用于表格数据的深度学习堆栈来解决 Airbnb 纽约市价格预测问题,并比较了每种解决方案的优缺点。在下一节中,我们将回顾本章中我们练习的所有堆栈的整体比较。

8.5 堆栈的整体比较

我们总共研究了四种用于表格数据的深度学习堆栈。表 8.4 总结了所有这些堆栈在默认 Colab 设置上的性能,为深度学习解决方案选择了标准的 GPU Colab 运行时选项。

表 8.4 深度学习与表格数据堆栈(包括 XGBoost)的准确率和运行时间总结(用于 Airbnb 纽约市列表价格预测问题)

测试准确率 笔记本运行时间
TensorFlow with Keras 81% 16 seconds
fastai with PyTorch 83% 69 seconds
TabNet with PyTorch 81% 568 seconds
Lightning Flash with PyTorch 待定 14 seconds
XGBoost 79% 14 seconds

这里需要注意的是,为了进行这种比较,我们没有对任何方法进行任何调整。我们希望有一个真正的“苹果对苹果”的比较,比较方法在没有额外调整的情况下与 Keras 基线如何比较。在随后的章节中,我们将讨论可以对深度学习解决方案进行的一些调整,以获得最佳结果。

为了准确性,fastai 略优于其他堆栈。在执行时间方面,Lightning Flash 最佳,而 TabNet 最慢。那么,用于表格数据的最佳堆栈是什么?首先,如果我们比较 XGBoost 与任何深度学习解决方案,哪一个更好?我们将在第九章中更详细地回答这个问题,但我们可以现在说,如果是在深度学习和梯度提升之间的直接比较,为了简单性和整体“开箱即用”的性能,“梯度提升”方法如 XGBoost 目前在大多数数据集上优于任何深度学习解决方案。有些用例值得探索深度学习解决方案之一,我们将在第九章中回顾这些用例。

如果你确实决定使用深度学习解决方案来解决表格数据问题,在我们已经探索的四个选项中,你应该使用哪一个?以下是我们的一般建议:

  • 如果你刚开始接触深度学习,并且主要对探索解决方案感兴趣,而不需要立即在生产环境中实施解决方案,那么 fastai 是最好的选择。它是使用最简单的堆栈,拥有足够大的用户社区,你不太可能遇到别人从未见过的难题。Fastai 包含许多便利功能,使处理表格数据变得容易,因此你可以快速原型化你的解决方案。然而,如果你需要快速将解决方案转移到生产环境中,fastai 可能不是你的最佳选择,因为它在生产环境中并不常用。

  • 如果你已经熟悉深度学习,并且需要将应用程序投入生产,我们推荐使用 Keras 堆栈。首先,堆栈的低级组件 TensorFlow 是工业界最常用的深度学习框架。其次,Keras 拥有庞大的用户社区。虽然 Keras 还没有像 fastai 那样对表格数据提供原生支持,但 Keras 足够高级,可以适用于表格数据问题。

随着越来越多的人使用表格数据深度学习,这些建议可能会改变。其他堆栈之一,例如 PyTorch 上的 TabNet,可能会成熟并成为表格数据深度学习的默认选择。然而,从当前最先进的技术来看,我们建议初学者和探索者使用 fastai,而对于更有经验且需要快速进入生产环境的人来说,推荐使用 Keras。

在下一节中,我们将讨论本章中没有探索的堆栈。

8.6 我们没有探索的堆栈

你可能已经问过为什么我们选择了三个特定的堆栈(fastai、PyTorch TabNet 和 Lightning Flash)来解决 Airbnb NYC 问题,而没有探索其他选项,例如 TensorFlow 上的 TabNet、SAINT 或 PyTorch Tabular。在本节中,我们将探讨这个问题,并看看答案告诉我们关于我们用于表格数据深度学习的堆栈有哪些选项。图 8.16 显示了我们没有探索的表格数据深度学习堆栈。

图片

图 8.16 我们未探索的堆栈

未探索的堆栈有一些共同的特点:

  • 所有未探索的堆栈都涉及专门的表格数据库。

  • 我们无法在 Colab 中使任何未探索的堆栈工作,这是我们用来练习本书中代码示例的环境。

我们无法在 Colab 中使未探索的堆栈工作可能是由多种原因造成的。理论上,Colab 可能存在一些限制。然而,Colab 是一个非常常见的探索环境,所以如果一个库无法在 Colab 中被激活,这并不是一个好兆头。所有的堆栈都提供了“hello world”示例来练习堆栈,而对于所有未探索的堆栈,这些示例都产生了错误,大多数与矛盾的 Python 库先决条件有关。可能如果我们更有耐心,或者更深入地调查,我们就能解决这些错误,让基本示例在 Colab 中运行。在我们探索的堆栈中,Keras、fastai 和 PyTorch TabNet 都能在 Colab 中“即开即用”。另一方面,Lightning Flash 在 Colab 中工作之前确实需要一些调整。

Lightning Flash 与未探索的堆栈之间的区别在于,很明显,其他人曾尝试在 Colab 中使 Lightning Flash 工作,我们可以找到非正式文档,展示了我们为了使其在 Colab 中工作需要做的一切。如果你的目标是使用深度学习解决表格数据问题,你希望专注于问题,而不是在 conda 和 pip 安装库时纠结于避免不兼容性。根据这个标准,Keras、fastai、PyTorch TabNet 以及在一定程度上 Lightning Flash 都是 Colab 中表格数据深度学习的可行选择。SAINT、DeepTables、PyTorch Tabular 和 TensorFlow 上的 TabNet 并不是 Colab 中探索的可行选择,因为它们不能立即工作,而且使它们工作的配方要么不存在,要么难以找到。

虽然我们无法使用更多的针对表格数据的专用深度学习库来练习 Airbnb 纽约价格预测问题令人失望,但我们仍然通过探索三个表格数据的深度学习堆栈实现了本章的目标。图 8.17 显示了我们能够探索的所有堆栈。

图片

图 8.17 我们探索的堆栈

本章中我们探索的堆栈,加上 TensorFlow 与 Keras,提供了一套全面的选项:

  • TensorFlow with Keras 是一个稳固的堆栈,拥有庞大的用户社区。对于你在这个堆栈中遇到的几乎所有问题,你都可以确信其他人已经遇到过这个问题并发布了解决方案。这个堆栈在 Colab 中运行得非常完美,因此进行初步调查很容易。TensorFlow with Keras 通常在生产中用于各种应用。然而,Keras 没有内置对表格数据的支持,因此你需要准备好编写一些自定义代码来处理表格数据问题。

  • PyTorch with fastai 设计得易于上手,并且在 Colab 中运行得非常完美,因此你可以期待用这个堆栈以最小的麻烦原型化深度学习中的表格数据问题。fastai 将表格数据视为一等公民,因此你获得内置支持以处理分类和连续特征,你不需要担心手动编码一个管道来确保当你向训练好的模型提供数据以获取预测时,数据会经历与训练模型所使用的数据相同的转换。然而,fastai 代码的简单性可能会付出代价。fastai 自动执行的步骤可能导致一些难以调试的问题(例如,在本章中我们描述的问题,如果目标列没有明确转换为字符串类型,就会训练错误类型的模型),如果你想要偏离 fastai 表格数据结构提供的既定路径,你需要准备好深入研究 fastai API。

  • PyTorch with TabNet 通过在 Colab 中无需任何麻烦地工作,在表格数据特定的库中脱颖而出。像 fastai 一样,使用 TabNet,你只需几行代码就可以在表格数据上定义和训练一个深度学习模型。与 fastai 不同,TabNet 使用的是 Scikit-learn 用户容易理解的常规 API。与其他堆栈相比,TabNet 训练模型所需的时间更长。此外,作为一个专门为表格数据设计的库,TabNet 的用户社区比 fastai 和 Keras 小,这意味着如果你遇到问题,其他人已经遇到过并已在 Stack Overflow 上记录了修复的可能性较小。

  • PyTorch with Lightning Flash 可以快速训练模型,并且一旦运行起来,API 就很简单。Lightning 拥有一个庞大的社区——不如 Keras 那么大,但比 fastai 大。然而,在 Colab 上使用 Lightning Flash 处理表格数据的问题领域并不大,我们刚刚才让 Lightning Flash 在 Colab 上运行起来。

在本章中,我们探索了一系列用于表格数据的深度学习堆栈,比较了每种方法的优缺点,并讨论了为什么我们没有探索一些其他堆栈。在下一章中,我们将回顾用于表格数据的深度学习的最佳实践。

摘要

  • 有两个低级深度学习框架:TensorFlow 和 PyTorch。

  • TensorFlow 在工业中应用得更频繁。

  • PyTorch 是研究中的主要深度学习框架。

  • Keras 是 TensorFlow 的高级 API。

  • fastai 既是 PyTorch 的一般用途、高级 API,也是一个表格数据库。

  • PyTorch Lightning 是一个高级 API,它抽象了一些 PyTorch 的细节。Lightning Flash 是基于 Lightning 的表格数据库。

  • Lightning Flash 和 fastai 都为 PyTorch 提供了一些与 Keras 为 TensorFlow 提供的相同的好处,通过抽象底层 PyTorch 框架的一些方面。

  • TabNet 是一个既适用于 TensorFlow 也适用于 PyTorch 的表格数据库。

  • SAINT 是 TensorFlow 的表格数据库。

  • PyTorch Tabular 是 PyTorch 的表格数据库。

  • 在所有可用的选择中,TensorFlow 与 Keras、PyTorch 与 fastai、PyTorch 与 TabNet 以及 PyTorch 与 Lightning Flash 都是 Colab 上进行表格数据深度学习的有效选项。

第九章:深度学习最佳实践

本章涵盖了

  • 吉隆坡房地产数据集简介

  • 处理数据集

  • 定义深度学习模型

  • 训练深度学习模型

  • 练习深度学习模型

在第八章中,我们考察了一系列用于表格数据深度学习的堆栈。在本章中,我们使用这些堆栈之一,Keras,来探索表格数据深度学习的最佳实践,包括如何准备数据、如何设计模型以及如何训练模型。我们引入了一个新问题来展示所有这些最佳实践:预测吉隆坡的房地产价格是否高于或低于市场中位数价格。我们选择这个数据集是因为它比我们迄今为止使用的 Airbnb 纽约市数据集更复杂、更具挑战性。因此,我们将能够展示更广泛的将深度学习应用于表格数据集的技术。

如果你刚开始训练深度学习模型,本章中的示例将帮助你学习一些最佳实践。如果你在定义和训练深度学习架构方面已有丰富的经验,那么本章作为原则的复习对你可能有益。

9.1 吉隆坡房地产数据集简介

在本章中,我们将使用吉隆坡房地产数据集来解释表格数据深度学习的最佳实践。该数据集包含描述在马来西亚首都吉隆坡出售的物业的记录。图 9.1 展示了使用 df.head() 输出的数据集中记录的样本。本章中展示的代码可在 mng.bz/yWQp 找到。

图片

图 9.1 马来西亚吉隆坡房地产数据集样本

在下一节中,我们将介绍我们需要采取的步骤来清理数据集中每一列。为了准备这些描述,让我们首先回顾数据集中每一列的内容:

  • 位置—该物业所在的街区。

  • 价格—该物业在林吉特(RM,马来西亚货币的传统符号)中的标价。

  • 房间数—该物业的房间数量。此列中的值如“2 + 1”表示该物业有两个卧室和一个不能归类为卧室的房间。

  • 浴室数—该物业中的洗手间数量。

  • 停车位—该物业上的停车位数量。

  • 物业类型—物业的分类,例如“公寓”、“服务式住宅”等。

  • 面积—该物业的尺寸。此列中的值可能指代物业的几个方面,包括整体土地面积或物业内的建筑面积。

  • 装修情况—该物业是否装修。

我们需要回答关于这个数据集的基本问题之一是哪些列是连续的还是分类的。通过查看图 9.1,我们可以发现包含数值值的列的子集。让我们仔细看看这个数据集的子集,看看我们是否能确定哪些列是连续的。图 9.2 显示了数据集中看起来包含数值数据的列的子集的值。

图 9.2 看起来包含数值数据的列的子集

我们可以使用以下命令验证这些列中哪些包含数值数据:

df.describe()

此命令返回 DataFrame 中所有数值列的描述性统计信息,提供了关于每个数值列的数据分布、集中趋势和变异性的洞察。通过检查此命令的输出,您可以确定哪些列确实包含数值值。此命令的输出显示在图 9.3 中。

图 9.3 该数据集describe()的输出

图 9.3 表明只有BathroomsCar Parks是数值列,而PriceRoomsSize不是数值列,尽管它们包含一些看起来是数值的数据。在下一节中,作为处理数据集的一部分,我们将描述从PriceRoomsSize特征中提取数值数据并使其可用于训练模型的步骤。

评估哪些列是分类的还是连续的另一种方法是计算每列的唯一值数量。如果一个列包含大量唯一值,这可能表明我们应该将其视为连续的,如果它包含相对较少的值,这可能表明我们应该将其视为分类的。实际上,具有少量唯一值的特征通常被认为是分类的,因为它们通常代表离散的分类或组,而不是连续的数值测量。但这并不是一个绝对规则,正如我们将看到的那样。df.unique()命令的输出给出了数据集中每列的唯一值数量。

列表 9.1 获取每个列中唯一值的数量

counts = df.nunique()                                  ①
print("unique value counts:\n",counts)

① 返回数据框 df 中每列的唯一值数量

列表 9.1 中命令的输出如下所示:

unique value counts:  
Location          112                                  ①
Price            4280                                  ②
Rooms              43                                  ③
Bathrooms          17                                  ④
Car Parks          21                                  ④
Property Type      99                                  ⑤
Size             6190                                  ⑥
Furnishing          4                                  ⑦
dtype: int64

① 这一列中有限数量的值强化了我们的直觉,即这一列是分类的。

② 这一列是连续的。

③ 这一列需要进一步调查。

④ 这一列是连续的。

⑤ 这一列是分类的。

⑥ 这一列是连续的,但需要特殊处理,正如我们将在本章后面看到的那样。

⑦ 这一列是分类的。

为了总结列表 9.1 中命令的输出关于数据集的信息:

  • 看似分类的列——Location, Property Type, Furnishing.

  • 看起来是连续的列——价格浴室停车场大小。在这些列中,大小也需要进一步调查。

  • 需要进一步调查以确定是否应将其视为连续或分类的列——房间数

其中两列需要进一步调查:“房间数”和“大小”。在下面的数据集处理部分,我们将深入调查这两列,以确定如何处理它们。

现在我们对数据集中的列以及它们提供的信息有了大致的了解,让我们进一步探索数据集的一些其他方面。首先,让我们检查数据集的维度,如下面的列表所示。

列表 9.2 检查数据集维度的代码

print("shape ",df.shape)                                 ①
shape  (53883, 8)                                        ②

① 获取输入数据框维度的语句

② 命令的输出

列表 9.2 显示了该数据集有超过 53,000 行和八个列。在第十二章中,我们将检查数据集行数、数据集列的性质以及深度学习模型对数据的适用性之间的关系。目前,可以安全地说,虽然这个数据集相对较小,但它足够大,我们可以有机会用它来训练一个深度学习模型。

下面的列表包含了列出数据集中每个列缺失值数量的语句。

列表 9.3 列出每个列缺失值的语句

missing_values_count = df.isnull().sum()                  ①
print("missing values before cleanup:\n",missing_values_count)

① 此语句的输出是按列计算的缺失值数量。

下面的命令输出是列表 9.3 的结果:

missing values before cleanup:
Location             0                                    ①
Price              248
Rooms             1706
Bathrooms         2013
Car Parks        17567                                    ②
Property Type       25
Size              1063
Furnishing        6930
dtype: int64

① “位置”是唯一没有缺失值的列。

② 缺失值最多的列是“停车场”列。

从 9.3 列表中的命令输出中,我们可以看到这个数据集中除了一个列之外的所有列都有缺失值。这是我们需要纠正的一些问题的早期迹象,以便使这个数据集准备好用深度学习模型进行训练。相比之下,Airbnb 纽约数据集只有四个列有缺失值:

Missing values:  
id                                    0
name                                 16                  ①
host_id                               0
host_name                            21                  ②
neighbourhood_group                   0
neighbourhood                         0
latitude                              0
longitude                             0
room_type                             0
price                                 0
minimum_nights                        0
number_of_reviews                     0
last_review                       10052                  ③
reviews_per_month                 10052                  ④
calculated_host_listings_count        0
availability_365                      0

① “name”列中的缺失值

② “host_name”列中的缺失值

③ “last_review”列中的缺失值

④ “reviews_per_month”列中的缺失值

如此所示,吉隆坡数据集几乎每个列都有缺失值,这警告我们,正如现实世界数据经常发生的那样,在我们可以开始使用模型之前,我们将不得不进行大量工作来清理这个数据集。

在本节中,我们首次查看了吉隆坡房地产数据集。在下一节中,我们将回顾准备此数据集以训练深度学习模型的过程。

9.2 处理数据集

现在我们已经查看了吉隆坡房地产数据集,并看到它有大量的缺失值,我们有一些想法,在我们可以用它来训练模型之前,它将需要大量的处理。在本节中,我们将逐个查看数据集的特征,以描述所需的清理工作。

在这一点上,我们还没有决定是使用子集还是完整集的列。最初,我们的方法默认使用所有可用特征。随着我们前进并检查模型的性能和行为,我们可能会决定排除一些特征,例如,因为这些特征有未检测到的无效值,这影响了模型的性能。我们努力获得模型训练时可用的一整套干净特征,不仅仅是因为我们无法预测哪些特征会起作用,哪些不会。这也是充分利用数据的一种方式,因为我们将在完全处理数据集的过程中了解更多关于数据集的信息,并将其用于其他项目,使其可重用。这种全面的方法确保了比我们仅清理最终用于训练模型的特征所能获得的结果更好的结果。

本节中突出显示的代码可在mng.bz/MDBQ找到,配置文件可在mng.bz/av1j找到。

我们将首先解决只涉及处理缺失值的列:浴室车位装修物业类型位置。接下来,我们将描述需要比简单地处理缺失信息更多的清理过程的列:价格房间数面积

9.2.1 处理浴室、车位、装修、物业类型和位置列

对于数据集中的一部分列(浴室车位装修物业类型位置),我们可以通过简单地处理缺失值来实现有效的清理。配置文件包含默认值,用于替换这些列的缺失值,这些默认值是基于它们的特性和领域知识确定的。

列表 9.4 定义缺失值的默认替换值

misc_col_dict: # default values to replace missing values for general columns 
   Bathrooms: median                                     ①
   Car Parks: 0                                          ②
   Furnishing: unknown_furnishing                        ③
   Property Type: unknown_property
   Location: unknown_location

① 对于浴室列,将该列的中位数作为默认值

② 对于车位列,将默认值设为零

③ 对于分类列,设置一个占位符类别作为默认值

列表 9.4 显示,我们在该字典中将浴室列的缺失值设置为列的中位数。对于车位列,我们将缺失值设为零。这些差异的原因是由于房地产列表的具体使用案例。住宅物业很少没有洗手间,因此将中位数作为浴室的默认值是有意义的。另一方面,许多物业可能没有停车位。如果一个物业确实有停车位,那么卖方和卖方代理的最佳利益是将其包含在列表中,以确保他们为该物业获得最佳售价。因此,当车位缺失值时,假设该物业没有停车位是有意义的,所以我们在这个列中将缺失值设为零。

在这个字典中,我们还有用于分类列的特定占位符类别值。获取这种简单清理的代码放置在数据清理笔记本的clean_up_misc_cols()函数中。

列表 9.5 替换一般缺失值的函数

def clean_up_misc_cols(df,misc_col_dict): 
 for col in misc_col_dict:                           ①
    if misc_col_dict[col] == 'median':               ②
      df[col] = df[col].fillna(df[col].median())
    else:
      df[col] = df[col].fillna(misc_col_dict[col])   ③
  return(df)

① 遍历具有简单数据清理的列

② 用指定列的中位数替换缺失值

③ 对于其他列,用该列的默认值替换列中的缺失值

在列表 9.5 中展示的clean_up_misc_cols()函数中,使用配置文件中定义的字典,如列表 9.4 所示,用于替换需要简单清理的列中的缺失值。

现在我们已经描述了如何清理仅需要处理缺失值的列,本节接下来的小节将描述剩余三个列(价格房间大小)所需的更密集的数据操作。

9.2.2 处理价格

在我们开始讨论价格列中需要清理的内容之前,让我们回顾一下该列的一些值示例,如图 9.4 所示。

图片

图 9.4 价格列的值示例

图 9.4 中的值显示了一些需要在价格列中处理的项目:

  • 包含表示马来西亚货币林吉特的符号“RM”的值

  • 缺失值

  • 需要转换为浮点数的值

列表 9.6 展示了clean_up_price_col()函数,其中包含一个代码片段,用于有效地清理价格列。

列表 9.6 清理价格列的函数

def clean_up_price_col(df):
  df.dropna(subset=['Price'], inplace=True)            ①
  df['Price'] = \
df['Price'].apply(lambda x:\ 
remove_currency_symbol("RM ",x))                       ②
  df['Price'] = \
pd.to_numeric(df['Price'].\
str.replace(',',''), errors='coerce')                  ③
  return(df)

① 删除在价格列中缺失值的行

② 移除货币符号

③ 移除逗号并将值转换为浮点数

如列表 9.6 所示,clean_up_price_col()函数删除了具有缺失价格值的行。删除这些行的理由(而不是用某个占位符替换缺失的价格值)是,价格是我们模型的目标;因此,保留此类值缺失的行是不可行的。clean_up_price_col()函数的输出是一个价格列中所有行都具有有效数值的数据框。

9.2.3 处理房间

在我们开始讨论房间列中需要清理的内容之前,让我们回顾一下该列的一些值,如图 9.5 所示。

图片

图 9.5 展示了房间列的值示例

图 9.5 中的值展示了需要在价格列中处理的一些问题:

  • 缺失值。

  • 包含多个组成部分值的双桶值。在吉隆坡房地产数据集中,此类值包括如“4 + 1.”之类的字符串表达式。这些值需要解析以提取模型训练中可用的值。

  • 至于缺失值,我们可以选择用零替换 NaN 值。

在这一点上,我们有选择如何处理 Rooms 列的整体选择的冲动:我们应该将其视为分类列还是连续列?为了帮助我们做出决定,让我们回顾 Rooms 列中唯一值的计数:

Rooms              43

Rooms 列大约有 40 个值的情况下,这是一个不算太大的类别数量,如果我们愿意,可以将其转换为分类列。假设我们选择将其视为数值列;让我们检查需要采取的必要步骤。首先,让我们看看前几个唯一值及其计数。

列表 9.7 Rooms 列中最常见值的计数

3           14249      ①
3+1          8070      ②
2            5407
4            5018
4+1          4404
5+1          2340
1            2322
5            2065
2+1          1938
1+1          1191
6             937
Studio        874      ③
6+1           807
4+2           479
3+2           477
5+2           410
7             358
7+1           237
2+2           132
8             125
6+             86      ④

① 一个可以立即转换为数值的值的示例

② 一个可以通过将其视为等式转换为数值的值的示例

③ 一个无法转换为数值的字符串值的示例

④ 一个可以通过一些外推转换为数值的值的示例

如果我们想将 Rooms 视为连续列,我们可以按照列表 9.7 中显示的代表性示例进行处理:

  • 可以直接转换为数值的值,如 3:转换为数值。

  • 可以使用内置的 eval() Python 函数将值 3 + 1 评估为等式。

  • 像这样的值 Studio:用合理的数值替换,例如 1。

  • 像这样的值 6+ 应该被视为 6+1。这不是一个完美的方法——数据集没有明确说明 6+6+1 的缩写,还是表示“6 加上一些未指定的额外房间数”。

注意,数据准备配置文件中的 treat_rooms_as_numeric 设置控制 Rooms 是否作为连续列或分类列准备。如果您将此值设置为 True,则 Rooms 将作为连续列准备;如果设置为 False,则 Rooms 将作为分类列准备。除了更新数据准备配置文件外,您还需要确保 Rooms 在模型训练配置文件中的适当列表中,以便模型训练笔记本知道是否将 Rooms 视为分类或连续,如下所示:

categorical: # categorical columns
      - 'Location'
#     - 'Rooms'
      - 'Property Type'
      - 'Furnishing'
      - 'Size_type_bin'
continuous: # continuous columns
      - 'Bathrooms'
      - 'Car Parks'
      - 'Rooms'
      - 'Size'

现在我们已经查看了对 Rooms 作为数值列进行处理的转换,我们可以查看 clean_up_rooms_col() 函数。

列表 9.8 Rooms 列中最常见值的计数

def clean_up_rooms_col(df,treat_rooms_as_numeric):
  if treat_rooms_as_numeric:                                ①
    print("Rooms treated as numeric")
    df['Rooms'] = df['Rooms'].fillna("0")                   ②
    df['Rooms'] = \
df['Rooms'].apply(lambda x: x+"1" \
if x.endswith('+') else x)                                  ③
    df['Rooms'] = df['Rooms'].replace("Studio", "1")        ④
    df['Rooms']= \
df['Rooms'].replace("20 Above", "21")                       ⑤
    df['Rooms']=\
df['Rooms'].apply(lambda x:eval(str(x)))                    ⑥
    df['Rooms'] = pd.to_numeric(df['Rooms'], 
errors='coerce')                                            ⑦
    # replace missing values with 0
    df['Rooms'] = df['Rooms'].fillna(0)                     ⑧
  else:
    print("Rooms treated as non-numeric")
    df['Rooms'] = df['Rooms'].fillna("unknown_rooms")       ⑨
  return(df)

① 检查参数以确定是否将 Rooms 视为连续列

② 处理像“6+”这样的值

③ 如果一个值以加号结尾,则在字符串末尾加 1。

④ 如果值是工作室,则将其替换为 1。

⑤ 一个可以通过一些外推转换为数值的值的示例

⑥ 将有效的等式字符串替换为等式的数值结果

将列中的所有值转换为数值

⑧ 如果在这些转换中引入了任何 NaN 值,请用 0 替换它们。

⑨ 如果该列被处理为分类,则用占位符值替换缺失值。

列表 9.8 显示,如果我们想将“房间”列作为连续列处理,我们需要执行许多转换。特别是,我们需要用我们最好的猜测值替换一次性非数值值(“工作室”,“20 以上”),并且我们需要用字符串作为方程的评估替换包含+的值。对于以+结尾的值,我们假设字符串以+1结尾是有效的,这样值就可以通过eval()函数作为方程处理。我们对像6+,“工作室”和“20 以上”这样的值的意义做了一些假设。

在现实世界的场景中,我们可能能够访问到主题专家,或者我们可能需要做出类似的猜测,以查看我们是否可以从这些值中获取信号。鉴于“房间”列预期的的重要性(通常房间越多意味着面积越大,这往往可以增加物业价值),真正的考验将是当我们训练模型时,根据将此列作为分类或连续处理的不同方式,比较产生的性能。当我们在本章的后面部分训练模型时,我们将尝试使用“房间”列的两种变体来确定哪一种产生最佳结果。

9.2.4 处理“大小”列

在吉隆坡房地产价格预测问题的所有潜在特征中,“大小”列是最有问题的。在详细说明如何清理这个特定列之前,让我们回顾一下“大小”列中的一些值样本,如图 9.6 所示。

图片

图 9.6 “大小”列中值的示例

除了缺失值外,该列还包含作为字符串的大小类型分类(“建成”或“土地面积”)以及物业面积和面积度量(“平方英尺”)。但这还不是全部。如图 9.7 所示,在“大小”列中,有一些条目用长度乘以宽度表示面积。

图片

图 9.7 “大小”列中用长度乘以宽度表示面积值的示例

“大小”列还有更多的工作要做。图 9.8 显示了“大小”列中值的示例,其中物业面积用各种方程表示。

图片

图 9.8 “大小”列中用复杂方程表示面积值的示例

因此,看起来“大小”列将每个条目中的三个或更多不同信息组合在一起:

  • 大小类型(“土地面积”或“建成”)。

  • 面积可以格式化为数值(例如,6900),长度乘以宽度(例如,20 × 80),或者在一维或两维中包含方程的长度乘以宽度(例如,10 + 24 × 80)。我们假设该属性是矩形的。请注意,这个假设需要由房地产专业人士进行验证,这是一个关键点。为了对数据集进行彻底的分析,必须能够访问一个可以验证假设的主题专家。例如,专家可能会建议使用缺失值替换,如浴室数量的中位数,这是替换Bathrooms列缺失值的良好默认值,或者 0,这是替换Car Parks列缺失值的良好替换。我们已经为数据准备和模型训练笔记本设置了配置文件,以便在假设与主题专家的说法不符时轻松进行更改。通过将这些参数放入配置文件中,我们可以在不接触 Python 代码的情况下更新系统的行为,并系统地运行实验。

  • 面积度量(例如,sq. ft.)。

你现在可能想知道我们是如何能够识别出Size列包含所有这些异常并找到补救措施的。答案是通过对错误进行尝试和修正。首先,我们将尺寸类型从面积和面积度量中分离出来。然后,我们移除了面积度量(因为它总是相同的)。接着,我们迭代剩余的面积值,移除或替换字符,直到每个面积值都可以应用于eval()

这种细致的迭代过程在现实世界的数据集中并不少见。我们选择吉隆坡房地产数据集作为本书这一章节的内容,因为它展示了这些现实世界的挑战,通过解决这些挑战,说明了需要采取的方法,有些是系统的,有些是战术性的,以尽可能从数据集中提取有用的信号。

我们需要将Size列中的三种值分开。我们将丢弃面积度量,因为它总是“平方英尺”的变体,并创建一个新分类列,该列结合了尺寸类型值和面积值所属分箱的标识符。为此,我们需要

  • 通过丢弃无关字符并使用eval()函数将方程的字符串表示转换为方程的数值结果,将面积值转换为数值。也就是说,20 × 80 被替换为 160。

  • 获取结果数值面积值的分箱,并为数据集中的每一行添加一个包含分箱值的新的列。

  • 创建一个新的分类列,该列结合了尺寸类型和分箱编号。

下面的列表显示了clean_up_size_col()函数,该函数实现了本节中描述的更改。

列表 9.9 清理Size列的代码

def clean_up_size_col(df,clean_up_list,size_bin_count):
    df.dropna(subset=['Size'], inplace=True)                  ①
    df['Size'] = df['Size'].str.lower()                       ②
    df[['Size_type','Size']] = \
    df['Size'].str.split(':',expand=True)                     ③
    df['Size'] = df['Size'].fillna("0")                       ④
    df = df[df.Size.str.contains(r'\d')]                      ⑤

    for string in clean_up_list:                              ⑥
        df = df[~df.Size.str.contains(string,na=False)]

    df['Size'] = (df['Size'].str.replace(',','')
                            .str.replace('`','')
                            .str.replace('@','x')
                            .str.replace('\+ sq. ft.','')
                            )                                 ⑦
    df['Size'] = (df['Size'].str.replace(' sq. ft.','')
                            .str.replace('sf sq.ft.','')
                            .str.replace('ft','')
                            .str.replace('sq','')
                            .str.replace("xx","*")
                            .str.replace("x ","*")
                            .str.replace(" x","*")
                            .str.replace("x","*")
                            .str.replace("X","*")
                            .replace('\'','')
                            )                                 ⑧
    df['Size'] = \
    df['Size'].apply(lambda x: remove_after_space(x))         ⑨
    df['Size'] = \
df['Size'].apply(lambda x: eval(str(x)))                      ⑩
    df['Size'] = df['Size'].fillna(0.0)
    print("min is: ",df['Size'].min())
    print("max is: ",df['Size'].max())
    bins = np.linspace(df['Size'].min(), 
    df['Size'].max(), size_bin_count)                         ⑪
    print("bins is: ",bins)
    bin_labels = range(1,size_bin_count+1)                    ⑫
    print("bin_labels is: ",bin_labels)
    df['Size_bin'] = pd.qcut(df['Size'],
    size_bin_count, labels=bin_labels)                        ⑬
    df['Size_type_bin'] = \
    df['Size_type']+df['Size_bin'].astype(str)                ⑭
    return(df)

① 移除缺失尺寸值的行

② 将Size列中的所有值转换为小写

③ 通过将大小类型值移动到新列来分割Size列”

④ 将任何剩余的缺失值替换为 0

⑤ 移除那些大小列不包含数字的行

⑥ 移除那些无法将大小列转换为数值的行

⑦ 替换那些会导致正确处理乘法时出现问题的字符

⑧ 移除剩余的会导致将剩余的Size值作为数值处理的字符

⑨ 移除空格后面的多余字符

⑩ 将剩余的Size值作为方程进行评估

⑪ 为Size值定义区间

⑫ 定义区间名称

⑬ 创建一个新列,包含该行Size值的区间值

⑭ 创建一个新分类列,结合Size类型值和Size_bin

列表 9.9 中显示的clean_up_size_col()函数包含一系列转换,允许将Size列中的面积信息评估为数值。为此,我们需要移除那些无法解释的行。物业的面积对于确定其定价价值是如此基本,因此使用无法提取面积信息的列表中的示例来训练模型是没有意义的;因此,我们简单地删除这些行。接下来,我们需要清理剩余的面积值,以便它们可以作为数值评估,要么因为它们可以直接转换为数值,要么因为它们可以解释为可以应用于eval()函数的方程。

由于列表的面积对其价格有重大影响,因此我们值得投入大量精力以任何可能的方式提取面积信息。为此,我们创建包含与列表大小相对应的区间编号的新分类列。利用这个区间值使得将Size列的面积部分评估为数值成为可能。在这个过程中,我们需要移除那些无法解释的行。物业的面积对其价值是如此基本,因此使用那些无法提取面积信息的列表中的数据来训练模型是没有意义的,因为这些数据与大小类型(建成区土地面积)连接在一起。

图 9.9 显示了修订后的Size列中的值示例,以及由列表 9.9 中的函数创建的新列(Size_typeSize_binSize_type_bin)。

图 9.9 展示了从Size列生成的新列中的值示例

让我们看看图 9.9 中的每一列:

  • Size—替换Size列中的原始值是单个连续值,对应于原始Size列中的土地面积。

  • Size_type—这个新列包含原始Size列值中的大小类型(建成区土地面积)部分。

  • Size_bin—这个新列包含Size列值所属的分区号。请注意,对于图 9.9 中的示例,具有最小Size值的行具有最小的分区号,而具有最大Size值的行具有最大的分区号。

  • Size_type_bin—这个新列包含其他两个新列值的组合。

考虑原始Size列的一个特定值以及它是如何被处理以创建新SizeSize_typeSize_binSize_type_bin列中的值的,如图 9.10 所示。

图片

图 9.10 原始Size列和新列中的值

我们从一个包含两种关键信息混合在一起的Size列开始,该列包含数值数据(财产面积),有时是数字,有时是方程。在应用清理步骤后,我们将Size列拆分为四个列:一个连续的(Size)和三个分类的(Size_typeSize_binSize_type_bin),我们可以从中选择来训练模型。

9.3 定义深度学习模型

在本节中,我们将通过定义吉隆坡房地产价格预测模型的深度学习模型的代码。首先,我们将比较本章中使用的定义模型的方法与第三章中使用的自定义层方法进行比较。然后,我们将详细审查构成模型定义的代码。最后,我们将通过讨论使用 Keras 预处理层作为深度学习表格数据的最佳实践的理由来结束本节。

本节中的代码可在mng.bz/gaBe找到,配置文件在mng.bz/ey19

9.3.1 对比自定义层和 Keras 预处理层方法

从第三章开始,我们研究了基于 Keras 的 Airbnb 纽约市房价预测问题的解决方案。在那个解决方案中,我们从头开始创建了一个深度学习模型和相关管道。也就是说,我们没有使用 TensorFlow 或 Keras 为表格数据专门设计的任何函数。在为吉隆坡房地产问题提出的解决方案中,我们将转换方向,利用 Keras 预处理层来简化表格数据集的处理。为了提供两种方法之间差异的背景,让我们进行比较:

  • 使用自定义层—这是我们第三章中用于 Airbnb 纽约市房价预测问题的方法,也是第八章中与其他深度学习方法进行比较的基线。

  • 使用 Keras 预处理层—这是我们本章以及本书后续章节将使用的方法。

见表 9.1。

表 9.1 使用自定义类和使用 Keras 预处理层比较吉隆坡的实数解

使用自定义层 使用 Keras 预处理层
管道 基于 Scikit-learn Pipeline 类的自定义管道类(mng.bz/pKP5);需要在单独的文件中定义类,以便在训练和推理时使用(mng.bz/OBxK);定义、训练、调用和保存管道的复杂代码。 标准的、现成的 Keras 预处理层,可在mng.bz/YD1o找到。定义、训练和调用管道的代码更加简单且更健壮。
模型定义 一组复杂的层,指定用于分类、连续和文本输入 一组简单的层,能够处理通用的分类和连续输入

图 9.11 展示了使用 Keras 预处理层构建的吉隆坡房地产价格预测模型。

图 9.11 带有 Keras 预处理层的吉隆坡房地产价格预测模型

图 9.12 展示了使用自定义层构建的吉隆坡房地产价格预测模型;代码可在mng.bz/KGeP找到。

图 9.12 带有自定义层的吉隆坡房地产价格预测模型

图 9.12 中带有自定义层的 Keras 模型的整体结构看起来比图 9.11 中带有 Keras 预处理层的模型结构更复杂,但内容很多,所以不容易看到细节。让我们放大只处理Size列的层,以更具体地了解这两个架构如何不同。图 9.13 显示了带有 Keras 预处理层的模型中的Size列的层。

图 9.13 Size列的 Keras 预处理层

我们可以看到,在Size输入层和最终输出层之间有四个层。将这个与图 9.14 中显示的带有自定义层的 Keras 模型的Size列的层进行比较。

图 9.14 Size列的自定义层

对于使用自定义层的 Keras 模型,在 Size 输入层和最终输出层之间有七个层,而使用 Keras 预处理层的模型有四个层。由于在具有自定义层的模型中层的连接方式,存在一系列逐层进行的单个连接操作。相比之下,具有 Keras 预处理层的模型有一个单一的连接层,它将来自每个输入的所有层拉在一起。这种在 Size 列的输入和最终层之间中间层数量的差异反映了与具有 Keras 预处理层的模型相比,具有自定义层的 Keras 模型的整体额外复杂性。

9.3.2 检查使用 Keras 预处理层定义模型代码

通常,使用 Keras 预处理定义和训练模型的代码比之前章节中提到的使用自定义层和基于 Scikit-learn 的管道的深度学习代码更简单、更流畅。然而,为了换取基于 Keras 预处理层的模型简化,我们需要付出一定的代价。为了避免在用 model.save() 和模型保存回调时出现错误,我们需要确保所有列名都是小写且不包含空格(可以使用 snake_case 命名约定来实现这一点)对于使用 Keras 预处理层的模型。以下列表中的代码自动完成此操作。

列表 9.10 将列名转换为小写并替换空格

merged_data.columns = \
merged_data.columns.str.replace(' ', '_')               ①
merged_data.columns  = merged_data.columns.str.lower()  ②
config['categorical'] = \
[x.replace(" ", "_") for x in \
config['categorical']]                                  ③
config['continuous'] = \
[x.replace(" ", "_") for x in \
config['continuous']]                                   ④
config['categorical'] = \
[x.lower() for x in config['categorical']]              ⑤
config['continuous'] = \
[x.lower() for x in config['continuous']]               ⑥

① 对于输入数据框中的列,将列名中的空格替换为下划线

② 在输入数据框中将列名转换为小写

③ 对于分类列名的列表,将空格替换为下划线

④ 对于连续列名的列表,将空格替换为下划线

⑤ 将分类列名的列表转换为小写

⑥ 将连续列名的列表转换为小写

列表 9.10 中的代码替换了输入 DataFrame 和分类和连续列名列表中的空格。例如,变量名 Car Parks 变为 Car_Parks

以下列表展示了 df_to_dataset 函数的定义,该函数为使用 Keras 预处理层的模型创建输入管道。

列表 9.11 创建输入管道的函数

# function from https:
//www.tensorflow.org/tutorials/structured_data/preprocessing_layers
def df_to_dataset(dataframe, shuffle=True, batch_size=32):
  df = dataframe.copy()
  labels = df.pop('target')                              ①
  df = {key: value[:,tf.newaxis] for key, 
    value in dataframe.items()}                          ②
  ds = tf.data.Dataset.from_tensor_slices((dict(df), 
    labels))                                             ③
  if shuffle:
    ds = ds.shuffle(buffer_size=len(dataframe))          ④
  ds = ds.batch(batch_size)                              ⑤
  ds = ds.prefetch(batch_size)                           ⑥
  return ds

① 从数据框的本地副本中获取目标列

② 创建一个新的字典 df,具有相同的键和值,但添加了一个新的轴

③ 使用 from_tensor_slices 方法创建 TensorFlow Dataset ds

④ 对数据集的元素进行洗牌,以避免数据具有某些内在排序时出现过拟合

⑤ 将数据集的元素分组为大小为 batch_size 的批次

⑥ 对数据集应用 prefetch() 操作

列表 9.11 中展示的 df_to_dataset 函数,该函数直接取自 www.tensorflow.org/tutorials/structured_data/preprocessing_layers,将被应用于训练、验证和测试数据集,以将它们转换为 tf.data.Dataset 对象,然后对数据集进行洗牌和分批。请注意,数据集 ds 的定义接受两个参数:dict(df),输入数据框的字典版本,以及 labels,输入数据框中的目标值。另外,请注意,将 prefetch() 应用于数据集允许在加载下一个批次的同时,通过叠加一个批次的预处理和模型执行来更有效地处理数据集。

以下列表定义了 get_normalization_layer() 函数,该函数为给定特征定义了一个归一化层。

列表 9.12 创建连续列的归一化层

# function from 
https://www.tensorflow.org/tutorials/structured_data/preprocessing_layers
def get_normalization_layer(name, dataset):
  normalizer = layers.Normalization(axis=None)            ①
  feature_ds = dataset.map(lambda x, y: x[name])          ②
  normalizer.adapt(feature_ds)                            ③
  return normalizer

① 定义一个归一化对象

② 从输入数据集中创建仅包含输入特征的数据集

③ 使用指定的输入特征训练归一化器

列表 9.12 中定义的 get_normalization_layer() 函数,该函数直接取自 www.tensorflow.org/tutorials/structured_data/preprocessing_layers,将被应用于我们想要用于训练模型的全部连续列。此函数使用围绕 0 中心且标准差为 1 的分布来缩放输入值。有关此函数中定义的归一化对象的详细信息,请参阅 mng.bz/9YDx。注意,对于梯度提升解决方案,归一化不是必需的。

以下列表展示了 get_category_encoding_layer() 函数的定义,该函数指定了给定分类列的编码层。

列表 9.13 创建分类列的编码层

# function from 
https://www.tensorflow.org/tutorials/structured_data/preprocessing_layers
def get_category_encoding_layer(name, dataset, dtype, max_tokens=None):
  if dtype == 'string':                                     ①
    index = layers.StringLookup(max_tokens=max_tokens)
  else:
    index = layers.IntegerLookup(max_tokens=max_tokens)
  feature_ds = dataset.map(lambda x, y: x[name])            ②
  index.adapt(feature_ds)                                   ③
  encoder = \
layers.CategoryEncoding(num_tokens= \
index.vocabulary_size())                                    ④
  return lambda feature: encoder(index(feature))            ⑤

① 根据列是否为字符串列创建层

② 从输入数据集中创建仅包含输入特征的数据集

③ 学习列的可能值集合,并为它们分配一个固定的数值索引

④ 对数值索引进行编码

⑤ 对索引应用多热编码

列表 9.13 中定义的 get_category_encoding_layer() 函数,该函数直接取自 www.tensorflow.org/tutorials/structured_data/preprocessing_layers,将被应用于我们想要用于训练模型的全部分类列。在此函数中,如果列是字符串列,则生成一个将字符串转换为数值索引的层;否则,创建一个将整数值转换为数值索引的层。

以下列表展示了将 df_to_dataset() 函数应用于训练、验证和测试数据集的代码。

列表 9.14 将 df_to_dataset() 应用到训练、验证和测试数据集

# function from 
# https://www.tensorflow.org/tutorials/structured_data/preprocessing_layers
train_ds = df_to_dataset(train, batch_size=batch_size)      ①
val_ds = df_to_dataset(val, shuffle=False, 
    batch_size=batch_size)                                  ②
test_ds = df_to_dataset(test, shuffle=False, 
    batch_size=batch_size)                                  ③

① 生成训练数据集

② 生成验证数据集

③ 生成测试数据集

一旦应用了列表 9.14 中的代码,我们将获得数据集并准备好开始训练过程。

以下列表显示了将 df_to_dataset() 函数应用于训练、验证和测试数据集的代码。

列表 9.15 定义连续和分类列的层

all_inputs = []                                              ①
encoded_features = []                                        ②

for header in config['continuous']:                          ③
  numeric_col = tf.keras.Input(shape=(1,), name=header)
  normalization_layer = get_normalization_layer(header, train_ds)
  encoded_numeric_col = normalization_layer(numeric_col)
  all_inputs.append(numeric_col)
  encoded_features.append(encoded_numeric_col)

for header in config['categorical']:                         ④
  categorical_col = tf.keras.Input(shape=(1,), name=header, dtype='string')
  encoding_layer = get_category_encoding_layer(name=header,
                                               dataset=train_ds,
                                               dtype='string',
                                               max_tokens=5)
  encoded_categorical_col = encoding_layer(categorical_col)
  all_inputs.append(categorical_col)
  encoded_features.append(encoded_categorical_col)

① 输入特征列表

② 编码特征列表

③ 为每个连续列创建一个归一化层

④ 为每个分类列创建一个编码层

在列表 9.15 中的代码中,对于每个连续列,get_normalization_layer() 为该列定义了一个归一化层。新层被添加到 encoded_features 列表中,列名被追加到 all_inputs 列表中。对于每个分类列,get_category_encoding_layer() 为该列定义了一个编码层。新层被添加到 encoded_features 列表中,列名被追加到 all_inputs 列表中。一旦运行了列表 9.15 中的代码,all_features 列表包含以下值:

[<KerasTensor: shape=(None, 1) dtype=float32 (created by layer 'Bathrooms')>,
 <KerasTensor: shape=(None, 1) dtype=float32 (created by layer 'Car_Parks')>,
 <KerasTensor: shape=(None, 1) dtype=float32 (created by layer 'Rooms')>,
 <KerasTensor: shape=(None, 1) dtype=float32 (created by layer 'Size')>,
 <KerasTensor: shape=(None, 1) dtype=string (created by layer 'Location')>,
 <KerasTensor: shape=\
(None, 1) dtype=string \
(created by layer 'Property_Type')>,
 <KerasTensor: shape=(None, 1) dtype=string (created by layer 'Furnishing')>,
 <KerasTensor: shape=\
(None, 1) dtype=string \
(created by layer 'Size_type_bin')>]

如果我们查看配置文件中的设置,这些设置指定了用于训练模型的连续和分类列,我们会看到它们与 all_features 列表中指定的层相匹配:

categorical: # categorical columns
      - 'Location'
      - 'Property Type'
      - 'Furnishing'
      - 'Size_type_bin'
continuous: # continuous columns
      - 'Bathrooms'
      - 'Car Parks'
      - 'Rooms'
      - 'Size'

现在我们已经定义了与输入数据集的列相对应的层,我们可以定义模型。以下列表显示了定义模型的代码。

列表 9.16 定义模型的代码

all_features = \
tf.keras.layers.concatenate(encoded_features)         ①
x = \
tf.keras.layers.Dense(32, 
    activation="relu")(all_features)                  ②
x = tf.keras.layers.Dropout(dropout_rate)(x)          ③
output = tf.keras.layers.Dense(1)(x)                  ④

model = tf.keras.Model(all_inputs, output) 

① 连接列表 9.15 代码中创建的编码特征列表中的特征

② 将一个密集层添加到模型中。它对应于图 9.15 中的层 2。

③ 将一个 dropout 层添加到模型中。它对应于图 9.15 中的层 3。

④ 将一个密集层添加到模型中。它对应于图 9.15 中的层 4。

列表 9.16 包含使用 concatenate() 函数连接 encoded_features 列表中的特征。它对应于图 9.15 中的层 1。

一旦应用了列表 9.16 中的代码,模型的层集就定义了。如图 9.15 所示,每个分类列都有一个输入层、一个 StringLookup 层和一个 CategoryEncoding 层,每个连续列都有一个输入层和一个归一化层。

图 9.15 模型层的图示

图 9.15 显示了以默认垂直排列渲染的层。要获得水平排列,我们可以在 plot_model 函数中使用 rankdir='LR' 参数:

tf.keras.utils.plot_model(model, show_shapes=True, rankdir="LR")

此外,请注意,图 9.15 中所示的架构对于吉隆坡数据集中的适度特征数量来说足够简单。如果你正在处理具有大量特征的数据集,架构将会变得复杂。此外,本章中描述的用于分类列的方法依赖于独热编码。这种方法适用于吉隆坡房地产数据集,因为任何分类列中唯一值的最大数量仅略超过 100:

Rooms              18
Location          108
Property Type      97
Furnishing          5
Size_type_bin      20

然而,如果我们有一个数据集,其中某些分类列包含数百或数千个值,使用独热编码可能会导致内存问题。在这种情况下,我们可能需要考虑使用嵌入来处理分类列,就像我们在具有自定义层的 Keras 模型中处理分类列那样。

在本节中,我们回顾了定义模型的代码。在下一节中,我们将讨论训练模型的代码。

9.4 训练深度学习模型

在上一节中,我们检查了定义模型的代码,并探讨了模型中的层是如何基于输入列构建起来的。在本节中,我们将描述训练上一节中定义的模型的过程。

本节中的代码可在 mng.bz/gaBe 找到,配置文件可在 mng.bz/ey19 找到。以下列表显示了编译和训练模型所需的代码。

列表 9.17 编译和训练模型的代码

model.compile(optimizer=config['hyperparameters']['optimizer'],
              loss=tf.keras.losses.BinaryCrossentropy(from_logits=True),
              metrics=config['metrics'])                      ①
if config['general']['early_stop']:
   callback_list, save_model_path = set_early_stop(es_monitor, es_mode)
   model.fit(train_ds, 
             epochs=config['hyperparameters']['epochs'],    
             validation_data=val_ds,
             callbacks=callback_list)                         ②
else:
   model.fit(train_ds, 
             epochs=config['hyperparameters']['epochs'], 
             validation_data=val_ds)                          ③

① 使用配置文件中的超参数编译列表 9.16 中定义的模型

② 创建回调列表并在 fit 调用中使用它来训练模型

③ 调用 fit 来训练模型,但不使用回调列表

列表 9.17 显示超参数值来自配置文件。通过在配置文件中定义超参数,我们可以调整它们,并重新运行模型训练笔记本,而无需触摸 Python 代码。将配置值保存在与 Python 代码分开的文件中,可以减少由于触摸代码而引起的回归风险,并使跟踪实验结果变得更加容易。

还请注意,我们可以调用 fit() 来使用回调训练模型。列表 9.17 中的代码显示,如果我们想控制训练过程,可以使用一组回调来调用 fit(),或者不使用回调,让训练过程无中断地运行所有指定的周期。使用回调来控制训练过程是使用 Keras 的深度学习模型的最佳实践,因为它允许我们更有效地使用资源来训练模型。我们不必运行所有指定的周期,并从最后一个周期中发生的情况获取模型的性能,而是可以使用回调在给定数量的周期后停止训练过程。我们可以确保训练过程的最终结果是整个训练过程最优的。

现在模型已经训练完成,以下列表展示了如何快速评估训练模型。

列表 9.18 获取训练模型快速评估的代码

loss, accuracy = model.evaluate(test_ds)                  ①
print("Test Loss", loss)
print("Test Accuracy", accuracy)
Test Loss 0.2754836082458496                              ②
Test Accuracy 0.8765323758125305                          ③

① 使用 evaluate()函数获取训练模型在测试数据集上的损失和准确性

② 测试本次训练运行的损失

③ 测试本次训练运行的准确性

运行列表 9.18 的输出显示了使用训练模型在测试集上获得的可观结果。

9.4.1 训练过程中的交叉验证

在第四章中,我们将交叉验证(即,分割数据集并在不同的数据子集上重复训练模型,使用不同的保留集来验证模型)作为经典机器学习方法的最佳实践进行了强调。对于使用表格数据的深度学习,我们需要担心交叉验证吗?简短的答案是“不”。在 Keras 中,默认情况下,当我们使用指定用于训练、验证和测试的数据集比例进行重复训练运行时,数据集的子集被随机分配到每个类别中,因此如果我们进行重复训练运行,我们将自然地获得交叉验证的好处(mng.bz/jpPz)。

9.4.2 训练过程中的正则化

在第四章中,我们还强调了一种技术,即正则化(即通过降低模型的复杂性来防止过拟合,以改善其泛化性能)。在使用深度学习时,我们是否需要像在经典机器学习中那样担心过拟合?答案是“绝对需要”,你可以在图 9.16 中看到模型图的一部分,它突出了模型中专门用于避免过拟合的部分,即 dropout 层。dropout 层随机将输入设置为 0 以减少过拟合(mng.bz/W2z4)。

图片

图 9.16 深度学习模型中的正则化

9.4.3 训练过程中的正则化

正则化是指调整特征之间的连续值,使它们的值落在一致的范围之内。在第四章中,我们介绍了在经典机器学习中使用这种技术(也称为标准化)。在列表 9.12 中,我们展示了使用正则化层的吉隆坡解决方案代码。

在吉隆坡数据集中有一些连续列的值范围差异很大。例如,在吉隆坡数据集中,Bathrooms特征的范围在 0 到 20 之间,而Size的范围从 0 到 1100 万。保留具有如此不同范围的连续列可能会使训练过程效率降低。图 9.17 显示了使连续特征的值范围落在更一致范围内的正则化层。

图片

图 9.17 深度学习模型中的正则化

归一化层将每个连续列中的值调整到以 0 为中心、标准差为 1 的分布(mng.bz/0QKp)。

在本节中,我们回顾了训练吉隆坡房地产价格预测模型的代码,以及模型中展示的一系列最佳实践:正则化以避免过拟合,以及归一化以将连续列的值带入一致的范围内。在下一节中,我们将通过使用全新数据点练习训练好的深度学习模型来整合我们的所有工作。

9.5 练习深度学习模型

到目前为止,在本章中,我们已经准备好了吉隆坡房地产数据集,使用 Keras 预处理层定义了一个深度学习模型,并训练了该模型。在本节中,我们将练习训练好的模型。

本节中的代码可在mng.bz/gaBe找到,配置文件可在mng.bz/ey19找到。

9.5.1 在一些新的数据点上练习训练好的模型的理由

模型部署的过程很复杂,我们将在第十章和第十一章中更深入地探讨它。完成初始部署可能是一项重大投资,因此我们希望确保部署过程顺利,如果不需要,我们就不必回溯到流程的早期阶段。如果我们能尽快在几个现实世界的数据点上练习训练好的模型,我们就可以避免在流程后期出现更多问题。尽快在新的数据点上练习训练好的模型有两个关键的好处:

  • 这是一种检测数据泄露(见mng.bz/zZXw)的简单方法。从数据科学项目的开始到训练好的模型部署之间可能需要数月时间,尤其是如果你正在处理深度学习的需求。在这段时间里,可能会失去对一旦部署后训练模型将可用数据的准确追踪。使用少量数据点练习训练好的模型是揭示潜在数据泄露的简单方法,在项目进行得太远之前。这样做的原因是,练习模型将迫使你思考新的数据点看起来是什么样子。

  • 它提供了一种验证模型在测试数据集上性能的方法。例如,如果你使用来自吉隆坡房地产市场的全新数据点来练习训练好的模型,你就能感受到模型在测试数据集上的性能是否与其在全新数据点上的性能一致。

为了说明在几个新的、真实世界示例上训练训练好的模型如何有助于防止数据泄露,让我们看看我们如何训练模型来预测一个全新的列表的售价是否会高于或低于吉隆坡的中位数。对于吉隆坡房地产数据集,假设有一个名为 上市周数 市场上 的列,它包含了房产在出售之前在市场上停留的周数。如果我们将这样的列包含在模型的训练中,当我们尝试使用少量新数据点来训练模型时会发生什么?图 9.18 说明了我们会遇到的问题。

图 9.18 使用新数据点验证用于训练模型的特征

在图 9.18 的左侧,我们有用于训练模型的特征列表,这些特征将按照配置文件中的指定方式指定,包括 上市周数 市场上 特征。右侧是吉隆坡真实房地产列表的一个摘录。如果我们想要能够使用训练好的模型来获取实际房地产列表的价格预测,那么我们应该能够在右侧的列表中找到左侧所有特征的值。

如图 9.18 中的数字所示,对于我们将用于训练模型的多数特征,在吉隆坡的真实房地产列表中都有一个值。然而,有两个特征我们无法从房地产列表中获取值:

  • 停车场 — 这个特定的列表提到了整个建筑的覆盖停车场,但没有包括关于这个特定单元的停车位信息。为了快速测试,我们可以假设这个列表的 停车场 值应该是 0。

  • 上市周数 市场上 — 列表中不包括在出售之前该房产在市场上停留的周数,因为我们不知道这个值直到列表出售之后。我们无法在训练过程中包含 上市周数 市场上

既然我们已经解释了为什么在少量真实数据点上训练模型很重要,我们将在下一小节中回顾训练模型的相关代码。

9.5.2 在一些新数据点上训练训练好的模型

既然我们已经确立了在新的数据点上训练训练好的模型的好处,让我们回顾执行此操作的代码。

以下列表显示了将模型保存到文件系统、通过指定用于训练和在新数据点上训练模型的全部特征的值的代码。

列表 9.19 在新数据点上训练训练好的模型的代码

model_file_name = \
os.path.join(get_model_path(),config['file_names']['saved_model'])
model.save(model_file_name)                                      ①
reloaded_model = \
tf.keras.models.load_model(model_file_name)                      ②
sample = {                                                       ③
    'location': 'Dutamas, Kuala Lumpur',
    'rooms': 7.0,
    'property_type': 'Serviced Residence (Intermediate)',
    'furnishing': 'Partly Furnished',
    'size_type_bin': 'built-up 1',
    'bathrooms': 1.0,
    'car_parks': 1.0,
    'size': 16805.0,
}
input_dict = \
{name: tf.convert_to_tensor([value]) for name, 
value in sample.items()}                                         ④
predictions = reloaded_model.predict(input_dict)                 ⑤
prob = tf.nn.sigmoid(predictions[0]) 

print(
    "This property has a %.1f percent probability of "
    "having a price over the median." % (100 * prob)
)

① 将训练好的模型保存到文件系统

② 重新加载已保存的模型

③ 定义一个新的数据点

④ 将新的数据点放入模型期望的格式

⑤ 获取新数据点的预测结果

如果我们运行列表 9.19 中显示的代码,我们会得到以下类似的输出:

This property has a 99.4 percent probability
of having a price over the median.

模型似乎确信这个挂牌价将超过中位数。你能想到一个原因吗?可能是因为这个房产的面积非常大——超过 16,000 平方英尺。假设我们用 1,500 平方英尺的面积值重新运行这段代码,而将其他所有值保持不变。我们得到以下输出:

This property has a 47.5 percent probability 
of having a price over the median.

这似乎与我们的预期大致相符。当我们把面积减少到一个更合理的值,并保持其他所有特征值不变时,我们得到的预测值大致符合我们的预期。通过列出 9.19 所示的代码,我们可以练习各种数据点。例如,图 9.18 中显示的吉隆坡真实数据点的数据点可能如下所示:

sample = {
    'location': 'Sentul, Kuala Lumpur',
    'rooms': 3.0,
    'property_type': 'Condominium For Sale',
    'furnishing': 'Partly Furnished',
    'size_type_bin': 'built-up 1',
    'bathrooms': 2.0,
    'car_parks': 0.0,
    'size': 950.0,
}

如果我们对这个数据点进行预测,输出结果如下:

This property has a 12.6 percent probability 
of having a price over the median.

这个预测似乎偏低。我们可以检查输入数据集中的中位价值,看看它与预测值相比如何:

merged_data['price'].median()
980000.0

因此,从输入数据集中得出的中位价是 RM 980,000,而全新房产的挂牌价是 RM 450,000。这一个数据点并不能证明模型是好的——这个数据集是在四年前收集的,而这个新挂牌的挂牌价可能远低于实际成交价。然而,通过尝试几个数据点,包括一些在重要特征(例如,面积,以及一个全新的现实世界数据点)上的极端值,我们可以得到一些保证,即模型在测试集上的性能并非偶然。通过一个房地产挂牌的现实世界例子,我们已经证明模型中的管道能够处理现实世界数据,并且模型没有在我们在想要得到预测时不可用的特征上训练过。

在获取全新挂牌的预测时,有一个复杂的问题与我们在准备数据时创建size_type_bin列的方式有关。回想一下,这个列中的值是Size_type值(建成面积土地面积)和面积分箱值的组合。我们合理地猜测新挂牌的Size_type值,但获取bin值需要一些工作。现在我们已经看到了一个现实世界的值,需要考虑的一点是是否需要这个组合特征。也许我们可以重构这个特征,使其只包含Size_type值,而不包含面积的bin值。毕竟,挂牌面积的信号已经在Size列中。在下一章中,当我们通过深度学习解决方案的端到端过程处理表格数据问题时,我们将重新审视这个问题。

摘要

  • 要有效地训练模型,清理数据集是必要的。在本章中,我们考察了清理数据集的各种过程。

  • 深度学习算法无法处理缺失值,因此我们必须填充值(例如,连续列的平均值)或删除记录。

  • 表示数值的字符串(如24 x 12)可以使用内置的 Python eval()函数转换为数值。通过将此类字符串转换为数值,我们可以提取出有用的信息,从而提高模型的表现。

  • 包含多种类型数据(如原始Size列)的列可以被分离成具有不同数据类别的列。通过将此类列分离成仅包含一种数据的独立列,我们可以将每个新列用作特征来训练模型。

  • 通过利用 Keras 内置的后处理层,我们可以定义一个比从头开始编码表格数据特征的 Keras 模型更简单、更容易维护的深度学习模型。

  • Keras 回调允许我们控制训练过程,并确保当模型不再改进时,我们不会在训练迭代上浪费资源。回调还确保我们最终得到的模型是训练过程中达到的峰值性能。

  • 许多经典机器学习的最佳实践也适用于深度学习,包括正则化以避免过拟合和归一化以调整连续列中的值,使其落在一致的范围内。

  • 在一些实际示例上训练训练好的模型有助于避免数据泄露并验证模型在测试数据集上的性能。

第十章:模型部署

本章涵盖

  • 在我们的本地系统上部署深度学习模型到简单的网络应用程序

  • Google Cloud 关键概念的介绍

  • Vertex AI 简介,Google Cloud 中的机器学习环境

  • 使用 Vertex AI 端点部署深度学习模型

  • 适配网络应用程序以使用 Vertex AI 端点

  • 使用 Gemini 为 Google Cloud 获取生成式 AI 辅助

在第九章中,我们回顾了使用表格数据训练深度学习模型的一组最佳实践,并介绍了吉隆坡房地产价格预测问题作为一个具有混合类型特征的挑战性表格问题。在本章中,我们将使用第九章中训练的模型,并在一个简单的网络应用程序中部署它。首先,我们将本地部署它——也就是说,将网络服务器和训练好的模型都放在我们的本地系统上。接下来,我们将介绍 Google Cloud 作为部署我们模型的另一种方式。实际上,我们将训练好的模型部署到 Vertex AI 中,这是 Google Cloud 中的机器学习环境。最后,我们将探讨如何在 Google Cloud 上使用 Google 的生成式 AI 助手 Gemini。本章中描述的代码可在 mng.bz/6e1A 获取。

10.1 简单的 Web 部署

一旦我们训练了一个深度学习模型,并遵循了第九章中描述的最佳实践,我们仅仅只是触及了从模型中获取价值的过程的表面。以我们在第九章中训练的吉隆坡房地产价格预测模型为例。这个模型对于想要为他们的客户提供定价建议的房地产经纪人来说可能很有用。这个模型也可能对想要将他们的房产推向市场以了解他们可能期望从其房产中获得的价值的房主有用。最后,这个模型也可能对对在吉隆坡购买房产感兴趣的买家有用,这样他们就可以根据一定的预算了解在特定位置可以购买哪种类型的房产。

显然,我们在第九章训练的模型有可能对各种受众都有用。我们面临的困境是如何让这个模型对所有具有不同商业目标和可能不同计算机技能的受众都可用。在本章的后面部分,我们将学习如何在公共云环境中部署模型,但如果我们想进行一个初步实验来了解模型的特点并与一些测试客户进行测试呢?我们需要实现整个在公共云中部署模型的过程吗?不,因为我们可以利用 Flask,这是一个 Python 网络应用程序库,并设置一个自包含的模型网络部署。Flask 最初由 Armin Ronacher 于 2010 年发布,并从此成为 Python 最受欢迎的 Web 框架之一。考虑到最近出现的包,如 Streamlit (streamlit.io/) 或 Gradio (www.gradio.app/),它可能听起来有点过时。然而,尽管它已经存在了一段时间,但由于其轻量级和灵活的特性,Flask 仍然是 Web 开发的合适选择,尤其是对于小型个人项目。我们不会在本书中探讨 Flask 的所有细节;我们只是提供设置模型演示所需的要点,但如果你想了解更多关于 Flask 的信息,请查看文档:flask.palletsprojects.com/en/2.3.x/

现在我们已经介绍了简单 Web 部署的概念,我们可以开始着手了。在本节中,我们将介绍如何使用 Flask 在两个网页中创建一个简单而完整的模型部署。

10.1.1 网络部署概述

到目前为止,在这本书中,我们已经探讨了各种机器学习和深度学习的库和框架。除了在 YAML 中定义的配置文件外,我们在这本书中查看的所有代码都是 Python 编写的。像我们之前看到的例子一样,机器学习或深度学习模型的 Web 部署确实包括 Python,但它还需要处理 HTML、JavaScript 和 CSS 脚本。图 10.1 展示了我们心中的网络部署概述。

图 10.1 网络部署概述

让我们回顾一下构成图 10.1 所示网络部署的组件:

  • 训练好的模型—这是我们保存在 Keras 预处理层笔记本中的模型。实际上,模型是以目录结构保存的 (mng.bz/oKJp)。在 Flask 服务器模块中,使用对该目录的引用来加载模型。

  • Flask 服务器模块—这是一个 Python 模块,用于加载训练好的模型,并包含每个解决方案 HTML 页面的视图函数。视图函数指定当这些 HTML 页面被加载时,Flask 服务器模块采取的操作。

  • Flask 服务器配置文件—YAML 文件,其中可以指定 Flask 服务器的参数,例如包含训练模型的目录。

  • home.html—由 Flask 服务器提供的 HTML 页面之一。此页面包含用户可以指定他们想要获取价格预测的房产特征的字段(例如位置、房间数量和停车位数量)。此页面还包含 JavaScript 函数,用于将默认值加载到页面上的每个字段中,指定可以输入到每个字段的合法值,并将用户输入的值打包,以便可以发送回 Flask 服务器模块。

  • show-prediction.html—由 Flask 服务器提供的 HTML 页面之一。此页面显示模型对用户在home.html页面上输入的房产特征的预测。

  • CSS 文件—指定解决方案中 HTML 页面的渲染方式。

与本书中的其他示例不同,默认情况下 Web 部署运行在您的本地系统上。当 Flask 服务器运行时,它会在localhost:5000上提供home.html,这样您就可以在浏览器中练习系统。

现在我们已经介绍了 Web 部署,我们将在后续小节中更详细地查看 Flask 服务器模块和 HTML 页面。

10.1.2 Flask 服务器模块

Flask 服务器模块是 Web 部署的核心。与本书中迄今为止我们检查的其他 Python 程序不同,它是一个独立的.py文件,而不是 Jupyter Notebook。

在本小节中,我们将回顾 Flask 服务器模块中的关键代码片段,并解释它们如何驱动 Web 部署。以下列表显示了 Flask 服务器模块的第一个关键部分:从文件系统将保存的 Keras 模型加载到 Python 模块中的对象的代码。

列表 10.1 加载保存的 Keras 模型

rawpath = os.getcwd()                                             ①
model_path = os.path.abspath(os.path.join(rawpath, 'models'))
model_directory_name = \
os.path.join(model_path,config['file_names'] \
['saved_model'])                                                  ②
loaded_model = \ 
tf.keras.models.load_model(model_directory_name)                  ③

① 获取当前目录

② 获取完全限定的模型目录

③ 使用完全限定的模型文件名加载模型

在列表 10.1 中,完全限定的模型目录是通过从配置文件中加载的模型文件名构建的。期望模型目录位于名为models的目录中,与 Flask 服务器模块在同一目录下。模型是通过与第九章中 Keras 预处理层训练笔记本中用于加载模型的相同tf.keras.models.load_model函数加载的。

Flask 服务器模块最重要的部分是视图函数,它们指定了应用程序中每个 HTML 页面采取的操作。以下列表显示了home.html的视图函数,这是当home.html是目标时调用的代码。

列表 10.2 home.html视图函数

@app.route('/')                                                 ①
def home():   
    title_text = "web deployment of Keras model"
    title = {'titlename':title_text}
    return render_template('home.html',title=title)             ②

① 装饰器,表示此视图函数用于home.html

② 发送到home.html以进行渲染的参数

列表 10.2 中显示的视图函数只是向home.html页面发送一个标题,并渲染页面。

列表 10.3 显示了show-prediction.html的视图函数。当show_prediction.html是目标时,Flask 模块运行此代码。此视图函数处理用户在home.html中输入的值,并调用模型对这些值进行预测。

列表 10.3 show-prediction.html视图函数

@app.route('/show-prediction/')                                 ①
def show_prediction():
    scoring_dict = {}
    for col in config['scoring_columns_cat']:
        print("value for "+col+" is: "+str(request.args.get(col))) 
        scoring_dict[col] = str(request.args.get(col))          ②
    for col in config['scoring_columns_cont']:
        scoring_dict[col] = float(request.args.get(col))        ③
    scoring_dict['size_type_bin'] = \
str(request.args.get('size_type'))+' 1'                         ④
    input_dict = \
{name: tf.convert_to_tensor([value]) for name, \
value in scoring_dict.items()}                                  ⑤
    predictions = loaded_model.predict(input_dict)              ⑥
    prob = tf.nn.sigmoid(predictions[0])
    if prob < 0.5:                                              ⑦
        predict_string = "Prediction is: property has a price less than 
median"
    else:
        predict_string = "Prediction is: property has a price more than 
median"
    prediction = {'prediction_key':predict_string}
    return(render_template('show-prediction.html',\
prediction=prediction))                                         ⑧

① 装饰器表示此视图函数用于show-prediction.html

② 将查询字符串中的分类特征值加载到scoring_dict字典中

③ 将连续特征值从查询字符串加载到scoring_dict字典中

④ 设置size_type_bin参数的值

⑤ 从scoring_dict构建输入字典

⑥ 从加载的模型中获取加载的特征值的预测

⑦ 根据预测值设置输出预测字符串

⑧ 使用预测字符串渲染show-prediction.html

列表 10.3 中显示的视图函数将home.html返回的查询字符串加载到 Python 字典scoring_dict中,并使用这些值调用列表 10.1 中代码加载的模型以获取预测。唯一的例外是size_type_bin。为了这个简单的 Web 部署,我们硬编码了分箱值。作为一个练习,考虑一下你将如何计算分箱值以避免这种硬编码。预测的值用于设置一个字符串值,该值在页面渲染时发送到show-prediction.html以显示。

图 10.2 说明了列表 10.2 和 10.3 中显示的视图函数如何与home.htmlshow-prediction.html网页交互。

图 10.2 视图函数与网页之间的交互

图 10.2 解释了 Flask 服务器模块中home.html的视图函数渲染home.htmlhome.html中的 JavaScript 函数构建一个包含用户在home.html中的特征值的查询字符串。这个查询字符串返回到 Flask 服务器模块中的show-prediction视图函数。该视图函数从查询字符串中加载特征值,并使用它们从模型中获取属性预测。模型预测用于创建预测字符串,当渲染时传递给show-prediction.html。预测字符串在show-prediction.html中显示。

现在我们已经检查了图 10.2 右侧 Flask 服务器模块中的 Python 代码的关键部分,在下一节中,我们将超越 Python,查看驱动图 10.2 左侧发生的 HTML 和 JavaScript 代码。

10.1.3 home.html页面

home.html页面中发生了很多事情:

  • 设置用户可以输入基于预测的八个属性特征的值的字段

  • 为每个字段设置默认值和范围

  • 当用户点击获取预测并从字段收集输入时,将它们发送到 Flask 服务器模块中的视图函数

图 10.3 总结了 home.html 中 JavaScript 函数与用户界面元素的交互。

图 10.3 home.html 中 JavaScript 和 UI 元素的交互

在介绍了 home.html 中的情况之后,让我们来检查这个网页的代码。列表 10.4 展示了 home.html 中输入字段定义的示例。这些是用户可以输入值以指定他们想要获取价格预测的属性的详细信息的 HTML 字段。

列表 10.4 字段 HTML 定义示例

<p> 
    Location:                                              ①
    <select id="location" >                                ②
    </select> 
</p>
<p> 
    <label for="rooms">
Number of rooms (1 - 20):</label>                          ③
        <input type="number" id="rooms" name="rooms"       ④
        min="1" max="20">                                  ⑤
</p>

① 位置字段的标签

② 定义位置字段的 ID

③ 定义房间字段的标签

④ 定义房间字段的 ID

⑤ 定义房间字段的最大值和最小值

列表 10.4 展示了用于分类字段(location)和连续字段(rooms)的 HTML 定义示例。其他字段的 HTML 定义遵循相同的模式,为所有连续字段设置了最小值和最大值。

当页面通过以下语句在 HTML 页面开头加载时,load-selections() JavaScript 函数会被执行:

<body onload="load_selections()">

以下列表展示了 load-selections() JavaScript 函数的关键部分,该函数设置页面以便用户可以选择他们想要获取预测的属性的属性。

列表 10.5 load-selections() JavaScript 函数

function load_selections(){
    // initialize all selections
    var select_location = 
document.getElementById("location");                        ①
    var select_property_type = document.getElementById("property-type"); 
    var select_furnishing = document.getElementById("furnishing");
    var select_size_type = document.getElementById("size-type");
    var location_list = ["KLCC, Kuala Lumpur",..            ②
    var property_type_list = 
["Serviced Residence","Bungalow",...                        ③
    var furnishing_list = ["Fully Furnished","Partly Furnished",...;
    var size_type_list = ["Built-up" ,"Land area" ];        ④
    document.getElementById("rooms").defaultValue  
= 1;                                                        ⑤
    document.getElementById("bathrooms").defaultValue = 1;
    document.getElementById("car-parks").defaultValue = 0;
    document.getElementById("size").defaultValue = 500;
    // Populate location list:
    for(var i = 0; i < location_list.length; i++) {         ⑥
        var opt = location_list[i];
        select_location.innerHTML += 
"<option value=\"" + opt + "\">" + opt + "</option>";
        }
….
    };

① 定义位置字段的标识符

② 定义包含位置字段选择列表值的列表

③ 定义包含属性类型字段选择列表值的列表

④ 定义包含尺寸类型字段选择列表值的列表

⑤ 设置房间字段的默认值

⑥ 循环填充位置字段选择列表中的值

从列表 10.5 中我们可以看到 load-selections() JavaScript 函数执行的主要操作:

  • 对于连续字段,设置默认值。这是页面初始加载时在字段中显示的值,如果用户没有更改它,则将其传递给 Flask 服务器模块。

  • 对于分类字段,定义用户可以从中选择的值,并使用这些值填充字段的选择列表。在 home.html 中,这些值定义在一个硬编码的列表中。在一个更健壮的 Web 应用程序中,这些值将保存在一个与代码分开的控制文件中,以便更容易维护这些值并减少向代码中添加回归的风险。

当用户设置了他们想要的属性值并点击获取预测按钮时,以下代码指定运行 link_with_args() JavaScript 函数:

<button>
<a onclick=
"link_with_args();" style="font-size : 20px; width: 100%; 
height: 100px;">Get prediction</a>
</button>

列表 10.6 显示了 link_with_args() JavaScript 函数。此函数在用户点击 home.html 中的获取预测按钮时被调用。它调用 home.html 中的其余代码,收集用户的输入值并将它们打包成一个查询字符串,然后将其传递回 Flask 模块中的 show_prediction() 视图函数。

列表 10.6 link_with_args() JavaScript 函数

function link_with_args(){
    getOption();
    console.log("in link_with_args");
    console.log(window.output);                              ①
    window.location.href = window.output;                    ②
    }

① 将查询字符串回显到控制台

② 设置页面目标

如列表 10.6 所示,link_with_args() JavaScript 函数简单地调用 getOption() 函数,并将生成的查询字符串设置为 window.output 页面的目标。

列表 10.7 显示了 getOption() JavaScript 函数。此函数加载用户在 home.html 中输入的所有值,并将它们打包成一个查询字符串,然后将其传递回 Flask 模块中的 show_prediction() 视图函数。

列表 10.7 getOption() JavaScript 函数

function getOption() { 
  selectElementlocation = 
document.querySelector('#location');                          ①
  selectElementpropertytype = document.querySelector('#property-type');
  selectElementfurnishing = document.querySelector('#furnishing');
  selectElementsizetype = document.querySelector('#size-type');
  rooms_value = document.getElementById("rooms").value;       ②
  bathrooms_value = document.getElementById("bathrooms").value;
  car_parks_value = document.getElementById("car-parks").value;
  size_value = document.getElementById("size").value;
  location_string = 
selectElementlocation.options\
[selectElementlocation.selectedIndex].value                   ③
  property_type_string = 
selectElementpropertytype.\
options[selectElementpropertytype.selectedIndex].value
  furnishing_string = 
selectElementfurnishing.\
options[selectElementfurnishing.selectedIndex].value
  size_type_string = selectElementsizetype.\
options[selectElementsizetype.selectedIndex].value
  prefix = "/show-prediction/?"
  window.output = 
prefix.concat("location=",location_string,"&rooms=",\
rooms_value,"&property_type=",property_type_string,\
"&furnishing=",furnishing_string,"&size_type=
\",size_type_string,"&bathrooms=\
",bathrooms_value,"&car_parks=",car_parks_value,\
"&size=",size_value);                                         ④
  document.querySelector('.output').textContent = window.output; 
}

① 获取位置字段选择列表中的选定条目

② 获取输入到房间字段的值

③ 获取位置字段选择列表中选定条目的值

④ 将 window.output 的值设置为查询字符串

列表 10.7 中显示的 getOption() JavaScript 函数执行以下操作:

  • 加载连续字段中的值:roomsbathroomscar-parkssize

  • 加载分类字段中选定的条目:locationproperty-typefurnishingsize-type

  • 构建查询字符串。查询字符串看起来如下:

/show-prediction/?location=KLCC, Kuala Lumpur&rooms=1
&property_type=Serviced Residence&furnishing=Fully Furnished
&size_type=Built-up&bathrooms=1&car_parks=0&size=500

查询字符串由以下内容组成

  • URL /show-prediction/。请注意,此 URL 与列表 10.3 中显示的 Flask 服务器模块中 show-prediction 视图函数之前的前缀匹配:
@app.route('/show-prediction/')
  • ? 用于表示查询字符串的开始。

  • 一组参数和值对,用于指示为 home.html 中的每个字段设置的值,由分隔符 & 分隔。例如,参数和值对可能如下所示:

location=KLCC, Kuala Lumpur
rooms=1
property_type=Serviced Residence
furnishing=Fully Furnished
size_type=Built-up
bathrooms=1
car_parks=0
size=500

将查询字符串传递给 Flask 服务器模块中的 show-prediction 视图函数。如列表 10.3 所示,在该函数中,查询字符串被解析以获取在 home.html 中输入的每个特征的值,并使用这些值从模型中获取预测。

10.1.4 show-prediction.html 页面

现在我们已经查看了 Flask 服务器模块和 home.html,还有另一个网络部署组件需要检查:show-prediction.html。此页面显示模型对用户在 home.html 中输入值的属性所做的预测,并有一个按钮将用户带回到 home.html,在那里他们可以为另一个属性输入值。

列表 10.8 show-prediction.html

<!DOCTYPE html> 
<head> 
  <title> 
    Page for showing prediction                            ①
  </title> 
  <link rel="stylesheet" href="{{ url_for('static', 
filename='css/main2.css') }}">                             ②
</head> 

<body> 
<!-- display the prediction  -->
<div class="home">
    <h1 style="color: green"> 
        Here is the prediction for \
this Kuala Lumpur property:                                ③
    </h1> 
    <h1 style="color: green"> 
        {{ prediction.prediction_key }}
    </h1>

<!-- link back to home.html for entering 
scoring parameters for another prediction  -->
    <form action="{{ url_for('home') }}">
        <input type="submit" 
value="Get another prediction" 
style="font-size : 20px; width: 40%; height: 30px;"/>
    </form>                                                ④

</div>
</body>

① 设置浏览器标签中显示的文本

② 设置页面的样式表

③ 简介文本

④ 返回到 home.html 的按钮

如列表 10.8 所示,show-prediction.html不包含任何 JavaScript 函数。页面的 HTML 定义了页面上的文本,按钮“获取另一个预测”将用户返回到home.html。图 10.4 显示了本节中讨论的元素在show-prediciton.html中的显示方式。

图 10.4 show-prediction.html中的关键元素

现在我们已经检查了 Web 部署的所有组件,我们将在下一节中回顾在 Web 上简单部署我们的模型的理由。

10.1.5 练习 Web 部署

既然我们已经了解了构成 Web 部署的组件,下一步就是看到部署的实际效果。为了练习 Web 部署,

  1. 在您的本地系统上创建一个新的目录并克隆仓库 github.com/lmassaron/ml_on_tabular_data

  2. chapter_10设置为当前目录并启动 Flask 服务器模块:

    python flask_endpoint_deploy.py
    
  3. 一旦 Flask 服务器模块运行,请在浏览器中转到localhost:5000以练习部署。

恭喜!您已经在简单本地 Web 应用程序的背景下练习了一个在表格数据上训练的深度学习模型。这种部署是我们练习训练好的模型并查看其是否按预期行为的一种高效方式。然而,这种非常基础的部署并没有包含我们在生产部署中期望拥有的许多特性。例如,我们不希望服务模型的资源受限于我们的本地系统。在本章的剩余部分,我们将探讨如何使用这个相同的 Web 应用程序来练习从云环境中的端点部署的训练模型。

10.2 公共云和机器学习操作

我们在上一个章节中回顾的简单 Web 部署展示了将训练好的模型投入生产所需的一些有用方面,但它有一些严重的局限性。它完全运行在本地系统上,因此没有访问本地系统的人将无法访问。这可能是一件好事,因为这种部署没有包含我们希望在生产部署中拥有的特性,包括

  • 扩展容量以满足需求。如果利率下降,对价格预测的需求翻倍会发生什么?

  • 无缝模型更新。如果我们用最新数据重新训练模型并需要快速部署它,而不中断服务,会怎样?

  • 同时服务多个模型的版本。如果我们想通过向用户子集展示新模型来实验在另一个数据集上训练的模型版本,会怎样?我们如何服务多个模型的版本并控制每个版本的用户比例?

  • 模型监控。随着房地产市场的不断发展,我们如何跟踪模型的准确性?我们如何在问题影响用户之前捕捉并纠正它们?

  • 弹性与可用性。当本地系统需要维护或出现计划外中断时,应用将如何应对?我们如何确保用户可以继续访问该应用?

我们可以实施自定义代码来处理所有这些场景,但有一个更简单的解决方案。公共云提供了完整的、端到端的机器学习和机器学习操作(MLOps)环境,以解决这里列出的所有挑战。世界上使用最广泛的三个公共云是

  • AWS

  • Azure

  • 谷歌云

许多组织将使用一个或多个这些公共云来部署他们的模型。

本章和第十一章中描述的公共云概念在这三个公共云中都是可用的,但我们将在本章中使用谷歌云作为公共云示例。这样做有几个原因。其中一位作者是在谷歌工作的员工,但更重要的是,我们客观的看法是谷歌云提供了一个易于使用的环境,用于部署使用表格数据训练的模型并探索 MLOps 的关键概念。在本章的剩余部分,我们将介绍如何开始使用谷歌云以及如何用它来部署模型。在第十一章中,我们将超越本章中的简单部署,探索谷歌云中使模型重新训练和重新部署变得容易的功能。

10.3 开始使用谷歌云

在本节中,我们将介绍开始使用谷歌云的初步步骤,包括首次访问谷歌云、创建项目以及创建谷歌云存储桶,一个数据存储容器。在下一节中,我们将了解如何使用谷歌云中的机器学习平台 Vertex AI 来部署我们的模型。

10.3.1 首次访问谷歌云

在本节中,我们将介绍如何首次登录谷歌云并设置一个谷歌云项目。你在谷歌云中做的每一件事都与一个项目相关联。你可以使用项目来组织你的工作,并控制可以访问给定谷歌云资源的团队和个人。要开始,请访问cloud.google.com并点击登录,如图 10.5 所示。

图 10.5 登录谷歌云

登录后,点击控制台以打开谷歌云控制台,如图 10.6 所示。

如果您之前没有使用过 Google Cloud,您需要设置您的计费账户。您可能有资格获得免费信用额度,但您需要输入信用卡详细信息才能使用本章和第十一章中描述的功能。一旦您登录,并在必要时完成设置带有计费详情的账户,您将进入控制台,如图 10.7 所示。

图 10.6 输入 Google Cloud 控制台

图 10.7 Google Cloud 控制台

控制台是 Google Cloud 的用户界面。这是与 Google Cloud 功能交互的界面选择之一,也是我们在本章中将在 Google Cloud 中执行的大多数操作所使用的界面。除了 Cloud 控制台外,您还可以使用

  • 命令行界面

  • 客户端库

注意:有关 Google Cloud 接口的更多详细信息,请参阅 cloud.google.com/docs/overview

10.3.2 创建 Google Cloud 项目

您成功登录 Google Cloud 并访问 Google Cloud 控制台后,可以创建一个项目。在本节中,我们将介绍创建项目的步骤。

在 Google Cloud 控制台中,单击“项目选择”字段,如图 10.8 所示。在“选择项目”屏幕中,选择“新建项目”,如图 10.9 所示。然后,在“项目名称”字段中输入 first-project-ml-tabular 并单击创建,如图 10.10 所示。

图 10.8 选择项目

图 10.9 选择项目屏幕

图 10.10 输入项目名称

恭喜!您已成功创建了您的第一个 Google Cloud 项目。

注意,项目名称对于您的项目集合必须是唯一的。项目 ID,位于“项目名称”字段下方,必须是全局唯一的,因此如果您有一个与 Google Cloud 中任何其他项目共享的项目名称,该项目的项目 ID 将自动更新为唯一。

10.3.3 创建 Google Cloud 存储桶

在 Google Cloud 中存储数据的主要方式是使用 Cloud Storage 存储桶。在本节中,我们将介绍您在部署模型时需要了解的关于 Cloud Storage 存储桶的详细信息。如果您想了解更多关于 Cloud Storage 存储桶的细节,请参阅文档:cloud.google.com/storage/docs/buckets。我们将使用一个存储桶来存储第九章中训练的模型。要创建 Cloud Storage 存储桶,请在 Cloud Console 的搜索栏中输入“cloud storage”,然后选择 Cloud Storage,如图 10.11 所示。

图 10.11 搜索 Cloud Storage

Cloud Storage 存储桶页面出现。选择创建以创建新的 Cloud Storage 存储桶,如图 10.12 所示。

图片

图 10.12 云存储视图

在“创建桶”页面中,为您的桶输入一个独特的名称(如图 10.13 所示),点击继续,然后在“位置类型”中选择区域,并选择一个区域,如图 10.14 所示。为了在 11 章中探索的机器学习管道脚本代码,桶需要创建在某个区域。为了本例的目的,您可以选择您喜欢的任何区域。点击创建。请注意,您的桶名称必须是全球唯一的。

图片

图 10.13 设置桶名称

图片

图 10.14 设置桶的位置类型

将显示新桶的“桶详细信息”页面,如图 10.15 所示。

图片

图 10.15 显示了新桶的详细信息

恭喜!您已创建了您的第一个 Google Cloud Storage 桶。

10.4 在 Vertex AI 中部署模型

在本章的早期,我们使用基于 Flask 的简单 Web 应用程序部署了吉隆坡房地产价格预测模型。在本节中,我们将使用 Google Cloud Vertex AI 环境部署相同的模型。

现在我们已经在 Google Cloud 中创建了一个项目,并在新项目中创建了一个云存储桶,我们准备在 Google Cloud 中部署一个模型。以下是我们将遵循的部署步骤(在 Vertex AI 文档mng.bz/nRJ2中描述得更详细):

  1. 将第九章中训练的模型上传到 Google Cloud 存储。

  2. 将模型导入 Vertex AI 模型注册表。

以下各节描述了这些步骤。

10.4.1 将模型上传到云存储桶

将训练好的模型上传到 Google Cloud Storage 的最简单方法是上传包含训练模型的文件夹。如果您想上传已经训练过的模型版本,请克隆github.com/lmassaron/Advanced_Analytics_for_Business上的 repo。您要上传的目录是chapter_10/models/kl_real_estate_keras_preprocessing_model

要上传在第九章中创建的模型,请选择如图 10.16 所示的云存储页面中您创建的云存储桶。在“桶详细信息”页面中,选择“上传文件夹”,如图 10.17 所示。

图片

图 10.16 在云存储页面中选择您的桶

图片

图 10.17 在桶详细信息页面中上传文件夹

选择包含第九章训练模型的本地系统上的文件夹。上传完成后,文件夹将出现在“桶详细信息”页面中,如图 10.18 所示。

图片

图 10.18 您上传的桶在桶详细信息页面中

恭喜!您已将模型上传到 Google Cloud。

10.4.2 将模型导入 Vertex AI

现在我们已经将模型上传到 Google Cloud Storage 存储桶,我们可以将其导入到 Vertex AI 模型注册表中。在 Google Cloud 控制台搜索栏中输入“vertex ai”以到达 Vertex AI 页面。如果您是第一次使用 Vertex AI,您将看到“启用所有推荐 API”。如果您看到此按钮,请单击它以启用使用 Vertex AI 所需的 API,如图 10.19 所示。

图 10.19 启用 Vertex AI API

然后在左侧导航面板中选择模型注册表,如图 10.20 所示,在模型注册表页面上单击导入,如图 10.21 所示。

图 10.20 Vertex AI 页面导航面板中的模型注册表

图 10.21 模型注册表导入

在导入模型页面上,选择“导入为新模型”,在名称字段中输入“first-model-ml-tabular”,然后单击继续,如图 10.22 所示。

图 10.22 在模型注册表中指定导入模型的名称

在导入模型页面的模型设置选项卡上,按照以下步骤操作:

  1. 在模型框架中,选择 TensorFlow。回想一下,第八章中 TensorFlow 是 Keras 的低级框架,我们用它来训练模型。

  2. 在模型框架版本中,选择用于训练模型的 TensorFlow 级别。您可以从用于训练模型的笔记本中tf.__version__的输出中找到此级别:

    tf.__version__ 
    2.9.2
    
  3. 在模型工件位置中,单击浏览并选择您上传模型的文件夹。

  4. 按照图 10.23 所示,单击继续并导入。

图 10.23 指定模型设置

模型导入过程可能需要几分钟才能完成。导入完成后,您将在模型注册表页面上看到新的模型名称,如图 10.24 所示。

图 10.24 模型注册表显示导入的模型

恭喜!您已将模型导入到 Vertex AI 中。在下一节中,我们将介绍如何通过端点使此模型可用。

10.4.3 将模型部署到端点

现在我们已经将训练好的模型导入到 Vertex AI 中,我们可以将其部署到端点。通过将模型部署到端点,我们得到一个 URL,我们可以使用它来调用模型。实际上,端点部署可以取代我们在本章早期简单 Web 部署中加载的本地文件系统中的模型文件。

要将模型部署到端点,在模型注册表页面上选择上一节中创建的模型。在模型详细信息页面上,选择模型的版本(默认为 1),如图 10.25 所示。在模型的版本页面上,单击“部署到端点”,如图 10.26 所示。在“部署到端点”页面上,输入端点名称并单击继续,如图 10.27 所示。

图 10.25 模型详情页面

图片

图 10.26 部署您的模型

图片

图 10.27 部署到端点页面

在模型设置选项卡下,在高级扩展选项中,选择一个最小机器类型,例如 n1-standard-2,如图 10.28 所示,然后点击继续。

图片

图 10.28 设置部署的机器类型

由于这是一个简单模型的测试部署,我们只需要一个最小机器类型。如果我们部署一个更复杂的需求模型或进行生产部署,我们可以根据模型的需求选择具有更多内存或计算资源的机器类型。对于我们的测试部署,最小机器类型已经足够好,并且它将比更高级的机器类型成本低。当您使用云环境时,使用足够满足您应用程序的资源而不是更多的资源是一种最佳实践。这样做可以为您节省金钱。

提示:有关 Vertex AI 端点部署的机器类型更详细的信息,请参阅mng.bz/vK14的文档。

如图 10.29 所示,点击部署。

图片

图 10.29 在部署到端点页面中选择部署

部署可能需要几分钟才能完成。当部署完成后,模型版本详情页面上的部署状态将变为活动状态,如图 10.30 所示。

图片

图 10.30 模型已部署的确认

恭喜!您已在 Vertex AI 中完成了模型的部署。在下一节中,我们将介绍快速测试部署的步骤。

10.4.4 模型部署的初步测试

现在我们已经部署了模型,我们可以在 Google 控制台中直接对模型部署进行初步测试。

回想我们在第九章中进行的训练模型测试,以在 Jupyter Notebook 的上下文中锻炼模型。我们定义了一个包含所有用于训练模型的特征的 Python 字典,以及每个特征的值:

sample = {
    'location': 'Sentul, Kuala Lumpur',
    'rooms': 3.0,
    'property_type': 'Condominium For Sale',
    'furnishing': 'Partly Furnished',
    'size_type_bin': 'built-up 1',
    'bathrooms': 2.0,
    'car_parks': 0.0,
    'size': 950.0,
}

我们可以重用这个示例来测试在 Vertex AI 中的模型部署。在模型版本详情页面,转到测试您的模型部分,如图 10.31 所示。

图片

图 10.31 部署和测试标签页的测试您的模型部分

更新 JSON 请求字段以使用第九章示例中的值,每个值都是一个列表条目,并且在整个文档中使用双引号。当您完成更新后,JSON 请求字段应如图 10.32 所示。

图片

图 10.32 JSON 格式的测试样本

注意 JSON 请求样本格式与第九章 Jupyter Notebook 中的原始样本之间的三个差异:

  • 键值对中的值都是数组而不是单个值。如果您有单个值,将会得到一个错误。

  • 全角引号(“ “)被广泛使用,而不是单引号。

  • 在最后一个键值对之后不需要逗号。

一旦 JSON 请求字段包含有效的 JSON,框架就会变成蓝色。点击预测,在响应字段中查看端点的输出,如图 10.33 所示。

图片

图 10.33 端点对测试样本的响应

注意,预测值不是概率。回想一下,当我们从第九章模型训练笔记本中的模型获取预测,以及当我们从 Flask 服务器模块中的模型获取预测时,我们需要将模型的输出应用 sigmoid 函数以获取该属性价格高于中值的概率:

predictions = loaded_model.predict(input_dict)
prob = tf.nn.sigmoid(predictions[0])

我们需要应用 sigmoid 函数来从端点提供的输出中获取概率。如果我们更新我们在训练笔记本中使用的语句,使得 sigmoid 函数的输入是端点的输出,那么我们就会得到与我们在第九章使用它来练习模型时相同的概率:

prob2 = tf.nn.sigmoid(-1.52191401)
print(
    "This property has a %.1f percent probability of "
    "having a price over the median." % (100 * prob2)
)
This property has a 17.9 percent probability 
of having a price over the median.

现在,我们已经验证了端点工作正常,并且对于相同的属性,我们得到的结果与我们在 Jupyter Notebook 中直接应用模型时得到的结果相同。

10.5 使用 Flask 部署 Vertex AI

在上一节中,我们将吉隆坡房价预测模型部署到了 Vertex AI 的端点。在本节中,我们将适配 Flask Web 部署以使用此端点。完成之后,我们应该从home.htmlshow-prediction.html网页中获得相同的使用体验,模型将由 Vertex AI 端点提供服务,而不是本地系统。

为了使 Flask 部署与 Vertex AI 端点部署兼容,我们需要采取以下步骤:

  1. 设置 Vertex AI SDK。

  2. 更新 Flask 服务器模块以访问 Vertex AI 端点,从该端点获取模型的预测。

图 10.34 显示了适配使用 Vertex AI 端点部署的应用程序的关键组件。

在本节的剩余部分,我们将介绍使用 Web 应用程序部署模型所需的步骤。

图片

图片

10.5.1 设置 Vertex AI SDK

Vertex AI 提供了客户端库,允许您通过 Python、Java 和node.js应用程序中的 API 访问 Vertex AI 功能。Python 客户端库包含在 Vertex AI 的 Python SDK 中,因此我们将安装 SDK 以获取通过端点调用模型所需的 API 访问权限。

注意:有关 Vertex AI SDK 的完整详细信息,请参阅mng.bz/4a1j上的文档。

您可以使用以下命令安装 Vertex AI SDK:

pip install google-cloud-aiplatform

现在我们已经安装了 Vertex AI SDK,我们可以进行下一步:更新 Flask 服务器模块。

10.5.2 更新 Flask 服务器模块以调用端点

要了解如何通过端点使用 Vertex AI API 访问模型,请点击模型版本详情页面中的示例请求链接,如图 10.35 所示。

图 10.35 部署和测试选项卡中的示例请求链接

Flask 服务器模块的第一个更新是导入 Vertex AI 所需的库:

from typing import Dict, List, Union
from google.cloud import aiplatform
from google.protobuf import json_format
from google.protobuf.struct_pb2 import Value

接下来,我们添加定义在 mng.bz/QD8vpredict_custom_trained_model_sample() 函数。我们需要对这个函数进行一项更新,以便它将 predictions(包含端点的响应)返回到 show-prediction.html 视图函数。

列表 10.9 突出了我们需要对 show-prediction.html 视图函数进行的更新。这些更改将输入值打包成 Vertex AI 端点部署所期望的格式,并通过 predict_custom_trained_model_sample() 函数在 Vertex AI 端点调用模型。

列表 10.9 show-prediction.html 端点视图函数

def show_prediction():
    scoring_dict = {}
    for col in config['scoring_columns_cat']:
        print("value for "+col+" is: "+str(request.args.get(col))) 
        scoring_dict[col] = str(request.args.get(col))
    for col in config['scoring_columns_cont']:
        scoring_dict[col] = float(request.args.get(col))
    scoring_dict['size_type_bin'] = str(request.args.get('size_type'))+' 1'
    scoring_dict.pop('size_type')                            ①
    input_dict = {name: [value] for name, \
value in scoring_dict.items()}                               ②
    print("input_dict: ",input_dict)
    predictions = predict_custom_trained_model_sample(
    project = config['endpoint']['project'],
    endpoint_id = config['endpoint']['endpoint_id'],
    location = config['endpoint']['location'],
    instances = input_dict)                                  ③
    prob = tf.nn.sigmoid(predictions[0])

① 从特征列表中删除 size_type

② 将 scoring_dict 中的值转换为值列表

③ 调用 predict_custom_trained_model_sample

列表 10.9 显示了对 show-prediction.html 视图函数的以下更新:

  • 对端点的调用必须具有精确正确的特征和值列表,不能缺少特征或额外特征。由于我们不直接使用 size_type 与模型一起,我们需要使用 scoring_dict.pop('size_type') 语句显式地从特征和值的字典中删除它。

  • 端点期望以与我们在 10.4.4 节中直接在控制台练习端点所使用的 JSON 相同的格式从字典中获取特征和值。这意味着字典中的值需要转换为值列表,每个列表中恰好包含一个值。此语句将字典转换为值列表:

input_dict = {name: [value] for name, value in scoring_dict.items()}
  • predict_custom_trained_model_sample() 函数需要使用指定项目、端点和我们想要预测的特征键值对的参数来调用。

除了对 Flask 服务器模块的这些更新外,我们还可以删除从本地文件系统加载模型的语句,因为我们在这个解决方案中不使用本地模型。

注意:使用端点的 Flask 服务器模块的更新版本可在 mng.bz/Xx5a 获取。

要运行此版本的 Flask 服务器模块,请按照以下步骤操作:

  1. 通过在运行 Flask 服务器模块的本地系统上的命令行中运行以下命令,通过此应用程序授权您用于访问 Google Cloud 的账户:
gcloud auth application-default login
  1. 启动 Flask 服务器模块:
python flask_endpoint_deploy.py

如果您遇到与 protobuf(协议缓冲区)级别相关的错误,请尝试以下命令:

pip install protobuf==3.20.*n

此命令将 protobuf 级别调整为端点所需的精确级别。

注意:在本应用的目的上,您不需要了解 protobufs,但如果您对此感兴趣,可以查看文档:protobuf.dev/.

  1. 当 Flask 服务器模块运行时,在浏览器中转到localhost:5000以测试部署。

现在我们已经看到了模型的两种部署方式:一种是从本地系统完全运行的 Web 部署,另一种是使用从 Vertex AI 端点提供模型的 Web 部署。

10.5.3 将模型部署到端点的益处

如果模型在简单的 Web 部署或部署到端点时得到相同的结果,您可能会问,将模型部署到端点有什么意义。将模型部署到端点可以带来许多好处:

  • 您可以扩展端点实例以处理额外的负载。

  • 您可以将多个模型部署到同一端点。想象一下这样一个场景,您需要用另一个模型替换生产环境中的一个模型。如果您将这两个模型部署到同一端点,您可以在不更改应用程序的情况下,逐渐调整流量分配到新模型的比例,而不会对应用程序的用户造成突兀的变化。

  • 您可以将相同的模型部署到不同的端点,从而优化机器资源。例如,如果您有一个生产和开发环境,您可以将相同的模型部署到两个端点,生产环境使用更高规格的机器资源,而开发环境使用更便宜的机器资源。

  • 使用端点,您可以使用 Vertex AI 监控来检测偏差(训练数据与在生产中应用模型时看到的数据之间的分布差异)和漂移(模型在生产中看到的数据分布随时间的变化)。模型监控可以帮助确保模型的性能不会随时间下降,并且模型在生产中应用的数据的变化不会意外发生。有关监控的更多详细信息,请参阅mng.bz/yWdd上的文档。

图 10.36 显示了多个模型部署到同一端点和单个模型部署到多个端点的示例。

在图 10.36 中,有两种场景:

  • 同一端点部署多个模型——模型 A 是当前生产中的版本,模型 B 是我们希望引入生产的下一级模型。通过调整 X 和 Y 的值,我们可以控制流量分配到每个模型的比例,逐渐增加分配到新模型级别的比例。

  • 同一模型部署到多个端点——端点 1 具有基本的计算资源,端点 2 具有足够的计算资源来处理我们当前的生产负载。通过这种方式,我们可以优化系统的资源成本,以满足多组用户的需求。

图 10.36 模型和端点之间的关系

注意:有关 Vertex AI 中模型部署的更多详细信息,请参阅 mng.bz/MDlB 的文档。

10.6 Gemini for Google Cloud:Google Cloud 中的生成式 AI 辅助

在你完成本章步骤的过程中,你可能遇到了障碍或需要澄清问题。为了取得进展,你可能查阅了 Google Cloud 的文档,在 Stack Overflow 中搜索,或向同事寻求帮助。除了这些传统的辅助来源外,Google Cloud 还包括一个集成、由生成式 AI 驱动的辅助来源:Gemini for Google Cloud。

Gemini for Google Cloud 是一套针对 Google Cloud 的生成式 AI 能力。Gemini for Google Workspace 是一套针对 Google Workspace(Google Docs、Sheets、Slides)的配套生成式 AI 能力(参见 workspace.google.com/solutions/ai/)。在本章和第十一章中,我们将展示如何通过 Gemini for Google Cloud 使用生成式 AI 来简化表格数据模型的部署,并自动化机器学习管道的一些脚本。Gemini for Google Cloud 提供了各种能力,包括以下内容:

  • 回答有关 Google Cloud 的问题

  • 从文本生成代码(包括 SQL 和各种编程语言,如 Python、Java 和 JavaScript)

  • 解释代码

注意:有关 Gemini for Google Cloud 的更详细概述,请参阅 Gemini for Google Cloud 文档:cloud.google.com/gemini/docs/overview

在本节中,我们将介绍如何设置 Gemini for Google Cloud 并使用它来回答有关 Google Cloud 的问题。我们还将讨论本章中完成的一些操作,以在 Vertex AI 端点部署我们的模型。在第十一章中,我们将回到 Gemini for Google Cloud,展示如何使用它来生成和解释代码。

Google Cloud 不是唯一利用生成式 AI 来简化平台使用并自动化开发工作流程中某些步骤的云平台。Azure 中的 Copilot 和 AWS 中的 Code Whisperer 是基于生成式 AI 的功能,每个都提供了 Gemini for Google Cloud 提供的部分好处。

10.6.1 设置 Gemini for Google Cloud

以下是为设置 Gemini for Google Cloud 提供的说明:

一旦为项目设置了 Gemini for Google Cloud,你将在工具栏中看到 Gemini for Google Cloud 图标(参见图 10.37)。

图 10.37 Gemini for Google Cloud 图标

现在我们已经完成了 Gemini for Google Cloud 的基本设置,我们将在下一节中看到如何使用它来回答关于 Google Cloud 的问题。

10.6.1 使用 Gemini for Google Cloud 回答关于 Google Cloud 的问题

Gemini for Google Cloud 在 Google Cloud 中表现出多种方式,包括

  • 在 Google Cloud 中可用的聊天面板中——图 10.38 展示了 Gemini for Google Cloud 的聊天面板。

图片

图 10.38 Gemini for Google Cloud 聊天面板

  • 在 Google Cloud 支持的多种 IDE 中,包括 VS Code、Cloud Workstations 和 Cloud Shell Editor——图 10.39 展示了 Gemini for Google Cloud 如何从 Cloud Shell Editor 中的注释生成一个简单的 Python 函数。

图片

图 10.39 Gemini for Google Cloud 在 Cloud Shell Editor 中生成 Python

  • 在 Big Query 和 Spanner 的查询编辑器中——图 10.40 展示了 Gemini for Google Cloud 如何从 Spanner 查询编辑器中的注释生成 SQL。

图片

图 10.40 Gemini for Google Cloud 在 Spanner 查询编辑器中生成 SQL

现在我们已经回顾了 Gemini for Google Cloud 的适用示例,接下来让我们更深入地考察 Gemini for Google Cloud 的聊天面板,看看它是如何被用来获取关于 Google Cloud 的问题答案。您可以通过点击 Gemini 图标(见图 10.37)来打开 Gemini for Google Cloud 聊天面板,如图 10.41 所示。

图片

图 10.41 Gemini for Google Cloud 聊天面板

通过在提示字段中输入指令提示并点击发送按钮来尝试使用 Gemini for Google Cloud 聊天面板,如图 10.42 所示。

图片

图 10.42 Gemini for Google Cloud 聊天面板发送按钮

Gemini for Google Cloud 聊天面板在 Google Cloud 中随时可用,您可以使用它来提出关于 Google Cloud 的各种问题。例如,假设我们想要创建一个 Cloud Storage 存储桶,但我们忘记了本章前面列出的步骤。图 10.43 显示了 Gemini for Google Cloud 对提示“如何创建 Google 存储桶”的响应。

图片

图 10.43 Gemini for Google Cloud 对“如何创建 Google Cloud 存储桶”的响应

Gemini for Google Cloud 专门训练以提供关于使用 Google Cloud 的有用答案。通过给出与本章某些部分相关的提示来尝试使用 Gemini for Google Cloud。例如,尝试“如何将自定义模型导入 Vertex AI”或“如何将模型部署到 Vertex AI 端点”,并查看 Gemini for Google Cloud 的响应与本章类似部分中列出的步骤有何不同。

摘要

  • 在简单的 Web 应用程序中部署您的模型可以给您一个关于其在生产中的特性的感觉。

  • 我们可以创建一个简单的 Web 应用程序来部署我们在第九章训练的模型。这个应用程序包括一个 Flask 服务器模块和两个简单的网页。

  • 公共云环境提供了一个部署模型的环境,允许你扩展容量并控制可用性。

  • 为了在 Google Cloud 中部署我们在第九章训练的模型,我们需要创建一个 Google Cloud 项目,创建一个 Google Cloud 存储桶,并将模型上传到存储桶中。

  • 一旦我们完成了准备 Google Cloud 模型部署的步骤,我们就可以将训练好的模型部署到 Vertex AI 端点。

  • 我们可以通过对我们在本章开头创建的 Web 应用程序中的 Flask 模块进行一些简单的更新来测试模型的 Vertex AI 端点部署。

  • 将模型部署到 Vertex AI 的端点使部署更加稳健。特别是,我们可以指定适合我们应用程序的机器资源,提供不同级别的模型组合,并监控模型在生产中的性能。

  • Gemini for Google Cloud 提供了生成式 AI 能力,这些能力对于在 Google Cloud 中的模型开发和部署任务非常有帮助。

第十一章:构建机器学习流程

本章涵盖

  • 机器学习流程概述

  • 在 Vertex AI 中运行机器学习流程的先决条件

  • 模型训练和部署:本地实现与机器学习流程实现

  • 定义一个机器学习流程以训练和部署模型

  • 更新模型训练代码以与机器学习流程一起工作

  • 使用生成式 AI 帮助创建机器学习流程

在第十章中,我们介绍了部署基于表格数据训练的深度学习模型的步骤。我们首先在本地系统中运行模型,然后将模型部署到 Vertex AI 端点。在本章中,我们将进一步介绍通过在 Vertex AI 中使用机器学习(ML)流程来自动化训练和部署过程的步骤。我们将首先概述设置 ML 流程所需的步骤,包括定义 Vertex AI 数据集。接下来,我们将对比第十章中看到的本地模型训练和部署与使用 ML 流程进行模型训练和部署。然后,我们将审查 ML 流程本身的代码,以及更新现有代码以使模型训练代码在 ML 流程的上下文中工作所需的更新。最后,我们将探讨一些我们可以应用生成式 AI 并在创建 ML 流程的工作流程中获得有用帮助的方法。本章中描述的代码可在mng.bz/DM4n找到。

11.1 机器学习流程简介

考虑到本书中我们已经涵盖的步骤来准备一个基于表格数据的深度学习模型:

  • 处理数据以解决诸如缺失值、包含两种不同类型数据列和以字符串表示的数值数据等问题

  • 使用处理后的数据训练模型

  • 将训练好的模型部署以便应用程序可以使用

假设我们需要反复进行这个过程来解决吉隆坡房地产问题。这是一个合理的预期,因为房地产市场会随着价格发展、利率变化和宏观经济因素影响房地产需求而不断变化。与其为从原始数据到部署模型的每个端到端周期手动运行各种笔记本和部署步骤,不如有一个可以作为一个单元反复和一致运行的编码解决方案。一个机器学习流程正好提供了这个功能,在本节中,我们将通过一个示例来说明如何为吉隆坡房地产问题设置一个简单、端到端的流程。

11.1.1 三种类型的流程

在深入了解 ML 管道的细节之前,值得注意的是,术语“管道”随着时间的推移被赋予了不同的含义。目前,在 ML/数据科学领域,至少有三个不同的“管道”含义占主导地位:

  • 训练/推理管道——这个管道确保数据转换,如将文本分配给标记或将分类列中的值分配给数值标识符,在训练和推理步骤中是一致的。吉隆坡模型中的预处理 Keras 层构成了这种类型的管道,因为它们确保,例如,在训练之前对处理过的数据进行的转换与在 Web 部署中home.html中输入的数据点进行的转换完全匹配。

  • 数据管道——这个管道处理输入训练数据中的异常,如缺失值或模式问题。它可以与前面提到的管道重叠,但它执行一个不同的任务。在 Google Cloud 的上下文中,Dataflow 和 Cloud Data Fusion 是能够执行数据管道任务的产品的例子。在本章的目的上,您不需要了解 Dataflow 或 Cloud Data Fusion,但如果您好奇,可以查看文档:cloud.google.com/dataflow/docscloud.google.com/data-fusion/docs

  • ML 管道——这是一种自动化各种步骤的管道,例如训练、部署和监控模型。TFX 和 KubeFlow 是 Vertex AI 中用于实现 ML 管道的两种方法。

图 11.1 展示了这三种类型的管道如何适应端到端 ML 工作流程。

图 11.1 三种管道及其关系

图 11.1 说明了管道的以下特征:

  • 机器学习(ML)管道可以涵盖整个工作流程,从原始数据到监控部署的模型。这样做的原因是,ML 管道旨在在模型需要重新训练和重新部署时自动化整个流程。

  • 数据管道与训练/推理管道的区别在于,训练/推理管道处理需要对新数据点应用以应用训练模型进行预测的转换,例如将分类值替换为数值标识符。在训练模型之前,必须对准备好的数据应用相同的转换。

  • 正如我们在第三章中解决 Airbnb 纽约市价格预测问题的 Keras 自定义层解决方案中看到的那样,训练/推理管道可以与模型训练过程分开。在 Keras 自定义层解决方案中,训练/推理管道是通过 Scikit-learn 管道结构和自定义类实现的,这两个都需要在模型训练之前以及在新数据点应用于训练模型以获取预测之前应用于数据。另一方面,在第九章中,我们看到了如何将相同的处理直接集成到 Keras 模型中。

  • 数据管道可以存在于机器学习工作流程之外。在 Google Cloud 中用于机器学习工作流程的相同数据管道工具,如 Dataflow 和 Cloud Data Fusion,也可以是包含机器学习的应用程序的一部分。

现在我们已经描述了三种不同类型的管道,在下一节中,我们将开始探讨如何在 Google Cloud 中使用 Kubeflow 创建一个针对吉隆坡房地产价格预测问题的机器学习管道。

11.1.2 Vertex AI ML 管道概述

在第十章中,我们介绍了将吉隆坡房地产价格预测模型部署到 Vertex AI 端点的过程。

要创建一个针对吉隆坡价格预测模型的机器学习管道,我们将从 Vertex AI 文档中描述的步骤开始:mng.bz/lYW6

以下是一个步骤概述:

  • 设置一个服务帐户。服务帐户是一个应用程序用于在 Google Cloud 中执行操作所使用的帐户。当我们将 Keras 模型导入 Google Cloud 并部署到端点时,我们使用自己的 ID 来执行这些操作。由于机器学习管道将是一个自动化脚本,我们需要一个服务帐户来允许脚本在没有直接依赖任何个人的手动干预的情况下执行操作。有关服务帐户的更多详细信息,请参阅 Google Cloud 文档:mng.bz/BXA0

  • 获取服务帐户密钥,并为服务帐户提供运行机器学习管道所需的访问权限。

  • 创建一个管道脚本来调用 Vertex AI SDK。

  • 将模型训练笔记本调整为独立的 Python 脚本,以便在预构建的 Vertex AI 容器中运行。

  • 运行管道脚本来在容器中运行训练脚本并生成训练好的模型。

在接下来的章节中,我们将逐步介绍如何为吉隆坡房地产预测模型创建一个机器学习(ML)管道。

11.2 ML 管道准备步骤

在我们可以运行机器学习管道来训练和部署模型之前,我们需要设置管道所需的 Google Cloud 对象。在本节中,我们将设置一个服务帐户并介绍 Cloud Shell,这是一个直接在 Google Cloud 中可用的实例,我们可以使用它来输入命令。我们还将上传我们的数据集到 Google Cloud Storage,并使用上传的数据集创建一个 Vertex AI 数据集。

11.2.1 为 ML 管道创建服务账户

由于我们希望能够在没有人工干预的情况下自动运行 ML 管道,我们需要设置一个服务账户来运行管道。

要创建一个服务账户,请按照以下步骤操作:

  1. 从整体 Google Cloud 控制台菜单中选择“IAM & Admin”->“服务账户”,如图 11.2 所示。

图片

图 11.2 在 Google Cloud 控制台中选择服务账户

  1. 在“服务账户”页面,选择“创建服务账户”,如图 11.3 所示。

图片

图 11.3 创建服务账户

  1. 在“创建服务账户”页面,输入服务账户的名称,然后点击“创建并继续”,如图 11.4 所示。请注意,服务账户 ID 会自动填写,并且服务账户的电子邮件 ID 以service-account-id@project-id.iam.gserviceaccount.com的形式显示——在本例中:ml-tabular-pipeline@first-project-ml-tabular.iam.gserviceaccount.com

图片

图 11.4 设置服务账户名称

  1. 在“角色”字段中选择“Vertex AI 用户”,然后点击“完成”,如图 11.5 所示。

图片

图 11.5 授予服务账户 Vertex AI 用户角色

现在我们已经创建了一个服务账户并授予它对 Vertex AI 的访问权限,在下一节中我们可以创建一个服务账户密钥。

11.2.2 创建服务账户密钥

ML 管道使用服务账户密钥来验证用于运行 ML 管道的服务账户。

要创建服务账户密钥,请按照以下步骤操作:

  1. 在“服务账户”页面,点击如图 11.6 所示的刚刚创建的服务账户的电子邮件地址。

图片

图 11.6 选择服务账户

  1. 选择“密钥”选项卡,然后点击“添加密钥”->“创建新密钥”,如图 11.7 所示。

图片

图 11.7 创建服务账户密钥

  1. 选择“JSON”,然后点击“创建”,如图 11.8 所示。

图片

图 11.8 下载服务账户密钥

创建了一个包含服务账户密钥的 JSON 文件,并将其以类似first-project-ml-tabular-039ff1f820a8.json的名称下载到您的本地系统。

11.2.3 授予服务账户对 Compute Engine 默认服务账户的访问权限

当你在 Google Cloud 中设置你的项目时,会自动创建一个 Compute Engine 默认服务账户。此账户有一个类似PROJECT_NUMBER-compute@developer.gserviceaccount.com的电子邮件地址。有关 Compute Engine 默认服务账户的更多详细信息,请参阅文档(mng.bz/dXdN)。

我们需要给在前几节中设置的服务账户访问 Compute Engine 默认服务账户的权限来运行 ML 管道。按照以下步骤设置对此 Compute Engine 默认服务账户的访问权限:

  1. 在“服务帐户”页面,点击您刚刚创建的服务帐户旁边的复制图标(您将在下一步需要它),然后点击 Compute Engine 默认服务帐户的电子邮件地址,如图 11.9 所示。

图片

图 11.9 Compute Engine 默认服务帐户

  1. 点击“权限”选项卡,然后点击“授予访问权限”,如图 11.10 所示。

图片

图 11.10 授予 Compute Engine 默认服务帐户访问权限

  1. 在“授予访问权限”页面,将您在“新主体”字段中创建的服务帐户的电子邮件 ID 粘贴到其中,在“角色”字段中选择“服务帐户用户”,然后点击“保存”,如图 11.11 所示。

图片

图 11.11 指定对 Compute Engine 默认服务帐户的访问权限

现在我们已经完成了设置 ML 管道服务帐户的步骤,我们可以继续设置管道。

11.2.4 Cloud Shell 简介

到目前为止,我们在 Google Cloud 中采取的所有操作都是在控制台 UI 中进行的。Google Cloud 还包括 Cloud Shell,这是一个自包含的实例,允许您运行命令行命令以与 Google Cloud 交互。除了命令行界面外,您还可以使用 Cloud Shell 编辑器编辑 Cloud Shell 文件系统中的文件。使用 Cloud Shell,您将获得本地 Linux 实例的功能,同时结合了与 Google Cloud 资源集成的基于 Web 的环境的便利性。Cloud Shell 特别适合原型设计和完成教程。我们将使用 Cloud Shell 在设置 ML 管道的下一步中。有关 Cloud Shell 的更多详细信息,请参阅文档:cloud.google.com/shell

要启动 Cloud Shell,请点击 Google Cloud 控制台顶部的“激活 Cloud Shell”图标,如图 11.12 所示。

图片

图 11.12 激活 Cloud Shell 图标

当您点击激活 Cloud Shell 图标时,Cloud Shell 终端将在控制台底部打开,您的家目录作为当前目录,如图 11.13 所示。

图片

图 11.13 Cloud Console 已激活 Cloud Shell

您可以直接在 Cloud Shell 终端中运行命令,包括标准 Linux 命令和 Google Cloud 特定命令。您可以通过点击“打开编辑器”来编辑 Cloud Shell 文件系统中的文件,如图 11.14 所示。要返回 Cloud Shell 终端,请点击“打开终端”。

图片

图 11.14 Cloud Shell 编辑器

现在我们已经简要了解了 Cloud Shell,我们可以继续下一步设置 ML 管道:使服务帐户密钥对管道可用。

11.2.5 上传服务帐户密钥

在本节中,我们将使用 Cloud Shell 上传服务账户密钥 JSON 文件,然后设置一个环境变量以指向服务账户密钥的位置:

  1. 在 Cloud Shell 中,将您的家目录设置为当前目录,创建一个名为 ml_pipeline 的新目录,然后将该新目录设置为当前目录:
cd ~  
mkdir ml_pipeline
cd ml_pipeline
  1. 要上传服务账户密钥,选择 Cloud Shell 工具栏中的三个点,然后选择上传,如图 11.15 所示。

图 11.15 在 Cloud Shell 中上传文件

  1. 在上传页面,将目标目录更新为您的家目录中的 ml_pipeline 目录,点击选择文件,并选择您在 11.2.2 节中下载的服务账户密钥 JSON 文件,然后点击上传,如图 11.16 所示。

图 11.16 设置上传参数

  1. 通过将 ~/ml_pipeline 设置为当前目录,并使用 ls 命令确保 JSON 服务账户密钥现在位于此目录中,来验证上传:
cd ~/ml_pipeline  
ls
  1. 将环境变量 GOOGLE_APPLICATION_CREDENTIALS 设置为服务账户密钥 JSON 文件的完整文件名。在以下示例中,将完整文件名替换为您自己的服务账户密钥 JSON 文件名:
export \
GOOGLE_APPLICATION_CREDENTIALS=\
'/home/ryanmark2023/ml_pipeline/\
first-project-ml-tabular-039ff1f820a8.json'
  1. 使用以下命令确认 GOOGLE_APPLICATION_CREDENTIALS 环境变量的值,并验证它是否设置为您的服务账户密钥文件的完整路径:
$ echo $GOOGLE_APPLICATION_CREDENTIALS

现在我们已经上传了服务账户密钥并设置了环境变量以指向服务账户密钥的位置,我们就可以进入定义 ML 流程的关键步骤了。

11.2.6 将清洗后的数据集上传到 Google Cloud Storage 存储桶

为了简化流程,我们将上传由数据准备笔记本生成的处理后的数据集到 Cloud Storage 存储桶,以便其余的 ML 流程可以访问。在实际应用中,我们会将数据清理步骤集成到 ML 流程中,但为了简化,我们将从已经清理好的数据开始流程。按照本节中的步骤上传清洗后的数据集到 Google Cloud Storage:

  1. 将清洗后的数据集的 CSV 版本上传到您创建用于上传模型的同一个存储桶。

  2. 从 Google Cloud Console 主菜单中选择 Cloud Storage -> 存储桶,如图 11.17 所示。

图 11.17 设置上传参数

  1. 在存储桶页面,选择您在第十章中创建的存储桶以包含训练模型。在存储桶详细信息页面,选择创建文件夹,如图 11.18 所示。

图 11.18 创建文件夹

  1. 在名称字段中输入 processed_dataset 并点击创建。

  2. 选择您刚刚创建的新文件夹,如图 11.19 所示。

图像

图 11.19 选择文件夹

  1. 点击上传文件并选择包含吉隆坡数据集处理版本的 CSV 文件(数据准备笔记本的输出)。

  2. 当上传完成时,你将在存储桶详细信息页面中看到该文件。点击三个点,然后复制 gsutil URI,如图 11.20 所示。

图像

图 11.20 复制 gsutil URI

gsutil 统一资源标识符 (URI) 值将如下所示:gs://first-project-ml-tabular-bucket/processed_dataset/kl_real_estate_output.csv

现在我们已经将清洗后的数据集上传到 Google Cloud Storage 存储桶中,我们可以用它来创建一个 Vertex AI 数据集。

11.2.7 创建 Vertex AI 管理数据集

机器学习管道调用 Vertex AI SDK 来训练模型;它将用于训练模型的数据库识别为 Vertex AI 管理数据集。要了解更多关于 Vertex AI 管理数据集的信息,请参阅文档:mng.bz/VVRP

Vertex AI SDK 自动执行以下操作,使管理数据集可供训练脚本使用:

  • 将数据集的内容复制到云存储。

  • 将数据集划分为训练、验证和测试子集。每个子集的数据集比例在管道配置文件 pipeline_config.yml 中设置,如图 11.2 所示。

图像

图 11.21 管道配置中的训练、验证和测试比例

  • 将每个子集划分为多个 CSV 文件。图 11.22 展示了数据集在云存储中 CSV 文件的一个示例。

图像

图 11.22 Google Cloud Storage 中的处理后的数据集

现在我们已经看到了数据集在云存储中的设置过程,让我们来了解一下创建用于训练数据的 Vertex AI 数据集的步骤。

  1. 在 Vertex AI 中选择“数据集”。在数据集页面,点击创建,如图 11.23 所示。

图像

图 11.23 创建数据集

  1. 在创建数据集页面,将 kuala-lumpur-real-estate 设置为数据集名称,选择表格选项卡,选择回归/分类,然后点击创建,如图 11.24 所示。

图像

图 11.24 指定数据集详细信息

  1. 在“源”选项卡中,选择从云存储选择 CSV 文件。在导入文件路径中,点击浏览,选择上一节中上传处理后的训练文件所在的云存储桶位置,然后点击继续,如图 11.25 所示。

图像

图 11.25 指定数据集的源

  1. 注意你刚刚创建的数据集的 ID 值,如图 11.26 所示。

图像

图 11.26 Google Cloud 控制台中的数据集 ID

这是需要在管道配置文件 pipeline_config.yml 中设置的 dataset_id 的值,如图 11.27 所示。

图像

图 11.27 管道配置文件中的dataset_id

恭喜!您已经为机器学习管道中模型训练部分将用于训练模型的那个数据集设置了 Vertex AI 托管数据集。

11.3 定义机器学习管道

到目前为止,在本章中,我们已经完成了以下机器学习管道的准备工作:

  1. 创建了一个服务账户和服务账户密钥

  2. 将服务账户密钥上传到我们将运行管道脚本的目录

  3. 将清理后的数据集上传到云存储

  4. 从清理后的数据集中创建了一个 Vertex AI 管理的数据集

在本节中,我们将使用上一节中准备好的元素来创建一个机器学习管道,该管道在一端接收预处理后的数据集,并在另一端通过 Vertex AI 端点部署训练好的模型。

11.3.1 本地实现与机器学习管道

在我们继续定义机器学习管道之前,让我们将机器学习管道与本地设置进行对比,以训练我们在第十章中实现的吉隆坡房地产价格预测模型。图 11.28 显示了这种对比并突出了两种实现之间的差异。

图 11.28 在本地系统上训练与使用机器学习管道训练

图 11.28 对比了完全本地实现与使用机器学习管道的训练过程的结构。机器学习管道实现与本地系统实现解决方案的关键区别在于

  • 数据清理过程是相同的。在现实世界的生产管道中,我们会将这个数据处理步骤移动到 Vertex AI 环境中,并使其成为机器学习管道的一部分,但为了使机器学习管道尽可能简单,我们在我们的机器学习管道实现中跳过这一步骤,并从清理后的数据集开始管道。

  • 在本地实现中,数据清理过程的输出是一个 pickle 文件。为了避免兼容性问题,我们切换到 CSV 文件用于机器学习管道。机器学习管道将这个 CSV 文件的内容分割成训练、验证和测试子集,每个子集在云存储中分割成多个 CSV 文件。

  • 机器学习管道实现中的训练代码在一个 Python .py 文件(模型训练脚本)中,而不是笔记本中。在下一节中描述了为了使其在容器环境中工作而对训练代码进行的重大更新。

  • 在机器学习管道实现中,模型训练配置文件位于云存储中,这样管道脚本就可以将其位置作为参数与模型训练脚本共享。

  • 管道脚本是在机器学习管道中的新组件。此脚本设置模型训练脚本所需的输入,使用 Vertex AI SDK 为模型训练脚本创建一个容器,并调用脚本进行模型训练。

  • 管道配置文件是 ML 管道中的新组件。此配置文件包含管道脚本的参数,包括用于 ML 管道的内置 Vertex AI 容器;每个训练、验证和测试子集的清理数据集比例;数据集 ID;以及训练脚本代码的位置。

  • 训练好的模型会自动放入 ML 管道实现中的模型注册库,并部署到 Vertex AI 端点。在本地系统实现中,我们手动将模型上传到云存储,然后部署到端点。

端点即本地系统实现和机器学习(ML)管道实现的结果,可以通过更新 Flask 服务器配置文件中的 endpoint_id 参数简单地插入到我们的网络部署中,如图 11.29 所示。

图 11.29 使用本地或 ML 管道端点的网络部署

关于在 Vertex AI 上训练自定义模型的流程的更多详细信息,请参阅文档:mng.bz/xKjW

11.3.2 容器简介

Vertex AI 中 ML 管道的一个关键点是使用容器使模型训练过程易于自动化和灵活。在本节中,我们将简要介绍容器及其对 ML 管道的益处。如果您已经熟悉容器和 Docker 的概念,可以跳过本节。

容器是一种软件构造,允许您将应用程序及其依赖项打包在一起,以便您可以在各种环境中可预测和高效地运行应用程序。Google Cloud 使用 Docker 容器。关于容器的详细描述超出了本书的范围,但我们需要花一些时间来了解它们为什么用于 ML 管道以及它们对我们代码施加的限制。有关容器的更多详细信息,请参阅 Docker 网站:www.docker.com/resources/what-container/

11.3.3 在 ML 管道中使用容器的优势

使用容器打包训练代码意味着我们不必担心训练所需的 Python 库,因为容器已经预装了所有必需的 Python 库。此外,代码在任何地方都易于重现。Vertex AI 为最流行的机器学习框架(包括 PyTorch、TensorFlow 和 XGBoost)提供了一系列预构建容器镜像。我们为我们的 ML 管道使用 TensorFlow 预构建容器。有关预构建容器的详细信息,请参阅 Vertex AI 文档:

如果我们的训练最终变得更加复杂(无论是从训练周期需要完成的速度还是完成给定持续时间训练周期所需的计算资源方面),我们可以利用训练的容器化特性来将训练分布在多个计算引擎上。对于像吉隆坡房地产价格预测这样的简单问题,单个节点就足以进行训练,但更大的应用确实可以从分布式训练中受益。关于使用 Vertex AI 进行分布式训练的所有可用选项的详细解释超出了本书的范围。如果您想了解更多细节,请查看文档:mng.bz/RVmK

11.3.4 容器中运行代码的介绍

现在我们已经回顾了使用容器进行训练过程的一些好处,我们可以看看运行训练代码在容器中所需的变化。为了理解在非虚拟化环境中运行代码和在容器中运行代码之间的区别,将容器视为一个自包含的机器,其中代码运行,这有助于理解。特别是,在容器中运行的代码默认情况下无法访问管理容器的环境文件系统。图 11.30 展示了模型训练笔记本如何与文件系统中的文件交互。

图片

图 11.30 训练代码与外部文件的交互

当训练代码在容器中运行时,它无法访问外部本地文件系统中的文件。相反,模型训练脚本使用的工件存储在云存储中,这些工件在云存储中的位置作为 URI 传递给模型训练脚本。图 11.31 给出了如何解释 Google Cloud Storage URI 的示例。

图片

图 11.31 解释 Google Cloud Storage URI

在机器学习流程中,我们使用两种方法将 URI 传递给在容器中运行的训练脚本:通过 Vertex AI SDK 在容器中设置的环境变量,以及通过流程脚本中 job.run 调用的参数列表,如图 11.32 所示。

图片

图 11.32 训练代码与云存储中的内容的交互

训练数据的位置(分为训练、验证和测试子集)会自动分配给环境变量,这些变量在流程脚本设置容器时在容器中设置。这对于所有 Vertex AI 容器都是标准的;请参阅mng.bz/2y70上的文档。

将配置文件的 URI 传递给模型训练脚本的方式不是 Vertex AI 的默认方式。如果我们有一个只有少量参数的训练脚本,我们可以创建一个包含参数值的argparser列表,并将其传递给模型训练脚本。由于我们的应用程序配置文件过于复杂,这样做效率不高,所以我们不是逐个传递参数,而是传递一个单一参数:我们保存配置文件副本的云存储位置的 URI。有了这个,模型训练脚本所需做的只是从参数列表中获取云存储位置,并从那里获取 YAML 文件。一旦参数被拉入模型训练脚本中的配置字典,使用它们的其余代码可以保持不变。这是一个主要的好处。

11.3.5 更新训练代码以在容器中工作

在本节中,我们将回顾我们如何将第九章中在 Colab 中运行的模型训练笔记本(mng.bz/1XJj)修改为预测吉隆坡房地产价格的模型。通过这些修改,我们将模型训练笔记本转换为一个可以在 Vertex AI 内置容器中运行的训练脚本。

以下是我们对训练笔记本所做的关键更改,以创建训练脚本:

  • 移除了不必要的库导入和相关代码。例如,当我们运行训练脚本时,我们不需要生成模型的图表,所以我们移除了与plot_model相关的代码。

  • 移除了将数据集拆分为训练、验证和测试子集的代码。在 ML 管道中,Vertex AI SDK 会在测试脚本启动之前负责拆分数据集。

  • 添加了代码来解释job.run参数列表,如下所示。

列表 11.1 加载保存的 Keras 模型

parser = argparse.ArgumentParser()                           ①
parser.add_argument(
        '--config_bucket',                                   ②
        help='Config details',
        required=True
    )
args = parser.parse_args().__dict__                          ③
config_bucket = args['config_bucket']                        ④

① 定义一个 argparser 对象,用于 Vertex AI SDK 传递的参数

② 将 config_bucket 参数添加到 argparser 对象中

③ 将 Vertex AI SDK 传递的参数作为字典获取

④ 从参数字典中获取配置文件 URI

  • 更新了获取训练配置文件的代码,使其从通过管道脚本传递的云存储 URI(列表 11.1 中的config_bucket)获取配置文件的内容,而不是从本地文件系统获取。如下所示,云存储中的配置文件 URI(config_bucket)用于将配置文件从云存储复制到容器中的一个文件,然后该文件的 内容被复制到字典config中。

列表 11.2 通过 URI 参数获取训练配置文件

bucket_name = config_bucket.split("/")[2]                    ①
object_name = "/".join(config_bucket.split("/")[3:])         ②
storage_client2 = storage.Client()                           ③
bucket = storage_client2.bucket(bucket_name)                 ④
blob_out = bucket.blob(object_name)                          ⑤
destination_file_name = 'config.yml'                         ⑥
blob_out.\
download_to_filename(destination_file_name)                  ⑦
try:
    with open (destination_file_name, 'r') as c_file:
        config = yaml.safe_load(c_file)                      ⑧
except Exception as e:
    print('Error reading the config file')

① 获取 config_bucket 的存储桶前缀

② 获取 config_buckett 的文件路径后缀

③ 定义一个 storage.Client 对象

④ 为存储桶创建一个存储对象

⑤ 为文件创建一个存储对象

⑥ 设置容器中配置文件副本的名称

⑦ 从云存储将文件下载到容器

⑧ 将容器版本的配置文件内容读取到字典中

  • 复制 Vertex AI SDK 在容器中设置的 AIP 环境变量的值。这些环境变量包含 SDK 在 Google Storage 中创建的 CSV 文件的 URI 模式,这些 CSV 文件包含数据集的训练、验证和测试子集。

列表 11.3 复制 AIP 环境变量值

def assign_container_env_variables():
    OUTPUT_MODEL_DIR = os.getenv("AIP_MODEL_DIR")             ①
    TRAIN_DATA_PATTERN = \
os.getenv("AIP_TRAINING_DATA_URI")                            ②
    EVAL_DATA_PATTERN = \
os.getenv("AIP_VALIDATION_DATA_URI")                          ③
    TEST_DATA_PATTERN = \
os.getenv("AIP_TEST_DATA_URI")                                ④
    return OUTPUT_MODEL_DIR, TRAIN_DATA_PATTERN, \
EVAL_DATA_PATTERN, TEST_DATA_PATTERN

① 获取保存训练模型的 URI 位置

② 获取训练数据集 CSV 的 URI

③ 获取验证数据集 CSV 的 URI

④ 获取测试数据集 CSV 的 URI

  • 从 AIP 环境变量中的每个模式创建了 dataframe。对于这些环境变量中的每一个,我们解析了 URI,获取了匹配模式的 CSV 文件列表,并在云存储中重新组装成单个 dataframe。

列表 11.4 创建数据集子集的 dataframe

bucket_pattern = tracer_pattern.split("/")[2]                  ①
pattern = "/".join(tracer_pattern.split("/")[3:])              ②
pattern_client = storage.Client()                              ③
bucket = pattern_client.get_bucket(bucket_pattern)
blobs = bucket.list_blobs()                                    ④
matching_files = [f"gs://{bucket_pattern}/{blob.name}" \
for blob in blobs if fnmatch.fnmatch(blob.name, pattern)]      ⑤
merged_data = \
pd.concat([pd.read_csv(f) for f in matching_files], 
ignore_index=True)                                             ⑥

① 对于每个文件模式,获取存储桶前缀

② 获取 CSV 文件模式

③ 定义一个 storage.Client 对象

④ 获取匹配模式的存储桶中的 CSV 列表

⑤ 获取匹配模式的 CSV 的完全限定 URI 列表

⑥ 创建包含所有匹配模式的 CSV 内容的数据 frame

  • 将训练模型保存到由OUTPUT_MODEL_DIR指定的位置,这是 Vertex AI SDK 设置的保存模型的位置:
tf.saved_model.save(model, OUTPUT_MODEL_DIR)

经过这些更改后,其余的训练代码可以在容器中运行。现在我们已经完成了创建训练脚本所需的更新,在下一节中,我们将探讨设置训练脚本运行的容器的管道脚本的关键部分。

11.3.6 管道脚本

现在我们已经完成了训练脚本,我们可以检查组成管道脚本的代码。您可以在mng.bz/PdRn查看完整的管道脚本代码。

管道脚本的关键部分是

  • 读取管道配置文件:mng.bz/JYdV

  • 设置训练脚本的参数:

model_args = ['--config_bucket', config['config_bucket_path']]
  • 创建一个指定训练脚本位置script_path、用于训练的预构建镜像container_uri以及需要在训练容器中安装的任何附加 Python 库的CustomTrainingJob对象,如下所示。

列表 11.5 创建CustomTrainingJob对象

def create_job(config):
    model_display_name = '{}-{}'.format(config['ENDPOINT_NAME'], TIMESTAMP)
    job = aiplatform.CustomTrainingJob(
            display_name='train-{}'.format(model_display_name),
            script_path = config['script_path'],
            container_uri=config['train_image'],            ①
            staging_bucket = config['staging_path'],
            requirements=['gcsfs'],                         ②
            model_serving_container_image_uri= \
config['deploy_image']                                      ③
    ) 
    return job

① 设置运行训练脚本的预构建 Vertex AI 容器镜像

② 定义在容器中安装的任何附加要求列表

③ 设置用于预测的预构建 Vertex AI 容器镜像

  • 定义用于训练的托管数据集的路径(使用 11.2.7 节中创建的托管数据集的 ID)并使用该路径创建一个TabularDataset对象:
dataset_path = \
'projects/'+config['project_id']+\
'/locations/'+config['region']+\
'/datasets/'+config['dataset_id']
 ds = aiplatform.TabularDataset(dataset_path)
  • 运行之前定义的作业,指定在此创建的数据集;用于训练、验证和测试的数据集比例;以及用于训练的machine_type

列表 11.6 运行作业

def run_job(job, ds, model_args,config):
    model_display_name = \
'{}-{}'.format(config['ENDPOINT_NAME'], TIMESTAMP)
    model = job.run(
        dataset=ds,                                           ①
        training_fraction_split = \
config['training_fraction_split'],                            ②
        validation_fraction_split = config['validation_fraction_split'],
        test_fraction_split = config['test_fraction_split'],
        model_display_name=model_display_name,
        args=model_args,                                      ③
        machine_type= config['machine_type'] 
    )
    return model

① 将作业与托管数据集关联

② 设置用于训练、验证和测试的数据集比例

③ 设置参数列表(其中包含测试脚本配置文件的 URI)

  • 创建一个端点,并将训练脚本中训练的模型部署到该端点。

列表 11.7 将训练好的模型部署到端点

def deploy_model(model,config):
    endpoints = aiplatform.Endpoint.list(                      ①
        filter='display_name="{}"'.format(config['ENDPOINT_NAME']),
        order_by='create_time desc',
        project=config['project_id'], 
        location=config['region']
    )
    endpoint = aiplatform.Endpoint.create(                     ②
         display_name=config['ENDPOINT_NAME'], 
         project=config['project_id'], 
         location=config['region']
        )
    model.deploy(                                              ③
        endpoint=endpoint,
        traffic_split={"0": 100},
        machine_type=config['machine_type_deploy'],
        min_replica_count=1,
        max_replica_count=1,
    )

① 设置端点的特征

② 创建端点

③ 将模型部署到端点

  • 以下列表是管道脚本的主函数,它调用了运行管道的函数。

列表 11.8 管道脚本的主函数

    start_time = time.time()
    config = get_pipeline_config('pipeline_config.yml')          ①
    model_args = ['--config_bucket', config['config_bucket_path']]
    job = create_job(config)
    dataset_path = \
'projects/'+config['project_id']+\
'/locations/'+config['region']+\
'/datasets/'+config['dataset_id']
    ds = aiplatform.TabularDataset(dataset_path)
    model = run_job(job, ds, model_args,config)                  ②
    if config['deploy_model']:
        deploy_model(model,config)                               ③
    print("pipeline completed")

① 设置端点的特征

② 创建端点

③ 将模型部署到端点

运行管道脚本,请按照以下步骤操作:

  • 在 Cloud Shell 的新目录中克隆github.com/lmassaron/ml_on_tabular_data,并将chapter_11设置为当前目录。

  • 更新管道配置文件,确保project_idregion与您的项目设置匹配,dataset_id与您的托管数据集 ID 匹配,staging_path与您的暂存路径匹配,以及config_bucket_path与云存储中您复制训练脚本配置文件的路径匹配,如图 11.33 所示。

图 11.33 训练代码与云存储中内容的交互

  • 在您克隆仓库的根目录中,输入以下命令:
python pipeline_script.py

注意,运行整个管道脚本可能需要 10 分钟或更长时间。如果脚本失败,您将收到一条包含包含有关训练运行诊断信息的日志文件链接的消息。如果脚本成功,输出将以管道完成和运行时间结束。

11.3.7 测试管道中训练的模型

一旦您运行了管道脚本以运行机器学习管道来训练和部署模型,您就可以使用生成的 Vertex AI 端点在我们在第十章中使用的相同 Web 部署框架中测试模型。请注意,使用这种简单的 Web 部署测试端点并不符合您在生产环境中会做的事情。然而,使用我们在第十章中使用的相同 Web 部署简化了此练习的测试过程。

测试管道中训练的模型的步骤是

  1. 在 Google Cloud 控制台中,转到 Vertex AI 端点。复制由机器学习管道创建的部署 ID,如图 11.34 所示。

图 11.34 由管道生成的模型端点 ID

  1. 在您在第十章中使用 Flask 测试初始端点部署的同一本地系统中,将您刚刚复制的端点 ID 粘贴到 flask_web_deploy_config.yml 配置文件中的 endpoint_id 参数 值中,并保存文件:
endpoint:
   project: "1028332300603"
   endpoint_id: "1447850105594970112"
   location: "us-central1"
  1. 在您的本地系统中,启动 Flask 服务器模块:
python flask_endpoint_deploy.py
  1. 一旦 Flask 服务器模块运行,请在浏览器中转到 localhost:5000home.html 将如图 11.35 所示渲染。当您点击“获取预测”时,由 ML 管道训练和部署在 Vertex AI 端点的模型将被调用(见图 11.35)。

图片

图 11.35 Home.html

注意,您在用于模型训练的预构建容器中使用的 TensorFlow 级别必须与您运行 Web 应用程序以测试端点的环境中的 TensorFlow 级别相匹配。例如,如果我们想在具有 TensorFlow 2.9 的环境中练习端点部署,那么在管道配置文件中,我们需要为 train_image(预构建训练容器)指定一个与该 TensorFlow 级别一致的值,例如 us-docker.pkg.dev/vertex-ai/training/tf-cpu.2-9:latest

如果您在 Cloud Shell 中运行管道脚本时遇到 protobuf 错误,请尝试运行以下命令以指定 protobuf 级别:

pip install protobuf==3.20.*

如果您想尝试不同的训练配置,您可以更新训练配置文件,将其上传到云存储(确保管道配置文件中 config_bucket_path 的值与训练配置文件的 URI 匹配),然后重新运行管道脚本。您可以通过更新管道配置文件中的 endpoint_id 值以匹配新端点的端点 ID,并重复本节中的步骤来使用 Web 应用程序练习新模型。通过在 ML 工作流程中将多个步骤封装在 ML 管道中,我们使得获得可重复的结果和尝试新设置变得容易。

11.4 使用生成式 AI 帮助创建 ML 管道

到目前为止,在本章中,我们已经看到我们如何使用 Google Cloud 中的操作组合和手动脚本来设置一个基本的 ML 管道,以训练和部署在表格数据上训练的模型。在本节中,我们将探讨我们如何使用第十章中介绍的 Google Cloud 的 Gemini 生成式 AI 功能来简化或自动化这些操作中的某些部分。正如我们在第十章中看到的,Gemini for Google Cloud 有四种方式可以帮助我们:

  • 回答有关 Google Cloud 的问题。

  • 从文本生成代码。

  • 解释代码。也就是说,给定一段代码,生成解释该代码做什么的文本。我们可以使用这种能力来帮助我们理解我们从其他地方改编的代码。我们还可以使用这种能力来记录我们正在编写的代码。

  • 概括日志条目以帮助调试问题。

11.4.1 使用 Gemini for Google Cloud 回答有关 ML 管道的问题

正如我们在第十章中看到的,我们可以使用 Gemini for Google Cloud 中的生成式 AI 功能来获取有关 Google Cloud 的问题的答案。以下是一些关于创建 ML 流水线的问题的示例,Gemini for Google Cloud 可以帮助我们回答:

  • 什么是 ML 流水线?虽然 Gemini for Google Cloud 是专门针对 Google Cloud 进行训练的,但它能够回答关于技术等广泛的问题,如这个问题。请注意,图 11.36 中显示的答案是普遍适用的,并不仅限于 Google Cloud。引用来自各种可信来源,包括 TensorFlow 和 Scikit-learn 的文档:

图 11.36 Gemini for Google Cloud 回答了“什么是 ML 流水线?”的问题

  • Vertex AI 流水线是什么?当我们对同一个问题进行限定,如图 11.37 所示,Gemini for Google Cloud 会给我们一个针对 Google Cloud 中 ML 流水线实现的特定答案。

图 11.37 Gemini for Google Cloud 回答了“什么是 Vertex AI 流水线?”的问题

  • Vertex AI 预构建容器用于训练自定义模型是什么?最后,让我们尝试提出一个与本章节中我们解决的问题相关的具体问题。如图 11.38 所示,Gemini for Google Cloud 提供的答案既描述了用于训练自定义模型的预构建容器是什么,也说明了使用它们的目的。

图 11.38 Gemini for Google Cloud 回答了“Vertex AI 预构建容器用于训练自定义模型是什么?”的问题

在本节中,我们看到了如何使用 Gemini for Google Cloud 回答有关构建 ML 流水线的一般性和具体问题。在下一节中,我们将探讨如何使用 Gemini for Google Cloud 生成 ML 流水线所需的代码。

11.4.2 使用 Gemini for Google Cloud 生成 ML 流水线的代码

现在我们已经看到了 Gemini for Google Cloud 如何回答有关创建 ML 流水线的问题,让我们探索 Gemini for Google Cloud 中的生成式 AI 功能如何帮助我们创建与 ML 流水线相关的代码。

Google Cloud 的 Gemini 支持在多个 Google Cloud 支持的 IDE 中启用,包括 VS Code、Cloud Workstations 和 Cloud Shell Editor。在本节中,我们将使用 Cloud Shell Editor 中的 Gemini for Google Cloud。如果您需要刷新 Cloud Shell Editor 的知识,请参阅概述文档:mng.bz/7pvv

我们将看到 Gemini for Google Cloud 如何在管道脚本中生成函数的代码:mng.bz/PdRn 使用此脚本的函数签名和介绍性注释,我们将看到 Gemini for Google Cloud 生成了什么。

首先,如果你还没有这样做,请按照文档说明在 Cloud Shell 编辑器中启用 Gemini 代码助手:mng.bz/mGja

一旦你在 Cloud Shell 编辑器中启用了 Gemini 代码助手,就在 Cloud Shell 编辑器中打开一个新的 Python 文件,并输入 get_pipeline_config 函数的签名和介绍性注释,如下所示。

列表 11.9 get_pipeline_config 的签名

def get_pipeline_config(path_to_yaml):
    '''ingest the config yaml file
    Args:
        path_to_yaml: yaml file containing parameters for the pipeline script

    Returns:
        config: dictionary containing parameters read from the config file
    '''

注意,此代码片段不包括函数的逻辑。

要让 Gemini for Google Cloud 为此函数生成代码以完成它,只需按 Enter 键。如图 11.39 所示,Gemini for Google Cloud 以斜体生成临时代码。

图 11.39 Gemini for Google Cloud 生成的第一组临时代码

按 Tab 键接受此临时代码,然后再次按 Enter 键以获取下一组生成的代码,如图 11.40 所示。

图 11.40 Gemini for Google Cloud 生成的第二组临时代码

再次按 Tab 键以接受这组第二组临时代码。结果函数如下所示。

列表 11.10 get_pipeline_config 函数

def get_pipeline_config(path_to_yaml):
    '''ingest the config yaml file
    Args:
        path_to_yaml: yaml file containing parameters for the pipeline script

    Returns:
        config: dictionary containing parameters read from the config file
    '''
    with open(path_to_yaml) as file:                     ①
        config = yaml.safe_load(file)

    return config                                        ②

① Gemini for Google Cloud 生成的第一组代码

② Gemini for Google Cloud 生成的第二组代码

列表 11.10 中的代码与 get_pipeline_config 函数的手写代码不完全相同,如下所示。

列表 11.11 get_pipeline_config 函数:手写版本

def get_pipeline_config(path_to_yaml):
    '''ingest the config yaml file
    Args:
        path_to_yaml: yaml file containing parameters for the pipeline script

    Returns:
        config: dictionary containing parameters read from the config file
    '''
    print("path_to_yaml "+path_to_yaml)
    try:                                                  ①
        with open (path_to_yaml, 'r') as c_file:          ②
            config = yaml.safe_load(c_file)
    except Exception as e:
        print('Error reading the config file')
    return config

① 手写代码包括文件打开操作的异常处理

② 手写代码包括文件打开时的 'r' 参数

将列表 11.10 中 Gemini for Google Cloud 生成的代码与列表 11.11 中的手写代码进行比较,我们可以看到两个差异:

  • 手写代码包括异常处理以处理打开配置文件时的问题。

  • 手写代码在文件打开操作中包括 'r' 参数。

get_pipeline_config 函数很简单,但无论如何,Gemini for Google Cloud 都能够为该函数生成可工作的代码。

对于 Gemini for Google Cloud 代码生成的额外考虑

  • 你不必一次性接受 Gemini for Google Cloud 生成的所有临时代码。要逐个接受临时代码标记,请按 CTRL + 右箭头以接受单个标记。

  • 要拒绝整个临时代码生成并重新开始,请按 ESC 键,整个临时代码集将被清除。

  • 当您多次使用完全相同的输入请求 Gemini for Google Cloud 生成代码时,并不能保证生成的代码完全相同。例如,在 get_pipeline_config 示例中,有时 Gemini for Google Cloud 会分两步生成函数,如图中所示,有时它会一次性生成整个函数,包括 return 语句。

现在我们已经使用生成式 AI 生成代码,在下一节中,我们将看到如何用它来解释代码。

11.4.3 使用 Gemini for Google Cloud 解释 ML 管道代码

现在我们已经看到了 Gemini for Google Cloud 生成代码的例子,让我们来练习它解释代码的能力。

要让 Gemini for Google Cloud 解释一段代码片段,请将以下列表中的代码(管道脚本的 main 函数)复制到 Cloud Shell 编辑器中的新文件中。

列表 11.12 get_pipeline_config 函数

if __name__ == '__main__':
    start_time = time.time()
    # load pipeline config parameters
    config = get_pipeline_config('pipeline_config.yml')
    # all the arguments sent to the training 
    #script run in the container are sent via
    # a yaml file in Cloud Storage whose URI is the single argument sent
    model_args = ['--config_bucket', config['config_bucket_path']]
    print("model_args: ",model_args)
    # create a CustomTrainingJob object
    job = create_job(config)
    # define TabularDataset object to use in running CustomTrainingJob
    dataset_path = \
'projects/'+config['project_id']+\
'/locations/'+config['region']+\
'/datasets/'+config['dataset_id']
    ds = aiplatform.TabularDataset(dataset_path)
    # run the CustomTrainingJob object to get a trained model
    model = run_job(job, ds, model_args,config)
    print("deployment starting")
    # deploy model to a Vertex AI endpoint
    if config['deploy_model']:
        deploy_model(model,config)
    print("pipeline completed")
    # show time taken by script
    print("--- %s seconds ---" % (time.time() - start_time))

一旦您将列表 11.13 中的代码粘贴到文件中,请选择它,然后从 Cloud Shell 编辑器工具栏中选择 Gemini for Google Cloud Smart Actions 图标(见图 11.41)。

图片

图 11.41 Gemini for Google Cloud Smart Actions 图标

在出现的菜单中,选择“解释”,如图 11.42 所示。

图片

图 11.42 Gemini for Google Cloud Smart Actions 图标

当您选择此选项时,代码的解释将出现在 Cloud Shell 编辑器的左侧面板中,如图 11.43 所示。

图片

图 11.43 代码解释

Gemini for Google Cloud 的代码解释能力可以应用于各种代码,包括 Python、Java 和 JavaScript 代码。您可以使用代码解释来理解不熟悉的代码,并为您的代码推荐文档。

到目前为止,在本节中,我们已经看到了如何使用 Gemini for Google Cloud 中的生成式 AI 功能来回答问题、生成代码和解释代码。在下一小节中,我们将看到如何使用 Gemini for Google Cloud 来帮助总结日志条目。

11.4.4 使用 Gemini for Google Cloud 总结日志条目

Google Cloud 包含一个日志,您可以使用它来跟踪环境的行为并调试问题。然而,有时日志条目可能难以解释。Gemini for Google Cloud 可以通过总结来帮助您理解日志条目的要点。在本小节中,我们将介绍如何使用 Gemini for Google Cloud 充分利用 Google Cloud 日志。

为了练习 Gemini for Google Cloud 的这一功能,我们将尝试使用 Vertex AI 中的基础模型调优。基础模型调优允许我们使用 JSONL(JSON Lines:jsonlines.org/)数据集中的数据集对预训练模型进行调优。有关在 Vertex AI 中调优文本模型的更多详细信息,请参阅文档:mng.bz/5goO

为了准备本节中的示例,在您在第十章中创建的云存储桶中创建一个名为staging的新文件夹。

在 Google Cloud 控制台的 Vertex AI 中,选择 Vertex AI Studio -> 语言。在语言页面上,选择调整和精炼,然后选择创建调整模型,如图 11.44 所示。

图片

图 11.44 Vertex AI Studio 语言页面

在创建调整模型页面的调整方法面板中:

  • 在“调整模型名称”字段中指定您的模型名称。

  • 在输出目录字段中指定您在本节开头创建的存档文件夹的 URI。

  • 点击“继续”。

见图 11.45。

图片

图 11.45 创建调整模型页面的调整方法面板

在创建调整模型页面的调整数据集面板中,执行以下操作:

  • 在云存储中选择“现有文件”。

  • 在“云存储文件路径”字段中输入此样本 JSONL 文件的 URI cloud-samples-data/vertex-ai/model-evaluation/peft_train_sample.jsonl。有关 JSONL 样本的详细信息,请参阅文档:mng.bz/6ene

  • 点击“开始调整”。

见图 11.46。

图片

图 11.46 创建调整模型页面中的调整数据集面板

一旦点击“开始调整”,你将看到一系列调整后的模型列表,其中你的模型状态显示为“运行中”,如图 11.47 所示。

图片

图 11.47 调整作业状态

当调整作业完成时,状态将更改为“成功”,如图 11.48 所示。

图片

图 11.48 显示完成调整作业状态的调整作业状态

如果调整作业没有成功,那没关系。这个特定练习的目标是检查一个错误,所以如果由于某些原因操作失败,那是可以的。

调整作业完成后,在控制台顶部的搜索字段中输入“logs explorer”以进入日志资源管理器页面。此页面提供了检查由 Google Cloud 生成的日志的许多选项。现在,我们只想查看一个错误。要查看错误,请如图 11.49 所示,在日志资源管理器页面的左下角选择“错误”。

图片

图 11.49 显示完成调整作业状态的调整作业状态

页面底部的查询结果面板显示了错误,如图 11.50 所示。

图片

图 11.50 显示错误的查询结果面板

选择这些错误条目之一以展开它,并点击如图 11.51 所示的“解释此日志条目”。

图片

图 11.51 展开的错误条目

在右侧,Gemini for Google Cloud 显示了错误解释,如图 11.52 所示。

图片

图 11.52 错误解释

Gemini 为 Google Cloud 提供的解释总结了日志中的嵌套条目,使其更容易阅读和解释。请注意,您将看到的解释将取决于您从日志中选择的错误。

11.4.5 在 Vertex AI 中调整基础模型

在前一小节中,我们看到了如何使用 Gemini for Google Cloud 的生成式 AI 功能来解释错误日志。值得仔细看看我们触发的操作,以生成我们可以用 Gemini for Google Cloud 检查的日志。以下是我们的操作总结:

  • 我们从 Vertex AI 中可用的基础模型之一text-bison开始。此模型旨在处理各种自然语言任务,如内容创作和分类。有关text-bison的更多详细信息,请参阅文档:mng.bz/oKrZ

  • 我们对text-bison基础模型进行了监督式调整,以适应特定的用例——我们的用例是分类医学转录。要了解更多关于 Vertex AI 中基础模型的监督式调整信息,请参阅文档:mng.bz/nR15

  • 我们用于调整的数据集包含与转录分类配对的医学诊断转录,如下所示。

列表 11.13 调整数据集的示例记录

{
"input_text":"TRANSCRIPT: \nREASON FOR CONSULTATION: , 
Loculated left effusion, 
multilobar pneumonia.\n\n LABEL:",                      ①
"output_text":"Consult - History and Phy."              ②
}

① 医学转录

② 分类

  • 此数据集的 URI 为gs://cloud-samples-data/vertex-ai/model-evaluation/peft_train_sample.jsonl

调整过程完成后,您可以在 Vertex AI Studio 中通过选择语言 -> 调整和精炼,然后选择之前小节中调整的模型的行中的测试来测试调整后的模型,如图 11.53 所示。

图片

图 11.53 在 Vertex AI Studio 中选择调整后的模型

提示编辑器以选定的调整后的模型作为模型打开,如图 11.54 所示。

图片

图 11.54 选择调整后的模型的提示编辑器

通过在提示字段中输入以下文本并点击提交来训练调整后的模型:

TRANSCRIPT: \nIMPRESSION:  ,EEG during wakefulness, 
drowsiness, and sleep with synchronous 
video monitoring demonstrated no evidence 
of focal or epileptogenic activity.\n\n LABEL:

注意响应,如图 11.55 所示。

图片

图 11.55 睡眠医学响应

现在将模型改回基础模型text-bison@001,如图 11.56 所示,然后再次点击提交。

图片

图 11.56 将模型改回 text-bison@001

使用调整后的模型和未调整的基础模型从提示中得到的响应有什么区别?使用调整后的模型,你可以获得基础模型的所有功能,以及适用于医学转录分类用例的适当响应。

如果你检查我们用于调整基础模型(URI 为 gs://cloud-samples-data/vertex-ai/model-evaluation/peft_train_sample.jsonl)的数据集,你会注意到它实际上是一个包含两列的表格数据集:一列包含医疗转录笔记,另一列包含笔记的分类,例如“心血管/肺”、“脊椎按摩”或“疼痛管理”。到目前为止,在这本书中,我们已经探讨了如何将生成式 AI 应用到表格数据的机器学习工作流程中。我们在日志解释练习中使用的示例展示了表格数据与生成式 AI 之间不同类型的关系:表格数据是生成式 AI 工作流程的一部分。对这个主题的详细探讨超出了本书的范围,但我们认为,表格数据在生成式 AI 工作流程中的作用是一个研究不足的领域,可能会在充分利用生成式 AI 方面带来显著的好处。

在本节中,我们看到了如何使用 Gemini 来利用 Google Cloud 的生成式 AI 功能来回答有关 ML 管道的问题,生成创建一个所需的部分代码,解释构成 ML 管道的代码,以及解释日志消息。

摘要

  • 在 Vertex AI 中设置 ML 管道之前,需要完成几个设置任务。

  • 需要创建一个服务账户,并将服务账户密钥上传到运行管道脚本的目录。

  • 将用于在管道中训练模型的训练数据集需要上传到 Google Cloud Storage 存储桶。然后需要使用该存储桶位置来定义一个 Vertex AI 数据集,该数据集将在管道脚本中作为 Vertex AI SDK 的参数使用。

  • 在 Vertex AI 预建容器中运行的训练脚本无法访问容器外的文件系统,因此训练数据集和 ML 管道实现使用的训练配置文件位于云存储中,它们的地址作为 URI 传递给训练脚本。

  • 我们在第九章的 Colab 中运行的训练笔记本中的训练代码需要适配以在容器中运行。例如,需要更新训练脚本以使用配置文件、训练数据和训练模型应保存的位置的云存储位置。

  • 管道脚本调用 Vertex AI SDK 的一系列函数来创建训练脚本运行的容器,运行训练脚本,并将训练好的模型部署到 Vertex AI 端点。

  • 你可以使用在第十章中用于练习本地部署的相同 Web 应用来练习由 ML 管道生成的端点部署。

  • 你可以在创建 ML 管道过程的各个步骤中使用 Gemini(集成在 Google Cloud 中的生成式 AI 工具包)来回答问题、从文本生成代码、解释代码以及解释日志消息。

第十二章:混合梯度提升和深度学习

本章涵盖

  • 对第七章中端到端梯度提升示例的回顾

  • 将第七章中梯度提升示例的结果与同一问题的深度学习解决方案进行比较

  • 将梯度提升模型与深度学习模型集成后的结果

在第七章中,我们对使用梯度提升的端到端示例进行了深入探索。我们探索了东京 Airbnb 列表的数据集,我们为定价回归任务创建了合适的特征,然后我们创建了一个基于此数据集的基线模型来预测价格。最后,应用我们在书中学到的方法,我们优化了基于此数据集训练的 XGBoost 模型,并检查了一些解释模型行为的方法。

在本章中,我们评估使用深度学习是否会导致同一问题上的不同结果和性能,确定哪种方法最有效,并发现如何使用和整合每种方法的优点和缺点。为此,我们首先回顾第七章中关于 Airbnb 东京问题的梯度提升方法。接下来,我们回顾一些我们可以采取的方法来将深度学习应用于同一问题,并分享我们选择的针对该问题的深度学习方法。最后,我们比较这两种解决方案,并确定在决定是否使用梯度提升或深度学习来解决表格数据问题时,我们可以从这种比较中学到什么,无论是回归问题还是分类问题。除了根据核心性能(例如每个解决方案的预测能力和推理时间)比较解决方案外,我们还探讨了两种解决方案在更多面向业务的指标(如维护成本、业务利益相关者的清晰度以及部署后的稳定性)方面的比较。

本章将本书中你看到的一些主题汇总在一起,结合我们在第七章中学到的 XGBoost 知识,以及我们在第一章和第八章中学到的如何使用深度学习处理表格数据的方法。

在第一章中,我们回顾了一些比较经典机器学习在表格数据应用与深度学习应用的论文。在本章中,我们将看到我们比较 XGBoost 与深度学习的结果与我们在第一章中引用的一篇论文的观察结果一致。在第八章中,我们评估了多种处理表格数据的深度学习方法。现在,我们将使用在那里学到的知识来帮助我们选择一个与 XGBoost 性能具有最佳竞争力的深度学习方法。

通过结合本书中探讨的两个主要主题(经典机器学习方法和对解决表格数据问题的深度学习方法),本章总结了本书到目前为止所涵盖的内容,并为将机器学习应用于表格数据的应用提供了指导。本章中展示的代码可在mng.bz/vKPp找到。

12.1 第七章中梯度提升解决方案的回顾

在第七章中,我们为东京的 Airbnb 列表创建了一个数据集,分析了该数据集的关键特征,并创建了一个 XGBoost 模型来预测列表的价格。从 Inside Airbnb Network 网站(insideairbnb.com/)开始,我们下载了以下与东京市相关的文件:

  • listings.csv,其中包含东京 Airbnb 住宿的摘要列表和其他信息

  • calendar.csv.gz,一个包含calendar.csv的压缩文件,该数据集包含每个列表在给定年份的占用和价格信息

请记住,listings.csv数据集包含以下列:

  • id—这是 Airbnb 上每个列表的唯一标识符。它是一个int64数据类型,意味着它是一个数值 ID 表示。在其他表中,它可以被称为listing_id

  • name—Airbnb 列表的描述。它属于object数据类型,通常表示字符串或文本。

  • host_id—这是 Airbnb 上每个房东的唯一标识符。它是一个int64数据类型。

  • host_name—拥有列表的房东的姓名。它属于object数据类型。

  • neighbourhood_group—此字段表示该社区所属的更广泛的区域或地区。它存储为float64数据类型,但需要注意的是,使用浮点数据类型来表示组或类别是不常见的。在这种情况下,浮点值的存在表明该字段的全部数据都是缺失值。

  • neighbourhood—列表所在的具体社区。它属于object数据类型。

  • latitude—列表位置的纬度坐标。它属于float64数据类型。

  • longitude—列表位置的经度坐标。它属于float64数据类型。

  • room_type—列表中提供的房间或住宿类型(例如,整个房屋/公寓、私人房间、共享房间)。它属于object数据类型。

  • price—租赁列表每晚的价格。它属于int64数据类型,表示一个整数值。

  • minimum_nights—预订列表所需的最少夜晚数。它属于int64数据类型。

  • number_of_reviews—列表收到的总评论数。它属于int64数据类型。

  • last_review—列表收到的最后一条评论的日期。数据类型为object,可能代表日期和时间信息,但可能需要进一步解析才能有效使用。

  • reviews_per_month—列表每月的平均评论数量。数据类型为float64

  • calculated_host_listings_count—房东在 Airbnb 上的列表总数。数据类型为int64

  • availability_365—列表一年中可供预订的天数(365 天中的天数)。数据类型为int64

  • number_of_reviews_ltm—过去 12 个月内收到的评论数量。数据类型为int64

  • license—列表的许可证号码或相关信息。数据类型为object,通常表示字符串或文本。

第七章中我们创建的模型的目标是预测新列表的价格。这实际上比我们在第八章中解决的 Airbnb 纽约问题对深度学习模型更具挑战性,正如表 12.1 中的比较所示。

表 12.1 Airbnb 纽约问题与 Airbnb 东京问题比较

Airbnb 纽约 Airbnb 东京
数据集行数 48,000 10,000
数据集列数 18 31
目标 分类:预测价格是否超过或低于中位数价格 回归:预测价格

实际上,东京 Airbnb 数据集的记录数少于纽约 Airbnb 数据集的 25%,并且列数是纽约 Airbnb 数据集的两倍以上。数据点较少时,你可能需要更多地依赖领域专业知识(因此特征工程的作用)。列数较多意味着在训练过程中过拟合的风险更高,并且无论如何,你必须处理特征与目标变量本身之间更复杂的关系。

通常来说,经典机器学习方法在小数据集上可以获得比深度学习更好的结果。数据增强等技术可以减轻深度学习的这一缺点,但深度学习方法在行数少于数万的数据集上会面临挑战。关于为什么深度学习需要更多数据的研究尚不完整,但深度学习架构中的大量参数以及模型泛化至少需要一定数量的数据被认为是深度学习在处理小数据集问题时遇到困难的原因之一[例如,参见 Thompson 等人撰写的“深度学习的计算限制”(arxiv.org/pdf/2007.05558.pdf),该文还论证了计算效率对于方法进步的必要性]。

东京 Airbnb 数据集中行数较少,这无疑给深度学习解决方案的成功实施带来了挑战。此外,数据集中列数较多,需要更广泛的数据预处理和特征工程,以确保生成的特征对建模目的相关且可用。由于我们遵循了与第七章相同的数据准备步骤,我们不应该担心需要处理更多列的特定应用,但值得记住,对于其他数据集来说,更多的列意味着需要制定有效的策略来处理缺失值、处理多重共线性(如我们在第二章中讨论的)以及选择最有信息量的特征。除了需要更多的数据准备外,更多的列还可能导致冗余或噪声特征,这可能会降低深度学习解决方案在该数据集上的有效性,尽管深度学习模型由于其能够捕捉特征与目标变量之间复杂关系的能力,通常对数据中的噪声更具有鲁棒性。

此外,我们用东京 Airbnb 数据集解决的问题是回归(预测列表价格),而不是二分类(预测给定列表的价格是否高于或低于中位数价格),这是我们尝试用纽约 Airbnb 数据集解决的问题,这具有不同的商业影响。如果目标是提供能够带来商业效益的解决方案,我们的解决方案应该尽可能正确地预测。在二分类问题中,解决方案是离散的(一个类别或另一个类别),从商业角度来看,解决方案做出正确预测的可能性比回归问题中看起来要高,在回归问题中,商业期望模型预测的价格与实际价格非常接近,但输出是连续值,可能显著不同于预期值。简而言之,从满足商业需求的角度来看,二分类问题看起来比回归问题更容易——因为它有一个明确的正确性阈值。

在第七章中,在完成对 Airbnb 东京数据集的一系列转换后,我们得到了以下一组数据集特征:

array(['onehot_encoding__room_type_Entire home/apt',
       'onehot_encoding__room_type_Hotel room',
       'onehot_encoding__room_type_Private room',
       'onehot_encoding__room_type_Shared room',
       'ordinal_encoding__neighbourhood_more_than_30',
       'ordinal_encoding__type_of_accommodation',
       'target_encoding__coordinates', 'numeric__minimum_nights',
       'numeric__number_of_reviews', 'numeric__days_since_last_review',
       'numeric__reviews_per_month',
       'numeric__calculated_host_listings_count',
       'numeric__availability_365', 'numeric__score',
       'numeric__number_of_reviews_ltm',
       'numeric__number_of_reviews_ltm_ratio',
       'numeric__number_of_bedrooms', 'numeric__number_of_beds',
       'numeric__number_of_baths', 'numeric__imperial_palace_distance',
       'numeric__nearest_convenience_store',
       'numeric__nearest_train_station', 'numeric__nearest_airport',
       'numeric__nearest_bus_station', 'numeric__nearest_subway',
       'binary__is_new', 'binary__is_studio', 'binary__has_shared_bath',
       'binary__has_half_bath'], dtype=object)

所有这些特征都是数值型的,我们也妥善处理了任何缺失值,以便能够使用线性模型基线。事实上,我们首先创建了一个线性回归模型作为基线,并为我们提供了一个衡量标准,可以用来比较 XGBoost 模型的进一步改进。

准备你的数据以进行线性回归或逻辑回归(取决于是否是回归或分类问题)会自动使你的模型准备好由神经网络处理。然而,虽然这很方便,但它可能会错过一些仅适用于神经网络的特定准备。例如,在线性模型中,分类特征通常使用 one-hot-encoding 处理,而在神经网络中,你可以在训练过程中使用编码层直接将分类值转换为数值。

在获得线性回归的基线结果后,我们对 XGBoost 模型进行了一系列优化。我们使用这个优化的 XGBoost 代码作为解决东京 Airbnb 问题的梯度提升解决方案的基础,我们将在本章中将它用于与深度学习的比较。列表 12.1 显示了我们将用于与深度学习解决方案比较的 XGBoost 代码。这个版本的 XGBoost 代码与第七章中使用的最终 XGBoost 代码非常接近。超参数与第七章中使用的笔记本的最佳超参数相匹配;mng.bz/4a6R)。在本章使用的代码版本中,预测结果保存在xgb_oof_preds数组中,以便进一步处理或与我们将从深度学习模型获得的预测一起使用。

列表 12.1 训练最终 XGBoost 模型的代码

xgb_params =  {'booster': 'gbtree',                          ①
               'objective': 'reg:tweedie', 
               'n_estimators': 932, 
               'learning_rate': 0.08588055025922144, 
               'subsample': 0.9566295202123205, 
               'colsample_bytree': 0.6730567082779646, 
               'max_depth': 7, 
               'min_child_weight': 6, 
               'reg_lambda': 6.643211493348415e-06, 
               'reg_alpha': 7.024597970671363e-05, 
               'tweedie_variance_power': 1.6727891016980427}

from sklearn.metrics import r2_score  
from sklearn.metrics import mean_squared_error
from sklearn.metrics import mean_absolute_error              ②
from XGBoost import XGBRegressor
import numpy as np

xgb = XGBRegressor(**xgb_params)                             ③

cv_splits = cv.split(X, y=neighbourhood_more_than_30)        ④

r2_scores = []
rmse_scores = []
mae_scores = []
xgb_oof_preds = np.zeros(len(X))

for train_index, test_index in cv_splits:                    ⑤
    X_train, X_test = X.iloc[train_index], X.iloc[test_index]
    y_train, y_test = y.iloc[train_index], y.iloc[test_index]

    xgb.fit(X_train, y_train)
    y_pred = xgb.predict(X_test)                             ⑥
    xgb_oof_preds[test_index] = y_pred

    r2_scores.append(r2_score(y_test, y_pred))               ⑦
    rmse_scores.append(np.sqrt(mean_squared_error(y_test, y_pred)))
    mae_scores.append(mean_absolute_error(y_test, y_pred))

print(f"Mean cv R-squared: {np.mean(r2_scores):.3f}")        ⑧
print(f"Mean cv RMSE: {np.mean(rmse_scores):.3f}")
Print(f"Mean cv MAE: {np.mean(mae_scores):.3f}")

① 设置超参数

② 导入所需的库

③ 使用指定的超参数设置 XGBoost 回归器

④ 基于 neighbourhood_more_than_30 特征定义交叉验证分割

⑤ 基于 neighbourhood_more_than_30 特征生成交叉验证分割

⑥ 执行交叉验证预测

⑦ 计算 R-squared、均方根误差和平均绝对误差评估指标以评估模型性能

⑧ 打印 R-squared、均方根误差和平均绝对误差的均值

对 XGBoost 解决方案进行的优化产生了显著优于线性回归基线的结果,如表 12.2 所示。

表 12.2 第七章结果总结

指标 线性回归基线 优化 XGBoost
R-squared 0.320 0.729
均方根误差 17197.323 10853.661
平均绝对误差 12568.371 6611.609

作为进一步的检查,你可以验证在第七章中跟踪的所有三个指标——即 R-squared、均方根误差(RMSE)和平均绝对误差(MAE)——优化的 XGBoost 模型在线性回归基线上的确总是有所改进。现在,以 XGBoost 模型作为我们的参考,我们将在本章的剩余部分探索使用相同的深度学习模型可以取得什么样的结果。

12.2 选择深度学习解决方案

在第八章中,我们回顾了一系列用于处理表格数据的不同深度学习框架,包括 Keras、fastai 以及其他专门为表格数据设计的库,如 TabNet。如果我们现在想要比较第七章中东京 Airbnb 问题的 XGBoost 解决方案,我们应该使用哪种深度学习方法?

作为提醒,我们分享了对使用纽约 Airbnb 数据集的不同深度学习方法的比较,该比较再次在表 12.3 中展示。

表 12.3 深度学习选项比较

Keras fastai 表格数据库(例如,TabNet)
Pro 模型细节透明。使用该框架的大规模社区意味着可以轻松找到常见问题的解决方案。 框架包括对表格数据模型的显式支持,这意味着代码将更加紧凑。它还设置了智能默认值,因此我们可以快速达到合理的结果。 专门创建以处理表格数据集的定制库
Con 没有内置对表格数据的支持 如果我们遇到问题,我们可能需要自己解决问题,因为社区规模小于 Keras。 到目前为止,还没有出现一个明显的选择;社区碎片化和一些库的不一致维护使得可靠地运行基本代码成为一项挑战。

我们在第八章中使用的数据集与我们现在用于比较 XGBoost 和深度学习的数据集之间的差异——即一个之前既大得多(行数是东京 Airbnb 数据集的四倍)又简单得多的数据集(列数不到东京 Airbnb 数据集的一半)——对于我们的 XGBoost 比较来说,并没有显著地偏向任何一种提出的解决方案。事实上,Keras 和 fastai 是通用深度学习框架,并不是专门为小型或复杂数据集设计的。TabNet 的设计使其在处理高维数据时具有优势,但当应用于较小的数据集时,与 Keras 或 fastai 相比的优势就不那么显著了。

在我们的选择中,真正重要的是我们想要在 XGBoost 和深度学习方法之间进行公平的比较。正如你在第七章中看到的,XGBoost 以其易用性而闪耀,并且在不进行大量调整的情况下就能得到良好的结果。如果我们决定使用一个复杂的深度学习模型,这个模型需要花费很长时间来设置、调整和优化,那么这就不会对深度学习模型进行公平的比较。

考虑到这一点,我们应该选择哪个深度学习框架呢?由于将其可靠运行起来的复杂性,我们将跳过 TabNet。这让我们在 Keras 和 fastai 之间做出选择。如表 12.3 中提到的,Keras 在生产中确实很受欢迎,拥有更大的社区。然而,fastai 与我们的目标更为契合。回想一下,在第八章中我们提到,fastai 是为像我们这样的表格数据构建的,并且它带有智能默认设置。这意味着你可以快速获得不错的结果,而无需花费大量时间进行优化。fastai 在幕后为你处理了很多琐碎的事情和细节。正如你稍后将会看到的,选择 fastai 来解决这个问题是值得的。目前,我们相信它为东京 Airbnb 问题提供了一个强大的深度学习解决方案,无需太多麻烦,只需几步即可应对第七章中的 XGBoost 模型。

12.3 针对东京 Airbnb 问题的所选深度学习解决方案

到目前为止,在本章中,我们已经回顾了东京 Airbnb 问题的 XGBoost 解决方案,并审查了用于比较的深度学习解决方案选项,并选择了 fastai 作为与 XGBoost 进行比较的深度学习框架。在本节中,我们将详细介绍 fastai 针对东京 Airbnb 问题的解决方案。

列表 12.2 展示了我们用来与 XGBoost 解决方案进行比较的 fastai 模型的核心理念。此代码使用TabularPandas函数(mng.bz/QDR6)在东京 Airbnb 数据集上训练了一个 fastai 回归模型,TabularPandas是一个封装器,在底层提供了所有必要的转换。

列表 12.2 东京 Airbnb 问题的 fastai 模型

from fastai.tabular.all import *

procs = [FillMissing, Normalize, Categorify]
cat_vars = [
    col for col in airbnb_tokyo.columns 
    if "onehot_encoding__" in col
    or ordinal_encoding__" in col 
    or "binary__" in col
]
cont_vars = [
    col for col in airbnb_tokyo.columns 
    if "numeric__" in col 
    or "target_encoding__" in col
]
dep_var = 'target'

cv_splits = cv.split(X, y=neighbourhood_more_than_30)

r2_scores = []
rmse_scores = []
mae_scores = []
dnn_oof_preds = np.zeros(len(X))

for k, (train_index, test_index) in enumerate(cv_splits):
    X_train = airbnb_tokyo.set_index("listing_id").iloc[train_index].copy()
    X_test = airbnb_tokyo.set_index("listing_id").iloc[test_index].copy()
    y_test = airbnb_tokyo["target"].iloc[test_index].copy()

    tab = TabularPandas(
        X_train, procs, cat_vars, cont_vars, 
        dep_var, y_block=RegressionBlock(),                       ①
        splits=RandomSplitter(
             valid_pct=0.2, seed=0)(range_of(X_train)),
        inplace=True, 
        reduce_memory=True
    )

    dls = tab.dataloaders(bs=128)                                 ②
    y_range = torch.tensor([0, X_train['target'].max() * 1.2])
    tc = tabular_config(ps=[0.001, 0.01], embed_p=0.04, y_range=y_range)
    learn = tabular_learner(dls, layers=[1000,500],               ③
                            metrics=mae,
                            config=tc,
                            loss_func=L1LossFlat())
    with learn.no_bar(), learn.no_logging():
        lr = learn.lr_find(show_plot=False)
        learn.fit_one_cycle(80, lr.valley)                        ④

    dl = learn.dls.test_dl(X_test)
    y_pred = (
        learn.get_preds(dl=dl)[0]
        .numpy()
        .ravel()
    )                                                             ⑤
    dnn_oof_preds[test_index] = y_pred

    r2_scores.append(r2_score(y_test, y_pred))                    ⑥
    rmse_scores.append(np.sqrt(mean_squared_error(y_test, y_pred)))
    mae_scores.append(mean_absolute_error(y_test, y_pred))
    print(f"CV Fold {k} MAE: {mae_scores[-1]:.3f}")

print(f"\nMean cv R-squared: {np.mean(r2_scores):.3f}")
print(f"Mean cv RMSE: {np.mean(rmse_scores):.3f}")
print(f"Mean cv MAE: {np.mean(mae_scores):.3f}")

① 定义一个 fastai TabularPandas 对象

② 基于 TabularPandas 对象定义一个数据加载器对象

③ 基于数据加载器对象定义一个 tabular_learner 对象

④ 训练模型

⑤ 从测试集上获取模型的预测结果

⑥ 保存指标

如列表 12.2 中的代码所示,fastai 的解决方案非常直接。首先,我们定义了预处理步骤(procs),例如填充缺失值、归一化和分类。然后,我们将分类变量和连续变量从数据集中分离出来,并选择因变量(dep_var)。之后,我们以与 XGBoost 解决方案相同的方式在分层 k 折交叉验证中迭代。

在迭代过程中,使用TabularPandas对训练数据进行预处理,指定分类变量、连续变量、目标变量和数据拆分。为训练和验证批次创建了数据加载器对象(dls)。在定义了一个由两层组成的神经网络模型(tabular_learner),第一层有 1,000 个神经元,接下来是 500 个节点,并设置了它们的 dropout 率(使用tabular_config并设置最后一层更高的 dropout 率)之后,我们使用lr_find找到的学习率以及单周期(fit_one_cycle)过程来训练模型。

通过结合lr_findfit_one_cycle过程,我们自动调整学习率参数,以在处理的数据类型上获得最佳结果,从而实现一个简单直接的解决方案,无需过多调整和实验。lr_find过程(mng.bz/Xxq9)在数据样本上探索一系列学习率,当学习率过高导致学习发散时停止。虽然这个过程需要一些时间,但它相对较快,并返回损失曲线下降部分的二分之一处的学习参数值。我们使用这个值作为另一个过程fit_one_cycle(mng.bz/yWZp)的上限边界,这是一个学习率不是固定或不断减少,而是在最小值和最大值之间振荡的训练方法。振荡允许网络不会陷入局部最小值,总体而言,使用这种方法得到的网络性能优于其他方法,尤其是在处理表格数据时。这两种方法都是由 Leslie Smith 在一系列论文中开发的:

据我们所知,fastai 对这些方法的实现是开源社区中最有效率和性能最高的。

继续编写代码,对于for循环的每次迭代,都会保存当前折的预测以及 R-squared、RMSE 和 MAE 评估。循环结束后,打印所有指标的均值,以便我们可以了解 fastai 解决方案的整体值。请注意,当我们通过集成过程比较预测值和实际 y 值时,我们将重新计算这些值,因为我们比较的是 100%的 fastai 结果。

12.4 比较 XGBoost 和 fastai 解决方案解决东京 Airbnb 问题

现在我们已经有了解决东京 Airbnb 问题的深度学习解决方案,我们可以将其结果与 XGBoost 解决方案进行比较。通过比较我们收集的两种解决方案的指标(R-squared、RMSE 和 MAE),我们可以了解每种解决方案在解决东京 Airbnb 问题上的有效性。表 12.4 包含了两种方法结果的总结。

表 12.4 XGBoost 和 fastai 模型结果比较

Metric XGBoost Fastai
R-squared 0.599 0.572
RMSE 10783.027 11719.387
MAE 6531.102 7152.143

表 12.4 表明,XGBoost 在所有三个错误指标上均优于 fastai——事实上,XGBoost 的 R-squared 值显著更高,其 RMSE 和 MAE 值大约低 8%到 9%。

除了表 12.4 中基本比较错误指标外,我们还可以可视化两种方法处理问题的差异。例如,我们可以通过检查东京 Airbnb 测试集中的每个预测数据点来直接比较 XGBoost 和 fastai 的预测。在图 12.1 中,x 轴代表 XGBoost 的预测,而 y 轴代表每个相应数据点的 fastai 预测。该图显示了 XGBoost 和 fastai 预测之间的关系。

图 12.1 XGBoost 和 fastai 预测的散点图

在图表的对角线上,实线趋势线与虚线对角线相当吻合,表明总体上,XGBoost 和 fastai 之间的预测差异并不大。显示 XGBoost 和 fastai 预测之间平滑回归线(一种称为 LOWESS [LOcally WEighted Scatterplot Smoothing]的技术)的实线趋势线与虚线相比没有显著偏离,这证实了即使算法在预测上存在分歧,两个算法之间也没有系统性的高估或低估。

此外,我们还可以尝试探索它们的预测如何相关,以 x 轴上的 70,000 值为基准,因为我们能观察到两个不同的预测簇。我们观察到,fastai 的平均预测值约为 81,300,而 XGBoost 的平均预测值在 70,000 以上的为 81,550,在 70,000 以下的为约 23,050,而 XGBoost 的预测值分别为 22,500。

列表 12.3 XGBoost 预测的平均 fastai 预测

predictions = pd.DataFrame(
    {'xgb': xgb_oof_preds, 'fastai': dnn_oof_preds}
)                                                             ①

avg_fastai_over_70000 = predictions.loc[
    predictions['xgb'] > 70000, 'fastai'
].mean()                                                      ②
avg_xgb_over_70000 = predictions.loc[
    predictions['xgb'] > 70000, 'xgb'
].mean()
print(f"Average prediction values when xgb > 70000:",
      f"fastai:{avg_fastai_over_70000:0.2f}",
      f"xgb:{avg_xgb_over_70000:0.2f}")

avg_fastai_under_70000 = predictions.loc[predictions['xgb'] <= 70000, 'fastai'].mean()
avg_xgb_under_70000 = predictions.loc[predictions['xgb'] <= 70000, 'xgb'].mean()
print(f"Average prediction values when xgb <= 70000: fastai:{avg_fastai_under_70000:0.2f}    
      xgb:{avg_xgb_under_70000:0.2f}")

① 获取一个包含 XGBoost 和 fastai 预测列的 pandas DataFrame

② 根据 XGBoost 预测值计算 XGBoost 和 fastai 预测的平均统计量

两个模型之间的差异很小;平均而言,fastai 和 XGBoost 的预测往往是一致的。fastai 倾向于对较低的预测定价水平高估,对较高的预测定价水平略低估。

返回到图 12.1,现在我们已经检查了比较预测的图表,让我们看看比较 XGBoost 与 fastai 错误的图表。对于东京 Airbnb 测试集中的每个数据点,图 12.2 中的图表显示了该数据点的错误(预测值与实际值之间差异的绝对值),x 值是 XGBoost 的错误,y 值是 fastai 的错误。

图 12.2 显示,存在一个数据点的大簇,其中 XGBoost 和 fastai 的误差都低于 20,000。整体 LOWESS 线(红色)显示,XGBoost 的误差总体上低于 fastai,大多数时候都是如此。

如聚合误差指标和预测及误差的图表所示,XGBoost 模型的表现优于 fastai 模型。然而,考虑到两个模型预测之间的差异,我们认为通过集成这两个模型,我们可以做得比单独使用 XGBoost 更好,因为有一个强烈的暗示,除了性能差异之外,两个模型在预测中表现不同,因为它们捕捉到不同的数据模式和特征。在下一节中,我们将找出集成是否改善了结果。

图片

图 12.2 XGBoost 和 fastai 误差的散点图

12.5 集成东京 Airbnb 问题的两个解决方案

既然我们已经确定了 XGBoost 和 fastai 解决方案在东京 Airbnb 问题上的独立性能,我们将查看集成这两个解决方案,看看两种方法的组合是否提供了任何改进。

下面的列表显示了我们将两个模型的结果进行集成的循环。

列表 12.4 集成两个模型的代码

blend_list = [
    [1., 0.], [0., 1.], [0.25,0.75],
    [0.75,0.25],[.5, .5]
]                                                            ①
for a, b in blend_list:
    print(f"XGBoost weight={a}, DNN weight={b}")
    blended_oof_preds = (
        xgb_oof_preds * a + dnn_oof_preds * b
    )                                                        ②
    r2 = r2_score(blended_oof_preds, y)                      ③
    rmse = np.sqrt(mean_squared_error(blended_oof_preds, y))
    mae = mean_absolute_error(blended_oof_preds, y)
    print(f"blended result for R-squared: {r2:.3f}")
    print(f"blended result for RMSE: {rmse:.3f}")
    print(f"blended result for MAE: {mae:.3f}\n")

① 集成比例以迭代方式遍历

② 生成根据集成比例混合的预测

③ 获取混合预测的 R-squared、RMSE 和 MAE

列表 12.4 中的代码根据blend_list中的混合值结合了 XGBoost 和 fastai 的结果。请注意,这些混合值并未优化以找到绝对最优——我们只是使用一组固定的混合值来获得混合结果的一般感觉。另外,请注意,我们正在使用出卷预测来评估结果。尽管如此,通过根据blend_list中指定的比例结合 XGBoost 和 fastai 的预测,我们可以看到在一系列值上集成两种方法的效果。

混合代码的典型运行输出如下

XGBoost weight=1.0, DNN weight=0.0
blended result for R-squared: 0.599
blended result for RMSE: 10783.027
blended result for MAE: 6531.102

XGBoost weight=0.75, DNN weight=0.25
blended result for R-squared: 0.619
blended result for RMSE: 10507.904
blended result for MAE: 6366.257

XGBoost weight=0.5, DNN weight=0.5
blended result for R-squared: 0.625
blended result for RMSE: 10527.024
blended result for MAE: 6384.576

XGBoost weight=0.25, DNN weight=0.75
blended result for R-squared: 0.618
blended result for RMSE: 10838.831
blended result for MAE: 6566.663

XGBoost weight=0.0, DNN weight=1.0
blended result for R-squared: 0.599
blended result for RMSE: 11419.374
blended result for MAE: 6959.540

这些数字可能有点难以解读,所以让我们看看它们以图表形式的样子。图 12.3 显示了 XGBoost 和 fastai 模型之间一系列混合的结果,包括 R-squared、RMSE 和 MAE。

如图 12.3 所示,当我们使用 XGBoost 和 fastai 模型预测的 50/50 混合时,我们得到了 R-squared 评估的最佳结果。对于基于误差的度量,RMSE 和 MAE,我们通过更多地使用 XGBoost 而不是 fastai 获得了更好的结果;然而,如果我们使用 50/50 的混合,我们只会得到略微差一些的分数。最差的结果是当我们使用 100%的 fastai 预测时,正如我们从单独查看每个模型得到的结果所预期的那样,但值得注意的是,仅使用 XGBoost 总是比将其与其他解决方案(使用 75/25 或 50/50 的份额)混合要差。

图 12.3

图 12.3 XGBoost 和 fastai 模型融合的结果

将 XGBoost 和 fastai 模型进行集成比单独使用任一模型得到的结果更好。正如我们将在下一节中看到的,我们从集成中得到的观察结果与一篇重要研究论文中分享的结果一致,该论文比较了经典机器学习方法和深度学习在表格数据问题上的表现。

12.6 梯度提升和深度学习的整体比较

在第一章中,我们介绍了关于是否需要深度学习来解决涉及表格数据的问题的争议。我们引用了支持双方论点的学术论文——那些倡导深度学习方法的人和那些坚持认为经典机器学习方法,特别是梯度提升,始终优于深度学习的人。我们在第一章中提到的一篇论文值得在此重新审视:“表格数据:深度学习并非一切所需”,由 Ravid Shwartz-Ziv 和 Amitai Armon 撰写(arxiv.org/abs/2106.03253)。在这篇论文的讨论和结论部分,作者做出了以下陈述:

在我们的分析中,对于那些没有出现在它们原始论文中的数据集,深度模型的表现较弱,并且它们的表现不如基线模型 XGBoost。因此,我们提出了使用这些深度模型与 XGBoost 的集成。这个集成在这些数据集上的表现优于任何单个模型和“非深度”的经典集成。

他们的观察并不是一个铁的规则,因为,根据我们的经验,我们遇到了使用梯度提升解决方案或深度学习解决方案单独使用时表现最佳的情况。然而,在许多我们面临的情况中,我们可以确认简单地平均解决方案导致了更好的预测。我们坚信,这是因为两种算法有优化预测的不同方式。梯度提升基于决策树,这是一种类比搜索的形式,因为作为一个算法,树将你的数据集分割成特征值之间相似的部分,并将它们映射到相似的目标输出。算法的梯度部分智能地集成多个树以获得更好的预测,尽管它并没有改变决策树的基本行为方式。另一方面,深度学习纯粹基于微分和非线性变换的原则,算法寻找最佳的权重来组合变换后的输入。

这两种不同的方法导致了相当不同的估计结果,它们的误差往往部分相互抵消,因为它们高度不相关,这与在随机森林算法中平均不相关决策树的结果相似。这个结论与我们在比较东京 Airbnb 问题上的梯度提升和深度学习结果的经验相符。XGBoost 模型与 fastai 模型的集成产生了最佳结果,如图 12.3 所示。

摘要

  • 第七章中展示的东京 Airbnb 问题的 XGBoost 解决方案提供了一个基线,我们可以用它来评估深度学习解决相同问题的有效性。

  • 以第七章中的 XGBoost 解决方案为起点,我们可以为东京 Airbnb 问题创建一个深度学习解决方案。fastai 库提供了一个紧凑且相对性能良好的深度学习解决方案。

  • 通过在从 100% XGBoost 到 100% fastai 的一系列比例中混合 XGBoost 和 fastai 模型的预测,我们可以看到集成模型的效果。我们通过两个模型的 50/50 集成获得了最佳结果。

  • 这个结果与研究对深度学习在表格数据上的有效性的声明进行审查的建议相符。

附录 A. 经典机器学习模型的超参数

表 A.1 线性模型的超参数

超参数 描述
C 与正则化成反比,较小的值对应更强的正则化。在范围 np.logspace(-4, 4, 10) 中搜索。
alpha 乘以正则化项的常数,较大的值对应更强的正则化。在范围 np.logspace(-2, 2, 10) 中搜索。
l1_ratio 在 Elasticnet 中混合 L1 和 L2 正则化,从值 [.1, .5, .7, .9, .95, .99] 中选择。

表 A.2 随机森林和 ERTs 的超参数

超参数 描述
max_features 降低此参数以增加偏差并降低方差。尝试 sqrtlog2 以及代表特征 1/10 和 1/20 的整数等值。
min_samples_leaf 一种正则化树的方法,通常设置为 1;尝试将其增加到 30。
bootstrap 一个布尔值,表示是否使用自助重采样。如果存在噪声或异常值,有时子采样可能比自助重采样更有效。
n_estimators 树越多越好,但超过一定点后你会浪费计算能力。从 100 开始,增加到 1,000。它适用于大多数问题。

表 A.3 Scikit-learn 的 HistGradientBoosting 的超参数

超参数 描述
learning_rate 决策树结果的乘法值。介于 0.001 和 0.1 之间的实数。
max_iter 增强过程中构建的树的最大数量。介于 100 和 1,000 之间的整数。
max_depth 每棵树的最大深度作为对树生长的正则化。选择介于 1 和 12 之间的整数。
max_leaf_nodes 每棵树的最大叶子节点数。与 max_depth 相关,它也控制树的生长。优化此参数或 max_depth,但不要同时优化。选择介于 2 和 4,096 之间的整数。
min_samples_leaf 每个叶子节点所需的最小样本数是对树生长的正则化。选择介于 2 和 300 之间的整数。
l2_regularization 集成中的 L2 正则化参数。选择介于 0.0 和 100.0 之间的浮点数。
max_bins 在直方图中使用的最大箱数。一种间接的正则化树的方法。选择介于 32 和 512 之间的整数。

表 A.4 XGBoost 的超参数

超参数 描述
learning_rate 用于缩小决策树结果的乘法值。介于 0.001 和 0.1 之间的实数。
n_estimators 增强集中树的数量。介于 100 和 1,000 之间的整数。
max_depth 树的最大深度是控制估计方差的一种方法。介于 1 和 12 之间的整数。
min_child_weight 子节点中需要的最小实例权重(海森)之和。默认值为 1。min_child_weight越大,算法越保守。我们建议一个介于 1 和 10 之间的整数。
max_delta_step 通常为零,表示无约束,如果设置为正数,则充当正则化器,因为它限制了更新的限制。在分类中类之间存在不平衡时很有益,因为它防止一个类主导其他类。我们建议一个介于 0 和 10 之间的浮点数。
max_bin 历史图使用的最大箱数。一个介于 32 和 512 之间的整数。
subsample 训练实例的采样比率。一个介于 0.1 和 1.0 之间的实数。
colsample_bytree 构建每个树时列的子采样比率。一个介于 0.1 和 1.0 之间的实数。
reg_lambda 权重的 L2 正则化项。一个介于 1e-9 和 100.0 之间的实数。
reg_alpha 权重的 L1 正则化项。一个介于 1e-9 和 100.0 之间的实数。
gamma 另一个通过设置最小损失减少来限制树分割的正则化器。将其设置为介于 0 和 0.5 之间的实数。
scale_pos_weight 控制正负权重平衡的权重值,对于不平衡的二进制分类问题很有用。默认设置为 1;一个典型的考虑值:负实例数/正实例数。我们建议一个介于 1e-6 和 500 之间的实数。

表 A.5 LightGBM 的超参数

超参数 描述
learning_rate 用于缩小决策树结果的乘法值。一个介于 0.001 和 0.1 之间的实数。
n_estimators 增强迭代的数量。一个介于 100 和 1,000 之间的整数。
max_depth 决策树的最大深度限制。控制复杂性和过拟合的一种方法。一个介于 1 和 12 之间的整数。
num_leaves 一个介于 2 和 2max_depth之间的整数,它表示树将拥有的最终叶子节点数,如果设置得较低,则充当树复杂性的正则化器。
min_data_in_leaf 一个叶子节点中的最小数据量。设置此参数有助于处理过拟合。零表示无约束。一个介于 0 和 300 之间的整数。
min_gain_to_split 决策树中进行分割的最小增益。一个介于 0 和 15 之间的浮点数。
max_bin 用于历史图的最大箱数。通过将其设置得较低来处理过拟合的一个间接方法。一个介于 32 和 512 之间的整数。
subsample 在不重采样的情况下选择随机数据百分比的参数。一个介于 0.1 和 1.0 之间的实数。
subsample_freq 子采样频率:一个介于 0 和 10 之间的整数。如果设置为 0,则算法将忽略与子采样相关的任何设置,并且不会执行子采样。
feature_fraction 每次迭代中使用的特征分数。一个介于 0.1 和 1.0 之间的实数。
reg_lambda L2 正则化。一个介于 0.0 和 100.0 之间的实数。
reg_alpha L1 正则化。一个介于 0.0 和 100.0 之间的实数。
scale_pos_weight 用于在类别不平衡的二分类中抵消正类标签权重的系数。一个介于 1e-6 和 500 之间的实数。

附录 B. K-最近邻和支持向量机

在本附录中,我们考察了具有更多计算性质的经典机器学习算法,这些算法在书中没有涉及,因为它们现在使用较少,并且与大多数应用中的决策树集成相比被认为是过时的。总的来说,支持向量机(SVMs)仍然是一种适合于高维、噪声或小规模数据应用的实用机器学习算法。另一方面,k-最近邻(k-NN)非常适合在数据特征较少、可能存在异常值且预测不需要高度准确性的应用中运行。例如,SVMs 仍然可以用于分类医学图像,如乳腺 X 光片和 X 射线;在汽车行业中用于车辆检测和跟踪;或用于检测电子邮件垃圾邮件。相反,k-NN 主要应用于推荐系统,特别是基于用户过去行为的协同过滤方法,以推荐产品或服务。

它们在大多数表格数据情况下都适用,当您的数据不是太小或太大时——作为一个经验法则,当行数少于 10,000 行时。我们将从 k-NN 算法开始,这是数据科学家在机器学习问题中使用了几十年的算法,它易于理解和实现。然后我们将通过 SVMs 和关于使用 GPU 以在中等规模数据集上运行这些算法的简要说明来完成我们的概述。所有示例都需要第四章中介绍的 Airbnb 纽约市数据集。您可以通过执行以下代码片段来重新执行它:

import numpy as np
import pandas as pd
excluding_list = [
    'price', 'id', 'latitude', 'longitude', 
    'host_id', 'last_review', 'name', 'host_name'
]                                                ①
categorical = [
    'neighbourhood_group', 'neighbourhood', 
    'room_type'
]                                                ②
continuous = [
    'minimum_nights', 'number_of_reviews', 'reviews_per_month', 
    'Calculated_host_listings_count'
]                                                ③
data = pd.read_csv("./AB_NYC_2019.csv")
target_median = (
    data["price"] > data["price"].median()
).astype(int)                                    ④

① 要排除的分析列名列表

② 可能代表数据集中分类变量的列名列表

③ 代表数据集中连续数值变量的列名列表

④ 二元平衡目标

代码将加载您的数据集并定义要排除的分析特征或考虑为连续或分类变量以进行处理的特征。

B.1 k-NN

k-NN 算法适用于回归和分类任务,被认为是制作预测的最简单和最直观的算法之一。它从训练集中找到 k 个(k 是一个整数)最近的例子,并使用它们的信息进行预测。例如,如果任务是回归,它将取 k 个最近例子平均值。如果任务是分类,它将在 k 个最近例子中选择最常见的类别。

从技术上讲,k-NN 通常被认为是一种基于实例的学习算法,因为它以原样记住训练示例。它也被认为是一种“懒惰算法”,因为与大多数机器学习算法相反,在训练时间几乎没有处理。在训练期间,通常会有一些通过优化算法和数据结构处理距离的过程,这使得在训练示例附近查找邻近点之后的计算成本较低。大部分的计算工作是在测试时间完成的(见图 B.1)。

图片

图 B.1 使用 k = 3 的 k-NN 对新样本(三角形)进行分类

我们将 k-NN 分类器应用于第四章中提到的 Airbnb 纽约市数据,如附录 B.1 所示。由于 k-NN 是基于距离工作的,为了获得一个有效的解决方案,特征必须在同一尺度上,从而确保在距离测量过程中每个维度都有相同的权重。如果一个特征在不同的或较小的尺度上,它会在过程中被过度加权。如果较大的尺度描述了一个特征,则相反的情况会发生。为了说明这个问题,让我们考虑当我们基于千米、米和厘米比较距离时会发生什么。即使距离是可比较的,米和厘米的数值将超过千米测量值。这个问题通常通过缩放特征来解决——例如,通过减去它们的平均值并除以它们的标准差(这种操作称为 z 分数标准化)。

或标准化)。此外,诸如降维或特征选择等技术对于此算法也是有帮助的,因为重新排列预测因子或不同的预测因子集可能会导致在问题上的预测性能有所提高或降低。

在我们的案例中,正如表格数据通常所做的那样,情况因分类特征而复杂化,这些特征一旦进行独热编码,就会变成从 0 到 1 的二进制值,其比例与归一化特征不同。我们提出的解决方案是首先对数值特征进行离散化,从而有效地将它们转换为二进制特征,每个特征表示一个特征的数值是否将落在特定的范围内。连续特征的二值化是通过嵌入到numeric_discretizing管道中的 KBinsDiscretizer 类(mng.bz/N12N)实现的,它将每个数值特征转换为五个二进制特征,每个特征覆盖一个值范围。在处理时间,我们还应用主成分分析(PCA)来降低维度并使所有特征无关。然而,我们可能会减弱数据中的非线性,因为 PCA 是一种基于变量线性组合的技术。PCA 处理的数据具有不相关结果特征的特点,这适合 k-NN:k-NN 基于距离,如果维度无关,距离测量才能正确工作。因此,任何距离变化都是由于单个维度的变化,而不是多个维度的变化。以下列表显示了实现数据转换过程和训练 k-NN 的代码。

列表 B.1 k-NN 分类器

from sklearn.neighbors import KNeighborsClassifier
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer

from sklearn.preprocessing import OneHotEncoder
from sklearn.preprocessing import KBinsDiscretizer
from sklearn.metrics import accuracy_score

categorical_onehot_encoding = OneHotEncoder(handle_unknown='ignore')

accuracy = make_scorer(accuracy_score)                            ①
cv = KFold(5, shuffle=True, random_state=0)                       ②
model = KNeighborsClassifier(n_neighbors=30,
                             weights="uniform",
                             algorithm="auto",
                             n_jobs=-1)                           ③

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_discretizing, continuous)],
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)                                         ④

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('pca', PCA(n_components="mle")),
     ('modeling', model)])                                        ⑤

cv_scores = cross_validate(estimator=model_pipeline,
                           X=data,
                           y=target_median,
                           scoring=accuracy,
                           cv=cv,
                           return_train_score=True,
                           return_estimator=True)                 ⑥

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
      f"fit: {fit_time:0.2f}",
      f"secs pred: {score_time:0.2f} secs")                       ⑦

① 使用 accuracy_score 度量创建一个评分函数

② 创建一个具有洗牌和固定随机状态的五折交叉验证迭代器

③ 创建一个具有指定超参数的 KNeighborsClassifier 实例

④ 定义一个 ColumnTransformer 来预处理特征,对低基数分类特征应用独热编码,对数值特征应用离散化

⑤ 创建一个管道,按顺序应用列转换,执行 PCA 降维,然后将 k-nn 模型拟合到数据上

⑥ 使用定义的管道对数据进行交叉验证,使用准确率评分

⑦ 打印测试分数的均值和标准差

运行脚本后,您将获得一个接近朴素贝叶斯解决方案性能的结果:

0.814 (0.005) fit: 0.13 secs pred: 8.75 secs

性能良好,尽管推理时间相对较高。由于此算法通过类比工作(它将在训练中寻找类似案例以获得可能的预测想法),因此在大数据集上表现更好,在大数据集中找到与要预测的实例相似的实例的可能性更高。自然地,数据集的正确大小由使用的特征数量决定,因为特征越多,算法需要更多的案例来很好地泛化。

虽然通常人们将重点放在设置 k 参数的最佳值,将其视为平衡算法对训练数据欠拟合和过拟合的关键,但我们反而将注意力转向其他方面,以有效地使用此模型。由于算法通过类比和复杂空间中的距离来工作,我们考虑了关于此方法两个重要的问题:

  • 要测量的维度和维度诅咒

  • 适当的距离度量以及如何处理特征

在 k-NN 中,分类或回归估计取决于基于特征计算的距离度量的最相似示例。然而,在数据集中,并非所有特征都可以被认为在判断一个示例与其他示例相似时很重要,并且并非所有特征都可以以相同的方式进行比较。在应用 k-NN 时,对问题的先验知识非常重要,因为你必须只选择与你要解决的问题相关的特征。如果你为问题组装了过多的特征,你将依赖于过多的复杂空间来导航。图 B.1 展示了 k-NN 算法如何仅使用两个特征(在 x 和 y 维度上表示)工作,你可以直观地理解,如果某个区域有混合的类别或者没有训练示例靠近新实例,那么对新实例进行分类(图中的三角形)可能很困难。你必须依赖于更远的那些实例。

游戏中出现了维度诅咒,它指出随着特征数量的增加,你需要更多的示例来保持数据点之间有意义的距离。此外,维度诅咒还意味着必要示例的数量会随着特征数量的增加而指数级增长。对于 k-NN 算法来说,这意味着如果你提供了过多的特征,而示例数量不足,它将在空空间中工作。寻找邻居将变得令人畏惧。此外,如果你只是组装了相关和不相关的特征,风险是算法可能会将一些与你要预测的案例非常远的示例标记为邻居,并且选择可能基于对问题无用的特征。因此,如果你打算使用 k-NN,你应该非常小心地选择要使用的特征(如果你不知道使用哪些,你需要依赖特征选择)或者非常熟悉问题,以确定应该将什么放入算法中。简约对于 k-NN 的正确工作至关重要。

当你决定好特征后,关于你将使用的距离度量,你需要标准化、去除冗余和转换特征。这是因为距离度量基于绝对测量,不同的尺度可以以不同的方式权衡。考虑使用千米、米和厘米的测量值。厘米可能会占主导地位,因为它们很容易就有最大的数字。此外,具有相似特征(多重共线性问题)可能导致距离测量对某些特征集的权重超过其他特征集。最后,距离测量意味着具有相同的维度进行测量。然而,在数据集中,你可能会发现不同类型的数据——数值、分类和时间相关的数据,它们通常需要在距离计算中更好地结合在一起,因为它们具有不同的数值特征。

因此,除了事先仔细选择要使用的特征外,当使用 k-NN 时,我们建议使用所有同种类的特征(或所有数值或所有分类)来标准化它们,如果需要的话,并通过如 PCA(mng.bz/8OrZ)等方法减少它们的信噪比,这将重新制定数据集成为一个新的数据集,其中特征之间不相关。

B.2 支持向量机(SVMs)

在 2010 年之前,SVMs 以表格问题中最有前途的算法而闻名。然而,在过去 10 年中,基于树的模型已经超越了 SVMs,成为表格数据的首选方法。然而,SVMs 仍然是一系列处理二元、多类、回归和异常/新颖性检测的技术。它们基于这样的想法:如果你的观察结果可以表示为多维空间中的点,那么存在一个超平面(即穿过多个维度的分离平面)可以将它们分开成类别或值,通过确保它们之间最大的分离,也保证了最稳健和可靠的预测。图 B.2 展示了一个简单的 SVM 应用到具有两个特征的二元分类问题示例,这些特征在 x 轴和 y 轴上表示,作为预测因子。SVM 模型产生了一条分隔线,在两组之间有最大的松弛空间,如图所示,其中虚线界定松弛空间。在这样做的时候,它只考虑靠近分隔器的几个点,称为支持向量。相反,它忽略了靠近但会混淆算法的点,例如,它们在错误的一侧。它还忽略了远离分隔线远离的点。异常值对这种算法的影响很小。

图片

图 B.2 SVM 中的一个分离超平面

SVM 的强点在于它们对过拟合、数据中的噪声和异常值的鲁棒处理,以及它们如何成功处理包含众多多重共线性特征的集合。将不同的非线性方法应用于数据时,SVM 不需要我们为逻辑回归所看到的变换(如多项式展开)。然而,它们可以使用基于领域的特征工程,就像所有其他机器学习算法一样。

在弱点方面,SVM 优化复杂,并且仅适用于有限数量的示例。此外,它们最适合二元预测和仅用于类别预测;它们不是概率算法,你需要将它们与另一个算法(如逻辑回归)结合使用以进行校准(以从它们中提取概率)。这使得 SVM 在风险估计的有限范围内有效。

在我们的例子中,我们使用具有径向基函数核的二进制分类 SVM 和 Airbnb 纽约市数据重新应用我们的问题,这是一种能够自动建模提供特征之间复杂非线性关系的途径。

列表 B.2 支持向量机分类器

from sklearn.svm import SVC
from sklearn.metrics import accuracy_score
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler

numeric_standardization = Pipeline([
       ("imputation", SimpleImputer(strategy="constant", fill_value=0)),
       ("standardizing", StandardScaler())
       ])

accuracy = make_scorer(accuracy_score)                            ①
cv = KFold(5, shuffle=True, random_state=0)                       ②
model = SVC(
    C=1.0,
    kernel='rbf',
    gamma='scale',
    probability=False
)                                                                 ③

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_standardization, continuous)],
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)                                         ④

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)])                                        ⑤

cv_scores = cross_validate(estimator=model_pipeline,
                           X=data,
                           y=target_median,
                           scoring=accuracy,
                           cv=cv,
                           return_train_score=True,
                           return_estimator=True)                 ⑥

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
      f"fit: {fit_time:0.2f}",
      f"secs pred: {score_time:0.2f} secs")                       ⑦

① 使用 accuracy_score 度量创建一个评分函数

② 创建一个具有洗牌和固定随机状态的五折交叉验证迭代器

③ 创建一个具有指定超参数的支持向量机分类器实例

④ 定义一个 ColumnTransformer 以预处理特征,对低基数分类特征应用独热编码,对数值特征进行标准化

⑤ 创建一个管道,按顺序应用列转换

使用定义的管道在数据上执行交叉验证,使用准确率评分

⑦ 打印测试分数的均值和标准差

结果相当有趣,通过调整超参数可能还会变得更好:

0.821 (0.004) fit: 102.28 secs pred: 9.80 secs

然而,训练单个折叠所需的时间与所有之前的机器学习算法相比过于冗长。在本附录的下一节中,我们将讨论如何使用 GPU 卡加速过程,同时仍然使用 Scikit-learn API。

B.3 使用 GPU 进行机器学习

由于深度学习在数据科学领域的迅速崛起,GPU 现在在本地和云计算中都得到了广泛应用。以前,你只听说过 GPU 在 3D 游戏、图形处理渲染和动画中的应用。由于它们便宜且擅长快速矩阵乘法任务,学者和实践者迅速将 GPU 用于神经网络计算。RAPIDS 是由 NVIDIA(GPU 顶级制造商之一)开发的一系列用于在 GPU 上执行数据科学全光谱的包,而不仅仅是深度学习。RAPIDS 包承诺帮助机器学习管道的各个阶段,从端到端。这对许多经典机器学习算法来说是一个变革,特别是对于 SVMs,它是处理涉及噪声、异常值和大型数据集(特别是如果特征多线性或稀疏)的复杂任务的最可靠选择。在 RAPIDS 包(表 B.1)中,所有命令都采用了现有的 API 作为它们的命令。这确保了包的即时市场采用,对于用户来说,无需重新学习轮子的工作方式。

表 B.1 Rapids 包

Rapids 包 任务 API 模拟
cuPy 数组操作 NumPy
cuDF 数据处理 pandas
cuML 机器学习 Scikit-learn

本节将重点介绍如何轻松地将你的 Scikit-learn 算法替换为 RAPIDS cuML 包。目前,此包包括线性模型、k-NN 和 SVMs 的实现,以及聚类和降维。以下列表显示了测试支持向量分类器(使用我们在上一节中尝试的径向基函数核)的 RAPIDS 实现(使用 P100 GPU)的代码。

列表 B.3 RAPIDS cuML 支持向量分类器

from cuml.svm import SVC
from sklearn.metrics import accuracy_score

accuracy = make_scorer(accuracy_score)                            ①
cv = KFold(5, shuffle=True, random_state=0)                       ②
model = SVC(
    C=1.0,
    kernel='rbf',
    gamma='scale',
    probability=False
)                                                                 ③

column_transform = ColumnTransformer(
    [('categories', categorical_onehot_encoding, low_card_categorical),
     ('numeric', numeric_standardization, continuous)],
    remainder='drop',
    verbose_feature_names_out=False,
    sparse_threshold=0.0)                                         ④

model_pipeline = Pipeline(
    [('processing', column_transform),
     ('modeling', model)])                                        ⑤

cv_scores = cross_validate(estimator=model_pipeline,
                           X=data,
                           y=target_median,
                           scoring=accuracy,
                           cv=cv,
                           return_train_score=True,
                           return_estimator=True)                 ⑥

mean_cv = np.mean(cv_scores['test_score'])
std_cv = np.std(cv_scores['test_score'])
fit_time = np.mean(cv_scores['fit_time'])
score_time = np.mean(cv_scores['score_time'])
print(f"{mean_cv:0.3f} ({std_cv:0.3f})",
      f"fit: {fit_time:0.2f}",

      f"secs pred: {score_time:0.2f} secs")                       ⑦

① 使用 accuracy_score 度量创建评分函数

② 创建一个具有洗牌和固定随机状态的五折交叉验证迭代器

③ 从 GPU 加速的 cuML 库中创建一个支持向量分类器实例,并指定超参数

④ 定义一个 ColumnTransformer 来预处理特征,对低基数分类特征应用独热编码,对数值特征应用标准化

⑤ 创建一个管道,按顺序将列转换和模型应用于数据

使用定义的管道在数据上执行交叉验证,并使用准确率评分

⑦ 打印测试分数的均值和标准差

我们获得的结果是

0.821 (0.004) fit: 4.09 secs pred: 0.11 secs

如您所见,我们通过重用相同的代码但依赖 cuML 获得了相同的结果。然而,处理时间已从每个文件夹 102 秒降低到每个文件夹 4 秒。如果您计算时间节省,那将是 25 倍的速度提升。确切的表现效益取决于您使用的 GPU 型号;GPU 越强大,结果越快,因为这与 GPU 卡从 CPU 内存传输数据以及处理矩阵乘法的速度有关。

基于标准 GPU(公众可访问的通用 GPU)上的此类性能,我们最近看到了将表格数据与深度学习模型(如文本或图像)的大嵌入融合的应用。SVMs(支持向量机)与众多特征(但不超过示例中的数量)和稀疏值(许多零值)配合良好。在这种情况下,SVMs 可以轻松获得最先进的结果,超越当时其他更受欢迎的表格算法——即 XGBoost 和其他梯度提升实现,以及端到端深度学习解决方案,后者在没有足够案例提供时表现较弱。

拥有 GPU 并使您的代码适应使用 RAPIDS 算法,使得某些经典的表格机器学习算法再次具有竞争力,这通常基于机器学习中没有免费午餐的原则(关于没有免费午餐定理的更多细节可在www.no-free-lunch.org/找到)。考虑到您的项目限制(例如,您可能无法在项目环境中获得某些资源),如果可行,永远不要排除在先验测试中将您的问题与所有可用算法进行比较。

posted @ 2025-09-12 14:08  绝不原创的飞龙  阅读(21)  评论(0)    收藏  举报