R-机器学习-全-

R 机器学习(全)

原文:Machine Learning with R,

译者:飞龙

协议:CC BY-NC-SA 4.0

第一部分. 简介

尽管这本书的第一部分只有两章,但提供给您在整本书中都将依赖的基本知识和技能是至关重要的。

第一章向您介绍了某些基本的机器学习术语。拥有核心概念的良好词汇量可以帮助您看到机器学习的整体图景,并有助于您理解我们在书中稍后将要探讨的更复杂主题。本章教您什么是机器学习,它如何(或可能)对我们有益或有害,以及我们如何对不同的机器学习任务进行分类。本章最后解释了为什么我们使用 R 进行机器学习,您将使用哪些数据集,以及您可以期待从本书中学到什么。

在第二章中,我们暂时离开机器学习,专注于通过介绍名为tidyverse的一系列包来提高您的 R 技能。tidyverse 的包为我们提供了使用更易于阅读、更直观的代码来存储、操作、转换和可视化数据的工具。在处理机器学习项目时,您不需要使用 tidyverse,但这样做可以帮助您简化数据处理过程。我们将在本书的项目中使用 tidyverse 工具,因此在第二章中对其有扎实的了解将有助于您在后续章节中。我相信您会发现这些技能提高了您的 R 编程和数据科学技能。

从第二章开始,我鼓励您与我一起开始编码。为了最大限度地提高您对知识的保留,我强烈建议您在自己的 R 会话中运行代码示例,并保存您的.R 文件,这样您可以在将来参考您的代码。确保您理解每一行代码与其输出之间的关系。

第一章. 机器学习简介

本章涵盖

  • 什么是机器学习

  • 监督学习与无监督学习

  • 分类、回归、降维和聚类

  • 为什么我们使用 R

  • 我们将使用哪些数据集

无论你是否意识到,你每天都在与机器学习互动。你在线上看到的广告是基于你之前购买或查看的产品更有可能购买的产品。你上传到社交媒体平台的照片中的人脸会自动识别并标记。你的汽车的 GPS 预测在一天中的某些时间哪些路线会最繁忙,并重新规划路线以缩短行程。你的电子邮件客户端会逐渐学习你想要的电子邮件和你认为垃圾邮件的电子邮件,以使你的收件箱更整洁;你的家庭个人助理能识别你的声音并回应你的请求。从这些对我们日常生活的小改进,到自动驾驶汽车、机器人手术和自动扫描其他类地行星等改变社会的大想法,机器学习已经成为现代生活越来越重要的部分。

但这里有一件事我想让你立刻明白:机器学习不仅仅是大型科技公司或计算机科学家的领域。任何人只要有基本的编程技能,就可以在自己的工作中实现机器学习。如果你是一位科学家,机器学习可以让你对所研究的现象有非凡的洞察力。如果你是一位记者,它可以帮助你理解数据中的模式,从而描绘你的故事。如果你是一位商人,机器学习可以帮助你定位正确的客户并预测哪些产品会卖得最好。如果你有疑问或问题,并且你有足够的数据来回答它,机器学习可以帮助你做到这一点。虽然阅读这本书后,你们不会建造智能汽车或会说话的机器人(就像谷歌和 Deep Mind 那样),但你们将获得做出强大预测和识别数据中信息性模式的能力。

我将用任何具备 R 语言基础知识的读者都能理解的水平,教你们机器学习的理论和实践。自从高中以来,我就对数学很糟糕,所以我不期望你们在这方面很出色。尽管你们即将学习的技巧基于数学,但我坚信机器学习中没有难以理解的概念。我们将一起探索的所有过程都将通过图形和直观的方式进行解释。这不仅意味着你们将能够应用和理解这些过程,而且你们将无需通过数学符号就能学到所有这些知识。然而,如果你对数学有浓厚的兴趣,你会发现书中呈现的方程式是“了解即可”,而不是“必须知道”。

在本章中,我们将定义我实际上所说的机器学习是什么。你们将了解算法和模型之间的区别,并发现机器学习技术可以根据类型划分,这有助于我们在选择给定任务的最佳技术时进行指导。

1.1. 什么是机器学习?

想象一下,你是一家医院的科研人员。如果当新患者入院时,你能计算出他们死亡的风险呢?这将允许临床医生更加积极地治疗高风险患者,并最终挽救更多生命。但你应该从哪里开始呢?你将使用哪些数据?你将如何从数据中获取这些信息?答案是使用机器学习。

机器学习,有时被称为统计学习,是人工智能(AI)的一个子领域,其中算法“学习”数据中的模式以执行特定任务。尽管算法可能听起来很复杂,但实际上并不复杂。事实上,算法背后的想法一点也不复杂。算法只是一个一步一步的过程,我们用它来实现有开始和结束的事情。厨师们对算法有不同的说法——他们称之为“食谱”。在食谱的每个阶段,你执行某种过程,比如打鸡蛋,然后你遵循食谱中的下一个指令,比如混合原料。

看一下图 1.1 中我制作的制作蛋糕的算法。它从顶部开始,通过一系列操作来烘焙并供应蛋糕。有时会有决策点,我们选择的路线取决于当前的情况,有时我们需要返回或迭代到算法的先前步骤。虽然确实可以用算法实现极其复杂的事情,但我希望你能理解,它们只是简单操作的顺序链。

图 1.1。制作和供应蛋糕的算法。我们从顶部开始,并在执行每个操作后,跟随下一个箭头。菱形是决策点,我们接下来跟随的箭头取决于我们蛋糕的状态。虚线箭头显示返回先前操作的路线。这个算法以原料作为输入,输出带有冰淇淋或奶油的蛋糕!

图 1.1

因此,在收集了患者的数据之后,你训练一个机器学习算法来学习与患者生存相关的数据模式。现在,当你收集到一个新患者的数据时,该算法可以估算该患者死亡的风险。

作为另一个例子,想象一下,你为一家电力公司工作,你的工作是确保客户的账单估算准确。你训练一个算法来学习与家庭电力使用相关的数据模式。现在,当一个新的家庭加入电力公司时,你可以估算每个月应该向他们收取多少费用。

最后,假设你是一位政治学家,你正在寻找没有人(包括你)知道的选民类型。你训练一个算法来识别调查数据中的选民模式,以更好地理解什么因素激励特定政治党的选民。你在这两个问题上看到了任何相似之处吗?那么——如果解决方案隐藏在你的数据中——你可以训练一个机器学习算法来为你提取它。

1.1.1. 人工智能与机器学习

IBM 的科学家亚瑟·萨缪尔在 1959 年首次使用了机器学习这个术语。他用来描述一种涉及训练算法学习如何玩国际象棋的 AI 形式。这里的学习这个词很重要,因为它区分了机器学习方法与传统 AI。

传统 AI 是程序化的。换句话说,你给计算机一套规则,这样当它遇到新数据时,它就知道应该给出哪个输出。一个例子就是使用if else语句来分类动物为狗、猫或蛇:

numberOfLegs <- c(4, 4, 0)
climbsTrees <- c(TRUE, FALSE, TRUE)

for (i in 1:3) {
  if (numberOfLegs[i] == 4) {
    if (climbsTrees[i]) print("cat") else print("dog")
  } else print("snake")
}

在这段 R 代码中,我创建了三条规则,将我们可用的每一个可能的输入映射到一个输出:

  1. 如果动物有四条腿且会爬树,它就是猫。

  2. 如果动物有四条腿且不会爬树,它就是狗。

  3. 否则,该动物是蛇。

现在,如果我们将这些规则应用于数据,我们会得到预期的答案:

[1] "cat"
[1] "dog"
[1] "snake"

这种方法的缺点在于我们需要事先知道计算机应该给出的所有可能的输出,而系统永远不会给出我们没有告诉它给出的输出。这与机器学习方法形成对比,在机器学习方法中,我们不是告诉计算机规则,而是给它数据,并允许它自己学习规则。这种方法的优点是机器可以“学习”我们甚至不知道数据中存在的模式——我们提供的数据越多,它在学习这些模式方面就越好(图 1.2)。

图 1.2. 传统 AI 与机器学习 AI 的比较。在传统的 AI 应用中,我们向计算机提供一套完整的规则。当它接收到数据时,它会输出相关的答案。在机器学习中,我们向计算机提供数据和答案,然后它自己学习规则。当我们通过这些规则传递新的数据时,我们得到这些新数据的答案。

图片

1.1.2. 模型与算法的区别

在实践中,我们称机器学习算法学习的一组规则为模型。一旦模型被学习,我们可以给它提供新的观察数据,它将输出对新数据的预测。我们称之为模型,因为它们以足够简单的方式表示现实世界现象,以至于我们和计算机可以解释和理解它。就像埃菲尔铁塔的模型可能很好地代表了真实的事物,但并不完全相同一样,所以统计模型是现实世界现象的尝试性表示,但不会完美匹配。

注意

你可能听说过统计学家乔治·博克斯提出的著名短语:“所有模型都是错误的,但有些是有用的”,这指的是模型的近似性质。

学习模型的过程被称为算法。正如我们之前发现的,算法只是一系列共同工作的操作,用于解决问题。那么在实践中是如何工作的呢?让我们举一个简单的例子。假设我们有两个连续变量,我们希望训练一个算法,可以基于另一个变量(结果依赖变量)预测一个(预测器独立变量)。这些变量之间的关系可以用一条直线来描述,这条直线可以用仅两个参数来定义:它的斜率和它与 y 轴交叉的位置(y 截距)。这如图图 1.3 所示。

图 1.3. 任何直线都可以用其斜率(y 的变化除以 x 的变化)和其截距(当 x=0 时与 y 轴交叉的位置)来描述。方程 y = intercept + slope * x 可以用来预测给定 x 值的 y 值。

图 1-3

学习这种关系的算法可能看起来像图 1.4 中的例子。我们首先通过所有数据的平均值拟合一条没有斜率的直线。我们计算每个数据点到直线的距离,平方它,并将这些平方值相加。这个平方和是衡量直线拟合数据紧密程度的指标。接下来,我们将直线顺时针旋转一点,并测量这条直线的平方和。如果平方和比之前更大,那么我们使拟合变得更差,所以我们将斜率旋转到另一个方向并再次尝试。如果平方和变得更小,那么我们使拟合变得更好。我们继续这个过程,每次我们更接近时,斜率的旋转幅度都稍微小一点,直到我们之前迭代的改进小于我们选择的某个预设值。该算法通过迭代学习模型(斜率和 y 截距),根据预测变量预测输出变量的未来值。这个例子稍微有些粗糙,但希望它能说明这样的算法是如何工作的。

注意

机器学习最初令人困惑但最终有趣的方面之一是,有大量的算法可以解决同一类型的问题。原因是不同的人提出了略微不同的解决同一问题的方法,所有这些方法都在试图改进之前的尝试。对于给定的任务,作为数据科学家,我们的任务是选择哪个算法(或算法组合)将学习到最佳性能的模型。

虽然某些算法在处理某些类型的数据时可能比其他算法表现更好,但没有任何一个算法会在所有问题上始终优于所有其他算法。这个概念被称为没有免费午餐定理。换句话说,你不会得到不劳而获的东西;你需要付出一些努力来找出最适合你特定问题的最佳算法。数据科学家通常会选择一些他们知道对于他们正在处理的数据类型和问题表现良好的算法,并查看哪个算法生成的模型性能最佳。你将在本书后面的章节中看到我们是如何做到这一点的。然而,我们可以通过根据它们执行的功能和执行方式将机器学习算法分为类别来缩小我们的初始选择范围。

图 1.4. 一个假设的算法,用于学习直线的参数。此算法将两个连续变量作为输入,并通过均值拟合一条直线。它迭代地旋转直线,直到找到一个最小化平方和的解。直线的参数作为学习模型输出。

1.2. 类别机器学习算法

所有机器学习算法都可以根据它们的学习类型和执行的任务进行分类。有三种学习类型:

  • 监督

  • 无监督

  • 半监督

类型取决于算法的学习方式。它们需要我们通过学习过程来引导吗?还是它们自己学习答案?监督和无监督算法可以进一步分为每类两个子类:

  • 监督

    • 分类

    • 回归

  • 无监督

    • 维度降低

    • 聚类

该类别取决于算法学习去做什么

因此,我们根据它们的学习方式和它们学习去做的事情来分类算法。但我们为什么要关心这个呢?嗯,我们有很多机器学习算法可供选择。我们如何知道该选择哪一个?它们需要什么类型的数据才能正常工作?了解不同算法属于哪些类别可以使我们选择最合适的算法的工作变得简单得多。在下一节中,我将介绍每个类别的定义以及为什么它们与其他类别不同。在本节结束时,你将清楚地了解为什么你会选择一个类别的算法而不是另一个类别的算法。到本书结束时,你将具备应用每个类别中多个算法的技能。

1.2.1. 监督、无监督和半监督学习之间的差异

想象一下,你试图通过使用木块来让幼儿学习形状。在他们面前,他们有一个球、一个立方体和一个星星。你要求他们给你展示立方体,如果他们指向正确的形状,你就告诉他们他们是对的;如果他们错了,你也告诉他们。你重复这个过程,直到幼儿几乎每次都能正确识别形状。这被称为监督学习,因为你知道哪种形状是哪种的人,通过告诉他们答案来监督学习者。

现在想象一个幼儿被给了多个球、立方体和星星,但这次还给了三个袋子。幼儿必须把所有的球放在一个袋子里,立方体放在另一个袋子里,星星放在另一个袋子里,但你不会告诉他们是否正确——他们必须自己根据面前的信息来解决这个问题。这被称为无监督学习,因为学习者必须自己识别模式,没有任何外部帮助。

如果一个机器学习算法使用的是真实数据或换句话说,是标记数据,那么它被称为监督学习。例如,如果我们想根据基因表达将患者的活检分类为健康或癌症,我们会给算法提供标记的基因表达数据,标明该组织是健康还是癌症。现在算法知道哪些案例来自两种类型中的每一种,并试图学习数据中的模式来区分它们。

另一个例子可能是如果我们试图估算一个人的月度信用卡支出。我们可以向算法提供有关其他人的信息,例如他们的收入、家庭规模、是否拥有自己的房子等等,包括他们通常一个月在信用卡上花费的金额。算法会寻找数据中的模式,以可重复的方式预测这些值。当我们从新的人那里收集数据时,算法可以根据它学到的模式来估算他们将花费多少。

如果一个机器学习算法不使用真实数据,而是自己寻找数据中的模式,这些模式暗示着某些潜在的结构,那么它被称为无监督学习。例如,假设我们从许多癌症活检中提取基因表达数据,并要求一个算法告诉我们是否有活检的集群。集群是一组彼此相似但与其他集群中的数据不同的数据点。这种分析可以告诉我们是否有需要不同方式治疗的癌症亚型。

或者,我们可能有一个包含大量变量的数据集——如此之多以至于难以手动解释数据并寻找关系。我们可以要求一个算法寻找一种方法,在尽可能保持原始数据信息的情况下,将这个高维数据集表示为低维数据集。看看图 1.5 的总结。如果你的算法使用标记数据(真实标签),那么它是监督的;如果没有使用标记数据,那么它是无监督的。

图 1.5. 监督学习与无监督学习。监督算法使用已经标记有真实标签的数据来构建一个可以预测未标记、新数据标签的模型。无监督算法使用未标记的数据,并从中学习模式,使得新数据可以被映射到这些模式上。

图 1.5

半监督学习

大多数机器学习算法都将属于这些类别之一,但还有一种称为半监督学习的额外方法。正如其名称所暗示的,半监督机器学习既不是完全监督的,也不是完全无监督的。

半监督学习通常描述的是一种机器学习方法,它将监督和无监督算法结合起来,而不是严格定义一个算法类别。半监督学习的前提是,通常,标记数据集需要专家观察者进行大量手动工作。这个过程可能非常耗时、昂贵且容易出错,可能对于整个数据集来说都是不可能的。因此,我们尽可能精确地标记尽可能多的案例,然后我们只使用标记的数据来构建一个监督模型。我们将剩余的数据(未标记的案例)输入模型以获取它们的预测标签,这些标签被称为伪标签,因为我们不知道它们是否全部正确。现在我们将数据与手动标签和伪标签结合起来,并使用结果来训练一个新的模型。

这种方法允许我们训练一个模型,该模型可以从标记和无标记的数据中学习,并且它可以提高整体预测性能,因为我们能够使用所有可以利用的数据。如果你在完成这本书后想了解更多关于半监督学习的信息,请参阅 Olivier Chapelle、Bernhard Scholkopf 和 Alexander Zien 合著的《半监督学习》(MIT Press,2006 年)。这个参考可能看起来相当过时,但它仍然非常好。

在监督和无监督类别中,机器学习算法可以根据它们执行的任务进一步分类。正如机械工程师知道如何使用适当的工具来完成手头的任务一样,数据科学家需要知道他们应该使用哪些算法来完成他们的任务。有四个主要类别可供选择:分类、回归、降维和聚类。

1.2.2. 分类、回归、降维和聚类

监督机器学习算法可以分为两类:

  • 分类算法接受标记数据(因为它们是监督学习方法),并学习数据中的模式,这些模式可以用来预测一个分类的输出变量。这通常是一个分组变量(指定特定案例属于哪个组的变量)可以是二项式(两组)或多项式(多于两组)。分类问题是机器学习任务中非常常见的问题。哪些客户会拖欠付款?哪些患者会存活?望远镜图像中的哪些物体是恒星、行星或星系?面对这些问题时,你应该使用分类算法。

  • 回归算法接受标记数据,并学习数据中的模式,这些模式可以用来预测一个连续的输出变量。一个家庭对大气贡献了多少二氧化碳?一家公司的股价明天会怎样?患者血液中胰岛素的浓度是多少?面对这些问题时,你应该使用回归算法。

无监督机器学习算法也可以分为两类:

  • 降维算法接受未标记的(因为它们是无监督学习方法)和高维数据(具有许多变量的数据),并学习一种方法,以更少的维度来表示它。降维算法可以用作探索性技术(因为人类很难同时视觉解释两个或三个以上的维度)或作为机器学习管道中的预处理步骤(它可以帮助缓解诸如多重共线性维度诅咒等问题,这些术语我将在后面的章节中定义)。降维算法还可以帮助我们通过允许我们在两个或三个维度中绘制数据来直观地确认分类和聚类算法的性能。

  • 聚类算法接受未标记数据,并学习数据中的聚类模式。一个是一组相互之间比其他簇中的数据点更相似的观察值。我们假设同一簇中的观察值共享一些统一特征,使它们与其他簇可识别地不同。聚类算法可以用作探索性技术来了解我们的数据结构,并可能表明可以输入到分类算法中的分组结构。临床试验中是否存在患者反应的亚型?调查中有多少个受访者类别?不同类型的客户是否使用我们的公司?面对这些问题时,你应该使用聚类算法。

见图 1.6 以总结不同类型算法的类型和功能。

通过将机器学习算法分为这四类,你会发现选择合适的算法来完成手头任务更容易。这就是本书结构安排的原因:我们首先处理分类,然后是回归,接着是降维,最后是聚类,这样你可以清晰地构建起针对特定应用的算法工具箱的清晰心理图像。决定选择哪种算法类别通常很简单:

  • 如果你需要预测一个分类变量,请使用分类算法。

  • 如果你需要预测一个连续变量,请使用回归算法。

  • 如果你需要用更少的变量来表示许多变量的信息,请使用降维。

  • 如果你需要识别案例的簇,请使用聚类算法。

1.2.3. 简述深度学习

如果你阅读过关于机器学习的相关内容,你很可能已经遇到过“深度学习”这个术语,甚至可能在媒体上听说过。深度学习是机器学习的一个子领域(所有深度学习都是机器学习,但并非所有机器学习都是深度学习),在过去 5 到 10 年中因其两个主要原因而变得极为流行:

  • 它可以产生具有卓越性能的模型。

  • 我们现在有了更广泛的计算能力来应用它。

深度学习使用神经网络来学习数据中的模式,这个术语指的是这些模型的结构在表面上类似于大脑中的神经元,它们之间有连接来传递信息。人工智能、机器学习和深度学习之间的关系总结在图 1.7 中。

图 1.6. 分类、回归、降维和聚类。分类和回归算法构建模型来预测未标记的新数据的分类和连续变量。降维算法在更少的维度中创建原始数据的新表示,并将新数据映射到这个表示上。聚类算法在数据中识别簇,并将新数据映射到这些簇上。

图 1-6 替代

图 1.7. 人工智能(AI)、机器学习与深度学习之间的关系。深度学习包含一系列技术,这些技术构成了机器学习技术的一个子集,而机器学习本身又是人工智能的一个子领域。

图 1-7

虽然深度学习方法对于同一数据集通常会比“浅层”学习方法(有时用来区分不是深度学习的机器学习方法)表现更好,但它们并不总是最佳选择。深度学习方法通常不是解决特定问题的最合适方法,原因有三:

  • 它们在计算上非常昂贵。 当然,我们这里所说的“昂贵”并不是指货币成本,而是指它们需要大量的计算能力,这意味着它们可能需要很长时间(几个小时甚至几天!)来训练。可以说,这不是不使用深度学习的一个不那么重要的原因,因为如果你认为某个任务很重要,你可以投入所需的时间和计算资源来解决它。但如果你能在几分钟内训练出一个表现良好的模型,那么为什么还要浪费额外的时间和资源呢?

  • 它们往往需要更多的数据。 深度学习模型通常需要数百到数千个案例才能表现出色。这很大程度上取决于手头问题的复杂性,但浅层方法在小数据集上的表现通常优于它们的深度学习对应物。

  • 规则的可解释性较低。 从本质上讲,深度学习模型更倾向于性能而非模型的可解释性。可以说,我们的重点应该是性能;但通常我们不仅对获得正确的输出感兴趣,我们还对算法学到的规则感兴趣,因为这些有助于我们理解关于现实世界的事情,并可能帮助我们进一步研究。神经网络学到的规则不易解释。

因此,虽然深度学习方法可能非常强大,但浅层学习技术仍然是数据科学家武器库中的无价之宝。

注意事项

深度学习算法特别擅长处理涉及复杂数据的任务,例如图像分类和音频转录。

由于深度学习技术需要大量的额外理论,我认为它们需要自己的书籍,因此我们在这里不会讨论它们。如果您想了解如何应用深度学习方法(并且完成这本书后,我建议您这样做),我强烈推荐 Francois Chollet 和 Joseph J. Allaire 合著的《使用 R 进行深度学习》(Manning, 2018)。

1.3. 考虑机器学习的伦理影响

¹

王一伦和米哈伊尔·科斯金斯基,“从面部图像中检测性取向,深度神经网络比人类更准确,”2017 年,osf.io/zn79k.

这里还有一个例子:2015 年,发现谷歌的图像识别算法会将有色人种的图像分类为大猩猩。[1]. 这里的伦理考虑是,该算法训练所使用的数据偏向于白人图像,在非白人图像上做出了不准确(和非种族主义)的预测。为了避免这种偏见,确保我们的数据集充分代表我们的模型将要释放的人群至关重要。无论是通过合理的抽样策略来完成,还是在训练后测试和纠正偏见,我们都有责任确保我们的模型不会对特定群体产生偏见。

²

Jessica Guynn, “Google Photos Labeled Black People ‘Gorillas,’” USA Today, 2015, mng.bz/j5Na.

机器学习研究的一个额外的伦理问题涉及安全和可信度。虽然这听起来像是直接来自科幻电影中的情节,但机器学习研究现在已经达到一个地步,模型可以从一个人的面部图像创建他们说话的视频。研究人员已经使用这种所谓的深度伪造技术来制作巴拉克·奥巴马说话的任何他们提供的音频的视频。[2]. 想象一下,如果滥用这项技术来伪造一个在刑事审判中被告从未说过的话的证据。类似的技术也已被用来在视频中用一个人的脸替换成另一个人的脸。遗憾的是,这被滥用来将名人面孔换成色情视频。想象一下这可能会对一个人的职业生涯和尊严造成多大的破坏。

³

Supasorn Suwajanakorn, Steven M. Seitz, and Ira Kemelmacher-Shlizerman, “Synthesizing Obama: Learning Lip Sync from Audio,” ACM Transactions on Graphics 36 (4), article 95, 2017, mng.bz/WOQg.

前一个观点让我想到了数据保护和同意的问题。为了训练表现良好的有用机器学习模型,我们需要数据。但是,考虑你使用的数据是否是按照伦理收集的很重要。它是否包含个人、敏感或财务信息?数据是否属于任何人?如果是的话,他们是否已经就其如何使用提供了知情同意?2018 年,咨询公司剑桥分析公司未经同意挖掘了数百万人的社交媒体数据,这些问题受到了关注。随后媒体的强烈抗议和剑桥分析公司的清算应该成为对伦理数据收集程序重要性的一个鲜明提醒。[3]

另外两个伦理考虑因素是:

  • 当一个模型建议采取特定的行动方案时,我们应该盲目地跟随它的预测,还是仅仅作为参考?

  • 当事情出错时,谁应该承担责任?

想象一下,我们有一个机器学习模型,它根据患者的诊断数据告诉我们是否对病人进行手术。如果它在所有之前的案例中都已被证明是正确的,你会愿意遵循模型的建议吗?关于一个预测被告是否有罪或无罪的模型呢?你可以认为第二个例子是荒谬的,但它突出了我的观点:人类应该参与由机器学习信息支持的决策过程吗?如果是这样,人类应该如何参与这些过程?这些问题的答案取决于所做出的决策、它对涉及的人的影响,以及是否应该在决策过程中考虑人类情感。

责任问题提出了这个问题:当机器学习算法做出的决策导致伤害时,谁应该负责?我们生活在一个要求人们对其行为负责的社会。当发生不好的事情时,无论是对是错,我们都期望有人会被发现负有责任。2018 年,一辆具备自动驾驶能力的汽车与一名行人相撞并致其死亡.^([4]) 谁应该负责?制造商?车上的那个人?行人?如果行人是在闯红灯,这有关系吗?在将这些机器学习技术发布到世界之前,需要考虑并仔细解决这类道德困境。

“Elaine Herzberg 之死”,维基百科,mng.bz/8zqK

当你训练一个机器学习模型时,我要求你问自己这五个问题:

  • 我的意图是否道德?

  • 即使我的意图是道德的,其他人是否可能用我的模型造成伤害?

  • 我的模型是否存在可能导致伤害或歧视的偏见?

  • 数据是否被道德地收集?

  • 一旦部署,人类将如何适应模型所做的决策?

如果对任何问题的回答让你感到不安,请仔细考虑你所做的事情是否道德。仅仅因为我们能够做某事,并不意味着我们应该这样做。如果你想深入了解如何进行道德机器学习,我建议阅读 Paula Boddington 的《迈向人工智能道德规范》(Springer,2017 年)。

1.4. 为什么使用 R 进行机器学习?

在两种最常用的数据科学语言之间,R 和 Python,存在某种竞争关系。任何刚开始学习机器学习的人都会选择其中之一来入门,他们的选择通常会受到他们可访问的学习资源、他们所在领域的工作中哪种语言更常见以及他们的同事使用哪种语言的影响。没有只能在一种语言中应用的机器学习任务,尽管一些更前沿的深度学习方法在 Python 中应用起来更容易(它们通常首先用 Python 编写,然后才在 R 中实现)。Python 虽然非常适合数据科学,但它是一种更通用的编程语言,而 R 则专门针对数学和统计应用。这意味着 R 的用户可以纯粹关注数据,但如果他们需要基于他们的模型构建应用程序,可能会感到受限。

当将这两个语言对比用于数据科学时,实际上并没有一个绝对的优势(尽管当然每个人都有他们偏爱的语言)。那么我为什么选择写一本关于 R 中机器学习的书呢?因为 R 中有专门设计来简化数据科学任务并使其易于人类阅读的现代工具,例如来自tidyverse的工具(我们将在第二章中深入探讨这些工具)。

传统上,R 中的机器学习算法分散在由不同作者编写的多个包中。这意味着每次你想应用一个新的算法时,都需要学习使用具有不同参数和实现的新的函数。Python 的支持者可能会用这个例子来说明为什么 Python 更适合机器学习,因为 Python 有一个著名的 scikit-learn 包,它包含大量的内置机器学习算法。但 R 现在也效仿了这一做法,有了 caret 和 mlr 包。虽然 mlr 在目的和功能上与 caret 相当相似,但我认为 mlr 更加灵活和直观;因此,我们在书中将使用 mlr。

mlr 包(代表R 中的机器学习)为大量机器学习算法提供了一个接口,并允许你用非常少的代码执行极其复杂的机器学习任务。在可能的情况下,我们将在这本书中始终使用 mlr 包,这样当你完成学习后,你将能够熟练使用目前最现代的机器学习包之一。

1.5. 我们将使用哪些数据集?

为了使你的学习过程尽可能有趣和吸引人,我们将在我们的机器学习管道中使用真实数据集。R 语言自带相当数量的内置数据集,这些数据集通过我们将加载到 R 会话中的包中的数据集得到补充。我决定使用 R 或其包中自带的数据集,以便你在离线状态下更容易地完成本书。我们将使用这些数据集来帮助我们构建机器学习模型,并比较不同模型在不同类型数据上的表现。

小贴士

在这么多数据集中选择,完成每一章后,我建议你将所学应用到不同的数据集上。

1.6. 在这本书中你将学到什么?

这本书通过 R 语言为你提供了一个动手学习的机器学习入门。为了从本书中受益,你应该熟悉基本的 R 语言编码,例如加载包和操作对象和数据结构。你将学习以下内容:

  • 如何使用 tidyverse 组织、整理和绘制你的数据

  • 诸如过拟合、欠拟合和偏差-方差权衡等关键概念

  • 如何应用来自四个类别(分类、回归、降维和聚类)中的几个机器学习算法

  • 如何验证模型性能并防止过拟合

  • 如何比较多个模型以决定最适合你目的的最佳模型

在整本书中,我们将使用有趣的例子来学习概念并应用我们的知识。在可能的情况下,我们还将将多个算法应用于同一数据集,以便你了解不同算法在特定情况下的表现。

摘要

  • 人工智能是计算机过程产生智能知识的表现。

  • 机器学习是人工智能的一个子领域,其中计算机通过学习数据中的关系来对未来的、未见过的数据进行预测,或者识别有助于我们更好地理解数据的有意义模式。

  • 机器学习算法是通过学习数据中的模式和规则的过程。模型是一组这些模式和规则,它接受新的数据,将这些规则应用于数据,并输出答案。

  • 深度学习是机器学习的一个子领域,而机器学习本身又是人工智能的一个子领域。

  • 根据它们是否从真实标签数据(监督学习)或无标签数据(无监督学习)中学习,机器学习算法被分为监督学习和无监督学习。

  • 监督学习算法被分为分类(如果它们预测一个分类变量)或回归(如果它们预测一个连续变量)。

  • 无监督学习算法被分为降维(如果它们找到数据的低维表示)或聚类(如果它们在数据中识别案例簇)。

  • 除了 Python 之外,R 也是一种流行的数据科学语言,它包含许多工具和内置数据集,这些工具和数据集简化了数据科学和机器学习的过程。

第二章. 使用 tidyverse 整理、操作和绘制数据

本章涵盖

  • 理解 tidyverse

  • 什么是“tidy”数据

  • 安装和加载 tidyverse

  • 使用 tibble、dplyr、ggplot2、tidyr 和 purrr 包

我非常兴奋地开始教你机器学习。但在我们深入探讨之前,我想教你一些技能,这些技能将使你的学习体验更加简单和有效。这些技能还将提高你的数据科学和 R 编程技能。

想象一下,我要求你为我造一辆车(朋友之间常见的请求)。你可以选择老式方法:你可以购买金属、玻璃和其他组件;手工切割所有零件;用锤子将其塑形;并用铆钉将其固定在一起。这辆车可能看起来很漂亮,工作得也很完美,但建造它需要非常长的时间,而且如果你需要再造一辆,你可能很难确切地记住你做了什么。

相反,你可以采取一种现代方法,在工厂中使用机械臂。你可以编程它们来切割和弯曲零件成预定义的形状,并为你组装零件。在这种情况下,对你来说,制造一辆汽车会更快、更简单,而且你很容易在未来重复同样的过程。

现在想象一下,我提出一个更合理的要求,让你重新组织和绘制一个数据集,以便通过机器学习流程。你可以使用基本的 R 函数来完成这个任务,它们会运行得很好。但是代码会很长,可读性不会很高(所以一个月后你可能会很难记住你做了什么),而且生成图表会相当繁琐。

相反,你可以采取更现代的方法,并使用来自 tidyverse 包族的功能。这些功能有助于简化数据处理过程,可读性很高,并且允许你通过最少的输入生成非常吸引人的图形。

2.1. tidyverse 是什么,tidy 数据是什么?

这本书的目的是给你提供将机器学习方法应用于你的数据的技能。虽然我的意图不是涵盖数据科学的各个方面(在一个单本书中我也无法做到),但我确实想介绍你认识 tidyverse。在你将数据输入到机器学习算法之前,它需要以算法愿意处理的形式存在。

tidyverse 是一个“有偏见的 R 包集合,专为数据科学设计”,旨在使 R 中的数据科学任务更简单、更易于阅读和复现。这些包之所以“有偏见”,是因为它们旨在使包作者认为的良好实践变得容易,而使他们认为的不良实践变得困难。这个名字来源于 tidy data 的概念,这是一种数据结构,其中

  • 每一行代表一个单独的观察。

  • 每一列代表一个变量。

看看 表 2.1 中的数据。想象一下,我们取四位跑者并将他们置于新的训练计划中。我们想知道这个计划是否在提高他们的跑步时间,因此我们记录了他们在新训练开始前(月份 0)以及之后的三个月的最佳时间。

表 2.1. 不整洁数据的示例。此表包含四位跑者在开始新的训练计划之前以及之后的三个月的跑步时间。
运动员 月份 0 月份 1 月份 2 月份 3
Joana 12.50 12.1 11.98 11.99
Debi 14.86 14.9 14.70 14.30
Sukhveer 12.10 12.1 12.00 11.80
Kerol 19.60 19.7 19.30 19.00

这是一个不整洁数据的示例。你能看出为什么吗?好吧,让我们回到我们的规则。每一行代表一个单独的观察吗?不。实际上,每一行有四个观察(每个月份一个)。每一列代表一个变量吗?不。此数据中只有三个变量:运动员、月份和最佳时间,但我们有五个列!

同样的数据在整洁格式下看起来会怎样?表 2.2 展示了这一点。

表 2.2. 此表包含与 表 2.1 相同的数据,但以整洁格式呈现。
运动员 月份 最佳
Joana 0 12.50
Debi 0 14.86
Sukhveer 0 12.10
Kerol 0 19.60
Joana 1 12.10
Debi 1 14.90
Sukhveer 1 12.10
Kerol 1 19.70
Joana 2 11.98
Debi 2 14.70
Sukhveer 2 12.00
Kerol 2 19.30
Joana 3 11.99
Debi 3 14.30
Sukhveer 3 11.80
Kerol 3 19.00

这次,我们有了包含之前作为单独列使用的月份标识符的“月份”列,以及包含每个运动员每月最佳时间的“最佳”列。每一行代表一个单独的观察吗?是的!每一列代表一个变量吗?是的!因此,这些数据是整洁格式。

确保你的数据是整洁格式是任何机器学习流程中的早期重要步骤,因此 tidyverse 包含 tidyr 包,它可以帮助你实现这一点。tidyverse 中的其他包与 tidyr 和彼此一起工作,帮助你完成以下任务:

  • 以合理的方式组织和显示你的数据(tibble)

  • 操作和子集你的数据(dplyr)

  • 绘制你的数据(ggplot2)

  • for 循环替换为函数式编程方法(purrr)

在 tidyverse 中你可以执行的所有操作都可以使用 base R 代码实现,但我强烈建议你在工作中使用 tidyverse:它将帮助你使代码更简单、更易于阅读,并且可重复。

tidyverse 的核心和可选包

我将教你如何使用 tidyverse 的 tibble、dplyr、ggplot2、tidyr 和 purrr 包。这些是 tidyverse 的“核心”包,还包括以下这些:

  • readr,用于将外部文件中的数据读入 R

  • forcats,用于处理因子

  • stringr,用于处理字符串

除了可以一起加载的核心包之外,tidyverse 还包括一些需要单独加载的可选包。

想了解更多关于 tidyverse 其他工具的信息,请参阅 Garrett Grolemund 和 Hadley Wickham 所著的《R for Data Science》(O’Reilly 媒体,2016 年)。

2.2. 加载 tidyverse

tidyverse 的包都可以一起安装和加载(推荐)

install.packages("tidyverse")
library(tidyverse)

或根据需要安装和单独加载:

install.packages(c("tibble", "dplyr", "ggplot2", "tidyr", "purrr"))
library(tibble)
library(dplyr)
library(ggplot2)
library(tidyr)
library(purrr)

2.3. tibble 包是什么以及它的作用

如果你曾在 R 中进行过任何形式的数据科学或分析,你肯定遇到过数据框作为存储矩形数据的结构。数据框工作得很好,并且长期以来一直是存储具有不同类型列的矩形数据的唯一方式(与只能处理相同类型数据的矩阵相比),但很少对数据科学家不喜欢的数据框方面进行改进。

注意

如果每一行都有与列数相等的元素数量,并且每一列都有与行数相等的元素数量,则数据是矩形的。数据并不总是这种类型!

tibble 包引入了一种新的数据结构,即 tibble,目的是“保留那些经得起时间考验的特性,并丢弃那些曾经方便但现在令人沮丧的特性”(mng.bz/1wxj)。让我们看看这是什么意思。

2.3.1. 创建 tibbles

使用 tibble() 函数创建 tibbles 与创建数据框的工作方式相同:

myTib <- tibble(x =  1:4,
                y = c("london", "beijing", "las vegas", "berlin"))

myTib

# A tibble: 4 x 2     *1*
      x y             *2*
  <int> <chr>         *3*
1     1 london
2     2 beijing
3     3 las vegas
4     4 berlin
  • 1 告诉我们这是一个有四行两列的 tibble

  • 2 变量名

  • 3 变量类型: = 整数, = 字符

如果你习惯于使用数据框,你将立即注意到 tibbles 打印时的两个不同之处:

  • 当你打印一个 tibble 时,它会告诉你它是一个 tibble 以及其维度。

  • Tibbles 会告诉你每个变量的类型。

第二个特性特别有用,可以避免因变量类型不正确而导致的错误。

小贴士

当打印一个 tibble 时,<int> 表示整数变量,<chr> 表示字符变量,<dbl> 表示浮点数(小数),<lgl> 表示逻辑变量。

2.3.2. 将现有数据框转换为 tibbles

正如你可以使用as.data.frame()函数将对象强制转换为数据框一样,你也可以使用as_tibble()函数将对象强制转换为 tibbles:

myDf <- data.frame(x =  1:4,
                   y = c("london", "beijing", "las vegas", "berlin"))

dfToTib <- as_tibble(myDf)

dfToTib

# A tibble: 4 x 2
      x y
  <int> <fct>
1     1 london
2     2 beijing
3     3 las vegas
4     4 berlin
注意

在这本书中,我们将使用 R 中已经构建好的数据。通常,我们需要从.csv 文件中读取数据到 R 会话中。要作为 tibble 加载数据,你使用read_csv()函数。read_csv()来自 readr 包,当调用library(tidyverse)时加载,是 tidyverse 版本的read.csv()

2.3.3. 数据框和 tibbles 之间的区别

如果你习惯于使用数据框,你将注意到与 tibbles 有一些不同。我在本节中总结了数据框和 tibbles 之间最显著的区别。

Tibbles 不会转换你的数据类型

当人们创建数据框时,常见的烦恼是他们默认将字符串变量转换为因子。这可能很烦人,因为这可能不是处理变量的最佳方式。为了防止这种转换,在创建数据框时必须提供stringsAsFactors = FALSE参数。

与此相反,tibbles 默认不会将字符串变量转换为因子。这种行为是可取的,因为自动将数据转换为特定类型可能会成为令人沮丧的 bug 来源:

myDf <- data.frame(x =  1:4,
                   y = c("london", "beijing", "las vegas", "berlin"))

myDfNotFactor <- data.frame(x =  1:4,
                            y = c("london", "beijing", "las vegas", "berlin"),
                            stringsAsFactors = FALSE)

myTib <- tibble(x =  1:4,
                y = c("london", "beijing", "las vegas", "berlin"))

class(myDf$y)
[1] "factor"

class(myDfNotFactor$y)
[1] "character"

class(myTib$y)
[1] "character"

如果你希望一个变量在 tibble 中是因子类型,你只需将c()函数包裹在factor()函数内部即可:

myTib <- tibble(x =  1:4,
                y = factor(c("london", "beijing", "las vegas", "berlin")))
myTib
简洁的输出,无论数据大小

当你打印数据框时,所有列都会打印到控制台(默认情况下),这使得查看早期变量和案例变得困难。当你打印 tibble 时,它只打印默认情况下适合屏幕的前 10 行和列数,这使得快速了解数据变得更容易。注意,未打印的变量名称列在输出底部列出。运行以下代码,对比starwars tibble(包含在 dplyr 中,当调用library(tidyverse)时可用)的输出与转换为数据框时的外观。

列表 2.1. starwars数据作为 tibble 和数据框
data(starwars)

starwars

as.data.frame(starwars)
小贴士

data()函数将包含在 R 基础包或 R 包中的数据集加载到你的全局环境中。使用不带参数的data()来列出当前加载的包中可用的所有数据集。

使用[进行子集操作始终返回另一个 tibble

当对数据框进行子集操作时,如果你保留多于一个列,[运算符将返回另一个数据框;如果你只保留一个列,则返回一个向量。当对 tibble 进行子集操作时,[运算符将始终返回另一个 tibble。如果你希望显式地将 tibble 列作为向量返回,可以使用[[$运算符。这种行为是可取的,因为我们应该明确我们想要向量还是矩形数据结构,以避免 bug:

myDf[, 1]

[1] 1 2 3 4

myTib[, 1]

# A tibble: 4 x 1
      x
  <int>
1     1
2     2
3     3
4     4

myTib[[1]]

[1] 1 2 3 4

myTib$x
[1] 1 2 3 4
注意

这里的一个例外是,如果你使用单个索引(没有逗号,例如 myDf[1])对数据框进行子集化。在这种情况下,[ 操作符将返回一个单列数据框,但这种方法不允许我们结合行和列的子集化。

变量是按顺序创建的

当构建 tibble 时,变量是按顺序创建的,以便后续变量可以引用之前定义的变量。这意味着我们可以在同一函数调用中动态创建引用其他变量的变量:

sequentialTib <- tibble(nItems = c(12, 45, 107),
                        cost = c(0.5, 1.2, 1.8),
                        totalWorth = nItems * cost)

sequentialTib

# A tibble: 3 x 3
  nItems  cost totalWorth
   <dbl> <dbl>      <dbl>
1     12   0.5         6
2     45   1.2        54
3    107   1.8       193

练习 1

使用 data() 函数加载 mtcars 数据集,将其转换为 tibble,并使用 summary() 函数进行探索。

2.4. dplyr 包是什么以及它做什么

在处理数据时,我们经常需要执行以下操作-:

  • 选择感兴趣的行和/或列

  • 创建新变量

  • 按某些变量的升序或降序排列数据

  • 获取汇总统计量

在执行这些操作时,数据中可能也存在我们希望保持的自然分组结构。dplyr 包允许我们以非常直观的方式执行这些操作。让我们通过一个例子来操作。

2.4.1. 使用 dplyr 操作 CO2 数据集

让我们在 R 中加载内置的 CO2 数据集。我们有一个包含 84 个案例和 5 个变量的 tibble,记录了在不同条件下不同植物对二氧化碳的吸收情况。我将使用这个数据集来教你们一些基本的 dplyr 技能。

列表 2.2. 探索 CO2 数据集
library(tibble)

data(CO2)

CO2tib <- as_tibble(CO2)
CO2tib

# A tibble: 84 x 5
   Plant Type   Treatment   conc uptake
 * <ord> <fct>  <fct>      <dbl>  <dbl>
 1 Qn1   Quebec nonchilled    95   16
 2 Qn1   Quebec nonchilled   175   30.4
 3 Qn1   Quebec nonchilled   250   34.8
 4 Qn1   Quebec nonchilled   350   37.2
 5 Qn1   Quebec nonchilled   500   35.3
 6 Qn1   Quebec nonchilled   675   39.2
 7 Qn1   Quebec nonchilled  1000   39.7
 8 Qn2   Quebec nonchilled    95   13.6
 9 Qn2   Quebec nonchilled   175   27.3
10 Qn2   Quebec nonchilled   250   37.1
# ... with 74 more rows

假设我们只想 选择 第 1、2、3 和 5 列。我们可以使用 select() 函数来完成这个操作。在以下列表中的 select() 函数调用中,第一个参数是数据;然后我们提供我们希望选择的列的数字或名称,用逗号分隔。

列表 2.3. 使用 select() 选择列
library(dplyr)

selectedData <- select(CO2tib, 1, 2, 3, 5)

selectedData

# A tibble: 84 x 4
   Plant Type   Treatment  uptake
 * <ord> <fct>  <fct>       <dbl>
 1 Qn1   Quebec nonchilled   16
 2 Qn1   Quebec nonchilled   30.4
 3 Qn1   Quebec nonchilled   34.8
 4 Qn1   Quebec nonchilled   37.2
 5 Qn1   Quebec nonchilled   35.3
 6 Qn1   Quebec nonchilled   39.2
 7 Qn1   Quebec nonchilled   39.7
 8 Qn2   Quebec nonchilled   13.6
 9 Qn2   Quebec nonchilled   27.3
10 Qn2   Quebec nonchilled   37.1
# ... with 74 more rows

练习 2

选择你的 mtcars tibble 的所有列,除了 qsecvs 变量。

现在假设我们希望 过滤 数据,只包含吸收量大于 16 的案例。我们可以使用 filter() 函数来完成这个操作。filter() 的第一个参数再次是数据,第二个参数是一个逻辑表达式,它将对每一行进行评估。我们可以通过逗号分隔来包含多个条件。

列表 2.4. 使用 filter() 过滤行
filteredData <- filter(selectedData, uptake > 16)

filteredData

# A tibble: 66 x 4
   Plant Type   Treatment  uptake
   <ord> <fct>  <fct>       <dbl>
 1 Qn1   Quebec nonchilled   30.4
 2 Qn1   Quebec nonchilled   34.8
 3 Qn1   Quebec nonchilled   37.2
 4 Qn1   Quebec nonchilled   35.3
 5 Qn1   Quebec nonchilled   39.2
 6 Qn1   Quebec nonchilled   39.7
 7 Qn2   Quebec nonchilled   27.3
 8 Qn2   Quebec nonchilled   37.1
 9 Qn2   Quebec nonchilled   41.8
10 Qn2   Quebec nonchilled   40.6
# ... with 56 more rows

练习 3

过滤你的 mtcars tibble,只包含气缸数(cyl)不等于 8 的案例。

接下来,我们希望按单个植物 分组 并使用 group_by()summarize() 函数分别对数据进行汇总,以获取每个组内吸收量的平均值和标准差。

group_by() 函数中,第一个参数是——没错——数据(看这里的模式?),后面跟着分组变量。我们可以通过逗号分隔来按多个变量进行分组。当我们打印 groupedData 时,除了数据上方有一个表示它们已分组的指示,以及它们是根据哪个变量分组的以及有多少组之外,没有太多变化。这告诉我们,我们将对每个组进行进一步的操作。

列表 2.5. 使用 group_by() 分组数据
groupedData <- group_by(filteredData, Plant)

groupedData

# A tibble: 66 x 4
# Groups:   Plant [11]
   Plant Type   Treatment  uptake
   <ord> <fct>  <fct>       <dbl>
 1 Qn1   Quebec nonchilled   30.4
 2 Qn1   Quebec nonchilled   34.8
 3 Qn1   Quebec nonchilled   37.2
 4 Qn1   Quebec nonchilled   35.3
 5 Qn1   Quebec nonchilled   39.2
 6 Qn1   Quebec nonchilled   39.7
 7 Qn2   Quebec nonchilled   27.3
 8 Qn2   Quebec nonchilled   37.1
 9 Qn2   Quebec nonchilled   41.8
10 Qn2   Quebec nonchilled   40.6
# ... with 56 more rows
提示

你可以通过将 tibble 包裹在 ungroup() 函数中来移除分组结构。

summarize() 函数中,第一个参数是数据;在第二个参数中,我们命名我们正在创建的新变量,后面跟着一个 = 符号,然后是该变量的定义。我们可以通过逗号分隔来创建尽可能多的新变量。在 列表 2.6 中,我们创建了两个汇总变量:每个组的吸收量平均值(meanUp)和每个组的吸收量标准差(sdUp)。现在,当我们打印 summarizedData 时,我们可以看到除了我们的分组变量外,我们的原始变量已经被我们刚刚创建的汇总变量所取代。

列表 2.6. 使用 summarize() 创建变量的汇总
summarizedData <- summarize(groupedData, meanUp = mean(uptake),
                            sdUp = sd(uptake))

summarizedData

# A tibble: 11 x 3
   Plant meanUp   sdUp
   <ord>  <dbl>  <dbl>
 1 Qn1     36.1  3.42
 2 Qn2     38.8  6.07
 3 Qn3     37.6 10.3
 4 Qc1     32.6  5.03
 5 Qc3     35.5  7.52
 6 Qc2     36.6  5.14
 7 Mn3     26.2  3.49
 8 Mn2     29.9  3.92
 9 Mn1     29.0  5.70
10 Mc3     18.4  0.826
11 Mc1     20.1  1.83

最后,我们将从现有变量中突变一个新变量来计算每个组的变异系数,然后我们将使用 mutate()arrange() 函数对数据进行排序,使得新变量值最小的行在顶部,值最大的行在底部。我们可以使用 mutate()arrange() 函数来完成此操作。

对于 mutate() 函数,第一个参数是数据。第二个参数是新变量的名称,后面跟着一个 = 符号,然后是其定义。我们可以通过逗号分隔来创建尽可能多的新变量。

列表 2.7. 使用 mutate() 创建新变量
mutatedData <- mutate(summarizedData,  CV = (sdUp / meanUp) * 100)

mutatedData

# A tibble: 11 x 4
   Plant meanUp   sdUp    CV
   <ord>  <dbl>  <dbl> <dbl>
 1 Qn1     36.1  3.42   9.48
 2 Qn2     38.8  6.07  15.7
 3 Qn3     37.6 10.3   27.5
 4 Qc1     32.6  5.03  15.4
 5 Qc3     35.5  7.52  21.2
 6 Qc2     36.6  5.14  14.1
 7 Mn3     26.2  3.49  13.3
 8 Mn2     29.9  3.92  13.1
 9 Mn1     29.0  5.70  19.6
10 Mc3     18.4  0.826  4.48
11 Mc1     20.1  1.83   9.11
提示

dplyr 函数中的参数评估是顺序的,这意味着我们可以在 summarize() 函数中通过引用 meanUpsdUp 变量来定义 CV 变量,即使它们尚未创建!

arrange() 函数将数据作为第一个参数,后面跟着我们希望按其排列案例的变量。我们可以通过逗号分隔多个列来按多列排列:这样做将按第一个变量的顺序排列案例,任何平局将根据第二个变量的值进行排序,依此类推。

列表 2.8. 使用 arrange() 按变量排列 tibbles
arrangedData <- arrange(mutatedData, CV)

arrangedData

# A tibble: 11 x 4
   Plant meanUp   sdUp    CV
   <ord>  <dbl>  <dbl> <dbl>
 1 Mc3     18.4  0.826  4.48
 2 Mc1     20.1  1.83   9.11
 3 Qn1     36.1  3.42   9.48
 4 Mn2     29.9  3.92  13.1
 5 Mn3     26.2  3.49  13.3
 6 Qc2     36.6  5.14  14.1
 7 Qc1     32.6  5.03  15.4
 8 Qn2     38.8  6.07  15.7
 9 Mn1     29.0  5.70  19.6
10 Qc3     35.5  7.52  21.2
11 Qn3     37.6 10.3   27.5
提示

如果你想要根据变量的值将 tibble 排序为降序,只需将变量包裹在 desc() 中:arrange(mutatedData, desc(CV))

2.4.2. 连接 dplyr 函数

我们在第 2.4.1 节中做的所有事情都可以使用基础 R 实现,但我希望你能看到 dplyr 函数——通常被称为动词(因为它们是可读的,并且清楚地表明了它们的作用)——有助于使代码更简单、更易读。但 dplyr 的真正力量来自于将函数链在一起形成直观、顺序的过程的能力。

在我们处理 CO2 数据的每个阶段,我们都保存了中间数据,并对其应用下一个函数。这样做很繁琐,在我们的 R 环境中创建了大量的不必要的数据对象,而且可读性也不强。相反,我们可以使用管道操作符%>%,当我们加载 dplyr 时它就可用。管道将左侧函数的输出作为右侧函数的第一个参数。让我们看看一个基本的例子:

library(dplyr)

c(1, 4, 7, 3, 5) %>% mean()

[1] 4

%>%操作符将左侧c()函数的输出(长度为 5 的向量)"管道"到mean()函数的第一个参数。我们可以使用%>%操作符将多个函数链在一起,使代码更简洁、更易读。

记得我强调过每个 dplyr 函数的第一个参数是数据吗?嗯,这之所以如此重要且有用,是因为它允许我们将前一个操作的数据管道传输到下一个操作。我们在第 2.4.1 节中经历的数据处理整个流程变成了以下列表。

列表 2.9. 使用%>%操作符将 dplyr 操作链在一起
arrangedData <- CO2tib %>%
  select(c(1:3, 5)) %>%
  filter(uptake > 16) %>%
  group_by(Plant) %>%
  summarize(meanUp = mean(uptake), sdUp = sd(uptake)) %>%
  mutate(CV = (sdUp / meanUp) * 100) %>%
  arrange(CV)

arrangedData

# A tibble: 11 x 4
   Plant meanUp   sdUp    CV
   <ord>  <dbl>  <dbl> <dbl>
 1 Mc3     18.4  0.826  4.48
 2 Mc1     20.1  1.83   9.11
 3 Qn1     36.1  3.42   9.48
 4 Mn2     29.9  3.92  13.1
 5 Mn3     26.2  3.49  13.3
 6 Qc2     36.6  5.14  14.1
 7 Qc1     32.6  5.03  15.4
 8 Qn2     38.8  6.07  15.7
 9 Mn1     29.0  5.70  19.6
10 Qc3     35.5  7.52  21.2
11 Qn3     37.6 10.3   27.5

从上到下阅读代码,每次遇到%>%操作符时,就说出“然后”。你会读作“取 CO2 数据,然后选择这些列,然后过滤这些行,然后按此变量分组,然后用这些变量汇总,然后突变这个新变量,然后按此变量的顺序排列并保存输出为arrangedData。你能看到这是你如何用普通英语向同事解释你的数据处理过程吗?这就是 dplyr 的力量:能够以逻辑的、可读的方式执行复杂的数据处理。

小贴士

%>%操作符之后开始新一行是一种惯例,有助于使代码更容易阅读。

| |

练习 4

mtcars tibble 按gear变量分组,总结mpgdisp变量的中位数,并突变一个新变量,该变量是mpg中位数除以disp中位数,所有这些操作都通过%>%操作符链在一起。

2.5. ggplot2 包是什么以及它做什么

在 R 中,有三个主要的绘图系统:

  • 基础图形

  • Lattice

  • ggplot2

不可否认,ggplot2 是数据科学家中最受欢迎的系统之一;由于它是 tidyverse 的一部分,我们将使用这个系统在本书中绘制我们的数据。ggplot2 中的“gg”代表图形语法,这是一种思想流派,认为任何数据图形都可以通过将数据与绘图组件的层(如轴、刻度、网格线、点、条形和线)相结合来创建。通过以这种方式分层绘图组件,您可以使用 ggplot2 以非常直观的方式创建具有沟通性和吸引力的图表。

让我们加载 R 中附带的数据集 iris,并创建其两个变量的散点图。这些数据由 Edgar Anderson 在 1935 年收集并发表,包含三种鸢尾花植物花瓣和萼片的长度和宽度测量值。

图 2.1. 使用 ggplot2 创建的散点图。Sepal.Length变量映射到 x 美学,Sepal.Width变量映射到 y 美学。通过添加theme_bw()层应用了黑白主题。

图 2-1 的替代图片

创建图 2.1 中图表的代码显示在代码清单 2.10。函数ggplot()将您提供的数据作为第一个参数,将函数aes()作为第二个参数(稍后将有更多关于此的介绍)。这创建了一个基于数据的绘图环境、轴和轴标签。

aes()函数代表美学映射,如果您习惯了基本的 R 绘图,这可能对您来说是新的。美学是图表的一个特征,可以通过数据中的变量来控制。美学的例子包括 x 轴、y 轴、颜色、形状、大小,甚至是绘制在图上的数据点的透明度。在代码清单 2.10 中的函数调用中,我们要求ggplot()Sepal.LengthSepal.Width变量分别映射到 x 轴和 y 轴。

代码清单 2.10. 使用ggplot()函数绘制数据
library(ggplot2)
data(iris)
myPlot <- ggplot(iris, aes(x = Sepal.Length, y = Sepal.Width)) +
  geom_point() +
  theme_bw()

myPlot
小贴士

注意,我们不需要用引号括起变量名;ggplot()很聪明!

我们以+符号结束行,我们使用+来添加我们的绘图中的额外层(我们可以添加尽可能多的层来创建我们想要的图表)。惯例是,当我们向我们的图表添加额外的层时,我们以+结束当前层,并在新行上放置下一层。这有助于保持可读性。

备注

当向初始ggplot()函数调用添加层时,每一行都需要以+结束;您不能将+放在新行上。

下一层是一个名为geom_point()的函数。Geom代表几何对象,这是一种用于表示数据点的图形元素,例如条形、线条、箱线图和更多;用于生成这些层的函数都命名为geom_[图形元素]。例如,让我们向我们的图表添加两个新的层:geom_density_2d(),它添加密度轮廓;以及geom_smooth(),它将带有置信带的平滑线拟合到数据(见图 2.2)。

图 2.2. 与图 2.1 相同的散点图,添加了 2D 密度等高线和平滑线作为层,分别使用geom_density_2d()geom_smooth函数。

图片

这个图表相当复杂,要在基础 R 中实现相同的效果需要很多行代码。而使用 ggplot2 则非常简单!

[代码列表 2.11. 向ggplot对象添加 geom 层]
myPlot +
  geom_density_2d() +
  geom_smooth()
注意

你可以将ggplot保存为命名对象,并简单地向该对象添加新层,而不是每次都从头开始创建图表。

最后,通常很重要的一点是要突出数据中的分组结构,我们可以通过添加颜色或形状美学映射来实现这一点,如图 2.3 所示。生成这些图表的代码在代码列表 2.12 中展示。它们之间的唯一区别是species被用作shapecol(颜色)美学的参数。

图 2.3. 与图 2.1 相同的散点图,将Species变量映射到形状和col美学

图片

[代码列表 2.12. 将species映射到形状和颜色美学]
ggplot(iris, aes(x = Sepal.Length, y = Sepal.Width, shape = Species)) +
  geom_point()  +
  theme_bw()

ggplot(iris, aes(x = Sepal.Length, y = Sepal.Width, col = Species)) +
  geom_point()  +
  theme_bw()
注意

注意到ggplot()在添加除 x 和 y 之外的美学映射时,会自动生成图例。在基础图形中,你必须手动生成这些图例!

我还想教你们关于ggplot()的最后一个非常强大的功能——分面功能。有时我们可能希望创建子图,其中每个子图或面元显示数据中属于某个分组的数据。

例如,图 2.4 显示了相同的数据,但这次按Species变量分面。创建此图表的代码在代码列表 2.13 中展示:我只是在ggplot调用中添加了一个facet_wrap()层,并指定我想按(~Species)分面。

图 2.4. 展示了相同的数据,但不同鸢尾花物种分别绘制在单独的子图或面元上。

图片

[代码列表 2.13. 使用facet_wrap()函数分组子图]
ggplot(iris, aes(x = Sepal.Length, y = Sepal.Width)) +
  facet_wrap(~ Species) +
  geom_point()  +
  theme_bw()

尽管 ggplot2 还有更多功能(包括几乎可以自定义任何外观),但我只想让你了解如何创建复制书中图表所需的基本图表。如果你想将数据可视化技能提升到下一个层次,我强烈推荐 Hadley Wickham 的《ggplot2:数据分析的优雅图形》(Springer International Publishing,2016 年)。

提示

ggplot 中的绘图元素顺序很重要!绘图元素是按顺序叠加的,所以在一个ggplot()调用中添加的元素将位于所有其他元素之上。重新排列用于创建图 2.2 的geom_density_2d()geom_point()函数,并仔细观察发生了什么(图表可能看起来相同,但实际上并不相同!)。

练习 5

从你的 mtcars tibble 中创建 dratwt 变量的散点图,并按 carb 变量着色点。看看当你将 carb 美学映射包裹在 as.factor() 中时会发生什么。

2.6. tidyr 包是什么以及它做什么

在 第 2.1 节 中,我们查看了一个非整洁数据示例,然后是重新结构化为整洁格式后的相同数据。作为数据科学家,我们通常对数据格式没有太多控制权;我们通常必须将不整洁的数据重新结构化为整洁格式,以便我们可以将其传递到我们的机器学习管道中。让我们创建一个不整洁的 tibble 并将其转换为它的整洁格式。

列表 2.14 展示了一个虚构的患者数据 tibble,其中患者的身体质量指数(BMI)在开始某些想象中的干预措施后的第 0 个月、第 3 个月和第 6 个月进行了测量。这是整洁数据吗?嗯,不是。数据中只有三个变量:

  • 患者 ID

  • 测量时月份

  • BMI 测量

但是我们有四列!而且每一行并不包含单个观察的数据:它包含了在该患者身上进行的所有观察数据。

列表 2.14. 不整洁的 tibble
library(tibble)

library(tidyr)

patientData <- tibble(Patient = c("A", "B", "C"),
                      Month0 = c(21, 17, 29),
                      Month3 = c(20, 21, 27),
                      Month6 = c(21, 22, 23))

patientData

# A tibble: 3 x 4
  Patient Month0 Month3 Month6
  <chr>    <dbl>  <dbl>  <dbl>
1 A           21     20     21
2 B           17     21     22
3 C           29     27     23

要将这个不整洁的 tibble 转换为其整洁对应物,我们可以使用 tidyr 的 gather() 函数。gather() 函数将数据作为其第一个参数。key 参数定义了将代表我们“聚集”的列的新变量名称。在这种情况下,我们正在聚集的列命名为 Month0Month3Month6,因此我们将包含这些 keys 的新列称为 Monthvalue 参数定义了将代表我们从聚集的列中获取的数据的新变量名称。在这种情况下,值是 BMI 测量值,因此我们将代表这些值的新的列称为 BMI。最后一个参数是一个定义要聚集并转换为键值对的变量的向量。通过使用 -Patient,我们告诉 gather() 使用除标识变量 Patient 之外的所有变量。

列表 2.15. 使用 gather() 函数整理数据
tidyPatientData <- gather(patientData, key = Month,
                          value = BMI, -Patient)

tidyPatientData

# A tibble: 9 x 3
  Patient Month    BMI
  <chr>   <chr>  <dbl>
1 A       Month0    21
2 B       Month0    17
3 C       Month0    29
4 A       Month3    20
5 B       Month3    21
6 C       Month3    27
7 A       Month6    21
8 B       Month6    22
9 C       Month6    23

我们可以通过输入以下内容达到相同的结果(注意,两个列表返回的 tibble 是相同的)。

列表 2.16. 选择列进行聚集的不同方式
gather(patientData, key = Month, value = BMI, Month0:Month6)

# A tibble: 9 x 3
  Patient Month    BMI
  <chr>   <chr>  <dbl>
1 A       Month0    21
2 B       Month0    17
3 C       Month0    29
4 A       Month3    20
5 B       Month3    21
6 C       Month3    27
7 A       Month6    21
8 B       Month6    22
9 C       Month6    23

gather(patientData, key = Month, value = BMI, c(Month0, Month3, Month6))

# A tibble: 9 x 3
  Patient Month    BMI
  <chr>   <chr>  <dbl>
1 A       Month0    21
2 B       Month0    17
3 C       Month0    29
4 A       Month3    20
5 B       Month3    21
6 C       Month3    27
7 A       Month6    21
8 B       Month6    22
9 C       Month6    23

将数据转换为宽格式

数据结构在patientData tibble 中被称为宽格式,其中单个案例的观测值放置在同一行,跨越多个列。我们通常想要处理整洁数据,因为它使我们的生活更简单:我们可以立即看到我们有哪些变量,分组结构变得清晰,并且大多数函数都设计得可以轻松与整洁数据一起工作。然而,有些罕见的情况下,我们需要将我们的整洁数据转换为宽格式,可能是因为我们需要的函数期望数据以这种格式。我们可以使用spread()函数将整洁数据转换为宽格式:

spread(tidyPatientData, key = Month, value = BMI)

# A tibble: 3 x 4
  Patient Month0 Month3 Month6
  <chr>    <dbl>  <dbl>  <dbl>
1 A           21     20     21
2 B           17     21     22
3 C           29     27     23

它的使用与gather()相反:我们提供keyvalue参数作为使用gather()函数创建的键和值列的名称,该函数为我们将这些转换为宽格式。

练习 6

mtcars tibble 中的vsamgearcarb变量收集到一个单个键值对中。

2.7. purrr 包是什么以及它做什么

我将要向你展示的最后一个 tidyverse 包是 purrr(有三个 r)。R 为我们提供了将其用作函数式编程语言所需的工具。这意味着它为我们提供了将所有计算视为返回其值而不改变工作区中的任何内容的数学函数的工具。

注意

当一个函数执行的操作不仅仅是返回一个值(例如绘制一个图表或改变环境),我们称其为函数的副作用。一个不产生任何副作用的函数被称为纯函数

函数是否产生副作用的一个简单例子可以在列表 2.17 中找到。pure()函数返回a + 1的值,但不会改变全局环境中的任何内容。side_effects()函数使用超级赋值运算符<<-重新分配全局环境中的对象a。每次运行pure()函数,它都会给出相同的输出;但运行side_effect()函数每次都会给出一个新的值(并且还会影响后续pure()函数调用的输出)。

列表 2.17. 创建一个数值向量的列表
a <- 20

pure <- function() {
  a <- a + 1
  a
}

side_effect <- function() {
  a <<- a + 1
  a
}

c(pure(), pure())
[1] 21 21

c(side_effect(), side_effect())
[1] 21 22

调用没有副作用的函数通常是可取的,因为这更容易预测函数将做什么。如果一个函数没有副作用,它可以被替换为不同的实现,而不会破坏你的代码中的任何内容。

一个重要的后果是,for循环,当单独使用时可以产生不希望出现的副作用(例如修改现有变量),可以被包裹在其他函数中。将for循环包裹在内的函数允许我们遍历向量/列表的每个元素(包括数据框或 tibble 的列和行),对该元素应用一个函数,并返回整个迭代过程的输出结果。

注意

如果你熟悉基础 R 的 apply() 家族函数,purrr 包中的函数帮助我们实现相同的功能,但使用一致的语法和一些方便的特性。

2.7.1. 用 map() 替换 for 循环

purrr 包提供了一套函数,允许我们对列表的每个元素应用一个函数。我们使用哪个 purrr 函数取决于输入的数量以及我们想要的输出类型;在本节中,我将演示这个包中最常用函数的重要性。

假设我们有一个包含三个数值向量的列表:

listOfNumerics <- list(a = rnorm(5),
                       b = rnorm(9),
                       c = rnorm(10))

listOfNumerics

$a
[1] -1.4617 -0.3948  2.1335 -0.2203  0.3429

$b
[1]  0.2438 -1.3541  0.6164 -0.5524  0.4519  0.3592 -1.3415 -1.7594  1.2160

$c
 [1] -1.1325  0.2792  0.5152 -1.1657 -0.7668  0.1778  1.4004  0.6492 -1.6320
[10] -1.0986

现在,假设我们想要分别对三个列表元素应用一个函数,比如使用 length() 函数来返回每个元素的长度。我们可以使用 for 循环来做这件事,遍历每个列表元素,并将长度保存为新列表的一个元素,我们预先定义这个新列表以节省时间:

elementLengths <- vector("list", length = 3)

for(i in seq_along(listOfNumerics)) {
  elementLengths[[i]] <- length(listOfNumerics[[i]])
}

elementLengths

[[1]]
[1] 5

[[2]]
[1] 9

[[3]]
[1] 20

这段代码难以阅读,需要我们预先定义一个空向量以防止循环变慢,并且有一个副作用:如果我们再次运行循环,它将覆盖 elementLengths 列表。

相反,我们可以用 map() 函数来替换 for 循环。map 家族中所有函数的第一个参数是我们正在迭代的 数据。第二个参数是我们应用到的每个列表元素的函数。看看 图 2.5,它说明了 map() 函数如何将一个函数应用到列表/向量的每个元素上,并返回一个包含输出的列表。

在这个例子中,map() 函数将 length() 函数应用到 listOfNumerics 列表的每个元素上,并返回这些值作为一个列表。注意,map() 函数还使用输入元素的名称作为输出元素的名称(abc):

map(listOfNumerics, length)

$a
[1] 5

$b
[1] 9

$c
[1] 20
注意

如果你熟悉 apply 家族的基础 R 函数,map() 就是 purrrlapply() 对应函数。

图 2.5. map() 函数接受一个向量或列表作为输入,对每个元素单独应用一个函数,并返回一个包含返回值的列表。

我希望你能立即看到,这比 for 循环简单得多,而且更容易阅读!

2.7.2. 返回原子向量而不是列表

所以 map() 函数总是返回一个列表。但如果,我们想要返回一个原子向量而不是列表,怎么办?purrr 包提供了一系列函数来完成这个任务:

  • map_dbl() 返回一个双精度浮点数向量。

  • map_chr() 返回一个字符向量。

  • map_int() 返回一个整数向量。

  • map_lgl() 返回一个逻辑向量。

这些函数中的每一个都返回一个由其后缀指定的原子向量。这样,我们被迫思考和预先确定我们的输出应该是哪种类型的数据。例如,正如代码清单 2.18 所示,我们可以像以前一样使用map_int()函数返回我们的listOfNumerics列表中每个元素的长度。就像map()一样,map_int()函数将length()函数应用到我们的列表的每个元素上,但它返回一个整数向量。我们可以使用map_chr()函数做同样的事情,它会将输出强制转换为字符向量,但map_lgl()函数会抛出一个错误,因为它无法将输出强制转换为逻辑向量。

注意

强迫我们明确声明我们想要的输出类型可以防止意外类型输出导致的错误。

列表 2.18. 返回原子向量
map_int(listOfNumerics, length)

 a  b  c
 5  9 20

map_chr(listOfNumerics, length)

   a    b    c
 "5"  "9" "20"

map_lgl(listOfNumerics, length)

Error: Can't coerce element 1 from a integer to a logical

练习 7

使用来自 purrr 包的函数返回一个逻辑向量,指示mtcars数据集中每列值的总和是否大于 1,000。

最后,我们可以使用map_df()函数返回一个 tibble 而不是列表。

列表 2.19. 使用map_df()返回一个 tibble
map_df(listOfNumerics, length)

# A tibble: 1 x 3
      a     b     c
  <int> <int> <int>
1     5     9    10

2.7.3. 在 map()家族中使用匿名函数

有时我们想要将一个函数应用到我们尚未定义的列表的每个元素上。在即兴定义的函数称为匿名函数,当我们要应用的函数不太可能经常使用到足以将其分配给一个对象时,它们非常有用。使用基础 R,我们通过简单地调用function()函数来定义一个匿名函数。

列表 2.20. 使用function()定义匿名函数
map(listOfNumerics, function(.) . + 2)

$a
[1] 0.5383 1.6052 4.1335 1.7797 2.3429

$b
[1] 2.2438 0.6459 2.6164 1.4476 2.4519 2.3592 0.6585 0.2406 3.2160

$c
 [1] 0.8675 2.2792 2.5152 0.8343 1.2332 2.1778 3.4004 2.6492 0.3680 0.9014
注意

注意匿名函数中的.。这代表map()当前正在迭代的元素。

function(.)后面的表达式是函数的主体。这种语法没有问题——它工作得很好——但 purrr 提供了function(.)的简写:波浪号~符号。因此,我们可以简化map()调用为

map(listOfNumerics, ~. + 2)

通过用~替换function(.)

2.7.4. 使用 walk()产生函数的副作用

有时我们想要迭代一个函数以产生其副作用。最常见的一个例子是我们想要生成一系列图表。在这种情况下,我们可以使用walk()函数将一个函数应用到列表的每个元素上,以产生函数的副作用。walk()函数还会返回我们传递给它的原始输入数据,因此它在一系列管道操作中的中间步骤绘图时非常有用。以下是一个使用walk()为列表中的每个元素创建单独直方图的例子:

par(mfrow = c(1, 3))

walk(listOfNumerics, hist)
注意

par(mfrow = c(1, 3))函数调用只是将绘图设备分成两行四列,用于基本绘图。

生成的图表显示在图 2.6 中。

但如果我们想使用每个列表元素的名称作为其直方图的标题呢?我们可以使用iwalk()函数,它使我们能够获取每个元素的名字或索引。在提供给iwalk()的函数中,我们可以使用.x来引用正在迭代的列表元素,使用.y来引用其名称/索引:

iwalk(listOfNumerics, ~hist(.x, main = .y))
注意

每个map()函数都有一个i版本,允许我们引用每个元素的名字/索引。

图 2.6. 使用walk()函数遍历列表中每个元素的hist()函数的结果

结果图显示在图 2.7。注意,现在每个直方图的标题显示了它所绘制的列表元素的名称。

图 2.7. 使用iwalk()遍历列表中每个元素的hist()函数的结果

2.7.5. 同时遍历多个列表

有时候我们想要迭代的不是单个列表中的数据。想象一下,我们想要将列表中的每个元素乘以不同的值。我们可以将这些值存储在单独的列表中,并使用map2()函数同时遍历这两个列表,将第一个列表中的元素乘以第二个列表中的元素。这次,我们不是使用.来引用数据,而是分别使用.x.y来特别引用第一个和第二个列表:

multipliers <- list(0.5, 10, 3)

map2(.x = listOfNumerics, .y = multipliers, ~.x * .y)

现在,想象一下,如果我们想迭代的不只是两个列表,而是三个或更多。pmap()函数允许我们同时遍历多个列表。当我想要测试函数的多个参数组合时,我会使用pmap()rnorm()函数从正态分布中抽取随机样本,有三个参数:n(样本数量)、mean(分布的中心)和sd(标准差)。我们可以为每个参数创建一个值列表,然后使用pmap()迭代每个列表,对每种组合运行函数。

我们首先使用expand.grid()函数创建一个包含所有输入向量组合的数据框。因为数据框实际上只是列的列表,所以提供给pmap()的数据框将迭代数据框中的每一列。本质上,我们要求pmap()迭代的函数将使用数据框每一行的参数运行。因此,pmap()将返回八个不同的随机样本,每个样本对应数据框中参数组合的一种。

因为所有map族函数的第一个参数是我们想要迭代的的数据,所以我们可以使用%>%运算符将它们连接起来。以下代码将pmap()返回的随机样本传递给iwalk()函数,为每个样本绘制一个单独的直方图,并标注其索引。

列表 2.21. 使用pmap()遍历多个列表
arguments <- expand.grid(n = c(100, 200),
                         mean = c(1, 10),
                         sd = c(1, 10))

arguments

    n mean sd
1 100    1  1
2 200    1  1
3 100   10  1
4 200   10  1
5 100    1 10
6 200    1 10
7 100   10 10
8 200   10 10
par(mfrow = c(2, 4))

pmap(arguments, rnorm) %>%
  iwalk(~hist(.x, main = paste("Element", .y)))

结果图显示在图 2.8 中。

图 2.8。使用了 pmap() 函数对三个参数向量进行迭代 rnorm() 函数。pmap() 的输出被传递到 iwalk() 中,以迭代每个随机样本的 hist() 函数。

fig2-8_alt.jpg

如果你没有记住我刚才提到的所有 tidyverse 函数,不要担心——我们将在本书的机器学习管道中一直使用这些工具。此外,我们还可以使用 tidyverse 工具做更多的事情,但我所涵盖的这些内容将足以解决你将遇到的最常见的数据操作问题。现在,你已经掌握了如何使用这本书的知识,在下一章中,我们将深入探讨机器学习的理论。

摘要

  • tidyverse 是一系列 R 包的集合,它简化了数据的组织、操作和绘图。

  • 整洁数据是矩形数据,其中每一行是一个单独的观测值,每一列是一个变量。在将数据传递给机器学习函数之前,确保数据处于整洁格式通常很重要。

  • Tibbles 是对数据框的现代改进,它具有更好的打印矩形数据的规则,永远不会改变变量类型,并且在用 [ 子集时总是返回另一个 tibble。

  • dplyr 包提供了人类可读的、动词样式的数据操作函数,其中最重要的是 select()filter()group_by()summarize()arrange()

  • dplyr 的最强大之处在于能够使用 %>% 操作符将函数连接起来,该操作符将左侧函数的输出作为右侧函数的第一个参数。

  • ggplot2 包是一个现代且流行的 R 语言绘图系统,它允许你以简单、分层的方式创建有效的图表。

  • tidyr 包提供了重要的 gather() 函数,它允许你轻松地将不整洁的数据转换为整洁格式。与此函数相反的是 spread(),它将整洁数据转换为宽格式。

  • purrr 包提供了一种简单、一致的方式来迭代地对列表中的每个元素应用函数。

练习解答

  1. 加载 mtcars,将其转换为 tibble,并使用 summary() 进行探索:

    library(tidyverse)
    
    data(mtcars)
    
    mtcarsTib <- as_tibble(mtcars)
    
    summary(mtcarsTib)
    
  2. 选择除 qsecvs 之外的所有列:

    select(mtcarsTib, c(-qsec, -vs))
    # or
    select(mtcarsTib, c(-7, -8))
    
  3. 筛选出气缸数不等于 8 的行:

    filter(mtcarsTib, cyl != 8)
    
  4. gear 分组,计算 mpgdisp 的中位数,并创建一个新变量,该变量是 mpg 中位数除以 disp 中位数:

    mtcarsTib %>%
      group_by(gear) %>%
      summarize(mpgMed = median(mpg), dispMed = median(disp)) %>%
      mutate(mpgOverDisp = mpgMed / dispMed)
    
  5. 创建 dratwt 变量的散点图,并按 carb 分色:

    ggplot(mtcarsTib, aes(drat, wt, col = carb)) +
      geom_point()
    
    ggplot(mtcarsTib, aes(drat, wt, col = as.factor(carb))) +
      geom_point()
    
  6. vsamgearcarb 聚合成一个单一的关键值对:

    gather(mtcarsTib, key = "variable", value = "value", c(vs, am, gear, carb))
    # or
    gather(mtcarsTib, key = "variable", value = "value", c(8:11))
    
  7. mtcars 的每一列进行迭代,返回一个逻辑向量:

    map_lgl(mtcars, ~sum(.) > 1000)
    # or
    map_lgl(mtcars, function(.) sum(.) > 1000)
    

第二部分. 分类

现在我们已经覆盖了一些基本的机器学习术语,并且您的 tidyverse 技能正在发展,让我们最终开始学习一些实用的机器学习技能。本书的其余部分分为四个部分:

  • 分类

  • 回归

  • 维度降低

  • 聚类

在这些各个部分中,每一章都将专注于不同的算法(或算法集)。每一章将从解释算法学习背后的理论开始,以图形化的方式呈现,然后本章的其余部分将通过将算法应用于真实数据集来将我们的知识转化为技能。

回想一下第一章,分类和回归都是监督学习任务。它们是监督学习,因为我们有一个可以用来训练模型的基准真实值。我们将从关注第三章到第八章(kindle_split_013.html#ch03 到 kindle_split_018.html#ch08)中的分类变量的预测开始,欢迎来到本书的分类部分。在向您介绍算法的工作原理和使用方法的同时,我还会向您介绍一系列其他机器学习技能,例如如何评估模型性能以及如何调整模型以最大化其性能。当您完成本书的这一部分时,我希望您会对使用 R 中的 mlr 包进行机器学习任务感到非常有信心。mlr 包为任何机器学习任务创建了一个非常简单、重复的工作流程,这将使您的学习变得更加简单。一旦我们完成了本书的分类部分,我们将转向回归部分,预测连续变量。

第三章. 基于 k 近邻相似性的分类

本章涵盖

  • 理解偏差-方差权衡

  • 欠拟合与过拟合

  • 使用交叉验证来评估模型性能

  • 构建 k 近邻分类器

  • 调整超参数

这可能是整本书中最重要的章节。在这章中,我将向您展示 k 近邻(kNN)算法是如何工作的,我们将用它来对潜在的糖尿病患者进行分类。此外,我还会使用 kNN 算法向您介绍一些机器学习中的基本概念,这些概念我们将贯穿整本书。

到本章结束时,您不仅将理解和能够使用 kNN 算法来构建分类模型,而且您还将能够验证其性能并调整它以尽可能提高其性能。一旦模型构建完成,您将学习如何将新的、未见过的数据输入其中,并获取数据的预测类别(我们试图预测的类别或分组变量的值)。我将向您介绍 R 中的极其强大的 mlr 包,它包含大量令人垂涎的机器学习算法,极大地简化了我们的所有机器学习任务。

3.1. k 近邻算法是什么?

我认为生活中的简单事情是最好的:在公园里玩飞盘、遛狗、和家人玩桌游,以及使用 kNN 算法。一些机器学习从业者对 kNN 有点不屑,因为它非常简单。事实上,kNN 可能是简单的机器学习算法,这也是我喜欢它的一个原因。尽管它很简单,但 kNN 可以提供令人惊讶的分类性能,而且它的简单性使得它易于解释。

注意

记住,因为 kNN 使用标记数据,所以它是一个监督学习算法。

3.1.1. k-最近邻算法是如何学习的?

那么,kNN 是如何学习的呢?好吧,我将用蛇来帮助我解释。我来自英国,有些人可能会惊讶地了解到,我们有一些本土的蛇种。两个例子是草蛇和蝰蛇,这是英国唯一的毒蛇。但我们还有一种可爱、无肢的爬行动物,叫做蚺蜥,它通常被误认为是蛇。

想象一下,你在一个旨在统计林地中草蛇、蝰蛇和蚺蜥数量的爬行动物保护项目中工作。你的任务是构建一个模型,让你能够快速地将你发现的爬行动物分类到这三个类别之一。当你发现这些动物之一时,你只有足够的时间快速估计它的长度以及它对你攻击性的某种度量,然后它就会滑走(你的项目资金非常紧张)。一位爬行动物专家帮助你手动分类你迄今为止所做的观察,但你决定构建一个 kNN 分类器来帮助你快速分类你将来遇到的样本。

在图 3.1 中查看分类前的数据图。我们的每个案例都是针对身体长度和攻击性绘制的,而你专家所识别的物种由数据点的形状表示。你再次进入林地并收集了三个新样本的数据,这些样本用黑色十字表示。

图 3.1. 爬行动物的身体长度和攻击性。蝰蛇、草蛇和蚺蜥的标记案例由其形状表示。新的未标记数据用黑色十字表示。

图 3-1

我们可以用两个阶段来描述 kNN 算法(以及其他机器学习算法):

  1. 训练阶段

  2. 预测阶段

kNN 算法的训练阶段仅包括存储数据。这在机器学习算法中是不寻常的(你将在后面的章节中了解到),这意味着大部分计算都是在预测阶段完成的。

在预测阶段,kNN 算法计算每个新的、未标记的案例与所有标记案例之间的距离。当我说“距离”时,我是指它们在攻击性和身体长度变量方面的接近程度,而不是你在树林中找到它们的距离!这个距离度量通常称为欧几里得距离,在二维甚至三维中,你可以将其想象为图上两点之间的直线距离(这个距离在图 3.2 中显示)。这是在数据中存在的维度数上计算的。

图 3.2。kNN 算法的第一步:计算距离。线条代表一个未标记案例(十字架)与每个标记案例之间的距离。

图 3-2

接下来,对于每个未标记的案例,算法将邻居从最近的(最相似的)到最远的(最不相似的)进行排序。这如图图 3.3 所示。

图 3.3。kNN 算法的第二步:排序邻居。线条代表一个未标记案例(十字架)与每个标记案例之间的距离。数字代表未标记案例(十字架)与每个标记案例(1 = 最接近)之间的排序距离。

图 3-3

算法识别每个未标记案例最近的k个标记案例(邻居)。k是一个我们指定的整数(我将在 3.1 节中介绍我们如何选择k)。换句话说,找到与未标记案例在变量方面最相似的k个标记案例。最后,每个 k 个最近邻案例“投票”决定未标记数据属于哪个类别,基于最近邻自己的类别。换句话说,k 个最近邻中大多数属于的类别就是未标记案例被分类为的类别。

注意

因为所有的计算都是在预测阶段完成的,所以 kNN 被称为懒惰学习器

让我们通过图 3.4 来实际操作,看看这个方法。当我们把k设为 1 时,算法找到与每个未标记数据项最相似的单一标记案例。每个未标记的爬行动物最接近草蛇类的一个成员,所以它们都被分配到这个类别。

图 3.4。kNN 算法的最终步骤:识别 k 个最近邻并进行多数投票。线条将未标记的数据与它们的单个、三个和五个最近邻连接起来。每个场景的多数投票由每个十字架下绘制的形状表示。

图 3-4

当我们将 k 设置为 3 时,算法会找到与每个未标记数据项最相似的三个标记案例。正如你在图中可以看到的,两个未标记案例的最近邻属于多个类别。在这种情况下,每个最近邻“投票”支持它自己的类别,多数投票获胜。这非常直观,因为如果一条异常好斗的草蛇恰好是尚未标记的蝰蛇的最近邻,它将被数据中的邻近蝰蛇所投票击败。

希望现在你能看到这是如何扩展到 k 的其他值的。例如,当我们把 k 设置为 5 时,算法只是找到与未标记数据最近的五个案例,并将多数投票作为未标记案例的类别。请注意,在这三种情况下,k 的值直接影响到每个未标记案例的分类。

小贴士

kNN 算法实际上可以用于分类 回归问题!我将在第十二章中向你展示如何做到这一点,但唯一的区别是,算法不是采取多数类投票,而是找到最近邻值的平均值或中位数。

3.1.2. 如果投票结果平局会怎样?

有可能所有 k 个最近邻属于不同的类别,投票结果出现平局。在这种情况下会发生什么?嗯,在二分类问题中(当数据只能属于两个互斥组之一时),我们可以通过确保选择奇数个 k 来避免这种情况。这样,总会有一票决定。但在像我们的爬行动物分类问题这样的情况下,我们有超过两个组怎么办?

处理这种情况的一种方法是将 k 减小,直到可以赢得多数投票。但如果一个未标记案例与它的两个最近邻等距,这并没有帮助。

相反,一种更常见(也更实用)的方法是将没有多数投票的案例随机分配到其中一个类别。实际上,在最近邻之间出现平局的案例比例非常小,所以这对模型的分类精度影响有限。然而,如果你数据中的平局很多,你的选择如下:

  • 选择不同的 k 值。

  • 向数据添加少量噪声。

  • 考虑使用不同的算法!我将在第八章的末尾向你展示如何比较同一问题的不同算法的性能。

3.2. 构建你的第一个 kNN 模型

想象一下,你在医院工作,并试图改善糖尿病患者的诊断。你从疑似糖尿病患者那里收集了几个月的诊断数据,并记录他们是否被诊断为健康、化学性糖尿病或显性糖尿病。你希望使用 kNN 算法训练一个模型,可以预测新患者属于这些类别中的哪一个,以便提高诊断。这是一个三分类问题。

我们将从构建一个简单的、直观的 kNN 模型开始,然后在接下来的章节中逐步改进它。首先的事情是——让我们安装 mlr 包并加载它以及 tidyverse:

install.packages("mlr", dependencies = TRUE)

library(mlr)

library(tidyverse)
警告

安装 mlr 包可能需要几分钟。您只需要做一次。

3.2.1. 加载和探索糖尿病数据集

现在,让我们加载 mclust 包中内置的一些数据,将其转换为 tibble,并对其进行一些探索(回想一下第二章中提到的,tibble 是 tidyverse 存储矩形数据的方式):见列表 3.1。我们有一个包含 145 个案例和 4 个变量的 tibble。class因子显示 76 个案例是非糖尿病的(Normal),36 个是化学性糖尿病的(Chemical),33 个是显性糖尿病的(Overt)。其他三个变量是葡萄糖耐量测试后血糖和胰岛素水平的连续测量(分别称为glucoseinsulin),以及血糖的稳态水平(sspg)。

列表 3.1. 加载糖尿病数据
data(diabetes, package = "mclust")

diabetesTib <- as_tibble(diabetes)

summary(diabetesTib)

class       glucose       insulin            sspg
Chemical:36    Min.   : 70   Min.   :  45.0   Min.   : 10.0
Normal  :76    1st Qu.: 90   1st Qu.: 352.0   1st Qu.:118.0
Overt   :33    Median : 97   Median : 403.0   Median :156.0
               Mean   :122   Mean   : 540.8   Mean   :186.1
               3rd Qu.:112   3rd Qu.: 558.0   3rd Qu.:221.0
               Max.   :353   Max.   :1568.0   Max.   :748.0

diabetesTib

# A tibble: 145 x 4
   class  glucose insulin  sspg
 * <fct>    <dbl>   <dbl> <dbl>
 1 Normal      80     356   124
 2 Normal      97     289   117
 3 Normal     105     319   143
 4 Normal      90     356   199
 5 Normal      90     323   240
 6 Normal      86     381   157
 7 Normal     100     350   221
 8 Normal      85     301   186
 9 Normal      97     379   142
10 Normal      97     296   131
# ... with 135 more rows

为了展示这些变量之间的关系,它们被绘制在一起,见图 3.5。生成这些图表的代码在列表 3.2 中。

图 3.5. 在diabetesTib中绘制变量之间的关系。所有连续变量的三种组合都显示出来,并用颜色阴影表示类别。

![fig3-5_alt.jpg]

列表 3.2. 绘制糖尿病数据
ggplot(diabetesTib, aes(glucose, insulin, col = class)) +
  geom_point()  +
  theme_bw()

ggplot(diabetesTib, aes(sspg, insulin, col = class)) +
  geom_point() +
  theme_bw()

ggplot(diabetesTib, aes(sspg, glucose, col = class)) +
  geom_point() +
  theme_bw()

观察数据,我们可以看到三个类别之间连续变量的差异,因此让我们构建一个 kNN 分类器,我们可以用它来预测未来患者的糖尿病状态。

练习 1

重新绘制图 3.5 中glucoseinsulin的关系图,但使用形状而不是颜色来表示每个案例所属的类别。完成此操作后,修改您的代码以使用形状颜色来表示类别。

我们的数据集只包含连续预测变量,但通常我们可能还要处理分类预测变量。kNN 算法不能直接处理分类变量;它们需要首先以某种方式编码,或者必须使用除欧几里得距离之外的距离度量。

对于 kNN(以及许多机器学习算法)来说,通过将预测变量除以它们的方差来缩放预测变量也非常重要。这保留了变量之间的关系,但确保算法不会因为变量测量在更大的尺度上而给予它们更多的重视。在当前示例中,如果我们把glucoseinsulin变量除以 1000000,那么预测将主要依赖于sspg变量的值。我们不需要自己缩放预测变量,因为默认情况下,由 mlr 包包装的 kNN 算法会为我们完成这项工作。

3.2.2. 使用 mlr 训练第一个 kNN 模型

我们理解我们试图解决的问题(将新患者分类为三个类别之一),现在我们需要训练 kNN 算法来构建一个可以解决该问题的模型。使用 mlr 包构建机器学习模型有三个主要阶段:

  1. 定义任务。 任务由数据和我们要对其做什么组成。在这种情况下,数据是diabetesTib,我们想要使用class变量作为目标变量进行数据分类。

  2. 定义学习器。 学习器只是我们计划使用的算法的名称,以及算法接受的任何附加参数。

  3. 训练模型。 这个阶段正如其名:你将任务传递给学习器,学习器生成一个你可以用来进行未来预测的模型。

小贴士

这可能看起来有些过于繁琐,但将任务、学习器和模型分成不同的阶段是非常有用的。这意味着我们可以定义一个单独的任务并对其应用多个学习器,或者定义一个单独的学习器并用多个不同的任务对其进行测试。

3.2.3. 告诉 mlr 我们想要实现的目标:定义任务

让我们从定义我们的任务开始。定义任务所需的组件

  • 包含预测变量的数据(我们希望包含用于做出预测/解决我们问题的所需信息的变量)

  • 我们想要预测的目标变量

对于监督学习,如果存在分类问题,目标变量将是分类的,如果存在回归问题,目标变量将是连续的。对于无监督学习,我们从任务定义中省略目标变量,因为我们没有访问到标记数据。任务组件在图 3.6 中展示。

图 3.6. 在 mlr 中定义一个任务。任务定义包括包含预测变量的数据,以及对于分类和回归问题,我们想要预测的目标变量。对于无监督学习,目标变量被省略。

我们想构建一个分类模型,所以我们使用makeClassifTask()函数来定义一个分类任务。当我们构建第三部分和第五部分的回归和聚类模型时,我们将分别使用makeRegrTask()makeClusterTask()。我们将我们的 tibble 的名称作为data参数,将包含类别标签的因子的名称作为target参数:

diabetesTask <- makeClassifTask(data = diabetesTib, target = "class")
注意

你可能会注意到当构建任务时,mlr 会发出一个警告消息,指出你的数据不是一个纯data.frame(它是一个 tibble)。这不是问题,因为该函数会为你将 tibble 转换为data.frame

如果我们调用任务,我们可以看到它是在diabetesTib tibble 上的分类任务,其目标是class变量。我们还获得了有关观测数和不同类型变量(在机器学习术语中通常称为特征)数量的信息。一些其他信息包括是否有缺失数据,每个类别的观测数,以及哪个类别被认为是“阳性”类别(仅适用于双类任务):

diabetesTask

Supervised task: diabetesTib
Type: classif
Target: class
Observations: 145
Features:
   numerics     factors     ordered functionals
          3           0           0           0
Missings: FALSE
Has weights: FALSE
Has blocking: FALSE
Has coordinates: FALSE
Classes: 3
Chemical   Normal    Overt
      36       76       33
Positive class: NA

3.2.4. 告诉 mlr 使用哪个算法:定义学习器

接下来,让我们定义我们的学习器。定义学习器所需的组件如下:

  • 我们正在使用的算法类别:

    • "classif."用于分类

    • "regr."用于回归

    • "cluster."用于聚类

    • "surv.""multilabel."用于预测生存和多层分类,这里不做讨论

  • 我们正在使用的算法

  • 我们可能希望使用的任何其他选项来控制算法

正如你所看到的,第一个和第二个组件组合成一个字符参数来定义将使用哪个算法(例如,"classif.knn")。学习器的组件在图 3.7 中显示。

图 3.7. 在 mlr 中定义学习器。学习器定义包括你想要使用的算法类别、单个算法的名称,以及可选的任何其他参数来控制算法的行为。

我们使用makeLearner()函数来定义一个学习器。makeLearner()函数的第一个参数是我们将要用于训练模型的算法。在这种情况下,我们想使用 kNN 算法,所以我们提供"classif.knn"作为参数。看看这是如何将类("classif.")与算法的名称(knn")结合在一起的吗?

参数par.vals代表参数值,这允许我们指定算法要使用的 k-最近邻的数量。现在,我们将其设置为 2,但我们将很快讨论如何选择k

knn <- makeLearner("classif.knn", par.vals = list("k" = 2))

如何列出 mlr 的所有算法

mlr 包有大量的机器学习算法可以提供给makeLearner()函数,多到我不检查就无法记住!要列出所有可用的学习器,只需使用

listLearners()$class

或者按功能列表:

listLearners("classif")$class
listLearners("regr")$class
listLearners("cluster")$class

如果你不确定有哪些算法可供选择,或者对于特定的算法,应该传递给makeLearner()函数的哪个参数,可以使用这些函数来提醒自己。

3.2.5. 将所有内容组合起来:训练模型

现在我们已经定义了我们的任务和我们的学习器,我们现在可以训练我们的模型了。训练模型所需的组件是我们之前定义的学习器和任务。定义任务和学习器并将它们组合起来以训练模型的全过程在图 3.8 中展示。

图 3.8. 在 mlr 中训练模型。训练模型简单来说就是将学习器与任务相结合。

这是通过train()函数实现的,它将学习器作为第一个参数,将任务作为第二个参数:

knnModel <- train(knn, diabetesTask)

我们已经有了我们的模型,现在让我们通过它传递数据来查看它的表现。predict()函数接受未标记的数据并将其通过模型传递以获取预测类别。第一个参数是模型,传递给它的数据作为newdata参数给出:

knnPred <- predict(knnModel, newdata = diabetesTib)

我们可以将这些预测作为performance()函数的第一个参数传递。这个函数将模型预测的类别与真实类别进行比较,并返回预测值和真实值匹配程度的性能指标predict()performance()函数的使用在图 3.9 中得到了说明。

图 3.9. mlr 的predict()performance()函数的总结。predict()函数将观测值传递给模型并输出预测值。performance()函数将这些预测值与案例的真实值进行比较,并输出一个或多个性能指标,总结这两个值之间的相似性。

我们通过将它们作为列表提供给measures参数来指定我们希望函数返回的性能指标。我请求的两个指标是mmce,即平均误分类误差;和acc,或准确率。MMCE 简单地说就是被错误分类为非真实类别的案例比例。准确率是它的对立面:模型正确分类的案例比例。你可以看到这两个指标的总和为 1.00:

performance(knnPred, measures = list(mmce, acc))

      mmce        acc
0.04827586 0.95172414

因此,我们的模型正确分类了 95.2%的案例!这是否意味着它在处理新、未见过的患者时表现良好?事实是,我们不知道。通过要求模型在最初用于训练它的数据上做出预测来评估模型性能,告诉你关于模型在完全未见过的数据上做出预测时表现如何的信息非常有限。因此,你永远不应该以这种方式评估模型性能。在我们讨论原因之前,我想介绍一个重要的概念,称为偏差-方差权衡。

3.3. 平衡模型误差的两个来源:偏差-方差权衡

机器学习中有一个概念非常重要,但很多人对此理解有误,我想花时间好好解释一下:偏差-方差权衡。让我们从一个例子开始。一位同事给你发送了公司收到的电子邮件数据,并请你构建一个模型来分类 incoming emails 为垃圾邮件或非垃圾邮件(这当然是一个分类问题)。数据集包含 30 个变量,包括电子邮件中的字符数、URL 的存在以及发送到的电子邮件地址数量,以及电子邮件是否为垃圾邮件。

你懒洋洋地构建了一个仅使用四个预测变量的分类模型(因为快到午餐时间了,今天有咖喱鸡块供应)。你将模型发送给你的同事,他将其作为公司的垃圾邮件过滤器实施。

一周后,你的同事回来抱怨垃圾邮件过滤器表现不佳,并且持续错误地分类某些类型的电子邮件。你将用于训练模型的原始数据重新输入到模型中,发现它只正确分类了 60%的电子邮件。你决定你可能对数据进行了欠拟合:换句话说,你的模型过于简单,并且倾向于错误分类某些类型的电子邮件。

你回到数据,这次你在模型中将所有 30 个变量作为预测变量。你将数据再次通过模型,发现它正确分类了 98%的电子邮件:一个改进,当然!你将这个第二个模型发送给你的同事,并告诉他们你确信它更好。又过去了一周,你的同事再次来找你抱怨模型表现不佳:它错误地分类了许多电子邮件,并且以一种不可预测的方式。你决定你已经对数据进行了过拟合:换句话说,你的模型过于复杂,正在模拟你用于训练它的数据中的噪声。现在,当你给模型提供新的数据集时,它给出的预测有很大的方差。一个过拟合的模型在训练数据上表现良好,但在新的数据上表现较差。

在模型构建中,欠拟合和过拟合是两种重要的错误来源。在欠拟合中,我们包含的预测变量太少或模型过于简单,无法充分描述数据中的关系/模式。结果是,模型被认为是有偏差的:一个在训练数据和新的数据上表现都差的模型。

备注

由于我们通常喜欢解释掉数据中的尽可能多的变化,并且因为我们通常有比问题重要的变量多得多,所以欠拟合不如过拟合常见。

过拟合是欠拟合的对立面,描述了包含过多预测因子或过于复杂的模型的情况,以至于我们不仅模拟了数据中的关系/模式,还模拟了噪声。数据集中的噪声是与我们所测量的变量没有系统关系的变异,而是由于我们变量的固有变异和/或测量误差。噪声的模式对单个数据集非常具体,因此如果我们开始模拟噪声,我们的模型可能在训练数据上表现非常好,但对于未来的数据集给出相当可变的结果。

欠拟合和过拟合都引入了误差并降低了模型的泛化能力:模型泛化到未来、未见数据的能力。它们也是对立的:在欠拟合且有偏差的模型和过拟合且有方差的模型之间,有一个平衡偏差-方差权衡的最优模型;参见图 3.10。

图 3.10. 偏差-方差权衡。泛化误差是模型做出错误预测的比例,是过拟合和欠拟合的结果。与过拟合(模型过于复杂)相关的误差是方差。与欠拟合(模型过于简单)相关的误差是偏差。与过拟合(模型过于复杂)相关的误差是方差。一个最优模型平衡了这种权衡。

图 3-10

现在,看看图 3.11。你能看到欠拟合模型在表示数据中的模式方面表现不佳,而过拟合模型过于细致,并且模型了数据中的噪声而不是真实模式吗?

图 3.11. 对于一个二分类问题的欠拟合、最优拟合和过拟合的示例。虚线代表决策边界。

图 3-11_ 替代

在我们的 kNN 算法的情况下,选择一个小的k值(其中只包含少量非常相似的案例进行投票)更有可能模拟我们数据中的噪声,导致一个更复杂的模型,当使用它来分类未来的患者时会产生很多方差。相比之下,选择一个大的k值(其中包含更多的邻居进行投票)更有可能错过我们数据中的局部差异,导致一个不太复杂的模型,是欠拟合的,并且倾向于错误分类某些类型的患者。我保证你很快就会学到如何选择k

所以你现在可能想知道的问题可能是,“我如何判断我是欠拟合还是过拟合?”答案是称为交叉验证的技术。

3.4. 使用交叉验证来判断我们是否过拟合或欠拟合

在电子邮件示例中,一旦你训练了第二个过度拟合的模型,你试图通过查看它对你用于训练的数据的分类效果来评估其性能。我提到这是一个极其糟糕的想法,原因如下:模型几乎总是会在你训练它的数据上比在新未见过的数据上表现更好。你可以构建一个极度过度拟合的模型,模拟数据集中的所有噪声,而你永远不会知道,因为将数据再次通过模型会给你良好的预测准确性。

答案是评估你的模型在尚未见过的数据上的性能。你可以采取的一种方法是在所有可用的数据上训练模型,然后,在接下来的几周和几个月里,随着你收集新的数据,将其通过模型并评估模型的性能。这种方法非常缓慢且效率低下,可能会使模型构建耗时数年!

相反,我们通常将数据分成两部分。我们使用一部分来训练模型:这部分被称为训练集。我们使用剩下的部分,算法在训练过程中从未见过,来测试模型:这部分是测试集。然后,我们评估模型在测试集上的预测与真实值之间的接近程度。我们用性能指标来总结这些预测的接近程度,我们将在 3.1 节中探讨这些指标。测量训练模型在测试集上的表现有助于我们确定我们的模型是否会在未见过的数据上表现良好,或者我们是否需要进一步改进它。

这个过程被称为交叉验证(CV),在任何监督机器学习流程中都是一个极其重要的方法。一旦我们交叉验证了模型并且对其性能感到满意,我们就使用我们拥有的所有数据(包括测试集中的数据)来训练最终的模型(因为通常,我们训练模型的数据越多,其偏差就越小)。

常见的交叉验证方法有三种:

  • 保留法交叉验证

  • K 折交叉验证

  • 留一法交叉验证

3.5. 对我们的 kNN 模型进行交叉验证

让我们先回顾一下我们之前创建的任务和学习器:

diabetesTask <- makeClassifTask(data = diabetesTib, target = "class")

knn <- makeLearner("classif.knn", par.vals = list("k" = 2))

太好了!在我们用所有数据训练最终模型之前,让我们先交叉验证学习器。通常,你会决定最适合你数据的 CV 策略;但为了演示目的,我将向你展示保留法、k 折和留一法交叉验证。

3.5.1. 保留法交叉验证

保留法交叉验证是理解起来最简单的方法:你只需随机保留你数据的一部分作为测试集,并在剩余的数据上训练你的模型。然后,你将测试集通过模型并计算其性能指标(我们很快会讨论这些)。你可以在图 3.12 中看到保留法交叉验证的方案。

图 3.12. 保留集交叉验证。数据被随机分为训练集和测试集。训练集用于训练模型,然后使用该模型在测试集上进行预测。预测值与测试集真实值的相似性用于评估模型性能。

图 3-12

在遵循此方法时,您需要决定使用多少比例的数据作为测试集。测试集越大,训练集就越小。这里令人困惑的部分是:通过交叉验证进行性能估计也受误差和偏差-方差权衡的影响。如果您的测试集太小,那么性能估计将具有高方差;但如果训练集太小,那么性能估计将具有高偏差。常用的分割方法是使用三分之二的数据进行训练,剩余的三分之一作为测试集,但这取决于数据中的案例数量等因素。

创建保留集重采样描述

在 mlr 中应用任何 CV 的第一步是创建一个重采样描述,这仅仅是一组关于如何将数据分割为测试集和训练集的指令。makeResampleDesc()函数的第一个参数是我们将要使用的 CV 方法:在这种情况下,"Holdout"。对于保留集交叉验证,我们需要告诉函数将使用多少比例的数据作为训练集,因此我们将其提供给split参数:

holdout <- makeResampleDesc(method = "Holdout", split = 2/3,
                            stratify = TRUE)

我包括了一个额外的、可选的参数,stratify = TRUE。它要求函数在将数据分割为训练集和测试集时,尝试保持每个集合中每个患者类别的比例。这在我们的分类问题中很重要,因为组别非常不平衡(我们拥有的健康患者比其他两组的总和还多),否则我们可能会得到一个包含我们较小类别中非常少数量的测试集。

进行保留集交叉验证

现在我们已经定义了如何交叉验证我们的学习器,我们可以使用resample()函数运行 CV。我们将我们创建的学习者和任务以及我们刚才定义的重采样方法提供给resample()函数。我们还要求它给我们提供 MMCE 和准确度的度量:

holdoutCV <- resample(learner = knn, task = diabetesTask,
                      resampling = holdout, measures = list(mmce, acc))

当您运行resample()函数时,它会打印性能指标,但您可以通过从resampling对象中提取$aggr组件来访问它们:

holdoutCV$aggr

mmce.test.mean  acc.test.mean
     0.1020408      0.8979592

您会注意到两点:

  • 通过保留集交叉验证估计的模型准确度低于我们在使用训练完整模型的数据上评估其性能时的准确度。这证明了我之前提到的观点,即模型在训练它们的数据上表现会比在未见过的数据上更好。

  • 你的性能指标可能和我的不同。实际上,反复运行 resample() 函数,你每次都会得到一个非常不同的结果!这种 方差 的原因是数据被随机分割成测试集和训练集。有时分割使得模型在测试集上表现良好;有时分割使得模型表现不佳。

练习 2

使用 makeResampleDesc() 函数创建另一个保留样本重采样描述,该描述使用 10%的数据作为测试集,并且不使用分层抽样(不要覆盖你现有的重采样描述)。

计算混淆矩阵

为了更好地了解哪些组被正确分类,哪些组被错误分类,我们可以构建一个混淆矩阵。一个 混淆矩阵 简单地是测试集中每个案例的真实和预测类别的表格表示。

使用 mlr,我们可以通过 calculateConfusionMatrix() 函数计算混淆矩阵。第一个参数是我们 holdoutCV 对象的 $pred 组件,它包含测试集的真实和预测类别。可选参数 relative 请求函数显示真实和预测类别标签中每个类别的比例:

calculateConfusionMatrix(holdoutCV$pred, relative = TRUE)

Relative confusion matrix (normalized by row/column):
          predicted
true       Chemical  Normal    Overt     -err.-
  Chemical 0.92/0.73 0.08/0.04 0.00/0.00 0.08
  Normal   0.12/0.20 0.88/0.96 0.00/0.00 0.12
  Overt    0.09/0.07 0.00/0.00 0.91/1.00 0.09
  -err.-        0.27      0.04      0.00 0.10

Absolute confusion matrix:
          predicted
true       Chemical Normal Overt -err.-
  Chemical       11      1     0      1
  Normal          3     23     0      3
  Overt           1      0    10      1
  -err.-          4      1     0      5

绝对混淆矩阵更容易解释。行显示真实的类别标签,列显示预测的标签。数字代表真实类别和预测类别组合中每种情况的数量。例如,在这个矩阵中,11 名患者被正确分类为化学性糖尿病患者,但其中一名被错误地分类为健康人。正确分类的患者位于矩阵的对角线上(即真实类别等于预测类别)。

相对混淆矩阵看起来有点吓人,但原理是相同的。这次,我们不是每个真实类别和预测类别组合的案例数量,而是比例。/ 前面的数字是这一列中该行的比例,/ 后面的数字是该行中这一列的比例。例如,在这个矩阵中,92%的化学性糖尿病患者被正确分类,而 8%被错误地分类为健康人。(你注意到这些比例和我在绝对混淆矩阵中使用的数字是一样的吗?)

混淆矩阵帮助我们了解模型在哪些类别上分类得很好,在哪些类别上分类得较差。例如,根据这个混淆矩阵,看起来我们的模型在区分健康患者和化学性糖尿病患者方面有困难。

注意

你的混淆矩阵看起来和我的不一样吗?当然不一样了!混淆矩阵是基于对测试集的预测结果;由于测试集是在保留样本交叉验证中随机选择的,因此每次重新运行交叉验证时,混淆矩阵都会发生变化。

由于保留法交叉验证报告的性能指标严重依赖于我们用作训练集和测试集的数据量,因此我尽量避免使用它,除非我的模型训练成本非常高,所以我通常更喜欢 k-fold 交叉验证。这种方法唯一的真正好处是,它比其他形式的交叉验证计算成本更低。这可以使它成为计算成本高昂的算法的唯一可行交叉验证方法。但交叉验证的目的是尽可能准确地估计模型性能,而保留法交叉验证可能会在每次应用时给出非常不同的结果,因为并非所有数据都用于训练集和测试集。这就是其他形式的交叉验证发挥作用的地方。

3.5.2. K-fold 交叉验证

在 k-fold 交叉验证中,我们将数据随机分成大约等大小的块,称为折叠。然后我们保留其中一个折叠作为测试集,并使用剩余的数据作为训练集(就像保留法一样)。我们将测试集通过模型,并记录相关的性能指标。现在,我们使用数据的不同折叠作为我们的测试集,并做同样的事情。我们继续这样做,直到所有折叠都至少被用作一次测试集。然后我们得到性能指标的平均值,作为模型性能的估计。您可以在图 3.13 中看到 k-fold 交叉验证的方案。

图 3.13. K-fold 交叉验证。数据被随机分成几乎等大小的块。每个块被用作测试集一次,其余数据用作训练集。使用测试集的真实值来评估模型性能的相似性。

备注

重要的是要注意,在这个程序中,数据中的每个案例在测试集中只出现一次。

这种方法通常会给出模型性能的更准确估计,因为每个案例在测试集中只出现一次,并且我们在多次运行中平均估计。但我们可以通过使用重复的 k-fold 交叉验证来稍微改进这一点,在这种方法中,在之前的程序之后,我们重新排列数据并再次执行。

例如,对于 k-fold 交叉验证,通常选择的 k 值是 10。同样,这取决于数据的大小等因素,但对于许多数据集来说,这是一个合理的值。这意味着我们将数据分成 10 个几乎等大小的块,并执行交叉验证。如果我们重复这个程序 5 次,那么我们就有 10 次交叉验证重复了 5 次(这不同于 50 次交叉验证),模型性能的估计将是 50 次不同运行的平均值。

因此,如果您有计算能力,通常更倾向于使用重复的 k-fold 交叉验证而不是普通的 k-fold 交叉验证。这正是本书许多示例中将使用的方法。

执行 k-fold 交叉验证

我们以与保留法相同的方式进行 k 折交叉验证。这次,当我们创建重采样描述时,我们告诉它我们将使用重复的 k 折交叉验证("RepCV"),并告诉它我们想要将数据分成多少折。默认折数是 10,这通常是一个不错的选择,但我想向你展示如何显式地控制分割。接下来,我们告诉函数我们想要使用 reps 参数重复 10 折交叉验证 50 次。这给我们提供了 500 个性能指标来平均!再次,我们要求在折之间分层分配类别:

kFold <- makeResampleDesc(method = "RepCV", folds = 10, reps = 50,
                          stratify = TRUE)

kFoldCV <- resample(learner = knn, task = diabetesTask,
                    resampling = kFold, measures = list(mmce, acc))

现在,让我们提取平均性能指标:

kFoldCV$aggr

mmce.test.mean  acc.test.mean
     0.1022788      0.8977212

该模型平均正确分类了 89.8%的案例——这比我们预测用于训练模型的那些数据时低得多!多次重新运行 resample() 函数,并比较每次运行后的平均准确率。估计值比我们重复保留法交叉验证时的估计值更稳定。

提示

我们通常只对平均性能指标感兴趣,但你可以通过运行 kFoldCV$measures.test 来访问每次迭代的性能指标。

选择重复次数

当交叉验证模型时,你的目标是尽可能准确地估计模型性能。一般来说,你能够进行的重复越多,这些估计的准确性和稳定性就越高。然而,在某个点上,增加重复次数不会提高性能估计的准确性和稳定性。

那么,你如何决定执行多少次重复?一个合理的方法是选择一个计算上合理的重复次数,运行几次过程,看看平均性能估计是否变化很大。如果没有,那就很好。如果变化很大,你应该增加重复次数。

练习 3

定义两个新的重采样描述:一个执行 5 次重复的 3 折交叉验证,另一个执行 500 次重复的 3 折交叉验证(不要覆盖你现有的描述)。使用 resample() 函数使用这两种重采样描述对 kNN 算法进行交叉验证。每种方法重复重采样 5 次,看看哪一个给出更稳定的结果。

计算混淆矩阵

现在,让我们基于重复的 k 折交叉验证构建混淆矩阵:

calculateConfusionMatrix(kFoldCV$pred, relative = TRUE)

Relative confusion matrix (normalized by row/column):
          predicted
true       Chemical  Normal    Overt     -err.-
  Chemical 0.81/0.78 0.10/0.05 0.09/0.10 0.19
  Normal   0.04/0.07 0.96/0.95 0.00/0.00 0.04
  Overt    0.16/0.14 0.00/0.00 0.84/0.90 0.16
  -err.-        0.22      0.05      0.10 0.10

Absolute confusion matrix:
          predicted
true       Chemical Normal Overt -err.-
  Chemical     1463    179   158    337
  Normal        136   3664     0    136
  Overt         269      0  1381    269
  -err.-        405    179   158    742
注意

注意案例数量大得多。这是因为我们重复了 50 次该过程。

3.5.3. 留一法交叉验证

留一法交叉验证可以被视为 k 折交叉验证的极端形式:我们不是将数据分成折,而是保留一个观测值作为测试案例,在其余所有数据上训练模型,然后通过它传递测试案例并记录相关的性能指标。接下来,我们做同样的事情,但选择不同的观测值作为测试案例。我们继续这样做,直到每个观测值都作为测试案例使用过一次,我们取性能指标的平均值。你可以在图 3.14 中看到留一法交叉验证的方案。

由于测试集只有一个观测值,留一法交叉验证往往会对模型性能给出相当可变估计(因为每个迭代的性能估计都取决于正确标记那个单个测试案例)。但是,当你的数据集较小时,它给出的模型性能估计比 k 折交叉验证更稳定。当你有一个小数据集时,将其分成k折会让你剩下一个非常小的训练集。在小数据集上训练的模型的方差往往较高,因为它会受到抽样误差/异常情况的影响更大。因此,留一法交叉验证对于将数据集分成k折会给出可变结果的小数据集是有用的。它也比重复的 k 折交叉验证计算成本更低。

图 3.14。留一法交叉验证是 k 折交叉验证的极端形式,我们保留一个案例作为测试集,在剩余数据上训练模型。使用测试集的真实值与预测值的相似性来评估模型性能。

注意

一个未经交叉验证的监督学习模型几乎毫无用处,因为你不知道它在新的数据上做出的预测是否准确。

执行留一法交叉验证

创建留一法重采样的描述与留出法和 k 折交叉验证一样简单。我们在创建重采样描述时指定留一法交叉验证,通过将LOO作为方法的参数。因为测试集只有一个案例,所以我们显然不能使用留一法进行分层。另外,因为每个案例都作为测试集使用一次,其余所有数据作为训练集,所以没有必要重复该过程:

LOO <- makeResampleDesc(method = "LOO")

练习 4

尝试创建两个新的留一法重采样描述:一个使用分层抽样,另一个重复该过程五次。会发生什么?

现在,让我们运行交叉验证并获取平均性能指标:

LOOCV <- resample(learner = knn, task = diabetesTask, resampling = LOO,
                  measures = list(mmce, acc))

LOOCV$aggr

mmce.test.mean  acc.test.mean
     0.1172414      0.8827586

如果你反复运行交叉验证,你会发现对于这个模型和数据,性能估计的变异性比 k 折交叉验证更大,但比我们之前运行的留出法更小。

计算混淆矩阵

再次,让我们看看混淆矩阵:

calculateConfusionMatrix(LOOCV$pred, relative = TRUE)

Relative confusion matrix (normalized by row/column):
          predicted
true       Chemical  Normal    Overt     -err.-
  Chemical 0.81/0.74 0.14/0.06 0.06/0.07 0.19
  Normal   0.05/0.10 0.95/0.94 0.00/0.00 0.05
  Overt    0.18/0.15 0.00/0.00 0.82/0.93 0.18
  -err.-        0.26      0.06      0.07 0.12

Absolute confusion matrix:
          predicted
true       Chemical Normal Overt -err.-
  Chemical       29      5     2      7
  Normal          4     72     0      4
  Overt           6      0    27      6
  -err.-         10      5     2     17

因此,你现在知道了如何应用三种常用的交叉验证类型!如果我们已经交叉验证了我们的模型,并且满意它在未见过的数据上表现良好,那么我们就会在所有可用的数据上训练模型,并使用它来做出未来的预测。

但是我认为我们仍然可以改进我们的 kNN 模型。记得之前我们手动选择 k 的值为 2 吗?好吧,随机选择一个 k 的值并不聪明,而且我们有更好的方法来找到最佳值。

3.6. 算法可以学习的内容以及它们必须被告知的内容:参数和超参数

机器学习模型通常与它们相关的 参数 有关。参数是从数据中估计的变量或值,它是模型内部的,并控制模型如何对新数据进行预测。模型参数的一个例子是回归线的斜率。

在 kNN 算法中,k 不是一个参数,因为算法不会从数据中估计它(实际上,kNN 算法实际上并没有学习任何参数)。相反,k 是所谓的 超参数:一个控制模型如何进行预测的变量或选项,但它 不是 从数据中估计的。作为数据科学家,我们不需要向我们的模型提供参数;我们只需提供数据,算法会自己学习参数。然而,我们确实需要提供它们所需的任何超参数。你会在本书中看到,不同的算法需要并使用不同的超参数来控制它们如何学习模型。

因此,由于 k 是 kNN 算法的超参数,它不能由算法本身估计,而需要我们选择一个值。我们如何决定呢?好吧,有三种方法你可以选择 k,或者实际上任何超参数:

  • 选择一个“合理”或默认值,这个值在之前类似的问题上已经有效。 这个选项是个坏主意。你无法知道你选择的 k 值是否是最佳的。仅仅因为某个值在其他数据集上有效,并不意味着它会在当前数据集上表现良好。这是懒惰的数据科学家选择,他们不太关心从数据中获得最大价值。

  • 手动尝试几个不同的值,看看哪个给你最好的性能。 这个选项稍微好一些。这里的想法是,你选择几个合理的 k 值,为每个值构建一个模型,并查看哪个模型表现最好。这更好,因为你更有可能找到最佳性能的 k 值;但你仍然不能保证找到它,而且手动做这件事可能会很繁琐且缓慢。这是关心但不太清楚自己在做什么的数据科学家的选择。

  • 使用一种称为超参数调优的流程来自动化选择过程。 这种解决方案是最好的。它最大化了你找到最佳性能的 k 值的可能性,同时为你自动化了这一过程。这是我们将在整本书中使用的这种方法。

注意

虽然在可能的情况下,第三个选项通常是最好的,但有些算法计算成本非常高,以至于它们禁止进行广泛的超参数调整,在这种情况下,你可能不得不手动尝试不同的值。

但改变 k 的值会如何影响模型性能呢?嗯,k 的值太低可能会导致开始模拟数据中的噪声。例如,如果我们设置 k = 1,那么一个健康的病人可能会被错误地分类为化学性糖尿病患者,仅仅是因为他们最近的邻居是一个胰岛素水平异常低的化学性糖尿病患者。在这种情况下,我们不仅模拟了类之间的系统性差异,还在模拟数据中的噪声和不可预测的变异性。

另一方面,如果我们设置 k 太高,大量的不同病人将被包括在投票中,模型将不会对数据的局部差异敏感。这当然是我们在前面讨论过的偏差-方差权衡。

3.7. 调整 k 以改进模型

让我们应用超参数调优来优化模型中 k 的值。我们可以采取的一种方法是用不同的 k 值构建模型,使用我们的完整数据集,然后将数据再次通过模型,看看哪个 k 值能给出最佳性能。这是不好的做法,因为我们有很大可能会得到一个过度拟合我们所调优的数据集的 k 值。所以,我们又依靠交叉验证(CV)来帮助我们防止过拟合。

我们需要做的第一件事是定义一个范围,mlr 将在调整 k 时尝试这个范围:

knnParamSpace <- makeParamSet(makeDiscreteParam("k", values = 1:10))

makeDiscreteParam() 函数位于 makeParamSet() 函数内部,允许我们指定我们将要调整的超参数是 k,并且我们想要在 1 和 10 之间搜索最佳的 k 值。正如其名称所暗示的,makeDiscreteParam() 用于定义离散的超参数值,例如 kNN 中的 k,但书中还会探索定义连续和逻辑超参数的函数。makeParamSet() 函数定义了我们定义的超参数空间作为参数集,如果我们想在调整过程中调整多个超参数,我们只需在这个函数内部用逗号简单地分隔它们。

接下来,我们定义 mlr 如何搜索参数空间。这里有几种选择,在后面的章节中我们将探讨其他方法,但现在是使用网格搜索方法。这可能是最简单的方法:在寻找最佳性能值时,它会尝试参数空间中的每一个值。对于调整连续超参数,或者当我们同时调整多个超参数时,网格搜索变得过于昂贵,因此更倾向于使用像随机搜索这样的其他方法:

gridSearch <- makeTuneControlGrid()

接下来,我们定义我们将如何交叉验证调整过程,我将使用我最喜欢的:重复 k 折交叉验证。这里的原理是,对于参数空间中的每一个值(整数 1 到 10),我们执行重复 k 折交叉验证。对于k的每一个值,我们取所有这些迭代中的平均性能度量,并将其与所有其他k值的平均性能度量进行比较。这可能会给我们提供性能最佳的k值:

cvForTuning <- makeResampleDesc("RepCV", folds = 10, reps = 20)

现在,我们调用tuneParams()函数来执行调整:

tunedK <- tuneParams("classif.knn", task = diabetesTask,
                     resampling = cvForTuning,
                     par.set = knnParamSpace, control = gridSearch)

第一个和第二个参数分别是我们要应用的算法和任务的名称。我们将我们的 CV 策略作为resampling参数,我们将定义的超参数空间作为par.set参数,将搜索过程作为control参数。

如果我们调用我们的tunedK对象,我们得到性能最佳的k值,7,以及该值的平均 MMCE 值。我们可以直接通过选择$x组件来访问性能最佳的k值:

tunedK

Tune result:
Op. pars: k=7
mmce.test.mean=0.0769524

tunedK$x
$k
[1] 7

我们还可以可视化调整过程(此代码的结果显示在图 3.15 中):

knnTuningData <- generateHyperParsEffectData(tunedK)

plotHyperParsEffect(knnTuningData, x = "k", y = "mmce.test.mean",
                    plot.type = "line") +
  theme_bw()

现在,我们可以使用调整后的k值来训练我们的最终模型:

tunedKnn <- setHyperPars(makeLearner("classif.knn"),
                         par.vals = tunedK$x)

tunedKnnModel <- train(tunedKnn, diabetesTask)
图 3.15. 在网格搜索过程中,使用不同值的k拟合 kNN 模型时的 MMCE 值

这就像将makeLearner()函数(我们创建一个新的 kNN 学习器)包裹在setHyperPars()函数中一样简单,并提供调整后的k值作为par.vals参数。然后我们使用train()函数像以前一样训练我们的最终模型。

3.7.1. 将超参数调整纳入交叉验证

现在,当我们对我们的数据或模型进行某种预处理时,例如调整超参数,将这个预处理包括在我们的 CV 中是很重要的,这样我们就可以交叉验证整个模型训练过程。这采取的形式是嵌套 CV,其中内部循环交叉验证我们的超参数的不同值(就像我们之前做的那样),然后获胜的超参数值被传递到外部 CV 循环。在外部 CV 循环中,获胜的超参数用于每个折。

嵌套 CV 的过程如下:

  1. 将数据分为训练集和测试集(这可以通过保留、k 折或留一法来完成)。这种划分称为外部循环

  2. 使用训练集交叉验证我们超参数搜索空间中的每个值(使用我们决定的方法)。这被称为“内层循环”。

  3. 来自每个内层循环的最佳交叉验证性能超参数被传递到外层循环。

  4. 在外层循环的每个训练集上训练一个模型,使用其内层循环中的最佳超参数。这些模型用于对其测试集进行预测。

  5. 这些模型在外层循环中的平均性能指标被报告为对模型在未见数据上表现的估计。

如果你更喜欢图形化的解释,请查看图 3.16。

图 3.16。嵌套交叉验证。数据集被分成几个折。对于每个折,训练集被用来创建内层 k 折交叉验证的集合。这些内层集合通过将数据分成训练集和测试集来交叉验证单个超参数值。对于这些内层集合中的每个折,使用训练集训练一个模型,并使用该集合的超参数值在该测试集上进行评估。来自每个内层交叉验证循环的最佳性能超参数被用来在外层循环中训练模型。

图 3-16

在图 3.16 的例子中,外层循环是 3 折交叉验证。对于每个折,应用 4 折交叉验证的内层集合,仅使用外层循环的训练集。这个 4 折交叉验证用于评估我们正在搜索的每个超参数值的性能。k(给出最佳性能的值)的获胜值随后传递到外层循环,然后用于训练模型,并在测试集上评估其性能。你能看到我们正在交叉验证整个模型构建过程,包括超参数调整吗?

这有什么目的呢?它验证了我们的整个模型构建过程,包括超参数调整步骤。从这个过程中我们得到的交叉验证性能估计应该很好地代表我们期望模型在完全新的、未见数据上的表现。

这个过程看起来相当复杂,但使用 mlr 执行起来却非常简单。首先,我们定义我们将如何执行内层和外层交叉验证:

inner <- makeResampleDesc("CV")

outer <- makeResampleDesc("RepCV", folds = 10, reps = 5)

我选择在外层循环中执行普通 k 折交叉验证(默认折数为 10)和 10 折 CV,重复 5 次。

接下来,我们创建一个所谓的“包装器”,这基本上是一个与某些预处理步骤相关联的学习器。在我们的例子中,这是超参数调整,因此我们使用makeTuneWrapper()创建一个调整包装器:

knnWrapper <- makeTuneWrapper("classif.knn", resampling = inner,
                              par.set = knnParamSpace,
                              control = gridSearch)

在这里,我们将算法作为第一个参数传递,并将我们的内部 CV 过程作为resampling参数。我们将超参数搜索空间作为par.set参数,并将我们的gridSearch方法作为control参数(记住我们之前创建了这两个对象)。这“包装”了学习算法和将在内部 CV 循环中应用的超参数调整过程。

现在我们已经定义了我们的内部和外部 CV 策略以及我们的调整包装器,我们运行嵌套 CV 过程:

cvWithTuning <- resample(knnWrapper, diabetesTask, resampling = outer)

第一个参数是我们刚才创建的包装器,第二个参数是任务的名称,我们提供我们的外部 CV 策略作为重采样参数。现在请放松并休息——这可能需要一段时间!

一旦完成,你可以打印平均 MMCE:

cvWithTuning

Resample Result
Task: diabetesTib
Learner: classif.knn.tuned
Aggr perf: mmce.test.mean=0.0856190
Runtime: 42.9978

由于验证过程的随机性,你的 MMCE 值可能与我有所不同,但模型估计在未见过的数据上正确分类了 91.4%的案例。这还不错;现在我们已经正确地交叉验证了我们的模型,我们可以自信地认为我们没有过度拟合数据。

3.7.2. 使用我们的模型进行预测

我们有了我们的模型,我们可以自由地使用它来对新患者进行分类!让我们想象一些新患者来到诊所:

newDiabetesPatients <- tibble(glucose = c(82, 108, 300),
                              insulin = c(361, 288, 1052),
                              sspg = c(200, 186, 135))

newDiabetesPatients

# A tibble: 3 x 3
  glucose insulin  sspg
    <dbl>   <dbl> <dbl>
1      82     361   200
2     108     288   186
3     300    1052   135

我们可以将这些患者输入我们的模型,并获取他们的预测糖尿病状态:

newPatientsPred <- predict(tunedKnnModel, newdata = newDiabetesPatients)

getPredictionResponse(newPatientsPred)

[1] Normal Normal Overt
Levels: Chemical Normal Overt

恭喜!你不仅构建了你的第一个机器学习模型,我们还覆盖了一些相当复杂的理论。在下一章中,我们将学习逻辑回归,但首先我想列出 k-最近邻算法的优缺点。

3.8. kNN 的优缺点

虽然通常很难判断哪些算法会对给定的任务表现良好,但以下是一些优势和弱点,这将帮助你决定 kNN 是否适合你的任务。

kNN 算法的优势如下:

  • 算法非常简单易懂。

  • 在学习过程中没有计算成本;所有计算都是在预测过程中完成的。

  • 它对数据没有任何假设,例如数据的分布情况。

kNN 算法的弱点如下:

  • 它不能原生地处理分类变量(它们必须首先重新编码,或者必须使用不同的距离度量)。

  • 当训练集很大时,计算新数据与训练集中所有案例之间的距离可能会很昂贵。

  • 模型不能从数据中的现实世界关系来解释。

  • 预测准确性可能会受到噪声数据和异常值的影响。

  • 在高维数据集中,kNN 往往表现不佳。这是由于你将在第五章中了解到的一种现象,称为“维度诅咒”。简而言之,在高维中,案例之间的距离开始看起来相同,因此找到最近邻变得困难。

练习 5

使用data()函数加载鸢尾花数据集,并构建一个 kNN 模型来对它的三种鸢尾花种类进行分类(包括调整k超参数)。

| |

练习 6

使用嵌套交叉验证来交叉验证这个鸢尾花 kNN 模型,其中外部交叉验证采用三分之二的比例进行保留。

| |

练习 7

重复前一个练习中的嵌套 CV,但使用 5 折、非重复 CV 作为外部循环。当你重复这些方法时,哪种方法给你更稳定的 MMCE 估计?

摘要

  • kNN 是一种简单的监督学习算法,它根据训练集中最近的k个案例的类别成员资格对新数据进行分类。

  • 要在 mlr 中创建机器学习模型,我们创建一个任务和一个学习器,然后使用它们来训练模型。

  • MMCE 是平均误分类误差,即在分类问题中误分类案例的比例。它与准确度相反。

  • 偏差-方差权衡是预测准确度中两种类型错误的平衡。具有高偏差的模型欠拟合,而具有高方差模型的模型过拟合。

  • 模型性能不应在用于训练的数据上评估;应使用交叉验证。

  • 交叉验证是一组通过将数据分为训练集和测试集来评估模型性能的技术。

  • 三种常见的交叉验证类型是保留法,其中使用单个分割;k 折法,其中数据被分割成k个块,并在每个块上执行验证;以及留一法,其中测试集是一个单独的案例。

  • 超参数是控制机器学习算法如何学习的选项,这些选项不能由算法本身学习。超参数调整是找到最佳超参数的最佳方式。

  • 如果我们执行一个数据依赖的前处理步骤,例如超参数调整,那么将其纳入我们的交叉验证策略中,使用嵌套交叉验证是很重要的。

练习的解答

  1. 葡萄糖胰岛素变量相互绘制,使用形状表示类别变量,然后使用形状颜色:

    ggplot(diabetesTib, aes(glucose, insulin,
                            shape = class)) +
      geom_point()  +
      theme_bw()
    
    ggplot(diabetesTib, aes(glucose, insulin,
                            shape = class, col = class)) +
      geom_point()  +
      theme_bw()
    
  2. 创建一个使用 10%的案例作为测试集且不使用分层抽样的留一法重采样描述:

    holdoutNoStrat <- makeResampleDesc(method = "Holdout", split = 0.9,
                                stratify = FALSE)
    
  3. 比较重复 5 次或 500 次的 3 折交叉验证的性能估计的稳定性:

    kFold500 <- makeResampleDesc(method = "RepCV", folds = 3, reps = 500,
                              stratify = TRUE)
    
    kFoldCV500 <- resample(learner = knn, task = diabetesTask,
                        resampling = kFold500, measures = list(mmce, acc))
    
    kFold5 <- makeResampleDesc(method = "RepCV", folds = 3, reps = 5,
                                 stratify = TRUE)
    
    kFoldCV5 <- resample(learner = knn, task = diabetesTask,
                           resampling = kFold5, measures = list(mmce, acc))
    
    kFoldCV500$aggr
    kFoldCV5$aggr
    
  4. 尝试制作使用分层抽样和重复抽样的留一法重采样描述:

    makeResampleDesc(method = "LOO", stratify = TRUE)
    
    makeResampleDesc(method = "LOO", reps = 5)
    
    # Both will result in an error as LOO cross-validation cannot
    # be stratified or repeated.
    
  5. 加载鸢尾花数据集,并构建一个 kNN 模型来对它的三种鸢尾花种类进行分类(包括调整k超参数):

    data(iris)
    
    irisTask <- makeClassifTask(data = iris, target = "Species")
    
    knnParamSpace <- makeParamSet(makeDiscreteParam("k", values = 1:25))
    
    gridSearch <- makeTuneControlGrid()
    
    cvForTuning <- makeResampleDesc("RepCV", folds = 10, reps = 20)
    
    tunedK <- tuneParams("classif.knn", task = irisTask,
                         resampling = cvForTuning,
                         par.set = knnParamSpace,
                         control = gridSearch)
    
    tunedK
    
    tunedK$x
    
    knnTuningData <- generateHyperParsEffectData(tunedK)
    
    plotHyperParsEffect(knnTuningData, x = "k", y = "mmce.test.mean",
                        plot.type = "line") +
                        theme_bw()
    
    tunedKnn <- setHyperPars(makeLearner("classif.knn"), par.vals = tunedK$x)
    
    tunedKnnModel <- train(tunedKnn, irisTask)
    
  6. 使用嵌套交叉验证交叉验证这个鸢尾花 kNN 模型,其中外部交叉验证采用三分之二的比例进行保留:

    inner <- makeResampleDesc("CV")
    
    outerHoldout <- makeResampleDesc("Holdout", split = 2/3, stratify = TRUE)
    
    knnWrapper <- makeTuneWrapper("classif.knn", resampling = inner,
                                  par.set = knnParamSpace,
                                  control = gridSearch)
    
    holdoutCVWithTuning <- resample(knnWrapper, irisTask,
                                    resampling = outerHoldout)
    
    holdoutCVWithTuning
    
  7. 使用 5 折、非重复交叉验证作为外部循环重复嵌套交叉验证。当你重复这些方法时,哪种方法给你更稳定的 MMCE 估计?

    outerKfold <- makeResampleDesc("CV", iters = 5, stratify = TRUE)
    
    kFoldCVWithTuning <- resample(knnWrapper, irisTask,
                                  resampling = outerKfold)
    
    kFoldCVWithTuning
    
    resample(knnWrapper, irisTask, resampling = outerKfold)
    
    # Repeat each validation procedure 10 times and save the mmce value.
    # WARNING: this may take a few minutes to complete.
    
    kSamples <- map_dbl(1:10, ~resample(
      knnWrapper, irisTask, resampling = outerKfold)$aggr
      )
    
    hSamples <- map_dbl(1:10, ~resample(
      knnWrapper, irisTask, resampling = outerHoldout)$aggr
      )
    
    hist(kSamples, xlim = c(0, 0.11))
    hist(hSamples, xlim = c(0, 0.11))
    
    # Holdout CV gives more variable estimates of model performance.
    

第四章. 基于概率的逻辑回归进行分类

本章涵盖

  • 与逻辑回归算法一起工作

  • 理解特征工程

  • 理解缺失值插补

在本章中,我将向你工具箱中添加一个新的分类算法:逻辑回归。就像你在上一章中学到的 k-最近邻算法一样,逻辑回归是一种监督学习方法,它预测类成员资格。逻辑回归依赖于直线的方程,并产生易于解释和传达的模型。

逻辑回归可以处理连续(没有离散类别)和分类(有离散类别)的预测变量。在其最简单形式中,逻辑回归用于预测二元结果(案例可以属于两个类别之一),但算法的变体也可以处理多个类别。它的名字来源于算法使用逻辑函数,这是一个计算案例属于某一类别的概率的方程。

虽然逻辑回归确实是一种分类算法,但它使用线性回归和直线的方程来结合多个预测器的信息。在本章中,你将学习逻辑函数是如何工作的,以及直线的方程是如何用来构建模型的。

注意

如果你已经熟悉线性回归,线性回归和逻辑回归之间的一个关键区别是,前者学习预测变量与一个连续的输出变量之间的关系,而后者学习预测变量与一个分类的输出变量之间的关系。

在本章结束时,你将应用在第二章和第三章中学到的技能来准备你的数据,并构建、解释和评估逻辑回归模型的表现。你还将了解什么是缺失值插补,这是一种在处理无法处理缺失值的算法时,用合理的值填充缺失数据的方法。你将应用缺失值插补的基本形式作为处理缺失数据的一种策略。

4.1. 什么是逻辑回归?

想象一下,你是博物馆中 15 世纪艺术品的馆长。当据说由著名画家创作的艺术品来到博物馆时,你的任务是确定它们是真迹还是赝品(一个二分类问题)。你可以访问对每幅画进行的化学分析,并且你知道这个时期的许多赝品使用的颜料铜含量低于原作。你可以使用逻辑回归来学习一个模型,告诉你根据画中颜料的铜含量判断画作为真迹的概率。然后,该模型将把这幅画分配给概率最高的类别(见图 4.1)。

图 4.1. 逻辑回归学习模型,输出新数据属于每个类别的概率(p)。通常,新数据被分配给它最有可能属于的类别。虚线箭头表示在计算概率时还有额外的步骤,我们将在 4.1.1 节中讨论。

注意

该算法通常应用于二分类问题(这被称为二项式逻辑回归),但一种称为多项式逻辑回归的变体可以处理有三个或更多类别的分类问题。

逻辑回归是一个非常流行的分类算法,在医学界尤其受欢迎,部分原因是因为模型的解释性。在我们的模型中,对于每个预测变量,我们都会得到一个估计值,说明该变量的值是如何影响案例属于某一类别而不是另一类别的概率的。

我们知道逻辑回归学习模型,这些模型估计新案例属于每个类别的概率。让我们深入了解算法是如何学习模型的。

4.1.1. 逻辑回归是如何学习的?

看一下图 4.2 中的(想象中的)数据。我绘制了我们已知是真实或伪造的样本画作中的铜含量,将其类别作为 0 到 1 之间的连续变量。我们可以看到,平均而言,伪造品在其油漆中的铜含量比原作少。我们可以用直线来模拟这种关系,如图所示。当你的预测变量与你要预测的连续变量有线性关系时,这种方法效果很好(我们将在第九章中介绍);但正如你所见,它并不能很好地模拟连续变量与类别变量之间的关系。

图 4.2. 将铜含量与类别进行绘图。y 轴显示类别成员资格,仿佛它是一个连续变量,伪造品和原作分别取值为 0 和 1。实线代表尝试用线性关系来模拟铜含量与类别之间的关系。y = 0.5 处的虚线表示分类的阈值。

如图中所示,我们可以找到铜含量,使得直线穿过 0 和 1 之间的中点,并将铜含量低于此值的画作归类为伪造品,高于此值的画作归类为原作。这可能会导致许多误分类,因此需要更好的方法。

我们可以使用逻辑函数更好地建模铜含量与类别成员之间的关系,如图 4.3 所示。逻辑函数是一个 S 形曲线,将连续变量(在我们的例子中是铜含量)映射到 0 和 1 之间的值。这比用直线表示铜含量与画作是否为原作之间的关系要好得多。该图显示了与图 4.2 相同的逻辑函数拟合数据。我们可以找到逻辑函数在 0 和 1 之间通过一半值时的铜含量,并将铜含量低于此值的画作归类为伪造品,高于此值的画作归类为原作。这通常比我们使用直线分类时产生的误分类要少。

图 4.3. 使用逻辑函数建模数据。S 形曲线表示拟合数据的逻辑函数。曲线的中心通过铜含量的平均值,并将其映射到 0 和 1 之间。

图 4.3

重要的是,由于逻辑函数将我们的x变量映射到 0 和 1 之间的值,我们可以将其输出解释为具有特定铜含量的案例是原作的概率。再看看图 4.3。你能看到随着铜含量的增加,逻辑函数趋近于 1 吗?这表示,平均而言,原作铜含量较高,因此如果你随机选择一幅画,发现其铜含量为 20,那么它有大约 0.99 或 99%的概率是原作。

注意

如果我以另一种方式编码分组变量(伪造品为 1,原作为 0),那么逻辑函数在低铜含量时趋近于 1,在高铜含量时趋近于 0。我们只需将输出解释为伪造品的概率即可。

反之亦然:随着铜含量的减少,逻辑函数趋近于 0。这表示,平均而言,伪造品铜含量较低,因此如果你随机选择一幅画,发现其铜含量为 7,那么它有大约 0.99 或 99%的概率是伪造品。

太好了!我们可以通过使用逻辑函数来估计一幅画是原作的概率。但如果我们有多个预测变量呢?由于概率被限制在 0 和 1 之间,很难结合两个预测变量的信息。例如,假设逻辑函数估计一幅画对于某个预测变量有 0.6 的概率是原作,对于另一个预测变量有 0.7 的概率。我们不能简单地将这些估计相加,因为它们会超过 1,这没有意义。

相反,我们可以将这些概率转换为它们的对数几率(逻辑回归模型的“原始”输出)。为了介绍对数几率,让我首先解释一下我所说的几率,以及几率和概率之间的区别。

画作是原作的几率

方程式 4.1。

方程式 4-1

你可能会看到它写成

方程式 4.2。

方程式 4-2

几率是表示某事发生可能性的便捷方式。它们告诉我们事件发生的可能性有多大,而不是不发生的可能性有多大。

在《帝国反击战》中,C3PO 说“成功穿越小行星带的几率大约是 3,720 比 1!”C3PO 试图告诉汉和莉娅的是,成功穿越小行星带的概率大约是不成功穿越它的概率的 3,720 倍小。仅仅陈述几率通常是一种更方便表示可能性的方式,因为我们知道,对于每成功穿越一个小行星带,就有 3,720 个没有成功穿越!此外,虽然概率介于 0 和 1 之间,但几率可以取任何正值。

注意

尽管 C3PO 是一个高度智能的礼仪机器人,但他把几率搞错了(正如许多人所做的那样)。他应该说的是成功穿越小行星带的几率大约是 1 比 3,720!

图 4.4 显示了铜含量与画作是否为原作的几率之间的关系。请注意,几率不在 0 和 1 之间有界,并且它们取正值。

然而,正如我们所看到的,油漆中的铜含量与画作是否为原作的几率之间的关系不是线性的。相反,如果我们对几率取自然对数(以 e 为底的对数,简写为ln),我们得到的是对数几率

方程式 4.3。

方程式 4-3

小贴士

方程式 4.3,它将概率转换为对数几率,也被称为logit函数。你经常会看到logit 回归逻辑回归被互换使用。

图 4.4。将原作几率与铜含量绘制在一起。从逻辑函数中导出的概率被转换为几率,并绘制在铜含量上。几率可以取任何正值。直线代表尝试将铜含量与几率之间的线性关系模型化的一次糟糕尝试。

图 4-4

我已经对图 4.3 中显示的几率取了自然对数,以生成它们的对数几率,并将这些对数几率与铜含量绘制在图 4.5 中。太棒了!我们预测变量与画作是否为原作的对数几率之间存在线性关系。此外,请注意,对数几率是完全无界的:它们可以延伸到正负无穷大。在解释对数几率时

  • 正值意味着某事发生的可能性大于不发生的可能性。

  • 负值意味着某事发生的可能性小于不发生的可能性。

  • 对数几率为 0 意味着某事发生的可能性与不发生的可能性相同。

图 4.5。绘制成为原作的对数几率与铜含量的关系图。几率通过 logit 函数转换为对数几率,并绘制在铜含量上。对数几率是无界的,可以取任何值。直线代表铜含量与对数几率之间的线性关系。

当讨论图 4.4 时,我强调了铜含量与成为原作画作几率之间的关系不是线性的。接着,我在图 4.5 中展示了铜含量与对数几率之间的关系是线性的。实际上,线性化这种关系是我们取几率自然对数的原因。为什么我对预测变量与其对数几率之间存在线性关系如此重视呢?因为建模直线是容易的。回想一下第一章,算法要学习建模直线关系只需要 y 截距和直线的斜率。因此,逻辑回归学习当铜含量为 0(y 截距)时画作成为原作的对数几率,以及随着铜含量增加对数几率如何变化(斜率)。

备注

预测变量对对数几率的影响越大,斜率就越陡峭,而没有任何预测价值的变量将具有几乎水平的斜率。

此外,具有线性关系意味着当我们有多个预测变量时,我们可以将它们的贡献相加到对数几率中,以获得基于所有预测变量的整体对数几率,即画作成为原作的对数几率。

现在,我们如何从铜含量与成为原作的对数几率之间的直线关系,过渡到对新画作进行预测呢?模型通过以下方式计算新数据成为原作画作的对数几率:

  • 对数几率 = y 截距 + 斜率 *

在我们的新画作中,我们添加了 y 截距以及斜率和铜值的乘积。一旦我们计算出新画作的对数几率,我们就使用逻辑函数将其转换为成为原作的几率:

公式 4.4。

其中 p 是概率,e 是欧拉数(一个固定常数 ~ 2.718),z 是特定情况的对数几率。

然后,简单来说,如果画作成为原作的几率大于 0.5,它就被分类为原作。如果几率小于 0.5,它就被分类为赝品。这种将对数几率转换为几率再转换为概率的过程在图 4.6 中得到了说明。

备注

默认情况下,这个阈值概率是 0.5。换句话说,如果一个案例属于阳性类的几率超过 50%,则将其分配到阳性类。然而,在需要在我们将案例分类为阳性类之前“非常”确定的情况下,我们可以改变这个阈值。例如,如果我们使用该模型来预测患者是否需要高风险手术,我们在进行手术之前一定要非常确定!

图 4.6. 总结了逻辑回归模型预测类别成员的方法。数据被转换为对数几率(logits),然后转换为几率,最后转换为属于“阳性”类的概率。如果案例的概率超过一个阈值概率(默认为 0.5),则将其分配到阳性类。

你经常会看到模型

  • 对数几率 = y 截距 + 斜率 *

重写为方程 4.5。

方程 4.5。

不要被吓到!再看一遍方程 4.5。这是统计学家表示预测直线模型的方式,它与描述对数几率的方程完全相同。逻辑回归模型通过添加 y 截距(β[0])和线的斜率(β[铜])乘以铜的值(x[铜])来预测对数几率(等号左边)。

你可能想知道:你为什么在我答应你不会的时候还给我展示方程?好吧,在大多数情况下,我们不会只有一个预测因子;我们会有很多。通过以这种方式表示模型,你可以看到它如何可以用来线性地组合多个预测因子:换句话说,通过将它们的效果相加。

假设我们还将金属铅的含量作为预测绘画是否为原作的一个指标。模型将看起来像这样:

方程 4.6。

该模型可能的样子示例显示在图 4.7 中。有两个预测变量时,我们可以将模型表示为一个平面,对数几率显示在垂直轴上。对于超过两个预测因子的情况,同样适用这个原理,但在二维表面上难以可视化。

现在,对于任何我们传递给模型的绘画,模型会执行以下操作:

  1. 将其铜含量乘以铜的斜率

  2. 将铅的含量乘以铅的斜率

  3. 将这两个值和 y 截距相加,得到该绘画为原作的对数几率

  4. 将对数几率转换为概率

  5. 如果概率大于 0.5,则将绘画分类为原作,如果概率小于 0.5,则将绘画分类为赝品

图 4.7. 可视化具有两个预测变量的逻辑回归模型。铜含量和铅含量分别绘制在 x 轴和 z 轴上。对数几率绘制在 y 轴上。图中显示的平面代表结合截距和铜含量、铅含量的斜率来预测对数几率的线性模型。

图 4-7

我们可以将模型扩展到包括我们想要的任何数量的预测变量:

方程式 4.7.

方程式 4-7

其中 k 是数据集中预测变量的数量,... 表示所有中间变量。

提示

记得在第三章,当我解释参数和超参数之间的区别时?嗯,β[0],β[1]等等是模型参数,因为它们是由算法从数据中学习的。

对新画作进行分类的整个流程总结在图 4.8 中。首先,我们使用算法学习到的线性模型将新数据的铜和铅值转换为它们的对数几率(logits)。接下来,我们使用逻辑函数将对数几率转换为概率。最后,如果概率大于 0.5,我们将画作分类为原作;如果其概率小于 0.5,我们将其分类为赝品。

图 4.8. 对新画作进行分类的过程。三幅画作的预测变量值根据学习到的模型参数(截距和斜率)转换为对数几率。对数几率转换为概率(p),如果 p > 0.5,则将案例分类为“阳性”类别。

图 4-8 替代

注意

尽管图 4.8 中的第一幅和第三幅画作都被分类为赝品,但它们的概率却非常不同。由于第三幅画作的几率远小于第一幅,我们可以更有信心地认为画作 3 是赝品,而不是我们有信心画作 1 是赝品。

4.1.2. 如果我们有超过两个类别怎么办?

之前的场景是二项逻辑回归的一个例子。换句话说,关于将哪个类别分配给新数据的决定只能采取两种命名的类别之一(分别来自拉丁语和希腊语的binomos)。但我们可以使用逻辑回归的变体来预测多个类别中的一个。这被称为多项式逻辑回归,因为现在有多个可能的类别可供选择。

在多项式逻辑回归中,模型不是为每个案例估计一个 logit,而是为每个案例的每个输出类别估计一个 logit。然后,将这些 logit 传递到一个称为softmax 函数的方程中,该函数将这些 logit 转换为每个类别的概率,这些概率之和为 1(见图 4.9)。然后,选择具有最大概率的类别作为输出类别。

图 4.9. softmax 函数的总结。在二项式情况下,每个案例只需要一个 logit(正类的 logit)。在存在多个类别的情况下(本例中的 a,b 和 c),模型为每个案例估计每个类别的 logit。softmax 函数将这些 logits 映射到总和为 1 的概率。案例被分配给概率最大的类别。

提示

你有时会看到 softmax 回归多项式逻辑回归 被互换使用。

由 mlr 包装的classif.logreg学习器只会处理二项逻辑回归。目前还没有 mlr 包装的普通多项式逻辑回归实现。然而,我们可以使用classif.LiblineaRL1LogReg学习器来执行多项式逻辑回归(尽管它有一些差异,我不会讨论)。

softmax 函数

你不需要记住 softmax 函数,所以可以自由跳过这部分,但 softmax 函数的定义如下

其中 p[a] 是一个案例属于类别 a 的概率,e 是欧拉数(一个固定常数 ~ 2.718),而 logit[a],logit[b] 和 logit[c] 分别是这个案例属于类别 a,b 和 c 的 logits。

如果你是一个数学爱好者,这可以推广到任意数量的类别,使用以下方程

其中 p[j] 是属于类别 j 的概率,而 表示从类别 1 到类别 K(总共有 K 个类别)的 e^(logits) 的和。

在 R 中编写自己的 softmax 函数实现,并尝试将其他数字向量插入其中。你会发现它总是将输入映射到所有元素之和为 1 的输出。

现在你已经知道了逻辑回归是如何工作的,你将构建你的第一个二项逻辑回归模型。

4.2. 构建你的第一个逻辑回归模型

想象一下,你是一位对 1912 年著名沉没的 RMS 泰坦尼克号 感兴趣的历史学家。你想要知道社会经济因素是否影响了一个人在灾难中幸存的概率。幸运的是,这样的社会经济数据是公开可用的!

你的目标是构建一个二项逻辑回归模型来预测乘客是否会在 泰坦尼克号 灾难中幸存,基于他们的性别和票价等数据。你还将解释模型以决定哪些变量在影响乘客幸存概率方面很重要。让我们首先加载 mlr 和 tidyverse 包:

library(mlr)
library(tidyverse)

4.2.1. 加载和探索泰坦尼克号数据集

现在让我们加载数据,该数据包含在titanic包中,将其转换为 tibble(使用as_tibble()),并对其进行初步探索。我们有一个包含 891 个案例和 12 个乘客变量的 tibble,这些乘客来自泰坦尼克号。我们的目标是训练一个模型,可以使用这些变量中的信息来预测乘客是否能在灾难中幸存。

列表 4.1. 加载和探索泰坦尼克号数据集
install.packages("titanic")

data(titanic_train, package = "titanic")

titanicTib <- as_tibble(titanic_train)

titanicTib

# A tibble: 891 x 12
   PassengerId Survived Pclass Name  Sex     Age SibSp Parch Ticket
         <int>    <int>  <int> <chr> <chr> <dbl> <int> <int> <chr>
 1           1        0      3 Brau… male     22     1     0 A/5 2…
 2           2        1      1 Cumi… fema…    38     1     0 PC 17…
 3           3        1      3 Heik… fema…    26     0     0 STON/…
 4           4        1      1 Futr… fema…    35     1     0 113803
 5           5        0      3 Alle… male     35     0     0 373450
 6           6        0      3 Mora… male     NA     0     0 330877
 7           7        0      1 McCa… male     54     0     0 17463
 8           8        0      3 Pals… male      2     3     1 349909
 9           9        1      3 John… fema…    27     0     2 347742
10          10        1      2 Nass… fema…    14     1     0 237736
# … with 881 more rows, and 3 more variables: Fare <dbl>,
#   Cabin <chr>, Embarked <chr>

tibble 包含以下变量:

  • PassengerId—每个乘客独特的任意数字

  • Survived—表示生存的整数(1 = 幸存,0 = 死亡)

  • Pclass—乘客是否被安排在头等舱、二等舱或三等舱

  • Name—乘客名字的字符向量

  • Sex—包含“男性”和“女性”的字符向量

  • Age—乘客的年龄

  • SibSp—船上兄弟姐妹和配偶的总数

  • Parch—船上父母和孩子的总数

  • Ticket—包含每个乘客票号的字符向量

  • Fare—每位乘客为他们的票支付的金额

  • Cabin—每个乘客的舱号的字符向量

  • Embarked—乘客登船的港口的字符向量

我们将要做的第一件事是使用 tidyverse 工具来清理和准备数据以供建模。

4.2.2. 充分利用数据:特征工程和特征选择

很少会有一个数据集可以直接用于建模。通常,我们需要先进行一些清理以确保我们能从数据中获得最大价值。这包括将数据转换为正确的类型、纠正错误和删除无关数据等步骤。titanicTib tibble 也不例外;在我们将其传递给逻辑回归算法之前,我们需要对其进行清理。我们将执行三个任务:

  1. SurvivedSexPclass变量转换为因子。

  2. 通过将SibSpParch相加创建一个新的变量FamSize

  3. 选择我们认为对我们模型有预测价值的变量。

如果一个变量应该是因子,那么让 R 知道它是一个因子是很重要的,这样 R 就会适当地处理它。我们可以从列表 4.1 中titanicTib的输出中看到,SurvivedPclass都是整数向量(输出中列上方显示<int>),而Sex是一个字符向量(列上方显示<chr>)。每个这些变量都应该被视为因子,因为它代表了在整个数据集中重复出现的案例之间的离散差异。

我们可以假设乘客在船上的家庭成员数量可能会影响他们的生存。例如,有众多家庭成员的人可能不愿意登上没有足够空间容纳整个家庭的救生艇。虽然SibSpParch变量分别按兄弟姐妹和配偶、父母和子女来存储这些信息,但将它们合并成一个包含整体家庭规模的单一变量可能更有信息量。

这是一个极其重要的机器学习任务,称为特征工程:修改你的数据集中的变量以提高它们的预测价值。特征工程有两种形式:

  • 特征提取— 预测信息存储在一个变量中,但以一个无用的格式。例如,假设你有一个变量,其中包含某些事件发生的年份、月份、日期和一天中的时间。一天中的时间具有重要的预测价值,但年份、月份和日期没有。为了使这个变量在你的模型中变得有用,你需要提取仅包含一天中时间信息的新变量。

  • 特征创建— 将现有变量合并以创建新的变量。将SibSpParch变量合并以创建FamSize就是一个例子。

使用特征提取和特征创建使我们能够提取数据集中存在的预测信息,但当前格式并未最大化其有用性。

最后,我们数据中的变量可能没有任何预测价值。例如,知道乘客的名字或船舱号码能帮助我们预测生存吗?可能不能,所以让我们移除它们。包括预测价值很小或没有的变量会给数据增加噪声,并会负面影响我们的模型表现,所以最好移除它们。

这又是一个极其重要的机器学习任务,称为特征选择,基本上就是它听起来那样:保留那些增加预测价值的变量,移除那些没有的。有时对我们人类来说,是否变量是有用的预测因子是显而易见的。例如,乘客姓名可能没有用,因为每个乘客都有一个不同的名字!在这些情况下,移除这样的变量是常识。然而,通常情况下,这并不那么明显,我们可以有更复杂的方法来自动化特征选择过程。我们将在后面的章节中探讨这一点。

这三个任务(转换为因子、特征工程和特征选择)都在 列表 4.2 中执行。我通过定义一个我们希望转换为因子的变量向量来简化了我们的工作,然后使用 mutate_at() 函数将它们全部转换为因子。mutate_at() 函数类似于 mutate() 函数,但它允许我们一次修改多个列。我们将现有变量作为字符向量提供给 .vars 参数,并使用 .funs 参数告诉它对这些变量要做什么。在这种情况下,我们提供了我们定义的变量向量,以及将它们转换为因子的“factor”函数。我们将这个结果通过管道传递给一个 mutate() 函数调用,该调用定义了一个新变量 FamSize,它是 SibSpParch 的总和。最后,我们将这个结果通过管道传递给一个 select() 函数调用,以选择我们相信可能对我们的模型有某些预测价值的变量。

列表 4.2. 清理 泰坦尼克号 数据,准备建模
fctrs <- c("Survived", "Sex", "Pclass")

titanicClean <- titanicTib %>%
  mutate_at(.vars = fctrs, .funs = factor) %>%
  mutate(FamSize = SibSp + Parch) %>%
  select(Survived, Pclass, Sex, Age, Fare, FamSize)

titanicClean

# A tibble: 891 x 6
   Survived Pclass Sex      Age  Fare FamSize
   <fct>    <fct>  <fct>  <dbl> <dbl>   <int>
 1 0        3      male      22  7.25       1
 2 1        1      female    38 71.3        1
 3 1        3      female    26  7.92       0
 4 1        1      female    35 53.1        1
 5 0        3      male      35  8.05       0
 6 0        3      male      NA  8.46       0
 7 0        1      male      54 51.9        0
 8 0        3      male       2 21.1        4
 9 1        3      female    27 11.1        2
10 1        2      female    14 30.1        1
# … with 881 more rows

当我们打印我们的新 tibble 时,我们可以看到 SurvivedPclassSex 现在是因子(在输出中显示为 <fct> 在其列上方);我们有我们的新变量 FamSize;并且我们移除了无关变量。

注意

我在从 tibble 中移除 Name 变量时是否过于草率?这个变量中隐藏着每位乘客的称呼(小姐、夫人、先生、少爷等),这些可能具有预测价值。使用这些信息需要进行特征提取。

4.2.3. 绘制数据

现在我们已经稍微清理了数据,让我们绘制它以更好地了解数据中的关系。这里有一个使用 ggplot2 简化绘制多个变量的小技巧。让我们将数据转换为不整洁的格式,使得每个预测变量名称在一个列中,其值在另一个列中,使用 gather() 函数(通过查看第二章末尾来刷新你的记忆 chapter 2)。

注意

gather() 函数将警告“度量变量之间的属性不相同;它们将被丢弃。”这只是一个警告,告诉你你正在收集在一起的变量没有相同的因子水平。通常这可能会意味着你意外地合并了你不打算合并的变量,但在这个情况下我们可以安全地忽略这个警告。

列表 4.3. 创建用于绘制的杂乱 tibble
titanicUntidy <- gather(titanicClean, key = "Variable", value = "Value",
                        -Survived)
titanicUntidy

# A tibble: 4,455 x 3
   Survived Variable Value
   <fct>    <chr>    <chr>
 1 0        Pclass   3
 2 1        Pclass   1
 3 1        Pclass   3
 4 1        Pclass   1
 5 0        Pclass   3
 6 0        Pclass   3
 7 0        Pclass   1
 8 0        Pclass   3
 9 1        Pclass   3
10 1        Pclass   2
# … with 4,445 more rows

我们现在有一个包含三个列的不整洁 tibble:一列包含 Survived 因子,一列包含预测变量的名称,另一列包含它们的值。

注意

注意,值列是一个字符向量 (<chr>)。这是因为它包含了来自 Sex 变量的“男性”和“女性”。由于一列只能持有单一类型的数据,所有数值数据也被转换为字符。

你可能想知道我们为什么要这样做。嗯,这使我们能够使用 ggplot2 的分面系统来一起绘制我们的不同变量。在列表 4.4 中,我选取了titanicUntidy tibble,筛选出不包含PclassSex变量(因为这些是因素,我们将单独绘制它们)的行,并将这些数据通过管道传递到ggplot()调用中。

列表 4.4. 为每个连续变量创建子图
titanicUntidy %>%
  filter(Variable != "Pclass" & Variable != "Sex") %>%
  ggplot(aes(Survived, as.numeric(Value))) +
  facet_wrap(~ Variable, scales = "free_y") +
  geom_violin(draw_quantiles = c(0.25, 0.5, 0.75)) +
  theme_bw()

ggplot()函数调用中,我们提供Survived作为 x 美学,Value作为 y 美学(通过as.numeric()将其强制转换为数值向量,因为我们的gather()函数调用之前将其转换为字符)。接下来——这是亮点——我们要求 ggplot2 按Variable列分面,使用facet_wrap()函数,并允许 y 轴在分面之间变化。分面使我们能够绘制数据的子图,这些子图由分面变量索引。最后,我们添加一个小提琴几何对象,它类似于箱线图,但也显示了 y 轴上的数据密度。结果图表显示在图 4.10 中。

图 4.10. SurvivedFamSizeFare的分面图。小提琴图显示了 y 轴上的数据密度。每个小提琴上的线条代表第一四分位数、中位数和第三四分位数(从最低到最高)。

你能看出分面是如何工作的吗?具有不同Variable值的行被绘制在不同的子图中!这就是为什么我们需要将数据收集到不整洁的格式中:这样我们就可以提供一个变量供 ggplot2 分面使用。

练习 1

重新绘制图 4.10 中的图表,但添加一个geom_point()层,将alpha参数设置为 0.05,将size参数设置为 3。这会使小提琴图更有意义吗?

现在,让我们通过筛选只包含PclassSex变量的数据行来对我们数据集中的因素做同样的事情。这次,我们想看到因素每个级别的乘客生存比例。要做到这一点,我们将因素级别放在 x 轴上,通过提供Value作为 x 美学映射;我们还想用不同的颜色来表示生存与非生存,所以我们提供Survived作为填充美学。我们像以前一样按Variable分面,并添加一个带有position = "fill"参数的条形几何对象,这样就可以堆叠生存者和非生存者的数据,使它们相加等于 1,以显示每个的比例。结果图表显示在图 4.11 中。

列表 4.5. 为每个分类变量创建子图
titanicUntidy %>%
  filter(Variable == "Pclass" | Variable == "Sex") %>%
  ggplot(aes(Value, fill = Survived)) +
  facet_wrap(~ Variable, scales = "free_x") +
  geom_bar(position = "fill") +
  theme_bw()
图 4.11. SurvivedPclassSex的分面图。填充条形表示因素每个级别的乘客生存比例(1 = 生存)。

注意

在列表 4.4 和 4.5 中的filter()函数调用中,我使用了&|运算符分别表示“和”和“或”。

因此,似乎幸存下来的乘客倾向于在船上拥有更多的家庭成员(可能与我们假设相矛盾),尽管在船上拥有非常大家庭的乘客往往不会幸存。年龄似乎对生存没有明显的影响,但女性意味着你更有可能幸存。为你的船票支付更多费用增加了你幸存的可能性,同样,处于更高等级也是如此(尽管这两个可能相关)。

练习 2

重新绘制图 4.11 中的图表,但将geom_bar()参数position设置为"dodge"。再次这样做,但将position参数设置为"stack"。你能看到三种方法之间的区别吗?

4.2.4. 训练模型

现在我们有了清洗过的数据,让我们使用 mlr 创建一个任务、学习者和模型(指定"classif.logreg"以使用逻辑回归作为我们的学习器)。通过设置参数predict.type = "prob",训练好的模型在对新数据进行预测时将输出每个类别的估计概率,而不仅仅是预测的类别成员资格。

列表 4.6. 创建任务和学习者,并训练模型
titanicTask <- makeClassifTask(data = titanicClean, target = "Survived")

logReg <- makeLearner("classif.logreg", predict.type = "prob")

logRegModel <- train(logReg, titanicTask)

Error in checkLearnerBeforeTrain(task, learner, weights) :
  Task 'titanicClean' has missing values in 'Age', but learner 'classif.logre
     g' does not support that!

哎呀!出错了。错误信息是什么?嗯,看起来我们在Age变量中有些缺失数据,逻辑回归算法不知道如何处理这种情况。让我们看看这个变量。(我仅显示前 60 个元素以节省空间,但你可以打印整个向量。)

列表 4.7. 在Age变量中计数缺失值
titanicClean$Age[1:60]
 [1] 22.0 38.0 26.0 35.0 35.0   NA 54.0  2.0 27.0 14.0  4.0 58.0 20.0
[14] 39.0 14.0 55.0  2.0   NA 31.0   NA 35.0 34.0 15.0 28.0  8.0 38.0
[27]   NA 19.0   NA   NA 40.0   NA   NA 66.0 28.0 42.0   NA 21.0 18.0
[40] 14.0 40.0 27.0   NA  3.0 19.0   NA   NA   NA   NA 18.0  7.0 21.0
[53] 49.0 29.0 65.0   NA 21.0 28.5  5.0 11.0
sum(is.na(titanicClean$Age))
[1] 177

啊,我们有很多 NA(实际上有 177 个!),这是 R 标记缺失数据的方式。

4.2.5. 处理缺失数据

处理缺失数据有两种方法:

  • 简单地从分析中排除具有缺失数据的案例

  • 应用一个插补机制来填补空白

当缺失值案例与完整案例的比例非常小的时候,第一个选项可能是有效的。在这种情况下,省略具有缺失数据的案例不太可能对我们的模型性能产生重大影响。这是一个简单(如果不是优雅)的解决方案。

第二种选项,缺失值插补,是我们使用某种算法估计那些缺失值可能是什么,用这些估计值替换 NA,并使用这个插补数据集来训练我们的模型的过程。有几种不同的方法可以估计缺失数据的值,我们将在整本书中使用更复杂的方法,但就目前而言,我们将采用均值插补,即简单地取缺失数据的变量的平均值,并用这个值替换缺失值。

在代码列表 4.8 中,我使用 mlr 的impute()函数替换缺失数据。第一个参数是数据名称,cols参数询问我们想要填充哪些列以及想要应用哪种方法。如果我们有多个列,我们将cols参数作为列名称的列表提供,列名称之间用逗号分隔。每个列名称后面应跟一个=符号和填充方法(imputeMean()使用变量的平均值来替换 NAs)。我将填充后的数据结构保存为对象imp,并使用sum(is.na())来计算数据中的缺失值数量。

代码列表 4.8. 在Age变量中填充缺失值
imp <- impute(titanicClean, cols = list(Age = imputeMean()))

sum(is.na(titanicClean$Age))
[1] 177

sum(is.na(imp$data$Age))
[1] 0

我们可以看到,那些 177 个缺失值都已经填充了!

4.2.6. 训练模型(第二部分)

好的,我们已经用平均值填充了那些讨厌的缺失值,并创建了新的对象imp。现在让我们再次尝试,通过使用填充数据创建一个任务。imp对象包含填充后的数据和我们所使用的填充过程的描述。要提取数据,我们只需使用imp$data

代码列表 4.9. 在填充数据上训练模型
titanicTask <- makeClassifTask(data = imp$data, target = "Survived")

logRegModel <- train(logReg, titanicTask)

这次没有错误信息。接下来,让我们交叉验证我们的模型以估计其性能。

4.3. 交叉验证逻辑回归模型

记住,当我们进行交叉验证时,我们应该交叉验证整个模型构建过程。这应该包括任何数据相关的预处理步骤,例如缺失值填充。在第三章中,我们使用包装函数将我们的学习者和超参数调整过程包装在一起。这次,我们将为我们的学习者和缺失值填充创建一个包装器。

4.3.1. 在交叉验证中包含缺失值填充

makeImputeWrapper()函数将一个学习器(作为第一个参数给出)和一个填充方法包装在一起。注意我们如何以与代码列表 4.8 中的impute()函数完全相同的方式指定填充方法,通过提供列的列表及其填充方法。

代码列表 4.10. 包装学习器和填充方法
logRegWrapper <- makeImputeWrapper("classif.logreg",
                                   cols = list(Age = imputeMean()))

现在让我们应用分层 10 折交叉验证,重复 50 次,到我们的包装学习器。

注意

记住,我们首先使用makeResampleDesc()定义我们的重采样方法,然后使用resample()运行交叉验证。

由于我们向resample()函数提供了包装后的学习器,对于交叉验证的每个折叠,训练集中Age变量的平均值将用于填充任何缺失值。

代码列表 4.11. 交叉验证模型构建过程
kFold <- makeResampleDesc(method = "RepCV", folds = 10, reps = 50,
                          stratify = TRUE)

logRegwithImpute <- resample(logRegWrapper, titanicTask,
                             resampling = kFold,
                             measures = list(acc, fpr, fnr))

logRegwithImpute
Resample Result
Task: imp$data
Learner: classif.logreg.imputed
Aggr perf: acc.test.mean=0.7961500,fpr.test.mean=0.2992605,fnr.test.mean=0.14
     44175
Runtime: 10.6986

由于这是一个二分类问题,我们可以访问一些额外的性能指标,例如假阳性率(fpr)和假阴性率(fnr)。在列表 4.11 中的交叉验证过程中,我们要求报告准确率、假阳性率和假阴性率作为性能指标。我们可以看到,尽管在重复实验中,我们的模型平均正确分类了 79.6%的乘客,但它错误地将 29.9%的死亡乘客分类为幸存者(假阳性),并将 14.4%的幸存乘客分类为死亡(假阴性)。

4.3.2. 准确率是最重要的性能指标,对吗?

你可能会认为模型预测的准确率是其性能的衡量标准。通常情况下是这样的,但有时并非如此。

假设你是一家银行的数据科学家,在欺诈检测部门工作。你的任务是构建一个模型,预测信用卡交易是合法的还是欺诈的。比如说,在 100,000 笔信用卡交易中,只有 1 笔是欺诈的。由于欺诈相对较少(并且因为他们今天午餐在提供披萨),你决定构建一个模型,简单地将所有交易分类为合法。

模型的准确率为 99.999%。很好吗?当然不是!该模型无法识别任何欺诈交易,并且具有 100%的假阴性率!

这里的教训是,你应该在特定问题的背景下评估模型性能。另一个例子可能是构建一个模型,指导医生为患者使用或不使用不愉快的治疗方法。在这个问题的背景下,可能可以接受错误地不给予患者不愉快的治疗,但如果你错误地给予患者不需要的治疗,这是绝对必要的!

如果积极事件很少见(如我们欺诈信用卡的例子),或者如果你特别重要,不要将阳性案例错误分类为阴性,你应该选择具有低假阴性率的模型。如果阴性事件很少见,或者如果你特别重要,不要将阴性案例错误分类为阳性(如我们的医疗治疗例子),你应该选择具有低假阳性率的模型。

查看以下链接以了解 mlr 目前包含的所有性能指标及其可用情况:mlr.mlr-org.com/articles/tutorial/measures.html

4.4. 解释模型:优势比

我在章节开头提到,逻辑回归之所以非常流行,是因为模型参数(y 截距和每个预测因子的斜率)的可解释性。为了提取模型参数,我们必须首先使用getLearnerModel()函数将我们的 mlr 模型对象logRegModel转换为 R 模型对象。接下来,我们将这个 R 模型对象作为coef()函数的参数传递,该函数代表系数(参数的另一个术语),因此这个函数返回模型参数。

列表 4.12. 提取模型参数
logRegModelData <- getLearnerModel(logRegModel)

coef(logRegModelData)

 (Intercept)      Pclass2      Pclass3      Sexmale          Age
 3.809661697 -1.000344806 -2.132428850 -2.775928255 -0.038822458
        Fare      FamSize
 0.003218432 -0.243029114

截距是在所有连续变量均为 0 且因素变量处于其参考水平时,生存“泰坦尼克号”灾难的对数概率。我们通常对斜率比 y 截距更感兴趣,但这些值是以对数概率单位表示的,难以解释。相反,人们通常将它们转换为概率比

概率比(odds ratio)实际上就是概率之比。例如,如果你是女性,在“泰坦尼克号”上的生存概率大约是 7 比 10,而如果你是男性,生存概率是 2 比 10,那么如果你是女性的生存概率比是 3.5。换句话说,如果你是女性,你生存的可能性比男性高 3.5 倍。概率比是解释预测因子对结果影响的一种非常流行的方式,因为它们很容易理解。

4.4.1. 将模型参数转换为概率比

我们如何从对数概率转换为概率比?通过取它们的指数(e^(对数概率))。我们还可以使用confint()函数计算 95%置信区间,以帮助我们决定每个变量具有预测价值的证据强度。

列表 4.13. 将模型参数转换为概率比
exp(cbind(Odds_Ratio = coef(logRegModelData), confint(logRegModelData)))

Waiting for profiling to be done…
             Odds_Ratio       2.5 %       97.5 %
(Intercept) 45.13516691 19.14718874 109.72483921
Pclass2      0.36775262  0.20650392   0.65220841
Pclass3      0.11854901  0.06700311   0.20885220
Sexmale      0.06229163  0.04182164   0.09116657
Age          0.96192148  0.94700049   0.97652950
Fare         1.00322362  0.99872001   1.00863263
FamSize      0.78424868  0.68315465   0.89110044

这些概率比中大多数都小于 1。小于 1 的概率比意味着事件发生的可能性较低。如果你将 1 除以它们,通常更容易解释这些值。例如,如果你是男性的生存概率比是 0.06,而 1 除以 0.06 = 16.7。这意味着,在保持所有其他变量不变的情况下,男性比女性生存的可能性低 16.7 倍。

对于连续变量,我们解释概率比是指乘客在变量每增加一个单位时生存可能性增加多少。例如,对于每个额外的家庭成员,乘客的生存可能性降低了 1/0.78 = 1.28 倍。

对于因素变量,我们解释概率比是指乘客相对于该变量的参考水平生存可能性增加多少。例如,我们有Pclass2Pclass3的概率比,分别表示 2 等和 3 等舱乘客相对于 1 等舱乘客生存可能性增加的倍数。

95%置信区间表明每个变量具有预测价值的证据强度。优势比率为 1 表示机会相等,变量对预测没有影响。因此,如果 95%置信区间包括值 1,例如Fare变量的那些,那么这可能表明这个变量没有做出贡献。

4.4.2. 当单位增加没有意义时

单位增加通常不容易解释。比如说,你得到一个优势比率为,对于每个额外的蚂蚁,蚁丘存活于白蚁攻击的可能性是 1.000005 倍。你如何理解这样一个小的优势比率的重要性?

当考虑单位增加没有意义时,一种流行的技术是在训练模型之前对连续变量进行 log[2]变换。这不会影响模型做出的预测,但现在优势比率可以这样解释:每次蚂蚁数量翻倍,蚁丘存活于白蚁攻击的可能性是x倍。这将给出更大且更易于解释的优势比率。

4.5. 使用我们的模型进行预测

我们已经构建、交叉验证并解释了我们的模型,现在使用该模型对新数据进行预测将是一件很棒的事情。这种情况有些不寻常,因为我们基于历史事件构建了一个模型,所以(希望!)我们不会用它来预测另一场泰坦尼克号的灾难。尽管如此,我想向你展示如何使用逻辑回归模型进行预测,就像你可以对任何其他监督算法做的那样。让我们加载一些未标记的乘客数据,对其进行清理以便预测,并将其通过我们的模型。

列表 4.14. 使用我们的模型对新数据进行预测
data(titanic_test, package = "titanic")

titanicNew <- as_tibble(titanic_test)

titanicNewClean <- titanicNew %>%
  mutate_at(.vars = c("Sex", "Pclass"), .funs = factor) %>%
  mutate(FamSize = SibSp + Parch) %>%
  select(Pclass, Sex, Age, Fare, FamSize)

predict(logRegModel, newdata = titanicNewClean)

Prediction: 418 observations
predict.type: prob
threshold: 0=0.50,1=0.50
time: 0.00
     prob.0     prob.1 response
1 0.9178036 0.08219636        0
2 0.5909570 0.40904305        0
3 0.9123303 0.08766974        0
4 0.8927383 0.10726167        0
5 0.4069407 0.59305933        1
6 0.8337609 0.16623907        0
… (#rows: 418, #cols: 3)

4.6. 逻辑回归的优势和弱点

虽然通常很难判断哪种算法对于特定任务会表现良好,但以下是一些优势和弱点,这将帮助你决定逻辑回归是否适合你。

逻辑回归算法的优势如下:

  • 它可以处理连续和分类预测变量。

  • 模型参数非常易于解释。

  • 预测变量假设是正态分布的。

逻辑回归算法的弱点如下:

  • 当类别之间完全分离时,它将不起作用。

  • 它假设类别是线性可分的。换句话说,它假设在n-维空间(其中n是预测变量的数量)中的一个平面可以用来分离类别。如果需要曲面来分离类别,与一些其他算法相比,逻辑回归的表现将不佳。

  • 它假设每个预测变量与对数几率之间存在线性关系。例如,如果预测变量的低值和高值案例属于一个类别,而预测变量的中等值案例属于另一个类别,这种线性关系就会破裂。

练习 3

重复模型构建过程,但省略Fare变量。交叉验证估计的模型性能是否有差异?为什么?

练习 4

Name变量中提取称呼,并将任何不是"Mr""Dr""Master""Miss""Mrs""Rev"的称呼转换为"Other"。查看以下代码,了解如何使用 stringr tidyverse 包中的str_split()函数提取称呼:

names <- c("Mrs. Pool", "Mr. Johnson")

str_split(names, pattern = "\\.")
[[1]]
[1] "Mrs"   " Pool"

[[2]]
[1] "Mr"       " Johnson"

练习 5

构建一个包含Salutation作为另一个预测变量的模型,并进行交叉验证。这会提高模型性能吗?

摘要

  • 逻辑回归是一种监督学习算法,通过计算数据属于每个类的概率来对新数据进行分类。

  • 逻辑回归可以处理连续和分类预测变量,并建模预测变量与属于正类对数几率之间的线性关系。

  • 特征工程是从现有变量中提取信息或创建新变量的过程,以最大化其预测价值。

  • 特征选择是选择数据集中哪些变量对机器学习模型具有预测价值的过程。

  • 假设缺失值处理策略,其中使用某些算法来估计缺失值可能是什么。你学习了如何为泰坦尼克号数据集应用均值填充。

  • 几率比是一种解释每个预测变量对案例属于正类几率影响的有信息量的方式。它们可以通过取模型斜率的指数(e^(对数几率))来计算。

练习题解答

  1. 重新绘制小提琴图,添加一个透明度的geom_point()层:

    titanicUntidy %>%
      filter(Variable != "Pclass" & Variable != "Sex") %>%
      ggplot(aes(Survived, as.numeric(Value))) +
      facet_wrap(~ Variable, scales = "free_y") +
      geom_violin(draw_quantiles = c(0.25, 0.5, 0.75)) +
      geom_point(alpha = 0.05, size = 3) +
      theme_bw()
    
  2. 重新绘制条形图,但使用"dodge""stack"位置参数:

    titanicUntidy %>%
      filter(Variable == "Pclass" | Variable == "Sex") %>%
      ggplot(aes(Value, fill = Survived)) +
      facet_wrap(~ Variable, scales = "free_x") +
      geom_bar(position = "dodge") +
      theme_bw()
    
    titanicUntidy %>%
      filter(Variable == "Pclass" | Variable == "Sex") %>%
      ggplot(aes(Value, fill = Survived)) +
      facet_wrap(~ Variable, scales = "free_x") +
      geom_bar(position = "stack") +
      theme_bw()
    
  3. 构建模型,但省略Fare变量:

    titanicNoFare <- select(titanicClean, -Fare)
    
    titanicNoFareTask <- makeClassifTask(data = titanicNoFare,
                                         target = "Survived")
    
    logRegNoFare <- resample(logRegWrapper, titanicNoFareTask,
                             resampling = kFold,
                             measures = list(acc, fpr, fnr))
    
    logRegNoFare
    

    忽略Fare变量对模型性能的影响很小,因为它对Pclass变量没有额外的预测价值(查看列表 4.13 中Fare的几率比和置信区间)。

  4. Name变量中提取称呼(有多种方法可以做到这一点,所以不要担心你的方法与我的不同):

    surnames <- map_chr(str_split(titanicTib$Name, "\\."), 1)
    
    salutations <- map_chr(str_split(surnames, ", "), 2)
    
    salutations[!(salutations %in% c("Mr", "Dr", "Master",
                                     "Miss", "Mrs", "Rev"))] <- "Other"
    
  5. 使用Salutation作为预测变量构建模型:

    fctrsInclSals <- c("Survived", "Sex", "Pclass", "Salutation")
    
    titanicWithSals <- titanicTib %>%
      mutate(FamSize = SibSp + Parch, Salutation = salutations) %>%
      mutate_at(.vars = fctrsInclSals, .funs = factor) %>%
      select(Survived, Pclass, Sex, Age, Fare, FamSize, Salutation)
    
    titanicTaskWithSals <- makeClassifTask(data = titanicWithSals,
                                           target = "Survived")
    
    logRegWrapper <- makeImputeWrapper("classif.logreg",
                                       cols = list(Age = imputeMean()))
    
    kFold <- makeResampleDesc(method = "RepCV", folds = 10, reps = 50,
                              stratify = TRUE)
    
    logRegWithSals <- resample(logRegWrapper, titanicTaskWithSals,
                               resampling = kFold,
                               measures = list(acc, fpr, fnr))
    logRegWithSals
    

特征提取效果显著!将Salutation作为预测变量提高了模型性能。

第五章:通过判别分析最大化分离进行分类

本章涵盖

  • 理解线性与二次判别分析

  • 构建判别分析分类器以预测葡萄酒

判别分析 是一个总称,指的是多种解决分类问题(我们希望预测一个分类变量)的算法。虽然不同的判别分析算法学习方式略有不同,但它们都找到了原始数据的新表示,以最大化类之间的分离。

回想一下第一章,预测变量是我们希望包含对新数据进行预测所需信息的变量。判别函数分析算法通过将它们组合成新的变量(这些变量必须是连续的)来找到预测变量的新表示,这些新变量最好地 区分 类别。这种预测变量的组合通常有一个有用的好处,即减少预测变量的数量到一个更小的数量。正因为如此,尽管判别分析算法是分类算法,但它们与我们在本书第四部分(kindle_split_024.html#part04)中将要遇到的某些降维算法相似。

注意

降维 是一个过程,它学习如何将一组变量中的信息尽可能少地损失地压缩成更少的变量。

5.1. 什么是判别分析?

在本节中,你将了解判别分析为何有用以及它是如何工作的。想象一下,你想要找出是否可以根据患者的基因表达预测他们对药物的反应。你测量了 1,000 个基因的表达水平,并记录它们对药物的反应是积极、消极还是完全没有反应(一个三分类问题)。

一个具有如此多预测变量(并且找到这样大的数据集并不罕见)的数据集会带来一些问题:

  • 数据非常难以手动探索和绘制。

  • 可能存在许多预测变量,它们几乎不包含或只包含很少的预测信息。

  • 我们必须应对 维度灾难(算法在尝试学习高维数据中的模式时遇到的问题)。

在我们的基因表达示例中,要这样绘制所有 1,000 个基因,以便我们能够解释类之间的相似性/差异性几乎是不可能的。相反,我们可以使用判别分析将所有这些信息压缩成可管理的数量的 判别函数,每个函数都是原始变量的组合。换句话说,判别分析将预测变量作为输入,并找到这些变量的新、低维表示,以最大化类之间的分离。因此,尽管判别分析是一种分类技术,但它使用降维来实现其目标。这如图 5.1 所示。

注意

由于它们的降维,判别分析算法是分类问题中流行的技术,在这些问题中,你有很多连续的预测变量。

图 5.1。判别分析算法将原始数据与连续预测变量结合,形成新的变量,这些变量最大化类别的分离。

这些判别函数的数量将是以下两者中的较小者:

  • 类别数减去 1

  • 预测变量的数量

在基因表达示例中,那些 1,000 个预测变量包含的信息将浓缩成仅仅 2 个变量(三个类别减去 1)。现在我们可以轻松地将这两个新变量相互对比,以查看我们的三个类别是如何分离的!

正如你在第四章中学到的,包括包含很少或没有预测价值的预测变量会增加噪声,这可能会对学习到的模型的表现产生负面影响。当判别分析算法学习它们的判别函数时,会给予更好地区分类别的预测变量更大的权重或重要性。包含很少或没有预测价值的预测变量得到的权重较小,对最终模型的贡献也较小。在一定程度上,这种对无信息预测变量的低权重减轻了它们对模型性能的影响。

注意

尽管可以减轻弱预测变量的影响,但在进行特征选择(移除弱预测变量)后,判别分析模型仍然倾向于表现更好。

维度灾难是一个听起来令人恐惧的现象,当处理高维数据(具有许多预测变量的数据)时会引起问题。随着特征空间(所有可能的预测变量组合的集合)的增加,该空间中的数据变得更加稀疏。更简单地说,对于数据集中相同数量的案例,如果你增加特征空间,案例彼此之间的距离会变得更远,它们之间的空隙也会更大。这通过从一维特征空间到三维特征空间的转换在图 5.2 中得到了演示。

图 5.2。随着维度的增加,数据变得更加稀疏。两个类别在一维、二维和三维特征空间中显示。三维表示中的虚线用于澄清点在 z 轴上的位置。注意随着维度的增加,空隙越来越大。

维度增加的后果是特征空间的一个区域可能只有很少的案例占据,因此算法更有可能从数据中的“异常”案例中学习。当算法从异常案例中学习时,这会导致过度拟合的模型,其预测的方差很大。这就是维度灾难。

注意

随着预测变量数量的线性增加,案例的数量需要指数级增加,以保持特征空间中的相同密度。

这并不是说变量越多就越不好!对于大多数问题,添加具有有价值信息的预测变量可以提高模型的预测精度……直到它不再这样做(直到我们得到递减的回报)。那么我们如何防止由于维度诅咒导致的过拟合?通过执行特征选择(正如我们在第四章中所做的那样),只包括具有预测价值的变量,以及/或者通过执行降维。你将在本书的第四部分中了解到许多具体的降维算法,但判别分析实际上在它的学习过程中执行降维。

备注

当预测变量的数量增加时,模型的预测能力会增加,但当我们继续添加更多预测变量时,这种增加又会再次减少,这种现象被称为休斯现象,以统计学家 G. Hughes 的名字命名。

判别分析不是一个算法,而是有许多不同的版本。我将教你两种最基本且最常用的算法:

  • 线性判别分析(LDA)

  • 二次判别分析(QDA)

在下一节中,你将了解这些算法是如何工作的以及它们之间的区别。现在,只需说 LDA 和 QDA 分别学习线性(直线)和曲线决策边界之间的类别即可。

5.1.1. 如何进行判别分析的学习?

我将从解释 LDA 的工作原理开始,然后将其推广到 QDA。想象一下,我们有两个预测变量,我们试图使用这些变量来分离数据中的两个类别(参见图 5.3)。LDA 的目标是学习数据的新表示,该表示可以分离每个类别的质心,同时尽可能保持类内方差最低。质心简单地是特征空间中所有预测变量的平均值(一个均值向量,每个维度一个)。然后 LDA 找到一条通过原点的线,当数据被投影到这条线上时,它同时完成以下操作:

  • 沿着线最大化类中心之间的差异

  • 沿着线最小化类内方差

为了选择这条线,算法在所有可能的轴上最大化方程 5.1 中的表达式:

方程 5.1。

eq5-1.jpg

分子是类别均值之间的差异( 分别为类别 1 和类别 2 的均值),平方以确保值为正(因为我们不知道哪个会更大)。分母是每个类别沿线的方差之和( 分别为类别 1 和类别 2 的方差)。这种直觉背后的想法是我们希望类别的均值尽可能分离,每个类别内的散点/方差尽可能小。

图 5.3. 在二维中学习判别函数。LDA 学习一个新轴,使得当数据投影到它上(虚线)时,它最大化类别均值之间的差异,同时最小化类内方差。s² 分别是每个类别沿新轴的均值和方差。

图 5.4. 仅通过构建一个最大化类别质心分离的新轴并不能完全解决类别问题(左侧示例)。构建一个同时最大化质心分离并最小化每个类别内方差的轴,可以更好地分离类别(右侧)。s² 分别是每个类别沿新轴的均值和方差。

为什么不简单地找到最大化质心分离的线呢?因为最佳分离质心的线并不能保证在不同类别中案例的最佳分离。这可以在图 5.4 中看到。在左侧的示例中,画出一个新轴,它仅仅最大化两个类别的质心分离。当我们把数据投影到这个新轴上时,由于相对较高的方差,类别并没有完全分离。然而,在右侧的示例中,新轴试图在最大化质心分离的同时,最小化每个类别沿该轴的方差。这导致质心稍微靠近,但方差却小得多,从而使得两个类别的案例完全分离。

这个新轴被称为判别函数,它是原始变量的线性组合。例如,一个判别函数可以由以下方程描述:

  • DF = –0.5 × var[1] + 1.2 × var[2] + 0.85 × var[3]

以这种方式,该方程中的判别函数(DF)是变量var[1]、var[2]和var[3]的线性组合。这种组合是线性的,因为我们只是将每个变量的贡献相加。每个变量乘以的值被称为典型判别函数系数,并按每个变量对类别分离的贡献程度对其进行加权。换句话说,对类别分离贡献最大的变量将具有较大的绝对典型判别函数系数(正或负)。包含少量或没有类别分离信息的变量将具有接近零的典型判别函数系数。

线性判别分析 vs. 主成分分析

如果你之前遇到过主成分分析(PCA),你可能想知道它与线性判别分析(LDA)有何不同。PCA 是一种无监督的降维学习算法,这意味着,与 LDA 不同,它不依赖于标记数据。

虽然这两个算法都可以用来降低数据集的维度,但它们以不同的方式这样做,以达到不同的目标。LDA 创建新的轴,最大化类别分离,这样我们就可以使用这些新轴来对新数据进行分类,而 PCA 创建新的轴,最大化投影到这些轴上的数据的方差。PCA 的目标不是分类,而是尽可能多地解释数据中的变异和信息,只使用少量新轴。然后,这种新的、低维度的表示可以输入到其他机器学习算法中。(如果你对 PCA 不熟悉,不要担心!你将在第十三章中深入了解它。)

如果你想要降低具有标记类别成员资格的数据的维度,你应该通常优先考虑 LDA 而不是 PCA。如果你想要降低未标记数据的维度,你应该优先考虑 PCA(或书中第四部分 part 4 中我们将讨论的许多其他降维算法)。

5.1.2. 如果我们有超过两个类别怎么办?

判别分析可以处理多于两个类别的分类问题。但它是如何在这种情况下学习最佳轴的呢?它不是试图最大化类别质心的分离,而是最大化每个类别质心与数据的总体质心(忽略类别成员资格的所有数据的质心)之间的分离。这在图 5.5 中得到了说明,其中我们对来自三个类别的案例进行了两个连续的测量。类别质心用三角形表示,总体质心用十字表示。

图 5.5. 当类别超过两个时,LDA 最大化每个类别质心(三角形)与总体质心(交叉)之间的距离,同时最小化类内方差。一旦找到第一个判别函数,就构建第二个与它正交的函数。原始数据可以与这些函数进行绘图。

LDA 首先找到最佳分离类别质心与总体质心的轴,同时最小化沿其的每个类别的方差。然后,LDA 构建第二个与第一个正交的 DF。这仅仅意味着第二个 DF 必须与第一个垂直(在这个 2D 例子中是直角)。

注意

DF 的数量将是以下两者中较小的一个:(类别数)减 1,或预测变量数。

然后,数据被投影到这些新轴上,使得每个案例对每个函数都有一个判别得分(它在新轴上的值)。这些判别得分可以相互绘制,以形成原始数据的新表示。

但这有什么大不了的?我们从一个预测变量变成了……两个预测变量!实际上,你能看到我们所做的只是将数据居中和缩放,并围绕零旋转?当我们只有两个变量时,判别分析无法执行任何降维,因为 DF 的数量是类别数减 1 和变量数中的较小者(我们只有两个变量)。

但当我们有超过两个预测变量时怎么办?图 5.6 展示了有一个三个预测变量(xyz)和三个类别的例子。就像在图 5.5 中一样,LDA 找到最大化每个类别质心与总体质心之间分离度的 DF,同时最小化沿其的方差。这条线穿过三维空间。

图 5.6. 当预测变量超过两个时,立方体代表一个包含三个预测变量(xyz)和三个类别(虚线有助于指示每个案例沿z-轴的位置)的特征空间。找到了判别函数 1(DF1),然后找到与 DF1 正交的 DF2。虚线表示 DF1 和 DF2 在 z 轴上的“阴影”,有助于显示它们的深度。数据可以被投影到 DF1 和 DF2 上。

接下来,LDA 找到第二个 DF(与第一个正交),它也试图最大化分离度同时最小化方差。因为我们只有三个类别(且 DF 的数量是类别数减 1 和预测变量数中的较小者),所以我们停在两个 DF 上。通过取数据中每个案例的判别得分(每个案例沿两个 DF 的值),我们可以在仅两个维度上绘制我们的数据。

注意

第一个 DF 总是做得最好,其次是第二个、第三个,依此类推。

LDA 已经将一个三维数据集合并,将这三个变量中的信息合并成两个新的变量,这些变量最大化了类别的分离。这很酷——但如果不是只有三个预测变量,而是有 1,000 个(如我之前使用的示例中所示),LDA 仍然会将所有这些信息压缩成只有 2 个变量!这太酷了。

5.1.3. 用曲线而不是直线来学习:QDA

当每个类别的数据在所有预测变量上都是正态分布,并且类别具有相似的协方差时,LDA 表现良好。协方差简单地说就是当一个变量增加/减少时,另一个变量增加/减少的程度。因此,LDA 假设对于数据集中的每个类别,预测变量之间的协方差是相同的。

这通常并不是情况,不同的类别有不同的协方差。在这种情况下,QDA 往往比 LDA 表现更好,因为它不做出这个假设(尽管它仍然假设数据是正态分布的)。QDA 不是学习直线来分隔类别,而是学习曲线。因此,它也非常适合那些类别最好通过非线性决策边界来分隔的情况。这可以在图 5.7 中看到。

图 5.7. 具有相同协方差(变量 1 和 2 之间的关系对于两个类别都是相同的)和不同协方差的两个类别的示例。椭圆形代表每个类别内的数据分布。展示了二次和线性 DFs(QDF 和 LDF)。展示了具有不同协方差类别的每个 DF 上的投影。

图 5-7

在图中的左侧示例中,两个类别在两个变量上都是正态分布的,并且具有相同的协方差。我们可以看到协方差是相等的,因为对于两个类别,当变量 1 增加时,变量 2 以相同的量减少。在这种情况下,LDA 和 QDA 将找到相似的 DFs,尽管 LDA 比 QDA 更不容易过拟合,因为它不太灵活。

在图中的右侧示例中,两个类别都是正态分布的,但它们的协方差不同。在这种情况下,QDA 将找到一个曲线 DF,当数据投影到它上面时,将比线性 DF 更好地分隔类别。

5.1.4. LDA 和 QDA 如何进行预测?

无论你选择了哪种方法,DFs 都已经构建,你已经将你的高维数据减少到少数几个判别变量。LDA 和 QDA 如何使用这些信息来对新观测进行分类?它们使用一个极其重要的统计定理,称为贝叶斯定理

贝叶斯定理为我们提供了一种回答以下问题的方法:给定数据中任何案例的预测变量值,该案例属于类别 k 的概率是多少?这表示为 p(k|x),其中 k 代表属于类别 k 的成员资格,而 x 代表预测变量的值。我们会读作“在数据 x 的条件下,属于类别 k 的概率。”这是由贝叶斯定理给出的:

方程 5.2。

不要被这个吓到!方程中只有四个术语,我将带你逐一了解它们。你已经知道 p(k|x) 是在给定数据的情况下,一个案例属于类别 k 的概率。这被称为 后验概率

p(x|k) 与此相同,但方向相反:在案例属于类别 k 的情况下,观察这些数据点的概率是什么?换句话说:如果这个案例 确实 在类别 k 中,那么它具有这些预测变量值的 似然性 是多少?这被称为 似然性

p(k) 被称为 先验概率,它简单地表示任何案例属于类别 k 的概率。这是数据中属于类别 k 的所有案例的比例。例如,如果 30% 的案例属于类别 k,那么 p(k) 等于 0.3。

最后,p(x) 是在数据集中观察到一个具有这些预测变量值的案例的概率。这被称为 证据。估计证据通常非常困难(因为数据集中的每个案例可能都有独特的预测变量值的组合),它只用于确保所有后验概率之和为 1。因此,我们可以从方程中省略证据,并说

方程 5.3。

其中 ∝ 符号表示其两侧的值是 成比例的,而不是 相等的。以更易于理解的方式,

  • 后验似然 × 先验

案例的先验概率 (p(k)) 很容易计算:它是数据集中属于类别 k 的案例的比例。但我们如何计算似然 (p(x|k)) 呢?似然是通过将数据投影到其 DF 上并估计其 概率密度 来计算的。概率密度是观察具有特定判别分数组合的案例的相对概率。

判别分析假设数据是正态分布的,因此它通过将每个类别拟合到每个 DF 上来估计概率密度。每个正态分布的中心是类别质心,其标准差是判别轴上的一个单位。这在 图 5.8 中对单个 DF 和两个 DF 进行了说明(在超过两个维度的情况下也会发生相同的事情,但难以可视化)。你可以看到,沿着判别轴靠近类别质心的案例具有该类的高概率密度,而远离的案例具有较低的概率密度。

图 5.8. 每个类别的概率密度假设为正态分布,其中每个分布的中心是类别的质心。这在一个 DF(对于类别 k 和 j)以及两个 DF 中显示。

一旦为给定类别的案例估计了概率密度,就可以将其传递到方程中:

  • 后验概率 = 似然率 × 先验概率

为每个类别估计后验概率,并且具有最高概率的类别就是案例被分类为的类别。

注意

前验概率(该类别的案例比例)很重要,因为如果类别严重不平衡,尽管案例离类别的质心很远,但由于该类别中有许多更多的案例,案例更有可能属于该类别。

贝叶斯定理在统计学和机器学习中非常重要。如果你现在还不完全理解它,请不要担心;这是故意的。我现在想轻轻地介绍它,我们将在第六章(kindle_split_016.html#ch06)中更深入地探讨它。

5.2. 构建你的第一个线性判别分析和二次判别分析模型

现在你已经知道了判别分析是如何工作的,你将构建你的第一个 LDA 模型。如果你还没有做的话,请加载 mlr 和 tidyverse 包:

library(mlr)
library(tidyverse)

5.2.1. 加载和探索葡萄酒数据集

在本节中,你将学习如何构建和评估线性判别分析和二次判别分析模型的表现。想象一下,你是一名侦探,正在一个谋杀谜案中。一位当地葡萄酒生产商,罗纳德·费希尔,在一次晚宴上被人用含有砒霜的葡萄酒替换了酒壶中的葡萄酒而被毒害。

三位其他(竞争对手)葡萄酒生产商也在晚宴上,他们是你的主要嫌疑人。如果你能追踪到这些葡萄园之一,你就能找到凶手。幸运的是,你能够访问到来自每个葡萄园的葡萄酒的一些之前的化学分析,并且你要求对犯罪现场中的有毒酒壶进行分析。你的任务是构建一个模型,告诉你含有砒霜的葡萄酒来自哪个葡萄园,因此找出罪犯。

让我们加载 HDclassif 包中内置的葡萄酒数据,将其转换为 tibble,并对其进行一些探索。我们有一个包含 178 个案例和 14 个变量(对各种葡萄酒瓶进行的测量)的 tibble。

列表 5.1. 加载和探索葡萄酒数据集
install.packages("HDclassif")

data(wine, package = "HDclassif")

wineTib <- as_tibble(wine)
wineTib

# A tibble: 178 x 14
   class    V1    V2    V3    V4    V5    V6    V7    V8    V9
   <int> <dbl> <dbl> <dbl> <dbl> <int> <dbl> <dbl> <dbl> <dbl>
 1     1  14.2  1.71  2.43  15.6   127  2.8   3.06 0.28   2.29
 2     1  13.2  1.78  2.14  11.2   100  2.65  2.76 0.26   1.28
 3     1  13.2  2.36  2.67  18.6   101  2.8   3.24 0.3    2.81
 4     1  14.4  1.95  2.5   16.8   113  3.85  3.49 0.24   2.18
 5     1  13.2  2.59  2.87  21     118  2.8   2.69 0.39   1.82
 6     1  14.2  1.76  2.45  15.2   112  3.27  3.39 0.34   1.97
 7     1  14.4  1.87  2.45  14.6    96  2.5   2.52 0.3    1.98
 8     1  14.1  2.15  2.61  17.6   121  2.6   2.51 0.31   1.25
 9     1  14.8  1.64  2.17  14      97  2.8   2.98 0.290  1.98
10     1  13.9  1.35  2.27  16      98  2.98  3.15 0.22   1.85
# ... with 168 more rows, and 4 more variables: V10 <dbl>,
#   V11 <dbl>, V12 <dbl>, V13 <int>

通常,作为数据科学家,我们接收到的数据可能是杂乱无章的或者整理得不够好。在这种情况下,变量的名称缺失!我们可以继续使用 V1V2 等等,但很难追踪哪个变量是哪个。因此,我们将手动添加变量名称。谁说数据科学家的生活是光鲜亮丽的?然后,我们将 class 变量转换为因子。

列表 5.2. 清洗数据集
names(wineTib) <- c("Class", "Alco", "Malic", "Ash", "Alk", "Mag",
                    "Phe", "Flav", "Non_flav", "Proan", "Col", "Hue",
                    "OD", "Prol")

wineTib$Class <- as.factor(wineTib$Class)

wineTib

# A tibble: 178 x 14
   Class  Alco Malic   Ash   Alk   Mag   Phe  Flav Non_flav Proan
   <fct> <dbl> <dbl> <dbl> <dbl> <int> <dbl> <dbl>    <dbl> <dbl>
 1 1      14.2  1.71  2.43  15.6   127  2.8   3.06    0.28   2.29
 2 1      13.2  1.78  2.14  11.2   100  2.65  2.76    0.26   1.28
 3 1      13.2  2.36  2.67  18.6   101  2.8   3.24    0.3    2.81
 4 1      14.4  1.95  2.5   16.8   113  3.85  3.49    0.24   2.18
 5 1      13.2  2.59  2.87  21     118  2.8   2.69    0.39   1.82
 6 1      14.2  1.76  2.45  15.2   112  3.27  3.39    0.34   1.97
 7 1      14.4  1.87  2.45  14.6    96  2.5   2.52    0.3    1.98
 8 1      14.1  2.15  2.61  17.6   121  2.6   2.51    0.31   1.25
 9 1      14.8  1.64  2.17  14      97  2.8   2.98    0.290  1.98
10 1      13.9  1.35  2.27  16      98  2.98  3.15    0.22   1.85
# ... with 168 more rows, and 4 more variables: Col <dbl>,
#   Hue <dbl>, OD <dbl>, Prol <int>

这好多了。我们可以看到,我们对 178 瓶葡萄酒进行了 13 次连续测量,其中每次测量都是葡萄酒中不同化合物/元素的含量。我们还有一个单个的分类变量 Class,它告诉我们这瓶酒来自哪个葡萄园。

注意

许多人认为保持变量名小写是一种好的做法。只要我的风格一致,我就不太介意。因此,请注意,我将分组变量 class 的名称更改为 Class

5.2.2. 绘制数据

让我们绘制数据,以了解化合物在葡萄园之间的变化情况。至于 第四章 中的 Titanic 数据集,我们将数据收集到一个杂乱格式中,这样我们就可以按每个变量进行分面。

列表 5.3. 创建用于绘图的杂乱 tibble
wineUntidy <- gather(wineTib, "Variable", "Value", -Class)

ggplot(wineUntidy, aes(Class, Value)) +
  facet_wrap(~ Variable, scales = "free_y") +
  geom_boxplot() +
  theme_bw()

结果图示在 图 5.9 中。

图 5.9. 数据中每个连续变量与葡萄园编号的箱线图。对于箱线图,粗横线代表中位数,箱子代表四分位距(IQR),胡须代表 Tukey 范围(四分位数以上和以下 1.5 倍的 IQR),点代表 Tukey 范围之外的数据。

一个数据科学家(以及正在处理这个案件的大侦探)看到这些数据会非常高兴!看看来自三个不同葡萄园的葡萄酒之间有多少明显的差异。由于类别非常可分,我们应该能够轻松构建一个表现良好的分类模型。

5.2.3. 训练模型

让我们定义我们的任务和学习者,并像往常一样构建一个模型。这次,我们将 "classif.lda" 作为 makeLearner() 函数的参数,以指定我们将使用 LDA。

小贴士

LDA 和 QDA 没有需要调整的超参数,因此它们被称为具有 闭式解。换句话说,LDA 和 QDA 所需要的信息都在数据中。它们的性能也不受不同尺度变量的影响。无论数据是否缩放,它们都会给出相同的结果!

列表 5.4. 创建任务和学习者,并训练模型
wineTask <- makeClassifTask(data = wineTib, target = "Class")

lda <- makeLearner("classif.lda")

ldaModel <- train(lda, wineTask)
注意

回想一下 第三章,makeClassifTask() 函数会警告我们的数据是 tibble 而不是纯 data.frame。这个警告可以安全地忽略。

让我们使用 getLearnerModel() 函数提取模型信息,并使用 predict() 函数获取每个案例的 DF 值。通过打印 head(ldaPreds),我们可以看到模型已经学习了两个 DF,LD1LD2,并且 predict() 函数确实为我们的 wineTib 数据集中的每个案例返回了这些函数的值。

列表 5.5. 提取每个案例的 DF 值
ldaModelData <- getLearnerModel(ldaModel)

ldaPreds <- predict(ldaModelData)$x

head(ldaPreds)
        LD1       LD2
1 -4.700244 1.9791383
2 -4.301958 1.1704129
3 -3.420720 1.4291014
4 -4.205754 4.0028715
5 -1.509982 0.4512239
6 -4.518689 3.2131376

为了可视化这两个学习到的 DF 如何将来自三个葡萄园的酒瓶分开,让我们将它们相互绘制。我们首先将 wineTib 数据集通过一个 mutate() 调用,为每个 DF 创建一个新列。然后,我们将这个变异的 tibble 通过一个 ggplot() 调用,并将 LD1LD2Class 分别设置为 x、y 和颜色美学。最后,我们添加一个 geom_point() 层来添加点,并添加一个 stat_ellipse() 层来在每个类别周围绘制 95% 置信椭圆。

列表 5.6. 将 DF 值相互绘制
wineTib %>%
  mutate(LD1 = ldaPreds[, 1],
         LD2 = ldaPreds[, 2]) %>%
  ggplot(aes(LD1, LD2, col = Class)) +
  geom_point() +
  stat_ellipse() +
  theme_bw()

结果图示在 图 5.10 中。

图 5.10. 将 DF 相互绘制。每个案例的 LD1LD2 值相互绘制,并按其类别着色。

看起来不错。你能看到 LDA 将我们的 13 个预测变量减少到仅仅两个 DF,这两个 DF 在将葡萄酒与每个葡萄园分开方面做得非常出色吗?

接下来,让我们使用完全相同的程序来构建一个 QDA 模型。

列表 5.7. 将 DF 值相互绘制
qda <- makeLearner("classif.qda")

qdaModel <- train(qda, wineTask)
注释

很遗憾,从 mlr 实现的 QDA 中提取 DF 并绘制它们,就像我们对 LDA 所做的那样,并不容易。

现在,让我们将我们的 LDA 和 QDA 模型一起进行交叉验证,以估计它们在新数据上的表现。

列表 5.8. 交叉验证 LDA 和 QDA 模型
kFold <- makeResampleDesc(method = "RepCV", folds = 10, reps = 50,
                          stratify = TRUE)

ldaCV <- resample(learner = lda, task = wineTask, resampling = kFold,
                  measures = list(mmce, acc))

qdaCV <- resample(learner = qda, task = wineTask, resampling = kFold,
                  measures = list(mmce, acc))

ldaCV$aggr
mmce.test.mean  acc.test.mean
    0.01177012     0.98822988

qdaCV$aggr
mmce.test.mean  acc.test.mean
   0.007977296    0.992022704

太好了!我们的 LDA 模型平均正确分类了 98.8% 的酒瓶。这里几乎没有改进的余地,但我们的 QDA 模型设法正确分类了 99.2% 的案例!让我们也看看混淆矩阵(解释它们是本章练习的一部分):

calculateConfusionMatrix(ldaCV$pred, relative = TRUE)

Relative confusion matrix (normalized by row/column):
        predicted
true     1           2           3           -err.-
  1      1e+00/1e+00 3e-04/3e-04 0e+00/0e+00 3e-04
  2      8e-03/1e-02 1e+00/1e+00 1e-02/2e-02 2e-02
  3      0e+00/0e+00 1e-02/7e-03 1e+00/1e+00 1e-02
  -err.-       0.010       0.007       0.021 0.01

Absolute confusion matrix:
        predicted
true        1    2    3 -err.-
  1      2949    1    0      1
  2        29 3470   51     80
  3         0   23 2377     23
  -err.-   29   24   51    104

calculateConfusionMatrix(qdaCV$pred, relative = TRUE)

Relative confusion matrix (normalized by row/column):
        predicted
true     1           2           3           -err.-
  1      0.993/0.984 0.007/0.006 0.000/0.000 0.007
  2      0.014/0.016 0.986/0.991 0.000/0.000 0.014
  3      0.000/0.000 0.005/0.003 0.995/1.000 0.005
  -err.-       0.016       0.009       0.000 0.009

Absolute confusion matrix:
        predicted
true        1    2    3 -err.-
  1      2930   20    0     20
  2        49 3501    0     49
  3         0   12 2388     12
  -err.-   49   32    0     81

现在,侦探,毒酒化学分析的结果出来了。让我们使用我们的 QDA 模型来预测它来自哪个葡萄园:

poisoned <- tibble(Alco = 13, Malic = 2, Ash = 2.2, Alk = 19, Mag = 100,
                   Phe = 2.3, Flav = 2.5, Non_flav = 0.35, Proan = 1.7,
                   Col = 4, Hue = 1.1, OD = 3, Prol = 750)

predict(qdaModel, newdata = poisoned)

Prediction: 1 observations
predict.type: response
threshold:
time: 0.00
  response
1        1

模型预测毒害的瓶子来自葡萄园 1。是时候去逮捕了!

罗纳德·费舍尔

你可能会很高兴地知道,在现实世界中,罗纳德·费舍尔并没有在晚宴上中毒。这可能对你来说是个幸运的事,因为罗纳德·费舍尔爵士(1890-1962)是一位著名的生物统计学家,后来被称为统计学的奠基人。费舍尔开发了今天我们使用的许多统计工具和概念,包括判别分析。事实上,线性判别分析通常与 费舍尔的判别分析 混淆,这是费舍尔开发的判别分析的原型(但略有不同)。

然而,费舍尔也是优生学的支持者,认为某些种族比其他种族优越。事实上,他在 1952 年的联合国教科文组织声明“种族问题”中分享了他的观点,他说“人类群体在先天的智力和情感发展能力上存在深刻差异” (unesdoc.unesco.org/ark:/48223/pf0000073351)。也许现在你不再为我们的谋杀之谜受害者感到那么遗憾了。

5.3. LDA 和 QDA 的优点和缺点

虽然通常不容易判断哪种算法会对给定的任务表现良好,但以下是一些优点和缺点,这将帮助您决定 LDA 和 QDA 是否适合您。

LDA 和 QDA 算法的优点如下:

  • 它们可以将高维特征空间减少到更易于管理的数量。

  • 它们可以用作分类或作为其他可能在该数据集上表现更好的分类算法的预处理(降维)技术。

  • QDA 可以学习类之间的曲线决策边界(LDA 不是这种情况)。

LDA 和 QDA 算法的缺点如下:

  • 它们只能处理连续预测因子(尽管在某些情况下将分类变量重新编码为数值可能有所帮助)。

  • 它们假设数据在预测因子上是正态分布的。如果数据不是,性能将受到影响。

  • LDA 只能学习类之间的线性决策边界(QDA 不是这种情况)。

  • LDA 假设类的协方差相等,如果这种情况不成立(对于 QDA 来说情况就是这样),性能将受到影响。

  • QDA 比 LDA 更灵活,因此更容易过拟合。

练习 1

解释上一节中显示的混淆矩阵。

  1. 哪个模型在识别 3 号葡萄园的酒方面更好?

  2. 我们的 LDA 模型是否将更多来自 2 号葡萄园的酒误分类为来自 1 号或 3 号葡萄园?

练习 2

从我们的 LDA 模型中提取判别得分,并仅使用这些作为 kNN 模型的预测因子(包括调整k)。尝试您自己的交叉验证策略。如果您需要复习如何训练 kNN 模型,请回顾第三章。

摘要

  • 判别分析是一种监督学习算法,它将数据投影到低维表示以创建判别函数。

  • 判别函数是原始(连续)变量的线性组合,它们最大化类质心的分离,同时最小化每个类沿其的方差。

  • 判别分析有多种形式,其中最基本的是 LDA 和 QDA。

  • LDA 学习类之间的线性决策边界,并假设类是正态分布的,并且具有相等的协方差。

  • QDA 可以学习类之间的曲线决策边界,并假设每个类都是正态分布的,但假设协方差相等。

  • 判别函数的数量是类数减 1 和预测变量数中的较小者。

  • 类预测使用贝叶斯规则来估计案例属于每个类的后验概率。

练习解答

  1. 解释混淆矩阵:

    1. 我们的 QDA 模型在识别 3 号葡萄园的酒方面表现更好。它将 12 号酒误分类为 2 号葡萄园,而 LDA 模型将 23 号酒误分类。

    2. 我们的 LDA 模型将来自葡萄园 2 的案例误分类为来自葡萄园 3 的案例,多于来自葡萄园 1 的案例。

  2. 将 LDA 的判别分数用作 kNN 模型的预测变量:

    # CREATE TASK ----
    wineDiscr <- wineTib %>%
      mutate(LD1 = ldaPreds[, 1], LD2 = ldaPreds[, 2]) %>%
      select(Class, LD1, LD2)
    
    wineDiscrTask <- makeClassifTask(data = wineDiscr, target = "Class")
    
    # TUNE K ----
    knnParamSpace <- makeParamSet(makeDiscreteParam("k", values = 1:10))
    gridSearch <- makeTuneControlGrid()
    cvForTuning <- makeResampleDesc("RepCV", folds = 10, reps = 20)
    tunedK <- tuneParams("classif.knn", task = wineDiscrTask,
                         resampling = cvForTuning,
                         par.set = knnParamSpace,
                         control = gridSearch)
    
    knnTuningData <- generateHyperParsEffectData(tunedK)
    plotHyperParsEffect(knnTuningData, x = "k", y = "mmce.test.mean",
                        plot.type = "line") +
        theme_bw()
    # CROSS-VALIDATE MODEL-BUILDING PROCESS ----
    inner <- makeResampleDesc("CV")
    outer <- makeResampleDesc("CV", iters = 10)
    knnWrapper <- makeTuneWrapper("classif.knn", resampling = inner,
                                  par.set = knnParamSpace,
                                  control = gridSearch)
    
    cvWithTuning <- resample(knnWrapper, wineDiscrTask, resampling = outer)
    cvWithTuning
    
    # TRAINING FINAL MODEL WITH TUNED K ----
    tunedKnn <- setHyperPars(makeLearner("classif.knn"), par.vals = tunedK$x)
    
    tunedKnnModel <- train(tunedKnn, wineDiscrTask)
    

第六章. 使用朴素贝叶斯和支持向量机进行分类

本章涵盖

  • 使用朴素贝叶斯算法工作

  • 理解支持向量机算法

  • 使用随机搜索同时调整许多超参数

朴素贝叶斯和支持向量机(SVM)算法是用于分类的监督学习算法。每个算法以不同的方式学习。朴素贝叶斯算法使用贝叶斯定理,这是你在第五章中学到的,来估计新数据属于数据集中某一类别的概率。然后,将案例分配给概率最高的类别。SVM 算法寻找一个超平面(一个维度比预测变量少的表面),将类别分开。这个超平面的位置和方向取决于支持向量:位于类别边界最近的案例。

注意

虽然 SVM 算法常用于分类,但它也可以用于回归问题。这里不会讨论这一点,但如果您感兴趣(并希望更深入地探索 SVM),请参阅 Andreas Christmann 和 Ingo Steinwart 所著的《支持向量机》(Springer,2008 年)。

朴素贝叶斯和 SVM 算法具有不同的特性,使它们在不同的环境下都适用。例如,朴素贝叶斯可以原生地混合连续和分类预测变量,而 SVM 则需要首先将分类变量重新编码为数值格式。另一方面,SVM 在寻找非线性可分类别的决策边界方面表现出色,通过向数据添加一个新维度来揭示线性边界。朴素贝叶斯算法很少会优于在相同问题上训练的 SVM,但朴素贝叶斯在诸如垃圾邮件检测和文本分类等问题上往往表现良好。

使用朴素贝叶斯训练的模型也具有概率解释。对于模型做出预测的每个案例,模型会输出该案例属于某一类别而非另一类别的概率,这为我们提供了预测确定性的度量。这在我们需要进一步审查概率接近 50%的案例的情况下很有用。相反,使用 SVM 算法训练的模型通常不会输出易于解释的概率,但具有几何解释。换句话说,它们将特征空间划分为几个部分,并根据案例所属的部分进行分类。与朴素贝叶斯模型相比,SVM 模型在训练时计算成本更高,因此如果朴素贝叶斯模型在您的问题上表现良好,可能没有必要选择一个训练成本更高的模型。

到本章结束时,你将了解朴素贝叶斯和 SVM 算法是如何工作的,以及如何将它们应用于你的数据。你还将学会如何同时调整多个超参数,因为 SVM 算法有很多这样的超参数。你还将理解如何应用更实用的方法,即使用 随机搜索——而不是我们在第三章中应用的网格搜索——来找到表现最佳的超参数组合。

6.1. 什么是朴素贝叶斯算法?

在上一章中,我向你介绍了贝叶斯定理(以数学家托马斯·贝叶斯的名字命名)。我展示了判别分析算法如何使用贝叶斯定理,根据其判别函数值预测案例属于每个类别的概率。朴素贝叶斯算法以完全相同的方式工作,除了它不像判别分析那样执行降维,并且它可以处理分类变量,以及连续变量。在本节中,我希望通过几个例子传达对贝叶斯定理如何工作的更深入理解。

想象一下,0.2%的人口患有独角兽病(症状包括对闪光的痴迷和强迫性的彩虹绘画)。独角兽病的测试具有 90%的真阳性率(如果你有这种病,测试 90%的时间会检测到它)。当进行测试时,整个人口的 5%从测试中获得阳性结果。基于这些信息,如果你从测试中获得阳性结果,你患有独角兽病的概率是多少?

许多人本能地会说 90%,但这没有考虑到疾病的普遍性和阳性测试的比例(这也包括假阳性)。那么,我们如何估计在阳性测试结果下患病的概率呢?嗯,我们使用贝叶斯定理。让我们提醒一下贝叶斯定理是什么:

图片

其中

  • p(k|x) 是在阳性测试结果(x)下患有疾病(k)的概率。这被称为 后验概率

  • p(x|k) 是如果你确实患有疾病,获得阳性测试结果的概率。这被称为 似然

  • p(k) 是不考虑任何测试的患病概率。这是人群中患病者的比例,被称为 先验概率

  • p(x) 是获得阳性测试结果(包括真阳性假阳性)的概率。这被称为 证据

我们可以用普通英语重写:

图片

因此,我们的似然(如果我们确实有独角兽病,获得阳性测试结果的可能性)是 90%,或者以小数表示为 0.9。我们的先验概率(独角兽病患者的比例)是 0.2%,或者以小数表示为 0.002。最后,我们的证据(获得阳性测试结果的可能性)是 5%,或者以小数表示为 0.05。您可以在图 6.1 中看到所有这些值的说明。现在我们只需将这些值代入贝叶斯定理:

图片

呼吁!在考虑疾病的流行率和测试结果为阳性的比例(包括假阳性)后,阳性测试结果意味着我们实际上患有该病的可能性只有 3.6%——比 90%好得多!这是贝叶斯定理的力量:它允许你结合先验信息,以获得对条件概率(给定数据的概率)的更准确估计。

图 6.1. 使用贝叶斯定理计算在测试结果为阳性时拥有独角兽病的后验概率。先验是患有或未患病的比例。似然是每种疾病状态下获得阳性或阴性测试结果的可能性。证据是获得阳性测试结果(真阳性加上假阳性)的概率。

图片

6.1.1. 使用朴素贝叶斯进行分类

让我们再举一个更侧重于机器学习的例子。想象一下,你有一个来自社交媒体平台 Twitter 的推文数据库,你想建立一个模型,自动将每条推文分类到某个主题。这些主题是

  • 政治

  • 体育

  • 电影

  • 其他

你创建了四个分类预测变量:

  • 是否包含单词opinion

  • 是否包含单词score

  • 是否包含单词game

  • 是否包含单词cinema

注意

我在这个例子中保持内容简单。如果我们真的试图建立一个模型来预测推文主题,我们需要包括比这更多的单词!

对于我们的四个主题中的每一个,我们可以将一个案例属于该主题的概率表示为

图片

现在我们有多个预测变量,p(words|topic)是在该主题下推文具有该确切单词组合的似然性。我们通过找到每个预测变量值的似然性,假设推文属于该主题,并将它们相乘来估计这个值。这看起来是这样的:

图片

例如,如果一个推文包含单词opinionscoregame,但不包含cinema,那么对于任何特定主题,似然性如下:

图片

现在,如果一个推文包含某个特定主题中的某个词,那么这个推文在该主题中的似然率就是包含该词的推文在该主题中的比例。将每个预测变量的似然率相乘,我们得到观察这个组合(这些词的组合)的似然率,前提是它属于特定的类别。

这就是为什么朴素贝叶斯被称为“朴素”。通过单独估计每个预测变量的似然率,然后相乘,我们做出了一个非常强的假设,即预测变量是独立的。换句话说,我们假设一个变量的值与另一个变量的值没有关系。在大多数情况下,这个假设是不成立的。例如,如果一个推文包含单词score,那么它可能也更可能包含单词game

尽管这种朴素假设经常是错误的,但朴素贝叶斯即使在存在非独立预测变量的情况下也往往表现良好。话虽如此,高度相关的预测变量将影响性能。

因此,似然率和先验概率的计算相对简单,是算法学习到的参数;但关于证据(p(words))呢?在实践中,由于预测变量的值通常对数据中的每个案例都是相对独特的,计算证据(观察该值组合的概率)是非常困难的。由于证据实际上只是一个归一化常数,使得所有后验概率之和为 1,我们可以忽略它,只需将似然率和先验概率相乘即可:

  • 后验概率似然率 × 先验概率

注意,我使用∝而不是等号=,表示“成比例于”,因为没有证据来归一化方程,后验概率就不再等于似然率乘以先验概率。尽管如此,这是可以接受的,因为成比例性足以找到最可能的类别。现在,对于每条推文,我们计算每个主题的相对后验概率:

p(politics words) ∝ p(words politics) × p(politics)
p(sports words) ∝ p(words sports) × p(sports)
p(movies words) ∝ p(words movies) × p(movies)
p(other words) ∝ p(words other) × p(other)

然后,我们将推文分配给相对后验概率最高的主题。

6.1.2. 计算分类和连续预测变量的似然率

当我们有一个分类预测变量(例如,一个单词是否存在)时,朴素贝叶斯使用该特定类别中训练案例的该预测变量的比例。当我们有一个连续变量时,朴素贝叶斯(通常)假设每个组内的数据是正态分布的。然后,根据这个拟合的正态分布,每个案例的概率密度被用来估计在该类别中观察到该预测变量值的可能性。这样,接近特定类别正态分布均值的案例将具有该类别的高概率密度,而远离均值的案例将具有低概率密度。这与你在第五章中看到的判别分析在图 5.7 中计算似然性的方式相同。

当你的数据包含分类和连续预测变量的混合时,因为朴素贝叶斯假设数据值之间是独立的,它将简单地使用适当的方法来估计似然性,这取决于每个预测变量是分类的还是连续的。

6.2. 构建您的第一个朴素贝叶斯模型

在本节中,我将向您介绍如何构建和评估一个朴素贝叶斯模型以预测政党归属。想象一下,你是一位政治学家。你正在寻找 20 世纪 80 年代中期的共同投票模式,以预测美国国会议员是民主党人还是共和党人。您拥有 1984 年众议院每位成员的投票记录,并确定了 16 个您认为最能将两个政党区分开的投票。您的任务是训练一个朴素贝叶斯模型,根据议员一整年的投票情况来预测他们是否是民主党人或共和党人。让我们先加载 mlr 和 tidyverse 包:

library(mlr)
library(tidyverse)

6.2.1. 加载和探索 HouseVotes84 数据集

现在,让我们加载数据,该数据内置在 mlbench 包中,将其转换为 tibble(使用as_tibble()),并对其进行探索。

注意

记住,tibble 只是 tidyverse 版本的数据框,它有助于使我们的生活更加轻松。

我们有一个包含 1984 年众议院 435 名成员的 17 个变量的 tibble。Class变量是一个因子,表示政党成员资格,其他 16 个变量是因子,表示个人在每个 16 个投票上的投票情况。y值表示他们投了赞成票,n值表示他们投了反对票,而缺失值(NA)表示个人弃权或未投票。我们的目标是训练一个模型,该模型可以使用这些变量中的信息,根据议员如何投票来预测他们是否是民主党人或共和党人。

列表 6.1. 加载和探索HouseVotes84数据集
data(HouseVotes84, package = "mlbench")

votesTib <- as_tibble(HouseVotes84)

votesTib

# A tibble: 435 x 17
   Class V1    V2    V3    V4    V5    V6    V7    V8    V9    V10
   <fct> <fct> <fct> <fct> <fct> <fct> <fct> <fct> <fct> <fct> <fct>
 1 repu... n     y     n     y     y     y     n     n     n     y
 2 repu... n     y     n     y     y     y     n     n     n     n
 3 demo... NA    y     y     NA    y     y     n     n     n     n
 4 demo... n     y     y     n     NA    y     n     n     n     n
 5 demo... y     y     y     n     y     y     n     n     n     n
 6 demo... n     y     y     n     y     y     n     n     n     n
 7 demo... n     y     n     y     y     y     n     n     n     n
 8 repu... n     y     n     y     y     y     n     n     n     n
 9 repu... n     y     n     y     y     y     n     n     n     n
10 demo... y     y     y     n     n     n     y     y     y     n
# ... with 425 more rows, and 6 more variables: V11 <fct>, V12 <fct>,
#   V13 <fct>, V14 <fct>, V15 <fct>, V16 <fct>
注意

通常我会手动给未命名的列命名,以便更清楚地知道我在处理什么。在这个例子中,变量名称是投票的名称,有点繁琐,所以我们将坚持使用 V1、V2 等等。如果你想查看每个投票是针对什么问题,请运行 ?mlbench::HouseVotes84

看起来我们的 tibble 中有一些缺失值(NA)。让我们使用 map_dbl() 函数总结每个变量中缺失值的数量。回想一下 第二章,map_dbl() 会遍历向量/列表(或在这种情况下,tibble 的每一列)中的每个元素,对该元素应用一个函数,并返回包含函数输出的向量。

map_dbl() 函数的第一个参数是要应用函数的数据名称,第二个参数是我们想要应用的函数。我选择使用匿名函数(使用 ~ 符号作为 function(.) 的简写)。

注意

回想一下 第二章,一个 匿名 函数是我们即时定义的函数,而不是预先定义一个函数并将其分配给一个对象。

我们的功能将每个向量传递给 sum(is.na(.)) 来计算该向量中缺失值的数量。这个函数应用于 tibble 的每一列,并返回每一列的缺失值数量。

列表 6.2. 使用 map_dbl() 函数显示缺失值
map_dbl(votesTib, ~sum(is.na(.)))

Class    V1    V2    V3    V4    V5    V6    V7    V8    V9   V10
    0    12    48    11    11    15    11    14    15    22     7
  V11   V12   V13   V14   V15   V16
   21    31    25    17    28   104

除了 Class 变量外,我们 tibble 中的每一列都有缺失值!幸运的是,朴素贝叶斯算法可以以两种方式处理缺失数据:

  • 通过省略特定情况下的缺失值变量,但仍然使用该情况来训练模型

  • 通过完全从训练集中省略该情况

默认情况下,mlr 使用的朴素贝叶斯实现是保留案例并删除变量。如果大多数案例中缺失值与完整值的比例相当小,这通常工作得很好。然而,如果你有少量变量并且缺失值的比例很大,你可能希望省略这些案例(并且更广泛地考虑你的数据集是否足够用于训练)。

练习 1

使用我们在 列表 6.2 中使用的 map_dbl() 函数来统计 votesTib 每一列中 y 值的数量。提示:使用 which(. == "y") 来返回每一列中等于 y 的行。

6.2.2. 绘制数据

让我们绘制我们的数据,以更好地理解政治党和投票之间的关系。再一次,我们将使用我们的技巧将数据收集到一个不整洁的格式中,这样我们就可以在预测变量上分面。因为我们正在绘制相互之间的分类变量,我们将 geom_bar() 函数的 position 参数设置为 "fill",这为 ynNA 响应创建了堆叠条形图,它们的总和为 1。

列表 6.3. 绘制 HouseVotes84 数据集
votesUntidy <- gather(votesTib, "Variable", "Value", -Class)

ggplot(votesUntidy, aes(Class, fill = Value)) +
  facet_wrap(~ Variable, scales = "free_y") +
  geom_bar(position = "fill") +
  theme_bw()

结果图显示在 图 6.2。我们可以看到,民主党和共和党之间存在着一些非常明显的意见差异!

图 6.2. 填充条形图显示了在 16 次不同投票中民主党人和共和党人投票支持 (y)、反对 (n) 或弃权 (NA) 的比例。

6.2.3. 训练模型

现在,让我们创建我们的任务和学习者,并构建我们的模型。我们将 Class 变量设置为 makeClassifTask() 函数的分类目标,并将我们提供给 makeLearner() 函数的算法设置为 "classif.naiveBayes"

列表 6.4. 创建任务和学习者,并训练模型
votesTask <- makeClassifTask(data = votesTib, target = "Class")

bayes <- makeLearner("classif.naiveBayes")

bayesModel <- train(bayes, votesTask)

模型训练完成后没有错误,因为朴素贝叶斯可以处理缺失数据。

接下来,我们将使用重复 50 次的 10 折交叉验证来评估我们的模型构建过程的性能。同样,因为这是一个双类分类问题,我们可以访问假阳性率和假阴性率,所以我们也在 resample() 函数的 measures 参数中请求这些。

列表 6.5. 交叉验证朴素贝叶斯模型
kFold <- makeResampleDesc(method = "RepCV", folds = 10, reps = 50,
                          stratify = TRUE)

bayesCV <- resample(learner = bayes, task = votesTask,
                    resampling = kFold,
                    measures = list(mmce, acc, fpr, fnr))

bayesCV$aggr

mmce.test.mean  acc.test.mean  fpr.test.mean  fnr.test.mean
    0.09820658     0.90179342     0.08223529     0.10819658

在我们的交叉验证中,我们的模型正确预测了 90% 的测试集案例。这还不错!现在让我们使用我们的模型来预测新政治家的政党,基于他们的投票。

列表 6.6. 使用模型进行预测
politician <- tibble(V1 = "n", V2 = "n", V3 = "y", V4 = "n", V5 = "n",
                     V6 = "y", V7 = "y", V8 = "y", V9 = "y", V10 = "y",
                     V11 = "n", V12 = "y", V13 = "n", V14 = "n",
                     V15 = "y", V16 = "n")

politicianPred <- predict(bayesModel, newdata = politician)

getPredictionResponse(politicianPred)

[1] democrat
Levels: democrat republican
[source]

我们的模型预测这位新政治家是民主党人。

练习 2

将您的朴素贝叶斯模型包裹在 getLearnerModel() 函数中。您能识别出每个投票的先验概率和似然吗?

6.3. 朴素贝叶斯的优缺点

虽然通常很难判断哪些算法会对给定的任务表现良好,但以下是一些优势和劣势,这将帮助您决定朴素贝叶斯是否会对您的任务表现良好。

朴素贝叶斯的优点如下:

  • 它可以处理连续和分类预测变量。

  • 训练它计算上并不昂贵。

  • 它通常在基于包含的单词对文档进行分类的主题分类问题上表现良好。

  • 它没有需要调整的超参数。

  • 它是概率性的,并输出新数据属于每个类别的概率。

  • 它可以处理缺失数据的情况。

朴素贝叶斯的劣势如下:

  • 它假设连续预测变量是正态分布的(通常是),如果它们不是,性能将受到影响。

  • 它假设预测变量之间相互独立,这通常并不真实。如果这个假设被严重违反,性能将受到影响。

6.4. 什么是支持向量机 (SVM) 算法?

在本节中,你将了解 SVM 算法的工作原理以及它如何为数据添加一个额外的维度,使其类别线性可分。想象一下,你想要预测你的老板是否会心情愉快(这是一个非常重要的机器学习应用)。在接下来的几周里,你记录了你在办公桌前玩游戏的小时数以及你每天为公司赚多少钱。你还记录了第二天老板的心情,是好的还是坏的(他们非常二元)。你决定使用 SVM 算法构建一个分类器,帮助你决定是否需要在某一天避免见到你的老板。SVM 算法将学习一个线性超平面,将老板心情好的日子与心情不好的日子分开。SVM 算法还能够为数据添加一个额外的维度,以找到最佳的超平面。

6.4.1. 线性可分数据的 SVM

看一下 图 6.3 中显示的数据。这些图显示了基于你工作努力程度和为公司赚多少钱,你记录的老板心情数据。

SVM 算法找到一个最优的线性超平面,将类别分开。超平面是一个比数据集中的变量少一个维度的表面。对于二维特征空间,例如 图 6.3 中的例子,超平面就是一个简单的直线。对于三维特征空间,超平面是一个表面。在四维或更高维度的特征空间中,超平面很难想象,但原理是相同的:它们是穿过特征空间的表面。

图 6.3. SVM 算法找到一个通过特征空间的超平面(实线)。一个最优的超平面是最大化其周围边界的超平面(虚线)。边界是一个围绕超平面的区域,接触到的案例最少。支持向量用双圆圈表示。

图 6.3

对于类别完全线性可分的问题,可能存在许多不同的超平面,它们在训练数据中分离类别的效果一样好。为了找到一个最优的超平面(希望它能更好地泛化到未见过的数据),算法找到最大化其周围 边界 的超平面。边界是围绕超平面的一个距离,接触到的训练案例最少。接触边界的案例被称为 支持向量,因为它们支撑着超平面的位置(因此,算法的名称)。

支持向量是训练集中最重要的案例,因为它们定义了类之间的边界。不仅如此,算法学习的超平面完全依赖于支持向量的位置,而训练集中的其他案例则不然。看看图 6.4。如果我们移动其中一个支持向量的位置,那么超平面的位置也会移动。然而,如果我们移动一个非支持向量案例,对超平面没有任何影响!

图 6.4。超平面的位置完全取决于支持向量的位置。移动一个支持向量会将超平面从其原始位置(虚线)移动到新的位置(顶部两个图)。移动一个非支持向量对超平面没有任何影响(底部两个图)。

SVMs 目前非常受欢迎。这主要基于三个原因:

  • 它们擅长找到分离非线性可分类的方法。

  • 它们在各种任务中表现良好。

  • 我们现在有了足够的计算能力来处理更大、更复杂的数据集。

最后一点很重要,因为它突显了 SVMs 的一个潜在缺点:它们在训练时往往比许多其他分类算法更耗费计算资源。因此,如果你有一个非常大的数据集,且计算能力有限,尝试更便宜的算法并观察其表现可能对你来说更经济。

小贴士

通常,我们更倾向于预测性能而非速度。但一个计算成本较低且足以解决你问题的算法,可能比你选择的昂贵算法更受欢迎。因此,我可能会在尝试昂贵算法之前先尝试更便宜的算法。

SVM 算法是如何找到最优超平面的?

支持 SVM 工作原理的数学原理很复杂,但如果你感兴趣,这里有一些关于如何学习超平面的基础知识。回想一下第四章,一条直线的方程可以写成 y = ax + b,其中 ab 分别是直线的斜率和 y 截距。我们可以通过将所有项移到等号的一侧来重新排列这个方程,使其成为 yaxb = 0。使用这种公式,我们可以说任何落在直线上的点都满足这个方程(表达式将等于零)。

你经常会看到超平面的方程被表示为 wx + b = 0,其中 w 是向量(–ba 1),x 是向量(1 x y),而 b 仍然是截距。与任何位于直线上的点都满足 yaxb = 0 的方式相同,任何位于超平面上的点都满足方程 wx + b = 0。

向量 w 与超平面正交或垂直。因此,通过改变截距 b,我们可以创建与原始超平面平行的新的超平面。通过改变 b(以及重新缩放 w),我们可以任意定义标记间隔为 wx + b = –1 和 wx + b = +1 的超平面。这些间隔之间的距离由 2/||w|| 给出,其中 ||w|| 是 图片。由于我们想要找到最大化这个距离的超平面,我们需要在确保每个案例被正确分类的同时最小化 ||w||。算法通过确保一个类别的所有案例都位于 wx + b = –1 下方,而另一个类别的所有案例都位于 wx + b = +1 之上来实现这一点。一种简单的方法是将每个案例的预测值乘以其相应的标签(–1 或 +1),使所有输出都为正。这创建了间隔必须满足的约束 y[i](wx[i] + b) ≥ 1。因此,SVM 算法试图解决以下最小化问题:

  • 最小化 ||w||,同时满足 y[i](wx[i] + b) ≥ 1 对于 i = 1 ... N

6.4.2. 如果类别不是完全可分的,会怎样?

在我之前向你展示的示例中,类别是完全可分的。这样做是为了清楚地向你展示如何选择超平面的位置以最大化间隔。但是,当类别不是完全可分时怎么办?当没有间隔时,算法如何找到超平面?其中会有案例位于间隔内部。

SVMs 的原始公式使用通常被称为硬间隔的方法。如果一个 SVM 使用硬间隔,则不允许任何案例落在间隔内。这意味着,如果类别不是完全可分的,算法将失败。这当然是一个大问题,因为它将硬间隔 SVM限制在只能处理“简单”的分类问题,其中训练集可以清楚地划分为其组成部分类别。因此,一个称为软间隔 SVM的 SVM 算法的扩展被更广泛地使用。在软间隔 SVM 中,算法仍然学习一个最佳地分离类别的超平面,但它允许案例落在其间隔内。

软间隔支持向量机(SVM)算法仍然试图找到最佳的超平面来分离类别,但它会因为在其边界内有案例而受到惩罚。案例在边界内受到的惩罚程度由一个超参数控制,该超参数控制边界的“硬”或“软”程度(我们将在本章后面讨论这个超参数及其对超平面位置的影响)。边界越硬,内部案例越少;超平面将依赖于更少的支持向量。边界越软,内部案例越多;超平面将依赖于更多的支持向量。这对偏差-方差权衡有影响:如果我们的边界太硬,我们可能会在决策边界附近的噪声上过度拟合,而如果我们的边界太软,我们可能会欠拟合数据,并学习到一个在分离类别方面做得不好的决策边界。

6.4.3. 用于非线性可分数据的 SVM

太好了!到目前为止,SVM 算法看起来相当简单——对于像我们老板情绪示例中的线性可分类别,它确实是这样的。但我提到 SVM 算法的一个优点是它可以学习非线性可分类别之间的决策边界。我已经告诉你该算法学习线性超平面,这似乎是一种矛盾。好吧,这就是 SVM 算法之所以强大的原因:它可以为你的数据添加一个额外的维度,以找到一种线性方式来分离非线性数据。

图 6.5. SVM 算法为线性分离数据添加了一个额外的维度。原始数据中的类别无法线性分离。SVM 算法添加了一个额外的维度,在二维特征空间中,可以将其表示为数据的“拉伸”到第三维度。这个额外的维度允许数据被线性分离。当这个超平面投影回原始的两个维度时,它看起来像是一个弯曲的决策边界。

看一下图 6.5 中的示例。使用两个预测变量无法线性分离类别。SVM 算法为数据添加了一个额外的维度,使得在这个新的、更高维的空间中,线性超平面可以分离类别。我们可以将这视为特征空间的一种变形或拉伸。这个额外的维度被称为核函数

注意

回想一下第五章中的内容,判别分析将预测变量的信息压缩成更少的变量。这与 SVM 算法形成对比,SVM 算法将预测变量的信息扩展到一个额外的变量!

| |

为什么叫核函数?

词语核函数可能会让你感到困惑(它确实让我感到困惑)。它与计算机中的核函数(直接与计算机硬件接口的操作系统的一部分)或玉米或水果中的核没有关系。

事实上,它们被称为核的原因并不明确。1904 年,一位名叫 David Hilbert 的德国数学家发表了《Grundzüge einer allgemeinen theorie der linearen integralgleichungen》(线性积分方程的一般理论原理)。在这本书中,Hilbert 使用kern这个词来表示积分方程的核心。1909 年,一位名叫 Maxime Bôcher 的美国数学家发表了《An introduction to the study of integral equations》,其中他将 Hilbert 对kern的使用翻译成了kernel

核函数的数学是从这些出版物的工作中演变而来的,并且带着这个名字。令人极其困惑的是,数学中包含这个词的多个看似无关的概念!

算法是如何找到这个新核的?它使用了一种称为核函数的数据数学变换。有许多核函数可供选择,每个核函数都对数据进行不同的变换,适用于找到不同情况下的线性决策边界。图 6.6 显示了某些常见核函数可以分离非线性可分数据的示例:

  • 线性核(相当于没有核)

  • 多项式核

  • 高斯径向基核

  • Sigmoid 核

对于给定问题的核函数类型并不是从数据中学习得到的——我们必须指定它。正因为如此,核函数的选择是一个分类超参数(一个取离散值而不是连续值的超参数)。因此,选择最佳性能核函数的最佳方法是进行超参数调整。

图 6.6. 核函数的示例。对于每个示例,实线表示决策边界(投影回原始特征空间),虚线表示边界。除了线性核外,想象一下其中一个组的一个案例在第三维中被抬离页面。

图片

6.4.4. SVM 算法的超参数

这取决于你的问题、计算预算和幽默感,SVM 变得有趣/困难/痛苦。在构建 SVM 时,我们需要调整相当多的超参数。这,加上训练单个模型可能相当昂贵的事实,可能会使训练一个最优性能的 SVM 花费相当长的时间。你将在第 6.5.2 节的工作示例中看到这一点。

因此,SVM 算法有很多超参数需要调整,但最重要的考虑因素如下:

  • 超参数(如图 6.6 所示)

  • 超参数,它控制多项式核的决策边界将有多“弯曲”(如图 6.6 所示)

  • 成本C超参数,它控制边界有多“硬”或“软”(如图 6.7 所示)

  • gamma超参数控制单个案例对决策边界位置的影响程度(如图 6.7 所示)

核函数和degree超参数的影响在图 6.6 中展示。注意二次和三次多项式决策边界的形状差异。

注意

多项式的度数越高,学习的决策边界可以越弯曲和复杂,但这可能导致过拟合训练集。

软间隔 SVM 中的cost(也称为C)超参数将成本或惩罚分配给边缘内的案例,或者换句话说,告诉算法边缘内案例有多糟糕。低成本告诉算法边缘内可以有更多案例,这将导致更宽的间隔,受类别边界附近局部差异的影响较小。高成本对边缘内案例施加更严厉的惩罚,将导致更窄的间隔,受类别边界附近局部差异的影响较大。cost的影响在图 6.6 的顶部部分展示。

注意

边缘内的案例也是支持向量,因为移动它们会改变超平面的位置。

gamma超参数控制每个案例对超平面位置的影响,除了线性核以外的所有核函数都会使用它。想象训练集中的每个案例都在上下跳跃,大声喊道,“我!我!正确分类我!”gamma值越大,每个案例越想吸引注意,决策边界将越细粒度(可能导致过拟合)。gamma值越小,每个案例越不吸引注意,决策边界将越粗粒度(可能导致欠拟合)。gamma的影响在图 6.7 的底部部分展示。

图 6.7. 成本和gamma超参数的影响。成本超参数的值越大,对边缘内案例的惩罚越重。gamma超参数的值越大,意味着单个案例对决策边界的位置影响越大,导致决策边界更加复杂。

因此,SVM 算法有多个超参数需要调整!我将向您展示如何使用 mlr 同时调整这些超参数,请参阅第 6.5.2 节。

6.4.5. 如果我们有多于两个类别怎么办?

到目前为止,我只展示了二元分类问题的例子。这是因为 SVM 算法本质上是为了分离两个类别而设计的。但我们能否用它来解决多类问题(我们试图预测多于两个类别的情况)?当然可以!当有多个类别时,我们不是创建一个单一的 SVM,而是创建多个模型,并让它们竞争以预测新数据的可能性最大的类别。有两种方法可以实现这一点:

  • 一对一对抗所有

  • 一对一

在一对一(也称为一对一对抗所有)方法中,我们创建与类别数量相等的 SVM 模型。每个 SVM 模型描述了一个最佳的超平面,该超平面能够将一个类别与所有其他类别分开。因此得名一对一对抗所有。当我们对新的、未见过的案例进行分类时,模型会玩一场“赢家通吃”的游戏。简单来说,将新案例放在其超平面的“正确”一侧(与所有其他类别分开的那一侧)的模型获胜。然后,该案例被分配给模型试图与之分开的类别。这如图 6.8 中的左侧所示。

图 6.8. 多类 SVM 的一对一和一对一方法。在一对一方法中,为每个类别学习一个超平面,将其与其他所有案例分开。在一对一方法中,为每一对类别学习一个超平面,在忽略其他类别的数据的同时将它们分开。

图 6-8

在一对一方法中,我们为每一对类别创建一个 SVM 模型。每个 SVM 模型描述了一个最佳的超平面,该超平面能够将一个类别与另一个类别分开,而忽略其他类别的数据。因此,得名一对一。当我们对新的、未见过的案例进行分类时,每个模型都会投一票。例如,如果一个模型将类别 A 和 B 分开,并且新的数据落在决策边界的 B 侧,那么这个模型将投票给 B。这个过程会持续对所有模型进行,多数类别的投票获胜。这如图 6.8 中的右侧所示。

我们应该选择哪一种?实际上,两种方法的性能通常没有太大差异。尽管训练了更多的模型(对于多于三个类别),但一对一有时比一对一对抗所有在计算上更节省。这是因为,尽管我们训练了更多的模型,但训练集更小(因为忽略了案例)。mlr 调用的 SVM 算法实现使用一对一方法。

然而,这些方法存在一个问题。特征空间中往往会有一些区域,其中没有任何一个模型给出明确的胜出类别。你能在图 6.8 中看到超平面之间的三角形空间吗?如果一个新的案例出现在这个三角形内部,三个模型中的任何一个都不会明显胜出。这是一种分类的无人区。虽然在图 6.8 中不是很明显,这种情况也出现在单对一方法中。

如果在预测新案例时没有明确的胜者,将使用一种称为Platt 缩放的技术(以计算机科学家约翰·Platt 命名)。Platt 缩放法将每个案例到每个超平面的距离转换为概率,使用逻辑函数。回想一下第四章中提到的逻辑函数将连续变量映射到 0 到 1 之间的概率。使用 Platt 缩放法进行预测的过程如下:

  1. 对于每个超平面(无论我们使用单对多还是单对一):

    1. 测量每个案例到超平面的距离。

    2. 使用逻辑函数将这些距离转换为概率。

  2. 将新数据分类为具有最高概率的超平面所属的类别。

如果这看起来很困惑,请查看图 6.9。图中我们采用了单对多方法,并生成了三个独立的超平面(每个超平面用于将每个类别与其它类别分开)。图中的虚线箭头表示距离,即从超平面出发的任意方向的距离。Platt 缩放法使用逻辑函数将这些距离转换为概率(每个超平面将其它类别分开时具有正距离)。

当我们分类新的、未见过的数据时,新数据的距离会使用三个 S 形曲线中的每一个转换为概率,并将案例分类为给出最高概率的那个。方便的是,所有这些在 mlr 中调用 SVM 的实现时都为我们处理好了。如果我们提供一个三分类任务,我们将得到一个带有 Platt 缩放的单对一 SVM 模型,而无需更改我们的代码。

图 6.9。如何使用 Platt 缩放法为每个超平面获取概率。这个例子展示了单对多方法(它也适用于单对一)。对于每个超平面,记录每个案例到超平面的距离(由双头箭头表示)。这些距离使用逻辑函数转换为概率。

图片 6-9

6.5. 构建你的第一个 SVM 模型

在本节中,我将教你如何构建 SVM 模型并同时调整多个超参数。想象一下,你厌倦了收到如此多的垃圾邮件(也许你不需要想象!)!由于你收到了许多要求你提供银行详情以获取神秘的乌干达遗产的邮件,以及试图向你推销伟哥的邮件,你很难保持生产力。

你决定对你收到的几个月的电子邮件进行特征提取,这些电子邮件你手动分类为垃圾邮件或非垃圾邮件。这些特征包括感叹号的数量和某些单词的频率等。有了这些数据,你想要制作一个 SVM,可以用作垃圾邮件过滤器,将新电子邮件分类为垃圾邮件或非垃圾邮件。

在本节中,你将学习如何训练一个 SVM 模型并同时调整多个超参数。让我们首先加载 mlr 和 tidyverse 包:

library(mlr)
library(tidyverse)

6.5.1. 加载和探索垃圾邮件数据集

现在我们来加载数据,这些数据是 kernlab 包内置的,将其转换为 tibble(使用 as_tibble()),并对其进行探索。

注意

kernlab 包应该作为 mlr 的建议包一起安装。如果在尝试加载数据时遇到错误,你可能需要使用 install.packages("kernlab") 来安装它。

我们有一个包含 4,601 封电子邮件和 58 个从电子邮件中提取的变量的 tibble。我们的目标是训练一个模型,该模型可以使用这些变量中的信息来预测新电子邮件是否为垃圾邮件。

注意

除了表示电子邮件是否为垃圾邮件的因子 type 之外,所有变量都是连续的,因为 SVM 算法无法处理分类预测变量。

列表 6.7. 加载和探索垃圾邮件数据集
data(spam, package = "kernlab")

spamTib <- as_tibble(spam)

spamTib

# A tibble: 4,601 x 58
    make address   all num3d   our  over remove internet order  mail
   <dbl>   <dbl> <dbl> <dbl> <dbl> <dbl>  <dbl>    <dbl> <dbl> <dbl>
 1  0       0.64  0.64     0  0.32  0      0        0     0     0
 2  0.21    0.28  0.5      0  0.14  0.28   0.21     0.07  0     0.94
 3  0.06    0     0.71     0  1.23  0.19   0.19     0.12  0.64  0.25
 4  0       0     0        0  0.63  0      0.31     0.63  0.31  0.63
 5  0       0     0        0  0.63  0      0.31     0.63  0.31  0.63
 6  0       0     0        0  1.85  0      0        1.85  0     0
 7  0       0     0        0  1.92  0      0        0     0     0.64
 8  0       0     0        0  1.88  0      0        1.88  0     0
 9  0.15    0     0.46     0  0.61  0      0.3      0     0.92  0.76
10  0.06    0.12  0.77     0  0.19  0.32   0.38     0     0.06  0
# ... with 4,591 more rows, and 48 more variables...
小贴士

这个数据集有很多特征!我不会讨论每个特征的含义,但你可以通过运行 ?kernlab::spam 来查看它们的描述。

6.5.2. 调整我们的超参数

让我们定义我们的任务和学习器。这次,我们将 "classif.svm" 作为 makeLearner() 的参数传递,以指定我们将使用 SVM。

列表 6.8. 创建任务和学习器
spamTask <- makeClassifTask(data = spamTib, target = "type")

svm <- makeLearner("classif.svm")

在我们训练模型之前,我们需要调整我们的超参数。要找出算法中可用于调整的超参数,我们只需将算法的名称用引号传递给 getParamSet()。例如,列表 6.9 展示了如何打印 SVM 算法的超参数。我已经删除了一些输出行和列以使其适合,但最重要的列都在那里:

  • 行名是超参数的名称。

  • Type 表示超参数是取数值、整数、离散值还是逻辑值。

  • Def 是默认值(如果你不调整超参数,将使用的值)。

  • Constr 定义了超参数的约束:要么是一组特定值,要么是可接受值的范围。

  • Req 定义了超参数是否被学习器所必需。

  • Tunable 是逻辑值,表示该超参数是否可以被调整(某些算法有用户可以设置但不能调整的选项)。

列表 6.9. 打印可用的 SVM 超参数
getParamSet("classif.svm")

                  Type     Def              Constr  Req  Tunable
cost           numeric       1            0 to Inf    Y     TRUE
kernel        discrete  radial  [lin,poly,rad,sig]    -     TRUE
degree         integer       3            1 to Inf    Y     TRUE
gamma          numeric       -            0 to Inf    Y     TRUE
scale    logicalvector    TRUE                   -    -     TRUE

SVM 算法对变量处于不同尺度敏感,因此通常在首先缩放预测变量是一个好主意。注意缩放超参数:它告诉我们算法将默认为我们缩放数据。

提取超参数的可能值

虽然 getParamSet() 函数很有用,但我发现从它中提取信息并不特别简单。如果你调用 str(getParamSet("classif.svm")),你会看到它有一个相当复杂的结构。

要提取有关特定超参数的信息,你需要调用 getParamSet("classif.svm")$pars$[HYPERPAR](其中 [HYPERPAR] 被你感兴趣的超参数所替换)。要提取该超参数的可能值,你需要在调用中附加 $values。例如,以下提取了可能的核函数:

getParamSet("classif.svm")$pars$kernel$values

$linear
[1] "linear"
$polynomial
[1] "polynomial"

$radial
[1] "radial"

$sigmoid
[1] "sigmoid"

这些是我们需要调整的最重要超参数:

  • 成本

  • 度数

  • Gamma

列表 6.10 定义了我们想要调整的超参数。我们将从定义一个我们希望调整的核函数向量开始。

小贴士

注意,我省略了线性核。这是因为线性核与 度数 = 1 的多项式核相同,所以我们将确保将 1 作为 度数 超参数的可能值之一。包括线性核和一阶多项式核纯粹是浪费计算时间。

接下来,我们使用 makeParamSet() 函数来定义我们希望调整的超参数空间。我们将定义我们希望调整的每个超参数所需的信息提供给 makeParamSet() 函数,用逗号分隔。让我们逐行分析:

  • 超参数取离散值(核函数的名称),因此我们使用 makeDiscreteParam() 函数来定义其值为我们所创建的核函数向量。

  • 度数 超参数取整数值(整数),因此我们使用 makeIntegerParam() 函数并定义我们希望调整的下限和上限值。

  • 成本gamma 超参数取数值(零和无穷大之间的任何数),因此我们使用 makeNumericParam() 函数来定义我们希望调整的下限和上限值。

对于这些函数中的每一个,第一个参数是由 getParamSet("classif.svm") 给出的超参数名称,用引号括起来。

列表 6.10. 定义调整超参数的空间
kernels <- c("polynomial", "radial", "sigmoid")

svmParamSpace <- makeParamSet(
  makeDiscreteParam("kernel", values = kernels),
  makeIntegerParam("degree", lower = 1, upper = 3),
  makeNumericParam("cost", lower = 0.1, upper = 10),
  makeNumericParam("gamma", lower = 0.1, 10))

想想 第三章,当我们为 kNN 算法调整 k 时。我们在调整过程中使用了网格搜索过程来尝试我们定义的 k 的每一个值。这就是网格搜索方法所做的事情:它尝试你定义的超参数空间中的每一个组合,并找到性能最佳的组合。

网格搜索非常出色,因为只要你指定一个合理的超参数搜索空间,它总是会找到最佳性能的超参数。但看看我们为我们的 SVM 定义的超参数空间。假设我们想要尝试从 0.1 到 10 的costgamma超参数的值,步长为 0.1(这意味着每个参数有 100 个值)。我们正在尝试三种核函数和degree超参数的三个值。要在这样的参数空间上进行网格搜索,需要训练模型 90,000 次!在这种情况下,如果你有足够的时间、耐心和计算预算来进行这样的网格搜索,那么恭喜你。至少对我来说,我可以用我的电脑做更有意义的事情!

相反,我们可以采用一种称为随机搜索的技术。随机搜索不是尝试所有可能的参数组合,而是按照以下步骤进行:

  1. 随机选择一个超参数值组合。

  2. 使用交叉验证来训练和评估使用这些超参数值的模型。

  3. 记录模型的性能指标(通常对于分类任务是平均误分类错误)。

  4. 重复(迭代)步骤 1 到 3,直到你的计算预算允许。

  5. 选择给你带来最佳性能模型的超参数值组合。

与网格搜索不同,随机搜索并不能保证找到最佳的超参数值组合。然而,随着迭代次数的增加,它通常可以找到一个表现良好的组合。通过使用随机搜索,我们可以运行 500 个超参数值的组合,而不是全部 90,000 个组合。

让我们使用makeTuneControlRandom()函数来定义我们的随机搜索。我们使用maxit参数来告诉函数我们想要使用多少次随机搜索过程的迭代。你应该尽量设置这个值,但要考虑到你的计算预算,在这个例子中,我们将坚持使用 20 次以防止示例运行时间过长。接下来,我们将描述我们的交叉验证过程。记得我在第三章中提到,除非过程计算成本高昂,否则我更喜欢 k 折交叉验证。但是,这个过程计算成本很高,所以我们妥协使用保留交叉验证。

列表 6.11. 定义随机搜索
randSearch <- makeTuneControlRandom(maxit = 20)

cvForTuning <- makeResampleDesc("Holdout", split = 2/3)

我们还可以采取其他措施来加快这一过程。R 语言作为一种语言,并不大量使用多线程(同时使用多个 CPU 来完成任务)。然而,mlr 包的一个好处是它允许在函数中使用多线程。这可以帮助你使用计算机上的多个核心/CPU 来更快地完成超参数调整和交叉验证等任务。

小贴士

如果你不知道你的计算机有多少个核心,你可以在 R 中运行parallel::detectCores()来找出(如果你的计算机只有一个核心,90 秒后——他们想要他们的电脑回来)。

要并行运行一个 mlr 过程,我们将它的代码放在 parallelMap 包中的parallelStartSocket()parallelStop()函数之间。为了开始我们的超参数调整过程,我们调用tuneParams()函数并传递以下参数:

  • 第一个参数 = 学习者的名称

  • task = 我们任务的名称

  • resampling = 交叉验证过程(定义在列表 6.11)

  • par.set = 超参数空间(定义在列表 6.10)

  • control = 搜索过程(随机搜索,定义在列表 6.11)

parallelStartSocket()parallelStop()函数之间的代码显示在列表 6.12 中。请注意,并行运行交叉验证过程的缺点是我们不再能获得进度更新的实时信息。

警告

我编写这段代码的电脑有四个核心,这段代码在这台电脑上运行几乎需要一分钟。在它运行的时候,你最好去泡一杯茶。请加牛奶,不加糖。

列表 6.12. 执行超参数调整
library(parallelMap)
library(parallel)

parallelStartSocket(cpus = detectCores())

tunedSvmPars <- tuneParams("classif.svm", task = spamTask,
                     resampling = cvForTuning,
                     par.set = svmParamSpace,
                     control = randSearch)

parallelStop()
小贴士

degree 超参数仅适用于多项式核函数,而gamma超参数不适用于线性核。这会在随机搜索选择不合理的组合时创建错误吗?不。如果随机搜索选择了 sigmoid 核,例如,它将简单地忽略degree超参数的值。

在我们的插曲之后欢迎回来!你可以通过调用tunedSvm来打印最佳性能的超参数值和用它们构建的模型的性能,或者通过调用tunedSvm$x来提取仅命名的值(这样你可以使用它们来训练一个新的模型)。查看以下列表,我们可以看到一阶多项式核函数(相当于线性核函数)给出了性能最佳的模型,其成本为 5.8,gamma 为 1.56。

列表 6.13. 从调整中提取获胜的超参数值
tunedSvmPars

Tune result:
Op. pars: kernel=polynomial; degree=1; cost=5.82; gamma=1.56
mmce.test.mean=0.0645372

tunedSvmPars$x
$kernel
[1] "polynomial"

$degree
[1] 1

$cost
[1] 5.816232

$gamma
[1] 1.561584

你的值可能和我不同。这就是随机搜索的本质:每次运行时,它可能会找到不同的超参数值的获胜组合。为了减少这种差异,我们应该承诺增加搜索的迭代次数。

6.5.3. 使用调整后的超参数训练模型

现在我们已经调整了超参数,让我们使用表现最好的组合来构建我们的模型。回想一下第三章,我们使用setHyperPars()函数将一个学习者和一组预定义的超参数值结合起来。第一个参数是我们想要使用的学习者,而par.vals参数是包含我们调整后的超参数值的对象。然后我们使用train()函数和我们的tunedSvm学习者来训练一个模型。

列表 6.14. 使用调整后的超参数训练模型
tunedSvm <- setHyperPars(makeLearner("classif.svm"),
                         par.vals = tunedSvmPars$x)

tunedSvmModel <- train(tunedSvm, spamTask)
小贴士

由于我们已经在列表 6.8 中定义了我们的学习器,我们可以简单地运行 setHyperPars(svm, par.vals = tunedSvmPars$x) 来达到相同的结果。

6.6. 交叉验证我们的 SVM 模型

我们已经使用调整过的超参数构建了一个模型。在本节中,我们将交叉验证该模型以估计它在新的、未见过的数据上的表现。

从第三章回忆一下,交叉验证 整个模型构建过程 是很重要的。这意味着我们模型构建过程中的任何 数据相关 步骤(例如超参数调整)都需要包含在我们的交叉验证中。如果我们不包括它们,我们的交叉验证很可能会给出一个过于乐观的估计(一个 有偏 估计)关于模型将如何表现。

小贴士

在模型构建中,什么算作一个数据-独立步骤?例如,手动删除无意义变量、更改变量名称和类型,以及用 NA 替换缺失值代码。这些步骤是数据独立的,因为无论数据中的值如何,它们都会是相同的。

还要记住,为了将超参数调整纳入我们的交叉验证,我们需要使用一个 包装函数,该函数将我们的学习器和超参数调整过程包装在一起。交叉验证过程在列表 6.15 中展示。

由于 mlr 将使用嵌套交叉验证(其中超参数调整在内循环中执行,获胜的值组合传递到外循环),我们首先使用 makeResamplDesc() 函数定义我们的外部交叉验证策略。在这个例子中,我选择了外循环的 3 折交叉验证。对于内循环,我们将使用在列表 6.11 中定义的 cvForTuning 交叉验证描述(保留 2/3 的数据作为验证集)。

接下来,我们使用 makeTuneWrapper() 函数构建我们的包装学习器。参数如下:

  • 第一个参数 = 学习器的名称

  • resampling = 内循环交叉验证策略

  • par.set = 超参数空间(在列表 6.10 中定义)

  • control = 搜索过程(在列表 6.11 中定义)

由于交叉验证需要一段时间,我们明智地使用 parallelStartSocket() 函数开始并行化。现在,为了运行我们的嵌套交叉验证,我们调用 resample() 函数,其中第一个参数是我们的包装学习器,第二个参数是我们的任务,第三个参数是我们的外部交叉验证策略。

警告

这在我的四核计算机上需要超过一分钟的时间。与此同时,你知道该做什么。请加牛奶不加糖。你有蛋糕吗?

列表 6.15. 模型构建过程的交叉验证
outer <- makeResampleDesc("CV", iters = 3)

svmWrapper <- makeTuneWrapper("classif.svm", resampling = cvForTuning,
                              par.set = svmParamSpace,
                              control = randSearch)

parallelStartSocket(cpus = detectCores())

cvWithTuning <- resample(svmWrapper, spamTask, resampling = outer)

parallelStop()

现在我们通过打印 cvWithTuning 对象的内容来查看我们的交叉验证过程的结果。

列表 6.16. 提取交叉验证结果
cvWithTuning

Resample Result
Task: spamTib
Learner: classif.svm.tuned
Aggr perf: mmce.test.mean=0.0988956
Runtime: 73.89

我们正确地将 1 - 0.099 = 0.901 = 90.1%的电子邮件分类为垃圾邮件或非垃圾邮件。对于第一次尝试来说,还不错!

6.7. SVM 算法的优点和缺点

虽然通常不容易判断哪些算法会对给定的任务表现良好,但以下是一些优点和缺点,这将帮助您决定 SVM 算法是否会在您的案例中表现良好。

SVM 算法的优点如下:

  • 它非常擅长学习复杂的非线性决策边界。

  • 它在各种任务上表现都非常出色。

  • 它不对预测变量的分布做出假设。

SVM 算法的缺点如下:

  • 它是训练成本最高的算法之一。

  • 它具有多个需要同时调整的超参数。

  • 它只能处理连续的自变量(尽管在某些情况下将分类变量编码为数值可能有所帮助)。

练习 3

votesTib中的NA值包括政治家弃权的情况,因此将这些值重新编码为值"a",并对包含这些值的朴素贝叶斯模型进行交叉验证。这会提高性能吗?

练习 4

使用嵌套交叉验证进行另一轮随机搜索以找到最佳 SVM 超参数。这次,将搜索限制在线性核(因此不需要调整degreegamma),在cost超参数的范围内搜索 0.1 和 100,并将迭代次数增加到 100。警告:在我的机器上完成这项操作几乎需要 12 分钟!

摘要

  • 朴素贝叶斯和支持向量机(SVM)算法是用于分类问题的监督学习器。

  • 朴素贝叶斯使用贝叶斯定理(在第五章中定义)来估计新数据属于每个可能的输出类别的概率。

  • SVM 算法找到一个超平面(一个维度比预测变量少的表面),它能最好地分离类别。

  • 虽然朴素贝叶斯可以处理连续和分类的自变量,但支持向量机(SVM)算法只能处理连续的自变量。

  • 朴素贝叶斯在计算上成本低廉,而 SVM 算法是最昂贵的算法之一。

  • SVM 算法可以使用核函数为数据添加一个额外的维度,这有助于找到线性决策边界。

  • SVM 算法对超参数的值很敏感,必须调整以最大化性能。

  • mlr 包允许通过使用 parallelMap 包并行化密集型过程,如超参数调整。

练习题解答

  1. 使用map_dbl()函数计算votesTib中每列的y值的数量:

    map_dbl(votesTib, ~ length(which(. == "y")))
    
  2. 从您的朴素贝叶斯模型中提取先验概率和似然度:

    getLearnerModel(bayesModel)
    
    # The prior probabilities are 0.61 for democrat and
    # 0.39 for republican (at the time these data were collected!).
    
    # The likelihoods are shown in 2x2 tables for each vote.
    
  3. votesTib中的NA值重新编码,并对包含这些值的模型进行交叉验证:

    votesTib[] <- map(votesTib, as.character)
    
    votesTib[is.na(votesTib)] <- "a"
    
    votesTib[] <- map(votesTib, as.factor)
    
    votesTask <- makeClassifTask(data = votesTib, target = "Class")
    
    bayes <- makeLearner("classif.naiveBayes")
    
    kFold <- makeResampleDesc(method = "RepCV", folds = 10, reps = 50,
                              stratify = TRUE)
    
    bayesCV <- resample(learner = bayes, task = votesTask, resampling = kFold,
                        measures = list(mmce, acc, fpr, fnr))
    
    bayesCV$aggr
    
    # Only a very slight increase in accuracy
    
  4. 在线性核中执行随机搜索,对成本超参数的范围进行更大范围的调整:

    svmParamSpace <- makeParamSet(
      makeDiscreteParam("kernel", values = "linear"),
      makeNumericParam("cost", lower = 0.1, upper = 100))
    
    randSearch <- makeTuneControlRandom(maxit = 100)
    
    cvForTuning <- makeResampleDesc("Holdout", split = 2/3)
    
    outer <- makeResampleDesc("CV", iters = 3)
    
    svmWrapper <- makeTuneWrapper("classif.svm", resampling = cvForTuning,
                                  par.set = svmParamSpace,
                                  control = randSearch)
    
    parallelStartSocket(cpus = detectCores())
    
    cvWithTuning <- resample(svmWrapper, spamTask, resampling = outer) # ~1 min
    
    parallelStop()
    
    cvWithTuning
    

第七章. 使用决策树进行分类

本章涵盖

  • 与决策树一起工作

  • 使用递归分割算法

  • 决策树的一个重要弱点

没有什么比伟大的户外更棒了。我住在乡村,当我带着我的狗在树林里散步时,我就会想起我们有多么依赖树木。树木产生我们呼吸的空气,为野生动物创造栖息地,为我们提供食物,并且出人意料地擅长预测。是的,你没听错:树木擅长预测。但在你向你家后院的桦树询问下周的彩票号码之前,我应该澄清我指的是几个使用分支树结构的监督学习算法。这个算法家族可以用于解决分类和回归任务,可以处理连续和分类预测因子,并且自然适合解决多类分类问题。

注意

记住,预测变量是我们认为可能包含关于我们的结果变量值的信息的变量。连续预测变量可以在它们的测量尺度上具有任何数值,而分类变量只能具有有限、离散的值/类别。

所有基于树的分类算法的基本前提是它们学习一系列问题,这些问题将案例分为不同的类别。每个问题都有二元答案,案例将根据它们满足的哪个标准被发送到左分支或右分支。分支中可以有分支;一旦模型被学习,它可以用图形表示为树。你有没有玩过 20 个问题的游戏,你必须通过提出是或否的问题来猜测某人正在想什么物体?又或者猜猜看的游戏,你必须通过询问关于他们外观的问题来猜测其他玩家的角色?这些都是基于树的分类器的例子。

到本章结束时,你将看到如何使用这样简单、可解释的模型进行预测。我们将通过强调决策树的一个重要弱点来结束本章,你将在下一章中学习如何克服这个弱点。

7.1. 递归分割算法是什么?

在本节中,你将了解决策树算法——特别是递归划分(rpart)算法——是如何学习树结构的。想象一下,你想要创建一个模型来表示人们根据车辆特征上下班的方式。你收集有关车辆的信息,例如它们有多少轮子,是否有引擎,以及它们的重量。你可以将你的分类过程表述为一系列连续的问题。每辆车在每个问题上进行评估,并根据其特征如何满足问题在模型中向左或向右移动。这种模型的例子在图 7.1 中展示。

图 7.1. 决策树的结构。根节点是包含所有在分裂之前的数据的节点。节点通过分裂标准被分成两个分支,每个分支都指向另一个节点。不再分裂的节点被称为叶子

图 7-1

注意到我们的模型具有分支和树状结构,其中每个问题将数据分成两个分支。每个分支可以引出更多的问题,这些问题又有自己的分支。树的提问部分被称为节点,而非常第一个问题/节点被称为根节点。节点有一个分支指向它们,有两个分支从它们离开。一系列问题结束处的节点被称为叶节点叶子。叶节点只有一个分支指向它们,但没有分支从它们离开。当一个案例找到其路径进入叶节点时,它不再进一步发展,并被分类为该叶节点内的多数类。对你来说(至少对我来说)可能看起来很奇怪,根节点在顶部而叶子在底部,但这是基于树的模型通常表示的方式。

注意

尽管在这个小示例中没有展示,但在树的不同的部分对同一特征提出问题是完全正常(并且常见)的。

到目前为止,这一切似乎都很简单。但在之前的简单示例中,我们完全可以通过手工构建这样的模型。(事实上,我就是这么做的!)因此,基于树的模型并不一定是通过机器学习来学习的。例如,决策树可能是一个现成的 HR 流程,用于处理纪律处分。你可以采用基于树的策略来决定购买哪架航班(价格是否超出你的预算,航空公司是否可靠,食物是否糟糕,等等)。那么,我们如何自动学习具有许多特征的复杂数据集的决策树结构呢?这就引入了 rpart 算法。

注意

基于树的模型可以用于分类回归任务,因此你可能会看到它们被描述为分类和回归树(CART)。然而,CART 是一个商标算法,其代码是专有的。rpart 算法只是 CART 的开源实现。你将在第十二章中学习如何使用树进行回归任务。

在构建树的每个阶段,rpart 算法考虑所有预测变量,并选择那个在区分类别方面做得最好的预测变量。它从根节点开始,然后在每个分支上再次寻找将最好地区分该分支上案例类别的下一个特征。但是,rpart 是如何决定每个分割的最佳特征的?这可以通过几种不同的方式来完成,rpart 提供了两种方法:的差异(称为信息增益)和基尼指数的差异(称为基尼增益)。这两种方法通常给出非常相似的结果;但基尼指数(以社会学家和统计学家 Corrado Gini 命名)计算速度略快,所以我们重点关注它。

提示

基尼指数是 rpart 默认用于决定如何分割树的指标。如果你担心你可能会错过最佳性能的模型,你可以在超参数调整期间始终比较基尼指数和熵。

7.1.1. 使用基尼增益来分割树

在本节中,我将向你展示如何计算基尼增益,以找到在生长决策树时特定节点的最佳分割。熵和基尼指数是试图衡量同一事物(不纯度)的两种方式。不纯度是衡量节点内类别异质性的度量。

备注

如果一个节点只包含一个类别(这将使其成为叶子节点),则可以说它是的。

通过估计使用每个预测变量进行下一次分割将产生的不纯度(无论你选择哪种方法),算法可以选择将导致最小不纯度的特征。换句话说,算法选择将导致后续节点尽可能同质的特征。

那么,基尼系数看起来是什么样子呢?图 7.2 展示了一个示例分割。我们有一个父节点,包含属于两个类别 A 和 B 的 20 个案例。我们根据某些标准将该节点分割成两个叶子节点。在左叶子节点中,我们有来自类别 A 的 11 个案例和来自类别 B 的 3 个案例。在右叶子节点中,我们有来自类别 B 的 5 个案例和来自类别 A 的 1 个案例。

图 7.2. 属于类别 A 和 B 的 20 个案例的示例决策树分割

我们想知道这个分割的基尼增益。基尼增益是父节点基尼指数与分割基尼指数之间的差异。查看我们的示例图 7.2,任何节点的基尼指数计算如下

  • 基尼指数 = 1 –(p(A)² + p(B)²)

其中p(A)和p(B)分别是属于类别 A 和 B 的案例比例。因此,父节点、左叶和右叶的基尼指数如图 7.3 所示。

图 7.3. 计算父节点和左右叶子的基尼指数

现在我们有了左右叶子的基尼指数,我们可以计算整个分割的基尼指数。分割的基尼指数是左右基尼指数的总和乘以它们从父节点接受的案例比例:

并且基尼增益(父节点和分割的基尼指数之间的差异)简单地是

  • 基尼增益 = 0.48 – 0.32 = 0.16

其中 0.48 是父节点的基尼指数,如图 7.3 中计算所得。

在特定节点上,为每个预测变量计算基尼增益,并使用产生最大基尼增益的预测变量来分割该节点。随着树的生长,这个过程会重复应用于每个节点。

将基尼指数推广到任意数量的类别

在这个例子中,我们只考虑了两个类别,但对于有多个类别的实际问题,节点的基尼指数很容易计算。在这种情况下,基尼指数的方程推广为

这只是说我们计算每个类别从 1 到K(类别数量)的p(class[k])²,将它们全部加起来,然后从 1 中减去这个值的一种花哨的说法。

如果你对熵的公式感兴趣,它是

这只是说我们计算每个类别从 1 到K(类别数量)的-p(class) × log[2]p(class*),并将它们全部加起来(由于第一个项是负数,这变成了减法)。至于基尼增益,信息增益是父节点的熵减去分割的熵(分割的熵计算方式与分割的基尼指数完全相同)。

7.1.2. 关于连续和多级分类预测变量怎么办?

在本节中,我将向你展示如何为连续和分类预测变量选择分割。当一个预测变量是二元的(只有两个级别)时,如何用它进行分割是很明显的:具有一个值的案例向左走,具有另一个值的案例向右走。决策树也可以使用连续变量来分割案例,但分割点选择什么值呢?看看图 7.4 中的例子。我们有来自三个类别的案例,它们与两个连续变量相关。特征空间由每个节点分割成矩形。在第一个节点,案例被分割成变量 2 的值大于或小于 20 的案例。到达第二个节点的案例进一步被分割成变量 1 的值大于或小于 10,000 的案例。

图 7.4. 如何对连续预测变量进行分割。属于三个类别的案例是针对两个连续变量绘制的。第一个节点根据变量 2 的值将特征空间分割成矩形。第二个节点进一步根据变量 1 的值将变量 2 ≥ 20 的特征空间分割成矩形。

注意

注意到变量处于截然不同的尺度上。rpart 算法对变量处于不同尺度不敏感,因此不需要对预测变量进行缩放和中心化!

但是,对于一个连续预测变量,如何选择确切的分割点呢?嗯,训练集中的案例是按照连续变量的顺序排列的,并且评估了每对相邻案例之间的中点处的基尼增益。如果所有预测变量中最大的基尼增益是这些中点之一,那么这个中点就被选为该节点的分割点。这可以在图 7.5 中看到。

对于具有超过两个水平的分类预测变量,使用类似的程序。首先,计算预测变量的每个水平的基尼指数(使用具有该预测变量值的每个类的比例)。因素水平按照它们的基尼指数顺序排列,并且评估相邻水平之间的分割的基尼增益。看看图 7.6 中的例子。我们有一个有三个水平(A、B 和 C)的因素:我们评估每个水平的基尼指数,并发现它们的值是 B < A < C。现在我们评估分割 B 与 A 和 C,以及 C 与 B 和 A 的基尼增益。

这样,我们可以在不需要尝试每个可能的水平分割组合(2m(-1),其中m是变量的水平数)的情况下,从具有许多预测变量的分类变量中创建二分分割。如果发现分割 B 与 A 和 C 具有最大的基尼增益,那么具有该变量 B 值的案例将沿着一个分支向下,而具有 A 或 C 值的案例将沿着另一个分支向下。

图 7.5. 如何为连续预测变量选择分割点。案例(圆圈)按照它们的连续预测变量的值排列。考虑每个相邻案例对之间的中点作为候选分割点,并计算每个的基尼增益。如果这些分割中的任何一个具有任何候选分割中最高的基尼增益,它将用于在此节点分割树。

图 7.6. 如何为分类预测变量选择分割点。每个因素水平的基尼指数是通过计算具有该因素水平的每个类别的案例比例来计算的。因素水平按照它们的基尼指数顺序排列,并且评估了相邻水平之间的每个分割的基尼增益。

7.1.3. rpart 算法的超参数

在本节中,我将向您展示对于 rpart 算法,哪些超参数需要调整,它们的作用,以及为什么我们需要调整它们以获得性能最佳的树。决策树算法被描述为贪婪的。这里的“贪婪”并不是指它们在自助餐线上多拿一份;我的意思是它们寻找的是在当前节点上表现最佳的分割,而不是全局上产生最佳结果的分割。例如,某个分割可能在当前节点上对类别进行最佳区分,但在该分支的更深处导致分离效果差。相反,在当前节点上导致分离效果差的分割可能在树的更深处产生更好的分离。决策树算法永远不会选择第二个分割,因为它们只关注局部最优的分割,而不是全局最优的分割。这种方法的三个问题是:

  • 算法不保证学习到全局最优模型。

  • 如果不加以控制,树将继续生长,直到所有叶子都是纯的(仅有一个类别)。

  • 对于大型数据集,生长非常深的树在计算上变得昂贵。

虽然 rpart 不保证学习到全局最优模型,但树的深度对我们来说更为重要。除了计算成本外,将树生长到所有叶子都是纯的,很可能过度拟合训练集并创建一个具有高方差度的模型。这是因为随着特征空间被分割成越来越小的部分,我们更有可能开始模拟数据中的噪声。

我们如何防止这种过度构建的树?有两种方法可以做到:

  • 首先生长一棵完整的树,然后剪枝

  • 采用停止标准

在第一种方法中,我们允许贪婪算法生长完整的、过度拟合的树,然后我们拿出我们的园艺剪刀,移除不符合某些标准的叶子。这个过程被富有想象力地命名为剪枝,因为我们最终从树中移除了分支和叶子。这有时被称为自底向上剪枝,因为我们从叶子开始,向上修剪到根。

在第二种方法中,我们在构建树的过程中包含条件,如果未满足某些标准,将强制停止分割。这有时被称为自顶向下剪枝,因为我们是从根向下修剪树的。

实际上,两种方法可能产生相似的结果,但自顶向下剪枝在计算上略有优势,因为我们不需要先生长完整的树然后再修剪它们。因此,我们将使用停止标准方法。

我们可以在树构建过程的每个阶段应用的停止标准如下:

  • 分割前节点中的最小案例数

  • 树的最大深度

  • 分割的最小性能提升

  • 叶子中的最小案例数

这些标准在图 7.7 中得到了说明。在构建树的过程中,对每个候选分割,都会评估这些标准,并且节点必须通过这些标准才能进一步分割。

图 7.7. rpart 的超参数。在每个示例中突出显示重要节点,每个节点中的数字代表案例数。minsplitmaxdepthcpminbucket 这几个超参数同时约束每个节点的分割。

rpart 中将分割节点所需的最小案例数称为 minsplit。如果一个节点少于指定的数量,则该节点将不会进一步分割。rpart 中将树的最高深度称为 maxdepth。如果一个节点已经达到这个深度,则该节点将不会进一步分割。性能的最小改进,令人困惑的是,不是分割的基尼增益。相反,为树的每个深度级别计算一个称为 复杂性参数 的统计量(rpart 中的 cp)。如果一个深度的 cp 值小于选择的阈值值,则该级别的节点将不会进一步分割。换句话说,如果添加另一层到树中不通过 cp 改善模型的性能,则不要分割节点。cp 值的计算如下

其中 p(错误) 是在树的特定深度上错误分类案例的比例,而 n(分割) 是在该深度上的分割数。索引 ll + 1 表示当前深度 (l) 和比它高一个深度的深度 (l + 1)。这相当于将一个深度与比它高一个深度的深度中错误分类案例的差异除以添加到树中的新分割数。如果现在这个概念听起来有点抽象,我们将在第 7.7 节中构建自己的决策树时通过一个例子来解释。

最后,rpart 中将叶节点中的最小案例数称为 minbucket。如果分割一个节点会导致叶节点包含的案例数少于 minbucket,则该节点将不会进行分割。

这四个标准结合在一起可以形成非常严格和复杂的停止标准。因为这些标准的值不能直接从数据中学习,所以它们是超参数。我们对超参数怎么办?调整它们!因此,当我们使用 rpart 构建模型时,我们将调整这些停止标准以获得最佳性能模型的值。

注意

从第三章回忆起,一个变量或选项控制算法如何学习,但不能从数据中学习,被称为 超参数

7.2. 构建你的第一个决策树模型

在本节中,你将学习如何使用 rpart 构建决策树以及如何调整其超参数。想象一下,你在野生动物保护区从事公众参与工作。你的任务是创建一个互动游戏,向孩子们介绍不同的动物类别。游戏要求孩子们想到保护区中的任何一种动物,然后询问他们关于该动物身体特征的问题。根据孩子给出的回答,模型应该告诉孩子他们的动物属于哪个类别(哺乳动物、鸟类、爬行动物等)。对于你的模型来说,重要的是它足够通用,可以在其他野生动物保护区使用。让我们首先加载 mlr 和 tidyverse 包:

library(mlr)
library(tidyverse)

7.3. 加载和探索 zoo 数据集

让我们加载内置在 mlbench 包中的 zoo 数据集,将其转换为 tibble,并对其进行探索。我们有一个包含 101 个案例和 17 个变量的 tibble,这些变量是关于各种动物观察的结果;其中 16 个变量是逻辑变量,表示某些特征的缺失或存在,而type变量是一个因子,包含我们希望预测的动物类别。

列表 7.1. 加载和探索 zoo 数据集
data(Zoo, package = "mlbench")

zooTib <- as_tibble(Zoo)

zooTib

# A tibble: 101 x 17
   hair  feathers eggs  milk  airborne aquatic predator toothed backbone
   <lgl> <lgl>    <lgl> <lgl> <lgl>    <lgl>   <lgl>    <lgl>   <lgl>
 1 TRUE  FALSE    FALSE TRUE  FALSE    FALSE   TRUE     TRUE    TRUE
 2 TRUE  FALSE    FALSE TRUE  FALSE    FALSE   FALSE    TRUE    TRUE
 3 FALSE FALSE    TRUE  FALSE FALSE    TRUE    TRUE     TRUE    TRUE
 4 TRUE  FALSE    FALSE TRUE  FALSE    FALSE   TRUE     TRUE    TRUE
 5 TRUE  FALSE    FALSE TRUE  FALSE    FALSE   TRUE     TRUE    TRUE
 6 TRUE  FALSE    FALSE TRUE  FALSE    FALSE   FALSE    TRUE    TRUE
 7 TRUE  FALSE    FALSE TRUE  FALSE    FALSE   FALSE    TRUE    TRUE
 8 FALSE FALSE    TRUE  FALSE FALSE    TRUE    FALSE    TRUE    TRUE
 9 FALSE FALSE    TRUE  FALSE FALSE    TRUE    TRUE     TRUE    TRUE
10 TRUE  FALSE    FALSE TRUE  FALSE    FALSE   FALSE    TRUE    TRUE
# ... with 91 more rows, and 8 more variables: breathes <lgl>, venomous <lgl>,
#   fins <lgl>, legs <int>, tail <lgl>, domestic <lgl>, catsize <lgl>,
#   type <fct>

不幸的是,mlr 不允许我们创建带有逻辑预测器的任务,所以让我们将它们转换为因子。有几种方法可以做到这一点,但 dplyr 的mutate_if()函数在这里很有用。这个函数将数据作为第一个参数(或者我们可以使用%>%管道将其引入)。第二个参数是我们选择列的标准,所以我使用了is.logical来考虑只有逻辑列。最后一个参数是针对这些列要做什么,所以我使用了as.factor将逻辑列转换为因子。这将保留现有的因子type不变。

列表 7.2. 将逻辑变量转换为因子
zooTib <- mutate_if(zooTib, is.logical, as.factor)
提示

或者,我本可以使用mutate_all(zooTib, as.factor),因为type列已经是因子类型。

7.4. 训练决策树模型

在本节中,我将指导你使用 rpart 算法训练决策树模型。我们将调整算法的超参数,并使用最佳超参数组合来训练模型。

让我们定义我们的任务和学习器,并像往常一样构建模型。这次,我们将"classif.rpart"作为makeLearner()的参数传递,以指定我们将使用 rpart。

列表 7.3. 创建任务和学习器
zooTask <- makeClassifTask(data = zooTib, target = "type")

tree <- makeLearner("classif.rpart")

接下来,我们需要进行超参数调整。回想一下,第一步是在我们想要搜索的超参数空间中定义超参数。让我们看看 rpart 算法为我们提供的超参数,在列表 7.4 中。我们已经讨论了调整时最重要的超参数:minsplitminbucketcpmaxdepth。还有一些其他你可能想了解的超参数。

maxcompete 超参数控制模型总结中每个节点可以显示多少个候选分割。模型总结按它们提高模型(基尼增益)的顺序显示候选分割。了解实际使用的下一个最佳分割可能是有用的,但调整 maxcompete 不会影响模型性能,只会影响其总结。

maxsurrogate 超参数类似于 maxcompete,但它控制显示多少个 代理分割。代理分割是在特定案例缺少实际分割数据时使用的分割。通过这种方式,rpart 可以处理缺失数据,因为它学习哪些分割可以用作缺失变量的替代。maxsurrogate 超参数控制模型中保留多少个这些代理(如果主分割缺少值,它将被传递到第一个代理分割,然后如果它也缺少第一个代理的值,它将被传递到第二个代理,依此类推)。尽管我们的数据集中没有缺失数据,但我们希望预测的未来案例可能会有。我们可以将其设置为 0 以节省一些计算时间,这相当于不使用代理变量,但这样做可能会降低对未来案例缺失数据做出的预测的准确性。默认值 5 通常是可以的。

小贴士

回想一下第六章,我们可以通过运行 map_dbl(zooTib, ~sum(is.na(.)))) 快速计算每个数据框或 tibble 列中缺失值的数量。

usesurrogate 超参数控制算法如何使用代理分割。值为 0 表示不会使用代理,并且带有缺失数据的案例将不会被分类。值为 1 表示将使用代理,但如果一个案例的实际分割和所有代理分割都缺少数据,该案例将不会被分类。默认值 2 表示将使用代理,但一个实际分割和所有代理分割都缺少数据的案例将被发送到包含最多案例的分支。默认值 2 通常是合适的。

注意

如果你有一些实际分割和所有代理分割都缺少数据的案例,你应该考虑缺少数据对你的数据集质量产生的影响!

列表 7.4. 打印可用的 rpart 超参数
getParamSet(tree)

                   Type len  Def   Constr Req Tunable Trafo
minsplit        integer   -   20 1 to Inf   -    TRUE     -
minbucket       integer   -    - 1 to Inf   -    TRUE     -
cp              numeric   - 0.01   0 to 1   -    TRUE     -
maxcompete      integer   -    4 0 to Inf   -    TRUE     -
maxsurrogate    integer   -    5 0 to Inf   -    TRUE     -
usesurrogate   discrete   -    2    0,1,2   -    TRUE     -
surrogatestyle discrete   -    0      0,1   -    TRUE     -
maxdepth        integer   -   30  1 to 30   -    TRUE     -
xval            integer   -   10 0 to Inf   -   FALSE     -
parms           untyped   -    -        -   -    TRUE     -

现在,让我们定义我们想要搜索的超参数空间。我们将调整 minsplit(一个整数)、minbucket(一个整数)、cp(一个数值)和 maxdepth(一个整数)的值。

注意

记住,我们使用 makeIntegerParam()makeNumericParam() 分别定义整数和数值超参数的搜索空间。

列表 7.5. 定义超参数空间以进行调整
treeParamSpace <- makeParamSet(
  makeIntegerParam("minsplit", lower = 5, upper = 20),
  makeIntegerParam("minbucket", lower = 3, upper = 10),
  makeNumericParam("cp", lower = 0.01, upper = 0.1),
  makeIntegerParam("maxdepth", lower = 3, upper = 10))

接下来,我们可以定义如何搜索我们在列表 7.5 中定义的超参数空间。由于超参数空间相当大,我们将使用随机搜索而不是网格搜索。回想一下第六章,随机搜索不是穷尽的(不会尝试每个超参数组合),但会随机选择组合,次数(迭代)如我们告诉它的那样。我们将使用 200 次迭代。

在列表 7.6 中,我们还定义了用于调整的交叉验证策略。在这里,我将使用普通的 5 折交叉验证。回想一下第三章,这将把数据分成五个部分,并且每次使用其中一个部分作为测试集。对于每个测试集,将在剩余的数据(训练集)上训练一个模型。这将针对随机搜索尝试的每个超参数值组合进行。

注意

通常情况下,如果类别不平衡,我会使用分层抽样。然而,在这里,由于某些类别中案例非常少,没有足够多的案例来进行分层(试一试:你会得到一个错误)。在这个例子中,我们不会进行分层;但在你有一个类别中案例非常少的情况下,你应该考虑是否有足够的数据来证明保留该类别在模型中的合理性。

列表 7.6. 定义随机搜索
randSearch <- makeTuneControlRandom(maxit = 200)

cvForTuning <- makeResampleDesc("CV", iters = 5)

最后,让我们执行超参数调整!

列表 7.7. 执行超参数调整
library(parallel)
library(parallelMap)

parallelStartSocket(cpus = detectCores())

tunedTreePars <- tuneParams(tree, task = zooTask,
                           resampling = cvForTuning,
                           par.set = treeParamSpace,
                           control = randSearch)

parallelStop()

tunedTreePars

Tune result:
Op. pars: minsplit=10; minbucket=4; cp=0.0133; maxdepth=9
mmce.test.mean=0.0698

为了加快速度,我们首先通过运行parallelStartSocket()来启动并行化,将 CPU 的数量设置为可用的数量。

提示

如果你想在调整过程中使用计算机做其他事情,你可能希望将使用的 CPU 数量设置为小于你可用的最大数量。

然后我们使用tuneParams()函数开始调整过程。参数与之前使用的一样:第一个是学习者,第二个是任务,resampling是交叉验证方法,par.set是超参数空间,control是搜索方法。一旦完成,我们停止并行化并打印调整结果。

警告

在我的四核机器上运行大约需要 30 秒。

rpart 算法在计算上并不像我们在第六章中用于分类的支持向量机(SVM)算法那样昂贵。因此,尽管调整了四个超参数,调整过程并不需要花费很长时间(这意味着我们可以进行更多的搜索迭代)。

7.4.1. 使用调整后的超参数训练模型

现在我们已经调整了超参数,我们可以使用它们来训练最终的模型。就像上一章一样,我们使用setHyperPars()函数创建一个使用调整超参数的学习者,我们通过tunedTreePars$x来访问它。然后我们可以使用train()函数像往常一样训练最终的模型。

列表 7.8. 训练最终调整后的模型
tunedTree <- setHyperPars(tree, par.vals = tunedTreePars$x)

tunedTreeModel <- train(tunedTree, zooTask)

决策树的一个美妙之处在于其可解释性。解释模型的最简单方法就是绘制树的图形表示。在 R 中绘制决策树模型有多种方法,但我最喜欢的是同名的rpart.plot()函数。让我们首先安装 rpart.plot 包,然后使用getLearnerModel()函数提取模型数据。

列表 7.9. 绘制决策树
install.packages("rpart.plot")

library(rpart.plot)

treeModelData <- getLearnerModel(tunedTreeModel)

rpart.plot(treeModelData, roundint = FALSE,
           box.palette = "BuBn",
           type = 5)

rpart.plot()函数的第一个参数是模型数据。因为我们使用 mlr 训练了这个模型,所以函数会给出一个警告,表示它找不到用于训练模型的数据。我们可以安全地忽略这个警告,但如果它像对我一样让你感到烦恼,你可以通过提供roundint = FALSE参数来防止它。如果我们的类别比其默认调色板(最需要的函数!)多,该函数也会抱怨。要么忽略它,要么通过设置box.palette参数为预定义调色板之一来请求不同的调色板(运行?rpart.plot以获取可用调色板的列表)。type参数改变树显示的方式。我非常喜欢选项 5 的简洁性,但请检查?rpart.plot以实验其他选项。

由列表 7.9 生成的图显示在图 7.8 中。你能看到这棵树是多么简单且易于理解吗?在预测新案例的类别时,它们从顶部(根节点)开始,根据每个节点的分割标准跟随分支。

第一个节点询问动物是否产奶。这个分割被选择是因为它在所有候选分割中具有最高的 Gini 增益(它立即区分出哺乳动物,占训练集的 41%,而其他类别)。叶节点告诉我们该节点分类了哪个类别以及该节点中每个类别的比例。例如,将案例分类为 mollusc.et.al 的叶节点包含 83%的 mollusc.et.al 案例和 17%的昆虫案例。每个叶节点底部的百分比表示该叶节点中训练集中案例的百分比。

要检查每个分割的cp值,我们可以使用printcp()函数。此函数将模型数据作为第一个参数,并可选地使用digits参数指定输出中要打印的小数位数。输出中包含一些有用的信息,例如实际用于分割数据的变量和根节点错误(任何分割之前的错误)。最后,输出还包括每个分割的cp值表。

图 7.8. 我们决策树模型的图形表示。每个节点显示了分割标准。每个叶节点显示了预测的类别,该叶节点中每个类别的比例,以及该叶节点中所有案例的比例。

![fig7-8_alt.jpg]

列表 7.10. 探索模型
printcp(treeModelData, digits = 3)

Classification tree:
rpart::rpart(formula = f, data = d, xval = 0, minsplit = 7, minbucket = 3,
    cp = 0.0248179216007702, maxdepth = 5)

Variables actually used in tree construction:
[1] airborne aquatic  backbone feathers fins     milk

Root node error: 60/101 = 0.594

n= 101

      CP nsplit rel error
1 0.3333      0     1.000
2 0.2167      1     0.667
3 0.1667      2     0.450
4 0.0917      3     0.283
5 0.0500      5     0.100
6 0.0248      6     0.050

记住,在 第 7.1.3 节 中,我向您展示了 cp 值是如何计算的:

为了让您更好地理解 cp 值的含义,让我们通过查看 列表 7.10 中的表格来了解 cp 值是如何计算的。

第一次分割的 cp 值为

第二次分割的 cp 值为

等等。如果任何候选分割会产生低于调整设置的阈值的 cp 值,则节点不会进一步分割。

小贴士

要获取模型的详细摘要,请运行 summary(treeModelData)。输出相当长(随着树的深入而变长),所以这里不打印。它包括 cp 表,按重要性排序预测变量,并显示每个节点的初级和替代分割。

7.5. 对我们的决策树模型进行交叉验证

在本节中,我们将交叉验证我们的模型构建过程,包括超参数调整。我们已经这样做了几次,但这是如此重要,以至于我要重申:您 必须 在交叉验证中包含数据相关的预处理。这包括我们在 列表 7.7 中执行的超参数调整。

首先,我们定义我们的外部交叉验证策略。这次我使用 5 折交叉验证作为我的外部交叉验证循环。我们将使用在 列表 7.6 中创建的 cvForTuning 重采样描述作为内部循环。

接下来,我们通过“包装在一起”我们的学习器和超参数调整过程来创建包装器。我们将内部交叉验证策略、超参数空间和搜索方法提供给 makeTuneWrapper() 函数。

最后,我们可以使用 parallelStartSocket() 函数开始并行化,并使用 resample() 函数开始交叉验证过程。resample() 函数将我们的包装学习器、任务和外部交叉验证策略作为参数。

警告

这在我的四核机器上大约需要 2 分钟。

列表 7.11. 交叉验证模型构建过程
outer <- makeResampleDesc("CV", iters = 5)

treeWrapper <- makeTuneWrapper("classif.rpart", resampling = cvForTuning,
                              par.set = treeParamSpace,
                              control = randSearch)

parallelStartSocket(cpus = detectCores())

cvWithTuning <- resample(treeWrapper, zooTask, resampling = outer)

parallelStop()

现在让我们看看交叉验证结果,看看我们的模型构建过程表现如何。

列表 7.12. 提取交叉验证结果
cvWithTuning

Resample Result
Task: zooTib
Learner: classif.rpart.tuned
Aggr perf: mmce.test.mean=0.1200
Runtime: 112.196

嗯,这有点令人失望,不是吗?在超参数调整期间,最佳超参数组合给我们带来了平均误分类误差 (MMCE) 为 0.0698(你很可能得到了不同的值)。但我们的交叉验证模型性能估计给出了 MMCE 为 0.12。相当大的差异!发生了什么事?嗯,这是一个过度拟合的例子。我们的模型在超参数调整期间的表现比在交叉验证期间要好。这也是为什么在交叉验证过程中包含超参数调整很重要的好例子。

我们刚刚发现了 rpart 算法(以及决策树一般)的主要问题:它们倾向于产生过拟合的模型。我们如何克服这个问题?答案是使用集成方法,这是一种我们使用多个模型对一个单一任务进行预测的方法。在下一章中,我将向您展示集成方法是如何工作的,我们将使用它们来大幅提高我们的决策树模型。我建议您保存您的.R 文件,因为我们将在下一章继续使用相同的 dataset 和 task。这样我就可以向您突出显示这些集成技术相比普通决策树有多好。

7.6. 基于树的算法的优缺点

虽然通常很难判断哪些算法会对给定的任务表现良好,但以下是一些优势和弱点,这将帮助您决定决策树是否适合您。

基于树的算法的优点如下:

  • 树构建背后的直觉非常简单,每个单独的树都非常可解释。

  • 它可以处理分类和连续预测变量。

  • 它对预测变量的分布没有假设。

  • 它可以以合理的方式处理缺失值。

  • 它可以处理不同尺度的连续变量。

基于树的算法的弱点如下:

  • 单个树模型非常容易过拟合——如此之容易以至于它们很少被使用。

摘要

  • rpart 算法是用于分类和回归问题的监督学习器。

  • 基于树的算法从根节点中的所有案例开始,找到顺序的二分分割,直到案例找到自己在叶节点中。

  • 树构建是一个贪婪的过程,可以通过设置停止标准(例如,在节点可以分割之前所需的案例的最小数量)来限制。

  • Gini 增益是一个用于决定在特定节点上哪个预测变量将导致最佳分割的标准。

  • 决策树有过度拟合训练集的倾向。

第八章. 使用随机森林和 boosting 改进决策树

本章涵盖

  • 理解集成方法

  • 使用 bagging、boosting 和 stacking

  • 使用随机森林和 XGBoost 算法

  • 将多个算法与同一任务进行基准测试

在上一章中,我向您展示了我们可以如何使用递归分割算法来训练非常可解释的决策树。我们通过强调决策树的一个重要限制结束:它们有过度拟合训练集的倾向。这导致模型对新数据的泛化能力差。因此,单个决策树很少被使用,但当许多树组合在一起时,它们可以成为极其强大的预测因子。

到本章结束时,你将了解普通决策树和集成方法(如随机森林梯度提升),这些方法通过结合多个树来进行预测之间的区别。最后,由于这是本书分类部分的最后一章,你将学习什么是基准测试以及如何使用它来找到特定问题的最佳性能算法。基准测试是让一组不同的学习算法竞争,以选择在特定问题中表现最佳的算法的过程。

我们将继续使用我们在上一章中使用过的动物园数据集。如果你在你的全局环境中不再有zooTibzooTasktunedTree对象定义(运行ls()以查找),只需重新运行上一章的列表 7.1 到 7.8。

8.1. 集成技术:Bagging、Boosting 和 Stacking

在本节中,我将向您展示什么是集成方法以及它们如何被用来提高基于树的模型的性能。想象一下,如果你想知道一个国家在某个特定问题上的观点,你会认为哪个更能反映公众舆论:你在街上询问的某一个人的观点,还是许多人在投票箱上的集体投票?在这个场景中,决策树就是街上那个人。你创建一个单一模型,传递新数据,并询问其关于预测输出的观点。另一方面,集成方法是集体投票。

集成方法背后的想法是,而不是训练一个单一模型,你训练多个模型(有时是数百甚至数千个模型)。接下来,你询问每个模型关于新数据预测输出的观点。然后,在做出最终预测时,你考虑所有模型的投票。想法是,基于多数投票的预测将比单个模型做出的预测具有更小的方差。

有三种不同的集成方法:

  • Bootstrap aggregating

  • Boosting

  • Stacking

让我们更详细地讨论这些内容。

8.1.1. 在样本数据上训练模型:Bootstrap aggregating

在本节中,我将解释 bootstrap aggregating 集成技术的原理,以及这是如何在称为随机森林的算法中使用的。机器学习算法可能对异常值和测量误差产生的噪声敏感。如果我们的训练集中存在噪声数据,那么我们的模型在预测未来数据时更有可能具有高方差。我们如何训练一个利用我们所有可用数据的学习者,但可以忽略这些噪声数据并减少预测方差?答案是使用bootstrap aggregating(或简称为bagging)。

Bagging 的前提非常简单:

  1. 决定你将要训练多少个子模型。

  2. 对于每个子模型,从训练集中随机采样案例,有放回地采样,直到你有一个与原始训练集大小相同的样本。

  3. 在每个案例样本上训练一个子模型。

  4. 将新数据通过每个子模型,并让他们对预测进行投票。

  5. 所有子模型的模态预测(最频繁的预测)被用作预测输出。

袋装法最关键的部分是案例的随机采样。想象一下你在玩 Scrabble 游戏,手里有一袋 100 个字母棋子。现在想象你把手伸进袋子,盲目地翻找一番,抽出一个棋子,并写下你得到的字母。这就是随机采样。然后,关键的是,你把棋子放回去。这被称为放回,有放回地采样简单来说就是在你抽取值之后将它们放回。这意味着相同的值可能再次被抽取。你继续这样做,直到你抽取了 100 个随机样本,这与最初袋子里的数量相同。这个过程被称为自举,是统计学和机器学习中的一个重要技术。你的 100 个棋子的自举样本应该能够合理地反映原始袋子中每个字母的频率。

那么,为什么在训练集的自举样本上训练子模型能帮助我们呢?想象一下案例分布在它们的特征空间中。每次我们进行自举样本采样时,因为我们是在有放回地采样,所以我们更有可能选择一个位于分布中心的案例,而不是一个位于分布极端的案例。一些自举样本可能包含许多极端值,并且它们自己做出的预测可能很差,但这里是袋装法的第二个关键部分:我们聚合了所有这些模型的预测。这仅仅意味着我们让它们都做出预测,然后进行多数投票。这种效果是所有模型的一种平均,这减少了噪声数据的影响,并减少了过拟合。决策树的袋装法在图 8.1 中展示。

图 8.1. 使用决策树的袋装法(bootstrap aggregating,bagging)。并行学习多个决策树,每个决策树都训练于训练集中案例的自举样本。在预测新数据时,每棵树做出一个预测,并且模态(最频繁)的预测获胜。

图 8-1

袋装法(以及你将学习的提升和堆叠)是一种可以应用于任何监督机器学习算法的技术。话虽如此,它最适合那些倾向于创建低偏差、高方差模型的算法,例如决策树。事实上,有一个著名的、非常流行的决策树袋装法实现,称为随机森林。为什么它被称为随机森林呢?因为它使用了训练集中的许多随机样本来训练决策树。许多树能做什么?一个森林!

小贴士

尽管仍然适用“没有免费午餐”定理(如第一章中提到的),单个决策树很少比它们的随机森林对应物表现更好。因此,我可能会构建一个决策树来对数据中的关系有一个广泛的理解,但我倾向于直接使用集成技术进行预测建模。

因此,随机森林算法使用 bagging 来创建大量树。这些树作为模型的一部分被保存;当我们向模型传递新数据时,每棵树都会做出自己的预测,并返回模型预测。然而,随机森林算法还有一个额外的技巧。在特定树的每个节点上,算法随机选择一个比例的预测变量,它将考虑用于该分割。在下一个节点上,算法再次随机选择用于该分割的预测变量,依此类推。虽然这看起来可能有些不合逻辑,但随机抽样案例和随机抽样特征的组合结果是创建出高度非相关的单独树。

注意

如果数据中的一些变量高度预测结果,那么这些变量将被选为许多树的分割标准。包含相同分割的树不会提供更多信息。这就是为什么希望有非相关树,以便不同的树提供不同的预测信息。随机抽样案例可以减少噪声和异常案例对模型的影响。

8.1.2. 从先前模型的错误中学习:Boosting

在本节中,我将解释 boosting 集成技术的原理以及它在称为AdaBoostXGBoost和其他算法中的应用。与 bagging 不同,boosting 是一种集成技术,它再次训练许多单个模型,但按顺序构建它们。每个额外的模型都试图纠正先前集成模型的错误。

就像 bagging 一样,boosting 可以应用于任何监督机器学习算法。然而,当使用弱学习器作为子模型时,boosting 最有益。这里的弱学习器不是指那些驾驶考试总是不及格的人;我指的是一个在预测上仅略好于随机猜测的模型。因此,boosting 传统上应用于浅层决策树。这里的浅层是指没有很多层深度的决策树,或者可能只有一个分割。

注意

只有一个分割的决策树被富有想象力地称为决策桩。如果你回顾图 7.2,你可以看到一个决策桩的例子。

提升的功能是将许多弱学习器组合在一起形成一个强大的集成学习器。我们使用弱学习器的原因是,与弱学习器相比,使用强学习器进行提升并不会提高模型性能。所以,为什么我们要浪费计算资源来训练数百个强大、可能更复杂的学习者,当我们可以通过训练弱、更简单的一些来获得相同性能时?

提升模型性能有两种方法,它们在纠正先前模型错误的方式上有所不同:

  • 自适应提升

  • 梯度提升

权重错误预测的案例:自适应提升

只有一种著名的自适应提升算法,那就是 1997 年发表的著名 AdaBoost 算法。AdaBoost 的工作原理如下。最初,训练集中的所有案例都具有相同的重要性,或者说权重。一个初始模型在训练集的 bootstrap 样本上训练,其中案例被采样的概率与其权重成正比(在这个点上都是相等的)。这个初始模型错误分类的案例被赋予更多的权重/重要性,而正确分类的案例则被赋予较少的权重/重要性。

下一个模型从训练集中再次进行 bootstrap 采样,但权重不再相等。记住,案例被采样的概率与其权重成正比。因此,一个权重是另一个案例两倍的案例更有可能被采样(并且更有可能被反复采样)。这确保了先前模型错误分类的案例更有可能出现在后续模型的 bootstrap 中。因此,后续模型更有可能学习到能够正确分类这些案例的规则。

一旦我们至少有两个模型,数据就会根据聚合投票进行分类,就像在 bagging 中一样。然后,被多数投票错误分类的案例会被赋予更多的权重,而被多数投票正确分类的案例则会被赋予较少的权重。可能有些令人困惑的是,模型本身也有权重。这个模型权重基于特定模型犯的错误数量(错误越多,权重越少)。如果你只有一个集成中的两个模型,其中一个预测组 A,另一个预测组 B,那么权重更高的模型将赢得投票。

此过程持续进行:向集成中添加新的模型,所有模型进行投票,更新权重,下一个模型根据新的权重采样数据。一旦达到预定义的最大树的数量,过程停止,我们得到最终的集成模型。这如图 8.2 所示。[#ch08fig02]。考虑这种影响:新的模型正在纠正先前模型集的错误。这就是为什么提升是一种减少偏差的优秀方法。然而,就像袋装一样,它也减少了方差,因为我们也在进行自助采样!当未知的案例被传递到最终模型进行预测时,每棵树单独投票(就像在袋装中一样),但每个投票都由模型权重加权。

图 8.2. 基于决策树的自适应提升。初始模型在训练集的随机样本上训练。正确分类的案例获得较低的权重,而错误分类的案例获得较高的权重(由数据点大小表示)。后续模型采样每个案例的概率与案例的权重成正比。随着树的增加,它们投票形成一个集成模型,其预测用于在每次迭代中更新权重。

模型权重和案例权重是如何计算的?

模型权重计算如下

其中 ln 是自然对数,p(incorrect)是错误分类案例的比例。

案例权重计算如下

这种表示法只是意味着对于正确分类的案例,我们使用顶部的公式;对于错误分类的案例,我们使用底部的公式。唯一的细微差别是,对于正确分类的案例,模型权重是负的。将这些数字代入这些公式:你会发现,正确分类案例的公式会降低它们的权重,而错误分类案例的公式会增加它们的权重。

从先前模型的残差中进行学习:梯度提升

梯度提升与自适应提升非常相似,只是它在纠正先前模型错误的方式上有所不同。后续模型不是根据分类的准确性来不同地加权案例,而是尝试预测先前集成模型的残差

一个残差,或残差误差,是真实值(“观察”值)与模型预测值之间的差异。当考虑预测一个连续变量(回归)时,这更容易理解。想象一下,你正在尝试预测一个人有多少债务。如果一个人实际债务为 2,500 美元,但我们的模型预测他们有 2,100 美元的债务,那么残差是 400 美元。它被称为残差,因为这是模型做出预测后留下的误差。

对于分类模型来说,思考残差可能有点困难,但我们可以将分类模型的残差误差量化为

  • 所有被错误分类的案例的比例

  • 对数损失

被错误分类的案例比例相当直观。对数损失类似,但更严厉地惩罚那些自信地做出错误分类的模型。如果你的朋友“绝对肯定”地告诉你赫尔辛基是瑞典的首都(它不是),你可能会对他们有更少的信心,而如果他们说是“可能”,你可能会对他们有更多的信心。这就是对数损失对待错误分类误差的方式。对于任何一种方法,给出正确分类的模型将比那些做出大量错误分类的模型具有更低的误差。哪种方法更好?再一次,这取决于具体情况,所以我们将让超参数调整来选择最好的一个。

注意

使用被错误分类的案例比例作为残差误差往往会导致模型对少量错误分类的案例的容忍度略高,而使用对数损失则不然。在每个迭代中最小化的这些残差误差度量被称为损失函数

因此,在梯度提升中,后续模型的选择是为了最小化先前模型集的残差误差。通过最小化残差误差,后续模型实际上将更倾向于正确分类先前被错误分类的案例(从而建模残差)。

计算对数损失

你不需要知道对数损失的公式,但对于对数学感兴趣的数学爱好者来说,它是这样计算的

其中 N 是案例数量,K 是类别数量,ln 是自然对数,y[ik] 是一个指示器,表示标签 k 是否是案例 i 的正确分类,p[ik] 是属于与案例 i 相同类别的、被正确分类的案例的比例。我们可以这样读:

  1. 对于训练集中的每个案例:

    1. 取与该案例属于同一类别的、被正确分类的案例的比例。

    2. 取这些比例的自然对数。

  2. 求和这些对数。

  3. 乘以 –1 / N

梯度提升不一定在训练集的样本上训练子模型。如果我们选择对训练集进行采样,这个过程被称为随机梯度提升随机只是意味着“随机”,但这是一个能让你在朋友面前炫耀的好词)。在随机梯度下降中采样通常是不放回的,这意味着它不是一个自举样本。我们不需要在采样过程中替换每个案例,因为根据它们的权重(如 AdaBoost)进行采样并不重要,并且对性能的影响很小。就像 AdaBoost 和随机森林一样,对训练集进行采样是个好主意,因为这样做可以减少方差。我们从训练集中采样的案例比例可以作为超参数进行调整。

目前有众多梯度提升算法,但可能最广为人知的是 XGBoost(极端梯度提升)算法。2014 年发布的 XGBoost 是一种非常流行的分类和回归算法。它的流行归功于它在各种任务上的出色表现,因为它往往能超越大多数其他监督学习算法。许多 Kaggle(一个运行机器学习竞赛的在线社区)数据科学竞赛都是使用 XGBoost 赢得的,它已成为许多数据科学家在尝试其他算法之前首选的监督学习算法。

虽然 XGBoost 是梯度提升的一种实现,但它还有一些小技巧:

  • 它可以并行地构建每棵树的各个分支,从而加快模型构建速度。

  • 它可以处理缺失数据。

  • 它采用了正则化。你将在第十一章中了解更多关于它的内容,但它可以防止单个预测器对预测产生太大的影响(这有助于防止过拟合)。

小贴士

还有更多最新的梯度提升算法可用,如 LightGBM 和 CatBoost。这些算法目前尚未被 mlr 包封装,因此我们将坚持使用 XGBoost,但你可以自由探索它们!

8.1.3. 从其他模型的预测中学习:堆叠

在本节中,我将解释堆叠集成技术的原理以及它是如何用于结合多个算法的预测的。堆叠是一种集成技术,虽然很有价值,但不如袋装和提升算法常用。因此,我不会过多地讨论它,但如果你对了解更多感兴趣,我推荐周志华的《集成方法:基础与算法》(Chapman and Hall/CRC,2012 年)。

在袋装和提升中,学习器通常是(但不必总是)同质化的。换句话说,所有子模型都是由相同的算法(决策树)学习的。堆叠明确使用不同的算法来学习子模型。例如,我们可能选择使用 kNN 算法(来自第三章),逻辑回归算法(来自第四章),以及 SVM 算法(来自第六章)来构建三个独立的基础模型

Stacking 背后的想法是创建擅长学习特征空间中不同模式的基模型。一个模型可能在特征空间的一个区域预测得很好,但在另一个区域犯错误。另一个模型可能在其他模型表现不佳的特征空间区域中很好地预测值。因此,Stacking 的关键在于:基模型的预测被用作预测变量(连同所有原始预测变量)由另一个模型使用:堆叠模型。这个堆叠模型随后能够从基模型的预测中学习,以做出更准确的预测。Stacking 可能既繁琐又复杂,但如果你使用足够不同的基学习器,它通常会导致模型性能的提高。

我希望我已经传达了对集成技术的基本理解,特别是随机森林和 XGBoost 算法。在下一节中,我们将使用这两种算法在我们的动物园任务上训练模型,并看看哪种表现最好!

注意

集成方法,如 bagging、boosting 和 stacking,本身并不是严格的机器学习算法。它们是可以应用于其他机器学习算法的算法。例如,我在这里将 bagging 和 boosting 描述为应用于决策树。这是因为集成通常最常应用于基于树的学习者;但我们同样可以将 bagging 和 boosting 应用于其他机器学习算法,如 kNN 和线性回归。

8.2. 构建您的第一个随机森林模型

在本节中,我将向您展示如何构建随机森林模型(使用自助法训练多个树并聚合它们的预测)以及如何调整其超参数。以下是我们需要考虑的四个重要超参数:

  • ntree— 森林中单独树的数量

  • mtry— 在每个节点随机采样的特征数量

  • nodesize— 叶子中允许的最小案例数(与 rpart 中的minbucket相同)

  • maxnodes— 允许的最大叶子数

由于我们在随机森林中聚合了许多树的投票,因此树的数量越多,效果越好。除了计算成本之外,没有增加树的数量的缺点:在某个点上,我们得到的是递减的回报。我通常不会调整这个值,而是将其固定为一个我知道适合我的计算预算的数字,通常是几百到低千位数。在本节的后面部分,我将向您展示如何判断您是否已经使用了足够的树,或者是否可以减少树的数量以加快训练时间。

尽管其他三个超参数—mtrynodesizemaxnodes—需要调整,但让我们开始吧。我们将继续使用我们在上一章中定义的zooTask(如果你在你的全局环境中不再有zooTask定义,只需重新运行列表 7.1、7.2 和 7.3)。首先要做的事情是使用makeLearner()函数创建一个学习器。这次,我们的学习器是"classif.randomForest"

forest <- makeLearner("classif.randomForest")

接下来,我们将创建我们将要调整的超参数空间。首先,我们希望将树的数量固定在 300,所以我们只需在其makeIntegerParam()调用中指定lower = 300upper = 300。在我们的数据集中有 16 个预测变量,所以让我们在 6 到 12 之间寻找mtry的最佳值。因为我们的某些组非常小(可能太小),我们需要允许我们的叶子节点包含少量案例,所以我们将nodesize调整在 1 到 5 之间。最后,我们不想过多地约束树的大小,所以我们将搜索maxnodes值在 5 到 20 之间。

列表 8.1. 调整随机森林超参数
forestParamSpace <- makeParamSet(                         *1*
  makeIntegerParam("ntree", lower = 300, upper = 300),
  makeIntegerParam("mtry", lower = 6, upper = 12),
  makeIntegerParam("nodesize", lower = 1, upper = 5),
  makeIntegerParam("maxnodes", lower = 5, upper = 20))

randSearch <- makeTuneControlRandom(maxit = 100)          *2*

cvForTuning <- makeResampleDesc("CV", iters = 5)          *3*

parallelStartSocket(cpus = detectCores())

tunedForestPars <- tuneParams(forest, task = zooTask,     *4*
                            resampling = cvForTuning,     *4*
                            par.set = forestParamSpace,   *4*
                            control = randSearch)         *4*

parallelStop()

tunedForestPars                                           *5*

Tune result:
Op. pars: ntree=300; mtry=11; nodesize=1; maxnodes=13
mmce.test.mean=0.0100
  • 1 创建超参数调整空间

  • 2 定义了一个具有 100 次迭代的随机搜索方法

  • 3 定义了 5 折交叉验证策略

  • 4 调整超参数

  • 5 打印调整结果

现在,让我们通过使用setHyperPars()来创建具有调整超参数的学习器,并将其传递给train()函数来训练一个最终模型:

tunedForest <- setHyperPars(forest, par.vals = tunedForestPars$x)

tunedForestModel <- train(tunedForest, zooTask)

我们如何知道我们在森林中是否包含了足够的树?我们可以将平均的袋外误差与树的数量进行绘图。在构建随机森林时,请记住我们为每棵树取一个自助样本。袋外误差是每个案例的预测误差的平均值,这些误差是由没有包含该案例在其自助样本中的树产生的。袋外误差估计是特定于使用袋装算法的算法,它允许我们估计森林随着其增长的性能。

我们需要做的第一件事是使用getLearnerModel()函数提取模型信息。然后我们可以在该模型数据对象上简单地调用plot()函数(指定每个类使用的颜色和线型)。让我们使用legend()函数添加一个图例,以便我们知道我们在看什么。

列表 8.2. 绘制袋外误差图
forestModelData <- getLearnerModel(tunedForestModel)

species <- colnames(forestModelData$err.rate)

plot(forestModelData, col = 1:length(species), lty = 1:length(species))

legend("topright", species,
       col = 1:length(species),
       lty = 1:length(species))

结果图显示在图 8.3 中。你无法在书的打印版本中看到线条颜色,但在电子书或如果你自己在 R 中重现该图时可以看到。该图显示了每个类别的平均袋外误差(单独的线条和平均值的线条)与森林中树的不同数量之间的关系。你能看到一旦森林中有至少 100 棵树,我们的误差估计就会稳定吗?这表明我们的森林中有足够的树(甚至可以使用更少的树)。如果你训练的模型中平均袋外误差没有稳定,你应该添加更多的树!

好的,所以我们很高兴我们的森林里有足够的树木。现在让我们正确地交叉验证我们的模型构建过程,包括超参数调整。我们将首先定义我们的外部交叉验证策略,即普通的 5 折交叉验证。

图 8.3. 绘制平均袋外误差与树数量的关系图。在训练过程中,对于给定的森林大小,平均袋外误差在 y 轴上针对每个类别(不同线条)和整体袋外(OOB)误差进行绘制。袋外误差是每个案例的平均预测误差,由那些没有将此案例包含在其自助样本中的树进行预测。y 轴显示所有案例的平均袋外误差。

列表 8.3. 交叉验证模型构建过程
outer <- makeResampleDesc("CV", iters = 5)

forestWrapper <- makeTuneWrapper("classif.randomForest",
                                 resampling = cvForTuning,
                                 par.set = forestParamSpace,
                                 control = randSearch)

parallelStartSocket(cpus = detectCores())

cvWithTuning <- resample(forestWrapper, zooTask, resampling = outer)

parallelStop()

cvWithTuning

Resample Result
Task: zooTib
Learner: classif.randomForest.tuned
Aggr perf: mmce.test.mean=0.0400
Runtime: 66.1805

哇!看看我们的随机森林模型与原始决策树相比表现得有多好(通过查看上一章中的列表 7.12 来提醒自己)!Bagging 大大提高了我们的分类准确率。接下来,让我们看看 XGBoost 是否能做得更好。

8.3. 构建你的第一个 XGBoost 模型

在本节中,我将向您展示如何构建 XGBoost 模型以及如何调整其超参数。我们有八个(!)重要的超参数需要考虑:

  • eta— 也称为学习率。这是一个介于 0 和 1 之间的数字,模型权重乘以以给出它们的最终权重。将此值设置为 1 以下会减慢学习过程,因为它“缩小”了每个额外模型所做的改进。防止集成学习得太快可以防止过拟合。通常,低值更好,但会使模型训练时间更长,因为需要许多模型子模型才能达到良好的预测准确率。

  • gamma— 节点必须通过的最小分割量来提高预测。类似于我们为 rpart 调整的cp值。

  • max_depth— 每棵树可以生长的最大深度。

  • min_child_weight— 在尝试分割节点之前,节点中需要的最小不纯度度数(如果一个节点足够纯净,就不再尝试分割它)。

  • subsample— 每棵树随机采样(不替换)的案例比例。将此设置为 1 使用训练集中的所有案例。

  • colsample_bytree— 每棵树中采样的预测变量的比例。我们也可以调整 colsample_bylevelcolsample_bynode,它们分别在每个树的每个深度级别和每个节点上采样预测变量。

  • nrounds— 模型中按顺序构建的树的数目。

  • eval_metric— 我们将要使用的残差误差/损失函数的类型。对于多类分类,这将是错误分类的案例比例(由 XGBoost 称为 merror)或对数损失(由 XGBoost 称为 mlogloss)。

首先要做的事情是使用 makeLearner() 函数创建一个学习器。这次,我们的学习器是 "classif.xgboost":

xgb <- makeLearner("classif.xgboost")

让人烦恼的是,XGBoost 只喜欢与数值预测变量玩耍。我们的预测变量目前是因子,所以我们需要将它们转换为数值,然后定义一个新的任务,使用这个转换后的 tibble。我使用了 mutate_at() 函数将除了 type 之外的所有变量(通过设置 .vars = vars(-type))转换为数值(通过设置 .funs = as.numeric)。

列表 8.4. 将因子转换为数值
zooXgb <- mutate_at(zooTib, .vars = vars(-type), .funs = as.numeric)

xgbTask <- makeClassifTask(data = zooXgb, target = "type")
注意

在我们的例子中,我们的预测变量都是数值的这一点并没有什么区别。这是因为我们的大部分预测变量都是二元的,除了 legs,它作为一个数值变量是有意义的。然而,如果我们有一个具有许多离散级别的因子,将其视为数值是否有意义?从理论上讲,没有;但在实践中,它可以非常有效。我们只需将因子的每个级别重新编码为一个任意的整数,然后让决策树为我们找到最佳的分割。这被称为 数值编码(这是我们对我们数据集中的变量所做的那样)。你可能听说过另一种编码分类特征的方法,称为 独热编码。虽然我不会在这里讨论独热编码,但我想要提到的是,基于树的模型的独热编码因子通常会导致 性能不佳

现在我们可以定义我们的超参数空间以进行调整。

警告

这在我的四核机器上大约需要 3 分钟。

列表 8.5. 调整 XGBoost 超参数
xgbParamSpace <- makeParamSet(
  makeNumericParam("eta", lower = 0, upper = 1),
  makeNumericParam("gamma", lower = 0, upper = 5),
  makeIntegerParam("max_depth", lower = 1, upper = 5),
  makeNumericParam("min_child_weight", lower = 1, upper = 10),
  makeNumericParam("subsample", lower = 0.5, upper = 1),
  makeNumericParam("colsample_bytree", lower = 0.5, upper = 1),
  makeIntegerParam("nrounds", lower = 20, upper = 20),
  makeDiscreteParam("eval_metric", values = c("merror", "mlogloss")))

randSearch <- makeTuneControlRandom(maxit = 1000)

cvForTuning <- makeResampleDesc("CV", iters = 5)

tunedXgbPars <- tuneParams(xgb, task = xgbTask,
                              resampling = cvForTuning,
                              par.set = xgbParamSpace,
                              control = randSearch)

tunedXgbPars

Tune result:
Op. pars: eta=0.669; gamma=0.368; max_depth=1; min_child_weight=1.26;
subsample=0.993; colsample_bytree=0.847; nrounds=10;
eval_metric=mlogloss; mmce.test.mean=0.0190

因为通常情况下,更多的树会更好,直到我们看不到任何收益为止,所以我通常不会调整 nrounds 超参数,而是根据我的计算预算来设置它(在这里,我将其设置为 20,通过使 lowerupper 参数相同)。一旦我们构建了模型,我们就可以检查在构建了一定数量的树之后错误是否趋于平稳,并决定我们是否需要更多的树或者可以使用更少的树(就像我们对随机森林模型所做的那样)。

一旦我们定义了超参数空间,我们将搜索方法定义为具有 1,000 次迭代的随机搜索。我喜欢将迭代次数设置得尽可能高,尤其是在我们同时调整许多超参数时。我们将交叉验证策略定义为普通的 5 折交叉验证,然后运行调整程序。因为 XGBoost 将使用所有核心并行化构建每个树(在超参数调整期间查看您的 CPU 使用情况),所以我们不会并行化调整程序。

现在,让我们使用调整好的超参数训练最终的 XGBoost 模型。你现在应该开始熟悉这个过程了。我们首先使用 setHyperPars() 创建一个学习器,然后将其传递给 train() 函数。

列表 8.6. 训练最终的调整模型
tunedXgb <- setHyperPars(xgb, par.vals = tunedXgbPars$x)

tunedXgbModel <- train(tunedXgb, xgbTask)

让我们绘制损失函数与迭代次数的对比图,以了解我们是否包含了足够的树。

列表 8.7. 绘制迭代次数与对数损失的对比图
xgbModelData <- getLearnerModel(tunedXgbModel)

ggplot(xgbModelData$evaluation_log, aes(iter, train_mlogloss)) +
  geom_line() +
  geom_point()

首先,我们使用 getLearnerModel() 提取模型数据。接下来,我们可以使用模型数据的 $evaluation_log 组件提取包含每个迭代损失函数数据的 DataFrame。这包含 iter(迭代次数)和 train_mlogloss(该迭代的对数损失)列。我们可以将它们相互绘制以查看损失是否已经平坦化(这表明我们已经训练了足够的树)。

注意

我的超参数调整选择了对数损失作为最佳损失函数。如果您的选择了分类错误,您将需要在这里使用 $train_merror 而不是 $train_mlogloss

列表 8.7 的结果图显示在 图 8.4 中。你能看到对数损失在大约 15 次迭代后平坦化吗?这意味着我们已经训练了足够的树,并没有通过训练过多的树浪费计算资源。

还可以绘制集成中的单个树,这是一种解释模型构建过程的好方法(除非你有大量的树)。为此,我们首先需要安装 DiagrammeR 包,然后将模型数据对象作为参数传递给 XGBoost 包的函数 xgb.plot.tree()。我们还可以使用 trees 参数指定要绘制的树。

图 8.4. 在模型构建过程中绘制对数损失与树数量的对比图。曲线在 15 棵树之后平坦化,表明增加更多树到模型中没有任何好处。

列表 8.8. 绘制单个决策树
install.packages("DiagrammeR")
xgboost::xgb.plot.tree(model = xgbModelData, trees = 1:5)

结果图形显示在 图 8.5 中。注意我们使用的树都很浅,其中一些是决策树桩(第 2 棵树甚至没有分裂)。

提示

我不会讨论图 8.5 中每个节点显示的信息,但为了更好地理解,你可以运行?xgboost::xgb.plot.tree。你还可以使用xgboost::xgb .plot.multi.trees(xgbModelData)将最终集成表示为单个树结构;这有助于你整体地解释你的模型。

最后,让我们像对随机森林和 rpart 模型所做的那样,对我们的模型构建过程进行交叉验证。

警告

这在我的四核机器上几乎需要 15 分钟!我强烈建议你在这段时间内做些其他事情。

列表 8.9. 绘制单个决策树
outer <- makeResampleDesc("CV", iters = 3)

xgbWrapper <- makeTuneWrapper("classif.xgboost",
                              resampling = cvForTuning,
                              par.set = xgbParamSpace,
                              control = randSearch)

cvWithTuning <- resample(xgbWrapper, xgbTask, resampling = outer)

cvWithTuning
Resample Result
Task: zooXgb
Learner: classif.xgboost.tuned
Aggr perf: mmce.test.mean=0.0390
Runtime: 890.29

太棒了!交叉验证估计我们的模型准确率为 1 - 0.039 = 0.961 = 96.1%!加油 XGBoost!

图 8.5. 从我们的 XGBoost 模型绘制单个树

8.4. 基于树的算法的优缺点

虽然通常很难判断哪些算法对特定任务表现良好,但以下是一些优势和劣势,将帮助你决定随机森林或 XGBoost 是否适合你。

随机森林和 XGBoost 算法的优点如下:

  • 它们可以处理分类和连续预测变量(尽管 XGBoost 需要一些数值编码)。

  • 它们对预测变量的分布没有做出任何假设。

  • 它们可以以合理的方式处理缺失值。

  • 它们可以处理不同尺度的连续变量。

  • 集成技术可以显著提高模型性能,超过单个树。特别是 XGBoost 在减少偏差和方差方面表现出色。

基于树的算法的缺点如下:

  • 与 rpart 相比,随机森林降低了方差,但没有降低偏差(XGBoost 两者都降低)。

  • XGBoost 的调整可能很昂贵,因为它有很多超参数并且按顺序生长树。

8.5. 对比基准算法

在本节中,我将教你们什么是基准测试,我们将用它来比较特定任务上几个算法的性能。你的工具箱中的分类器现在有很多算法!经验是选择特定任务算法的好方法。但请记住,我们总是受到“没有免费午餐”定理的约束。有时你可能会惊讶地发现,一个简单的算法在特定任务上比一个更复杂的算法表现更好。决定哪个算法在特定任务上表现最好的好方法是进行基准测试实验。

基准测试很简单。你创建一个你感兴趣尝试的学习者列表,让他们竞争以找到学习最佳性能模型的一个。让我们用xgbTask来做这件事。

列表 8.10. 绘制单个决策树
learners = list(makeLearner("classif.knn"),
                makeLearner("classif.LiblineaRL1LogReg"),
                makeLearner("classif.svm"),
                tunedTree,
                tunedForest,
                tunedXgb)

benchCV <- makeResampleDesc("RepCV", folds = 10, reps = 5)

bench <- benchmark(learners, xgbTask, benchCV)
bench

  task.id                learner.id mmce.test.mean
1  zooXgb               classif.knn        0.03182
2  zooXgb classif.LiblineaRL1LogReg        0.09091
3  zooXgb               classif.svm        0.07109
4  zooXgb             classif.rpart        0.09891
5  zooXgb      classif.randomForest        0.03200
6  zooXgb           classif.xgboost        0.04564

首先,我们创建了一个包含学习算法列表,包括 k 近邻("classif .knn")、多项式逻辑回归("classif.LiblineaRL1LogReg")、支持向量机("classif.svm")、我们在上一章中训练的 tunedTree 模型,以及我们在本章中训练的 tunedForesttunedXgb 模型。如果你在你的全局环境中不再有定义的 tunedTree 模型,请重新运行 7.1 到 7.8 的代码。

备注

这并不是一个完全公平的比较,因为前三个学习者将使用默认的超参数进行训练,而基于树的模型已经进行了调整。

我们使用 makeResampleDesc() 定义我们的交叉验证方法。这次,我选择了重复 5 次的 10 折交叉验证。重要的是要注意,mlr 在这里很聪明:虽然数据在每次重复时随机分成几部分,但 相同的划分 被用于每个学习者。更简单地说,对于每次交叉验证重复,基准中的每个学习者在每个交叉验证重复中都得到完全相同的训练集和测试集。

最后,我们使用 benchmark() 函数运行基准实验。第一个参数是学习者的列表,第二个参数是任务的名称,第三个参数是交叉验证方法。

我之前告诉过你关于免费午餐的事情吗?谦逊的 k 近邻算法在这个任务上的表现比强大的 XGBoost 算法还要好——即使我们没有对其进行调整!

摘要

  • 随机森林和 XGBoost 算法是用于分类和回归问题的监督学习器。

  • 集成技术构建多个子模型,以产生一个比其单个组件单独表现更好的模型。

  • Bagging 是一种集成技术,它并行地在训练集的 bootstrap 样本上训练多个子模型。然后每个子模型对新案例的预测进行投票。随机森林是 bagging 算法的一个例子。

  • Boosting 是一种集成技术,它按顺序训练多个子模型,其中每个后续子模型专注于前一组子模型的错误。AdaBoost 和 XGBoost 是 boosting 算法的例子。

  • 基准测试使我们能够比较多个算法/模型在单个任务上的性能。

第三部分. 回归

抽空回顾一下你到目前为止学到的内容。假设你已经完成了本书的第一部分和第二部分,你现在拥有了处理大量分类问题的技能。在本部分书中,我们将把重点从预测分类变量转移到预测连续变量。

正如你在第一章中学到的,我们使用术语回归来表示预测连续结果变量的监督机器学习。在第九章(kindle_split_020.html#ch09)至第十二章(kindle_split_023.html#ch12)中,你将学习各种回归算法,这些算法将帮助你处理不同的数据情况。其中一些适合于预测变量与结果之间存在线性关系的场景,并且具有高度的可解释性。其他算法能够模拟非线性关系,但可能不太容易解释。

我们将从介绍线性回归开始——正如你将要学习的,它与我们在第四章中使用的逻辑回归密切相关。事实上,如果你已经熟悉线性回归,你可能想知道为什么我等到现在才介绍线性回归,因为逻辑回归的理论建立在它之上。这是因为为了让你的学习更加简单和愉快,我想分别介绍分类、回归、降维和聚类,这样每个主题在你的脑海中都是独立的。但我希望我们在下一部分将要涵盖的理论将巩固你对逻辑回归的理解。

第九章. 线性回归

本章涵盖

  • 使用线性回归

  • 回归任务的性能指标

  • 使用机器学习算法来填充缺失值

  • 算法性地执行特征选择

  • 在 mlr 中组合预处理包装器

在第三部分“回归”的第一站,我们将来到线性回归。这是一种经典且常用的统计方法,通过估计预测变量与结果变量之间关系的强度来构建预测模型。线性回归之所以得名,是因为它假设预测变量与结果变量之间的关系是线性的。线性回归可以处理连续和分类预测变量,我将在本章中向你展示。

到本章结束时,我希望你能理解使用 mlr 解决回归问题的通用方法,以及这与分类有何不同。特别是,你将了解我们用于回归任务的不同性能指标,因为平均误分类误差(MMCE)不再有意义。我还会像在第四章承诺的那样,展示更复杂的缺失值插补和特征选择方法。最后,我将介绍如何使用序列包装器结合尽可能多的预处理步骤,这样我们就可以将它们包含在我们的交叉验证中。

9.1. 什么是线性回归?

在本节中,你将学习线性回归是什么以及它是如何使用直线的方程来进行预测的。想象一下,你想要根据每批苹果汁中苹果含量的数量(以千克为单位)来预测这些批次的 pH 值。这种关系可能的样子在图 9.1 中有展示。

图 9.1. 苹果含量与苹果汁批次 pH 值变化的假设数据

图 9-1

注意

回想一下高中化学,pH 值越低,物质越酸。

苹果重量与苹果汁 pH 值之间的关系看起来是线性的,我们可以用直线来模拟这种关系。回想一下第一章,描述一条直线所需的唯一参数是斜率和截距:

  • y = intercept + slope × x

y 是结果变量,x 是预测变量,截距是当 x 为零时 y 的值(即直线与 y 轴的交点),斜率是当 x 增加一个单位时 y 的变化量。

注意

解释斜率是有用的,因为它告诉我们结果变量如何随着预测变量(们)的变化而变化,但解释截距通常并不那么直接(或不那么有用)。例如,一个预测弹簧张力的模型,其长度为零时可能有一个正的截距,这表明长度为零的弹簧有张力!如果所有变量都中心化,使得均值为零,那么截距可以解释为 x 均值处的 y 值(这通常是更有用的信息)。以这种方式中心化变量不会影响斜率,因为变量之间的关系保持不变。因此,线性回归模型做出的预测不受数据中心化和缩放的影响。

如果你用普通英语大声读出来,你会说:“对于任何特定的情况,结果变量 y 的值是模型截距,加上预测变量 x 的值乘以其斜率。”

统计学家将这个方程写作

  • y = β[0] + β[1]x[1] + ϵ

其中 β[0] 是截距,β[1] 是变量 x[1] 的斜率,ϵ 是模型未观察到的、无法解释的误差。

注意

线性回归模型的参数(也称为系数)只是真实值的估计。这是因为我们通常只处理来自更广泛人群的有限样本。推导出真实参数值的方法只能是测量整个群体,而这通常是不可能的。

因此,为了学习一个可以预测 pH 值的模型,我们需要一种方法来估计最能代表这种关系的直线截距和斜率。

线性回归在技术上不是一个算法。相反,它是使用直线方程建模关系的途径。我们可以使用几种不同的算法来估计直线的截距和斜率。对于像我们的苹果汁 pH 问题这样的简单情况,最常用的算法是普通最小二乘法(OLS)。

OLS 的职责是学习截距和斜率的值组合,以最小化残差平方和。我们在第七章中遇到了残差的概念,即模型未解释的信息量。在线性回归中,我们可以将此视为案例与直线之间的垂直距离(沿 y 轴)。但 OLS 不仅考虑每个案例与线之间的原始距离:它首先将它们平方,然后将它们全部加起来(因此,平方和)。这在我们苹果汁示例的图 9.2 中得到了说明。

图 9.2. 通过数据找到最小二乘线。残差是案例与线之间的垂直距离。方框的面积代表三个案例的平方残差。截距(β[0])是当x = 0 时线与 y 轴相交的点。斜率是y(Δy)的变化除以x(Δx)的变化。

图片

为什么 OLS 要平方距离?你可能读到这是因为它使得任何负残差(对于位于线下的案例)变为正数,因此它们对平方和的贡献而不是减去它。这当然是一个方便的副产品,但如果这是真的,我们就会简单地使用|残差|来表示绝对值(去除负号)。我们使用平方残差是为了不成比例地惩罚远离其预测值的案例。

9.1.1. 如果我们有多个预测变量怎么办?

最小二乘法(OLS)找到斜率和截距的组合,使得平方和最小化,通过这种方式学习到的线将是最适合数据的线。但回归问题很少像尝试用一个预测变量预测一个结果那样简单;当我们有多个预测变量时怎么办?让我们给我们的苹果汁 pH 问题添加另一个变量:发酵时间(见图 9.3)。

图 9.3. 添加一个额外的变量:每个点的尺寸对应于每个苹果汁批次的发酵时间。

图片

当我们有多个预测变量时,为每个变量估计一个斜率(使用 OLS),并将每个变量的贡献线性相加,同时加上模型截距(现在每个预测变量等于零时y的值)。线性回归中的斜率告诉我们,在保持所有其他预测变量不变的情况下,每个预测变量增加一个单位时,因变量如何变化。换句话说,斜率告诉我们当我们逐个改变预测变量时,因变量如何变化。例如,我们的两个预测变量苹果酒模型看起来会是这样:

  • y = β[0] + β[apples] × apples + β[fermentation] × fermentation + ϵ
备注

你有时会看到单变量线性回归和多变量回归被描述为简单线性回归多元回归,分别。然而,我认为这种区分有点不必要,因为我们很少只处理单个预测变量。

当我们有两个预测变量时,我们的线就变成了一个表面/平面。你可以在图 9.4 中看到我们苹果酒示例的说明。当我们有超过两个预测变量时,我们的平面就变成了超平面。实际上,我们的直线方程可以推广到任何数量的预测变量

  • y = β[0] + β[1]x[1] + β[2]x[2] ... β[k]x[k] + ϵ
图 9.4. 用两个预测变量表示线性模型。在我们的线性模型中将苹果含量和发酵时间结合可以表示为一个表面。实线显示了每个案例的残差误差(其垂直距离从表面)。

其中模型中有 k 个预测变量。这被称为一般线性模型,它是所有线性模型的核心方程。如果你来自传统的统计建模背景,你可能熟悉t检验和方差分析。这些方法都使用一般线性模型来表示预测变量和因变量之间的关系。

备注

一般线性模型并不完全等同于广义线性模型,后者指的是一类允许因变量具有不同分布的模型。我很快就会谈到广义线性模型。

你能认出一般线性模型吗?在我们讲解逻辑回归的第四章时,你曾见过与之类似的东西。实际上,方程右侧的所有内容都是相同的。唯一的区别在于等号左侧的内容。回想一下,在逻辑回归中,我们预测一个案例属于特定类别的对数几率。而在线性回归中,我们只是预测案例的因变量值。

当可解释性与其性能同样重要或更重要时

虽然另一个回归算法可能在特定任务上表现更好,但使用一般线性模型构建的模型通常因其可解释性而受到青睐。斜率告诉你,在保持所有其他变量不变的情况下,结果变量随着每个预测变量单位增加而变化的程度。

还有其他算法可能学习到在特定任务上表现更好的模型,但可解释性较差。这样的模型通常被描述为黑盒,其中模型接受输入并给出输出,但很难看到和/或解释导致该特定输出的模型内部的规则。随机森林、XGBoost 和 SVMs 是黑盒模型的例子。

所以,我们什么时候会倾向于选择一个可解释的模型(例如线性回归模型),而不是表现更好的黑盒模型呢?嗯,一个例子是,如果我们的模型具有区分能力。想象一下,如果模型在训练过程中引入了对女性的偏见。使用黑盒模型可能很难立即检测到这种偏见,而如果我们能解释规则,我们就可以检查这种偏见。类似的考虑还有安全性,确保我们的模型不会给出可能危险的结果(例如不必要的医疗干预)是至关重要的。

另一个例子是,当我们使用机器学习来更好地理解一个系统或自然时。从模型中获得预测可能是有用的,但理解这些规则以深化我们的理解和刺激进一步的研究可能更为重要。黑盒可能会使这变得困难。

最后,理解我们模型的规则允许我们改变做事的方式。想象一下,一家企业使用线性回归模型来预测特定产品的需求,基于其成本和公司在广告上的支出等因素。公司不仅能够预测未来的需求,而且还可以通过解释预测变量如何影响结果来控制需求。

当我们使用一般线性模型来建模我们的数据时,我们假设我们的残差是正态分布的,并且同方差。同方差是一个听起来很荒谬的词(用这个词让你的朋友印象深刻),它仅仅意味着结果变量的方差不会随着结果预测值的增加而增加。

小贴士

同样均方误差的对立面是异方差

我们还假设每个预测变量和结果之间存在线性关系,以及预测变量对响应变量的影响是可加的(而不是乘法的)。

当这些假设有效时,我们的模型将做出更准确和无偏的预测。然而,一般线性模型可以扩展以处理违反正态分布残差假设的情况(逻辑回归就是一个例子)。

注意

我会在本章后面构建我们自己的线性回归模型时,向你展示如何检查这些假设的有效性。

在这种情况下,我们转向广义线性模型。广义线性模型与一般线性模型相同(实际上,后者是前者的一种特殊情况),只不过它使用各种称为链接函数的转换将结果变量映射到等号右侧做出的线性预测。例如,计数数据很少呈正态分布,但通过构建一个具有适当链接函数的广义模型,我们可以将模型做出的线性预测转换回计数。我不打算在这里进一步讨论广义线性模型,但关于这个主题的一个很好的资源(如果有点沉重)是 Peter K. Dunn 和 Gordon K. Smyth 合著的《带有 R 示例的广义线性模型》(Springer,2018 年)。

小贴士

如果残差是异方差性的,有时构建一个预测结果变量某些转换的模型会有所帮助。例如,预测响应变量的对数[10]是一个常见的选择。这样的模型做出的预测可以转换回原始尺度进行解释。当多个预测变量对结果的影响不是加性的,我们可以在模型中添加交互项,说明当一个预测变量变化时,另一个预测变量对结果的影响。

9.1.2. 如果我们的预测变量是分类的怎么办?

到目前为止,我们只考虑了我们的预测变量是连续的情况。因为一般线性模型本质上是一条直线的方程,我们用它来寻找变量之间的斜率,那么我们如何找到一个分类变量的斜率呢?这甚至有意义吗?好吧,结果是我们可以通过将分类变量重新编码为虚拟变量来作弊。虚拟变量是分类变量的新表示,将类别映射到 0 和 1。

假设我们想要根据苹果类型(Gala 或 Braeburn)预测苹果酒的酸度。我们想要找到描述这两种苹果类型和酸度之间关系的截距和斜率,但我们如何做到这一点?记住,早些时候斜率是当x增加一个单位时y增加的量。如果我们重新编码我们的苹果类型变量,使得 Gala = 0 和 Braeburn = 1,我们可以将苹果类型视为连续变量,并找出从 0 到 1 时酸度如何变化。查看图 9.5:截距是x为 0 时y的值,即苹果类型 = Gala 时的平均酸度。因此,Gala 被认为是我们的参考水平。斜率是x增加一个单位时y的变化,这是 Gala 的平均酸度与 Braeburn 的平均酸度之间的差异。这可能感觉像是在作弊,但它有效,并且最小二乘法中的斜率将是连接类别均值的斜率。

注意

你选择哪个类别作为参考水平对模型做出的预测没有影响,并且它是因子的第一级(默认情况下按字母顺序排列)。

图 9.5。使用虚拟变量在分类变量的两个水平之间找到斜率。苹果类型被重新编码为 0 和 1,并被视为连续变量。现在的斜率代表两种苹果类型之间的均值差异,截距代表参考类别(Gala)的均值。

图 9-5

将二进制(两级)因子重新编码为单个虚拟变量,其值为 0 和 1 是有意义的,但如果我们有一个多级因子(具有超过两个级别的因子)怎么办?我们将它们编码为 1、2、3、4 等等,并将它们视为单个连续预测变量?嗯,这不会起作用,因为不太可能有一条直线能连接各个类别的均值。相反,我们创建* k* - 1 个虚拟变量,其中* k* 是因子的级别数。

查看图 9.6 中的示例。图 9.6。我们有四种苹果类型(Granny Smith 是我最喜欢的),并希望根据用于制作特定一批苹果酒的苹果类型来预测 pH 值。为了将我们的四级因子转换为虚拟变量,我们执行以下操作:

  1. 创建一个三列的表格,其中每一列代表一个虚拟变量。

  2. 选择一个参考水平(在本例中为 Gala)。

  3. 将每个虚拟变量的值设为 0 以表示参考水平。

  4. 将每个虚拟变量的值设为 1 以表示特定的因子水平。

图 9.6。将多级分类变量重新编码为k - 1 个虚拟变量。一个四级因子可以用三个(* k* - 1)虚拟变量表示。参考水平(Gala)在每个虚拟变量中的值为 0。其他水平在特定虚拟变量中的值为 1。为每个虚拟变量估计一个斜率。

图 9-6

我们现在将我们的四个级别的单一变量转换成了三个不同的虚拟变量,每个虚拟变量取值为 1 或 0。但这如何帮助我们呢?嗯,每个虚拟变量在模型公式中充当一个标志,表示特定案例属于哪个级别。如图 9.6 所示,完整模型如下

  • y = β[0] + β[d1] d1 + β[d2] d2 + β[d3] d3 + ϵ

现在,因为截距(β[0])代表当所有预测变量都等于 0 时的酸度,所以这现在是参考水平 Gala 的平均值。模型中的斜率(β[d1],β[d2],等等)代表参考水平与其他每个水平平均值的差异。如果一批苹果酒是用某种类型的苹果制作的,其虚拟变量将“开启”该类型苹果与参考类别之间的斜率,并“关闭”其他斜率。例如,假设一批苹果酒是用 Braeburn 苹果制作的。模型将如下所示:

  • y = β[0] + β[d1] × 1 + β[d2] × 0 + β[d3] × 0 + ϵ

其他苹果类型的斜率仍然在模型中,但由于它们的虚拟变量被设置为 0,它们对预测值没有贡献!

我们使用广义线性模型建立的模型可以混合连续和分类预测变量。当我们使用我们的模型对新数据进行预测时,我们只需做以下几步:

  1. 取该数据中每个预测变量的值。

  2. 将这些值乘以模型学习到的相关斜率。

  3. 将这些值相加。

  4. 添加截距。

结果就是该数据的预测值。

我希望到现在你已经对线性回归有了基本的理解,那么让我们通过建立你的第一个线性回归模型来将这一知识转化为技能吧!

9.2. 建立你的第一个线性回归模型

在本节中,我将教你如何建立、评估和解释一个线性回归模型来预测每日空气污染。我还会展示其他填充缺失数据和选择相关特征的方法,以及如何将尽可能多的预处理步骤捆绑到你的交叉验证中。

想象一下,你是一名对预测洛杉矶每日大气臭氧污染水平感兴趣的环境科学家。回想一下高中化学,臭氧是氧分子的一种同素异形体(一种说法是“另一种形式”),它有三个氧原子而不是两个(就像你现在呼吸的二氧化氧那样)。虽然平流层中的臭氧可以保护我们免受太阳紫外线的伤害,但燃烧化石燃料的产物可以在地面转化为臭氧,那里它是有毒的。你的任务是建立一个回归模型,可以根据年份和气象读数(如湿度和温度)预测臭氧污染水平。让我们先加载 mlr 和 tidyverse 包:

library(mlr)

library(tidyverse)

9.2.1. 加载和探索臭氧数据集

现在我们来加载数据,这些数据内置在 mlbench 包中(我喜欢这个包中的数据示例),将其转换为 tibble(使用 as_tibble()),并对其进行探索。我们还将给变量起更易读的名字。我们有一个包含 366 个案例和 13 个变量的 tibble,这些变量是每日气象和臭氧读数。

列表 9.1. 加载和探索 Ozone 数据集
data(Ozone, package = "mlbench")

ozoneTib <- as_tibble(Ozone)

names(ozoneTib) <- c("Month", "Date", "Day", "Ozone", "Press_height",
                     "Wind", "Humid", "Temp_Sand", "Temp_Monte",
                     "Inv_height", "Press_grad", "Inv_temp", "Visib")

ozoneTib

# A tibble: 366 x 13
   Month Date  Day   Ozone Press_height  Wind Humid Temp_Sand Temp_Monte
   <fct> <fct> <fct> <dbl>        <dbl> <dbl> <dbl>     <dbl>      <dbl>
 1 1     1     4         3         5480     8    20        NA       NA
 2 1     2     5         3         5660     6    NA        38       NA
 3 1     3     6         3         5710     4    28        40       NA
 4 1     4     7         5         5700     3    37        45       NA
 5 1     5     1         5         5760     3    51        54       45.3
 6 1     6     2         6         5720     4    69        35       49.6
 7 1     7     3         4         5790     6    19        45       46.4
 8 1     8     4         4         5790     3    25        55       52.7
 9 1     9     5         6         5700     3    73        41       48.0
10 1     10    6         7         5700     3    59        44       NA
# ... with 356 more rows, and 4 more variables: Inv_height <dbl>,
#   Press_grad <dbl>, Inv_temp <dbl>, Visib <dbl>

目前,MonthDayDate 变量是因子。可以说这可能有意义,但在这个练习中我们将它们视为数值。为此,我们使用方便的 mutate_all() 函数,它将数据作为第一个参数,将转换/函数作为第二个参数。在这里,我们使用 as.numeric 将所有变量转换为数值类别。

注意

mutate_all() 函数不会改变变量的名称,它只是就地转换它们。

接下来,这个数据集中有一些缺失数据(使用 map_dbl(ozoneTib, ~sum(is .na(.))) 来查看有多少)。在我们的预测变量中,缺失数据是可以接受的(我们稍后会使用插补来处理这个问题),但我们不能接受我们试图预测的变量的缺失数据。因此,我们通过将 mutate_all() 调用的结果通过管道传递到 filter() 函数中,移除没有臭氧测量的案例,从而移除具有 NA 值的 Ozone 的案例。

列表 9.2. 清洗数据
ozoneClean <- mutate_all(ozoneTib, as.numeric) %>%
  filter(is.na(Ozone) == FALSE)

ozoneClean

# A tibble: 361 x 13
   Month  Date   Day Ozone Press_height  Wind Humid Temp_Sand Temp_Monte
   <dbl> <dbl> <dbl> <dbl>        <dbl> <dbl> <dbl>     <dbl>      <dbl>
 1     1     1     4     3         5480     8    20        NA       NA
 2     1     2     5     3         5660     6    NA        38       NA
 3     1     3     6     3         5710     4    28        40       NA
 4     1     4     7     5         5700     3    37        45       NA
 5     1     5     1     5         5760     3    51        54       45.3
# ... with 356 more rows, and 4 more variables: Inv_height <dbl>,
#   Press_grad <dbl>, Inv_temp <dbl>, Visib <dbl>
注意

我们能否在我们的目标变量中插补缺失数据?是的,我们可以,但这可能会将偏差引入我们的模型。这是因为我们将训练一个模型来预测由模型本身生成的值。

让我们绘制每个预测变量与 Ozone 的关系图,以了解数据中的关系。我们首先使用 gather() 函数收集变量,这样我们就可以在单独的面板上绘制它们。

列表 9.3. 绘制数据
ozoneUntidy <- gather(ozoneClean, key = "Variable",
                      value = "Value", -Ozone)

ggplot(ozoneUntidy, aes(Value, Ozone)) +
  facet_wrap(~ Variable, scale = "free_x") +
  geom_point() +
  geom_smooth() +
  geom_smooth(method = "lm", col = "red") +
  theme_bw()
注意

记得我们必须使用 -Ozone 来防止 Ozone 变量与其他变量一起收集。

在我们的 ggplot() 调用中,我们按 Variable 分面,并通过将 scale 参数设置为 "free_x" 允许面元的 x 轴根据变量变化。然后,除了一个 geom_point 层,我们还添加了两个 geom_smooth 层。第一个 geom_smooth 没有给出任何参数,因此使用默认设置。默认情况下,如果案例少于 1,000 个,geom_smooth 将在数据上绘制 LOESS 曲线(一条曲线,局部回归线),如果案例有 1,000 个或更多,则绘制 GAM 曲线。这两种方法都会给我们一个关于关系形状的线索。第二个 geom_smooth 层专门要求 lm 方法(线性模型),它绘制最佳拟合数据的线性回归线。绘制这两个层将帮助我们识别数据中是否存在非线性关系。

结果图示在图 9.7 中。嗯,一些预测变量与臭氧水平呈线性关系,一些呈非线性关系,还有一些似乎完全没有关系!

图 9.7。在Ozone数据集中,将每个预测变量与Ozone变量进行绘图。直线代表线性回归线,曲线代表 GAM 线。

图 9-7

9.2.2. 插补缺失值

线性回归无法处理缺失值。因此,为了避免丢弃大量数据集,我们将使用插补来填补空白。在第四章中,我们使用均值插补用变量的均值替换缺失值(NA)。虽然这可能有效,但它只使用了该单个变量内的信息来预测缺失值,并且一个变量中的所有缺失值都将取相同的值,这可能会对模型产生偏差。相反,我们实际上可以使用机器学习算法来预测缺失观察值的值,使用数据集中所有其他变量!在本节中,我将向你展示我们如何使用 mlr 来完成这项工作。

如果你运行 ?imputations,你将能够看到 mlr 附带的各种插补方法。这些方法包括例如 imputeMean()imputeMedian()imputeMode()(分别用每个变量的均值、中位数和众数来替换缺失值)。但最重要的方法是列表中最后一个:imputeLearner()imputeLearner() 函数允许我们指定一个监督机器学习算法来预测缺失值可能是什么,基于所有其他变量中包含的信息。例如,如果我们想插补连续变量的缺失值,过程如下:

  1. 将数据集分为包含和不包含缺失值的特定变量案例。

  2. 决定一个回归算法来预测缺失值可能是什么。

  3. 仅考虑没有缺失值的案例,使用算法预测变量缺失值的值,使用数据集中的其他变量(包括你最终模型中试图预测的因变量)。

  4. 仅考虑包含缺失值的案例,使用第 3 步中学习的模型根据其他预测变量的值来预测缺失值。

在插补分类变量时,我们采用相同的策略,只是我们选择一个分类算法而不是回归算法。因此,我们最终使用一个监督学习算法来填补空白,以便我们可以使用另一个算法来训练我们的最终模型!

我们如何选择一个插补算法呢?有几个实际考虑因素,但就像往常一样,这多少取决于具体情况,尝试不同的方法并看看哪种方法能给你带来最佳性能可能是有益的。我们至少最初可以将其缩小到分类或回归算法,这取决于缺失值的变量是连续的还是分类的。接下来,我们是否在一个或多个变量中存在缺失值是有区别的,因为如果是后者,我们需要选择一个可以自己处理缺失值的算法。例如,假设我们尝试使用逻辑回归来插补一个分类变量的缺失值。我们将到达上一个流程的第 3 步并停止,因为数据中的其他变量(算法试图用来预测分类变量的)也包含缺失值。逻辑回归无法处理这种情况,并将抛出错误。如果只有缺失值的变量是我们试图插补的,这就不会是问题。最后,唯一其他的考虑因素是计算预算。如果你用来学习最终模型的算法已经计算成本很高,使用一个计算成本高的算法来插补你的缺失值将增加额外的开销。在这些限制条件下,通常最好的做法是尝试不同的插补学习器,看看哪个最适合当前任务。

在进行任何形式的缺失值插补时,确保数据是随机缺失(MAR)或完全随机缺失(MCAR),而不是非随机缺失(MNAR)这一点极为重要。如果数据是 MCAR,这意味着缺失值的可能性与数据集中的任何变量无关。如果数据是 MAR,这意味着缺失值的可能性仅与数据集中其他变量的值有关。例如,某人可能因为年龄而不太可能填写他们的薪水。在这两种情况下,我们仍然可以构建由于缺失数据的存在而具有无偏性的模型。但考虑这种情况,某人可能因为他们的薪水低而不太可能填写他们的薪水。这是一个数据非随机缺失(MNAR)的例子,其中缺失值的可能性取决于变量的值本身。在这种情况下,你可能会构建一个倾向于高估调查中人们薪水的模型。

我们如何判断我们的数据是 MCAR、MAR 还是 MNAR?并不容易。有方法可以区分 MCAR 和 MAR。例如,您可以构建一个分类模型,预测一个案例是否对特定变量有缺失值。如果模型在预测缺失值方面比随机猜测做得更好,那么数据就是 MAR。如果模型做得不比随机猜测好多少,那么数据可能就是 MCAR。有没有办法判断数据是否是 MNAR?很遗憾,没有。确信您的数据不是 MNAR 取决于良好的实验设计和对预测变量的深思熟虑。

小贴士

有一种更强大的插补技术叫做多重插补。多重插补的前提是您创建许多新的数据集,在每个数据集中用合理的值替换缺失数据。然后,您在每个插补数据集上训练一个模型,并返回平均模型。虽然这可能是最广泛使用的插补技术,但遗憾的是,它还没有在 mlr 中实现,所以我们在这里不会使用它。然而,我强烈建议您阅读 R 中 mice 包的文档。

对于我们的臭氧数据,我们在几个变量上都有缺失值,而且它们都是连续变量。因此,我将选择一个可以处理缺失数据的回归算法:rpart。是的,您没听错:我们将使用 rpart 决策树算法来插补缺失值。在我们讨论基于树的算法时第七章,我们只考虑了它们用于分类问题;但决策树也可以用来预测连续变量。我将在第十二章中详细展示这是如何工作的;但现在,我们将让 rpart 做它的事情,为我们插补缺失值。

列表 9.4. 使用 rpart 插补缺失值
imputeMethod <- imputeLearner("regr.rpart")

ozoneImp <- impute(as.data.frame(ozoneClean),
                   classes = list(numeric = imputeMethod))

我们首先使用imputeLearner()函数来定义我们将要使用什么算法来插补缺失值。我们提供给这个函数的唯一参数是学习者的名称,在这种情况下是"regr.rpart"

小贴士

此外,还有一个可选的参数,features,允许我们指定在预测缺失值时使用数据集中的哪些变量。默认情况下,使用所有其他变量,但您可以使用它来指定没有任何缺失值的变量,这样您就可以使用无法处理缺失数据的算法。有关更多详细信息,请参阅?imputeLearner

接下来,我们使用impute()函数创建填充后的数据集,其中第一个参数是数据。我们只是将我们的 tibble 包裹在as.data.frame()函数中,以防止重复警告数据是 tibble 而不是数据框(这些可以安全忽略)。我们可以通过向cols参数提供一个命名列表来为不同的列指定不同的填充技术。例如,我们可以说cols = list(var1 = imputeMean(), var2 = imputeLearner("regr.lm"))。我们还可以通过在相同的classes参数中使用相同的方式为不同类别的变量指定不同的填充技术(一个技术用于数值变量,另一个用于因子)。在下面的列表中,我们使用classes参数使用我们定义的imputeMethod来填充所有变量(它们都是数值变量)。

这将产生一个我们可以使用ozoneImp$data访问的数据集,其中缺失值已被 rpart 算法学习到的模型预测值所替换。现在我们可以使用填充后的数据集定义我们的任务和学习器。通过将"regr.lm"作为makeLearner()函数的参数,我们告诉 mlr 我们想要使用线性回归。

列表 9.5. 定义我们的任务和学习器
ozoneTask <- makeRegrTask(data = ozoneImp$data, target = "Ozone")

lin <- makeLearner("regr.lm")
注意

在本书的第二部分(part 2),我们习惯于将学习器定义为classif .[ALGORITHM]。在本书的这一部分,而不是classif.,前缀将是regr.。这很重要,因为同一个算法有时可以用于分类和回归,所以前缀告诉 mlr 我们想要使用算法执行哪种任务。

9.2.3. 自动化特征选择

有时可能很明显哪些变量没有预测价值,可以从分析中移除。领域知识在这里也非常重要,我们在这里包括我们认为对我们要研究的输出结果有某些预测价值的变量。但通常更好的方法是采取更客观的特征选择方法,并允许算法为我们选择相关特征。在本节中,我将向您展示我们如何在 mlr 中实现这一点。

自动化特征选择有两种方法:

  • 过滤方法— 过滤方法将每个预测变量与结果变量进行比较,并计算结果变量随预测变量变化的度量。这个度量可以是相关系数:例如,如果两个变量都是连续的。预测变量将按照这个度量排序(理论上,按照它们可以贡献给模型的信息量排序),我们可以选择从我们的模型中删除一定数量或比例的最差表现变量。我们可以将删除变量数量或比例作为模型构建过程中的超参数进行调整。

  • 包装方法— 在包装方法中,我们不是使用单个模型外的统计量来估计特征重要性,而是迭代地使用不同的预测变量训练我们的模型。最终,选择能够给出最佳性能的预测变量组合。有几种不同的方法可以做到这一点,但一个例子是顺序前向选择。在顺序前向选择中,我们从没有预测变量开始,然后逐个添加预测变量。在算法的每个步骤中,选择导致模型性能最佳的特性。最后,当添加任何更多预测变量都不会提高性能时,特征添加停止,并在选定的预测变量上训练最终模型。

我们应该选择哪种方法?这归结为:包装方法可能会导致性能更好的模型,因为我们实际上正在使用我们正在训练的模型来估计预测变量的重要性。然而,由于我们在选择过程的每次迭代中(每个步骤可能包括其他预处理步骤,如插补),包装方法往往计算成本较高。另一方面,过滤方法可能或可能不会选择表现最佳的预测变量集,但计算成本要低得多。

特征选择中的过滤方法

我将向您展示我们臭氧示例中的两种方法,首先是过滤方法。我们可以使用许多指标来估计预测变量的重要性。要查看 mlr 中内置的可用过滤方法列表,请运行 listFilterMethods()。描述全部内容太多,但常见的选项包括这些:

  • 线性相关性— 当预测变量和结果变量都是连续变量时

  • 方差分析(ANOVA)— 当预测变量是分类变量且结果变量是连续变量时

  • 卡方检验(Chi-squared)— 当预测变量和结果变量都是连续变量时

  • 随机森林重要性— 可以用于预测变量和结果变量是分类变量或连续变量的情况(默认)

小贴士

欢迎尝试 mlr 中实现的方法。其中许多方法需要您首先安装 FSelector 包:install.packages("FSelector")

mlr 默认使用的方法(因为它不依赖于变量是否为分类变量)是构建随机森林来预测结果,并返回对模型预测贡献最大的变量(使用我们在第八章中讨论的袋外误差)。在这个例子中,由于预测变量和结果变量都是连续的,我们将使用线性相关性来估计变量重要性(它比随机森林的重要性更容易解释)。

首先,我们使用 generateFilterValuesData() 函数(最长的函数名!)为每个预测变量生成一个重要性度量。第一个参数是任务,其中包含我们的数据集,并让函数知道 Ozone 是我们的目标变量。第二个可选参数是 method,我们可以向其中提供 listFilterMethods() 列出的方法之一。在这个例子中,我使用了 "linear.correlation"。通过提取此对象中的 $data 组件,我们得到包含预测变量及其皮尔逊相关系数的表格。

列表 9.6. 使用过滤方法进行特征选择
filterVals <- generateFilterValuesData(ozoneTask,
                                       method = "linear.correlation")

filterVals$data

           name    type linear.correlation
1         Month numeric           0.053714
2          Date numeric           0.082051
3           Day numeric           0.041514
4  Press_height numeric           0.587524
5          Wind numeric           0.004681
6         Humid numeric           0.451481
7     Temp_Sand numeric           0.769777
8    Temp_Monte numeric           0.741590
9    Inv_height numeric           0.575634
10   Press_grad numeric           0.233318
11     Inv_temp numeric           0.727127
12        Visib numeric           0.414715

plotFilterValues(filterVals) + theme_bw()

将此信息解释为图表更为容易,我们可以使用 plotFilterValues() 函数生成图表,将保存过滤值的对象作为其参数。生成的图表显示在图 9.8 中。

练习 1

ozoneTask 生成和绘制过滤值,但使用默认方法 randomForestSRC_importance(不要覆盖 filterVals 对象)。两种方法中变量的重要性排名是否相同?

现在我们有了按估计重要性对预测变量进行排名的方法,我们可以决定如何“去除”最不具信息量的变量。我们使用 filterFeatures() 函数来完成这项任务,该函数将任务作为第一个参数,将我们的 filterVals 对象作为 fval 参数,以及 absperthreshold 参数。abs 参数允许我们指定要保留的最佳预测变量的绝对数量。per 参数允许我们指定要保留的最佳预测变量的百分比。threshold 参数允许我们指定过滤度量(在本例中为相关系数)的值,预测变量必须超过此值才能被保留。我们可以手动使用这三种方法之一过滤我们的预测变量。这将在下面的列表中展示,但我已经注释掉了这些行,因为我们不会这样做。相反,我们可以将我们的学习器(线性回归)和过滤方法包装在一起,这样我们就可以将 absperthreshold 视为超参数并进行调整。

图 9.8. 使用 plotFilterValues() 绘制每个预测变量与臭氧水平的相关性

![fig9-8_alt.jpg]

列表 9.7. 手动选择要删除的特征
#ozoneFiltTask <- filterFeatures(ozoneTask,
#                                fval = filterVals, abs = 6)
#ozoneFiltTask <- filterFeatures(ozoneTask,
#                                fval = filterVals, per = 0.25)
#ozoneFiltTask <- filterFeatures(ozoneTask,
#                                fval = filterVals, threshold = 0.2)

为了将我们的学习器和过滤方法包装在一起,我们使用 makeFilterWrapper() 函数,将我们定义的线性回归学习器作为 learner 参数,并将我们的过滤度量作为 fw.method 参数。

列表 9.8. 创建过滤包装器
filterWrapper = makeFilterWrapper(learner = lin,
                                  fw.method = "linear.correlation")
警告

注意术语混淆!我们仍在使用 过滤方法 进行特征选择。不幸的是,我们创建了一个 过滤包装器,但这并不是特征选择的 包装方法。我们将在稍后介绍这一点。

当我们将学习器和预处理步骤组合在一起时,两者的超参数都成为我们包装学习器的一部分可供调整。在这种情况下,这意味着我们可以使用交叉验证调整absperthreshold超参数,以选择最佳性能的特征。在这个例子中,我们将调整要保留的绝对特征数量。

列表 9.9. 调整要保留的预测因子数量
lmParamSpace <- makeParamSet(
  makeIntegerParam("fw.abs", lower = 1, upper = 12)
)

gridSearch <- makeTuneControlGrid()

kFold <- makeResampleDesc("CV", iters = 10)

tunedFeats <- tuneParams(filterWrapper, task = ozoneTask, resampling = kFold,
                        par.set = lmParamSpace, control = gridSearch)

tunedFeats

Tune result:
Op. pars: fw.abs=10
mse.test.mean=20.8834
小贴士

如果你运行getParamSet(filterWrapper),你会看到由于我们包装了过滤方法,absperthreshold的超参数名称已经变成了fw.absfw.perfw.threshold。现在,另一个有用的超参数fw.mandatory.feat允许你强制某些变量被包含,无论它们的分数如何。

首先,我们像往常一样使用makeParamSet()定义超参数空间,并将fw.abs定义为介于 1 到 12 之间的整数(我们将保留的最小和最大特征数量)。接下来,我们使用makeTuneControlGrid()定义我们熟悉的老朋友——网格搜索,这将尝试我们超参数的每个值。我们使用makeResampleDesc()定义一个普通的 10 折交叉验证策略,然后使用tuneParams()进行调优。第一个参数是我们的包装学习器,然后我们提供我们的任务、交叉验证方法、超参数空间和搜索过程。

我们的选择过程挑选出与臭氧相关性最高的 10 个预测因子作为最佳性能组合。但什么是mse.test.mean?你之前还没有见过这个性能指标。好吧,我们用于分类的性能指标,如平均误分类误差,在预测连续变量时没有意义。对于回归问题,有三个常用的性能指标:

  • 平均绝对误差 (MAE)——找到每个案例与模型之间的绝对残差,将它们全部加起来,然后除以案例数量。我们可以将这解释为案例与模型之间的平均绝对距离。

  • 均方误差 (MSE)——与 MAE 类似,但在找到平均值之前将残差平方。这意味着 MSE 比 MAE 对异常值更敏感,因为平方残差的大小随着与模型预测的距离的平方增长。MSE 是 mlr 中回归学习者的默认性能指标。MSE 或 MAE 的选择取决于你如何处理数据中的异常值:如果你想让你的模型能够预测这样的案例,使用 MSE;否则,如果你想让你的模型对异常值不太敏感,使用 MAE。

  • 均方根误差(RMSE)—— 由于 MSE 对残差进行平方,其值与结果变量的尺度不同。相反,如果我们对 MSE 取平方根,我们得到的是 RMSE。在调整超参数和比较模型时,MSE 和 RMSE 总是会选择相同的模型(因为 RMSE 只是 MSE 的一种变换),但 RMSE 的好处是它与我们的结果变量处于相同的尺度,因此更具可解释性。

提示

我们还有其他回归性能指标可供选择,例如 MAE 和 MSE 的百分比版本。如果你对阅读 mlr 中可用的更多性能指标感兴趣(而且有很多),请运行?measures

练习 2

在列表 9.8 和 9.9 中重复特征过滤过程,但使用默认的fw.method参数(randomForestSRC_importance,或者不提供它)。这会选择与使用线性相关性相同的预测因子数量吗?哪种方法更快?

使用均方误差(MSE)性能指标,我们的调整滤波器方法得出结论,保留与臭氧水平相关性最高的 10 个特征会导致性能最佳的模型。我们现在可以训练一个只包含这些前 10 个特征的最终模型。

列表 9.10. 使用过滤特征训练模型
filteredTask <- filterFeatures(ozoneTask, fval = filterVals,
                               abs = unlist(tunedFeats$x))

filteredModel <- train(lin, filteredTask)

首先,我们使用filterFeatures()函数创建一个只包含过滤特征的新的任务。我们将现有任务的名称、我们在列表 9.6 中定义的filterVals对象以及要保留的特征数量作为abs函数的参数传递给这个函数。这个值可以作为tunedFeats$x组件访问,并且需要用unlist()包装;否则,函数将抛出错误。这创建了一个只包含过滤预测因子并保留Ozone作为目标变量的新任务。最后,我们使用这个任务训练线性模型。

特征选择的包装方法

使用过滤方法,我们生成描述每个预测因子如何与结果变量相关的单变量统计量。这可能会导致选择最有信息的预测因子,但并不保证。相反,我们可以使用我们试图训练的实际模型来确定哪些特征有助于它做出最佳预测。这有可能选择更好的预测因子组合,但因为它为预测变量的每个排列都训练一个新的模型,所以计算成本更高。

让我们首先定义我们将如何搜索最佳预测因子组合。我们有四种选择:

  • 穷举搜索— 这基本上是一个网格搜索。它将尝试你数据集中预测变量可能的所有组合,并选择表现最好的那个。这保证找到最佳组合,但可能速度过慢。例如,在我们的 12 个预测变量数据集中,穷举搜索需要尝试超过 1.3 × 10⁹种不同的变量组合!

  • 随机搜索— 这就像超参数调优中的随机搜索。我们定义一系列迭代次数,并随机选择特征组合。最终迭代后的最佳组合获胜。这通常不那么密集(取决于你选择的迭代次数),但它并不保证找到最佳特征组合。

  • 顺序搜索— 从一个特定的起点开始,我们在每个步骤中添加或移除特征,以改善性能。这可以是以下之一:

    • 正向搜索— 我们从一个空模型开始,依次添加改进模型最多的特征,直到额外的特征不再提高性能。

    • 反向搜索— 我们从所有特征开始,移除移除后模型改进最大的特征,直到额外的移除不再提高性能。

    • 浮动正向搜索— 从一个空模型开始,我们在每个步骤中添加一个变量或移除一个变量, whichever improves the model the most,直到添加或移除都不再提高模型性能。

    • 浮动反向搜索— 与浮动正向搜索相同,只是我们从完整的模型开始。

  • 遗传算法— 这种方法受到达尔文进化论的启发,找到作为“父母”的“后代”变量组合的特征组合对,这些组合继承了表现最好的特征。这种方法非常酷,但随着特征空间的扩大,计算成本可能会很高。

哇!有这么多选项可以选择,我们从哪里开始呢?嗯,我发现对于大特征空间,穷举搜索和遗传搜索速度过慢。虽然随机搜索可以缓解这个问题,但我发现顺序搜索在计算成本和找到最佳性能特征组合的概率之间是一个很好的折衷方案。在其不同的变体中,你可能想尝试不同的选项,看看哪个会产生最佳性能的模型。我喜欢浮动版本,因为它们在每个步骤都考虑了添加和移除,所以在这个例子中,我们将使用浮动反向选择。

首先,我们使用 makeFeatSelControlSequential() 函数定义搜索方法(哇,mlr 的作者真的非常喜欢他们的长函数名)。我们使用 "sfbs" 作为方法参数来使用序列浮点向后选择。然后,我们使用 selectFeatures() 函数执行特征选择。我们将学习者、任务、在列表 9.9 中定义的交叉验证策略和搜索方法提供给此函数。就这么简单。当我们运行函数时,使用我们的 kFold 策略对预测变量的每个排列进行交叉验证,以获得其性能的估计。通过打印此过程的结果,我们可以看到算法选择了六个预测变量,其 MSE 值略低于列表 9.9 中通过过滤器方法选择的预测变量。

小贴士

要查看所有可用的包装器方法和如何使用它们,请运行 ?FeatSelControl

现在我需要提醒你关于序列浮点前向搜索的一个令人沮丧的错误。截至本文写作时,在某些情况下,使用 "sffs" 作为特征选择方法将抛出以下错误:Error in sum(x) : invalid 'type' (list) of argument。如果你尝试在这个例子中使用 "sffs" 作为搜索方法,可能会遇到这样的错误。因此,虽然这非常令人沮丧,但我选择使用序列浮点向后搜索 ("sfbs") 代替。

列表 9.11. 使用包装方法进行特征选择
featSelControl <- makeFeatSelControlSequential(method = "sfbs")

selFeats <- selectFeatures(learner = lin, task = ozoneTask,
                           resampling = kFold, control = featSelControl)

selFeats

FeatSel result:
Features (6): Month, Press_height, Humid, Temp_Sand, Temp_Monte, Inv_height
mse.test.mean=20.4038

现在,就像我们对过滤器方法所做的那样,我们可以使用只包含所选预测因子的插补数据创建一个新的任务,并在其上训练模型。

列表 9.12. 使用包装方法进行特征选择
ozoneSelFeat <- ozoneImp$data[, c("Ozone", selFeats$x)]

ozoneSelFeatTask <- makeRegrTask(data = ozoneSelFeat, target = "Ozone")

wrapperModel <- train(lin, ozoneSelFeatTask)

9.2.4. 在交叉验证中包含插补和特征选择

我已经说过很多次了,但我要再说一遍:在交叉验证中包含所有数据相关的预处理步骤!但到目前为止,我们只需要考虑一个预处理步骤。我们如何组合多个步骤呢?嗯,mlr 使得这个过程非常简单。当我们把一个学习者和一个预处理步骤包装在一起时,我们实际上创建了一个包含该预处理的新学习算法。因此,为了包含一个额外的预处理步骤,我们只需包装包装过的学习者!我在图 9.9 中展示了这一点。这导致了一种类似套娃的包装器,其中一个是被另一个封装的,然后是被另一个封装的,以此类推。

图 9.9. 组合多个预处理包装器。一旦一个学习者和预处理步骤(如插补)在包装器中组合,这个包装器就可以用作另一个包装器中的学习者。

图 9-9

使用这个策略,我们可以组合任意多的预处理步骤来创建一个管道。最内层的包装器将始终首先使用,然后是下一个内层的,依此类推。

注意

因为最内层的包装器首先使用,一直到最外层,所以仔细思考你希望预处理步骤采取的顺序是很重要的。

让我们通过实际操作来加强这一点。我们将创建一个填补包装器,然后将其作为学习器传递给一个特征选择包装器。

列表 9.13. 结合填补和特征选择包装器
imputeMethod <- imputeLearner("regr.rpart")

imputeWrapper <- makeImputeWrapper(lin,
                                   classes = list(numeric = imputeMethod))

featSelWrapper <- makeFeatSelWrapper(learner = imputeWrapper,
                                     resampling = kFold,
                                     control = featSelControl)

首先,我们使用imputeLearner()函数(首次定义在列表 9.4)重新定义我们的填补方法。然后,我们使用makeImputeWrapper()函数创建一个填补包装器,该函数将学习器作为第一个参数。我们使用list(numeric = imputeMethod)作为classes参数,将此填补策略应用于我们所有的数值预测因子(所有这些,duh)。

现在是时候展示一些巧妙的部分了:我们使用makeFeatSelWrapper()创建一个特征选择包装器,并提供我们创建的填补包装器作为学习器。这是关键步骤,因为我们正在创建一个包含另一个包装器的包装器!我们将交叉验证方法设置为kFold(在列表 9.9 中定义)和搜索特征组合的方法为featSelControl(在列表 9.11 中定义)。

现在,让我们像优秀的数据科学家一样交叉验证我们的整个模型构建过程。

列表 9.14. 交叉验证模型构建过程
library(parallel)
library(parallelMap)

ozoneTaskWithNAs <- makeRegrTask(data = ozoneClean, target = "Ozone")

kFold3 <- makeResampleDesc("CV", iters = 3)

parallelStartSocket(cpus = detectCores())

lmCV <- resample(featSelWrapper, ozoneTaskWithNAs, resampling = kFold3)

parallelStop()

lmCV

Resample Result
Task: ozoneClean
Learner: regr.lm.imputed.featsel
Aggr perf: mse.test.mean=20.5394
Runtime: 86.7071

在加载了并行和 parallelMap 包的朋友之后,我们使用ozoneClean tibble 定义一个任务,它仍然包含缺失数据。接下来,我们为交叉验证过程定义一个普通的 3 折交叉验证策略。最后,我们使用parallelStartSocket()开始并行化,并通过将学习器(包装的包装器)、任务和交叉验证策略提供给resample()函数来启动交叉验证过程。在我的四核机器上,这几乎花费了 90 秒,所以我建议你开始这个过程,然后继续阅读代码的总结。

交叉验证过程如下进行:

  1. 将数据分为三个折叠。

  2. 对于每个折叠:

    1. 使用 rpart 算法来填补缺失值。

    2. 执行特征选择:更新模板以支持超过两层嵌套有序列表。

    3. 使用选择方法(例如反向搜索)来选择特征组合以训练模型。

    4. 使用 10 折交叉验证来评估每个模型的表现。

  3. 返回三个外部折叠中每个表现最好的模型。

  4. 返回平均 MSE 以给出我们性能的估计。

我们可以看到,我们的模型构建过程给我们一个平均均方误差(MSE)为 20.54,这表明在原始臭氧尺度上的平均残差误差为 4.53(取 20.54 的平方根)。

9.2.5. 解释模型

由于它们的简单结构,线性模型通常很容易解释,因为我们可以通过查看每个预测因子的斜率来推断结果变量受到的影响程度。然而,这些解释是否合理取决于是否满足某些模型假设,因此在本节中,我将向您展示如何解释模型输出并生成一些诊断图。

首先,我们需要使用 getLearnerModel() 函数从我们的模型对象中提取模型信息。通过在模型数据上调用 summary(),我们得到一个包含大量关于我们模型信息的输出。请看下面的列表。

列表 9.15. 解释模型
wrapperModelData <- getLearnerModel(wrapperModel)

summary(wrapperModelData)

Call:
stats::lm(formula = f, data = d)

Residuals:
    Min      1Q  Median      3Q     Max
-13.934  -2.950  -0.284   2.722  13.829
Coefficients:
              Estimate Std. Error t value Pr(>|t|)
(Intercept)  41.796670  27.800562    1.50  0.13362
Month        -0.296659   0.078272   -3.79  0.00018
Press_height -0.010353   0.005161   -2.01  0.04562
Wind         -0.122521   0.128593   -0.95  0.34136
Humid         0.076434   0.014982    5.10  5.5e-07
Temp_Sand     0.227055   0.043397    5.23  2.9e-07
Temp_Monte    0.266534   0.063619    4.19  3.5e-05
Inv_height   -0.000474   0.000185   -2.56  0.01099
Visib        -0.005226   0.003558   -1.47  0.14275

Residual standard error: 4.46 on 352 degrees of freedom
Multiple R-squared:  0.689, Adjusted R-squared:  0.682
F-statistic: 97.7 on 8 and 352 DF,  p-value: <2e-16

Call 组件通常会告诉我们创建模型所使用的公式(哪些变量,以及我们是否在它们之间添加了更复杂的关系)。由于我们使用 mlr 构建了这个模型,所以我们很遗憾地在这里没有获得这些信息;但模型公式是所有选定的预测因子线性组合在一起的结果。

Residuals 组件为我们提供了关于模型残差的某些汇总统计信息。在这里,我们正在查看中位数是否大约为 0,以及第一四分位数和第三四分位数是否大约相同。如果它们不相同,这可能会表明残差要么不是正态分布的,要么是异方差性的。在这两种情况下,这不仅可能对模型性能产生负面影响,还可能使我们对斜率的解释不正确。

Coefficients 组件显示了一个模型参数及其标准误差的表格。截距为 41.8,这是当所有其他变量都为 0 时臭氧水平的估计值。在这个特定情况下,某些变量为 0(例如 month)实际上并没有太多意义,所以我们不会过多地从这个结果中得出解释。预测因子的估计值是它们的斜率。例如,我们的模型估计,当 Temp_Sand 变量增加一个单位时,Ozone 增加 0.227(保持所有其他变量不变)。Pr(>|t|) 列包含的 p 值,在理论上,如果总体斜率实际上是 0,则表示看到如此大的斜率的概率。无论如何,请使用 p 值来指导你的模型构建过程;但与 p 值相关的问题有一些,所以不要过分依赖它们。

最后,Residual standard error 与 RMSE 相同,Multiple R-squared 是我们模型解释数据方差的估计比例(68.9%),而 F-statistic 是模型解释的方差与模型未解释的方差的比率。这里的 p 值是对我们模型比仅使用 Ozone 的平均值进行预测更好的概率的估计。

注意

注意残差标准误差值接近但并不等于通过交叉验证估计的模型构建过程的 RMSE。这种差异是因为我们交叉验证了模型构建过程,而不是这个特定的模型本身。

我们可以通过将模型数据作为plot()函数的参数来快速轻松地打印线性模型的诊断图。通常,这将提示你按 Enter 键循环浏览图表。我发现这很烦人,所以我更喜欢使用par()函数的mfrow参数将绘图设备分成四个部分。这意味着当我们创建我们的诊断图(将有四个)时,它们将在同一个绘图窗口中平铺。这些图表可能有助于我们识别影响预测性能的模型缺陷。

小贴士

我随后又用par()函数将其改回。

列表 9.16. 创建模型的诊断图
par(mfrow = c(2, 2))
plot(wrapperModelData)
par(mfrow = c(1, 1))

结果图如图 9.10 所示。残差与拟合值图显示了每个案例的预测臭氧水平(x 轴)和残差(y 轴)。我们希望在这个图中没有模式。换句话说,误差量不应取决于预测值。在这种情况下,我们有一个曲线关系。这表明我们在预测因子和臭氧之间存在非线性关系,以及/或异方差性。

图 9.10. 为我们的线性模型绘制诊断图。残差与拟合值图和尺度位置图有助于识别非线性异方差性的模式。正态 Q-Q 图有助于识别残差的非正态性,而残差与杠杆作用图有助于识别有影响力的异常值。

正态 Q-Q(分位数-分位数)图显示了模型残差的分位数与如果它们是从理论正态分布中抽取的分位数之间的关系。如果数据与 1:1 对角线有显著偏差,这表明残差不是正态分布的。这似乎不是这个模型的问题:残差很好地排列在对角线上。

尺度位置图帮助我们识别残差的异方差性。这里不应该有模式,但看起来残差随着预测值的增大而越来越多样化,这表明存在异方差性。

最后,残差与杠杆作用图帮助我们识别对模型参数有过度影响的案例(潜在的异常值)。落在称为库克距离的虚线区域内的案例可能是异常值,其包含或排除对模型有很大影响。因为我们甚至看不到库克距离在这里(它超出了坐标轴限制),所以我们不用担心异常值。

这些诊断图(尤其是残差与拟合图)表明预测变量和结果变量之间存在非线性关系。因此,我们可能能够从假设线性的模型中获得更好的预测性能。在下一章中,我将向您展示广义加性模型是如何工作的,我们将训练一个模型来提高我们的模型性能。我建议您保存您的 .R 文件,因为我们将在下一章继续使用相同的 dataset 和任务。这样我可以向您强调非线性对线性回归性能的影响有多大。

9.3. 线性回归的优缺点

虽然通常很难判断哪些算法会对特定任务表现良好,但以下是一些优缺点,这将帮助您决定线性回归是否适合您。

线性回归的优点如下:

  • 它产生的模型非常易于解释。

  • 它可以处理连续和分类预测变量。

  • 它在计算上非常经济。

线性回归的缺点如下:

  • 它对数据做出了强烈的假设,例如同方差性、线性性和残差的分布(如果违反这些假设,性能可能会受到影响)。

  • 它只能学习数据中的线性关系。

  • 它无法处理缺失数据。

练习 3

而不是使用包装方法,使用过滤器方法交叉验证构建我们的模型过程。估计的 MSE 值是否相似?哪种方法更快?提示:

  1. 首先,使用我们的 imputeWrapper 作为学习器创建一个过滤器包装器。

  2. 定义一个超参数空间,使用 makeParamSet() 调整 "fw.abs"

  3. 定义一个调整包装器,它将过滤器包装器作为学习器并执行网格搜索。

  4. 使用 resample() 进行交叉验证,使用调整包装器作为学习器。

摘要

  • 线性回归可以处理连续和分类预测变量。

  • 线性回归使用直线的方程来模拟数据中的关系,表现为直线。

  • 可以使用使用所有其他变量信息的监督学习算法来填充缺失值。

  • 自动特征选择有两种形式:过滤器方法和包装方法。

  • 特征选择的过滤器方法在模型之外计算单变量统计量,以估计预测变量与结果之间的关系。

  • 包装方法通过在预测变量的不同排列中主动训练模型来选择最佳性能的组合。

  • 在 mlr 中可以通过包装函数的顺序包装将预处理步骤组合在一起。

练习题的解答

  1. 使用默认的 randomForestSRC_importance 方法生成过滤器值:

    filterValsForest <- generateFilterValuesData(ozoneTask,
                                  method = "randomForestSRC_importance")
    
    filterValsForest$data
    
    plotFilterValues(filterValsForest) + theme_bw()
    
    # The randomForestSRC_importance method ranks variables
    # in a different order of importance.
    
  2. 使用默认的过滤器统计量重复特征过滤:

    filterWrapperDefault <- makeFilterWrapper(learner = lin)
    
    tunedFeats <- tuneParams(filterWrapperDefault, task = ozoneTask,
                             resampling = kFold, par.set = lmParamSpace,
                             control = gridSearch)
    
    tunedFeats
    
    # The default filter statistic (randomForestSRC) tends to select fewer
    # predictors in this case, but the linear.correlation statistic was faster.
    
  3. 使用过滤器方法交叉验证构建线性回归模型:

    filterWrapperImp <- makeFilterWrapper(learner = imputeWrapper,
                                       fw.method = "linear.correlation")
    filterParam <- makeParamSet(
      makeIntegerParam("fw.abs", lower = 1, upper = 12)
    )
    
    tuneWrapper <- makeTuneWrapper(learner = filterWrapperImp,
                                   resampling = kFold,
                                   par.set = filterParam,
                                   control = gridSearch)
    
    filterCV <- resample(tuneWrapper, ozoneTask, resampling = kFold)
    
    filterCV
    
    # We have a similar MSE estimate for the filter method
    # but it is considerably faster than the wrapper method. No free lunch!
    

第十章. 使用广义加性模型的非线性回归

本章涵盖

  • 在线性回归中包含多项式项

  • 在回归中使用样条

  • 使用广义加性模型(GAMs)进行非线性回归

在 第九章 中,我向您展示了如何使用线性回归创建非常可解释的回归模型。线性回归做出的最强假设之一是每个预测变量和结果之间存在线性关系。这通常并不成立,因此在本章中,我将向您介绍一类模型,它允许我们模拟数据中的非线性关系。

我们将首先讨论如何在线性回归中包含 多项式 项来模拟非线性关系,以及这样做的好处和坏处。然后,我们将转向更复杂的 广义加性模型,这些模型为我们提供了更多的灵活性来模拟复杂非线性关系。我还会向您展示这些广义加性模型如何处理连续变量和分类变量,就像在线性回归中一样。

到本章结束时,我希望您能理解如何创建仍然非常可解释的非线性回归模型。我们将继续使用我们在上一章中使用过的臭氧数据集。如果您在全局环境中不再有定义的 ozoneClean 对象,只需重新运行 第九章的列表 9.1 和 9.2。

10.1. 使用多项式项使线性回归非线性

在本节中,我将向您展示如何将我们在上一章讨论的通用线性模型扩展,以包括预测变量和结果变量之间的非线性、多项式关系。线性回归强加了一个假设,即预测变量和结果之间存在线性关系。有时现实世界的变量具有线性关系,或者可以被一个线性关系充分近似,但通常并非如此。当面对非线性关系时,通用线性模型显然会失效,对吧?毕竟,它被称为通用 线性 模型,并使用直线方程。然而,结果出人意料,通用线性模型非常灵活,我们可以用它来模拟 多项式 关系。

回想一下高中数学,多项式方程只是一个包含多个项(单个数字或变量)的方程。如果方程中的所有项都提高到 1 的幂(即 1 的指数)——换句话说,它们都等于自身——那么这个方程就是一个一次多项式。如果方程中的最高指数是 2——换句话说,一个或多个项是平方的,但没有更高的指数——那么这个方程就是一个二次多项式二次方程。如果最高指数是 3,那么方程就是一个三次多项式;如果最高指数是 4,那么方程就是一个四次多项式

小贴士

虽然有更高次多项式的名称,但人们通常只是称它们为n次多项式(例如,五次多项式)。当然,除非你想显得特别聪明!

让我们看看一些n次多项式的例子:

  • y = x¹ (线性)

  • y = x² (二次方)

  • y = x³ (三次方)

  • y = x⁴ (四次方)

这些函数的形状在图 10.1 中显示,x的值在-30 到 30 之间。当指数为 1 时,函数是直线;但当指数大于 1 时,函数是曲线的。

我们可以利用这一点:如果我们的预测变量与结果变量之间的关系是曲线关系,我们可能可以通过在模型定义中包含n次多项式来模拟这种关系。回想一下我们在第九章中的苹果醋例子。想象一下,如果我们不是苹果含量与苹果醋批次 pH 值之间的线性关系,而是像图 10.2 中所示的下凹曲线关系,那么一条直线就不再很好地模拟这种关系,这种模型做出的预测很可能有很高的偏差。相反,我们可以在模型定义中包含一个二次项来更好地模拟这种关系。

图 10.1. 从一次到四次多项式函数的形状。当x变量提高到一次幂时,方程模拟一条直线。随着x提高的幂次增加,方程模拟的线条具有不同程度的灵活性。

图 10.2. 比较线性拟合和二次拟合在苹果含量与苹果醋酸度之间的假设非线性关系

图 10.2 中所示模型的公式将是

  • y = β[apples] × apples + β[apples]² × apples² + ϵ

其中β[apples]²是apples²项的斜率,这更容易理解为随着苹果含量的增加,线条弯曲的程度(绝对值越大,曲线越极端)。对于单个预测变量,我们可以将任何n次多项式关系推广为

  • y = β[0] + β[1]x + β[1]x² + ... β[n]x^n + ϵ

其中 n 是你正在建模的多项式的最高次数。请注意,在进行多项式回归时,通常还会包括该预测变量的所有低次项。例如,如果你正在建模两个变量之间的四次关系,你会在你的模型定义中包含 xx²,x³和 x⁴ 项。为什么这样做呢?如果我们不在模型中包含低次项,曲线的顶点——它变平的部分(取决于曲线弯曲的方向,在曲线的顶部或底部)——被迫通过 x = 0。这可能是对模型的一个合理的约束,但通常不是这样。相反,如果我们把低次项包含在模型中,曲线就不需要通过 x = 0,并且可以“摇摆”得更多,以(希望)更好地拟合数据。这通过图 10.3 进行了说明。

图 10.3. 比较包含和不包含一次项的多项式函数的形状。垂直虚线表示每个函数顶点在 x 轴上的位置。

正如我们在第九章中看到的,当模型接收到新数据时,它会将预测变量的值(包括指定的指数)乘以它们的斜率,然后将它们全部与截距相加以得到预测值。我们使用的模型仍然是广义线性模型,因为我们是以线性方式组合模型项(将它们相加)。

10.2. 更大的灵活性:样条函数和广义加性模型

当在线性回归中使用多项式项时,我们使用的多项式次数越高,我们的模型就越灵活。高次多项式使我们能够捕捉数据中的复杂非线性关系,但因此也更可能过度拟合训练集。有时,增加多项式的次数也没有帮助,因为预测变量和结果变量之间的关系可能不会跨越预测变量的整个范围。在这种情况下,我们不是使用高次多项式,而是可以使用样条函数。在本节中,我将解释样条函数是什么,如何使用它们,以及它们与多项式和一组称为广义加性模型(GAMs)的模型之间的关系。

样条函数是一种分段多项式函数。这意味着它将预测变量分割成若干区域,并在每个区域内拟合一个单独的多项式,这些区域通过节点相互连接。节点是沿着预测变量位置的点,它将区域分割成单独多项式拟合的区域。预测变量每个区域的多项式曲线都通过界定该区域的节点。这使我们能够模拟预测变量范围中不恒定的复杂非线性关系。这通过我们的苹果汁示例在图 10.4 中进行了说明。

图 10.4. 将样条拟合到非线性关系。实心点表示节点。单个多项式函数在节点之间拟合数据,并通过它们相互连接。

使用样条是一种建模复杂关系的极好方法,如图 10.4 中所示,但这种方法有一些局限性:

  • 节点的位置和数量需要手动选择。这两个选择都会对样条形状产生重大影响。节点的位置选择通常是数据中变化明显的区域,或者在预测变量的常规间隔处,例如在四分位数处。

  • 需要选择节点之间多项式的次数。我们通常使用三次样条或更高次,因为这些确保多项式通过节点平滑地连接(二次多项式可能在节点处使样条断开)。

  • 将不同预测器的样条组合起来可能会变得困难。

那么,我们能否比简单的样条回归做得更好?当然可以。解决方案是 GAMs(广义加性模型)。GAMs 扩展了广义线性模型,使得不再

  • y = β[0] + β[1]x + β[2]x[2] + ... β[2]x[2] + ϵ

它们具有以下形式

  • y = β[0] + f1 + f2 + ...fk + ϵ

其中每个f(x)代表特定预测变量的函数。这些函数可以是任何类型的平滑函数,但通常将是多个样条的组合。

注意

你能看出广义线性模型是广义加性模型的一个特例,其中每个预测变量的函数是恒等函数 (f(x) = x)吗?我们可以更进一步,并说广义线性模型是广义加性模型的一个特例。这是因为我们还可以使用 GAMs 中的不同连接函数,这使我们能够使用它们来预测分类变量(如逻辑回归)或计数变量。

10.2.1. GAMs 如何学习它们的平滑函数

图 10.5. GAMs 中连续变量的平滑函数通常是多个基函数之和,这些基函数通常是样条。在每个x的值处,将三个样条基函数相加来预测y的值。虚线显示了三个基函数的和,它模拟了数据中的非线性关系。

构建这些平滑函数最常见的方法是使用样条函数作为基函数。基函数是简单的函数,可以组合成更复杂的函数。请看图 10.5。变量xy之间的非线性关系被建模为三个样条函数的加权总和。换句话说,对于x的每个值,我们都会将这些基函数的每个贡献相加,从而得到一个模型关系的函数(虚线)。整体函数是一个加权的总和,因为每个基函数都有一个相应的权重,决定了它对最终函数的贡献程度。

让我们再次看看 GAM 公式:

  • y = β[0] + f1 + f2 + ... fk + ϵ

因此,每个f[k](x[k])是该特定变量的平滑函数。当这些平滑函数使用样条函数作为基函数时,该函数可以表示为

  • f(x[i]) = a[1]b1 + a[2]b2 + ... + a[n]b[n](x[i])

其中b1 是在x的特定值处评估的第一个基函数的值,而a[1]是第一个基函数的权重。GAMs 通过最小化模型的残差平方误差来估计这些基函数的权重。

GAMs 自动学习每个预测变量和结果变量之间的非线性关系,然后将这些效应线性地加在一起,包括截距。GAMs 通过以下方式克服了在一般线性模型中仅使用样条函数的局限性:

  • 自动选择样条函数的节点

  • 通过控制基函数的权重来自动选择平滑函数的灵活性程度

  • 允许我们同时组合多个预测变量的样条函数

小贴士

如果我想使用线性建模,并且我的预测变量和结果变量之间的关系是非线性的,那么 GAMs 是我的首选模型。这是因为它们的灵活性和克服多项式回归局限性的能力。例外情况是,如果我有理论上的理由相信数据中存在特定的多项式关系(比如,二次),在这种情况下,使用带有多项式项的线性回归可能会导致一个更简单的模型,而 GAMs 可能会过拟合。

10.2.2. GAMs 如何处理分类变量

到目前为止,我已经向你展示了 GAMs 如何学习预测变量和结果变量之间的非线性关系。但是,当我们的预测变量是分类变量时怎么办呢?嗯,GAMs 可以通过两种不同的方式处理分类变量。

一种方法是将分类变量处理得与一般线性模型完全相同,并为每个预测变量的每个水平创建* k* - 1 个虚拟变量,以编码每个水平对结果的影响。当我们使用这种方法时,案例的预测值只是所有平滑函数的总和,加上分类变量效应的贡献。这种方法假设分类变量和连续变量之间是独立的(换句话说,平滑函数在分类变量的每个水平上都是相同的)。

另一种方法是为分类变量的每个水平建模一个单独的平滑函数。在连续变量和每个分类变量水平上的结果之间存在明显非线性关系的情况下,这一点很重要。

注意

当通过 mlr 指定 GAM 作为我们的学习器时,默认方法是第一种方法。

GAMs 对于广泛的机器学习问题来说非常灵活且强大。如果您想深入了解 GAMs 的细节,我推荐 Simon Wood 的《广义加性模型:R 语言导论》(Chapman and Hall/CRC,2017 年)。

我希望到现在为止,您已经对多项式回归和 GAMs 有了基本的了解,所以让我们通过构建您的第一个非线性回归模型来将这种知识转化为技能!

10.3. 构建你的第一个 GAM

我们通过检查线性回归模型的诊断图完成了第九章的内容,并决定数据中似乎存在非线性关系。因此,在本节中,我将向您展示如何使用 GAM(广义加性模型)来建模数据,以解释预测变量和结果之间的非线性关系。

我将从一些特征工程开始。从第九章图 9.7 来看,MonthOzone之间存在一种曲线关系,夏季达到峰值,冬季下降。因为我们也有月份的日信息,让我们看看是否可以通过结合这两个变量来获得更预测的价值。换句话说,我们不是从年月分辨率来获取数据,而是从年日分辨率来获取数据。

为了实现这一点,我们创建了一个名为DayOfYear的新列。我们使用interaction()函数生成一个包含DateMonth变量信息的变量。因为interaction()函数返回一个因子,所以我们将其包裹在as.numeric()函数中,将其转换为表示年日的数值向量。

练习 1

为了更好地了解interaction()函数的作用,运行以下代码:

interaction(1:4, c("a", "b", "c", "d"))

因为新变量包含了DateMonth变量的信息,我们使用select()函数将它们从数据中删除——它们现在是多余的。然后我们绘制我们的新变量,以查看它与Ozone的关系。

列表 10.1. 创建 DateMonth 之间的交互
ozoneForGam <- mutate(ozoneClean,
                      DayOfYear = as.numeric(interaction(Date, Month))) %>%
               select(c(-"Date", -"Month"))

ggplot(ozoneForGam, aes(DayOfYear, Ozone)) +
  geom_point() +
  geom_smooth() +
  theme_bw()

结果图显示在 图 10.6 中。啊哈!如果我们使用天而不是月来分辨,臭氧水平和年份之间的关系就更加清晰了。

练习 2

在图表中添加另一个 geom_smooth() 层,使用以下参数将二次多项式线拟合到数据:

  • method = "lm"

  • formula = "y ~ x + I(x²)"

  • col = "red"

这个多项式关系是否很好地拟合了数据?

图 10.6. 将 DayOfYear 变量与臭氧水平绘制成图

现在我们定义我们的任务、插补包装器和特征选择包装器,就像我们为线性回归模型所做的那样。遗憾的是,mlr 的作者还没有实现普通 GAM 的包装(例如来自 mgcv 包)。然而,我们仍然可以访问 gamboost 算法,该算法使用提升(正如你在第八章中了解的那样)来学习 GAM 模型的集成。因此,对于这个练习,我们将使用 regr.gamboost 学习者。除了不同的学习者(regr.gamboost 而不是 regr.lm),我们创建插补和特征选择包装器的方式与 列表 9.13 中完全相同。

列表 10.2. 定义任务和包装器
gamTask <- makeRegrTask(data = ozoneForGam, target = "Ozone")

imputeMethod <- imputeLearner("regr.rpart")

gamImputeWrapper <- makeImputeWrapper("regr.gamboost",
                                    classes = list(numeric = imputeMethod))

gamFeatSelControl <- makeFeatSelControlSequential(method = "sfbs")

kFold <- makeResampleDesc("CV", iters = 10)

gamFeatSelWrapper <- makeFeatSelWrapper(learner = gamImputeWrapper,
                                        resampling = kFold,
                                        control = gamFeatSelControl)
注意

mlr 的作者编写它是为了允许几乎任何机器学习算法的集成。如果你想要使用尚未由 mlr 包装的包中的算法,你可以自己实现它,以便可以使用 mlr 的功能。虽然这样做并不超级复杂,但确实需要一些解释。因此,如果你想这样做,我建议遵循 mng.bz/gV5x 上的 mlr 教程,它很好地解释了整个过程。

剩下的工作就是交叉验证模型构建过程。由于 gamboost 算法比线性回归计算量更大,所以我们只将 holdout 作为外部交叉验证的方法。

警告

这在我的四核机器上运行大约需要 1.5 分钟。

列表 10.3. 交叉验证 GAM 模型构建过程
holdout <- makeResampleDesc("Holdout")

gamCV <- resample(gamFeatSelWrapper, gamTask, resampling = holdout)

gamCV

Resample Result
Task: ozoneForGam
Learner: regr.gamboost.imputed.featsel
Aggr perf: mse.test.mean=16.4009
Runtime: 147.441

太好了!我们的交叉验证表明,使用 gamboost 算法对数据进行建模将优于通过线性回归学习到的模型(后者在上一章中给出了平均 MSE 为 22.8)。

现在我们实际上构建一个模型,这样我就可以向您展示如何调查您的 GAM 模型,以了解它们为预测变量学习到的非线性函数。

警告

这在我的四核机器上运行大约需要 3 分钟。

列表 10.4. 训练 GAM
library(parallel)
library(parallelMap)

parallelStartSocket(cpus = detectCores())

gamModel <- train(gamFeatSelWrapper, gamTask)

parallelStop()

gamModelData <- getLearnerModel(gamModel, more.unwrap = TRUE)

首先,我们使用gamTask训练一个增强型广义线性混合模型(GAM)。我们可以直接使用gamFeatSelWrapper作为我们的学习器,因为它会为我们执行插补和特征选择。为了加快速度,我们可以在运行train()函数实际训练模型之前,通过运行parallelStartSocket()函数来并行化特征选择。

我们使用getLearnerModel()函数提取模型信息。这次,因为我们的学习器是一个包装函数,我们需要提供一个额外的参数,more.unwrap = TRUE,来告诉 mlr 它需要一路向下通过包装器来提取基础模型信息。

现在,让我们通过绘制模型为每个预测变量学习到的函数来更好地理解我们的模型。这就像在我们的模型信息上调用plot()一样简单。我们还可以通过使用resid()函数提取残差来查看模型中的残差。这允许我们绘制预测值(通过提取$fitted()组件)与其残差的关系,以寻找表明拟合不良的模式。我们还可以使用qqnorm()qqline()将残差的分位数与理论正态分布的分位数进行比较,以查看它们是否呈正态分布。

列表 10.5. 绘制我们的 GAM
par(mfrow = c(3, 3))

plot(gamModelData, type = "l")

plot(gamModelData$fitted(), resid(gamModelData))

qqnorm(resid(gamModelData))

qqline(resid(gamModelData))

par(mfrow = c(1, 1))
小贴士

由于我们即将为每个预测变量创建一个子图,以及两个残差图,我们首先使用par()函数的mfrow参数将绘图设备分成九个部分。我们使用相同的函数将其恢复。您可能具有与我不同的预测变量数量,这是从您的特征选择中返回的。

结果图显示在图 10.7。对于每个预测变量,我们得到一个其值与该预测变量对其值范围内的臭氧估计贡献的图。线条显示了算法学习到的函数形状,我们可以看到它们都是非线性的。

图 10.7. 绘制我们的 GAM 学习到的非线性关系。每个图底部的地毯显示了沿 x 轴的每个案例的位置。残差与拟合图(第二行的中间面板)显示了一个表明异方差性的模式,而正态 Q-Q 图(第二行的右侧面板)显示残差呈正态分布。

小贴士

每个图底部“地毯”状的刻度标记表示训练案例的位置。这有助于我们识别每个变量中案例数量较少的区域,例如在Visib变量的顶部。GAM 在案例数量较少的区域有过度拟合的潜力。

最后,查看残差图,我们仍然可以看到一个模式,这可能表明数据中的异方差性。我们可以尝试在转换后的Ozone变量(如 log[10])上训练模型,看看这是否有帮助,或者使用不做出这种假设的模型。分位数图显示,大部分残差都靠近对角线,表明它们近似于正态分布,尾部有一些偏差(这是不常见的)。

10.4. GAMs 的优势和劣势

虽然通常不容易判断哪些算法会对给定的任务表现良好,但以下是一些优势和劣势,这将帮助您决定 GAMs 是否会对您表现良好。

GAMs 的优势如下:

  • 尽管是非线性的,但它们产生的模型非常可解释。

  • 它们可以处理连续和分类预测变量。

  • 它们可以自动学习数据中的非线性关系。

GAMs 的劣势如下:

  • 它们仍然对数据做出强烈的假设,例如同方差性和残差的分布(如果违反这些假设,性能可能会受到影响)。

  • GAMs 倾向于过度拟合训练集。

  • GAMs 在预测训练集值范围之外的数据时可能特别差。

  • 它们无法处理缺失数据。

练习 3

正如第九章练习 3 中所述,而不是使用包装器方法,使用 filter 方法交叉验证构建我们的 GAM 的过程。估计的 MSE 值是否相似?哪种方法更快?提示:

  1. 首先,创建一个 filter 包装器,使用gamImputeWrapper作为学习器。

  2. 定义一个超参数空间以调整"fw.abs",使用makeParamSet()

  3. 使用makeTuneControlGrid()创建一个网格搜索定义。

  4. 定义一个 tune 包装器,它接受 filter 包装器作为学习器并执行网格搜索。

  5. 使用resample()执行交叉验证,使用 tune 包装器作为学习器。

摘要

  • 多项式项可以包含在线性回归中,以模拟预测变量和结果之间的非线性关系。

  • 广义加性模型(GAMs)是用于回归问题的监督学习器,可以处理连续和分类预测变量。

  • GAMs 使用直线方程,但允许预测变量和结果之间存在非线性关系。

  • GAMs 学习的非线性函数通常是通过对一系列基函数求和创建的样条函数。

练习题解答

  1. 尝试使用interaction()函数:

    interaction(1:4, c("a", "b", "c", "d"))
    
  2. 添加一个geom_smooth()层,将二次关系拟合到数据中:

    ggplot(ozoneForGam, aes(DayOfYear, Ozone)) +
      geom_point() +
      geom_smooth() +
      geom_smooth(method = "lm", formula = "y ~ x + I(x²)", col = "red") +
      theme_bw()
    
    # The quadratic polynomial does a pretty good job of modeling the
    # relationship between the variables.
    
  3. 使用 filter 方法交叉验证构建 GAM:

    filterWrapperImp <- makeFilterWrapper(learner = gamImputeWrapper,
                                       fw.method = "linear.correlation")
    
    filterParam <- makeParamSet(
      makeIntegerParam("fw.abs", lower = 1, upper = 12)
    )
    
    gridSearch <- makeTuneControlGrid()
    
    tuneWrapper <- makeTuneWrapper(learner = filterWrapperImp,
                                   resampling = kFold,
                                   par.set = filterParam,
                                   control = gridSearch)
    
    filterGamCV <- resample(tuneWrapper, gamTask, resampling = holdout)
    
    filterGamCV
    

第十一章. 使用岭回归、LASSO 和弹性网络防止过度拟合

本章涵盖

  • 在回归问题中管理过度拟合

  • 理解正则化

  • 使用 L1 和 L2 范数来收缩参数

我们的社会充满了制衡。在我们的政治体系中,政党相互制衡(理论上)以找到既不是彼此观点的极端解决方案。专业领域,如金融服务,有监管机构来防止它们做错事,并确保他们所说的和所做的是真实和正确的。当涉及到机器学习时,我们发现我们可以将我们自己的形式应用于学习过程,以防止算法过度拟合训练集。我们将这种机器学习中的制衡称为正则化

11.1. 什么是正则化?

在本节中,我将解释正则化是什么以及为什么它有用。正则化(有时也称为收缩)是一种防止模型参数变得过大并将它们“缩小”到 0 的技术。正则化的结果是,当在新数据上做出预测时,模型具有更小的方差。

注意

回想一下,当我们说一个模型有“更小的方差”时,我们的意思是它在新的数据上做出更稳定的预测,因为它对训练集中的噪声不太敏感。

虽然我们可以将正则化应用于大多数机器学习问题,但它最常用于线性建模,其中它将每个预测器的斜率参数缩小到 0。以下三种特别著名且常用的线性模型正则化技术如下:

  • 岭回归

  • 最小绝对收缩和选择算子(LASSO)

  • 弹性网络

这三种技术可以被视为线性模型的扩展,用于减少过拟合。因为它们将模型参数缩小到 0,它们还可以通过强制信息量少的预测器对预测没有或可忽略的影响来自动执行特征选择。

注意

当我说“线性建模”时,我指的是使用我在第九章和第十章中展示的广义线性模型、广义线性模型或广义加性模型对数据进行建模。

到本章结束时,我希望你能对正则化是什么、它是如何工作的以及为什么它很重要有一个直观的理解。你将了解岭回归和 LASSO 是如何工作的以及它们为什么有用,以及弹性网络是如何结合两者的。最后,你将构建岭回归、LASSO 和弹性网络模型,并使用基准测试来比较它们以及与没有正则化的线性回归模型。

11.2. 什么是岭回归?

在本节中,我将向您展示岭回归是什么,它是如何工作的,以及为什么它是有用的。请查看图 11.1,这是我从第三章中复制的示例。我在第三章中使用这个图向您展示分类问题中欠拟合和过拟合的样子。当我们欠拟合问题时,我们会以一种无法很好地捕捉决策边界附近局部差异的方式划分特征空间。当我们过拟合时,我们过于重视这些局部差异,最终得到的决策边界捕捉了训练集中的大部分噪声,导致决策边界过于复杂。

图 11.1. 对于二分类问题的欠拟合、最佳拟合和过拟合的示例。虚线代表决策边界。

现在请看图 11.2,它展示了欠拟合和过拟合在回归问题中的样子。当我们欠拟合数据时,我们会错过关系中的局部差异,并产生一个具有高偏差(做出不准确预测)的模型。当我们过拟合数据时,我们的模型对关系的局部差异过于敏感,具有高方差(将在新数据上做出非常变化的预测)。

图 11.2. 对于单预测回归问题的欠拟合、最佳拟合和过拟合的示例。虚线代表回归线。

注意

我用来阐述这个观点的例子是一个非线性关系,但这个例子也适用于线性关系模型。

正则化的主要任务是防止算法学习过拟合的模型,通过不鼓励复杂性来实现。这是通过惩罚大的模型参数,将它们缩小到 0 来实现的。这听起来可能有些反直觉:普通最小二乘法(OLS,见第九章)学习的模型参数无疑是最好的,因为它们最小化了残差误差。问题是,这只有在训练集上是必要的,而不是在测试集上。

考虑图 11.3 中的例子。在左侧图中,假设我们只测量了两个较深阴影的案例。OLS 会学习一条穿过这两个案例的线,因为这会最小化平方和。我们在研究中收集了更多的案例,当我们将它们绘制在右侧图中时,我们可以看到我们最初训练的模型对新数据推广得不好。这是由于抽样误差,即我们样本案例中的数据分布与我们要进行预测的更广泛群体中的数据分布之间的差异。在这个(稍微有些人为的)案例中,因为我们只测量了两个案例,样本并不能很好地代表更广泛的群体,我们学习到的模型过度拟合了训练集。

这就是正则化的作用所在。虽然 OLS 会学习最适合训练集的模型,但训练集可能并不能完美地代表更广泛的群体。过度拟合训练集更有可能导致模型参数过大,因此正则化会给最小二乘法添加一个惩罚项,该惩罚项随着估计的模型参数增大而增大。这个过程通常会给模型添加一点偏差,因为我们有意地欠拟合训练集,但模型方差减少通常会导致更好的模型。这在预测者与案例之间的比例很大的情况下尤其正确。

图 11.3. 抽样误差导致模型无法很好地推广到新数据。在左侧示例中,拟合回归线时只考虑了较深阴影的案例。在右侧示例中,使用了所有案例来构建回归线。虚线有助于表明左侧的斜率幅度大于右侧。

图片 11-3

注意

你的数据集代表更广泛群体的程度取决于你精心规划数据获取、避免在实验设计中引入偏差(或者如果数据已经存在,识别并纠正它),并确保你的数据集足够大,以便学习真实模式。如果你的数据集不能很好地代表更广泛的群体,没有任何机器学习技术,包括交叉验证,能够帮助你!

因此,正则化可以帮助防止由于采样误差导致的过拟合,但正则化可能更为重要的用途是防止包含虚假预测因子。如果我们向现有的线性回归模型中添加预测因子,我们很可能会在训练集上得到更好的预测。这可能会让我们(错误地)相信,通过包含更多的预测因子,我们正在创建一个更好的模型。这有时被称为厨房水槽回归(因为所有东西都放进去,包括厨房水槽)。例如,想象一下,您想预测某一天公园的人数,并且您将那天 FTSE 100 的值作为一个预测因子。除非公园靠近伦敦证券交易所,否则 FTSE 100 的值不太可能对公园的人数有影响。保留这个虚假预测因子在模型中可能会导致训练集过拟合。因为正则化会缩小这个参数,它将减少模型过拟合训练集的程度。

正则化也可以帮助解决那些病态的问题。数学中的病态问题是那些不满足以下三个条件的问题:有解、有唯一解以及解依赖于初始条件。在统计建模中,一个常见的病态问题是当参数的数量高于案例数量时,通常会遇到没有最优参数值的情况。在这种情况下,正则化可以使估计参数的问题更加稳定。

我们添加到最小二乘估计中的这种惩罚看起来是什么样子?常用的两种惩罚是 L1 范数和 L2 范数。我将首先向您展示 L2 范数是什么以及它是如何工作的,因为这是岭回归中使用的正则化方法。然后,我将扩展这个概念向您展示 LASSO 如何使用 L1 范数方法,以及弹性网络如何结合 L1 和 L2 范数。

11.3. L2 范数是什么,岭回归是如何使用它的?

在本节中,我将向您展示 L2 范数的数学和图形解释,岭回归如何使用它,以及为什么您会使用它。想象一下,您想根据当天的温度预测您当地的公园会有多繁忙。这个数据可能看起来的一个例子如图 11.4 所示。

图 11.4. 基于温度预测公园人数的模型计算平方和

注意

我意识到可能有人来自使用华氏度或摄氏度来测量温度的国家,所以我展示了开尔文尺度,让每个人都同样感到烦恼。

当使用 OLS 时,对于每个截距和斜率的特定组合,都会计算每个案例的残差并平方。然后,将这些平方残差全部加起来,得到平方和。我们可以在数学符号中表示为方程 11.1。

方程 11.1.

方程 11-1

y[i]是案例i的因变量值,而ŷ[i]是模型预测的值。这是每个案例与线的垂直距离。希腊字母 sigma 简单地意味着我们计算这个垂直距离并将其平方,对于从第一个案例(i = 1)到最后一个案例(n)的所有案例,然后将所有这些值加起来。

机器学习算法通过最小化参数的最佳组合来选择数学函数,这些函数被称为损失函数。因此,最小二乘法是 OLS 算法的损失函数。

岭回归稍微修改了最小二乘损失函数,以包括一个使函数值随着参数估计值的增大而增大的项。因此,算法现在必须平衡选择最小化平方和的模型参数,以及选择最小化这个新惩罚的参数。在岭回归中,这个惩罚被称为L2 范数,它非常容易计算:我们只需将所有模型参数平方并相加(除了截距)。当我们只有一个连续预测因子时,我们只有一个参数(斜率),所以 L2 范数就是它的平方。当我们有两个预测因子时,我们分别平方每个斜率,然后将这些平方相加,依此类推。这在我们公园的例子中图 11.5 中得到了说明。

图 11.5. 计算温度和公园人数之间的斜率的平方和与 L2 范数。

图 11-5

注意

你能看出,一般来说,模型拥有的预测因子越多,其 L2 范数就越大,因为我们正在将它们的平方相加吗?因此,岭回归正则化惩罚过于复杂的模型(因为它们有太多的预测因子)。

为了我们可以控制我们想要惩罚模型复杂性的程度,我们将 L2 范数乘以一个称为lambda(λ,因为希腊字母听起来总是很酷)的值。Lambda可以是 0 到无穷大之间的任何值,它充当一个音量旋钮:lambda的大值会强烈惩罚模型复杂性,而小值会弱化惩罚模型复杂性。Lambda不能从数据中估计出来,因此它是一个需要通过交叉验证调整的超参数,以实现最佳性能。一旦我们计算出 L2 范数并将其乘以lambda,然后我们再将这个乘积加到平方和中,以得到我们的惩罚最小二乘损失函数。

注意

如果我们将lambda设为 0,这将从方程中移除 L2 范数惩罚,我们就会回到 OLS 损失函数。如果我们将lambda设为一个非常大的值,所有斜率都会收缩到接近 0。

如果我们数学思维,那么我们可以用数学符号表示,如方程 11.2。你能看出这与方程 11.1 中的平方和相同,但我们增加了λ和 L2 范数项吗?

方程 11.2。

因此,岭回归学习一组模型参数,以最小化这个新的损失函数。想象一下我们有很多预测因子的情况。OLS 可能估计一组模型参数,这些参数在最小化平方损失函数方面做得很好,但这个组合的 L2 范数可能非常大。在这种情况下,岭回归将估计一组参数,这些参数的平方损失值略高,但 L2 范数显著降低。因为当模型参数较小时,L2 范数会变小,所以岭回归估计的斜率可能会比 OLS 估计的斜率小。

重要

当使用 L2 或 L1 惩罚损失函数时,关键是要首先对预测变量进行缩放(除以它们的标准差,使它们处于相同的尺度)。这是因为我们在添加平方斜率(在 L2 正则化的情况下),这个值对于较大尺度的预测因子(例如毫米与千米)将会大得多。如果我们不首先缩放预测变量,它们将不会得到同等的重要性。

如果你更喜欢图形化的解释 L2 惩罚损失函数(我知道我确实如此),请查看图 11.6。x 轴和 y 轴显示两个斜率参数(β[1]和β[2])的值。阴影轮廓线代表两个参数不同组合的不同平方和值,其中导致最小平方和的组合位于轮廓的中心。以 0 为中心的虚线圆代表乘以不同λ值的 L2 范数,对于β[1]和β[2]的组合,虚线穿过。

图 11.6。岭回归惩罚的图形表示。x 轴和 y 轴代表两个模型参数的值。实心、同心圆代表参数不同组合的平方和值。虚线圆代表乘以λ值的 L2 范数。

注意,当λ = 0 时,圆通过最小化平方和的β[1]和β[2]的组合。当λ增加时,圆对称地向 0 缩小。现在,最小化惩罚损失函数的参数组合是位于圆上的最小平方和组合。换句话说,使用岭回归的最优解总是在圆和围绕 OLS 估计的椭圆的交点处。你能看出随着λ的增加,圆缩小,所选的模型参数组合被吸向 0 吗?

备注

在这个例子中,我展示了 L2 正则化对两个斜率参数的应用。如果我们只有一个斜率,我们将在数轴上表示相同的过程。如果我们有三个参数,在三维空间中也会同样适用,惩罚圆将变成惩罚球体。这将继续适用于你拥有的非截距参数的维度数(其中惩罚变成超球体)。

因此,通过使用 L2 惩罚的损失函数来学习斜率参数,岭回归阻止我们训练过度拟合训练数据的模型。

注意

在计算 L2 范数时,不包括截距,因为它是当所有斜率参数都等于 0 时结果变量的值。

11.4. L1 范数是什么,LASSO 如何使用它?

现在你已经了解了岭回归,学习 LASSO 的工作原理将是你对已学知识的简单扩展。在本节中,我将向你展示 L1 范数是什么,它与 L2 范数有何不同,以及最小绝对收缩和选择算子(LASSO)如何使用它来收缩参数估计。

让我们回顾一下 L2 范数在 方程式 11.3 中的样子。回想一下,我们平方每个斜率参数的值并将它们全部加起来。然后我们将这个 L2 范数乘以 lambda 以得到我们添加到平方损失函数总和的惩罚项。

方程式 11.3.

L1 范数与 L2 范数只有细微的差别。我们不是平方参数值,而是取它们的绝对值,然后求和。这通过 方程式 11.4 中的 β[j] 周围的垂直线来表示。

方程式 11.4.

我们以与岭回归相同的方式创建 LASSO(L1 惩罚的损失函数):我们将 L1 范数乘以 lambda(具有相同的意义)并将其添加到平方和。L1 惩罚的损失函数在 方程式 11.5 中显示。注意,这个方程与 方程式 11.2 的唯一区别在于,我们在求和之前取参数的绝对值,而不是平方它们。假设我们有三个斜率,其中一个是负数:2.2,–3.1,0.8. 这三个斜率的 L1 范数将是 2.2 + 3.1 + 0.8 = 6.1。

方程式 11.5.

我已经能听到你在想,“那又怎样?使用 L1 范数而不是 L2 范数有什么好处/区别?”好吧,岭回归可以将参数估计值缩小到 0,但它们永远不会真正0(除非 OLS 估计一开始就是 0)。所以如果你有一个机器学习任务,你相信所有变量都应该有一定的预测价值,岭回归是非常好的,因为它不会移除任何变量。但如果你有很多变量,或者你想要一个能够为你进行特征选择的算法呢?LASSO 在这里很有帮助,因为与岭回归不同,LASSO确实能够将小的参数值缩小到 0,从而有效地从模型中移除那个预测因子。

让我们以与岭回归相同的方式图形化地表示这一点。图 11.7 显示了与图 11.6 中相同的两个假设参数的平方和的等高线。LASSO 惩罚形成的是一个正方形,旋转了 45 度,使得其顶点沿着轴(我想你可以称之为一个菱形)。你能看到,对于与我们的岭回归示例中相同的lambda,接触菱形的具有最小平方和的参数组合是参数β[2]为 0 的那个吗?这意味着代表这个参数的预测因子已经被从模型中移除了。

图 11.7. LASSO 惩罚的图形表示。x 轴和 y 轴代表两个模型参数的值。实心、同心圆代表参数的不同组合的平方和值。虚线菱形代表乘以lambda的 L2 范数。

注意

如果我们有三个参数,我们可以将 LASSO 惩罚表示为一个立方体(其顶点与轴对齐)。在超过三个维度中可视化这一点是困难的,但 LASSO 惩罚将是一个超立方体。

为了使这一点更加清晰,我在图 11.8 中叠加了 LASSO 和岭回归的惩罚,包括突出显示每种方法选择的参数值的虚线。

11.5. 弹性网络是什么?

在本节中,我将向你展示弹性网络是什么,以及它是如何混合 L2 和 L1 正则化,在岭回归和 LASSO 参数估计之间找到一个折衷方案的。有时你可能有一个先验的理由来解释为什么你想使用岭回归或 LASSO。然而,如果你认为必须将所有预测因子包括在模型中,无论它们的贡献有多小,那么使用岭回归。如果你想算法通过将无信息斜率缩小到 0 来为你进行特征选择,那么使用 LASSO。但通常情况下,岭回归和 LASSO 之间的选择并不是一个明确的选择。在这种情况下,不要在它们之间做出选择:使用弹性网络。

图 11.8. 比较岭回归和 LASSO 惩罚

注意

LASSO 的一个重要限制是,如果你有比案例更多的预测变量,它最多会选择与数据中案例数量相等的预测变量数量。换句话说,如果你的数据集包含 100 个预测变量和 50 个案例,LASSO 将至少将 50 个预测变量的斜率设置为 0!

弹性网络是线性建模的扩展,其损失函数中包括 L2 和 L1 正则化。它找到岭回归和 LASSO 找到的参数估计值之间的组合。我们还可以使用超参数alpha来控制我们对 L2 范数和 L1 范数的重视程度。

看一下方程 11.6。我们乘以 L2 范数和 1-α,乘以 L1 范数和α,然后将这些值相加。我们将这个值乘以lambda并加到平方和上。Alpha在这里可以取 0 到 1 之间的任何值:

  • alpha为 0 时,L1 范数变为 0,我们得到岭回归。

  • alpha为 1 时,L2 范数变为 0,我们得到 LASSO。

  • alpha介于 0 和 1 之间时,我们得到岭回归和 LASSO 的混合。

我们如何选择alpha?我们不做!我们将其作为超参数进行调整,让交叉验证为我们选择最佳性能的值。

方程 11.6.

如果你更倾向于数学,完整的弹性网络损失函数在方程 11.7 中显示。如果你不倾向于数学,请随意跳过;但如果你仔细看,我相信你一定能看到弹性网络损失函数是如何结合岭回归和 LASSO 损失函数的。

方程 11.7.

倾向于图形解释?是的,我也是。图 11.9 比较了岭回归、LASSO 和弹性网络惩罚的形状。因为弹性网络惩罚介于岭回归和 LASSO 惩罚之间,它看起来像一个边角圆润的方形。

图 11.9. 比较岭回归、LASSO 和弹性网络惩罚的形状

那么为什么我们可能更倾向于弹性网络而不是岭回归或 LASSO 呢?嗯,弹性网络可以将参数估计值缩小到 0,使其能够像 LASSO 一样进行特征选择。但它也避免了 LASSO 无法选择比案例更多的变量的限制。LASSO 的另一个限制是,如果有一组预测变量彼此相关,LASSO 只会选择其中一个预测变量。另一方面,弹性网络能够保留这组预测变量。

由于这些原因,我通常直接选择弹性网络作为我的正则化方法。即使纯岭回归或 LASSO 会产生性能最佳的模型,调整 alpha 作为超参数的能力仍然允许选择岭回归或 LASSO,尽管最佳解通常位于它们之间。一个例外是当我们对我们模型中包含的预测变量的影响有先验知识时。如果我们有非常强的领域知识,认为预测变量应该包含在模型中,那么我们可能更喜欢岭回归。相反,如果我们有一个强烈的先验信念,认为有一些变量可能没有任何贡献(但我们不知道是哪些),我们可能更喜欢 LASSO。

我希望我已经传达了如何使用正则化来扩展线性模型以避免过拟合。现在你也应该对岭回归、LASSO 和弹性网络有一个概念性的理解,所以让我们通过训练每个模型来将概念转化为经验!

11.6. 构建你的第一个岭回归、LASSO 和弹性网络模型

在本节中,我们将使用相同的数据集构建岭回归、LASSO 和弹性网络模型,并使用基准测试来比较它们之间的性能以及与普通(未正则化)线性模型的性能。想象一下,你正在尝试估算爱荷华州下一年度的市场小麦价格。市场价格取决于那一年的产量,因此你正在尝试通过降雨量和温度测量来预测小麦产量。让我们首先加载 mlr 和 tidyverse 包:

library(mlr)

library(tidyverse)

11.6.1. 加载和探索 Iowa 数据集

现在,让我们加载数据,该数据内置在 lasso2 包中,将其转换为 tibble(使用 as_tibble()),并对其进行探索。

注意

你可能需要首先使用 install.packages("lasso2") 安装 lasso2 包。

我们有一个包含仅 33 个案例和 10 个变量的 tibble,这些变量包括各种降雨量和温度测量值、年份和小麦产量。

列表 11.1. 加载和探索 Iowa 数据集
data(Iowa, package = "lasso2")

iowaTib <- as_tibble(Iowa)

iowaTib

# A tibble: 33 x 10
    Year Rain0 Temp1 Rain1 Temp2 Rain2 Temp3 Rain3 Temp4 Yield
   <int> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
 1  1930  17.8  60.2  5.83  69    1.49  77.9  2.42  74.4  34
 2  1931  14.8  57.5  3.83  75    2.72  77.2  3.3   72.6  32.9
 3  1932  28.0  62.3  5.17  72    3.12  75.8  7.1   72.2  43
 4  1933  16.8  60.5  1.64  77.8  3.45  76.4  3.01  70.5  40
 5  1934  11.4  69.5  3.49  77.2  3.85  79.7  2.84  73.4  23
 6  1935  22.7  55    7     65.9  3.35  79.4  2.42  73.6  38.4
 7  1936  17.9  66.2  2.85  70.1  0.51  83.4  3.48  79.2  20
 8  1937  23.3  61.8  3.8   69    2.63  75.9  3.99  77.8  44.6
 9  1938  18.5  59.5  4.67  69.2  4.24  76.5  3.82  75.7  46.3
10  1939  18.6  66.4  5.32  71.4  3.15  76.2  4.72  70.7  52.2
# ... with 23 more rows

让我们绘制数据以更好地理解其中的关系。我们将使用我们常用的技巧来收集数据,以便我们可以按每个变量进行分面,将 "free_x" 作为 scales 参数,以允许 x 轴在分面之间变化。为了得到与 Yield 之间任何线性关系的指示,我还应用了一个 geom_smooth 层,使用 "lm" 作为 method 参数以获取线性拟合。

列表 11.2. 绘制数据
iowaUntidy <- gather(iowaTib, "Variable", "Value", -Yield)

ggplot(iowaUntidy, aes(Value, Yield)) +
  facet_wrap(~ Variable, scales = "free_x") +
  geom_point() +
  geom_smooth(method = "lm") +
  theme_bw()

最终的图表显示在图 11.10 中。看起来一些变量与Yield相关联;但请注意,因为我们没有大量的案例,如果我们只移除 x 轴极端附近的一两个案例,这些关系中的某些斜率可能会急剧变化。例如,如果我们没有测量那三个降雨量最高的案例,Rain2Yield之间的斜率会接近那么陡峭吗?我们需要正则化来防止这个数据集过拟合。

图 11.10. 将每个预测变量与Iowa数据集中的小麦产量进行绘图。线条代表每个预测变量和产量之间的线性模型拟合。

图 11-10 替代

11.6.2. 训练岭回归模型

在本节中,我将指导你训练一个岭回归模型来从我们的Iowa数据集中预测Yield。我们将调整lambda超参数,并使用其最佳值来训练模型。

让我们定义我们的任务和学习器,这次将"regr.glmnet"作为makeLearner()的参数。方便的是,glmnet函数(来自同名包)允许我们使用相同的函数创建岭回归、LASSO 和弹性网络模型。请注意,我们在这里将alpha的值设为 0。这就是我们如何指定我们想要使用纯岭回归的glmnet函数。我们还提供了一个你之前没有见过的参数:idid参数只是让我们为每个学习器提供一个唯一的名称。我们需要这个参数的原因是,在本章的后面部分,我们将基准测试我们的岭回归、LASSO 和弹性网络学习器。因为我们使用相同的glmnet函数创建它们,所以我们会得到一个错误,因为它们不会有唯一的标识符。

列表 11.3. 创建任务和学习器
iowaTask <- makeRegrTask(data = iowaTib, target = "Yield")

ridge <- makeLearner("regr.glmnet", alpha = 0, id = "ridge")

让我们了解每个预测变量对模型预测Yield能力的贡献程度。我们可以使用我们在第九章中使用的generateFilterValuesData()plotFilterValues()函数,当时我们使用过滤方法进行特征选择。

列表 11.4. 生成和绘制过滤值
filterVals <- generateFilterValuesData(iowaTask)

plotFilterValues(filterVals) + theme_bw()

最终的图表显示在图 11.11 中。我们可以看到Year包含关于Yield的最多的预测信息;Rain3Rain1Rain0似乎贡献很小;而Temp1似乎有负贡献,这表明将其包含在模型中将损害预测精度。

但我们不会执行特征选择。相反,我们将输入所有预测变量,并让算法缩小对模型贡献较小的那些变量。我们需要做的第一件事是调整控制参数估计惩罚大小的lambda超参数。

备注

记住,当lambda等于 0 时,我们正在应用无惩罚并得到 OLS 参数估计。lambda越大,参数就越会被压缩到 0。

图 11.11。绘制generateFilterValuesData()的结果。条形高度表示每个预测变量包含多少关于小麦产量的信息。

我们将首先定义我们要搜索的超参数空间,以找到lambda的最佳值。回想一下,为了做到这一点,我们使用makeParamSet()函数,将每个要搜索的超参数通过逗号分隔开来。因为我们只有一个超参数需要调整,而且因为lambda可以取 0 到无穷大之间的任何数值,所以我们使用makeNumericParam()函数来指定我们想要搜索 0 到 15 之间的lambda的数值。

注意

注意,我使用的是超参数"s"而不是"lambda"。如果你运行getParamSet(ridge),你确实会看到一个名为lambda的可调整超参数,那么"s"是怎么回事呢?glmnet 的作者们很贴心地这样写,以便为我们构建一系列lambda值的模型。然后我们可以绘制lambda值,看看哪一个给出了最佳的交叉验证性能。这很方便,但鉴于我们正在使用 mlr 作为许多机器学习包的通用接口,我们以我们习惯的方式自己调整lambda是有意义的。glmnet 的lambda超参数用于指定要尝试的lambda值的序列,作者们特别建议不要为这个超参数提供一个单一值。相反,s超参数用于训练一个使用单个、特定lambda的模型,因此这就是我们在使用 mlr 时将要调整的内容。有关更多信息,我建议通过运行?glmnet::glmnet来阅读 glmnet 的文档。

接下来,让我们使用makeTuneControlRandom()定义我们的搜索方法为随机搜索,200 次迭代,并定义我们的交叉验证方法为重复 5 次的 3 折交叉验证,使用makeResampleDesc()。最后,我们使用tuneParams()函数运行我们的超参数调整过程。为了加快速度,让我们使用parallelStartSocket()来并行化搜索。

警告

这在我的四核机器上大约需要 30 秒。

列表 11.5。调整lambda (s)超参数
ridgeParamSpace <- makeParamSet(
  makeNumericParam("s", lower = 0, upper = 15))

randSearch <- makeTuneControlRandom(maxit = 200)

cvForTuning <- makeResampleDesc("RepCV", folds = 3, reps = 10)

library(parallel)
library(parallelMap)

parallelStartSocket(cpus = detectCores())

tunedRidgePars <- tuneParams(ridge, task = iowaTask,
                             resampling = cvForTuning,
                             par.set = ridgeParamSpace,
                             control = randSearch)

parallelStop()

tunedRidgePars

Tune result:
Op. pars: s=6.04
mse.test.mean=96.8360

我们的选择过程选择了 6.04 作为最佳性能的lambda(由于随机搜索,你的可能略有不同)。但我们如何确保我们搜索了足够大的lambda值范围?让我们绘制每个lambda值与其模型平均 MSE 的对应关系,看看是否可能存在搜索空间之外(大于 15)的更好值。

首先,我们通过将我们的调整对象作为 generateHyperParsEffectData() 函数的参数,提取每次随机搜索的 lambda 和均方误差 (MSE) 值。然后,我们将这些数据作为 plotHyperParsEffect() 函数的第一个参数,并告诉它我们想在 x 轴上绘制 s 的值,在 y 轴上绘制均方误差 ("mse.test.mean"),并且我们想要一条连接数据点的线。

列表 11.6. 绘制超参数调整过程
ridgeTuningData <- generateHyperParsEffectData(tunedRidgePars)

plotHyperParsEffect(ridgeTuningData, x = "s", y = "mse.test.mean",
                    plot.type = "line") +
  theme_bw()

结果图显示在 图 11.12 中。我们可以看到,当 lambda 在 5 和 6 之间时,MSE 最小化,并且看起来当 lambda 超过 6 时,模型的表现会变差。如果 MSE 在搜索空间的边缘似乎仍在下降,我们就需要扩大搜索范围,以防我们错过了更好的超参数值。因为我们看起来已经达到了最小值,所以我们将在这里停止搜索。

图 11.12. 绘制岭回归 lambda 调整过程。x 轴代表 lambda,y 轴代表均方误差。点代表随机搜索中采样的 lambda 值。线连接这些点。

注意

也许我太急躁了,因为我们可能只是处于一个 局部最小值,即与周围 lambda 值相比最小的 MSE 值。在搜索超参数空间时,可能会有许多局部最小值(minimum 的复数形式);但我们真正想要找到的是 全局最小值,这是所有可能的超参数值中的最低 MSE 值。例如,想象一下,如果我们继续增加 lambda,MSE 会先升高然后开始下降,形成一个山丘。这个山丘可能比 图 11.12 中显示的最小值下降得更多。因此,真正搜索超参数空间以尝试找到那个全局最小值是一个好主意。

| |

练习 1

重复调整过程,但这次将搜索空间扩大到包括 0 到 50 之间的 s 值(不要覆盖任何内容)。我们的原始搜索是否找到了局部最小值或全局最小值?

好的,既然我们认为我们已经选择了最佳性能的 lambda 值,那么我们就用这个值来训练一个模型。首先,我们使用 setHyperPars() 函数定义一个新的学习器,使用我们调整过的 lambda 值。然后,我们使用 train() 函数在 iowaTask 上训练模型。

列表 11.7. 使用调整过的 lambda 训练岭回归模型
tunedRidge <- setHyperPars(ridge, par.vals = tunedRidgePars$x)

tunedRidgeModel <- train(tunedRidge, iowaTask)

使用线性模型的主要动机之一是我们可以通过解释斜率来了解结果变量随着每个预测变量的变化情况。所以让我们从岭回归模型中提取参数估计。首先,我们使用getLearnerModel()函数提取模型数据。然后,我们使用coef()函数(简称系数)来提取参数估计。请注意,由于 glmnet 的工作方式,我们需要提供lambda的值来获取该模型的参数。

当我们打印ridgeCoefs时,我们得到一个包含每个参数名称及其斜率的矩阵。截距是当所有预测变量都为 0 时的估计Yield。当然,负小麦产量没有多少意义,但因为我们不能让所有预测变量都为 0(例如年份),所以我们不会对此进行解释。我们更感兴趣的是解释斜率,这些斜率报告在预测变量的原始尺度上。我们可以看到,对于每增加一年,小麦产量每英亩增加 0.533 蒲式耳。对于Rain1每增加一英寸,小麦产量减少了 0.703,等等。

注意

回想一下,我提到过如何重要地对我们预测变量进行缩放,以便在计算 L1 和/或 L2 范数时它们被同等加权。嗯,glmnet 默认为我们做了这件事,使用其standardize = TRUE参数。这很方便,但重要的是要记住,参数估计被转换回变量的原始尺度。

列表 11.8. 提取模型参数
ridgeModelData <- getLearnerModel(tunedRidgeModel)

ridgeCoefs <- coef(ridgeModelData, s = tunedRidgePars$x$s)

ridgeCoefs

10 x 1 sparse Matrix of class "dgCMatrix"
                     1
(Intercept) -908.45834
Year           0.53278
Rain0          0.34269
Temp1         -0.23601
Rain1         -0.70286
Temp2          0.03184
Rain2          1.91915
Temp3         -0.57963
Rain3          0.63953
Temp4         -0.47821

让我们绘制这些参数估计与无正则化线性回归估计的对比图,这样你可以看到参数收缩的影响。首先,我们需要使用 OLS 训练一个线性模型。我们可以使用 mlr 来做这件事,但因为我们不会对这个模型做任何复杂的事情,我们可以快速使用lm()函数创建一个。lm()的第一个参数是公式Yield ~ .,这意味着Yield是我们的结果变量,我们希望使用数据中的所有其他变量(~)来对其进行建模。我们告诉函数在哪里找到数据,并将整个lm()函数包裹在coef()函数中,以提取其参数估计。

接下来,我们创建一个包含三个变量的 tibble:

  • 参数名称

  • 岭回归参数值

  • lm参数值

因为我们想排除截距,所以我们使用[-1]来选择除了第一个参数(截距)之外的所有参数。

为了能够按模型进行分解,我们使用gather()函数收集数据,然后使用ggplot()函数进行绘图。因为按升序或降序查看事物很方便,所以我们提供了reorder(Coef, Beta),这将使用Coef变量作为 x 美学,按Beta变量排序。默认情况下,geom_bar()试图绘制频率,但因为我们希望条形图代表每个参数的实际值,所以我们设置了stat = "identity"参数。

列表 11.9. 绘制模型参数
lmCoefs <- coef(lm(Yield ~ ., data = iowaTib))

coefTib <- tibble(Coef = rownames(ridgeCoefs)[-1],
                  Ridge = as.vector(ridgeCoefs)[-1],
                  Lm = as.vector(lmCoefs)[-1])

coefUntidy <- gather(coefTib, key = Model, value = Beta, -Coef)

ggplot(coefUntidy, aes(reorder(Coef, Beta), Beta, fill = Model)) +
  geom_bar(stat = "identity", col = "black") +
  facet_wrap(~Model) +
  theme_bw()  +
  theme(legend.position = "none")

最终的图表显示在图 11.13 中。在左侧部分,我们有未正则化模型的参数估计;在右侧部分,我们有岭回归模型的估计。你能看到大多数岭回归参数(尽管不是全部)比未正则化模型的参数要小吗?这就是正则化的效果。

练习 2

创建另一个与图 11.13 完全相同的图表,但这次包含截距。它们在两个模型中是否相同?为什么?

图 11.13. 比较我们的岭回归模型和 OLS 回归模型的参数估计

11.6.3. 训练 LASSO 模型

在本节中,我们将重复上一节的模型构建过程,但这次使用 LASSO。一旦我们训练了模型,我们将将其添加到我们的图中,这样我们就可以比较模型之间的参数估计,以便更好地理解这些技术之间的差异。

我们首先定义 LASSO 学习器,这次将alpha设置为 1(使其成为纯 LASSO)。我们给学习器一个 ID,稍后我们将用它来基准测试模型:

lasso <- makeLearner("regr.glmnet", alpha = 1, id = "lasso")

现在,让我们像之前对岭回归那样调整lambda

警告

这在我的四核机器上大约需要 30 秒。

列表 11.10. 调整 LASSO 的lambda
lassoParamSpace <- makeParamSet(
  makeNumericParam("s", lower = 0, upper = 15))

parallelStartSocket(cpus = detectCores())

tunedLassoPars <- tuneParams(lasso, task = iowaTask,
                             resampling = cvForTuning,
                             par.set = lassoParamSpace,
                             control = randSearch)

parallelStop()

tunedLassoPars

Tune result:
Op. pars: s=1.37
mse.test.mean=87.0126

现在我们绘制调整过程,以查看是否需要扩大搜索范围。

列表 11.11. 绘制超参数调整过程
lassoTuningData <- generateHyperParsEffectData(tunedLassoPars)

plotHyperParsEffect(lassoTuningData, x = "s", y = "mse.test.mean",
                    plot.type = "line") +
  theme_bw()

最终的图表显示在图 11.14 中。再次,我们可以看到所选的lambda值位于平均均方误差值低谷的底部。请注意,当lambda值达到 10 时,平均均方误差变得平坦:这是因为这里的惩罚如此之大,以至于所有预测变量都被从模型中移除,我们得到了仅包含截距项的模型的平均均方误差。

图 11.14. 绘制 LASSO lambda调整过程。x 轴代表lambda,y 轴代表平均均方误差。点代表随机搜索采样的lambda值。线连接这些点。

让我们使用调整后的lambda值训练一个 LASSO 模型。

列表 11.12. 使用调整后的lambda训练 LASSO 模型
tunedLasso <- setHyperPars(lasso, par.vals = tunedLassoPars$x)

tunedLassoModel <- train(tunedLasso, iowaTask)

现在,让我们查看调整后的 LASSO 模型的参数估计,并看看它们与岭回归和 OLS 估计相比如何。再次,我们使用getLearnerModel()函数提取模型数据,然后使用coef()函数提取参数估计。有什么不寻常的地方吗?我们的三个参数估计只是点。实际上,这些点代表 0.0。什么都没有。零。什么都没有。这些参数在数据集中的斜率被设置为正好为 0。这意味着它们已经被完全从模型中移除。这就是 LASSO 可以用于执行特征选择的方法。

列表 11.13. 提取模型参数
lassoModelData <- getLearnerModel(tunedLassoModel)

lassoCoefs <- coef(lassoModelData, s = tunedLassoPars$x$s)

lassoCoefs

10 x 1 sparse Matrix of class "dgCMatrix"
                     1
(Intercept) -1.361e+03
Year         7.389e-01
Rain0        2.217e-01
Temp1        .
Rain1        .
Temp2        .
Rain2        2.005e+00
Temp3       -4.065e-02
Rain3        1.669e-01
Temp4       -4.829e-01

让我们将这些参数估计与我们的岭回归和 OLS 模型的参数估计并排放置,以进行更直观的比较。为此,我们只需在coefTib tibble 中添加一个新的列,使用$LASSO;它包含我们的 LASSO 模型的参数估计(不包括截距)。然后我们收集这些数据,以便我们可以按模型进行细分,并使用ggplot()像以前一样绘制它。

列表 11.14. 绘制模型参数
coefTib$LASSO <- as.vector(lassoCoefs)[-1]

coefUntidy <- gather(coefTib, key = Model, value = Beta, -Coef)

ggplot(coefUntidy, aes(reorder(Coef, Beta), Beta, fill = Model)) +
  geom_bar(stat = "identity", col = "black") +
  facet_wrap(~ Model) +
  theme_bw() +
  theme(legend.position = "none")

结果图显示在图 11.15 中。该图很好地突出了岭回归(将参数缩小到 0,但永远不会真正缩小到 0)和 LASSO(可以将参数缩小到正好为 0)之间的差异。

图 11.15. 比较我们的岭回归模型、LASSO 模型和 OLS 回归模型的参数估计

11.6.4. 训练弹性网络模型

这一节将非常类似于前两个章节,但我将向您展示如何通过调整lambdaalpha来训练弹性网络模型。我们将首先创建一个弹性网络学习器;这次我们不会提供一个alpha的值,因为我们打算调整它以找到 L1 和 L2 正则化之间的最佳权衡。我们还给它一个 ID,我们可以在以后进行基准测试时使用:

elastic <- makeLearner("regr.glmnet", id = "elastic")

现在让我们定义我们将要调整的超参数空间,这次包括alpha作为一个介于 0 和 1 之间的数值超参数。因为我们现在正在调整两个超参数,所以让我们增加随机搜索的迭代次数,以获得对搜索空间的更多覆盖。最后,我们像以前一样运行调整过程并打印出最佳结果。

警告

这在我的四核机器上大约需要一分钟。

列表 11.15. 调整弹性网络中的lambdaalpha
elasticParamSpace <- makeParamSet(
  makeNumericParam("s", lower = 0, upper = 10),
  makeNumericParam("alpha", lower = 0, upper = 1))

randSearchElastic <- makeTuneControlRandom(maxit = 400)

parallelStartSocket(cpus = detectCores())

tunedElasticPars <- tuneParams(elastic, task = iowaTask,
                               resampling = cvForTuning,
                               par.set = elasticParamSpace,
                               control = randSearchElastic)

parallelStop()

tunedElasticPars

Tune result:
Op. pars: s=1.24; alpha=0.981
mse.test.mean=84.7701

现在,让我们绘制我们的调整过程以确认我们的搜索空间是否足够大。这次,因为我们同时调整两个超参数,我们将lambdaalpha作为 x 轴和 y 轴,将平均均方误差("mse.test.mean")作为 z 轴。将plot.type参数设置为"heatmap"将绘制一个热图,其中颜色映射到我们设置为 z 轴的任何内容。但是,为了使其工作,我们需要填充我们 1,000 次搜索迭代之间的空白。为此,我们将任何回归算法的名称提供给interpolate参数。在这里,我使用了"regr.kknn",它使用 k 最近邻根据最近的搜索迭代的 MSE 值来填充空白。我们向图中添加一个geom_point来指示由我们的调整过程选择的lambdaalpha的组合。

注意

这种插值仅用于可视化,因此选择不同的插值学习器可能会改变调整图,但不会影响我们选择的超参数。

列表 11.16. 绘制调整过程
elasticTuningData <- generateHyperParsEffectData(tunedElasticPars)

plotHyperParsEffect(elasticTuningData, x = "s", y = "alpha",
                    z = "mse.test.mean", interpolate = "regr.kknn",
                    plot.type = "heatmap") +
  scale_fill_gradientn(colours = terrain.colors(5)) +
  geom_point(x = tunedElasticPars$x$s, y = tunedElasticPars$x$alpha,
             col = "white") +
  theme_bw()

结果图表显示在图 11.16 中。太棒了!你可以把它挂在墙上,称之为艺术。注意,选择的lambdaalpha组合(白色点)落在平均均方误差值的山谷中,这表明我们的超参数搜索空间足够宽。

图 11.16. 绘制我们的弹性网络模型超参数调整过程。x 轴代表lambda,y 轴代表alpha,阴影代表平均均方误差。白色点代表我们的调整过程选择的超参数组合。

练习 3

让我们实验一下plotHyperParsEffect()函数。将plot.type参数更改为"contour",添加参数show.experiments = TRUE,并重新绘制图表。接下来,将plot.type更改为"scatter",移除interpolateshow .experiments参数,并移除scale_fill_gradientn()层。

现在,让我们使用调整后的超参数来训练最终的弹性网络模型。

列表 11.17. 使用调整后的超参数训练弹性网络模型
tunedElastic <- setHyperPars(elastic, par.vals = tunedElasticPars$x)

tunedElasticModel <- train(tunedElastic, iowaTask)

接下来,我们可以提取模型参数,并将它们与其他三个模型一起绘制,就像我们在列表 11.9 和 11.14 中所做的那样。

列表 11.18. 绘制模型参数
elasticModelData <- getLearnerModel(tunedElasticModel)

elasticCoefs <- coef(elasticModelData, s = tunedElasticPars$x$s)

coefTib$Elastic <- as.vector(elasticCoefs)[-1]

coefUntidy <- gather(coefTib, key = Model, value = Beta, -Coef)

ggplot(coefUntidy, aes(reorder(Coef, Beta), Beta, fill = Model)) +
  geom_bar(stat = "identity", position = "dodge", col = "black") +
  facet_wrap(~ Model) +
  theme_bw()

结果图表显示在图 11.17 中。注意,我们的弹性网络模型的参数估计介于岭回归估计和 LASSO 估计之间。然而,由于我们的调整值alpha接近 1(记住当alpha等于 1 时,我们得到纯 LASSO),弹性网络模型的参数与纯 LASSO 估计的参数更相似。

练习 4

重新绘制图 11.17 中的图表,但移除facet_wrap()层,并将geom_bar()的位置参数设置为"dodge"。你更喜欢哪种可视化?

图 11.17. 比较我们的岭回归模型、LASSO 模型、弹性网络模型和 OLS 回归模型的参数估计

11.7. 将岭回归、LASSO、弹性网络和 OLS 相互基准测试

让我们使用基准测试来同时交叉验证并比较我们的岭回归、LASSO、弹性网络和 OLS 建模过程的表现。回想一下第八章,基准测试需要一个学习者列表、一个任务和一个交叉验证过程。然后,对于交叉验证过程的每个迭代/折,使用每个学习者在相同的训练集上训练一个模型,并在相同的测试集上评估。一旦整个交叉验证过程完成,我们得到每个学习者的平均性能指标(在这种情况下是均方误差),这使我们能够比较哪个表现最好。

列表 11.19. 绘制模型参数
ridgeWrapper <- makeTuneWrapper(ridge, resampling = cvForTuning,
                                par.set = ridgeParamSpace,
                                control = randSearch)

lassoWrapper <- makeTuneWrapper(lasso, resampling = cvForTuning,
                                par.set = lassoParamSpace,
                                control = randSearch)

elasticWrapper <- makeTuneWrapper(elastic, resampling = cvForTuning,
                                  par.set = elasticParamSpace,
                                  control = randSearchElastic)

learners = list(ridgeWrapper, lassoWrapper, elasticWrapper, "regr.lm")

我们首先为每个学习器定义调整包装器,这样我们就可以在我们的交叉验证循环中包含超参数调整。对于每个包装器(分别为 Ridge、LASSO 和弹性网络),我们提供学习器、交叉验证策略、该学习器的参数空间以及该学习器的搜索过程(注意,我们为弹性网络使用不同的搜索过程)。OLS 回归不需要超参数调整,所以我们不为它创建包装器。因为benchmark()函数需要一个学习器列表,所以我们接下来创建一个这些包装器的列表(以及"regr.lm",我们的 OLS 回归学习器)。

要运行基准实验,让我们定义我们的外部重采样策略为 3 折交叉验证。在启动并行化后,我们通过向benchmark()实验提供学习器列表、任务和外部交叉验证策略来运行基准实验。

警告

这在我的四核机器上几乎花费了 6 分钟。

列表 11.20. 绘制模型参数
library(parallel)
library(parallelMap)

kFold3 <- makeResampleDesc("CV", iters = 3)

parallelStartSocket(cpus = detectCores())

bench <- benchmark(learners, iowaTask, kFold3)

parallelStop()

bench

  task.id    learner.id mse.test.mean
1 iowaTib   ridge.tuned         95.48
2 iowaTib   lasso.tuned         93.98
3 iowaTib elastic.tuned         99.19
4 iowaTib       regr.lm        120.37

比起弹性网络,Ridge 和 LASSO 回归都表现更好,尽管所有三种正则化技术都比 OLS 回归表现更好。因为弹性网络有可能选择纯 Ridge 或纯 LASSO(基于alpha超参数的值),增加随机搜索的迭代次数可能会导致弹性网络处于领先地位。

11.8. Ridge、LASSO 和弹性网络的优势与劣势

虽然对于给定的任务通常不容易判断哪些算法会表现良好,但以下是一些优势和劣势,可以帮助你决定 Ridge 回归、LASSO 和弹性网络是否适合你。

Ridge、LASSO 和弹性网络的优势如下:

  • 它们产生的模型非常易于解释。

  • 它们可以处理连续和分类预测器。

  • 它们在计算上成本较低。

  • 它们通常优于 OLS 回归。

  • LASSO 和弹性网络可以通过将无信息预测器的斜率设置为 0 来执行特征选择。

  • 它们也可以应用于广义线性模型(如逻辑回归)。

Ridge、LASSO 和弹性网络的劣势如下:

  • 它们对数据做出了强烈的假设,例如同方差性(常数方差)和残差的分布(如果违反这些假设,性能可能会受到影响)。

  • Ridge 回归无法自动执行特征选择。

  • LASSO 无法估计比训练集中案例更多的参数。

  • 它们无法处理缺失数据。

练习 5

创建一个新的 tibble,其中只包含Yield变量,并使用这些数据创建一个新的回归任务,将Yield设置为目标变量。

  1. 在此数据上训练一个普通 OLS 模型(一个没有预测器的模型)。

  2. 在原始iowaTask上训练一个lambda值为 500 的 LASSO 模型。

  3. 使用留一法交叉验证(makeResampleDesc("LOO"))对两个模型进行交叉验证。

  4. 两个模型的平均均方误差值如何比较?为什么?

练习 6

glmnet模型对象上调用plot()不会绘制模型残差。安装 plotmo 包并使用其plotres()函数,将岭回归、LASSO 和弹性网络模型的模型数据对象作为参数传递。

摘要

  • 正则化是一组通过缩小模型参数估计来防止过拟合的技术。

  • 线性模型有三种正则化技术:岭回归、LASSO 和弹性网络。

  • 岭回归使用 L2 范数将参数估计缩小到 0(但永远不会精确到 0,除非它们一开始就是 0)。

  • LASSO 使用 L1 范数将参数估计缩小到 0(并且可能精确到 0,从而实现特征选择)。

  • 弹性网络结合了 L2 和 L1 正则化,其比例由alpha超参数控制。

  • 对于所有三个模型,lambda超参数控制收缩的强度。

练习题解答

  1. 将搜索空间扩展到包括从 0 到 50 的lambda值:

    ridgeParamSpaceExtended <- makeParamSet(
      makeNumericParam("s", lower = 0, upper = 50))
    
    parallelStartSocket(cpus = detectCores())
    
    tunedRidgeParsExtended <- tuneParams(ridge, task = iowaTask, # ~30 sec
                                 resampling = cvForTuning,
                                 par.set = ridgeParamSpaceExtended,
                                 control = randSearch)
    
    parallelStop()
    
    ridgeTuningDataExtended <- generateHyperParsEffectData(
                                          tunedRidgeParsExtended)
    
    plotHyperParsEffect(ridgeTuningDataExtended, x = "s", y = "mse.test.mean",
                        plot.type = "line") +
      theme_bw()
    
    # The previous value of s was not just a local minimum,
    # but the global minimum.
    
  2. 绘制岭回归和 LASSO 模型的截距:

    coefTibInts <- tibble(Coef = rownames(ridgeCoefs),
                      Ridge = as.vector(ridgeCoefs),
                      Lm = as.vector(lmCoefs))
    coefUntidyInts <- gather(coefTibInts, key = Model, value = Beta, -Coef)
    
    ggplot(coefUntidyInts, aes(reorder(Coef, Beta), Beta, fill = Model)) +
      geom_bar(stat = "identity", col = "black") +
      facet_wrap(~Model) +
      theme_bw()  +
      theme(legend.position = "none")
    
    # The intercepts are different. The intercept isn't included when
    # calculating the L2 norm, but is the value of the outcome when all
    # the predictors are zero. Because ridge regression changes the parameter
    # estimates of the predictors, the intercept changes as a result.
    
  3. 尝试不同的方法来绘制超参数调整过程:

    plotHyperParsEffect(elasticTuningData, x = "s", y = "alpha",
                        z = "mse.test.mean", interpolate = "regr.kknn",
                        plot.type = "contour", show.experiments = TRUE) +
      scale_fill_gradientn(colours = terrain.colors(5)) +
      geom_point(x = tunedElasticPars$x$s, y = tunedElasticPars$x$alpha) +
      theme_bw()
    
    plotHyperParsEffect(elasticTuningData, x = "s", y = "alpha",
                        z = "mse.test.mean", plot.type = "scatter") +
      theme_bw()
    
  4. 使用水平错位条形图而不是小面元绘制模型系数:

    ggplot(coefUntidy, aes(reorder(Coef, Beta), Beta, fill = Model)) +
      geom_bar(stat = "identity", position = "dodge", col = "black") +
      theme_bw()
    
  5. 比较具有高lambda的 LASSO 模型和没有预测变量的 OLS 模型的性能:

    yieldOnly <- select(iowaTib, Yield)
    
    yieldOnlyTask <- makeRegrTask(data = yieldOnly, target = "Yield")
    
    lassoStrict <- makeLearner("regr.glmnet", lambda = 500)
    
    loo <- makeResampleDesc("LOO")
    
    resample("regr.lm", yieldOnlyTask, loo)
    
    Resample Result
    Task: yieldOnly
    Learner: regr.lm
    Aggr perf: mse.test.mean=179.3428
    Runtime: 0.11691
    
    resample(lassoStrict, iowaTask, loo)
    
    Resample Result
    Task: iowaTib
    Learner: regr.glmnet
    Aggr perf: mse.test.mean=179.3428
    Runtime: 0.316366
    
    # The MSE values are identical. This is because when lambda is high
    # enough, all predictors will be removed from the model, just as if
    # we trained a model with no predictors.
    
  6. 使用plotres()函数绘制 glmnet 模型的模型诊断:

    install.packages("plotmo")
    
    library(plotmo)
    
    plotres(ridgeModelData)
    
    plotres(lassoModelData)
    
    plotres(elasticModelData)
    
    # The first plot shows the estimated slope for each parameter for
    # different values of (log) lambda. Notice the different shape
    # between ridge and LASSO.
    

第十二章. 使用 kNN、随机森林和 XGBoost 进行回归

本章涵盖

  • 使用 k 最近邻算法进行回归

  • 使用基于树的回归算法

  • 比较 k 最近邻、随机森林和 XGBoost 模型

你会发现本章内容轻松易懂。这是因为你之前已经做过其中的所有内容(某种程度上)。在第三章中,我向你介绍了 k 最近邻(kNN)算法作为分类工具。在第七章中,我向你介绍了决策树,然后在第八章中扩展了这一内容,涵盖了用于分类的随机森林和 XGBoost。方便的是,这些算法也可以用于预测连续变量。因此,在本章中,我将帮助你扩展这些技能来解决回归问题。

到本章结束时,我希望你能理解如何将 kNN 和基于树的算法扩展到预测连续变量。正如你在第七章中学到的,决策树倾向于过拟合其训练数据,因此通常通过使用集成技术来大幅改进。因此,在本章中,你将训练一个随机森林模型和一个 XGBoost 模型,并将它们的性能与 kNN 算法进行比较。

注意

从第八章回顾,随机森林和 XGBoost 是两种基于树的算法,它们通过创建许多树的集成来提高预测精度。随机森林在数据的不同自助样本上并行训练许多树,而 XGBoost 则优先训练那些被错误分类的序列树。

12.1. 使用 k 近邻预测连续变量

在本节中,我将向你展示如何使用 kNN 算法进行回归,图形化和直观化。想象一下,你不是个早睡早起的人(也许,像我一样,你不需要非常努力地想象),你尽可能地想多在床上多待一会儿。为了最大化你睡眠的时间,你决定训练一个机器学习模型来预测你通勤到工作地点所需的时间,基于你离开家的时间。你早上需要 40 分钟来准备,所以你希望这个模型能告诉你你需要什么时候离开家才能准时到达工作地点,因此你需要什么时候起床。

在接下来的两周内,你记录你离开家的时间和你的旅程所需的时间。你的旅程时间受交通状况(在早晨变化)的影响,所以你的旅程长度取决于你离开的时间。离开时间和旅程长度之间可能存在的关系示例在图 12.1 中展示。

图 12.1. 根据你离开家的时间,通勤所需时间的示例关系

从第三章回顾,kNN 算法是一种懒惰学习器。换句话说,它在模型训练期间不做任何工作(相反,它只是存储训练数据);它所有的工都在进行预测时完成。在做出预测时,kNN 算法会在训练集中寻找与每个新的、未标记的数据值最相似的k个案例。这些k个最相似的案例会对新数据的预测值进行投票。当使用 kNN 进行分类时,这些投票是关于类成员的,并且获得多数票的投票选择模型为新的数据输出的类别。为了提醒你这个过程是如何工作的,我重新绘制了第三章的图 3.4 的修改版,即图 12.2。

图 12.2. kNN 算法进行分类:识别k个最近邻并采取多数投票。线条将未标记的数据与它们的单个、三个和五个最近邻连接起来。每种情况下的多数投票由每个十字下方的形状表示。

使用 kNN 进行回归时的投票过程非常相似,只是我们取这些k个投票的平均值作为新数据的预测值。

此过程在图 12.3 中用我们的通勤示例进行了说明。x 轴上的交叉点代表新的数据:我们离开家的时刻,我们想要预测行程长度。如果我们训练一个一最近邻模型,模型会找到训练集中与每个新数据点的出发时间最接近的单个案例,并使用该值作为预测的行程长度。如果我们训练一个三最近邻模型,模型会找到与每个新数据点的出发时间最相似的三个训练案例,取这些最近案例的平均行程长度,并将此作为新数据的预测值。对于任何我们用于训练模型的 k 值,情况都是相同的。

图 12.3. kNN 算法如何预测连续变量。交叉点代表我们希望预测行程长度的新的数据点。对于一、三和五个最近邻模型,每个新数据点的最近邻被以较浅的阴影突出显示。在每种情况下,预测值是最近邻的平均行程长度。

备注

就像当我们使用 kNN 进行分类时,选择最佳性能的k值对于模型性能至关重要。如果我们选择一个太低的k,我们可能会产生一个过度拟合的模型,并做出具有高方差预测。如果我们选择一个太高的k,我们可能会产生一个欠拟合的模型,并做出具有高偏差的预测。

12.2. 使用基于树的算法预测连续变量

在本节中,我将向您展示如何使用基于树的算法来预测连续结果变量。在第七章中,我向您展示了基于树的算法(如 rpart 算法)如何一次分割特征空间成单独的区域,每次一个二分分割。算法试图将特征空间分割成每个区域只包含特定类别的案例。换句话说,算法试图学习导致尽可能纯净区域的二分分割。

备注

记住,特征空间指的是预测变量值的所有可能组合,而purity指的是单个区域内案例的均匀程度。

为了刷新您的记忆,我在图 12.4 中重新绘制了图 7.4,展示了如何将两个预测变量的特征空间分割以预测三个类别的成员资格。

图 12.4. 如何对分类问题进行分割。属于三个类别的案例被绘制在两个连续变量上。第一个节点根据变量 2 的值将特征空间分割成矩形。第二个节点进一步根据变量 1 的值将变量 2 ≥ 20 的特征空间分割成矩形。

基于树的算法进行分类有点像在农场把动物赶到不同的围栏里。很明显,我们想要一个围栏放鸡,一个放牛,一个放羊驼(我不认为你在农场看到很多羊驼,但我特别喜欢它们)。所以从概念上讲,我们很容易想象将特征空间的区域分割成不同类别的不同围栏。但也许想象将特征空间分割来预测一个连续变量并不那么容易。

那么,这种分割对于回归问题是如何工作的呢?完全一样:唯一的区别是,每个区域代表的是连续结果变量的值,而不是一个类别。看看图 12.5,在那里我们使用旅程长度示例创建回归树。回归树的节点将特征空间(出发时间)分割成不同的区域。每个区域代表其中案例的结果变量的平均值。当对新数据进行预测时,模型将预测新数据所属区域的值。树的叶子不再是类别,而是数字。这在图 12.5 中用有一个和两个预测变量的情况进行了说明,但它可以扩展到任何数量的预测变量。

正如分类一样,回归树可以处理连续和分类预测变量(除了 XGBoost,它要求分类变量进行数值编码)。对于连续和分类变量决定分割的方式与分类树相同,只是算法寻找的是具有最低平方和的分割,而不是具有最高 Gini 增益的分割。

图 12.5. 对于回归问题,如何进行分割。特征空间根据每个图表旁边的树节点分割成阴影区域。每个区域内部显示了预测的旅程长度。顶部图表中的虚线展示了如何根据树从出发时间预测旅程长度。底部图表显示了两个预测变量的情况。

图 12-5

注意

回想一下第七章,Gini 增益是父节点和分割的 Gini 指数之间的差异。Gini 指数是不纯度的度量,等于 1 – (p(A)² + p(B)²),其中p(A)和p(B)分别是属于类别AB的案例的比例。

对于每个候选分割,算法计算左右分割的平方残差之和,并将它们相加以形成整个分割的平方残差之和。在 图 12.6 中,算法正在考虑 7:45 之前的出发时间的候选分割。对于每个出发时间在 7:45 之前的案例,算法计算平均旅程长度,找到残差误差(每个案例的旅程长度与平均值的差异),并将其平方。对于你在 7:45 之后离开房子的案例,也以它们各自的平均值进行同样的操作。这两个平方残差之和给出了分割的平方和。如果您更喜欢用数学符号表示,它显示在 方程式 12.1 中。

图 12.6. 如何为回归问题选择候选分割。纯度的度量是分割的平方和,即左右节点的组合平方和。每个平方和是每个案例与其所属叶子的预测值之间的垂直距离。

方程式 12.1。

其中 ileftiright 分别表示属于左分割和右分割的案例。

具有最低平方和的候选分割被选为树中任何特定点的分割。因此,对于回归树来说,纯度指的是数据围绕节点均值的分布程度。

12.3. 构建您的第一个 kNN 回归模型

在本节中,我将教你如何定义一个用于回归的 kNN 学习者,调整 k 超参数,并训练一个模型,这样你就可以用它来预测一个连续变量。想象一下,你是一位化学工程师,试图根据对每个批次的测量来预测各种批次燃料释放的热量。我们将首先在这个任务上训练一个 kNN 模型,然后在章节的后面部分将其性能与随机森林和 XGBoost 模型进行比较。

让我们从加载 mlr 和 tidyverse 包开始:

library(mlr)

library(tidyverse)

12.3.1. 加载和探索燃料数据集

mlr 包方便地包含几个预定义的任务,以帮助您尝试不同的学习者和过程。我们将在本章中使用的数据集包含在 mlr 的 fuelsubset.task 中。我们以加载任何内置数据集相同的方式将此任务加载到我们的 R 会话中:使用 data() 函数。然后我们可以使用 mlr 的 getTaskData() 函数从任务中提取数据,以便我们可以探索它。像往常一样,我们使用 as_tibble() 函数将数据框转换为 tibble。

列表 12.1. 加载和探索燃料数据集
data("fuelsubset.task")

fuel <- getTaskData(fuelsubset.task)

fuelTib <- as_tibble(fuel)

fuelTib

# A tibble: 129 x 367
   heatan   h20 UVVIS.UVVIS.1 UVVIS.UVVIS.2 UVVIS.UVVIS.3 UVVIS.UVVIS.4
    <dbl> <dbl>         <dbl>         <dbl>         <dbl>         <dbl>
 1   26.8  2.3         0.874          0.748        0.774         0.747
 2   27.5  3          -0.855         -1.29        -0.833        -0.976
 3   23.8  2.00       -0.0847        -0.294       -0.202        -0.262
 4   18.2  1.85       -0.582         -0.485       -0.328        -0.539
 5   17.5  2.39       -0.644         -1.12        -0.665        -0.791
 6   20.2  2.43       -0.504         -0.890       -0.662        -0.744
 7   15.1  1.92       -0.569         -0.507       -0.454        -0.576
 8   20.4  3.61        0.158          0.186        0.0303        0.183
 9   26.7  2.5         0.334          0.191        0.0777        0.0410
10   24.9  1.28        0.0766         0.266        0.0808       -0.0733
# ... with 119 more rows, and 361 more variables

我们有一个包含 129 个不同批次的燃料和 367 个变量/特征的 tibble!实际上,变量如此之多,以至于我已经截断了 tibble 的打印输出,以删除不适合我控制台的变量名称。

小贴士

运行names(fuelTib)以返回数据集中所有变量的名称。当处理具有太多列而无法在控制台可视化的大型数据集时,这很有用。

heatan变量是当一定量的燃料燃烧时释放的能量量(以兆焦耳计量)。h20变量是燃料容器中湿度的百分比。其余变量显示了每批燃料吸收特定波长的紫外或近红外光量(每个变量代表不同的波长)。

小贴士

要查看 mlr 中内置的所有任务,请使用data(package = "mlr")

让我们绘制数据,以了解heatan变量在紫外和近红外光的各个波长下与absorbance变量之间的相关性。我们将通过进行一些更复杂的操作来提升 tidyverse 的使用水平,所以让我一步步带你通过列表 12.2 中的过程:

  1. 因为我们要为数据中的每个案例绘制一个单独的geom_smooth()线,所以我们首先将数据通过管道传递给一个mutate()函数调用,在那里我们创建一个仅作为行索引的id变量。我们使用nrow(.)来指定通过mutate()传递的数据对象中的行数。

  2. 我们将步骤 1 的结果通过管道传递给一个gather()函数,以创建包含光谱信息的键值对变量(wavelength作为键,该波长的absorbance作为值)。我们在收集过程中省略了heatanh20id变量(c(-heatan, -h20, -id))。

  3. 我们将步骤 2 的结果通过管道传递给另一个mutate()函数,以创建两个新变量:

    1. 一个表示行是否显示紫外或近红外光谱吸收率的字符向量

    2. 一个表示该特定光谱波长的数值向量

我在这里介绍了来自 stringr tidyverse 包的两个函数:str_sub()str_extract()str_sub()函数将字符字符串分割成其单个字母数字字符和符号,并返回位于startend参数之间的那些。例如,str_sub("UVVIS.UVVIS.1", 1, 3)返回"UVV"。我们使用此函数在光谱为紫外时将列值更改为"UVV",在光谱为近红外时更改为"NIR"

str_extract()函数在字符字符串中查找特定的模式,并返回该模式。在列表 12.2 的例子中,我们要求该函数查找任何数字,使用\\d\\d后面的+告诉函数该模式可能被匹配多次。例如,比较str_extract("hello123", "\\d")str_extract("hello123", "\\d+")的输出。

列表 12.2. 准备绘图数据
fuelUntidy <- fuelTib %>%
  mutate(id = 1:nrow(.)) %>%
  gather(key = "variable", value = "absorbance",
  c(-heatan, -h20, -id)) %>%
  mutate(spectrum = str_sub(variable, 1, 3),
         wavelength = as.numeric(str_extract(variable, "(\\d)+")))

fuelUntidy
# A tibble: 47,085 x 7
   heatan   h20    id variable      absorbance spectrum wavelength
    <dbl> <dbl> <int> <chr>              <dbl> <chr>         <dbl>
 1   26.8  2.3      1 UVVIS.UVVIS.1     0.874  UVV               1
 2   27.5  3        2 UVVIS.UVVIS.1    -0.855  UVV               1
 3   23.8  2.00     3 UVVIS.UVVIS.1    -0.0847 UVV               1
 4   18.2  1.85     4 UVVIS.UVVIS.1    -0.582  UVV               1
 5   17.5  2.39     5 UVVIS.UVVIS.1    -0.644  UVV               1
 6   20.2  2.43     6 UVVIS.UVVIS.1    -0.504  UVV               1
 7   15.1  1.92     7 UVVIS.UVVIS.1    -0.569  UVV               1
 8   20.4  3.61     8 UVVIS.UVVIS.1     0.158  UVV               1
 9   26.7  2.5      9 UVVIS.UVVIS.1     0.334  UVV               1
10   24.9  1.28    10 UVVIS.UVVIS.1     0.0766 UVV               1
# ... with 47,075 more rows

这是一些相当复杂的数据处理,所以运行代码并查看生成的 tibble,确保你理解我们是如何创建它的。

小贴士

我们通过指定正则表达式来在字符向量中寻找模式,例如列表 12.2 中的"\\d+"。正则表达式是用来描述搜索模式的一种特殊文本字符串。正则表达式是提取(有时是复杂的)字符字符串模式非常有用的工具。如果我对您对正则表达式产生了兴趣,您可以通过运行?regex来了解更多关于如何在 R 中使用它们的信息。

现在我们已经格式化好了数据以供绘图,我们将绘制三个图:

  • 吸光度加热量的关系,每个波长都有单独的曲线

  • 波长吸光度的关系,每个案例都有单独的曲线

  • 湿度(h20)与加热量的关系

吸光度加热量的图中,我们将波长放在as.factor()函数内,这样每个波长都会用离散的颜色绘制(而不是从低波长到高波长的颜色渐变)。为了防止ggplot()函数绘制一个显示每条线颜色的巨大图例,我们通过添加theme(legend.position = "none")来抑制图例。我们通过光谱进行分面,为紫外光谱和近红外光谱创建子图,允许使用scales = "free_x"参数在子图之间变化 x 轴。

我不知道你们是否如此,但我在学校总是被告知要在我的图中添加标题。我们可以在 ggplot2 中使用ggtitle()函数,在引号中提供我们想要的标题。

提示

theme()函数允许您几乎可以自定义 ggplot 的任何外观,包括字体大小和网格线的存在/不存在。我不会深入讨论这个问题,但我建议您使用?theme查看帮助页面,以了解您可以做什么。

波长吸光度的图中,我们将group美学设置为创建的id变量,这样geom_smooth()层将为每批燃料绘制单独的曲线。

列表 12.3. 绘制数据
fuelUntidy %>%
  ggplot(aes(absorbance, heatan, col = as.factor(wavelength))) +
  facet_wrap(~ spectrum, scales = "free_x") +
  geom_smooth(se = FALSE, size = 0.2) +
  ggtitle("Absorbance vs heatan for each wavelength") +
  theme_bw() +
  theme(legend.position = "none")

fuelUntidy %>%
  ggplot(aes(wavelength, absorbance, group = id, col = heatan)) +
  facet_wrap(~ spectrum, scales = "free_x") +
  geom_smooth(se = FALSE, size = 0.2) +
  ggtitle("Wavelength vs absorbance for each batch") +
  theme_bw()

fuelUntidy %>%
  ggplot(aes(h20, heatan)) +
  geom_smooth(se = FALSE) +
  ggtitle("Humidity vs heatan") +
  theme_bw()

结果图示显示在图 12.7 中(我将它们合并成单个图以节省空间)。有时数据确实很美,不是吗?在吸光度加热量的图中,每条线对应一个特定的波长。每个预测变量与结果变量之间的关系是复杂且非线性的。h20加热量之间也存在非线性关系。

波长吸光度的图中,每条线对应一个特定的燃料批次,线显示了其紫外和近红外光的吸光度。线的阴影对应于该批次的加热量值。在这些图中识别模式很困难,但某些吸光度轮廓似乎与更高的加热量值和更低的加热量值相关联。

提示

虽然你当然可以过拟合你的数据,但你永远不能过拟合你的图。在开始探索性分析时,我会以多种不同的方式绘制我的数据集,以便从不同的角度/视角更好地理解它。

练习 1

absorbanceheatan的图中添加一个额外的geom_smooth()层,并使用以下参数:

  • group = 1

  • col = "blue"

使用group = 1参数,创建一条平滑线,以建模所有数据,忽略分组。

图 12.7. 在 fuelTib 数据集中绘制关系。最上面的图显示了absorbanceheatan的关系,每个wavelength都有单独的线条,按近红外(NIR)或紫外(UVV)光进行分面。中间的图显示了wavelengthabsorbance的关系,按heatan着色,每个燃料批次都有单独的线条,按 NIR 或 UVV 光进行分面。最下面的图显示了h20heatan的关系。

建模光谱数据

我们正在处理的数据集是光谱数据的一个例子。光谱数据包含在一系列(通常是)波长范围内进行的观测。例如,我们可能会测量一种物质从一系列不同颜色中吸收光量的多少。

统计学家和数据科学家将此类数据称为泛函数据,其中数据集具有许多维度(我们测量的波长),并且这些维度具有特定的顺序(从测量最低波长处的吸光度开始,逐步到最高波长)。

统计学的一个分支,称为泛函数据分析,致力于建模此类数据。在泛函数据分析中,每个预测变量都被转换为一个函数(例如,一个描述紫外和近红外波长范围内吸光度变化的函数)。然后,这个函数在模型中用作预测变量,以预测结果变量。我们不会将此类技术应用于这些数据,但如果你对泛函数据分析感兴趣,可以查看 James Ramsay 所著的泛函数据分析(Springer,2005 年)。

因为预定义的fuelsubset.task将紫外和近红外光谱定义为泛函变量,我们将定义自己的任务,将每个波长视为一个单独的预测变量。我们像往常一样使用makeRegrTask()函数做这件事,将heatan变量作为我们的目标。然后我们使用makeLearner()函数定义我们的 kNN 学习器。

列表 12.4. 定义任务和 kNN 学习器
fuelTask <- makeRegrTask(data = fuelTib, target = "heatan")

kknn <- makeLearner("regr.kknn")
注意

注意到,对于回归,学习器的名称是"regr.kknn",有两个 k,而不是我们在第三章中使用的"classif.knn"。这是因为此函数来自 kknn 包,它允许我们执行核 k 最近邻,其中我们使用核函数(就像在第六章中使用的 SVMs 一样)在类别之间找到线性决策边界。

12.3.2. 调整 k 超参数

在本节中,我们将调整 k 以获得最佳性能的 kNN 模型。记住,对于回归,k 的值决定了在为新案例进行预测时平均多少个最近邻的输出值。我们首先使用 makeParamSet() 函数定义超参数搜索空间,并将 k 定义为一个离散超参数,其可能值为 1 到 12。然后,我们将搜索过程定义为网格搜索(这样我们将尝试搜索空间中的每个值),并定义一个 10 折交叉验证策略。

正如我们之前多次做的那样,我们使用 tuneParams() 函数运行调整过程,将学习器、任务、交叉验证方法、超参数空间和搜索过程作为参数传递。

列表 12.5. 调整 k
kknnParamSpace <- makeParamSet(makeDiscreteParam("k", values = 1:12))

gridSearch <- makeTuneControlGrid()

kFold <- makeResampleDesc("CV", iters = 10)

tunedK <- tuneParams(kknn, task = fuelTask,
                     resampling = kFold,
                     par.set = kknnParamSpace,
                     control = gridSearch)

tunedK

Tune result:
Op. pars: k=7
mse.test.mean=10.7413

我们可以通过使用 generateHyperParsEffectData() 函数提取调整数据,并将其传递给 plotHyperParsEffect() 函数来绘制超参数调整过程,将我们的超参数("k") 作为 x 轴,MSE("mse.test.mean") 作为 y 轴。将 plot.type 参数设置为 "line" 将样本用线连接起来。

列表 12.6. 绘制调整过程
knnTuningData <- generateHyperParsEffectData(tunedK)

plotHyperParsEffect(knnTuningData, x = "k", y = "mse.test.mean",
                    plot.type = "line") +
  theme_bw()

结果图显示在 图 12.8。我们可以看到,随着 k 超过 7,平均 MSE 开始上升,所以看起来我们的搜索空间是合适的。

练习 2

让我们确保我们的搜索空间足够大。重复调整过程,但搜索 k 的值从 1 到 50。像我们在 图 12.8 中做的那样绘制这个调整过程。我们的原始搜索空间是否足够大?

现在我们已经得到了调整后的 k 值,我们可以使用 setHyperPars() 函数定义一个使用该值的学习者,并使用它来训练一个模型。

图 12.8. 绘制我们的超参数调整过程。显示了每个 k 值的平均 MSE(mse.test.mean)。

列表 12.7. 训练最终的、调整后的 kNN 模型
tunedKnn <- setHyperPars(makeLearner("regr.kknn"), par.vals = tunedK$x)

tunedKnnModel <- train(tunedKnn, fuelTask)

12.4. 构建你的第一个随机森林回归模型

在本节中,我将教你如何定义一个用于回归的随机森林学习者,调整其许多超参数,并为我们燃料任务训练一个模型。

注意

我们还可以使用 rpart 算法构建回归树,但由于它几乎总是被集成学习和提升学习所超越,我们将跳过它,直接进入随机森林和 XGBoost。回想一下,集成(自助聚合)学习者在数据的自助样本上训练多个模型,并返回多数投票。提升学习器按顺序训练模型,更加重视纠正先前集成模型的错误。

我们将首先定义我们的随机森林学习者。请注意,与 第八章 中的 "classif .randomForest" 不同,回归的等效是 "regr.randomForest"

forest <- makeLearner("regr.randomForest")

接下来,我们将调整我们的随机森林学习者的超参数:ntreemtrynodesizemaxnodes。我首先在第八章中定义了这些超参数的作用,但让我们在这里回顾一下每个参数:

  • ntree控制要训练的单独树的数量。更多的树通常更好,直到增加更多的树不再进一步提高性能。

  • mtry控制为每个单独的树随机采样的预测变量数量。在每个单独的树上使用预测变量的随机选择进行训练有助于保持树的不相关性,从而有助于防止集成模型过度拟合训练集。

  • nodesize定义了在叶节点中允许的最小案例数。例如,将nodesize设置为 1 将允许训练集中的每个案例都有自己的叶节点。

  • maxnodes定义了每个单独树中的最大节点数。

如同往常,我们使用makeParamSet()函数创建我们的超参数搜索空间,将每个超参数定义为具有合理上下限的整数。

我们定义了一个具有 100 次迭代的随机搜索,并使用森林学习者、fuel 任务和holdout交叉验证策略开始调整过程。

警告

这个调整过程需要一点时间,所以让我们使用我们的好朋友并行和 parallelMap 包。使用并行化,在我的四核机器上这需要 2 分钟。

列表 12.8. 随机森林的超参数调整
forestParamSpace <- makeParamSet(
  makeIntegerParam("ntree", lower = 50, upper = 50),
  makeIntegerParam("mtry", lower = 100, upper = 367),
  makeIntegerParam("nodesize", lower = 1, upper = 10),
  makeIntegerParam("maxnodes", lower = 5, upper = 30))

randSearch <- makeTuneControlRandom(maxit = 100)

library(parallel)

library(parallelMap)

parallelStartSocket(cpus = detectCores())

tunedForestPars <- tuneParams(forest, task = fuelTask,
                              resampling = kFold,
                              par.set = forestParamSpace,
                              control = randSearch)

parallelStop()

tunedForestPars

Tune result:
Op. pars: ntree=50; mtry=244; nodesize=6; maxnodes=25
mse.test.mean=6.3293

接下来,让我们使用调整好的超参数来训练随机森林模型。一旦我们训练了模型,提取模型信息并将其传递给plot()函数以绘制袋外误差是个好主意。回想一下第八章,袋外误差是由没有包含在该树的 bootstrap 样本中的树对每个案例的平均预测误差。分类和回归随机森林的袋外误差之间的唯一区别在于,在分类中,误差是错误分类的案例比例;但在回归中,误差是均方误差。

列表 12.9. 训练模型并绘制袋外误差
tunedForest <- setHyperPars(forest, par.vals = tunedForestPars$x)

tunedForestModel <- train(tunedForest, fuelTask)

forestModelData <- getLearnerModel(tunedForestModel)

plot(forestModelData)

结果图显示在图 12.9 中。看起来袋外误差在 30-40 个袋装树之后稳定下来,因此我们可以满意地认为我们已经包含了足够多的树在我们的森林中。

图 12.9. 绘制我们的随机森林模型的袋外误差。误差 y 轴显示了所有情况预测的均方误差,这些预测是由没有包含该案例的训练集的树做出的。这显示了集成中不同树的数量。线条变平表明我们已经包含了足够单独的树在森林中。

12.5. 构建你的第一个 XGBoost 回归模型

在本节中,我将向您介绍如何定义一个用于回归的 XGBoost 学习器,调整其众多超参数,并为我们燃料任务训练一个模型。我们将首先定义我们的 XGBoost 学习器。就像在第八章中使用的 kNN 和随机森林学习器一样,我们不是使用"classif.xgboost",而是使用回归等效的"regr.xgboost"

xgb <- makeLearner("regr.xgboost")

接下来,我们将调整我们的 XGBoost 学习器的超参数:etagammamax_depthmin_child_weightsubsamplecolsample_bytreenrounds。我在第八章中首先定义了这些超参数的作用,但再次,让我们在这里回顾每个参数:

  • eta被称为学习率。它取 0 到 1 之间的值,乘以每棵树的模型权重,以减慢学习过程,防止过拟合。

  • gamma是节点必须通过分割改进损失函数(在回归的情况下为 MSE)的最小分割量。

  • max_depth是每棵树可以生长的最大深度。

  • min_child_weight是在尝试分割节点之前,节点中需要的最小不纯度(如果一个节点足够纯净,则不要再次尝试分割它)。

  • subsample是每个树随机采样(不重复)的案例比例。将此设置为 1 将使用训练集中的所有案例。

  • colsample_bytree是每个树中采样的预测变量比例。我们还可以调整colsample_bylevelcolsample_bynode,它们分别在每个树的每个深度级别和每个节点处采样预测变量。

  • nrounds是模型中按顺序构建的树的数目。

注意

当我们使用 XGBoost 进行分类问题时,我们还可以调整eval_metric超参数,以在日志损失和分类错误损失函数之间进行选择。对于回归问题,我们只有一个可用的损失函数——RMSE,因此不需要调整此超参数。

在列表 12.10 中,我们定义了我们将搜索的每个这些超参数的类型和上下限。我们将max_depthnrounds定义为整数超参数,其余的为数值。我为每个超参数的上限和下限选择了合理的起始值,但您可能在自己的项目中发现需要调整搜索空间以找到最佳值的组合。我通常将nrounds超参数固定为一个适合我的计算预算的单个值,然后绘制损失函数(RMSE)与树数的关系图,以查看模型误差是否已经平坦化。如果没有,我将增加nrounds超参数,直到它平坦化。我们将在列表 12.11 中执行此操作。

一旦定义了搜索空间,我们就开始调整过程,就像我们在本章的前两次一样。

警告

这在我的四核机器上大约需要 1.5 分钟。

列表 12.10. XGBoost 的超参数调整
xgbParamSpace <- makeParamSet(
  makeNumericParam("eta", lower = 0, upper = 1),
  makeNumericParam("gamma", lower = 0, upper = 10),
  makeIntegerParam("max_depth", lower = 1, upper = 20),
  makeNumericParam("min_child_weight", lower = 1, upper = 10),
  makeNumericParam("subsample", lower = 0.5, upper = 1),
  makeNumericParam("colsample_bytree", lower = 0.5, upper = 1),
  makeIntegerParam("nrounds", lower = 30, upper = 30))

tunedXgbPars <- tuneParams(xgb, task = fuelTask,
                           resampling = kFold,
                           par.set = xgbParamSpace,
                           control = randSearch)

tunedXgbPars

Tune result:
Op. pars: eta=0.188; gamma=6.44; max_depth=11; min_child_weight=1.55; subsamp
     le=0.96; colsample_bytree=0.7; nrounds=30
mse.test.mean=6.2830

现在我们已经得到了调整后的超参数组合,让我们使用这个组合来训练最终的模型。一旦我们这样做,我们就可以提取模型信息,并使用它来绘制迭代次数(树数)与 RMSE 的关系,以查看我们是否在我们的集成中包含了足够的树。每个树数的 RMSE 信息包含在模型信息的$evaluation_log组件中,因此我们使用这个作为ggplot()函数的数据参数,指定itertrain_rmse来分别绘制树数及其 RMSE 作为 x 和 y 的美学。

列表 12.11. 训练模型并绘制 RMSE 与树数的关系
tunedXgb <- setHyperPars(xgb, par.vals = tunedXgbPars$x)

tunedXgbModel <- train(tunedXgb, fuelTask)

xgbModelData <- getLearnerModel(tunedXgbModel)

ggplot(xgbModelData$evaluation_log, aes(iter, train_rmse)) +
  geom_line() +
  geom_point() +
  theme_bw()

结果图显示在图 12.10 中。我们可以看到,30 次迭代/树数几乎足以使 RMSE 平缓(增加更多的迭代不会导致更好的模型)。

12.6. 基准测试 kNN、随机森林和 XGBoost 模型构建过程

我喜欢一点健康的竞争。在本节中,我们将对 kNN、随机森林和 XGBoost 模型构建过程进行相互基准测试。我们首先创建调整包装器,将每个学习器及其超参数调整过程包装在一起。然后我们创建一个这些包装学习器的列表,将其传递给benchmark()函数。由于这个过程将花费一些时间,我们将定义并使用holdout交叉验证程序来评估每个包装器的性能(理想情况下我们会使用 k 折或重复 k 折)。

图 12.10. 绘制平均均方根误差(train_rmse)与提升过程迭代的对比。曲线在 30 次迭代前变平,表明我们已经在我们的集成中包含了足够的树。

警告

是时候喝茶吃蛋糕了!在我的四核机器上运行大约需要 7 分钟。使用 parallelMap 包不会有所帮助,因为我们正在将 XGBoost 模型作为基准测试的一部分进行训练,而 XGBoost 在允许其进行内部并行化时运行最快。

列表 12.12. 基准测试 kNN、随机森林和 XGBoost
kknnWrapper <- makeTuneWrapper(kknn, resampling = kFold,
                                par.set = kknnParamSpace,
                                control = gridSearch)

forestWrapper <- makeTuneWrapper(forest, resampling = kFold,
                                par.set = forestParamSpace,
                                control = randSearch)

xgbWrapper <- makeTuneWrapper(xgb, resampling = kFold,
                                  par.set = xgbParamSpace,
                                  control = randSearch)

learners = list(kknnWrapper, forestWrapper, xgbWrapper)

holdout <- makeResampleDesc("Holdout")

bench <- benchmark(learners, fuelTask, holdout)

bench

  task.id              learner.id mse.test.mean
1 fuelTib         regr.kknn.tuned        10.403
2 fuelTib regr.randomForest.tuned         6.174
3 fuelTib      regr.xgboost.tuned         8.043

根据这个基准结果,随机森林算法可能为我们提供性能最好的模型,平均预测误差为 2.485(6.174 的平方根)。

12.7. kNN、随机森林和 XGBoost 的优缺点

kNN、随机森林和 XGBoost 算法的优缺点在回归方面与在分类方面相同。

练习 3

通过重新运行基准实验,将我们的holdout交叉验证对象更改为kFold对象,我们可以更准确地估计我们的模型构建过程。警告:在我的四核机器上这几乎花了一个小时!将基准结果保存到对象中,并将该对象作为唯一参数传递给plotBMRBoxplots()函数。

| |

练习 4

对在练习 3 中赢得基准的模型的建模过程进行交叉验证,但在超参数调整期间进行 2,000 次随机搜索迭代。使用holdout作为内部交叉验证循环,10 折交叉验证作为外部循环。警告:我建议您使用并行化,并在午餐或夜间运行此操作。

摘要

  • k-最近邻(kNN)和基于树的算法可以用于回归以及分类。

  • 当预测连续的因变量时,kNN 做出的预测是 k 个最近邻的平均结果值。

  • 当预测连续的因变量时,基于树的算法的叶子是那个叶子内案例的平均值。

  • 在回归问题中,可以使用袋外误差和 RMSE 来识别随机森林和 XGBoost 集成是否具有足够的树。

练习题的解决方案

  1. 使用额外的geom_smooth()层绘制absorbanceheatan的关系图,以模拟整个数据集:

    fuelUntidy %>%
      ggplot(aes(absorbance, heatan, col = as.factor(wavelength))) +
      facet_wrap(~ spectrum, scales = "free_x") +
      geom_smooth(se = FALSE, size = 0.2) +
      geom_smooth(group = 1, col = "blue") +
      ggtitle("Absorbance vs heatan for each wavelength") +
      theme_bw() +
      theme(legend.position = "none")
    
  2. 将 kNN 搜索空间扩展到包括 1 到 50 之间的值:

    kknnParamSpace50 <- makeParamSet(makeDiscreteParam("k", values = 1:50))
    
    tunedK50 <- tuneParams(kknn, task = fuelTask,
                         resampling = kFold,
                         par.set = kknnParamSpace50,
                         control = gridSearch)
    
    tunedK50
    
    knnTuningData50 <- generateHyperParsEffectData(tunedK50)
    
    plotHyperParsEffect(knnTuningData50, x = "k", y = "mse.test.mean",
                        plot.type = "line") +
      theme_bw()
    
    # Our original search space was large enough.
    
  3. 在基准实验中,使用 10 折交叉验证作为外部交叉验证循环:

    benchKFold <- benchmark(learners, fuelTask, kFold)
    
    plotBMRBoxplots(benchKFold)
    
  4. 对赢得基准的算法的建模过程进行交叉验证,执行 2,000 次随机搜索迭代,并使用holdout作为内部交叉验证策略(在调整包装器内部):

    holdout <- makeResampleDesc("Holdout")
    
    randSearch2000 <- makeTuneControlRandom(maxit = 2000)
    
    forestWrapper2000 <- makeTuneWrapper(forest, resampling = holdout,
                                         par.set = forestParamSpace,
                                         control = randSearch2000)
    
    parallelStartSocket(cpus = detectCores())
    
    cvWithTuning <- resample(forestWrapper2000, fuelTask, resampling = kFold)
    
    parallelStop()
    

第四部分:降维

您现在正走在成为监督机器学习大师的路上!到目前为止,您的机器学习算法工具箱已经为您提供了解决许多现实世界分类和回归问题的技能。我们现在将进入无监督学习的领域,在那里我们不再依赖于标记数据来从数据中学习模式。因为我们不再有真实标签进行比较,验证无监督学习者的性能可能会很具挑战性,但我将展示确保最佳性能的实用方法。

回想一下第一章,无监督学习可以分为两个目标:降维和聚类。在第十三章、第十四章和第十五章中,我将向您介绍几种降维算法,您可以使用这些算法将大量变量转换为更少、更易于管理的数量。我们这样做的原因可能是为了简化可视化高维数据中模式的过程;或者作为将数据传递给监督算法之前的预处理步骤,以减轻维度灾难。

第十三章:使用主成分分析最大化方差

本章涵盖

  • 理解降维

  • 处理高维性和多重共线性

  • 使用主成分分析进行降维

降维包括多种方法,可以将一组(可能很多)变量转换为更少的变量,同时尽可能保留原始的多维信息。我们有时希望减少我们在数据集中处理的维度数量,以帮助我们可视化数据中的关系或避免高维中出现的奇怪现象。因此,降维是您机器学习工具箱中需要添加的一项关键技能!

我们在降维的第一站将带我们到一个非常著名且实用的技术:主成分分析 (PCA)。PCA 自 20 世纪初以来一直存在,它创建了新的变量,这些变量是原始变量的线性组合。因此,PCA 与我们在第五章中遇到的判别分析类似;但 PCA 不是构建新的变量来区分类别,而是构建新的变量来解释数据中的大部分变化/信息。实际上,PCA 没有标签,因为它是无监督的,并且在不依赖真实标签的情况下从数据本身中学习模式。然后我们可以使用这些新变量中的两个或三个来捕获大部分信息,作为回归、分类或聚类算法的输入,同时也可以使用它们来更好地理解我们数据中的变量是如何相互关联的。

注意

降维的第一个历史例子是具有两个维度的地图。我们在日常生活中遇到的另一种降维形式是将音频压缩成 .mp3 和 .flac 等格式。

mlr 包没有降维任务的类别,也没有降维学习者的类别(我想象中可能是 dimred.[ALGORITHM] 这样的)。PCA 是 mlr 包中唯一被封装的降维算法,我们可以将其作为预处理步骤(如插补或特征选择)包括在内。鉴于这一点,我们暂时将 mlr 包的安全性放在一边。

到本章结束时,我希望你能理解降维是什么以及为什么我们有时需要它。我将向你展示 PCA 算法是如何工作的,以及你如何可以使用它来降低数据集的维度,以帮助识别假钞。

13.1. 为什么需要降维?

在本节中,我将向您展示应用降维的主要理由:

  • 使可视化具有许多变量的数据集变得更加容易

  • 缓解维度灾难

  • 缓解多重共线性的影响

我将扩展说明维度灾难和多重共线性是什么,以及它们为什么会给机器学习带来问题,以及为什么降维可以减少我们在数据中寻找模式时这两种问题的冲击。

13.1.1. 可视化高维数据

在开始探索性分析时,你应该做的第一件事之一就是绘制你的数据。对我们这些数据科学家来说,理解我们数据的结构、变量之间的关系以及数据的分布方式是很重要的。但如果我们有一个包含数千个变量的数据集呢?我们甚至从哪里开始?将每个变量与其他变量一一绘制出来已经不再是可行的选项了,那么我们如何获得我们数据中整体结构的感受呢?嗯,我们可以将维度降低到一个更易于管理的数量,并绘制这些数据。当我们这样做时,我们不会得到原始数据集的所有信息,但这将帮助我们识别数据中的模式,比如可能表明数据中存在分组结构的案例集群。

13.1.2. 维度灾难的后果

在第五章中,我讨论了维度的诅咒。这个听起来有些戏剧性的现象描述了我们在尝试从具有许多变量的数据集中识别模式时遇到的挑战。维度诅咒的一个方面是,对于固定数量的案例,当我们增加数据集中的维度(增加特征空间)时,案例之间的距离会越来越远。为了重申这一点,在图 13.1 中,我重新绘制了第五章中的图 5.2。在这种情况下,数据被称为稀疏的。许多机器学习算法在从稀疏数据中学习模式时都会遇到困难,并且可能开始从数据集中的噪声中学习。

图 13.1. 随着维度的增加,数据变得更加稀疏。在单维、二维和三维特征空间中显示了两个类别。三维表示中的虚线用于澄清点在 z 轴上的位置。注意随着维度的增加,空隙越来越大。

维度诅咒的另一个方面是,随着维度的增加,案例之间的距离开始收敛到一个单一值。换句话说,对于特定的案例,其最近邻和最远邻之间的距离比在高度维度中趋向于 1。这对依赖于测量距离(尤其是欧几里得距离)的算法提出了挑战,如 k 最近邻算法,因为距离开始变得没有意义。

最后,我们经常会遇到这样的情况:我们拥有的变量比数据中的案例多得多。这被称为p >> n 问题,其中p是变量的数量,n是案例的数量。这再次导致特征空间的稀疏区域,使得许多算法难以收敛到最优解。

13.1.3. 共线性的后果

数据集中的变量往往与其他变量有不同程度的关联。有时我们可能有两个高度相关的变量,以至于一个基本上包含了另一个的信息(例如,皮尔逊相关系数> 0.9)。在这种情况下,这些变量被称为共线性或表现出共线性。两个可能共线性的变量的例子是年收入和银行愿意贷款给某人的最大金额;你可能会以很高的准确性从其中一个预测另一个。

提示

当超过两个变量共线性时,我们说我们的数据集中存在多重共线性。当一个变量可以从另一个变量或变量的组合中完美地预测时,我们说存在完美共线性

那么,多重共线性有什么问题呢?嗯,这取决于你分析的目标以及你使用的算法。多重共线性最常遇到的负面影响是线性回归模型参数估计。

假设你正在尝试根据卧室数量、房屋年龄(以年为单位)和房屋年龄(以月为单位)来预测房屋的价值,使用线性回归。年龄变量之间完全多重共线性,因为其中一个变量所包含的信息在另一个变量中都没有。两个预测变量的参数估计(斜率)描述了每个预测变量与结果变量之间的关系,在考虑其他变量的影响之后。如果两个预测变量捕捉到关于结果变量的大部分(或全部,在这种情况下)相同信息,那么当我们考虑一个变量的影响时,另一个变量将没有信息可以贡献。因此,两个预测变量的参数估计将比应有的要小(因为每个都是在考虑了另一个变量的影响之后估计的)。

因此,多重共线性使得参数估计更加多变,并且对数据的小幅变化更加敏感。这主要是一个问题,如果你对解释和推断参数估计感兴趣的话。如果你只关心预测准确性,而不是解释模型参数,那么多重共线性对你来说可能根本不是问题。

然而,值得注意的是,当使用你在第六章中学习的朴素贝叶斯算法时,多重共线性尤其成问题。第六章中提到的“朴素”指的是该算法假设预测变量之间是独立的。这种假设在现实世界中通常是不成立的,但朴素贝叶斯通常对预测变量之间的小相关性具有抵抗力。然而,当预测变量高度相关时,朴素贝叶斯的预测性能将显著下降,尽管在交叉验证模型时通常很容易识别这一点。

13.1.4. 通过使用 - 维度降低来缓解维度诅咒和多重共线性

你如何减轻维度诅咒和/或多重共线性对模型预测性能的影响?当然,通过维度降低!如果你能将 100 个变量的大部分信息压缩到仅仅 2 或 3 个变量中,那么数据稀疏性和接近相等距离的问题就会消失。如果你将两个多重共线性变量转换为一个新变量,该变量可以捕捉到两个变量的所有信息,那么变量之间的依赖性问题就会消失。

但我们已经遇到了另一组可以减轻维度灾难和多重共线性问题的技术:正则化。正如我们在第十一章(kindle_split_022.html#ch11)中看到的,正则化可以用来缩小参数估计,甚至完全移除贡献较弱的预测因子。因此,正则化可以减少维度灾难导致的稀疏性,并移除与其他变量共线的变量。

注意

对于大多数人来说,解决维度灾难比减少多重共线性更重要。

13.2. 主成分分析是什么?

在本节中,我将向您展示 PCA 是什么,它是如何工作的,以及为什么它是有用的。想象一下,我们测量了七个人的两个变量,并希望使用 PCA 将这些信息压缩成一个单一的变量。我们首先需要做的是通过从每个案例中每个变量的对应值中减去每个变量的均值来对变量进行中心化。

除了对变量进行中心化之外,我们还可以通过将每个变量除以其标准差来对它们进行缩放。如果变量是在不同的尺度上测量的,这很重要——否则,那些在大尺度上的变量会被赋予更重的权重。如果我们的变量在相似的尺度上,这个标准化步骤就不必要了。

在我们的数据被中心化(可能还进行了缩放)之后,PCA 现在找到一个满足两个条件的新轴:

  • 这个轴穿过原点。

  • 这个轴最大化了数据沿自身的方差。

满足这些条件的新轴被称为第一个 主轴。当数据投影到这个主轴上(以直角移动到轴上的最近点)时,这个新变量被称为第一个 主成分,通常缩写为 PC1。数据中心化和找到 PC1 的这个过程如图 13.2 所示。

图 13.2. 在应用 PCA 算法之前,我们首先(通常)通过减去每个案例中每个变量的均值来对数据进行中心化。这使数据原点位于数据中心。然后找到第一个主轴:它是通过原点并且当数据投影到它上面时,最大化数据方差的轴。

图片

第一个主轴是通过数据原点的线,一旦数据投影到它上面,沿着这条线具有最大的方差,并被称为“最大化方差”。这如图 13.3 所示。这个轴被选择是因为如果这条线解释了数据中大部分的方差,那么它也解释了数据中大部分的信息。

图 13.3. 首要主轴“最大化方差”的含义。左侧图显示了次优候选主轴。右侧图显示了最优候选主轴。数据在各自的图表下方投影到每个主轴上。数据沿轴的方差在右侧最大。

图 13-3_ 替代

这个新的主轴实际上是预测变量的线性组合。再次看看图 13.3。第一个主轴穿过两个案例簇,在 var 1 和 var 2 之间形成一个负斜率。就像在线性回归中一样,我们可以通过一个变量随另一个变量变化(当线通过原点时,截距为 0)来表示这条线。看看图 13.4,我突出显示了当 var 1 沿主轴增加两个单位时,var 2 的变化量。对于 var 1 的每两个单位的变化,var 2 减少 0.68 个单位。

有一个标准化的方式来描述通过我们的特征空间的斜率是有用的。在线性回归中,我们可以通过 y 随 x 增加一个单位而变化的量来定义斜率。但在执行 PCA 时,我们通常没有预测变量和结果变量的任何概念:我们只有一组我们希望压缩的变量。相反,我们通过每个变量(在图 13.4 中二维示例中的 x 轴和 y 轴)需要走多远来定义主轴(这样从原点的距离等于 1)。

再看看图 13.4。我们试图计算当长度 c 等于 1 时,三角形的边 a 和 b 的长度。这将告诉我们沿着 var 1 和 var 2 需要走多远,才能在主轴上离原点有 1 个单位的距离。我们如何计算 c 的长度?为什么,我们的好朋友毕达哥拉斯定理可以帮助我们!通过应用 c² = a² + b²,我们可以计算出如果我们沿着 var 1 走 2.00 个单位,沿着 var 2 走-0.68 个单位,c 的长度等于 2.11。为了使 c 的长度等于 1,我们只需将三角形的三个边都除以 2.11。现在我们定义我们的主轴如下:对于 var 1 每增加 0.95 个单位,我们在 var 2 上减少 0.32 个单位。

图 13.4. 计算主成分的特征向量。每个变量的距离都进行了缩放,以便它们标记一个点,该点距离原点沿主轴有 1 个单位的距离。我们可以通过取一个由一个变量的变化量除以另一个变量的变化量定义的三角形,并使用毕达哥拉斯定理来找到从原点到该点的距离来进行图形说明。

图 13-4_ 替代

注意,这种变换不会改变线的方向;它所做的只是将所有东西标准化,使得从原点到点的距离为 1。这些定义主轴的每个变量的标准化距离被称为特征向量。因此,从主轴得到的主成分的公式是

方程式 13.1。

方程式 13.1

因此,对于任何特定的情况,我们将其中心化(减去每个变量的均值),取其方差 1 的值并乘以 0.95,然后将结果加到方差 2 乘以-0.32 的值上,以得到该案例的 PC1 值。一个案例的主成分值被称为其成分得分

一旦我们找到了第一个主轴,我们需要找到下一个。PCA 将找到与变量数量一样多或比数据集中的案例数量少一个的主轴,取较小者。因此,第一个主成分总是解释数据中大部分方差的那个。具体来说,如果我们计算每个主成分的案例方差,PC1 将具有最大的值。沿着特定主成分的数据方差被称为其特征值

注意

如果特征空间中的特征向量定义了主轴的方向,那么特征值定义了沿着主轴的扩散程度。

一旦找到第一个主轴,下一个主轴必须与它正交。当我们数据集中只有两个维度时,这意味着第二个主轴将与第一个主轴形成直角。图 13.5 中的例子显示了一组案例被投影到它们的第一和第二个主轴上。当仅将两个变量转换为两个主成分时,绘制数据的成分得分相当于围绕原点旋转数据。

图 13.5。在二维特征空间中,第一个主轴是最大化方差的那一个(正如它总是那样),第二个主轴与第一个主轴正交(成直角)。在这种情况下,绘制主成分仅仅导致数据的旋转。

图 13.5

注意

这种强加的正交性是 PCA 擅长去除变量之间共线性原因之一:它可以将一组相关变量转换为一组不相关(正交)变量。

在旋转了图 13.5 中的数据后,数据中的大部分方差都由 PC1 解释,而 PC2 与它正交。但主成分分析(PCA)通常用于降低维度,而不仅仅是旋转双变量数据,那么当我们处于更高维空间时,主轴是如何计算的?看看图 13.6 吧。我们在三维空间中有一个数据云,它在特征空间的右下角离我们最近,而在左上角离我们越来越远(注意点变得越小)。第一个主成分轴仍然是解释数据中大部分方差的那个轴,但这次它延伸到了三维空间(从右前到左上)。在具有超过三个维度的特征空间中,这个过程也会发生,但可视化起来比较困难!

第二个主成分轴仍然与第一个正交,但现在我们有三个维度可以操作,它可以在保持它们之间直角关系的平面上自由围绕第一个轴旋转。我用一个围绕原点的圆圈来表示这种旋转自由度,离我们越远,圆圈就越淡。第二个主成分轴是那个与第一个正交但解释数据中剩余大部分方差的主成分轴。第三个主成分轴必须与前两个轴正交(与它们都成直角),因此没有移动的自由。第一个主成分总是解释最多的方差,然后是第二个、第三个,以此类推。

图 13.6。在三维特征空间中,第二个主成分轴仍然与第一个主成分轴正交,但它有自由在第一个轴周围旋转(左侧图表中的箭头椭圆所示),直到最大化剩余的方差。第三个主成分轴与第一个和第二个主成分轴正交,因此没有旋转的自由;它解释的方差最少。

图 13-6

在这一点上,你可能想知道,如果 PCA 计算的是变量数量较少的那个或案例数量减一,那么它究竟是如何降低维度数的?好吧,仅仅计算主成分根本就不是降维!降维涉及到我们在分析剩余部分决定保留多少主成分。在图 13.6 的例子中,我们有三个主成分,但前两个解释了数据集中 79% + 12% = 91%的变异。如果这两个主成分能够捕捉到原始数据集中足够的信息,使得降维变得有价值(也许我们可以从聚类或分类算法中获得更好的结果),那么我们可以愉快地丢弃剩余的 9%的信息。在章节的后面部分,我会向你展示一些决定保留多少主成分的方法。

13.3. 构建你的第一个 PCA 模型

在本节中,我们将通过使用 PCA 降低数据集的维度,将我们刚刚讨论的 PCA 理论转化为技能。想象一下,你为瑞士联邦财政部工作(由于你对金钱、巧克力、奶酪和政治中立性的热爱)。该部门认为流通中有大量伪造的瑞士银行券,你的任务是找到一种方法来识别它们。在此之前没有人研究过这个问题,也没有标记的数据可以依据。因此,你要求 200 名同事每人给你一张纸币(你承诺会还给他们),并测量每张纸币的尺寸。你希望真钞和假钞之间可能存在一些差异,你可以使用 PCA 来识别。

在本节中,我们将通过以下方法来解决这个问题:

  1. 在 PCA 之前探索和绘制原始数据集

  2. 使用prcomp()函数从数据中学习主成分

  3. 探索和绘制 PCA 模型的结果

13.3.1. 加载和探索银行券数据集

我们将首先加载 tidyverse 包,从 mclust 包中加载数据,并将数据框转换为 tibble。我们有一个包含 200 张纸币的 tibble,有 7 个变量。

列表 13.1. 加载银行券数据集
library(tidyverse)

data(banknote, package = "mclust")

swissTib <- as_tibble(banknote)

swissTib

# A tibble: 200 x 7
   Status  Length  Left Right Bottom   Top Diagonal
   <fct>    <dbl> <dbl> <dbl>  <dbl> <dbl>    <dbl>
 1 genuine   215\.  131   131\.    9     9.7     141
 2 genuine   215\.  130\.  130\.    8.1   9.5     142.
 3 genuine   215\.  130\.  130\.    8.7   9.6     142.
 4 genuine   215\.  130\.  130\.    7.5  10.4     142
 5 genuine   215   130\.  130\.   10.4   7.7     142.
 6 genuine   216\.  131\.  130\.    9    10.1     141.
 7 genuine   216\.  130\.  130\.    7.9   9.6     142.
 8 genuine   214\.  130\.  129\.    7.2  10.7     142.
 9 genuine   215\.  129\.  130\.    8.2  11       142.
10 genuine   215\.  130\.  130\.    9.2  10       141.
# ... with 190 more rows

眼尖的你们可能已经注意到,这个 tibble 实际上是标记过的。我们有变量Status告诉我们每张纸币是真钞还是假钞。这纯粹是为了教学目的;我们将从 PCA 分析中排除它,但稍后会将标签映射到最终的主成分上,以查看 PCA 模型是否将类别分开。

在我有明确的因变量的情况下,我经常将每个预测变量与因变量(如我们在前面的章节中所做的那样)进行绘图。在不监督学习的情况下,我们没有因变量,所以我更喜欢将所有变量相互绘制(前提是我没有那么多变量以至于无法这样做)。我们可以使用 GGally 包中的ggpairs()函数轻松完成此操作,你可能需要先安装它。我们将我们的 tibble 作为第一个参数传递给ggpairs()函数,然后通过将 ggplot2 的aes()函数传递给映射参数来提供任何额外的美学映射。最后,我们添加一个theme_bw()层来添加黑白主题。

列表 13.2. 使用ggpairs()绘制数据
install.packages("GGally")

library(GGally)
ggpairs(swissTib, mapping = aes(col = Status)) +
  theme_bw()

结果图表显示在 图 13.7。ggpairs() 函数的输出需要一点时间来习惯,但它为每种变量类型的组合绘制了不同类型的图表。例如,在面板顶部的一行是箱线图,显示了每个连续变量相对于分类变量的分布。在面板左侧的列中,我们以直方图的形式得到相同的结果。对角面板显示了每个变量的值分布,忽略所有其他变量。最后,点图显示了成对连续变量之间的二元关系。

图 13.7. 在我们的纸币数据集上调用 ggpairs() 函数的结果。每个变量都与每个其他变量进行绘图,根据变量类型的组合绘制不同的图表类型。

观察图表,我们可以看到一些变量似乎可以区分真伪纸币,例如 Diagonal 变量。然而,Length 变量却包含很少的信息来区分这两种纸币类别。

注意

你会看到,如果我们有更多的变量,以这种方式可视化它们之间的关系将开始变得困难!

13.3.2. 执行主成分分析

在本节中,我们将使用主成分分析算法来学习我们的纸币数据集的主成分。为此,我将向您介绍随您的 base R 安装一起提供的 stats 包中的 prcomp() 函数。一旦我们完成这个,我们将检查该函数的输出以解释主成分的成分得分。然后,我将向您展示如何从主成分中提取和解释 变量载荷,这告诉我们每个原始变量与每个主成分的相关程度。

列表 13.3. 执行主成分分析
pca <- select(swissTib, -Status) %>%
    prcomp(center = TRUE, scale = TRUE)

pca

Standard deviations (1, .., p=6):
[1] 1.7163 1.1305 0.9322 0.6706 0.5183 0.4346

Rotation (n x k) = (6 x 6):
               PC1      PC2      PC3     PC4     PC5      PC6
Length    0.006987 -0.81549  0.01768  0.5746 -0.0588  0.03106
Left     -0.467758 -0.34197 -0.10338 -0.3949  0.6395 -0.29775
Right    -0.486679 -0.25246 -0.12347 -0.4303 -0.6141  0.34915
Bottom   -0.406758  0.26623 -0.58354  0.4037 -0.2155 -0.46235
Top      -0.367891  0.09149  0.78757  0.1102 -0.2198 -0.41897
Diagonal  0.493458 -0.27394 -0.11388 -0.3919 -0.3402 -0.63180

summary(pca)

Importance of components:
                         PC1   PC2   PC3   PC4    PC5    PC6
Standard deviation     1.716 1.131 0.932 0.671 0.5183 0.4346
Proportion of Variance 0.491 0.213 0.145 0.075 0.0448 0.0315
Cumulative Proportion  0.491 0.704 0.849 0.924 0.9685 1.0000

我们首先使用 select() 函数删除 Status 变量,然后将结果数据管道输入到 prcomp() 函数中。prcomp() 函数有两个额外的重要参数:centerscalecenter 参数控制是否在应用主成分分析之前对数据进行均值中心化,其默认值是 TRUE。我们应该在应用主成分分析之前始终对数据进行中心化,因为这会移除截距并迫使主轴通过原点。

scale参数控制变量是否通过它们的方差除以以使它们彼此处于相同的尺度,其默认值是FALSE。在运行 PCA 之前是否应该标准化变量并没有明确的共识。一个常见的经验法则是,如果原始变量是在相似的尺度上测量的,则不需要标准化;但如果有一个变量测量的是克,另一个变量测量的是千克,你应该通过设置scale = TRUE来标准化它们,使它们处于相同的尺度。这很重要,因为如果你有一个变量在很大的尺度上测量,这个变量将主导特征向量,而其他变量对主成分的贡献将少得多。在这个例子中,我们将设置scale = TRUE,但本章的一个练习是设置scale = FALSE并比较结果。

注意

在这个例子中,我们并不感兴趣将Status变量包含在我们的降维模型中;但即使我们感兴趣,PCA 也无法处理分类变量。如果你有分类变量,你的选择是将它们编码为数值(这可能或可能不起作用),使用不同的降维方法(有一些可以处理分类变量,这里不会讨论),或者从连续变量中提取主成分,然后在最终数据集中将这些主成分与分类变量重新组合。

当我们打印pca对象时,我们得到了模型的一些信息输出。标准差组件是沿着每个主成分数据的标准差向量。因为方差是标准差的平方,要将这些标准差转换为主成分的特征值,我们只需将它们平方。注意,值是从左到右逐渐变小的吗?这是因为主成分按顺序解释了数据中越来越少的方差。

旋转组件包含六个特征向量。记住,这些特征向量描述了我们在每个原始变量上的距离,这样我们就沿着主轴离原点有一个单位。这些特征向量描述了主轴的方向。

如果我们将 PCA 结果传递给summary()函数,我们将得到每个主成分重要性的分解。标准差行与我们刚才看到的相同,包含特征值的平方根。方差比例行告诉我们每个主成分解释了多少总方差。这是通过将每个特征值除以特征值的总和来计算的。累积比例行告诉我们到目前为止主成分解释了多少方差。例如,我们可以看到 PC1 和 PC2 分别解释了总方差的 49.1%和 21.3%;累积起来,它们共同解释了 70.4%。当我们决定为下游分析保留多少主成分时,这些信息是有用的。

如果我们对解释主成分感兴趣,提取变量载荷是有用的。变量载荷告诉我们原始变量中的每一个与每一个主成分的相关程度。计算特定主成分变量载荷的公式是

公式 13.2。

我们可以使用map_dfc()函数同时计算所有主成分的变量载荷,并将它们作为 tibble 返回。

列表 13.4. 计算变量载荷
map_dfc(1:6, ~pca$rotation[, .] * sqrt(pca$sdev ^ 2)[.])

# A tibble: 6 x 6
       V1     V2      V3      V4      V5      V6
    <dbl>  <dbl>   <dbl>   <dbl>   <dbl>   <dbl>
1  0.0120 -0.922  0.0165  0.385  -0.0305  0.0135
2 -0.803  -0.387 -0.0964 -0.265   0.331  -0.129
3 -0.835  -0.285 -0.115  -0.289  -0.318   0.152
4 -0.698   0.301 -0.544   0.271  -0.112  -0.201
5 -0.631   0.103  0.734   0.0739 -0.114  -0.182
6  0.847  -0.310 -0.106  -0.263  -0.176  -0.275

我们可以将这些值解释为皮尔逊相关系数,因此我们可以看到长度变量与 PC1 的相关性非常小(0.012),但与 PC2 有非常强的负相关性(-0.922)。这有助于我们得出结论,平均而言,PC2 成分得分较小的案例,其长度较大。

13.3.3. 绘制我们的 PCA 结果

接下来,让我们通过查看模型是否揭示了任何模式来绘制我们的 PCA 模型结果,以更好地理解数据中的关系。factoextra 包中有一些用于 PCA 结果的优秀绘图函数,所以让我们安装并加载这个包,并对其进行操作(见列表 13.5)。一旦加载了包,使用get_pca()函数从我们的 PCA 模型中获取信息,以便我们可以对其应用 factoextra 函数。

提示

尽管我们在列表 13.4 中手动计算了变量载荷,但提取这些信息的一种更简单的方法是打印我们在列表 13.5 中创建的pcaDat对象的$coord组件。

fviz_pca_biplot() 函数绘制一个 载荷图。载荷图是一种常见的绘图方法,可以同时绘制前两个主成分的成分得分和变量载荷。您可以在 图 13.8 的左上角看到载荷图。点表示每张纸币相对于前两个主成分的成分得分,箭头指示每个变量的载荷。这个图帮助我们确定似乎有两群不同的纸币,箭头帮助我们看到哪些变量倾向于与每个群组相关。例如,这个图中最右侧的群组在 Diagonal 变量上往往有更高的值。

提示

label = "var" 参数告诉函数只标记变量;否则,它将每个案例的行号作为标签,这让我感到非常不适。

fviz_pca_var() 函数绘制一个 变量载荷图。您可以在 图 13.8 的右上角看到变量载荷图。请注意,这显示了与载荷图相同的变量载荷箭头,但现在轴代表每个变量与每个主成分的相关性。如果您再次查看 列表 13.4 中计算出的变量载荷,您会看到这个图显示了相同的信息:每个原始变量与第一个两个主成分的相关程度。

图 13.8. 由 factoextra 包提供的 PCA 分析的典型探索性图。左上角的图显示了一个载荷图,结合了每个案例的成分得分和表示变量载荷的箭头。右上角的图显示了带有相关圆(变量载荷必须位于其边界内)的变量载荷图。底部的 scree 图显示了特征值(左)和百分比解释方差(右)。

图片

fviz_screeplot() 函数绘制一个 碎石图。碎石图是一种常见的绘图方式,将主成分与它们在数据中解释的方差量进行对比,作为一种图形化的方式来帮助确定保留多少个主成分。该函数允许我们使用 choice 参数来绘制每个主成分的特征值或百分比方差。您可以在 图 13.8 的底部两个图中看到这两种不同的 y 轴上的 scree 图。

注意

碎石图之所以得名,是因为它们类似于 碎石坡,这是由于风化侵蚀而在悬崖脚下积累的岩石和碎屑。

列表 13.5. 绘制 PCA 结果
install.packages("factoextra")

library(factoextra)

pcaDat <- get_pca(pca)

fviz_pca_biplot(pca, label = "var")

fviz_pca_var(pca)

fviz_screeplot(pca, addlabels = TRUE, choice = "eigenvalue")

fviz_screeplot(pca, addlabels = TRUE, choice = "variance")

我将 列表 13.5 中的四个图压缩成一个单独的图 (图 13.8) 以节省空间。

在决定保留多少个主成分时,有一些经验法则。一个是保留累积解释至少 80%方差的成分。另一个是保留所有特征值至少为 1 的成分;所有特征值的平均值总是 1,因此这会导致保留包含比平均值更多信息的成分。第三个经验法则是寻找“拐点”在特征值分布图上,并排除拐点之后的成分(尽管在我们的例子中没有明显的拐点)。而不是过分依赖这些经验法则,我会查看我的数据在主成分上的投影,并考虑我可以为我的应用容忍丢失多少信息。如果我在应用机器学习算法之前对我的数据进行 PCA,我更喜欢使用自动特征选择方法,就像我们在前面的章节中所做的那样,以选择导致最佳性能的主成分组合。

最后,让我们将我们的前两个主成分相互对比,看看它们能有多好地分离真伪银行钞票。我们首先将原始数据集变异,包括一个 PC1 和 PC2 的成分得分列(使用$x从我们的pca对象中提取)。然后我们将主成分相互对比,并为Status变量添加颜色美学。

列表 13.6. 映射真伪标签
swissPca <- swissTib %>%
  mutate(PCA1 = pca$x[, 1], PCA2 = pca$x[, 2])

ggplot(swissPca, aes(PCA1, PCA2, col = Status)) +
  geom_point() +
  theme_bw()
图 13.9. 对于每个案例,PCA 成分得分被绘制出来,根据它们是否为真钞或假钞进行着色。

结果图表显示在图 13.9 中。我们开始时有六个连续变量,并将大部分信息压缩成仅包含两个主成分,这两个主成分包含了足够的信息来分离两种银行钞票的集群!如果我们没有标签,已经识别出不同的数据集群,我们现在会尝试理解这两个集群是什么,也许会想出一个区分真钞和假钞的方法。

练习 1

在图 13.9 的图表中添加一个stat_ellipse()层,为每种银行钞票类别添加 95%置信椭圆。

13.3.4. 计算新数据的成分得分

我们已经有了 PCA 模型,但当我们得到新数据时我们该怎么办?嗯,因为特征向量精确地描述了每个变量对每个主成分价值的贡献程度,我们可以简单地计算新数据的成分得分(包括中心化和缩放,如果我们将其作为模型的一部分执行)。

让我们生成一些新的数据来查看这在实践中是如何工作的。在 列表 13.7 中,我们首先定义了一个包含两个新案例的 tibble,以及所有输入到我们的 PCA 模型中的相同变量。为了计算这些新案例的成分得分,我们只需使用 predict() 函数,将模型作为第一个参数,将新数据作为第二个参数。正如我们所看到的,predict() 函数返回每个主成分的每个案例的成分得分。

列表 13.7. 计算新数据的成分得分
newBanknotes <- tibble(
  Length = c(214, 216),
  Left = c(130, 128),
  Right = c(132, 129),
  Bottom = c(12, 7),
  Top = c(12, 8),
  Diagonal = c(138, 142)
)

predict(pca, newBanknotes)

        PC1     PC2     PC3    PC4    PC5   PC6
[1,] -4.729  1.9989 -0.1058 -1.659 -3.203 1.623
[2,]  6.466 -0.8918 -0.8215  3.469 -1.838 2.339

您已经学会了如何将 PCA 应用于您的数据并解释它提供的信息。在下一章中,我将介绍两种 非线性 维度降低技术。我建议您保存您的 .R 文件,因为我们将在下一章继续使用相同的数据集。这样我们可以比较这些非线性算法的性能与这里使用 PCA 创建的表示。

13.4. PCA 的优势和劣势

虽然通常不容易判断哪些算法会对给定的任务表现良好,但以下是一些优势和劣势,这将帮助您决定 PCA 是否适合您。

PCA 的优势如下:

  • PCA 创建了可以直接用原始变量解释的新轴。

  • 新数据可以投影到主轴上。

  • PCA 实际上是一种数学变换,因此计算成本较低。

PCA 的劣势如下:

  • 从高维到低维的映射不能是非线性的。

  • 它无法原生处理分类变量。

  • 对于手头的应用,必须由我们决定保留最终主成分的数量。

练习 2

在我们的瑞士纸币数据集上重新运行 PCA,但这次将 scale 参数设置为 FALSE。将以下结果与我们在缩放数据上训练的 PCA 进行比较:

  1. 特征值

  2. 特征向量

  3. 双变量图

  4. 变量加载图

  5. 切片图

练习 3

再次执行与 练习 2 相同的操作,但这次将参数 center = FALSEscale = TRUE 设置。

摘要

  • 维度降低是一种无监督学习,它在尽可能保留信息的同时,学习高维数据集的低维表示。

  • PCA 是一种线性维度降低技术,它找到新的轴,以最大化数据中的方差。这些主轴中的第一个最大化最大方差,然后是第二个,第三个,以此类推,它们都与之前计算过的轴正交。

  • 当数据投影到这些主轴上时,新的变量被称为主成分。

  • 在 PCA 中,特征值表示主成分沿方差的大小,而特征向量表示通过原始特征空间的主轴方向。

练习解答

  1. 将 95% 置信椭圆添加到 PCA1 与 PCA2 的图中:

    ggplot(swissPca, aes(PCA1, PCA2, col = Status)) +
      geom_point() +
      stat_ellipse() +
      theme_bw()
    
  2. 比较当 scale = FALSE 时的 PCA 结果:

    pcaUnscaled <- select(swissTib, -Status) %>%
      prcomp(center = TRUE, scale = FALSE)
    
    pcaUnscaled
    
    fviz_pca_biplot(pcaUnscaled, label = "var")
    
    fviz_pca_var(pcaUnscaled)
    
    fviz_screeplot(pcaUnscaled, addlabels = TRUE, choice = "variance")
    
  3. 比较当center = FALSEscale = TRUE时的 PCA 结果:

    pcaUncentered <- select(swissTib, -Status) %>%
      prcomp(center = FALSE, scale = TRUE)
    
    pcaUncentered
    
    fviz_pca_biplot(pcaUncentered, label = "var")
    
    fviz_pca_var(pcaUncentered)
    
    fviz_screeplot(pcaUncentered, addlabels = TRUE, choice = "variance")
    

第十四章. 使用 t-SNE 和 UMAP 最大化相似性

本章涵盖

  • 理解非线性降维

  • 使用 t 分布随机邻域嵌入

  • 使用均匀流形近似和投影

在上一章中,我向您介绍了 PCA 作为我们的第一个降维技术。虽然 PCA 是一个线性降维算法(它找到原始变量的线性组合),但有时一组变量中的信息不能提取为这些变量的线性组合。在这种情况下,我们可以转向许多非线性降维算法,例如t 分布随机邻域嵌入(t-SNE)和均匀流形近似和投影(UMAP)。

t-SNE 是最受欢迎的非线性降维算法之一。它测量数据集中每个观测值与其他每个观测值之间的距离,然后随机地将观测值分配到(通常是)两个新的轴上。然后,观测值在这些新轴周围迭代地重新排列,直到它们在这个二维空间中的距离尽可能接近原始高维空间中的距离。

UMAP 是另一种非线性降维算法,它克服了 t-SNE 的一些局限性。它的工作方式与 t-SNE 相似(在具有许多变量的特征空间中找到距离,然后试图在低维空间中重现这些距离),但在测量距离的方式上有所不同。

到本章结束时,我希望您能理解非线性降维是什么,以及为什么它比线性降维更有益。我将向您展示 t-SNE 和 UMAP 算法是如何工作的,以及它们之间的不同之处,并且我们将将它们应用到第十三章中的钞票数据集 chapter 13 上,以便我们可以比较它们的性能与 PCA。如果您在全局环境中不再有swissTibnewBanknotes对象,只需重新运行列表 13.1 和 13.7。

14.1. 什么是 t-SNE?

在本节中,我将向您展示什么是 t 分布随机邻域嵌入,它是如何工作的,以及为什么它是有用的。t 分布随机邻域嵌入这个名字太长了——我很高兴人们把它缩写为 t-SNE(通常发音为“tee-snee”,偶尔为“tiz-nee”),至少当你听到有人这么说时,你可以回答“愿上帝保佑你”,然后大家都会笑(至少最初几次)。

与 PCA 是一个线性降维算法(因为它找到新的轴,这些轴是原始变量的线性组合)不同,t-SNE 是一个非线性降维算法。它是非线性的,因为它不是寻找新的轴,这些轴是原始变量的逻辑组合,而是关注数据集中附近案例之间的相似性,并试图在低维空间中重现这些相似性。这种方法的主要好处是,t-SNE 几乎总是比 PCA 更好地突出数据中的模式(如集群)。这种方法的一个缺点是,轴不再可解释,因为它们不代表原始变量的逻辑组合。

t-SNE 算法的第一步是计算数据集中每个案例与其他每个案例之间的距离。默认情况下,这个距离是欧几里得距离,即特征空间中任意两点之间的直线距离(但我们也可以使用其他距离度量)。然后,这些距离被转换成概率。这可以在图 14.1 中看到。

对于数据集中特定的一个案例,测量这个案例与其他所有案例之间的距离。然后在这个案例上建立一个正态分布,并将距离转换成概率,通过将它们映射到正态分布的概率密度。这个正态分布的标准差与问题案例周围案例的密度成反比。换句话说,如果附近有很多案例(更密集),那么正态分布的标准差会更小;但如果附近案例较少(密度较低),那么标准差会更大。

将距离转换为概率后,每个案例的概率通过除以它们的总和进行缩放。这使得数据集中每个案例的概率总和为 1。使用不同的标准差来处理不同的密度,并将每个案例的概率归一化到 1,意味着如果数据集中有密集的案例集群和稀疏的案例集群,t-SNE 将扩展密集集群并压缩稀疏集群,以便更容易一起可视化。数据密度与正态分布标准差之间的确切关系取决于一个称为困惑度的超参数,我们将在稍后讨论。

图 14.1. t-SNE 通过在当前案例上拟合正态分布来测量每个案例到其他每个案例的距离,并将其转换为概率。这些概率通过除以它们的总和进行缩放,因此它们加起来为 1。

图 14-1

一旦计算出了数据集中每个案例的缩放概率,我们就得到了一个概率矩阵,它描述了每个案例与其他每个案例的相似程度。这在图 14.2 中以热图的形式展示,这是一种思考它的有用方式。

我们的概率矩阵现在是我们如何将数据值关联到原始、高维空间中的参考,或模板。t-SNE 算法的下一步是在(通常是)两个新轴上随机化案例(这就是“随机”这个名字的由来)。

注意

不需要是两个轴,但通常是这样的。这是因为人类难以同时可视化超过两个维度中的数据,而且因为,超过三个维度,t-SNE 的计算成本会越来越高,变得难以承受。

t-SNE 计算在这个新的、随机化的低维空间中案例之间的距离,并将它们转换为概率,就像之前一样。唯一的不同是,它现在使用 Student’s t 分布,而不是正态分布。t 分布看起来有点像正态分布,但中间部分不那么高,尾部更平,延伸得更远(参见图 14.3)。这就像有人在正态分布上坐下并把它压扁了。这就是 t-SNE 中“t”的由来。我马上会解释为什么我们使用 t 分布。

图 14.2. 每个案例的缩放概率存储为一个值矩阵。这里以热图的形式展示:两个案例越接近,代表它们在热图中的距离的方框就越暗。

图片 14-2

图 14.3. 在将低维表示中的距离转换为概率时,t-SNE 在当前案例上拟合 Student’s t 分布,而不是正态分布。Student’s t 分布的尾部更长,这意味着不相似的案例被推得更远,以达到与高维表示相同概率。

图片 14-3

t-SNE 现在的任务是“重新排列”这些新轴上的数据点,逐步进行,使得低维空间中的概率矩阵尽可能接近原始、高维空间中的概率矩阵。这里的直觉是,如果矩阵尽可能相似,那么每个案例在原始特征空间中接近的数据点在低维空间中仍然会接近。你可以把这想成一个吸引和排斥的游戏。

为了使低维空间中的概率矩阵看起来像高维空间中的矩阵,每个案例都需要移动到在原始数据中与其靠近的案例附近,并远离远离的案例。因此,应该靠近的案例会将其邻居拉向自己,而应该远离的案例则会将非邻居推开。这些吸引力和排斥力的平衡导致数据集中的每个案例都朝着使两个概率矩阵稍微更相似的方向移动。现在,在这个新位置,再次计算低维概率矩阵,案例再次移动,使低维和高维矩阵看起来稍微更相似。这个过程会继续进行,直到达到预定的迭代次数,或者直到矩阵之间的散度(差异)不再改善。整个过程在图 14.4 中得到了说明。

图 14.4。案例在新的轴上随机初始化(这里显示了其中一个轴)。为此轴计算概率矩阵,并通过最小化 Kullback-Leibler(KL)散度来重新排列案例,使这个矩阵类似于原始的高维矩阵。在重新排列过程中,案例会被吸引向与其相似的案例(带有圆圈的线条)并排斥不相似的案例(带有三角形的线条)。

fig14-4_alt

注意

两个矩阵之间的差异是通过一个称为Kullback-Leibler 散度的统计量来衡量的,当矩阵非常不同时,这个散度很大,而当矩阵完全相同的时候,散度为零。

为什么我们要使用 t 分布将距离转换为低维空间中的概率?再次注意,从图 14.4 中可以看出,t 分布的尾部比正态分布的尾部更宽。这意味着,为了得到与正态分布相同的概率,不相似的案例需要被推得更远,远离 t 分布中心所在的案例。这有助于分散数据中可能存在的数据簇,帮助我们更容易地识别它们。然而,这一点的重大后果是,t-SNE 通常被认为在低维表示中保留了局部结构,但它通常不保留全局结构。实际上,这意味着我们可以将最终表示中彼此靠近的案例解释为彼此相似,但我们不能轻易地说原始数据中哪些案例簇比其他案例簇更相似。

一旦这个迭代过程在低 KL 散度下收敛,我们应该有一个低维度的原始数据表示,它保留了附近案例之间的相似性。虽然 t-SNE 通常在突出数据中的模式方面优于 PCA,但它确实有一些显著的局限性:

  • 它在计算上非常昂贵:其计算时间随着数据集中案例数量的增加而呈指数增长。有一个多核实现(见 github.com/RGLab/Rtsne.multicore),但对于极其大的数据集,t-SNE 可能需要数小时才能运行。

  • 它不能将新数据投影到嵌入中。我的意思是,由于数据在新的轴上的初始放置是随机的,反复在相同的数据集上运行 t-SNE 将会给出略微不同的结果。因此,我们不能像使用 PCA 那样使用 predict() 函数将新数据映射到低维表示。这阻止了我们将 t-SNE 作为机器学习流程的一部分使用,实际上将其使用限制在数据探索和可视化中。

  • 聚类之间的距离通常没有意义。比如说,在我们的最终 t-SNE 表示中,有三个数据点聚类:两个彼此靠近,第三个离其他两个较远。因为 t-SNE 专注于局部结构而不是全局结构,所以我们不能说前两个聚类比第三个聚类更相似。

  • t-SNE 并不一定保留最终表示中数据的距离或密度,因此将 t-SNE 的输出传递给依赖于距离或密度的聚类算法通常不如预期有效。

  • 我们需要为许多超参数选择合理的值,如果 t-SNE 算法在数据集上运行需要数分钟到数小时,这可能会很困难。

14.2. 构建第一个 t-SNE 嵌入

在本节中,我将向您展示如何使用 t-SNE 算法创建瑞士银行钞票数据集的低维嵌入,以比较我们在上一章中创建的 PCA 模型。首先,我们将安装并加载 Rtsne 包到 R 中,然后我将解释控制 t-SNE 学习方式的各个超参数。然后,我们将使用最佳的超参数组合创建 t-SNE 嵌入。最后,我们将绘制 t-SNE 算法学习的新低维表示,并将其与我们之前在 第十三章 中绘制的 PCA 表示进行比较。

14.2.1. 执行 t-SNE

让我们从安装和加载 Rtsne 包开始:

install.packages("Rtsne")

library(Rtsne)

t-SNE 有四个重要的超参数,这些参数可以极大地改变结果嵌入:

  • perplexity(困惑度) — 控制用于将距离转换为概率的分布的宽度。高值更多地关注全局结构,而小值更多地关注局部结构。典型值位于 5 到 50 的范围内。默认值为 30。

  • theta— 控制速度和准确度之间的权衡。因为 t-SNE 很慢,人们通常使用一个称为Barnes-Hut t-SNE 的实现,这允许我们更快地执行嵌入,但会损失一些准确性。theta超参数控制这种权衡,0 表示“精确”的 t-SNE,1 表示最快但最不准确的 t-SNE。默认值是 0.5。

  • eta— 每次迭代中每个数据点移动的距离(也称为学习率)。较低的值需要更多的迭代次数以达到收敛,但可能会导致更准确的嵌入。默认值是 200,这通常是可以接受的。

  • max_iter— 允许在计算停止之前的最大迭代次数。这取决于你的计算预算,但确保有足够的迭代次数以达到收敛是很重要的。默认值是 1,000。

小贴士

通常需要调整的最重要超参数是perplexitymax_iter

到目前为止,我们调整超参数的方法是允许自动调整过程为我们选择最佳组合,通过网格搜索或随机搜索。但由于其计算成本,大多数人会使用 t-SNE 的默认超参数值,并在嵌入看起来不合理时更改它们。如果这听起来非常主观,那是因为它是;但人们通常能够通过视觉识别 t-SNE 是否很好地将观察到的簇分开。

为了提供一个视觉辅助,说明每个超参数如何影响最终的嵌入,我已经使用一组超参数值在我们的瑞士银行票据数据上运行了 t-SNE。图 14.5 显示了使用默认的etamax_iter值的theta(行)和perplexity(列)的不同组合的最终嵌入。注意,随着perplexity值的增大,簇变得更加紧密,而在非常低的值时簇会丢失。此外,注意对于合理的perplexity值,当theta设置为 0(精确的 t-SNE)时,簇的分辨率最佳。

图 14.5. 使用默认的etamax_iter值,改变theta(行面元)和perplexity(列面元)对银行票据数据集最终 t-SNE 嵌入的影响

图片

图 14.6. 使用默认的thetaperplexity值,改变max_iter(行面元)和eta(列面元)对银行票据数据集最终 t-SNE 嵌入的影响

图片

图 14.6 展示了不同组合的 max_iter(行)和 eta(列)的最终嵌入。这里的效果稍微微妙一些,但较小的 eta 值需要更多的迭代次数才能收敛(因为每个迭代中案例移动的步长更小)。例如,对于 eta 为 100 的情况,1,000 次迭代就足以分离簇;但使用 eta 为 1 的情况下,簇在 1,000 次迭代后仍然分辨率不佳。如果你想看到我用来生成这些图代码,本章的代码可在 www.manning.com/books/machine-learning-with-r-tidyverse-and-mlr 获取。

现在你已经对 t-SNE 的超参数如何影响其性能有了更多的了解,让我们在我们的瑞士纸币数据集上运行 t-SNE。就像 PCA 一样,我们首先选择除了分类变量之外的所有列(t-SNE 也不能处理分类变量),并将这些数据通过 Rtsne() 函数传递。我们手动设置 perplexitythetamax_iter 超参数的值(说实话,我很少改变 eta),并将 verbose = TRUE 参数设置为真,以便算法在每次迭代时打印出 KL 散度的运行注释。

列表 14.1. 运行 t-SNE
swissTsne <- select(swissTib, -Status) %>%
  Rtsne(perplexity = 30, theta = 0, max_iter = 5000, verbose = TRUE)
小贴士

默认情况下,Rtsne() 函数将数据集减少到二维。如果你想返回另一个数字,你可以使用 dims 参数来设置这个值。

这没有花太多时间,对吧?对于如此小的数据集,t-SNE 只需要几秒钟。但随着数据集大小的增加,它会迅速变慢(看我在这里做了什么?)。

14.2.2. 绘制 t-SNE 的结果

接下来,让我们将两个 t-SNE 维度相互绘制,以查看它们如何将真钞和假钞分开。因为我们不能根据每个变量与它们的关联程度来解释轴,所以人们通常根据它们原始变量的值来着色 t-SNE 图,以帮助识别哪些簇具有更高的和更低的值。为此,我们首先使用 mutate_if() 函数将原始数据集中的数值变量居中(通过设置 .funs = scale.predicate = is.numeric)。我们包括 scale = FALSE 以仅居中变量,而不是除以其标准差。我们居中变量的原因是我们将根据它们的值在图上着色,我们不希望具有较大值的变量主导颜色刻度(省略此行,并亲自查看最终图中的差异)。

接下来,我们突变两个包含每个案例 t-SNE 轴值的新的列。最后,我们收集数据,以便我们可以根据原始变量进行分面。我们绘制这些数据,将每个原始变量的值映射到颜色美学,并将每张纸币的状态(真币与假币)映射到形状美学,并根据原始变量进行分面。我们添加一个自定义颜色渐变,使颜色刻度在打印时更易读。

列表 14.2. 绘制 t-SNE 嵌入图
swissTibTsne <- swissTib %>%
  mutate_if(.funs = scale, .predicate = is.numeric, scale = FALSE) %>%
  mutate(tSNE1 = swissTsne$Y[, 1], tSNE2 = swissTsne$Y[, 2]) %>%
  gather(key = "Variable", value = "Value", c(-tSNE1, -tSNE2, -Status))

ggplot(swissTibTsne, aes(tSNE1, tSNE2, col = Value, shape = Status)) +
  facet_wrap(~ Variable) +
  geom_point(size = 3) +
  scale_color_gradient(low = "dark blue", high = "cyan") +
  theme_bw()
图 14.7. tSNE1 和 tSNE2 轴相互绘制,根据原始变量分面和着色,以及根据每个案例是真实纸币还是假币进行形状绘制

图片 14-7_alt

结果图表显示在图 14.7。哇!注意 t-SNE 在仅用两个维度表示特征空间中两个簇之间的差异时比 PCA 做得好得多。簇被很好地解析,尽管如果你仔细看,你可以看到一些似乎在错误簇中的案例。通过每个变量的值着色点也有助于我们识别假币往往具有较低的对角线变量值和较高的底部顶部变量值。似乎还可能有一个小的假币第二簇:这可能是由不同的伪造者制作的纸币集,或者是不完美的超参数组合的产物。需要更多的调查来确定这些是否实际上是不同的簇。

注意

你的图表看起来和我的一样吗?当然不一样了!记住,初始嵌入是随机的(随机性的),所以每次你在相同的数据和相同的超参数上运行 t-SNE 时,你都会得到一个略微不同的嵌入。

练习 1

重新绘制图 14.7 中的图表,但这次在运行 t-SNE 之前不要对变量进行中心化(只需移除mutate_if()层)。你能看出为什么缩放是必要的吗?

14.3. 什么是 UMAP?

在本节中,我将向您展示 UMAP 是什么,它是如何工作的,以及为什么它有用。均匀流形近似和投影,幸运的是简称为 UMAP,是一种类似于 t-SNE 的非线性降维算法。UMAP 是前沿技术,仅在 2018 年发表,并且它相对于 t-SNE 算法有一些优势。

首先,它比 t-SNE 快得多,t-SNE 的运行时间长度增加小于数据集中案例数量的平方。为了更具体地说明这一点,一个可能需要 t-SNE 数小时来压缩的数据集将只需要 UMAP 几分钟。

第二个优势(在我看来是主要优势)是 UMAP 是一个确定性算法。换句话说,给定相同的输入,它总是会给出相同的输出。这意味着,与 t-SNE 不同,我们可以将新数据投影到低维表示中,使我们能够将 UMAP 纳入我们的机器学习流程中。

第三个好处是 UMAP 保留了局部全局结构。实际上,这意味着我们不仅可以将低维空间中彼此靠近的两个案例解释为在高层空间中彼此相似,而且我们还可以将彼此靠近的两个解释为在高层空间中更相似。

那么,UMAP 是如何工作的呢?嗯,UMAP 假设数据分布在一条流形上。流形是一个n维光滑几何形状,其中,对于流形上的每个点,都存在一个围绕该点的小邻域,该邻域看起来像是一个平坦的二维平面。如果你觉得这很难理解,可以考虑世界是一个三维流形,其任何部分都可以映射到一个平面的表示,即地图。UMAP 在数据分布的表面或具有许多维度的空间中搜索。然后可以计算案例在流形上的距离,并通过迭代优化数据的低维表示来重现这些距离。

倾向于视觉表示?我也是。看看图 14.8。我在两个变量上围绕流形随机放置了 15 个案例,并将问号作为流形绘制出来。UMAP 的工作是学习问号流形,以便它可以测量案例在流形上的距离,而不是像 t-SNE 那样测量普通欧几里得距离。它通过在每个案例周围搜索另一个案例来实现这一点。在这些区域包含另一个案例的地方,案例通过边连接起来。这就是我在图 14.8 的上排中所做的——但你能否看出流形是不完整的?我的问号中存在间隙。这是因为我在每个案例周围搜索的区域具有相同的半径,数据在流形上分布不均匀。如果案例在问号上以均匀间隔排列,那么这种方法将有效,前提是我选择了适当的搜索区域半径。

图 14.8. UMAP 如何学习一个流形。UMAP 在每个案例周围扩展一个搜索区域。这种形式的直观表示在上排中,其中每个搜索区域的半径相同。当具有重叠搜索区域的案例通过边连接时,流形中会有间隙。在下排中,搜索区域扩展到最近的邻居,然后以一种模糊的方式向外扩展,其半径与该区域数据的密度成反比。这导致了一个完整的流形。

图 14.8 的替代图片

现实世界的数据很少均匀分布,UMAP 以两种方式解决这个问题。首先,它为每个案例扩展每个搜索区域,直到它遇到其最近邻。这确保了没有孤儿案例:虽然数据集中可以有多个不相连的流形,但每个案例必须至少连接到另一个案例。其次,UMAP 在低密度区域创建一个具有更大半径的额外搜索区域,在高密度区域创建一个具有更小半径的搜索区域。这些搜索区域被描述为模糊的,因为另一个案例离中心越远,这些案例之间存在边的概率就越低。这强制对案例进行人工均匀分布(这也是 UMAP 中“均匀”一词的来源)。这个过程在图 14.8 的下方行中表示;请注意,我们现在得到了对潜在流形的更完整估计。

下一步是将数据放置在(通常是)两个新维度的新流形上。然后算法迭代地围绕这个新流形进行随机排列,直到流形上案例之间的距离看起来像原始高维流形上案例之间的距离。这与 t-SNE 的优化步骤类似,但 UMAP 最小化的是称为交叉熵的不同损失函数(而 t-SNE 最小化 KL 散度)。

注意

就像 t-SNE 一样,如果我们想的话,我们可以创建超过两个新维度。

一旦 UMAP 学会了低维流形,新的数据就可以投影到这个流形上,以获得新轴上的值,用于可视化或作为其他机器学习算法的输入。

注意

UMAP 还可以用于执行监督降维,这实际上只是意味着给定高维、标记的数据,它学习一个流形,可以用来将案例分类到组中。

14.4. 构建您的第一个 UMAP 模型

在本节中,我将向您展示如何使用 UMAP 算法创建瑞士银行钞票数据集的低维嵌入。请记住,我们正在尝试找到这个数据集的低维表示,以帮助我们识别模式,例如不同类型的钞票。我们将首先在 R 中安装和加载 umap 包。就像我们对 t-SNE 所做的那样,我们将讨论 UMAP 的超参数以及它们如何影响嵌入。然后我们将在银行钞票数据集上训练一个 UMAP 模型,并绘制它以比较它与我们的 PCA 模型和 t-SNE 嵌入。

14.4.1. 执行 UMAP

在本节中,我们将安装并加载 umap 包,然后调整和训练我们的 UMAP 模型。让我们首先安装并加载 umap 包:

install.packages("umap")

library(umap)

就像 t-SNE 一样,UMAP 有四个重要的超参数,它们控制着结果的嵌入:

  • n_neighbors— 控制模糊搜索区域的半径。较大的值将包括更多的邻近案例,迫使算法关注更全局的结构。较小的值将包括较少的邻居,迫使算法关注更局部结构。

  • min_dist— 定义了案例在低维表示中允许的最小距离。低值会导致“密集”的嵌入,而高值会导致案例分布得更远。

  • metric— 定义了 UMAP 将使用哪种距离度量来测量流形上的距离。默认情况下,UMAP 使用普通欧几里得距离,但也可以使用其他(有时很疯狂)的距离度量。欧几里得距离的常见替代方案是曼哈顿距离(也称为出租车距离):它不是将两点之间的距离作为一个单一的(可能是对角线)距离来测量,而是逐个变量地测量两点之间的距离,并将这些小旅程相加,就像出租车在城市街区中行驶一样。我们还可以使用除了欧几里得距离以外的距离度量来应用 t-SNE,但我们首先需要手动计算这些距离。UMAP 实现只是让我们指定我们想要的距离,然后它会处理其余的事情。

  • n_epochs— 定义了优化步骤的迭代次数。

再次强调,为了给您提供一个视觉辅助,展示每个超参数如何影响最终的嵌入,我已经使用一组超参数值在我们的瑞士纸币数据上运行了 UMAP。图 14.9 显示了使用默认的metricn_epochs值,不同组合的n_neighbors(行)和min_dist(列)的最终嵌入。请注意,n_neighborsmin_dist的值较小时,案例分布得更开,并且当n_neighbors超参数的值较低时,聚类开始分解。

图 14.9. 通过改变n_neighbors(行面元)和min_dist(列面元)的默认值,对纸币数据集的最终 UMAP 嵌入的影响,其中n_neighbors表示邻居数量,min_dist表示最小距离。

图 14.10. 通过改变metric(行面元)和n_epochs(列面元)的默认值,对瑞士纸币数据集的最终 UMAP 嵌入的影响,其中metric表示度量,n_epochs表示迭代次数。

图 14.10 显示了具有不同组合的度量(行)和n_epochs(列)的最终嵌入。这里的效果稍微微妙一些,但集群在更多迭代中往往更远。看起来曼哈顿距离在打破那些三个较小的集群(我们之前没有见过!)方面做得稍微好一些!如果你想看到我用来生成这些图形的代码,本章的代码可在www.manning.com/books/machine-learning-with-r-the-tidyverse-and-mlr找到。

我希望这能稍微揭开 UMAP 超参数的神秘面纱。现在让我们在我们的瑞士钞票数据集上运行 UMAP。就像之前一样,我们首先选择所有除了分类变量(目前 UMAP 无法处理分类变量,但将来可能会改变)之外的所有列,并将这些数据通过as.matrix()函数(只是为了防止一个令人烦恼的警告信息)。然后,这个矩阵被传递到umap()函数中,我们在其中手动设置所有四个超参数的值,并将verbose = TRUE参数设置为,以便算法打印出经过的每个 epoch(迭代)的运行注释。

列表 14.3. 执行 UMAP
swissUmap <- select(swissTib, -Status) %>%
             as.matrix() %>%
             umap(n_neighbors = 7, min_dist = 0.1,
                  metric = "manhattan", n_epochs = 200, verbose = TRUE)

14.4.2. 绘制 UMAP 的结果

接下来,让我们将两个 UMAP 维度相互对比,看看它们如何将真钞和假钞分开。我们通过与列表 14.2 中相同的步骤来重塑数据,使其准备好绘图。

列表 14.4. 绘制 UMAP 嵌入
swissTibUmap <- swissTib %>%
  mutate_if(.funs = scale, .predicate = is.numeric, scale = FALSE) %>%
  mutate(UMAP1 = swissUmap$layout[, 1], UMAP2 = swissUmap$layout[, 2]) %>%
  gather(key = "Variable", value = "Value", c(-UMAP1, -UMAP2, -Status))

ggplot(swissTibUmap, aes(UMAP1, UMAP2, col = Value, shape = Status)) +
  facet_wrap(~ Variable) +
  geom_point(size = 3) +
  scale_color_gradient(low = "dark blue", high = "cyan") +
  theme_bw()

结果图显示在图 14.11 中。UMAP 嵌入似乎暗示存在三个不同的假钞集群!也许有三个不同的造假者活跃着。

图 14.11. UMAP1 和 UMAP2 轴相互对比,通过原始变量进行分面和着色,并通过每个案例是真钞还是假钞进行形状塑造

图 14-11

14.4.3. 计算新数据的 UMAP 嵌入

记得我之前说过,与 t-SNE 不同,新数据可以被可重复地投影到 UMAP 嵌入中吗?好吧,让我们为在第十三章中预测 PCA 成分得分时定义的newBanknotes tibble 做这件事(如果你不再有这个定义,请重新运行列表 13.7)。实际上,这个过程完全相同:我们使用predict()函数,将模型作为第一个参数,新数据作为第二个参数。这将输出一个矩阵,其中行代表两个案例,列代表 UMAP 轴:

predict(swissUmap, newBanknotes)

     [,1]   [,2]
1 -6.9516 -7.777
2  0.1213  6.160

14.5. t-SNE 和 UMAP 的优势和劣势

虽然通常不容易判断哪些算法会对给定的任务表现良好,但以下是一些优势和劣势,这将帮助你决定 t-SNE 和 UMAP 是否适合你。

t-SNE 和 UMAP 的优势如下:

  • 它们可以在数据中学习非线性模式。

  • 它们比 PCA 更好地分离案例的簇。

  • UMAP 可以对新数据进行预测。

  • UMAP 计算成本较低。

  • UMAP 保留局部和全局距离。

t-SNE 和 UMAP 的弱点如下:

  • t-SNE 和 UMAP 的新轴不能直接从原始变量中解释。

  • t-SNE 不能对新数据进行预测(每次结果都不同)。

  • t-SNE 计算成本较高。

  • t-SNE 不一定保留全局结构。

  • 它们无法原生处理分类变量。

练习 2

在我们的瑞士钞票数据集上重新运行 UMAP,但这次包括参数n_components = 3(请随意通过更改其他超参数的值进行实验)。将 UMAP 对象的$layout组件传递给GGally::ggpairs()函数。(提示:您需要将此对象包裹在as.data.frame()中,否则ggpairs()会发怒。)

摘要

  • t-SNE 和 UMAP 是非线性降维算法。

  • t-SNE 将数据中所有案例之间的距离转换为基于正态分布的概率,然后迭代地在低维空间中重新排列案例以再现这些距离。

  • 在低维空间中,t-SNE 使用 Student 的 t 分布将距离转换为概率,以更好地分离数据簇。

  • UMAP 学习数据排列的流形,然后在低维空间中迭代地重新排列数据以再现沿流形的案例之间的距离。

练习解答

  1. 不对变量进行缩放,重新创建 t-SNE1 与 t-SNE2 的绘图:

    swissTib %>%
      mutate(tSNE1 = swissTsne$Y[, 1], tSNE2 = swissTsne$Y[, 2]) %>%
      gather(key = "Variable",
             value = "Value",
             c(-tSNE1, -tSNE2, -Status)) %>%
      ggplot(aes(tSNE1, tSNE2, col = Value, shape = Status)) +
      facet_wrap(~ Variable) +
      geom_point(size = 3) +
      scale_color_gradient(low = "dark blue", high = "cyan") +
      theme_bw()
    
    # Scaling is necessary because the scales of the variables are different
    # from each other.
    
  2. 重新运行 UMAP,但输出和绘制三个新轴而不是两个:

    umap3d <- select(swissTib, -Status) %>%
      as.matrix() %>%
      umap(n_neighbors = 7, min_dist = 0.1, n_components = 3,
           metric = "manhattan", n_epochs = 200, verbose = TRUE)
    
    library(GGally)
    
    ggpairs(as.data.frame(umap3d$layout), mapping = aes(col = swissTib$Status))
    

第十五章. 自组织映射和局部线性嵌入

本章涵盖

  • 创建自组织映射以降低维度

  • 创建高维数据的局部线性嵌入

在本章中,我们将继续讨论降维:一类机器学习任务,专注于以更少的变量表示大量变量中包含的信息。正如你在第十三章和第十四章中学到的,有多种方法可以降低数据集的维度。哪种降维算法最适合你取决于你的数据结构和你要实现的目标。因此,在本章中,我将向你的不断增长的机器学习工具箱中添加两个额外的非线性降维算法:自组织映射(SOMs)和局部线性嵌入(LLE)。

15.1. 前提:节点网格和流形

SOM 算法和 LLE 算法都将大量数据集缩减为更小、更易于管理的变量数量,但它们的工作方式非常不同。SOM 算法创建了一个二维的节点网格,就像地图上的网格参考。数据中的每个案例都被放置在一个节点中,然后在这些节点周围进行随机排列,使得在原始数据中彼此更相似的案例在地图上被放置得更近。

这可能很难在脑海中想象,所以让我们来看一个类比。想象一下,你有一个装满珠子的罐子,里面有你缝纫工具的珠子。珠子大小和重量不同,有些比其他的长。外面在下雨,没有更好的事情可做,所以你决定将珠子组织成几组,以便将来更容易找到所需的珠子。你在桌子上排列了一个碗的网格,并依次考虑每个珠子。然后,你将彼此最相似的珠子放在同一个碗里。相似的但不同的珠子放在相邻的碗里,而非常不同的珠子则放在彼此远离的碗里。一个可能的样子示例显示在图 15.1 中。

图 15.1. 基于珠子的特征将珠子放入碗中。相似的珠子被放在同一个或附近的碗中,而不相似的珠子被放在彼此远离的碗中。有一个碗没有放入任何珠子,但这没关系。

图 15-1

一旦你将所有珠子都放入碗中,你看看你的网格,会发现出现了一种模式。所有的大球形珠子都聚集在网格的右上角。当你从右向左移动时,珠子变小;当你从上向下移动时,珠子变得更长。你根据珠子之间的相似性将珠子放入碗中的过程揭示了珠子的结构。

这就是自组织图试图做的事情。自组织图的“地图”相当于碗的网格,其中每个碗被称为节点

另一方面,LLE 算法学习数据所在的手性,类似于你在第十四章中看到 UMAP 算法。回想一下,手性是一个n-维光滑的几何形状,可以通过一系列线性“补丁”构建。UMAP 试图一次性学习手性,而 LLE 则寻找每个案例周围的局部线性补丁,然后将这些线性补丁组合起来形成(可能是非线性的)手性。

如果这很难想象,请看图 15.2。球是一个光滑的三维流形。我们可以通过将其分解成一系列组合在一起的平面来近似球体(我们使用的这些表面越多,我们就能越接近地近似球体)。这显示在图 15.2 的左侧。想象一下,有人给你一张平面的纸张和一把剪刀,并要求你制作一个球体。你可能会将纸张切割成图 15.2 右侧所示的那种形状。然后你可以折叠这张平面的纸张来近似球体。你能看出这个平面的二维切割是球体的低维表示吗?这是 LLE 背后的基本原理,只不过它试图学习表示数据的流形,并以更少的维度表示它。

图 15.2. 球是一个三维流形。我们可以通过一系列相互连接的线性补丁来重建球体。这个球体的三维流形可以通过以某种方式切割纸张来在二维中表示。

fig15-2_alt

在本章中,我将更详细地展示 SOM 和 LLE 算法的工作原理以及我们如何使用它们来降低各种跳甲虫收集的数据的维度。我还会展示一个特别有趣的例子,说明 LLE 如何“展开”一些复杂和形状异常的数据。

15.2. 什么是自组织映射?

在本节中,我将解释 SOM 是什么,它们是如何工作的,以及为什么它们对降维很有用。考虑地图的目的。地图方便地将地球的一部分(不是平面的)以二维的形式表示出来,使得在地图上彼此靠近的地球区域实际上是彼此靠近的。这是以一种复杂的方式来说明,你会发现印度比马达加斯加更靠近斯里兰卡,因为它们在空间上彼此更近。

SOM 的目标非常相似;但与国家、城镇和城市不同,SOM 试图在二维中表示数据集,使得彼此更相似的数据案例在地图上彼此靠近。算法的第一步是在二维晶格中创建一个节点网格(就像图 15.1 中的碗的网格一样)。

15.2.1. 创建节点网格

在本节中,我将全面解释当我提到 SOM 算法创建节点网格时我的意思。这与我们在图 15.1 中排序珠子的碗的网格非常相似,SOM 算法首先创建一个节点网格。目前,你可以将节点想象成一个碗,我们将最终将数据集中的案例放入其中。我使用“网格”这个词来帮助你想象节点的晶格结构,但“地图”这个词更常用,所以从现在起我们将使用这个词。

地图可以由正方形/矩形节点组成,就像地图上的正方形网格参考一样;或者由六边形节点组成,它们紧密地像蜂巢一样排列。当地图由正方形节点组成时,每个节点与其四个邻居相连(可以说它们是它的北、南、东和西邻居)。当地图由六边形节点组成时,每个节点与其六个邻居相连(东北、东、东南、西南、西和西北)。图 15.3 展示了正方形和六边形 SOMs 的两种常见表示方式。左侧表示将每个节点表示为一个圆圈,通过线条或与邻居相连。右侧表示将每个节点表示为一个正方形或六边形,通过其平坦的侧面与邻居相连。地图的尺寸(有多少行和列)需要我们决定;我将在本章后面向你展示如何选择合适的地图大小。记住,我们仍然将这些节点视为碗。

图 15.3. 常见的正方形和六边形自组织地图的图形表示。上面两张地图显示了一个由矩形节点组成的网格,每个节点都与四个邻居相连。下面两张地图显示了一个由六边形节点组成的网格,每个节点都与六个邻居相连。

图 15-3 替代

注意

自组织地图(SOMs)是由一位名叫 Teuvo Kohonen 的芬兰计算机科学家创建的,因此有时你会看到它们被称为Kohonen 地图。SOM 算法如此受欢迎,以至于 Kohonen 教授是有史以来被引用次数最多的芬兰计算机科学家。

一旦创建了地图,下一步就是随机为每个节点分配一组权重

15.2.2. 随机分配权重,并将案例放置在节点中

在本节中,我将解释“权重”这个术语的含义以及它们的相关性。我会向你展示这些权重是如何为地图中的每个节点随机初始化的。

假设我们有一个包含三个变量的数据集,我们希望将这个数据集的案例分布到地图的节点上。最终,我们希望算法将案例放置在节点上,使得相似的案例位于同一个节点或附近的节点,而不同的案例则放置在彼此远离的节点上。

在创建地图之后,算法接下来要做的事情是随机为每个节点分配一组权重:数据集中每个变量的一个权重。所以,在我们的例子中,每个节点有三个权重,因为我们有三个变量。这些权重只是随机数,你可以把它们看作是对每个变量值的猜测。如果这很难想象,请看图 15.4。我们有一个包含三个变量的数据集,我们正在查看地图上的三个节点。每个节点下面都写着三个数字:一个对应于数据集中的每个变量。例如,节点 1 的权重是 3(变量 1)、9(变量 2)和 1(变量 3)。记住,在这个阶段,这些只是对每个变量值的随机猜测。

接下来,算法从数据集中随机选择一个案例,并计算每个变量的值与该案例的值最接近的节点权重。例如,如果数据集中有一个案例,其变量 1、变量 2 和变量 3 的值分别为 3、9 和 1,那么这个案例将完美匹配节点 1 的权重。为了找到与所讨论案例最相似的节点权重,算法计算每个案例与地图中每个节点权重的距离。这个距离通常是平方欧几里得距离。记住,欧几里得距离只是两点之间的直线距离,所以平方欧几里得距离只是省略了开方步骤,以加快计算速度。

在图 15.4 中,你可以看到计算出的第一个案例与每个节点权重之间的距离。这个案例与节点 1 的权重最相似,因为它与它们的平方欧几里得距离最小(93.09)。

注意

图 15.4 中的插图只显示了三个节点,为了简洁起见,但地图上的每个节点都会计算距离。

一旦计算出特定案例与所有节点的距离,就选择距离最小的节点(与案例最相似)作为该案例的最佳匹配单元(BMU)。这如图 15.5 所示。就像我们往碗里放珠子一样,算法将那个案例放在它的 BMU 里。

图 15.4。计算每个案例与每个节点之间距离的方法。从每个变量指向每个节点的箭头代表该变量在该特定节点上的权重(例如,节点 1 的权重是 3、9 和 1)。距离是通过找到节点权重与案例每个变量的值的差异,平方这些差异,并将它们相加来计算的。

图 15-4_alt

图 15.5。在算法的每个阶段,选择与特定案例距离最小的节点作为该案例的最佳匹配单元(BMU)。

图 15-5

15.2.3。更新节点权重以更好地匹配其内部的案例

在本节中,我将向您展示如何更新案例的 BMU 及其周围节点的权重,以更接近数据。首先,让我们总结一下到目前为止我们对 SOM 算法的了解:

  1. 创建节点图。

  2. 随机为每个节点分配权重(每个变量一个)。

  3. 随机选择一个案例,并计算其与地图中每个节点权重的距离。

  4. 将案例放入权重与案例距离最小的节点(案例的 BMU)中。

现在 BMU 已经被选中,其权重被更新,以使其与我们放入其中的案例更相似。然而,不仅仅是 BMU 的权重被更新。BMU 的邻域中的节点也更新了它们的权重(靠近 BMU 的节点)。我们可以以几种不同的方式定义邻域:一种常见的方式是使用气泡函数。使用气泡函数,我们只需在 BMU 周围定义一个半径(或气泡),半径内的所有节点都会以相同的程度更新权重。半径外的节点则完全不更新。对于气泡函数,半径为 3 将包括 BMU 三个直接连接内的任何节点。

另一个流行的选择是根据节点与 BMU 的距离来更新地图中节点的权重(距离 BMU 越远,节点的权重更新越少)。这通常使用高斯函数来完成。你可以想象,我们为 BMU 拟合一个中心的高斯分布,BMU 周围的节点权重将按照高斯密度成比例更新。我们仍然定义一个围绕 BMU 的半径,以定义高斯分布的宽度或瘦度,但这次它是一个没有硬截止的软半径。高斯函数很受欢迎,但它的计算成本比简单的气泡函数要高一些。

注意

用于更新 BMU 周围节点权重的气泡函数和高斯函数被称为邻域函数

我们选择的邻域函数是一个超参数,因为它会影响地图更新节点的方式,但不能从数据本身估计出来。

注意

你有时会看到节点的权重集被称为其代码向量

无论我们使用哪种邻域函数,在 BMU 周围更新节点权重的优点是,随着时间的推移,这样做会创建出彼此相似但仍然捕捉到数据中一些变化的节点邻域。算法使用的另一个技巧是,随着时间的推移,这个邻域的半径以及更新权重的量都会变小。这意味着地图最初更新得非常快,然后在学习过程中继续进行越来越小的更新。这有助于地图收敛到一个解决方案,希望将相似的案例放置在相同或附近的节点中。这个过程在图 15.6 中说明了在 BMU 邻域更新节点权重。

图 15.6. 在算法的第一次迭代和最后一次迭代之间,BMU 周围邻域的半径(最暗的节点)以及更新相邻节点权重的量都变小了。高斯邻域函数的半径以半透明的圆圈形式显示在 BMU 上方,每个相邻节点更新的量通过其阴影的深浅来表示。如果显示气泡邻域函数,所有节点都会被同样着色(因为它们以相同的量更新)。

现在我们已经确定了一个特定案例的 BMU 并更新了其权重及其邻居的权重,我们只需重复该过程进行下一次迭代,从数据中随机选择另一个案例。随着这个过程继续,案例可能会被多次选择,并且随着其 BMU 随时间变化,它们将在图上移动。换句话说,如果它们当前所在的节点不再是它们的 BMU,案例将改变节点。最终,相似的案例将收敛到图上的特定区域。

结果是,随着时间的推移,地图上的节点开始更好地拟合数据集。最终,在原始特征空间中相似的案例将被放置在同一个节点或地图上附近的节点。

注意

记住,特征空间指的是预测变量值的所有可能组合。

在我们动手构建自己的 SOM 之前,让我们回顾一下整个算法,以确保它留在你的脑海中:

  1. 创建节点图。

  2. 随机为每个节点分配权重(每个变量一个)。

  3. 随机选择一个案例,并计算其与图中每个节点权重的距离。

  4. 将案例放入权重与案例距离最小的节点(案例的 BMU)。

  5. 更新 BMU 及其邻域(根据邻域函数)的权重,以更接近其内部的案例。

  6. 重复步骤 3-5,直到指定的迭代次数。

15.3. 构建你的第一个 SOM

在本节中,我将向您展示如何使用 SOM 算法将数据集的维度减少到二维地图。通过这样做,我们希望通过将相似的案例放置在相同的或附近的节点上,揭示数据中的某些结构。例如,如果数据中隐藏着分组结构,我们希望不同的组会分开到地图的不同区域。我还会向您展示算法的超参数以及它们的作用。

注意

记住,超参数是一个控制算法性能/功能的变量,但不能直接从数据本身估计出来。

想象一下,你是跳蚤马戏团的头目。你决定测量所有跳蚤,看看不同的跳蚤群体在特定的马戏团任务中表现是否更好。让我们先加载 tidyverse 和 GGally 包:

library(tidyverse)

library(GGally)

15.3.1. 加载和探索跳蚤数据集

现在,让我们加载数据,这些数据是 GGally 包内置的;将其转换为 tibble(使用as_tibble());并使用我们在第十四章中发现的ggpairs()函数绘制它。

列表 15.1. 加载和探索跳蚤数据集
data(flea)

fleaTib <- as_tibble(flea)

fleaTib

# A tibble: 74 x 7
   species  tars1 tars2  head aede1 aede2 aede3
   <fct>    <int> <int> <int> <int> <int> <int>
 1 Concinna   191   131    53   150    15   104
 2 Concinna   185   134    50   147    13   105
 3 Concinna   200   137    52   144    14   102
 4 Concinna   173   127    50   144    16    97
 5 Concinna   171   118    49   153    13   106
 6 Concinna   160   118    47   140    15    99
 7 Concinna   188   134    54   151    14    98
 8 Concinna   186   129    51   143    14   110
 9 Concinna   174   131    52   144    14   116
10 Concinna   163   115    47   142    15    95
# ... with 64 more rows

ggpairs(flea, mapping = aes(col = species)) +
  theme_bw()

我们有一个包含 7 个变量的 tibble 数据集,这些变量是在 74 只跳蚤的不同部位测量的。species变量是一个因子,告诉我们每只跳蚤属于哪个物种,而其他变量是对跳蚤身体各个部位的连续测量。在降维过程中,我们将省略species变量,但稍后我们会使用它来查看 SOM 是否将同一物种的跳蚤聚在一起。

结果图显示在图 15.7。我们可以看到,通过使用连续变量的不同组合,可以区分三种跳蚤的物种。让我们训练一个 SOM,将这六个连续变量减少到只有两个维度的表示,并看看它如何将三种跳蚤的物种分开。

图 15.7. 使用ggpairs()函数创建的矩阵图,将跳蚤数据集中的所有变量相互绘制。由于单个图相当小,我已手动使用虚拟放大镜放大了一个图(就像你可能需要用来看跳蚤的放大镜一样)。

15.3.2. 训练 SOM

让我们训练我们的 SOM,将跳蚤放置在节点上,以便(希望)同一物种的跳蚤彼此靠近,而不同物种的跳蚤则分开。我们首先安装并加载 kohonen 包(当然是以 Teuvo Kohonen 的名字命名的)。接下来,我们需要做的是创建一个将成为我们地图的节点网格。我们使用somgrid()函数来完成此操作(如列表 15.2 所示),并且我们有几个选择:

  • 地图的维度

  • 我们的地图是由矩形节点还是六边形节点组成

  • 使用哪种邻域函数

  • 地图的边缘将如何表现

我已经使用了somgrid()函数的参数来做出这些选择,但让我们来探讨一下它们各自的意义以及它们如何影响生成的地图。

列表 15.2. 加载 kohonen 包并创建 SOM 网格
install.packages("kohonen")

library(kohonen)

somGrid <- somgrid(xdim = 5, ydim = 5, topo = "hexagonal",
                   neighbourhood.fct = "bubble", toroidal = FALSE)
选择地图的维度

首先,我们需要使用xdimydim参数分别选择 x 和 y 维度的节点数量。这一点非常重要,因为它决定了地图的大小以及它将如何划分我们的案例。我们如何选择地图的维度?实际上,这并不是一个容易回答的问题。节点太少,所有数据都会堆积在一起,以至于案例的簇会相互合并。节点太多,我们可能会得到包含单个案例或根本没有案例的节点,这会稀释任何簇并阻止解释。

SOM 的最佳维度在很大程度上取决于数据中的案例数量。我们首先希望大多数节点中都有案例,但真正最佳的 SOM 节点数量是能最好揭示数据中模式的数量。我们还可以绘制每个节点的质量,这是衡量特定节点中每个案例与该节点最终权重之间平均差异的度量。然后我们可以考虑选择一个地图大小,以给我们提供最佳质量的节点。在这个例子中,我们将从一个 5×5 的网格开始,但选择地图维度的主观性可以说是 SOM 的一个弱点。

提示

网格的 x 和 y 维度不需要长度相等。如果我发现某个网格维度在数据集中合理地揭示了模式,我可能会在某一维度上扩展地图,看看这能否进一步帮助区分案例的簇。有一种 SOM 算法的实现称为增长 SOM,该算法根据数据增长网格的大小。完成这一章后,我建议您查看 R 中的 GrowingSOM 包:github.com/alexhunziker/GrowingSOM

选择地图是否具有矩形或六边形节点

下一个选择是决定我们的网格是由矩形节点还是六边形节点组成。矩形节点与四个相邻节点相连,而六边形节点与六个相邻节点相连。因此,当一个节点的权重更新时,六边形节点将最多更新其六个直接邻居,而矩形节点将最多更新其四个直接邻居。虽然六边形节点可能产生“更平滑”的地图,其中数据簇看起来更圆滑(而矩形节点的网格中的数据簇可能看起来“块状”),但这取决于你的数据。在这个例子中,我们将通过设置topo = "hexagonal"参数来指定我们想要一个六边形拓扑。

提示

我通常更喜欢六边形节点给出的结果,无论是从它们在我数据中揭示的模式,还是从美学角度来看。

选择邻域函数

接下来,我们需要选择我们将使用哪个邻域函数,将我们的选择提供给 neighbourhood.fct 参数(注意英国拼写)。两个选项是 "bubble""gaussian",对应于我们之前讨论的两个邻域函数。我们选择的邻域函数是一个超参数,我们可以调整它;但在这个例子中,我们只是将使用气泡邻域函数,这是默认设置。

选择映射边的行为

我们需要做的最后一个选择是是否希望我们的网格是 toroidal(另一个可以用来让你的朋友印象深刻的话)。如果网格是 toroidal,则地图左边的节点连接到右边的节点(以及顶部和底部边界的等效节点)。如果你从一个 toroidal 地图的左边边缘走开,你会在右边重新出现!因为边缘上的节点与其他节点的连接较少,它们的权重更新往往少于地图中间的节点。因此,使用 toroidal 地图可能有助于防止案例在地图边缘“堆积”,尽管 toroidal 地图往往更难解释。在这个例子中,我们将 toroidal 参数设置为 FALSE,以便使最终的地图更容易解释。

使用 som() 函数训练 SOM

现在我们已经初始化了我们的网格,我们可以将我们的 tibble 传递给 som() 函数来训练我们的映射。

清单 15.3. 训练 SOM
fleaScaled <- fleaTib %>%
  select(-species) %>%
  scale()

fleaSom <- som(fleaScaled, grid = somGrid, rlen = 5000,
               alpha = c(0.05, 0.01))

我们首先通过将 tibble 管道到 select() 函数来移除 species 因子。案例被分配到具有最相似权重的节点,因此我们需要对变量进行缩放,以确保大尺度上的变量不会得到更多的重视。为此,我们将 select() 函数调用的输出管道到 scale() 函数,以对每个变量进行居中和缩放。

要构建 SOM,我们使用 kohonen 包中的 som() 函数,提供以下内容:

  • 数据作为第一个参数

  • 在 清单 15.2 中创建的网格对象作为第二个参数

  • 两个超参数参数 rlenalpha

rlen 超参数简单来说就是数据集被算法用于采样的次数(即迭代次数);默认值为 100。就像我们在其他算法中看到的那样,更多的迭代通常更好,直到我们得到递减的回报。我很快就会向你展示如何评估你是否已经包含了足够的迭代次数。

alpha 超参数是学习率,它是一个包含两个值的向量。记住,随着迭代次数的增加,每个节点权重更新的量会减少。这是由 alpha 的两个值控制的。第 1 次迭代使用 alpha 的第一个值,它以线性方式递减到第 1 次迭代的第二个 alpha 值。

向量 c(0.05, 0.01) 是默认值;但对于较大的 SOM,如果你担心 SOM 在区分具有微妙差异的类别时做得不好,你可以尝试将这些值降低以使学习率变得更慢。

备注

如果你将算法的学习率设置得更慢,通常需要增加迭代次数以帮助它收敛到稳定的结果。

15.3.3. 绘制 SOM 结果图

现在我们已经训练了我们的 SOM,让我们绘制一些关于它的诊断信息。kohonen 包附带用于绘制 SOM 的绘图函数,但它使用的是基础 R 图形而不是 ggplot2。绘制 SOM 对象的语法是 plot(x, type, shape),其中 x 是我们的 SOM 对象,type 是我们想要绘制的图表类型,而 shape 允许我们指定是否要将节点绘制为圆形或带有直线边(如果网格是矩形的则为正方形,如果网格是六边形的则为六边形)。

列表 15.4. 绘制 SOM 诊断图
par(mfrow = c(2, 3))

plotTypes <- c("codes", "changes", "counts", "quality",
               "dist.neighbours", "mapping")

walk(plotTypes, ~plot(fleaSom, type = ., shape = "straight"))
备注

我更喜欢绘制边缘直线的图表,但这个选择只是美学上的。尝试将 shape 参数设置为 "round""straight" 进行实验。

我们可以为我们的 SOM 绘制六种不同的诊断图表,但与其将 plot() 函数写六次,我们定义一个包含图表类型名称的向量,并使用 walk() 一次性绘制它们。我们首先通过运行 par(mfrow = c(2, 3)) 将绘图设备分为六个区域。

我们可以用 purrr::map() 实现相同的效果,但 purrr::walk() 调用一个函数以产生副作用(如绘制图表)并静默返回其输入(如果你想在一系列操作中绘制中间数据集,这很有用)。这里的便利之处在于 purrr:::walk() 不会向控制台打印任何输出。

警告

kohonen 包还包含一个名为 map() 的函数。如果你已经加载了 kohonen 包和 purrr 包,那么在函数调用中包含包前缀是一个好主意(kohonen::map()purrr::map())。

生成的图表显示在图 15.8 中。代码图是每个节点权重的扇形图表示。扇形的每一部分代表特定变量(如图例中所示)的权重,扇形从中心延伸的距离代表其权重的幅度。例如,我图表的左上角的节点对于 tars2 变量的权重最高。这个图表可以帮助我们识别与特定变量值较高或较低相关的地图区域。

图 15.8. 我们 SOM 的诊断图。每个节点的代码扇形图表示每个变量的权重。训练进度图显示了每个迭代中每个案例与其 BMU 之间的平均距离。案例数量图显示了每个节点的案例数量。质量图显示了每个案例与其 BMU 权重之间的平均距离。邻域距离图显示了同一节点中案例与相邻节点中案例之间差异的总和。映射图绘制了分配给每个节点的案例。

注意

你的图看起来和我的一样吗?这是因为每次运行算法时节点权重都是随机初始化的。可以说,这是 SOM 算法的一个缺点,因为它可能在重复运行时对相同的数据产生不同的结果。这个缺点通过以下事实得到了缓解——与 t-SNE 不同,我们可以将新数据映射到现有的 SOM 上。

训练进度图帮助我们评估在训练 SOM 时是否包含了足够的迭代。x 轴显示了迭代次数(由rlen参数指定),y 轴显示了每个迭代中每个案例与其 BMU 之间的平均距离。我们希望看到这个图的轮廓在我们达到最大迭代次数之前变平,在这个例子中似乎就是这样。如果我们觉得这个图还没有平缓下来,我们会增加迭代次数。

案例数量图是一个热力图,显示了分配给每个节点的案例数量。在这个图中,我们希望确保我们没有很多空节点(表明地图太大),并且案例在地图上的分布相当均匀。如果我们有很多案例堆积在边缘,我们可能会考虑增加地图维度或训练一个环状地图。

质量图显示了每个案例与其 BMU 权重之间的平均距离。这个值越低,越好。

邻域距离图显示了同一节点中案例与相邻节点中案例之间的距离总和。有时你会看到这被称为U 矩阵图,它有助于在地图上识别案例的簇。由于簇边缘的案例与相邻簇的案例距离更远,因此高距离节点往往将簇分开。这通常看起来像是地图上的暗区(潜在簇)被亮区(潜在簇)分开。解释如此小的地图是困难的,但看起来我们可能在左右边缘和可能在上中心有一个簇。

最后,映射图显示了案例在节点中的分布。请注意,案例在节点内的位置没有任何意义——它们只是避开(移动一小段随机距离),这样它们就不会全部堆叠在一起。

代码图是可视化每个节点权重的有用方式,但当变量很多时,它变得难以阅读,并且不提供可解释的量级指示。相反,我更喜欢创建热图:每个变量一个。我们使用getCodes()函数提取每个节点的权重,其中每一行是一个节点,每一列是一个变量,并将其转换为 tibble。以下列表显示了如何为每个变量创建单独的热图,这次使用iwalk()遍历每一列。

列表 15.5。为每个变量绘制热图
getCodes(fleaSom) %>%
  as_tibble() %>%
  iwalk(~plot(fleaSom, type = "property", property = .,
             main = .y, shape = "straight"))
注意

回想一下第二章,每个map()函数都有一个i等价物(imap()imap_dbl()iwalk()等),它允许我们将每个元素的名称/位置传递给函数。iwalk()函数是walk2(.x, .y = names(.x), .f)的简写,允许我们通过在函数内部使用.y来访问每个元素的名称。

我们将type参数设置为"property",这允许我们根据某些数值属性对每个节点进行着色。然后我们使用property参数告诉函数我们想要绘制哪个属性。为了将每个图的标题设置为显示的变量的名称,我们将main参数设置为.y(这就是为什么我选择使用iwalk()而不是walk())。

最终的图表显示在图 15.9 中。热图显示了每个变量非常不同的权重模式。地图右侧的节点对于tars1aede2变量具有更高的权重,而对于aede3变量(在地图右下角最低)具有较低的权重。地图左上角的节点对于tars2headaede1变量具有更高的权重。由于变量在训练 SOM 之前进行了缩放,因此热图的刻度是以每个变量的标准差单位。

由于我们对跳蚤有一些类别信息,让我们根据物种对 SOM 进行着色。

列表 15.6。将跳蚤物种绘制到 SOM 上
par(mfrow = c(1, 2))

nodeCols <- c("cyan3", "yellow", "purple")

plot(fleaSom, type = "mapping", pch = 21,
     bg = nodeCols[as.numeric(fleaTib$species)],
     shape = "straight", bgcol = "lightgrey")
图 15.9。分别显示每个原始变量节点权重的热图。刻度是以标准差单位。

图 15-9 的替代图片

首先,我们定义一个颜色向量,用于区分不同的类别。然后,我们使用plot()函数创建一个映射图,并使用type = "mapping"参数。我们将pch = 21参数设置为使用实心圆来表示每个案例(因此我们可以为每个物种设置背景颜色)。bg参数设置点的背景颜色。通过将species变量转换为数值向量并使用它来子集颜色向量,每个点都将有一个与其物种对应的背景颜色。最后,我们使用shape参数绘制六边形而不是圆形,并将背景颜色(bgcol)设置为"lightgrey"

结果图显示在图 15.10 中。你能看到 SOM 已经自行排列,使得来自同一物种的跳蚤(彼此之间比来自其他物种的跳蚤更相似)被分配到与同一物种案例相近的节点中吗?我在图 15.10 的右侧创建了一个图,该图使用聚类算法找到节点簇。我根据每个节点所属的簇给节点着色,并添加了分隔簇的粗边框。因为我们还没有介绍聚类,所以我不想解释我是如何做到这一点的(代码可在www.manning.com/books/machine-learning-with-r-the-tidyverse-and-mlr找到),但我想向你展示 SOM 成功地将不同的类别分开,并且可以在 SOM 上执行聚类!我们将在下一章开始介绍聚类。

图 15.10. 在 SOM 上显示类别成员。左侧映射图显示了绘制在其分配节点内的案例,根据它们所属的跳蚤物种进行着色。右侧图显示了相同的信息,但节点在应用聚类算法后根据簇成员进行着色。实线黑色线条分隔了分配给不同簇的节点。

注意

SOMs 与其他降维技术略有不同,因为它们并不真正创建新的变量,每个案例都赋予一个值(例如,PCA 中的主成分)。SOMs 通过将案例放置在二维地图上的节点中而不是创建新变量来降低维度。因此,如果我们想在 SOM 的结果上执行聚类分析,我们可以使用权重来聚类节点。这本质上是将每个节点视为新数据集中的一个案例。如果我们的聚类分析返回节点簇,我们可以将原始数据集中的案例分配给其节点所属的簇。

练习 1

使用 somgrid() 函数创建另一个地图,但这次设置参数如下:

  • topo = rectangular

  • toroidal = TRUE

使用此地图训练一个 SOM,并创建其映射图,如图 15.10 所示。注意每个节点现在与四个邻居节点相连。你能看到 toroidal 参数如何影响最终地图吗?如果不能,将此参数设置为 FALSE,但保持其他一切不变,看看有什么区别。

15.3.4. 将新数据映射到 SOM 上

在本节中,我将向您展示如何将新数据映射到我们的训练好的 SOM 上。让我们创建两个新案例,这些案例包含我们用于训练 SOM 的数据中的所有连续变量。

列表 15.7. 在 SOM 上绘制跳蚤物种
newData <- tibble(tars1 = c(120, 200),
                  tars2 = c(125, 120),
                  head = c(52, 48),
                  aede1 = c(140, 128),
                  aede2 = c(12, 14),
                  aede3 = c(100, 85)) %>%
           scale(center = attr(fleaScaled, "scaled:center"),
                 scale = attr(fleaScaled, "scaled:scale"))

predicted <- predict(fleaSom, newData)

par(mfrow = c(1, 1))

plot(fleaSom, type = "mapping", classif = predicted, shape = "round")

一旦我们定义了 tibble,我们就将其管道输入到scale()函数中,因为我们是在缩放数据上训练 SOM 的。但这里有一个非常重要的部分:一个常见的错误是通过对新数据减去其自身的平均值并除以其自身的标准差来缩放新数据。这可能会导致错误的映射,因为我们需要减去训练集的平均值并除以标准差。幸运的是,这些值存储为缩放数据集的属性,我们可以使用attr()函数来访问它们。

小贴士

如果您不确定attr()函数正在检索什么,请运行attributes(fleaScaled)以查看fleaScaled对象的完整属性列表。

我们使用predict()函数,将 SOM 对象作为第一个参数,将新的、缩放后的数据作为第二个参数,将新数据映射到我们的 SOM 上。然后,我们可以使用plot()函数,提供type = "mapping"参数,在地图上绘制新数据的位置。classif参数允许我们指定由predict()函数返回的对象,以仅绘制新数据。这次,我们使用shape = "round"参数来显示圆形节点的外观。

结果图显示在图 15.11。每个案例都被放置在一个单独的节点中,其权重最能代表案例的变量值。回顾图 15.9 和 15.10,看看您可以根据它们在地图上的位置对这些两个案例做出什么推断。

图 15.11.新数据可以映射到现有的 SOM 上。此映射图显示了分配给两个新案例的节点图形表示。

使用 SOMs 进行监督学习

我们专注于 SOMs,因为它们作为无监督学习器用于降维。这可能是 SOMs 最常见的使用方式,但它们也可以用于回归和分类,这使得 SOMs 在机器学习算法中非常独特。

在监督学习环境中,SOMs 实际上创建了两个映射:让我们称它们为 x 映射和 y 映射。x 映射与您迄今为止所学的相同;其节点的权重会迭代更新,以便将相似案例放置在附近的节点中,将不相似案例放置在较远的节点中,仅使用数据集中的预测变量。一旦案例被放置在 x 映射上的相应节点中,它们就不会移动。y 映射的节点权重代表结果变量的值。现在,算法再次随机选择案例,并迭代更新每个 y 映射节点的权重,以更好地匹配该节点中案例的结果变量值。这些权重可以代表连续的结果变量(在回归的情况下)或一组类别概率(在分类的情况下)。

我们可以使用 kohonen 包中的xyf()函数来训练一个监督 SOM。使用?xyf()了解更多信息。

15.4.什么是局部线性嵌入?

在本节中,我将解释 LLE 是什么,它是如何工作的,为什么它有用,以及它与 SOMs 的不同之处。就像 UMAP 一样,LLE 算法试图识别数据所在的基础流形。但 LLE 以略不同的方式做到这一点:它不是试图一次性学习流形,而是在每个案例周围学习局部、线性的数据片段,然后将这些线性片段组合起来形成(可能是非线性的)流形。

注意

LLE 算法经常引用的格言是“全局思考,局部拟合”:算法查看每个案例周围的局部小片,并使用这些小片来构建更广泛的流形。

LLE 算法特别擅长“展开”或“展开”卷曲或扭曲成不寻常形状的数据。例如,想象一个三维数据集,其中案例被卷成瑞士卷。LLE 算法能够展开数据,并将其表示为数据点的二维矩形。

图 15.12. 计算每个案例与其他每个案例之间的距离,并分配它们的 k 个最近邻(左上角图中沿 z 轴的距离通过圆圈的大小表示)。对于每个案例,算法学习一组权重,每个最近邻一个,这些权重之和为 1。每个邻居的变量值乘以其权重(因此第 1 行变为 x = 3.1 × 0.1,y = 2.0 × 0.1,z = 0.1 × 0.1)。每个邻居的加权值相加(列相加)以近似所选案例的原始值。

图 15.12 的替代图片

那么,LLE 算法是如何工作的呢?看看图 15.12。它首先从数据集中选择一个案例并计算其 k 个最近邻(这就像第三章中的 kNN 算法一样,所以k是 LLE 算法的超参数)。LLE 随后将此案例表示为其k个邻居的线性、加权总和。我都能听到你在问:那是什么意思?好吧,每个k个邻居都被分配了一个权重:一个介于 0 和 1 之间的值,使得所有 k 个最近邻的权重之和为 1。特定邻居的变量值乘以其权重(因此加权值是原始值的一部分)。

注意

因为 LLE 算法依赖于测量案例之间的距离来计算最近邻,所以它对变量尺度之间的差异很敏感。在嵌入之前对数据进行缩放通常是一个好主意。

当每个变量的加权值在 k 个最近邻中累加时,这个新的加权总和应该近似于我们最初计算 k 个最近邻的案例的变量值。因此,LLE 算法为每个最近邻学习一个权重,使得当我们乘以每个邻居的权重并将这些值相加时,我们得到原始案例(或近似值)。这就是我说 LLE 将每个案例表示为其邻居的线性加权总和时的意思。

这个过程对数据集中的每个案例重复进行:计算其 k 个最近邻,然后学习可以用来重建它的权重。因为权重是线性组合的(相加),所以该算法本质上是在每个案例周围学习一个线性“补丁”。但是它是如何组合这些补丁来学习流形的呢?嗯,数据被放置在一个低维空间中,通常是两到三个维度,这样在这个新空间中的坐标保留了之前步骤中学习的权重。换句话说,数据被放置在这个新特征空间中,这样每个案例仍然可以通过其邻居的加权总和来计算。

15.5. 构建您的第一个 LLE

在本节中,我将向您展示如何使用 LLE 算法将数据集的维度降低到二维地图。我们将从一个非常规的例子开始,这个例子真正展示了 LLE 作为非线性降维算法的强大功能。这个例子之所以不寻常,是因为它表示的是三维形状为S的数据,这与我们在现实世界中可能遇到的情况不同。然后我们将使用 LLE 创建我们跳蚤马戏团数据的二维嵌入,以查看它与我们之前创建的 SOM 相比如何。

15.5.1. 加载和探索 S 曲线数据集

首先,让我们安装并加载 lle 包:

install.packages("lle")

library(lle)

接下来,让我们从 lle 包中加载 lle_scurve_data 数据集,为其变量命名,并将其转换为 tibble。我们有一个包含 800 个案例和 3 个变量的 tibble。

列表 15.8. 加载 S 曲线数据集
data(lle_scurve_data)

colnames(lle_scurve_data) <- c("x", "y", "z")

sTib <- as_tibble(lle_scurve_data)

sTib

# A tibble: 800 x 3
        x     y      z
    <dbl> <dbl>  <dbl>
 1  0.955 4.95  -0.174
 2 -0.660 3.27  -0.773
 3 -0.983 1.26  -0.296
 4  0.954 1.68  -0.180
 5  0.958 0.186 -0.161
 6  0.852 0.558 -0.471
 7  0.168 1.62  -0.978
 8  0.948 2.32   0.215
 9 -0.931 1.51  -0.430
10  0.355 4.06   0.926
# ... with 790 more rows

这个数据集包含折叠成三维中字母S形状的案例。让我们创建一个三维图来可视化这一点,使用 plot3D 和 plot3Drgl 包(从它们的安装开始)。

列表 15.9. 在三维中绘制 S 曲线数据集
install.packages(c("plot3D", "plot3Drgl"))

library(plot3D)

scatter3D(x = sTib$x, y = sTib$y, z = sTib$z, pch = 19,
          bty = "b2", colkey = FALSE, theta = 35, phi = 10,
          col = ramp.col(c("darkred", "lightblue")))

plot3Drgl::plotrgl()

scatter3D()函数允许我们创建一个三维图,而plotrgl()函数允许我们交互式地旋转它。以下是scatter3D()函数参数的摘要:

  • xyz—要在各自的轴上绘制哪些变量。

  • pch—我们希望绘制的点的形状(19绘制实心圆圈)。

  • bty—绘制在数据周围的框类型("b2"绘制带有网格线的白色框;使用?scatter3D查看其他选项)。

  • colkey—我们是否想要为每个点的着色添加图例。

  • thetaphi—该图的视角角度。

  • col—我们想要使用的调色板,以指示 z 变量的值。在这里,我们使用 ramp.col() 函数来指定颜色渐变的起始和结束颜色。

一旦我们创建了我们的静态图表,我们可以通过简单地调用 plotrgl() 函数(不带任何参数)将其转换为交互式图表,我们可以通过点击和旋转鼠标来旋转它。

小贴士

你可以使用鼠标滚轮来放大和缩小这个交互式图表。

生成的图表显示在 图 15.13 中。你能看到数据形成了一个三维的 S 形吗?这确实是一个不寻常的数据集,但我希望它能展示 LLE 在学习数据集下隐含的流形时的强大能力。

图 15.13. 使用 scatter3D() 函数在三维中绘制的 S 曲线数据集。点的阴影映射到 z 变量。

图片

15.5.2. 训练 LLE

除了我们想要将数据集减少到的维度数量(通常是两到三个),k 是我们唯一需要选择的超参数。我们可以通过使用 calc_k() 函数来选择 k 的最佳性能值。这个函数将 LLE 算法应用于我们的数据,使用我们在指定范围内指定的不同 k 值。对于使用不同 k 的每个嵌入,calc_k() 计算原始数据中以及在低维表示中的案例之间的距离。这些距离之间的相关系数被计算出来(ρ,或“rho”),并用于计算一个指标(1 – ρ²),该指标可用于选择 k。这个指标值最小的 k 是在高低维表示之间最好地保留了案例之间距离的值。

这里是 calc_k() 函数参数的摘要:

  • 第一个参数是数据集。

  • m 参数是我们想要将数据集减少到的维数。

  • kminkmax 参数指定函数将使用的 k 值范围的最低值和最高值。

  • cpus 参数让我们指定我们想要用于并行化的核心数(我使用了 parallel::detectCores() 来使用所有核心)。

注意

由于我们为每个 k 值计算一个嵌入,如果我们的值范围很大,并且/或者我们的数据集中包含许多案例,我建议通过将 parallel 参数设置为 TRUE 来并行化此函数。

当这个函数完成后,它将绘制一个图表,显示每个 k 值的 1 – ρ² 指标(见 图 15.14)。

图 15.14. 将 1 – ρ² 对 k 进行绘图以找到 k 的最佳值。实线水平线表示 1 – ρ² 最低的 k 值。

图片

calc_k() 函数还返回一个包含每个 k 值的 1 – ρ² 度量的 data.frame。我们使用 filter() 函数选择包含 rho 列最低值的行。我们将使用与这个最小值相对应的 k 值来训练我们的最终 LLE。在这个例子中,k 的最佳值是 17 个邻居。

注意

这有点令人困惑,因为我们实际上想要最高的 rho (ρ) 值,这给我们最小的 1 – ρ² 值。尽管这个列被称为 rho,但它包含 1 – ρ² 的值,因此我们想要这些值中最小的一个。

最后,我们使用 lle() 函数运行 LLE 算法,提供以下内容:

  • 数据作为第一个参数

  • 我们想要嵌入的维数的数量作为 m 参数

  • k 超参数的值

列表 15.10. 计算 k 并执行 LLE
lleK <- calc_k(lle_scurve_data, m = 2, kmin = 1, kmax = 20,
               parallel = TRUE, cpus = parallel::detectCores())

lleBestK <- filter(lleK, rho == min(lleK$rho))

lleBestK

   k    rho
1 17 0.1469

lleCurve <- lle(lle_scurve_data, m = 2, k = lleBestK$k)

15.5.3. 绘制 LLE 结果

现在我们已经完成了嵌入,让我们提取两个新的 LLE 轴并将数据绘制到它们上面。这将使我们能够在这个新的二维空间中可视化我们的数据,以查看算法是否揭示了分组结构。

列表 15.11. 绘制 LLE
sTib <- sTib %>%
  mutate(LLE1 = lleCurve$Y[, 1],
         LLE2 = lleCurve$Y[, 2])

ggplot(sTib, aes(LLE1, LLE2, col = z)) +
  geom_point() +
  scale_color_gradient(low = "darkred", high = "lightblue") +
  theme_bw()

我们首先在我们的原始 tibble 上突变两个新列,每个列包含一个新 LLE 轴的值。然后我们使用 ggplot() 函数将两个 LLE 轴相互绘制,将 z 变量映射到颜色美学。我们添加一个 geom_point() 层和一个 scale_color_gradient() 层,该层指定将映射到 z 变量的颜色尺度的极端颜色。这将使我们能够直接比较每个案例在我们新的二维表示中的位置与其在 图 15.13 中的三维图中的位置。

结果图显示在 图 15.15 中。你能看到 LLE 将 S 形状扁平化为一个平坦的二维点矩形吗?如果不能,请回顾 图 15.13 并尝试将两个图联系起来。这几乎就像数据被画在折叠的纸张上,而 LLE 将其拉直了!这是降维的流形学习算法的力量。

图 15.15. 绘制 S 曲线数据的二维嵌入图。点的阴影映射到 z 变量,与 图 15.11 中的相同。

15.6. 构建我们跳蚤数据的 LLE

有时对 LLE 的批评是,它被设计来处理“玩具数据”——换句话说,是构建成有趣和不同形状的数据,但在现实世界的数据集中很少(如果有的话)体现出来。我们在上一节中工作的 S 曲线数据是这种玩具数据的例子,它是为了测试学习数据所在流形的算法而生成的。因此,在本节中,我们将看到 LLE 在我们的跳蚤马戏团数据集上的表现如何,以及它是否能够像我们的 SOM 一样识别跳蚤簇。

我们将遵循与 S 曲线数据集相同的程序:

  1. 使用calc_k()函数计算最佳性能的k值。

  2. 在二维空间中进行嵌入。

  3. 将两个新的 LLE 轴相互绘制出来。

这次,让我们将物种变量映射到颜色美学上,看看我们的 LLE 嵌入如何将簇分离得有多好。

列表 15.12。在跳蚤数据集上执行和绘制 LLE
lleFleaK <- calc_k(fleaScaled, m = 2, kmin = 1, kmax = 20,
                   parallel = TRUE, cpus = parallel::detectCores())

lleBestFleaK <- filter(lleFleaK, rho == min(lleFleaK$rho))

lleBestFleaK

   k    rho
1 12 0.2482

lleFlea <- lle(fleaScaled, m = 2, k = lleBestFleaK$k)
fleaTib <- fleaTib %>%
  mutate(LLE1 = lleFlea$Y[, 1],
         LLE2 = lleFlea$Y[, 2])

ggplot(fleaTib, aes(LLE1, LLE2, col = species)) +
  geom_point() +
  theme_bw()

结果图显示在图 15.16 中(我将图表合并到一个图中以节省空间)。LLE 似乎在分离跳蚤的不同物种方面做得相当不错,尽管结果并不像 LLE 能够解开 S 曲线数据集那样令人印象深刻。

图 15.16。绘制列表 15.12 的输出。顶部图显示了不同k值的 1 – ρ²。底部图显示了跳蚤数据的二维嵌入,按物种着色。

图 15-16

注意

很遗憾,因为每个案例都被重建为其邻居的加权求和,新数据不能投影到 LLE 映射上。因此,LLE 不能轻易用作其他机器学习算法的预处理步骤,因为新数据不能通过它。

练习 2

将每个跳蚤物种的 95%置信椭圆添加到图 15.16 中显示的底部图中。

15.7。SOMs 和 LLE 的优势与劣势

虽然通常不容易判断哪些算法会对特定任务表现良好,但以下是一些优势和劣势,这将帮助您决定 SOM 或 LLE 是否适合您。

SOMs 和 LLE 的优势如下:

  • 它们都是非线性降维算法,因此可以揭示数据中的模式,而线性算法(如 PCA)可能无法揭示这些模式。

  • 新数据可以映射到现有的 SOM 上。

  • 它们的训练成本相对较低。

  • 在相同的k值下,重新运行 LLE 算法在相同的数据集上会产生相同的嵌入。

SOMs 和 LLE 的劣势如下:

  • 它们无法原生地处理分类变量。

  • 低维表示在原始变量的意义上不可直接解释。

  • 它们对不同尺度的数据敏感。

  • 新数据不能映射到现有的 LLE 上。

  • 它们不一定保留数据的全局结构。

  • 在相同的数据集上重新运行 SOM 算法每次都会产生不同的映射。

  • 小型 SOM 可能难以解释,因此该算法在大型数据集(超过数百个案例)上表现最佳。

练习 3

使用我们创建的原始 somGrid,创建另一个 SOM,但将迭代次数增加到 10,000,并将 alpha 参数设置为 c(0.1, 0.001) 以减慢学习速率。创建与练习 1 中相同的映射图。多次重新训练并绘制 SOM。映射是否比之前更稳定?你能想到为什么吗?

| |

练习 4

重复我们的 LLE 嵌入,但将嵌入在三维而不是二维空间中。使用 scatter3() 函数绘制这个新的嵌入,并按物种着色点。

| |

练习 5

重复我们的 LLE 嵌入(在二维中),但这次使用未缩放的变量。将两个 LLE 轴相互绘制,并将 species 变量映射到颜色美学。将此嵌入与使用缩放变量的结果进行比较。

概述

  • SOMs 创建一个网格/地图,将数据集中的案例分配到节点上。

  • SOMs 通过更新每个节点的权重来学习数据中的模式,直到地图收敛到一组权重,该权重保留案例之间的相似性。

  • 新数据可以映射到现有的 SOM 上,并且可以根据它们的权重对 SOM 节点进行聚类。

  • LLE 将每个案例重建为其邻居的线性加权总和。

  • LLE 然后将数据嵌入到一个低维特征空间中,该空间保留了权重。

  • LLE 极佳于学习一组数据下复杂的流形,但新数据不能映射到现有的嵌入中。

练习解答

  1. 训练一个矩形、环形的 SOM:

    somGridRect <- somgrid(xdim = 5, ydim = 5, topo = "rectangular",
                       toroidal = TRUE)
    
    fleaSomRect <- som(fleaScaled, grid = somGridRect, rlen = 5000,
                       alpha = c(0.05, 0.01))
    
    plot(fleaSomRect, type = "mapping", pch = 21,
         bg = nodeCols[as.numeric(fleaTib$species)],
         shape = "straight", bgcol = "lightgrey")
    
    # Making the map toroidal means that nodes on one edge are connected to
    # adjacent nodes on the opposite side of the map.
    
  2. 将每个跳蚤物种的 95% 置信椭圆添加到 LLE1 与 LLE2 的图中:

    ggplot(fleaTib, aes(LLE1, LLE2, col = species)) +
      geom_point() +
      stat_ellipse() +
      theme_bw()
    
  3. 使用更多迭代但较慢的学习速率训练 SOM:

    fleaSomAlpha <- som(fleaScaled, grid = somGrid, rlen = 10000,
                       alpha = c(0.01, 0.001))
    
    plot(fleaSomAlpha, type = "mapping", pch = 21,
         bg = nodeCols[as.numeric(fleaTib$species)],
         shape = "straight", bgcol = "lightgrey")
    
    # While the positions of the groups change between repeats, there is less
    # variation in how well cases from the same species cluster together.
    # This is because the learning rate is slower and there are more iterations.
    
  4. 在三维空间中训练 LLE:

    lleFlea3 <- lle(fleaScaled, m = 3, k = lleBestFleaK$k)
    
    fleaTib <- fleaTib %>%
      mutate(LLE1 = lleFlea3$Y[, 1],
             LLE2 = lleFlea3$Y[, 2],
             LLE3 = lleFlea3$Y[, 3])
    
    scatter3D(x = fleaTib$LLE1, y = fleaTib$LLE2, z = fleaTib$LLE3, pch = 19,
              bty = "b2", colkey = FALSE, theta = 35, phi = 10, cex = 2,
              col = c("red", "blue", "green")[as.integer(fleaTib$species)],
              ticktype = "detailed")
    
    plot3Drgl::plotrgl()
    
  5. 在未缩放的跳蚤数据上训练 LLE:

    lleFleaUnscaled <- lle(dplyr::select(fleaTib, -species),
                           m = 2, k = lleBestFleaK$k)
    
    fleaTib <- fleaTib %>%
      mutate(LLE1 = lleFleaUnscaled$Y[, 1],
             LLE2 = lleFleaUnscaled$Y[, 2])
    
    ggplot(fleaTib, aes(LLE1, LLE2, col = species)) +
      geom_point() +
      theme_bw()
    
    # As we can see, the embedding is different depending on
    # whether the variables are scaled or not.
    

第五部分. 聚类

我们在无监督学习中的下一个停留点是聚类。聚类涵盖了用于在数据集中识别案例簇的一系列技术。是一组案例,它们彼此之间比与其他簇中的案例更相似。

从概念上讲,聚类可以看作与分类相似,因为我们试图为每个案例分配一个离散值。区别在于,虽然分类使用标记的案例来学习数据中的模式,以区分类别,但我们使用聚类是因为我们没有关于类别成员资格或数据中是否存在不同类别的先验知识。因此,聚类描述了一组试图在数据集中识别分组结构的算法。

在第十六章到第十九章中,我将为你提供不同的聚类技术,这些技术可以处理各种聚类问题。验证聚类算法的性能可能是一个挑战,并且可能并不总是有一个明显或甚至“正确”的答案,但我将教你技能,帮助你最大化从这些方法中获得的信息。

第十六章. 使用 k-means 寻找中心进行聚类

本章涵盖

  • 理解聚类需求

  • 理解聚类过拟合和欠拟合

  • 验证聚类算法的性能

我们在聚类中的第一个停留点带我们到一个非常常用的技术:k-means 聚类。我在这里使用的是技术这个词,而不是算法,因为 k-means 描述了聚类的一个特定方法,多个算法都遵循这种方法。我将在本章的后面讨论这些个别算法。

注意

不要混淆 k-means 和 k-最近邻!k-means 用于无监督学习,而 k-最近邻是一个用于分类的监督算法。

K-means 聚类试图在数据集中学习分组结构。k-means 方法首先由我们定义我们认为数据集中有多少个簇。这就是k的含义;如果我们把k设为 3,我们将识别出三个簇(无论这些是否代表真实的分组结构)。这可以说是 k-means 的一个弱点,因为我们可能没有关于要搜索多少个簇的先验知识,但我将向你展示如何选择一个合理的k值。

一旦我们定义了要搜索的集群数量k,k-means 将在数据集中初始化(通常随机)k个中心或质心。每个质心可能不是数据中的实际案例,但每个变量都有一个随机值。这些质心中的每一个代表一个集群,案例被分配到与它们最近的质心的集群。迭代地,质心在特征空间中移动,试图最小化每个集群内的数据方差,但最大化不同集群之间的分离。在每次迭代中,案例被分配到与它们最近的质心的集群。

到本章结束时,我希望你能理解聚类的一般方法以及聚类任务中的过拟合和欠拟合是什么样的。我将向你展示如何将 k-means 聚类应用于数据集以及评估聚类性能的方法。

16.1. 什么是 k-means 聚类?

在本节中,我将向您展示 k-means 聚类的通用过程,然后解释实现它的各种算法以及它们的区别。K-means 算法将数据集中的案例划分为k个集群,其中k是我们定义的一个整数。k-means 算法返回的集群倾向于是n-维度的球形(其中n是特征空间的维度数)。这意味着集群在二维空间中倾向于形成一个圆圈,在三维空间中形成一个球体,在超过三维的空间中形成一个超球体。K-means 集群也倾向于具有相似的大径。这些可能是数据基础结构中不真实的特征。

有许多 k-means 算法,但一些常用的算法如下:

  • Lloyd 算法(也称为 Lloyd-Forgy 算法)

  • MacQueen 算法

  • Hartigan-Wong 算法

Lloyd、MacQueen 和 Hartigan-Wong 算法在概念上非常相似,但它们之间的一些差异会影响它们的计算成本以及在特定问题上的性能。让我们逐一介绍每个算法,解释它们是如何工作的。

16.1.1. Lloyd 算法

在本节中,我将向您展示这三个算法中最容易理解的一个:Lloyd 算法。想象一下,你是一名体育科学家,对跑者的生物物理差异感兴趣。你测量了一组跑者的静息心率和最大摄氧量,并希望使用 k-means 算法来识别可能从不同训练方案中受益的跑者集群。

假设你事先有理由相信数据集中可能存在三个不同的运动员集群。Lloyd 算法的第一步是在数据中随机初始化k(在这个例子中是三个)质心(见图 16.1)。接下来,计算每个案例与每个质心之间的距离。这个距离通常是欧几里得距离(直线距离),但也可以是其他距离度量,例如曼哈顿距离(出租车距离)。

图 16.1. k-means 聚类的五次迭代。在左上角的图中,在特征空间中随机生成了三个初始中心(交叉点)。案例被分配给其最近中心的簇。在每次迭代中,每个中心移动到其簇中案例的平均值处(由箭头指示)。特征空间可以被划分为 Voronoi 单元(我将在稍后讨论这些),由阴影区域表示,显示特征空间中距离特定质心最近的区域。

注意

因为 k-means 依赖于距离度量,如果变量在不同的尺度上测量,那么缩放变量是很重要的;否则,较大尺度的变量将不成比例地影响结果。

每个案例都被分配给由其最近的质心表示的簇。这样,每个质心都作为其簇的原型案例。接下来,将质心移动,使它们位于上一步分配给其簇的案例的平均值处(这就是为什么这种方法被称为k-means)。

现在过程会重复进行:计算每个案例与每个质心的距离,并将案例分配给最近的质心所在的簇。你能看到,因为质心会更新并在特征空间中移动,所以一个特定案例最近的质心可能会随时间改变吗?这个过程会一直持续到没有案例从一个迭代到下一个迭代改变簇,或者达到最大迭代次数。注意,在图 16.1 的迭代 4 和 5 之间,没有案例改变簇,所以算法停止。

注意

因为初始中心通常随机选择,所以我们重复执行此过程几次,每次都使用新的随机初始中心是很重要的。然后我们可以使用开始时具有最低簇内平方误差和的中心。

让我们总结一下 Lloyd 算法的步骤:

  1. 选择k

  2. 在特征空间中随机初始化k个中心。

  3. 对于每个案例:

    1. 计算案例与每个中心之间的距离。

    2. 将案例分配给最近的质心所在的簇。

  4. 将每个中心放置在其簇分配的案例的平均值处。

  5. 重复步骤 3 和 4,直到没有案例改变簇或达到最大迭代次数。

在 图 16.1 中,你能看到在每次迭代中,质心的位置是如何更新的(箭头),以便它们向真实聚类的中心移动?在每次迭代中,我们可以将特征空间划分为围绕每个质心的多边形(或多面体,在两个以上维度中)区域,这些区域显示了“属于”特定聚类的区域。这些区域被称为 Voronoi 单元;如果一个案例落在其中,这意味着该案例最接近该单元的质心,并将被分配到其聚类。在图上可视化 Voronoi 单元(有时称为 Voronoi 地图)是可视化聚类算法如何划分特征空间的有用方法。

16.1.2. MacQueen 算法

MacQueen 算法与 Lloyd 算法极为相似,只是在质心更新时间上略有不同。Lloyd 算法被称为 批量离线 算法,意味着它在迭代结束时一起更新质心。另一方面,MacQueen 算法每次案例改变聚类并且算法已经遍历了数据中的所有案例时,都会更新质心。

备注

与 Lloyd 算法被称为批量或离线算法相比,MacQueen 算法被称为 增量在线 算法,因为它每次案例移动聚类时都会更新质心,而不是在遍历所有数据后更新。

就像 Lloyd 算法一样,MacQueen 算法初始化 k 个中心,将每个案例分配到最近的质心的聚类,并将质心的位置更新为与其最近的案例的平均值。然后算法逐个考虑每个案例,并计算其到每个质心的距离。如果案例改变了聚类(因为它现在更接近不同的质心),则更新新旧质心的位置。算法继续通过数据集,逐个考虑每个案例。一旦所有案例都已考虑,再次更新质心的位置。如果没有案例改变聚类,则算法停止;否则,它将进行另一轮遍历。

与 Lloyd 算法相比,MacQueen 算法的优点是它往往更快地收敛到最优解。然而,对于非常大的数据集,它可能稍微计算成本更高。

让我们总结一下 MacQueen 算法的步骤:

  1. 选择 k

  2. 在特征空间中随机初始化 k 个中心。

  3. 将每个案例分配到其最近中心的聚类。

  4. 将每个中心放置在其分配给其聚类的案例的平均值处。

  5. 对于每个案例:

    1. 计算案例与每个质心之间的距离。

    2. 将案例分配到最近的质心的聚类。

    3. 如果案例改变了聚类,则更新新旧质心的位置。

  6. 一旦所有案例都已考虑,更新所有质心。

  7. 如果没有案例改变聚类,则停止;否则,重复步骤 5。

16.1.3. Hartigan-Wong 算法

第三个 k-means 算法与 Lloyd 和 MacQueen 算法略有不同。Hartigan-Wong 算法首先初始化 k 个随机中心,并将每个案例分配给其最近中心的聚类,就像我们在其他两个算法中看到的那样。这里的不同之处在于:对于数据集中的每个案例,算法计算如果该案例被移除,则其当前聚类的平方误差之和,以及如果该案例包含在那些聚类中,则每个其他聚类的平方误差之和。回想一下,从前几章中我们知道,平方误差之和(或简称为平方和)是每个案例的值与其预测值(在此上下文中,其质心)之间的差异,平方后对所有案例求和。如果您更喜欢数学符号,请查看方程 16.1。

方程 16.1。

eq16-1

其中 ik 是属于聚类 k 的第 i 个案例,而 c[k] 是聚类 k 的质心。

当包括当前正在考虑的案例时,具有最小平方误差之和的聚类被分配为该案例的聚类。如果一个案例改变了聚类,那么旧聚类和新聚类的质心将被更新为它们聚类中案例的平均值。算法继续进行,直到没有案例改变聚类。因此,一个案例可能被分配给特定的聚类(因为它减少了平方误差之和),即使它更接近另一个聚类的质心。

让我们总结一下 Hartigan-Wong 算法的步骤:

  1. 选择 k

  2. 在特征空间中随机初始化 k 个中心。

  3. 将每个案例分配给其最近中心的聚类。

  4. 将每个中心放置在其分配给其聚类的案例的平均值处。

  5. 对于每个案例:

    1. 计算其聚类的平方误差之和,忽略正在考虑的案例。

    2. 计算其他聚类的平方误差之和,就像那个案例被包含在内一样。

    3. 将案例分配给具有最小平方误差之和的聚类。

    4. 如果案例改变了聚类,则更新新聚类和旧聚类的位置。

  6. 如果没有案例改变聚类,则停止;否则,重复步骤 5。

Hartigan-Wong 算法 倾向于比 Lloyd 或 MacQueen 算法找到更好的聚类结构,尽管我们总是受到“没有免费午餐”定理的限制。Hartigan-Wong 算法也比其他两个算法更耗费计算资源,因此对于大型数据集来说会慢得多。

我们应该选择哪种算法呢?嗯,选择是一个离散的超参数,因此我们可以使用超参数调整来帮助我们选择表现最好的方法,并确保我们不会做出错误的抉择!

16.2. 构建您的第一个 k-means 模型

在本节中,我将向您展示如何使用 R 中的 mlr 包构建 k-means 模型。我将介绍创建聚类任务和学习者,以及我们可以用来评估聚类算法性能的一些方法。

想象一下,你正在寻找来自移植物抗宿主病(GvHD)患者的白细胞簇。GvHD 是一种令人不快的疾病,其中移植组织中残留的白细胞攻击接受移植的患者。你从每位患者那里取活检,并测量每个细胞表面的不同蛋白质。你希望创建一个聚类模型,帮助你从活检中识别不同的细胞类型,以帮助你更好地理解这种疾病。让我们先加载 mlr 和 tidyverse 包:

library(mlr)

library(tidyverse)

16.2.1. 加载和探索 GvHD 数据集

现在让我们加载数据,这些数据内置在 mclust 包中,将其转换为 tibble(使用as_tibble()),并对其进行一些探索。我们有一个包含 6,809 个案例和 4 个变量的 tibble,每个变量都是测量在每个细胞表面的不同蛋白质。

列表 16.1. 加载和探索 GvHD 数据集
data(GvHD, package = "mclust")

gvhdTib <- as_tibble(GvHD.control)
gvhdTib

# A tibble: 6,809 x 4
     CD4  CD8b   CD3   CD8
   <dbl> <dbl> <dbl> <dbl>
 1   199   420   132   226
 2   294   311   241   164
 3    85    79    14   218
 4    19     1   141   130
 5    35    29     6   135
 6   376   346   138   176
 7    97   329   527   406
 8   200   342   145   189
 9   422   433   163    47
10   391   390   147   190
# ... with 6,799 more rows
备注

调用data(GvHD, package = "mclust")实际上加载了两个数据集:GvHD.control 和 GvHD.pos。我们将使用 GvHD.control 数据集,但在本节的最后,我还会指导你使用 GvHD.pos 数据集构建一个聚类模型。

由于 k-means 算法使用距离度量来将案例分配到簇中,因此我们的变量必须是缩放的,以便不同尺度的变量得到相同的权重。我们的所有变量都是连续的,因此我们可以简单地通过将整个 tibble 管道到scale()函数中来进行缩放。记住,这将通过减去平均值并除以标准差来对每个变量进行中心化和缩放。

列表 16.2. 缩放 GvHD 数据集
gvhdScaled <- gvhdTib %>% scale()

接下来,让我们使用 GGally 包中的好朋友ggpairs()来绘制数据。这次,我们修改了ggpairs()绘制分面图的方式。我们使用upperlowerdiag参数来分别指定应该在上部、下部和对角线绘制哪种类型的图表。每个参数都接受一个列表,其中每个列表元素可以用来指定连续变量、离散变量以及两者的组合的不同类型图表。在这里,我选择在上部图表上绘制二维密度图,在下部图表上绘制散点图,在对角线上绘制密度图。

为了防止拥挤,我们希望减小下部图表上点的尺寸。要更改图表的任何图形选项(如 geoms 的大小和颜色),我们只需将图表类型的名称(字面上)包裹在wrap()函数中,以及我们正在更改的选项。在这里,我们使用wrap("points", size = 0.5)在底部面板上绘制散点图,点的尺寸比默认值小。

备注

记住,geom代表几何对象,指的是图表上的线条、点和条形等图形元素。

列表 16.3. 使用ggpairs()创建成对图
library(GGally)

ggpairs(GvHD.control,
        upper = list(continuous = "density"),
        lower = list(continuous = wrap("points", size = 0.5)),
        diag = list(continuous = "densityDiag")) +
  theme_bw()
备注

对于连续变量,默认的对角线图是密度图。我仍然明确地将其定义为这样的,这样您就可以看到如何独立控制上、下和对角线图。

结果图显示在图 16.2。您能在数据中看到不同的案例簇吗?人脑在识别二维甚至三维中的簇方面相当出色,看起来数据集中至少有四个簇。密度图有助于我们查看案例的密集区域,这些区域在散点图中简单地显示为黑色。

图 16.2. GvHD 数据集中每个变量与每个其他变量的ggpairs()图。散点图显示在对角线以下,二维密度图显示在对角线以上,一维密度图绘制在对角线上。看起来数据中存在多个簇。

fig16-2_alt.jpg

在本节中,我将向您展示如何定义聚类任务和聚类学习器。在 mlr 中,我们通过使用makeClusterTask()函数来创建聚类任务(这里没有惊喜)。我们将我们的缩放数据(转换为数据框)作为data参数提供。

|

重要

注意,与创建监督学习任务(用于分类或回归)不同,我们不再需要提供target参数。这是因为在不监督学习任务中,没有标签变量可以用作目标。

16.2.2. 定义我们的任务和学习器

现在让我们定义我们的 k-means 学习器。我们使用熟悉的makeLearner()函数来完成这个任务,这次我们将"cluster.kmeans"作为学习器的名称。我们使用par.vals参数向学习器提供两个参数:iter.maxnstart

注意

就像分类和回归学习者的前缀分别是classif.regr.一样,聚类学习者的前缀是cluster

让我们使用您在第三章中了解到的listLearners()函数,看看 mlr 包目前实现了哪些算法。在撰写本文时,我们目前只有九种聚类算法可用。诚然,这比分类和回归中可用的算法数量要少得多,但 mlr 仍然为聚类提供了一些有用的工具。如果您想使用 mlr 目前没有包装的算法,您始终可以自己实现它(访问 mlr 网站了解如何:mng.bz/E1Pj)。
gvhdTask <- makeClusterTask(data = as.data.frame(gvhdScaled))

listLearners("cluster")$class

[1] "cluster.cmeans"        "cluster.Cobweb"        "cluster.dbscan"
[4] "cluster.EM"            "cluster.FarthestFirst" "cluster.kkmeans"
[7] "cluster.kmeans"        "cluster.SimpleKMeans"  "cluster.XMeans"

kMeans <- makeLearner("cluster.kmeans",
                      par.vals = list(iter.max = 100, nstart = 10))

iter.max参数为算法遍历数据的次数设置一个上限(默认为 10)。k-means 算法会在案例停止移动簇时停止,但为大数据集设置最大值可能很有用,因为这些数据集收敛需要很长时间。在本节稍后,我将向您展示如何判断聚类模型是否在达到此限制之前已经收敛。

nstart 参数控制函数将随机初始化中心的次数。回想一下,初始中心通常在特征空间中随机初始化:这可能会影响最终的质心位置,因此也会影响最终的簇成员资格。将 nstart 参数设置高于默认值 1 将随机初始化这么多中心。对于每一组初始中心,案例被分配到每个组中最近中心所在的簇,然后使用具有最小簇内平方误差和的组作为聚类算法的其余部分。这样,算法会选择与数据中的真实簇质心最相似的中心集。增加 nstart 可能比增加迭代次数更重要。

小贴士

如果你的数据集具有非常明显可分离的簇,将 nstart 设置高于 1 可能是计算资源的浪费。然而,除非你的数据集非常大,否则通常将 nstart 设置为大于 1 是一个好主意;在 列表 16.4 中,我将它设置为 10。

16.2.3. 选择簇的数量

在本节中,我将向你展示我们如何合理地选择 k 的值,它定义了我们的模型将识别的中心数量和簇数量。选择 k 的必要性常被引用为 k-means 聚类的弱点。这是因为选择 k 可能是主观的。如果你有先验领域知识,知道数据集中理论上应该有多少簇,那么你应该使用这些知识来指导你的选择。如果你在使用聚类作为监督学习算法(例如分类)之前的预处理步骤,那么选择相当简单:将 k 调整为整个模型构建过程的超参数,并将最终模型的预测与原始标签进行比较。

但如果我们没有先验知识,也没有可比较的标记数据怎么办?如果我们选择错误会发生什么?嗯,就像分类和回归一样,聚类也受到偏差-方差权衡的影响。如果我们想将聚类模型推广到更广泛的群体,那么我们既不能过度拟合也不能欠拟合训练数据。图 16.3 展示了聚类问题中欠拟合和过拟合可能看起来像什么。当我们欠拟合时,我们未能识别和分离数据中的真实簇;但当我们过拟合时,我们将真实簇分割成更小、无意义的簇,这些簇在更广泛的群体中根本不存在。

图 16.3。聚类任务中欠拟合和过拟合的外观。在左侧图表中,簇欠拟合(识别的簇比实际存在的簇少)。在右侧图表中,簇过拟合(真实簇被分解成更小的簇)。在中间图表中,找到了一个最优的聚类模型,它忠实地代表了数据中的结构。

避免过拟合和欠拟合的聚类问题并不简单。人们提出了许多不同的方法来避免过拟合和欠拟合,并且它们对于特定问题不会完全一致。许多这些方法依赖于内部聚类指标的计算,这些指标是旨在量化聚类结果“质量”的统计数据。

注意

“高质量”簇的构成定义不明确,有些主观,但人们通常意味着每个簇尽可能紧凑,而簇之间的距离尽可能大。

这些指标是“内部”的,因为它们是从聚类数据本身计算出来的,而不是通过与任何外部标签或真实情况进行比较。选择聚类数量的常见方法是在一系列聚类数量范围内训练多个聚类模型,并比较每个模型的聚类指标,以帮助选择最佳拟合模型。以下三种常用的内部聚类指标如下:

  • 戴维斯-博尔丁指数

  • Dunn 指数

  • 伪 F 统计量

使用戴维斯-博尔丁指数评估聚类性能

戴维斯-博尔丁指数(以其创造者 David Davies 和 Donald Bouldin 命名)量化了每个簇与其最近邻簇的平均分离度。它是通过计算簇内方差(也称为散度)与簇质心之间的分离度之比来实现的(参见图 16.4)。

图 16.4。戴维斯-博尔丁指数计算了簇内(聚类内)方差(左侧图表)和每个簇质心的距离(右侧图表)。对于每个簇,确定其最近邻簇,并将它们的簇内方差之和除以它们质心之间的差异。这个值对每个簇进行计算,戴维斯-博尔丁指数是这些值的平均值。

如果我们固定聚类之间的距离,但使每个聚类内部的案例更加分散,戴维斯-博尔丁指数将变大。相反,如果我们固定聚类内的方差,但使聚类彼此之间距离更远,则指数将变小。理论上,值越小(介于零和无穷大之间),聚类之间的分离度越好。用简单的话来说,戴维斯-博尔丁指数量化了每个聚类与其最相似对应物之间的平均分离度。

计算戴维斯-博尔丁指数

你不需要记住 Davies-Bouldin 指数的公式(实际上,它相当复杂)。如果你感兴趣,我们可以将簇内的散布定义为

其中 scatter[k] 是簇 k 内部散布的度量,n[k] 是簇 k 中的案例数量,x[i] 是簇 k 中的第 i 个案例,而 c[k] 是簇 k 的质心。

簇之间的分离可以定义为

其中 separation[j][,k] 是簇 jk 之间分离的度量,c[j]c[k] 是它们各自的质心,而 N 是簇的总数。

然后计算簇内散布与两个簇之间的分离比

这个比率是针对所有簇对计算的,对于每个簇,将其与其他簇之间的最大比率定义为 R[k]。然后,Davies-Bouldin 指数就是这些最大比率的平均值:

使用 Dunn 指数来评估聚类性能

Dunn 指数是另一个内部簇度量,它量化了不同簇中点之间最小距离与任何簇内最大距离(称为簇的 直径)之间的比率(参见 图 16.5)。这些可以是任何距离度量,但通常是欧几里得距离。

图 16.5. Dunn 指数量化了不同簇中案例之间最小距离(左侧图表)与簇内最大距离(右侧图表)之间的比率。

这里的直觉是,如果我们保持簇的直径不变但将最近的成对案例分开,Dunn 指数将会变大。相反,如果我们保持簇中心之间的距离不变但缩小簇的直径(通过使簇更密集),Dunn 指数也会增加。因此,产生最大 Dunn 指数的簇数量是产生簇之间最大最小距离和簇内案例之间最小最大距离的簇。

计算 Dunn 指数

你不需要记住 Dunn 指数的公式。如果你感兴趣,我们可以将 Dunn 指数定义为

其中 δ(c[i],c[j]) 表示簇 ij 中案例之间的所有成对差异,而 δ(c[i]) 表示簇 k 中案例之间的所有成对差异。

使用伪 F 统计量来评估聚类性能

伪 F 统计量是簇间平方和与簇内平方和的比率(见图 16.6)。簇间平方和是每个簇质心与总体质心(数据如果全部在一个大簇中,其质心)之间的平方差,按该簇中的案例数量加权,并在每个簇中累加。这是衡量簇之间分离程度的一种方法(簇质心彼此越远,簇间平方和越小)。簇内平方和是每个案例与其簇质心之间的平方差,在每个簇中累加。这是衡量每个簇内方差或分散度的一种方法(簇越密集,簇内平方和越小)。

图 16.6。伪 F 统计量是簇间平方和(右侧图表)与簇内平方和(左侧图表)的比率。总体质心在右侧图表中以正方形表示。

因为伪 F 统计量也是一个比率,如果我们保持相同的簇方差但使簇彼此更远,伪 F 统计量将增加。相反,如果我们保持簇质心之间的相同分离度但使簇更加分散,伪 F 统计量将减少。因此,导致最大伪 F 统计量的簇数量,在理论上,是最大化簇之间分离度的那个。

计算伪 F 统计量

您不需要记住伪 F 统计量的公式。如果您感兴趣,我们可以将伪 F 统计量定义为

SS[between] 和 SS[within] 的计算方法如下

其中,有 N 个簇,n[k] 是簇 k 中的案例数量,c[k] 是簇 k 的质心,c[g] 是所有案例的总体质心。

这些只是众多常用内部簇度量指标中的三个,此时你可能想知道为什么没有只有一个指标能告诉我们簇之间的分离程度。原因是,当我们有非常清晰、定义良好的簇时,这些指标往往会相互一致,但当解决方案变得更加模糊时,它们将开始相互不一致,某些指标在特定情况下表现优于其他指标。例如,依赖于计算平方和的内部簇度量指标可能更喜欢选择直径相等的簇的数量。如果真实簇的直径非常不均匀,这可能不是最佳簇数量。因此,在确定簇数量时,考虑多个内部簇度量指标作为证据通常是一个好主意。

因此,像这样的内部聚类指标可以帮助我们找到最佳聚类数量。但仍然始终存在一种风险,我们可能会通过过度聚类来过度拟合训练数据。避免过度聚类的一种方法是从数据中抽取多个自助样本(有放回地抽取案例),对每个样本应用聚类算法,并比较样本之间聚类成员资格的一致性。如果稳定性很高(换句话说,聚类结果在样本之间是稳定的),那么我们更有信心我们不是在拟合数据中的噪声。

对于能够预测新数据聚类的聚类算法,如 k-means 算法,另一种方法是使用类似于交叉验证的程序。这涉及到将数据分为训练集和测试集(例如使用 k 折),在训练集上训练聚类算法,预测测试集中案例的聚类成员资格,并计算预测聚类的内部聚类指标。这种方法的好处是,它既允许我们测试聚类稳定性,又可以在算法从未见过的数据上计算指标。这是我们将在本章中使用 k-means 选择最佳聚类数量的方法。

注意

在 k-means 聚类中,可以通过简单地将新案例分配到最近的质心所在的聚类来将新数据投影到现有的聚类模型上。

16.2.4. 调整 k 和 k-means 模型的选择算法

在本节中,我将向您展示我们可以如何使用类似于交叉验证的方法,通过将内部聚类指标应用于预测聚类来调整k(聚类数量)和我们的 k-means 算法选择。让我们首先使用makeParamSet()函数定义我们的超参数搜索空间。我们定义了两个离散超参数,我们将搜索它们的值:centers,这是算法将搜索的聚类数量(k),以及algorithm,它指定我们将使用哪个算法来拟合模型。

小贴士

正如我们之前看到的,我们可以使用getParamSet(kMeans)来找到我们可用的所有超参数。

然后,我们将搜索方法定义为网格搜索(尝试每个超参数组合),并将交叉验证方法定义为 10 折。

列表 16.5. 定义超参数的调整方式
kMeansParamSpace <- makeParamSet(
  makeDiscreteParam("centers", values = 3:8),
  makeDiscreteParam("algorithm",
                    values = c("Hartigan-Wong", "Lloyd", "MacQueen")))

gridSearch <- makeTuneControlGrid()

kFold <- makeResampleDesc("CV", iters = 10)

现在我们已经定义了搜索空间,让我们进行调优。要使用 Davies-Bouldin 指数和伪 F 统计性能度量,您首先需要安装 clusterSim 包。

小贴士

mlr 实现了另外两个内部聚类指标:轮廓和 G2(使用listMeasures("cluster")列出可用的指标)。这两个指标的计算成本更高,所以我们在这里不会使用它们,但它们是额外的指标,帮助我们决定合适的聚类数量。

要执行调整,我们使用 tuneParams() 函数。因为我们没有在本书的降维部分使用此函数,让我们刷新一下对这个函数参数的记忆:

  • 第一个参数是学习器的名称。

  • task 参数是我们聚类任务的名称。

  • resampling 参数是我们交叉验证策略的名称。

  • par.set 参数是我们的超参数搜索空间。

  • control 参数是我们的搜索方法。

  • measures 参数允许我们定义我们想要为搜索的每一迭代计算哪些性能度量。在这里,我们请求 Davies-Bouldin 指数(db)、Dunn 指数(dunn)和伪 F 统计量(G1),按此顺序。

提示

我们可以提供我们想要的任何性能度量标准列表。所有这些度量标准都将为搜索的每一迭代计算,但优化列表中第一个度量标准值的超参数组合将始终从调整中返回。mlr 包还“知道”哪些度量标准应该最大化,哪些应该最小化以获得最佳性能。

只是为了重申:当我们执行调整时,对于每个超参数组合,数据将被分成 10 个折叠,k-means 算法将在每个折叠的训练集上训练。测试集中的案例将被分配到最近的簇中心,内部簇度量标准将在这些测试集簇上计算。调用调整的结果显示,具有四个簇的 Lloyd 算法给出了最低(最优化)的 Davies-Bouldin 指数。

列表 16.6. 执行调整实验
install.packages("clusterSim")

tunedK <- tuneParams(kMeans, task = gvhdTask,
                     resampling = kFold,
                     par.set = kMeansParamSpace,
                     control = gridSearch,
                     measures = list(db, dunn, G1))

tunedK

Tune result:
Op. pars: centers=4; algorithm=Lloyd
db.test.mean=0.8010,dunn.test.mean=0.0489,G1.test.mean=489.5331
注意

在调整过程的最后,你是否收到了“在 100 次迭代后没有收敛”的警告?这是判断你在学习器定义中是否将 iter.max 参数设置得太低的方法。你的选择是接受结果,这可能是也可能不是接近最优的解决方案,或者如果你有计算预算,增加 iter.max

练习 1

修改我们的 kmeans 定义(在代码列表 16.4 中创建),将 iter.max 的值设置为 200。重新运行代码列表 16.6 中的调整程序。关于无法收敛的错误消失了吗?

为了更好地理解我们的三个内部度量标准如何随着簇数量和算法选择的变化而变化,让我们绘制调整过程。回想一下,为了做到这一点,我们首先需要使用 generateHyperParsEffectData() 函数从调整结果中提取调整数据。从 kMeansTuningData 对象中调用 $data 组件,以便您可以查看其结构(这里我不会打印它,为了节省空间)。

注意

注意,我们有一个我们没有请求的度量标准:exec.time,它记录了使用每个超参数组合训练模型所需的时间,以秒为单位。

让我们绘制这些数据,以便每个性能指标有一个不同的面,每个算法有一条不同的线。为此,我们首先需要收集数据,使得每个性能指标的名字在一列,而指标值在另一列。我们使用gather()函数,将键列命名为"Metric",将值列命名为"Value"。因为我们只想收集这些列,所以我们提供了一个我们不希望收集的列的向量。打印新的收集数据集以确保你理解我们所做的。以这种格式拥有数据允许我们按算法进行分面,并为每个指标绘制单独的线条。

要绘制数据,我们使用ggplot()函数,将centers(聚类数量)映射到 x 美学,将Value映射到 y 美学。通过将algorithm映射到col美学,将为每个算法(不同颜色)绘制不同的geom_line()geom_point()层。我们使用facet_wrap()函数为每个性能指标绘制一个单独的子图,设置scales = "free_y"参数以允许每个面有不同的 y 轴(因为它们的刻度不同)。最后,我们添加geom_line()geom_point()层以及一个主题。

列表 16.7. 绘制调整实验
kMeansTuningData <- generateHyperParsEffectData(tunedK)

kMeansTuningData$data

gatheredTuningData <- gather(kMeansTuningData$data,
                             key = "Metric",
                             value = "Value",
                             c(-centers, -iteration, -algorithm))

ggplot(gatheredTuningData, aes(centers, Value, col = algorithm)) +
  facet_wrap(~ Metric, scales = "free_y") +
  geom_line() +
  geom_point() +
  theme_bw()

最终的图表显示在图 16.7 中。每个面展示了一个不同的性能指标,而每条单独的线表示三种算法之一。请注意,具有四个聚类(中心)的聚类模型中,Davies-Bouldin 指数最小化,而 Dunn 指数和伪 F 统计量(G1)最大化。因为 Davies-Bouldin 指数的较低值和 Dunn 指数以及伪 F 统计量的较高值(理论上)表示聚类分离得更好,所以这三个内部指标都一致认为四个是此数据集的最佳聚类数量。不同算法之间也存在很少的分歧,尤其是在四个聚类的最优值时。

图 16.7. 绘制我们的调整过程。每个子图显示了不同的内部聚类指标。不同的线条表示三种不同算法的性能。

算法之间最大的差异是它们的训练时间。请注意,MacQueen 算法始终比其他任何一种都要快。这是因为该算法比 Lloyd 算法更频繁地更新其质心,并且比 Hartigan-Wong 算法更少地重新计算距离。Hartigan-Wong 算法在低聚类数量时似乎计算量最大,但随着聚类数量超过七个,它超过了 Lloyd 算法。

注意

调整过程选择了 Lloyd 算法,因为它的 Davis-Bouldin 指数略小于其他算法。对于非常大的数据集,计算速度可能对你来说比这种小的性能提升更重要,在这种情况下,你可能会更喜欢选择 MacQueen 算法,因为它有更短的训练时间。

16.2.5. 训练最终的调整后的 k-means 模型

在本节中,我们将使用调整后的超参数来训练我们的最终聚类模型。你会注意到我们不会使用嵌套交叉验证来交叉验证整个模型构建过程。虽然 k 均值算法能够预测新数据的聚类成员资格,但它通常不作为预测技术使用。相反,我们可能会使用 k-means 来帮助我们更好地定义数据集中的类别,我们可以在以后使用这些类别来构建分类模型。

让我们首先创建一个使用调整后的超参数值的 k-means 学习器,使用setHyperPars()函数。然后我们使用train()函数在gvhdTask上训练这个调整后的模型,并使用getLearnerModel()函数提取模型数据以便绘制聚类。通过调用kMeansModel-Data打印模型数据,并检查输出;它包含大量有用的信息。通过提取对象的$iter组件,我们可以看到算法只用了三次迭代就收敛了(远少于iter.max)。

列表 16.8. 使用调整后的超参数训练模型
tunedKMeans <- setHyperPars(kMeans, par.vals = tunedK$x)

tunedKMeansModel <- train(tunedKMeans, gvhdTask)

kMeansModelData <- getLearnerModel(tunedKMeansModel)

kMeansModelData$iter

[1] 3

寻找最佳聚类数量不是一个定义明确的问题;因此,尽管内部指标提供了关于正确聚类数量的证据,但你仍然应该始终尝试通过视觉验证你的聚类模型,以了解你得到的结果是否合理(至少)。这可能会显得主观,确实是这样的,但使用你的专业判断比完全依赖内部指标要好得多。我们可以通过绘制数据(如图 16.2 所示)并按其聚类成员资格着色来实现这一点。

小贴士

如果确定正确的聚类数量对你来说很困难,可能是因为数据中根本不存在定义良好的聚类,或者你可能需要进行进一步的探索,包括生成更多数据。尝试不同的聚类方法可能值得考虑:例如,不寻找像 k-means 那样球形聚类的算法,或者可以排除异常值(如你将在第十八章中遇到的 DBSCAN)。

要做到这一点,我们首先使用mutate()函数将每个案例的聚类成员资格添加为gvhdTib tibble 的新列。我们从模型数据的$cluster组件中提取聚类成员资格的向量,并使用as.factor()函数将其转换为因子,以确保在绘图时应用离散的颜色方案。

我们随后使用 ggpairs() 函数来绘制所有变量之间的对比图,将 kMeansCluster 映射到颜色美学。我们使用 upper 参数在主对角线以上的图表上绘制密度图,并应用黑白主题。

列表 16.9. 使用 ggpairs() 绘制簇
gvhdTib <- mutate(gvhdTib,
                  kMeansCluster = as.factor(kMeansModelData$cluster))

ggpairs(gvhdTib, aes(col = kMeansCluster),
        upper = list(continuous = "density")) +
  theme_bw()
图 16.8. 将 k-means 簇成员资格映射到颜色美学的 ggpairs() 图。箱线图和直方图显示了连续变量值在簇之间的变化情况。

结果图表显示在 图 16.8。从直观上看,我们的 k-means 模型在整体上很好地捕捉了数据中的结构。但看看 CD8 与 CD4 的对比图:第三个簇似乎被分割了。这表明我们可能对数据进行得 过聚类,或者这些案例被分配到了错误的簇中;或者也许它们仅仅是异常值,其重要性被密度图高估了。

16.2.6. 使用我们的模型预测新数据的聚类

在本节中,我将向您展示如何使用现有的 k-means 模型来预测新数据的簇成员资格。正如我之前提到的,聚类技术并不打算用于预测数据类别——我们有一些在分类方面表现卓越的算法。但 k-means 算法 可以处理新数据并将新案例最接近的簇输出出来。这在您仍在探索并试图理解数据中的结构时非常有用,所以让我来演示一下。

让我们从创建一个包含新案例数据的 tibble 开始,包括模型训练数据集中每个变量的值。因为我们已经对训练数据进行了缩放,所以我们需要对新案例的值进行缩放。记住,根据用于训练模型的数据的均值和标准差缩放通过模型传递的新数据非常重要。最简单的方法是使用 attr() 函数从缩放数据中提取 centerscale 属性。因为 scale() 函数返回一个类为 matrix 的对象(如果给它一个矩阵,predict() 函数将抛出错误),我们需要将缩放数据通过 as_tibble() 函数管道,将其转换回 tibble。

要预测新案例属于哪个簇,我们只需调用 predict() 函数,将模型作为第一个参数,将新案例作为 newdata 参数。我们可以从输出中看到,这个新案例最接近簇 2 的质心。

列表 16.10. 预测新数据的簇成员资格
newCell <- tibble(CD4 = 510,
                  CD8b = 26,
                  CD3 = 500,
                  CD8 = 122) %>%
  scale(center = attr(gvhdScaled, "scaled:center"),
        scale = attr(gvhdScaled, "scaled:scale")) %>%
  as_tibble()

predict(tunedKMeansModel, newdata = newCell)

Prediction: 1 observations
predict.type: response
threshold:
time: 0.01
  response
1        2

你现在已经学会了如何将 k-means 聚类应用于你的数据。在下一章中,我将介绍 层次聚类,这是一组有助于揭示数据中层次结构的聚类方法。我建议你保存你的 .R 文件,因为我们将在下一章继续使用相同的数据集。这样我们可以比较 k-means 和层次聚类在相同数据集上的性能。

16.3. k-means 聚类的优势和劣势

虽然对于给定的任务很难判断哪种算法会表现良好,但以下是一些优势和劣势,这将帮助你决定 k-means 聚类是否适合你。

k-means 聚类的优势如下:

  • 案例可以在每次迭代中在聚类之间移动,直到找到稳定的结果。

  • 当有多个变量时,它可能比其他算法计算更快。

  • 它的实现相当简单。

k-means 聚类的劣势如下:

  • 它无法原生地处理分类变量。这是因为计算分类特征空间上的欧几里得距离没有意义。

  • 它不能选择最佳数量的聚类。

  • 它对不同尺度的数据敏感。

  • 由于初始质心的随机性,聚类可能在运行之间略有不同。

  • 它对异常值敏感。

  • 它优先找到等直径的球形聚类,即使基础数据不符合这种描述。

练习 2

以与我们处理 GvHD.control 数据集相同的方式对 GvHD.pos 数据集进行聚类。聚类数量的选择是否同样简单?你可能需要手动提供 centers 参数的值,而不是依赖于调整过程的输出。

概述

  • 聚类是一种无监督的机器学习技术,它关注于在数据集中找到彼此之间比其他集合中的案例更相似的案例集。

  • k-means 聚类涉及创建随机放置的质心,这些质心会迭代地移动到数据集中聚类的中心。

  • 最常用的三种 k-means 算法是 Lloyd 的、Mac-Queen 的和 Hartigan-Wong 的。

  • k-means 的聚类数量需要由用户选择。这可以通过图形化方式完成,并且可以通过结合内部聚类指标与交叉验证和/或自助法来完成。

练习题的解答

  1. 将我们的 k-means 学习者的 iter.max 增加到 200:

    kMeans <- makeLearner("cluster.kmeans",
                          par.vals = list(iter.max = 200, nstart = 10))
    
    tunedK <- tuneParams(kMeans, task = gvhdTask,
                         resampling = kFold,
                         par.set = kMeansParamSpace,
                         control = gridSearch,
                         measures = list(db, dunn, G1))
    
    # The error about not converging disappears when we set iter.max to 200.
    
  2. 使用 k-means 对 GvHD.pos 数据集进行聚类:

    gvhdPosTib <- as_tibble(GvHD.pos)
    
    gvhdPosScaled <- scale(gvhdPosTib)
    
    gvhdPosTask <- makeClusterTask(data = as.data.frame(gvhdPosScaled))
    
    tunedKPos <- tuneParams(kMeans, task = gvhdPosTask,
                            resampling = kFold,
                            par.set = kMeansParamSpace,
                            control = gridSearch,
                            measures = list(db, dunn, G1))
    
    kMeansTuningDataPos <- generateHyperParsEffectData(tunedKPos)
    
    gatheredTuningDataPos <- gather(kMeansTuningDataPos$data,
                                    key = "Metric",
                                    value = "Value",
                                    c(-centers, -iteration, -algorithm))
    
    ggplot(gatheredTuningDataPos, aes(centers, Value, col = algorithm)) +
      facet_wrap(~ Metric, scales = "free_y") +
      geom_line() +
      geom_point() +
      theme_bw()
    
    tunedKMeansPos <- setHyperPars(kMeans, par.vals = list("centers" = 4))
    
    tunedKMeansModelPos <- train(tunedKMeansPos, gvhdPosTask)
    
    kMeansModelDataPos <- getLearnerModel(tunedKMeansModelPos)
    
    mutate(gvhdPosTib,
           kMeansCluster = as.factor(kMeansModelDataPos$cluster)) %>%
      ggpairs(mapping = aes(col = kMeansCluster),
              upper = list(continuous = "density")) +
      theme_bw()
    
    # The optimal number of clusters is less clear than for GvHD.control.
    

第十七章. 层次聚类

本章涵盖

  • 理解层次聚类

  • 使用链接方法

  • 测量聚类结果的不稳定性

在上一章中,我们看到了 k-means 聚类如何在特征空间中找到k个质心,并迭代更新它们以找到一组集群。层次聚类采用不同的方法,正如其名称所暗示的,可以在数据集中学习集群的层次结构。与提供“平坦”的集群输出不同,层次聚类给我们提供了一个集群内部的集群树。因此,层次聚类比 k-means 这样的平坦聚类方法提供了对复杂分组结构的更多洞察。

通过迭代计算每个案例或集群与数据集中每个其他案例或集群之间的距离来构建集群树。根据算法,要么将彼此最相似的案例/集群对合并成一个集群,要么将彼此最不相似的案例/集群集分割成单独的集群。我将在本章后面向您介绍这两种方法。

到本章结束时,我希望您能理解层次聚类的工作原理。我们将应用这种方法来处理上一章的 GvHD 数据,以帮助您了解层次聚类与 k-means 的不同之处。如果您在全局环境中不再定义gvhdScaled对象,只需重新运行列表 16.1 和 16.2。

17.1. 什么是层次聚类?

在本节中,我将为您更深入地了解层次聚类是什么以及它与 k-means 的不同之处。我将向您展示我们可以采取的两种不同的方法来执行层次聚类,如何解释学习到的层次结构的图形表示,以及如何选择要保留的集群数量。

当我们在上一章查看 k-means 聚类时,我们只考虑了单层聚类。但有时,我们的数据集中存在层次结构,单层平坦的聚类无法突出显示。例如,想象一下我们正在查看管弦乐队的乐器集群。在最高层,我们可以将每个乐器放入四个不同的集群之一:

  • 打击乐器

  • 铜管乐器

  • 木管乐器

  • 弦乐器

但我们可以根据它们演奏的方式进一步将这些集群细分为子集群:

  • 打击乐器

    • 用木槌演奏

    • 手吹

  • 铜管乐器

    • 阀门

    • 滑音

  • 木管乐器

    • 有哨片的

    • 无哨片的

  • 弦乐器

    • 弹拨

    • 弓弦乐器

接下来,我们可以根据它们发出的声音进一步将这一层次的集群细分为子集群:

  • 打击乐器

    • 用木槌演奏

      • 定音鼓

    • 手吹

      • 手镲

      • 铃鼓

  • 铜管乐器

    • 阀门

      • 小号

      • 法式号

    • 滑音

      • 长号
  • 木管乐器

    • 有哨片的

      • 单簧管

      • 巴松管

    • 无哨片的

      • 长笛

      • 短笛

  • 弦乐器

    • 弹拨

      • 竖琴
    • 弓弦乐器

      • 小提琴

      • 大提琴

注意到我们形成了一个层次结构,其中包含其他簇中的乐器簇,从非常高级的聚类一直到底层的每个单独的乐器。可视化此类层次结构的一种常见方法是使用称为树状图的图形表示。我们管弦乐队层次结构的一个可能的树状图如图 17.1 所示图 17.1。

图 17.1. 显示管弦乐队中乐器假想聚类的树状图。水平线表示簇的合并。合并的高度表示簇之间的相似性(合并高度越低,相似性越高)。

图 17.1

注意到在树状图的底部,每种乐器都由其自己的垂直线表示,在这个层面上,每种乐器都被认为是位于自己的簇中。随着我们向上移动层次结构,同一簇中的乐器通过水平线连接。簇合并的这种高度与簇之间的相似性成反比。例如,我(主观上)绘制了这个树状图,以表明短笛和小号之间的相似性比双簧管和长笛之间的相似性更大。

通常,当我们寻找此类数据中的层次结构时,树状图的一端显示每个案例都位于自己的簇中;这些簇向上合并,最终所有案例都被放置在单个簇中。因此,我已经指出了我们的字符串、木管乐器、铜管乐器和打击乐器簇的位置,但我已经继续对这些簇进行聚类,直到只有一个包含所有案例的簇。

因此,层次聚类算法的目的是在数据集中学习这种聚类层次结构。与 k-means 聚类相比,层次聚类的优点在于我们能够获得对数据结构更细致的理解,并且这种方法通常能够重建自然界中的真实层次结构。例如,假设我们测序了所有犬种(所有 DNA)的基因组。我们可以安全地假设一个犬种的基因组将比它所没有衍生的犬种的基因组更相似于它所衍生的犬种的基因组。如果我们对这份数据应用层次聚类,这个层次结构,可以将其可视化为树状图,可以直接解释为显示哪些犬种是从其他犬种衍生的。

层次结构非常有用,但我们如何将树状图划分为有限簇集?嗯,在树状图的任何高度上,我们都可以水平地切割树,并取该层的簇数。另一种想象方式是,如果我们切割树状图的一个切片,那么掉落的分支数量就是簇的数量。回顾一下 图 17.1。如果我们切割我在标签处标记的弦乐器、木管乐器、铜管乐器和打击乐器,我们会得到四个单独的簇,案例将被分配到它们所在的这四个簇中。我将在本节的后面部分向您展示如何选择切割点。

注意

如果我们更靠近树顶切割,我们会得到更少的簇。如果我们更靠近树底切割,我们会得到更多的簇。

好的,我们已经了解了层次聚类算法试图实现的目标。现在让我们谈谈它们是如何实现这一目标的。在尝试学习数据中的层次结构时,我们可以采取两种方法:

  • 聚类

  • 划分

聚类层次聚类是从每个案例都孤立(且孤独)地位于其自己的簇中开始的,然后按顺序合并簇,直到所有数据都位于单个簇中。划分层次聚类则相反:它从所有案例都位于单个簇开始,并递归地将它们划分为簇,直到每个案例都位于其自己的簇中。

17.1.1. 聚类层次聚类

在本节中,我将向您展示聚类层次聚类是如何学习数据中的结构的。算法的步骤相当简单:

  1. 计算每个簇与其他所有簇之间的某些距离度量(由我们定义)。

  2. 将最相似的簇合并成一个簇。

  3. 重复步骤 1 和 2,直到所有案例都位于单个簇中。

图 17.2 展示了这种情况的一个例子。我们开始时有九个案例(因此有九个簇)。算法计算每个簇之间的距离度量(稍后将有更多关于此的信息),并将最相似的簇合并。这个过程一直持续到所有案例都被最终的超簇吞噬。

图 17.2. 聚类层次聚类在每次迭代中将彼此最近的簇合并。椭圆表示每次迭代中簇的形成,从左上角到右下角。

图片 17-2

那么,我们如何计算簇之间的距离呢?我们需要做的第一个选择是我们要计算哪种类型的距离。像往常一样,欧几里得距离和曼哈顿距离是最受欢迎的选择。第二个选择是如何计算簇之间的这种距离度量。计算两个案例(两个向量)之间的距离是相当明显的,但一个簇包含多个案例;我们如何计算,比如说,两个簇之间的欧几里得距离?嗯,我们有几种可供选择的方法,称为链接方法

  • 质心链接

  • 单链接

  • 完全链接

  • 平均链接

  • 沃德方法

这些链接方法在图 17.3 中均有说明。质心链接计算每个簇质心与每个其他簇质心之间的距离(例如,欧几里得距离或曼哈顿距离)。单链接取两个簇中最近案例之间的距离作为这些簇之间的距离。完全链接取两个簇中最远案例之间的距离作为这些簇之间的距离。平均链接取两个簇中所有案例之间的平均距离作为这些簇之间的距离。

图 17.3. 定义簇之间距离的不同链接方法。质心链接计算簇质心之间的距离。单链接计算簇之间的最小距离。完全链接计算簇之间的最大距离。平均链接计算两个簇中案例之间的所有成对距离并找到平均值。沃德方法计算每个候选合并的簇内平方和,并选择具有最小值的那个。

图片

沃德方法稍微复杂一些。对于每个可能的簇组合,沃德方法(有时称为沃德最小方差方法)计算簇内的平方和。请查看图 17.3 中沃德方法的示例。算法有三个簇需要考虑合并。对于每个候选合并,算法计算每个案例与其簇质心之间的平方差之和,然后将这些平方和相加。在每一步选择导致最小平方差之和的候选合并。

17.1.2. 分层聚类

在本节中,我将向您展示分层聚类是如何工作的。与聚合聚类不同,分层聚类从单个簇中的所有案例开始,并递归地将这些案例划分为越来越小的簇,直到每个案例都位于自己的簇中。在聚类的每个阶段找到最佳分割是一个困难的任务,因此分层聚类使用启发式方法。

在聚类的每个阶段,选择具有最大 直径 的集群。回想一下图 16.5,集群的直径是集群内任何两个案例之间的最大距离。然后算法找到这个集群中与集群内所有其他案例的平均距离最大的案例。这个最不相似的案例开始其自己的 分裂小组(就像一个没有原因的叛逆者)。然后算法遍历集群中的每个案例,根据它们与哪个集群最相似将案例分配到分裂小组或原始集群。本质上,分裂聚类在每个层次级别应用 k-means 聚类(其中 k = 2),以分割每个集群。这个过程重复进行,直到所有案例都位于自己的集群中。

分裂聚类只有一个实现:DIANA(DIvisive ANAlysis)算法。凝聚聚类比 DIANA 算法更常用,并且计算成本更低。然而,在层次聚类早期犯下的错误无法在树的下层得到修正;因此,虽然凝聚聚类可能在发现小集群方面做得更好,但 DIANA 在发现大集群方面可能做得更好。在本章的其余部分,我将向您介绍如何在 R 中执行凝聚聚类,但其中一项练习是重复使用 DIANA 进行聚类,并比较结果。

17.2. 构建您的第一个凝聚层次聚类模型

在本节中,我将向您展示如何在 R 中构建一个凝聚层次聚类模型。遗憾的是,mlr 包并没有对层次聚类进行封装的实现,因此我们将使用内置的 stats 包中的 hclust() 函数。

我们将要使用的 hclust() 函数执行凝聚层次聚类时,期望输入的是一个 距离矩阵,而不是原始数据。距离矩阵包含了每个元素组合之间的成对距离。这种距离可以是任何我们指定的距离度量,在这种情况下,我们将使用欧几里得距离。由于计算案例之间的距离是层次聚类的第一步,您可能会期望 hclust() 为我们完成这项工作。但这个创建我们自己的距离度量并将其提供给 hclust() 的两步过程确实给了我们使用各种距离度量的灵活性。

我们在 R 中使用 dist() 函数创建距离矩阵,将我们想要计算距离的数据作为第一个参数提供,以及我们想要使用的距离类型。请注意,我们正在使用我们的缩放数据集,因为层次聚类对变量之间的尺度差异也很敏感(任何依赖于连续变量之间距离的算法也是如此):

gvhdDist <- dist(gvhdScaled, method = "euclidean")
小贴士

如果你想更直观地了解距离矩阵的样子,运行dist(c(4, 7, 11, 30, 16))不要尝试打印本节中创建的距离矩阵——它包含超过 2.3 × 10⁷个元素!

现在我们有了距离矩阵,我们可以运行算法来学习数据中的层次结构。hclust()函数的第一个参数是距离矩阵,method参数允许我们指定我们希望用来定义聚类之间距离的链接方法。可用的选项有"ward.D""ward.D2""single""complete""average""centroid"以及一些不太常用的选项,我没有定义(如果你对这些选项感兴趣,请参阅?hclust)。注意,对于 Ward 方法似乎有两个选项:选项"ward.D2"是 Ward 方法的正确实现,如我之前所述。在这个例子中,我们将首先使用 Ward 方法("ward.D2"),但我会在本章的练习中让你比较这个结果与其他方法:

gvhdHclust <- hclust(gvhdDist, method = "ward.D2")

现在hclust()已经学习了数据的层次聚类结构,让我们用树状图来表示这一点。我们可以通过简单地调用聚类模型对象的plot()方法来实现这一点,但如果我们首先将我们的模型转换为树状图对象并绘制它,树会显得更清晰。我们可以使用as.dendrogram()函数将我们的聚类模型转换为树状图对象。要绘制树状图,我们将它传递给plot()函数。默认情况下,图表将为原始数据中的每个案例绘制一个标签。由于我们的数据集很大,让我们使用leaflab = "none"参数来抑制这些标签。

列表 17.1. 绘制树状图
gvhdDend <- as.dendrogram(gvhdHclust)

plot(gvhdDend, leaflab = "none")

生成的图表显示在图 17.4 中。这里的 y 轴表示基于我们使用的链接方法(和距离度量)的聚类之间的距离。因为我们使用了 Ward 方法,这个轴的值是聚类内的平方和。当两个聚类合并在一起时,它们通过一条水平线连接,这条线在 y 轴上的位置对应于这些聚类之间的距离。因此,在树的下部合并的案例聚类(在聚合聚类中较早)比在树上部合并的聚类更相似。x 轴上案例的顺序被优化,以便相似的案例被绘制在一起,以帮助解释(否则,分支会交叉)。正如我们所看到的,树状图递归地合并聚类,从每个案例都在自己的聚类到所有案例都属于一个超聚类。

图 17.4. 表示我们层次聚类模型的树状图。y 轴表示案例之间的距离。水平线表示案例/聚类合并的位置。合并得越高,聚类之间的相似度越低。

练习 1

重复聚类过程,但这次在创建距离矩阵时指定method = "manhattan"(不要覆盖任何现有对象)。绘制簇层次结构的树状图,并将其与我们使用欧几里得距离得到的树状图进行比较。

层次聚类算法已经完成了它的任务:它已经学会了层次结构,而我们如何使用它取决于我们自己。我们可能想直接解释树的结构,从而对自然界中可能存在的层次结构做出一些推断,尽管在我们(大型)数据集中,这可能相当具有挑战性。

层次聚类的另一种常见用途是对热图的行和列进行排序,例如,用于基因表达数据。使用层次聚类对热图的行和列进行排序有助于研究人员同时识别基因簇和患者簇。

最后,我们的主要动机可能是识别数据集中对我们最有兴趣的有限数量的簇。这就是我们将如何处理我们的聚类结果。

17.2.1. 选择簇的数量

在本节中,我将向您展示如何从层次结构中提取多少个簇的方法。另一种思考方式是我们正在决定使用层次结构的哪个级别进行聚类。

为了在层次聚类后定义有限数量的簇,我们需要在我们的树状图上定义一个切割点。如果我们接近树顶进行切割,我们将得到较少的簇;如果我们接近树底进行切割,我们将得到更多的簇。那么我们如何选择一个切割点呢?嗯,我们的朋友戴维斯-鲍尔丁指数、邓恩指数和伪 F 统计量可以在这里帮助我们。对于 k-means 聚类,我们执行了一个类似于交叉验证的程序来估计不同簇数量的性能。遗憾的是,我们无法使用这种方法进行层次聚类,因为与 k-means 不同,层次聚类不能预测新案例的簇成员资格

注意

层次聚类算法本身不能预测新案例的簇成员资格,但你可以尝试将新数据分配到最近的质心所在的簇。你可以使用这种方法来创建单独的训练集和测试集,以评估内部簇度量。

相反,我们可以利用自助法。回想一下第八章,自助法是从自助样本中抽取样本,对每个样本应用一些计算,并返回一个统计量(s)。我们自助统计量的平均值告诉我们最可能的价值,而分布则给我们提供了关于统计量(s)稳定性的指示。

注意

记住,为了获得自助样本,我们随机从数据集中选择案例,有放回地,以创建一个与旧样本相同大小的新的样本。有放回地采样简单地说就是,一旦我们采样了一个特定的案例,我们就将其放回,这样它就有可能再次被抽取。

在层次聚类的背景下,我们可以使用自助法从我们的数据生成多个样本,并为每个样本生成一个单独的层次结构。然后,我们可以从每个层次结构中选择一系列聚类数量,并计算每个的内部聚类度量。使用自助法的优点是,在完整数据集上计算内部聚类度量并不给我们提供估计稳定性的指示,而自助样本则可以。聚类度量的自助样本将围绕其平均值有一些变化,因此我们可以选择具有最优化和最稳定度量的聚类数量。

首先,我们定义一个自己的函数,该函数接受我们的数据和包含聚类成员资格向量的向量,并返回数据的三种熟悉的内部聚类度量:戴维斯-博尔丁指数、邓恩指数和伪 F 统计量。因为我们使用的函数来计算邓恩指数期望一个距离矩阵,所以我们在函数中包含一个额外的参数,我们将提供一个预先计算的距离矩阵。

列表 17.2. 定义cluster_metrics函数
cluster_metrics <- function(data, clusters, dist_matrix) {
  list(db       = clusterSim::index.DB(data, clusters)$DB,
       G1       = clusterSim::index.G1(data, clusters),
       dunn     = clValid::dunn(dist_matrix, clusters),
       clusters = length(unique(clusters))
  )
}

跟随我一起查看函数的主体,这样我们就能理解我们在做什么。我们使用function()参数来定义一个函数,将其分配给名称cluster_metrics(这将允许我们使用cluster_metrics()调用该函数)。我们为函数定义了三个强制参数:

  • data,我们将传递我们正在聚类的数据

  • clusters,一个包含data中每个案例聚类成员资格的向量

  • dist_matrix,我们将传递data的预计算距离矩阵

函数的主体(告诉函数做什么的指令)定义在花括号{}内。我们的函数将返回一个包含四个元素的列表:戴维斯-博尔丁指数(db)、伪 F 统计量(G1)、邓恩指数(dunn)和聚类数量。我们不是从头开始定义它们,而是使用来自其他包的预定义函数来计算内部聚类度量。戴维斯-博尔丁指数是通过 clusterSim 包中的index.DB()函数计算的,它接受dataclusters参数(统计量本身包含在$DB组件中)。伪 F 统计量是通过 clusterSim 包中的index.G1()函数计算的,它接受与index.DB()相同的参数。邓恩指数是通过 clValid 包中的dunn()函数计算的,它接受dist_matrixclusters参数。

我们定义这个函数的动机是,我们将从我们的数据集中抽取自助样本,学习每个样本中的层次结构,从每个样本中选择一系列聚类数量,并使用我们的函数来计算每个自助样本中每个聚类数量的这三个指标。所以现在,让我们创建我们的自助样本。我们将从我们的 gvhdScaled 数据集中创建 10 个自助样本。我们使用 map() 函数重复抽样过程 10 次,以返回一个列表,其中每个元素都是不同的自助样本。

列表 17.3. 创建自助样本
gvhdBoot <- map(1:10, ~ {
  gvhdScaled %>%
    as_tibble() %>%
    sample_n(size = nrow(.), replace = TRUE)
})
备注

记住,~ 只是 function() 的简称。

我们正在使用 dplyr 包中的 sample_n() 函数来创建样本。这个函数可以从数据集中随机抽取行。因为这个函数无法处理矩阵,我们首先需要将我们的 gvhdScaled 数据通过 as_tibble() 函数进行管道处理。通过设置参数 size = nrow(.),我们要求 sample_n() 随机抽取与原始数据集行数相等的案例数量(. 是“被管道传输的数据集”的简称)。通过将 replace 参数设置为 TRUE,我们告诉函数进行有放回的抽样。创建简单的自助样本实际上就像这样简单!

现在,让我们使用我们的 cluster_metrics() 函数来计算我们刚刚生成的每个自助样本的三个内部指标,对于一系列的聚类数量。看看下面的列表,不要眯着眼睛!我会一步一步地带你通过这段代码。

列表 17.4. 计算聚类模型的性能指标
metricsTib <- map_df(gvhdBoot, function(boot) {
  d <- dist(boot, method = "euclidean")
  cl <- hclust(d, method = "ward.D2")

  map_df(3:8, function(k) {
    cut <- cutree(cl, k = k)
    cluster_metrics(boot, clusters = cut, dist_matrix = d)
  })
})
小贴士

map_df() 函数就像 map(),但它不是返回一个列表,而是将每个元素按行组合起来返回一个数据框。

我们首先调用 map_df() 函数,这样我们就可以将一个函数应用到我们的自助样本列表中的每个元素上。我们定义一个匿名函数,它只接受 boot(当前正在考虑的元素)作为其唯一参数。

对于 gvhdBoot 中的每个元素,匿名函数计算其欧几里得距离矩阵,将其存储为对象 d,并使用该矩阵和 Ward 方法进行层次聚类。一旦我们有了每个自助样本的层次结构,我们就使用另一个 map_df() 函数调用来在三个和八个聚类之间选择,然后将数据分割成这些聚类,并在每个结果上计算三个内部聚类方法。我们将使用这个过程来查看在三个和八个之间哪个数量的聚类给出了最佳的内部聚类指标值。

使用cutree()函数从层次聚类模型中选择要保留的簇数量。我们使用这个函数在返回簇数量的地方切割我们的树状图。我们可以通过指定一个切割的高度,使用h参数,或者通过指定要保留的特定簇数量,使用k参数(如这里所示)来完成这个操作。第一个参数是调用hclust()函数的结果。cutree()函数的输出是一个向量,表示分配给数据集中每个案例的簇编号。一旦我们有了这个向量,我们就可以调用我们的cluster_metrics()函数,提供 bootstrap 数据,簇成员资格的向量,以及距离矩阵。

警告

这在我的机器上运行了近 3 分钟!

如果你对我们刚才所做的不太清楚,请打印metricsTib tibble 以查看输出。我们有一个 tibble,其中每列对应一个内部簇指标,还有一个列表示计算这些指标的簇数量。

让我们绘制我们的 bootstrap 实验的结果。我们将为每个内部簇指标创建一个单独的子图(使用分面)。每个子图将显示 x 轴上的簇数量,y 轴上的内部簇指标值,每个单独的 bootstrap 样本的线条,以及连接所有 bootstrap 均值值的线条。

列表 17.5. 转换数据,准备绘图
metricsTib <- metricsTib %>%
  mutate(bootstrap = factor(rep(1:10, each = 6))) %>%
  gather(key = "Metric", value = "Value", -clusters, -bootstrap)

我们首先需要创建一个新列,表示每个案例所属的 bootstrap 样本。由于有 10 个 bootstrap 样本,每个样本针对 6 个不同的簇数量进行评估(3 到 8),我们通过使用rep()函数重复 1 到 10 的每个数字六次来创建这个变量。我们将这个变量包裹在factor()函数中,以确保在绘图时它不被视为连续变量。接下来,我们收集数据,使得内部指标的选择包含在单个列中,而该指标的价值保持在另一个列中。我们指定-clusters-bootstrap来告诉函数不要收集这些变量。打印这个新的 tibble,并确保你理解我们是如何到达这里的。

现在我们数据已经以这种格式,我们可以创建图表。

列表 17.6. 计算指标
ggplot(metricsTib, aes(as.factor(clusters), Value)) +
  facet_wrap(~ Metric, scales = "free_y") +
  geom_line(size = 0.1, aes(group = bootstrap)) +
  geom_line(stat = "summary", fun.y = "mean", aes(group = 1)) +
  stat_summary(fun.data="mean_cl_boot",
               geom="crossbar", width = 0.5, fill = "white") +
  theme_bw()

我们将簇数量(作为一个因子)映射到 x 美学,将内部簇指标的值映射到 y 美学。我们添加一个facet_wrap()层,通过内部簇指标进行分面,设置scales = "free_y"参数,因为指标处于不同的尺度。接下来,我们添加一个geom_line()层,使用size参数使这些线条不那么突出,并将 bootstrap 样本编号映射到 group 美学。因此,这个层将为每个 bootstrap 样本绘制一条单独的细线。

提示

注意,当你指定 ggplot() 函数层内的美学映射时,该映射会被所有使用该美学的附加层继承。然而,你可以在每个 geom 函数内部使用 aes() 函数指定美学映射,并且映射只会应用于该层。

我们接着添加另一个 geom_line() 层,该层将连接所有自助样本的均值。默认情况下,geom_line() 函数喜欢连接单个值。如果我们想让函数连接一个汇总统计量(如均值),我们需要指定 stat = "summary" 参数,然后使用 fun.y 参数告诉函数我们想要绘制哪个汇总统计量。在这里,我们使用了 "mean",但你也可以提供任何返回输入 y 的单个值的函数名称。

最后,可视化自助样本的 95%置信区间会很好。95%置信区间告诉我们,如果我们重复进行这个实验 100 次,预期有 95 个构建的置信区间将包含该指标的真正值。自助样本之间的估计越一致,置信区间就越小。我们想要使用灵活的 stat_summary() 函数来可视化置信区间。这个函数可以以许多不同的方式可视化多个汇总统计量。为了绘制均值 ± 95% 置信区间,我们使用 fun.data 参数指定我们想要 "mean_cl_boot"。这将绘制自助置信区间(默认为 95%)。

注意

另一个选项是使用 "mean_cl_normal" 来构建置信区间,但这假设数据是正态分布的,这可能并不正确。

现在我们已经定义了我们的汇总统计量,让我们指定我们将使用 geom 参数来表示它们的几何形状。"crossbar" 几何形状绘制看起来像箱线图的箱体部分,其中通过我们指定的中心趋势度量(在这种情况下是均值)绘制一条实线,箱体的上下限延伸到我们请求的分散度度量范围(在这种情况下是 95%置信限)。然后,根据我的偏好,我们将交叉条的宽度设置为 0.5,填充颜色设置为白色。

生成的图示显示在 图 17.5 中。花点时间欣赏一下我们刚刚投入大量努力后的结果是多么美丽。回顾 列表 17.6 确保你理解了我们是如何创建这个图的(stat_summary() 可能是最令人困惑的部分)。看起来导致最小的平均戴维斯-博尔丁指数和最大的平均邓恩指数以及平均伪 F 统计量的聚类数量是四个。看看代表每个单独自助聚类的细线,你能看出其中一些可能使我们得出不同数量的聚类是最佳选择的结论吗?这就是为什么对这些度量进行自助比仅使用单个数据集计算每个度量一次更好。

图 17.5. 绘制我们的自助实验的结果。每个子图显示了不同内部聚类度量的结果。x 轴显示聚类编号,y 轴显示每个度量的值。淡线连接每个单独的自助样本的结果,而粗线连接平均值。每个十字线的顶部和底部表示该特定值的 95% 置信区间,水平线表示平均值。

练习 2

让我们尝试另一种可视化这些结果的方法。从以下使用 dplyr 的操作开始(将每个步骤管道输入到下一个):

  1. MetricmetricsTib 对象进行分组。

  2. 使用 mutate()Value 变量替换为 scale(Value)

  3. Metricclusters 进行分组。

  4. 创建一个新列 Stdev,等于 sd(Value)

然后,将这个 tibble 管道输入到 ggplot() 调用中,并使用以下美学映射:

  • x = clusters

  • y = Metric

  • fill = Value

  • height = Stdev

最后,添加一个 geom_tile() 层。回顾一下你的代码,确保你理解了如何创建这个图以及如何解释它。

17.2.2. 剪切树以选择一组平坦的聚类

在本节中,我将向你展示我们如何最终剪切树状图以返回我们所需数量的聚类标签。我们的自助实验使我们得出结论,四个是我们用 GvHD 数据集表示结构的最佳聚类数量。为了提取表示这四个聚类的聚类成员资格向量,我们使用 cutree() 函数,提供我们的聚类模型和 k(我们想要返回的聚类数量)。我们可以通过像以前一样绘制树状图并调用 rect.hclust() 函数来使用与 cutree() 相同的参数来可视化我们的树状图是如何被切割以生成这四个聚类的。

列表 17.7. 剪切树
gvhdCut <- cutree(gvhdHclust, k = 4)

plot(gvhdDend, leaflab = "none")

rect.hclust(gvhdHclust, k = 4)

这个函数在现有的树状图上绘制矩形,以显示哪些分支被剪切以产生我们指定的聚类数量。生成的图示显示在 图 17.6 中。

图 17.6. 与图 17.4 相同的图表,但这次用矩形表示通过切割树得到的聚类

接下来,让我们使用 ggpairs() 来绘制聚类,就像我们在第十六章中为我们的 k-means 模型所做的那样,第十六章。

列表 17.8. 绘制聚类
gvhdTib <- mutate(gvhdTib, hclustCluster = as.factor(gvhdCut))

 ggpairs(gvhdTib, aes(col = hclustCluster),
        upper = list(continuous = "density"),
        lower = list(continuous = wrap("points", size = 0.5))) +
  theme_bw()
图 17.7. ggpairs() 图显示我们的层次聚类模型的结果。将这些聚类与图 16.8 中通过 k-means 得到的聚类进行比较。

结果图显示在图 17.7 中。将这些聚类与我们在图 16.8 中得到的 k-means 模型返回的聚类进行比较。两种方法都产生了相似的聚类成员资格,而且我们的层次聚类得到的聚类似乎也低估了聚类 3。

17.3. 我们的聚类有多稳定?

在本节中,我将向您展示一个评估我们的聚类模型性能的更多工具。除了在 bootstrap 实验中计算每个 bootstrap 样本的内部聚类度量外,我们还可以量化聚类成员资格在 bootstrap 样本之间的一致性。这种一致性被称为聚类稳定性。量化聚类稳定性的常见方法是通过一个称为Jaccard 指数的相似性度量(以发表它的植物学教授命名)。

Jaccard 指数量化了两个离散变量集合之间的相似性。它的值可以解释为两个集合中存在的总值的百分比,其范围从 0%(没有共同值)到 100%(两个集合都有的所有值)。Jaccard 指数在方程 17.1 中定义。

方程 17.1.

例如,如果我们有两个集合

a = {3, 3, 5, 2, 8}

b = {1, 3, 5, 6}

那么,Jaccard 指数是

如果我们在多个 bootstrap 样本上进行聚类,我们可以计算“原始”聚类(所有数据的聚类)与每个 bootstrap 样本之间的 Jaccard 指数,并取平均值。如果平均 Jaccard 指数低,那么聚类成员资格在 bootstrap 样本之间变化很大,这表明我们的聚类结果是不稳定的,可能无法很好地推广。如果平均 Jaccard 指数高,那么聚类成员资格变化很小,这表明聚类结果是稳定的。

幸运的是,fpc 包中的 clusterboot() 函数已经被编写来执行这项操作!让我们首先将 fpc 包加载到我们的 R 会话中。因为 clusterboot() 作为副作用产生了一系列基础 R 图表,让我们将绘图设备分成三行四列,以便容纳输出,使用 par(mfrow = c(3, 4))

列表 17.9. 使用 clusterboot() 计算 Jaccard 指数
library(fpc)

par(mfrow = c(3, 4))

clustBoot <- clusterboot(gvhdDist, B = 10,
                         clustermethod = disthclustCBI,
                         k = 4, cut = "number", method = "ward.D2",
                         showplots = TRUE)

clustBoot

Number of resampling runs:  10

Number of clusters found in data:  4

 Clusterwise Jaccard bootstrap (omitting multiple points) mean:
[1] 0.9728 0.9208 0.8348 0.9624

clusterboot()函数的第一个参数是数据。该参数将接受原始数据或类dist的距离矩阵(它将适当地处理任一)。B参数是我们希望计算的自助样本数量,我将其设置为 10 以减少运行时间。clustermethod参数是我们指定希望构建哪种聚类模型的地方(有关可用方法的列表,请参阅?clusterboot;包括了许多常见方法)。对于层次聚类,我们将此参数设置为disthclustCBIk参数指定我们想要返回的聚类数量,method允许我们指定用于聚类的距离度量,而showplots给我们提供了抑制打印图的机会。该函数可能需要几分钟才能运行。

我已经截断了clusterboot()函数输出的结果,以显示最重要的信息:聚类层面的 Jaccard 自助样本均值。这四个值是每个聚类,原始聚类和每个自助样本之间的平均 Jaccard 指数。我们可以看到,所有四个聚类在不同自助样本中都有良好的一致性(> 83%),这表明聚类具有较高的稳定性。

结果图示在图 17.8 中展示。第一个(左上角)和最后一个(右下角)图展示了原始、完整数据集上的聚类。在这两个图之间的每个图展示了不同自助样本上的聚类。这种图示是图形化评估聚类稳定性的有用方式。

图 17.8. clusterboot()函数的图形输出。第一和最后一个图展示了完整、原始的数据聚类,而中间的图展示了自助样本上的聚类。每个案例的聚类成员资格由一个数字表示。注意聚类的相对高稳定性。

17.4. 层次聚类的优缺点

虽然通常不容易判断哪种算法会对特定任务表现良好,但以下是一些优势和劣势,这将帮助您决定层次聚类是否适合您。

层次聚类的优势如下:

  • 它学习到的层次结构本身可能很有趣且可解释。

  • 它的实现相当简单。

层次聚类的劣势如下:

  • 它无法原生地处理分类变量。这是因为在一个分类特征空间上计算欧几里得距离是没有意义的。

  • 它无法选择“平坦”聚类的最佳数量。

  • 它对不同尺度的数据敏感。

  • 它无法预测新数据的聚类成员资格。

  • 一旦案例被分配到某个聚类,它们就不能再移动。

  • 对于大数据集,它可能会变得计算成本高昂。

  • 它对异常值敏感。

练习 3

使用 clusterboot() 函数对 k-means 聚类(四个簇)的 Jaccard 指数进行自助法,就像我们对层次聚类所做的那样。这次,clustermethod 应该等于 kmeansCBI(使用 k-means),并且你应该将 method 参数替换为 algorithm = "Lloyd"。哪种方法会导致更稳定的簇:k-means 还是层次聚类?

练习 4

使用聚类包中的 diana() 函数对 GvHD 数据进行分裂层次聚类。将输出保存为对象,并通过传递给 as.dendrogram() %>% plot() 来绘制树状图。将其与聚合层次聚类的树状图进行比较。警告:在我的机器上这几乎花了 15 分钟!

练习 5

使用聚合层次聚类重复我们的自助法实验,但这次将簇的数量固定为四个,并在每个自助法上比较不同的链接方法。哪种链接方法表现最好?

练习 6

使用 hclust() 重新聚类数据,使用 练习 5 中指示的最佳链接方法。使用 ggpairs() 绘制这些簇,并将它们与我们使用 Ward 方法生成的簇进行比较。这种新的链接方法在发现簇方面做得好吗?

摘要

  • 层次聚类使用案例之间的距离来学习簇的层次结构。

  • 这些距离是如何计算的由我们选择的链接方法控制。

  • 层次聚类可以是自下而上(聚合)或自上而下(分裂)。

  • 通过在特定高度“切割”树状图,可以从层次聚类模型中返回一个平面的簇集。

  • 通过在自助样本上进行聚类并使用 Jaccard 指数量化样本之间簇成员资格的一致性来衡量聚类稳定性。

练习题的解答

  1. 使用曼哈顿距离创建层次聚类模型,绘制树状图,并进行比较:

    gvhdDistMan <- dist(gvhdScaled, method = "manhattan")
    
    gvhdHclustMan <- hclust(gvhdDistMan, method = "ward.D2")
    
    gvhdDendMan <- as.dendrogram(gvhdHclustMan)
    
    plot(gvhdDendMan, leaflab = "none")
    
  2. 以另一种方式绘制自助法实验:

    group_by(metricsTib, Metric) %>%
      mutate(Value = scale(Value)) %>%
      group_by(Metric, clusters) %>%
      mutate(Stdev = sd(Value)) %>%
    
      ggplot(aes(as.factor(clusters), Metric, fill = Value, height = Stdev)) +
      geom_tile() +
      theme_bw() +
      theme(panel.grid = element_blank())
    
  3. 使用 clusterboot() 评估 k-means 模型的稳定性:

    par(mfrow = c(3, 4))
    
    clustBoot <- clusterboot(gvhdScaled,
                             B = 10,
    
                             clustermethod = kmeansCBI,
                             k = 4, algorithm = "Lloyd",
                             showplots = TRUE)
    
    clustBoot
    
    # k-means seems to give more stable clusters.
    
  4. 使用 diana() 函数对数据进行聚类:

    library(cluster)
    
    gvhdDiana <- as_tibble(gvhdScaled) %>% diana()
    
    as.dendrogram(gvhdDiana) %>% plot(leaflab = "none")
    
  5. 重复自助法实验,比较不同的链接方法:

    cluster_metrics <- function(data, clusters, dist_matrix, linkage) {
      list(db   = clusterSim::index.DB(data, clusters)$DB,
           G1   = clusterSim::index.G1(data, clusters),
           dunn = clValid::dunn(dist_matrix, clusters),
           clusters = length(unique(clusters)),
           linkage = linkage
      )
    }
    
    metricsTib <- map_df(gvhdBoot, function(boot) {
      d <- dist(boot, method = "euclidean")
      linkage <- c("ward.D2", "single", "complete", "average", "centroid")
    
      map_df(linkage, function(linkage) {
        cl <- hclust(d, method = linkage)
        cut <- cutree(cl, k = 4)
        cluster_metrics(boot, clusters = cut, dist_matrix = d, linkage)
      })
    })
    
    metricsTib
    
    metricsTib <- metricsTib %>%
      mutate(bootstrap = factor(rep(1:10, each = 5))) %>%
      gather(key = "Metric", value = "Value", -clusters, -bootstrap, -linkage)
    
    ggplot(metricsTib, aes(linkage, Value)) +
      facet_wrap(~ Metric, scales = "free_y") +
      geom_line(size = 0.1, aes(group = bootstrap)) +
      geom_line(stat = "summary", fun.y = "mean", aes(group = 1)) +
      stat_summary(fun.data="mean_cl_boot",
                   geom="crossbar", width = 0.5, fill = "white") +
      theme_bw()
    
    # Single linkage seems the best, indicated by DB and Dunn,
    # though pseudo F disagrees.
    
  6. 使用 练习 5 中的获胜链接方法对数据进行聚类:

    gvhdHclustSingle <- hclust(gvhdDist, method = "single")
    
    gvhdCutSingle <- cutree(gvhdHclustSingle, k = 4)
    
    gvhdTib <- mutate(gvhdTib, gvhdCutSingle = as.factor(gvhdCutSingle))
    
    select(gvhdTib, -hclustCluster) %>%
      ggpairs(aes(col = gvhdCutSingle),
              upper = list(continuous = "density"),
              lower = list(continuous = wrap("points", size = 0.5))) +
      theme_bw()
    
    # Using single linkage on this dataset does a terrible job of finding
    # clusters! This is why visual evaluation of clusters is important:
    # don't blindly rely on internal metrics only!
    

第十八章. 基于密度的聚类:DBSCAN 和 OPTICS

本章涵盖

  • 理解基于密度的聚类

  • 使用 DBSCAN 和 OPTICS 算法

无监督学习技术的最后一站带我们来到了基于密度的聚类。基于密度的聚类算法旨在实现与 k-means 和层次聚类相同的目标:将数据集划分为有限个簇,揭示数据中的分组结构。

在最后两章中,我们看到了 k-means 和层次聚类是如何使用距离来识别聚类的:案例之间的距离,以及案例与其质心之间的距离。基于密度的聚类包括一系列算法,正如其名称所示,它使用案例的密度来分配聚类成员资格。有多种测量密度的方法,但我们可以将其定义为特征空间单位体积中的案例数量。特征空间中包含许多紧密排列的案例的区域可以被认为是高密度区域,而特征空间中包含少量或没有案例的区域可以被认为是低密度区域。我们的直觉表明,数据集中的不同聚类将由高密度区域表示,这些区域由低密度区域分隔。基于密度的聚类算法试图学习这些独特的高密度区域并将它们划分为聚类。基于密度的聚类算法具有一些很好的特性,可以克服 k-means 和层次聚类的某些局限性。

在本章结束时,我希望你能对两种最常用的基于密度的聚类算法的工作原理有一个牢固的理解:DBSCAN 和 OPTICS。我们还将应用你在前几章中学到的技能,帮助我们评估和比较不同聚类模型的表现。

18.1. 什么是基于密度的聚类?

在本节中,我将向你展示两种最常用的基于密度的聚类算法是如何工作的:

  • 基于噪声的密度聚类(DBSCAN)

  • 使用点排序来识别聚类结构(OPTICS)

除了名字看起来像是精心设计以形成有趣的缩写外,DBSCAN 和 OPTICS 都在数据集中学习高密度区域,这些区域由低密度区域分隔。它们以相似但略有不同的方式实现这一点,但两者都比 k-means 和层次聚类有一些优势:

  • 它们不倾向于寻找球形聚类,实际上可以找到形状各异且复杂的聚类。

  • 它们不倾向于寻找等直径的聚类,并且可以在同一数据集中识别非常宽和非常紧密的聚类。

  • 它们在聚类算法中似乎是独一无二的,因为那些不足以形成高密度区域的情况被放入一个单独的“噪声”聚类中。这通常是一个理想的属性,因为它有助于防止数据过拟合,并允许我们专注于证据更强的聚类案例。

小贴士

如果将案例分为噪声聚类对于你的应用来说不是理想的(但使用 DBSCAN 或 OPTICS 是),你可以使用一种启发式方法,例如根据它们最近的聚类质心对噪声点进行分类,或者将它们添加到它们的 k 个最近邻的聚类中。

这三个优点都可以在图 18.1 中看到。三个子图分别展示了相同的数据,使用 DBSCAN、k-means(Hartigan-Wong 算法)或层次聚类(完全连接)进行聚类。这个数据集确实很奇怪,你可能认为不太可能遇到类似的真实世界数据,但它说明了基于密度的聚类相对于 k-means 和层次聚类的优势。数据中的聚类形状和直径非常不同(当然不是球形的)。虽然 k-means 和层次聚类学习到的聚类将真实聚类分割和合并,但 DBSCAN 能够忠实地找到每个形状作为独立的聚类。此外,请注意,k-means 和层次聚类将每个案例都放入一个聚类中。DBSCAN 创建了一个名为“0”的聚类,将任何它认为是噪声的案例放入其中。在这种情况下,所有那些几何形状的聚类之外的案例都被放入噪声聚类中。然而,如果你仔细观察,你可能会注意到数据中有一个正弦波,而这三个算法都没有将其识别为聚类。

图 18.1. 一个具有挑战性的聚类问题。每个面展示的数据集包含形状和直径各不相同、可能被认为是噪声的案例的聚类。三个子图展示了使用 DBSCAN、层次聚类(完全连接)和 k-means(Hartigan-Wong)进行聚类的数据。在这三个算法中,只有 DBSCAN 能够忠实地将这些形状表示为独立的聚类。

那么基于密度的聚类算法是如何工作的呢?DBSCAN 算法相对容易理解,所以我们将从它开始,并在此基础上理解 OPTICS。

18.1.1. DBSCAN 算法是如何学习的?

在本节中,我将向您展示 DBSCAN 算法是如何在数据中学习高密度区域以识别聚类的。为了理解 DBSCAN 算法,您首先需要了解它的两个超参数:

  • epsilon(ϵ)

  • minPts

算法首先在数据中选择一个案例,并在搜索半径内寻找其他案例。这个半径是epsilon超参数。所以epsilon就是算法在点周围搜索其他案例的距离(在一个n-维球体中)。Epsilon 以特征空间的单位表示,默认情况下是欧几里得距离。更大的值意味着算法将搜索距离每个案例更远的地方。

minPts超参数指定了一个聚类必须拥有的最小点数(案例),以便它被视为一个聚类。因此,minPts超参数是一个整数。如果一个特定的案例在其epsilon半径内(包括自身)至少有minPts个案例,那么这个案例被认为是核心点

让我们一起通过查看 图 18.2 来了解 DBSCAN 算法。算法的第一步是从数据集中随机选择一个案例。算法在一个 n-维球体(其中 n 是数据集中特征的数量)内搜索其他案例,半径等于 epsilon。如果这个案例在其搜索半径内包含至少 minPts 个案例,它被标记为核心点。如果案例在其搜索空间内不包含 minPts 个案例,它不是核心点,算法继续到另一个案例。

图 18.2. DBSCAN 算法。随机选择一个案例,如果其 epsilon 半径(ϵ)包含至少 minPts 个案例,它被认为是核心点。这个核心点的可达案例以相同的方式进行评估,直到没有更多的可达案例。这个密度连接案例的网络被认为是簇。可以从核心点到达但自身不是核心点的案例是边界点。算法继续到下一个未访问的案例。既不是核心点也不是边界点的案例被标记为噪声。

假设算法选择了一个案例,并发现它是一个核心点。然后算法访问核心点周围 epsilon 范围内的每个案例,并重复相同的任务:查看这个案例是否在其自身的搜索半径内有 minPts 个案例。两个位于彼此搜索半径内的案例被称为 直接密度连接 并可以从彼此那里 可达。搜索递归地进行,跟随从核心点出发的所有直接密度连接。如果算法找到一个可以到达核心点但自身没有 minPts-可达案例的案例,这个案例被认为是 边界点。算法 搜索核心点的搜索空间,而不是边界点的搜索空间。

如果两个案例不是直接密度连接,但通过一系列直接密度连接的案例链或系列连接在一起,则称这两个案例为 密度连接。一旦搜索完成,并且访问过的案例没有更多的直接密度连接可以探索,所有相互密度连接的案例都被放入同一个簇中(包括边界点)。

现在算法在数据集中选择一个不同的案例——它之前没有访问过的案例——然后相同的流程再次开始。一旦数据集中的每个案例都被访问过,那些既不是核心点也不是边界点的孤立案例将被添加到噪声簇中,并被认为是离高密度区域太远,无法自信地将它们与其它案例聚类。因此,DBSCAN 通过在特征空间的高密度区域找到案例链来找到簇,并将占据特征空间稀疏区域的案例排除在外。

注意

从边界点向外搜索有助于防止噪声事件被包含到簇中。

我刚刚介绍了很多新的术语!让我们快速回顾一下,以便这些术语能留在你的脑海中,因为它们对 OPTICS 算法也很重要:

  • Epsilon— 案例周围的n-维球体的半径,算法在其中搜索其他案例

  • minPts— 簇中允许的最小案例数,以及必须在一个案例的epsilon范围内才能成为核心点的案例数

  • 核心点— 至少有minPts个可到达案例的案例

  • 可到达/直接密度连接— 当两个案例彼此之间在epsilon范围内时

  • 密度连接— 当两个案例通过一系列直接密度连接的案例连接,但它们本身可能不是直接密度连接时

  • 边界点— 可以从核心点到达但本身不是核心点的案例

  • 噪声点— 既不是核心点也从未被核心点到达的案例

18.1.2. OPTICS 算法是如何学习的?

在本节中,我将向你展示 OPTICS 算法如何学习数据集中的高密度区域,它与 DBSCAN 的相似之处以及不同之处。从技术上来说,OPTICS 实际上不是一个聚类算法。相反,它以某种方式对数据中的案例进行排序,以便我们可以从中提取簇。这听起来有点抽象,所以让我们来看看 OPTICS 是如何工作的。

DBSCAN 算法有一个重要的缺点:它难以识别具有不同密度的簇。OPTICS 算法试图缓解这一缺点,并识别具有不同密度的簇。它是通过允许每个案例周围的搜索半径动态扩展,而不是固定在预定的值来实现的。

为了理解 OPTICS 是如何工作的,我需要介绍两个新术语:

  • 核心距离

  • 可达性距离

在 OPTICS 中,一个案例周围的搜索半径不是固定的,而是扩展到至少包含minPts个案例。这意味着特征空间中密集区域的案例将具有较小的搜索半径,而稀疏区域的案例将具有较大的搜索半径。包含至少minPts个其他案例的案例与该案例的最小距离称为核心距离,有时简称为ϵ′。实际上,OPTICS 算法只有一个强制性的超参数:minPts

注意

我们仍然可以提供epsilon,但它主要用于通过充当最大核心距离来加速算法。换句话说,如果核心距离达到epsilon,只需将epsilon作为核心距离,以防止数据集中的所有案例都被考虑。

可达距离是指一个核心点与其 epsilon 范围内的另一个核心点之间的距离,但不能小于核心距离。换句话说,如果一个案例的核心点在其核心距离内部,那么这些案例之间的可达距离就是核心距离。如果一个案例的核心点在其核心距离外部,那么这些案例之间的可达距离就是它们之间的欧几里得距离。

注意

在 OPTICS 中,如果一个案例在其 epsilon 范围内有minPts个案例,则该案例是核心点。如果我们不指定 epsilon,则所有案例都将被视为核心点。一个案例与非核心点之间的可达距离是未定义的。

看一下图 18.3 中的示例。你可以看到围绕深色阴影案例的两个圆圈。半径较大的圆圈是epsilon,半径较小的圆圈是核心距离(ϵ')。这个例子展示了值为 4 的minPts的核心距离,因为核心距离已经扩展以包含四个案例(包括所讨论的案例)。箭头指示核心点与其 epsilon 范围内的其他案例之间的可达距离。

图 18.3. 定义核心距离和可达距离。在 OPTICS 中,epsilon(ϵ)是最大搜索距离。核心距离(ϵ′)是需要包含minPts个案例(包括所讨论的案例)的最小搜索距离。一个案例的可达距离是该案例的核心距离和与另一个在其 epsilon 范围内的案例之间的距离中的较大者。

因为可达距离是一个核心点与其 epsilon 范围内的另一个核心点之间的距离,所以 OPTICS 需要知道哪些案例是核心点。因此,算法首先遍历数据中的每个案例,并确定其核心距离是否小于 epsilon。这如图 18.4 所示。如果一个案例的核心距离小于或等于 epsilon,则该案例是核心点。如果一个案例的核心距离大于 epsilon(我们需要扩展到 epsilon 之外以找到minPts个案例),则该案例不是核心点。两种情况都在图 18.4 中展示。

图 18.4. 在 OPTICS 中定义核心点。核心距离(ϵ′)小于或等于最大搜索距离(ϵ)的案例被认为是核心点。核心距离大于最大搜索距离的案例不被认为是核心点。

现在你已经理解了核心距离和可达距离的概念,让我们看看 OPTICS 算法是如何工作的。第一步是访问数据集中的每个情况,并标记它是否为核心点。算法的其余部分在图 18.5 中展示,所以假设这部分已经完成。OPTICS 选择一个情况,并计算它与所有在其epsilon(最大搜索距离)内的情况的可达距离。

在移动到下一个情况之前,算法做两件事:

  • 记录情况的可达分数

  • 更新情况的处理顺序

情况的可达分数与可达距离不同(术语很不幸地令人困惑)。一个情况的可达分数定义为它的核心距离或其最小可达距离中的较大者。让我们重新表述:如果一个情况在其epsilon内没有minPts个情况(它不是核心点),那么它的可达分数将是到其最近核心点的可达距离。如果一个情况在其epsilon内确实有minPts个情况,那么它的最小可达距离将小于或等于其核心距离,所以我们只需将核心距离作为该情况的可达分数。

图 18.5. OPTICS 算法。选择一个情况,并测量其核心距离(ϵ′)。计算该情况与其最大搜索距离(ϵ)内的所有情况的可达距离。更新数据集的处理顺序,以便访问最近的下一个情况。记录该情况的可达分数和处理顺序,然后算法继续处理下一个情况。

注意

因此,情况的可达分数永远不会小于其核心距离,除非核心距离大于最大值epsilon,在这种情况下,epsilon将是可达分数。

一旦记录了特定情况的可达性,算法随后更新它将要访问的下一个情况的序列(处理顺序)。它更新处理顺序,以便接下来访问与当前情况具有最小可达距离的核心点,然后是下一个最远的点,依此类推。这可以在图 18.5 的第 2 步中看到。

然后算法按照更新后的处理顺序访问下一个情况,并重复相同的过程,可能会再次更改处理顺序。当当前链中没有更多可达情况时,算法将移动到数据集中下一个未访问的核心点,并重复该过程。

一旦访问了所有案例,算法将返回处理顺序(每个案例被访问的顺序)和每个案例的可达性分数。如果我们绘制处理顺序与可达性分数的关系图,我们得到类似于图 18.6 顶部的图。为了生成这个图,我使用具有四个簇的模拟数据集应用了 OPTICS 算法(你可以在这个www.manning.com/books/machine-learning-with-r-the-tidyverse-and-mlr找到重现此图的代码)。注意,当我们绘制处理顺序与可达性分数的关系图时,我们得到四个浅谷,每个谷之间都由高可达性的尖峰隔开。图中的每个谷对应于高密度区域,而每个尖峰表示这些区域通过低密度区域进行分离。

图 18.6. 模拟数据集的可达性图。上面的图显示了 OPTICS 算法从下面图中所示的数据中学习到的可达性图。图被着色以表示特征空间中的每个簇映射到可达性图的位置。

图 18.6 的替代图

注意

谷越深,密度越高。

OPTICS 算法实际上并没有进一步做。一旦它生成了这个图,它的任务就完成了,现在是我们使用图中包含的信息来提取簇成员的工作。这就是为什么我说 OPTICS 在技术上不是一个聚类算法,而是创建了一个数据排序,使我们能够在数据中找到簇。

那么,我们如何提取簇呢?我们有几种选择。一种方法是在可达性图上简单地画一条水平线,在某个可达性分数处,并将簇的开始和结束定义为图下降并再次上升至该线以下的位置。任何在直线以上的案例可以分类为噪声,如图 18.7 顶部的图所示。这种方法将导致与 DBSCAN 算法产生的聚类非常相似,但某些边界点更有可能被放入噪声簇中。

图 18.7. 从可达性图中提取不同方式簇的示意图。在上面的图中,定义了一个单一的可达性分数截止值,任何被高于此截止值的峰值包围的谷都被定义为簇。在下面的图中,根据可达性变化的陡峭程度定义了一个簇的层次结构,允许簇中有簇。

图 18.7 的替代图

另一种(通常更有用)的方法是在可达性图中定义特定的陡度作为簇开始和结束的指示。我们可以将簇的开始定义为当我们有一个至少这种陡度的下坡时,其结束定义为当我们有一个至少这种陡度的上坡时。我们稍后将使用的方法将陡度定义为 1 – ξ(xi,发音为“zy”,“sigh”或“kzee”,取决于你的偏好和数学老师),其中两个连续案例的可达性必须改变一个 1 – ξ的因子。当我们有一个满足这个陡度标准的下坡时,簇的开始就被定义了;当我们有一个满足这个陡度的上坡时,簇的结束就被定义了。

注意

因为ξ无法从数据中估计出来,它是一个我们必须自己选择/调整的超参数。

使用这种方法有两个主要好处。首先,它使我们能够克服 DBSCAN 只能找到相同密度簇的限制。其次,它使我们能够在簇内找到簇,从而形成层次结构。想象一下,我们有一个开始簇的下坡,然后在簇结束前还有一个另一个下坡:我们有一个簇内的簇。这种从可达性图中提取簇的层次结构在图 18.7 的底部图中显示。

注意

注意,这两种方法都不能直接应用于原始数据。它们会从 OPTICS 算法生成的顺序和可达性分数中提取所有信息来分配簇成员资格。

18.2. 构建第一个 DBSCAN 模型

在本节中,我将向你展示如何使用 DBSCAN 算法对数据集进行聚类。然后,我们将使用你在第十七章(kindle_split_030.html#ch17)中学到的一些技术来验证其性能并选择最佳性能的超参数组合。

注意

mlr 包确实有 DBSCAN 算法的学习器(cluster.dbscan),但我们不会使用它。它没有问题;但正如你稍后将会看到的,噪声簇的存在会给我们内部簇度量带来问题,所以我们将要在 mlr 之外进行自己的性能验证。

18.2.1. 加载和探索纸币数据集

让我们从加载 tidyverse 和加载数据开始,这些数据是 mclust 包的一部分。我们将使用瑞士纸币数据集,我们在第十三章(kindle_split_025.html#ch13)和第十四章(kindle_split_026.html#ch14)中对其应用了 PCA、t-SNE 和 UMAP。一旦我们加载数据,我们将将其转换为 tibble,并在对数据进行缩放后创建一个单独的 tibble(因为 DBSCAN 和 OPTICS 对变量尺度敏感)。因为我们将假设我们没有地面实况,所以我们移除了Status变量,该变量指示哪些纸币是真钞,哪些是假钞。回想一下,tibble 包含 200 张瑞士纸币,有 6 个关于其尺寸的测量值。

列表 18.1. 加载 tidyverse 包和数据集
library(tidyverse)

data(banknote, package = "mclust")

swissTib <- select(banknote, -Status) %>%
  as_tibble()

swissTib

# A tibble: 200 x 6
   Length  Left Right Bottom   Top Diagonal
    <dbl> <dbl> <dbl>  <dbl> <dbl>    <dbl>
 1   215\.  131   131\.    9     9.7     141
 2   215\.  130\.  130\.    8.1   9.5     142.
 3   215\.  130\.  130\.    8.7   9.6     142.
 4   215\.  130\.  130\.    7.5  10.4     142
 5   215   130\.  130\.   10.4   7.7     142.
 6   216\.  131\.  130\.    9    10.1     141.
 7   216\.  130\.  130\.    7.9   9.6     142.
 8   214\.  130\.  129\.    7.2  10.7     142.
 9   215\.  129\.  130\.    8.2  11       142.
10   215\.  130\.  130\.    9.2  10       141.
# ... with 190 more rows

swissScaled <- swissTib %>% scale()

让我们使用 ggpairs() 绘制数据,以提醒自己数据的结构。

列表 18.2. 绘制数据
library(GGally)

ggpairs(swissTib, upper = list(continuous = "density")) +
  theme_bw()

结果图显示在 图 18.8。看起来数据中至少有两个高密度区域,在低密度区域有一些散布的案例。

18.2.2. 调整 epsilon 和 minPts 超参数

在本节中,我将向您展示如何为 DBSCAN 选择合理的 epsilonminPts 范围,以及我们如何手动调整它们以找到最佳性能组合。选择 epsilon 超参数的值可能并不明显。我们应该搜索多远?幸运的是,我们可以使用一种启发式方法来至少得到正确的范围。这包括计算每个点到其第 k 个最近邻的距离,然后根据这个距离在图中对点进行排序。在密度高和密度低区域的数据中,这往往会产生一个包含“拐点”或“肘部”(取决于您的偏好)的图。epsilon 的最佳值就在那个拐点/肘部附近。因为 DBSCAN 中的核心点在其 epsilon 内有 minPts 个案例,所以在该图的拐点选择 epsilon 的值意味着选择一个搜索距离,这将导致高密度区域的案例被视为核心点。我们可以使用 dbscan 包中的 kNNdistplot() 函数创建此图。

图 18.8. 使用 ggpairs() 绘制瑞士银行钞票数据集。2D 密度图显示在对角线以上。

列表 18.3. 绘制 kNN 距离图
library(dbscan)

kNNdistplot(swissScaled, k = 5)

abline(h = c(1.2, 2.0))

我们需要使用 k 参数来指定我们想要计算距离的最近邻的数量。但我们还不知道我们的 minPts 参数应该是多少,所以我们如何设置 k?我通常会选择一个我认为大约正确的合理值(记住 minPts 定义了最小聚类大小):这里,我选择了 5。图中拐点的位置对 k 的变化相对稳健。

kNNdistplot() 函数将创建一个矩阵,其行数与数据集中的案例数(200)相同,有 5 列,每一列代表每个案例与其 5 个最近邻之间的距离。这些 200 × 5 = 1,000 个距离将在图中绘制。

我们随后使用 abline() 函数在膝部的起始和结束位置绘制水平线,以帮助我们确定将要调整的 epsilon 值的范围。生成的图表显示在 图 18.9 中。注意,从左到右读取图表,在最初的急剧增加之后,5 个最近邻的距离仅逐渐增加,直到再次急剧增加。曲线向上弯曲的这个区域是膝部/肘部,在这个弯曲点处的 epsilon 的最佳值。使用这种方法,我们选择 1.2 和 2.0 作为调整 epsilon 的上下限。

图 18.9. k = 5 的 k-最近邻距离图。使用 abline() 绘制了水平线,以突出显示图表中膝部/肘部起始和结束位置的 5-NN 距离。

让我们手动定义我们的 epsilonminPts 的超参数搜索空间。我们使用 expand.grid() 函数创建一个数据框,包含我们想要搜索的 epsilon (eps) 和 minPts 的所有值组合。我们将搜索 1.2 和 2.0 之间的 epsilon 值,步长为 0.1;我们将搜索 1 和 9 之间的 minPts 值,步长为 1。

列表 18.4. 定义我们的超参数搜索空间
dbsParamSpace <- expand.grid(eps = seq(1.2, 2.0, 0.1),
                             minPts = seq(1, 9, 1))

练习 1

打印 dbsParamSpace 对象,以更好地理解 expand .grid() 正在做什么。

现在我们已经定义了我们的超参数搜索空间,让我们对每个不同的 epsilonminPts 组合运行 DBSCAN 算法。为此,我们使用 purrr 包中的 pmap() 函数将 dbscan() 函数应用于 dbsParamSpace 对象的每一行。

列表 18.5. 对每个超参数组合运行 DBSCAN
swissDbs <- pmap(dbsParamSpace, dbscan, x = swissScaled)

swissDbs[[5]]

DBSCAN clustering for 200 objects.
Parameters: eps = 1.6, minPts = 1
The clustering contains 10 cluster(s) and 0 noise points.

  1   2   3   4   5   6   7   8   9  10
  1 189   1   1   1   3   1   1   1   1

Available fields: cluster, eps, minPts

我们将缩放后的数据集作为 dbscan() 的参数 x 的参数。pmap() 的输出是一个列表,其中每个元素都是对特定 epsilonminPts 组合运行 DBSCAN 的结果。要查看特定排列的输出,我们只需对列表进行子集化。

当打印 dbscan() 调用的结果时,输出告诉我们数据中的对象数量,epsilonminPts 的值,以及识别出的簇和噪声点的数量。也许最重要的信息是每个簇内的案例数量。在这个例子中,我们可以看到簇 2 中有 189 个案例,而在大多数其他簇中只有一个案例。这是因为这个排列运行时 minPts 等于 1,这允许簇只包含一个案例。这很少是我们想要的,并且会导致一个聚类模型,其中没有案例被识别为噪声。

现在我们有了聚类结果,我们应该通过视觉检查聚类,看看哪些(如果有的话)排列给出了合理的结果。为此,我们想要从每个排列中提取聚类成员的向量作为一列,然后将这些列添加到我们的原始数据中。

第一步是从 tibble 中提取聚类成员作为单独的列。为此,我们使用 map_dfc() 函数。我们之前已经遇到过 map_df() 函数:它将函数应用于向量的每个元素,并将输出作为 tibble 返回,其中每个输出形成 tibble 的不同行。这实际上与使用 map_dfr() 相同,其中 r 代表行绑定。如果我们想使每个输出形成 tibble 的不同 ,我们则使用 map_dfc()

注意

这里为了节省空间,已截断输出。

列表 18.6. DBSCAN 排列的聚类成员关系
clusterResults <- map_dfc(swissDbs, ~.$cluster)

clusterResults

# A tibble: 200 x 81
      V1    V2    V3    V4    V5    V6    V7    V8    V9   V10   V11
   <int> <int> <int> <int> <int> <int> <int> <int> <int> <int> <int>
 1     1     1     1     1     1     1     1     1     1     0     0
 2     2     2     2     2     2     2     2     2     2     1     1
 3     2     2     2     2     2     2     2     2     2     1     1
 4     2     2     2     2     2     2     2     2     2     1     1
 5     3     3     3     3     3     3     3     3     2     0     0
 6     4     4     4     4     4     2     2     2     2     0     0
 7     5     2     2     2     2     2     2     2     2     0     1
 8     2     2     2     2     2     2     2     2     2     1     1
 9     2     2     2     2     2     2     2     2     2     1     1
10     6     2     2     2     2     2     2     2     2     2     1
# ... with 190 more rows, and 70 more variables

现在我们有了聚类成员的 tibble,让我们使用 bind_cols() 函数来,嗯,绑定 swissTib tibble 和我们的聚类成员 tibble 的列。我们称这个新的 tibble 为 swissClusters,听起来像是一种早餐谷物。请注意,我们有原始变量,以及包含每个排列的聚类成员输出的额外列。

注意

再次,为了节省空间,我稍微截断了输出。

列表 18.7. 将聚类成员绑定到原始数据
swissClusters <- bind_cols(swissTib, clusterResults)

swissClusters

# A tibble: 200 x 87
   Length  Left Right Bottom   Top Diagonal    V1    V2    V3    V4
    <dbl> <dbl> <dbl>  <dbl> <dbl>    <dbl> <int> <int> <int> <int>
 1   215\.  131   131\.    9     9.7     141      1     1     1     1
 2   215\.  130\.  130\.    8.1   9.5     142\.     2     2     2     2
 3   215\.  130\.  130\.    8.7   9.6     142\.     2     2     2     2
 4   215\.  130\.  130\.    7.5  10.4     142      2     2     2     2
 5   215   130\.  130\.   10.4   7.7     142\.     3     3     3     3
 6   216\.  131\.  130\.    9    10.1     141\.     4     4     4     4
 7   216\.  130\.  130\.    7.9   9.6     142\.     5     2     2     2
 8   214\.  130\.  129\.    7.2  10.7     142\.     2     2     2     2
 9   215\.  129\.  130\.    8.2  11       142\.     2     2     2     2
10   215\.  130\.  130\.    9.2  10       141\.     6     2     2     2
# ... with 190 more rows, and 77 more variables

为了绘制结果,我们希望按排列进行分面,这样我们就可以为超参数的每个组合绘制一个单独的子图。为此,我们需要 gather() 数据以创建一个新列,指示排列编号,另一个列指示聚类编号。

列表 18.8. 收集数据,准备绘图
swissClustersGathered <- gather(swissClusters,
                                key = "Permutation", value = "Cluster",
                                -Length, -Left, -Right,
                                -Bottom, -Top, -Diagonal)
swissClustersGathered

# A tibble: 16,200 x 8
   Length  Left Right Bottom   Top Diagonal Permutation Cluster
    <dbl> <dbl> <dbl>  <dbl> <dbl>    <dbl> <chr>         <int>
 1   215\.  131   131\.    9     9.7     141  V1                1
 2   215\.  130\.  130\.    8.1   9.5     142\. V1                2
 3   215\.  130\.  130\.    8.7   9.6     142\. V1                2
 4   215\.  130\.  130\.    7.5  10.4     142  V1                2
 5   215   130\.  130\.   10.4   7.7     142\. V1                3
 6   216\.  131\.  130\.    9    10.1     141\. V1                4
 7   216\.  130\.  130\.    7.9   9.6     142\. V1                5
 8   214\.  130\.  129\.    7.2  10.7     142\. V1                2
 9   215\.  129\.  130\.    8.2  11       142\. V1                2
10   215\.  130\.  130\.    9.2  10       141\. V1                6
# ... with 16,190 more rows

太好了——现在我们的 tibble 已经处于绘图格式。回顾 图 18.8,我们可以看到最明显区分数据中聚类的变量是 RightDiagonal。因此,我们将这些变量分别映射到 x 和 y 美学,以相互绘制。我们将 Cluster 变量映射到颜色美学(将其包裹在 as.factor() 中,以便颜色不会绘制成单一渐变)。然后按 Permutation 分面,添加 geom_point() 层,并添加一个主题。由于一些聚类模型有大量的聚类,我们通过添加 theme(legend.position = "none") 行来抑制绘制非常大的图例。

列表 18.9. 绘制排列的聚类成员关系
ggplot(swissClustersGathered, aes(Right, Diagonal,
                                  col = as.factor(Cluster))) +
  facet_wrap(~ Permutation) +
  geom_point() +
  theme_bw() +
  theme(legend.position = "none")
小贴士

theme() 函数允许您控制图表的外观(例如更改背景颜色、网格线、字体大小等)。要了解更多信息,请调用 ?theme

结果图显示在图 18.10 中。我们可以看到,不同的epsilonminPts组合导致了实质上不同的聚类模型。许多这些模型捕捉到了数据集中的两个明显的聚类,但大多数没有。

练习 2

让我们还将可视化每个排列返回的聚类数量和大小。将我们的swissClustersGathered对象传递给ggplot(),并使用以下美学映射:

  • x = reorder(Permutation, Cluster)

  • y = fill = as.factor(Cluster)

还添加一个geom_bar()层。现在再次绘制相同的图表,但这次添加一个coord_polar()层。将 x 美学映射仅设置为Permutation。你能看到reorder()函数做了什么吗?

图 18.10。可视化我们的调整实验的结果。每个子图显示了epsilonminPts的不同排列下,RightDiagonal变量相互之间的绘图。案例根据其聚类成员进行着色。

图 18-10

我们将如何选择表现最佳的epsilonminPts组合?嗯,正如我们在第十七章中看到的,确保聚类是有意义的视觉检查很重要,但我们还可以计算内部聚类指标以帮助我们的选择。

在第十七章中,我们定义了自己的函数,该函数将接受聚类模型中的数据和聚类成员,并计算 Davies-Bouldin 和 Dunn 指数以及伪 F 统计量。让我们重新定义这个函数以刷新你的记忆。

列表 18.10。定义cluster_metrics()函数
cluster_metrics <- function(data, clusters, dist_matrix) {
  list(db   = clusterSim::index.DB(data, clusters)$DB,
       G1   = clusterSim::index.G1(data, clusters),
       dunn = clValid::dunn(dist_matrix, clusters),
       clusters = length(unique(clusters))
  )
}

为了帮助我们选择哪个聚类模型最能捕捉数据中的结构,我们将从我们的数据集中抽取自举样本,并对每个自举样本使用所有 81 种epsilonminPts的组合来运行 DBSCAN。然后我们可以计算每个性能指标的均值,并查看它们的稳定性。

注意

从第十七章回顾,自举样本是通过从原始数据中抽取案例(有放回)来创建的,以创建一个与原始样本大小相同的新样本。

让我们从我们的swissScaled数据集中生成 10 个自举样本开始。我们这样做就像在第十七章中做的那样,使用sample_n()函数,并将replace参数设置为TRUE

|
swissBoot <- map(1:10, ~ {
  swissScaled %>%
    as_tibble() %>%
    sample_n(size = nrow(.), replace = TRUE)
})

在我们运行调整实验之前,DBSCAN 在计算内部聚类度量时可能存在潜在问题。正如我们在第十六章中讨论的那样,这些度量通过比较簇之间的分离和簇内的扩散(无论它们如何定义这些概念)来工作。思考一下噪声簇,以及它将如何影响这些度量。因为噪声簇不是一个占据特征空间一个区域的独立簇,而是通常分布在整个特征空间中,它对内部聚类度量的影响可能会使度量难以解释和比较。因此,一旦我们得到聚类结果,我们将移除噪声簇,这样我们就可以只使用非噪声簇来计算我们的内部聚类度量。

注意

这并不意味着在评估 DBSCAN 模型性能时考虑噪声簇不重要。理论上,两个聚类模型可能给出相同的聚类度量,但一个模型可能将案例放置在噪声簇中,而你认为这些案例很重要。因此,你应该始终视觉评估你的簇结果(包括噪声案例),特别是当你对你的任务有领域知识时。

在下面的列表中,我们在 bootstrap 样本上运行调整实验。代码相当长,所以我们将一步一步地讲解。

列表 18.12. 执行调整实验
metricsTib <- map_df(swissBoot, function(boot) {
  clusterResult <- pmap(dbsParamSpace, dbscan, x = boot)

  map_df(clusterResult, function(permutation) {
    clust <- as_tibble(permutation$cluster)
    filteredData <- bind_cols(boot, clust) %>%
      filter(value != 0)

    d <- dist(select(filteredData, -value))

    cluster_metrics(select(filteredData, -value),
                    clusters = filteredData$value,
                    dist_matrix = d)
  })
})

首先,我们使用map_df()函数,因为我们希望将匿名函数应用于每个 bootstrap 样本,并将结果行绑定到一个 tibble 中。我们使用pmap()函数,通过dbsParamSpace中的每个epsilonminPts的组合来运行 DBSCAN 算法,就像我们在列表 18.5 中所做的那样。

现在簇结果已经生成,代码的下一部分将cluster_metric()函数应用于每个epsilonminPts的排列。同样,我们希望它返回一个 tibble,所以我们使用map_df()来迭代匿名函数,遍历clusterResult中的每个元素。

我们首先从每个排列中提取簇成员资格,将其转换为 tibble(单列),并使用bind_cols()函数将簇成员资格的这一列粘接到 bootstrap 样本上。然后,我们将这个结果传递到filter()函数中,以移除属于噪声簇(簇 0)的案例。因为 Dunn 指数需要一个距离矩阵,所以我们接下来定义距离矩阵d,使用过滤后的数据。

在这一点上,对于特定 bootstrap 样本中特定epsilonminPts的排列,我们有一个包含缩放变量和簇成员资格列的 tibble(单列)。然后,我们将这个 tibble 传递给我们的cluster_metrics()函数(对于第一个参数移除value变量,并从第二个参数提取它)。我们将距离矩阵作为dist_matrix参数传递。

呼!这需要相当多的专注力。我强烈建议你重新阅读代码,确保每一行对你来说都有意义。打印出 metricsTib tibble。我们最终得到一个包含四个列的 tibble:每个内部聚类指标一个,还有一个包含聚类数量的列。每一行包含单个 DBSCAN 模型的结果,总共 810 个(81 个 epsilonminPts 的排列组合,以及每个组合的 10 个 bootstrap 样本)。

现在我们已经完成了调优实验,评估结果的最简单方法就是绘制它们。

列表 18.13. 准备绘图调优结果
metricsTibSummary <- metricsTib %>%
  mutate(bootstrap = factor(rep(1:10, each = 81)),
         eps = factor(rep(dbsParamSpace$eps, times = 10)),
         minPts = factor(rep(dbsParamSpace$minPts, times = 10))) %>%

  gather(key = "metric", value = "value",
         -bootstrap, -eps, -minPts) %>%

  mutate_if(is.numeric, ~ na_if(., Inf)) %>%
  drop_na() %>%

  group_by(metric, eps, minPts) %>%
  summarize(meanValue = mean(value),
            num = n()) %>%
  group_by(metric) %>%
  mutate(meanValue = scale(meanValue)) %>%
  ungroup()

我们首先需要 mutate() 指示特定案例使用了哪个 bootstrap、使用了哪个 epsilon 值以及使用了哪个 minPts 值的列。阅读到 列表 18.13 的第一行换行处以查看这一点。

接下来,我们需要收集数据,以便有一个列指示行表示我们四个指标中的哪一个,这样我们就可以按每个指标进行分面。我们使用 gather() 函数在 列表 18.13 的第二次换行之前完成此操作。

在这一点上,我们遇到了一个问题。一些聚类模型只包含一个聚类。为了返回一个合理的值,我们的三个内部聚类指标需要至少有两个聚类。当我们将 cluster_metrics() 函数应用于聚类模型时,对于只包含一个聚类的任何模型,函数将返回 Davies-Bouldin 指数和伪 F 统计量的 NA 值,以及 Dunn 指数的 INF 值。

小贴士

运行 map_int(metricsTib, ~sum(is.na(.)))map_int(metricsTib, ~sum(is.infinite(.))) 以确认这一点。

因此,让我们从我们的 tibble 中移除 INFNA 值。我们首先将 INF 值转换为 NA。我们使用 mutate_if() 函数仅考虑数值变量(我们也可以使用 mutate_at(.vars = "value", ...)),并使用 na_if() 函数将当前为 INF 的值转换为 NA。然后我们将这个操作通过管道传递到 drop_na() 以一次性移除所有 NA 值。

最后,为了为每个指标生成平均值,对于每个 epsilonminPts 的组合,我们首先按 metricepsminPts 进行 group_by(),然后对 value 变量的平均值和数量进行 summarize()。由于指标处于不同的尺度上,我们接着按 metric 进行 group_by(),对 meanValue 变量进行 scale(),然后进行 ungroup()

这是一些严肃的 dplyring!再次提醒,不要只是匆匆浏览这段代码。从头开始,重新阅读 列表 18.13 以确保你理解它。同时,请放心,我并不是第一次就写出了所有这些;我知道我想要达到的目标,并且我逐行解决了问题。在每一步,我都会查看输出以确保我所做的是正确的,并确定下一步需要做什么。打印出 metricsTibSummary 以了解我们最终得到的结果。

太棒了。现在我们的调整数据已经以正确的格式,让我们来绘制它。我们将创建一个热图,其中 epsilonminPts 被映射到 x 和 y 视觉效果,指标值被映射到热图中每个瓷砖的填充。每个指标将有一个单独的子图。此外,因为我们移除了包含 NAINF 值的行,所以 epsilonminPts 的某些组合的自举样本数量少于 10。为了帮助我们选择超参数,我们将每个组合的样本数量映射到 alpha 视觉效果(透明度),因为我们可能对具有较少自举样本的超参数组合的信心较低。我们将在下面的列表中完成所有这些操作。

列表 18.14. 调整实验结果的绘图
ggplot(metricsTibSummary, aes(eps, minPts,
                              fill = meanValue, alpha = num)) +
  facet_wrap(~ metric) +
  geom_tile(col = "black") +
  theme_bw() +
  theme(panel.grid.major = element_blank())

除了将 num 变量映射到 alpha 视觉效果之外,这里唯一的新事物是 geom_tile(),它将为 x 和 y 变量的每个组合创建矩形瓷砖。将 col = "black" 设置为简单地围绕每个单独的瓷砖绘制黑色边框。为了防止绘制主要网格线,我们添加了层 theme(panel.grid.major = element_blank())

结果图表显示在图 18.11。我们有四个子图:一个用于我们三个内部聚类指标中的每一个,一个用于聚类数量。每个内部指标子图的右上角有一个孔,显示了超参数调整空间这个区域只产生了一个聚类(我们移除了这些值)。围绕孔的瓷砖是半透明的,因为这些组合的 epsilonminPts 的某些自举样本只产生了一个聚类,因此被移除。

图 18.11. 可视化聚类性能实验。每个子图显示了聚类模型返回的聚类数量、Davies-Bouldin 指数 (db)、Dunn 指数 (dunn) 和伪 F 统计量 (G1) 的热图。每个瓷砖代表 epsilonminPts 的组合,瓷砖的阴影深度表示其每个指标的价值。指标图右上角的空白区域表示没有数据,半透明瓷砖表示样本数量少于 10。

图片

注意

你的图表看起来和我有点不同?这是因为我们用来创建自举样本的随机抽样。然而,应该存在一个类似的模式。

让我们使用这个图表来指导我们最终选择 epsilonminPts。这并不一定简单,因为没有单一、明显的组合是所有三个内部指标都同意的。首先,让我们避免图表中的洞附近或其中的组合——我认为这是一个很明确的起点。接下来,让我们提醒自己,从理论上讲,最佳的聚类模型将是具有最低的 Davies-Bouldin 指数、最大的 Dunn 指数和伪 F 统计量的模型。因此,我们正在寻找一个最能满足这些标准的组合。考虑到这一点,在继续阅读之前,请查看图表并尝试决定你会选择哪个组合。

我想我会选择 epsilon 为 1.2,minPts 为 9。你能看到,在这个值组合(每个子图的左上角)中,Dunn 和伪 F 统计量接近最高,而 Davies-Bouldin 指数处于最低。让我们找出 dbsParamSpace tibble 中哪个行对应于这个值组合:

which(dbsParamSpace$eps == 1.2 & dbsParamSpace$minPts == 9)

[1] 73

接下来,让我们使用 ggpairs() 绘制最终的聚类。因为我们计算了内部聚类度量,没有考虑噪声聚类,所以我们将结果绘制为有噪声和无噪声案例的图表。这将使我们能够直观地确认将案例分配为噪声是否合理。

列表 18.15. 绘制带异常值的最终聚类
filter(swissClustersGathered, Permutation == "V73") %>%
  select(-Permutation) %>%
  mutate(Cluster = as.factor(Cluster)) %>%
  ggpairs(mapping = aes(col = Cluster),
          upper = list(continuous = "density")) +
  theme_bw()

我们首先过滤 swissClustersGathered tibble,只包含属于排列 73 的行(这些是使用我们选择的 epsilonminPts 组合进行聚类的案例)。接下来,我们删除表示排列编号的列,并将聚类成员资格的列转换为因子。然后,我们使用 ggpairs() 函数创建图表,将聚类成员资格映射到颜色美学。

图 18.12. 使用 ggpairs() 绘制我们的最终 DBSCAN 聚类模型。此图包括噪声聚类。

图片

结果图表显示在图 18.12 中。模型似乎很好地捕捉到了数据集中的两个明显聚类。相当多的案例被分类为噪声。这是否合理将取决于你的目标和你想有多严格。如果你认为将更少的案例放在噪声聚类中很重要,你可能想选择不同的 epsilonminPts 组合。这就是为什么仅仅依赖指标是不够好的:在可用的情况下,应始终考虑专家/领域知识。

现在,让我们做同样的事情,但不绘制异常值。这里我们做的唯一改变是在 filter() 调用中添加 Cluster != 0

列表 18.16. 绘制不带异常值的最终聚类
filter(swissClustersGathered, Permutation == "V73", Cluster != 0) %>%
  select(-Permutation) %>%
  mutate(Cluster = as.factor(Cluster)) %>%
  ggpairs(mapping = aes(col = Cluster),
          upper = list(continuous = "density")) +
  theme_bw()

结果图表显示在图 18.13 中。查看这个图表,我们可以看到 DBSCAN 模型识别的两个聚类非常整洁且分离良好。

图 18.13. 使用 ggpairs() 绘制我们的最终 DBSCAN 聚类模型。此图排除了噪声聚类。

图片

警告

确保你总是查看你的异常值。当移除异常值时,DBSCAN 可能会使聚类看起来比实际更重要。

我们的聚类模型看起来相当合理,但它有多稳定?为了评估我们的 DBSCAN 模型的表现,我们将要做的最后一件事是计算多个自助样本中的 Jaccard 指数。回想一下第十七章(kindle_split_030.html#ch17),Jaccard 指数量化了在不同自助样本上训练的聚类模型之间聚类成员资格的一致性。

要做到这一点,我们首先需要加载 fpc 包。然后我们使用clusterboot()函数,就像我们在第十七章中做的那样。第一个参数是我们将要聚类的数据(我们的缩放 tibble),B是自助样本的数量(越多越好,取决于您的计算预算),clustermethod = dbscanCBI告诉函数使用 DBSCAN 算法。然后我们设置epsilonMinPts(注意:这次是大写的M),并将showplots = FALSE设置为避免绘制 500 个图表。

注意

我已截断输出以显示最重要的信息。

列表 18.17. 在自助样本中计算 Jaccard 指数
library(fpc)

clustBoot <- clusterboot(swissScaled, B = 500,
                         clustermethod = dbscanCBI,
                         eps = 1.2, MinPts = 9,
                         showplots = FALSE)

clustBoot

Number of resampling runs:  500

Number of clusters found in data:  3

Clusterwise Jaccard bootstrap (omitting multiple points) mean:
[1] 0.6893 0.8074 0.6804

我们可以看到三个聚类(其中聚类 3 令人困惑地是噪声聚类)的 Jaccard 指数。聚类 2 的稳定性相当高:原始聚类 2 中有 80.7%的案例在自助样本中达成一致。聚类 1 和 3 的稳定性较低,大约有 68%的一致性。

我们现在已经以三种方式评估了我们的 DBSCAN 模型的表现:使用内部聚类指标,检查聚类的外观,以及使用 Jaccard 指数来评估它们的稳定性。对于任何特定的聚类问题,您需要将所有这些证据综合起来,以决定您的聚类模型是否适合当前的任务。

练习 3

使用dbscan()swissScaled数据集进行聚类,将epsilon设置为 1.2,但将minPts设置为 1。噪声聚类中有多少案例?为什么?fpc包也有一个dbscan()函数,所以使用dbscan::dbscan()来使用 dbscan 包中的函数。

18.3. 构建第一个 OPTICS 模型

在本节中,我将向您展示我们如何使用 OPTICS 算法对数据集中的案例进行排序,以及我们如何从这个排序中提取聚类。我们将直接比较使用 OPTICS 得到的结果和使用 DBSCAN 得到的结果。

要做到这一点,我们将使用来自 dbscan 包的optics()函数。第一个参数是数据集;与 DBSCAN 一样,OPTICS 对变量尺度很敏感,所以我们使用我们的缩放 tibble。

列表 18.18. 使用 OPTICS 对案例进行排序并提取聚类
swissOptics <- optics(swissScaled, minPts = 9)

plot(swissOptics)

就像dbscan()函数一样,optics()epsminPts参数。因为epsilon是 OPTICS 算法的可选参数,并且仅用于加速计算,我们将它保留为默认的NULL,这意味着没有最大epsilon。我们将minPts设置为 9,以匹配我们最终 DBSCAN 模型中使用的值。

一旦我们创建了我们的顺序,我们可以通过在optics()函数的输出上调用plot()来检查可达性图;参见图 18.14。注意,我们有两个明显分离的低谷,由高峰值隔开。记住,这表明在特征空间中由低密度区域分隔的高密度区域。

图 18.14。从应用 OPTICS 算法到我们的数据生成的可达性图。x 轴显示案例的处理顺序,y 轴显示每个案例的可达距离。我们可以看到图中有两个主要低谷,由更高的可达距离峰值所包围。

现在,让我们使用陡度法从这个顺序中提取簇。为此,我们使用extractXi()函数,将optics()函数的输出作为第一个参数传递,并指定xi参数:

swissOpticsXi <- extractXi(swissOptics, xi = 0.05)

回想一下,xi(ξ)是一个超参数,它决定了在可达性图中开始和结束簇所需的最小陡度(1 – ξ)。我们如何选择ξ的值?嗯,在这个例子中,我简单地选择了一个能给出合理聚类结果的ξ值(你很快就会看到)。正如我们所知,这不是一个非常科学或客观的方法;对于你自己的工作,你应该像我们对epsilonminPts进行 DBSCAN 调整一样,将ξ作为超参数进行调整。

注意

ξ超参数介于 0 和 1 之间,因此这为您在搜索空间内提供了一个固定的范围。

让我们绘制聚类结果,以便我们可以将其与我们的 DBSCAN 模型进行比较。我们在数据集中添加一个新列,包含我们使用陡度法提取的簇。然后我们将这些数据通过管道传递到ggpairs()函数。

列表 18.19。绘制 OPTICS 簇
swissTib %>%
  mutate(cluster = factor(swissOpticsXi$cluster)) %>%
  ggpairs(mapping = aes(col = cluster),
          upper = list(continuous = "points")) +
  theme_bw()
注意

因为我们只有一个噪声案例,这导致密度图的计算失败。因此,我们将上部分设置为简单地显示"points"而不是密度。

结果图显示在图 18.15 中。我们的 OPTICS 聚类主要识别出与 DBSCAN 相同的两个簇,但还识别出一个似乎分布在特征空间中的额外簇。这个额外的簇对我来说并不令人信服(但我们可以计算簇内指标和簇稳定性来加强这个结论)。为了改进聚类,我们应该调整minPts和ξ超参数,尽管我们在这里不会这样做。

你已经学会了如何使用 DBSCAN 和 OPTICS 算法来聚类你的数据。在下一章中,我将向你介绍 混合模型聚类,这是一种聚类技术,它将一组模型拟合到数据中,并将案例分配给最可能的模型。我建议你保存你的 .R 文件,因为我们将继续在下一章中使用同一个数据集。这样我们可以比较我们的 DBSCAN 和 OPTICS 模型的性能与我们的混合模型输出的结果。

图 18.15. 使用 ggpairs() 绘制我们的最终 OPTICS 聚类模型

18.4. 基于密度的聚类的优缺点

虽然通常不容易判断哪些算法会对给定的任务表现良好,但以下是一些优势和劣势,这将帮助你决定基于密度的聚类是否适合你。

基于密度的聚类的优点如下:

  • 它可以识别不同直径的非球形聚类。

  • 它能够原生地识别异常案例。

  • 它可以识别复杂、非球形的聚类。

  • OPTICS 能够学习层次聚类结构,并且不需要调整 epsilon

  • OPTICS 能够找到不同密度的聚类。

  • 通过设置合理的 epsilon 值可以加快 OPTICS 的速度。

基于密度的聚类的缺点如下:

  • 它不能原生地处理分类变量。

  • 这些算法不能自动选择最佳聚类数量。

  • 它对数据的不同尺度敏感。

  • DBSCAN 倾向于寻找密度相等的聚类。

练习题 4

使用 dbscan() 对未缩放的 swissTib 数据集进行聚类,将 epsilon 设置为 1.2,将 minPts 设置为 9。这些聚类是否相同?为什么?

练习题 5

从我们的 swissOptics 对象中提取聚类,使用 xi 值 0.035、0.05 和 0.065。使用 plot() 来查看这些不同的值如何改变从可达性图中提取的聚类。

摘要

  • 基于密度的聚类算法,如 DBSCAN 和 OPTICS,通过在特征空间中搜索由低密度区域分隔的高密度区域来找到聚类。

  • DBSCAN 有两个超参数,epsilonminPts,其中 epsilon 是每个案例周围的搜索半径。如果一个案例在其 epsilon 范围内有 minPts 个案例,那么这个案例就是一个核心点。

  • DBSCAN 递归地扫描任何聚类中从起始案例开始的与所有案例密度连接的 epsilon,将案例分类为核心点或边界点。

  • DBSCAN 和 OPTICS 为那些离高密度区域太远的案例创建一个噪声聚类。

  • OPTICS 通过创建一个案例的顺序来提取聚类,这个顺序可以可视化为可达性图,其中峰谷之间的低谷表示聚类。

练习题的解答

  1. 打印 expand.grid() 的结果,并检查结果以了解该函数的功能:

    dbsParamSpace
    
    # The function creates a data frame whose rows make up
    # every combination of the input vectors.
    
  2. 绘制调整实验图,以可视化每个排列的簇的数量和大小:

    ggplot(swissClustersGathered, aes(reorder(Permutation, Cluster),
                 fill = as.factor(Cluster))) +
      geom_bar(position = "fill", col = "black") +
      theme_bw() +
      theme(legend.position = "none")
    
    ggplot(swissClustersGathered, aes(reorder(Permutation, Cluster),
                                      fill = as.factor(Cluster))) +
      geom_bar(position = "fill", col = "black") +
      coord_polar() +
      theme_bw() +
      theme(legend.position = "none")
    
    ggplot(swissClustersGathered, aes(Permutation,
                                      fill = as.factor(Cluster))) +
      geom_bar(position = "fill", col = "black") +
      coord_polar() +
      theme_bw() +
      theme(legend.position = "none")
    
    # The reorder function orders the levels of the first argument
    # according to the values of the second argument.
    
  3. 使用dbscan(),设置epsilon为 1.2 和minPts为 1:

    swissDbsNoOutlier <- dbscan::dbscan(swissScaled, eps = 1.2, minPts = 1)
    
    swissDbsNoOutlier
    
    # There are no cases in the noise cluster because the minimum cluster
    # size is now 1, meaning all cases are core points.
    
  4. 使用dbscan()对未缩放的数据进行聚类:

    swissDbsUnscaled <- dbscan::dbscan(swissTib, eps = 1.2, minPts = 9)
    
    swissDbsUnscaled
    
    # The clusters are not the same as those learned for the scaled data.
    # This is because DBSCAN and OPTICS are sensitive to scale differences.
    
  5. 使用不同的xi值从swissOptics中提取不同的簇:

    swissOpticsXi035 <- extractXi(swissOptics, xi = 0.035)
    plot(swissOpticsXi035)
    
    swissOpticsXi05 <- extractXi(swissOptics, xi = 0.05)
    plot(swissOpticsXi05)
    
    swissOpticsXi065 <- extractXi(swissOptics, xi = 0.065)
    plot(swissOpticsXi065)
    

第十九章。基于混合建模的分布聚类

本章涵盖

  • 理解混合模型聚类

  • 理解硬聚类和软聚类的区别

在无监督学习技术的最后,我们来到了寻找数据中簇的另一种额外方法:混合模型聚类。就像我们之前讨论的其他聚类算法一样,混合模型聚类旨在将数据集划分为有限个簇。

在第十八章中,我向你展示了 DBSCAN 和 OPTICS 算法,以及它们如何通过学习特征空间中高密度和低密度的区域来找到簇。混合模型聚类采用另一种方法来识别簇。混合模型是任何通过结合两个或更多概率分布来描述数据集的模型。在聚类的上下文中,混合模型通过将有限数量的概率分布拟合到数据,并迭代修改这些分布的参数,直到它们最好地拟合底层数据来帮助我们识别簇。然后,案例被分配到它们最有可能属于的分布的簇中。混合建模最常见的形式是高斯混合建模,它将高斯(或正态)分布拟合到数据。

到本章结束时,我希望你能对混合模型聚类的工作原理及其与我们已经讨论的一些算法的异同有一个牢固的理解。我们将应用这种方法来处理第十八章中的瑞士纸币数据(第十八章),以帮助你理解混合模型聚类与基于密度的聚类有何不同。如果你在你的全局环境中不再定义swissTib对象,只需重新运行列表 18.1。

19.1. 什么是混合模型聚类?

在本节中,我将向您展示混合模型聚类是什么,以及它是如何使用一个称为期望最大化的算法来迭代地改进聚类模型的拟合。我们之前遇到的聚类算法都被认为是硬聚类方法,因为每个案例完全分配给一个簇,而不是另一个簇。混合模型聚类的优势之一是它是一种软聚类方法:它将一组概率模型拟合到数据中,并为每个案例分配属于每个模型的概率。这使得我们可以量化每个案例属于每个簇的概率。因此,我们可以这样说:“这个案例有 90%的概率属于簇 A,有 9%的概率属于簇 B,有 1%的概率属于簇 C。”这很有用,因为它为我们提供了做出更好决策所需的信息。比如说,一个案例有 51%的概率属于一个簇,有 49%的概率属于另一个簇,我们有多高兴将其包含在其最可能的簇中?也许我们并不足够自信将这些案例包含在我们的最终聚类模型中。

注意

混合模型聚类本身并不像 DBSCAN 和 OPTICS 那样识别异常案例,但如果我们愿意,我们可以手动设置一个概率截止值。例如,我们可以说,任何属于其最可能簇的概率低于 60%的案例应被视为异常。

因此,混合模型聚类将一组概率模型拟合到数据中。这些模型可以是各种概率分布,但最常见的是高斯分布。这种聚类方法被称为混合模型,因为我们拟合多个(混合的)概率分布到数据中。因此,高斯混合模型简单地说就是拟合多个高斯分布到一组数据中的模型。

混合模型中的每个高斯分布代表一个潜在的簇。一旦我们的高斯混合模型尽可能好地拟合了数据,我们就可以计算每个案例属于每个簇的概率,并将案例分配给最可能的簇。但我们是怎样找到拟合底层数据的高斯混合模型呢?我们可以使用一个称为期望最大化(EM)的算法。

19.1.1. 使用 EM 算法计算概率

在本节中,我将向您介绍您需要了解的一些先验知识,以便理解 EM 算法。这主要关注算法如何计算每个案例来自每个高斯分布的概率。

想象一下,我们有一个一维数据集:一个分布有案例的数线(参见图 19.1 的上部分)。首先,我们必须预先定义在数据中要寻找的簇的数量;这设置了我们将要拟合的高斯分布的数量。在这个例子中,让我们假设我们相信数据集中存在两个簇。

图 19.1.两个一维高斯分布的期望最大化算法。点代表数轴上的案例。沿线随机初始化两个高斯分布。在期望步骤中,计算每个案例对于每个高斯分布的后验概率(用阴影表示)。在最大化步骤中,根据计算的后验概率更新每个高斯分布的均值、方差和先验概率。这个过程一直持续到似然收敛。

fig19-1_alt.jpg

注意

这是混合模型聚类与 k-means 相似的一种方式。我将在本章后面展示它们相似的其他方式。

一维高斯分布需要两个参数来定义它:均值和方差。因此,我们通过选择它们的均值和方差的随机值,在数轴上随机初始化两个高斯分布。让我们称这些高斯分布为 jk。然后,给定这两个高斯分布,我们计算每个案例属于一个簇而不是另一个簇的概率。为此,我们可以使用我们的好朋友,贝叶斯定理。

回想一下第六章,我们可以使用贝叶斯定理来计算给定似然(p(x|k))、先验(p(k))和证据(p(x))的事件的后验概率(p(k|x))。

方程 19.1

eq19-1.jpg

在这种情况下,p(k|x)是案例 x 属于高斯分布 k 的概率;p(x|k)是在从高斯分布 k 中采样时观察到案例 x 的概率;p(k)是随机选择的案例属于高斯分布 k 的概率;p(x)是从整个混合模型中采样时抽取案例 x 的概率。因此,证据p(x)是从任一高斯分布中抽取案例 x 的概率。

当计算一个事件或另一个事件发生的概率时,我们只需将每个事件独立发生的概率相加。因此,从高斯分布 jk 中抽取案例 x 的概率是从高斯分布 j 中抽取的概率加上从高斯分布 k 中抽取的概率。从高斯分布中抽取案例 x 的概率是似然乘以该高斯分布的先验概率。考虑到这一点,我们可以更完整地写出贝叶斯定理,如方程 19.2 所示。

方程 19.2

eq19-2.jpg

注意,证据已经被扩展,更具体地显示了从任一高斯分布中抽取案例 x[i] 的概率是独立抽取的概率之和。方程 19.2 使我们能够计算案例 x[i] 属于高斯分布 k 的后验概率。方程 19.3 展示了相同的计算,但针对案例 x[i] 属于高斯分布 j 的后验概率。

方程 19.3

eq19-3.jpg

到目前为止,一切顺利。但我们如何计算似然和先验概率呢?似然是高斯分布的概率密度函数,它告诉我们从具有特定均值和方差组合的高斯分布中抽取具有特定值的案例的相对概率。高斯分布 k 的概率密度函数在方程式 19.4 中显示,但不需要你记住它:

方程式 19.4。

eq19-4.jpg

其中 μ[k]pg457.jpg 分别是高斯 k 的均值和方差。

在算法开始时,先验概率是随机生成的,就像高斯分布的均值和方差一样。这些先验概率在每次迭代中都会更新,成为每个高斯分布后验概率的总和除以案例数。你可以将这视为所有案例中特定高斯分布的后验概率均值。

19.1.2. EM 算法的期望和最大化步骤

现在你已经拥有了理解后验概率如何计算的知识,让我们看看 EM 算法如何迭代地拟合混合模型。EM 算法(正如其名称所暗示的)有两个步骤:期望和最大化。期望步骤是计算每个案例、每个高斯分布的后验概率。这可以在图 19.1 从上数第二部分中看到。

在这个阶段,算法使用我们之前设定的贝叶斯定理来计算后验概率。图 19.1 中数轴上的案例被阴影覆盖,以表示它们的后验概率。

接下来是最大化步骤。最大化步骤的任务是更新混合模型的参数,以最大化潜在数据的似然。这意味着更新高斯分布的均值、方差和先验概率。

更新特定高斯分布的均值涉及将每个案例的值加起来,并乘以它们对该高斯分布的后验概率,然后除以所有后验概率的总和。这可以在方程式 19.5 中看到。

方程式 19.5。

eq19-5.jpg

想想看。接近分布均值的案例将具有该分布的高后验概率,因此会对更新的均值贡献更多。远离分布的案例将具有较小的后验概率,对更新的均值贡献较少。结果是高斯分布会向在此高斯分布下最可能的案例均值移动。你可以在图 19.1 的第三部分中看到这一过程的说明。

每个高斯分布的方差以类似的方式更新。我们计算每个案例与高斯均值之间的平方差,乘以该案例的后验概率,然后除以后验概率的总和。这可以在方程 19.6 中看到。结果是,高斯分布将根据在此高斯下最可能案例的分布范围变宽或变窄。你还可以在图 19.1 的第三部分中看到这一点的说明。

方程 19.6.

eq19-6

最后要更新的是每个高斯分布的先验概率。如前所述,新的先验是通过将特定高斯的后验概率总和除以案例数量来计算的,如方程 19.7 所示。这意味着对于许多案例具有大后验概率的高斯分布,其先验概率将很大。

相反,对于很少案例具有大后验概率的高斯分布,其先验概率将很小。你可以将其视为将先验设置为属于每个高斯案例比例的软或概率等价物。

方程 19.7.

eq19-7

一旦最大化步骤完成,我们进行期望步骤的另一次迭代,这次计算每个案例在新高斯下的后验概率。完成此操作后,我们再次运行最大化步骤,再次根据后验更新每个高斯分布的均值、方差和先验。这种期望-最大化循环迭代进行,直到达到指定的迭代次数或模型下数据的整体似然变化小于指定的量(称为收敛)。

19.1.3. 如果我们有多于一个变量呢?

在本节中,我们将扩展你关于一维 EM 算法工作原理的知识,将其扩展到多维聚类。遇到一元(一维)聚类问题的情况很少。通常,我们的数据集包含多个变量,我们希望使用这些变量来识别聚类。我在上一节中限制了高斯混合模型 EM 算法的解释,因为一元高斯只有两个参数:其均值和方差。当我们有一个多维高斯分布(多元高斯)时,我们需要使用其质心和其协方差矩阵来描述它。

我们在之前的章节中遇到了质心:质心简单地是一个均值向量,对于数据集中的每个维度/变量都有一个。协方差矩阵是一个方阵,其元素是变量之间的协方差。例如,协方差矩阵的第二行第三列的值表示数据中变量 2 和变量 3 之间的协方差。协方差是衡量两个变量一起变化的非标准化度量。正协方差表示当一个变量增加时,另一个变量也增加。负协方差表示当一个变量增加时,另一个变量减少。零协方差通常表示变量之间没有关系。我们可以使用方程 19.8 计算两个变量之间的协方差。

方程 19.8。

eq19-8.jpg

注意

虽然协方差是两个变量之间关系的非标准化度量,但相关系数是两个变量之间关系的标准化度量。我们可以通过除以变量标准差的乘积将协方差转换为相关系数。

协方差矩阵中一个变量与自身的协方差简单地是该变量的方差。因此,协方差矩阵的对角线元素是每个变量的方差。

小贴士

由于这个原因,协方差矩阵通常被称为方差-协方差矩阵。

如果 EM 算法只估计每个高斯分布在每个维度上的方差,那么高斯分布将与特征空间的轴垂直。换句话说,这会迫使模型假设数据中的变量之间没有关系。通常更有意义的是假设变量之间将存在某种程度的关系,并且估计协方差矩阵允许高斯分布在特征空间中对角线排列。

注意

由于我们估计协方差矩阵,高斯混合模型聚类对不同尺度的变量不敏感。因此,我们不需要在训练模型之前对变量进行缩放。

当我们在多个维度上进行聚类时,EM 算法会随机初始化每个高斯分布的质心、协方差矩阵和先验概率。然后,在期望步骤中,它计算每个案例的每个高斯分布的后验概率。在最大化步骤中,每个高斯分布的质心、协方差矩阵和先验概率都会更新。EM 算法会继续迭代,直到达到最大迭代次数或算法收敛。二元情况的 EM 算法在图 19.2 中展示。

图 19.2. 两个二维高斯的双向期望最大化算法。在特征空间中随机初始化两个高斯。在期望步骤中,为每个高斯计算每个案例的后验概率。在最大化步骤中,根据后验,更新每个高斯的中心、协方差矩阵和先验。这个过程一直持续到似然收敛。

图片

多元情况下的数学

更新均值和(协)方差的方程比我们在单变量情况中遇到的要复杂一些。如果你感兴趣,这里它们是。

对于变量 a 的高斯 k 的均值是

图片

因此,高斯的重心就是一个向量,其中每个元素是不同变量的均值。

对于高斯 k 的变量 a 和 b 之间的协方差是

图片

其中 σ[k] 是高斯 k 的协方差矩阵。

最后,在多元情况下,现在需要考虑协方差,因此现在变为

图片

这个过程看起来熟悉吗——基于数据中的案例与聚类位置的距离,迭代更新聚类的位置?我们在第十六章中看到了类似的过程,即 k-means 算法。因此,高斯混合模型聚类扩展了 k-means 聚类,允许非球形聚类或不同直径(由于协方差矩阵)以及软聚类。实际上,如果你约束高斯混合模型,使得所有聚类都有相同的方差、没有协方差和相等的先验,你会得到与 Lloyd 算法提供的结果非常相似的结果!

19.2. 构建您的第一个用于聚类的高斯混合模型

在本节中,我将向您展示如何构建用于聚类的高斯混合模型。我们将继续使用瑞士银行纸币数据集,以便我们可以将结果与 DBSCAN 和 OPTICS 聚类结果进行比较。混合模型聚类相对于 DBSCAN 和 OPTICS 的一个直接优势是它对不同尺度的变量不变,因此我们不需要先对数据进行缩放。

注意

为了正确性,我应该说明,只要我们没有对模型组件的协方差做出先前的指定,就没有必要对数据进行缩放。我们可以指定我们对组件的均值和协方差的先验信念,尽管我们在这里不会这样做。如果我们这样做,考虑数据的尺度对于协方差来说是很重要的。

mlr 包没有我们即将使用的混合建模算法的实现,因此我们将使用 mclust 包中的函数。让我们首先加载这个包:

library(mclust)

我特别喜欢使用 mclust 包进行聚类的几个方面。首先,它是唯一我知道在加载时会打印出酷炫标志的 R 包。其次,它显示一个进度条来指示聚类还需要多长时间(对于判断是否有时间泡一杯茶非常重要)。第三,它的模型拟合函数会自动尝试一系列聚类数量,并尝试选择最佳拟合数量。如果我们认为我们知道得更好,我们也可以手动指定聚类数量。

让我们使用Mclust()函数进行聚类,然后对结果调用plot()函数。

列表 19.1。执行并绘制混合模型聚类
swissMclust <- Mclust(swissTib)

plot(swissMclust)

绘制Mclust()输出的结果有些奇怪(而且对我来说有些恼人)。它提示我们输入一个 1 到 4 的数字,对应以下选项之一:

  1. BIC

  2. 分类

  3. 不确定性

  4. 密度

输入数字将绘制包含有用信息的相应图表。让我们依次查看这些图表。

我们可以获得的第一个图表显示了Mclust()函数尝试的聚类数量和模型类型的贝叶斯信息准则(BIC)。此图表显示在图 19.3。BIC 是用于比较不同模型拟合度的指标,它会对模型中过多的参数进行惩罚。BIC 通常定义为方程式 19.9。

方程式 19.9。

eq19-9.jpg

其中 n 是案例数量,p 是模型中的参数数量,L 是模型的总体似然值。

因此,对于固定的似然值,随着参数数量的增加,BIC 会增加。相反,对于固定的参数数量,随着模型似然值的增加,BIC 会减少。因此,BIC 越小,我们的模型越好和/或越简洁。想象一下,我们有两个模型,它们都能同样好地拟合数据集,但一个有 3 个参数,另一个有 10 个。具有 3 个参数的模型将具有较低的 BIC。

图 19.3。我们的 mclust 模型的 BIC 图。x 轴显示聚类数量,y 轴显示贝叶斯信息准则(BIC),每条线表示不同的模型,三位代码表示对协方差矩阵施加了哪些约束。在这个 BIC 排列中,较高的值表示更好的拟合和/或更简洁的模型。

fig19-3_alt.jpg

图表中显示的 BIC 形式实际上是相反的,其形式如方程式 19.10 所示。经过这样的重新排列后,更好的拟合和/或更简洁的模型实际上会有更高的 BIC 值。

方程式 19.10。

eq19-10.jpg

现在我们知道了 BIC 是什么以及如何解释它,但图 19.3 中的所有线条代表什么?嗯,Mclust()函数尝试了不同模型类型和簇数量的范围,为我们。对于模型类型和簇数量的每一种组合,该函数都会评估 BIC。这些信息通过我们的 BIC 图传达。但我说的是模型类型是什么意思?在我向您展示高斯混合模型如何工作时,我没有提到这一点。当我们训练混合模型时,我们可以对协方差矩阵施加约束,以减少描述模型所需的参数数量。这有助于防止数据过拟合。

每种模型类型在图 19.3 中由不同的线条表示,并且每种都有代表它的奇怪的三字母代码。每个代码的第一个字母指的是每个高斯分布的体积,第二个字母指的是形状,第三个字母指的是方向。这些组件中的每一个都可以取以下值之一:

  • E代表相等

  • V代表变量

形状和方向分量也可以取值为I,代表恒等。这些值对模型的影响如下:

  • 体积分量:

    • E—等体积的高斯分布

    • V—不同体积的高斯分布

  • 形状分量:

    • E—具有相等纵横比的高斯分布

    • V—具有不同纵横比的高斯分布

    • I—完美球形的簇

  • 方向分量:

    • E—通过特征空间具有相同方向的高斯分布

    • V—不同方向的高斯分布

    • I—与特征空间轴正交的簇

因此,实际上,Mclust()函数为我们进行了一次调整实验,并会自动选择具有最高 BIC 值的模型。在这种情况下,最佳模型是使用三个高斯分布的 VVE 协方差矩阵的模型(使用swissMclust$modelNameswissMclust$G来提取此信息)。

图 19.4。我们 mclust 模型的分类图。原始数据中的所有变量都在散点图矩阵中相互绘制,案例根据其簇进行着色和形状设计。椭圆表示每个高斯分布的协方差,星号表示它们的质心。

图 19-4 替代

那是第一个图表,当然很有用。然而,最有用的图表可能是从选项 2 获得的图表。它显示了所选模型的最终聚类结果;请参见图 19.4。椭圆表示每个簇的协方差,每个簇中心的星号表示其质心。模型似乎很好地拟合了数据,并且似乎与我们的 DBSCAN 模型识别的两个簇相比,识别了三个相当有说服力的簇(尽管我们应该使用内部簇指标和 Jaccard 指数来更客观地比较模型)。

第三张图与第二张图类似,但它根据每个案例的不确定性设置其大小(见图 19.5)。如果一个案例的后验概率不是由单个高斯分布主导,那么它将具有高不确定性,而这个图有助于我们识别可能被视为异常值的案例。

图 19.5. 我们 mclust 模型的不确定性图。这个图与分类图类似,但每个案例的大小对应于最终模型下的不确定性。

第四张和最后一张图显示了最终混合模型的密度(见图 19.6)。我发现这个图不太有用,但它看起来相当酷。要退出Mclust()plot()方法,您需要输入0(这就是为什么我觉得这很烦人的原因)。

19.3. 混合模型聚类的优缺点

虽然通常不容易判断哪些算法会对特定任务表现良好,但以下是一些优点和缺点,这将帮助您决定混合模型聚类是否适合您。

混合模型聚类的优点如下:

  • 它可以识别不同直径的非球形聚类。

  • 它估计一个案例属于每个聚类的概率。

  • 它对不同尺度的变量不敏感。

图 19.6. 我们 mclust 模型的密度图。这个图的矩阵显示了特征空间中每个变量组合的最终模型的二维密度。

混合模型聚类的缺点如下:

  • 虽然聚类不需要是球形的,但它们确实需要是椭圆形的。

  • 它不能原生地处理分类变量。

  • 它不能自动选择最佳聚类数量。

  • 由于初始高斯分布的随机性,它有可能收敛到一个局部最优模型。

  • 它对异常值敏感。

  • 如果聚类不能由多元高斯近似,那么最终模型不太可能拟合良好。

练习 1

使用Mclust()函数训练模型,将G参数设置为2,将modelNames参数设置为"VVE"以强制使用两个聚类的 VVE 模型。绘制结果,并检查聚类。

练习 2

使用clusterboot()函数,计算从双聚类和三聚类 VVE 模型生成的聚类的稳定性。提示:将clustermethod参数设置为noisemclustCBI以使用混合模型。比较不同数量聚类的 Jaccard 指数容易吗?

摘要

  • 高斯混合模型聚类将一组高斯分布拟合到数据中,并估计数据来自每个高斯分布的概率。

  • 使用期望最大化(EM)算法迭代更新模型,直到数据的似然收敛。

  • 高斯混合建模是一种软聚类方法,它为我们提供了每个案例属于每个聚类的概率。

  • 在一维中,EM 算法只需要更新每个高斯分布的均值、方差和先验概率。

  • 在超过一维的情况下,EM 算法需要更新每个高斯分布的重心、协方差矩阵和先验概率。

  • 可以对协方差矩阵施加约束,以控制高斯分布的体积、形状和方向。

练习题的解答

  1. 使用两个簇训练 VVE 混合模型:

    swissMclust2 <- Mclust(swissTib, G = 2, modelNames = "VVE")
    
    plot(swissMclust2)
    
  2. 比较两个簇和三个簇混合模型的聚类稳定性:

    library(fpc)
    
    mclustBoot2 <- clusterboot(swissTib, B = 10,
                             clustermethod = noisemclustCBI,
                             G = 2, modelNames = "VVE",
                             showplots = FALSE)
    
    mclustBoot3 <- clusterboot(swissTib, B = 10,
                               clustermethod = noisemclustCBI,
                               G = 3, modelNames = "VVE",
                               showplots = FALSE)
    
    mclustBoot2
    
    mclustBoot3
    
    # It can be challenging to compare the Jaccard indices between models with
    # different numbers of clusters. The model with three clusters may better
    # represent nature, but as one of the clusters is small, the membership is
    # more variable between bootstrap samples.
    

第二十章。最后的笔记和进一步阅读

本章涵盖

  • 我们所涵盖内容的简要总结

  • 进一步拓展知识的路线图

抽空回顾一下本书中涵盖的所有主题。我们涵盖了大量的信息,现在我们接近本书的结尾,我想将所有这些内容整合起来,给你一个更全面的视角。在大学时,我常常对那些认为因为他们教了我们某些东西,我们就应该简单地记住它们的讲师感到沮丧。我知道这并不是大多数人学习的方式,你很可能已经忘记了我在本书中试图教授的许多细节。没关系——我希望你觉得你可以将这本书作为你未来可能从事的机器学习项目的参考。在本章中,我还总结了本书中涉及到的许多广泛而重要的概念。

完成本书后,你将在工具箱中拥有大量的机器学习算法——足够解决各种各样的问题。我也希望你现在知道了一种通用的机器学习方法,并且更重要的是,如何客观地评估你的模型构建过程的表现。虽然我为你提供了“面包和黄油”算法以及现代算法,但机器学习研究进展迅速。还有许多我没有涵盖的算法,例如深度学习、强化学习和异常检测中使用的算法。因此,在本章中,我还为你提供了几个未来学习的潜在途径。当我学到新东西时,当我到达教科书结尾却不知道下一步该去哪里时,我会感到沮丧;所以,我会推荐额外的书籍和资源来进一步你的学习。

20.1. 机器学习概念的简要回顾

在本节中,我将总结本书中涵盖的通用机器学习概念,并在进行过程中引用相关的章节。这些概念包括以下内容:

  • 机器学习算法的类型

  • 偏差-方差权衡

  • 模型验证

  • 超参数调整

  • 缺失值插补

  • 特征工程和特征选择

  • 集成技术

  • 正则化

我希望现在你已经完成了本书,这些概念将更具体地融入你对机器学习的整体理解中。

20.1.1. 监督学习、无监督学习和半监督学习

根据算法是否有访问标记数据的权限:在训练模型时我们是否可以访问真实值,可以将机器学习任务分为监督学习无监督学习。那些在数据中学习可用于预测真实值的模式的算法被称为监督学习。根据它们预测的输出变量的类型,监督机器学习算法可以进一步区分。预测分类变量(或类别)的监督学习算法被称为分类算法,而预测连续变量的算法被称为回归算法

注意

一些算法——如 k 近邻、随机森林和 XGBoost——可以用于分类回归

无监督算法在没有任何形式真实值的情况下学习数据中的模式。我们可以根据它们的目的来区分这些算法。可以将将高维数据集中的信息压缩到低维表示的无监督学习算法称为降维算法。将找到比其他组中的案例更相似的案例组的无监督学习算法称为聚类算法

您第一次遇到这些定义是在第 1.2 节,早在第一章。我在图 20.1 中重新绘制了图 1.5:它总结了监督学习和无监督学习之间的差异。

图 20.1. 监督学习与无监督学习。监督算法使用已经标记有真实值的标签数据来构建一个可以预测新、未标记数据标签的模型。无监督算法使用未标记的数据,并学习其中的模式,以便可以将新数据映射到这些模式上。

注意

尽管我在第一章中没有提到这一点,但并非所有无监督算法都能对新数据进行预测。例如,层次聚类和 t-SNE 模型无法对新数据进行预测。

监督学习无监督学习之间有一个被称为半监督学习的方法。半监督学习是一种方法,而不是一种算法,当我们可以访问部分标记的数据时很有用。如果我们能够尽可能精确地标记数据集中的案例,那么我们可以仅使用这些标记数据构建一个监督模型。我们使用这个模型来预测数据集中其余部分的标签。现在我们将数据与手动标签和伪标签结合起来,并使用这些数据来训练一个新的模型。

图 20.2 展示了本书中使用的所有机器学习算法,将它们分为监督学习和无监督学习,以及分类、回归、降维和聚类。我的希望是,当你在决定哪些算法最适合当前任务时,可以参考这个图,并且随着你知识的增长,你将添加更多列在这里的算法。

图 20.2. 本书涵盖的算法总结,无论它们是监督学习器还是无监督学习器,以及它们是否可用于分类、回归、降维或聚类

图片 20-2

20.1.2. 平衡模型性能的偏差-方差权衡

在训练预测模型时,评估它在现实世界中的表现非常重要。在评估我们模型的表现时,我们永远不应该使用我们用来训练模型的数据来评估它们。这是因为模型在预测用于训练它们的数据时几乎总是表现得比在预测未见数据时更好。

在第三章中,你了解到在评估模型性能时,一个重要的概念是偏差-方差权衡。随着模型复杂性的增加,以及它对训练集的拟合程度越紧密,它在未见数据上的预测将变得更加多变。过于简单且无法很好地捕捉数据中关系的模型倾向于做出持续较差的预测。当我们增加模型的复杂性时,其方差会增加,其偏差会减少;反之亦然。

因此,偏差-方差权衡描述了过拟合(训练一个拟合训练集噪声的模型)和欠拟合(训练一个拟合训练集较差的模型)之间的平衡。在过拟合模型和欠拟合模型之间,存在一个最优拟合模型,其预测可以很好地推广到未见数据。判断我们是否欠拟合或过拟合的方法是使用交叉验证。然而,即使只是将训练集再次通过模型,也会告诉你你是否欠拟合,因为模型的表现会较差。

20.1.3. 使用模型验证来识别过拟合/欠拟合

为了评估模型在新数据上预测的效果,我们需要将新的、未见过的数据通过模型,并查看其预测与真实值之间的匹配程度。一种方法是在手头的数据上训练模型,然后,随着新数据的生成,将数据通过模型来评估其预测。这个过程可能会使模型构建过程持续数年,因此一个更现实的方法是将数据分为训练集和测试集。这样,模型使用训练集进行训练,并给出测试集以进行预测。这个过程被称为交叉验证,你已经在第三章中了解过它。

将数据集分为训练集和测试集有多种方法。保留法交叉验证是最简单的,其中数据集中的案例比例被“保留”为测试集,模型在剩余的案例上进行训练。由于分割通常是随机的,因此保留法交叉验证的结果高度依赖于测试集中保留的案例比例以及进入测试集的案例。因此,保留法交叉验证在多次运行时可能会给出相当可变的结果,尽管它是计算成本最低的方法。我在图 20.3 中重现了图 3.12:它展示了一个说明保留法交叉验证的示意图。

图 20.3. 保留法交叉验证。数据被随机分为训练集和测试集。训练集用于训练模型,然后用于在测试集上做出预测。预测与测试集真实值之间的相似性用于评估模型性能。

图 20-3

K 折交叉验证将案例随机划分为近等大小的k个折。对于每个折,折内的案例用作测试集,而剩余的数据用作训练集。然后返回所有折的平均性能指标。K 折交叉验证相对于保留法交叉验证的优势在于,因为每个案例只被用作测试集一次,所以结果变化较小,尽管结果将对我们选择的折数敏感。为了使结果更加稳定,我们可以使用重复 K 折交叉验证,其中整个 K 折过程重复多次,每次重复随机打乱案例。我在图 20.4 中重现了图 3.13:它说明了 K 折交叉验证。

图 20.4. K 折交叉验证。数据被随机分为近等大小的折。每个折被用作测试集一次,其余数据用作训练集。预测与测试集真实值之间的相似性用于评估模型性能。

图 20-4

留一法交叉验证是 k 折交叉验证的极端情况,其中折数等于数据集中的案例数。这样,数据集中的每个案例都作为测试集使用一次,模型使用所有其他案例进行训练。留一法交叉验证通常比 k 折交叉验证给出更多变动的性能估计,除非数据集很小,在这种情况下,k 折交叉验证可能由于训练集小而给出更多变动的估计。我在图 20.5 中重现了图 3.14;它说明了留一法交叉验证。

许多人在训练机器学习模型时犯的一个最常见的错误是没有将他们的数据依赖预处理步骤包含在交叉验证过程中。如果这个预处理包括任何超参数的调整,那么使用嵌套交叉验证是非常重要的。这样做可以确保我们用于模型最终评估的数据完全没有被模型看到过。

嵌套交叉验证首先将数据分成训练集和测试集(可以使用保留法、k 折法或留一法来完成)。这种划分称为外层循环。训练集用于交叉验证超参数搜索空间中的每个值。这称为内层循环。从每个内层循环中给出最佳交叉验证性能的超参数传递到外层循环。在外层循环的每个训练集上训练一个模型,使用其内层循环的最佳超参数,并使用这些模型在其测试集上进行预测。然后报告这些模型在外层循环中的平均性能指标,作为模型在未见数据上表现的一个估计。我在图 20.6 中重现了图 3.16;它说明了嵌套交叉验证。在这个例子中,我们使用 3 折交叉验证作为外层循环,4 折作为内层循环。

图 20.5. 留一法交叉验证是 k 折交叉验证的极端情况,其中我们保留一个案例作为测试集,并在剩余数据上训练模型。使用测试集的真实值来评估模型性能的相似性。

图 20.6. 嵌套交叉验证。数据集被分成几个折。对于每个折,使用训练集创建内层 k 折交叉验证的集合。这些内层集合中的每一个通过将数据分成训练集和测试集来交叉验证单个超参数值。对于这些内层集合中的每个折,使用训练集训练一个模型,并在测试集上评估,使用该集合的超参数值。从每个内层交叉验证循环中给出最佳性能模型的超参数用于在外层循环中训练模型。

训练集、测试集和...验证集?

你可能会看到其他人将他们的数据分为训练集、测试集和验证集。我想向你展示这只是一个嵌套交叉验证的特殊情况。当使用这种方法时,人们使用具有一系列超参数值的训练集来训练模型,并使用测试集来评估这些超参数值的性能。然后,具有最佳性能超参数值的模型被赋予验证集来进行预测。模型在验证集上的性能被用作模型构建过程性能的最终指标。这一点的重要性在于,验证集在训练过程中没有被模型看到,包括在超参数调整期间,因此模型不会泄露有关验证集中存在的模式的信息。

现在再次看看图 20.6 中的示意图。你能看到将数据分为训练集、测试集和验证集只是嵌套交叉验证,其中内循环和外循环都使用保留交叉验证吗?我使用“嵌套”这个术语是因为它为我们提供了一个比仅仅将数据分为训练集、测试集和验证集更灵活的工具集来评估模型性能。例如,它允许我们在内循环和外循环中使用更复杂的交叉验证策略,甚至可以在它们之间混合不同的策略。

20.1.4. 使用超参数调整最大化模型性能

许多机器学习算法都有控制它们如何学习的超参数。超参数是一个变量、设置或选项,不能直接从数据本身估计出来。为任何给定的算法和数据集选择最佳超参数组合的最佳方式是使用超参数调整。

超参数调整是迭代尝试不同超参数组合的模型,并选择给出最佳性能模型的组合的过程。调整过程应伴随着交叉验证,其中,对于每个超参数组合,模型在训练集上训练并在测试集上评估。

如果我们需要搜索的超参数值范围较小,那么使用网格搜索方法通常是有益的。在网格搜索中,我们简单地尝试搜索空间中定义的每个超参数值的组合。网格搜索是唯一保证能够从我们的搜索空间中选择最佳超参数组合的搜索方法。

但是,当处理多个超参数,或者搜索空间变得非常大时,网格搜索可能会变得过于缓慢。在这种情况下,我们可以采用随机搜索。随机搜索从搜索空间中随机采样超参数的组合,进行尽可能多的迭代。随机搜索不能保证找到最佳性能的超参数组合,但它通常可以在网格搜索所需时间的一小部分内找到接近的近似值。

无论我们使用哪种搜索方法,作为一个数据依赖的前处理步骤,将超参数调整纳入我们的交叉验证策略中,以嵌套交叉验证的形式,是至关重要的。

20.1.5. 使用缺失值插补处理缺失数据

缺失值插补 是使用合理值填充数据集中缺失数据的一种实践,这样我们仍然可以使用完整的数据集来训练模型。另一种选择是丢弃任何缺失数据的案例。

一种简单的插补缺失值的方法是将缺失值简单地替换为连续变量的均值或中位数(正如我们在 第四章 中所做的那样)或分类变量的众数。问题是这种方法会给任何你训练的模型添加偏差,并丢弃关于数据中可能具有预测价值的关系的信息。因此,更好的方法是使用另一个机器学习算法来估计缺失数据的合理值,基于该案例其他变量的值(正如我们在 第九章 和 第十章 中所做的那样)。例如,我们可以使用 k 最近邻算法找到与问题案例最相似的案例的缺失值。作为一个数据依赖的前处理步骤,缺失值插补应包含在交叉验证过程中。

20.1.6. 特征工程和特征选择

特征工程 是从现有变量中提取有用/预测信息的一种实践,当数据目前处于不太有用的格式时。例如,这可能包括从转录的医疗记录中提取性别,或者将各种财务指标结合起来创建市场稳定性的指数。特征工程通常需要一些领域知识,以及对哪些特征可能影响结果变量的思考。我们首次在 第四章 中介绍了特征工程,并在 第十章 中再次使用它。

另一方面,特征选择 关注的是移除对模型贡献很少或没有预测信息的变量。通过这样做,我们可以防止过拟合和维度诅咒。你曾在 第九章 中了解到,特征选择可以以两种不同的方式进行:过滤方法和包装方法。

过滤方法在计算上成本较低,但不太可能得到特征选择的最佳结果。它们依赖于计算每个特征与结果变量之间关系的一些度量。这个度量可以简单地是每个特征与结果之间的相关性,例如。然后我们可以筛选掉与结果关系较弱的特定数量或比例的特征。

包装方法在计算上成本较高,但更有可能得到拟合度更好的模型。它们包括迭代地拟合和评估不同排列的预测变量模型。选择给出最佳性能模型变量组合。

特征工程和选择非常重要——可以说比我们选择算法更重要。我们可以使用最前沿、性能最高的算法,但如果我们的特征没有充分利用它们所包含的预测信息,或者数据中有许多无关变量,我们的模型的表现可能不会像预期的那样好。如果我们的特征工程/选择过程依赖于数据,那么在交叉验证中包含它们是很重要的。

20.1.7. 使用集成技术提高模型性能

大多数监督机器学习算法的性能可以通过结合集成技术来提高。集成是指,而不是训练单个模型,我们训练多个模型,这些模型帮助我们减少过拟合并提高预测的准确性。有三种类型的集成技术:

  • 分袋法

  • 提升法

  • 堆叠

你在第八章和第十二章中分别学习了分类和回归的集成技术。

分袋法(也称为自助聚合)包括从原始数据集中创建多个自助样本,并在每个样本上并行训练模型。然后,将新数据传递给每个单独的模型,并返回模型或平均预测(对于分类和回归问题分别)。分袋法帮助我们避免过拟合,因此可以减少我们模型的方差。分袋法几乎可以用于任何监督学习算法(以及一些聚类算法),但它在随机森林算法中最著名的实现,该算法使用分类/回归树。

当 bagging 并行训练模型时,boosting按顺序训练模型,其中每个后续模型都试图改进现有模型链中的错误。在自适应提升中,现有集成模型错误分类的案例被赋予更高的权重,这样它们在下一迭代中更有可能被采样。AdaBoost 是自适应提升的唯一知名实现。在梯度提升中,每个额外模型通过最小化现有集成模型的残差误差。XGBoost 是使用分类/回归树进行梯度提升的著名实现;但就像 bagging 一样,boosting 可以与任何监督学习算法一起使用。

堆叠中,我们创建了一些基础模型,这些模型擅长学习特征空间中的不同模式。一个模型可能在特征空间的一个区域预测得很好,但在另一个区域犯错误。其他模型中的一个可能在做得不好的特征空间区域中预测值做得很好。基础模型做出的预测被用作预测变量(连同所有原始预测变量)由最终的堆叠模型使用。这个堆叠模型随后能够从基础模型的预测中学习,以做出更准确的预测。

20.1.8. 使用正则化防止过拟合

正则化描述了一组技术,用于限制模型参数的幅度,以防止过拟合。正则化对于防止由于包含预测值很小或没有预测价值的预测因子而导致的过拟合尤为重要。你在第十一章中了解到,最常见的形式是 L2 和 L1 正则化。

在 L2 正则化中,模型的损失函数增加了一个惩罚项,即模型参数的 L2 范数,通过可调的超参数lambda加权。模型参数的 L2 范数是参数值的平方和。L2 正则化的效果是模型参数可以被缩小到零(但永远不会缩小到零,除非普通最小二乘[OLS]估计为零),预测能力较弱的预测因子会受到更大的惩罚。岭回归是使用 L2 正则化来防止线性回归中过拟合的一个例子。

在 L1 正则化中,我们向损失函数中添加 L1 范数,通过lambda加权。L1 范数是参数值的绝对值之和。L1 正则化的效果是模型参数可以被缩小到零,从而有效地从模型中移除它们。因此,L1 正则化是一种自动特征选择的形式。LASSO 是使用 L1 正则化来防止线性回归中过拟合的一个例子。

20.2. 从这里你可以走向何方?

你可能想知道你在机器学习教育中的下一步是什么。这完全取决于你想要实现的目标,但在本节中,我会为你指明一些你可以用来进一步发展你的知识和技能的优秀资源。然而,我坚信,巩固新知识最好的方式是使用它——所以,在你的工作中使用本书中学到的技术和算法,并将它们教授给你的同事!

20.2.1. 深度学习

如我在本书开头所提到的,我省略了深度学习(使用人工神经网络的机器学习),因为我认为它值得一本单独的书籍。但任何机器学习教育如果没有涉及这个非凡领域,都不能算是全面的。神经网络是任何机器学习任务的强大工具,但如果你的工作将围绕计算机视觉、图像/视频的分类,或构建其他复杂数据(如音频文件)的模型,那么深度学习是你必须探索的重要途径。对于 R 语言,我非常推荐 Francois Chollet 和 Joseph J. Allaire 的《用 R 进行深度学习》(Manning, 2018, www.manning.com/books/deep-learning-with-r)。这本书对非专业人士来说很容易理解,并将加强我们在本节中介绍的一些基本机器学习概念。

20.2.2. 强化学习

强化学习是机器学习研究和应用的前沿领域,其中的算法通过在做出良好决策时获得奖励来从经验中学习。通常与监督学习和无监督学习算法并列为机器学习算法的第三类,它已被用于创建能够战胜世界冠军棋手的棋类机器人。如果你对强化学习感兴趣,我强烈推荐 Max Pumperla 和 Kevin Ferguson 的《深度学习与围棋》(Manning, 2019, www.manning.com/books/deep-learning-and-the-game-of-go)。

20.2.3. 通用 R 数据科学和 tidyverse

如果你想要提高你的 R 数据科学技能,以及更熟练地使用 tidyverse 工具(包括我们未使用的一些工具),我推荐 Garrett Grolemund 和 Hadley Wickham 的《R 数据科学》(O’Reilly Media, 2016)。

如果你想要成为 ggplot2 的大师,那么请购买 Hadley Wickham 的《ggplot2》(Springer International Publishing, 2016)。

如果你已经具备相当好的 R 技能,并想了解更多关于语言的工作原理以及如何进行更高级的编程(例如面向对象编程),你将喜欢 Hadley Wickham 的《高级 R》(CRC Press, 2019)。你可能注意到这个人 Hadley 经常出现;如果你想跟上 R 社区和 tidyverse 的发展,跟随他是个不错的选择。

20.2.4. mlr 教程和创建新的学习器/度量

在书中,我提到过几次,某个特定的算法尚未在 mlr 中实现。mlr 包旨在使你的机器学习体验更加流畅,而不是减少灵活性;所以,如果你希望在另一个包(或你自己的)或新的性能度量中实现算法,实际上自己来做并不难。你可以在 mlr 网站上找到如何做到这一点的教程(以及其他有用的信息和资源):mng.bz/5APD

20.2.5. 广义加性模型

如果你的工作将涉及回归任务中的非线性关系建模,我建议你深入了解广义加性模型(GAMs)的内部工作原理。对于 R 语言,一个很好的地方是西蒙·伍德的《广义加性模型:R 语言导论》(Chapman and Hall/CRC,2017)。

20.2.6. 集成方法

对集成方法感到兴奋吗?在这本书中,我们只是触及了表面,使用了基于树的模型进行集成。如果你确信集成几乎总是可以使模型变得更好,我建议你阅读周志华的《集成方法:基础与算法》(Chapman and Hall/CRC,2012)。

20.2.7. 支持向量机

对支持向量机(SVMs)如何扭曲特征空间以创建线性边界感到兴奋吗?SVMs 非常受欢迎,其理论相当复杂。为了了解更多关于如何利用它们的预测能力,我推荐安德烈亚斯·克里斯曼和英戈·斯坦瓦特的《支持向量机》(Springer,2008)。

20.2.8. 异常检测

有时候你可能对数据中的常见模式不感兴趣。有时候,你真正感兴趣的是那些不寻常的、异常的案例。例如,你可能正在尝试识别信用卡上的欺诈活动,或者尝试识别来自恒星的罕见辐射爆发。在数据集中识别这样的罕见事件可能具有挑战性,但机器学习中的一个领域称为异常检测正是致力于解决这些问题。这本书中你遇到的一些算法可以重新用于异常检测,例如 SVM 算法。如果你对罕见和异常的事物有偏好,可以看看基山·G·梅赫罗特拉、奇卢库里·K·莫汉和华明·黄合著的《异常检测原理与算法》(Springer,2017)。

20.2.9. 时间序列

本书没有涉及的是时间序列预测。这是机器学习和统计学中关注基于变量的先前状态预测其未来状态的领域。时间序列预测的常见应用包括预测股票市场变量的波动和预测天气模式。如果你想致富或保持干燥,我会从保罗·科珀特韦特和安德鲁·梅特卡夫合著的《R 语言时间序列导论》(Springer,2009)开始。

20.2.10. 聚类

在聚类方面,我们已经覆盖了相当多的内容,但还有更多内容等待您去深入研究。要了解更多信息,我推荐 Charu Aggarwal 的《数据聚类:算法与应用》(Chapman & Hall/CRC,2013 年)。

20.2.11. 广义线性模型

对广义线性模型能够扩展到预测类别,就像我们在逻辑回归中所做的那样感到印象深刻吗?我们可以使用同样的原理来预测计数数据(如泊松回归)或百分比(如贝塔回归)。当我们的结果不是一个正态分布的连续变量时,广义线性模型是处理这种情况的扩展形式。它在构建预测模型时提供了非凡的灵活性,同时仍然允许完全解释模型参数。要了解更多信息,我推荐 Peter K. Dunn 和 Gordon K. Smyth 的《广义线性模型及其在 R 中的应用》(Springer,2018 年),尽管如果您在线性建模方面没有良好的数学基础,可能会觉得这本书很难读。

20.2.12. 半监督学习

如果您有手动标记数据既耗时又昂贵的问题,您可能可以从半监督学习的应用中受益。要了解更多信息,我推荐 Olivier Chapelle、Bernhard Scholkopf 和 Alexander Zien 的《半监督学习》(MIT Press,2006 年)。

20.2.13. 光谱数据建模

如果您将要处理光谱数据,或者可以用平滑函数表示的数据,您将需要具备功能数据分析的良好基础(在第十章中简要提及)。功能数据分析是我们将函数作为模型中的变量使用,而不是使用单个值。要了解更多信息,我推荐 James Ramsay 的《功能数据分析》(Springer,2005 年)。

20.3. 最后一个词

我真心希望,通过阅读这本书,您所获得的技术能够帮助您深入理解您正在研究的那部分自然,帮助您优化和改进您的商业实践,或者只是帮助您从您的数据科学项目爱好中获得更多。我还希望,书中使用的 tidyverse 技能能够帮助您编写更简单、更易读的代码,而您的新 mlr 技能将继续使您的机器学习项目变得更加简单。

感谢您的阅读!

附录. 统计概念复习

如果你没有统计学背景,或者只是想复习一下某些统计概念,这个附录旨在帮助你掌握阅读本书所需的基本知识。如果你不确定是否需要使用这个复习资料,翻阅一下章节标题,确保没有你不自信的内容。你不需要记住任何这些材料,只需了解重要的概念。此外,在阅读本书的过程中,你也可以随时参考这里的任何定义。

A.1. 数据词汇

让我们从我们将用来描述数据的某些基本词汇开始。数据科学家和统计学家在术语使用上存在一些差异,所以我将尽力说明哪些术语是等效的,以及我在整本书中选择的术语。在本节中,我们将讨论

  • 样本与总体的区别

  • 我们所说的行、列、案例和变量的含义

  • 不同类型变量及其区别

A.1.1. 样本与总体

在数据科学和统计学中,我们通常试图了解现实世界中的某些内容,或者预测某些内容。比如说,我们对河马的獠牙长度感兴趣。不可能测量世界上每只河马的獠牙长度——数量太多,而且它们并不愿意我们把尺子放进它们的嘴里。因此,我们测量尽可能多的河马獠牙,从财务和时间成本的角度来看都是可行的。这个更小、更易于管理的河马数量被称为我们的样本。我们希望样本中的獠牙长度能够很好地代表世界上所有河马的獠牙长度,这是我们试图推广发现的总体。样本与总体之间的区别在图 A.1 中得到了说明。

图 A.1. 总体与样本的区别。总体是我们希望将结果推广到的所有单位的集合。总体通常被认为几乎是无限的。样本是我们测量的一个更易于管理的子集,我们希望它能代表总体。

图片

样本与总体之间的差异被称为抽样误差,它产生的原因是样本几乎永远不会是总体的完美代表。我们希望通过使用尽可能大的样本,并在创建样本时避免引入偏差(例如,不是选择较小的河马因为它们不那么可怕)来使抽样误差尽可能小。如果抽样误差太大,我们就无法将我们的发现推广到更广泛的总体。

A.1.2. 行与列

收集到我们的数据后,大多数情况下我们可以将其结构化为带有行和列的表格格式。在 R 中,表示此类数据的一种常见方式是使用数据框。

如 第二章 所解释的,我们通常需要根据我们的目标重新排列表格数据的结构,但大多数时候,我们希望以这样的方式格式化数据,即每一行代表我们样本的单个单位,每一列代表不同的 变量。在我们的河马例子中,每只河马都是数据集中的单个单位,因此每一行将对应于对单个河马进行的测量,如 表 A.1 所示。

表 A.1. 数据以表格格式排列的示例,其中每一行对应一只单独的河马,每一列对应不同的变量。请注意,从文化角度来看,河马给它们的孩子起名时以 H 开头。
Name TuskLength Female
Harry 32 FALSE
Hermione 15 TRUE
Hector 45 FALSE
Heidi 20 TRUE

我们可以在 R 中使用 data.frame() 函数创建类似的数据框,如下面的列表所示。

列表 A.1. 创建我们的河马数据框
hippos <- data.frame(
  Name = c("Harry", "Hermione", "Hector", "Heidi"),
  TuskLength = c(32, 15, 45, 20),
  Female = c(FALSE, TRUE, FALSE, TRUE)
  )

在统计学中,当数据以这种方式格式化时,每一行都对应数据中的一个 主体,这里的主体是指一只单独的河马。在数据科学和机器学习中,更常见的是使用术语 案例 来描述数据中的单个单位,因此我在整本书中使用了这个术语。

包含对每个案例进行测量的列被称为 变量。当我们试图根据与其他变量的关系预测一个变量的值时,我们使用术语来区分我们想要预测的变量和用于预测的变量。统计学家将我们试图预测的变量称为 因变量,而用于做出这些预测的变量称为 自变量。在数据科学中,你更有可能听到术语 结果变量响应变量 来指代因变量,以及 预测变量特征 来指代自变量。我在整本书中使用了数据科学的术语。

A.1.3. 变量类型

不同的变量可能使用不同的尺度进行测量,这意味着我们需要以不同的方式处理它们。在整本书中,我提到了连续变量、分类变量,有时还有逻辑变量。

连续变量代表在数值连续体上的某种测量。例如,河马的獠牙长度可以用连续变量来表示。我们可以对连续变量应用数学变换。在 R 中,连续变量最常见地表示为 整数双精度浮点数。整数变量只能有整数,而双精度浮点数可以在小数点后包含非零数字。在 表 A.1 中显示的数据中,TuskLength 变量是数值型的。

分类别变量有级别,每个级别代表一组或一类不同的对象。例如,假设我们正在比较河马和侏儒河马的獠牙长度。我们的数据将包含一个类别变量,指示数据中的每个案例属于哪种河马物种。在 R 中,通常将类别变量表示为因子,其中因子的可能级别是预定义的。在表 A.1 中显示的数据中,Name变量是类别变量。

逻辑变量可以取TRUEFALSE的值,以表示二元结果。例如,我们可以包含一个逻辑变量来指示河马是否试图咬我们。逻辑变量作为函数的参数最有用,以控制它们的行为,或选择对我们最有兴趣的案例。在表 A.1 中显示的数据中,Female变量是逻辑变量。

下面的列表显示了我们可以如何使用class()函数来确定我们正在处理什么类型的变量。

列表 A.2. 使用class()确定变量类型
class(hippos$Name)
[1] "factor"

class(hippos$TuskLength)
[1] "numeric"

class(hippos$Female)
[1] "logical"

A.2. 向量

图 A.2. 一个位于 x = 3, y = 5 点的二维向量的示例。箭头显示了向量如何编码大小,我们可以将其表示为它们与原点(或另一个向量)的距离。表示 x 轴与箭头之间角度的曲线线表示向量如何编码方向。

图片

向量是一组编码大小和方向的数字。想象一个有 x 轴和 y 轴的坐标系,如图 A.2 所示。如果我们在这个坐标系中选取一个点,这个点将具有每个轴的值:比如说 x = 3 和 y = 5。我们可以将这个点表示为向量(3,5)。向量编码大小,因为我们可以计算由这个向量定义的点与坐标系原点(0,0)之间的距离。向量也编码方向,因为如果我们从原点(0,0)画一条线连接到这个点(3,5),我们可以计算这条线与坐标系轴之间的角度。图 A.2 是一个二维向量的示例,但向量可以有我们想要的任意维度。

我们可以使用向量执行操作,如加法、减法和乘法,以创建新的向量。在本书中,我们不会使用向量进行任何复杂的数学运算,但有时当我们处理超过两个维度的概念时,我会提到向量。例如,在本书的一些部分,我提到了均值向量,其中向量的每个元素是不同变量的均值。

令人困惑的是,R 有一个称为原子向量的数据结构,它可能或可能不表示数学向量。R 中的原子向量包含一组必须都是相同类型的值(这就是名字中“原子”一词的来源)。如果原子向量的元素是数值的,那么它也将是数学意义上的向量,因为值编码了大小和方向。但是,如果我们有包含字符或逻辑元素的原子向量,这些元素都无法编码大小和方向;因此,尽管我们在 R 中称它们为向量,但在数学意义上它们并不是向量。以下是使用c()函数创建数值、字符和逻辑原子向量的方法。

列表 A.3。在 R 中创建原子向量
numericVector <- c(1, 31, 10)

characterVector <- c("common hippo", "pygmy hippo")

logicalVector <- c(TRUE, TRUE, FALSE)

A.3。分布

当我们测量一个变量时,通常希望检查变量所取值的范围。例如,我们可以使用直方图来做这件事,其中我们将变量的可能值与观察到的每个值的频率进行对比。从绘制此类直方图得到的形状代表我们变量的分布,并告诉我们有关变量中心位置、分散程度、其值是否围绕中心对称分布以及它有多少个峰值等信息。

我们可以使用各种统计量来总结变量的分布,例如那些总结分布中心趋势的统计量,那些总结分散度的统计量,以及那些总结形状和对称性的统计量。然而,检查我们变量的分布对于帮助我们决定处理不同变量的最佳方式非常重要。

一些分布在大自然中出现的频率非常高,以至于数学家们已经正式定义了它们并研究了它们的性质。这很有用,因为如果我们发现我们的变量很好地近似了这些定义明确的分布之一,我们可以通过假设底层群体中的变量遵循这种分布来简化我们的统计建模。常见的定义明确的分布例子包括高斯分布(也称为正态分布),这是许多钟形分布之一,以及泊松分布,通常表示离散计数的变量遵循这种分布。

如果我们测量了 1,000 个河马獠牙并绘制了它们的长度直方图,我们可能会得到类似于图 A.3 中显示的分布。直方图的条形表示特定獠牙长度在数据集中出现的频率。我在直方图上叠加了一个理论上的正态分布(平滑的线),其均值和标准差与数据相对应。

图 A.3。一个显示假想河马獠牙长度分布的直方图。该分布近似于高斯分布。曲线代表具有与样本相同的均值和标准差的高斯分布的概率密度函数。

在数学上定义的分布通常被称为 概率分布,并且它们有一个定义的 概率密度函数。特定分布的概率密度函数是一个方程,我们可以用它来计算特定值来自该分布的概率。例如,假设我们测量到河马獠牙的长度为 32 厘米。如果我们知道最能代表所有河马獠牙长度的分布,我们可以使用概率密度函数来估计发现 32 厘米獠牙的河马的概率。在阅读本书之前,你不需要知道或记住任何概率密度函数,但我会偶尔提到它们,因此了解它们是什么很有用。回顾一下 图 A.2:我覆盖在直方图上的平滑线是具有相同均值和标准差的高斯分布的概率密度函数。

A.4. 西格玛符号

对于没有正式接受过其使用的培训的人来说,数学符号可能看起来令人畏惧。但数学符号实际上是为了让我们的生活更简单。虽然这本书中有一些方程,但没有任何一个方程比加法、减法、乘法和除法更复杂。然而,我确实使用了一个使我的生活变得容易得多的符号;一旦你掌握了它,它也会让你的生活变得更简单(并且让许多方程看起来不那么难以理解)。这个符号是大写希腊字母 西格玛,它看起来像奇怪的“E”(Σ)。

在方程中,大写西格玛简单地意味着对其右侧的任何内容进行求和。你通常会看到西格玛符号上方和下方的索引,告诉我们从哪里开始和停止求和。例如,我们不必写 1 + 2 + 3 + 4 + 5 = 15,我们可以使用方程 A.1 中显示的西格玛符号。

方程式 A.1.

我们可以在 R 中使用 sum() 函数来完成这个操作。

列表 A.4. 在 R 中使用 sum() 函数
sum(1:5)

[1] 15

我们可以使用西格玛符号来写出更复杂的表达式,并且索引给了我们控制我们想要求和的值范围的权限。看看方程 A.2 并尝试找出 x 的值。

方程式 A.2.

如果答案对你来说不清楚,也许像程序员一样思考会帮到你。你可以将西格玛符号视为一个加法的 for 循环。如果我要大声朗读方程 A.2,我会说,“对于所有介于 3 和 6 之间的 i 值,取 2 的 i 次幂并减去 i,然后将所有这些值加起来。” 这就变成了

  • 2³ – 3 = 5

  • 2⁴ – 4 = 12

  • 2⁵ – 5 = 27

  • 2⁶ – 6 = 58

以及 5 + 12 + 27 + 58 = 102.

我们可以在 R 中通过创建一个计算西格玛符号右侧值的函数,并将其传递给 sum() 函数来完成这个操作。

列表 A.5. 使用 sum() 进行更复杂的函数
fun <- function(i) (2^i) - i

sum(fun(3:6))

[1] 102

使用 sigma 符号意味着当我们需要求和数十、数百甚至数千个数字时,我们不必全部写出它们。所以我希望你能看到 sigma 符号是如何使我们的生活变得更简单的!我在这里向你介绍它,因为我将在下一节中使用它来提醒你如何计算算术平均数。

A.5. 集中趋势

当处理变量时,了解它们分布的中心往往很重要。我们可以使用多种统计量来总结分布的中心;它们提供不同的信息,适用于不同的情境。提供此类信息的统计量被称为集中趋势度量,其中最常见的是算术平均数中位数众数

A.5.1. 算术平均数

让电子表格用户感到惊讶的是,没有“平均”的正式数学概念。但当人们口语中提到“平均”时,他们通常指的是算术平均数。算术平均数(或简称平均数)只是向量中所有值的总和除以元素的数量。例如,如果我测量 5 只河马的象牙长度为 32、15、45、20 和 54,那么平均数是(32 + 15 + 45 + 20 + 54) / 5 = 33.2。

仅用五个河马象牙的例子来写出这些已经足够繁琐,但想象一下,如果我要为数十个象牙写出这些!相反,我们可以使用我们新认识的朋友,sigma 符号。算术平均数的 sigma 符号表示在方程 A.3 中。

equation A.3.

对于我们的河马例子,x代表我们象牙长度的向量,i是一个索引,告诉我们考虑该向量的哪个元素,而n是向量中的元素总数。然后我们可以大声朗读方程 A.3,即“对于x中从第一个元素到最后一个元素之间的每个元素,加上x的值。然后将此值除以x中的元素数量。”我们可以在 R 中使用mean()函数来完成这个操作。

列表 A.6. 在 R 中使用mean()函数
mean(c(32, 15, 45, 20, 54))

[1] 33.2
注意

为什么我要费心指出这是算术平均数?那是因为还有两种其他类型的平均数,适用于其他情境,称为几何平均数调和平均数。我在书中没有提到它们,所以不会详细说明,但我建议你了解它们的使用。

算术平均数对于总结具有单个峰值的对称分布(如高斯分布)的中心很有用。然而,对于不对称、具有多个峰值或包含异常值的分布,平均数可能不是分布中心趋势的良好代表。

注意

术语异常值用于描述与大多数案例相当不同的案例。它是一个对于一个或多个变量具有异常高或低值的案例。有许多方法可以用来识别一个案例是否是异常值,但这实际上取决于手头的任务。

A.5.2. 中位数

中位数是稳健的集中趋势度量,这意味着它不像均值那样会受到分布中的不对称或异常值的影响。中位数还有一个非常简单的解释:它是使得 50% 的案例大于它,50% 的案例小于它的值。为了计算中位数,我们只需按大小顺序排列向量的元素,然后选择中间的值。

让我们回顾一下之前提到的象牙长度:32, 15, 45, 20, 和 54。将象牙按大小顺序排列后得到 15, 20, 32, 45, 和 54,因此中位数是 32,因为它是最中间的值。如果一个向量有偶数个元素,中位数是位于它们中间的值。所以如果我们测量到另一只河马的象牙长度仅为 5,现在按顺序排列元素得到 5, 15, 20, 32, 45, 和 54。这意味着中位数位于 20 和 32 之间,即 26。我们可以在 R 中使用 median() 函数来计算中位数。

列表 A.7. 在 R 中使用 median() 函数
median(c(32, 15, 45, 20, 54))

[1] 32

median(c(32, 15, 45, 20, 54, 5))

[1] 26

A.5.3. 众数

众数通常用于与均值和中位数略有不同的情境。均值和中位数总结了分布的中心,而众数告诉我们分布中最常观察到的单个值是哪一个。

注意

在基础 R 中没有用于计算众数的函数,但如果你需要,可以自己编写一个。

A.6. 离散度度量

除了总结分布的中心之外,通常还很重要要总结分布值的分散或分布情况。有许多不同的离散度度量,它们告诉我们稍微不同的信息,适用于不同的情境,但它们都给我们提供了一个关于我们的值分布是瘦还是宽的指示。我将提醒你四个这样的度量:平均绝对偏差标准差方差四分位距

A.6.1. 平均绝对偏差

让我们先来谈谈我所说的偏差(这并不是你祖父母在责备不道德行为时可能使用的意思)。一个分布中元素的偏差是指该元素值与分布均值的距离。所以如果我们河马象牙的平均长度是 33.2 厘米,那么 16.1 厘米长象牙的偏差是 –17.1 厘米。请注意,这个偏差是有符号的:如果元素小于均值,则偏差为负,如果元素大于均值,则偏差为正。

注意

一个值与估计值之间的偏差称为残差,我在本书的主体部分对此有更详细的阐述。

为了了解所有元素与分布均值之间的平均(又是那个不明确的词)差异,我们可以取所有偏差的平均值。这个问题在于,在近似对称的分布中,正偏差和负偏差会相互抵消,我们得到的平均偏差接近于零。

相反,我们可以通过改变负偏差的符号为正,并取这些偏差的平均值来获取绝对偏差。这给出了平均绝对偏差,当数据分布较广时,它将更大;当数据集中在分布的中心附近时,它将更小。平均绝对偏差的方程式在方程式 A.4 中展示,其中垂直线表示它们之间表达式的绝对值,而 表示平均值。

方程式 A.4.

我们可以使用 R 中的 mad() 函数来计算平均绝对偏差。默认情况下,此函数计算的是 中位数绝对偏差,这也是常用的,因此我们使用 center 参数来指定我们想要的是平均值。

列表 A.8. 在 R 中使用 mad() 函数
tusks <- c(32, 15, 45, 20, 54)

mad(tusks, center = mean(tusks))

[1]

A.6.2. 标准偏差

虽然平均绝对偏差是一个非常直观和合理的分散度度量,但你不会经常看到它被报告。这是因为人们更常用并报告标准偏差。标准偏差与平均绝对偏差相似,但有一些不同之处。首先,我们不是对均值与绝对偏差求和,而是对平方偏差求和。然后,我们将这个总和除以 n – 1(小于向量中元素的数量)并取平方根。你可以在方程式 A.5 中看到这一点,其中 S 是标准偏差。

方程式 A.5.

我们可以使用 R 中的 sd() 函数来计算这个值。

列表 A.9. 在 R 中使用 sd() 函数
sd(c(32, 15, 45, 20, 54))

[1] 16.42

为什么使用标准偏差,而不是更直观的平均绝对偏差呢?因为标准偏差有一些很好的数学特性,使得它更方便使用。使用标准偏差而不是平均绝对偏差的一个重要后果是,由于差异被平方,它对远离均值的案例影响更大。标准偏差的另一个便利之处是,如果数据遵循高斯(正态)分布,那么已知比例的数据将落在均值的特定标准偏差范围内。这在上文图 A.4 中进行了详细说明,该图显示,对于完美的正态分布,68%,95%,和 99.7%的案例分别落在均值的 1 个,2 个和 3 个标准偏差内。

A.6.3. 方差

方差的计算非常简单:它仅仅是标准差的平方。其公式与标准差的公式相同,当然,我们省略了平方根符号。这可以在方程 A.6 中看到,其中S²是方差。

方程 A.6。

![eqa-6.jpg]

如果方差和标准差是彼此的转换,为什么我们还需要两者?实际上我们不需要;但是,虽然方差使某些统计计算稍微简单一些,但标准差的优势在于它具有与所计算变量相同的单位。

图 A.4. 对于一个完美的、高斯分布的变量,68%的情况位于均值的一个标准差范围内。95%和 99.7%的情况分别位于两个和三个标准差内。

![app-4_alt.jpg]

我们可以使用 R 中的var()函数或通过取标准差的平方来计算方差。

列表 A.10. 在 R 中使用var()函数
var(c(32, 15, 45, 20, 54))

[1] 269.7

sd(c(32, 15, 45, 20, 54))²

[1] 269.7

A.6.4. 四分位距

尽管标准差和方差,尤其是标准差,非常适合总结没有异常值的对称分布的离散程度,但我们还需要总结不遵循这些规则分布的离散程度的方法。在这种情况下,四分位距(IQR)是一个很好的选择,因为它是一个稳健的统计量,不受异常值和不对称性的严重影响。简单来说,IQR 是第一四分位数和第三四分位数之间的差值。

如果我们按照向量的值对向量元素进行排序,向量的四分位数是那些使得其他 25%、50%、75%和 100%的元素值较小的元素。第一四分位数是最小元素和中间值之间的中间值:它将向量分成两部分,其中 25%的元素位于其下方,75%的元素位于其上方。第二四分位数是中间值,将向量分成两部分,其中 50%的元素位于其上方,50%的元素位于其下方。第三四分位数是中间值,位于中间值和最大元素之间,将向量分成两部分,其中 75%的元素位于其下方,25%的元素位于其上方。零四分位数和第四四分位数分别是最小元素和最大元素。

注意

我将第一四分位数和第三四分位数的定义相对模糊,因为至少有九种不同的方法来计算它们的精确值!这些方法并不总是相互一致,但它们总是将向量的元素分成 25%和 75%,所以我们在这里不会过分纠结于它们。

一种常见的图形方法来展示四分位数是使用箱线图(有时简称为箱形图)。一个箱线图的例子显示在图 A.5 中,其中包含三个不同河马物种的獠牙长度的假设数据。粗的水平线表示每个河马物种的第二四分位数(中位数)。箱子的上下边缘分别代表第一和第三四分位数。箱须(从箱子延伸出来的垂直线)连接每个物种的最小和最大值,因此代表数据的全范围。

图 A.5. 假设河马獠牙数据的箱线图。粗的水平线是中位数,箱子的边缘代表第一和第三四分位数,垂直的箱须代表数据的全范围。

![app-5_alt.jpg]

注意

有时箱须代表全范围。通常它们表示图克范围,即分别位于第一和第三四分位数以下和以上的 1.5 倍四分位数范围。任何超出这个范围的案例都会以点状绘制,以突出显示它们作为潜在异常值。

IQR 是向量第一和第三四分位数之间的差值,因此告诉我们向量中中间 50%的元素的范围。它在存在异常值和/或非高斯分布数据的情况下很有用。

我们可以使用 R 中的IQR()函数来计算四分位数范围(这个函数名称首字母大写是不寻常的)。

列表 A.11. 在 R 中使用IQR()函数
IQR(c(32, 15, 45, 20, 54))

[1] 25

A.7. 变量之间关系的度量

在我们处理的一对变量之间找到关系是很常见的。即使两个变量之间没有因果关系,它们之间有关系的现象也不少见。这可能是一种正相关,即当一个变量的值增加时,另一个变量也会增加;或者是一种负相关,即当一个变量增加时,另一个变量会减少。

能够用变量对之间的关系来总结它们的方向(正的、负的或无关系)和幅度(无关系到完全关系)是很重要的。用于总结两个变量之间关系方向和幅度的两种最常见统计量是协方差皮尔逊相关系数

A.7.1. 协方差

两个变量之间的协方差告诉我们它们是如何共变的。如果一对变量一起增加和减少,协方差是正的;如果一个变量增加而另一个变量减少,协方差是负的。如果一对变量之间没有关系,协方差为零(但在现实世界中这种情况几乎从未发生)。

两个变量可能具有零(或接近零)的协方差,但实际上存在非线性关系。运行以下代码并亲自查看(注意协方差值有多小):

x <- seq(-1, 1, length = 1e6)

y <- x⁴

plot(x, y, type = "l")

cov(x, y)

要计算协方差,我们考虑单个案例,并找出它相对于第一个变量的均值和第二个变量的偏差。然后我们找到这些偏差的乘积。这个过程在数据集中的所有案例中都会进行,这些偏差的乘积会被加起来,然后除以 n – 1(向量中元素数量减一)。这个过程在方程式 A.7 中得到了说明。

方程式 A.7.

我们可以使用 R 中的 cov() 函数计算两个向量之间的协方差。

列表 A.12. 在 R 中使用 cov() 函数
tusks <- c(32, 15, 45, 20, 54)

weight <- c(18, 11, 19, 15, 18)

cov(tusks, weight)

[1] 44.7

协方差在数学上非常有用,但因为它是以两个变量的值相乘为单位的,其大小可能难以解释。因此,协方差被称为变量之间关系的非标准化度量,这意味着我们无法比较不同尺度上测量的变量对的协方差。协方差的标准版本是相关系数——或者更正式地说,是皮尔逊相关系数。

A.7.2. 皮尔逊相关系数

皮尔逊相关系数(或简称相关系数)是协方差的无单位标准化版本,其值介于 –1 和 +1 之间。相关系数为 –1 表示变量对之间存在完美的负相关关系,相关系数为 +1 表示完美的正相关关系,相关系数为零表示完全没有关系。这三个极端在现实世界中很少发生(如果你得到 +1,请检查你是否计算了变量与自身的相关系数),而介于它们之间的值则更可能。

注意

我特意将其称为皮尔逊相关系数(以统计学家卡尔·皮尔逊的名字命名),以区别其他可能不太常用的类型:肯德尔等级相关、斯皮尔曼相关和点二列相关。这些其他类型在变量不是连续且遵循高斯分布的情况下很有用,这是皮尔逊相关系数所假设的,但我们在这本书中不考虑它们。

如果我们知道如何计算协方差,计算皮尔逊相关系数很简单;我们只需将协方差除以变量的标准差乘积。这可以在方程式 A.8 中看到。

方程式 A.8.

因为相关系数(通常用 r 表示)是标准化的且无单位的,我们可以比较不同尺度上变量对的值。我们可以在 R 中使用 cor() 函数计算两个向量之间的皮尔逊相关系数。

列表 A.13. 在 R 中使用 cov() 函数
tusks <- c(32, 15, 45, 20, 54)

weight <- c(18, 11, 19, 15, 18)

cor(tusks, weight)

[1] 0.8321

A.8. 对数

对数,或称为 logs,是指数运算的逆运算。例如,如果 2⁵ = 32,那么 log²(32) = 5。在这个例子中,对数的 底数 是 2。换句话说,log²(32) 的结果是 2 必须被提升到多少次幂才能得到 32。对数可以有任何我们喜欢的底数,这取决于我们想要使用对数函数的原因。最常见的三个选择是底数为 2、10 和欧拉数 (e) 的对数,欧拉数是一个重要的常数,其值约为 2.718。对数的底数通常在 log 符号后面表示为下标(例如,log² 或 log¹⁰);但是当底数是 e 时,对数被称为 自然对数,通常表示为 ln

注意

您可能会看到类似 log(x) 的表达式,但没有下标。根据目标受众的不同,这可能被解释为 log¹⁰(x) 或 log^e(x)。明确指出您指的是哪一个会更好。

对数在数学和统计学中具有许多有用的特性。其中之一是它们可以用来将极大值和极小值一起压缩到同一个尺度上。例如,向量 1, 10, 100, 1,000, 10,000, 100,000 的 log¹⁰ 是 0, 1, 2, 3, 4, 5。所以如果我们有一个包含非常小和非常大的数字的变量,如果我们对它进行 log¹⁰-变换,这个变量就可以更容易地处理了。

对数,尤其是自然对数,的另一个有用特性是,如果两个变量之间存在指数关系(例如,时间和细菌生长),取其中一个变量的对数可以将关系线性化。在变量之间工作线性关系通常在数学上更简单。

查看图 A.6 中的示例。figure A.6。左侧图表显示了具有非常小和非常大的值的 y 变量,其中 xy 变量之间的关系是指数增长的。右侧图表显示了相同的数据,但经过对 y 变量的 log¹⁰-变换。您可以看到,变换后,y 变量现在可以更容易地在图表上可视化,并且 xy 变量之间的关系已经被线性化。

图 A.6. 对数[10]变换对变量的影响。左侧图表显示了具有非常小和非常大的值的 y 变量。在右侧图表中,y 变量已经进行了 log[10]-变换。

监督学习与无监督学习。监督算法使用已经标记有真实标签的数据来构建一个可以预测未标记、新数据标签的模型。无监督算法使用未标记的数据,并从中学习模式,以便新数据可以映射到这些模式上。

本书涵盖的算法概要,无论它们是监督学习器还是无监督学习器,以及它们是否可用于分类、回归、降维或聚类


  1. [2] ↩︎

  2. [3] ↩︎

  3. [3] ↩︎

posted @ 2025-11-19 09:21  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报