精通-R-预测性分析第二版-全-
精通 R 预测性分析第二版(全)
原文:
annas-archive.org/md5/02a3f7ef323eeaafa02bae559600fcbc译者:飞龙
前言
预测分析结合了来自预测建模、机器学习和数据挖掘的各种统计技术,旨在分析当前和历史事实,以产生关于未来或未知事件的预测结果。
R 是一种开源编程语言,在统计学家和数据挖掘者中广泛用于预测建模和数据挖掘。凭借其不断增长的社区和丰富的包,R 提供了处理真正广泛问题的功能。
本书基于第一版,旨在成为想要超越预测建模基础读者的一部指南和参考。本书从模型语言以及预测建模过程的一个专门章节开始。接下来的每一章都针对特定类型的模型,如神经网络,并重点关注模型如何工作、如何使用 R 进行训练,以及如何使用真实世界的数据集来衡量和评估其性能。
本第二版提供了关于性能指标和学习曲线、多项式回归、泊松回归和负二项回归、反向传播、径向基函数网络等主题的最新深入信息。还增加了一章,专注于处理非常大的数据集。到本书结束时,你将探索并测试在真实世界数据集上使用的最流行的建模技术,并掌握预测分析中多样化的技术。
本书涵盖内容
第一章,准备预测建模,帮助你设置并准备开始查看单个模型和案例研究,然后以一系列步骤描述预测建模的过程,并介绍几个基本区别。
第二章,整理数据与衡量性能,涵盖了性能指标、学习曲线以及整理数据的过程。
第三章,线性回归,解释了预测建模的经典起点;它从最简单的单变量模型开始,进而扩展到多元回归、过拟合、正则化,并描述了线性回归的正则化扩展。
第四章,广义线性模型,是线性回归的延续,本章介绍了逻辑回归作为一种二元分类形式,将其扩展到多项式逻辑回归,并使用这些模型来展示敏感性和特异性概念。
第五章,神经网络,解释了逻辑回归模型可以看作是一个单层感知器。本章讨论了神经网络作为这一想法的扩展,以及它们的起源,并探讨了它们的强大功能。
第六章,支持向量机,介绍了一种使用核函数将数据转换到不同空间的方法,以及尝试找到最大化类别之间边界的决策线。
第七章,基于树的方法,介绍了各种流行的基于树的方法,如决策树和著名的 C5.0 算法。本章还涵盖了回归树、随机森林,以及与之前章节中介绍的 bagging 方法的联系。在基于树的方法的背景下,还介绍了用于评估预测因子的交叉验证方法。
第八章,降维,涵盖了 PCA、ICA、因子分析和非负矩阵分解。
第九章,集成方法,讨论了结合多个预测因子或同一预测因子的多个训练版本的方法。本章介绍了重要的 bagging 和 boosting 概念,以及如何使用 AdaBoost 算法通过单个分类器提高之前分析的数据集的性能。
第十章,概率图模型,介绍了朴素贝叶斯分类器作为在讨论条件概率和贝叶斯定理之后的最简单的图模型。朴素贝叶斯分类器在情感分析的应用中得到了展示。同时介绍了隐马尔可夫模型,并通过下一个单词预测的任务进行了演示。
第十一章,主题建模,提供了对主题模型进行预测的逐步指导。它还将展示降维方法来总结和简化数据。
第十二章,推荐系统,探讨了在 R 中构建推荐系统的不同方法,包括最近邻方法、聚类以及如协同过滤等算法。
第十三章,扩展,解释了如何处理非常大的数据集,包括一些使用非常大的数据集训练我们之前看到的一些模型的示例。
第十四章,深度学习,通过例如词嵌入和循环神经网络(RNNs)等示例,探讨了深度学习这一真正重要的主题。
您需要这本书的内容
为了使用和运行本书中找到的代码示例,以下事项应予以注意:
-
R 是一个用于统计计算和图形的免费软件环境。它可以在各种 UNIX 平台、Windows 和 MacOS 上编译和运行。要下载 R,有多个位置可供选择,包括
www.rstudio.com/products/rstudio/download。 -
R 提供了广泛的文档访问和搜索帮助的便利。一个很好的信息来源是
www.r-project.org/help.html。 -
R 的功能通过用户创建的包得到扩展。本书中提到了并使用了各种包,每个包的功能和访问方式将在介绍时详细说明。例如,wordcloud 包在第十一章(part0082_split_000.html#2E6E41-c6198d576bbb4f42b630392bd61137d7 "第十一章。主题建模")主题建模中介绍,用于绘制跨文档共享的词云。这可以在
cran.r-project.org/web/packages/wordcloud/index.html找到。
这本书面向谁
如果读者对预测分析和 R 编程语言有一些经验,那将很有帮助;然而,这本书对那些对这些主题不熟悉但渴望尽快开始的人也将很有价值。
习惯用法
在这本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"\Drupal\Core\Url类提供了生成自身实例的静态方法,例如::fromRoute()"。
代码块设置如下:
/**
* {@inheritdoc}
*/
public function alterRoutes(RouteCollection $collection) {
if ($route = $collection->get('mymodule.mypage)) {
$route->setPath('/my-page');
}
}
当我们希望将您的注意力引向代码块中的特定部分时,相关的行或项目将以粗体显示:
/**
* {@inheritdoc}
*/
public function alterRoutes(RouteCollection $collection) {
if ($route = $collection->get('mymodule.mypage)) {
$route->setPath('/my-page');
}
}
任何命令行输入或输出都如下所示:
$ php core/scripts/run-tests.sh PHPUnit
新术语和重要词汇以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中显示如下:“点击下一步按钮将您带到下一屏幕。”
注意
警告或重要注意事项以如下框的形式出现。
小贴士
小技巧和技巧看起来像这样。
读者反馈
我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。
要向我们发送一般反馈,请简单地发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书籍标题。
如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您的账户中下载本书的示例代码文件。www.packtpub.com。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持选项卡上。
-
点击代码下载与勘误。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
您还可以通过点击 Packt Publishing 网站上书籍网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录您的 Packt 账户。
文件下载后,请确保您使用最新版本的软件解压缩或提取文件夹:
-
Windows 上的 WinRAR / 7-Zip
-
Mac 上的 Zipeg / iZip / UnRarX
-
Linux 上的 7-Zip / PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-Predictive-Analytics-with-R-Second-Edition。我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/MasteringPredictiveAnalyticswithRSecondEdition_ColorImages.pdf下载此文件。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
侵权
在互联网上侵犯版权是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过<copyright@packtpub.com>与我们联系,并提供疑似侵权材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面所提供的帮助。
问题
如果您对本书的任何方面有问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。
第一章:为预测建模做准备
在本章的第一章中,我们将首先建立一个用于模型的标准语言,并对预测建模过程进行深入探讨。预测建模的大部分内容涉及统计学和机器学习的关键概念,本章将简要介绍这些领域的核心特征,这些特征对于预测模型师来说是必备的知识。特别是,我们将强调了解如何评估适合我们试图解决的问题类型的模型的重要性。最后,我们将展示我们的第一个模型,即 k 近邻模型,以及caret,这是一个对预测模型师非常有用的 R 包。
模型
模型是预测分析的核心,因此,我们将从讨论模型及其外观开始我们的旅程。简单来说,模型是我们想要理解和推理的状态、过程或系统的表示。我们建立模型是为了从中得出推论,并且对我们这本书来说更重要的是,对世界做出预测。模型有多种不同的格式和风味,本书将探讨其中的一些多样性。模型可以是连接我们可以观察或测量的数量的方程;它们也可以是一组规则。我们大多数人从学校时代就熟悉的一个简单模型是牛顿的第二运动定律。该定律表明,作用在物体上的合力使物体沿着力的方向加速,加速度与力的结果大小成正比,与物体的质量成反比。
我们通常通过使用字母 F、m 和 a 来表示涉及的数量,通过方程来总结这些信息。我们还使用大写希腊字母 sigma (Σ) 来表示我们对力进行求和,并在字母上方使用箭头来表示矢量量(即具有大小和方向的量):

这个简单但强大的模型使我们能够对世界做出一些预测。例如,如果我们对一个已知质量的物体施加一个已知的力,我们可以使用这个模型来预测它将加速多少。像大多数模型一样,这个模型做出了一些假设和概括。例如,它假设物体的颜色、环境的温度以及它在空间中的精确坐标都与模型所指定的三个数量如何相互作用无关。因此,模型抽象掉了特定过程或系统实例的众多细节,在这种情况下,是我们感兴趣的特定物体的运动,并且只关注那些重要的属性。
牛顿第二定律并不是描述物体运动的唯一可能模型。物理学学生很快会发现其他更复杂的模型,例如考虑相对论质量的模型。一般来说,如果模型考虑了更多的量或其结构更复杂,则认为模型更复杂。例如,非线性模型通常比线性模型更复杂。确定在实际情况中应使用哪个模型并不像简单地选择一个更复杂的模型而不是一个简单的模型那样简单。事实上,这是我们将在本书中探讨的许多不同模型中反复出现的核心主题。为了构建我们对为什么是这样的直觉,考虑这样一种情况,即我们测量物体质量和施加力的仪器非常嘈杂。在这种情况下,投资使用更复杂的模型可能没有意义,因为我们知道由于输入的噪声,预测的额外准确性不会产生影响。另一种情况下,我们可能想要使用更简单的模型,因为我们应用中根本不需要额外的准确性。第三种情况是,一个更复杂的模型涉及一个我们无法测量的量。最后,如果由于复杂性,训练或做出预测需要太长时间,我们可能不想使用更复杂的模型。
从数据中学习
在这本书中,我们将要研究的模型有两个重要且定义性的特征。第一个特征是,我们不会使用数学推理或逻辑归纳从已知事实中产生模型,也不会从技术规范或业务规则中构建模型;相反,预测分析领域是从数据中构建模型的。更具体地说,我们将假设对于任何我们想要完成的预测任务,我们都会从与(或从)当前任务以某种方式相关(或派生)的数据开始。例如,如果我们想要构建一个模型来预测一个国家不同地区的年降雨量,我们可能已经收集(或拥有收集)了不同地点的降雨数据,同时测量潜在的感兴趣量,如海拔高度、纬度和经度。构建模型以执行我们的预测任务的力量源于这样一个事实,即我们将使用有限列表地点的降雨测量示例来预测我们没有收集任何数据的地方的降雨量。
我们将要构建的模型的问题的第二个重要特征是,在从某些数据构建模型以描述特定现象的过程中,我们必然会遇到一些随机性的来源。我们将称之为模型的随机或非确定性成分。可能的情况是,我们试图模拟的系统本身并不具有固有的随机性,但数据中包含随机成分。数据中随机性的一个很好的例子是从温度等数量读取的测量误差。不包含固有随机成分的模型被称为确定性模型,牛顿第二定律就是这样一个很好的例子。一个随机模型是假设被模拟的过程具有内在随机性的模型。有时,这种随机性的来源可能是无法测量可能影响系统的所有变量,我们只是选择用概率来模拟这种情况。一个纯随机模型的例子是掷一个公平的六面骰子。回想一下,在概率论中,我们使用随机变量这个术语来描述实验或随机过程的特定结果的值。在我们的掷骰子例子中,我们可以定义随机变量Y为掷一次骰子后正面朝上的面的点数,从而得到以下模型:

这个模型告诉我们,掷出特定数字的概率,比如说 3,是六分之一。请注意,我们并没有对掷骰子特定结果的预测做出明确判断;相反,我们是在说每个结果的可能性是相等的。
备注
概率是一个在日常生活中经常使用的术语,但同时也可能对其实际解释产生混淆。实际上,存在多种不同的概率解释方式。两种常见的解释是频率主义概率和贝叶斯概率。频率主义概率与可重复的实验相关联,例如掷一个单面的骰子。在这种情况下,看到数字 3 的概率,就是如果这个实验无限次重复,数字 3 出现的相对比例。贝叶斯概率与看到特定结果的主观信念程度或惊讶程度相关,因此可以用来赋予一次性事件意义,例如总统候选人赢得选举的概率。在我们的掷骰子实验中,我们看到数字 3 出现的惊讶程度与其他任何数字一样。请注意,在这两种情况下,我们仍在谈论相同的概率数值(1/6);只是解释不同。
在骰子模型的例子中,我们没有需要测量的变量。然而,在大多数情况下,我们将研究涉及多个独立变量的预测模型,这些变量将被用来预测一个因变量。预测建模借鉴了许多不同的领域,因此,根据你参考的特定文献,你经常会发现这些名称的不同。在我们进一步探讨这一点之前,让我们将一个数据集加载到 R 中。R 附带了一些常用的数据集已经加载,我们将选择其中最著名的,即鸢尾花数据集:
> head(iris, n = 3)
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
小贴士
要查看 R 附带的其他数据集,我们可以使用data()命令来获取数据集列表以及每个数据集的简要描述。如果我们修改了数据集中的数据,我们可以通过将数据集的名称作为输入参数提供给data()命令来重新加载它;例如,data(iris)重新加载了鸢尾花数据集。
iris数据集包含了对 150 个不同品种的鸢尾花样本进行的测量。在前面的代码中,我们可以看到每个样本进行了四个测量,即花瓣和萼片的长度和宽度。鸢尾花数据集通常被用作预测鸢尾花样本物种的典型基准,前提是提供前面提到的四个测量值。在文献中,萼片长度、萼片宽度、花瓣长度和花瓣宽度统称为特征、属性、预测变量、维度或自变量。在这本书中,我们更喜欢使用“特征”这个词,但其他术语同样有效。同样,数据框中的物种列是我们试图用我们的模型预测的内容,因此它被称为因变量、输出或目标。再次强调,在这本书中,我们将为了保持一致性而偏好一种形式,并使用“输出”。数据框中对应单个数据点的每一行被称为观测值,尽管它通常涉及观察多个特征值。
由于我们将使用数据集,例如前面描述的鸢尾花数据,来构建我们的预测模型,因此建立一些符号约定也是有帮助的。在这里,这些约定在大多数文献中都很常见。我们将使用大写字母 Y 来指代输出变量,并使用下标大写字母 X[i] 来表示第 i 个特征。例如,在我们的鸢尾花数据集中,我们有四个特征,我们可以将其称为 X[1] 到 X[4]。我们将使用小写字母表示单个观测值,因此 x[1] 对应于第一个观测值。请注意,x[1] 本身是一个特征成分的向量,x[ij],因此 x[12] 指的是第一个观测值中第二个特征的价值。我们将尽量少地使用双下标,并且为了简单起见,我们不会使用箭头或其他形式的向量符号。通常,我们讨论的是观测值或特征,因此变量的大小写将使读者清楚我们正在引用的是这两个中的哪一个。
当考虑使用数据集的预测模型时,我们通常假设对于具有 n 个特征的模型,存在一个真实或理想的函数 f,它将特征映射到输出:

我们将把这个函数称为我们的目标函数。在实践中,当我们使用我们可用的数据训练模型时,我们将产生我们自己的函数,我们希望这个函数是对目标函数的良好估计。我们可以通过在符号 f 上面放置一个撇号来表示我们的预测函数,以及输出 Y,因为预测函数的输出是预测输出。不幸的是,我们的预测输出并不总是与所有观测值(在我们的数据或一般情况中)的实际输出一致:

基于此,我们可以将预测建模概括为一个产生函数以预测一个数量的过程,同时最小化它与目标函数之间的误差。此时我们可以提出的一个好问题是,误差从何而来?换句话说,为什么我们通常不能通过分析数据集来精确地再现底层的目标函数?
这个问题的答案在于现实中存在几个潜在的误差来源,我们必须处理。记住,我们数据集中的每一个观测值都包含 n 个特征值,因此我们可以将我们的观测值几何地视为 n 维特征空间中的点。在这个空间中,我们的目标函数应该通过这些点,这正是目标函数的定义。如果我们现在考虑将函数拟合到有限点集的这个问题,我们会很快意识到实际上有无限多的函数可以穿过相同的点集。预测建模的过程涉及到在数据所使用的模型类型上做出选择,从而限制我们可以拟合数据的可能目标函数的范围。同时,无论我们选择什么模型,数据的固有随机性是无法消除的。这些想法使我们注意到在建模过程中遇到的误差类型的重要区别,即可减少误差和不可减少误差。
可减少误差基本上是指我们作为预测模型师可以通过选择一个对建模过程做出有效假设且其预测函数与潜在目标函数具有相同形式的模型结构来最小化的误差。例如,正如我们将在下一章中看到的,线性模型通过在其特征之间施加线性关系来组成输出。
这个限制性假设意味着,无论我们使用什么训练方法,我们有多少数据,以及我们投入多少计算能力,如果特征在现实世界中不是线性相关的,那么我们的模型必然会对至少一些可能的观测值产生误差。相比之下,一个不可减少误差的例子出现在尝试用不足的特征集构建模型时。这通常是常态而不是例外。通常,发现要使用哪些特征是构建准确模型中最耗时的一项活动。
有时候,我们可能无法直接测量一个我们知道很重要的特征。在其他时候,收集太多特征的数据可能只是不切实际或过于昂贵。此外,解决这个问题的方法并不仅仅是添加尽可能多的特征。向模型添加更多特征会使模型更加复杂,我们冒着添加与输出无关的特征的风险,从而在我们的模型中引入噪声。这也意味着我们的模型函数将有更多的输入,因此将是一个更高维空间中的函数。
向模型添加更多特征可能带来的潜在实际后果包括增加训练模型所需的时间,使最终解决方案的收敛更困难,以及在某些情况下(例如,与高度相关的特征一起)实际上降低模型精度。最后,我们必须接受的不可减少误差的另一个来源是我们测量特征时的误差,这样数据本身可能就是嘈杂的。
可减少误差不仅可以通过选择正确的模型来实现,还可以通过确保模型被正确训练来实现。因此,可减少误差也可能来自没有找到正确的特定函数来使用,考虑到模型假设。例如,即使我们正确选择了训练线性模型,我们仍然可以使用无限多的特征线性组合。正确选择模型参数(在这种情况下是线性模型的系数)也是最小化可减少误差的一个方面。当然,正确训练模型的大部分工作涉及使用良好的优化程序来拟合模型。在本书中,我们将至少给出我们研究的每个模型是如何训练的直观理解。我们通常避免深入探讨优化程序如何工作的数学,但我们确实为感兴趣的读者提供了相关文献的指针。
模型的核心理念
到目前为止,我们已经建立了一些模型背后的核心概念和讨论数据的通用语言。在本节中,我们将探讨统计模型的核心理念是什么。主要组件通常是:
-
一组需要调整参数的方程
-
一些代表我们试图建模的系统或过程的代表性数据
-
一个描述模型拟合优度的概念
-
一种更新参数以改进模型拟合优度的方法
正如我们将在本书中看到的,大多数模型,如神经网络、线性回归和支持向量机,都有某些参数化方程来描述它们。让我们看看一个线性模型,它试图从三个输入特征(我们将它们称为 X[1]、X[2] 和 X[3])预测输出 Y:

这个模型恰好有一个方程来描述它,这个方程提供了模型的线性结构。该方程由四个参数参数化,在这种情况下称为系数,它们是四个 β 参数。在下一章中,我们将看到这些参数的确切作用,但在此讨论中,重要的是要注意线性模型是参数化模型的一个例子。参数集通常比可用的数据量小得多。
给定一组方程和一些数据,我们接下来讨论训练模型。这涉及到为模型的参数赋值,以便模型能更准确地描述数据。我们通常采用某些标准度量来描述模型对数据的拟合优度,即模型描述训练数据的好坏。训练过程通常是一个迭代过程,涉及到对数据进行计算,以便可以计算参数的新值,从而提高模型的拟合优度。例如,模型可以有一个目标或误差函数。通过对该函数求导并令其等于零,我们可以找到一组参数组合,它给我们带来最小的误差。一旦我们完成这个过程,我们就称该模型为训练好的模型,并说该模型已经从数据中学习到了。这些术语来自机器学习文献,尽管通常与统计学领域(该领域有自己的术语)进行比较。在这本书中,我们将主要使用机器学习的术语。
我们的第一个模型 - k 最近邻
为了使本章的一些想法更清晰,我们将介绍本书的第一个模型,k 最近邻,通常缩写为kNN。简而言之,这种方法实际上避免了构建一个显式的模型来描述我们数据中的特征是如何组合以产生目标函数的。相反,它依赖于这样的观点:如果我们试图对从未见过的数据点进行预测,我们将查看原始训练数据,并找到与我们的新数据点最相似的k个观测值。然后,我们可以使用某种平均技术对这些k 个邻居的目标函数已知值进行计算,以得出预测。让我们通过一个例子来理解这一点。假设我们收集到一个新的未识别的鸢尾花样本,其测量值如下:
> new_sample
Sepal.Length Sepal.Width Petal.Length Petal.Width
4.8 2.9 3.7 1.7
我们希望使用 kNN 算法来预测我们应该使用哪种花卉种类来识别我们的新样本。使用 kNN 算法的第一步是确定新样本的 k 个最近邻。为了做到这一点,我们不得不给出一个更精确的定义,即两个观测值彼此相似意味着什么。一种常见的方法是在特征空间中计算两个观测值之间的数值距离。直观上,相似的两个观测值在特征空间中会彼此靠近,因此它们之间的距离会很小。为了计算特征空间中两个观测值之间的距离,我们通常使用欧几里得距离,这是两点之间直线段的长度。两个观测值x[1]和x[2]之间的欧几里得距离计算如下:

记住,前面公式中的第二个后缀j对应于j^(th)特征。所以,这个公式本质上告诉我们的是,对于每个特征,取两个观测值值差的平方,将这些平方差加起来,然后取结果的平方根。有许多其他可能的距离定义,但在 kNN 设置中这是最常遇到的一种。我们将在第十一章推荐系统中看到更多的距离度量。
为了找到我们新样本鸢尾花的最近邻居,我们必须计算与鸢尾数据集中每个点的距离,然后对结果进行排序。我们将首先对鸢尾数据框进行子集化,只包括我们的特征,从而排除物种列,这是我们试图预测的内容。然后,我们将定义自己的函数来计算欧几里得距离。接下来,我们将使用apply()函数计算数据框中每个鸢尾观测值的距离。最后,我们将使用 R 的sort()函数,并将index.return参数设置为TRUE,这样我们也会得到对应于每个计算出的距离的行号索引:
> iris_features <- iris[1:4]
> dist_eucl <- function(x1, x2) sqrt(sum((x1 - x2) ^ 2))
> distances <- apply(iris_features, 1,
function(x) dist_eucl(x, new_sample))
> distances_sorted <- sort(distances, index.return = T)
> str(distances_sorted)
List of 2
$ x : num [1:150] 0.574 0.9 0.9 0.949 0.954 ...
$ ix: int [1:150] 60 65 107 90 58 89 85 94 95 99 ...
$x属性包含计算出的样本鸢尾花与鸢尾数据框中的观测值之间的距离的实际值。$ix属性包含相应观测值的行号。如果我们想找到最近的五个邻居,我们可以使用$ix属性的前五个条目作为行号来对原始鸢尾数据框进行子集化:
> nn_5 <- iris[distances_sorted$ix[1:5],]
> nn_5
Sepal.Length Sepal.Width Petal.Length Petal.Width Species
60 5.2 2.7 3.9 1.4 versicolor
65 5.6 2.9 3.6 1.3 versicolor
107 4.9 2.5 4.5 1.7 virginica
90 5.5 2.5 4.0 1.3 versicolor
58 4.9 2.4 3.3 1.0 versicolor
如我们所见,我们样本的五个最近邻居中有四个是versicolor物种,而剩下的一个是virginica物种。对于这种选择类标签的问题,我们可以使用多数投票作为我们的平均技术来做出最终预测。因此,我们将我们的新样本标记为 versicolor 物种。请注意,将k的值设置为奇数是一个好主意,因为它使得我们遇到平局投票的可能性更小(当输出标签的数量为两个时,完全消除了平局)。
在出现平局的情况下,通常的做法是随机从平局的标签中选取一个来解决问题。请注意,在整个过程中,我们并没有尝试描述我们的四个特征与输出之间的关系。因此,我们通常将 kNN 模型称为懒惰学习器,因为本质上,它所做的只是记住训练数据,并在预测时直接使用它。我们将在 kNN 模型上有更多要说的,但首先我们将回到我们对模型的通用讨论,并讨论不同的分类方法。
模型的类型
在对模型的基本组成部分有一个广泛了解之后,我们准备探索一些模型师用来对不同的模型进行分类的常见区别。
监督学习、无监督学习、半监督学习和强化学习模型
我们已经研究了鸢尾花数据集,它包含四个特征和一个输出变量,即物种变量。在训练数据中,所有观测值都可用输出变量是监督学习设置的标志性特征,这代表了最常遇到的场景。简而言之,在监督学习设置下训练模型的优点是我们有正确的答案,这是我们应为训练数据中的数据点预测的。正如我们在上一节中看到的,kNN 是一个使用监督学习的模型,因为它通过结合该点附近少数邻居的输出变量值来对输入点进行预测。在这本书中,我们将主要关注监督学习。
使用输出变量值的可用性作为区分不同模型的方式,我们还可以设想第二种场景,其中输出变量未指定。这被称为无监督学习设置。无监督版本的鸢尾花数据集将只包含四个特征。如果我们没有可用的物种输出变量,那么我们显然不知道每个观测值指的是哪种物种。实际上,我们甚至不知道数据集中有多少种花卉物种,或者每种物种有多少观测值。乍一看,似乎没有这些信息,就无法执行任何有用的预测任务。事实上,我们可以检查数据,并基于我们可用的四个特征,根据观测值之间的相似性创建观测值组。这个过程被称为聚类。聚类的优点之一是我们可以在数据中发现自然的数据点组;例如,我们可能能够发现我们的鸢尾花集的无监督版本中的花样本形成了三个不同的组,分别对应三种不同的物种。
在无监督方法和监督方法之间,这两种方法在输出变量的可用性方面是两种绝对的区别,存在着半监督学习和强化学习设置。半监督模型是使用那些包含输出变量值的数据构建的,其中通常只有一小部分数据包含这些值,而其余的数据则完全未标记。许多这样的模型首先使用数据集的有标签部分来粗略地训练模型,然后通过将模型在此点之前预测的标签投影到未标记数据中,来包含这些未标记数据。
在强化学习环境中,输出变量不可用,但提供了与输出变量直接相关的其他信息。一个例子是根据完整棋局的数据预测下一步的最佳走法。在训练数据中,单个棋步没有输出值,但对于每一场比赛,每个玩家的集体走法序列最终导致胜利或失败。由于篇幅限制,本书没有涵盖半监督和强化学习设置。
参数和非参数模型
在前一个部分,我们提到了我们将遇到的大多数模型都是参数模型,并看到了一个简单线性模型的例子。参数模型的特点是它们倾向于定义函数形式。这意味着它们将选择目标函数所有可能函数的问题简化为特定函数族,该函数族形成一个参数集。选择将定义模型的特定函数本质上涉及选择参数的精确值。因此,回到我们三个特征线性模型的例子,我们可以看到我们有以下两种可能的参数选择(当然,选择是无限的;这里我们只演示两个具体的例子):

在这里,我们使用输出变量 Y 的下标来表示两种不同的可能模型。哪一种可能更好?答案是这取决于数据。如果我们将我们的每个模型应用于数据集中的观测值,我们将为每个观测值得到预测输出。在监督学习中,我们训练数据集中的每个观测值都带有输出变量的正确值。为了评估我们模型拟合的好坏,我们可以定义一个误差函数,该函数衡量我们的预测输出与正确输出之间的差异程度。然后我们使用这个函数在这两种候选模型之间进行选择,但更普遍的是通过一系列逐渐更好的候选模型来迭代改进模型。
一些参数模型比线性模型更灵活,这意味着它们可以用来捕捉更多可能的函数。线性模型要求输出是输入特征的线性加权组合,被认为是严格的。我们可以直观地看到,更灵活的模型更有可能让我们以更高的精度近似输入数据;然而,当我们看到过拟合时,我们会看到这并不总是好事。更灵活的模型也往往更复杂,因此训练它们通常比训练不那么灵活的模型更困难。
模型不一定需要参数化,实际上,没有参数的模型类别(不出所料)被称为非参数模型。非参数模型通常不对输出函数的特定形式做出假设。有不同方式构建没有参数的目标函数。样条函数是非参数模型的一个常见例子。样条函数背后的关键思想是我们设想输出函数,其形式对我们来说是未知的,它在对应于我们训练数据中所有观察点的点上被精确地定义。在点之间,函数通过使用平滑的多项式函数进行局部插值。本质上,输出函数是在我们的训练数据点之间的空间中以分段方式构建的。与大多数情况不同,样条函数将保证在训练数据上达到 100%的准确率,而我们的训练数据中存在一些错误是完全正常的。另一个很好的非参数模型例子是我们已经看到的 k-最近邻算法。
回归和分类模型
回归和分类模型之间的区别与我们要预测的输出类型有关,通常与监督学习相关。回归模型试图预测一个数值或定量值,例如股票市场指数、降雨量或项目的成本。分类模型试图从有限(尽管可能很大)的类别或类别集中预测一个值。这类例子包括预测网站的主题、用户接下来将要输入的下一个单词、一个人的性别,或者根据一系列症状预测患者是否患有特定疾病。在这本书中,我们将研究的多数模型都相当清晰地属于这两个类别之一,尽管一些模型,如神经网络,可以适应解决这两种类型的问题。在此强调,这里的区别仅在于输出,而不是用于预测输出的特征值本身是定量还是定性。一般来说,特征可以被编码成一种方式,使得定性和定量特征都可以在回归和分类模型中使用。早些时候,当我们构建一个 kNN 模型来根据花样本的测量值预测鸢尾花的物种时,我们解决的是一个分类问题,因为我们的物种输出变量只能取三个不同的标签之一。
kNN 方法也可以用于回归设置;在这种情况下,模型通过取平均值或中位数来结合所选最近邻的输出变量的数值,以便做出最终的预测。因此,kNN 也是一个可以在回归和分类设置中使用的模型。
实时和批量机器学习模型
预测模型可以使用实时机器学习,也可以涉及批量学习。实时机器学习的术语可以指两种不同的场景,尽管它当然不是指实时机器学习涉及在实时内做出预测,即在通常较小的时间限制内做出预测。例如,一旦训练好,一个神经网络模型只需进行少量计算(取决于输入数量和网络层数)就能产生其输出预测。但这并不是我们谈论实时机器学习时所指的是什么。
一个使用实时机器学习的良好例子是使用来自各种气象仪器的实时读数流进行天气预报的模型。在这里,模型的实时性指的是我们只取最近的一个读数窗口来预测天气。时间越往回推,读数的相关性就越低,因此我们可以选择只使用最新信息来做出预测。当然,用于实时环境的模型也必须能够快速计算其预测——如果早晨进行测量的系统需要数小时才能计算出晚上的预测,那么这几乎没有什么用处,因为当计算结束时,预测已经没有多少价值了。
当我们谈论考虑近期获得的信息来做出预测的模型时,我们通常指的是那些在假设代表未来模型将被要求做出预测的所有数据上训练过的模型。当我们描述检测到被建模的过程的性质以某种方式发生变化时,实时机器学习的第二种解释就出现了。当我们查看时间序列模型时,本书将重点关注第一种类型的例子。
预测建模的过程
通过查看一些不同的模型特征,我们已经暗示了预测建模过程中的各种步骤。在本节中,我们将按顺序介绍这些步骤,并确保我们理解每个步骤如何有助于整个工作的成功。
定义模型的客观目标
简而言之,每个项目的第一步是精确地确定期望的结果,因为这有助于我们在项目过程中做出良好的决策。在预测分析项目中,这个问题涉及到深入探讨我们想要进行的预测类型,并详细了解任务。例如,假设我们正在尝试构建一个预测公司员工流失率的模型。我们首先需要精确地定义这个任务,同时尽量避免使问题过于宽泛或过于具体。我们可以将流失率衡量为新全职员工在公司前六个月内离职的百分比。请注意,一旦我们正确地定义了问题,我们已经在思考我们将要使用的数据方面取得了一些进展。例如,我们不需要从兼职承包商或实习生那里收集数据。这项任务还意味着我们应该只从我们自己的公司收集数据,同时认识到我们的模型可能并不一定适用于为不同公司的员工群体做出预测。如果我们只对流失率感兴趣,这也意味着我们不需要预测员工的表现或病假(尽管避免未来的惊喜,询问我们为之人构建模型的人是有益的)。
一旦我们对想要构建的模型有了足够精确的想法,下一个合乎逻辑的问题是要问我们感兴趣实现什么样的性能,以及我们将如何衡量这一点。也就是说,我们需要为我们的模型定义一个性能指标,然后定义一个可接受的最低性能阈值。本书将详细讨论如何评估模型性能。现在,我们想要强调的是,尽管在用一些数据训练模型后讨论评估模型性能并不罕见,但在实践中,记住定义我们模型的期望和性能目标是预测模型师在项目初期就应该与项目利益相关者讨论的事情。模型永远不会完美,很容易陷入永远试图提高性能的模式。明确的目标性能不仅有助于我们决定使用哪些方法,而且有助于我们知道何时我们的模型已经足够好。
最后,我们还需要考虑在收集数据时我们将能够获得的数据,以及模型将被使用的上下文。例如,假设我们知道我们的员工流失率模型将作为决定我们公司新申请人是否被录用的因素之一。在这种情况下,我们应该只收集在我们招聘之前就可供使用的现有员工的数据。我们不能使用他们的第一次绩效评估结果,因为这项数据不会对潜在申请人可用。
收集数据
训练一个模型进行预测通常是一个数据密集型的项目,在这个行业中,如果你有什么东西永远都不嫌多,那就是数据。收集数据往往是整个过程中耗时和资源消耗最多的部分,这就是为什么确保定义任务和确定要收集的正确数据的第一步得到妥善处理是如此关键。当我们了解像逻辑回归这样的模型是如何工作时,我们通常是通过一个示例数据集来做到的,这也是本书我们将遵循的主要方法。不幸的是,我们没有一种方法来模拟收集数据的过程,可能会给人一种大部分努力都花在训练和改进模型上的印象。当我们使用现有数据集了解模型时,我们应该记住,通常已经投入了大量努力来收集、整理和预处理数据。我们将在下一节更详细地探讨数据预处理。
在收集数据的过程中,我们应始终牢记我们是否在收集正确类型的数据。我们在数据预处理期间对数据进行的大量合理性检查也适用于收集过程中,以便我们能够及早发现过程中是否犯了错误。例如,我们应该始终检查我们是否正确测量了特征以及是否使用了正确的单位。我们还应确保我们从足够新、可靠且与当前任务相关的来源收集数据。在我们之前章节中描述的员工流失模型中,当我们收集关于过去员工的信息时,我们应该确保我们在测量特征方面的一致性。例如,当我们测量一个人在我们公司工作了多少天时,我们应该始终一致地使用日历日或工作日。我们还必须检查在收集日期时,例如一个人加入或离开公司时,我们始终要么使用美国格式(月/日),要么使用欧洲格式(日/月),并且不要混合两种格式,否则像 03/05/2014 这样的日期将会模糊不清。我们还应尽可能从尽可能广泛的样本中获取信息,并在数据收集过程中避免引入隐藏的偏差。例如,如果我们想建立一个关于员工流失的通用模型,我们就不想只从女性员工或单一部门的员工那里收集数据。
我们如何知道我们已经收集了足够的数据?在早期收集数据时,如果我们还没有构建和测试任何模型,我们无法知道我们最终需要多少数据,也没有任何简单的经验法则可以遵循。然而,我们可以预测,我们问题的某些特征将需要更多的数据。例如,当我们构建一个将学会从三个类别之一进行预测的分类器时,我们可能想检查我们是否有了足够代表每个类别的观察结果。
我们拥有的输出类别越多,就需要收集更多的数据。同样,对于回归模型,检查训练数据中输出变量的范围是否与我们想要预测的范围相符也是有用的。如果我们正在构建一个覆盖较大输出范围的回归模型,与在相同精度要求下覆盖较小输出范围的回归模型相比,我们也需要收集更多的数据。
另一个帮助我们估计需要多少数据的重要因素是期望的模型性能。直观地讲,我们需要的模型精度越高,就应该收集更多的数据。我们还应该意识到,提高模型性能不是一个线性过程。从 90%的准确率提升到 95%,通常需要更多的努力和更多的数据,与从 70%提升到 90%相比,这种跨越需要更多的努力和数据。具有较少参数或设计更简单的模型,例如线性回归模型,通常比更复杂的模型,如神经网络,需要的数据更少。最后,我们想要将更多特征纳入模型,就应该收集更多的数据。此外,我们还应该意识到,这种对额外数据的需求也不是线性的。也就是说,构建具有两倍特征数量的模型,通常需要的原始数据量远不止两倍。如果我们考虑模型需要处理的不同输入组合的数量,这一点应该很容易理解。增加两倍的维度会导致可能的输入组合数量远超过两倍。为了理解这一点,假设我们有一个具有三个输入特征的模型,每个特征有 10 个可能的值。我们有 10³=1000 个可能的输入组合。增加一个额外的特征,该特征也有 10 个值,将组合数量提升到 10,000,这比我们初始输入组合的数量多得多。
人们尝试过获取一个更量化的视角来判断我们是否为特定数据集收集了足够的数据,但在这本书中我们没有时间涵盖这些内容。学习更多关于预测建模这一领域的好方法是从研究学习曲线开始。简而言之,我们通过从数据的一小部分开始,并在数据集上连续构建模型,逐步添加更多数据来构建模型。其理念是,如果在整个过程中,测试数据的预测精度始终在提高而没有下降,那么我们可能从获取更多数据中受益。作为数据收集阶段的最后一点,即使我们认为我们已经有了足够的数据,在决定停止收集并开始建模之前,我们也应该考虑获取更多数据将花费我们多少(以时间和资源衡量)。
选择模型
一旦我们明确了预测任务,并且拥有了正确类型的数据,下一步就是选择我们的第一个模型。首先,没有一种模型在总体上是最优的,甚至没有一个基于一些经验法则的最佳模型。在大多数情况下,从一个简单的模型开始,比如在分类任务中使用朴素贝叶斯模型或逻辑回归,或者在回归任务中使用线性模型,是有意义的。一个简单的模型将为我们提供一个起始的基准性能,然后我们可以努力提高。一开始选择一个简单的模型也可能有助于回答一些有用的问题,例如每个特征如何对结果产生影响,也就是说,每个特征的重要性如何,以及与输出的关系是正相关还是负相关。有时,这种分析本身就需要首先生产一个简单的模型,然后是一个更复杂的模型,该模型将用于最终的预测。
有时,一个简单的模型可能已经足够准确,以至于我们不需要投入更多努力来获得一点额外的效果。另一方面,一个简单的模型通常不足以完成任务,需要我们选择更复杂的模型。选择比简单模型更复杂的模型并不总是直截了当的决定,即使我们可以看到复杂模型的准确性将大大提高。某些约束,如我们拥有的特征数量或数据的可用性,可能阻止我们转向更复杂的模型。了解如何选择模型涉及到理解我们工具箱中各种模型的优势和局限性。对于本书中遇到的每个模型,我们将特别关注学习这些要点。在实际项目中,为了帮助指导我们的决策,我们通常会回到任务要求,并问一些问题,例如:
-
我们的任务类型是什么?有些模型只适合特定的任务,如回归、分类或聚类。
-
模型需要解释其预测吗?一些模型,如决策树,在提供易于解释的见解方面做得更好,可以解释为什么它们做出了特定的预测。
-
我们对预测时间有什么限制?
-
我们是否需要频繁地更新模型,因此训练时间很重要?
-
如果我们具有高度相关的特征,模型是否表现良好?
-
我们拥有的特征数量和数据量是否适合模型扩展?如果我们有大量的数据,我们可能需要一个可以并行化训练过程以利用并行计算机架构的模型,例如。
在实践中,即使我们的初步分析指向了特定的模型,我们很可能在做出最终决定之前会尝试多种选项。
数据预处理
在我们能够使用数据来训练模型之前,我们通常需要对其进行预处理。在本节中,我们将讨论我们通常执行的一些常见预处理步骤。其中一些是为了检测和解决我们数据中的问题所必需的,而其他一些则是为了转换我们的数据,使它们适用于我们选择的模型。
探索性数据分析
一旦我们有一些数据并决定开始工作于特定的模型,我们首先想要做的就是查看数据本身。这并不一定是一个结构化的过程部分;它主要涉及理解每个特征所测量的内容,以及对我们收集到的数据的感知。真正重要的是要理解每个特征代表什么以及它的测量单位。检查单位的一致使用也是一个非常好的主意。我们有时将探索和可视化我们数据的这一调查过程称为探索性数据分析。
一个很好的实践是使用 R 的 summary() 函数对我们的数据框进行操作,以获取每个特征的某些基本指标,例如均值和方差,以及最大值和最小值。有时,通过数据中的不一致性,我们很容易发现数据收集过程中出现了错误。例如,对于一个回归问题,具有相同特征值但输出结果差异极大的多个观测值(根据应用情况)可能是一个信号,表明存在错误的测量。同样,了解是否存在任何在存在显著噪声的情况下测量的特征也是一个好主意。这有时可能导致模型选择的不同,或者意味着该特征应该被忽略。
小贴士
另一个用于总结数据框中特征的常用函数是 psych 包中的 describe() 函数。该函数返回有关每个特征偏斜程度的信息,以及位置(如均值和中位数)和分散度(如标准差)的常规度量。
探索性数据分析的一个基本部分是使用图表来可视化我们的数据。根据上下文,我们可以使用各种图表。例如,我们可能想要创建数值特征的箱线图来可视化范围和四分位数。条形图和马赛克图有助于可视化不同组合的类别输入特征的数值比例。我们不会进一步详细介绍信息可视化,因为这是一个独立的领域。
小贴士
R 是一个创建可视化的优秀平台。base R 软件包提供了一系列不同的函数来绘制数据。两个用于创建更高级图表的优秀包是 lattice 和 ggplot2。这两本书都是 Springer 出版的 Use R! 系列中的,分别是 Lattice: Multivariate Data Visualization with R 和 ggplot2: Elegant Graphics for Data Analysis,它们也是制作有效可视化所使用原则的良好参考。
特征转换
通常,我们会发现我们的数值特征是在完全不同的尺度上测量的。例如,我们可能会用摄氏度来测量一个人的体温,因此数值通常在 36-38 之间。同时,我们也可能测量一个人每微升血液中的白细胞计数。这个特征通常取值在数千。如果我们将这些特征作为算法(如 kNN)的输入,我们会发现白细胞计数的较大值会主导欧几里得距离计算。我们可能会有几个在输入中重要的特征,对分类很有用,但如果它们是在产生数值远小于一千的尺度上测量的,我们实际上主要是基于单个特征(即白细胞计数)来选择最近的邻居。这个问题经常出现,并且适用于许多模型,而不仅仅是 kNN。我们通过在模型中使用之前对输入特征进行转换(也称为缩放)来处理这个问题。
我们将讨论三种流行的特征缩放选项。当我们知道我们的输入特征接近正态分布时,可以使用的一种可能的转换是 Z 分数标准化,它通过减去平均值并除以标准差来实现:

E(x) 是 x 的期望或平均值,标准差是 x 方差的平方根,写作 Var(x)。注意,由于这种转换,新的特征将围绕零平均值和单位方差进行中心化。另一种可能的转换,当输入均匀分布时更好,是将所有特征和输出缩放,使它们位于单个区间内,通常是单位区间 [0,1]:

第三种选项被称为 Box-Cox 转换。这通常在我们输入特征高度偏斜(不对称)且我们的模型要求输入特征至少是正态分布或对称时应用:

由于λ位于分母中,它必须取一个非零的值。实际上,这个变换是对零值的λ定义的:在这种情况下,它由输入特征的自然对数ln(x)给出。请注意,这是一个参数化变换,因此需要指定λ的具体值。从数据本身估计一个合适的λ值有多种方法。例如,我们将提到一种称为交叉验证的技术,我们将在本书的第五章中遇到,即支持向量机。
注意
Box-Cox 变换的原始参考文献是 1964 年由皇家统计学会期刊发表的一篇论文,标题为《变换分析》,作者是G. E. P. Box和D. R. Cox。
为了了解这些转换在实际中的工作方式,我们将尝试在我们的鸢尾花数据集的Sepal.Length特征上应用它们。然而,在我们这样做之前,我们将介绍我们将要使用的第一个 R 包,即caret包。
caret包是一个非常实用的包,它有几个目标。它提供了一系列在预测建模过程中常用的有用函数,从数据预处理和可视化,到特征选择和重采样技术。它还提供了一个统一接口,用于许多预测建模函数,并提供并行处理的功能。
注意
使用caret包进行预测建模的权威参考文献是一本名为《应用预测建模》的书,由Max Kuhn和Kjell Johnson撰写,并由Springer出版。Max Kuhn是caret包的主要作者。这本书还附带一个配套网站,网址为appliedpredictivemodeling.com。
当我们将输入特征转换为用于训练模型的训练数据时,我们必须记住,我们还需要将相同的转换应用于预测时将使用的后续输入的特征。因此,使用caret包转换数据分为两个步骤。在第一步中,我们使用preProcess()函数来存储要应用于数据的转换参数,在第二步中,我们使用predict()函数来实际计算转换。我们倾向于只使用一次preProcess()函数,然后在需要将相同的转换应用于某些数据时每次使用predict()函数。preProcess()函数的第一个输入是一个包含一些数值的数据框,我们还将指定一个包含要应用于method参数的转换名称的向量。然后predict()函数将前一个函数的输出以及我们想要转换的数据作为输入,在这种情况下,训练数据本身可能就是相同的数据框。让我们看看这一切是如何运作的:
> library("caret")
> iris_numeric <- iris[1:4]
> pp_unit <- preProcess(iris_numeric, method = c("range"))
> iris_numeric_unit <- predict(pp_unit, iris_numeric)
> pp_zscore <- preProcess(iris_numeric, method = c("center", "scale"))
> iris_numeric_zscore <- predict(pp_zscore, iris_numeric)
> pp_boxcox <- preProcess(iris_numeric, method = c("BoxCox"))
> iris_numeric_boxcox <- predict(pp_boxcox, iris_numeric)
小贴士
下载示例代码:
您可以从www.packtpub.com下载示例代码文件,以获取您购买的所有 Packt Publishing 书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
我们为鸢尾花数据的数值特征创建了三个新版本,区别在于每种情况下我们使用了不同的转换。我们可以通过使用density()函数绘制每个缩放数据框的Sepal.Length特征的密度,并绘制结果来可视化我们转换的效果,如下所示:

注意,Z 分数和单位区间转换在平移和缩放值的同时保留了密度的整体形状,而 Box-Cox 转换也改变了整体形状,导致密度比原始密度更少偏斜。
编码分类特征
许多模型,从线性回归到神经网络,都需要所有输入都是数值的,因此我们通常需要一种方法来对分类字段进行数值编码。例如,如果我们有一个大小特征,其值在集合{small, medium, large}中,我们可能希望用数值 1、2 和 3 分别表示它。在有序分类的情况下,例如前面描述的大小特征,这种映射可能是有意义的。
数字 3 是这个尺度上最大的,它对应于大类别,它比小类别(由数字 1 表示)更远离,比中类别(由值 2 表示)更近。使用这个尺度只是一种可能的映射,特别是它迫使中类别与大和小类别等距,这可能是或不可能是基于我们对特定特征的知识的适当做法。在无序类别的情况下,例如品牌或颜色,我们通常避免将它们映射到单个数值尺度上。例如,如果我们将集合{blue, green, white, red, orange}分别映射到数字一至五,那么这个尺度是任意的,没有理由说明为什么red比white更近,而比blue更远。为了克服这一点,我们创建了一系列指标特征,I[i],它们具有以下形式:

我们需要的指标特征数量与类别数量相同,所以对于我们的颜色示例,我们会创建五个指标特征。在这种情况下,I[1]可能如下所示:

以这种方式,我们的原始颜色特征将被映射到五个指标特征,并且对于每一个观测,这些指标特征中只有一个取值为 1,其余为 0,因为每个观测将涉及我们原始特征中的一个颜色值。指标特征是二元特征,因为它们只取两个值:0 和 1。
注意
我们可能会经常遇到一种替代方法,它只使用n-1个二元特征来编码n个因素的水平。这是通过选择一个水平作为参考水平,并且指示每个n-1个二元特征取值为 0 的位置来实现的。这样做可以在特征数量上更加经济,并避免引入它们之间的线性依赖性,但它违反了所有特征彼此等距的性质。
缺失数据
有时,数据中包含缺失值,其中对于某些观测,一些特征不可用或无法正确测量。例如,假设在我们的鸢尾花数据集中,我们丢失了一个特定观测的瓣长测量值。那么,在这个花朵样本的Petal.Length特征中,我们就会有一个缺失值。大多数模型都没有处理缺失数据的内在能力。通常,缺失值在我们的数据中表现为空白条目或符号NA。我们应该检查我们的数据中是否确实存在缺失值,但被错误地分配了值,例如0,这通常是一个非常合法的特征值。
在决定如何处理缺失数据之前,尤其是当我们打算简单地丢弃具有缺失值的观测值时,我们应该认识到缺失的特定值可能遵循某种模式。具体来说,我们通常区分不同所谓的缺失值机制。在理想的完全随机缺失(MCAR)场景中,缺失值独立于它们出现的特征的真实值,以及所有其他特征。在这种情况下,如果我们缺少特定鸢尾花花瓣长度的值,那么这独立于花瓣的实际长度以及任何其他特征的值,例如观察值是否来自杂色物种或设特兰物种。随机缺失(MAR)场景是一个不太理想的情况。在这里,缺失值独立于所讨论特征的真正值,但可能与另一个特征相关。这种情况的一个例子是,在我们的鸢尾花数据集中,缺失花瓣长度值主要出现在设特兰样本中,只要它们仍然独立于真正的花瓣长度值。
在非随机缺失(MNAR)场景中,这是最棘手的情况,存在某种模式,可以解释基于特征的真正值何时可能缺失。例如,如果我们很难测量非常小的花瓣长度,并最终得到缺失值,那么简单地删除不完整的样本将导致具有高于平均花瓣长度的观测值的样本,因此我们的数据将存在偏差。
处理缺失值的方法有很多,但在这本书中我们不会深入探讨这个问题。在极少数情况下,如果我们遇到缺失值,我们会将它们从数据集中排除,但请注意,在实际项目中,我们会调查缺失值的原因,以确保我们可以安全地这样做。另一种方法是尝试猜测或估计缺失值。kNN 算法本身通过找到具有缺失值的特征样本的最近邻来做到这一点。这是通过使用排除包含缺失值的维度的距离计算来完成的。然后,缺失值被计算为该维度最近邻值的平均值。
注意
对此感兴趣的读者可以在由 Wiley 出版的《缺失数据统计分析》(第二版)中找到如何处理缺失值的详细说明,作者是Roderick J. A. Little和Donald B. Rubin。
异常值
异常值也是一个经常需要解决的问题。异常值是指在其一个或多个特征中,与数据集中其他数据点距离非常远的特定观测值。在某些情况下,这可能代表一个实际罕见的情形,是我们试图建模的系统的一种合法行为。在其他情况下,可能是因为测量错误。例如,在报告人们的年龄时,110 岁可能是一个异常值,这可能是由于实际值为 11 的报告中出现了错误。它也可能是有效但极其罕见的测量结果。通常,我们问题的领域会给我们一个很好的指示,说明异常值是否可能是测量错误,如果是这样,作为数据预处理的一部分,我们通常会希望完全排除数据中的异常值。在第二章 线性回归中,我们将更详细地探讨异常值的排除。
移除问题特征
预处理数据集还可能涉及决定删除一些特征,如果我们知道它们会给我们模型带来问题。一个常见的例子是当两个或更多特征彼此之间高度相关时。在 R 中,我们可以使用cor()函数轻松地在数据框上计算成对的相关性:
> cor(iris_numeric)
Sepal.Length Sepal.Width Petal.Length Petal.Width
Sepal.Length 1.0000000 -0.1175698 0.8717538 0.8179411
Sepal.Width -0.1175698 1.0000000 -0.4284401 -0.3661259
Petal.Length 0.8717538 -0.4284401 1.0000000 0.9628654
Petal.Width 0.8179411 -0.3661259 0.9628654 1.0000000
在这里,我们可以看到Petal.Length特征与Petal.Width特征高度相关,相关系数超过 0.96。caret包提供了findCorrelation()函数,它接受一个相关矩阵作为输入,以及可选的cutoff参数,该参数指定成对相关系数的绝对值阈值。然后它返回一个(可能为零长度)向量,显示由于相关性需要从我们的数据框中删除的列。cutoff的默认设置为 0.9:
> iris_cor <- cor(iris_numeric)
> findCorrelation(iris_cor)
[1] 3
> findCorrelation(iris_cor, cutoff = 0.99)
integer(0)
> findCorrelation(iris_cor, cutoff = 0.80)
[1] 3 4
移除相关性的另一种方法是完全转换整个特征空间,正如在许多降维方法中(如主成分分析(PCA)和奇异值分解(SVD))所做的那样。我们很快就会看到前者,后者我们将在第十一章 推荐系统中探讨。
类似地,我们可能想要删除彼此是线性组合的特征。通过特征的线性组合,我们指的是特征的总和,其中每个特征都乘以一个标量常数。为了了解caret如何处理这些,我们将创建一个新的 iris 数据框,并添加两个额外的列,我们将它们称为Cmb和Cmb.N,如下所示:
> new_iris <- iris_numeric
> new_iris$Cmb <- 6.7 * new_iris$Sepal.Length –
0.9 * new_iris$Petal.Width
> set.seed(68)
> new_iris$Cmb.N <- new_iris$Cmb +
rnorm(nrow(new_iris), sd = 0.1)
> options(digits = 4)
> head(new_iris,n = 3)
Sepal.Length Sepal.Width Petal.Length Petal.Width Cmb Cmb.N
1 5.1 3.5 1.4 0.2 33.99 34.13
2 4.9 3.0 1.4 0.2 32.65 32.63
3 4.7 3.2 1.3 0.2 31.31 31.27
如我们所见,Cmb 是 Sepal.Length 和 Petal.Width 特征的完美线性组合。Cmb.N 是一个与 Cmb 相同的特征,但添加了一些均值为零且标准差非常小(0.1)的高斯噪声,因此其值非常接近 Cmb 的值。caret 包可以使用 findLinearCombos() 函数检测特征的确切线性组合,尽管当特征有噪声时则不行:
> findLinearCombos(new_iris)
$linearCombos
$linearCombos[[1]]
[1] 5 1 4
$remove
[1] 5
如我们所见,该函数仅建议我们应该从数据框中移除第五个特征(Cmb),因为它是一、四两个特征的精确线性组合。精确的线性组合很少见,但有时在我们有非常多的特征且它们之间存在冗余时会出现。相关特征以及线性组合都是线性回归模型的问题,正如我们将在第二章,线性回归中很快看到的,也是一个问题。在本章中,我们还将看到一个检测特征彼此之间几乎为线性组合的方法。
我们将探讨的最后一个问题是具有特征在数据集中完全没有变化,或者具有几乎零方差的问题。对于某些模型,具有这些类型的特征不会引起我们问题。对于其他模型,它可能会造成问题,我们将展示为什么这是这种情况。与前面的例子一样,我们将创建一个新的鸢尾花数据框,如下所示:
> newer_iris <- iris_numeric
> newer_iris$ZV <- 6.5
> newer_iris$Yellow <- ifelse(rownames(newer_iris) == 1, T, F
> head(newer_iris, n = 3)
Sepal.Length Sepal.Width Petal.Length Petal.Width ZV Yellow
1 5.1 3.5 1.4 0.2 6.5 TRUE
2 4.9 3.0 1.4 0.2 6.5 FALSE
3 4.7 3.2 1.3 0.2 6.5 FALSE
ZV 列对所有观测值都有恒定的数字 6.5。Yellow 列是一个虚构的列,记录观测值的花瓣上是否有黄色。除了第一个观测值外,所有观测值都被设置为具有此特征的 FALSE,因此这是一个几乎零方差列。caret 包使用一种定义来检查特征所取的唯一值数量与观测总数相比非常小,或者最常见值与第二常见值(称为频率比)的比率非常高。将 nearZeroVar() 函数应用于数据框返回一个包含具有零或几乎零方差特征的向量。通过将 saveMetrics 参数设置为 TRUE,我们可以看到关于数据框中特征更多的信息:
> nearZeroVar(newer_iris)
[1] 5 6
> nearZeroVar(newer_iris, saveMetrics = T)
freqRatio percentUnique zeroVar nzv
Sepal.Length 1.111 23.3333 FALSE FALSE
Sepal.Width 1.857 15.3333 FALSE FALSE
Petal.Length 1.000 28.6667 FALSE FALSE
Petal.Width 2.231 14.6667 FALSE FALSE
ZV 0.000 0.6667 TRUE TRUE
Yellow 149.000 1.3333 FALSE TRUE
在这里,我们可以看到ZV列已被识别为零方差列(这根据定义也是一个接近零方差列)。黄色列确实有一个非零方差,但它的频率比高和唯一值百分比低,使其成为一个接近零方差列。在实践中,我们倾向于移除零方差列,因为它们对我们的模型没有任何信息可以提供。然而,移除接近零方差列却很棘手,应该谨慎进行。为了理解这一点,考虑这样一个事实:一个用于物种预测的模型,使用我们更新的鸢尾花数据集,可能会学习到如果一个样本的花瓣是黄色的,那么无论其他所有预测因素如何,我们都会预测为塞托萨物种,因为这是在我们整个数据集中唯一一个花瓣呈黄色的观察结果对应的物种。在现实中,这可能是真的,在这种情况下,黄色特征是有信息的,我们应该保留它。另一方面,鸢尾花花瓣上出现黄色可能是完全随机的,并不表明物种,但也是一个极其罕见的事件。这可以解释为什么在我们的数据集中只有一个观察结果的花瓣是黄色的。在这种情况下,保留这个特征是危险的,因为上述结论。保留这个特征的另一个潜在问题将在我们查看将数据分为训练集和测试集,以及其他数据分割情况时变得明显,例如在第五章中描述的交叉验证,支持向量机。在这里,问题是我们的数据中的一个分割可能会导致接近零方差列的唯一值,例如,我们的黄色鸢尾花列中只有FALSE值。
特征工程和降维
我们在模型中使用特征的数量和类型是在预测建模过程中我们将做出的最重要的决定之一。拥有适合模型的特征将确保我们有足够的证据来基于此进行预测。另一方面,我们处理特征的数量正是模型维度的数量。大量的维度可能成为几个复杂问题的源头。高维问题通常遭受数据稀疏性的困扰,这意味着由于可用的维度数量,所有特征之间可能值的组合范围变得非常大,以至于我们不太可能收集到足够的数据,以便有足够的代表性示例用于训练。在类似的情况下,我们经常谈论维度诅咒。这描述了这样一个事实,由于可能的输入空间极其庞大,我们收集的数据点在特征空间中可能彼此相距甚远。因此,使用训练数据中靠近我们试图进行预测的点进行预测的局部方法,如 k 最近邻,在高维中可能不会工作得很好。大型特征集也存在问题,因为它可能会显著增加我们训练(在某些情况下预测)模型所需的时间。
因此,特征工程涉及两种类型的过程。其中第一种,即扩展特征空间,是基于我们数据中的特征设计新特征。有时,一个新特征可能是两个原始特征的乘积或比率,可能会工作得更好。有许多方法可以将现有特征组合成新的特征,并且通常需要从问题的特定应用领域获取专家知识来指导我们。然而,总的来说,这个过程需要经验和大量的试错。请注意,添加新特征并不保证不会降低性能。有时,添加一个非常嘈杂或与现有特征高度相关的特征实际上可能导致我们失去准确性。
特征工程中的第二个过程是特征减少或收缩,它减少了特征空间的大小。在数据预处理的前一节中,我们探讨了如何检测可能对我们模型有问题的单个特征。特征选择是指从原始特征集中选择出对目标输出最有信息量的特征子集的过程。一些方法,如基于树的模型,具有内置的特征选择功能,我们将在第六章(第六章,基于树的算法)中看到。在第二章(第二章,线性回归)中,我们还将探讨为线性模型执行特征选择的方法。
另一种减少特征总数的方法,称为降维,是将整个特征集转换成数量更少的新特征集。这个概念的典型例子是主成分分析(PCA)。
简而言之,主成分分析(PCA)创建了一组新的输入特征,称为主成分,这些主成分都是原始输入特征的线性组合。对于第一个主成分,线性组合的权重被选择以捕捉数据中的最大变异量。如果我们能将第一个主成分可视化为原始特征空间中的一条线,那么这条线就是数据变化最大的线。同时,这条线也是原始特征空间中所有数据点最近的一条线。每一个后续的主成分都试图捕捉一条最大变异的线,但以这种方式,新的主成分与已经计算出的前一个主成分不相关。因此,第二个主成分选择原始输入特征中数据变异程度最高的线性组合,同时与第一个主成分不相关。
主成分根据它们捕捉的变异量自然地按降序排列。这允许我们通过保留前N个成分来简单地执行降维,其中我们选择N,使得所选成分包含原始数据集中最小量的方差。我们不会深入探讨计算主成分所需的底层线性代数的细节。
相反,我们将注意力转向这样一个事实,这个过程对原始特征的方差和尺度很敏感。因此,我们在执行此过程之前通常会对特征进行缩放。为了可视化 PCA 的有用性,我们再次转向我们忠实的鸢尾花数据集。我们可以使用caret包来执行 PCA。为此,我们在preProcess()函数的method参数中指定pca。我们还可以使用thresh参数,该参数指定我们必须保留的最小方差。我们将明确使用值0.95以保留原始数据的 95%方差,但请注意,这也是此参数的默认值:
> pp_pca <- preProcess(iris_numeric, method = c("BoxCox", "center", "scale", "pca"), thresh = 0.95)
> iris_numeric_pca <- predict(pp_pca, iris_numeric)
> head(iris_numeric_pca, n = 3)
PC1 PC2
1 -2.304 -0.4748
2 -2.151 0.6483
3 -2.461 0.3464
由于这种转换,我们现在只剩下两个特征,因此我们可以得出结论,数值鸢尾花特征的前两个主成分包含了数据中超过 95%的变异。
如果我们感兴趣的是学习用于计算主成分的权重,我们可以检查pp_pca对象的rotation属性:
> options(digits = 2)
> pp_pca$rotation
PC1 PC2
Sepal.Length 0.52 -0.386
Sepal.Width -0.27 -0.920
Petal.Length 0.58 -0.049
Petal.Width 0.57 -0.037
这意味着第一个主成分PC1的计算如下:

有时,我们可能不想直接指定由主成分捕获的总方差阈值,而是想检查每个主成分及其方差的图表。这被称为碎石图,我们可以通过首先执行 PCA 并指示我们想要保留所有成分来构建这个图表。为此,我们不是指定方差阈值,而是设置pcaComp参数,这是我们想要保留的主成分数量。我们将将其设置为4,这包括所有成分,记住主成分的总数与原始特征或维度的总数相同。然后我们将计算这些成分的方差和累积方差,并将其存储在一个数据框中。最后,我们将在这个后续的图表中绘制这些数据,注意括号中的数字是方差捕获的累积百分比:
> pp_pca_full <- preProcess(iris_numeric, method = c("BoxCox", "center", "scale", "pca"), pcaComp = 4)
> iris_pca_full <- predict(pp_pca_full, iris_numeric)
> pp_pca_var <- apply(iris_pca_full, 2, var)
> iris_pca_var <- data.frame(Variance = round(100 * pp_pca_var / sum(pp_pca_var), 2), CumulativeVariance = round(100 * cumsum(pp_pca_var) / sum(pp_pca_var), 2))
> iris_pca_var
Variance CumulativeVariance
PC1 73.45 73.45
PC2 22.82 96.27
PC3 3.20 99.47
PC4 0.53 100.00

如我们所见,在鸢尾花数据集中,第一主成分解释了总方差的 73.45%,而与第二个成分一起,总方差捕获率为 96.27%。PCA 是一种降维的无监督方法,即使输出变量可用,也不使用输出变量。相反,它在特征空间中从几何角度观察数据。这意味着我们无法保证 PCA 会给我们一个新的特征空间,这个空间在我们的预测问题中表现良好,除了具有更少特征的计算优势之外。这些优势可能会使得 PCA 即使在模型精度有所降低的情况下(只要这种降低是小的且对特定任务可接受)也是一个可行的选择。最后,我们应该指出,主成分的权重,通常被称为载荷,只要它们被归一化,其符号翻转是唯一的。在我们有完全相关的特征或完美的线性组合的情况下,我们将获得几个恰好为零的主成分。
训练和评估模型
在我们之前关于参数模型的讨论中,我们看到了它们使用一组训练数据来训练模型的程序。非参数模型通常会执行懒惰学习,在这种情况下,实际上并没有真正的训练程序,除了记住训练数据之外,或者,就像样条曲线的情况一样,在训练数据上执行局部计算。
无论哪种方式,如果我们想要评估我们模型的性能,我们需要将我们的数据分为训练集和测试集。关键思想是我们希望根据我们预期它在未见过的未来数据上的表现来评估我们的模型。我们通过使用测试集来完成这一点,这是我们收集并为此目的保留的数据的一部分(通常是 15-30%),在训练过程中我们没有使用这部分数据。例如,一种可能的划分是,将原始数据中的 80%作为训练集,剩下的 20%作为测试集。我们需要测试集的原因是我们不能使用训练集来公平地评估我们的模型性能,因为我们已经将模型拟合到训练数据上,它并不代表我们之前未见过的数据。从预测的角度来看,如果我们的目标是仅在我们自己的训练数据上最大化性能,那么最好的做法就是简单地记住输入数据以及期望的输出值,因此我们的模型将只是一个简单的查找表!
一个值得问的问题是:我们如何决定用于训练和测试的数据量?这里涉及到的权衡使得这个问题的答案并不简单。一方面,我们希望尽可能多地使用训练集中的数据,这样模型就有更多的例子来学习。另一方面,我们希望有一个大的测试集,这样我们可以使用许多例子来测试我们的训练模型,以最小化我们对模型预测性能估计的方差。如果我们测试集中的观察值只有几个,那么我们实际上无法对模型在未见数据上的整体表现进行概括。
另一个需要考虑的因素是我们收集了多少起始数据。如果我们数据非常少,我们可能需要使用更多的数据来训练我们的模型,例如 85-15 的分割。如果我们有足够的数据,那么我们可能会考虑 70-30 的分割,以便在测试集上获得更准确的预测。
要使用caret包分割数据集,我们可以使用createDataPartition()函数创建一个包含我们将用于训练集的行索引的采样向量。这些是通过随机采样行直到达到指定的行比例来选择的,使用p参数:
> set.seed(2412)
> iris_sampling_vector <- createDataPartition(iris$Species, p = 0.8, list = FALSE)
小贴士
在报告涉及随机数生成的统计分析结果时,一个好的做法是在随机选择的但固定的数字上应用set.seed()函数。这个函数确保每次代码运行时,从下一个涉及随机数生成的函数调用生成的随机数都是相同的。这样做是为了让阅读分析的其他人能够精确地重现结果。注意,如果我们代码中有几个执行随机数生成的函数,或者同一个函数被多次调用,我们理想上应该在它们每一个之前应用set.seed()。
使用我们为鸢尾花数据集创建的采样向量,我们可以构建我们的训练集和测试集。我们将为之前在尝试不同的特征转换时构建的几个版本的鸢尾花数据集做这件事:
> iris_train <- iris_numeric[iris_sampling_vector,]
> iris_train_z <- iris_numeric_zscore[iris_sampling_vector,]
> iris_train_pca <- iris_numeric_pca[iris_sampling_vector,]
> iris_train_labels <- iris$Species[iris_sampling_vector]
>
> iris_test <- iris_numeric[-iris_sampling_vector,]
> iris_test_z <- iris_numeric_zscore[-iris_sampling_vector,]
> iris_test_pca <- iris_numeric_pca[-iris_sampling_vector,]
> iris_test_labels <- iris$Species[-iris_sampling_vector]
我们现在可以构建并测试三种不同的模型来处理鸢尾花数据集。这些模型依次是未归一化模型、一个输入特征经过 Z 分数变换进行中心化和缩放的模型,以及具有两个主成分的 PCA 模型。我们可以在构建这些模型后使用测试集来衡量每个模型的预测性能;然而,这意味着,在我们的最终未见准确度估计中,我们将使用测试集进行模型选择,从而产生一个有偏的估计。因此,我们通常保留一个与测试集大小相当的数据分割,通常称为验证集。这个验证集用于调整模型参数,例如 kNN 中的k,以及在使用测试集预测未见性能之前,对输入特征的编码和变换进行不同的调整。在第五章中,我们将讨论这种方法的替代方法,称为交叉验证。
一旦我们分割了数据,按照它所需的相关训练程序训练了模型,并调整了模型参数,我们接下来就必须评估它在测试集上的表现。通常,我们在测试集上不会找到与训练集相同的性能。有时,我们甚至可能发现,当我们部署模型时看到的性能与我们根据训练或测试集的性能所期望看到的不一致。这种性能差异可能有多种原因。首先,我们可能收集的数据可能既不代表我们正在建模的过程,或者我们没有在训练数据中遇到某些特征输入的组合。这可能导致与我们的预期不一致的结果。这种情况可能发生在现实世界中,也可能发生在我们的测试集中,例如,如果它包含异常值。另一个常见的情况是模型过度拟合的问题。
过度拟合是一个问题,其中一些模型,尤其是更灵活的模型,在它们的训练数据集上表现良好,但在未见过的测试集上表现显著较差。这发生在模型过于紧密地匹配训练数据中的观察结果,而无法对未见数据泛化时。换句话说,模型正在捕捉训练数据集中的虚假细节和变化,而这些细节和变化并不代表整个潜在人群。过度拟合是我们不根据模型在训练数据上的表现来选择模型的关键原因之一。训练数据和测试数据性能之间的其他差异来源是模型偏差和方差。这些因素实际上形成了统计建模中一个众所周知的权衡,称为偏差-方差权衡。
统计模型的方差指的是,如果使用一个不同选择的训练集(但来自我们试图预测的原始过程的或系统的确切相同过程)来训练模型,该模型的预测函数会有多大的变化。我们希望有较低的方差,因为本质上,我们不希望使用从同一过程生成的不同训练集来预测一个非常不同的函数。模型偏差指的是由于特定模型可以学习的函数形式有限制而固有的预测函数中的误差。例如,线性模型在尝试逼近非线性函数时引入偏差,因为它们只能学习线性函数。一个好的预测模型的理想情况是既具有低方差又具有低偏差。对于预测模型师来说,了解存在一个由模型选择引起的偏差-方差权衡的事实非常重要。由于它们对目标函数的假设较少,通常更复杂的模型更容易出现偏差,但比简单但更受限制的模型(如线性模型)具有更高的方差。这是因为更复杂的模型能够由于其灵活性而更接近地逼近训练数据,但作为结果,它们对训练数据的变化更敏感。当然,这也与复杂模型通常表现出的过拟合问题有关。
我们实际上可以通过首先在我们的鸢尾花数据集上训练一些 kNN 模型来看到过拟合的影响。有许多软件包提供了 kNN 算法的实现,但我们将使用我们熟悉的caret包中提供的knn3()函数。要使用此函数训练模型,我们只需提供包含数值输入特征的 DataFrame、输出标签的向量以及我们想要用于预测的最近邻数量k:
> knn_model <- knn3(iris_train, iris_train_labels, k = 5)
> knn_model_z <- knn3(iris_train_z, iris_train_labels, k = 5)
> knn_model_pca <- knn3(iris_train_pca, iris_train_labels, k = 5)
为了看到不同k值的影响,我们将使用方便地以二维形式可用的鸢尾花 PCA 模型,以便我们可视化并反复训练:

在前面的图中,我们使用了不同的符号来表示不同物种对应的数据点。图中显示的线条对应于不同物种之间的决策边界,即我们输出变量的类别标签。请注意,使用较低的k值,例如1,可以非常紧密地捕捉数据的局部变化,因此决策边界非常不规则。较高的k值使用许多邻居来创建预测,从而产生平滑效果和更平滑的决策边界。在 kNN 中调整k值是调整模型参数以平衡过拟合影响的例子。
我们在本节中没有提到任何具体的性能指标。对于回归和分类,存在不同的模型质量度量标准,我们将在讨论完预测建模过程之后解决这些问题。
使用不同模型和最终模型选择
在这个过程的第一次迭代(这非常是一个迭代过程!)中,我们通常到达这个阶段,已经训练并评估了一个简单的模型。简单的模型通常允许我们以最小的努力快速获得一个粗略的解决方案,从而让我们及早了解我们离一个能够以合理精度进行预测的模型还有多远。简单的模型也非常擅长为我们提供一个基线水平的表现,我们可以用它来衡量未来模型的表现。作为模型构建者,我们往往对某一方法比对其他方法有偏好,但重要的是要记住,尝试不同的方法来解决问题并且使用数据来帮助我们决定最终使用哪种方法,通常是非常值得的。
在选择最终模型之前,考虑是否使用多个模型来解决问题可能是一个好主意。在第七章中,我们花费了一整章来研究涉及许多模型共同工作以提升整体系统预测精度的技术。
模型部署
一旦我们选择了要使用的最终模型,我们希望最终确定其实施方案,以便最终用户可以可靠地使用它。程序员将这个过程称为部署到生产环境。这是声音的软件工程原则变得极其重要的地方。以下指南提供了一些有用的建议:
-
模型应该被优化以提高其计算预测的速度。例如,这意味着确保在运行时计算的任何特征都执行得非常高效。
-
模型应该有良好的文档记录。最终的输入特征应该被明确定义,用于训练的方法和数据应该被存储起来,以便在需要时可以轻松重新训练。训练集和测试集上的原始性能也应该被存储起来,作为后续改进的参考。
-
模型的性能应该随着时间的推移进行监控。这很重要,不仅是为了验证模型是否按预期工作,也是为了捕捉任何潜在的数据变化。如果正在建模的过程随时间变化,那么我们的模型性能可能会下降,这将表明需要训练一个新的模型。
-
用于实现模型的软件应该使用标准的单元和集成测试进行适当的测试。通常,我们会使用很多已经过测试的现有 R 包,其函数已经过测试,但模型的最终部署可能需要我们亲自编写一些额外的代码,例如用于特征计算。
-
部署的模型应该能够处理输入中的错误。例如,如果某些输入特征缺失,应该通过适当的错误信息向用户清楚地说明模型无法进行预测的原因。错误和警告也应该被记录,尤其是在模型在实时设置中用于连续预测时。
摘要
在本章中,我们探讨了围绕预测模型的基本思想。我们看到了有很多方法可以分类模型,在学习过程中学习到重要区别,例如监督学习与无监督学习以及回归与分类的区别。接下来,我们概述了构建预测模型涉及的步骤,从数据收集过程一直到模型评估和部署。关键的是,这个过程是迭代的,我们通常在尝试和训练了几个不同的模型之后才得到最终的模型。
我们还介绍了我们的第一个模型,即 k 最近邻模型,它在执行分类和回归方面都很有用。kNN 是一个非常灵活的模型,它不对底层数据做出任何明确的假设。因此,它可以适应非常复杂的决策边界。它是一个懒惰的学习者,因为它不会构建一个模型来描述输入特征与输出变量之间的关系。因此,它不需要长时间的训练。另一方面,对于具有许多维度的数据,它可能需要很长时间才能产生预测,并且由于模型需要记住所有训练数据以找到目标点的最近邻,它通常也需要大量的内存。kNN 不区分不同特征的重要性,并且它在其预测中使用距离度量的事实意味着,一方面,它没有内置的处理缺失数据的方法,另一方面,它通常需要将特征转换为相似的尺度。最后,可以通过选择合适的k值,即最近邻的数量,来调整模型,以平衡过拟合的程度。在牢固掌握预测建模过程的基本知识之后,我们将在下一章中探讨线性回归。
第二章.整理数据与衡量性能
在本章中,我们将涵盖整理数据以准备预测建模、性能指标、交叉验证和学习曲线等主题。
在统计学中,有一个公认的概念,即有两种类型的数据,它们是:
-
杂乱
-
整洁
杂乱的数据被认为是原始的或混乱的;整洁的数据是经过质量保证流程并准备好使用的。
开始
在我们开始讨论整理数据的过程之前,指出以下几点是非常谨慎的:无论你如何整理数据,你都应该确保:
-
创建并保存您的脚本,以便您可以在新的或类似的数据源中再次使用它们。这被称为可重用性。为什么要在不需要的情况下花费时间重新创建相同的代码、规则或逻辑?这适用于同一项目中的新数据(脚本是为该项目开发的)或您未来可能参与的新的项目。
-
尽可能“上游”地整理数据,也许甚至在原始来源处。换句话说,保存并维护原始数据,但使用程序脚本进行清理、修复错误,并保存该清理后的数据集以供进一步分析。
整理数据
值得明确的是,整理数据的概念。整理数据是重新组织(或可能只是组织)数据的过程,以及解决在您的数据中识别出的任何问题或担忧。问题会影响数据的质量。当然,数据质量是相对于拟议的用途(数据)而言的。
数据质量分类
或许有一个公认的观点,即数据质量问题可以归类到以下一个领域:
-
准确性
-
完整性
-
更新状态
-
相关性
-
一致性(跨来源)
-
可靠性
-
适当性
-
可访问性
您的数据的质量或质量水平可能受到其输入、存储和管理方式的影响。解决数据质量(通常称为数据质量保证(DQA))的过程需要定期和常规地审查和评估数据,并执行称为配置文件和清理的持续过程(即使数据存储在多个不同的系统中,这些过程也很困难)。
在这里,整理数据将更加以项目为中心,因为我们可能不关心创建正式的 DQA 流程,而只是确保数据对您的特定预测项目是正确的。
在统计学中,数据科学家尚未观察到的数据或尚未审查的数据被认为是原始的,不能在预测项目中可靠地使用。整理数据的过程通常涉及几个步骤。强烈建议花额外的时间将工作分解出来(而不是随意地一起解决多个数据问题)。
第一步
第一步需要将数据带到可能被称为机械正确性的状态。在这一步中,你关注的是如下事项:
-
文件格式和组织:字段顺序、列标题、记录数量等
-
记录数据类型(例如,将数值存储为字符串)
-
日期和时间处理(通常将值重新格式化为标准格式或一致格式)
-
缺失内容:错误的类别标签、未知或意外的字符编码等
下一步
第二步是解决数据的统计可靠性。在这里,我们纠正那些可能是机械上正确但很可能会(根据主题内容)影响统计结果的问题。
这些问题可能包括:
-
正负不匹配:年龄变量可能报告为负数
-
无效(基于接受逻辑)数据:一个未成年的人可能被登记为拥有驾照
-
缺失数据:关键数据值可能只是从数据源中缺失
最后一步
最后,在实际上使用数据之前,最后一步可能是重新格式化步骤。在这一步中,数据科学家将根据预期的用途或目标,确定数据必须采取的形式,以便最有效地处理它。
例如,一个人可能会决定:
-
重新排序或重复列;也就是说,某些最终处理可能需要在文件源中生成冗余或重复的数据,以便正确或更易于处理
-
删除列和/或记录(基于特定标准)
-
设置小数位数
-
数据透视数据
-
截断或重命名值
-
等等
使用 R 解决上述数据错误有多种相对常规的方法。
例如:
-
更改数据类型:也称为“数据类型转换”,可以使用 R 的
is函数来测试对象的数据类型,以及as函数来进行显式转换。以下是一个最简单的例子:![最后一步]()
-
日期和时间:使用 R 管理日期信息有多种方式。实际上,我们可以扩展前面的例子,并提到
as.Date函数。通常,日期值对统计模型很重要,因此花时间了解模型日期字段的格式并确保它们得到适当处理是很重要的。大多数情况下,日期和时间将以原始数据格式作为字符串出现,可以按需转换和格式化。在下面的代码中,包含saledate和returndate的字符串字段被转换为日期类型值,并使用一个常见的时间函数difftime:![最后一步]()
-
类别标签对于统计建模以及数据可视化至关重要。使用标签对分类数据的样本进行标记的一个例子可能是为研究中的参与者分配一个标签,例如通过教育水平:1 = 博士,2 = 硕士,3 = 学士,4 = 专科,5 = 非学位,6 = 一些大学,7 = 高中,或 8 = 无:
> participant<-c(1,2,3,4,5,6,7,8) > recode<-c(Doctoral=1, Masters=2, Bachelors=3, Associates=4, Nondegree=5, SomeCollege=6, HighSchool=7, None=8)) > (participant<-factor (participant, levels=recode, labels=names(recode))) [1] Doctoral Masters Bachelors Associates Nondegree SomeCollege HighSchool None Levels: Doctoral Masters Bachelors Associates Nondegree SomeCollege HighSchool None -
为数据分配标签不仅有助于可读性,而且允许机器学习算法从样本中学习,并将相同的标签应用于其他未标记的数据。
-
缺失数据参数:很多时候,只需设置适当的参数值,就可以将缺失数据从计算中排除。例如,R 函数
var、cov和cor用于计算变量的方差、协方差或相关系数。这些函数有设置na.rm为 TRUE 的选项。这样做会告诉 R 排除任何带有缺失值的记录或案例。 -
在您的数据中可能存在各种其他数据整理的烦恼,例如错误标记的数值数据(即,对于如参与者年龄之类的数据,为负值),基于接受场景逻辑的数据值无效(例如,参与者的年龄与教育水平相比,一个 10 岁的孩子获得硕士学位是不可能的),数据值简单缺失(参与者未作回应是否表示该问题不适用或存在错误?),等等。幸运的是,至少有几种方法可以处理这些数据场景,使用 R 语言。
性能指标
在上一章中,我们讨论了预测建模过程,我们深入探讨了使用训练集和测试集来评估训练模型性能的重要性。在本节中,我们将探讨在描述不同模型的预测准确性时经常遇到的具体性能指标。结果是,根据问题的类别,我们需要使用稍微不同的方式来评估(模型的)性能。由于本书专注于监督模型,我们将探讨如何评估回归模型和分类模型。对于分类模型,我们还将讨论一些用于二元分类任务的额外指标,这是一种非常重要且经常遇到的问题类型。
注意
注意:在统计学中,性能一词通常与准确性可以互换使用。
评估回归模型
在回归场景中,让我们回顾一下,通过我们的模型,我们正在构建一个估计理论上的目标函数 f 的函数。模型的输入是我们选择的输入特征值。如果我们将这个函数应用于我们的训练数据中的每一个观测值,x[i],这些数据被标记为函数的真实值,y[i],我们将获得一组配对。为了确保我们清楚这一点,第一个条目是我们训练数据中第 i 个观测值的输出变量的实际值,第二个条目是使用我们的模型对这一观测值的特征值进行预测得到的值。
如果我们的模型很好地拟合了数据,这两个值在训练集中将非常接近。如果这在我们的测试集中也是真的,那么我们认为我们的模型很可能会在未来的未见观测值上表现良好。为了量化预测值和正确值对所有观测值在数据集中都接近这一概念,我们定义了一个称为 均方误差 (MSE) 的度量,如下所示:

在这里,n 是数据集中观测值的总数。因此,这个方程告诉我们首先计算测试集中每个观测值 i 的输出值与其预测值之间的平方差,然后将这些值的总和除以观测值的数量来取平均值。因此,应该清楚为什么这个度量被称为均方误差。这个数字越低,实际输出变量的值与我们的预测值之间的平均误差就越低,因此我们的模型就越准确。我们有时会提到 均方根误差 (RMSE),它只是 MSE 的平方根,以及 平方和误差 (SSE),它与 MSE 类似,但没有除以训练示例数量 n 导致的归一化。这些量在训练数据集上计算时是有价值的,因为低数值表示我们已经很好地训练了模型。我们知道通常我们不期望这个值为零,而且由于过度拟合的问题,我们不能根据这些量来决定模型之间的优劣。
计算这些度量的关键地方是在测试数据上。在大多数情况下,一个模型的训练数据 MSE(或者同样,RMSE 或 SSE)将低于在测试数据上计算的相应度量。一个与另一个模型 m[2] 相比过度拟合数据的模型 m[1],通常可以通过 m[1] 模型产生比模型 m[2] 更低的训练 MSE 但更高的测试 MSE 来识别。
评估分类模型
在回归模型中,我们的预测函数对特定观察值 x[i] 的输出 y[i] 的近似程度,是通过均方误差(MSE)来考虑的。具体来说,大的误差会被平方,因此一个数据点的非常大的偏差可能比多个数据点的几个小偏差有更大的影响。正是因为我们在回归中处理的是数值输出,所以我们不仅可以测量哪些观察值在预测上做得不好,还可以测量我们偏离的程度有多远。
对于执行分类的模型,我们同样可以定义一个错误率,但在这里我们只能谈论我们的模型所做的错误分类的数量。具体来说,我们有一个由以下公式给出的错误率:

这个度量使用 indicator 函数,当预测的类别与标记的类别不同时返回值为 1。因此,错误率是通过计算输出变量类别被错误预测的次数,并将这个计数除以数据集中的观察值数量来计算的。这样,我们可以看到错误率实际上是我们模型做出的错误分类观察值的百分比。需要注意的是,这个度量将所有类型的错误分类视为相等。如果某些错误分类的成本高于其他错误分类,那么可以通过添加权重来调整这个度量,这些权重将每个错误分类乘以与其成本成比例的量。
如果我们想要诊断回归问题中最大的错误来源,我们通常会查看预测值与实际值之间误差最大的点。在进行分类时,计算所谓的混淆矩阵通常非常有用。这是一个显示我们在数据上所做的所有成对错误分类的矩阵。现在,我们将回到我们的鸢尾花物种分类问题。在前一节中,我们训练了三个 kNN 模型。现在,我们将看到我们如何评估它们的性能。像许多分类模型一样,kNN 可以返回最终类别标签或与每个可能的输出类别相关的分数集。有时,就像这里的情况一样,这些分数实际上是模型分配给每个可能输出的概率。无论分数是否是实际概率,我们都可以根据这些分数来决定选择哪个输出标签,通常是通过简单地选择得分最高的标签。
在 R 语言中,最常用的进行模型预测的函数是 predict() 函数,我们将使用它来与我们的 kNN 模型一起使用:
> knn_predictions_prob <- predict(knn_model, iris_test,
type = "prob")
> tail(knn_predictions_prob, n = 3)
setosa versicolor virginica
[28,] 0 0.0 1.0
[29,] 0 0.4 0.6
[30,] 0 0.0 1.0
在 kNN 模型中,我们可以通过计算属于每个输出标签的最近邻的比例来直接将输出分数作为概率。在所展示的三个测试示例中,virginica 物种在其中的两个示例中具有单位概率,但在剩余的示例中只有 60% 的概率。其余的 40% 归属于 versicolor 物种,因此似乎在后者的情况下,五个最近邻中有三个属于 virginica 物种,而另外两个属于 versicolor 物种。很明显,我们应该对前两种分类比后一种分类更有信心。
现在,我们将计算三个模型在测试数据上的类别预测:
> knn_predictions <- predict(knn_model, iris_test, type = "class")
> knn_predictions_z <- predict(knn_model_z, iris_test_z, type = "class")
> knn_predictions_pca <- predict(knn_model_pca, iris_test_pca, type = "class")
我们可以使用 caret 包中的 postResample() 函数来显示我们模型的测试集准确度指标:
> postResample(knn_predictions, iris_test_labels)
Accuracy Kappa
0.9333333 0.9000000
> postResample(knn_predictions_z, iris_test_labels)
Accuracy Kappa
0.9666667 0.9500000
> postResample(knn_predictions_pca, iris_test_labels)
Accuracy Kappa
0.90 0.85
在这里,准确度是误差率的倒数,因此是正确分类观察值的百分比。我们可以看到,所有模型在准确度方面表现非常接近,使用 Z 分数归一化的模型占主导地位。考虑到测试集的大小很小,这种差异并不显著。
这被定义为如下:

Kappa 统计量旨在抵消随机因素的影响,其值在 [-1,1] 的区间内,其中 1 表示完美准确,-1 表示完美不准确,当准确度正好是随机猜测者所能获得的准确度时,出现 0。请注意,对于分类模型,随机猜测者猜测最频繁的类别。在我们的鸢尾花分类模型中,三种物种在数据中均匀分布,因此预期的准确度是三分之一。鼓励读者检查一下,通过使用这个值作为预期的准确度,我们可以从准确度值中获得 Kappa 统计量的观察值。
我们还可以通过混淆矩阵来检查我们的模型所犯的具体错误分类。
这可以通过将预测与正确的输出标签进行交叉表来简单地构建:
> table(knn_predictions, iris_test_labels)
iris_test_labels
knn_predictions setosa versicolor virginica
setosa 10 0 0
versicolor 0 9 1
virginica 0 1 9
caret 包还包含一个非常有用的 confusionMatrix() 函数,该函数会自动计算这个表格以及其他几个性能指标,其解释可以在 topepo.github.io/caret/other.html 找到。
在先前的混淆矩阵中,我们可以看到正确分类的总观测数是 28,这是主对角线上数字10、9和9的总和。输出表显示,setosa 物种似乎更容易用我们的模型预测,因为它从未与其他物种混淆。然而,versicolor和virginica物种可能会相互混淆,并且模型错误地将每种物种的一个实例分类。因此,我们可以推断出计算混淆矩阵是一项有用的练习。识别经常混淆的类别对将指导我们改进模型,例如,通过寻找可能有助于区分这些类别的特征。
评估二元分类模型
一种称为二元分类的特殊分类情况发生在我们有两个类别时。以下是一些典型的二元分类场景:
-
我们想根据电子邮件的内容和标题将收到的电子邮件分类为垃圾邮件或非垃圾邮件
-
我们想根据患者的症状和病史将患者分类为患有疾病或未患病
-
我们想根据查询中的单词和文档中的单词将来自大型文档数据库的文档分类为与查询相关的文档
-
我们想将装配线上的产品分类为有缺陷或无缺陷
-
我们想根据客户的信用评分和财务状况预测申请银行信贷的客户是否会违约
在二元分类任务中,我们通常将我们的两个类别称为正类和负类。按照惯例,正类对应于我们的模型试图预测的特殊情况,并且通常比负类更罕见。从前面的例子中,我们会用正类标签来标记垃圾邮件、有缺陷的装配线产品、违约客户等等。现在考虑一个医学诊断领域的例子,我们试图训练一个模型来诊断一种我们知道在人口中只有 1/10000 的人会患上的疾病。我们会将正类分配给患有这种疾病的病人。请注意,在这种情况下,错误率本身并不是衡量模型的一个充分指标。例如,我们可以设计一个最简单的分类器,其错误率仅为 0.01%,通过预测每个病人都将健康,但这样的分类器将毫无用处。我们可以通过检查混淆矩阵来得出更有用的指标。假设我们构建了一个用于诊断罕见疾病的模型,并在 100,000 个病人的测试样本上获得了以下混淆矩阵:
> table(actual,predicted)
predicted
actual negative positive
negative 99900 78
positive 9 13
二元分类问题如此普遍,以至于二元混淆矩阵的单元格有自己的名称。在主对角线上,它包含正确分类的条目,我们称这些元素为真正的负例和真正的正例。在我们的案例中,我们有 99900 个真正的负例和 13 个真正的正例。当我们错误地将一个观察值分类为正类,而实际上它属于负类时,我们就有了一个假阳性,也称为 I 型错误。当我们错误地将一个正类观察值分类为负类时,就发生了假阴性或 II 型错误。在我们的案例中,我们的模型有 78 个假阳性和 9 个假阴性。
我们现在将介绍在二元分类背景下两个非常重要的度量指标,即精确度和召回率。精确度定义为正确预测的正类实例数与预测的正类实例总数的比率。使用前一个二元混淆矩阵的标签,精确度可以表示为:

因此,精确度本质上衡量了我们预测正类时的准确性。根据定义,我们可以通过从不为正类做出任何预测来达到 100%的精确度,因为这样我们保证不会犯任何错误。相比之下,召回率定义为在数据集中所有正类成员中正确预测的正类数量。再次使用二元混淆矩阵的标签,我们可以看到召回率的定义如下:

召回率衡量我们识别数据集中所有正类成员的能力。我们可以通过总是预测所有数据点的正类来轻松实现最大召回率。我们将会犯很多错误,但我们将不会有任何假阴性。请注意,精确度和召回率在我们的模型性能中形成了一种权衡。在一端,如果我们不对我们的任何数据点预测正类,我们将有零召回率但最大精确度。在另一端,如果所有我们的数据点都被预测为属于正类(记住,这通常是一个罕见的类别),我们将有最大召回率但极低的精确度。换句话说,试图减少 I 型错误会导致增加 II 型错误,反之亦然。这种反向关系通常在特定问题的精确度-召回率曲线上绘制。通过使用适当的阈值参数,我们通常可以调整我们模型的性能,以便在精确度-召回率曲线上达到一个适合我们情况的特定点。例如,在某些问题域中,我们倾向于倾向于有比高精确度更高的召回率,因为将正类观察误分类为负类的成本很高。因为我们经常想用一个单一的数字来描述模型的性能,所以我们定义了一个称为 F1 分数的度量,它结合了精确度和召回率。具体来说,F1 分数定义为精确度和召回率的调和平均数:

读者应验证,在我们的示例混淆矩阵中,精确度为 14.3%,召回率为 59.1%,F1 分数为 0.23。
交叉验证
交叉验证(你可能听到一些数据科学家将其称为旋转估计,或简单地作为一种评估模型的一般技术),是评估模型性能(或其准确性)的另一种方法。
主要用于预测建模来估计模型在实际应用中可能表现出的准确性,人们可能会看到交叉验证被用来检查模型潜在的泛化能力;换句话说,模型将如何将其从样本中推断出的信息应用到整个群体(或数据集)中。
使用交叉验证,你将一个(已知)数据集作为你的验证数据集,在该数据集上运行训练,以及一个未知数据集(或首次看到的数据集),模型将对其进行测试(这被称为你的测试数据集)。目标是确保像过度拟合(允许非包容性信息影响结果)等问题得到控制,以及提供有关模型如何泛化实际问题或真实数据文件的见解。
此过程将包括将数据分为相似子集的样本,在一个子集(称为训练集)上执行分析,并在另一个子集(称为验证集或测试集)上验证分析:
分离 → 分析 → 验证
为了减少变异性,使用不同的分区进行多次迭代(也称为折或轮)的交叉验证,并将验证结果在轮次中平均。通常,数据科学家会使用模型的不变性来确定应该执行的实际交叉验证轮次数量。
再次强调,通过思考选择数据子集并手动计算结果,可以更好地理解交叉验证方法。一旦你知道正确的结果,它们可以与模型产生的结果(使用另一个数据子集)进行比较。这是一轮。将执行多轮,比较结果平均并审查,最终提供一个公平的模型预测性能估计。
假设一所大学提供其学生群体随时间变化的数据。学生被描述为具有各种特征,例如高中 GPA 是否大于或小于 3.0,是否有家庭成员从该校毕业,学生是否在非编程活动中活跃,是否是居民(住在校园内),是否是学生运动员等等。我们的预测模型想要预测提前毕业的学生具有哪些特征。
下表展示了使用五轮交叉验证过程预测模型预期准确性的结果表示:

根据前面的图表,我认为我们的预测模型预计将非常准确!
总结来说,交叉验证通过(平均)拟合度(预测误差)的度量来推导出模型预测性能的更准确估计。这种方法通常用于数据不足以进行测试而不失去显著建模或测试质量的情况下。
学习曲线
评估模型性能的另一种方法是通过评估模型的学习增长或模型通过额外经验(例如,更多轮次的交叉验证)提高学习(获得更好的分数)的能力。
注意
学习是获取新知识或修改和加强现有知识的行为。
表示模型结果或分数与数据文件群体信息的数据可以与其他分数结合,以显示一条线或曲线,这被称为模型的学习曲线。
学习曲线是学习增长(垂直轴上显示的分数)与练习(水平轴上显示的个体数据文件或轮次)之间关系的图形表示。
这也可以被概念化为:
-
重复相同的任务
-
随时间学习到的知识体系
下图展示了一个假设的学习曲线,显示了通过交叉验证轮次得到的分数来提高预测模型学习的情况:

来源链接:en.wikipedia.org/wiki/File:Alanf777_Lcd_fig01.png
小贴士
很有趣;一个人可能知道熟悉的表达“it's a steep learning curve”是用来描述一个难以学习的活动,但在统计学中,一个陡峭的学习曲线实际上代表的是快速进步。
将模型性能与经验相关的学习曲线通常在执行模型评估时被发现被使用。
如我们在此节之前所提到的,性能(或分数)是指模型的准确度,而经验(或轮次)可能是指用于优化模型参数的训练样本数、数据集或迭代次数。
绘图和 ping
使用两个通用的 R 函数,我们可以展示一个简单的学习曲线可视化。ping 将打开一个包含我们的学习曲线可视化的图像文件,这样我们就可以轻松地将其包含在文档中,而 plot 将绘制我们的图形。
以下是我们示例 R 代码语句:
# -- 5 rounds of numeric test scores saved in a vector named "v"
v <-c(74,79, 88, 90, 99)
# -- create an image file for the visualization for later use
png(file = "c:/simple example/learning curve.png", type = c("windows", "cairo", "cairo-png"))
# -- plot the model scores round by round
plot(v, type = "o", col = "red", xlab = "Round", ylab = "Score", main = "Learning Curve")
# -- close output
dev.off()
前面的陈述创建了一个以下图形的文件:

摘要
在本章中,我们探讨了围绕数据质量问题及其类型分类的基本思想,以及整理数据的建议。
为了比较一个人可能创建的不同模型的性能,我们进一步建立了一些关于模型性能的基本概念,例如回归的均方误差(MSE)和分类的错误率。
我们还介绍了交叉验证作为一种通用的评估技术,用于数据量有限的情况。
最后,学习曲线被讨论为判断模型提高分数或学习能力的一种方式。
在对预测建模过程的基本原理有了坚实的了解之后,我们将在下一章中探讨线性回归。
第三章。线性回归
我们从前几章了解到,回归问题涉及预测数值输出。最简单但最常见的一种回归是线性回归。在本章中,我们将探讨为什么线性回归如此常用,其局限性以及扩展,然后简要介绍多项式回归,当线性关系不适合你的情况时,你可能需要考虑它。
线性回归简介
在线性回归中,输出变量是通过输入特征的线性加权组合来预测的。以下是一个简单线性模型的例子:

前面的模型本质上表示我们正在估计一个输出,这是一个单一预测变量(即特征)的线性函数,用字母 x 表示。涉及希腊字母 β 的项是模型的参数,被称为回归系数。一旦我们训练了模型并确定了这些参数的值,我们就可以通过在我们的方程中进行简单的替换来对任何 x 值的输出变量进行预测。另一个线性模型的例子,这次有三个特征并分配了回归系数的值,如下方程所示:

在这个方程中,就像前一个方程一样,我们可以观察到我们有一个比特征数量多的系数。这个额外的系数,β[0],被称为截距,是当所有输入特征值为零时模型的期望值。其他 β 系数可以解释为输出值随特征单位增加的期望变化。例如,在前面的方程中,如果特征 x[1] 的值增加一个单位,输出值的期望值将增加 1.91 个单位。同样,特征 x[3] 的单位增加会导致输出值减少 7.56 个单位。在一个简单的一维回归问题中,我们可以在图的 y 轴上绘制输出,在 x 轴上绘制输入特征。在这种情况下,模型预测这两个变量之间存在直线关系,其中 β[0] 代表直线与 y 轴相交或截取的点,而 β[1] 代表直线的斜率。我们通常将只有一个特征(因此有两个回归系数)的情况称为简单线性回归,而将有两个或更多特征的情况称为多元线性回归。
线性回归的假设
在我们深入探讨如何训练线性回归模型及其表现之前,我们将查看模型假设。模型假设本质上描述了模型对我们试图预测的输出变量 y 的信念。具体来说,线性回归模型假设输出变量是一组特征变量的加权线性函数。此外,模型假设对于特征变量的固定值,输出是正态分布且方差恒定。这等同于说模型假设真实输出变量 y 可以用一个如下的方程表示,这里展示了两个输入特征:

在这里,ε 代表一个误差项,它服从均值为零、方差为常数 σ² 的正态分布:

我们可能会遇到同方差性这个术语,作为描述恒定方差概念的一种更正式的方式。通过同方差性或恒定方差,我们指的是误差成分的方差不会随着输入特征的值或水平而变化。在下面的图中,我们可视化了一个具有异方差误差的假设线性关系示例,这些误差没有恒定的方差。在输入特征的低值处,数据点靠近线,因为在这个图的这个区域方差较低,但在输入特征的高值处,由于方差较高,数据点远离线:

ε 项是真实函数 y 的不可约误差组成部分,可以用来表示随机误差,例如特征值中的测量误差。在训练线性回归模型时,我们总是期望在我们的输出估计中观察到一定量的误差,即使我们拥有所有正确的特征、足够的数据,并且所建模的系统确实是线性的。
换句话说,即使有一个真正的线性函数,我们仍然期望一旦我们通过训练示例找到一个最佳拟合线,由于误差成分所表现出的这种固有方差,我们的线不会穿过所有,甚至任何,我们的数据点。然而,关键要记住的是,在这个理想场景中,因为我们的误差成分具有零均值和恒定方差,我们的训练标准将允许我们在足够大的样本下接近回归系数的真实值,因为误差将会相互抵消。
另一个重要的假设与误差项的独立性相关。这意味着我们并不期望与一个特定观测值相关的残差或误差项以某种方式与另一个观测值相关联。如果观测值彼此是函数关系,这一假设可能会被违反,这通常是测量误差的结果。如果我们从我们的训练数据中取出一部分,将所有特征和输出的值加倍,并将这些新的数据点添加到我们的训练数据中,我们可以创造出拥有更大数据集的假象;然而,由此将会有成对的观测值,它们的误差项将相互依赖,因此我们的模型假设将被违反。顺便提一下,以这种方式人工增加数据集在任何模型中都是不可接受的。同样,如果观测值通过一个未测量的变量以某种方式相关联,也可能出现相关的误差项。例如,如果我们正在测量装配线零部件的故障率,那么来自同一工厂的零部件可能存在误差的相关性:例如,由于装配过程中使用了不同的标准和协议。因此,如果我们不将工厂作为特征,我们可能会在我们的样本中观察到来自同一工厂的零部件之间的相关误差。实验设计的研究涉及识别和减少误差项中的相关性,但这超出了本书的范围。
最后,另一个重要的假设涉及特征本身在统计上相互独立的概念。在这里值得澄清的是,在线性模型中,尽管输入特征必须是线性加权的,但它们自身可能是另一个函数的输出。为了说明这一点,人们可能会惊讶地看到以下是一个包含三个特征sin(z[1]**)、ln(z[2] )和exp(z[3])的线性模型:

我们可以通过对输入特征进行一些变换,然后在我们的模型中进行替换,来看到这是一个线性模型:

现在,我们有一个更易被识别为线性回归模型的方程。如果先前的例子让我们相信几乎所有东西都可以转化为线性模型,那么接下来的两个例子将明确地让我们相信这实际上并非如此:

由于第一个回归系数(β[1]),这两个模型都不是线性模型。第一个模型不是线性模型,因为β[1]充当第一个输入特征的指数。在第二个模型中,β[1]位于一个正弦函数内部。从这些例子中可以吸取的重要教训是,在某些情况下,我们可以对我们的输入特征应用变换,以便将数据拟合到线性模型中;然而,我们需要小心,确保我们的回归系数始终是结果新特征的线性权重。
简单线性回归
下面是简单线性回归模型的代码:
> set.seed(5427395)
> nObs = 100
> x1minrange = 5
> x1maxrange = 25
> x1 = runif(nObs, x1minrange, x1maxrange)
> e = rnorm(nObs, mean = 0, sd = 2.0)
> y = 1.67 * x1 - 2.93 + e
> df = data.frame(y, x1)
对于我们的输入特征,我们从均匀分布中随机采样点。我们使用均匀分布来获得数据点的良好分布。请注意,我们的最终df数据框旨在模拟我们在实践中获得的数据框;因此,我们不包含误差项,因为在现实世界的设置中这些误差项对我们是不可用的。
当我们使用某些数据(如我们的数据框中的数据)训练线性模型时,我们实际上希望产生一个与数据的潜在模型具有相同系数的线性模型。换句话说,原始系数定义了一个总体回归线。在这种情况下,总体回归线代表了数据的真实潜在模型。一般来说,我们会发现自己试图模拟一个不一定是线性的函数。在这种情况下,我们仍然可以将总体回归线定义为最佳可能的线性回归线,但线性回归模型显然不会表现同样好。
估计回归系数
对于我们的简单线性回归模型,训练模型的过程相当于从我们的数据集中估计两个回归系数。正如我们可以从我们之前构建的数据框中看到的那样,我们的数据实际上是一系列观察结果,每个观察结果是一对值(x[i],y[i]),其中这对值的第一个元素是输入特征值,第二个元素是对应的输出标签。结果证明,对于简单线性回归的情况,我们可以写出两个方程来计算我们的两个回归系数。我们不会仅仅展示这些方程,而是首先简要回顾一些读者可能之前已经遇到的一些非常基本的统计量,因为它们将在不久的将来被介绍。
一组值的均值就是这些值的平均值,通常被描述为位置度量,给出值在其测量尺度上的中心位置。在统计文献中,随机变量的平均值通常被称为期望,所以我们经常发现随机变量 X 的均值表示为 E(X)。另一种常用的表示法是横线表示法,其中我们可以通过在该变量上方放置横线来表示取该变量的平均值。为了说明这一点,以下两个方程显示了输出变量 y 和输入特征 x 的均值:

第二个非常常见的量,你也应该很熟悉,是变量的方差。方差衡量各个值与均值之间的平均平方距离。因此,它是一个分散度度量,所以低方差意味着大多数值都聚集在均值附近,而高方差会导致值分布得更广。请注意,方差的定义涉及到均值的定义,因此我们将在以下方程中看到带有横线的 x 变量的使用,该方程显示了我们的输入特征 x 的方差:

最后,我们将使用以下方程定义两个随机变量 x 和 y 之间的协方差:

从前面的方程中,应该很明显,我们之前定义的方差实际上是一个特殊的情况,即协方差中的两个变量是相同的。协方差衡量两个变量之间相互关联的强度,可以是正的也可以是负的。正协方差意味着正相关;也就是说,当一个变量增加时,另一个变量也会增加。负协方差表示相反的情况;当一个变量增加时,另一个变量往往会减少。当两个变量在统计上相互独立且因此不相关时,它们的协方差将为零(尽管应该注意的是,零协方差并不一定意味着统计独立性)。
拥有这些基本概念后,我们现在可以给出简单线性回归中两个回归系数估计的方程:

第一个回归系数可以计算为输出特征与输入特征之间的协方差与输入特征方差的比率。请注意,如果输出特征与输入特征独立,协方差将为零,因此,我们的线性模型将是一条没有斜率的水平线。在实践中,应注意,即使两个变量在统计上独立,我们通常也会看到由于误差的随机性质而存在一定程度的协方差;因此,如果我们训练一个线性回归模型来描述它们之间的关系,我们的第一个回归系数通常不会为零。稍后,我们将看到如何使用显著性测试来检测我们不应包括在模型中的特征。
在 R 中实现线性回归时,没有必要执行这些计算,因为 R 为我们提供了lm()函数,该函数为我们构建线性回归模型。以下代码示例使用我们之前创建的df数据框并计算回归系数:
> myfit <- lm(y~x1, df)
> myfit
Call:
lm(formula = y ~ x1, data = df)
Coefficients:
(Intercept) x1
-2.380 1.641
在第一行中,我们看到使用lm()函数的用法包括首先指定一个公式,然后是data参数,在我们的情况下是数据框。在简单线性回归的情况下,我们为lm()函数指定的公式的语法是输出变量的名称,后跟波浪号(~),然后是单个输入特征的名称。当我们在本章后面进一步研究多元线性回归时,我们将看到如何指定更复杂的公式。最后,输出显示了两个回归系数的值。请注意,β[0]系数被标记为截距,而β[1]系数被标记为对应特征(在这种情况下,x[1])的名称,在线性模型的方程中:
下面的图表显示了人口线和估计线在同一图上的情况:

如我们所见,这两条线非常接近,几乎无法区分,这表明模型非常接近地估计了真实的人口线。从第一章《准备预测建模》中,我们知道我们可以形式化我们的模型与数据集匹配的紧密程度,以及它将如何使用均方误差与类似的测试集匹配。在本章中,我们将检查这一点以及几个其他模型性能和质量的指标,但首先我们将泛化我们的回归模型以处理多个输入特征。
多元线性回归
每当我们有多个输入特征并想要构建线性回归模型时,我们就处于多元线性回归的领域。具有k个输入特征的多元线性回归模型的一般方程是:

我们对模型和误差分量ε的假设与简单线性回归相同,记住我们现在有多个输入特征,我们假设它们是相互独立的。我们不会使用模拟数据来展示多重线性回归,而是将分析两个真实世界的数据集。
预测 CPU 性能
我们第一个真实世界的数据集是由研究人员Dennis F. Kibler、David W. Aha和Marc K. Albert在 1989 年发表的一篇题为Instance-based prediction of real-valued attributes的论文中提出的,该论文发表在Journal of Computational Intelligence杂志上。数据包含了不同 CPU 模型的特征,例如周期时间和缓存内存的大小。在决定处理器之间选择时,我们希望考虑所有这些因素,但理想情况下,我们希望在一个单一的数值尺度上比较处理器。因此,我们经常开发程序来基准测试 CPU 的相对性能。我们的数据集还包含了我们 CPU 的已发布相对性能,我们的目标将是使用可用的 CPU 特征来预测这一点。该数据集可以通过以下链接从 UCI 机器学习仓库在线获取:archive.ics.uci.edu/ml/datasets/Computer+Hardware。
提示
UCI 机器学习仓库是一个优秀的在线资源,它托管了大量的数据集,其中许多数据集经常被书籍和教程的作者引用。熟悉这个网站及其数据集是值得努力的。学习预测分析的一个非常好的方法是练习使用你在本书中学到的技术在不同数据集上进行分析,而 UCI 仓库提供了许多这样的数据集,正好用于这个目的。
machine.data文件包含了我们所有的数据,以逗号分隔的格式,每行对应一个 CPU 模型。我们将使用 R 导入这些数据并标记所有列。请注意,总共有 10 列,但我们的分析不需要前两列,因为这些只是 CPU 的品牌和型号名称。同样,最后一列是研究人员自己产生的相对性能预测估计;我们的实际输出变量 PRP 在第 9 列。我们将需要的所有数据存储在一个名为machine的数据框中:
> machine <- read.csv("machine.data", header = F)
> names(machine) <- c("VENDOR", "MODEL", "MYCT", "MMIN", "MMAX", "CACH", "CHMIN", "CHMAX", "PRP", "ERP")
> machine <- machine[, 3:9]
> head(machine, n = 3)
MYCT MMIN MMAX CACH CHMIN CHMAX PRP
1 125 256 6000 256 16 128 198
2 29 8000 32000 32 8 32 269
3 29 8000 32000 32 8 32 220
该数据集还包含了数据列的定义:
| 列名 | 定义 |
|---|---|
MYCT |
机器周期时间(纳秒) |
MMIN |
最小主内存(千字节) |
MMAX |
最大主内存(千字节) |
CACH |
缓存内存(千字节) |
CHMIN |
最小通道数(单位) |
CHMAX |
最大通道数(单位) |
PRP |
已发布的相对性能(我们的输出变量) |
数据集不包含缺失值,因此不需要移除或修改任何观测值。我们会注意到,我们只有大约 200 个数据点,这在一般情况下被认为是一个非常小的样本。尽管如此,我们将继续将我们的数据分为训练集和测试集,比例为 85-15,如下所示:
> library(caret)
> set.seed(4352345)
> machine_sampling_vector <- createDataPartition(machine$PRP, p = 0.85, list = FALSE)
> machine_train <- machine[machine_sampling_vector,]
> machine_train_features <- machine[, 1:6]
> machine_train_labels <- machine$PRP[machine_sampling_vector]
> machine_test <- machine[-machine_sampling_vector,]
> machine_test_labels <- machine$PRP[-machine_sampling_vector]
现在我们已经设置了数据集并开始运行,我们通常会想进一步调查并检查我们的一些线性回归假设是否有效。例如,我们想知道我们是否有高度相关的特征。为此,我们可以使用cor()函数构建一个相关矩阵,并使用caret包中的findCorrelation()函数来获取关于哪些特征需要移除的建议:
> machine_correlations <- cor(machine_train_features)
> findCorrelation(machine_correlations)
integer(0)
> findCorrelation(machine_correlations, cutoff = 0.75)
[1] 3
> cor(machine_train$MMIN, machine_train$MMAX)
[1] 0.7679307
使用默认的0.9截止值以获得高度相关性,我们发现我们没有任何特征应该被移除。当我们把这个截止值降低到0.75时,我们看到caret建议我们移除第三个特征(MMAX)。正如前面代码的最后一行所示,这个特征和 MMIN 之间的相关性为0.768。虽然这个值不是很高,但它仍然足够高,让我们对它可能会影响我们的模型产生一定的担忧。直观上,当然,如果我们查看我们输入特征的定义,我们肯定会倾向于期望一个最小主存值相对较高的模型也可能会有一个相对较高的最大主存值。线性回归有时仍然可以用相关变量给出一个好的模型,但我们预计如果我们的变量不相关,我们会得到更好的结果。目前,我们决定保留这个数据集的所有特征。
预测二手车价格
我们的第二个数据集包含在caret包中的cars数据框中,由Shonda Kuiper在 2008 年从Kelly Blue Book网站收集,www.kbb.com。这是一个在线资源,可以获取可靠的二手车价格。数据集包括 804 辆通用汽车,所有车辆都是 2005 年的型号。它包括许多汽车属性,如里程和发动机尺寸以及建议的售价。许多特征是二元指示变量,例如 Buick 特征,表示一辆特定汽车的制造商是否为 Buick。当定价时,这些汽车都处于极好状态且不到一年,因此汽车状况没有被包括为特征。我们的目标是构建一个模型,使用这些属性的值来预测汽车的售价。特征的定义如下:
| 列名 | 定义 |
|---|---|
价格 |
美元(我们的输出变量)的建议零售价 |
里程 |
汽车行驶的英里数 |
汽缸数 |
汽车发动机的汽缸数 |
车门数 |
车门数量 |
Cruise |
表示汽车是否具有定速巡航的指示变量 |
Sound |
表示汽车是否升级了扬声器的指示变量 |
Leather |
表示汽车是否配备了真皮座椅的指示变量 |
Buick |
表示汽车品牌是否为别克 的指示变量 |
Cadillac |
表示汽车品牌是否为凯迪拉克的指示变量 |
Chevy |
表示汽车品牌是否为雪佛兰的指示变量 |
Pontiac |
表示汽车品牌是否为庞蒂亚克的指示变量 |
Saab |
表示汽车品牌是否为萨博的指示变量 |
Saturn |
表示汽车品牌是否为土星的指示变量 |
convertible |
表示汽车类型是否为敞篷车的指示变量 |
coupe |
表示汽车类型是否为敞篷车的指示变量 |
hatchback |
表示汽车类型是否为掀背车的指示变量 |
sedan |
表示汽车类型是否为轿车的指示变量 |
wagon |
表示汽车类型是否为旅行车的指示变量 |
与机器数据集一样,我们应该调查输入特征之间的相关性:
> library(caret)
> data(cars)
> cars_cor <- cor(cars_train_features)
> findCorrelation(cars_cor)
integer(0)
> findCorrelation(cars_cor, cutoff = 0.75)
[1] 3
> cor(cars$Doors,cars$coupe)
[1] -0.8254435
> table(cars$coupe,cars$Doors)
2 4
0 50 614
1 140 0
就像机器数据集一样,当我们把caret包中的findCorrelation()函数的cutoff设置为0.75时,会出现相关性。通过直接检查相关矩阵,我们发现Doors特征和coupe特征之间存在相对较高的相关性。通过交叉表分析这两个特征,我们可以看到为什么会出现这种情况。如果我们知道一辆车的类型是敞篷车,那么车门数量总是两个。如果汽车不是敞篷车,那么它很可能有四个车门。
汽车数据中另一个问题方面是,一些特征是其他特征的精确线性组合。这是通过使用caret包中的findLinearCombos()函数发现的:
> findLinearCombos(cars)
$linearCombos
$linearCombos[[1]]
[1] 15 4 8 9 10 11 12 13 14
$linearCombos[[2]]
[1] 18 4 8 9 10 11 12 13 16 17
$remove
[1] 15 18
在这里,我们被建议删除coupe和wagon列,它们分别是第 15 和第 18 个特征,因为它们是其他特征的精确线性组合。我们将从我们的数据框中删除这两列,从而消除我们之前看到的关联问题。
接下来,我们将数据分为训练集和测试集:
> cars <- cars[,c(-15, -18)]
> set.seed(232455)
> cars_sampling_vector <- createDataPartition(cars$Price, p =
0.85, list = FALSE)
> cars_train <- cars[cars_sampling_vector,]
> cars_train_features <- cars[,-1]
> cars_train_labels <- cars$Price[cars_sampling_vector]
> cars_test <- cars[-cars_sampling_vector,]
> cars_test_labels <- cars$Price[-cars_sampling_vector]
现在我们已经准备好了数据,我们将构建一些模型。
评估线性回归模型
我们将再次使用lm()函数将线性回归模型拟合到我们的数据上。对于我们的两个数据集,我们希望使用各自数据框中剩余的所有输入特征。R 为我们提供了一个简写,可以编写包含数据框所有列作为特征的公式,排除选定的输出列。这可以通过单个点来完成,如下面的代码片段所示:
> machine_model1 <- lm(PRP ~ ., data = machine_train)
> cars_model1 <- lm(Price ~ ., data = cars_train)
一旦我们准备好了所有数据,训练线性回归模型可能只是一行代码的事情,但重要的工作紧接着就开始了,我们需要研究模型以确定我们做得如何。幸运的是,我们可以通过使用summary()函数立即获取有关模型的一些重要信息。此函数对我们 CPU 数据集的输出如下所示:
> summary(machine_model1)
Call:
lm(formula = PRP ~ ., data = machine_train)
Residuals:
Min 1Q Median 3Q Max
-199.29 -24.15 6.91 26.26 377.47
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) -5.963e+01 8.861e+00 -6.730 2.43e-10 ***
MYCT 5.210e-02 1.885e-02 2.764 0.006335 **
MMIN 1.543e-02 2.025e-03 7.621 1.62e-12 ***
MMAX 5.852e-03 6.867e-04 8.522 7.68e-15 ***
CACH 5.311e-01 1.494e-01 3.555 0.000488 ***
CHMIN 7.761e-02 1.055e+00 0.074 0.941450
CHMAX 1.498e+00 2.304e-01 6.504 8.20e-10 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Residual standard error: 61.31 on 172 degrees of freedom
Multiple R-squared: 0.874, Adjusted R-squared: 0.8696
F-statistic: 198.8 on 6 and 172 DF, p-value: < 2.2e-16
在重复调用lm()函数本身之后,summary()函数提供的信息被组织成三个不同的部分。第一部分是模型残差的总结,这些残差是我们模型在训练数据上观测到的误差。第二部分是一个表格,包含模型系数的预测值以及它们显著性测试的结果。最后几行显示了模型的总体性能指标。如果我们对汽车数据集重复同样的过程,我们会在模型总结中注意到以下这一行:
Coefficients: (1 not defined because of singularities)
这种情况发生是因为我们仍然有一个特征,其影响输出效果与其他特征不可区分,这是由于潜在的依赖关系。这种现象被称为混叠。alias()命令显示了我们需要从模型中移除的特征:
> alias(cars_model1)
Model :
Price ~ Mileage + Cylinder + Doors + Cruise + Sound + Leather +
Buick + Cadillac + Chevy + Pontiac + Saab + Saturn + convertible + hatchback + sedan
Complete :
(Intercept) Mileage Cylinder Doors Cruise Sound
Saturn 1 0 0 0 0 0
Leather Buick Cadillac Chevy Pontiac Saab convertible
Saturn 0 -1 -1 -1 -1 -1 0
hatchback sedan
Saturn 0 0
如我们所见,问题特征是Saturn,因此我们将移除这个特征并重新训练模型。要排除线性回归模型中的一个特征,我们在公式中包含它,并在其后加上一个减号:
> cars_model2 <- lm(Price ~. -Saturn, data = cars_train)
> summary(cars_model2)
Call:
lm(formula = Price ~ . - Saturn, data = cars_train)
Residuals:
Min 1Q Median 3Q Max
-9324.8 -1606.7 150.5 1444.6 13461.0
Coefficients:
Estimate Std. Error t value Pr(>|t|)
(Intercept) -954.1919 1071.2553 -0.891 0.37340
Mileage -0.1877 0.0137 -13.693 < 2e-16 ***
Cylinder 3640.5417 123.5788 29.459 < 2e-16 ***
Doors 1552.4008 284.3939 5.459 6.77e-08 ***
Cruise 330.0989 324.8880 1.016 0.30998
Sound 388.4549 256.3885 1.515 0.13022
Leather 851.3683 274.5213 3.101 0.00201 **
Buick 1104.4670 595.0681 1.856 0.06389 .
Cadillac 13288.4889 673.6959 19.725 < 2e-16 ***
Chevy -553.1553 468.0745 -1.182 0.23772
Pontiac -1450.8865 524.9950 -2.764 0.00587 **
Saab 12199.2093 600.4454 20.317 < 2e-16 ***
convertible 11270.4878 597.5162 18.862 < 2e-16 ***
hatchback -6375.4970 669.6840 -9.520 < 2e-16 ***
sedan -4441.9152 490.8347 -9.050 < 2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
Residual standard error: 2947 on 669 degrees of freedom
Multiple R-squared: 0.912, Adjusted R-squared: 0.9101
F-statistic: 495.1 on 14 and 669 DF, p-value: < 2.2e-16
残差分析
残差简单来说就是我们的模型对特定观测值产生的误差。换句话说,它是实际输出值与我们的预测值之间的差异:

在构建一个好的回归模型时,分析残差非常重要,因为残差揭示了我们的模型的各种方面,从违反的假设和拟合质量到其他问题,例如异常值。为了理解残差摘要中的指标,想象一下将残差从小到大排序。除了出现在序列两端的极值最小值和最大值之外,摘要还显示了第一和第三四分位数,它们分别代表在这个序列中四分之一和三分之四处的值。中位数是序列中间的值。四分位距是第一和第三四分位数之间的序列部分,并且根据定义包含了一半的数据。首先看看我们 CPU 模型的残差摘要,有趣的是,与最小值和最大值相比,第一和第三四分位数的值相当小。这是第一个迹象,表明可能有一些点具有较大的残差误差。在理想情况下,我们的残差将有一个中位数为零,并且四分位数将具有较小的值。我们可以通过注意由lm()函数生成的模型具有residuals属性来重现残差摘要:
> summary(cars_model2$residuals)
Min. 1st Qu. Median Mean 3rd Qu. Max.
-9325.0 -1607.0 150.5 0.0 1445.0 13460.0
> mean(cars_train$Price)
[1] 21320.2
注意,在我们之前的 cars 模型示例中,我们需要将残差值与输出变量的平均值进行比较,以便了解残差是否很大。因此,之前的结果表明,我们训练数据中汽车的平均售价约为 21 千美元,并且 50%的预测值大致在正确值的±1.6 千美元范围内,这似乎相当合理。显然,我们 CPU 模型的残差绝对值都小得多,因为该模型的输出变量值,即发布的相对性能,比 cars 模型中的Price值小得多。
在线性回归中,我们假设模型中的不可减少误差是随机分布的,服从正态分布。一种称为分位数-分位数图(Q-Q 图)的诊断图有助于我们直观地评估这种假设的成立程度。这种图背后的关键思想是,我们可以通过比较两个分布的分位数来比较这两个分布。分布的分位数实际上是随机变量的等间距区间,每个区间具有相同的概率;例如,四分位数是四分位数,因为它们将分布分成四个等可能的四部分。如果两个分布相同,那么图表应该是一条线y = x的图。为了检查我们的残差是否服从正态分布,我们可以将它们的分布与正态分布进行比较,并看看我们离y = x线有多近。
小贴士
有许多其他方法可以检查模型残差是否呈正态分布。一个好的地方是查看nortest R 包,它实现了许多著名的正态性测试,包括安德森-达尔林测试和 Lilliefors 测试。此外,stats包包含用于执行 Shapiro-Wilk 正态性测试的shapiro.test()函数。
以下代码生成我们的两个数据集的 Q-Q 图:
> par(mfrow = c(2, 1))
> machine_residuals <- machine_model1$residuals
> qqnorm(machine_residuals, main = "Normal Q-Q Plot for CPU data set")
> qqline(machine_residuals)
> cars_residuals <- cars_model2$residuals
> qqnorm(cars_residuals, main = "Normal Q-Q Plot for Cars \data set")
> qqline(cars_residuals)
下图显示了 Q-Q 图:

两个模型的残差似乎合理地接近正态分布的理论分位数,尽管拟合并不完美,这在大多数现实世界的数据中是典型的。对于线性回归来说,第二个非常有用的诊断图是所谓的残差图。这是训练数据中观测值的残差与对应拟合值的图。换句话说,这是(i, e[i])对的图。残差图有两个重要的特性特别引起我们的兴趣。首先,我们希望通过检查残差是否在拟合值的不同范围内平均上不是更大,而是更小,来确认我们的常数方差假设。其次,我们应该验证残差中是否存在某种模式。然而,如果观察到模式,这可能表明基础模型在涉及的特征方面是非线性的,或者我们的模型中缺少一些我们没有包括的额外特征。实际上,发现可能对我们模型有用的新特征的一种方法是寻找与我们模型残差相关的特征。

两个图都显示了图形左侧残差略微减少的模式。更令人担忧的是,残差的方差似乎对于两个输出变量的较高值都要高一些,这可能表明误差不是同方差。这在第二个关于汽车数据集的图中更为明显。在前两个残差图中,我们还标记了一些较大的残差(以绝对值计)。我们很快就会看到这些是潜在的异常值候选者。另一种获得残差图的方法是使用lm()函数生成的模型上的plot()函数。这生成了四个诊断图,包括残差图和 Q-Q 图。
线性回归的显著性测试
在仔细审查残差摘要之后,我们接下来应该关注的是我们模型产生的系数表。在这里,每个估计系数都伴随着一组额外的数字,以及一个或多个星号或点在末尾。起初,由于数字的冲击,这可能会让人感到困惑,但所有这些信息都包含在内是有很好的理由的。当我们对某些数据进行测量并指定一组特征来构建线性回归模型时,通常情况下,这些特征中的一个或多个实际上与我们要预测的输出无关。当然,在我们收集数据之前,我们通常不会意识到这一点。理想情况下,我们希望我们的模型不仅找到与我们的输出实际依赖的特征相对应的系数的最佳值,而且还告诉我们哪些特征我们不需要。
确定我们模型中某个特定特征是否需要的可能方法之一是训练两个模型而不是一个。第二个模型将包含第一个模型的所有特征,但不包括我们试图确定其重要性的特定特征。然后,我们可以通过查看它们的残差分布来测试这两个模型是否不同。这正是 R 为我们每个模型中指定的所有特征所做的事情。对于每个系数,都会为对应特征与输出变量无关的零假设构建一个置信区间。具体来说,对于每个系数,我们考虑一个包含所有其他特征的线性模型,除了与该系数对应的特征。然后,我们测试是否将这个特定特征添加到模型中会显著改变残差误差的分布,这将作为该特征与输出之间存在线性关系的证据,并且其系数不应为零。R 的lm()函数会自动为我们运行这些测试。
注意
在统计学中,置信区间结合了点估计的精度。这是通过指定一个区间来完成的,在该区间内,估计的参数的真实值预计将在一定程度的置信度下。一个参数的 95%全局置信区间基本上告诉我们,如果我们从同一实验中收集 100 个数据样本,并为每个样本中估计的参数构建一个 95%的置信区间,那么目标参数的真实值将位于其对应的置信区间内 95 个数据样本。对于具有高方差的点估计构建的置信区间,例如当使用非常少的数据点进行估计时,往往会定义一个更宽的区间,以相同的置信度来定义,比使用低方差进行的估计。
让我们看一下 CPU 模型的摘要输出的快照,它显示了 CPU 模型中截距和 MYCT 特征的系数:
Estimate Std. Error t value Pr(>|t|)
(Intercept) -5.963e+01 8.861e+00 -6.730 2.43e-10 ***
MYCT 5.210e-02 1.885e-02 2.764 0.006335 **
目前专注于 MYCT 特征,其所在行中的第一个数字是它系数的估计值,这个数字大约是 0.05(5.210×10^(-2))。标准误差是这个估计值的标准差,这个值接下来给出为 0.01885。我们可以通过计算零和我们的系数估计值之间的标准误差数量来衡量我们对系数值是否真正为零(表示此特征的线性关系不存在)的信心。为此,我们可以将我们的系数估计值除以我们的标准误差,这正是t 值的定义,我们行中的第三个值:
> (q <- 5.210e-02 / 1.885e-02)
[1] 2.763926
因此,我们的 MYCT 系数几乎有 3 个标准误差远离零,这是一个相当好的指标,表明这个系数不太可能是零。t 值越高,我们越有可能在我们的线性模型中包含我们的特征,并且系数不为零。我们可以将这个绝对值转换成一个概率,告诉我们系数真正为零的可能性有多大。这个概率是从 Student's t 分布获得的,称为p 值。对于 MYCT 特征,这个概率是 0.006335,这个值很小。我们可以使用pt()函数获得这个值:
> pt(q, df = 172, lower.tail = F) * 2
[1] 0.006333496
pt()函数是 t 分布的分布函数,它是对称的。为了理解为什么我们的 p 值是这样计算的,请注意,我们感兴趣的是 t 值的绝对值大于我们计算出的值的概率。为了获得这个值,我们首先获得 t 分布的上尾或右尾的概率,然后乘以 2,以便包括下尾。在 R 中使用基本分布函数是一个非常重要的技能,如果这个例子看起来过于困难,我们已经在我们的在线教程章节中包含了示例。t 分布由自由度参数化。
注意
自由度基本上是我们计算特定统计量(如系数估计)时可以自由改变的变量的数量。在我们的线性回归背景下,这相当于我们的训练数据中的观测数减去模型中的参数数(回归系数的数量)。对于我们的 CPU 模型,这个数字是179 – 7 = 172。对于数据点更多的汽车模型,这个数字是 664。这个名字来源于它与作为系统输入应用的独立维度或信息数量的关系,因此反映了系统在不违反任何输入约束的情况下可以自由配置的程度。
一般来说,我们希望我们的 p 值小于 0.05,这意味着我们希望我们的系数估计的 95%置信区间不包括零。每个系数旁边的星号数量为我们提供了一个快速的可视辅助工具,以了解置信水平,一个星号对应我们的 95%经验法则,而两个星号则代表 99%的置信区间。因此,我们模型总结中没有任何星号的每个系数都对应一个特征,我们不太确定是否应该使用我们的经验法则将其包含在模型中。在 CPU 模型中,CHMIN 特征是唯一一个可疑的特征,其他特征的 p 值都非常小。在 cars 模型中,情况则不同。这里,我们有四个可疑的特征,包括截距项。
在我们的线性回归模型背景下,正确理解 p 值的解释非常重要。首先,我们不能也不应该将 p 值相互比较,以判断哪个特征最重要。其次,高 p 值并不一定意味着特征与输出之间没有线性关系;它仅仅表明,在所有其他模型特征存在的情况下,这个特征不会为输出变量提供任何新的信息。最后,我们应始终记住,95%的经验法则并非完美无缺,并且只有在特征和系数数量不是很大时才真正有用。在 95%的置信水平下,如果我们模型中有 1,000 个特征,我们平均可以期望有 50 个系数的结果是错误的。因此,线性回归系数显著性检验在处理高维问题时不那么有用。
显著性检验的最终结果实际上出现在lm()输出摘要的底部,位于最后一行。这一行提供了F 统计量,这个名字来源于 F 检验,该检验检查两个(理想情况下为正态分布)分布的方差之间是否存在统计显著性。在这个情况下,F 统计量试图评估所有系数为零的模型的残差方差与训练模型的残差方差之间是否存在显著的差异。
换句话说,F 检验将告诉我们训练的模型是否解释了输出中的部分方差,因此我们知道至少有一个系数不为零。虽然当我们有许多系数时并不那么有用,但这个测试一起测试了系数的显著性,并且不会像对单个系数的 t 检验那样出现问题。总结显示了一个极小的 p 值,因此我们知道至少有一个系数不为零。我们可以使用anova()函数重现所运行的 F 检验,该函数代表方差分析。这个测试比较了零模型,即仅包含截距而没有特征构建的模型,与我们的训练模型。我们将在这里展示 CPU 数据集的示例:
> machine_model_null <- lm(PRP ~ 1, data = machine_train)
> anova(machine_model_null, machine_model1)
Analysis of Variance Table
Model 1: PRP ~ 1
Model 2: PRP ~ MYCT + MMIN + MMAX + CACH + CHMIN + CHMAX
Res.Df RSS Df Sum of Sq F Pr(>F)
1 178 5130399
2 172 646479 6 4483919 198.83 < 2.2e-16 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
注意,零模型公式为 PRP ~ 1,其中 1 代表截距。
线性回归的性能指标
我们总结中的最后细节涉及模型的整体性能以及线性模型拟合数据的程度。为了理解我们如何评估线性回归拟合,我们首先应该指出,线性回归模型的训练标准是最小化数据上的 MSE。换句话说,将线性模型拟合到一组数据点相当于找到一个斜率和位置,使得这些点到直线的平方距离之和(或平均值)最小。由于我们将数据点与其在直线上的预测值之间的误差称为残差,我们可以将残差平方和(RSS)定义为所有平方残差的和:

换句话说,RSS 仅仅是平方误差之和(SSE),因此我们可以通过这个简单的方程与我们所熟悉的 MSE(均方误差)联系起来:

除去某些历史原因之外,RSS 是一个需要关注的 重要指标,因为它与另一个重要的指标 RSE(残差平方和)相关,我们将在下一节中讨论。为此,我们首先需要建立起对训练线性回归模型时会发生什么的感觉。如果我们多次运行我们的简单线性回归实验,每次改变随机种子以获得不同的随机样本,我们会看到我们会得到多条回归线,这些回归线很可能非常接近真实总体线,正如我们的单次运行所显示的那样。这说明了线性模型通常具有低方差的特点。当然,我们试图逼近的未知函数可能非常非线性,因此,即使是总体回归线也不太可能很好地拟合非线性函数的数据。这是因为线性假设非常严格,因此,线性回归是一种具有高偏差的方法。
我们定义了一个称为残差标准误差(RSE)的度量,它估计了我们的模型与目标函数的标准差。也就是说,它大致衡量了我们的模型平均偏离总体回归线的距离。这是以输出变量的单位来衡量的,是一个绝对值。因此,它需要与y的值进行比较,以判断对于特定的样本它是否很高。具有k个输入特征的模型的通用 RSE 计算如下:

对于简单线性回归,这仅仅是k = 1时的情况:

我们可以使用前面的公式来计算我们两个模型的 RSE,如下所示:
> n_machine <- nrow(machine_train)
> k_machine <- length(machine_model1$coefficients) - 1
> sqrt(sum(machine_model1$residuals ^ 2) / (n_machine - k_machine - 1))
[1] 61.30743
> n_cars <- nrow(cars_train)
> k_cars <- length(cars_model2$coefficients) - 1
> sqrt(sum(cars_model2$residuals ^ 2) / (n_cars - k_cars - 1))
[1] 2946.98
为了解释我们两个模型的 RSE 值,我们需要将它们与我们的输出变量均值进行比较:
> mean(machine_train$PRP)
[1] 109.4804
> mean(cars_train$Price)
[1] 21320.2
注意,在汽车模型中,61.3 的 RSE 与汽车模型的 RSE 相比非常小,后者大约为 2,947。然而,当我们从这些数字与各自输出变量均值接近的程度来看时,我们了解到实际上汽车模型的 RSE 显示出更好的拟合。
现在,尽管 RSE 作为一个绝对值是有用的,因为可以将其与输出变量的均值进行比较,但我们经常想要一个相对值,我们可以用它来比较不同的训练场景。为此,在评估线性回归模型的拟合度时,我们通常也会查看R²统计量。在总结中,这表示为多重 R 平方。在我们提供方程之前,我们首先介绍总平方和(TSS)的概念。总平方和与输出变量的总方差成比例,旨在衡量我们在进行回归之前该变量的内在变异性。TSS 的公式是:

R²统计量的背后的思想是,如果一个线性回归模型与真实总体模型非常接近,它应该能够完全捕捉输出中的所有方差。实际上,我们经常将 R²统计量称为相对量,它显示了输出方差中有多少比例是由回归解释的。当我们应用我们的回归模型来获得输出变量的估计时,我们看到我们的观察误差被称为残差,而 RSS 基本上与我们的预测和输出函数真实值之间剩余的方差成比例。因此,我们可以定义 R²统计量,即我们的线性回归模型解释的输出y中的方差量,作为起始方差(TSS)和结束方差(RSS)相对于起始方差(TSS)的差值。作为一个公式,这仅仅是:

从这个方程中,我们可以看到 R²的范围在 0 到 1 之间。一个接近 1 的值表明拟合良好,因为它意味着输出变量的大部分方差已经被回归模型解释。另一方面,一个低值表明模型中仍然存在显著的误差方差,这表明我们的模型不是一个好的拟合。让我们看看如何手动计算我们两个模型的 R²统计量:
compute_rsquared <- function(x, y) {
rss <- sum((x - y) ^ 2)
tss <- sum((y - mean(y)) ^ 2)
return(1 - (rss / tss))
}
> compute_rsquared(machine_model1$fitted.values, machine_train$PRP)
[1] 0.8739904
> compute_rsquared(cars_model2$fitted.values, cars_train$Price)
[1] 0.9119826
我们使用了由lm()训练的模型的fitted.values属性,这是模型在训练数据上做出的预测。这两个值都相当高,汽车模型再次显示出略微更好的拟合。我们现在已经看到了两个评估线性回归模型的重要指标,即 RSE 和 R²统计量。在这个时候,我们可能考虑是否存在一个更通用的度量两个变量之间线性关系的指标,我们也可以将其应用于我们的案例。从统计学中,我们可能会回忆起相关性的概念正是描述这一点。
两个随机变量X和Y之间的相关性由以下公式给出:

结果表明,在简单回归的情况下,输出变量和输入特征之间的相关性的平方与 R²统计量相同,这一结果进一步强调了后者作为一个有用指标的重要性。
比较不同的回归模型
当我们想要比较在相同输入特征集上训练的两个不同的回归模型时,R²统计量非常有用。然而,通常我们想要比较两个没有相同数量输入特征的模型。例如,在特征选择的过程中,我们可能想知道是否在我们的模型中包含某个特定特征是一个好主意。R²统计量的一个局限性是它往往对于具有更多输入参数的模型更高。
调整 R²试图纠正 R²总是对于具有更多输入特征的模型更高的现象,因此容易过拟合。调整 R²通常低于 R²本身,正如我们可以通过检查我们的模型摘要中的值来验证的那样。调整 R²的公式是:

n和k的定义与 R²统计量的定义相同。现在,让我们在 R 中实现这个函数并计算我们两个模型的调整 R²:
compute_adjusted_rsquared <- function(x, y, k) {
n <- length(y)
r2 <- compute_rsquared(x, y)
return(1 - ((1 - r2) * (n - 1) / (n - k - 1)))
}
> compute_adjusted_rsquared(machine_model1$fitted.values,
machine_train$PRP, k_machine)
[1] 0.8695947
> compute_adjusted_rsquared(cars_model2$fitted.values,
cars_train$Price, k_cars)
[1] 0.9101407
注意
有几种其他常用的性能指标,旨在比较具有不同特征数量的模型。赤池信息量准则(AIC)使用信息论方法,通过平衡模型复杂度和准确性来评估模型的相对质量。对于通过最小化平方误差训练的线性回归模型,这与其他已知的统计量马尔可夫 Cp(Mallow's Cp)成比例,因此它们可以互换使用。第三个指标是贝叶斯信息量准则(BIC)。与之前的指标相比,它倾向于对具有更多变量的模型进行更重的惩罚。
测试集性能
到目前为止,我们已经从训练数据的角度来评估我们模型的性能。这对于判断一个线性模型是否能够很好地拟合数据非常重要,但它并不能给我们一个关于未见数据预测准确性的良好感觉。为此,我们转向我们的测试数据集。为了使用我们的模型进行预测,我们可以使用predict()函数。这是 R 中一个通用的函数,许多包都对其进行了扩展。对于使用lm()训练的模型,我们只需要提供模型和一个包含我们想要预测的观测值的数据框:
> machine_model1_predictions <- predict(machine_model1,
machine_test)
> cars_model2_predictions <- predict(cars_model2, cars_test)
接下来,我们将定义我们自己的函数来计算均方误差(MSE):
compute_mse <- function(predictions, actual) {
mean( (predictions - actual) ^ 2 )
}
> compute_mse(machine_model1$fitted.values, machine_train$PRP)
[1] 3611.616
> compute_mse(machine_model1_predictions, machine_test$PRP)
[1] 2814.048
>
> compute_mse(cars_model2$fitted.values, cars_train$Price)
[1] 8494240
> compute_mse(cars_model2_predictions, cars_test$Price)
[1] 7180150
对于每个模型,我们已经使用我们的compute_mse()函数来返回训练和测试的 MSE。在这种情况下,两个测试 MSE 值都小于训练 MSE 值。测试 MSE 是略微大于还是小于训练 MSE 并不特别重要。重要的问题是测试 MSE 并没有显著大于训练 MSE,因为这会表明我们的模型正在过度拟合数据。请注意,特别是对于 CPU 模型,原始数据集中的观测数非常少,这导致了测试集的大小也非常小。因此,我们应该对我们的模型在未见数据上的预测性能的准确性保持谨慎,因为使用小测试集大小做出的预测将具有更高的方差。
线性回归问题
在本章中,我们已经看到了一些例子,说明尝试构建线性回归模型可能会遇到问题。我们讨论的一个大类别的问题与我们的模型假设——线性、特征独立性和误差的同方差性和正态性有关。特别是,我们看到了通过绘图(如残差图)或使用识别相关成分的函数来诊断这些问题的方法。在本节中,我们将探讨一些可能出现在线性回归中的更多问题。
多重共线性
作为预处理步骤的一部分,我们勤奋地移除了彼此之间线性相关的特征。在这个过程中,我们寻找的是精确的线性关系,这是一个完全共线性的例子。共线性是描述两个特征大约处于线性关系时的属性。这给线性回归带来了问题,因为我们试图为几乎相互为线性函数的变量分配单独的系数。这可能导致两个高度共线性特征具有高 p 值,表明它们与输出变量无关,但如果我们移除其中一个并重新训练模型,剩下的那个将具有低 p 值。共线性的另一个经典迹象是其中一个系数的异常符号;例如,对于一个预测收入的线性模型,教育背景的系数为负。可以通过成对相关性检测两个特征之间的共线性。处理共线性的方法之一是将两个特征合并成一个新的特征(例如,通过平均);另一种方法简单地丢弃其中一个特征。
当线性关系涉及超过两个特征时,就会发生多重共线性。检测这种关系的一个标准方法是计算线性模型中每个输入特征的方差膨胀因子(VIF)。简而言之,VIF 试图估计由于该特征与其他特征共线性而导致的特定系数估计中观察到的方差增加。这通常是通过拟合一个线性回归模型来完成的,我们将其中一个特征作为输出特征,将剩余的特征作为常规输入特征。然后我们计算这个线性模型的 R²统计量,并据此使用公式 1 / (1 – R²) 计算我们选择特征的 VIF。在 R 中,car包包含vif()函数,它可以方便地计算线性回归模型中每个特征的 VIF 值。这里的一个经验法则是,如果一个特征的 VIF 得分超过 4,那么它是可疑的,而得分超过 10 则表明存在多重共线性的可能性很大。鉴于我们注意到我们的汽车数据具有线性相关的特征,我们必须移除它们,让我们调查剩下的那些是否有多重共线性:
> library("car")
> vif(cars_model2)
Mileage Cylinder Doors Cruise Sound
1.010779 2.305737 4.663813 1.527898 1.137607
Leather Buick Cadillac Chevy Pontiac
1.205977 2.464238 3.158473 4.138318 3.201605
Saab convertible hatchback sedan
3.515018 1.620590 2.481131 4.550556
在这里,我们看到三个略高于4的值,但没有超过这个值的值。例如,以下代码展示了如何计算sedan的 VIF 值:
> sedan_model <- lm(sedan ~ .-Price -Saturn, data = cars_train)
> sedan_r2 <- compute_rsquared(sedan_model$fitted.values, cars_train$sedan)
> 1 / (1-sedan_r2)
[1] 4.550556
异常值
当我们查看两个模型的残差时,我们发现某些观测的残差明显高于其他观测。例如,参考 CPU 模型的残差图,我们可以看到观测 200 有一个非常高的残差。这是一个异常值的例子,其预测值与实际值相差甚远。由于残差的平方,异常值往往会显著影响 RSS,让我们有一种感觉,即我们没有好的模型拟合。异常值可能由于测量误差而产生,检测它们可能很重要,因为它们可能表示不准确或不有效的数据。
另一方面,异常值可能仅仅是由于没有正确的特征或构建了错误类型的模型。
由于我们在数据收集过程中通常无法知道一个异常值是错误还是真实的观测,处理异常值可能非常棘手。有时,尤其是当我们只有很少的异常值时,一种常见的做法是移除它们,因为包括它们通常会显著改变预测模型的系数。我们说异常值通常是具有高影响力的点。
注意
异常值并不是唯一可能具有高影响力的观测。高杠杆点是指至少有一个特征具有极端值的观测,因此它们与其他大多数观测点相距甚远。库克距离是一个典型的指标,它结合了异常值和高杠杆的概念,以识别对数据具有高影响力的点。对于线性回归诊断的更深入探索,一本非常好的参考书是《应用回归的 R 伴侣》,作者约翰·福克斯,由 Sage Publications 出版。
为了说明移除异常值的效果,我们将使用不包含观测编号 200 的训练数据创建一个新的 CPU 模型。然后,我们将看看我们的模型在训练数据上是否有更好的拟合。在这里,我们展示了所采取的步骤和截断后的模型摘要,只包含最后三行:
> machine_model2 <- lm(PRP ~ ., data = machine_ train[!(rownames(machine_train)) %in% c(200),])
> summary(machine_model2)
...
Residual standard error: 51.37 on 171 degrees of freedom
Multiple R-squared: 0.8884, Adjusted R-squared: 0.8844
F-statistic: 226.8 on 6 and 171 DF, p-value: < 2.2e-16
如我们所见,RSE 降低和 R2 提高,表明我们在训练数据上有了更好的拟合。当然,模型准确性的真正衡量标准是测试数据的性能,而且我们无法保证将观测 200 标记为虚假异常值是正确的决定。
> machine_model2_predictions <- predict(machine_model2,
machine_test)
> compute_mse(machine_model2_predictions, machine_test$PRP)
[1] 2555.355
我们现在的测试均方误差(MSE)比之前低,这通常是一个好兆头,表明我们做出了正确的选择。然而,由于我们的测试集很小,尽管 MSE 给出了积极的指示,我们仍然不能确定这一点。
特征选择
我们的 CPU 模型只包含六个特征。通常,我们会遇到来自各种测量的具有大量特征的现实世界数据集。或者,当我们不确定哪些特征会对影响我们的输出变量产生重要影响时,我们可能需要提出大量特征。此外,我们可能还有许多可能级别的分类变量,我们必须从中创建大量新的指示变量,正如我们在 第一章 中所见到的,为预测建模做准备。当我们的场景涉及大量特征时,我们通常发现我们的输出只依赖于这些特征的一个子集。给定 k 个输入特征,我们可以形成 2^k 个不同的子集,因此对于特征数量适中的情况,子集空间太大,我们无法通过在每个子集上拟合模型来完全探索。
小贴士
理解为什么存在 2^k 个可能的特征子集的一个简单方法是这样的:我们可以为每个子集分配一个唯一的识别码,这个识别码是一个长度为 k 的二进制数字字符串,其中某个位置 i 的数字为 1 表示我们选择了包含第 i 个特征(特征可以任意排序)的子集。例如,如果我们有三个特征,字符串 101 对应的子集只包含第一个和第三个特征。通过这种方式,我们从长度为 k 的零字符串形成所有可能的二进制字符串,直到长度为 k 的全一字符串;因此我们得到了从 0 到 2^(k-1) 和 2^k 个总子集的所有数字。
特征选择 指的是在模型中选择特征子集的过程,以便形成一个具有较少特征的新模型。这移除了我们认为与输出变量无关的特征,从而得到一个更简单的模型,这个模型更容易训练和解释。有许多方法旨在完成这项任务,它们通常不涉及对可能子集空间的全面搜索,而是通过这个空间进行有指导的搜索。
一种这样的方法是前向选择,它是一个逐步回归的例子,通过一系列步骤进行特征选择。使用前向选择时,我们的想法是从一个没有任何特征选择的空模型开始。然后我们进行 k 个简单的线性回归(每个特征一个),并选择最好的一个。在这里,我们比较具有相同数量特征的模型,这样我们就可以使用 R² 统计量来指导我们的选择,尽管我们也可以使用 AIC 等指标。一旦我们选择了要添加的第一个特征,我们就从剩余的 k-1 个特征中选择另一个特征来添加。因此,我们现在为每个可能的特征对运行 k-1 个多重回归,其中一对特征中的一个是我们第一步中选择的特征。我们继续以这种方式添加特征,直到我们评估了包含所有特征的模型并停止。请注意,在每一步中,我们都要做出一个关于要包含哪个特征以供所有后续步骤使用的艰难选择。
例如,具有一个以上特征且不包含我们在此过程的第一步中选择的特征的模型永远不会被考虑。因此,我们不会彻底搜索我们的空间。实际上,如果我们考虑到我们还要评估空模型,我们可以计算出我们对多少个模型进行了线性回归的总数如下:

这种计算的量级在 k² 的范围内,对于 k 的较小值来说,这已经比 2^k 小得多。在前向选择过程结束时,我们必须在 k+1 个模型之间进行选择,这些模型对应于过程每一步结束时获得的子集。由于过程的最后部分涉及比较具有不同数量特征的模型,我们通常使用 AIC 或调整后的 R² 等标准来做出最终模型选择。我们可以通过运行以下命令来演示我们的 CPU 数据集的过程:
> machine_model3 <- step(machine_model_null, scope = list(lower = machine_model_null, upper = machine_model1), direction = "forward")
step() 函数实现了前向选择的过程。我们首先向它提供通过在我们的训练数据上拟合没有特征的线性模型得到的空模型。对于 scope 参数,我们指定我们希望我们的算法从空模型逐步过渡到包含所有六个特征的完整模型。在 R 中发出这些命令的效果是输出一个演示迭代每一步中指定的特征子集的输出。为了节省空间,我们将结果以及每个模型的 AIC 值以以下表格的形式呈现。请注意,AIC 值越低,模型越好。
| 步骤 | 子集特征 | AIC 值 |
|---|---|---|
| 0 | {} |
1839.13 |
| 1 | {MMAX} |
1583.38 |
| 2 | {MMAX, CACH} |
1547.21 |
| 3 | {MMAX, CACH, MMIN} |
1522.06 |
| 4 | {MMAX, CACH, MMIN, CHMAX} |
1484.14 |
| 5 | {MMAX, CACH, MMIN, CHMAX, MYCT} |
1478.36 |
step() 函数使用了一种替代的前向选择规范,即在没有任何剩余特征可以添加到当前特征子集并且会提高我们的分数时终止。对于我们的数据集,最终模型中只遗漏了一个特征,因为添加它并没有提高整体分数。有趣且多少有些令人放心的是,这个特征是 CHMIN,它是唯一一个相对高 p 值的变量,这表明在其他特征存在的情况下,我们并不确定我们的输出变量与这个特征相关。
有些人可能会想知道我们是否可以通过从完整模型开始,逐个删除特征,根据哪个特征被删除时会使模型分数提高最大来进行变量选择。这确实可能,这个过程被称为向后选择或向后消除。在 R 中,可以通过指定 backward 作为方向并从完整模型开始,使用 step() 函数来完成此操作。我们将在我们的汽车数据集上展示这一点,并将结果保存到一个新的汽车模型中:
> cars_model_null <- lm(Price ~ 1, data = cars_train)
> cars_model3 <- step(cars_model2, scope = list(
lower=cars_model_null, upper=cars_model2), direction = "backward")
汽车数据集上最终线性回归模型的公式是:
Call:
lm(formula = Price ~ Mileage + Cylinder + Doors + Leather + Buick + Cadillac + Pontiac + Saab + convertible + hatchback + sedan,
data = cars_train)
如我们所见,最终模型已经丢弃了 Cruise、Sound 和 Chevy 特征。查看我们之前的模型摘要,我们可以看到这三个特征具有高 p 值。前两种方法是贪婪算法的例子。这意味着一旦关于是否包含变量的选择被做出,它就变得最终且不能在以后撤销。为了解决这个问题,一种称为混合选择或双向消除的变量选择方法开始时是前向选择,使用前向步骤添加变量,但在这些步骤可以提高 AIC 时也包括向后步骤。可预测的是,当 direction 被指定为 both 时,step() 函数会这样做。
现在我们有了两个新的模型,我们可以看到它们在测试集上的表现:
> machine_model3_predictions <- predict(machine_model3, machine_test)
> compute_mse(machine_model3_predictions, machine_test$PRP)
[1] 2805.762
>
> cars_model3_predictions <- predict(cars_model3, cars_test)
> compute_mse(cars_model3_predictions, cars_test$Price)
[1] 7262383
对于 CPU 模型,我们在测试集上的表现略好于我们的原始模型。一个合适的下一步可能是调查这个特征集是否与移除我们的异常值结合使用效果更好;这留给读者作为练习。相比之下,对于汽车模型,我们看到由于移除了所有这些特征,测试 MSE 略有增加。
正则化
变量选择是一个重要的过程,因为它试图通过消除与输出无关的变量,使模型更容易解释、更容易训练,并且没有虚假关联。这是处理过拟合问题的一种可能方法。一般来说,我们不期望模型完全拟合我们的训练数据;事实上,过拟合的问题通常意味着如果我们对训练数据拟合得太好,可能会损害我们的预测模型在未见数据上的准确性。在本节关于正则化的内容中,我们将研究一种减少变量数量的替代方法,以处理过拟合问题。正则化本质上是在我们的训练过程中引入一个有意的偏差或约束,以防止我们的系数取大值。由于这是一个试图缩小系数的过程,因此我们将会探讨的方法也被称为收缩方法。
岭回归
当参数数量非常大,尤其是与可用观测值的数量相比时,线性回归往往会表现出非常高的方差。这意味着观测值中的一些微小变化会导致系数发生显著变化。岭回归是一种通过其约束引入偏差的方法,但能有效减少模型的方差。岭回归试图最小化残差平方和的总和,并使用一个涉及系数平方和乘以一个常数的项,我们将使用希腊字母λ来表示这个常数。对于一个有k个参数的模型(不包括常数项β[0]),以及一个有n个观测值的数据库,岭回归最小化以下量:

我们仍在最小化均方误差(RSS),但第二项是惩罚项,当任何系数较高时,该惩罚项会增大。因此,在最小化过程中,我们实际上是在将系数推向更小的值。λ参数被称为元参数,我们需要选择或调整它。λ的值非常大时,会掩盖 RSS 项,并将系数推向零。λ的值过小则对防止过拟合的效果不佳,而λ参数为 0 则仅执行普通线性回归。
在进行岭回归时,我们通常希望通过将所有特征值除以它们的方差来进行缩放。这与普通线性回归不同,因为如果某个特征值乘以 10 倍,那么系数将简单地乘以十分之一来补偿。在岭回归中,一个特征的缩放会通过惩罚项影响其他所有特征的计算。
最小绝对收缩和选择算子(lasso)
lasso是岭回归的一种替代正则化方法。差异仅出现在惩罚项上,该惩罚项涉及最小化系数绝对值的总和。

结果表明,惩罚项的差异非常显著,因为 lasso 结合了收缩和选择,它将一些系数收缩到正好为零,而岭回归则不是这样。尽管如此,这两种方法之间并没有明确的胜者。依赖于输入特征子集的模型倾向于使用 lasso 表现更好;具有许多不同变量系数分布广泛的模型倾向于使用岭回归表现更好。通常尝试这两种方法都是值得的。
注意
岭回归中的惩罚通常被称为 l[2] 惩罚,而 lasso 中的惩罚项则被称为 l[1] 惩罚。这源于向量 范数 的数学概念。向量的范数是一个函数,它将一个正数分配给该向量以表示其长度或大小。有许多不同类型的范数。l[1] 和 l[2] 范数是称为 p-范数 的范数族中的例子,对于具有 n 个分量的向量 v,它们具有以下一般形式:

在 R 中实现正则化
有许多不同的函数和包实现了岭回归,例如来自 MASS 包的 lm.ridge() 和来自 genridge 包的 ridge()。对于 lasso,也有 lars 包。在本章中,我们将使用来自 glmnet 包的 glmnet() 函数,因为它具有一致且友好的界面。使用正则化的关键是确定一个合适的 λ 值。glmnet() 函数使用的方法是使用不同 λ 值的网格,并为每个值训练一个回归模型。然后,可以选择手动选择一个值或使用一种技术来估计最佳的 lambda。我们可以通过 lambda 参数指定要尝试的 λ 值序列;否则,将使用默认的包含 100 个值的序列。glmnet() 函数的第一个参数必须是一个特征矩阵,我们可以使用 model.matrix() 函数构建它。
第二个参数是一个包含输出变量的向量。最后,alpha 参数是在岭回归(0)和 lasso(1)之间的开关。我们现在已经准备好在 cars 数据集上训练一些模型:
> library(glmnet)
> cars_train_mat <- model.matrix(Price ~ .-Saturn, cars_train)[,-1]
> lambdas <- 10 ^ seq(8, -4, length = 250)
> cars_models_ridge <-
glmnet(cars_train_mat, cars_train$Price, alpha = 0, lambda = lambdas)
> cars_models_lasso <-
glmnet(cars_train_mat, cars_train$Price, alpha = 1, lambda = lambdas)
由于我们提供了一系列 250 个 λ 值,我们实际上训练了 250 个岭回归模型和另外 250 个 lasso 模型。我们可以从 glmnet() 函数生成的对象的 lambda 属性中看到 λ 的值,然后应用 coef() 函数来检索第 100 个模型的相应系数,如下所示:
> cars_models_ridge$lambda[100]
[1] 1694.009
> coef(cars_models_ridge)[,100]
(Intercept) Mileage Cylinder Doors
6217.5498831 -0.1574441 2757.9937160 371.2268405
Cruise Sound Leather Buick
1694.6023651 100.2323812 1326.7744321 -358.8397493
Cadillac Chevy Pontiac Saab
11160.4861489 -2370.3268837 -2256.7482905 8416.9209564
convertible hatchback sedan
10576.9050477 -3263.4869674 -2058.0627013
我们可以使用 plot() 函数来获得一个图表,显示系数的值如何随着 λ 的对数变化而变化。
如下所示,同时展示岭回归和 lasso 的对应图表非常有帮助:
> layout(matrix(c(1, 2), 1, 2))
> plot(cars_models_ridge, xvar = "lambda", main = "Ridge
Regression\n")
> plot(cars_models_lasso, xvar = "lambda", main = "Lasso\n")

这两个图的关键区别在于,lasso 强制许多系数恰好降到零,而岭回归中它们倾向于平滑下降,只有在λ的极端值时才整体变为零。这一点通过阅读两个图顶部水平轴上的数值可以进一步证实,这些数值显示了随着λ的变化非零系数的数量。这样,lasso 在特征选择(因为零系数的特征实际上不包括在模型中)以及提供正则化以最小化过拟合问题方面具有显著优势。我们可以通过更改xvar参数提供的值来获得其他有用的图。值norm在 x 轴上绘制系数的 l[1]范数,而dev绘制解释的偏差百分比。我们将在下一章学习偏差。
为了解决寻找合适的λ值的问题,glmnet()包提供了cv.glmnet()函数。这个函数使用一种称为交叉验证的技术(我们将在第五章中学习),支持向量机)在训练数据上找到合适的λ值,以最小化均方误差(MSE):
> ridge.cv <- cv.glmnet(cars_train_mat, cars_train$Price, alpha = 0)
> lambda_ridge <- ridge.cv$lambda.min
> lambda_ridge
[1] 641.6408
> lasso.cv <- cv.glmnet(cars_train_mat, cars_train$Price, alpha = 1)
> lambda_lasso <- lasso.cv$lambda.min
> lambda_lasso
[1] 10.45715
如果我们绘制cv.glmnet()函数产生的结果,我们可以看到均方误差如何随λ的不同值而变化:

每个点上方和下方的条形是误差条,表示每个绘制的λ值估计的 MSE 上方和下方的一个标准差。这些图还显示了两条垂直虚线。第一条垂直线对应于lambda.min的值,这是交叉验证提出的最佳值。第二条垂直线在右侧的值是lambda.1se属性中的值。这对应于比lambda.min大一个标准误差的值,并产生一个更正则化的模型。
使用glmnet包,predict()函数现在可以在各种上下文中运行。例如,我们可以获取一个不在我们原始列表中的λ值的模型系数。
例如,我们有以下内容:
> predict(cars_models_lasso, type = "coefficients", s = lambda_lasso)
15 x 1 sparse Matrix of class "dgCMatrix"
1
(Intercept) -521.3516739
Mileage -0.1861493
Cylinder 3619.3006985
Doors 1400.7484461
Cruise 310.9153455
Sound 340.7585158
Leather 830.7770461
Buick 1139.9522370
Cadillac 13377.3244020
Chevy -501.7213442
Pontiac -1327.8094954
Saab 12306.0915679
convertible 11160.6987522
hatchback -6072.0031626
sedan -4179.9112364
注意,在这种情况下,lasso 似乎没有强制任何系数为零,这表明根据 MSE,它不建议从 cars 数据集中删除任何系数。最后,再次使用predict()函数,我们可以使用newx参数提供特征矩阵来对观察值进行预测,从而使用正则化模型进行预测:
> cars_test_mat <- model.matrix(Price ~ . -Saturn, cars_test)[,-1]
> cars_ridge_predictions <- predict(cars_models_ridge, s =
lambda_ridge, newx = cars_test_mat)
> compute_mse(cars_ridge_predictions, cars_test$Price)
[1] 7609538
> cars_lasso_predictions <- predict(cars_models_lasso, s =
lambda_lasso, newx = cars_test_mat)
> compute_mse(cars_lasso_predictions, cars_test$Price)
[1] 7173997
Lasso 模型表现最佳,并且与岭回归不同,在这种情况下,在测试数据上也略微优于常规模型。
多项式回归
多项式回归是一种类型的线性回归。
当预测变量和响应变量都是连续的并且线性相关时,这就是线性回归,响应变量会以恒定的比率相对于预测变量增加或减少(即,呈直线),而在多项式回归中,会依次添加预测变量的不同幂次,以查看它们是否能显著调整响应。随着这些增加被添加到方程中,数据点的线条将改变其形状,将线性回归模型从最佳拟合线转变为最佳拟合曲线。
那么,为什么你应该费心去考虑多项式回归呢?普遍接受的答案或思维过程是:当线性模型似乎不是你数据的最佳模型时。
有三个主要条件表明线性关系可能不是一个好的模型:
-
在你的数据中,将会有一些变量关系,你假设它们是曲线关系。
-
在检查你的变量时,你建立(使用散点图是最常见的方法)一个曲线关系。
-
在你实际上创建了线性回归模型之后,通过残差(使用散点图)的检查显示,中间有许多正残差值,但在两端(或反之)有负残差值的块状区域。
注意
注意:在曲线关系中,值会一起增加到一定水平(如正相关关系),然后,随着一个值的增加,另一个值减少(负相关关系)或反之。
因此,在这里,我们考虑一个类似于刚才提到的涉及用户汽车的一个例子。在我们的车辆数据中,我们有许多属性的信息,包括每辆车的选项数量。假设我们感兴趣的是汽车拥有的选项数量(如空调或加热座椅)与二手价格之间的关系。
人们可能会认为,一辆车拥有的选项越多,其售价就越高。
然而,在更仔细地分析这些数据后,我们发现情况并非如此:

如果我们绘制数据,我们可以看到可能是一个受益于多项式回归的情景的教科书示例:

在这个假设场景中,自变量x(二手价格相对于蓝皮书价值的百分比增加)和因变量y(车辆拥有的选项数量)之间的关系可以用x的n次幂多项式来建模。
摘要
在本章中,我们研究了线性回归,这是一种允许我们在有多个输入特征和单个数值输出的监督学习环境中拟合线性模型的方法。简单线性回归是指我们只有一个输入特征的情况,而多重线性回归描述的是我们拥有多个输入特征的情况。线性回归是非常常用的回归问题解决方案的第一步。它假设输出是输入特征的线性加权组合,存在一个不可减少的误差成分,该误差成分服从正态分布,均值为零,方差恒定。该模型还假设特征是独立的。线性回归的性能可以通过多种不同的指标来评估,从更标准的均方误差(MSE)到其他指标,如 R²统计量。我们探讨了几个模型诊断和显著性测试,旨在检测违反假设的问题和异常值。最后,我们还讨论了如何使用逐步回归进行特征选择,以及如何使用岭回归和 Lasso 进行正则化。
线性回归是一个具有多个优点的模型,包括快速且成本低廉的参数计算,以及由于其简单形式,非常容易解释和从中得出推论。有大量的测试可用于诊断模型拟合问题,并执行假设检验以检查系数的显著性。总的来说,作为一种方法,它被认为是低方差,因为它对数据中的小误差具有鲁棒性。然而,从负面来看,因为它做出了非常严格的假设,特别是输出函数在模型参数中必须是线性的,这引入了很高的偏差,对于复杂或高度非线性的通用函数,这种方法往往表现不佳。此外,我们看到了当我们转向大量输入特征时,我们实际上不能真正依赖于系数的显著性测试。这一事实,加上特征之间的独立性假设,使得线性回归在处理高维特征空间时成为一个相对较差的选择。
我们还提到了多项式回归作为线性回归不足以拟合数据时的一个选项,这基于你的数据点值之间的关系,或者当线性回归模型的残差显示出某些正负关系时。
在下一章中,我们将研究逻辑回归,这是在分类问题中使用的 重要方法。
第四章. 广义线性模型
对于预测数值输出的回归任务,例如价格或温度,我们已经看到线性回归可能是一个好的起点。它易于训练且易于解释,尽管作为一个模型,它对数据和潜在的目标函数做出了严格的假设。在学习更高级的回归问题解决技术之前,我们将介绍逻辑回归。尽管其名称有些误导,但实际上这是我们第一个用于执行分类的模型。正如我们在第一章中学习的,“准备预测建模”,在分类问题中,我们的输出是定性的,因此由有限值的集合组成,我们称之为类别。我们将从考虑二元分类场景开始,我们试图区分两个类别,我们将任意地将它们标记为 0 和 1,稍后我们将扩展到区分多个类别。最后,我们将简要介绍其他回归方法,泊松回归和负二项回归。
使用线性回归进行分类
尽管我们知道分类问题涉及定性输出,但似乎很自然地会问我们是否可以使用我们现有的线性回归知识并将其应用于分类场景。我们可以通过训练一个线性回归模型来预测区间 [0, 1] 内的值来实现这一点,记住我们已经选择将两个类别标记为 0 和 1。然后,我们可以将阈值应用于我们模型的输出,这样,如果模型输出的值低于 0.5,我们就会预测类别 0;否则,我们就会预测类别 1。
以下图表展示了对于具有单个输入特征 X1 的简单线性回归和二元分类问题,这一概念。

我们的目标变量 y 要么是 0,要么是 1,因此所有数据都位于两条水平线上。实线表示模型的输出,虚线表示决策边界,它出现在我们将模型预测输出的阈值设置为 0.5 时。虚线左侧的点被预测为属于类别 0,而右侧的点被预测为属于类别 1。
该模型显然并不完美,但它似乎确实正确分类了大部分数据。
尽管在这种情况下这是一个很好的近似,但这种方法在许多方面感觉并不正确。首先,尽管我们事先知道我们的输出变量被限制在区间[0, 1]内,因为我们只有两个类别,但线性回归的原始输出预测的值超出了这个范围。我们可以从输入特征X1的值非常低或非常高的图中看到这一点。其次,线性回归旨在解决最小化均方误差的问题,这似乎不适合我们这种情况。我们的目标实际上是找到一种方法来分离两个类别,而不是最小化与最佳拟合线的均方误差。因此,决策边界的位置对高杠杆点的存在非常敏感。正如我们在第二章“线性回归”中讨论的那样,高杠杆点是那些由于至少一个输入特征的极端值而远离大部分数据的点。
下面的图展示了高杠杆点对我们分类器的影响:

这里,数据与之前完全相同,只是我们为类别 1 添加了两个新的观测值,这两个观测值在特征X1上的值相对较高,因此出现在图表的右侧。现在理想情况下,因为这两个新添加的观测值已经处于我们预测类别 1 的图表区域,它们不应该对我们的决策边界产生太大的影响。由于我们正在最小化均方误差,旧的线性回归线(以实线表示)现在已经向右移动(以虚线表示)。因此,我们的新线性回归线在y轴上与 0.5 相交的点已经向右移动。因此,仅添加两个新点就明显将我们的决策边界向右移动。
逻辑回归通过提供一个输出值在区间[0,1]内且使用与线性回归完全不同的优化标准进行训练,解决了所有这些问题,因此我们不再通过最小化均方误差来拟合函数,正如我们现在将看到的。
逻辑回归简介
在逻辑回归中,输入特征与线性回归一样进行线性缩放;然而,结果随后被作为输入传递给逻辑函数。这个函数对其输入进行非线性转换,并确保输出值的范围,即解释为输入属于类别 1 的概率,位于区间[0,1]内。逻辑函数的形式如下:

这里是逻辑函数的图示:

当 x = 0 时,逻辑函数取值为 0.5。当 x 趋向于 +∞ 时,分母中的指数消失,函数趋近于值 1。当 x 趋向于 -∞ 时,指数以及分母趋向于无限大,函数趋近于值 0。因此,我们的输出保证在区间 [0,1] 内,这对于它作为一个概率是必要的。
广义线性模型
逻辑回归属于一类称为广义线性模型(GLMs)的模型。广义线性模型有三个统一的特点。第一个特点是它们都涉及输入特征的线性组合,从而解释了它们名称的一部分。第二个特点是输出被认为具有属于指数分布族的潜在概率分布。这些包括正态分布、泊松分布和二项分布。最后,输出分布的均值通过一个称为连接函数的函数与输入特征的线性组合相关联。让我们看看这一切如何与逻辑回归联系起来,逻辑回归只是许多广义线性模型(GLM)的例子之一。我们知道我们从一个输入特征的线性组合开始,例如,在只有一个输入特征的情况下,我们可以构建一个如下所示的 x 项:

注意
注意,在逻辑回归的情况下,我们是在模拟输出属于类别 1 的概率,而不是像线性回归那样直接模拟输出。因此,我们不需要模拟误差项,因为我们的输出,即概率,直接包含了模型固有的随机性。
接下来,我们将逻辑函数应用于这个项,以产生我们模型的输出:

这里,左边的项直接告诉我们,我们正在根据我们看到的输入特征 X1 的值来计算输出属于类别 1 的概率。对于逻辑回归,输出的潜在概率分布是伯努利分布。这与单次试验的二项分布相同,是在只有两种可能结果且概率恒定的实验中获得的分布,例如抛硬币。
伯努利分布的均值 μy 是(任意选择的)成功事件的概率,在本例中,为类别 1。因此,前一个方程的左侧也是我们潜在输出分布的均值。因此,将输入特征线性组合转换的函数有时被称为均值函数,我们刚刚看到这个函数是逻辑回归中的逻辑函数。
现在,为了确定逻辑回归的连接函数,我们可以进行一些简单的代数运算,以便隔离我们的输入特征线性组合。

左侧的项被称为对数几率或logit 函数,是逻辑回归的连接函数。对数中的分母是在给定数据的情况下输出为类别 0 的概率。因此,这个分数代表了类别 1 和类别 0 之间概率的比率,也称为优势比:
小贴士
逻辑回归的良好参考书籍,以及泊松回归等其他广义线性模型(GLM)的示例,是《使用 R 扩展线性模型》,作者为Julian J. Faraway,出版社为CRC Press。
逻辑回归系数的解释
观察最后一个方程的右侧,我们可以看到,我们几乎有与简单线性回归完全相同的结构,只是没有误差项。然而,左侧有 logit 函数的事实意味着我们不能像线性回归那样解释我们的回归系数。在逻辑回归中,特征Xi的单位增加会导致优势比乘以一个量,
。当一个系数βi为正时,那么我们将优势比乘以一个大于 1 的数,因此我们知道增加特征Xi将有效地增加输出被标记为类别 1 的概率。
同样,增加一个具有负系数的特征会将平衡偏向预测类别 0。最后,请注意,当我们改变输入特征的值时,这种影响是对优势比进行乘法运算,而不是对模型输出本身进行运算,正如我们所看到的,这是预测类别 1 的概率。从绝对值的角度来看,我们模型输出的变化(由于输入的变化引起)并不是恒定的,而是取决于我们输入特征当前的值。这与线性回归不同,在线性回归中,无论输入特征的值如何,回归系数始终代表输入特征单位增加时输出增加的固定量。
逻辑回归的假设
逻辑回归对输入的假设比线性回归要少。特别是,逻辑函数的非线性变换意味着我们可以模拟更复杂的输入输出关系。我们仍然有一个线性假设,但在这个情况下,它是特征和对数几率之间的。我们不再需要残差的正态性假设,也不需要同方差性假设。另一方面,我们的误差项仍然需要是独立的。严格来说,特征本身不再需要是独立的,但在实践中,如果特征表现出高度的多重共线性,我们的模型仍然会面临问题。最后,我们注意到,就像未正则化的线性回归一样,特征缩放不会影响逻辑回归模型。这意味着对特定输入特征进行中心化和缩放将简单地导致输出模型中的调整系数,而不会对模型性能产生任何影响。实际上,对于逻辑回归来说,这是由于一个称为最大似然不变性的性质所导致的。最大似然是选择系数的方法,将在下一节中讨论。然而,需要注意的是,如果特征处于非常不同的尺度上,对特征进行中心化和缩放可能仍然是一个好主意。这是在训练过程中帮助优化过程。简而言之,我们只有在遇到模型收敛问题时才应该转向特征缩放。
最大似然估计
当我们学习线性回归时,我们通过最小化平方误差项的总和来找到我们的系数。对于逻辑回归,我们通过最大化数据的似然性来实现这一点。一个观察值的似然性是在特定模型下看到该观察值的概率。
在我们的情况下,看到类别 1 的观察值X的似然性简单地由概率P(Y=1|X)给出,其形式在本章前面已经给出。因为我们只有两个类别,所以看到类别 0 的观察值的似然性由1 - P(Y=1|X)给出。看到我们整个观察值数据集的总体似然性是所有单个数据点的似然性的乘积,因为我们认为我们的观察值是独立获得的。由于每个观察值的似然性由回归系数βi参数化,因此我们整个数据集的似然函数也由这些系数参数化。我们可以将我们的似然函数表示为一个方程,如下所示:

现在,这个方程简单地计算了一个具有特定回归系数的逻辑回归模型可能生成我们的训练数据的概率。我们的想法是选择我们的回归系数,使得这个似然函数最大化。我们可以看到,似然函数的形式是两个大乘积的乘积,来自两个大的π符号。第一个乘积包含我们所有类别 1 观测值的似然,第二个乘积包含我们所有类别 0 观测值的似然。我们通常指的是数据的对数似然,它是通过对似然函数取对数来计算的。利用乘积的每个项的对数之和等于对数乘积的事实,我们可以写出:

我们可以使用一个经典的技巧进一步简化,形成一个单独的求和:

为了看到这是为什么,请注意,对于实际输出变量y的值为 1 的观测值,求和中的右侧项为零,所以我们实际上只剩下前一个方程中的第一个求和。同样,当y的实际值为 0 时,我们剩下前一个方程中的第二个求和。理解对数似然的形式很重要,当我们开始使用 R 训练逻辑回归模型时,我们将对此进行一些练习。请注意,最大化似然等价于最大化对数似然;两种方法都将产生相同的参数。
最大似然估计是参数拟合的基本技术,我们将在本书的其他模型中遇到它。尽管它很受欢迎,但应该注意的是,最大似然并不是万能的。确实存在可以构建模型的替代训练标准,并且有一些众所周知的情况,这种方法不会导致一个好的模型,正如我们在后续章节中将要看到的。最后,请注意,实际优化过程的细节,即找到回归系数的最大似然值,超出了本书的范围,通常我们可以依赖 R 为我们实现这一点。
预测心脏病
我们将使用 UCI 机器学习仓库的真实世界数据集来测试二分类任务的逻辑回归。这次,我们将使用Statlog (Heart)数据集,为了简便起见,我们将称之为心脏数据集。该数据集可以从 UCI 机器学习仓库的网站archive.ics.uci.edu/ml/datasets/Statlog+%28Heart%29下载。数据包含 270 个潜在心脏问题的患者观察结果。其中,120 名患者被证实有心脏病,因此两个类别的分割相当均匀。任务是预测患者是否有心脏病,基于他们的个人资料和一系列医疗测试。首先,我们将数据加载到数据框中,并根据网站重命名列:
> heart <- read.table("heart.dat", quote = "\"")
> names(heart) <- c("AGE", "SEX", "CHESTPAIN", "RESTBP", "CHOL", "SUGAR", "ECG", "MAXHR", "ANGINA", "DEP", "EXERCISE", "FLUOR", "THAL", "OUTPUT")
以下表格包含了我们输入特征的定义和输出:
| 列名 | 类型 | 定义 |
|---|---|---|
AGE |
数值 | 年龄(年) |
SEX |
二元 | 性别 |
CHESTPAIN |
分类 | 4 种值的胸痛类型 |
RESTBP |
数值 | 休息血压(每分钟跳动次数) |
CHOL |
数值 | 血清胆固醇(mg/dl) |
SUGAR |
二元 | 空腹血糖水平是否大于 120 mg/dl? |
ECG |
分类 | 3 种值的静息心电图结果 |
MAXHR |
数值 | 达到的最大心率(每分钟跳动次数) |
ANGINA |
二元 | 是否由运动引起心绞痛? |
DEP |
数值 | 相对于休息时由运动引起的 ST 段压低 |
EXERCISE |
有序分类 | 运动峰值 ST 段斜率 |
FLUOR |
数值 | 通过荧光透视术着色的主要血管数量 |
THAL |
分类 | 3 种值的 Thal |
OUTPUT |
二元 | 是否存在心脏病 |
在我们为这些数据训练逻辑回归模型之前,有一些预处理步骤我们应该执行。当处理数值数据时,一个常见的陷阱是没有注意到当一个特征实际上是分类变量而不是数值变量时,当级别被编码为数字时。在心脏数据集中,我们有四个这样的特征。CHESTPAIN、THAL和ECG特征都是分类特征。EXERCISE变量,尽管是有序分类变量,但仍然是一个分类变量,因此它也必须被编码为因子:
> heart$CHESTPAIN = factor(heart$CHESTPAIN)
> heart$ECG = factor(heart$ECG)
> heart$THAL = factor(heart$THAL)
> heart$EXERCISE = factor(heart$EXERCISE)
在第一章《准备预测建模》中,我们看到了如何将具有多个级别的分类特征转换为一组二元值指示变量。通过这样做,我们可以使用它们在模型中,如线性或逻辑回归,该模型要求所有输入都是数值。只要数据框中的相关分类变量已被编码为因子,R 在执行逻辑回归时会自动应用编码方案。具体来说,R 会将其中一个 k 个因子级别作为参考级别,并从其他因子级别创建 k-1 个二元特征。当我们研究我们将要训练的逻辑回归模型的摘要输出时,我们将看到这一点的视觉证据。
接下来,我们应该注意到OUTPUT变量被编码,使得类别 1 对应于没有心脏病,类别 2 对应于存在心脏病。作为最后的改变,我们希望重新编码OUTPUT变量,以便我们将有熟悉的类别标签 0 和 1,分别。这可以通过简单地减去1来完成:
> heart$OUTPUT = heart$OUTPUT - 1
我们的数据框现在已准备就绪。然而,在我们训练模型之前,我们将数据框分为两部分,用于训练和测试,正如我们在线性回归中所做的那样。再次,我们将使用 85-15 的分割:
> library(caret)
> set.seed(987954)
> heart_sampling_vector <-
createDataPartition(heart$OUTPUT, p = 0.85, list = FALSE)
> heart_train <- heart[heart_sampling_vector,]
> heart_train_labels <- heart$OUTPUT[heart_sampling_vector]
> heart_test <- heart[-heart_sampling_vector,]
> heart_test_labels <- heart$OUTPUT[-heart_sampling_vector]
我们现在在训练集中有 230 个观测值,在测试集中有 40 个观测值。要在 R 中训练逻辑回归模型,我们使用glm()函数,代表广义线性模型。此函数可用于训练各种广义线性模型,但在这里我们将关注逻辑回归的语法和用法。调用如下:
> heart_model <-
glm(OUTPUT ~ ., data = heart_train, family = binomial("logit"))
注意,格式与我们之前看到的线性回归非常相似。第一个参数是模型公式,它标识输出变量以及我们想要使用的特征(在这种情况下,所有特征)。第二个参数是数据框,最后的family参数用于指定我们想要执行逻辑回归。我们可以使用summary()函数来了解更多关于我们刚刚训练的模型的信息,如下所示:
> summary(heart_model)
Call:
glm(formula = OUTPUT ~ ., family = binomial("logit"), data = heart_train)
Deviance Residuals:
Min 1Q Median 3Q Max
-2.7137 -0.4421 -0.1382 0.3588 2.8118
Coefficients:
Estimate Std. Error z value Pr(>|z|)
(Intercept) -7.946051 3.477686 -2.285 0.022321 *
AGE -0.020538 0.029580 -0.694 0.487482
SEX 1.641327 0.656291 2.501 0.012387 *
CHESTPAIN2 1.308530 1.000913 1.307 0.191098
CHESTPAIN3 0.560233 0.865114 0.648 0.517255
CHESTPAIN4 2.356442 0.820521 2.872 0.004080 **
RESTBP 0.026588 0.013357 1.991 0.046529 *
CHOL 0.008105 0.004790 1.692 0.090593 .
SUGAR -1.263606 0.732414 -1.725 0.084480 .
ECG1 1.352751 3.287293 0.412 0.680699
ECG2 0.563430 0.461872 1.220 0.222509
MAXHR -0.013585 0.012873 -1.055 0.291283
ANGINA 0.999906 0.525996 1.901 0.057305 .
DEP 0.196349 0.282891 0.694 0.487632
EXERCISE2 0.743530 0.560700 1.326 0.184815
EXERCISE3 0.946718 1.165567 0.812 0.416655
FLUOR 1.310240 0.308348 4.249 2.15e-05 ***
THAL6 0.304117 0.995464 0.306 0.759983
THAL7 1.717886 0.510986 3.362 0.000774 ***
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
(Dispersion parameter for binomial family taken to be 1)
Null deviance: 315.90 on 229 degrees of freedom
Residual deviance: 140.36 on 211 degrees of freedom
AIC: 178.36
Number of Fisher Scoring iterations: 6
评估逻辑回归模型
使用glm()函数生成的逻辑回归模型摘要的格式与使用lm()函数生成的线性回归模型摘要的格式相似。这表明,对于我们的分类变量,我们比原始变量的级别数少一个二进制特征,例如,三值THAL输入特征生成了两个标记为THAL6和THAL7的二进制变量。我们将首先查看模型预测的回归系数。这些系数与它们的对应z 统计量一起呈现。这与我们在线性回归中看到的 t 统计量类似,再次强调,z 统计量的绝对值越高,这个特定特征与我们的输出变量显著相关的可能性就越大。z 统计量旁边的 p 值以概率的形式表达这一概念,并用星号和点标注,就像在线性回归中一样,表示包含相应 p 值的最小置信区间。
由于逻辑回归模型是用最大似然准则训练的,我们使用标准正态分布对我们系数进行显著性测试。例如,为了重现对应于列出的 z 值为 3.362 的THAL7特征的 p 值(当测试负系数时,将lower.tail参数设置为T):
> pnorm(3.362 , lower.tail = F) * 2
[1] 0.0007738012
注意
学习统计学中分布的基本概念的绝佳参考书籍是《All of Statistics》,作者 Larry Wasserman,Springer 出版社。
从模型摘要中,我们看到FLUOR、CHESTPAIN4和THAL7是心脏病最强的特征预测因子。许多输入特征具有相对较高的 p 值。这表明,在其他特征存在的情况下,它们可能不是心脏病的好指标。我们再次强调正确解释这个表的重要性。该表并没有说心脏年龄,例如,不是心脏病的好指标;相反,它表示,在其他输入特征存在的情况下,年龄实际上并没有给模型增加多少。此外,请注意,我们几乎肯定在我们的特征中存在一定程度的多重共线性,因为年龄的回归系数是负的,而我们会预期心脏病的发生概率随着年龄的增长而增加。当然,这个假设只在所有其他输入特征不存在的情况下才是有效的。事实上,如果我们只使用AGE变量重新训练逻辑回归模型,我们也会得到一个正的回归系数以及一个低的 p 值,这两个结果都支持我们的信念,即特征是共线的:
> heart_model2 <- glm(OUTPUT ~ AGE, data = heart_train, family = binomial("logit"))
> summary(heart_model2)
Call:
glm(formula = OUTPUT ~ AGE, family = binomial("logit"), data = heart_train)
Deviance Residuals:
Min 1Q Median 3Q Max
-1.5027 -1.0691 -0.8435 1.2061 1.6759
Coefficients:
Estimate Std. Error z value Pr(>|z|)
(Intercept) -2.71136 0.86348 -3.140 0.00169 **
AGE 0.04539 0.01552 2.925 0.00344 **
---
Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
(Dispersion parameter for binomial family taken to be 1)
Null deviance: 315.90 on 229 degrees of freedom
Residual deviance: 306.89 on 228 degrees of freedom
AIC: 310.89
Number of Fisher Scoring iterations: 4
注意,这个更简单模型的 AIC 值高于我们使用完整模型获得的结果,因此我们预计这个简单模型会更差。
模型偏差
为了理解模型总结的其余部分,我们需要引入一个称为偏差的重要概念。在线性回归中,我们的残差被定义为预测值与实际输出值之间的差异,这是我们试图预测的输出。逻辑回归使用最大似然进行训练,因此可以自然地预期,与残差类似的概念将涉及似然。偏差的概念有几个紧密相关的定义。在这里,我们将使用 glm() 函数使用的定义来解释模型的输出。观察值的偏差可以计算为该观察值的 -2 倍对数似然。数据集的偏差仅仅是所有观察值偏差的总和。
观察值的偏差残差是从偏差本身派生出来的,类似于线性回归的残差。它可以按以下方式计算:

对于观察值 i,dr[i] 代表偏差残差,而 di 代表偏差。请注意,偏差残差的平方实际上消除了符号函数,只产生了观察值的偏差。因此,平方偏差残差的和就是数据集的偏差,这仅仅是数据集对数似然值的常数 -2 倍。因此,最大化数据的对数似然与最小化平方偏差残差之和是相同的,所以我们的线性回归类比就完整了。
为了重现模型总结中显示的结果,并理解偏差是如何计算的,我们将使用 R 编写一些自己的函数。我们将从使用本章前面提到的对数似然方程计算数据集的对数似然值开始。从方程中,我们将创建两个函数。log_likelihoods() 函数计算数据集中所有观察值的对数似然向量,给定模型预测的概率和实际的目标标签,而 dataset_log_likelihood() 函数将这些值加起来以产生数据集的对数似然:
log_likelihoods <- function(y_labels, y_probs) {
y_a <- as.numeric(y_labels)
y_p <- as.numeric(y_probs)
y_a * log(y_p) + (1 - y_a) * log(1 - y_p)
}
dataset_log_likelihood <- function(y_labels, y_probs) {
sum(log_likelihoods(y_labels, y_probs))
}
接下来,我们可以使用偏差的定义来计算两个类似函数:deviances() 和 dataset_deviance()。第一个函数计算观察值偏差的向量,第二个函数将这些值加起来以计算整个数据集的偏差:
deviances <- function(y_labels, y_probs) {
-2 * log_likelihoods(y_labels, y_probs)
}
dataset_deviance <- function(y_labels, y_probs) {
sum(deviances(y_labels, y_probs))
}
给定这些函数,我们现在可以创建一个计算模型偏差的函数。为此,我们需要使用 predict() 函数来计算训练数据中观察值的模型概率预测。这与线性回归类似,但默认情况下它返回对数尺度上的概率。为了确保我们得到实际的概率,我们需要指定 type 参数的 response 值:
model_deviance <- function(model, data, output_column) {
y_labels = data[[output_column]]
y_probs = predict(model, newdata = data, type = "response")
dataset_deviance(y_labels, y_probs)
}
为了检查我们的函数是否正常工作,让我们计算我们的心脏模型中模型偏差,也称为残差偏差:
> model_deviance(heart_model, data = heart_train, output_column =
"OUTPUT")
[1] 140.3561
令人欣慰的是,这个值与我们在模型摘要中列出的相同。评估逻辑回归模型的一种方法是通过计算模型偏差与无特征模型偏差之间的差异,后者是在没有任何特征的情况下训练的模型。无特征模型的偏差被称为零偏差。由于没有特征,零模型通过一个恒定的概率预测类别 1。这个概率是通过估计训练数据中类别 1 观察值的比例来估计的,我们可以通过简单地平均OUTPUT列来获得这个比例:
null_deviance <- function(data, output_column) {
y_labels <- data[[output_column]]
y_probs <- mean(data[[output_column]])
dataset_deviance(y_labels, y_probs)
}
> null_deviance(data = heart_training, output_column = "OUTPUT")
[1] 314.3811
再次强调,我们看到我们重现了 R 在模型摘要中为我们计算出的值。残差偏差和零偏差类似于我们在线性回归中看到的残差平方和(RSS)和真实平方和(TSS)。如果这两个值之间的差异很大,其解释与线性回归中残差平方和的概念类似,即通过输出变量的观察值来“解释”方差。
继续这个类比,我们可以为我们的模型定义一个伪 R2值,使用与计算线性回归 R2 相同的方程,但用偏差来替换。我们在 R 中这样实现:
model_pseudo_r_squared <- function(model, data, output_column) {
1 - ( model_deviance(model, data, output_column) /
null_deviance(data, output_column) )
}
> model_pseudo_r_squared(heart_model, data = heart_train,
output_column = "OUTPUT")
[1] 0.5556977
我们的逻辑回归模型解释了大约 56%的零偏差。这并不特别高;很可能是我们没有足够丰富的特征集来使用逻辑模型进行准确预测。与线性回归不同,伪 R2 可以超过 1,但这只发生在残差偏差超过零偏差的困难情况下。如果发生这种情况,我们不应相信模型,并继续使用特征选择方法,或者尝试其他模型。
除了伪 R2 之外,我们可能还想有一个统计检验来检查零偏差和残差偏差之间的差异是否显著。模型摘要中残差偏差旁边没有 p 值表示,R 没有创建任何测试。实际上,残差偏差和零偏差之间的差异大约是渐近地以χ2(发音为CHI squared)分布分布的。我们将定义一个函数来计算这个差异的 p 值,但这只是一个近似。
首先,我们需要零偏差和残差偏差之间的差异。我们还需要这个差异的自由度,这可以通过简单地从我们的模型自由度中减去零模型自由度来计算。零模型只有一个截距,所以自由度是数据集中观察值的总数减 1。对于残差偏差,我们正在计算包括截距在内的多个回归系数,因此我们需要从这个总数中减去这个数字。最后,我们使用 pchisq() 函数来获得 p-value,注意我们正在进行一个上尾计算,因此需要将 lower.tail 参数设置为 FALSE。代码如下:
model_chi_squared_p_value <- function(model, data, output_column) {
null_df <- nrow(data) - 1
model_df <- nrow(data) - length(model$coefficients)
difference_df <- null_df - model_df
null_deviance <- null_deviance(data, output_column)
m_deviance <- model_deviance(model, data, output_column)
difference_deviance <- null_deviance - m_deviance
pchisq(difference_deviance, difference_df,lower.tail = F)
}
> model_chi_squared_p_value(heart_model, data = heart_train,
output_column = "OUTPUT")
[1] 7.294219e-28
我们获得的 p-value 非常小,所以我们确信我们的模型产生的预测比平均猜测更好。在我们的原始模型摘要中,我们还看到了偏差残差的摘要。使用我们之前给出的偏差残差定义,我们将定义一个函数来计算偏差残差的向量:
model_deviance_residuals <- function(model, data, output_column) {
y_labels = data[[output_column]]
y_probs = predict(model, newdata = data, type = "response")
residual_sign = sign(y_labels - y_probs)
residuals = sqrt(deviances(y_labels, y_probs))
residual_sign * residuals
}
最后,我们可以使用 summary() 函数对我们的 model_deviance_residuals() 函数获得的偏差残差进行总结,以获得一个表格:
> summary(model_deviance_residuals(heart_model, data =
heart_train, output_column = "OUTPUT"))
Min. 1st Qu. Median Mean 3rd Qu. Max.
-2.71400 -0.44210 -0.13820 -0.02765 0.35880 2.81200
再次验证,我们可以确认我们得到了正确的结果。我们的模型摘要还提供了一项最后的诊断:Fisher 分数迭代次数,我们尚未讨论。这个数字通常在 4 到 8 之间,是一个收敛诊断。如果 R 用于训练逻辑模型的优化过程没有收敛,我们预计会看到一个较高的数字。如果发生这种情况,我们的模型可能是可疑的,我们可能无法用它来做出预测。在我们的情况下,我们处于预期的范围内。
测试集性能
我们已经看到如何使用 predict() 函数来计算我们模型的输出。这个输出是输入属于类别 1 的概率。我们可以通过应用阈值来进行二元分类。我们将对训练数据和测试数据进行此操作,并将它们与我们的预期输出进行比较,以衡量分类准确率:
> train_predictions <- predict(heart_model, newdata = heart_train,
type = "response")
> train_class_predictions <- as.numeric(train_predictions > 0.5)
> mean(train_class_predictions == heart_train$OUTPUT)
[1] 0.8869565
> test_predictions = predict(heart_model, newdata = heart_test,
type = "response")
> test_class_predictions = as.numeric(test_predictions > 0.5)
> mean(test_class_predictions == heart_test$OUTPUT)
[1] 0.9
训练集和测试集上的分类准确率非常相似,接近 90 %。这对于模型构建者来说是一个非常好的起点。我们模型中的系数表显示,一些特征似乎并不显著,我们还发现了一定程度的共线性,这意味着我们现在可以继续进行变量选择,并可能通过计算或获取更多关于我们患者的数据来寻找更多特征。伪 R2 的计算显示,我们没有充分解释模型中的偏差,这也支持了这一点。
Lasso 正则化
在上一章关于线性回归的章节中,我们使用了glmnet包来进行岭回归和 lasso 的正则化。正如我们所见,移除一些特征可能是个不错的主意,因此我们将尝试将 lasso 应用于我们的数据集并评估结果。首先,我们将使用glmnet()训练一系列正则化模型,然后我们将使用cv.glmnet()来估计一个合适的λ值。然后,我们将使用这个λ来检查我们正则化模型的系数:
> library(glmnet)
> heart_train_mat <- model.matrix(OUTPUT ~ ., heart_train)[,-1]
> lambdas <- 10 ^ seq(8, -4, length = 250)
> heart_models_lasso <- glmnet(heart_train_mat,
heart_train$OUTPUT, alpha = 1, lambda = lambdas, family = "binomial")
> lasso.cv <- cv.glmnet(heart_train_mat, heart_train$OUTPUT, alpha = 1,lambda = lambdas, family = "binomial")
> lambda_lasso <- lasso.cv$lambda.min
> lambda_lasso
[1] 0.01057052
> predict(heart_models_lasso, type = "coefficients", s = lambda_lasso)
19 x 1 sparse Matrix of class "dgCMatrix"
1
(Intercept) -4.980249537
AGE .
SEX 1.029146139
CHESTPAIN2 0.122044733
CHESTPAIN3 .
CHESTPAIN4 1.521164330
RESTBP 0.013456000
CHOL 0.004190012
SUGAR -0.587616822
ECG1 .
ECG2 0.338365613
MAXHR -0.010651758
ANGINA 0.807497991
DEP 0.211899820
EXERCISE2 0.351797531
EXERCISE3 0.081846313
FLUOR 0.947928099
THAL6 0.083440880
THAL7 1.501844677
我们可以看到,我们的一些特征已经有效地从模型中移除,因为它们的系数为零。如果我们现在使用这个模型来衡量训练集和测试集上的分类准确率,我们会观察到在两种情况下,我们得到了略微更好的性能。即使这个差异很小,记住我们是通过使用三个更少的特征来达到这个效果的:
> lasso_train_predictions <- predict(heart_models_lasso, s = lambda_lasso, newx = heart_train_mat, type = "response")
> lasso_train_class_predictions <-
as.numeric(lasso_train_predictions > 0.5)
> mean(lasso_train_class_predictions == heart_train$OUTPUT)
[1] 0.8913043
> heart_test_mat <- model.matrix(OUTPUT ~ ., heart_test)[,-1]
> lasso_test_predictions <- predict(heart_models_lasso, s = lambda_lasso, newx = heart_test_mat, type = "response")
> lasso_test_class_predictions <-
as.numeric(lasso_test_predictions > 0.5)
> mean(lasso_test_class_predictions == heart_test$OUTPUT)
[1] 0.925
分类指标
虽然我们检查了模型的测试集准确率,但我们从第一章,“准备预测建模”中知道,二元混淆矩阵可以用来计算我们数据集的许多其他有用的性能指标,例如精确率、召回率和F度量。
我们现在将为我们的训练集计算这些值:
> (confusion_matrix <- table(predicted = train_class_predictions, actual = heart_train$OUTPUT))
actual
predicted 0 1
0 118 16
1 10 86
> (precision <- confusion_matrix[2, 2] / sum(confusion_matrix[2,]))
[1] 0.8958333
> (recall <- confusion_matrix[2, 2] / sum(confusion_matrix[,2]))
[1] 0.8431373
> (f = 2 * precision * recall / (precision + recall))
[1] 0.8686869
在这里,我们使用了将赋值语句括起来的技巧,同时将表达式的结果赋给一个变量并打印出所赋的值。现在,召回率是正确识别的类别 1 实例与属于类别 1 的总观察值的比率。在我们这样的医疗背景下,这也被称为灵敏度,因为它是一个衡量模型检测或对特定条件敏感的有效指标。召回率也被称为真正率。有一个类似的度量称为特异性,它是假阴性率。这涉及到对类别 0 的召回率的镜像计算,即正确识别的类别 0 成员与我们的数据集中类别 0 的所有观察值的比率。在我们这样的医疗背景下,例如,特异性的解释是它衡量模型拒绝不具有类别 1 所代表条件(在我们的案例中,心脏病)的观察的能力。我们可以如下计算我们模型的特异性:
> (specificity <- confusion_matrix[1,1]/sum(confusion_matrix[1,]))
[1] 0.880597
在计算这些度量时,我们开始看到将阈值设置为0.5的重要性。如果我们选择不同的阈值,很明显,所有先前的度量都会改变。特别是,有许多情况,我们当前的医疗环境就是一个很好的例子,我们可能希望调整我们的阈值,使其偏向于识别类别 1 的成员。例如,假设我们的模型被临床医生用来确定是否让患者进行更详细和昂贵的疾病检查。我们可能会认为将患有心脏病的患者误标为健康是一个比要求健康患者进行进一步测试以被视为不健康更严重的错误。为了实现这种偏差,我们可以将我们的分类阈值降低到0.3或0.2,例如。
理想情况下,我们希望有一种视觉方式来评估改变阈值对我们性能指标的影响,而精确度召回率曲线就是这样一种有用的图表。在 R 中,我们可以使用ROCR包来获取精确度召回率曲线:
> library(ROCR)
> train_predictions <- predict(heart_model, newdata = heart_train, type = "response")
> pred <- prediction(train_predictions, heart_training$OUTPUT)
> perf <- performance(pred, measure = "prec", x.measure = "rec")
然后,我们可以绘制perf对象以获得我们的精确度召回率曲线。

图表显示,例如,为了获得超过 0.8 的召回率值,我们可能不得不相当突然地牺牲精确度。为了微调我们的阈值,我们希望看到用于计算此图表的个别阈值。一个有用的练习是创建一个包含截止值的 DataFrame,这些截止值是我们数据中精确度和召回率发生变化的阈值,以及它们对应的精确度和召回率值。然后我们可以从这个 DataFrame 中提取出我们感兴趣的个别阈值。
例如,假设我们想要找到一个合适的阈值,以便至少有 90%的召回率和 80%的精确度。我们可以这样做:
> thresholds <- data.frame(cutoffs = perf@alpha.values[[1]], recall = perf@x.values[[1]], precision = perf@y.values[[1]])
> subset(thresholds,(recall > 0.9) & (precision > 0.8))
cutoffs recall precision
112 0.3491857 0.9019608 0.8288288
113 0.3472740 0.9019608 0.8214286
114 0.3428354 0.9019608 0.8141593
115 0.3421438 0.9019608 0.8070175
如我们所见,大约 0.35 的阈值将满足我们的要求。
小贴士
你可能已经注意到,我们使用了@符号来访问perf对象的一些属性。这是因为这个对象是一种特殊类型的对象,称为 S4 类。S4 类用于在 R 中提供面向对象的功能。关于 S4 类以及 R 中更广泛的面向对象编程的参考资料是Advanced R,Hadley Wickham,Chapman and Hall。
二元逻辑分类器的扩展
到目前为止,本章的重点一直集中在二元分类任务上,其中我们有两个类别。现在,我们将转向多类别预测问题。在第一章“准备预测建模”中,我们研究了鸢尾花数据集,其目标是根据描述鸢尾花样本外部形态的特征来区分三种不同的鸢尾花种类。在介绍更多多类别问题的例子之前,我们将提出一个重要的警告。这个警告是,我们将在本书中研究的几种其他分类方法,如神经网络和决策树,在涉及两个以上类别的分类问题中,比逻辑回归更自然且更常用。考虑到这一点,我们将转向多项式逻辑回归,这是二元逻辑分类器的第一次扩展。
多项式逻辑回归
假设我们的目标变量包含K个类别。例如,在鸢尾花数据集中,K = 3。多项式逻辑回归通过拟合K-1个独立的二元逻辑回归分类模型来解决多类别问题。这是通过任意选择一个输出类别作为参考类别,并拟合K-1个回归模型来完成的,这些模型将每个剩余类别与这个类别进行比较。例如,如果我们有两个特征,X1和X2,以及三个类别,我们可以称之为 0、1 和 2,我们将构建以下两个模型:

在这里,我们将类别 0 作为基线,并建立了两个二元回归模型。在第一个模型中,我们比较了类别 1 与类别 0,在第二个模型中,我们比较了类别 2 与类别 0。请注意,因为我们现在有多个二元回归模型,我们的模型系数有两个下标。第一个下标标识模型,第二个下标将系数与特征配对。例如,β[12]是第一个模型中特征X[2]的系数。我们可以为当总共有K个类别(编号从0到K-1,类别 0 被选为参考类别)时,我们的组合模型预测类别k的概率写一个通用表达式:

读者应验证所有输出类概率之和为 1,这是必需的。这种指数除以指数之和的特定数学形式被称为softmax函数。对于之前讨论的三个类别问题,我们只需在前面方程中将K=3代入即可。在此阶段,我们应该提到这种方法的一些重要特性。
首先,我们训练的模型数量比输出变量中类别的总数少一个,因此,当从大量可能的输出类别中进行选择时,这种方法扩展性并不好。我们构建和训练这么多模型的事实也意味着我们往往需要一个更大的数据集才能产生具有合理准确性的结果。最后,当我们独立地将每个输出类别与一个参考类别进行比较时,我们做出一个假设,称为无关备选方案的独立性(IIA)假设。
简而言之,IIA 假设指出,预测一个特定输出类别相对于另一个类别的概率,并不依赖于我们是否通过添加新类别来增加可能的输出类别数量k。为了说明这一点,假设为了简化,我们使用多项逻辑回归来模拟我们的鸢尾花数据集,输出类别的概率为 0.33 : 0.33 : 0.33,对于三种不同的物种,每种物种与其他物种的比例为 1 : 1。IIA 假设指出,如果我们重新拟合一个包含新类型鸢尾花样本(例如,日本鸢尾花 ensata)的模型,那么前三个鸢尾花物种之间的概率比将保持不变。四个物种之间新的整体概率比为 0.2 : 0.2 : 0.2 : 0.4(其中 0.4 对应 ensata)将是有效的,例如,因为旧的三种物种之间的 1 : 1 比例得到了保持。
预测玻璃类型
在本节中,我们将通过一个示例数据集来展示如何在 R 中训练多项逻辑回归模型。我们将检查的数据来自法医学领域。在这里,我们的目标是检查犯罪现场发现的玻璃碎片特性,并预测这些碎片的来源,例如,前灯。玻璃识别数据集由 UCI 机器学习存储库托管,网址为archive.ics.uci.edu/ml/datasets/Glass+Identification。我们首先将数据加载到数据框中,使用网站上的信息重命名列,并丢弃第一列(每个样本的唯一标识符),因为这已被任意分配,并且对我们模型来说不是必需的:
> glass <- read.csv("glass.data", header = FALSE)
> names(glass) <- c("id","RI","Na", "Mg", "Al", "Si", "K", "Ca",
"Ba", "Fe", "Type")
> glass <- glass[,-1]
接下来,我们将查看一个表格,显示我们的数据框中每一列代表的内容:
| 列名 | 类型 | 定义 |
|---|---|---|
RI |
数值 | 折射率 |
Na |
数值 | 按重量计算的氧化钠百分比 |
Mg |
数值 | 按重量计算的氧化镁百分比 |
Al |
数值 | 按重量计算的氧化铝百分比 |
Si |
数值 | 按重量计算的氧化硅百分比 |
K |
数值 | 按重量计算的氧化钾百分比 |
Ca |
数值 | 按重量计算的氧化钙百分比 |
Ba |
数值 | 按重量计算的氧化钡百分比 |
Fe |
数值 | 按重量计算的氧化铁百分比 |
Type |
分类 | 玻璃类型(1:浮法加工建筑窗户,2:非浮法加工建筑窗户,3:浮法加工车辆窗户,4:非浮法加工车辆窗户,5:容器,6:餐具,7:车灯) |
如往常一样,我们将为玻璃数据准备训练集和测试集:
> set.seed(4365677)
> glass_sampling_vector
<- createDataPartition(glass$Type, p = 0.80, list = FALSE)
> glass_train <- glass[glass_sampling_vector,]
> glass_test <- glass[-glass_sampling_vector,]
现在,为了执行多项逻辑回归,我们将使用nnet包。此包还包含与神经网络一起工作的函数,因此我们将在下一章再次讨论这个包。multinom()函数用于多项逻辑回归。它通过指定一个公式和一个数据框来实现,因此它有一个熟悉的界面。此外,我们还可以指定maxit参数,该参数确定底层优化过程将运行的最大迭代次数。有时,我们可能会发现训练模型返回一个错误,表明没有达到收敛。在这种情况下,一种可能的方法是增加这个参数,并允许模型在更多的迭代中进行训练。然而,在这样做的时候,我们应该意识到模型可能需要更长的时间来训练:
> library(nnet)
> glass_model <- multinom(Type ~ ., data = glass_train, maxit = 1000)
> summary(glass_model)
Call:
multinom(formula = Type ~ ., data = glass_train, maxit = 1000)
Coefficients:
(Intercept) RI Na Mg Al
2 52.259841 229.29126 -3.3704788 -5.975435 0.07372541
3 596.591193 -237.75997 -1.2230210 -2.435149 -0.65752347
5 -1.107583 -22.94764 -0.7434635 -4.244450 8.39355868
6 -7.493074 -11.83462 11.7893062 -6.383788 35.54561277
7 -55.888124 442.23590 -2.5269178 -10.479849 1.35983136
Si K Ca Ba Fe
2 -4.0428142 -3.4934439 -4.6096363 -6.319183 3.2295218
3 -2.6703131 -4.1221815 -1.7952780 -3.910554 0.2818498
5 0.6992306 -0.2149109 -0.8790202 -4.642283 4.3379314
6 -2.2672275 -138.1047925 0.9011624 -161.700857 -200.9598019
7 -6.5363409 -7.5444163 -8.5710078 -4.087614 -67.9907347
Std. Errors:
(Intercept) RI Na Mg Al Si
2 0.03462075 0.08068713 0.5475710 0.7429120 1.282725 0.1392131
3 0.05425817 0.08750688 0.7339134 0.9173184 1.544409 0.1805758
5 0.06674926 0.11759231 1.0866157 1.4062285 2.738635 0.3225212
6 0.17049665 0.28791033 17.2280091 4.9726046 2.622643 4.3385330
7 0.06432732 0.10522206 2.2561142 1.5246356 3.244288 0.4733835
K Ca Ba Fe
2 1.98021049 0.4897356 1.473156e+00 2.45881312
3 2.35233054 0.5949799 4.222783e+00 3.45835575
5 2.78360034 0.9807043 5.471887e+00 5.52299959
6 0.02227295 7.2406622 1.656563e-08 0.01779519
7 3.25038195 1.7310334 4.381655e+00 0.28562065
Residual Deviance: 219.2651
AIC: 319.2651
我们的模型摘要显示我们拥有五组系数。这是因为我们的TYPE输出变量有六个级别,也就是说我们选择预测六种不同的玻璃来源之一。数据中没有Type取值为 4 的例子。模型还显示了标准误差,但没有显著性测试。一般来说,测试系数显著性比二元逻辑回归要复杂得多,这也是这种方法的一个弱点。我们通常需要独立测试我们训练的每个二元模型的系数显著性。
我们不会进一步探讨这个问题,而是会检查训练数据集的整体准确性,以了解整体拟合质量:
> glass_predictions <- predict(glass_model, glass_train)
> mean(glass_predictions == glass_train$Type)
[1] 0.7209302
我们的训练准确率是 72%,这并不特别高。以下是混淆矩阵:
> table(predicted = glass_predictions, actual = glass_train$Type)
actual
predicted 1 2 3 5 6 7
1 46 17 8 0 0 0
2 13 40 6 2 0 1
3 0 0 0 0 0 0
5 0 1 0 7 0 0
6 0 0 0 0 7 0
7 0 0 0 0 0 24
混淆矩阵揭示了某些有趣的事实。首先,似乎模型在区分前两个类别方面做得不好,因为许多错误都涉及这两个类别。然而,部分原因是这两个类别在数据中是最频繁的。我们看到的第二个问题是模型从未预测类别 3。事实上,它完全将这个类别与第一个两个类别混淆。类别 6 的七个例子被完美地区分开来,类别 7 的准确率也几乎是完美的,只有 25 个中的 1 个错误。总的来说,在训练数据上 72%的准确率被认为是平庸的,但考虑到我们只有六个输出类别和 172 个训练数据观测值,这种情况是可以预料的。让我们为测试数据集重复这个过程:
> glass_test_predictions <- predict(glass_model, glass_test)
> mean(glass_test_predictions == glass_test$Type)
[1] 0.6428571
> table(predicted = glass_test_predictions, actual =
glass_test$Type)
actual
predicted 1 2 3 5 6 7
1 7 2 2 0 0 0
2 4 15 1 2 0 0
3 0 0 0 0 0 0
5 0 0 0 1 0 2
6 0 0 0 0 2 0
7 0 1 0 1 0 2
如我们所见,混淆矩阵描绘了一幅与我们在训练中看到相当相似的图景。再次强调,我们的模型从未预测过类别 3,前两个类别仍然难以区分。我们的测试集观测数只有 42,这非常少。测试集准确率仅为 64%,略低于我们在训练中看到的。如果我们的样本量更大,我们可能会怀疑我们的模型存在过拟合问题,但在这个案例中,由于样本量小,我们的测试集性能的方差很高。
在多项式逻辑回归中,我们假设输出类别没有自然顺序。如果我们的输出变量是序数,也称为有序因素,我们可以训练一个不同的模型,称为有序逻辑回归。这是我们二元逻辑回归模型的第二次扩展,将在下一节中介绍。
序列逻辑回归
有序因素在许多场景中非常常见。例如,人类对调查的回答通常是在 1 到 5 分的主观量表上,或者使用具有内在顺序的定性标签,如不同意、中立和同意。我们可以尝试将这些问题作为回归问题来处理,但我们仍然会面临我们在将二元分类问题作为回归问题处理时所遇到的问题。与尝试训练K-1个二元逻辑回归模型作为多项式逻辑回归不同,有序逻辑回归通过在输出上设置多个阈值来训练一个单一模型。为了实现这一点,它做出了一个重要的假设,即比例优势假设。如果我们有K个类别,并且想在单个二元逻辑回归模型的输出上设置阈值,我们需要K-1个阈值或截止点。比例优势假设是,在 logit 尺度上,所有这些阈值都位于一条直线上。换句话说,模型使用一组单一的βi系数来确定直线的斜率,但存在K-1个截距项。对于一个具有p个特征和K个类别输出变量(编号从 0 到K-1)的模型,我们的模型预测如下:

这个假设可能有点难以可视化,也许通过一个例子来理解会更好。假设我们正在尝试根据调查参与者的人口统计数据来预测一项特定政府政策公众舆论调查的结果。
输出变量是一个有序因素,它在五点量表上从 强烈不同意 到 强烈同意 范围内变化(也称为 Likert 量表)。假设 l[0] 是强烈不同意与不同意或更好之间的概率的对数几率,l[1] 是不同意或强烈不同意与至少中立之间的概率的对数几率,依此类推,直到 l[3]。这四个对数几率 l[0] 到 l[3] 形成一个算术序列,这意味着连续数字之间的距离是一个常数。
备注
尽管比例优势模型是处理有序因素的逻辑回归模型中最常引用的模型,但还有其他方法。讨论比例优势模型以及其他相关模型,如相邻类别逻辑模型的好参考是 《应用逻辑回归第三版》,由 Hosmer Jr.,Lemeshow 和 Sturdivant 撰写,并由 Wiley 出版。
预测葡萄酒质量
我们序对数逻辑回归示例的数据集是来自 UCI 机器学习仓库 的 葡萄酒质量数据集。该数据集中的观测值包括来自葡萄牙绿酒品种的红葡萄酒和白葡萄酒的酒样。这些酒样由多位葡萄酒专家按照 1 到 10 的评分标准进行评分。数据集的目标是使用一系列的物理化学特性,如酸度和酒精成分,来预测专家对酒样的评分。网站是 archive.ics.uci.edu/ml/datasets/Wine+Quality。数据被分为两个文件,一个用于红葡萄酒,一个用于白葡萄酒。我们将使用白葡萄酒数据集,因为它包含更多的样本。此外,为了简化,并且因为按评分分配的酒样分布稀疏,我们将原始输出变量缩减为从 0 到 2 的三点量表。首先,让我们加载数据并处理:
> wine <- read.csv("winequality-white.csv", sep = ";")
> wine$quality <- factor(ifelse(wine$quality < 5, 0,
ifelse(wine$quality > 6, 2, 1)))
下表显示了我们的输入特征和输出变量:
| 列名 | 类型 | 定义 |
|---|---|---|
固定酸度 |
数值 | 固定酸度(每立方分米苹果酸克数) |
挥发酸度 |
数值 | 挥发酸度(每立方分米乙酸克数) |
柠檬酸 |
数值 | 柠檬酸(每立方分米克数) |
残糖 |
数值 | 残糖(每立方分米克数) |
氯化物 |
数值 | 氯化物(每立方分米钠氯化物的克数) |
游离二氧化硫 |
数值 | 游离二氧化硫(每立方分米毫克数) |
总二氧化硫 |
数值 | 总二氧化硫(每立方分米毫克数) |
密度 |
数值 | 密度(每立方厘米克数) |
pH |
数值 | pH 值 |
硫酸盐 |
数值 | 硫酸盐(每立方分米硫酸钾的克数) |
酒精 |
数值 | 酒精(体积百分比) |
质量 |
分类 | 葡萄酒质量(1 = 差,2 = 一般,3 = 好) |
首先,我们将准备训练集和测试集:
> set.seed(7644)
> wine_sampling_vector <- createDataPartition(wine$quality, p =
0.80, list = FALSE)
> wine_train <- wine[wine_sampling_vector,]
> wine_test <- wine[-wine_sampling_vector,]
接下来,我们将使用来自 MASS 包的 polr() 函数来训练一个比例优势逻辑回归模型。就像我们迄今为止看到的其他模型函数一样,我们首先需要指定一个公式和一个包含我们的训练数据的 data frame。此外,我们必须将 Hess 参数指定为 TRUE 以获得包含额外信息(如系数的标准误差)的模型:
> library(MASS)
> wine_model <- polr(quality ~ ., data = wine_train, Hess = T)
> summary(wine_model)
Call:
polr(formula = quality ~ ., data = wine_train, Hess = T)
Coefficients:
Value Std. Error t value
fixed.acidity 4.728e-01 0.055641 8.4975
volatile.acidity -4.211e+00 0.435288 -9.6741
citric.acid 9.896e-02 0.353466 0.2800
residual.sugar 3.386e-01 0.009835 34.4248
chlorides -2.891e+00 0.116025 -24.9162
free.sulfur.dioxide 1.176e-02 0.003234 3.6374
total.sulfur.dioxide -1.618e-04 0.001384 -0.1169
density -7.534e+02 0.625157 -1205.1041
pH 3.107e+00 0.301434 10.3087
sulphates 2.199e+00 0.338923 6.4873
alcohol 2.883e-02 0.041479 0.6951
Intercepts:
Value Std. Error t value
1|2 -736.9784 0.6341 -1162.3302
2|3 -731.4177 0.6599 -1108.4069
Residual Deviance: 4412.75
AIC: 4438.75
我们的模型摘要显示我们有三个输出类别,并且我们有两个截距。现在,在这个数据集中,我们有许多被评为平均(要么是 5 要么是 6)的葡萄酒,因此这个类别是最频繁的。我们将使用 table() 函数按输出分数计算样本数量,然后应用 prop.table() 将这些表示为相对频率:
> prop.table(table(wine$quality))
1 2 3
0.03736219 0.74622295 0.21641486
类别 2,对应于平均葡萄酒,是最频繁的。事实上,一个总是预测这个类别的简单基线模型将有 74.6% 的时间是正确的。让我们看看我们的模型是否比这做得更好。我们将从查看训练数据上的拟合和相应的混淆矩阵开始:
> wine_predictions <- predict(wine_model, wine_train)
> mean(wine_predictions == wine_train$quality)
[1] 0.7647359
> table(predicted = wine_predictions,actual = wine_train$quality)
actual
predicted 1 2 3
1 4 1 0
2 141 2764 619
3 2 159 229
我们在训练数据上的模型表现仅略好于我们的基线模型。我们可以看到这是为什么——它经常预测平均类别(2),几乎从不预测类别 1。在测试集上重复此操作揭示了类似的情况:
> wine_test_predictions <- predict(wine_model, wine_test)
> mean(wine_test_predictions == wine_test$quality)
[1] 0.7681307
> table(predicted = wine_test_predictions,
actual = wine_test$quality)
actual predicted
1 2 3
1 2 2 0
2 33 693 155
3 1 36 57
看起来我们的模型并不是这个数据集的一个特别好的选择。正如我们所知,有许多可能的原因,从选择了错误的模型类型到特征不足或特征类型不正确。我们应该始终尝试检查有序逻辑回归模型的一个方面,即比例优势假设是否有效。没有普遍接受的方法来做这件事,但文献中已经提出了许多不同的统计测试。不幸的是,在 R 中很难找到这些测试的可靠实现。然而,有一个简单的测试很容易做,那就是使用多项式逻辑回归训练第二个模型。然后,我们可以比较我们两个模型的 AIC 值。让我们这样做:
> wine_model2 <- multinom(quality ~ ., data = wine_train,
maxit = 1000)
> wine_predictions2 <- predict(wine_model2, wine_test)
> mean(wine_predictions2 == wine_test$quality)
[1] 0.7630235
> table(predicted = wine_predictions2, actual = wine_test$quality)
actual
predicted 1 2 3
1 2 2 0
2 32 682 149
3 2 47 63
这两个模型在拟合质量上几乎没有差异。让我们检查它们的 AIC 值:
> AIC(wine_model)
[1] 4438.75
> AIC(wine_model2)
[1] 4367.448
多项式逻辑回归模型的 AIC 值较低,这表明我们可能更擅长使用该模型。在这个数据集上改进的另一个可能途径是进行特征选择。例如,我们在上一章中看到的 step() 函数也可以用于使用 polr() 函数训练的模型。我们将把这个作为读者的练习,以验证我们实际上可以通过删除一些特征来获得几乎相同水平的性能。对于这个最新数据集上逻辑回归的结果不满意,我们将在后续章节中重新审视它,以看看更复杂的分类模型是否能做得更好。
泊松回归
另一种回归分析形式是泊松回归。这种分析是一种广义线性模型或 GLM,用于建模计数数据。
与前一部分中葡萄酒样品按 1 到 10 的等级评分(或排名)的例子不同,计数数据是一种(统计)数据类型,其中观测值只能取非负整数值 {0, 1, 2, 3, ...},并且这些整数是从计数而不是排名中产生的。
泊松回归假设你的分析结果具有泊松分布——即它表示:如果这些事件以已知的平均速率发生且与自上次事件以来经过的时间无关,则在固定时间间隔内发生一定数量事件的概率。
一个可能的模型可能是每小时软件支持中心接收的电话数量。接收电话数量的预测因素包括新版本(软件)发布后的天数和客户使用软件的年数(按照我们的葡萄酒例子,你可以使用泊松回归来分析一个月内销售的葡萄酒瓶数,可能以“商店位置”和“一年中的月份”作为预测因素)。
数据科学家还可能使用泊松分布来表示其他指定区间内事件的数量,例如距离、面积或体积。
负二项式回归
虽然泊松回归假设一个(已知)的平均值,但负二项式回归是通过所谓的最大似然估计来实现的。
记住,尽管泊松分布假设平均值和方差相同,但有时数据会显示出更大的变异性或大于平均值的额外变异。当这种情况发生时,负二项式回归是一个更好的选择,因为它在这方面具有更大的灵活性。
为了说明,如果我们考虑一个大学想要预测学生运动员每年可能缺席的平均天数。预测因素(缺席天数)包括学生运动员所属的体育类型和他们平均的 GPA 分数。变量体育是一个四级名义变量,表示运动员参加的体育项目(在这种情况下,它可以是“足球”、“田径”、“足球”或“排球”)。
如果我们分析我们的数据,假设我们找到以下统计数据:
Football: M (SD) = 10.65 (8.20)
Track: M (SD) = 6.93 (7.45)
Field Hockey: M (SD) = 2.67 (3.73)
Volleyball: M (SD) = 1.67 (1.73)
我们可以查看前面的统计数据,并看到体育缺席的平均天数似乎表明变量“体育”是预测缺席天数的好候选,因为结果的平均值似乎会随着所选的运动员体育项目而变化。然而,当我们查看标准差时,我们发现每种体育类型内的方差高于每个级别内的平均值。
这些是条件均值和方差。这些差异表明存在过度分散(更大的变异),并且负二项式模型可能更为合适。你仍然可以使用泊松回归,但标准误差可能会存在偏差。
负二项式回归利用了一个额外的参数(相对于泊松回归)来独立于均值(在此例中是学生运动员的 GPA 分数)调整方差。
摘要
逻辑回归是解决分类问题的典型方法,就像线性回归是解决回归问题的典型例子一样。在本章中,我们通过展示最小二乘准则不是在尝试分离两个类别时最合适的准则,证明了逻辑回归与具有阈值的线性回归相比,提供了一种更好的处理分类问题的方法。我们介绍了似然的概念及其最大化作为训练模型的基础。这是一个非常重要的概念,在各种机器学习环境中反复出现。逻辑回归是广义线性模型的一个例子。这是一个通过链接函数将输出变量与输入特征的线性组合相关联的模型,我们在此例中看到了这是 logit 函数。对于二元分类问题,我们使用 R 的glm()函数在现实世界数据集上执行逻辑回归,并研究了模型诊断以评估我们的模型性能。我们发现与线性回归有相似之处,即模型产生偏差残差,类似于最小二乘误差残差,并且我们可以计算一个类似于 R2 统计量的伪 R2 统计量,该统计量衡量线性回归中的拟合优度。
我们还看到,我们可以将正则化技术应用于逻辑回归模型。我们通过研究精确率-召回率曲线来选择合适的模型阈值,结束了使用逻辑回归模型进行二元分类的旅行,这是一个在涉及的两个类别中,误分类观察值的成本不对称时非常重要的练习。然后,我们研究了两种可能的二元逻辑回归模型的扩展,以处理具有许多类别标签的输出。这些是多项式逻辑回归模型和有序逻辑回归模型,当输出类别有序时可能很有用。最后,我们简要提到了泊松回归的使用,以及对于具有更大变异性的模型,负二项式回归的使用。
结果表明,逻辑回归在一般情况下并不是解决多类设置的最佳选择。在下一章中,我们将介绍神经网络,这是一种非线性模型,用于解决回归和分类问题。我们还将看到神经网络如何以自然的方式处理多个类别标签。
第五章。神经网络
到目前为止,我们已经探讨了用于预测建模的两种最知名的方法。线性回归可能是预测数值量的目标问题的最典型起点。该模型基于输入特征的线性组合。逻辑回归使用非线性变换来限制线性特征组合的输出范围在[0,1]区间内。通过这种方式,它预测输出属于两个类别中的一个的概率。因此,它是一种非常著名的分类技术。
这两种方法都存在一个缺点,那就是在处理许多输入特征时不够稳健。此外,逻辑回归通常用于二元分类问题。在本章中,我们将介绍神经网络的概念,这是一种解决回归和分类问题的非线性方法。它们在处理高维输入特征空间时显著更加稳健,并且在分类方面,它们拥有一种自然的方式来处理超过两个输出类别。
神经网络是一种生物启发模型,其起源可以追溯到 20 世纪 40 年代。多年来,对神经网络的研究兴趣波动很大,因为最初模型与当时的期望相比相当有限。此外,训练大型神经网络需要大量的计算资源。最近,由于分布式按需计算资源现在广泛存在,以及机器学习的一个重要领域——深度学习已经显示出巨大的潜力,因此对神经网络的研究兴趣激增。因此,现在是学习这类模型的好时机。
生物神经元
神经网络模型借鉴了人类大脑中神经元的组织结构,因此它们也常被称为人工神经网络(ANNs)以区别于它们的生物对应物。关键平行之处在于,单个生物神经元作为一个简单的计算单元,但当大量这些单元组合在一起时,结果是一个极其强大且广泛分布的处理机器,能够进行复杂的学习,通常被称为人脑。为了了解大脑中神经元是如何连接的,以下图像展示了一个简化的人神经细胞图:

简而言之,我们可以将人类神经元视为一个计算单元,它接收一系列平行的电信号输入,这些信号被称为突触神经递质,它们从树突传入。树突在接收到突触神经递质后,将信号化学物质传输到神经元的胞体或身体。这种将外部输入信号转换为局部信号的过程可以被视为树突对其输入应用权重(根据产生的化学物质是抑制剂还是激活剂,权重可以是负的或正的)的过程。
神经元的胞体,其中包含核或中央处理器,将这些输入信号混合在一起,这个过程可以被视为对所有信号求和。因此,原始的树突输入基本上被转换成一个单一的线性加权总和。这个总和被发送到神经元的轴突,它是神经元的传输器。电输入的加权总和在神经元中产生一个电势,这个电势通过轴突中的激活函数进行处理,该函数决定了神经元是否会放电。
通常,激活函数被建模为一个开关,它需要达到一个最小电势,称为偏置,才能被打开。因此,激活函数本质上决定了神经元是否会输出电信号,如果是的话,信号将通过轴突传输,并通过轴突末端传播到其他神经元。这些末端反过来连接到邻近神经元的树突,电信号输出成为后续神经处理的一个输入。
当然,这个描述是对我们神经元中发生的事情的简化,但这里的目的是解释生物过程中哪些方面被用来启发神经网络计算模型。
人工神经元
使用我们的生物类比,我们可以构建一个计算神经元的模型,这个模型被称为神经元的麦库洛奇-皮茨模型:

注意
沃伦·麦库洛奇和沃尔特·皮茨在 1943 年由《数学生物物理学通报》发表的论文《神经活动中内在思想的逻辑演算》中提出了这个神经网络模型作为计算机器。
这个计算神经元是神经网络最简单的例子。我们可以直接从以下图表中构建我们神经网络的输出函数,y:

我们神经网络中的函数g()是激活函数。在这里,选择的特定激活函数是阶跃函数:

当输入的线性加权总和超过零时,步函数输出 1,当它不等于零时,函数输出-1。通常,我们会创建一个虚拟输入特征x[0],它始终被取为 1,以便将偏差或阈值w[0]合并到主要求和中,如下所示:

利用我们对逻辑回归的经验,我们可以很容易地得出结论,我们可以使用这个设置构建一个简单的分类器来解决二元分类问题。唯一的区别在于,在逻辑回归中,我们会选择逻辑函数作为激活函数。事实上,在 1957 年,弗兰克·罗森布拉特提出了一种监督学习算法,用于训练神经元的麦库洛奇-皮茨模型以执行二元分类,这个算法以及产生的学习模型被称为罗森布拉特感知器。
到目前为止,我们已将线性回归和逻辑回归作为可以解决监督学习问题的模型进行介绍,并展示了用于训练它们的准则,而没有深入到涉及训练算法的优化细节。这样做是有意为之,以便我们能够专注于理解模型本身,以及如何在 R 中应用它们。
现在我们已经积累了一些关于分类和回归的经验,这一章将有所不同,我们将探讨预测模型训练的一些细节,因为这也是一个重要的过程,有助于我们全面理解模型。此外,神经网络与之前我们所见的模型有显著不同,训练神经网络通常耗时更长,并涉及调整大量参数,其中许多参数源于优化过程本身。因此,了解这些参数在训练期间的作用以及它们如何影响最终模型是有帮助的。
在我们介绍感知器训练算法之前,我们首先需要学习解决优化问题中最基本的技术之一。
随机梯度下降
在我们之前看到的模型中,例如线性回归,我们讨论了模型在训练过程中必须最小化的准则或目标函数。这个准则有时也被称为损失函数。例如,模型的平方损失函数可以表示为:

我们在这个公式前添加了一个常数项½,原因将在稍后变得明显。从基本的微分知识我们知道,当我们最小化一个函数时,将函数乘以一个常数因子不会改变函数最小值。在线性回归中,正如我们的感知器模型一样,我们的模型预测
仅仅是输入特征的线性加权组合的总和。如果我们假设我们的数据是固定的,而权重是可变的,并且必须选择以最小化我们的标准,那么我们可以将成本函数视为权重的函数:

在这里,我们使用字母w来表示模型权重,以表示更一般的情况,尽管在线性回归中,我们通常使用希腊字母β。由于我们的模型变量是权重,我们可以认为我们的函数是权重向量
的函数。为了找到这个函数的最小值,我们只需要对成本函数关于这个权重向量求偏导。对于特定的权重w[k],这个偏导数由以下给出:

注意,一半的系数已经有效地抵消了导数中的2。我们现在有三个不同的下标,所以退一步理解这个方程是个好主意。最内层的求和仍在进行,这是模型的预测输出。让我们将这个替换到方程中以简化事情:

现在我们应该更有能力理解这个方程了。它表明,我们试图最小化的成本函数的偏导数,针对我们模型中的特定权重w[k],仅仅是模型预测输出与实际标记输出的差,乘以x[ik](对于第i个观察值,对应于我们的权重w[k]的输入特征值),然后对所有数据集中的n个观察值进行平均。
小贴士
如果你熟悉微分但不熟悉偏微分,你已经知道理解这个方程所需的一切。我们使用偏微分来明确识别我们将相对于一个具有多个变量的方程进行微分变量的变量。当我们这样做时,我们将所有其他变量视为常数,并正常进行微分。
为了找到最优权重,我们需要为权重向量中的每个权重求解此方程。注意,通过预测输出项,模型中的所有权重都出现在每个单个权重的偏导数中。换句话说,这产生了一个完整的线性方程组,通常非常大,因此直接求解通常成本过高,从计算角度来看。
相反,许多模型实现使用迭代优化过程,这些过程旨在逐步接近正确解。其中一种方法是梯度下降法。对于权重向量的特定值,梯度下降法找到成本函数梯度最陡的方向,并通过一个称为学习率的参数以小量调整该方向上的权重。因此,更新后的方程是:

在前面的方程中,学习率用希腊字母η表示。将学习率设置为一个适当的值是使用梯度下降进行优化的一个非常重要的方面。如果我们选择一个过小的值,算法每次将权重更新一个非常小的量,因此它将花费太长时间来完成。如果我们使用一个过大的值,我们可能会使权重变化过于剧烈,在值之间振荡,因此学习算法要么需要太长时间才能收敛,要么持续振荡。
有各种复杂的方法来估计适当的学习率,其细节我们在此不讨论。相反,我们将尝试通过试错法找到一个适当的学习率,这在实践中通常效果很好。跟踪我们选择的 learning rate 是否合适的一种方法是将我们试图最小化的成本函数与时间(通过通过数据集进行的迭代次数表示)绘制出来。如果我们选择了好的学习率值,我们应该会看到成本函数随时间逐渐减少(或者至少是非增加的)。
梯度下降法的一种变体是随机梯度下降法,它执行类似的计算,但一次只处理一个观察值而不是全部一起。关键思想是,平均而言,为特定观察值计算的成本函数的梯度将等于在整个观察值上计算出的梯度的平均值。这当然是一个近似,但它确实意味着我们可以一次处理一个单独的观察值,这在实践中非常有用,特别是如果我们想进行在线学习。
随机梯度下降在处理数据集中的第i个观察值时更新特定的权重w[k],根据以下方程:

注意
对于在随机梯度下降训练模型时有用的技巧,一个很好的资源是 Leo Bottou 的一个章节,标题为 Stochastic Gradient Descent Tricks。这本书的版本可以在网上找到,链接为 research.microsoft.com/pubs/192769/tricks-2012.pdf。
梯度下降和局部最小值
梯度下降方法依赖于这样一个观点:正在最小化的成本函数是一个凸函数。我们将跳过这个数学细节,只说凸函数是一个最多只有一个全局最小值的函数。让我们来看一个关于单个权重 w 的非凸成本函数的例子:

这个函数的全局最小值是在 w 接近 4.5 的值时左侧的第一个凹槽。如果我们对权重 w 的初始猜测是 1,则成本函数的梯度指向全局最小值,我们将逐步接近它,直到达到它。如果我们对权重 w 的初始猜测是 12,那么成本函数的梯度将指向接近 10.5 的凹槽下方。一旦我们达到第二个凹槽,成本函数的梯度将为 0,因此,我们将无法向全局最小值前进,因为我们已经陷入局部最小值。
检测和避免局部最小值可能非常棘手,尤其是如果有很多局部最小值。一种方法是使用不同的起始点重复优化,然后选择在优化运行的不同时间产生成本函数最低值的权重。如果局部最小值数量很少且彼此之间不是很接近,这个程序效果很好。幸运的是,我们在上一节中看到的平方误差成本函数是一个凸函数,因此梯度下降方法保证找到全局最小值,但了解我们还将遇到其他非凸成本函数的例子是好的。
感知机算法
不再拖延,我们将介绍第一个用于神经网络分类的训练算法。这是感知机学习算法的一种变体,被称为口袋感知机算法。
输入:
-
x:一个二维矩阵,其中行是观测值,列是输入特征。 -
y:一个向量,包含 x 中所有观测值的类别标签(-1 或 1)。 -
learning_rate:一个控制算法学习率的数字。 -
max_iterations:算法在学习过程中允许执行的最大数据循环次数。
输出:
-
w:感知机的学习权重。 -
converged:算法是否收敛(真或假)。 -
iterations:学习过程中实际执行的数据循环次数。
方法:
-
随机初始化权重 w。
-
在 x 中选择一个观测值,并将其称为 xi。
-
使用当前权重 w 的值和感知器输出的方程计算预测类别,
。 -
如果预测类别,
与实际类别 yi不相同,则使用随机梯度下降更新权重向量。 -
对数据集中的所有观测值重复步骤 2–4,并计算犯下的错误数量。
-
如果错误数量为零,则我们已收敛,算法终止。
-
如果当前迭代中犯下的错误数量少于以前犯下的最低错误数量,则将权重向量存储为迄今为止看到的最佳权重向量。
-
如果我们达到了最大迭代次数,停止并返回最佳权重向量的值。否则,从步骤 2 开始在数据集上开始新的迭代。
我们将直接展示 R 代码,并详细讨论这些步骤:
step_function <- function(x) {
if (x < 0) -1 else 1
}
pocket_perceptron <- function(x, y, learning_rate, max_iterations) {
nObs = nrow(x)
nFeatures = ncol(x)
w = rnorm(nFeatures + 1, 0, 2) # Random weight initialization
current_iteration = 0
has_converged = F
best_weights = w
# Start by assuming you get all the examples wrong
best_error = nObs
while ((has_converged == F) &
(current_iteration < max_iterations)) {
# Assume we are done unless we misclassify an observation
has_converged = T
# Keep track of misclassified observations
current_error = 0
for (i in 1:nObs) {
xi = c(1, x[i,]) # Append 1 for the dummy input feature x0
yi = y[i]
y_predicted = step_function(sum(w * xi))
if (yi != y_predicted) {
current_error = current_error + 1
# We have at least one misclassified example
has_converged = F
w = w - learning_rate * sign(y_predicted - yi) * xi
}
}
if (current_error < best_error) {
best_error = current_error
best_weights = w
}
current_iteration = current_iteration+1
}
model <- list("weights" = best_weights,
"converged" = has_converged,
"iterations" = current_iteration)
model
}
我们定义的第一个函数是步进函数,我们知道它将产生 -1 或 1 的值,对应于数据集中的两个类别。然后我们定义我们的主要函数,我们称之为 pocket_perceptron()。这个函数的目的是学习感知器的权重,以便我们的模型能够正确地分类训练数据。
注意,我们没有在我们的算法中引入任何正则化,以保持简单,因此我们可能会得到一个过度拟合数据的模型,因为我们追求的是 100% 的训练准确率。继续我们的算法描述,我们首先初始化权重向量为小的随机生成的数字。在实践中,确保权重不是设置为 0 并且不是对称的,这是一个避免这种情况的好方法。
我们还将起始的最佳权重猜测设置为我们的初始向量,并将起始的最佳错误率设置为观测值的总数,这是数据集上最坏可能的错误率。
函数的主要 while 循环控制算法将运行的迭代次数。只有在我们没有收敛且未达到最大迭代次数时,我们才会开始新的迭代。在 while 循环内部,我们使用 for 循环遍历数据集中的观测值,并使用我们权重向量的当前版本对这些观测值进行分类。
每当我们分类错误时,我们都会更新我们的错误率,注意我们在这个迭代中尚未收敛,并根据我们在上一节中看到的平方最小化随机梯度下降更新规则更新我们的权重向量。尽管由于用于阈值输出的步进函数,感知器的成本函数不可导,但事实证明,我们实际上仍然可以使用相同的权重更新规则。
在完整地遍历我们的数据集之后,也称为一个时代,我们检查是否需要更新我们的最佳权重向量并更新迭代次数。只有当当前迭代在训练数据上的性能是我们迄今为止在所有完成的迭代中看到的最佳性能时,我们才更新我们的最佳权重向量。当算法终止时,无论是否收敛,我们都返回找到的最佳权重,以及完成的迭代总数。
注意
关于神经网络的决定性教科书,以及一本更详细解释感知机学习的书籍,是《神经网络与学习机器 第 3 版》,作者西蒙·海金,出版社:普伦蒂斯·霍尔。
我们可以通过生成一些人工数据来测试我们的模型。我们将通过从两个均匀分布中采样值来创建两个输入特征:x[1] 和 x[2]。然后,我们将根据我们随机选择的线性决策边界将这些数据点分为两个不同的类别:

一旦我们有了数据和计算出的类别标签,我们就可以在它们上运行感知机算法。以下代码生成测试数据和构建我们的模型:
> set.seed(4910341)
> x1 <- runif(200, 0, 10)
> set.seed(2125151)
> x2 <- runif(200, 0, 10)
> x <- cbind(x1, x2)
> y <- sign(-0.89 + 2.07 * x[,1] - 3.09 * x[,2])
> pmodel <- pocket_perceptron(x, y, 0.1, 1000)
> pmodel
$weights
x1 x2
-1.738271 4.253327 -6.360326
$converged
[1] TRUE
$iterations
[1] 32
我们可以看到,经过 32 次迭代后,我们的感知机算法已经收敛。如果我们将权重向量除以2(这不会改变我们的决策边界),我们可以更清楚地看到我们有一个非常接近用于分类数据的决策边界的决策边界:
> pmodel$weights / 2
x1 x2
-0.8741571 2.1420697 -3.2122627
下面的图示显示,该模型的决策边界几乎与种群线无法区分。对于我们的人工生成数据集,这是因为两个类别非常接近。如果类别之间距离更远,我们更有可能看到种群决策边界和模型决策边界之间的明显差异。
这是因为可能分离数据的线条(或当我们处理超过两个特征时的平面)的空间会更大。

线性分离
我们生成的数据具有特定的属性,确保感知机算法会收敛——它是线性可分的。当两个类别在一系列特征上线性可分时,这意味着可以找到这些特征的线性组合作为决策边界,这将允许我们以 100%的准确率对两个类别进行分类。
如果我们考虑在 p-维特征空间中绘制属于两个类别的数据点,那么线性分离意味着可以画出一个平面(或线,如我们在示例中看到的)来分离这两个类别。有一个称为感知器收敛定理的定理,它表明对于线性可分类别,如果给定足够的时间,感知器学习算法将始终收敛到一个正确分类所有给定数据的解。
逻辑神经元
感知器也被称为二元阈值神经元。我们可以通过改变激活函数来创建不同类型的神经元。例如,如果我们完全移除阈值函数,我们最终会得到一个线性神经元,它本质上执行与线性回归相同的任务。通过将激活函数更改为逻辑函数,我们可以创建一个逻辑神经元。
一个逻辑神经元执行的任务与逻辑回归相同,通过将输入进行线性组合并应用逻辑函数来预测区间 [0,1] 内的值。可以使用随机梯度下降来学习线性神经元以及逻辑神经元的权重。因此,它也可以用于学习逻辑回归和线性回归的权重。随机梯度下降权重更新规则的一般形式是:

在这里,导数是计算成本函数在特定观察点处的梯度。我们在上一节中看到了线性回归和线性神经元的简单形式。如果我们对逻辑回归的成本函数进行微分,我们会发现逻辑神经元的随机梯度下降更新规则似乎与线性神经元完全相同:

这里的微妙区别在于,
的形式完全不同,因为它现在将权重包含在逻辑函数中,而线性回归中并非如此。逻辑神经元非常重要,因为它们是构建由许多相互连接的神经元组成的网络时最常用的神经元类型。正如我们将在下一节中看到的,我们通常按层构建神经网络。产生我们输出的神经元所在的层被称为输出层。输入层由我们的数据特征组成,这些特征是网络的输入。
输入层和输出层之间的层被称为隐藏层。逻辑神经元是最常见的隐藏层神经元。此外,当我们的任务是分类时,我们使用逻辑神经元作为输出层神经元,当我们的任务是回归时,我们使用线性神经元。
多层感知器网络
多层神经网络是连接许多神经元以创建神经架构的模型。单个神经元是非常基本的单元,但当我们组织在一起时,我们可以创建一个比单个神经元强大得多的模型。
如前所述,我们按层构建神经网络,我们主要根据这些层之间的连接和使用的神经元类型来区分不同类型的神经网络。以下图显示了多层感知器(MLP)神经网络的一般结构,这里展示了两个隐藏层:

MLP 网络的第一个特征是信息从输入层流向输出层,方向单一。因此,它被称为前馈神经网络。这与其他神经网络类型形成对比,其他神经网络类型中存在循环,允许信息作为反馈信号流回网络中的早期神经元。这些网络被称为反馈神经网络或循环神经网络。循环神经网络通常很难训练,并且通常不随着输入数量的增加而很好地扩展。尽管如此,它们仍然找到了许多应用,特别是在涉及时间成分的问题中,如预测和信号处理。
回到图中所示的 MLP 架构,我们注意到左侧的第一组神经元被称为输入神经元,形成了输入层。我们总是有与输入特征数量一样多的输入神经元。输入神经元被认为产生我们输入特征的值作为输出。因此,我们通常不称它们为输入神经元,而是称它们为输入源或输入节点。在图的最右侧,我们有输出层和输出神经元。我们通常有与我们要建模的输出数量一样多的输出神经元。因此,我们的神经网络可以自然地学习一次预测多个事物。这个规则的例外之一是当我们建模多类分类问题时,我们通常为每个类别有一个二进制输出神经元。在这种情况下,所有输出神经元都是单个多类因素输出的虚拟编码。
在输入层和输出层之间,我们有隐藏层。神经元根据它们之间以及与输入神经元之间的神经元数量被组织成层。例如,第一隐藏层中的神经元直接连接到输入层中至少一个神经元,而第二隐藏层中的神经元直接连接到第一隐藏层中的一个或多个神经元。我们的图是一个 4-4 架构的例子,这意味着有两个每个有四个神经元的隐藏层。尽管它们本身不是神经元,但该图明确显示了所有神经元的偏差单元。我们在单个神经元输出的方程中看到,我们可以将偏差单元视为具有值为 1 的虚拟输入特征,并且它有一个与之对应的偏差或阈值权重的权重。
并不是架构中的所有神经元都假设具有相同的激活函数。通常,我们为隐藏层中的神经元选择激活函数时,会与输出层的激活函数分开考虑。我们已经看到的输出层激活函数的选择是基于我们希望得到哪种类型的输出,这反过来又取决于我们是进行回归还是分类。
隐藏层神经元的激活函数通常是非线性函数,因为将线性神经元链式连接起来可以从代数上简化为一个具有不同权重的单个线性神经元,因此这并不会给网络增加任何能力。最常用的激活函数是对数逻辑函数,但其他如双曲正切函数也被使用。
神经网络的输出可以通过依次计算每一层神经元的输出来计算。第一隐藏层单元的输出可以使用我们迄今为止看到的神经元输出方程来计算。这些输出成为第二隐藏层神经元的输入,因此,它们实际上是相对于该层的新特征。
神经网络的一个优势是这种通过学习隐藏层中的权重来学习新特征的能力。这个过程在神经网络的每一层重复进行,直到最终层,在那里我们获得整个神经网络的输出。从输入层到输出层的信号传播过程被称为正向传播。
训练多层感知器网络
多层感知器网络比单个感知器更难训练。用于训练它们的著名算法——自 20 世纪 80 年代以来一直存在——被称为反向传播算法。在这里,我们将简要介绍这个算法的工作原理,但强烈建议对神经网络感兴趣的读者深入了解这个算法。
理解这个算法有两个非常重要的洞察。第一个是,对于每一个观察,它分为两个步骤进行。正向传播步骤从输入层开始,到输出层结束,并计算网络对于这个观察的预测输出。这相对简单,可以使用每个神经元的输出方程来完成,这仅仅是将其输入的线性加权和应用其激活函数。
反向传播步骤是为了在预测输出与期望输出不匹配时修改网络的权重。这一步骤从输出层开始,计算输出节点的误差以及输出神经元权重的必要更新。然后,它通过网络反向移动,反向更新每个隐藏层的权重,直到达到最后一个隐藏层,它被最后处理。因此,网络中有一个正向传递,然后是一个反向传递。
第二个重要的洞察是,更新隐藏层中神经元的权重比更新输出层的权重要复杂得多。为了看到这一点,考虑当我们想要更新输出层中神经元的权重时,我们知道对于给定的输入,该神经元应该有什么期望输出。
这是因为输出神经元的期望输出就是网络本身的输出,这些输出在我们的训练数据中都是可用的。相比之下,乍一看,我们实际上并不知道对于一个特定的输入,隐藏层中一个神经元的正确输出应该是什么。此外,这个输出被分布到网络中下一层的所有神经元,因此影响了它们的输出。
这里的关键洞察是,我们将输出神经元中犯的错误传播回隐藏层中的神经元。我们通过找到成本函数的梯度来调整神经元的权重,以减少最大误差的方向,并应用微分链式法则将这个梯度用我们感兴趣的个别神经元的输出来表示。这个过程导致了一个更新网络中任何神经元权重的通用公式,称为delta 更新规则:

让我们通过假设我们目前正在处理层 l 中所有神经元的权重来理解这个方程。这个方程告诉我们如何更新层 l 中第j个神经元和它之前一层(层 l-1)中第i个神经元之间的权重。所有的(n)上标都表示我们目前正在更新权重,这是由于处理数据集中第n个观察的结果。从现在起,我们将省略这些上标,并假设它们是隐含的。
简而言之,delta 规则告诉我们,为了获得神经元权重的新的值,我们必须将三个项的乘积加到旧值上。这三个项中的第一个是学习率 η。第二个被称为局部梯度,δ[j],它是神经元 j 的误差,e[j],与其激活函数的梯度,g() 的乘积:

这里,我们用 z[j] 表示应用其激活函数之前神经元 j 的输出,以便以下关系成立:

结果表明,局部梯度也是网络成本函数相对于 z[j] 计算的梯度。最后,delta 更新规则中的第三项是来自神经元 i 的神经元 j 的输入,这仅仅是神经元 i 的输出,y[i]。输出层神经元和隐藏层神经元之间唯一不同的项是局部梯度项。我们将通过一个示例来说明使用逻辑神经元进行分类的神经网络。当神经元 j 是输出神经元时,局部梯度由以下公式给出:

方括号中的第一个项是输出神经元的已知误差,这是目标输出,t[j],与实际输出,y[j] 之间的差异。其他两个项来自逻辑激活函数的微分。当神经元 j 是隐藏层神经元时,逻辑激活函数的梯度相同,但误差项是下一个层中接收来自神经元 j 输入的 k 个神经元的局部梯度的加权总和:

反向传播算法
错误反向传播,或简称反向传播,是另一种用于训练人工神经网络的常见方法,它通常与一种优化方法(如本章后面将要描述的梯度下降法)结合使用。
反向传播的目标是 优化权重,以便神经网络模型能够学习如何正确地将任意输入映射到输出。换句话说,当使用反向传播时,初始系统输出会持续与期望输出进行比较,系统会进行调整,直到两者之间的差异最小化。
预测建筑物的能源效率
在本节中,我们将研究如何使用神经网络解决一个实际的回归问题。再次,我们转向 UCI 机器学习仓库以获取我们的数据集。我们选择尝试位于archive.ics.uci.edu/ml/datasets/Energy+efficiency的能源效率数据集。预测任务是使用各种建筑特征,如表面积和屋顶面积,来预测建筑的能源效率,该效率以两种不同的指标形式表达——加热负荷和冷却负荷。
这是一个很好的例子,我们可以用它来展示如何使用单个神经网络预测两个不同的输出。数据集的完整属性描述如下表所示:
| 列名 | 类型 | 定义 |
|---|---|---|
relCompactness |
数值 | 相对紧凑度 |
surfArea |
数值 | 表面积 |
wallArea |
数值 | 墙面积 |
roofArea |
数值 | 屋顶面积 |
height |
数值 | 总高度 |
orientation |
数值 | 建筑朝向(因子) |
glazArea |
数值 | 玻璃面积 |
glazAreaDist |
数值 | 玻璃面积分布(因子) |
heatLoad |
数值 | 加热负荷(第一个输出) |
coolLoad |
数值 | 冷却负荷(第二个输出) |
数据是使用名为Ecotect的模拟器生成的。数据集中的每个观测值对应一个模拟建筑。所有建筑具有相同的体积,但影响其能效的其他属性,如玻璃面积,则进行了修改。
备注
该数据集在 2012 年发表的论文《使用统计机器学习工具准确估计住宅建筑能效》中进行了描述,该论文由Athanasios Tsanas和Angeliki Xifara撰写,发表于Energy and Buildings,第 49 卷。
网站上的数据以 Microsoft Excel 格式提供。要将这些数据加载到 R 中,我们可以使用 R 包xlsx,它可以读取和理解 Microsoft Excel 文件:
> library(xlsx)
> eneff <- read.xlsx2("ENB2012_data.xlsx", sheetIndex = 1,
colClasses = rep("numeric", 10))
> names(eneff) <- c("relCompactness", "surfArea", "wallArea", "roofArea", "height", "orientation", "glazArea", "glazAreaDist", "heatLoad", "coolLoad")
> eneff <- eneff[complete.cases(eneff),]
导入操作在数据框的末尾添加了一些空观测值,因此最后一行删除了这些值。现在,通过参考介绍数据集的论文,我们发现我们的两个属性实际上是因子。为了让我们的神经网络能够使用这些属性,我们需要将它们转换为虚拟变量。为此,我们将使用caret包中的dummyVars()函数:
> library(caret)
> eneff$orientation <- factor(eneff$orientation)
> eneff$glazAreaDist <- factor(eneff$glazAreaDist)
> dummies <- dummyVars(heatLoad + coolLoad ~ ., data = eneff)
> eneff_data <- cbind(as.data.frame(predict(dummies, newdata =
eneff)), eneff[,9:10])
> dim(eneff_data)
[1] 768 18
dummyVars()函数接受一个公式和一个数据框。从这些中,它识别输入特征并对那些是因子的特征进行虚拟编码,以产生新的二进制列。为因子创建的列数与该因子的级别数相同。就像我们之前使用的preProcess()函数一样,我们实际上是在使用predict()函数后获得这些列。接下来,我们将在训练数据和测试数据之间进行 80-20 的分割:
> set.seed(474576)
> eneff_sampling_vector <- createDataPartition(eneff_data$heatLoad, p
= 0.80, list = FALSE)
> eneff_train <- eneff_data[eneff_sampling_vector, 1:16]
> eneff_train_outputs <- eneff_data[eneff_sampling_vector, 17:18]
> eneff_test <- eneff_data[-eneff_sampling_vector, 1:16]
> eneff_test_outputs <- eneff_data[-eneff_sampling_vector, 17:18]
在训练神经网络时,执行最重要的预处理步骤之一是缩放输入特征和输出。执行输入缩放的一个好理由是为了避免饱和,这发生在优化过程达到一个点,其中误差函数的梯度绝对值非常小。这通常是由于非线性神经元激活函数的输入非常大或非常小。饱和会导致优化过程终止,认为我们已经收敛。
根据特定的神经网络实现,对于回归任务,也可能有理由对输出进行缩放,因为一些线性神经元的实现被设计为在区间[-1,1]内产生输出。缩放也有助于收敛。因此,我们将使用caret将所有数据维度缩放到单位区间,注意这不会影响之前产生的二进制列:
> eneff_pp <- preProcess(eneff_train, method = c("range"))
> eneff_train_pp <- predict(eneff_pp, eneff_train)
> eneff_test_pp <- predict(eneff_pp, eneff_test)
> eneff_train_out_pp <- preProcess(eneff_train_outputs, method =
c("range"))
> eneff_train_outputs_pp <-
predict(eneff_train_out_pp, eneff_train_outputs)
> eneff_test_outputs_pp <-
predict(eneff_train_out_pp, eneff_test_outputs)
几个不同的包在 R 中实现了神经网络,每个包都有其各自的优点和优势。因此,熟悉多个包是有帮助的,在本章中,我们将研究这三个包,首先是neuralnet:
> library("neuralnet")
> n <- names(eneff_data)
> f <- as.formula(paste("heatLoad + coolLoad ~", paste(n[!n %in%
c("heatLoad", "coolLoad")], collapse = " + ")))
> eneff_model <- neuralnet(f,
data = cbind(eneff_train_pp, eneff_train_outputs_pp), hidden = 10)
> eneff_model
Call: neuralnet(formula = f, data = cbind(eneff_train_pp, eneff_train_outputs_pp), hidden = 10)
1 repetition was calculated.
Error Reached Threshold Steps
1 0.3339635783 0.009307995429 9998
neuralnet()函数根据其参数中提供的信息训练一个神经网络。我们提供的第一个参数是一个公式,其格式与我们在前几章中看到的lm()和glm()函数中的公式类似。这里的一个有趣差异是我们指定了两个输出,heatLoad和coolLoad。另一个差异是,目前我们无法使用点(.)表示法来暗示我们数据框中剩余的所有列都可以用作特征,因此我们需要明确指定它们。
注意,使用公式后,我们已经有效地定义了神经网络的输入层和输出层,因此需要指定的是隐藏层的结构。这通过hidden参数来指定,它可以是单个标量表示单层,或者是一个标量向量,指定了从输入层之后的每一层到输出层之前的每一层的隐藏单元数量。
在我们之前看到的例子中,我们使用了一个包含 10 个节点的单层。实际上,我们可以将我们的神经网络可视化,因为该包提供了直接绘制模型的能力(编号的圆圈是虚拟偏置神经元):

对neuralnet()的调用还允许我们通过参数字符串act.fct指定我们希望为神经元使用的激活函数类型。默认情况下,这被设置为逻辑激活函数,所以我们没有更改它。另一个非常重要的参数是linear.output,它可以设置为TRUE或FALSE。这指定了我们应该是否将激活函数应用于输出层的神经元。我们使用的默认值TRUE意味着我们不应用激活函数,因此我们可以观察到线性输出。对于回归类型问题,这是合适的。这是因为;如果我们应用逻辑激活函数,我们的输出将限制在区间[0,1]内。最后,我们可以通过err.fct参数指定一个可微分的误差函数,作为我们优化策略的一部分。由于我们正在进行回归,我们使用默认值sse,它对应于平方误差之和。
由于神经网络训练中存在随机成分,即权重的初始化,我们可能希望指定我们应该多次重新训练同一个模型,以便我们能够选择最佳可能的模型(使用 SSE 等标准对这些进行排名)。这可以通过指定rep参数的整数值来完成。让我们重写我们原始的调用,以明确显示我们正在使用的默认值:
> eneff_model <- neuralnet(f,
data = cbind(eneff_train_pp, eneff_train_outputs_pp), hidden = 10, act.fct = "logistic", linear.output = TRUE, err.fct = "sse", rep = 1)
模型的输出为我们提供了关于神经网络性能的一些信息,显示的内容取决于其配置。由于我们已指定 SSE 作为我们的误差度量,显示的误差就是所获得的 SSE。阈值数字仅仅是当模型停止训练时误差函数偏导数的值。本质上,我们不是在梯度精确为 0 时终止,而是指定一个非常小的值,误差梯度需要下降到这个值以下算法才会终止。这个值的默认值是 0.01,可以通过在neuralnet()函数中提供一个数字来改变阈值参数。降低这个值通常会导致更长的训练时间。模型输出还显示了执行的训练步骤数量。最后,如果我们使用了rep参数重复这个过程多次,我们会看到每个训练的模型都有一行。我们的输出显示我们只训练了一个模型。
小贴士
由于神经网络包含一个以权重向量初始化形式存在的随机成分,重新运行我们的代码可能不会给出完全相同的结果。如果在运行示例时,R 输出一个消息表示模型没有收敛,请尝试再次运行代码。
评估用于回归的多层感知器
neuralnet包为我们提供了一个方便的方法,通过compute()函数使用我们的模型进行预测。本质上,它不仅为我们提供了观察数据框的预测输出,还显示了模型架构中所有神经元的输出值。为了评估模型的性能,我们对神经网络在测试集上的输出感兴趣:
> test_predictions <- compute(eneff_model, eneff_test_pp)
我们可以通过test_predictions对象的net.result属性访问神经网络的预测输出,如下所示:
> head(test_predictions$net.result)
[,1] [,2]
7 0.38996108769 0.39770348145
8 0.38508402576 0.46726904682
14 0.29555228848 0.24157156896
21 0.49912349400 0.51244876337
23 0.50036257800 0.47436990729
29 0.01133684342 0.01815294595
由于这是一个回归问题,我们希望能够使用均方误差(MSE)来评估我们的模型在目标输出上的性能。为了做到这一点,我们需要将我们的预测输出转换回原始尺度,以便进行公平的评估。我们用于数据缩放的常数存储在eneff_train_out_pp对象的ranges属性中:
> eneff_train_out_pp$ranges
heatLoad coolLoad
[1,] 6.01 10.90
[2,] 42.96 48.03
第一行包含原始数据的最小值,第二行包含最大值。现在我们将编写一个函数,该函数将接受一个缩放向量以及包含原始最小值和最大值的另一个向量,并返回原始未缩放向量:
reverse_range_scale <- function(v, ranges) {
return( (ranges[2] - ranges[1]) * v + ranges[1] )
}
接下来,我们将使用这个方法来获取测试集的未缩放预测输出:
> output_ranges <- eneff_train_out_pp$ranges
> test_predictions_unscaled <- sapply(1:2, function(x)
reverse_range_scale(test_predictions[,x], output_ranges[,x]))
我们还可以定义一个简单的函数来计算 MSE,并使用它来检查我们在两个任务上的性能:
mse <- function(y_p, y) {
return(mean((y - y_p) ^ 2))
}
> mse(test_predictions_unscaled[,1], eneff_test_outputs[,1])
[1] 0.2940468477
> mse(test_predictions_unscaled[,2], eneff_test_outputs[,2])
[1] 1.440127075
这些值非常低,表明我们的预测准确度非常高。我们还可以研究相关性,它是尺度无关的,我们也可以在未缩放输出上使用它:
> cor(test_predictions_unscaled[,1], eneff_test_outputs[,1])
[1] 0.9986655316
> cor(test_predictions_unscaled[,2], eneff_test_outputs[,2])
[1] 0.9926735348
这些值非常高,表明我们几乎达到了完美的性能,这在现实世界的数据中是非常罕见的。如果准确率不是这么高,我们可能会通过使架构更复杂来进行实验。例如,我们可以通过设置hidden=c(10,5)来构建一个具有额外层的模型,这样我们就在输出层之前有一个额外的五神经元层。
重新审视预测玻璃类型
在第三章中,我们分析了玻璃识别数据集,其任务是识别在犯罪现场发现的玻璃碎片所属的玻璃类型。该数据集的输出是一个具有多个类别级别的因子,对应不同的玻璃类型。我们之前的方法是使用多项逻辑回归构建一个一对一模型。结果并不十分令人鼓舞,主要问题之一是训练数据上的模型拟合度较差。
在本节中,我们将重新审视这个数据集,看看神经网络模型是否能做得更好。同时,我们将展示神经网络如何处理分类问题:
> glass <- read.csv("glass.data", header = FALSE)
> names(glass) <- c("id", "RI", "Na", "Mg", "Al", "Si", "K", "Ca",
"Ba", "Fe", "Type")
> glass$id <- NULL
我们的输出是一个多类因素,因此我们希望将其虚拟编码为二进制列。在neuralnet包中,我们通常需要作为预处理步骤手动执行此操作,然后才能构建我们的模型。
在本节中,我们将探讨一个包含构建神经网络函数的第二个包,即nnet。实际上,这正是我们用于多项式逻辑回归的同一个包。这个包的一个好处是,对于多类分类,训练神经网络的nnet()函数将自动检测输出因素并为我们执行虚拟编码。考虑到这一点,我们将准备一个训练集和测试集:
> glass$Type <- factor(glass$Type)
> set.seed(4365677)
> glass_sampling_vector <- createDataPartition(glass$Type, p =
0.80, list = FALSE)
> glass_train <- glass[glass_sampling_vector,]
> glass_test <- glass[-glass_sampling_vector,]
接下来,就像我们之前的数据集一样,我们将对输入数据进行归一化处理:
> glass_pp <- preProcess(glass_train[1:9], method = c("range"))
> glass_train <- cbind(predict(glass_pp, glass_train[1:9]), Type = glass_train$Type)
> glass_test <- cbind(predict(glass_pp, glass_test[1:9]), Type = glass_test$Type)
现在,我们已经准备好训练我们的模型。虽然neuralnet包能够模拟多个隐藏层,但nnet包旨在模拟具有单个隐藏层的神经网络。因此,我们仍然像以前一样指定一个公式,但这次,我们不是指定一个可以是标量或整数向量的hidden参数,而是指定一个size参数,它是一个整数,表示模型单个隐藏层中的节点数。
此外,nnet包中的默认神经网络模型用于分类,因为输出层使用逻辑激活函数。当使用不同包训练相同类型的模型时,例如多层感知器,检查各种模型参数的默认值非常重要,因为这些值会因包而异。我们在这里要提到的两个包之间的另一个区别是,nnet目前不提供任何绘图功能。无需进一步说明,我们现在将训练我们的模型:
> glass_model <- nnet(Type ~ ., data = glass_train, size = 10)
# weights: 166
initial value 343.685179
iter 10 value 265.604188
iter 20 value 220.518320
iter 30 value 194.637078
iter 40 value 192.980203
iter 50 value 192.569751
iter 60 value 192.445198
iter 70 value 192.421655
iter 80 value 192.415382
iter 90 value 192.415166
iter 100 value 192.414794
final value 192.414794
stopped after 100 iterations
从输出中我们可以看到,模型没有收敛,在默认的 100 次迭代后停止。为了收敛,我们可以多次重新运行此代码,或者我们可以使用maxit参数将允许的迭代次数增加到 1,000:
> glass_model <- nnet(Type ~ ., data = glass_train, size = 10, maxit =
1000)
让我们先调查我们的模型在训练数据上的准确性,以评估拟合质量。为了计算预测值,我们使用predict()函数并指定类型参数为class。这会让predict()函数知道我们想要选择概率最高的类别。如果我们想看到每个类别的概率,我们可以为type参数指定值response。最后,请记住,我们必须将不带输出的数据框传递给predict()函数,因此需要对训练数据框进行子集化:
> train_predictions <- predict(glass_model, glass_train[,1:9],
type = "class")
> mean(train_predictions == glass_train$Type)
[1] 0.7183908046
我们第一次尝试表明,我们得到的拟合质量与我们的多项式逻辑回归模型相同。为了改进这一点,我们将通过在隐藏层中添加更多神经元来增加模型的复杂性。我们还将把maxit参数增加到10,000,因为模型更复杂,可能需要更多的迭代才能收敛:
> glass_model2 <- nnet(Type ~ ., data = glass_train, size = 50, maxit =
10000)
> train_predictions2 <- predict(glass_model2, glass_train[,1:9],
type = "class")
> mean(train_predictions2 == glass_train$Type)
[1] 1
如我们所见,我们现在已经达到了 100%的训练准确率。现在我们有一个相当好的模型拟合,我们可以调查我们在测试集上的性能:
> test_predictions2 <- predict(glass_model2, glass_test[,1:9],
type = "class")
> mean(test_predictions2 == glass_test$Type)
[1] 0.6
尽管我们的模型完美地拟合了训练数据,但我们看到测试集上的准确率仅为 60%。即使考虑到数据集非常小,这种差异也是一个经典的信号,表明我们的模型在训练数据上过度拟合。当我们查看线性回归和逻辑回归时,我们看到了如 lasso 这样的收缩方法,这些方法旨在通过限制模型中系数的大小来对抗过度拟合。
对于神经网络,存在一个称为权重衰减的类似技术。使用这种方法,将衰减常数与所有网络权重平方和的乘积添加到成本函数中。这限制了任何权重取过大的值,从而对网络进行正则化。尽管目前neuralnet()没有正则化选项,但nnet()使用decay参数:
> glass_model3 <- nnet(Type~., data = glass_train, size = 10, maxit =
10000, decay = 0.01)
> train_predictions3 <- predict(glass_model3, glass_train[,1:9],
type = "class")
> mean(train_predictions3 == glass_train$Type)
[1] 0.9367816092
> test_predictions3 <- predict(glass_model3, glass_test[,1:9],
type = "class")
> mean(test_predictions3 == glass_test$Type)
[1] 0.775
使用这个模型,我们的训练数据拟合仍然非常高,并且比我们使用多项式逻辑回归所达到的要高得多。在测试集上,性能仍然比训练集差,但比我们之前的好得多。
我们不会在玻璃识别数据上花费更多时间。相反,我们将在继续之前反思一些学到的经验教训。其中之一是,使用神经网络获得良好的性能,有时甚至只是达到收敛,可能很棘手。训练模型涉及网络权重的随机初始化,最终结果往往对这些初始条件非常敏感。我们可以通过多次训练我们迄今为止看到的不同模型配置,并注意到某些运行中的某些配置可能无法收敛,以及我们的训练集和测试集的性能确实会随每次运行而有所不同,来证实这一事实。
另一个洞见是训练神经网络涉及调整各种参数,从隐藏神经元的数量和排列到衰减参数的值。我们没有实验过的其他参数包括用于隐藏层神经元的非线性激活函数的选择、收敛的准则以及我们用来拟合模型的特定成本函数。例如,我们不是使用最小二乘法,而可以使用一个称为熵的准则。
因此,在确定最终模型选择之前,尝试尽可能多的不同组合是值得的。在caret包的train()函数中实验不同的参数组合是一个好地方。它为我们所看到的神经网络包提供了一个统一的接口,并且与expand.grid()结合使用,允许同时训练和评估几个不同的神经网络配置。我们在这里只提供一个示例,感兴趣的读者可以使用这个示例继续他们的研究:
> library(caret)
> nnet_grid <- expand.grid(.decay = c(0.1, 0.01, 0.001, 0.0001),
.size = c(50, 100, 150, 200, 250))
> nnetfit <- train(Type ~ ., data = glass_train, method = "nnet",
maxit = 10000, tuneGrid = nnet_grid, trace = F, MaxNWts = 10000)
预测手写数字
我们神经网络应用的最终任务是手写数字预测。在这个任务中,目标是构建一个模型,该模型将展示一个数字(0-9)的图像,并且模型必须预测显示的是哪个数字。我们将使用来自yann.lecun.com/exdb/mnist/的MNIST手写数字数据库。
从这个页面,我们已经下载并解压了两个训练文件,train-images-idx3-ubyte.gz和train-images-idx3-ubyte.gz。前者包含图像数据,后者包含相应的数字标签。使用这个网站的优势在于数据已经被预处理,每个数字都在图像中居中,并且将数字缩放到统一的大小。为了加载数据,我们使用了网站关于 IDX 格式的信息来编写两个函数:
read_idx_image_data <- function(image_file_path) {
con <- file(image_file_path, "rb")
magic_number <- readBin(con, what = "integer", n = 1, size = 4,
endian = "big")
n_images <- readBin(con, what = "integer", n = 1, size = 4,
endian="big")
n_rows <- readBin(con, what = "integer", n = 1, size = 4,
endian = "big")
n_cols <- readBin(con, what = "integer", n = 1, size = 4,
endian = "big")
n_pixels <- n_images * n_rows * n_cols
pixels <- readBin(con, what = "integer", n = n_pixels, size = 1,
signed = F)
image_data <- matrix(pixels, nrow = n_images, ncol = n_rows *
n_cols, byrow = T)
close(con)
return(image_data)
}
read_idx_label_data <- function(label_file_path) {
con <- file(label_file_path, "rb")
magic_number <- readBin(con, what = "integer", n = 1, size = 4,
endian = "big")
n_labels <- readBin(con, what = "integer", n = 1, size = 4,
endian = "big")
label_data <- readBin(con, what = "integer", n = n_labels, size = 1,
signed = F)
close(con)
return(label_data)
}
然后,我们可以通过以下两个命令加载我们的两个数据文件:
> mnist_train <- read_idx_image_data("train-images-idx3-ubyte")
> mnist_train_labels <- read_idx_label_data("train-labels-idx1-
ubyte")
> str(mnist_train)
int [1:60000, 1:784] 0 0 0 0 0 0 0 0 0 0 ...
> str(mnist_train_labels)
int [1:60000] 5 0 4 1 9 2 1 3 1 4 ...
每个图像由一个 28 像素乘以 28 像素的灰度值矩阵表示,灰度值范围在 0 到 255 之间,其中 0 是白色,255 是黑色。因此,我们的每个观测值都有 28²=784 个特征值。每个图像通过从右到左和从上到下光栅化矩阵存储为一个向量。训练数据中有 60,000 个图像,我们的mnist_train对象将这些存储为一个 60,000 行乘以 78 列的矩阵,这样每一行对应一个单独的图像。为了了解我们的数据看起来像什么,我们可以可视化前七个图像:

为了分析这个数据集,我们将介绍我们的第三个也是最后一个用于训练神经网络模型的 R 包,RSNNS。实际上,这个包是围绕斯图加特神经网络模拟器(SNNS)的 R 包装器,这是一个在斯图加特大学创建的包含标准神经网络 C 实现的流行软件包。
包的作者为原始软件中的许多函数添加了一个便捷的接口。使用此包的好处之一是它提供了自己的几个数据处理函数,例如将数据分割成训练集和测试集。另一个好处是它实现了许多不同类型的神经网络,而不仅仅是 MLP。我们将首先通过除以255将数据规范化到单位区间,然后指出我们的输出是一个因子,每个级别对应一个数字:
> mnist_input <- mnist_train / 255
> mnist_output <- as.factor(mnist_train_labels)
虽然 MNIST 网站已经包含了包含测试数据的单独文件,但我们选择将训练数据文件分割,因为模型已经运行了相当长的时间。鼓励读者使用提供的测试文件重复以下分析。为了准备分割数据,我们将随机打乱训练数据中的图像:
> set.seed(252)
> mnist_index <- sample(1:nrow(mnist_input), nrow(mnist_input))
> mnist_data <- mnist_input[mnist_index, 1:ncol(mnist_input)]
> mnist_out_shuffled <- mnist_output[mnist_index]
接下来,我们必须对输出因子进行虚拟编码,因为这并不是自动为我们完成的。RSNNS包中的decodeClassLabels()函数是完成这一任务的便捷方式。此外,我们将使用splitForTrainingAndTest()函数将我们的打乱数据分成 80-20 的训练和测试集分割。这将分别存储训练集和测试集的特征和标签,这将在不久的将来对我们很有用。
最后,我们还可以使用normTrainingAndTestSet()函数来规范化我们的数据。为了指定单位区间规范化,我们必须将type参数设置为0_1:
> library("RSNNS")
> mnist_out <- decodeClassLabels(mnist_out_shuffled)
> mnist_split <- splitForTrainingAndTest(mnist_data, mnist_out,
ratio = 0.2)
> mnist_norm <- normTrainingAndTestSet(mnist_split, type = "0_1")
为了比较,我们将使用mlp()函数训练两个 MLP 网络。默认情况下,这是配置为分类,并使用逻辑函数作为隐藏层神经元的激活函数。第一个模型将有一个包含 100 个神经元的单个隐藏层;第二个模型将使用 300 个。
mlp()函数的第一个参数是输入特征矩阵,第二个参数是标签向量。size参数在neuralnet包中与hidden参数扮演相同的角色。也就是说,当我们想要一个以上的隐藏层时,我们可以指定一个整数来表示单个隐藏层,或者指定一个整数向量来表示每层的隐藏神经元数量。
接下来,我们可以使用inputsTest和targetsTest参数事先指定测试集的特征和标签,这样我们就可以在一次调用中准备好观察测试集的性能。我们将训练的模型将需要数小时才能运行。如果我们想知道每个模型运行了多长时间,我们可以在训练模型之前使用proc.time()保存当前时间,并与模型完成时的时间进行比较。将所有这些放在一起,以下是我们的两个 MLP 模型是如何训练的:
> start_time <- proc.time()
> mnist_mlp <- mlp(mnist_norm$inputsTrain, mnist_norm$targetsTrain, size = 100, inputsTest = mnist_norm$inputsTest, targetsTest = mnist_norm$targetsTest)
> proc.time() - start_time
user system elapsed
2923.936 5.470 2927.415
> start_time <- proc.time()
> mnist_mlp2 <- mlp(mnist_norm$inputsTrain, mnist_norm$targetsTrain, size = 300, inputsTest = mnist_norm$inputsTest, targetsTest = mnist_norm$targetsTest)
> proc.time() - start_time
user system elapsed
7141.687 7.488 7144.433
如我们所见,模型运行时间相当长(数值以秒为单位)。为了参考,这些模型是在 2.5 GHz 英特尔酷睿 i7 苹果 MacBook Pro 上,16 GB 内存上训练的。我们的测试集上的模型预测被保存在fittedTestValues属性中(而对于我们的训练集,它们存储在fitted.values属性中)。我们将关注测试集的准确率。首先,我们必须通过选择具有最大值的二进制列来解码虚拟编码的网络输出。我们也必须对目标输出执行此操作。请注意,第一列对应数字0:
> mnist_class_test <- (0:9)[apply(mnist_norm$targetsTest, 1, which.max)]
> mlp_class_test <- (0:9)[apply(mnist_mlp$fittedTestValues, 1, which.max)]
> mlp2_class_test <- (0:9)[apply(mnist_mlp2$fittedTestValues, 1, which.max)]
现在,我们可以检查我们两个模型的准确率,如下所示:
> mean(mnist_class_test == mlp_class_test)
[1] 0.974
> mean(mnist_class_test == mlp2_class_test)
[1] 0.981
两个模型的准确率都非常高,第二个模型略优于第一个。我们可以使用confusionMatrix()函数来详细查看所犯的错误:
> confusionMatrix(mnist_class_test, mlp2_class_test)
predictions
targets 0 1 2 3 4 5 6 7 8 9
0 1226 0 0 1 1 0 1 1 3 1
1 0 1330 5 3 0 0 0 3 0 1
2 3 0 1135 3 2 1 1 5 3 0
3 0 0 6 1173 0 11 1 5 6 1
4 0 5 0 0 1143 1 5 5 0 10
5 2 2 1 12 2 1077 7 3 5 4
6 3 0 2 1 1 3 1187 0 1 0
7 0 0 7 1 3 1 0 1227 1 4
8 5 4 3 5 1 4 4 0 1110 5
9 1 0 0 6 8 5 0 11 6 1164
如预期,我们在这个矩阵中看到了相当多的对称性,因为某些数字对通常比其他数字对更难区分。例如,模型最常混淆的数字对是(3,5)。网站上可用的测试数据包含一些难以与其他数字区分的数字示例。
默认情况下,mlp()函数通过其maxint参数允许最大 100 次迭代。通常,我们不知道应该为特定模型运行多少次迭代;确定这一点的一个好方法是绘制训练和测试错误率与迭代次数的关系图。使用 RSNNS 包,我们可以使用plotIterativeError()函数来完成这项工作。
以下图表显示,对于我们的两个模型,两个错误在 30 次迭代后都达到了平台期:

受试者工作特征曲线
在第三章中,我们研究了逻辑回归作为展示二元分类器两个重要性能指标(精确率和召回率)之间权衡的重要图表的例子。在本章中,我们将介绍另一个相关且常用的图表来展示二元分类性能,即受试者工作特征(ROC)曲线。
此曲线是在 y 轴上的真正例率和 x 轴上的假正例率之间的一个图。正如我们所知,真正例率只是召回率,或者说是一个二元分类器的灵敏度。假正例率是 1 减去特异性。一个随机的二元分类器将具有与假正例率相同的真正例率,因此,在 ROC 曲线上,y = x线表示随机分类器的性能。任何位于此线之上的曲线都将比随机分类器表现更好。
一个完美的分类器将显示出从原点到点(0,1)的曲线,这对应于 100%的真正率和 0%的假正率。我们经常将ROC 曲线下的面积(ROC AUC)作为一个性能指标。随机分类器下的面积仅为 0.5,因为我们正在计算单位正方形上直线y = x下的面积。按照惯例,完美分类器下的面积为 1,因为曲线通过点(0,1)。在实践中,我们获得介于这两个值之间的值。对于我们的 MNIST 数字分类器,我们有一个多类问题,但我们可以使用 RSNNS 包的plotROC()函数来研究我们的分类器在单个数字上的性能。
以下图表显示了数字 1 的 ROC 曲线,几乎完美:

径向基函数网络
基于函数逼近概念的径向基函数网络是一种使用径向基函数来定义节点输出(给定一组输入)的人工神经网络。网络的输出由输入和神经元参数的径向基函数的线性组合组成。
径向基函数(RBF)网络(也称为 RBFNN,即径向基函数神经网络)将具有三个独立的层:一个输入层、一个隐藏层和一个线性输出层。输入层将是一组节点,它们将输入值传递到第二层(或隐藏层),在那里应用激活模式。这些模式将是最佳拟合应用或目标的径向基函数。这种转换以非线性方式进行。第三层(或输出层)提供网络对输入激活或 RFB 函数的响应。在 RFB 网络中,从隐藏层到输出层的转换是非线性的。
径向基函数网络是一种神经网络,通常将其设计视为高维空间中的曲线拟合(猜测)问题。学习等同于找到一个多维度函数,该函数为训练数据提供最佳拟合,最佳拟合的准则以某种统计意义来衡量。
通常,RBF 网络似乎具有易于理解的设计、泛化能力和良好的对数据“噪声”容忍度的记录。
RBF 网络(径向基函数网络)的特性使其成为设计需要非常灵活的控制系统的理想选择,因为这些系统必须不断评估各种完成路径并确定最有效的路径。在 RBF 网络应用中最著名的研究是解决旅行商问题(在一系列城市之间找到最短闭合路径)。
摘要
在本章中,我们将神经网络视为一种非线性方法,能够解决回归和分类问题。受人类神经元生物类比启发,我们首先介绍了最简单的神经网络——感知器。只有当两个类别线性可分时,感知器才能解决二元分类问题,这在实际应用中是非常少见的。
通过改变转换输入线性加权组合的函数,即激活函数,我们发现如何创建不同类型的单个神经元。线性激活函数创建一个执行线性回归的神经元,而逻辑激活函数创建一个执行逻辑回归的神经元。通过组织和连接神经元到层中,我们可以创建多层神经网络,这些网络是解决非线性问题的强大模型。
隐藏层神经元背后的思想是,每个隐藏层都会从其输入中学习一组新的特征。作为最常见类型的多层神经网络,我们介绍了多层感知器,并看到它可以自然地使用相同的网络学习多个输出。此外,我们还对回归和分类任务的真实世界数据集进行了实验,包括一个多类分类问题,我们看到了它也是自然处理的。R 语言有多个用于实现神经网络的包,包括neuralnet、nnet和RSNNS,我们逐一尝试了这些包。每个包都有其各自的优缺点,并没有一个在所有情况下都是明确的赢家。
与神经网络一起工作的一个重要好处是,它们在解决回归和分类的复杂非线性问题时非常强大,而不需要对输入特征之间的关系做出任何重大假设。另一方面,神经网络通常很难训练。缩放输入特征很重要。同时,了解影响模型收敛的各种参数也很重要,例如学习率和误差梯度容差。另一个需要做出的关键决定是隐藏层神经元的数量和分布。随着网络复杂度、输入特征数量或训练数据大小的增加,与其它监督学习方法相比,训练时间通常会变得相当长。
在我们的回归示例中,我们也看到,由于神经网络的灵活性和强大功能,它们可能会过度拟合数据,从而高估模型的准确性。为了在一定程度上缓解这个问题,存在正则化方法,例如权重衰减。最后,一个值得注意的明显缺点是,神经权重没有直接的解释,与回归系数不同。即使神经网络拓扑可能学习到特征,这些特征也难以解释或理解。
我们下一章将继续探索监督学习的世界,并介绍支持向量机,这是我们第三个非线性建模工具,主要用于处理分类问题。
第六章:支持向量机
在本章中,我们将通过介绍支持向量机来重新审视非线性预测模型。支持向量机,通常缩写为 SVMs,在分类问题中非常常用,尽管当然有方法使用它们进行函数逼近和回归任务。在本章中,我们将重点关注它们在分类中更典型的角色。为此,我们首先将介绍最大间隔分类的概念,它提出了如何在许多可能的分类边界之间进行选择的另一种公式化方法,并且与迄今为止我们所看到的方法不同,例如最大似然。我们将介绍相关的支持向量概念以及如何,与最大间隔分类一起,我们可以获得一个以支持向量分类器形式存在的线性模型。最后,我们将展示如何通过使用某些称为核的函数来引入非线性,最终达到我们的目的地,即支持向量机。
最大间隔分类
我们将从这个章节开始,回到现在应该非常熟悉的情况:二元分类任务。再一次,我们将思考如何设计一个模型来正确预测一个观察值属于两个可能类别之一的问题。我们已经看到,当两个类别是线性可分的时候,这个任务是最简单的;也就是说,当我们可以在我们的特征空间中找到一个分离超平面(一个多维空间中的平面)时,超平面一侧的所有观察值属于一个类别,而位于另一侧的所有观察值属于第二个类别。根据我们特定模型使用的结构、假设和优化标准,我们可能会得到无限多个这样的超平面。
让我们使用二维特征空间中的某些数据来可视化这个场景,其中分离超平面仅仅是一条分离线:

在前面的图中,我们可以看到两个属于不同类别的观察值簇。我们使用了不同的符号来明确表示这一点。接下来,我们展示了三条可以作为分类器决策边界的不同线,所有这些线在整个数据集上都会产生 100%的分类准确率。我们将提醒自己,超平面的方程可以表示为输入特征的线性组合,这些特征是超平面所在空间中的维度:

分离超平面具有以下属性:

第一个方程简单地说明属于类别 1 的数据点都位于超平面之上,第二个方程说明属于类别 -1 的数据点都位于超平面之下。下标 i 用于索引观察,下标 k 用于索引特征,因此 x[ik] 表示第 i 个观察的第 k 个特征的值。为了简化,我们可以将这两个方程合并为一个方程,如下所示:

为了理解这种简化的原因,考虑对类别 -1 的观察(y[i] = -1*)。这个观察将位于分离超平面下方,因此括号中的线性组合将产生一个负值。将其与其 y[i] 的 -1 值相乘,结果为正值。对于类别 1 的观察,有类似的论点。
回顾我们的图,注意两条虚线与某些观察相当接近。直观上,实线作为决策边界比其他两条线更好,因为它在它们之间的空间中心穿越,将两个类别分开,而不靠近任何一个类别。这样,它在两个类别之间平均分配空间。我们可以定义一个称为 间隔 的量,它是一个特定分离超平面产生的,即从数据集中任何点到超平面的最小垂直距离。在二维和两个类别的情况下,我们总是至少有两个点,它们与分离线的垂直距离等于间隔,一个在线的每一侧。有时,正如我们的数据那样,我们可能有超过两个点,它们与分离线的垂直距离等于间隔。
下一个图显示了前一个图中实线的间隔,表明我们有三个点与这个分离线的间隔相等:

现在我们已经掌握了间隔的定义,我们就有了一种方法来编码我们选择实线作为三个线中较好决策边界的直觉。我们可以更进一步,定义 最大间隔超平面 为所有可能的分离超平面中间隔最大的超平面。在我们的二维例子中,我们实际上是在寻找一条线,它将两个类别分开,同时尽可能远离观察点。结果证明,我们例子中的实线实际上是最大间隔线,因此没有其他线可以画出比两个单位更高的间隔。这解释了为什么我们在第一个图中将其标记为最大间隔分离线。
为了理解我们如何在简单示例中找到最大间隔超平面,我们需要使用以下方程将问题形式化为一个具有p个特征的优化问题:

这两个约束条件共同表达了我们的优化问题中的想法,即我们的数据中的观察点不仅需要被正确分类,而且至少需要位于分离超平面至少M个单位之外。目标是通过对系数β[i]进行适当的选取来最大化这个距离M。因此,我们需要一个处理这类问题的优化过程。优化实际上如何在实践中实现的具体细节超出了本书的范围,但当我们用 R 进行编程时,我们将在后面看到它们是如何发挥作用的。
我们现在有一个自然的前进方式,那就是开始研究当我们的数据不是线性可分时,情况会如何变化,我们知道这是现实世界数据集的典型场景。在这样做之前,让我们退一步。我们已经研究了两种估计模型参数的方法:即最大似然估计和线性回归的最小二乘误差标准。例如,当我们研究逻辑回归的分类时,我们考虑了最大化我们数据似然的想法。这考虑了所有可用的数据点。在用多层感知器进行分类时也是如此。然而,对于最大间隔分类器,我们的决策边界的构建只由位于边缘的点支持。换句话说,在我们的二维示例中,我们可以自由调整除边缘上的三个点外的任何观察点的位置,只要调整不会导致观察点落在边缘内,我们的分离线将保持在完全相同的位置。因此,我们将从位于边缘的点到分离超平面的垂直向量定义为支持向量。因此,我们已经看到我们的二维示例有三个支持向量。只有数据集中所有点的子集实际上决定了分离超平面的位置这一事实意味着我们有过度拟合训练数据的潜力。
另一方面,这种方法确实产生了一些很好的性质。我们在两个类别之间平等地分割空间,而不对任何一个类别施加任何偏差。显然位于特定类别占据的空间内的点的点在模型中的作用不如边缘上的点大,这是我们放置决策边界的区域。
支持向量分类
我们需要我们的数据是线性可分的,以便使用最大边界分类器对其进行分类。当我们的数据不是线性可分时,我们仍然可以使用定义边界的支持向量的概念,但这次,我们将允许一些示例被误分类。因此,我们本质上定义了一个软边界,即我们数据集中的某些观测可能违反了它们需要至少与分离超平面保持一定距离的约束。同样重要的是要注意,有时我们可能即使在数据是线性可分的情况下也想使用软边界。这样做的原因是为了限制数据过度拟合的程度。请注意,边界越大,我们对正确分类新观测的信心就越大,因为在我们训练数据中,类别彼此之间的距离越远。如果我们使用非常小的边界实现分离,我们对正确分类数据的信心就会降低,我们可能更愿意允许一些错误,并提出一个更大的、更稳健的边界。研究以下图表:

为了更牢固地掌握为什么即使是对于线性可分的数据,软边界可能比硬边界更可取的原因,我们稍微改变了我们的数据。我们使用了之前相同的数据,但我们在类别 1 中添加了一个额外的观测点,并将其放置在类别-1 的边界附近。请注意,随着这个单一新数据点的添加,特征值 f1=16 和 f2=40,我们的最大边界线发生了巨大变化!边界从两个单位减少到 0.29 单位。看着这张图,我们可能会觉得这个新点可能是我们的数据集中的异常值或误标记。如果我们允许我们的模型使用软边界进行一次误分类,我们会回到我们之前的线,这条线以更宽的边界分隔两个类别,并且不太可能对数据进行过度拟合。我们通过修改我们的优化问题设置来形式化我们的软分类器的概念:

在这个新的设置下,我们引入了一组新的变量 ξi,被称为松弛变量。对于我们的数据集中的每一个观测值,都有一个松弛变量,而 ξi 松弛变量的值取决于第 i 个观测值相对于边界的位置。当一个观测值位于分离超平面的正确一侧且在边界之外时,该观测值的松弛变量取值为 0。这是我们对于所有观测值在硬边界下看到的最理想的情况。当一个观测值被正确分类但落在边界内的一定距离处时,相应的松弛变量取一个小于 1 的小正数。当一个观测值实际上被错误分类,因此完全落在超平面的错误一侧时,其关联的松弛变量取值大于 1。总的来说,看看以下内容:

当一个观测值被错误分类时,松弛变量的幅度与该观测值与分离超平面边界的距离成正比。由于松弛变量的总和必须小于一个常数 C,我们可以将这个常数视为我们准备容忍的错误预算。由于单个特定观测值的错误分类会导致松弛变量至少取值为 1,而我们的常数 C 是所有松弛变量的总和,将 C 的值设为小于 1 意味着我们的模型将容忍一些观测值落在边界内,但不会出现错误分类。C 的值较高通常会导致许多观测值要么落在边界内,要么被错误分类,而这些都是支持向量,我们最终会有更多的支持向量。这导致了一个具有较低方差但因为我们已经通过增加对边界违规和错误的容忍度而改变了边界,我们可能会有更高的偏差。相比之下,由于模型(因此 C 的值较低)非常严格而导致的支持向量数量减少,可能会在我们的模型中产生较低的偏差。然而,这些支持向量将分别以更高的程度影响我们边界的位置。因此,我们将在不同的训练集上经历模型性能的更高方差。再次强调,模型偏差和方差之间的相互作用再次出现在我们作为预测模型制定者必须做出的设计决策中。
内积
支持向量机模型参数计算的详细过程超出了本书的范围。然而,结果表明,该模型本身可以被简化为一个更方便的形式,该形式使用观测值的内积。两个长度相同的向量 v1 和 v2 的内积是通过首先计算两个向量的逐元素乘积,然后取这些结果的和来计算的。在 R 中,我们只需使用乘号即可获得两个向量的逐元素乘积。因此,我们可以按以下方式计算两个向量的内积:
> v1 <- c(1.2, 3.3, -5.6, 4.5, 0, 9.0)
> v2 <- c(-3.5, 0.1, -0.2, 1.0, -8.7, 0)
> v1 * v2
[1] -4.20 0.33 1.12 4.50 0.00 0.00
>inner_product<- sum(v1 * v2)
>inner_product
[1] 1.75
从数学的角度来看,我们使用三角括号来表示内积运算,并按以下方式表示这个过程:

在前面的方程中,对于两个向量 v[1] 和 v[2],索引 i 正在遍历 p 个特征或维度。现在,这是我们的支持向量机分类器的原始形式:

这只是输入特征线性组合的标准方程。结果表明,对于支持向量机,模型解可以用我们试图分类的 x 观测值与其他所有训练数据集中的 xi 观测值之间的内积来表示。更具体地说,我们的支持向量机的形式也可以写成:

对于这个方程,我们明确指出,我们的模型将 y 预测为输入观测值 x 的函数。求和函数现在计算当前观测值与数据集中每个其他观测值的内积的加权和,这就是为什么我们现在要对 n 个观测值进行求和。我们想非常清楚地说明,我们没有改变原始模型本身;我们只是写了同一模型的两种不同表示。请注意,我们不能假设线性模型在一般情况下都采取这种形式;这仅适用于支持向量机。现在,在现实世界的场景中,我们数据集中观测值的数量 n 通常远大于参数的数量 p,因此 α 系数的数量似乎比 β 系数的数量大。
此外,虽然在第一个方程中我们是独立考虑每个观测值,但第二个方程的形式表明,为了分类所有观测值,我们需要考虑所有可能的成对组合并计算它们的内积。这样的成对组合有 n2 个,这似乎是在引入复杂性而不是产生一个更简单的表示。然而,实际上,在我们的数据集中,除了支持向量之外,所有 α 系数都是零。
在我们的数据集中,支持向量的数量通常远小于总观察数量。因此,我们可以通过明确显示我们在数据集中的支持向量集合 S 上求和来简化我们的新表示:

核和支持向量机
到目前为止,我们介绍了在线性可分条件下最大间隔分类的概念及其扩展到支持向量分类器,它仍然使用超平面作为分离边界,但通过指定容错预算来处理非线性可分的数据集。位于或位于间隔内,或被支持向量分类器错误分类的观察值是支持向量。这些在决策边界定位中发挥的关键作用也在使用内积的替代模型表示支持向量分类器中得到了体现。
在本章中我们迄今为止看到的情况中,共同点是我们的模型在输入特征方面总是线性的。我们已经看到,创建实现非线性边界的模型的能力,在处理不同类型的潜在目标函数方面要灵活得多。在我们的模型中引入非线性的一种方法是对这个结果应用非线性变换。我们可以定义一个通用函数 K,我们将它称为核函数,它作用于两个向量并产生一个标量结果。这允许我们如下泛化我们的模型:

我们现在的模型具有与支持向量一样多的特征,每个特征都被定义为核函数作用于当前观察结果和其中一个支持向量的结果。对于支持向量机分类器,我们应用的核函数被称为线性核,因为这仅仅使用内积本身,产生一个线性模型。

核函数也被称为相似度函数,因为我们可以将它们产生的输出视为两个输入向量之间相似度的度量。我们使用非线性核在我们的模型中引入非线性,当我们这样做时,我们的模型被称为支持向量机。有几种不同类型的非线性核。最常见的是多项式核和径向基函数核。多项式核使用两个向量之间内积的幂展开。对于度数为 d 的多项式,多项式核的形式如下:

使用这个核函数,我们实际上是将我们的特征空间转换到了一个更高维的空间。计算应用于内积的核函数比首先将所有特征转换到高维空间,然后尝试在那个空间中拟合线性模型要高效得多。这在我们使用径向基函数核时尤其正确,通常简称为径向核,因为由于展开中的项数无限,转换后的特征空间的维度实际上是无限的。径向核的形式是:

仔细观察后,我们应该能够发现径向核不使用两个向量之间的内积。相反,指数中的求和只是这两个向量之间欧几里得距离的平方。径向核通常被称为局部核,因为当两个输入向量之间的欧几里得距离很大时,由于指数中的负号,核计算出的结果非常小。因此,当我们使用径向核时,只有接近当前观察值的向量在计算中起重要作用。我们现在已经准备好使用一些真实世界的数据集来实践所有这些内容。
预测化学物质生物降解
在本节中,我们将使用 R 语言的e1071包,在一个真实世界的数据集上尝试我们讨论过的模型。作为第一个例子,我们选择了QSARbiodegration 数据集,可以在archive.ics.uci.edu/ml/datasets/QSAR+biodegradation找到。这是一个包含 41 个数值变量,描述了 1,055 种化学物质的分子组成和性质的数据集。建模任务是预测特定化学物质是否可生物降解,基于这些性质。示例性质包括碳、氮、氧原子的百分比,以及分子中的重原子数量。这些特征非常专业且数量充足,因此这里不会给出完整的列表。涉及到的完整列表和更多细节可以在网站上找到。目前,我们已经将数据下载到了一个bdf数据框中:
>bdf<- read.table("biodeg.csv", sep = ";", quote = "\"")
> head(bdf, n = 3)
V1 V2 V3 V4 V5 V6 V7 V8 V9 V10 V11 V12 V13
1 3.919 2.6909 0 0 0 0 0 31.4 2 0 0 0 3.106
2 4.170 2.1144 0 0 0 0 0 30.8 1 1 0 0 2.461
3 3.932 3.2512 0 0 0 0 0 26.7 2 4 0 0 3.279
V14 V15 V16 V17 V18 V19 V20 V21 V22 V23 V24
1 2.550 9.002 0 0.960 1.142 0 0 0 1.201 0 0
2 1.393 8.723 1 0.989 1.144 0 0 0 1.104 1 0
3 2.585 9.110 0 1.009 1.152 0 0 0 1.092 0 0
V25 V26 V27 V28 V29 V30 V31 V32 V33 V34 V35
1 0 0 1.932 0.011 0 0 4.489 0 0 0 0
2 0 0 2.214 -0.204 0 0 1.542 0 0 0 0
3 0 0 1.942 -0.008 0 0 4.891 0 0 0 1
V36 V37 V38 V39 V40 V41 V42
1 2.949 1.591 0 7.253 0 0 RB
2 3.315 1.967 0 7.257 0 0 RB
3 3.076 2.417 0 7.601 0 0 RB
最后一个列,V42,包含输出变量,对于不可生物降解的化学物质取值为NRB,对于可生物降解的化学物质取值为RB。我们将将其重新编码为熟悉的标签0和1:
> levels(bdf$V42) <- c(0, 1)
现在我们已经准备好了数据,我们将像往常一样,将它们分为训练集和测试集,比例为 80-20:
> library(caret)
>set.seed(23419002)
>bdf_sampling_vector<- createDataPartition(bdf$V42, p = 0.80,
list = FALSE)
>bdf_train<- bdf[bdf_sampling_vector,]
>bdf_test<- bdf[-bdf_sampling_vector,]
在 R 中,有多个包实现了支持向量机。在本章中,我们将探讨使用e1071包,它为我们提供了svm()函数。如果我们检查我们的训练数据,我们会很快注意到一方面,各种特征的比例相差很大,另一方面,许多特征是稀疏特征,这意味着对于许多条目,它们取零值。在神经网络中我们这样做,将特征进行缩放是一个好主意,尤其是如果我们想使用径向核。幸运的是,svm()函数有一个scale参数,默认设置为TRUE。在模型训练之前,这个参数将标准化输入特征,使它们具有零均值和单位方差。这避免了我们需要手动执行此预处理步骤的需要。我们将要研究的第一个模型将使用线性核:
> library(e1071)
>model_lin<- svm(V42 ~ ., data = bdf_train, kernel = "linear", cost = 10)
调用svm()函数遵循熟悉的范式,首先提供一个公式,然后提供数据框的名称,最后提供与模型相关的其他参数。在我们的情况下,我们想要训练一个模型,其中最终的V42列是预测列,所有其他列都用作特征。因此,我们可以只使用简单的公式V42 ~,而不是必须完全列出所有其他列。指定我们的数据框后,我们再指定我们将使用的核的类型,在这种情况下,我们选择了线性核。我们还将指定cost参数的值,这与我们的模型中的错误预算 C 相关:
>model_lin
Call:
svm(formula = V42 ~ ., data = biodeg_training2, kernel = "linear", cost = 10)
Parameters:
SVM-Type: C-classification
SVM-Kernel: linear
cost: 10
gamma: 0.02439024
Number of Support Vectors: 272
我们的模型没有提供太多关于其性能的信息,除了我们指定的参数细节。一个有趣的信息是,在我们的模型中作为支持向量的数据点的数量;在这种情况下,272。然而,如果我们使用str()函数来检查拟合模型的架构,我们会发现它包含许多有用的属性。例如,拟合属性包含模型对训练数据的预测。我们将使用这些预测来评估模型拟合的质量,通过计算训练数据的准确率和混淆矩阵:
> mean(bdf_train[,42] == model_lin$fitted)
[1] 0.8887574
> table(actual = bdf_train[,42], predictions = model_lin$fitted)
predictions
actual 0 1
0 519 41
1 53 232
我们的训练准确率略低于 89%,这是一个不错的开始。接下来,我们将使用predict()函数检查测试数据的性能,看看我们是否能得到接近这个准确率的测试准确率,或者我们是否最终过度拟合了数据:
>test_predictions<- predict(model_lin, bdf_test[,1:41])
> mean(bdf_test[,42] == test_predictions)
[1] 0.8619048
我们确实比预期的测试准确率略低,但与我们在之前训练中获得的准确率足够接近,因此我们可以相对有信心地认为我们并没有过度拟合训练数据。现在,我们已经看到cost参数在我们的模型中起着重要作用,选择这个参数涉及到模型偏差和方差的权衡。因此,在确定最终模型之前,我们想要尝试cost参数的不同值。在手动重复前述代码的几个参数值之后,我们得到了以下结果集:
>linearPerformances
0.01 0.1 1 10 100 1000
training 0.858 0.888 0.883 0.889 0.886 0.886
test 0.886 0.876 0.876 0.862 0.862 0.862
小贴士
有时候,在构建模型时,我们可能会看到一个警告,告知我们已达到最大迭代次数。如果发生这种情况,我们应该对我们的模型持怀疑态度,因为这可能是没有找到解决方案并且优化过程没有收敛的迹象。在这种情况下,最好是尝试不同的cost值和/或核类型。
这些结果表明,对于cost参数的大多数值,我们在训练数据上看到的是非常相似的质量拟合水平,大约 88%。具有讽刺意味的是,在测试数据上获得最佳性能的是在训练数据拟合最差的模型,使用了 0.01 的成本。简而言之,尽管我们在训练和测试数据集上都有合理的性能,但表格中显示的结果的低方差实际上告诉我们,通过调整这个特定数据集上的cost参数,我们不太可能显著提高拟合质量。
现在,让我们尝试使用径向核来查看是否引入一些非线性可以让我们提高性能。当我们指定径向核时,我们还必须指定一个正的gamma参数。这对应于径向核方程中的1/2σ2参数。这个参数所起的作用是控制其两个向量输入之间的相似度计算的局部性。大的gamma值意味着核将产生接近零的值,除非两个向量非常接近。较小的gamma值会导致核更加平滑,并考虑距离较远的向量对。同样,这个选择归结为偏差和方差的权衡,所以就像cost参数一样,我们不得不尝试gamma的不同值。现在,让我们看看如何使用特定配置的径向核创建支持向量机模型:
>model_radial<- svm(V42 ~ ., data = bdf_train, kernel = "radial",
cost = 10, gamma = 0.5)
> mean(bdf_train[,42] == model_radial$fitted)
[1] 0.9964497
>test_predictions<- predict(model_radial, bdf_test[,1:41])
> mean(bdf_test[,42] == test_predictions)
[1] 0.8047619
注意,在这些设置下,径向核能够更紧密地拟合训练数据,这从几乎 100%的训练准确率中可以看出;但是当我们看到测试数据集上的性能时,结果实际上比我们在训练数据上获得的结果要低得多。因此,我们有一个非常明确的迹象表明,这个模型正在过度拟合数据。为了解决这个问题,我们将手动尝试调整gamma和cost参数的几个不同设置,看看我们是否可以提高拟合度:
>radialPerformances
[,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9]
cost 0.01 0.1 1 10 100 0.01 0.1 1 10
gamma 0.01 0.01 0.01 0.01 0.01 0.05 0.05 0.05 0.05
training 0.663 0.824 0.88 0.916 0.951 0.663 0.841 0.918 0.964
test 0.662 0.871 0.89 0.89 0.886 0.662 0.848 0.89 0.89
[,10] [,11] [,12] [,13] [,14] [,15] [,16] [,17]
cost 100 0.01 0.1 1 10 100 0.01 0.1
gamma 0.05 0.1 0.1 0.1 0.1 0.1 0.5 0.5
training 0.989 0.663 0.815 0.937 0.985 0.995 0.663 0.663
test 0.838 0.662 0.795 0.886 0.867 0.824 0.662 0.662
[,18] [,19] [,20] [,21] [,22] [,23] [,24] [,25]
cost 1 10 100 0.01 0.1 1 10 100
gamma 0.5 0.5 0.5 1 1 1 1 1
training 0.98 0.996 0.998 0.663 0.663 0.991 0.996 0.999
test 0.79 0.805 0.805 0.662 0.662 0.748 0.757 0.757
如我们所见,这两个参数cost和gamma的组合,使用径向核可以产生更广泛的结果。从我们之前构建的数据框中,我们可以看到一些组合,例如cost = 1和gamma = 0.05,将我们的测试数据准确率提高到 89%,同时仍然在训练数据上保持类似的表现。此外,在数据框中,我们看到许多设置,其中训练准确率几乎达到 100%,但测试准确率却远低于这个水平。
因此,我们得出结论,在使用非线性核,如径向核时,需要谨慎,以避免过度拟合。尽管如此,径向核非常强大,在建模高度非线性决策边界时可以非常有效,通常允许我们比线性核实现更高的分类准确率。在我们分析的这个阶段,我们通常会希望确定cost和gamma参数的特定值,然后使用可用的全部数据重新训练我们的模型,在现实世界中部署之前。
不幸的是,在用测试集指导我们决定使用哪些参数之后,它就不再代表一个未知的测试数据集,这将使我们能够预测模型在现实世界中的准确率。解决这个问题的可能方法之一是使用验证集,但这将需要我们留出一部分数据,从而导致训练集和测试集的大小减小。
交叉验证,我们在第二章、整理数据和衡量性能中讨论过,应被视为解决这一困境的实用方法。
注意
一本关于支持向量机的非常易读的书籍是 Nello Christiani 和 John Shawe-Taylor 合著的《支持向量机及其核学习方法导论》。另一个很好的参考资料是 Simon Haykin 的《神经网络与学习机器》,它展示了 SVMs 与一种称为径向基函数网络的相关神经网络之间的洞察力链接,我们也在第五章、神经网络中引用了它。
预测信用评分
在本节中,我们将探讨另一个数据集,这次是在银行和金融领域。具体的数据集被称为德国信用数据集,并由 UCI 机器学习存储库托管。数据的链接是archive.ics.uci.edu/ml/datasets/Statlog+%28German+Credit+Data%29。
数据集中的观测值是银行个人提交的贷款申请。数据的目标是确定一个申请是否构成高信用风险。
| 列名 | 类型 | 定义 |
|---|---|---|
checking |
分类 | 现有支票账户的状态 |
duration |
数值 | 持续时间(以月为单位) |
creditHistory |
分类 | 申请人的信用历史 |
purpose |
分类 | 贷款目的 |
credit |
数值 | 信用额度 |
savings |
分类 | 储蓄账户/债券 |
employment |
分类 | 自从现在起有现职 |
installmentRate |
数值 | 分期付款率(作为可支配收入的百分比) |
personal |
分类 | 个人状况和性别 |
debtors |
分类 | 其他债务人/担保人 |
presentResidence |
数值 | 现居住地时间 |
property |
分类 | 财产类型 |
age |
数值 | 申请人的年龄(以年为单位) |
otherPlans |
分类 | 其他分期付款计划 |
housing |
分类 | 申请人的住房状况 |
existingBankCredits |
数值 | 在这家银行现有的信用数量 |
job |
分类 | 申请人的工作状况 |
dependents |
数值 | 受抚养人数 |
telephone |
分类 | 申请人的电话状态 |
foreign |
分类 | 外籍工人 |
risk |
二进制 | 信用风险(1 = 好,2 = 差) |
首先,我们将数据加载到名为german_raw的数据框中,并为其提供与上一表格匹配的列名:
>german_raw<- read.table("german.data", quote = "\"")
> names(german_raw) <- c("checking", "duration", "creditHistory", "purpose", "credit", "savings", "employment", "installmentRate", "personal", "debtors", "presentResidence", "property", "age", "otherPlans", "housing", "existingBankCredits", "job", "dependents", "telephone", "foreign", "risk")
表格中的注释说明了我们有很多分类特征需要处理。因此,我们将再次使用dummyVars()来为这些特征创建虚拟的二进制变量。此外,我们将risk变量,我们的输出,记录为一个因子,其中 0 级表示良好的信用,1 级表示不良的信用:
> library(caret)
> dummies <- dummyVars(risk ~ ., data = german_raw)
>german<- data.frame(predict(dummies, newdata = german_raw),
risk = factor((german_raw$risk - 1)))
> dim(german)
[1] 1000 62
经过此处理,我们现在有一个包含 61 个特征的数据框,因为几个分类输入特征有很多级别。接下来,我们将数据分为训练集和测试集:
>set.seed(977)
>german_sampling_vector<- createDataPartition(german$risk,
p = 0.80, list = FALSE)
>german_train<- german[german_sampling_vector,]
>german_test<- german[-german_sampling_vector,]
该数据集的一个特定之处,在网站上提到的是,数据来自一个两种不同类型的错误具有不同成本的场景。具体来说,将高风险客户错误分类为低风险客户的成本,对于银行来说比将低风险客户错误分类为高风险客户的成本高出五倍。这是可以理解的,因为在第一种情况下,银行可能会从无法偿还的贷款中损失大量资金,而在第二种情况下,银行会错过发放能够为银行带来利息的贷款的机会。
svm() 函数有一个 class.weights 参数,我们用它来指定将观察值错误分类到每个类的成本。这就是我们将非对称错误成本信息纳入模型的方式。首先,我们将创建一个类权重向量,注意我们需要指定与输出因子水平相对应的名称。然后,我们将使用 tune() 函数训练具有径向核的各种 SVM 模型:
>class_weights<- c(1, 5)
> names(class_weights) <- c("0", "1")
>class_weights
0 1
1 5
>set.seed(2423)
>german_radial_tune<- tune(svm,risk ~ ., data = german_train,
kernel = "radial", ranges = list(cost = c(0.01, 0.1, 1, 10, 100),
gamma = c(0.01, 0.05, 0.1, 0.5, 1)), class.weights = class_weights)
>german_radial_tune$best.parameters
cost gamma
9 10 0.05
>german_radial_tune$best.performance
[1] 0.26
建议的最佳模型具有 cost = 10 和 gamma = 0.05,并在训练中达到 74%的准确率。让我们看看这个模型在我们的测试数据集上的表现:
>german_model<- german_radial_tune$best.model
>test_predictions<- predict(german_model, german_test[,1:61])
> mean(test_predictions == german_test[,62])
[1] 0.735
> table(predicted = test_predictions, actual = german_test[,62])
actual
predicted 0 1
0 134 47
1 6 13
在我们的测试集上的性能是 73.5%,非常接近我们在训练中看到的结果。正如预期的那样,我们的模型倾向于犯更多的错误,将低风险客户错误分类为高风险客户。可以预见,这会对整体分类准确率产生负面影响,因为整体分类准确率只是正确分类的观察值与总观察值之比。实际上,如果我们消除这种成本不平衡,我们实际上会选择一组不同的模型参数,并且从无偏分类准确率的角度来看,我们的性能会更好:
>set.seed(2423)
>german_radial_tune_unbiased<- tune(svm,risk ~ .,
data = german_train, kernel = "radial", ranges = list(
cost = c(0.01, 0.1, 1, 10, 100), gamma = c(0.01, 0.05, 0.1, 0.5, 1)))
>german_radial_tune_unbiased$best.parameters
cost gamma
3 1 0.01
>german_radial_tune_unbiased$best.performance
[1] 0.23875
当然,这个最后的模型可能会犯更多的代价高昂的错误,将高风险客户错误分类为低风险客户,这是我们知道的非常不希望看到的。我们将以两个最后的想法来结束本节。首先,我们为 gamma 和 cost 参数使用了相对较小的范围。读者可以将这两个参数的值范围扩大,重新运行我们的分析,以查看我们是否可以获得更好的性能。然而,这必然会导致更长的训练时间。其次,这个特定的数据集相当具有挑战性,因为其基线准确率实际上是 70%。这是因为数据中的 70%的客户是低风险客户(两个输出类别不平衡)。因此,计算我们在第一章中看到的 Kappa 统计量,为预测建模做准备,可能是一个更好的指标,而不是分类准确率。
使用支持向量机进行多类分类
就像逻辑回归一样,我们看到了支持向量机背后的基本前提是它被设计来处理两类。当然,我们经常遇到我们希望能够处理更多类的情况,例如根据各种物理特征对不同的植物物种进行分类。一种方法是一对多的方法。在这里,如果我们有K个类别,我们创建K个 SVM 分类器,并且对于每个分类器,我们试图将一个特定的类别与所有其他类别区分开来。
为了确定最佳类别,我们分配给观察结果产生与分离超平面最大距离的类别,即离所有其他类别最远的类别。更正式地说,我们选择我们的线性特征组合在所有不同分类器中具有最大值的类别。
另一种方法被称为(平衡的)一对多方法。我们为所有可能的输出类别对创建一个分类器。然后,我们用每个这样的分类器对每个观察结果进行分类,并统计每个获胜类别的总数。最后,我们选择获得最多票数的类别。这种后一种方法实际上是e1071包中的svm()函数所实现的。因此,当我们有多个类的问题时,我们可以使用这个函数。
摘要
在本章中,我们介绍了最大间隔超平面作为决策边界,它是通过找到与两个类别之一的最大距离来设计用来分离两个类别的。当两个类别是线性可分时,这创造了一个两个类别之间的空间均匀分割的情况。
我们看到,在某些情况下,这并不总是理想的,例如当类别由于一些观察结果而彼此接近时。这种方法的改进是支持向量分类器,它允许我们容忍一些边界违规,甚至误分类,以获得更稳定的结果。这也允许我们处理非线性可分的类别。支持向量分类器的形式可以用被分类的观察结果和支持向量之间的内积来表示。这把我们的特征空间从p个特征转换为我们有支持向量的那么多特征。使用这些新特征上的核函数,我们可以在模型中引入非线性,从而获得支持向量机。
在实践中,我们发现训练一个支持向量分类器,这是一个具有线性核的支持向量机,涉及到调整成本参数。我们在训练数据上获得的表现可以接近我们在测试数据上获得的表现。相比之下,我们发现使用径向核,我们有可能使我们的训练数据拟合得更紧密,但我们更有可能陷入过拟合的陷阱。
为了应对这个问题,尝试不同的cost和gamma参数组合是有用的。
在下一章中,我们将探讨机器学习的另一个基石:基于树的模型。也称为决策树,它们可以处理具有许多类别的回归和分类问题,具有高度的可解释性,并且内置了处理缺失数据的方式。
第七章:树方法
在本章中,我们将介绍创建预测模型最直观的方法之一——使用树的概念。基于树的模型,通常也称为决策树模型,成功地用于处理回归和分类类型的问题。我们将在本章中探讨这两种场景,并查看一系列在训练这些模型方面有效的不同算法。我们还将了解这些模型所具有的一些有用特性,例如它们处理缺失数据的能力以及它们的高度可解释性。
树模型的直观理解
决策树是一种结构非常直观的模型,它允许我们根据一系列以树状结构排列的规则,对输出变量进行预测。我们可以建模的输出变量可以是分类的,这样我们就可以使用决策树来处理分类问题。同样,我们也可以使用决策树来预测数值输出,这样我们也将能够解决预测任务为回归任务的问题。
决策树由一系列称为节点的分割点组成。为了使用决策树进行预测,我们从树顶的单一节点开始,这个节点被称为根节点。根节点是一个决策或分割点,因为它根据输入特征中的一个特征值提出条件,基于这个决策我们知道是继续树的左部分还是右部分。我们在遇到的每个内部节点上重复选择向左或向右的过程,直到我们达到一个叶节点。这些是树的底部节点,它们给出了输出变量的特定值,作为我们的预测使用。
为了说明这一点,让我们看看一个由两个特征x1和x2组成的非常简单的决策树。

注意,树是一个递归结构,因为位于特定节点下的树的左右部分本身也是树。它们分别被称为左子树和右子树,它们所指向的节点分别是左孩子和右孩子。为了理解我们在实践中如何使用决策树,我们可以尝试一个简单的例子。假设我们想用我们的树来预测一个观察值的输出,其中x1的值为 96.0,x2的值为 79.9。我们从根节点开始,决定跟随哪个子树。我们的x2值大于 23,所以我们跟随右分支,来到一个新的节点,需要检查新的条件。我们的x1值大于 46,所以我们再次选择右分支,到达一个叶节点。因此,我们输出叶节点指示的值,即-3.7。这是我们的模型根据我们指定的输入对预测的值。
决策树的一种思考方式是,它们实际上是在编码一系列导致不同输出的 if-then 规则。对于每个叶节点,我们可以写一条规则(如果需要,可以使用布尔AND运算符将多个条件连接起来),这条规则必须为真,树才能输出该节点的值。我们可以通过从根节点开始,沿着每条通向叶节点的路径向下遍历树,来提取所有这些 if-then 规则。例如,我们的小回归树导致以下三条规则,每一条对应其一个叶节点:
-
如果(x2 < 23) 则输出 2.1 -
如果(x2 > 23) AND (x1 < 46) 则输出 1.2 -
如果(x2 > 23) AND (x1 > 46) 则输出 -3.7
注意,我们必须使用AND运算符将最后两条规则中的两个条件连接起来,因为通向叶节点的路径包含多个决策节点(包括根节点)。
另一种思考决策树的方式是,它们将特征空间划分为二维中的一系列矩形区域,三维中的立方体,以及更高维度的超立方体。记住,特征空间中的维度数就是特征的数量。我们示例回归树的特征空间有两个维度,我们可以如下可视化这个空间是如何划分为矩形区域的:

规则解释和空间划分解释是同一模型的等效视角。特别是空间划分解释在帮助我们理解决策树的一个特定特性方面非常有用:它们必须完全覆盖所有可能的输入特征组合。换句话说,对于决策树中不存在到达叶节点的路径的特定输入,应该没有。每次我们给出输入特征的值时,我们都应该始终能够返回一个答案。我们的决策树特征空间划分解释本质上告诉我们,没有不属于特定分区并分配了值的点或点的空间。同样,从我们的决策树 if-then 规则集视角来看,我们是在说对于任何输入特征组合,总有一条规则可以使用,因此我们可以将规则重新组织成一个等效的if-then-else结构,其中最后一个规则是else语句。
决策树训练算法
现在我们已经了解了决策树的工作原理,我们接下来想要解决的是如何使用一些数据来训练一个决策树的问题。已经提出了几种算法来构建决策树,在本节中,我们将介绍其中一些最著名的算法。我们应该记住的一点是,无论我们选择哪种树构建算法,我们都必须回答四个基本问题:
-
对于每个节点(包括根节点),我们应该如何选择用于分割的输入特征,以及给定这个特征,分割点的值是多少?
-
我们如何决定一个节点应该成为叶节点,还是我们应该创建另一个分割点?
-
我们应该允许树有多深?
-
一旦我们到达叶节点,我们应该预测什么值?
注意
决策树的优秀介绍可以在《机器学习》的第三章中找到,作者是Tom Mitchell。这本书可能是对机器学习最全面的介绍之一,值得一读。尽管这本书是在 1997 年出版的,但书中的大部分内容至今仍然适用。此外,根据书中网站www.cs.cmu.edu/~tom/mlbook.html的信息,正在计划出版第二版。
分类和回归树
分类和回归树(CART)方法,我们以后将简单地称之为 CART,是早期提出的构建基于树模型的最早方法之一。正如其名所示,该方法包括构建回归树和分类树的方法。
CART 回归树
对于回归树,使用 CART 方法的关键直觉是,在树的任何给定点,我们通过找到最大化这些组合中平方误差和(SSE)减少的组合,来选择分割的输入特征和该特征内的分割点值。对于使用 CART 构建的回归树中的每个叶节点,预测值只是分配给该特定叶节点的所有数据点预测输出的平均值。为了确定是否应该创建新的分割点或者树是否应该生长一个叶节点,我们只需计算当前分配给节点的数据点的数量;如果这个值小于一个预定的阈值,我们创建一个新的叶节点。
对于树中的任何给定节点,包括根节点,我们首先将一些数据点分配给该节点。在根节点,所有数据点都被分配,但一旦我们进行分割,一些数据点被分配给左子节点,剩余的点被分配给右子节点。SSE 的起始值只是使用分配给当前节点的 n 个数据点的输出变量的平均值
计算的平方误差和
:

如果我们将这些数据点分成两个大小为 n[1] 和 n[2] 的组,使得 n[1] + n[2] = n,并且计算所有数据点的新的 SSE 作为两个新组 SSE 值的总和,我们有:

在这里,第一个求和是对 j 进行迭代,j 是第一组中对应左子节点的数据点的新的索引,第二个求和是对 k 进行迭代,k 是第二组内部属于右子节点的数据点的新的索引。CART 的基本思想是通过考虑每个可能的特征以及该特征内的每个可能的分割点,找到一种方法来形成这两组数据点,从而使这个新的量最小化。因此,我们可以将 CART 中的误差函数视为 SSE。
CART 及其一般基于树的模型的一个自然优势是它们能够处理各种输入类型,从数值输入(离散和连续)到二进制输入以及分类输入。数值输入可以通过按升序排序以自然方式排序,例如。当我们这样做时,我们可以看到,如果我们有 k 个不同的数字,那么将这些数字分成两组,使得一个组中的所有数字都小于第二个组中的所有数字,并且两个组至少有一个元素,有 k-1 种不同的分割方式。这很简单,只需选择数字本身作为分割点,而不将最小的数字作为分割点(这将产生一个空组)。所以如果我们有一个包含数字 {5.6, 2.8, 9.0} 的特征向量 x,我们首先将这些数字排序为 {2.8, 5.6, 9.0}。
然后,我们取除了最小的 {2.8} 之外的所有数字来形成一个分割点,并形成一个相应的规则来检查输入值是否小于分割点。通过这种方式,我们产生了我们特征向量的唯一两种可能的分组:
-
Group1 = {2.8}, Group2 = {5.6, 9.0} IF x < 5.6 THEN Group1 ELSE Group2 -
Group1 = {2.8, 5.6}, Group2 = {9.0} IF x < 9.0 THEN Group1 ELSE Group2
注意,每个组至少有一个元素是很重要的,否则我们实际上并没有分割我们的数据。二进制输入特征也可以通过简单地使用将所有具有此特征的第一值的数据点放入第一组,而具有此特征的第二个值的数据点放入第二组的分割点来处理。
处理无序的分类输入特征(因子)要困难得多,因为没有自然顺序。因此,任何级别的组合都可以分配给第一组,其余的分配给第二组。如果我们处理一个有 k 个不同级别的因子,那么有 2^(k-1)-1 种可能的分组方式,每个组至少有一个级别。
因此,一个二值特征有一个可能的分割,正如我们所知,一个三值特征有三个可能的分割。在包含数字 {5.6, 2.8, 9.0} 的数值特征向量中,我们已经看到了两个可能的分割。如果这些数字是标签,可能出现的第三个分割是这样一个分割:一个组的数据点具有这个特征的值为 5.6,另一个组具有两个值 2.8 和 9.0。显然,当我们把特征视为数值时,这不是一个有效的分割。
最后要注意的是,对于分类输入特征,我们始终可以选择一对一的方法,这本质上与考虑一个组始终由单个元素组成的分割相同。这并不总是一个好主意,因为它可能最终会显示出,当某些级别组合在一起时,它们可能比单个级别更能预测输出。如果这种情况发生,生成的树可能会更复杂,节点分割的数量会更多。
有多种方法可以处理与找到和评估所有不同分割点相关的大幅增加的复杂性,但我们现在不会进一步详细介绍。相反,让我们编写一些 R 代码来查看我们如何使用 CART 使用的 SSE 标准来找到数值输入特征的分割点:
compute_SSE_split <- function(v, y, split_point) {
index <- v < split_point
y1 <- y[index]
y2 <- y[!index]
SSE <- sum((y1 - mean(y1)) ^ 2) + sum((y2 - mean(y2)) ^ 2)
return(SSE)
}
compute_all_SSE_splits <- function(v, y) {
sapply(unique(v), function(sp) compute_SSE_split(v, y, sp))
}
rcart_df:
> set.seed(99)
> x1 <- rbinom(20, 1, 0.5)
> set.seed(100)
> x2 <- round(10 + rnorm(20, 5, 5), 2)
> set.seed(101)
> y <- round((1 + (x2 * 2 / 5) + x1 - rnorm(20, 0, 3)), 2)
> rcart_df <- data.frame(x1, x2, y)
> rcart_df
x1 x2 y
1 1 12.49 7.97
2 0 15.66 5.61
3 1 14.61 9.87
4 1 19.43 9.13
5 1 15.58 7.30
6 1 16.59 5.11
7 1 12.09 4.98
8 0 18.57 8.77
9 0 10.87 2.60
10 0 13.20 6.95
11 1 15.45 6.60
12 1 15.48 10.58
13 0 13.99 2.31
14 1 18.70 13.88
15 1 15.62 8.96
16 1 14.85 8.52
17 0 13.06 8.77
18 0 17.55 7.84
19 0 10.43 7.63
20 0 26.55 17.77
在实践中,20 个数据点可能是一个合适的数量,可以用作构建叶子节点的阈值,但在这个例子中,我们将简单地假设我们想要使用这些数据来创建一个新的分割。我们有两个输入特征,x1和x2。前者是一个二进制输入特征,我们使用数值标签 0 和 1 进行编码。这允许我们重用我们刚刚编写的函数来计算可能的分割。后者是一个数值输入特征。通过分别对每个特征应用我们的compute_all_SSE_splits()函数,我们可以计算每个特征的所有可能的分割点及其 SSE。以下两个图表依次显示了每个特征的这些 SSE 值:

观察这两个图表,我们可以看到最佳分割产生的 SSE(总平方误差)值为124.05,这可以通过在特征x2的值18.7处进行分割来实现。因此,我们的回归树将包含以下分割规则:
If x2 < 18.7
CART 方法始终应用相同的逻辑来确定在每个节点是否进行新的分割,以及如何选择分割的特征和值。这种在节点处递归分割数据点以构建回归树的方法也是为什么这个过程也被称为递归分割。
树剪枝
如果我们允许递归分割过程无限期地重复,我们最终将通过每个叶子节点包含一个数据点来终止,因为那时我们不能再分割数据了。这个模型将完美地拟合训练数据,但它在未见过的数据上的性能很可能不会泛化。因此,基于树的模型容易过拟合。为了解决这个问题,我们需要控制最终决策树的深度。
从树中移除节点以限制其大小和复杂性的过程被称为剪枝。一种可能的剪枝方法是为创建新分割点而不是创建叶节点而使用可以使用的最小数据点数量设置一个阈值。这将使在程序早期就创建叶节点,分配给它们的那些数据点可能并不都具有相同的输出。在这种情况下,我们可以简单地预测回归的平均值(以及分类中最受欢迎的类别)。这是一个预剪枝的例子,因为我们是在树构建过程中以及它完全构建之前进行剪枝的。
直观上,我们应该能够看到,树的深度越大,分配给叶节点的平均数据点数越小,过拟合的程度就越大。当然,如果我们树中的节点较少,我们可能没有足够细致地模拟底层数据。
因此,树木应该允许生长到多大这个问题实际上是一个如何尽可能紧密地模拟我们的数据同时控制过拟合程度的问题。在实践中,使用预剪枝是棘手的,因为很难找到一个合适的阈值。
CART 方法用来剪枝的另一种正则化过程被称为成本复杂度调整。实际上,树通常被允许使用上一节中描述的递归分割方法完全生长。一旦完成,我们就剪枝得到的树,也就是说,我们开始移除分割点并合并叶节点,根据一定标准缩小树的大小。这被称为后剪枝,因为我们是在树构建之后进行剪枝的。当我们构建原始树时,我们使用的误差函数是 SSE。为了剪枝,我们使用惩罚版本的 SSE 来最小化:

在这里,α是一个控制正则化程度的复杂度参数,Tp是树中的节点数,这是模拟树大小的一种方式。类似于 lasso 在广义线性模型中限制回归系数大小的方式,这种正则化过程限制了结果树的大小。α的值非常小会导致剪枝程度很小,当α取值为 0 时,极限情况下表示根本不进行剪枝。另一方面,使用这个参数的高值会导致树变得非常短,在极限情况下,可以导致没有分割点且大小为零的树,预测所有可能输入的输出平均值。
结果表明,每个 α 的特定值都对应于一个独特的树结构,该结构最小化了该特定值的惩罚形式的 SSE。换句话说,给定一个特定的 α 值,有一个独特且可预测的方式来修剪树以最小化惩罚 SSE,但这个过程的细节超出了本书的范围。现在,我们只需假设每个 α 值都与一棵树相关联。
这个特定功能非常有用,因为我们一旦确定了复杂度参数 α 的值,在选择树时就不会有任何歧义。然而,它并没有给我们提供确定实际应该使用什么值的方法。交叉验证,我们在第五章中看到的,支持向量机,是一种常用的方法,旨在估计这个参数的适当值。将交叉验证应用于这个问题将涉及将数据分成 k 个部分。然后我们通过使用除了一个部分之外的所有数据来训练和修剪 k 棵树,并对每一部分重复此过程。最后,我们在为测试保留的部分上测量 SSE,并平均结果。我们可以对 a 的不同值重复我们的交叉验证过程。当有更多数据可用时,另一种方法是使用验证数据集来评估在相同训练数据集上训练但 α 值不同的模型。
缺失数据
决策树的一个特点是它们在训练过程中自然地处理缺失数据。例如,当我们考虑在特定节点上分割哪个特征时,我们可以忽略具有特定特征缺失值的点,并使用剩余的数据点计算我们的误差函数(偏差、SSE 等)的潜在减少。请注意,虽然这种方法很方便,但它可能会大大增加模型的偏差,特别是如果我们因为缺失值而忽略了大量可用训练数据的话。
人们可能会想知道我们是否能够在预测未见数据点时处理缺失值。如果我们处于一个在某个特征上分割的特定节点,并且我们的测试数据点在该特征上有一个缺失值,我们似乎就陷入了困境。在实践中,这种情况可以通过使用代理分割来处理。这些方法背后的关键概念是,对于树中的每个节点,除了最优分割特征外,我们还跟踪一个其他特征列表,这些特征在数据中产生与实际选择的特征相似的分割。这样,当我们的测试数据点在需要做出预测的特征上有一个缺失值时,我们可以参考节点的代理分割,并使用不同的特征来处理这个节点。
回归模型树
使用 CART 构建的回归树的一个潜在缺点是,尽管我们限制了分配给特定叶节点的数据点的数量,但这些数据点之间仍然可能在输出变量上有显著的差异。当这种情况发生时,取平均值并以此作为该叶节点的一个单一预测可能不是最好的主意。
回归模型树试图通过使用叶节点上的数据点来构建一个线性模型以预测输出,来克服这个限制。原始的回归模型树算法是由 J. Ross Quinlan 开发的,被称为 M5。M5 算法在树的每个节点上计算一个线性模型。对于测试数据点,我们首先计算从根节点到叶节点的决策路径。然后做出的预测是与该叶节点相关的线性模型的输出。
M5 与 CART 中使用的算法的不同之处在于,它采用不同的标准来确定在哪个特征上进行分割。这个标准是加权标准差的减少:

这个通用方程假设我们将数据分成 p 个分区(正如我们在树结构中看到的,p 通常为 2)。对于每个分区 i,我们计算标准差 σ[i]。然后,我们使用每个分区的相对大小(n[i]/n)作为权重,计算这些标准差的加权平均值。这个值从未分区数据的初始标准差中减去。
这个标准的背后思想是,分割一个节点应该产生数据点组,在每组中,与所有数据点分组在一起相比,输出变量的变异性更小。我们将在本章后面有机会看到 M5 树 以及 CART 树的实际应用。
CART 分类树
使用 CART 方法构建分类树继续了递归分割数据点组以最小化某些误差函数的概念。我们首先猜测一个合适的误差函数是分类准确度。结果证明,这不是构建分类树的一个特别好的度量。
我们实际上想使用的是节点纯度的度量,该度量会根据节点是否包含主要属于一个输出类别的数据点来评分。这是一个非常直观的想法,因为我们实际上在分类树中追求的是最终能够将我们的训练数据点分组到叶节点上的数据点集合中,使得每个叶节点只包含属于一个类别的数据点。这意味着如果我们预测时到达那个叶节点,我们可以自信地预测这个类别。
节点纯度的一个可能度量,常与 CART 分类树一起使用,是基尼指数。对于一个有 K 个不同类别的输出变量,基尼指数 G 定义如下:

要计算基尼指数,我们计算每个类别的概率估计,并将其与不是该类别的概率相乘。然后我们将所有这些乘积相加。对于二元分类问题,应该很容易看出基尼指数等于 2
(*1- *
),其中
是一个类别的估计概率。
要计算树中特定节点的基尼指数,我们可以简单地使用标记为类别 k 的数据点数与总数据点数的比率,作为该节点中数据点属于类别 k 的概率的估计。以下是一个简单的 R 函数来计算基尼指数:
gini_index <- function(v) {
t <- table(v)
probs <- t / sum(t)
terms <- sapply(probs, function(p) p * (1 - p) )
return(sum(terms))
}
要计算基尼指数,我们的 gini_index() 函数首先将向量中的所有条目进行汇总。它将每个这些频率计数除以总计数,将它们转换为概率估计。最后,它计算每个这些的乘积 (1-) 并对所有这些项进行求和。让我们尝试几个例子:
> gini_index(v = c(0, 0, 0, 1, 1, 1))
[1] 0.5
> gini_index(v = c(0, 0, 0, 1, 1, 1, 1, 1, 1))
[1] 0.4444444
> gini_index(v = c(0, 0, 0, 1, 1, 1, 2, 2, 2))
[1] 0.6666667
> gini_index(v = c(1, 1, 1, 1, 1, 1))
[1] 0
注意,完全纯的节点(只有一个类别的节点)的基尼指数为 0。对于两个类别比例相等的二元输出,基尼指数为 0.5。类似于回归树中的标准差,我们使用加权减少的基尼指数,其中我们按相对大小权衡每个分区,以确定适当的分割点:

另一个常用的标准是偏差。当我们研究逻辑回归时,我们看到了这仅仅是常数 -2 乘以数据的对数似然。在分类树设置中,我们计算分类树中一个节点的偏差如下:

与基尼指数不同,节点处的观察总数 n[k] 影响偏差的值。所有具有不同类别间数据点相同比例的节点将具有相同的基尼指数值,但如果它们有不同的观察数,它们将具有不同的偏差值。然而,在两种分割标准中,一个完全纯的节点将具有 0 值,否则为正值。
除了使用不同的分裂标准外,使用 CART 方法构建分类树的逻辑与构建回归树的逻辑完全平行。缺失值以相同的方式处理,并且使用剩余数据点构建叶节点的数量阈值对树进行预剪枝。树也使用与回归树中概述的相同成本复杂度方法进行后剪枝,但用 Gini 指数或偏差替换了 SSE 作为误差函数。
C5.0
由罗斯·奎因兰开发的C5.0算法是一种用于构建分类决策树的算法。这个算法是自一个被称为ID3的算法开始的一系列连续改进版本中的最新版本,该算法发展成了C4.5(以及在 Java 编程语言中称为J48的开源实现),最终演变为 C5.0。用于决策树的好缩写有很多,但幸运的是,其中许多都是相互关联的。C5.0 算法链与 CART 方法有几个不同之处,最显著的是在分裂标准的选择以及剪枝过程上。
C5.0 使用的分裂标准被称为熵或信息统计量,其根源在于信息理论。熵被定义为通过消息传递所需平均二进制数字(比特)的数量,作为不同符号概率的函数。熵在统计物理学中也有其根源,在那里它被用来表示系统中的混沌和不确定程度。当一个系统的符号或组成部分具有相等的概率时,存在很高的不确定性,但当某个符号比其他符号远更可能时,熵较低。这一观察使得熵的定义在衡量节点纯度时非常有用。对于具有K个类别的多类场景,熵的正式定义(以比特为单位)是:

在二进制情况下,方程简化为(其中p任意指代两个类别中的一个的概率):

我们可以在以下图表中比较熵与二分类的 Gini 指数:

从图表中我们可以看到,对于二类问题,这两个函数具有相同的一般形状。回想一下,熵越低,我们对类别分布的不确定性就越低,因此节点纯度就越高。因此,我们在构建树的过程中希望最小化熵。在 ID3 中,使用的分裂标准是加权熵减少,也称为信息增益:

结果表明,这个标准存在选择偏差的问题,因为它倾向于偏好分类变量,因为与我们在连续特征中找到的分割范围相比,可能的分组数量要多得多。为了解决这个问题,从 C4.5 开始,这个标准被细化为信息增益率。这是信息增益的标准化版本,其中我们相对于一个称为分割信息值的量进行标准化。
这反过来又代表了仅通过分区本身的大小就能获得的潜在信息增加。当我们有大小均匀的分区时,就会发生高分割信息值;而当大多数数据点集中在少数几个分区中时,就会发生低值。总的来说,我们有以下内容:

C5.0 算法链还包含了超越简单节点和子树消除的剪枝方法。例如,内部节点可以在叶节点之前被移除,这样被移除节点(子树)下的节点(子树)就会被推上去(提升)以替换被移除的节点。特别是 C5.0 是一个非常强大的算法,它还包含了改进速度、内存使用、原生提升(将在下一章中介绍)的能力,以及指定成本矩阵的能力,这样算法就可以避免在某些类型的误分类上比其他类型更频繁地发生,正如我们在上一章中看到的支持向量机那样。
我们将在下一节中演示如何在 R 中使用 C5.0 构建树。
在合成 2D 数据上预测类别成员资格
我们第一个展示 R 中基于树的方法的例子将操作于我们创建的合成数据集。该数据集可以使用本章配套 R 文件的命令生成,该文件由出版商提供。数据包括 287 个观测值,两个输入特征x1和x2。
输出变量是一个具有三个可能类别的分类变量:a、b和c。如果我们遵循代码文件中的命令,我们将最终在 R 中得到一个数据框mcdf:
> head(mcdf, n = 5)
x1 x2 class
1 18.58213 12.03106 a
2 22.09922 12.36358 a
3 11.78412 12.75122 a
4 23.41888 13.89088 a
5 16.37667 10.32308 a
这个问题实际上非常简单,因为一方面,我们有一个非常小的数据集,只有两个特征,另一方面,类别在特征空间中恰好被很好地分开,这是非常罕见的。尽管如此,在本节中,我们的目标是演示在下一节在真实世界数据集上动手(或键盘)之前,在表现良好的数据上构建分类树。
为了为这个数据集构建一个分类树,我们将使用 tree 包,它为我们提供了 tree() 函数,该函数使用 CART 方法训练模型。按照惯例,第一个要提供的参数是一个公式,第二个参数是数据框。该函数还有一个 split 参数,用于标识用于分割的标准。默认情况下,这个参数设置为 deviance,对于偏差标准,我们在该数据集上观察到更好的性能。我们鼓励读者通过将 split 参数设置为 gini 来在基尼指数上分割重复这些实验。
不再拖延,让我们训练我们的第一个决策树:
> library(tree)
> d2tree <- tree(class ~ ., data = mcdf)
> summary(d2tree)
Classification tree:
tree(formula = class ~ ., data = mcdf)
Number of leaf nodes: 5
Residual mean deviance: 0.03491 = 9.844 / 282
Misclassification error rate: 0.003484 = 1 / 287
我们在我们的训练模型上调用 summary() 函数来获取有关我们构建的树的一些有用信息。请注意,对于这个例子,我们不会将我们的数据分成训练集和测试集,因为我们的目标是首先讨论模型拟合的质量。从提供的摘要来看,我们似乎在整个数据集中只误分类了一个示例。通常情况下,这会引发我们过度拟合的怀疑;然而,我们已经知道我们的类别在特征空间中分布良好。我们可以使用 plot() 函数来绘制树的形状,以及使用 text() 函数来显示所有相关的标签,这样我们就可以完全可视化我们构建的分类器:
> plot(d2tree)
> text(d2tree, all = T)
这就是产生的图表:

注意,我们的图表显示了每个节点的预测类别,包括非叶节点。这仅仅允许我们看到在树的每一步中哪个类别占主导地位。例如,在根节点,我们看到主导类别是类别 b,仅仅因为这个类别在我们的数据集中是最常见的。能够看到我们的决策树所表示的 2D 空间的划分是有教育意义的。
对于一个和两个特征,tree 包允许我们使用 partition.tree() 函数来可视化我们的决策树。我们已经这样做了,并将我们的原始数据叠加在其上,以便看到分类器是如何划分空间的:

我们大多数人可能会在我们的数据中识别出六个簇;然而,图表右上角的簇都被分配到了类别 b,因此树分类器已经将这个整个空间区域识别为单个叶节点。最后,我们可以看到属于类别 b 但被分配到类别 c 的误分类点(它在图表顶部中间的三角形中)。
另一个值得注意的有趣观察是,在这个特定情况下,空间是如何有效地被划分为矩形的(对于有六个聚类的数据集,只有五个矩形)。另一方面,我们可以预期这个模型可能存在一些不稳定性,因为几个矩形的边界与数据集中的数据点非常接近(因此接近聚类的边缘)。因此,我们也应该预期,使用与生成我们的训练数据相同过程生成的未见数据,将获得较低的准确度。
在下一节中,我们将为现实世界的分类问题构建一个树模型。
预测纸币的真实性
在本节中,我们将研究预测特定纸币是真是伪造的问题。纸币认证数据集托管在archive.ics.uci.edu/ml/datasets/banknote+authentication。数据集的创建者从真币和伪造币中取了样本,并用工业相机拍摄。生成的灰度图像使用一种称为小波变换的时间-频率变换进行处理。构建了该变换的三个特征,加上图像熵,它们构成了这个二元分类任务中的四个特征。
| 列名 | 类型 | 定义 |
|---|---|---|
waveletVar |
数值 | 小波变换图像的方差 |
waveletSkew |
数值 | 小波变换图像的偏度 |
waveletCurt |
数值 | 小波变换图像的峰度 |
熵 |
数值 | 图像的熵 |
class |
二元 | 真实性(0 输出表示真币,1 输出表示伪造币) |
首先,我们将我们的 1,372 个观察值分为训练集和测试集:
> library(caret)
> set.seed(266)
> bnote_sampling_vector <- createDataPartition(bnote$class, p =
0.80, list = FALSE)
> bnote_train <- bnote[bnote_sampling_vector,]
> bnote_test <- bnote[-bnote_sampling_vector,]
接下来,我们将介绍包含 C5.0 算法分类实现的C50 R 包。属于此包的C5.0()函数也接受公式和数据框作为其最小必需输入。就像之前一样,我们可以使用summary()函数来检查生成的模型。我们不会重现后者的整个输出,而是只关注构建的树:
> bnote_tree <- C5.0(class ~ ., data = bnote_train)
> summary(bnote_tree)
waveletVar > 0.75896:
:...waveletCurt > -1.9702: 0 (342)
: waveletCurt <= -1.9702:
: :...waveletSkew > 4.9228: 0 (128)
: waveletSkew <= 4.9228:
: :...waveletVar <= 3.4776: 1 (34)
: waveletVar > 3.4776: 0 (2)
waveletVar <= 0.75896:
:...waveletSkew > 5.1401:
:...waveletVar <= -3.3604: 1 (31)
: waveletVar > -3.3604: 0 (93/1)
waveletSkew <= 5.1401:
:...waveletVar > 0.30081:
:...waveletCurt <= 0.35273: 1 (25)
: waveletCurt > 0.35273:
: :...entropy <= 0.71808: 0 (24)
: entropy > 0.71808: 1 (3)
waveletVar <= 0.30081:
:...waveletCurt <= 3.0423: 1 (241)
waveletCurt > 3.0423:
:...waveletSkew > -1.8624: 0 (21/1)
waveletSkew <= -1.8624:
:...waveletVar <= -0.69572: 1 (146)
waveletVar > -0.69572:
:...entropy <= -0.73535: 0 (2)
entropy > -0.73535: 1 (6)
如我们所见,在树中使用一个特征多次以创建新的分割是完全可接受的。树中叶节点右侧括号中的数字表示分配给该节点的每个类别的观察数。如我们所见,树中的绝大多数叶节点是纯节点,因此只分配了来自一个类的观察值。
只有两个叶节点各自来自少数类的单个观察值,因此我们可以推断,使用这个模型我们只犯了两个训练数据错误。为了看看我们的模型是否过度拟合了数据,或者它是否真的可以很好地泛化,我们将在测试集上对其进行测试:
> bnote_predictions <- predict(bnote_tree, bnote_test)
> mean(bnote_test$class == bnote_predictions)
[1] 0.9890511
测试准确率几乎完美,这是一个罕见的景象,也是本章中我们最后一次如此轻松地完成!最后值得一提的是,C50() 还有一个 costs 参数,这对于处理不对称错误成本非常有用。
预测复杂技能学习
在本节中,我们将有机会探索一个名为 SkillCraft 的新近项目的数据。感兴趣的读者可以通过访问 skillcraft.ca/ 在网上了解更多关于这个项目的信息。该项目背后的关键前提是,通过研究涉及复杂资源管理和战略决策的实时策略游戏(RTS)中玩家的表现,我们可以研究人类如何学习复杂技能,并在动态资源分配场景中提高速度和竞争力。为了实现这一点,已经收集了玩家在由 Blizzard 开发的热门实时策略游戏 Starcraft 2 中玩游戏的资料。
在这个游戏中,玩家在许多固定地图和起始位置之一与其他玩家竞争。每个玩家必须从三个可选的虚构种族中选择一个,并从六个工人单位开始,这些单位用于收集两种游戏资源中的一种。这些资源是建造军事和生产建筑、每个种族独特的军事单位、研究技术和建造更多工人单位所必需的。游戏涉及经济进步、军事增长和实时交战中的军事策略。
玩家通过在线匹配算法相互对抗,该算法根据玩家感知的技能水平将玩家分组到联赛中。算法对玩家技能的感知会根据玩家在参与的比赛中表现的变化而随时间变化。总共有八个联赛,人口分布不均,低级别联赛通常有更多玩家,而高级别联赛玩家较少。
对游戏有基本了解后,我们可以通过访问archive.ics.uci.edu/ml/datasets/SkillCraft1+Master+Table+Dataset从 UCI 机器学习仓库下载 SkillCraft1 大师表数据集。该数据集的行是玩过的单个游戏,游戏特征是玩家游戏速度、能力和决策的指标。数据集的作者使用了玩家熟悉的标准化性能指标,以及其他指标,如感知动作周期(PACs),这些指标试图量化玩家在特定时间窗口内查看地图上固定位置的动作。
当前任务是根据这些性能指标预测玩家目前被分配到八个联赛中的哪一个。我们的输出变量是一个有序分类变量,因为我们有八个不同的联赛,从 1 到 8 排序,其中后者对应于拥有最高技能玩家的联赛。
处理有序输出的一个可能方法是将它们视为数值变量,将其建模为回归任务,并构建回归树。以下表格描述了我们数据集中的特征和输出变量:
| 特征名称 | 类型 | 描述 |
|---|---|---|
Age |
数值 | 玩家年龄 |
HoursPerWeek |
数值 | 每周报告的游戏时间 |
TotalHours |
数值 | 报告的累计游戏时间 |
APM |
数值 | 每分钟游戏动作数 |
SelectByHotkeys |
数值 | 每个时间戳使用快捷键进行的单位或建筑选择数量 |
AssignToHotkeys |
数值 | 每个时间戳分配给快捷键的单位或建筑数量 |
UniqueHotkeys |
数值 | 每个时间戳使用的独特快捷键数量 |
MinimapAttacks |
数值 | 每个时间戳在最小地图上的攻击动作数量 |
MinimapRightClicks |
数值 | 每个时间戳在最小地图上的右键点击次数 |
NumberOfPACs |
数值 | 每个时间戳的 PAC 数量 |
GapBetweenPACs |
数值 | PACs 之间的平均持续时间(毫秒) |
ActionLatency |
数值 | 从 PAC 开始到第一次动作的平均延迟(毫秒) |
ActionsInPAC |
数值 | 每个 PAC 内的平均动作数 |
TotalMapExplored |
数值 | 玩家在每个时间戳查看的 24x24 游戏坐标网格数量 |
WorkersMade |
数值 | 每个时间戳训练的工人单位数量 |
UniqueUnitsMade |
数值 | 每个时间戳制作的独特单位 |
ComplexUnitsMade |
数值 | 每个时间戳训练的复杂单位数量 |
ComplexAbilitiesUsed |
数值 | 每个时间戳使用需要特定目标指令的能力 |
LeagueIndex |
数值 | 青铜、白银、黄金、白金、钻石、大师、宗师和职业联赛,编码为 1-8(输出) |
提示
如果读者之前从未在电脑上玩过像星际争霸 2这样的实时策略游戏,那么数据集中使用的许多特征可能听起来很神秘。如果一个人只是接受这些特征代表玩家在游戏中的表现各个方面,那么仍然可以毫无困难地跟随关于我们回归树训练和测试的所有讨论。
首先,我们将这个数据集加载到数据框skillcraft中。在开始处理数据之前,我们必须做一些预处理。首先,我们将删除第一列。这只是一个唯一的游戏标识符,我们不需要也不会使用。其次,快速检查导入的数据框将显示,有三个列被解释为因子,因为输入数据集包含一个问号来表示缺失值。为了处理这个问题,我们首先需要将这些列转换为数值列,这个过程将在我们的数据集中引入缺失值。
接下来,尽管我们已经看到树可以很好地处理这些缺失值,但我们还是打算删除包含这些值的几行。我们会这样做,因为我们想要能够比较本章和下一章中几个不同模型的性能,并不是所有这些模型都支持缺失值。
下面是上述预处理步骤的代码:
> skillcraft <- read.csv("SkillCraft1_Dataset.csv")
> skillcraft <- skillcraft[-1]
> skillcraft$TotalHours <- as.numeric(
levels(skillcraft$TotalHours))[skillcraft$TotalHours]
Warning message:
NAs introduced by coercion
> skillcraft$HoursPerWeek <- as.numeric(
levels(skillcraft$HoursPerWeek))[skillcraft$HoursPerWeek]
Warning message:
NAs introduced by coercion
> skillcraft$Age <- as.numeric(
levels(skillcraft$Age))[skillcraft$Age]
Warning message:
NAs introduced by coercion
> skillcraft <- skillcraft[complete.cases(skillcraft),]
如同往常,下一步将是将我们的数据分为训练集和测试集:
> library(caret)
> set.seed(133)
> skillcraft_sampling_vector <- createDataPartition(
skillcraft$LeagueIndex, p = 0.80, list = FALSE)
> skillcraft_train <- skillcraft[skillcraft_sampling_vector,]
> skillcraft_test <- skillcraft[-skillcraft_sampling_vector,]
这次,我们将使用rpart包来构建我们的决策树(与tree包一起,这两个包是 R 中构建基于树的模型最常用的包)。这个包为我们提供了一个rpart()函数来构建我们的树。就像tree()函数一样,我们可以通过简单地提供一个公式和我们的数据框来使用默认行为构建一个回归树:
> library(rpart)
> regtree <- rpart(LeagueIndex ~ ., data = skillcraft_train)
我们可以绘制我们的回归树来查看其外观:
> plot(regtree, uniform = TRUE)
> text(regtree, use.n = FALSE, all = TRUE, cex = .8)
这是生成的图表:

为了了解我们的回归树的准确性,我们将对测试数据进行预测,然后测量 SSE。这可以通过我们定义的一个简单函数compute_SSE()来完成,它计算给定目标值向量和预测值向量时的平方误差之和:
compute_SSE <- function(correct, predictions) {
return(sum((correct - predictions) ^ 2))
}
> regtree_predictions <- predict(regtree, skillcraft_test)
> (regtree_SSE <- compute_SSE(regtree_predictions, skillcraft_test$LeagueIndex))
[1] 740.0874
调整 CART 树模型参数
到目前为止,我们所做的一切只是为构建树的递归划分算法的所有参数使用默认值。rpart()函数有一个特殊的control参数,我们可以提供一个包含我们希望覆盖的任何参数值的对象。要构建这个对象,我们必须使用特殊的rpart.control()函数。我们可以调整许多不同的参数,研究这个函数的帮助文件以了解更多关于它们的信息。
在这里,我们将关注三个影响我们树的大小和复杂性的重要参数。minsplit 参数表示算法在被迫创建叶节点之前尝试分割所需的最小数据点数。默认值是 30。cp 参数是我们之前见过的复杂度参数,其默认值为 0.01。最后,maxdepth 参数限制了叶节点和根节点之间的最大节点数。这里的默认值 30 相当宽松,允许构建相当大的树。我们可以通过指定与默认值不同的值来尝试不同的回归树。我们将这样做,看看这会影响测试集上的 SSE 性能:
> regtree.random <- rpart(LeagueIndex ~ ., data = skillcraft_train,
control = rpart.control(minsplit = 20, cp = 0.001, maxdepth = 10))
> regtree.random_predictions <- predict(regtree.random,
skillcraft_test)
> (regtree.random_SSE <- compute_SSE(regtree.random_predictions,
skillcraft_test$LeagueIndex))
[1] 748.6157
使用这些值,我们试图将树限制在深度为 10,同时通过在节点上需要 20 个或更多数据点来简化强制分割。我们还通过将复杂度参数设置为 0.001 来降低正则化的影响。这是一个完全随机的选择,不幸的是,它在我们的测试集上给出了更差的 SSE 值。在实践中,需要一种系统的方法,通过尝试多种不同的组合并使用交叉验证作为评估它们在未见数据上性能的一种方式,来找到适合我们树的这些参数的适当值。
实际上,我们希望调整我们的回归树训练,在第五章中,支持向量机,我们遇到了 e1071 包内的 tune() 函数,它可以帮助我们做到这一点。我们将使用这个函数与 rpart() 一起,并为刚才讨论的三个参数提供范围:
> library(e1071)
> rpart.ranges <- list(minsplit = seq(5, 50, by = 5), cp = c(0,
0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2,0.5), maxdepth = 1:10)
> (regtree.tune <- tune(rpart,LeagueIndex ~ .,
data = skillcraft_train, ranges = rpart.ranges))
Parameter tuning of 'rpart':
- sampling method: 10-fold cross validation
- best parameters:
minsplit cp maxdepth
35 0.002 6
- best performance: 1.046638
运行前面的任务可能需要几分钟才能完成,因为有许多参数组合。一旦程序完成,我们可以用建议的值训练一个树:
> regtree.tuned <- rpart(LeagueIndex ~ ., data = skillcraft_train,
control = rpart.control(minsplit = 35, cp = 0.002, maxdepth = 6))
> regtree.tuned_predictions <- predict(regtree.tuned,
skillcraft_test)
> (regtree.tuned_SSE <- compute_SSE(regtree.tuned_predictions,
skillcraft_test$LeagueIndex))
[1] 701.3386
的确,在我们的测试集上,这些设置带来了更低的 SSE 值。如果我们输入我们新的回归树模型名称,regree.tuned,我们会看到我们的树中有更多的节点,这使得树现在变得更加复杂。
树模型中的变量重要性
对于如此大的树,绘图不太有用,因为很难使绘图可读。我们可以获得的一个有趣的绘图是 变量重要性 的绘图。对于每个输入特征,我们跟踪每次它在树中的任何地方被使用时发生的优化标准(例如,偏差或 SSE)的减少。然后我们可以累计树中所有分割的这个数量,从而获得变量重要性的相对量。
直观地说,高度重要的特征往往会很早就被用来分割数据(因此出现在树中较高的位置,接近根节点),以及更频繁地使用。如果一个特征从未被使用过,那么它就不重要,这样我们就可以看到我们有一个内置的特征选择。
注意,这种方法对特征之间的相关性很敏感。在尝试确定要分割哪个特征时,我们可能会随机选择两个高度相关的特征,导致模型使用比必要的更多特征,因此这些特征的重要性低于单独选择任何一个。实际上,变量重要性是由rpart()自动计算的,并存储在返回的树模型上的variable.importance属性中。使用barplot()绘制它会产生以下结果:

对于 RTS 类型的经验玩家来说,这个图表看起来相当合理和直观。根据这个图表,技能的最大分隔点是玩家每分钟做出的平均游戏动作数(APM)。经验丰富且有效的玩家能够做出很多动作,而经验较少的玩家则会做出较少的动作。
乍一看,这似乎仅仅是获得所谓的肌肉记忆和培养更快反应速度的问题,但实际上是知道要执行哪些动作,在游戏中进行策略和计划(这是优秀玩家的特征),这也显著提高了这个指标。
另一个与速度相关的属性是ActionLatency特征,它本质上衡量的是从选择在战场上关注特定位置到在该位置执行第一个动作之间的时间。更好的玩家在查看地图位置上花费的时间会更少,并且在选择单位、下达命令和根据游戏中的情况图像做出决定方面会更快。
回归模型树在实际应用中
我们将在本章的实验中通过一个非常简短的演示来结束,演示如何在 R 中运行回归模型树,然后是一些关于改进 M5 模型概念的信息。
我们可以使用包含M5P()函数的RWeka包非常容易地做到这一点。这遵循了典型的惯例,即需要一个公式和一个包含训练数据的 data frame:
> library("RWeka")
> m5tree <- M5P(LeagueIndex ~ ., data = skillcraft_train)
> m5tree_predictions <- predict(m5tree, skillcraft_test)
> m5tree_SSE <- compute_SSE(m5tree_predictions,
skillcraft_test$LeagueIndex)
> m5tree_SSE
[1] 714.8785
注意,我们使用默认设置几乎可以达到与调整过的 CART 树相当的性能。我们将让读者进一步探索这个函数,但我们将再次在第九章中回顾这个数据集,集成方法。
注意
关于包含多个案例研究的回归模型树的好参考是 Quinlan 的原始论文,题为Learning with continuous cases,发表于 1992 年的澳大利亚联合人工智能会议。
M5 模型的改进
标准的 M5 算法树目前已被接受为在完成复杂回归任务中决策树中最先进的一种模型。这主要是因为它产生的准确结果以及它处理具有数百个属性的大量维度任务的能力。
为了改进或优化标准的 M5 算法,最近推出了M5Flex,可能是最可行的选择。M5Flex 算法方法将尝试通过领域知识来增强标准的 M5 树模型。换句话说,M5Flex 赋予那些熟悉数据集的人审查和选择那些重要节点(在模型树中)的分割属性和分割值的权力,假设他们“知道得最好”,因此产生的模型将比仅依赖标准 M5 的模型更加准确、一致,并且更适合实际应用。使用 M5Flex 的一个缺点或批评是,在大多数情况下,领域专家可能并不总是可用。
M5 的另一种改进尝试是M5opt。M5opt 是一种半非贪婪算法,它采用了一种不试图整体解决全局复杂优化问题的方法,或者不将其视为“一个整体”,而是将生成树层的程序分为两个不同的步骤,每个步骤使用不同类型或性质的算法,这取决于树的层:
-
全局优化:使用全局(多极值)优化算法(或优于贪婪方法的算法)生成树的顶层(从第一层开始)。
-
贪婪搜索:使用类似于标准 M5 的更快“贪婪算法”生成树的其余部分(树的底层)。
此外,应用全局优化的层在不同分支中可能不同。然而,将所有分支固定在某个值上似乎是合理的;这允许在速度和优化之间进行灵活的权衡。尽管使用 M5opt 算法优化构建树模型的过程已被证明可以产生比使用标准 M5 创建的模型更准确的模型,但由于“非贪婪”算法的工作性质,计算成本将会增加。
为了解决这个问题,可以通过审查哪个树层“最合适”,或者哪个层可以以最小的成本产生最大的准确性来控制成本,然后在树的该层进行更彻底的非贪婪搜索。
进一步优化标准 M5 的尝试包括尝试结合 M5opt 和 M5flex 方法。
最后,在第五章中讨论的人工神经网络(ANNs),作为一种替代标准 M5 的方法被提出,但仅限于那些假设树模型较为简单的情况下。在复杂模型中,M5 几乎总是优于 ANN。
概述
在本章中,我们学习了如何构建用于回归和分类任务的决策树。我们了解到,尽管这个想法很简单,但在构建我们的树模型时,我们仍需做出几个决定,例如选择何种分割标准,以及何时以及如何修剪我们的最终树。
在每种情况下,我们都考虑了多种可行的选项,结果发现,有几种算法被用来构建决策树模型。决策树的一些最佳特性是它们通常易于实现和解释,同时不对数据的潜在模型做出假设。决策树具有原生选项来执行特征选择和处理缺失数据,并且能够处理广泛的特征类型。
话虽如此,我们从计算的角度看到,由于可能分割数量的指数增长,找到分类变量的分割点相当昂贵。此外,我们还看到,由于潜在分割点数量众多,分类特征往往倾向于在信息增益等分割标准中引入选择偏差。
使用决策树模型的另一个缺点是它们可能不稳定,这意味着数据中的微小变化可能会改变树中较高位置的分割决策,结果我们可能会得到一个完全不同的树。此外,特别是对于回归问题而言,由于叶节点数量有限,我们的模型在输出上可能不够细致。最后,尽管有几种不同的剪枝方法,但我们应注意到决策树可能容易过拟合。
在下一章中,我们不会关注新的模型类型。相反,我们将探讨不同的技术来组合多个模型,例如袋装法和提升法。这些方法统称为集成方法。这些方法已被证明在提高简单模型的性能和克服前面讨论的基于树的模型的局限性(如模型不稳定和易过拟合)方面非常有效。
我们将介绍一个著名的算法,AdaBoost,它可以与迄今为止我们所看到的一些模型一起使用。此外,我们还将介绍随机森林,作为一种专门为决策树设计的特殊集成模型。一般来说,集成方法通常不易解释,但对于随机森林,我们仍然可以使用本章中看到的变量重要性的概念,以便对模型最依赖哪些特征有一个整体的认识。
第八章。维度缩减
构建一个有用的预测模型需要分析适当数量的观察(或案例)。这个数字将根据你的项目或目标而变化。严格来说,分析的变化(不一定是更多的数据)越多,模型的结果或结果就越好。
本章将讨论通过各种常见方法(如相关性分析、主成分分析、独立成分分析、共同因素分析和非负矩阵分解)在不影响分析结果(或项目的成功)的情况下,缩减观察数据的大小或数量的概念。
让我们先从澄清“维度缩减”的含义开始。
定义 DR
人们普遍认为,难以理解或可视化超过三个维度的数据。
维度(-性)缩减是指尝试减少在统计考虑下的随机变量(或数据维度)的数量,或者也许更好地说:找到对感兴趣的特性集的低维表示。
这使得数据科学家可以:
-
避免所谓的维度灾难
注意
维度灾难是指当尝试分析高维空间(通常具有数百或数千个维度)中的数据时出现的一种现象,而在低维设置或日常经验中并不存在。
-
减少正确分析数据所需的时间和内存量
-
使数据更容易可视化
-
消除与模型目的无关的特征
-
减少模型噪声
数据维度缩减的一个有用(尽管可能被过度使用)的概念示例是计算机生成的面孔或面孔或单个人类面孔的图像,实际上是由成千上万张单个人类面孔的图像组成的。如果我们考虑每个个体的面部特征,数据可能会变得难以处理;然而,如果我们将这些图像的维度缩减为几个主成分(眼睛、鼻子、嘴唇等),数据就会变得更容易管理。
以下各节概述了一些最常见的维度缩减方法和策略。
相关数据分析
人们通常认为术语依赖性和关联性具有相同的意义。这些术语用于描述两个(或更多)随机变量或双变量数据之间的关系。
注意
随机变量是一个变量量,其值取决于可能的结果;双变量数据是包含两个变量且可能或可能没有暴露关系的资料。
相关数据,或具有相关性的数据,描述了一种(通常是线性的)统计关系。相关性的一个流行例子是产品定价,例如当产品的受欢迎程度推动制造商的定价策略时。
识别相关性非常有用,因为它们可以是可被利用或用于在人群或数据文件中降低维度的预测关系。
常见的相关性和预测关系例子通常涉及天气,但另一个想法可以在www.nfl.com/找到。如果你熟悉国家橄榄球联盟并访问过该网站,那么你就会知道每个 NFL 球队都销售带有球队标志的商品,而赢得胜利赛季的球队那年很可能有更高的产品销售额。
在这个例子中,存在一种因果关系,因为一支球队的胜利赛季导致其球迷购买更多球队商品。然而,通常来说,存在相关性并不足以推断出(甚至)存在因果关系(关于这一点,本章后面将有更多讨论)。
散点图
作为旁白,散点图常用于图形表示两个变量之间的关系,因此是可视化相关数据的绝佳选择。
使用 R 的plot函数,你可以轻松生成我们获胜队伍示例的相当不错的视觉效果,如下所示:

备注
作为其他可视化选项,箱线图和小提琴图也可以使用。
如前图所述,相关性描述或衡量两个或多个变量一起波动的水平或程度(在前面的例子中,赢得的比赛和销售的纪念品)。
这种测量可以归类为正相关性或负相关性,其中正相关性表明这些变量在平行增加或减少的程度,而负相关性表明一个变量增加时另一个变量减少的程度。
相关系数是衡量一个变量的值的变化将如何或可以预测另一个变量的值的变化程度的统计量。
当一个变量的变化可靠地预测另一个变量相似的变化时,人们往往会倾向于认为这意味着一个变量的变化导致另一个变量的变化。然而,相关性并不意味着因果关系。[例如,可能存在一个影响两个变量的未知因素]。
为了说明,想象一下一种数据相关性情况,其中电视广告暗示穿某一品牌鞋的运动员跑得更快。然而,这些运动员每个人都聘请了个人教练,这可能是影响因素。
因此,相关性(或进行相关性分析)是一种统计技术,可以显示变量对之间的相关程度以及是否存在相关性。当识别出强相关的变量时,从分析中移除其中一个变量在统计学上是有意义的;然而,当变量对看起来相关但关系较弱时,最好让两个变量都保留在总体中。
例如,赢得职业足球比赛的球队和球队商品的销售是相关的,因为赛季赢得比赛的球队通常销售更多的商品。然而,一些球队比其他球队有更忠实的追随者,即使他们输的比赢的多,商品销售仍然很高。尽管如此,赢得超过 50%比赛的球队的销售额平均要高于输掉 50%比赛的球队,赢得超过 75%比赛的球队销售额要超过输掉 75%比赛的球队,依此类推。那么,赢得比赛对球队商品销售的影响是什么?这可能很难确定,但确定数据点之间的相关性可以告诉你一个球队的业绩变化中有多少与他们的商品销售相关。
虽然赢得比赛和销售商品的相关性可能很明显,但我们的例子可能包含未预料到的数据相关性。你也可能怀疑存在其他相关性,但不确定哪一个是最强的。
对数据进行周密、彻底的相关性分析可以加深你对数据的理解;然而,就像所有统计技术一样,相关性只适用于某些类型的数据。相关性适用于可量化数据,其中数字是有意义的——通常是某种数量(如销售的产品)。它不能用于纯粹分类数据,如个人的性别、品牌偏好或教育水平。
让我们继续更详细地看看因果关系。
因果关系
理解因果关系及其与相关性的比较概念非常重要。
在统计学中,因果关系被定义为可以导致另一个变量或其内部发生变化的变量。这种效果的结果可以总是被预测,从而在变量之间建立一种可以确定的关系。
因果关系涉及相关性,但相关性并不一定意味着因果关系。每个与另一个变量有某种联系的变量可能看起来暗示了因果关系。这并不总是这样;将一件事与另一件事联系起来并不总是证明结果是由另一件事引起的。经验法则是:只有当你可以直接将一个变量的变化或变化与另一个变量的变化联系起来时,你才能说它是因果关系。
相关系数的程度
为了表示或量化变量之间的统计关系,我们使用一个称为相关系数的数字:
-
它的范围从-1.0 到+1.0
-
它越接近+1 或-1,两个变量之间的关系就越紧密
-
如果为零,这意味着它所代表的变量之间没有 关系
-
如果是正数,这意味着,当一个变量增加时,另一个变量也增加
-
如果是负数,这意味着,当一个变量增加时,另一个变量减少
报告相关性
虽然相关系数通常简单地报告(一个介于 -1 和 +1 之间的值),但它们通常首先被平方,以便更容易理解。
如果相关系数是 r,那么 r 的平方(你移除小数点)等于一个变量的变化与另一个变量的变化相关的百分比。据此,相关系数为 .5 意味着变化百分比为 25%。
在本章的早期部分,我们查看了一个简单的可视化,展示了赢得比赛数量与一支球队商品销售额之间的相关性。在实践中,当创建 相关性报告 时,你也可以展示第二个图表——统计显著性。添加显著性将显示所识别的相关信息中的错误概率。最后,由于样本大小可能会对结果产生重大影响,因此展示样本大小也是一个好的做法。
总结来说,识别观察数据中的相关性是一种常见且被接受的方法来实现降维。另一种方法是 主成分分析,我们将在下一节中介绍。
主成分分析
主成分分析(PCA)是另一种流行的统计技术,用于数据降维。
注意
PCA 也被称为 Karhunen-Loeve 变换 方法,实际上,根据听众的不同,PCA 被说成是最常用的降维技术。
PCA 是一种试图不仅降低数据维度,而且尽可能保留数据中变化的技术。
注意
主成分分析是因子分析(将在本章后面讨论)的一种方法,它考虑了数据文件中的 总方差。
PCA 的过程使用所谓的 正交变换 过程将一组可能 相关 的变量的观察值转换为一组线性 不相关 的变量的值。这些变量被称为 主成分,或数据的变动的首要模式。
通过 PCA 的努力,主成分(或变量)的数量应该 小于或等于 原始变量的数量或原始观察的数量,从而 降低数据的独立维度(即降维)或独立维度的数量。
这些主成分被定义和排列,使得第一个主成分尽可能多地解释数据中的变异性,并且每个后续主成分都有可能的最大方差,前提是它必须与前一个成分正交。
执行主成分分析的一般概念或目标是观察,无论是对独立变量还是对因变量产生影响,都会得到相同的结果,无论是否单独对变量的影响进行建模。
PCA 通常用作在执行探索性数据分析或数据概览时的工具,因为其操作可以被认为是以最佳方式揭示数据内部结构,从而解释数据的方差,但它也可以在预测建模中有所帮助。
注意
数据概览涉及通过查询、实验和审查逻辑地了解数据。在概览过程之后,你可以使用你收集到的信息为数据添加上下文(以及/或应用新的视角)。向数据添加上下文需要操作数据,例如通过添加计算、聚合或额外的列,以及重新排序等。
主成分分析或 PCA 是常见因子分析(将在本章后面讨论)的另一种形式。因子分析通常包含更多关于被观察数据潜在结构的特定领域假设,并解算一个略微不同的矩阵的特征向量。
在一个非常高的层面上理解,可以将 PCA 想象为持续地为一个n-维椭球体拟合一个绘制的数据文件,其中椭球体的每个轴代表该数据的一个主成分。如果椭球体的某个轴很小,那么该轴上的方差也较小,通过从数据文件中省略该轴(及其对应的主成分),我们将只丢失关于数据文件的信息的一小部分;否则,该轴(主成分)将保留,代表数据文件整体均值的一些变异程度。
使用 R 理解 PCA
如前一段所述,如果对 PCA 的理解不需要太多的努力和逻辑,你可以使用通用的 R 函数princomp。
princomp函数通过使用数据文件主成分的计算标准差来揭示数据中的变异来源,从而简化复杂的数据文件。这最好通过经典的 iris 数据文件(在安装 R 编程语言时提供)来说明。
数据文件(部分显示在下述截图)包含超过 150 种iris的花朵属性(花瓣宽度、花瓣长度、萼片宽度和萼片长度):

我们应该注意,princomp函数无法处理字符串数据(这没关系,因为我们的 PCA 分析只对识别主成分的数值变化感兴趣),因此我们可以通过以下 R 代码行将数据的前五行保存到名为pca的新对象中(省略Species列):
pca<-princomp(iris[-5])
接下来,我们可以使用我们刚刚创建的pca对象上的 R summary命令。生成的输出如下所示:

你可以从前面的图像中看到,数据集中的 92.4%的变化仅由第一个成分解释,而 97.8%由前两个成分解释!
为了更好地理解,我们可以使用 R 的screeplot函数和我们的pca对象来可视化前面的观察结果,如下所示:

screeplot函数生成一个屏幕图,显示在主成分分析中,每个组件解释的数据文件中总变异的比例,显示了需要多少主成分来总结数据。
我们生成的可视化如下:

PCA 的结果通常根据每个组件的得分(有时称为因子得分)来考虑,这些得分是对应于数据文件中特定数据点的转换变量值,以及加载,即每个标准化原始变量应该乘以的权重,以获得组件得分。
我们可以使用 R 的loadings和scores命令来查看我们的加载和得分信息,如下所示:

独立成分分析
另一个关于降维的概念是 ICA,或独立成分分析(ICA)。这是一个尝试在高度数据源中揭示或验证统计独立变量或维度的过程。
使用一个选定的 ICA 过程,可以识别数据中的每个变量或维度,检查其独立性,然后选择性地从整体数据分析中移除或保留。
注意
如果读者花时间对 ICA 进行任何额外的研究,他/她将遇到一个常见的应用示例,称为鸡尾酒会问题,即在嘈杂的房间里监听一个人的讲话。
定义独立性
ICA 试图通过最大化组件的统计独立性来寻找数据中的独立成分。但变量独立性的定义究竟是什么呢?
如果一个变量的实现不影响另一个变量的概率分布,则确定这些组件是独立的。
确定独立性的方法有很多,你的选择将决定使用的 ICA 算法的形式。
独立性的两种最广泛使用的定义(对于 ICA)是:
-
最小化互信息(MMI):互信息衡量两个或多个组件共享的信息,衡量知道这些变量中的一个变量在多大程度上减少了关于其他变量的不确定性。组件包含的互信息越少,该组件就越独立。
-
非高斯最大化(NGM):非高斯最大化旨在避免或减少平均值,换句话说,突出一个成分中的可变性(其独立性的水平)。
ICA 预处理
在将任何 ICA 逻辑应用于数据之前,典型的 ICA 方法使用诸如中心化和白化等预处理步骤,以便简化并减少问题的复杂性,并突出数据中平均或协方差无法轻易解释的特征。
换句话说,在尝试确定成分独立性水平之前,可以使用各种预处理方法来审查和操作数据文件,使其更容易理解。
中心化是最常用的基本预处理方法,正如其名所示,涉及通过减去其均值来中心化数据点(或x),从而使其成为一个零均值变量。白化是另一种预处理方法,它将数据点(x)线性变换,使其成分不相关且等于单位一。
因子分析
因子分析是另一种重要的统计方法,用于确定和描述数据(或数据成分)在观测、相关变量与(可能)较少的未观测(或也称为潜在)变量或数据因子之间的可变性。
注意
观测变量是指那些你应该在数据文件中有明确测量的变量,而未观测变量是指那些你没有明确测量的变量,也许是从数据文件中的某些观测变量中推断出来的。
换句话说,我们考虑:六种观测变量的变化是否反映了仅两种未观测变量中发现的相同变化?
当数据文件包含大量似乎反映较少未观测变量的观测变量时,可以使用因子分析,这为我们提供了降维的机会,减少了需要研究的项目数量,并观察它们是如何相互关联的。
总体而言,因子分析涉及使用技术来帮助产生更少的变量线性组合,尽管变量数量减少了,但它们解释了数据成分中的大部分方差。
简而言之,对数据文件进行因子分析试图寻找先前未观测变量中的协同变化。
探索和确认
通常,人们会从探索数据文件开始进行因子分析,探索一组数据观测变量的可能潜在因子结构,而不强加一个预定的结果。这个过程被称为探索性因子分析(EFA)。
在探索性因子分析阶段,我们试图确定未观察或隐藏变量的数量,并提出一种使用更少的隐藏变量来解释数据中变异的方法;换句话说,我们正在压缩观察所需的信息。
一旦做出确定(形成一个或多个假设),接下来就会想要确认(或测试或验证)在 EFA 过程中揭示的因子结构。这一步骤通常被称为验证性因子分析(CFA)。
使用 R 进行因子分析
如同往常,R 编程语言提供了多种执行适当因子分析的方法。
例如,我们有 R 函数factanal。此函数对一个数值矩阵执行最大似然因子分析。在其最简单形式中,此函数需要x(你的数值矩阵对象)和要考虑的因子数量(或拟合):
factanal(x, factors = 3)
我们可以在这里运行一个简单的示例,以澄清基于 R 文档和许多通用函数。
首先,通过将随机数值列表组合成六个变量(保存为 R 向量 v1 到 v6)来构建一个数值矩阵。
如下所示,R 代码:
> v1 <- c(1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,4,5,6)
> v2 <- c(1,2,1,1,1,1,2,1,2,1,3,4,3,3,3,4,6,5)
> v3 <- c(3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,5,4,6)
> v4 <- c(3,3,4,3,3,1,1,2,1,1,1,1,2,1,1,5,6,4)
> v5 <- c(1,1,1,1,1,3,3,3,3,3,1,1,1,1,1,6,4,5)
> v6 <- c(1,1,1,2,1,3,3,3,4,3,1,1,1,2,1,6,5,4)
下一步是从我们的六个变量创建一个数值矩阵,命名为m1。
要完成此操作,我们可以使用 R 的cbind函数:
> m1 <- cbind(v1,v2,v3,v4,v5,v6)
以下截图显示了我们的代码执行情况,以及我们的对象m1的摘要:

R 函数summary为我们提供了关于我们六个变量的有趣细节,例如最小值和最大值、中位数和平均值。
现在我们有一个数值矩阵,我们可以通过运行 R 的cor函数来审查我们六个变量之间的方差。
以下截图显示了由cor函数生成的输出:

有趣的信息,但让我们继续前进。
最后,我们现在准备好使用(R 函数)factanal。
再次,使用函数的最简单形式——只需提供要分析的数据名称(m1)和要考虑的因子数量(让我们使用3)——以下输出就为我们生成了:

输出
R 函数factanal首先计算独特性。独特性是每个变量独有的方差,不与其他变量共享。
因子载荷也被计算并显示在因子矩阵中。因子矩阵是包含所有变量在所有提取的因子上的因子载荷的矩阵。因子载荷表示因子与变量之间的简单相关,例如观察分数与未观察分数之间的相关。一般来说,越高越好。
注意前一个截图生成的最终消息:
模型的自由度为 0,拟合度为 0.4755
自由度的数量可以定义为可以完全指定系统位置的独立坐标的最小数量(因此,零不是那么好)。所以,如果我们通过增加因素的数目到四个进行一点实验,我们会看到factanal足够聪明,能告诉我们,只有六个变量时,四个因素太多了。
以下截图显示了使用四个因素生成的(全局)输出:

根据显示的结果,现在让我们尝试将因素的数目减少到两个:

注意这次我们看到两个因素就足够了。
根据前面的内容,我们简单的因子分析结果似乎有所改善,但这是否意味着变量的数量可以正确地描述数据?
显然,需要更多的数据、更多的变量和更多的实验!
NNMF
术语分解指的是分解的过程或行为,即把一个对象分解成其他对象(或因子)的结果,当它们相乘时等于原始对象。因此,矩阵分解就是分解矩阵,或者找到两个(或更多)矩阵,当它们相乘时等于原始矩阵。
非负矩阵分解(NMF,NNMF)是使用算法将矩阵分解成(通常是)两个矩阵,这三个矩阵都具有没有负元素的特性。这种非负性使得生成的矩阵更容易分析。
摘要
在本章中,我们介绍了(数据)降维的概念及其目的:在创建预测模型时减少需要考虑的总观测数。
对减少的最常见方法、策略和概念进行了回顾,包括相关数据分析、报告相关性、PCA、ICA 和因子分析。
在下一章中,我们思考几个训练好的模型如何作为一个集成体一起工作,以产生一个比单个模型更强大的单一模型。
第九章。集成方法
在本章中,我们暂时放慢学习新模型的速度,而是思考如何将几个训练好的模型作为一个集成一起工作,以产生一个比单个模型更强大的单一模型。
我们将要研究的第一种集成类型使用相同数据集的不同样本来训练同一模型的多个版本。然后,这些模型对新观测的正确答案进行投票,并基于问题的类型做出平均或多数决策。这个过程被称为 bagging,即 bootstrap aggregation 的缩写。另一种结合模型的方法是 boosting。这本质上涉及训练一系列模型,并为被错误分类或远低于其预测值的观测分配权重,以便后续模型被迫优先考虑它们。
作为方法,Bagging 和 Boosting 相当通用,并且已经应用于多种不同类型的模型。在第六章(第六章
这个数字也恰好是整个训练数据集中未选择的观察结果的平均比例,因为我们通过将前面的表达式乘以 n 并除以 n 来计算这个量。这个表达式的数值结果可以近似为 e-1,大约是 37%。因此,所选观察结果的平均比例大约是 63%。这个数字只是一个平均值,当然,对于更大的 n 值来说更准确。
间隔和袋外观察结果
让我们假设对于特定的观察结果 x[1],85%的模型预测正确的类别,而剩下的 15%预测错误的类别。让我们再假设有一个另一个观察结果 x[2],其相应的百分比是 53%和 47%。显然,我们的直觉表明,我们应该对前者的分类比后者的分类更有信心。换句话说,分类比例之间的差异,也称为间隔(类似于但不要与支持向量机中使用的间隔混淆),是我们分类置信度的一个良好指标。
观察到的 70%的误差范围 x[1] 比观察到的 6%的误差范围 x[2] 大得多,因此,我们对我们正确分类前者的能力更有信心。一般来说,我们希望的是一个对所有观察都有大误差范围的分类器。对于只有少数观察有较小误差范围的分类器的泛化能力,我们不太乐观。
读者可能已经注意到,在生成每个模型的预测值集时,我们使用了训练模型相同的同一数据。如果我们仔细观察程序的步骤 3,我们会用我们在步骤 2中用于训练模型的相同采样数据进行分类。尽管我们最终依赖于在最后使用平均过程来获得未见数据的袋装分类器的估计准确率,但我们实际上在过程中任何步骤都没有使用任何未见数据。
记住,在步骤 1中,我们构建了一个训练数据的样本来训练我们的模型。从原始数据集中,我们将未选择用于该程序特定迭代的观察称为袋外观察(OOB)。因此,这些观察在该迭代中未用于模型的训练。因此,我们实际上可以使用 OOB 观察来记录特定模型的准确率。
最后,我们计算所有 OOB 准确率的平均值以获得平均准确率。这个平均准确率更有可能是对未见数据上袋装分类器性能的现实和客观估计。对于特定的观察,所分配的类别是所有在对应训练样本中没有选择该观察的分类器的多数投票结果。
从原始数据集有放回地抽取的样本,称为自助样本,类似于从同一分布中抽取多个样本。由于我们试图使用多个不同的样本而不是一个来估计相同的目标函数,平均过程减少了结果的变化。为了看到这一点,考虑尝试估计从同一分布中抽取的一组观察的平均值,并且所有观察都是相互独立的。更正式地说,这些被称为独立同分布(iid)的观察。这些观察的平均值的方差是
。
这表明随着观测值的增加,方差会减小。袋装法试图为我们试图建模的函数实现相同的行为。我们并没有真正独立的训练样本,而是被迫使用自助样本,但这个思想实验应该足以让我们相信,原则上,袋装法有可能减少模型的方差。同时,这种平均过程是对我们试图估计的函数中任何局部峰值的平滑。假设我们试图估计的目标回归函数或分类边界实际上是平滑的,那么袋装法也可能减少我们模型的偏差。
使用袋装法预测复杂技能学习
袋装法和提升法都非常受我们第六章中研究的基于树的模型的欢迎。有许多值得注意的实现将这些方法应用于构建树的方法,如 CART。
例如,ipred包包含了一个用于构建由rpart()构建的树的袋装预测器的实现。我们可以尝试这个包提供的bagging()函数。为此,我们指定要构建的袋装树的数量,使用nbagg参数(默认为25),并通过将coob参数设置为TRUE来指示我们想要使用 OOB(Out-of-Bag)样本来计算准确率。
我们将使用上一章中的复杂技能学习数据集,并使用相同的训练数据框进行此操作:
> baggedtree <- bagging(LeagueIndex ~ ., data = skillcraft_train,
nbagg = 100, coob = T)
> baggedtree_predictions <- predict(baggedtree, skillcraft_test)
> (baggedtree_SSE <- compute_SSE(baggedtree_predictions,
skillcraft_test$LeagueIndex))
[1] 646.3555
如我们所见,测试集上的 SSE(均方误差)低于我们在调整单个树时看到的最低 SSE。然而,增加袋装迭代次数似乎并没有显著提高这种性能。我们稍后还会再次访问这个数据集。
使用袋装法预测心脏病
使用袋装法的典型用例是决策树;然而,重要的是要记住我们可以使用这种方法与各种不同的模型。在本节中,我们将展示如何构建一个袋装逻辑回归分类器。我们在第三章中为 Statlog Heart 数据集构建了一个逻辑回归分类器,逻辑回归。现在,我们将重复那个实验,但使用袋装法来查看我们是否可以改进我们的结果。首先,我们将用放回抽样来抽取样本,并使用这些样本来训练我们的模型:
> M <- 11
> seeds <- 70000 : (70000 + M - 1)
> n <- nrow(heart_train)
> sample_vectors <- sapply(seeds, function(x) { set.seed(x);
return(sample(n, n, replace = T)) })
在我们的代码中,数据框heart_train和heart_test指的是我们在第三章中准备的数据框,逻辑回归。我们首先决定我们将训练的模型数量,并设置适当的M值。在这里,我们使用了初始值11。
注意,使用奇数个模型进行袋装是一种好方法,因为在多数投票过程中,二元分类永远不会出现平局。为了可重复性,我们设置了一个种子向量,我们将使用它。这只是一个从任意选择的起始种子值70000开始的计数器。我们代码中的sample_vectors矩阵包含一个矩阵,其中列是随机选择的训练数据行的索引。请注意,在训练数据中,行号从 1 到 230,这使得采样过程易于编码。
接下来,我们将定义一个函数,该函数根据用于训练数据框的索引采样向量创建单个逻辑回归模型:
train_1glm <- function(sample_indices) {
data <- heart_train[sample_indices,];
model <- glm(OUTPUT ~ ., data = data, family = binomial("logit"));
return(model)
}
> models <- apply(sample_vectors, 2, train_1glm)
在上一行的代码中,我们遍历了我们之前产生的sample_vectors矩阵的列,并将它们作为输入提供给我们的逻辑回归模型训练函数train_1glm()。然后,这些模型被存储在我们的最终列表变量models中。现在它包含了 11 个训练好的模型。
作为评估我们模型的第一种方法,我们将使用训练每个单独模型所用的数据。为此,我们将构建一个名为bags的变量,它是一个包含这些数据框的列表,这次带有唯一的索引,因为我们不想在评估中使用任何来自自助抽样过程的重复行。我们还将为这些数据框添加一个名为ID的新列,该列存储来自heart_train数据框的原始行名。我们很快就会看到我们为什么要这样做:
get_1bag <- function(sample_indices) {
unique_sample <- unique(sample_indices);
df <- heart_train[unique_sample, ];
df$ID <- unique_sample;
return(df)
}
> bags <- apply(sample_vectors, 2, get_1bag)
现在我们有一个模型列表和一个它们训练过的数据框列表,后者没有重复的观测值。从这两个列表中,我们可以创建一个预测列表。对于每个训练数据框,我们将在其中添加一个名为PREDICTIONS {m}的新列,其中{m}将是用于进行预测的模型的编号。因此,bags列表中的第一个数据框将有一个名为PREDICTIONS 1的预测列。第二个数据框将有一个名为PREDICTIONS 2的预测列,第三个将有一个名为PREDICTIONS 3的预测列,依此类推。
以下调用产生了一组新的数据框,正如之前所描述的,但只保留PREDICTIONS{m}和ID列,并且这些数据框被存储在变量training_predictions中作为一个列表:
glm_predictions <- function(model, data, model_index) {
colname <- paste("PREDICTIONS", model_index);
data[colname] <- as.numeric(
predict(model, data, type = "response") > 0.5);
return(data[,c("ID", colname), drop = FALSE])
}
> training_predictions <-
mapply(glm_predictions, models, bags, 1 : M, SIMPLIFY = F)
接下来,我们想要将这些数据框合并到一个单独的数据框中,其中行是原始数据框的行(因此,对应于数据集中的观测值),列是每个模型对观测值做出的预测。如果一个特定的行(观测值)没有被采样过程选中来训练特定的模型,它将在对应于该模型做出的预测的列中有一个NA值。
为了明确起见,请记住,每个模型只对其用于训练的观测值做出预测,因此每个模型做出的预测数量小于我们起始数据中可用的观测值总数。
由于我们已经将原始heart_train数据框的行号存储在每个在之前步骤创建的数据框的ID列中,我们可以使用这个列进行合并。我们使用Reduce()函数以及merge()函数来将我们的training_predictions变量中的所有数据框合并到一个新的数据框中。以下是代码:
> train_pred_df <- Reduce(function(x, y) merge(x, y, by = "ID",
all = T), training_predictions)
让我们来看看这个聚合数据框的前几行和列:
> head(training_prediction_df[, 1:5])
ID PREDICTIONS 1 PREDICTIONS 2 PREDICTIONS 3 PREDICTIONS 4
1 1 1 NA 1 NA
2 2 0 NA NA 0
3 3 NA 0 0 NA
4 4 NA 1 1 1
5 5 0 0 0 NA
6 6 0 1 0 0
第一列是用于合并数据框的ID行。这个列中的数字是从起始训练数据框中观测值的行号。PREDICTIONS 1列包含第一个模型做出的预测。我们可以看到,这个模型将行1、2、5和6作为其训练数据的一部分。对于第一行,模型预测类别1,而对于其他三行,它预测类别0。行3和4不是其训练数据的一部分,因此有两个NA值。这种推理可以用来理解剩余的列,这些列对应于接下来训练的三个模型。
在构建了这个数据框之后,我们现在可以使用前一个数据框的每一行中的多数投票来生成整个袋装模型的训练数据预测。一旦我们有了这些预测,我们只需将预测与原始heart_train数据框相应行的标签值相匹配,并计算我们的准确度:
> train_pred_vote <- apply(train_pred_df[,-1], 1,
function(x) as.numeric(mean(x, na.rm = TRUE) > 0.5))
> (training_accuracy <- mean(train_pred_vote ==
heart_train$OUTPUT[as.numeric(train_pred_df$ID)]))
[1] 0.9173913
现在,我们有了我们的第一个袋装模型的准确度衡量指标——91.7%。这与在训练数据上测量准确度相似。我们将现在重复这个过程,使用每个模型的 OOB 观测值来计算 OOB 准确度。
然而,这里有一个需要注意的地方。在我们的数据中,ECG 列是一个有三个级别的因素,其中之一,即级别 1,非常罕见。因此,当我们从原始训练数据中抽取 bootstrap 样本时,可能会遇到这个因素级别从未出现过的样本。当这种情况发生时,glm()函数会认为这个因素只有两个级别,并且当它遇到一个它以前从未见过的 ECG 因素值的观测值时,生成的模型将无法做出预测。
为了处理这种情况,我们需要将这个因素的第一级值替换为NA值,对于 OOB 观察值,如果它们对应的模型在其训练数据中没有至少一个具有 ECG 因素级别 1 的观察值。本质上,为了简单起见,我们将在这些问题观察值出现时,根本不尝试对这些观察值进行预测。考虑到这一点,我们将定义一个函数来计算特定样本的 OOB 观察值,然后使用这个函数来找到所有样本的 OOB 观察值:
get_1oo_bag <- function(sample_indices) {
unique_sample <- setdiff(1 : n, unique(sample_indices));
df <- heart_train[unique_sample,];
df$ID <- unique_sample;
if (length(unique(heart_train[sample_indices,]$ECG)) < 3)
df[df$ECG == 1,"ECG"] = NA;
return(df)
}
> oo_bags <- apply(sample_vectors, 2, get_1oo_bag)
接下来,我们将使用我们的glm_predictions()函数来使用我们的 OOB 样本计算预测。其余的过程与之前所做的相同:
> oob_predictions <- mapply(glm_predictions, models, oo_bags,
1 : M, SIMPLIFY = F)
> oob_pred_df <- Reduce(function(x, y) merge(x, y, by = "ID",
all = T), oob_predictions)
> oob_pred_vote <- apply(oob_pred_df[,-1], 1,
function(x) as.numeric(mean(x, na.rm = TRUE) > 0.5))
> (oob_accuracy <- mean(oob_pred_vote ==
heart_train$OUTPUT[as.numeric(oob_pred_df$ID)],
na.rm = TRUE))
[1] 0.8515284
如预期的那样,我们看到我们的 OOB 准确率,这是对未见数据性能的更好衡量,低于训练数据准确率。在前一个代码样本的最后一条线中,我们在计算 OOB 准确率时排除了NA值。这是很重要的,因为可能有一个特定的观察值出现在所有的 bootstrap 样本中,因此永远不会用于 OOB 预测。
同样,我们对 ECG 因素罕见级别的修复意味着即使一个观察值没有被采样过程选中,我们可能仍然无法对其做出预测。读者应该验证只有由于上述两种现象的结合,才有一个观察值产生了NA值。
最后,我们将使用heart_test数据框重复这个过程第三次,以获得测试集准确率:
get_1test_bag <- function(sample_indices) {
df <- heart_test;
df$ID <- row.names(df);
if (length(unique(heart_train[sample_indices,]$ECG)) < 3)
df[df$ECG == 1,"ECG"] = NA;
return(df)
}
> test_bags <- apply(sample_vectors, 2, get_1test_bag)
> test_predictions <- mapply(glm_predictions, models, test_bags,
1 : M, SIMPLIFY = F)
> test_pred_df <- Reduce(function(x, y) merge(x, y, by = "ID",
all = T), test_predictions)
> test_pred_vote <- apply(test_pred_df[,-1], 1,
function(x) as.numeric(mean(x, na.rm = TRUE) > 0.5))
> (test_accuracy <- mean(test_pred_vote ==
heart_test[test_pred_df$ID,"OUTPUT"], na.rm = TRUE))
[1] 0.8
测试集上的准确率似乎低于我们没有使用 Bagged 模型时找到的准确率。这对我们来说不一定是坏消息,因为测试集非常小。事实上,这个 Bagged 模型与第三章中训练的原始模型(逻辑回归)的性能差异是 32/40 比 36/40,也就是说,它只比 40 个观察值中的四个更差。
在现实世界中,我们通常希望有一个更大的测试集来估计我们的未见准确率。事实上,正因为如此,我们更倾向于相信我们的 OOB 准确率测量,这是在更多的观察值上进行的,并且平均了多个模型。
在这种情况下,Bagging 实际上对我们非常有用,因为它为我们提供了一个模型,我们可以用它来更好地估计测试准确率,这是因为测试集非常小,我们可以使用 OOB(Out-of-Bag)观察值。作为最后的演示,我们多次运行之前的代码,使用不同的M值,并将结果存储在一个数据框中:
> heart_bagger_df
M Training Accuracy Out-of-bag Accuracy Test Accuracy
1 11 0.9173913 0.8515284 0.800
2 51 0.9130435 0.8521739 0.800
3 101 0.9173913 0.8478261 0.800
4 501 0.9086957 0.8521739 0.775
5 1001 0.9130435 0.8565217 0.775
此表显示,测试准确率在 80%左右波动。考虑到我们的测试集只有 40 个观测值,这并不令人惊讶。对于训练准确率,我们看到它在 91%左右波动。作为准确度衡量标准,OOB 准确率(Out-of-Bag)远更稳定,它显示模型预期的性能大约在 85%左右。随着模型数量的增加,我们并没有看到超过 11 个模型后的显著改进,尽管对于大多数实际数据集,我们通常会在性能下降之前看到一些改进。
尽管我们的例子专注于分类问题上的袋装法,但转向回归问题相对简单。我们不是对特定观测值使用多数投票,而是使用个别模型预测的目标函数的平均值。袋装法并不总是保证在模型上提供性能改进。首先,我们应该注意,只有在我们有一个非线性模型时,使用袋装法才有意义。由于袋装过程在对生成的模型进行平均(线性操作),因此我们不会看到线性回归等线性模型有任何改进,因为我们没有增加我们模型的表达能力。下一节将讨论袋装法的其他局限性。
注意
关于袋装法的更多信息,请参阅Leo Breiman于 1996 年在《Machine Learning》期刊上发表的原论文《Bagging Predictors》。
袋装法的局限性
到目前为止,我们只探讨了使用袋装法的优点,但在某些情况下,它可能不是一个好主意。袋装法涉及对多个模型在训练数据的 bootstrap 样本上训练后做出的预测进行平均。这个过程平滑了整体输出,当目标函数是平滑的时,可能会减少偏差。不幸的是,如果目标函数不是平滑的,我们实际上可能会通过使用袋装法引入偏差。
袋装法引入偏差的另一种方式是当其中一个输出类别非常罕见时。在这种情况下,多数投票系统往往会偏向于更常见的类别。与采样过程本身相关的问题也可能出现。正如我们已经学到的,当一些分类特征包含罕见值时,这些值可能根本不会出现在某些 bootstrap 样本中。当这种情况发生时,为这些样本构建的模型在遇到测试集中的这个新特征级别时将无法做出预测。
高杠杆点,与其他点相比,在确定模型输出函数时具有高度影响力,也可能成为问题。如果抽取的 bootstrap 样本不包括一个或多个高杠杆点,那么得到的训练模型将与包含这些点时的模型大不相同。因此,袋装法的性能取决于这些特定观察结果被采样的频率,以便赢得多数投票。由于这个事实,我们的集成模型在高杠杆点存在时将具有高方差。对于给定的数据集,我们通常可以通过寻找异常值和高度偏斜的特征来预测我们是否处于这种情况。
我们还必须记住,我们构建的不同模型在严格意义上并不是真正相互独立的,因为它们仍然使用相同的输入特征集。如果模型是独立的,平均过程将更有效。此外,当使用的模型类型预测的函数形式与目标函数的真实形式相差甚远时,袋装法不起作用。当这种情况发生时,训练多个此类模型仅仅是在不同模型中复制系统误差。换句话说,当我们的模型具有低偏差和高方差时,袋装法效果更好,因为平均过程主要是设计用来减少方差的。
最后,这适用于所有集成模型,我们往往会失去模型的可解释性。我们在线性回归中看到了可解释性的一个例子,其中每个模型参数(回归系数)对应于输出变化量,对于相应特征的单位增加。决策树是另一个具有高可解释性的模型例子。使用袋装法会失去这种好处,因为多数投票过程,所以我们不能直接将输入与预测输出联系起来。
提升法
提升法为如何组合模型以实现更好的性能提供了另一种思路。特别是,它非常适合弱学习器。弱学习器是那些产生的准确率优于随机猜测模型的模型,但提升并不大。创建弱学习器的一种方法是用一个复杂度可配置的模型。
例如,我们可以用一个非常小的隐藏层神经元数量来训练一个多层感知器网络。同样,我们可以训练一个决策树,但只允许树包含一个节点,从而在输入数据中产生一个单一的分割。这种特殊的决策树被称为桩。
当我们考虑袋装法时,关键思想是取一组训练数据的随机重采样样本,然后使用这些不同的样本训练同一模型的多个版本。在经典的提升法场景中,没有随机成分,因为所有模型都使用了所有训练数据。
对于分类,提升法通过在训练数据上构建模型,然后在该训练数据上测量分类准确性来实现。模型误分类的个别观察值比正确分类的观察值赋予更大的权重,然后使用这些新权重重新训练模型。然后重复多次,每次根据观察值在上一次迭代中是否被正确分类来调整个别观察值的权重。
为了对抗过拟合,集成分类器被构建为在此序列中训练的所有模型的加权平均,权重通常与每个单独模型的分类精度成比例。由于我们使用的是全部训练数据,因此没有 OOB(Out-of-Bag)观察值,所以每个案例的准确性使用训练数据本身来衡量。使用提升法进行回归通常是通过根据预测值和标签值之间的距离度量来调整观察值的权重。
AdaBoost
继续关注分类问题,我们现在介绍AdaBoost,即自适应提升。特别是,我们将关注离散 AdaBoost,因为它对二进制类别进行预测。我们将使用-1和1作为类别标签。真实 AdaBoost是 AdaBoost 的扩展,其输出是类别概率。在我们的 AdaBoost 版本中,使用了所有训练数据;然而,AdaBoost 还有其他版本,其中也使用了训练数据的采样。AdaBoost 还有多类扩展以及适合回归类型问题的扩展。
AdaBoost 二分类
以下概述了 AdaBoost 的具体细节——输入、输出和使用的算法:
输入:
-
data: 包含输入特征和具有二进制输出标签的列的输入数据框
-
M: 一个整数,表示我们想要训练的模型数量
输出:
-
models: 一系列Μ个已训练的模型
-
alphas: 一个包含M个模型权重的向量
方法:
-
初始化一个长度为n的观察权重向量w,其中条目w[i] = 1/n。此向量将在每次迭代中更新。
-
使用当前观察权重值和训练集中的所有数据,训练一个分类器模型G[m]。
-
计算加权错误率,即将所有误分类观察值的加权总和除以权重向量的总和。按照我们通常的约定,使用x[i]作为观察值,y[i]作为其标签,我们可以用以下方程表示:
![AdaBoost 二分类]()
-
然后,我们将此模型的模型权重a[m]设置为准确率和错误率比值的对数。用公式表示,这是:
![AdaBoost 二分类]()
-
我们随后更新下一次迭代的观察权重向量,w。错误分类的观察值其权重乘以
,从而增加其在下一次迭代中的权重。正确分类的观察值其权重乘以
,从而减少其在下一次迭代中的权重。 -
重置权重向量,使得权重的总和为 1。
-
重复步骤二至六 M 次,以生成 M 个模型。
-
定义我们的集成分类器为所有提升模型输出的加权总和的符号:
![AdaBoost 二分类]()
预测大气伽马射线辐射
为了研究提升算法的实际应用,在本节中,我们将从大气物理学领域引入一个新的预测问题。更具体地说,我们将分析辐射在望远镜相机上形成的模式,以预测特定模式是否来自伽马射线泄漏到大气中,或来自常规背景辐射。
伽马射线会留下独特的椭圆形图案,因此我们可以创建一组特征来描述这些图案。我们将使用的数据集是MAGIC 伽马望远镜数据集,由UCI 机器学习存储库托管,archive.ics.uci.edu/ml/datasets/MAGIC+Gamma+Telescope。我们的数据包括 19,020 个以下属性的观察值:
| 列名 | 类型 | 定义 |
|---|---|---|
FLENGTH |
数值 | 椭圆的主轴(mm) |
FWIDTH |
数值 | 椭圆的次轴(mm) |
FSIZE |
数值 | 相机照片中所有像素内容之和的以 10 为底的对数 |
FCONC |
数值 | 两个最高像素之和与FSIZE的比值 |
FCONC1 |
数值 | 最高像素与FSIZE的比值 |
FASYM |
数值 | 最高像素到中心的距离,投影到主轴上(mm) |
FM3LONG |
数值 | 主轴上第三矩的立方根(mm) |
FM3TRANS |
数值 | 次轴上第三矩的立方根(mm) |
FALPHA |
数值 | 主轴与原点向量的夹角(度) |
FDIST |
数值 | 原点到椭圆中心的距离(mm) |
CLASS |
二进制 | 伽马射线 (g) 或背景质子辐射 (b) |
首先,我们将数据加载到名为magic的数据框中,将CLASS输出变量重新编码为使用类1和-1分别代表伽马射线和背景辐射:
> magic <- read.csv("magic04.data", header = FALSE)
> names(magic) <- c("FLENGTH", "FWIDTH", "FSIZE", "FCONC", "FCONC1",
"FASYM", "FM3LONG", "FM3TRANS", "FALPHA", "FDIST", "CLASS")
> magic$CLASS <- as.factor(ifelse(magic$CLASS =='g', 1, -1))
接下来,我们将数据框拆分为训练数据框和测试数据框,使用典型的 80-20 拆分:
> library(caret)
> set.seed(33711209)
> magic_sampling_vector <- createDataPartition(magic$CLASS,
p = 0.80, list = FALSE)
> magic_train <- magic[magic_sampling_vector, 1:10]
> magic_train_output <- magic[magic_sampling_vector, 11]
> magic_test <- magic[-magic_sampling_vector, 1:10]
> magic_test_output <- magic[-magic_sampling_vector, 11]
我们将要用于提升的模型是一个简单的单隐藏层多层感知器。回顾 第四章,神经网络,我们知道 nnet 包非常适合这项任务。在使用神经网络时,我们通常在归一化输入后获得更高的准确率,因此在我们训练任何模型之前,我们将执行此预处理步骤:
> magic_pp <- preProcess(magic_train, method = c("center",
"scale"))
> magic_train_pp <- predict(magic_pp, magic_train)
> magic_train_df_pp <- cbind(magic_train_pp,
CLASS = magic_train_output)
> magic_test_pp <- predict(magic_pp, magic_test)
提升旨在与弱学习器配合使用效果最佳,因此我们在隐藏层中将使用非常少的隐藏神经元。具体来说,我们将从使用单个隐藏神经元的可能的最简单多层感知器开始。为了了解使用提升的效果,我们将通过训练单个神经网络并测量其准确率来建立基线性能。我们可以这样做:
> library(nnet)
> n_model <- nnet(CLASS ~ ., data = magic_train_df_pp, size = 1)
> n_test_predictions <- predict(n_model, magic_test_pp,
type = "class")
> (n_test_accuracy <- mean(n_test_predictions ==
magic_test_output))
[1] 0.7948988
这表明我们的基线准确率约为 79.5%。这并不太糟糕,但我们将使用提升来查看是否可以改进它。为此,我们将编写自己的函数 AdaBoostNN(),该函数将接受一个数据框、输出变量的名称、我们想要构建的单隐藏层神经网络模型的数量,以及最后这些神经网络将拥有的隐藏单元数。然后该函数将实现我们之前描述的 AdaBoost 算法,并最终返回模型及其权重的列表。以下是该函数:
AdaBoostNN <- function(training_data, output_column, M,
hidden_units) {
require("nnet")
models <- list()
alphas <- list()
n <- nrow(training_data)
model_formula <- as.formula(paste(output_column, '~ .', sep = ''))
w <- rep((1/n), n)
for (m in 1:M) {
model <- nnet(model_formula, data = training_data,
size = hidden_units, weights = w)
models[[m]] <- model
predictions <- as.numeric(predict(model,
training_data[, -which(names(training_data) ==
output_column)], type = "class"))
errors <- predictions != training_data[, output_column]
error_rate <- sum(w * as.numeric(errors)) / sum(w)
alpha <- 0.5 * log((1 - error_rate) / error_rate)
alphas[[m]] <- alpha
temp_w <- mapply(function(x, y) if (y) { x * exp(alpha) }
else { x * exp(-alpha)}, w, errors)
w <- temp_w / sum(temp_w)
}
return(list(models = models, alphas = unlist(alphas)))
}
在继续之前,我们将逐步分析函数以了解每一行的作用。我们首先初始化空的模型和模型权重列表(alphas)。我们还计算训练数据中的观测数,并将此存储在变量 n 中。然后使用提供的输出列名称创建一个公式,描述我们将构建的神经网络。
在我们的数据集中,这个公式将是 CLASS ~ .,这意味着神经网络将计算 CLASS 作为所有其他列作为输入特征的函数。然后我们初始化我们的权重向量,就像在 AdaBoost 的 步骤 1 中做的那样,并定义我们的循环,该循环将运行 M 次迭代以构建 M 个模型。
在每次迭代中,第一步是使用当前权重向量的设置,使用输入中指定的隐藏单元数量训练一个神经网络。然后我们使用predict()函数计算模型在训练数据上生成的预测向量。通过将这些预测与训练数据的输出列进行比较,我们计算出当前模型在训练数据上的错误。这然后允许我们计算错误率。根据 AdaBoost 算法的第 4 步,这个错误率被设置为当前模型的权重。最后,根据 AdaBoost 算法的第 5 步,根据每个观察是否被正确分类来更新下一次迭代循环中使用的观察权重。然后权重向量被归一化,我们就可以开始下一次迭代。完成M次迭代后,我们输出一个包含模型及其相应模型权重的列表。
我们现在有一个函数能够使用 AdaBoost 训练我们的集成分类器,但我们还需要一个函数来进行预测。这个函数将接收我们的训练函数AdaBoostNN()生成的输出列表以及一个测试数据集。我们把这个函数命名为AdaBoostNN.predict(),如下所示:
AdaBoostNN.predict <- function(ada_model, test_data) {
models <- ada_model$models
alphas <- ada_model$alphas
prediction_matrix <- sapply(models, function (x)
as.numeric(predict(x, test_data, type = "class")))
weighted_predictions <- t(apply(prediction_matrix, 1,
function(x) mapply(function(y, z) y * z, x, alphas)))
final_predictions <- apply(weighted_predictions, 1, function(x) sign(sum(x)))
return(final_predictions)
}
在这个函数中,我们首先从之前函数生成的列表中提取模型和模型权重。然后我们创建一个预测矩阵,其中每一列对应于特定模型做出的预测向量。因此,在这个矩阵中,我们将有与用于提升的模型数量一样多的列。
然后,我们将每个模型生成的预测与相应的模型权重相乘。例如,第一个模型的每个预测都在预测矩阵的第一列,并且其值将乘以第一个模型权重α1。最后,在最后一步中,我们将加权观察值的矩阵简化为单个观察值向量,通过将每个观察值的加权预测相加并取结果的符号来实现。然后,这个预测向量由我们的函数返回。
作为实验,我们将使用单个隐藏单元训练 10 个神经网络模型,看看提升是否能够提高准确率:
> ada_model <- AdaBoostNN(magic_train_df_pp, 'CLASS', 10, 1)
> predictions <- AdaBoostNN.predict(ada_model, magic_test_pp,
'CLASS')
> mean(predictions == magic_test_output)
[1] 0.804365
提升使用 10 个模型似乎在准确率上只带来轻微的提高,但也许训练更多的模型可能会带来更大的差异。我们还对弱学习器的复杂性(通过隐藏神经元的数量来衡量)与在数据集上提升可以预期的性能收益之间的关系感兴趣。
下面的图表显示了使用不同数量的模型以及隐藏神经元进行实验的结果:

对于只有一个隐藏单元的神经网络,随着提升模型的数量增加,我们看到了准确性的提高,但达到 100 个模型后,这种提高逐渐减弱,对于 200 个模型来说实际上略有下降。对于这些网络,与单个模型基线相比,改进是显著的。当我们通过具有三个隐藏神经元的隐藏层增加学习者的复杂性时,我们在性能上的改进要小得多。在 200 个模型时,这两个集成表现水平相似,这表明在这个点上,我们的准确性受到我们正在训练的模型类型的限制。
注意
原始的 AdaBoost 算法由Freund和Schapire在 1997 年发表在《Computer and System Sciences》期刊上的论文《A Decision-theoretic generalization of on-line learning and an application to boosting》中提出。这是学习 AdaBoost 的一个好起点。
使用提升预测复杂技能学习
在本节中,我们将重新审视我们的 Skillcraft 数据集--这次是在另一种称为随机梯度提升的增强技术背景下。这种方法的主要特点是,在提升的每一轮迭代中,我们计算模型在当前迭代中犯的错误方向上的梯度。
这个梯度随后被用来指导在下一轮迭代中添加的模型构建。随机梯度提升通常与决策树一起使用,在 R 语言中,gbm包提供了一个良好的实现,它提供了gbm()函数。对于回归问题,我们需要指定distribution参数为gaussian。此外,我们可以通过n.trees参数指定我们想要构建的树的数量(这相当于提升的迭代次数),以及一个用于控制算法学习率的shrinkage参数:
> boostedtree <- gbm(LeagueIndex ~ ., data = skillcraft_train,
distribution = "gaussian", n.trees = 10000, shrinkage = 0.1)
注意
要了解更多关于随机梯度提升的工作原理,一个很好的参考资料是标题为《随机梯度提升》的论文。这篇论文由Jerome H. Friedman撰写,发表在 2002 年 2 月的期刊《Computational Statistics & Data Analysis》上。
为了使用这种设置进行预测,我们需要使用gbm.perf()函数,其任务是取我们构建的提升模型并挑选出最佳的提升迭代次数。然后我们可以将这个结果提供给我们的predict()函数,以便对我们的测试数据进行预测。为了测量测试集中的 SSE,我们将使用我们在第六章中编写的compute_SSE()函数:
> best.iter <- gbm.perf(boostedtree, method = "OOB")
> boostedtree_predictions <- predict(boostedtree,
skillcraft_test, best.iter)
> (boostedtree_SSE <- compute_SSE(boostedtree_predictions,
skillcraft_test$LeagueIndex))
[1] 555.2997
通过允许算法在更多的树上迭代,我们发现无法显著提高结果。尽管如此,我们使用这种方法已经比单树和袋装树分类器表现得更好。
提升法的局限性
提升法是一种非常强大的技术,它持续受到很多关注和研究,但它并非没有局限性。提升法依赖于将弱学习器结合起来。特别是,当使用的模型本身不是复杂模型时,我们可以期待从提升法中获得最大的收益。我们已经通过注意到具有三个隐藏神经元的更复杂架构在开始时比具有单个隐藏神经元的简单架构提供更好的学习器,看到了这种效果的例子。
将弱学习器结合起来可能是一种减少过拟合的方法,但这并不总是有效的。默认情况下,提升法使用其所有的训练数据,并逐步尝试纠正它所犯的错误,而不进行任何惩罚或收缩标准(尽管训练的个别模型本身可能已经进行了正则化)。因此,提升法有时可能会过拟合。
最后,一个非常重要的局限性是许多提升算法具有对称损失函数。具体来说,在分类中,对错误正分类和错误负分类没有进行区分。当更新观察权重时,所有类型的错误都被同等对待。
在实践中,这可能不是所希望的,因为两种错误中的一种可能代价更高。例如,在我们的主要大气伽马成像切伦科夫(MAGIC)望远镜数据集网站上,作者表示,在没有任何伽马射线的情况下检测到伽马射线是比将伽马射线错误分类为背景辐射的错误更糟糕的。然而,已经提出了提升算法的成本敏感扩展。
随机森林
本章将要讨论的最终集成模型是树模型特有的,被称为随机森林。简而言之,随机森林背后的想法源于对袋装树的观察。假设特征与目标变量之间的实际关系可以用树结构充分描述。在用适度大小的自助样本进行袋装时,我们很可能会在树的高层不断选择相同的特征进行分割。
例如,在我们的 Skillcraft 数据集中,我们预计在大多数袋装树的最顶层会选择APM作为特征。这是一种树相关形式,本质上阻碍了我们从袋装中获得方差减少的好处。换句话说,我们构建的不同树模型并不是真正相互独立的,因为它们将有许多共同的特征和分割点。因此,最终的平均过程在减少集成方差方面将不太成功。
为了抵消这种影响,随机森林算法在树构建过程中引入了随机化元素。就像在袋装法中一样,随机森林涉及构建多个使用自助样本的树,并使用它们预测的平均值来形成集成预测。然而,当我们构建单个树时,随机森林算法施加了一个约束。
在树的每个节点上,我们从所有输入特征中随机抽取大小为 mtry 的样本。而在常规的树构建过程中,我们在每个节点考虑所有特征以确定要分割的特征,而在随机森林中,我们只考虑为该节点创建的样本中的特征。我们通常可以使用一个相对较小的 mtry 值。
我们构建的树的数量,加上每棵树有几个节点的事实,通常足以确保重要的特征被足够多次地采样。已经提出了各种启发式方法来选择此参数的适当值,例如可用特征总数的 1/3 或平方根。
这个采样步骤有效地迫使袋装树的结构彼此不同,并提供了许多不同的好处。特征采样使我们能够考虑在目标变量的小范围内成功分割数据的输入特征。这些局部相关特征在没有采样约束的情况下很少被选中,因为我们通常更喜欢在树中的给定节点处形成良好的整体分割的特征。尽管如此,如果我们不希望忽略输出中的局部变化,我们可能仍然希望将这些特征包含在我们的模型中。
类似地,当我们的输入特征相关时,采样输入特征是有用的。常规的树构建往往只偏向于相关集合中的一个特征,而忽略其余的特征,尽管即使是非常相关的特征产生的分割结果也不完全相同。当我们采样输入特征时,我们不太可能让相关的特征相互竞争,因此我们可以选择更广泛的特征范围来与我们的模型一起使用。
通常情况下,采样过程的随机性被设计用来对抗过拟合,因为我们可以把这个过程看作是对每个输入特征影响进行正则化的应用。如果我们碰巧有太多与目标变量无关的输入特征,与相关的特征相比,过拟合仍然可能成为一个问题,但这种情况相当罕见。随机森林在一般情况下与输入特征的数量有很好的扩展性,这正是由于这种采样过程,它不需要我们在每个节点分裂时考虑所有特征。特别是,当特征数量超过观测数量时,这个模型是一个很好的选择。最后,采样过程减轻了构建大量树的成本,因为我们决定在每个节点如何分裂时只考虑输入特征的一个子集。树的数量是随机森林模型中我们必须决定的另一个调整参数;通常情况下,我们会构建几百到几千棵树。
在 R 中,我们可以使用randomForest包来训练随机森林模型。randomForest()函数接受一个公式和一个训练数据框,以及一些其他可选参数。特别值得注意的是ntree参数,它控制将构建多少棵树用于集成,以及mtry参数,它是每个节点分裂时用于分裂的特征样本数量。这些参数应该通过尝试不同的配置来调整,我们可以使用e1071包中的tune()函数来完成这项工作:
> library("randomForest")
> library("e1071")
> rf_ranges <- list(ntree = c(500, 1000, 1500, 2000), mtry = 3:8)
> rf_tune <- tune(randomForest, LeagueIndex ~ ., data =
skillcraft_train, ranges = rf_ranges)
> rf_tune$best.parameters
ntree mtry
14 1000 6
> rf_best <- rf_tune$best.model
> rf_best_predictions <- predict(rf_best, skillcraft_test)
> (rf_best_SSE <- compute_SSE(rf_best_predictions,
skillcraft_test$LeagueIndex))
[1] 555.7611
结果显示,在这个数据集上,最佳的参数组合是训练 1,000 棵树并使用mtry的值为 6。这个最后的值对应于输入特征数量的三分之一,这是回归问题的典型启发式方法。我们的测试集上的 SSE 值几乎与使用梯度提升法获得的结果相同。
随机森林中变量的重要性
我们已经讨论了集成模型通常没有解释能力的事实。对于随机森林,我们发现我们仍然可以通过对所有集成中的树进行错误函数减少的计数和跟踪来测量不同输入特征的变量重要性得分。通过这种方式,我们可以获得与我们在第六章“基于树的算法”中查看此数据集时获得的单个树的类似图。
为了计算变量重要性,我们使用importance()函数并绘制结果:

观察这个图表,我们可以看到APM和ActionLatency再次是最重要的特征,但它们的顺序颠倒了。我们还看到TotalHours现在在重要性上排名第三,比之前在单个树中看到的要高得多。
我们使用多种不同的方法探索了 Skillcraft 数据集,但每次我们都将其视为回归问题,并使用 SSE 来衡量我们的准确度。我们的目标变量是联赛指数,它告诉我们玩家在哪个游戏联赛中竞争。因此,它实际上是一个有序因子。
正如我们之前所见,输出为有序因子的模型在训练和评估时可能很棘手。例如,评估我们模型的一个更合适的方法可能是首先将我们模型的数值输出四舍五入,以便我们获得一个关于实际玩家联赛的预测。然后,我们可以使用加权分类误差率来评估模型,该误差率对预测的联赛指数与实际联赛指数差异很大的情况给予更重的惩罚。我们将此作为读者的练习。
当我们将问题建模为回归问题时,我们经常面临的一个问题是无法强迫输出预测覆盖原始级别的完整范围。例如,在我们的特定数据集中,我们可能永远不会预测最低或最高的联赛。关于使用回归树以其他方式建模有序因子的建议,有一篇由 Kramer 和其他人于 2000 年发表的见解深刻的论文,标题为使用回归树预测有序类别。这篇论文发表在IOS Press出版的Fundamentals Informaticae的第 34 期。
注意
对于随机森林,原始参考文献是 2001 年由 Leo Breiman 发表在Machine Learning期刊上的论文Random Forests。除了这个参考文献之外,书中还有一个精彩的章节,包含了许多示例,标题为从回归视角的统计学习,作者是 Richard A. Derk,出版社是 Springer。
XGBoost
与 AdaBoost 类似,并在梯度提升上又有新的变化,是极端梯度提升(XGBoost)。XGBoost 是一个专门设计和优化的函数库,用于提升树算法。它是一个通常高级的工具包,能产生令人印象深刻的成果,但理解它需要一些时间。
XGBoost 基于广为人知的梯度提升框架,但效率更高。具体来说,XGBoost 利用了系统优化概念,如离核计算、并行化、缓存优化和分布式计算,以创建一个更快、更灵活的学习树集成工具。
注意
基本上,XGBoost 是通过利用机器的所有核心,在递归地将数据划分为部分的同时执行序列化过程提升而构建的,保留第一部分作为测试数据,然后将第一部分重新整合到数据集中,保留第二部分,进行训练并重复,依此类推。
此外,XGBoost 具有许多可定制的参数,且可扩展,因此具有更大的灵活性。XGBoost 提供的其他优势包括正则化、可定制的参数、更深入的树剪枝和内置交叉验证。
摘要
在本章中,我们偏离了我们通常的学习新模型的方式,而是专注于构建之前见过的模型集成技术。我们发现,有无数种有意义地组合模型的方法,每种方法都有其自身的优势和局限性。我们构建集成模型的第一种技术是 bagging。bagging 的核心思想是,我们使用训练数据的自助样本构建多个相同模型的版本。然后,我们平均这些模型做出的预测,以构建我们的总体预测。通过构建多个不同版本的模型,我们可以平滑掉由于过拟合而产生的错误,并最终得到一个方差较小的模型。
构建模型集成的另一种方法使用所有训练数据,称为 boosting。在这里,定义特征是训练一系列模型,但每次我们根据我们在前一个模型中是否正确分类了该观察结果,以不同的权重来权衡每个观察结果。boosting 有许多变体,我们介绍了两种最著名的算法,即 AdaBoost 和随机梯度提升(以及提到可能更新、更高效的树学习器 XGBoost)。在计算最终预测时,对个别模型做出的预测进行平均的过程通常根据每个模型的表现来权衡。
介绍 bagging 和 boosting 的传统文本通常在决策树的环境中介绍它们。这有很好的理由,因为决策树是 bagging 和 boosting 应用的典型模型。特别是,boosting 在弱学习器模型上效果最好,通过在构建过程中显著限制其大小和复杂性,决策树可以很容易地变成弱学习器。
然而,这往往让读者产生一种观点,即集成方法仅适用于决策树,或者没有经验了解它们如何应用于其他方法。在本章中,我们强调了这些技术的通用性以及它们可以与多种不同类型的模型一起使用。因此,我们将这些技术应用于我们之前见过的模型,例如神经网络和逻辑回归。
我们研究的最后一种集成模型是随机森林。这是一个基于袋装决策树的非常流行且强大的算法。这个模型背后的关键突破是使用输入特征采样程序,这限制了在构建每棵树时可用于分裂的特征选择。通过这样做,模型减少了树之间的相关性,捕捉了输出中的显著局部变化,并提高了最终结果的方差减少程度。这个模型的另一个关键好处是它能够很好地扩展到更多的输入特征。对于我们的实际 Skillcraft 数据集,我们发现随机森林和随机梯度提升产生了最佳性能。
在下一章中,我们将介绍另一种具有独特结构的模型,称为概率图模型。这些模型使用图形结构来明确表示输入特征之间的条件独立性。概率图模型在各种预测任务中都有应用,从垃圾邮件识别到 DNA 序列标记。
第十章。概率图模型
概率图模型,或简称为我们在本章中将要提到的图模型,是使用图的表示来描述一系列随机变量之间的条件独立性关系的模型。近年来,这一主题受到了越来越多的关注,概率图模型已经在从医学诊断到图像分割等任务中得到了成功的应用。在本章中,我们将介绍一些必要的背景知识,这将有助于理解最基本的图模型——朴素贝叶斯分类器。然后,我们将探讨一个稍微复杂一些的图模型,称为隐马尔可夫模型(HMM)。要进入这个领域,我们首先必须了解图以及它们为什么有用。
一点图论
图论是数学的一个分支,它处理称为图的数学对象。在这里,图没有我们更习惯于谈论的日常意义,即具有 x 和 y 轴的图表或图解。在图论中,一个图由两个集合组成。第一个是顶点集合,也被称为节点。我们通常使用整数来标记和列举顶点。第二个集合由这些顶点之间的边组成。
因此,图不过是一些点的描述以及它们之间的连接。这些连接可以有方向,使得边从源或尾顶点指向目标或头顶点。在这种情况下,我们有一个有向图。或者,边可以没有方向,这样图就是无向图。
描述图的一种常见方式是通过邻接矩阵。如果我们有一个图中的 V 个顶点,那么邻接矩阵是一个 V×V 矩阵,其条目如果行号表示的顶点与列号表示的顶点不相连,则为 0。如果存在连接,则条目为 1(然而,当你在使用加权图时,条目值不总是 1)。
在无向图中,每条边上的节点都相互连接,因此邻接矩阵是对称的。对于有向图,一个顶点 v[i] 通过边 (v[i],v[j]) 与顶点 v[j] 相连;也就是说,其中 v[i] 是尾节点,v[j] 是头节点。以下是一个具有七个节点的图的邻接矩阵示例:
> adjacency_m
1 2 3 4 5 6 7
1 0 0 0 0 0 1 0
2 1 0 0 0 0 0 0
3 0 0 0 0 0 0 1
4 0 0 1 0 1 0 1
5 0 0 0 0 0 0 0
6 0 0 0 1 1 0 1
7 0 0 0 0 1 0 0
这个矩阵不是对称的,因此我们知道我们正在处理一个有向图。矩阵第一行中的第一个 1 值表示从顶点 1 出发并结束在顶点 6 的边。当节点数量较少时,很容易可视化一个图。我们只需画圆圈来表示顶点,并在它们之间画线来表示边。
对于有向图,我们在线段上使用箭头来表示边的方向。需要注意的是,我们可以在页面上以无限多种不同的方式绘制相同的图。这是因为图告诉我们关于节点在空间中的位置信息很少;我们只关心它们是如何相互连接的。以下是我们刚才看到的邻接矩阵描述的图的不同但同样有效的方式:

如果两个顶点之间存在边(在有向图的情况下,注意顺序),则称这两个顶点相互连接。如果我们可以从顶点 v[i] 通过移动到顶点 v[j],从第一个顶点开始,在第二个顶点结束,沿着图中的边移动并通过任意数量的图顶点,那么这些中间边形成这两个顶点之间的路径。请注意,这个定义要求路径上的所有顶点和边彼此不同(可能只有第一个和最后一个顶点除外)。
例如,在我们的图中,顶点 6 可以通过通过顶点 1 的路径从顶点 2 到达。有时,图中可能会有许多这样的可能路径,而我们通常对最短路径感兴趣,即通过最少数量的中间顶点。我们可以在图中定义两个节点之间的距离为它们之间最短路径的长度。起点和终点相同的路径称为环。没有环的图称为无环图。如果一个无环图有有向边,则称为有向无环图,通常缩写为DAG。
小贴士
关于图论有许多优秀的参考资料。其中之一是可在网上找到的《图论》,作者为Reinhard Diestel,Springer出版社。这本里程碑式的参考书现在已出到第 4 版,可在diestel-graph-theory.com/找到。
起初可能并不明显,但事实是,许多现实世界的情况可以方便地使用图来描述。例如,社交媒体网站上的友谊网络,如 Facebook,或 Twitter 上的关注者,都可以表示为图。在 Facebook 上,友谊关系是相互的,因此图是无向的。在 Twitter 上,关注者关系则不是,因此图是有向的。
另一个图是网络上的网站网络,其中从一个网页到下一个网页的链接形成有向边。运输网络、通信网络和电网都可以表示为图。对于预测模型师来说,结果是存在一类称为概率图模型或简称图模型的特殊模型,这些模型涉及图结构。
在图形模型中,节点代表随机变量,节点之间的边代表它们之间的依赖关系。在我们可以进一步详细说明之前,我们需要短暂地偏离一下,以便访问贝叶斯定理,这是统计学中的一个经典定理,尽管它很简单,但在统计推断和预测方面具有深远和实用的意义。
贝叶斯定理
假设我们感兴趣的两个事件是 A 和 B。在这种情况下,事件 A 可能代表患者患有阑尾炎,而事件 B 可能代表患者有高白细胞计数。事件 A 在事件 B 发生条件下的条件概率实际上是在我们知道事件 B 已经发生时事件 A 发生的概率。
形式上,我们定义事件 A 在事件 B 发生条件下的条件概率为两个事件同时发生的联合概率除以事件 B 发生的概率:

注意,这与我们定义的统计独立性是一致的。当两个事件同时发生的联合概率只是这两个事件各自概率的乘积时,就发生了统计独立性。如果我们用这个替换我们之前的方程,我们得到:

这从直观上是有意义的,因为如果我们知道两个事件是相互独立的,那么知道事件 B 已经发生并不会改变事件 A 发生的概率。现在,我们可以重新排列我们的条件概率方程,并注意我们可以交换事件 A 和 B 来得到另一种形式:

这最后一步使我们能够以最简单形式表述贝叶斯定理:

在前一个方程中,P(A) 被称为事件 A 的先验概率,因为它代表了在获得任何新信息之前事件 A 发生的概率。P(A|B),即在事件 B 发生的情况下事件 A 的条件概率,通常也被称为 A 的后验概率。这是在接收到一些新信息后事件 A 发生的概率;在这种情况下,事件 B 已经发生的事实。
所有这些都可能看起来像是代数技巧,但如果我们回顾一下事件 A 代表患者患有阑尾炎,事件 B 代表患者有高白细胞计数的例子,贝叶斯定理的有用性将得到揭示。知道 P(A|B),即在观察到患者有高白细胞计数(以及其他症状)的情况下患有阑尾炎的条件概率,对于医生来说是非常有用的知识。这将使他们能够利用可以观察到的(高白细胞计数)来对不易观察到的(阑尾炎)进行诊断。
不幸的是,这很难估计,因为高白细胞计数可能是一系列其他疾病或病理症状的表现。然而,逆概率 P(B|A)(即在患者已经患有阑尾炎的情况下,高白细胞计数的条件概率)则容易得多。只需要检查过去阑尾炎病例的记录并检查这些病例的血液检查结果。贝叶斯定理是预测建模的基本福音,因为它允许我们通过观察效果来估计原因。
条件独立性
从统计学我们知道,统计独立性的概念表明两个随机变量 A 和 B 的联合概率只是它们(边缘)概率的乘积。有时,两个变量可能一开始就不相互统计独立,但观察第三个变量 C 可能会导致它们相互独立。简而言之,我们说在 C 的条件下,事件 A 和 B 是条件独立的,我们可以用以下方式表示:

例如,假设 J 代表在特定公司获得工作机会的概率,而 G 代表在特定大学被录取的概率。这两个概率都可能依赖于一个变量 U,即一个人在本科学习中的表现。这可以用以下图表示:

当我们不知道 U,即一个人的本科学习成绩时,知道他们被研究生院录取可能会增加我们对其获得工作机会的信心,反之亦然。这是因为我们倾向于相信他们在本科学习表现良好,这影响了那个人获得工作的机会。因此,这两个事件 J 和 G 并不相互独立。
然而,如果我们被告知一个人的本科学习成绩,我们可能会假设这个人获得工作机会的机会可能与进入研究生院的机会独立。这是因为可能影响这一点的其他因素,例如某一天这个人的工作面试或其他潜在候选人的工作质量,这些因素不受这个人申请研究生院的影响。
贝叶斯网络
贝叶斯网络是一种涉及有向无环图结构的图形模型。我们通常将图形模型中一条有向边的尾节点称为父节点,而头节点称为子节点或后代节点。实际上,我们推广了这种后一种概念,即如果模型中从节点 A 到节点 B 存在一条路径,那么节点 B 就是节点 A 的后代。我们可以通过说后者是直接后代来区分节点 A 与节点 B 直接相连的特殊情况。
在贝叶斯网络中,父节点关系和子节点关系是互斥的,因为它没有循环。贝叶斯网络具有一个显著特性,即给定其父节点,网络中的每个节点在条件上独立于网络中所有不是其子节点的其他节点。这有时被称为局部马尔可夫性质。这是一个重要的特性,因为它意味着我们可以通过简单地注意图中的边来轻松分解模型中所有随机变量的联合概率函数。
为了理解这是如何工作的,我们将从三个变量的概率乘法定律开始,该定律如下(以 G、J 和 U 作为示例变量):

这是一条普遍适用的规则,在没有任何普遍性损失的情况下始终成立。让我们回到我们的学生申请人示例。这实际上是一个简单的贝叶斯网络,其中 G 和 J 以 U 为父节点。利用贝叶斯网络的局部马尔可夫性质,我们可以简化联合概率分布的方程如下:

以这种方式分解概率分布的能力是有用的,因为它简化了我们需要的计算。它还可以使我们能够以更紧凑的形式表示整个分布。假设每个随机变量的分布是离散的,并取有限集合中的值,例如,随机变量 G 和 J 可以分别取两个离散值 {是,否}。为了在不分解的情况下存储联合概率分布,并考虑独立性关系,我们需要考虑每个随机变量的所有可能组合。
相比之下,如果分布分解为更简单分布的乘积,如我们之前所见,我们需要考虑的随机变量组合总数要少得多。对于具有多个随机变量且取许多值的网络,这种节省确实是实质性的。
除了计算和存储之外,另一个显著的好处是,当我们想要确定给定某些数据时随机变量的联合概率分布,由于已知的独立性关系,当我们能够分解它时,这样做会变得简单得多。我们将在下一节详细研究贝叶斯网络的一个重要示例时看到这一点。
为了总结本节,我们将注意贝叶斯网络的联合概率函数的分解,该网络由本章第一幅图中看到的图表示,并将其留作读者的练习以验证:

天真贝叶斯分类器
现在我们有了学习我们第一个也是最简单的图形模型——朴素贝叶斯分类器所必需的工具。这是一个包含单个父节点和一系列子节点的有向图形模型,这些子节点代表仅依赖于该节点的随机变量,它们之间没有依赖关系。以下是一个示例:

我们通常将单个父节点解释为因果节点,因此在我们特定的例子中,Sentiment 节点的值将影响 sad 节点、fun 节点等的值。由于这是一个贝叶斯网络,局部马尔可夫性质可以用来解释模型的核心假设。给定 Sentiment 节点,所有其他节点都是相互独立的。
在实践中,我们使用朴素贝叶斯分类器在一个可以观察和测量子节点并尝试估计父节点作为我们的输出的环境中。因此,子节点将成为我们模型的输入特征,而父节点将是输出变量。例如,子节点可能代表各种医疗症状,而父节点可能表示是否存在特定的疾病。
为了理解模型在实际中的工作方式,我们求助于贝叶斯定理,其中 C 是父节点,F[i] 是子节点或特征节点:

我们可以使用网络的条件独立性假设来简化这一点:

为了从这个概率模型中构建一个分类器,我们的目标是选择最大化后验概率 P(Ci|F[1] …F[n] ) 的类 C[i];即给定观察到的特征的这个类的后验概率。分母是观察特征的联合概率,它不受所选类的影响。因此,最大化后验类概率等同于最大化前一个方程的分子:

给定一些数据,我们可以估计特征 F[i] 的所有不同值的概率 P(F[i] |C[j] ),作为类 C[j] 中具有每个不同特征 F[i] 的观察值的相对比例。我们还可以估计 P(C[j] ),作为分配给类 C[j] 的观察值的相对比例。这些都是最大似然估计。在下一节中,我们将看到朴素贝叶斯分类器在真实示例中的工作方式。
预测电影评论的情感
在在线评论、论坛和社交媒体的世界中,一个已经并且继续受到越来越多关注的是情感分析的任务。简单来说,这个任务就是分析一段文本,以确定作者所表达的情感。一个典型的场景是收集在线评论、博客文章或推文,并构建一个模型来预测用户是否试图表达正面或负面的感受。有时,这个任务可以扩展以捕捉更广泛的情感,例如中性情感或情感程度,如轻微负面与非常负面。
在本节中,我们将限制自己只进行区分正面和负面情感这一更简单的任务。我们将通过使用与上一节中看到类似的贝叶斯网络来建模情感。情感是我们的目标输出变量,可以是正面或负面。我们的输入特征是所有二元特征,描述特定单词是否出现在电影评论中。关键思想是,表达负面情感的用户倾向于在他们评论中选择一组具有特征性的单词,这与用户在撰写正面评论时选择的特征性单词集不同。
通过使用朴素贝叶斯模型,我们的假设是,如果我们知道表达的情感,文本中每个词的存在将独立于所有其他词。当然,这是一个非常严格的假设,并且根本无法说明真实文本的写作过程。尽管如此,我们将展示即使在这些严格的假设下,我们也能构建一个表现合理的模型。
我们将使用大型电影评论数据集,该数据集首次在题为学习用于情感分析的词向量的论文中提出,该论文由安德鲁·L·马斯、雷蒙德·E·戴利、彼得·T·范、丹·黄、安德鲁·Y·吴和克里斯托弗·波茨撰写,发表于第 49 届计算语言学协会年会(ACL 2011)。数据托管在ai.stanford.edu/~amaas/data/sentiment/,包含一个由 25,000 条电影评论组成的训练集和一个由另外 25,000 条电影评论组成的测试集。
为了展示模型的工作原理,我们希望将模型的训练时间保持得尽可能低。因此,我们将原始训练集划分为一个新的训练集和测试集,但强烈建议读者使用原始数据集的一部分更大的测试数据集重复此练习。下载后,数据组织在train文件夹和test文件夹中。train文件夹包含一个名为pos的文件夹,其中包含 12,500 条正面电影评论,每个评论都在一个单独的文本文件中,同样,还有一个名为neg的文件夹,包含 12,500 条负面电影评论。
我们的首要任务是将其所有信息加载到 R 中并进行一些必要的预处理。为此,我们将安装并使用tm包,这是一个专门用于执行文本挖掘操作的包。当处理文本数据时,这个包非常有用,我们将在下一章再次使用它。
当使用tm包工作时,首要任务是组织各种文本来源到一个语料库中。在语言学中,这通常指的是一系列文档的集合。在tm包中,它只是表示单个文本来源的字符串集合,以及一些描述这些信息的元数据,例如从哪些文件中检索到的文件名。
使用tm包,我们通过Corpus()函数构建语料库,我们必须提供要导入的各种文档的来源。我们可以创建一个字符串向量,并将其作为参数传递给Corpus()函数使用VectorSource()函数。相反,由于我们的数据源是一个目录中的文本文件序列,我们将使用DirSource()函数。首先,我们将创建两个字符串变量,它们将包含我们机器上上述neg和pos文件夹的绝对路径(这取决于数据集下载的位置)。
然后,我们可以使用Corpus()函数两次来创建两个语料库,分别用于正面和负面评论,然后合并成一个单一的语料库:
> path_to_neg_folder <- "~/aclImdb/train/neg"
> path_to_pos_folder <- "~/aclImdb/train/pos"
> library("tm")
> nb_pos <- Corpus(DirSource(path_to_pos_folder),
readerControl = list(language = "en"))
> nb_neg <- Corpus(DirSource(path_to_neg_folder),
readerControl = list(language = "en"))
> nb_all <- c(nb_pos, nb_neg, recursive = T)
Corpus()函数的第二个参数readerControl是一个可选参数列表。我们使用它来指定我们的文本文件的语言是英语。在c()函数中用于合并两个语料库的recursive参数是必要的,以保持存储在语料库对象中的元数据信息。
注意,我们可以合并这两个语料库而不丢失情感标签。代表电影评论的每个文本文件都使用格式<counter>_<score>.txt命名,并且这些信息存储在由Corpus()函数创建的语料库对象的元数据部分中。我们可以使用meta()函数查看我们语料库中第一个评论的元数据:
> meta(nb_all[[1]])
Metadata:
author : character(0)
datetimestamp: 2015-04-19 09:17:48
description : character(0)
heading : character(0)
id : 0_9.txt
language : en
origin : character(0)
因此,meta()函数检索我们语料库中每个条目的元数据对象。该对象中的ID属性包含文件名。名称中的分数部分是一个介于 0 到 10 之间的数字,其中较大的数字表示正面评论,而较小的数字表示负面评论。在训练数据中,我们只有极性评论;也就是说,评论在 0-4 和 7-10 的范围内。因此,我们可以使用这些信息来创建一个文档名称向量:
> ids <- sapply( 1 : length(nb_all),
function(x) meta(nb_all[[x]], "id"))
> head(ids)
[1] "0_9.txt" "1_7.txt" "10_9.txt" "100_7.txt"
[5] "1000_8.txt" "10000_8.txt"
从这个文档名称列表中,我们将使用sub()函数和适当的正则表达式提取分数组件。如果电影评论的分数小于或等于 5,则它是负面评论;如果分数更高,则是正面评论:
> scores <- as.numeric(sapply(ids,
function(x) sub("[0-9]+_([0-9]+)\\.txt", "\\1", x)))
> scores <- factor(ifelse(scores >= 5, "positive", "negative"))
> summary(scores)
negative positive
12500 12500
小贴士
sub() 函数只是 R 语言中众多使用正则表达式的函数之一。对于不熟悉这个概念的人来说,正则表达式本质上是一种用于描述字符串的模式语言。在线教程很容易找到。关于正则表达式以及更广泛的文本处理,一个极好的资源是 《语音与语言处理 第二版》,作者为 Jurafsky 和 Martin。
我们模型的特征将是描述字典中特定单词存在或不存在情况的二进制特征。直观地,我们应该预期包含像 boring、cliché 和 horrible 这样的单词的电影评论很可能是负面评论。而包含像 inspiring、enjoyable、moving 和 excellent 这样的单词的电影评论很可能是好评。
当处理文本数据时,我们几乎总是需要执行一系列预处理步骤。例如,我们倾向于将所有单词转换为小写格式,因为我们不希望对于单词 Excellent 和 excellent 有两个不同的特征。我们还想从文本中移除任何可能作为特征不具信息量的内容。因此,我们倾向于移除标点符号、数字和停用词。停用词是一些在英语中非常常用且几乎会出现在所有电影评论中的词,如 the、and、in 和 he。最后,因为我们从句子中移除了单词并创建了重复的空格,所以我们将想要移除这些内容,以协助分词(将文本分割成单词的过程)。
tm 包有两个函数,tm_map() 和 content_transformer(),它们可以一起使用来将文本转换应用于语料库中每个条目的内容:
> nb_all <- tm_map(nb_all, content_transformer(removeNumbers))
> nb_all <- tm_map(nb_all, content_transformer(removePunctuation))
> nb_all <- tm_map(nb_all, content_transformer(tolower))
> nb_all <- tm_map(nb_all, content_transformer(removeWords),
stopwords("english"))
> nb_all <- tm_map(nb_all, content_transformer(stripWhitespace))
现在我们已经预处理了语料库,我们准备计算我们的特征。本质上,我们需要的是一个称为文档-词矩阵的数据结构。矩阵的行是文档。矩阵的列是我们字典中的单词。矩阵中的每个条目都是一个二进制值,其中 1 表示列号所代表的单词在行号所代表的评论中出现过。例如,如果第一列对应于单词 action,第四行对应于第四篇电影评论,并且矩阵在位置 (4,1) 的值是 1,这表示第四篇电影评论包含单词 action。
tm 包为我们提供了一个 DocumentTermMatrix() 函数,它接受一个语料库对象并构建一个文档-词矩阵。构建的特定矩阵具有数值条目,表示特定单词在特定文本中出现的总次数,因此我们将在之后将这些转换为二进制因子:
> nb_dtm <- DocumentTermMatrix(nb_all)
> dim(nb_dtm)
[1] 25000 117473
在这种情况下,我们的文档词频矩阵有 117,473 列,这表明我们在语料库中找到了这么多不同的单词。这个矩阵非常稀疏,这意味着大多数条目都是 0。这是构建文本文档的词频矩阵时的一个非常典型场景,尤其是对于像电影评论这样短的文本文档。任何特定的电影评论只会突出词汇表中的一小部分单词。让我们检查我们的矩阵,看看它有多稀疏:
> nb_dtm
<<DocumentTermMatrix (documents: 25000, terms: 117473)>>
Non-/sparse entries: 2493414/2934331586
Sparsity : 100%
Maximal term length: 64
Weighting : term frequency (tf)
从非稀疏到稀疏条目的比率来看,我们可以看到矩阵中的 2,936,825,000 个条目(25,000 × 117,473)中,只有 2,493,414 个是非零的。在此阶段,我们应该减少这个矩阵的列数有两个原因。一方面,因为我们的词汇表中的单词将成为我们模型中的特征,我们不希望构建一个使用 117,473 个特征的模型。这将花费很长时间来训练,同时,仅使用 25,000 个数据点,也不太可能提供令人满意的拟合。
我们想要减少列数的另一个重要原因是,许多单词在整个语料库中只出现一次或两次,它们对用户情感的了解程度与在几乎所有文档中出现的单词一样。鉴于这一点,我们有一种自然的方法可以减少文档词频矩阵的维度,即通过删除最稀疏的列(即从特征集中删除某些单词)。我们可以使用removeSparseTerms()函数删除具有一定百分比稀疏元素的列。我们必须提供的第一个参数是文档词频矩阵,第二个是我们将允许的最大列稀疏度。选择稀疏度是一个棘手的问题,因为我们不希望丢弃太多的列,这些列将成为我们的特征。我们将通过进行 99%稀疏度的实验来继续进行,同时鼓励读者尝试不同的值,以观察这对特征数量和模型性能的影响。
我们矩阵中有 25,000 行,对应于我们语料库中文档的总数。如果我们允许最大 99%的稀疏度,我们实际上是在删除至少在 25,000 个文档中不出现至少 1%的单词;也就是说,至少在 250 个文档中:
> nb_dtm <- removeSparseTerms(x = nb_dtm, sparse = 0.99)
> dim(nb_dtm)
[1] 25000 1603
我们现在已将列数显著减少至 1,603 列。这对于我们来说是一个更加合理的特征数量。接下来,我们使用tm库中的另一个函数weightBin()将所有条目转换为二进制:
> nb_dtm <- weightBin(nb_dtm)
由于文档词频矩阵通常是一个非常稀疏的矩阵,R 使用紧凑的数据结构来存储信息。为了窥视这个矩阵并检查前几个术语,我们将在这个矩阵的一小部分上使用inspect()函数:
> inspect(nb_dtm[10:16, 1:6])
<<DocumentTermMatrix (documents: 7, terms: 6)>>
Non-/sparse entries: 2/40
Sparsity : 95%
Maximal term length: 10
Weighting : binary (bin)
Terms
Docs ability able absolute absolutely absurd academy
10004_8.txt 0 1 0 0 0 0
10005_7.txt 0 0 0 0 0 0
10006_7.txt 0 0 0 0 0 0
10007_7.txt 0 0 0 0 0 0
10008_7.txt 0 0 0 0 0 1
10009_9.txt 0 0 0 0 0 0
1001_8.txt 0 0 0 0 0 0
看起来单词ability在前六个文档中没有出现,而单词able出现在文档10004_8.txt中。我们现在既有特征也有输出向量。下一步是将我们的文档-词矩阵转换为数据框。这是训练朴素贝叶斯模型所需的功能所必需的。然后,在我们训练模型之前,我们将数据分为一个包含 80%文档的训练集和一个包含 20%文档的测试集,如下所示:
> nb_df <- as.data.frame(as.matrix(nb_dtm))
> library(caret)
> set.seed(443452342)
> nb_sampling_vector <- createDataPartition(scores, p = 0.80,
list = FALSE)
> nb_df_train <- nb_df[nb_sampling_vector,]
> nb_df_test <- nb_df[-nb_sampling_vector,]
> scores_train = scores[nb_sampling_vector]
> scores_test = scores[-nb_sampling_vector]
要训练一个朴素贝叶斯模型,我们将使用我们在前面看到的e1071包中的naiveBayes()函数。我们将提供的第一个参数是我们的特征数据框,第二个参数是我们的输出标签向量:
> library("e1071")
> nb_model <- naiveBayes(nb_dtm_train, scores_train)
我们可以使用predict()函数对我们的训练数据进行预测:
> nb_train_predictions <- predict(nb_model, nb_df_train)
> mean(nb_train_predictions == scores_train)
[1] 0.83015
> table(actual = scores_train, predictions = nb_train_predictions)
predictions
actual negative positive
negative 8442 1558
positive 1839 8161
我们使用简单的朴素贝叶斯模型达到了超过 83%的训练准确率,诚然,对于一个具有独立性假设的简单模型来说,这已经很不错了,尽管我们知道这个假设对于我们的数据来说并不现实。让我们在测试数据上重复同样的操作:
> nb_test_predictions <- predict(nb_model, nb_df_test)
> mean(nb_test_predictions == scores_test)
[1] 0.8224
> table(actual = scores_test, predictions = nb_test_predictions)
predictions
actual negative positive
negative 2090 410
positive 478 2022
超过 82%的测试准确率与我们在训练数据上看到的结果相当。这里有许多潜在的改进途径。首先,要注意到像movie和movies这样的单词被不同地对待,尽管它们是同一个单词的不同变形。在语言学中,变形是指将单词的基本形式或词元修改为与另一个词在诸如时态、格、性别和数量等属性上保持一致的过程。例如,在英语中,动词必须与主语一致。
tm包支持词干提取,这是一个去除单词变形部分的过程,以保留一个词干或根词。这并不总是与检索所谓的形态词元相同,这是我们查字典时查找的内容,但这是一个粗略的近似。tm包使用著名的Porter 词干提取器。
注意
Martin Porter,Porter 词干提取器的作者,维护了一个网站tartarus.org/martin/PorterStemmer/,这是关于他著名算法的极好信息来源。
为了对我们的语料库应用词干提取,我们需要使用tm_map()向语料库添加一个最终的转换,然后重新计算我们的文档-词矩阵,因为现在列(即单词特征)现在是词干:
> nb_all <- tm_map(nb_all, stemDocument, language = "english")
> nb_dtm <- DocumentTermMatrix(nb_all)
> nb_dtm <- removeSparseTerms(x = nb_dtm, sparse = 0.99)
> nb_dtm <- weightBin(nb_dtm)
> nb_df_train <- nb_df[nb_sampling_vector,]
> nb_df_test <- nb_df[-nb_sampling_vector,]
> dim(nb_dtm)
[1] 25000 1553
注意,我们匹配 99%最大稀疏度标准的列更少了。我们可以使用这个新的文档-词矩阵来训练另一个朴素贝叶斯分类器,然后在测试集上测量准确率:
> nb_model_stem <- naiveBayes(nb_df_train, scores_train)
> nb_test_predictions_stem <- predict(nb_model_stem, nb_df_test)
> mean(nb_test_predictions_stem == scores_test)
[1] 0.8
> table(actual = scores_test, predictions =
nb_test_predictions_stem)
predictions
actual negative positive
negative 2067 433
positive 567 1933
结果,80%,略低于我们没有进行词干提取时观察到的结果,尽管我们使用的特征比以前少。词干提取并不总是保证是一个好主意,因为在某些问题中它可能会提高性能,而在其他问题中则可能没有区别,甚至可能变得更糟。然而,当处理文本数据时,这是一种常见的转换,值得尝试。
第二种可能的改进是在我们的朴素贝叶斯模型训练过程中使用加性平滑(也称为拉普拉斯平滑)。这实际上是一种正则化形式,它通过在训练过程中向所有特征和类组合的计数中添加一个固定数值来实现。使用我们的原始文档词频矩阵,我们可以通过指定laplace参数来计算具有加性平滑的朴素贝叶斯模型。然而,对于我们的特定数据集,我们没有观察到任何通过这种方式进行的改进。
我们可以尝试使用朴素贝叶斯模型的一些其他方法,并且我们将在这里提出这些方法供读者实验。首先,手动整理用于模型特征的字词列表通常是值得的。当我们研究我们的文档词频矩阵选择的术语时,我们可能会发现有些词在我们的训练数据中很常见,但我们不期望它们在一般情况下很常见,或者不能代表整体人群。此外,我们可能只想实验那些我们知道能暗示情感和情绪的字词。这可以通过指定在构建我们的文档词频矩阵时使用的特定术语字典来完成。以下是一个例子:
> emotion_words <- c("good", "bad", "enjoyed", "hated", "like")
> nb_dtm <- DocumentTermMatrix(nb_all, list(dictionary =
emotion_words))
在互联网上可以相对容易地找到这样的列表示例。与朴素贝叶斯模型一起使用的另一个常见预处理步骤是去除特征之间的相关性。一种实现方式是执行 PCA,正如我们在第一章中看到的,准备预测建模。此外,这种方法还允许我们从具有更多术语的稍微稀疏的文档词频矩阵开始,因为我们知道我们将通过 PCA 减少整体特征数量。
尽管有潜在的模型改进,但了解朴素贝叶斯模型强加的限制,这些限制阻碍了我们训练一个高度准确的情感分析器的能力,这一点很重要。假设电影评论中的所有词都是相互独立的,一旦我们知道涉及的 sentiment,这是一个相当不切实际的假设。我们的模型完全忽略了句子结构和词序。例如,评论中的短语not bad可能表示积极的 sentiment,但由于我们孤立地看待单词,我们倾向于将单词bad与消极的 sentiment 联系起来。
否定在文本处理中通常是最难处理的问题之一。我们的模型也无法处理常见的语言模式,例如讽刺、反语、包含他人观点的引述段落以及其他此类语言手段。
下一个部分将介绍一个更强大的图形模型。
注意
隐马尔可夫模型
研究朴素贝叶斯分类器的一个好参考是I. Rish在 2001 年 IJCAI 关于人工智能中的经验方法研讨会上的论文An empirical study of the Naïve Bayes classifier。对于情感分析,我们推荐Bing Liu在 2011 年 AAAI 教程中的幻灯片(截至本文写作时)www.researchgate.net/profile/Irina_Rish/publication/228845263_An_Empirical_Study_of_the_Naive_Bayes_Classifier/links/00b7d52dc3ccd8d692000000/An-Empirical-Study-of-the-Naive-Bayes-Classifier.pdf。
隐马尔可夫模型,通常缩写为HMM,我们在这里将使用它,是一种具有重复结构的贝叶斯网络,通常用于建模和预测序列。在本节中,我们将看到该模型的两个应用:一个用于建模 DNA 基因序列,另一个用于建模构成英语文本的字母序列。HMM 的基本图示如下:

如图中所示,序列从左到右流动,并且对于我们要尝试建模的序列中的每个条目,我们都有一个节点对。标记为Ci的节点被称为潜在状态、隐藏状态或仅仅是状态,因为它们通常是不可观察的节点。标记为Oi的节点是观察状态或观察结果。我们将使用术语状态和观察结果。
现在,由于这是一个贝叶斯网络,我们可以立即识别一些关键属性。所有观察结果在给定它们对应的状态时是相互独立的。此外,每个状态在给定其前面的状态(在网络上是其父节点)的情况下,与序列历史中的任何其他状态都是独立的。因此,隐马尔可夫模型背后的关键思想是模型以线性方式从一个状态移动到下一个状态。
在每个潜在状态中,它产生一个观察结果,这也被称为发射符号。这些符号是序列中观察到的部分。隐马尔可夫模型在自然语言处理中非常常见,一个很好的例子是它们在词性标注中的应用。词性标注器的任务是读取一个句子,并返回该句子中单词对应的词性标签序列。例如,给定前面的句子,一个词性标注器可能会为单词“The”返回限定词,为单词“task”返回单数名词,等等。
要使用 HMM 来建模,我们将单词作为发射符号,将词性标签作为潜在状态,因为前者是可观察的,而后者是我们想要确定的。在自然语言处理中,有许多其他序列标注任务已经应用了隐马尔可夫模型,例如命名实体识别,其目标是识别句子中指代个人、地点、组织和其他实体的单词。
隐藏马尔可夫模型由五个核心组件组成。第一个是可能的潜在类别标签集合。对于词性标注器的例子,这可能是我们将使用的所有词性标签的列表。第二个组件是所有可能的发射符号集合。对于一个英语词性标注器,这是英语单词的字典。
接下来的三个组件涉及概率。起始概率向量是一个概率向量,它告诉我们开始于每个潜在状态的概率。例如,在词性标注中,我们可能会有一个很高的概率以一个像the这样的限定词开始。转移概率矩阵是一个矩阵,它告诉我们当当前状态是C[i]时,转移到状态C[j]的概率。因此,这包含了从限定词到名词的转移概率,以我们的词性标注为例。最后,发射概率矩阵告诉我们我们字典中每个符号在我们可以处于的每个状态下的概率。请注意,一些单词(如bank,它既是名词也是动词)可以标记为多个词性标签,因此将会有从多个状态发射的非零概率。
在词性标注等情况下,我们通常有一组标记序列,因此我们的数据包含观察序列及其相应的状态。在这种情况下,类似于朴素贝叶斯模型,我们使用相对频率计数来填充我们模型中的概率组件。
例如,为了找到一个合适的起始概率向量,我们可以列出我们数据集中每个序列的起始状态,并使用这个来获取每个状态的相对频率。当我们只有未标记的序列时,任务会显著更难,因为我们甚至可能不知道我们需要在我们的模型中包含多少个状态。一种在训练数据中将状态分配给未标记观察序列的方法被称为Baum-Welch 算法。
一旦我们知道了我们模型的参数,问题就变成了如何预测观察序列背后的最可能状态序列。给定一个未标记的英文句子,基于 HMM 的词性标注器必须预测词性标签的序列。用于此的最常用算法是基于称为动态规划的编程技术,被称为维特比算法。
我们在本书中讨论的用于隐马尔可夫模型的算法超出了本书的范围,但它们相当直观,值得深入研究。在了解模型的核心组件及其假设的基本理解之后,我们的下一个目标是看看我们如何将它们应用于一些现实世界的情况。我们将首先看到一个带有标记序列的例子,稍后,我们将看到一个带有未标记序列的例子。
小贴士
也许对隐马尔可夫模型最权威和最全面的介绍是 L. R. Rabiner 发表的题为A Tutorial on Hidden Markov Models and Selected Applications in Speech Recognition的开创性论文,发表于IEEE Proceedings, 1989。我们之前提到的Jurafsky和Martin教科书也是学习 HMM 的理想参考,包括 Baum-Welch 和 Viterbi 算法的细节,以及诸如词性标注和命名实体识别等应用。
预测启动子基因序列
我们将要详细研究的第一个应用来自生物学领域。在那里,我们了解到 DNA 分子的基本构建块实际上是四种被称为核苷酸的基本分子。这些被称为胸腺嘧啶、胞嘧啶、腺嘌呤和鸟嘌呤,DNA 链中这些分子出现的顺序编码了 DNA 携带的遗传信息。
分子生物学中的一个有趣问题是找到更大 DNA 链中的启动子序列。这些是起着重要作用的特殊核苷酸序列,它们在调节称为基因转录的遗传过程中扮演着重要角色。这是 DNA 中信息读取机制的第一步。
由 UCI 机器学习仓库托管在archive.ics.uci.edu/ml/datasets/Molecular+Biology+(Promoter+Gene+Sequences)的molecular biology (promoter gene sequences)数据集,包含来自细菌E. Coli的 DNA 中的许多基因序列。
当前预测任务是构建一个模型,能够从非启动子基因序列中区分出启动子基因序列。我们将使用 HMM 来解决这个问题。具体来说,我们将为启动子构建一个 HMM,为非启动子构建一个 HMM,然后选择为我们提供测试序列最高概率的模型,以标记该序列:
> promoters <- read.csv("promoters.data", header = F, dec = ",",
strip.white = TRUE, stringsAsFactors = FALSE)
> promoters[1,]
V1 V2 V3
1 + S10 tactagcaatacgcttgcgttcggtggttaagtatgtataatgcgcgggcttgtcgt
注意,使用read.csv()函数调用中的strip.white = TRUE参数设置来去除空白是很重要的,因为一些字段有前导制表符。数据框的第一列包含一个+或-来表示启动子或非启动子。第二列是特定序列的标识符,第三列是核苷酸序列本身。我们将首先使用第一列将数据分离为启动子序列的正负观察:
> positive_observations <- subset(promoters, V1 == '+', 3)
> negative_observations <- subset(promoters, V1 == '-', 3)
为了训练我们的 HMM,我们想要将每个类别的所有观察结果连接成一个单个观察结果。然而,我们确实想要存储每个序列的开始和结束信息。因此,我们将每个序列前面加上字符S来表示序列的开始,并在每个序列后面加上字符X来表示序列的结束:
> positive_observations <- sapply(positive_observations,
function(x) paste("S", x, "X", sep=""))
> negative_observations <- sapply(negative_observations,
function(x) paste("S", x, "X", sep=""))
> positive_observations[1]
[1] "StactagcaatacgcttgcgttcggtggttaagtatgtataatgcgcgggcttgtcgtX"
接下来,我们将使用strsplit()函数将每个观察结果从字符串分割成一个字符向量,该函数将用于分割的字符串作为第一个参数,以及用作分割点的字符(分隔符)。在这里,我们使用一个空字符进行分割,这样整个字符串就被分割成单个字符:
> positive_observations <- strsplit(positive_observations, "")
> negative_observations <- strsplit(negative_observations, "")
> head(positive_observations[[1]], n = 15)
[1] "S" "t" "a" "c" "t" "a" "g" "c" "a" "a" "t" "a" "c" "g" "c"
现在我们必须指定我们想要训练的 HMM 的概率矩阵。在这种情况下,状态与发出的符号有一一对应的关系,因此实际上这类问题可以简化为可见马尔可夫模型,在这种情况下它只是一个马尔可夫链。尽管如此,我们将遵循与具有多个符号分配给每个状态的情况相同的过程来将这个问题建模为 HMM。我们将假设正负 HMM 都涉及四个状态,对应于四种核苷酸。尽管这两个模型在每个状态都会发出相同的符号,但它们在从一个状态到下一个状态的转移概率上会有所不同。
除了我们之前提到的四个状态之外,我们在每个序列的末尾创建了一个特殊的终止状态,使用符号X表示。我们还创建了一个特殊的起始状态,我们称之为S,这样所有其他状态的起始概率都是 0。此外,发射概率很容易计算,因为每个状态只发出一个符号。由于状态与符号之间的一一对应关系,我们将使用相同的字母表来表示状态及其发出的符号:
> states <- c("S", "X", "a", "c", "g", "t")
> symbols <- c("S", "X", "a", "c", "g", "t")
> startingProbabilities <- c(1,0,0,0,0,0)
> emissionProbabilities <- diag(6)
> colnames(emissionProbabilities) <- states
> rownames(emissionProbabilities) <- symbols
> emissionProbabilities
S X a c g t
S 1 0 0 0 0 0
X 0 1 0 0 0 0
a 0 0 1 0 0 0
c 0 0 0 1 0 0
g 0 0 0 0 1 0
t 0 0 0 0 0 1
计算转移概率矩阵需要我们做更多的工作。因此,我们为这个定义了自己的函数:calculateTransitionProbabilities()。这个函数的输入是一个由训练序列连接而成的单个向量,以及一个包含状态名称的向量。
函数首先计算一个空的转换概率矩阵。通过遍历每个连续的状态对,它累计状态转换的计数。在遍历完所有数据后,我们通过将矩阵的每一行除以该行的元素总和来归一化转换概率矩阵。这样做是因为这个矩阵的行必须总和为 1。我们使用 sweep() 函数,它允许我们使用汇总统计量对矩阵的每个元素应用一个函数。以下是 calculateTransitionProbabilities():
calculateTransitionProbabilities <- function(data, states) {
transitionProbabilities <- matrix(0, length(states), length(states))
colnames(transitionProbabilities) <- states
rownames(transitionProbabilities) <- states
for (index in 1:(length(data) - 1)) {
current_state <- data[index]
next_state <- data[index + 1]
transitionProbabilities[current_state, next_state] <-
transitionProbabilities[current_state, next_state] + 1
}
transitionProbabilities <- sweep(transitionProbabilities, 1,
rowSums(transitionProbabilities), FUN = "/")
return(transitionProbabilities)
}
现在我们准备训练我们的模型。在这个数据集上,关键观察结果是观察值非常少,实际上每个类只有 53 个。这个数据集太小,无法留出一部分用于测试。相反,我们将实现留一法交叉验证来估计我们模型的准确性。为此,我们将从正观察值中省略一个观察值。这将为我们的负 HMM 计算转换概率矩阵留下所有负观察值:
> negative_observation<-Reduce(function(x, y) c(x, y),
negative_observations, c())
> (transitionProbabilitiesNeg <-
calculateTransitionProbabilities(negative_observation, states))
S X a c g t
S 0 0.00000000 0.2264151 0.2830189 0.1320755 0.3584906
X 1 0.00000000 0.0000000 0.0000000 0.0000000 0.0000000
a 0 0.02168022 0.2113821 0.2696477 0.2506775 0.2466125
c 0 0.01256983 0.2500000 0.1634078 0.2667598 0.3072626
g 0 0.01958225 0.3133159 0.2480418 0.1919060 0.2271540
t 0 0.01622971 0.1885144 0.2434457 0.2946317 0.2571785
当处于起始状态(S)时,我们可以随机移动到核苷酸状态,但移动到停止状态(X)或保持在起始状态的几率为零。当处于核苷酸状态时,我们可以随机转换到任何状态,但不能转换回起始状态。最后,从停止状态到起始状态的唯一有效转换是为了新的序列。
现在我们介绍 R 中的 HMM 包,它用于处理隐马尔可夫模型,正如其名所示。我们可以使用 initHMM() 函数使用一组特定的参数初始化一个 HMM。正如预期的那样,这需要五个输入,对应于我们之前讨论过的隐马尔可夫模型的五个组成部分:
> library("HMM")
> negative_hmm <- initHMM(states, symbols, startProbs =
startingProbabilities, transProbs = transitionProbabilitiesNeg,
emissionProbs = emissionProbabilities)
下一步是构建正 HMM,但我们需要多次进行此操作,每次测试时省略一个观察值。这个测试观察值随后将由我们之前训练的负 HMM 和没有该观察值训练的正 HMM 处理。如果正 HMM 对测试观察值的预测概率高于负 HMM,则我们的模型将正确分类测试观察值。以下代码块执行这些计算的循环,针对每个正观察值:
> incorrect <- 0
> for (obs in 1 : length(positive_observations)) {
positive_observation <- Reduce(function(x, y) c(x, y),
positive_observations[-obs], c())
transitionProbabilitiesPos <-
calculateTransitionProbabilities(positive_observation, states)
positive_hmm <- initHMM(states, symbols,
startProbs = startingProbabilities,
transProbs = transitionProbabilitiesPos,
emissionProbs = emissionProbabilities)
test_observation <- positive_observations[[obs]]
final_index <- length(test_observation)
pos_probs <- exp(forward(positive_hmm, test_observation))
neg_probs <- exp(forward(negative_hmm, test_observation))
pos_seq_prob <- sum(pos_probs[, final_index])
neg_seq_prob <- sum(neg_probs[, final_index])
if (pos_seq_prob < neg_seq_prob) incorrect <- incorrect + 1
}
我们现在将逐步分析之前的代码块。首先,我们使用 incorrect 变量跟踪我们犯的任何错误。对于我们的正观察值列表中的每个观察值,我们将训练一个没有此观察值的正 HMM。然后,这个观察值成为我们的测试观察值。
要找到给定特定 HMM 的特定序列的概率,我们使用了forward()函数,该函数计算一个包含观察序列中每一步所有前向概率的对数矩阵。这个矩阵的最后一列,其数值索引就是序列的长度,包含了整个序列的前向概率。我们使用我们训练的正 HMM 来计算正序列概率,并使用exp()函数来撤销对数运算(尽管在这个情况下不是严格必要的,我们只需要比较)。我们使用负 HMM 重复这个过程来计算负序列概率。由于我们的测试观察是正观察之一,只有当负序列概率大于正序列概率时,我们才会误分类。在代码块执行完成后,我们可以看到我们犯了多少错误:
> incorrect
[1] 13
这意味着在 53 个正观察中,我们误分类了 13 个,正确分类了 40 个。尽管如此,我们还没有完成,因为我们还需要对负观察执行类似的循环。这次,我们将使用所有正观察训练一个正 HMM:
> positive_observation <- Reduce(function(x, y) c(x, y),
positive_observations, c())
> transitionProbabilitiesPos <-
calculateTransitionProbabilities(positive_observation, states)
> positive_hmm = initHMM(states, symbols, startProbs =
startingProbabilities, transProbs = transitionProbabilitiesPos,
emissionProbs = emissionProbabilities)
接下来,我们将迭代所有负观察。我们将通过省略一个观察作为测试观察来训练一个负模型。然后,我们将使用我们刚刚训练的正 HMM 和没有这个观察的训练数据的负 HMM 来处理这个观察。
最后,我们将比较由两个 HMMs 产生的这个测试观察序列的预测概率,并根据哪个模型产生了更高的概率来对测试观察序列进行分类。本质上,我们正在做与我们之前迭代正观察时完全相同的过程。下面的代码块将继续更新我们的incorrect变量,并且应该是自解释的:
> for (obs in 1:length(negative_observations)) {
negative_observation<-Reduce(function(x, y) c(x, y),
negative_observations[-obs], c())
transitionProbabilitiesNeg <-
calculateTransitionProbabilities(negative_observation, states)
negative_hmm <- initHMM(states, symbols,
startProbs = startingProbabilities,
transProbs = transitionProbabilitiesNeg,
emissionProbs = emissionProbabilities)
test_observation <- negative_observations[[obs]]
final_index <- length(test_observation)
pos_probs <- exp(forward(positive_hmm,test_observation))
neg_probs <- exp(forward(negative_hmm,test_observation))
pos_seq_prob <- sum(pos_probs[, final_index])
neg_seq_prob <- sum(neg_probs[, final_index])
if (pos_seq_prob > neg_seq_prob) incorrect <- incorrect+1
}
交叉验证中的误分类总数存储在incorrect变量中:
> incorrect
[1] 25
> (cross_validation_accuracy <- 1 - (incorrect/nrow(promoters)))
[1] 0.7641509
我们的整体交叉验证准确率大约是 76%。鉴于我们正在使用留一法,并且训练数据的整体大小如此之小,我们预计这个估计将具有相对较高的方差。
在我们的 HMM 中,马尔可夫属性本质上假设只有前一个核苷酸决定了序列中下一个核苷酸的选择。我们可以合理地预期存在更长的范围依赖,因此,我们受到模型假设的限制。因此,存在一些模型,如三元 HMM,它们考虑了除了当前状态之外过去的状态。
在下一节中,我们将研究一个示例,其中我们使用未标记的数据来训练一个隐马尔可夫模型。我们将手动定义隐藏状态的数量,并使用 Baum-Welch 算法来训练一个 HMM,同时估计状态转换和发射。
预测英语单词中的字母模式
在本节中,我们将模拟构成英语单词的字母模式。除了有不同单词和有时有字母表外,语言之间的不同之处在于构成单词所使用的字母模式。英语单词具有独特的字母和字母序列分布,在本节中,我们将尝试通过使用隐马尔可夫模型以非常简单的方式来模拟单词形成的过程。
我们模型发出的符号将是字母本身,但这次,由于我们使用的是未标记的数据,我们不知道状态可能是什么。因此,我们将只提供我们希望模型拥有的状态数量,然后使用 Baum-Welch 算法来训练我们的 HMM 的参数。
对于这项任务,我们只需要一个英语文本语料库。在本章的早期,我们研究了使用朴素贝叶斯分类器的电影评论,因此我们将使用这些评论以方便起见,尽管也可以使用其他英语文本来源。我们将首先重新加载我们的电影评论,并使用tm包将它们全部转换为小写:
> library("tm")
> nb_pos <- Corpus(DirSource(path_to_pos_folder),
readerControl = list(language = "en"))
> nb_neg <- Corpus(DirSource(path_to_neg_folder),
readerControl = list(language = "en"))
> nb_all <- c(nb_pos, nb_neg, recursive = T)
> nb_all <- tm_map(nb_all, content_transformer(tolower))
接下来,我们将从每个评论中读取文本,并将这些文本收集到一个单独的向量中:
> texts <- sapply(1 : length(nb_all), function(x) nb_all[[x]])
为了简化我们的任务,除了单个字母外,我们还将考虑一个包含所有空白字符(空格、制表符等)的类别,并用大写字母W来表示这些字符。对于数字,我们将使用大写字符N,对于所有标点符号,我们将使用大写字符P,而对于任何剩下的东西,我们将使用大写字符O。我们使用正则表达式来完成这项任务:
> texts <- sapply(texts, function(x) gsub("\\s", "W", x))
> texts <- sapply(texts, function(x) gsub("[0-9]", "N", x))
> texts <- sapply(texts, function(x) gsub("[[:punct:]]", "P", x))
> texts <- sapply(texts, function(x) gsub("[^a-zWNP]", "O", x))
一旦我们将所有文本转换完毕,我们将挑选一个样本,并将每个评论拆分为字符。然后,每个评论中的字符序列将相互连接,以创建一个长的字符序列。在这个上下文中,这工作得相当好,因为评论语料库包含完整的句子,将它们连接起来相当于连接完整的句子。我们选择了 100 篇电影评论的样本。我们可以使用更多,但训练模型所需的时间会更长:
> big_text_splits <- lapply(texts[1:100],
function(x) strsplit(x, ""))
> big_text_splits <- unlist(big_text_splits, use.names = F)
接下来,我们希望初始化我们的 HMM。在这个例子中,我们将考虑一个有三个状态的模式,我们将任意命名为s1、s2和s3。对于发射符号,我们有小写字母和前面提到的四个大写字符,这些字符被用来表示四个特殊字符类别,如数字。R变量中包含小写字母的向量letters,这对我们来说非常方便:
> states <- c("s1", "s2", "s3")
> numstates <- length(states)
> symbols <- c(letters, "W", "N", "P", "O")
> numsymbols <- length(symbols)
接下来,我们将创建随机的起始、发射和传输概率矩阵。我们将使用runif()函数在[0,1]区间内生成随机条目。我们需要对这些矩阵的每一行进行归一化,以确保条目对应于概率。为此,我们将使用之前使用的sweep()函数:
> set.seed(124124)
> startingProbabilities <- matrix(runif(numstates), 1, numstates)
> startingProbabilities <- sweep(startingProbabilities, 1,
rowSums(startingProbabilities), FUN = "/")
> set.seed(454235)
> transitionProbabilities <- matrix(runif(numstates * numstates),
numstates, numstates)
> transitionProbabilities <- sweep(transitionProbabilities, 1,
rowSums(transitionProbabilities), FUN = "/")
> set.seed(923501)
> emissionProbabilities <- matrix(runif(numstates * numsymbols),
numstates, numsymbols)
> emissionProbabilities <- sweep(emissionProbabilities, 1,
rowSums(emissionProbabilities), FUN = "/")
我们现在使用之前获得的大字符序列初始化和训练 HMM。这需要几分钟的时间来运行,具体取决于可用的计算资源,这也是我们之前只抽取文本样本的主要原因:
> hmm <- initHMM(states, symbols, startProbs =
startingProbabilities, transProbs = transitionProbabilities,
emissionProbs = emissionProbabilities)
> hmm_trained <- baumWelch(hmm, big_text_splits)
我们通过简单地提供字符序列,以完全无监督的方式训练我们的模型。我们没有有意义的测试数据集来评估我们模型的性能;相反,这项练习是值得的,因为它产生了一个具有有趣特性的 HMM。查看每个状态的符号发射概率是有教育意义的。这些概率可以通过hmm_trained对象上的hmm$emissionProbs属性访问:

让我们仔细检查这些状态。所有状态都有相对较高的发射空白字符的概率。状态 3 非常有趣,因为它除了空白字符外,似乎还把标点和元音分组在一起。HMM 成功地成功地将字母a、e、i、o和u归入同一类别,而没有关于英语语言的任何先验信息。
此状态也以明显的概率发射了两个辅音。辅音y被发射,我们知道它在诸如rhythm和phylum等单词中偶尔表现得像元音。辅音s也被发射,因为它经常用来构成名词的复数形式,所以我们发现它在单词的末尾,就像标点符号一样。因此,我们看到这个状态似乎将两个主要主题分组在一起。
相比之下,状态 1 倾向于发射辅音而不是元音。事实上,只有元音u似乎有从该状态发射的小概率。状态 2 有元音和辅音的混合,但它是唯一一个辅音h有高概率的状态。这非常有趣,因为h是另一个在发音上具有元音特性的字母(它通常是沉默的或双元音的一部分)。我们可以通过检查状态之间的转移概率来了解更多信息:
> (trained_transition_probabilities <- hmm_trained$hmm$transProbs)
to
from s1 s2 s3
s1 1.244568e-01 5.115204e-01 0.36402279
s2 7.739387e-05 2.766151e-01 0.72330746
s3 9.516911e-01 5.377194e-06 0.04830349
再次,我们可以发现许多有趣的特性。例如,当我们处于状态 3,即元音状态时,我们有 95%的概率会转到状态 1,即辅音状态。这在直觉上是很明显的,因为英语很少出现连续的元音。当我们处于状态 1 时,我们有 36%的概率转到元音状态,有 51%的概率转到状态 2。
现在我们可以开始理解状态 2 代表什么了。它主要代表当我们有两个连续辅音时,发出第二个辅音的状态。这就是为什么在这个状态下,字母h有如此高的概率,因为它参与了非常常见的双元音,如ch、sh和th,当然,th在非常常见的单词如the中也能找到。从这个状态出发,最常见的后续状态,概率为 72%,是元音状态,正如连续两个辅音之后所预期的。
这个实验值得在不同条件下重复进行。如果我们使用不同的种子或者采样不同数量的电影评论,我们可能会看到不同的结果,因为 Baum-Welch 算法对初始条件敏感,并且是无监督的。具体来说,我们的隐马尔可夫模型可能会学习到一组完全不同的状态。
例如,在某些迭代中,我们注意到所有的标点符号和数字都被归入一个状态,另一个状态变成了元音状态,第三个状态是纯辅音状态。如果我们之前在代码中采样了 40 个文本,并使用 1816、1817 和 1818 这三个数字作为三个种子,我们可以重现这种行为。还有许多其他可能性——其中一些比其他更容易解释。
在这里值得调整的另一个参数是状态的数量。如果我们使用两个状态,那么分割往往是在元音和辅音之间。如果我们增加状态的数量,我们通常会继续找到对于多达 10 个状态可解释的结果。隐马尔可夫模型通常也被称为生成模型,因为一旦训练完成,我们可以使用它们来生成状态和观察的示例。我们可以通过提供我们的模型和想要生成的序列长度来使用simHMM()函数实现这一点:
> set.seed(987987)
> simHMM(hmm_trained$hmm, 30)
$states
[1] "s2" "s3" "s1" "s3" "s3" "s1" "s3" "s3" "s1" "s1" "s2" "s3"
[13] "s3" "s1" "s2" "s3" "s1" "s2" "s2" "s2" "s3" "s1" "s2" "s2"
[25] "s3" "s1" "s2" "s3" "s1" "s2"
$observation
[1] "h" "o" "P" "P" "a" "n" "W" "i" "r" "r" "h" "e" "i" "n" "h"
[16] "o" "n" "l" "W" "h" "e" "s" "t" "W" "e" "t" "c" "e" "P" "W"
最后一点,我们可以下载并使用markovchain包,取我们学到的转移概率矩阵,并找出在长期中我们的模型在每个状态上花费了多少时间。这是通过稳态计算来完成的,其数学原理我们将在本书中不进行探讨。幸运的是,markovchain包有一个简单的方法来初始化马尔可夫链,当我们知道涉及的概率时。它是通过使用simpleMc()函数来实现的,我们可以在我们的马尔可夫链上使用steadyStates()函数来找出稳态分布:
> library("markovchain")
> simpleMc<-new("markovchain", states = c("s1", "s2", "s3"),
transitionMatrix = trained_transition_probabilities,
name = "simpleMc")
> steadyStates(simpleMc)
s1 s2 s3
[1,] 0.3806541 0.269171 0.3501748
从长远来看,我们在状态 1(第一个辅音状态)上花费了 38%的时间,在状态 2(第二个辅音状态)上花费了 27%的时间,在状态 3(主要的元音状态)上花费了 35%的时间。
摘要
在本章中,我们介绍了机器学习研究中的一个非常活跃的领域,即概率图模型领域。这些模型涉及使用图形结构来编码随机变量之间的条件独立性关系。我们看到了贝叶斯定理,一个非常简单的公式,它本质上告诉我们如何通过观察效应来推断原因,可以用来构建一个简单的分类器,即朴素贝叶斯分类器。这是一个简单的模型,我们试图预测一个输出类别,该类别最好地解释了一组观察到的特征,所有这些特征都被假定为在给定输出类别的情况下相互独立。
我们使用这个模型来预测一组电影评论中的用户情感,其中特征是评论中存在的单词。虽然我们获得了合理的准确性,但我们发现我们模型中的假设相当严格,这阻碍了我们取得更好的效果。通常,在建模过程中构建一个朴素贝叶斯模型,以提供我们知道应该超过更复杂模型的基线性能。
我们还研究了隐马尔可夫模型,这些模型通常用于标记和预测序列。序列中的每个位置都由一个隐藏状态和从该状态发出的观察组成。模型的关键假设是,每个状态在给定紧邻的前一个状态的情况下独立于整个序列历史。此外,所有观察都是相互独立的,以及所有其他状态,给定它们发出的状态。
当我们拥有标记序列时,我们可以通过使用从数据本身获得的状态转换和符号发射计数来训练一个隐马尔可夫模型。还可以使用一个非常聪明的算法,即 Baum-Welch 算法,来训练一个无监督的 HMM。尽管我们没有深入算法的细节,但我们通过在一个英文单词的字符序列上训练 HMM 的例子,看到了这种实际操作是如何工作的。
从这里,我们看到了所得到的模型捕捉到了语言的一些有趣特性。顺便提一下,尽管我们没有提到,也可以使用EM 算法训练一个带有缺失类标签的朴素贝叶斯模型。尽管 HMMs 也有相对严格的独立性假设,但它们相当强大,并且已经在从语音处理到分子生物学等广泛的应用中取得了成功。
在下一章中,我们将探讨对时间序列进行分析和预测。许多现实世界应用涉及在特定时间段内进行测量,并使用这些测量来预测未来。例如,我们可能想根据今天的天气预测明天的天气,或者根据过去几周的市场波动预测明天的股票市场指数。
第十一章。主题建模
主题建模是一个相对较新且令人兴奋的领域,它起源于自然语言处理和信息检索领域,但同时也被应用于许多其他领域。在分类中,许多问题,如情感分析,涉及将单个类别分配给特定的观察对象。在主题建模中,关键思想是我们可以将不同类别的混合分配给一个观察对象。由于这个领域从信息检索中汲取灵感,我们通常将我们的观察对象视为文档,将我们的输出类别视为主题。在许多应用中,这实际上就是情况,因此我们将重点关注文本文档及其主题的领域,这是一个非常自然的方式来了解这个重要的模型。特别是,我们将关注一种称为潜在狄利克雷分配(LDA)的技术,这是主题建模中最广泛使用的方法。
主题建模概述
在第十章《概率图模型》中,我们看到了如何使用词袋作为朴素贝叶斯模型的特征来执行情感分析。在那里,具体的预测任务涉及确定某个电影评论是表达正面情感还是负面情感。我们明确假设电影评论只表达了一种可能的情感。用作特征的每个单词(如bad、good、fun等)在每个情感下出现在评论中的可能性都不同。
为了计算模型的决策,我们基本上计算了特定评论中所有单词在一个类别下的可能性,并将其与所有单词由另一个类别生成的可能性进行比较。我们使用每个类别的先验概率调整这些可能性,这样,当我们知道训练数据中某个类别更受欢迎时,我们预计在未来未见数据中会更多地发现它。没有机会让电影评论部分为正面,即一些单词来自正面类别,部分为负面,即其余单词出现在负面类别中。
主题模型背后的核心前提是我们的问题中有一组特征和一组生成这些特征的隐藏或潜在变量。关键的是,我们数据中的每个观察对象都包含由这些隐藏变量的混合或子集生成的特征。例如,一篇文章、网站或新闻文章可能有一个中心主题或主题,如政治,但也可能包括来自其他主题的一个或多个元素,如人权、历史或经济学。
在图像领域,我们可能对从阴影和表面等视觉特征集合中识别场景中的特定对象感兴趣。这些特征反过来可能是不同对象的混合产物。在主题建模中,我们的任务是观察文档内的单词,或图像的像素和视觉特征,并从这些特征中确定潜在的混合主题和对象。
文本数据上的主题建模可以用多种方式使用。一种可能的应用是将相似的文档分组在一起,无论是基于它们最占主导地位的主题,还是基于它们的主题混合。因此,它可以被视为一种聚类形式。通过研究主题组成、最频繁出现的单词以及我们获得的集群的相对大小,我们能够总结关于特定文档集合的信息。
我们可以使用集群中最频繁出现的单词和主题直接描述一个集群,这反过来可能对自动生成标签有用,例如提高我们文档的信息检索服务的搜索能力。另一个例子可能是在为推文数据库构建了主题模型之后,自动推荐 Twitter 标签。
当我们使用词袋方法描述如网站之类的文档时,每个文档本质上是一个由我们词典中的单词索引的向量。向量的元素是各种单词的计数或捕获单词是否出现在文档中的二元变量。无论如何,这种表示是编码文本到数值格式的好方法,但结果是,由于单词词典通常很大,结果是一个高维空间中的稀疏向量。在主题模型下,每个文档由主题的混合表示。由于这个数字通常比词典大小小得多,主题建模也可以作为一种降维的形式。
最后,主题建模也可以被视为一个分类的预测任务。如果我们有一组用主要主题标签标记的文档,我们可以在该集合上执行主题建模。如果我们从这种方法中获得的主要主题聚类与我们的标记类别一致,我们可以使用该模型来预测未知文档的主题混合,并根据最占主导地位的主题对其进行分类。我们将在本章后面看到一个例子。现在,我们将介绍执行主题建模最著名的技巧,即潜在狄利克雷分配。
潜在狄利克雷分配
潜在狄利克雷分配(LDA)是进行主题建模的原型方法。遗憾的是,LDA 这个缩写也被用于机器学习中的另一种方法。这种方法与 LDA 完全不同,通常用作执行降维和分类的一种方式。
虽然 LDA 涉及大量的数学,但了解其一些技术细节以了解模型的工作原理和它所使用的假设是值得的。首先,我们应该了解狄利克雷分布,它为 LDA 命名。
注意
关于 LDA 主题模型的更全面处理,一个优秀的参考是A. Srivastava和M. Sahami编辑的《文本挖掘:分类、聚类与应用》一书中关于主题模型的章节,由Chapman & Hall于 2009 年出版。
狄利克雷分布
假设我们有一个具有K个类别的分类问题,并且每个类别的概率是固定的。给定一个长度为K的向量,包含每个类别的发生次数,我们可以通过将向量中的每个条目除以所有计数之和来估计每个类别的概率。
现在假设我们想要预测每个类别在N次试验中出现的次数。如果我们有两个类别,我们可以用二项分布来模拟,就像我们在抛硬币实验中通常做的那样。对于K个类别,二项分布推广到多项式分布,其中每个类别的概率pi是固定的,所有pi实例的总和等于一。现在,假设我们想要模拟具有K个类别的特定多项式分布的随机选择。狄利克雷分布正是如此。以下是它的形式:

这个方程看起来很复杂,但如果我们将其分解为其组成部分并标注所用的符号,我们就能更好地理解它。首先,
项是一个具有K个分量的向量,x[k],代表一个特定的多项式分布。
向量也是一个K个分量的向量,包含狄利克雷分布的K个参数,α[k]。因此,我们正在计算在给定特定参数组合的情况下选择特定多项式分布的概率。请注意,我们向狄利克雷分布提供了一个参数向量,其长度与它将返回的多项式分布的类别数相同。
方程式右侧大乘积之前的分数是一个归一化常数,它只依赖于狄利克雷参数的值,并以伽马函数的形式表示。为了完整性,伽马函数,是阶乘函数的推广,其表达式如下:

最后,在最终产品中,我们看到每个参数,α[k],都与多项式分布的相应分量x[k]配对,形成乘积的项。关于这个分布的重要一点是,通过修改α[k]参数,我们正在修改我们可以绘制的不同多项式分布的概率。
我们特别关注α[k]参数的总和以及它们之间的相对比例。α[k]参数的总和较大往往会产生一个更平滑的分布,涉及许多主题的混合,并且这种分布更有可能遵循α参数的相对比例模式。
狄利克雷分布的一个特殊情况是对称狄利克雷分布,其中所有α[k]参数具有相同的值。当α[k]参数相同且值较大时,我们很可能会抽取一个接近均匀的多项式分布。因此,当我们对特定主题分布没有信息,并且认为所有主题的可能性相等时,我们使用对称狄利克雷分布。
同样,假设我们有一个偏斜的α[k]参数向量,其绝对值很大。例如,我们可能有一个向量,其中一个α[k]参数远高于其他参数,表明倾向于选择其中一个主题。如果我们将其作为狄利克雷分布的输入,我们很可能会抽取一个多项式分布,其中上述主题是可能的。
与之相反,如果α[k]参数的总和是一个小数,这通常会导致一个峰度分布,其中只有一个或两个主题是可能的,其余的则不太可能。因此,如果我们只想对只选择少数主题的多项式抽样过程进行建模,我们会使用低值的α[k]参数,而如果我们想要一个良好的混合,我们会使用较大的值。以下两个图将有助于可视化这种行为。第一个图是对称的狄利克雷分布:

在这个图中,每一列包含使用对称狄利克雷分布为五个主题生成的四个多项式分布的随机样本。在第一列中,所有α[k]参数都被设置为 0.1。请注意,分布非常峰度,并且由于所有α[k]参数的可能性相等,没有偏好于哪个主题将倾向于被选择为最高的峰值。
在中间列中,α[k]参数被设置为 1,由于参数的总和现在更大,我们看到主题的混合更加丰富,即使分布仍然偏斜。当我们把第三列中的α[k]参数设置为 10 时,我们看到样本现在与均匀分布非常接近。
在许多情况下,我们使用狄利克雷分布作为先验分布;也就是说,一个描述我们关于尝试抽取的多项式分布的先验信念的分布。当α[k]参数的总和较高时,我们倾向于认为我们的先验信念非常强烈。在下一张图中,我们将调整α[k]参数的分布,以偏袒第一个主题:

在第一列中,α[k]参数的平均值为 0.1,但我们调整了它们的分布,使得α[1],对应于第一个主题,现在的值是其他主题的四倍。我们看到这增加了第一个主题在抽取的多项式分布中显著出现的概率,但它并不保证是分布的模态。
在中间列中,α[k]参数的平均值现在是 1,但具有相同的偏斜,主题 1 在所有样本中都是分布的模态。此外,其他主题的选择仍然存在很高的变异性。在第三列中,我们有一个更平滑的分布,它同时混合了所有五个主题,但强制偏好第一个主题。
现在我们已经了解了这种分布的工作原理,我们将在下一节中看到它是如何用于构建 LDA 主题模型的。
生成过程
我们对狄利克雷分布进行了深入探讨,因为它在 LDA 主题建模的核心。有了这种理解,我们现在将描述 LDA 背后的生成过程。
生成过程被恰当地命名,因为它描述了 LDA 模型假设我们的数据中的文档、主题和单词是如何生成的。这个过程本质上是对模型假设的说明。为了将 LDA 模型拟合到数据中,所使用的优化过程实际上估计了生成过程的参数。我们现在将看到这个过程是如何工作的:
-
对于我们的 K 个主题中的每一个,我们使用一个由向量α参数化的狄利克雷分布,α的长度为V,即我们的词汇表大小,来对词汇表中的单词抽取一个多项式分布。尽管我们每次都从同一个狄利克雷分布中抽取,但我们已经看到,抽取的多项式分布可能彼此不同。
-
对于我们想要生成的每个文档,d:
-
通过从狄利克雷分布中抽取一个多项式分布,θ[k],该分布由长度为K的向量β参数化,K是主题的数量,来确定这个文档的主题组合。因此,每个文档都将有不同的主题组合。
-
对于我们想要生成的文档中的每个单词,w:
-
使用这个文档的多项式主题分布,θ[k],来抽取一个与这个单词相关联的主题
-
使用特定主题的分布,φ[k],来选择实际的单词
-
注意,在我们的生成过程中,我们使用了两个不同参数化的狄利克雷分布,一个用于抽取主题的多项式分布,另一个用于抽取单词的多项式分布。尽管模型很简单,但它确实捕捉到了关于文档和主题的一些直觉。特别是,它捕捉到了这样一个观点:关于不同主题的文档通常将包含不同的单词,并且比例也不同。一个特定的单词可以与多个主题相关联,但对于某些主题,它出现的频率可能比其他主题高。文档可能有一个中心主题,但它们也可能讨论其他主题,因此我们可以将文档视为处理主题的混合。一个在文档中更重要的主题将是因为文档中处理该主题的单词百分比更高。
狄利克雷分布可以是平滑的或偏斜的,组件的混合可以通过α[k]参数来控制。因此,通过适当调整狄利克雷分布,这个过程可以生成具有单一主题的文档,也可以生成涵盖许多主题的文档。
同时,重要的是要记住模型通过它所做出的某些简化假设的限制。该模型完全忽略了文档内部的单词顺序,并且在生成过程中是无记忆的,这意味着当它生成文档的第n个单词时,它不会考虑之前为该文档抽取的n-1个单词。
此外,LDA 不试图对文档中抽取的主题之间的关系进行建模,因此我们不会尝试组织更可能共同出现的主题,例如天气和旅行或生物学和化学。这是 LDA 模型的一个重大限制,为此已经提出了解决方案。例如,LDA 的一个变体,称为相关主题模型(CTM),遵循与 LDA 相同的生成过程,但使用不同的分布,允许对主题之间的相关性进行建模。在我们的实验部分,我们还将看到 CTM 模型的实现。
注意
相关主题模型在 D. M. Blei 和 J. D. Lafferty 的《科学相关主题模型》一文中提出,该文由 2007 年的《应用统计年鉴》出版。
将 LDA 模型拟合
将 LDA 模型拟合到文档语料库本质上涉及通过 LDA 生成过程,计算估计最有可能生成数据的多项式主题和单词分布,即φ[k]和θ[d]。这些变量是隐藏的或潜在的,这也是为什么这种方法被称为 LDA 的原因。
已经提出了多种优化过程来解决此问题,但数学细节超出了本书的范围。我们将提到其中两种,我们将在下一节中遇到。第一种方法被称为变分期望最大化(VEM),是著名的期望最大化(EM)算法的一个变体。第二种被称为 Gibbs 抽样,是一种基于马尔可夫链蒙特卡洛(MCMC)的方法。
注意
对于 EM 算法和 VEM 的教程,我们推荐《贝叶斯推理的变分近似》,由Dimitris G. Tzikas等人撰写,发表在 2008 年 11 月的IEEE 信号处理杂志上。对于 Gibbs 抽样,有一篇 1992 年的文章发表在《美国统计学家》上,题为解释 Gibbs 抽样器。这两篇文章都很好读,但相当技术性。关于 Gibbs 抽样的更全面的教程是《Gibbs 抽样入门》,由Philip Resnik和Eric Hardisty撰写。最后一个参考资料数学要求较低,可以在www.cs.umd.edu/~hardisty/papers/gsfu.pdf在线找到。
在线新闻故事的主题建模
为了查看主题模型在真实数据上的表现,我们将查看两个包含 2004-2005 年期间来自 BBC 新闻的文章的数据集。第一个数据集,我们将称之为BBC 数据集,包含 2,225 篇文章,这些文章被分为五个主题。这些主题是商业、娱乐、政治、体育和技术。
第二个数据集,我们将称之为BBCSports 数据集,仅包含 737 篇关于体育的文章。这些文章也根据所描述的体育类型分为五个类别。涉及的五种运动是田径、板球、足球、橄榄球和网球。我们的目标将是看看我们是否可以为这两个数据集中的每一个构建主题模型,这些模型将把同一主要主题的文章分组在一起。
注意
两个 BBC 数据集都由D. Greene和P. Cunningham在 2005 年 10 月发表的论文中提出,该论文题为从高维数据生成准确可解释的聚类,并发表在第九届欧洲知识发现与数据挖掘会议(PKDD'05)的论文集中。
两个数据集可以在mlg.ucd.ie/datasets/bbc.html找到。下载后,每个数据集都是一个包含几个不同文件的文件夹。我们将使用变量bbc_folder和bbcsports_folder来存储这些文件夹在计算机上的路径。
每个文件夹包含三个重要文件。具有.mtx扩展名的文件实际上是一个包含稀疏矩阵形式的项文档矩阵的文件。具体来说,矩阵的行是可以在文章中找到的术语,列是文章本身。矩阵中的条目M[i,j]包含对应于行i的术语在对应于列j的文档中出现的次数。因此,项文档矩阵是一个转置的文档术语矩阵,我们在第八章的概率图模型中遇到过。在文件中存储矩阵的特定格式是一种称为矩阵市场格式的格式,其中每一行对应于矩阵中的一个非空单元格。
通常,当我们处理文本,如新闻文章时,我们需要执行一些预处理步骤,例如去除停用词,就像我们在第八章的例子中使用tm包进行情感分析时做的那样,概率图模型。幸运的是,这些数据集中的文章已经过处理,以便它们已经被词干提取;停用词已被移除,以及任何出现次数少于三次的术语。
为了解释项文档矩阵,具有.terms扩展名的文件包含实际的术语,每行一个,它们是项文档矩阵的行名。同样,项文档矩阵中的文档名(列名)存储在具有.docs扩展名的文件中。
我们首先为每个数据集所需的三个文件路径创建变量:
>bbc_folder<- "~/Downloads/bbc/"
>bbcsports_folder<- "~/Downloads/bbcsport/"
>bbc_source<- paste(bbc_folder, "bbc.mtx", sep = "")
>bbc_source_terms<- paste(bbc_folder, "bbc.terms", sep = "")
>bbc_source_docs<- paste(bbc_folder, "bbc.docs", sep = "")
>bbcsports_source<- paste(bbcsports_folder, "bbcsport.mtx", sep = "")
>bbcsports_source_terms<- paste(bbcsports_folder,
"bbcsport.terms", sep = "")
>bbcsports_source_docs<- paste(bbcsports_folder,
"bbcsport.docs", sep = "")
为了将数据从 Market Matrix 格式的文件加载到 R 中,我们可以使用Matrix R 包中的readMM()函数。这个函数将数据加载并存储到一个稀疏矩阵对象中。我们可以使用tm包中的as.TermDocumentMatrix()函数将这个稀疏矩阵转换为tm包可以解释的项文档矩阵。除了该函数的第一个参数矩阵对象外,我们还需要指定weighting参数。这个参数描述了原始矩阵中的数字代表什么。在我们的例子中,我们有原始的词频,所以我们指定值为weightTf:
> library("tm")
> library("Matrix")
>bbc_matrix<- readMM(bbc_source)
>bbc_tdm<- as.TermDocumentMatrix(bbc_matrix, weightTf)
>bbcsports_matrix<- readMM(bbcsports_source)
>bbcsports_tdm<- as.TermDocumentMatrix(bbcsports_matrix,
weightTf)
接下来,我们加载剩余两个文件中的术语和文档标识符,并分别使用这些来为术语-文档矩阵创建适当的行和列名称。我们可以使用标准的scan()函数来读取每行只有一个条目的文件,并将条目加载到向量中。一旦我们有了术语向量和文档标识符向量,我们将使用这些来更新术语-文档矩阵的行和列名称。最后,我们将这个矩阵转置成一个文档-术语矩阵,因为这是我们后续步骤所需的格式:
>bbc_rows<- scan(bbc_source_terms, what = "character")
Read 9635 items
>bbc_cols<- scan(bbc_source_docs, what = "character")
Read 2225 items
>bbc_tdm$dimnames$Terms<- bbc_rows
>bbc_tdm$dimnames$Docs<- bbc_cols
> (bbc_dtm<- t(bbc_tdm))
<<DocumentTermMatrix (documents: 2225, terms: 9635)>>
Non-/sparse entries: 286774/21151101
Sparsity : 99%
Maximal term length: 24
Weighting : term frequency (tf)
>bbcsports_rows<- scan(bbcsports_source_terms, what =
"character")
Read 4613 items
>bbcsports_cols<- scan(bbcsports_source_docs, what =
"character")
Read 737 items
>bbcsports_tdm$dimnames$Terms<- bbcsports_rows
>bbcsports_tdm$dimnames$Docs<- bbcsports_cols
> (bbcsports_dtm<- t(bbcsports_tdm))
<<DocumentTermMatrix (documents: 737, terms: 4613)>>
Non-/sparse entries: 85576/3314205
Sparsity : 97%
Maximal term length: 17
Weighting : term frequency (tf)
现在我们已经准备好了两个数据集的文档-术语矩阵。我们可以看到 BBC 数据集的术语数量大约是 BBCSports 数据集的两倍,后者也有大约三分之一的文档数量,因此它是一个规模较小的数据集。在我们构建主题模型之前,我们还必须创建包含文章原始主题分类的向量。如果我们检查文档 ID,我们可以看到每个文档标识符的格式是<topic>.<counter>:
>bbc_cols[1:5]
[1] "business.001""business.002""business.003""business.004"
[5] "business.005"
>bbcsports_cols[1:5]
[1] "athletics.001""athletics.002""athletics.003"
[4] "athletics.004""athletics.005"
要创建具有正确主题分配的向量,我们只需简单地去除每个条目最后的四个字符。如果我们然后将结果转换为因子,我们就可以看到每个主题有多少个文档:
>bbc_gold_topics<- sapply(bbc_cols,
function(x) substr(x, 1, nchar(x) - 4))
>bbc_gold_factor<- factor(bbc_gold_topics)
> summary(bbc_gold_factor)
business entertainment politics sport
510 386 417 511
tech
401
>bbcsports_gold_topics<- sapply(bbcsports_cols,
function(x) substr(x, 1, nchar(x) - 4))
>bbcsports_gold_factor<- factor(bbcsports_gold_topics)
> summary(bbcsports_gold_factor)
athletics cricket football rugby tennis
101 124 265 147 100
这表明 BBC 数据集在主题分布上相当均匀。然而,在 BBCSports 数据中,我们发现足球文章的数量大约是其他四种运动的两倍。
对于我们的两个数据集,我们现在将使用topicmodels包构建一些主题模型。这是一个非常有用的包,因为它允许我们使用tm包创建的数据结构来执行主题建模。对于每个数据集,我们将构建以下四个不同的主题模型:
-
LDA_VEM: 这是一个使用变分期望最大化(VEM)方法训练的 LDA 模型。这种方法自动估计αDirichlet 参数向量。 -
LDA_VEM_α: 这是一个使用 VEM 训练的 LDA 模型,但这里的区别在于αDirichlet 参数向量没有估计。 -
LDA_GIB: 这是一个使用吉布斯抽样的 LDA 模型。 -
CTM_VEM: 这是一个使用 VEM 训练的相关主题模型(CTM)的实现。目前,topicmodels包不支持使用吉布斯抽样的方法进行训练。
要训练一个 LDA 模型,topicmodels包为我们提供了LDA()函数。我们将为此函数使用四个关键参数。第一个参数指定我们想要为它构建 LDA 模型的文档-术语矩阵。第二个参数k指定我们希望在模型中拥有的目标主题数量。第三个参数method允许我们选择要使用的训练算法。默认情况下,它设置为VEM,所以我们只需要指定我们的LDA_GIB模型,它使用吉布斯抽样。
最后,有一个控制参数,它接受一个影响拟合过程的参数列表。由于主题模型的训练中涉及固有的随机成分,我们可以在该列表中指定一个种子参数,以便使结果可重复。此外,这也是我们指定是否想要估计αDirichlet 参数的地方。这也是我们可以包括吉布斯抽样过程参数的地方,例如训练过程开始时省略的吉布斯迭代次数(burnin)、省略的中间迭代次数(thin)和总的吉布斯迭代次数(iter)。为了训练一个 CTM 模型,topicmodels包为我们提供了CTM()函数,其语法与LDA()函数类似。
使用这些知识,我们将定义一个函数,该函数根据特定的文档词矩阵、所需的主题数量和种子创建一个包含四个训练模型的列表。对于这个函数,我们使用了上述训练参数的一些标准值,鼓励读者进行实验,理想情况下是在调查了两种优化方法提供的参考文献之后:
compute_model_list<- function (k, topic_seed, myDtm){
LDA_VEM<- LDA(myDtm, k = k, control = list(seed = topic_seed))
LDA_VEM_a<- LDA(myDtm, k = k, control = list(estimate.alpha =
FALSE, seed = topic_seed))
LDA_GIB<- LDA(myDtm, k = k, method = "Gibbs", control =
list(seed = topic_seed, burnin = 1000, thin =
100, iter = 1000))
CTM_VEM<- CTM(myDtm, k = k, control = list(seed = topic_seed,
var = list(tol = 10^-4), em = list(tol = 10^-3)))
return(list(LDA_VEM = LDA_VEM, LDA_VEM_a = LDA_VEM_a,
LDA_GIB = LDA_GIB, CTM_VEM = CTM_VEM))
}
我们现在将使用这个函数为两个数据集训练一系列模型:
> library("topicmodels")
> k <- 5
>topic_seed<- 5798252
>bbc_models<- compute_model_list(k, topic_seed,bbc_dtm)
>bbcsports_models<- compute_model_list(k, topic_seed,
bbcsports_dtm)
为了了解主题模型的表现,让我们首先看看每个模型学习的五个主题是否对应于文章最初分配的五个主题。给定这些训练模型之一,我们可以使用topics()函数来获取每个文档最可能选择的主题的向量。
这个函数实际上接受第二个参数,k,默认设置为1,并返回模型预测的前* k* 个主题。在这个特定实例中,我们只想在每个模型中有一个主题。找到最可能的主题后,我们就可以将预测的主题与标记主题的向量进行表格化。这是 BBC 数据集的LDA_VEM模型的预测结果:
>model_topics<- topics(bbc_models$LDA_VEM)
> table(model_topics, bbc_gold_factor)
bbc_gold_factor
model_topics business entertainment politics sport tech
1 11 174 2 0 176
2 4 192 1 0 202
3 483 3 10 0 7
4 9 17 403 4 15
5 3 0 1 507 1
看这张表格,我们可以看到主题 5 几乎完全对应于体育类别。同样,主题 4 和 3 似乎分别对应于政治和商业类别。不幸的是,模型 1 和 2 都包含娱乐和技术文章的混合,因此这个模型并没有真正成功地区分我们想要的类别。
应该很明显,在理想情况下,每个模型主题应该匹配到一个金主题(我们经常用形容词金来指代特定变量的正确或标记值。这源于表达金标准,它指的是一个广泛接受的标准)。我们可以在LDA_GIB模型上重复这个过程,那里的情况不同:
>model_topics<- topics(bbc_models$LDA_GIB)
> table(model_topics, bbc_gold_factor)
bbc_gold_factor
model_topics business entertainment politics sport tech
1 471 2 12 1 5
2 0 0 3 506 3
3 9 4 1 0 371
4 27 16 399 3 9
5 3 364 2 1 13
直观地感觉,这个主题模型比第一个模型更好地匹配我们的原始主题,正如每个模型主题主要选择一个金主题的文章所证明的那样。
估计主题模型与我们的目标主题向量之间匹配质量的一个粗略方法是说,每一行中的最大值对应于分配给该行表示的模型主题的黄金主题。然后,总准确度是这些最大行值与总文档数的比率。在先前的例子中,对于LDA_GIB模型,这个数字将是(471+506+371+399+364)/2225 = 2111/2225= 94.9 %。以下函数计算给定模型和黄金主题向量时的这个值:
compute_topic_model_accuracy<- function(model, gold_factor) {
model_topics<- topics(model)
model_table<- table(model_topics, gold_factor)
model_matches<- apply(model_table, 1, max)
model_accuracy<- sum(model_matches) / sum(model_table)
return(model_accuracy)
}
使用这个准确度的概念,让我们看看在两个数据集中哪个模型表现更好:
>sapply(bbc_models, function(x)
compute_topic_model_accuracy(x, bbc_gold_factor))
LDA_VEMLDA_VEM_aLDA_GIBCTM_VEM
0.7959551 0.7923596 0.9487640 0.6148315
>sapply(bbcsports_models, function(x)
compute_topic_model_accuracy(x, bbcsports_gold_factor))
LDA_VEMLDA_VEM_aLDA_GIBCTM_VEM
0.7924016 0.7788331 0.7856174 0.7503392
对于 BBC 数据集,我们看到LDA_GIB模型显著优于其他模型,而CTM_VEM模型则显著劣于 LDA 模型。对于 BBCSports 数据集,所有模型的表现大致相同,但LDA_VEM模型略好。
评估模型拟合质量的另一种方法是计算给定模型的数据的对数似然,记住这个值越大,拟合越好。我们可以使用topicmodels包中的logLik()函数来做这件事,它表明在两种情况下,最佳模型都是使用吉布斯采样训练的 LDA 模型:
>sapply(bbc_models, logLik)
LDA_VEMLDA_VEM_aLDA_GIBCTM_VEM
-3201542 -3274005 -3017399 -3245828
>sapply(bbcsports_models, logLik)
LDA_VEMLDA_VEM_aLDA_GIBCTM_VEM
-864357.7 -886561.9 -813889.7 -868561.9
模型稳定性
结果表明,在拟合这些模型的过程中,优化程序中的随机成分往往对训练的模型有显著影响。换句话说,我们可能会发现,如果我们使用不同的随机数种子,结果有时可能会显著变化。
理想情况下,我们希望我们的模型是稳定的,这意味着我们希望优化过程的初始条件(由随机数种子确定)的影响最小。通过在多个种子上训练我们的四个模型来研究不同种子对模型的影响是一个好主意:
>seeded_bbc_models<- lapply(5798252 : 5798256,
function(x) compute_model_list(k, x, bbc_dtm))
>seeded_bbcsports_models<- lapply(5798252 : 5798256,
function(x) compute_model_list(k, x, bbcsports_dtm))
在这里,我们使用了五个连续的种子序列,并在两个数据集上分别训练了五次模型。完成这些后,我们可以调查不同种子下我们模型的准确度。如果一个方法的准确度在种子之间变化不大,我们可以推断该方法相当稳定,并产生相似的主题模型(尽管在这种情况下,我们只考虑每份文档中最突出的主题)。
>seeded_bbc_models_acc<- sapply(seeded_bbc_models,
function(x) sapply(x, function(y)
compute_topic_model_accuracy(y, bbc_gold_factor)))
>seeded_bbc_models_acc
[,1] [,2] [,3] [,4] [,5]
LDA_VEM 0.7959551 0.7959551 0.7065169 0.7065169 0.7757303
LDA_VEM_a 0.7923596 0.7923596 0.6916854 0.6916854 0.7505618
LDA_GIB 0.9487640 0.9474157 0.9519101 0.9501124 0.9460674
CTM_VEM 0.6148315 0.5883146 0.9366292 0.8026966 0.7074157
>seeded_bbcsports_models_acc<- sapply(seeded_bbcsports_models,
function(x) sapply(x, function(y)
compute_topic_model_accuracy(y, bbcsports_gold_factor)))
>seeded_bbcsports_models_acc
[,1] [,2] [,3] [,4] [,5]
LDA_VEM 0.7924016 0.7924016 0.8616011 0.8616011 0.9050204
LDA_VEM_a 0.7788331 0.7788331 0.8426052 0.8426052 0.8914518
LDA_GIB 0.7856174 0.7978290 0.8073270 0.7978290 0.7761194
CTM_VEM 0.7503392 0.6309362 0.7435550 0.8995929 0.6526459
在两个数据集中,我们可以清楚地看到,吉布斯采样导致模型更稳定,在 BBC 数据集中,它在准确度方面也是明显的赢家。吉布斯采样通常倾向于产生更准确的模型,但尽管在这些数据集中并不明显,一旦数据集变得很大,它可能会比 VEM 方法慢得多。
使用变分方法训练的两个 LDA 模型在两个数据集上都表现出大约 10%的分数变化。在两个数据集上,我们看到LDA_VEM始终比LDA_VEM_a略好。这种方法在 BBCSports 数据集中,平均而言,所有模型中的准确性也更好。CTM 模型是最不稳定的模型,在两个数据集上都表现出很高的变异性。有趣的是,尽管如此,CTM 模型在五次迭代中的最高性能略逊于使用其他方法可能达到的最佳准确性。
如果我们发现我们的模型在几个种子迭代中不是很稳定,我们可以在训练期间指定nstart参数,该参数指定了在优化过程中使用的随机重启次数。为了看到这在实践中是如何工作的,我们创建了一个修改后的compute_model_list()函数,我们将其命名为compute_model_list_r(),它接受一个额外的参数,nstart。
另一个不同之处在于,现在的seed参数需要一个与随机重启次数一样多的种子向量。为了处理这个问题,我们将简单地从提供的种子开始创建一个适当大小的种子范围。以下是我们的新函数:
compute_model_list_r<- function (k, topic_seed, myDtm, nstart) {
seed_range<- topic_seed : (topic_seed + nstart - 1)
LDA_VEM<- LDA(myDtm, k = k, control = list(seed = seed_range,
nstart = nstart))
LDA_VEM_a<- LDA(myDtm, k = k, control = list(estimate.alpha =
FALSE, seed = seed_range, nstart = nstart))
LDA_GIB<- LDA(myDtm, k = k, method = "Gibbs", control =
list(seed = seed_range, burnin = 1000, thin =
100, iter = 1000, nstart = nstart))
CTM_VEM<- CTM(myDtm, k = k, control = list(seed = seed_range,
var = list(tol = 10^-4), em = list(tol = 10^-3),
nstart = nstart))
return(list(LDA_VEM = LDA_VEM, LDA_VEM_a = LDA_VEM_a,
LDA_GIB = LDA_GIB, CTM_VEM = CTM_VEM))
}
我们将使用这个函数来创建一个新的模型列表。请注意,使用随机重启意味着我们正在增加训练所需的时间,所以接下来的几个命令将需要一些时间才能完成:
>nstart<- 5
>topic_seed<- 5798252
>nstarted_bbc_models_r<-
compute_model_list_r(k, topic_seed, bbc_dtm, nstart)
>nstarted_bbcsports_models_r<-
compute_model_list_r(k, topic_seed, bbcsports_dtm, nstart)
>sapply(nstarted_bbc_models_r, function(x)
compute_topic_model_accuracy(x, bbc_gold_factor))
LDA_VEMLDA_VEM_aLDA_GIBCTM_VEM
0.7959551 0.7923596 0.9487640 0.9366292
>sapply(nstarted_bbcsports_models_r, function(x)
compute_topic_model_accuracy(x, bbcsports_gold_factor))
LDA_VEMLDA_VEM_aLDA_GIBCTM_VEM
0.9050204 0.8426052 0.7991859 0.8995929
注意,即使只使用了五个随机重启,模型的准确性也得到了提高。更重要的是,我们现在看到使用随机重启已经克服了 CTM 模型所经历的波动,因此现在它的表现几乎与每个数据集中最好的模型一样好。
寻找主题数量
在这个预测任务中,不同主题的数量是事先已知的。这证明非常重要,因为它被用作训练我们模型的函数的输入。当我们使用主题建模作为探索性分析的一种形式时,我们的目标是简单地根据主题的相似性将文档聚在一起,这时主题的数量可能并不知道。
这是一个具有挑战性的问题,与我们在执行聚类时选择聚类数量的通用问题有一些相似之处。针对这个问题的一个解决方案是在不同数量的主题范围内进行交叉验证。当数据集很大时,这种方法根本无法扩展,特别是当我们考虑到训练单个主题模型已经相当计算密集,尤其是考虑到随机重启等问题。
注意
一篇讨论了多种估计主题模型中主题数量方法的文章是Edoardo M. Airoldi和其他人撰写的重新概念化 PNAS 文章的分类。这篇文章发表在美国国家科学院院刊,第 107 卷,2010 年。
主题分布
在生成过程的描述中,我们看到了我们使用狄利克雷分布来采样主题的多项分布。在LDA_VEM模型中,估计αk参数向量。请注意,在所有情况下,此实现中均使用对称分布,因此我们只估计α的值,这是所有α[k]参数所取的值。对于 LDA 模型,我们可以调查在估计和不估计的情况下使用此参数的哪个值:
>bbc_models[[1]]@alpha
[1] 0.04893411
>bbc_models[[2]]@alpha
[1] 10
>bbcsports_models[[1]]@alpha
[1] 0.04037119
>bbcsports_models[[2]]@alpha
[1] 10
正如我们所见,当我们估计α的值时,我们获得的α值比默认使用的值要低得多,这表明对于两个数据集,主题分布被认为是峰值的。我们可以使用posterior()函数来查看每个模型的主题分布。例如,对于 BBC 数据集上的LDA_VEM模型,我们发现前几篇文章的主题分布如下:
> options(digits = 4)
> head(posterior(bbc_models[[1]])$topics)
1 2 3 4 5
business.001 0.2700360 0.0477374 0.6818 0.0002222 0.0002222
business.002 0.0002545 0.0002545 0.9990 0.0002545 0.0002545
business.003 0.0003257 0.0003257 0.9987 0.0003257 0.0003257
business.004 0.0002153 0.0002153 0.9991 0.0002153 0.0002153
business.005 0.0337131 0.0004104 0.9651 0.0004104 0.0004104
business.006 0.0423153 0.0004740 0.9563 0.0004740 0.0004740
下面的图是四个模型预测的最可能主题的后验概率直方图。LDA_VEM模型假设一个非常峰值分布,而其他模型则分布更广。CTM_VEM模型在非常高的概率处也有峰值,但与LDA_VEM不同,概率质量分布在广泛的值范围内。我们可以看到,最可能主题的最小概率为 0.2,因为我们有五个主题:

另一种估计主题分布平滑度的方法是计算模型熵。我们将定义为不同文档中所有主题分布的平均熵。平滑分布将比峰值分布具有更高的熵。为了计算我们模型的熵,我们将定义两个函数。函数compute_entropy()计算文档特定主题分布的熵,而compute_model_mean_entropy()函数计算模型中所有不同文档的平均熵:
compute_entropy<- function(probs) {
return(- sum(probs * log(probs)))
}
compute_model_mean_entropy<- function(model) {
topics <- posterior(model)$topics
return(mean(apply(topics, 1, compute_entropy)))
}
使用这些函数,我们可以计算在我们两个数据集上训练的模型的平均模型熵:
>sapply(bbc_models, compute_model_mean_entropy)
LDA_VEMLDA_VEM_aLDA_GIBCTM_VEM
0.3119491 1.2664310 1.2720891 0.8373708
>sapply(bbcsports_models, compute_model_mean_entropy)
LDA_VEMLDA_VEM_aLDA_GIBCTM_VEM
0.3058856 1.3084006 1.3421798 0.7545975
这些结果与前面的图表显示的一致,即峰值最高的LDA_VEM模型比其他模型的熵要低得多。
单词分布
正如前一个部分,我们查看不同文档的主题分布一样,我们通常也感兴趣于了解分配给相同主题的文档中频繁出现的重要术语。我们可以使用terms()函数查看模型主题的* k* 个最频繁术语。这个函数接受一个模型和一个指定我们想要检索的最频繁术语数量的数字。让我们看看 BBC 数据集中LDA_GIB模型的每个主题的 10 个最重要的单词:
>GIB_bbc_model<- bbc_models[[3]]
> terms(GIB_bbc_model, 10)
Topic 1 Topic 2 Topic 3 Topic 4 Topic 5
[1,] "year""plai""peopl""govern""film"
[2,] "compani""game""game""labour""year"
[3,] "market""win""servic""parti""best"
[4,] "sale""against""technolog""elect""show"
[5,] "firm""england""mobil""minist""includ"
[6,] "expect""first""on""plan""on"
[7,] "share""back""phone""sai""award"
[8,] "month""player""get""told""music"
[9,] "bank""world""work""peopl""top"
[10,] "price""time""wai""public""star"
如我们所见,给定这个词根列表,我们可以很容易地猜测我们应该将哪个五个主题标签分配给每个主题。通过词云可视化文档集中的频繁词是一个非常方便的方法。R 包wordcloud对于创建这些词云非常有用。wordcloud()函数允许我们指定一个术语向量以及它们的频率向量,然后这些信息被用于绘图。
不幸的是,我们将不得不对文档词矩阵进行一些操作,以便通过主题计算词频,这样我们就可以将它们输入到这个函数中。为此,我们创建了自己的函数plot_wordcloud(),如下所示:
plot_wordcloud<- function(model, myDtm, index, numTerms) {
model_terms<- terms(model,numTerms)
model_topics<- topics(model)
terms_i<- model_terms[,index]
topic_i<- model_topics == index
dtm_i<- myDtm[topic_i, terms_i]
frequencies_i<- colSums(as.matrix(dtm_i))
wordcloud(terms_i, frequencies_i, min.freq = 0)
}
我们的功能接受一个模型、一个文档词矩阵、一个主题索引以及我们希望在词云中显示的最频繁词的数量。我们首先通过主题计算模型中最频繁的词,就像我们之前做的那样。我们还计算了最可能的主题分配。接下来,我们子集文档词矩阵,以便我们只获得涉及我们感兴趣的词和对应于我们作为参数传递的索引的主题的文档的单元格。
从这个简化的文档词矩阵中,我们对列进行求和以计算最频繁词的频率,最后我们绘制词云。我们使用这个函数绘制了 BBC 数据集中主题的词云,使用了LDA_GIB模型和每个主题 25 个词。如下所示:

LDA 扩展
主题模型是研究的热点领域,因此已经提出了几个 LDA 模型的扩展。我们将简要介绍其中的两个。第一个是监督 LDA模型,其实现可以在ldaR 包中找到。这是一种更直接的方式,使用标准 LDA 方法来建模响应变量,并且将是调查本章讨论的应用的一个很好的下一步。
第二个有趣的扩展是作者-主题模型。这是为了在生成过程中添加一个额外的步骤来考虑作者信息而设计的,当构建总结或预测作者写作习惯和主题的模型时,这是一个很好的模型。
注意
监督 LDA 的标准参考文献是 David M. Blei 和 Jon D. McAuliffe 合著的论文《Supervised Topic Models》。该论文发表于 2007 年《神经信息处理系统》期刊。对于作者-主题模型,请参阅 Michal Rosen-Zvi 等人撰写的论文《The Author-Topic Model for Authors and Documents》。该论文发表在《第 20 届不确定人工智能会议》的论文集中。
建模推文主题
在机器学习和自然语言处理中,主题模型是一种用于发现文档集合中出现的抽象主题的统计模型。一个很好的例子或用例来阐述这个概念是Twitter。假设我们可以分析个人的(或组织的)推文以发现任何主导趋势。让我们看看一个简单的例子。
如果你有一个 Twitter 账户,你可以非常容易地完成这个练习(然后你可以将相同的流程应用于你想要关注的推文存档和/或模型)。首先,我们需要创建一个推文存档文件。
在设置中,你可以提交一个请求以接收你的推文存档文件。一旦准备好,你将收到一封包含下载链接的电子邮件:

然后将你的文件保存在本地:

现在我们有了可以工作的数据源,我们可以将推文移动到一个列表对象(我们将它称为x)中,然后将其转换为 R 数据框对象(df1):

在使用 R 的tm包将推文转换为数据框之前,首先将推文转换为语料库或语料库集合(文本文档对象):

接下来,我们使用以下代码将语料库转换为文档-词矩阵对象。这创建了一个数学矩阵,它描述了在文档集合中出现的词频,在这种情况下,我们的推文集合:

词云生成
在构建文档-词矩阵(之前已展示)后,我们可以更轻松地通过词云(也称为标签云)来展示我们推文中找到的单词的重要性。我们可以使用 R 包wordcloud来完成这项操作:

最后,让我们生成词云视觉图:

看起来这里可能有一个主题!词云显示,单词south和carolinas是最重要的单词。
摘要
本章致力于学习主题模型;在电影评论的情感分析之后,这是我们第二次涉足处理真实文本数据。这次,我们的预测任务是分类网络新闻文章的主题。我们主要关注的主题建模技术是 LDA。这个名字来源于它假设文档内部可以找到的主题和词分布是由从 Dirichlet 先验中采样的隐藏的多项式分布产生的。我们看到了从这些多项式分布中采样单词和主题的生成过程与我们对这个领域的许多自然直觉相吻合;然而,它明显没有考虑到文档内部可能同时出现的各种主题之间的相关性。
在我们的 LDA 实验中,我们发现拟合 LDA 模型的方式不止一种,特别是我们发现被称为 Gibbs 抽样的方法通常更准确,即使它通常计算成本更高。在性能方面,我们发现,当涉及的主题彼此非常不同时,例如 BBC 数据集中的主题,我们在主题预测中获得了非常高的准确率。
同时,然而,当我们对具有更相似主题的文档进行分类,例如 BBCSports 数据集中的不同体育文档时,我们发现这带来了更大的挑战,我们的结果并不那么高。在我们的案例中,另一个可能起作用的因素是,文档和可用的特征数量都比 BBCSports 数据集要少得多。目前,越来越多的 LDA 变体正在被研究和开发,以应对性能和训练速度的限制。
作为一项有趣的练习,我们还下载了一个推文存档,并使用 R 命令创建了一个文档-词矩阵对象,然后我们将其用作创建可视化推文中找到的单词的词云对象的输入。
主题模型可以被视为一种聚类形式,这是我们首次涉足这个领域。在下一章关于推荐系统的章节中,我们将更深入地探讨聚类领域,以便理解像亚马逊这样的网站是如何通过预测购物者最可能感兴趣的产品来做出产品推荐的,这些预测基于他们的购物历史和类似购物者的购物习惯。
第十二章:推荐系统
在我们的最后一章中,我们将解决电子商务世界中普遍存在的问题之一:向客户做出有效的产品推荐。推荐系统,也称为推荐器系统,通常依赖于对象之间相似性的概念,这种方法被称为协同过滤。其基本前提是,如果顾客购买的产品大部分相同,则可以认为他们彼此相似;同样,如果它们有大量共同购买者,则可以认为项目彼此相似。
有许多不同的方法可以量化这种相似性的概念,我们将介绍一些常用的替代方案。无论我们想要推荐电影、书籍、酒店还是餐厅,构建推荐系统通常涉及处理非常大的数据集。
评分矩阵
推荐系统通常涉及一组用户,U = {u[1] , u[2] , …, u[m] },他们对一组项目,I = {i[1], i[2], …, i[n]* },有不同的偏好。用户的数量,|U| = m,通常与项目的数量,|I| = n*,不同。此外,用户通常可以通过对某些项目进行评分来表达他们的偏好。例如,我们可以将用户视为城市中的餐厅顾客,项目是他们访问的餐厅。在这种设置下,用户的偏好可以用五星级评分来表示。当然,我们的推广并不要求项目是实物或用户是真实的人——这只是一个在推荐系统问题中常用的抽象。
作为说明,想象一个用户评分的交友网站;在这里,被评分的项目是实际用户的个人资料。让我们回到我们的餐厅推荐系统示例,并构建一些示例数据。对于推荐系统来说,一种流行的自然数据结构是评分矩阵。这是一个m × n矩阵,其中行代表用户,列代表项目。矩阵中的每个条目e[i],[j]代表用户i对项目j的评分。以下是一个简单的例子:
>oliver<- c(1,1,2,5,7,8,9,7)
>thibault<- c(5,9,4,1,1,7,5,9)
>maria<- c(1,4,2,5,8,6,2,8)
>pedro<- c(2,6,7,2,6,1,8,9)
>ines<- c(1,3,2,4,8,9,7,7)
>gertrude<- c(1,6,5,7,3,2,5,5)
>ratingMatrix<- rbind(oliver, thibault, maria, pedro, ines,
gertrude)
>colnames(ratingMatrix) <- c("Berny's", "La Traviata", "El Pollo
Loco", "Joey's Pizza", "The Old West", "Jake and Jill", "Full
Moon", "Acropolis")
>ratingMatrix
Berny's La Traviata El Pollo Loco Joey's Pizza
oliver 1 1 2 5
thibault 5 9 4 1
maria 1 4 2 5
pedro 2 6 7 2
ines 1 3 2 4
gertrude 1 6 5 7
The Old West Jake and Jill Full Moon Acropolis
oliver 7 8 9 7
thibault 1 7 5 9
maria 8 6 2 8
pedro 6 1 8 9
ines 8 9 7 7
gertrude 3 2 5 5
在这里,我们使用了一个 10 点评分系统作为评价标准,其中 10 是最高评价,1 是最低评价。另一种评分系统是二进制评分系统,其中 1 表示正面评价,0 表示负面评价。第二种方法将产生一个二进制评分矩阵。我们如何能够利用这个评分矩阵来为其他用户提供一个简单的推荐系统呢?
具体来说,假设一个新用户 Silvan 已经对几家餐厅进行了评分,我们希望为他推荐一家他尚未光顾的餐厅。或者,我们可能想要提出前三家餐厅的列表,甚至预测 Silvan 是否会喜欢他目前正在考虑的特定餐厅。
考虑这个问题的方法之一是找到与 Silvan 在已评分餐厅上有相似观点的用户。然后,我们可以使用他们对 Silvan 尚未评分的餐厅的评分来预测 Silvan 对这些餐厅的评分。这似乎很有希望,但我们应该首先考虑如何量化基于他们项目评分的两个用户之间的相似性概念。
测量用户相似度
即使拥有一个非常庞大的用户数据库,对于现实世界的推荐系统来说,找到两个对项目集内所有项目给出完全相同评分的人的可能性很小——如果不是极其不可能的话。话虽如此,我们仍然可以根据用户对不同项目的评分情况,说一些用户比其他用户更相似。例如,在我们的餐厅评分矩阵中,我们可以看到 Ines 和 Oliver 对前四家餐厅的评价较差,而对后四家餐厅的评价较高,因此他们的口味可以被认为是与 Thibault 和 Pedro 这样的配对相比要相似得多,后者有时意见一致,有时对特定餐厅的意见完全相反。
通过将用户表示为评分矩阵中的特定行,我们可以将用户视为一个在n维空间中的向量,其中n是项目的数量。因此,我们可以使用适合向量距离度量的不同度量来衡量两个不同用户的相似度。请注意,距离的概念与相似度的概念成反比,因此我们可以通过将两个向量之间的大距离解释为低相似度分数来将距离度量作为相似度度量。
对于两个向量a和b,最熟悉的距离度量是欧几里得距离:

我们可以使用 R 的内置dist()函数来计算评分矩阵中的所有成对距离,如下所示:
>dist(ratingMatrix, method = 'euclidean')
oliverthibaultmariapedroines
thibault 12.529964
maria 8.000000 11.000000
pedro 10.723805 9.899495 10.246951
ines 3.316625 11.224972 6.082763 10.583005
gertrude 10.488088 10.344080 8.717798 8.062258 10.440307
结果是一个下三角矩阵,因为欧几里得距离是一个对称函数。因此,(maria, pedro)的条目与(pedro, maria)的条目完全相同,所以我们只需要显示其中一个。在这里,我们可以清楚地看到 Ines 和 Oliver 是两个最相似的用户,因为他们之间的距离是最小的。请注意,我们也可以根据从不同用户那里收到的评分相似性来谈论项目之间的距离。为了计算这一点,我们只需要转置评分矩阵:
>dist(t(ratingMatrix), method = 'euclidean')
Berny's La Traviata El Pollo Loco Joey's Pizza
La Traviata 8.366600
El Pollo Loco 6.708204 5.744563
Joey's Pizza 9.643651 9.949874 7.745967
The Old West 13.038405 12.247449 10.535654 7.810250
Jake and Jill 12.000000 11.575837 12.449900 9.848858
Full Moon 12.369317 10.246951 8.717798 9.486833
Acropolis 14.212670 8.831761 10.723805 11.789826
The Old West Jake and Jill Full Moon
La Traviata
El Pollo Loco
Joey's Pizza
The Old West
Jake and Jill 8.246211
Full Moon 8.062258 9.110434
Acropolis 8.831761 9.273618 7.549834
如我们所见,两个最不相似的餐厅(即它们之间差异最大的餐厅)是Acropolis和Berny's。回顾评分矩阵,我们应该很容易看出为什么是这样。前者在我们的用户基础中收到了大量正面评价,而后者则收到了较差的评价。
欧几里得距离(或称为 L2 范数)的一个常用替代方法是余弦距离。这个度量衡量的是两个向量之间最小角度的余弦值。如果两个向量相互平行,即它们的夹角为 0,那么余弦距离也是 0。如果两个向量相互垂直,那么根据这个度量,它们之间的距离最大。余弦距离由以下公式给出:

在这里,分子是两个向量的点积,分母是两个向量模长的乘积(通常通过 L2 范数计算)。余弦距离在 R 的基础分布的dist()函数中不是一个可用方法,但我们可以安装proxy包,它通过添加一些新的距离度量来增强这个函数,以便计算我们的评分矩阵的余弦距离:
> library("proxy")
>dist(ratingMatrix, method = 'cosine')
oliverthibaultmariapedroines
thibault 0.28387670
maria 0.12450495 0.23879093
pedro 0.20947046 0.17687385 0.20854178
ines 0.02010805 0.22821528 0.06911870 0.20437426
gertrude 0.22600742 0.21481973 0.19156876 0.12227138 0.22459114
假设我们的用户对餐厅的评分是二进制的。我们可以通过将所有高于 5 的评分视为正面,并赋予它们新的分数 1,将我们的评分矩阵转换为二进制评分矩阵。其余的评分都转换为分数 0。对于两个二进制向量,Jaccard 相似度由逻辑交集的基数除以逻辑并集的基数给出。Jaccard 距离则是这个值的 1 减去:

简而言之,这计算的是两个向量在位置上都有正面评分的数量与两个向量中任意一个有正面评分的总位置数量的比率之差。两个在所有正面位置上达成一致的二进制向量将是相同的,因此距离为 0。使用proxy包,我们可以如下显示我们的餐厅顾客的Jaccard距离:
>binaryRatingMatrix<- ratingMatrix> 5
>dist(binaryRatingMatrix, method = 'jaccard')
oliverthibaultmariapedroines
thibault 0.6000000
maria 0.2500000 0.5000000
pedro 0.5000000 0.6666667 0.6666667
ines 0.0000000 0.6000000 0.2500000 0.5000000
gertrude 1.0000000 0.7500000 1.0000000 0.8333333 1.0000000
注意
测量和距离度量的研究范围很广,有许多适合的度量已经应用于推荐系统设置。距离度量的权威参考是《距离度量百科全书》,米歇尔·玛丽·德扎和伊莲娜·德扎,斯普林格。
协同过滤
在覆盖了距离之后,我们准备深入探讨协同过滤这一主题,这将帮助我们定义一个制定推荐策略的方法。协同过滤描述了一种算法,或者更确切地说,是一系列算法,旨在根据其他用户的评分信息(通过评分矩阵)以及测试用户已经做出的任何评分,为测试用户创建推荐。协同过滤旨在根据其他用户的评分信息(通过评分矩阵)以及测试用户已经做出的任何评分,为测试用户创建推荐。
协同过滤有两种非常常见的变体,基于记忆的协同过滤和基于模型的协同过滤。在基于记忆的协同过滤中,所有用户所有评分的历史都被记住,并且必须处理这些评分以做出推荐。典型的基于记忆的协同过滤方法是基于用户的协同过滤。尽管这种方法使用了所有可用的评分,但缺点是它可能计算成本高昂,因为整个数据库都用于为我们的测试用户做出评分预测。
与此相反的方法是模型基础的协同过滤。在这里,我们首先创建一个模型来模拟用户的评分偏好,例如一组喜欢相似物品的用户集群,然后使用该模型生成推荐。我们将研究基于物品的协同过滤,这是最著名的模型基础协同过滤方法。
基于用户的协同过滤
基于用户的协同过滤通常被描述为基于记忆或懒惰学习的方法。与本书中我们构建的大多数模型不同,这些模型假设我们将数据拟合到特定的模型,然后使用该模型进行预测,而懒惰学习只是直接使用训练数据本身进行预测。我们在第一章中看到了懒惰学习的例子,即使用 k 近邻算法,在“为预测建模做准备”中。实际上,基于用户的协同过滤方法的前提直接建立在 k 近邻方法之上。
然而,在基于用户的协同过滤中,当我们想要为新用户做出推荐时,我们首先会使用特定的距离度量选择一组相似用户。然后,我们试图推断目标用户将分配给尚未评分的物品的评分,作为相似用户对这些物品评分的平均值。我们通常将这组相似用户称为用户的邻域。因此,这种想法是,用户将更喜欢他们的邻域所偏好的物品。
通常,有两种定义用户邻域的方法。我们可以通过找到 k 近邻来计算一个固定的邻域。这些是我们数据库中与目标用户距离最小的 k 个用户。
或者,我们可以指定一个相似性阈值,并选择数据库中与目标用户距离不超过此阈值的所有用户。这种第二种方法的优势在于,我们将通过使用尽可能接近目标用户的用户来进行推荐,因此我们可以对我们的推荐有很高的信心。另一方面,可能只有很少的用户满足我们的要求,这意味着我们将依赖于这些少数用户的推荐。更糟糕的是,可能没有足够相似于目标用户的用户在我们的数据库中,我们可能根本无法做出任何推荐。如果我们不介意我们的方法有时无法做出推荐,例如因为我们有备用计划来处理这些情况,那么第二种方法可能是一个不错的选择。
在现实世界的场景中,另一个重要的考虑因素是稀疏评分问题。在我们的简单餐厅示例中,每个用户都对每家餐厅进行了评分。这种情况在现实中很少发生,甚至几乎不会发生,因为通常物品的数量太多,以至于用户无法对它们全部进行评分。如果我们以亚马逊.com 等电子商务网站为例,例如,很容易想象任何用户已评分的产品数量仍然是销售产品总数的极小部分。
为了计算用户之间的距离度量以确定相似性,我们通常只包含两个用户都评分的项目。因此,在实践中,我们经常在更少的维度上对用户进行比较。
一旦我们确定了距离度量标准以及如何形成与我们的测试用户相似的用户邻域,我们就使用这个邻域来计算测试用户的缺失评分。这样做最简单的方法是计算用户邻域中每个项目的平均评分,并报告这个值。因此,对于测试用户 t 和一个测试用户尚未评分的项目 j,我们可以预测测试用户对该项目的评分,
,如下所示:

这个方程表达了这样一个简单想法:我们的测试用户 t 对项目 j 的预测评分只是测试用户邻域对该项目的评分的平均值。假设我们有一个新的餐厅场景用户,并且这个用户已经对几家餐厅进行了评分。然后,想象一下,从这些评分中,我们发现新用户的邻域包括 Oliver 和 Thibault。如果我们想要预测测试用户对餐厅El Pollo Loco的评分,这将是通过平均 Oliver 和 Thibault 对该餐厅的评分来完成的,在这种情况下,这将是对 2 和 4 的平均值,得到评分为 3。
如果我们的目标是获取用户的前 N 个推荐列表,我们将对数据库中的所有项目重复此过程,按降序排列,以便评分最高的项目排在最前面,然后从这个列表中挑选出前 N 个项目。在实践中,我们只需要检查新用户邻域中至少有一个用户评价过的项目,以简化这个计算。
我们可以对这种非常简单的方法进行一些改进。首先可能的改进来自于观察,一些用户可能会比其他用户更严格或更宽松地持续评价项目,我们希望平滑这种变化。在实践中,我们经常使用Z分数标准化,它考虑了评分的方差。我们还可以通过减去用户对所有项目的平均评分来对用户的每个评分进行中心化。在评分矩阵中,这意味着从每一行的元素中减去该行的平均值。让我们将这个最后的转换应用到我们的餐厅评分矩阵上,看看结果如何:
>centered_rm<- t(apply(ratingMatrix, 1, function(x) x - mean(x)))
>centered_rm
Berny's La Traviata El Pollo Loco Joey's Pizza
oliver -4.00 -4.00 -3.00 0.0
thibault -0.12 3.88 -1.12 -4.1
maria -3.50 -0.50 -2.50 0.5
pedro -3.12 0.88 1.88 -3.1
ines -4.12 -2.12 -3.12 -1.1
gertrude -3.25 1.75 0.75 2.8
The Old West Jake and Jill Full Moon Acropolis
oliver 2.00 3.0 4.00 2.00
thibault -4.12 1.9 -0.12 3.88
maria 3.50 1.5 -2.50 3.50
pedro 0.88 -4.1 2.88 3.88
ines 2.88 3.9 1.88 1.88
gertrude -1.25 -2.2 0.75 0.75
尽管 Ines 和 Gertrude 最初都给Berny's评了相同的 1 分,但中心化操作使得 Ines 给这家餐厅的评分低于 Gertrude。这是因为 Ines 的平均评分通常高于 Gertrude,因此 Ines 的 1 分可以解释为比 Gertrude 的更强的负面评分。
另一个改进的领域涉及我们如何将新用户邻域的评分纳入以创建最终的评分。通过将所有相邻用户的评分视为相等,我们忽略了这样一个事实,即我们的距离度量可能表明新用户邻域中的某些用户比其他用户更相似。
正如我们在 Jaccard 相似性和 Jaccard 距离的例子中已经看到的,我们通常可以通过以某种方式反转距离度量来定义一个相似度度量。例如,从 1 中减去或取倒数。因此,对于我们选择的距离度量,我们可以定义其相应的相似度度量,并用sim(u,t)表示。用户相似度度量对相似用户具有高值,这些用户在距离度量中具有低值。
在明确了这一点之后,我们可以通过取新用户邻域中相邻用户的评分的加权平均来将用户 u 和 t 之间的相似性纳入我们之前的方程中,如下所示:

我们可能希望在用户评分中包含权重的原因还包括信任度。例如,我们可能更信任那些长期使用我们的餐厅推荐服务的用户,而不是新用户。同样,我们也可能想要考虑用户与新用户共同评价的物品总数。例如,如果一个用户只与一个新用户共同评价了两件物品,那么即使相应的评分是相同的,这两个用户确实非常相似的证据也是有限的。
总的来说,基于用户的协同过滤最大的困难在于,为测试用户做出推荐需要访问整个用户数据库,以便确定用户邻域。这是通过在测试用户和每个其他用户之间执行相似性计算来完成的,这是一个计算上昂贵的步骤。接下来,我们将探讨基于物品的协同过滤,它试图改善这种情况。
基于物品的协同过滤
基于物品的协同过滤是一种基于模型的协同过滤方法。这种方法的核心思想是,我们不会像对待测试用户那样查看其他相似用户,而是直接推荐那些与测试用户评价高的物品相似的物品。由于我们是直接比较物品,而不是首先比较用户来推荐物品,因此我们可以建立一个模型来描述物品之间的相似性,然后使用该模型而不是整个数据库来做出推荐。
建立基于物品的相似性模型的过程包括计算数据库中所有物品对之间的相似性矩阵。如果我们有 N 件物品,那么我们将得到一个包含 N² 个元素的相似性矩阵。为了减少模型的大小,我们可以存储数据库中每个物品的前 k 个最相似物品的相似性值列表。
由于 k 将远小于 N,我们将大大减少我们需要为模型保留的数据量。对于数据库中的每个物品,这个包含 k 个最相似物品的列表类似于基于用户的协同过滤方法中的用户邻域。关于在基于用户的协同过滤中对用户评分的偏差和方差进行归一化的讨论也适用于此处。也就是说,我们可以在归一化评分矩阵后计算物品到物品的相似性。
这种方法并非没有缺点。在基于内存的推荐系统中,由于该方法使用整个数据库(评分矩阵),新的用户评分可以自动纳入推荐过程。基于模型的协同过滤要求我们定期重新训练模型以纳入这些新评分的信息。此外,建模过程丢弃原始评分矩阵中的一些信息,这意味着它有时会做出非最优的推荐。
尽管存在这些缺点,基于物品的协同过滤在空间和时间性能上的表现意味着它在许多现实世界的场景中得到了非常成功的应用。模型的重训练可以在离线状态下进行,并且可以自动安排,而且推荐的非最优性通常是可以容忍的。
我们可以设计一个类似于用户基于协同过滤的方程,解释如何使用基于物品的协同过滤模型来预测新的评分。假设我们想要估计我们的测试用户t对物品I的评分。假设我们已选择了一对物品i和j之间的相似度函数sim(i,j),并据此构建了我们的模型。使用该模型,我们可以检索到我们感兴趣的物品的存储物品邻域S(i)。为了计算我们的测试用户对这一物品的预测评分,我们计算用户对与其相似的物品所做评分的加权总和:

当用户没有对与问题项类似的任何项进行评分时,这种方法可能不会奏效,但它不需要找到与测试用户有相似偏好的用户。
奇异值分解
在现实世界的推荐系统中,随着更多用户被添加到系统中以及提供的物品列表的增长,评分矩阵最终会变得非常大。因此,我们可能希望对此矩阵应用一种降维技术。理想情况下,我们希望在降维的同时尽可能保留原始矩阵中的信息。这种方法在许多学科领域都有应用,它就是奇异值分解,或简称SVD。
SVD 是一种矩阵分解技术,具有许多有用的应用,其中之一就是降维。它与我们在第一章中看到的降维方法 PCA 有关,为预测建模做准备,许多人混淆了这两个概念。实际上,SVD 只是描述了分解矩阵的数学方法。事实上,一些 PCA 的实现使用 SVD 来计算主成分。
让我们先看看这个过程是如何工作的。奇异值分解是一个矩阵分解过程,所以我们从一个代表我们数据的原始矩阵开始,并将其表示为矩阵的乘积。在降维场景中,我们的输入数据矩阵将是行代表数据点、列代表特征的矩阵;因此,在 R 中,这只是一个数据框。在我们的推荐系统场景中,我们使用的矩阵是我们的评分矩阵。假设我们称我们的评分矩阵为 D,并且我们有 m 个用户(行)对 n 个物品(列)进行评分。这个矩阵的奇异值分解由以下公式给出:

在前一个方程中,U 和 V 是方阵,而矩阵 Σ 是与我们的输入矩阵 D 具有相同维度的矩阵。此外,它是一个对角矩阵,这意味着矩阵的所有元素都是零,除了主对角线上的元素。这些元素通常按从大到小的顺序排列,被称为矩阵 D 的奇异值,从而产生了奇异值分解的名称。
注意
熟悉线性代数的读者会知道,矩阵的特征值通常也描述为包含有关该矩阵重要维度的信息。实际上,矩阵的特征值与奇异值通过以下关系相关联——矩阵 D 的奇异值等于矩阵乘积 D × D^T 的特征值的平方根。
我们可以通过 R 的 svd() 函数轻松地对矩阵进行奇异值分解,该函数是 R 的 base 包的一部分。让我们用我们现有的 ratingMatrix 来看看这个例子:
> options(digits = 2)
> (rm_svd<- svd(ratingMatrix))
$d
[1] 35.6 10.6 7.5 5.7 4.7 1.3
$u
[,1] [,2] [,3] [,4] [,5] [,6]
[1,] -0.44 0.48 -0.043 -0.401 0.315 0.564
[2,] -0.41 -0.56 0.703 -0.061 0.114 0.099
[3,] -0.38 0.24 0.062 0.689 -0.494 0.273
[4,] -0.43 -0.40 -0.521 -0.387 -0.483 -0.033
[5,] -0.44 0.42 0.170 -0.108 -0.003 -0.764
[6,] -0.33 -0.26 -0.447 0.447 0.641 -0.114
$v
[,1] [,2] [,3] [,4] [,5] [,6]
[1,] -0.13 -0.255 0.30 -0.0790 0.013 0.301
[2,] -0.33 -0.591 0.16 0.3234 0.065 -0.486
[3,] -0.25 -0.382 -0.36 -0.0625 -0.017 -0.200
[4,] -0.27 0.199 -0.36 0.5796 0.578 0.284
[5,] -0.38 0.460 -0.30 0.1412 -0.556 -0.325
[6,] -0.39 0.401 0.68 0.0073 0.239 -0.226
[7,] -0.42 0.044 -0.26 -0.7270 0.369 -0.047
[8,] -0.52 -0.161 0.11 0.0279 -0.398 0.628
奇异值以向量 d 返回,我们可以使用 diag() 函数轻松地构造对角矩阵。为了验证这个因子分解确实是我们预期的,我们可以通过简单地乘以我们获得的矩阵因子来重建我们的原始评分矩阵:
>reconstructed_rm<- rm_svd$u %*% diag(rm_svd$d) %*% t(rm_svd$v)
>reconstructed_rm
[,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8]
[1,] 1 1 2 5 7 8 9 7
[2,] 5 9 4 1 1 7 5 9
[3,] 1 4 2 5 8 6 2 8
[4,] 2 6 7 2 6 1 8 9
[5,] 1 3 2 4 8 9 7 7
[6,] 1 6 5 7 3 2 5 5
这里需要注意的一点是,如果我们尝试直接用原始矩阵进行等式检查,我们很可能会失败。这是由于我们在存储因子矩阵时引入的舍入误差造成的。我们可以使用 all.equal() 函数来检查我们的两个矩阵几乎相等:
>all.equal(ratingMatrix, reconstructed_rm, tolerance = 0.000001,
check.attributes = F)
[1] TRUE
鼓励读者减小容差的大小,并注意,在几个小数点之后,等式检查会失败。尽管两个矩阵并不完全相等,但差异非常小,这不会对我们产生任何重大影响。现在,一旦我们有了这个因子分解,让我们来研究我们的奇异值。35.6 的第一个奇异值远大于 1.3 的最小奇异值。
我们可以通过保留最大的奇异值并丢弃其余的值来执行降维。为了做到这一点,我们需要知道应该保留多少个奇异值以及应该丢弃多少个。解决这个问题的一个方法是通过计算奇异值的平方,这可以被视为矩阵能量的向量,然后选择至少保留原始矩阵 90%总能量的前几个奇异值。在 R 中这样做很容易,因为我们可以使用cumsum()函数来创建累积和,而奇异值已经按照从大到小的顺序排列:
> energy <- rm_svd$d ^ 2
>cumsum(energy) / sum(energy)
[1] 0.85 0.92 0.96 0.98 1.00 1.00
保留前两个奇异值将保留我们原始矩阵 92%的能量。仅使用两个值,我们可以重建我们的评分矩阵并观察差异:
>d92<- c(rm_svd$d[1:2], rep(0, length(rm_svd$d) - 2))
>reconstructed92_rm<- rm_svd$u %*% diag(d92) %*% t(rm_svd$v)
>reconstructed92_rm
[,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8]
[1,] 0.68 2.0 1.9 5.1 8.3 8.0 6.7 7.2
[2,] 3.37 8.3 5.9 2.7 2.9 3.3 5.9 8.6
[3,] 1.10 3.0 2.4 4.1 6.4 6.3 5.9 6.7
[4,] 3.02 7.5 5.4 3.2 3.9 4.2 6.2 8.6
[5,] 0.87 2.5 2.2 5.1 8.1 7.9 6.8 7.5
[6,] 2.20 5.5 4.0 2.6 3.3 3.5 4.9 6.6
如我们所见,绝对值上有一些差异,但大多数不同用户的模式都得到了很大程度的保留。丢弃奇异值有效地在矩阵D的领先对角线上引入了零,因此这个矩阵最终只有包含零的整个行和列。因此,我们不仅可以截断这个矩阵,还可以截断矩阵U和V的行。
因此,我们减少了需要存储的数据的大小。
预测电影和笑话的推荐
在本章中,我们将专注于使用两个不同的数据集来构建推荐系统。为此,我们将使用recommenderlab包。这个包不仅提供了执行推荐的算法,还提供了存储稀疏评分矩阵的高效数据结构。我们将使用的第一个数据集包含了来自Jester Online Joke 推荐系统的匿名用户对笑话的评论。
笑话评分落在连续的量表上(-10 到+10)。可以从eigentaste.berkeley.edu/dataset/找到来自 Jester 系统的多个数据集。我们将使用网站上标记为Dataset 2+的数据集。这个数据集包含了 50,692 个用户对 150 个笑话的评分。与现实世界的应用典型情况一样,评分矩阵非常稀疏,因为每个用户只对部分笑话进行了评分;用户做出的最低评分数量是 8。我们将把这个数据集称为 jester 数据集。
第二个数据集可以在grouplens.org/datasets/movielens/找到。这个网站包含了在movielens.org的MovieLens网站上制作的用户对电影的评分数据。同样,网站上不止一个数据集;我们将使用标记为MovieLens1M的数据集。这个数据集包含了 6,040 个用户对 3,706 部电影在五点量表(1-5)上的评分。每个用户对电影的最低评分数量是 20。我们将把这个数据集称为电影数据集。
小贴士
这两个数据集实际上是众所周知的开源数据集,以至于 recommenderlab 包本身将它们的小版本包含在包中。对于那些想跳过加载数据和预处理过程,或者由于计算限制而想在小数据集上运行后续示例的读者,我们鼓励他们尝试使用 data(Jester5k) 或 data(MovieLense)。
加载数据和预处理
在构建我们的推荐系统时,我们的第一个目标是使用 R 加载数据,对其进行预处理,并将其转换为评分矩阵。更确切地说,在每种情况下,我们将创建一个 realRatingMatrix 对象,这是 recommenderlab 包用于存储数值评分的特定数据结构。我们将从 jester 数据集开始。如果我们从网站上下载并解压存档,我们会看到文件 jesterfinal151cols.csv 包含评分。更具体地说,该文件中的每一行对应于特定用户做出的评分,每一列对应于一个特定的笑话。
列之间用逗号分隔,没有标题行。实际上,格式几乎已经是一个评分矩阵,如果不是因为第一列是一个特殊的列,它包含特定用户做出的总评分数。我们将使用函数 fread() 将此数据加载到数据表中,这是一个 read.table() 的快速实现,并有效地将数据文件加载到数据表中。然后,我们将使用 data.table 语法高效地删除第一列:
> library(data.table)
> jester <- fread("jesterfinal151cols.csv", sep = ",", header = F)
> jester[, V1 := NULL]
最后一行使用了赋值运算符 := 将第一列 V1 设置为 NULL,这就是我们在数据表上删除列的方法。在我们准备好将数据表 jester 转换为 realRatingMatrix 对象之前,我们还需要在数据表上执行一个最后的预处理步骤。具体来说,我们将将其转换为矩阵,并将所有评分为 99 的实例替换为 NA,因为 99 是用于表示缺失值的特殊评分:
>jester_m<- as.matrix(jester)
>jester_m<- ifelse(jester_m == 99, NA, jester_m)
> library(recommenderlab)
>jester_rrm<- as(jester_m, "realRatingMatrix")
根据我们可用的计算机的计算资源(尤其是可用内存),我们可能想要尝试一次性处理整个数据集,而不是同时加载两个数据集。在这里,我们选择并行处理这两个数据集,以便展示分析的主要步骤,并突出显示单个数据集相对于特定步骤的差异或特殊性。
让我们继续到 MovieLens 数据。下载 MovieLens1M 存档并解压后,会显示三个主要数据文件。users.dat 文件包含有关用户的信息,例如年龄和性别。movies.dat 数据文件反过来包含有关被评分的电影的信息,即电影的标题和属于(例如,喜剧)的电影类型列表。
我们主要关注ratings.dat文件,其中包含评分本身。与原始的 Jester 数据不同,这里每一行对应一个用户做出的单个评分。行格式包含用户 ID、电影 ID、评分和时间戳,所有这些信息都由两个冒号字符::分隔。不幸的是,fread()需要一个单字符分隔符,因此我们将指定一个单冒号。原始数据中的双冒号分隔符会导致我们创建包含NA值的额外列,以及包含时间戳的最后一列,这些列我们都需要删除:
> movies <- fread("ratings.dat", sep = ":", header = F)
> movies[, c("V2", "V4", "V6", "V7") := NULL]
> head(movies)
V1V3V5
1: 1 1193 5
2: 1 661 3
3: 1 914 3
4: 1 3408 4
5: 1 2355 5
6: 1 1197 3
如我们所见,我们现在剩下三列,其中第一列是UserID,第二列是MovieID,最后一列是评分。我们现在将聚合用户做出的所有评分,以形成一个可以解释为或转换为评分矩阵的对象。我们应该以最小化内存使用的方式聚合数据。我们将通过使用Matrix包中的sparseMatrix()命令构建稀疏矩阵来实现这一点。
当我们使用recommenderlab包时,该包会自动加载,因为它是其依赖之一。要使用此函数构建稀疏矩阵,我们可以简单地指定一个行坐标向量、一个匹配的列坐标向量和填充稀疏矩阵的非零值向量。记住,由于我们的矩阵是稀疏的,我们只需要非零条目的位置和值。
目前,我们无法直接将用户 ID 和电影 ID 解释为坐标,这有些不方便。这是因为,如果我们有一个用户 ID 为 1 的用户和一个用户 ID 为 3 的用户,R 会自动创建一个用户 ID 为 2 的用户并创建一个空行,尽管这个用户实际上并不存在于训练数据中。列的情况也类似。因此,在创建我们的评分矩阵之前,我们必须首先将UserID和MovieID列转换为因子。以下是构建 MovieLens 数据评分矩阵的代码:
>userid_factor<- as.factor(movies[, V1])
>movieid_factor<- as.factor(movies[, V3])
>movies_sm<- sparseMatrix(i = as.numeric(userid_factor), j =
as.numeric(movieid_factor), x = as.numeric(movies[,V5]))
>movies_rrm<- new("realRatingMatrix", data = movies_sm)
>colnames(movies_rrm) <- levels(movieid_factor)
>rownames(movies_rrm) <- levels(userid_factor)
> dim(movies_rrm)
[1] 6040 3706
检查结果的维度是否与我们对用户和电影数量的预期相符是一个很好的练习。
探索数据
在使用我们已加载的两个数据集构建和评估推荐系统之前,了解数据是很重要的。一方面,我们可以使用getRatings()函数从评分矩阵中检索评分,这有助于构建项目评分的直方图。此外,我们还可以根据我们之前讨论的,对每个用户的评分进行归一化。以下代码片段显示了如何计算 Jester 数据的评分和归一化评分。然后我们可以对 MovieLens 数据进行同样的操作,并生成评分的直方图:
>jester_ratings<- getRatings(jester_rrm)
>jester_normalized_ratings<- getRatings(normalize(jester_rrm,
method = "Z-score"))
以下图表显示了不同的直方图:

在 Jester 数据中,我们可以看到零以上的评分比零以下的评分更为突出,最常见的评分是 10,即最大评分。归一化评分创建了一个以零为中心的更对称的分布。对于具有 5 点评分尺度的 MovieLens 数据,4 是最突出的评分,而高于 4 的评分比低于 4 的评分更为常见。
我们还可以通过查看评分矩阵的行数和列均值来查找每个用户评分的项目数量和每个项目的平均评分。同样,以下代码片段展示了如何计算 jester 数据中的这些值,并且我们随后用直方图展示了两个数据集的结果:
>jester_items_rated_per_user<- rowCounts(jester_rrm)
>jester_average_item_rating_per_item<- colMeans(jester_rrm)
这里展示了 Jester 和 MovieLens 数据的直方图:

两个数据集在用户平均评分中显示出的曲线看起来像幂曲线。大多数用户评分的项目很少,但少数非常投入的用户实际上评分了大量的项目。在 Jester 案例中,有些人评了数据集中最多的笑话。这是一个例外,并且只发生在这个数据集中项目(笑话)的数量相对较小的情况下。平均笑话评分的分布介于-3 和 4 之间,但对于电影,我们看到整个光谱的范围,这表明一些用户已经对他们认为完全糟糕或完全出色的电影进行了评分。我们可以找到这些分布的平均值,以确定每个用户平均评分的项目数量和每个项目的平均评分。
注意,我们需要从 Jester 数据集中移除NA值,因为某些列可能没有评分:
> (jester_avg_items_rated_per_user<- mean(rowCounts(jester_rrm)))
[1] 34.10493
> (jester_avg_item_rating<- mean(colMeans(jester_rrm), na.rm = T))
[1] 1.633048
> (movies_avg_items_rated_per_user<- mean(rowCounts(movies_rrm)))
[1] 165.5975
> (movies_avg_item_rating<- mean(colMeans(movies_rrm)))
[1] 3.238892
评估二元前 N 个推荐
现在我们对两个数据集的数据有了大致的了解,可以开始构建一些模型。我们将从研究为二元推荐系统制作前 N 个推荐的问题开始,这比我们有更细粒度的评分数据时更容易实现。回想一下,前 N 个推荐只不过是一个列表,其中包含N个更有可能引起用户兴趣的推荐。为此,我们将使用 jester 数据集并创建我们评分矩阵的二元版本。我们将任何评分在 5 或以上的评分视为正面评分。由于这可能导致一些用户没有正面评分,我们将修剪评分矩阵,并只保留至少有 10 个正面评分的用户:
>jester_bn<- binarize(jester_rrm, minRating = 5)
>jester_bn<- jester_bn[rowCounts(jester_bn) > 1]
> dim(jester_bn)
[1] 13789 150
recommenderlab 包的一个优点是它使我们能够很容易地比较几个算法的结果。为 top-N 推荐训练和评估多个算法的过程是从创建一个包含我们想要使用的算法定义的列表开始的。列表中的每个元素都给出了我们选择的名称,但本身必须是一个包含配置已知算法参数的集合的列表。具体来说,这个内部参数列表的 name 参数必须是 recommenderlab 包所认可的。使用此包可以创建并注册自己的算法,但我们的重点将放在现有的实现上,这些实现对于我们的意图和目的已经足够:
> algorithms <- list(
"Random" = list(name = "RANDOM", param = NULL),
"Popular" = list(name = "POPULAR", param = NULL),
"UserBasedCF_COS" = list(name = "UBCF",
param = list(method = "Cosine", nn = 50)),
"UserBasedCF_JAC" = list(name = "UBCF",
param = list(method = "Jaccard", nn = 50))
)
随机算法是一个基线算法,它随机做出推荐。流行算法是另一个基线算法,有时很难击败。它按全球流行度降序提出项目,因此对于 top-1 推荐,它将推荐数据集中平均评分最高的项目。我们选择尝试两种基于用户的协同过滤变体。第一个使用余弦距离,并指定 50 个最近邻的数量。第二个与第一个相同,但使用 Jaccard 距离。
接下来,我们通过函数 evaluationScheme() 定义一个评估方案。此函数记录我们将如何将数据分为训练集和测试集,我们将通过 given 参数从测试用户那里接受的评分数量,以及我们想要执行多少次运行。我们将对训练集和测试集进行直接的 80-20 分割,将测试用户的 10 个评分视为已知评分,并在单次运行中进行评估:
>jester_split_scheme<- evaluationScheme(jester_bn, method =
"split", train = 0.8, given = 10, k = 1)
注意,given 参数必须至少与我们的数据集中用户评分的最小数量相同。我们之前已过滤数据集以确保每个用户有 10 个项目,所以在这个案例中我们已经覆盖了。最后,我们将使用 evaluate() 函数依次用我们的评估方案评估我们的算法列表。除了评估方案和算法列表之外,我们还将指定在通过 n 参数进行 top-N 推荐时使用的 N 值的范围。我们将对 1 到 20 的值进行此操作:
>jester_split_eval<- evaluate(jester_split_scheme, algorithms,
n = 1 : 20)
RANDOM run
1 [0.015sec/1.87sec]
POPULAR run
1 [0.006sec/12.631sec]
UBCF run
1 [0.001sec/36.862sec]
UBCF run
1 [0.002sec/36.342sec]
现在我们有一个列表,其中包含代表每个算法在我们数据上的评估结果的四个对象。我们可以通过查看混淆矩阵来获取重要度量,如精确度。请注意,由于我们已经为 top-N 推荐运行了此实验,其中 N 在 1-20 的范围内,我们预计每个算法将有 20 个这样的混淆矩阵。
当将函数 getConfusionMatrix() 应用到这些对象之一时,可以用来检索折叠的混淆矩阵,以便每行代表 N 的特定值的混淆矩阵:
> options(digits = 4)
>getConfusionMatrix(jester_split_eval[[4]])
[[1]]
TP FP FN TN precision recall TPRFPR
1 0.5181 0.4819 18.47 120.5 0.5181 0.06272 0.06272 0.003867
2 1.0261 0.9739 17.96 120.0 0.5131 0.12042 0.12042 0.007790
3 1.4953 1.5047 17.49 119.5 0.4984 0.16470 0.16470 0.012011
4 1.9307 2.0693 17.06 118.9 0.4827 0.20616 0.20616 0.016547
5 2.3575 2.6425 16.63 118.4 0.4715 0.24215 0.24215 0.021118
6 2.7687 3.2313 16.22 117.8 0.4614 0.27509 0.27509 0.025791
7 3.1530 3.8470 15.83 117.2 0.4504 0.30508 0.30508 0.030709
8 3.5221 4.4779 15.46 116.5 0.4403 0.33216 0.33216 0.035735
9 3.8999 5.1001 15.09 115.9 0.4333 0.36069 0.36069 0.040723
10 4.2542 5.7458 14.73 115.3 0.4254 0.38723 0.38723 0.045890
11 4.6037 6.3963 14.38 114.6 0.4185 0.40927 0.40927 0.051036
12 4.9409 7.0591 14.04 114.0 0.4117 0.43368 0.43368 0.056345
13 5.2534 7.7466 13.73 113.3 0.4041 0.45345 0.45345 0.061856
14 5.5638 8.4362 13.42 112.6 0.3974 0.47248 0.47248 0.067360
15 5.8499 9.1501 13.14 111.9 0.3900 0.48907 0.48907 0.073066
16 6.1298 9.8702 12.86 111.1 0.3831 0.50604 0.50604 0.078836
17 6.4090 10.5910 12.58 110.4 0.3770 0.52151 0.52151 0.084592
18 6.6835 11.3165 12.30 109.7 0.3713 0.53664 0.53664 0.090384
19 6.9565 12.0435 12.03 109.0 0.3661 0.55187 0.55187 0.096198
20 7.2165 12.7835 11.77 108.2 0.3608 0.56594 0.56594 0.102095
为了可视化这些数据并比较我们的算法,我们可以尝试直接使用plot()函数绘制结果。对于我们的评估结果,默认是绘制真正例率(TPR)与假正例率(FPR)的曲线。正如我们从第四章,神经网络中了解的那样,这只是一个 ROC 曲线。
> plot(jester_split_eval, annotate = 2, legend = "topright")
> title(main = "TPR vs FPR For Binary Jester Data")
这是二值 Jester 数据的 ROC 曲线:

图表显示,基于用户的协同过滤算法比两个基线算法表现更好,但这两者之间几乎没有区别,余弦距离略优于 Jaccard 距离。我们可以通过绘制精确度-召回率曲线来补充我们对结果的观点:
> plot(jester_split_eval, "prec/rec", annotate = 2,
legend = "bottomright")
> title(main = "Precision versus Recall Binary Jester Data")
下图是二值 Jester 数据的精确度-召回率曲线:

精确度-召回率曲线描绘了类似的画面,使用余弦距离的基于用户的协同过滤算法成为赢家。请注意,在 top-N 推荐系统中,精确度和召回率之间的权衡通过系统做出的推荐数量体现出来。我们的评估方案的工作方式是,我们将测试数据中的用户视为系统中刚刚贡献了一定数量评分的新用户。我们保留与given参数允许的评分数量一样多的评分。然后,我们应用我们的模型来查看我们建议的评分是否会与剩余的评分一致。我们按信心度降序排列建议,以便在 top-1 推荐系统中,我们将建议我们认为最有可能会引起用户兴趣的项目。增加N,因此,就像撒更宽的网一样。我们的建议将不那么精确,但更有可能找到用户会喜欢的东西。
注意
推荐系统的一个优秀且免费的资源是来自在线教材《大规模数据集挖掘》(Mining of Massive Datasets)的第九章,该教材由Jure Leskovec、Anand Rajaraman和Jeffrey David Ullman编写。网站地址为www.mmds.org/.
评估非二值 top-N 推荐
在本节中,我们将使用电影数据集来观察我们在非二值场景下的表现。首先,我们将定义我们的算法,就像之前一样:
>normalized_algorithms<- list(
"Random" = list(name = "RANDOM", param = list(normalize =
"Z-score")),
"Popular" = list(name = "POPULAR", param = list(normalize =
"Z-score")),
"UserBasedCF" = list(name = "UBCF", param = list(normalize =
"Z-score", method = "Cosine", nn = 50)),
"ItemBasedCF" = list(name = "IBCF", param = list(normalize =
"Z-score")),
"SVD" = list(name = "SVD", param = list(categories = 30,
normalize = "Z-score", treat_na = "median"))
)
这次,我们的算法将通过指定normalize参数来使用归一化评分。我们只将使用余弦距离进行基于用户的协同过滤,因为 Jaccard 距离仅适用于二进制设置。此外,我们还将尝试基于物品的协同过滤以及基于 SVD 的推荐。我们不会直接分割我们的数据,而是通过修改我们的评估方案来展示如何进行十折交叉验证。我们将继续研究在 1 到 20 范围内的顶级 N 推荐。使用 10 折交叉验证评估一个中等规模的数据集和五个算法意味着我们可以预期这个过程将花费相当长的时间来完成,这取决于我们可用的计算能力:
>movies_cross_scheme<- evaluationScheme(movies_rrm, method =
"cross-validation", k = 10, given = 10, goodRating = 4)
>movies_cross_eval<- evaluate(movies_cross_scheme,
normalized_algorithms, n = 1 : 20)
为了节省空间,我们已截断显示不同算法每个迭代运行时间的输出。请注意,在训练过程中最昂贵的算法是基于物品的协同过滤算法,因为这是在构建模型,而不仅仅是进行懒惰学习。一旦过程终止,我们可以像为我们的二值化 Jester 数据集所做的那样绘制结果,以比较我们算法的性能:
> plot(movies_cross_eval, annotate = 4, legend = "topright")
> title(main = "TPR versus FPR For Movielens Data")
这是 MovieLens 数据的 ROC 曲线:

如我们所见,基于用户的协同过滤在这里是明显的赢家。SVD 的表现与 POPULAR 基线相似,尽管后者在N较高时开始变得更好。最后,我们看到基于物品的协同过滤的表现远逊于这些,仅优于随机基线。从这些实验中可以清楚地看出,调整推荐系统可能是一个非常耗时、资源密集的过程。
我们指定的所有算法都可以以各种方式进行调优,我们已经探索了许多参数,从邻域大小到相似度度量,这些都会影响结果。此外,我们注意到,即使是对于顶级 N 场景,也有几种方法可以评估我们的推荐系统;因此,如果我们想尝试其中的一些进行比较,我们还需要在模型训练上花费更多的时间。
鼓励读者使用不同的参数和评估方案重复这些实验,以便了解设计和训练推荐系统的过程。此外,通过访问我们的两个数据集的网站,读者可以找到到类似数据集的链接,这些数据集通常用于学习推荐系统,例如 book-crossing 数据集。
为了完整性,我们将绘制 MovieLens 数据的精确度召回率曲线:
> plot(movies_split_eval, "prec/rec", annotate = 3,
legend = "bottomright")
> title(main = "Precision versus Recall For Movielens Data")
这是 MovieLens 数据的精确度召回率曲线:

评估单个预测
评估推荐系统的另一种方法是要它预测一组测试用户使用他们剩余的评分所做出的部分已知评分的具体值。通过这种方式,我们可以通过在预测评分上取平均距离度量来衡量准确性。这些包括我们之前见过的均方误差(MSE)和均方根误差(RMSE),以及平均绝对误差(MAE),它只是绝对误差的平均值。我们将为此常规(非二值化)的 Jester 数据集执行此操作。
我们像以前一样,首先定义一个评估方案:
>jester_split_scheme<- evaluationScheme(jester_rrm, method =
"split", train = 0.8, given = 5, goodRating = 5)
接下来,我们将使用Recommender()和getData()函数定义基于用户和项目的协同过滤推荐器。这些背后的逻辑是,getData()函数将提取评估方案保留用于训练的评分集,而Recommender()函数将使用这些数据来训练一个模型:
>jester_ubcf_srec<- Recommender(getData(jester_split_scheme,
"train"), "UBCF")
>jester_ibcf_srec<- Recommender(getData(jester_split_scheme,
"train"), "IBCF")
我们可以使用这些模型来预测测试数据中那些被分类为已知(其数量与给定参数指定的数量相同)的评分:
>jester_ubcf_known<- predict(jester_ubcf_srec,
getData(jester_split_scheme, "known"), type="ratings")
>jester_ibcf_known<- predict(jester_ibcf_srec,
getData(jester_split_scheme, "known"), type="ratings")
最后,我们可以使用已知的评分来计算保留用于测试的评分的预测准确性:
> (jester_ubcf_acc<- calcPredictionAccuracy(jester_ubcf_known,
getData(jester_split_scheme, "unknown")))
RMSEMSE MAE
4.70765 22.16197 3.54130
> (jester_ibcf_acc<- calcPredictionAccuracy(jester_ibcf_known,
getData(jester_split_scheme, "unknown")))
RMSEMSE MAE
5.012211 25.122256 3.518815
我们可以看到,这两个算法的性能相当接近。当我们通过平方来惩罚较大的误差(通过 RMSE 和 MSE)时,基于用户的协同过滤表现更好。从平均绝对误差的角度来看,基于项目的协同过滤略微更好。
因此,在这种情况下,我们可能会基于与我们业务需求更接近的错误行为来决定使用哪种类型的推荐系统。在本节中,我们使用了两个算法的默认参数值,但通过在Recommender()函数中使用parameter参数,我们可以像以前一样尝试不同的配置。这留作读者的练习。
其他推荐系统方法
在本章中,我们集中精力通过遵循协同过滤范例来构建推荐系统。这是一个非常受欢迎的方法,因为它具有许多优点。通过本质上模仿口碑推荐,它几乎不需要了解被推荐的项目或有关用户的任何背景知识。
此外,协同过滤系统会随着新评分的出现而纳入新评分,无论是通过记忆方法,还是通过基于模型的常规重新训练。因此,随着时间的推移,它们会自然地变得对用户更好,因为它们学习到更多信息并适应不断变化的偏好。另一方面,它们并非没有缺点,其中最不重要的是,即使有可用信息,它们也不会考虑任何关于项目和它们内容的信息。
基于内容的推荐系统试图根据内容向用户推荐与用户喜欢的项目相似的项目。这一想法背后的关键前提是,如果知道用户恰好喜欢乔治·R·R·马丁(George R. R. Martin)的小说,那么书籍推荐服务可能建议一个类似作者,例如罗伯特·乔丹,是有意义的。
协同过滤系统本质上需要某种反馈系统,以便推荐器记录特定的评分。特别是,它们非常适合利用显式反馈,即用户记录实际的评分或分数。隐式反馈是间接反馈,例如,仅基于用户选择租借某部电影就认为用户喜欢这部电影。基于内容的推荐系统更适合隐式反馈,因为它们将使用有关项目内容的信息来改善对用户偏好的了解。
此外,基于内容的推荐系统通常使用用户配置文件,用户可以记录他们喜欢的关键词列表,例如。此外,如果支持搜索,偏好关键词可以从用户在项目数据库中提出的查询中学习。
某些类型的内容更适合基于内容的方法。基于内容的推荐系统的经典场景是当内容以文本形式存在时。例如,包括书籍和新闻文章推荐系统。基于文本内容,我们可以使用信息检索领域的技巧来构建对不同项目之间相似性的理解。例如,当我们查看第八章(part0069_split_000.html#21PMQ2-c6198d576bbb4f42b630392bd61137d7 "第八章. 维度约简")、概率图模型和第十章(part0076_split_000.html#28FAO2-c6198d576bbb4f42b630392bd61137d7 "第十章. 概率图模型")的主题建模时,我们看到了使用词袋特征分析文本的方法。
当然,图像和视频等内容的这种方法的适用性要低得多。对于通用产品,基于内容的方法需要数据库中所有项目的文本描述,这是其缺点之一。此外,基于内容的推荐往往可能持续推荐过于相似的项目;也就是说,我们的推荐可能不够多样化。例如,我们可能会持续推荐同一作者的书籍或同一主题的新闻文章,正是因为它们的内容非常相似。
相比之下,协同过滤范式仅基于偏好来使用用户和项目之间经验上发现的关系。因此,它可能远不如可预测(尽管在某些情况下,这并不一定是好事)。
协同过滤和基于内容的推荐系统都面临的一个经典难题是冷启动问题。如果我们是基于用户提供的评分或他们以某种方式表示喜欢的内客来提供推荐,那么我们如何处理没有评分的新用户和新物品呢?处理这个问题的一种方法是通过启发式方法或经验法则,例如,通过建议大多数用户都会喜欢的物品,就像 POPULAR 算法所做的那样。
基于知识的推荐系统通过基于规则和其他关于用户和物品的信息来源来制定推荐,从而完全避免了这个问题。这些系统通常表现得很可预测,质量可靠,并且可以在制定推荐时强制执行特定的商业实践,例如销售驱动政策。这类推荐器通常会通过交互式提问来了解用户的偏好,并使用规则或约束来识别应该推荐的物品。
通常,这会导致一个虽然可预测但可以解释其输出的系统。这意味着它可以向用户证明其推荐的合理性,这是大多数遵循其他范式的推荐器所缺乏的特性。除了设计它所需的初始努力之外,基于知识的范式的一个重要缺点是它是静态的,无法适应用户行为的变化或趋势。
最后,值得一提的是,我们可以设计混合推荐系统,结合多种方法。一个例子是,一个使用协同过滤为大多数用户推荐,但对于新加入系统的用户使用基于知识的组件进行推荐的推荐器。混合推荐系统的另一种可能性是构建多个推荐器,并使用投票方案将它们集成到一个集成中,以进行最终推荐。
注意
一本涵盖广泛不同推荐系统范式和示例的优秀全面书籍是Dietmar Jannach和其他人合著的《推荐系统:入门》。这本书由剑桥大学出版社出版。
摘要
在本章中,我们探讨了使用recommenderlab包在 R 中构建和评估推荐系统的过程。我们主要关注协同过滤范式,它简而言之就是通过口碑推荐物品给用户。一般来说,我们发现基于用户的协同过滤执行速度相当快,但需要所有数据来做出预测。基于物品的协同过滤在训练模型时可能较慢,但一旦模型训练完成,预测速度就非常快。它在实践中很有用,因为它不需要我们存储所有数据。在某些场景中,这两种方法在准确性之间的权衡可能很高,但在其他情况下,这种差异是可以接受的。
训练推荐系统的过程非常资源密集,设计过程中涉及到许多重要参数,例如用于量化物品和用户之间相似性和距离的度量标准。最后,我们简要讨论了协作过滤范式的替代方案。基于内容的推荐系统旨在利用物品内容之间的相似性。因此,它们非常适合文本领域。基于知识的推荐系统旨在根据专家设计的规则或约束为用户提供推荐。这些方法可以与其他方法结合使用,以解决新用户或物品的冷启动问题。
在下一章中,我们将介绍如何将本书中已经介绍的技术和实践应用于非常大量的数据源,并指出在处理大数据时遇到的具体挑战。
第十三章。扩展
到目前为止,我们已经回顾了一系列与统计学和特别是预测分析相关的重要主题。在本章中,我们将提供一篇教程,专门介绍如何将这些概念和实践应用于非常大的数据集。首先,我们将从定义“非常大”这个短语开始——至少就其用于描述数据定义(我们希望用它来训练我们的预测模型或运行我们的统计算法)而言。接下来,我们将回顾使用更大数据源带来的挑战列表,最后,我们将提出一些应对这些挑战的想法。
我们本章分为以下部分:
-
开始
-
分析项目的阶段
-
经验和数据规模
-
大数据的特征
-
规模化训练模型
-
特定挑战(大数据)
-
前进的道路
开始项目
通用预测分析项目的阶段可能很简单,也许很容易(真正具有挑战性的是有效地执行每个阶段)。

预测分析项目的阶段
这些阶段包括:
-
定义(数据)。
-
概要和准备(数据)。
-
确定问题(要预测什么)。
-
选择算法。
-
应用模型。
数据定义
一个有趣的思考:
“……一旦你有了足够的数据,你就会开始看到模式,”他说。“你可以建立一个这些数据如何工作的模型。一旦你建立了模型,你就可以预测……”
– 贝托鲁奇,2013
在任何(以及每一个)分析项目的开始阶段,数据被定义——审查和分析:来源、格式、状态、间隔等(有些人将此称为调查可用数据的广度和深度的过程)。
一个要求进行的练习是执行所谓的数据源概要分析,或者通过确定其特征、关系和模式(以及上下文)来建立你的数据概要。这个过程有望产生对将要用于项目的数据的内容和质量的一个更清晰的看法——即数据概要。
然后,在完成数据概要分析之后,人们很可能会进行某种形式的数据清洗(这有时也被称为净化或在某些情况下准备),以努力提高其质量水平。在清洗或清洗数据的过程中,你很可能会执行诸如聚合、追加、合并、重新格式化字段、更改变量类型或添加缺失值等任务。
注意
数据概要技术可以包括特定的分析类型,例如单变量分析,它涉及对分类变量的频率分析以及对连续变量的分布和汇总统计的理解。这有助于处理缺失值、理解分布和异常值处理。
经验
当向主题专家(SME)寻求建议时,很可能会同意一个经验更丰富的人更有可能提供更好的服务。在预测分析项目中,目标不是数据能告诉我们什么,而是数据能告诉我们关于目标或问题的什么,因此,可用于项目的数据源的大小或数量(经验量)变得更为重要。通常情况下,数据越多,越好。
那么,在什么情况下可以说你的预测项目已经有了足够的数据?对这个问题的政治正确答案是这取决于具体情况。某些类型的数据科学和预测分析项目可能需要比其他项目更具体的数据要求,从而实际上确定了可能的最小数据量。
在极端情况下,预测可能需要跨越多年甚至数十年的数据——因为更多的数据可以产生围绕行为和决策等方面的广泛模式。为什么?因为通常情况下,使用更多数据进行分析(或用数据训练模型)可以发展出更全面的理解或更好的预测。
考虑到这一点,可能的一个一般性规则是尽可能收集尽可能多的数据(取决于目标或应用类型)。一些专家可能会建议在开始任何预测分析项目之前收集至少三年的数据,最好是五年的数据。当然,根据应用类型,年可能不是合适的度量单位。例如,案例可能更适合或文本行,等等。
注意
在实践中,如果一个应用是基于医院访问构建的,那么患者案例(通常是数百万)越多越好;一个单词预测应用希望拥有尽可能多的文本句子或单词短语(数千万)以有效(使用)。
另一个预测分析数据的争议可能是理解足够与充足的概念。
在某些情况下,如果数据量或数量不足,明智的数据科学家会始终关注数据的质量或适用性。这意味着尽管数据量少于预期,但根据项目的目标,数据的质量被认为是足够的。
在理解了所有上述要点之后,评估你的数据以确定你的数据量是否已经达到临界点——即典型分析活动开始变得难以执行的那个点——是很重要的。
在下一节中,我们将介绍如何确定数据量的临界点,因为在开始重型模型训练之前理解和预期挑战总是比在已经开始之后发现困难的方法要好。
规模数据——大数据
当我们使用“数据规模”这个短语时,我们并不是指区间、顺序、名义和二分的统计测量尺度。我们在这里使用这个短语是松散的,以传达在您的分析项目中将要使用的数据源的大小、量或复杂性。
现在,众所周知的时髦词大数据可能(松散地)适用于这里,所以让我们在这里停下来,定义我们是如何使用大数据这个术语的。
大量的数据集合,如此之大或复杂以至于传统的数据处理应用不足,以及关于我们生活各个方面的数据都被用来定义或指代大数据。
下图说明了大数据的三个 V:

2001 年,当时的 Gartner 分析师道格·兰尼提出了 3Vs 概念来描述大数据的发生。根据兰尼的说法,3Vs 是量、种类和速度。Vs 构成了大数据的维度:量(或可测量的数据量)、种类(意味着数据类型的数量)和速度(指处理或处理该数据的速度)。
注意
兰尼的解释可以在这里查看:blogs.gartner.com/doug-laney/files/2012/01/ad949-3D-Data-Management-Controlling-Data-Volume-Velocity-and-Variety.pdf)。
使用量、种类和速度的概念,可以更容易地预见一个大数据源如何变得或迅速变得难以处理,并且随着这些维度的增加或扩展,它们将只会阻碍在数据上有效训练预测模型的能力。
使用 Excel 衡量你的数据
微软 Excel 不是一个用来确定你的数据是否符合大数据标准的工具。
如果你的数据太大以至于无法使用微软 Excel 处理,这并不意味着它一定符合大数据的标准。 事实上,即使是几吉字节的数据,仍然可以通过各种技术、企业级甚至开源工具来管理,尤其是在今天存储成本较低的情况下。
在选择方法或开始任何配置文件或准备工作之前,能够现实地评估或调整你将在预测项目中使用的数据技术(考虑到预期的数据增长速度)是很重要的。这段时间花得很值得,因为它将节省以后可能因性能瓶颈或重写脚本以使用不同方法(可以处理更大数据源的方法)而失去的时间。
因此,问题变成了,你如何衡量你的数据——它真的是大数据吗?它是可管理的吗?还是它属于需要特殊处理或预处理才能有效用于预测分析目标的那一类?
大数据的特征
为了确定你的数据源是否属于大数据或需要特殊处理,你可以从以下方面开始检查你的数据源:
-
数据的体积(数量)。
-
数据的多样性。
-
不同数据源的数量和数据跨度。
让我们逐一考察这些领域。
体积
如果你谈论的是行数或记录数,那么你的数据源很可能不是大数据源,因为大数据通常以千兆字节、太字节和拍字节来衡量。然而,空间并不总是意味着大数据,因为这些尺寸测量在体积和功能方面可能有很大的差异。此外,具有数百万条记录的数据源,如果其结构(或缺乏结构)符合条件,也可能被视为大数据。
多样性
用于预测模型的数据可能是结构化的、非结构化的(或两者兼有),包括来自数据库的交易、调查结果、网站日志、应用程序消息等(通过使用包含更多样化数据的数据源,你通常能够覆盖更广泛的上下文,从而从其中获得的分析)。多样性与体积一样,被视为大数据的正常标准。
数据源和跨度
如果你的预测分析项目数据源是整合了多个来源的结果,你很可能同时满足了体积和多样性的标准,你的数据可以被视为大数据。如果你的项目使用受政府法规影响的数据,如消费者请求的历史分析,你几乎可以确定正在使用大数据。政府法规通常要求某些类型的数据需要存储数年。产品在其生命周期内可能由消费者驱动,并且根据今天的趋势,历史分析数据通常可用超过五年。再次强调,这些都是大数据来源的例子。
结构
你经常会发现数据源通常属于以下三个类别之一:
-
数据结构化程度低或没有结构的数据源(如简单的文本文件)。
-
包含结构化和非结构化数据(如来自文档管理系统或各种网站的来源)的数据源。
-
包含高度结构化数据(如存储在关系型数据库中的交易数据)的数据源。
你的数据源如何分类将决定你在预测分析项目的每个阶段如何准备和操作你的数据。
虽然具有结构的数据源显然仍然可以归入大数据类别,但包含结构化和非结构化数据(以及当然完全非结构化数据)的数据源才符合大数据的定义,并且需要特殊处理或预处理。
统计噪声
最后,我们应该注意,除了本章中已经讨论的因素之外,其他因素也可以使你的项目数据源被视为难以处理、过于复杂或大数据源。
这包括(但不限于):
-
统计噪声(一个术语,用于描述数据中未解释的变异量)
-
数据存在理解不匹配(社区、文化、实践等对数据的解释差异)
-
以及其他
一旦你确定你将在预测分析项目中使用的数据源似乎符合大的标准(再次强调,我们在这里使用这个术语),那么你可以继续决定如何管理和操作这个数据源的过程,基于这类数据所要求的已知挑战,以便最有效地进行。
在下一节中,我们将在继续提供可用的解决方案之前,回顾一些这些常见问题。
规模化训练模型
在本章的早期部分,我们列出并研究了行业专家一致认为的任何预测分析项目最常见阶段的内容。
回顾一下,它们如下:
-
定义数据源
-
数据源的配置文件和准备
-
确定你想向你的数据提出的问题(们)
-
选择在数据源上训练的算法
-
应用预测模型
在使用大数据的预测分析项目中,这些相同的阶段都存在,但可能略有变化,并需要一些额外的努力。
阶段性疼痛
在项目的初期阶段,一旦你选择了数据源(确定了数据源),就必须获取数据。一些行业专家将此描述为数据的获取和记录。在一个涉及更常见数据源的预测项目中,访问数据可能就像在你的本地磁盘上打开一个文件一样简单;而对于大数据源来说,则要复杂得多。例如,假设你的项目从多种设备(多个服务器和许多移动设备,即物联网数据)的组合中获取数据。
这种活动生成数据可能包括网站跟踪信息、应用程序日志、传感器数据——以及其他机器生成内容——非常适合你的分析。你可以看到,将获取这些信息作为你项目单一数据源的努力需要一些努力(以及专业知识!)。
在配置文件和准备阶段,数据被提取、清理和注释。通常,任何分析项目都将需要这种数据预处理:设置上下文、确定操作定义和统计类型等。这一步至关重要,因为这是我们建立对数据挑战理解的过程,以便以后可以最小化意外。这一阶段通常涉及花费时间查询和重新查询数据、创建可视化以验证发现,然后更新数据以解决关注领域。大数据阻碍了这些活动,因为它可能包括更多需要处理的数据,格式可能不一致,并且可能变化迅速。
在问题确定阶段,必须考虑数据集成、聚合和数据表示,以便可以确定向数据提出的问题。这一阶段可以分为三个步骤;准备、集成和问题确定。准备步骤涉及组装数据、识别唯一键、聚合/重复、按要求进行清理、格式操作,也许还有值的映射。集成步骤涉及合并数据、测试和协调。最后,确定项目问题。再次强调,大数据的量、种类和速度可能会显著减缓这一阶段。
选择算法和预测模型的应用是分析、建模和解释数据的阶段。考虑到大数据源的量、种类和速度,选择用于训练数据的适当算法可能更加复杂。一个例子是预测建模在可能的最小粒度下效果最好,在前一阶段,大数据源的 sheer volume 可能需要大量聚合,从而可能埋没了数据中存在的异常和变化。
具体挑战
让我们花几分钟时间来讨论一下大数据带来的具体挑战。其中一些主要话题包括:
-
异质性
-
规模
-
位置
-
及时性
-
隐私
-
合作
-
可重复性
异质性
通过多样性,我们通常需要考虑数据类型、表示和语义解释的异质性。正确审查和理解大数据源中这些变化的工作可能既耗时又复杂。有趣的是,一个元素在较大尺度上可能是同质的(更均匀),而与较小尺度上的异质(不均匀)相比。这意味着你处理大数据源的方法可能会导致非常不同的结果!
规模
我们已经提到了规模的概念——通常规模指的是数据源的 sheer size,但也可能指的是其复杂性。
位置
通常情况下,当你决定使用大数据源时,你会发现它并不位于一个地方,而是分散在电子空间中。这意味着任何过程(手动或自动化)都必须在数据能够被项目正确使用之前,对数据进行物理或虚拟的整合。
及时性
数据量越大,分析所需的时间就越长。然而,当人们在大数据背景下谈论速度时,并不仅仅是指这个时间。相反,数据获取率是一个挑战。换句话说,当数据源中的数据不断累积或更新时,何时(或多长时间)才能建立正确的快照?此外,扫描整个数据源以找到与特定预测分析目标相关的合适样本显然是不切实际的。
隐私
使用任何数据源时,都应该考虑数据隐私,在大数据背景下,这种考虑会变得更加复杂。最著名的例子是电子健康记录——它们受到严格的法律法规约束。
假设,例如,需要预处理一个超过一太字节大小的数据源,以隐藏用户的身份和位置信息?
协作
人们可能会认为,在这个时代,分析和预测模型完全是计算性的(尤其是当你听到机器学习这个术语时),然而,无论预测算法或模型声称多么先进,数据中仍然存在许多人类可以轻易察觉但计算机算法在逻辑上难以找到的模式。
注意
在分析领域出现了一种新的趋势,这可能被视为视觉分析的一个子领域,它利用了领域专家的知识,至少在预测项目的建模和分析阶段是这样。
在预测分析项目中包含一个领域专家可能不是一个大问题,但面对大数据源时,通常需要来自不同领域的多位专家才能真正理解数据的情况,并分享他们各自的结果探索和建议。
这些多位专家可能在空间和时间上分散,难以在某一时间聚集在同一个地点。这再次导致需要花费额外的时间和精力。
可重复性
信不信由你,大多数预测分析项目因各种原因而重复进行。例如,如果结果因任何原因受到质疑或数据存在疑点,项目的所有阶段都可能需要重复。大数据分析项目的重复很少是合理的。在大多数情况下,所能做的就是找到大数据资源中的不良数据并将其标记为不良数据。
前进的道路
因此,拥有足够多的数据来训练模型的想法似乎非常吸引人。
大数据源似乎能够满足这一需求,然而在实践中,大数据源很少(如果有的话)被完全分析。你可以相当肯定地执行一个广泛的过滤过程,旨在将大数据减少到小(一些)数据(更多内容将在下一节中介绍)。
在下一节中,我们将回顾各种方法,以解决将大数据作为预测分析项目数据源所面临的挑战。
机会
在本节中,我们提供了一些关于在预测分析项目中使用 R 处理大数据源的推荐方法。此外,我们还将提供一些实际用例示例。
更大的数据,更大的硬件
我们首先从最明显的选项开始。
为了明确,R 将所有对象都保存在内存中,如果数据源太大,这会成为一个限制。在 R 中处理大数据的最简单方法之一就是增加机器的内存。
在撰写本文时,如果 R 在 64 位机器上运行,它可以使用 8 TB 的 RAM(相比之下,32 位机器上只有 2 GB 可寻址 RAM)。用于预测分析项目的大多数机器(至少应该是)已经是 64 位,所以你只需要添加 RAM。
注意
R 有 32 位和 64 位版本。请自己方便,使用 64 位版本!
如果你对你的数据源非常了解,并且已经为你的机器添加了适当的内存,那么你很可能能够有效地处理大数据源,特别是如果你使用本章以下部分概述的方法之一。
分割
使用 R(或任何语言)驯服大数据源的最直接和最有效的方法之一是从大数据资源中创建可工作的数据子集。
例如,假设我们有一个由患者健康记录组成的当前大数据源。数据中实际上有数万亿的患者病例记录,几乎每分钟都在增加。这些病例记录了基本信息(性别、年龄、身高、体重等)以及患者背景的详细信息(例如,患者是否吸烟、饮酒、目前正在服用药物、是否曾经接受过手术等)。幸运的是,我们的文件不包含任何可以用来识别患者的个人信息(如姓名或社会保障号码),所以我们不会违反任何法律。
数据源由全国各地的医院和诊所提供。我们的预测项目旨在确定患者健康状况与他们居住状态之间的关系。我们不是试图在所有数据上训练(这通常是一项不切实际的尝试),而是可以使用一些逻辑来准备一系列更小、更易于处理的数据子集。例如,我们可以简单地将整体数据源分成 50 个更小的文件——每个州一个。这将有所帮助,但较小的文件可能仍然很大,所以通过对数据进行一点分析,我们可能能够识别出我们可以用来划分数据的其他度量。
数据发现和分离的过程可能看起来非常接近以下步骤:
-
由于我们正在处理大数据源,并且不确定文件中的案例或记录数,我们可以从创建一个 R 数据对象开始,并限制要读取的记录数:
x<-read.table(file="HCSurvey20170202.txt", sep=",", nrows=150) -
x现在包含 150 条记录,我们可以从中查找可能用于逻辑分割数据的有趣度量。您还可以使用 summary 函数评估数据源中的变量。例如,我们看到第 9 列是患者的家庭州,第 5 列是患者的当前体重,而第 79 列表示患者一年前的体重:![分割]()
-
现在,我们或许可以创建一系列较小的子集,其中包含 50 个州文件,但每个文件只包含在过去一年中体重增加超过五磅的患者案例:
![分割]()
我们最终确实得到了 50 个文件,但每个文件应该比单个大型大数据源小得多,更容易处理。这只是一个简单的例子,在实践中,你可能(并且很可能)需要重新运行分割代码,并将多个州文件拼接在一起。
这就是大数据研究通常工作方式的一个例子——通过构建可以高效分析的较小数据集!
采样
另一种处理大数据源体积的方法是使用总体抽样。
采样是从统计总体中选择或选择子集的案例,目的是估计或代表整个群体的特征。简而言之,要训练的数据量减少了。
有一些担忧认为采样可能会降低模型的性能(不是指处理时间,而是指生成的结果的准确性)。这可能是部分正确的,因为通常模型训练的数据越多,结果越好,但根据目标的不同,性能的下降可能是可以忽略不计的。
总的来说,可以说如果可以避免采样,使用另一种大数据策略是可取的。但如果发现采样是必要的,它仍然可以导致令人满意的模型。
当你使用采样作为大数据预测策略时,你应该尽量使样本尽可能大,仔细考虑样本大小与整个总体之间的比例,并尽可能确保样本没有偏差。
创建样本的最简单方法之一是使用 R 函数 sample。Sample 从x的元素中抽取指定大小的样本,可以使用或不使用替换。
以下 R 代码行是一个从原始数据中创建 500 个随机样本的简单示例。注意行数(通过使用 R 函数nrow表示):

聚合
另一种减少大数据源大小(再次取决于你的项目目标)的方法是通过数据的统计聚合。换句话说,你可能根本不需要数据中可用的粒度级别。
在统计数据聚合中,可以从多个测量值中组合数据。这意味着观察组的组合被基于这些观察值的汇总统计所取代。聚合在描述性分析中应用广泛,但也可以用于为预测项目准备数据。
对于更大且特别是分布不均的大数据源,可以使用 Hadoop 和 Hive(或类似技术)解决方案来聚合数据。如果数据在事务型数据库中,甚至可能使用原生 SQL。在纯 R 解决方案中,你需要做更多的工作。
R 提供了一个名为 aggregate 的方便函数,可用于大数据聚合,一旦你确定如何在你的项目中使用(或需要)数据。
例如,以下代码展示了将函数应用于原始数据(存储在名为 x 的数据对象中)并按 3 个变量(患者 sex)进行聚合:
aggregate(x, by=x["sex"], FUN=mean, na.rm=TRUE)
回到前面的部分,关于将数据拆分成 50 个州文件的例子,我们可能可以使用以下 R 代码来聚合并按州生成汇总统计。注意,原始案例数为 5,994,在聚合数据后,我们有 50 个案例(每个州一个汇总记录):

维度降低
在 第八章,维度降低中,我们介绍了维度降低的过程,这个过程(正如我们当时所指出的)允许数据科学家最小化数据的维度,但也可以减少大数据源的整体体积,从而减少处理数据所需的时间和内存,使其更容易可视化,并消除与模型目的无关的特征,减少模型噪声等。
就像将数据拆分成更小的、更易于管理的文件一样,使用维度降低会有所帮助,但这也需要良好的数据理解以及可能的大量处理步骤,最终产生一个可工作的数据集。
替代方案
由于 R 是内存语言,它有时被认为无法处理大数据。然而,通过一些创造性和战略性的思考,你可以在预测分析项目中相当成功地使用大数据。
除了上述方法之外,目前还有许多其他替代方法你可能希望研究,例如:
分块处理
有一些包可以避免在内存中存储数据。相反,对象存储在硬盘上,并以块的形式进行分析。作为副作用,如果算法允许对块进行并行分析,分块也会自然地导致并行化。你可以搜索:Revolution R Enterprise 了解该主题的一些背景信息。
替代语言集成
在 R 中集成性能更高的编程语言正成为处理大数据源的流行替代方案。这个概念将 R 代码的部分内容移动到另一种可能更适合执行逻辑或工作的语言中。这样做既融合了 R 的优点,又避免了性能瓶颈。
将 R 中的代码块外包给另一种语言可以很容易地隐藏在函数中。在这种情况下,开发者必须精通其他编程语言,但用户不需要。
摘要
在本章中,我们将典型的预测分析项目分解为阶段,并解释了第一阶段是定义要使用哪些数据的地方。
通常情况下,数据越多,预测模型的性能(或结果)就越好,但在某个时候(例如在大数据源的情况下),数据可能太多,至少难以有效处理。
在回顾了大数据之所以如此具有挑战性的原因之后,我们指导了如何评估你的数据源,将其认定为大数据源,并提供了各种经过验证的技术来解决使用大数据的常见挑战。
第十四章:深度学习
本章的目的是探讨非常重要的主题深度学习,以及近年来它如何以及为什么在统计学领域变得越来越重要。
我们将首先简要解释一下什么是机器学习,然后讨论一下深度学习是什么,它与机器学习的比较,以及它为什么几乎每天都在不断增长的重要性。为了阐明这些概念,我们将然后展示两个标志性的示例用例:词嵌入,其中涉及一些关于自然语言处理或 NLP 应用逻辑的讨论,以及循环神经网络(RNNs),这是一种有趣且更高级、更高效的类型的人工神经网络。
机器学习或深度学习
在机器学习中,选择并使用算法来分析数据和数据源,而不是对它们做出决策,而是从它们中学习,以便它们可以使用在数据中发现的模式或结果来对某个主题做出决策或预测,或者解决特定问题。
这意味着,你不需要编程或编写出用于特定任务(如做出预测)的每个规则和指令,而是通过大量数据和算法来训练计算机,使其能够真正学习如何执行任务、做出预测、解决问题或达到目标。
注意
究竟多少数据才能算作足够的数据以实现成功的机器学习?
通常“越大越好”,但在实践中,你必须根据你的目的或需求收集足够的数据。如果数量不足,明智的数据科学家应该始终关注数据的质量或适用性。
统计学领域的专家常用以下场景来举例说明机器学习是如何工作的:一个算法或模型根据一个人的身高预测其体重。在这个例子中,如果有一个相当丰富的经验(或实际的数据案例,提供了一个人的实际身高和体重),就可以建立一个模型,根据身高测量值预测一个人的体重。
显然,经验越多(或模型消耗和分析的实际数据越多),结果(或预测)就越好。
注意
数据科学家通常将模型的经验称为它在一段时间内训练过的原始数据量。
机器学习的种类或方法有很多,我们发现随着时间的推移,行业专家们根据算法或模型使用的学习类型对这些方法进行了分类。
最常见或最常见的机器学习类型通常包括以下几种:
-
监督学习
-
无监督学习
-
半监督学习
-
强化学习
-
转导等
深度学习不同;尽管机器学习按类型分组,但深度学习不是一种类型。深度学习被视为一种实现机器学习的方法或方式。
下一个部分将更深入地探讨这个概念。
深度学习是什么?
深度学习(在行业内也被称为深度结构学习或分层学习等名称)实际上是机器学习方法更广泛家族或分支的一部分,如前所述。这些方法基于学习所谓的表示(即,模型从数据中发现执行所需任务或满足目标所需的表示、模式或规则),而不是特定任务的算法(即,详细写出的或预先定义的规则,描述如何执行特定任务)。
注意
表示或特征表示对所有类型的机器学习都是至关重要的。特征表示可以通过学习或由模型在分析数据时手动或自动定义。
手动指令的替代方案
作为手动创建规则、指令或方程式的替代方案,这些方程式被认为是解决问题的关键,然后组织数据通过它们运行,深度学习的过程只是设定关于要解决的问题的基本参数,然后训练计算机通过识别数据中的模式来自主学习。
这是通过使用多层处理来实现的。例如,第一层可能通过找到简单或基本的模式来建立最基本的特征或特征。然后,下一层将接收这些已识别的信息,然后努力提取下一层次的信息,并将其传递给另一层,依此类推,直到最终层可以确定结果或做出预测。
这个过程通常通过决策树或决策流程图的树状流程来展示。这种图形表示可以直观地显示决策及其可能的后果,包括随机事件的结果等。
如果我们再次利用之前提到的身高和体重示例,使用机器学习,就必须根据个体是男性还是女性、他们的年龄和种族,以及可能还有他们的 BMI 或体质指数来定义特征、指令或规则。简而言之,你需要概述用于满足目标(猜测正确的体重)的物理属性,然后让系统使用更重要的特征来确定一个主体的疑似体重。
因此,深度学习会自动发现或找出用于预测的重要特征。这个发现过程可以描述为以下列出的步骤(再次强调,如果我们使用身高和体重用例示例):
-
首先,该过程试图确定哪些物理属性与确定体重最相关
-
接下来,它构建一个类似于我们之前提到的决策流程图的层次结构,它可以利用这个层次结构来确定主体的体重(例如,主体是男性还是女性,或者是否在某个身高范围内,等等)
-
在对这些组合进行连续的层次识别(或分类)之后,它随后决定哪些特征负责预测答案(即,主体的体重)
总结来说,虽然经典机器学习需要从数据中提取和建立规则或特征,然后对数据进行预处理或组织(这些步骤通常是 85%到 90%的人工努力),之后模型才能用于做出预测,而深度学习则使用深度学习算法进行自己的特征学习,然后能够做出预测。
在撰写本文时,深度学习通常被认为是四种基本架构之一。
这些包括:
-
无监督预训练
-
卷积神经网络
-
递归神经网络
-
递归神经网络
这些深度学习架构已经成功应用于各个领域,并产生了与(在某些情况下优于)适当技能的人类主题专家(SMEs)相当的结果:
-
计算机视觉
-
语音识别
-
自然语言处理
-
音频识别
-
社交网络过滤
-
机器翻译
-
生物信息学
日益重要的
现在,深度学习已被确立为实际机器学习用例的关键工具。由于计算机的日益强大,使用深度学习技术从不断增长的数据源(甚至大数据)中学习,我们预计可以比以往任何时候都更快、更准确地处理和预测。
注
大数据是一个用于描述数据量如此之大或如此复杂,以至于传统的算法和系统软件不足以处理它的术语。
此外,深度学习的概念在媒体中被多次描述为不仅仅是一种机器学习的方法或实践(如我们在本章前面提到的),而是一种革命性的学习态度,它使用认知技能,如分析、产生、解决问题以及进行元认知思考的能力,以构建长期理解。
注
认知技能通常指的是从审查数据(也称为经验或信息)中发展意义和/或特定知识的能力。
深度学习技术的应用促进了我们对生活知识的理解和应用,其效果比其他学习形式更为先进、有效和迅速,因此它是一个具有极高潜力的领域,有可能深刻影响我们所知的世界。
更深的数据?
几乎每个人、每个地方都听说过“大数据”这个术语。尽管可能仍然有一些关于这个术语实际含义的争论或分歧,但底线是,今天可用的数据比昨天多得多(而且明天还会更多!)。
这意味着这些数据可用于构建具有许多更深层的神经网络,提供更准确(或者至少更有趣)的结果。
物联网深度学习
此外,还有令人兴奋的新兴领域,那就是物联网(IoT)。IoT 这个缩写描述了设备、车辆、建筑以及许多其他物品如何相互交流或通信。如今几乎所有设备以及未来都将具备成为智能设备或连接设备的能力,捕捉它们的使用和周围环境及条件的信息,然后连接并共享它们收集的信息和事件。
机器和深度学习模型和算法将在物联网分析中发挥重要作用。物联网设备的数据稀疏且/或具有时间元素,深度学习算法可以用这些信息进行训练,以产生重大见解。
分布式云计算和图形处理单元领域的许多、许多最近进展使得难以置信的计算能力可用于使用,这反过来又提高了深度学习应用的最大积极效果能力。
用例
今天已经存在许多实际应用案例,可以应用深度学习算法,包括(仅举几个例子):
-
欺诈检测
-
图像识别
-
语音识别
-
自然语言处理
现在越来越主流,日益增长的预测分析和预测分析学领域正在使用深度学习在金融、会计、政府、安全、硬件制造、搜索引擎、电子商务和医学等领域。
对于深度学习来说,一个较新、非常激动人心且可能越来越重要的用例是与运动检测用于情况评估、安全和防御。
词嵌入
自然语言处理(NLP)是计算机科学(或更具体地说,计算语言学)的一个领域,专注于计算机与人类语言之间的交互。
在自然语言应用中,试图处理极端大量的真实世界文本,正式称为自然语言语料库数据源。
注意
语料库相当于单词样本。在这个背景下,自然语言语料库数据源将是一个包含实际单词和短语文本的数据库或文件,这些文本是预期的语言。
语音识别是自然语言处理(NLP)最知名且可能最发达的应用之一,即便如此,挑战仍然很多,通常包括:
-
自然语言理解
-
自然语言生成
-
连接语言和机器感知
-
对话系统
-
所有这些的组合
词嵌入是语言建模和特征学习技术中非常流行的方法,这些方法被广泛应用于许多自然语言处理应用中。
这是一种使用词汇表中的词或短语并将它们映射到实数向量中的做法。简单来说,词嵌入是将文本转换为数字的过程,这种文本到数字的转换是必需的,因为大多数深度学习算法都需要它们的输入是连续数值的向量(它们不能处理纯文本字符串),而且,嗯,计算机处理数字的能力出奇地好。
因此,根据前面的定义,词嵌入被用来将词汇表中的词或短语映射到相应的实数向量,这个向量还提供了以下好处:
-
降维:将短语简化为数字显然是一种更有效的表示
-
上下文相似性:数值可以是一种更具有表现力的表示
“上下文词相似性不过是识别词之间不同类型的相似性。它是自然语言处理的一个目标。统计方法用于计算词之间相似度的程度。”
– Robin,2012 年 12 月 10 日
词预测
为了使统计语言模型能够预测某些文本的意义,它需要意识到词的上下文相似性。
例如,你可能会同意,你期望在句子中找到像martini或cosmopolitan这样的词,这些词在句子中是dry、shaken、stirred和chilled的,但你不会期望在这些词附近找到像automobile这样的概念。
注意
另一种词预测的形式是自动完成或词补全。这是当算法可以预测用户输入的词的其余部分时发生的情况。
词向量
通过应用词嵌入的逻辑和推理产生的词向量(实际上它们是数值向量)揭示了这些相似性,因此,在文本中经常相邻出现的词,在向量空间中也会彼此靠近。
理解这些词或数值向量是如何工作的非常重要,所以让我们简要(并且希望简单)地解释一下这个概念。
如果一个词向量被分成几百个元素,词汇表中的每个词都通过这些元素(在该向量中)的权重分布来表示。因此,在向量中的一个元素和词之间不再是简单的映射,该词的表示分布在向量的所有元素上,向量的每个元素都贡献于许多词的定义。这样的向量以某种抽象的方式代表了词的意义。
注意
在线可以找到一篇易于理解的教程和一些关于词语或数值向量的精美插图:blog.acolyer.org/2016/04/21/the-amazing-power-of-word-vectors/。
因此,再次回答一下什么是词嵌入的问题?
"…词嵌入是一种从文本语料库中创建低维向量表示的方法,它保留了词语的上下文相似性…"
上下文相似性的数值表示
实现词向量的一个额外好处是它们可以进行算术操作(就像任何其他数值向量一样)。由于词汇表中的词语被转换成数值向量,并且这些向量的位置存在语义关系,因此可以在这些向量上使用或应用简单的算术来找到额外的含义和洞察。
有许多例子可以说明这个概念,包括通过在嵌入空间中从“Man”(男人)移动到“Queen”(女王)来减去“King”(国王)并加上“Woman”(女人)。
备注
在该领域,对词语或数值向量进行的算术操作被称为向量数学。
通过利用这种技术,词语的分组不仅仅是接近的变体或同义词,而是构成上下文集合的独特词语或仅仅是属于一起的词语。
Netflix 学习
我最喜欢的机器学习用例之一是 Netflix(一个专注于并提供流媒体和视频点播的网站)。
Netflix 服务(可流媒体播放的电影和视频)的典型视图提供了超过 40 行的可能选择。就像任何其他业务一样,消费者在浏览约两分钟的视频选择后就会失去兴趣,因此 Netflix 几乎没有时间吸引客户的注意力。
Netflix 并非依赖于客户评分和调查,而是利用一个非常广泛的数据资产集:每个会员观看的内容、观看时间、客户在 Netflix 屏幕上找到视频的位置、客户未选择的推荐以及目录中视频的流行度。
"所有这些数据都是由众多算法读取的,这些算法由机器学习技术驱动。方法使用监督(分类、回归)和无监督(通过聚类或压缩进行维度降低)方法…",
- C. Raphel.
备注
提到的报告可在以下网址在线获取:www.rtinsights.com/netflix-recommendations-machine-learning-algorithms。
视频到视频相似性算法,或称 Sims,在“因为你观看了”这一行提供推荐
- C. Raphel.
可能有人会认为选择仅基于类型,但上下文相似性的概念在挖掘符合消费者或观众心态的选择中肯定起到了作用。适合一起使用的词语可以激发出观众可能喜欢的电影想法。操纵词向量可以产生几乎无穷无尽的想法。
如下一段所述,Netflix 算法的结果实际上在做出推荐方面比直觉认为的成功率更高:
“…例如,作者描述了与《纸牌屋》类似的电视剧推荐。虽然有人可能会认为像《西翼》或《广告狂人》这样的政治或商业剧会增加客户参与度,但结果却表明,像《公园与游憩》和《橙子不是新的黑色》这样的流行但非类型作品表现更好。作者称这为‘直觉失败’的案例...”
– C. Raphel
实现
那么,我们如何在典型的词嵌入应用中实现词或数字向量呢?
可用于生成词嵌入模型的最受欢迎的算法之一是word2vec,由谷歌在 2013 年创建。Word2vec 是用 C++编写的,但也实现了 Java/Scala 和 Python,接受文本语料库(或者非正式地说,期望输入是一个句子序列,每个句子是一个单词列表)作为输入,并产生词向量作为输出。
关于 word2vec 输入的另一个备注,它只要求您提供的数据以顺序句子形式提供,您不必担心一次性将所有内容存储在内存中以便处理。这意味着您可以:
-
提供一句话
-
处理它
-
加载另一句话
-
处理它
-
重复...
这意味着大量数据,例如那些符合大数据标准的数据源(在第十一章中讨论),本书的主题建模,可能由分布在多个位置的多份文件中的数据组成,可以通过每行一句(而不是将所有内容一次性加载到内存列表中,逐个文件,逐行读取)进行处理。这种架构还允许进行预处理,例如转换为 Unicode、转换为小写、去除数字、提取命名实体等,而无需 word2vec 意识到这些操作。
备注
Word2vec 对于非常小的数据来说不是一个好的选择。为了得到真实的结果,如通过试验报告,您应该至少有一百万个单词。小数据文件或来源不足以创建简洁的词相似度或适当的词向量。
Word2vec 还设置了一些参数,例如min_count。
此参数对于设置数据中出现单词的下限非常有效。例如,在百万词数据源中只出现几次的任何单词可能都是打字错误或垃圾,应该在创建词向量时忽略。此参数允许您自动删除不感兴趣或不重要的单词。默认设置为5。
Word2vec 首先从提供的文本数据中构建词汇表,然后学习单词的向量表示。生成的词向量文件可以用作许多自然语言处理和机器学习应用中的特征。
下图是 word2vec 创建的部分词向量图像:

注意
尽管 word2vec 是一个强大的工具,即使是谷歌也宣称它不够用户友好,并且随着时间的推移已经开发了各种开源软件包,为该算法添加了用户友好的界面。您可以在网上访问 word2vec,网址为code.google.com/p/word2vec。
就像统计学中的许多实现一样,关于 word2vec 究竟是什么或者其逻辑最终是如何实现的,存在一些分歧。它是经典机器学习模型的例子吗?是实施深度学习的例子吗?或者,我们可以说它是一种某种混合模型?
一点在线研究揭示了众多观点,例如,A.Thakker,2017 年 6 月 18 日:
“……Word2Vec 被认为(在行业内的一些人看来)是“自然语言处理中的深度学习”的起点。然而,Word2Vec 并不深。但 Word2Vec 的输出是深度学习模型可以轻松理解的。Word2Vec 基本上是一个从原始文本中学习词嵌入的计算效率高的预测模型。Word2Vec 的目的是在向量空间中将语义相似的单词分组在一起。它通过数学计算相似度。给定大量数据……”
让我们回顾一下深度学习的架构,从下一节开始。
深度学习架构
我们在本章的“深度学习”部分之前已经指出,目前(至少在写作的时候)有四种基本的深度学习架构。现在我们将简要地看看三种(无监督预训练、卷积神经网络和递归神经网络),然后深入探讨其中最刺激和最有效的(至少对于适当的用例)循环神经网络:
-
无监督预训练神经网络:想象在模型训练真正开始之前通过调整权重来“洗牌”。
-
卷积神经网络:一种前馈模型,使用多层感知器(或单个学习单元)的变体,旨在需要最少的预处理,用于视觉图像处理和自然语言处理。
-
递归神经网络:这些网络通过在结构上递归地应用同一组权重来创建,试图产生一种结构化预测(即,预测结构化对象的能力,而不是离散或实值)。
人工神经网络
人工神经网络(ANNs)系统是受我们人类大脑中生物神经网络工作方式启发的计算系统、算法或模型。
这些系统通过考虑数据中发现的模式(称为获得经验)来学习执行工作和解决问题,通常无需编程特定的逻辑提示。
ANNs 是深度学习的一个重要部分。
注意
大多数人工神经网络与它们更复杂的生物对应物只有轻微的相似之处,但在分类或分割等预期任务上非常有效。更多信息,请参阅:en.wikipedia.org/wiki/Types_of_artificial_neural_networks。
在第五章中,我们详细介绍了神经网络,特别是人工神经网络(ANNs)。在本章的下一节中,我们将继续这一主题,并转向循环神经网络(RNNs)这一主题。
循环神经网络
一般而言,行业内普遍认为实际上只有两种主要的神经网络类型。
这些是:
-
前馈
-
循环
前馈神经网络是首先开发的第一种也是最简单的一种类型。
在前馈网络中,激活从输入层推向输出层。在这个网络中,信息仅从输入层直接通过任何隐藏层到输出层,没有循环或循环。
换句话说,前馈神经网络是一条单行道。
注意
大多数神经网络类型都是按层组织的。层由相互连接的节点组成,这些节点包含所谓的激活函数。模式由输入层呈现给网络,然后与一个或多个隐藏层进行通信。隐藏层是真正工作的地方,使用加权连接系统。
让我们继续我们的对话,声明一个循环神经网络(或 RNN)是人工神经网络(ANN)中一个有趣且独特的类别。
使用 RNN 逻辑的目标是利用顺序或时间序列数据。这与传统神经网络所使用的逻辑大不相同,传统神经网络假设所有输入和输出都是相互独立的,或者彼此之间没有关联。这种假设(或限制)对于某些应用来说是有效的,或者至少是足够的,但对于许多任务来说,这不是一个可接受的假设。例如,如果你试图预测某人正在搜索引擎中输入的下一个单词,你需要知道之前输入了哪些单词。
RNNs 被称为循环,因为它们对序列中的每个元素执行相同的任务,输出依赖于所有之前的计算。
另一种思考 RNNs 的方式是,它们可以记住序列中到目前为止已计算的信息。这使得它能够表现出动态时间(或相关)行为。
记住,RNNs 使用一个称为状态层的特殊层,该层不仅更新网络的外部输入信息,还更新来自先前前向传播的激活信息。
有一个有趣的博客,提供了关于 RNNs 工作原理的宝贵见解。以下图表基于这些信息。读者可以在以下链接查看信息:shapeofdata.wordpress.com/2015/10/20/recurrent-neural-networks。

为了展示这一点是多么有价值的一个特性,例如,单词aliens如果它是序列ancient aliens的一部分,可能会有不同的含义。
注意
理论上,RNNs 可以使用任意长序列中的信息,但在实践中,它们只能回溯查看几个步骤。
正如我们在本节前面所述,与逻辑层之间没有形成循环(技术上称为前馈神经网络)的人工神经网络不同,RNNs 可以使用它们的内部记忆来处理任意输入序列。这使得它们非常适合于手写识别或语音识别等应用。
摘要
在本章中,我们讨论了机器学习和深度学习以及两者之间的区别。我们还提到了深度学习如何有能力推动世界的变化。
我们看到了深度学习如何减少人类所需的努力,并列出了一些这些算法已经成功应用的应用案例。然后我们探讨了在 NLP 应用等用例中使用词嵌入,并解释了它是如何工作的。
最后,我们讨论了神经网络,特别是 RNNs。
通过本章,我们结束了这次旅程,提供了关于性能指标和学习曲线、多项式回归、泊松回归和负二项式回归、反向传播、径向基函数网络等方面的深入信息。我们还讨论了处理非常大的数据集的过程。
希望您已经享受了探索和测试这些流行建模技术的过程,并掌握了多种预测分析风格。






,从而增加其在下一次迭代中的权重。正确分类的观察值其权重乘以
,从而减少其在下一次迭代中的权重。


浙公网安备 33010602011771号