机器学习的艺术-全-

机器学习的艺术(全)

原文:zh.annas-archive.org/md5/fa0633ebc32df7d4e35b48ff05a1be98

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Image

机器学习!以这样一个科幻感十足的名字,人们可能会认为它是技术,只适用于那些博学的专家。事实并非如此。

事实上,机器学习(ML)可以用常识性的术语来轻松解释,任何对图表、图形以及直线的斜率有一定了解的人都应该能够理解并有效地使用机器学习。当然,正如谚语所说,“魔鬼藏在细节中”,必须通过这些细节。然而,尽管机器学习是如此强大的工具,它并不是火箭科学。

0.1 什么是机器学习?

机器学习的核心是预测。一个病人是否患有某种疾病?一个客户是否会从当前的手机服务商换到别的?在这段听起来有些混乱的音频记录中,实际上说了什么?卫星观察到的那个亮点是森林火灾,还是仅仅是一个反射?

我们从一个或多个特征中预测一个结果。在疾病诊断的例子中,结果是是否患病,特征可能包括血液检查、家族病史等等。

所有机器学习方法都涉及一个简单的概念:相似性。在手机服务的例子中,我们如何预测某个客户的结果?我们查看过去的客户,选择与当前客户在特征(账单大小、延迟记录、年收入等)上最相似的那些客户。如果这些相似的客户大多数都离开了,我们就预测当前客户也会离开。当然,我们不能保证这个结果,但这是我们最好的猜测。

0.2 数学在机器学习理论与实践中的角色

许多机器学习方法基于优雅的数学理论,支持向量机(SVM)就是一个显著的例子。然而,掌握这些理论在实际应用中对应用支持向量机的能力几乎没有帮助。

诚然,良好的直观理解机器学习方法的工作原理对于在实践中有效使用机器学习至关重要。本书力求培养读者对直觉的敏锐理解,不使用高等数学。实际上,本书中几乎没有方程式。

0.3 为什么要另写一本机器学习书籍?

当然,市面上有很多很棒的机器学习书籍,但没有一本真正能够赋能读者在现实问题中有效使用机器学习。在很多情况下,问题在于书籍过于理论化,但我同样担心那些应用类书籍往往像“食谱书”(过于“按步骤操作”),以一种步骤 1、步骤 2、步骤 3 的方式来处理问题。它们的重点是机器学习软件的语法和语义,导致虽然读者可能熟悉软件,但他们并没有准备好去有效使用机器学习。

我写这本书是因为:

  • 需要一本使用R 语言而不是关于R 的书。这是一本关于机器学习的书,恰好使用 R 作为示例,而不是一本关于 R 在机器学习中应用的书。

  • 有一本机器学习书籍是必要的,它认识到机器学习是一门艺术,而非一门科学。(这也是本书标题的由来。)

  • 目前缺少一本避免高阶数学却能够强调一个观点的机器学习书籍——即为了有效使用机器学习,确实需要深入理解相关概念——机器学习方法的“为什么”和“如何”。 大多数“应用型”机器学习书籍在这方面讲得不够。

这三点都回归到“反菜谱”主题。我的目标是:

我希望使用机器学习的人不仅知道随机森林的定义,还能清晰地解释随机森林中各个超参数如何影响过拟合。机器学习者还应该能够清楚地阐述特征工程中的“p-hacking”问题。

我们将赋能读者,提供强大的、实用的机器学习方法的现实世界知识——它们的优缺点,是什么让它们成功与失败,应该注意哪些问题。我们将避免复杂的数学形式,且肯定会采用实践导向的方法,使用流行的软件包在真实数据集上进行操作。但我们将以一种聪明的方式进行。我们将成为“知情的消费者”。

0.4 特殊的反复出现的章节

本书中有一些反复出现的主题和章节:

偏差与方差

许多段落具体说明——没有迷信!——这两个核心概念在每种具体的机器学习方法中如何体现。

陷阱

许多标有“陷阱”标题的章节提醒读者潜在的问题,并展示如何避免它们。

0.5 需要的背景知识

读者需要什么样的背景才能有效地使用本书?

  • 本书不假设读者有机器学习或统计学的先前经验。

  • 一般来说,关于数学,本书大部分内容没有正式的方程式。只要读者对基本图表(如直方图和散点图)以及简单的代数概念(如直线的斜率)感到熟悉,就足够了。

  • 本书假设读者具备一定的 R 编程背景,例如对向量、因子、数据框和函数有所了解。本书贯穿使用 R 命令行(> 提示符,RStudio 控制台)。没有 R 背景的读者,或者希望复习的读者,可以参考我的 fasteR 教程:https://github.com/matloff/fasteR

  • 确保你已经在电脑上安装了 R 和 qeML 包。对于该包,推荐的安装源是 GitHub,因为它总是包含该包的最新版本。你需要安装 devtools 包;如果尚未安装,可以输入:

    install.packages('devtools')
    

    然后,要安装 qeML,请输入:

    install_github('https://github.com/matloff/qeML')
    

    qeML 包也会在 CRAN R 代码库中提供,但更新频率较低。

0.6 qe*-Series 软件

本书中大部分使用的软件将来自流行的 R 包:

  • e1071

  • gbm

  • glmnet

  • keras

  • randomForest

读者可以直接使用这些包。如果读者愿意,通常我们会使用这些包的函数封装,封装在我的包qeML中,这对于读者有很大帮助,主要体现在两个方面:

  1. 包装器提供了一个统一的接口。

  2. 这个统一接口也是简单的。

例如,考虑day1,这是本书中在多个地方使用的自行车租赁数据集。我们希望预测tot,即总骑行量。以下是我们如何使用随机森林来实现这一点的步骤,这是本书中介绍的机器学习话题:

qeRF(day1,'tot')

对于支持向量机,另一个重要话题,调用方法是:

qeSVM(day1,'tot')

等等。简单到不能再简单了!比如,没有必要编写定义模型的准备代码;只需调用qe函数之一,直接开始!前缀qe-代表“快速简便”。还可以指定特定方法的参数,我们也会这样做,但总的来说,依然非常简单。

对于非常高级的用法,本书展示了如何直接使用这些包。

0.7 本书的宏大计划

这里是我们将要走的路径。前面三章介绍了书中反复出现的一些通用概念以及具体的机器学习方法。上面提到的机器学习简要描述——基于相似案例进行预测——最容易通过一种叫做k 近邻(k-NN)的机器学习方法来实现。第一部分将承担两个角色。首先,它将详细介绍 k-NN。其次,它将向读者介绍适用于所有机器学习方法的通用概念,如超参数的选择。在 k-NN 中,通常表示为k的相似案例数量是超参数。对于 k-NN,什么是k的“黄金法则”值——既不太小也不太大?同样,超参数的选择在大多数机器学习方法中都至关重要,并将在 k-NN 中进行介绍。

第二部分将介绍 k-NN 的自然扩展——基于树的方法,具体是随机森林梯度提升。这些方法以类似流程图的方式工作,逐个询问特征问题。在前面的疾病诊断示例中,第一个问题可能是,患者是否超过 50 岁?接下来的问题可能是,患者的体重指数是否低于 20.2?最终,这个过程将患者分成相似的小组,所以它有点像 k-NN。但这些组与 k-NN 的形式不同,树方法通常在预测准确性上超过 k-NN,并被认为是主要的机器学习工具。

第三部分讨论了基于线性关系的方法。有一定线性回归分析背景的读者可能会认出其中的一些内容,尽管再次强调,假设读者没有这方面的背景知识。本部分最后讨论了LASSO岭回归,这两种方法有一个诱人的特点,即故意缩小一些经典线性回归的估计值。

第四部分涉及基于分隔线和平面的算法方法。再次考虑手机服务的例子。假设我们用蓝色在图表中绘制了离开服务的老客户的数据。然后,在同一张图上,我们用红色绘制了那些仍然忠诚的客户。我们能找到一条直线,将大部分蓝色点与大部分红色点分开吗?如果能,那么我们将通过检查新客户的案例在哪一侧来预测他的行为。这一描述不仅适用于SVM,在某种意义上,也适用于最著名的机器学习方法之一——神经网络,我们也将介绍它。

最后,第五部分介绍了几种特定类型的机器学习应用,比如图像分类

常说,没有一种机器学习方法在所有应用中都表现最佳。的确如此,但希望本书的结构能帮助你理解不同方法之间的相似性和差异,理解每种方法在整体中的适用场景。

本书有一个网站,http://heather.cs.ucdavis.edu/artofml,其中包含代码、更正、新示例等内容。

0.8 另一个要点

在阅读本书时,请记住,文笔和代码同样重要。避免仅专注于代码和图表。一个全是文字的页面——没有数学公式、没有图表、没有代码——可能是书中最重要的页面。在这一页,你将学习到机器学习中至关重要的为什么,比如为什么超参数的选择如此关键。文笔对你掌握机器学习的目标至关重要,它能帮助你获得最深刻的见解和预测能力!

请记住,你听到的那些令人眼花缭乱的机器学习成功案例,往往是在分析师经过仔细、长期的调优和思考后才出现的,这需要真正的洞察力。本书旨在培养这种洞察力。这里的正式数学内容最小化,但请注意,这意味着数学将让位于描述许多关键问题的文字。

那么,开始吧。祝你在机器学习中愉快!

第一部分:序言与基于邻域的方法

第一章:回归模型**

图片

在本章中,我们将介绍回归函数。这种函数根据一个或多个其他变量来给出某个变量的均值——例如,根据年龄给出儿童的平均体重。所有机器学习方法在某种形式上都是回归方法,意味着它们利用我们提供的数据来估计回归函数。

我们将介绍第一个机器学习方法,k 最近邻(k-NN),并将其应用于真实数据。我们还将融入一些贯穿全书的概念,如虚拟变量、过拟合、p-hacking、“脏数据”等。我们暂时只简要介绍这些概念,以便让你对后面会详细讨论的内容有一个宏观的了解:机器学习是直观且连贯的,但如果分阶段学习,掌握起来会更容易。读者,请做好准备,时常会看到类似“我们现在只讨论一个方面,稍后会详细介绍”的表述。

在开始之前,确保你已经在计算机上安装了 R 和qeML以及regtools包(后者版本需为 1.7 或更新)。你可以运行packageVersion('regtools')来检查版本。书中的所有代码展示假定用户已经加载了这些包:

library(regtools)
library(qeML)

所以,让我们来看一下第一个示例数据集。

1.1 示例:自行车共享数据集

在介绍 k-NN 之前,我们需要一些数据来进行操作。我们从 UC Irvine 机器学习库获取这个数据集,它包含 2011 年至 2012 年期间资本自行车共享系统的每小时和每天的租车数量,以及与天气和其他因素相关的信息。数据的更详细描述可在 UC Irvine 机器学习库中找到。¹

该数据集由数据管理者许可,作为regtools中的day数据集提供。然而,请注意,我们将使用稍微修改过的版本day1(也包含在regtools中),其中数值型天气变量以其原始尺度提供,而不是转换到区间[0,1]。

我们的主要兴趣是预测一天的总骑行人数。

一些术语

假设我们希望根据温度和湿度来预测骑行人数。标准的机器学习术语将用于预测的变量——在这种情况下是温度和湿度——称为特征

如果要预测的变量是数值型的,比如骑行人数,那么机器学习中没有标准术语来表示它。我们就称其为结果变量。但如果要预测的变量是 R 因子——即分类变量——它就被称为标签

例如,在本书后面,我们将分析关于人类脊椎疾病的数据集。这个数据集有三种可能的结果或类别:正常(NO)、椎间盘突出(DH)或脊椎滑脱(SL)。我们数据集中显示每个病人类别的那一列,NO、DH 或 SL,就是标签列。

我们的数据集,比如这里的day1,被称为训练集。我们使用它来预测未来的情况,其中特征已知但结果变量未知。我们预测的正是后者。

1.1.1 加载数据

数据以每小时和每天两种形式呈现,后者是regtools包中的数据格式。加载数据:

> data(day1)

对于任何数据集,首先查看数据总是个好主意。这个数据包含了哪些变量?它们是什么类型的,比如数值型还是 R 中的因子类型?它们的典型值是什么?一种做法是使用 R 的head()函数查看数据的前几行:

> head(day1)
  instant     dteday season yr mnth holiday
1       1 2011-01-01      1  0    1       0
2       2 2011-01-02      1  0    1       0
3       3 2011-01-03      1  0    1       0
4       4 2011-01-04      1  0    1       0
5       5 2011-01-05      1  0    1       0
6       6 2011-01-06      1  0    1       0
  weekday workingday weathersit     temp
1       6          0          2 8.175849
2       0          0          2 9.083466
3       1          1          1 1.229108
4       2          1          1 1.400000
5       3          1          1 2.666979
6       4          1          1 1.604356
      atemp      hum windspeed casual registered
1  7.999250 0.805833 10.749882    331        654
2  7.346774 0.696087 16.652113    131        670
3 -3.499270 0.437273 16.636703    120       1229
4 -1.999948 0.590435 10.739832    108       1454
5 -0.868180 0.436957 12.522300     82       1518
6 -0.608206 0.518261  6.000868     88       1518
   tot
1  985
2  801
3 1349
4 1562
5 1600
6 1606
> nrow(day1)
[1] 731

我们看到数据集包含 731 行数据(即 731 个不同的日期),包括日期、日期性质(例如weekday)、以及天气状况(如温度temp和湿度hum)。最后三列分别表示来自临时用户、注册用户和总用户的骑行数据。

你可以通过?day1命令获取更多关于数据集的信息。

1.1.2 展望未来

我们很快就会开始对这些数据进行实际分析。现在,先来个预览。假设我们希望基于特定的天气条件等因素预测明天的总骑行量。我们如何通过 k-NN 算法来实现这一点?

我们将通过搜索数据,寻找与这些天气条件及其他变量匹配或几乎匹配的数据点。然后,我们将这些数据点的骑行量取平均值,这将是我们对这一天的预测骑行量。

这么简单,真的能行吗?其实不,确实如此;上述描述是准确的。当然,正如老话说的那样,“魔鬼藏在细节中”,但过程确实简单。不过,首先,让我们处理一些一般性的问题。

1.2 机器学习与预测

机器学习本质上是关于预测的。在我们深入了解第一个机器学习方法的细节之前,我们应该确保明白“预测”是什么意思。

假设考虑共享单车数据集。一天的清晨,共享单车服务的经理可能想要预测当天的总骑行人数。经理可以通过分析特征之间的关系——如各种天气条件、当天的工作状态(工作日、节假日)等——来做出预测。当然,预测并不完美,但如果结果接近实际数字,它们就非常有帮助。例如,它们可以帮助经理决定需要提供多少辆自行车,并确保轮胎充气完好等。(更高级的版本是预测每个站点的自行车需求,从而进行相应的重新分配。)

1.2.1 预测过去、现在与未来

著名棒球运动员和误用词语的笑话大师约吉·贝拉曾说过:“预测是困难的,特别是关于未来的预测。”尽管这句话很有趣,但他确实说到了一点;在机器学习中,预测不仅仅是指未来,也可以指现在甚至过去。例如,一位研究者可能希望估算 1700 年代工人的平均工资。或者一位医生可能希望根据血液检查、症状等做出诊断,判断患者是否患有某种疾病,这时是猜测患者当前的病情,而不是未来的病情。所以,当我们在机器学习领域谈论“预测”时,不要过于字面地理解“预-”这一部分。

1.2.2 统计学与机器学习在预测中的区别

一个常见的误解是,机器学习关注的是预测,而统计学家则做推断——即置信区间和对感兴趣量的检验——但预测无疑是统计学领域中的一个重要部分。

统计学与机器学习社区之间有时存在一种友好的竞争关系,甚至在术语上也有所不同(参见附录 B)。事实上,统计学家有时使用“统计学习”这一术语来指代在机器学习领域中被称为机器学习的相同方法!

作为一名曾在计算机科学系度过大部分职业生涯的前统计学教授,我可以同时站在两个阵营的立场上。我将以计算的术语来介绍机器学习方法,但也会结合统计学原理来提供一些见解。

历史性备注

本书中讨论的许多方法,构成了机器学习的骨干部分,最初都是在统计学界发展起来的。这些方法包括 k-NN、决策树或随机森林、逻辑回归,以及 L1/L2 收缩法。这些方法源自 19 世纪提出的线性模型,但后来的统计学家认为这些模型在某些应用中并不充分。正是这种考虑促使了对假设较少限制的方法的兴趣,最终促成了 k-NN 等技术的发明。

另一方面,另外两种著名的机器学习方法,支持向量机(SVM)和神经网络,几乎完全在统计学领域之外发展,尤其是在大学计算机科学系中。(另一种方法,boosting,最初起源于计算机科学,但得到了两个领域的重大贡献。)它们的发展动力完全与统计学无关。正如我们在媒体中常听到的,神经网络最初是作为理解人类大脑运作的一种手段进行研究的。SVM 则单纯以计算机科学算法的角度来看待——给定一组属于两类的数据点,我们如何计算出最佳的分割线或平面?

1.3 介绍 k-最近邻方法

本章的重点方法是k-最近邻,简称k-NN。它可以说是最古老的机器学习方法,最早可追溯到 1950 年代初期,但今天依然被广泛使用,尤其是在特征数量较少的应用场景中(原因将在后文解释)。它也很容易解释并且易于实现——是本章入门部分的完美选择。

1.3.1 使用 k-NN 预测自行车使用量

首先,我们来看一下如何使用 k-NN 根据单一特征:温度预测自行车使用量。假设当天的温度预报为 28 摄氏度。我们应该如何使用 28 这个温度值和我们的历史骑行数据集(训练集)来预测当天的使用量呢?没有机器学习背景的人可能会建议查看数据中的所有日期,筛选出温度最接近 28 的那些日期(可能没有或很少有完全 28 度的日期),然后计算这些日期的平均骑行量。我们将用这个平均值作为当天的预测骑行量。

事实上,这个直觉是正确的!实际上,这正是许多常见机器学习方法的基础,正如我们将在第 1.6 节的回归函数中进一步讨论的那样。目前,只需要知道,k-NN 的形式就是简单地对相似的案例进行平均——也就是对相邻的数据点进行平均。k是我们使用的邻居数量。例如,我们可以选择温度最接近 28 的 5 个历史天数,对这些天的骑行量进行平均,然后用这个结果来预测温度为 28 度的当天的使用量。

在本章后面的部分,我们将学习如何使用qe*系列实现的 k-NN,qeKNN()。不过目前,我们先手动执行 k-NN 操作,以便更好地理解这种方法。

好的,准备好了!在这一节中,我们将做出我们的第一个预测。

1.3.1.1 R 子集操作回顾

在展示代码之前,让我们回顾一下 R 语言的一些方面。回想一下,在 R 中,#符号用于注释;也就是说,它不是代码的一部分,而是用于解释的。下面代码中的注释和全书中的注释都用来作为代码执行过程的内联解释。

对于即将到来的示例,您还需要记住在 R 中如何进行子集操作。例如,看看这段代码:

> x <- c(5,12,13,8,88)
> x[c(2,4,5)]
[1] 12  8 88

表达式x[c(2,4,5)]提取向量x中的第 2、第 4 和第 5 个元素。同时请记住,这里我们将 2、4 和 5 称为下标索引

1.3.1.2 第一个预测

这里是我们的小型手动 k-NN 示例:

> data(day1)
> tmps <- day1$temp
> dists <- abs(tmps - 28)  # distances of the temps to 28
> do5 <- order(dists)[1:5]  # which are the 5 closest?
> dists[do5]  # and how close are they?
[1] 0.005849 0.033349 0.045000 0.045000 0.084151

两个数之间的距离是它们差的绝对值:|25 − 32| = 7,因此 25 与 32 的距离是 7。这就是我们调用 R 的abs()(绝对值)函数的原因。R 的order()函数类似于sort(),不同之处在于它展示了排序数字的索引。以下是一个示例:

> x <- c(12,5,8,88,13)
> order(x)
[1] 2 3 1 5 4

数值 2、3 等表示:“x 中的最小值是 x[2],第二小的是 x[3],以此类推。”这一行

> do5 <- order(dists)[1:5]  # which are the 5 closest?

将会把do5中包含的索引设置为day1中与 28 度温度最接近的 5 个行。在这种情况下,温度与 28 度非常接近;最远的差距仅为 0.08。

那些天的乘车人数是多少?

> day1$tot[do5]
[1] 7175 4780 4326 5687 3974

然后我们计算这些值的平均值:

> mean(day1$tot[do5])
[1] 5188.4

我们现在可以预测,在气温为 28 度的日子里,大约会有 5,200 名乘客使用共享单车服务。

还有一些悬而未决的问题,尤其是:为什么选择与 28 度最接近的 5 天?样本量 5 是否太小,或者是否足以做出准确预测?这是机器学习中的一个核心问题,我们将在第 1.7 节中进一步探讨。

1.4 虚拟变量与分类变量

为了处理这个数据集并进行机器学习,你需要理解数据中几个表示虚拟变量的列。虚拟变量只有 1 和 0 两种取值,取决于它们是否满足特定条件。例如,在workingday列中,0 表示“否”(所示日期不是工作日),1 表示“是”(所示日期是工作日)。2011 年 1 月 5 日的workingday列值为 1,表示这是一个工作日。

虚拟变量有时被更正式地称为指示变量,因为它们指示某个特定条件是否成立(代码 1)或不成立(代码 0)。在机器学习领域,另一种流行的术语是独热编码

我们的共享单车数据还包括分类变量mnthweekday。还有一个特征weathersit,包含四个类别(1 = 晴天,2 = 薄雾或多云,3 = 小雪或小雨,4 = 大雨、冰雹或雷暴)。这个变量也可以视为分类变量。

虚拟变量的一种常见用法是对分类数据的编码。例如,在市场研究中,一个感兴趣的因素可能是居住地区的类型,比如城市、郊区或乡村。我们原始的数据可能将这些类别编码为 1、2 或 3。然而,这些只是任意的代码,因此,例如,乡村并不意味着比城市好三倍。但机器学习算法可能会将其理解为如此,这是我们不希望的。

本书及整个机器学习领域常用的解决方案是使用虚拟变量。我们可以为城市(1 = 是,0 = 否)和郊区创建虚拟变量。乡村属性则通过将城市和郊区都设置为 0 来编码,这样我们就不需要第三个虚拟变量(拥有第三个变量可能会引发本书范围之外的技术问题)。当然,使用前两个值作为虚拟变量并没有什么特别之处;我们也可以仅为城市和乡村创建虚拟变量,而不使用郊区;在这种情况下,郊区会通过城市和乡村的 0 值来表示。

在当前章节中,我们专注于应用场景,其中我们的结果变量是数值型的,例如共享单车数据中的总骑行量。但在许多应用中,结果变量是类别型的,例如我们之前的预测脊椎疾病的例子。在这种情况下,称为分类应用Y变量是类别型的,必须转换为虚拟变量。

幸运的是,大多数机器学习包,包括qeML,会自动将数据转换为虚拟变量,正如我们稍后所看到的那样。

1.5 使用 qeKNN() 进行分析

既然我们对底层的运作有了更清晰的了解,让我们尝试使用 qeKNN() 函数进行一些 k-NN 分析。正如在介绍中所提到的,本书使用了 qe* 系列的封装函数。对于 k-NN,这意味着使用 qeKNN()。后者封装regtools 的基本 k-NN 函数 kNN(),也就是说,qeKNN() 调用了 kNN(),但以更简单、方便的方式。

在我们开始分析之前,我们将引入一些“X”和“Y”符号,以帮助我们跟踪正在进行的工作。做笔记——这对于之后的章节非常重要。

特征 X 和结果 Y

以下是机器学习和统计学领域中常用的非正式简写,表示特征和结果的标准符号:

  • 传统上,特征通常统称为 X,而要预测的结果称为 Y

  • X 是数据框或矩阵中的一组列。如果我们根据温度和湿度预测骑行量,X 就是数据中这两个列。这里的 Y 是骑行量列。

在二分类应用中,Y 通常是一个虚拟变量,即一列 1 和 0。然而,在多分类应用中,Y 是一组列,每一列对应一个虚拟变量。或者,Y 也可以是一个 R 因子,存储在单列中。

还有一个标准符号:

  • X 中的行数——即数据点的数量——通常用 n 来表示。

  • X 中的列数——即特征的数量——通常用 p 来表示。

这只是一个方便的简写。例如,说“X”比说更繁琐的“我们的特征集”要容易。再次强调,XYnp 将在本书中(以及其他地方,因为它们是机器学习领域的标准符号)反复出现,因此请确保记住它们。

1.5.1 使用 qeKNN() 预测共享单车骑行量

对于共享单车的例子,我们将预测任意一天的总骑行量。我们先从使用工作日的虚拟变量和数值型天气变量作为特征开始。

我们来提取 day1 数据框中的这些列。正如我们在第 1.1.1 节中看到的,它们位于第 8 列和第 10 至 13 列,而第 16 列包含结果变量,即总骑行量(tot)。因此,我们可以通过以下表达式获得这些列:

day1[,c(8,10:13,16)]

这提取了所需的列。

注意

或者,您可能更喜欢使用列名而非数字

day1[c('workingday','temp','atemp','hum','windspeed','tot')]

在基础 R 中,或者使用 tidyverse 或 data.table,每种方法都可以正常工作。数字数据框索引更容易输入,但使用列名可能会更清晰。

正如在介绍中所指出的,这是一本关于机器学习的书,它恰好使用 R 作为教学工具,而不是一本关于 R 在机器学习中的书。各个读者如何处理 R 中的数据操作并不是本书的重点,所以请随意使用你自己喜欢的方式来实现相同的结果。

所以,我们构建子数据框,并像往常一样查看一下。

> day1 <- day1[,c(8,10:13,16)]
> head(day1)
  workingday     temp     atemp      hum windspeed  tot
1          0 8.175849  7.999250 0.805833 10.749882  985
2          0 9.083466  7.346774 0.696087 16.652113  801
3          1 1.229108 -3.499270 0.437273 16.636703 1349
4          1 1.400000 -1.999948 0.590435 10.739832 1562
5          1 2.666979 -0.868180 0.436957 12.522300 1600
6          1 1.604356 -0.608206 0.518261  6.000868 1606

现在我们来进行一次预测。假设你是今天早上的经理,今天是工作日,温度 12.0,atemp 为 11.8,湿度为 23%,风速为每小时 5 英里。你会如何预测骑行人数?

所有qe*系列函数都有一个非常简单的调用形式:

qeSomeMLmethod(your_data_frame, Y_name, options_if_any)

这个示例中使用的选项是k,即最近邻的数量,我们设定为 5 个。(如果在调用时没有指定k,默认值是 25。)

> knnout <- qeKNN(day1,'tot',k=5)  # fit the k-NN model
holdout set has 73 cases

我们正在应用 k-NN 方法,将我们的“Y”(或者“结果”,如你所记得)设为数据框day1中的变量tot,并使用 5 个邻居。我们稍后会讨论持出集,但现在先不考虑这个。

我们将qeKNN()的返回值保存在knnout中。(当然,你可以使用任何你喜欢的名字。)它包含了大量信息,但我们暂时不考虑这些信息。qeKNN()函数做了预处理,使我们能够使用knnout进行未来的预测。

那么,这到底是怎么做到的呢?qe*系列函数的一般形式同样非常简单:

predict(output_from_qe_function, new_case)

假设作为共享单车业务的经理,我们知道今天是工作日,温度、atemp、湿度和风速分别为 12.8、11.8、0.23 和 5。以下是我们的预测:

> today <- data.frame(workingday=1, temp=12.8, atemp=11.8, hum=0.23,
   windspeed=5)
> predict(knnout,today)  # predict new case
     [,1]
[1,] 6321

我们将knnout传入predict()函数,并指定预测点today,得到预测的骑行人数:约 6,300 人。注意,参数today是一个数据框,其列名与原始数据集day1中的列名相同。这是必须的,无论在这里还是在许多 R 机器学习包中,都是为了将预测点的名称与训练集的名称匹配。

注意

如果你自己运行上述代码,可能会得到不同的输出,因为测试集的随机性。确实有一种方法可以标准化结果,以确保不同的人得到相同的结果;这一点将在第 1.12.3 节中解释。

让我们再做一个预测,假设和上述相同,但风速为 18:

> anotherday <- today
> anotherday$windspeed <- 18
> predict(knnout,anotherday)
     [,1]
[1,] 5000

人们似乎在风中不太愿意骑行。

第二个参数,比如上面的anotherday,可以是任何具有相同列名的数据框。例如,我们本可以同时要求两个预测:

> predict(knnout,rbind(today,anotherday))
     [,1] [,2]
[1,] 6321 5000

在第 1.8 节中,我们将通过分析另一个数据集深入探讨 k-NN。

技术说明:PREDICT()和通用函数

我们看到qeKNN()predict()函数配对。所有qe*系列的函数也都是如此配对,这在 R 中是一种非常常见的技巧。

尽管所有这些函数看起来都使用相同的predict(),每个函数实际上都有自己独立的预测函数,例如在qeKNN()的情况下是predict.qeKNN()predict()本身在 R 中被称为通用函数。它只是分派调用,这意味着它将原始调用转发到一个特定于我们正在进行的分析类型的函数。因此,调用predict()在由qeKNN()创建的对象上,实际上会被转发到predict.qeKNN(),依此类推。

R 有多种通用函数,其中一些你可能已经在使用了,也许你并没有意识到。比如print()plot()summary()。我们将在第 9.6 节中看到plot()的例子。

1.6 回归函数:机器学习的基础

要理解机器学习方法,你需要知道学习的是什么。答案是一个叫做回归函数的东西。直接或间接地(通常是后者),它是机器学习方法的基础。它给出了一个变量的均值,同时保持另一个变量不变。让我们具体说明一下。

注意

回归函数是一个广泛的统计学和机器学习术语,比一些读者在统计学课程中学到的线性回归概念要广泛得多。

回想我们之前的例子,其中我们将 28 度的日子的预测值定为接近该温度的几天的平均乘客量。如果我们要预测 15 度的日子的乘客量,我们将使用所有 15 度或接近 15 度的日子的平均乘客量作为预测值,依此类推。用r()表示回归函数,感兴趣的量是r(28)、r(15)等。

我们说r()是乘客量对温度的回归函数。它确实是一个函数;对于每一个输入(一个温度),我们得到一个输出(相应的平均乘客量)。我们使用这个函数来进行预测;例如,要预测 15 度日子的乘客量,我们使用r(15)的估计值。

但是它是一个未知的函数,而不是像sqrt()这样熟悉的东西。因此,我们需要使用我们的训练数据来推断该函数的值。在机器学习术语中,我们说我们“学习”这个函数,使用我们的数据,展示机器学习中 L 的来源。(M 仅表示我们使用计算机或算法来进行学习。)例如,在上面的例子中,我们通过平均温度接近 28 的几天的乘客量来学习 r(28)。因此,“学习”一词反映在训练中,即训练数据一词。

通常使用“帽子”符号表示“估计值”。因此,我们用Images表示我们对r()的估计值。

回归函数也被称为 条件均值。在根据温度预测乘客量时,r(28) 是在温度为 28 的条件下的平均乘客量。这是一个子人群的均值,和整个总人群的均值有很大不同。

让我们总结一下这些重要的观点:

  • 回归函数 r() 给出了我们的结果变量作为特征的函数的均值。

  • 我们从训练数据中估计 r()。我们将这个估计值称为 Images

  • 我们使用 Images 作为我们预测的基础。

任何函数都有参数。回归函数的参数数量与我们有多少特征有关。比如,我们将湿度作为第二个特征。为了预测温度为 28 并且湿度为 0.51 的一天的乘客量,我们会使用在温度和湿度大约为 28 和 0.51 的日子中,我们数据集中的平均乘客量。在回归函数的符号中,就是 r(28, 0.51)。在第 12 页的示例中,关注的值是 r(1, 12.8, 11.8, 0.23, 5)。

如前所述,回归函数在所有预测性机器学习方法中,直接或间接,都是基础内容。在本书中,它会反复出现。

1.7 偏差-方差权衡

在引言部分,特别是在第 0.8 节,我们恳请读者:

一页完全由散文构成——没有数学、没有图表,也没有代码——可能是本书中最重要的一页。

本节中的页面是这一点的典范,因为 偏差-方差权衡 是该领域最著名的话题之一。我的谷歌搜索结果显示了 18,400,000 个相关结果!这是机器学习中的一个绝对核心问题,我们将在第三章中深入探讨。然而,你应该从一开始就意识到它,所以我们来做一个概述。

问题在于,例如,选择较大或较小的 k 值。较大的 k 值具有较小的方差,但较大的偏差;而较小的 k 值则有相反的效果。让我们看看这一点是如何运作的。

1.7.1 与选举民调的类比

首先考虑与选举调查的类比。在选举竞选期间,会进行选民民意调查,以估计各候选人的受欢迎程度。分析师会从所有电话号码的集合中随机抽取样本,并向接电话的人征求意见。

假设我们对 p 感兴趣,p 是支持候选人 C 的整个选民群体的比例。由于我们只有该人群体的一个样本,我们只能通过我们样本中喜欢候选人 C 的比例 Images 来估计 p 的值。因此,民意调查结果会伴随一个 误差范围,以表明报告的比例 Images 仅是 p 的估计值。(有统计学背景的人可能知道,误差范围是 95% 置信区间的半径。)

误差范围给出了我们估计值的准确性。它衡量的是抽样变异性。值越大,说明我们对 p 的估计在不同样本之间差异越大;如果调查员再次随机抽取电话号码,估计的 p 值可能会有很大不同,尤其是样本量较小的时候。自然,调查员不会再进行第二次抽样,但从一个样本到下一个样本的抽样变异性告诉我们 p 的估计有多可靠。误差范围反映了这种抽样变异性,如果误差范围较大,则说明我们的样本量太小。

关键问题是抽样变异性,也就是 方差,它可以通过误差范围在投票示例中进行计算。

也可能会出现偏差问题。假设调查员只有固定电话的号码列表,没有手机号码。许多人,尤其是年轻人,没有固定电话,因此仅拨打固定电话的号码可能会导致结果有偏差。

1.7.2 回到机器学习

回到机器学习,考虑第 1.5.1 节中的共享单车示例,我们希望得到 r(1, 12.8, 11.8, 0.23, 5) 的值,这将作为我们的预测值。我们将得到一个估计值,Images(1, 12.8, 11.8, 0.23, 5),作为实际预测值。

我们将每日骑行者数量的数据视为从所有日子(无论是过去、现在还是未来)的(相当抽象的)总体中抽取的样本。使用 k-NN 方法(或任何其他机器学习方法),我们只是在估计真实总体回归函数 r()。仅从最近的 k = 5 个邻居中形成预测值,实际上是基于一个非常小的样本。想象一下,调查员只抽取了 5 位选民!在另一个样本中,与我们预测的点最接近的日子会不同,骑行人数的值也会不同。换句话说,这是一个方差问题。

另一方面,使用 k = 5,我们在第 1.3.1.2 节中发现,5 个邻居都非常接近预测点。假设我们查看最近的 k = 50 个邻居。在这种情况下,我们可能会使用距离预测点较远的数据点,这些数据点与预测点的相似度较低。这将导致偏差问题。

总之,较大的 k 值降低了方差,但以增加偏差为代价。我们想找到一个“黄金值” k ——即既不太小也不太大。

然而,请注意,计算时间和方法所需的内存量也是重要因素:如果最佳方法运行时间过长或占用内存过多,我们可能会选择另一种方法。

1.8 示例:mlb 数据集

为了加深你对qeKNN()的理解,并介绍一个我们可以在本书剩余部分中参考的例子,让我们尝试在mlb数据集上进行类似的操作。该数据集由 UCLA 统计学系提供,记录了大联盟棒球运动员的身高和体重,单位分别是英寸和磅。它包含在regtools中。

让我们先浏览一下数据,这样我们知道自己在处理什么:

> data(mlb)
> head(mlb)
             Name Team       Position Height
1   Adam_Donachie  BAL        Catcher     74
2       Paul_Bako  BAL        Catcher     74
3 Ramon_Hernandez  BAL        Catcher     72
4    Kevin_Millar  BAL  First_Baseman     72
5     Chris_Gomez  BAL  First_Baseman     73
6   Brian_Roberts  BAL Second_Baseman     69
  Weight   Age PosCategory
1    180 22.99     Catcher
2    215 34.69     Catcher
3    210 30.78     Catcher
4    210 35.43   Infielder
5    188 35.71   Infielder
6    176 29.39   Infielder

让我们预测一个新球员的体重,我们只知道他的身高和年龄分别为 72 和 24。

> w <- mlb[,c(4:6)]  # extract height, weight, and age
> z <- qeKNN(w,'Weight')  # fit k-NN model
holdout set has  101 rows
> predict(z,data.frame(Height=72,Age=24))
       [,1]
[1,] 182.56

请再次注意,我们需要在与mlb相同的数据框形式中指定预测点(72,24),即我们拟合模型的那个数据集。

1.9 k-NN 和类别特征

在之前的棒球球员示例中,身高和年龄这两个特征都是数值型的。但是,如果我们添加一个第三个特征,Position,这是一个类别变量呢?由于 k-NN 是基于距离的,特征需要是数值型的,以便计算数据点之间的距离。那么我们如何使用 k-NN 处理非数值型特征呢?

答案当然是,类别变量(也就是 R 因素)应该转换为虚拟变量。我们可以通过regtools函数factorToDummies()来完成这个转换。不过,由于qe*系列函数在需要时会自动进行这种转换,我们不需要自己将Position转换为虚拟变量。qeKNN()函数也会在其输出中做出标记,以便predict()在预测时进行相同的转换。

例如,假设我们想计算一个新球员的体重,除了身高和年龄外,还使用类别变量Position

> knnout1 <- qeKNN(mlb[,3:6],'Weight',25)  # extract Position, Height, Age
> predict(knnout1,data.frame(Height=72,Age=24,Position='Catcher'))
     [,1]
[1,]  197

在第一次预测中,没有使用Position时,我们的预测值大约是 183 磅。但如果我们知道新球员是捕手,我们会看到,在这种情况下,预测值增加到 197 磅。这是有道理的;捕手通常体型较大,以便守住本垒。

qe*系列函数通过其作为 R 因素的状态来识别类别特征。在这里的示例中,Position确实是一个 R 因素。如前所述,qeKNN()predict.qeKNN()的内部机制会自动为我们完成虚拟变量的转换,这是一个非常方便的功能。

通常,类别特征已经在数据框中作为因素表达。在某些情况下,特征是类别型的,但以数值编码的形式表达。如果是这种情况,可以应用as.factor()将其转换为因素形式。

1.10 数据缩放

许多机器学习方法中都涉及到一个主题,那就是数据缩放。在你未来的机器学习项目中,最好记得考虑缩放。许多机器学习方法都使用缩放,即使某些方法不要求缩放,进行缩放也可能产生更好的结果。

让我们回到棒球选手的数据。考虑两个选手,一个身高 70,年龄 24,另一个身高 72,年龄 30。在代数的上下文中,考虑这两个数值对,它们的距离是平面上(70,24)和(72,30)这两点之间的距离:

Image

这样的距离会在 k-NN 中进行计算。但如果身高被转换为米,年龄转换为月数,会怎样呢?身高会除以 39.37,而年龄则会乘以 12:

> sqrt((70/39.37-72/39.37)² + (12*24-12*30)²)
[1] 72.00002

这样会产生一个问题,即年龄会主导计算,身高只起到较小的作用。由于身高显然是预测体重的重要因素,因此单位的变化可能会降低我们的预测能力。

解决方法是去掉像英寸、米这样的单位,这叫做缩放。为了缩放我们的数据,首先减去平均值,使每个数据的均值为 0,这叫做中心化。然后,我们将每个特征除以其标准差,缩放。这使得所有特征的标准差为 1。现在,所有特征都没有单位且具有可比性。(通常,在进行缩放时,我们也会进行中心化,这个过程通常叫做缩放。)

为了使这个概念更具体化,作为一名曾经或现在的学生,你可能会记得其中一位教授这样转换考试分数:“要获得 A,你需要比平均分高出 1.5 个标准差。”这位教授是在减去考试的平均分,然后除以标准差。R 函数scale()为我们执行了这个操作,但为了说明,下面是我们如何自己操作的:

> ht <- mlb$Height
> ht <- (ht - mean(ht)) / sd(ht)
> mlb$Height <- ht

qeKNN()函数有一个参数scaleX,用于此目的。其默认值为TRUE,所以在我们之前的 k-NN 示例中,默认进行了缩放。在每个X列(回想一下这意味着特征列)中,qeKNN()会通过调用scale()来转换该列。(实际上,我们可以通过一次scale()调用来缩放所有X变量。)

当然,我们必须记得对新的预测案例中的X值进行相同的缩放——即除以相同的标准差——比如在共享单车示例中的新一天。qe*系列函数会注意到这一点,然后由配套的predict()函数使用。

注意

请记住,scale()不会对所有值相同的向量起作用,因为标准差为 0。我们可以通过调用 regtools 函数 constCols()来检查这一点,该函数会报告数据框中的所有常数列。

在共享单车数据中,day1使用的是未经缩放的数据,主要用于教学目的。数据的“官方”版本day是经过缩放的。虽然它以不同的方式进行缩放:它将变量的值缩放到区间[0,1]内。实现这一点的一种方法是通过以下方式进行转换:

Image

这比 scale() 有优势,因为它生成了有界变量——即被限制在 0 和 1 之间的数值。相比之下,scale() 生成的变量位于区间 (–∞, ) 内,而标准差较小的变量可能会有非常大的缩放值,这会在分析中给予该变量过大的影响。

regtools 函数 mmscale() 执行上述的映射,将数据转换到 [0,1] 范围内。这里是一个小例子:

> x <- data.frame(u=3:5,v=c(12,5,13))
> x
  u  v
1 3 12
2 4  5
3 5 13
> mmscale(x)
     [,1]  [,2]
[1,]  0.0 0.875
[2,]  0.5 0.000
[3,]  1.0 1.000

u 列的均值为 4,最小值和最大值分别为 3 和 5。因此,第二行的 4 被替换为 (4 – 3) / (5 – 3) = 1/2,例如。正如你所看到的,所有结果值都在 [0,1] 范围内,并且是无量纲的——也就是说,没有英寸或月份的单位。

1.11 选择超参数

在介绍中,我们提到最近邻的数量 k 是一个超参数调优参数,是由用户选择的一个值,它会影响模型的预测能力。正如在第 1.7 节中所述,k 是一个“金发姑娘”的量值,需要仔细设置以获得最佳性能——既不能太小,也不能太大。

找到最佳的 k 可能是一个挑战。幸运的是,k-NN 只有一个超参数,但正如你稍后在书中看到的,大多数机器学习方法有多个超参数;在一些超出本书范围的高级方法中,甚至可能有十个或更多。选择多个超参数的“正确”组合尤其困难,但即便是为单个超参数选择一个合适的值也并非易事。

我们在下面首次探讨解决选择超参数这一挑战性问题的方法,然后将在第三章中详细讨论。请在这一节多花点时间,因为这个问题将在本书中反复出现,并贯穿你整个机器学习职业生涯。

1.11.1 预测训练数据

几乎所有选择超参数的方法都涉及通过预测新案例来测试我们的模型。在最基本的形式中,我们会回头预测我们原始的训练数据。这听起来有点奇怪——我们已经知道数据中的乘车量,那么为什么还要预测它们呢?其背后的思路是尝试不同的 k 值,看看哪一个能最好地预测我们的已知数据。然后,这个 k 值将是我们未来预测新 X 数据时所使用的值。

这并不理想,实际上通常会对这种方法进行轻微修改。我们也会使用这种方法,但在介绍它之前,让我们先通过一个例子看看可能会出错的地方。让我们用 k 的最小值:1 来预测 day1 中的第三个数据点。

> kno <- qeKNN(day1,'tot',1)
> datapoint3X <- day1[3,-6]  # remote Y value
> predict(kno,datapoint3X)
     [,1]
[1,] 1349
> day1[3,6]
[1] 1349

我们的预测完全正确!但是等一下……这似乎有些可疑,的确是。数据点 3(我们数据中的第三行)最接近的邻居是它自己!该点到它自己的距离是 0。同样地,第 8 行的数据点与第 8 行最接近,第 56 行的数据点与第 56 行最接近,依此类推。当然我们是 100%正确的;我们取的是 1 个最近邻的平均值,因此只是复制了Y值。同样的分析表明,即使是k = 5 也会给出过于乐观的预测准确度。5 个邻居中的一个仍然是原始数据点,从而偏向了我们的预测。

关键是,当我们评估给定k值的预测准确性时,我们应该在一个与拟合 k-NN 方法的数据集不同的数据集上进行预测。但是,你可能会抗议,我们只有一个数据集。那么,我们如何才能得到另一个数据集来正确评估预测准确性呢?这正是下一节关于验证集的内容。

1.12 验证集

同样,在前一节中提到的,为了评估模型的预测准确性,我们需要在“新鲜”的数据上进行测试,而不是在模型拟合过的数据上。但是我们没有新的数据,那该怎么办呢?解决这个困境的关键是本节介绍的验证集交叉验证的概念。它们是机器学习中的核心内容,并且会在整本书中反复出现。

day1的 731 个数据点中,我们可以随机抽取出,比如说 100 个数据点。这些数据点将作为我们的验证集,或者测试集。其余的 631 个数据点将暂时作为我们的训练集。我们将模型拟合到这个训练集上,然后查看它在验证集上的预测效果,验证集作为“新鲜”的数据,不受训练数据的偏倚。(在这里的例子中,验证集大小是 73,因为默认大小是数据集的 10%)

从技术上讲,如果我们将模型拟合到整个数据集,未来的预测准确度最好,此时我们将holdout=NULL。然而,在初步探索阶段,了解我们的模型在新数据上的表现是很重要的。因此,在探索阶段最好使用验证集,然后在选择超参数等后,重新拟合整个数据集。

在选择验证集之前,如果我们要评估预测的质量,我们需要一个评估预测准确性的标准。

1.12.1 损失函数

对于验证集中的一个数据点,我们可以计算实际值和预测值之间的绝对差值,然后将所有验证集点的绝对差值取平均,得到平均绝对预测误差(MAPE)。这是一个损失函数的例子,损失函数只是一个预测优度的标准。我们可以将 MAPE 作为这个标准,值越小越好。

另一种常见的损失函数是均方预测误差(MSPE),它是将预测误差的平方进行平均,而不是其绝对值。MSPE 是两者中更常用的一个,但我更倾向于使用 MAPE,因为 MSPE 过于强调大误差。假设我们在 MLB 数据中预测体重,考虑两种情况,其中误差分别为 12 和 15 磅。这两个数字相当相似,但它们的平方,144 和 225,差别就很大了。

对于分类应用,最常用的损失函数就是简单的误分类总体概率。我们预测保留集中的每个Y,统计错误的次数,然后除以保留集的大小。

1.12.2 qe*系列中的保留集

qe*系列函数将自动执行上述寻找 MAPE 或整体误分类概率的过程,然后将结果报告在输出组件testAcc中。这些函数会根据我们是否将Y(任何qe*系列调用中的第二个参数)指定为 R 因子,自动判断我们的应用是分类问题。如果是这样,计算的将是误分类概率,而不是 MAPE。

我们将再次使用共享单车数据集,作为寻找qeKNN自动生成的 MAPE 的例子:

> knnout <- qeKNN(day1,'tot',5)
holdout set has  73 rows
> knnout$testAcc
[1] 1203.644

使用 5 个最近邻,我们的平均预测误差大约是 1,200 名骑行者。虽然这不是很好,但比起另一种方法要好,如下所示。

假设我们无法获取天气条件等信息,那我们该如何预测骑行量呢?一个自然的想法是直接使用总体平均值:

> meanTot <- mean(day1$tot)
> meanTot
[1] 4504.349

换句话说,每天,包括今天,我们将预测大约 4,500 名骑行者。使用这个策略我们会有怎样的表现呢?

> mean(abs(day1$tot - meanTot))
[1] 1581.793

如果我们总是使用每日的总体平均骑行人数来预测骑行量,我们的平均预测误差将接近 1,600。使用天气变量和workingday作为预测因子确实有助于将 MAPE 降低到 1,200。

我们能找到比 5 更好的k值吗?我们来试试 10 和 25:

> qeKNN(day1,'tot',10)$testAcc
holdout set has  73 rows
[1] 1127.333
> qeKNN(day1,'tot',25)$testAcc
holdout set has  73 rows
[1] 1131.108

在我们尝试的值中,k = 10 似乎是最有效的,尽管我们必须记住保留集的随机性。事实上,对于每个候选的k值,最好尝试多个保留集,这是我们下一节的主题。

1.12.3 激励交叉验证

使用qeKNN自动生成的 MAPE 时,重要的是要记住,软件是随机选择保留集的。保留集的大小只有 73,这是一个相对较小的样本——想象一下我们上面的选举民调员只抽取了 73 名选民。因此,在不同保留集之间,MAPE 会有相当大的抽样波动。我们可以通过执行交叉验证来解决这个问题——也就是说,计算多个保留集的 MAPE 值的平均值。

为了展示抽样波动,让我们再运行几次相同的代码:

> qeKNN(day1,'tot',10)$testAcc
holdout set has  73 rows
[1] 1094.211
> qeKNN(day1,'tot',10)$testAcc
holdout set has  73 rows
[1] 1157.5

我们可以看到,我们不能过分依赖那个 MAPE 值;样本量为 73 太小了。这个问题还有很多值得讨论的地方,正如前面提到的,我们将在第三章中继续讨论这个问题。现在可以简单说一下,我们应该查看多个验证集,然后通过计算得到的 testAcc 值的平均值来进行交叉验证。

顺便说一下,我们可以通过使用 set.seed() 函数来控制 R 的随机数生成器:

> set.seed(9999)
> qeKNN(day1,'tot',10)$testAcc
holdout set has  73 rows
[1] 1210.373
> qeKNN(day1,'tot',10)$testAcc
holdout set has  73 rows
[1] 1090.622
> set.seed(9999)  # try it again
> qeKNN(day1,'tot',10)$testAcc
holdout set has  73 rows
[1] 1210.373
> qeKNN(day1,'tot',10)$testAcc
holdout set has  73 rows
[1] 1090.622

在本书中,我们经常这么做。它设置了一定的随机数序列,以防读者希望运行代码并检查结果。通过使用相同的种子,这里是 9999,将生成与我这里的训练集和验证集相同的数据集。(我只是选择了 9999 作为我喜欢的种子,没什么特别的。)

显然,通过生成多个验证集并计算得到的 MAPE 值的平均值,我们可以获得更准确的结果。这就是所谓的 交叉验证,将在第三章中详细讨论。

1.12.4 超参数、数据集大小和特征数量

回顾一下 1.7.2 节中关于选择最近邻数 k 的权衡问题:

  • 如果我们设置 k = 5,那么我们将平均 5 个数据点,这似乎太少了。这是一个方差问题;在给定的 X 值下,5 个 Y 值的平均值在不同样本间差异会很大。

  • 另一方面,设置 k = 50 时,我们可能会在邻域中有一些距离被预测点很远的点,这些点可能就不具有代表性。

    例如,在共享单车数据中,假设我们预测的是在 20 度的天气下的骑行人数,这种天气相当舒适。训练集中温度为 40 度的样本与当前预测关系不大,往往会导致我们预测的结果过低。这是一个偏差问题。

所以,我们面临一个权衡。我们希望将 k 设置得较大以降低方差,但设置较大的 k 也可能带来较大的偏差风险。但是……如果我们的共享单车数据集有 n = 73000 个数据点,而不是 731 个呢?在这种情况下,第 50 个最近邻可能实际上与预测点非常接近,从而解决了偏差问题。这样,我们就可以使用较大的 k 来控制方差。换句话说:

其他条件相同的情况下,n 越大,我们可以设置的 k 也就越大。

这仍然没有告诉我们应选择哪个特定的 k 值。我们将在第三章和第四章中回到这个问题,但至少在这个早期阶段,这是一个需要注意的事项。

关于特征数量 p 的相应说明是:

其他条件相同的情况下,p 越大,我们必须设置更大的 k

这个问题比涉及 n 的前一个陈述更不直观,但大致上问题是这样的。特征更多意味着点间距离的变化性更大,从而增加了预测的方差。为了解决这个问题,我们需要更大的 k

1.13 陷阱:p-hacking 与超参数选择

在非机器学习(ML)环境下,p-hacking指的是分析大规模研究时的一个常见陷阱。假设有人正在研究基因对某些结果的影响,并且涉及了大量的基因。即便没有任何基因真正产生影响,由于采样的变化,其中一个基因很可能仅仅因为偶然的原因而显得有“显著”的影响。虽然我们现在不深入探讨如何解决这个问题,但你应该从一开始就意识到它的存在。

p-hacking 对机器学习中的超参数设置也有重要影响。假设我们在一个机器学习方法中有四个调优参数,并且每个参数试验 10 个值。那么就有 10⁴ = 10000 种可能的组合。即便它们都同样有效,也有可能其中一个组合偶然地拥有一个远优于其他组合的 MAPE 值。看似“最佳”的超参数设置可能只是一个错觉。

regtools函数fineTuning()采取了一些措施来应对在搜索最佳调优参数组合时可能出现的 p-hacking 问题。我们将在第七章中进一步讨论。

1.14 陷阱:长期时间趋势

在我们开始进入下一章之前,让我们先讨论在使用这里介绍的方法时可能会遇到的三大陷阱,这些陷阱可能会影响你预测的质量:脏数据、缺失数据和数据中的长期趋势。我们将首先处理最后一个陷阱。

在我们对 MLB 数据进行的少数几次实验中,我们发现最佳的 MAPE 值可能约为 1,100。这看起来相当大,但请记住,我们并没有使用所有的数据。再看一下 1.1.1 节中的共享单车数据。数据中有几个与观察时间相关的变量:日期、季节、年份和月份。为了研究这些时间数据是否能改善我们的 MAPE 值,让我们画出骑行人数与时间的关系图。(数据按时间顺序排列。)

> plot(day1$tot,type='l')  # plotting type is 'l', lines between points

结果见图 1-1。显然,数据中存在季节性趋势(下跌大约每年一次)和总体的上升趋势。共享单车服务似乎随着时间的推移变得越来越受欢迎。分析随时间变化的数据的统计技术称为时间序列方法。在机器学习的不同背景下,这种方法经常被使用。我们将在第十三章中详细探讨这个问题,但现在就先尝试不使用新工具来进行分析。

图片

图 1-1:时间趋势,骑行数据

共享单车数据中的第 1 列,instant,是日期编号,数据集的第一天值为 1,第二天为 2,依此类推,直到最后一天 731。

让我们把instant加入到特征集中。回想一下我们之前在 1.5.1 节中选择的列:

> day1[,c(8,10:13,16)]

我们当时决定探索的特征位于第 8 列、第 10 到 13 列以及第 16 列。现在我们还想使用instant,它位于第 1 列:

> data(day1)
> day2 <- day1[,c(1,8,10:13,16)]
> kno <- qeKNN(day2,'tot',k=5)  # rerun k-NN on the new data
holdout set has  73 rows
> kno$testAcc
[1] 662.9836

啊,好多了!我们的 MAPE 降到了大约 663。

在使用 k-NN 和本书中介绍的其他方法时,需记住,所研究现象的条件可能会随时间变化,且这种变化可能成为一个重要因素。在某些情况下,时间变量甚至可能不是显式的,而是通过记录的顺序来隐含。未能探索这一点可能会导致预测质量的大幅下降,因此请留意可能会遇到这个陷阱的地方。

1.15 陷阱:脏数据

除了考虑长期时间趋势外,你还可能会遇到由脏数据引起的问题。以 2011 年 1 月 1 日的共享单车数据为例。

> head(day1)
  instant     dteday season yr mnth holiday
1       1 2011-01-01      1  0    1       0

如你在holiday列中看到的,数据集声称这不是假期。但实际上,1 月 1 日是美国的联邦假日。此外,尽管数据集的文档声明weathersit这个类别变量有 4 个值,但实际上只有值 1、2 和 3:

> table(day1$weathersit)

  1   2   3
463 247  21

数据中的错误非常常见,当然,这也是良好分析的障碍。例如,考虑将在第五章中深入讨论的纽约市出租车数据,它包含了接送位置、行程时间等信息。如果相信数据,有一个下车地点居然在南极!(你可以查看https://data.cityofnewyork.us/Transportation/2018-Yellow-Taxi-Trip-Data/t29m-gskq)。

每次处理新数据集时,分析师应该进行相当多的探索——例如,使用hist()table(),正如我们这里所展示的。你还应该警惕多变量离群点,即在单一维度上不极端,但从整体上看却不寻常的数据点。例如,假设某人被记录为身高 74 英寸(29.1 厘米)且年龄 6 岁。单独来看,这个身高或年龄都不会引起关注(假设我们的数据中包含各个年龄段的人),但如果将这两者结合起来,就显得相当可疑。在这种情况下,k-NN 是一种有用的工具!像上面提到的 74 英寸身高、6 岁的小孩这样的数据点,将与其他数据点有很大的距离,可能会因此被暴露出来。

可惜的是,没有一种公式化的方法可以检测异常数据点。这与我们强调的机器学习的本质相似:没有“魔法食谱”。这是一个统计学的高级话题,超出了本书的范围。

1.16 陷阱:缺失数据

在 R 中,NA 值表示数据不可用——即缺失。数据集通常会包含 NA 值,可能很多。我们该如何处理这些呢?

关于缺失值分析,有整本书专门讨论这个主题。我们无法在这里深入探讨,但会简要讨论一种常见方法——逐列表删除,至少能介绍一下其中涉及的问题。

假设我们的数据包含人物,并且我们有年龄、性别、受教育年限等变量。如果某个特定人的年龄缺失,但其他变量完整,那么这种方法会跳过该案例。但是,这里有两个问题:

  • 如果我们有大量特征,可能很多案例至少会有一个缺失值(NA)。这意味着需要丢弃许多宝贵的数据。

  • 跳过包含缺失值(NA)的案例可能会引入偏差。例如,在调查数据中,拒绝回答某个问题的人可能与回答该问题的人有所不同,这可能会影响我们预测的准确性。

请注意,当我们发现缺失值是以数字形式编码而不是 NA 时,我们应该将这些值更改为 NA。

与处理脏数据一样,处理缺失值的个人方法通常是通过经验积累的。学习一些工具,并逐渐发展你的方法,这将是每个人各不相同的。CRAN 的任务视图系列包括了有关缺失数据的内容。

1.17 直接访问 regtools k-NN 代码

如前言所述,本书中的大多数代码使用了特定于每种不同机器学习方法的流行 R 包。我们使用qe*系列的包装器作为统一、简单的便利接口,但当然也可以选择直接访问。

这样做的一个原因是,这些标准 R 包中的函数包含许多通过包装器无法使用的高级选项。在qeKNN()的特定情况下,但不是其他qe*系列函数的情况下,如果仅进行单次预测,直接访问可能更快。

在大多数应用中,这种专门的用法通常不必要,但我们的书会展示如何在读者有兴趣时直接使用这些函数。请注意,我们无法演示如何使用那些高级选项,因为它们数量众多且常涉及高级概念。相反,我们会简单地展示如何通过直接调用使用我们现有的一个示例。

这里是我们 k-NN 代码的具体内容。qeKNN()函数封装了regtools::kNN(),其参数如下:

> library(regtools)
> args(kNN)
function (x, y, newx = x, kmax, scaleX = TRUE, PCAcomps = 0,
    expandVars = NULL, expandVals = NULL, smoothingFtn = mean,
    allK = FALSE, leave1out = FALSE, classif = FALSE, startAt1 = TRUE,
    saveNhbrs = FALSE, savedNhbrs = NULL)

在这里,xy是我们的XYnewx是我们希望进行预测的Xkmaxk

让我们回顾一下第 1.8 节的内容:

> x <- mlb[,c(4,6)] # height, age
> y <- mlb[,5]  # weight
> newx <- c(72,24)  # ht, age of new player to be predicted re weight
> kmax <- 25   # k
> knnout <- kNN(x,y,newx,kmax)
> knnout$regests  # predicted value
[1] 183.76

请注意,模型拟合和预测已合并为一个步骤,尽管如果需要,后者可以推迟进行。

这里的预测与之前的略有不同,因为后者有一个保留集。如果我们抑制保留集的生成,我们将得到相同的结果:

> kno <- qeKNN(mlb[,4:6],'Weight',k=25,holdout=NULL)
> predict(kno,data.frame(Height=72,Age=24))
       [,1]
[1,] 183.76

有关kNN()的更多信息,可以通过输入?kNN(而不是?knn)来查看。

1.18 结论

我们已经有了一个好的开始!你现在应该对 k-NN 方法以及几个常见的机器学习概念有了清晰的了解:回归函数、k-NN 如何估计它、偏差-方差权衡的一般概念、超参数如何影响它,以及如何使用保留集来找到该权衡范围上的一个合适点。

你已经掌握了足够的工具,可以尝试自己进行一些数据分析了。请务必尝试!

下一章将介绍分类应用中的问题。

第二章:分类模型**

图片

上一章简要介绍了 分类应用,即我们预测虚拟或分类变量。这些与我们分析过的 数值应用 不同,例如预测骑车人数,它是一个数值实体。例如,在市场营销应用中,我们可能希望预测一个顾客是否会购买某个产品。在这种情况下,我们会用一个虚拟变量表示“Y”结果,购买该产品用 1 表示,不购买用 0 表示。这里有两个 类别:购买和不购买。

我们在第 1.1 节的“某些术语”框中讨论了一个分类 Y 的例子。在那个例子中,一位医生根据患者的脊柱状况将其分为三类——即三种类别:正常(NO)、椎间盘突出(DH)和脊椎滑脱(SL)。在这里,Y 是给定患者的类别。因此,Y 是一个具有三类的分类变量。如果 Y 被编码为 R 中的 factor(通常是这种情况),那么该因子将有三个级别。另一方面,我们也可以将 Y 编码为一组虚拟变量,每个类别对应一个虚拟变量。

我们将在第 2.3.1 节中详细分析这些椎骨数据,说明如何获取它们等。但让我们先做一个简短的预览,来说明前面提到的类别或分类的概念。

> vert <- read.table('column_3C.dat',header=FALSE,stringsAsFactors=TRUE)
> head(vert)
     V1    V2    V3    V4     V5    V6 V7
1 63.03 22.55 39.61 40.48  98.67 -0.25 DH
2 39.06 10.06 25.02 29.00 114.41  4.56 DH
3 68.83 22.22 50.09 46.61 105.99 -3.53 DH
4 69.30 24.65 44.31 44.64 101.87 11.21 DH
5 49.71  9.65 28.32 40.06 108.17  7.92 DH
6 40.25 13.92 25.12 26.33 130.33  2.23 DH
> table(vert$V7)

 DH  NO  SL
 60 100 150
> class(vert$V7)
[1] "factor"
> levels(vert$V7)
[1] "DH" "NO" "SL"

椎骨状况位于最后一列。我们可以看到,在这个数据集中,DH 类的患者有 60 位,依此类推。

本章将更详细地讨论分类应用,从对一个概念问题的简要讨论——回归函数的概念开始,然后直接进入数据分析。我们将引入一些新的数据集,再次使用 qeKNN() 进行分析,并展示在分类情境中出现的一些特殊问题。

2.1 分类是回归的一个特例

分类应用在机器学习中相当常见。事实上,它们可能构成了大多数机器学习应用。那么,回归函数 r(t)(见第 1.6 节)在这样的情境中是如何表现的呢?

回忆一下,回归函数将均值 YX 关联。如果我们从身高和年龄来预测体重,那么 r(71, 25) 表示身高为 71 英寸且年龄为 25 岁的所有人的平均体重。但如果 Y 是一个虚拟变量,这又是如何工作的呢?

在分类设置中,回归函数,即条件均值,变成了条件概率。为了理解这一点,考虑一个分类应用,其中结果Y由一个虚拟变量表示,编码为 1 或 0,类似于本章开头的营销示例。在收集我们的k最近邻之后,我们对其 1 和 0 进行平均。例如,假设k = 8,且这 8 个邻居的结果分别是 0、1、1、0、0、0、1、0。然后,平均值为(0 + 1 + 1 + 0 + 0 + 0 + 1 + 0) / 8 = 3/8 = 0.375。由于这意味着 3/8 的结果是 1,因此可以将 0 或 1 的平均值视为 1 的概率

在营销示例中,回归函数是根据客户的特征值(如年龄、性别、收入等)预测客户购买某个产品的概率。然后,我们根据哪个类别的概率更大来猜测购买或不购买。由于这里只有两个类别,这等同于说,如果购买的类别概率大于 0.5,我们就猜测购买。(这个策略最小化了整体的错误分类概率。不过,其他标准也是可能的,这是我们稍后会回到的一个点。)

在多分类环境中也是如此。考虑上面的医学应用。对于一个新患者,其脊椎状况需要预测,机器学习将给医生三个概率值——每个类别一个。如上所述,这些概率是通过对虚拟变量中的 1 和 0 进行平均得到的,每个类别有一个虚拟变量。初步诊断将是具有最高估计概率的脊椎类别。

总结:

用于预测数值的函数r()同样适用于分类设置。在这些设置中,均值变成了概率,我们利用这些概率来预测类别。

这个观点作为一个统一的概念是很不错的。

注意

读者可能会想,为什么我们使用三个虚拟变量来分类患者的脊椎状况。如第 1.4 节所述,实际上只需要两个虚拟变量即可;然而,稍后介绍的某些其他方法需要更多的虚拟变量。为了保持一致,我们将始终使用与类别数相等的虚拟变量数量。请注意,这个约定适用于YX中的类别特征通常会比该特征可以取的值少一个虚拟变量。

因此,分类问题实际上是回归的特殊情况,这是我们在本书中经常会提到的一点。然而,该领域使用了一些令人困惑的术语,需要我们特别注意。

之前,我们区分了两种应用,一种是数值应用,例如预测骑行人数,另一种是分类应用,例如前面提到的营销和医学示例,我们是在预测一个类别

然而,在机器学习领域,通常将数值应用称为回归问题。当然,这也是一个混淆源,因为数值和分类应用都涉及回归函数!唉……只要你意识到这一点,它其实不是大问题,但在本书中我们将使用数值-Y 应用这个术语,以便更清晰。

2.2 示例:电信流失数据集

对于我们第一个分类模型的示例,我们将使用电信客户流失数据集。在营销领域,流失一词指的是客户从一个服务提供商转移到另一个服务提供商。服务提供商希望能够识别出可能“流失”的客户,或者那些有较大可能离开的客户。因此,我们有两个类别:流失(Churn)或不流失(No Churn)(即,离开或留下)。

你可以在https://www.kaggle.com/blastchar/telco-customer-churn 下载并了解更多关于数据集的信息。让我们加载它并看一看:

> telco <- read.csv('WA_Fn-UseC_-Telco-Customer-Churn.csv',header=TRUE)
> head(telco)
  customerID gender SeniorCitizen Partner Dependents tenure
1 7590-VHVEG Female             0     Yes         No      1
2 5575-GNVDE   Male             0      No         No     34
3 3668-QPYBK   Male             0      No         No      2
4 7795-CFOCW   Male             0      No         No     45
5 9237-HQITU Female             0      No         No      2
6 9305-CDSKC Female             0      No         No      8
  PhoneService    MultipleLines InternetService
1           No No phone service             DSL
2          Yes               No             DSL
3          Yes               No             DSL
4           No No phone service             DSL
5          Yes               No     Fiber optic
6          Yes              Yes     Fiber optic
...
> names(telco)
 [1] "customerID"       "gender"
 [3] "SeniorCitizen"    "Partner"
 [5] "Dependents"       "tenure"
 [7] "PhoneService"     "MultipleLines"
 [9] "InternetService"  "OnlineSecurity"
[11] "OnlineBackup"     "DeviceProtection"
[13] "TechSupport"      "StreamingTV"
[15] "StreamingMovies"  "Contract"
[17] "PaperlessBilling" "PaymentMethod"
[19] "MonthlyCharges"   "TotalCharges"
[21] "Churn"

最后一列是响应;Churn列中的Yes表示客户流失了。

接下来我们将进行数据准备,比如检查 NA 值。这是一个相当复杂的数据集,需要额外的准备工作——对于像本书读者这样的数据科学学习者来说,这是一个额外的奖励!

2.2.1 陷阱:将因素数据读取为非因素数据

电信数据集中的许多特征是 R 中的因素(即非数值量,如genderInternetService)。大多数 R 机器学习包,包括qe*系列函数,允许使用因素类型。但等一下... 它们实际上并不是因素。默认情况下,read.csv() 将非数值项目视为字符字符串:

> class(telco$Churn)
[1] "character"

由于我们的软件预期使用 R 因素,我们需要告诉 R 将非数值项目视为因素。(实际上,取决于你使用的 R 版本和默认设置,这可能已经是你的默认值。如果你不确定,建议在调用时进行设置。)

> telco <- read.csv('WA_Fn-UseC_-Telco-Customer-Churn.csv',header=TRUE,
   stringsAsFactors=TRUE)
> class(telco$Churn)
[1] "factor"

如果不这样做,将会导致字符或数值问题,运行机器学习函数时会出现问题。

2.2.2 陷阱:保留无用特征

在一些数据集中,某些列没有预测价值,应该被移除。customerID 特征没有预测价值(尽管如果我们为每个客户有多个数据点,它可能会有预测价值),所以我们将删除第一列:

> tc <- telco[,-1]

保留无用特征可能会导致过拟合。

2.2.3 处理 NA 值

正如在第 1.16 节中提到的,许多数据集包含缺失(不可用)数据的 NA 值。我们来看看这里是否也存在这种情况:

> sum(is.na(tc))
[1] 11

确实存在 11 个 NA 值。

我们将在这里使用逐行删除作为第一步分析(参见第 1.16 节),但如果我们进一步深入研究,我们可能会更仔细地查看 NA 值的模式。R 实际上有一个complete.cases()函数,它会返回TRUE,表示该行数据完整。让我们删除其他不完整的行:

> ccIdxs <- which(complete.cases(tc))
> tc <- tc[ccIdxs,]

如果我们不需要知道哪些具体案例被排除,我们可以简单地运行:

> tc <- na.exclude(tc)

还剩下多少案例?

> nrow(tc)
[1] 7032

通常,检查这个是一个好主意。

其中,剩余行数影响我们为近邻数k选择的值的大小(参见第 1.12.4 节)。

2.2.4 应用 k-最近邻方法

让我们看看如何在分类问题中调用qeKNN()函数,假设将k设置为 75:

> set.seed(9999)
> knnout <- qeKNN(tc,'Churn',75,yesYVal='Yes')

作为预测的一个例子,假设我们有一个新案例需要预测,像数据中第 8 行的假设客户,但此人是男性且是老年人。为此,我们将复制tc的第 8 行,并在genderSeniorCitizen列中进行所述的更改:

> newCase <- tc[8,]
> newCase$gender <- 'Male'
> newCase$SeniorCitizen <- 1

还要注意,由于这行数据将作为我们的“X”,我们需要删除“Y”部分——也就是删除Churn列。我们之前看到,Churn是第 21 列,但记得我们删除了第 1 列,所以它现在在第 20 列。

> newCase <- newCase[,-20]

或者,我们也可以使用 R 基本包中的subset()函数,

> newCase <- subset(newCase,select=-Churn)

data.table包或 tidyverse。正如在第 11 页所述,读者可以选择任何他们最为熟悉的 R 构造;本书的重点是机器学习,R 只扮演一个辅助角色。

现在我们为新案例做出预测:

> predict(knnout,newCase)
[,1]
[1,] 0.3066667

我们感兴趣的类别是 Churn,因此我们检查这个案例的 Churn 概率,结果是 0.32。由于小于 0.5,我们猜测是没有 Churn。

对于分类问题的qeKNN()调用与数值输出应用几乎相同,输出形式略有不同。

但是……qeKNN()是如何知道这是一个分类应用,而不是一个数值型的Y问题呢?我们指定的Y变量Churn是一个 R 因子,这告诉qeKNN()我们正在运行的是一个分类应用。

让我们检查一下分类准确率。

> knnout$testAcc
[1] 0.2247511

我们的误分类率大约是 22%,这还算不错。然而,我必须再次强调,这些数字是受样本变异影响的,我们将在第三章进一步讨论。

2.2.5 陷阱:由于具有多个类别的特征而导致的过拟合

假设我们在原始数据telco中没有删除customerID列。那么有多少个不同的 ID?

> length(levels(telco$customerID))
[1] 7043

ID 的数量也是行数,因为每个客户对应一条记录。

回想一下,qeKNN()就像许多 R 包中的函数一样,会内部将因子转换为虚拟变量。如果我们没有删除这一列,tc的内部版本将会有 7,042 列,这些列仅来自这个 ID 列!这样不仅结果会变得笨重,而且所有这些列的存在还会稀释 k-NN 的效果。

这个现象被称为过拟合。使用过多的特征实际上会减少预测未来案例的准确性。第三章对此有更详细的讨论,但现在可以粗略地认为,数据被过多特征“共享”,而每个特征能用到的数据很少。请注意,过拟合在分类和数值-Y应用中都是一个问题。

如果我们包含客户 ID,可能会遇到计算上的问题。拥有 7000 个客户 ID 意味着 7000 个虚拟变量。这意味着内部数据矩阵有超过 7000 × 7000 个条目——大约 5000 万个条目。每个条目占用 8 字节,那么就需要大约 0.4GB 的内存。我们必须去掉这一列。

即使是直接接受因子数据的机器学习包,也必须关注包在做什么——以及我们输入的数据。良好的实践是关注 R 因子中有大量级别的情况。它们可能看起来很有用,实际上可能确实如此,但它们也可能导致过拟合以及计算或内存问题。

2.3 示例:脊椎数据

考虑另一个 UCI 数据集——脊椎柱数据集,^(1),该数据集与脊椎疾病相关。数据集的管理者描述它为“用于将骨科患者分类为三类(正常、椎间盘疝或脊椎滑脱[sic])的六个生物力学特征的值。”他们将这三类分别缩写为 NO、DH 和 SL。

这个例子与上一个类似,但有三个类别而不是两个。回想一下,在一个二分类问题中,我们是根据感兴趣类别的概率是否大于 0.5 来进行预测的。在电信例子中,那个类别是流失,因此我们预测流失或不流失,取决于流失的概率是否大于 0.5。该概率为 0.32,所以我们预测为不流失。但是在有三个或更多类别的情况下,可能没有任何概率大于 0.5;我们只需选择概率最大的类别。

2.3.1 分析

让我们读取数据,并像往常一样,先浏览一下。

> vert <- read.table('column_3C.dat',header=FALSE,stringsAsFactors=TRUE)
> head(vert)
     V1    V2    V3    V4     V5    V6 V7
1 63.03 22.55 39.61 40.48  98.67 -0.25 DH
2 39.06 10.06 25.02 29.00 114.41  4.56 DH
3 68.83 22.22 50.09 46.61 105.99 -3.53 DH
4 69.30 24.65 44.31 44.64 101.87 11.21 DH
5 49.71  9.65 28.32 40.06 108.17  7.92 DH
6 40.25 13.92 25.12 26.33 130.33  2.23 DH
> nrow(vert)
[1] 310

患者状态位于列V7。顺便提一下,我们看到数据集的管理者决定按患者类别对行进行分组。这就是为什么qe*系列函数会随机选择留出集的原因。

让我们来拟合模型。如何选择k的问题仍然悬而未决,但我们需要考虑数据集的大小。这里只有 310 个案例,而在客户流失的例子中我们有n = 7032 个。回想一下第 1.12.4 节,随着数据点数量n的增大,我们可以设置更大的最近邻数k,所以对于这个小数据集,我们尝试一个较小的值,比如k = 5。

> set.seed(9999)
> kout <- qeKNN(vert,'V7',5)

作为预测的一个例子,考虑一个与我们数据中第一个患者相似的患者,但V2的值为 25,而不是 22.55。我们的预测类别会是什么?

> z <- vert[1,-7]  # exclude Y
> z$V2 <- 25
> predict(kout,z)
$predClasses
[1] "dfr.DH"

$probs
     dfr.DH dfr.NO dfr.SL
[1,]    0.6    0.2    0.2

我们会预测 DH 类别,估算概率为 0.6。现在让我们使用这个模型找到我们预测的整体准确度。

> kout$testAcc
[1] 0.1935484

我们的错误率大约为 19%。不过需要注意的是,由于样本量较小(310),在这种情况下,我们的预测很容易受到抽样变异的影响。

2.4 陷阱:使用特征时错误率仅略有改善

在使用任何机器学习方法时,无论在什么数据集上,重要的是检查你的预测是否比随机猜测(不使用特征)更有可能成功。回顾我们在第 1.12.2 节中的分析。让我们看一个分类领域的例子。

考虑第 2.2 节中的电信数据集。我们发现,在使用 19 个特征预测客户是否会忠诚时,错误率大约为 22%。22%的错误率算好吗?

为了回答这个问题,考虑一下如果我们没有任何特征来预测会发生什么。那么我们就只能根据大多数客户的行为来进行预测。那他们中的多少人会离开呢?

> mean(telco$Churn == 'Yes')
[1] 0.2653699

这意味着大约 27%的客户会离开。我们经常进行这种形式的计算,所以让我们回顾一下它是如何工作的。这个表达式

telco$Churn == 'Yes'

这个表达式的结果是许多 TRUE 和 FALSE。但是在 R 语言中,像大多数编程语言一样,TRUE 和 FALSE 分别表示 1 和 0。因此,我们计算的是一堆 1 和 0 的平均值,这给出了 1 的比例。那就是Yes的比例。

所以,如果没有客户信息,我们将简单地预测每个人都会留下——而我们会错误地预测 27%的时间。换句话说,使用客户信息将我们的错误率从 27%降低到 22%——有帮助,是的,但并不是特别显著。当然,我们可能会通过其他的k值获得更好的结果,建议读者试试看,但这也让我们的分析变得有了可比性。

正如你所看到的,看似“好的”错误率可能与随机猜测几乎没有什么不同。记住,总是要检查无条件的类别概率——也就是说,计算时不使用X

让我们从第 2.3 节的例子来考虑这个问题。我们达到了大约 26%的错误率。如果我们不使用特征,而是猜测最常见的类别,我们的错误率会增加吗?

为此,让我们看看忽略我们六个特征时,每个类别的比例。我们可以轻松地回答这个问题。

> table(vert$V7) / nrow(vert)
       DH        NO        SL
0.1935484 0.3225806 0.4838710

调用table()函数给出了各类别的计数,因此通过将计数总数除以总计数,我们可以得到各类别的比例。

如果我们不使用特征,我们总是会猜测 SL 类别,因为它是最常见的。那样的话,我们会错误地预测 1 − 0.4838710 = 0.516129 的比例,这比我们使用特征时得到的 26%的错误率要糟糕得多。在这种情况下,使用特征大大提高了我们的预测能力。

实际上,regtools 中的 qe* 系列函数会为我们计算没有特征的错误率。在上述椎骨示例中:

> kout$baseAcc
[1] 0.5089606

请注意,结果与之前的 0.516129 略有不同。这是因为后者是在完整数据集上计算的,而此结果是在使用留出法的情况下计算的:均值来自训练集,而Y值则来自留出集。

如这里所示,在分类问题中,如果不使用特征,baseAcc 将显示总体错误分类率。当然,类似的比较——使用特征与不使用特征的误差率——在数值 Y 设置中同样是我们关注的重点。因此,如果我们忽略特征,预测新的 Y 将是训练集中的 Y 的总体均值,这与我们在使用特征时利用条件均值的情况类似。没有特征时,MAPE 的类比是总体均值 Y 与测试集中的实际 Y 之间的平均绝对差。这一结果被报告在 baseAcc 中。

举个例子,再次考虑第一章中的自行车骑行数据。

> data(day1)
> day1 <- day1[,c(8,10:13,16)]  # extract the desired features
> set.seed(9999)
> knnout <- qeKNN(day1,'tot',k=5)  # fit the k-NN model
holdout set has  73 rows
> knnout$testAcc
[1] 1203.644
> knnout$baseAcc
[1] 1784.578

在这里,使用我们选择的 5 个预测变量时,MAPE 大约是 1,204,而使用没有预测变量时,MAPE 为 1,785。

2.5 混淆矩阵

在多分类问题中,总体错误率仅仅是故事的开始。我们还可能计算(不幸命名的)混淆矩阵,它计算每个类别的错误率。让我们看看它在椎骨数据中的表现。

qe* 系列函数在返回值中包括了混淆矩阵。

> kout$confusion
      pred
actual DH NO SL
    DH  6  2  0
    NO  2  6  2
    SL  1  1 11

在 6 + 2 + 0 = 8 个实际类别为 DH 的数据点中,6 个被正确分类为 DH,2 个被误分类为 NO,但没有一个被错误预测为 SL。

这种分析方法使我们能够更细致地评估预测能力。机器学习领域的人经常使用它来识别模型潜在的弱点。

2.6 消除混淆:不平衡数据

在这里,我们将讨论有关 不平衡数据 的问题,这是分类问题中常见的情况,也是机器学习领域中经常讨论的话题。

回想一下本章早些时候的客户流失示例,大约 73% 的客户是“忠诚”的,而 27% 的客户转移到了其他电信公司。在我们数据中的 7,032 个案例中,这些数字转化为 5,141 个忠诚客户和 1,901 个流失客户。忠诚客户的数量是流失客户的 2.5 倍以上。通常,这一比例可以达到 100:1 甚至更多。这种情况被称为 不平衡。 (本节的讨论主要集中在二分类问题,但多分类问题也是类似的。)

许多分析师建议,如果数据集存在不平衡的类别大小,应通过修改数据来创建相等的类别计数。他们的理由是,应用机器学习方法于不平衡数据时,几乎所有的预测都会是我们将新数据点的类别猜为占主导地位的类别——例如,在电信数据中,我们总是猜测“未流失”(No Churn)而不是“流失”(Churn)。这并不提供太多有用的信息!

在机器学习文献的许多地方,都有关于该问题的例子和解决办法,从网络教程^(2)到主要的 CRAN 包,如caretparsnpmlr3。尽管统计学家已提出警告^(3),所有这些资源仍然建议通过人工平衡数据中的类别计数,比如通过丢弃占主导地位类别的“多余”数据来实现。

尽管不平衡的数据确实会导致总是或几乎总是预测占主导地位的类别,但通过人工平衡类别大小来解决这个问题是没有必要的,而且在许多情况下是有害的。显然,丢弃数据通常不是一个好主意;它总是会削弱分析的效果。此外,根据给定应用的目标,实际上可能希望总是预测占主导地位的类别。

2.6.1 示例:Kaggle 预约数据集

为了说明如何最好地处理不平衡数据,我们来看一个来自 Kaggle 的数据集,^(4) Kaggle 是一个通过举办数据科学竞赛来运作的公司。目标是使用这个数据集来预测一个患者是否会错过医生的预约;如果医疗机构能够标记出有可能未按时到达的患者,工作人员可以采取额外的措施,避免因缺席而带来的经济损失。

读取数据:

> ma <- read.csv('KaggleV2-May-2016.csv',header=TRUE,
   stringsAsFactors=TRUE)
> names(ma)
 [1] "PatientId"      "AppointmentID"  "Gender"         "ScheduledDay"
 [5] "AppointmentDay" "Age"            "Neighbourhood"  "Scholarship"
 [9] "Hypertension"   "Diabetes"       "Alcoholism"     "Handicap"
[13] "SMS_received"   "No.show"
> nrow(ma)
[1] 110527

我们是否应该像在电信数据中那样去除患者 ID,以避免过拟合?我们发现平均而言,每个患者在数据中出现的次数少于两次,这意味着每个患者的数据量不多:

> length(unique(ma$PatientId))
[1] 62299

所以,是的,我们可能不应该包括这个特征。以相同的逻辑来看,去除预约 ID、社区、预约日期和预定日期变量也有意义:

> ma <- ma[,-c(1,2,4,5,7)]
> names(ma)
[1] "Gender"       "Age"           "Scholarship"  "Hypertension" "Diabetes"
[6] "Alcoholism"   "Handicap"      "SMS_received" "No.show"

大约 20%的情况是未到诊(反直觉的是,Yes在这里表示“是的,患者没有按时到达”):

> table(ma$No.show)
   No   Yes
88208 22319

是的,这确实是一个不平衡的数据集。

回想一下,问题在于我们的预测Y也可能是不平衡的——也就是说,大多数或所有的预测都会认为患者会按时到达。我们可以通过检查运行qeKNN()的输出结果来验证这一点。以下是调用方式:

> kout <- qeKNN(ma,'No.show',25)

在大多数qe*系列函数的返回值中包含了大量信息。以下是qeKNN()函数输出的各个组件:

> names(kout)
 [1] "whichClosest"   "regests"        "scaleX"         "classif"
 [5] "xminmax"        "mhdists"        "x"              "y"
 [9] "noPreds"        "leave1out"      "startAt1adjust" "classNames"
[13] "factorsInfo"    "trainRow1"      "holdoutPreds"   "testAcc"
[17] "baseAcc"        "confusion"      "holdIdxs"

那个holdoutPreds组件实际上是regtools::kNN()的返回值,详细讨论见第 1.17 节。 (R 语言中的p::e表示包p中的实体e。)我们来看一下里面的内容:

> names(kout$holdoutPreds)
[1] "predClasses" "probs"

检查文档后,我们发现predClasses是保留集中的预测Y值向量,这正是我们需要的。我们来列出这些值:

> predY <- kout$holdoutPreds$predClasses
> table(predY)
predY
 No Yes
990  10

首先,我们如何处理这个表达式中的两个美元符号($)?

kout$holdoutPreds$predClasses

记住,符号u$v表示对象u中的组件v。所以,u$v$w表示对象u$v中的组件w! 所以,是的,我们这里有一个 R 列表嵌套在另一个 R 列表中,这在 R 语言中是常见的。

无论如何,我们看到在我们当前的模型中,我们在绝大多数情况下预测患者会到场(再次说明,Yes 表示未到场),这验证了许多人对不平衡数据的担忧。

事实上,预测结果比数据本身更加不平衡;我们看到整体数据集中大约 20%的Y值是未到场的,而在这里,预测的保留集Y中只有 1.3%的未到场。这其实是可以预料的:记住,如果模型认为错过预约的概率大于 0.5,那么预测结果将是未到场,即使实际上他们到场了。

这正是之前提到的来源所指出的问题,他们推荐人工平衡数据。他们建议通过某种方式改变数据,使所有类别在数据中得到平等表示。他们建议以下一种(或变体)方法来平衡类别大小:

下采样

用从原始 88,208 个记录中随机选取的 22,319 个元素替换 No 记录。这样,我们将拥有 22,319 个 Yes 记录和 22,139 个 No 记录,从而实现平衡。

上采样

用从原始 22,319 个记录中随机选取的 88,208 个元素(带放回)替换 Yes 记录。这样,我们将拥有 88,208 个 Yes 记录和 88,208 个 No 记录,从而实现平衡。

然后,我们将应用所选的机器学习方法,比如 k-NN,来处理修改后的数据,然后根据估计的条件概率是否大于 0.5 来预测新案例是否未到场。

2.6.2 更好的不平衡数据处理方法

再次强调,下采样是不可取的;数据非常宝贵,不应丢弃。另一种平衡方法——上采样——也没有意义,为什么增加完全重复的数据会有所帮助呢?

此外,平衡假设了假阴性和假阳性带来的负面影响相同,但在像预约数据这样的应用中,这种假设不太可能成立。我们可以在这里设置正式的效用值,来表示假阴性和假阳性的相对成本。但在许多应用中,我们需要比完全机械化算法更灵活的方法。例如,考虑信用卡欺诈问题。正如全球会计网络普华永道(PricewaterhouseCoopers)在其出版物《欺诈:防范、检测与调查指南》中指出的那样,“每个欺诈事件都是不同的,反应性的应对措施会根据每个案件独特的事实有所不同。”^(5)

更简单和更好的解决方案是让我们的机器学习算法标记出存在较高欺诈概率的案例,比如超过某个指定阈值,然后由人工进一步处理。一旦算法选择出可能的欺诈实例,(人工)审计员将会考虑到该估计概率——现在不仅要担心它是否超过阈值,还要考虑超过了多少——以及诸如费用金额、未在可用特征中衡量的特殊特征等因素。

审计员可能不会优先考虑,例如,概率高于阈值但交易金额较小的案例。另一方面,如果概率远高于阈值,审计员即使交易金额较小,可能也会对这个交易进行更仔细的审查。

因此,解决不平衡数据“问题”的实际方案不是人工重采样数据,而是识别在应用背景下的个别关注案例。这意味着要关注那些有足够高概率值得关注的案例,同样是在给定应用的背景下。

回想一下,在分类应用中的qe*系列调用的probs组件给出了各种类别的估计概率——这正是我们所需要的。例如,在缺席约会的数据集中,我们可以检查probs,以找出一个患者未到场的概率。

请注意,probs每一行对应一个要预测的案例。由于缺席约会数据有两个类别,因此将有两列。我们之前看到“是”类别(即缺席约会)排在第二位。我们来看看。

> preds <- predict(kout,ma[,-9]) # exclude "Y", column 9
> table(preds$probs)
    0  0.04  0.08  0.12  0.16   0.2  0.24  0.28
  604  6283  9959 16465 17713 18427 12641  8943
 0.32  0.36   0.4  0.44  0.48  0.52  0.56   0.6
 7081  5293  3306  2123  1038   298   154   121
 0.64  0.68  0.72
   27    25    26

有相当多的患者,尽管保持约会的可能性较大,但仍然有较大的缺席风险。例如,18,427 人预计有 0.2 的概率未能保持约会。另有 28,435 名患者缺席的概率超过 25%:

> sum(preds$probs > 0.75)
[1] 80451

合理的做法是决定一个缺席概率的阈值,然后确定哪些患者未能达到该阈值。例如,设定一个 0.75 的阈值,任何约会保持概率大于该水平的患者可能会受到特别关注。我们会额外拨打电话给他们,解释缺席约会的惩罚等。

如果这些调用过于繁重,我们可以提高阈值。或者,如果这些调用效果非常显著,我们可以降低阈值。无论哪种方式,关键是现在的控制权掌握在数据的最终用户手中,而这正是它应有的地方。人为地平衡数据剥夺了用户的这一权力。

2.7 接收者操作特征与曲线下面积

我们已经看到,MAPE 被用作数值型 Y 问题中的预测能力衡量标准,而整体误分类错误(OME)则用于分类应用中。这两者都非常流行,但在分类问题中,还有其他常见的衡量标准,下面我们将讨论其中两个。

2.7.1 ROC 和 AUC 的详细信息

许多分析师使用曲线下面积(AUC)值作为分类问题中预测能力的总体衡量标准。所讨论的曲线是接收者操作特征(ROC)曲线。

要理解 ROC,请回顾 第 2.6.2 节,我们在其中讨论了分类预测的阈值。如果估计的类别概率在阈值的一侧,我们预测为 Class 1,另一侧则预测为 Class 0。阈值可以是从 0 到 1 之间的任何值;我们根据特定应用中的目标选择阈值。

ROC 曲线会探索各种情境。比如,如果我们将阈值设为 0.4,我们的预测效果如何?如果是 0.7 呢?以此类推。“我们能预测得有多好?”这个问题由两个数字来回答:真正阳性率(TPR)假阳性率(FPR)

TPR,也称为灵敏度,是指在实际类别为 Class 1 时,我们预测为 Class 1 的概率。FPR,特异性,是指在实际类别为 Class 0 时,我们预测为 Class 1 的概率。请注意,阈值决定了 FPR 和 TPR。ROC 曲线将 TPR 和 FPR 随阈值变化绘制出来。

AUC 是 ROC 曲线下的总面积。它的取值范围在 0 到 1 之间。曲线越高越好,因为这意味着对于任何固定的假阳性率(FPR),真正阳性率(TPR)较高。因此,AUC 越接近 1.0,预测能力越好。

2.7.2 qeROC() 函数

qeROC() 函数是 pROC 包中 roc() 函数的封装。它对 qe*- 函数(例如 qeKNN())的输出进行 ROC 分析,调用形式如下:

qeROC(dataIn, qeOut, yName, yLevelName)

这里,dataIn 是调用了 qe*- 函数的数据集,qeOut 是该函数的输出,yNamedataInY 的名称,yLevelName 是感兴趣的 Y 水平(在 R 因子意义下)。请注意,后者允许处理多类别情况。

如前所述,qeROC() 调用了 pROC::roc()。前者的返回值包括后者的返回值。将返回值赋给一个变量以供进一步使用可能很有用(见下文),但如果没有这么做,ROC 曲线将被绘制,并打印出 AUC 值。

请注意,qeROC() 是在持出集(holdout set)上操作的。这一点很重要,原因与 qe* 系列函数中的 testAcc 输出使用持出集相同(见 第 1.12 节)。

2.7.3 示例:电信流失数据

让我们看看 ROC 曲线对电信流失数据的解释,继续我们之前的 k-NN 分析。

> w <- qeROC(tc,knnout,'No')
...
> w$auc
Area under the curve: 0.8165

图示见 图 2-1。让我们来看看如何解读它。

图像

图 2-1:ROC,电信流失数据

45 度线被画出以表示纯粹的猜测,正类预测的比率是一样的,无论我们是否有真正的正例。再说一次,ROC 曲线越是高于这条线,结果就越好。

2.7.4 示例:脊椎数据

qeROC() 函数也可以在多类设置中使用:

> qeROC(vert,kout,'V7','DH')
Area under the curve: 0.9181
> qeROC(vert,kout,'V7','NO')
Area under the curve: 0.7985
> qeROC(vert,kout,'V7','SL')
Area under the curve: 0.8706

由于这是一个小数据集,我们选择了较大的保留集(在 10% 的默认设置下,保留集只有 31 个案例)。但即便如此,我们必须记住,这些 AUC 值会受到相当大的样本变异性的影响。因此,我们在得出结论认为我们在预测 'NO' 类别时准确度较低时,必须谨慎。

不管怎样,这些数字在多类情况下意味着什么呢?实际上,它们与运行三次独立的 ROC 分析(在相同的保留集上)得到的结果是一样的;qeROC() 在这种情况下只是一个便利函数,免去了用户需要运行 roc() 三次的麻烦。而且,由于没有进行新的计算(类概率已缩放至总和为 1.0),如果数据集很大,这也节省了用户的计算时间。

2.7.5 陷阱:过度依赖 AUC

AUC 可以是补充 OME 的一个有用指标,许多分析师将其视为他们机器学习工具包的一个重要组成部分。然而,使用时应谨慎。

从数学角度来看,AUC 是所有可能阈值上 ROC 的平均值。但请记住,每个阈值隐含地对应着一个相对的效用。例如,在信用卡欺诈数据集中,我们可能认为,判断一笔交易是合法的,而实际上它是欺诈的,比反过来判断要更严重。问题在于,有一些阈值我们甚至不会考虑在特定应用中使用。然而,它们被平均到 AUC 值中,从而使得后者的意义减少。

2.8 结论

现在我们已经建立了机器学习问题的两种基本类型的坚实基础:数值型 Y 和分类问题。在此过程中,我们还掌握了一些杂项技能,如去除无用特征和处理 NA 值。

现在是时候认真考虑如何选择 k 的值,以及更一般的,在所有机器学习方法中选择超参数的问题,这是我们迄今为止一直忽略的话题。我们将在接下来的两章中详细讨论这一点。

第三章:偏差、方差、过拟合与交叉验证**

Image

接下来,我们详细探讨在第 1.7 节、第 1.12.4 节和第 2.2.5 节中提到的一个重要话题——过拟合。在本章中,我们将解释偏差和方差在机器学习中的真正含义,以及它们如何影响过拟合。接着,我们将介绍一种常用的避免过拟合的方法,称为 交叉验证

过拟合的问题恰好阐明了本书标题中提到的观点:机器学习是一门艺术,而非科学。对于各种问题,尤其是过拟合,没有固定的公式解法。加州理工大学的著名机器学习专家 Yaser Abu-Mostafa 教授曾总结道:“避免过拟合的能力是区分专业人士与业余爱好者的关键。”^(1) 而我在谷歌上查询“过拟合”时,得到了 6,560,000 个结果!

不要被吓到。教授是对的,但只要对偏差和方差有充分的理解,避免过拟合并不困难。掌握这一点,并通过实践积累经验,就能做到。

3.1 过拟合与欠拟合

那么,过拟合究竟是怎么一回事呢?我们在之前的章节中已经稍微提到了这个话题。现在,让我们深入探讨这个问题。

回想我们在第 1.7 节中关于偏差-方差权衡的讨论,尤其是选择超参数值时的讨论,具体来说,就是在 k-NN 中选择 k 的值。我们再次以共享单车数据(第 1.1 节)为例来说明。假设我们想预测骑行量,例如某一天的温度为 28 度。我们将查看数据中温度最接近 28 的几天。这些天的平均骑行量将作为我们的预测值。

假设我们取 k = 5. 即使是那些不在科技领域的人,也可能直观地感觉到 k 为 5 的值是“样本量太小”。即便它们的温度接近 28,5 天的骑行量也会有太多变动。如果我们从一个不同的 731 天数据集中获取样本,而不是我们当前拥有的数据集,我们将得到一组不同的 5 天,温度接近 28,且对应的平均骑行量也会不同。使用 k = 50 时,高骑行量和低骑行量的数据在平均过程中会大致相互抵消,但如果仅使用 k = 5,则不会出现这种情况。这表明我们应选择一个大于 5 的 k 值。这是一个 方差 问题:选择过小的 k 值会导致过多的抽样变异性。

另一方面,如果我们使用例如 k = 25 天,并选择温度最接近 28 度的日子,我们就有可能得到一些温度远离 28 度的日子。例如,第 25 个最接近的日子可能温度是 35 度。在如此炎热的天气下,人们不愿骑车。如果我们在预测 28 度那天的骑行人数时包含了过多的高温天气,可能会倾向于低估真正的骑行人数。在这种情况下,k = 25 可能太大了。这是一个 偏差 问题:选择过大的 k 值可能会引入系统性低估或高估的倾向。

注意

我们将在本章及后续章节中反复提到方差和偏差。需要记住的是,讨论的是哪种量的方差和偏差:预测值。假设我们在预测 28 度那天的骑行人数。我们使用的 k 值越大,预测值的变异性就越小,但该值的偏差却越大。

方差和偏差是相互对立的。对于给定的数据集,我们只能通过牺牲其中一个来减少另一个。这种权衡对于选择超参数的值以及选择哪些特征使用至关重要。使用过小的 k 值——试图将偏差减少到该数据上无法实现的程度——叫做 过拟合。使用过大的 k 值——过于保守的值——叫做 欠拟合。我们希望在“甜点区”选择超参数值,既不发生过拟合也不发生欠拟合。

3.1.1 特征数量与过拟合的直觉解释

对于特征也有类似的情况:使用过大的 p 值(即特征的数量)会导致过拟合,而使用过小的值则会导致欠拟合。这里是这一现象的直觉解释。

回想一下 mlb 数据集,关于美国职业棒球大联盟球员的数据(见 第 1.8 节)。我们可能根据身高和年龄预测体重。但如果我们从特征集中省略身高呢?那就会引入偏差。大致来说,我们会默许每个人的身高都是中等的,这样会导致我们倾向于高估较矮球员的体重,同时低估较高球员的体重。

另一方面,事实证明,我们使用的预测变量越多(通常来说,不仅仅是针对这个数据集),预测值的方差就越高。为了说明这一点,假设我们正在进行一项营销研究,预测冬季羽绒服的购买情况,并希望考虑客户的地理位置。美国大约有 42,000 个邮政编码。假设我们将邮政编码作为特征之一来预测购买情况。那样的话,我们将有 42,000 个虚拟变量,并且还会有其他特征,如年龄、性别和收入,即p > 42000。如果我们的数据包含 100,000 个客户,我们每个邮政编码的平均数据点只有 2 或 3 个。再次强调,甚至非技术人员也会指出,这是一个过小的样本,导致方差增加。换句话说,过大的p值会增加方差。我们再次看到了方差和偏差之间的矛盾。

3.1.2 与整体数据集大小的关系

但还有更多内容。在选择一个“好的”kp值时,我们需要考虑n,即我们拥有的数据点数量。回想一下,在共享单车示例中,我们有n = 731(也就是只有 731 天的数据)。这个数量是否足够大,能够做出好的预测呢?为什么这个数字很重要?实际上,这与偏差-方差权衡直接相关。原因如下。

在我们上面的共享单车示例中,我们担心当k = 25 时,可能会有一些天的温度与 28 度相差较远。但如果我们有,比如说,2,000 天的数据,而不是 731 天,那么第 25 个最接近的温度可能仍然会非常接近 28 度。换句话说:

n越大,我们可以选择的k值越大,同时仍能避免过大的偏差。

类似地,考虑上面提到的邮政编码问题。如果我们有 100,000 个客户,那么每个邮政编码的平均数据点只有 2 到 3 个。但如果我们的数据集由 5000 万客户组成呢?那时包括邮政编码虚拟变量可能是有用的,因为我们可能从大多数邮政编码中获得足够数量的客户。记住,p表示特征的数量,这里每个虚拟变量都会单独计算。因此,将邮政编码包含在我们的特征集中会使p增加大约 42,000。

换句话说:

n越大,我们可以使用的p值越大——也就是说,我们可以使用更多的特征,同时仍然避免过大的方差。

3.1.3 那么,kp的最佳值是什么?**

请注意,这仍然没有告诉我们如何设置一个好的“金发女孩”k值——既不太小也不太大。对于选择p(也就是选择使用多少个特征),同样的情况也适用;事实上,这是一个更具挑战性的问题,因为它不仅是一个关于使用多少个特征的问题,还涉及选择哪些特征。

正如我们已经多次提到的:

这是机器学习中的一个现实问题。对于大多数问题,没有简洁的“魔法公式”答案。再说一遍,机器学习是一门艺术,而不是科学。然而,验证方法在实践中是常用的,并且它们通常效果不错,尤其是在分析师积累经验之后。

我们将在本章稍后详细介绍验证方法。

此外,一些数学理论建议的一个大致经验法则是遵循以下限制:

Image

也就是说,最近邻的数量应少于数据点数量的平方根。

那么,如何选择p呢?如前所述,一个特征集本身并不是“大”或“小”的,而是其大小p必须相对于数据点数量n来考虑。对于给定的数据集大小,使用过多特征可能会导致过拟合。在经典统计学中,一个粗略的——尽管在我看来是保守的——经验法则是遵循另一个“平方根n”的限制:

Image

也就是说,特征的数量应少于数据点数量的平方根。在此标准下,如果我们的数据框架有 1,000 行数据,那么它可以支持大约 30 个特征。这不是一个坏的粗略指导,并且得到了参数模型理论结果的支持。

然而,在现代统计学和机器学习中,现在常见的是拥有——或至少从——一个比n大的p值。我们将在本书后面看到某些方法时会遇到这一点。我们将保持Image作为一个合理的起始点。如果我们的数据满足该规则,我们可以放心。但如果p较大,我们不应自动认为它过大。

3.2 交叉验证

选择超参数值或选择特征集的最常见方法是最小化 MAPE(数值型Y情况)或总体误分类错误(OME,分类情况)。对于 k-NN 和数值型Y设置,我们可能会为一系列候选k值计算 MAPE,然后选择生成最小 MAPE 的那个。

在决定使用哪个k值时,我们需要评估不同超参数值的预测能力。但在此过程中,我们需要确保使用的是一个“新的”数据集来进行预测。这促使我们将数据分为两组:一个训练集和一个验证集或测试集。

然而,验证集是随机选择的。这在我们已有的采样变异基础上引入了额外的随机性。我们在第 1.12.3 节中看到了这个例子。因此,在选择 k-NN 中的k时,某一验证集可能指示k = 5 为最佳,而另一个则可能倾向于k = 12。为了全面起见,我们不应该仅依赖单一的验证集。这导致了K 折交叉验证方法,在这种方法中,我们生成多个验证集,并对所有这些集的 MAPE、OME 或其他标准进行平均。请注意,k是邻居的数量,而K是折叠数,或者说是可能的验证集数量。

3.2.1 K 折交叉验证

为了了解 K 折交叉验证如何工作,考虑“留一法”,我们将保留集的大小设置为 1。假设我们希望评估 k = 5 的预测能力。对于我们所有的 n 个数据点,我们将保留集设为该数据点,其余 n − 1 个点作为训练集;然后我们预测保留集中的数据点。这将给我们 n 次预测,并计算 MAPE 作为这些 n 次预测的平均绝对预测误差。换句话说,我们将按以下伪代码进行操作:

set sumMape = 0
for i = 1,2,...,n
   set training set = d[-i,]
   set test set = d[i,]
   apply k-NN to training set, with k = 5
   predict the test set
   sumMape = sumMape + abs(predicted Y - actual Y)
MAPE = sumMape / n

我们称之为 n 倍交叉验证。或者,我们可以将保留集的大小设为 2,例如通过将集合 1,2, . . . ,n 分成不重叠的相邻对。现在有 n/2 个可能的保留集(折叠)。对于每个折叠,我们将 k-NN 应用于剩余数据,然后预测该折叠中的数据。MAPE 是 n/2 个折叠的平均值。

有人可能认为 K = n 是最好的,因为这样 MAPE 将基于最多的试验。另一方面,每次试验将只基于预测一个数据点,这可能不够准确。这里可能还存在计算和理论问题,我们不在此讨论。那么我们应该如何选择 K 呢?

请注意,K 不是超参数,因为它不是 k-NN 的特征。它仅仅是如何可靠地估算 MAPE 的问题。但确实,它是我们需要考虑的一个额外因素。许多分析师建议使用 5 或 10 的值。

另一种方法如下,假设我们有大小为 2 的保留集。我们简单地选择多个随机保留集,选择的数量取决于我们有多少时间,示例如下:

set sumMape = 0
for m = 1,2,...,r
   set test set = a random pair of data points (not necessarily adjacent)
   set training set = the remaining n-2 points
   apply k-NN to training set, with a candidate k
   predict the test set
   sumMape = sumMape + mean(abs(predicted Ys - actual Ys))
MAPE = sumMape / r

这里,r 是保留集的数量。我们选择更大的 r 值时,MAPE 的准确性会更高。这只取决于我们愿意花费多少计算时间。(复数形式的 predicted Ys 暗示任何保留集都有两个 Y 值需要预测。)

3.2.2 使用 replicMeans() 函数

我们可以使用 regtools 函数 replicMeans() 来实现 K-means 交叉验证。函数名的意思是“重复一个操作,然后取结果的平均值”。

例如,假设我们有一个数据框 d,其中我们要预测一列 y。考虑以下调用的效果:

cmd <- "qeKNN(d,'y')$testAcc"
crossvalOutput <- replicMeans(10,cmd)

这表示运行cmd 10 次,并返回结果的平均值。由于该命令是运行qeKNN(),10 次运行将使用 10 个不同的保留集,得到 10 个不同的testAcc值。最终结果将是函数返回这 10 个值的平均值,这正是我们想要的。

3.2.3 示例:程序员和工程师数据

在这里,我们将介绍一个新的数据集 pef,它将在本书中的多个地方使用,并展示如何在该数据上进行交叉验证。

pef 数据集包含在 regtools 包中,该包又包含在 qeML 中。它来源于 2000 年美国人口普查,展示了程序员和工程师的数据。以下是一个简要的展示:

> data(pef)
> head(pef)
       age     educ occ sex wageinc wkswrkd
1 50.30082 zzzOther 102   2   75000      52
2 41.10139 zzzOther 101   1   12300      20
3 24.67374 zzzOther 102   2   15400      52
4 50.19951 zzzOther 100   1       0      52
5 51.18112 zzzOther 100   2     160       1
6 57.70413 zzzOther 100   1       0       0
> dim(pef)
[1] 20090     6

所以,关于超过 20,000 名工人的数据存储在这里。

这里的教育变量需要一些解释。普查有不同教育层次的代码,甚至包括没有受过教育的人。但是在这个数据集中,不会有许多(如果有的话)只有六年级教育的人。因此,educ列被简化为三个层次:硕士(代码 14)、博士(16)和“其他”(由软件编码为zzzOther,使用regtools::toSubFactor())。大多数“其他”类别的工作者有本科学历,但即使是学历较低的,也被归入这个层次。

为什么要这么做?qe*系列函数会将任何 R 因子转换为虚拟变量,对于某些此类函数,输出是以虚拟变量的形式显示的。因此,像上面这样的合并会压缩输出。如果包含所有教育层次并显示虚拟变量,甚至运行head()也会得到非常宽的输出。

其次,这种简化通常是为了避免过拟合——记住,每个虚拟变量在特征数p中是单独计算的——即便在这个数据集中,我们已经很符合“Images”的经验法则。

有关此数据集的详细信息,比如各种职业代码,可以在 R 提示符下输入?pef。

3.2.3.1 MAPE 的改进估算

假设我们希望预测该pef数据集中的wageinc,即工资收入。我们先试着进行一次初步预测:

> z <- qeKNN(pef,'wageinc',k=10)
holdout set has  1000 rows
> z$testAcc
[1] 25296.21

平均而言,我们的预测偏差大约为$25,300。这是一个相当大的数字,但正如在第 2.4 节中强调的,我们必须始终将特征集的预测准确度与不使用特征时的预测准确度进行比较:

> z$baseAcc
[1] 32081.36

因此,仅预测每个人的整体平均收入会导致一个更大的 MAPE。

无论如何,我们在这里要强调的不是这个特定数据集,而是如果 MAPE 是基于单一保留集进行预测的,其一般准确性问题。我们确实需要使用交叉验证,查看多个保留集的情况。我们可以使用replicMeans()来实现这一点。

> cmd <- "qeKNN(pef,'wageinc')$testAcc"
> replicMeans(10,cmd)
holdout set has  1000 rows
holdout set has  1000 rows
holdout set has  1000 rows
holdout set has  1000 rows
holdout set has  1000 rows
holdout set has  1000 rows
holdout set has  1000 rows
holdout set has  1000 rows
holdout set has  1000 rows
holdout set has  1000 rows
[1] 25633.51
attr(,"stderr")
[1] 412.1483

所以,所示的qeKNN()函数调用执行了 10 次,产生了 10 个保留集,在测试集上的平均准确度约为$25,633。这比我们之前基于一个保留集获得的$25,296 的结果稍大。因此,我们应该将这个新结果视为更可靠。

那个$412 的数字是标准误差。将其乘以 1.96 得到我们误差的范围。如果我们认为这个范围太大,可以调用replicMeans(),比如进行 100 次重复(即 100 个保留集)。

然后,我们可以尝试其他的k值,像上面那样运行replicMeans(),然后最终选择给出最佳 MAPE 或 OME 的值。如果我们有多个这样的值,使用qeML函数中的qeFT()会更方便,这将在第七章中介绍。

3.2.4 三重交叉验证

假设我们将数据分为训练集和测试集,然后拟合许多不同的超参数组合,选择在测试集上表现最好的组合。我们再次遇到潜在的 p-hacking 问题,这意味着测试集报告的准确率可能过于乐观。

一种常见的解决方案是将数据分为三个子集,而不是两个,其中间的子集称为验证集。我们将不同的超参数组合拟合到训练集上,并在验证集上评估它们。选择最佳组合后,我们再在测试集上评估(仅)该组合,以获得未受 p-hacking 影响的准确性估计。

3.3 结论

总结一下,本章虽然简短,但关键的概念有:

  • 在选择超参数,例如 k-NN 的k,以及选择特征集时,方差和偏差是相互对立的。对于固定数据集,较小的k或较大的p会增加方差而减少偏差,反之亦然。

  • 在更大的n情况下,我们可以选择更大的kp

  • 不幸的是,对于kp的“金发女孩”值,没有固定不变的公式。但有一些非常粗略的经验法则,合理使用保留集和交叉验证会给我们带来很大帮助。随着经验的积累,技能也会随之提升。

再次强调,使用保留集是主要的解决办法,包括如果担心单一数据集上的 MAPE 或 OME 的准确性,可以使用多个保留集。

第四章:处理大量特征**

Image

在上一章中,我们讨论了过拟合——即在给定的设置中使用过多的特征。特征数量过多也可能导致计算时间过长的问题。本章将重点介绍如何减少特征集的大小——换句话说,降维

请注意,这不仅仅是需要使用更少的特征;我们还需要决定使用哪些特征,甚至是哪些特征组合。我们将讨论主成分分析(PCA),这是一种处理较大p值的著名技术,其原理是通过组合旧特征来形成新特征,然后仅使用这些新特征中的一部分作为我们的特征集。

4.1 陷阱:大型数据集中的计算问题

再次强调,过拟合是机器学习中的一个主要问题。正如上一章所述,在谷歌中搜索这个术语,我得到了 6,560,000 个结果!除了过拟合带来的预测准确性问题,我们还需要担心计算问题。特征数量越多,我们的计算时间就越长。

在某些情况下,计算时间可能会变得非常具有挑战性。例如,关于 AlexNet(一个用于图像分类的神经网络)的报道指出,该网络在两台极为强大的计算机上训练需要五到六天的时间。^(1)

此外,计算过程中的能耗可能非常惊人。^(2) 训练一个大型自然语言处理模型可能会消耗大量能源,产生的二氧化碳排放量超过 78,000 磅。相比之下,一辆普通汽车的使用寿命内大约会排放 126,000 磅二氧化碳。

本书的大多数读者不会遇到像上述那样的特别大的应用。但即便是在“较大”的数据集上,计算仍然可能成为一个显著的问题,例如本章中讨论的一些数据集。单个函数调用的运行时间可能不需要以天来计量,但肯定会达到几分钟,甚至在某些情况下,可能需要几小时。

此外,较大的数据集可能占用过多的内存。一个拥有百万行和 1,000 列的数据集包含十亿个元素。每个元素占用 8 字节,那么就需要 8GB 的内存。算法使用的内存量可能是这个数值的几倍。

降维的讨论很少提到由于 NA 值导致的数据丢失,但它可能是一个重要因素。如果数据中的某一行包含一个 NA 值,大多数机器学习软件库将会丢弃这一整行。列数越多,任何给定的行出现至少一个 NA 值的可能性就越大,因此该行被丢弃的可能性也越大。换句话说:

如果我们的数据容易出现 NA 值,那么p越大,我们的有效n值就越小。

因此,我们有了另一个丢弃一些特征的动力。这将导致我们分析中完整案例的数量增加,从而提高预测能力。

和往常一样,这是一种权衡:如果我们删除太多特征,可能会丧失一些具有重要预测能力的特征。因此,我们希望舍弃一些不太重要的特征。

4.2 维度减少简介

在本章以及本书中,我们攻克大数据问题的主要工具将是减少p,即数据集中的特征数量。这被称为维度减少。虽然我们可以采取简单地删除我们认为不太有用的特征的方法,但也有其他更系统的方式可以应用。

维度减少有两个目标,这两个目标同样重要:

  1. 避免过拟合。如果我们没有Image(参见第 3.1.3 节),我们应该考虑减少p

  2. 减少计算。对于更大的数据集,k-NN 和本书中的大多数方法将面临巨大的计算要求,而一个显而易见的解决方案是减少p

4.2.1 示例:百万歌曲数据集

假设你遇到了没有标签的旧歌曲录音,无法确定其名称,你想要识别它。《百万歌曲数据集》可以让我们根据 90 个音频特征预测歌曲的发行年份,让我们尝试一下,因为知道年份可能有助于找到歌曲的标题。你可以从 UC Irvine 机器学习库下载数据^3。(实际上,这个版本的数据集只有大约 50 万首歌曲。)

这样规模的数据集可能会涉及相当大的计算负担。让我们调查一下,看看仅仅做一次预测需要多少时间。

从下载的文件中读取数据,并将结果分配给yr

> yr <- read.csv('YearPredictionMSD.txt',header=FALSE)

请注意,尽管文件名以.txt结尾,但它实际上是一个 CSV 文件,因此我们使用了read.csv()

此外,上面的文件读取速度较慢。读者可以考虑在像这样的较大文件上使用data.table包中的fread()。这里的调用将是:

> library(data.table)
> yr <- fread('YearPredictionMSD.txt')
> yr <- as.data.frame(yr)

由于kNN()需要数据框或 R 矩阵输入(附录 C),我们需要使用最后一行代码将data.table转换为数据框。

让我们来看一下:

> yr <- read.csv('YearPredictionMSD.txt',header=FALSE)
> yr[1,]
    V1       V2       V3      V4      V5        V6        V7        V8
1 2001 49.94357 21.47114 73.0775 8.74861 -17.40628 -13.09905 -25.01202
         V9     V10      V11     V12      V13      V14      V15      V16
1 -12.23257 7.83089 -2.46783 3.32136 -2.31521 10.20556 611.1091 951.0896
       V17      V18      V19      V20      V21      V22      V23      V24
1 698.1143 408.9848 383.7091 326.5151 238.1133 251.4241 187.1735 100.4265
      V25      V26       V27      V28      V29       V30       V31     V32
1 179.195 -8.41558 -317.8704 95.86266 48.10259 -95.66303 -18.06215 1.96984
       V33     V34    V35     V36      V37       V38       V39       V40
1 34.42438 11.7267 1.3679 7.79444 -0.36994 -133.6785 -83.26165 -37.29765
       V41       V42      V43       V44       V45      V46       V47      V48
1 73.04667 -37.36684 -3.13853 -24.21531 -13.23066 15.93809 -18.60478 82.15479
       V49       V50      V51       V52      V53      V54     V55      V56
1 240.5798 -10.29407 31.58431 -25.38187 -3.90772 13.29258 41.5506 -7.26272
        V57      V58      V59      V60      V61      V62     V63      V64
1 -21.00863 105.5085 64.29856 26.08481 -44.5911 -8.30657 7.93706 -10.7366
        V65       V66       V67     V68      V69      V70     V71       V72
1 -95.44766 -82.03307 -35.59194 4.69525 70.95626 28.09139 6.02015 -37.13767
       V73      V74     V75      V76      V77       V78      V79       V80
1 -41.1245 -8.40816 7.19877 -8.60176 -5.90857 -12.32437 14.68734 -54.32125
       V81     V82       V83      V84      V85     V86       V87      V88
1 40.14786 13.0162 -54.40548 58.99367 15.37344 1.11144 -23.08793 68.40795
       V89       V90     V91
1 -1.82223 -27.46348 2.26327

我们看到数据集中有超过 515,000 行(即 515,000 首歌曲)和 91 列。第一列是发行年份,接下来的 90 列是晦涩的音频测量值。这意味着第一列是我们的结果变量,其余 90 列是(潜在的)特征。

4.2.2 维度减少的必要性

我们这里有 90 个特征。对于超过 50 万的数据点,根据第 3.1.3 节的粗略经验法则,可能我们可以使用所有 90 个特征而不发生过拟合。但 k-NN 需要大量计算,所以维度仍然是一个重要问题。

作为计算负担的一个例子,我们来看预测一个数据点需要多长时间,比如预测数据的第一行。正如在第 1.17 节中解释的那样,要预测一个数据点,直接调用kNN()比调用它的封装函数qeKNN()要快。后者是大规模运行的,我们在这个实例中并不需要它,而且它会计算许多这里不需要的量。(它在训练集的每个点上计算估计的回归函数,仅当我们计划未来做大量预测时才有效。)

回顾一下,kNN()的参数是X数据、Y数据、待预测的数据点以及k。以下是代码:

> system.time(kNN(yr[,-1],yr[,1],yr[1,-1],25))
  user  system elapsed
30.866   8.330  39.201

仅仅预测一个数据点就需要 39 秒多。如果我们预测整个原始数据集的一个重要部分,比如holdout = 100000,那就意味着我们需要等待 39 秒 100,000 次,这将是不可承受的。

因此,我们可能希望缩减特征集的大小,无论是出于计算考虑(如本文所述),还是在其他情况下为了避免过拟合。

特征选择是机器学习的另一个方面,虽然没有完美的解决方案,但已有一些相当好的方法。在我们进入本章讨论的主要方法 PCA 之前,先看几个其他方法,以便更好地理解在选择特征时我们面临的挑战。

4.3 降维方法

现在我们看到了降维的必要性,那么如何实现它呢?我们将在这里介绍几种方法:整合与嵌入、所有可能的子集以及 PCA。这些方法具有广泛的适用性,书中的后续章节将针对一些特定的机器学习方法介绍更多技术。

4.3.1 整合与嵌入

经济学家谈到代理变量。我们可能希望获取某个变量U的数据,但由于缺乏它,改为使用我们有数据的变量V,而且V能很好地捕捉到U的本质。V被称为U的“代理”变量。机器学习中的相关技术包括整合嵌入

在降维的背景下,代理变量可能有不同的用途。假设我们确实有U的数据,但这个变量是分类的,且具有大量的类别(即在 R 中表示变量的因子实现有大量级别)。这意味着会有大量的虚拟变量,或者说是高维度。在这种情况下,减少维度的一种方法是合并级别,从而得到一个新的分类变量V,并减少虚拟变量的数量。更好的方法是,我们可能能够使用一个数值型的V,这样就不需要虚拟变量,只有那个变量。

再次考虑第 3.1.1 节中的邮政编码示例,其中假设的目标是估计派克大衣的销量。如果有 42,000 个邮政编码,那么在虚拟变量的情况下,我们会有 42,000 个虚拟变量。我们可以通过选择仅使用邮政编码的首位数字来减少这个数字。例如,可以将 90000、90001、...、99999 这些邮政编码合并成一个单一的层级 9。(虽然并不是所有这些邮政编码都存在,但原理是相同的。)加州大学戴维斯分校和加州大学洛杉矶分校的邮政编码分别为 95616 和 90024,现在这两个邮政编码都将简化为 9。

显然,这将导致信息的丢失;它将引入一些偏差,影响偏差-方差平衡。但我们仍然可以得到相当不错的地理细节——例如,9 这个数字代表的是西海岸——用于预测派克大衣购买等目的。这样,我们就可以为每个 10 个(缩写)邮政编码 0 到 9 收集大量的数据点,从而减少方差。

这将把 42,000 个虚拟变量减少到 9 个。更好的是,我们可能选择使用嵌入。我们可以从政府数据网站获取每个邮政编码的平均每日冬季温度(记住,我们在卖派克大衣),并使用这个温度代替邮政编码作为特征。这样,我们就只需要一个特征,而不是 42,000 个,甚至不是 9 个。

4.3.2 所有可能子集方法

可能有人会想,“为了选择一个好的特征集,为什么不查看所有可能的特征子集呢?我们可以为每个子集计算 MAPE 或 OME,然后使用最小化该指标的子集。”这就是所谓的所有可能子集方法

例如,在歌曲数据集中,在预测留出数据时,我们可以按以下步骤进行操作:对于我们的 90 个列的每个子集,我们可以预测我们的留出集,然后使用预测效果最佳的列集合。这里有两个大问题:

  1. 我们需要大量的计算时间。仅仅是 2 个特征集就有超过 4,000 个。所有大小的特征集的数量是 2⁹⁰,这是类似“宇宙中原子的数量”这样荒谬的数字,常见于新闻报道中。

  2. 我们可能会面临严重的 p-hacking 问题。某些列对预测发行年份的准确度可能会偶然看起来非常高,这种机会可能非常大。

4.3.3 主成分分析

但如果我们通过使用一种最流行的降维方法来改变特征,这种方法也并非完全不可行:主成分分析(PCA)。例如,在歌曲数据中,我们不需要调查不可能的 2⁹⁰个子集,而只需关注 90 个特征。其原理如下。

为了将 PCA 应用到百万歌曲数据集,我们将用 90 个新的特征替换原来的 90 个特征,这 90 个新特征将作为原始特征的 90 种组合来构建。在我们的数据框中,我们将用这 90 个新特征来替代原始的 90 个特征列,这些新特征被称为主成分(PCs)

听起来没有什么进展,对吧?我们希望减少特征数量,而在上述场景中,我们只是将一组 90 个特征换成了另一组同样大小的特征。但正如下文所示,我们将通过对这些新特征使用“所有可能子集法”,尽管是按照某种顺序进行的,从而实现这一目标。

我们首先检查的子集是 PC1;接着是 PC1 和 PC2 的组合;然后是 PC1、PC2 和 PC3 的三重组合;依此类推。也就是说,总共有 90 个子集,通常我们会在达到 90 个之前就停止。

你会发现,在这些新特征中选择子集比在原始的 90 个特征中选择要容易得多。

4.3.3.1 主成分特性

主成分有两个特殊的属性。首先,它们是非相关的;大致来说,一个主成分的值不会影响其他主成分。从这个意义上讲,我们可以认为它们互不重复。这为什么重要?如果在减少预测变量数量之后,某些变量之间发生了部分重复,那就意味着我们应该进一步减少变量数量。因此,拥有非相关特征意味着没有重复特征,我们觉得自己已经达到了一个最小且不重复的特征集。

其次,主成分(PCs)是按方差递减的顺序排列的。因为一个方差小的特征本质上是常数,它可能不会是一个有用的特征。因此,我们可能只保留那些具有较大方差的主成分。由于方差是有顺序的,这意味着我们可以保留,例如,方差最大的m个主成分。请注意,m因此成为了另一个超参数。

每个主成分(PC)都是我们原始特征的线性组合——也就是说,一个 PC 是常数与特征的乘积之和。例如,如果后者是身高、体重和年龄,一个 PC 可能是 0.12 身高 + 0.59 体重 − 0.02 年龄。回想一下代数知识,这些数字 0.12、0.59 和−0.02 被称为系数

在机器学习中,重点是预测而不是描述。例如,在自行车租赁数据中,可能有人有兴趣研究特征如何影响骑行人数,比如假期期间我们可能会有多少额外的骑行者。这将是一个描述性应用,而这通常不是机器学习中的关注点。所以,像 0.12、0.59 和−0.02 这样的系数对我们来说并不是很重要。相反,关键在于我们正在将原始特征组合成新的特征,我们可能并不会直接检查这些系数本身。

使用这些新特征,我们需要从更少的候选集合中进行选择:第一个 PC,第一个两个 PC,前三个 PC,依此类推。我们只需从 90 个候选特征集中的选择,而不是从我们如果查看所有可能的子集将得到的 2⁹⁰个子集中选择。我们可以通过交叉验证或下文介绍的其他方法来从这些子集中选择。请记住,较小的集合可以节省计算时间(对于 k-NN 方法以及其他方法),并帮助避免过拟合和 p-hacking。

4.3.3.2 PCA 在百万歌曲数据集中的应用

Base-R 包含了多个用于进行 PCA 的函数,其中包括我们在这里使用的prcomp()。CRAN 还有实现特别快速的 PCA 算法的软件包,例如RSpectra,这些算法对于大数据集非常有用。

我们将主要使用qePCA(),这是一个封装了prcomp()regtools函数,但你应该至少对后者有一些了解,方法如下。以下是调用方式:

> pcout <- prcomp(yr[,-1])

记住,PCA 是针对X数据的,而不是Y,所以我们在这里跳过了年字段,它位于第一列。

作为示例,假设我们决定使用前 20 个主成分。我们通过保留输出中rotation部分的前 20 列来实现这一点,rotation包含了主成分的系数(例如上面的 0.59 等)。让我们先了解这个组件:

> rt <- pcout$rotation
> dim(rt)
[1] 90 90
> rt[1:10,1:10]
              PC1           PC2           PC3           PC4           PC5
V2  -1.492918e-03 -0.0001759914  0.0006706243 -0.0005624011 -0.0027970362
V3  -6.293541e-03  0.0064782264  0.0027845347 -0.0051094877 -0.0239474483
V4  -4.794322e-03 -0.0034153809 -0.0066157025 -0.0044176142  0.0071294386
V5   2.143497e-03  0.0041913051  0.0022151336 -0.0016482743 -0.0087460406
V6   3.013464e-03  0.0013071748 -0.0013400543  0.0032640501  0.0028682836
V7   1.892908e-03  0.0052421454  0.0031921120 -0.0003470488  0.0028807861
V8  -1.518245e-04 -0.0001992038 -0.0004968169  0.0010151582 -0.0061954012
V9   3.160014e-04  0.0009142221 -0.0001189277 -0.0008729237  0.0014144888
V10 -7.203671e-04 -0.0001721222  0.0003838908 -0.0012362764 -0.0009607571
V11  3.348651e-05  0.0011663312  0.0003964205 -0.0008766769 -0.0002092245
              PC6           PC7           PC8           PC9          PC10
V2   3.227906e-04 -0.0012293820  0.0009259120 -0.0009496448  0.0007397941
V3  -1.247137e-02 -0.0185123792  0.0122138115 -0.0186672476  0.0099506738
V4  -9.063568e-03  0.0160957668  0.0123209033 -0.0018904928 -0.0039866354
V5   2.708827e-03 -0.0085000743 -0.0070522158  0.0008992783  0.0003743787
V6  -7.412826e-03 -0.0003856357  0.0003811818 -0.0002785585  0.0022809304
V7   9.840927e-05 -0.0009025075 -0.0006593167  0.0032070603  0.0024775608
V8   3.425829e-03 -0.0038602270 -0.0040122340  0.0005974108  0.0009542331
V9  -1.280174e-03  0.0032705198  0.0059051252 -0.0025076692 -0.0049897536
V10 -4.725944e-05 -0.0035063179 -0.0002710257 -0.0016148579  0.0007367726
V11 -1.940717e-03 -0.0015004583  0.0036280402 -0.0025576335 -0.0025080611

所以,rt有 90 行和 90 列,且所有条目都是数值型的。我们看到行名和列名分别是V2V3,...和PC1PC2,...。行名来自我们的特征名(第一个是Y,不是特征):

> names(yr)
 [1] "V1"  "V2"  "V3"  "V4"  "V5"  "V6"  "V7"  "V8"  "V9"  "V10" "V11" "V12"
[13] "V13" "V14" "V15" "V16" "V17" "V18" "V19" "V20" "V21" "V22" "V23" "V24"
...
[85] "V85" "V86" "V87" "V88" "V89" "V90" "V91"

这些列名代表主成分(PCs)。我们有 90 个特征,因此有 90 个主成分,命名为PC1PC2,依此类推。

在这个例子中,我们已经说过,我们只想使用前 20 个主成分来进行降维。因此,我们将丢弃rt中的 PC21 至 PC90 列:

> pcout$rotation <- rt[,1:20]

因此,我们现在只使用前 20 个主成分。现在我们根据这些主成分转换原始数据:

> pcX <- predict(pcout,yr[,-1])

这是我们的新X数据。我们稍后会看看如何使用它,但首先,predict()调用中发生了什么呢?该函数实际上并没有进行任何预测。它只是将原始特征转换为新的特征。我们来仔细看看这个过程。

首先,回想一下,在 R 中,predict()是一个通用函数(请参见第 1.5.1 节)。prcomp()函数返回一个'prcomp'类的对象(通过输入class(pcout)来检查上述数据)。因此,predict()的调用会转发到predict.prcomp(),这是 R 开发人员为此目的编写的函数——将旧特征转换为新特征。

所以,我们的调用predict(pcout,yr[,-1])告诉 R,“请根据pcout中的内容,将yr[,-1]中的特征转换为主成分特征。”由于我们之前已经将pcout$rotation更改为只使用前 20 个主成分,因此现在调用predict()时,pcout将生成一个新的 20 列数据框,替代我们原来的 90 列数据框:

> dim(pcX)
[1] 515345     20

我们的行数与旧的X相同——因为它仍然是歌曲数据(尽管已被转换),共有 515,345 首歌。但现在我们有 20 列,而不是 90 列,表示我们现在只拥有 20 个特征。顺便提一下,这些新列中没有一个是yr中的列;每一列都是原始列的某种组合。

现在,我们如何使用 PCA 进行 k-NN 预测呢?假设我们有一个新的案例,它的 90 个音频指标与数据集中第八首歌完全相同,只是第一列的值为 32.6。我们将其存储在w中。

> w <- yr[8,-1]
> w[1] <- 32.6

由于我们将训练集中的X数据转换成了 PCA 形式,因此也要对w进行相同的处理:

> newW <- predict(pcout,w)
> newW
        PC1       PC2       PC3      PC4      PC5       PC6       PC7      PC8
8 -1129.298 -168.8051 -368.4595 567.6397 -424.265 -291.5593 -168.7774 17.12907
       PC9      PC10      PC11     PC12     PC13     PC14     PC15      PC16
8 243.8396 -134.2792 -35.67449 191.6426 37.70002 50.17716 -66.3604 -72.97002
       PC17      PC18     PC19     PC20
8 -131.9982 -223.3206 -50.0114 52.04066

然后,我们会在新的数据上调用qeKNN()

但直接这么做会很繁琐,通常我们会使用省时的qePCA()函数。但在我们讨论如何选择使用的 PC 数量时,先将预测问题暂时放一边。

4.3.4 但现在我们有了两个超参数

以前,为了获得最准确的预测,我们只需要尝试一系列邻居数量k的值,但在这种情况下,我们将需要测试k和 PC 数量m的多个值。这当然不是什么新鲜事,因为我们一直需要决定使用哪些特征;过拟合可能来源于k值过小或特征过多。现在,通过转换为 PCA,我们至少将后者形式化为超参数m。因此,实际上我们让事情变得稍微容易一些。

假设我们尝试每个 10 个k值与每个 10 个m值组合,并找出每对组合的保留 MAPE 值。然后,我们会选择 MAPE 最小的那一对。但这意味着我们需要尝试 10 × 10 = 100 对,假设对每对组合应用 K 折交叉验证(即对每对组合运行多个保留集)。

记住,这里有两个主要问题。首先,每对组合都需要大量的计算时间,导致总运行时间非常长;其次,我们应该关注潜在的 p-hacking 问题(见第 1.13 节):其中一个 100 对中的组合可能仅仅是巧合,恰好预测出了歌曲的发布日期。

作为一种替代方法,我们也许可以直接选择m。一种常见的直观方法是查看 PC 的方差(标准差的平方)。一个方差很小的变量本质上是常数,因此可能对预测Y几乎没有帮助。因此,思路是丢弃方差很小的 PC。我们来看看:

> pcout$sdev²
 [1] 4.471212e+06 1.376757e+06 8.730969e+05 4.709903e+05
 [5] 2.951400e+05 2.163123e+05 1.701326e+05 1.553153e+05
 [9] 1.477271e+05 1.206304e+05 1.099533e+05 9.319449e+04
...

e表示 10 的幂。例如,第一个表示 4.471212 × 10⁶。

我们看到方差迅速减小。第十二个方差约为 90,000,相比第一个方差超过 400 万,几乎可以忽略不计。因此,我们可能决定选择m = 12。一旦这样做,我们就回到了只需要选择一个超参数k的情况。

目前仍然没有选择 PC 数量m的魔法公式,但关键是,一旦我们决定了m的值,就可以单独选择k。例如,我们可以用这种直观的方式选择m,然后通过交叉验证来得到k。这可能比同时寻找最佳的(k, m)组合要容易。

分析师通常会将这些方差看作总方差的累积比例。这里,R 的cumsum()(累积和)函数会派上用场。该函数的工作方式如下:

> u <- c(12,5,13)
> cumsum(u)  # 12+5 = 17, 12+5+13 = 30
[1] 12 17 30
> cumsum(u) / length(u)  # convert to proportions
[1]  4.000000  5.666667 10.000000

让我们将其应用到 PC 方差上:

> pcsds <- pcout$sdev²
> cumsum(pcsds) / sum(pcsds)
 [1] 0.4691291 0.6135814 0.7051886 0.7546059 0.7855726 0.8082685 0.8261192
 [8] 0.8424152 0.8579151 0.8705719 0.8821084 0.8918866 0.9003893 0.9075907
[15] 0.9146712 0.9212546 0.9269892 0.9324882 0.9376652 0.9416823 0.9455962
[22] 0.9493949 0.9530321 0.9564536 0.9597000 0.9627476 0.9653836 0.9678344
[29] 0.9700491 0.9720936 0.9739898 0.9756223 0.9771978 0.9785780 0.9799187
[36] 0.9811843 0.9824226 0.9836061 0.9847728 0.9858420 0.9868994 0.9879009
[43] 0.9888200 0.9896491 0.9904612 0.9912403 0.9919651 0.9926362 0.9932901
[50] 0.9938841 0.9944387 0.9949139 0.9953413 0.9957644 0.9961658 0.9965495
[57] 0.9968947 0.9971970 0.9974792 0.9977482 0.9979897 0.9982232 0.9984284
[64] 0.9985960 0.9987592 0.9989146 0.9990633 0.9992035 0.9993261 0.9994199
[71] 0.9995115 0.9995965 0.9996681 0.9997329 0.9997858 0.9998289 0.9998700
[78] 0.9999061 0.9999277 0.9999483 0.9999608 0.9999708 0.9999792 0.9999856
[85] 0.9999907 0.9999948 0.9999974 0.9999987 0.9999996 1.0000000

因此,如果我们选择m = 12,我们选择的主成分大约能够解释数据总方差的 89%。或者我们可能认为这降维过多,选择一个 95%的方差截断值,因此使用m = 23 个主成分。

注意

请注意此处的措辞“可能”,“例如,如果”和“假设”。正如我们之前所见,机器学习中的许多事情并没有机械式、公式化的“方案”。再次提醒,机器学习是一门艺术,而非科学。只有通过经验,才能在这门艺术上获得精通,而不是通过固定的“方案”。

4.3.5 使用 qePCA()包装函数

我们的目标是首先使用 PCA 进行降维,然后使用前m个主成分作为特征进行 k-NN 预测。为了实现这一点,需要执行以下一系列相对复杂的操作。幸运的是,有一个函数可以自动化这些步骤,但像往常一样,我们首先需要理解这些步骤,然后再转向方便的函数。

假设我们的训练数据的X部分和Y部分分别存储在trnXtrnY中。

  1. 我们调用prcomp(trnX)来计算主成分系数。我们将这个结果命名为pcout,如上所述。

  2. 我们将pcout$rotation中的列数限制为反映所需的主成分数。

  3. 我们调用predict(pcout,trnX)将我们的X数据转换为主成分形式,假设为pcX。现在我们的新训练集由pcXtrnY组成。

  4. 我们现在在pcXtrnY上应用qeKNN()。假设我们将结果命名为knnout

  5. 随后,当新的X数据(例如newX)到来时,我们首先调用predict(pcout,newX)将其转换为主成分形式。假设我们将结果命名为pcNewx

  6. 对于我们的Y预测,我们接着调用predict(knnout,pcNewX)来获得预测的Y

顺便提一下,你注意到我们在这里使用predict()有两种不同的方式吗,一种是转换为主成分形式,另一种是进行 k-NN 预测?在这里我们看到了 R 语言中通用函数的概念。在第一个上下文中,R 将predict()调用转发给predict.prcomp(),而在第二个上下文中,转发给predict.qeKNN()

上述过程包含了很多步骤,但它确实有一定的模式,这意味着我们应该通过代码来自动化它。这正是qePCA()包装函数的目的。

使用qePCA()时,指定数据和Y列名,如同其他qe*系列函数,并且还指定以下内容:所需的机器学习方法(例如 k-NN)、该方法的常见超参数(k)以及主成分的总方差所需的比例。

例如,调用

z <- qePCA(yr,'V1','qeKNN',opts=list(k=25),0.85)

该语句表示我们想使用 k-NN 预测歌曲数据中的年份(V1),并且k = 25。它还表明,我们希望使用足够的主成分来解释特征总方差的 85%。

那么,我们使用k = 25 和 85%的总方差来进行预测的效果如何?

> z$testAcc
[1] 7.373

在猜测一首歌的发行年份时,我们的平均误差大约是 7 年。

为了预测一个新的案例——比如说 w,我们在第 4.3.3.2 节中使用的示例歌曲——我们会做出如下调用:

> w <- yr[8,-1]
> w[1] <- 32.6
> predict(z,w)
        [,1]
[1,] 1994.96

所以,我们会猜测这首歌大约是在 1995 年发布的。

所有这些比上面的多步骤过程要简单得多;qePCA()节省了我们很多工作!

4.3.6 主成分和偏差-方差权衡

现在我们知道如何使用 PCA 进行降维和随后的预测,让我们回到如何选择最近邻数 k 和主成分数 m 的问题。请注意,选择 m 等同于选择 qePCA() 中最后一个参数的方差比例。

像往常一样,我们面临着偏差-方差问题,无论是对于 k 还是 m。我们之前已经讨论过 k 的权衡,那么 m 呢?

实际上,m 的情况也并不新鲜。回想一下我们在第 3.1.1 节中关于 mlb 数据集所说的:

我们可能会根据身高和年龄预测体重。但如果我们从特征集中省略了身高呢?那将导致偏差。粗略地说,我们实际上是在默许每个人的身高是中等的,这会导致我们倾向于高估矮个子运动员的体重,而低估高个子运动员的体重。

换句话说,省略一个特征会导致偏差。或者,等效地,添加特征会减少偏差。由于主成分(PCs)就是特征,我们可以看到,使用的主成分越多,偏差越小。但同一部分还指出,特征越多,预测的方差越大,这不是好事。还需要注意的是,使用更多的主成分会导致更长的计算时间。

为了说明这一点,让我们设计一个小实验,研究变化的 m(主成分数)对结果的影响。

由于我们的歌曲数据集相当大,让我们考虑一个随机子集,看看在不同的方差比例水平下,我们能做到什么程度。我们将使用一个子集,比如说 25,000 个样本,并检查计算时间和预测准确度:

set.seed <- 9999
yr1 <- yr[sample(1:nrow(yr),25000),]  # extract 25,000 random rows
res <- matrix(nrow=9,ncol=4)
for (i in 1:9) {  # loop to do proportions 0.05, 0.15,..., 0.95
   pcaProp <- 0.05 + i*0.10
   st <- system.time(z <- qePCA(yr1,'V1','qeKNN',opts=list(k=25),pcaProp))
   res[i,1] <- pcaProp
   res[i,2] <- st[3]  # the actual elapsed run time
   res[i,3] <- z$qeOut$testAcc
   res[i,4] <- z$numPCs  # m
}

结果如表 4-1 所示。第一列显示了总方差的比例,进而得出第四列中显示的主成分数 m。在查看后者时,记得完整的主成分数是 90。第二列显示了运行时间,显然它随着 m 增加而增加;更大的 m 意味着 k-NN 必须进行更多的计算。

在这项研究中,最重要的列是第三列,即 MAPE 值。我们看到,在计算时间大幅增加的情况下,MAPE 的降低仅为适度。而且,最佳的 MAPE 只使用了 90 个主成分中的 11 个。

然而,请始终记住,这些 MAPE 值受到抽样变化的影响。它们来自留出集,而如你所知,留出集是随机选择的。对于每个方差比例水平,我们应该查看多个留出集,而不仅仅是一个,使用交叉验证。我们可以通过将 replicMeans() 应用于 qePCA() 调用来做到这一点,尽管这会非常耗时。

这意味着我们不能确定 m = 11 是最佳选择。至少,我们可以看到数据表明我们应该使用远少于 90 个主成分。此外,除了著名的偏差-方差权衡外,还有一个涉及分析师时间的权衡。我们可能会觉得设置 m = 11 已经足够好了。

表 4-1: 不同 m 值的行为

pcaProp 时间 (秒) MAPE 主成分数量
0.15 1.137 8.40020 2
0.25 1.230 7.83712 3
0.35 2.051 8.12444 6
0.45 7.222 7.46536 11
0.55 17.812 7.88648 16
0.65 35.269 7.66808 24
0.75 51.041 7.55740 33
0.85 72.916 7.85736 46
0.95 94.761 7.72288 66

此外,记得在 第 3.1.2 节 中讨论过,n 越大,我们可以承受的 p 就越大。在上述分析中,我们设定 n = 25000 且 p = m,最终选择了 m = 11。但对于完整数据集,n = 500000,我们应该使用超过 11 个主成分。

因此,我们可能会想要在完整数据集上运行上述代码。然而,即便是 n = 25000,运行时间也大约为半小时;对于超过 500,000 条记录的完整数据集,可能需要几个小时。因此,我们可能会选择 m = 11,并在时间允许的情况下稍后进行更精细的分析。

4.4 维度灾难

维度灾难(CoD) 表示随着特征数量的增加,机器学习变得越来越困难。例如,数学理论表明,在高维空间中,每个点与其他点的距离大致相同。让我们简要讨论一下这种奇异现象背后的直觉。显然,这对依赖距离的 k-NN 方法有影响,实际上对于一些,甚至可能是所有其他机器学习方法也有影响。

为了大致理解维度灾难,考虑由学生在数学、文学、历史、地理等科目的成绩组成的数据。学生 A 和 B 的成绩数据向量之间的距离将是以下差异的平方和的平方根:

(数学成绩[A] − 数学成绩[B])² + (文学成绩[A] − 文学成绩[B])² +

(历史成绩[A] − 历史成绩[B])² + (地理成绩[A] − 地理成绩[B]

那个表达式是一个求和,且可以证明,具有大量项的和(这里只有四项,但我们可以有更多项)相对于其均值具有较小的标准差。具有小标准差的量几乎是常数,因此在高维空间中——即在具有大量 p(特征数目多)的设置中——距离几乎是常数。

这就是为什么在高维空间中 k-NN 表现不佳的原因。这个问题不仅限于 k-NN;大多数机器学习工具箱中的方法都有类似的问题。线性回归/逻辑回归的计算(参见 第八章)涉及到 p 项的求和,类似的计算也出现在支持向量机和神经网络中。

实际上,许多问题出现在高维空间中。一些分析师将它们都归类为 CoD。不管 CoD 的定义是什么,显然,高维度是一个挑战——这也是进行维度降维的更多理由。

4.5 其他维度降维方法

维度降维是机器学习和统计学中最活跃的研究和争论问题之一。本章虽然聚焦于 PCA 这种常见的方法,但实际上还有许多其他方法。

4.5.1 基于条件独立性的特征排序

我发现一个有用的方法(并且我为其发展略有贡献)是基于条件独立性的特征排序(FOCI)。它基于坚实的数学原理(这里过于复杂,无法详细解释),并且效果相当不错。

qeML包包含了一个 FOCI 封装函数,qeFOCI。以下是它的基本调用方式:

qeFOCI(data,yName)

由于此方法的计算需求可能较大,因此也有并行化选项,例如以下方法:

qeFOCI(data,yName,parPlat='locThreads')

这将把计算分成多个部分,计算机的每个核心将处理其中一个部分。

我们来尝试对第 3.2.3 节中的人口普查数据应用 FOCI:

> qeFOCI(pef,'wageinc')
$selectedVar
    index   names
 1:    10 wkswrkd
 2:     1     age
 3:     6 occ.102
 4:     2 educ.14
 5:     3 educ.16
 6:     5 occ.101
 7:     4 occ.100
 8:     9   sex.1
 9:     8 occ.140
10:     7 occ.106

$stepT
 [1] 0.1909457 0.2373486 0.2501502 0.2736589 0.2838555 0.2879643 0.2946782
 [8] 0.2978845 0.2985381 0.3004416

attr(,"class")
[1] "foci"

请注意,qeFOCI()将 R 因子转换为虚拟变量。因此,我们可以看到对每个职业的评估。例如,编码为 102 的职业似乎具有良好的预测能力,而职业 106 的预测能力则可能较差。

输出中的stepT部分给出了某种类型的相关性;我们增加预测变量时,变量的预测集体能力会变强。如果我们希望更为保守,可能会在相关性趋于平稳时选择截断,例如在本例中,可能在 6 或 7 个变量时就停止。

那么歌曲数据呢?为了减轻计算负担,我对数据进行了 10%的子抽样,并使用了 2 个核心来进行计算。即便如此,它仍然运行了超过 20 分钟:

> yr1 <- yr[sample(1:nrow(yr),50000),]
> system.time(z <- qeFOCI(yr1,'V1',numCores=2,parPlat='locThreads'))
[[1]]
 [1]  1 14  2  3  9 10  5  4 21  6 17 13 22

[[2]]
[1]  1 14  3  2 50 16 40 23 21

    user   system  elapsed
  79.873   10.489 1253.839
> z
$selectedVar
   index names
1:     1    V2
2:    14   V15
3:     3    V4
4:     2    V3
5:    50   V51

$stepT
 [1] 0.0496876 0.1006503 0.1306439 0.1547381 0.1695977 0.1687346 0.0000000
 [8] 0.0000000 0.0000000 0.0000000 0.0000000 0.0000000 0.0000000 0.0000000
...

使用了 2 个核心,每个核心都将 FOCI 应用于其数据部分。第一个核心选择了变量 1、14、2,依此类推,而第二个核心选择了一些相同的变量,也有不同的变量;例如,第二个核心选择了变量 50,但第一个核心没有。我们设计的并行算法会取这两个变量集合的并集,因此变量 50 出现在最终的变量列表中。不过,最终只选择了前 7 个变量,因为相关性在此之后并没有增加。

4.5.2 均匀流形近似与投影

均匀流形近似与投影(UMAP)方法的使用模式与 PCA 类似,我们通过原始变量找到新的变量,并且只保留在预测能力上排名前几的变量。不过不同的是,使用 UMAP 时,新的变量是原始变量的复杂非线性函数。

qeML包为 UMAP 提供了一个封装函数,qeUMAP()。如前所述,它的使用方式与qePCA()类似。在本书中我们不会进一步探讨这个话题,但建议读者尝试一下。

4.6 更深入的计算方法

对于非常大的数据集,我强烈推荐使用data.table包。bigmemory包可以帮助解决内存限制问题,尽管它是为那些了解操作系统级别计算机的专家设计的。另外,对于熟悉 SQL 数据库的人来说,有几个包可以与这种数据接口,如RSQLitedplyr

4.7 结论

在本章中,我们已经了解了在处理大规模数据时,计算问题如何与偏差-方差权衡相互作用。例如,我们可能希望在添加更多变量变得统计上不再有利之前,就限制特征的数量。我们在这里介绍的解决方法是主成分分析(PCA),虽然我们也简要提到过其他方法。

第二部分:基于树的方法

在这里,我们介绍树方法,它是邻域概念的扩展。当我们进入这些以及其他复杂的模型时,我们将引入一个更复杂的超参数选择工具,即qeML函数qeFT()

第五章介绍了决策树,这是一种最早的机器学习方法,至今仍然非常流行。这引出了两种更为流行的方法——随机森林和梯度提升,它们在第六章中有所讲解。

第五章:超越 k-NN:决策树**

Image

在 k-NN 中,我们会观察待预测数据点的邻域。在这里,我们同样会观察邻域,但方式更加复杂。这个方法易于实现和解释,能生成直观的图示,并且拥有更多可调的超参数,以便进行微调。

在这里,我们将介绍决策树(DT),它是机器学习领域的主要方法之一。除了直接使用外,决策树还是随机森林梯度提升的基础,这些内容将在后续章节中介绍。

5.1 决策树基础

虽然早期曾有一些相关思想被提出,但决策树方法由于统计学家 Leo Breiman、Jerry Friedman、Richard Olshen 和 Chuck Stone 的工作而被广泛应用。他们将他们的方法称为分类与回归树(CART),并在他们的书《分类与回归树》(Wadsworth,1984 年)中进行了描述。

决策树方法基本上将预测过程设定为一个流程图,因此得名决策树。例如,参见图 5-1 在第 5.2.1 节中。我们根据温度、风速等特征来预测臭氧水平。在预测一个新案例时,我们从树的顶部开始,沿着某条路径向下走,并在此过程中做出是否向左或向右转的决策。在树的底部,我们做出最终预测。

我们使用训练集数据生成一棵树。树的顶部(根节点)包含所有数据。然后,我们根据某个特征是否大于或小于给定的值,将数据分成两部分。这会在根节点下方分别创建两个新节点,位于左右两侧。接着,我们将每个部分再分成两部分,如此继续下去。因此,另一种该过程的名称是递归划分

在每一步中,我们可以选择停止——即在树的某个路径或分支上不再进行进一步分裂。此时,未分裂的节点称为叶子节点终端节点。树的任何分支最终都会结束于某个叶节点。

最终,为了预测一个新案例,我们从树的根节点开始,一直走到叶子节点。我们预测的Y值取决于应用类型。在数值型Y的情况下,我们预测的Y值是该节点内所有Y值的平均值。对于分类应用,我们预测的Y值是给定叶节点中最为常见的类别。或者等效地,可以将Y表示为虚拟变量,并计算每个虚拟变量的平均值。这样,我们就得到了各个类别的概率,并将预测类别设置为概率最大的那个。

从这个意义上说,决策树(DT)与 k-近邻(k-NN)类似。叶节点类似于 k-NN 中的邻域概念。

为了决定(a)是否将树中的某个节点进行分割,以及(b)如果分割,如何进行分割,已经设计了各种方案。稍后将详细介绍。

5.2 qeDT()函数

R 的 CRAN 仓库有几个决策树包,我特别喜欢的是partykit及其早期版本party。(这些名字是对术语递归分区的双关。)我们的qe*系列函数qeDT()是对party::ctree()的包装。为了说明这一点,我们运行一个来自该包的示例。

这里的数据集airquality是内置在 R 中的,结构如下所示:

> head(airquality)
  Ozone Solar.R Wind Temp Month Day
1    41     190  7.4   67     5   1
2    36     118  8.0   72     5   2
3    12     149 12.6   74     5   3
4    18     313 11.5   62     5   4
5    NA      NA 14.3   56     5   5
6    28      NA 14.9   66     5   6
7    23     299  8.6   65     5   7

我们的目标是根据其他特征预测臭氧水平:

> airq <- subset(airquality, !is.na(Ozone)) # remove rows with Y NAs
> dim(airq)
[1] 116   6
> dtout <- qeDT(airq,'Ozone',holdout=NULL)

由于这是一个非常小的数据集,我们决定不使用保留集。

我们像往常一样预测新的数据点(毕竟,qe*系列旨在为它们包装的各种函数提供统一的接口)。假设我们有一天的新数据需要预测,数据与airq[1,]相同,但风速为 8.8 英里每小时。我们应该预测臭氧浓度的什么值?

> w <- airq[1,-1]
> w[2] <- 8.8
> w
  Solar.R Wind Temp Month Day
1     190  8.8   67     5   1
> predict(dtout,w)
        Ozone
[1,] 18.47917

我们预测臭氧浓度大约为 18.5 百万分之一。

如你所知,qe*系列函数是包装器,它们的返回对象通常包括一个组件,该组件包含被包装函数的返回对象。这在qeDT()中也适用:

> names(dtout)
[1] "ctout"     "classif"   "trainRow1"

这里的ctout是由ctree()返回的对象,当后者从qeDT()调用时返回。顺便提一句,ctout属于'party'类。

我们在这里使用的是默认的超参数,可能通过更好的参数设置获得更好的预测效果。有关更多内容,请参见第 5.6 节,但现在让我们专注于通过绘制流程图来理解决策树的工作过程。

5.2.1 查看图形

大多数决策树包允许你绘制树图,这有时可以为分析师提供有价值的见解。不过,在我们的设置中,我们将使用该图来更好地理解决策树的工作原理。

调用非常简单:

> plot(dtout)

如前所述,plot()是一个 R 的通用函数(即占位符)。上述调用被分派到plot.qeDT(dtout)。由于后者已编写为在ctout组件上调用plot(),因此最终上述的plot()调用将最终被分派到plot.party()

图 5-1 展示了图形。由于我们现在只是对整体情况进行概述,所以不要试图一眼看懂全部内容。

Image

图 5-1:示例图

决策树确实呈现为流程图。对于给定的Solar.RWind等值,我们应该预测臭氧的什么值?图中显示了我们的预测过程。

现在让我们看看当我们预测一个新的点,例如上面提到的w时会发生什么。我们从根节点开始,节点 1,然后查看Temp。由于w中的温度值为 67,低于 82 度,我们向左走,到节点 2。在那里我们询问Wind是否小于或等于 6.9 英里每小时。它的值是 8.8,所以我们向右走,到节点 4,在那里我们被要求将Temp与 77 进行比较。再次地,w中的值是 67,所以我们向左走,到节点 5。

我们之前看到的预测值是 18.47917。那这个值是如何从节点 5 得出的呢?

我们的预测值将是节点 5 中所有训练集数据点的均值Ydtout中有关于哪些数据点属于该节点的信息。具体来说,qeDT()输出的termNodeMembers组件是一个 R 列表,每个树节点对应一个元素。为了更好地理解该函数的工作原理,让我们“手动”检查一下节点 5:

> dtout$termNodeMembers[['5']]
 [1]   1   2   3   4   5   6   7   8  10  11  12
[12]  13  14  15  16  17  18  19  20  21  22  23
[23]  26  31  32  33  34  35  45  74  76  79  80
[34]  96  97  99 101 102 104 105 106 108 109 111
[45] 112 114 115 116

我们看到airq的 48 个数据点最终落入了节点 5,具体包括airq[1,]airq[2,],依此类推。决策树(DT)随后计算这些点的均值Y

> node5indices <- dtout$termNodeMembers[['5']]
> mean(airq$Ozone[node5indices])
[1] 18.47917

这与我们通过predict()获得的值一致。

5.3 示例:纽约市出租车数据

让我们在一个更大的数据集上尝试这一切。幸运的是,对于我们这些数据分析师来说,纽约市出租车与豪华轿车委员会提供了大量的出租车行程数据。^(1) 其中一小部分数据作为yell10k存在于regtools包中。

该数据集由 2019 年 1 月的数据中的 10,000 个随机记录组成。它仅保留了原始的 18 个特征中的 7 个,并进行了部分日期转换。

如果出租车运营商有一个应用程序来预测行程时间,那将会很好,因为许多乘客可能希望了解这个信息。这也将是我们在这里的目标。

这是数据:

> data(yell10k)
> head(yell10k)
        passenger_count trip_distance PULocationID DOLocationID PUweekday
2969561               1          1.37          236           43         1
7301968               2          0.71          238          238         4
3556729               1          2.80          100          263         3
7309631               2          2.62          161          249         4
3893911               1          1.20          236          163         5
4108506               5          2.40          161          164         5
        tripTime
2969561      598
7301968      224
3556729      761
7309631      888
3893911      648
4108506      977

在这里,PUDO分别代表“接客”和“送客”。行程距离以英里为单位,行程时间以秒为单位。

另一方面,单单行程距离不足够;接客和送客的地点也很重要,因为城市的某些区域可能比其他地方更难行驶。原始数据中还包含了时间信息,这在这里虽然没有使用,但实际上是非常重要的。

5.3.1 陷阱:因子水平组合过多

现在,注意一下位置 ID:

> length(unique(yell10k$PULocationID))
[1] 143
> length(unique(yell10k$DOLocationID))
[1] 205
> 143*205
[1] 29315

潜在的接客和送客组合有 29,315 种!由于我们只有n = 10000 个数据点,我们面临着严重过拟合的风险。而且,至少有这么多潜在的树节点会影响训练集的运行时间。

此外,当我使用partykit包而不是party包时,我遇到了一个错误信息:“水平过多”。文档建议在这种情况下使用party,但即使那样,我们也很可能会遇到大数据集的麻烦。

这表明可能需要使用合并或嵌入(参见第 4.3.1 节)。例如,我们可能希望形成连续位置的组,或者我们可以尝试嵌入——即用纬度和经度替代位置 ID。但我们先看看不采取这些措施时会发生什么。

5.3.2 基于树的分析

如前所述,这个数据集可能存在一些挑战,特别是在可能出现过拟合问题的情况下。我们试试看:

> dtout <- qeDT(yell10k,'tripTime')
holdout set has  1000 rows
> dtout$testAccAA
[1] 211.7106
> dtout$baseAcc
[1] 433.8724

不错;通过使用这里的特征,我们将 MAPE 减少了一半。同样,通过使用非默认的超参数组合,以及添加原始数据集中yell10k中没有的一些特征,我们可能会做得更好。

即使是我们在这里使用的缩小版数据集,其复杂度也远高于绘制其树形图所能承载的水平。我们仍然可以以打印形式显示它:

> dtout

     Conditional inference tree with 40 terminal nodes

Response:  tripTime
Inputs:  passenger_count, trip_distance, PULocationID, DOLocationID, PUweekday
Number of observations:  9000

1) trip_distance <= 3.08; criterion = 1, statistic = 5713.065
  2) trip_distance <= 1.39; criterion = 1, statistic = 3517.53
    3) trip_distance <= 0.79; criterion = 1, statistic = 1216.608
      4) trip_distance <= 0.49; criterion = 1, statistic = 404.09
        5) trip_distance <= 0.16; criterion = 1, statistic = 138.913
          6)*  weights = 107
        5) trip_distance > 0.16
          7) trip_distance <= 0.27; criterion = 0.998, statistic = 83.348
            8)*  weights = 51
          7) trip_distance > 0.27
            9) DOLocationID == {13, 68, 75, 87, 100, 107, 125, 137, 142, 148,
               161, 162, 209, 230, 233, 234, 237, 264}; criterion = 0.982,
               statistic = 83.545
              10)*  weights = 138
...

尽管即使是打印形式的显示也相当复杂,这里仅强行列出一部分,且其中包含一些我们尚未描述的数量,但仍然可以获得一些有趣的信息。首先,我们看到共有 40 个终端节点,而在我们之前的例子中只有 5 个,这反映了该数据集的复杂性更高。(整个树中有 79 个节点,输入dtout$nNodes可以看到。)

其次,我们看到部分是如何实现该减少的:决策树能够形成自己的接送位置分组,比如在节点 9:

            9) DOLocationID == {13, 68, 75, 87, 100, 107, 125, 137, 142, 148,
               161, 162, 209, 230, 233, 234, 237, 264}; criterion = 0.982,
               statistic = 83.545
              10)*  weights = 138

如果DOLocationID是 13、68 等之一,我们向左走,否则向右走。这解决了我们在第 5.3.1 节中的问题。决策树(DT)为我们分组了位置!难怪决策树这么受欢迎!

注意

如果我们在 R 交互模式下输入一个表达式,R 会打印出该表达式。这里我们输入了dtout,所以它等同于输入print(dtout)。但是print()是另一个 R 的通用函数,因此我们将有一个类似于plot()上面的调用链,最后调用print.party(dtout$ctout)

在决策树分析中,有一件事值得检查,那就是各个叶子节点中的数据点数量。假设某些节点的数据点很少,这类似于在 k-NN 邻域中点数过少。就像在后者的情况下我们可以尝试不同的k值一样,在这里我们可能需要调整一些决策树超参数。

我们将在第 5.6 节讨论超参数,但现在先来看一下如何检查数据点较少的叶子节点:

> dtout$termNodeCounts
  6   8  10  12  13  16  17  19  20  24  25  27  28  31  32  34  36  37  42  43
107  51 138  71 148 262 245 190 423 492 216  18 361 370 309 266 304  17 496 101
 44  47  48  49  52  54  55  57  58  63  64  66  67  69  71  72  74  76  78  79
317 110 395 286 127 145 287 552 177 266 240  95 245 211 378 105 165 233  10  71

有一些较小的节点,特别是节点 78,只有 10 个数据点。这可能是调整超参数的一个原因。

5.4 示例:森林覆盖数据

另一个 UCI 数据集,Covertype,旨在“[预测]森林覆盖类型,仅基于制图变量。”^(2) 其目的是通过遥感来确定难以到达的地区存在的草种类。数据集中有 581,012 个数据点,包含 54 个特征,例如海拔、午间山坡阴影和到最近地表水的距离。共有七种不同的覆盖类型,存储在第 55 列。

这个示例有很多值得注意的地方。在这里,我们将看到决策树在多类分类问题中的应用,且其规模大于我们迄今为止所见。而且,章节讲解树结构时,数据来自森林难道不更好?

输入数据,例如,使用data.table::fread()来提高速度:

> library(data.table)
> cvr <- fread('covtype.data.gz')
> cvr[1,]
     V1 V2 V3  V4 V5  V6  V7  V8  V9  V10 V11 V12
1: 2596 51  3 258  0 510 221 232 148 6279   1   0
   V13 V14 V15 V16 V17 V18 V19 V20 V21 V22 V23
1:   0   0   0   0   0   0   0   0   0   0   0
   V24 V25 V26 V27 V28 V29 V30 V31 V32 V33 V34
1:   0   0   0   0   0   0   0   0   0   0   0
   V35 V36 V37 V38 V39 V40 V41 V42 V43 V44 V45
1:   0   0   0   0   0   0   0   0   1   0   0
   V46 V47 V48 V49 V50 V51 V52 V53 V54 V55
1:   0   0   0   0   0   0   0   0   0   5

类别(位于V55列)作为整数读取,而qe*系列函数需要在分类问题中将Y转换为 R 的因子。我们本可以使用fread()colClasses参数,但直接修正它会更方便:

> cvr$V55 <- as.factor(cvr$V55)

有七个类别,但有些类别比其他类别要常见得多:

> table(cvr$V55)

     1      2      3      4      5      6      7
211840 283301  35754   2747   9493  17367  20510

覆盖类型 1 和 2 是最常见的。

由于np都很大,我们可以选择运行一个 50,000 条记录的随机子集,便于更好地说明这些概念。这样的做法在数据分析中也很常见:先在数据子集上进行初步分析,便于操作,然后再对完整数据进行更为深入的分析。

> cvr50k <- cvr[sample(1:nrow(cvr),50000),]
> dto <- qeDT(cvr50k,'V55')
holdout set has  1000 rows
> dto$testAcc
[1] 0.249
> dto$baseAcc
[1] 0.5125714

再次强调,使用特征(25%的错误率)比不使用特征(51%的错误率)要好得多。

我们也可以查看混淆矩阵:

> dto$confusion
      pred
actual   1   2   3   4   5   6   7
     1 241 103   0   0   0   0   3
     2  65 402   3   0   3   8   1
     3   0   4  54   0   1  12   0
     4   0   0   3   3   0   0   0
     5   1  10   0   0   8   1   0
     6   0   3  18   0   0  20   0
     7  10   0   0   0   0   0  23

类别 1 经常被误预测为类别 2,反之亦然。

在这里,随着样本量n和特征数量p的增大,可能会生成一个非常大的树。实际上,它比我们之前的示例要大得多:

> dto$nNodes
[1] 1065
> dto$nTermNodes
[1] 533

这棵树有 1,000 个节点,其中大约一半是终端节点!

5.5 决策树超参数:如何进行分裂?

不同的决策树包在节点分裂的细节上有所不同。在大多数情况下,这个过程相当复杂,因此超出了本书的讨论范围。party包中的分裂过程也不例外,但我们至少需要对这个过程有一个大致的了解。我们将重点介绍party包中一个主要的分裂标准,即p 值

再次查看图 5-1。椭圆形区域显示用于分裂的特征是Wind,p 值为 0.002,分裂点为 6.9。但最初,当树结构建立时,该椭圆区域是空的,底部没有任何线条延伸。那么,这个节点是如何从图中看到的状态构建出来的呢?

节点 2 从节点 1 的左分支继承了数据点。然后执行了以下算法:

pv = null vector
for feature f in Solar.R, Wind, Temp, Month, Day do:
   for split_point in the values of f do:
      compute a p-value pval
      append pval to pv

我们在上述伪代码的输出上做以下操作:

  • 如果最小的 p 值低于用户指定的标准,则使用产生最小 p 值的特征和分裂点来分裂节点(在本例中是Wind和 6.9)。

  • 另一方面,如果最小的 p 值没有小于用户指定的标准,就不要拆分节点。

例如,我们看到,对于节点 2 及潜在的(后来实际的)拆分特征Wind,有很多候选的潜在拆分点:

> sort(airq$Wind)
  [1]  2.3  2.8  3.4  4.0  4.1  4.6  4.6  4.6  5.1  5.1  5.1  5.7  5.7  6.3  6.3
 [16]  6.3  6.3  6.3  6.3  6.9  6.9  6.9  6.9  6.9  6.9  6.9  6.9  7.4  7.4  7.4
 [31]  7.4  7.4  7.4  7.4  7.4  7.4  7.4  8.0  8.0  8.0  8.0  8.0  8.0  8.0  8.6
 [46]  8.6  8.6  9.2  9.2  9.2  9.2  9.2  9.2  9.7  9.7  9.7  9.7  9.7  9.7  9.7
 [61]  9.7  9.7 10.3 10.3 10.3 10.3 10.3 10.3 10.3 10.3 10.3 10.3 10.9 10.9 10.9
 [76] 10.9 10.9 10.9 11.5 11.5 11.5 11.5 11.5 11.5 11.5 11.5 11.5 11.5 12.0 12.0
 [91] 12.0 12.0 12.6 12.6 13.2 13.8 13.8 13.8 13.8 14.3 14.3 14.3 14.3 14.9 14.9
[106] 14.9 14.9 14.9 15.5 15.5 15.5 16.6 16.6 18.4 20.1 20.7

值 2.8、3.4、...、20.1 中的任何一个都可以使用。算法会考虑每一个。

直观上,我们希望拆分能产生两个大致平衡的子集,比如在 9.7 处分裂。但更紧迫的要求是,两个子集在Y的均值上有很大的差异。如果两个候选子集的Y均值相差不大,则认为该节点是同质的,不会拆分——至少对于该特征而言。

那么,什么才算“相差很多”呢?这是由一个正式的统计显著性测试来决定的。本书不假设读者有统计学背景,对于我们的目的,我们只说明一个测试的结果是通过一个叫做 p 值的数字来总结的。

近年来,测试方法受到许多批评,我认为这有其合理之处(请参见文件NoPVals.md,位于regtools中)。然而,在这里进行节点拆分时,p 值阈值只是另一个超参数,称为alpha,在qeDT()中默认值为 0.05。

如果某个候选特征和候选拆分点的 p 值小于alpha,则认为该节点值得拆分。所选择的特征和拆分点是具有最小 p 值的一对。我们在图 5-1 中看到,最小的 p 值恰好是 0.002,它与Wind特征和拆分点 6.9 相关联。由于 0.002 < 0.05,该节点相应地被拆分了。

如果没有任何分裂点满足上述标准,则该节点不进行拆分。在节点 3 中发生了这种情况,因此它成为了终端节点。

5.6 qeDT()函数中的超参数

如前所述,决策树(DT)可以被看作是 k-NN 思想的延伸。每个叶节点形成一种邻域,其中的数据点在某些特征上具有相似的值。回想一下第三章,较小的邻域会导致预测的Y值具有更大的方差——因为样本太小,无法有效工作——而较大的邻域则可能存在偏差问题(即,同一邻域中的点可能彼此差异较大,从而不能代表整体)。

在决策树的背景下,我们应该查看叶节点,考虑偏差-方差权衡。如果有太多小的终端节点,我们就会面临方差问题,而过多大的终端节点则可能意味着偏差问题。

这里是超参数发挥作用的地方。它们以各种方式控制树的配置,我们可以使用交叉验证来选择具有最佳预测能力的树配置。

一般的调用形式是:

> args(qeDT)
function (data, yName, alpha = 0.05, minsplit = 20, minbucket = 7,
    maxdepth = 0, mtry = 0, holdout = floor(min(1000, 0.1 * nrow(data))))

datayNameholdout参数在所有qe*系列函数中都是通用的。其余的alphaminsplitminbucketmaxdepthmtry都涉及分裂标准。以下是它们的作用:

alpha 如上所述。

minsplit 在这里我们可以指定任何节点的最小大小。默认值为 20,意味着我们不会允许任何节点分裂后小于 20 个数据点。

minbucket 类似于minsplit,但专门针对终端节点。

maxdepth 树的最大层数或行数。在图 5-1 中,我们有 4 个层次,根节点在第 1 层,叶节点在第 4 层。

mtry 如果这个值非零,它表示每个节点尝试的特征数量;见下文。

如果mtry非零,我们的分裂算法会稍作调整:

pv = null vector
randomly choose mtry features among Solar.R, Wind, Temp, Month, Day
for f in chosen feature set do
   for split_point in the values of f do:
      compute a p-value pval
      append pval to pv

这为树的构建过程增加了一些随机性,是朝着机器学习方法随机森林的一步。我们将在下一章看到这为什么可能有用,但对于严格的决策树方法,通常不使用它。

从偏差-方差权衡的角度考虑上述每个超参数。假设我们希望使叶节点更小。其他条件相同的情况下,我们可以通过增大alpha、减小minsplit、减小minbucket、增大maxdepth和增大mtry(或设为 0)来实现这一目标。

例如,增大alpha时,更多的 p 值会低于这个高阈值,因此节点更有可能分裂。随着树的深入,剩余的数据点会减少,因此如果我们鼓励分裂,到了一个无法再分裂的节点时,它将不会有很多剩余的数据点。

这些超参数并非独立工作,因此设置过多的超参数可能会变得冗余。

5.7 结论

决策树在机器学习中起着基础性作用,我们将在后续的袋装法和提升法材料中再次遇到它们。与任何机器学习算法一样,我们必须处理各种超参数,这是本书稍后将深入讨论的另一个主题。

第六章:微调决策树**

AdaBoost 是世界上最好的现成分类器。

—CART 联合发明人 Leo Breiman,1996 年

XGBoost 是许多机器学习竞赛获胜团队首选的算法。

—维基百科条目,2022 年

Image

在这里,我们讨论机器学习中的两种通用技术,baggingboosting,并将它们应用于扩展决策树分析。这些扩展,随机森林基于树的梯度提升,被广泛使用——事实上,比起单一的树方法,它们的使用更加普遍。

6.1 偏差与方差,Bagging 和 Boosting

失去一颗钉子,鞋子也丢了;

失去一只鞋子,马也丢了;

失去一匹马,人也丢了。

—古老的谚语

我们必须始终记住,我们处理的是样本数据。有时,“被采样的总体”大多是概念性的;例如,在第 5.3 节的出租车数据中,我们将数据视为来自所有天数的乘客数据样本,包括过去、现在和未来。但无论如何,都存在采样变异。

在自行车租赁数据中,假设数据收集周期多延续一天会怎么样?即使是这个微小的变化,也可能会影响树的顶部的精确分裂,即节点 1. 这个影响可能会继续影响节点 2 和 3 的分裂(或可能的非分裂),以此类推,直到影响到结果树的最底层。需要注意的是,不仅节点中的分裂点可能发生变化,节点的成员资格也可能发生变化。曾经在节点 2 的数据点现在可能会被归到节点 3. 换句话说:

决策树对输入的微小变化非常敏感。这意味着它们对采样变异非常敏感——也就是说,决策树具有较高的方差

回想一下,分裂一个节点可以减少偏差,通常,减少偏差也会增加方差。但正如上面所说,方差在决策树设置中可能特别成问题。

在这一章中,我们讨论处理这一问题的两种主要方法,bagging/随机森林boosting。这两种方法的观点是:“方差太大?好吧,那意味着样本量太小,那么我们就生成更多的树!”但怎么做呢?

6.2 Bagging:通过重采样生成新树

bagging 这个术语指的是机器学习中一种现代统计学工具——自助法(bootstrap)的应用。这包括从我们的数据中随机抽取多个子样本,应用我们给定的估计器到每个子样本,然后对结果进行平均(或以其他方式组合)。在这里,我们将自助法应用于决策树。

从我们原始数据开始,再次考虑它是某一总体的样本,我们将从原始数据集中生成 s 个新样本。我们通过从 n 个数据点中随机抽取 m 个样本( 放回)来生成新样本。(我们可能会得到一些重复的样本。)我们将为每个新样本拟合一棵树,从而实现上述目标,即生成更多的树,并以稍后展示的方式合并结果。这里的 sm 是——你猜对了——超参数。

6.2.1 随机森林

假设我们有一个新的案例需要预测。然后,我们将通过对每棵树形成一个预测值,并将所有这些预测值结合起来,按照如下方式聚合 s 棵树,以形成我们的最终预测:

  • 在数值型 Y 设置中,合并的方式是对所有预测值进行平均。例如,在出租车数据中(见第 5.3 节),每棵树会给出一个预测的行程时间,而我们的最终预测行程时间将是所有这些单独预测值的平均值。

  • 在分类问题中,例如我们在第 2.3 节中讨论的脊椎骨示例,我们可以通过使用投票过程来进行合并。对于每棵树,我们将找到预测的类别:NO、DH 或 SL,然后查看哪些类别在不同的树之间获得了最多的“投票”。这将是我们的预测类别。或者,我们可以找到每棵树对于这个新案例的估计类别概率,然后取平均概率。我们的预测类别将是概率最大的那个类别。

因此,我们进行自助抽样然后聚合,简称bagging。它也通常被称为随机森林,这是 Leo Breiman 的具体实现。(这一思路最早的提出者似乎是 Tin Kam Ho,她称这种方法为随机决策森林。)该方法限制了在任何给定节点进行分裂时考虑的特征数量,每一步都有一个不同的候选特征集。

为什么这种策略,即每次使用不同的候选特征集,会奏效呢?普通的 bagging 可能导致树之间有较高的相关性,因为它每次都倾向于选择相同的特征。可以证明,正相关的数值的平均值比独立数值的平均值具有更高的方差。因此,在每一步限制候选特征集的方法有助于降低方差。

6.2.2 qeRF()函数

qe*- 系列函数实际上包括了多个用于随机森林的函数。对于给定的应用,某些函数可能比其他函数更准确或更快速,但它们都使用前面描述的通用随机森林范式。我们将在这里使用 qeRF()

回想一下,qe* 函数都有以下参数:

data 一个包含我们训练数据的数据框。

yName data中包含Y(即需要预测的结果变量)的列名。用户通过将该列设置为数字型或 R 因子,分别区分数值型Y和分类设置。

holdout 可选的 holdout 集的大小。

特定应用的参数 例如,在qeKNN()的情况下,k是最近邻的数量。

每个qe*函数都是标准 R 机器学习包中函数的封装接口。在随机森林的情况下,qeRF()randomForest包的一个封装。调用形式如下:

qeRF(data, yName, nTree = 500, minNodeSize = 10,
   holdout = floor(min(1000,0.1 * nrow(data))))

特定应用的参数包括nTree,即生成的自举树的数量,以及minNodeSize,它类似于ctree()中的minsplit

6.2.3 示例:脊椎数据

让我们再次查看第 2.3 节中的脊椎数据集,现在应用随机森林而不是 k-NN。我们将预测与之前示例中相同的假设新案例:

# fit RF model
> rfout <- qeRF(vert,'V7',holdout=NULL)
# new case to predict
> z <- vert[1,-7]
> z$V2 <- 18
# predict
> predict(rfout,z)
$predClasses
[1] "DH"

$probs
     DH    NO   SL
2 0.532 0.378 0.09
attr(,"class")
[1] "matrix" "array"  "votes"

使用 k-NN 时,我们预测了相同的类别 DH,但具有略微不同的类别概率:

> predict(kout,z)
$predClasses
[1] "DH"

$probs
      DH  NO  SL
[1,] 0.6 0.2 0.2

两组概率之间的差异既来自于我们使用了两种不同的机器学习算法,也来自于该数据集中的小n(310),这导致了较大的样本变异性。

在这里,我们使用了nTreeminNodeSize的默认值。我们可以探索这些超参数的其他组合,然后比较随机森林和 k-NN 在该数据集上的表现。

6.2.4 示例:遥感土壤分析

在这里,我们将分析 Kaggle 上的非洲土壤属性数据集。^(1) 来自数据网站:

利用红外光谱法对土壤样本进行快速、低成本分析,土壤样本的地理参考,以及地球遥感数据的更大可用性,为预测未采样地点的土壤功能特性提供了新机遇……土壤功能特性的数字化绘图,特别是在数据稀缺的地区,如非洲,对于规划可持续农业集约化和自然资源管理非常重要。

我们希望预测不同的土壤属性,而不是直接测试土壤。

这个数据集的一个重要特性是我们以前未曾遇到过的,即它满足p > n(即列数大于行数)。原始的第一列,一个 ID 变量,已经被移除。

> dim(afrsoil)
[1] 1157 3599

传统上,统计学领域对这种设置持谨慎态度,因为线性模型(第八章)在此类设置下无法工作。必须首先进行降维。基于树的方法作为其操作的一个整体部分已实现此功能,因此我们可以尝试使用qeRF()

这是各列的名称:

> names(afrsoil)
...
[3547] "m659.543" "m657.615" "m655.686" "m653.758" "m651.829" "m649.901"
[3553] "m647.972" "m646.044" "m644.115" "m642.187" "m640.258" "m638.33"
[3559] "m636.401" "m634.473" "m632.544" "m630.616" "m628.687" "m626.759"
[3565] "m624.83"  "m622.902" "m620.973" "m619.045" "m617.116" "m615.188"
[3571] "m613.259" "m611.331" "m609.402" "m607.474" "m605.545" "m603.617"
[3577] "m601.688" "m599.76"  "BSAN"     "BSAS"     "BSAV"     "CTI"
[3583] "ELEV"     "EVI"      "LSTD"     "LSTN"     "REF1"     "REF2"
[3589] "REF3"     "REF7"     "RELI"     "TMAP"     "TMFI"     "Depth"
[3595] "Ca"       "P"        "pH"       "SOC"      "Sand"

第 1 列到第 3594 列是X变量,具有难以理解的代码名称。其余的列是Y,其中一些具有更容易猜测的名称。我们将预测 pH,即土壤酸度。

这种设置被认为很难处理。由于有这么多特征,存在严重的过拟合潜力,因为其中一个或多个特征可能由于 p-hacking(第 1.13 节)而偶然被认为是强预测因子。让我们看看qeRF()在这里的表现如何。

> set.seed(9999)
> rfo <- qeRF(afrsoil[,c(1:3578,3597)],'pH',holdout=500)
> rfo$testAcc
[1] 0.3894484
> rfo$baseAcc
[1] 0.6858574

特征的使用将 MAPE 减少了近一半。请注意这里使用的 pH 尺度的范围:

> range(afrsoil$pH)
[1] -1.886946  3.416117

我们现在准备进行预测,比如在训练数据中的第 88 行的假设新案例:

> predict(rfo,afrsoil[88,1:3594])
       88
0.6068828

我们预测的 pH 值大约为 0.61。

6.3 Boosting:反复调整一棵树

想象一个分类问题,只有两个类别,即Y = 1 或 0,且只有一个特征,X,比如年龄。我们拟合一个只有一层的树。假设我们的规则是,如果X > 12.5,则猜测Y = 1;如果X ≤ 12.5,则猜测Y = 0。Boosting将涉及探索将 12.5 阈值的小变化对我们整体正确分类率的影响。

考虑一个数据点,其中X = 5.2。在原始分析中,我们会猜测Y为 0。而且,关键在于,如果我们将阈值调整到 11.9,我们仍然会猜测Y = 0。但这个调整可能会将一些原本分类错误的接近 12.5 的数据点修正过来。如果更多曾经被错误分类的点被正确分类,那么这就是一个成功。

所以,Boosting 的思想是调整原始树,从而形成一棵新树,然后再调整这棵新树,形成第二棵新树,依此类推。生成s棵树(s是一个超参数)后,我们通过将新案例输入到所有这些树中并以某种方式结合生成的预测值来进行预测。

6.3.1 实现:AdaBoost

Boosting 的第一个提议是AdaBoost。调整过程涉及为训练集中的每个数据点分配权重,每棵树都会更新这些权重。每次形成一棵新树时,我们都会根据最新的权重集拟合一棵树,并用每棵新树更新这些权重。

在数值型Y的情况下,为了预测具有某个X值的新案例,我们将该值输入到所有树中,得到s个预测值。在数值型Y设置中,我们的最终预测值是各个预测值的加权平均。在分类设置中,我们将对Y = 1 的估计概率进行加权平均,以获得最终的概率估计,或者使用加权投票法。

为了使这个概念更具体,下面是如何使用ctree()实现该过程的概述。它依赖于ctree()中的一个参数,名为weights,它是一个非负数向量,为每个数据点分配一个权重。假设我们的响应变量名为y,特征为x。用dx表示数据框d中关于x的部分。

在下面的伪代码中,我们将维护两个权重向量:

  1. wts将存储训练数据中各行的当前权重。回想一下,随着提升过程的进行,我们会根据某些行对分类错误的影响程度,对这些行赋予更高的权重。

  2. alpha将存储我们各种树的当前权重。回想一下,当我们进行预测时,我们会对某些树赋予比其他树更多的权重。

这里是算法的大纲:

ctboost <- function(d,s) {
   # uniform weights to begin
   wts <- rep(1/n,n)
   trees <- list()
   alpha <- vector(length=s)  # alpha[i] = coefficient for tree i
   for(treeNum in 1:s) {
      trees[[i]] <- ctree(y ~ x,data=d,weights=wts)
      preds <- predict(trees[[i]],dx)
      # update wts, placing larger weight on data points on which
      # we had the largest errors (regression case) or which we
      # misclassified (classification case)
      wts <- (computation not shown)
      # find latest tree weight
      alpha[i] <- (computation not shown)
   }
   l <- list(trees=trees,treeWts=alpha)
   class(l) <- 'ctboost'
   return(l)
}

为了预测新的案例,newx

predict.ctboost <- function(ctbObject,newx)
{
   trees <- ctbObject$trees
   alpha <- ctbObject$alpha
   pred <- 0.0
   for (i in 1:s) {
      pred <- pred + alpha[i] * predict(trees[[i]],newx)
   }
   return(pred)
}

由于本书旨在避免过于数学化,我们省略了wtsalpha的公式。然而需要指出的是,alpha是一个递增序列,因此在预测新案例时,后面的树会发挥更大的作用。

qeML包有一个用于 AdaBoost 的函数,qeAdaBoost()。但它仅适用于分类任务,因此我们直接进入下一个梯度提升的形式。

6.3.2 梯度提升

在统计学/机器学习中,有一个概念叫做残差——即预测值与实际值之间的差异。梯度提升通过将树拟合到残差上来工作。给定我们的数据集,过程的大致描述如下:

  1. 从一棵初始树开始。将CurrentTree设置为它。

  2. 对于我们的每一个数据点,计算CurrentTree的残差。

  3. 将一棵树拟合到残差上——即将我们的残差作为“数据”并拟合一棵树 T。将CurrentTree = T

  4. 跳到第 2 步。

这些步骤会根据用户指定的树的数量进行迭代。然后,为了预测新的案例,我们将其输入到所有树中。预测值只是各个树预测值的总和。

在任何给定的步骤中,我们都在说:“很好,我们已经有了一定的预测能力,那么让我们处理剩下的部分——也就是我们当前的误差。”因此,我们对任何新案例的预测值是每棵树为该案例预测值的总和。

6.3.2.1 qeGBoost()函数

qe*函数用于梯度提升是qeGBoost(),这是对同名包中gbm()的封装。它的调用形式是:

qeGBoost(data, yName, nTree = 100, minNodeSize = 10, learnRate = 0.1,
    holdout = floor(min(1000, 0.1 * nrow(data))))

这类似于qeRF(),但多了一个新的参数,即学习率。这个学习率是机器学习中常见的概念,稍后会进行解释。

注意

有很多梯度提升的包可用于 R 语言。我们选择了gbm* 包,因为它简单易用。就像之前在随机森林中一样,其他包在某些数据集上可能更快或更准确,特别是qeXGBoost。在这里,qeGBoost()* 坚持了qe**系列的“快速简便”理念,但鼓励读者将其他包作为高级话题进行探索。

6.3.3 示例:呼叫网络监测

我们首先将提升算法应用于一个名为“移动网络监测与优化的呼叫测试测量”的数据集,^(2),该数据集用于评估移动呼叫的服务质量。目标是预测质量评分。

6.3.3.1 数据

这里是数据的介绍:

> ds <- read.csv('dataset.csv',stringsAsFactors=TRUE)
> names(ds)
[1] "Date.Of.Test"             "Signal..dBm."
[3] "Speed..m.s."              "Distance.from.site..m."
[5] "Call.Test.Duration..s."   "Call.Test.Result"
[7] "Call.Test.Technology"     "Call.Test.Setup.Time..s."
[9] "MOS"
> ds <- ds[,-1]
> head(ds)
  Signal..dBm. Speed..m.s. Distance.from.site..m. Call.Test.Duration..s.
1          -61       68.80                1048.60                     90
2          -61       68.77                1855.54                     90
3          -71       69.17                1685.62                     90
4          -65       69.28                1770.92                     90
5         -103        0.82                 256.07                     60
6          -61       68.86                 452.50                     90
  Call.Test.Result Call.Test.Technology Call.Test.Setup.Time..s. MOS
1          SUCCESS                 UMTS                     0.56 2.1
2          SUCCESS                 UMTS                     0.45 3.2
3          SUCCESS                 UMTS                     0.51 2.1
4          SUCCESS                 UMTS                     0.00 1.0
5          SUCCESS                 UMTS                     3.35 3.6
6          SUCCESS                 UMTS                     0.00 1.0
...

这里的YMOS,即服务质量。

它有多大?

> dim(ds)
[1] 105828      8

现在,让我们来拟合模型。

6.3.3.2 拟合模型

在超过 100,000 个数据点和仅 8 个特征的情况下,过拟合应该不是该数据集的问题。它轻松满足我们粗略的经验法则,p < Images (第 3.1.3 节)。因此,我们不需要使用持出集。不过,算法中仍然存在一些随机性,因此为了保证一致性,我们设置随机种子。

> set.seed(9999)
> gbout <- qeGBoost(ds,'MOS',nTree=750,holdout=NULL)

nTree的默认值仅为 100,但我们尝试了一个更大的数字,750,这一点将在下面说明。

让我们做个预测。假设我们有一个像ds[3,]这样的案例,但距离为 1500,持续时间为 62:

> ds3 <- ds[3,-8]
> ds3[,3] <- 1500
> ds3[,4] <- 62
> predict(gbout,ds3)
[1] 2.462538
6.3.3.3 超参数:树木数量

但是,我们是否应该使用这么多的树呢?毕竟,750 棵可能会导致过拟合。也许后面的树是在进行“噪音拟合”。该包有几种方式来解决这个问题,其中一种是使用辅助函数gbm.perf()。将其应用于gbm()的输出,它可以估算出最佳的树木数量。

如前所述,qeGBoost()调用gbm()并将后者的输出放入其输出的gbmOuts组件中。因此,我们能够调用gbm.perf()

> gbm.perf(gbout$gbmOuts)

请参见图 6-1 中的输出图。虚线垂直线表示估算的“甜点”——即最佳树木数量,在本例中为 382。(该值也会打印到 R 控制台。)

Image

图 6-1:来自 gbm.perf 的输出

但我们无需重新拟合模型。我们可以在预测中改变树木的数量:

> predict(gbout,ds3,newNTree=382)
[1] 2.45214

由于我们没有形成持出集,我们需要手动计算 MAPE:

> mean(abs(preds - ds[,8]))
[1] 0.6142699

gbm包的其他特性可以参考其文档。

6.3.4 示例:椎骨数据

Boosting 可以用于分类问题以及数值-Y类型的情况。(而且它在分类方面的使用可能更为普遍。)这里展示了qeGBoost()应用于椎骨数据(请参见第 2.3 节)。

> set.seed(9999)
> gbout <- qeGBoost(vert,'V7')

假设我们要预测一个像训练集第 12 行这样的新案例:

> predict(gbout,vert[12,-7])
$predClasses
[1] "DH"

$probs
            DH        NO          SL
[1,] 0.6283904 0.3694108 0.002198735

attr(,"class")
[1] "qeGBoost"

我们预测 DH,估算概率大约为 0.63(不幸的是,gbm.perf()在多分类情况下不可用)。

6.3.5 Boosting 中的偏差与方差

Boosting 是对树进行“调整”,有可能使其更稳定,特别是因为我们在平均多个树,从而平滑掉“缺一根钉子……问题”。因此,它可能会降低方差。通过对树进行小的调整,我们有可能开发出更详细的分析,从而减少偏差。

但这一切只是“潜在的”真实。虽然调整过程有一定的理论基础,但它仍然可能会把我们引入误区,实际上增加偏差,并且可能也会增加方差。如果超参数s设置得过大,导致树木过多,我们可能会发生过拟合。

6.3.6 计算速度

提升算法可能会占用大量的 CPU 周期,因此我们可能需要一些方法来加速。n.cores参数在gbm()中尝试将计算任务分配到机器的不同核心。如果你有一台四核系统,可以尝试将这个参数设置为 4 甚至 8(然后直接调用gbm(),而不是通过qeGBoost())。

6.3.7 进一步的超参数

提升算法通常有许多超参数。我们之前提到过nTree(在gbm()中为n.trees),它是生成的树的数量。

另一个超参数是minNodeSize(在gbm()中为n.minobsinnode),它是我们愿意在一个树节点中拥有的最小数据点数量。如我们在第五章中看到的,减少此值会降低偏差但增加方差。

shrinkage超参数在一般机器学习环境中非常重要,因此我们将在下一小节中单独讨论它。

6.3.8 学习率

学习率的概念在机器学习中经常出现。我们将在此处一般性地描述它,然后解释它在梯度提升中的工作原理。我们将在后续关于支持向量机(第十章)和神经网络(第十一章)的材料中再次提到它。

本节包含了一些数学内容,涉及曲线及其切线,这是本书明确不以数学为主的例外。但仍然没有方程式,甚至对于数学有排斥的读者也应该能跟上讨论。

6.3.8.1 一般概念

回想一下,在机器学习方法中,我们通常试图最小化某个损失函数,比如 MAPE 或整体误分类错误 OME。从计算角度来看,这种最小化可能是一个挑战。

考虑在图 6-2 中绘制的函数。它是一个一维变量* x 的函数,而通常我们的 x *是高维的,但这个例子足以说明我们的观点。

Image

图 6-2:一个需要最小化的函数

大约在* x * = 2.2 处有一个全局最小值,这被称为全局最小值。但在* x * = 0.4 处也有一个局部最小值;该术语意味着这个值仅适用于 0.4 附近的点——即“局部”的最小值。我们将全局最小值处的* x 称为 x *[0]。

对于我们这些看图的人来说,* x *[0]的位置是显而易见的,但我们需要我们的软件能够找到它。这可能会成为一个问题。原因如下。

大多数机器学习算法采用迭代方法来寻找所需的最小值点* x [0]。这涉及到一系列对 x [0]的猜测。代码从一个初始猜测 g [0]开始,比如随机选择,然后评估f( g [0])。根据结果,算法然后以某种方式(见下文)更新猜测到 g [1]。接着它评估f( g [1]),生成下一个猜测 g *[2],依此类推。

算法不断生成猜测,直到它们变化不大,比如说,直到|g**[i][+1] − g**[i]| < 0.00000001 为止,步骤i。我们说算法已经收敛到这个点。我们将i的值称为c。然后它报告* x* [0],即全局最小点为最新的猜测,g**[c]

那么,之前提到的“某种方式”又是怎么回事呢?算法如何从当前猜测生成下一个猜测?答案就在于梯度。在我们这里的简单示例中,x是一维的,梯度是给定点上函数的斜率——也就是曲线的切线的斜率。

假设我们的初始猜测是g[0] = 1.1。切线如图 6-3 所示。该线向右上方指,意味着它的斜率是正的,因此它告诉我们,向左走会让我们得到更小的函数值。我们想找到f()最小的点,而切线则说:“哦,你想要比f(1.1)更小的值吗?往左走!”但实际上我们应该向右走,朝着 2.2 走,那是全局最小值所在。

图片

图 6-3:需要最小化的函数,以及切线

所以,读者可以看到,迭代算法充满了危险。更糟糕的是,它还增加了另一个超参数:我们不仅需要决定下一步猜测应该朝哪个方向移动,还需要决定在那个方向上移动多远。学习率就是为了处理后者的问题。

如前所述,我们应该从 1.1 向右移动,而不是向左。函数f(x)在这里欺骗了算法。实际上,在这种情况下,我们的算法可能会收敛到错误的点。或者它甚至可能根本不收敛,反而漫无目的地游荡。

这就是为什么典型的机器学习包允许用户设置学习率。较小的值可能更可取,因为较大的值可能导致我们的猜测来回摆动,总是错失目标。另一方面,如果学习率太小,我们将只会缓慢前进,花费很长时间才能到达目标。或者更糟的是,我们会收敛到局部最小值。

再次强调,我们有一个超参数,它需要处于“恰到好处”的水平——既不能太大,也不能太小——可能需要通过尝试不同的值来进行实验。

6.3.8.2 gbm 中的学习率

这是gbm()中的shrinkage参数,在qeGBoost()中叫做learnRate。假设我们将其设置为 0.2。回想一下 6.3.2 节中描述的梯度提升伪代码。修订后的版本是:

  1. 从一个初始树开始。将CurrentTree设为它。

  2. 对于我们的每个数据点,计算CurrentTree的残差。

  3. 将一棵树拟合到残差上——也就是说,把我们的残差当作“数据”,并在其上拟合一棵树 T。将CurrentTree设为旧的CurrentTree,加上shrinkage * T

  4. 转到第 2 步。

这里,shrinkage * T意味着将树的终端节点中的所有值乘以shrinkage因子。最终,我们仍然将所有树加起来,生成用于预测新案例的“超树”。

再次强调,较小的shrinkage值更为谨慎且处理速度较慢,可能会导致我们需要更多的树木来获得良好的预测能力。但它可能有助于防止过拟合。

6.4 陷阱:没有免费的午餐

没有免费的午餐。

—古老的经济学说法

尽管利奥·布雷曼(Leo Breiman)在谈到 AdaBoost 的巨大价值时有其道理(特别是提到“现成的”,即可以直接使用默认的超参数值),但“没有免费的午餐”这句老话同样适用。像往常一样,应用交叉验证等方法对于开发良好的模型是不可或缺的。

类似的建议涉及另一个著名的布雷曼(Breiman)声明:即使用随机森林是不可能过拟合的。读到这本书此处的读者会立即意识到,布雷曼并不是以某些人所理解的方式表达他的意思。任何机器学习方法都有可能发生过拟合。布雷曼的意思是,不可能将s(树木的数量)设置得太高。但树木本身仍然可能发生过拟合,例如,节点中数据点的最小数量设置得太小,或者包含了过多的特征。

第七章:寻找一个好的超参数组合**

Image

如前几章所讨论的,特别是第 3.2.1 节,大多数分析师处理确定超参数的好值的方法是使用交叉验证。本章中,我们将学习使用qeML函数qeFT(),它极大地简化了这一过程。

7.1 超参数组合

请注意,通常我们讨论的是超参数的集合。举例来说,假设我们希望在 k-NN 设置中使用 PCA。那么我们有两个超参数:邻居数量k和主成分数量m。因此,我们关注的是找到一个好的k值和m值的组合

在许多情况下,超参数的组合不仅仅是成对的。例如,在qeDT()中,有超参数alphaminsplitminbucketmaxdepthmtry。因此,我们希望找到一个由五个超参数组成的良好组合。

许多机器学习方法有更多的超参数。机器学习方法的超参数越多,找到一个良好的组合值就越具挑战性。qeML函数qeFT()旨在帮助进行这一搜索。

注意

在继续之前,请注意,尽管机器学习讨论——以及一些软件文档——通常会提到寻找最佳超参数组合,但这通常是一个幻觉。由于 p-hacking(请参见第 1.13 节),给定训练集的最佳组合可能并不是预测新数据的最佳组合,而后者才是关键。尽管如此,到本章结束时,您将掌握可靠地确定良好组合的工具。

7.2 使用 qeFT()进行网格搜索

许多机器学习包包含用于进行网格搜索的函数,这意味着评估所有可能的超参数组合。然而,组合的数量通常非常庞大,进行完整的网格搜索需要耗费巨大的时间。

一些网格搜索软件库试图通过仅评估看似有前景的组合来解决这个问题,通过一个迭代搜索在网格的狭窄部分进行移动。在每次迭代中,算法会更新对下一步应该尝试什么的猜测。这节省了时间,但也可能走错方向,并且同样容易受到 p-hacking 问题的影响。

qeML函数qeFT()采取了更加谨慎的方法。它生成大量的随机超参数组合,数量由用户指定,并根据相关的损失准则(数值型Y设置使用 MAPE,分类设置使用 OME)评估这些组合。它会列出并显示结果,并包括图形显示选项。最重要且独特的是,它防止 p-hacking,稍后将对此进行解释。

qeFT()函数是一个qe系列的包装器,封装了regtools函数fineTuning()。回想一下,超参数的另一个术语是调优参数。这个函数名是对老式收音机时代的双关语,当时调整到你喜欢的电台的精确频率被称为“微调”。

7.2.1 如何调用 qeFT()

这是基本的qeFT()调用格式:

qeFT(data,yName,qeftn,pars,nCombs=NULL,nTst,nXval,showProgress=TRUE)

让我们来看看这些参数的作用:

data 如同所有qe*系列中的情况,这是我们的输入数据。

yName 如同所有qe*系列中的情况,这是我们Y列的名称。

qeftn ML 函数名称,例如qeKNN

pars R 列表,指定我们希望考虑的qeftn超参数值,例如 k-NN 中的k

nCombs 要评估的超参数随机组合的数量。如果为NULL,则会运行所有可能的组合。

nTst 验证集的大小。

nXval 每个超参数组合运行的验证集数量。

showProgress 对于急躁的人;随着结果的生成,打印出来。

简而言之,我们对nCombs个超参数组合运行指定的 ML 函数qeftn,使用pars中显示的范围。对于每个组合,我们生成nXval个训练/测试数据划分,测试部分的大小为nTst。然后,我们统计所有超参数组合中结果的 MAPE 或 OME 值。

注意qeFT()和第 3.2.2 节中介绍的replicMeans()函数之间的区别。后者处理的是分析人员可能认为单一的验证集不足以准确评估性能的问题。qeFT()函数也做了这件事,通过参数nXval,但它做得更多,自动化了搜索过程。

7.3 示例:程序员与工程师数据

返回到 2000 年美国普查数据中关于程序员和工程师薪资的信息(见第 3.2.3 节),我们来找出合适的超参数以预测工资收入。

> set.seed(9999)
> ftout <- qeFT(data=pef,yName='wageinc',qeftn='qeKNN',
+    pars=list(k=5:25),nTst=1000,nXval=5)
> ftout
$outdf
    k  meanAcc       CI   bonfCI
1   5 22991.82 23402.16 23693.80
2   7 23168.20 24038.72 24657.43
3   9 23302.83 23829.56 24203.92
4  14 23384.68 23857.61 24193.75
5  10 23471.30 24095.60 24539.30
6   6 23635.61 24538.43 25180.09
7  25 23767.42 24651.47 25279.81
8  15 23843.55 24633.13 25194.31
9   8 23921.75 24846.51 25503.77
10 22 23924.46 24271.38 24517.95
11 16 24036.80 24784.32 25315.61
12 20 24120.60 24996.35 25618.78
13 11 24168.83 25639.28 26684.37
14 13 24192.18 24693.87 25050.43
15 12 24256.22 24690.67 24999.46
16 17 24261.34 24934.30 25412.59
17 18 24375.20 24576.41 24719.41
18 23 24376.66 25109.56 25630.46
19 24 24619.82 25249.43 25696.91
20 21 24693.10 25456.93 25999.81
21 19 24842.66 25564.61 26077.72

$nTst
[1] 1000
...

这里唯一的超参数是k。我们已将其范围指定为5:25——也就是说,我们依次尝试k = 5、k = 6,依此类推,一直到k = 25。由于我们没有提供nCombs参数,默认情况下会检查这 21 种组合。

meanAcc是主要结果,它给我们提供了所有交叉验证运行的testAcc均值。我们将在下一节中解释CIbonfCI列。

7.3.1 置信区间

起初看起来k = 5 个邻居是最佳选择。确实,这是我们对当前设置下最佳k的猜测(也就是说,这个n、这个特征集、这个采样的群体等等)。但我们应该小心。以下是原因。

qe*系列函数输出的任何testAcc值都是随机的,因为验证集是随机的。使用qeFT()时,我们会查看多个验证集,并通过平均结果来获得meanAcc。由于所有的验证集都是随机的,因此meanAcc也是随机的。当然,nXval越大,准确度越好。

因此,meanAcc 列只是一个近似值。CI 列的作用是让我们大致了解这个近似值的准确性。具体来说,CI 列中的值是针对任何给定组合的真实平均准确度的 95% 置信区间的右端点。(对于那些懂统计学的人来说,这些是单侧置信区间,形式为 (− ∞, a)。)

在我们的这个例子中,7 个邻居的 meanAcc 值完全落在 5 个邻居的置信区间内。实际上,在使用 5 个或 7 个邻居之间几乎是一个抛硬币的选择,而且它们的 meanAcc 数字本来就没有太大差距。因此,我们不应该把 k = 5 的明显优越性当作字面意义来解读。

换句话说,CI 列“让我们保持诚实”,提醒我们 meanAcc 只是一个近似值,并且为我们提供了一个是否能够区分出那些看似表现最好的组合的提示。

但问题不仅仅是这样。当我们构建大量的置信区间时,由于 p-hacking,其总体有效性会下降(参见第 1.13 节)。在名义上的 95% 水平下单独设定的置信区间会有一个更低的总体置信水平。为了解释这一点,想象一下投掷 10 枚硬币。每枚硬币的正面概率是 0.5,但它们全都朝正面朝上的概率要小得多。同样,如果我们有十个 95% 的置信区间,它们正确的概率远小于 95%。

bonfCI 列对这一点进行了调整,使用了一个叫做Bonferroni−Dunn的置信区间(CIs)。换句话说,该列为我们提供了考虑到我们在查看多个随机置信区间的情况下的置信区间。因此,我们实际上应该更关注该列,而不是CI列。

在我们的这个例子中,调整后的置信区间的界限仅比原始的略大。这意味着在这个简单的例子中,我们不太可能遇到 p-hacking 的问题。但正如在第 1.13 节中讨论的那样,对于有许多超参数的机器学习算法,这可能会成为一个问题。在这种情况下,我们很可能会抓住一个看似“最佳”的组合,实际上它并不具代表性,因此远不如其他一些选择。

我们当然无法知道情况是否如此,但一个好的经验法则是,在几个具有相似 meanAcc 值的组合之间,考虑选择更为中等的组合,而不是极大或极小的超参数值。

例如,考虑神经网络(我们将在第十一章中进一步讨论这些),它们通常有许多超参数,包括:

  • 层数

  • 每层的神经元数量

  • 丢弃率

  • 学习率

  • 动量

  • 初始权重

为了调查各种各样的超参数组合,我们需要将qeFT()中的nCombs参数设置为一个非常大的数字,这样我们就有很大风险找到一个实际上并不有效,但偶然看起来很好的组合。bonfCI列警告我们这一点;它与CI列之间的差异越大,风险越大。

另一方面,我们仅仅是在寻找一个好的超参数组合,而不是绝对最佳的组合。对于任何特定的组合,bonfCI数值为我们提供了一个合理的指示,告诉我们这个组合是否能很好地预测未来的案例。与机器学习中的许多事情一样,如何处理置信区间(CIs)没有固定的魔法公式,但它们可以作为我们思考的非正式辅助手段。

注意

这是关于 Bonferroni−Dunn 区间的一些历史:传统上,只有 Bonferroni 这个名字被使用,以纪念开发了该概率不等式的意大利数学家,这个不等式对于置信区间至关重要。然而,作为 Olive Jean Dunn 教授的前学生,我很高兴发现现在她的名字也常常被包括在内,因为正是她提出了使用这个不等式来构建置信区间。

7.3.2 网格搜索的要点

这里的要点是,我们不能字面理解网格搜索结果的顺序。最初的几个“最佳”结果可能实际上是相似的。而且,表面上看似“最佳”的结果可能并不具有代表性。与其试图优化,不如选择一个“好”的组合,最好不要过于极端。

7.4 示例:程序员和工程师数据

让我们尝试预测职业而不是工资收入。

> ftout <- qeFT(data=pef,yName='occ',qeftn='qeKNN',pars=list(k=1:25),
   nTst=1000,nXval=5)
> ftout
$outdf
    k meanAcc        CI    bonfCI
1   4  0.4656 0.4774134 0.4862065
2   7  0.4688 0.4756510 0.4807504
3   3  0.4726 0.4850419 0.4943029
4   2  0.4746 0.4846176 0.4920740
5   1  0.4766 0.4866176 0.4940740
6   5  0.4782 0.4827307 0.4861032
7   8  0.4990 0.5082016 0.5150508
8   6  0.5016 0.5179475 0.5301156
9  11  0.5150 0.5273033 0.5364611
10  9  0.5162 0.5239988 0.5298037
11 10  0.5292 0.5376199 0.5438871
12 14  0.5326 0.5425630 0.5499789
13 13  0.5332 0.5411714 0.5471048
14 15  0.5374 0.5522555 0.5633130
15 12  0.5402 0.5546542 0.5654131
16 17  0.5416 0.5499582 0.5561795
17 16  0.5422 0.5568134 0.5676908
18 24  0.5514 0.5632823 0.5721268
19 18  0.5570 0.5706960 0.5808905
20 20  0.5576 0.5682114 0.5761100
21 19  0.5600 0.5699275 0.5773169
22 21  0.5656 0.5766019 0.5847911
23 22  0.5674 0.5797099 0.5888727
24 25  0.5738 0.5844089 0.5923055
25 23  0.5758 0.5904321 0.6013233

置信区间,特别是 Bonferroni−Dunn 置信区间——如前所述,更加可靠——表明,任何前k个值的预测能力大致相同。对于 4 个邻居的bonfCI值延伸至包括 5 个邻居的meanAcc值。

请注意这里nXval的作用。我们使用的交叉验证次数太少了。我们应该尝试更多的交叉验证次数,但如果不能,我们选择的k值(1、2、3、4 和 7)看起来差不多。保守地说,我们可能会选择使用 3 或 4 个邻居。

7.5 示例:音素数据

这个数据集包含在regtools包中,旨在根据五个声音测量值预测两种音素类型中的一种。我们来看看:

> head(phoneme)
         V1        V2        V3        V4        V5 lbl
0  0.489927 -0.451528 -1.047990 -0.598693 -0.020418   1
1 -0.641265  0.109245  0.292130 -0.916804  0.240223   1
2  0.870593 -0.459862  0.578159  0.806634  0.835248   1
3 -0.628439 -0.316284  1.934295 -1.427099 -0.136583   1
4 -0.596399  0.015938  2.043206 -1.688448 -0.948127   1
5  0.164735 -0.642728 -0.980619 -0.386415 -0.242046   1
> dim(phoneme)
[1] 5404    6

这里的Y列是lbl。如前所述,它有两个级别,所以这是一个二类分类问题。

让我们在这组数据上尝试qeDT()。如前所述,各种超参数之间是相互影响的,所以一开始我们可能不尝试使用所有超参数。我们可能只使用,比如,alphaminbucketmaxdepth

我们需要为这些参数指定我们希望调查的范围。同样,这没有一个固定的公式来决定,必须通过经验积累来获得洞察。但作为示例,我们可以尝试alpha的值为 0.01、0.05、0.10、0.25、0.50 和 1,minbucket的值为 1、5 和 10,等等,如调用中所示:

> z <- qeFT(phoneme,'lbl','qeDT',list(alpha=c(0.01,0.05,0.10,0.25,0.50,1),
   minbucket=c(1,5,10),maxdepth=c(3,8),minsplit=c(1,5,10),mtry=c(0,3)),
   50,1000,5,showProgress=T)
> z
$outdf
   alpha minbucket maxdepth minsplit mtry meanAcc        CI    bonfCI
1   1.00         1        8        5    0  0.1150 0.1284351 0.1401622
2   1.00         5        8       10    0  0.1176 0.1224275 0.1266412
3   1.00         5        8        1    0  0.1180 0.1238801 0.1290127
4   0.25         1        8        5    0  0.1218 0.1344352 0.1454640
5   1.00        10        8        1    3  0.1276 0.1403232 0.1514289
6   0.10         1        8        1    0  0.1310 0.1412380 0.1501744
7   1.00        10        8       10    3  0.1336 0.1380151 0.1418689
8   0.05         5        8       10    0  0.1338 0.1386500 0.1428834
9   0.05         5        8        1    0  0.1358 0.1429046 0.1491060
10  0.50         1        8        5    3  0.1362 0.1507200 0.1633940
11  0.01         1        8       10    0  0.1376 0.1416952 0.1452698
12  0.10        10        8       10    0  0.1408 0.1442374 0.1472378
13  0.50         5        8        5    3  0.1448 0.1543984 0.1627765
14  0.05         1        8        1    0  0.1466 0.1511066 0.1550404
15  0.25         5        8       10    3  0.1480 0.1609606 0.1722736
16  0.01         5        8       10    0  0.1486 0.1535665 0.1579015
17  0.10        10        8        1    3  0.1502 0.1631963 0.1745404
18  0.25        10        8        1    3  0.1536 0.1682711 0.1810770
19  0.25         5        8        5    3  0.1548 0.1731395 0.1891475
20  0.10         1        8        1    3  0.1552 0.1629286 0.1696747
...
46  0.50        10        3       10    3  0.2210 0.2279024 0.2339274
47  0.10        10        3       10    3  0.2216 0.2274476 0.2325518
48  0.50        10        3        1    3  0.2224 0.2302890 0.2371751
49  0.25         1        3       10    3  0.2228 0.2301494 0.2365645
50  0.01         5        3        5    3  0.2238 0.2333700 0.2417233

回想一下nCombs的作用。如果将其设置为NULL,则意味着我们希望qeFT()尝试所有可能的超参数组合范围。结果显示,共有 216 种组合(未展示)。但我们将nCombs设置为 50,因此qeFT()在 216 种组合中随机选择了 50 种进行测试,因此我们在此只看到 50 行输出。

一个机器学习算法的超参数越多,我们尝试的每个超参数值越多,我们就有更多的可能组合。在某些情况下,组合数实在太多,无法尝试所有可能的组合,因此需要使用非NULLnCombs

还要注意的是,我们运行的超参数组合越多,p-hacking 的风险就越大。此时,bonfCI列最为有用。事实上,在上面的输出中,bonfCI列在大多数情况下与CI列非常接近,这告诉我们 p-hacking 在这组数据中可能不是一个问题。

那么,我们能从这些输出中得出什么结论呢?

  1. 超参数调优很重要。最低的 OME 值大约是最大 OME 值的一半。

  2. 由于前面三个CI值非常接近,并且都在彼此的置信区间内,因此前三个超参数组合中的任何一个都应该是好的选择。

  3. 前 20 个超参数组合的maxdepth值都为 8。这表明,值大于 8 的情况下可能会表现得更好。

  4. 较大的alpha值似乎表现得更好。这表明我们可以尝试一些额外的大值。例如,我们没有尝试 0.50 到 1 之间的任何值,因此 0.75 可能值得一试。

  5. 排名前三的组合的mtry值都为 0,而排名靠后的组合则在该超参数上取值为 3。我们可能应该在这里做更详细的调查。

  6. 超参数确实存在相互作用。例如,看第 6 行。alpha的值比大多数最优行的值要小,这在一定程度上抑制了节点分裂过程,但通过设置较小的minsplitminbucket,这种抑制得到了部分补偿,这两者有助于大量节点分裂。这种负面“相关性”在qeFT()的图形显示功能中非常明显(未展示)。

7.6 结论

毫无疑问,找到一组好的超参数是机器学习中的一个主要挑战。但在本章中,我们已经看到了可以用于这个目的的工具,我们可以合理地相信我们已经做出了一个好的选择。

第三部分:基于线性关系的方法

这些章节从经典的线性和逻辑回归模型开始,这些模型假设回归函数具有线性形式。接着,我们转向更现代的变种——LASSO,这个模型奇怪地故意缩小线性模型中的系数。

第八章:参数化方法

Image

回顾一下“回归函数”这个术语,它首先出现在第 1.6 节,并用 r(t) 表示。它是由条件 X = t 定义的子群体中的 Y 的均值。当时我们举的例子是骑行人数数据:

回归函数的参数个数与特征数量相等。举个例子,假设湿度是第二个特征。如果我们要预测一天的骑行人数,已知当天的温度为 28 度,湿度为 0.51,那么我们将使用数据集中温度和湿度接近 28 和 0.51 的日子的平均骑行人数。在回归函数表示法中,就是 r(28, 0.51)。

基本上,机器学习方法都是从样本数据中估计回归函数的技术。使用 k-NN 方法,我们会通过计算(28, 0.51)附近日期的平均骑行人数来估算 r(28, 0.51)。使用决策树时,我们会将(28, 0.51)输入到树中,沿着适当的分支走,然后计算最终叶节点的平均骑行人数,叶节点就像是一个邻域。

到目前为止,我们并没有对回归函数图形的形状做出任何假设。在本章中,我们将假设回归函数的形状为直线,或者在更高维度下为平面等。

所谓的线性模型实际上已有几个世纪的历史。它在“简单”的预测应用中表现良好,甚至在一些“高级”应用中也能发挥作用。实际上,我们将在第 8.13 节中看到,线性模型的一个变体往往能超越更复杂的机器学习模型。

线性模型应该是每个分析师工具箱中的必备工具。但更有说服力的理由是,线性模型是一些最流行和最强大的机器学习算法的基础,包括 LASSO、支持向量机和神经网络,我们将在本书后续章节中介绍这些内容。

8.1 动机示例:棒球运动员数据

我们很快将介绍线性模型的 qe* 系列函数 qeLin()。但是,为了理解它的功能,我们先从一个简单的场景开始,在这个场景中我们只有一个特征,并使用它来激发对线性模型的概念。

回顾一下第 1.8 节中的数据集 mlb,它是随 regtools 附带的。我们将只关注球员的身高和体重:

> data(mlb)
> hw <- mlb[,2:3]

在这里,XY 分别表示身高和体重。

8.1.1 一个引导我们直觉的图形

所以,我们在预测体重与身高的关系。在 r() 表示法中,这意味着如果我们希望预测一个新玩家的体重,而他的身高是 71 英寸,我们需要估算 r(71)。这就是所有身高为 71 的玩家的平均体重。

我们不知道总体值,因为我们只有来自总体的一个样本。(如前所述,我们将数据视为来自所有球员的总体样本,包括过去、现在和未来的球员。)那么,我们如何估计 r(71) 呢?自然的估计是类似的样本量,即我们样本中所有身高为 71 的球员的平均体重:

# find indices of data rows having height 71
> ht71 <- which(hw$Height == 71)
# find the average weight in those rows
> mean(hw$Weight[ht71])
[1] 190.3596

回想一下,“帽子”符号表示“估计值”,因此我们有 Image(71) = 190.3596。通过熟练使用 R 的 tapply() 函数,我们可以得到所有估计的 r() 值:

> meanWts <- tapply(hw$Weight,hw$Height,mean)
> meanWts
      67       68       69       70       71       72       73       74
172.5000 173.8571 179.9474 183.0980 190.3596 192.5600 196.7716 202.4566
      75       76       77       78       79       80       81       82
208.7161 214.1386 216.7273 220.4444 218.0714 237.4000 245.0000 240.5000
      83
260.0000

这表示,“按身高分组体重值,并找到每个组的平均体重。”顺便说一下,注意身高是作为体重项目的名称提供的:

> meanWts['70']
     70
183.098

让我们绘制估计的平均体重与身高的关系图:

> plot(names(meanWts),meanWts)

图 8-1 显示了结果。

Image

图 8-1:估计的回归函数,体重与身高的关系

值得注意的是,这些点似乎几乎都落在一条直线上。这表明可以为 r(t) 建立一个模型,

Image

对于我们将从数据中估计的未知的斜率 m 和截距 b。我们假设 r(t) 的图形是 条直线,尽管我们不知道是哪一条——也就是说,我们不知道 bm。这就是线性模型。

请记住,r(t) 是子群体 X = t平均 Y,所以我们建模的是 平均 Y 而不是 Y 本身。我们并不是说 方程 8.1 给出了单个球员的体重,尽管我们确实使用该方程作为预测的基础。

8.1.2 作为维度约简的视图

如果 方程 8.1 是有效的模型,那么我们就大大简化了问题。

通常情况下,我们需要估计 r(t) 的多个不同值,比如 t 等于 68、69、70、71、72、73 等等,假设有 15 或 20 个值。但使用上述模型,我们只需要估计两个数字mb。因此,这是一种维度约简的形式。

8.2 lm() 函数

假设线性模型(稍后我们将讨论其有效性),我们可以使用 R 的 lm() 函数来估计 mb

> lmout <- lm(Weight ~ .,data=hw)
> lmout

Call:
lm(formula = Weight ~ ., data = hw)

Coefficients:
(Intercept)       Height
   -151.133        4.783

所以, ImageImage

让我们看看这个调用在说什么:

lm(Weight ~ .,data=hw)

这里我们要求 R 对我们的数据框 hw 拟合一个线性模型,预测体重。点号(.)表示“所有其他列”,在此情况下,仅为身高列。

为了预测一个身高为 71 的新球员的体重,我们将计算:

Image

但是,嘿,我们应该让计算机来做这个计算,而不是手工计算:

> predict(lmout,data.frame(Height=71))
       1
188.4833

轻微的误差是由于手工计算时的四舍五入误差造成的,我们的数据只提供了几位数字。

8.3 lm() 在 qe*-Series 中的封装:qeLin()

lm()函数在 R 语言中非常基础,每个人至少应该见过一次,因此我们在上一节中使用了它。但为了简化和统一,我们将使用其qe*系列封装函数qeLin()

下面是如何在qeLin()中执行上述计算:

> qelout <- qeLin(hw,'Weight',holdout=NULL)
> qelout$coef
(Intercept)      Height
-151.133291    4.783332
> predict(qelout,data.frame(Height=71))
       2
188.4833

大多数应用程序不仅仅有一个特征。接下来我们将讨论一般情况。

8.4 使用多个特征

我们可以,并且通常会,拟合多个特征的模型。

8.4.1 示例:棒球运动员,继续

假设我们加入了年龄,这样我们的线性模型就是:

Image

为了术语的统一(这里和后面都使用),我们将其写成:

Image

其中D是一个人工变量,总是等于 1。然后我们说,平均体重是D身高年龄这三个变量的线性组合。这只是一个术语,意思是为了得到平均体重,我们将这三个变量D身高年龄分别乘以对应的系数bm[1]和m[2],然后将结果求和。

现在我们使用mlb的第 4、5、6 列,因此我们按如下方式拟合模型,例如,对于 28 岁:

> qelout <- qeLin(mlb[,4:6],'Weight',holdout=NULL)
> predict(qelout,data.frame(Height=71,Age=28))
      11
187.4603

8.4.2 β符号

由于本书的读者可能会在网络上看到其他相关讨论,因此需要提到,通常使用希腊字母β表示系数。例如,公式 8.3 可以写成如下形式:

Image

我们将从我们的样本数据中估计β[0]、β[1]和β[2],如在第 8.4.4 节中所见。而且,回顾一下,我们使用“帽子符号”表示估计值,因此我们的估计系数将表示为 ImageImage

8.4.3 示例:Airbnb 数据

短期租赁公司 Airbnb 提供了大量的租赁数据。这里我们查看了来自旧金山的一些数据。^(1)(这里使用的数据集来自 2019 年 2 月 1 日,似乎现在已经不再可用。)这不仅提供了线性模型的另一个示例,而且还将展示一些数据清理问题。

8.4.3.1 数据准备

在下载数据并将其读入 R 后(细节省略),我们得到了一个数据框Abb,但它仍然需要很多注意。

许多特征是文本数据,例如:

> Abb[1,]$house_rules
[1] "* No Pets - even visiting guests for a short time period. * No Smokers..."

本书稍后会讨论文本数据的主题,但为了这个示例,我们暂时省略了该部分。

另一个问题是价格包含美元符号和逗号,例如:

> Abb[1,]$monthly_price
[1] "$4,200.00"

处理此类问题往往占据数据科学家工作的大部分时间。这里我们编写了一个函数,将此类数字的列d转换为正确的格式,使用了 R 语言的一些字符字符串操作功能:

convertFromDollars <- function(d) {
   d <- as.character(d)
   # replace dollar sign by ''
   d <- sub('\\$','',d,fixed=F)
   # replace commas by ''
   d <- gsub(',','',d)
   d <- as.numeric(d)
   # some entries were ''; replace by NAs
   d[d == ''] <- NA
   d
}

而且,毫不奇怪,这个数据集似乎也有一些错误的条目:

> table(Abb$square_feet)

   0    1    2   14  120  130  140  150  160  172  175  195  250  280  300  360
   2    3    2    1    1    1    3    2    1    1    1    1    2    2    4    2
 400  450  500  538  550  600  650  700  750  780  800  810  815  840  850  853
   1    2    8    1    1    4    1    3    5    1    4    1    1    1    1    1
 890  900  950 1000 1012 1019 1100 1200 1390 1400 1490 1500 1600 1660 1750 1800
   1    2    3    9    1    1    2    9    1    2    1    7    1    1    1    3
1850 1900 1996 2000 2100 2200 2250 2600 3000
   1    1    1    4    3    2    1    1    4

例如,列出 1 和 2 平方英尺的区域,显然这是不正确的。我们在这里不再深入探讨,但显然,如果这不仅仅是书中的一个示例,我们将需要做更多的工作。

数据清理后,数据框架如下所示:

> head(Abb)
  zipcode bathrooms bedrooms square_feet weekly_price monthly_price
1   94117       1.0        1          NA         1120          4200
2   94110       1.0        2          NA         1600          5500
3   94117       4.0        1          NA          485          1685
4   94117       4.0        1          NA          490          1685
5   94117       1.5        2          NA           NA            NA
6   94115       1.0        2          NA           NA            NA
  security_deposit guests_included minimum_nights maximum_nights
1              100               2              1             30
2               NA               2             30             60
3              200               1             32             60
4              200               1             32             90
5                0               2              7           1125
6                0               1              2            365
  review_scores_rating
1                   97
2                   98
3                   85
4                   93
5                   97
6                   90

现在我们准备进行分析了。

8.4.4 应用线性模型

这是调用,省略了面积和周价格列:

> linout <- qeLin(Abb[,-c(4,5)],'monthly_price',holdout=NULL)
> linout$coef
         (Intercept)         zipcode94103         zipcode94104
       -4.485690e+03        -4.441996e+02         6.364539e+02
        zipcode94105         zipcode94107         zipcode94108
        1.012009e+03        -2.846037e+02        -1.649897e+03
        zipcode94109         zipcode94110         zipcode94111
       -3.945963e+02        -1.113476e+03         1.619558e+03
        zipcode94112         zipcode94114         zipcode94115
       -2.304310e+03        -2.607913e+02        -3.881351e+02
        zipcode94116         zipcode94117         zipcode94118
       -1.959336e+03        -1.543353e+02        -1.362785e+03
        zipcode94121         zipcode94122         zipcode94123
       -1.315474e+03        -1.434050e+03         1.639610e+03
        zipcode94124         zipcode94127         zipcode94131
       -2.309765e+03        -2.127720e+03        -1.525655e+03
        zipcode94132         zipcode94133         zipcode94134
       -1.675761e+03         6.496800e+02        -1.370148e+03
        zipcode94158            bathrooms             bedrooms
       -2.509281e+03         2.025493e+02         1.540830e+03
    security_deposit      guests_included       minimum_nights
        3.462443e-01         3.663498e+02        -6.400597e-01
      maximum_nights review_scores_rating
       -2.371457e-04         6.613115e+01

正如在 R 中常见的那样,估计的系数以科学计数法显示,例如,1.605326e + 03 = 1.605326 × 10³ = 1605.326。比如说,Image 大约是 −4,486,Image 大约是 −444,依此类推。

请注意,lm()(通过其封装函数qeLin())已将邮政编码特征,一个 R 因子,转换为虚拟变量。回想一下,通常我们会比类别的数量少一个虚拟变量——在这里是邮政编码的数量。R 在这里省略了第一个,即 94102。

由于本书的重点是预测而非因果解释,因此估计的系数并不是特别重要。此外,在解释系数时必须非常小心。然而,关于这些系数,接下来需要做一些说明。

8.5 降维

让我们在前一节的线性模型和 Airbnb 示例的背景下讨论这个基本的机器学习话题。

8.5.1 哪些特征是重要的?

如第 3.1.1 节所述,美国有超过 40,000 个邮政编码;这通常远远超过了可以直接使用的数量。在旧金山,这个数量是可管理的,但我们可能还是希望去掉那些对预测似乎不重要的邮政编码。

另一方面,正如房地产经纪人所说:“位置,位置,位置。”邮政编码应该非常重要,且估计的系数至少似乎证实了这一点。例如,根据之前给出的系数估计,位于 94105 邮政编码的房产,平均来说,其价格溢价大约为 1,012 美元,而位于 94107 的房产,平均而言,其价格低于市场价大约 285 美元,假设其他变量保持不变。那么,溢价低于成本是相对于什么而言的呢?由于省略了 94102 邮政编码,我们看到 94105 的价格平均比 94102 高出 1,012 美元——在Image(t)中的邮政编码项将是 1·1012,对于该邮政编码的房产,而 94102 则为 0,因为没有该邮政编码的虚拟变量。类似地,94107 的价格比 94102 低大约 444 美元,依此类推。换句话说,94102 成为基准邮政编码。

但是……注意上面的措辞:“估计的系数至少似乎证实了这一点。”毕竟,我们所做的是有限准确度的估计。这是一个必须考虑的关键点,接下来我们将讨论。

另一方面,security_deposit 的数量似乎并没有太大影响,因此我们应该考虑将其从分析中删除。回想一下,更多的特征意味着更少的偏差,但更多的方差。由于保证金在预测值中的影响似乎很小,删除这一特征应当不会增加太多的偏差。对于 minimum_nightsmaximum_nights 特征,情况也是如此。

8.5.2 统计显著性与降维

在上一节中,我们建议从分析中删除几个特征。但我们仅仅是凭借一种“直觉”来做这个决定。人们自然会希望有一个神奇的公式来决定保留哪些特征,删除哪些特征。然而,正如本书所解释的那样,实际上并没有这样的神奇公式。我们列举了一些常用的方法,比如交叉验证和主成分分析(PCA),但这些方法也不是万能的、万无一失的解决方案。

本节中,我们将讨论统计显著性在参数模型中用于降维的应用。我们不推荐使用它,而且它的受欢迎程度比过去低了,但仍然在许多分析师中流行。因此,有必要在这里介绍这一技术。

首先,我们需要介绍一个新的 R 通用函数(第 1.5.1 节)。除了 print()plot()predict(),R 中另一个常见的通用函数是 summary()。它的功能正如其名字所示;也就是说,它提供了对象的摘要信息。

回顾一下,通用函数是根据当前对象的类别量身定制的。我们这里的对象 linout 的类别是什么?

> class(linout)
[1] "qeLin" "lm"

因此,如果我们调用 summary(linout),R 解释器会首先查找 summary.qeLin() 函数。由于 qeML 包中没有此函数,解释器接着会查找 summary.lm(),它是存在的。我们来看一下这个函数给我们提供的内容:

> summary(linout)
...
Coefficients:
                       Estimate Std. Error t value Pr(>|t|)
(Intercept)          -4.486e+03  1.478e+03  -3.034 0.002479 **
zipcode94103         -4.442e+02  5.177e+02  -0.858 0.391094
zipcode94104          6.365e+02  2.131e+03   0.299 0.765227
zipcode94105          1.012e+03  7.071e+02   1.431 0.152724
zipcode94107         -2.846e+02  4.906e+02  -0.580 0.561978
zipcode94108         -1.650e+03  6.354e+02  -2.597 0.009566 **
zipcode94109         -3.946e+02  4.955e+02  -0.796 0.426025
zipcode94110         -1.113e+03  4.280e+02  -2.601 0.009435 **
zipcode94111          1.620e+03  1.014e+03   1.598 0.110396
zipcode94112         -2.304e+03  4.761e+02  -4.840 1.52e-06 ***
zipcode94114         -2.608e+02  4.425e+02  -0.589 0.555770
zipcode94115         -3.881e+02  4.666e+02  -0.832 0.405719
zipcode94116         -1.959e+03  7.028e+02  -2.788 0.005412 **
zipcode94117         -1.543e+02  4.441e+02  -0.348 0.728269
zipcode94118         -1.363e+03  5.560e+02  -2.451 0.014434 *
zipcode94121         -1.315e+03  6.422e+02  -2.048 0.040819 *
zipcode94122         -1.434e+03  5.437e+02  -2.638 0.008493 **
zipcode94123          1.640e+03  5.507e+02   2.977 0.002985 **
zipcode94124         -2.310e+03  6.552e+02  -3.525 0.000444 ***
zipcode94127         -2.128e+03  6.051e+02  -3.516 0.000459 ***
zipcode94131         -1.526e+03  5.024e+02  -3.037 0.002459 **
zipcode94132         -1.676e+03  7.745e+02  -2.164 0.030746 *
zipcode94133          6.497e+02  5.402e+02   1.203 0.229402
zipcode94134         -1.370e+03  8.837e+02  -1.550 0.121376
zipcode94158         -2.509e+03  1.546e+03  -1.623 0.104905
bathrooms             2.025e+02  1.323e+02   1.531 0.125996
bedrooms              1.541e+03  1.071e+02  14.385  < 2e-16 ***
security_deposit      3.462e-01  9.820e-02   3.526 0.000443 ***
guests_included       3.663e+02  5.897e+01   6.212 7.92e-10 ***
minimum_nights       -6.401e-01  2.670e+00  -0.240 0.810569
maximum_nights       -2.371e-04  2.132e-03  -0.111 0.911465
review_scores_rating  6.613e+01  1.432e+01   4.617 4.44e-06 ***
...

这里计算的一个特定类型的信息是标准误差,接下来会讨论。

8.5.2.1 标准误差

你可以看到,上面报告了每个估计系数的标准误差 Image。这是对于所采样的总体,所有可能样本的 Image 的标准偏差估计。这让我们可以通过以下推理来了解 Image 的准确度。

如果标准误差很小,这意味着如果我们使用与给定总体不同的一组样本数据,Image 可能会得到与我们当前值差不多的结果。换句话说,我们可以将 Image 视为具有代表性。

我们可以通过加减 1.96 倍的标准误差来形成一个大约 95% 的置信区间(CI)来估计 β[i]。

例如,考虑虚拟变量zipcode94134。该变量的估计 beta 系数为−$1,370\。这是相对于基准的邮政编码而言,也就是说,指没有虚拟变量的那个邮政编码。(回想一下第 1.4 节,当我们有一个分类特征时,虚拟变量的数量比类别数少 1。)如前所述,被省略的邮政编码是 94102\。因此,对于某个特定的安全押金、客人政策等,该地区的价格估计比基准低超过$1,000。但看看置信区间(CI):

Image

置信区间显示,这个地区实际上可能比基准地区上几百美元。

8.5.2.2 显著性检验

最后的这个例子表明,zipcode94134作为租金预测因子的状态尚无定论。因此,我们应该认真考虑将其从模型中去除。记住,偏差-方差权衡的概念意味着,如果某个特征并不特别有用,那么将其纳入模型可能会降低我们的预测能力。

但是,让我们考虑另一个邮政编码,比如 94132\。这里的置信区间是

Image

置信区间完全位于负值范围。因此,它被标记了一个星号。

这些星号到底是什么意思?为什么某些系数有双星号?本书的重点不在统计学上,但对读者来说至少了解这个情况是很重要的,因为通常使用星号作为降维的指南。

大致来说,如果置信区间不包含 0,那么系数就会被标记一个星号。如果 0 远离区间,则标记两个星号;如果置信区间远离 0,则标记三个星号。带一个星号的系数称为显著(即,与 0 显著不同);带两个星号的称为高度显著,三个星号则获得非常高度显著的称号。

那么,什么算是“远远超出区间”和“远远远离 0”呢?这是由 p 值决定的。p 值低于 0.05 是显著的,低于 0.01 或 0.001 则分别表示高度显著或非常高度显著。

p 值是一个特定的概率,其复杂的定义我们暂时跳过。(回想一下第 5.5 节中的这个术语。)可以简单地说,在这种降维方法下,任何没有星号的特征,比如zipcode94134,都会被舍弃,其他特征则保留在模型中。如果想稍微谨慎一点,也可以只保留那些至少有两个星号的系数。

今天,包括我自己在内的许多分析师认为这种方法存在缺陷。让我们来看一下为什么。简短的回答是,p 值过于依赖数据点的数量n。实际上,标准误差与Image成反比。这有一个相当深远的影响,具体如下。

假设,假设zipcode94132的估计系数为 1.4,标准误差为 0.9\。那么我们可以得到一个置信区间:

Image

这包含 0,因此没有星号。这可能是件好事,因为这个特征似乎没有实际的预测能力:在这个邮政编码中,租金的估计差异只有一美元左右。

但是,如果我们幸运地拥有 25 倍的数据呢?那么Images将增加 5 倍,因此标准误差将缩小 5 倍,约为 0.18\。它会有所变化,系数估计值 1.4 也会有所变化,但大体上我们的 CI 现在将是:

Image

啊,现在它变得显著了!太好了!但是……估计系数仍然大概是 1.40 美元——不到 2 美元!这个变量几乎无法帮助我们预测租金。换句话说,这个特征的所谓显著性可能会误导我们。

许多统计学家(包括本文作者)对使用显著性检验和 p 值持反对态度。^(2)这些检验在预测应用中尤其不可靠。对于大数据集,每个特征都会被声明为“非常显著”(三个星号),无论该特征是否具有实质性的预测能力。一个回归系数非常小的特征也可能被声明为“显著”,尽管它在预测中几乎没有用处。

8.5.2.3 陷阱:NA 值及其对 n 的影响

如上所示,数据集还包括一些 NA 值。我们不需要直接处理这些 NA 值,因为lm()qeLin()所包装的函数)会自动将计算限制在完整的案例中。然而,正如在第 4.1 节中所指出的,如果数据集包含许多 NA 值,这又是另一个需要进行维度减少的原因,因为这样做可能会增加完整案例的数量。这意味着方差较小,这是非常可取的。通过一些实验,我们发现,去除那些 NA 倾向较大的特征(除了我们已经删除的特征)并不会在这个特定案例中帮助增加数据量,但这是一个重要的普遍原则。

8.5.2.4 陷阱:具有多级分类变量的保留集形成困难

在我们之前的 Airbnb 分析中,如果我们形成一个保留集,就会出现问题:

> linout <- qeLin(Abb[,-c(4,5)],'monthly_price')
holdout set has  707 rows
Error in model.frame.default(Terms, newdata,
   na.action = na.action, xlev = object$xlevels) :
  factor zipcode has new levels 94014
> linout <- qeLin(Abb[,-c(4,5)],'monthly_price')
holdout set has  707 rows
Error in model.frame.default(Terms, newdata,
   na.action = na.action, xlev = object$xlevels) :
  factor zipcode has new levels 94014, 94106

当然,由于保留集是随机选择的,因此每次可能会有不同的结果。但我们看到,在我们这里的两次尝试中,每次至少有一个错误,“因子邮政编码有新水平。”这里发生了什么?

问题在于,某些邮政编码(如 94014)仅出现在少数数据点中。显然,在这里的每个训练集中都没有 94014 的案例,因此lm() 对在保留集中看到一个表示“感到惊讶”。

唯一的解决办法是在运行qeLin()之前从数据中删除所有包含 94014(以及可能的其他)案例。

8.6 最小二乘法和残差

尽管lm()背后的计算细节超出了本书的范围,但大致了解所涉及的内容是很重要的,因为类似的计算将在本书后续出现。这将引入最小二乘法的概念。接着,这将引导出残差的概念,它们本身也很重要。

为简便起见,我们这里考虑第 8.2 节的上下文。数量 ImageImage 等是使用著名的普通最小二乘法(OLS)方法计算得出的,其原理如下。

想象一下,在计算出 ImageImage 后,我们回过头来“预测”样本数据中第一个玩家的体重。正如引号所示,这样做是愚蠢的;毕竟,我们已经知道第一个玩家的体重是 180:

> data(mlb)
> mlb[1,]
           Name Team Position Height Weight   Age
1 Adam_Donachie  BAL  Catcher     74    180 22.99
  PosCategory
1     Catcher

但无论如何,还是要思考这个练习。最终这将成为事物运作的基础,既适用于线性模型,也适用于本书其余部分的所有机器学习方法。

我们预测的值将是 Image。因此,我们的预测误差为:

Image

这是数据集中该行的残差。(回想一下,这在第 6.3.2 节中有简要提到。)我们将对该误差进行平方,而不是直接使用它的原始形式,因为我们将对误差进行求和,而不希望正误差和负误差互相抵消。现在,我们也“预测”所有其他数据点,并将平方误差相加:

Image

现在要注意的是:lm()找到 ImageImage 的方式是将它们设置为能够最小化平方和的值(方程式 8.10)。换句话说,把这个表达式看作是两个变量 ImageImage 的函数,然后对这两个变量最小化该表达式。(懂微积分的读者可能已经注意到,我们将这两个导数设为 0 并解出 ImageImage。)

由于我们是在最小化平方和,估计的系数被称为最小二乘估计。(通常会加上“普通”一词,因为普通最小二乘法与一些我们在这里不讨论的变体不同。)

8.7 诊断:线性模型是否有效?

所有模型都是错误的,但有些是有用的。

—乔治·博克斯,著名的早期统计学家

线性假设是相当强的。这何时适用?让我们仔细看看。

8.7.1 精确性?

读者可能会问:“方程式 8.1 中的线性模型如何有效?”是的,图 8-1 中的点看起来似乎大致位于一条直线上,但并不完全如此。这里有两个重要的回答:

  • 正如乔治·博克斯的名言所指出的,没有模型是完全正确的。常用的物理模型忽略了空气阻力和摩擦等因素,即使是考虑了这些因素的模型,也无法反映所有可能的因素。即便线性回归函数rt)的模型并不完美,它在预测方面也可能表现得很好。

  • 即使方程式 8.1 完全正确,图 8-1 中的点也不会精确地落在直线上。记住,r(71)只是所有 71 身高球员的平均体重。大多数该身高的球员体重大于或小于这个值,因此他们的数据点不会完全落在直线上,事实上,在某些情况下可能远离直线。同样,图 8-1 中我们绘制的平均体重也有类似的问题;每个平均值都是基于少数几个球员的数据。

顺便提一下,经典的线性模型方法论有一些假设,超出了线性假设的范围,比如假设Y在每个子群体中服从正态分布。但这些假设对于我们的预测背景并不相关。(实际上,即使是在统计推断中,正态性假设在大样本中也不是很重要。)

8.7.2 诊断方法

多年来,分析师们已经开发了多种方法来检验线性模型的有效性。我在我的书《统计回归与分类:从线性模型到机器学习》(CRC Press, 2017)中描述了几种方法。

再次强调,由于我们关注的是预测而非因果分析,这部分内容我们将在此不予讨论。只要结果变量是特征的递增或递减函数——例如,人的平均体重是身高的递增函数——线性模型应该能在以预测为导向的应用中表现得相当好。通过线性多项式模型(参见第 8.11 节),这一点可以进一步完善。

8.8 R 平方值

回想一下,估计的回归系数是通过最小化实际和预测Y值之间的平方差之和来计算的(参见第 8.6 节)。R²是实际和预测Y之间的平方相关性。

可以证明,这可以被解释为由于X引起的Y变异的比例。(和往常一样,X指的是我们所有的特征。)因此,R²的值范围是 0 ≤ R² ≤ 1,值为 1 表示X能完美预测Y。然而,这里存在一个大问题,因为我们正在预测的正是我们用来估计预测模型(回归系数)的数据。如果我们出现过拟合,那么R²将显得过于乐观。

这,当然,就是使用保留数据集的动机。因此,qeLin()不仅报告标准的R²值,还报告在保留集上计算的R²值(存储在qeLin()返回值的holdoutR2组件中)。后者更可靠。此外,如果这两个值之间有较大差异,表明我们发生了过拟合。

大多数线性回归软件库也会报告调整后的 R²值。这里的调整一词暗示着公式试图修正过拟合的情况。qeLin()也报告这个值,且如果这个值与第一个R²值之间存在较大差异,则表明我们正在发生过拟合。

8.9 分类应用:logistic模型

线性模型是为回归应用设计的。那么分类问题呢?线性模型的一个推广,毫不奇怪地被称为广义线性模型,用于处理分类问题。这里我们将介绍该模型的一种形式——logistic模型。

回想一下第二章开头的讨论,指出在分类问题中,Y的值要么是 1,要么是 0,回归函数变成了给定子群体中Y = 1 的概率。如果我们使用lm()拟合一个纯线性模型,估算的回归值可能会超出[0,1]区间,因此无法代表概率。当然,我们可以将lm()预测的任何值截断到[0,1]区间,但logistic模型提供了一个更好的方法。

该模型的名称来源于 logistic 函数l(t) = 1/(1 + e^(−t))。由于该函数的值域在(0,1)之间,因此非常适合用于建模概率。我们仍然使用线性形式,但将该形式通过 logistic 函数进行转换,将其压缩到(0,1)区间,进行概率建模。

假设我们希望根据身高预测性别。我们的模型可能是:

Image

这里的β[0]和β[1]再次是人口参数,我们从数据中进行估算。

logistic和线性模型基本相似:在线性模型中,β**[i]是第i个特征对平均Y的影响,而在 logit 模型中,β**[i]是第i个特征对Y = 1 的概率的影响。(一些分析师将 logit 视为log-odds 比率的线性模型,即 log(P(Y = 1| X) / [1 − P(Y = 1| X)])。)

logistic模型通常被称为logit模型,简化时常用这个名字。

8.9.1 glm()和 qeLogit()函数

在 R 中,用于广义线性模型的标准函数是glm()。在 logistic 回归的情况下,该函数被qeML包中的qeLogit()函数所包装。后者的调用形式为:

qeLogit(data,yName,
   holdout = floor(min(1000, 0.1 * nrow(data))),yesYVal = NULL)

前三个参数与其他qe*系列函数相同。最后一个参数yesYVal在二分类情况下是必要的,它指定我们希望被编码为Y = 1 的Y值。

8.9.2 示例:电信流失数据

在 第 2.2 节中,我们使用 k-NN 分析了一些客户流失数据。现在,我们用一个逻辑模型重新分析这部分数据。回忆一下,Churn 变量的值为 'Yes''No'

# data prep as before, not shown
> set.seed(9999)
> glout <- qeLogit(tc,'Churn',holdout=NULL,yesYVal='Yes')

我们的模型是:

Image

假设我们要预测一个新案例,它类似于我们数据集中第 333 个案例,但性别不同:

> names(tc)
 [1] "gender"           "SeniorCitizen"    "Partner"          "Dependents"
 [5] "tenure"           "PhoneService"     "MultipleLines"    "InternetService"
 [9] "OnlineSecurity"   "OnlineBackup"     "DeviceProtection" "TechSupport"
[13] "StreamingTV"      "StreamingMovies"  "Contract"         "PaperlessBilling"
[17] "PaymentMethod"    "MonthlyCharges"   "TotalCharges"     "Churn"
> newx <- tc[333,-20]  # exclude Y
> newx
    gender SeniorCitizen Partner Dependents tenure PhoneService MultipleLines
333 Male             0      No         No     46          Yes           Yes
    InternetService OnlineSecurity OnlineBackup DeviceProtection TechSupport
333     Fiber optic             No          Yes              Yes          No
    StreamingTV StreamingMovies Contract PaperlessBilling
333         Yes              No One year              Yes
              PaymentMethod MonthlyCharges TotalCharges
333 Credit card (automatic)           94.9      4422.95
> newx$gender <- 'Female'
> predict(glout,newx)
$predClasses
[1] "No"

$probs

          [,1]
[1,] 0.2307227

我们猜测客户会保持原状——即不会跳槽到其他服务提供商——跳槽概率大约只有 23%。

我们还会收到一个警告消息:

Warning messages:
1: In predict.lm(object, newdata, se.fit, scale = 1, type = if (type ==  :
  prediction from a rank-deficient fit may be misleading
2: In predict.lm(object, newdata, se.fit, scale = 1, type = if (type ==  :
  prediction from a rank-deficient fit may be misleading

这是一个技术问题,通常发生在特征高度相关时。此时,glm() 实际上跳过了一些本质上是冗余的特征。

有时 glm() 会给我们一个警告消息,例如:

glm.fit: fitted probabilities numerically 0 or 1 occurred

再次强调,这是一个技术问题,我们在这里不会深入探讨。读者可以照常继续。

另一方面,有一个警告是不容忽视的:“未能收敛”。这在 lm() 中不会发生(lm() 是我们封装的 qeLinear() 使用的 R 函数),但在使用 logit 时偶尔会发生。这通常可以通过执行一些维度压缩来解决。

8.9.3 多类情况

如果类别超过两个,我们有两个选择。为了具体说明,考虑 第 2.3 节中的脊柱数据。那里我们有三个类别:DH、NO 和 SL。现在,暂时考虑直接使用 glm(),而不是它的封装函数 qeLogit()

一对多(OVA)方法 在这里我们对每个类别运行一次 glm()。我们首先使用 DH 作为 Y 来运行 logit。然后我们使用 NO 作为 Y 运行,再最后对 SL 进行相同的操作。这样,我们就得到了三组系数——实际上是从 glm() 返回的三个对象,比如 DHoutNOoutSLout。然后,在预测新案例 newx 时,我们运行:

predict(DHout,newx,type='response')
predict(NOout,newx,type='response')
predict(SLout,newx,type='response')

这会给我们三个概率。我们会选择概率最高的类别作为预测结果。

全对全(AVA)方法 在这里我们对每一对类别运行一次 glm()。首先,我们将数据限制在 DH 和 NO 之间,将 SL 类别暂时放在一旁,并将 Y 设为 DH。然后,我们仅关注 DH 和 SL,将 Y 设为 DH。最后,我们将 DH 放到一旁,运行 NO 和 SL 并将 Y 设为 NO。这样,我们也会得到三个 glm() 输出的对象。

然后我们会对 newx 调用 predict() 三次。假设第一次得到的结果小于 0.5,这意味着在 DH 和 NO 之间,我们会预测这个新案例为 NO——也就是说,NO “获胜”。我们在所有三个对象上都这么做,最后,预测的类别是出现频率最多的那个类别。

qeLogit() 函数使用了 OVA 方法。由于 qeLogit()glm() 的封装函数,我们看不到后者的具体操作,它们仅作为中间内部计算使用。然而,如果需要,可以通过 qeLogit() 返回的对象中的 glOuts 组件访问 glm() 调用的结果。

8.9.4 示例:跌倒检测数据

这个数据集包含在qeML中,最初来自 Kaggle。^(3) 来自该网站:

跌倒是一个严重的公共卫生问题,对于跌倒风险群体的人来说,甚至可能是致命的。我们开发了一种自动跌倒检测系统,该系统配备了穿戴式运动传感器单元,传感器被安装在受试者身体的六个不同位置。

有六种活动类型,因此有六个类别,分别编码为 0(站立)、1(行走)、2(坐着)、3(跌倒)、4(抽筋)和 5(跑步)。让我们看看我们能有多准确地预测类别:

> data(falldetection)
> fd <- falldetection
> head(fd)
  ACTIVITY    TIME       SL      EEG BP  HR CIRCULATION
1        3 4722.92  4019.64 -1600.00 13  79         317
2        2 4059.12  2191.03 -1146.08 20  54         165
3        2 4773.56  2787.99 -1263.38 46  67         224
4        4 8271.27  9545.98 -2848.93 26 138         554
5        4 7102.16 14148.80 -2381.15 85 120         809
6        5 7015.24  7336.79 -1699.80 22  95         427
> fd$ACTIVITY <- as.factor(fd$ACTIVITY)  # was integer, need factor for qe*
> set.seed(9999)
> fd$ACTIVITY <- as.factor(fd$ACTIVITY)
> fdout <- qeLogit(fd,'ACTIVITY')
> fdout$testAcc
[1] 0.593
> fdout$baseAcc
[1] 0.7186972
> table(fd$ACTIVITY)
   0    1    2    3    4    5
4608  502 2502 3588 3494 1688

我们的 logit 模型仅正确预测了大约 40%的案例,但这比我们仅仅猜测每个案例为 Class 3(最常见类别)时的 28%正确率要好。

假设我们要预测一个假设的新案例,比如数据中的第一行,但BP等于 28:

> newx <- fd[1,-1]
> newx
     TIME      SL   EEG BP HR CIRCULATION
1 4722.92 4019.64 -1600 13 79         317
> newx$BP <- 28
> newx
     TIME      SL   EEG BP HR CIRCULATION
1 4722.92 4019.64 -1600 28 79         317
> predict(fdout,newx)
$predClasses
[1] "2"

$probs
             0         1         2         3         4          5
[1,] 0.2294015 0.1111428 0.2324076 0.1605359 0.1830733 0.08343888

这种情况下,预测结果为 Class 2,概率约为 23%。

8.10 线性/广义线性模型中的偏差与方差

正如第三章中讨论的那样,我们使用的特征越多,偏差越小,但方差越大。对于像本章中的参数模型来说,较大的方差表现为系数估计的不稳定性,而这反过来又使得后续的预测更加不稳定。

再次提醒,高方差的回归系数估计意味着该估计值在不同样本之间会有很大的波动。大的波动反过来意味着估计的系数向量更可能远离真实的总体值。

在这里,我们通过一个具体的例子来说明方差是如何随着模型复杂度增加而增加的。

8.10.1 示例:共享单车数据

我们可以使用regtools函数stdErrPred()来更具体地说明预测不稳定性的问题。这个函数计算从lm()得到的预测值的标准误差。回顾第 8.5.2.1 节,估计量的标准误差是该估计量的标准差。因此,较大的标准误差意味着估计量在不同样本之间的波动更大。

我们将拟合两个模型,一个使用较小的特征集,另一个使用稍大的特征集,然后在每个模型上进行相同的预测;作为示例,我们将预测数据集中的第三个数据点。我们将打印出两个预测值,并且最重要的是,打印出这两个预测的标准误差。

> data(day1)
> e1 <- day1[,c(4,10,12,13,16)]
> e2 <- day1[,c(4,10,12,13,16,6,7)]  # add holiday, weekday columns
> names(e1)
[1] "yr"        "temp"      "hum"       "windspeed" "tot"
> names(e2)
[1] "yr"        "temp"      "hum"       "windspeed" "tot"       "holiday"
[7] "weekday"
> set.seed(9999)
> e1out <- qeLin(e1,'tot')
> e2out <- qeLin(e2,'tot')
> newx1 <- e1[3,-5]  # exclude tot
> newx2 <- e2[3,-5]  # exclude tot
> predict(e1out,newx1)
      31
1818.779
> predict(e2out,newx2)
       3
1689.054
> stdErrPred(e1out,newx1)
[1] 97.77229
> stdErrPred(e2out,newx2)
[1] 108.3989

因此,使用较大特征集的预测具有更大的标准误差。标准误差是估计量的标准差——在这种情况下,是我们对预测准确性的估计。所以在这里我们看到了偏差-方差权衡的实际应用。尽管更大的模型更详细,从而偏差较小,但它确实具有更大的方差。

那是否意味着我们应该使用更小的特征集呢?不。为了判断我们是否达到了切换点,我们需要使用交叉验证。但是,读者应该牢记这个具体的折中示例。

8.11 多项式模型

出人意料的是,我们可以使用线性回归方法来建模非线性效应。我们将在本节中展示如何做到这一点。为什么这很重要?

  • 多项式模型通常能够与许多更华丽的机器学习模型匹敌,甚至超越它们。

  • 多项式将在我们关于支持向量机的章节(第十章)以及我们对神经网络的讨论(第十一章)中发挥重要作用,甚至在这里,多项式与这些内容之间有着出人意料的联系。

8.11.1 动机

我们在本书早些时候使用了程序员和工程师工资的例子(见第 3.2.3 节)。考虑图 8-2 中展示的工资收入与年龄的关系图。看起来工人在 20 多岁时工资急剧上升,然后平稳一段时间,在 55 岁左右甚至有下降的迹象。这显然不是线性关系。

Image

图 8-2:工资收入与年龄的关系

或者可以参考图 8-3,对于共享单车数据,绘制总骑行人数与温度的关系。这里非线性关系更加明显。(我们似乎看到了两组数据,可能是注册用户和临时用户。)这并不奇怪——如果天气太冷或太热,人们自然不愿意去骑车——但重点是,线性模型似乎不太合适。

Image

图 8-3:骑行人数与温度的关系

幸运的是,这些非线性效应实际上是可以通过线性模型来适应的。

8.11.2 使用线性模型建模非线性

从简单开始,假设在共享单车数据中,我们希望预测总骑行人数tot,只使用温度temp作为唯一特征,但需要一个二次模型:

Image

这仍然是一个线性模型!当然,temp有一个平方项,所以我们说模型在temp上是非线性的。但它在bcd上仍然是线性的。我们正在将平均tot建模为三者的线性组合:1、temptemp²(可以将b看作b × 1)。

然后我们可以简单地添加一个temp²列,并调用qeLin()

> data(day1)
> day1tottemp <- day1[,c(10,16)]  # just tot, temp
> head(day1tottemp)
      temp  tot
1 8.175849  985
2 9.083466  801
3 1.229108 1349
4 1.400000 1562
5 2.666979 1600
6 1.604356 1606
> day1tottemp$tempSqr <- day1tottemp$temp² 
> head(day1tottemp)
      temp  tot   tempSqr
1 8.175849  985 66.844507
2 9.083466  801 82.509355
3 1.229108 1349  1.510706
4 1.400000 1562  1.960000
5 2.666979 1600  7.112777
6 1.604356 1606  2.573958
> qeLin(day1tottemp,'tot')
...
Coefficients:
(Intercept)         temp      tempSqr
   1305.597      346.422       -6.815

我们看到的是ImageImage

但这也很不方便。我们不仅需要手动添加那个平方列,而且还需要记得在以后预测新案例时添加它。

更糟糕的是,我们还需要添加交叉乘积项。假设我们要根据温度和湿度来预测总骑行人数。在这种情况下,方程 8.11 将包含这两个特征的乘积,变成:

Image

如果我们有很多特征,手动添加这些项将变得非常麻烦。

一个更微妙的问题涉及虚拟变量。因为 0² = 0 且 1² = 1,我们可以看到任何虚拟变量的平方就是其本身。因此,在我们的模型中添加虚拟变量的平方项会是多余的。

为了避免这种问题,qeML包提供了qePolyLin()函数,它可以自动处理这些问题。其基本调用形式为:

qePolyLin(data, yName, deg = 2, maxInteractDeg = deg,
   holdout = floor(min(1000,0.1 * nrow(data))))

参数deg是多项式的阶数,maxInteractDeg是最大交互项的阶数;例如,temp × hum在方程式 8.12 中被视为二阶项。当然,如果我们只有一个特征,则没有交互项。

它给出的拟合结果与我们之前手动得到的相同(当然)。我们再次用temp来预测tot

> day1tottemp <- day1[,c(10,16)]
> qepout <- qePolyLin(day1tottemp,'tot',deg=2,holdout=NULL)

让我们来看看得到的估计系数:

> names(qepout)
 [1] "bh"             "deg"            "maxInteractDeg" "modelFormula"
 [5] "XtestFormula"   "retainedNames"  "standardize"    "x"
 [9] "y"              "classif"        "trainRow1"
> qepout$bh
              [,1]
[1,] 1305.597268
[2,]  346.421623
[3,]   -6.815313

预测仍然像往常一样,例如,预测 12 度天的情况:

> predict(qepout,data.frame(temp=12))
[1] 4481.252

让我们看看二次模型是否能做出更好的预测:

> set.seed(9999)
> qePolyLin(day1tottemp,'tot',deg=2,holdout=100)$testAcc
[1] 1214.063
> set.seed(9999)  # to get the same holdout set
> qeLin(day1tottemp,'tot',holdout=100)$testAcc
[1] 1250.177

是的,二次模型的 MAPE 值较小,尽管一如既往,必须补充说明我们应该使用replicMeans()来确保(参见第 3.2.2 节)。

8.11.3 多项式逻辑回归

回忆一下,logit 模型从特征的线性组合开始,然后通过逻辑函数 l(t) = 1/(1 + e^(−t))将其挤压到[0,1]之间。这意味着我们可以像线性模型那样添加多项式项。regtools包中的qePolyLog()函数可以做到这一点。

8.11.4 示例:程序员与工程师薪资

让我们先应用非多项式 logit 模型来预测职业:

> data(pef)
> set.seed(9999)
> qeLogit(pef,'occ')$testAcc
[1] 0.646

约 35%的准确率,考虑到有 6 个类别,这还算不错。但也许二次模型——即添加收入和年龄的平方等项——会有所改善。我们来看看:

> set.seed(9999)
> qePolyLog(pef,'occ',2)$testAcc
[1] 0.619

这是一个轻微的改进。但这是否是一个采样意外?我们可以使用qeCompare()函数来比较不同的多项式阶数,同时使用多个保留集来解决采样问题(参见第 8.13 节)。

8.12 将线性模型与其他方法结合

问题往往出现在 k-NN 模型的训练数据集边缘。作为一个简单的具体例子,我们再考虑一下预测从第 1.8 节中获取的美国职业棒球大联盟球员数据中身高与体重的关系。

这是数据的总结:

> table(mlb$Height)

 67  68  69  70  71  72  73  74  75  76  77  78  79  80  81  82  83
  2   7  19  51  89 150 162 173 155 101  55  27  14   5   2   2   1

假设我们希望预测一个新球员的体重,他的身高为 68.2,并且使用 k-NN 方法。这个身高处于我们训练数据的较低端,所以大多数最近邻的身高会比这个球员高。身高较高的人往往也较重,而我们新点的数据集中的邻居大多会比这个新球员高,因此也很可能更重。结果是我们的预测将会有偏向,通常会预测比这个球员实际体重大。类似地,如果我们新案例的身高是 81.5,那么我们的预测将会有向下的偏差。

一种解决方法是在邻域内拟合一个线性模型。假设我们正在预测一个新的数据点 x,并使用 k = 25 个邻居。然后,我们不是对这 25 个邻近的玩家的权重取平均值,而是对该邻域数据调用 lm()。然后,我们根据 lm() 的输出预测 x。这个过程的线性性质将使得在数据边缘的预测更加真实。

关键是,代替在邻域内使用均值平滑数据,我们可以使用 lm()。或者,如果我们担心异常值的影响,可以尝试使用 median()

regtools 函数 kNN()(由 qeKNN() 封装)中有一个名为 smoothingFtn 的参数,允许我们指定除常规均值外的某种平滑方法。默认值是 smoothingFtn = mean;若要使用中位数平滑,可以指定 smoothingFtn = median。对于线性平滑,我们使用 smoothingFtn = loclin

回想一下,决策树(因此也包括随机森林和基于树的提升方法)也会形成邻域。因此,它们也会面临相同的问题——即,在数据边缘附近的邻域存在偏差。因此,可以在这里应用相同的局部线性思想。CRAN 包 grf 就是这么做的;它被 qeRFgrf() 封装。

8.13 qeCompare() 函数

在我们 8.11.4 节的实验中,二次模型确实有所帮助,具有略低于普通对数几率模型的 OME 值。一次全面的调查将涉及 fineTuning(),进行交叉验证试验,可能还需要探索其他次数的多项式模型,而不仅限于 1 次或 2 次。

但记住,“qe”代表“快速和简便”。qeML 函数 qeCompare() 可以用于模型之间的快速比较。(当然,对于大型数据集,它可能不会那么快速。)

让我们用它来比较普通的逻辑回归与二次版本的逻辑回归,使用脊椎数据。顺便比较一下我们到目前为止在书中提到的其他方法。

> qeCompare(vert,'V7',c('qeLogit','qePolyLog','qeKNN','qeRF','qeGBoost'),100)
      qeFtn    meanAcc
1   qeLogit 0.13677419
2 qePolyLog 0.09129032
3     qeKNN 0.23741935
4      qeRF 0.15741935
5  qeGBoost 0.16322581

这里发生了什么?

  • 在这里,我们生成了 100 个随机的保留集(每个大小为 73,这是该数据集的默认值)。所有方法使用相同的保留集。(qeCompare() 函数有一个可选的随机数 seed 参数,但我们使用了默认值 9999。)

  • 我们在每个函数中都使用了默认的超参数。qeCompare() 函数有一个可选的 opts 参数,用于设置非默认值,比如 qeRF()nTree

  • 我们找到了每种方法的 OME 值。

二次对数几率模型不仅优于普通的对数几率模型,而且最终证明它是所有方法中表现最好的!是的,它优于复杂的机器学习方法。当然,每个方法都是使用默认的超参数运行的,使用其他参数值时,结果可能会有所不同。

8.13.1 关于多项式模型的谨慎使用

多项式的次数是一个超参数。在我们 8.11.4 节的职业预测示例中,我们可能会尝试一个三次(3 次)模型。

qePolyLog(pef,'occ',3)

或者甚至将次数设置为 4、5 等。

然而,随着度数的增加,我们确实需要关注过拟合问题,因为随着度数的增加,项数会迅速增加。我们可以通过查看不同度数下每个模型中β系数的数量来说明这一点,如表 8-1 所示。这需要深入挖掘输出对象。虽然在此解释会引起分心,但对于有兴趣的读者,可以查看组件$glmOuts[[1]]。然后需要排除截距项。

表 8-1: 多项式模型的复杂度

度数 系数
1 6
2 22
3 50
4 90
5 143

请记住,这些是添加了多项式项后的p值,也就是我们的特征数量。我们的样本大小n仍然是 20,090。随着度数的增加,在某个点上我们会出现过拟合。

如果我们遵循一个大致的经验法则,要求Image,这就建议度数的限制大约是p < 141,对应使用最多度数为 4 的模型。但毕竟,这只是一个经验法则。也有可能,在某些情况下,比如说,OME 在度数为 2 的模型后就开始增加。建议读者在此数据集及其他数据集上尝试不同度数的多项式,并注意结果中的 OME 值。

我们注意到以下几点:

  • 多项式模型可能能够与更深奥的机器学习对手竞争,甚至超越它们。

  • 多项式模型的使用具有吸引力,因为它只有一个超参数(度数),并且在qePolyLin()的情况下,计算是非迭代的,因此不会有收敛问题。

  • 和任何机器学习方法一样,我们必须时刻记住过拟合的可能性。

不幸的是,许多机器学习的研究忽视了多项式模型。但它们可以非常强大,绝对应该成为分析师工具包的一部分。

8.14 接下来做什么

线性模型是最古老的机器学习形式。正如我们所见,它仍然可以非常强大,在某些情况下甚至优于“现代”机器学习方法。但在某些设置下,通过“收缩”来改进它,奇怪的是!Image。这是下一章的主题。

第九章:切割事物以适应大小:正则化**

Image

许多现代统计方法将其经典对手进行“缩小”。这对于机器学习方法也适用。特别是,这一原则可以应用于:

  • 提升方法(在第 6.3.8 节中讲解)

  • 线性模型

  • 支持向量机

  • 神经网络

在本章中,我们将看到为什么这样做可能是有利的,并将其应用于线性模型的情况。这也为后续章节中关于支持向量机和神经网络的内容打下基础。

9.1 动机

假设我们有关于人类身高、体重和年龄的样本数据。我们将这些量的人群均值分别表示为μ[ht]、μ[wt]和μ[age]。我们从样本数据中估算它们,得到相应的样本均值,ImageImage

然后,我们添加一些符号,将这些量组合成向量

Image

并且

Image

神奇的是,James−Stein 理论表示,μ的最佳估计值可能并不是Image。它可能是Image的一个缩小版,比如,0.9×Image

Image

并且,维度越高(这里是 3),需要进行的缩小就越多。

直觉是这样的:对于许多样本,有一些数据点在分布的边缘极端。这些点会使我们的估计值偏向过大。所以,最优的做法是缩小估计值。

注意,通常,向量的不同分量会被不同程度地缩小。与方程 9.3 不同,最佳估计量可能是:

Image

在这个例子中,第二个分量实际上扩展了而不是缩小。缩小是指向量的整体大小(将在下一节定义),而不是单个分量。

应该进行多少缩小?在实际操作中,这通常通过我们常用的交叉验证方法来决定。

抛开数学理论不谈——它相当深奥——对我们本书的意义在于,例如,线性模型中人口系数向量β的最小二乘估计量Image通常过大,应当进行缩小。最有趣的是,这恰好是解决过拟合的一个可能方法

9.2 向量的大小

向量(15.2,3.0,−6.8)“大”吗?我们到底是什么意思它的大小呢?

有两个主要的度量,称为[1]和[2],它们通过“范数”符号表示,即|| ||(两对竖线)。所以这两个范数分别表示为|| ||[1]和|| ||[2]。对于上面的例子,[1]范数是

Image

即,向量元素的绝对值之和。这里是[2]的情况:

Image

这是向量元素平方和的平方根。(记得几何的读者可能会注意到,在二维情况下,这仅仅是直角三角形斜边的长度——著名的毕达哥拉斯定理。)

9.3 岭回归与 LASSO

多年来,詹姆斯-斯坦因理论主要是一个数学上的好奇,适合理论研究,但对主流数据分析没有影响。虽然曾有人使用过岭回归,下面将介绍,但即便如此,也仅限于有限的使用。重大变化来自最小绝对收缩与选择算子(LASSO)的开发及其在机器学习社区中的采用。

9.3.1 它们是如何工作的

回顾一下最小二乘法在线性模型中的基本概念,例如,假设有一个特征:我们选择来最小化平方预测误差的和,如方程 8.10 所示。为了方便,这里是该表达式的副本:

岭回归的思想是通过添加向量大小限制来“加上阻尼”。我们现在最小化方程 9.7,满足以下约束

这里的η > 0 是一个由用户设置的超参数,例如通过交叉验证设置。最小化的的值即为岭回归系数。

这里是这种方法背后的直觉。我们基本上是说,我们希望最小化平方和,就像以前一样,但是不允许变得太大。这是在一方面良好预测Y**[i]和另一方面限制大小之间的折中。(我们希望这种收缩会提高我们对未来情况的预测。)超参数η控制这种权衡。

可以证明,这个约束最小化问题等价于选择,使得最小化以下量:

这里的λ > 0 是一个超参数,替代了η,并且通常通过交叉验证来设置。

这个公式(方程 9.9)实际上是岭回归的标准定义。η版本在詹姆斯-斯坦因的背景下更容易解释,但这个λ的公式也应该是直观的:“惩罚”项使我们在最小化平方和时受到限制。我们设置λ的值越大,惩罚越大,从而迫使我们限制的大小。

LASSO 版本几乎与岭回归相同,只不过使用了[1]“阻尼”项,而不是[2]。它找到的的值使得

η的情况下,对于 LASSO,我们进行最小化

受限于:

Image

9.3.2 偏差-方差权衡,避免过拟合

收缩思想——通常称为正则化——对统计学和机器学习产生如此巨大影响的一个主要原因是,它是避免过拟合的工具。这里有几个问题:

  • 一方面,我们希望尽可能使预测的平方和最小,这可以证明消除了偏差。

  • 另一方面,回想一下第 8.8 节中提到的,平方和可能过于乐观,因此可能小于我们在预测未来新案例时得到的结果。平方和的一个小值可能伴随着较大的方差,部分原因是极端数据点的影响,如前所述。收缩可以减少方差——较小的量变化较少——从而在一定程度上中和极端点的有害影响。

因此,超参数λ用于控制我们在偏差-方差权衡中希望处于哪个位置。过拟合发生在我们位于该权衡的错误一侧时。

结论:收缩减少了方差,如果可以在不显著增加偏差的情况下做到这一点,那么就是一个胜利。

再次强调,正则化不仅在本章讨论的线性模型中使用,也在支持向量机、神经网络等中使用。它甚至可以应用于主成分分析。

9.3.3 λ, n 和 p 之间的关系

再次强调,偏差-方差权衡概念在这里扮演着核心角色,且对数据集大小有影响。n(即样本量)越大,Image的方差越小,这意味着收缩的需求越小。

换句话说,对于大型数据集,我们可能不需要正则化。但回想一下第三章中提到的,“大n”既是绝对意义上的大,也相对于p而言——例如,按照方程 3.2 中的标准。所以,如果有大量特征,即使是非常大的数据集,也可能仍然需要正则化。

无论如何,确定是否在特定情境中需要收缩的最可靠方法是再次通过交叉验证来尝试。

9.3.4 比较,岭回归与 LASSO

岭回归的优势在于其计算简单。有一个明确的闭式解——也就是说,它是非迭代的;而 LASSO 则需要迭代计算(尽管它没有收敛问题)。

但 LASSO 成功的原因在于它提供了一个稀疏解,这意味着通常许多Image的元素是 0。我们将η设得越小,0 的数量就越多。然后,我们丢弃那些Image = 0 的特征,从而实现降维。需要注意的是,当然,最终非零的Image值与相应的 OLS 值是不同的。

9.4 软件

我们将再次使用一个qe*系列的函数,qeLASSO(),并使用以下调用形式:

qeLASSO(data,yName,alpha = 1,
   holdout = floor(min(1000, 0.1 * nrow(data))))

这个函数在glmnet包中包装了cv.glmnet()。该包允许用户通过参数alpha指定岭回归或 LASSO,分别将该值设置为 0 或 1;默认值是 LASSO。用户还可以将alpha设置为中间值,结合这两种方法,这被称为弹性网

cv.glmnet()算法将从一个非常大的λ值开始,然后逐渐减少λ。这相当于从一个非常小的η值开始,然后逐步增加它。由于一个非常小的η值意味着不允许任何特征,逐步增加它意味着我们开始添加特征。整个过程是按顺序添加每个特征的。算法在每个步骤上计算 MSPE 或 OME,并使用其内置的交叉验证。qeLASSO()包装器的返回值实际上是cv.glmnet()返回的对象,带有一些附加组件,如testAcc

该对象将包含每个λ值运行时的一组结果。因此,每个λ值将有一个 Image 向量。然而,当我们进行后续预测时,代码使用的是具有最小平均交叉验证预测误差的特定λ值。

9.5 示例:纽约市出租车数据

让我们回到第 5.3 节中的纽约市出租车数据。

> yellout <- qeLASSO(yell10k,'tripTime')
> yellout$testAcc
[1] 258.4983
> yellout$baseAcc
[1] 442.4428

我们看到这些特征在预测中确实很有帮助,相较于仅使用整体均值进行预测,它大大降低了 MAPE。

记住,LASSO 通常会产生一个稀疏的 Image,这意味着大多数系数都是 0。通过这种方式,LASSO 不仅可以用于预测模型本身,还可以用于降维。让我们通过检查输出中的coefs组件来探索一下出租车数据。

首先要注意,像往常一样,作为 R 因子的特征会转换为虚拟变量。那么有多少个呢?

> length(yellout$coefs)
[1] 475

考虑到原始数据集只有 5 个特征,475 个特征其实已经很多了!但请记住,我们的两个特征是接送地点,这些地点有数百个,因此有数百个虚拟变量。

好的,哪些系数是非零的呢?

> yellout$coefs
475 x 1 sparse Matrix of class "dgCMatrix"
                           1
(Intercept)       401.500380
passenger_count     .
trip_distance     128.666529
PULocationID.1      .
PULocationID.3      .
PULocationID.4      .
PULocationID.7      .
PULocationID.8      .
...
PULocationID.130    .
PULocationID.131    .
PULocationID.132 -263.807074
PULocationID.133    .
...
PULocationID.263    .
PULocationID.264    .
DOLocationID.1     -4.005357
DOLocationID.3      .
...
DOLocationID.262    .
DOLocationID.263    .
DOLocationID.264    .
PUweekday           3.030196
> sum(yellout$coefs != 0)
[1] 11

只有 11 个系数是非零的,包括接送地点 132 和 1。那是相当出色的降维效果。

9.6 示例:Airbnb 数据

让我们重新审视在第 8.4.3 节中分析的 Airbnb 数据集,我们正在预测月租。

> Abb$square_feet <- NULL
> Abb$weekly_price <- NULL
> Abb <- na.exclude(Abb)
> z <- qeLASSO(Abb,'monthly_price',holdout=NULL)

qeLASSO()函数包装了cv.glmnet()。后者有一个通用的plot()函数,我们可以在这里访问:

> plot(z)

如图 9-1 所示的图表展示了经典的偏差-方差权衡,其形状本质上是 U 形的。当λ增加时(右侧的横坐标轴;使用了对数),非零系数的数量(上方横坐标轴)减少。起初,这会减少 MSPE。然而,在大约 26 个非零系数时,这个数量开始上升。从偏差-方差的角度来看,增加λ显著降低了方差,而偏差几乎没有增加。但在达到 26 个特征后,偏差成为主导因素。

无论如何,使用 26 个特征,相当于λe⁴ = 53.9,似乎是最好的选择,能够显著提高预测精度。(标准误差也显示在曲线上下的垂直条中。)

图片

图 9-1:MSPE,Airbnb 数据

让我们尝试预测,比如从我们的数据中取第 18 行,并将保证金改为 360 美元,评分改为 92。那么我们预测的租金值是多少?

> x18 <- Abb[18,-4]
> x18[4] <- 360
> x18[8] <- 92
> predict(z,x18)
            1
[1,] 3750.618

我们的收缩方法与第 8.4.4 节中 OLS 输出相比,改变了多少系数?例如,在 OLS 模型中,居住在 94123 邮政编码的估计平均溢价为 1,639.61 美元。那么,使用 LASSO 后是多少呢?

> z$coefs
...
zipcode.94118           .
zipcode.94121           .
zipcode.94122           .
zipcode.94123         698.6044574
zipcode.94124           .
zipcode.94127           .
...

啊,结果确实进行了收缩。另一方面,LASSO 收缩的是向量,而不一定是单独的元素,某些元素甚至可能稍微增加。当然,许多元素确实被收缩到了 0。

回顾一下这个过程是如何工作的:它一开始模型中没有任何特征,这对应着一个非常大的λ值。在每一步中,λ会被减少,这可能导致我们获取一个新的特征。我们还可以查看特征引入模型的顺序:

> z$whenEntered
            bedrooms      guests_included     security_deposit
                   2                   10                   13
       zipcode.94112 review_scores_rating        zipcode.94123
                  15                   16                   17
       zipcode.94133        zipcode.94105        zipcode.94117
                  22                   24                   24
       zipcode.94124        zipcode.94127        zipcode.94114
                  24                   25                   26
       zipcode.94111        zipcode.94131            bathrooms
                  27                   27                   27
       zipcode.94110        zipcode.94116        zipcode.94102
                  28                   29                   30
       zipcode.94108        zipcode.94122        zipcode.94118
                  30                   30                   31
       zipcode.94132        zipcode.94107        zipcode.94121
                  31                   33                   34
       zipcode.94158        zipcode.94115        zipcode.94134
                  35                   37                   37
       zipcode.94104        zipcode.94109       minimum_nights
                  44                   56                   57
      maximum_nights
                  57

不太令人惊讶的是,过程选择的第一个特征是卧室数量。但也许不太直观的是,过程选择的第二个特征是关于客人的虚拟变量。我们上面举的例子,94123 邮政编码的虚拟变量,在第 17 步时被选择。可以将这个顺序视为对每个选定特征重要性的报告。

注意

由于本书的重点是从数据中进行预测,而不是对数据进行描述,因此我们之前没有讨论特征重要性的问题。我们这里只是将其作为理解 LASSO 如何工作的辅助工具。不过,某些在本书中使用的软件包中提供了这个功能。例如,参见randomForests包中的importance()函数。

9.7 示例:非洲土壤数据

如第 6.2.4 节所述,非洲土壤数据集的重要性在于它具有p > n,特征的数量几乎是数据点数量的三倍。这被认为是一个非常困难的情况。

记住,对于许多分析师来说,LASSO 的精髓就在于降维,所以看到 LASSO 如何处理这些数据将是非常有趣的。

9.7.1 LASSO 分析

同样,我们将预测土壤酸度,pH 值:

> afrsoil1 <- afrsoil[,c(1:3578,3597)]
> z <- qeLASSO(afrsoil1,'pH',holdout=NULL)

输出的nzero组件告诉我们每一步过程中选择了多少特征:

> z$nzero
 s0  s1  s2  s3  s4  s5  s6  s7  s8  s9 s10 s11 s12 s13 s14 s15 s16 s17 s18 s19
  0   2   2   2   4   4   5   5   6   6   6   7   8   8  10  12  17  16  14  13
s20 s21 s22 s23 s24 s25 s26 s27 s28 s29 s30 s31 s32 s33 s34 s35 s36 s37 s38 s39
 13  13  12  15  15  15  15  12  13  13  13  13  13  14  16  15  16  16  17  18
s40 s41 s42 s43 s44 s45 s46 s47 s48 s49 s50 s51 s52 s53 s54 s55 s56 s57 s58 s59
 21  21  22  22  24  24  24  24  40  25  26  28  35  44  35  41  40  42  44  42
s60 s61 s62 s63 s64 s65 s66 s67 s68 s69 s70 s71 s72 s73 s74 s75 s76 s77 s78 s79
 43  56  50  70  61  66  64  62  57  58  64  73  79  84  85  85  97  97 102  82
s80 s81 s82 s83 s84 s85 s86 s87 s88 s89 s90 s91 s92 s93 s94 s95 s96 s97 s98 s99
 85  80  81  70  58  77  83  80  77  80  82  93  99  86 141 131 177 140 142 156

lambda组件给出了相应的λ值:

> z$lambda
  [1] 0.342642919 0.327069269 0.312203466 0.298013337 0.284468171 0.271538653
  [7] 0.259196803 0.247415908 0.236170473 0.225436161 0.215189739 0.205409033
 [13] 0.196072876 0.187161061 0.178654302 0.170534188 0.162783146 0.155384401
 [19] 0.148321940 0.141580479 0.135145428 0.129002860 0.123139480 0.117542601
 [25] 0.112200108 0.107100440 0.102232560 0.097585932 0.093150501 0.088916667
 [31] 0.084875267 0.081017555 0.077335183 0.073820179 0.070464938 0.067262198
 [37] 0.064205027 0.061286810 0.058501230 0.055842258 0.053304142 0.050881386
 [43] 0.048568749 0.046361224 0.044254035 0.042242621 0.040322628 0.038489903
 [49] 0.036740477 0.035070566 0.033476554 0.031954993 0.030502590 0.029116200
 [55] 0.027792824 0.026529597 0.025323786 0.024172781 0.023074090 0.022025337
 [61] 0.021024252 0.020068667 0.019156515 0.018285822 0.017454703 0.016661360
 [67] 0.015904076 0.015181211 0.014491201 0.013832554 0.013203843 0.012603708
 [73] 0.012030850 0.011484029 0.010962062 0.010463820 0.009988223 0.009534243
 [79] 0.009100897 0.008687247 0.008292398 0.007915496 0.007555724 0.007212305
 [85] 0.006884495 0.006571584 0.006272895 0.005987782 0.005715628 0.005455844
 [91] 0.005207868 0.004971162 0.004745215 0.004529538 0.004323663 0.004127146
 [97] 0.003939561 0.003760502 0.003589581 0.003426429

对应的图形显示在图 9-2 中。

Image

图 9-2:非洲土壤数据

这里我们得到的是一个相当不完整的结果。最小的 MSPE 来自软件尝试的最小λ值(0.003426429),但曲线似乎暗示即使更小的值也会表现得更好。因此,我们可能会使用一组自定义的λ值重新运行,而不是使用默认的值序列。

尽管如此,即使我们选择了λ = 0.003426429,这个值也已经相当不错了。LASSO 从原始的 3,578 个特征中保留了 156 个。这是一个相当大的降维。

9.8 可选部分:著名的 LASSO 图

本部分包含更多的数学内容,可以安全跳过,因为它在后续部分并不使用。然而,对为什么 LASSO 保留了一些原始特征并排除其他特征感兴趣的读者,可能会发现这一部分内容有帮助。

如前所述,LASSO 的一个关键特性是它通常提供稀疏的解决方案,表示许多Image值为 0。换句话说,许多特征被丢弃,从而提供了一种降维的方法。图 9-3 展示了原因。其工作原理如下:

Image

图 9-3:LASSO 的特征子集性质

图 9-3 展示的是p = 2 个预测变量的情况,它们的系数分别是b[1]和b[2]。(为简便起见,我们假设没有常数项b[0]。)让UV分别表示相应的特征。将b = (b[1], b[2])表示为b[i]*的向量。

如果没有收缩,我们会选择b来最小化平方误差的和:

Image

横轴和纵轴分别表示b[1]和b[2],如图所示。关键点是,对于我们在方程 9.13 中为 SSE 设定的任何值,解出方程的(b[1], b[2])点都会形成一个椭圆。LASSO 计算出的(b[1], b[2])值只是给定椭圆中的一个点;许多其他(b[1], b[2])值也能得到相同的 SSE。

当我们改变 SSE 值时,我们会得到各种同心椭圆,其中两个在图 9-3 中展示。较大的 SSE 值对应于较大的椭圆。

现在,当我们给 LASSO 算法一个λη的值时,会发生什么呢?如前所述,可以使用任一数量,但我们假设使用后者会更容易。那么,当我们给 LASSO 算法一个η的值时,它会做什么呢?

  • 该算法将最小化 SSE,同时满足约束条件:

    Image

    让我们用 SSE[alg]表示 SSE 的最小值,并用对应的(b[1], b[2])值表示(b[1], b[2])[alg]

  • 一方面,点 (b[1], b[2])[alg] 将位于与 SSE[alg] 相关的椭圆上。

  • 另一方面,公式 9.14 表明,(b[1], b[2])[alg] 必须位于图中的菱形内部,菱形的角落坐标为(η, 0)、(0, η),依此类推。

  • 因此,(b[1], b[2])[alg] 必须位于与菱形相交的椭圆上。

  • 但请记住,我们希望 SSE 尽可能小,同时满足公式 9.14。还要记住,较小的 SSE 值对应着较小的椭圆。因此,SSE[alg] 的椭圆必须刚好接触到菱形,如图 9-3 中的外椭圆所示。

  • 在图中,“刚好接触”点位于菱形的一个角落。每个角落的 b[1] 或 b[2] 都等于 0——这就是稀疏性!

  • 那种稀疏性是巧合吗?不是!原因如下:根据输入数据的相对值(U**[i], V**[i]),图中的椭圆会有不同的方向。图中的椭圆大约指向“西北和东南”。但通过检查可以明显看出,大多数方向都会导致接触点位于菱形的一个角落,从而产生稀疏解。

因此,LASSO 通常会产生稀疏解,这也是它受欢迎的主要原因。那么岭回归呢?在这种情况下,菱形变成了一个圆形,因此没有稀疏性。

9.9 即将到来

接下来,我们采用一种完全不同的方法。使用 k-NN 和决策树时,没有使用线性关系,随后这个属性被显式假设。在第四部分中,我们介绍了在间接使用线性关系的情况下的方法。

第四部分:基于分割线和超平面的方法

我们到目前为止看到的方法是由统计学家开发的,或者在提升方法的情况下,由统计学家和机器学习(ML)研究人员共同开发的。在这一部分,我们讨论的两种方法——支持向量机和神经网络——完全起源于机器学习领域。

这两种方法主要用于分类应用。支持向量机基于在二维空间(即两个特征)或者超平面(三个或更多特征)中存在一条(大部分情况下)将数据按类别分开的线。这些线或超平面中的系数将像线性模型中的系数一样工作,尽管方式更复杂。通过最小化一个类似于线性模型的和来获得这些系数,尽管同样更复杂。

神经网络也会具有这些相似性。使用最流行的激活函数ReLU()时,特征空间也被分割成由超平面定义的部分(尽管原始目标并没有以这些术语表达),同样地,一定的和被最小化。

第十章:边界方法:支持向量机(SVM)**

图片

支持向量机(SVM),与神经网络(NN)一起,被认为是最“纯粹”的机器学习方法之一,最初受到人工智能的启发——即非统计概念。我们将在本章讨论 SVM,在下一章讨论神经网络。SVM 最为人熟知的是其分类应用。虽然它们也可以用于回归问题,但我们将重点讨论分类。

请记住,本章的数学内容会比其他章节稍微多一些。然而,为了保持书籍非数学化的精神,方程式会保持在最低限度。SVM 是一个非常强大、通用的方法,理解一些数学内容是时间的绝佳投资。即使是阅读 SVM 软件的文档,也需要对该方法的结构基础有所理解。

10.1 动机

SVM 的所有内容都涉及将一个类与另一个类分开的边界线。为了引出这个概念,我们将首先使用逻辑回归模型进行边界分析,然后再引入 SVM。在本节中,重要的是要记住,我们只是为了引出 SVM 而进行探索。

10.1.1 示例:森林覆盖数据集

让我们回顾一下第 5.4 节中的森林覆盖数据。在这里,我们将构建一个激励图,因此只需要查看数据的一个小子集。首先,为了避免“黑屏问题”(即数据点太多,图形变得杂乱无章),我们将绘制一个随机选择的 500 个数据点的子集。其次,为了保持数据的二维可视化,我们只使用两个特征。

qeML包包含一个数据集forest500,它由原始数据的 500 行随机抽取而来。那么,列呢?我们可以尝试使用第 4.5.1 节中的条件独立特征排序(FOCI)方法:

> data(forest500)
> qeFOCI(forest500,'V55')$sel
    index names
 1:     1    V1
 2:     6    V6
...

我们可能会再次运行该函数,因为其中涉及一些随机性,但我们以上述内容为例,像往常一样,先熟悉一下数据:

> f500 <- forest500[,c(1,6,55)]
> head(f500)
     V1   V6 V55
1: 3438 1033   1
2: 3165 3961   2
3: 3020 5407   2
4: 3244  911   2
5: 2754 1463   2
6: 3008 1275   2
> table(f500$V55)
  1   2   3   4   5   6   7
194 238  29   3   5  10  21

如上所示,数据中有七种封面类型,这意味着这是一个多类问题。这里我们将考虑一个二分类版本,在该版本中,我们希望预测封面类型 1 与其他所有类型的区别。regtools::toSubFactor()函数非常适合这种情况。

> f500$V55 <- regtools::toSubFactor(f500$V55,list('1'))
> head(f500)
     V1   V6      V55
1: 3438 1033        1
2: 3165 3961 zzzOther
3: 3020 5407 zzzOther
4: 3244  911 zzzOther
5: 2754 1463 zzzOther
6: 3008 1275 zzzOther

让我们看看数据的样子:

> plot(f500[,1:2],pch=ifelse(f500[,3] == '1',0,3))

这段代码生成了图 10-1 中所示的图表。

图片

图 10-1:森林覆盖数据

我们想要绘制第 1 列和第 2 列的数据,因此使用表达式f500[,c(1,2)]。但我们希望在视觉上区分两类,比如使用方形和加号作为符号。在基础 R 图形中,绘图符号通过pch(点字符)参数来指定,事实证明数字编码分别是 0 和 3。^(1) 方形代表封面类型 1 的点,而加号代表非类型 1 的点。

在图表中似乎没有明显的趋势(即没有两组分离的趋势)。我们看到图表中到处都是方块和加号。然而,加号似乎更多集中在左侧和上方,而方块则更多集中在右侧。

我们希望在图 10-1 中绘制一条线,使得大多数加号位于线的一侧,大多数方块位于另一侧。读者可以偷看一下图 10-2 来了解我们要走的方向。那么,这条线是从哪里来的呢?实际上,可以在这里使用 logit 模型。这应该不会太令人惊讶,因为你会记得 logit 模型在其核心具有线性形式。

下面是如何绘制这样一条线的步骤。让我们拟合模型:

> w <- qeLogit(f500,'V55',holdout=NULL,yesYVal='1')

通常,在预测上下文中我们不关心估计的逻辑模型系数 Image。然而,在这里,我们希望使用这些系数在图 10-1 中绘制分隔线。我们如何从输出对象w中获取这些系数呢?

回想一下第 8.9.3 节,logit 模型的多类应用使用的是“一个对所有”(OVA)或“所有对所有”(AVA)方法;qeLogit()使用的是 OVA。因此,它对每个类运行一个 logit 模型,并将glm()的输出放在qeLogit()输出的glmOuts组件中。

然而,二分类模型稍微不同。为了避免实际上运行相同的模型两次——对于森林覆盖数据,类型 1 与非类型 1——qeLogit()只运行一次。换句话说,我们将查看w$glmOuts[[1]]

为了获取系数,我们使用coef(),这是另一个通用函数,就像我们之前看到的print()plot()一样。这个函数提取估计的系数:

> cf <- coef(w$glmOuts[[1]])
 (Intercept)           v1           v2
-1.684947e+01  5.389779e-03  1.469269e-05

现在再回忆一下,逻辑模型将线性模型转换为逻辑函数,(t) = 1/(1 + e^(−t));占位符t被设置为线性形式。上述输出给出了一个位置在具有特征值v1 和v6 的情况下,类型 1 覆盖的估计概率:

Image

假设我们根据方程 10.1 中的估计概率是否大于 0.5 来猜测该位置是否有类型 1 覆盖。将该方程设置为 0.5,开始时看起来很复杂,但当我们注意到e⁰ = 1 时,事情就变得简单了。换句话说,如果指数

Image

然后,方程 10.1 的右侧等于 0.5,这正是我们想要的,一个形成决策边界的直线。

所以,公式 10.2 中的线形成了区分预测类型 1 或非类型 1 封面的边界。那就是一条直线的方程,绘制在图 10-2 中。我们通过使用 R 的abline()函数将这条线叠加到图 10-1 上,abline()函数的作用正如其名——即在现有图形中添加一条直线:

# arguments are intercept and slope
> abline(a=-cf[1]/cf[3], b=-cf[2]/cf[3])

结果如图 10-2 所示。它恰好几乎是垂直的,这并不奇怪,因为V6的系数非常小,但这无关紧要。线右侧的数据点被预测为类型 1 封面,左侧的数据点则预测为非类型 1 封面。

Image

图 10-2:带有 logistic 边界线的森林覆盖数据

显然,有相当多的数据点被误分类——也就是说,右边的加号和左边的方块。我们可能通过增加使用的特征数量来减少误分类的点——这里我们只有p = 2 个特征——但仍然会有一些误分类的点。

这激发了 SVM 的基本目标:

我们希望找到一条能够很好地区分类别的直线,然后通过判断新样本落在直线的哪一侧来预测未来的案例。我们的直线通常不会完全将类别分开,所以我们会有一些误分类错误,就像任何机器学习方法一样。但希望精心选择的直线能够为我们提供良好的结果。

p = 3 个特征时,线变成了三维空间中的一个平面,难以直观地理解,如果特征超过三个,那就完全无法可视化。但通过始终牢记二特征情况下的几何解释,我们将能够直观地使用 SVM。

进入细节之前,还有一个问题:为什么不直接使用上述的 logit 方案来创建我们的边界线呢?使用 SVM 生成的线有什么优势吗?答案是,logit 方法非常局限。它规定了回归函数的特定形式,涉及指数函数等内容,如公式 10.1 所示,虽然在某些应用中这可能是一个合理的假设,但在其他情况下则不一定适用。

相比之下,除了隐含假设最佳的类间边界是直线而不是其他曲线(即使这个条件可以被放宽,稍后我们会看到),SVM 没有做出任何假设,因此它更具灵活性,可能会产生更好的拟合效果(就像 k-NN、随机森林等方法一样,这些方法做出的假设更少)。

10.2 直线、平面与超平面

让我们进一步探讨一下这个几何视角。

上述推导在逻辑回归模型中始终成立;Y = 1 与Y = 0 的预测总是归结为计算特征的线性函数。如果我们有p = 2(即两个特征,如v1 和v 6),预测Y = 1 与Y = 0 之间的边界是如下形式的直线

Image

如我们在方程 10.2 中看到的那样。如果p = 3,例如,添加v的第 8 个特征,边界变成平面形式,具体为

Image

如上所述,这很难可视化。同样如前所述,对于p > 3,我们完全无法可视化这个设置。但是我们仍然在处理特征的线性形式,其行为像一条线或一个平面。由于它像平面一样,我们称之为超平面。为了技术准确性,我们将使用缩写 LPH(线/平面/超平面),而不是单纯说“线”,但读者应始终将p = 2 的情况作为直观的指导。

10.3 数学符号

任何关于 SVM 的讨论—包括阅读 SVM 软件的文档—都离不开“点积”符号。尽管这个名字和数学公式可能让人觉得有些吓人,但它只是以更简洁的方式表达事物。我们首先讨论如何将 SVM 符号转换为向量形式,然后引入点积。

10.3.1 向量表达式

如从方程 10.3 和 10.4 中可以看出,LPH 可以通过其系数向量表示——例如,在方程 10.4 中是(c[1], c[2], c[3], c[4])。在 SVM 中,通常以等式为 0 的形式表示,因此,举个例子,我们将方程 10.4 重写为

Image

并设定:

Image

向量w和数字w[0]构成我们对 LPH 的描述。

因此,我们将通过以下方式总结方程 10.2:

Image

10.3.2 点积

SVM 的基础理论广泛使用了微积分和线性代数。如前所述,这些数学内容远超本书的范围。然而,使用其中的一些符号—仅仅是符号,而非概念上的内容,除了少量代数—将是高效且易于理解的。

我们的目标是找到一种简单、紧凑的方法,以确定一个新案例落在边界线或 LPH 的哪一侧,从而可以轻松地预测其类别。

两个向量u = (u[1], . . . , u**[m])和v = (v[1], . . . , v**[m])之间的点积仅仅是乘积的和:

Image

例如,我们可以取方程 10.7 中w与向量(1,−4)的点积:

Image

将方程 10.1 转化为我们新的点积符号会很有帮助:

Image

注意一些代数性质:

  • e⁰ = 1

  • e**^t > 1,当t > 0 时

  • e^(−t) < 1,当t > 0 时

  • Image

因此,在方程 10.10 中,

Image

Image

所以,当面对一个新的预测案例时,我们只需要查看 w • (v1, v6) + w[0] 的符号——正负。如果是正数,那么这个新案例更有可能(概率大于 0.5)属于封面类型 1,而如果是负数,那这个概率小于 0.5。换句话说,如果 w • (v1, v6) + w[0] > 0,我们预测该新案例为封面类型 1,否则预测为非类型 1。

这再次表明,如果我们要预测的新案例位于线的右侧,我们猜测它是封面类型 1;如果位于左侧,则猜测它是非类型 1。而我们的 SVM 边界是使以下式子成立的向量 x

Image

再次强调,这只是符号表示,但在数学中,便捷的符号往往有助于澄清问题。对于 p = 2 个特征的应用,画线是可以可视化的,但如果 p = 3,则画平面就很难可视化;如果 p > 3,则无法可视化。使用点积符号的好处是,我们只需注意 w • (v1, v6) + w[0] 是否为正或负,就能知道该如何预测新案例的类别。

顺便说一下,SVM 的理论学者也喜欢将两个类别编码为Y = +1 和Y = −1,而不是像统计学中标准的那样编码为 1 或 0。我们通常会继续使用后者的编码,但在本章中会转而使用前者。

10.3.3 SVM 作为一个参数模型

我们曾提到线性和逻辑模型是参数化的,因为回归函数被建模为由有限数量的值 β[0], β[1], . . . , β**[p] 确定。这与例如 k-NN 方法相对立,后者并不对回归函数的形式做出任何假设。

方程 10.13 的一个含义是,SVM 也是一个参数模型。与其假设回归函数的参数形式,这里我们假设两个类别之间边界线的参数形式。

10.4 SVM:基本思想——可分离情况

如前所述,我们在图 10-2 中绘制的那条线并没有完全分开两个类别。线的两边都有加号和方框。这是典型的情况,不仅适用于逻辑回归产生的线,也适用于 SVM——我们在此关注的内容。然而,如果我们首先考虑两个类别可以干净地分开的数据集,那么 SVM 方法更容易解释,因此大多数书籍都会从这个情况开始,正如我们将在这里所做的那样。

为了清晰起见,我们将继续专注于两类问题,就像上面提到的类型 1/非类型 1 封面例子一样。再者,我们将继续讨论 p = 2 特征的情况,其中 LPH 是一条直线。

请注意,我们在此处提到的“数据”将指训练数据。我们为训练数据找到边界线,然后根据该线预测未来的案例。类似地,当我们谈论“数据”的可分性时,我们指的是训练数据。

10.4.1 示例:安德森鸢尾花数据集

埃德加·安德森关于鸢尾花的数据,包含在 R 中,已经成为书籍、网站等中无数示例的主题。数据包含三类:setosaversicolorvirginica

该数据集包含在 R 中:

> head(iris)
  Sepal.Length Sepal.Width Petal.Length Petal.Width Species
1          5.1         3.5          1.4         0.2  setosa
2          4.9         3.0          1.4         0.2  setosa
3          4.7         3.2          1.3         0.2  setosa
4          4.6         3.1          1.5         0.2  setosa
5          5.0         3.6          1.4         0.2  setosa
6          5.4         3.9          1.7         0.4  setosa

请注意,本节中的内容主要是为了激发后续材料的动机。对于日常的 SVM 计算,您将使用 qeSVM()。因此,在这里的示例中,我们进行一些非 SVM 分析以激发动机,我们将省略部分代码和代数。

如前所述,对于这个示例,我们希望数据中两个类别能够被一条直线清晰分隔。如果我们将两个鸢尾花类别取为 setosa 和非 setosa,并选择 Sepal.LengthPetal.Width 列作为特征,那么情况就是这样。

让我们首先绘制数据。

> j2 <- iris[,c(2,4,5)]  # sepal width, petal width, species
# set up species code, 1 for setosa, 0 for nonsetosa
> head(j2)
  Sepal.Width Petal.Width Species
1         3.5         0.2  setosa
2         3.0         0.2  setosa
3         3.2         0.2  setosa
4         3.1         0.2  setosa
5         3.6         0.2  setosa
6         3.9         0.4  setosa
> j2$Species <- toSubFactor(j2$Species,'setosa')
> j2[c(7,77),]
   Sepal.Width Petal.Width  Species
7          3.4         0.3   setosa
77         2.8         1.4 zzzOther
> plot(j2[,1:2],pch=3*as.numeric(j2[,3]))

这将产生图 10-3 中的图形。

Image

图 10-3:Setosa 和非 Setosa

可以很容易地在两个类别之间画一条线——实际上,可以画许多条线。但,哪一条线是最优的呢?

10.4.2 优化准则

选择我们的边界线等同于选择系数向量 ww[0] 项。换句话说,这种情况类似于线性和广义线性模型,在这些模型中,我们选择估计的系数 Image。(实际上,ww[0] 也是估计值,但为了避免混乱,我们不使用帽记号。)现在回想一下,在线性模型中,我们选择系数 Image的方式是通过优化问题:我们最小化某个平方和。

SVM 仍然最小化某个和,但它使用的是与平方误差不同的损失函数。详细说明这一点将使我们深入一些晦涩的数学内容,几乎没有什么实际帮助。幸运的是,数学有一个易于理解的几何版本,我们现在将讨论它。

为此,请查看图 10-4。在这里,我们“圈定”了两个类别(上方是 setosa, 下方是非 setosa),将它们分成了所谓的 凸包。再次说明,这仅仅是为了说明问题;qeSVM() 函数会为我们进行计算(并使用不同的方法),而我们在此示例之后将不再自己计算凸包。因此,我们省略了代码。(可以使用 mvtnorm::chull() 函数。)

Image

图 10-4:SVM 凸包

数学上可以证明,SVM 的边界线恰好位于两个凸包的“中间”。更准确地说,首先找到两个凸包中最接近的两点。我们的边界线就是这两点之间线段的垂直平分线。我们在图 10-5 中绘制了这一点,以及两条相关的虚线,定义了 SVM 拟合的“边际”。

Image

图 10-5:SVM 边际

请注意以下几点,无论是此处还是一般情况:

  • 两条虚线之间的区域称为边际

  • 对于可分数据,边际内不会有数据点。

  • 位于边际上的点称为支持向量(SVM 中的 SV)。对于这个数据集,我们有三个支持向量,一个位于(2.7,1.0)的 setosa 类,另外两个位于(2.3,0.3)和(3.5,0.6)的非 setosa 类。

  • 就* w w [0]而言,w*[0] + wx 的值将是:

    •      0 对于边界上的任何点 x

    •      +1 对于Y = +1 类中的任何支持向量(在此案例中为 setosa)

    •      −1 对于Y = −1 类中的任何支持向量(在此案例中为非 setosa)

    •      > +1 对于Y = +1 类中的任何非支持向量

    •      < −1 对于Y = −1 类中的任何非支持向量

    顺便提一下,查看公式 10.13 可以看到,ww[0] 的值并不是唯一的。比如将它们都乘以 8.8,右侧的结果仍然是 0。因此,约定是选择它们,使得在支持点上,w[0] + wx 的值为+1 或−1。

  • 要预测Y对于一个新案例,其中X = x[new],我们猜测Y为+1 或−1,取决于* w *[0] + wx[new]是大于还是小于 0。请注意,尽管我们假设训练数据在这里是可分的,但新数据点可能会落在边际内。

请记住,拟合 SVM 模型等同于选择* w w *[0]。上面的方案可以证明是最优的。但是等一下——“最优”究竟是什么意思?SVM 中通常使用的最优标准是:

我们选择* w w *[0],使得边际具有最大的可能宽度。

换句话说,SVM 不仅仅是试图将两类数据点分开,还要尽可能地将这两类数据点相对于分界面拉得尽可能远。它在两类之间找到一个“缓冲区”,并最大化这个区域的宽度。如前所述,这个缓冲区被称为边际。

关键思想是,训练集中的大边际意味着两类数据点被很好地分开,这也意味着未来的新案例可能会被正确分类。事实证明,像上述那样使用凸包选择* w w [0](即使p* > 2)确实能够最大化边际。

10.4.2.1 支持向量的意义

支持向量通过“支持”拟合,意思是任何一个支持向量的变化都会改变拟合结果;而其他数据点的变化则不会改变拟合结果(只要它们仍然在凸包内)。当然,我们也可以说,添加新的数据点也不会改变拟合结果,只要这些新点位于凸包内。

支持向量机(SVM)的一个常见优点是(至少在可分情况中)它们往往会产生稀疏的拟合,在这里,稀疏并不意味着w的大多数组件为 0,而是拟合的基本维度较低。从另一个角度看,这个说法是:支持向量越多,过拟合的风险就越大。我认为,这个说法的证据较为薄弱,尽管它可以作为模型拟合过程中的几个指南之一。

但这个好处可能是虚幻的。正如前面所说,稀疏性的来源是w依赖于少数数据点(即支持向量)。但如果某些支持向量是异常值(不代表整体数据)或者甚至是明显错误的呢?许多实际数据集确实存在一些错误。那么我们的拟合结果就会高度依赖一些可疑的数据。

此外,w对少数数据点的高度敏感性导致了较高的抽样变异性;不同的样本可能会有不同的支持向量。换句话说,w具有较高的方差。

10.5 主要问题:缺乏线性可分性

边距,实际上被称为硬边距,只在存在某条线(或 LPH)能干净地分开两类数据点的情况下才定义。正如我们在早期关于森林覆盖数据的图表中看到的那样,在大多数实际情况下,并不存在这样的分隔线。即使是鸢尾花数据,在第一列和第三列中,也没有任何一条线能分开变色鸢尾和维吉尼卡鸢尾:

> plot(iris[,c(1,3)],pch=as.numeric(iris$Species))

请参见图 10-6;加号和三角形没有被干净地分开。

解决这个问题有两种方法:(a) 使用核函数将数据转换为线性可分,或(b) 创建一个软边距,在其中允许一些点位于边距内。通常,这两种方法会结合使用。例如,在进行核转换后,我们仍然可能—实际上,可能会—发现没有能够干净分隔的 LPH,因此我们还需要允许一些异常点位于边距内。然而,异常点越少越好,所以最好结合使用这两种方法,而不是直接采用软边距解决方案。

Image

图 10-6:鸢尾花数据;三类

10.5.1 应用“核函数”

在这里,我们对数据进行转换,比如通过应用多项式转换,然后在新的数据上找到一个 LPH 分隔符。多项式的阶数就是一个超参数。

10.5.1.1 动机示例

为了理解为什么核函数可能有用,我们考虑一个在机器学习展示中常用的例子:“甜甜圈形状”的数据。让我们生成一些数据。(这里的代码相当复杂,可以安全跳过,不影响后续内容。)

# generate 250 pairs of data points centered around (0,0)
> set.seed(9999)
> z <- matrix(rnorm(500),ncol=2)
# form new data by taking only certain points from z
> plus1 <- z[z[,1]² + z[,2]² > 4,]  # outer ring, class +1
> minus1 <- z[z[,1]² + z[,2]² < 2,]  # inner disk, class -1
> plus1 <- cbind(plus1,+1)  # add in Y column
> minus1 <- cbind(minus1,-1)  # add in Y column
> head(plus1)  # take a look
           [,1]       [,2] [,3]
[1,]  2.9038161  0.7172792    1
[2,] -0.4499405  2.0006861    1
[3,]  2.3329026  0.2288606    1
[4,] -0.2989460  2.3790936    1
[5,] -2.0778949  0.1488060    1
[6,] -0.9867098 -2.2020235    1
> head(minus1)
           [,1]       [,2] [,3]
[1,]  1.0840991  0.670507239   -1
[2,]  0.8431089 -0.074557109   -1
[3,] -0.7730161 -0.009357795   -1
[4,]  0.9088839 -1.050183477   -1
[5,] -0.1882887 -1.348365272   -1
[6,]  0.9864382  0.936775923   -1
> pm1 <- rbind(plus1,minus1)  # combine into one dataset
> plot(pm1[,1:2],pch=pm1[,3]+2)  # gives us pluses and circles

数据在图 10-7 中显示。两类数据,分别用加号和圆圈表示,是明显可分的——但通过一个圆而不是一条直线。

Image

图 10-7:“甜甜圈”数据

但我们可以通过添加一个平方项来解决这个问题:

> pm2 <- pm1[,1:2]²  # replace each X value by its square
> pm2 <- cbind(pm2,pm1[,3])  # tack on Y to the new datasets
> plot(pm2[,1:2],pch=pm2[,3]+2)

我们取了原始数据集并对其进行了转换,将每个数据点替换为其平方。这个平方就是我们的新特征,替代了旧的特征。在新数据的图表中,图 10-8,加号和圆圈很容易被一条直线分开。

Image

图 10-8:“甜甜圈”数据,经过转换

这就是 SVM 用户通常做的事情:尝试找到一种数据转换方式,使得转换后的数据能够线性可分,或者至少接近线性可分。当然,SVM 软件为我们完成了所有的工作;我们并不是像上面的示例那样手动转换数据。这个过程是通过核函数完成的,如下一节所示。

10.5.1.2 核函数的概念

在转向实际数据示例之前,我们还需要讨论一个问题。什么是核函数?核函数是将我们的数据进行转换的一种方式,目标是使数据变得可分离。但它比这更具体:它是一个函数K(u, v),其输入是两个向量。

这样做是有道理的,因为我们在第 10.3.2 节中看到,点积在 SVM 中起着关键作用,实际上,它在内部计算中起着更加重要的作用,这部分内容在本书中并未涉及。因此,许多核函数是点积的函数。

一个例子是多项式核函数

Image

dγ是超参数。在二次情况下,d = 2,我们实际上达到了与上一节相同的效果,在上一节中我们将X值平方了。在这里,我们对点积进行平方。

另一个常用的函数是径向基函数(RBF)

Image

这里的γ是一个超参数。

再次强调,像许多机器学习问题一样,问题“哪个核函数最好?”的答案是:“这取决于。”数据集的类型和大小、特征的数量等等,都会导致不同核函数在性能上的差异。

10.5.2 软间隔

如前所述,线性可分的数据是比较罕见的。不可分性才是典型情况。我们如何处理这种情况,即容忍一些点位于间隔内?我们可以考虑两种方法。

10.5.2.1 几何视角

我们可以通过处理简化的凸包来代替计算凸包。再次强调,数学公式超出了本书的范围,但程序的核心是这样的:

  • 我们用缩小版本替换两个原始的凸包。(它们不仅在大小上会变小,而且形状也会趋于“更圆”或更不那么长条形。)

  • 边界将基于缩小的凸包来计算。

  • 然后,按常规进行操作,即使一些训练集数据点会落在边界内。

当然,收缩量始终是一个由用户设置的超参数,可能通过交叉验证来调整。

10.5.2.2 代数视图

几何视图直观易懂,但更常见的方法是通过“成本”来实现。这里有一个成本超参数,通常表示为 C。它的工作原理如下。假设训练集中数据点 iX**[i],即该数据点的特征向量,Y**[i] 是类标签,要么是 +1,要么是 −1。

在可分情况中,回想一下,对于位于边界上的数据点,w[0] + wX[i] 等于 +1 或 −1。对于边界外的点,

Image

取决于 Y[i] 是 +1 还是 −1。但有一个巧妙的技巧,可以更简洁地陈述这个要求:

Image

在软边界的情况下,我们稍微放宽一些,允许差异达到 1.0,例如,

Image

Image

d**[i] 表示数据点 i 的差异,这样在这里 d[3] = 0.12 和 d[8] = −1.71。若数据点 i 没有差异,则设 d**[i] = 0——即如果 Y**[i] (w[0] + wX**[i]) ≥ 1。

注意,如果 0 < d**[i] < 1,则数据点 i 是边界违规点,但它仍位于决策边界的正确侧——即它会被正确分类。但如果 d**[i] > 1,点就会位于边界的另一侧,因此会被错误分类。

我们通过规定来控制总的差异量,

Image

其中超参数 C 是我们的“差异预算”。同样,用户设置 C

注意,d**[i] 不是超参数;它们是副产品。用户选择 C。每个潜在的 ww[0] 的值会产生 d**[i]。我们为 C 设置的值越小,位于边界内的数据点数量就越少——但边界会变得更窄。我们希望有一个较宽的边界;SVM 算法会找到使边界宽度最大的 ww[0] 的值,前提是满足约束条件(方程 10.20)。

10.6 示例:森林覆盖数据

让我们试试qeSVM()

> z <- qeSVM(f500,'V55',holdout=NULL)

然后我们可以预测,比如,对于一个与我们数据中案例 8 相似的新案例,但将第二个特征值改为 2,888:

> newx <- f500[8,1:2]
> newx
    V1   V6
8 3085 2977
> newx[2] <- 2888
> predict(z,newx)
$predClasses
[1] "zzzOther"

$probs
          1  zzzOther
8 0.4829782 0.5170218

我们预测非类型 1 的覆盖,虽然它比类型 1 更可能。

我们这里使用的是默认值,其中之一将 kernel 设置为 radial。如果我们只希望使用软间隔(即不进行核转换),我们将 kernel 设置为 linear。超参数 gamma 的默认值是 1.0。一个可选的超参数 cost 是我们在早期讨论软间隔时提到的 C 值。顺便提一下,qeML()svm 封装在著名的 e10171 包中。

10.7 那么,那个核技巧呢?

如果不讨论著名的核技巧,SVM 的介绍就不完整,因为它在很大程度上促进了 SVM 的成功。像往常一样,我们不会深入讨论数学细节——假设读者没有强烈的兴趣了解再生核希尔伯特空间——但是该原理本身具有重要的实际意义。

为了说明这一点,让我们了解一下在没有使用核函数的情况下,通过多项式转换时,我们的数据集可能会扩大到多大。我们在转换后的数据中的大小度量将是列的数量。

让我们再次使用森林覆盖数据,结合 polyreg 包,后者用于我们的计数数据列。(polyreg中的getPoly()函数由我们的qe系列中的多项式模型使用。)

> gpout <- polyreg::getPoly(forest500,2)  # input with n = 500, p = 54
P > N. With polynomial terms and interactions, P is 1564.

原始数据集只有 54 个特征,但在新的形式下,我们有超过 1,500 个特征!如果 getPoly() 没有避免创建重复项——例如,虚拟变量的平方——我们可能会有更多的特征。

因此,新的 p 值是 1,564,对于一个仅有 500 行的数据集来说,几乎无法运行。使用原始的数据框,超过 580,000 行数据,新的数据大小将是 581,012 × 1,564 = 908,702,768 个元素。每个元素 8 字节,这意味着需要超过 7GB 的内存!而且不仅是空间,时间也会成为问题——代码将永远运行下去。

这只是对于 2 次方的情况。试想一下 3 次多项式,依此类推!(3 次方会生成 16,897 列。)因此,某种快捷方式是迫切需要的。核技巧来拯救!

关键点是,通过使用方程 10.14 中的核函数,我们可以避免计算和存储那些额外的列。在森林覆盖数据中,我们可以保持原始的 54 个特征——在这个表达式中,向量 uv 每个都有 54 个元素——而不是计算和存储 1,564 个特征。数学上,我们得到的计算结果与将 uv 作为 1,564 元素的向量时相同。

10.8 “警告:已达到最大迭代次数”

与许多其他机器学习方法一样,支持向量机(SVM)的计算是迭代的。然而,不像其他方法,SVM 不应该有收敛问题。搜索空间具有凸性特性,这基本上意味着它是碗状的,因此很容易找到最小值。

然而,这假设存在可以最小化的东西。正如我们在使用软边界或核函数时所看到的,可能不存在一条清晰分隔类别的直线。对于某些特定的成本值和核函数(以及后者的超参数)组合,可能没有解。在这种情况下,我们当然会遇到收敛问题,我们需要尝试其他组合。

10.9 小结

本章比其他章节更具数学性,也许有些抽象。但其实,基本原理是简单的:

  • SVM 主要用于分类问题。通过 OVA 或 AVA 配对,SVM 可以处理任意数量的类别,但为了简化起见,我们在这里假设只有两个类别。

  • 如果我们有 p = 2 个特征,基本目标是找到一条线将两类分开。

  • 对于 p = 3,我们希望在三维空间中找到一个分隔平面。

  • 对于 p > 3 的情况,我们称之为分隔超平面。我们无法直观地看出这些超平面,而是通过与超平面 w 向量的点积来进行分析。为了对一个新的案例进行分类,我们计算该案例的特征向量的点积,加入 w[0],然后根据点积是否大于或小于 0 来决定类别。

  • 我们将一对与分隔 LPH 平行的 LPH 关联起来,从而创建出边界。选择 w 的优化标准是最大化边界的宽度。

  • 通常,类别之间是重叠的,因此不存在分隔 LPH,我们需要采用人工方法来分隔类别。通常有两种方法,通常是组合使用的:

    •      我们可以假设存在一个分隔 LPH,但它是“弯曲的”,而不是直线或平面。我们使用核函数对数据进行变换,尝试至少逼近这种弯曲性。

    •      我们可以根据用户设置的不同程度,允许数据点位于边界内。

如前所述,SVM 是一种比我们之前看到的工具更复杂的工具。但它被广泛使用,且有很多成功案例,因此本章所做的额外努力是非常值得的。

第十一章:类似类固醇的线性模型:神经网络**

Image

神经网络(NNs)可能是公众最熟悉的机器学习技术。这个听起来像科幻小说中的名字非常吸引人——尤其是随着深度学习这一术语的出现——神经网络已成为图像分类的首选方法,应用于公众也感兴趣的领域,如面部识别。

然而,神经网络可能是最具挑战性的机器学习技术之一,使用时会遇到如下问题:

  • “黑箱”操作,内部发生了什么并不明确

  • 需要调优的超参数众多

  • 过拟合的倾向

  • 可能需要较长的计算时间,在某些大数据案例中,当需要大量内存时,计算可能需要几个小时甚至几天。

  • 收敛问题

让我们看看这到底有什么大惊小怪。

11.1 概述

神经网络这个术语指的是一种受人类思维生物学启发的机器学习方法。例如,在一个二分类问题中,预测变量作为神经元的输入,输出 1 或 0,1 表示神经元激活——我们便决定为类别 1。神经网络由若干个隐藏层组成,其中一个神经层的输出作为输入传递给下一层,以此类推,直到处理过程到达最终的输出层。这个过程也被赋予了生物学解释。术语节点单元神经元是同义的。

该方法后来被推广,使用了激活函数,其输出不仅限于 1 和 0,并允许后续层对前面层进行反向反馈。这使得该领域的发展在某种程度上偏离了生物学的动机,尽管有些人对生物学解释提出质疑,但神经网络(NNs)在机器学习社区中仍具有强大的吸引力。事实上,使用深度学习的广为宣传的大型项目重新激发了人们对神经网络的兴趣。

图 11-1,由neuralnet包在我们的脊椎数据上生成,展示了该方法的工作原理。(我们不会使用该包,但它确实能生成漂亮的展示图。)

Image

图 11-1:具有一个隐藏层的脊椎神经网络

下面是概述:

  • 神经网络由若干个组成(本例中为三个)。

  • 在描述特定网络的图像中,最左侧是输入层(这里是脊椎测量数据),最右侧是输出层,本例中输出的是类别预测。

  • 中间有一个或多个隐藏层,本例中为一个。

  • 一层的输出作为输入传递给下一层。

  • 输出通常是一个单一的数字,回归问题为一个数字,c分类问题则为c个数字。

  • 层的输入通过相当于线性模型的方式传递。层的输出通过一个激活函数传递,这类似于 SVM 中的核函数,用于适应非线性关系。在图 11-1 中,使用的激活函数是我们熟悉的 logit 函数,a(t) = 1/[1 + exp(−t)](尽管是在完全不同的背景下;我们不是在执行逻辑回归)。

那么这一切如何在图 11-1 中展现出来呢?让我们看看图中一些数字。例如,第一圆圈的输入(中间列)是 1.0841 · 1 + 0.84311 V1 + 0.49439 V2 + ……,这是特征的线性组合。不同的线性组合被输入到第二个圆圈。每个圆圈的输出被传递到下一个层。

那么,这些线性组合中的系数(权重)是如何计算的呢?我们在这里省略详细的数学解答,但本质上,我们在回归问题中最小化平方预测误差之和。在分类问题中,我们选择权重来最小化总体的错误分类率,或其变体。

11.2 在复杂基础设施之上工作

在我们开始之前,关于qeNeural(),即我们用于构建神经网络的qe*系列函数,先说几句。

正如我们在本书中反复提到的,qe*系列函数主要是封装器——即,便捷的封装器,封装其他函数。这么做是为了让这个系列能够提供统一、快速且简便的用户界面,支持多种机器学习算法。

例如,我们的qeSVM()函数封装了e1071包中的svm()函数。那么qeNeural()呢?这里有一段有趣的故事!大致发生的事情是:

  • 函数qeNeural()封装了regtools()包中的krsFit()函数。

  • krsFit()函数封装了 R 的keras包中多个用于神经网络的函数。

  • R 的keras包封装了 R 的tensorflow包。

  • R 的tensorflow包封装了同名的 Python 包。

  • 而且,tensorflow的大部分代码实际上是用 C 语言编写的。

而且这一切大多依赖于同名包中的reticulate()函数。它的作用是实现 R 和 Python 之间的转换。

因此,设置这个过程可能有些微妙。有关特定平台的帮助,请参阅 RStudio 网站(例如,https://tensorflow.rstudio.com/tutorials/quickstart/beginner.xhtml)。上述列表中的 R 接口以及reticulate是由 RStudio 开发的。

牢记这些关于软件“二语”特性的要点是很重要的。例如,一个含义是,即使在运行神经网络前调用了set.seed(),你仍然会注意到不同运行之间会有一些变化。如果你不知道 Python 有自己独立的随机数生成器的话,这一点会让你感到困惑!

11.3 示例:脊椎数据

假设我们希望拟合一个模型并进行预测。如前所述,我们将不指定保留集,以便尽可能多的数据用于预测:

> z <- vert[1,-7]  # exclude "Y", which we are predicting
> nnout <- qeNeural(vert,'V7',holdout=NULL)
Epoch 1/30
2/2 [==============================] - 0s 62ms/step - loss: 1.0794 - accuracy: 0
.4274 - val_loss: 1.2847 - val_accuracy: 0.0000e+00
Epoch 2/30
2/2 [==============================] - 0s 19ms/step - loss: 0.9832 - accuracy: 0
.6048 - val_loss: 1.3886 - val_accuracy: 0.0000e+00
...

拟合过程是迭代的,并且每次迭代或周期都会给出报告。周期数是一个超参数。这个以及其他超参数将在下一节中讨论。

作为预测的一个例子,考虑一个与数据中第一个病人相似的病人,但其 V2 为 18 而不是 22.55。我们预测的类别会是什么?

> z$V2 <- 18
> predict(nnout,z)
$predClasses
[1] "DH"

我们预测的是 DH 类。

11.4 神经网络超参数

神经网络库以拥有大量超参数而著名。我们的 qeNeural() 函数经过设计,避免了这一点,只有少量的超参数。调用形式是:

qeNeural(data,yName,
   hidden=c(100,100),
   nEpoch=30,
   acts=rep("relu", length(hidden)),
   learnRate=0.001,
   conv = NULL, xShape = NULL,
   holdout=floor(min(1000,0.1*nrow(data)))
)

下面是与神经网络特定参数相关的说明:

hidden 指定隐藏层的数量以及每层的单元数量(每层的数量不必相同)。默认设置意味着两层隐藏层,每层有 100 个单元。如果这个向量中的某个数值是小数,则表示丢弃,下面将进行讨论。

nEpoch 指定周期数。

acts 指定激活函数,每个隐藏层有一个激活函数。

learnRate 与我们在梯度提升中看到的非常相似(见第 6.3.8 节)。

conv,xShape 在图像分类设置中使用的参数,将在第十二章中讨论。

分析师可以直接使用 keras 包来进行更详细的控制。这些参数旨在实现以下一个或两个目标:

  • 控制偏差-方差权衡:hiddennEpoch

  • 处理收敛问题:nEpochactslearnRate

读者可能会对上面偏差-方差权衡列表中的周期数(即迭代次数)感到惊讶。在大多数迭代算法中,迭代次数越多越好。但从经验上看,分析师发现,神经网络中迭代次数过多可能会导致过拟合。

11.5 激活函数

如果我们在每一层中简单地输入和输出线性函数,我们将得到线性函数的线性函数的线性函数……,经过多次组合后仍然是线性函数。为了能够建模非线性关系,我们在每一层的输出处放置激活函数a(t)。

多年来,关于激活函数的最佳选择一直存在一些争论。从原则上讲,任何非线性函数都应该有效,但问题确实会出现,尤其是与极其重要的收敛问题相关。

再次考虑图 6-2。在 2.2 附近的最小值出现在一个相当陡峭的下凹处(用微积分术语来说,是一个大的二阶导数)。但如果曲线像图 11-2 中的那样呢?在最小值附近有一个相当浅的槽,假设其范围是−4 到 4。即使使用更大的学习率,我们也可能会在几次迭代中几乎没有进展。这就是梯度消失问题。如果曲线在最小值附近非常陡峭,我们可能会遇到梯度爆炸问题,即使在非常小的学习率下,也会对网络造成严重影响。

Image

图 11-2:浅最小值区域

激活函数的选择在这些事情中起着重要作用。各层之间存在乘法效应。(再说一次,对于懂得微积分的人来说,这是链式法则在起作用。)区间(−1,1)中的量在相乘时会变得越来越小,因此乘法效应导致数值变得越来越小,从而产生梯度消失。如果每一层的梯度很大,我们可能会遇到梯度爆炸问题。

经历了多年的反复试验,今天神经网络用户中流行的选择是修正线性单元(ReLU)f(x) 在 x < 0 时为 0,而在 x ≥ 0 时等于 x

11.6 正则化

如前所述,神经网络有过拟合的趋势,许多网络拥有成千上万的权重,有些甚至有百万级的权重。请记住,权重本质上是线性回归系数,因此权重的总数实际上是p的新值(即我们的特征数量)。我们必须找到某种方法来减少这个值。

11.6.1 L1 和 L2 正则化

由于神经网络(通常)最小化平方和,我们可以应用惩罚项来减少解的大小,就像岭回归和 LASSO 中的情况一样。还要回想一下,在 LASSO 中,使用[1]惩罚时,这往往会产生一个稀疏解,大多数系数为 0。

好的,这正是我们在这里想要的。我们担心我们有太多的权重,并且希望应用[1]惩罚能使大部分权重归零。

然而,由于使用了非线性激活函数,这种情况在神经网络中可能不会发生。问题在于,图 9-3 中的等高线不再是椭圆形的,因此“第一次接触点”不太可能位于菱形的一个角落。

然而,[1]仍然会缩小权重,[2]也是如此,因此我们应该在某种意义上实现维度减少。

11.6.2 通过 Dropout 进行正则化

如果某个权重为 0,那么在网络图中,例如图 11-1,对应的连接将被移除。所以,如果目标是移除一些连接,为什么不直接移除一些连接呢?或者更好的是,移除整个节点。这正是dropout的作用。

例如,如果我们的丢弃率是 0.2,我们会随机(且暂时地)选择给定层的 20%的连接并将其移除。这里还有更多的细节,我们不在此列举,但这就是该方法的核心。

11.7 示例:跌倒检测数据

让我们重新审视在第 8.9.4 节中分析的数据集。我们将进行网格搜索,寻找一个合适的超参数组合。

请回忆一下,qeFT()函数中的参数pars定义了网格,因为它指定了我们希望探索的值范围。

> pars <- list(hidden=c('5,5','25,25','100,100','100,0.2,100,0.2',
   '100,0.5,100,0.5','250,0.5,250,0.5'),
   learnRate=c(0.0001,0.0005,0.001,0.005))
> ftout <- qeFT(fd,'ACTIVITY','qeNeural',pars=pars,nTst=250,nXval=25)

所以,我们在每层神经元的数量(5, 100, 250)和丢弃率(无、0.2、0.5)之间进行变化。我们也可以改变nEpoch,甚至是激活函数。还要注意,我们也可以尝试在不同的层中使用不同数量的神经元。

以下是结果:

> ftout$outdf
            hidden learnRate meanAcc        CI    bonfCI
1          100,100     5e-03 0.53256 0.5437322 0.5519608
2  100,0.2,100,0.2     5e-03 0.55896 0.5669100 0.5727654
3  250,0.5,250,0.5     1e-03 0.59480 0.6029615 0.6089727
4  250,0.5,250,0.5     5e-03 0.59696 0.6055761 0.6119221
5          100,100     1e-03 0.60040 0.6106513 0.6182015
6  100,0.2,100,0.2     1e-03 0.60048 0.6074992 0.6126690
7  250,0.5,250,0.5     5e-04 0.60928 0.6154121 0.6199285
8  100,0.5,100,0.5     5e-03 0.60952 0.6176062 0.6235618
9            25,25     5e-03 0.61344 0.6228619 0.6298013
10         100,100     5e-04 0.61744 0.6253014 0.6310915
11 100,0.5,100,0.5     1e-03 0.62120 0.6288043 0.6344051
12 100,0.2,100,0.2     5e-04 0.63056 0.6395884 0.6462380
13 100,0.5,100,0.5     5e-04 0.64048 0.6502662 0.6574739
14           25,25     1e-03 0.64664 0.6539690 0.6593669
15 250,0.5,250,0.5     1e-04 0.65368 0.6603284 0.6652252
16         100,100     1e-04 0.66168 0.6700386 0.6761949
17           25,25     5e-04 0.66528 0.6740087 0.6804375
18 100,0.2,100,0.2     1e-04 0.67240 0.6814274 0.6880762
19             5,5     5e-03 0.68504 0.6927206 0.6983776
20 100,0.5,100,0.5     1e-04 0.69240 0.6989253 0.7037314
21             5,5     1e-03 0.69696 0.7049328 0.7108050
22             5,5     5e-04 0.70368 0.7099837 0.7146265
23           25,25     1e-04 0.70608 0.7143002 0.7203546
24             5,5     1e-04 0.72544 0.7366268 0.7448662

首先需要注意的是,最小值与最大值之间的差距有多小。事实上,后者实际上与基础精度差不多:

> qeNeural(fd,'ACTIVITY')$baseAcc  # any qe* function could be called
[1] 0.7182421

如果没有特征,我们的错误率将是 72%。

所以,在这里,探索不同超参数值的使用确实带来了显著的收获。

但即便如此,还是能发现一些有趣的模式,特别是学习率的影响。较小的值往往效果较差。记住,如果我们的学习率太小,不仅可能会减慢收敛速度,还可能会让我们卡在局部最小值。

最后,请注意,在这种情况下,较小的丢弃率似乎能产生更好的结果。

11.8 陷阱:收敛问题

如前所述,配置神经网络分析以确保正确地收敛到一个好的解通常是一项挑战。在某些情况下,可能会遇到坏时钟问题——即网络无论输入是什么,都预测相同的值。

或者,可能会遇到如下输出:

Epoch 27/30
618/618 [==============================] -
1s 2ms/step - loss: nan - accuracy: 0.7571 - val _loss: nan - val_accuracy: 0.7520

这里nan代表“不是一个数字”。这个听起来不祥的消息可能意味着代码试图除以 0,这可能是由于梯度消失问题导致的。

以下描述了一些可以尝试的技巧,通常是通过一个或多个超参数指定的。

在某些情况下,通过对数据进行缩放可以解决收敛问题,可以使用 R 的scale()函数,或者将数据映射到[0,1]之间。建议常规地对数据进行缩放;在qeNeural()中,缩放实际上是硬编码到软件中的。

以下是一些可调节的值:

学习率 在第 6.3.8 节中讨论。

激活函数 尝试更改为具有更陡峭/更平缓斜率的函数。例如,函数a(t) = 1/(1 + exp(−2t))在t = 0 附近比普通的逻辑函数更陡峭。

早停法 在大多数算法中,迭代次数越多越好,但在神经网络中,许多问题偏离了传统的智慧。运行算法过长可能导致收敛到一个较差的解。这就引出了早停法的概念,且有许多变体。

动量 这里的大致想法是,“我们在顺利推进”,过去几个周期产生了朝正确方向前进的有效步骤,每次都减少验证误差。所以,为什么不结合过去几个步骤的步长呢?下一步的步长将设置为过去几个步长的加权平均值,并且对最近的步长赋予更大的权重。(这个超参数在qeNeural()中不可用,但可以通过keras包直接访问。)

请注意,与分类不同,回归应用可能特别容易出现收敛问题,因为Y是无界的。

11.9 与多项式回归的密切关系

在第 8.11 节中,我们介绍了多项式回归,这是一种线性模型,其中特征是多项式形式的。所以,例如,在一个二次模型中,我们不仅有人的身高和年龄作为特征,还会有身高和年龄的平方,以及一个交叉乘积项,身高 × 年龄。

多项式再次出现在支持向量机(SVM)中,使用了多项式核。例如,我们可能不仅仅考虑身高和年龄,还包括身高和年龄的平方,以及身高 × 年龄项。我们还注意到,即使是使用径向基函数(radial basis function),这是一种非多项式核,由于泰勒级数展开,它大致上也可以视为多项式。

事实证明,神经网络本质上也在进行多项式回归。为了验证这一点,让我们再次看看图 11-1。假设我们将激活函数选择为平方函数t²。这个选择并不常见,但我们从这个例子开始,然后进一步扩展这个论点。

因此,在图 11-1 的隐藏层中,一个圆形会形成输入的线性组合,然后输出线性组合的平方。这意味着隐藏层的输出是输入的二次多项式。如果我们有第二个隐藏层,那么它的输出将是四次多项式。

如果我们的激活函数本身是一个多项式呢?那么,每一层将会为我们提供更高次的输入多项式。由于神经网络(NN)最小化的是平方预测误差的和,正如线性模型一样,你可以看出,最小化的解将是多项式回归的解。

那么,流行的激活函数呢?其中之一是双曲正切tanht),其图形看起来与逻辑函数相似。但它也有泰勒级数展开,因此我们所做的实际上是近似的多项式回归。

ReLU 没有泰勒级数展开,但我们也可以形成一个多项式近似。

那么,为什么一开始不直接使用多项式回归呢?为什么使用神经网络(NN)?一个答案是,对于大规模的p数据,直接使用lm()glm()可能会涉及非常多的多项式项,这样计算上是不可行的,这会导致内存问题。(对于神经网络而言,这不是问题,因为它们通过迭代找到最小二乘解。虽然这可能会导致收敛问题,但至少使用的内存较少。)核技巧在这里非常有用,甚至有一种核岭回归方法,将其应用于线性岭回归模型,但事实证明,这对于大规模n数据也是不可行的。

正如前面所提到的,神经网络有自己的计算问题,但通过尝试多种超参数组合,我们仍然可能得到良好的结果。此外,如果我们能够在某些类别的问题上找到一个好的神经网络拟合,有时我们可以调整它,以便在一些相关类别的问题上找到一个好的神经网络拟合(迁移学习)。

11.10 神经网络中的偏差与方差

我们通常将隐藏层的数量称为网络的深度,每层的单元数量称为宽度。这两者的乘积(实际上是深度乘以宽度的平方)越大,网络的权重或参数就越多。如第 8.10.1 节中讨论的,模型的参数越多,方差就越大,即使偏差有所减少。

这一点也可以从前一节中提到的多项式回归与神经网络的联系来说明。粗略地说,神经网络中隐藏层的数量越大,多项式回归的近似度就越高。而且,多项式回归模型的阶数越高,偏差越小,但方差越大。

所以,神经网络并不是偏差-方差权衡的免疫者。在设计神经网络架构时,必须牢记这一点。

11.11 讨论

神经网络在近年来的“机器学习革命”中发挥了重要作用,在某些类型的应用中取得了显著的成功。但它们也可能带来巨大的计算成本,在某些情况下,运行时间可达数小时甚至数天,并且可能存在令人头痛的收敛问题。

此外,机器学习社区的传言表明,神经网络在处理表格数据时并不特别有效,这里指的是存储在数据框中的数据——也就是本书中迄今为止所讨论的所有数据集。读者可能希望将神经网络保留用于图像识别和自然语言处理等应用,这些内容将在接下来的两章中讨论。

第十二章:第五部分

应用

在这一部分,我们概述了机器学习在多个领域中取得的辉煌成就。然而,这仅仅是一个概述。每一个这些主题都有完整的书籍进行详细论述。

在这些领域中,真正有效的机器学习应用通常需要先进的技术、大量的调优和一定的领域专长。鼓励感兴趣的读者进一步学习这些主题。

第十三章:图像分类

Image

神经网络(NNs)最广泛应用的领域可能就是图像分类。事实上,神经网络今天的流行在很大程度上是由于 2000 年代初神经网络在图像分类比赛中的一些惊人成就。早些时候,神经网络领域基本上被视为一种好奇心驱动的技术,而不是主流工具。

神经网络在图像分类中的流行随之产生了反馈效应:神经网络在图像分类中的表现越好,图像分类研究者就越多使用神经网络作为工具,从而使得他们在图像领域中对神经网络的使用更加精细,这又反过来推动了神经网络在比赛中的更多成功。

原则上,本书中的任何方法都可以用于图像。特征是像素强度,结果是图像的类别。例如,考虑著名的 MNIST 数据集。这里有 70,000 张手写数字的图像,每张图像有 28 行 28 列的像素。每个像素有一个 0 到 255 之间的强度(亮度)值,我们有 28² = 784 个像素,因此我们有 784 个特征和 10 个类别。

神经网络在图像领域的“秘密武器”是卷积操作,这就是卷积神经网络(CNNs)这一术语的来源。实际上,这些操作并不完全是新的;它们借鉴了经典的图像处理技术。最重要的是,卷积操作并不是神经网络的固有特性。它们可以与其他机器学习方法结合使用,事实上,一些研究者已经开发了卷积支持向量机(SVMs)。但同样,图像领域的动力显然集中在 CNNs 上。

因此,本章关于图像的重点将放在神经网络上。我们将从一个非神经网络的示例开始,目的是说明任何机器学习方法都可以使用,然后再深入讨论 CNNs。

12.1 示例:Fashion MNIST 数据

通常使用 MNIST 数据集作为入门示例,但我们这里做点不一样的。Fashion MNIST 数据集与 MNIST 大小相同(28 × 28 像素结构,10 个类别,70,000 张图片),但它包含的是服装的图片(10 种类型),而不是数字。(该数据集可在https://github.com/zalandoresearch/fashion-mnist找到。)

一个重要的区别是,虽然 MNIST 可以基本看作是黑白图像,Fashion MNIST 则真正拥有“灰度”。例如,见图 12-1。模糊是由于图像集的低 28 × 28 分辨率造成的。

Image

图 12-1:Fashion MNIST 图像

这使得数据集更具挑战性,通常 Fashion MNIST 的准确率要低于 MNIST。

12.1.1 使用 Logit 模型的首次尝试

数据集实际上已经分为训练集和测试集(分别为 60,000 和 10,000 行),但为了方便起见,我们只使用训练集,我将其命名为ftrn,其中包含列 V1、V2、...、V785。最后一列是衣物类型,值为 0 至 9。

让我们在这些数据上尝试一个逻辑回归模型(顺便说一下,运行这个模型大约花了 2 个小时):

> z <- qeLogit(ftrn,'V785')
> z$testAcc
[1] 0.205
> z$baseAcc
[1] 0.8998305

所以,我们的准确率大约是 80%。基线准确率只有大约 10%,这很有道理:10 种衣物类型大致数量相等,所以随机猜测会得到大约 10%的准确率。因此 80%的准确率并不算差。但由于这个数据集在世界纪录中的最佳准确率已经接近 90%以上,我们希望做得更好。

12.1.2 通过 PCA 进行改进

我们可以推测,正如上面所说,p = 784 太大,需要进行降维。一个可能的解决办法是使用 PCA:

> z <- qePCA(ftrn,'V785','qeLogit',pcaProp=0.8)
> z$testAcc
[1] 0.172

啊,现在我们的准确率已经达到了大约 83%。(而且运行只花了大约一分钟。)

我们可以尝试不同的主成分数值,但更好的方法可能是利用我们对图像的了解,正如接下来我们将看到的那样。

12.2 卷积模型

尽管 CNN 结构一开始看起来可能很复杂,但它实际上基于一些简单的思想。让我们开始吧。

12.2.1 需要识别局部性

图 12-1 中的图片模糊不清,这并非巧合。记住,这些是非常低分辨率的图像(28 × 28 像素)。尽管这些图像很小,但它们给我们提供了 784 个特征。假设n = 70,000,我们的“Image”经验法则(公式 3.2)建议最大特征数应为大约 260 个,这明显低于 784 个特征。虽然这个经验法则比较保守——卷积神经网络(CNN)在特征数量p远大于n的情况下也能表现不错——但显然将 784 个像素当作独立特征来使用,会妨碍我们有效预测新数据。

我们需要利用图像的局部性。一个图像像素往往与其相邻的像素存在相关性,这种关系的性质应该有助于我们对图像进行分类。这个像素是短直线的一部分吗?或者是一个小圆圈?卷积模型就是考虑到这一点设计的,包含了对图像内小块区域应用的各种操作。这些小块区域通常被称为瓦片,我们在这里也将使用这个术语。

12.2.2 卷积方法概述

首先,我们来看一下代码,并在 Fashion MNIST 数据集上运行它。之后,我们将解释代码中的操作。我们将使用从 RStudio 示例中改编的代码:

# set up 5 image op layers
> conv1 <- list(type='conv2d',filters=32,kern=3)
> conv2 <- list(type='pool',kern=2)
> conv3 <- list(type='conv2d',filters=64,kern=3)
> conv4 <- list(type='pool',kern=2)
> conv5 <- list(type='drop',drop=0.5)

# note that qeNeural() by default sets up two hidden layers; these will
# come after the convolutional ones
> z <- qeNeural(ftrn,'V785',
   conv=list(conv1,conv2,conv3,conv4,conv5),xShape=c(28,28))
> z$testAcc
[1] 0.075

啊,现在我们的正确分类率已经超过了 92%。我们几乎肯定可以通过调整超参数(包括更改图像操作层的数量和结构)进一步提高准确率。

我们在这里看到了什么?

  • 使用qeNeural()conv参数,我们设置了五个图像操作层。

  • 图像操作层后面是“普通”层(此处未指定),即qeNeural()的默认值为两层,每层 100 个神经元。 “普通”层被称为密集层或全连接层。

  • 第一个图像操作层对输入图像执行卷积操作,涉及提取瓦片并形成每个瓦片内图像强度的线性组合。这些线性组合成为该层的输出。

  • 回顾之前的章节,在线性组合中,例如 3a − 1.5b + 16.2c,数字 3、−1.5 和 16.2 被称为系数。在卷积神经网络(CNN)中,它们被称为权重

  • 通常我们会使用多组不同的权重。conv2d参数filters指定了我们希望为某一层设置的权重组数。它与密集层中的神经元数量类似。

  • conv2d参数kern值指定瓦片的大小,第一层中的值 3 意味着 3 × 3 的瓦片。

  • 另一个conv2d参数stride通过指定瓦片与邻域的重叠量来控制图像中瓦片的数量,具体内容将在下文解释。

  • 参数xShape指定图像的大小,例如当前示例中的 28 × 28。

  • 对于此大小的彩色数据,我们会将其表示为 28 × 28 × 3,其中 3 表示基本颜色的数量:红色、黄色和蓝色。我们会有一个 28 × 28 的红色强度数组,然后是黄色和蓝色的数组。然后我们将xShape设置为(28,28,3)。

  • 第三个坐标 3 被称为通道。我们不称其为“颜色”,因为它可能并不是颜色,如下所示。

一层的输出作为下一层的输入,输出的维度可能是 13 × 13 × 64。例如,这将被视为一个 13 × 13 的“图像”,具有 64 种“基本颜色”,这两者都是人为的。关键是,数学上,任何三维数组都可以这样处理,并且这样做使得代码更简洁。(三维或更高维的数组被称为张量,因此 Python 包tensorflow的名称来源于此。)

12.2.3 图像切割

上述代码示例中的第一层和第三层执行了卷积操作。(具有概率论、傅里叶分析等背景的读者会发现,该术语在机器学习中的用法与其他领域有所不同。)为了说明这一点,我们首先需要讨论如何将图像分割成瓦片。

考虑一个 6 × 6 的灰度图像示例:

Image

例如,图像中第二行第四列的像素强度为 11。在 R 中,我们会将其存储在一个 6 行 6 列的矩阵中。(R 矩阵类似于数据框,其中元素可以是全数字或全字符等。)

我们可以将其分割为不重叠的 3 × 3 大小的瓦片:

Image

所以,我们的原始矩阵已经被划分为四个子矩阵或块。

我们也可以有重叠的块,使用一个叫做步幅的数字。在上述例子中,步幅是 3:右上角块的第一列

Image

它位于方程式 12.2 中的左上角块的第一列右侧 3 列

Image

依此类推。类似的陈述也适用于行。例如,右下角块的第一行距离右上角块的第一行有 3 行。

在步幅为 1 的情况下,假设我们的第一个 3 × 3 的块仍然是

Image

但第二个块将仅位于第一个块的右侧一列

Image

依此类推。

stride'conv2d'操作中的默认值是 1。

12.2.4 卷积操作

假设我们使用 3 × 3 的块。用矩阵形式表达线性组合的系数也很方便,例如,在权重矩阵中:

Image

对于给定的块:

  • 该块在第 1 行第 1 列的元素将与w[11]相乘。

  • 该块在第 1 行第 2 列的元素将与w[12]相乘。

  • . . .

  • 该块在第 3 行第 3 列的元素将与w[33]相乘。

所有这些乘积将被加总以生成一个单一的数字。

这组权重随后会应用到每个块。将其应用于方程式 12.2 中的左上角块,我们得到单一的数字:

Image

我们对每个块应用相同的权重。例如,将权重应用于右上角的块,我们得到:

Image

我们对左下角和右下角的块做同样的操作,最终得到四个数字,并将它们排列成 2 × 2 矩阵。这个矩阵作为输出传递给下一层。

假设我们有 12 个滤波器。这意味着 12 组不同的权重——也就是说,12 个不同版本的矩阵,如方程式 12.7 所示。这意味着从这一层输出 12 个不同的 2 × 2 矩阵。因此,这一层的输出可以描述为 2 × 2 × 12。确实,这一层的总输出将是 48 个数字,但我们将其视为由 12 组 2 × 2 矩阵组成,因此使用 2 × 2 × 12 的表示法。

注意,我们并不是自己选择这些权重。它们是由神经网络算法选择的,目的是最小化整体的预测平方和。我们选择的是集合的数量,这里是 12,但不是具体的集合。算法将尝试许多不同的 12 组权重的组合,期望找到一种最小化预测平方和的组合。

所以……其实并没有什么新奇的地方。我们只是对输入进行线性组合,并将其送入下一层,就像上一章一样。最终,算法最小化的是预测误差的平方和,就像之前一样。

不同之处在于数据被结构化为瓦片,利用了局部性。权重的作用是决定各种像素的相对重要性,尤其是在它们如何协同工作时。

将其想象成一个“建筑物”,它有 12 层。“每层”由四个“房间”组成,房间按两行排列,每行两个房间。我们将在下面采用这种方法。

注意,我们仍然有与稠密层一样的常见偏差-方差权衡:滤波器越多,减少偏差的机会越多,但权重的方差也会增加。

12.2.5 池化操作

回忆一下 12.2 节中示例的第二层:

conv2 <- list(type='pool',kern=2)

这不是一个卷积层;它是一个池化层。

池化操作涉及用某些代表性值来替换瓦片中的元素,比如均值、中位数,甚至是瓦片中的最大值。后者实际上是非常常见的,实际上在regtoolsqeML包中就使用了这一方法。

读者可能会想:“池化不就是卷积操作的一种特殊情况吗?例如,在一个 2 × 2 的瓦片中取均值,不就是一个所有权重都为 0.25 的卷积操作吗?”答案是肯定的,但有一个很大的区别:这里的权重固定为 0.25,而不是由算法选择的。

conv2d操作不同,后者的默认步幅是 1,而池化操作的默认步幅是瓦片大小,上文中指定为 2。

12.2.6 层之间的形状演变

那么,第二层的输出结构会是什么样的呢?我们来推理一下。这里再次给出前两层的规格:

conv1 <- list(type='conv2d',filters=32,kern=3)
conv2 <- list(type='pool',kern=2)

第一层的输入是 28 × 28,或者 28 × 28 × 1。第一层将其分成 3 × 3 的瓦片,步幅为 1。就像在方程式 12.2 中有一个 2 × 2 的瓦片阵列一样,这里我们将有一个 26 × 26 的瓦片阵列,同样考虑到步幅为 1。

所以,在第一层中,每个滤波器将输出 26²个数字,形式为 26 × 26。对于 32 个滤波器,第一层的总输出将是 26 × 26 × 32。在“建筑物”隐喻中,这意味着 32 层,每层有 26 行,每行 26 个房间。请注意,这里的每个“房间”都包含一个数字。

那么,第二层会发生什么呢?它将接收 32 个大小为 26 × 26 的瓦片。它会如何处理这些瓦片呢?

如上所述,本层使用的瓦片大小是 2 × 2,步幅为 2。将其应用到一个输入的 26 × 26 的瓦片中,将形成 13 行,每行 13 个 2 × 2 的瓦片。在每个 2 × 2 的瓦片中,将提取 4 个数字中的最大值。

再次使用“建筑”比喻,每一“层”将产生 13² = 169 个数字,排列成 13 × 13 的形式。由于我们有 32 层,“楼层”的总输出将呈现 13 × 13 × 32 的形式。(regtoolsqeML包使用二维池化操作,因此池化操作在每一层内进行,而不是跨层进行。)

12.2.7 Dropout

与全连接层一样,过拟合的风险——每个卷积层的神经元太多或卷积层太多——也很高。解决方法是 dropout(丢弃法),例如:

> conv5 <- list(type='drop',drop=0.5)

这意味着随机删除这一层中 50%的节点。

12.2.8 形状演化总结

keras包可以根据请求给出我们的 CNN 的总结:

> z$model
Model
Model: "sequential"
__
Layer (type)                        Output Shape                    Param #
===============================================================================
conv2d (Conv2D)                     (None, 26, 26, 32)              320
__
max_pooling2d (MaxPooling2D)        (None, 13, 13, 32)              0
__
conv2d_1 (Conv2D)                   (None, 11, 11, 64)              18496
__
max_pooling2d_1 (MaxPooling2D)      (None, 5, 5, 64)                0
__
dropout (Dropout)                   (None, 5, 5, 64)                0
__
flatten (Flatten)                   (None, 1600)                    0
__
dense (Dense)                       (None, 100)                     160100
__
dense_1 (Dense)                     (None, 10)                      1010
===============================================================================
Total params: 179,926
Trainable params: 179,926
Non-trainable params: 0
_

回想一下,qeNeural()调用了regtools::krsFit(),后者又调用了 R 语言中的keras包,因此该输出实际上来自后者。

最后一列显示了每一层的权重数量。例如,这里就是 320 个数字的来源:每个滤波器——即每组数字w**[ij]——是一个 3 × 3 的矩阵,因此包含 9 个数字。还有一个截距项w[0](类似于线性回归模型中的β[0]),所以总共有 10 个权重。由于有 32 个滤波器,因此总共有 320 个权重,正如上面的输出表所示。

flatten层仅仅是将我们的a × c形式转换为普通数据。我们第二个池化层的输出形式是 5 × 5 × 64,总共是 1,600 个数字。为了能被全连接层使用,这些数据会转换成一个长度为 1,600 的单一向量。

总体来说,我们有p = 179926,但只有n = 65000。所以我们肯定出现了过拟合。事实上,许多类似的模型被发现能有效工作,这在机器学习领域引起了不小的争议!

12.2.9 平移不变性

权重结构赋予了平移不变性——这是一个看似复杂但实际意义简单的术语——我们分析的工具。假设我们使用 3 × 3 作为我们的瓦片大小,那就是 9 个像素。对于任何瓦片,考虑瓦片左上角的像素。那么无论瓦片位于图像的顶部、底部,还是其他位置,我们都会为该像素使用相同的权重w[11]。

以人脸识别为例,这意味着在很大程度上,我们不必担心人脸是在图像的顶部、底部还是中间。(不过,图像边缘的确会出现一些问题,所以这一特性仅近似成立。)对于左右位置也是同样的道理。

12.3 行业技巧

那么,究竟该如何构建这些模型呢?多少层?什么样的层?哪些参数值?

可以根据数据集的性质,如图像的各个部分的大小、图像的纹理等,凭直觉设定一些模型。但最终的答案往往是相当平凡的:经过多年的各种架构(配置)实验,这种架构似乎适用于某些类型的图像。一些架构已经成功应用于广泛的场合,以至于它们有了名称并成为标准,比如 AlexNet。

12.3.1 数据增强

处理较小图像集的一种方法是数据增强。这里的想法很简单:从现有图像中形成新图像。可以将给定图像水平或垂直平移,缩小或放大图像,水平或垂直翻转图像,等等。这样做的动机是,之后我们可能会遇到一个新图像,它与我们训练集中的某个图像非常相似,但它可能在图像框架内的位置较高或较低。我们希望我们的算法能够识别新图像与训练集中的图像相似。

这对于医学组织图像尤其重要,例如来自活检的图像,因为这些图像没有明确的方向感——没有上或下、左或右、前或后。这与 MNIST 图像集不同,例如‘6’是倒置的‘9’,两者是完全不同的。

我们可以使用 OpenImageR 包进行数据增强,利用它的 Augmentation() 函数。例如,在这个函数中,我们可以执行一个垂直翻转操作:

> h18f <- Augmentation(matrix(h18,nrow=28),flip_mode='vertical')
> imageShow(matrix(h18f,nrow=28))

keras 包也提供数据增强服务,包括剪切(扭曲)操作。

12.3.2 预训练网络

图像分类领域中的一个大问题是迁移学习。问题在于,不是从零开始设计神经网络——密集层、卷积层以及每个层的细节——而是构建在他人已经找到有效的网络上。然后,可以直接使用该网络,或者以该网络为起点进行一些调整。

12.4 那么,过拟合问题怎么办?

正如在第 12.2.8 节中指出的,重参数化网络在图像分类中的成功似乎与关于过拟合的传统智慧相矛盾。这已经成为机器学习社区中许多猜测的主题。

一个关键点可能是,图像上下文中的误分类率往往非常低,对于高度调优的网络来说,接近 0。从这个意义上讲,我们实际上处于第十章中提到的可分离设置中。通过重新审视该章节中的图 10-4,可以获得对这一问题的某些见解。

如前所述,实际上有无数条线可以用来区分这两类,因此可以用来预测一个新案例。SVM 会选择一条特定的线——即在两类中最接近的两个点之间的中间线——但同样,也可以使用其他许多线。

确实,分隔符不一定非得是直线。它可以是一个“弯曲”的线,例如,通过使用多项式核与支持向量机(SVM)获得的线。由于两类之间有很好的分隔,我们有足够的自由度可以拟合一条非常弯曲的曲线,比如高次多项式。而且次数越高,曲线方程中的系数就越多,也就是说,p 的值越大。

结果:我们可以拟合一条曲线,使得 p 的值远大于 n,但仍然能得到完美的预测准确性。注意到神经网络与多项式回归的关联(参见第 11.9 节),我们对过度参数化在图像分类中成功的原因有了一个合理的解释。

12.5 结论

尽管本书的目的是避免写太多方程式,但这里的内容无疑是所有章节中最具数学性的。从一个高层次来看,卷积神经网络(CNN)基于一个非常简单的想法:将图像分割成小块,然后对分块的数据应用神经网络(NN)。但是那句老话“魔鬼藏在细节中”在这里非常贴切。例如,在从一层到另一层的过程中,保持数据块维度的清晰可能是一个挑战。希望进一步深入学习的读者,线性代数和微积分的背景将非常有用。

第十四章:处理时间序列和文本数据**

Image

时间序列是按时间索引的数据集,通常是在规律的时间间隔内。这里有一些熟悉的例子:

  • 股票市场数据,包括某个特定股票的每日价格,甚至是每小时的价格,等等

  • 天气数据,按天或更细粒度的时间尺度

  • 人口统计数据,例如每月或甚至每年出生的人数,用于规划学校的容量

  • 测量心脏电活动的心电图数据,通常在规律的时间间隔内采集

一种特殊类型的时间序列是书面或口语的语音数据。在这里,“时间”指的是单词的位置。举个例子,假设我们在句子级别进行工作,一个句子由八个单词组成,那么会有单词 1、单词 2,一直到单词 8,其中索引 1 到 8 起到了“时间”的作用。

时间序列方法学领域已经被统计学家、经济学家等高度发展。像往常一样,机器学习专家们也发展了他们自己的方法,主要是神经网络的应用。被称为递归神经网络(RNNs)长短期记忆(LSTMs)的方法尤其值得注意。

统计学和机器学习方法都使用非常精妙和复杂的技术,其数学内容远高于本书的数学水平。然而,即便如此,人们仍然可以在遵循基础原理的同时,构建一些非常强大的机器学习应用,本章将围绕这个主题展开。它将介绍如何将qe*-系列函数应用于一般的时间序列问题,以及应用于一种特殊的文本识别设置(这种设置不利用文本的时间序列性质)。

13.1 将时间序列数据转换为矩形形式

在讨论机器学习时,人们常听到矩形数据表格数据这两个术语,指的是通常的n × p数据框或矩阵,其中n是行数,每行代表一个数据点的p个特征。作为一个非时间序列的简单例子,我们在本书中使用过好几次,假设我们尝试通过身高和年龄预测体重,样本量为 1,000 人。那么我们会有n = 1000 和 p = 2。

显然,“矩形”和“表格”这两个词是指与相关数据框或矩阵的矩形形状或表格结构。但这其实有些误导。图像数据也有类似的形式,例如对于 MNIST 数据,n = 70000 和 p = 28² = 784,然而图像数据并不被称为矩形数据。

然而,在时间序列的情况下,实际上可以将时间序列转换为矩形形式,然后应用机器学习方法,这也是我们在这里要做的。

13.1.1 玩具示例

假设我们的训练集时间序列x是(5,12,13,8,88,6)。为了具体说明,假设这是每日数据,那么我们这里有六天的数据,分别称为第 1 天、第 2 天,依此类推。在每一天,我们知道截至当前的序列值,并希望预测第二天的值。

我们将使用一个滞后值为 2,这意味着我们通过前两天的数据来预测某一天。在上面的 x 中,这意味着我们:

  • 根据第 5 天和第 12 天预测第 3 天

  • 根据第 12 天和第 13 天预测第 4 天

  • 根据第 13 天和第 8 天预测第 5 天

  • 根据第 8 天和第 88 天预测第 6 天

想想上面描述的内容(“预测第 13... ”)在我们通常的“X”(特征矩阵)和“Y”(结果向量)符号中意味着什么:

Image

请注意,X 只有 4 行,不是 6 行,而 Y 的长度是 4,不是 6。这是因为我们有 2 的滞后;我们需要 2 个先前的数据点。因此,在第 3 天之前我们甚至无法开始分析。

在这里我们只处理单变量时间序列。但我们也可以处理多变量情况——例如,根据先前的值预测每日的温度、湿度和风速。

13.1.2 regtools 函数 TStoX()

函数TStoX()做的就是它的名字所暗示的——将时间序列转换为“X”矩阵。“Y”也会创建并返回在最后一列。对于之前的示例,我们有:

> x <- c(5,12,13,8,88,6)
> w <- TStoX(x,2)
     [,1] [,2] [,3]
[1,]    5   12   13
[2,]   12   13    8
[3,]   13    8   88
[4,]    8   88    6

我们的“X”数据位于前两列,而“Y”则是第三列。

该函数返回一个矩阵,如果需要,我们可以将其转换为数据框:

> wd <- as.data.frame(w)
> wd
  V1 V2 V3
1  5 12 13
2 12 13  8
3 13  8 88
4  8 88  6

然后我们可以使用任何一个qe*系列的函数,例如随机森林:

qeRF(wd,'V3',holdout=NULL)

换句话说,除了一个例外,其他一切与之前一样:我们不能将 holdout 集作为数据的随机子集,因为剩下的数据将不再是连续时间段的数据。我们将很快详细说明这一点。

13.2 qeTS() 函数

但是,和上面一样,我们不需要手动调用 qeRF(),我们有一个方便的包装函数 qeTS(),它将时间序列格式转换为“X,Y”形式,然后应用我们最喜欢的机器学习方法。包装函数的调用形式是:

qeTS(lag,data,qeName,opts=NULL,
   holdout=floor(min(1000, 0.1 * length(data))))

这里 qeName 是一个 qe* 系列函数的引用名——例如,'qeRF'

参数 opts 允许我们使用被引用函数的非默认版本的参数。例如,要使用 k-NN 并将 k 设置为 10,可以这样写:

> eus <- EuStockMarkets  # built-in R dataset
> tsout <- qeTS(5,eus,'qeKNN',opts=list(k=10))  # use k-NN with k = 10

需要对 holdout 做一些说明。虽然它在 qe* 系列中发挥着常规作用,但需要注意的是,在时间序列上下文中,交叉验证通常是困难的。我们不能随机从数据中选择一些数字作为我们的 holdout 集,因为在时间序列中,我们是通过前一个时间点的数据预测当前数据。但是在这里,我们对 TStoX() 的输出进行 holdout 操作,其输出一组连续值的行,因此这是有效的。

13.3 示例:天气数据

在这里,我们将使用由 NASA 收集的一些天气时间序列数据,这些数据包含在 regtools 中。

> data(weatherTS)
> head(weatherTS)
     LON       LAT YEAR MM DD DOY   YYYYMMDD  RH2M   T2M PRECTOT
1 151.81 -27.47999 1985  1  1   1 1985-01-01 48.89 25.11    1.07
2 151.81 -27.47999 1985  1  2   2 1985-01-02 41.78 28.42    0.50
3 151.81 -27.47999 1985  1  3   3 1985-01-03 40.43 27.53    0.03
4 151.81 -27.47999 1985  1  4   4 1985-01-04 46.42 24.65    0.10
5 151.81 -27.47999 1985  1  5   5 1985-01-05 50.77 26.54    2.13
6 151.81 -27.47999 1985  1  6   6 1985-01-06 58.57 26.81    5.32

最后一列是降水量。让我们为它拟合一个模型,然后根据第 4016 天和第 4017 天的数据,预测数据结束后的第一天,即第 4018 天:

> ptot <- weatherTS$PRECTOT
> z <- qeTS(2,ptot,'qeRF',holdout=NULL)
> length(ptot)
[1] 4017
> predict(z,ptot[4016:4017])
       2
1.087949

因此,我们预测降雨量略多于 1 英寸。

我们这里使用了 2 天的时滞。其他时滞值会如何表现呢?我们可以在这里使用qeFT(),但事情有点复杂。例如,qeTS()没有yName参数,所以我们改用replicMeans()(参见第 3.2.2 节)。

如何用 1 的时滞而不是 2?我们调用replicMeans(),要求它执行

> qeTS(1,ptot,"qeKNN")$testAcc

进行 1,000 次实验,然后报告得到的 1,000 个testAcc值的平均值:

> replicMeans(1000,'qeTS(1,ptot,"qeKNN")$testAcc')
[1] 2.116511

这给我们带来了 2.12 的均方预测误差。这算好吗?像往常一样,让我们将其与仅用均值进行预测的效果进行比较:

> mean(abs(ptot - mean(ptot)))
[1] 2.626195

啊,我们开始有了进展。

那么其他时滞怎么样?

> replicMeans(1000,'qeTS(1,ptot,"qeKNN")$testAcc')
[1] 2.116511
> replicMeans(1000,'qeTS(2,ptot,"qeKNN")$testAcc')
[1] 2.051895
> replicMeans(1000,'qeTS(3,ptot,"qeKNN")$testAcc')
[1] 2.033376
> replicMeans(1000,'qeTS(4,ptot,"qeKNN")$testAcc')
[1] 2.067625
> replicMeans(1000,'qeTS(5,ptot,"qeKNN")$testAcc')
[1] 2.092022
> replicMeans(1000,'qeTS(6,ptot,"qeKNN")$testAcc')
[1] 2.085409
> replicMeans(1000,'qeTS(7,ptot,"qeKNN")$testAcc')
[1] 2.093377
> replicMeans(1000,'qeTS(8,ptot,"qeKNN")$testAcc')
[1] 2.118068
> replicMeans(1000,'qeTS(9,ptot,"qeKNN")$testAcc')
[1] 2.135797
> replicMeans(1000,'qeTS(10,ptot,"qeKNN")$testAcc')
[1] 2.157187

确实,时滞似乎会有一些影响。3 天的时滞似乎是最好的,尽管像往常一样,我们必须记住采样变异的影响。(replicMeans()函数还提供了标准误差,这里没有显示。)

如何尝试一些其他的机器学习方法呢?让我们考虑一个线性模型,因为大多数经典的时间序列方法都使用线性模型:

> replicMeans(1000,'qeTS(3,ptot,"qeLin")$testAcc')
[1] 2.245138

> replicMeans(1000,'qeTS(3,ptot,"qePolyLin")$testAcc')
[1] 2.167949

如前所述,经典的时间序列方法,例如自回归模型,是线性的。我们看到,线性模型在这个特定数据集上效果并不理想。拟合多项式可以显著改善结果,但仍然不如 k-NN。

或许是随机森林?

> replicMeans(1000,'qeTS(3,ptot,"qeRF")$testAcc')
[1] 2.138265

它仍然不如 k-NN。然而,通过调整超参数,在这两种情况下,任一方法都可能最终成为胜者。

13.4 偏差与方差

时滞的值会影响偏差和方差,尽管这种影响可能是复杂的。

较大的时滞显然增加了偏差;过去较远时间段的相关性可能较低。这类似于 k-NN 中较大k的问题。

另一方面,方差方面是棘手的。较大的时滞平滑了日常(或其他时间段)变化——即减少了方差。但较大的时滞也增加了p,即特征的数量,从而增加了方差。整体效果因此是复杂的。

13.5 文本应用

文本分析领域非常复杂,类似于图像识别领域。正如后者的情况一样,在本书中,我们只能浅尝辄止,主要从两个方面进行介绍:

  • 我们将限制在文档分类领域,而不是比如说,语言翻译。

  • 我们将限制在词袋模型(请参见下一节)。该方法仅依赖于各种单词在文档中出现的频率,而不依赖于单词出现的顺序。

因此,我们没有涵盖诸如前述的循环神经网络(RNNs)等高级方法,甚至更高级的方法,如隐马尔可夫模型(HMMs)

13.5.1 词袋模型

假设我们希望自动分类报纸文章。我们的软件发现某些文档中包含了bondyield这两个词,并将其分类为金融类别。

这是袋装词模型。我们决定一组词语,即“词袋”,并计算每个词语在每个文档类别中出现的频率。这些频率通常存储在文档-词项矩阵(DTM)中,dd[i,j]表示在训练集中,词语j出现在文档i中的次数。或者,d[i,j]可能仅为 1 或 0,表示词语j是否出现在文档i中。

矩阵d则成为我们的“X”,而“Y”是类别标签的向量,比如金融、体育等。X 的每一行表示我们对一个文档的数据,Y 中有一个对应的标签。

这仍然是一个简单的模型。如果我们猜测上面的文档属于金融类别,可能会不准确。例如,如果文档中有一句话写着:“家庭成员之间的纽带通常会产生稳定的家庭环境。” 更复杂的分析会考虑到纽带产生之间的词语。袋装词模型在某些情况下可能不如基于时间序列的方法准确。然而,它易于实现,并且在许多应用中表现良好。

13.5.2 qeText()函数

当然,也有一个qeML函数来处理这个,qeText()。它的调用形式如下:

qeText(data, yName, kTop = 50, stopWords = tm::stopwords("english"),
    qeName, opts = NULL, holdout = floor(min(1000, 0.1 * length(data))))

data参数中,假定每个文档占一行,由yName指示每个文档的类别,如金融;另一列(必须正好有两列)存储文档文本。参数qeName指定要使用的 ML 方法,opts则指定该方法的可选参数。术语停用词指的是一些不太重要的词语,如theis,这些词会被忽略。

kTop参数的作用如下:软件对训练数据中文档中的所有词语进行普查,选择最频繁的kTop个词语作为特征。

13.5.3 示例:测验数据

qeML包有一个内置的数据集,名为quizzes,包含我在各种课程中给出的测验文本。人们可能会问,是否能根据文本预测课程。

> data(quizzes)
> str(quizzes)
'data.frame':   143 obs. of  2 variables:
 $ quiz  : chr  " Directions: Work only on this sheet (on both sides,
...
...
 $ course: Factor w/ 5 levels "ECS132","ECS145",..: 3 3 3 3 3 3 3 3 3 3 ...

共有 143 个测验文档。其中第八个文档将有测验文本存储在quizzes[8,1]中,作为一个非常长的字符字符串:

> quizzes[8,1]
...
...
largest thread number.  The code with print out
...
...

课程编号在quizzes[8,2]中:

> quizzes[8,2]
[1] ECS158
Levels: ECS132 ECS145 ECS158 ECS256 ECS50

这是 ECS 158,平行计算导论。

作为示例,假设我们不知道该文档的类别,尝试使用随机森林来预测:

> z <- qeText(quizzes,qeName='qeRF')
holdout set has  14 rows

> predict(z,quizzes[8,1])
$predClasses
[1] "ECS158"
$probs
   ECS132 ECS145 ECS158 ECS256 ECS50
11  0.062  0.066  0.812  0.002 0.058

预测的课程是 ECS 158。

13.5.4 示例:AG 新闻数据集

这个数据集由四个类别的短新闻文章组成:世界、体育、商业和科技。可以从 CRAN 包textdata中获得,该包提供了下载各种文本数据测试库的接口:

> library(textdata)
> ag <- dataset_ag_news()
Do you want to download:
 Name: AG News
...
> agdf <- as.data.frame(ag)  # qe-series functions require data frames
> agdf[,1] <- as.factor(agdf[,1])  # qe requires a factor Y

让我们四处看看:

> dim(ag)
[1] 120000      3
> agdf[28,]  # for example
      class                           title
28 Business HP shares tumble on profit news
                                      description
28 Hewlett-Packard shares fall after disappointing third-quarter profits,
while the firm warns the final quarter will also fall short of expectations.

这里有大量数据,共有 120,000 个文档。嗯,也许多了,因为运行时间可能会很长。为了快速示范,我们只取 10,000 行:

> smallSet <- sample(1:nrow(agdf),10000)
> agdfSmall <- agdf[smallSet,]

那么,我们试着拟合一个模型,比如 SVM:

> w <- qeText(agdfSmall[,c(1,3)],'class',qeName='qeSVM')
holdout set has  1000 rows
Loading required namespace: e1071
> w$testAcc
[1] 0.461
> w$baseAcc
[1] 0.7403333

还不错。我们将基础误差从 74% 降低到了 46%。虽然后者仍然相对较高,因此接下来我们会尝试调整 SVM 超参数。请注意,kTop 也是一个超参数!我们应该也尝试不同的值。

13.6 总结

我们可以看到,即使没有高级方法,也可能能够为时间序列和文本数据拟合出良好的预测模型。在这两种情况下,qe*-series 函数 qeTS()qeText() 使我们能够方便地使用我们喜爱的机器学习方法。

第十五章:A

缩略语和符号列表

k-NN    k 近邻算法

MAPE    平均绝对预测误差

ML    机器学习

n    数据点数量(行数)

OME    总体误分类错误

p    特征数量(列数)

PC    主成分

PCA    主成分分析

r(t)    真实回归函数

SVM    支持向量机

UMAP    统一流形近似与投影

第十六章:B

统计学与机器学习术语对照

类别 — 标签

协变量 — 辅助信息

虚拟变量 — 独热编码

截距项/常数项 — 偏差

模型拟合 — 学习

正态分布 — 高斯分布

观测值 — 示例

预测 — 推断

预测变量 — 特征

调节参数 — 超参数

第十七章:**C

矩阵、数据框和因子转换**

在 R 的世界中,R 在数据类型方面的极大灵活性意味着要进行严谨使用需要一些类型转换的技巧。本附录将确保读者掌握这一技能。

C.1 矩阵

尽管 R 的矩阵类可能被视为比数据框更为基础,但现在有些 R 用户并不了解它。由于 R 中任何 serious 的机器学习使用都需要掌握此类,本附录将提供一个简短的教程。

本书的主题是限制数学工具的使用,因此我们不会讨论矩阵的数学性质。

R 矩阵本质上是一个数据框,其中所有列都是数值型的。它使用相同的 [i,j] 表示法。两者之间可以进行转换。以下是一些示例:

> library(regtools)
> data(mlb)
> head(mlb)
             Name Team       Position Height Weight   Age PosCategory
1   Adam_Donachie  BAL        Catcher     74    180 22.99     Catcher
2       Paul_Bako  BAL        Catcher     74    215 34.69     Catcher
3 Ramon_Hernandez  BAL        Catcher     72    210 30.78     Catcher
4    Kevin_Millar  BAL  First_Baseman     72    210 35.43   Infielder

C.2 转换:在 R 因子与虚拟变量之间、在数据框与矩阵之间

在 R 中,分类变量有一个正式的类:factor。这实际上是 R 最有用的方面之一,但必须熟练掌握因子和相应的虚拟变量之间的转换。

类似地,尽管本书中我们主要使用数据框,但有些算法需要矩阵,比如,因为它们计算行之间的距离并进行矩阵乘法和逆运算。你不需要知道矩阵逆运算是什么,但某些软件包要求你只提供矩阵输入,而非数据框。在本附录的开头有一个关于矩阵的简短教程。

一些非常流行的 R 机器学习包会自动从因子生成虚拟变量,但其他的则不会。例如,LASSO 模型的 glmnet 要求分类特征必须是虚拟变量的形式,而 ranger(用于随机森林)则接受因子。

所以,能够自己生成虚拟变量非常重要。regtools 包中的函数 factorToDummies()factorsToDummies() 就是用来做这件事的。我们在第 1.9 节中讨论了 factorToDummies() 函数。我们在整本书中,包括本附录,都使用虚拟变量。

我们还使用内置的 R 函数 as.matrix() 将数据框转换为矩阵。

5     Chris_Gomez  BAL  First_Baseman     73    188 35.71   Infielder
6   Brian_Roberts  BAL Second_Baseman     69    176 29.39   Infielder
> hwa <- mlb[,4:6]
> head(hwa)
  Height Weight   Age
1     74    180 22.99
2     74    215 34.69
3     72    210 30.78
4     72    210 35.43
5     73    188 35.71
6     69    176 29.39
> class(hwa)
[1] "data.frame"
> hwam <- as.matrix(hwa)
> class(hwam)
[1] "matrix" "array"
> head(hwam)
  Height Weight   Age
1     74    180 22.99
2     74    215 34.69
3     72    210 30.78
4     72    210 35.43
5     73    188 35.71
6     69    176 29.39
> hwam[2,3]
[1] 34.69
> mean(hwam[,3])  # mean age
[1] 28.70835
> mean(hwam$age)  # illegal
Error in hwam$age : $ operator is invalid for atomic vectors
# rbind(), "row bind", combines rows
> m <- rbind(3:5,c(5,12,13))
> m
     [,1] [,2] [,3]
[1,]    3    4    5
[2,]    5   12   13
> class(m)
[1] "matrix" "array"
> m[2,3]
[1] 13
> md <- as.data.frame(m)
> md
  V1 V2 V3
1  3  4  5
2  5 12 13
> class(md)
[1] "data.frame"
> md[2,3]
[1] 13
# the apply() function can be a nice shortcut
> apply(m,1,sum)  # apply sum() to each row (argument 1) of m
[1] 12 30
> apply(m,2,sum)  # apply sum() to each column (argument 2) of m
[1]  8 16 18

在数学中,我们将矩阵表示为矩形数组。例如,对于上面的矩阵 m

Image

第十八章:**D

陷阱:小心“P-HACKING”!**

近年来,人们对被称为p-hacking的问题表示了极大的关注。尽管这些问题一直为人所知并被讨论,但在约翰·伊奥安尼迪斯(John Ioannidis)那篇标题极具挑衅性的论文《为什么大多数已发布的研究结果是错误的》(PLOS Medicine,2005 年 8 月 30 日)发表后,事情真正到了一个高潮。这个争议的一个方面可以这样描述。

假设我们有 250 枚硬币,并且怀疑其中一些是偏的。(任何硬币都至少会有某种程度的不平衡,但我们暂且不提这个。)我们抛每一枚硬币 100 次,如果一枚硬币出现的正面少于 40 次或多于 60 次,我们就认为它是不平衡的。对于懂一些统计学的人来说,这个范围的选择是为了确保一枚平衡硬币有 5%的概率出现超过 50 次正面偏差超过 10 次。所以,虽然每枚硬币的这个概率只有 5%,但有 250 枚硬币时,至少有一枚硬币很可能会落在[40,60]范围之外,即使没有一枚硬币是不平衡的。我们会错误地宣称一些硬币是不平衡的。实际上,这只是巧合,那些硬币看起来不平衡。

或者,举个稍微轻松一点的例子,但仍能说明问题,假设我们正在研究幽默感是否与遗传有关。有没有幽默基因?有许多许多基因需要考虑——实际上超过了 250 个。测试每一个基因与幽默感的关系,就像检查每一枚硬币是否不平衡:即使没有幽默基因,最终我们也可能偶然发现一个看似与幽默相关的基因。

在一项复杂的科学研究中,分析员测试了许多基因,或者许多与癌症相关的风险因素,或者许多外星行星是否有生命的可能,或者许多经济通胀因素,等等。术语p-hacking指的是分析员考虑了如此多的不同因素,以至于其中一个很可能会被认为是“统计学显著”的,即使没有任何因素对结果有真正的影响。一句常见的笑话是,分析员“逼迫数据直到它们承认”,暗指研究人员测试了太多的因素,最终有一个因素会被判定为“显著”。

谷歌决策智能部门负责人 Cassie Kozyrkov 说得很好:

人类对墨迹的处理方式,正如同它处理数据的方式一样。复杂的数据集几乎在要求你在其中找到虚假的意义。

这对机器学习分析有重大影响。 例如,机器学习社区中有一种流行的做法,就是举办竞赛,许多分析员对机器学习方法进行调整,试图在某一数据集上超越对方。通常这些是分类问题,“获胜”意味着获得最低的错误分类率。

问题在于,拥有 250 个机器学习分析师攻克同一数据集,就像我们上面举例的 250 个硬币。即使他们尝试的 250 种方法都同样有效,其中某一个方法也会偶然成为赢家,并被誉为“技术进步”。

当然,可能确实有 250 种方法中的某一种是优越的。但如果没有对这 250 个数据点进行仔细的统计分析,就无法确定什么是真实的,什么只是偶然的。还要注意,即使 250 种方法中确实有一种优越的方法,由于随机变异,它很可能不会在比赛中获胜。

问题的严重性在于,参赛者很可能不会提交自己的作品,如果看起来不太可能创造新的记录。这进一步加剧了结果的偏差。

如前所述,这个概念对统计学家来说是第二天性,但在机器学习圈子中很少提及。一个例外是 Lauren Oakden-Rayner 的博客文章《AI 竞赛无法产生有用的模型》,她的精彩图表在图 D-1 中得到再现,并且得到了 Oakden-Rayner 博士的许可。^(1)

Image

图 D-1:AI p-hacking

Rayner 使用简单的统计功效分析来分析 ImageNet,这是一个机器学习图像分类比赛。他认为,至少从 2014 年开始的那些“新记录”都是过拟合,或者只是噪声。如果使用更复杂的统计工具,可以做更精细的分析,但原理是明确的。

这对调优参数的设定也有很大影响。假设我们在一个机器学习方法中有四个调优参数,并且每个参数尝试 10 个不同的值。那就有 10⁴ = 10000 种可能的组合,比 250 多得多!所以,再次强调,看似“最佳”的调优参数设定可能是虚幻的。

regtools函数fineTuning()采取措施以应对在搜索最佳调优参数组合时可能出现的 p-hacking 问题。

posted @ 2025-11-30 19:33  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报