哈佛-CS109-数据科学导论第二版-全-

哈佛 CS109 数据科学导论第二版(全)

原文:geostatsguy.github.io/MachineLearningDemos_Book/intro.html

译者:飞龙

协议:CC BY-NC-SA 4.0

数据科学导论

原文:rafalab.dfci.harvard.edu/dsbook-part-1/

使用 R 进行数据整理和可视化

前言

数据科学导论

这是《数据科学导论》《使用 R 进行数据整理和可视化》部分的网站。

《通过案例研究学习统计和预测算法》**部分的网站在此¹。

我们在 Twitter 上发布与本书相关的公告。如需更新,请关注@rafalab²。

本书最初是哈佛 X 数据科学系列³中使用的课程笔记。

用于生成本书的 Quarto 文件可在GitHub⁴上找到。注意,本书中用于图表的图形主题可以使用dslabs包中的ds_theme_set()函数重新创建。

本作品受 Creative Commons Attribution-NonCommercial-ShareAlike 4.0 国际CC BY-NC-SA 4.0⁵许可。

本书的有售纸质版可在CRC Press⁶处获得。

可在Leanpub⁷获得 2019 年 10 月 24 日版本本书的免费 PDF。

致谢

本书献给所有参与构建和维护 R 及其在本书中使用的 R 包的人们。特别感谢基础 R、tidyverse、data.table 和 caret 包的开发者和维护者。

感谢 Jenna Landy 对本书的仔细编辑和有益的建议;感谢 David Robinson 慷慨回答关于 tidyverse 的许多问题,并帮助我理解它;感谢 Amy Gill 提供的数十条评论、编辑和建议。还要感谢 Stephanie Hicks,她两次担任我的数据科学课程合教,以及耐心回答我关于 markdown 的许多问题的 Yihui Xie。感谢 Karl Broman,我从他那里借鉴了数据可视化和生产力工具部分的想法。感谢 Peter Aldhous,我从他那里借鉴了数据可视化原则部分的想法,以及撰写了Happy Git and GitHub for the useR的 Jenny Bryan,这对我们的 Git 章节产生了影响。还要感谢 Jeff Leek、Roger Peng 和 Brian Caffo,他们的在线课程启发了本书的章节划分方式,感谢 Garrett Grolemund 和 Hadley Wickham,他们使他们的 R for Data Science 书的 markdown 代码开源,感谢编辑 John Kimmel 和 Lara Spieker 的支持。最后,感谢 Alex Nones 在本书各个阶段审阅手稿。

本书是在教授多个应用统计学课程的过程中构思的,始于十五年前。多年来与我合作的助教对该书做出了重要的间接贡献。材料在由 Heather Sternshein 和 Zofia Gajdos 协调的 HarvardX 系列课程中得到进一步精炼。我们感谢他们的贡献。我们也感谢所有提出问题和评论的学生,他们的帮助使我们改进了本书。这些课程部分由 NIH 资助的 R25GM114818 号资助。我们非常感谢美国国立卫生研究院的支持。

特别感谢所有通过 GitHub 拉取请求编辑书籍或通过创建问题或发送电子邮件提出建议的人:nickyfoto(黄强),desautm(马克-安德烈·德索特尔斯),michaschwab(米哈伊尔·施瓦布),alvarolarreategui(阿尔瓦罗·拉雷特圭),jakevc(杰克·范坎彭),omerta(吉列尔莫·伦格曼),espinielli(恩里科·斯皮内利),asimumba(阿隆·西姆巴),braunschweig(马尔代沃),gwierzchowski(格热戈 orz·维赫罗夫斯基),technocrat(理查德·卡雷加),atzakasdefeit(大卫·艾默生·费特),shiraamitchell(希拉·米切尔),Nathalie-Sandreashandel(安德烈亚斯·汉德尔),berkowitze(伊利亚斯·伯科维茨),Dean-Webb(迪恩·韦伯),mohayusufjimrothsteinmPloenzke(马修·普洛恩茨克),NicholasDowand(尼古拉斯·道),kant(达里奥·赫雷努),debbieyuster(黛比·尤斯特),tuanchauict(段超),phzellerBTJ01(布拉德·J),glsnow(格雷格·斯诺),mberlanda(马乌罗·贝尔兰达),wfan9larswestvang(拉斯·韦斯特万格),jj999(扬·安德烈·约德科维奇),Kriegslustig(卢卡·尼尔斯·施密德),odahhaniaidanhorn(艾丹·霍恩),atraxler(阿德里安·特拉克斯勒),alvegorovawycheong( Won Young Cheong),med-hat(梅哈特·哈利勒),kengustafsonYowza63ryan-heslin(瑞安·赫斯林),raffaemtim8westjooleerpauluhn(保罗),tci1beanb2(布伦南·比恩),edasdemirlab(厄迪·达斯德米尔),jimnicholls(吉姆·尼科尔),JimKay1941(吉姆·凯),devpowerplatform(QuantScripter),carvalhais(安德烈·卡瓦利亚斯),hbmacleanGENHENwhatchalookingat,David D. Kane,El Mustapha El Abbassi,Vadim Zipunnikov,Anna Quaglieri,Chris Dong,Bowen Gu,Rick Schoenberg,以及 Robert Gentleman。


  1. rafalab.dfci.harvard.edu/dsbook-part-2/↩︎

  2. twitter.com/rafalab↩︎

  3. www.edx.org/professional-certificate/harvardx-data-science↩︎

  4. github.com/rafalab/dsbook-part-1↩︎

  5. creativecommons.org/licenses/by-nc-sa/4.0↩︎

  6. 《数据科学导论:使用 R 进行数据整理和可视化》↩︎

  7. 《数据科学书籍》(Data Science Book)↩︎

引言

原文:rafalab.dfci.harvard.edu/dsbook-part-1/intro.html

在工业、学术界和政府中,对熟练的数据科学实践者的需求正在迅速增长。本书介绍了可以帮助你应对现实世界数据分析挑战的技能。这些包括 R 编程、使用 dplyr 进行数据整理、使用 ggplot2 进行数据可视化、使用 UNIX/Linux shell 进行文件组织、使用 Git 和 GitHub 进行版本控制,以及使用 Quarto 和 knitr 进行可重复文档准备。本书分为四个部分:R数据可视化数据整理生产力工具。每个部分都有几个章节,旨在作为一次讲座进行展示,并包括分布在各章节中的数十个练习。

案例研究

在整本书中,我们使用有启发性的案例研究。在每个案例研究中,我们试图真实地模拟数据科学家的经验。对于每个涵盖的技能,我们首先提出具体问题,并通过数据分析来回答这些问题。我们通过学习概念来回答问题。本书中包含的案例研究示例包括:

案例研究 概念
各州谋杀率 R 基础
学生身高 统计摘要
世界健康和经济趋势 数据可视化
疫苗对传染病率的影响 数据可视化
报告的学生身高 数据整理

谁会找到这本书有用?

这本书旨在作为数据科学入门课程的教科书。不需要先前的 R 语言知识,尽管一些编程经验可能会有所帮助。要成为一名成功的数据分析师,实施这些技能需要理解高级统计概念,例如在高级数据科学中涵盖的概念。如果你阅读并理解了本书的所有章节,完成了所有练习,并理解了统计概念,你将能够很好地执行基本的数据分析任务,并准备好学习成为专家所需的更高级的概念和技能。

这本书涵盖了什么内容?

我们首先回顾了 R 的基础tidyversedata.table包。你将在整本书中学习 R,但在第一部分中,我们回顾了继续学习所需的构建块。

信息的数据库和软件工具的日益可用导致许多领域对数据可视化的依赖性增加。在第二部分中,我们展示了如何使用ggplot2生成图表并描述重要的数据可视化原则。

第三部分通过几个示例使读者熟悉数据整理。我们学习的具体技能包括网络抓取、使用正则表达式以及合并和重塑数据表。我们使用tidyverse工具来完成这些工作。

在最后一部分,我们简要介绍了我们在数据科学项目中日常使用的生产力工具。这些包括 RStudio、UNIX/Linux shell、Git 和 GitHub、Quarto 以及knitr

本书没有涵盖哪些内容?

本书专注于数据科学中数据分析方面所需的计算技能。正如所述,我们不涉及统计概念。我们也不涵盖与数据管理或工程相关的方面。尽管 R 编程是本书的一个重要部分,但我们不教授更高级的计算机科学主题,如数据结构、优化和算法理论。同样,我们也不涉及诸如网络服务、交互式图形、并行计算和数据流处理等主题。

R

原文:rafalab.dfci.harvard.edu/dsbook-part-1/R/intro-to-R.html

在这本书中,我们将使用 R 软件环境进行所有分析。你将同时学习 R 和数据分析技术。因此,为了跟上进度,你需要能够访问 R。我们还推荐使用一个集成开发环境(IDE),例如 RStudio,以便保存你的工作。请注意,课程或研讨会通常通过你的网络浏览器提供 R 环境和 IDE 的访问权限,就像 RStudio 云¹所做的那样。如果你可以访问这样的资源,你就不需要安装 R 和 RStudio。然而,如果你打算成为一名高级数据分析师,我们强烈建议在你的计算机上安装这些工具。R 和 RStudio 都是免费的,并且可以在网上获得。


  1. rstudio.cloud↩︎

1  开始使用

原文:rafalab.dfci.harvard.edu/dsbook-part-1/R/getting-started.html

  1. R

  2. 1  开始使用

1.1 为什么选择 R?

R 不是像 C 或 Java 这样的编程语言。它不是由软件工程师为软件开发而创建的。相反,它是统计学家开发的一个交互式数据分析环境。您可以在论文《S 简史》中阅读完整的背景¹。交互性是数据科学中不可或缺的特性,因为您很快就会了解到,快速探索数据的能力是成功在这个领域的关键。然而,与其他编程语言一样,您可以保存您的作品为脚本,这些脚本可以在任何时刻轻松执行。这些脚本记录了您所进行的分析,这是一个关键特性,有助于可重复性工作。如果您是专家程序员,您不应该期望 R 遵循您习惯的约定,因为您可能会感到失望。如果您有耐心,您将开始欣赏 R 在数据分析(尤其是数据可视化)方面的不凡能力。

R 的其他吸引人的特性包括:

  1. R 是免费和开源的²。

  2. 它在所有主要平台上运行:Windows、Mac Os、UNIX/Linux。

  3. 脚本和数据对象可以在不同平台上无缝共享。

  4. R 用户有一个庞大、不断增长和活跃的社区,因此有大量的学习资源和提问资源³ ⁴。

  5. 其他人为贡献的附加功能使得开发者能够分享新的数据科学方法论的软件实现。这使得 R 用户能够提前接触到最新的方法,以及为众多学科(包括生态学、分子生物学、社会科学和地理学等)开发的工具,仅举几个例子。

1.2 R 控制台

交互式数据分析通常在R 控制台上进行,该控制台在您输入命令时执行。有多种方式可以访问 R 控制台。一种方式是在您的计算机上简单地启动 R。控制台看起来大致如下:

作为快速示例,尝试使用控制台计算一顿价值 19.71 美元的餐点的 15%小费:

0.15 * 19.71 
#> [1] 2.96

请注意,在这本书中,灰色框用于显示在 R 控制台中输入的 R 代码。符号#>用于表示 R 控制台输出的内容。

1.3 脚本

R 相对于点选分析软件的一个巨大优势是您可以保存您的作品为脚本。您可以使用文本编辑器编辑和保存这些脚本。本书中的材料是使用交互式集成开发环境(IDE)RStudio 开发的⁵。RStudio 包括一个具有许多 R 特定功能的编辑器、一个用于执行代码的控制台以及其他有用的面板,包括一个用于显示图形的面板。

大多数基于网络的 R 控制台也提供了一个面板来编辑脚本,但并非所有都允许你保存脚本以供以后使用。

所有用于生成这本书的 R 脚本都可以在 GitHub⁶ 上找到。

1.4 RStudio

RStudio 将成为我们数据科学项目的起点。它不仅为我们提供了一个编辑器来创建和编辑我们的脚本,还提供了许多其他有用的工具。在本节中,我们将介绍一些基础知识。

1.4.1 面板

当你第一次启动 RStudio 时,你会看到三个面板。左侧面板显示 R 控制台。在右侧,顶部面板包括 环境历史 等标签页,而底部面板显示了五个标签页:文件绘图帮助查看器(这些标签页在新版本中可能会有所变化)。你可以点击每个标签来浏览不同的功能。例如,要开始一个新的脚本,你可以点击 文件,然后选择 新建文件,接着选择 R 脚本

图片

这将在左侧启动一个新的面板,你可以在那里开始编写你的脚本。

图片

1.4.2 快捷键

我们使用鼠标执行的大多数任务都可以通过组合键来达到。这些用于执行任务的键盘版本被称为 快捷键。例如,我们刚刚展示了如何使用鼠标开始一个新的脚本,但你也可以使用快捷键:Windows 上的 Ctrl+Shift+N 和 Mac 上的 command+shift+N。

虽然在这个教程中我们经常展示如何使用鼠标,但我们强烈建议你记住你使用最多的操作的快捷键。RStudio 提供了一份有用的快捷键表,列出了最常用的命令。你可以直接从 RStudio 获取它:

图片

你可能想把它放在手边,这样你就可以在你发现自己进行重复的点按操作时查找快捷键。

1.4.3 在编辑脚本时运行命令

有许多专门为编码制作的编辑器。这些编辑器很有用,因为它们会自动添加颜色和缩进,使代码更易于阅读。RStudio 就是这些编辑器之一,它是专门为 R 开发的。RStudio 相比其他编辑器提供的主要优势之一是我们可以在编辑脚本时轻松测试我们的代码。下面我们展示一个例子。

让我们先打开一个新的脚本,就像之前做的那样。下一步是为脚本命名。我们可以通过编辑器保存当前未命名的脚本来实现这一点。为此,点击保存图标或使用 Windows 上的 Ctrl+S 键或 Mac 上的 command+S 键。

当你第一次要求保存文档时,RStudio 会提示你输入名称。一个好的惯例是使用描述性的名称,使用小写字母,没有空格,只使用连字符来分隔单词,然后跟随着后缀 .R。我们将把这个脚本称为 my-first-script.R

图片

现在我们准备开始编辑我们的第一个脚本。R 脚本中的第一行代码是专门用来加载我们将要使用的库的。另一个有用的 RStudio 功能是,一旦我们输入 library(),它就会自动完成我们已安装的库。注意当我们输入 library(ti) 时会发生什么:

图片

你可能还注意到的一个功能是,当你输入 library( 时,第二个括号会自动添加。这将帮助你避免编码中最常见的错误之一:忘记关闭括号。

现在我们可以继续编写代码。作为一个例子,我们将制作一个图表,展示各州的谋杀总数与人口总数。一旦你编写了制作此图表所需的代码,你可以通过 执行 代码来尝试它。为此,点击编辑窗格右上角的 运行 按钮。你也可以使用快捷键:Windows 上的 Ctrl+Shift+Enter 或 Mac 上的 command+shift+return。

一旦你运行了代码,你将在 R 控制台中看到它,在这种情况下,生成的图表将出现在图表控制台中。请注意,图表控制台有一个有用的界面,允许你点击在不同图表之间前后导航,放大图表,或将图表保存为文件。

图片

要逐行运行代码而不是整个脚本,你可以在 Windows 上使用 Control-Enter,在 Mac 上使用 command-return。

1.4.4 更改全局选项

你可以大幅度改变 RStudio 的外观和功能。

要更改全局选项,你点击 工具 然后选择 全局选项…

作为例子,我们展示如何进行我们 强烈推荐 的更改。这是将 退出时保存工作空间到 .RData 改为 从不,并取消选中 启动时将 .RData 恢复到工作空间。默认情况下,当你退出 R 时,会将你创建的所有对象保存到一个名为 .RData 的文件中。这样做是为了当你重新在同一文件夹中启动会话时,它会加载这些对象。我们发现这会导致混淆,尤其是当我们与同事共享代码并假设他们有这个 .RData 文件时。要更改这些选项,让你的 常规 设置看起来像这样:

图片

1.5 安装 R 包

R 的新安装提供的功能只是可能功能的一小部分。实际上,我们将您第一次安装后获得的内容称为 基础 R。额外的功能来自开发者的附加组件。目前 CRAN 上有数百个这样的附加组件,还有许多通过其他存储库如 GitHub 分享。然而,由于并非所有人都需要所有可用功能,R 通过 提供不同的组件。R 使您能够非常容易地从 R 内部安装包。例如,要安装我们用于共享与本书相关的数据集和代码的 dslabs 包,您将输入:

install.packages("dslabs")

在 RStudio 中,您可以导航到 工具 选项卡并选择安装包。然后我们可以使用 library 函数将包加载到我们的 R 会话中:

library(dslabs)

随着您阅读本书,您将看到我们加载包而不安装它们。这是因为一旦您安装了一个包,它就会保持安装状态,并且只需要使用 library 加载。包将保持加载状态,直到我们退出 R 会话。如果您尝试加载一个包并遇到错误,这可能意味着您需要先安装它。

我们可以通过向此函数提供字符向量来一次性安装多个包:

install.packages(c("tidyverse", "dslabs"))

使用 RStudio 的一个优点是,一旦您开始输入,它会自动完成包名,这在您不记得包的确切拼写时很有帮助。

注意,安装 tidyverse 实际上会安装几个包。这通常发生在包有 依赖项 或使用其他包中的函数时。当您使用 library 函数加载包时,也会加载其依赖项。

一旦安装了包,您就可以将它们加载到 R 中,除非您安装了 R 的新版本,否则不需要再次安装。请记住,包是在 R 中而不是在 RStudio 中安装的。

将您工作中需要的所有包列在一个脚本中很有帮助,因为如果您需要重新安装 R,只需运行脚本即可重新安装所有包。

您可以使用以下函数查看您已安装的所有包:

installed.packages()

  1. pdfs.semanticscholar.org/9b48/46f192aa37ca122cfabb1ed1b59866d8bfda.pdf↩︎

  2. opensource.org/history↩︎

  3. stats.stackexchange.com/questions/138/free-resources-for-learning-r↩︎

  4. www.r-project.org/help.html↩︎

  5. posit.co//↩︎

  6. github.com/rafalab/dsbook-part-1↩︎

2 R 基础知识

原文:rafalab.dfci.harvard.edu/dsbook-part-1/R/R-basics.html

  1. R

  2. 2 R 基础知识

2.1 动机示例:美国枪支谋杀

假设你住在欧洲,并被一家在全美各州都有分公司的美国公司提供了一份工作。这是一份非常好的工作,但像“美国枪支谋杀率高于其他发达国家”这样的新闻标题让你感到担忧。这样的图表可能会让你更加担忧:

图片

或者甚至更糟,来自everytown.org²的这个版本:

图片

但然后你记得美国是一个庞大且多样化的国家,有 50 个非常不同的州以及哥伦比亚特区(DC)。

图片

例如,加利福尼亚的人口比加拿大多,而且有 20 个美国州的人口超过挪威。在某些方面,美国各州之间的差异类似于欧洲各国之间的差异。此外,尽管上述图表中没有包括,但立陶宛、乌克兰和俄罗斯的谋杀率都高于每 10 万人 4 起。因此,可能让你担心的新闻报道过于肤浅。你有选择居住地的权利,并希望确定每个特定州的安全性。我们将通过分析 2010 年美国枪支谋杀相关的数据来获得一些见解,我们将使用 R 语言来完成这项工作。

在我们开始我们的示例之前,我们需要了解一些后勤工作以及一些非常基础的构建块,这些构建块对于获得更高级的 R 技能是必需的。请注意,这些构建块的一些有用性可能不会立即明显,但在本书的后面部分,你会感激你已经掌握了这些技能。

2.2 基础知识

在我们开始动机数据集之前,我们需要了解 R 的非常基础知识。

2.2.1 对象

假设一个高中生向我们寻求帮助解决几个形式为 \(ax²+bx+c = 0\) 的二次方程。二次公式给出了解:

\[ \frac{-b - \sqrt{b² - 4ac}}{2a}\,\, \mbox{ 和 } \frac{-b + \sqrt{b² - 4ac}}{2a} $$ 当然,这些值取决于 $a$、$b$ 和 $c$ 的值。编程语言的一个优点是我们可以定义变量并使用这些变量编写表达式,就像我们在数学中做的那样,但获得数值解。下面我们将写出二次方程的一般代码,但如果要求我们解 $x² + x -1 = 0$,那么我们定义系数: ```r coef_a <- 1 coef_b <- 1 coef_c <- -1 ``` 它存储了用于后续使用的值。我们使用`<-`来给变量赋值。我们也可以使用`=`来赋值,但为了防止混淆,我们建议不要使用`=`。 将上面的代码复制并粘贴到您的控制台中以定义三个变量。请注意,当我们进行这种赋值时,R 不会打印任何内容。这意味着对象已成功定义。如果您犯了错误,您将收到错误消息。 要查看变量中存储的值,我们只需要求 R 评估`coef_a`,它就会显示存储的值: ```r coef_a #> [1] 1 ``` 要求 R 显示`coef_a`中存储的值的更明确的方法是使用`print`,如下所示: ```r print(coef_a) #> [1] 1 ``` 我们使用术语*对象*来描述存储在 R 中的东西。变量是例子,但对象也可以是更复杂的实体,例如函数,这些将在后面进行描述。 ### 2.2.2 工作空间 当我们定义控制台中的对象时,我们实际上是在改变*工作空间*。您可以通过输入以下内容来查看您工作空间中保存的所有变量: ```r ls() ``` 在 RStudio 中,*环境*标签显示了值: ![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/hvd-cs109-intd-ds-2e/img/e1278b0fe7bd1c2a986275b0c0b73c2e.png) 我们应该看到`coef_a`、`coef_b`和`coef_c`。如果您尝试恢复不在您工作空间中的变量的值,您将收到错误。例如,如果您输入`x`,您将收到以下消息:`错误:找不到对象'x'`。 现在这些值已保存在变量中,为了获得方程的解,我们使用二次公式: ```r (-coef_b + sqrt(coef_b² - 4*coef_a*coef_c))/(2*coef_a) #> [1] 0.618 (-coef_b - sqrt(coef_b² - 4*coef_a*coef_c))/(2*coef_a) #> [1] -1.62 ``` ### 2.2.3 预构建函数 一旦定义了变量,数据分析过程通常可以描述为一系列应用于数据的*函数*。R 包括几个预定义的函数,我们构建的大多数分析流程都广泛使用了这些函数。 我们已经使用或讨论了`install.packages`、`library`和`ls`函数。我们还使用了`sqrt`函数来解决上面的二次方程。有许多预构建的函数,甚至可以通过包添加更多。这些函数不会出现在工作空间中,因为您没有定义它们,但它们可用于立即使用。 通常,我们需要使用括号来评估一个函数。如果您输入`ls`,函数不会被评估,而是 R 显示定义函数的代码。如果您输入`ls()`,函数将被评估,如上所示,我们看到工作空间中的对象。 与`ls`不同,大多数函数需要一个或多个*参数*。以下是如何将对象分配给`log`函数参数的示例。记住,我们之前已经定义了`coef_a`为 1: ```r log(8) #> [1] 2.08 log(coef_a) #> [1] 0 ``` 要了解函数期望什么以及它做什么,可以通过查看 R 中包含的非常有用的手册来查找。您可以使用`help`函数,如下所示: ```r help("log") ``` 对于大多数函数,我们也可以使用这种简写方式: ```r ?log ``` 帮助页面将显示函数期望的参数。例如,`log` 需要 `x` 和 `base` 才能运行。然而,一些参数是必需的,而另一些是可选的。你可以通过在帮助文档中注意使用 `=` 分配默认值来确定哪些参数是可选的。定义这些是可选的。例如,函数 `log` 的基数默认为 `base = exp(1)`,这使得 `log` 默认为自然对数。 如果你想快速查看参数而不打开帮助系统,你可以输入: ```r args(log) #> function (x, base = exp(1)) #> NULL ``` 你可以通过简单地分配另一个对象来更改默认值: ```r log(8, base = 2) #> [1] 3 ``` 请注意,我们尚未指定参数 `x`: ```r log(x = 8, base = 2) #> [1] 3 ``` 上述代码是可行的,但我们完全可以节省一些输入:如果没有使用参数名称,R 会假设你正在按照帮助文件或通过 `args` 显示的顺序输入参数。因此,不使用名称,它假定参数是 `x` 后跟 `base`: ```r log(8, 2) #> [1] 3 ``` 如果使用参数的名称,那么我们可以按任何顺序包含它们: ```r log(base = 2, x = 8) #> [1] 3 ``` 要指定参数,我们必须使用 `=`, 而不能使用 `<-`。 在函数需要括号来评估的规则中存在一些例外。其中最常用的包括算术和关系运算符。例如: ```r 2³ #> [1] 8 ``` 你可以通过输入来查看算术运算符: ```r help("+") ``` 或者 ```r ?"+" ``` 并且通过输入来查看关系运算符: ```r help(">") ``` 或者 ```r ?">" ``` ### 2.2.4 预建对象 有几个数据集包含在用户练习和测试函数时使用。你可以通过输入来查看所有可用的数据集: ```r data() ``` 这显示了这些数据集的对象名称。这些数据集是可以通过简单地输入名称来使用的对象。例如,如果你输入: ```r co2 ``` R 将显示莫纳罗亚大气二氧化碳浓度数据。 其他预建对象是数学量,例如常数 $\pi$ 和 $\infty$: ```r pi #> [1] 3.14 Inf + 1 #> [1] Inf ``` ### 2.2.5 变量名 我们使用了 `coef_a`、`coef_b` 和 `coef_c` 作为变量名,但变量名可以是几乎任何东西。在 R 中编写代码时,选择既有意义又不会与现有函数或语言中的保留词冲突的变量名是很重要的。例如,我们没有使用 `a`、`b` 和 `c` 以避免与 R 中的 `c()` 函数冲突,该函数在 2.4.1 节中描述。如果你将一个变量命名为 `c`,你不会收到错误或警告,但冲突可能导致意外的行为和难以诊断的错误。 R 中的一些基本规则是变量名必须以字母开头,不能包含空格,并且不应是 R 中预定义的变量,如 `c`。 一个好的约定是使用描述存储内容的含义丰富的单词,只使用小写字母,并使用下划线作为空格的替代。对于二次方程,我们可以为两个根使用类似以下的内容: ```r r_1 <- (-coef_b + sqrt(coef_b² - 4*coef_a*coef_c))/(2*coef_a) r_2 <- (-coef_b - sqrt(coef_b² - 4*coef_a*coef_c))/(2*coef_a) ``` 为了获得更多建议,我们强烈建议学习 Hadley Wickham 的风格指南³。 ### 2.2.6 保存你的工作空间 值将保留在工作空间中,直到你结束会话或使用`rm`函数删除它们。但工作空间也可以保存以供以后使用。事实上,当你退出 R 时,程序会询问你是否想要保存工作空间。如果你保存了它,那么下次你启动 R 时,程序将恢复工作空间。 我们实际上不建议以这种方式保存工作空间,因为当你开始处理不同的项目时,跟踪保存的内容会变得更加困难。相反,我们建议你为工作空间分配一个特定的名称。你可以通过使用`save`或`save.image`函数来实现这一点。要加载,请使用`load`函数。在保存工作空间时,我们建议使用后缀`rda`或`RData`。在 RStudio 中,你也可以通过导航到*会话*选项卡并选择*另存为工作空间*来完成此操作。你可以稍后使用同一选项卡中的*加载工作空间*选项来加载它。你可以阅读有关`save`、`save.image`和`load`的帮助页面以了解更多信息。 ### 2.2.7 为什么使用脚本? 要解决另一个方程,例如 $3x² + 2x -1$,我们可以复制并粘贴上面的代码,然后重新定义变量并重新计算解: ```r coef_a <- 3 coef_b <- 2 coef_c <- -1 (-coef_b + sqrt(coef_b² - 4*coef_a*coef_c))/(2*coef_a) (-coef_b - sqrt(coef_b² - 4*coef_a*coef_c))/(2*coef_a) ``` 通过创建和保存上面的脚本,我们就不需要每次都重新输入所有内容,而只需简单地更改变量值。尝试将上面的脚本写入编辑器,并注意如何轻松地更改变量并得到答案。 ### 2.2.8 为代码添加注释 如果一行 R 代码以符号`#`开头,它是一个注释,不会被评估。我们可以使用这个来写上为什么写特定的代码的提醒。例如,在上述脚本中,我们可以添加: ```r ## Code to compute solution to quadratic equation ## Define the variables coef_a <- 3 coef_b <- 2 coef_c <- -1 ## Now compute the solution (-coef_b + sqrt(coef_b² - 4*coef_a*coef_c))/(2*coef_a) (-coef_b - sqrt(coef_b² - 4*coef_a*coef_c))/(2*coef_a) ``` 你已经准备好做练习 1-5 了。 ## 2.3 数据类型 R 中的变量可以是不同类型的。例如,我们需要区分数字和字符字符串,以及表格和简单的数字列表。`class`函数帮助我们确定我们有什么类型的对象: ```r a <- 2 class(a) #> [1] "numeric" ``` 为了在 R 中高效工作,学习不同类型的变量以及我们可以用它们做什么是非常重要的。 ### 2.3.1 数据框 到目前为止,我们定义的变量只是一个数字。这对于存储数据来说并不很有用。在 R 中存储数据集最常见的方式是使用*数据框*。从概念上讲,我们可以将数据框视为一个表格,其中行代表观测值,而每个观测值报告的不同变量定义了列。数据框对于数据集特别有用,因为我们可以将不同的数据类型组合到一个对象中。 大部分数据分析挑战都是从存储在数据框中的数据开始的。例如,我们将激励示例中的数据存储在一个数据框中。你可以通过加载提供`murders`数据集的`dslabs`库来访问这个数据集: ```r library(dslabs) ``` 为了验证这确实是一个数据框,我们输入: ```r class(murders) #> [1] "data.frame" ``` ### 2.3.2 检查对象 函数`str`对于了解对象的更多结构非常有用: ```r str(murders) #> 'data.frame': 51 obs. of 5 variables: #> $ state : chr "Alabama" "Alaska" "Arizona" "Arkansas" ... #> $ abb : chr "AL" "AK" "AZ" "AR" ... #> $ region : Factor w/ 4 levels "Northeast","South",..: 2 4 4 2 4 4 1 2 2 #> 2 ... #> $ population: num 4779736 710231 6392017 2915918 37253956 ... #> $ total : num 135 19 232 93 1257 ... ``` 这让我们对这个对象有了更多的了解。我们看到这个表格有 51 行(50 个州加上华盛顿特区)和五个变量。我们可以使用`head`函数显示前六行: ```r head(murders) #> state abb region population total #> 1 Alabama AL South 4779736 135 #> 2 Alaska AK West 710231 19 #> 3 Arizona AZ West 6392017 232 #> 4 Arkansas AR South 2915918 93 #> 5 California CA West 37253956 1257 #> 6 Colorado CO West 5029196 65 ``` 在这个数据集中,每个州都被视为一个观测值,并为每个州报告了五个变量。 在我们进一步回答关于不同州的原问题之前,让我们更多地了解这个对象的组成部分。 ### 2.3.3 访问器:`$` 为了我们的分析,我们需要访问这个数据框中包含的列所表示的不同变量。为此,我们在以下方式中使用访问器运算符`$`: ```r murders$population #> [1] 4779736 710231 6392017 2915918 37253956 5029196 3574097 #> [8] 897934 601723 19687653 9920000 1360301 1567582 12830632 #> [15] 6483802 3046355 2853118 4339367 4533372 1328361 5773552 #> [22] 6547629 9883640 5303925 2967297 5988927 989415 1826341 #> [29] 2700551 1316470 8791894 2059179 19378102 9535483 672591 #> [36] 11536504 3751351 3831074 12702379 1052567 4625364 814180 #> [43] 6346105 25145561 2763885 625741 8001024 6724540 1852994 #> [50] 5686986 563626 ``` 但我们是如何知道使用`population`的呢?之前,通过将函数`str`应用于对象`murders`,我们揭示了存储在这个表格中的五个变量的名称。我们可以快速使用以下方式访问变量名称: ```r names(murders) #> [1] "state" "abb" "region" "population" "total" ``` 重要的是要知道`murders$population`中条目的顺序与我们的数据表中行的顺序保持一致。这将在以后允许我们根据另一个变量的结果来操作一个变量。例如,我们将能够根据谋杀案的数量对州名进行排序。 R 自带一个非常棒的自动完成功能,这可以节省我们输入所有名称的麻烦。尝试输入`murders$p`然后按键盘上的*tab*键。当在 RStudio 中工作时,这个功能和许多其他有用的自动完成功能都是可用的。 ### 2.3.4 向量 对象`murders$population`不是一个数字,而是几个数字。我们称这些类型的对象为*向量*。一个单独的数字在技术上是一个长度为 1 的向量,但通常我们使用向量这个术语来指代具有多个条目的对象。`length`函数会告诉你向量中有多少条目: ```r pop <- murders$population length(pop) #> [1] 51 ``` 这个特定的向量是*数值*的,因为人口规模是数字: ```r class(pop) #> [1] "numeric" ``` 在数值向量中,每个条目都必须是数字。 为了存储字符字符串,向量也可以是*字符*类。例如,州名是字符: ```r class(murders$state) #> [1] "character" ``` 与数值向量一样,字符向量中的所有条目都需要是字符。 另一种重要的向量类型是*逻辑向量*。这些必须是`TRUE`或`FALSE`。 ```r z <- 3 == 2 z #> [1] FALSE class(z) #> [1] "logical" ``` 在这里,`==`是一个关系运算符,询问 3 是否等于 2。在 R 中,如果你只使用一个`=`,实际上是在赋值一个变量,但如果你使用两个`==`,你就是在测试相等性。 你可以通过输入以下内容来查看其他*关系运算符*: ```r ?Comparison ``` 在未来的章节中,你将看到关系运算符有多么有用。 在下一组练习之后,我们将讨论向量的更多重要特性。 从数学上讲,`pop`中的值是整数,R 中有一个整数类。然而,默认情况下,即使数字是整数,也会将它们分配为 numeric 类。例如,`class(1)`返回 numeric。您可以使用`as.integer()`函数或添加一个`L`(如`1L`)来将它们转换为 integer 类。注意类:`class(1L)` ### 2.3.5 因素 在`murders`数据集中,我们可能预计地区也是一个字符向量。然而,它不是: ```r class(murders$region) #> [1] "factor" ``` 这是一个*因素*。因素对于存储分类数据很有用。我们可以通过使用`levels`函数看到只有 4 个地区: ```r levels(murders$region) #> [1] "Northeast" "South" "North Central" "West" ``` 在幕后,R 将这些*级别*存储为整数,并保持一个映射来跟踪标签。这比存储所有字符更节省内存。 注意,级别的顺序与因素对象中出现的顺序不同。在 R 中,默认情况下,级别按照字母顺序排列。然而,我们通常希望级别按照不同的顺序排列。您可以通过在创建因素时使用`factor`函数的`levels`参数来指定顺序。例如,在`murders`数据集中,地区按从东到西的顺序排列。`reorder`函数允许我们根据对数值向量的汇总来改变因素变量的级别顺序。我们将通过一个简单的例子来演示这一点,并在本书的数据可视化部分看到更高级的例子。 假设我们想要按谋杀总数而不是按字母顺序对地区级别进行排序。如果每个级别都与值相关联,我们可以使用`reorder`函数并指定数据汇总来确定顺序。以下代码计算每个地区的总谋杀数,并按这些总和重新排序因素。 ```r region <- murders$region value <- murders$total region <- reorder(region, value, FUN = sum) levels(region) #> [1] "Northeast" "North Central" "West" "South" ``` 新的顺序与东北谋杀最少、南部谋杀最多的实际情况一致。 因素有时表现得像字符有时又不像,因此它们可能成为混淆的来源。结果,混淆因素和字符是常见的错误来源。 ### 2.3.6 列表 数据框是*列表*的特殊情况。列表很有用,因为您可以存储不同类型的任意组合。您可以使用`list`函数创建列表,如下所示: ```r record <- list(name = "John Doe", student_id = 1234, grades = c(95, 82, 91, 97, 93), final_grade = "A") ``` 函数`c`在第 2.4 节中描述。 此列表包括一个字符、一个数字、一个包含五个数字的向量以及另一个字符。 ```r record #> $name #> [1] "John Doe" #> #> $student_id #> [1] 1234 #> #> $grades #> [1] 95 82 91 97 93 #> #> $final_grade #> [1] "A" class(record) #> [1] "list" ``` 与数据框一样,您可以使用访问器`$`提取列表的组件。 ```r record$student_id #> [1] 1234 ``` 我们也可以像这样使用双方括号(`[[`): ```r record[["student_id"]] #> [1] 1234 ``` 您应该习惯在 R 中,通常有几种方法可以完成同一件事,例如访问条目。 您还可能遇到没有变量名的列表。 ```r record2 <- list("John Doe", 1234) record2 #> [[1]] #> [1] "John Doe" #> #> [[2]] #> [1] 1234 ``` 如果一个列表没有名称,您不能使用 `$` 提取元素,但您仍然可以使用括号方法,而不是提供变量名,而是提供列表索引,如下所示: ```r record2[[1]] #> [1] "John Doe" ``` 我们不会在稍后使用列表,但您可能在探索 R 的过程中遇到一个。因此,我们在这里向您展示一些基础知识。 ### 2.3.7 矩阵 矩阵是 R 中常见的一种对象。矩阵与数据框相似,因为它们都是二维的:它们有行和列。然而,就像数值、字符和逻辑向量一样,矩阵中的条目必须都是同一类型。因此,数据框对于存储数据来说更有用,因为我们可以在其中存储字符、因子和数字。 然而,矩阵相对于数据框有一个主要优势:我们可以执行矩阵代数运算,这是一种强大的数学技术。我们在这本书中不描述这些操作,但您在执行数据分析时背景中发生的大部分操作都涉及矩阵。我们在这里简要介绍矩阵,因为我们将学习的某些函数会返回矩阵。然而,如果您计划进行更高级的工作,我们强烈建议您学习更多,因为它们在数据分析中得到了广泛的应用。 我们可以使用 `matrix` 函数定义一个矩阵。我们需要指定矩阵中的数据以及行数和列数。 ```r mat <- matrix(1:12, 4, 3) mat #> [,1] [,2] [,3] #> [1,] 1 5 9 #> [2,] 2 6 10 #> [3,] 3 7 11 #> [4,] 4 8 12 ``` 使用 `:` 的缩写方式在 第 2.4 节 中描述。 您可以使用方括号 (`[`) 访问矩阵中的特定条目。如果您想获取第二行,第三列,您使用: ```r mat[2, 3] #> [1] 10 ``` 如果您想获取整个第二行,您可以将列位置留空: ```r mat[2, ] #> [1] 2 6 10 ``` 请注意,这返回的是一个向量,而不是一个矩阵。 类似地,如果您想获取整个第三列,您可以将行位置留空: ```r mat[, 3] #> [1] 9 10 11 12 ``` 这同样是一个向量,而不是矩阵。 如果您想访问多个列或多个行,您可以这样做。这将给您一个新的矩阵。 ```r mat[, 2:3] #> [,1] [,2] #> [1,] 5 9 #> [2,] 6 10 #> [3,] 7 11 #> [4,] 8 12 ``` 您可以同时选择行和列的子集: ```r mat[1:2, 2:3] #> [,1] [,2] #> [1,] 5 9 #> [2,] 6 10 ``` 我们可以使用 `as.data.frame` 函数将矩阵转换为数据框: ```r as.data.frame(mat) #> V1 V2 V3 #> 1 1 5 9 #> 2 2 6 10 #> 3 3 7 11 #> 4 4 8 12 ``` 您还可以使用单个方括号 (`[`) 来访问数据框的行和列: ```r murders[25, 1] #> [1] "Mississippi" murders[2:3, ] #> state abb region population total #> 2 Alaska AK West 710231 19 #> 3 Arizona AZ West 6392017 232 ``` 您已经准备好做练习 6-11。 ## 2.4 向量 在 R 中,最基本的数据存储对象是 *向量*。正如我们所见,复杂的数据集通常可以分解为向量组件。例如,在一个数据框中,每一列都是一个向量。在这里,我们更多地了解这个重要的类别。 ### 2.4.1 创建向量 我们可以使用 `c` 函数创建向量,其中 `c` 代表 *连接*。我们使用 `c` 以以下方式连接条目: ```r codes <- c(380, 124, 818) codes #> [1] 380 124 818 ``` 我们还可以创建字符向量。我们使用引号来表示条目是字符而不是变量名。 ```r country <- c("italy", "canada", "egypt") ``` 在 R 中,您还可以使用单引号: ```r country <- c('italy', 'canada', 'egypt') ``` 但请注意不要将单引号 ‘与 *反引号* `混淆。 到现在为止,您应该知道如果您输入: ```r country <- c(italy, canada, egypt) ``` 你会收到一个错误,因为变量`italy`、`canada`和`egypt`未定义。如果我们不使用引号,R 会寻找具有这些名称的变量,并返回一个错误。 ### 2.4.2 名称 有时给向量的条目命名很有用。例如,当定义国家代码向量时,我们可以使用名称来连接两者: ```r codes <- c(italy = 380, canada = 124, egypt = 818) codes #> italy canada egypt #> 380 124 818 ``` 对象`codes`继续是一个数值向量: ```r class(codes) #> [1] "numeric" ``` 但是使用名称: ```r names(codes) #> [1] "italy" "canada" "egypt" ``` 如果使用不带引号的字符串看起来很混乱,要知道你也可以使用引号: ```r codes <- c("italy" = 380, "canada" = 124, "egypt" = 818) codes #> italy canada egypt #> 380 124 818 ``` 这个函数调用与上一个没有区别。这是 R 与其他语言相比奇特之处之一。 我们还可以使用`names`函数来分配名称: ```r codes <- c(380, 124, 818) country <- c("italy","canada","egypt") names(codes) <- country codes #> italy canada egypt #> 380 124 818 ``` ### 2.4.3 序列 另一个用于创建向量的有用函数是生成序列: ```r seq(1, 10) #> [1] 1 2 3 4 5 6 7 8 9 10 ``` 第一个参数定义了起始值,第二个参数定义了结束值(包含)。默认情况下,以 1 的增量递增,但第三个参数让我们可以告诉它跳过的数量: ```r seq(1, 10, 2) #> [1] 1 3 5 7 9 ``` 如果我们想得到连续的整数,我们可以使用以下简写: ```r 1:10 #> [1] 1 2 3 4 5 6 7 8 9 10 ``` 当我们使用这些函数时,R 产生整数而不是数值,因为它们通常用于索引: ```r class(1:10) #> [1] "integer" ``` 然而,如果我们创建一个包含非整数的序列,类会改变: ```r class(seq(1, 10, 0.5)) #> [1] "numeric" ``` ### 2.4.4 子集 我们使用方括号来访问向量的特定元素。对于上面定义的`codes`向量,我们可以使用以下方式访问第二个元素: ```r codes[2] #> canada #> 124 ``` 你可以通过使用多元素向量作为索引来得到多个条目: ```r codes[c(1,3)] #> italy egypt #> 380 818 ``` 如果我们想访问,比如,前两个元素,上面定义的序列特别有用: ```r codes[1:2] #> italy canada #> 380 124 ``` 如果元素有名称,我们也可以使用这些名称来访问条目。下面是两个例子。 ```r codes["canada"] #> canada #> 124 codes[c("egypt","italy")] #> egypt italy #> 818 380 ``` ## 2.5 强制转换 一般来说,*强制转换*是 R 在数据类型上保持灵活的一种尝试。当一个条目不匹配预期时,一些预构建的 R 函数会尝试猜测在抛出错误之前是什么意思。这也可能导致混淆。当尝试在 R 中编码时,由于它在这一点上与大多数其他语言的行为相当不同,因此未能理解*强制转换*可能会让程序员发疯。让我们通过一些例子来了解它。 我们说过向量必须是同一类型的。所以如果我们尝试组合,比如数字和字符,你可能会期望一个错误: ```r x <- c(1, "canada", 3) ``` 但我们没有得到一个,甚至没有警告!发生了什么?看看`x`及其类: ```r x #> [1] "1" "canada" "3" class(x) #> [1] "character" ``` R 将数据强制转换为字符。它猜测因为你在一个向量中放入了一个字符串,所以你实际上想将 1 和 3 视为字符字符串`"1"`和`"3"`。甚至没有发出警告的事实是强制转换如何导致 R 中许多未察觉的错误的一个例子。 R 还提供了将一种类型转换为另一种类型的函数。例如,你可以使用以下方式将数字转换为字符: ```r x <- 1:5 y <- as.character(x) y #> [1] "1" "2" "3" "4" "5" ``` 你可以使用`as.numeric`将其转换回来: ```r as.numeric(y) #> [1] 1 2 3 4 5 ``` 这个函数实际上非常有用,因为包含数字作为字符串的集合很常见。 ## 2.6 不可用(NA) 当函数试图将一种类型强制转换为另一种类型并遇到不可能的情况时,它通常会给我们一个警告,并将条目转换为称为“不可用”(NA)的特殊值。例如: ```r x <- c("1", "b", "3") as.numeric(x) #> Warning: NAs introduced by coercion #> [1] 1 NA 3 ``` R 在你输入 `b` 时没有对你想要的数字有任何猜测,所以它不会尝试。 作为数据科学家,你经常会遇到 `NA`,因为它们通常用于缺失数据,这是现实世界数据集中常见的问题。 你已经准备好做 12-23 题了。 ## 2.7 排序 现在我们已经掌握了一些基本的 R 知识,让我们尝试在枪支谋杀的背景下了解不同州的安全性。 ### 2.7.1 `sort` 假设我们想按枪支谋杀数最少到最多的顺序排名州。`sort` 函数按递增顺序对向量进行排序。因此,我们可以通过输入以下内容来查看枪支谋杀数最多的数量: ```r library(dslabs) sort(murders$total) #> [1] 2 4 5 5 7 8 11 12 12 16 19 21 22 #> [14] 27 32 36 38 53 63 65 67 84 93 93 97 97 #> [27] 99 111 116 118 120 135 142 207 219 232 246 250 286 #> [40] 293 310 321 351 364 376 413 457 517 669 805 1257 ``` 然而,这并没有告诉我们哪些州有哪个谋杀总数。例如,我们不知道哪个州有 1257. ### 2.7.2 `order` 函数 `order` 更接近我们想要的。它接受一个向量作为输入,并返回一个索引向量,该向量按输入向量的顺序排序。这听起来可能有些令人困惑,所以让我们看看一个简单的例子。我们可以创建一个向量并对其进行排序: ```r x <- c(31, 4, 15, 92, 65) sort(x) #> [1] 4 15 31 65 92 ``` 而不是对输入向量进行排序,函数 `order` 返回排序输入向量的索引: ```r index <- order(x) x[index] #> [1] 4 15 31 65 92 ``` 这与 `sort(x)` 返回的输出相同。如果我们查看这个索引,我们会看到为什么它有效: ```r x #> [1] 31 4 15 92 65 order(x) #> [1] 2 3 1 5 4 ``` `x` 的第二个条目是最小的,所以 `order(x)` 从 `2` 开始。下一个最小的是第三个条目,所以第二个条目是 `3`,以此类推。 这如何帮助我们根据谋杀数对州进行排序?首先,记住你用 `$` 访问的向量的条目遵循与表格中行相同的顺序。例如,这两个包含州名和缩写的向量按其顺序匹配: ```r murders$state[1:6] #> [1] "Alabama" "Alaska" "Arizona" "Arkansas" "California" #> [6] "Colorado" murders$abb[1:6] #> [1] "AL" "AK" "AZ" "AR" "CA" "CO" ``` 这意味着我们可以根据总谋杀数对州名进行排序。我们首先获得根据谋杀总数排序的向量的索引,然后对州名向量进行索引: ```r ind <- order(murders$total) murders$abb[ind] #> [1] "VT" "ND" "NH" "WY" "HI" "SD" "ME" "ID" "MT" "RI" "AK" "IA" "UT" #> [14] "WV" "NE" "OR" "DE" "MN" "KS" "CO" "NM" "NV" "AR" "WA" "CT" "WI" #> [27] "DC" "OK" "KY" "MA" "MS" "AL" "IN" "SC" "TN" "AZ" "NJ" "VA" "NC" #> [40] "MD" "OH" "MO" "LA" "IL" "GA" "MI" "PA" "NY" "FL" "TX" "CA" ``` 根据上述内容,加利福尼亚的谋杀数最多。 ### 2.7.3 `max` 和 `which.max` 如果我们只对具有最大值的条目感兴趣,我们可以使用 `max` 来获取值: ```r max(murders$total) #> [1] 1257 ``` 和 `which.max` 用于最大值的索引: ```r i_max <- which.max(murders$total) murders$state[i_max] #> [1] "California" ``` 对于最小值,我们可以用相同的方式使用 `min` 和 `which.min`。 这意味着加利福尼亚是最危险的状态吗?在接下来的部分中,我们争论我们应该考虑比率而不是总数。在这样做之前,我们介绍一个最后的与排序相关的函数:`rank`。 ### 2.7.4 `rank` 尽管 `order` 和 `sort` 函数使用得更频繁,但 `rank` 函数也与排序相关,并且可能很有用。对于任何给定的向量,它返回一个向量,其中包含输入向量第一个元素、第二个元素等的排名。以下是一个简单的例子: ```r x <- c(31, 4, 15, 92, 65) rank(x) #> [1] 3 1 2 5 4 ``` 为了总结,让我们看看我们已介绍的三种函数的结果: | 原始 | 排序 | 排序 | 排名 | | --- | --- | --- | --- | | 31 | 4 | 2 | 3 | | 4 | 15 | 3 | 1 | | 15 | 31 | 1 | 2 | | 92 | 65 | 5 | 5 | | 65 | 92 | 4 | 4 | 你现在可以开始做 24-31 题的练习了 ## 2.8 向量算术 加利福尼亚州谋杀案最多,但这是否意味着它是最危险的状态?如果它只是比其他州有更多的人,会怎样?我们可以快速确认加利福尼亚州确实有最大的人口: ```r library(dslabs) murders$state[which.max(murders$population)] #> [1] "California" ``` 拥有超过 3700 万居民。因此,如果我们对了解州的安全性感兴趣,比较总数是不公平的。我们真正应该计算的是每千人谋杀数。我们在激励部分描述的报告使用了每 10 万人的谋杀数作为单位。为了计算这个数量,R 强大的向量算术功能派上了用场。 ### 2.8.1 向量缩放 在 R 中,向量的算术运算是以**元素级**进行的。为了快速举例,假设我们有以下英寸为单位的高度: ```r inches <- c(69, 62, 66, 70, 70, 73, 67, 73, 67, 70) ``` 我们想要将其转换为厘米。注意当我们把 `inches` 乘以 2.54 时会发生什么: ```r inches * 2.54 #> [1] 175 157 168 178 178 185 170 185 170 178 ``` 在上面的行中,我们将每个元素乘以 2.54。同样,如果我们想计算每个条目相对于 69 英寸的平均男性身高是高还是矮,我们可以从每个条目中减去它,如下所示: ```r inches - 69 #> [1] 0 -7 -3 1 1 4 -2 4 -2 1 ``` ### 2.8.2 两个向量 如果我们有两个长度相同的向量,并且我们在 R 中将它们相加,它们将按以下方式逐条相加: $$ \begin{pmatrix} a\\ b\\ c\\ d \end{pmatrix} + \begin{pmatrix} e\\ f\\ g\\ h \end{pmatrix} = \begin{pmatrix} a +e\\ b + f\\ c + g\\ d + h \end{pmatrix} \]

同样,其他数学运算,如 -*/ 也适用。

这意味着要计算谋杀率,我们可以简单地输入:

murder_rate <- murders$total / murders$population * 100000

一旦我们这样做,我们会注意到加利福尼亚州不再位于名单的顶端。事实上,我们可以利用我们所学到的知识按谋杀率对州进行排序:

murders$abb[order(murder_rate)]
#>  [1] "VT" "NH" "HI" "ND" "IA" "ID" "UT" "ME" "WY" "OR" "SD" "MN" "MT"
#> [14] "CO" "WA" "WV" "RI" "WI" "NE" "MA" "IN" "KS" "NY" "KY" "AK" "OH"
#> [27] "CT" "NJ" "AL" "IL" "OK" "NC" "NV" "VA" "AR" "TX" "NM" "CA" "FL"
#> [40] "TN" "PA" "AZ" "GA" "MS" "MI" "DE" "SC" "MD" "MO" "LA" "DC"

2.8.3 小心循环引用

R 中另一个常见的未注意到的错误来源是使用循环引用。我们看到了向量是按元素级相加的。所以如果向量长度不匹配,我们自然会假设我们应该得到一个错误。但事实并非如此。注意下面发生了什么:

x <- c(1, 2, 3)
y <- c(10, 20, 30, 40, 50, 60, 70)
x + y
#> Warning in x + y: longer object length is not a multiple of shorter
#> object length
#> [1] 11 22 33 41 52 63 71

我们确实得到了一个警告,但没有错误。对于输出,R 已经在 x 中循环引用了数字。注意输出中数字的最后一位。

你现在可以开始做 32-34 题的练习了

2.9 索引

R 提供了一种强大且方便的向量索引方式。例如,我们可以根据另一个向量的属性对向量进行子集化。在本节中,我们继续使用我们的美国谋杀案例,我们可以这样加载:

library(dslabs)

2.9.1 使用逻辑进行子集化

我们现在已经使用以下方法计算了谋杀率:

murder_rate <- murders$total / murders$population * 100000 

想象一下,你从意大利搬走,据 ABC 新闻报道,那里的谋杀率仅为每 10 万人口 0.71。你更愿意搬到一个谋杀率相似的状态。R 的另一个强大功能是我们可以使用逻辑来索引向量。如果我们将一个向量与一个单个数字进行比较,它实际上会对每个条目执行测试。以下是与上述问题相关的一个例子:

ind <- murder_rate < 0.71

如果我们想知道一个值是否小于或等于,我们可以使用:

ind <- murder_rate <= 0.71

注意,我们得到一个逻辑向量,其中每个小于或等于 0.71 的条目都返回 TRUE。为了查看这些状态是哪些,我们可以利用向量可以用逻辑索引的事实。

murders$state[ind]
#> [1] "Hawaii"        "Iowa"          "New Hampshire" "North Dakota" 
#> [5] "Vermont"

为了计算有多少是 TRUEsum 函数返回向量的条目之和,逻辑向量会通过将 TRUE 编码为 1 和 FALSE 编码为 0 来转换为数值。因此,我们可以使用以下方法来计算州的数量:

sum(ind)
#> [1] 5

2.9.2 逻辑运算符

假设我们喜欢山脉,并想在国家的西部地区找一个安全的状态。我们希望谋杀率不超过 1。在这种情况下,我们希望两个不同的事情都为真。在这里,我们可以使用逻辑运算符 and,在 R 中用 & 表示。这个操作只在两个逻辑都为 TRUE 时才返回 TRUE。为了看到这一点,考虑以下例子:

TRUE & TRUE
#> [1] TRUE
TRUE & FALSE
#> [1] FALSE
FALSE & FALSE
#> [1] FALSE

对于我们的例子,我们可以形成两个逻辑值:

west <- murders$region == "West"
safe <- murder_rate <= 1

并且我们可以使用 & 来得到一个逻辑向量,它告诉我们哪些状态同时满足这两个条件:

ind <- safe & west
murders$state[ind]
#> [1] "Hawaii"  "Idaho"   "Oregon"  "Utah"    "Wyoming"

2.9.3 which

如果我们想查找加利福尼亚的谋杀率。对于这种类型的操作,将逻辑向量转换为索引而不是保留长逻辑向量会更方便。which 函数告诉我们逻辑向量的哪些条目是 TRUE。因此,我们可以输入:

ind <- which(murders$state == "California")
murder_rate[ind]
#> [1] 3.37

2.9.4 match

如果我们想找到多个州的谋杀率,而不是一个州,比如纽约、佛罗里达和德克萨斯州,我们可以使用 match 函数。这个函数告诉我们第二个向量的哪个索引与第一个向量的每个条目匹配:

ind <- match(c("New York", "Florida", "Texas"), murders$state)
ind
#> [1] 33 10 44

现在我们可以查看谋杀率:

murder_rate[ind]
#> [1] 2.67 3.40 3.20

2.9.5 %in%

如果我们想得到一个逻辑值,告诉我们第一个向量的每个元素是否在第二个向量中,我们可以使用 %in% 函数。让我们假设你不确定波士顿、达科他州和华盛顿是否是州。你可以这样找出:

c("Boston", "Dakota", "Washington") %in% murders$state
#> [1] FALSE FALSE  TRUE

注意,在整个书中,我们经常会使用 %in%

match%in% 通过 which 有联系。为了看到这一点,请注意以下两行产生相同的索引(尽管顺序不同):

match(c("New York", "Florida", "Texas"), murders$state)
#> [1] 33 10 44
which(murders$state %in% c("New York", "Florida", "Texas"))
#> [1] 10 33 44

你现在可以开始做练习 35-42。

2.10 基本绘图

在第八章(../dataviz/ggplot2.html)中,我们描述了一个附加包,它提供了一种强大的方法来生成 R 中的图表。然后我们在数据可视化部分有一个完整的部分,其中我们提供了许多示例。在这里,我们简要描述了一些在基本 R 安装中可用的函数。

2.10.1 plot

plot 函数可以用来制作散点图。这里是一个总谋杀数与人口之间的散点图。

x <- murders$population / 10⁶
y <- murders$total
plot(x, y)

为了快速绘图并避免两次访问变量,我们可以使用 with 函数:

with(murders, plot(population, total))

with 函数让我们可以在 plot 函数中使用 murders 列名。它也可以与任何数据框和任何函数一起使用。

2.10.2 hist

我们将描述直方图与本书数据可视化部分中的分布的关系。在这里,我们只是简单地指出,直方图是数字列表的强大图形总结,它为你提供了一个关于你拥有的值类型的总体概述。我们可以通过简单地输入以下内容来制作谋杀率的直方图:

x <- with(murders, total / population * 100000)
hist(x)

我们可以看到,存在一个很大的值范围,其中大多数值在 2 到 3 之间,有一个极端案例,谋杀率超过 15:

murders$state[which.max(x)]
#> [1] "District of Columbia"

2.10.3 boxplot

本书的数据可视化部分也将描述箱线图。它们比直方图提供更简洁的总结,但它们更容易与其他箱线图堆叠。例如,这里我们可以使用它们来比较不同的地区:

murders$rate <- with(murders, total / population * 100000)
boxplot(rate~region, data = murders)

我们可以看到,南部比其他三个地区的谋杀率要高。

2.10.4 image

图像函数使用颜色显示矩阵中的值。这里是一个快速示例:

x <- matrix(1:120, 12, 10)
image(x)

您现在可以开始做第 43-45 题了。

2.11 练习

  1. 前一百个正整数的和是多少?整数 1 到 n 的和的公式是 \(n(n+1)/2\)。定义 \(n=100\),然后使用 R 根据公式计算 1 到 100 的和。和是多少?

  2. 现在用同样的公式计算从 1 到 1000 的整数的和。

  3. 查看将以下代码输入 R 后的结果:

n <- 1000
x <- seq(1, n)
sum(x)

根据结果,你认为函数 seqsum 做了什么?你可以使用 help

  1. sum 创建一个数字列表,seq 将它们加起来。

  2. seq 创建一个数字列表,sum 将它们加起来。

  3. seq 创建一个随机列表,sum 计算从 1 到 1,000 的和。

  4. sum 总是返回相同的数字。

  5. 在数学和编程中,我们说当我们用一个给定的值替换参数时,我们在评估一个函数。所以如果我们输入 sqrt(4),我们就在评估 sqrt 函数。在 R 中,你可以在另一个函数内部评估一个函数。评估是从内到外发生的。使用一行代码计算 100 的平方根的以 10 为底的对数。

  6. 以下哪个选项将始终返回存储在 x 中的数值?如果您想尝试示例,可以使用帮助系统。

  7. log(10^x)

  8. log10(x¹⁰)

  9. log(exp(x))

  10. exp(log(x, base = 2))

  11. 确保已加载美国谋杀数据集。使用 str 函数检查 murders 对象的结构。以下哪个选项最好地描述了在此数据框中表示的变量?

  12. 51 个州。

  13. 所有 50 个州和哥伦比亚特区的谋杀率。

  14. 州名、州名的缩写、州的区域以及 2010 年该州的人口和谋杀总数。

  15. str 显示没有相关信息。

  16. 数据框用于这五个变量的列名是什么?

  17. 使用访问器 $ 提取州缩写,并将它们分配给对象 a。此对象的类型是什么?

  18. 现在用方括号提取州缩写,并将它们分配给对象 b。使用 identical 函数确定 ab 是否相同。

  19. 我们看到 region 列存储了一个因子。您可以通过输入以下内容来证实这一点:

class(murders$region)

使用一行代码,使用 levelslength 函数确定由该数据集定义的区域数量。

  1. 函数 table 接收一个向量并返回每个元素的出现频率。通过应用此函数,您可以快速查看每个区域有多少个状态。使用一行代码调用此函数来创建每个区域状态数量的表格。

  2. 使用函数 c 创建一个向量,包含北京、拉各斯、巴黎、里约热内卢、圣胡安和多伦多的 1 月平均高温,分别是 35、88、42、84、81 和华氏 30 度。将对象命名为 temp

  3. 现在创建一个包含城市名称的向量,并将对象命名为 city

  4. 使用 names 函数和之前定义的对象将温度数据与其对应的城市关联起来。

  5. 使用 [: 操作符访问列表上前三个城市的温度。

  6. 使用 [ 操作符访问巴黎和圣胡安的温度。

  7. 使用 : 操作符创建一个数字序列 \(12,13,14,\dots,73\)

  8. 创建一个包含所有小于 100 的正奇数的向量。

  9. 创建一个从 6 开始,不超过 55,以 4/7 为增量增加数字的向量:6, 6 + 4/7, 6 + 8/7,依此类推。这个列表有多少个数?提示:使用 seqlength

  10. 以下对象 a <- seq(1, 10, 0.5) 的类型是什么?

  11. 以下对象 a <- seq(1, 10) 的类型是什么?

  12. 对象 a <- 1 的类型是数值型,而不是整型。R 默认为数值型,要强制转换为整型,需要添加字母 L。确认 1L 的类型是整型。

  13. 定义以下向量:

x <- c("1", "3", "5")

并将其强制转换为整数。

  1. 对于 24-31 题的练习,我们将使用美国谋杀数据集。确保在开始之前加载它。使用 $ 操作符访问人口规模数据并将其存储为对象 pop。然后使用 sort 函数重新定义 pop 以使其排序。最后,使用 [ 操作符报告最小的人口规模。

  2. 现在不是找到最小的人口规模,而是找到具有最小人口规模的条目的索引。提示:使用 order 而不是 sort

  3. 我们实际上可以使用与上一个练习相同的操作,使用函数 which.min。写一行代码来完成这个操作。

  4. 现在我们知道最小的州有多小,也知道代表它的行是哪一行。它是哪个州?定义一个变量 states 来存储 murders 数据框中的州名。报告具有最小人口规模的州的名字。

  5. 您可以使用 data.frame 函数创建数据框。这里有一个快速示例:

temp <- c(35, 88, 42, 84, 81, 30)
city <- c("Beijing", "Lagos", "Paris", "Rio de Janeiro", 
 "San Juan", "Toronto")
city_temps <- data.frame(name = city, temperature = temp)

使用 rank 函数确定每个州的人口排名,从最小人口到最大人口。将这些排名存储在名为 ranks 的对象中,然后创建一个包含州名及其排名的数据框。将数据框命名为 my_df

  1. 重复上一个练习,但这次按人口从少到多对 my_df 进行排序。提示:创建一个对象 ind 来存储排序人口值所需的索引。然后使用方括号操作符 [ 重新排序数据框中的每一列。

  2. na_example 向量代表一系列计数。您可以使用以下方法快速检查对象:

str(na_example)
#>  int [1:1000] 2 1 3 2 1 3 1 4 3 2 ...

然而,当我们使用函数 mean 计算平均值时,我们得到一个 NA

mean(na_example)
#> [1] NA

is.na 函数返回一个逻辑向量,告诉我们哪些条目是 NA。将这个逻辑向量分配给一个名为 ind 的对象,并确定 na_example 有多少个 NA

  1. 现在再次计算平均值,但只针对不是 NA 的条目。提示:记住 ! 操作符,它将 FALSE 转换为 TRUE,反之亦然。

  2. 在 28 题的练习中,我们创建了 temp 数据框:

temp <- c(35, 88, 42, 84, 81, 30)
city <- c("Beijing", "Lagos", "Paris", "Rio de Janeiro", 
 "San Juan", "Toronto")
city_temps <- data.frame(name = city, temperature = temp)

使用上面的代码重新制作数据框,但添加一行将温度从华氏度转换为摄氏度。转换公式是 \(C = \frac{5}{9} \times (F - 32)\)

  1. 以下求和 \(1+1/2² + 1/3² + \dots 1/100²\) 是多少?提示:多亏了欧拉,我们知道它应该接近 \(\pi²/6\)

  2. 计算每个州的每 10 万人谋杀率并将其存储在对象 murder_rate 中。然后使用 mean 函数计算美国的平均谋杀率。平均数是多少?

  3. 对于剩余的 35-42 题练习,首先加载库和数据。

library(dslabs)

计算每个州的每 10 万人谋杀率并将其存储在名为 murder_rate 的对象中。然后使用逻辑运算符创建一个名为 low 的逻辑向量,告诉我们 murder_rate 中的哪些条目低于 1。

  1. 现在利用前一个练习的结果和函数 which 来确定与低于 1 的值相关的 murder_rate 的索引。

  2. 使用前一个练习的结果来报告谋杀率低于 1 的各州名称。

  3. 现在扩展练习中的代码,以报告谋杀率低于 1 的东北部各州。提示:使用先前定义的逻辑向量 low 和逻辑运算符 &

  4. 在之前的练习中,我们计算了每个州的谋杀率和这些数字的平均值。有多少个州的人口低于平均值?

  5. 使用匹配函数来识别缩写为 AK, MI 和 IA 的各州。提示:首先定义一个与三个缩写匹配的 murders$abb 条目的索引,然后使用 [ 操作符提取各州。

  6. 使用 %in% 操作符创建一个逻辑向量,回答以下问题:以下哪些是实际缩写:MA, ME, MI, MO, MU?

  7. 将你在练习 41 中使用的代码扩展,以报告一个不是实际缩写的条目。提示:使用 ! 操作符,它将 FALSE 转换为 TRUE,反之亦然,然后使用 which 获取索引。

  8. 我们绘制了总谋杀数与人口的关系图,并注意到存在强烈的关联。不出所料,人口较多的州谋杀案更多。

population_in_millions <- murders$population/10⁶
total_gun_murders <- murders$total
plot(population_in_millions, total_gun_murders)

请注意,许多州的人口低于 500 万,且分布密集。我们可能通过在对数尺度上绘制此图来获得更深入的见解。使用 log10 转换变量,然后绘制它们。

  1. 创建人口直方图。

  2. 按地区生成人口箱线图。


  1. abcnews.go.com/blogs/headlines/2012/12/us-gun-ownership-homicide-rate-higher-than-other-developed-countries/↩︎

  2. everytownresearch.org↩︎

  3. adv-r.had.co.nz/Style.html↩︎

3 编程基础

原文:rafalab.dfci.harvard.edu/dsbook-part-1/R/programming-basics.html

  1. R

  2. 3 编程基础

我们教授 R 语言,因为它极大地促进了数据分析,这是本书的主要内容。通过在 R 语言中编码,我们可以高效地进行数据探索分析,构建数据分析管道,并准备数据可视化以传达结果。然而,R 语言不仅仅是一个数据分析环境,它也是一种编程语言。高级 R 语言程序员可以开发复杂的包,甚至可以改进 R 语言本身,但本书不涉及高级编程。尽管如此,在本节中,我们介绍了三个关键编程概念:条件表达式、for 循环和函数。这些不仅是高级编程的关键构建块,有时在数据分析中也非常有用。我们还注意到,有几个在 R 语言编程中广泛使用的函数,但本书不会涉及。这些包括splitcutdo.callReduce。如果你计划成为一名专家 R 语言程序员,这些是值得学习的。

3.1 条件表达式

条件表达式是编程的基本特性之一。它们用于所谓的流程控制。最常用的条件表达式是 if-else 语句。在 R 语言中,我们实际上可以在不使用条件语句的情况下进行相当多的数据分析。然而,它们偶尔会出现,一旦你开始编写自己的函数和包,你将需要它们。

这是一个非常简单的例子,展示了 if-else 语句的一般结构。基本思想是打印a的倒数,除非a为 0:

a <- 0
 if (a != 0) {
 print(1/a)
} else{
 print("No reciprocal for 0.")
}
#> [1] "No reciprocal for 0."

让我们再看一个使用美国谋杀数据的例子:

library(dslabs)
murder_rate <- murders$total / murders$population*100000

这是一个非常简单的例子,告诉我们如果谋杀率低于每 10 万人 0.5,那么谋杀率最低的状态。if-else 语句保护我们免受没有州满足条件的情况。

ind <- which.min(murder_rate)
 if (murder_rate[ind] < 0.5) {
 print(murders$state[ind]) 
} else{
 print("No state has murder rate that low")
}
#> [1] "Vermont"

如果我们再次尝试以 0.25 的比率,我们会得到不同的答案:

if (murder_rate[ind] < 0.25) {
 print(murders$state[ind]) 
} else{
 print("No state has a murder rate that low.")
}
#> [1] "No state has a murder rate that low."

一个相关的非常有用的函数是ifelse。该函数接受三个参数:一个逻辑值和两个可能的答案。如果逻辑值为TRUE,则返回第二个参数的值,如果为FALSE,则返回第三个参数的值。以下是一个例子:

a <- 0
ifelse(a > 0, 1/a, NA)
#> [1] NA

该函数特别有用,因为它可以在向量上工作。它检查逻辑向量的每个条目,如果条目为TRUE,则从第二个参数提供的向量中返回元素,如果条目为FALSE,则从第三个参数提供的向量中返回元素。

a <- c(0, 1, 2, -4, 5)
result <- ifelse(a > 0, 1/a, NA)

这个表格帮助我们了解发生了什么:

a is_a_positive answer1 answer2 result
0 FALSE Inf NA NA
1 TRUE 1.00 NA 1.0
2 TRUE 0.50 NA 0.5
-4 FALSE -0.25 NA NA
5 TRUE 0.20 NA 0.2

以下是一个示例,说明如何使用此函数轻松地将向量中的所有缺失值替换为零:

no_nas <- ifelse(is.na(na_example), 0, na_example) 
sum(is.na(no_nas))
#> [1] 0

另外两个有用的函数是 anyallany 函数接受一个逻辑向量,如果其中任何一项为 TRUE,则返回 TRUEall 函数接受一个逻辑向量,如果所有项都为 TRUE,则返回 TRUE。以下是一个例子:

z <- c(TRUE, TRUE, FALSE)
any(z)
#> [1] TRUE
all(z)
#> [1] FALSE

你已经准备好做练习 1-3。

3.2 定义函数

随着你变得越来越有经验,你将发现自己需要反复执行相同的操作。一个简单的例子是计算平均值。我们可以使用 sumlength 函数计算向量 x 的平均值:sum(x)/length(x)。因为我们反复这样做,所以编写执行此操作的函数会更高效。这个特定的操作非常常见,因此有人已经编写了 mean 函数,它包含在基础 R 中。然而,你将遇到函数尚未存在的情况,因此 R 允许你编写自己的函数。一个计算平均值的函数的简单版本可以定义如下:

avg <- function(x){
 s <- sum(x)
 n <- length(x)
 s/n
}

现在 avg 是一个计算平均值的函数:

x <- 1:100
identical(mean(x), avg(x))
#> [1] TRUE

请注意,在函数内部定义的变量不会保存在工作空间中。因此,当我们调用 avg 时使用 sn,这些值仅在调用期间创建和更改。以下是一个说明性的例子:

s <- 3
avg(1:10)
#> [1] 5.5
s
#> [1] 3

注意,在调用 avg 之后,s 仍然是 3。

通常,函数是对象,所以我们使用 <- 将它们分配给变量名。function 函数告诉 R 你即将定义一个函数。函数定义的一般形式如下:

my_function <- function(VARIABLE_NAME){
 perform operations on VARIABLE_NAME and calculate VALUE
 VALUE
}

你定义的函数可以有多个参数以及默认值。例如,我们可以定义一个函数,根据用户定义的变量计算算术平均数或几何平均数,如下所示:

avg <- function(x, arithmetic = TRUE){
 n <- length(x)
 ifelse(arithmetic, sum(x)/n, prod(x)^(1/n))
}

随着我们面对更复杂的任务,我们将通过经验学习如何创建函数。

你已经准备好做练习 4-7。

3.3 命名空间

一旦你开始成为一个更高级的 R 用户,你可能会需要为某些分析加载几个附加包。一旦你开始这样做,很可能两个包会为两个不同的函数使用相同的名称。而且,这些函数通常执行完全不同的操作。实际上,你已经遇到过这种情况,因为 dplyr 和 R 基础包的 stats 都定义了一个 filter 函数。dplyr 中还有五个其他例子。我们知道这一点是因为当我们第一次加载 dplyr 时,我们会看到以下消息:

The following objects are masked from ‘package:stats’:

    filter, lag

The following objects are masked from ‘package:base’:

    intersect, setdiff, setequal, union

现在我们输入 filter 时,它使用的是 dplyr 的版本。但如果我们想使用 stats 版本怎么办?

这些函数位于不同的 命名空间 中。R 在这些 命名空间 中搜索函数时会遵循一定的顺序。你可以通过输入以下内容来查看顺序:

search()

列表中的第一个条目是全局环境,它包括你定义的所有对象。

所以,如果我们想使用 stats filter 而不是 dplyr filter,但 dplyr 在搜索列表中排在前面怎么办?你可以通过使用双冒号 (::) 来强制使用特定的命名空间,如下所示:

stats::filter

如果我们想绝对确保使用 dplyr filter,我们可以使用

dplyr::filter

注意,如果我们想在不需要加载整个包的情况下使用包中的函数,我们也可以使用双冒号。

如果你想查看所有具有名为 filter 的函数的包,你可以使用双问号:??filter。* *关于这个更高级的话题,我们推荐阅读 R 包书籍¹。

3.4 For 循环

数列 \(1+2+\dots+n\) 的和的公式是 \(S_n = n(n+1)/2\)。如果我们不确定这是正确的函数怎么办?我们如何检查?利用我们关于函数的知识,我们可以创建一个计算 \(S_n\) 的函数:

compute_s_n <- function(n) { 
 sum(1:n)
}

我们如何计算 \(S_n\) 的各种值,比如 \(n=1,\dots,25\)?我们是否需要写 25 行代码调用 compute_s_n?不,这就是编程中 for 循环的作用。在这种情况下,我们正在重复执行完全相同的任务,唯一改变的是 \(n\) 的值。for 循环允许我们定义变量取值的范围(在我们的例子中 \(n=1,\dots,10\)),然后改变值并 循环 评估表达式。

可能最简单的 for 循环示例就是下面这段无用的代码:

for (i in 1:5) {
 print(i)
}
#> [1] 1
#> [1] 2
#> [1] 3
#> [1] 4
#> [1] 5

以下是我们的 \(S_n\) 示例中会写的 for 循环:

m <- 25
s_n <- vector(length = m) # create an empty vector
for (n in 1:m) {
 s_n[n] <- compute_s_n(n)
}

在每次迭代 \(n=1\)\(n=2\) 等…,我们计算 \(S_n\) 并将其存储在 s_n 的第 \(n\) 个条目中。

现在,我们可以创建一个图来寻找模式:

n <- 1:m
plot(n, s_n)

如果你注意到它似乎是一个二次函数,那么你就在正确的道路上,因为公式是 \(n(n+1)/2\)

3.5 向量化与函数式编程

尽管 for 循环是一个重要的概念,但在 R 中我们很少使用它。随着你对 R 的学习越来越深入,你会意识到 向量化 被优先于 for 循环,因为它会产生更短、更清晰的代码。我们已经在向量算术部分看到了例子。一个 向量化 的函数是会对每个向量执行相同操作的函数。我们已经在向量算术部分看到了例子。A 向量化 的函数是会对每个向量执行相同操作的函数。

x <- 1:10
sqrt(x)
#>  [1] 1.00 1.41 1.73 2.00 2.24 2.45 2.65 2.83 3.00 3.16
y <- 1:10
x*y
#>  [1]   1   4   9  16  25  36  49  64  81 100

为了进行这个计算,不需要使用 for 循环。然而,并非所有函数都以这种方式工作。例如,我们刚才写的函数 compute_s_n 并不是逐元素工作的,因为它期望一个标量。这段代码并没有在 n 的每个条目上运行函数:

n <- 1:25
compute_s_n(n)

函数式**是帮助我们将相同的函数应用于向量、矩阵、数据框或列表中的每个条目的函数。在这里,我们介绍在数值、逻辑和字符向量上操作的函数式:sapply

函数 sapply 允许我们对任何函数执行逐元素操作。以下是它是如何工作的:

x <- 1:10
sapply(x, sqrt)
#>  [1] 1.00 1.41 1.73 2.00 2.24 2.45 2.65 2.83 3.00 3.16

每个 x 的元素都传递给函数 sqrt,并返回结果。这些结果被连接起来。在这种情况下,结果是一个与原始 x 长度相同的向量。这意味着上面的 for 循环可以写成以下形式:

n <- 1:25
s_n <- sapply(n, compute_s_n)

其他函数包括 apply, lapply, tapply, mapply, vapply, 和 replicate。我们在这本书中主要使用 sapply, apply, 和 replicate,但我们建议您熟悉其他函数,因为它们可能非常有用。

你已经准备好做练习 8-11。

3.6 练习题

  1. 这个条件表达式将返回什么?
x <- c(1,2,-3,4)
 if(all(x>0)){
 print("All Postives")
} else{
 print("Not all positives")
}
  1. 以下哪个表达式在逻辑向量 x 至少有一个条目为 TRUE 时总是 FALSE

  2. all(x)

  3. any(x)

  4. any(!x)

  5. all(!x)

  6. 函数 nchar 告诉你字符向量有多长。编写一行代码,将对象 new_names 赋值为当州名长度超过 8 个字符时的州简称。

  7. 创建一个名为 sum_n 的函数,对于任何给定的值,例如 \(n\),计算从 1 到 \(n\)(包含)的整数之和。使用该函数确定从 1 到 5,000 的整数之和。

  8. 创建一个名为 altman_plot 的函数,该函数接受两个参数 xy,并绘制差值与和的关系图。

  9. 在运行以下代码后,x 的值是多少?

x <- 3
my_func <- function(y){
 x <- 5
 y+5
}
  1. 编写一个名为 compute_s_n 的函数,对于任何给定的 \(n\),计算和 \(S_n = 1² + 2² + 3² + \dots n²\)。当 \(n=10\) 时,报告和的值。

  2. 使用 s_n <- vector("numeric", 25) 定义一个大小为 25 的空数值向量 s_n,并使用 for 循环将 \(S_1, S_2, \dots S_{25}\) 的结果存储在其中。

  3. 重复练习 8,但这次使用 sapply

  4. 绘制 \(S_n\)\(n\) 的关系图。使用 \(n=1,\dots,25\) 定义的点。

  5. 确认这个和的公式是 \(S_n= n(n+1)(2n+1)/6\)


  1. r-pkgs.had.co.nz/namespace.html↩︎

4  The tidyverse

原文:rafalab.dfci.harvard.edu/dsbook-part-1/R/tidyverse.html

  1. R

  2. 4  The tidyverse

到目前为止,我们通过重新排序和通过索引进行子集化来操作向量。然而,一旦我们开始更高级的分析,数据存储的首选单位不是向量,而是数据框。在本章中,我们将学习如何直接与数据框一起工作,这极大地促进了信息的组织。我们将在这本书的大部分内容中使用数据框。我们将关注一种特定的数据格式,称为tidy,以及一组特别有助于处理tidy数据的包,称为tidyverse

我们可以通过安装和加载tidyverse包一次性加载所有 tidyverse 包:

library(tidyverse

本书中我们将学习如何实现 tidyverse 方法,但在深入细节之前,在本章中我们介绍了一些最广泛使用的 tidyverse 功能,从用于操作数据框的dplyr包和用于处理函数的purrr包开始。请注意,tidyverse 还包括一个绘图包ggplot2,我们将在本书数据可视化部分的第八章中介绍,还有在第六章中讨论的readr包以及其他许多包。在本章中,我们首先介绍tidy 数据的概念,然后展示我们如何使用 tidyverse 以这种格式处理数据框。

4.1 整洁数据

我们说一个数据表处于tidy格式,如果每一行代表一个观察结果,而列代表每个这些观察结果可用的不同变量。murders数据集是一个整洁数据框的例子。

#>        state abb region population total
#> 1    Alabama  AL  South    4779736   135
#> 2     Alaska  AK   West     710231    19
#> 3    Arizona  AZ   West    6392017   232
#> 4   Arkansas  AR  South    2915918    93
#> 5 California  CA   West   37253956  1257
#> 6   Colorado  CO   West    5029196    65

每一行代表一个州,五个列中的每一个提供与这些州相关的不同变量:名称、缩写、地区、人口和谋杀总数。

要了解相同的信息可以以不同的格式提供,请考虑以下示例:

#>       country year fertility
#> 1     Germany 1960      2.41
#> 2 South Korea 1960      6.16
#> 3     Germany 1961      2.44
#> 4 South Korea 1961      5.99
#> 5     Germany 1962      2.47
#> 6 South Korea 1962      5.79

这个 tidy 数据集提供了两个国家在多年间的生育率。这是一个 tidy 数据集,因为每一行代表一个观察结果,有三个变量:国家、年份和生育率。然而,这个数据集最初以另一种格式提供,并为dslabs包进行了重塑。最初,数据是以以下格式提供的:

#>       country 1960 1961 1962
#> 1     Germany 2.41 2.44 2.47
#> 2 South Korea 6.16 5.99 5.79

提供的信息相同,但在格式上有两个重要差异:1) 每一行包含多个观察结果,2) 其中一个变量,年份,存储在标题中。为了最优地使用 tidyverse 包,数据需要重新塑形为tidy格式,你将在本书的数据整理部分学习如何做到这一点。在此之前,我们将使用已经处于 tidy 格式的示例数据集。

虽然一开始可能不明显,但随着你阅读本书,你将开始欣赏在函数使用整洁格式进行输入和输出的框架中工作的优势。你会看到这如何使数据分析师能够专注于分析的重要方面,而不是数据的格式。

你准备好做练习 1-4 了。

4.2 优化数据框

来自tidyversedplyr包引入了执行数据框操作中最常见的一些函数,并使用相对容易记住的名称为这些函数命名。例如,要更改数据表并添加新列,我们使用mutate。要过滤数据表以仅显示行子集,我们使用filter。最后,要按选择特定列来子集数据,我们使用select

4.2.1 添加列

我们希望分析所需的所有信息都包含在数据框中。第一个任务是向我们的谋杀数据框中添加谋杀率。函数mutate将数据框作为第一个参数,将变量名称和值作为第二个参数,使用约定name = values。因此,要添加谋杀率,我们使用:

murders <- mutate(murders, rate = total/population*100000)

请注意,在这里我们在函数内部使用了totalpopulation,这些对象在我们的工作空间中没有定义。但为什么我们没有得到错误?

这是dplyr的主要功能之一。此包中的函数,如mutate,知道在第一个参数提供的数据框中查找变量。在上面的mutate调用中,total将具有murders$total中的值。这种方法使代码更易于阅读。

我们可以看到新列已添加:

head(murders)
#>        state abb region population total rate
#> 1    Alabama  AL  South    4779736   135 2.82
#> 2     Alaska  AK   West     710231    19 2.68
#> 3    Arizona  AZ   West    6392017   232 3.63
#> 4   Arkansas  AR  South    2915918    93 3.19
#> 5 California  CA   West   37253956  1257 3.37
#> 6   Colorado  CO   West    5029196    65 1.29

4.2.2 行子集

现在假设我们只想过滤数据框,只显示谋杀率低于 0.71 的条目。为此,我们使用filter函数,它将数据框作为第一个参数,然后是一个条件语句作为第二个参数。像mutate一样,我们可以在函数内部使用murders中的未引用变量名,并且它将知道我们指的是列而不是工作空间中的对象。

filter(murders, rate <= 0.71)
#>           state abb        region population total  rate
#> 1        Hawaii  HI          West    1360301     7 0.515
#> 2          Iowa  IA North Central    3046355    21 0.689
#> 3 New Hampshire  NH     Northeast    1316470     5 0.380
#> 4  North Dakota  ND North Central     672591     4 0.595
#> 5       Vermont  VT     Northeast     625741     2 0.320

4.2.3 列子集

尽管我们的数据框只有六个列,但一些数据框包含数百列。如果我们只想查看其中的一些,我们可以使用dplyrselect函数。在下面的代码中,我们选择三个列,将其分配给一个新的对象,然后过滤这个新对象:

new_dataframe <- select(murders, state, region, rate)
filter(new_dataframe, rate <= 0.71)
#>           state        region  rate
#> 1        Hawaii          West 0.515
#> 2          Iowa North Central 0.689
#> 3 New Hampshire     Northeast 0.380
#> 4  North Dakota North Central 0.595
#> 5       Vermont     Northeast 0.320

select的调用中,第一个参数murders是一个对象,但stateregionrate是变量名。

dplyr**提供了一系列辅助函数,用于根据内容选择列。例如,以下代码使用where函数仅保留数值列:

new_dataframe <- select(murders, where(is.numeric))
names(new_dataframe)
#> [1] "population" "total"      "rate"

辅助函数starts_withends_withcontainsmatchesnum_range可以用来根据列名选择列。以下是一个示例,显示所有以r开头的行:

new_dataframe <- select(murders, starts_with("r"))
names(new_dataframe)
#> [1] "region" "rate"

辅助函数everything选择所有列。

4.2.4 变量转换

函数mutate也可以用于转换变量。例如,以下代码对人口变量进行了对数转换:

mutate(murders, population = log10(population))

通常,我们需要将相同的转换应用于多个变量。函数across简化了这一操作。例如,如果我们想对人口总数和谋杀总数进行对数转换,我们可以使用:

mutate(murders, across(c(population, total), log10))

当使用across时,辅助函数非常有用。例如,如果我们想将相同的转换应用于所有数值变量:

mutate(murders, across(where(is.numeric), log10))

或者所有字符变量:

mutate(murders, across(where(is.character), tolower))

您现在可以开始练习 5-11 题了。

4.3 管道

在 R 中,我们可以通过使用所谓的管道操作符 %>% 将一个函数的结果传递给另一个函数来执行一系列操作,例如select然后filter。自 R 版本 4.1.0 起,您也可以使用|>。以下包含了一些细节。

我们在第 4.2.3 节中编写了代码,以展示具有谋杀率低于 0.71 的州的三个变量(州、地区、比率)。为此,我们定义了中间对象new_dataframe。在dplyr中,我们可以编写看起来更像是我们想要执行的操作描述的代码,而不需要中间对象:

\[\mbox{原始数据 } \rightarrow \mbox{ select } \rightarrow \mbox{ filter } \]

对于此类操作,我们可以使用管道|>。代码如下:

murders |> select(state, region, rate) |> filter(rate <= 0.71)
#>           state        region  rate
#> 1        Hawaii          West 0.515
#> 2          Iowa North Central 0.689
#> 3 New Hampshire     Northeast 0.380
#> 4  North Dakota North Central 0.595
#> 5       Vermont     Northeast 0.320

此行代码等同于第 4.2.3 节中的两行代码。这里发生了什么?

通常,管道会将管道左侧的结果发送到管道右侧函数的第一个参数。这里有一个非常简单的例子:

16 |> sqrt()
#> [1] 4

我们可以继续将值通过管道传递:

16 |> sqrt() |> log2()
#> [1] 2

上述语句等同于log2(sqrt(16))

记住,管道将值发送到第一个参数,因此我们可以定义其他参数,就像第一个参数已经定义一样:

16 |> sqrt() |> log(base = 2)
#> [1] 2

因此,当使用管道与数据框和dplyr一起时,我们不再需要指定所需的第一参数,因为我们所描述的dplyr函数都接受数据作为第一个参数。在代码中,我们这样写:

murders |> select(state, region, rate) |> filter(rate <= 0.71)

murdersselect函数的第一个参数,而新的数据框(以前称为new_dataframe)是filter函数的第一个参数。

注意,管道操作符与那些第一个参数是输入数据的函数配合得很好。tidyverse包中的dplyr函数具有这种格式,并且可以很容易地与管道操作符一起使用。

4.4 数据汇总

探索性数据分析的一个重要部分是总结数据。平均值和标准差是广泛使用的摘要统计的两个例子。通过首先将数据分成组,通常可以获取更有信息量的摘要。在本节中,我们介绍了两个新的dplyr动词,使这些计算更容易:summarizegroup_by。我们学习使用pull函数访问结果值。

4.4.1 summarize函数

dplyr**中的summarize函数提供了一种使用直观且易于阅读的代码来计算摘要统计的方法。我们从基于身高的简单示例开始。heights数据集包括课堂调查中报告的学生身高和性别。

library(dplyr
library(dslabs)

以下代码计算了女性的平均值和标准差:

s <- heights |> 
 filter(sex == "Female") |>
 summarize(average = mean(height), standard_deviation = sd(height))
s
#>   average standard_deviation
#> 1    64.9               3.76

这个函数以我们的原始数据框为输入,过滤它以仅保留女性,然后生成一个新的摘要表,其中只包含平均身高和标准差。我们可以选择结果表的列名。例如,上面我们决定使用averagestandard_deviation,但我们可以使用其他名称。

因为存储在s中的结果表是一个数据框,我们可以使用访问器$来访问其组件:

s$average
#> [1] 64.9
s$standard_deviation
#> [1] 3.76

与大多数其他dplyr函数一样,summarize了解变量名,我们可以直接使用它们。所以当我们在summarize函数的调用中写mean(height)时,函数正在访问名为“height”的列,然后计算结果数值向量的平均值。我们可以计算任何其他在向量上操作并返回单个值的摘要。

以下是我们如何使用summarize函数的另一个示例,让我们计算美国的平均谋杀率。记住,我们的数据表包括每个州的谋杀总数和人口规模,我们之前已经使用dplyr添加了一个谋杀率列:

murders <- murders |> mutate(rate = total/population*100000)

记住,美国的谋杀率不是州谋杀率的平均值:

murders |>
 summarize(rate = mean(rate))
#>   rate
#> 1 2.78

这是因为在上面的计算中,小州被赋予了与大州相同的权重。美国的谋杀率是美国谋杀总数除以美国总人口。因此,正确的计算方法是:

us_murder_rate <- murders |>
 summarize(rate = sum(total)/sum(population)*100000)
us_murder_rate
#>   rate
#> 1 3.03

这种计算按比例计算较大州的规模,从而得到更大的值。

4.4.2 多个摘要

假设我们想要从同一个变量中获取三个摘要,例如中位数、最小值和最大值。我们可以这样使用summarize

heights |> summarize(median = median(height), min = min(height), max = max(height))
#>   median min  max
#> 1   68.5  50 82.7

但我们可以使用quantile函数仅用一行代码就获得这三个值:quantile(x, c(0.5, 0, 1))返回向量x的中位数(50th 百分位数)、最小值(0th 百分位数)和最大值(100th 百分位数)。在这里,我们不能使用summarize,因为它期望每行只有一个值。相反,我们必须使用reframe函数:

heights |> reframe(quantiles = quantile(height, c(0.5, 0, 1)))
#>   quantiles
#> 1      68.5
#> 2      50.0
#> 3      82.7

然而,如果我们想为每个总结定义一个列,就像上面的 summarize 调用一样,我们必须定义一个返回类似这样的数据框的函数:

median_min_max <- function(x){
 qs <- quantile(x, c(0.5, 0, 1))
 data.frame(median = qs[1], min = qs[2], max = qs[3])
}

然后我们可以像上面一样调用 summarize

heights |> summarize(median_min_max(height))
#>   median min  max
#> 1   68.5  50 82.7

4.4.3 使用 group_by 进行分组后总结

数据探索中一个常见的操作是首先将数据分成组,然后对每个组进行总结。例如,我们可能想分别计算男性和女性身高的平均值和标准差。group_by 函数帮助我们做到这一点。

如果我们输入这个:

heights |> group_by(sex)
#> # A tibble: 1,050 × 2
#> # Groups:   sex [2]
#>   sex   height
#>   <fct>  <dbl>
#> 1 Male      75
#> 2 Male      70
#> 3 Male      68
#> 4 Male      74
#> 5 Male      61
#> # ℹ 1,045 more rows

结果看起来与 heights 并没有太大区别,除了打印对象时我们会看到 Groups: sex [2]。尽管从外观上并不立即明显,这现在是一个特殊的数据框,称为 分组数据框,并且 dplyr 函数,特别是 summarize,在作用于这个对象时会表现出不同的行为。从概念上讲,你可以将这个表格视为许多表格,它们具有相同的列,但行数不一定相同,堆叠在一个对象中。当我们对分组后的数据进行总结时,就会发生这种情况:

heights |> 
 group_by(sex) |>
 summarize(average = mean(height), standard_deviation = sd(height))
#> # A tibble: 2 × 3
#>   sex    average standard_deviation
#>   <fct>    <dbl>              <dbl>
#> 1 Female    64.9               3.76
#> 2 Male      69.3               3.61

summarize 函数会对每个分组单独应用总结。

作为另一个例子,让我们使用上面定义的 median_min_max 计算国家四个地区的谋杀率的中间值、最小值和最大值:

murders |> 
 group_by(region) |>
 summarize(median_min_max(rate))
#> # A tibble: 4 × 4
#>   region        median   min   max
#>   <fct>          <dbl> <dbl> <dbl>
#> 1 Northeast       1.80 0.320  3.60
#> 2 South           3.40 1.46  16.5 
#> 3 North Central   1.97 0.595  5.36
#> 4 West            1.29 0.515  3.63

4.4.4 使用 pull 提取变量

在 4.4.1 节 中定义的 us_murder_rate 对象只代表一个数字。然而,我们将其存储在一个数据框中:

class(us_murder_rate)
#> [1] "data.frame"

因为,像大多数 dplyr 函数一样,summarize 总是返回一个数据框。

如果我们想使用需要数值型值的函数,这个结果可能会出现问题。这里我们展示了一个在通过管道使用数据时访问存储的值的实用技巧:当一个数据对象通过管道传递时,可以使用 pull 函数访问该对象及其列。要从原始数据表中获取一个数字,我们只需多写一行代码,可以输入:

us_murder_rate <- murders |> 
 summarize(rate = sum(total)/sum(population)*100000) |>
 pull(rate)
 us_murder_rate
#> [1] 3.03

现在是一个数值型:

class(us_murder_rate)
#> [1] "numeric"

4.5 排序

在检查数据集时,按不同的列对数据框进行排序通常很方便。我们了解 ordersort 函数,但为了对整个数据框进行排序,dplyr 函数 arrange 非常有用。例如,这里我们按人口规模对州进行排序:

murders |> arrange(population) |> head()
#>                  state abb        region population total   rate
#> 1              Wyoming  WY          West     563626     5  0.887
#> 2 District of Columbia  DC         South     601723    99 16.453
#> 3              Vermont  VT     Northeast     625741     2  0.320
#> 4         North Dakota  ND North Central     672591     4  0.595
#> 5               Alaska  AK          West     710231    19  2.675
#> 6         South Dakota  SD North Central     814180     8  0.983

使用 arrange,我们可以决定按哪个列进行排序。例如,要按谋杀率对州进行排序,我们会使用 arrange(rate)

注意默认行为是按升序排序。在 dplyr 中,函数 desc 将向量转换成降序。要按谋杀率降序排序数据框,我们可以输入:

murders |> arrange(desc(rate)) 

4.5.1 嵌套排序

如果我们按有并列的列进行排序,我们可以使用第二个列来打破并列。同样,第三个列可以用来打破第一和第二之间的并列,依此类推。这里我们按 region 排序,然后在区域内按谋杀率排序:

murders |> 
 arrange(region, rate) |> 
 head()
#>           state abb    region population total  rate
#> 1       Vermont  VT Northeast     625741     2 0.320
#> 2 New Hampshire  NH Northeast    1316470     5 0.380
#> 3         Maine  ME Northeast    1328361    11 0.828
#> 4  Rhode Island  RI Northeast    1052567    16 1.520
#> 5 Massachusetts  MA Northeast    6547629   118 1.802
#> 6      New York  NY Northeast   19378102   517 2.668

4.5.2 顶部 \(n\)

在上面的代码中,我们使用了head函数来避免整个数据集填充整个页面。例如,使用arrange(desc(rate))然后使用head将按顺序显示谋杀率最高的 6 个州。相反,要查看具有最高谋杀率的具体数量的观测值,我们可以使用slice_max函数。这个函数将其第一个参数作为数据框,第二个参数是要显示的行数,第三个参数是要过滤的变量。以下是一个查看前 5 行示例:

murders |> slice_max(rate, n = 5)
#>                  state abb        region population total  rate
#> 1 District of Columbia  DC         South     601723    99 16.45
#> 2            Louisiana  LA         South    4533372   351  7.74
#> 3             Missouri  MO North Central    5988927   321  5.36
#> 4             Maryland  MD         South    5773552   293  5.07
#> 5       South Carolina  SC         South    4625364   207  4.48

slice_min函数做的是同样的事情,但是针对最小值。

你准备好做 12-19 题练习了。

4.6 Tibbles

要使用 tidyverse,数据必须存储在数据框中。我们在第 2.3.1 节中介绍了数据框,并在整本书中使用了murders数据框。在第 4.4.3 节中,我们介绍了group_by函数,它允许在计算汇总统计之前对数据进行分层。但是,数据框中存储了分组信息在哪里?

murders |> group_by(region)
#> # A tibble: 51 × 6
#> # Groups:   region [4]
#>   state      abb   region population total  rate
#>   <chr>      <chr> <fct>       <dbl> <dbl> <dbl>
#> 1 Alabama    AL    South     4779736   135  2.82
#> 2 Alaska     AK    West       710231    19  2.68
#> 3 Arizona    AZ    West      6392017   232  3.63
#> 4 Arkansas   AR    South     2915918    93  3.19
#> 5 California CA    West     37253956  1257  3.37
#> # ℹ 46 more rows

注意,没有包含此信息的列。但是,如果你仔细查看上面的输出,你会看到A tibble后面跟着维度。我们可以使用以下方法了解返回对象的类别:

murders |> group_by(region) |> class()
#> [1] "grouped_df" "tbl_df"     "tbl"        "data.frame"

tbl,发音为“tibble”,是一种特殊的数据框。group_bysummarize函数总是返回这种类型的数据框。group_by函数返回一种特殊的tbl,即grouped_df。我们将在稍后详细介绍这些。为了保持一致性,dplyr操作符(selectfiltermutatearrange)保留输入的类别:如果它们接收一个常规数据框,则返回一个常规数据框;如果它们接收一个 tibble,则返回一个 tibble。但是,tibbles 是 tidyverse 中首选的格式,因此 tidyverse 函数从头开始生成数据框时返回一个 tibble。例如,在第六章中,我们将看到 tidyverse 函数用于导入数据时创建 tibbles。

4.7 Tibbles 与数据框

Tibbles 与数据框非常相似。实际上,你可以把它们看作是数据框的现代版本。尽管如此,它们之间还有一些重要的区别,我们将在下面描述。

(1) Tibbles 显示得更好

Tibbles 的打印方法比数据框的可读性更好。为了看到这一点,比较一下输入murders和将murders转换为 tibble 后的输出。我们可以使用as_tibble(murders)来完成这个操作。如果在 RStudio 中使用,tibble 的输出会根据你的窗口大小进行调整。为了看到这一点,改变你的 R 控制台宽度,注意显示的列数是更多还是更少。

如果你从数据框中提取列,你可能会得到一个不是数据框的对象,例如向量或标量。例如:

class(murders[,4])
#> [1] "numeric"

不是一个数据框。使用 tibbles 就不会发生这种情况:

class(as_tibble(murders)[,4])
#> [1] "tbl_df"     "tbl"        "data.frame"

这在 tidyverse 中很有用,因为函数需要数据框作为输入。

使用 tibbles,如果你想访问定义列的向量,而不是得到一个数据框,你需要使用访问器 $

class(as_tibble(murders)$population)
#> [1] "numeric"

一个相关特性是,如果你尝试访问一个不存在的列,tibbles 会给出警告。如果我们不小心将 Population 写成 population 而不是这样:

murders$Population
#> NULL

返回一个没有警告的 NULL,这可能会使调试变得更困难。相比之下,如果我们尝试使用 tibble,我们会得到一个有用的警告:

as_tibble(murders)$Population
#> Warning: Unknown or uninitialised column: `Population`.
#> NULL

(2) Tibbles 可以有复杂条目

虽然数据框的列需要是数字、字符串或逻辑值的向量,但 tibbles 可以有更复杂的对象,例如列表或函数。此外,我们还可以使用函数创建 tibbles:

tibble](https://tibble.tidyverse.org/reference/tibble.html)(id = [c(1, 2, 3), func = c(mean, median, sd))
#> # A tibble: 3 × 2
#>      id func 
#>   <dbl> <list>
#> 1     1 <fn> 
#> 2     2 <fn> 
#> 3     3 <fn>

(3) Tibbles 可以分组

函数 group_by 返回一种特殊的 tibble:分组 tibble。这个类存储了信息,让你知道哪些行属于哪个组。tidyverse 函数,特别是 summarize 函数,都了解分组信息。

4.7.1 创建 tibbles

有时我们创建自己的数据框是有用的。要在 tibble 格式下创建数据框,你可以通过使用 tibble 函数来实现。

grades <- tibble](https://tibble.tidyverse.org/reference/tibble.html)(names = [c("John", "Juan", "Jean", "Yao"), 
 exam_1 = c(95, 80, 90, 85), 
 exam_2 = c(90, 85, 85, 90))

请注意,基础 R(未加载任何包)有一个 data.frame 函数,可以用来创建常规数据框而不是 tibble。

grades <- data.frame(names = c("John", "Juan", "Jean", "Yao"), 
 exam_1 = c(95, 80, 90, 85), 
 exam_2 = c(90, 85, 85, 90))

要将常规数据框转换为 tibble,你可以使用 as_tibble 函数。

as_tibble](https://tibble.tidyverse.org/reference/as_tibble.html)(grades) |> [class()
#> [1] "tbl_df"     "tbl"        "data.frame"

4.8 占位符

使用管道 |> 的一大优点是我们不需要在操作数据框时不断为新对象命名。管道左侧的对象用作管道右侧函数的第一个参数。但如果我们想将其作为非第一个参数传递给右侧函数怎么办?答案是占位符运算符 _(对于 %>% 管道,占位符是 .)。下面是一个简单示例,它将 base 参数传递给 log 函数。以下三个是等价的:

log(8, base = 2)
2 |> log(8, base = _)
2 %>%](https://magrittr.tidyverse.org/reference/pipe.html) [log(8, base = .)

4.9 purrr** 包

在 第 3.5 节 我们学习了 sapply 函数,它允许我们将相同的函数应用于向量的每个元素。我们构建了一个函数,并使用 sapply 来计算 n 的前 n 个整数的和,如下所示:

compute_s_n <- function(n) {
 sum(1:n)
}
n <- 1:25
s_n <- sapply(n, compute_s_n)

这种类型的操作,将相同的函数或过程应用于对象中的元素,在数据分析中相当常见。purrr包包括类似于sapply但与其他 tidyverse 函数更好地交互的函数。主要优势是我们可以更好地控制函数的输出类型。相比之下,sapply可以返回几种不同的对象类型;例如,我们可能期望一行代码的结果是数值,但在某些情况下sapply可能会将我们的结果转换为字符。purrr函数永远不会这样做:它们将返回指定类型的对象,或者在不可能的情况下返回错误。

我们将要学习的第一个purrr函数是map,它的工作方式与sapply非常相似,但始终,没有例外,返回一个列表:

library(purrr)
s_n <- map(n, compute_s_n)
class(s_n)
#> [1] "list"

如果我们想要一个数值向量,我们可以使用map_dbl,它总是返回一个数值向量。

s_n <- map_dbl(n, compute_s_n)
class(s_n)
#> [1] "numeric"

这会产生与上面显示的sapply调用相同的结果。

一个特别有用的purrr函数,用于与其他 tidyverse 交互,是map_df,它总是返回一个 tibble 数据框。然而,被调用的函数需要返回一个带有名称的向量或列表。因此,以下代码会导致Argument 1 must have names错误:

s_n <- map_df(n, compute_s_n)

函数需要返回一个数据框才能使这起作用:

compute_s_n <- function(n) {
 tibble](https://tibble.tidyverse.org/reference/tibble.html)(sum = [sum(1:n))
}
s_n <- map_df(n, compute_s_n)

purrr包提供了更多这里没有涵盖的功能。有关更多详细信息,您可以查阅这个在线资源¹。

4.10 Tidyverse 条件语句

典型数据分析通常涉及一个或多个条件操作。在第 3.1 节中,我们描述了ifelse函数,我们将在本书中广泛使用它。在本节中,我们介绍了两个dplyr函数,它们提供了执行条件操作的功能。

4.10.1 case_when

case_when函数对于向量化条件语句很有用。它与ifelse类似,但可以输出任意数量的值,而不仅仅是TRUEFALSE。以下是一个将数字分为负数、正数和 0 的示例:

x <- c(-2, -1, 0, 1, 2)
case_when(x < 0 ~ "Negative", 
 x > 0 ~ "Positive", 
 TRUE  ~ "Zero")
#> [1] "Negative" "Negative" "Zero"     "Positive" "Positive"

这个函数的常见用途是根据现有变量定义分类变量。例如,假设我们想要比较四个州组(新英格兰西海岸南部其他)的谋杀率。对于每个州,我们需要询问它是否在新英格兰,如果不是,我们询问它是否在西海岸,如果不是,我们询问它是否在南部,如果不是,我们将其归为其他。以下是使用case_when来完成此操作的示例:

murders |> 
 mutate(group = case_when(
 abb %in% c("ME", "NH", "VT", "MA", "RI", "CT") ~ "New England",
 abb %in% c("WA", "OR", "CA") ~ "West Coast",
 region == "South" ~ "South",
 TRUE ~ "Other")) |>
 group_by(group) |>
 summarize(rate = sum(total)/sum(population)*10⁵) 
#> # A tibble: 4 × 2
#>   group        rate
#>   <chr>       <dbl>
#> 1 New England  1.72
#> 2 Other        2.71
#> 3 South        3.63
#> 4 West Coast   2.90

4.10.2 between

数据分析中的一个常见操作是确定一个值是否在区间内。我们可以使用条件语句来检查这一点。例如,为了检查向量x的元素是否在ab之间,我们可以输入

x >= a & x <= b

然而,这可能会变得繁琐,尤其是在 tidyverse 方法中。between函数执行相同的操作。

between(x, a, b)

4.11 练习

  1. 检查内置数据集co2。以下哪项是正确的:

  2. co2是整洁数据:每行有一个年份。

  3. co2不是整洁数据:我们需要至少有一个字符向量列。

  4. co2不是整洁数据:它是一个矩阵而不是数据框。

  5. co2不是整洁数据:为了变得整洁,我们需要将其整理成三列(年份、月份和值),然后每个 co2 观测值将有一行。

  6. 检查内置数据集ChickWeight。以下哪项是正确的:

  7. ChickWeight不是整洁数据:每只鸡有多于一行。

  8. ChickWeight是整洁数据:每个观测值(一个重量)由一行表示。这个测量的鸡是其中一个变量。

  9. ChickWeight不是整洁数据:我们缺少年份列。

  10. ChickWeight是整洁数据:它存储在数据框中。

  11. 检查内置数据集BOD。以下哪项是正确的:

  12. BOD不是整洁数据:它只有六行。

  13. BOD不是整洁数据:第一列只是一个索引。

  14. BOD是整洁数据:每行是一个观测值,包含两个值(时间和需求)

  15. BOD是整洁数据:所有小型数据集按定义都是整洁的。

  16. 以下哪个内置数据集是整洁的(您可以选择多个):

  17. BJsales

  18. EuStockMarkets

  19. DNase

  20. Formaldehyde

  21. Orange

  22. UCBAdmissions

  23. 加载dplyr包和谋杀数据集。

library(dplyr
library(dslabs)

您可以使用dplyr函数mutate添加列。此函数了解列名,在函数内部您可以不使用引号调用它们:

murders <- mutate(murders, population_in_millions = population/10⁶)

我们可以写population而不是murders$populationmutate函数知道我们在从murders中获取列。

使用mutate函数添加一个名为rate的谋杀率列,其值如上例代码所示,每 10 万人谋杀率。确保您像示例代码中那样重新定义murdersmurders <- [your code]),这样我们就可以继续使用这个变量。

  1. 如果rank(x)给出x从低到高的排名,则rank(-x)给出从高到低的排名。使用mutate函数添加一个包含谋杀率从高到低排名的rank列。确保您重新定义murders,这样我们就可以继续使用这个变量。

  2. 使用dplyr,我们可以用select来只显示某些列。例如,使用以下代码我们只会显示州和人口规模:

select(murders, state, population)

使用select来显示murders中的州名和缩写。不要重新定义murders,只需显示结果。

  1. dplyr函数filter用于选择数据框中特定的行以保留。与用于列的select不同,filter用于行。例如,您可以像这样显示仅纽约的行:
filter(murders, state == "New York")

您可以使用其他逻辑向量来过滤行。

使用 filter 显示谋杀率最高的前 5 个州。从现在开始,不要更改 murders 数据集,只需显示结果。记住,你可以根据 rank 列进行筛选。

  1. 我们可以使用 != 操作符来删除行。例如,要删除佛罗里达州,我们会这样做:
no_florida <- filter(murders, state != "Florida")

创建一个新的数据框 no_south,删除南部地区的州。这个类别中有多少个州?你可以使用 nrow 函数来做这件事。

  1. 我们还可以使用 %in%dplyr 进行筛选。因此,你可以这样查看纽约和德克萨斯州的数据:
filter(murders, state %in% c("New York", "Texas"))

创建一个新的数据框 murders_nw,只包含东北部和西部的州。这个类别中有多少个州?

  1. 假设你想要住在东北部或西部 并且 谋杀率要低于 1。我们想要查看满足这些条件的州的数据。注意,你可以在 filter 中使用逻辑运算符如 &。以下是一个示例,其中我们筛选出仅包含东北部小州的例子。
filter(murders, population < 5000000 & region == "Northeast")

确保 murders 已经使用 raterank 定义,并且仍然包含所有州。创建一个名为 my_states 的表格,其中包含满足以下两个条件的行:它位于东北部或西部,谋杀率低于 1。使用 select 仅显示州名、率和排名。

  1. 管道 |> 可以用来按顺序执行操作,而无需定义中间对象。首先,重新定义 murders 以包括率和排名。
murders <- mutate(murders, rate =  total/population*100000, 
 rank = rank(-rate))

在上一题的解决方案中,我们做了以下操作:

my_states <- filter(murders, region %in% c("Northeast", "West") & 
 rate < 1)
 select(my_states, state, rate, rank)

管道 |> 允许我们按顺序执行操作,而无需定义中间变量 my_states。因此,我们可以在同一行中突变和选择,如下所示:

mutate(murders, rate =  total/population*100000, 
 rank = rank(-rate)) |>
 select(state, rate, rank)

注意,select 的第一个参数不再是数据框。第一个参数被认为是 |> 之前执行的操作的结果。

重复上一个练习,但现在不是创建一个新的对象,而是显示结果,并且只包括州、率和排名列。使用管道 |> 在一行内完成此操作。

my_states <- murders |>
 mutate SOMETHING |> 
 filter SOMETHING |> 
 select SOMETHING
  1. 对于第 13-19 题的练习,我们将使用美国国家健康统计中心(NCHS)收集的调查数据。自 1960 年代以来,该中心进行了一系列健康和营养调查。从 1999 年开始,每年大约有 5,000 名各年龄段的个人接受采访,并完成调查的健康检查部分。部分数据通过 NHANES 包提供。一旦安装了 NHANES 包,你可以这样加载数据:
library(NHANES)

NHANES** 数据有许多缺失值。在 R 中,如果输入向量的任何条目是 NA,则 meansd 函数将返回 NA。以下是一个示例:

library(dslabs)
mean(na_example)
#> [1] NA
sd(na_example)
#> [1] NA

要忽略 NA,我们可以使用 na.rm 参数:

mean(na_example, na.rm = TRUE)
#> [1] 2.3
sd(na_example, na.rm = TRUE)
#> [1] 1.22

现在让我们探索 NHANES 数据。

我们将提供一些关于血压的基本事实。首先,让我们选择一个组来设定标准。我们将使用 20 至 29 岁的女性。AgeDecade 是一个具有这些年龄的类别变量。请注意,类别编码为 " 20-29",前面有一个空格!BPSysAve 变量中保存的收缩压的平均值和标准差是多少?将其保存到名为 ref 的变量中。

提示:使用 filtersummarize,在计算平均值和标准差时使用 na.rm = TRUE 参数。您还可以使用 filter 过滤 NA 值。

  1. 使用管道操作符,将平均值赋给名为 ref_avg 的数值变量。提示:使用前一个练习中的代码,然后使用 pull

  2. 现在报告同一组的最大值和最小值。

  3. 计算女性的平均值和标准差,但分别按年龄组计算,而不是像练习 13 中那样选择一个十年。注意,年龄组由 AgeDecade 定义。提示:与其按年龄和性别过滤,不如按 Gender 过滤,然后使用 group_by

  4. 对男性重复练习 16。

  5. 对于 40-49 岁的男性,比较 Race1 变量中报告的种族间的收缩压,并按平均收缩压从低到高排序结果表。

  6. 加载 murders 数据集。以下哪个说法是正确的?

  7. murders 是整洁格式,存储在 tibble 中。

  8. murders 是整洁格式,存储在数据框中。

  9. murders 不是整洁格式,存储在 tibble 中。

  10. murders 不是整洁格式,存储在数据框中。

  11. 使用 as_tibblemurders 数据表转换为 tibble 并将其保存在名为 murders_tibble 的对象中。

  12. 使用 group_by 函数将 murders 转换为按地区分组的 tibble。

  13. 编写与以下代码等价的 tidyverse 代码:

exp(mean(log(murders$population)))
  • 使用管道操作符,确保每个函数调用不带参数。使用点操作符访问人口。提示:代码应从 murders |> 开始。
  1. 使用 map_df 创建一个包含三个列名为 ns_ns_n_2 的数据框。第一列应包含从 1 到 100 的数字。第二列和第三列应分别包含从 1 到 \(n\) 的数字之和,其中 \(n\) 是行号。

  1. jennybc.github.io/purrr-tutorial/↩︎

5  data.table

原文:rafalab.dfci.harvard.edu/dsbook-part-1/R/data-table.html

  1. R

  2. 5  data.table

在这本书中,我们使用 tidyverse 包,主要是因为它们提供了对初学者有益的易读性。这种易读性使我们能够强调数据分析与统计概念。然而,虽然 tidyverse 对初学者友好,但 R 中还有其他更高效的方法,可以更有效地处理更大的数据集。其中一个这样的包是 data.table,它在 R 社区中得到了广泛的应用。我们将在本章简要介绍 data.table。对于那些想要深入了解的人,有大量的在线资源,包括提到的介绍¹。

5.1 精炼数据表

data.table 是一个独立的包,需要安装。一旦安装,我们就需要加载它以及我们将使用的其他包:

library(dplyr
library(dslabs)
library(data.table)

我们将提供示例代码,展示 data.table 对应于 第四章 中展示的 dplyrmutatefilterselectgroup_bysummarize 的方法。就像那章一样,我们将使用 murders 数据集:

使用 data.table 的第一步是将数据框转换为 data.table 对象,使用 as.data.table 函数:

murders_dt <- as.data.table(murders)

如果没有这个初始步骤,下面展示的大多数方法将无法工作。

5.1.1 列子集

使用 data.table 进行选择的方式与子集矩阵类似。当使用 dplyr 时,我们写

select(murders, state, region)

data.table 中我们使用

murders_dt, [c("state", "region")] 

我们还可以使用 .() data.table 语法来提醒 R,括号内的变量是列名,而不是 R 环境中的对象。所以上面的代码也可以写成这样:

murders_dt[, .(state, region)] 

5.1.2 添加或转换变量

我们通过这个例子学习了如何使用 dplyrmutate 函数:

murders <- mutate(murders, rate = total / population * 100000)

data.table** 使用一种避免新赋值(通过引用更新)的方法。这可以帮助处理占用你电脑大部分内存的大数据集。data.table:= 函数允许我们这样做:

murders_dt[, rate := total / population * 100000]

这向表中添加了一个新的列,rate。注意,就像在 dplyr 中一样,我们使用了 totalpopulation 而没有引号。

要定义新的多个列,我们可以使用带有多个参数的 := 函数:

murders_dt, ":="(rate = total / population * 100000, rank = [rank(population))]

5.1.3 引用与复制

data.table** 包旨在避免浪费内存。所以如果你复制了一个表格,就像这样:

x <- data.table(a = 1)
y <- x

y 实际上是在引用 x,它不是一个新对象:y 只是 x 的另一个名称。除非你改变 y,否则不会创建新对象。然而,:= 函数通过引用来改变,所以如果你改变 x,不会创建新对象,y 仍然只是 x 的另一个名称:

x[, a := 2]
y
#>        a
#>    <num>
#> 1:     2

你还可以这样改变 x

y[, a := 1]
x
#>        a
#>    <num>
#> 1:     1

为了避免这种情况,你可以使用 copy 函数,该函数强制创建实际的副本:

x <- data.table(a = 1)
y <- copy(x)
x[, a := 2]
y
#>        a
#>    <num>
#> 1:     1

请注意,函数 as.data.table 会创建正在转换的数据框的副本。然而,如果处理大型数据框,使用 setDT 避免这一点是有帮助的:

x <- data.frame(a = 1)
setDT(x)

请注意,因为没有创建副本,以下代码不会创建新对象:

x <- data.frame(a = 1)
y <- setDT(x)

对象 xy 引用了相同的数据表:

x[, a := 2]
y
#>        a
#>    <num>
#> 1:     2

5.1.4 行级子集

使用 dplyr,我们这样过滤:

filter(murders, rate <= 0.7)

使用 data.table,我们再次使用类似于子集矩阵的方法,但与 dplyr 一样,data.table 知道 rate 指的是列名,而不是 R 环境中的对象:

murders_dt[rate <= 0.7]

注意,我们可以将过滤和选择合并为一个简洁的命令。以下是那些速率低于 0.7 的状态名称和速率。

murders_dt[rate <= 0.7, .(state, rate)]
#>            state  rate
#>           <char> <num>
#> 1:        Hawaii 0.515
#> 2:          Iowa 0.689
#> 3: New Hampshire 0.380
#> 4:  North Dakota 0.595
#> 5:       Vermont 0.320

这比 dplyr 方法更紧凑:

murders |> filter(rate <= 0.7) |> select(state, rate)

您已准备好做练习 1-7。

5.2 总结数据

作为一个例子,我们将使用 heights 数据集:

heights_dt <- as.data.table(heights)

data.table 中,我们可以在 .() 内调用函数,它们将应用于列。因此,相当于:

s <- heights |> summarize(avg = mean(height), sd = sd(height))

dplyr 中是以下内容在 data.table 中:

s <- heights_dt, .(avg = [mean(height), sd = sd(height))]

请注意,这允许一种紧凑的子集和汇总方式。而不是:

s <- heights |> 
 filter(sex == "Female") |>
 summarize(avg = mean(height), sd = sd(height))

我们可以写成:

s <- heights_dtsex == "Female", .(avg = [mean(height), sd = sd(height))]

5.2.1 多个总结

在 第四章 中,我们定义了以下函数,以允许在 dplyr 中进行多列摘要:

median_min_max <- function(x){
 qs <- quantile(x, c(0.5, 0, 1))
 data.frame(median = qs[1], minimum = qs[2], maximum = qs[3])
}

data.table 中,我们通过 .() 调用函数来获取三个数字摘要:

heights_dt[, .(median_min_max(height))]

5.2.2 分组后总结

dplyr 中的 group_by 后跟 summarizedata.table 中一行完成。我们只需添加 by 参数,根据分类变量的值将数据拆分为组:

heights_dt, .(avg = [mean(height), sd = sd(height)), by = sex]
#>       sex   avg    sd
#>    <fctr> <num> <num>
#> 1:   Male  69.3  3.61
#> 2: Female  64.9  3.76

5.3 排序

我们可以使用与过滤相同的方法对行进行排序。以下是按谋杀率排序的州:

murders_dt[order(population)]

要按降序对表进行排序,我们可以按 population 的负值排序或使用 decreasing 参数:

murders_dt[order(population, decreasing = TRUE)] 

同样,我们可以通过包含多个变量来进行嵌套排序:

murders_dt[order(region, rate)] 

您已准备好做练习 8-12。

5.4 练习

  1. 加载 data.table 包和 murders 数据集,并将其转换为 data.table 对象:
library(data.table)
library(dslabs)
murders_dt <- as.data.table(murders)

记住,您可以这样添加列:

murders_dt[, population_in_millions := population / 10⁶]

添加一个名为 ratemurders 列,其每 10 万人谋杀率为上例代码所示。

  1. 添加一个包含排名的列 rank,从最高到最低谋杀率排序。

  2. 如果我们只想显示状态和种群大小,我们可以使用:

murders_dt[, .(state, population)] 

显示 murders 中的州名称和缩写。

  1. 您可以这样显示纽约行:
murders_dt[state == "New York"]

您可以使用其他逻辑向量来过滤行。

显示谋杀率最高的前 5 个州的名称。从现在开始,不要更改 murders 数据集,只需显示结果。请记住,您可以根据 rank 列进行过滤。

  1. 我们可以使用 != 运算符删除行。例如,要删除佛罗里达,我们会这样做:
no_florida <- murders_dt[state != "Florida"]

创建一个新的数据框no_south,移除南部地区的州。这个类别中有多少个州?你可以使用nrow函数来完成这个操作。

  1. 我们也可以使用%in%进行过滤。因此,你可以如下查看纽约和德克萨斯州的数据:
murders_dtstate [%in% c("New York", "Texas")]

创建一个新的数据框murders_nw,只包含东北部和西部的州。这个类别中有多少个州?

  1. 假设你想要住在东北部或西部并且谋杀率要低于 1。我们想要查看满足这些选项的州的资料。注意,你可以使用逻辑运算符与filter一起使用。以下是一个示例,其中我们过滤以仅保留东北地区的较小州。
murders_dt[population < 5000000 & region == "Northeast"]

确保murders已经被定义为带有raterank,并且仍然包含所有州。创建一个名为my_states的表格,包含满足以下两个条件的行的州:它们位于东北部或西部,谋杀率低于 1。只显示州名、率和排名。

对于第 8-12 题,我们将使用NHANES数据。

library(NHANES)
  1. 我们将提供一些关于血压的基本事实。首先,让我们选择一个组来设定标准。我们将使用 20 至 29 岁的女性。AgeDecade是一个具有这些年龄的类别变量。请注意,类别编码为" 20-29",前面有一个空格!使用data.table包计算存储在BPSysAve变量中的收缩压的平均值和标准差。将其保存到名为ref的变量中。

  2. 报告同一组的最大和最小值。

  3. 计算女性的平均值和标准差,但对于每个年龄组分别计算,而不是像第 8 题那样选择一个十年为一个组。注意,年龄组由AgeDecade定义。请注意,类别编码为" 20-29",前面有一个空格!使用data.table包计算存储在BPSysAve变量中的收缩压的平均值和标准差。将其保存到名为ref的变量中。

  4. 为男性重复第 10 题的练习。

  5. 对于 40-49 岁的男性,比较在Race1变量中报告的种族间收缩压。将结果表按最低到最高的平均收缩压排序。


  1. cran.r-project.org/web/packages/data.table/vignettes/datatable-intro.html↩︎

6 导入数据

原文:rafalab.dfci.harvard.edu/dsbook-part-1/R/importing-data.html

  1. R

  2. 6 导入数据

我们一直在使用已经存储为 R 对象的数据集。在数据分析工作中,我们很少这么幸运,通常需要将数据从文件、数据库或其他来源导入到 R 中。目前,存储和共享数据以供分析最常见的方式是通过电子表格。电子表格以行和列的形式存储数据。它基本上是数据框的文件版本。当将此类表格保存到计算机文件时,需要一种方法来定义何时一行或一列结束,另一行或列开始。这反过来又定义了存储单个值的单元格。以下是一个示例,如果我们用基本的文本编辑器打开逗号分隔的文件,它看起来会是什么样子:

图片

在本章中,我们概述了如何将数据从文件加载到 R 中。首先,确定文件位置至关重要;因此,我们简要介绍了文件路径和工作目录(在第十八章详细描述)。接下来,我们深入探讨文件类型(文本或二进制)和编码(如 ASCII 和 Unicode),这两者对于数据导入都是必不可少的。然后,我们介绍了一些流行的数据导入函数,称为解析器。最后,我们提供了一些关于如何在电子表格中存储数据的技巧。关于从网站或 PDF 中提取数据等高级主题将在本书的数据整理部分进行讨论。

6.1 导航和管理文件系统

从电子表格导入数据的第一步是找到包含数据的文件。虽然我们不推荐这样做,但你可以使用类似于打开 Microsoft Excel 文件的方法,通过点击 RStudio 的“文件”菜单,然后点击“导入数据集”,接着通过文件夹找到文件。然而,我们更倾向于编写代码而不是使用点击操作。我们需要详细学习的核心概念在第十八章中描述。在这里,我们提供了一些基本概念的概述。

6.1.1 文件系统

你可以将你的计算机文件系统想象成一系列嵌套的文件夹,每个文件夹都包含其他文件夹和文件。我们将文件夹称为目录。我们将包含所有其他目录的文件夹称为根目录。我们将我们当前所在的目录称为工作目录。因此,随着你通过文件夹移动,工作目录会发生变化:可以将其视为你的当前位置。

6.1.2 相对路径和完整路径

文件 路径 是一系列目录名称,可以将其视为点击文件夹的指令,以及找到文件的顺序。如果这些指令是从根目录开始查找文件,我们将其称为 完整路径。如果指令是从工作目录开始查找文件,我们将其称为 相对路径。第 18.3 节提供了更多关于这个主题的详细信息。

要在你的系统中查看完整路径的示例,请输入以下内容:

[system.file(package = "dslabs")
#> 1] "/Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/library/dslabs"

请注意,输出将在不同的计算机上有所不同。system.file 函数找到在安装 dslabs 包时添加到系统中的文件的完整路径。由斜杠分隔的字符串是目录名称。第一个斜杠代表根目录,我们知道这是一个完整路径,因为它以斜杠开头。

我们可以使用 list.files 函数来显示任何目录中的文件和目录名称。例如,以下是 dslabs 包目录中的文件:

dir <- [system.file(package = "dslabs")
list.files(dir)
#>  1] "data"        "DESCRIPTION" "extdata"     "help" 
#>  [5] "html"        "INDEX"       "Meta"        "NAMESPACE" 
#>  [9] "R"           "script"

请注意,这些路径不以斜杠开头,这意味着它们是 相对路径。这些相对路径给出了如果 dir 中存储的路径是我们的工作目录,文件或目录的位置。

在日常数据分析工作中,你不太可能用到 system.file 函数。我们在这个部分介绍它,因为它便于共享用于练习的电子表格。这些电子表格位于 extdata 目录中。

6.1.3 工作目录

我们强烈建议在代码中只使用相对路径。原因是完整路径对于你的计算机是唯一的,你希望代码是可移植的。如果你想使用 getwd 函数知道工作目录的完整路径。如果你需要更改工作目录,你可以使用 setwd 函数,或者你可以通过点击 RStudio 上的“会话”来更改它。

在开始一个项目时,选择一个目录来存储所有相关文件,并将其设置为分析的工作目录。这种做法确保当你使用相对路径在导入文件的函数中时,R 会自动在当前工作目录中搜索文件。有关在 RStudio 中组织项目的更多详细信息,请参阅第二十章。

6.1.4 生成路径名

file.path 函数将字符组合成完整的路径,确保与各自的操作系统兼容。Linux 和 Mac 使用正斜杠 /,而 Windows 使用反斜杠 \ 来分隔目录。这个函数很有用,因为通常你希望使用变量来定义路径。以下是一个示例,它构建了一个包含谋杀数据的电子表格的完整路径。在这里,变量 dir 包含 dslabs 包的完整路径,而 extdata/murders.csv 是电子表格的相对路径,如果 dir 被视为工作目录的话。

dir <- [system.file(package = "dslabs")
file_path <- file.path(dir, "extdata/murders.csv")

您可以使用file.copy函数将包含完整路径的file_path文件复制到您的当前工作目录中:

file.copy(file_path, "murders.csv")
#> [1] TRUE

如果文件复制成功,此函数将返回TRUE。请注意,我们使用了相同的文件名作为目标文件,但我们可以给它任何我们想要的名称。如果目标目录中已存在同名文件,复制将失败。您可以使用overwrite参数更改此行为。

6.2 文件类型

对于大多数数据分析应用,文件通常可以分为两大类:文本文件和二进制文件。在本节中,我们描述了这两种类型最广泛使用的格式以及识别它们最佳的方法。在最后一小节中,我们描述了了解文件编码的重要性。

对于本节以及以下章节,我们假设您已将murders.csv文件复制到您的当前工作目录中。您可以使用上一节末尾的代码来完成此操作。* *### 6.2.1 文本文件

您已经处理过文本文件。例如,所有您的 R 脚本和 Quarto 文件,以及用于创建此书的 Quarto 文件,都是文本文件。这些文件的一个大优点是我们可以轻松地“查看”它们,而无需购买任何特殊软件或遵循复杂的说明。任何文本编辑器都可以用来检查文本文件,包括免费编辑器,如 RStudio 或 nano。为了验证这一点,请尝试使用 RStudio 的“打开文件”工具打开 csv 文件。您应该能够在您的编辑器中看到内容。

当使用文本文件存储电子表格时,行断行符用于分隔行,而一个预定义的字符,称为分隔符,用于在行内分隔列。最常见的分隔符是逗号(,)、分号(;)、空格()和制表符(预设的空格数或`\t`)。将这些文件读入 R 时,使用的方法略有不同,因此我们需要知道使用了哪种分隔符。在某些情况下,分隔符可以从文件后缀推断出来。例如,以`csv`结尾的文件预期是逗号分隔的,而以`tsv`结尾的文件预期是制表符分隔的。然而,对于以`txt`结尾的文件,推断分隔符会更困难。因此,我们建议查看文件而不是从后缀推断。您可以使用`readLines`函数在 R 中查看文件中的任意行数:

PRE4] [readLines("murders.csv", n = 3) #> [1] "state,abb,region,population,total" #> [2] "Alabama,AL,South,4779736,135" #> [3] "Alaska,AK,West,710231,19" ```r This immediately reveals that the file is indeed comma delimited. It also reveals that the file has a header: the first row contains column names rather than data. This is also important to know. Most parsers assume the file starts with a header, but not all files have one. ````

*6.2.2 二进制文件*

*在文本编辑器中打开图像文件,如 jpg 或 png,或者在 R 中使用`readLines`,将不会显示可理解的内容,因为这些是*二进制*文件。与为人类可读性设计的文本文件和具有标准化约定的文件不同,二进制文件可以采用多种特定于其数据类型的格式。虽然 R 的`readBin`函数可以处理任何二进制文件,但解释输出需要彻底了解文件的结构。这个复杂的话题本书没有涉及。相反,我们专注于电子表格的常见二进制格式:Microsoft Excel 的 xls 和 xlsx。*

*6.2.3 编码*

PRE6] fn <- "calificaciones.csv" [file.copy(file.path(system.file("extdata", package = "dslabs"), fn), fn) #> 1] TRUE [readLines(fn, n = 1) #> [1] ""nombre","f.n.","estampa","puntuaci\xf3n"" ```r *In the following section, we’ll introduce several helpful import functions, some of which allow you to specify the file encoding.**````

6.3 解析器**

PRE8] dat <- read.csv("murders.csv") PRE9] x <- scan("murders.csv", sep = ",", what = "c") x1:10] #> 1] "state" "abb" "region" "population" "total" #> [6] "Alabama" "AL" "South" "4779736" "135" r* ### 6.3.2 readr **The **readr** package includes parsers, for reading text file spreadsheets into R. **readr** is part of the **tidyverse**, but you can load it directly using: [library(readr("murders.csv") #> Rows: 51 Columns: 5 #> ── Column specification ──────────────────────────────────────────────── #> Delimiter: "," #> chr (3): state, abb, region #> dbl (2): population, total #> #> ℹ Use spec() to retrieve the full column specification for this data. #> ℹ Specify the column types or set show_col_types = FALSE to quiet this message. PRE12] [guess_encoding("murders.csv") #> # A tibble: 1 × 2 #> encoding confidence #> #> 1 ASCII 1 PRE13] [guess_encoding("calificaciones.csv") #> # A tibble: 3 × 2 #> encoding confidence #> #> 1 ISO-8859-1 0.92 #> 2 ISO-8859-2 0.72 #> 3 ISO-8859-9 0.53 PRE14] dat <- [read_csv("calificaciones.csv", show_col_types = FALSE, locale = locale(encoding = "ISO-8859-1")) PRE15] [names(dat) #> 1] "nombre" "f.n." "estampa" "puntuación" r******* ### 6.3.3 readxl ***The **readxl** package provides functions to read-in Microsoft Excel formats. [library(readxl(tmp_filename) [file.remove(tmp_filename) ```r*********````

6.4 使用电子表格组织数据****

****尽管这本书几乎完全专注于数据分析,数据管理也是数据科学操作的重要部分。正如引言中所述,我们不涉及这个主题。然而,数据分析师经常需要以最方便存储在电子表格中的方式收集数据,或者与他人一起收集数据。虽然我们强烈反对手动填写电子表格,并建议尽可能自动化这个过程,但有时你不得不这样做。因此,在本节中,我们提供有关如何在电子表格中组织数据的建议。尽管有 R 包可以读取 Microsoft Excel 电子表格,但我们通常想避免这种格式。相反,我们推荐使用 Google Sheets 作为免费软件工具。以下是我们总结了 Karl Broman 和 Kara Woo 在论文中提出的建议¹。请阅读论文以获取重要细节。 * **保持一致** - 在开始输入数据之前,制定一个计划。一旦有了计划,就要保持一致并坚持下去。* **为事物选择好的名称** - 你选择的物体、文件和目录的名称应该是容易记住的、容易拼写的,并且具有描述性。实际上,这是一个很难达到的平衡,确实需要时间和思考。一个重要的规则是**不要使用空格**,使用下划线 `_` 或破折号 `-` 代替。此外,避免使用符号;坚持使用字母和数字。* **将日期写成 YYYY-MM-DD 格式** - 为了避免混淆,我们强烈建议使用这个全球 ISO 8601 标准。* **不要有空单元格** - 填充所有单元格,并使用一些常见的代码来表示缺失数据。* **一个单元格只放一件事情** - 添加列来存储额外信息比一个单元格中放多个信息更好。* **使其成为矩形** - 电子表格应该是矩形的。* **创建数据字典** - 如果需要解释事物,例如列是什么,或用于分类变量的标签是什么,请在单独的文件中这样做。* **在原始数据文件中不要进行计算** - Excel 允许你进行计算。不要将这部分包含在你的电子表格中。计算代码应在脚本中。* **不要使用字体颜色或突出显示作为数据** - 大多数重要功能都无法导入此信息。将此信息编码为变量。* **备份** - 定期备份你的数据。* **使用数据验证来避免错误** - 利用电子表格软件中的工具,使过程尽可能无错误和重复性压力伤害。* **将数据保存为文本文件** - 以逗号分隔或制表符分隔的格式保存用于共享的文件。****

6.5 练习****

PRE23] 路径 <- [system.file("extdata", package = "dslabs") 文件列表 <- list.files(路径) 文件 PRE24] [names(dat) ```r to see that the names are not informative. Use the readLines function to read in just the first line. 4. Pick a measurement you can take on a regular basis. For example, your daily weight or how long it takes you to run 5 miles. Keep a spreadsheet that includes the date, the hour, the measurement, and any other informative variable you think is worth keeping. Do this for 2 weeks. Then make a plot.*****````


****1. [www.tandfonline.com/doi/abs/10.1080/00031305.2017.1375989](https://www.tandfonline.com/doi/abs/10.1080/00031305.2017.1375989)↩︎****

数据可视化

原文:rafalab.dfci.harvard.edu/dsbook-part-1/dataviz/intro-dataviz.html

观察定义数据集的数字和字符串通常没有太大用处。为了说服自己,打印并盯着美国谋杀数据表看看:

library(dslabs)
head(murders)
#>        state abb region population total
#> 1    Alabama  AL  South    4779736   135
#> 2     Alaska  AK   West     710231    19
#> 3    Arizona  AZ   West    6392017   232
#> 4   Arkansas  AR  South    2915918    93
#> 5 California  CA   West   37253956  1257
#> 6   Colorado  CO   West    5029196    65

你从盯着这张表格中学到了什么?你能多快确定哪个州的人口最多?哪个州的人口最少?一个典型的州有多大?人口规模和谋杀总数之间有关系吗?这个国家的不同地区谋杀率是如何变化的?对于大多数人类大脑来说,仅通过观察数字很难提取这些信息。相比之下,上述所有问题的答案都可以通过查看这个图表轻松获得:

图片

我们想起了那句俗语“一图胜千言”。数据可视化提供了一种强大的方式来传达基于数据的发现。在某些情况下,可视化如此令人信服,以至于不需要进一步的分析。

信息的数据库和软件工具的日益可用,导致数据可视化在许多行业、学术界和政府中的依赖性增加。一个显著的例子是新闻机构,它们越来越多地采用数据新闻学,并将有效的信息图表作为其报道的一部分。

一个特别有效的例子是《华尔街日报》的一篇文章¹,展示了疫苗对抗传染病影响的相关数据。其中一张图表显示了多年来美国各州的麻疹病例,并通过一条垂直线展示了疫苗引入的时间。

图片

《纽约时报》的一张图表通过总结纽约市教育委员会考试的成绩²提供了一个令人信服的例子。根据随附的文章³,这些成绩收集用于各种目的,其中之一是确定学生是否有资格高中毕业。在纽约市,65 分是及格的分数线。这些测试分数的模式表明了一些不寻常的情况。

图片

最常见的测试分数是最低及格分数,很少有分数仅低于阈值。这个意外的结果与即将及格的学生分数被提高是一致的。

这就是数据可视化如何导致发现,如果我们仅仅将数据提交给一系列数据分析工具或程序,这些发现可能会被忽视。数据可视化是我们所说的探索性数据分析(EDA)中最强大的工具。约翰·W·图基⁴,被认为是 EDA 之父,曾说过:

“图片的最大价值在于它迫使我们注意到我们从未预料到看到的东西。”

许多广泛使用的数据分析工具都是由通过 EDA(探索性数据分析)做出的发现所启发的。EDA 可能是数据分析中最重要的部分,但它往往被忽视。

数据可视化现在也普遍应用于慈善和教育组织。在《贫困的新见解⁵》和《你见过的最佳统计数据⁶》的演讲中,汉斯·罗斯林通过一系列与全球健康和经济相关的图表,迫使我们注意到意外之处。在他的视频中,他使用动画图表向我们展示世界是如何变化的,以及旧叙事是如何不再真实的。

还需要注意的是,错误、偏见、系统性错误和其他意外问题往往会导致需要谨慎处理的数据。未能发现这些问题可能导致分析结果有缺陷和错误发现。例如,考虑测量设备有时会失败,以及大多数数据分析程序都没有设计来检测这些情况。然而,这些数据分析程序仍然会给出答案。仅仅从报告的结果中难以或无法察觉错误的事实使得数据可视化尤为重要。

在本书的这一部分,我们将通过三个具有启发性的例子来学习数据可视化和探索性数据分析的基础。我们将使用ggplot2包进行编码。为了学习最基本的知识,我们将从一个相对人为的例子开始:学生报告的身高。然后我们将关注两个案例研究:1)世界健康和经济以及 2)美国传染病趋势。请注意,我们不涵盖交互式图形。对于那些有兴趣创建交互式图形的人来说,我们强烈推荐学习使用基于 R 的plotly包或 Shiny⁷,两者都建立在 R 的基础上。对于更高级的挑战,考虑学习使用 D3.js⁸进行编程。


  1. graphics.wsj.com/infectious-diseases-and-vaccines/?mc_cid=711ddeb86e↩︎

  2. graphics8.nytimes.com/images/2011/02/19/nyregion/19schoolsch/19schoolsch-popup.gif↩︎

  3. www.nytimes.com/2011/02/19/nyregion/19schools.html↩︎

  4. en.wikipedia.org/wiki/John_Tukey↩︎

  5. www.ted.com/talks/hans_rosling_reveals_new_insights_on_poverty?language=en↩︎

  6. www.ted.com/talks/hans_rosling_shows_the_best_stats_you_ve_ever_seen↩︎

  7. shiny.rstudio.com/↩︎

7 可视化数据分布

原文:rafalab.dfci.harvard.edu/dsbook-part-1/dataviz/distributions.html

  1. 数据可视化

  2. 7 可视化数据分布

在数据分析中,总结复杂数据集至关重要,它使我们能够更有效地分享从数据中得出的见解。一种常见的方法是使用平均值来总结一系列数字。例如,一所高中的质量可能由标准化考试的平均分数来表示。有时,还会添加一个额外的值,即标准差。因此,一份报告可能会说分数是 680 \(\pm\) 50,将一整套分数简化为两个数字。但这足够吗?我们是否通过仅仅依赖这些总结而不是完整的数据而忽略了关键信息?

我们数据可视化构建块的第一步是学习总结数字或类别的列表。通常情况下,分享或探索这些总结的最佳方式是通过数据可视化。一组对象或数字的最基本的统计总结是其分布。一旦数据被总结为分布,就有几种数据可视化技术可以有效地传达这些信息。因此,深入了解分布的概念非常重要。

在本章中,我们讨论了各种分布的特性以及如何使用学生身高的激励示例来可视化分布。

7.1 变量类型

两种主要的变量类型是分类数值。每种类型都可以分为另外两组:分类可以是有序的或无序的,而数值变量可以是离散的或连续的。当数据集中的条目代表一个组而不是一个数字时,我们称这些数据为分类数据。两个简单的例子是性别(男或女)和美国地区(东北部、南部、北中部、西部)。一些分类数据即使不是数字本身也可以排序,例如辣度(温和、中等、辣)。在统计学中,有序分类数据被称为有序数据。数值数据的例子包括人口规模、谋杀率和身高。我们可以进一步将数值数据分为连续和离散。连续变量是可以取任何值的变量,例如身高,如果测量足够精确的话。例如,一对双胞胎的身高可能分别是 68.12 英寸和 68.11 英寸。计数,如人口规模,是离散的,因为它们必须是整数。

请记住,离散数值数据可以被认为是有序的。虽然这在技术上是真的,但我们通常将有序数据术语保留给属于少数不同组别的变量,每组有多个成员。相比之下,当我们有许多组,每组案例很少时,我们通常将它们称为离散数值变量。因此,例如,一个人每天抽的香烟包数(四舍五入到最接近的包),将被认为是有序的,而实际抽的香烟数量将被认为是数值变量。但确实,在可视化数据时,有一些例子既可以被认为是数值的,也可以是有序的。

在这里,我们专注于数值变量,因为可视化这种数据类型要复杂得多。然而,我们首先描述数据可视化和摘要方法,用于分类数据。

7.2 案例研究:描述学生身高

我们介绍一个新的激励性问题。这是一个人为的问题,但它将帮助我们说明理解分布所需的概念。

假设我们必须向 ET 描述我们同学的身高,ET 是一个从未见过人类的地球外生物。作为第一步,我们需要收集数据。为此,我们要求学生报告他们的身高(英寸)。我们要求他们提供性别信息,因为我们知道存在两种不同的性别身高分布。我们收集数据并将其保存在heights数据框中:

library(tidyverse
library(dslabs)
head(heights)
#>      sex height
#> 1   Male     75
#> 2   Male     70
#> 3   Male     68
#> 4   Male     74
#> 5   Male     61
#> 6 Female     65

一种向 ET 传达高度的方法就是简单地发送这份包含 1,050 个高度的列表。但还有更有效的方法来传达这些信息,而理解分布的概念将是关键。为了简化解释,我们首先关注男性身高。我们将在第 7.6 节中检查女性身高数据。

7.3 分布

一个对象或数字列表的最基本的统计摘要就是其分布。将分布简单地视为对具有许多条目的列表的紧凑描述。这个概念对于本书的读者来说不应该陌生。例如,对于分类数据,分布简单地描述了每个唯一类别的比例。在身高数据集中表示的性别是:

#> 
#> Female   Male 
#>  0.227  0.773

这个双类别频率表是分布的最简单形式。我们实际上不需要可视化它,因为一个数字就描述了我们所需了解的一切:23%是女性,其余是男性。当有更多类别时,简单的条形图就可以描述分布。以下是一个关于美国州区域的例子:

图片

这个特定的图表仅仅显示了四个数字,每个类别一个。我们通常使用条形图来显示几个数字。尽管这个特定的图表本身并不比频率表提供更多的洞察力,但它是我们如何将向量转换为简洁地总结向量中所有信息的图表的第一个例子。当数据是数值时,显示分布的任务更具挑战性。

7.3.1 直方图

非分类的数值数据也有分布。然而,通常情况下,当数据不是分类的时,报告每个条目的频率,就像我们对分类数据所做的那样,不是一个有效的总结,因为大多数条目是唯一的。例如,在我们的案例研究中,虽然有几个学生报告身高为 68 英寸,但只有一名学生报告身高为68.503937007874英寸,只有一名学生报告身高68.8976377952756英寸。我们假设他们分别从 174 厘米和 175 厘米转换而来。

统计学教材教导我们,定义数值数据的分布的一个更有用方法是定义一个函数,该函数报告所有可能的值 \(a\) 下数据的比例。这个函数被称为经验累积分布函数(eCDF),它可以绘制出来,并提供了我们数据分布的完整描述。以下是男性学生身高的 eCDF:

图片

然而,通过绘制经验累积分布函数(eCDF)来总结数据实际上在实践上并不常见。主要原因在于它并不能轻易传达感兴趣的特性,例如:分布的中心值是多少?分布是对称的吗?哪些范围包含 95%的值?

直方图*更受欢迎,因为它们极大地简化了回答此类问题。直方图牺牲了一点点信息,以产生更容易解释的图表。制作直方图的最简单方法是将我们的数据范围划分为相同大小的非重叠区间。然后,对于每个区间,我们计算落在该区间内的值的数量。直方图将这些计数作为条形图绘制出来,条形的底部由区间定义。以下是按一英寸间隔分割值范围的身高直方图:\((49.5, 50.5]\)\((50.5, 51.5]\)\((51.5,52.5]\)\((52.5,53.5]\)\(...\)\((82.5,83.5]\)

图片

如上图所示,直方图类似于条形图,但不同之处在于 x 轴是数值的,而不是分类的。

如果我们将这个图表发送给 ET,他将会立即了解我们数据的一些重要属性。首先,数据范围从 50 到 84,其中大多数(超过 95%)在 63 到 75 英寸之间。其次,高度在 69 英寸左右对称。此外,通过累计计数,ET 可以非常准确地获得任何区间内数据比例的良好近似。因此,上面的直方图不仅易于解释,而且提供了大约 30 个区间中包含在原始 812 名男性身高列表中的几乎所有信息。

我们失去了哪些信息?请注意,在计算区间高度时,每个区间的所有值都被视为相同。因此,例如,直方图无法区分 64 英寸、64.1 英寸和 64.2 英寸。鉴于这些差异几乎对眼睛不可见,实际影响可以忽略不计,我们能够将数据总结为仅仅 23 个数字。

我们在第 8.14 节中讨论了如何编码直方图。

7.3.2 平滑密度

平滑密度*图传达了与直方图相同的信息,但外观更吸引人。以下是我们的男性身高数据的平滑密度图:

图片

在这个图表中,我们不再在区间边界处有尖锐的边缘,许多局部峰值已经被移除。此外,y 轴的刻度从计数改为密度。为了完全理解平滑密度,我们必须了解估计,这是我们在高级数据科学教科书中讨论的一个主题。在这里,我们简单地描述它们为通过直方图柱状图的顶部绘制一条曲线,然后移除柱状图。y 轴上显示的值被选择,使得曲线下的面积加起来为 1。这表明,对于任何区间,曲线下该区间的面积为我们提供了数据在该区间内比例的一个近似。

与直方图相比,平滑密度在可视化方面的一个优点是,密度使得比较两个分布更容易。这在很大程度上是因为直方图的锯齿边缘增加了杂乱。以下是一个比较男性和女性身高的例子:

图片

使用正确的参数,ggplot会自动用不同的颜色阴影相交的区域。我们将在第十章以及第 8.14 节中展示用于密度的ggplot2代码示例。

7.3.3 正态分布

直方图和密度图提供了对分布的出色总结。但我们能否进一步总结?我们经常看到平均值和标准差被用作总结统计量:两个数字的总结!为了理解这些总结是什么以及为什么它们被广泛使用,我们需要了解正态分布。

正态分布,也称为钟形曲线和高斯分布。以下是正态分布的形状:

正态分布是历史上最著名的数学概念之一。一个原因是许多数据集的分布可以用正态分布来近似。这包括赌博收益、身高、体重、血压、标准化测试分数和实验测量误差。统计教科书提供了为什么会出现这种情况的解释。但是,同一个分布如何近似具有完全不同值范围的多个数据集,例如身高和体重?正态分布的第二个重要特征是,它可以通过调整两个数字来适应不同的数据集,这两个数字被称为平均值均值标准差(SD)。正态分布是对称的,以我们所说的平均值为中心,大多数值(约 95%)都在平均值的 2 个标准差范围内。上图显示了一个平均值为 0,标准差为 1 的正态分布,通常被称为标准正态分布。请注意,仅需要两个数字来调整正态分布以适应数据集的事实意味着,如果我们的数据分布被正态分布近似,描述分布所需的所有信息都可以通过仅两个数字来编码。我们现在为任意的一组数字定义这些值。

一旦我们确信我们的数据,比如说存储在向量x中,具有近似正态分布,我们就可以通过将数据的平均值和标准差分别与正态分布的平均值和标准差相匹配来找到与我们的数据匹配的具体分布。对于一个包含在向量x中的数字列表:

index <- heights$sex == "Male"
x <- heights$height[index]

平均值定义为

m <- sum(x) / length(x)

标准差定义为

s <- sqrt(sum((x - mu)²) / length(x))

可以解释为值与其平均值之间的平均距离。

预先构建的函数meansd(请注意,由于统计教科书中解释的原因,sd除以length(x)-1而不是length(x))可以在这里使用:

m <- mean(x)
s <- sd(x)
c(average = m, sd = s)
#> average      sd 
#>   69.31    3.61

以下是我们的学生身高平滑密度图,以蓝色表示,以及平均值为 69.3 和标准差为 3.6 的正态分布,以黑色线条绘制:

7.4 箱线图

为了理解箱线图,我们需要定义一些在探索性数据分析中常用的术语。

百分位数是数据中\(p = 0.01, 0.02, ..., 0.99\)的值,分别表示数据中小于或等于该值的百分比。例如,\(p=0.10\)的情况被称为第 10 个百分位数,它给出了 10%的数据低于该数值。最著名的百分位数是第 50 个,也称为中位数。另一个被命名的特殊情况是四分位数*,当设置\(p=0.25,0.50\)\(0.75\)时获得,这些值在箱线图中使用。

为了激发对箱形图的需求,我们将回到美国谋杀数据。假设我们想要总结谋杀率分布。使用我们学到的数据可视化技术,我们可以快速看到这里不适用正态近似:

在这种情况下,上面的直方图或平滑密度图可以作为一个相对简洁的总结。

现在假设那些习惯于只接收两个数字作为总结的人要求我们提供一个更紧凑的数值总结。

箱形图提供了一个由范围(最小值和最大值)以及四分位数(第 25、50 和 75 百分位数)组成的五数总结。R 实现中的箱形图在计算范围时忽略异常值*,并将这些值作为独立点绘制。帮助文件提供了异常值的特定定义。箱形图将这些数字显示为“箱”和“须须”。

由 25%和 75%分位数定义的箱形和显示范围的须须。25%和 75%分位数之间的距离称为四分位距。根据 R 的实现,这两个点是异常值。中位数用水平线表示。

从这张简单的图表中,我们可以知道中位数大约是 2.5,分布不是对称的,以及大多数州的范围是 0 到 5,只有两个例外。

我们在第 8.14 节中讨论了如何制作箱形图。

7.5 分层

在数据分析中,我们通常根据与观察值相关的一个或多个变量的值将观察值分成组。例如,在下一节中,我们将身高值根据性别变量分成组:女性和男性。我们称此过程为分层,并将由此产生的组称为

分层在数据可视化中很常见,因为我们通常对变量在不同子组中的分布差异感兴趣。在本书的这一部分,我们将看到几个例子,从下一节开始。

7.6 案例研究继续

如果我们确信男性身高数据可以用正态分布很好地近似,我们可以向 ET 报告一个非常简洁的总结:男性身高遵循平均为 69.3 英寸,标准差为 3.6 英寸的正态分布。有了这些信息,ET 将对他遇到我们的男性学生时可以期待什么有一个很好的了解。然而,为了提供一个完整的画面,我们还需要提供女性身高的总结。

当我们想要快速比较两个或多个分布时,箱形图很有用。以下是男性和女性的身高:

图表立即显示,男性平均身高高于女性。四分位数范围看起来相似。但是,正态近似也适用于调查收集的女性身高数据吗?我们预计它们将遵循正态分布,就像男性一样。然而,探索性图表显示,这种近似并不那么有用:

我们看到了在男性身上没有看到的事情:密度图有一个第二个“峰值”。而且,最高点往往比正态分布预期的要高。在向 ET 汇报时,我们可能需要提供一个直方图,而不仅仅是女性身高的平均值和标准差。

然而,回顾一下图克的话。我们已经注意到了我们没有预料到的事情。如果我们查看其他女性的身高分布,我们发现它们很好地被正态分布所近似。那么为什么我们的女学生不同?我们的班级是女性篮球队的必修课吗?是否有小部分女性声称自己的身高高于实际身高?另一个可能更合理的解释是,在学生用来输入身高的形式中,FEMALE是默认性别,一些男性输入了他们的身高,但忘记了更改性别变量。无论如何,数据可视化帮助我们发现了数据中可能存在的潜在缺陷。

关于前五个最小值,请注意,这些值是:

heights |> filter(sex == "Female") |> 
 slice_min(height, n = 5) |>
 pull(height)
#> [1] 51 52 52 53 55

因为这些是报告的身高,一种可能是学生本意是想输入5'1"5'2"5'3"5'5"

7.7 练习

  1. 定义包含男性和女性身高的变量,如下所示:
library(dslabs)
male <- heights$height[heights$sex == "Male"]
female <- heights$height[heights$sex == "Female"]

我们每个国家有多少个测量值?

  1. 假设我们无法绘制图表,而想并排比较分布。我们不能只是列出所有数字。相反,我们将查看百分位数。创建一个五行的表格,显示female_percentilesmale_percentiles,包括每个性别的 10th、30th、50th、70th 和 90th 百分位数。然后创建一个数据框,将这两个作为列。

  2. 研究以下按国家显示的人口规模的箱型图:

哪个洲的国家人口规模最大?

  1. 哪个洲的中位人口规模最大?

  2. 非洲的中位人口规模是多少,四舍五入到百万?

  3. 欧洲有多少比例的国家人口低于 1400 万?

  4. 0.99

  5. 0.75

  6. 0.50

  7. 0.25

  8. 如果我们使用对数变换,上面显示的哪个洲的四分位数范围最大?

  9. 加载身高数据集,并创建一个只包含男性身高的向量x

library(dslabs)
x <- heights$height[heights$sex=="Male"]

数据中在 69 英寸到 72 英寸(高于 69 英寸,但小于或等于 72 英寸)之间的比例是多少?提示:使用逻辑运算符和mean

8  ggplot2

原文:rafalab.dfci.harvard.edu/dsbook-part-1/dataviz/ggplot2.html

  1. 数据可视化

  2. 8  ggplot2

探索性数据可视化可能是 R 的最大优势。人们可以快速从想法到数据再到图表,这种灵活性与易用性的独特平衡。例如,对于某些图表,Excel 可能比 R 更易用,但它的灵活性远远不如 R。D3.js 可能比 R 更灵活和强大,但生成图表需要更长的时间。

在整本书中,我们将使用ggplot2¹包来创建图表。

library(dplyr
library(ggplot2

在 R 中创建图表的方法还有很多。实际上,R 的基本安装就已经提供了相当强大的绘图功能。还有其他用于创建图形的包,如gridlattice。我们选择在这本书中使用ggplot2,因为它将图表分解成组件,这种方式允许初学者使用直观且相对容易记忆的语法创建相对复杂且美观的图表。

ggplot2对初学者来说通常更直观的一个原因是它使用了图形语法²,即ggplot2中的gg。这类似于学习语法可以帮助初学者通过学习少量动词、名词和形容词来构建成百上千种不同的句子,而不需要记住每个具体的句子。同样,通过学习少量ggplot2**构建块及其语法,您将能够创建成百上千种不同的图表。

ggplot2**对初学者来说容易的另一个原因是它的默认行为是精心选择的,以满足大多数情况,并且视觉效果令人愉悦。因此,可以使用相对简单且易于阅读的代码创建信息丰富且优雅的图表。

一个限制是ggplot2设计为仅与整洁格式的数据表一起工作。然而,初学者处理的大量数据集都在这种格式中,或者可以转换成这种格式。这种方法的一个优点是,假设我们的数据是整洁的,ggplot2简化了绘图代码和各种图表语法的学习。

要使用ggplot2,您将不得不学习几个函数和参数。这些很难记住,所以我们强烈建议您手头备有 ggplot2 速查表。您可以从 Posit 的网站³获取一份,或者简单地在网上搜索“ggplot2 cheat sheet”。

8.1 图表组成部分

我们将构建一个图表,总结美国谋杀数据集,看起来像这样:

我们可以清楚地看到各州在人口规模和谋杀总数上的差异。不出所料,我们还看到谋杀总数与人口规模之间存在明显的关联。位于虚线灰色线上的州具有与美国平均相同的谋杀率。四个地理区域用颜色表示,这描绘了大多数南部州的谋杀率高于平均水平。

这份数据可视化几乎展示了数据框中的所有信息。制作此图表所需的代码相对简单。我们将学习逐步创建图表。

学习ggplot2的第一步是能够将图表分解成组件。让我们分解上面的图表并介绍一些ggplot2术语。需要注意的主要三个组件是:

  • 数据:正在汇总的 US 谋杀数据框。我们将其称为数据组件。

  • 几何形状:上面的图表是一个散点图。这被称为几何组件。其他可能的几何形状包括条形图、直方图、平滑密度、QQ 图和箱线图。我们将在第 8.14 节中了解更多关于这些内容。

  • 美学映射:该图表使用几个视觉线索来表示数据集提供的信息。在这个图表中,最重要的两个线索是 x 轴和 y 轴上的点位置,分别代表人口规模和谋杀总数。每个点代表一个不同的观察结果,我们将这些观察结果的数据映射到 x 和 y 刻度等视觉线索上。颜色是另一个映射到区域的视觉线索,我们将其称为美学映射组件。我们如何定义映射取决于我们正在使用的几何形状

我们还注意到:

  • 点被标记为州缩写。

  • x 轴和 y 轴的范围似乎由数据的范围定义。它们都使用对数刻度。

  • 图表中包含标签、标题、图例,我们采用了《经济学人》杂志的风格。

ggplot2**的一般方法是逐步构建图表,通过向由ggplot函数创建的ggplot对象中添加来实现。层可以定义几何形状、计算汇总统计量、定义要使用的刻度或更改样式。要添加层,我们使用符号+。一般来说,一行代码将看起来像这样:

DATA |> ggplot() + LAYER 1 + LAYER 2 + … + LAYER N

我们现在将通过剖析如何构建上面的图表来展示 ggplot2 的基本原理。

8.2 使用数据初始化对象

我们首先加载相关的数据集,该数据集位于dslabs 包中:

library(dslabs)

创建ggplot2图表的第一步是定义一个ggplot对象。通常,大多数或所有层都将映射来自同一数据集的变量,因此我们将此对象与相关数据框关联

ggplot(data = murders)

或者等价地

murders |> ggplot()

* *这两行代码都生成了一个图表,在这种情况下是一个空白画布,因为没有定义几何形状。我们看到的唯一样式选择是默认的灰色背景。我们看到一个图表,因为创建了一个对象而没有分配给变量,它被自动评估并打印出来。但我们可以像通常一样将我们的图表分配给一个对象:

p <- ggplot(data = murders)

要渲染与此对象关联的图表,我们只需打印对象p。以下两行代码各自生成了我们上面看到的相同图表:

print(p)
p

总结来说,p是一个ggplot对象,其数据组件是murders数据框。

8.3 添加几何形状

一个常见的第一步是让ggplot2知道要使用哪种几何形状。我们经常添加多个几何形状,但我们至少需要一个。在我们的例子中,我们想要制作一个散点图。几何形状是通过函数添加的。快速查看速查表,我们看到应该使用函数geom_point

(图片由 Posit 提供⁴。CC-BY-4.0 许可⁵。)

注意,几何函数名称遵循以下模式:geom_X,其中 X 是几何形状的名称。一些例子包括geom_histogramgeom_boxplotgeom_col。我们将在第 8.14 节中进一步讨论这些内容。

为了使geom_point正常运行,我们需要提供数据和映射。我们已将murders数据表分配给对象p。接下来我们需要添加geom_point层来定义几何形状。要找出此函数期望的映射,我们阅读geom_point帮助文件的美学部分:

美学 geom_point()理解以下美学(必需美学以粗体显示):

  • **x
  • **y
  • alpha
  • colour

我们看到至少需要两个参数:xy。接下来我们将解释如何将数据集中的值映射到图表上。

8.4 美学映射

美学映射*描述了数据属性如何与图表特征(如轴上的距离、大小或颜色)相关联。aes函数通过定义美学映射将数据与我们在图表上看到的内容连接起来,将是你在绘图时最常使用的函数之一。此示例生成了一个总谋杀数与百万人口之间的散点图:

murders |> ggplot() + geom_point(aes(population/10⁶, total))

* *我们没有使用xy来定义参数,因为帮助文件显示这些是前两个预期的参数。

添加此层时,默认定义了刻度和标签。像 dplyr 函数一样,aes 也使用数据组件中的变量名:我们可以使用 populationtotal,而无需将它们作为 murders$populationmurders$total 调用。从数据组件中识别变量的行为是 aes 特有的。对于 ggplot2 函数中的 aes 之外的函数,如果您尝试访问 populationtotal 的值,您将收到一个错误。

8.5 其他层

为了将图形塑造成最终形式,我们继续添加层。我们希望在图形中添加的第二层涉及为每个点添加标签以标识州。geom_labelgeom_text 函数允许我们分别带和不带文本后面的矩形将文本添加到图形中。

因为每个点(在这种情况下是每个州)都有一个标签,我们需要一个美学映射来在点和标签之间建立联系。通过阅读帮助文件,我们了解到我们通过 aeslabel 参数提供点与标签之间的映射。因此,代码看起来是这样的:

murders |> ggplot() + 
 geom_point(aes(population/10⁶, total)) +
 geom_text(aes(population/10⁶, total, label = abb))

* *作为一个与变量名相关的 aes 独特行为的例子,请注意,如果添加的层是 geom_text(aes(population/10⁶, total), label = abb),由于 abb 现在不在 aes 的调用中,它不是我们工作区中的对象,而是 ggplot 对象数据组件中的变量名,我们将会得到一个错误。

8.6 全局美学映射

在之前的代码行中,我们定义了映射 aes(population/10⁶, total) 两次,一次在每个层中。我们可以通过使用一个 全局 美学映射来避免这种情况。我们可以在定义空白画布 ggplot 对象时这样做。记住,ggplot 函数的 mapping 参数允许我们定义美学映射。如果我们定义了 ggplot 中的映射,所有添加为层的几何图形都将默认使用此映射。因此,我们可以简单地编写以下代码来生成之前的图形:

murders |> ggplot(aes(population/10⁶, total)) +
 geom_point() +
 geom_text(aes(label = abb))

请注意,label 的映射仅在 geom_text 中定义,因为 geom_point 不使用此参数。

如果需要,我们可以在每个层中定义一个新的映射来覆盖全局映射。这些 局部 定义覆盖了 全局。以下是一个例子:

murders |> ggplot(aes(population/10⁶, total)) +
 geom_point() +
 geom_text(aes(x = 10, y = 800, label = "Hello there!"))
#> Warning in geom_text(aes(x = 10, y = 800, label = "Hello there!")): All aesthetics have length 1, but the data has 51 rows.
#> ℹ Please consider using `annotate()` or provide this layer with data
#>   containing a single row.

* *显然,第二次调用 geom_text 没有使用 populationtotal

8.7 非美学参数

每个几何函数都有除 aesdata 之外的参数。它们通常是特定于函数的,并且不会映射到数据中的变量。例如,在我们希望制作的图形中,点的大小大于默认大小。作为另一个例子,为了避免将文本放在点上方,我们可以在 geom_text 中使用 nudge_x 参数。带有参数的代码如下:

murders |> ggplot(aes(population/10⁶, total)) +
 geom_point(size = 3) +
 geom_text(aes(label = abb), nudge_x = 1.5)

* *在第 8.12 节中,我们学习了一种更好的方法来确保我们可以看到点和标签。

8.8 将类别作为颜色

对于最终的图形,我们希望每个区域都有不同的颜色。因为这种信息来自数据,所以它是一种美学映射。在我们的例子中,我们可以使用geom_point函数中的color映射将颜色映射到类别,如下所示:

murders |> ggplot(aes(population/10⁶, total)) +
 geom_point(aes(color = region), size = 3) 

* *注意geom_point自动为每个类别分配不同的颜色,并添加了图例!图例通常是想要的,但为了避免添加图例,我们可以设置geom_point参数show.legend = FALSE

注意,color也是ggplot2函数中的非美学参数,包括geom_point。此参数不是用来将颜色映射到类别,而是用来改变所有点的颜色。例如,如果我们想让所有点都是蓝色,我们会将层改为geom_point(col = "blue", size = 3)

murders |> ggplot(aes(population/10⁶, total)) +
 geom_point(color = "blue", size = 3) 

8.9 更新 ggplot 对象

ggplot2中,我们通过部分构建图形。该包的一个有用特性是我们可以通过添加层级来更新现有的ggplot对象。例如,我们可以通过使用数据集和全局美学初始化对象来开始。

p0 <- murders |> ggplot(aes(population/10⁶, total))

然后开始添加层级。例如,我们首先添加散点图

p1 <- p0 +  geom_point(aes(color = region), size = 3)

以及标签:

p2 <- p1 + geom_text(aes(label = abb), nudge_x = 0.1)

在接下来的几节中,我们将使用这种方法在前面几节创建的对象上构建。这有助于改进图形以及测试选项。注意,我们将nudge_x从 1.5 改为 0.1,因为在下一节中我们将应用对数变换,较小的值更合适。

8.10 刻度

ggplot2**的一个优势是默认行为通常足以实现我们的可视化目标。然而,它也提供了改变这些默认设置的方法。许多这些改变都是通过scales函数来实现的。

两个例子是scale_x_continuousscale_y_continuous函数,它们允许我们分别调整 x 轴和 y 轴。在最终的图形中,我们试图产生对数刻度的刻度,这可以通过在这些函数中分配参数trans = "log10"来实现。然而,因为这个操作非常常见,ggplot2包括了scale_x_log10scale_y_log10函数。我们可以通过添加这些层级来实现所需的变换:

p3 <- p2 + scale_x_log10() + scale_y_log10() 
p3

* *请注意,ggplot2 提供了巨大的灵活性,尤其是在 scales 函数方面。我们只介绍了许多可用功能中的一个。在本书的后续章节中,我们将提供与我们的可视化相关的示例。然而,为了熟悉这些函数,我们建议您查阅 ggplot2 的速查表或根据具体需求进行网络搜索。

8.11 注释

我们经常想要向不是直接从美学映射派生的图表添加注释。注释函数的例子有 labsannotategeom_ablinelabs 函数允许添加标题、副标题、字幕和其他标签。请注意,这些也可以使用 xlabylabggtitle 等函数单独定义。

labs 函数还允许进行另一个对我们所需图表所需的更改:更改图例标题。因为这是颜色映射的图例,所以可以通过 color = "NEW_TITLE" 参数来实现:

p4 <- p3 + labs(title = "US Gun Murders in 2010",
 x = "Populations in millions (log scale)", 
 y = "Total number of murders (log scale)",
 color = "Region")
p4

* *我们期望的最终图表包括一条代表整个国家平均谋杀率的线。一旦我们确定每百万的比率是 \(r\),所需的线由以下公式定义:\(y = r x\),其中 \(y\)\(x\) 是我们的坐标轴:总谋杀数和百万人口,分别。在对数尺度上,这条线变为:\(\log(y) = \log(r) + \log(x)\),这是一条斜率为 1,截距为 \(\log(r)\) 的线。我们可以使用以下方法计算 r

r <- murders |> 
 summarize(rate = sum(total)/sum(population)*10⁶) |> 
 pull(rate)

要添加一条线,我们使用 geom_abline 函数。名称中的 ab 让我们想起我们正在提供截距(a)和斜率(b)。默认线具有斜率 1 和截距 0,所以我们只需要定义截距。请注意,最终图表具有虚线类型和灰色,这些可以通过 lty(线型)和 color 非美学参数进行更改。我们添加层的方式如下:

p5 <- p4 + 
 geom_abline](https://ggplot2.tidyverse.org/reference/geom_abline.html)(intercept = [log10(r), lty = 2, color = "darkgrey") 
p5

* *请注意,一旦我们有了斜率,geom_abline 函数不会使用数据对象中的任何映射。

我们几乎完成了!我们只需添加对样式的可选更改。

8.12 扩展包

由于扩展包的可用性,ggplot2 的功能进一步增强。为了给我们的图表添加最后的修饰,我们需要 ggthemesggrepel 扩展包。

可以使用 theme 函数更改 ggplot2 图表的风格。ggplot2 包包含几个主题。实际上,对于本书中的大多数图表,我们使用 dslabs 包中的一个函数来自动设置默认主题:

ds_theme_set()

ggthemes 添加了许多其他主题。其中之一是我们在这里使用的 theme_economist 主题。安装包后,您可以通过添加类似这样的层来更改样式:

library(ggthemes)
p6 <- p5 + theme_economist()

您可以通过简单地更改函数来查看其他主题的外观。例如,您可能会尝试 theme_fivethirtyeight() 主题。

最后的更改是为了更好地定位标签以避免拥挤;目前,一些标签重叠在一起。添加包 ggrepel 包含一个几何形状,在添加标签的同时确保它们不会重叠。我们只需将 geom_text 更改为 geom_text_repel

8.13 整合所有内容

现在我们已经完成了测试,我们可以写一行代码从头开始生成我们想要的图表。

library(ggthemes)
library(ggrepel)
 r <- murders |> 
 summarize(rate = sum(total) /  sum(population) * 10⁶) |>
 pull(rate)
 murders |> 
 ggplot(aes(population/10⁶, total)) + 
 geom_abline](https://ggplot2.tidyverse.org/reference/geom_abline.html)(intercept = [log10(r), lty = 2, color = "darkgrey") +
 geom_point(aes(col = region), size = 3) +
 geom_text_repel(label = abb)) + 
 scale_x_log10() +
 scale_y_log10() +
 labs(title = "US Gun Murders in 2010",
 x = "Populations in millions (log scale)", 
 y = "Total number of murders (log scale)",
 color = "Region") +
 theme_economist()

8.14 几何形状

在我们的示例中,我们介绍了散点图几何形状 geom_point。然而,ggplot2 有很多其他形状,在这里我们演示如何生成与分布相关的图表,特别是第七章中显示的图表。

8.14.1 条形图

要生成条形图,我们可以使用 geom_bar 几何形状。默认情况下,是计算每个类别的数量并绘制一个条形。以下是美国各地区的条形图。

murders |> ggplot(aes(region)) + geom_bar()

然而,我们通常已经有一个包含我们想要展示为条形图数字的表格。以下是一个此类表格的例子:

tab <- murders |> 
 count(region) |> 
 mutate(proportion = n/sum(n))

在这种情况下,我们使用 geom_col 而不是 geom_bar

tab |> ggplot(aes(region, proportion)) + geom_col()

8.14.2 直方图

要生成直方图,我们使用 geom_histogram。通过查看该函数的帮助文件,我们了解到唯一必需的参数是 x,这是我们将要构建直方图的变量。我们省略了 x,因为我们知道它是第一个参数。代码看起来是这样的:

heights |> filter(sex == "Female") |> 
 ggplot(aes(height)) + 
 geom_histogram(binwidth = 1, fill = "blue", col = "black")

请注意,我们使用可选参数 bandwidth = 1 来改变条形大小为 1 英寸。默认情况下,创建 30 个条形。我们还使用可选参数 fill = "blue"col = "black" 来用颜色填充条形,并使用不同的颜色来勾勒条形。

8.14.3 密度图

要创建平滑密度,我们使用 geom_density。要使用之前显示为直方图的数据创建平滑密度图,我们可以使用以下代码:

heights |> 
 filter(sex == "Female") |>
 ggplot(aes(height)) +
 geom_density(fill = "blue")

请注意,我们使用可选参数 fill 来改变颜色。为了改变密度的平滑度,我们使用 adjust 参数将默认值乘以该 adjust。例如,如果我们想带宽是两倍大,我们使用:

heights |> 
 filter(sex == "Female") |>
 ggplot(aes(height)) +
 geom_density(fill="blue", adjust = 2)

8.14.4 箱线图

箱线图的几何形状是 geom_boxplot。如前所述,箱线图用于比较分布。例如,下面是之前显示的女性身高,但与男性相比。对于这个几何形状,我们需要 x 作为类别,y 作为值。

heights |> ggplot(aes(sex, height)) +
 geom_boxplot()

8.14.5 图片

本章中描述的概念不需要图像,但我们在 第 10.9 节 中将使用图像,因此我们介绍用于绘制图像的两个几何形状:geom_tilegeom_raster。它们的行为类似;要了解它们之间的区别,请查阅帮助文件。要在 ggplot2 中创建图像,我们需要一个包含 x 和 y 坐标以及与每个这些坐标相关联的值的数据框。下面是一个数据框。

x <- expand.grid](https://rdrr.io/r/base/expand.grid.html)(x = 1:12, y = 1:10) |> [mutate(z = 1:120) 

注意,这是一个矩阵的整洁版本,matrix(1:120, 12, 10)。要绘制图像,我们使用以下代码:

x |> ggplot(aes(x, y, fill = z)) + geom_raster()

使用这些图像,你通常会想更改颜色范围。这可以通过 scale_fill_gradientn 层来完成。

x |> ggplot(aes(x, y, fill = z)) + 
 geom_raster() + 
 scale_fill_gradientn](https://ggplot2.tidyverse.org/reference/scale_gradient.html)(colors =  [terrain.colors(10, 1))

8.15 绘图网格

通常,有理由将图形并排放置。gridExtra 包允许我们这样做。以下是上一节中创建的图形 p5p6

library(gridExtra)
grid.arrange(p5, p6, ncol = 2)

8.16 练习

首先,加载 dplyrggplot2 库以及 murdersheights 数据。

library(dplyr
library(ggplot2
library(dslabs)

1. 使用 ggplot2,可以将图形保存为对象。例如,我们可以将数据集与图形对象关联起来,如下所示

p <- ggplot(data = murders)

因为 data 是第一个参数,所以我们不需要明确写出

p <- ggplot(murders)

我们也可以使用管道:

p <- murders |> ggplot()

p 对象的类别是什么?

2. 记住,要打印一个对象,你可以使用 print 命令或简单地输入对象。打印在练习一中定义的对象 p 并描述你所看到的。

  1. 没有发生任何事情。

  2. 一个空白画布图。

  3. 一个散点图。

  4. 一个直方图。

3. 使用管道 |> 创建一个对象 p,但这次与 heights 数据集相关联,而不是 murders 数据集。

4. 你刚刚创建的对象 p 的类别是什么?

5. 现在我们将添加一层,以及相应的美学映射。对于谋杀数据,我们绘制了总谋杀数与人口规模的关系。探索 murders 数据框,以提醒自己这两个变量的名称,并选择正确的答案。提示:查看 ?murders

  1. stateabb

  2. total_murderspopulation_size

  3. totalpopulation

  4. murderssize

6. 要创建散点图,我们添加一个 geom_point 层。美学映射要求我们定义 x 轴和 y 轴变量,所以代码看起来像这样:

murders |> ggplot(aes(x = , y = )) +
 geom_point()

除了我们必须定义两个变量 xy。用正确的变量名称填写。

7. 注意,如果我们不使用参数名称,我们可以通过确保以正确的顺序输入变量名称来获得相同的图形,如下所示:

murders |> ggplot(aes(population, total)) +
 geom_point()

重新制作这个图,但现在以总谋杀数作为 x 轴,人口作为 y 轴。

  1. 如果我们想添加文本而不是点,我们可以使用geom_text()geom_label()几何形状。以下代码
murders |> ggplot(aes(population, total)) + geom_label()

将给出错误信息:Error: geom_label requires the following missing aesthetics: label

为什么呢?

  1. 我们需要通过aes中的标签参数将一个字符映射到每个点。

  2. 我们需要让geom_label知道在图中使用哪个字符。

  3. geom_label几何形状不需要 x 轴和 y 轴值。

  4. geom_label不是一个 ggplot2 命令。

  5. 将上面的代码重写为通过aes使用缩写作为标签。

  6. 将标签的颜色更改为蓝色。我们将如何做到这一点?

  7. murders中添加一个名为blue的列。

  8. 因为每个标签都需要不同的颜色,我们通过aes映射颜色。

  9. 使用ggplot中的color参数。

  10. 因为我们希望所有标签都是蓝色,所以我们不需要映射颜色,只需在geom_label中使用颜色参数。

  11. 将上面的代码重写为使标签为蓝色。

  12. 现在假设我们想用颜色来表示不同的区域。在这种情况下,以下哪个最合适:

  13. murders中添加一个名为color的列,并添加我们想要使用的颜色。

  14. 因为每个标签都需要不同的颜色,我们通过aes的颜色参数映射颜色。

  15. ggplot中使用color参数。

  16. 因为我们希望所有颜色都是蓝色,所以我们不需要映射颜色,只需在geom_label中使用颜色参数。

  17. 将上面的代码重写为使标签的颜色由州的区域决定。

  18. 现在我们将 x 轴改为对数尺度,以考虑到人口分布是偏斜的。让我们首先定义一个对象p,它包含我们到目前为止制作的图表。

p <- murders |> 
 ggplot(aes(population, total, label = abb, color = region)) +
 geom_label() 

要改变 y 轴为对数尺度,我们学习了scale_x_log10()函数。将此层添加到对象p中,以更改尺度并渲染图表。

  1. 重复之前的练习,但现在将两个轴都改为对数尺度。

  2. 现在编辑上面的代码,向图表添加标题“枪支谋杀数据”。提示:使用ggtitle函数。

  3. 现在我们将使用geom_histogram函数来制作height数据框中高度的直方图。当阅读该函数的文档时,我们看到它只需要一个映射,即用于直方图的值。制作所有图表的直方图。

哪个变量包含高度?

  1. sex

  2. heights

  3. height

  4. heights$height

  5. 现在创建一个 ggplot 对象,使用管道将高度数据分配给 ggplot 对象。通过aes函数将height分配给 x 值。

  6. 现在我们准备添加一个层来实际制作直方图。使用之前练习中创建的对象和geom_histogram函数来制作直方图。

  7. 注意,当我们运行上一个练习中的代码时,我们会得到警告:stat_bin()使用bins = 30。使用binwidth选择更好的值。

使用binwidth参数将上一个练习中制作的直方图更改为使用 1 英寸大小的 bin。

  1. 我们将不再使用直方图,而是制作一个平滑密度图。在这种情况下,我们不会创建一个对象,而是用一行代码来渲染图表。将之前用于制作直方图的几何形状更改为制作平滑密度。

  2. 现在我们将分别为男性和女性制作密度图。我们可以使用group参数来完成这项工作。我们通过美学映射来分配组,因为每个点在执行估计密度的计算之前都需要属于一个组。

  3. 我们也可以通过color参数来分配组。这个额外的优点是它使用颜色来区分组。将上面的代码更改为使用颜色。

  4. 我们也可以通过fill参数来分配组。这个额外的优点是它使用颜色来区分组,如下所示:

heights |> 
 ggplot(aes(height, fill = sex)) + 
 geom_density() 

然而,在这里,第二密度是覆盖在其他密度之上的。我们可以通过使用 alpha 混合来增加透明度,使曲线更加明显。在geom_density函数中将 alpha 参数设置为 0.2 以实现这一变化。


  1. ggplot2 文档↩︎

  2. Springer 出版社书籍↩︎

  3. 数据可视化速查表↩︎

  4. 数据可视化速查表↩︎

  5. rstudio 速查表许可↩︎

9 数据可视化原则

rafalab.dfci.harvard.edu/dsbook-part-1/dataviz/dataviz-principles.html

  1. 数据可视化

  2. 9 数据可视化原则

本章的目标是提供一些通用原则,我们可以将其用作有效数据可视化的指南。本节的大部分内容基于 Karl Broman¹的演讲,标题为“创建有效的图表和表格”²,其中包括一些使用 Karl 在 GitHub 存储库³上提供的代码制作的图表,以及 Peter Aldhous 数据可视化导论课程的课堂笔记⁴。遵循 Karl 的方法,我们展示了我们应该避免的绘图风格的一些示例,解释了如何改进它们,并以此作为原则列表的动机。我们比较了遵循这些原则的图表和不遵循的图表。

这些原则主要基于与人类检测模式和进行视觉比较相关的研究。首选的方法是那些最能符合我们大脑处理视觉信息的方式的方法。在决定可视化方法时,也要牢记我们的目标。我们可能是在比较可观察的数量、描述类别或数值的分布、比较两组数据或描述两个变量之间的关系。最后,我们想强调的是,根据受众调整和优化图表非常重要。例如,为我们自己制作的探索性图表将不同于旨在向普通受众传达发现的图表。

在本章中,我们专注于原则,不展示代码(代码可以在 GitHub 上查看⁵)。在第十章 dataviz-in-practice.html 中,我们将这些原则应用于案例研究,并展示了代码。

9.1 使用视觉线索编码数据

我们首先描述一些用于编码数据的原则。我们有几个视觉线索可供选择,包括位置、对齐长度、角度、面积、亮度和颜色色调。

为了说明这些视觉线索的一些比较,让我们假设我们想要报告关于浏览器偏好的两个假设性调查的结果,分别在 2000 年和 2015 年进行。对于每一年,我们只是比较五个数量——五个百分比。百分比的一个广泛使用的图形表示方法是饼图,由 Microsoft Excel 普及:

图片

在这里,我们使用面积和角度来表示数量,因为每个饼图的角和面积都与它所代表的数量成比例。事实证明,这是一个次优选择,因为,正如感知研究所示,人类在精确量化角度方面并不擅长,当面积是唯一的可用视觉线索时,情况更糟。饼图是仅使用面积的图表示例:

要了解量化角度和面积有多困难,请注意,上述图表中的排名和所有百分比在 2000 年到 2015 年之间发生了变化。你能确定实际的百分比并对浏览器的流行度进行排名吗?你能看到百分比从 2000 年到 2015 年的变化吗?从图表中很难判断。

在这种情况下,仅仅展示数字不仅更清晰,而且如果打印纸质副本,还能节省印刷成本:

浏览器 2000 2015
Opera 3 2
Safari 21 22
Firefox 23 21
Chrome 26 29
IE 28 27

这些量的最佳绘制方式是使用长度和位置作为视觉线索,因为人类在判断线性度量方面要优越得多。条形图通过使用与感兴趣的数量成比例的条形长度来实现这一方法。通过在战略性地选择的位置添加水平线,在这种情况下,每 10 的倍数,我们减轻了通过条形顶部位置进行量化的视觉负担。比较和对比我们可以从两个图表中提取的信息。

注意条形图中看到差异的难度有多大。实际上,我们现在可以通过跟随水平线到 x 轴来确定实际的百分比。

如果出于某种原因你需要制作饼图,为每个饼图切片标注其相应的百分比,以便观众不必从角度或面积中推断它们:

通常,在显示数量时,位置和长度比角度和/或面积更受欢迎。亮度和颜色比角度更难以量化。但,正如我们稍后将会看到的,当必须同时显示超过两个维度时,它们有时是有用的。

9.2 知道何时包含 0

当使用长度作为视觉线索时,如果不从 0 开始,就会误导信息。这是因为,当我们使用长度作为视觉线索,比如条形图时,我们暗示长度与显示的数量成比例。通过避免 0,相对较小的差异可以显得比实际上大得多。这种方法通常被政治家或媒体机构用来夸大差异。以下是一个说明性的例子⁶:

(来源:Fox News,via Media Matters⁷。)

从上面的图表来看,当实际上只增加了大约 16%时,担忧几乎增加了三倍。从 0 开始绘制图表可以清楚地说明这一点:

图片

这里是另一个例子:

图片

(来源:Fox News,via Flowing Data⁸)

这个图表使 13%的增长看起来像是五倍的变化。以下是适当的图表:

图片

最后,这是一个极端示例,将不到 2%的微小差异看起来像是 10-100 倍的变化:

图片

(来源:Venezolana de Televisión via El Mundo⁹)

这里是适当的图表:

图片

当使用位置而不是长度时,则不需要包含 0。这尤其适用于我们想要比较组内差异相对于组内变异性的情况。以下是一个说明性示例,展示了 2012 年按大洲划分的国家平均预期寿命:

图片

注意,在左侧的图表中,它包括 0,0 和 43 之间的空间没有提供任何信息,并且使得比较组间和组内变异性更加困难。

9.3 不要扭曲数量

在巴拉克·奥巴马总统 2011 年的国情咨文中,以下图表被用来比较美国 GDP 与四个竞争国家的 GDP:

图片

(来源:2011 年国情咨文¹⁰)

通过圆的面积来判断,美国似乎拥有比中国大五倍以上的经济,比法国大 30 倍以上。然而,如果我们看实际数字,我们会发现情况并非如此。实际的比率分别比中国和法国大 2.6 倍和 5.8 倍。这种扭曲的原因是半径而不是面积被用来与数量成比例,这意味着面积之间的比例是平方的:2.6 变成了 6.5,5.8 变成了 34.1。以下是我们将值与半径和面积成比例时得到的圆的比较:

图片

毫不奇怪,ggplot2默认使用面积而不是半径。当然,在这种情况下,我们实际上根本不应该使用面积,因为我们可以使用位置和长度:

图片

9.4 按有意义的值对类别进行排序

当一个轴用于显示类别时,就像在条形图和箱线图中那样,ggplot2的默认行为是当它们由字符字符串定义时按字母顺序排列类别。如果它们由因子定义,则按因子水平排列。我们很少想使用字母顺序。相反,我们应该按一个有意义的数量排列。在所有上述情况下,条形图都是按显示的值排列的。例外的是比较浏览器的条形图。在这种情况下,我们保持条形图的顺序不变,以简化比较。具体来说,我们不是在两年中分别按浏览器排序,而是按 2000 年和 2015 年的平均值对两年进行排序。

要欣赏正确的顺序如何有助于传达信息,假设我们想要创建一个比较各州谋杀率的图表。我们特别感兴趣的是最危险和最安全的州。注意当我们按字母顺序(默认)排列时与按实际比率排列时的区别:

图片

这里有一个例子,展示了各地区收入分布的箱线图。以下是两个版本的对比:

图片

第一条命令按字母顺序排列区域,而第二条命令按该组的中间值排列。

9.5 展示数据

我们一直专注于在类别间显示单个数量。我们现在将注意力转向显示数据,重点是比较组。

为了激励“展示数据”这一原则,我们回到我们描述身高给外星人 ET 的例子,这是一个外星人的例子。这次让我们假设 ET 对男性和女性身高差异感兴趣。用于比较组之间的一种常见图表,由 Microsoft Excel 等软件普及,是炸丨药图,它显示了平均值和标准误差(标准误差将在下一章定义,但不要与数据的标准差混淆)。图表看起来是这样的:

图片

每个组的平均值由每个条形的顶部表示,触角从平均值延伸到平均值加两个标准误差。如果 ET 收到的只是这个图表,他将几乎没有关于遇到一群男性和女性时可以期待什么的信息。条形达到 0:这是否意味着有身高不到一英尺的小人?是否所有男性都比最高的女性高?是否存在身高范围?由于我们没有提供关于身高分布的几乎任何信息,ET 无法回答这些问题。

这就引出了“展示数据”原则。这段简单的ggplot2代码仅通过显示所有数据点就生成了一个比条形图更有信息的图表:

图片

例如,这个图表让我们对数据的范围有一个概念。然而,这个图表也有局限性,因为我们实际上看不到为女性和男性分别绘制的 238 和 812 个点,而且许多点都重叠在一起。正如我们之前所描述的,可视化分布更有信息量。但在这样做之前,我们指出两种可以改进显示所有点的图表的方法。

第一个方法是添加抖动,这会给每个点添加一个小随机移动。在这种情况下,添加水平抖动不会改变解释,因为点的高度没有变化,但我们最小化了落在彼此之上的点的数量,因此更好地直观地了解数据的分布。第二个改进来自于使用alpha 混合:使点变得半透明。更多的点落在彼此之上,图表就越暗,这也帮助我们了解点的分布。以下是添加抖动和 alpha 混合的相同图表:

heights |> 
 ggplot(aes(sex, height)) +
 geom_jitter(width = 0.05, alpha = 0.2) 

* *现在我们开始感觉到,平均而言,男性的身高比女性高。我们还注意到点的深色水平带,表明许多人报告的值是四舍五入到最接近的整数。

9.6 便于比较

9.6.1 使用公共坐标轴

由于数据点很多,显示分布比显示单个点更有效。因此,我们为每个组显示直方图:

然而,从这个图表中并不立即明显看出男性平均身高比女性高。我们必须仔细观察才能注意到男性直方图的 x 轴有更高的值域。这里的一个重要原则是在比较两个图表中的数据时保持坐标轴相同。下面我们看看比较如何变得容易一些:

9.6.2 对齐图表以进行比较

在这些直方图中,与高度增加或减少相关的视觉线索分别是向左或向右的移动:水平变化。将图表垂直对齐有助于我们在坐标轴固定时看到这种变化:

这个图表使得注意到男性平均身高更高变得容易得多。

如果我们想要由箱线图提供的更紧凑的摘要,我们则将它们水平对齐,因为默认情况下,箱线图会随着高度的变化而上下移动。遵循我们的“展示数据”原则,我们随后将所有数据点叠加:

现在对比和比较这三个基于完全相同数据的图表:

注意从右侧的两个图表中我们学到了多少。条形图适用于显示一个数字,但当我们想要描述分布时并不十分有用.

9.7 对数变换

错误选择条形图以及未能使用应有的变换可能会特别扭曲。例如,考虑以下 2015 年每个大洲平均人口规模的条形图:

图片

从这个图表中,人们可能会得出结论,亚洲国家的人口比其他大陆多得多。遵循展示数据的原则,我们很快就会注意到这是由于两个非常大的国家,我们假设是印度和中国:

图片

在这里,我们关注的是对数变换如何改善偏斜数据的可视化效果的例子。其他需要了解的常见变换包括对数变换(logit),它有助于解释概率变化的倍数,以及平方根变换(sqrt),通常应用于稳定计数数据的方差。

9.7.1 右偏斜分布

国家人口规模提供了一个清晰的例子,说明右偏斜分布:大多数国家人口相对较小,而少数国家人口极其庞大。如上图箱线图所示,这种不平衡导致大多数数据点被压缩到图表的一个小区域内,而大部分绘图空间则留空。这使得很难看到大多数国家之间的有意义差异。

在第 8.10 节中,我们看到了如何通过解决这种偏斜来应用对数变换,从而提高散点图的易读性。同样,将人口规模应用对数变换可以产生更丰富的可视化效果。下面,我们将原始条形图与具有对数变换 y 轴的箱线图进行比较,以说明改进。

图片

使用新的图表,我们意识到非洲国家实际上具有比亚洲更大的中位人口规模。

9.7.2 比率

比率在数据分析中常用以比较两个值。例如,我们经常报告疾病的相对风险,例如指出吸烟者患病的风险比非吸烟者高三倍

从概念上讲,比率围绕 1 中心,比率值为 1 表示两个值之间没有差异。然而,从数学上讲,比率并不围绕 1 对称。例如,如果\(A\)\(B\)的比率为 3,意味着\(A\)\(B\)的三倍大,那么反向比率\(B\)\(A\)的比率为\(1/3\)。请注意,3 和\(1/3\)与 1 的距离并不相等,这可能会使以公平方式表示增加和减少的比率可视化变得困难。

因此,在显示比率时,通常使用对数变换。这是因为比率的对数具有对称性质:

\[\log(A/B) = -\log(B/A) \]

这意味着如果 \(A\)\(B\) 的三倍大,对数比率与 \(B\)\(A\) 的三倍大时距离零同样远,但方向相反。这种围绕零的对称性使得差异更容易从视觉上进行解释。

下面是一个展示使用对数尺度显示比率的优点的图表。

在绘制对数比率时,一个常见的挑战是处理分子或分母中的零,因为 \(\log(0)\) 是未定义的。此外,非常小的值可以产生接近正无穷或负无穷的对数比率,使得结果不稳定且难以解释。

解决这两个问题的实际方法是应用一个 连续性校正,这涉及到在分子和分母中添加一个小的常数。这防止了零的出现并稳定了极端值。常用的常数是 0.5,导致以下调整后的对数比率:

\[\log\left( \frac{A + 0.5}{B + 0.5} \right) \]

这个简单的调整有助于确保对数比率保持有限且更具可解释性,尤其是在处理接近 0 的值时。此外,当 \(A\)\(B\) 都很大时,它接近实际的对数比率 \(\log(A/B)\)

9.8 要比较的视觉线索应相邻

对于每个大陆,让我们比较 1970 年和 2010 年的收入。当比较 1970 年和 2010 年各地区之间的收入数据时,我们制作了一个类似于下面的图表,但这次我们研究的是大陆而不是地区。

ggplot2** 的默认设置是按字母顺序排列标签,因此带有 1970 的标签会排在带有 2010 的标签之前,这使得比较变得具有挑战性,因为一个大陆在 1970 年的分布与 2010 年的分布视觉上相差甚远。当每个大陆的箱线图相邻时,比较 1970 年和 2010 年的每个大陆就更容易了:

如果我们使用颜色来表示我们想要比较的两个事物,比较就会变得更加容易:

9.9 考虑色盲人士

大约 10% 的人口有色盲。不幸的是,ggplot2 中使用的默认颜色对这个群体来说并不理想。然而,ggplot2 确实使得更改图表中使用的调色板变得容易。我们如何在 R cookbook¹¹ 中使用色盲友好调色板的例子描述如下:

color_blind_friendly_cols <- 
 c("#999999", "#E69F00", "#56B4E9", "#009E73", 
 "#F0E442", "#0072B2", "#D55E00", "#CC79A7")

以下是一些颜色

有几个资源可以帮助你选择颜色,例如 R-bloggers 上的教程¹²。

9.10 两个变量的图表

一般来说,你应该使用散点图来可视化两个变量之间的关系。在我们考察的两个变量关系的每一个实例中,包括谋杀总数与人口规模以及预期寿命与生育率,我们都使用了散点图。这是我们通常推荐的图表。然而,也有一些例外,我们在这里描述两种替代图表:斜率图和 Bland-Altman 图**。

9.10.1 斜率图

有一个例外,在这种情况下,另一种类型的图表可能更有信息量,那就是当你比较同一类型的变量,但在不同时间点和相对较少的比较时。例如,比较 2010 年和 2015 年的预期寿命。在这种情况下,我们可能会推荐一个斜率图

ggplot2中没有斜率图的几何形状,但我们可以使用geom_line构建一个。我们需要做一些调整来添加标签。以下是一个比较 2010 年和 2015 年大型西方国家的例子:

图片

斜率图的一个优点是它允许我们根据线的斜率快速了解变化。虽然我们使用角度作为视觉线索,但我们也有位置来确定确切值。与散点图相比,比较改进要困难一些:

图片

在散点图中,我们遵循了“使用公共坐标轴”的原则,因为我们是在比较这些前后情况。然而,如果我们有很多点,斜率图就不再有用,因为很难看到所有的线。

9.10.2 Bland-Altman 图

由于我们主要对差异感兴趣,因此将一个坐标轴专门用于它是合理的。Bland-Altman 图,也称为 Tukey 均值差异图和 MA 图,显示了差异与平均值的关系:

图片

在这里,通过简单地观察 y 轴,我们很快就能看到哪些国家取得了最大的改进。我们还可以从 x 轴中获得整体价值的想法。

9.11 编码第三个变量

一个早期的散点图显示了婴儿生存率与平均收入之间的关系。下面是这个图表的版本,它编码了三个额外的变量:OPEC 成员资格、地区和人口**。

图片

我们用颜色和形状来编码分类变量。这些形状可以用shape参数来控制。以下是在 R 中可用的形状。对于最后五个,颜色在内部。

图片

对于连续变量,我们可以使用颜色、强度或大小。现在我们通过一个案例研究来展示我们如何做到这一点。

当选择颜色来量化一个数值变量时,我们有两种选择:顺序和发散。顺序颜色适合从高到低的数据。高值与低值可以清楚地区分。以下是RColorBrewer包提供的几个例子:

图片

发散颜色用于表示偏离中心的值。我们对数据范围的两端给予同等重视:高于中心值和低于中心值。我们可能会使用发散模式的一个例子是,如果我们想要展示离平均值的标差。以下是发散模式的几个例子:

图片

9.12 避免伪三维图表

下面的图表,取自科学文献¹³,显示了三个变量:剂量、药物类型和生存。尽管你的屏幕/书页是平面的二维,但图表试图模仿三维,并为每个变量分配一个维度。

图片

(图片由 Karl Broman 提供)

人类在三维空间中的视觉能力有限(这也解释了为什么平行停车很难),我们对伪三维的限制更为严重。为了看到这一点,尝试确定上图生存变量的值。你能判断紫色带状与红色带状相交的时刻吗?这是一个我们可以轻松使用颜色来表示分类变量而不是使用伪 3D 的例子:

图片

注意确定生存值是如何变得容易多了。

伪三维有时被完全无用地使用:即使第三维度并不代表一个数量,图表也被制作成看起来是三维的。这只会增加混乱,并使得传达信息变得更加困难。以下有两个例子:

图片

图片

(图片由 Karl Broman 提供)

9.13 避免过多的有效数字

默认情况下,像 R 这样的统计软件会返回许多有效数字。R 的默认行为是显示 7 位有效数字。那么多的数字通常不会增加信息,并且增加的视觉杂乱可能会让观看者难以理解信息。作为一个例子,以下是使用 R 从总数和人口中计算出的加利福尼亚在五个十年中的每 10,000 人疾病率:

年份 麻疹 百日咳 脊髓灰质炎
加利福尼亚 1940 37.8826320 18.3397861 0.8266512
加利福尼亚 1950 13.9124205 4.7467350 1.9742639
加利福尼亚 1960 14.1386471 NA 0.2640419
加利福尼亚 1970 0.9767889 NA NA
加利福尼亚 1980 0.3743467 0.0515466 NA

我们报告的精度高达每 10,000 个案例中的 0.00001,在日期变化的大背景下,这是一个非常小的值。在这种情况下,一位有效数字就足够了,并且清楚地表明了比率正在下降:

state year Measles Pertussis Polio
加利福尼亚 1940 37.9 18.3 0.8
加利福尼亚 1950 13.9 4.7 2.0
加利福尼亚 1960 14.1 NA 0.3
加利福尼亚 1970 1.0 NA NA
加利福尼亚 1980 0.4 0.1 NA

改变有效数字数量或四舍五入数字的有用方法是 signifround。你可以通过设置如下选项来全局定义有效数字的数量:options(digits = 3)

与显示表格相关的另一个原则是将比较的值放在列而不是行上。注意,我们上面的表格比这个表格更容易阅读:

| 州 | 疾病 | 1940 | 1950 | 1960 | 1970 | 1980 |
| --- | --- | --- | --- | --- | --- |
| 加利福尼亚 | 麻疹 | 37.9 | 13.9 | 14.1 | 1 | 0.4 |
| 加利福尼亚 | 百日咳 | 18.3 | 4.7 | NA | NA | 0.1 |

| 加利福尼亚 | 麻疹 | 0.8 | 2.0 | 0.3 | NA | NA |

9.14 了解你的受众

图表可以用于 1)我们自己的探索性数据分析,2)向专家传达信息,或 3)帮助向普通受众传达信息。确保目标受众理解图表的每个元素。

作为简单的例子,考虑一下,对于你自己的探索,可能更有用将数据进行对数转换然后绘图。然而,对于不熟悉将日志值转换回原始测量的普通受众,使用轴的对数刻度而不是对数转换的值将更容易理解。

9.15 练习

对于这些练习,我们将使用 dslabs 包中的疫苗数据:

library(dslabs)
  1. 饼图是合适的:

  2. 当我们想要显示百分比时。

  3. ggplot2 不可用。

  4. 当我在面包店时。

  5. 永远不会。条形图和表格总是更好。

  6. 下面的图表有什么问题:

  1. 值是错误的。最终投票结果是 306 票对 232 票。

  2. 轴没有从 0 开始。根据长度判断,似乎特朗普获得的选票是实际上的 3 倍,而实际上只是多了大约 30%。

  3. 颜色应该相同。

  4. 百分比应以饼图的形式显示。

  5. 查看以下两个图表。它们显示了相同的信息:1928 年 50 个州的麻疹比率。

如果你想确定哪些州在比率方面最好和最差,哪个图表更容易阅读?

  1. 它们提供了相同的信息,所以它们都是同样好的。

  2. 右侧的图表更好,因为它按字母顺序排列了各州。

  3. 右侧的图表更好,因为字母顺序与疾病无关,按实际比率排序后,我们可以快速看到谋杀率最高和最低的州。

  4. 两个图表都应该是一个饼图。

  5. 要在左侧制作图表,我们必须重新排序状态变量的级别。

dat <- us_contagious_diseases |> 
 filter(year == 1967 & disease=="Measles" & !is.na(population)) |>
 mutate(rate = count / population * 10000 * 52 / weeks_reporting)

注意当我们制作条形图时会发生什么:

dat |> ggplot(aes(state, rate)) +
 geom_col() +
 coord_flip() 

* *定义这些对象:

state <- dat$state
rate <- dat$count/dat$population*10000*52/dat$weeks_reporting

重新定义state对象,以便按级别重新排序。打印新的state对象及其级别,以便您可以查看向量是否未按级别重新排序。

  1. 现在用一行代码定义dat表,如上所述,但将mutate的使用改为创建一个比率变量,并重新排序状态变量,以便按此变量重新排序级别。然后使用上面的代码制作条形图,但针对这个新的dat

  2. 假设我们感兴趣的是比较美国各地区的枪支谋杀率。我们看到这个图表:

library(dslabs)
murders |> mutate(rate = total/population*100000) |>
group_by(region) |>
summarize(avg = mean(rate)) |>
mutate(region = factor(region)) |>
ggplot(aes(region, avg)) +
geom_col() + 
ylab("Murder Rate Average")

* *并决定搬到西部地区的一个州。这种解释的主要问题是什么?

  1. 类别按字母顺序排序。

  2. 图表没有显示标准误差。

  3. 它没有显示所有数据。我们看不到一个地区内的变异性,而且可能最安全的状态不在西部。

  4. 东北部平均最低。

  5. 制作谋杀率的箱形图,定义为

murders |> mutate(rate = total/population*100000)

按地区显示所有点,并按中位数比率对地区进行排序。

  1. 下面的图表显示了三个连续变量。

看起来线 \(x=2\) 将点分开。但实际上并非如此,我们可以通过绘制几个二维点来看到这一点。

为什么会发生这种情况?

  1. 人类不擅长阅读伪 3D 图表。

  2. 代码中肯定有错误。

  3. 颜色让我们困惑。

  4. 当我们有 3 个变量可供比较时,不应使用散点图来比较两个变量。


  1. kbroman.org/↩︎

  2. www.biostat.wisc.edu/~kbroman/presentations/graphs2017.pdf↩︎

  3. github.com/kbroman/Talk_Graphs↩︎

  4. www.peteraldhous.com/ucb/2014/dataviz/index.html↩︎

  5. github.com/rafalab/dsbook-part-1/blob/main/dataviz/dataviz-principles.qmd↩︎

  6. www.peteraldhous.com/ucb/2014/dataviz/week2.html↩︎

  7. mediamatters.org/blog/2013/04/05/fox-news-newest-dishonest-chart-immigration-enf/193507↩︎

  8. flowingdata.com/2012/08/06/fox-news-continues-charting-excellence/↩︎

  9. www.elmundo.es/america/2013/04/15/venezuela/1366029653.html↩︎

  10. www.youtube.com/watch?v=kl2g40GoRxg↩︎

  11. www.cookbook-r.com/Graphs/Colors_(ggplot2)/#a-colorblind-friendly-palette↩︎

  12. www.r-bloggers.com/2013/10/creating-colorblind-friendly-figures/↩︎

  13. projecteuclid.org/download/pdf_1/euclid.ss/1177010488↩︎

10 数据可视化实践

原文:rafalab.dfci.harvard.edu/dsbook-part-1/dataviz/dataviz-in-practice.html

  1. 数据可视化

  2. 10 数据可视化实践

在本章中,我们将展示相对简单的 ggplot2 代码如何创建有洞察力和美观的图表。作为动机,我们将创建帮助我们更好地理解世界健康和经济趋势的图表。我们将实施在第八章(ggplot2.html)和第九章(dataviz-principles.html)中学到的知识,并学习如何增强代码以完善图表。在我们进行案例研究的过程中,我们将描述相关的通用数据可视化原则,并学习诸如 faceting时间序列图转换脊线图 等概念。

10.1 案例研究 1:关于贫困的新见解

Hans Rosling¹ 是 Gapminder Foundation² 的联合创始人,该组织致力于通过使用数据来消除关于所谓发展中世界的常见神话,以教育公众。该组织使用数据来展示实际的健康和经济趋势如何与来自耸人听闻的灾难、悲剧和其他不幸事件的媒体报道中的叙述相矛盾。正如 Gapminder Foundation 网站所述:

记者和游说者讲述戏剧性的故事。这是他们的工作。他们讲述关于非凡事件和非凡人物的故事。这些戏剧性的故事堆积在人们的脑海中,形成了一个过于戏剧化的世界观和强烈的负面压力感:“世界正在变得更糟!”,“这是我们与他们的对抗!”,“其他人很奇怪!”,“人口一直在增长!”和“没有人关心!”

Hans Rosling 以他独特的方式传达了基于实际数据趋势,使用有效的数据可视化。本节基于两个体现这种教育方法的演讲:关于贫困的新见解³ 和你见过的最佳统计数据⁴。具体来说,在本节中,我们使用数据来尝试回答以下两个问题:

  1. 是否可以说,今天的世界被划分为西方富裕国家和非洲、亚洲和拉丁美洲的发展中国家是公平的描述?

  2. 在过去 40 年中,国家间的收入不平等是否恶化了?

为了回答这些问题,我们将使用 dslabs 中提供的 gapminder 数据集。这个数据集是使用来自 Gapminder Foundation 的多个电子表格创建的。你可以这样访问表格:

library(tidyverse
library(dslabs)
gapminder |> as_tibble()
#> # A tibble: 10,545 × 9
#>   country     year infant_mortality life_expectancy fertility population
#>   <fct>      <int>            <dbl>           <dbl>     <dbl>      <dbl>
#> 1 Albania     1960            115\.             62.9      6.19    1636054
#> 2 Algeria     1960            148\.             47.5      7.65   11124892
#> 3 Angola      1960            208              36.0      7.32    5270844
#> 4 Antigua a…  1960             NA              63.0      4.43      54681
#> 5 Argentina   1960             59.9            65.4      3.11   20619075
#> # ℹ 10,540 more rows
#> # ℹ 3 more variables: gdp <dbl>, continent <fct>, region <fct>

正如在 关于贫困的新见解 视频中所做的那样,我们首先测试我们对不同国家儿童死亡率差异的知识。对于下面六个国家配对中的每一个,你认为哪个国家在 2015 年的儿童死亡率最高?你认为哪一对最相似?

  1. 斯里兰卡或土耳其

  2. 波兰或韩国

  3. 马来西亚或俄罗斯

  4. 巴基斯坦或越南

  5. 泰国或南非

在没有数据的情况下回答这些问题时,通常会选择非欧洲国家具有更高的儿童死亡率:斯里兰卡高于土耳其,韩国高于波兰,马来西亚高于俄罗斯。通常也假设被认为是发展中国家的国家:巴基斯坦、越南、泰国和南非,具有类似的死亡率。

要用数据回答这些问题,我们可以使用dplyr。例如,对于第一次比较,我们看到:

gapminder |> 
 filter(year == 2015 & country %in% c("Sri Lanka","Turkey")) |> 
 select(country, infant_mortality)
#>     country infant_mortality
#> 1 Sri Lanka              8.4
#> 2    Turkey             11.6

土耳其的婴儿死亡率较高。

我们可以在所有比较中使用此代码,并发现以下结果:

国家 婴儿死亡率 国家 婴儿死亡率
斯里兰卡 8.4 土耳其 11.6
波兰 4.5 韩国首尔 2.9
马来西亚 6.0 俄罗斯 8.2
巴基斯坦 65.8 越南 17.3
泰国 10.5 南非 33.6

我们看到列表中的欧洲国家儿童死亡率较高:波兰的比率高于韩国,俄罗斯的比率高于马来西亚。我们还看到巴基斯坦的比率远高于越南,南非的比率远高于泰国。结果证明,当汉斯·罗斯林向受过教育的群体提出这个测验时,平均得分不到 2.5 分(满分 5 分),比他们随机猜测的结果还要差。这表明,我们不仅仅是无知,而是被误导了。在本章中,我们将看到数据可视化如何帮助我们获得信息。

10.2 散点图

前一小节中描述的误解的原因源于一种先入为主的观念,即世界被分为两组:西方世界(西欧和北美),以长寿和家庭成员数量少为特征,与发展中国家(非洲、亚洲和拉丁美洲)相对,以寿命短和家庭规模大为特征。但数据是否支持这种二分法?

回答这个问题的必要数据也存在于我们的gapminder表中。利用我们新学的数据可视化技能,我们将能够应对这个挑战。

为了分析这种世界观,我们的第一个图是寿命与生育率(每名女性的平均子女数)的散点图。我们首先查看大约 50 年前的数据,那时这种观点可能首次在我们脑海中根深蒂固。

filter(gapminder, year == 1962) |>
 ggplot(aes(fertility, life_expectancy)) +
 geom_point()

* *大多数观点可以分为两个截然不同的类别:

  1. 平均寿命约为 70 岁,每个家庭有 3 个或更少的子女。

  2. 平均寿命低于 65 岁,每个家庭有 5 个或更多的子女。

为了确认这些国家确实是我们预期的地区,我们可以用颜色来代表大洲。

filter(gapminder, year == 1962) |>
 ggplot( aes(fertility, life_expectancy, color = continent)) +
 geom_point() 

* 1962 年,“西方与发展中国家”的观点在某种程度上是现实的。50 年后,情况是否仍然如此?***

10.3 分面分析

我们可以很容易地以与 1962 年相同的方式绘制 2012 年的数据。然而,为了进行比较,并排图更可取。在ggplot2中,我们可以通过分面变量来实现这一点:我们通过某些变量对数据进行分层,并为每个层绘制相同的图。

要实现分面,我们添加一个带有facet_grid函数的层,该函数自动分离图表。这个函数允许你通过列表示一个变量和行表示另一个变量,使用最多两个变量进行分面。该函数期望行和列变量由一个~分隔。以下是一个添加了facet_grid作为最后一层的散点图的示例:

filter(gapminder, year %in% c(1962, 2012)) |>
 ggplot(aes(fertility, life_expectancy, col = continent)) +
 geom_point() +
 facet_grid(year~continent)

* *我们看到了每个大陆/年份对的图。然而,这只是一个例子,比我们想要的要多,我们想要的只是比较 1962 年和 2012 年。在这种情况下,只有一个变量,我们使用.让分面知道我们不是使用第二个变量:

filter(gapminder, year %in% c(1962, 2012)) |>
 ggplot(aes(fertility, life_expectancy, col = continent)) +
 geom_point() +
 facet_grid(. ~ year)

* 这个图清楚地表明,大多数国家已经从发展中国家集群移动到西方国家*集群。到 2012 年,西方与发展中国家的观点已经不再适用。当比较欧洲和亚洲时,这一点尤其明显,后者包括几个取得了巨大进步的国家。

10.3.1 facet_wrap

为了探索这种转变是如何逐年发生的,我们可以绘制几个年份的图。例如,我们可以添加 1970 年、1980 年、1990 年和 2000 年。如果我们这样做,我们不会希望所有图都在同一行,这是facet_grid的默认行为,因为它们会变得太薄而无法显示数据。相反,我们希望使用多行和多列。facet_wrap函数允许我们通过自动包装一系列图来实现这一点,以便每个显示都有可查看的维度:

years <- c(1962, 1980, 1990, 2000, 2012)
continents <- c("Europe", "Asia")
gapminder |> 
 filter(year %in% years & continent %in% continents) |>
 ggplot( aes(fertility, life_expectancy, col = continent)) +
 geom_point() +
 facet_wrap(~year) 

* *这个图清楚地显示了大多数亚洲国家比欧洲国家改善的速度要快得多。

10.3.2 为更好的比较固定刻度

轴范围的默认选择很重要。当不使用facet时,这个范围由图中显示的数据决定。当使用facet时,这个范围由所有图中显示的数据决定,因此在整个图中保持固定。这使得跨图比较变得容易。例如,在上面的图中,我们可以看到大多数国家的预期寿命都在增加,而生育率在下降。我们之所以看到这一点,是因为点云在移动。如果我们调整刻度,情况就不同了:

filter(gapminder, year %in% c(1962, 2012)) |>
 ggplot(aes(fertility, life_expectancy, col = continent)) +
 geom_point() +
 facet_wrap(. ~ year, scales = "free")

* *在上面的图中,我们必须特别注意范围,以注意到右侧的图具有更长的预期寿命。

10.4 时间序列图

上述可视化有效地说明了数据不再支持西方与发达国家对比的观点。一旦我们看到这些图表,新的问题就会浮现。例如,哪些国家改善得更多,哪些国家改善得更少?在过去 50 年里,改善是持续的吗,还是在某些时期加速了?为了更仔细地观察,这可能有助于回答这些问题,我们引入了时间序列图**。

时间序列图在 x 轴上有时间,在 y 轴上有感兴趣的输出或测量。例如,这里是美国生育率的趋势图:

gapminder |> 
 filter(country == "United States") |> 
 ggplot(aes(year, fertility)) +
 geom_point()

* *我们看到趋势根本不是线性的。相反,在 20 世纪 60 年代和 70 年代有一个急剧下降,降至 2 以下。然后趋势回到 2,并在 20 世纪 90 年代稳定下来。

当点均匀且密集地分布时,就像这里一样,我们通过用线连接点来创建曲线,以传达这些数据来自单一系列,这里是一个国家。为此,我们使用geom_line函数而不是geom_point

gapminder |> 
 filter(country == "United States") |> 
 ggplot(aes(year, fertility)) +
 geom_line()

* *当我们查看两个国家时,这尤其有用。我们可以对数据进行子集化,包括两个国家,一个来自欧洲,一个来自亚洲,然后调整上面的代码:

countries <- c("South Korea", "Germany")
 gapminder |> filter(country %in% countries) |> 
 ggplot(aes(year,fertility)) +
 geom_line()

* *不幸的是,这不是我们想要的图表。不是每个国家的线条,而是两个国家的点被连接起来。这实际上是可以预料的,因为我们没有告诉ggplot我们想要两条分开的线。为了让ggplot知道需要分别制作两条曲线,我们将每个点分配到一个group中,一个国家一个:

countries <- c("South Korea","Germany")
 gapminder |> filter(country %in% countries & !is.na(fertility)) |> 
 ggplot(aes(year, fertility, group = country)) +
 geom_line()

* *但是哪条线对应哪个国家?我们可以分配颜色来区分这一点。使用color参数为不同的国家分配不同颜色的一个有用副作用是数据会自动分组:

countries <- c("South Korea","Germany")
gapminder |> filter(country %in% countries & !is.na(fertility)) |> 
 ggplot(aes(year,fertility, col = country)) +
 geom_line()

* *该图清楚地显示了韩国的生育率在 20 世纪 60 年代和 70 年代急剧下降,到 1990 年与德国的生育率相似。

对于趋势图,我们建议标注线条而不是使用图例,因为观众可以快速看到哪条线对应哪个国家。这个建议实际上适用于大多数图表:标注通常比图例更受欢迎。

我们演示了如何使用geomtextpath包来做这件事。我们定义一个包含标签位置的表格数据,然后使用第二个映射仅用于这些标签:

library(geomtextpath)
gapminder |> 
 filter(country %in% countries) |> 
 ggplot(aes(year, life_expectancy, col = country, label = country)) +
 geom_textpath() +
 theme(legend.position = "none")

* *这个图清楚地显示了预期寿命的提高是如何随着生育率的下降而发生的。1960 年,德国人的预期寿命比韩国人长 15 年,尽管到 2010 年这个差距完全消失。它展示了在过去 40 年里许多非西方国家所取得的进步。

10.5 数据变换

我们现在将注意力转向第二个问题,这个问题与普遍认为的过去几十年全球财富分配变得更加不均的观点相关。当问及普通大众是否认为贫穷国家变得更穷,富裕国家变得更富时,大多数人回答是。通过使用分层、直方图、平滑密度和箱线图,我们将能够理解这是否确实如此。首先,我们学习如何变换有时可以帮助提供更丰富的总结和图表。

gapminder数据表包括一个包含各国国内生产总值(GDP)的列。GDP 衡量一个国家在一年内生产的商品和服务的市场价值。人均 GDP 通常被用作一个国家财富的粗略总结。在这里,我们将这个数量除以 365,以获得更可解释的度量——每日美元。使用当前的美元作为单位,每天收入低于 2 美元的人被定义为生活在绝对贫困中。我们将这个变量添加到数据表中:

gapminder <- gapminder |> 
 mutate(dollars_per_day = gdp/population/365)

GDP 值已根据通货膨胀进行调整,并代表当前的美元,因此这些值意味着可以跨年进行比较。当然,这些是国家的平均值,并且每个国家内部存在很大的变异性。下面描述的所有图表和洞察都关系到国家平均值,而不是个人。

10.5.1 对数变换

下面是 1970 年的每日收入直方图:

past_year <- 1970
gapminder |> 
 filter(year == past_year & !is.na(gdp)) |>
 ggplot(aes(dollars_per_day)) + 
 geom_histogram(binwidth = 1, color = "black")

* *我们使用color = "black"参数来绘制边界,并清楚地区分各个区间。

在这个图中,我们看到大多数国家的平均值每天低于 10 美元。然而,x 轴的大部分都用于表示平均每天收入超过 10 美元的 35 个国家。因此,这个图对于每天收入低于 10 美元的国家来说并不很有信息量。

快速看到有多少国家的平均每日收入约为 1 美元(极其贫穷)、2 美元(非常贫穷)、4 美元(贫穷)、8 美元(中等)、16 美元(富裕)、32 美元(富有)、64 美元(极其富有)可能更有信息量。这些变化是乘法的,而对数变换将乘法变化转换为加法变化:当使用 2 为底时,一个值的翻倍变成了增加 1。

如果我们对平均值应用 2 为底的对数变换,分布如下:

gapminder |> 
 filter(year == past_year & !is.na(gdp)) |>
 ggplot](https://ggplot2.tidyverse.org/reference/ggplot.html)(aes([log2(dollars_per_day))) + 
 geom_histogram(binwidth = 1, color = "black")

* *从某种意义上说,这提供了对中低收入国家的近距离观察。

10.5.2 哪个底数?

在上述情况下,我们在对数转换中使用了 2 为底。其他常见的选择是以\(\mathrm{e}\)(自然对数)和 10 为底。

通常,我们不推荐使用自然对数进行数据探索和可视化。这是因为虽然\(2², 2³, 2⁴, \dots\)\(10², 10³, \dots\)在心理计算上很容易,但\(\mathrm{e}², \mathrm{e}³, \dots\)并不容易。自然对数刻度不直观也不容易解释。

在每天美元的例子中,我们使用 2 为底而不是 10 为底,因为结果的范围更容易解释。未转换的值的范围是 0.3269426,48.8852142。

在 10 为底的情况下,这变成了一个包含非常少整数的范围:只有 0 和 1。以 2 为底,我们的范围包括-2, -1, 0, 1, 2, 3, 4 和 5。当\(x\)是介于-10 和 10 之间的整数时,计算\(2^x\)\(10^x\)更容易,所以我们更喜欢在刻度上使用较小的整数。范围有限的一个后果是选择箱宽更具挑战性。以 2 为底的对数,我们知道箱宽为 1 将转换为范围在\(x\)\(2x\)的箱。

对于一个以 10 为底更有意义的例子,考虑人口规模。以 10 为底的对数更可取,因为这些的范围是:

filter(gapminder, year == past_year) |>
 summarize(min = min(population), max = max(population))
#>     min      max
#> 1 46075 8.09e+08

以下是转换值的直方图:

gapminder |> 
 filter(year == past_year) |>
 ggplot](https://ggplot2.tidyverse.org/reference/ggplot.html)(aes([log10(population))) +
 geom_histogram(binwidth = 0.5, color = "black")

* *在上图中,我们可以快速看到国家人口介于十万和一百亿之间。

10.5.3 转换值还是刻度?

在图中使用对数转换有两种方式。我们可以在绘图之前对数据进行对数转换,或者在轴上使用对数刻度。除了轴上的数字外,图表看起来将是一样的。两种方法都很实用,各有不同的优点。如果我们对数据进行对数转换,我们就可以更容易地解释刻度中的中间值。例如,如果我们看到:

----1----x----2--------3----

对于对数转换的数据,我们知道\(x\)的值大约是 1.5。如果刻度是对数的:

----10---x---100------1000---

然后,为了确定x,我们需要计算\(10^{1.5}\),这在我们的头脑中并不容易做到。然而,显示对数刻度的优点是原始值会在图中显示,这更容易解释。例如,我们会看到“每天 32 美元”而不是“每天 5 以 2 为底的对数美元”。

如我们之前所学,如果我们想使用对数来缩放坐标轴,我们可以使用scale_x_continuous函数。我们不需要先对值进行对数转换,而是应用这一层:

gapminder |> 
 filter(year == past_year & !is.na(gdp)) |>
 ggplot(aes(dollars_per_day)) + 
 geom_histogram(binwidth = 1, color = "black") +
 scale_x_continuous(trans = "log2")

!](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/hvd-cs109-intd-ds-2e/img/a9131d0b50294ed0cf1e864198b4a20b.png)* *请注意,以 10 为底的对数转换有自己的函数:[scale_x_log10(),但目前以 2 为底没有,尽管我们可以轻松定义自己的。

通过trans参数还有其他可用的转换。正如我们稍后将要学习的,平方根(sqrt)转换在考虑计数时很有用。对数转换(logit)在绘制 0 到 1 之间的比例时很有用。reverse转换在我们想要较小的值在右边或顶部时很有用。

10.6 多众数分布

在上面的直方图中,我们看到两个峰值:一个大约在 4,另一个大约在 32。在统计学中,这些峰值有时被称为众数。分布的众数是频率最高的值。正态分布的众数是平均值。当一个分布,如上面的分布,不是单调地从众数递减时,我们称它再次上升和下降的位置为局部众数,并说该分布有多个众数

上面的直方图表明,1970 年国家收入分布有两个众数:一个大约每天 2 美元(对数 2 尺度中的 1),另一个大约每天 32 美元(对数 2 尺度中的 5)。这种双峰性与由平均每天收入低于 8 美元(对数 2 尺度中的 3)的国家和高于该收入的国家组成的二分世界一致。

10.7 比较分布

直方图显示,1970 年的收入分布值显示出二分性。然而,直方图并没有显示这两组国家是西方发展中国家的对立。

让我们先快速查看按地区划分的数据。我们按中位数重新排序地区,并使用对数刻度。

gapminder |> 
 filter(year == past_year & !is.na(gdp)) |>
 mutate(region = reorder(region, dollars_per_day, FUN = median)) |>
 ggplot(aes(dollars_per_day, region)) +
 geom_point() +
 scale_x_continuous(trans = "log2") 

* *我们已经可以看到确实存在“西方与其它”的二分性:我们看到两个清晰的组,富裕组由北美、北欧和西欧以及新西兰和澳大利亚组成。我们根据这个观察结果定义组:

gapminder <- gapminder |> 
 mutate(group = case_when(
 region %in% c("Western Europe", "Northern Europe","Southern Europe", 
 "Northern America", 
 "Australia and New Zealand") ~ "West",
 region %in% c("Eastern Asia", "South-Eastern Asia") ~ "East Asia",
 region %in% c("Caribbean", "Central America", 
 "South America") ~ "Latin America",
 continent == "Africa" & 
 region != "Northern Africa" ~ "Sub-Saharan",
 TRUE ~ "Others"))

我们将这个group变量转换为因子,以控制级别的顺序:

gapminder <- gapminder |> 
 mutate(group = factor(group, levels = c("Others", "Latin America", 
 "East Asia", "Sub-Saharan",
 "West")))

在下一节中,我们将展示如何可视化和比较组间的分布。

10.7.1 箱线图

上述的探索性数据分析揭示了 1970 年人均收入分布的两个特点。使用直方图,我们发现了一个双峰分布,众数与穷国和富国相关。现在我们想要比较这五个组之间的分布,以确认“西方与其它”的二分性。每个类别中的点数足够多,以至于一个总结图可能是有用的。我们可以生成五个直方图或五个密度图,但将所有视觉总结放在一个图中可能更实用。因此,我们首先将箱线图并排放置。请注意,我们添加了theme(axis.text.x = element_text(angle = 90, hjust = 1))层,将组标签垂直,因为如果水平显示它们则不合适,并且我们移除了轴标签以留出空间。

p <- gapminder |> 
 filter(year == past_year & !is.na(gdp)) |>
 ggplot(aes(group, dollars_per_day)) +
 geom_boxplot() +
 scale_y_continuous(trans = "log2") +
 xlab("") +
 theme(axis.text.x = element_text(angle = 90, hjust = 1)) 
p

* *箱线图的局限性在于,通过将数据总结为五个数字,我们可能会错过数据的重要特征。避免这种情况的一种方法是通过展示数据。

p + geom_point(alpha = 0.5)

10.7.2 脊线图

展示每个单独的点并不总是能揭示分布的重要特征。尽管这里并非如此,但当数据点的数量非常大以至于出现重叠时,展示数据可能会适得其反。箱线图通过提供五个数字的总结来帮助解决这个问题,但这也有其局限性。例如,箱线图不允许我们发现双峰分布。为了看到这一点,请注意下面的两个图表是在总结相同的数据集:

在我们担心箱线图总结过于简单的情况下,我们可以展示堆叠的平滑密度或直方图。我们称这些为脊线图。因为我们习惯于使用 x 轴上的值来可视化密度,所以我们垂直堆叠它们。此外,由于这种方法需要更多的空间,因此叠加它们很方便。ggridges包提供了一个方便的函数来完成这项工作。以下是上面显示的带有箱线图的收入数据,但使用的是脊线图

library(ggridges)
p <- gapminder |> 
 filter(year == past_year & !is.na(dollars_per_day)) |>
 ggplot(aes(dollars_per_day, group)) + 
 scale_x_continuous(trans = "log2") 
p  + geom_density_ridges() 

* *请注意,我们必须反转用于箱线图的xy轴。一个有用的geom_density_ridges参数是scale,它允许你确定重叠的程度,其中scale = 1表示没有重叠,更大的值会导致更多的重叠。

如果数据点的数量足够小,我们可以使用以下代码将它们添加到脊线图中:

p + geom_density_ridges(jittered_points = TRUE)

* 默认情况下,点的垂直高度是抖动的,不应以任何方式解释。为了显示数据点,但又不使用抖动,我们可以使用以下代码来添加所谓的数据的毛刷表示*。

p + geom_density_ridges(jittered_points = TRUE, 
 position = position_points_jitter(height = 0),
 point_shape = '|', point_size = 3, 
 point_alpha = 1, alpha = 0.7)

10.7.3 示例:1970 年与 2010 年的收入分布

数据探索清楚地表明,1970 年存在“西方与其它”的二分法。但这种二分法持续存在吗?让我们使用facet_grid来看看分布是如何变化的。首先,我们将关注两组:西方和其它。我们制作四个直方图。我们只为在 1970 年和 2010 年都有数据的国家绘制此图。请注意,一些国家是在 1970 年之后成立的;例如,苏联在 20 世纪 90 年代分裂成几个国家。我们还注意到,2010 年有更多国家的数据可用。

因此,我们只为在两年都有数据的国家绘制图表:

past_year <- 1970
present_year <- 2010
years <- c(past_year, present_year)
country_list <- gapminder |> 
 filter(year %in% c(present_year, past_year)) |>
 group_by(country) |>
 summarize(n = sum(!is.na(dollars_per_day)), .groups = "drop") |>
 filter(n == 2) |>
 pull(country)

这些 108 个国家占世界人口的 86%,因此这个子集应该是具有代表性的。我们可以使用此代码比较分布:

gapminder |> 
 filter(year %in% years & country %in% country_list) |>
 mutate(west = ifelse(group == "West", "West", "Developing")) |>
 ggplot(aes(dollars_per_day)) +
 geom_histogram(binwidth = 1, color = "black") +
 scale_x_continuous(trans = "log2") + 
 facet_grid(year ~ west)

* 现在我们看到富裕国家变得更富裕了,但从百分比来看,贫穷国家似乎改善得更多。特别是,我们看到每天收入超过 16 美元的发展中国家*的比例显著增加。

为了看到哪些特定地区改善得最多,我们可以重新制作我们上面制作的箱线图,但现在添加 2010 年,然后使用分面来比较两年。

gapminder |> 
 filter(year %in% years & country %in% country_list) |>
 ggplot(aes(group, dollars_per_day)) +
 geom_boxplot() +
 theme(axis.text.x = element_text(angle = 90, hjust = 1)) +
 scale_y_continuous(trans = "log2") +
 xlab("") +
 facet_grid(. ~ year)

* *在这里,我们暂停一下,介绍另一个强大的ggplot2功能。因为我们想比较每个地区在前后的情况,所以对于每个地区,将 1970 年的箱线图放在 2010 年的箱线图旁边会方便很多。一般来说,当数据并排绘制时,比较更容易。

因此,我们不是进行分面处理,而是将每年的数据保留在一起,并根据年份来着色(或填充)它们。请注意,组别会根据年份自动分开,并且每对箱线图都会相邻绘制。因为年份列是一个数字,所以我们必须将其转换为因子,因为ggplot2会自动为因子的每个类别分配一个颜色。

gapminder |> 
 filter(year %in% years & country %in% country_list) |>
 mutate(year = factor(year)) |>
 ggplot(aes(group, dollars_per_day, fill = year)) +
 geom_boxplot() +
 theme(axis.text.x = element_text(angle = 90, hjust = 1)) +
 scale_y_continuous(trans = "log2") +
 xlab("") 

* *之前的数据探索表明,在过去 40 年里,富裕国家和贫穷国家之间的收入差距已经显著缩小。我们使用一系列直方图和箱线图来观察这一点。我们建议用一张图表简洁地传达这一信息。

让我们从注意到 1970 年和 2010 年的收入分布密度图传达了差距正在缩小的信息开始:

gapminder |> 
 filter(year %in% years & country %in% country_list) |>
 ggplot(aes(dollars_per_day)) +
 geom_density(fill = "grey") + 
 scale_x_continuous(trans = "log2") + 
 facet_grid(. ~ year)

* *在 1970 年的图表中,我们可以看到两个明显的模式:贫穷国家和富裕国家。到了 2010 年,一些贫穷国家似乎向右移动,缩小了差距。

我们需要传达的下一个信息是,这种分布变化的原因是几个贫穷国家变得更富裕,而不是一些富裕国家变得更贫穷。为此,我们可以为我们数据探索期间确定的组分配颜色。

然而,由于当我们叠加两个密度时,默认情况下,无论每个组的大小如何,分布曲线下的面积都会加起来等于 1,因此我们首先需要学习如何以保留每个组国家数量信息的方式制作这些平滑密度。为此,我们需要学习如何使用geom_density函数访问计算变量。

10.7.4 访问计算变量

为了使这些密度的面积与组的大小成比例,我们可以简单地乘以 y 轴的值和组的大小。从geom_density的帮助文件中,我们看到函数计算一个名为count的变量,它正好做这件事。我们希望这个变量在 y 轴上,而不是在密度上。

ggplot2 中,我们使用函数 after_stat 访问这些变量。因此,我们将使用以下映射:

aes(x = dollars_per_day, y = after_stat(count))

现在,我们可以通过简单地更改上一段代码块中的映射来创建所需的图表。我们还将扩展 x 轴的极限。

p <- gapminder |> 
 filter(year %in% years & country %in% country_list) |>
 mutate(group = ifelse(group == "West", "West", "Developing")) |>
 ggplot(aes(dollars_per_day, y = after_stat(count), fill = group)) +
 scale_x_continuous](https://ggplot2.tidyverse.org/reference/scale_continuous.html)(trans = "log2", limits = [c(0.125, 300))
p + geom_density(alpha = 0.2) + facet_grid(year ~ .)

* *如果我们想使密度更平滑,我们使用 bw 参数,以便在每种密度中使用相同的带宽。我们在尝试了几个值后选择了 0.75。

p + geom_density(alpha = 0.2, bw = 0.75) + facet_grid(year ~ .)

* *这个图表现在非常清楚地显示了正在发生的事情。发展中国家的分布正在变化。出现了一个第三种模式,由那些差距缩小最多的国家组成。

为了可视化是否上述任何组在驱动这一变化,我们可以快速制作一个脊线图:

gapminder |> 
 filter(year %in% years & !is.na(dollars_per_day)) |>
 ggplot(aes(dollars_per_day, group)) + 
 scale_x_continuous(trans = "log2") + 
 geom_density_ridges(bandwidth = 1.5) +
 facet_grid(. ~ year)

* *另一种实现这一目标的方法是将密度堆叠在一起:

gapminder |> 
 filter(year %in% years & country %in% country_list) |>
 group_by(year) |>
 mutate(weight = population/sum(population)*2) |>
 ungroup() |>
 ggplot(aes(dollars_per_day, fill = group)) +
 scale_x_continuous](https://ggplot2.tidyverse.org/reference/scale_continuous.html)(trans = "log2", limits = [c(0.125, 300)) + 
 geom_density(alpha = 0.2, bw = 0.75, position = "stack") + 
 facet_grid(year ~ .) 

* *在这里,我们可以清楚地看到东亚、拉丁美洲和其他地区的分布如何明显向右移动,而撒哈拉以南非洲保持不变。

注意,我们按组级别的顺序排列,以便首先绘制西方的密度,然后是撒哈拉以南非洲。将两个极端先绘制出来,使我们能更好地看到剩余的双峰性。

10.7.5 加权密度

最后一点,我们注意到这些分布对每个国家都给予相同的权重。所以,如果大多数人口正在改善,但居住在一个非常大的国家,例如中国,我们可能不会重视这一点。实际上,我们可以使用 weight 映射参数来对平滑密度进行加权。然后图表看起来是这样的:

这个特定的图表非常清楚地展示了收入分配差距是如何在撒哈拉以南非洲的大部分贫困人口中缩小的。

10.8 案例研究 2:生态谬误

在本节中,我们一直在比较世界各地的地区。我们看到了,平均而言,一些地区比其他地区做得更好。在本节中,我们专注于描述在检查一个国家的婴儿死亡率与平均收入之间的关系时,组内变异性的重要性。

我们定义了更多地区,并比较了这些地区的平均值:

这两个变量之间的关系几乎是完美线性的,选择了轴变换,图表显示了显著差异。在西方,不到 0.5% 的婴儿死亡,而在撒哈拉以南非洲,这个比率超过 6%!

注意,这个图表使用了一种新的转换,即逻辑转换。

10.8.1 逻辑转换

比例或率 \(p\) 的逻辑或 logit 转换定义为:

\[f(p) = \log \left( \frac{p}{1-p} \right) \]

\(p\) 是比例或概率时,被取对数的量,\(p/(1-p)\),被称为优势比。在这种情况下 \(p\) 是婴儿存活的比例。优势比告诉我们预期存活婴儿比死亡婴儿多多少。对数转换使这一关系对称。如果比率相同,那么对数优势比为 0。增加或减少的折叠分别转化为正数和负数增量。

这个比例尺在我们要强调接近 0 或 1 的差异时很有用。对于存活率来说,这很重要,因为 90% 的存活率是不可接受的,而 99% 的存活率相对较好。我们更希望存活率接近 99.9%。我们希望我们的比例尺能够突出这些差异,而 logit 就能这样做。请注意,99.9/0.1 大约是 99/1 的 10 倍,而 99/1 大约是 90/10 的 10 倍。通过使用对数,这些折叠变化转化为恒定的增量。

10.8.2 展示数据

现在,回到我们的图表。基于上面的图表,我们是否得出结论,一个低收入国家注定会有低存活率?我们是否得出结论,撒哈拉以南非洲的存活率都低于南亚,而南亚又低于太平洋岛屿,依此类推?

基于显示平均值的图表得出这一结论被称为生态谬误。仅在地区层面的平均存活率和收入之间观察到几乎完美的关系。一旦我们展示了所有数据,我们就会看到一个更为复杂的故事:

具体来说,我们看到存在大量的变异性。我们看到来自同一地区的国家可以非常不同,而且收入相同的国家可能会有不同的存活率。例如,尽管撒哈拉以南非洲的平均健康状况和经济成果最差,但该群体内部存在很大的变异性。毛里求斯和博茨瓦纳的表现优于安哥拉和塞拉利昂,毛里求斯与西方国家相当。

10.9 案例研究 3:疫苗和传染病

疫苗帮助拯救了数百万人的生命。在 19 世纪,在通过疫苗接种计划实现群体免疫之前,诸如天花和脊髓灰质炎等传染病的死亡是常见的。然而,尽管有科学证据证明其重要性,疫苗接种计划今天却变得有些有争议。

争议始于 1988 年发表的一篇论文⁵,由安德鲁·韦克菲尔德领导,声称接种麻疹、腮腺炎和风疹(MMR)疫苗与自闭症和肠道疾病的出现之间存在联系。尽管有大量科学证据与此发现相矛盾,但耸人听闻的媒体报道和阴谋论者的恐惧煽动导致公众的一部分人相信疫苗是有害的。因此,许多父母停止了给他们的孩子接种疫苗。鉴于疾病控制中心(CDC)估计,疫苗接种将预防过去 20 年出生的儿童中超过 2100 万次住院和 73.2 万次死亡(参见疫苗接种儿童计划时代的免疫接种益处——美国,1994-2013,MMWR⁶),这种危险的做法可能会带来潜在的灾难性后果。1988 年的论文已被撤回,安德鲁·韦克菲尔德最终被“从英国医学注册名单上除名,声明其发表在《柳叶刀》杂志上的研究存在故意伪造,因此被禁止在英国行医。”(来源:维基百科⁷)。然而,由于自称活动家继续传播关于疫苗的错误信息,误解仍然存在。

数据的有效沟通是针对错误信息和恐惧煽动的强大解毒剂。在本部分书的引言中,我们展示了一个例子,由《华尔街日报》的文章⁸提供,展示了疫苗对抗传染病影响的相关数据。在这里,我们重建了这个例子。

10.9.1 疫苗数据

用于这些图表的数据由提科项目⁹收集、整理和分发。它们包括从 1928 年到 2011 年,来自所有五十个州的七种疾病的每周报告计数。我们在dslabs包中包括了年度总计:

library(tidyverse
library(RColorBrewer)
library(dslabs)
names(us_contagious_diseases)
#> [1] "disease"         "state"           "year" 
#> [4] "weeks_reporting" "count"           "population"

我们创建了一个临时对象dat,它只存储麻疹数据,包括每 10 万人比率,按疾病平均值排序州,并删除阿拉斯加和夏威夷,因为它们只在 20 世纪 50 年代末成为州。请注意,有一个weeks_reporting列告诉我们一年中有多少周的数据被报告。在计算比率时,我们必须调整这个值。

the_disease <- "Measles"
dat <- us_contagious_diseases |>
 filter(!state %in% c("Hawaii","Alaska") & disease == the_disease) |>
 mutate(rate = count / population * 10000 * 52 / weeks_reporting) |> 
 mutate(state = reorder(state, ifelse(year <= 1963, rate, NA), 
 median, na.rm = TRUE)) 

10.9.2 趋势图

我们现在可以轻松地按年份绘制疾病比率。以下是加利福尼亚的麻疹数据:

dat |> filter(state == "California" & !is.na(rate)) |>
 ggplot(aes(year, rate)) +
 geom_line() + 
 ylab("Cases per 10,000")  + 
 geom_vline(xintercept = 1963, col = "blue")

* *我们在 1963 年添加了一条垂直线,因为这是疫苗被引入的时间¹⁰。

10.10 热图

现在我们能否在一个图表中展示所有州的数据?我们需要展示三个变量:年份、州和比率。在《华尔街日报》的图表中,他们使用 x 轴表示年份,y 轴表示州,使用颜色色调来表示比率。然而,他们使用的颜色刻度,从黄色到蓝色到绿色到橙色到红色,可以改进。

在我们的例子中,我们想使用顺序调色板,因为没有有意义的中心,只有低和高比率。

我们使用geom_tile几何形状用代表疾病率的颜色填充区域。我们使用平方根变换来避免极高计数主导图表。注意,缺失值以灰色显示。请注意,一旦一种疾病基本被根除,一些州就完全停止报告病例。这就是为什么我们在 1980 年之后看到这么多灰色。

dat |> ggplot(aes(year, state, fill = rate)) +
 geom_tile(color = "grey50") +
 scale_x_continuous](https://ggplot2.tidyverse.org/reference/scale_continuous.html)(expand = [c(0,0)) +
 scale_fill_gradientn(colors = brewer.pal(9, "Reds"), trans = "sqrt") +
 geom_vline(xintercept = 1963, col = "blue") +
 theme_minimal() + 
 theme(panel.grid = element_blank(), 
 legend.position = "bottom", 
 text = element_text(size = 8)) +
 labs(title = the_disease, x = "", y = "")

* *这个图表非常有力地证明了疫苗的贡献。然而,这个图表的一个局限性是它使用颜色来表示数量,我们之前解释过这会使我们难以确切知道值有多高。位置和长度是更好的线索。如果我们愿意放弃州信息,我们可以制作一个显示值的图表版本。我们还可以显示美国的平均值,我们像这样计算:

avg <- us_contagious_diseases |>
 filter(disease == the_disease) |> group_by(year) |>
 summarize(us_rate = sum(count, na.rm = TRUE) / 
 sum(population, na.rm = TRUE) * 10000)

现在我们来制作图表,我们简单地使用geom_line几何形状:

dat |> 
 filter(!is.na(rate)) |>
 ggplot() +
 geom_line(aes(year, rate, group = state),  color = "grey50", 
 show.legend = FALSE, alpha = 0.2, linewidth = 1) +
 geom_line(mapping = aes(year, us_rate),  data = avg, linewidth = 1) +
 scale_y_continuous](https://ggplot2.tidyverse.org/reference/scale_continuous.html)(trans = "sqrt", breaks = [c(5, 25, 125, 300)) + 
 ggtitle("Cases per 10,000 by state") + 
 xlab("") + ylab("") +
 geom_text](https://ggplot2.tidyverse.org/reference/geom_text.html)(data = [data.frame(x = 1955, y = 50), 
 mapping = aes(x, y, label = "US average"), 
 color = "black") + 
 geom_vline(xintercept = 1963, col = "blue")

* *理论上,我们可以使用颜色来表示分类值州,但很难挑选出 50 种不同的颜色。

10.11 练习

  1. 重现我们之前制作的图像图,但这次是针对天花的。对于这个图表,不要包括在 10 周或以上没有报告病例的年份。

  2. 现在重现我们之前制作的时序图,但这次按照之前问题的说明来处理天花。

  3. 对于加利福尼亚州,制作一个显示所有疾病率的时序图。只包括有 10 周或以上报告的年份。每种疾病使用不同的颜色。

  4. 现在同样为美国的比率做同样的处理。提示:使用summarize计算美国比率,即总数除以总人口。


  1. zh.wikipedia.org/wiki/Hans_Rosling↩︎

  2. www.gapminder.org/↩︎

  3. www.ted.com/talks/hans_rosling_reveals_new_insights_on_poverty?language=zh↩︎

  4. www.ted.com/talks/hans_rosling_shows_the_best_stats_you_ve_ever_seen↩︎

  5. www.thelancet.com/journals/lancet/article/PIIS0140-6736(97)11096-0/abstract↩︎

  6. www.cdc.gov/mmwr/preview/mmwrhtml/mm6316a4.htm↩︎

  7. zh.wikipedia.org/wiki/Andrew_Wakefield↩︎

  8. graphics.wsj.com/infectious-diseases-and-vaccines/↩︎

  9. www.tycho.pitt.edu/††

  10. 疾病控制与预防中心(2014 年)。国际旅行健康信息 2014(黄色手册)。第 250 页。ISBN 9780199948505††

数据整理

原文:rafalab.dfci.harvard.edu/dsbook-part-1/wrangling/intro-to-wrangling.html

本书使用的数据集已经以 R 对象的形式提供给你,具体来说是数据框。美国谋杀数据、报告身高数据和 Gapminder 数据都是数据框。这些数据集包含在dslabs包中,我们使用data函数加载了它们。此外,我们还以所谓的“整洁”形式提供了数据。tidyverse 包和函数假设数据是“整洁”的,这个假设是这些包能够如此良好协作的重要原因。

然而,在数据科学项目中,数据很少作为包的一部分轻松可用。我们做了很多“幕后”工作,将原始原始数据整理成你工作的整洁表格。更典型的情况是数据存储在文件、数据库中,或从文档中提取,包括网页、推文或 PDF 文件。在这些情况下,第一步是将数据导入 R,当使用tidyverse时,整理数据。数据分析过程中的这一初始步骤通常涉及几个步骤,通常是复杂的步骤,以将数据从其原始形式转换为整洁形式,这极大地简化了后续的分析。我们将这个过程称为数据整理

在这里,我们涵盖了数据整理过程的几个常见步骤,包括整理数据、字符串处理、HTML 解析、处理日期和时间以及文本分析。在单一分析中,通常并不需要所有这些整理步骤,但作为一名数据分析师,你可能会在某个时刻遇到所有这些步骤。我们用来展示数据整理技术的某些示例是基于我们将原始数据转换为dslabs包提供的整洁数据集,并在书中作为示例使用的工作。

11  重塑数据

原文:rafalab.dfci.harvard.edu/dsbook-part-1/wrangling/reshaping-data.html

  1. 数据处理

  2. 11  重塑数据

正如我们在书中所见,数据以整洁格式存在是 tidyverse 流畅运行的关键。在数据分析过程的第一步,即导入数据之后,一个常见的下一步是将数据重塑成便于后续分析的形式。tidyr包是tidyverse的一部分,它包含了一些对整理数据有用的函数。

在本节中,我们将使用第 4.1 节中描述的生育宽格式数据集作为示例。

library(tidyverse 
library(dslabs)
path <- system.file("extdata", package = "dslabs")
filename <- file.path(path, "fertility-two-countries-example.csv")
wide_data <- read_csv(filename)

11.1 pivot_longer

tidyr包中最常用的函数之一是pivot_longer,它可以将宽数据转换为整洁数据。

与大多数 tidyverse 函数一样,pivot_longer函数的第一个参数是要转换的数据框。在这里,我们想要重塑wide_data数据集,使得每一行代表一个生育观察值,这意味着我们需要三个列来存储年份、国家和观察值。在其当前形式中,不同年份的数据位于不同的列中,年份值存储在列名中。通过names_tovalues_to参数,我们将告诉pivot_longer我们想要分配给包含当前列名和观察值的列的列名。默认名称是namevalue,这些通常是可用的选择。在这种情况下,这两个参数的更好选择可能是yearfertility。请注意,数据文件中没有任何地方告诉我们这是生育数据。相反,我们是从文件名中解读出来的。通过cols参数,第二个参数,我们指定包含观察值的列;这些是将会被旋转的列。默认情况下是旋转所有列,所以大多数情况下我们必须指定这些列。在我们的例子中,我们想要19601961直到2015的列。

因此,旋转生育数据的代码如下:

new_tidy_data <- wide_data |>
 pivot_longer(`1960`:`2015`, names_to = "year", values_to = "fertility")

我们可以看到数据已经被转换为整洁格式,列有yearfertility

head(new_tidy_data)
#> # A tibble: 6 × 3
#>   country year  fertility
#>   <chr>   <chr>     <dbl>
#> 1 Germany 1960       2.41
#> 2 Germany 1961       2.44
#> 3 Germany 1962       2.47
#> 4 Germany 1963       2.49
#> 5 Germany 1964       2.49
#> # ℹ 1 more row

并且由于我们有两个国家,并且这一列没有被旋转,所以每年产生了两行。有一种更快的方式来编写这段代码,即指定哪些列不会包含在旋转中,而不是所有将要旋转的列:

new_tidy_data <- wide_data |>
 pivot_longer(-country, names_to = "year", values_to = "fertility")

new_tidy_data对象看起来与这样定义的原始tidy_data相同

tidy_data <- gapminder |> 
 filter(country %in% c("South Korea", "Germany") & !is.na(fertility)) |>
 select(country, year, fertility)

只有一个细微的差别。你能发现吗?看看年份列的数据类型。pivot_longer函数假设列名是字符。因此,在我们准备好绘图之前,我们需要进行一些额外的处理。我们需要将年份列转换为数字:

new_tidy_data <- wide_data |>
 pivot_longer(-country, names_to = "year", values_to = "fertility") |>
 mutate(year = as.integer(year))

现在数据已经整洁,我们可以使用以下相对简单的 ggplot 代码:

new_tidy_data |> 
 ggplot(aes(year, fertility, color = country)) + 
 geom_point()

11.2 pivot_wider

正如我们将在后面的示例中看到的那样,有时为了数据整理的目的,将整洁数据转换为宽数据是有用的。我们经常将此用作整理数据的一个中间步骤。pivot_wider函数基本上是pivot_longer的逆函数。第一个参数是数据,但由于我们正在使用管道,所以我们没有显示它。names_from参数告诉pivot_wider哪个变量将被用作列名。values_from参数指定哪个变量用于填充单元格。

new_wide_data <- new_tidy_data |> 
 pivot_wider(names_from = year, values_from = fertility)
select(new_wide_data, country, `1960`:`1967`)
#> # A tibble: 2 × 9
#>   country     `1960` `1961` `1962` `1963` `1964` `1965` `1966` `1967`
#>   <chr>        <dbl>  <dbl>  <dbl>  <dbl>  <dbl>  <dbl>  <dbl>  <dbl>
#> 1 Germany       2.41   2.44   2.47   2.49   2.49   2.48   2.44   2.37
#> 2 South Korea   6.16   5.99   5.79   5.57   5.36   5.16   4.99   4.85

pivot_wider类似,names_fromvalues_from默认为namevalue

11.3 分离变量

与通常所需的数据整理相比,上述数据整理很简单。在我们的示例电子表格文件中,我们包括了一个稍微复杂一些的示例。它包含两个变量:预期寿命和生育率。然而,它的存储方式并不整洁,而且,正如我们将解释的,也不是最优的。

path <- system.file("extdata", package = "dslabs")
 filename <- "life-expectancy-and-fertility-two-countries-example.csv"
filename <-  file.path(path, filename)
 raw_dat <- read_csv(filename)
select(raw_dat, 1:5)
#> # A tibble: 2 × 5
#>   country     `1960_fertility` `1960_life_expectancy` `1961_fertility`
#>   <chr>                  <dbl>                  <dbl>            <dbl>
#> 1 Germany                 2.41                   69.3             2.44
#> 2 South Korea             6.16                   53.0             5.99
#> # ℹ 1 more variable: `1961_life_expectancy` <dbl>

首先,请注意数据是宽格式。其次,请注意这个表格包含了两个变量的值,生育率和预期寿命,列名编码了哪一列代表哪个变量。在列名中编码信息是不推荐的,但不幸的是,这相当常见。我们将运用我们的整理技巧来提取这些信息,并以整洁的方式存储。

我们可以使用pivot_longer函数开始数据整理,但不再使用year作为新列的列名,因为它还包含变量类型。我们暂时将其称为name

dat <- raw_dat |> pivot_longer(-country)
head(dat)
#> # A tibble: 6 × 3
#>   country name                 value
#>   <chr>   <chr>                <dbl>
#> 1 Germany 1960_fertility        2.41
#> 2 Germany 1960_life_expectancy 69.3 
#> 3 Germany 1961_fertility        2.44
#> 4 Germany 1961_life_expectancy 69.8 
#> 5 Germany 1962_fertility        2.47
#> # ℹ 1 more row

结果是并不完全符合我们所说的整洁,因为每个观测值都关联着两行,而不是一行。我们希望将两个变量,生育率和预期寿命的值,分别放在两个单独的列中。实现这一目标的首要挑战是将name列分割成年份和变量类型。请注意,这个列中的条目使用下划线将年份和变量名称分开:

dat$name[1:5]
#> [1] "1960_fertility"       "1960_life_expectancy" "1961_fertility" 
#> [4] "1961_life_expectancy" "1962_fertility"

在列名中编码多个变量是一个如此常见的问题,以至于tidyr包包括将列分割成两个或更多列的函数。separate_wider_delim函数接受三个参数:要分割的列的名称、用于新列的名称以及分隔变量的字符。因此,将变量名称从年份中分离出来的第一次尝试可能是:

dat |> separate_wider_delim(name, delim = "_", 
 names = c("year", "name"))

然而,这一行代码将产生错误。这是因为预期寿命的名称由三个由下划线分隔的字符串组成,而生育率的名称有两个。这是一个常见问题,因此separate_wider_delim函数有too_fewtoo_many参数来处理这些情况。我们在帮助文件中看到,选项too_many = merge将合并任何额外的部分。以下一行代码就是我们所需要的:

dat |> separate_wider_delim(name, delim = "_", 
 names = c("year", "name"), 
 too_many = "merge")
#> # A tibble: 224 × 4
#>   country year  name            value
#>   <chr>   <chr> <chr>           <dbl>
#> 1 Germany 1960  fertility        2.41
#> 2 Germany 1960  life_expectancy 69.3 
#> 3 Germany 1961  fertility        2.44
#> 4 Germany 1961  life_expectancy 69.8 
#> 5 Germany 1962  fertility        2.47
#> # ℹ 219 more rows

但我们还没有完成。我们需要为每个变量创建一个列,并将 year 转换为数字。正如我们所学的,pivot_wider 函数可以做到这一点:

dat <- dat |> 
 separate_wider_delim(name, delim = "_", 
 names = c("year", "name"), 
 too_many = "merge") |>
 pivot_wider() |>
 mutate(year = as.integer(year))
 dat
#> # A tibble: 112 × 4
#>   country  year fertility life_expectancy
#>   <chr>   <int>     <dbl>           <dbl>
#> 1 Germany  1960      2.41            69.3
#> 2 Germany  1961      2.44            69.8
#> 3 Germany  1962      2.47            70.0
#> 4 Germany  1963      2.49            70.1
#> 5 Germany  1964      2.49            70.7
#> # ℹ 107 more rows

数据现在以整洁格式存在,每行有一个观测值,包含三个变量:年份、生育率和预期寿命。

三个相关函数是 separate_wider_positionseparate_wider_regexuniteseparate_wider_position 使用宽度而不是分隔符。separate_wider_regex,在第 16.4.13 节中描述,提供了更多控制我们如何分隔以及保留什么的选项。unite 函数可以看作是 separate 函数的逆操作:它将两列合并为一列。

11.4 使用 data.table 重新塑形

一般来说,你可以使用 tidyverse 做的所有事情都可以使用 data.table 和 base R 做到,尽管可能更难阅读,但它通常更灵活、更快、更高效。在这里,我们展示了如何使用 data.table 方法进行 pivot_longerpivot_widerseparate。我们将使用之前使用的这个例子来说明:

path <- system.file("extdata", package = "dslabs")
filename <- file.path(path, "fertility-two-countries-example.csv")

11.4.1 pivot_longermelt

如果在 tidyverse 中编写

wide_data <- read_csv(filename)
new_tidy_data <- wide_data |>
 pivot_longer(-1, names_to = "year", values_to = "fertility")

data.table 中,我们使用 melt 函数。

library(data.table)
dt_wide_data <- fread(filename) 
dt_new_tidy_data  <- melt(dt_wide_data, 
 measure.vars = 2:ncol(dt_wide_data), 
 variable.name = "year", 
 value.name = "fertility")

11.4.2 pivot_widerdcast

如果在 tidyverse 中编写

new_wide_data <- new_tidy_data |> 
 pivot_wider(names_from = year, values_from = fertility)

data.table 中,我们使用 dcast 函数。

dt_new_wide_data <- dcast(dt_new_tidy_data, formula = ... ~ year,
 value.var = "fertility")

11.4.3 分离变量

我们现在用之前使用的例子来说明:

path <- system.file("extdata", package = "dslabs")
filename <- "life-expectancy-and-fertility-two-countries-example.csv"
filename <-  file.path(path, filename)

tidyverse 中,我们使用

raw_dat <- read_csv(filename)
dat <- raw_dat |> pivot_longer(-country) |>
 separate_wider_delim](https://tidyr.tidyverse.org/reference/separate_wider_delim.html)(name, delim = "_", names = [c("year", "name"), 
 too_many = "merge") |>
 pivot_wider() |>
 mutate(year = as.integer(year))

data.table 中,我们可以使用 tstrsplit 函数:

dt_raw_dat <- fread(filename)
dat_long <- melt(dt_raw_dat, 
 measure.vars = which(names(dt_raw_dat) != "country"), 
 variable.name = "name", value.name = "value")
dat_long, [c("year", "name", "name2") := 
 tstrsplit(name, "_", fixed = TRUE, type.convert = TRUE)]
dat_long[is.na(name2), name2 := ""]
dat_long, name := [paste(name, name2, sep = "_")][, name2 := NULL]
dat_wide <- dcast(dat_long, country + year ~ name, value.var = "value")

11.5 janitor

janitor 包包括一些常见的数据整理步骤的函数。这些步骤通常是重复且耗时的。关键特性包括检查和清理列名、删除空或重复行以及转换数据类型的函数。它还提供了轻松生成频率表和执行交叉表的特性。该包旨在与 tidyverse 无缝工作。在这里,我们展示了四个示例。

电子表格通常使用与编程不兼容的名称。最常见的问题是带有空格的列名。clean_names() 函数试图解决这个问题和其他常见问题。默认情况下,它强制变量名变为小写,并使用下划线代替空格。在这个例子中,我们更改了上一节中创建的 dat 对象的变量名,然后演示了这个函数的工作方式:

library(janitor)
names(dat) <- c("Country", "Year", "Fertility",  "Life Expectancy")
clean_names](https://sfirke.github.io/janitor/reference/clean_names.html)(dat) |> [names()
#> [1] "country"         "year"            "fertility" 
#> [4] "life_expectancy"

另一个非常常见的挑战现实是,数字矩阵保存在电子表格中,并包含一个包含行名的字符列。为了解决这个问题,我们必须删除第一列,但只有在将它们分配为我们将在将数据框转换为矩阵后用作 rownames 的向量之后才能这样做。column_to_rows函数为我们执行这些操作,我们只需要指定包含 rownames 的列:

data.frame(ids = letters[1:3], x = 1:3, y = 4:6) |> 
 column_to_rownames("ids") |>
 as.matrix() 
#>   x y
#> a 1 4
#> b 2 5
#> c 3 6

另一个常见的挑战是电子表格将列名作为第一行。为了快速解决这个问题,我们可以使用row_to_names

x <- read.csv(file.path(path, "murders.csv"), header = FALSE) |> 
 row_to_names(1)
names(x)
#> [1] "state"      "abb"        "region"     "population" "total"

我们的最后一个例子与查找重复项有关。在创建电子表格时,行重复是一个非常常见的错误。get_dups函数可以找到并报告重复记录。默认情况下,它考虑所有变量,但你也可以指定使用哪些变量。

x <- bind_rows(x, x[1,])
get_dupes(x)
#> No variable names specified - using all columns.
#>     state abb region population total dupe_count
#> 1 Alabama  AL  South    4779736   135          2
#> 2 Alabama  AL  South    4779736   135          2

11.6 练习

  1. 运行以下命令以定义co2_wide对象:
co2_wide <- data.frame(matrix(co2, ncol = 12, byrow = TRUE)) |> 
 setNames(1:12) |>
 mutate(year = as.character(1959:1997))

使用pivot_longer函数将此整理成整洁数据集。将包含二氧化碳测量的列命名为co2,将月份列命名为month。将结果对象命名为co2_tidy

  1. 使用以下代码绘制二氧化碳与月份的关系图,每年使用不同的曲线:
co2_tidy |> ggplot(aes(month, co2, color = year)) + geom_line()

如果预期的图表没有生成,可能是因为co2_tidy$month不是数字:

class(co2_tidy$month)

重写你的代码以确保月份列是数字的。然后绘制图表。

  1. 我们从这个图表中学到了什么?

  2. 二氧化碳的测量值从 1959 年到 1997 年单调递增。

  3. 二氧化碳的测量值在夏季较高,并且从 1959 年到 1997 年的年平均值有所增加。

  4. 二氧化碳的测量值看起来是恒定的,随机变异性解释了差异。

  5. 二氧化碳的测量值没有季节性趋势。

  6. 现在加载admissions数据集,该数据集包含六个专业中男性和女性的录取信息,并仅保留录取百分比列:

load(admissions)
dat <- admissions |> select(-applicants)

如果我们把一个观测值看作是一个专业,并且每个观测值有两个变量(男性录取百分比和女性录取百分比),那么这就不整洁。使用pivot_wider函数整理成整洁形状:每个专业一行。

  1. 现在我们将尝试一个更高级的数据整理挑战。我们想要整理录取数据,以便对于每个专业,我们都有 4 个观测值:admitted_menadmitted_womenapplicants_menapplicants_women。我们在这里执行的技巧实际上相当常见:首先使用pivot_longer生成一个中间数据框,然后使用pivot_wider获取我们想要的整洁数据。我们将逐步进行,并在接下来的两个练习中也是如此。

使用pivot_longer函数创建一个包含观测类型列(admittedapplicants)的tmp数据框。将新列命名为namevalue

  1. 现在你有一个名为 tmp 的对象,包含 majorgendernamevalue 列。注意,如果你将 namegender 结合起来,我们就能得到我们想要的列名:admitted_menadmitted_womenapplicants_menapplicants_women。使用 unite 函数创建一个名为 column_name 的新列。

  2. 现在用 pivot_wider 函数生成每个专业有四个变量的整洁数据。

  3. 现在用管道符写一行代码,将 admissions 转换为上一练习中产生的表格。

12 连接表

原文:rafalab.dfci.harvard.edu/dsbook-part-1/wrangling/joining-tables.html

  1. 数据整理

  2. 12 连接表

对于一个特定的分析,所需的信息可能不仅仅在一个表中。在这里,我们用一个简单的例子来说明合并表的一般挑战。

假设我们想探索美国各州人口规模与选举人票数之间的关系。我们在这个表中找到了人口规模:

library(tidyverse
library(dslabs)
head(murders)
#>        state abb region population total
#> 1    Alabama  AL  South    4779736   135
#> 2     Alaska  AK   West     710231    19
#> 3    Arizona  AZ   West    6392017   232
#> 4   Arkansas  AR  South    2915918    93
#> 5 California  CA   West   37253956  1257
#> 6   Colorado  CO   West    5029196    65

以及在这个表中:

head(results_us_election_2016)
#>          state electoral_votes clinton trump johnson stein mcmullin
#> 1   California              55    61.7  31.6    3.37 1.965    0.279
#> 2        Texas              38    43.2  52.2    3.16 0.798    0.472
#> 3      Florida              29    47.8  49.0    2.20 0.684    0.000
#> 4     New York              29    59.0  36.5    2.29 1.398    0.134
#> 5     Illinois              20    55.8  38.8    3.79 1.387    0.211
#> 6 Pennsylvania              20    47.5  48.2    2.38 0.810    0.105
#>   others
#> 1 1.0383
#> 2 0.0992
#> 3 0.2732
#> 4 0.6591
#> 5 0.0294
#> 6 1.0571

仅仅将这两个表连接起来是不行的,因为各州的顺序并不相同。

identical(results_us_election_2016$state, murders$state)
#> [1] FALSE

下面描述的连接函数旨在处理这个挑战。

12.1 连接

dplyr**包中的连接函数确保表被合并,使得匹配的行在一起。如果你了解 SQL,你会看到这种方法和语法非常相似。一般思路是,需要确定一个或多个列,这些列将用于匹配两个表。然后返回一个新的包含合并信息的表。注意,如果我们使用left_join通过州来连接上面的两个表会发生什么(我们将移除others列,并将electoral_votes重命名,以便表格适合页面):

tab <- left_join(murders, results_us_election_2016, by = "state") |>
 select(-others) |> rename(ev = electoral_votes)
head(tab)
#>        state abb region population total ev clinton trump johnson stein
#> 1    Alabama  AL  South    4779736   135  9    34.4  62.1    2.09 0.442
#> 2     Alaska  AK   West     710231    19  3    36.6  51.3    5.88 1.800
#> 3    Arizona  AZ   West    6392017   232 11    44.6  48.1    4.08 1.319
#> 4   Arkansas  AR  South    2915918    93  6    33.7  60.6    2.65 0.838
#> 5 California  CA   West   37253956  1257 55    61.7  31.6    3.37 1.965
#> 6   Colorado  CO   West    5029196    65  9    48.2  43.3    5.18 1.383
#>   mcmullin
#> 1    0.000
#> 2    0.000
#> 3    0.670
#> 4    1.165
#> 5    0.279
#> 6    1.040

数据已成功连接,现在我们可以,例如,绘制一个图来探索关系:

我们看到关系接近线性,每百万人口大约有 2 张选举人票,但小州的比率更高。

在实践中,并不是每个表中的每一行都一定在另一个表中有一个匹配的行。因此,我们有了几种不同的连接方式。为了说明这个挑战,我们将从上面的表中取子集。我们创建了tab1tab2这两个表,它们有一些共同的状态,但并非全部:

tab_1 <- slice(murders, 1:6) |> select(state, population)
tab_2 <- results_us_election_2016 |> 
 filter(state %in% c("Alabama", "Alaska", "Arizona", 
 "California", "Connecticut", "Delaware")) |> 
 select(state, electoral_votes) |> rename(ev = electoral_votes)

我们将在下一节中使用这两个表作为例子。

12.1.1 左连接

假设我们想要一个类似于tab_1的表,但添加我们可用的任何州的选举人票数。为此,我们使用left_join,并将tab_1作为第一个参数。我们通过by参数指定要匹配的列。

left_join(tab_1, tab_2, by = "state")
#>        state population ev
#> 1    Alabama    4779736  9
#> 2     Alaska     710231  3
#> 3    Arizona    6392017 11
#> 4   Arkansas    2915918 NA
#> 5 California   37253956 55
#> 6   Colorado    5029196 NA

注意,NAs 被添加到tab_2中未出现的两个州。此外,请注意,这个函数以及所有其他连接函数都可以通过管道接收第一个参数:

tab_1 |> left_join(tab_2, by = "state")

12.1.2 右连接

如果我们想要一个与第二个表具有相同行的表,而不是与第一个表具有相同行的表,我们可以使用right_join

tab_1 |> right_join(tab_2, by = "state")
#>         state population ev
#> 1     Alabama    4779736  9
#> 2      Alaska     710231  3
#> 3     Arizona    6392017 11
#> 4  California   37253956 55
#> 5 Connecticut         NA  7
#> 6    Delaware         NA  3

现在 NAs 出现在来自tab_1的列中。

12.1.3 内连接

如果我们只想保留两个表中都有信息的行,我们使用inner_join。你可以将其视为一个交集:

inner_join(tab_1, tab_2, by = "state")
#>        state population ev
#> 1    Alabama    4779736  9
#> 2     Alaska     710231  3
#> 3    Arizona    6392017 11
#> 4 California   37253956 55

12.1.4 全连接

如果我们想保留所有行并用 NAs 填充缺失部分,我们可以使用 full_join。你可以将其视为一个并集:

full_join(tab_1, tab_2, by = "state")
#>         state population ev
#> 1     Alabama    4779736  9
#> 2      Alaska     710231  3
#> 3     Arizona    6392017 11
#> 4    Arkansas    2915918 NA
#> 5  California   37253956 55
#> 6    Colorado    5029196 NA
#> 7 Connecticut         NA  7
#> 8    Delaware         NA  3

12.1.5 半连接

semi_join 函数允许我们保留第一表中在第二表中具有信息的部分。它不会添加第二表的列:

semi_join(tab_1, tab_2, by = "state")
#>        state population
#> 1    Alabama    4779736
#> 2     Alaska     710231
#> 3    Arizona    6392017
#> 4 California   37253956

12.1.6 反连接

函数 anti_joinsemi_join 的对立面。它保留第一表中在第二表中没有信息的元素:

anti_join(tab_1, tab_2, by = "state")
#>      state population
#> 1 Arkansas    2915918
#> 2 Colorado    5029196

以下图表总结了上述连接:

(Image courtesy of RStudio¹. CC-BY-4.0 license². Cropped from original.)

12.2 绑定

尽管我们在这本书中还没有使用它,但另一种常见的将数据集合并的方式是通过 绑定。与连接函数不同,绑定函数不尝试通过变量进行匹配,而是简单地合并数据集。如果数据集在适当的维度上不匹配,则会得到一个错误。

12.2.1 绑定列

dplyr 函数 bind_cols 通过将它们作为 tibble 的列来绑定两个对象。例如,我们很快就想创建一个由我们可以使用的数字组成的数据框

bind_cols(a = 1:3, b = 4:6)
#> # A tibble: 3 × 2
#>       a     b
#>   <int> <int>
#> 1     1     4
#> 2     2     5
#> 3     3     6

此函数要求我们为列分配名称。这里我们选择了 ab

注意,存在一个具有完全相同功能性的 R-base 函数 cbind。一个重要的区别是 cbind 可以创建不同类型的对象,而 bind_cols 总是产生一个数据框。

bind_cols 也可以绑定两个不同的数据框。例如,这里我们拆分了 tab 数据框,然后将其重新绑定在一起:

tab_1 <- tab[, 1:3]
tab_2 <- tab[, 4:6]
tab_3 <- tab[, 7:8]
new_tab <- bind_cols(tab_1, tab_2, tab_3)
head(new_tab)
#>        state abb region population total ev clinton trump
#> 1    Alabama  AL  South    4779736   135  9    34.4  62.1
#> 2     Alaska  AK   West     710231    19  3    36.6  51.3
#> 3    Arizona  AZ   West    6392017   232 11    44.6  48.1
#> 4   Arkansas  AR  South    2915918    93  6    33.7  60.6
#> 5 California  CA   West   37253956  1257 55    61.7  31.6
#> 6   Colorado  CO   West    5029196    65  9    48.2  43.3

12.2.2 按行绑定

bind_rows 函数与 bind_cols 类似,但绑定的是行而不是列:

tab_1 <- tab[1:2,]
tab_2 <- tab[3:4,]
bind_rows(tab_1, tab_2)
#>      state abb region population total ev clinton trump johnson stein
#> 1  Alabama  AL  South    4779736   135  9    34.4  62.1    2.09 0.442
#> 2   Alaska  AK   West     710231    19  3    36.6  51.3    5.88 1.800
#> 3  Arizona  AZ   West    6392017   232 11    44.6  48.1    4.08 1.319
#> 4 Arkansas  AR  South    2915918    93  6    33.7  60.6    2.65 0.838
#>   mcmullin
#> 1     0.00
#> 2     0.00
#> 3     0.67
#> 4     1.17

这是基于 R-base 函数 rbind

12.3 集合运算符

另一组用于合并数据集的命令是集合运算符。当应用于向量时,这些运算符的行为正如其名。例如,有 intersectunionsetdiffsetequal。然而,如果加载了 tidyverse,或者更具体地说 dplyr,则这些函数可以用于数据框,而不仅仅是向量。

12.3.1 交集

你可以对任何类型的向量进行交集,例如数值:

intersect(1:10, 6:15)
#> [1]  6  7  8  9 10

或者字符:

intersect](https://generics.r-lib.org/reference/setops.html)([c("a","b","c"), c("b","c","d"))
#> [1] "b" "c"

dplyr 包含一个 intersect 函数,可以应用于具有相同列名的表。此函数返回两个表之间的公共行。为了确保我们使用 dplyr 版本的 intersect 而不是基础 R 版本,我们可以使用 dplyr::intersect 如此:

tab_1 <- tab[1:5,]
tab_2 <- tab[3:7,]
dplyr::intersect(tab_1, tab_2)
#>        state abb region population total ev clinton trump johnson stein
#> 1    Arizona  AZ   West    6392017   232 11    44.6  48.1    4.08 1.319
#> 2   Arkansas  AR  South    2915918    93  6    33.7  60.6    2.65 0.838
#> 3 California  CA   West   37253956  1257 55    61.7  31.6    3.37 1.965
#>   mcmullin
#> 1    0.670
#> 2    1.165
#> 3    0.279

12.3.2 并集

同样,并集 取向量的并集。例如:

union(1:10, 6:15)
#>  [1]  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15
union](https://generics.r-lib.org/reference/setops.html)([c("a","b","c"), c("b","c","d"))
#> [1] "a" "b" "c" "d"

dplyr 包含了一个 union 版本,它可以结合两个具有相同列名的表的全部行。

tab_1 <- tab[1:5,]
tab_2 <- tab[3:7,]
dplyr::union(tab_1, tab_2) 
#>         state abb    region population total ev clinton trump johnson
#> 1     Alabama  AL     South    4779736   135  9    34.4  62.1    2.09
#> 2      Alaska  AK      West     710231    19  3    36.6  51.3    5.88
#> 3     Arizona  AZ      West    6392017   232 11    44.6  48.1    4.08
#> 4    Arkansas  AR     South    2915918    93  6    33.7  60.6    2.65
#> 5  California  CA      West   37253956  1257 55    61.7  31.6    3.37
#> 6    Colorado  CO      West    5029196    65  9    48.2  43.3    5.18
#> 7 Connecticut  CT Northeast    3574097    97  7    54.6  40.9    2.96
#>   stein mcmullin
#> 1 0.442    0.000
#> 2 1.800    0.000
#> 3 1.319    0.670
#> 4 0.838    1.165
#> 5 1.965    0.279
#> 6 1.383    1.040
#> 7 1.389    0.128

12.3.3 setdiff

通过 setdiff 可以获得第一个和第二个参数的集合差。与 intersectunion 不同,此函数不是对称的:

setdiff(1:10, 6:15)
#> [1] 1 2 3 4 5
setdiff(6:15, 1:10)
#> [1] 11 12 13 14 15

与上面显示的函数类似,dplyr 也有针对数据框的版本:

tab_1 <- tab[1:5,]
tab_2 <- tab[3:7,]
dplyr::setdiff(tab_1, tab_2)
#>     state abb region population total ev clinton trump johnson stein
#> 1 Alabama  AL  South    4779736   135  9    34.4  62.1    2.09 0.442
#> 2  Alaska  AK   West     710231    19  3    36.6  51.3    5.88 1.800
#>   mcmullin
#> 1        0
#> 2        0

12.3.4 setequal

最后,函数 setequal 告诉我们两个集合是否相同,无论顺序如何。所以请注意:

setequal(1:5, 1:6)
#> [1] FALSE

但:

setequal(1:5, 5:1)
#> [1] TRUE

dplyr 版本检查数据框是否相等,无论行或列的顺序*:

dplyr::setequal(tab_1, tab_2)
#> [1] FALSE

12.4 使用 data.table 进行连接

data.table 包包括 merge,这是一个用于连接表的非常高效的函数。

tidyverse 中,我们使用 left_join 将两个表连接起来:

tab <- left_join(murders, results_us_election_2016, by = "state") 

data.table 中,merge 函数的工作方式类似:

library(data.table)
tab <- merge(murders, results_us_election_2016, by = "state", all.x = TRUE)

merge 不为不同类型的连接定义不同的函数,而是使用逻辑参数 all(全连接)、all.x(左连接)和 all.y(右连接)。

12.5 练习

  1. 安装并加载 Lahman 库。这个数据库包括与棒球队相关的数据。它包括关于球员在进攻和防守方面多年表现的总结统计数据。它还包括球员的个人资料。

Batting 数据框包含了许多年所有球员的进攻统计数据。例如,你可以通过运行以下代码看到前 10 名击球手:

library(Lahman)
 top <- Batting |> 
 filter(yearID == 2016) |>
 arrange(desc(HR)) |>
 slice(1:10)
 top |> as_tibble()

但这些人是谁?我们看到一个 ID,但没有名字。球员的名字在这个表中

People |> as_tibble()

我们可以看到列名 nameFirstnameLast。使用 left_join 函数创建一个顶级全垒打手的表格。该表应包含 playerID、名字、姓氏和全垒打次数(HR)。用这个新表重写 top 对象。

  1. 现在使用 Salaries 数据框将每位球员的薪水添加到你在练习 1 中创建的表中。注意,薪水每年都不同,所以请确保筛选出 2016 年的数据,然后使用 right_join。这次显示姓名、姓氏、球队、HR 和薪水。

  2. 在之前的练习中,我们创建了 co2 数据集的整洁版本:

co2_wide <- data.frame(matrix(co2, ncol = 12, byrow = TRUE)) |> 
 setNames(1:12) |>
 mutate(year = 1959:1997) |>
 pivot_longer(-year, names_to = "month", values_to = "co2") |>
 mutate(month = as.numeric(month))

我们想看看月度趋势是否在变化,所以我们将移除年份效应,然后绘制结果。我们首先计算每年的平均值。使用 group_bysummarize 计算每年的平均 CO2。将结果保存在名为 yearly_avg 的对象中。

  1. 现在使用 left_join 函数将年度平均值添加到 co2_wide 数据集中。然后计算残差:观察到的 CO2 测量值 - 年度平均值。

  2. 在移除年份效应后,绘制季节性趋势的图表。


  1. github.com/rstudio/cheatsheets↩︎

  2. github.com/rstudio/cheatsheets/blob/master/LICENSE↩︎

13  解析日期和时间

原文:rafalab.dfci.harvard.edu/dsbook-part-1/wrangling/dates-and-times.html

  1. 数据整理

  2. 13  解析日期和时间

我们已经描述了三种主要的向量类型:数值、字符和逻辑。在分析数据时,我们经常遇到日期变量。虽然我们可以用字符串表示日期,例如2017 年 11 月 2 日,但一旦我们选择一个参考日,计算机程序员称之为纪元,它们就可以通过计算自纪元以来的天数来转换为数字。在 R 和 Unix 中,纪元被定义为 1970 年 1 月 1 日。因此,例如,1970 年 1 月 2 日是第 1 天,1969 年 12 月 31 日是第-1 天,2017 年 11 月 2 日是第 17,204 天。

现在我们如何在 R 中分析数据时表示日期和时间呢?我们本可以使用自纪元以来的天数,但那样几乎无法解释。如果我告诉你今天是 2017 年 11 月 2 日,你会立刻知道这意味着什么。如果我告诉你今天是第 17,204 天,你将会非常困惑。类似的问题也出现在时间上,甚至由于时区的原因可能会出现更多复杂的情况。因此,R 定义了一种专门用于日期和时间的数据类型。

13.1 日期数据类型

我们可以在这里看到 R 用于数据的示例数据类型:

library(tidyverse
library(dslabs)
polls_us_election_2016$startdate |> head()
#> [1] "2016-11-03" "2016-11-01" "2016-11-02" "2016-11-04" "2016-11-03"
#> [6] "2016-11-03"

日期看起来像字符串,但它们不是:

class(polls_us_election_2016$startdate)
#> [1] "Date"

看看当我们将它们转换为数字时会发生什么:

as.numeric(polls_us_election_2016$startdate) |> head()
#> [1] 17108 17106 17107 17109 17108 17108

它们将它们转换为自纪元以来的天数。as.Date函数可以将字符转换为日期。因此,为了看到纪元是第 0 天,我们可以输入

as.Date("1970-01-01") |> as.numeric()
#> [1] 0

绘图函数,如 ggplot 中的函数,了解日期格式。这意味着,例如,散点图可以使用数值表示来决定点的位置,但在标签中包含字符串:

polls_us_election_2016 |> filter(pollster == "Ipsos" & state == "U.S.") |>
 ggplot(aes(startdate, rawpoll_trump)) +
 geom_line()

* *特别注意的是,月份名称被显示出来,这是一个非常方便的功能。

13.2 lubridate 包

lubridate包提供了处理日期和时间的工具。

library(lubridate

我们将随机抽取一些日期样本来展示一些可以做的有用事情:

set.seed(2002)
dates <- sample(polls_us_election_2016$startdate, 10) |> sort()
dates
#>  [1] "2016-05-31" "2016-08-08" "2016-08-19" "2016-09-22" "2016-09-27"
#>  [6] "2016-10-12" "2016-10-24" "2016-10-26" "2016-10-29" "2016-10-30"

yearmonthday函数提取这些值:

tibble(date = dates, month = month(dates), day = day(dates), year = year(dates))
#> # A tibble: 10 × 4
#>   date       month   day  year
#>   <date>     <dbl> <int> <dbl>
#> 1 2016-05-31     5    31  2016
#> 2 2016-08-08     8     8  2016
#> 3 2016-08-19     8    19  2016
#> 4 2016-09-22     9    22  2016
#> 5 2016-09-27     9    27  2016
#> # ℹ 5 more rows

我们还可以提取月份标签:

month(dates, label = TRUE)
#>  [1] May Aug Aug Sep Sep Oct Oct Oct Oct Oct
#> 12 Levels: Jan < Feb < Mar < Apr < May < Jun < Jul < Aug < ... < Dec

另一组有用的函数是解析器,它们将字符串转换为日期。ymd函数假设日期格式为 YYYY-MM-DD,并尽可能地进行解析。

x <- c(20090101, "2009-01-02", "2009 01 03", "2009-1-4",
 "2009-1, 5", "Created on 2009 1 6", "200901 !!! 07")
ymd(x)
#> [1] "2009-01-01" "2009-01-02" "2009-01-03" "2009-01-04" "2009-01-05"
#> [6] "2009-01-06" "2009-01-07"

进一步复杂化的是,日期通常以不同的格式出现,其中年、月、日的顺序不同。首选的格式是显示年份(四位数字),然后是月份(两位数字),最后是日期,或者称为 ISO 8601 格式。具体来说,我们使用 YYYY-MM-DD 格式,这样如果对字符串进行排序,它将按日期排序。您可以看到ymd函数以这种格式返回它们。

但是,如果你遇到像“09/01/02”这样的日期怎么办?这可能是指 2002 年 9 月 1 日、2009 年 1 月 2 日或 2002 年 1 月 9 日。在这些情况下,通过排除法检查整个日期向量将帮助你确定它的格式。一旦你知道了,你就可以使用 lubridate 提供的许多解析器。

例如,如果字符串是:

x <- "09/01/02"

ymd 函数假设第一个条目是年份,第二个是月份,第三个是日期,因此它将其转换为:

ymd(x)
#> [1] "2009-01-02"

mdy 函数假设第一个条目是月份,然后是日期,最后是年份:

mdy(x)
#> [1] "2002-09-01"

lubridate** 包为每个可能性都提供了一个函数。这里还有另一个常见的例子:

dmy(x)
#> [1] "2002-01-09"

lubridate** 包在处理时间方面也非常有用。在基础 R 中,你可以通过输入 Sys.time() 来获取当前时间。lubridate 包提供了一个稍微高级一点的函数 now,允许你定义时区:

now()
#> [1] "2025-09-16 20:01:28 EDT"
now("GMT")
#> [1] "2025-09-17 00:01:28 GMT"

你可以使用 OlsonNames() 函数查看所有可用的时区。

我们还可以提取小时、分钟和秒:

now() |> hour()
#> [1] 20
now() |> minute()
#> [1] 1
now() |> second()
#> [1] 28.1

该包还包括将字符串解析为时间的函数以及解析包含日期的时间对象的解析器:

x <- c("12:34:56")
hms(x)
#> [1] "12H 34M 56S"
x <- "Nov/2/2012 12:34:56"
mdy_hms(x)
#> [1] "2012-11-02 12:34:56 UTC"

这个包还有许多其他有用的函数。我们在这里描述了其中两个我们认为特别有用的函数。

make_date 函数可以用来快速创建日期对象。它可以接受最多七个参数:年、月、日、小时、分钟、秒和时区,默认为 UTC 时间的纪元值。例如,要创建代表 2019 年 7 月 6 日的日期对象,我们写:

make_date(2019, 7, 6)
#> [1] "2019-07-06"

要创建一个 80 年代 1 月 1 日的向量,我们写:

make_date(1980:1989)
#>  [1] "1980-01-01" "1981-01-01" "1982-01-01" "1983-01-01" "1984-01-01"
#>  [6] "1985-01-01" "1986-01-01" "1987-01-01" "1988-01-01" "1989-01-01"

另一个非常有用的函数是 round_date。它可以用来将日期四舍五入到最近的年份、季度、月份、星期、天、小时、分钟或秒。所以如果我们想按年份的星期分组所有民意调查,我们可以这样做:

polls_us_election_2016 |> 
 mutate(week = round_date(startdate, "week")) |>
 group_by(week) |>
 summarize(margin = mean(rawpoll_clinton - rawpoll_trump)) |>
 ggplot(aes(week, margin)) +
 geom_point()

* *最后,你应该知道还有一些有用的函数用于计算时间操作,例如 difftimetime_lengthinterval

13.3 使用 data.table 处理日期和时间

data.table** 包包含与 lubridate 相同的一些功能。例如,它包括与 lubridate 中相同的 monthyear 函数。lubridateday 等价于 mday

library(data.table)
st <- as.Date("2024-03-04")
day(st)
#> [1] 4
mday(st)
#> [1] 4

data.table** 包中还包括其他类似的功能,例如 secondminutehourydaywdayweekisoweekquarter

该包还包括 IDateITime 类,它们比 lubridate 和基础 R 更有效地存储日期和时间。这对于具有日期戳的大文件来说很方便。你可以使用 as.IDateas.ITime 将日期转换为常规 R 格式。你可以通过使用 object.size 函数来查看这一点:

object.size(polls_us_election_2016$startdate)
#> 33936 bytes
object.size](https://rdrr.io/r/utils/object.size.html)([as.IDate(polls_us_election_2016$startdate))
#> 17168 bytes

13.4 练习

对于这些练习,我们将使用以下数据集:

library(dslabs)
head(pr_death_counts)
  1. 我们想绘制死亡人数与日期的图表。确认date变量实际上是日期而不是字符串。

  2. 绘制死亡人数与日期的图表。

  3. 这些数据代表了哪个时间段?

  4. 注意到 2018 年 5 月 31 日之后,死亡人数都是 0。数据可能尚未输入。我们还看到大约从 5 月 1 日开始有所下降。重新定义dat以排除 2018 年 5 月 1 日或之后的观测数据。然后,重新绘制图表。

  5. 重复绘制图表,但使用年份中的天数作为 x 轴,而不是日期。

  6. 计算每月的每日死亡人数。

  7. 显示 7 月和 9 月的每日死亡人数。你注意到了什么?

  8. 计算每周的死亡人数并绘制图表。

14  区域设置

原文:rafalab.dfci.harvard.edu/dsbook-part-1/wrangling/locales.html

  1. 数据处理

  2. 14  区域设置

计算机设置根据语言和位置而变化,对此可能性的不了解可能会使某些数据处理挑战难以克服。

区域设置的目的在于将可能影响以下方面的常见设置组合在一起:

  1. 月份和日期名称,对于解释日期是必要的。

  2. 标准日期格式,对于解释日期也是必要的。

  3. 默认时区,对于解释日期时间至关重要。

  4. 字符编码,对于读取非 ASCII 字符至关重要。

  5. 小数点和数字分组符号,对于解释数值非常重要。

在 R 中,区域设置指的是一组设置,这些设置决定了系统如何根据文化惯例行事。这些设置影响数据的格式化和展示方式,包括日期格式、货币符号、小数分隔符和其他相关方面。

R 中的区域设置影响多个领域,包括字符向量的排序方式,以及日期、数字和货币格式。此外,错误、警告和其他消息可能会根据区域设置翻译成除英语以外的语言。

14.1 R 中的区域设置

要访问 R 中的当前区域设置,您可以使用 Sys.getlocale() 函数:

Sys.getlocale()
#> [1] "en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8"

要设置特定的区域设置,请使用 Sys.setlocale() 函数。例如,要将区域设置为美国英语:

Sys.setlocale("LC_ALL", "en_US.UTF-8")
#> [1] "en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8"

用于设置区域设置的精确字符串(如“en_US.UTF-8”)可能取决于您的操作系统及其配置。

上面的代码中使用的 LC_ALL 指的是所有区域设置类别。R,像许多系统一样,将区域设置分解为类别,每个类别负责以下列出的不同方面。

  • LC_COLLATE: 用于字符串排序

  • LC_TIME: 日期和时间格式化

  • LC_MONETARY: 货币格式化。

  • LC_MESSAGES: 系统消息翻译。

  • LC_NUMERIC: 数字格式化。

如果您不想通过 LC_ALL 改变所有内容,您可以单独为每个类别设置区域设置。

我们已经展示了控制区域设置的工具。这些设置很重要,因为它们会影响您数据的外观和行为。然而,并非所有计算机都提供这些设置;它们的可用性取决于您拥有的计算机类型及其配置。

改变这些设置,尤其是 LC_NUMERIC,当您在 R 中处理数字时可能会导致意外问题。例如,如果您习惯于使用点作为小数点,但您的区域设置使用逗号,这种差异在导入数据时可能会引起问题。

重要的一点是要记住,这些区域设置只持续一个 R 会话。如果你在操作时更改它们,当你关闭 R 并再次打开时,它们将恢复到默认设置。

14.2 locale 函数

readr 包包含一个 locale() 函数,可以在 R 中使用它来学习或更改当前的区域设置:

library(readr
locale()
#> <locale>
#> Numbers:  123,456.78
#> Formats:  %AD / %AT
#> Timezone: UTC
#> Encoding: UTF-8
#> <date_names>
#> Days:   Sunday (Sun), Monday (Mon), Tuesday (Tue), Wednesday (Wed),
#>         Thursday (Thu), Friday (Fri), Saturday (Sat)
#> Months: January (Jan), February (Feb), March (Mar), April (Apr), May
#>         (May), June (Jun), July (Jul), August (Aug), September
#>         (Sep), October (Oct), November (Nov), December (Dec)
#> AM/PM:  AM/PM

您可以通过输入以下内容来查看系统上可用的所有区域设置:

system("locale -a")

这里是你将日期区域设置改为西班牙语时得到的结果:

locale(date_names = "es")
#> <locale>
#> Numbers:  123,456.78
#> Formats:  %AD / %AT
#> Timezone: UTC
#> Encoding: UTF-8
#> <date_names>
#> Days:   domingo (dom.), lunes (lun.), martes (mar.), miércoles (mié.),
#>         jueves (jue.), viernes (vie.), sábado (sáb.)
#> Months: enero (ene.), febrero (feb.), marzo (mar.), abril (abr.), mayo
#>         (may.), junio (jun.), julio (jul.), agosto (ago.),
#>         septiembre (sept.), octubre (oct.), noviembre (nov.),
#>         diciembre (dic.)
#> AM/PM:  a. m./p. m.

14.3 示例:处理西班牙语数据集

在 第 6.3.2 节 中,我们提到读取文件:

fn <- file.path(system.file("extdata", package = "dslabs"), "calificaciones.csv")

具有与默认的 UTF-8 编码不同的编码。我们使用了 guess_encoding 来确定正确的编码:

guess_encoding(fn)$encoding[1]
#> [1] "ISO-8859-1"

并使用 locale 函数来更改这一点,并读取此编码:

dat <- read_csv(fn, locale = locale(encoding = "ISO-8859-1"))

此文件提供了七个学生的家庭作业分数。列代表学生姓名、他们的出生日期、提交作业的时间以及他们获得的分数。您可以使用 read_lines 查看整个文件:

read_lines(fn, locale = locale(encoding = "ISO-8859-1"))
#> [1] "\"nombre\",\"f.n.\",\"estampa\",\"puntuación\"" 
#> [2] "\"Beyoncé\",\"04 de septiembre de 1981\",2023-09-22 02:11:02,\"87,5\""
#> [3] "\"Blümchen\",\"20 de abril de 1980\",2023-09-22 03:23:05,\"99,0\"" 
#> [4] "\"João\",\"10 de junio de 1931\",2023-09-21 22:43:28,\"98,9\"" 
#> [5] "\"López\",\"24 de julio de 1969\",2023-09-22 01:06:59,\"88,7\"" 
#> [6] "\"Ñengo\",\"15 de diciembre de 1981\",2023-09-21 23:35:37,\"93,1\"" 
#> [7] "\"Plácido\",\"24 de enero de 1941\",2023-09-21 23:17:21,\"88,7\"" 
#> [8] "\"Thalía\",\"26 de agosto de 1971\",2023-09-21 23:08:02,\"83,0\""

作为一个说明性的例子,我们将编写代码来计算学生的年龄并检查他们是否在 2023 年 9 月 21 日午夜之前提交了作业:

我们可以用正确的编码读取文件,如下所示:

dat <- read_csv(fn, locale = locale(encoding = "ISO-8859-1"))

然而,请注意,最后一列,本应包含 0 到 100 之间的考试分数,显示的数字大于 800:

dat$puntuación
#> [1] 875 990 989 887 931 887 830

这是因为文件中的分数使用的是欧洲的十进制点,这会混淆 read_csv

为了解决这个问题,我们也可以将编码更改为使用欧洲十进制,这可以修复问题:

dat <- read_csv(fn, locale = locale(decimal_mark = ",",
 encoding = "ISO-8859-1"))
dat$puntuación
#> [1] 87.5 99.0 98.9 88.7 93.1 88.7 83.0

现在,为了计算学生年龄,让我们尝试将提交时间更改为日期格式:

library(lubridate
#> 
#> Attaching package: 'lubridate'
#> The following objects are masked from 'package:base':
#> 
#>     date, intersect, setdiff, union
dmy(dat$f.n.)
#> Warning: All formats failed to parse. No formats found.
#> [1] NA NA NA NA NA NA NA

没有任何内容被正确转换。这是因为日期是西班牙语。我们可以将区域设置更改为使用西班牙语作为日期的语言:

parse_date(dat$f.n., format = "%d de %B de %Y", locale = locale(date_names = "es"))
#> [1] "1981-09-04" "1980-04-20" "1931-06-10" "1969-07-24" "1981-12-15"
#> [6] "1941-01-24" "1971-08-26"

我们也可以使用正确的区域设置重新读取文件:

dat <- read_csv(fn, locale = locale(date_names = "es",
 date_format = "%d de %B de %Y",
 decimal_mark = ",",
 encoding = "ISO-8859-1"))

计算学生的年龄现在变得简单:

time_length](https://lubridate.tidyverse.org/reference/time_length.html)(today() - dat$f.n., unit = "years") |> [floor()
#> [1] 44 45 94 56 43 84 54

最后,让我们检查哪些学生在 9 月 22 日的截止日期之后提交了他们的作业:

dat$estampa >= make_date(2023, 9, 22)
#> [1]  TRUE  TRUE FALSE  TRUE FALSE FALSE FALSE

我们看到有两个学生迟交了。然而,由于时间,我们必须特别小心,因为一些函数默认使用 UTC 时区:

tz(dat$estampa)
#> [1] "UTC"

如果我们将其更改为东部标准时间(EST),我们看到没有人迟交:

with_tz(dat$estampa, tz =  "EST") >= make_date(2023, 9, 22)
#> [1] FALSE FALSE FALSE FALSE FALSE FALSE FALSE

14.4 练习

1. 加载 lubridate 包并将区域设置为此练习的法国。

2. 创建一个包含以下数字的数值向量:12345.67, 9876.54, 3456.78, 和 5432.10。

3. 使用 format() 函数将数值向量格式化为货币,显示以欧元表示的值。确保根据法国区域设置正确表示小数点。打印格式化的货币值。

  1. 创建一个包含三个日期的日期向量:1789 年 7 月 14 日、1803 年 1 月 1 日和 1962 年 7 月 5 日。使用 format() 函数将日期向量格式化为“dd 月 yyyy”格式,其中“月”应使用法语显示。确保月份名称根据法语地区正确翻译。打印格式化后的日期值。

  2. 将地区重置为默认设置(例如,“C”或“en_US.UTF-8”),以恢复标准格式。

  3. 对数值向量重复步骤 2-4,对日期向量重复步骤 5-7,以观察标准格式。

15 提取网络数据

原文:rafalab.dfci.harvard.edu/dsbook-part-1/wrangling/web-scraping.html

  1. 数据整理

  2. 15 提取网络数据

在当今的数字时代,互联网是一个数据宝库。本章介绍了检索这些数据并为数据分析做准备的方法。我们重点关注两种主要方法:HTML 抓取和 API 集成。HTML 抓取使我们能够以编程方式导航和从网页中提取数据,将 HTML 页面的非结构化内容转换为分析准备好的结构化数据。另一方面,API 提供了对由网络服务提供的数据的直接、高效和结构化访问。本章旨在介绍开始利用互联网庞大数据资源所需的基本知识。

15.1 抓取 HTML

我们需要回答问题的数据并不总是以我们可读的电子表格形式存在。例如,我们在 R 基础知识章节中使用的美国谋杀数据集最初来自这个维基百科页面:

url <- paste0("https://en.wikipedia.org/w/index.php?title=",
 "Gun_violence_in_the_United_States_by_state",
 "&direction=prev&oldid=810166167")

当您访问网页时,可以看到数据表:

(网页由维基百科提供¹。CC-BY-SA-3.0 许可²。页面部分截图。)

网络抓取网络采集*是我们用来描述从网站中提取数据的过程的术语。我们可以这样做的原因是,浏览器用于渲染网页的信息是以文本文件的形式从服务器接收的。文本是以超文本标记语言(HTML)编写的代码。每个浏览器都有一种显示页面 HTML 源代码的方式,每种方式都不同。在 Chrome 中,您可以在 PC 上使用 Control-U,在 Mac 上使用 command+alt+U。您将看到类似以下内容:

15.1.1 HTML

由于此代码是可访问的,我们可以下载 HTML 文件,将其导入 R 中,然后编写程序从页面中提取所需的信息。然而,一旦我们查看 HTML 代码,这可能会显得是一项艰巨的任务。但我们将向您展示一些方便的工具来简化这个过程。为了了解它是如何工作的,以下是来自提供美国谋杀数据维基百科页面的几行代码:

<table class="wikitable sortable">
<tr>
<th>State</th>
<th><a href="/wiki/List_of_U.S._states_and_territories_by_population" 
title="List of U.S. states and territories by population">Population</a><br />
<small>(total inhabitants)</small><br />
<small>(2015)</small> <sup id="cite_ref-1" class="reference">
<a href="#cite_note-1">[1]</a></sup></th>
<th>Murders and Nonnegligent
<p>Manslaughter<br />
<small>(total deaths)</small><br />
<small>(2015)</small> <sup id="cite_ref-2" class="reference">
<a href="#cite_note-2">[2]</a></sup></p>
</th>
<th>Murder and Nonnegligent
<p>Manslaughter Rate<br />
<small>(per 100,000 inhabitants)</small><br />
<small>(2015)</small></p>
</th>
</tr>
<tr>
<td><a href="/wiki/Alabama" title="Alabama">Alabama</a></td>
<td>4,853,875</td>
<td>348</td>
<td>7.2</td>
</tr>
<tr>
<td><a href="/wiki/Alaska" title="Alaska">Alaska</a></td>
<td>737,709</td>
<td>59</td>
<td>8.0</td>
</tr>
<tr>

您实际上可以看到数据,但数据值被 HTML 代码如<td>包围。我们还可以看到其存储的模式。如果您了解 HTML,您可以编写利用这些模式提取所需内容的程序。我们还利用了一种广泛用于使网页看起来“漂亮”的语言,称为层叠样式表(CSS)。我们将在第 15.1.3 节中详细介绍这一点。

虽然我们提供了无需了解 HTML 即可抓取数据的工具,但学习一些 HTML 和 CSS 仍然很有用。这不仅能够提高你的抓取技能,而且如果你正在创建一个展示你工作的网页,这可能会派上用场。有很多在线课程和教程可以帮助你学习这些。两个例子是 Codeacademy³ 和 W3schools⁴。

15.1.2 rvest 包

tidyverse** 提供了一个名为 rvest 的网络抓取包。使用此包的第一步是将网页导入 R。该包使这一过程变得非常简单:

library(tidyverse
library(rvest)
h <- read_html(url)

请注意,现在整个美国维基百科上的“谋杀案”网页现在都包含在 h 中。这个对象的类是:

class(h)
#> [1] "xml_document" "xml_node"

实际上,rvest 包更加通用;它处理 XML 文档。XML 是一种通用标记语言(ML 代表的就是这个),可以用来表示任何类型的数据。HTML 是 XML 的一种特定类型,专门为表示网页而开发。在这里,我们专注于 HTML 文档。

现在,我们如何从对象 h 中提取表格?如果你打印 h,我们会看到一些不太有用的对象信息。我们可以使用 html_text 函数查看下载的网页的所有代码,如下所示:

html_text(h)

我们在这里不展示输出,因为它包含了数千个字符。但如果我们查看它,我们可以看到我们想要的数据都存储在一个 HTML 表格中:你可以在上面的 HTML 代码的这一行中看到 <table class="wikitable sortable">。HTML 文档的不同部分,通常用 <> 之间的消息定义,被称为 节点rvest 包包含用于提取 HTML 文档节点的函数:html_nodes 提取所有不同类型的节点,html_node 提取第一个节点。要从 HTML 代码中提取表格,我们使用:

tab <- h |> html_nodes("table")

现在,我们不再拥有整个网页,而是只有页面中表格的 HTML 代码:

tab
#> {xml_nodeset (2)}
#> [1] <table class="wikitable sortable"><tbody>\n<tr>\n<th>State\n</th> ...
#> [2] <table class="nowraplinks hlist mw-collapsible mw-collapsed navbo ...

我们感兴趣的表格是第一个:

tab[[1]]
#> {html_node}
#> <table class="wikitable sortable">
#> [1] <tbody>\n<tr>\n<th>State\n</th>\n<th>\n<a href="/wiki/List_of_U.S ...

这显然不是一个整洁的数据集,甚至不是一个数据框。在上面的代码中,你可以肯定地看到一个模式,编写代码来提取数据是完全可以实现的。事实上,rvest 包包含一个专门用于将 HTML 表格转换为数据框的函数:

tab <- tab[1]] |> [html_table()
class(tab)
#> [1] "tbl_df"     "tbl"        "data.frame"

我们现在已经非常接近拥有一个可用的数据表了:

tab <- tab |> setNames(c("state", "population", "total", "murder_rate")) 
head(tab)
#> # A tibble: 6 × 4
#>   state      population total murder_rate
#>   <chr>      <chr>      <chr>       <dbl>
#> 1 Alabama    4,853,875  348           7.2
#> 2 Alaska     737,709    59            8 
#> 3 Arizona    6,817,565  309           4.5
#> 4 Arkansas   2,977,853  181           6.1
#> 5 California 38,993,940 1,861         4.8
#> # ℹ 1 more row

我们仍然需要进行一些整理。例如,我们需要删除逗号并将字符转换为数字。在继续之前,我们将学习一种更通用的从网站提取信息的方法。

15.1.3 CSS 选择器

使用最基本 HTML 制作的网页默认外观相当不吸引人。我们今天看到的令人愉悦的页面是使用 CSS 来定义网页的外观和样式制作的。公司所有页面都有相同风格的事实通常是由于它们使用相同的 CSS 文件来定义样式。这些 CSS 文件的一般工作方式是定义网页每个元素的外观。例如,标题、标题、项目符号列表、表格和链接等,每个元素都拥有自己的样式,包括字体、颜色、大小和与边距的距离。CSS 通过利用定义这些元素的模式来实现这一点,这些模式被称为 选择器。上面我们使用的这样一个模式示例是 table,但还有许多许多其他的模式。

如果我们想从网页中抓取数据,并且恰好知道包含这些数据的页面部分所特有的选择器,我们可以使用 html_nodes 函数。然而,知道哪个选择器可能相当复杂。实际上,随着网页变得越来越复杂,网页的复杂性也在不断增加。对于一些更高级的网页,似乎几乎不可能找到定义特定数据的节点。然而,选择器工具实际上使这成为可能。

SelectorGadget⁵ 是一款软件,允许你交互式地确定你需要从网页中提取特定组件的 CSS 选择器。如果你计划从 HTML 页面中抓取除表格以外的数据,我们强烈建议你安装它。有一个可用的 Chrome 扩展程序,允许你开启这个工具,然后当你点击页面时,它会突出显示部分内容并显示你需要提取这些部分的选择器。包括 rvest 作者 Hadley Wickham 的示例⁶ 和基于示例的其他教程⁷ 在内,有许多演示如何进行这项操作。

15.2 JSON

在互联网上共享数据变得越来越普遍。不幸的是,提供者使用不同的格式,这使得数据分析师将数据整理到 R 中变得更加困难。然而,也有一些标准正在变得越来越普遍。目前,一个被广泛采用的格式是 JavaScript 对象表示法或 JSON。因为这个格式非常通用,它不像电子表格。这个 JSON 文件看起来更像是你用来定义列表的代码。以下是一个存储在 JSON 格式的信息示例:

#> [
#>   {
#>     "name": "Miguel",
#>     "student_id": 1,
#>     "exam_1": 85,
#>     "exam_2": 86
#>   },
#>   {
#>     "name": "Sofia",
#>     "student_id": 2,
#>     "exam_1": 94,
#>     "exam_2": 93
#>   },
#>   {
#>     "name": "Aya",
#>     "student_id": 3,
#>     "exam_1": 87,
#>     "exam_2": 88
#>   },
#>   {
#>     "name": "Cheng",
#>     "student_id": 4,
#>     "exam_1": 90,
#>     "exam_2": 91
#>   }
#> ]

上面的文件实际上代表一个数据框。要读取它,我们可以使用 jsonlite 包中的 fromJSON 函数。请注意,JSON 文件通常通过互联网提供。一些组织提供 JSON API 或网络服务,你可以直接连接到它们并获取数据。以下是一个提供诺贝尔奖获得者信息的示例:

library(jsonlite)
nobel <- fromJSON("http://api.nobelprize.org/v1/prize.json")

这下载了一个列表。第一个参数,名为“prizes”的表格包含了关于诺贝尔奖获得者信息。每一行对应一个特定的年份和类别。“laureates”列包含一个数据框列表,每个获奖者都有一个包含 id、firstname、surname 和 motivation 列的数据框。

nobel$prizes |>
 filter(category == "literature" & year == "1971") |> 
 pull(laureates) |>
 first() |>
 select(id, firstname, surname)
#>    id firstname surname
#> 1 645     Pablo  Neruda

您可以通过检查 jsonlite 包的教程和帮助文件来了解更多信息。此包旨在处理相对简单的任务,例如将数据转换为表格。为了获得更多灵活性,我们推荐使用 rjson 包。

15.3 数据 API

应用程序编程接口(API)是一套规则和协议,允许不同的软件实体之间进行通信。它定义了软件组件在请求和交换信息时应使用的方法和数据格式。API 在实现当今软件如此互联和多功能性方面发挥着至关重要的作用。

15.3.1 API 类型和概念

有几种类型的 API。与获取数据相关的主要类型包括:

  • Web 服务 - 通常使用 HTTP/HTTPS 等协议构建。通常用于使应用程序能够通过网络相互通信。例如,智能手机上的天气应用程序可能使用 Web API 从远程服务器请求天气数据。

  • 数据库 API - 允许应用程序与数据库之间进行通信,例如基于 SQL 的调用。

与 API 相关的一些关键概念:

  • 端点:通过 API 可用的特定功能。对于 Web API,端点通常是 API 可以访问的特定 URL。

  • 方法:可以执行的操作。在 Web API 中,这些通常对应于 HTTP 方法,如 GET、POST、PUT 或 DELETE。

  • 请求和响应:请求 API 执行其功能的行为是 请求。它返回的数据是 响应

  • 速率限制:限制您调用 API 的频率,通常用于防止滥用或服务过载。

  • 身份验证和授权:确保只有经过批准的用户或应用程序才能使用 API 的机制。常见的方法包括 API 密钥OAuthJSON Web Tokens (JWT)。

  • 数据格式:许多 Web API 以特定的格式交换数据,通常是 JSON 或 CSV。

现在描述一下httr2包,它促进了 R 与 HTTP Web 服务之间的交互。

15.3.2 httr2 包

HTTP 是通过互联网进行数据共享最广泛使用的协议。httr2 包提供了处理 HTTP 请求的函数。该包中的一个核心函数是 request,用于向 Web 服务发送请求。req_perform 函数用于发送请求。

这个 request 函数向指定的 URL 发送一个 HTTP GET 请求。通常,HTTP GET 请求用于根据提供的 URL 从服务器检索信息。

函数返回一个类为 response 的对象。该对象包含服务器响应的所有详细信息,包括状态码、头信息和内容。然后你可以使用其他 httr2 函数从该响应中提取或解释信息。

假设你想从 CDC 获取按州划分的 COVID-19 死亡数据。通过访问他们的数据目录⁸,你可以搜索数据集并发现数据是通过以下 API 提供的:

url <- "https://data.cdc.gov/resource/muzy-jte6.csv"

然后我们可以创建并执行如下请求:

library(httr2
response <- request(url) |> req_perform()

我们可以通过查看返回的对象来查看请求的结果。

response
#> <httr2_response>
#> GET https://data.cdc.gov/resource/muzy-jte6.csv
#> Status: 200 OK
#> Content-Type: text/csv
#> Body: In memory (210922 bytes)

为了提取主体,即数据所在的位置,我们可以使用 resp_body_string 并将结果,一个以逗号分隔的字符串,发送到 read_csv

library(readr
tab <- response |> resp_body_string()

我们注意到返回的对象只有 1000 条记录。API 通常限制你可以下载的数据量。该 API 的文档⁹ 解释说我们可以通过 $limit 参数来更改这个限制。我们可以使用 req_url_query 将其添加到我们的请求中:

response <- request(url) |> 
 req_url_query(`$limit` = 100000) |>
 req_perform() 

CDC 服务以 csv 格式返回数据,但由网络服务更常使用的格式是 JSON。CDC 还通过以下 URL 提供了 json 格式的数据:

url <- "https://data.cdc.gov/resource/muzy-jte6.json"

为了提取数据表,我们使用来自 jsonlite 包的 fromJSON 函数。

tab <- request(url) |> 
 req_perform() |> 
 resp_body_string() |> 
 fromJSON(flatten = TRUE)

在处理 API 时,检查 API 的文档以了解速率限制、所需头信息或认证方法是非常重要的。httr2 包提供了处理这些要求的工具,例如设置头信息或认证参数。

15.4 练习

  1. 访问以下网页:web.archive.org/web/20181024132313/http://www.stevetheump.com/Payrolls.htm

注意有几个表格。假设我们感兴趣的是比较跨年度的团队薪资。接下来的几个练习将引导我们完成这一步骤。

首先应用你所学到的知识,将网站内容读入一个名为 h 的对象中。

  1. 注意,尽管不太有用,我们实际上可以通过输入以下内容来查看页面内容:
html_text(h)

下一步是提取表格。为此,我们可以使用 html_nodes 函数。我们了解到 HTML 中的表格与 table 节点相关联。使用 html_nodes 函数提取所有表格。将其存储在对象 nodes 中。

  1. html_nodes 函数返回一个类为 xml_node 的对象列表。我们可以使用例如 html_text 函数来查看每个对象的内容。例如,你可以这样查看任意选择组件的内容:
html_text(nodes[[8]])

如果该对象的内容是 HTML 表格,我们可以使用 html_table 函数将其转换为数据框。使用 html_table 函数将 nodes 的第 8 个条目转换为表格。

  1. nodes 的前 4 个组件重复上述操作。以下哪些是薪资表:

  2. 所有这些。

  3. 1

  4. 2

  5. 2-4

  6. nodes 的最后 3 个组件重复上述操作。以下哪个说法是正确的:

  7. nodes中的最后一条记录显示的是所有团队随时间推移的平均值,而不是每个团队的工资总额。

  8. 所有这三个都是每个团队的工资表。

  9. 所有这三者都像第一条记录一样,不是工资表。

  10. 所有上述内容。

  11. 我们已经了解到nodes的第一个和最后一个条目不是工资表。重新定义nodes,以便删除这两个条目。

  12. 在前面的分析中,我们看到第一个表格节点实际上不是一个表格。这在 HTML 中有时会发生,因为表格被用来使文本看起来有特定的样子,而不是存储数值。移除第一个组件,然后使用sapplyhtml_tablenodes中的每个节点转换为表格。请注意,在这种情况下,sapply将返回一个表格列表。你也可以使用lapply来确保列表被应用。

  13. 查看生成的表格。它们是否都相同?我们能否只用bind_rows将它们连接起来?

  14. 创建两个表格,命名为tab_1tab_2,使用nodes中的第 10 个和第 19 个表格。

  15. 使用full_join函数合并这两个表格。在这样做之前,你必须解决缺失标题的问题。你还需要确保名称匹配。

  16. 在连接表格后,你看到几个NA。这是因为一些队伍在一个表格中,而在另一个表格中则没有。使用anti_join函数来更好地了解这是为什么。

  17. 我们可以看到,问题之一是洋基队被列为N.Y. YankeesNY Yankees。在下一节中,我们将学习解决此类问题的有效方法。在这里,我们可以手动这样做如下:

tab_1 <- tab_1 |>
 mutate(Team = ifelse(Team == "N.Y. Yankees", "NY Yankees", Team))

现在将表格连接起来,只显示奥克兰和洋基队以及工资列。

  1. 高级:从 IMDB¹⁰中提取获得最佳影片奖的电影的标题。

  1. en.wikipedia.org/w/index.php?title=Gun_violence_in_the_United_States_by_state&direction=prev&oldid=810166167↩︎

  2. en.wikipedia.org/wiki/Wikipedia:Text_of_Creative_Commons_Attribution-ShareAlike_3.0_Unported_License↩︎

  3. www.codecademy.com/learn/learn-html↩︎

  4. www.w3schools.com/↩︎

  5. selectorgadget.com/↩︎

  6. rvest.tidyverse.org/articles/selectorgadget.html↩︎

  7. www.analyticsvidhya.com/blog/2017/03/beginners-guide-on-web-scraping-in-r-using-rvest-with-hands-on-knowledge/↩︎

  8. data.cdc.gov↩︎

  9. dev.socrata.com/docs/queries/↩︎

  10. m.imdb.com/chart/bestpicture/↩︎

16 字符串处理

原文:rafalab.dfci.harvard.edu/dsbook-part-1/wrangling/string-processing.html

  1. 数据整理

  2. 16 字符串处理

最常见的数据整理挑战之一涉及从字符字符串中提取数值数据,并将它们转换为 R 中制作图表、计算摘要或拟合模型所需的数值表示。同样常见的是将无组织文本处理成有意义的变量名或分类变量。数据科学家面临的大多数字符串处理挑战都是独特的,并且往往出乎意料。因此,撰写一个关于这个主题的全面章节是非常雄心勃勃的。在这里,我们使用一系列案例研究来帮助我们展示字符串处理是如何成为许多数据整理挑战的必要步骤。具体来说,我们描述了将我们从原始数据中提取的murdersheightsresearch_funding_rates示例转换成我们在本书中研究的数据框的过程。

通过研究这些案例研究,我们将涵盖字符串处理中最常见的任务,包括从字符串中提取数字、从文本中删除不需要的字符、查找和替换字符、提取字符串的特定部分、将自由文本转换为更统一的格式,以及将字符串拆分为多个值。

基础 R 包括执行许多这些任务的功能。stringi包在基础 R 中提供了额外的功能,特别是对于复杂和多样化的文本处理需求。stringr包为stringi包提供了一致、简单且用户友好的包装。例如,在stringr中,所有的字符串处理函数都以str_开头。这意味着如果你输入str_然后按 Tab 键,R 会自动完成并显示所有可用的函数。因此,我们不一定需要记住所有的函数名称。另一个优点是,在这个包中的函数中,被处理的字符串总是第一个参数,这意味着我们可以更轻松地使用管道。因此,我们将首先描述如何使用stringr包中的函数。

大多数示例将来自第二个案例研究,该研究涉及学生自我报告的身高,而本章的大部分内容都致力于学习stringr包中的正则表达式(regex)和函数。

16.1 stringr 包

library(tidyverse
library(stringr

一般来说,字符串处理任务可以分为在字符串中检测定位提取替换模式。我们将看到几个示例。下表包括了stringr包中可用的函数。我们按任务将它们分开。我们还包括了当可用时的基础 R 的等效函数。

所有这些函数都将字符向量作为第一个参数。此外,对于每个函数,操作都是向量化的:操作应用于向量中的每个字符串。

最后,我们在表中提到了 。这些将在第 16.4.9 节中解释。

stringr 任务 描述 基础 R
str_detect 检测 字符串中是否存在模式? grepl
str_which 检测 返回包含模式的条目的索引。 grep
str_subset 检测 返回包含模式的字符串子集。 grepvalue = TRUE
str_locate 定位 返回模式在字符串中第一次出现的位置。 regexpr
str_locate_all 定位 返回模式在字符串中所有出现的位置。 gregexpr
str_view 定位 显示与模式匹配的字符串的第一部分。
str_view_all 定位 显示与模式匹配的字符串的所有部分。
str_extract 提取 提取与模式匹配的字符串的第一部分。
str_extract_all 提取 提取与模式匹配的字符串的所有部分。
str_match 提取 提取与模式匹配的字符串的第一部分以及由模式定义的组。
str_match_all 提取 提取与模式匹配的字符串的所有部分以及由模式定义的组。
str_sub 提取 提取子字符串。 substring
str_split 提取 将字符串分割成由模式分隔的部分组成的列表。 strsplit
str_split_fixed 提取 将字符串分割成由模式分隔的固定数量的部分组成的矩阵。 strsplitfixed = TRUE
str_count 描述 计算模式在字符串中出现的次数。
str_length 描述 字符串中的字符数。 nchar
str_replace 替换 将匹配模式的字符串的第一部分替换为另一个字符串。
str_replace_all 替换 将匹配模式的字符串部分替换为另一个字符串。 gsub
str_to_upper 替换 将所有字符转换为大写。 toupper
str_to_lower 替换 将所有字符转换为小写。 tolower
str_to_title 替换 将每个单词的首字母转换为大写,其余字母转换为小写。
str_replace_na 替换 将所有 NA 替换为新的值。
str_trim 替换 从字符串的开始和结束处移除空白字符。
str_c 操作 连接多个字符串。 paste0
str_conv 操作 更改字符串的编码。
str_sort 操作 按字母顺序排序向量。 sort
str_order 操作 提供排序向量所需的索引,按字母顺序排序。 order
str_trunc 操作 截断字符串到固定大小。
str_pad 操作 向字符串添加空白以使其成为固定大小。
str_dup 操作 重复字符串。 rep然后paste
str_wrap 操作 将内容包装成格式化的段落。

| str_interp | 操作 | 字符串插值。 | sprintf |

案例研究 1:自我报告的身高

dslabs包包括了从其中获得身高数据集的原始数据。你可以这样加载它:

library(dslabs)
head(reported_heights)
#>            time_stamp    sex height
#> 1 2014-09-02 13:40:36   Male     75
#> 2 2014-09-02 13:46:59   Male     70
#> 3 2014-09-02 13:59:20   Male     68
#> 4 2014-09-02 14:51:53   Male     74
#> 5 2014-09-02 15:16:15   Male     61
#> 6 2014-09-02 15:16:16 Female     65

这些高度是通过一个网页表单获得的,学生们被要求输入他们的身高。他们可以输入任何内容,但说明要求输入以英寸为单位的高度,一个数字。我们收集了 1,095 份提交,但不幸的是,包含报告的身高列中有几个非数字条目,因此变成了字符向量:

class(reported_heights$height)
#> [1] "character"

如果我们尝试将其解析为数字,我们会得到一个警告:

x <- as.numeric(reported_heights$height)
#> Warning: NAs introduced by coercion

尽管大多数值看起来像是按照要求输入的英寸高度,但我们最终还是有许多NA

sum(is.na(x))
#> [1] 81

以下是未能成功转换的一些条目:

reported_heights |> 
 mutate(new_height = as.numeric(height)) |>
 filter(is.na(new_height)) |> 
 head(n = 10)
#>             time_stamp    sex                 height new_height
#> 1  2014-09-02 15:16:28   Male                  5' 4"         NA
#> 2  2014-09-02 15:16:37 Female                  165cm         NA
#> 3  2014-09-02 15:16:52   Male                    5'7         NA
#> 4  2014-09-02 15:16:56   Male                  >9000         NA
#> 5  2014-09-02 15:16:56   Male                   5'7"         NA
#> 6  2014-09-02 15:17:09 Female                   5'3"         NA
#> 7  2014-09-02 15:18:00   Male 5 feet and 8.11 inches         NA
#> 8  2014-09-02 15:19:48   Male                   5'11         NA
#> 9  2014-09-04 00:46:45   Male                  5'9''         NA
#> 10 2014-09-04 10:29:44   Male                 5'10''         NA

我们立即看到了发生了什么。一些学生没有按照要求报告他们的身高。我们可以丢弃这些数据并继续。然而,许多条目遵循的模式,在原则上,我们可以轻松地将它们转换为英寸。例如,在上面的输出中,我们看到各种使用格式x'y"x'y''的案例,其中xy分别代表英尺和英寸。这些案例中的每一个都可以由人类读取并转换为英寸,例如5'4"5*12 + 4 = 64。因此,我们可以手动修复所有有问题的条目。然而,人类容易犯错,所以自动方法更可取。此外,因为我们计划继续收集数据,所以编写自动纠正错误输入的代码将很方便。

在这类任务中,第一步是调查有问题的条目,并尝试定义大量条目遵循的特定模式。这些组越大,我们就能用单一程序方法修复更多的条目。我们希望找到可以用规则准确描述的模式,例如“一个数字,后面跟着英尺符号,然后是一个或两个数字,最后跟着英寸符号”。

为了寻找这样的模式,删除与英寸一致且只查看有问题的条目是有帮助的。因此,我们编写了一个函数来自动完成这项工作。我们保留那些在应用as.numeric时产生NA或超出合理身高范围的数据条目。我们允许一个范围,该范围覆盖了大约 99.9999%的成年人口。我们还使用suppressWarnings来避免我们知道as.numeric会给出的警告信息。

我们应用这个函数,并找到有问题的条目数量:

problems <- reported_heights |> 
 mutate(inches = suppressWarnings(as.numeric(height))) |>
 filter(is.na(inches) | inches < 50 | inches > 84) |>
 pull(height)
length(problems)
#> [1] 292

现在我们可以通过简单地打印它们来查看所有案例。如果我们这样做,我们会看到可以使用三种模式来定义这些异常中的三个大型组。

  1. 形式为x'yx' y''x'y"的模式,其中xy分别代表英尺和英寸。以下有十个例子:
#> 5' 4" 5'7 5'7" 5'3" 5'11 5'9'' 5'10'' 5' 10 5'5" 5'2"
  1. 形式为x.yx,y的模式,其中x代表英尺,y代表英寸。以下有十个例子:
#> 5.3 5.5 6.5 5.8 5.6 5,3 5.9 6,8 5.5 6.2
  1. 报告的条目是以厘米而不是英寸为单位。以下有十个例子:
#> 150 175 177 178 163 175 178 165 165 180

一旦我们看到这些大组遵循特定的模式,我们就可以制定一个行动计划。

  1. 将符合前两种模式的条目转换为一种标准化的形式。

  2. 利用标准化来提取英尺和英寸,并将它们转换为英寸。

  3. 定义一个程序来识别以厘米为单位的条目并将它们转换为英寸。

  4. 再次检查哪些条目没有被修复,看看我们是否可以调整我们的方法使其更全面。

最后,我们希望有一个脚本,使基于网络的收集方法对最常见的用户错误具有鲁棒性。

记住,完成这些任务通常没有唯一的方法。我们在这里选择了一种帮助我们教授几个有用技术的方法。但肯定还有更有效的方法来完成这项任务。

为了实现我们的目标,我们将使用一种技术,使我们能够准确地检测模式并提取我们想要的各个部分:正则表达式(regex)。但首先,我们简要描述如何转义某些字符的功能,以便它们可以包含在字符串中。

16.3 转义

在 R 中定义字符串,我们可以使用双引号或单引号:

s <- "Hello!"
s <- 'Hello!' 

确保你选择正确的单引号,而不是反引号`

现在,如果我们想定义的字符串中包含双引号会怎样?例如,如果我们想写成 10 英寸这样10"?在这种情况下,你不能使用:

s <- "10""

因为这只是字符串10后面跟着一个双引号。如果你在 R 中输入这个,你会得到一个错误,因为你没有关闭双引号。为了避免这种情况,我们可以使用单引号:

s <- '10"'

如果我们打印出s,我们会看到双引号被反斜杠\转义了。

s
#> [1] "10\""

实际上,使用反斜杠转义提供了一种在仍然使用双引号定义字符串的同时定义字符串的方法:

s <- "10\""

在 R 中,cat函数让我们看到字符串实际上看起来是什么样子:

cat(s)
#> 10"

现在,如果我们想我们的字符串是 5 英尺,写成这样5'?在这种情况下,我们可以使用双引号或转义单引号

s <- "5'"
s <- '5\''

所以我们已经学会了如何分别写出 5 英尺和 10 英寸,但如果我们想将它们一起写出来,表示5 英尺和 10 英寸,写成这样5'10"?在这种情况下,单引号和双引号都不起作用,因为'5'10"'在 5 之后关闭了字符串,而"5'10""在 10 之后关闭了字符串。记住,如果我们将上述代码片段之一输入 R,它将卡住等待你关闭开头的引号,你将不得不使用esc按钮退出执行。

为了达到预期的结果,我们需要用反斜杠\转义两个引号。你可以转义任何可能被误认为是关闭引号的字符。这两个选项是:

s <- '5\'10"'
s <- "5'10\""

在处理字符串时,转义字符是经常需要使用的东西。另一个经常需要转义的字符是反斜杠字符本身。我们可以用\\来做到这一点。当使用正则表达式时,下一节的主题,我们经常需要转义这种方法中使用的特殊字符

16.4 正则表达式

正则表达式(regex)是描述文本中特定字符模式的一种方式。它们可以用来确定给定的字符串是否与该模式匹配。已经定义了一套规则来高效且精确地完成这项任务,以下是一些示例。我们可以通过阅读详细的教程¹ ² 来了解更多关于这些规则的信息。RStudio 的stringr和正则表达式速查表³也非常有用。

传递给stringr函数的模式可以是正则表达式,而不是标准字符串。我们将通过一系列示例来了解这是如何工作的。

在本节中,你会看到我们创建字符串来测试我们的正则表达式。为此,我们定义了我们知道应该匹配的图案和我们也知道不应该匹配的图案。我们将分别称它们为yesno。这允许我们检查两种类型的错误:匹配失败和错误匹配。

16.4.1 字符串是正则表达式

从技术上讲,任何字符串都是正则表达式,可能最简单的例子是一个单个字符。所以下一个代码示例中使用的逗号,就是一个简单的正则表达式搜索示例。

pattern <- ","
str_detect](https://stringr.tidyverse.org/reference/str_detect.html)([c("1", "10", "100", "1,000", "10,000"), pattern) 
#> [1] FALSE FALSE FALSE  TRUE  TRUE

在上面,我们提到一个条目包含了cm。这也是一个简单的正则表达式示例。我们可以这样显示所有使用了cm的条目:

str_subset(reported_heights$height, "cm")
#> [1] "165cm"  "170 cm"

16.4.2 特殊字符

现在让我们考虑一个稍微复杂一点的例子。以下哪个字符串包含模式cminches

yes <- c("180 cm", "70 inches")
no <- c("180", "70''")
s <- c(yes, no)

我们可以通过两次搜索来完成这个操作:

str_detect(s, "cm") | str_detect(s, "inches")
#> [1]  TRUE  TRUE FALSE FALSE

然而,我们不需要这样做。正则表达式语言与普通字符串的主要区别在于我们可以使用特殊字符。这些字符具有特定的意义。我们首先介绍|,它表示。所以如果我们想知道cminches是否出现在字符串中,我们可以使用正则表达式cm|inches

str_detect(s, "cm|inches")
#> [1]  TRUE  TRUE FALSE FALSE

并得到正确答案。

另一个有用的特殊字符是\d,它表示任何数字:0,1,2,3,4,5,6,7,8,9。反斜杠用于将其与字符d区分开来。在 R 中,我们必须转义反斜杠\,所以我们实际上必须使用\\d来表示数字。以下是一个示例:

yes <- c("5", "6", "5'10", "5 feet", "4'11")
no <- c("", ".", "Five", "six")
s <- c(yes, no)
pattern <- "\\d"
str_detect(s, pattern)
#> [1]  TRUE  TRUE  TRUE  TRUE  TRUE FALSE FALSE FALSE FALSE

我们借此机会介绍str_view函数,它在故障排除中非常有用,因为它显示了每个匹配字符串的所有匹配项。每个匹配项都被<>字符包围。下面,我们可以看到5有一个匹配项,而5'10有三个匹配项。

str_view(s, pattern)

要查看所有字符串,即使没有匹配,我们也可以使用match = NA参数。

str_view(s, pattern, match = NA)

另一个有用的特殊字符是 \w,它代表单词字符,可以匹配任何字母、数字或下划线。

还有许多其他的特殊字符。我们将在下面学习一些,但你可以看到大多数或所有这些都在之前提到的作弊单⁴中。

16.4.3 字符类

字符类用于定义可以匹配的一系列字符。我们使用方括号[]定义字符类。例如,如果我们想要模式只匹配有56的情况,我们使用正则表达式[56]

str_view(s, "[56]", match = NA)

假设我们想要匹配介于 4 和 7 之间的值。定义字符类的一种常见方式是使用范围。例如,[0-9]等同于\\d。因此,我们想要的模式是[4-7]

yes <- as.character(4:7)
no <- as.character(1:3)
s <- c(yes, no)
str_detect(s, "[4-7]")
#> [1]  TRUE  TRUE  TRUE  TRUE FALSE FALSE FALSE

然而,重要的是要知道,在正则表达式中,一切都是字符;没有数字。所以4是字符4,而不是数字 4。例如,注意到[1-20]并不表示从 1 到 20,它表示字符 1 到 2 或字符 0。所以[1-20]简单地表示由 0、1 和 2 组成的字符类。

请记住,字符确实有顺序,数字也遵循数字顺序。所以01之前,12之前,以此类推。同样地,我们可以将小写字母定义为[a-z],大写字母定义为[A-Z],而[a-zA-z]则表示两者。

注意到\w等同于[a-zA-Z0-9_]

16.4.4 锚点

如果我们想要匹配恰好一个数字的情况会怎样?这在我们的案例研究中很有用,因为英尺的数字永远不会超过一位,所以限制会有所帮助。使用正则表达式实现这一点的其中一种方法是通过使用锚点,这允许我们定义必须从特定位置开始或结束的模式。最常用的两个锚点是^$,分别代表字符串的开始和结束。因此,模式^\\d$可以读作“字符串开始后跟一个数字,然后是字符串结束”。

这个模式现在只检测恰好有一个数字的字符串:

pattern <- "^\\d$"
yes <- c("1", "5", "9")
no <- c("12", "123", " 1", "a4", "b")
s <- c(yes, no)
str_view(s, pattern, match = NA)

1不匹配,因为它不是以数字开头,而是以空格开头,这不容易看到。

16.4.5 有界量词

对于英寸部分,我们可以有一个或两个数字。这可以通过正则表达式中的量词来指定。这是通过在模式后面跟随包含前一个条目可以重复的次数的括号来完成的。我们称之为有界,因为量词中的数字受括号中数字的限制。稍后我们将学习关于无界量词的内容。

我们用一个例子来说明。一个或两个数字的模式是:

pattern <- "^\\d{1,2}$"
yes <- c("1", "5", "9", "12")
no <- c("123", "a4", "b")
str_view](https://stringr.tidyverse.org/reference/str_view.html)([c(yes, no), pattern, match = NA)

在这种情况下,123不匹配,但12匹配。为了查找我们的英尺和英寸模式,我们可以在数字后面添加英尺的符号'和英寸的符号"

使用我们所学的知识,我们现在可以构建一个x'y"模式的例子,其中x代表英尺,y代表英寸。

pattern <- "^[4-7]'\\d{1,2}\"$"

模式现在变得越来越复杂,但你可以仔细观察并分解它:

  • ^ = 字符串的开始

  • [4-7] = 一个数字,可以是 4、5、6 或 7

  • ' = 英尺符号

  • \\d{1,2} = 一个或两个数字

  • \" = 英寸符号

  • $ = 字符串的结尾

让我们测试一下:

yes <- c("5'7\"", "6'2\"",  "5'12\"")
no <- c("6,2\"", "6.2\"","I am 5'11\"", "3'2\"", "64")
str_detect(yes, pattern)
#> [1] TRUE TRUE TRUE
str_detect(no, pattern)
#> [1] FALSE FALSE FALSE FALSE FALSE

目前,我们允许英寸为 12 或更大。稍后我们将添加一个限制,因为对此的正则表达式比我们准备展示的要复杂一些。

16.4.6 空白

我们遇到的另一个问题是空格。例如,我们的模式不匹配5' 4",因为在'4之间有一个空格,而我们的模式不允许空格。空格是字符,R 不会忽略它们:

identical("Hi", "Hi ")
#> [1] FALSE

在正则表达式中,\s代表空白。为了找到像5' 4这样的模式,我们可以将我们的模式更改为:

pattern_2 <- "^[4-7]'\\s\\d{1,2}\"$"
str_subset(problems, pattern_2)
#> [1] "5' 4\""  "5' 11\"" "5' 7\""

然而,这不会匹配没有空格的图案。那么我们是否需要多个正则表达式模式?结果证明,我们也可以用量词来实现这一点。

16.4.7 无界量词:``, ?, +

我们希望模式允许空格,但不是必须的。即使有几个空格,比如这个例子中的5' 4,我们仍然希望它匹配。有一个量词正是为此目的。在正则表达式中,字符*表示前一个字符的零个或多个实例。以下是一个例子:

yes <- c("AB", "A1B", "A11B", "A111B", "A1111B")
no <- c("A2B", "A21B")
str_detect(yes, "A1*B")
#> [1] TRUE TRUE TRUE TRUE TRUE
str_detect(no, "A1*B")
#> [1] FALSE FALSE

上面的模式匹配第一个字符串,它没有 1,以及所有包含一个或多个 1 的字符串。然后我们可以通过在空格字符\s后面添加*来改进我们的模式。

还有另外两个类似的量词。对于零次或一次,我们可以使用?,而对于一次或多次,我们可以使用+。你可以通过这个例子看到它们的不同:

data.frame(string = c("AB", "A1B", "A11B", "A111B", "A1111B"),
 none_or_more = str_detect(yes, "A1*B"),
 nore_or_once = str_detect(yes, "A1?B"),
 once_or_more = str_detect(yes, "A1+B"))
#>   string none_or_more nore_or_once once_or_more
#> 1     AB         TRUE         TRUE        FALSE
#> 2    A1B         TRUE         TRUE         TRUE
#> 3   A11B         TRUE        FALSE         TRUE
#> 4  A111B         TRUE        FALSE         TRUE
#> 5 A1111B         TRUE        FALSE         TRUE

实际上,我们将在报告高度示例中使用这三个量词,但我们将稍后在后续部分看到它们。

16.4.8 非

为了指定我们不想检测的模式,我们可以在方括号内使用^符号,但只能在方括号内。记住,方括号外部的^表示字符串的开始。所以,例如,如果我们想检测除了字母之外任何东西前面的数字,我们可以这样做:

pattern <- "[^a-zA-Z]\\d"
yes <- c(".3", "+2", "-0","*4")
no <- c("A3", "B2", "C0", "E4")
str_detect(yes, pattern)
#> [1] TRUE TRUE TRUE TRUE
str_detect(no, pattern)
#> [1] FALSE FALSE FALSE FALSE

另一种生成搜索除了之外的所有内容的模式的方法是使用特殊字符的大写形式。例如 \\D 表示除了数字之外的所有内容,\\S 表示除了空格之外的所有内容,等等。

16.4.9 组

组是正则表达式的一个强大方面,允许提取值。组是用括号定义的。它们本身不影响模式匹配。相反,它允许工具识别模式的具体部分,以便我们可以提取它们。

我们想要将像 5.6 这样的高度更改为 5'6

为了避免更改像 70.2 这样的模式,我们将要求第一个数字在 4 到 7 之间 [4-7],第二个数字是零个或多个数字 \\d*。让我们首先定义一个匹配此模式的简单模式:

pattern_without_groups <- "^[4-7],\\d*$"

我们想要提取数字,以便然后使用句点形成新的版本。这些是我们的两个组,因此我们用括号将它们封装起来:

pattern_with_groups <-  "^([4-7]),(\\d*)$"

我们将与我们要稍后使用的部分匹配的模式部分封装起来。添加组不会影响检测,因为它只是表示我们想要保存由组捕获的内容。请注意,当使用 str_detect 时,两种模式返回相同的结果:

yes <- c("5,9", "5,11", "6,", "6,1")
no <- c("5'9", ",", "2,8", "6.1.1")
s <- c(yes, no)
str_detect(s, pattern_without_groups)
#> [1]  TRUE  TRUE  TRUE  TRUE FALSE FALSE FALSE FALSE
str_detect(s, pattern_with_groups)
#> [1]  TRUE  TRUE  TRUE  TRUE FALSE FALSE FALSE FALSE

一旦我们定义了组,我们就可以使用 str_match 函数来提取这些组定义的值:

str_match(s, pattern_with_groups)
#>      [,1]   [,2] [,3]
#> [1,] "5,9"  "5"  "9" 
#> [2,] "5,11" "5"  "11"
#> [3,] "6,"   "6"  "" 
#> [4,] "6,1"  "6"  "1" 
#> [5,] NA     NA   NA 
#> [6,] NA     NA   NA 
#> [7,] NA     NA   NA 
#> [8,] NA     NA   NA

注意,第二列和第三列分别包含英尺和英寸。第一列是字符串匹配模式的部分。如果没有发生匹配,我们看到一个 NA

现在我们可以理解 str_extractstr_match 函数之间的区别。str_extract 仅提取与模式匹配的字符串,而不是由组定义的值:

str_extract(s, pattern_with_groups)
#> [1] "5,9"  "5,11" "6,"   "6,1"  NA     NA     NA     NA

16.4.10 搜索和替换

之前我们定义了包含不像是英寸的字符串的对象 problems。我们可以看到,我们的问题字符串中并不太多与该模式匹配:

pattern <- "^[4-7]'\\d{1,2}\"$"
sum(str_detect(problems, pattern))
#> [1] 14

为了了解为什么是这样,我们展示了一些例子,以揭示我们没有更多匹配的原因:

problemsc(2, 10, 11, 12, 15)] |> [str_view(pattern)

我们立即看到的一个初始问题是,一些学生写出了“feet”和“inches”这两个词。我们可以使用 str_subset 函数看到这样做条目的例子:

str_subset(problems, "inches")
#> [1] "5 feet and 8.11 inches" "Five foot eight inches"
#> [3] "5 feet 7inches"         "5ft 9 inches" 
#> [5] "5 ft 9 inches"          "5 feet 6 inches"

我们还看到,一些条目使用了两个单引号 '' 而不是双引号 "

str_subset(problems, "''")
#>  [1] "5'9''"   "5'10''"  "5'10''"  "5'3''"   "5'7''"   "5'6''" 
#>  [7] "5'7.5''" "5'7.5''" "5'10''"  "5'11''"  "5'10''"  "5'5''"

为了纠正这一点,我们可以用统一的符号替换表示英寸和英尺的不同方式。我们将使用 ' 表示英尺,而对于英寸,我们将简单地不使用符号,因为有些条目是 x'y 的形式。现在,如果我们不再使用英寸符号,我们必须相应地更改我们的模式:

pattern <- "^[4-7]'\\d{1,2}$"

如果我们在这个匹配之前进行替换,我们会得到更多的匹配:

problems |> 
 str_replace("feet|ft|foot", "'") |> # replace feet, ft, foot with ' 
 str_replace("inches|in|''|\"", "") |> # remove all inches symbols
 str_detect(pattern) |> 
 sum()
#> [1] 48

然而,我们仍然有很多案例要处理。

注意,在上面的代码中,我们利用了 stringr 的一致性并使用了管道符。

目前,我们通过在英尺符号 ' 前后添加 \\s* 来改进我们的模式,以允许英尺符号和数字之间有空间。现在我们可以匹配更多条目:

pattern <- "^[4-7]\\s*'\\s*\\d{1,2}$"
problems |> 
 str_replace("feet|ft|foot", "'") |> # replace feet, ft, foot with ' 
 str_replace("inches|in|''|\"", "") |> # remove all inches symbols
 str_detect(pattern) |> 
 sum()
#> [1] 53

我们可能会倾向于通过使用 str_replace_all 移除所有空格来避免这样做。然而,在进行此类操作时,我们需要确保它不会产生意外的效果。在我们的报告高度示例中,这将是一个问题,因为一些条目是以 x y 的形式出现的,英尺和英寸之间由空格分隔。如果我们移除所有空格,我们将错误地将 x y 转换为 xy,这意味着 6 1 将成为 61 英寸而不是 73 英寸。

第二大类有问题的条目形式为 x.yx,yx y。我们希望将这些全部更改为我们的常用格式 x'y。但我们不能简单地执行搜索和替换,因为我们将会将像 70.5 这样的值更改为 70'5。因此,我们的策略将是搜索一个非常具体的模式,以确保提供了英尺和英寸,然后对于匹配的条目,适当地进行替换。

16.4.11 使用组进行搜索和替换

组的一个强大方面是,你可以在搜索和替换时引用提取的值。

对于第 i 个组的正则表达式特殊字符是 \\i。因此,\\1 是从第一个组中提取的值,\\2 是从第二个组中提取的值,依此类推。作为一个简单的例子,请注意以下代码将替换逗号为句点,但仅当它位于 4 到 7 之间的数字之后,并且后面跟着零个或多个数字时:

pattern_with_groups <-  "^([4-7]),(\\d*)$"
yes <- c("5,9", "5,11", "6,", "6,1")
no <- c("5'9", ",", "2,8", "6.1.1")
s <- c(yes, no)
str_replace(s, pattern_with_groups, "\\1'\\2")
#> [1] "5'9"   "5'11"  "6'"    "6'1"   "5'9"   ","     "2,8"   "6.1.1"

我们可以使用这个来转换我们报告的高度中的情况。

我们现在可以定义一个模式,帮助我们将所有 x.yx,yx y 转换为我们喜欢的格式。我们需要使 pattern_with_groups 更灵活一些,以捕获所有情况。

pattern_with_groups <- "^([4-7])\\s*[,\\.\\s+]\\s*(\\d*)$"

让我们来分解这个:

  • ^ = 字符串的开始

  • [4-7] = 表示英尺的一位数字,可以是 4、5、6 或 7

  • \\s* = 零个或多个空白字符

  • [,\\.\\s+] = 英尺和英寸由逗号 ``,、点 .` 或至少一个空格分隔

  • \\s* = 零个或多个空白字符

  • \\d* = 零个或多个表示英寸的数字

  • $ = 字符串的结束

我们可以看到它似乎正在工作:

str_subset](https://stringr.tidyverse.org/reference/str_subset.html)(problems, pattern_with_groups) |> [head()
#> [1] "5.3"  "5.25" "5.5"  "6.5"  "5.8"  "5.6"

并且能够执行搜索和替换:

str_subset(problems, pattern_with_groups) |> 
 str_replace](https://stringr.tidyverse.org/reference/str_replace.html)(pattern_with_groups, "\\1'\\2") |> [head()
#> [1] "5'3"  "5'25" "5'5"  "6'5"  "5'8"  "5'6"

同样,我们将在稍后处理英寸大于十二的挑战。

16.4.12 先行断言

先行断言提供了一种方式,可以在不移动搜索位置或匹配它的情况下要求满足一个或多个条件。例如,你可能想要检查多个条件,如果它们匹配,则返回匹配的模式或模式的某个部分。

有四种类型的先行断言:先行断言 (?=pattern),后行断言 (?<=pattern),负先行断言 (?!pattern),和负后行断言 (?<!pattern)

传统的示例检查密码必须满足几个条件,例如 1) 8-16 个单词字符,2) 以字母开头,3) 至少有一个数字。你可以连接前瞻来检查多个条件。对于我们的示例,我们可以写出

pattern <- "(?=\\w{8,16})(?=^[a-z|A-Z].*)(?=.*\\d+.*)"

一个更简单的例子是将所有 superman 改为 supergirl,而不改变所有男性为女性。我们可以使用类似这样的前瞻:

s <- "Superman saved a man. The man thanked superman."
str_replace_all(s, "(?<=[Ss]uper)man", "girl")
#> [1] "Supergirl saved a man. The man thanked supergirl."

16.4.13 分离变量

在第 11.3 节 中,我们介绍了可以将列拆分为新列的函数。separate_wider_regex 使用正则表达式而不是分隔符来在数据框中分隔变量。它使用类似于正则表达式组的策略,并将每个组转换为一个新的列。

假设我们有一个如下所示的数据框:

tab <- data.frame(x = c("5'10", "6' 1", "5 ' 9", "5'11\""))

请注意,使用 separate_wider_delim 在这里不会起作用,因为分隔符可能在不同条目中变化。使用 separate_wider_regex 我们可以定义灵活的模式,这些模式与每个列匹配。

patterns <- c(feet = "\\d", "\\s*'\\s*", inches = "\\d{1,2}", ".*")
tab |> separate_wider_regex(x, patterns = patterns)
#> # A tibble: 4 × 2
#>   feet  inches
#>   <chr> <chr> 
#> 1 5     10 
#> 2 6     1 
#> 3 5     9 
#> 4 5     11

通过不为 patterns 的第二个和第四个条目命名,我们告诉函数不要保留与该模式匹配的值。

16.5 去除空白

一般来说,字符串开头或结尾的空格是无意义的。这些可能特别具有欺骗性,因为有时它们可能很难看到:

s <- "Hi "
cat(s)
#> Hi
identical(s, "Hi")
#> [1] FALSE

这是一个足够普遍的问题,以至于有一个专门的函数用于移除它们:str_trim

str_trim("5 ' 9 ")
#> [1] "5 ' 9"

16.6 大小写转换

请注意,正则表达式是区分大小写的。我们经常想要匹配一个单词,而不考虑大小写。实现这一目标的一种方法是将所有内容首先转换为小写,然后忽略大小写进行操作。例如,请注意,其中一个条目将数字写成文字 Five foot eight inches。虽然效率不高,但我们可以在 zero0one1 等等之间添加 13 个额外的 str_replace 调用来转换。为了避免必须为 ZerozeroOneone 等等分别编写两个单独的操作,我们可以使用 str_to_lower 函数首先将所有内容转换为小写:

s <- c("Five feet eight inches")
str_to_lower(s)
#> [1] "five feet eight inches"

其他相关函数包括 str_to_upperstr_to_title。我们现在可以定义一个将所有问题案例转换为英寸的程序。

16.7 案例研究 1 继续进行:将所有内容组合起来

我们现在可以将其全部组合起来,整理我们的报告身高数据,以尝试恢复尽可能多的身高。代码很复杂,但我们将将其分解为部分。

让我们看看使用我们在 16.4 节 中介绍的方法可以恢复多少问题条目。我们首先定义一个函数,用于检测未报告为英寸或厘米的条目:

not_inches_or_cm <- function(x, smallest = 50, tallest = 84){
 inches <- suppressWarnings(as.numeric(x))
 is.na(inches) |
 !((inches >= smallest & inches <= tallest) |
 (inches/2.54 >= smallest & inches/2.54 <= tallest))
}

我们可以看到有多少条目不是英寸或厘米:

problems <- reported_heights |> 
 filter(not_inches_or_cm(height)) |>
 pull(height)
length(problems)
#> [1] 200

让我们看看我们可以通过应用第 16.4 节的发现恢复多少英尺英寸格式。具体来说,我们将英尺/foot/ft 替换为',移除单词 inches 及其缩写,然后将类似模式重写为所需的英尺'英寸模式。一旦完成,我们就会看到有多少比例不符合所需模式。

converted <- problems |> 
 str_replace("feet|foot|ft", "'") |> 
 str_remove_all("inches|in|''|\"") |> 
 str_replace("^([4-7])\\s*[,\\.\\s+]\\s*(\\d*)$", "\\1'\\2") 
index <- str_detect(converted, "^[4-7]\\s*'\\s*\\d{1,2}$")
mean(!index)
#> [1] 0.385

我们看到还有相当一部分尚未修复。试错法是找到满足所有所需条件的正则表达式模式的一种常见方法。我们可以检查剩余的案例,试图解码我们可以用来修复它们的新模式:

converted[!index]
#>  [1] "6"             "165cm"         "511"           "6" 
#>  [5] "2"             ">9000"         "5 ' and 8.11 " "11111" 
#>  [9] "6"             "103.2"         "19"            "5" 
#> [13] "300"           "6'"            "6"             "Five ' eight "
#> [17] "7"             "214"           "6"             "0.7" 
#> [21] "6"             "2'33"          "612"           "1,70" 
#> [25] "87"            "5'7.5"         "5'7.5"         "111" 
#> [29] "5' 7.78"       "12"            "6"             "yyy" 
#> [33] "89"            "34"            "25"            "6" 
#> [37] "6"             "22"            "684"           "6" 
#> [41] "1"             "1"             "6*12"          "87" 
#> [45] "6"             "1.6"           "120"           "120" 
#> [49] "23"            "1.7"           "6"             "5" 
#> [53] "69"            "5' 9 "         "5 ' 9 "        "6" 
#> [57] "6"             "86"            "708,661"       "5 ' 6 " 
#> [61] "6"             "649,606"       "10000"         "1" 
#> [65] "728,346"       "0"             "6"             "6" 
#> [69] "6"             "100"           "88"            "6" 
#> [73] "170 cm"        "7,283,465"     "5"             "5" 
#> [77] "34"

我们注意到两名学生添加了cm,一些条目末尾有空格,所以我们将其纳入我们的清理阶段。我们还注意到至少有一条记录写出了诸如Five foot eight inches之类的数字。我们可以使用english包中的words()函数将这些转换为实际数字。为了准备我们的最终产品,我们定义了一个函数,用于清理之前注意到的这些问题和新问题:

library(english)
cleanup <- function(s){
 s <- str_remove_all(s, "inches|in|''|\"|cm|and") |> 
 str_trim() |>
 str_to_lower()
 for (i in 0:11) {
 s <- str_replace_all](https://stringr.tidyverse.org/reference/str_replace.html)(s, words(i), [as.character(i))
 }
 return(s)
}

许多人也注意到,身高正好为 5 或 6 英尺的学生没有输入任何英寸,例如6',而我们的模式要求必须包含英寸,并且一些条目使用米制,其中一些使用欧洲小数,例如1.61,70。因此,我们创建了一个函数,将这些更正添加到已经识别为需要重新格式化的条目中,以便将其作为英尺'英寸格式化:

convert_format <- function(s){
 s |> str_replace("feet|foot|ft", "'") |> 
 str_replace("^([4-7])\\s*[,\\.\\s+]\\s*(\\d*)$", "\\1'\\2") |> 
 str_replace("^([56])'?$", "\\1'0") |> 
 str_replace("^([12])\\s*,\\s*(\\d*)$", "\\1\\.\\2")
}

现在我们准备好处理报告的身高数据集。策略如下:

  1. 我们首先定义一个变量来保存原始条目的副本,然后使用上述函数清理和转换height条目。

  2. 我们然后使用separate_wider_regex_函数在条目匹配我们的英尺'英寸格式时提取英尺和英寸。

  3. 我们使用前瞻来确保没有紧跟'的数字的条目不匹配并返回NA

  4. 一旦完成,我们将英尺和英寸转换为英寸。

  5. 最后,我们决定条目是英寸、厘米还是米,并相应地进行转换。

patterns <- c(feet = "4-7", 
 "\\s*'\\s*", 
 inches = "\\d+\\.?\\d*")
smallest <- 50
tallest <- 84
new_heights <- reported_heights |> 
 mutate(original = height, 
 height = convert_format(cleanup(height))) |>
 separate_wider_regex(height, patterns = patterns, 
 too_few = "align_start", 
 cols_remove = FALSE) |> 
 mutate(across(c(height, feet, inches), as.numeric)) |>
 mutate(guess = 12 * feet + inches) |>
 mutate(height = case_when(
 is.na(height) ~ as.numeric(NA),
 between(height, smallest, tallest) ~ height,  #inches
 between(height/2.54, smallest, tallest) ~ height/2.54, #cm
 between(height*100/2.54, smallest, tallest) ~ height*100/2.54, #meters
 TRUE ~ as.numeric(NA))) |>
 mutate(height = ifelse(is.na(height) & 
 inches <= 12 & between(guess, smallest, tallest),
 guess, height)) |>
 select(-feet, -inches, -guess)

我们看到我们修复了除 44 条以外的所有条目。你可以看到这些大多是无法修复的:

new_heights |> filter(is.na](https://rdrr.io/r/base/NA.html)(height)) |> [pull(original)
#>  [1] "511"       ">9000"     "5.25"      "11111"     "103.2" 
#>  [6] "19"        "300"       "5.75"      "7"         "214" 
#> [11] "0.7"       "2'33"      "612"       "87"        "111" 
#> [16] "12"        "yyy"       "89"        "34"        "25" 
#> [21] "22"        "684"       "1"         "1"         "6*12" 
#> [26] "87"        "120"       "120"       "23"        "5.51" 
#> [31] "5.69"      "86"        "708,661"   "5.25"      "649,606" 
#> [36] "10000"     "1"         "728,346"   "0"         "100" 
#> [41] "5.57"      "88"        "7,283,465" "34"

你可以通过输入以下内容来查看我们修复的条目:

new_heights |>
 filter(not_inches_or_cm(original)) |>
 select(original, height) |> 
 arrange(height) |>
 View()

一个最后的观察是,如果我们看看我们课程中最矮的学生:

new_heights |> arrange(height) |> head(n = 6)
#> # A tibble: 6 × 4
#>   time_stamp          sex    height original
#>   <chr>               <chr>   <dbl> <chr> 
#> 1 2017-07-04 01:30:25 Male       50 50 
#> 2 2017-09-07 10:40:35 Male       50 50 
#> 3 2014-09-02 15:18:30 Female     51 51 
#> 4 2016-06-05 14:07:20 Female     52 52 
#> 5 2016-06-05 14:07:38 Female     52 52 
#> # ℹ 1 more row

我们看到身高为 50、51、52 等等。这些较矮的身高很少见,学生实际上可能是指 5’0、5’1、5’2 等等。因为我们并不完全确定,所以我们将它们保留为报告的数值。对象 new_heights 包含本案例研究的最终解决方案。

16.8 案例研究 2:从 PDF 中提取表格

dslabs提供的某些数据集中,显示了荷兰按性别划分的科学资助率:

library(dslabs)
research_funding_rates |> 
 select("discipline", "success_rates_men", "success_rates_women")
#>            discipline success_rates_men success_rates_women
#> 1   Chemical sciences              26.5                25.6
#> 2   Physical sciences              19.3                23.1
#> 3             Physics              26.9                22.2
#> 4          Humanities              14.3                19.3
#> 5  Technical sciences              15.9                21.0
#> 6   Interdisciplinary              11.4                21.8
#> 7 Earth/life sciences              24.4                14.3
#> 8     Social sciences              15.3                11.5
#> 9    Medical sciences              18.8                11.2

数据来自发表在《美国国家科学院院刊》(PNAS)上的一篇论文⁵,这是一本广受欢迎的科学期刊。然而,数据并没有以电子表格的形式提供;它是在 PDF 文档中的一个表格中。以下是表格的截图:

(来源:Romy van der Lee 和 Naomi Ellemers,PNAS 2015 112 (40) 12349-12353⁶.)

我们可以手动提取数字,但这可能导致人为错误。相反,我们可以尝试使用 R 来整理数据。我们首先下载 PDF 文档,然后将其导入 R:

library("pdftools")
temp_file <- tempfile()
url <- paste0("https://web.archive.org/web/20150927033124/",
 "https://www.pnas.org/content/suppl/2015/09/16/",
 "1510159112.DCSupplemental/pnas.201510159SI.pdf")
download.file(url, temp_file, mode = "wb")
txt <- pdf_text(temp_file)
file.remove(temp_file)

mode = "wb" 参数仅在 Microsoft Windows 中使用时是必要的。在 MacOS 或 Linux 上,默认的 mode = "w" 就可以工作。要了解这种差异,请参阅 download.file 帮助文件。* 如果我们检查对象文本,我们会注意到它是一个字符向量,每个页面都有一个条目。因此,我们保留我们想要的页面:

raw_data_research_funding_rates <- txt[2]

上述步骤实际上可以跳过,因为我们已经将此原始数据包含在 dslabs 包中。

检查对象 raw_data_research_funding_rates,我们发现它是一个长字符串,页面上的每一行(包括表格行)都由换行符 \n 分隔。将此转换为数据框的第一步是将行单独存储,以便它们易于访问。为此,我们使用 str_split 函数:

tab <- str_split(raw_data_research_funding_rates, "\n+")

在 MacOS 或 Linux 上,您可以直接使用 \n 作为分隔符。Microsoft Windows 和基于 Unix 的 macOS 使用不同的约定来处理文本文件中的行结束,因此 raw_data_research_funding_rates 在 Windows 上使用时 \n 更多。* 因为我们从只有一个字符串开始,所以我们最终得到一个只有一个条目的列表。

tab <- tab[[1]]

通过检查 tab,我们看到列名信息是第三和第四条记录:

the_names_1 <- tab[3]
the_names_2 <- tab[4]

这些行中的第一行看起来像这样:

#>                                                       Applications, n
#>                   Awards, n                      Success rates, %

我们想要为每一列创建一个具有单个名称的向量。使用我们刚刚学到的某些函数,我们可以这样做。

让我们从上面的 the_names_1 开始。我们想要去除前导空格和逗号后面的任何内容。我们使用正则表达式来处理后者。然后我们可以通过分割由空格分隔的字符串来获取元素。我们只想在有两个或更多空格时进行分割,以避免分割 Success rates。因此,我们使用正则表达式 \\s{2,}

the_names_1 <- the_names_1 |>
 str_trim() |>
 str_replace_all(",\\s.", "") |>
 str_split("\\s{2,}", simplify = TRUE)
the_names_1 
#>      [,1]           [,2]     [,3] 
#> [1,] "Applications" "Awards" "Success rates"

现在我们将查看 the_names_2

#>                         Discipline              Total     Men      Women
#> n         Total    Men       Women          Total    Men      Women

在这里,我们想要去除前导空格,然后按照我们为第一行所做的那样通过空格进行分割:

the_names_2 <- the_names_2 |>
 str_trim() |>
 str_split("\\s+", simplify = TRUE)
the_names_2
#>      [,1]         [,2]    [,3]  [,4]    [,5]    [,6]  [,7]    [,8] 
#> [1,] "Discipline" "Total" "Men" "Women" "Total" "Men" "Women" "Total"
#>      [,9]  [,10] 
#> [1,] "Men" "Women"

然后我们可以将这些信息连接起来,为每一列生成一个名称:

tmp_names <- paste(rep(the_names_1, each = 3), the_names_2[-1], sep = "_")
the_names <- c(the_names_2[1], tmp_names) |>
 str_to_lower() |>
 str_replace_all("\\s", "_")
the_names
#>  [1] "discipline"          "applications_total"  "applications_men" 
#>  [4] "applications_women"  "awards_total"        "awards_men" 
#>  [7] "awards_women"        "success_rates_total" "success_rates_men" 
#> [10] "success_rates_women"

现在我们准备获取实际数据。通过检查 tab 对象,我们发现信息位于第 6 到 14 行。我们可以再次使用 str_split 来实现我们的目标:

new_research_funding_rates <- tab[6:14] |>
 str_trim() |>
 str_split("\\s{2,}", simplify = TRUE) |>
 data.frame() |>
 setNames(the_names) |>
 mutate(across(-1, parse_number))
new_research_funding_rates |> as_tibble()
#> # A tibble: 9 × 10
#>   discipline      applications_total applications_men applications_women
#>   <chr>                        <dbl>            <dbl>              <dbl>
#> 1 Chemical scien…                122               83                 39
#> 2 Physical scien…                174              135                 39
#> 3 Physics                         76               67                  9
#> 4 Humanities                     396              230                166
#> 5 Technical scie…                251              189                 62
#> # ℹ 4 more rows
#> # ℹ 6 more variables: awards_total <dbl>, awards_men <dbl>,
#> #   awards_women <dbl>, success_rates_total <dbl>,
#> #   success_rates_men <dbl>, success_rates_women <dbl>

我们可以看到这些对象是相同的:

identical(research_funding_rates, new_research_funding_rates)
#> [1] TRUE

16.9 重命名层级

另一个常见的字符串操作是重命名分类变量的级别。假设你的级别名称非常长。如果你将在图形中显示它们,你可能希望使用这些名称的简短版本。例如,在包含国家名称的字符向量中,你可能希望将“United States of America”改为“USA”,将“United Kingdom”改为 UK,等等。

而不是更改每个条目,一个更有效的方法是更改级别。

这里有一个示例,展示了如何重命名具有长名称的国家:

library(dslabs)

假设我们想展示加勒比地区按国家划分的预期寿命时间序列。如果我们绘制这个图

gapminder |> 
 filter(region == "Caribbean") |>
 ggplot(aes(year, life_expectancy, color = country)) +
 geom_line()

* *我们可以看到图例占据了大部分的绘图区域,因为我们有四个国家名称超过 12 个字符。我们可以使用case_when函数来重命名这些级别:

x <- levels(gapminder$country)
levels](https://rdrr.io/r/base/levels.html)(gapminder$country) <- [case_when(
 x == "Antigua and Barbuda" ~ "Barbuda",
 x == "Dominican Republic" ~ "DR",
 x == "St. Vincent and the Grenadines" ~ "St. Vincent",
 x == "Trinidad and Tobago" ~ "Trinidad",
 .default = x)
gapminder |> 
 filter(region == "Caribbean") |>
 ggplot(aes(year, life_expectancy, color = country)) +
 geom_line()

* *我们可以使用forcats包中的fct_recode函数来代替:

library(forcats)
gapminder$country <- 
 fct_recode(gapminder$country, 
 "Barbuda" = "Antigua and Barbuda",
 "DR" = "Dominican Republic",
 "St. Vincent" = "St. Vincent and the Grenadines",
 "Trinidad" = "Trinidad and Tobago")

16.10 练习

  1. 在 RegexOne⁷在线交互教程中完成所有课程和练习。

  2. dslabs包的extdata目录中,你可以找到一个包含从 2015 年 1 月 1 日到 2018 年 5 月 31 日每天死亡率数据的 PDF 文件。你可以这样找到文件:

fn <- system.file("extdata", "RD-Mortality-Report_2015-18-180531.pdf",
 package="dslabs")

找到并打开文件,或者直接从 RStudio 中打开。在 Mac 上,你可以输入以下命令:

system2("open", args = fn)

在 Windows 上,你可以输入以下命令:

system("cmd.exe", input = paste("start", fn))

以下哪个最能描述这个文件:

  1. 这是一个表格。提取数据将会很容易。

  2. 这是一个用散文写成的报告。提取数据将是不可能的。

  3. 这是一个结合图形和表格的报告。提取数据似乎可行。

  4. 它显示了数据的图形。提取数据将会很困难。

  5. 我们将创建一个整洁的数据集,其中每一行代表一个观察值。这个数据集中的变量将是年份、月份、日期和死亡人数。首先安装并加载pdftools包:

install.packages("pdftools")
library(pdftools)

现在使用pdf_text函数读取fn并将其结果存储在一个名为txt的对象中。以下哪个最能描述你在txt中看到的内容?

  1. 一个包含死亡率数据的表格。

  2. 一个长度为 12 的字符字符串。每个条目代表每一页中的文本。死亡率数据就在其中某个地方。

  3. 一个包含 PDF 文件中所有信息的条目的字符字符串。

  4. 一个 HTML 文档。

  5. 从对象txt中提取 PDF 文件的第九页,然后使用stringr包中的str_split,以便每行都在不同的条目中。将这个字符串向量命名为s。然后查看结果并选择最能描述你所看到的内容的那个。

  6. 它是一个空字符串。

  7. 我可以看到第 1 页显示的图形。

  8. 这是一个整洁的表格。

  9. 我可以看到这个表格!但是还有一大堆其他东西需要去除。

  10. s是什么类型的对象,它有多少个条目?

  11. 我们看到输出是一个只有一个元素的列表。将s重新定义为列表的第一个条目。s是什么类型的对象,它有多少个条目?

  12. 当检查我们获得的字符串时,我们看到一个常见问题:其他字符前后有空格。修剪是字符串处理中的常见第一步。这些额外的空格最终会使字符串分割变得困难,所以我们首先开始移除它们。我们学习了str_trim命令,它可以移除字符串开头或结尾的空格。使用此函数来修剪s

  13. 我们想要从存储在s中的字符串中提取数字。然而,有许多非数字字符会妨碍这个过程。我们可以移除这些字符,但在这样做之前,我们想要保留包含月份缩写的列标题字符串。使用str_which函数来找到包含标题的行。将这些结果保存到header_index。提示:使用str_which函数找到第一个匹配模式2015的字符串。

  14. 现在我们将定义两个对象:month将存储月份,header将存储列名。确定哪一行包含表格的标题。将行的内容保存到名为header的对象中,然后使用str_split来帮助我们定义所需的两个对象。提示:这里的分隔符是一个或多个空格。同时,考虑使用simplify参数。

  15. 注意到在页面末尾,你看到有一个totals行,后面跟着其他摘要统计信息的行。创建一个名为tail_index的对象,其中包含totals条目的索引。

  16. 因为我们的 PDF 页面包含带有数字的图表,所以一些行只有从图表的 y 轴来的一个数字。使用str_count函数创建一个对象n,其中包含每行中的数字数量。提示:你可以写一个像这样的数字正则表达式\\d+

  17. 我们现在准备从我们知道不需要的行中删除条目。header_index及其之前的内容应该被删除。n为 1 的条目也应该被删除,以及tail_index及其之后的内容也应该被删除。

  18. 现在我们准备删除所有非数字条目。使用正则表达式和str_remove_all函数来完成此操作。提示:记住,在正则表达式中,特殊字符的大写版本通常表示相反的含义。所以\\D表示“不是数字”。记住你还需要保留空格。

  19. 要将字符串转换为表格,使用str_split_fixed函数。将s转换为只包含日期和死亡计数数据的矩阵。提示:注意分隔符是一个或多个空格。将参数n设置为限制列数为 4 列的值,最后一列捕获所有额外内容。然后只保留前四列。

  20. 现在你几乎要完成了。给矩阵添加列名,包括一个名为day的列。再添加一个表示月份的列。将结果对象命名为dat。最后,确保天数是整数而不是字符。提示:只使用前五行。

  21. 现在通过使用pivot_longer函数整理tab来完成它。

  22. 使用颜色表示年份,绘制死亡人数与天数的关系图。由于我们没有整年的数据,排除 2018 年。

  23. 现在我们已经逐步整理了这些数据,将它们全部放在一个 R 代码块中,尽可能多地使用管道操作符。提示:首先定义索引,然后写一行代码来完成所有的字符串处理。

  24. 高级:让我们回到网络爬取部分中的 MLB 薪资示例。使用你在网络爬取和字符串处理章节中学到的知识来提取纽约洋基队、波士顿红袜队和奥克兰运动家的薪资,并按时间绘制它们。


  1. www.regular-expressions.info/tutorial.html↩︎

  2. r4ds.had.co.nz/strings.html#matching-patterns-with-regular-expressions↩︎

  3. rstudio.github.io/cheatsheets/strings.pdf↩︎

  4. rstudio.github.io/cheatsheets/strings.pdf↩︎

  5. www.pnas.org/content/112/40/12349.abstract↩︎

  6. www.pnas.org/content/112/40/12349↩︎

  7. regexone.com/↩︎

17  文本分析

rafalab.dfci.harvard.edu/dsbook-part-1/wrangling/text-analysis.html

  1. 数据整理

  2. 17  文本分析

除了用于表示分类数据的标签外,我们主要关注数值数据。但在许多应用中,数据最初是以文本形式出现的。众所周知的例子包括垃圾邮件过滤、网络犯罪预防、反恐和情感分析。在这些所有情况下,原始数据都是由自由文本组成的。我们的任务是从中提取洞察。在本节中,我们学习如何从文本数据中生成有用的数值摘要,然后我们可以应用我们已学到的某些强大的数据可视化和分析技术。

17.1 案例研究:特朗普推文

在 2016 年美国总统选举期间,当时的候选人唐纳德·J·特朗普使用他的推特账户与潜在选民进行沟通。2016 年 8 月 6 日,托德·瓦齐里¹ 关于特朗普发推说:“每个非夸张的推文都来自 iPhone(他的工作人员)。每个夸张的推文都来自 Android(他本人)。”大卫·罗宾逊² 进行了分析,以确定数据是否支持这一说法。在这里,我们通过大卫的分析来学习文本分析的一些基础知识。要了解更多关于 R 中的文本分析,我们推荐茱莉亚·西尔格和戴维·罗宾逊合著的《Text Mining with R》一书³。

我们将使用以下库:

library(tidyverse
library(scales
library(tidytext)
library(textdata)
library(dslabs)

X,以前称为 twitter,提供了一个 API,允许下载推文。布伦丹·布朗运营的 trump archive⁴ 收集了特朗普账户的推文数据。dslabs 包含以下范围内的推文:

range(trump_tweets$created_at)
#> [1] "2009-05-04 13:54:25 EST" "2018-01-01 08:37:52 EST"

数据框包括以下变量:

names(trump_tweets)
#> [1] "source"                  "id_str" 
#> [3] "text"                    "created_at" 
#> [5] "retweet_count"           "in_reply_to_user_id_str"
#> [7] "favorite_count"          "is_retweet"

帮助文件 ?trump_tweets 提供了每个变量代表的详细说明。实际的推文包含在 text 变量中:

trump_tweets$text16413] |> str_wrap(width = [options()$width) |> cat()
#> Great to be back in Iowa! #TBT with @JerryJrFalwell joining me in
#> Davenport- this past winter. #MAGA https://t.co/A5IF0QHnic

源变量告诉我们每个推文是用哪种设备编写和上传的:

trump_tweets |> count(source) |> arrange(desc(n)) |> head(5)
#>                source     n
#> 1  Twitter Web Client 10718
#> 2 Twitter for Android  4652
#> 3  Twitter for iPhone  3962
#> 4           TweetDeck   468
#> 5     TwitLonger Beta   288

我们对 2016 年竞选期间发生的事情感兴趣,因此在本分析中,我们将关注特朗普宣布竞选到选举日之间的推文。我们定义以下表格,仅包含该时间段的推文。我们移除了 Twitter for 的部分,只保留来自 Android 或 iPhone 的推文,并过滤掉转发推文。

campaign_tweets <- trump_tweets |> 
 filter(source %in% paste("Twitter for", c("Android", "iPhone")) &
 created_at >= ymd("2015-06-17") & 
 created_at < ymd("2016-11-08")) |>
 mutate(source = str_remove(source, "Twitter for ")) |>
 filter(!is_retweet) |>
 arrange(created_at) |> 
 as_tibble()

现在我们可以使用数据可视化来探索两个不同的群体是否可能在这些设备上发推。对于每条推文,我们将提取推文的东部标准时间(EST)小时,然后计算每个设备在每个小时推文的比例:

campaign_tweets |>
 mutate(hour = hour(with_tz(created_at, "EST"))) |>
 count(source, hour) |>
 group_by(source) |>
 mutate(percent = n / sum(n)) |>
 ungroup() |>
 ggplot(aes(hour, percent, color = source)) +
 geom_line() +
 geom_point() +
 scale_y_continuous(labels = percent_format()) +
 labs(x = "Hour of day (EST)", y = "% of tweets", color = "")

* *我们注意到在早上 6 点到 8 点之间,Android 设备有一个很大的峰值。这些模式似乎存在明显的差异。因此,我们将假设两个不同的实体正在使用这两款设备。

现在,我们将研究当我们将 Android 与 iPhone 进行比较时,推文的文本如何不同。为此,我们引入了tidytext包。

17.2 文本作为数据

tidytext包帮助我们将自由形式的文本转换为整洁的表格。以这种格式存储数据极大地促进了数据可视化和统计技术的应用。

实现这一目标所需的主要功能是unnest_tokenstoken指的是我们考虑作为数据点的单元。最常见的token将是单词,但它们也可以是单个字符、n-grams、句子、行或由正则表达式定义的模式。该函数将接受一个字符串向量,并提取 token,以便每个 token 在新表格中占一行。以下是一个简单的例子:

poem <- c("Roses are red,", "Violets are blue,", 
 "Sugar is sweet,", "And so are you.")
example <- tibble](https://tibble.tidyverse.org/reference/tibble.html)(line = [c(1, 2, 3, 4),
 text = poem)
example
#> # A tibble: 4 × 2
#>    line text 
#>   <dbl> <chr> 
#> 1     1 Roses are red, 
#> 2     2 Violets are blue,
#> 3     3 Sugar is sweet, 
#> 4     4 And so are you.
example |> unnest_tokens(word, text)
#> # A tibble: 13 × 2
#>    line word 
#>   <dbl> <chr> 
#> 1     1 roses 
#> 2     1 are 
#> 3     1 red 
#> 4     2 violets
#> 5     2 are 
#> # ℹ 8 more rows

现在让我们看看特朗普的推文。我们将查看第 3008 条推文,因为它将允许我们说明几个要点:

i <- 3008
campaign_tweets$texti] |> str_wrap(width = 65) |> [cat()
#> Great to be back in Iowa! #TBT with @JerryJrFalwell joining me in
#> Davenport- this past winter. #MAGA https://t.co/A5IF0QHnic
campaign_tweets[i,] |> 
 unnest_tokens(word, text) |>
 pull(word) 
#>  [1] "great"          "to"             "be"             "back" 
#>  [5] "in"             "iowa"           "tbt"            "with" 
#>  [9] "jerryjrfalwell" "joining"        "me"             "in" 
#> [13] "davenport"      "this"           "past"           "winter" 
#> [17] "maga"           "https"          "t.co"           "a5if0qhnic"

请注意,该函数试图将 token 转换为单词。一个小的调整是删除图片链接:

links_to_pics <- "https://t.co/[A-Za-z\\d]+|&amp;"
campaign_tweets[i,] |> 
 mutate(text = str_remove_all(text, links_to_pics))  |>
 unnest_tokens(word, text) |>
 pull(word)
#>  [1] "great"          "to"             "be"             "back" 
#>  [5] "in"             "iowa"           "tbt"            "with" 
#>  [9] "jerryjrfalwell" "joining"        "me"             "in" 
#> [13] "davenport"      "this"           "past"           "winter" 
#> [17] "maga"

现在我们已经准备好从我们所有的推文中提取单词。

tweet_words <- campaign_tweets |> 
 mutate(text = str_remove_all(text, links_to_pics))  |>
 unnest_tokens(word, text)

我们现在可以回答像“最常见的单词是什么?”这样的问题:

tweet_words |> 
 count(word) |>
 arrange(desc(n))
#> # A tibble: 6,264 × 2
#>   word      n
#>   <chr> <int>
#> 1 the    2330
#> 2 to     1413
#> 3 and    1245
#> 4 in     1190
#> 5 i      1151
#> # ℹ 6,259 more rows

这些是顶级单词,它们并不令人惊讶。在文本分析中,tidytext包有一个常用单词的数据库,称为停用词

head(stop_words)
#> # A tibble: 6 × 2
#>   word  lexicon
#>   <chr> <chr> 
#> 1 a     SMART 
#> 2 a's   SMART 
#> 3 able  SMART 
#> 4 about SMART 
#> 5 above SMART 
#> # ℹ 1 more row

如果我们使用filter(!word %in% stop_words$word)过滤掉表示停用词的行:

tweet_words <- campaign_tweets |> 
 mutate(text = str_remove_all(text, links_to_pics))  |>
 unnest_tokens(word, text) |>
 filter(!word %in% stop_words$word ) 

我们最终得到了一组更有信息量的前 10 位推文单词:

tweet_words |> 
 count(word) |>
 slice_max(n, n = 10) |>
 arrange(desc(n))
#> # A tibble: 10 × 2
#>   word                      n
#>   <chr>                 <int>
#> 1 trump2016               415
#> 2 hillary                 407
#> 3 people                  304
#> 4 makeamericagreatagain   298
#> 5 america                 255
#> # ℹ 5 more rows

对结果单词的一些探索(此处未显示)揭示了我们的 token 中存在一些不希望的特性。首先,我们的一些 token 只是数字(例如年份)。我们希望删除这些,我们可以使用正则表达式^\d+$找到它们。其次,一些 token 来自引号,并且以'开头。我们希望在单词的开头删除',所以我们将使用str_replace。我们将这两行代码添加到上面的代码中,以生成我们的最终表格:

tweet_words <- campaign_tweets |> 
 mutate(text = str_remove_all(text, links_to_pics))  |>
 unnest_tokens(word, text) |>
 filter(!word %in% stop_words$word &
 !str_detect(word, "^\\d+$")) |>
 mutate(word = str_replace(word, "^'", ""))

现在我们已经将所有单词以及它们来自哪个设备的信息放入表格中,我们可以开始探索在比较 Android 和 iPhone 时哪些单词更常见。

对于每个单词,我们想知道它更有可能来自 Android 推文还是 iPhone 推文。因此,我们分别计算每个单词在 Android 和 iPhone 推文中的频率,然后推导出这些比例的比率(Android 比例除以 iPhone 比例)。由于一些单词不太常见,我们应用了第 9.7 节中描述的连续性校正:

android_vs_iphone <- tweet_words |>
 count(word, source) |>
 pivot_wider(names_from = "source", values_from = "n", values_fill = 0) |>
 mutate(p_a = (Android + 0.5)/(sum(Android) + 0.5), 
 p_i = (iPhone + 0.5)/(sum(iPhone) + 0.5), 
 ratio = p_a / p_i)

对于总出现至少 100 次的单词,以下是 Android 的最高百分比差异

android_vs_iphone |> filter(Android + iPhone >= 100) |> arrange(desc(ratio))
#> # A tibble: 30 × 6
#>   word        Android iPhone     p_a     p_i ratio
#>   <chr>         <int>  <int>   <dbl>   <dbl> <dbl>
#> 1 bad             104     26 0.00648 0.00191  3.39
#> 2 crooked         156     49 0.00971 0.00357  2.72
#> 3 cnn             116     37 0.00723 0.00271  2.67
#> 4 ted              86     28 0.00537 0.00206  2.61
#> 5 interviewed      76     25 0.00475 0.00184  2.58
#> # ℹ 25 more rows

以及 iPhone 的前几名**:

android_vs_iphone |> filter(Android + iPhone >= 100) |>  arrange(ratio)
#> # A tibble: 30 × 6
#>   word                  Android iPhone       p_a     p_i   ratio
#>   <chr>                   <int>  <int>     <dbl>   <dbl>   <dbl>
#> 1 makeamericagreatagain       0    298 0.0000310 0.0216  0.00144
#> 2 trump2016                   3    412 0.000217  0.0298  0.00729
#> 3 join                        1    157 0.0000930 0.0114  0.00818
#> 4 tomorrow                   24    101 0.00152   0.00733 0.207 
#> 5 vote                       46     67 0.00288   0.00487 0.592 
#> # ℹ 25 more rows

我们已经看到,在一种设备上比另一种设备上更多地被推特的单词类型中存在某种模式。然而,我们感兴趣的并不是具体的单词,而是语气。Vaziri 的断言是,Android 的推文更加夸张。那么我们如何用数据来检查这一点呢?夸张**是一种难以从单词中提取的情感,因为它依赖于对短语的解释。然而,单词可以与更基本的情感相关联,如愤怒、恐惧、喜悦和惊讶。在下一节中,我们将展示基本的情感分析。

17.3 情感分析

在情感分析中,我们将一个词分配给一个或多个“情感”**。尽管这种方法可能会错过依赖于上下文情感,如讽刺,但在处理大量单词时,总结可以提供洞察。

情感分析的第一步是为每个词分配一个情感。正如我们所展示的,tidytext包包含几个映射或词典。textdata**包包含这些词典中的几个。

bing词典将单词分为正面负面情感。我们可以使用tidytext函数get_sentiments查看这一点**:

get_sentiments("bing")

AFINN词典将单词分配给介于-5 和 5 之间的分数,其中-5 是最负面的,5 是最正面的**。请注意,此词典在第一次调用get_sentiment函数时需要下载:

get_sentiments("afinn")

nrc词典提供几种不同的情感。请注意,这也需要在第一次使用时下载**。

get_sentiments](https://juliasilge.github.io/tidytext/reference/get_sentiments.html)("nrc") |> [count(sentiment)
#> # A tibble: 10 × 2
#>   sentiment        n
#>   <chr>        <int>
#> 1 anger         1245
#> 2 anticipation   837
#> 3 disgust       1056
#> 4 fear          1474
#> 5 joy            687
#> # ℹ 5 more rows

对于我们的分析,我们感兴趣的是探索每条推文的不同情感,因此我们将使用nrc词典**:

nrc <- get_sentiments](https://juliasilge.github.io/tidytext/reference/get_sentiments.html)("nrc") |> [select(word, sentiment)

我们可以使用inner_join将单词和情感结合起来,这将仅保留与情感相关的单词。以下是来自推文的 5 个随机单词**:

tweet_words |> inner_join(nrc, by = "word", relationship = "many-to-many") |> 
 select(source, word, sentiment) |> 
 sample_n(5)
#> # A tibble: 5 × 3
#>   source  word     sentiment 
#>   <chr>   <chr>    <chr> 
#> 1 Android enjoy    joy 
#> 2 iPhone  terrific sadness 
#> 3 iPhone  tactics  trust 
#> 4 Android clue     anticipation
#> 5 iPhone  change   fear

relationship = "many-to-many"被添加以解决left_join检测到的“意外的多对多关系”警告。然而,在这个上下文中,这种行为实际上是预期的,因为许多单词与多个情感相关联。现在我们准备通过比较来自每个设备的推文的情感来执行定量分析。在这里,我们可以进行逐条推文的分析,为每条推文分配一个情感。然而,这将具有挑战性,因为每条推文都将与几个情感相关联,每个词在词典中都有一个。为了说明目的,我们将执行一个更简单的分析:我们将计算并比较每个设备中出现的每个情感的出现频率**。

sentiment_counts <- tweet_words |>
 left_join(nrc, by = "word", relationship = "many-to-many") |>
 count(source, sentiment) |>
 pivot_wider(names_from = "source", values_from = "n") |>
 mutate(sentiment = replace_na(sentiment, replace = "none"))

对于每个情感,我们分别计算其在 Android 和 iPhone 的总回复中的比例,并推导出这些比例的比率(Android 比例除以 iPhone 比例)**。

sentiment_counts <- sentiment_counts |>
 mutate(p_a = Android/sum(Android), p_i = iPhone/sum(iPhone), ratio = p_a/p_i) |> 
 arrange(desc(ratio))
sentiment_counts
#> # A tibble: 11 × 6
#>   sentiment Android iPhone    p_a    p_i ratio
#>   <chr>       <int>  <int>  <dbl>  <dbl> <dbl>
#> 1 disgust       639    314 0.0290 0.0178  1.63
#> 2 anger         962    527 0.0437 0.0298  1.47
#> 3 negative     1657    931 0.0753 0.0527  1.43
#> 4 sadness       901    514 0.0409 0.0291  1.41
#> 5 fear          799    486 0.0363 0.0275  1.32
#> # ℹ 6 more rows

所以我们确实看到了一些差异,顺序很有趣:最大的三个情感是厌恶、愤怒和负面!

如果我们感兴趣的是探索哪些特定单词导致了这些差异,我们可以参考我们的android_vs_iphone对象。对于每个情感,我们展示了 10 个最大的比率,无论是正向还是负向。我们排除了总出现次数少于 10 次的单词。

这只是 tidytext 可以执行的多项分析中的一个简单示例。要了解更多信息,我们再次推荐阅读《tidytext 挖掘》一书⁵。

17.4 练习

Project Gutenberg 是一个公共领域书籍的数字档案。R 包gutenbergr简化了这些文本导入 R 的过程。

您可以通过输入以下命令进行安装和加载:

install.packages("gutenbergr")
library(gutenbergr)

您可以看到如下所示的可用书籍:

gutenberg_metadata
  1. 使用str_detect查找小说《傲慢与偏见》的 ID。

  2. 我们注意到有几个版本。gutenberg_works()函数过滤这个表格以移除重复项并仅包含英语作品。阅读帮助文件并使用此函数查找《傲慢与偏见》的 ID。

  3. 使用gutenberg_download函数下载《傲慢与偏见》的文本。将其保存到名为book的对象中。

  4. 使用tidytext包创建一个包含文本中所有单词的整洁表格。将表格保存到名为words的对象中

  5. 我们将在稍后绘制情感与书中位置的关系图。为此,向表格中添加一个包含单词数量的列将很有用。

  6. words对象中移除停用词和数字。提示:使用anti_join

  7. 现在使用AFINN词典为每个单词分配情感值。

  8. 绘制情感得分与书中位置的关系图,并添加一个平滑器。

  9. 假设每页有 300 个单词。将位置转换为页码,然后计算每页的平均情感。按页绘制平均得分。添加一个看起来穿过数据的平滑器。


  1. twitter.com/tvaziri/status/762005541388378112/photo/1↩︎

  2. varianceexplained.org/r/trump-tweets/↩︎

  3. www.tidytextmining.com/↩︎

  4. www.thetrumparchive.com/↩︎

  5. www.tidytextmining.com/↩︎

生产力工具

原文:rafalab.dfci.harvard.edu/dsbook-part-1/productivity/intro-productivity.html

通常来说,我们不推荐使用点选式方法进行数据分析。相反,我们推荐使用脚本语言,例如 R,因为它们更加灵活,并且极大地促进了可重复性。同样,我们也反对使用点选式方法来组织文件和文档准备。在本书的这一部分,我们将展示替代方法。具体来说,我们将学习如何使用免费工具,虽然一开始可能感觉笨拙且不直观,但最终会使你成为一个更加高效和富有成效的数据科学家。

激励我们在这里学习的三项基本原则是:1)在组织文件系统时要系统化,2)尽可能自动化,3)最小化鼠标的使用。随着你在编码方面变得更加熟练,你会发现:1)你希望最小化花费在记住文件名或放置位置上的时间,2)如果你发现自己反复执行相同的任务,可能有一种方法可以自动化,3)任何时候你的手指离开键盘,都会导致生产力下降。

数据分析项目并不总是由数据集和脚本组成。典型的数据分析挑战可能涉及多个部分,每个部分都包含多个数据文件,包括包含我们用于数据分析的脚本的文件。保持所有这些内容井然有序可能是一项挑战。我们将学习如何使用 Unix shell 作为管理计算机系统上文件和目录的工具。使用 Unix 将允许你在创建文件夹、从一个目录移动到另一个目录、重命名、删除或移动文件时使用键盘,而不是鼠标。我们还提供了一些具体的建议,说明如何保持文件系统井然有序。

数据分析过程也是迭代和自适应的。因此,我们不断地编辑我们的脚本和报告。在本章中,我们向您介绍版本控制系统 Git,这是一个跟踪这些更改的强大工具。我们还向您介绍 GitHub¹,这是一个允许你托管和分享代码的服务。我们将演示如何使用这项服务来促进协作。使用 GitHub 的另一个积极好处是,你可以轻松地向潜在雇主展示你的工作。

最后,我们将学习如何使用 R markdown 编写报告,这允许你将文本和代码合并到单个文档中。我们将演示如何使用 knitr 包,通过同时运行分析和生成报告,来编写可重复和美观的报告。

我们将使用强大的集成桌面环境 RStudio² 将所有这些内容整合在一起。在整个章节中,我们将构建一个关于美国枪支谋杀的示例。最终项目,包括多个文件和文件夹,可以在 GitHub 仓库 rairizarry/murders³ 中查看。


  1. github.com↩︎

  2. www.rstudio.com/↩︎

  3. github.com/rairizarry/murders↩︎

18  使用 Unix 组织

原文:rafalab.dfci.harvard.edu/dsbook-part-1/productivity/unix.html

  1. 生产力工具

  2. 18  使用 Unix 组织

Unix 是数据科学中首选的操作系统。我们将通过一个示例向您介绍 Unix 的思维方式:如何组织数据分析项目。我们将学习一些最常用的命令。然而,我们不会深入探讨高级细节。我们强烈鼓励您进一步学习,尤其是在您发现自己经常使用鼠标或执行重复性任务时。在这些情况下,Unix 中可能有一种更有效的方法来完成它。以下是一些基本的课程,以帮助您入门:

  • “学习命令行”通过 codecademy¹

  • “LinuxFoundationX: Linux 入门”通过 edX²

  • “Unix 工作台”通过 coursera³

此外,还有很多参考书籍⁴。Bite Size Linux⁵ 和 Bite Size Command Line⁶ 是两个特别清晰、简洁且完整的示例。

在搜索 Unix 资源时,请记住,我们在这里将要学习的其他术语还有 Linuxshell命令行。基本上,我们学习的是一系列命令和一种思维方式,它有助于在不使用鼠标的情况下组织文件。

为了提供动力,我们将开始使用 Unix 工具和 RStudio 构建目录。

18.1 命名约定

在您开始使用 Unix 组织项目之前,您想要选择一个命名约定,这将用于系统地命名您的文件和目录。这将帮助您找到文件并了解它们的内容。

通常,你希望以与文件内容相关的方式命名文件,并指定它们与其他文件的关系。史密森尼数据管理最佳实践⁷ 提出了“文件命名和组织五原则”,它们是:

  • 使用一个独特、易于人类阅读的名称,并给出内容的指示。
  • 遵循一致的、对机器友好的模式。
  • 将文件组织到遵循一致模式的目录中(如有必要)。
  • 避免在文件和目录名称中重复语义元素。
  • 文件扩展名与文件格式匹配(不要更改扩展名!)

对于具体的建议,我们强烈建议您遵循 Tidyverse 风格指南⁸。

18.2 终端

我们将不再通过点击、拖动和放下来组织我们的文件和文件夹,而是将 Unix 命令输入到终端中。我们这样做的方式类似于我们在 R 控制台中输入命令,但不同的是,我们将不会生成图表和统计摘要,而是将组织系统中的文件。

终端集成在 Mac 和 Linux 系统中,但 Windows 用户将必须安装一个 模拟器。一旦打开终端,您就可以开始输入命令。您应该看到一个闪烁的光标,它将显示您输入的内容的位置。这个位置被称为命令行。一旦您输入一些内容并在 Windows 上按回车或在 Mac 上按 return,Unix 将尝试执行此命令。如果您想尝试一个示例,请输入以下命令:

echo "hello world"

命令 echo 与 R 中的 cat 类似。执行此行应打印出 hello world,然后返回到命令行。

注意,您不能在终端中使用鼠标移动。您必须使用键盘。要返回之前输入的命令,您可以按上箭头键。

注意,上面我们以与之前展示 R 命令相同的方式展示了 Unix 命令的代码块。我们将确保在命令针对 R 和 Unix 时进行区分。

18.3 文件系统

我们将您电脑上的所有文件、文件夹和程序统称为文件系统。请记住,文件夹和程序也是文件,但这是一种我们很少考虑且在此书中忽略的技术细节。我们现在将专注于文件和文件夹,并在稍后的章节中讨论程序,或称为可执行文件

18.3.1 目录和子目录

要成为 Unix 用户,您需要掌握的第一个概念是您的文件系统是如何组织的。您应该将其视为一系列嵌套的文件夹,每个文件夹都包含文件、文件夹和可执行文件。

这里是我们所描述的结构的一个视觉表示:

图片

在 Unix 中,我们将文件夹称为目录。位于其他目录内的目录通常被称为子目录。例如,在上面的图中,目录 docs 有两个子目录:reportsresumes,而 docshome 的子目录。

18.3.2 家目录

家*目录是您所有东西的存放地,与随电脑一起提供的系统文件不同,这些文件存放在其他地方。在上面的图中,名为 home 的目录代表您的家目录,但这个名字很少被使用。在您的系统中,您的家目录的名称可能和该系统上的用户名相同。以下是在 Windows 和 Mac 上显示家目录的示例,在这种情况下,名为 rafa

图片

图片

现在,回顾一下显示文件系统的图。假设你正在使用一个点选系统,并且你想删除文件cv.tex。想象一下,在你的屏幕上你可以看到home目录。为了删除这个文件,你需要双击home目录,然后是docs,然后是resumes,最后将cv.tex拖到垃圾桶。在这里,你正在体验系统的层次结构:cv.texresumes目录中的一个文件,而resumes目录是docs目录的子目录,docs目录又是home目录的子目录。

现在假设你屏幕上看不到你的主目录。你需要以某种方式让它出现在屏幕上。一种方法是从所谓的root目录导航到你的主目录。任何文件系统都会有一个所谓的root目录,它是包含所有目录的目录。上图所示的home目录通常位于 root 目录的二级或更多级。在 Windows 上,你将有一个类似的结构:

在 Mac 上,情况将如下所示:

在 Windows 上,典型的 R 安装将使你的Documents目录成为 R 中的主目录。这可能与 Git Bash 中的主目录不同。通常,当我们讨论主目录时,我们指的是 Unix 主目录,在本书中对于 Windows 来说,是 Git Bash 的 Unix 目录*。

18.3.3 工作目录

“当前位置”的概念是点选体验的一部分:在任何给定时刻,我们都在一个文件夹中,并看到该文件夹的内容。当你搜索文件时,就像我们上面做的那样,你正在体验“当前位置”的概念:一旦你双击一个目录,你改变了位置,现在你就在那个文件夹中,而不是你之前所在的文件夹中*。

在 Unix 中,我们没有相同的视觉提示,但“当前位置”的概念是必不可少的。我们称这个为工作目录。你打开的每个终端窗口都有一个与之关联的工作目录。

我们如何知道我们的工作目录是什么?为了回答这个问题,我们学习我们的下一个 Unix 命令:pwd,它代表打印工作目录。这个命令返回工作目录。

打开一个终端并输入:

pwd

我们不展示运行此命令的结果,因为它在你的系统上与其他系统相比可能会有很大差异。如果你打开一个终端并输入pwd作为你的第一个命令,你应该在 Mac 上看到类似/Users/yourusername的内容,或者在 Windows 上看到类似/c/Users/yourusername的内容。pwd命令返回的字符串代表你的工作目录。当我们第一次打开终端时,它将启动在我们的主目录中,因此在这种情况下,工作目录是主目录。

注意到上述字符串中的正斜杠 / 用于分隔目录。例如,位置 /c/Users/rafa 表示我们的工作目录名为 rafa,它是 Users 的子目录,而 Users 又是 c 的子目录,c 是根目录的子目录。因此,根目录仅用一个正斜杠 / 表示:/.

18.3.4 路径

我们称 pwd 返回的字符串为工作目录的 完整路径。这个名字来源于这个字符串表示了从根目录到达指定目录所需的 路径。每个目录都有一个完整路径。稍后,我们将介绍 相对路径,它告诉我们如何从工作目录到达某个目录。

在 Unix 中,我们使用缩写 ~ 作为你的主目录的昵称。例如,如果 docs 是你主目录中的一个目录,那么 docs 的完整路径可以写成这样 ~/docs

大多数终端会在命令行上直接显示工作目录的路径。如果你使用默认设置并在 Mac 上打开终端,你将看到命令行上显示类似 computername:~ username 的内容,其中 ~ 代表你的工作目录,在这个例子中是主目录 ~。Git Bash 终端也是如此,你将看到类似 username@computername MINGW64 ~ 的内容,工作目录位于末尾。当我们更改目录时,无论是在 Mac 还是 Windows 上,我们都会看到这种变化。

18.4 Unix 命令

现在,我们将学习一系列 Unix 命令,这将使我们能够为数据科学项目准备目录。我们还提供了如果你在终端输入将返回错误的命令示例。这是因为我们假设的是早期图中的文件系统。你的文件系统是不同的。在下一节中,我们将提供你可以输入的示例。

18.4.1 ls:列出目录内容

在点对点系统中,我们知道目录中有什么,因为我们能看到它。在终端中,我们看不到图标。相反,我们使用 ls 命令来列出目录内容。

要查看你的主目录内容,打开终端并输入:

ls

我们很快将看到更多示例。

18.4.2 mkdirrmdir:创建和删除目录

当我们准备数据科学项目时,我们需要创建目录。在 Unix 中,我们可以使用 mkdir 命令来完成此操作,该命令代表 创建目录

由于你很快将参与多个项目,我们强烈建议在你的主目录中创建一个名为 projects 的目录。

你可以在你的系统上尝试这个特定的示例。打开终端并输入:

mkdir projects

如果你正确执行,将不会有任何操作:没有消息就是好消息。如果目录已存在,你将收到错误消息,现有目录将保持不变。

要确认你已创建目录,你可以列出当前工作目录的内容:

ls

您应该能看到我们刚刚创建的目录被列出。也许您还可以看到许多已经在您计算机上的其他目录。

为了说明目的,让我们再创建几个目录。您可以使用这种方式列出多个目录名:

mkdir docs teaching

您可以检查是否创建了三个目录:

ls

如果您犯了一个错误并需要删除目录,您可以使用 rmdir 命令来删除它。

mkdir junk
rmdir junk

这将删除目录,只要它是空的。如果它不为空,您将收到错误消息,并且目录将保持不变。要删除非空目录,我们将在稍后学习 rm 命令。

18.4.3 cd:通过更改目录在文件系统中导航

接下来,我们想在已创建的目录内创建目录。我们还想避免通过指向和点击在文件系统中导航。我们将在 Unix 中解释如何使用命令行来完成此操作。

假设我们打开一个终端,并且我们的工作目录是主目录。我们想要将工作目录更改为 projects。我们使用 cd 命令来完成此操作,该命令代表 更改目录

cd projects

要检查工作目录是否已更改,我们可以使用我们之前学过的命令来查看我们的位置:

pwd

我们的工作目录现在应该是 ~/projects。请注意,在您的计算机上,主目录 ~ 将展开为类似 /c/Users/yourusername 的路径。

在 Unix 中,您可以通过按 Tab 键来自动完成。这意味着我们可以输入 cd d 然后按 Tab 键。Unix 会自动完成,如果 docs 是以 d 开头的唯一目录/文件,或者显示选项。试试看!在不使用自动完成的情况下使用 Unix 可能会让人难以忍受。*当使用 cd 时,我们可以输入完整的路径,它将以 /~ 开头,或者输入一个 相对路径。在上面的例子中,我们输入了 cd projects,我们使用了一个相对路径。如果您输入的路径不以 /~ 开头,Unix 会假设您输入的是一个相对路径,这意味着它会根据您当前的工作目录查找目录。所以类似以下的内容会给出错误信息:

cd Users

因为在您的工作目录中没有 Users 目录。

现在假设我们想要回到 projects 是子目录的目录,即所谓的 父目录。我们可以使用父目录的完整路径,但 Unix 提供了一个快捷方式:工作目录的父目录用两个点 .. 表示,所以为了返回,我们只需输入:

cd ..

现在,您应该已经回到了主目录,您可以使用 pwd 命令来确认。

由于我们可以使用带有 cd 的完整路径,以下命令:

cd ~

无论我们在文件系统中的位置如何,都会带我们回到主目录。

工作目录还有一个别名,即单个 .,所以如果您输入

cd .

您将不会移动。尽管这种特定用途的.并不实用,但这个昵称有时确实很有用。原因与这一节无关,但您仍然应该了解这个事实。

总结来说,我们了解到在使用cd时,我们要么保持原位,要么使用所需的目录名移动到新目录,或者使用..移动到父目录。

在输入目录名时,我们可以使用正斜杠连接目录。所以如果我们想无论在文件系统的哪个位置都能到达projects目录,我们可以输入:

cd ~/projects

这与写出整个路径是等价的。例如,在 Windows 中,我们会写类似

cd /c/Users/yourusername/projects

最后两个命令是等价的,在两种情况下我们都在输入完整路径。

我们也可以连接目录名来表示相对路径。例如,如果我们想回到工作目录的父目录的父目录,我们可以输入:

cd ../..

这里有一些与cd命令相关的最终提示。首先,您可以通过输入以下内容返回到您刚刚离开的目录:

cd -

这在您输入一个非常长的路径然后意识到您想回到之前的位置,而且那个位置也有一个非常长的路径时非常有用。

第二,如果您只输入:

cd

您将被返回到您的家目录。

18.4.4 示例

让我们探索一些使用cd命令的例子。为了帮助可视化,我们将垂直显示我们的文件系统的图形表示:

假设我们的工作目录是~/projects,我们想移动到project-1中的figs目录。

在这种情况下,使用相对路径会更方便:

cd project-1/figs

现在假设我们的工作目录是~/projects,我们想移动到docs中的reports目录,我们该如何做?

一种方法是通过相对路径:

cd ../docs/reports

另一种方法是使用完整路径:

cd ~/docs/reports

如果您在自己的系统上尝试这个操作,请记住使用自动完成功能。

让我们再考察一个例子。假设我们位于~/projects/project-1/figs,并想切换到~/projects/project-2。同样有两种方法。

使用相对路径:

cd ../../project-2

和使用完整路径:

cd ~/projects/project-2

18.5 更多 Unix 命令

18.5.1 mv: 移动文件

在点按和点击系统中,我们通过拖放将文件从一个目录移动到另一个目录。在 Unix 中,我们使用mv命令。

mv命令在移动导致覆盖文件时不会询问“您确定吗?”* *现在您已经知道如何使用完整和相对路径,使用mv命令相对简单。一般形式如下:

mv path-to-file path-to-destination-directory

例如,如果我们想把文件cv.texresumes移动到reports,您可以使用完整的路径,如下所示:

mv ~/docs/resumes/cv.tex ~/docs/reports/

您也可以使用相对路径。您可以这样做:

cd ~/docs/resumes
mv cv.tex ../reports/

或者这样:

cd ~/docs/reports/
mv ../resumes/cv.tex ./

注意到最后一个例子中,我们使用了工作目录快捷方式.来提供一个相对路径作为目标目录。

我们还可以使用 mv 来更改文件名。为此,第二个参数不再是目标目录,它还包括一个文件名。例如,要将文件名从 cv.tex 更改为 resume.tex,我们只需输入:

cd ~/docs/resumes
mv cv.tex resume.tex

我们还可以将移动和重命名结合起来。例如:

cd ~/docs/resumes
mv cv.tex ../reports/resume.tex

我们还可以移动整个目录。要将 resumes 目录移动到 reports,我们按照以下步骤操作:

mv ~/docs/resumes ~/docs/reports/

重要的一点是添加最后的 /,以明确你不想重命名 resumes 目录为 reports,而是将其移动到 reports 目录中。

18.5.2 cp:复制文件

cp 命令的行为类似于 mv,除了不移动文件,而是复制文件,这意味着原始文件保持不变。

所以在上述所有的 mv 示例中,你可以将 mv 替换为 cp,它们将执行复制操作而不是移动操作,只有一个例外:在不了解参数的情况下,我们无法复制整个目录,这一点我们稍后会学习。

18.5.3 rm:删除文件

在点按式系统中,我们通过将文件拖放到垃圾桶或使用鼠标的特殊点击来删除文件。在 Unix 中,我们使用 rm 命令。

与将文件拖入垃圾桶不同,rm 是永久删除。请务必小心!* *它的一般工作方式如下:

rm filename

你实际上也可以这样列出文件:

rm filename-1 filename-2 filename-3

你可以使用完整路径或相对路径。要删除非空目录,你将需要学习参数,这一点我们稍后会学习。

18.5.4 less:查看文件

通常你想要快速查看文件的内容。如果这个文件是文本文件,最快的方式是使用 less 命令。要查看 cv.tex 文件,你可以这样做:

cd ~/docs/resumes
less cv.tex 

要退出查看器,你输入 q。如果文件很长,你可以使用箭头键上下移动。在 less 中,你可以使用许多其他键盘命令,例如搜索或跳转页面。

如果你想知道为什么命令叫做 less,那是因为原始命令叫做 more,就像“显示这个文件的更多内容”。第二个版本被叫做 less 是因为“少即是多”的说法。

18.6 案例研究:为项目做准备

我们现在已经准备好为项目创建一个目录。我们将以美国谋杀案项目⁹ 为例。

你应该首先创建一个目录,用于存放你所有的项目。我们建议在主目录中创建一个名为 projects 的目录。为此,你需要输入:

cd ~
mkdir projects

我们的项目与枪支暴力谋杀案相关,因此我们将我们的项目目录命名为 murders。它将成为我们项目目录的子目录。在 murders 目录中,我们将创建两个子目录来存储原始数据和中间数据。我们将分别称之为 datarda

打开一个终端并确保你处于主目录:

cd ~

现在运行以下命令来创建我们想要的目录结构。最后,我们使用 lspwd 来确认我们已在正确的当前工作目录中生成了正确的目录:

cd projects
mkdir murders
cd murders
mkdir data rdas 
ls
pwd

注意,我们的 murders 数据集的完整路径是 ~/projects/murders

因此,如果我们打开一个新的终端并想要进入那个目录,我们输入:

cd projects/murders

在 第 20.3 节 中,我们将描述如何使用 RStudio 组织数据分析项目,一旦创建了这些目录。

18.7 高级 Unix

大多数 Unix 实现都包含大量强大的工具和实用程序。我们在这里只学习了最基本的内容。我们建议您将 Unix 作为您的主要文件管理工具。熟悉它需要时间,但当你努力适应时,你会发现自己在通过在互联网上查找解决方案的过程中学习。在本节中,我们简要地覆盖了一些更高级的主题。本节的主要目的是让您了解有哪些可用选项,而不是详细解释所有内容。

18.7.1 参数

大多数 Unix 命令都可以带参数运行。参数通常通过使用一个短横线 - 或两个短横线 --(取决于命令)后跟一个字母或单词来定义。rm 命令后面的 -r 就是一个参数的例子。r 代表递归,结果是文件和目录将被递归删除,这意味着如果你输入:

rm -r directory-name

所有文件、子目录、子目录中的文件、子目录中的子目录等都将被删除。这相当于将文件夹放入垃圾桶,但你无法恢复它。一旦删除,它将永久删除。通常,当你删除目录时,你会遇到受保护的文件。在这种情况下,你可以使用代表 force 的参数 -f

你也可以组合参数。例如,要删除目录而不考虑受保护的文件,你输入:

rm -rf directory-name

请记住,一旦删除,就无法恢复,所以请非常小心地使用此命令。

常常需要带参数的命令是 ls。以下是一些示例:

ls -a 

a 代表所有。这个参数使得 ls 命令显示目录中的所有文件,包括隐藏文件。在 Unix 中,以 . 开头的文件都是隐藏的。许多应用程序创建隐藏目录来存储重要信息,而不会干扰你的工作。例如 git(我们将在第十九章中深入探讨 Chapter 19)。一旦你使用 git init 将目录初始化为 git 目录,就会创建一个名为 .git 的隐藏目录。另一个隐藏文件是 .gitignore 文件。

使用参数的另一个例子是:

ls -l 

l 代表长格式,结果是会显示更多关于文件的信息。

有时按时间顺序查看文件很有用。为此,我们使用:

ls -t 

要反转文件显示的顺序,可以使用:

ls -r 

我们可以将所有这些参数组合起来,以逆时间顺序显示所有文件的信息:

ls -lart 

每个命令都有不同的参数集。在下一节中,我们将学习如何找出每个参数的作用。

18.7.2 获取帮助

如你所注意到的,Unix 使用了极端的缩写。这使得它非常高效,但很难猜测如何调用命令。为了弥补这一弱点,Unix 包含了完整的帮助文件或 man 页面(man 是 manual 的缩写)。在大多数系统中,你可以输入 man 后跟命令名称来获取帮助。所以对于 ls,我们会输入以下内容:

man ls

这个命令在某些 Unix 的紧凑实现中不可用,例如 Git Bash。在 Git Bash 中获取帮助的替代方法是输入命令后跟 --help。对于 ls 命令,操作如下:

ls --help

18.7.3 管道

帮助页面通常很长,如果你输入上述命令查看帮助,它会滚动到最后一页。如果我们能将帮助保存到文件中,然后使用 less 查看,那就很有用了。管道符号 | 就有类似的功能。它将一个命令的结果传递到管道后面的命令。这类似于我们在 R 中使用的管道 |>。因此,为了获取更多帮助,我们可以输入以下内容:

man ls | less

或者在 Git Bash 中:

ls --help | less 

当列出具有许多文件的目录时,这也很有用。我们可以输入以下内容:

ls -lart | less 

18.7.4 通配符

Unix 最强大的功能之一是 通配符。假设我们想要删除在项目故障排除过程中产生的所有临时 html 文件。想象一下有几十个文件。逐个删除它们会很痛苦。在 Unix 中,我们实际上可以编写一个表达式,表示所有以 .html 结尾的文件。为此,我们输入通配符:*。正如本书数据处理部分所讨论的,这个字符表示任何数量的任何组合的字符。具体来说,要列出所有 html 文件,我们会输入以下内容:

ls *.html

要删除目录中的所有 html 文件,我们会输入以下内容:

rm *.html

另一个有用的通配符是 ? 符号。这意味着任何单个字符。所以如果我们想要删除的所有文件都符合 file-001.html 的形式,数字从 1 到 999,我们可以输入以下内容:

rm file-???.html

这将只会删除具有该格式的文件。

我们可以将通配符组合起来。例如,要删除所有形式为 file-001file-999 的文件,无论后缀如何,我们可以输入以下内容:

rm file-???.* 

rm* 通配符结合使用可能很危险。这些命令的组合可能会在未询问“你确定吗?”的情况下删除你的整个文件系统。所以在使用这个通配符与 rm 命令之前,请确保你理解它是如何工作的。我们建议在使用这个通配符之前,首先使用 ls 命令检查哪些文件会匹配通配符。

18.7.5 环境变量

Unix 有一些影响你的命令行 环境 的设置。这些被称为环境变量。主目录就是其中之一。我们实际上可以更改其中的一些。在 Unix 中,变量通过在前面添加 $ 来与其他实体区分开来。主目录存储在 $HOME 中。

之前我们看到 echo 是 Unix 的打印命令。因此,我们可以通过输入以下内容来查看我们的主目录:

echo $HOME 

你可以通过输入以下内容来查看它们:

env

你可以更改其中的一些环境变量。但它们的名称在不同的 shell 中会有所不同。我们将在下一节中描述 shell。

18.7.6 Shell

本章中我们使用的大部分内容是所谓的 Unix shell 的一部分。实际上存在不同的 shell,但它们的区别几乎不明显。它们也很重要,尽管我们在这里没有涉及。你可以通过输入以下命令来查看你正在使用的 shell:

echo $SHELL

最常见的是 bash

一旦你知道了 shell,你就可以更改环境变量。在 Bash Shell 中,我们使用 export variable value 来做这件事。要更改路径,将在稍后更详细地描述,你会输入:

export PATH = /usr/bin/

不过,实际上不要运行这个命令!* *在每次终端启动之前,有一个程序可以编辑变量,这样每次调用终端时它们都会改变。这在不同实现中会有所不同,但如果你使用 bash,你可以创建一个名为 .bashrc.bash_profile.bash_login.profile 的文件。你可能已经有了这样一个文件。

18.7.7 可执行文件

在 Unix 中,所有程序都是文件。它们被称为可执行文件。所以 lsmvgit 都是文件。但这些程序文件在哪里?你可以使用 which 命令来找出:

which git
#> /usr/bin/git

那个目录可能充满了程序文件。通常,/usr/bin 目录会存放许多程序文件。如果你输入:

ls /usr/bin

在你的终端中,你会看到几个可执行文件。

通常还有其他目录会存放程序文件。例如,Mac 中的应用程序目录或 Windows 中的程序文件目录。

当你输入 ls 时,Unix 会知道运行一个存储在其他目录中的可执行程序。那么 Unix 是如何知道在哪里找到它的呢?这个信息包含在环境变量 $PATH 中。如果你输入:

echo $PATH

你会看到一个由 : 分隔的目录列表。/usr/bin 目录可能是列表中的第一个。

Unix 会按照这个顺序在这些目录中查找程序文件。虽然我们在这里不教授它,但你实际上可以自己创建可执行文件。然而,如果你将它放在你的工作目录中,而这个目录不在路径上,你只能通过输入完整路径来运行它。你可以通过输入完整路径来解决这个问题。所以如果你的命令叫做 my-ls,你可以输入:

./my-ls

一旦你掌握了 Unix 的基础知识,你应该考虑学习编写自己的可执行文件,因为它们可以帮助减轻重复性工作。

18.7.8 权限和文件类型

如果你输入:

ls -l

在开始时,你会看到一系列这样的符号 -rw-r--r--。这个字符串表示文件的类型:普通文件 -、目录 d 或可执行文件 x。这个字符串还表示文件的权限:它是可读的?可写的?可执行的?系统上的其他用户可以读取文件吗?可以编辑文件吗?如果文件是可执行的,其他用户可以执行吗?这比我们在这里讨论的内容更高级,但你可以在 Unix 参考书中学习到更多。

18.7.9 应该学习的命令

有许多命令我们没有在这本书中介绍,但我们想让您了解它们以及它们的功能。它们包括:

  • open/start - 在 Mac 上,open filename会尝试确定正确的文件名应用程序,并使用该应用程序打开它。这是一个非常有用的命令。在 Git Bash 中,您可以尝试start filename。尝试使用openstart打开一个RRmd文件:它应该使用 RStudio 打开它们。

  • nano - 打开一个简单的文本编辑器。

  • tar - 将目录中的文件和子目录存档到一个文件中。

  • ssh - 连接到另一台计算机。

  • find - 在您的系统中通过文件名查找文件。

  • grep - 在文件中搜索模式。

  • awk/sed - 这两个命令非常强大,允许您在文件中查找特定的字符串并更改它们。

  • ln - 创建符号链接。我们不推荐使用它,但您应该熟悉它。

18.7.10 R 中的文件操作

我们还可以在 R 中执行文件管理。要了解的关键函数可以通过查看?files¹⁰的帮助文件来查看。另一个有用的函数是unlink

虽然通常不推荐,但您可以使用system在 R 中运行 Unix 命令。


  1. www.codecademy.com/learn/learn-the-command-line↩︎

  2. www.edx.org/course/introduction-linux-linuxfoundationx-lfs101x-1↩︎

  3. www.coursera.org/learn/unix↩︎

  4. www.quora.com/Which-are-the-best-Unix-Linux-reference-books↩︎

  5. gumroad.com/l/bite-size-linux↩︎

  6. jvns.ca/blog/2018/08/05/new-zine--bite-size-command-line/↩︎

  7. library.si.edu/sites/default/files/tutorial/pdf/filenamingorganizing20180227.pdf↩︎

  8. style.tidyverse.org/↩︎

  9. github.com/rairizarry/murders↩︎

  10. rdrr.io/r/base/files.html↩︎

19  Git 和 GitHub

原文:rafalab.dfci.harvard.edu/dsbook-part-1/productivity/git.html

  1. 生产力工具

  2. 19  Git 和 GitHub

在这里,我们简要介绍了 Git 和 GitHub。我们只是触及了表面。要了解更多关于 Git 的信息,我们强烈推荐以下资源:

  • “在 Codecademy 上学习 Git & GitHub”¹

  • “Hello World” 练习 GitHub 指南²

如果你计划经常将 Git 和 GitHub 与 R 一起使用,我们强烈建议阅读《Happy Git and GitHub for the useR³》来了解我们这里没有涵盖的细节。

19.1 为什么使用 Git 和 GitHub?

使用 Git 和 GitHub 的三个主要原因是:

  1. 版本控制: Git 允许你跟踪代码中的更改,回滚到之前的文件版本,并同时在多个分支上工作。一旦更改确定,不同的分支可以合并。

  2. 协作: GitHub 为项目提供了一个中央存储解决方案,并允许你添加协作者。这些协作者可以做出更改,同时保持所有版本同步。此外,GitHub 上的“pull request”功能允许其他人对你的代码提出修改建议,你可以随后批准或拒绝这些修改。

  3. 共享: 除了其强大的版本控制和协作工具之外,Git 和 GitHub 还是一个易于与他人共享代码的平台。

我们在这里主要强调共享功能。要深入了解其其他功能,请参阅上面提供的资源。托管代码在 GitHub 上的一个主要优势是,你可以轻松地向潜在的雇主展示你的工作样本。鉴于许多公司和组织使用像 Git 这样的版本控制系统进行项目协作,他们可能会认为你掌握这个工具是值得赞扬的。

19.2 Git 概述

为了有效地使用 Git 进行版本控制和协作,我们需要理解“仓库”的概念,通常简称为“repo”。仓库是一个数字存储空间,你可以在这里保存、编辑和跟踪特定项目的文件版本。把它想象成一个项目文件夹加上详细的日志簿。它包含与项目相关的所有文件和目录,以及每次更改的记录、更改者以及更改时间。这允许多个人在不覆盖他人贡献的情况下共同协作。如果需要,你也可以轻松地回滚到之前的版本。

注意,Git 允许在仓库内创建不同的 分支。这允许并行处理文件,这在涉及重大更改的测试想法并在稳定版本中合并之前特别有用。在这本书中,我们只提供了只有一个分支的示例。要了解更多关于如何定义和使用多个分支的信息,请参阅上面提供的资源。

一种常见的做法是在 GitHub 仓库上托管中央 主分支,所有协作者都可以远程访问。主分支被认为是稳定的官方版本。每个协作者也在他们的电脑上维护一个 本地仓库,允许他们在提交到主仓库之前编辑和测试更改。

我们将通过以下步骤来探索 Git 的工作原理:

  1. 首先,您将学习如何在所谓的 工作目录 中对您的电脑进行更改。

  2. 当您对更改满意后,您会将它们移动到 暂存区。将其视为准备或打包您的更改。

  3. 从那里,您将把这些更改保存到您的 本地仓库,这就像您在电脑上的个人保存点,并将在日志中生成仓库的新版本。

  4. 在本地保存后,您接下来会将这些更改发送,或 推送 到主存储空间,这样每个人都可以看到它们。在我们的示例中,这个主存储空间托管在 GitHub 上,Git 称之为 上游仓库

图片

现在,为了使用这种策略,您需要在 GitHub 上有一个账户。在接下来的两个部分中,我们将指导您如何设置账户并在 GitHub 上创建仓库。

19.3 GitHub 账户

基本 GitHub 账户是免费的。要创建一个,请访问 GitHub⁴,在那里您将看到一个可以注册的框。

您需要仔细选择名称。它应该简短、易于记忆和拼写,与您的名字有关,并且专业。这一点很重要,因为您可能会向潜在雇主发送您 GitHub 账户的链接。您的首字母和姓氏通常是不错的选择。

19.4 GitHub 仓库

一旦您有了账户,您现在就可以创建一个 GitHub 仓库,它将作为项目的主体或上游仓库。您添加到这个项目的协作者将能够管理他们电脑上的本地仓库并推送更改。Git 将帮助您保持所有不同副本的同步。

要创建仓库,首先通过点击 GitHub 上的 登录 按钮登录到您的账户。您可能已经登录,在这种情况下,登录 按钮将不会显示。如果需要登录,您将需要输入用户名和密码。我们建议您设置浏览器记住这些信息,以避免每次都输入。

一旦登录到您的账户,您就可以点击 仓库,然后点击 新建 来创建一个新的仓库。系统会提示您输入一个名称:

图片

图片

当命名您的项目时,选择一个描述性的名称,清楚地说明项目的内容。请记住,随着您参与更多项目,您将积累许多仓库。为了说明,我们将使用名称 homework-0

您还将被提示决定您的仓库应该是公开的还是私有的。为了做出决定,了解以下区别:

  • 公共仓库:互联网上的任何人都可以看到这些。只有协作者才能进行更改。

  • 私有仓库:只有您授权访问的人才能查看它们。

虽然还有其他设置要考虑,但我们通常坚持使用 GitHub 提供的默认选项。

创建您的仓库后,GitHub 将向您展示步骤,将您的本地仓库(您电脑上的那个)链接到您在 GitHub 上设置的新仓库。他们将会提供一些可以直接复制粘贴到您的终端中的代码。我们将分解这些代码,以便您确切地知道每个命令的作用。

19.5 连接 Git 和 GitHub

当访问 GitHub 时,您需要凭证来验证您的身份。有两种连接方式:HTTPS 或 SSH,每种都需要不同的凭证。我们建议使用 HTTPS,它使用个人访问令牌(PAT)。请注意,您的 GitHub 网站密码不是您的访问令牌

GitHub 提供了一份详细的指南,说明如何获取访问令牌⁵,您可以在 GitHub 文档网站上通过搜索“管理您的个人访问令牌”找到它⁶。要生成令牌:

  1. 仔细遵循 GitHub 提供的说明。

  2. 在设置令牌权限时,选择不失效,并在作用域部分选择repo选项。

完成这些步骤后,GitHub 将显示您的令牌——一串长长的字符。然后您应该:

  1. 立即复制此令牌到您的剪贴板。请记住,这是 GitHub 唯一一次向您显示它的时候。

  2. 为了安全起见,请将此令牌保存在密码管理器中。这确保了您在需要时可以访问它。

在以下一些流程中,您将被提示输入您的密码。相反,请粘贴您已复制的令牌。在此之后,密码提示应不再出现。如果您以后需要令牌,请从密码管理器中检索它。

对于更详细的解释,包括如何使用 SSH 而不是 HTTPS,请参阅 Happy Git and GitHub for the useR⁷。

下一步是让 Git 知道我们是谁。这将使与 GitHub 连接更容易。为此,请在我们的终端窗口中输入以下两个命令:

git config --global user.name "Your Name"
git config --global user.mail "your@email.com"

这将更改 Git 配置,以便每次您使用 Git 时,它都会知道这些信息。请注意,您需要使用您用于打开 GitHub 账户的电子邮件账户

19.6 初始设置

在终端中,切换到您想要存储本地仓库的目录。我们建议将目录命名为与 GitHub 仓库相同的名称。在我们的示例中,我们会使用:

mkdir homework-0
cd homework-0

然后,我们将目录初始化为 Git 仓库,开始版本控制过程。

git init

main 与 master* GitHub 现在使用main作为默认分支名称。在过去,Git 和 GitHub 都使用master作为默认。因此,许多较旧的仓库或较旧的 Git 版本可能仍然使用master作为其主要分支

为了确保您的本地分支与 GitHub 仓库的分支名称一致:

  1. 访问 GitHub 仓库页面。

  2. 检查左侧的下拉菜单,其中列出了分支。这将显示默认分支名称。

要验证你的本地分支名称,请使用:

git branch

如果你看到分支名称不是main,但希望它是main,可以使用以下命令重命名:

git branch -M main

-M代表移动。请注意,这与更改分支不同,它是重命名当前分支。* 要将本地仓库与其在 GitHub 上的对应仓库链接起来,你需要 GitHub 仓库的 URL。要找到这个 URL,请访问仓库的网页。点击绿色的代码*按钮以快速复制 URL,在我们的例子中是https://github.com/rairizarry/homework-0.git

图片

一旦你有了这个,你可以输入:

git remote add origin https://github.com/rairizarry/homework-0.git

要理解这个命令,请注意git remote add添加了一个新的远程引用。在 Git 中,远程指的是你的代码仓库存储的另一个地方,通常在互联网或另一个网络上。origin是给远程仓库或其他人将之视为主项目源的中央仓库的常规名称。它基本上是仓库 URL 的简写别名。你可以技术上将其命名为任何你想要的名称,但origin是最常用的约定。最后,https://github.com/rairizarry/homework-0.git是远程仓库的 URL。它告诉 Git 仓库托管的位置。这些命令共同设置了一个新的本地 Git 仓库,并将其链接到 GitHub 上的远程仓库。* *## 19.7 Git 基础知识

现在你已经初始化了一个目录来存储你的本地仓库,我们可以学习如何将文件从我们的工作目录移动到上游仓库。

19.7.1 工作目录

图片

工作目录与你的 Unix 工作目录相同。在我们的例子中,如果我们创建homework-0目录中的文件,它被认为是工作目录的一部分。Git 可以使用命令告诉你工作目录中的文件与其他区域中文件版本的关系:

git status

因为我们还没有做任何事情,你应该会收到一条类似的消息:

On branch main

No commits yet

nothing to commit (create/copy files and use "git add" to track)

如果我们添加一个文件,比如code.R,你会看到一条类似的消息:

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    code.R

19.7.2 添加

图片

现在我们将对这些文件进行修改。最终,我们希望这些文件的这些新版本被跟踪并与上游仓库同步。但我们不想跟踪每一个小变化:我们不想同步,直到我们确信这些版本足够最终,可以作为一个新版本分享。因此,暂存区的编辑不会被版本控制系统保留。

为了演示,我们将code.R添加到暂存区:

git add code.R

现在运行git status会显示:

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
    new file:   code.R

注意,工作目录中有不在暂存区的其他文件并不是问题。例如,如果我们创建文件test-1.Rtest-2.Rgit status会提醒我们这些文件尚未暂存:

On branch main

No commits yet

Changes to be committed:
  (use "git rm --cached <file>..." to unstage)
    new file:   code.R

Untracked files:
  (use "git add <file>..." to include in what will be committed)
    test-1.R
    test-2.R

19.7.3 提交

图片

如果我们现在准备好制作仓库的第一个版本,该版本仅包括 code.R,我们可以使用以下命令:

git commit -m "Adding a new file." 

注意,commit 需要我们添加一条消息。使这些消息信息丰富将帮助我们记住为什么做出这个更改。运行 commit 后,我们将收到一条消息,告知我们已提交:

[main (root-commit) 1735c25] adding a new file
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 code.R

注意,如果我们编辑 code.R,它只会在工作目录中发生变化。git status 会显示给我们

Changes not staged for commit:
 (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
    modified:   code.R

要将编辑后的文件添加到本地仓库,我们需要暂存编辑后的文件并提交更改

git add code.R
git commit -m "Added some lines of code."

这将给我们一条消息,告知我们已进行更改:

[main 8843673] added some lines of code
 1 file changed, 1 insertion(+)

注意,我们可以通过在 commit 命令后跟要提交的文件,只用一行命令就达到相同的效果:

git commit -m "Added some lines of code." code.R

当文件更改的数量很少,并且我们可以在末尾列出它们时,这很方便。

要查看版本控制的实际操作,请注意当我们输入

git log code.R

我们得到存储在我们日志中的版本列表:

commit 88436739dcbd57d8ad27a23663d30fd2c06034ca (HEAD -> main)
Author: Rafael A Irizarry 
Date:   Sun Sep 3 15:32:03 2023 -0400

    Added some lines of code.

commit 1735c25c675d23790df1f9cdb3a215a13c8ae5d6
Author: Rafael A Irizarry 
Date:   Sun Sep 3 15:27:19 2023 -0400

    Adding a new file.

19.7.4 push

图片

一旦我们准备好将本地仓库与上游 GitHub 仓库同步,我们可以使用

git push -u origin main

如果您是第一次将代码推送到您的 GitHub 账户,您将需要输入密码,并输入我们在第 19.5 节中描述的个人访问令牌。您可能只需要这样做一次。* *-u 标志,代表 --set-upstream,将使 Git 记住在这个仓库中,您想要推送到远程仓库 origin 中定义的 main 分支。这很有益处,因为下次您想要从这个分支推送到或拉取时,您只需简单地使用

git push

如果您需要提醒您正在推送到哪里,您可以输入

git remote -v

v 代表 verbose。在我们的例子中,我们将得到

origin  https://github.com/username/homework-0.git (fetch)
origin  https://github.com/username/homework-0.git (push)

我们接下来描述 fetch。如果您没有收到任何回复,这意味着您还没有定义远程仓库,就像我们在第 19.6 节中所做的那样。* *### 19.7.5 fetch 和 merge

图片

图片

如果这是一个协作项目,上游仓库可能会发生变化,与您的版本不同。为了更新您的本地仓库以与上游仓库一致,我们使用 fetch 命令:

git fetch

然后为了将这些副本放入我们的工作目录,我们使用以下命令:

git merge

19.7.6 pull

图片

我们经常只想获取和合并,而不进行检查。为此,我们使用:

git pull

19.7.7 克隆

您可以轻松地从现有的公共仓库使用 git clone 下载所有代码和版本控制日志。当您克隆时,您实际上是在制作整个目录的完整副本。例如,您可以使用以下方式下载创建此书所使用的所有代码:

git clone https://github.com/rafalab/dsbook-part-1.git

您可以看到一个简单的示例,这是通过克隆此仓库在 Unix 章节中创建的谋杀目录:

git clone https://github.com/rairizarry/murders.git

如果你使用 git clone,你不需要初始化,因为分支和远程仓库已经定义好了。现在,要推送更改,你需要被添加为合作者。否则,你将不得不遵循更复杂的 pull request 流程,这里我们不涉及。* *## 19.8 .gitignore

当我们使用 git status 时,我们获得有关本地仓库中所有文件的信息。但并不一定需要将工作目录中的所有文件添加到 Git 仓库中,只需那些我们想要跟踪或共享的文件。如果我们的工作正在生成我们不希望跟踪的特定类型的文件,我们可以将这些文件定义的后缀添加到 .gitignore 文件中。关于使用 .gitignore 的更多详细信息,请参阅 git-scm 网站⁸。当你输入 git status 时,这些文件将不再出现。

19.9 Git in RStudio

虽然命令行 Git 是一个强大且灵活的工具,但当我们刚开始时,它可能会有些令人望而生畏。RStudio 提供了一个图形界面,它简化了在数据分析项目中使用 Git 的过程。

要这样做,我们开始一个项目,但不是选择 New DirectoryExisting Directory,而是选择 Version Control,然后我们将选择 Git 作为我们的版本控制系统:

图片

图片

仓库 URL 是你用作 origin 或克隆的链接。在 第 19.4 节 中,我们使用了 https://github.com/username/homework-0.git 作为示例。在项目目录名称中,你需要放入生成的文件夹名称,在我们的示例中,这将是仓库的名称 homework-0。这将在你的本地系统中创建一个名为 homework-0 的文件夹。注意,如果你已经存在该文件夹,则需要删除它或选择不同的名称。一旦这样做,项目就创建好了,并且它知道与 GitHub 仓库的连接。你将在右上角看到项目名称和类型,以及右上角的新标签页,标题为 Git

图片

图片

如果你选择这个标签页,它将显示你的项目中的文件,排除 .gitignore 中的文件,以及一些图标,这些图标会给你提供关于这些文件及其与仓库关系的详细信息。在下面的示例中,我们已经在文件夹中添加了一个文件,名为 code.R,你可以在编辑面板中看到它。

图片

我们现在需要关注 Git 选项卡。重要的是要知道,你的本地文件和 GitHub 仓库不会自动同步。如 第 19.2 节 所述,当你准备好时,你必须使用 git push 进行同步。我们将在下面的 RStudio 中而不是在终端中展示如何这样做。

在我们开始协作项目之前,我们通常首先做的事情是从远程仓库拉取更改,在我们的例子中是 GitHub 上的那个。然而,对于这里显示的示例,由于我们从一个空的仓库开始,并且我们是唯一进行更改的人,所以我们不需要从拉取开始。

在 RStudio 中,文件相对于远程和本地仓库的状态通过带有颜色的状态符号表示。黄色方块表示 Git 对此文件一无所知。为了与 GitHub 仓库同步,我们需要添加该文件,然后提交更改到我们的本地 Git 仓库,然后推送更改到 GitHub 仓库。目前,该文件仅存在于我们的计算机上。要使用 RStudio 添加文件,我们点击暂存框。你会看到状态图标现在变成了绿色 A。

图片

现在我们已准备好将文件提交到我们的本地仓库。在 RStudio 中,我们可以使用提交按钮。这将打开一个新的对话框窗口。使用 Git 时,每次我们提交更改,都必须输入一个描述正在提交的更改的消息。

图片

在这种情况下,我们将简单地描述我们正在添加一个新的脚本。在这个对话框中,RStudio 还会给你一个关于你要更改到 GitHub 仓库的内容的摘要。在这种情况下,因为它是一个新文件,整个文件都被高亮显示为绿色,这突出了更改。

一旦我们点击提交按钮,我们应该会看到 Git 发来的一个包含已提交更改摘要的消息。现在我们已准备好将这些更改推送到 GitHub 仓库。我们可以通过点击右上角的推送按钮来完成此操作:

图片

图片

现在,我们看到 Git 发来的消息,告知我们推送已成功。在弹出的窗口中,我们不再看到 code.R 文件。这是因为自上次推送以来,我们没有进行任何新的更改。现在我们可以退出这个弹出窗口,继续我们的代码工作。

图片

图片

如果我们现在访问我们的仓库在网页上,我们会看到它与我们的本地副本相匹配。

图片

恭喜你,你已经在 GitHub 仓库成功共享了代码!

对于这里显示的示例,我们只添加了 code.R。但,一般来说,对于 RStudio 项目,我们建议添加一个 README.md 文件以及 .gitignore 和 .Rproj 文件。** *

  1. www.codecademy.com/learn/learn-git↩︎

  2. guides.github.com/activities/hello-world/↩︎

  3. happygitwithr.com/↩︎

  4. github.com/↩︎

  5. docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens↩︎

  6. docs.github.com/en↩︎

  7. happygitwithr.com/https-pat↩︎

  8. git-scm.com/docs/gitignore↩︎

20  可重复的项目

原文:rafalab.dfci.harvard.edu/dsbook-part-1/productivity/reproducible-projects.html

  1. 生产力工具

  2. 20  可重复的项目

数据分析项目的最终产品通常是报告。许多科学出版物可以被视为数据分析的最终报告。同样,基于数据的新闻报道、您公司的分析报告或数据分析课程讲义也是如此。这些报告通常是在纸上或在包含分析结果文本描述和一些图表、表格的 PDF 文件中。

想象一下,在你完成分析和报告后,你被告知你得到了错误的数据集,你收到了一个新的数据集,并被要求运行相同的分析。或者,如果你意识到犯了一个错误,你需要重新检查代码,修复错误,并重新运行分析呢?或者想象一下,你正在培训的人想看看你的代码,并能够重现结果来了解你的方法?

就像刚才描述的情况一样,对于数据分析师来说实际上是非常常见的。在这里,我们描述了如何使用 RStudio 来组织您的项目,以便重新运行分析变得简单直接。然后我们演示了如何使用 quarto 或 R markdown 生成可重复的报告。knitR包将极大地帮助以最少的工作重新创建报告。这是由于 markdown 文档允许将代码和文本描述组合到同一文档中,并且由代码生成的图表和表格会自动添加到文档中。

20.1 RStudio 项目

RStudio 提供了一种方法,可以将数据分析项目的所有组件组织到一个文件夹中,并在一个地方跟踪有关此项目的信息,例如文件的 Git 状态。在第 19.9 节中,我们展示了 RStudio 如何通过 RStudio 项目促进 Git 和 GitHub 的使用。在本节中,我们简要演示了如何启动一个新的项目以及一些关于如何保持项目组织有序的建议。RStudio 项目还允许您同时打开多个 RStudio 会话,并跟踪每个会话是哪一个。

要开始一个项目,点击文件然后新建项目。通常我们已经在保存工作的地方创建了一个文件夹,就像我们在第 18.6 节中所做的那样,我们选择现有目录。在这里,我们展示了一个例子,我们还没有创建文件夹,并选择了新建目录选项。

然后,对于数据分析项目,你通常选择新建项目选项:

现在,您必须决定与您的项目关联的文件夹的位置以及文件夹的名称。在选择文件夹名称时,就像文件名一样,确保它是一个有意义的名称,这将帮助您记住项目的内容。与文件一样,我们建议使用小写字母,没有空格,并使用连字符来分隔单词。我们将把这个项目的文件夹命名为 my-first-project。这将在这个项目关联的文件夹中生成一个名为 my-first-project.RprojRproj 文件。我们将在下面几行中看到这如何有用。

Markdown

您将获得选项,以确定此文件夹在您的文件系统中的位置。在这个例子中,我们将将其放置在我们的主文件夹中,但这通常不是好的做法。正如我们在 Unix 章节中描述的 第 18.6 节,您希望按照分层方法组织您的文件系统,并使用名为 projects 的文件夹来保存每个项目的文件夹。

Markdown

当您使用项目启动 RStudio 时,您将在右上角看到项目名称。这将提醒您这个特定的 RStudio 会话属于哪个项目。当您在没有项目的情况下打开 RStudio 会话时,它将显示 项目:无

在处理项目时,所有文件都将保存在与项目关联的文件夹中,并在此文件夹中进行搜索。下面,我们展示了一个我们编写的并保存为 code.R 命名的脚本示例。因为我们为项目使用了有意义的名称,所以在命名文件时可以稍微不那么详细。尽管我们在这里没有这样做,但您可以同时打开几个脚本。您只需点击 文件,然后 新建文件,选择您想要编辑的文件类型。

Markdown

使用项目的主要优点之一是,在关闭 RStudio 后,如果我们希望继续在项目中的工作,我们只需双击或打开我们首次创建 RStudio 项目时保存的文件。在这种情况下,文件名为 my-first-project.Rproj。如果我们打开此文件,RStudio 将启动并打开我们正在编辑的脚本。

Markdown

Markdown

另一个优点是,如果您点击两个或更多不同的 Rproj 文件,您将为每个文件启动新的 RStudio 和 R 会话。

20.2 Markdown

Markdown 是一种用于 文学编程 文档的格式,广泛用于生成 html 页面或 pdf 文档。文学编程将指令、文档和详细的注释编织在机器可执行代码之间,生成一个描述程序的文档,这对于人类理解来说是最优的¹。您可以通过在线教程了解更多关于 Markdown 的信息²。

与 Microsoft Word 等文字处理器不同,在 markdown 中,你需要 编译 文档以生成最终的报告。markdown 文档看起来与最终产品不同。这种做法一开始可能看起来是一个缺点,但从长远来看可以节省你的时间。例如,你不需要一个接一个地生成图表并将其插入到文字处理文档中,图表在编译文档时会自动添加。如果你需要更改图表,只需在编辑生成图表的代码后重新编译文档即可。

在 R 中,我们可以使用 Quarto 或 R Markdown 生成文献编程文档。我们推荐使用 Quarto,因为它是一个比 R Markdown 更新且更灵活的版本,允许使用除 R 之外的语言。由于 R Markdown 比 Quarto 早几年,许多公共和教育文献编程文档都是用 R Markdown 编写的。然而,由于格式相似,并且两者都使用 knitr 包来执行 R 代码(详情见 20.2.4 节),大多数现有的 R Markdown 文件无需修改即可用 Quarto 渲染。

在 RStudio 中,你可以通过点击 文件新建文件,然后分别选择 Quarto 文档R Markdown 来开始一个 Quarto 或 R Markdown 文档。接下来,系统会要求你输入文档的标题和作者。我们将准备一份关于枪支谋杀的报告,所以我们会给它一个合适的名字。你也可以决定最终报告的格式:HTML、PDF 或 Microsoft Word。稍后我们可以轻松更改这个格式,但在这里我们选择 html,因为它是调试目的的首选格式:

图片

图片

这将生成一个模板文件:

图片

作为一个惯例,我们使用 qmdRmd 后缀分别代表 Quarto 和 R Markdown 文件。

一旦你熟悉了 markdown,你将能够不使用模板就能做到这一点,可以直接从一个空白模板开始。

在模板中,你会看到一些需要注意的事项。

20.2.1 标题

在顶部,你可以看到:

---
title: "Report on Gun Murders"
author: "Rafael Irizarry"
format: html
---

--- 之间的内容是 YAML 标题。YAML 是一种广泛使用的语言,主要用于提供配置数据。在 Quarto 和 R Markdown 中,它主要用于定义文档的选项。你可以在标题中定义比模板中包含的更多其他内容。我们在这里不讨论那些内容,但 quartoguide³ 中提供了大量信息。我们将强调的一个参数是 format。通过将其更改为,例如,pdf,我们可以控制编译时产生的输出类型。标题和作者参数会自动填写,因为我们已经在创建新文档时弹出的 RStudio 对话框中填写了空白。

20.2.2 R 代码块

在文档的各个地方,我们会看到类似的东西:

```{r}

1 + 1

```r

这些是代码块。当你编译文档时,代码块内的 R 代码,在这个例子中是1+1,将被评估,并将结果包含在最终文档的相应位置。

要添加你自己的 R 代码块,你可以在 Mac 上使用快捷键 command-option-I,在 Windows 上使用 Ctrl-Alt-I 快速输入上述字符。

这同样适用于图表;图表将被放置在那个位置。我们可以写如下内容:

```{r}

plot(1)

```r

默认情况下,代码也会显示。为了避免代码显示,可以使用一个带有#|注释的参数。为了避免在最终文档中显示代码,可以使用参数echo: FALSE。例如:

```{r}

#| echo: false

1+1

```r

我们建议养成给 R 代码块添加标签的习惯。这在调试和其他情况下将非常有用。你可以通过添加一个描述性的词,如这样:

```{r}

#| 标签:一加一

1+1

```r

20.2.3 全局执行选项

如果你想要全局应用一个选项,可以在标题下的execute部分包含它。例如,在标题中添加以下行可以使代码默认不显示:

execute:
  echo: false

我们在这里不会详细说明,但随着你对 R Markdown 的熟悉,你将了解设置编译过程全局选项的优势。

20.2.4 knitR

我们使用knitR包来编译 Quarto 或 R markdown 文档。用于编译的具体函数是knit函数,它接受一个文件名作为输入。RStudio 提供了一个按钮,使得编译文档变得更加容易。对于下面的截图,我们已经编辑了文档,以生成关于枪支谋杀的报告。你可以在 GitHub 上看到这个文件⁴。现在你可以点击Render按钮:

注意,当你第一次点击*Render*按钮时,可能会弹出一个对话框,询问你是否需要安装所需的包。一旦安装了这些包,点击Render将编译你的 Quarto 文件,生成的文档将弹出来。

这个特定的例子生成一个 HTML 文档,你可以在工作目录中看到它。要查看它,打开终端并列出文件。你可以在浏览器中打开文件,并使用它来展示你的分析。你也可以通过将format: html改为format: pdfformat: docx来生成 PDF 或 Microsoft 文档。注意这是 Quarto 和 R markdown 之间的一个区别。在使用 R markdown 时,我们使用output: html_documentoutput: pdf_documentoutput: word_document

我们还可以使用format: gfm来生成在 GitHub 上渲染的文档,这代表 GitHub flavored markdown。这将生成一个带有md后缀的 markdown 文件,在 GitHub 上渲染得很好。因为我们已经将这些文件上传到 GitHub,所以你可以点击md文件,你将看到报告作为一个网页:

这是一种方便的分享报告的方式。

20.2.5 学习更多

R markdown 有很多其他用途。我们强烈建议你在撰写 R 报告的过程中继续学习,以获得更多经验。互联网上有许多免费资源,包括:

  • The Quarto Guide⁵

  • Hello, Quarto tutorial⁶

  • Dynamic Documents with R and knitr textbook⁷

20.3 组织数据科学项目

在本节中,我们将所有内容整合在一起,创建美国谋杀案项目,并在 GitHub 上分享。

20.3.1 在 Unix 中创建目录

在 第 18.6 节 中,我们通过一个示例演示了如何使用 Unix 准备数据科学项目。这里我们继续这个示例,并展示如何使用 RStudio。在 第 18.6 节 中,我们使用 Unix 创建了以下目录:

cd ~
cd projects
mkdir murders
cd murders
mkdir data rdas 

20.3.2 创建 RStudio 项目

在下一节中,我们将创建一个 RStudio 项目。在 RStudio 中,我们转到 文件,然后选择 新建项目…,当给出选项时,我们选择 现有目录。然后我们写下上面创建的 murders 目录的完整路径。

一旦你这样做,你将在 RStudio 的 文件 选项卡中看到你创建的 rdasdata 目录。

请记住,当我们在这个项目中时,我们的默认工作目录将是 ~/projects/murders。你可以通过在 R 会话中输入 getwd() 来确认这一点。这很重要,因为它将帮助我们组织代码,当我们需要编写文件路径时。

在数据分析项目中,尽量始终使用相对路径。这些路径应相对于默认工作目录。使用完整路径的问题,包括使用家目录 ~ 作为路径的一部分,是代码可能无法在其他文件系统上工作,因为目录结构将不同。

20.3.3 编辑 R 脚本

现在让我们编写一个脚本,将文件下载到数据目录中。我们将把这个文件命名为 download-data.R

此文件的将包含以下内容:

url <- "https://raw.githubusercontent.com/rafalab/dslabs/master/inst/
extdata/murders.csv"
dest_file <- "data/murders.csv"
download.file(url, destfile = dest_file)

请注意,我们正在使用相对路径 data/murders.csv

在 R 中运行此代码,你将看到在 data 目录中添加了一个文件。

现在我们准备编写一个脚本来读取这些数据,并准备一个我们可以用于分析的表格。将文件命名为 wrangle-data.R。此文件的内容将如下:

library(tidyverse
murders <- read_csv("data/murders.csv")
murders <- murders |> mutate(region = factor(region),
 rate = total / population * 10⁵)
save(murders, file = "rdas/murders.rda")

再次注意,我们只使用相对路径。

20.3.4 保存处理后的数据

在这个文件中,我们介绍了一个我们之前没有见过的 R 命令:save。R 中的 save 命令将对象保存到称为 rda 文件 的东西中:rda 是 R 数据的缩写。我们建议在保存 R 对象的文件上使用 .rda 后缀。你会看到 .RData 也被使用了。

如果您运行上面的代码,处理后的数据对象将被保存在rda目录中的一个文件中。然后您可以使用load来恢复对象。尽管这里不是这种情况,但这种方法通常很实用,因为生成我们用于最终分析和图表的数据对象可能是一个复杂且耗时的过程。因此,我们只运行此过程一次并保存文件。但我们仍然希望能够从原始数据生成整个分析。

save允许您保存多个对象,然后使用相同的名称加载时,saveRDS函数允许您保存一个对象,而不需要名称。要将其恢复,您使用readRDS函数。如何使用此函数的一个例子是,您在一个会话中保存它:

saveRDS(murders, file = "rdas/murders.rda")

然后在另一个会话中读取它,使用您想要的任何对象名称:

dat <- readRDS("rdas/murders.rda")

20.3.5 主要分析文件

现在我们准备好编写分析文件了。让我们称它为analysis.R。内容应该是以下内容:

library(tidyverse
load("rdas/murders.rda")
 murders |> mutate(abb = reorder(abb, rate)) |>
 ggplot(aes(abb, rate)) +
 geom_bar(width = 0.5, stat = "identity", color = "black") +
 coord_flip()

如果您运行此分析,您将看到它生成一个图表。

20.3.6 其他目录

现在假设我们想要保存生成的图表以用于报告或演示。我们可以使用ggplot命令ggsave来完成此操作。但我们应该把图表放在哪里?我们应该有系统地组织,所以我们将把图表保存到名为figs的目录中。首先,在终端中输入以下内容来创建一个目录:

mkdir figs

然后您可以添加以下行:

ggsave("figs/barplot.png")

到您的 R 脚本中。如果您现在运行脚本,一个 png 文件将被保存到figs目录中。如果我们想要将此文件复制到我们正在开发演示的其他目录中,我们可以通过在终端中使用cp命令来避免使用鼠标。

20.3.7 README 文件

您现在在单个目录中拥有一个自包含的分析。最后的建议是创建一个README.txt文件,描述每个文件的功能,以便其他人阅读您的代码时受益,包括您未来的自己。这不会是一个脚本,而只是一些笔记。在 RStudio 中打开新文件时提供的选项之一是文本文件。您可以将类似以下内容保存到文本文件中:

We analyze US gun murder data collected by the FBI.

download-data.R - Downloads csv file to data directory

wrangle-data.R - Creates a derived dataset and saves as R object in rdas
directory

analysis.R - A plot is generated and saved in the figs directory.

20.3.8 初始化 Git 目录

在第 19.6 节中,我们演示了如何初始化 Git 目录并将其连接到 GitHub 上的上游仓库,该仓库我们已经在该节中创建。

我们可以在 Unix 终端中这样做:

cd ~/projects/murders
git init
git add README.txt
git commit -m "First commit. Adding README.txt file just to get started"
git remote add origin `https://github.com/rairizarry/murders.git`
git push -u origin remote

20.3.9 使用 RStudio 添加、提交和推送文件

我们可以继续添加和提交每个文件,但使用 RStudio 可能更容易。为此,通过打开 Rproj 文件来启动项目。应该会出现 git 图标,您可以使用这些图标添加、提交和推送。

我们现在可以前往 GitHub 确认我们的文件是否已上传。您可以在 GitHub 上看到这个项目的版本,它使用 Unix 目录组织⁸。您可以通过在终端使用git clone命令将副本下载到您的计算机上。此命令将在您的当前工作目录中创建一个名为murders的目录,所以请小心选择调用它的位置。

git clone https://github.com/rairizarry/murders.git

  1. Knuth, Donald Ervin. “Literate programming.” The computer journal 27.2 (1984): 97-111. academic.oup.com/comjnl/article/27/2/97/343244↩︎

  2. www.markdowntutorial.com/↩︎

  3. quarto.org/docs/guide/↩︎

  4. raw.githubusercontent.com/rairizarry/murders/master/report.qmd↩︎

  5. quarto.org/docs/guide/↩︎

  6. quarto.org/docs/get-started/hello/rstudio.html↩︎

  7. duhi23.github.io/Analisis-de-datos/Yihue.pdf↩︎

  8. github.com/rairizarry/murders↩︎

数据科学导论

原文:rafalab.dfci.harvard.edu/dsbook-part-2/

  1. 前言

  2. 序言

统计与预测算法案例研究

序言

这是《通过案例研究学习统计与预测算法》(Statistics and Prediction Algorithms Through Case Studies)的第二个部分《数据科学导论》的网站。

第一部分《使用 R 进行数据整理和可视化》的网站在这里

本书最初是哈佛 X 数据科学系列 中使用的课堂笔记的一部分。

可以从 CRC Press 获取本书第一版的印刷版,该版结合了两个部分。

可以从 Leanpub 获取 2019 年 10 月 24 日版本的免费 PDF,该版本结合了两个部分。

生成本书的 Quarto 代码可在 GitHub 上找到。

本作品根据 Creative Commons Attribution-NonCommercial-ShareAlike 4.0 国际 CC BY-NC-SA 4.0 许可协议授权。

我们在 X 上发布与本书相关的公告。请关注 @rafalab 以获取更新。

致谢

特别感谢艾米·吉尔(Amy Gill)、豪尔赫·康尼克(Jorge Cornick)、巴里·麦克莱恩(Barry MacLean)和罗伯特· gentleman(Robert Gentleman)对数十条评论、编辑和建议。还要感谢斯蒂芬妮·希克斯(Stephanie Hicks),她两次担任我的数据科学课程助教,以及耐心回答我关于 bookdown 的许多问题的谢一辉(Yihui Xie)。还要感谢赫克托·科拉德-布拉沃(Héctor Corrada-Bravo),他提供了关于如何最好地教授机器学习的建议。感谢阿莉萨·弗拉齐(Alyssa Frazee)帮助创建成为推荐系统案例研究的家庭作业问题。还要感谢哈代·维克汉姆(Hadley Wickham)、米内·切廷卡亚-朗德尔(Mine Çetinkaya-Rundel)和加勒特·格罗勒蒙德(Garrett Grolemund)使他们的《R for Data Science》一书的 Quarto 代码开源。最后,感谢亚历克斯·诺恩斯(Alex Nones)在各个阶段校对稿件。

本书是在过去十五年中教授几门应用统计学课程的过程中构思的。多年来与我合作的助教对本书做出了重要的间接贡献。本课程的最新版本是由希瑟·斯滕申(Heather Sternshein)和佐菲亚·加约多斯(Zofia Gajdos)协调的哈佛 X 系列。我们感谢他们的贡献。我们还要感谢所有提出问题和评论的学生,他们的帮助使我们改进了本书。这些课程部分由 NIH 奖学金 R25GM114818 资助。我们非常感谢美国国立卫生研究院的支持。

特别感谢所有通过 GitHub 拉取请求编辑书籍或通过创建一个问题或发送电子邮件提出建议的人:jcornickm(Jorge Cornick)、hbmaclean(Barry MacLean)、nickyfoto(黄强)、desautm(Marc-André Désautels)、michaschwab(Michail Schwab)、alvarolarreategui(Alvaro Larreategui)、jakevc(Jake VanCampen)、omerta(Guillermo Lengemann)、espinielli(Enrico Spinielli)、asimumba(Aaron Simumba)、braunschweig(Maldewar)、gwierzchowski(Grzegorz Wierzchowski)、technocrat(Richard Careaga)、atzakasdefeit(David Emerson Feit)、shiraamitchell(Shira Mitchell)、Nathalie-Sandreashandel(Andreas Handel)、berkowitze(Elias Berkowitz)、Dean-Webb(Dean Webber)、mohayusufjimrothsteinmPloenzke(Matthew Ploenzke)、NicholasDowand(Nicholas Dow)、kant(Darío Hereñú)、debbieyuster(Debbie Yuster)、tuanchauict(Tuan Chau)、phzellerBTJ01(BradJ)、glsnow(Greg Snow)、mberlanda(Mauro Berlanda)、wfan9larswestvang(Lars Westvang)、jj999(Jan Andrejkovic)、Kriegslustig(Luca Nils Schmid)、odahhaniaidanhorn(Aidan Horn)、atraxler(Adrienne Traxler)、alvegorovawycheong(Won Young Cheong)、med-hat(Medhat Khalil)、biscotty666(Brian Carey)、kengustafsonYowza63ryan-heslin(Ryan Heslin)、SydneyUni-Jim(Jim Nicholls)、raffaemBruciiZ(Huiyuan (Bruce) Zhou)、tim8westaosaf-e-c(Aosaf Ershad Chowdhury)、annlia、David D. Kane、El Mustapha El Abbassi、Vadim Zipunnikov、Anna Quaglieri、Chris Dong、Rick Schoenberg、Isabella Grabski、Doug Snyder 和 JT Harton。

引言

原文:rafalab.dfci.harvard.edu/dsbook-part-2/intro.html

  1. 前言

  2. 引言

“数据科学”这一短语大约在 2012 年开始获得显著的关注,部分原因是《“数据科学家:21 世纪最具吸引力的职业”》一书的出版¹(https://hbr.org/2012/10/data-scientist-the-sexiest-job-of-the-21st-century)。这与 21 世纪初科技行业和学术项目中一种新类型努力的兴起相吻合:从混乱、复杂和大型数据集中提取见解,随着数字数据存储的出现,这些数据集变得越来越普遍。

例子包括结合多个政治民意调查的数据来提高选举预测,抓取体育部门网站以评估棒球前景,分析来自数百万流媒体服务用户的电影评分以提供个性化推荐,开发通过数字化手写数字来读取邮政编码的软件,以及使用先进的测量技术来理解疾病的分子原因。本书围绕这些和其他实际例子展开。

在这些情况下取得成功需要具有互补技能的专家之间的合作。在这本书中,我们的主要重点是数据分析,特别是那种使我们能够从数据中得出有意义的结论的统计思维。为了理解如何有效地分析这些示例中的数据,我们将涵盖关键数学概念。其中许多概念并不新颖,一些最初是为了不同的目的而开发的,但它们已经证明在广泛的领域中具有适应性和实用性。

本书中的数学难度因主题而异,我们假设你已经熟悉所需材料。然而,每个部分的结尾推荐阅读可以帮助填补任何空白。数学本身不是目的,它是表达和深化我们对统计思想及其在数据分析中用途理解的一种工具。

对于代码来说,也是如此。我们在基础 Rdata.tabletidyverse之间交替使用,选择最适合当前任务的工具。鉴于大型语言模型(LLMs)在编写和解释代码方面的能力已经变得非常强大,我们并不专注于语法或编程最佳实践。相反,我们包含代码,因为它们将统计思想与数据联系起来,从而与实际应用联系起来。

例如,在自己的计算机上运行蒙特卡洛模拟并生成自己的数据可以使抽象的概率概念变得具体。总的来说,我们鼓励你进行实验:改变参数、修改代码或设计自己的模拟来测试和细化你的理解。这种积极的探索过程对于培养对随机性、不确定性和推理的直觉至关重要。

虽然现在大型语言模型(LLMs)可以为许多特定问题生成可工作的代码,但它们还无法完成我们在这里教授的主要任务:对数据驱动问题进行统计性思考。这项技能包括提出问题、可视化探索数据、识别变异来源以及处理不确定性。这些是以人为中心的任务,仍然是数据分析的核心。

本书分为六个部分:描述性统计概率论统计推断线性模型高维数据机器学习。前两部分通过实例介绍关键统计概念,而后续部分则聚焦于现实世界的案例研究,展示这些概念在实际中的应用。对于已经熟悉概率和统计理论,主要对应用视角感兴趣的读者,可能希望跳过这些后期章节。

每一部分都组织成简洁的章节,旨在适应单个讲座,并配以练习题以供实践。本书中使用的所有数据集都可在dslabs包中找到,完整的 Quarto 源文件可在GitHub²上找到。


  1. https://hbr.org/2012/10/data-scientist-the-sexiest-job-of-the-21st-century↩︎

  2. [github.com/rafalab/dsbook-part-2]↩︎

摘要统计

原文:rafalab.dfci.harvard.edu/dsbook-part-2/summaries/intro-summaries.html

我们从数据分析中最简单、最强大的工具之一开始:数据摘要。本书的这一部分介绍了帮助我们描述和理解数据集的技术,而不依赖于概率模型。这些摘要将后来提供理解统计建模和推断所需的直觉。

在第一章中,我们关注分布、如直方图和密度图这样的视觉表示,它们揭示了变化的模式、对称性和异常值。在第二章中,我们从图像转向数字,介绍了如平均值和标准差这样的数值摘要,它们量化了分布的中心和范围。为了激发这些摘要,我们引入了正态分布。

然而,这些摘要并不适用于所有数据集。异常值和偏斜分布可能会使某些摘要,如平均值和标准差,变得不太合适,或者导致它们不再代表我们原本认为它们所代表的内容。因此,我们也介绍了基于排名的摘要,如中位数和四分位数范围,这些摘要对异常值更加稳健,并提供了对数据的补充视角。这些想法共同构成了数据分析的基础:在我们尝试建模为什么会出现这些现象之前,描述我们所看到的内容。

1  分布

原文:rafalab.dfci.harvard.edu/dsbook-part-2/summaries/distributions.html

  1. 描述性统计量

  2. 1  分布

在我们开始之前,我们明确指出,本章不会涉及概率论中的分布,这是用于建模不确定性的数学框架。我们将这一讨论推迟到本书的下一部分,在那里我们将贯穿使用它。在这里,我们关注分布作为描述和总结我们已收集的数据的一种方式。我们的目标是理解数据集中的变异和模式,而不进行概率性陈述或预测。本章引入的概念将作为理解概率分布的基础,在那里使用类似的工具来描述随机过程的可能结果。

为了说明理解分布及其与描述性统计量之间关系所需的概念,我们将假装我们需要向 ET,一个从未见过人类的地球外生物,描述我们同学的身高。作为第一步,我们需要收集数据。为此,我们要求学生报告他们的身高(英寸)。我们要求他们提供性别信息,因为我们知道存在两种不同的性别分布。我们收集数据并将其保存在dslabs包中包含的heights数据框中。

library(dslabs)
str(heights)
#> 'data.frame':    1050 obs. of  2 variables:
#>  $ sex   : Factor w/ 2 levels "Female","Male": 2 2 2 2 2 1 1 1 1 2 ...
#>  $ height: num  75 70 68 74 61 65 66 62 66 67 ...

向 ET 传达身高的一种方法就是简单地发送一个包含所有身高的列表。然而,还有更有效的方法来传达这些信息,理解分布的概念将有所帮助。为了简化解释,我们首先关注男性的身高。我们将在第 3.3 节中检查女性的身高数据。

在这个阶段,我们纯粹将分布视为总结和描述数据的工具。在本书的下一部分,我们将将这些相同的思想与概率联系起来,在那里分布帮助我们建模不确定性。目前,我们的目标仅仅是探索我们所拥有的数据中存在的模式和变异。

结果表明,在某些情况下,下一章中引入的两个描述性统计量,即平均值和标准差,就足以帮助我们理解数据。我们将学习数据可视化技术,这些技术将帮助我们确定这两个数字的总结是否合适。这些相同的技巧在两个数字不足以描述时也可以作为替代方案。

1.1 变量类型

了解我们正在处理哪种变量类型非常重要,因为它将帮助我们确定最有效的方法来总结我们的数据并展示其分布。

我们将处理两种类型的变量:分类和数值。每种类型都可以分为另外两组:分类可以是有序的或无序的,而数值变量可以是离散的或连续的。

当数据值代表组而不是数字时,我们称这些数据为分类数据。两个简单的例子是性别(男或女)和美国地区(东北部、南部、北中部、西部)。即使这些数据不是数字,一些分类数据也可以排序,例如辣度(温和、中等、辣)。这些有序分类数据被称为有序数据

数值数据可以是连续的或离散的。一个连续变量可以取一个范围内的任何值。例如,如果测量足够精确,身高是连续的,因为两个个体(即使是同卵双胞胎)之间的差异可以是任意小的分数。然而,如果身高四舍五入到最近的英寸,它就变成了离散的,因为值必须是整数。其他离散数据的例子包括抛掷 10 个硬币时的正面数量、一个人一天抽的香烟数量,或一小时光顾商店的客户数量。在这些情况下,值是可数的,不能取分数值。与可以随着精度增加而测量的连续数据不同,离散数据仅限于不同的、单独的值。

在实践中,有时将离散变量视为连续变量。例如,一个数字测量仪器可能记录数据为 16 位整数。然而,由于很少能获得完全相同的读数超过一次,因此将数据视为连续的是方便的。另一个例子是国家人口规模。尽管人口计数总是整数,但两个司法管辖区人口完全相同的情况很少见。因此,人口规模也通常被视为连续的。

1.2 相对频率分布

一组数字或类别的最基本统计摘要是其分布。简单来说,分布可以看作是具有许多条目的值的紧凑描述。这个概念对于本书的读者来说不应该陌生。例如,对于分类数据,相对频率分布简单地描述了每个唯一类别的比例:

\[p_k = \frac{\text{类别 } k \text{ 出现在列表中的次数}}{n} \]

其中 \(n\) 是列表的长度。

这里有一个关于美国州区域的例子:

prop.table(table(state.region))
#> state.region
#>     Northeast         South North Central          West 
#>          0.18          0.32          0.24          0.26

当数据是离散数值时,相对频率分布的定义方式相同,但更常见的是使用以下符号,因为频率可以被认为是数值的函数:

\[f(x) = \frac{\text{值 } x \text{ 出现在列表中的次数}}{n} \]

这里是按最接近百万数四舍五入的美国各州人口分布,这些是离散的数值:

图片

1.3 实验累积分布函数

当数据是连续的时,基于分布构建摘要的任务更具挑战性,因为报告每个条目的频率不是一个有效的摘要:大多数条目是唯一的,因此相对频率分布并不能很好地总结。

下面是各州人口的相对频率:

由于有 50 个州,每个州都有一个独特的种群值\(x\),因此图表显示了每个州的\(f(x) = \frac{1}{50} = 0.02\)

为了定义连续数值数据的分布,我们定义一个函数,该函数报告所有可能的值\(a\)下数据条目\(x\)的比例。这个函数被称为经验累积分布函数(eCDF),通常用\(F\)表示:

\[F(a) = \mbox{小于或等于 }a\mbox{ 的数据点的比例} \]

下面是上述州人口数据的 eCDF:

与频率表总结分类数据的方式相似,经验累积分布函数(eCDF)总结了连续数据。eCDF 提供了关于值如何分布的清晰图景,并突出了数据集的关键特征。例如,在人口数据中,我们可以看到大约一半的州的种群超过 500 万,而大多数州的种群低于 2000 万。这种类型的摘要有助于我们快速识别中位数、范围和其他重要的分布特征。

在我们关于身高的案例研究中,大多数学生报告的身高是四舍五入到最接近的英寸。然而,有些人没有这样做。例如,一名学生报告的身高为68.503937007874英寸,另一名学生报告的身高为68.8976377952756英寸。这些值分别对应于 174 厘米和 175 厘米转换为英寸,这表明一些学生知道自己的身高精确到厘米,并报告了这个数值除以 2.54。

因此,数据是连续的,相对频率分布有些令人困惑:

尽管这种方法保留了观察列表中的所有信息,但如果将洞察力外推到更广泛的群体,可能会产生误导性的印象。例如,它可能表明没有人的身高在 74 英寸到 74.5 英寸之间,或者有显著更多的人的身高正好是 72 英寸,而不是 71.5 英寸,这些模式我们知道并不准确。相比之下,经验累积分布图(eCDF)提供了更有用的视觉摘要:

从对图表的快速浏览中,我们已能推断出数据的一些一般特征。例如,大约一半的男性身高低于 69 英寸,因为 \(F(69) \approx 0.5\),而身高低于 60 英寸或高于 80 英寸是非常罕见的。此外,因为我们可以计算任何两个值 \(a\)\(b\) 之间个体身高的比例 \(F(b) - F(a)\),上面的图表包含了重建整个数据集所需的所有信息。

将俗语“一图胜千言”进行改写,在这种情况下,一张图的信息量相当于 812 个数字。

我们添加“经验”这个词的原因是,正如我们将在第 6.1 节中看到的那样,累积分布函数(CDF)可以数学上定义,这意味着无需任何数据。

1.4 直方图

尽管经验累积分布函数(eCDF)是统计学中的一个基本概念,但在实践中并不常用。主要原因是不易突出关键分布特征,如中心趋势、对称性或包含 95%值的范围。相比之下,直方图因其使这些特征更容易解释而更受欢迎。虽然直方图牺牲了一小部分信息,但它提供了对数据的更清晰的总结。

要构建直方图,我们首先将数据的范围划分为非重叠的箱。每个箱作为一个类别,其高度被选择,使得条形的高度(即高度乘以箱宽度)代表该箱内观测值的相对频率。尽管直方图类似于条形图,但它不同之处在于 x 轴代表数值而不是类别。

如果我们将报告的身高四舍五入到最接近的英寸,并创建一个相对频率分布,我们得到一个以一英寸间隔定义的箱的直方图:

\[(49.5, 50.5], (50.5, 51.5], (51.5, 52.5], (52.5, 53.5], \dots, (82.5, 83.5] \]

我们可以使用以下ggplot2代码来完成这项工作:

library(tidyverse
heights |> filter(sex == "Male") |> 
 mutate(height = round(height)) |>
 count(height) |> mutate(f = n / sum(n)) |> 
 ggplot(aes(height, f)) + geom_col()

* *R 的基础hist函数也允许我们快速构建直方图。我们可以定义自定义的箱间隔,这对于分组极端值和避免空箱特别有用:

with(heights, hist(heightsex == "Male"], breaks = [c(50, 55, 60:80, 85)))

* *如果我们将这些图表之一发送给一个对人类身高分布不熟悉的地球外生物,他们可以立即获得关于数据的关键见解。首先,身高值范围从 50 到 84 英寸,超过 95%的观测值在 63 到 75 英寸之间。其次,分布在大约 69 英寸处近似对称。此外,通过求和箱计数,可以估计任何给定区间内数据所占的比例。因此,直方图提供了一个直观的总结,仅使用大约 30 个箱计数就保留了从原始的 812 个身高测量值中提取的大部分信息。

失去了哪些信息?直方图在计算条形高度时将分箱内的所有值视为相同的。例如,64.0 英寸、64.1 英寸和 64.2 英寸的值被分组在一起。然而,由于这些差异几乎不可察觉,实际影响可以忽略不计,我们只需使用 23 个数字就能得到有意义的总结。

默认情况下,直方图通常在 y 轴上显示频率而不是相对频率。ggplot2包遵循这一惯例:

heights |> filter(sex == "Male") |> ggplot(aes(x = height)) + 
 geom_histogram](https://ggplot2.tidyverse.org/reference/geom_histogram.html)(breaks = [c(50, 55, 60:80, 85), fill = "grey", color = "black")

* *注意我们使用fillcolor来模仿hist的行为。

当显示频率时,使用等间距的分箱很重要,因为较大的分箱自然会包含更多的观察值,并可能扭曲数据的视觉表示。要显示相对频率,可以通过设置y = after_stat(density)来修改代码:

heights |> filter(sex == "Male") |> 
 ggplot(aes(x = height, y = after_stat(density))) + 
 geom_histogram](https://ggplot2.tidyverse.org/reference/geom_histogram.html)(breaks = [c(50, 55, 60:80, 85), fill = "grey", color = "black")

*** ***### 选择分箱宽度

直方图的外观和解释取决于分箱的选择,在等宽分箱的情况下,还取决于它们的宽度。不同的分箱宽度可以使相同的数据看起来非常不同,太窄时,直方图可能显得嘈杂或破碎;太宽时,分布的重要特征可能被隐藏。分箱宽度的选择应受数据上下文指导。例如,在身高的情况下,大多数人报告的数值是四舍五入到最近的半英寸,因此使用比这更小的分箱是没有意义的。同时,由于现实生活中超过一英寸的高度差异是可察觉的,因此大于约两英寸的分箱会掩盖有意义的变异。对于分布的极端区域,例如低于 60 英寸或高于 80 英寸的区域,由于观察值很少,使用较宽的分箱来保持总结清晰是合理的。每个数据集都需要一个特定于上下文的决定:目标是选择分箱,以便在不引入噪声的情况下揭示结构,最大化单张图所传达的信息。

1.5 平滑密度图

平滑密度图与直方图类似,但数据没有被分成分箱。以下是我们身高数据的平滑密度图:

heights |> filter(sex == "Male") |> 
 ggplot(aes(height)) + geom_density(alpha = 0.2, fill = "#00BFC4")

* *在这个图中,我们不再在区间边界处有尖锐的边缘,许多局部峰值也被移除了。

要理解平滑密度,我们必须理解估计,这是一个我们稍后才会涉及的话题。然而,我们提供了一个启发式解释来帮助您理解基础知识。

你必须理解的主要新概念是我们假设我们所观察到的值的列表是未观察到的更大列表的一个子集。在身高的例子中,你可以想象我们的 812 名男学生的身高列表来自一个假设的列表,该列表包含所有男性学生的身高,这些身高被非常精确地测量。假设有 1,000,000 个这样的测量值。这个值列表有一个分布,就像任何其他值列表一样,而我们真正想要报告给 ET 的是这个更大的分布,因为它更加普遍。不幸的是,我们看不到它。

然而,我们做出一个假设,这个假设可能帮助我们近似它。如果我们有 1,000,000 个值,这些值被非常精确地测量,我们可以制作具有非常非常小柱子的直方图。这个假设是,如果我们展示这个,连续柱子的高度将相似。这就是我们所说的平滑:连续柱子的高度没有大的跳跃。下面,我们展示了具有大小为 1、0.5 和 0.25 的柱子的这个假设数据的直方图。为了使曲线不依赖于假设列表的假设大小,我们使用频率而不是计数来计算曲线。我们使柱子越小,直方图就越平滑:

图片

平滑密度基本上是在柱状图柱子顶部通过的曲线,当柱子非常非常小的时候。

现在,回到现实。我们没有数百万的测量值。相反,我们有 812 个,我们无法制作具有非常小柱子的直方图。

因此,我们使用适合我们数据大小的柱子大小制作直方图,计算频率而不是计数。此外,我们绘制一条通过直方图柱子顶部的平滑曲线。以下图表展示了导致平滑密度的步骤:

图片

选择带宽

平滑*是一个相对术语。密度曲线的平滑程度不是固定的,它取决于一个参数,通常称为带宽,它控制我们如何平滑曲线。大多数计算平滑密度的函数都包括一个选项来调整这个带宽。下面的例子显示了使用两种不同带宽选择的相同直方图,说明了平滑程度如何改变密度曲线的外观。

图片

正如直方图的形状取决于选择的桶宽一样,平滑密度取决于带宽,即平滑参数。不同的带宽可以使相同的数据看起来噪声过多(太小)或过度平滑(太大)。我们应该选择一个带宽,以反映上下文以及我们对潜在分布的信念。例如,对于高度来说,合理地预期在一英寸范围内的值会以相似频率发生,72 英寸的比例应该比 71 英寸的比例更接近,比 65 英寸或 78 英寸的比例更接近,因此适度的平滑是合理的。目标是与桶宽相同:揭示结构,而不添加噪声或隐藏有意义的特点。

解释 y 轴

解释平滑密度图的 y 轴并不简单。它是按比例缩放的,使得密度曲线下的面积总和为 1。如果你想象我们用一个长度为 1 单位的底部的桶,y 轴的值告诉我们该桶中值的比例。然而,这只适用于大小为 1 的桶。对于其他大小区间,确定该区间中数据比例的最佳方法是通过计算该区间包含的总面积的比例。例如,这里是在 65 英寸和 68 英寸之间的值的比例:

图片

这个区域的占比大约是 0.3,这意味着大约 30%的男性身高在 65 英寸到 68 英寸之间。

通过理解这一点,我们就可以使用平滑密度作为总结。对于这个数据集,我们会非常舒适地接受平滑假设,因此可以与 ET 分享这个美观的图表,他可以用它来理解我们的男性身高数据:

图片

到目前为止,你可以完成 1 到 10 的练习。

1.6 描述分布

在用直方图或密度图可视化数据时,我们经常使用一些关键术语和概念来描述分布的形状。在本节中,我们的目标是简单地让你熟悉这个词汇,这些词汇你会在大多数数据分析讨论中遇到。

模式*是数据最集中的值或区域,分布的峰值。一些分布有一个峰值(单峰),而其他分布有两个或更多(双峰或多峰),这可以表明数据中有不同的子组。

对称分布的模态两侧具有相同数量的数据点。如果分布是不对称的,则称为偏斜分布。

分布的尾部是指向最小值和最大值延伸的区域。

右偏斜*(正偏斜)分布具有指向较大值的较长尾部,例如收入,大多数人收入适中,但少数人收入很高。

一个左偏斜(负偏斜)的分布具有朝向较小值的更长尾巴,例如在简单考试中,大多数学生得分较高,但少数学生表现不佳的考试成绩。

在下一章中,我们将介绍正态分布,这是一种对称的钟形分布,可以作为有用的参考点。具有重尾的分布包含比正态分布更多的极端观测值,而轻尾分布包含较少的观测值。

下面是一些模拟示例,说明了上述术语:

1.7 从视觉到数值摘要

正如我们所见,直方图、密度图和 QQ 图等视觉工具帮助我们了解分布的形状、中心、范围、对称性和尾巴。然而,图形摘要通常需要数值摘要来补充,这些摘要将大量信息浓缩为几个关键数字。在下一章中,我们将介绍两个最有用的数值摘要,即平均值和标准差,当数据近似正态分布时,这些摘要提供了你需要的一切信息。我们还将探讨在分布偏离正态性时更合适的替代度量。这里介绍的可视化技术将指导我们在简单数值摘要足够时以及需要更详细描述时做出决定。

1.8 练习

  1. murders数据集中,地区是一个分类变量,以下是其分布情况:

按最接近的 5%计算,有多少比例的州位于中北部地区?

  1. 以下哪项是正确的:

  2. 上面的图表是一个直方图。

  3. 上面的图表仅用条形图显示了四个数字。

  4. 类别不是数字,因此绘制分布没有意义。

  5. 颜色,而不是条形的高度,描述了分布。

  6. 下面的图表显示了男性身高的经验累积分布函数(eCDF):

根据图表,有多少比例的男性身高低于 75 英寸?

  1. 100%

  2. 95%

  3. 80%

  4. 72 英寸

  5. 按最接近英寸计算,哪个身高m具有以下属性:一半的男性学生身高高于m,另一半低于m

  6. 61 英寸

  7. 64 英寸

  8. 69 英寸

  9. 74 英寸

  10. 下面是 2010 年各州谋杀率(每 10 万人中的谋杀次数)的经验累积分布函数(eCDF):

知道有 51 个观测值(50 个州和华盛顿特区)并根据此图表,有多少个州的谋杀率超过每 10 万人 10 次?

  1. 1

  2. 5

  3. 10

  4. 50

  5. 根据上述经验累积分布函数(eCDF),以下哪些陈述是正确的:

  6. 大约一半的州的谋杀率高于每 10 万人 7 次,另一半低于。

  7. 大多数州的谋杀率低于每 10 万人 2 次。

  8. 所有州的谋杀率都高于每 10 万人 2 次。

  9. 除了 4 个州外,谋杀率都低于每 10 万人 5 次。

  10. 下面是我们heights数据集中男性身高的直方图:

根据这个图,有多少男性身高在 63.5 到 65.5 之间?

  1. 10

  2. 24

  3. 47

  4. 100

  5. 关于百分比,有多少是小于 60 英寸的?

  6. 1%

  7. 10%

  8. 25%

  9. 50%

  10. 根据下面的密度图,大约有多少比例的美国州人口超过 1000 万?

  1. 0.02

  2. 0.15

  3. 0.50

  4. 0.55

  5. 下面是三个密度图。它们是否可能来自同一个数据集?

以下哪个陈述是正确的:

  1. 它们不可能来自同一个数据集。

  2. 它们来自同一个数据集,但图表不同是因为编码错误。

  3. 它们是同一个数据集,但前两个图在平滑处理下,第三个图在平滑处理之上。

  4. 它们是同一个数据集,但第一个没有使用对数刻度,第二个在平滑处理下,第三个在平滑处理之上。

2 数值汇总

原文:rafalab.dfci.harvard.edu/dsbook-part-2/summaries/numerical-summaries.html

  1. 汇总统计

  2. 2 数值汇总

直方图和密度图提供了分布的优秀汇总。但我们能否进一步汇总?我们经常看到平均值和标准差被用作汇总统计:一个双数汇总!为了理解这些汇总是什么以及为什么它们被广泛使用,我们需要了解正态分布。

2.1 正态分布

正态分布,也称为钟形曲线和高斯分布,是历史上最著名的数学概念之一。其中一个原因是许多情况下的分布大约是正态分布,包括赌博收益、身高、体重、血压、标准化考试成绩和实验测量误差。这些现象有解释,我们将在后面描述。这里我们关注正态分布如何帮助我们汇总数据。

正态分布不是用数据定义的,而是用数学公式定义的。对于任何区间 \((a,b)\),该区间内值的比例可以使用此公式计算:

\[\mathrm{Pr}(a < x \leq b) = \int_a^b \frac{1}{\sqrt{2\pi}\sigma} e^{-\frac{1}{2}\left( \frac{x-\mu}{\sigma} \right)²} \, dx \]

但是,你不需要记住公式就可以在实践中使用正态分布。最重要的特征是它完全由两个参数定义:\(\mu\)\(\sigma\)。公式中的其余符号代表区间的端点 \(a\)\(b\) 以及已知的数学常数 \(\pi\)\(e\)。这两个参数,\(\mu\)\(\sigma\),分别被称为分布的 均值标准差(分别用希腊字母 \(m\)\(s\) 表示)。在 R 中,函数 pnorm 允许我们计算任何 \(a,\mu,\)\(\sigma\)\(\mathrm{Pr}(x \leq a)\)

pnorm(a, mu, sigma)

分布是对称的,以 \(\mu\) 为中心,大部分值(约 95%)都在 \(\mu\)\(2\sigma\) 范围内。当 \(\mu = 0\)\(\sigma = 1\) 时,正态分布看起来是这样的:

分布仅由两个参数定义的事实意味着,如果一个数据集的分布近似于正态分布,描述分布所需的所有信息都可以编码在两个数字中:均值和标准差。

2.2 平均值和标准差

离散分布也有均值和标准差。我们将在第七章中更详细地讨论这些术语。对于值 \(x_1, \dots, x_n\) 的均值,称为 \(\mu_x\),定义为

\[ \mu_x = \frac{1}{n}\sum_{i=1}^n x_i $$ 注意,这相当于$x$的平均值。 标准差定义为 $$ \sigma_x = \sqrt{\frac{1}{n}\sum_{i=1}^n (x_i - \mu_x)²} $$ 这可以被认为是点$x_i$到均值$\mu_x$的平均距离。 让我们计算男性身高的平均值和标准差: ```r library(tidyverse library(rafalib) x <- with(heights, height[sex == "Male"]) ``` 预定义的函数`mean`和`sd`可以在这里使用: ```r m <- mean(x) s <- sd(x) ``` 如第 10.2.1 节中解释的原因,`sd(x)`除以`length(x)-1`而不是`length(x)`。但请注意,当`length(x)`很大时,`sd(x)`和`sqrt(sum((x-mu)²)/length(x))`实际上是相等的。* 如果存储在`x`中的值的分布很好地被正态分布所近似,那么使用具有均值`m <- mean(x)`和标准差`s <- sd(x)`的正态分布是有意义的。这意味着`x`的整个分布可以用这两个数字来概括! 下面的图显示了观测数据的平滑密度(蓝色)以及相应的正态近似(黑色线),均值为 69.3,标准差为 3.6: ![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/hvd-cs109-intd-ds-2e/img/81d84d86040c8b0ed1ee203d3563ae68.png) 正态分布似乎是一个很好的近似。例如,使用`pnorm`计算的概率与直接从数据中估计的概率非常接近。 为了验证这一点,我们可以比较由正态模型预测的落在给定区间(例如$(a, b]$)内的观测值的比例与数据中实际观察到的比例。区间$[a, b]$内的值的比例可以用以下方法计算: ```r mean(x > a & x <= b) ``` 在这里,逻辑表达式`x >= a & x <= b`对于区间内的所有`x`值返回`TRUE`,否则返回`FALSE`。因为`mean`函数自动将`TRUE`转换为 1,将`FALSE`转换为 0,所以这个逻辑向量的平均值简单地是落在区间内的观测值的比例。我们将在整个书中使用这种方便的方法。 以下示例说明了观测比例与正态分布的理论概率多么接近。 低于平均学生的概率: ```r pnorm(m, m, s) #> [1] 0.5 mean(x <= m) #> [1] 0.515 ``` 平均数在两个标准差范围内的概率: ```r pnorm(76.5, m, s) - pnorm(62.1, m, s) #> [1] 0.954 mean(x > 62.1 & x <= 76.5) #> [1] 0.95 ``` 当使用离散数据的正态近似(例如,当身高四舍五入到最接近的英寸时),请注意`mean(x < a)`和`mean(x <= a)`可能不同。在这种情况下,通常应用*连续性校正*,使用`pnorm(a + delta/2, m, s)`来近似`mean(x <= a)`,其中`delta`是箱宽(在我们的例子中,1 英寸)。然而,在我们的数据集中,一些学生报告了更精确的测量值,因此离散化的程度不同。因此,应该小心地应用正态近似。 ## 2.3 标准单位 对于近似正态分布的数据,用*标准单位*来思考是方便的。一个值的标准化单位告诉我们它离平均值有多少个标准差。具体来说,对于向量`x`中的值`x`,我们定义其在标准单位中的值为 `z = (x - m)/s`,其中 `m` 和 `s` 分别是 `x` 的平均值和标准差。为什么这很方便? 首先,回顾正态分布的公式,并观察正在指数化的值是 $-z²/2$,其中 $z$ 等同于标准单位中的 $x$。因为 $e^{-z²/2}$ 的最大值在 $z = 0$ 处,这解释了为什么分布的最大值出现在平均值处。这也解释了对称性,因为 $- z²/2$ 在 0 处是对称的。其次,请注意,通过将正态分布的数据转换为标准单位,我们可以快速确定,例如,一个人是否大约是平均值($z = 0$)、是否是最高的人之一($z \approx 2$)、是否是最矮的人之一($z \approx -2$),或者是一个极其罕见的情况($z > 3$ 或 $z < -3$)。记住,原始单位是什么并不重要,这些规则适用于任何近似正态的数据。 在 R 中,我们可以使用`scale`函数将数据转换为标准单位: ```r z <- scale(x) ``` 要查看有多少男性在平均值的 2 个标准差范围内,我们只需输入: ```r mean(abs(z) < 2) #> [1] 0.95 ``` 比例大约是 95%,这正是正态分布所预测的! 在统计学中,字母 $z$ 通常用来表示以标准单位表示的值。当提到随机结果而不是特定观察到的值时,我们使用大写 $Z$。 在整本书中,每当描述经过标准化的数量时,我们都会以这种方式使用 $z$ 和 $Z$。这种符号有助于将不同章节中的例子联系起来。 ## 2.4 稳健的总结 请注意,我们在第一章中探讨的高度并非学生报告的原高度。第二个挑战在于探索*原始*报告的高度,这些高度也包含在`dslabs`包中的`reported_heights`对象中。我们将看到,由于报告错误,使用*稳健的总结*是必要的,以产生有用的总结。 ### 异常值 在现实世界的数据分析中,异常值非常常见。数据记录可能很复杂,观察到的数据点可能由于错误而产生。例如,一个旧的监控设备在完全失效之前可能会读出无意义的测量值。人为错误也是异常值的一个来源,尤其是在手动输入数据时。例如,一个人可能会错误地将他们的身高以厘米为单位输入,而不是英寸,或者将小数点放在错误的位置。 我们如何区分异常值和由于预期变异性太大或太小而产生的测量值?这并不总是容易回答的问题,但我们会尝试提供一些指导。让我们从一个简单的案例开始。 假设一位同事负责收集一组男性的人口统计数据。数据报告身高以英尺为单位,并存储在以下对象中: ```r library(dslabs) str(outlier_example) #> num [1:500] 5.59 5.8 5.54 6.15 5.83 5.54 5.87 5.93 5.89 5.67 ... ``` 我们的同事利用了身高通常很好地近似正态分布的事实,并用平均值和标准差总结数据: ```r mean(outlier_example) #> [1] 6.1 ``` 我们的同事撰写了一份报告,指出这个男性群体比平常要高得多。平均身高超过六英尺!然而,使用你的数据分析技能,你可能会注意到一些意想不到的事情:标准差超过 7 英尺。 ```r sd(outlier_example) #> [1] 7.8 ``` 加减两个标准差,你会注意到 95%的这部分人群的身高将在-9.49 至 21.7 英尺之间,这是没有意义的。一个快速的图表揭示了问题: ```r boxplot(outlier_example) ``` ![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/hvd-cs109-intd-ds-2e/img/b9dd27b9683f0c9ee24329450b5b0aa7.png)* *由于我们知道 180 英尺的身高是不可能的,因此似乎至少有一个值是没有意义的。箱线图将这个点检测为异常值。 ### 中位数 异常值可以使平均值任意增大。有 500 个数据点时,增加一个观测值 $\Delta \times$ 500 将使平均值增加 $\Delta$。相比之下,*中位数* 对异常值具有 *稳健性*:无论一个值变得多么极端,只要它不穿过数据的中间,中位数就不会改变。 我们示例数据的中间值是: ```r median(outlier_example) #> [1] 5.74 ``` 这相当于大约 5 英尺 9 英寸。当数据包含异常值时,中位数通常是衡量 *典型* 值的更好指标。 ### 中位数绝对偏差 与异常值可以扭曲平均值一样,它们也可以使标准差误导性地变大。一个更 *稳健* 的分散度度量是 *中位数绝对偏差(MAD)*。 要计算 MAD,我们首先找到中位数,然后计算每个值与中位数的绝对偏差,最后取这些偏差的中位数。为了使它在数据正态分布时与标准差可比,我们将其乘以 1.4826。 (R 中的 `mad` 函数会自动应用这个修正。) 对于我们的身高数据,MAD 是: ```r mad(outlier_example) #> [1] 0.237 ``` 这相当于大约 3 英寸。 当数据包含异常值时,MAD 提供了对变异性的更稳定的估计。在这种情况下,它提供了对数据集 *典型* 分散的更真实的感觉。 ### 四分位距(IQR) 当数据包含异常值或不符合正态分布时,*四分位距(IQR)* 提供了另一种比标准差更稳健的总结数据分散的方法。IQR 表示包含数据 *中间 50%* 的范围,从第 25 百分位数(第一四分位数)到第 75 百分位数(第三四分位数),并且总是包括中位数。 与中位数和 MAD 一样,IQR 对极端值具有抵抗力:无论异常值变得多大或多小,IQR 都基本不受影响。对于正态分布的数据,将 IQR 除以 1.349 可以得到如果没有异常值存在时预期的标准差估计。 在我们的例子中,这个近似效果很好。基于 IQR 的标准差估计为: ```r IQR(outlier_example)/1.349 #> [1] 0.245 ``` 对应大约 3 英寸。 除了总结范围之外,IQR 还允许我们正式定义我们所说的*异常值*的概念,我们将在下一节介绍这个概念。 ### 数据驱动的异常值定义 约翰·图基(John Tukey)提出了一个精确的数据驱动定义的*异常值*,他也是箱线图的开发者,我们将在下一节介绍这种可视化方法。为了构建箱线图,我们首先需要一种正式的方法来识别哪些数据点算作异常值。 让第一和第三四分位数分别表示为 $Q_1 = Q(0.25)$ 和 $Q_3 = Q(0.75)$,图基定义一个观测值如果落在以下范围之外则为*异常值*: $$ [Q_1 - 1.5 \times \text{IQR}, ; Q_3 + 1.5 \times \text{IQR}], \quad \text{其中 } \text{IQR} = Q_3 - Q_1 \]

当数据遵循标准正态分布时,这个范围是:

q3 <- qnorm(0.75); q1 <- qnorm(0.25); iqr <- q3 - q1
c(q1 - 1.5*iqr, q3 + 1.5*iqr)
#> [1] -2.7  2.7

使用pnorm,我们可以计算预期落在这个范围内的数据比例:

r <- c(q1 - 1.5*iqr, q3 + 1.5*iqr)
round((pnorm(r2]) - [pnorm(r[1]))*100, 3)
#> [1] 99.3

因此,对于正态分布,大约 99.302%的数据位于这个区间内。换句话说,我们预计在一个完美的正态数据集中,每 1000 个观测值中大约有 7 个会落在这个范围之外,这不是因为它们是错误的,而是由于随机变化。

为了使定义更严格,图基提出了使用更宽的乘数,例如 3 而不是 1.5,来标记远端异常值。对于正态分布的数据,范围

r <- c(q1 - 3*iqr , q3 + 3*iqr)
round((pnorm(r2]) - [pnorm(r[1]))*100, 4)
#> [1] 100

涵盖了大约 99.9998%的所有值,这意味着大约每百万个观测值中只有两个会落在这个范围之外。在ggplot geom_boxplot() 函数中,这个乘数由coef参数控制,默认为 1.5。

让我们将图基的规则应用于我们的身高数据。极端的 180 英尺测量值远远超出了由 \(Q_3 + 3 \times \text{IQR}\) 定义的范围内:

max_height <- quantile(outlier_example, 0.75) + 3*IQR(outlier_example)
max_height
#>  75% 
#> 6.91

如果我们移除这个值,剩余的数据将遵循一个大约正常的分布,正如预期的那样:

x <- outlier_example[outlier_example < max_height]
hist(x, breaks = seq(5, 7, 2/12))

* *这证明了图基方法的有用性:它提供了一种客观、可重复的方法来检测异常观测值——这种方法能够适应数据的自身分布。

2.5 箱线图

为了介绍箱线图,我们将使用美国各州谋杀案件的数据集。假设我们想要总结谋杀率的分布情况。直方图迅速显示数据呈右偏态,表明正态近似不适用:

虽然直方图提供了有价值的信息,但人们通常更喜欢一个更简洁的数值摘要,它能突出分布的主要特征,而不显示每个数据点。

箱线图*提供了这样的总结。它将数据集压缩成五个数字:最小值、第一四分位数(\(Q_1\))、中位数(\(Q_2\))、第三四分位数(\(Q_3\))和最大值。异常值,即落在上一节中定义的 Tukey 范围之外的值,不包括在范围计算中,而是作为单独的点绘制。

这里是谋杀率数据的箱线图:

箱线图的范围从\(Q_1\)\(Q_3\),一条水平线标记了中位数。胡须延伸到不是异常值的最小和最大观测值,而异常值则显示为单独的点。箱的高度对应于四分位距(IQR),代表数据的中间 50%。

箱线图在比较不同组之间的分布时特别有用,例如,比较不同地区的谋杀率或男性和女性身高分布。它们提供了关于中心、离散度和异常值存在的快速视觉摘要。在下一章中,我们将使用箱线图以这种方式比较组之间的分布。

2.6 练习

  1. 加载身高数据集并创建一个只包含男性身高的向量x
library(dslabs)
x <- heights$height[heights$sex=="Male"]

数据中在 69 至 72 英寸(高于 69 英寸,但不超过 72 英寸)之间的比例是多少?提示:使用逻辑运算符和mean

  1. 假设你只知道数据的平均值和标准差。使用正态近似来估计你刚刚计算的比例。提示:首先计算平均值和标准差。然后使用pnorm函数来预测比例。

  2. 注意到问题 1 中计算的近似值与问题 2 中精确计算的值非常接近。现在对更极端的值执行相同的任务。比较区间(79,81]的精确计算和正常近似。实际比例比近似值大多少倍?

  3. 将世界成年男性的分布近似为平均值为 69 英寸、标准差为 3 英寸的正态分布。使用这个近似值,估计成年男性中身高 7 英尺或更高的比例,称为七英尺高的人。提示:使用pnorm函数。

  4. 世界上大约有 10 亿名 18 至 40 岁的男性。使用你之前问题的答案来估计这些男性(18-40 岁)中在世界范围内身高七英尺或更高的有多少人?

  5. 大约有 10 名身高 7 英尺或更高的国家篮球协会(NBA)球员。使用之前两个问题的答案,世界范围内 18 至 40 岁的七英尺高球员中有多少比例在 NBA?

  6. 重复上一题中进行的计算,针对勒布朗·詹姆斯的身高:6 英尺 8 英寸。大约有 150 名球员身高至少这么高。

  7. 在回答上一题时,我们发现七尺高的人成为 NBA 球员并不罕见。对我们计算的一个公平批评可能是什么:

  8. 练习和天赋是造就伟大篮球运动员的因素,而不是身高。

  9. 正态近似对于身高来说并不合适。

  10. 正态近似往往低估极端值。可能七尺高的人比我们预测的多。

  11. 正态近似往往高估极端值。可能七尺高的人比我们预测的少。

我们将使用HistData包。加载Galton数据并创建一个仅包含儿童身高的向量x

library(HistData)
x <- Galton$child
  1. 计算这些数据的平均值和标准差。

  2. 计算这些数据的均值和中值绝对偏差。

  3. 现在假设高尔顿在输入第一个值时犯了一个错误,忘记使用小数点。你可以通过以下方式模仿这个错误:

x_with_error <- x
x_with_error[1] <- x_with_error[1]*10

这个错误之后平均身高增长了多少英寸?

  1. 这个错误之后标准差增长了多少英寸?

  2. 这个错误之后中位数增长了多少英寸?

  3. 这个错误之后 MAD 增长了多少英寸?

  4. 你如何使用探索性数据分析来检测出错误?

  5. 由于这只是一个众多值中的一个,我们无法检测到这一点。

  6. 我们会看到分布的明显偏移。

  7. 箱线图、直方图或 QQ 图会揭示一个明显的异常值。

  8. 散点图会显示出高水平的测量误差。

  9. 这种错误会导致平均值意外增长多少?编写一个名为error_avg的函数,该函数接受一个值k,并返回将向量x的第一个条目更改为k后的平均值。显示k=10000k=-10000的结果。

  10. 使用dslabs包中的murders数据集。计算每个州的谋杀率。制作一个比较美国各地区的谋杀率的箱线图。

  11. 对于相同的数据库,计算每个地区的中位数和四分位数间距谋杀率。

  12. 我们在heights数据框中查看的身高不是学生报告的原身高。原始报告的身高也包含在dslabs包中的对象reported_heights中。注意,这个数据框中的height列是字符类型,如果我们尝试创建一个包含数值版本的列:

library(tidyverse 
reported_heights <- reported_heights |>
 mutate(original_heights = height, height = as.numeric(height))

我们得到了关于 NAs 的警告。检查导致 NAs 的行并描述为什么会发生这种情况。

  1. reported_heights中添加一个列,表示身高记录的年份。你可以使用lubridate包中的year函数从reported_heights$time_stamp中提取年份。使用as.numericheight列从字符转换为数字。一些身高会被转换为NA,因为它们被错误地输入并包含字符,例如165cm。这些身高本应报告为英寸,但许多显然没有这样做。使用dplyr包中的na_if函数将任何高度异常的条目(低于 54 或高于 72)转换为NA。完成此操作后,按性别和年份分层,并报告错误输入的身高百分比,用NA表示。

  2. 移除在尝试将身高转换为数字时产生NA的条目。按性别计算均值、标准差、中位数和 MAD。你注意到了什么?

  3. 生成箱线图来总结男性和女性的身高,并描述你所看到的内容。

  4. 查看最高的 10 个身高,并对你认为正在发生的事情提出一个假设。

  5. 通过查看 Tukey 认为的“离谱”数据来审查所有不合逻辑的答案,并评论你所看到的错误类型。

3  比较组

原文:rafalab.dfci.harvard.edu/dsbook-part-2/summaries/comparing-groups.html

  1. 汇总统计

  2. 3  比较组

到目前为止,我们一直专注于总结单个变量的分布,描述其中心、离散度和形状。然而,在实践中,数据分析的最常见目标之一是比较:我们想知道一个组与另一个组有何不同,治疗与对照如何比较,或者结果在不同条件下如何变化。比较组通常是揭示关系和理解变异来源的第一步。

我们为描述分布开发的工具,如直方图、箱线图和数值摘要(如均值和中位数),自然地扩展到比较。通过检查这些摘要在子组内或通过比较分布之间的分位数,我们可以揭示单个摘要可能隐藏的模式。

在本章中,我们探讨了比较分布的两个关键技术:

  • 分位数-分位数,提供了一种系统地比较两个分布形状的方法,包括观察数据与理论模型(如正态分布)的差异。

  • 分层,涉及将数据划分为有意义的子组并分别检查每个分布。

这些方法共同构成了我们的描述性工具集,连接了从探索单个变量到分析变量之间关系的过渡。

3.1 分位数-分位数图

比较两个分布的系统方法是比较它们的累积分布函数(CDF)。一种实际的方法是通过它们的分位数

对于一个比例 \(0 \leq p \leq 1\),分布 \(F(x)\)\(p\) 个分位数是值 \(q\),使得 \(F(q) = p\),或者 \(q = F^{-1}(p)\)。例如,\(p = 0\) 给出最小值,\(p = 0.5\) 给出中位数,\(p = 1\) 给出最大值。

符号 \(Q(p) = F^{-1}(p)\) 或等价地 \(q_p = F^{-1}(p)\) 常用于教科书,以表示分布的第 \(p\) 个分位数。

分位数-分位数图(qqplot)*提供了一种可视化的方法来比较两个分布,例如 \(F_1(x)\)\(F_2(x)\),通过绘制一个分布的分位数与另一个分布的对应分位数进行比较。具体来说,如果我们定义 $$ Q_1(p_i) = F_1^{-1}(p_i) \quad \text{和} \quad Q_2(p_i) = F_2^{-1}(p_i) $$ 对于一组概率 \(p_1, \dots, p_m\),那么 QQ 图是通过绘制对 $$ {Q_1(p_i),, Q_2(p_i)}, \quad i = 1, \dots, m. $$ 的对来创建的。这些概率的常见选择是 $$ p_i = \frac{i - 0.5}{n}, \quad i = 1, \dots, n, $$ 其中 \(n\) 是较小数据集的样本大小。减去 \(0.5\) 防止在 \(p = 0\)\(p = 1\) 时评估分位数,对于某些理论分布,如正态分布,这会对应于 \(-\infty\)\(+\infty\)

QQ 图最常见的使用是评估两个分布 \(F_1(x)\)\(F_2(x)\) 是否相似。如果 QQ 图中的点大致沿身份线分布,这表明两个分布具有相似的形状。

示例

到目前为止,我们只考察了男性的身高分布。我们预计女性的分布将与男性相似,但平均身高差异会导致 2-3 英寸的偏移。QQ 图确认了这一点:

library(dslabs)
f <- with(heights, height[sex == "Female"])
m <- with(heights, height[sex == "Male"])
qqplot(f - mean(f), m - mean(m))
abline(0,1)

* *我们看到在图的中部,点位于身份线上,确认了分布相似,除了偏移。然而,对于较小的和较大的分位数,我们看到偏离了线。我们将在第 3.3 节中回到这个观察。

百分位数

百分位数分位数的特殊情况,通常被使用。百分位数是在将 \(p\) 设置为 \(0.01, 0.02, ..., 0.99\) 时获得的分位数。例如,我们将 \(p = 0.25\) 的情况称为第 25 个百分位数,表示低于此值的 25% 的数据。最著名的百分位数是第 50 个,也称为中位数。另一个获得名称的特殊情况是四分位数**,当将 \(p\) 设置为 \(0.25,0.50\)\(0.75\) 时获得。

3.2 使用 QQ 图评估正态性

QQ 图的常见用途是评估数据是否遵循正态分布。

标准正态分布的累积分布函数(CDF)表示为 \(\Phi(x)\),表示一个正态随机变量小于 \(x\) 的概率。例如,\(\Phi(-1.96) = 0.025\)\(\Phi(1.96) = 0.975\)。在 R 中,这可以通过 pnorm 函数计算:

pnorm(-1.96)
#> [1] 0.025

\(\Phi\) 的逆,表示为 \(\Phi^{-1}(p)\),提供了理论分位数。例如,\(\Phi^{-1}(0.975) = 1.96\),我们可以通过 qnorm 计算得到:

qnorm(0.975)
#> [1] 1.96

默认情况下,pnormqnorm 假设为标准正态分布(均值 0,标准差 1)。您可以使用 meansd 参数指定不同的参数:

qnorm(0.975, mean = 69, sd = 3)
#> [1] 74.9

对于观察数据,我们可以使用 quantile 函数计算样本分位数。例如,以下是男性身高的四分位数:

x <- with(heights, height[sex == "Male"])
quantile(x, c(0.25, 0.50, 0.75))
#> 25% 50% 75% 
#>  67  69  72

为了检查这些数据是否近似正态分布,我们可以按照以下步骤构建 QQ 图:

  1. 定义一个比例向量 \(p_1, \dots, p_m\)

  2. 从数据中计算样本分位数。

  3. 从具有与数据相同均值和标准差的正态分布中计算理论分位数。

  4. 将两组分位数相互绘制。

示例:

p <- seq(0.05, 0.95, 0.05)
sample_quantiles <- quantile(x, p)
theoretical_quantiles <- qnorm(p, mean = mean(x), sd = sd(x))
plot(theoretical_quantiles, sample_quantiles)
abline(0, 1)

* *因为点接近身份线,我们得出结论,正态分布为这个数据集提供了一个很好的近似。

如果我们首先标准化数据,代码就会变得简单:

plot(quantile(scale(x), p), qnorm(p))

R 提供了生成与正态分布比较的 q-q 图的内置函数:

qqnorm(x)
qqline(x)

这些函数使用数据 x 的平均值和标准差来定义用于 x 轴值的理论正态分布。换句话说,绘制的线代表如果数据具有与观察样本相同的均值和扩散的正态分布,我们会期望看到的情况。

并且与 ggplot2 相当:

library(ggplot2
heights |> filter(sex == "Male") |>
 ggplot](https://ggplot2.tidyverse.org/reference/ggplot.html)(aes(sample = [scale(height))) + 
 geom_qq() +
 geom_abline()

默认情况下,qqnormgeom_qq 都使用所有 \(n\) 分位数,其中 \(p_i = (i - 0.5)/n\)。由于这种默认行为,大数据集可以产生数千个重叠的点,看起来像一条实线。在这种情况下,手动计算并绘制一个较小的、具有代表性的分位数集,而不是依赖于 qqplotqqnorm 中的默认设置,会更好。

3.3 分层

在数据分析中,我们通常根据与这些观察值相关的一个或多个变量的值将观察值分成组。例如,我们可以根据性别变量将身高值分成组:女性和男性。我们称这种程序为分层,并将结果组称为

层次化在数据可视化中很常见,因为我们通常对变量在不同子组中的分布差异感兴趣。

使用直方图、密度图和 q-q 图,我们已经确信男性身高数据很好地近似于正态分布。在这种情况下,我们向 ET 提供一个非常简洁的总结:男性身高遵循平均值为 69.3 英寸,标准差为 3.6 英寸的正态分布。有了这些信息,ET 将对当他遇到我们的男性学生时可以期待什么有一个很好的了解。然而,为了提供一个完整的图景,我们还需要提供女性身高的总结。

我们了解到,当我们想要快速比较两个或多个分布时,箱线图很有用。以下是女性和男性的身高:

library(tidyverse
library(dslabs)
heights |> ggplot(aes(sex, height, fill = sex)) + geom_boxplot()

* *该图立即显示,男性平均身高高于女性。标准差似乎相似。但是,正态近似也适用于调查收集的女性身高数据吗?我们预计它们将遵循正态分布,就像男性一样。然而,探索性绘图显示,这种近似并不那么有用:

我们看到了对于男性没有看到的东西:密度图有一个第二个峰值。此外,qqplot 显示最高点往往比正态分布预期的要高。最后,我们还在 qqplot 中看到了五个点,表明对于正态分布来说,高度比预期的要短。在向 ET 汇报时,我们可能需要提供一个直方图,而不仅仅是女性身高的平均值和标准差。

我们注意到了我们没有预料到看到的事情。如果我们查看其他女性身高分布,我们发现它们很好地被正态分布所近似。那么为什么我们的女学生不同?我们的班级是女性篮球队的必修课吗?是否有小部分女性声称比实际身高更高?另一个,可能更可能的解释是,在学生用来输入他们身高的表格中,Female是默认性别,一些男性输入了他们的身高,但忘记了更改性别变量。无论如何,数据可视化帮助我们发现了数据中可能存在的潜在缺陷。

关于前五个最小值,请注意这些是:

heights |> filter(sex == "Female") |> 
 top_n(5, desc(height)) |>
 pull(height)
#> [1] 51 53 55 52 52

因为这些是报告的高度,一种可能是学生本意是想输入5'1"5'2"5'3"5'5"

3.4 练习

  1. 研究以下按国家显示的人口规模的箱线图:

哪个大洲的国家人口规模最大?

  1. 哪个大洲的中位人口数量最大?

  2. 非洲的中位人口数量是多少,四舍五入到最近的百万?

  3. 欧洲有多少比例的国家人口低于 1400 万?

  4. 0.99

  5. 0.75

  6. 0.50

  7. 0.25

  8. 如果我们使用对数变换,上面哪个大洲的样本四分位距最大?

  9. 定义包含男性和女性身高的变量如下:

library(dslabs)
male <- heights$height[heights$sex == "Male"]
female <- heights$height[heights$sex == "Female"]

我们有多少个测量值?

  1. 假设我们无法绘制图表,而想并排比较分布。我们不能只是列出所有数字。相反,我们将查看百分位数。创建一个包含female_percentilesmale_percentiles的五行表格,显示每个性别的第 10、30、50、70 和 90 百分位数。然后创建一个包含这两个作为列的数据框。

  2. 使用 qqplot 来证明谋杀率

  3. 移除哥伦比亚特区,然后生成 qqplot 以查看四个区域中的比率是否遵循正态分布。

推荐阅读

原文:rafalab.dfci.harvard.edu/dsbook-part-2/summaries/reading-summaries.html

  1. 摘要统计

  2. 推荐阅读

  • 弗里德曼,D.,皮萨尼,R.,& 普尔维斯,R. (2007). 统计学 (第 4 版). 一种清晰、以概念为先导的分布、变异性和总结的介绍,非常适合直觉和统计思维。

  • 图基,J. W. (1977). 数据探索分析. 关于箱线图、稳健总结和 EDA 哲学的经典来源,为本章强调描述数据奠定了基础。

  • 克利夫兰,W. S. (1993). 可视化数据. 一种实用、以图形为主的分布和模式处理方法;与直方图、密度图和 eCDFs 相得益彰。

  • 霍格林,D. C.,莫斯特勒,F.,& 图基,J. W. (1983). 理解稳健和探索性数据分析. Wiley. 一本旨在以易于理解的方式解释稳健方法的经典编辑集;许多章节都是实用的。

  • 威尔科克斯,R. R. (2017). 稳健估计与假设检验导论 (第 4 版). 学术出版社. 一本包含许多实例的实用、应用性文本。它超越了本章所涵盖的内容,但始于关于中位数、截尾均值、MAD 和稳健替代标准差的章节。后面的章节需要假设检验的知识,我们在本书的统计推断部分进行了介绍。

  • 胡贝尔,P. J.,& 朗切蒂,E. M. (2009). 稳健统计 (第 2 版). Wiley. 更高级,但开篇章节以严谨且易读的方式阐述了稳健性为何重要。对希望深入了解的读者很有用。需要我们本书统计推断和线性模型部分所涵盖的知识。

概率

原文:rafalab.dfci.harvard.edu/dsbook-part-2/prob/intro-to-prob.html

引言

本书的第一部分专注于描述我们已经收集到的数据。但在大多数数据分析项目中,我们所观察到的只是众多可能结果中的一个。如果我们再次收集相同的数据,进行另一次调查或实验,我们不会得到完全相同的结果。这种变化不容忽视,因为它反映了伴随所有真实数据的根本不确定性。

概率是我们用来描述这种不确定性的数学语言。它为推理变化、量化不同结果的可能性以及最终从数据中得出结论提供了基础。我们将学习的每一个推断方法,从置信区间到回归和机器学习,都建立在这些理念之上。

概率论的研究始于机会游戏,其中不确定性既具体又可控:抛硬币、抽牌或下注结果。像 Cardano、Fermat 和 Pascal 这样的数学家在这些环境中开发了计算赔率的第一个正式方法,为后来成为现代概率理论奠定了基础。

在本书的这一部分,我们使用这些机会游戏作为教学工具。它们提供了清晰、定义良好的例子,使抽象概念变得直观。通过使用它们,我们可以专注于概率的逻辑,而无需考虑现实世界数据收集的复杂性。在本书的后续部分,我们将回到数据分析,展示这些相同的原理如何应用于现实世界的不确定性、民意调查、实验和预测问题。

我们将通过简单、具体的例子介绍概率论的基本概念:定义事件、计算概率以及描述随机变量及其分布。我们的重点是直观和计算,而不是数学推导。

在本书的这一部分,我们还把概率理论与计算机模拟联系起来。使用 R 代码和蒙特卡洛方法,我们将学习如何估计概率、探索随机行为,并通过模拟培养对不确定性的直觉。这种基于代码的实用方法将使你能够看到概率如何直接与后续章节中的真实数据相联系。

4  连接数据和概率

原文:rafalab.dfci.harvard.edu/dsbook-part-2/prob/connecting-data-and-probability.html

  1. 概率

  2. 4  连接数据和概率

在本书的这一部分,我们使用机会游戏、抛硬币、抽珠子、旋转轮盘赌来阐述概率原理。这些例子很有价值,因为概率机制是完美已知的。我们可以计算确切的概率,重复实验任意多次,并通过数学或模拟来验证结果。

在现实世界的数据分析中,概率与观察之间的联系很少如此清晰。我们研究的系统,如人、医院、天气、经济和生物过程,都是复杂的。我们使用概率并不是因为这些系统必然是真正随机的,而是因为概率模型提供了一种实用的方法来描述和推理未解释的变异和不确定性。在本章中,我们介绍了一些将现实世界问题与概率模型联系起来的方法,这样你可以在学习以下章节的概率理论时开始识别这些联系。

4.1 现实世界中的概率模型

今天,概率不仅仅基于赌博。我们用它来描述降雨的可能性、疾病的危险或选举预报的不确定性。然而,在机会游戏之外,概率的含义往往不那么明显。在实践中,当我们使用概率来分析数据时,我们几乎总是假设一个概率模型适用。有时这种假设有很好的动机;其他时候,它仅仅是一个方便的近似。

重要的是要记住这一点:当我们的概率模型错误时,我们的结论也可能错误。

下面我们将介绍五种主要方式,我们将概率与数据联系起来。

随机抽样

在民意调查中,我们通常假设回答我们问题的人是感兴趣人群的随机样本。例如,想象将所有有资格选民的电话号码放入一个大袋子中,随机抽取 1,000 个,然后打电话给他们,询问他们支持谁。如果这个过程真正是随机的,我们将介绍的那些统计方法将直接适用。

这种假设在动物或实验室研究中也很常见。当研究人员从繁殖设施订购小鼠时,供应商的货物通常被视为从该品系所有可能小鼠中随机抽取的样本。这使得我们可以以与我们机会游戏非常相似的方式应用概率模型。

随机分配

概率直接与数据相连的第二个场景是随机实验。例如,在临床试验中,患者通常通过掷硬币等方式随机分配到治疗组或对照组。这确保了两组在平均意义上是可比的,并且任何结果差异都可以归因于治疗本身。同样的逻辑也适用于今天被称为 A/B 测试的方法,它在技术和商业环境中广泛用于比较两个版本的产品、网站或算法。无论是在医学还是在线平台上,这些实验都依赖于同样的基本思想:随机分配创造了可以使用概率来推断因果关系的条件。在这些例子中,就像在罐子或硬币的例子中一样,概率与数据之间的联系是明显的。

当假设存在随机性时

在许多现实世界的分析中,数据不是通过随机抽样或控制实验收集的。相反,我们大多数使用的数据集都是便利样本,这些样本是从容易接触到的个体中收集的,而不是从定义良好的总体中随机选择的。例如,包括在线调查的参与者、特定医院的病人、特定班级的学生或研究中的志愿者。

当我们将概率模型拟合到这样的数据时,我们隐含地做出了一个假设,即样本的行为就像它是随机的。这个假设很少被明确陈述,但它却是许多报告结果如\(p\)-值、置信区间或预测概率的分析的基础。这些量都依赖于概率推理,它假定数据可以被视为来自随机过程的抽取。

同样的假设出现在机器学习中。当我们训练一个模型时,我们只使用可用数据的一个子集,并期望模型能够推广到未见过的案例。这种期望依赖于这样一个观点:训练数据在所有实际意义上都是来自与未来数据相同总体的随机样本。然而,在实践中,这些训练集通常是便利样本。

尽管如此,这样的模型和分析通常工作得非常出色,并提供了宝贵的见解。例如,使用观察数据的研究有力地表明吸烟有害健康,而定期锻炼和良好的饮食可以延长寿命。但是,当数据来自便利样本时,需要格外小心。我们概率模型背后的假设变得尤为重要。将便利样本当作简单随机样本处理可能会导致有偏和误导性的结论。

这些偏差有时可以通过统计技术纠正,但这样做需要做出额外的假设。理解这些假设是什么,并批判性地思考它们是否合理,对于进行可靠的数据分析至关重要。本书稍后(见第十九章)将重新探讨这个主题,并展示未经检验的随机性假设如何扭曲结果的实例。

自然变异

最后,许多自然过程即使在没有测量误差或抽样的情况下也看似随机。哪些基因会被继承,基因相同生物体之间的小生物测量差异,股市的波动,一年中的地震次数,某个细胞是否癌变,或者撞击望远镜探测器的光子数量,所有这些都在以看似不可预测的方式变化。在这些情况下,概率作为一种建模工具:一种简洁地描述复杂系统的方法,这些系统的潜在机制过于复杂、混乱或数量众多,无法精确预测。

在这些情况下,概率模型并不暗示自然本身是随机的。相反,它们承认我们预测能力的局限性,并为我们提供了一种有用的方法来总结不确定性。

4.2 设计的作用

由于大多数现实世界的数据并非来自理想的随机过程,我们得出有效结论的能力在很大程度上取决于研究和实验的设计。正如统计学家罗纳德·费希尔著名地指出:“实验结束后咨询统计学家,通常只是要求他进行尸检。”设计是将概率数学与数据收集的混乱现实联系起来的桥梁。

一个设计良好的研究,即使用随机化、适当的抽样和一致的测量,创造了概率推理有意义的条件。没有这个基础,即使是最复杂的分析也可能产生误导性的结果。随机分配有助于隔离因果关系。随机抽样有助于确保代表性。标准化程序减少偏差并使不确定性可量化。

本书没有详细涵盖实验设计,但在进一步阅读部分我们推荐了几本优秀的入门书籍。目前的关键思想是:当我们使用概率来推理数据时,我们隐含地假设数学模型与实际过程之间存在联系。通过仔细设计、随机抽样或随机分配,这种联系越强,我们的结论就越可靠。另一方面,设计不当会削弱这座桥梁,使不确定性难以解释。

4.3 练习

  1. 假设一项全国性民意调查显示,基于对 1,000 人的随机抽样,52%的选民支持某位候选人。a. 解释在这个背景下“随机抽样”的含义。b. 描述至少两种现实抽样过程可能偏离真正随机性的方式。

2. 一项临床试验随机将 500 名参与者分配到治疗组和 500 名分配到对照组。a. 这是不是一个随机抽样的例子?b. 随机分配如何帮助我们得出因果结论?c. 为了使试验结论可以推广到更广泛的群体,必须仍然保持哪些假设?

3. 一位研究人员记录了每位患者的血压两次,并发现两次测量的平均值差异约为 5 毫米汞柱。解释如何将测量误差视为随机性的来源。

4. 你正在研究大学生对咖啡因的摄入量,但你的调查回应主要来自生物专业学生。a. 为什么将这个样本视为“随机”可能会导致误导性的结论?b. 建议至少一种改进数据收集过程代表性的方法。c. 描述一个在提高代表性可能困难或不可能的真实世界场景。

5. 对于以下每一项,确定正在模拟的主要随机来源,是抽样变异测量误差自然变异还是随机分配:a. 从家庭调查中估计失业率。b. 每小时测量一周的空气温度。c. 测试肥料是否增加作物产量。d. 比较不同学校的考试成绩。e. 预测明年将形成多少次飓风。

5  离散概率

原文:rafalab.dfci.harvard.edu/dsbook-part-2/prob/discrete-probability.html

  1. 概率

  2. 5  离散概率

我们首先介绍一些与分类数据相关的基本原则。处理分类数据的特定概率领域被称为离散概率。理解这个主题将帮助我们理解我们稍后将要介绍的用于数值和连续数据的概率理论,这在数据分析中更为常见。由于离散概率在纸牌游戏中非常有价值,我们将使用这些作为说明性例子。

5.1 相对频率

“概率”这个词在日常语言中被广泛使用。然而,回答关于概率的问题往往是困难的,甚至是不可能的。在本节中,我们讨论了概率的数学定义,它允许我们给出对某些问题的精确答案。

例如,如果我在一个瓮里¹里有 2 个红珠子和 3 个蓝珠子(大多数概率书籍都使用这个古老的术语,所以我们也不例外)并且随机抽取一个,抽取到红珠子的概率是多少?我们的直觉告诉我们答案是 2/5 或 40%。可以通过指出有五种可能的结果,其中两种满足“抽取红珠子”这一事件的必要条件来给出一个精确的定义。由于这五种结果发生的概率是相等的,我们得出结论,红珠子的概率是 0.4,蓝珠子的概率是 0.6。

考虑事件概率的一个更直观的方法是将它视为在独立且在相同条件下重复实验时,事件发生的长期比例,实验次数无限。这种解释自然地引出了数据科学中最强大的技术之一,蒙特卡洛模拟,我们将在本章后面探讨。

5.2 概率分布

概率分布是大多数统计模型的基础。它们描述了不同结果的可能性,提供了不确定性的数学总结。我们在数据分析中使用的每个模型,从简单的民意调查例子到复杂的多变量模型,都是建立在关于潜在概率分布的假设之上的。当我们转向连续和多维设置时,这些分布变得更加复杂和抽象。然而,对于离散情况,这个想法是非常直观的:每个可能的结果或类别都根据其相对频率分配一个概率。

如果我们知道不同类别的相对频率,定义分类结果的分布相对简单。我们只需为每个类别分配一个概率。在类似于瓮中珠子的例子中,对于每种珠子类型,它们的比例定义了分布。

例如,如果我们从由 44%民主党人、44%共和党人、10%未决定和 2%绿党人组成的群体中随机抽取可能投票者,这些比例定义了任何给定电话中每个群体的概率。每个电话的概率分布由四个数字定义:Pr(打电话给共和党人) = 0.44,Pr(打电话给民主党人) = 0.44,Pr(打电话给未决定者) = 10%,以及 Pr(打电话给绿党人) = 0.02。

5.3 蒙特卡洛

计算机提供了一种实际执行上述简单随机长期实验的方法:从包含三个蓝色珠子和两个红色珠子的袋子中随机选择一个珠子。随机数生成器允许我们模拟随机选择的过程。

一个例子是 R 中的 sample 函数。我们将在下面的代码中演示其用法。首先,我们使用 rep 函数生成一个罐子:

beads <- rep(c("red", "blue"), times = c(2,3))

然后使用 sample 随机选择一个珠子:

sample(beads, 1)
#> [1] "red"

这一行代码产生一个随机结果。我们希望无限次重复这个实验,但这是不可能的。相反,我们重复实验足够多次,以使结果在实际上等同于无限次重复。这是一个 蒙特卡洛 模拟的例子。

数学统计学家和理论统计学家研究的大部分内容,即本书中未涉及的主题,都与提供“实际上等效”的严格定义有关。此外,他们探索大量实验如何接近极限情况。在本章的后面部分,我们将提供一个确定“足够大”的实用方法。

要执行我们的第一次蒙特卡洛模拟,我们使用 replicate 函数,它允许我们重复相同的任务任意次数。在这里,我们重复随机事件 10,000 次:

set.seed(1986) 
events <- replicate(10000, sample(beads, 1))

这里我们使用 replicate 函数是为了教育目的,但有一个更快的方法来生成这个模拟:

sample(beads, 10000, replace = TRUE)

我们将在下一节中解释。** 我们现在可以验证我们的定义是否与这个蒙特卡洛模拟近似相一致。我们使用 table 来计数结果,而 prop.table 给出比例:

prop.table(table(events))
#> events
#>  blue   red 
#> 0.601 0.399

上述数字代表通过此蒙特卡洛模拟获得的估计概率。统计学理论告诉我们,随着 \(B\) 的增大,估计值将越来越接近 3/5=0.6 和 2/5=0.4。

这是一个简单且不太有用的例子,因为我们可以很容易地从数学上计算概率。蒙特卡洛模拟在难以或无法从数学上计算精确概率时很有用。在深入研究更复杂的例子之前,我们使用简单的例子来展示 R 中可用的计算工具。

设置随机种子

在我们继续之前,我们将简要解释以下重要的一行代码:

set.seed(1986) 

在这本书的整个过程中,我们使用随机数生成器。这意味着所呈现的许多结果可能会因偶然性而改变,表明静态版本的书籍可能显示的结果与您按照代码执行时获得的结果不同。实际上,这是完全可以接受的,因为结果是随机的,并且会因偶然性而改变。然而,如果您想确保每次运行的结果都一致,您可以设置 R 的随机数生成种子为一个特定的数字。在上文中,我们将其设置为 1986。我们不想每次都使用相同的种子,所以我们使用了一种流行的选择种子方法,即年份 - 月份 - 日期。例如,我们在 2018 年 12 月 20 日选择了 1986:\(2018 - 12 - 20 = 1986\)

您可以通过查看文档来了解更多关于设置随机数种子(seed)的信息:

?set.seed

在练习中,我们可能会要求您设置种子以确保您获得的结果与我们期望的完全一致。

有放回和无放回

函数 sample 有一个参数允许我们从罐中选取多个元素。然而,默认情况下,这种选择是无放回的:选中珠子后不会放回袋中。注意当我们随机选择五颗珠子时会发生什么:

sample(beads, 5)
#> [1] "red"  "blue" "blue" "blue" "red"
sample(beads, 5)
#> [1] "red"  "red"  "blue" "blue" "blue"

这导致的结果是一直由三个蓝色珠子和两个红色珠子组成的排列。如果我们要求选择六颗珠子,我们会得到一个错误。

然而,sample 函数可以直接使用,无需使用 replicate,在相同条件下不断重复抽取 1 个 5 颗珠子的相同实验。为此,我们以放回的方式抽样:在选中珠子后将其放回罐中。我们可以通过更改 replace 参数来告诉 sample 做这件事,该参数默认为 FALSE,将其更改为 replace = TRUE

events <- sample(beads, 10000, replace = TRUE)

5.4 组合与排列

大多数大学水平的统计学课程都是从组合数学开始的,这为许多概率计算提供了基础。这些技术让我们能够计算满足某个条件的结果数量,通常是通过计算 排列(当顺序重要时)或 组合(当顺序不重要时)的数量。

为了使这些想法具体化,让我们考虑一个熟悉的场景:纸牌游戏。假设我们想要了解从一副牌中抽取特定手牌的概率。这个例子有助于展示组合数学是如何工作的,以及我们如何使用 R 来执行这些计算。

首先,让我们构建一副牌:

numbers <- c("Ace", "Deuce", "Three", "Four", "Five", "Six", "Seven",
 "Eight", "Nine", "Ten", "Jack", "Queen", "King")
suits <- c("Diamonds", "Clubs", "Hearts", "Spades")
deck <- expand.grid(number = numbers, suit = suits)
deck <- paste(deck$number, deck$suit)

如果我们随机抽取一张牌,抽到国王的概率是 1/13:

mean(deck %in% paste("King", suits))
#> [1] 0.0769

因为牌组包括了所有可能的结果,这个计算给出了确切的概率。由于 mean 将逻辑值转换为 0s 和 1s,结果表示了牌组中国王的比例,即四张牌中的五十二张,这正好对应于理论概率 \(1/13\)

现在让我们介绍两个关键函数:permutations()combinations(),这两个函数来自gtools包。这些函数允许我们根据顺序是否重要(permutations)或不重要(combinations)来枚举从列表中选择物品的所有可能方式。这样的计算在概率论中是基本的,因为它们允许我们在复杂的环境中计算可能结果的数量,例如确定抽取特定扑克牌手或按特定顺序排列牌的几率。纸牌游戏、彩票以及许多其他概率问题都依赖于这些原则来计算精确的概率。对于对数学基础感兴趣的读者,推荐阅读部分列出了探索排列和组合的详细概率教科书的书籍。

这里是一个简单的例子,列举了从三个物品中每次取两个的所有排列:

library(gtools)
permutations(3, 2)
#>      [,1] [,2]
#> [1,]    1    2
#> [2,]    1    3
#> [3,]    2    1
#> [4,]    2    3
#> [5,]    3    1
#> [6,]    3    2

这列出了从数字 1、2 和 3 的所有有序对。请注意,(1,2)和(2,1)被视为不同的。如果顺序不重要,我们使用combinations()

combinations(3, 2)
#>      [,1] [,2]
#> [1,]    1    2
#> [2,]    1    3
#> [3,]    2    3

现在(1,2)和(2,1)被视为相同的结果。

让我们使用这些工具来计算 21 点游戏中“自然 21”的概率,这发生在玩家的前两张牌是一张 A 和一张面牌(杰克、皇后、国王或 10)。由于顺序不重要,我们使用combinations()

aces <- paste("Ace", suits)
facecard <- expand.grid(number = c("King", "Queen", "Jack", "Ten"), suit = suits)
facecard <- paste(facecard$number, facecard$suit)
hands <- combinations(52, 2, v = deck)
mean((hands,1] [%in% aces & hands,2] [%in% facecard) |
 (hands,2] [%in% aces & hands,1] [%in% facecard))
#> [1] 0.0483

蒙特卡洛方法

我们可以通过模拟获得相同的结果。我们不是枚举所有组合,而是反复从牌堆中抽取两张牌,并检查我们是否得到了一个“自然 21”。这是对相同概率的蒙特卡洛近似。

blackjack <- function(){
 x <- sample(deck, 2)
 (x1] [%in% aces & x2] [%in% facecard) | (x2] [%in% aces & x1] [%in% facecard)
}
 results <- replicate(10000, blackjack())
mean(results)
#> [1] 0.0453

两种方法得到的结果几乎相同。组合函数给出精确的概率,而蒙特卡洛模拟提供了一个近似值,随着重复次数的增加,该近似值收敛到相同的值。

5.5 示例

在本节中,我们描述了两个离散概率的流行例子:蒙提·霍尔问题和生日问题。我们使用 R 来帮助说明数学概念。

蒙提·霍尔问题

在 20 世纪 70 年代,有一个名为“让我们来交易”的游戏节目,由蒙提·霍尔担任主持人。在游戏的某个环节,参赛者被要求从三个门中选择一个。其中一个门后面有奖品,而其他两个门后面有山羊,以表明参赛者已经输了。参赛者选择一个门后,在揭示所选门后面是否有奖品之前,蒙提·霍尔会打开剩下的两个门中的一个,并向参赛者展示那个门后面没有奖品。然后,他会问,“你想换门吗?”你会怎么做?

我们可以使用概率来证明,如果你坚持原始的门选择,你赢得奖品的几率仍然是 1/3。然而,如果你切换到另一扇门,你赢得奖品的几率翻倍到 2/3!这可能会让人感觉反直觉。许多人错误地认为两种机会都是 1/2,因为你是在两个选项之间进行选择。你可以在可汗学院²上观看详细的数学解释,或者在维基百科³上阅读。下面,我们使用蒙特卡洛模拟来查看哪种策略更好。请注意,为了教学目的,这段代码写得比应有的要长。

monty_hall <- function(strategy = c("stick", "switch")) {
 strategy <- match.arg(strategy)
 doors <- c("1", "2", "3")
 prize_door <- sample(doors, 1)
 my_pick <- sample(doors, 1)
 show <- sample(setdiff(doors, c(my_pick, prize_door)), 1)
 final <- if (strategy == "stick") my_pick else setdiff(doors, c(my_pick, show))
 final == prize_door
}
B <- 10000
mean(replicate(B, monty_hall("stick")))
#> [1] 0.326
mean(replicate(B, monty_hall("switch")))
#> [1] 0.669

当我们检查代码时,我们注意到以my_pickshow开头的行在我们坚持原始选择时不会影响最终结果。这证实了在这种情况下我们的获胜概率是预期的 1/3。当我们切换时,蒙特卡洛模拟验证了 2/3 的概率。这说明了为什么切换可以提高我们的机会:show行移除了一个肯定不是赢家的门,有效地将其概率转移到了剩余未开启的门上。因此,除非我们的初始选择是正确的,这种情况只发生三分之一的几率,我们通过切换获胜,这种情况发生三分之二的几率。

生日问题

假设你在一个有 50 人的教室里。如果我们假设这是一个随机选择的群体,那么至少有两个人有相同生日的几率是多少?虽然这有些高级,我们可以通过数学方法推断出来。我们稍后会做。这里,我们使用蒙特卡洛模拟。为了简单起见,我们假设没有人出生在 2 月 29 日,这不会显著改变答案。

首先,请注意,生日可以用 1 到 365 之间的数字来表示,因此 50 个生日的样本可以如下获得:

n <- 50
bdays <- sample(1:365, n, replace = TRUE)

为了检查这 50 个人中是否至少有两个人有相同的生日,我们可以使用duplicated函数,该函数在向量的元素是重复项时返回TRUE

any(duplicated(bdays))
#> [1] TRUE

在这种情况下,我们看到确实发生了这种情况;至少有两个人在同一天出生。

为了估计这个群体中共享生日的概率,我们通过重复采样 50 个生日的集合来重复这个实验:

same_birthday <- function(n) any(duplicated(sample(1:365, n, replace = TRUE)))
results <- replicate(10000, same_birthday(50))
mean(results)
#> [1] 0.969

人们往往低估这些概率,所以让我们说我们想利用这个知识来和朋友打赌,一群人中两个人共享生日的可能性。在什么群体规模下,几率会超过 50%?超过 75%?

让我们创建一个查找表。我们可以快速创建一个函数来计算任何群体大小:

compute_prob <- function(n, B = 10000) mean(replicate(B, same_birthday(n)))
n <- seq(1,60)
prob <- sapply(n, compute_prob)

现在我们可以绘制一个图表,显示在大小为\(n\)的群体中两个人有相同生日的估计概率:

plot(n, prob)

* *现在让我们计算确切的概率,而不是依赖于蒙特卡洛近似。使用数学给出精确答案,并且由于我们不需要模拟实验,所以速度更快。我们应用一个常见的概率技巧:不是寻找至少有两个人共享生日的概率,而是计算没有人共享生日的概率。根据乘法规则,第一个人可以拥有任何生日,第二个人必须从剩余的 365 天中的 364 天中选择,以此类推,所有\(n\)个生日都是独特的概率为:

\[1 \times \frac{364}{365}\times\frac{363}{365} \dots \frac{365-n + 1}{365} \]

我们可以编写一个函数来对任何数字进行此操作,并将其作为红色曲线绘制出来:

exact_prob <- function(n) 1 - prod(seq(365, 365 - n + 1)/365)
eprob <- sapply(n, exact_prob)
plot(n, prob)
lines(n, eprob, col = "red")

* *这个图显示蒙特卡洛模拟提供了非常准确的概率估计。如果我们无法计算确切的概率,我们仍然可以准确地估计它们。

5.6 实际中的无穷大

这里描述的理论需要无限次重复实验。在实践中,我们无法做到这一点。在上面的例子中,我们使用了\(B=10,000\)次蒙特卡洛实验,得到了准确的估计。这个数字越大,估计就越准确,直到近似如此之好,以至于你的电脑无法分辨出差异。然而,在更复杂的计算中,10,000 可能远远不够。此外,对于某些计算,10,000 次实验可能从计算上不可行。

虽然在这种情况下我们知道确切的概率是 0.5686997,但在实际场景中,我们事先不知道答案,因此也不知道我们的蒙特卡洛估计是否准确。我们知道\(B\)越大,近似越好。但我们需要多大呢?这实际上是一个具有挑战性的问题,回答它通常需要高级理论统计学培训。

一种实际的方法是检查估计的稳定性。以下例子说明了 25 人小组的生日问题。

B <- 10^seq(1, 5, len = 100)
compute_prob <- function(B, n = 25) mean(replicate(B, same_birthday(n)))
prob <- sapply(B, compute_prob)
plot(log10(B), prob)
abline(h = eprob[25], lty = 2)

* *在这个图中,我们可以看到值在大约 1,000 时开始稳定下来。

5.7 练习

  1. 一个盒子里有 3 个青色球,5 个品红色球和 7 个黄色球。随机抽取一个球。编写蒙特卡洛模拟来估计并确认抽到青色球的概率。

  2. 对于球不是青色的概率,也进行同样的操作?

  3. 而不是只抽取一次,考虑抽取两次。第二次抽取时不将第一次抽取的球放回盒子中。我们称这种抽样为不替换。编写蒙特卡洛模拟来估计并确认第一次抽取是青色而第二次不是青色的概率?

  4. 现在重复这个实验,但这次,在第一次抽取并记录颜色后,将其放回盒子中并摇动盒子。我们称这种抽样为带替换的抽样。编写一个蒙特卡洛模拟来估计和验证第一次抽取是青色而第二次抽取不是青色的概率?

  5. 假设你已经从盒子中抽取了 5 个球,并且都是黄色的。下一个球也是黄色的概率是多少?

  6. 如果你掷一个六面的骰子六次,编写一个蒙特卡洛模拟来估计和验证没有看到 6 的概率?

  7. 两支球队,比如说凯尔特人队和骑士队,正在进行一场七场系列赛。骑士队是一支更强的球队,每场比赛获胜的概率为 60%。凯尔特人队赢得至少一场比赛的概率是多少?

  8. 创建一个蒙特卡洛模拟来验证上一个问题的答案。使用 B <- 10000 次模拟。提示:使用以下代码生成前四场比赛的结果:

celtic_wins <- sample(c(0,1), 4, replace = TRUE, prob = c(0.6, 0.4))

凯尔特人队必须赢得这 4 场比赛中的至少一场。

  1. 两支球队,比如说骑士队和勇士队,正在进行一场七场冠军系列赛。因此,首先赢得四场比赛的队伍将赢得系列赛。两支球队实力相当,因此每场比赛获胜的概率都是 50-50。如果骑士队输掉了第一场比赛,那么他们赢得系列赛的概率是多少?

  2. 使用蒙特卡洛模拟验证上一个问题的结果。

  3. 两支球队,\(A\)\(B\),正在进行一场七场系列赛。球队 \(A\) 比球队 \(B\) 更强,每场比赛获胜的概率为 \(p>0.5\)。给定一个值 \(p\),劣势球队 \(B\) 赢得系列赛的概率可以使用以下基于蒙特卡洛模拟的函数计算:

prob_win <- function(p, B =10000){
 result <- replicate(B, {
 b_win <- sample(c(1,0), 7, replace = TRUE, prob = c(1 - p, p))
 sum(b_win) >= 4
 })
 mean(result)
}

使用函数 sapply 计算概率,称为 ws,对于 p <- seq(0.5, 0.95, 0.025)。然后绘制结果。

  1. 重复上述练习,但现在将概率固定在 p <- 0.75,并计算不同系列长度的概率:1 局最佳,3 局最佳,5 局最佳,等等。具体来说,ns <- seq(1, 25, 2)。提示:使用以下函数。
prob_win <- function(n, p = 0.75, B = 10000){
 result <- replicate(B, {
 b_win <- sample(c(1,0), n, replace = TRUE, prob = c(1 - p, p))
 sum(b_win) >= (n + 1)/2
 })
 mean(result)
}

  1. https://en.wikipedia.org/wiki/Urn_problem↩︎

  2. https://www.khanacademy.org/math/precalculus/prob-comb/dependent-events-precalc/v/monty-hall-problem↩︎

  3. https://en.wikipedia.org/wiki/Monty_Hall_problem↩︎

6 连续概率

原文:rafalab.dfci.harvard.edu/dsbook-part-2/prob/continuous-probability.html

  1. 概率

  2. 6 连续概率

在第 1.3 节中,我们讨论了为什么给每个可能的连续结果(如精确身高)分配频率是不切实际的,因为可能存在无限多个值。同样的想法也适用于在连续尺度上取值的随机结果:每个单独的值概率为零。相反,我们通过概率密度函数来描述它们的行为,这使我们能够计算值区间的概率而不是单个点的概率。

在本章中,我们介绍了连续概率分布的数学框架,并展示了在数据分析中经常出现的一些有用的近似。

6.1 累积分布函数

我们回到使用成年男性学生身高的例子:

library(tidyverse
library(dslabs)
x <- heights |> filter(sex == "Male") |> pull(height)

我们之前定义了经验累积分布函数(eCDF)为

F <- function(a) mean(x <= a)

对于任何值a,给出列表x中小于或等于a的值的比例。

要将经验累积分布函数(eCDF)与概率联系起来,想象随机选择一名男性学生。他身高超过 70.5 英寸的概率是多少?因为每个学生被选中的可能性相同,所以这个概率就是身高超过 70.5 英寸的学生比例。使用 eCDF,我们可以这样计算它:

1 - F(70.5)
#> [1] 0.363

累积分布函数(CDF)是 eCDF 的理论对应物。它不是依赖于观察数据,而是为随机结果\(X\)的值范围分配概率。具体来说,CDF 为任何数\(a\)给出了\(X\)小于或等于\(a\)的概率:

\[F(a) = \Pr(X \leq a) \]

一旦定义了 CDF,我们就可以计算\(X\)落在任何区间内的概率。例如,一个学生身高在\(a\)\(b\)之间的概率是:

\[\Pr(a < X \leq b) = F(b) - F(a) \]

因为我们可以从累积分布函数(CDF)中确定任何事件的概率,它完全定义了连续结果的概率分布。

6.2 概率密度函数

对于大多数连续分布,我们可以用另一个函数\(f(x)\)来描述累积分布函数(CDF),使得

\[F(b) - F(a) = \int_a^b f(x)\,dx \]

这个函数\(f(x)\)被称为概率密度函数(PDF)。

PDF 在离散数据中的角色类似于相对频率分布。关键区别在于,对于连续变量,单个值具有零概率,因此不是将概率分配给特定的结果,而是 PDF 显示了概率如何在 \(x\) 的可能值范围内分布。变量落在某个区间(例如 \([a,b]\))内的概率由曲线在 \(a\)\(b\) 之间的面积给出。因此,PDF 的形状显示了值更可能或不太可能出现的区域,较宽或较高的区域对应于包含更多概率质量的范围。

为了理解为什么我们使用连续函数和积分来描述概率,考虑一个结果可以非常精确测量的情况。在这种情况下,我们可以将相对频率视为与下面图表中柱子的高度成比例:

结果落在某个区间内的概率,例如 \(x \in [0.5, 1.5]\),可以通过计算该范围内柱子的高度之和,并除以所有柱子的总高度来近似。随着测量的精度提高和柱宽变窄,这个总和趋近于连续函数 \(f(x)\) 曲线下的面积。

在柱宽缩小到零的极限情况下,总和与积分是相同的:\(x\) 落在某个区间的概率正好等于 \(f(x)\) 在该区间下的面积。为了使这一点成立,我们定义 \(f(x)\) 使得曲线下的总面积等于 1:

\[\int_{-\infty}^{\infty} f(x) \, dx = 1 \]

这确保了 \(f(x)\) 代表一个有效的 PDF。

PDF 的重要例子是正态分布,它在 第 2.1 节 中介绍。它的概率密度函数是

\[f(x) = \frac{1}{\sqrt{2\pi}\,\sigma} \exp\left(-\frac{1}{2}\left(\frac{x - \mu}{\sigma}\right)²\right) \]

正如我们之前所看到的,它有一个以 \(\mu\) 为中心的钟形曲线,95% 的面积在 \(\mu\)\(2\sigma\) 范围内:

在 R 中,这个函数的 PDF 由 dnorm 提供,相应的 CDF 由 pnorm 提供。

如果一个随机结果被说成是以均值 m 和标准差 s 正态分布,那么它的 CDF 是通过以下方式定义的:

F(a) <- pnorm(a, mean = m, sd = s)

这在实践中特别有用。如果我们愿意假设一个变量,例如身高,遵循正态分布,我们可以在不需要完整数据集的情况下回答概率问题。例如,要找到随机选择的学生身高超过 70.5 英寸的概率,我们只需要样本均值和标准差:

m <- mean(x)
s <- sd(x)
1 - pnorm(70.5, m, s)
#> [1] 0.371

很好——你现有的“理论分布作为近似”部分清晰且概念上合理,但它与你在“正态分布”中已经解释的内容重叠严重(尤其是在你讨论使用连续正态曲线近似离散数据的地方)。

6.3 理论分布作为实际模型

理论分布,如正态分布,是数学上定义的,而不是直接从数据中推导出来的。在实践中,我们使用它们来近似来自复杂或未知过程的实际数据的行为。我们分析的几乎所有的数据集都由离散观测值组成,但许多这些量,如身高、体重或血压,更好地理解为潜在连续变量的测量。

例如,我们的身高数据看起来是离散的,因为数值通常会被四舍五入到最接近的英寸。少数人报告了更精确的公制测量值,而大多数人则四舍五入到整数英寸。因此,将身高视为一个连续变量,其明显的离散性来自四舍五入,这更为现实。

当使用正态分布等连续分布时,我们不再为单个点分配概率,每个确切值都有零概率,而是为区间分配概率。在第二章中,我们展示了如何近似这些理论分布。

关键思想是理论分布作为有用的近似。它们提供了平滑的数学描述,使我们能够计算概率并推理不确定性。尽管实际测量是离散的,但适用的连续近似,如正态分布,使我们能够从分析数据并构建泛化能力强的模型。

6.4 蒙特卡洛模拟

模拟**是一种强大的理解随机性、近似概率以及探索理论模型在实际中如何表现的方法。在数据分析中使用的许多概率模型都是连续分布,它们描述了可以取任何值的范围内的结果。在 R 中,所有从连续分布生成模拟数据的随机数生成函数,都以字母r开头。这些函数构成了一个一致的家族,使我们能够从几乎任何概率分布中进行模拟。在本节中,我们将使用连续分布来展示蒙特卡洛技术,从正态分布开始,扩展到其他常用模型。

正态分布

R 提供了rnorm函数来生成正态分布的结果。它接受三个参数,样本大小、均值(默认为 0)和标准差(默认为 1),并产生遵循正态分布的随机数。

这里是一个我们可以生成类似于我们报告的身高数据的例子:

n <- length(x)
m <- mean(x)
s <- sd(x)
simulated_heights <- rnorm(n, m, s)

不出所料,模拟的身高分布看起来是正态的:

这是 R 中最有用的函数之一,因为它允许我们生成模拟自然变异的数据,并通过蒙特卡洛模拟探索可能偶然发生的后果。

示例:极值

在 800 人中找到一个七英尺高的人有多罕见?假设我们反复随机抽取 800 名男性学生并记录每组中最高的学生。这些最高身高分布看起来像什么?以下蒙特卡洛模拟帮助我们找出答案:

tallest <- replicate(10000, max(rnorm(800, m, s)))

拥有七英尺高的人相当罕见:

mean(tallest >= 7*12)
#> [1] 0.0207

以下是最高身高的人的身高分布:

虽然从理论上推导这个分布是可能的,但并不简单。一旦推导出来,它提供了一种比模拟更快的方法来评估概率。然而,当解析推导过于复杂或不可行时,蒙特卡洛模拟提供了一个实用的替代方案。通过反复生成随机样本,我们可以近似几乎任何统计量的分布,并在理论结果不可用的情况下获得可靠的估计。

其他连续分布

正态分布并非唯一有用的理论模型。在数据分析中经常出现的其他连续分布包括学生t分布、卡方分布、指数分布、伽马分布和贝塔分布。它们在 R 中的简称分别是tchisqexpgammabeta

这些分布中的每一个都有一个相关的r函数,例如rtrchisqrexprgammarbeta,允许你为模拟生成随机样本。这种一致性使得将蒙特卡洛方法应用于各种数据生成过程变得容易。

6.5 计算密度、概率和分位数

除了模拟之外,R 还提供了评估和总结连续分布的伴随函数。基础 R 中的每个分布都遵循一个简单的命名约定:

  • d - 密度函数

  • p - 累积分布函数 (CDF)

  • q - 分位数函数

  • r - 随机数生成

例如,学生t分布使用dtptqtrt。基础 R 包括最常见的连续分布,如正态分布、t分布、卡方分布、指数分布、伽马分布和贝塔分布,但许多额外的分布可以在专门的包如extraDistractuar中找到。尽管有数十种连续分布被使用,但基础 R 提供的分布涵盖了实践中遇到的大多数应用。

6.6 练习

  1. 假设女性身高的分布近似为均值为 64 英寸,标准差为 3 英寸的正态分布。如果我们随机选择一个女性,她身高 5 英尺或更矮的概率是多少?

  2. 假设女性身高的分布近似为均值为 64 英寸,标准差为 3 英寸的正态分布。如果我们随机选择一个女性,她身高 6 英尺或更高的概率是多少?

  3. 假设女性身高的分布近似为均值为 64 英寸,标准差为 3 英寸的正态分布。如果我们随机选择一个女性,她身高在 61 到 67 英寸之间的概率是多少?

  4. 重复上述练习,但将所有内容转换为厘米。也就是说,将包括标准差在内的每个身高乘以 2.54。现在答案是什么?

  5. 注意,当你改变单位时,问题的答案不会改变。这很有道理,因为列表中一个条目与平均值的偏差不受我们使用的单位影响。实际上,如果你仔细观察,你会发现 61 和 67 都是离平均值 1 个标准差。计算一个随机选择的、正态分布的随机变量在平均值 1 个标准差内的概率。

  6. 为了理解解释练习 3、4 和 5 答案的数学原理,假设我们有一个随机变量 \(X\),其平均值为 \(\mu\),标准误差为 \(\sigma\),并且我们询问 \(X\) 小于或等于 \(a\) 的概率。这个概率是:

\[ \mathrm{Pr}(X \leq a) $$ 请记住,根据定义,$a$ 是 $(a - \mu)/\sigma$ 标准差 $s$ 离平均值 $\mu$ 的距离。现在我们从两边减去 $\mu$,然后将两边都除以 $\sigma$: $$ \mathrm{Pr}\left(\frac{X-\mu}{\sigma} \leq \frac{a-\mu}{\sigma} \right) \]

左边的量是一个标准正态随机变量。它具有平均值为 0,标准误差为 1。我们将它称为 \(Z\)

\[\mathrm{Pr}\left(Z \leq \frac{a-\mu}{\sigma} \right) \]

所以,无论单位如何,\(X\leq a\) 的概率与标准正态变量小于 \((a - \mu)/s\) 的概率相同。

如果 m 是平均值,s 是标准误差,以下哪个 R 代码会在每种情况下给出正确答案?

  1. mean(X <= a)

  2. pnorm((a - m)/s)

  3. pnorm((a - m)/s, m, s)

  4. pnorm(a)

  5. 想象男性成年人的分布大约是正态分布,期望值为 69,标准差为 3。第 99 百分位的男性身高是多少?提示:使用 qnorm

  6. 智商分数大约呈正态分布,均值为 100,标准差为 15。假设我们想知道在一个学区内每年出生 10,000 人的情况下,所有高中毕业班中最高智商的分布情况。使用 B = 1000 运行蒙特卡洛模拟,其中每个迭代生成 10,000 个智商分数并记录最高值。然后,创建一个直方图来可视化该地区最高智商的分布。

7  随机变量

原文:rafalab.dfci.harvard.edu/dsbook-part-2/prob/random-variables.html

  1. 概率

  2. 7  随机变量

为了严格研究不确定性,我们需要描述数据在随机性下如何行为的数学工具。本书下一部分的主题统计推断,建立在这些工具之上,以量化不确定性并做出预测。在本章中,我们介绍了随机变量的概念,并通过简单的例子探讨其主要特性。

7.1 定义

统计推断始于随机变量的概念,这是一个数值量,其值取决于随机过程的输出。随机变量通过提供一种用数学方式描述不确定性的方法,将概率论与数据分析联系起来。一旦我们定义了一个随机变量,我们就可以研究其分布,计算其期望值和变异性,并利用它对未来或未观察到的结果做出基于概率的陈述。

在本章中,我们关注随机变量的概率分布完全已知的情况。这些是理想化的环境,例如机会游戏,其中概率是由游戏的规则而不是数据决定的。通过处理这些简单、受控的例子,我们可以理解概率、期望值、标准误差和抽样分布的数学基础。

在后面的章节中,我们将转向现实世界的数据问题,其中潜在分布是未知的。在这些情况下,我们使用统计推断来估计或近似这些分布。这里引入的概率概念为这些推断方法提供了理论基础。

首先,考虑一个简单的离散例子。假设我们从包含红色和蓝色珠子的罐子中随机抽取一个珠子。定义随机变量:

\[X = \begin{cases} 1 & \text{if the bead is blue},\\ 0 & \text{if the bead is red.} \end{cases} \]

在 R 中,我们可以使用我们在第 5.3 节中介绍的代码来模拟这个过程:

beads <- rep(c("red", "blue"), times = c(2, 3))
x <- ifelse(sample(beads, 1) == "blue", 1, 0)

每次我们抽取一个珠子,\(X\)的值可能会改变,因为结果是随机的。在统计教科书中,这种只取 0 和 1 值的变量被称为伯努利随机变量。伯努利试验是许多统计模型的基础,因为许多结果,如成功/失败、是/否或正面/反面,都可以用这种方式表示。

一个经典的例子是在抛掷\(n\)个公平硬币时观察到的正面数量,称为\(S_n\)。我们可以使用模拟生成这个随机变量的一个观测值:

s <- sum(sample(c(0,1), n, replace = TRUE))

我们将在后面的章节中使用这个例子。

并非所有随机变量都是离散的。一些可以取连续的值。例如,随机选择的人的身高或物理测量的结果可以被视为 连续随机变量。例如,我们使用正态分布模拟这样的变量:

x <- rnorm(10, mean = 70, sd = 3)

这里每个 x 的值代表从均值为 70、标准差为 3 的正态分布中抽取的随机变量的一个实现。重复模拟每次会产生略微不同的数字,反映了过程的固有随机性。

这些例子,离散的伯努利变量和一个连续的正态变量,说明了我们在本章中将研究的随机变量的主要类型。理解它们的行为和总结它们的分布是引导我们走向期望值、变异性和最终统计推断的关键步骤。

7.2 随机变量的符号表示

在统计符号中,大写字母表示随机变量,而小写字母表示观察到的值。有时两者同时出现,例如 \(X = x\),其中 \(X\) 是随机的,而 \(x\) 是一个固定值。例如,\(X\) 可能代表掷骰子显示的数字,而 \(x\) 是观察到的结果,1、2、3、4、5 或 6:

\[\mathrm{Pr}(X = x) = 1/6 \quad \text{for } x = 1, \dots, 6. \]

这种符号很方便,因为它让我们可以紧凑地表达概率陈述。一开始可能看起来有些奇怪,因为 \(X\) 代表尚未观察到的结果,我们可以讨论其可能值的可能性,但不能讨论其实现值。一旦收集到数据,我们就观察到 \(X\) 的一个实现,然后根据已经发生的情况推理可能发生的情况。

7.3 随机变量的概率分布

随机变量的概率分布告诉我们每个可能结果的可能性。例如,如果我们掷 \(n\) 个公平的硬币,我们可能会询问观察到 40 个或更少头部的概率。这相当于询问 \(\mathrm{Pr}(S_n \leq 40)\)

如果我们可以定义累积分布函数(CDF)\(F(a) = \mathrm{Pr}(S_n \leq a)\),我们就可以回答任何涉及 \(S_n\) 的概率问题,例如落在区间 \((a, b]\) 内的概率。我们称 \(F\) 为随机变量的 分布函数

我们可以通过蒙特卡洛模拟来近似这个分布函数。例如,以下代码模拟了 100,000 次掷 100 个公平硬币:

n <- 100
s <- replicate(100000, sum(sample(c(0,1), n, replace = TRUE)))

然后我们可以通过计算小于或等于 a 的模拟值的比例来估计 \(\mathrm{Pr}(S_n \leq a)\)

mean(s <= a)

例如,观察到 100 个硬币中有 40 个或更少头部的概率是:

mean(s <= 40)
#> [1] 0.028

我们可以通过直方图可视化 \(S_n\) 的分布,并叠加相应的正态密度曲线以进行比较:

我们看到分布近似为正态分布。QQ 图将证实正态近似提供了非常好的拟合。在下一章(第 8.3 节)中,我们将学习解释为什么这种近似如此之好的理论。实际上,这个结果远远超出了抛硬币的范畴,它广泛适用于许多类型的随机变量的平均值和总和。

如果分布是正态分布,它完全由其平均值和标准差来表征。这两个量在概率论中有特殊的名称:随机变量的期望值标准误。我们将在下面讨论这些内容。

统计理论使我们能够推导出作为从罐子中独立抽取的随机变量的和的随机变量的精确分布。在我们的抛硬币例子中,正面(成功)的数量 \(S_n\) 服从二项分布。因此,蒙特卡洛模拟仅用于说明。

我们可以直接使用 dbinompbinom 来计算概率。例如,要找到 \(\mathrm{Pr}(S_n \leq 40)\)

 pbinom(40, size = n, prob = 1/2)
#> [1] 0.0284

我们也可以使用 rbinom 而不是 replicatesample 来模拟正面数量:

rbinom(100000, n, 1/2)

7.4 期望值和标准误

在统计学教科书中,随机变量 \(X\) 的期望值通常表示为 \(\mu_X\)\(\mathrm{E}[X]\),两者都表示“\(X\) 的期望值”。

直观地说,期望值代表如果随机过程重复多次,则长期平均结果。重复次数越多,观察值的平均值就越接近 \(\mathrm{E}[X]\)

对于具有可能结果 \(x_1,\dots,x_n\) 的离散随机变量,期望值定义为:

\[\mathrm{E}[X] = \sum_{i=1}^n x_i ,\mathrm{Pr}(X = x_i) \]

对于具有概率密度函数 \(f(x)\) 和范围 \((a, b)\) 的连续随机变量,求和变为积分:

\[\mathrm{E}[X] = \int_a^b x f(x), dx \]

如果所有 \(x_i\) 都具有相等的概率 \(1/n\),例如从罐子中均匀抽取时,期望值就简化为算术平均值:

\[\mathrm{E}[X] = \frac{1}{n}\sum_{i=1}^n x_i. \]

例如,在抛硬币的情况下,\(X = 1\) 代表正面,\(0\) 代表反面,

\[\mathrm{E}[X] = 1 \times \Pr(X=1) + 0 \times \Pr(X=0) = 1/2. \]

虽然 \(X\) 只取 0 或 1 的值,但其期望值为 0.5 代表如果实验重复多次,则正面的长期比例:

B <- 10⁶
x <- sample(c(0, 1), B, replace = TRUE)
mean(x)
#> [1] 0.5

标准误*(SE)描述了 \(X\) 与其期望值的典型偏差。它定义为:

\[\mathrm{SE}[X] = \sqrt{\sum_{i=1}^n (x_i - \mathrm{E}[X])² ,\mathrm{Pr}(X = x_i)}. \]

对于具有密度函数 \(f(x)\) 的连续随机变量,这变为:

\[\mathrm{SE}[X] = \sqrt{\int_a^b (x - \mathrm{E}[X])² f(x), dx}. \]

许多教科书在介绍标准误之前先介绍方差。方差是标准误的平方:

\[\mathrm{Var}[X] = \mathrm{SE}[X]². \]

方差在推导中很有用,因为它避免了平方根,但标准误更容易解释,因为它使用与数据相同的单位。*当所有结果 \(x_i\) 都等可能时,标准误简化为标准差:

\[\mathrm{SE}[X] = \sqrt{\frac{1}{n}\sum_{i=1}^n (x_i - \mathrm{E}[X])²}, \quad \mathrm{E}[X] = \frac{1}{n}\sum_{i=1}^n x_i. \]

对于抛硬币:

\[\mathrm{SE}[X] = \sqrt{(1-0.5)² \times 0.5 + (0-0.5)² \times 0.5} = 0.5. \]

因此,一枚硬币的期望值为 0.5,标准误也为 0.5——这是合理的,因为可能的结果是 0 或 1。

我们使用希腊字母 \(\mu\)\(\sigma\) 分别表示期望值和标准误。这种约定反映了它们与 均值 (\(m\)) 和 标准差 (\(s\)) 的联系。然而,我们通常更喜欢使用 \(\mathrm{E}[X]\)\(\mathrm{SE}[X]\) 符号,因为它更清楚地推广到涉及随机变量之和或变换的数学表达式。

7.5 期望值和标准误的关键性质

在处理数据时,我们将频繁使用期望值和标准误的几个数学性质。

  1. 随机变量之和的期望值是每个随机变量期望值的和。我们可以这样写:

\[\mathrm{E}[X_1+X_2+\dots+X_n] = \mathrm{E}[X_1] + \mathrm{E}[X_2]+\dots+\mathrm{E}[X_n] \]

如果 \(X\) 代表从罐子中独立抽取的样本,那么它们都具有相同的期望值。让我们用 \(\mu_X\) 表示期望值,并重新写下方程:

\[\mathrm{E}[X_1+X_2+\dots+X_n]= n\mu_X \]

这是以另一种方式写出我们上面对于抽取之和的结果。

  1. 非随机常数乘以随机变量的期望值等于非随机常数乘以随机变量的期望值。如果我们移动随机变量,期望值也会相应移动。用符号表示更容易解释:

\[\mathrm{E}[aX+b] = a\times\mathrm{E}[X] + b \]

要理解为什么这是直观的,考虑改变单位。如果我们改变随机变量的单位,例如从美元到分,期望值应该以相同的方式改变。

上述两个事实的后果是,从同一罐子中独立抽取的平均值的期望值等于罐子的期望值,再次用 \(\mu_X\) 表示:

\[\mathrm{E}[(X_1+X_2+\dots+X_n) / n]= \mathrm{E}[X_1+X_2+\dots+X_n] / n = n\mu_X/n = \mu_X \]

  1. 独立随机变量之和的方差是每个随机变量方差的和:

\[ \mathrm{Var}[X_1+X_2+\dots+X_n] =\mathrm{Var}[X_1] + \mathrm{Var}[X_2]+\dots+\mathrm{Var}[X_n] $$ 这意味着以下关于独立随机变量之和的标准误的性质: $$ \mathrm{SE}[X_1+X_2+\dots+X_n] = \sqrt{\mathrm{SE}[X_1]² + \mathrm{SE}[X_2]²+\dots+\mathrm{SE}[X_n]² } \]

注意,这个特定的属性不像前三个那样直观,更深入的解释可以在统计学教科书中找到。

4. 非随机常数乘以随机变量的标准误差是非随机常数乘以随机变量标准误差。就像期望一样:

\[\mathrm{SE}[aX] = a \times \mathrm{SE}[X] \]

要理解为什么这是直观的,再次考虑单位。

如果我们通过一个常数移动随机变量,变异性不会改变。因此,它对标准误差没有影响:

\[\mathrm{SE}[aX+b] = a \times \mathrm{SE}[X] \]

3 和 4 的后果是,从同一抽屉中独立抽取的平均值的标准误差是抽屉的标准差除以平方根\(n\)(抽取的数量),称之为\(\sigma_X\):

\[\begin{aligned} \mathrm{SE}[\bar{X}] = \mathrm{SE}[(X_1+X_2+\dots+X_n) / n] &= \mathrm{SE}[X_1+X_2+\dots+X_n]/n \\ &= \sqrt{\mathrm{SE}[X_1]²+\mathrm{SE}[X_2]²+\dots+\mathrm{SE}[X_n]²}/n \\ &= \sqrt{\sigma_X²+\sigma_X²+\dots+\sigma_X²}/n\\ &= \sqrt{n\sigma_X²}/n\\ &= \sigma_X / \sqrt{n} \end{aligned} \]

独立性假设很重要* *给定的方程揭示了实际场景中的关键见解。具体来说,它表明可以通过增加样本大小\(n\)来最小化标准误差,我们可以量化这种减少。然而,这个原则只有在变量\(X_1, X_2, ... X_n\)是独立的时才成立。如果它们不是,估计的标准误差可能会显著偏离。

在第 15.2 节中,我们介绍了相关性的概念,它量化了变量相互依赖的程度。如果\(X\)变量之间的相关系数是\(\rho\),它们平均值的标准误差是:

\[\mathrm{SE}\left[\bar{X}\right] = \sigma_X \sqrt{\frac{1 + (n-1) \rho}{n}} \]

这里的关键观察是,随着\(\rho\)接近其上限 1,标准误差增加。值得注意的是,在\(\rho = 1\)的情况下,标准误差\(\mathrm{SE}[\bar{X}]\)等于\(\sigma_X\),并且它不受样本大小\(n\)的影响。*5. 如果\(X\)是一个正态分布的随机变量,那么如果\(a\)\(b\)是非随机常数,\(aX + b\)也是一个正态分布的随机变量。我们所做的只是通过乘以\(a\)来改变随机变量的单位,然后通过\(b\)来移动中心。

7.6 大数定律

上述结果 4 的一个重要含义是,随着\(n\)的增大,平均值的标准误差越来越小。当\(n\)非常大时,标准误差实际上为 0,抽取的平均值收敛到抽屉的平均值。这在统计学教科书中被称为大数定律或平均定律*****。

平均数法则的误解* 平均数法则有时会被误解。例如,如果你掷硬币 5 次,每次都是正面,你可能会听到有人争论说下一次掷硬币很可能是反面,因为平均数法则:平均来说,我们应该看到 50% 的正面和 50% 的反面。一个类似的论点是,在看到连续五次出现黑色之后,轮盘赌上的红色“应该”出现。然而,这些事件是独立的,所以硬币落在正面的概率是 50%,无论之前 5 次的结果如何。同样的原则也适用于轮盘赌的结果。平均数法则只适用于抽取次数非常多的情况,而不是小样本。在掷一百万次之后,你肯定会看到大约 50% 的正面,无论前五次的结果如何。平均数法则的另一个有趣的误用是在体育比赛中,当电视体育解说员预测一名球员即将成功,因为他们连续几次失败时。

7.7 数据分布与概率分布

在继续之前,区分数据集的分布概率分布是很重要的。

任何数字列表 \(x_1, \dots, x_n\) 或任何数据集,都有一个分布描述了观察到的值的分布情况。我们可以用简单的统计量,如平均值和标准差来总结它:

m <- mean(x)
s <- sd(x)

另一方面,概率分布是一个理论构造,它描述了随机变量的可能值及其可能性。它不依赖于数据。

\(X\) 代表从罐子中随机抽取一个数字时,罐子中的数字列表定义了可能的结果,它们的相对频率定义了 \(X\) 的概率分布。罐子中数字的平均值和标准差对应于随机变量的期望值标准误

这种联系可能会导致混淆:每个数字列表都有一个标准差,每个随机变量都有一个标准误,但随机变量的标准误是其概率分布的标准差,而不是特定数据集的标准差。

7.8 练习

  1. 在美国轮盘赌中,你可以下注黑色或红色。有 18 个红色,18 个黑色和 2 个绿色(0 和 00)。黑色出现的概率是多少?

  2. 在黑色上赢的赔率是 1 美元。这意味着如果你下注 1 美元并且它落在黑色上,你将得到 1 美元,否则你将损失 1 美元。使用 sample 函数创建一个模拟随机变量 \(X\) 的抽样模型来模拟你的收益。

  3. 计算随机变量 \(X\) 的期望值。

  4. 计算随机变量 \(X\) 的标准误。

  5. 现在创建一个随机变量 \(S_n\),它是你在黑色上投注 n = 100 次后的收益总和。模拟 \(S_n\) 的 100,000 个结果。开始你的代码时,使用 set.seed(1) 将种子设置为 1。

  6. \(S_n\) 的期望值是多少?

  7. \(S_n\) 的标准误是多少?

  8. 你最终赢钱的概率是多少?

  9. 在美国轮盘赌中,你也可以投注绿色。有 18 个红色,18 个黑色和 2 个绿色(0 和 00)。绿色出现的几率是多少?

  10. 赢得绿色的赔付是 17 美元。这意味着如果你下注 1 美元并且它落在绿色上,你将得到 17 美元,否则你将损失 1 美元。使用 sample 函数创建一个采样模型来模拟你的收益随机变量 \(X\)

  11. 计算随机变量 \(X\) 的期望值。

  12. 计算随机变量 \(X\) 的标准误差。

  13. 现在创建一个随机变量 \(S_n\),它是你在绿色上投注 n = 100 次后的总收益。模拟 \(S_n\) 的 100,000 个结果。开始你的代码时,使用 set.seed(1) 将种子设置为 1。

  14. \(S_n\) 的期望值是什么?

  15. \(S_n\) 的标准误差是什么?

  16. 你最终赢钱的概率是多少?

8  抽样模型和中心极限定理

原文:rafalab.dfci.harvard.edu/dsbook-part-2/prob/sampling-models-and-clt.html

  1. 概率

  2. 8  抽样模型和中心极限定理

在上一章中,我们介绍了随机变量,并看到了它们的分布、期望值和标准误差如何描述不确定性。在本章中,我们通过引入抽样模型来构建这些想法,这些模型是数据生成过程的数学抽象。然后我们使用中心极限定理(CLT)来近似独立抽取的总和和平均值的分布。通过基于在赌场玩轮盘赌的具体例子,我们展示了如何使用 CLT 来回答诸如“输钱的可能性有多大?”等实际问题。

8.1 抽样模型

许多数据生成过程可以有效地建模为从抽屉中抽取。例如,对可能投票者的民意调查可以看作是从包含整个人口偏好的抽屉中抽取 0(共和党人)和 1(民主党人)。在流行病学中,我们通常假设研究中的个体是从更大的群体中随机抽取的,其中每个抽取对应于个体的结果。同样,在实验研究中,我们将受试者(如蠕虫、苍蝇、老鼠或人类,例如)视为从更广泛的群体中随机抽取的。随机实验遵循相同的逻辑:每个参与者分配到治疗或对照组可以看作是从抽屉中随机抽取。

由于随机抽样和随机分配是数据分析的基础,抽样模型在统计学中无处不在。为了建立直观感觉,我们从一个机会游戏开始,其中随机性是明确的,概率与实际结果之间的联系是完美的。我们现在定义一个具体的例子。

轮盘赌例子

假设一家小赌场雇佣你来决定它是否应该安装轮盘赌。为了简化问题,想象 1,000 个人每人下注一次黑。赌场希望你估计它能期望赚多少钱,或者会损失多少钱,最重要的是,输钱的可能性。如果这个可能性太高,他们将放弃安装轮盘赌。

我们定义一个随机变量 \(S_n\) 来表示在 \(n\) 场游戏后赌场的总赢利。一个轮盘有 18 个红色口袋,18 个黑色口袋和 2 个绿色口袋。结果等同于从这个抽屉中抽取一个球:

color <- rep(c("Black", "Red", "Green"), times = c(18, 18, 2))

每个 1,000 个结果都是从这个抽屉中独立抽取的。如果出现黑色,赌徒赢,赌场输 1 美元(记录为-1)。否则,赌场赢 1 美元。

n <- 1000
x <- sample(ifelse(color == "Black", -1, 1), n, replace = TRUE)

既然我们已经知道了 1 和-1 的比例,我们可以简化:

x <- sample(c(-1, 1), n, replace = TRUE, prob = c(9/19, 10/19))

我们称之为采样模型,因为它通过从容器中重复抽取来表示随机行为。赌场的总赢利,定义为 \(S_n\),仅仅是这些独立抽取的总和:

s <- sum(x)

在推导总赢利分布 \(S_n\) 之前,值得停下来问问我们最初为什么研究像轮盘赌这样的问题。

将采样模型与实际数据联系起来

在不确定性数据分析中,从采样模型的角度思考是至关重要的。它将我们在概率中研究的随机变量与我们实际分析的数据集联系起来。通过将数据想象为从容器、群体或随机化程序中抽取的随机抽取,我们阐明了我们的模型代表什么以及它们依赖于哪些假设。

这种观点也支撑了现代统计学的大部分内容。像置信区间、假设检验和回归这样的工具都依赖于从采样模型中推导出的假设。理解这些假设有助于我们深思熟虑地应用这些方法,并认识到它们的局限性。

赌场游戏使这些想法变得具体,因为它们的采样模型是完全已知的。例如,红色、黑色和绿色口袋的数量是明确的,并且容易模拟。相比之下,现实世界的数据来自更复杂的“容器”,例如身高、收入或健康结果各不相同的人群。当我们收集数据时,我们实际上是从这样的群体中抽取一个随机样本。

这种思维方式构建了统计直觉:每个数据集代表某个潜在过程的样本,我们结论的有效性取决于我们对该过程的假设有多好。

8.2 蒙特卡洛模拟

为了找到赌场亏损的概率,我们需要估计 \(S_n\) 的分布。在概率的语言中,这是 \(\Pr(S_n < 0)\)。如果我们定义分布函数 \(F(a) = \Pr(S_n \leq a)\),我们就可以回答任何关于 \(S_n\) 概率的任何问题。

我们可以通过蒙特卡洛模拟来估计 \(F(a)\),生成许多 \(S_n\) 的实现。以下代码模拟了 1,000 人玩轮盘赌,并重复了 100,000 次这个过程:

n <- 1000
roulette_winnings <- function(n){
 x <- sample(c(-1, 1), n, replace = TRUE, prob = c(9/19, 10/19))
 sum(x)
}
s <- replicate(100000, roulette_winnings(n))

在模拟中,\(S_n \leq a\) 的比例近似于 \(F(a)\)

mean(s <= a)

例如,亏损的概率是:

mean(s < 0)
#> [1] 0.0453

\(S_n\) 的直方图和 qqplot 显示分布近似正态分布,这是一个中心极限定理很快将解释的观察结果。

8.3 中心极限定理

中心极限定理(CLT)表明,当抽取次数(样本大小)很大时,独立抽取之和的分布近似正态分布。由于采样模型描述了如此多的数据生成过程,CLT 是统计学中最重要的结果之一。

在轮盘赌的例子中,容器包含 20 美元的收益和 18 美元的损失,期望值为:

\[\mathrm{E}[X] = (20 -18)/38 = 1/19 \approx 0.05. \]

一般而言,对于两个可能的结果 \(a\)\(b\) 以及概率 \(p\)\((1-p)\)

\[\mathrm{E}[X] = ap + b(1-p). \]

根据第 7.5 节中引入的性质,期望总收益为:

\[\mathrm{E}[S_n] = n\,\mathrm{E}[X]. \]

因此,如果有 1000 人玩,赌场预计平均会赢大约 50 美元。

为了量化不确定性,我们计算标准误差,它衡量 \(S_n\) 围绕其期望值的波动程度。对于一个有两个结果 \(a\)\(b\) 的罐子,标准差可以证明为:

\[\mathrm{SE}[X] = |b - a|\sqrt{p(1-p)}. \]

在轮盘赌的例子中,我们得到:

2*sqrt(90)/19
#> [1] 0.999

因此,总和的标准误差为:

\[\mathrm{SE}[S_n] = \sqrt{n}\,\mathrm{SD}[X], \]

在这种情况下是:

sqrt(n)*2*sqrt(90)/19
#> [1] 31.6

这些理论值与蒙特卡洛结果非常吻合:

mean(s)
#> [1] 52.5
sd(s)
#> [1] 31.6

使用中心极限定理(CLT),我们现在可以计算赌场不亏损的概率,而无需模拟:

pnorm(0, n*(20 - 18)/38, sqrt(n)*2*sqrt(90)/19)
#> [1] 0.0478

这与我们之前的估计相匹配:

mean(s < 0)
#> [1] 0.0453

\((S_n + n)/2\) 代表赌场的获胜次数,并遵循二项分布。因此,可以直接计算 \(\Pr(S_n < 0)\) 的概率:

pbinom(n/2 - 1, size = n, prob = 10/19)
#> [1] 0.0448

然而,二项式结果仅适用于伯努利试验。在许多现实世界的场景中,数据并非二项式分布,但我们仍然希望近似总和或平均值的分布。对于这些情况,中心极限定理(CLT)提供了一个超越伯努利试验的通用框架。

8.4 多大才算大?

中心极限定理(CLT)适用于抽取次数“大”时,但“大”取决于上下文。在许多情况下,30 次抽取就足够得到良好的近似,有时甚至更少。在轮盘赌的例子中,当成功的概率非常小的时候,可能需要更大的样本。

例如,在一个中奖概率小于百万分之一的彩票中,即使有数百万张彩票,也只会产生少数几个赢家。在这种情况下,泊松分布比正态分布提供了更好的近似。虽然我们在这里不涉及其理论,但它通常在概率论教科书中以及维基百科泊松分布上讨论。您可以使用 R 中的dpoisppoisrpois探索泊松分布。

总结来说,中心极限定理(CLT)解释了为什么正态分布在统计学中如此常见,以及为什么许多方法在实践中效果良好。但 CLT 也有局限性:当概率极小或分布高度偏斜时,其他模型,如泊松分布或二项分布,可能更合适。对 CLT 何时适用有直觉是进行原则性、深思熟虑的数据分析的关键。

8.5 练习

1. 在美国轮盘赌中,你也可以赌绿色。有 18 个红色,18 个黑色和 2 个绿色(0 和 00)。创建一个随机变量 \(S_n\),表示你赌绿色 1000 次后的总收益。

2. 计算 \(S_n\) 的期望值?

  1. 如何计算 \(S_n\) 的标准误差?

  2. 使用 CLT 估计你最终赢钱的概率?提示:使用 CLT。

  3. 创建一个生成 10,000 个 \(S_n\) 结果的蒙特卡洛模拟。计算结果列表的平均值和标准差以确认 2 和 3 的结果。开始你的代码时,使用 set.seed(1) 将种子设置为 1。

  4. 现在用蒙特卡洛结果检查你的 4 的答案。

  5. 蒙特卡洛结果和 CLT 近似很接近,但并不那么接近。这可能是怎么回事?

  6. 10,000 次模拟还不够。如果我们做得更多,它们就会匹配。

  7. 当成功的概率较小时,CLT 工作得不是很好。在这种情况下,它是 1/19。如果我们使轮盘赌的次数更多,它们会更好地匹配。

  8. 差异在舍入误差范围内。

  9. CLT 只适用于平均值。

现在创建一个随机变量 \(\bar{X}_n\),它是每场赌注的平均收益,定义为 \(X_n = S_n/n\)。保持 \(n\) = 1,000。

  1. \(\bar{X}_n\) 的期望值是多少?

  2. \(\bar{X}_n\) 的标准误差是多少?

  3. 你最终每场比赛的收益为正的概率是多少?提示:使用 CLT。

  4. 创建一个蒙特卡洛模拟,生成 25,000 个 \(\bar{X}_n\) 的结果,而不是 1,000 个。计算结果列表的平均值和标准差以确认 13 和 14 的结果。开始你的代码时,使用 set.seed(1) 将种子设置为 1。

  5. 现在将你的答案与 14 使用蒙特卡洛结果进行比较。你能对 \(\mathrm{Pr}(\bar{X}_n>0)\)\(\mathrm{Pr}(S_n>0)\) 的 CLT 近似进行比较吗?

  6. 我们现在正在计算平均值而不是总和,所以它们非常不同。

  7. 25,000 次蒙特卡洛模拟并不比 10,000 次好,并且提供了更接近的估计。

  8. 当样本量较大时,CLT 工作得更好。我们从 10,000 增加到 25,000。

  9. 差异在舍入误差范围内。

以下练习受到了 2007-2008 年金融危机周围事件的影响¹。这场金融危机部分是由金融机构出售的某些证券的风险被低估造成的。具体来说,抵押贷款支持证券(MBS)和担保债务凭证(CDO)的风险被严重低估。这些资产以假设大多数房主会按时支付月供的价格出售,而这种情况不会发生的概率被计算为很低。一系列因素导致违约数量远多于预期,这导致了这些证券的价格暴跌。因此,银行损失了大量资金,需要政府救助以避免完全关闭。

  1. 我们讨论过的采样模型更复杂版本也被银行用来确定利率,以及保险公司用来确定保费。为了理解这一点,假设你经营一家小银行,该银行有识别出可以信赖支付款项的潜在房主的记录。事实上,从历史数据来看,你只有 2%的客户在特定年份违约,这意味着他们没有偿还你借给他们的钱。假设你的银行今年将发放 \(n\) = 1,000 份贷款,总额为 180,000 美元。此外,在加上所有成本后,假设你的银行每笔房屋查封损失 \(l\) = 200,000 美元。为了简化,我们假设这包括所有运营成本。在这种情况下,你银行的预期利润 \(S_n\) 是多少?

  2. 注意,之前练习中定义的总损失是由最终求和得到的,这是一个随机变量。每次你运行采样模型代码时,你都会得到不同数量的违约人数,从而导致不同的损失。编写一个采样模型来表示你在第十三部分描述的场景下银行利润 \(S_n\) 的随机变量。

  3. 之前的练习表明,如果你只是无息贷款给每个人,你最终会因为 2%的违约者而亏损。尽管你知道 2%的客户可能会违约,但你不知道是哪些客户,因此你不能将他们剔除。然而,通过向每个人收取一点点额外的利息,你可以弥补由于这 2%造成的损失,并覆盖你的运营成本。你需要向每个借款人收取多少数量 \(x\),才能使你银行的预期利润为 0?假设你不会从违约的借款人那里得到 \(x\)。此外,请注意 \(x\) 不是利率,而是你额外收取的总金额,即 \(x/180000\)利率

  4. 将练习 14 中的样本模型重写,以考虑你收取的利息,并运行蒙特卡洛模拟,以了解当你收取不同利率时利润的分布情况。

  5. 我们实际上不需要蒙特卡洛模拟。根据我们所学到的,中心极限定理(CLT)告诉我们,由于我们的损失是独立抽取的总和,其分布大约是正态分布。利润 \(S_n\) 的期望值和标准误差是多少?将这些值表示为查封概率 \(p\)、贷款数量 \(n\)、每笔查封损失 \(l\) 以及你向每个借款人收取的数量 \(x\) 的函数。

  6. 如果你将 \(x\) 设置为使你的银行收支平衡(预期利润为 0),那么你的银行亏损的概率是多少?

  7. 假设如果你的银行有负利润,它就必须关闭。因此,你需要提高 \(x\) 来最小化这种风险。然而,设定过高的利率可能会导致你的客户选择另一家银行。所以,让我们说,我们希望亏损的概率是 1 比 100。现在 \(x\) 的数量需要是多少?提示:我们希望 \(\mathrm{Pr}(S_n<0) = 0.01\)。注意,你可以向不等式的两边添加或减去常数,概率不会改变:\(\mathrm{Pr}(S_n<0) = \mathrm{Pr}(S_n+k<0+k)\),同样,用正常数的除法:\(\mathrm{Pr}(S_n+k<0+k) = \mathrm{Pr}((S_n+k)/m <k/m)\)。使用这个事实和中心极限定理(CLT)将 \(\mathrm{Pr}(S_n<0)\) 中的不等式左侧转换为标准正态分布。

  8. 我们现在的利率提高了。但这仍然是一个非常具有竞争力的利率。对于你在第 20 题中获得的 \(x\),每笔贷款的预期利润和预期总利润是多少?

  9. 运行一个蒙特卡洛模拟来双重检查 19 和 20 中使用的理论近似。

  10. 你的一个员工指出,由于银行每笔贷款都在盈利,银行应该发放更多贷款!为什么只限制在 \(n\) 上?你解释说找到那些 \(n\) 个客户是项工作。你需要一个可预测的群体,以保持违约的可能性低。然后员工指出,即使违约的可能性更高,只要我们的预期值是正的,通过增加 \(n\) 并依赖大数定律,你可以最小化亏损的可能性。假设违约概率是两倍高,即 4%,你将利率设定为 5%,即 \(x\) = $9,000,每笔贷款的预期利润是多少?

  11. 我们需要将 \(n\) 增加多少才能确保亏损的概率仍然小于 0.01?

  12. 使用蒙特卡洛模拟确认第 23 题的结果。

  13. 根据这个公式,发放更多贷款会增加你的预期利润并降低亏损的可能性!发放更多贷款似乎是个不言而喻的选择。因此,你的同事决定离开你的银行,自己创办一家高风险的抵押贷款公司。几个月后,你的同事的银行破产了。一本书被写出来,最终,电影《大空头》和《大额交易》被制作出来,回顾了你的朋友以及许多其他人犯下的错误。发生了什么?

你的同事的方案主要基于这个数学公式 \(\mathrm{SE}[\bar{X}]= \sigma_X / \sqrt{n}\)。通过使 \(n\) 变大,我们最小化了每笔贷款利润的标准误差。然而,为了使这个规则成立,\(X\) 必须是独立的抽取:一个人的违约必须与其他人的违约独立。

要构建一个比你的同事运行的原版更现实的模拟,让我们假设有一个全球事件影响所有高风险抵押贷款持有者,并同时改变他们的违约概率。我们将假设有 50-50 的几率,所有违约概率略微增加或减少到 0.03 和 0.05 之间。然而,这种变化是普遍发生的,影响所有人,而不仅仅是某一个人。由于这些抽取不再独立,我们用于随机变量总和标准误差的方程不再适用。使用这个模型为你的总利润编写一个蒙特卡洛模拟。

  1. 使用第 25 题的模拟结果来报告预期利润、亏损的概率以及亏损超过 1000 万美元的概率。研究利润的分布,并讨论错误的假设如何导致灾难性的结果。

  1. https://en.wikipedia.org/w/index.php?title=Financial_crisis_of_2007–2008↩︎

  2. https://en.wikipedia.org/w/index.php?title=Security_(finance)↩︎

推荐阅读

原文:rafalab.dfci.harvard.edu/dsbook-part-2/prob/reading-prob.html

  1. 概率

  2. 推荐阅读

  • 费舍尔,R. A.(1935). 《实验设计》. 爱丁堡:Oliver & Boyd

    一本介绍随机化和实验控制原则的基础性文本,构成了现代统计设计的基石。

  • 蒙哥马利,D. C.(2017). 《实验设计与分析》(第 9 版). Wiley

    对实验设计的一个全面且易于理解的现代处理,强调应用和解释。

  • 大卫·弗里德曼,罗伯特·皮萨尼和罗杰·珀维斯,《统计学》。

    本书强调清晰的推理、真实案例和概念洞察,而不是公式记忆。

  • 《概率论入门》舍洛德·罗斯著 - 一本广泛使用的本科教材,通过清晰的解释和广泛的应用练习建立了坚实的基础。

  • 《概率论导论》约瑟夫·K. 布利茨斯坦和杰西卡·黄著 - 一本引人入胜、以案例驱动的入门书籍,强调问题解决和直觉。

  • 《概率论及其应用(第一卷)》威廉·费勒著 - 一部严谨的经典著作,对主题进行了更深层次和更正式的处理。

统计推断

原文:rafalab.dfci.harvard.edu/dsbook-part-2/inference/intro-inference.html

统计推断是统计学的一个分支,致力于区分由信号产生的模式和由偶然产生的模式。这是一个广泛的话题,在本节中,我们使用民意调查作为激励示例来回顾基础知识。为了说明这些概念,我们用蒙特卡洛模拟和 R 代码补充数学公式。我们通过使用选举预测作为案例研究来展示这些概念的实际价值。对于已经熟悉统计推断理论并对案例研究感兴趣的读者,可以专注于 12 分层模型。

在 2008 年总统大选的前一天,内特·西尔弗的 FiveThirtyEight 网站表示,“巴拉克·奥巴马似乎准备着赢得决定性的选举胜利”。他们更进一步预测,奥巴马将以 349 票对 189 票的选举人票数获胜,以及以 6.1%的得票率赢得普选票。FiveThirtyEight 还附上了概率声明,声称奥巴马有 91%的胜选机会。预测非常准确,因为最终结果中,奥巴马以 365 票对 173 票赢得了选举人团,以及以 7.2%的差距赢得了普选票。他们在 2008 年的表现使 FiveThirtyEight 引起了政治评论员和电视名人的注意。四年后,在 2012 年总统大选的前一周,尽管许多专家认为最终结果会更加接近,FiveThirtyEight 的内特·西尔弗仍然给奥巴马 90%的胜选机会。政治评论员乔·斯卡伯勒在他的节目中¹说:

任何认为这场选举现在不是势均力敌的人都是如此意识形态化的……他们只是在开玩笑。

内特·西尔弗通过推特做出了回应:

如果你认为这是一场势均力敌的比赛,那我们就来赌一把。如果奥巴马获胜,你就向美国红十字会捐赠 1000 美元。如果罗姆尼获胜,我就捐。怎么样?

奥巴马赢得了选举。

在 2016 年,西尔弗并不那么确定,只给了希拉里·克林顿 71%的胜选机会。相比之下,许多其他预测者几乎确信她会获胜。但她输了。但 71%仍然多于 50%,那么西尔弗先生是不是错了?而且在这个背景下概率到底意味着什么?

在本书的这一部分,我们展示了如何将前一部分的概率概念应用于开发统计方法,使民意调查成为理解公众舆论的有效工具。尽管在美国,普选票并不决定总统选举结果,但我们将其作为一个简单且具有说明性的例子来介绍统计推断的核心思想。

我们首先学习如何定义流行票数的估计值和误差范围,以及这些如何自然地导致置信区间。然后,我们通过聚合多个民调机构的数据来扩展这个框架,以检验传统模型的局限性并探索改进方法。为了解释关于候选人获胜可能性的概率性陈述,我们引入了贝叶斯建模,最后,我们通过层次模型将这些想法结合起来,构建了一个简化的 FiveThirtyEight 选举模型,并将其应用于 2016 年选举。

我们以两个广泛教授的主题作为总结,虽然它们不是案例研究的必需内容,但对于统计实践至关重要:统计功效和 p 值。本部分以对自助法的简要介绍结束,这是一种我们将在机器学习部分重新探讨的推断方法。


  1. https://www.youtube.com/watch?v=TbKkjm-gheY↩︎

9 估计和置信区间

原文:rafalab.dfci.harvard.edu/dsbook-part-2/inference/estimates-confidence-intervals.html

  1. 统计推断

  2. 9 估计和置信区间

民意调查自 19 世纪以来就已经进行。一般目标是在特定主题上描述特定人群持有的观点。在最近的时代,这些调查在美国总统选举期间变得特别普遍。当从逻辑上不可能采访整个群体时,民意调查是有用的。一般策略包括采访一个较小、随机选择的群体,然后从这一子集推断整个群体的观点。

统计理论,也称为推断,为这一过程提供了合理的框架。在本书的这一部分,我们描述了统计推断的基础,并展示了它们如何在民意调查中应用。特别是,我们解释了民意调查员如何使用置信区间和误差范围来量化其估计的不确定性,并报告反映数据所能揭示范围的结果。

也许最著名的民意调查是那些用于确定在特定选举中选民偏好的候选人的调查。政治策略家广泛使用此类调查来决定如何分配资源,例如确定在动员选民的努力中要针对哪些地区。

选举是特别有趣的民意调查例子,因为选举日会揭示整个公众的真实观点。当然,进行真正的选举需要花费数百万美元,这使得民意调查成为那些寻求预测结果的人的一种成本效益高的替代方案。新闻机构也对这些预测投入了巨大的兴趣,鉴于公众对选举结果早期洞察的需求。

9.1 民意调查的抽样模型

我们首先将概率论与使用民意调查来了解人口的任务联系起来。

尽管通常政治候选人的民意调查结果被保密,但新闻机构也会进行民意调查,因为结果往往对公众感兴趣并公开。我们最终将查看这些公开数据集。

Real Clear Politics¹ 是一个新闻聚合器,它组织和发布民意调查结果。例如,他们展示了以下民意调查结果,报告了 2016 年总统选举的民调估计²:

民调 日期 样本 MoE 克林顿 特朗普 差距
RCP 平均 10/31 - 11/7 -- -- 47.2 44.3 克林顿 +2.9
彭博社 11/4 - 11/6 799 LV 3.5 46.0 43.0 克林顿 +3
经济学家 11/4 - 11/7 3669 LV -- 49.0 45.0 克林顿 +4
IBD 11/3 - 11/6 1026 LV 3.1 43.0 42.0 克林顿 +1
ABC 11/3 - 11/6 2220 LV 2.5 49.0 46.0 克林顿 +3
FOX 新闻 11/3 - 11/6 1295 LV 2.5 48.0 44.0 克林顿 +4
蒙茅斯 11/3 - 11/6 748 LV 3.6 50.0 44.0 克林顿 +6
CBS 新闻 11/2 - 11/6 1426 LV 3.0 47.0 43.0 克林顿 +4
洛杉矶时报 10/31 - 11/6 2935 LV 4.5 43.0 48.0 特朗普 +5
NBC 新闻 11/3 - 11/5 1282 LV 2.7 48.0 43.0 克林顿 +5
NBC 新闻 10/31 - 11/6 30145 LV 1.0 51.0 44.0 克林顿 +7
麦克拉奇 11/1 - 11/3 940 LV 3.2 46.0 44.0 克林顿 +2
路透社 10/31 - 11/4 2244 LV 2.2 44.0 40.0 克林顿 +4
GravisGravis 10/31 - 10/31 5360 RV 1.3 50.0 50.0 平局

让我们对上表进行一些观察。首先,注意不同民意调查,尽管都是在选举前几天进行的,但报告了不同的差距:两个候选人支持率的估计差异。注意,报告的差距围绕着最终成为实际结果的情况:克林顿以 2.1% 的优势赢得了普选票。此外,我们看到了一个标题为MoE的列,它代表误差范围。我们将了解这是什么意思。

动机示例:民意调查竞赛

为了帮助我们理解民意调查与我们所学知识之间的联系,让我们构建一个与民意调查者面临的情况类似的情况。为了模拟民意调查者在与其他民意调查者争夺媒体关注时所遇到的挑战,我们将使用一个装满珠子的罐子来代表选民,并假装我们在争夺 25 美元的奖金。挑战是猜测这个罐子(在这种情况下,是一个腌黄瓜罐)中蓝色和红色珠子比例的差距:

在做出预测之前,你可以从罐子中抽取一个样本(有放回)。为了反映进行民意调查的成本,每次抽取珠子你需要支付 0.10 美元。因此,如果你的样本大小是 250,并且你赢了,你将收支平衡,因为你已经支付了 25 美元来收集你的 25 美元奖金。你的参赛方式可以是一个区间。如果你提交的区间包含真实比例,你将获得你支付的一半,并进入比赛的第二阶段。在第二阶段,选择区间最小的参赛者作为获胜者。

dslabs** 包含一个函数,可以显示从这个罐子中进行的随机抽取:

library(tidyverse
library(dslabs)
take_poll(25)

* *思考一下你将如何根据上述数据构建你的区间。

我们刚刚描述了一个简单的民意调查采样模型。在这个模型中,瓮中的珠子代表将在选举日投票的个人。红色珠子代表投票给共和党候选人的人,而蓝色珠子代表民主党人。为了简化,让我们假设没有其他颜色;也就是说,只有两个政党:共和党和民主党。

9.2 总体、样本、参数和估计

我们想要预测瓮中蓝色珠子的比例。让我们称这个量为 \(p\),这样它就告诉我们红色珠子的比例 \(1-p\),以及范围 \(p - (1-p)\),这简化为 \(2p - 1\)

在统计教科书中,瓮中的珠子被称为 总体。总体中蓝色珠子的比例 \(p\) 被称为 参数。我们在前面的图中看到的 25 个珠子被称为 样本。统计推断的目标是根据样本中的观察数据预测参数 \(p\)

我们能否用上面的 25 个观察结果来做这件事?这当然是有信息的。例如,鉴于我们看到 13 个红色珠子和 12 个蓝色珠子,\(p\) > .9 或 \(p\) < .1 的可能性似乎不大。但我们是否准备好确信罐子里红色珠子比蓝色珠子多?

我们希望仅使用我们观察到的信息来构建对 \(p\) 的估计。估计应该被视为观察数据的总结,我们认为它对感兴趣的参数具有信息性。直观地认为,样本中蓝色珠子的比例 \(0.48\) 必须至少与实际比例 \(p\) 有关。但我们是否简单地预测 \(p\) 为 0.48?首先,记住样本比例是一个随机变量。如果我们运行命令 take_poll(25) 四次,每次都会得到不同的答案,因为样本比例是一个随机变量。

图片

注意到在上述四个随机样本中,样本比例的范围从 0.44 到 0.60。通过描述这个随机变量的分布,我们将能够了解这个估计有多好,以及我们如何改进它。

样本平均作为估计值

民意调查的进行被建模为从瓮中抽取随机样本。我们建议使用样本中蓝色珠子的比例作为参数 \(p\)估计值。一旦我们有了这个估计值,我们就可以轻松地报告范围 \(2p-1\) 的估计值。然而,为了简化,我们将说明估计 \(p\) 的概念。我们将使用我们对概率的了解来证明我们使用样本比例的合理性,并量化其与总体比例 \(p\) 的接近程度。

我们首先定义随机变量 \(X\)\(X=1\),如果我们随机抽取一个蓝色珠子,如果它是红色,则 \(X=0\)。这意味着总体是一个由 0 和 1 组成的列表。如果我们抽取 \(N\) 个珠子,那么抽取的平均值 \(X_1, \dots, X_N\) 等价于样本中蓝色珠子的比例。这是因为将 \(X\) 相加等价于计数蓝色珠子,将这个计数除以总数 \(N\) 等价于计算比例。我们用符号 \(\bar{X}\) 来表示这个平均值。在统计学教科书中,符号上方的横线通常表示平均值。

我们刚刚讨论的关于抽取总和的理论变得有用,因为平均值是抽取总和乘以常数 \(1/N\)

\[\bar{X} = \frac{1}{N} \sum_{i=1}^N X_i \]

为了简化,让我们假设抽取是独立的;在看到每个抽取的珠子后,我们将其放回罐子中。在这种情况下,我们知道抽取总和的分布是什么?首先,我们知道抽取总和的期望值是罐中数值平均值的 \(N\) 倍。我们知道罐中 0 和 1 的平均值必须是 \(p\),即蓝色珠子的比例。

在这里,我们遇到了与书中概率部分所做不同的重要区别:我们不知道罐子的组成。虽然我们知道有蓝色和红色珠子,但我们不知道每种颜色的数量。这正是我们想要找到的:我们正在尝试估计 \(p\)

参数

正如我们使用变量来定义方程组中的未知数一样,在统计推断中,我们定义参数来表示我们模型中的未知部分。在我们用来模拟民意调查的罐子模型中,我们不知道罐中蓝色珠子的比例。我们定义参数 \(p\) 来表示这个数量。由于我们的主要目标是确定 \(p\),我们将估计这个参数

这里介绍的是如何估计参数的概念,以及如何评估这些估计的好坏,这些概念可以扩展到许多数据分析任务中。例如,我们可能想要确定接受治疗的患者与对照组之间健康改善的差异,调查吸烟对人群的健康影响,分析警察致命射击中种族群体之间的差异,或者评估过去 10 年美国预期寿命变化率。所有这些问题都可以被构建为从样本中估计参数的任务。

入门统计学教材通常首先介绍总体平均数作为参数的第一个例子。在我们的情况下,感兴趣的参数 \(p\) 被定义为罐中 1(蓝色)的比例。请注意,这个比例也等于罐中所有数字的平均值,因为 1 和 0 可以被视为数值。

这意味着我们的参数 \(p\) 可以被解释为总体平均数。因此,我们将使用 \(\bar{X}\) 来表示其估计值,这是从抽屉中抽取样本计算出的平均值。尽管许多教科书使用 \(\hat{p}\) 来表示这个估计值,但符号 \(\bar{X}\) 更好地强调了样本平均值与总体均值之间的联系,这个概念可以自然地扩展到二元数据以外的情形。

9.3 估计属性:期望值和标准误差

为了了解我们的估计有多好,我们将描述上面定义的随机变量的统计特性:样本比例 \(\bar{X}\)。记住,\(\bar{X}\) 是独立抽取的总和除以一个非随机常数,因此我们之前讨论的规则第 7.4 节适用。

应用我们学到的概念,我们可以证明:

\[\mathrm{E}[\bar{X}] = p \]

我们还可以使用我们学到的知识来确定标准误差:$$ \mathrm{SE}[\bar{X}] = \sqrt{p(1-p)/N} $$

这个结果揭示了调查的力量。样本比例的期望值 \(\bar{X}\) 是我们感兴趣的参数 \(p\),我们可以通过增加 \(N\) 来使标准误差尽可能小。大数定律告诉我们,随着样本量的增大,我们的估计会收敛到 \(p\)

如果我们抽取足够大的样本,使标准误差大约为 1%,我们将非常确信谁会获胜。但是,调查需要多大才能使标准误差如此之小?

一个问题是,我们不知道 \(p\),因此我们实际上无法计算标准误差。然而,为了说明目的,让我们假设 \(p=0.51\) 并绘制标准误差与样本量 \(N\) 的关系图:

图表显示,我们需要一个超过 10,000 人的调查才能达到如此低的标准误差。我们很少看到这么大规模的调查,部分原因是相关的成本。根据 Real Clear Politics 表格,民意调查的样本量范围在 500-3,500 人之间。对于样本量为 1,000 且 \(p=0.51\) 的情况,标准误差是:

sqrt(p*(1 - p))/sqrt(1000)
#> [1] 0.0158

或者 1.5 个百分点。因此,即使在大规模调查中,对于竞争激烈的选举,如果我们没有意识到 \(\bar{X}\) 是一个随机变量,它也可能误导我们。然而,我们实际上可以更多地了解我们接近 \(p\) 的程度,我们将在第 9.4.1 节中这样做。

民意调查与预测** 在我们继续之前,重要的是要澄清一个与预测选举相关的实际问题。如果在选举前四个月进行民意调查,它是在估计那个时刻的 \(p\),而不是选举日的 \(p\)。选举之夜的 \(p\) 可能会不同,因为人们的观点往往会随时间波动。通常,选举前一晚进行的民意调查最准确,因为观点在一天内不会发生显著变化。然而,预报员试图开发工具来模拟观点随时间的变化,并试图通过考虑这些波动来预测选举之夜的结果。我们将在第十二章中探讨一些实现这一目标的方法。

9.4 置信区间

民意调查员使用一个简单易懂的数字来总结不确定性,这个数字被称为误差范围。误差范围与参数的估计值一起定义了一个区间,他们对此区间有信心地认为它包含了真实值。但“有信心”实际上意味着什么呢?

为了将这个想法与一个熟悉的问题联系起来,回想一下第 9.1 节中描述的竞赛,其中你被要求报告一个真实比例 \(p\) 的区间。如果你的区间包含了实际的 \(p\),你就能收回一半的民意调查成本并进入下一轮。一种保证进入下一轮的方法是报告一个非常宽的区间,比如 \([0,1]\),这肯定包含 \(p\)。但这样的区间是没有用的,因为它没有传达任何信息,你可能会输给提交更窄区间的人。同样,一个预测选举范围在 -100% 到 100% 之间的选举预报员是不会被认真对待的。即使范围在 -10% 到 10% 之间也会过于模糊而失去意义。

另一方面,一个非常窄的区间是有风险的。一个报告非常紧密区间但经常错过真实值的民意调查员会很快失去信誉。目标是找到一个平衡点:区间足够窄以提供信息,但足够宽以可靠。

统计理论提供了一种方法,使用之前开发的概率框架来精确量化我们所说的置信度。具体来说,我们可以构建一个区间,我们可以计算包含真实参数 \(p\) 的概率。当一个民意调查员报告一个估计值以及一个误差范围时,他们实际上是在报告一个有 95% 概率包含真实参数的区间,这被称为95% 置信区间

中心极限定理

在第九章中,我们介绍了样本平均值 \(\bar{X}\) 作为参数 \(p\) 的估计值,并展示了如何计算其标准误差。然而,为了计算概率,我们需要 \(\bar{X}\)分布

在书的 概率 部分(第 8.3 节),我们学习了当样本量很大时,从总体中独立抽取的平均值近似正态分布,无论总体的形状如何。这是 中心极限定理 (CLT)。因为 \(\bar{X}\) 是独立抽取的平均值,CLT 直接适用于这里,并且它是构建置信区间的主要工具。

让我们用它来回答一个具体问题:

我们的样本估计值 \(\bar{X}\) 在真实总体比例 \(p\) 的 2% 以内有多大的可能性?

我们可以表示为:

\[\Pr(|\bar{X} - p| \leq 0.02) = \Pr(\bar{X} \leq p + 0.02) - \Pr(\bar{X} \leq p - 0.02). \]

如果我们对 \(\bar{X}\) 进行标准化,即减去其期望值并除以其标准误差,我们得到一个标准正态随机变量 \(Z\)

\[Z = \frac{\bar{X} - \mathrm{E}[\bar{X}]}{\mathrm{SE}[\bar{X}]}. \]

由于 \(\mathrm{E}[\bar{X}] = p\)\(\mathrm{SE}[\bar{X}] = \sqrt{p(1-p)/N}\),上面的概率变为:

\[\Pr\left(Z \leq \frac{0.02}{\sqrt{p(1-p)/N}}\right) - \Pr\left(Z \leq -\frac{0.02}{\sqrt{p(1-p)/N}}\right). \]

为了计算这个,我们需要 \(\sqrt{p(1-p)/N}\),但 \(p\) 是未知的。幸运的是,如果我们使用 插值估计,用观察到的 \(\bar{X}\) 替换 \(p\),CLT 仍然成立:

\[\widehat{\mathrm{SE}}[\bar{X}] = \sqrt{\bar{X}(1 - \bar{X})/N}. \]

在统计学中,帽子符号表示估计值。例如,\(\hat{p}\) 通常用来代替 \(\bar{X}\) 表示 \(p\) 的估计值。同样,\(\widehat{\mathrm{SE}}\) 表示我们正在估计标准误差而不是精确计算它。* 使用我们之前的民意调查,其中 \(\bar{X} = 0.48\)\(N = 25\),我们得到:

x_hat <- 0.48
se <- sqrt(x_hat*(1 - x_hat)/25)
se
#> [1] 0.0999

现在我们可以计算我们的估计值在真实值 2% 以内的概率:

pnorm(0.02/se) - pnorm(-0.02/se)
#> [1] 0.159

\(N = 25\) 时,这种情况发生的可能性很小。

对于任何期望的误差,上述推理同样适用。为了找到使 \(\bar{X}\)\(p\) 的给定范围内有 95% 概率的误差 \(\epsilon\),我们解:

\[\Pr(|\bar{X} - p| \leq \epsilon) = 0.95. \]

从标准正态分布中,我们知道

\[\Pr(-1.96 \leq Z \leq 1.96) = 0.95. \]

因此,\(\epsilon = 1.96 \times \widehat{\mathrm{SE}}[\bar{X}]\)

这个结果给我们提供了一种构建 置信区间 的方法,在重复的样本中,有 95% 的时间包含真实的参数。

构建置信区间

使用这个结果,我们可以写出 \(p\) 的 95% 置信区间如下:

\[\left[\bar{X} - 1.96,\widehat{\mathrm{SE}}[\bar{X}], , \bar{X} + 1.96,\widehat{\mathrm{SE}}[\bar{X}]\right]. \]

该区间的端点不是固定的数字——它们取决于数据。每次我们取一个新的样本,我们就会得到一个新的 \(\bar{X}\) 和一个新的置信区间。为了看到这一点,我们可以使用之前相同的参数重复抽样过程:

p <- 0.45
N <- 1000
x <- sample(c(0, 1), size = N, replace = TRUE, prob = c(1 - p, p))
x_hat <- mean(x)
se_hat <- sqrt(x_hat*(1 - x_hat)/N)
c(x_hat - 1.96*se_hat, x_hat + 1.96*se_hat)
#> [1] 0.400 0.462

如果你多次运行此代码,你会看到由于随机抽样变化,区间会从一次运行到下一次运行而变化。

95% 置信区间的定义是,在多次重复此过程的情况下,大约 95% 的以这种方式构建的区间将包含 \(p\) 的真实值。从数学上讲:

\[\Pr\left(p \in \left[\bar{X} - 1.96 ,\widehat{\mathrm{SE}}[\bar{X}], \bar{X} + 1.96,\widehat{\mathrm{SE}}[\bar{X}]\right]\right) = 0.95. \]

置信区间不是唯一的。我们可以构建其他也有 95% 覆盖率的 \(p\) 的区间。例如,当 \(\bar{X}\) 接近 0 或 1 时,避免超过 0 或 1 的方法。

我们推导出的 95% 置信区间在 \(\bar{X}\) 两侧是对称的,并且在标准假设下,是包含 \(p\) 的概率为 95% 的最短区间。关于替代区间及其属性的讨论,请参阅推荐阅读部分。* *如果我们想得到不同的置信水平,例如 99%,我们调整乘数。值 \(z\)

\[\Pr(-z \leq Z \leq z) = 1 - \alpha, \]

在 R 中,这可以计算为:

alpha <- 0.01
z <- qnorm(1 - alpha/2)

例如,qnorm(0.975) 对于 95% 的置信区间给出 1.96,而 qnorm(0.995) 对于 99% 的区间给出 2.58。

蒙特卡洛模拟

我们可以通过蒙特卡洛模拟验证我们构建的区间具有包含 \(p\) 的所需概率。通过反复抽取样本并构建置信区间,我们可以检查区间实际上包含 \(p\) 的真实值的频率。

set.seed(1)
N <- 1000
B <- 10000
p <- 0.45
inside <- replicate(B, {
 x <- sample(c(0,1), size = N, replace = TRUE, prob = c(1 - p, p))
 x_hat <- mean(x)
 se_hat <- sqrt(x_hat*(1 - x_hat)/N)
 p >= x_hat - 1.96*se_hat & p <= x_hat + 1.96*se_hat
})
mean(inside)
#> [1] 0.948

正如预期的那样,区间大约有 95% 的时间覆盖 \(p\)

下面的图显示了前 100 个模拟的置信区间。每一条水平线代表一个区间,黑色垂直线标记的是真实比例 \(p\)。大约 95% 的区间与这条线重叠,而大约 5% 的区间没有重叠,正如预测的那样。

在应用此理论时,请记住,区间是随机的,不是 \(p\)。置信水平指的是程序,而不是关于 \(p\) 本身的概率。

误差范围

误差范围与置信区间密切相关。联系很简单:误差范围是加到和从估计值中减去的量,以形成置信区间。

对于民意调查,误差范围由

\[z \times \sqrt{\bar{X}(1-\bar{X}) / N}. \]

对于 95% 的置信区间,这相当于使用 \(z = 1.96\)。提高置信水平或减少样本量都会使误差范围更大,而较大的样本会产生更小的误差范围。

在实践中,民意调查员几乎总是报告 95%的置信区间。除非另有说明,否则应假定报告的误差范围基于此置信水平。结果通常以“估计 ± 误差范围”的形式呈现。例如,如果\(\bar{X} = 0.52\),误差范围为\(0.03\),结果将报告为“52% ± 3%”

为什么不只进行一次大规模民意调查?

如果我们调查了 10 万人,误差范围将缩小到小于 0.3%。原则上,我们可以通过增加\(N\)来使误差范围尽可能小。

然而,在实践中,民意调查不仅受成本限制,还受偏差的影响。现实世界的民意调查很少是简单的随机样本。有些人不回应,其他人错误地报告了他们的偏好,而定义人口(例如,注册选民与可能选民)并不简单。

这些不完美之处引入了系统误差,而误差范围无法捕捉到。历史上,美国民调显示偏差约为 2-3%。理解和模拟这些偏差是现代选举预测的重要组成部分,我们将在第十章中再次回到这个话题。

9.5 练习题

1. 假设你在调查一个选民群体,其中比例\(p\)的选民是民主党人,\(1-p\)是共和党人。你的样本大小是\(N=25\)。考虑随机变量\(S\),它是你样本中民主党人的总数。这个随机变量的期望值是多少?提示:它是\(p\)的函数。

2. \(S\)的标准误差是多少?提示:它是\(p\)的函数。

3. 考虑随机变量\(S/N\)。这相当于样本平均值,我们一直用\(\bar{X}\)表示。\(\bar{X}\)的期望值是多少?提示:它是\(p\)的函数。

4. \(\bar{X}\)的标准误差是多少?提示:它是\(p\)的函数。

5. 编写一行代码,给出问题 4 中对于\(p <- seq(0, 1, length = 100)\)的几个值的\(\mathrm{SE}[\bar{X}]\),并绘制\(\mathrm{SE}[\bar{X}]\)\(p\)的图表。

6. 将问题 5 中的代码复制到 for 循环中,制作三个图表,分别对应\(N=25\)\(N=100\)\(N=1,000\)

7. 如果我们关注比例的差异,\(\theta = p - (1-p)\),我们的估计是\(\hat{\theta} = \bar{X} - (1-\bar{X}) = 2\bar{X}-1\)。使用我们关于缩放随机变量的规则来推导\(\hat{\theta}\)的期望值。

8. \(\hat{\theta}\)的标准误差是多少?

9. 如果\(p=0.45\),这意味着共和党人以相对较大的优势获胜,因为\(\theta = -0.1\),这是一个 10%的胜利范围。在这种情况下,如果我们抽取\(N=25\)的样本,\(\hat{\theta} = 2\bar{X}-1\)的标准误差是多少?

10. 基于练习 9 的答案,以下哪个选项最能描述你使用样本大小\(N=25\)的策略?

  1. 我们估计 \(2\bar{X}-1\) 的期望值是 \(\theta\),因此我们的预测将是准确的。

  2. 我们的标准误差几乎与观察到的差异一样大,所以即使 \(p=0.5\)\(2\bar{X}-1\) 代表一个大的差距的可能性也不小。我们应该使用更大的样本量。

  3. 差异是 10%,标准误差大约是 0.1,因此远小于差异。

  4. 由于我们不知道 \(p\),我们无法知道增加 \(N\) 的数量实际上是否会改善我们的标准误差。

  5. 编写一个抽屉模型函数,该函数接受比例 \(p\) 和样本量 \(N\) 作为参数,并返回民主党为 1 和共和党为 0 的样本平均值。调用该函数为 take_sample

  6. 现在假设 p <- 0.45 并且你的样本量是 \(N=100\)。进行 10,000 次抽样,并将 mean(X) - p 的向量保存到名为 errors 的对象中。提示:使用你为练习 11 编写的函数,用一行代码来完成这个操作。

  7. 向量 errors 包含了对于每个模拟样本,我们的估计 \(\bar{X}\) 与实际 \(p\) 之间的差异。我们称这个差异为误差。计算误差的平均值,并绘制蒙特卡洛模拟中生成的误差的直方图。

mean(errors)
hist(errors)

并选择以下哪个最能描述它们的分布:

  1. 误差都在大约 0.05。

  2. 误差都在大约 -0.05。

  3. 误差在 0 的周围对称分布。

  4. 误差的范围从 -1 到 1。

  5. 注意,误差 \(\bar{X}-p\) 是一个随机变量。在实践中,由于我们不知道 \(p\),我们无法观察到误差。在这里,我们观察到它,因为我们构建了模拟。如果我们通过取绝对值 \(\mid \bar{X} - p \mid\) 来定义大小,误差的平均大小是多少?

  6. 标准误差与我们在预测时犯错的典型大小相关。由于与中心极限定理相关的数学原因,我们实际上使用errors的标准差,而不是绝对值的平均值,来量化典型大小。这个误差的标准差是多少?

  7. 我们刚刚学到的理论告诉我们这个标准差将会是多少,因为 \(\mathrm{SE}[\bar{X}-p]=\mathrm{SE}[\bar{X}]\),我们也展示了如何计算这个值。理论告诉我们对于样本量为 100 的标准误差 \(\mathrm{SE}[\bar{X}-p]\) 是多少?

  8. 在实践中,我们不知道 \(p\),所以我们通过将 \(\bar{X}\) 代入 \(p\) 来构建理论预测的估计。计算这个估计。使用 set.seed(1) 将种子设置为 1。

  9. 注意从蒙特卡洛模拟(练习 15)、理论预测(练习 16)和理论预测估计(练习 17)中获得的标准误差估计是多么接近。理论是有效的,它为我们提供了一个了解如果我们用 \(\bar{X}\) 预测 \(p\) 时将犯的典型误差的实际方法。理论结果提供的另一个优点是,它给出了一个关于需要多大样本量才能获得所需精度的概念。早些时候,我们了解到最大的标准误差发生在 \(p=0.5\) 时。创建一个从 100 到 5,000 的 \(N\) 范围内最大标准误差的图表。根据这个图表,样本量需要多大才能使标准误差约为 1%?

  10. 100

  11. 500

  12. 2,500

  13. 4,000

  14. 对于样本量 \(N=100\),中心极限定理告诉我们 \(\bar{X}\) 的分布是:

  15. 实际上等于 \(p\)

  16. 大约呈正态分布,期望值为 \(p\),标准误差为 \(\sqrt{p(1-p)/N}\)

  17. 大约呈正态分布,期望值为 \(\bar{X}\),标准误差为 \(\sqrt{\bar{X}(1-\bar{X})/N}\)

  18. 不是一个随机变量。

  19. 根据练习 18 的答案,误差 \(\bar{X} - p\) 是:

  20. 实际上等于 0。

  21. 大约呈正态分布,期望值为 \(0\),标准误差为 \(\sqrt{p(1-p)/N}\)

  22. 大约呈正态分布,期望值为 \(p\),标准误差为 \(\sqrt{p(1-p)/N}\)

  23. 不是一个随机变量。

  24. 为验证你对练习 19 的答案,绘制你在练习 12 中生成的 errors 的 qqplot,以查看它们是否遵循正态分布。

  25. 定义 \(p=0.45\)\(N=100\),如练习 12 中所述。然后使用 CLT 估计 \(\bar{X}>0.5\) 的概率。假设你为此计算知道 \(p=0.45\)

  26. 假设你处于实际情况下,你不知道 \(p\)。取一个样本量 \(N=100\) 的样本,并得到样本平均数 \(\bar{X} = 0.51\)。你的误差等于或大于 0.01 的概率的 CLT 近似值是多少?

在接下来的练习中,我们将使用包含在 dslabs 包中的 2016 年选举的实际民调。具体来说,我们将使用选举前一周结束的所有全国民调。

library(dslabs)
library(tidyverse
polls <- polls_us_election_2016 |> 
 filter(enddate >= "2016-10-31" & state == "U.S.") 
  1. 对于第一份民调,你可以获得样本量和估计的克林顿百分比:
N <- polls$samplesize[1]
x_hat <- polls$rawpoll_clinton[1]/100

假设只有两位候选人,构建选举之夜克林顿选民比例 \(p\) 的 95% 置信区间。

  1. polls 对象中添加置信区间作为两列,分别称为 lowerupper,然后展示 pollsterenddatex_hatlowerupper 变量。提示:定义临时列 x_hatse_hat

  2. 选举人票的最终计票结果是克林顿 48.2%,特朗普 46.1%。在之前的表格中添加一列,称为 hit,说明置信区间是否包含真实比例 \(p=0.482\)

  3. 对于你刚刚创建的表格,有多少比例的置信区间包含了 \(p\)

  4. 如果这些置信区间构建正确,并且理论成立,那么应该有多少比例包含\(p\)

  5. 与预期相比,只有极少数的民调产生了包含\(p\)的置信区间。如果你仔细查看表格,你会看到大多数未能包含\(p\)的民调都是低估的。主要原因是不确定的选民,即那些尚未决定他们将投票给谁或不想说的人。因为,从历史上看,不确定的选民在选举日几乎平均分配在两个主要候选人之间,因此估计两个候选人比例的差距或差异更有信息量,我们将用\(\theta\)表示它,在本届选举中为\(0.482 - 0.461 = 0.021\)。假设只有两个政党,且\(\theta = 2p - 1\)。重新定义polls如下,并重新做练习 15,但针对差异。

polls <- polls_us_election_2016 |> 
 filter(enddate >= "2016-10-31" & state == "U.S.")  |>
 mutate(theta_hat = rawpoll_clinton/100 - rawpoll_trump/100)
  1. 现在重复练习 26,但针对差异进行。

  2. 现在重复练习 27,但针对差异进行。

  3. 尽管置信区间的比例大幅增加,但仍然低于 0.95。在下一章中,我们将学习这种差异的原因。为了激发这一思考,绘制一个误差图,展示每个民调估计值与实际\(\theta=0.021\)之间的差异。按民调机构进行分层。

  4. 重新绘制你在练习 32 中制作的图表,但仅针对进行了五次或更多民调的民调机构。


  1. http://www.realclearpolitics.com↩︎

  2. http://www.realclearpolitics.com/epolls/2016/president/us/general_election_trump_vs_clinton-5491.html↩︎

10 数据驱动模型

原文:rafalab.dfci.harvard.edu/dsbook-part-2/inference/models.html

  1. 统计推断

  2. 10 数据驱动模型

“所有模型都是错误的,但有些是有用的。” ——乔治·E·P·博克斯

到目前为止,我们对与民意调查相关的结果的分析是基于一个简单的抽样模型。该模型假设每个选民被选入民意调查的机会均等,类似于从有两个颜色的罐子中挑选珠子。然而,在本节中,我们探索现实世界的数据,并发现这个模型过于简化。相反,我们提出了一种更有效的方法,其中我们直接模拟民意调查的结果,而不是单个民意调查。

自意见调查原始发明以来,一个更近的发展是使用计算机从不同来源汇总公开数据,并开发数据驱动预测模型。在这里,我们探讨民意调查聚合器如何收集和结合不同专家报告的数据,以产生改进的预测。我们将介绍用于改进选举预测的统计模型背后的思想,这些模型超越了单个民意调查的能力。具体来说,我们介绍了一个用于构建普选票差置信区间的有用模型。

强调这一点很重要,即本章仅对统计建模这一广阔领域提供了一个简要的介绍。例如,这里提出的模型不允许我们像 FiveThirtyEight 这样的民意调查聚合器那样,对特定候选人赢得普选等结果进行概率分配。在下一章中,我们将介绍贝叶斯模型,它为这样的概率陈述提供了数学框架。稍后,在本书的线性模型部分,我们将探讨实际数据分析中广泛使用的方法。然而,这个介绍只是触及了表面。对统计建模感兴趣的读者应鼓励查阅推荐阅读部分,以获取提供更深更广视角的额外参考文献。

10.1 案例研究:民意调查聚合器

在 2012 年选举前的几周,Nate Silver 给奥巴马赢得选举的概率是 90%。Silver 先生是如何如此自信的?我们将使用蒙特卡洛模拟来展示 Silver 先生所具有的洞察力,以及其他人所忽视的。

蒙特卡洛模拟

为了做到这一点,我们模拟了选举前一周进行的 12 次民意调查的结果。我们模仿了实际民意调查的样本量,为 12 次民意调查中的每一次都构建并报告了 95%置信区间:

图片

毫不奇怪,所有 12 个民调都报告了包含我们在选举之夜最终看到的结果的置信区间(垂直虚线)。然而,所有 12 个民调也包括 0(垂直实线)。因此,如果单独要求预测,民意调查员必须说:这是一个平局。

民调聚合者认识到,合并多个民调的结果可以大大提高精度。一种直接的方法是使用报告的估计和样本大小来逆向工程每个民调的原始数据。从这些数据中,我们可以重建所有民调的成功失败总数,有效地将它们合并成一个更大的数据集。然后,我们使用合并的样本大小(这比任何单个民调都要大得多)计算新的总体估计及其对应的标准误差。

当我们这样做时,结果误差范围是 0.018。

我们的合并估计预测传播为 3.1 个百分点,加减 1.8。这个区间不仅包括选举之夜观察到的实际结果,而且远未包括零。通过汇总十二个民调,假设没有系统性偏差,我们获得了一个更精确的估计,并且可以相当自信地认为奥巴马将赢得普选票。

图片

然而,这个例子只是一个简化的模拟,用以说明基本思想。在现实世界的设置中,将多个民调数据合并比将其视为一个大型随机样本要复杂得多。方法、时间、问题措辞以及甚至抽样偏差都可能影响结果。我们将在以后探讨这些因素如何使实际民调汇总更具挑战性,以及统计模型如何考虑这些因素。

2016 年总统选举的实时数据

dslabs** 中的 polls_us_election_2016 数据集的以下子集包括选举前一年内进行的全国民调和州民调的结果,并按 FiveThirtyEight 组织。在这个第一个例子中,我们将筛选数据以包括选举前一周内对可能选民(lv)进行的全国民调,并考虑他们对传播的估计:

library(dslabs)
polls <- polls_us_election_2016 |> 
 filter(state == "U.S." & enddate >= "2016-11-02" & population == "lv") |>
 mutate(spread = rawpoll_clinton/100 - rawpoll_trump/100) 

请注意,我们将专注于预测传播,而不是比例 \(p\)。由于我们假设只有两个党派,我们知道传播是 \(\theta = p - (1-p) = 2p - 1\)。因此,我们所做的一切都可以很容易地适应对 \(\theta\) 的估计。一旦我们有了我们的估计 \(\bar{X}\)\(\widehat{\mathrm{SE}}[\bar{X}]\),我们用 \(2\bar{X} - 1\) 来估计传播,由于我们乘以了 2,标准误差是 \(2\widehat{\mathrm{SE}}[\bar{X}]\)。记住,减去 1 不会增加任何变异性,因此它不会影响标准误差。此外,中心极限定理(CLT)也适用于此处,因为我们的估计是样本平均值的线性组合,而样本平均值本身近似服从正态分布。

在这里我们用 \(\theta\) 来表示范围,因为这是统计教科书中用于 感兴趣参数 的常用符号。* 我们有 50 个关于范围的估计值。我们从抽样模型中学到的理论告诉我们,这些估计值是一个具有近似正态分布的随机变量。期望值是选举之夜的范围 \(\theta\),标准误差是 \(2\sqrt{p (1 - p) / N}\)

假设我们之前描述的抽屉模型是好的,我们可以使用这些信息根据汇总数据构建一个置信区间。估计的范围是:

theta_hat <- with(polls, sum(spread*samplesize)/sum(samplesize)) 

并且标准误差是:

p_hat <- (theta_hat + 1)/2 
moe <- 2*1.96*sqrt(p_hat*(1 - p_hat)/sum(polls$samplesize))

因此,我们报告的范围是 3.59%,误差范围为 0.4%。在选举之夜,我们发现实际百分比是 2.1%,这超出了 95% 的置信区间。发生了什么?

报告范围的直方图揭示了一个问题:

polls |> ggplot(aes(spread)) + geom_histogram(color = "black", binwidth = .01)

* *这些估计值似乎不是正态分布的,标准误差似乎大于 0.0038。理论在这里不起作用。

10.2 超越简单的抽样模型

请注意,数据来自不同的民意调查机构,其中一些每周进行几次民调:

polls |> count(pollster) |> arrange(desc(n)) |> head(5)
#>                   pollster n
#> 1                 IBD/TIPP 6
#> 2 The Times-Picayune/Lucid 6
#> 3    USC Dornsife/LA Times 6
#> 4 ABC News/Washington Post 5
#> 5     CVOTER International 5

让我们可视化一下那些定期进行民调的民意调查机构的数据:

这个图揭示了一个意外的结果。首先,考虑理论预测的每个民调的标准误差在 0.018 到 0.033 之间:

polls |> group_by(pollster) |> filter(n() >= 5) |>
 summarize(se = 2*sqrt(p_hat*(1 - p_hat)/median(samplesize)))
#> # A tibble: 5 × 2
#>   pollster                     se
#>   <fct>                     <dbl>
#> 1 ABC News/Washington Post 0.0243
#> 2 CVOTER International     0.0260
#> 3 IBD/TIPP                 0.0333
#> 4 The Times-Picayune/Lucid 0.0197
#> 5 USC Dornsife/LA Times    0.0183

这与我们看到的所有民调中的内部变化一致。然而,似乎存在 跨民调的差异。例如,观察 USC Dornsife/LA Times 民调机构预测特朗普领先 4%,而 Ipsos 预测克林顿领先超过 5%。我们学到的理论对不同的民意调查机构产生具有不同期望值的民调没有任何说明,相反,它假设所有民调都有相同的期望值。FiveThirtyEight 将这些差异称为 house effects。我们也将它们称为 民意调查偏差。我们的简单抽屉模型中没有任何东西可以解释这些民意调查机构之间的差异。

这种模型设定错误导致了一个过于自信的区间,最终没有包括选举之夜的结果。因此,我们不是用抽屉模型来模拟生成这些值的流程,而是直接模拟民意调查结果。为此,我们收集数据。具体来说,对于每个民意调查机构,我们查看选举前的最后报告结果:

one_poll_per_pollster <- polls |> group_by(pollster) |> 
 filter(enddate == max(enddate)) |> ungroup()

以下是这 20 个民意调查机构最终民调计算范围的直方图:

hist(one_poll_per_pollster$spread, breaks = 10)

* *尽管我们不再使用罐子中红色(共和党)和蓝色(民主党)珠子的模型,但我们的新模型也可以被视为罐子模型,但包含所有可能民意调查者的投票结果。将我们的 \(N=\) 20 数据点 \(Y_1,\dots Y_N\) 视为从这个罐子中抽取的随机样本。为了开发一个有用的模型,我们 假设 罐子的期望值是实际的分布 \(\theta = 2p - 1\),这意味着样本平均有期望值 \(\theta\)

现在,我们的罐子中不再是 0 和 1,而是代表投票结果的连续数字。因此,这个罐子的标准差不再是 \(2\sqrt{p(1-p)}\)。在这种设置下,我们观察到的变异性不仅来自对选民的随机抽样,还来自不同民意调查者的差异。我们的新罐子捕捉了这两种变异来源。这个分布的整体标准差是未知的,我们用 \(\sigma\) 表示。

因此,我们新的统计模型是 \(Y_1, \dots, Y_N\) 是一个具有期望值 \(\theta\) 和标准差 \(\sigma\) 的随机样本。目前,分布是不指定的。但我们认为 \(N\) 足够大,可以假设样本平均 \(\bar{Y} = \sum_{i=1}^N Y_i\) 符合期望值为 \(\theta\) 和标准误 \(\sigma / \sqrt{N}\) 的正态分布。我们写:

\[\bar{Y} \sim \mbox{N}(\theta, \sigma / \sqrt{N}) \]

这里,符号左侧的 \(\sim\) 表示符号左侧的随机变量遵循右侧的分布。我们使用 \(N(a,b)\) 表示均值为 \(a\) 和标准差为 \(b\) 的正态分布。

这个用于样本平均的模型将在下一章再次使用。* *### 估计标准差

我们指定的模型有两个未知参数:期望值 \(\theta\) 和标准差 \(\sigma\)。我们知道样本平均 \(\bar{Y}\) 将是我们的 \(\theta\) 估计。但 \(\sigma\) 呢?

我们的任务是估计 \(\theta\)。鉴于我们将观察到的值 \(Y_1,\dots Y_N\) 模型化为来自罐子的随机样本,对于足够大的样本量 \(N\),样本平均 \(\bar{Y}\) 的概率分布近似为期望值为 \(\theta\) 和标准误 \(\sigma/\sqrt{N}\) 的正态分布。如果我们愿意考虑 \(N=\) 20 足够大,我们可以使用它来构建置信区间。

理论告诉我们,我们可以用定义为样本标准差的 样本标准差 来估计罐子模型 \(\sigma\)

\[s = \sqrt{ \frac{1}{N-1} \sum_{i=1}^N (Y_i - \bar{Y})² } \]

请记住,与人口标准差定义不同,我们现在除以 \(N-1\)。这使得 \(s\) 成为 \(\sigma\) 的更好估计。对此有一个数学解释,这在大多数统计学教科书中都有解释,但我们在这里不涉及。

R 中的 sd 函数计算样本标准差:

sd(one_poll_per_pollster$spread)
#> [1] 0.0222

计算置信区间

我们现在可以根据我们的新数据驱动模型形成一个新的置信区间:

results <- one_poll_per_pollster |> 
 summarize(avg = mean(spread), se = sd(spread)/sqrt(length(spread))) |> 
 mutate(start = avg - 1.96*se, end = avg + 1.96*se) 
round(results*100, 2)
#>    avg  se start end
#> 1 3.03 0.5  2.06   4

我们的置信区间现在更宽了,因为它包含了民意调查者的变异性。它确实包括了选举之夜的 2.1%的结果。此外,请注意,它足够小,以至于不包括 0,这意味着我们相信克林顿将赢得普选票。

t 分布

以上,我们使用了样本大小为 20 的 CLT(中心极限定理)。因为我们正在估计第二个参数\(\sigma\),这引入了额外的变异性到我们的置信区间中,导致区间过小。对于非常大的样本量,这种额外的变异性可以忽略不计,但一般来说,特别是对于\(N\)小于 30 的情况,我们需要在使用 CLT 时保持谨慎。

请注意,30 是一个非常一般的经验法则,基于数据来自正态分布的情况。有些情况下需要大样本量,有些情况下小样本量就足够了。*然而,如果已知尿壶中的数据遵循正态分布,那么我们实际上有数学理论告诉我们需要将区间扩大多少来考虑\(\sigma\)的估计。应用这一理论,我们可以为任何\(N\)构建置信区间。但再次强调,这**仅在尿壶中的数据已知遵循正态分布时才有效。所以对于我们之前尿壶模型中的 0, 1 数据,这个理论肯定不适用。

用于置信区间和假设检验的 t 分布近似仅在数据正态分布时才精确。然而,在实践中,它相当广泛地工作得很好。当数据的分布是单峰近似对称时,基于 t 的方法即使在数据不是完全正态的情况下也能给出准确的结果。

此指南故意不够精确。随着经验的积累,你将发展出一种感觉,知道何时近似是可靠的。当数据形状不清楚时,非蒙特卡洛模拟提供了一个实用的检查:你可以在一个提议的模型下模拟重复样本,并检查结果置信区间或测试是否按预期表现。

简而言之:不需要正态分布,但强烈的偏斜或重尾可以破坏近似,所以总是查看数据。*基于\(\theta\)的置信区间的统计量是:

\[ Z = \frac{\bar{Y} - \theta}{\sigma/\sqrt{N}} $$ 这里,$\theta$是真实总体分布,$\sigma$是民意调查级别尿壶的标准差。 CLT 告诉我们 Z 大约服从正态分布,期望值为 0,标准误为 1。但在实践中,我们不知道$\sigma$,所以我们使用: $$ t = \frac{\bar{Y} - \theta}{s/\sqrt{N}} \]

这被称为 t 统计量。通过将 \(\sigma\) 替换为 \(s\),我们引入了一些变异性。理论告诉我们 \(t\) 遵循具有 \(N-1\)自由度学生 t 分布。自由度是一个参数,通过较宽的尾部来控制变异性。以下是三个具有不同自由度的 t 分布的例子:

如果我们愿意假设民意调查效应数据是正态分布的,基于样本数据 \(Y_1, \dots, Y_N\)

one_poll_per_pollster |> ggplot(aes(sample = spread)) + stat_qq()

* *然后 \(t\) 遵循具有 \(N-1\) 个自由度的 t 分布。为了构建 95% 的置信区间,我们只需使用 qt 而不是 qnorm:这导致比之前得到的置信区间略大:

n <- length(one_poll_per_pollster$spread)
ttest_ci <- one_poll_per_pollster |> 
 summarize(avg = mean(spread), se = sd(spread)/sqrt(length(spread))) |> 
 mutate(start = avg - qt(0.975, n - 1)*se, end = avg + qt(0.975, n - 1)*se) |>
 select(start, end)
round(ttest_ci*100, 2)
#>   start  end
#> 1  1.99 4.07

这个区间比使用正态分布的区间要大,因为 t 分布的尾部较宽,如密度图所示。具体来说,t 分布的 97.5 分位数值

qt(0.975, n - 1)
#> [1] 2.09

比正态分布的值要大

qnorm(0.975)
#> [1] 1.96

使用 t 分布和 t 统计量是 t 检验 的基础,这是一种广泛用于计算 p 值的方法。要了解更多关于 t 检验的信息,您可以查阅任何统计学教科书。* *t 分布还可以用来模拟比正态分布更可能出现的较大偏差。在他们的蒙特卡洛模拟中,FiveThirtyEight 使用 t 分布来生成更好地模拟选举数据中偏差的错误。例如,在威斯康星州,六个高质量民调的平均值为支持克林顿 7%,标准差为 3%,但特朗普以 0.8% 的优势获胜。即使考虑到整体偏差,这个 7.7% 的残差比正态分布更符合 t 分布数据。

polls_us_election_2016 |>
 filter(state == "Wisconsin" & enddate > "2016-11-01" & 
 population == "lv" & grade %in% c("A+","A","A-","B+","B")) |>
 left_join(results_us_election_2016, by = "state") |>
 mutate(spread = rawpoll_clinton - rawpoll_trump, actual = clinton - trump) |>
 summarize(actual = first(actual), estimate = mean(spread), sd = sd(spread)) 
#>   actual estimate sd
#> 1 -0.764     7.04  3

10.3 练习题

我们一直在使用抽屉模型来激发概率模型的使用。然而,大多数数据科学应用与来自抽屉的数据无关。更常见的是来自个人的数据。概率在这里发挥作用的原因是因为数据来自随机样本。随机样本是从总体中抽取的,抽屉作为总体的类比。

将回答身高调查的男性定义为总体

library(dslabs)
x <- heights |> filter(sex == "Male") |>
 pull(height)

来回答以下问题。

  1. 从数学角度来说,x 代表我们的总体。使用抽屉类比,我们有一个包含 x 值的抽屉。我们总体的均值和标准差是多少?

  2. 将上面计算出的总体均值称为 \(\mu\),标准差称为 \(\sigma\)。现在取一个大小为 50 的样本,进行有放回抽样,并构建对 \(\mu\)\(\sigma\) 的估计。

  3. 理论告诉我们样本平均值 \(\bar{X}\) 是什么,以及它与 \(\mu\) 之间的关系?

  4. 它实际上与 \(\mu\) 完全相同。

  5. 它是一个期望值为 \(\mu\)、标准误差为 \(\sigma/\sqrt{N}\) 的随机变量。

  6. 它是一个具有期望值\(\mu\)和标准误差\(\sigma\)的随机变量。

  7. 不包含任何信息。

  8. 那么,这有什么用呢?我们将使用一个过于简化但具有说明性的例子。假设我们想知道我们男性学生的平均身高,但我们只能测量 708 人中的 50 人。我们将使用\(\bar{X}\)作为我们的估计值。根据练习 3 的答案,我们知道我们的误差\(\bar{X}-\mu\)的标准误差是\(\sigma/\sqrt{N}\)。我们想要计算这个值,但我们不知道\(\sigma\)。根据本章所描述的内容,展示你对\(\sigma\)的估计值。

  9. 使用我们对\(\sigma\)的估计值,为\(\mu\)构建一个 95%的置信区间。

  10. 现在运行一个蒙特卡洛模拟,就像在练习 5 中做的那样,计算 10,000 个置信区间。这些区间中有多少比例包含了\(\mu\)

  11. 使用qnormqt函数生成分位数。比较不同自由度的 t 分布的分位数。利用这一点来解释 30 个样本大小的经验法则。

11 贝叶斯统计

原文:rafalab.dfci.harvard.edu/dsbook-part-2/inference/bayes.html

  1. 统计推断

  2. 11 贝叶斯统计

在 2016 年,FiveThirtyEight 展示了这张图表,描绘了每位候选人的得票率分布:

彩色区域表示根据 FiveThirtyEight 模型,有 80%的概率包含实际结果。

但在我们之前讨论的理论背景下,这些百分比被认为是固定的,这又意味着什么呢?此外,选举预测者会做出概率性陈述,例如“奥巴马有 90%的胜选概率。”请注意,在抽屉模型中,这相当于说\(p>0.5\)的概率是 90%。然而,在抽屉模型中\(p\)是一个固定参数,谈论概率是没有意义的。在贝叶斯统计中,我们假设\(p\)是一个随机变量,因此,“90%的胜选概率”与数学方法是一致的。预测者还使用模型来描述不同层面的变异性。例如,抽样变异性、民意调查员之间的变异性、日日变异性以及选举之间的变异性。用于此目的的最成功的方法之一是层次模型,这可以在贝叶斯统计的背景下进行解释。

在本章中,我们将简要介绍贝叶斯统计。我们使用三个案例研究:1)解释罕见疾病的诊断测试,2)使用选举前的民意调查数据估计希拉里·克林顿在 2016 年赢得普选票的概率。

前几章中描述的方法,其中参数被视为固定,被称为频率派方法。频率派这个术语来源于概率被解释为事件长期频率的思想。参数被认为是固定和未知的,而数据被视为随机的。频率派方法依赖于如果实验重复多次,结果出现的频率,关注观察到的结果的频率,而不是对参数本身分配概率。* *## 11.1 贝叶斯定理

我们首先通过一个假设的囊性纤维化测试的例子来描述贝叶斯定理。

假设一项囊性纤维化测试的准确率为 99%。我们将使用以下符号:

\[\mathrm{Pr}(+ \mid D=1)=0.99, \mathrm{Pr}(- \mid D=0)=0.99 \]

其中“+”表示阳性测试,“D”表示你是否真的患有疾病(1)或不是(0)。

假设我们随机选择一个人,他们测试呈阳性。他们患有疾病的概率是多少?我们把这个概率写成\(\mathrm{Pr}(D=1 \mid +)\)

为了回答这个问题,我们将使用贝叶斯定理,它告诉我们:

\[\mathrm{Pr}(A \mid B) = \frac{\mathrm{Pr}(B \mid A)\mathrm{Pr}(A)}{\mathrm{Pr}(B)} \]

当将此方程应用于我们的问题时,它变为:

\[\begin{aligned} \mathrm{Pr}(D=1 \mid +) & = \frac{ \mathrm{Pr}(+ \mid D=1) \, \mathrm{Pr}(D=1)} {\mathrm{Pr}(+)} \\ & = \frac{\mathrm{Pr}(+ \mid D=1) \, \mathrm{Pr}(D=1)} {\mathrm{Pr}(+ \mid D=1) \, \mathrm{Pr}(D=1) + \mathrm{Pr}(+ \mid D=0) \mathrm{Pr}( D=0)} \end{aligned} \]

胆囊纤维症的患病率为 3900 人中有 1 人,这意味着 \(\mathrm{Pr}(D=1)\approx0.00025\)。将数字代入,我们得到:

\[\mathrm{Pr}(D=1 \mid +) = \frac{0.99 \cdot 0.00025}{0.99 \cdot 0.00025 + 0.01 \cdot (.99975)} \approx 0.02 \]

根据上述内容,尽管测试的准确率为 0.99,但在测试结果为阳性时,患病概率仅为 0.02。这可能会让一些人感到反直觉,但这是因为我们必须考虑随机选择的人患病的非常罕见的概率。为了说明这一点,我们运行了一个蒙特卡洛模拟。

我们首先从患病率约为 1/4000 的人群中随机选择 100,000 人。

p <- 0.00025
N <- 100000
D <- sample(c(1, 0), N, replace = TRUE, prob = c(p, 1 - p))

正如预期的那样,患病人数很少,而未患病人数很多,

N_1 <- sum(D == 1)
N_0 <- sum(D == 0)
cat(N_1, "with disease, and", N_0, "without.")
#> 23 with disease, and 99977 without.

这使得在测试不完美的情况下,我们更有可能看到一些假阳性。现在,每个人都会接受测试,测试正确率为 99%:

acc <- 0.99
test <- vector("character", N)
testD == 1] <- [sample(c("+", "-"), N_1, replace = TRUE, prob = c(acc, 1 - acc))
testD == 0] <- [sample(c("-", "+"), N_0, replace = TRUE, prob = c(acc, 1 - acc))

由于健康个体的人数远多于患病个体的人数,即使低误报率也会导致比实际病例更多的健康个体测试呈阳性。

table(D, test)
#>    test
#> D       -     +
#>   0 99012   965
#>   1     0    23

从这张表中,我们可以看到有疾病的阳性测试比例是 988 个中的 23 个。我们可以反复运行这个程序,以看到实际上,概率收敛到大约 0.02。

11.2 先验和后验

在前一章中,我们计算了希拉里·克林顿和唐纳德·特朗普之间普选票差异的估计值和误差范围。我们用 \(\theta\) 表示参数,即普选票差异。估计值在 2%到 4%之间,置信区间不包括 0。预报员会使用这个结果预测希拉里·克林顿将赢得普选票。但为了对选举胜利做出概率性陈述,我们需要使用贝叶斯方法。

我们在看到任何数据之前,通过量化我们的知识来开始贝叶斯方法。这是通过使用称为先验的概率分布来完成的。对于我们的例子,我们可以写成:

\[\theta \sim N(\theta_0, \tau) \]

我们可以将 \(\theta_0\) 视为我们没有看到任何调查数据时对普选票差异的最佳猜测,我们可以将 \(\tau\) 视为量化我们对这个猜测的确定性。一般来说,如果我们有与 \(\theta\) 相关的专业知识,我们可以尝试使用先验分布来量化它。在选举调查的情况下,专家使用基本面,例如经济状况,来开发先验分布。

数据被用来更新我们的初始猜测或先验信念。如果我们为任何给定的 \(\theta\) 定义观察数据的分布,这可以数学上进行。在我们的特定例子中,我们会写下我们调查平均的模型。如果我们固定 \(\theta\),这个模型与我们在上一章中使用的是相同的:

\[\bar{Y} \mid \theta \sim N(\theta, \sigma/\sqrt{N}) \]

如前所述,\(\sigma\) 描述了由于抽样和调查员效应而产生的随机性。在贝叶斯框架中,这被称为抽样分布。注意,我们写 \(\bar{Y} \mid \theta\) 是因为 \(\theta\) 现在被视为一个随机变量。

我们在这里不展示推导过程,但现在我们可以使用微积分和贝叶斯定理的一个版本来推导给定观察数据的 \(\theta\) 的条件分布,这被称为后验分布。具体来说,我们可以证明 \(\theta \mid \bar{Y}\) 服从期望值为:

\[\mathrm{E}[\theta \mid \bar{Y}] = B \theta_0 + (1-B) \,\bar{Y} \mbox{ with } B = \frac{\sigma²/N}{\sigma²/N+\tau²} \]

以及标准误差:

\[\mathrm{SE}[\mu \mid \bar{Y}] = \sqrt{\frac{1}{N/\sigma²+1/\tau²}}. \]

注意,期望值是我们先验猜测 \(\theta_0\) 和观察数据 \(\bar{Y}\) 的加权平均。权重取决于我们对先验信念的确定性,这由 \(\tau²\) 来量化,以及我们观察数据汇总的方差 \(\sigma²/N\)

这种加权平均有时被称为收缩,因为它会将估计值向先验值收缩。为了理解这一点,请注意我们可以将加权平均重写为:

\[ B \theta_0 + (1-B) \bar{Y}= \theta_0 + (1-B)(\bar{Y}-\theta_0)\\ $$ 随着 $B$ 越接近 1,我们越会将我们的估计值向 $\theta_0$ 收敛。 这些公式是量化我们如何更新信念的有用方法。 ## 11.3 信任区间 我们还可以报告给定我们的模型具有高发生概率的区间。具体来说,对于任何概率值 $\alpha$,我们可以使用后验分布来构建以我们的后验均值为中心,具有 $\alpha$ 发生概率的区间。这些被称为*信任区间*。 例如,我们计算一个后验分布,并在定义了均值为 0%和标准误差为 5%的先验分布后,为流行票差构建一个可信区间。这个先验分布可以这样解释:在看到投票数据之前,我们不相信任何候选人具有优势,并且任何方向的 10%的差异都是可能的。 ```r theta_0 <- 0 tau <- 0.05 ``` 然后我们可以通过将上述方程应用于第十章中定义的`one_poll_per_pollster`数据来计算后验分布: ```r res <- one_poll_per_pollster |> summarise(y_bar = mean(spread), sigma = sd(spread), n = n()) B <- with(res, sigma²/n / (sigma²/n + tau²)) posterior_mean <- B*theta_0 + (1 - B)*res$y_bar posterior_se <- with(res, sqrt(1/(n/sigma² + 1/tau²))) posterior_mean + c(-1, 1)*qnorm(0.975)*posterior_se #> [1] 0.0203 0.0397 ``` 此外,我们现在可以做出频率主义者方法无法做出的概率性陈述。具体来说,$\mathrm{Pr}(\mu>0 \mid \bar{X})$可以按照以下方式计算: ```r 1 - pnorm(0, posterior_mean, posterior_se) #> [1] 1 ``` 根据上述计算,我们几乎可以 100%确定克林顿将赢得普选票,这个估计感觉过于自信。此外,它并不符合 FiveThirtyEight 报道的 81.4%的概率。是什么导致了这种差异?我们当前模型尚未捕捉到所有的不确定性来源。我们将在第十二章章节链接中重新审视这个问题,并解决缺失的变异性。 ## 11.4 练习 1. 在 1999 年,在英国,萨莉·克拉克¹被判定犯有谋杀她两个儿子的罪行。两个婴儿都在早上被发现死亡,一个是在 1996 年,另一个是在 1998 年。在两种情况下,她都声称死亡原因是婴儿猝死综合症(SIDS)。在两个婴儿身上都没有发现身体伤害的证据,因此对她不利的证据主要是罗伊·梅多爵士教授的证词,他作证说两个婴儿死于 SIDS 的概率是 1/7300 万。他是通过发现 SIDS 的比率是 1/8500,然后计算出两个 SIDS 病例的概率是 8500 $\times$ 8500 $\approx$ 7300 万。以下哪个是你同意的? 1. 梅多爵士假设第二个儿子受到 SIDS 影响的可能性与第一个儿子受到影响的可能性是独立的,因此忽略了可能的遗传原因。如果遗传起作用,那么:$\mathrm{Pr}(\mbox{second case of SIDS} \mid \mbox{first case of SIDS}) > \mathrm{Pr}(\mbox{first case of SIDS})$。 1. 没有东西。乘法法则总是以这种方式适用:$\mathrm{Pr}(A \mbox{ and } B) =\mathrm{Pr}(A)\mathrm{Pr}(B)$ 1. 梅多爵士是一位专家,我们应该相信他的计算。 1. 数字不会说谎。 2. 假设确实存在婴儿猝死综合症(SIDS)的遗传因素,且在第一个 SIDS 病例发生后,第二个 SIDS 病例发生的概率 $\mathrm{Pr}(\mbox{second case of SIDS} \mid \mbox{first case of SIDS}) = 1/100$,这个概率远高于 1/8,500。那么,她两个儿子都死于 SIDS 的概率是多少? 3. 许多新闻报道称,专家声称莎莉·克拉克无辜的概率是 7300 万分之一。也许陪审团和法官也是这样解读证词的。这个概率可以写成在“两个孩子被发现死亡,没有身体伤害的证据”的条件下,“母亲是谋杀儿子的心理变态者”的概率。根据贝叶斯规则,这是什么? 4. 假设一个谋杀儿子的心理变态母亲找到一种方法杀害她的孩子,而不留下身体伤害的证据的概率是: $$ \mathrm{Pr}(A \mid B) = 0.50 \]

假设 A = 发现她的两个孩子死亡,没有身体伤害的证据,和 B = 母亲是谋杀儿子的心理变态者 = 0.50。假设谋杀儿子的心理变态母亲的比率是每百万分之一。根据贝叶斯定理,\(\mathrm{Pr}(B \mid A)\) 的概率是多少?

  1. 在莎莉·克拉克被判有罪后,皇家统计学会发表声明称,专家的“没有统计依据”。他们表示对“法庭中统计学的误用”表示担忧。最终,莎莉·克拉克在 2003 年 6 月被宣判无罪。专家错过了什么?

  2. 他犯了一个算术错误。

  3. 他犯了两个错误。首先,他误用了乘法规则,没有考虑到母亲谋杀孩子的罕见性。在使用贝叶斯规则后,我们发现概率更接近于 7300 万分之一,而不是 0.5。

  4. 他混淆了贝叶斯规则的分子和分母。

  5. 他没有使用 R。

  6. 佛罗里达是美国选举中最受关注的几个州之一,因为它拥有许多选举人票,对最终结果有重大影响。在 2016 年之前,佛罗里达是一个摇摆州,共和党和民主党都曾获胜,这意味着它可能影响一场紧绷的选举。

创建以下表格,包含过去两周内进行的投票:

library(tidyverse
library(dslabs)
polls <- polls_us_election_2016 |> 
 filter(state == "Florida" & enddate >= "2016-11-04" ) |> 
 mutate(spread = rawpoll_clinton/100 - rawpoll_trump/100)

取这些投票的平均差值。中心极限定理告诉我们这个平均值大约是正态分布的。计算平均值并提供标准误的估计。将结果保存在名为 results 的对象中。

  1. 现在假设一个贝叶斯模型,将佛罗里达选举夜分布的先验分布 \(\theta\) 设定为服从期望值为 \(\theta_0\) 和标准差 \(\tau\) 的正态分布。\(\theta\)\(\tau\) 的解释是什么?

  2. \(\theta_0\)\(\tau\) 是任意数字,让我们可以对 \(\theta\) 进行概率陈述。

  3. \(\theta_0\)\(\tau\) 总结了我们希望在看到任何投票之前对佛罗里达的预测。根据过去的选举,我们会将 \(\theta\) 设置得接近于 0,因为共和党和民主党都曾获胜,而 \(\tau\) 大约是 \(0.02\),因为这些选举往往很接近。

  4. \(\theta_0\)\(\tau\) 总结了我们想要成为真实的。因此,我们将 \(\theta_0\) 设置为 \(0.10\),将 \(\tau\) 设置为 \(0.01\)

  5. 先验的选择对贝叶斯分析没有影响。

  6. CLT 告诉我们,我们对扩散 \(\hat{\theta}\) 的估计具有期望值为 \(\theta\) 和标准差 \(\sigma\) 的正态分布,这些值是在第 6 个练习中计算的。使用我们提供的后验分布公式,如果我们将 \(\theta_0 = 0\)\(\tau = 0.01\),来计算后验分布的期望值。

  7. 现在计算后验分布的标准差。

  8. 利用后验分布是正态分布的事实,创建一个以后验期望值为中心,发生概率为 95%的区间。请注意,我们把这些区间称为可信区间。

  9. 根据这一分析,特朗普赢得佛罗里达州的可能性是多少?

  10. 现在请使用 sapply 函数将先验方差从 seq(0.005, 0.05, len = 100) 改变,并通过绘制图表来观察概率如何变化。


  1. https://en.wikipedia.org/wiki/Sally_Clark↩︎

12  分层模型

原文:rafalab.dfci.harvard.edu/dsbook-part-2/inference/hierarchical-models.html

  1. 统计推断

  2. 12  分层模型

分层模型用于量化不同级别的变异或不确定性。可以使用贝叶斯或频率主义框架来使用它们。然而,因为在频率主义框架中,它们通常通过假设参数实际上是随机的来扩展具有固定参数的模型,模型描述包括两个看起来像先验分布和贝叶斯框架中使用的抽样分布的分布。这使得得到的总结非常相似,甚至等于在贝叶斯背景下获得的结果。贝叶斯和频率主义分层模型方法之间的一个关键区别是,在后者中,我们使用数据来构建先验,而不是将先验视为先前专家知识的量化。在本节中,我们将通过描述 FiveThirtyEight 用于预测 2016 年选举的方法的简化版本来说明分层模型的使用。

12.1 案例研究:选举预测

在 2008 年选举之后,除了 FiveThirtyEight 之外,几个组织也启动了自己的选举预测团队,这些团队汇总了民意调查数据并使用统计模型进行预测。然而,在 2016 年,许多预测者大大低估了特朗普获胜的机会。在大选前一天,《纽约时报》报道了以下希拉里·克林顿赢得总统职位的概率:

NYT 538 HuffPost PW PEC DK Cook Roth
胜率 85% 71% 98% 89% >99% 92% 倾向民主党 倾向民主党

注意,普林斯顿选举联盟(PEC)给特朗普的获胜机会不到 1%,而 Huffington Post 给了他 2%的机会。相比之下,FiveThirtyEight 将特朗普获胜的概率定为 29%,这比其他人高得多。事实上,在大选前四天,FiveThirtyEight 发表了一篇题为特朗普只是克林顿的正常民意调查误差背后²的文章。

那么为什么 FiveThirtyEight 的模型比其他模型表现得更好呢?如果 PEC 和 Huffington Post 使用的是相同的数据,他们怎么会犯这么大的错误呢?在本章中,我们将描述 FiveThirtyEight 如何使用分层模型正确地考虑关键变异来源,并超越所有其他预测者。为了说明目的,我们将继续研究我们流行的选票示例。在最后一节中,我们将描述用于预测选举人团结果的更复杂方法。

12.2 系统性民意调查误差

在上一章中,我们使用标准贝叶斯分析计算了希拉里·克林顿赢得普选票的后验概率,并发现其非常接近 100%。然而,FiveThirtyEight 给了她 81.4% 的机会³。是什么解释了这种差异?下面,我们描述了 FiveThirtyEight 模型中包含的另一种变异来源,即 系统性民意调查误差,它解释了这种差异。

选举结束后,人们可以查看民意调查平均值与实际结果之间的差异。一个重要的观察结果是,我们最初的模型没有考虑到这一点,那就是通常可以看到一种影响大多数民意调查员的系统性民意调查误差。统计学家将此称为 偏差。这种偏差的原因尚不清楚,但历史数据显示其波动。在一次选举中,民意调查可能使民主党获得 2% 的优势,下一次使共和党获得 1% 的优势,然后没有偏差,后来使共和党获得 3% 的优势。在 2016 年,民意调查使民主党获得 1-2% 的优势。

虽然我们知道这种系统性民意调查误差会影响我们的民意调查,但我们无法知道这种偏差是什么,直到选举之夜。因此,我们无法相应地纠正我们的民意调查。我们可以做的是在我们的模型中包含一个考虑变异性的项。

12.3 层级模型的数学表示

假设我们从一位民意调查员那里收集数据,并且我们假设没有系统性误差。该民意调查员收集了多个样本量为 \(N\) 的民意调查,因此我们观察到了多个关于分布的测量值 \(Y_1, \dots, Y_J\)。假设希拉里的真实比例是 \(p\),分布是 \(\theta\)。尿壶模型理论告诉我们,这些随机变量大约服从正态分布,期望值为 \(\theta\),标准误差为 \(2 \sqrt{p(1-p)/N}\)

\[Y_j \sim \mbox{N}\left(\theta, \, 2\sqrt{p(1-p)/N}\right) \]

我们使用 \(j\) 作为指标来标记民意调查,因此第 \(j\) 次民意调查对应于第 \(j\) 次民意调查报告的结果。

下面是一个模拟六个民意调查的例子,假设分布为 2.1,\(N\) 为 2,000:

set.seed(2012)
J <- 6
N <- 3000
theta <- .021
p <- (theta + 1)/2
y <- rnorm(J, theta, 2*sqrt(p*(1 - p)/N))

现在,假设我们有 \(J=6\) 个来自 \(I=5\) 位不同民意调查员的民意调查。为了简单起见,让我们假设所有民意调查都有相同的样本量 \(N\)。尿壶模型告诉我们,对于所有民意调查员,分布都是相同的,因此为了模拟数据,我们为每个民意调查员使用相同的模型:

I <- 5
y <- sapply(1:I, function(i) rnorm(J, theta, 2*sqrt(p*(1 - p)/N)))

正如预期的那样,模拟数据并没有真正捕捉到实际数据的特征,因为它没有考虑到民意调查员之间的变异:

为了解决这个问题,我们需要表示两种变异水平,并且我们需要两个指标,一个用于民意调查员,一个用于每位民意调查员进行的每个民意调查。我们使用 \(Y_{ij}\) 来表示,其中 \(i\) 代表民意调查员,\(j\) 代表该民意调查员进行的第 \(j\) 次民意调查。现在模型已经扩展,包括民意调查员效应 \(h_i\),FiveThirtyEight 称其为 house effects,标准差为 \(\sigma_h\)

\[\begin{aligned} h_i &\sim \mbox{N}\left(0, \sigma_h\right)\\ Y_{ij} \mid h_i &\sim \mbox{N}\left(\theta + h_i, \, 2\sqrt{p(1-p)/N}\right) \end{aligned} \]

为了模拟特定调查员的数据,我们首先需要抽取一个 \(h_i\),然后在此效果添加后生成单个调查数据。在下面的模拟中,我们假设 \(\sigma_h\) 为 0.025,并使用 rnorm 生成 \(h\)

h <- rnorm(I, 0, 0.025)
y <- sapply(1:I, function(i) theta + hi] + [rnorm(J, 0, 2*sqrt(p*(1 - p)/N)))

模拟数据现在看起来更接近观察数据:

图片

注意,\(h_i\) 对所有观察到的调查员 \(i\) 的扩散都是共同的。不同的调查员有不同的 \(h_i\),这解释了为什么我们可以看到点群在调查员之间上下移动。

现在,在上面的模型中,我们假设平均 house effect 为 0:我们用 rnorm(I, 0, 0.025) 生成它。我们认为,对于每个偏向某一党派的调查员,都有一个偏向另一党派的调查员,这样在计算所有调查的平均值时误差会平均化。在这种情况下,调查平均是无偏的。然而,如上所述,当我们研究过去选举时,观察到系统性调查误差。

仅凭 2016 年的数据,我们无法观察到这种偏差,但如果我们收集历史数据,我们会看到调查的平均值与实际结果相比,偏差超过了上述模型等预测的偏差。我们在此不展示数据,但如果我们将每次过去选举的调查平均值与实际选举之夜的结果进行比较,我们会观察到 2-4%的标准差差异。

虽然我们无法观察到偏差,但我们可以定义一个模型来解释其变异性。我们通过在模型中添加另一个层次来实现这一点:

\[ \begin{aligned} b &\sim \mbox{N}\left(0, \sigma_b\right)\\ h_j \mid \, b &\sim \mbox{N}\left(b, \sigma_h\right)\\ Y_{ij} | \, h_j, b &\sim \mbox{N}\left(\theta + h_j, \, 2\sqrt{p(1-p)/N}\right) \end{aligned} $$ 这个模型捕捉到三种不同的变异性来源: 1. **选举中的系统性误差**,由随机变量 $b$ 表示,其变异性由 $\sigma_b$ 衡量。 1. **调查员之间的变异性**,通常称为*house effect*,由 $\sigma_h$ 衡量。 1. **每个调查中的抽样变异性**,由选民随机抽样引起,其值为 $2\sqrt{p(1-p)/N}$,其中 $p = (\theta + 1)/2$。 没有包括像 $b$ 这样的选举级偏差项,导致许多预测者对自己的预测过于自信。关键点是 $b$ 在一次选举到另一次选举之间会变化,但在一次选举内的所有调查员和调查中保持不变。因为 $b$ 没有索引,我们不能仅使用一次选举的数据来估计 $\sigma_b$。此外,这个共享的 $b$ 意味着同一选举内的所有随机变量 $Y_{ij}$ 都是相关的,因为它们受到相同的基本选举级偏差的影响。 ## 12.4 计算后验概率 现在,让我们将上述模型拟合到数据中。我们将使用第十章中定义的`one_poll_per_pollster`数据**: ```r one_poll_per_pollster <- polls |> group_by(pollster) |> filter(enddate == max(enddate)) |> ungroup() ``` 在这里,我们只有一个民意调查员的一个民意调查,所以我们将去掉 $j$ 索引,并像以前一样用 $Y_1, \dots, Y_I$ 表示数据。作为提醒,我们有来自 $I=$20 个民意调查员的数据。根据上述模型假设,我们可以从数学上证明平均 $\bar{Y}$ ```r y_bar <- mean(one_poll_per_pollster$spread) ``` 具有期望值 $\theta$;因此,从长远来看,它提供了一个对感兴趣结果的非偏估计。但这个估计有多精确?我们能用观察到的样本标准差来构建对 $\bar{Y}$ 标准误差的估计吗? 结果表明,由于 $Y_i$ 是相关的,估计标准误差比我们之前描述的要复杂。具体来说,我们可以证明标准误差可以用以下方式估计: ```r sigma_b <- 0.03 s2 <- with(one_poll_per_pollster, sd(spread)²/length(spread)) se <- sqrt(s2 + sigma_b²) ``` 如前所述,估计 $\sigma_b$ 需要过去选举的数据。然而,收集这些数据是一个复杂的过程,超出了本书的范围。为了提供一个实用的例子,我们将 $\sigma_b$ 设置为 3%。 我们现在可以重新进行贝叶斯计算,以考虑这种额外的变异性。这种调整得到的结果与 FiveThirtyEight 的估计非常接近: ```r theta_0 <- 0 tau <- 0.05 B <- se²/(se² + tau²) posterior_mean <- B*theta_0 + (1 - B)*y_bar posterior_se <- sqrt(1/(1/se² + 1/tau²)) 1 - pnorm(0, posterior_mean, posterior_se) #> [1] 0.803 ``` ## 12.5 预测选举人团 到目前为止,我们一直关注普选票。然而,在美国,选举并不是由普选票决定的,而是由所谓的*选举人团*决定的。每个州根据其人口规模以某种复杂的方式获得一定数量的选举人票。在 2016 年,最大的州加利福尼亚州有 55 张选举人票,而最小的七个州和哥伦比亚特区有 3 张。 除了两个州,缅因州和内布拉斯加州,美国总统选举中的选举人票是以全胜为基础分配的。这意味着如果一个候选人在一个州的普选票中只多了一票,他们就会获得该州所有的选举人票。例如,2016 年只要在加利福尼亚州赢得一票,就能确保其全部 55 张选举人票。 这个系统可能导致出现这样的情况:候选人赢得了全国普选票,但在选举人团中却输了,就像 1876 年、1888 年、2000 年和 2016 年发生的那样。 选举人团的设计是为了平衡人口众多州的影響力,并保护较小州在总统选举中的利益。作为一个联邦制国家,美国包括了担心失去对较大州权力的州。在 1787 年的宪法会议上,较小的州协商了这个系统,以确保他们的声音仍然重要,根据他们的参议员和代表获得选举人。这个妥协有助于确保他们在联邦中的地位。** **### 组织数据 现在我们已经准备好预测 2016 年的选举人团结果。我们首先创建一个包含每个州选举人票数的数据框: ```r results <- results_us_election_2016 |> select(state, electoral_votes) ``` 然后我们汇总选举前最后几周进行的民意调查的结果,并仅包括在注册和可能投票的选民中进行的调查。我们定义 `spread` 为估计的比例差异: ```r polls <- polls_us_election_2016 |> filter(state != "U.S." & enddate >= "2016-11-02" & population != "a") |> mutate(spread = (rawpoll_clinton - rawpoll_trump)/100) ``` 如果一个民意调查员在这个时期进行了不止一次的调查,我们只保留最新的。 ```r polls <- polls |> arrange(population, desc(enddate)) |> group_by(state, pollster) |> slice(1) |> ungroup() ``` ### 加权平均值 而不是简单地平均民意调查,FiveThirtyEight 根据字母等级给民意调查员分配权重,该等级存储在 `grade` 列中。这个等级反映了民意调查员过去的准确性和可靠性。我们生成一个包含等级和权重的表格,以供将来使用: ```r weights <- data.frame(grade = unique(sort(polls$grade))) |> mutate(weight = seq(0.3, 1, length = length(grade))) ``` FiveThirtyEight 在确定每个民意调查的权重时考虑了各种因素,包括选举日的接近程度。然而,为了简化,这次分析仅关注民意调查员的等级。* 等级较高的民意调查员在模型中分配更多的权重,使他们的结果具有更大的影响力,而等级较低或可靠性较低的民意调查员贡献较少。这通过加权平均在数学上实现: $$ \bar{Y}_w = \frac{\sum_{i=1}^N w_i Y_i}{\sum_{i=1}^N w_i} \]

其中 \(Y_i\) 代表民意调查员 \(i\) 的结果,\(w_i\) 是分配给民意调查员 \(i\) 的权重,

\(N\) 是民意调查员的总数。我们在 \(\bar{Y}_w\) 上添加 \(w\) 下标来表示它是一个加权平均值。

为了直观理解,可以将权重视为完整数据点的分数。例如,如果最大权重是 1,则权重为 \(0.5\) 的民意调查员对完全信任的民意调查员的贡献减半,而权重为 \(0.25\) 可以解释为贡献了四分之一的民意调查。

我们使用类似的方程来估计标准差:

\[s_w = \sqrt{\frac{\sum_{i=1}^N w_i (Y_i - \bar{Y}_w)}{\frac{N-1}{N}\sum_{i=1}^N w_i}} \]

我们可以使用在第 7.4 节中学到的公式来推导我们加权估计的标准误差:

\[\mathrm{SE}[\bar{Y}_w] = \frac{\sigma_h}{\sqrt{N_{\mbox{eff}}}} \mbox{ with } N_{\mbox{eff}} = \frac{\left(\sum_{i=1}^N w_i\right)²}{\sum_{i=1}^N w_i²} \]

因为它在样本平均的方程中占据相同的位置,\(N_{\mbox{eff}}\) 被称为有效样本量。请注意,当所有权重都是 1 时,它等于 \(N\),而当权重接近 0 时,它变得更小。

我们现在使用这些方程来计算加权平均值:

polls <- polls |> 
 filter(!is.na(grade)) |>
 left_join(weights, by = "grade") |> 
 group_by(state) |>
 summarize(n = n(),
 avg = sum(weight*spread)/sum(weight), 
 sd = sqrt(sum(weight*(spread - avg)²)/((n - 1)/n*sum(weight))),
 n_eff = sum(weight)²/sum(weight²))

我们假设民意调查员或house effect标准差 \(\sigma_h\) 在各州之间是相同的,并取基于有效样本量大于或等于 5 的这些值的中间值:

polls$sd <- with(polls, median(sd[n_eff >= 5], na.rm = TRUE))

在本书的线性模型部分,我们探讨了估计标准差的一种更严格的统计方法。* 接下来我们使用 left_join 命令将包含每个州的选举人票的 results 数据框和包含汇总统计数据的 polls 数据框合并:

results <- left_join(results, polls, by = "state")

构建先验概率

为了进行概率论证,我们将使用贝叶斯模型。为此,我们需要每个州先验分布的均值和标准差。我们将假设先验分布为正态分布,并使用 2012 年选举结果来构建先验均值。具体来说,我们计算民主党(奥巴马)和共和党(罗姆尼)候选人之间的差异:

results <- results_us_election_2012 |> 
 mutate(theta_0 = (obama - romney)/100) |> 
 select(state, theta_0) |> 
 right_join(results, by = "state")

与对普选票先验的处理一样,我们为先验标准差分配 5%或 \(\tau=0.05\)

results$tau <- 0.05

计算后验分布

在建立先验分布后,我们可以计算每个州的后验分布。在某些州,结果被认为是高度可预测的,一个政党(共和党或民主党)几乎肯定能赢。因此,在这些州没有进行民意调查。在这种情况下,后验分布保持与先验分布相同。

results <- results |>
 mutate(B = sd²/n_eff/((sd²/n_eff) + tau²),
 posterior_mean = if_else(is.na(avg), theta, B*theta_0 + (1 - B)*avg),
 posterior_se = if_else(is.na(avg), tau, sqrt(1/(n_eff/sd² + 1/tau²))))

蒙特卡洛模拟

然后我们使用蒙特卡洛方法为每个州生成 50,000 个选举日结果 \(\theta\)。对于每次迭代,我们检查每个迭代的 \(\theta\)。如果 \(\theta<0\),则在该迭代中特朗普获得该州的全部选举人票。我们假设每个州的投票结果是独立的**。

set.seed(1983)
B <- 50000
n_states <- nrow(results)
trump_ev <- replicate(B,{
 theta <- with(results, rnorm(n_states, posterior_mean, posterior_se))
 sum(results$electoral_votes[theta < 0])
})
mean(trump_ev > 269)
#> [1] 0.00322

这个模型给特朗普赢得选举的机会不到 1%,这与普林斯顿选举联合会的预测相似。我们现在知道这相当不准确。发生了什么?

上面的模型忽略了系统性投票误差的可能性,并且错误地假设不同州的投票结果是独立的。为了纠正这一点,我们假设系统性误差项的标准差为 3%。对于每次迭代,我们为所有州随机生成系统性误差并将其添加到结果中。

sigma_b <- 0.03
trump_ev_2 <- replicate(B, {
 bias <- rnorm(1, 0, sigma_b) 
 mu <- with(results, rnorm(n_states, posterior_mean, posterior_se))
 mu <- mu + bias
 sum(results$electoral_votes[mu < 0])
})
mean(trump_ev_2 > 269)
#> [1] 0.243

这给特朗普赢得选举的机会接近 25%,这最终证明是一个更合理的估计,并且更接近 FiveThirtyEight 预测的 29%。

区域相关性

投票在 2016 年选举后因“预测失误”而受到严重批评**。然而,投票平均实际上以显著的准确性预测了最终结果,这通常是情况。

图片

事实上,只有 5 个州中,投票平均在差异的符号上犯了错误。

tmp |> filter(sign(spread) != sign(avg)) |>
 select(state, avg, spread) |>
 mutate(across(-state, ~round(.,1))) |>
 setNames(c("State", "Polling average", "Actual result"))
#>            State Polling average Actual result
#> 1        Florida             0.1          -1.2
#> 2   Pennsylvania             2.0          -0.7
#> 3       Michigan             3.3          -0.2
#> 4 North Carolina             0.9          -3.7
#> 5      Wisconsin             5.6          -0.8

然而,请注意,所有误差都在同一方向,这表明存在系统性的投票误差。然而,散点图显示有几个点高于身份线,这表明所有州均匀偏差的假设存在偏差。更仔细的检查表明,偏差因地理区域而异,有些地区的效应比其他地区更强。这种模式通过直接绘制区域间的差异也得到了进一步证实:

图片

高级预测模型,如 FiveThirtyEight 的,认识到系统性的调查误差通常按地区变化。为了在模型中反映这一点,我们可以将州分为地区,并引入一个区域误差项。由于同一地区的州共享这个误差,它会在它们的成果之间创建相关性。

最终结果

更复杂的模型也通过使用允许比正态分布更极端事件的分布来考虑变异性。例如,具有少量自由度的 t 分布可以捕捉这些罕见但具有影响力的结果。

通过纳入这些调整,区域误差和重尾分布——我们的模型产生的特朗普的概率与 FiveThirtyEight 报道的 29%相似。模拟显示,考虑区域调查误差和相关性会增加结果的总体变异性。

12.6 预测

预测者旨在在选举日之前很久就预测选举结果,随着新调查的发布更新他们的预测。然而,一个关键问题仍然存在:选举几周前的调查对最终结果有多大的信息量?为了解决这个问题,我们考察了调查结果随时间的变化以及这种变异性如何影响预测准确性。

为了确保我们观察到的变异性不是由于调查员效应,让我们研究一个调查员的数据:

one_pollster <- polls_us_election_2016 |> 
 filter(pollster == "Ipsos" & state == "U.S.") |> 
 mutate(spread = rawpoll_clinton/100 - rawpoll_trump/100)

由于没有调查员效应,那么理论上的标准误差可能与数据得出的标准差相匹配。但经验标准差高于可能的理论估计的最高值:

one_pollster |> mutate(p_hat = (spread + 1)/2, N = samplesize) |>
 summarize(empirical = sd(spread), 
 theoretical = median(2*sqrt(p_hat*(1 - p_hat)/N))) 
#>   empirical theoretical
#> 1    0.0403      0.0277

此外,数据的分布并不像理论预测的那样正常:

我们所描述的模型包括调查员之间的变异性以及抽样误差。但这个图表是针对一个调查员的,我们看到的变异性肯定不是由抽样误差解释的。额外的变异性从何而来?以下图表强烈表明,这种变异性来自理论未考虑的时间波动,该理论假设\(p\)是固定的:

数据中的一些峰值和谷值与重大事件(如政党大会)相吻合,这些事件通常会暂时提升候选人的支持率。如果我们为其他调查员生成相同的图表,我们会发现这些模式在几个调查员中是一致的。这表明任何预测模型都应该包含一个术语来解释时间效应。这个术语的变化本身也会随时间而变化,因为随着选举日的临近,其标准差应该减小并接近零。

调查员试图从这些数据中估计趋势并将它们纳入他们的预测中。我们可以使用平滑函数来模拟时间趋势,然后使用这个趋势来改进预测。书中机器学习部分第 28.3 节讨论了多种估计趋势的方法。

12.7 练习

  1. 创建以下表格:
library(tidyverse
library(dslabs)
polls <- polls_us_election_2016 |> 
 filter(state != "U.S." & enddate >= "2016-10-31") |> 
 mutate(spread = rawpoll_clinton/100 - rawpoll_trump/100)

现在,对于每一项调查,使用中心极限定理(CLT)为每项调查报告的分布创建一个 95%置信区间。将得到的对象命名为 cis,其中包含置信区间的下限和上限的列。使用select函数保留state, startdate, enddate, pollster, grade, spread, lower, upper这些列。

  1. 您可以使用left_join函数将最终结果添加到您刚刚创建的cis表中,如下所示:
add <- results_us_election_2016 |> 
 mutate(actual_spread = clinton/100 - trump/100) |> 
 select(state, actual_spread)
cis <- cis |> 
 mutate(state = as.character(state)) |> 
 left_join(add, by = "state")

现在,确定 95%置信区间包含存储在actual_spread中的选举之夜结果的频率。

  1. 重复此操作,但显示每个调查员的命中比例。仅考虑拥有超过 5 项调查的调查员,并按从好到坏的顺序排序。显示每个调查员进行的调查数量以及每个调查员的 FiveThirtyEight 等级。提示:在调用summarize时使用n=n(), grade = grade[1]

  2. 重复练习 3,但不是按调查员分层,而是按州分层。注意,在这里我们无法显示等级。

  3. 基于练习 4 的结果制作一个条形图。使用coord_flip

  4. cis表中添加两列,通过计算每个调查预测分布与实际分布之间的差异,并定义一个名为hit的列,如果符号相同则为真。命名为resids。提示:使用sign函数。

  5. 创建一个类似于练习 5 的图表,但用于显示分布符号与选举之夜结果一致的次数比例。

  6. 在练习 7 中,我们看到对于大多数州,调查 100%正确。只有 9 个州的调查超过 25%的时间出错。特别是,注意在威斯康星州,每个调查都错了。在宾夕法尼亚州和密歇根州,超过 90%的调查符号错误。制作一个误差的直方图。这些误差的中位数是多少?

  7. 我们看到在州一级,中误差为 3%,有利于克林顿。分布不是以 0 为中心,而是以 0.03 为中心。这与第 12.2 节中描述的一般偏差有关。创建一个箱形图来查看偏差是否影响所有州,或者是否影响了某些州的差异。使用filter(grade %in% c("A+","A","A-","B+") | is.na(grade)))仅包括等级高的调查员。

  8. 在 2013 年 4 月,职业棒球运动员何塞·伊格莱西亚斯开始了他的职业生涯。他表现异常出色,拥有优秀的击球率(AVG)为.450。击球率统计是衡量成功的一种方式。粗略地说,它告诉我们击球时的成功率。何塞在 20 次尝试中有 9 次成功。.450 的击球率意味着何塞在击球时成功的比例相当高,这在历史上是相当罕见的。事实上,自从 1941 年泰德·威廉姆斯完成这一壮举以来,没有人完成过赛季的AVG达到或超过.400!我们希望在球员大约有 500 次尝试或击球之后预测何塞赛季结束时的击球率。使用频率派技术,我们别无选择,只能预测他的 AVG 将在赛季结束时为.450。计算成功率的置信区间。

  9. 尽管频率派预测为.450,但没有任何一个棒球爱好者会做出这样的预测。为什么?一个原因是他们知道这个估计有很大的不确定性。然而,主要原因是他们隐含地使用了一个考虑了多年跟踪棒球的层次模型。使用以下代码探索 2013 年之前三个赛季的击球率分布,并描述这告诉我们什么。

library(tidyverse
library(Lahman)
filter(Batting, yearID %in% 2010:2012) |> 
 mutate(AVG = H/AB) |> 
 filter(AB > 500) |> 
 ggplot(aes(AVG)) +
 geom_histogram(color = "black", binwidth = .01) +
 facet_wrap( ~ yearID)
  1. 所以是何塞幸运,还是他是过去 50 年中见过的最佳击球手?或许这是运气和天赋的结合。但各占多少呢?如果我们确信他是幸运的,我们应该将他交易到一个信任.450 观察结果并且可能高估他潜力的球队。层次模型为我们提供了如何看到.450 观察结果的数学描述。首先,我们随机选择一个球员,其内在能力由例如\(\theta\)这样的指标总结。然后,我们观察 20 次随机结果,成功概率为\(\theta\)。你会在层次模型的第一级使用什么模型?

  2. 描述层次模型的第二级。

  3. 将层次模型应用于何塞的数据。假设我们想要预测他的内在能力,即他的真实击球率\(\theta\)。写下层次模型的分布。

  4. 我们现在准备计算\(\theta\)在观察到的数据\(\bar{Y}\)条件下的分布。计算给定当前平均\(\bar{Y}\)\(\theta\)的期望值,并给出数学公式的直观解释。

  5. 我们从一个频率派 95%置信区间开始,该区间忽略了其他球员的数据,仅总结了何塞的数据:.450 \(\pm\) 0.220。根据层次模型构建\(\theta\)的置信区间。

  6. 可信区间表明,如果另一支球队对.450 的观察印象深刻,我们应该考虑交易 José,因为我们预测他只会略高于平均水平。有趣的是,红袜队在七月将 José Iglesias 交易给了底特律老虎队。以下是 José Iglesias 接下来五个月的击球平均分:

月份 打击数 安打数 平均分
四月 20 9 .450
五月 26 11 .423
六月 86 34 .395
七月 83 17 .205
八月 85 25 .294
九月 50 10 .200
总计(不含四月) 330 97 .293

哪种方法提供了更好的预测?


  1. https://www.nytimes.com/interactive/2016/upshot/presidential-polls-forecast.html↩︎

  2. https://fivethirtyeight.com/features/trump-is-just-a-normal-polling-error-behind-clinton/↩︎

  3. https://projects.fivethirtyeight.com/2016-election-forecast/↩︎

13  假设检验

原文:rafalab.dfci.harvard.edu/dsbook-part-2/inference/hypothesis-testing.html

  1. 统计推断

  2. 13  假设检验

在科学研究中,你经常会看到“结果具有统计学意义”这样的短语。这指向一种称为假设检验的技术,我们使用p 值,一种概率类型,来测试我们的初始假设或假设。

在假设检验中,我们不是提供我们正在研究的参数的估计值,而是提供一个作为支持或反驳特定假设的证据的概率。这个假设通常涉及参数是否与一个预定的值不同(通常是 0)。

当你可以将你的研究问题表述为参数是否与这个预定的值不同时,就会使用假设检验。它在各个领域都有应用,提出的问题例如:药物是否可以延长癌症患者的寿命?枪支销售的增加是否与更多的枪支暴力相关?班级规模是否会影响考试成绩?

以之前使用的彩色珠子为例。我们可能不关心蓝色珠子的确切比例,而是询问:蓝色珠子是否比红色珠子多?这可以重新表述为询问蓝色珠子的比例是否大于 0.5。

将参数等于预定值的初始假设称为零假设。它很受欢迎,因为它允许我们关注在零假设情景下数据的属性。一旦收集到数据,我们就估计参数并计算 p 值,即如果零假设为真,估计值达到观察到的极端情况的概率。如果 p 值很小,这表明零假设不太可能,提供了反对它的证据。

我们将在第十七章中看到更多关于假设检验的例子。

13.1 p 值

假设我们随机抽取一个样本 \(N=100\),观察到 \(52\) 个蓝色珠子,这给我们带来了 \(\bar{X} = 0.52\)。这似乎表明蓝色珠子比红色珠子多,因为 0.52 大于 0.5。然而,我们知道这个过程中存在偶然性,即使实际概率是 0.5,我们也有可能得到 52 个。我们将概率为 0.5 的假设,即 \(\pi = 0.5\),称为零假设。零假设是怀疑者的假设。

我们使用 \(\pi\) 来表示抽到蓝色珠子的概率,而不是像前几节中那样使用 \(p\),以避免参数 \(p\) 和 p 值中的 p 之间的混淆。* 我们观察到随机变量 \(\bar{X} = 0.52\),p 值是回答以下问题的答案:当零假设为真时,看到如此大的值的可能性有多大?如果 p 值足够小,我们 拒绝零假设 并说结果是 统计显著的

统计显著性阈值为 0.05 的 p 值在许多研究领域中被传统使用。0.01 的截止值也用于定义 高度显著性。0.05 的选择是有些任意的,并在 20 世纪 20 年代由英国统计学家罗纳德·费希尔普及。我们不推荐在没有理由的情况下使用这些截止值,并建议避免使用“统计显著”这一短语。* 要获得我们例子的 p 值,我们写出:

\[\mathrm{Pr}(\mid \bar{X} - 0.5 \mid > 0.02 ) \]

假设 \(\pi=0.5\)。在零假设下,我们知道:

\[\sqrt{N}\frac{\bar{X} - 0.5}{\sqrt{0.5(1-0.5)}} \]

是标准正态分布。因此,我们可以计算上述概率,即 p 值。

\[\mathrm{Pr}\left(\sqrt{N}\frac{\mid \bar{X} - 0.5\mid}{\sqrt{0.5(1-0.5)}} > \sqrt{N} \frac{0.02}{ \sqrt{0.5(1-0.5)}}\right) \]

N <- 100
z <- sqrt(N)*0.02/0.5
1 - (pnorm(z) - pnorm(-z))
#> [1] 0.689

在这种情况下,实际上在零假设下看到 52 或更大的可能性很大。

请记住,p 值和置信区间之间有密切的联系。在我们的例子中,如果一个 95% 的置信区间不包括 0.5,我们知道 p 值必须小于 0.05。一般来说,我们可以从数学上证明,如果一个 \((1-\alpha)\times 100\)% 的置信区间不包含零假设值,那么零假设将被拒绝,p 值将小于或等于 \(\alpha\)。因此,统计显著性可以通过置信区间来确定。

要了解更多关于 p 值的信息,您可以查阅任何统计学教科书。然而,一般来说,我们更喜欢报告置信区间而不是 p 值,因为它可以给我们一个关于估计大小的概念。如果我们只报告 p 值,我们就没有提供关于发现显著性的任何信息。因此,我们建议在可能的情况下避免使用 p 值。

13.2 力量(Power)

民意调查员在提供正确的置信区间方面并不成功,而是擅长预测谁会获胜。当我们取了一个 25 个珠子的样本大小时,置信区间的范围:

N <- 25
x_hat <- 0.48
(2*x_hat - 1) + c(-1.96, 1.96)*2*sqrt(x_hat*(1 - x_hat)/N)
#> [1] -0.432  0.352

包括 0。如果这是一个民意调查,并且我们被迫做出声明,我们不得不说是“平局”。

我们民意调查结果的一个问题是,考虑到样本大小和 \(\pi\) 的值,我们不得不牺牲错误判断的概率来创建一个不包括 0 的区间。

这并不意味着选举结果接近。这只意味着我们的样本量很小。在统计学教科书中,这被称为缺乏效力。在调查的背景下,效力是检测到与 0 不同的差异的概率。

通过增加我们的样本量,我们降低了标准误差,因此有更大的机会检测到差异的方向。

统计学教科书提供了关于假设检验的更多细节,包括 p 值、统计效力及相关概念的正式定义。推荐阅读部分列出了涵盖这些主题的深度文本,供有兴趣进一步探索的读者参考。

13.3 练习

  1. 从一个包含 50%蓝色珠子的 urn 模型中生成大小为\(N=1000\)的样本:
N <- 1000
pi0 <- 0.5 #we use pi0 to avoid the reserved constant pi
x <- rbinom(N, 1, pi0)

然后,如果\(\pi=0.5\),计算一个 p 值。重复此操作 10,000 次,并报告 p 值低于 0.05 的频率?它低于 0.01 的频率是多少?

  1. 绘制你在练习 1 中生成的 p 值的直方图。

  2. p 值都是 0.05。

  3. p 值是正态分布的;中心极限定理似乎成立。

  4. p 值是均匀分布的。

  5. p 值都小于 0.05。

  6. 用数学方法证明为什么我们在练习 2 中观察到直方图。

提示:为了计算 p 值,我们首先计算一个测试统计量\(Z\)。根据中心极限定理(CLT),在零假设下,\(Z\)近似服从标准正态分布。

然后计算 p 值:

\[p = 2\{1 - \Phi(|z|)\}, \]

其中\(z\)\(Z\)的观测值,\(\Phi(z)\)是标准正态分布的累积分布函数(CDF)(在 R 中为pnorm(z))。

为了理解 p 值的分布,考虑一个随机生成的 p 值小于或等于某个介于 0 和 1 之间的阈值\(a\)的概率:

\[\Pr(p \leq a) = \Pr\big( 2\{1 - \Phi(|Z|)\} \leq a \big). \]

记住,如果\(\Pr(p \leq a) = a\)对所有\(a\)\([0,1]\)范围内成立,那么\(p\)遵循均匀分布。证明当\(Z\)是标准正态分布时,这种关系成立。

  1. 从一个包含 52%蓝色珠子的 urn 模型中生成大小为\(N=1000\)的样本:
N <- 1000 
pi0 <- 0.52
x <- rbinom(N, 1, pi0)

计算一个 p 值来检验\(\pi=0.5\)。重复此操作 10,000 次,并报告 p 值大于 0.05 的频率?请注意,你正在计算 1 - 力量。

  1. 对以下值重复练习:
values <- expand.grid(N = c(25, 50, 100, 500, 1000), pi = seq(0.51 ,0.75, 0.01))

将效力作为\(N\)的函数绘制,每个pi0值用不同颜色的曲线表示**。

14  自助法

原文:rafalab.dfci.harvard.edu/dsbook-part-2/inference/bootstrap.html

  1. 统计推断

  2. 14  自助法

CLT 提供了一种构建置信区间和进行假设检验的有用方法。然而,它并不总是适用。在这里,我们提供了一个不依赖于 CLT 的估计分布的替代方法的简要介绍。

14.1 示例:中位数收入

假设你的人口收入分布如下:

set.seed(1995)
n <- 10⁶
income <- 10^(rnorm(n, log10(45000), log10(3)))
hist(income/10³, nclass = 1000)

* *总体中位数是:

median(income)
#> [1] 44939

假设我们无法访问整个总体,但想估计总体中位数,记为\(m\)。我们随机抽取 100 个观测值,并使用样本中位数\(M\)作为\(m\)的估计:

N <- 100
x <- sample(income, N)
median(x)
#> [1] 38461

现在的问题变成了:我们如何评估这个估计的不确定性?换句话说,我们如何根据我们的样本计算标准误差并构建\(m\)的置信区间?

在以下章节中,我们介绍了一种强大的重采样方法——自助法,它允许我们在不依赖强分布假设的情况下估计变异性并构建置信区间。

14.2 中位数置信区间

我们能否构建一个置信区间?\(M\)的分布是什么?

因为我们在模拟数据,我们可以使用蒙特卡洛模拟来了解\(M\)的实际分布。

m <- replicate(10⁴, {
 x <- sample(income, N)
 median(x)
})
hist(m, nclass = 30)
qqnorm(scale(m)); abline(0,1)

如果我们知道这个分布,我们可以构建一个置信区间。问题在于,正如我们之前所描述的,在实践中,我们无法访问这个分布。在之前的章节中,我们使用了 CLT,但我们学到的东西适用于平均值,而这里我们感兴趣的是中位数。我们可以看到,基于 CLT 的 95%置信区间

median(x) + 1.96*sd(x)/sqrt(N)*c(-1, 1)
#> [1] 21018 55905

与我们已知\(M\)的实际分布所生成的置信区间有很大不同

quantile(m, c(0.025, 0.975))
#>  2.5% 97.5% 
#> 34438 59050

自助法允许我们在无法访问完整总体分布的情况下近似蒙特卡洛模拟。其思路很简单:我们将观察到的样本视为总体。从这个样本中,我们抽取相同大小的新的数据集,进行有放回的抽取,并计算感兴趣的统计量,在这种情况下,是中位数,对于每个重新抽样的数据集。这些重新抽样的数据集被称为自助样本。

在许多实际情况下,从自助样本计算出的统计量的分布提供了对原始统计量抽样分布的良好近似。这种近似允许我们估计变异性,计算标准误差,并构建置信区间,而无需知道真实的潜在分布。

以下代码演示了如何生成自助样本并近似中位数的抽样分布:

m_star <- replicate(10⁴, {
 x_star <- sample(x, N, replace = TRUE)
 median(x_star)
})

注意使用自助法构建的置信区间与使用理论分布构建的置信区间非常接近:

quantile(m_star, c(0.025, 0.975))
#>  2.5% 97.5% 
#> 30253 56909

14.3 练习

  1. 生成一个类似这样的随机数据集:
y <- rnorm(100, 0, 1)

估计第 75 百分位数,我们知道它是:

qnorm(0.75)

使用样本分位数:

quantile(y, 0.75)

运行蒙特卡洛模拟以学习这个随机变量的期望值和标准误差。

  1. 在实践中,我们无法运行蒙特卡洛模拟,因为我们不知道是否使用了rnorm来模拟数据。使用自助法仅使用初始样本y来估计标准误差。使用 10 个自助样本。

  2. 重新做练习 2,但使用 10,000 个自助样本。

推荐阅读

原文:rafalab.dfci.harvard.edu/dsbook-part-2/inference/reading-inference.html

  1. 统计推理

  2. 推荐阅读

  • **弗里德曼,D.,皮萨尼,R.,珀维斯,R. (2007). 统计学 (第 4 版).

    强调对估计和不确定性的概念理解。特别是在解释标准误差和误差范围方面表现突出。

  • **摩尔,D. S.,麦卡布,G. P.,克雷格,B. A. (2017). 统计学实践导论 (第 9 版).

    一本经典的入门文本,从抽样分布通过置信区间到实际解释,发展了推理。

  • **伯格,J. O. (1985). 统计决策理论与贝叶斯分析 (第 2 版). Springer-Verlag.

    一本经典和全面的参考书,从决策理论的第一原理出发发展贝叶斯方法。推荐给希望更深入探索数学基础的读者。

  • 内特·西尔弗 (2016). *五三八大联盟的选举预测如何工作.

    五三八大联盟对 2016 模型的官方解释,包括民意调查加权、相关误差和模拟方法。

    fivethirtyeight.com/features/how-fivethirtyeights-election-forecast-works/

  • 五三八大联盟 (2020). *我们的总统预测如何工作 (2020 版).

    2020 模型细节更新,包括对州和国家民意调查相关性的调整、房屋效应以及投票率和选举人团的不确定性。

    fivethirtyeight.com/features/how-our-presidential-forecast-works/

  • 安德鲁·格尔曼 (2020). *选举预测:我们并不像我们想象的那么确定.

    对模型不确定性、相关民意调查误差以及预测概率解释的统计视角。

    statmodeling.stat.columbia.edu/2020/11/02/election-forecasting-why-were-not-as-sure-as-we-think/

  • **詹姆斯,G.,温滕,D.,哈斯蒂,T.,蒂布希拉尼,R. (2021). 统计学习导论 (第 2 版). Springer.

    查看关于重采样方法的章节,以直观的示例和代码介绍自助法。

  • **埃弗龙,B.,蒂布希拉尼,R. J. (1993). 自助法导论. Chapman & Hall/CRC.

    经典、权威的处理方式。从第一原理发展出自助法,包括理论、实用指导和许多示例。

线性模型

原文:rafalab.dfci.harvard.edu/dsbook-part-2/linear-models/intro-to-linear-models.html

到目前为止,本书主要关注单变量数据集。然而,在现实数据分析中,我们通常对两个或更多变量之间的关系感兴趣。在本书的这一部分,我们介绍了线性模型,这是一个统一研究变量之间关联方法的通用框架,包括简单回归和多变量回归、治疗效果模型、关联测试和广义线性模型。我们将通过几个案例研究来阐述这些观点。我们将研究身高是否具有遗传性(15 回归简介)、高脂肪饮食是否会使老鼠变重(17 治疗效果模型)、荷兰研究资金中是否存在性别偏见(关联测试)以及如何在预算内组建棒球队(20 多变量回归)。我们还包含了一章关于重要概念“关联不是因果关系”(19 关联不是因果关系),其中详细讨论了在解释变量之间的关系时出现的挑战和例子。

15  回归简介

原文:rafalab.dfci.harvard.edu/dsbook-part-2/linear-models/regression.html

  1. 线性模型

  2. 15  回归简介

为了理解相关性和简单回归的概念,我们转向孕育回归本身的那个数据集。这个例子来自遗传学。弗朗西斯·高尔顿¹研究了人类特征中的变异和遗传,从家庭收集数据以研究特征是如何从父母传递给孩子的。通过这项工作,他引入了相关性和回归的概念,并探讨了遵循正态分布的变量对之间的关系。当然,当高尔顿收集他的数据时,我们对遗传学的理解远不如今天这么深入。他试图回答的一个核心问题是:我们能在多大程度上根据父母的身高预测孩子的身高?他为了回答这个问题而开发的统计工具仍然是基础性的,并且现在被广泛应用于各种应用中。我们通过讨论一个称为回归谬误的概念来结束本章,这是一个在解释回归结果时出现的常见误解,我们用一个来自体育界的真实数据示例来说明它。

15.1 案例研究:身高是否遗传?

我们可以通过HistData包访问高尔顿家族的身高数据。这些数据包含几十个家庭的高度:母亲、父亲、女儿和儿子。为了模仿高尔顿的分析,我们将创建一个包含父亲和每个家庭随机选择的一个儿子的身高的数据集。在这里我们使用data.table包,因为它允许编写更简洁的代码:

library(data.table)
library(HistData)
 set.seed(1983)
galton <- as.data.table(GaltonFamilies)
galton <- galtongender == "male", .SD[[sample(.N, 1)], by = family]
galton <- galton[, .(father, son = childHeight)]

假设我们被要求总结父亲和儿子的数据。由于这两个分布都很好地被正态分布所近似,我们可以使用两个平均值和两个标准差作为总结:

galton, .(f_avg=[mean(father), f_sd=sd(father), s_avg=mean(son), s_sd=sd(son))]
#>    f_avg  f_sd s_avg  s_sd
#>    <num> <num> <num> <num>
#> 1:  69.1  2.55  69.2  2.72

然而,这个总结未能描述数据的一个重要特征:父亲越高,儿子也越高。

galton |> ggplot(aes(father, son)) + geom_point(alpha = 0.5)

* *我们将了解到相关系数是两个变量如何一起移动的有信息量的总结,然后通过注意到如何使用另一个变量来预测一个变量来激励简单的回归。

15.2 相关系数

相关系数**定义为一系列成对值 \((x_1, y_1), \dots, (x_n,y_n)\) 的标准化值乘积的平均值:

\[\rho = \frac{1}{n} \sum_{i=1}^n \left( \frac{x_i-\mu_x}{\sigma_x} \right)\left( \frac{y_i-\mu_y}{\sigma_y} \right) \]

其中 \(\mu_x, \mu_y\) 分别是 \(x_1,\dots, x_n\)\(y_1, \dots, y_n\) 的平均值,\(\sigma_x, \sigma_y\) 是标准差。统计学书籍中常用希腊字母 \(\rho\) 表示 \(r\),即相关性。\(r\)回归 的第一个字母并非巧合。很快我们就会了解到相关性和回归之间的联系。

我们可以使用 R 代码表示上面的公式:

rho <- mean(scale(x) * scale(y))

为了了解这个方程如何捕捉两个变量一起移动的方式,请注意,每个项 \(\frac{x_i - \mu_x}{\sigma_x}\) 表示 \(x_i\) 的值与 \(x\) 的平均值相差多少个标准差,而 \(\frac{y_i - \mu_y}{\sigma_y}\)\(y_i\) 相对于 \(y\) 的平均值做同样的处理。如果 \(x\)\(y\) 没有关系,这些标准化值的乘积 \(\left(\frac{x_i - \mu_x}{\sigma_x}\right)\left(\frac{y_i - \mu_y}{\sigma_y}\right)\) 将会正(正乘正或负乘负)的次数与负(正乘负或负乘正)的次数大致相同。当平均时,这些乘积会相互抵消,得到接近零的相关性。如果 \(x\)\(y\) 倾向于同时增加或减少,大多数乘积将是正的,从而得到正相关。如果其中一个增加而另一个减少,大多数乘积将是负的,从而得到负相关。

相关系数始终介于 -1 和 1 之间。我们可以用数学方法证明这一点,并将其作为练习留给你们去证明。

对于其他成对数据,相关性介于 -1 和 1 之间。使用 cor 函数计算的父亲和儿子身高之间的相关性约为 0.5:

galton, [cor(father, son)]
#> [1] 0.43

函数 cor(x, y) 计算样本相关性,它将乘积之和除以 length(x)-1 而不是 length(x)。这种做法的理由与我们在计算样本标准差 sd(x) 时除以 length(x)-1 的理由相似。也就是说,这种调整有助于考虑样本的自由度,这对于无偏估计是必要的。* *为了了解不同 \(\rho\) 值的数据看起来是什么样子,以下是六个相关系数从 -0.9 到 0.99 的成对示例:

样本相关性是一个随机变量

在我们继续将相关性连接到回归之前,让我们先回顾一下随机变异性。

在大多数数据分析项目中,我们观察到包含随机变化的数据。例如,在许多情况下,我们观察到的不是感兴趣的整体人群的数据,而是随机样本的数据。与平均值和标准差一样,样本相关性 是最常用的总体相关性的估计。这意味着我们计算并用作总结的相关性是一个随机变量。

为了说明,让我们假设 179 对父亲和儿子构成了我们的整个种群。一个不幸的遗传学家只能负担得起 25 对随机样本的测量。样本相关系数可以通过以下方式计算:

r <- galton[sample(.N, 25, replace = TRUE), cor(father, son)]

r 是一个随机变量。我们可以运行蒙特卡洛模拟来查看其分布:

N <- 25
r <- replicate(1000, galton[sample(.N, N, replace = TRUE), cor(father, son)])
hist(r, breaks = 20)

* *我们看到 r 的期望值是总体相关系数:

mean(r)
#> [1] 0.427

并且它相对于 r 可以取的值范围的标准误差相对较高:

sd(r)
#> [1] 0.161

因此,在解释相关性时,请记住,从样本中得出的相关性是包含不确定性的估计。

此外,请注意,由于样本相关系数是独立抽取的平均值,中心极限定理实际上适用:对于足够大的 \(N\)r 的分布近似正态,期望值为 \(\rho\),标准差为 \(\frac{1-r²}{\sqrt{N}}\)。请注意,这个标准差的推导很复杂,这里没有展示。

在我们的例子中,下面的 qqplot 显示 \(N=25\) 并不足以使正态近似很好地工作。观察到的分布的尾部始终低于理论正态分布的预测:

如果你增加 \(N\),你会看到分布收敛到正态分布。

相关性并不总是有用的汇总

相关性并不总是两个变量之间关系的良好汇总。以下四个被称为安斯康姆四重奏的人工数据集,著名地说明了这一点。所有这些对的相关系数都是 0.82:

为了理解相关性作为汇总统计量何时有意义,我们回到使用父亲身高预测儿子身高的例子。这个例子将有助于激发并定义线性回归。我们首先演示相关性如何对预测有用。

15.3 条件期望

高尔顿想了解我们如何从父母的身高预测孩子的身高。首先,我们将这个问题框架化为一个预测问题。

假设我们被要求猜测一个随机选取的儿子的高度,但我们没有被告知父亲的身高。由于儿子身高的分布近似正态分布,最合理的单一数值预测是总体均值,\(\mu_y\),因为数据最集中的值就在这个值周围。稍后,在第二十八章中,我们将解释均值在预测中也有理想的数学性质:它最小化了期望平方误差。

因此,如果没有关于父亲的信息,我们对儿子身高的最佳预测就是该人群中儿子的平均身高。在实践中,我们使用样本平均数来近似这个值,\(\hat{\mu}_y =\) 69.2。

但如果我们被告知父亲比平均水平高,比如说 72 英寸高,我们是否仍然猜测 \(\mu_y\) = 69.2 作为儿子的身高?

结果表明,如果我们能够收集到大量身高为 72 英寸的父亲的数据,他们儿子的身高分布将是正态分布。这意味着在这个子集上计算的分布的平均值将是我们最好的预测。

通常,我们称这种方法为 条件化。其思想是将总体划分为基于一个变量值的组或层,然后在每个组内计算另一个变量的汇总。

为了从数学上描述这一点,假设我们有一个包含对 \((x_1, y_1), \dots, (x_n, y_n)\) 的总体,例如英格兰所有父亲和儿子的身高对。我们了解到,如果我们从这个总体中随机选择一对 \((X, Y)\)\(Y\) 的期望值和最佳预测器是 \(\mathrm{E}[Y] = \mu_y\)

现在,我们不再关注整个总体,而是专注于由 \(X\) 的固定值定义的特定子群体。在我们的例子中,这是所有父亲身高为 72 英寸的父子对。这个子群体本身就是一个总体,因此同样的想法适用。在这个子群体中,\(y_i\) 的值遵循一个称为 条件分布 的分布,而这个分布有一个称为 条件期望 的期望值。在我们的例子中,条件期望是所有父亲身高为 72 英寸的儿子们的平均身高。

我们写 \(Y \mid X = x\) 来表示只从事件 \(X = x\) 发生的对中选择的 \(Y\) 的随机值。换句话说,\(Y \mid X = 72\) 代表从那些父亲身高为 72 英寸的儿子中随机选择的身高。

条件期望的表示法是:

\[\mathrm{E}[Y \mid X = x] \]

其中 \(x\) 是定义子集的固定值。同样,我们表示该子集或层的标准差为:

\[\mathrm{SD}[Y \mid X = x] = \sqrt{\mathrm{Var}(Y \mid X = x)} \]

因为条件期望 \(\mathrm{E}[Y \mid X = x]\) 是由 \(X = x\) 定义的组中个体的 \(Y\) 的最佳预测器,许多数据科学问题可以被视为估计这个量。条件标准差,\(\mathrm{SD}[Y \mid X = x]\),描述了预测的精确度。

因此,为了获得预测,我们希望使用高尔顿收集的样本来估计 \(E[Y|X=72]\)。当使用连续数据时,我们常常面临这样的挑战:在 \(X = 72\) 的数据点并不多:

sum(galton$father == 72)
#> [1] 8

如果我们把数字改为 72.5,我们将得到更少的数据点:

sum(galton$father == 72.5)
#> [1] 1

一种提高条件期望估计的实用方法是定义具有相似 \(x\) 值的观察层。在我们的例子中,我们可以将父亲身高四舍五入到最接近的英寸,并假设它们都是 72 英寸。如果我们这样做,我们最终会得到以下预测:一个父亲身高为 72 英寸的儿子:

mean(galton[round(father) == 72]$son)
#> [1] 70.5

请注意,身高为 72 英寸的父亲高于平均水平,具体来说(72.0 - 69.1)/ 2.5 = 1.1 个标准差高于平均水平父亲。我们的预测 70.5 也高于平均水平,但仅比平均水平儿子高出 0.48 个标准差。72 英寸父亲儿子的预测身高有回归到平均身高。我们注意到身高比标准差减少的数量约为 0.5,这恰好接近样本相关系数。正如我们将在后面的章节中看到的,这并非巧合。

如果我们想要预测任何身高,而不仅仅是 72 英寸,我们可以将相同的方法应用于每个层。分层后跟随箱线图让我们看到每个组的分布:

galton, .(father_strata = [factor(round(father)), son)] |>
ggplot(aes(father_strata, son)) + 
 geom_boxplot() + 
 geom_point()

* *不出所料,组的中心随着身高的增加而增加。此外,这些中心似乎遵循线性关系。下面,我们绘制了每个组的平均值。如果我们考虑到这些平均值是具有标准误的随机变量,数据与这些点遵循直线是一致的:

注意,虽然我们将数据分成了每英寸一个组,但每个组中的样本量仍然很小,并且并不相同。对应于更高父亲的组要小得多,这使得它们的平均值变化更大。

这些条件平均值遵循直线并非巧合。在下一节中,我们将解释这条被称为回归线的线如何提高我们估计的精度。然而,并不总是适合用回归线来估计条件期望,因此我们还描述了高尔顿的理论依据,这有助于我们理解何时可以使用它。

条件期望不仅在变量之间关系线性时有用。事实上,我们所说的许多机器学习都可以理解为估计复杂关系的条件期望的任务,这些关系远远超出了直线。我们在本书的机器学习部分更详细地探讨了这一想法,包括第二十八章,该章节涵盖了条件期望和概率。

15.4 回归线

如果我们使用回归线预测随机变量 \(Y\),已知另一个 \(X=x\) 的值,那么对于每个标准差 \(\sigma_X\)\(x\) 相对于平均值 \(\mu_X\) 增加,我们的预测 \(\hat{y}\) 将增加 \(\rho\) 个标准差 \(\sigma_Y\),其中 \(\rho\)\(X\)\(Y\) 之间的相关系数。因此,回归的公式如下:

\[\left( \frac{\hat{y}-\mu_Y}{\sigma_Y} \right) = \rho \left( \frac{x-\mu_X}{\sigma_X} \right) \]

我们可以这样重写:

\[\hat{y} = \mu_Y + \rho \left( \frac{x-\mu_X}{\sigma_X} \right) \sigma_Y \]

如果存在完全相关,回归线预测的增量与标准差相同的数量。如果相关系数为 0,则完全不使用 \(x\) 进行预测,而是简单地预测平均值 \(\mu_Y\)。对于介于 0 和 1 之间的值,预测值位于两者之间。如果相关系数为负,我们预测减少而不是增加。

注意,如果相关系数为正且小于 1,我们的预测在标准单位上比用于预测的值 \(x\) 更接近平均值。这就是为什么我们称之为 回归:儿子们回归到平均身高。事实上,高尔顿论文的标题是:遗传身高的回归到中等水平

我们可以通过将上述公式转换为 \(\hat{y} = m + bx\) 的形式来向图表添加回归线,这给出了斜率 \(m = \rho \frac{\sigma_y}{\sigma_x}\) 和截距 \(b = \mu_y - m \mu_x\)

params <- galton, .(mu_x = [mean(father), mu_y = mean(son),
 s_x  = sd(father), s_y  = sd(son),
 r    = cor(father, son))]
 galton |> ggplot(aes(father, son)) + 
 geom_point(alpha = 0.5) +
 geom_abline(slope = with(params, r*s_y/s_x), 
 intercept = with(params, mu_y - r*s_y/s_x*mu_x))

* **使用 ggplot 我们不需要计算所有这些汇总,可以很容易地使用 geom_smooth 函数添加回归线:

galton |> ggplot(aes(father, son)) + geom_point() + geom_smooth(method = "lm") 

在第 16.3 节 中,我们解释了这里的 lm 代表什么。** **注意,回归公式意味着如果我们首先对变量进行标准化,即减去平均值并除以标准差,那么回归线的截距为 0,斜率等于相关系数 \(\rho\)。你可以绘制相同的图表,但使用这样的标准单位:

galton |> ggplot(aes(scale(father), scale(son))) + 
 geom_point(alpha = 0.5) +
 geom_abline(intercept = 0, slope = r) 

线性回归提高精度

我们现在比较我们介绍的两个预测方法:

  1. 将父亲的身高四舍五入到最接近的英寸,分层,并在每个组内取平均值。

  2. 计算回归线并使用它进行预测。

我们的目标是估计使用每种方法获得的预测的期望值和标准误差。我们希望期望值非常接近真实总体平均值,而标准误差要尽可能小。为了做到这一点,我们使用蒙特卡洛模拟。具体来说,我们从总体中反复抽取大小为 \(N = 50\) 的家庭随机样本,对每个样本计算这两种预测,然后检查这些重复估计的分布。该分布的平均值提供了每种方法期望值的估计,其标准差提供了标准误差的估计,这反映了预测在随机样本之间的变化程度。

B <- 1000
N <- 50
 conditional_avg <- replicate(B, {
 dat <- galton[sample(.N, N, replace = TRUE)]
 dat[round(father) == 72, if (.N) mean(son) else NA]
})
 linear_regression_prediction <- replicate(B, {
 dat <- galton[sample(.N, N, replace = TRUE)]
 dat, [mean(son) + cor(father, son)*(72 - mean(father))/sd(son)*sd(father)]
})

尽管两种方法得到的期望值几乎相同:

mean(conditional_avg, na.rm = TRUE)
#> [1] 70.5
mean(linear_regression_prediction)
#> [1] 70.4

线性回归预测的标准误差要小得多:

sd(conditional_avg, na.rm = TRUE)
#> [1] 1.08
sd(linear_regression_prediction)
#> [1] 0.534

这意味着基于线性回归的预测在重复样本中具有更高的稳定性。原因很直观:条件平均值基于相对较小的数据子集,即身高约为 72 英寸的父亲,因此其估计值变化更大。在某些样本中,我们甚至可能在该组中几乎没有或没有观察值,这就是为什么我们使用 na.rm=TRUE 的原因。另一方面,回归线是使用整个数据集的信息进行估计的,这使得其预测更加精确。

那么为什么我们总是使用回归线进行预测呢?因为并不总是合适的。例如,Anscombe 提供了一些数据没有线性关系的案例。那么我们使用回归线进行预测是否合理呢?Galton 对身高数据给出了肯定的回答。下一节将提供合理的解释。

双变量正态分布的合理性

相关性和回归斜率是广泛使用的总结统计量,但它们经常被误用或误解。Anscombe 的例子提供了相关性不是有用总结的过度简化案例。但现实生活中有很多例子。

我们主要通过涉及双变量正态分布来激励适当使用相关性作为总结。

当一对随机变量被双变量正态分布近似时,散点图看起来像椭圆形。正如我们在第 15.2 节中看到的,它们可以是细长的(高相关性)或圆形的(无相关性)。

定义双变量正态分布的一个更技术性的方法是以下内容:如果

  • \(X\) 是一个正态分布的随机变量,

  • \(Y\) 也是一个正态分布的随机变量,并且

  • 对于任何 \(X=x\)\(Y\) 的条件分布近似于正态分布,

然后,这对数据点就近似于双变量正态分布。

当三个或更多变量具有每个对都是双变量正态分布的性质时,我们说这些变量遵循多元正态分布,或者它们是联合正态分布。

如果我们认为身高数据很好地被双变量正态分布近似,那么我们应该在每个层中看到正态近似的成立。在这里,我们通过标准化的父亲身高对儿子身高进行分层,并看到这个假设似乎成立:

galton, .(son, z = [round((father - mean(father))/sd(father)))]z [%in% -2:2] |>
 ggplot() + 
 stat_qq(aes(sample = son)) +
 facet_wrap( ~ z) 

* *Galton 使用数学统计学证明了,当两个变量遵循双变量正态分布时,计算回归线等同于计算条件期望。这里我们不展示推导过程,但我们可以证明,在这个假设下,对于任何给定的 \(x\) 值,\(X=x\)\(Y\) 对的期望值是:

\[\mathrm{E}[Y | X=x] = \mu_Y + \rho \frac{x-\mu_X}{\sigma_X}\sigma_Y \]

这就是回归线,斜率为 \(\rho \frac{\sigma_Y}{\sigma_X}\),截距为 \(\mu_y - \rho\mu_X\frac{\sigma_Y}{\sigma_X}\)。它与我们之前展示的回归方程等价,可以写成如下形式:

\[\frac{\mathrm{E}[Y \mid X=x] - \mu_Y}{\sigma_Y} = \rho \frac{x-\mu_X}{\sigma_X} \]

这意味着,如果我们的数据近似为双变量,回归线给出了条件概率。因此,我们可以通过找到回归线并使用它来预测,从而获得条件期望的一个更稳定的估计。

总结来说,如果我们的数据近似为双变量,那么在已知 \(X\) 的值的情况下,对 \(Y\) 的最佳预测,即条件期望,由回归线给出。

方差解释

上述条件分布的标准差由双变量正态理论告诉我们是:

\[\mathrm{SD}[Y \mid X=x ] = \sigma_Y \sqrt{1-\rho²} \]

为了理解为什么这是直观的,请注意,在没有条件的情况下,我们正在查看所有儿子的变异性:\(\mathrm{SD}(Y) = \sigma_Y\)。但一旦我们进行条件限制,我们只关注那些身高为 72 英寸的父亲的儿子们的变异性。这个群体都会倾向于相对较高,因此标准差会降低。

具体来说,它减少到 \(\sqrt{1-\rho²} = \sqrt{1 - 0.25}\) = 0.87 原始值的多少。我们可以说,父亲的高度“解释”了观察到的儿子身高变异性的 13%。

学术论文中常用“\(X\) 解释了这样的百分比变异性”的陈述。在这种情况下,这个百分比实际上指的是方差(即标准差的平方)。因此,如果数据是双变量的,方差会减少 \(1-\rho²\),所以我们说 \(X\) 解释了 \(1- (1-\rho²)=\rho²\)(相关系数的平方)的方差。

但重要的是要记住,“解释的方差”这一说法只有当数据近似为双变量正态分布时才有这种明确的解释。

存在两条回归线

我们计算了一条回归线来预测儿子的身高与父亲身高。我们使用了以下计算:

m_1 <- with(params, r*s_y/s_x)
b_1 <- with(params, mu_y - m_1*mu_x)

这给出了函数 \(\mathrm{E}[Y\mid X=x] =\) 37.5 + 0.46 \(x\)

如果我们想根据儿子的身高预测父亲的高度,重要的是要知道这并不是通过计算逆函数来确定的:\(x = \{ \mathrm{E}[Y\mid X=x] -\) 37.5 \(\} /\) 0.46。

我们需要计算 \(\mathrm{E}[X \mid Y=y]\)。由于数据近似为双变量正态,之前描述的理论告诉我们这个条件期望将遵循具有斜率和截距的直线:

m_2 <- with(params, r*s_x/s_y)
b_2 <- with(params, mu_x - m_2*mu_y)

使用这些计算我们得到 \(\mathrm{E}[X \mid Y=y] =\) 41.2 + 0.4y。再次,我们看到回归到平均值:对父亲的预测比儿子身高 \(y\) 对儿子平均值的预测更接近。

这里有一个显示两条回归线的图,蓝色用于预测儿子身高与父亲身高,红色用于预测父亲身高与儿子身高:

15.5 回归谬误

维基百科将“二年级下滑”定义为:

“二年级下滑”或“二年级霉运”或“二年级紧张”指的是第二次或二年级的努力未能达到第一次努力的标准。它通常用来指代学生(高中、大学或大学的第二年)、运动员(第二个赛季的表现)、歌手/乐队(第二张专辑)、电视节目(第二季)和电影(续集/前传)的冷漠。

在美国职业棒球大联盟中,最佳新秀奖(ROY)授予第一年表现最佳的球员。“二年级下滑”这个短语用来描述最佳新秀奖得主在第二年表现不佳的情况。例如,一篇 Fox Sports 的文章问道:“MLB 2015 年令人惊叹的新秀班能否避免二年级下滑?”

数据是否证实了“二年级下滑”的存在?让我们来看看。通过检查一个广泛使用的成功衡量标准——击球率,我们发现这一观察结果对于表现最好的最佳新秀(ROY)来说也是成立的:

nameFirst nameLast rookie_year rookie sophomore
Willie McCovey 1959 0.354 0.238
Ichiro Suzuki 2001 0.350 0.321
Al Bumbry 1973 0.337 0.233
Fred Lynn 1975 0.331 0.314
Albert Pujols 2001 0.329 0.314

所以是“紧张”还是“霉运”?为了回答这个问题,让我们将注意力转向所有在 2013 年和 2014 赛季中出场超过 130 次(至少要赢得最佳新秀奖)的所有球员。

当我们查看顶尖表现者时,同样的模式出现:大多数顶尖表现者的击球率都在下降。

nameFirst nameLast 2013 2014
Miguel Cabrera 0.348 0.313
Hanley Ramirez 0.345 0.283
Michael Cuddyer 0.331 0.332
Scooter Gennett 0.324 0.289
Joe Mauer 0.324 0.277

但这些不是新秀!也看看 2013 年最差表现者的表现:

nameFirst nameLast 2013 2014
Danny Espinosa 0.158 0.219
Dan Uggla 0.179 0.149
Jeff Mathis 0.181 0.200
B. J. Upton 0.184 0.208
Adam Rosales 0.190 0.262

他们的击球率大多在上升!这是否是一种“反向二年级下滑”?不是的。根本不存在“二年级下滑”。这一切都可以用一个简单的统计事实来解释:两年表现的相关性很高,但并不完美:

相关系数为 0.46,数据看起来非常像双变量正态分布,这意味着我们预测任何给定球员在 2013 年的击球率 \(x\) 下,2014 年的预测击球率 \(\hat{y}\) 为:

\[\frac{\hat{y} - .255}{.032} = 0.46 \left( \frac{x - .261}{.031}\right) \]

由于相关性不是完美的,回归分析告诉我们,平均而言,2013 年的高表现者 2014 年可能会稍微表现差一些。这并不是一个诅咒;这只是由于偶然。ROY 是从 \(x\) 的最高值中选出的,因此预期 \(y\) 将回归到平均值。

15.6 练习

  1. 证明相关系数必须小于或等于 1。提示:相关系数不能高于完全线性关系的情况。考虑 \(y_i = a + b x_i\) 其中 \(b > 0\)。将 \(a + b x_i\) 替换为 \(y_i\) 在相关公式中,并进行简化。

  2. 证明相关系数必须大于或等于-1。提示:相关系数不能比完全递减线性关系的情况更负。考虑 \(y_i = a - b x_i\) 其中 \(b > 0\)。将 \(a - b x_i\) 替换为 \(y_i\) 在相关公式中,并像练习 1 中那样进行简化。

  3. 我们进行了一次蒙特卡洛模拟,发现当 \(N=25\) 时,中心极限定理(CLT)对于样本相关性的分布并不是一个好的近似。使用来自 MASS 包的 mvrnorm 函数生成相关数据:

library(MASS)
N <- 25
dat <- mvrnorm](https://rdrr.io/pkg/MASS/man/mvrnorm.html)(N, [c(0, 0), matrix(c(1, 0.9, 0.9, 1), 2, 2))
cor(dat[,1], dat[,2])

\(N <- c(25, 50, 100, 250, 500, 1000)\) 进行 10,000 次蒙特卡洛模拟,以确定 CLT 何时开始成为一个有用的近似。

  1. 重复练习 3,但将相关性改为 0.9 而不是 0.5。

  2. 重复练习 3,但将相关性改为 0.1 而不是 0.5。

  3. HistData 包中加载 GaltonFamilies 数据。每个家庭中的孩子按性别和身高顺序列出。通过随机选择一个男性和一个女性来创建一个名为 galton 的数据集。

  4. 为母亲与女儿、母亲与儿子、父亲与女儿、父亲与儿子之间的高度制作散点图。

  5. 计算母亲与女儿、母亲与儿子、父亲与女儿、父亲与儿子之间的高度相关性。


  1. https://en.wikipedia.org/wiki/Francis_Galton↩︎

16 线性模型框架

原文:rafalab.dfci.harvard.edu/dsbook-part-2/linear-models/linear-model-framework.html

  1. 线性模型

  2. 16 线性模型框架

我们现在可以理解这本书这一部分标题的含义,特别是回归与线性模型之间的联系。在前一章中,我们展示了如果两个变量遵循二元正态分布,那么一个变量给定另一个变量的条件期望位于一条直线上,即回归线。在那个情况下,线性关系不是一个假设,而是概率模型的一个数学结果。

然而,在实际应用中,我们通常将线性关系作为建模策略,即使基础分布并非完全的二元正态分布。为了处理这样的模型,我们需要一个通用的数学方法来估计未知系数。在本章中,我们介绍了最小二乘估计器(LSE),这是用来估计这些参数的标准方法。LSE 提供了一种统一的方式来拟合广泛应用中的线性模型。

我们在本章的最后通过一个例子展示了线性模型在历史上是如何被激发出来的:测量误差模型。这个例子与我们之前研究的高度数据不同,但相同的线性模型框架、相同的平方最小数学方法,甚至相同的 R 函数都可以用来拟合它。后面的章节将介绍更多线性模型扮演核心角色的例子。

16.1 线性模型表示

我们注意到这里的线性并不特指直线,而是指条件期望是变量的线性组合。在数学中,当我们把每个变量乘以一个常数然后相加,我们说我们形成了变量的线性组合。例如,\(3x - 4y + 5z\)\(x\)\(y\)\(z\) 的线性组合。我们也可以添加一个常数,例如 \(2 + 3x - 4y + 5z\) 也是 \(x\)\(y\)\(z\) 的线性组合。

我们之前描述了如果 \(X\)\(Y\) 是二元正态分布,那么如果我们只观察 \(X=x\) 的那些对,那么 \(Y \mid X=x\) 就符合期望值为 \(\mu_Y + \rho \frac{x-\mu_X}{\sigma_X}\sigma_Y\) 的正态分布,这是一个 \(x\) 的线性函数。注意,标准差 \(\sigma_Y \sqrt{1-\rho²}\) 也不依赖于 \(x\)。这意味着我们可以写出:

\[Y = \beta_0 + \beta_1 x + \varepsilon \]

假设 \(\varepsilon\) 符合期望值为 0 和固定标准差的正态分布,那么 \(Y\) 就具有与回归设置给出的相同性质:\(Y\) 符合正态分布,期望值是 \(x\) 的线性函数,且标准差不依赖于 \(x\)

在统计教科书中,\(\varepsilon\) 项被称为误差。从历史上看,这反映了这样一个观点,即模型偏离是由于测量不准确造成的。今天,这个术语的使用更加广泛:\(\varepsilon\)s 代表了模型中预测因子无法解释的所有结果变异性。这种变异性可能与错误无关。例如,如果某人的身高比根据其父母的身高预测的身高高出两英寸,那么这增加的两英寸不是错误;这只是自然变异。尽管在这个意义上,“误差”这个术语并不理想,但它仍然是标准术语,我们将用它来表示模型中的未解释变异性。*如果我们为盖尔顿的数据指定一个线性模型,我们将用 \(x_1, \dots, x_n\) 表示 \(N\) 个观察到的父亲身高,然后我们用以下方式来预测我们试图预测的 \(N\) 个儿子的身高:

\[Y_i = \beta_0 + \beta_1 x_i + \varepsilon_i, \, i=1,\dots,N. \]

在这里 \(x_i\) 是父亲的身高,由于条件限制,它是固定的(非随机的),而 \(Y_i\) 是我们想要预测的随机儿子身高。我们可以进一步假设 \(\varepsilon_i\) 之间相互独立,并且都具有相同的标准差。

在线性模型框架中,\(x_i\) 被称为解释变量协变量预测变量

在上述模型中,我们知道 \(x_i\),但为了有一个有用的预测模型,我们需要 \(\beta_0\)\(\beta_1\)。我们从数据中估计这些值。一旦我们这样做,我们就可以预测任何父亲身高 \(x\) 的儿子的身高。我们将在下一节中展示如何做到这一点。

虽然这个模型与我们之前通过假设双变量正态数据推导出的模型完全相同,但有一点细微的差别是,在第一种方法中,我们假设数据是双变量正态的,而线性模型是通过推导而不是假设得出的。在实践中,线性模型只是被假设,而不一定假设正态性:\(\varepsilon\)s 的分布不一定被指定。尽管如此,如果你的数据是双变量正态的,上述线性模型仍然成立。如果你的数据不是双变量正态的,那么你需要有其他方法来证明模型的合理性。

线性模型受欢迎的一个原因是它们易于解释。在盖尔顿的数据情况下,我们可以这样解释数据:由于遗传基因,父亲的身高每增加一英寸,儿子的身高预测值就增加 \(\beta_1\)。因为并非所有身高为 \(x\) 的儿子身高都相同,我们需要 \(\varepsilon\) 这个项来解释剩余的变异性。这种剩余的变异性包括母亲的遗传效应、环境因素以及其他生物学上的随机性。

根据我们上面写的模型,截距\(\beta_0\)的解释性不是很强,因为它代表了一个没有身高的父亲儿子的预测身高。为了使斜率参数更具解释性,我们可以稍微修改模型:

\[Y_i = \beta_0 + \beta_1 (x_i - \bar{x}) + \varepsilon_i, \, i=1,\dots,N \]

其中\(\bar{x} = 1/N \sum_{i=1}^N x_i\)\(x\)的平均值。在这种情况下,\(\beta_0\)代表当\(x_i = \bar{x}\)时的高度,即平均父亲儿子的高度。

在后面的章节 17 和 20 中,我们将看到线性模型表示如何使我们能够在其他上下文中使用相同的数学框架,并实现比从另一个变量预测一个变量更复杂的目标。

16.2 最小二乘估计

为了使线性模型有用,我们必须估计未知参数\(\beta\)。标准方法是找到使拟合模型与数据之间的距离最小的值。具体来说,我们找到使以下最小二乘(LS)方程最小的\(\beta\)值。对于 Galton 的数据,LS 方程看起来是这样的:

\[RSS = \sum_{i=1}^n \left\{ y_i - \left(\beta_0 + \beta_1 x_i \right)\right\}² \]

我们试图最小化的量被称为残差平方和(RSS)。

一旦我们找到使 RSS 最小化的值,我们将这些值称为最小二乘估计(LSE),并用参数上方放置的帽子来表示。在我们的例子中,我们使用\(\hat{\beta}_0\)\(\hat{\beta}_1\)

我们将演示如何使用先前定义的galton数据集来找到这些值:

library(data.table)
library(HistData)
 set.seed(1983)
galton <- as.data.table(GaltonFamilies)gender == "male", .SD[[sample(.N, 1)], by = family]
galton <- galton[, .(father, son = childHeight)]

让我们先编写一个函数来计算任何一对值\(\beta_0\)\(\beta_1\)的 RSS。

rss <- function(beta0, beta1, data){
 resid <- galton$son - (beta0 + beta1*galton$father)
 return(sum(resid²))
}

因此,对于任何一对值,我们都会得到一个 RSS。以下是 RSS 作为\(\beta_1\)函数的图表,当我们保持\(\beta_0\)固定在 25 时。

beta1 <- seq(0, 1, length = nrow(galton))
results <- data.frame(beta1 = beta1, rss = sapply(beta1, rss, beta0 = 25))
results |> ggplot(aes(beta1, rss)) + geom_line() + 
 geom_line(aes(beta1, rss))

* *我们可以看到\(\beta_1\)在约 0.65 处有一个明显的最小值。然而,这个\(\beta_1\)的最小值是在\(\beta_0 = 25\)时,这是我们任意选择的值。我们不知道(25, 0.65)是否是使所有可能对中的方程最小化的对。

在这个情况下,试错法是行不通的。我们可以在\(\beta_0\)\(\beta_1\)值的精细网格中搜索最小值,但这样做是不必要的,因为我们可以使用微积分。具体来说,我们取偏导数,将它们设为 0,并求解\(\beta_0\)\(\beta_1\)。当然,如果我们有很多参数,这些方程可能会相当复杂。但在 R 中,有一些函数会为我们做这些计算。我们将在下一节学习这些函数。要了解背后的数学,你可以查阅推荐阅读部分中关于线性模型的书籍。

16.3 lm函数

在 R 中,我们可以使用lm函数获得线性模型的最小二乘估计。为了拟合模型:

\[Y_i = \beta_0 + \beta_1 x_i + \varepsilon_i \]

\(Y_i\)代表儿子的身高,\(x_i\)代表父亲的身高,我们可以使用以下代码:

fit <- lm(son ~ father, data = galton)

我们最常用的lm方法是使用字符~来让lm知道哪个是我们预测的变量(~的左边)以及我们用来预测的变量(~的右边)。截距会自动添加到将要拟合的模型中。

对象fit包含了关于拟合的信息。我们可以使用summary函数来提取这些信息的摘要:

summary(fit)
#> 
#> Call:
#> lm(formula = son ~ father, data = galton)
#> 
#> Residuals:
#>    Min     1Q Median     3Q    Max 
#>  -9.38  -1.59   0.00   1.83   9.39 
#> 
#> Coefficients:
#>             Estimate Std. Error t value Pr(>|t|) 
#> (Intercept)  37.4658     5.0052    7.49  3.2e-12 *
#> father        0.4592     0.0724    6.34  1.8e-09 *
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
#> 
#> Residual standard error: 2.46 on 177 degrees of freedom
#> Multiple R-squared:  0.185,  Adjusted R-squared:  0.181 
#> F-statistic: 40.2 on 1 and 177 DF,  p-value: 1.82e-09

为了理解这个摘要中包含的一些术语,我们需要记住 LSE 是随机变量。数学统计学给我们一些关于这些随机变量分布的想法。

在第二十章中,在描述了一个更复杂的案例研究之后,我们进一步了解了回归在 R 中的应用。

LSE 是随机变量

最小二乘估计(LSE)是从数据\(y_1,\dots,y_N\)中导出的,这些数据是随机变量\(Y_1, \dots, Y_N\)的一个实现。这意味着我们的估计值是随机变量。为了看到这一点,我们可以运行一个蒙特卡洛模拟,其中我们假设儿子和父亲的身高数据定义了一个总体,随机抽取一个大小为\(N=50\)的样本,并计算每个样本的回归斜率系数:

N <- 25
lse <- replicate(10000, {
 smpl <-  galton[sample(.N, N, replace = TRUE)]
 coef(lm(son ~ father, data = smpl))
})

我们可以通过绘制它们的分布图来看到估计值的变异性:

这些看起来是正态分布的原因是因为中心极限定理在这里同样适用:对于足够大的\(N\),最小二乘估计将大约呈正态分布,其期望值分别为\(\beta_0\)\(\beta_1\)

计算标准误差有点复杂,但数学理论确实允许我们计算它们,并且它们包含在lm函数提供的摘要中。summary函数显示了标准误差估计:

fit <- lm(son ~ father, data = galton[sample(.N, N, replace = TRUE)])
summary(fit)$coef
#>             Estimate Std. Error t value Pr(>|t|)
#> (Intercept)   26.574     17.383    1.53   0.1400
#> father         0.627      0.249    2.52   0.0193

你可以看到上面报告的标准误差估计与模拟中的标准误差非常接近:

with(lse, c(se_0 = sd(beta_0), se_1 = sd(beta_1)))
#>   se_0   se_1 
#> 13.012  0.188

summary函数还报告了 t 统计量(t value)和 p 值(Pr(>|t|))。t 统计量实际上并不是基于中心极限定理,而是基于\(\varepsilon\)s 服从正态分布的假设。在这个假设下,数学理论告诉我们,LSE 除以其标准误差,\(\hat{\beta}_0 / \widehat{\mathrm{SE}}[\hat{\beta}_0]\)\(\hat{\beta}_1 / \widehat{\mathrm{SE}}[\hat{\beta}_1]\),将遵循具有\(N-p\)自由度的 t 分布,其中\(p\)是我们模型中的参数数量。在我们的例子中\(p=2\),两个 p 值分别来自于检验\(\beta_0 = 0\)\(\beta_1=0\)的零假设。

记住,正如我们在第 10.2.3 节中描述的,对于足够大的 \(N\),中心极限定理(CLT)是有效的,t 分布几乎与正态分布相同。此外,请注意,我们可以使用 confint 函数构建置信区间:

confint(fit, "father", level = 0.95)
#>        2.5 % 97.5 %
#> father 0.112   1.14

尽管我们在这本书中没有展示示例,但回归模型的假设检验在流行病学和经济学中很常见,用于做出诸如“在调整 X、Y 和 Z 后,A 对 B 的影响在统计上显著”的陈述。然而,为了使这些陈述成立,必须满足几个假设。

预测值是随机变量

一旦我们拟合了模型,我们可以通过将估计值代入回归模型来获得 \(Y\) 的预测值。例如,如果父亲的身高是 \(x\),那么我们对儿子身高的预测 \(\hat{y}\) 将会是:

\[\hat{y} = \hat{\beta}_0 + \hat{\beta}_1 x \]

当我们绘制 \(\hat{y}\)\(x\) 的关系图时,我们看到回归线。

请记住,预测值 \(\hat{y}\) 也是一个随机变量,数学理论告诉我们标准误差是什么。如果我们假设误差是正态分布的,或者样本量足够大,我们可以使用理论来构建置信区间。事实上,ggplot2 层的 geom_smooth(method = "lm") 使用这些置信区间包围回归线:

galton |> ggplot(aes(son, father)) + geom_point() + geom_smooth(method = "lm")

* *R 函数 predict 接收一个 lm 对象作为输入并返回预测值。如果需要,它还会提供构建置信区间所需的标准误差和其他信息:

fit <- lm(son ~ father, data = galton) 
y_hat <- predict(fit, se.fit = TRUE)
names(y_hat)
#> [1] "fit"            "se.fit"         "df"             "residual.scale"

16.4 模型诊断

当我们假设线性模型而不是从理论推导它时,所有解释都取决于模型如何代表数据。即使模型指定不当,lm 函数也会拟合模型并产生系数估计、标准误差和 p 值。在没有验证假设的情况下解释这些结果可能导致误导性的结论。

因此,检查模型假设是统计建模中的关键要求。诊断图提供了视觉工具来评估模型是否捕捉了数据的主要结构,残差是否表现如预期,以及潜在的异常值或影响点是否扭曲了拟合。当没有强有力的理论依据支持假设模型时,这些检查尤为重要——例如,当数据生成过程未知或复杂时。在这些情况下,诊断是我们对抗得出错误或过度自信结论的主要防御手段。

通过视觉检查残差,定义为观测值与预测值之间的差异:

\[ r = y - \hat{y} = y- \left(\hat{\beta}_0 - \hat{\beta}_1 x_i\right), $$ 以及残差的摘要,是诊断模型是否有效的一种强大方式。请注意,残差可以被视为误差的估计,因为: $$ \varepsilon = Y - \left(\beta_0 + \beta_1 x_i \right). $$ 实际上,残差通常表示为 $\hat{\varepsilon}$。这促使我们进行几个 *诊断* 图表。因为我们观察到 $r$,但无法观察到 $\varepsilon$,所以我们基于残差来构建图表。 1. 由于假设误差不依赖于 $Y$ 的期望值,$r$ 与拟合值 $\hat{y}$ 的关系图应该没有关联。 1. 在我们假设误差遵循正态分布的情况下,当标准化 $r$ 与理论分位数绘制时,qqplot 应该落在一条直线上。 1. 由于我们假设误差的标准差是恒定的,如果我们绘制残差的绝对值,它应该看起来是恒定的。 我们更倾向于图表而不是基于例如相关性的总结,因为,如第 15.2.2 节所述,相关性并不总是关联性的最佳总结。将 `plot` 函数应用于 `lm` 对象会自动绘制这些图表。 ```r plot(fit, which = 1:3) ``` ![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/hvd-cs109-intd-ds-2e/img/0c32658af2f1b07541b79d8f38b7a0d0.png)* *此函数可以生成六种不同的图表,`which` 参数允许你指定要查看的图表。你可以通过阅读 `plot.lm` 帮助文件来了解更多信息。然而,一些图表基于更高级的概念,超出了本书的范围。要了解更多信息,我们建议阅读推荐阅读部分中包含的关于回归分析的进阶书籍。 在第十七章和第二十章中,我们介绍了数据分析挑战,在这些挑战中,我们可能决定不在模型中包含某些变量。在这些情况下,一个重要的诊断测试是检查残差是否与模型中未包含的变量相关。***** ## 16.5 测量误差模型 历史上,将线性模型写成 $Y = \beta_0 + \beta_1 x + \varepsilon$ 的形式起源于 $\varepsilon$ 项代表 *测量误差* 的上下文。早期科学家使用最小二乘法从受仪器噪声影响的重复测量中估计物理常数。后来,高尔顿和皮尔逊扩展了这一框架来研究变量之间的关系,例如我们之前考察的高度示例。 在本节中,我们简要介绍了一个适合测量误差解释的设置,并展示我们可以使用相同的数学方法和相同的 R 函数来拟合相应的模型。这说明了线性模型框架的一个关键优势:一套工具可以应用于许多看似不同的情况。 ### 示例:建模下落物体 要理解这些模型,想象一下你是 16 世纪的伽利略,试图描述一个下落物体的速度。一个助手爬上比萨斜塔并扔下一个球,而其他几个助手在不同时间记录位置。让我们使用我们目前所知的方程模拟一些数据,并添加一些测量误差。`dslabs`函数`rfalling_object`生成这些模拟: ```r library(tidyverse library(dslabs) falling_object <- rfalling_object() ``` 助手将数据交给伽利略,这就是他看到的情况: ![图表](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/hvd-cs109-intd-ds-2e/img/3c22a39efef48861c4d6c082ef3eb1a6.png) 伽利略不知道确切的方程,但通过观察上面的图表,他推断出位置应该遵循抛物线,我们可以写成这样: $$ f(x) = \beta_0 + \beta_1 x + \beta_2 x² \]

数据并不完全落在抛物线上。伽利略知道这是由于测量误差造成的。他的助手在测量距离时犯了错误。为了解决这个问题,他用以下模型来模拟数据:

\[Y_i = \beta_0 + \beta_1 x_i + \beta_2 x_i² + \varepsilon_i, i=1,\dots,n \]

其中\(Y_i\)代表米,\(x_i\)代表秒,\(\varepsilon_i\)代表测量误差。假设测量误差是随机的,彼此独立,并且对于每个\(i\)具有相同的分布。我们还假设没有偏差,这意味着误差项的期望值为 0:\(\mathrm{E}[\varepsilon] = 0\)

注意,这是一个线性模型,因为它是由已知量(\(x\)\(x²\)是已知的)和未知参数(\(\beta\)是伽利略不知道的参数)的线性组合。与我们的前几个例子不同,这里\(x\)是一个固定量;我们不是在条件化。

模型预测与观察之间的微小差异通常归因于测量误差,就像我们的例子中那样。在许多情况下,这是一个有用且实用的近似。然而,这种差异也可能揭示模型本身的局限性。伽利略关于重力的实验显示了他预测的均匀加速度的微小偏差,这主要是由于空气阻力而不是测量错误。同样,牛顿的万有引力定律准确地描述了行星运动,但水星轨道中的微小差异,一度被认为是观测误差,最终导致了爱因斯坦的广义相对论。虽然假设测量误差通常是合理的,但认识到差异信号模型局限性是至关重要的。第 16.4 节中讨论的诊断图可以帮助评估这种局限性。

使用最小二乘法估计参数

为了提出一个新的物理理论并开始预测其他下落物体的行为,伽利略需要实际的数字,而不是未知参数。使用最小二乘法似乎是一个合理的途径。我们如何找到最小二乘法?

最小二乘法计算不需要误差近似为正态分布。lm函数将找到\(\beta\),以最小化残差平方和:

fit <- falling_object |> 
 mutate(time_sq = time²) |> 
 lm(observed_distance~time+time_sq, data = _)
summary(fit)$coefficients
#>             Estimate Std. Error t value Pr(>|t|)
#> (Intercept)   54.753      0.625  87.589 5.36e-17
#> time           0.575      0.893   0.644 5.33e-01
#> time_sq       -4.929      0.265 -18.603 1.16e-09

让我们检查估计的抛物线是否适合数据。broom函数augment使我们能够轻松地做到这一点:

broom::augment(fit) |> 
 ggplot() +
 geom_point(aes(time, observed_distance)) + 
 geom_line(aes(time, .fitted), col = "blue")

* *残差证实了误差的正态近似是合理的:

plot(fit, which=1:2)

* *感谢我的高中物理老师,我知道下落物体的轨迹方程是:

\[d(t) = h_0 + v_0 t - 0.5 \times 9.8 \, t² \]

其中\(h_0\)\(v_0\)分别是起始高度和速度。我们上面模拟的数据遵循这个方程,添加测量误差以模拟从比萨斜塔(\(h_0=55.86\))掉落球体(\(v_0=0\))的\(n\)个观测值。

这些与参数估计一致:

confint(fit)
#>             2.5 % 97.5 %
#> (Intercept) 53.38  56.13
#> time        -1.39   2.54
#> time_sq     -5.51  -4.35

比萨斜塔的高度在\(\beta_0\)的置信区间内,初始速度 0 在\(\beta_1\)的置信区间内(注意 p 值大于 0.05),加速度常数在\(-2 \times \beta_2\)的置信区间内。

16.6 练习

1. co2数据集是一个包含 468 个 CO2 观测值的时间序列对象,从 1959 年到 1997 年每月一次。绘制前 12 个月的 CO2 水平,并注意它似乎遵循每年 1 个周期的正弦波。这意味着可能工作的测量误差模型是

\[ y_i = \mu + A \sin(2\pi \,t_i / 12 + \phi) + \varepsilon_i $$ 其中$t_i$是观测$i$的月份编号。这是参数$\mu$、$A$和$\phi$的线性模型吗? 2\. 使用三角学,我们可以证明我们可以将此模型重写为: $$ y_i = \beta_0 + \beta_1 \sin(2\pi t_i/12) + \beta_2 \cos(2\pi t_i/12) + \varepsilon_i \]

这是一个线性模型吗?

3. 使用lm找到\(\beta\)的最小二乘估计。展示\(y_i\)\(t_i\)的对比图,并在同一图上显示\(\hat{y}_i\)\(t_i\)的曲线。

4. 现在将测量误差模型拟合到包含趋势项的整个co2数据集,该趋势项是一个抛物线以及正弦波模型。

5. 对拟合模型运行诊断图并描述结果。

9. 将回归模型拟合到 Anscombe 四重奏中的每个数据集,并检查诊断图。

17 治疗效果模型

原文:rafalab.dfci.harvard.edu/dsbook-part-2/linear-models/treatment-effect-models.html

  1. 线性模型

  2. 17 治疗效果模型

到目前为止,我们使用线性模型来描述连续变量之间的关系。我们通过假设多元正态性来激发这些模型,并展示了条件期望如何呈现线性形式。这涵盖了回归的许多常见应用。

然而,线性模型并不仅限于描述连续测量中的自然变异。线性模型最重要的用途之一是量化干预或治疗的效果。这一框架起源于农业,研究人员比较了接受不同肥料或种植策略的田地上的作物产量。结果变量是产量,而治疗是肥料。现在,相同的数学思想构成了分析医学、经济学、公共卫生、教育和社会科学中随机对照试验的基础。互联网公司使用的现代 A/B 测试,用于评估网站更改或产品特性,也遵循这一治疗效果框架。

关键思想是治疗定义了组别,我们使用线性模型来估计这些组别之间结果差异。在随机实验中,比较是直接的,因为随机化有助于确保组别具有可比性。在观察性研究中,目标相同,但分析人员必须考虑由于治疗本身之外的原因导致的组间差异。例如,如果我们想估计富含水果和蔬菜的饮食对血压的影响,我们必须调整年龄、性别或吸烟状况等因素。

17.1 案例研究:高脂肪饮食和鼠标体重

在本章中,我们考虑了一个旨在测试高脂肪饮食对小鼠生理影响的实验。小鼠被随机选择并分为两组:一组接受高脂肪饮食,被视为治疗,而另一组作为对照组,接受常规饲料。数据包含在dslabs包中:

library(dslabs)
table(mice_weights$diet)
#> 
#> chow   hf 
#>  394  386

箱线图显示,高脂肪饮食的小鼠平均体重较重。

with(mice_weights, boxplot(body_weight ~ diet))

* *然而,鉴于我们是随机划分小鼠的,观察到的差异是否仅仅是由于偶然?

在将线性模型联系起来之前,在下一节中,我们使用第十三章中描述的方法对这两个均值的差异进行统计分析。

17.2 比较组均值

两组样本平均值,高脂肪饮食和常规饲料,是不同的:

library(tidyverse
mice_weights |> group_by(diet) |> summarize(average = mean(body_weight))
#> # A tibble: 2 × 2
#>   diet  average
#>   <fct>   <dbl>
#> 1 chow     31.5
#> 2 hf       36.7

然而,这是一个随机抽取的小鼠样本,分配到饮食组也是随机进行的。那么这种差异是由于偶然吗?我们将使用假设检验来回答这个问题,假设检验首先在第十三章中描述。

\(\mu_1\)\(\sigma_1\)分别代表如果整个小鼠群体都处于高脂肪饮食下,我们会观察到的平均体重和标准差。类似地,定义\(\mu_0\)\(\sigma_0\),但针对的是谷物饮食。定义\(N_1\)\(N_0\)为样本大小,\(\bar{X}_1\)\(\bar{X}_0\)分别为高脂肪和谷物饮食的样本平均值。

由于数据来自随机样本,中心极限定理告诉我们,如果样本足够大,平均差值\(\bar{X}_1 - \bar{X}_0\)的分布可以被近似为一个标准正态分布,期望值为\(\mu_1-\mu_0\),标准误差为\(\sqrt{\frac{\sigma_1²}{N_1} + \frac{\sigma_0²}{N_0}}\)

如果我们将零假设定义为高脂肪饮食没有效果,即\(\mu_1 - \mu_0 = 0\),这意味着

\[\frac{\bar{X}_1 - \bar{X}_0}{\sqrt{\frac{\sigma_1²}{N_1} + \frac{\sigma_0²}{N_0}}} \]

具有期望值 0 和标准误差 1,因此近似服从标准正态分布。

注意,我们实际上无法计算这个量,因为\(\sigma_1\)\(\sigma_0\)是未知的。然而,如果我们用样本标准差来估计它们,分别用\(s_1\)\(s_0\)表示高脂肪和谷物饮食,中心极限定理仍然成立,并告诉我们

\[t = \frac{\bar{X}_1 - \bar{X}_0}{\sqrt{\frac{s_1²}{N_1} + \frac{s_0²}{N_0}}} \]

当零假设为真时,服从标准正态分布。这意味着我们可以轻松地计算观察到与我们获得值一样大的值的概率:

stats <- mice_weights |> 
 group_by(diet) |> 
 summarize(xbar = mean](https://rdrr.io/r/base/mean.html)(body_weight), s = sd(body_weight), n = [n()) 
t_stat <- with(stats, (xbar2] - xbar[1])/[sqrt(s[2]²/n[2] + s[1]²/n[1]))
t_stat
#> [1] 9.34

我们也可以使用 R 函数在一行内完成计算:

with(mice_weights, t.test(body_weight[diet == "hf"], body_weight[diet == "chow"]))

在这里,t 值远远超过 3,所以我们实际上不需要计算 p 值1-pnorm(t_stat),因为我们知道它将会非常小。

注意,当\(N_0\)\(N_1\)不够大时,中心极限定理不适用。然而,正如我们在第 10.2.3 节中解释的,如果结果数据(在这种情况下是体重)近似服从正态分布,那么上面定义的\(t\)服从自由度为\(N_1+N_2-2\)的 t 分布。因此,p 值的计算方法相同,只是我们使用pt而不是pnorm。具体来说,我们使用1-pt(t_stat, with(stats, n[2]+n[1]-2))

在科学研究中,通常检查均值差异。因此,t 统计量是最广泛报道的总结之一。当用来确定观察到的差异是否具有统计学意义时,我们称这个过程为“执行t 检验”。

在上面的计算中,我们计算了观察到与所获得 \(t\) 值一样大的概率。然而,当我们的兴趣包括两个方向上的偏差时,例如,体重的增加或减少——我们必须考虑获得与观察到的 \(t\) 值一样极端的值的概率,无论符号如何。在这种情况下,我们使用 \(t\) 的绝对值并加倍单侧概率:2*(1 - pnorm(abs(t_stat)))2*(1-pt(abs(t_stat), with(stats, n[2]+n[1]-2)))

17.3 单因素设计

尽管 t 检验在比较两种治疗方法时很有用,但通常会有其他变量影响我们的结果。线性模型允许在这些更一般的情况下进行假设检验。我们通过演示如何使用线性模型进行 t 检验来开始描述使用线性模型估计治疗效果的方法。

如果我们假设标准饮食和高脂肪饮食中老鼠的体重分布都是正态分布的,我们可以写出以下线性模型来表示数据:

\[Y_i = \beta_0 + \beta_1 x_i + \varepsilon_i \]

\(x_i = 1\) 时,如果第 \(i\) 只老鼠被喂食高脂肪饮食,否则为 0,并且误差 \(\varepsilon_i\) 独立,并再次假设它们服从正态分布,期望值为 0,标准差为 \(\sigma\)

注意,这个数学公式看起来与我们为父子身高所写出的模型完全一样。然而,\(x_i\) 现在是 0 或 1 而不是连续变量,这使得我们能够在这个不同的环境中使用它。特别是,请注意现在 \(\beta_0\) 代表了啮齿动物在标准饮食中的平均体重,而 \(\beta_0 + \beta_1\) 代表了啮齿动物在高脂肪饮食中的平均体重。

这个模型的一个优点是 \(\beta_1\) 代表了接受高脂肪饮食的 治疗效果。高脂肪饮食没有效果的零假设可以量化为 \(\beta_1 = 0\)。我们估计 \(\beta_1\),然后询问真实值是否可能为零。

那么,我们如何估计 \(\beta_1\) 并计算这个概率?线性模型的一个强大特性是我们可以使用相同的 LSE 机制来估计 \(\beta\) 及其标准误差:

fit <- lm(body_weight ~ diet, data = mice_weights)

因为 diet 是一个有两个条目的因子,lm 函数知道要使用 \(x_i\),一个指示变量来拟合上面的线性模型。summary 函数显示给我们结果估计、标准误差和 p 值:

coefficients(summary(fit))
#>             Estimate Std. Error t value Pr(>|t|)
#> (Intercept)    31.54      0.386   81.74 0.00e+00
#> diethf          5.14      0.548    9.36 8.02e-20

如果我们查看 diethf 行,这里计算出的 t 值 是估计值除以其估计的标准误差:\(\hat{\beta}_1 / \widehat{\mathrm{SE}}[\hat{\beta}_1]\)Pr(>|t|) 是在检验 \(\beta_1=0\) 的零假设时的 p 值。

在简单单因素模型的情况下,我们可以证明这个统计量几乎等同于上一节中计算的 t 统计量t_stat。直观上看,这是有道理的,因为\(\hat{\beta}_1\)和 t 检验的分子都是处理效果的估计。唯一的细微差别是线性模型并不假设每个群体的标准差不同。相反,两个群体共享\(\mathrm{SD}[\varepsilon]\)作为标准差。请注意,尽管我们在这里没有用 R 来证明,但我们可以将线性模型重新定义为每个组有不同的标准误差。另外,请注意,您可以将t.test函数中的var.equal设置为TRUE,以获得完全相同的结果。

在本节提供的线性模型描述中,我们假设\(\varepsilon\)遵循正态分布。这个假设允许我们证明由估计值除以其估计标准误差形成的统计量遵循 t 分布,这反过来又允许我们估计 p 值或置信区间。然而,请注意,我们不需要这个假设来计算最小二乘估计的期望值和标准误差。此外,如果观察数足够多,那么中心极限定理适用,我们可以在不假设误差为正态分布的情况下获得 p 值和置信区间。

17.4 双因素设计

我们现在可以描述线性模型方法相对于直接比较平均值的重大优势。

注意,这个实验包括了雄性和雌性小鼠,并且已知雄性小鼠更重。这解释了为什么残差依赖于性别变量:

boxplot(fit$residuals ~ mice_weights$sex)

* *这种误设可能具有实际影响;例如,如果接受高脂肪饮食的雄性小鼠更多,那么这可以解释这种增加。相反,如果接受较少,我们可能会低估饮食效果。性别可能是一个混杂因素,表明我们的模型肯定可以改进。

From examining the data:

mice_weights |> ggplot](https://ggplot2.tidyverse.org/reference/ggplot.html)(aes(diet, log2(body_weight), fill = sex)) + [geom_boxplot()

* *我们看到饮食效果在两种性别中都存在,并且雄性比雌性重。尽管并不那么明显,饮食效果在雄性中似乎也更强烈。

一个允许以下四个组有不同的期望值的线性模型,1) 雌性啄食饲料,2) 雌性高脂肪饮食,3) 雄性啄食饲料,和 4) 雄性高脂肪饮食,可以写成这样:

\[Y_i = \beta_1 x_{i1} + \beta_2 x_{i2} + \beta_3 x_{i3} + \beta_4 x_{i4} + \varepsilon_i \]

其中\(x_{i1},\dots,x_{i4}\)是四个组中的每个组的指示变量。通过这种表示,我们允许饮食效果在雄性和雌性中不同。

然而,在原始表示中,没有一个 \(\beta\) 参数直接对应于感兴趣的待遇效应(高脂肪饮食的效果)。线性模型的一个有用特性是我们可以重新参数化模型,使得拟合的均值保持不变,但系数的解释与科学问题相一致。

例如,考虑以下模型

\[Y_i = \beta_0 + \beta_1 \, x_{i1} + \beta_2 \, x_{i2} + \beta_3 \,x_{i1} x_{i2} + \varepsilon_i\,, \]

其中

  • \(x_{i1} = 1\) 如果老鼠 \(i\) 接受了高脂肪饮食,否则为 \(0\)

  • \(x_{i2} = 1\) 如果老鼠 \(i\) 是雄性,如果是雌性则为 \(0\)

然后,我们按照以下方式解释参数:

  • \(\beta_0\) 是雌性在普通饮食中的基线平均体重

  • \(\beta_1\) 是雌性的饮食效应

  • \(\beta_2\) 是在普通饮食下雄性和雌性之间的平均差异

  • \(\beta_3\) 是雄性和雌性之间饮食效应的差异,通常称为 交互 效应

为了了解为什么这有效,让我们将 \(x_{1i}\)\(x_{2i}\) 的四种选项插入到上面的线性组合中,看看我们会得到什么:

\[\begin{aligned} \mbox{雌性在普通饮食中} \implies x_{1i} = 0, \,x_{2i} = 0 \implies \mathrm{E}[Y_i] &= \beta_0 \\ \mbox{雌性在高脂肪饮食中} \implies x_{1i} = 1, \,x_{2i} = 0 \implies \mathrm{E}[Y_i] &= \beta_0 + \beta_1\\ \mbox{雄性在普通饮食中} \implies x_{1i} = 0, \,x_{2i} = 1 \implies \mathrm{E}[Y_i] &= \beta_0 + \beta_2\\ \mbox{雄性在普通饮食中} \implies x_{1i} = 1, \,x_{2i} = 1 \implies \mathrm{E}[Y_i] &= \beta_0 +\beta_1 + \beta_2\\ \end{aligned} \]

这种参数化不会改变预测值。它只是改变了我们对系数的解释方式,以便每个系数都对应一个有意义的比较。

统计教材描述了其他几种重写模型的方法,以获得其他类型的解释。例如,我们可能希望 \(\beta_2\) 代表整体饮食效应(雌性和雄性效应的平均值),而不是对雌性的饮食效应。这是通过定义我们感兴趣的 对比 来实现的。

在 R 中,我们可以使用以下方式指定上述线性模型:

fit <- lm(body_weight ~ diet*sex, data = mice_weights)

在这里,* 表示 因子交叉,而不是乘法:diet*sexdiet+sex+diet:sex 的简写,表示在模型中分别包含饮食、性别和饮食/性别的交互作用。

summary(fit)$coef
#>             Estimate Std. Error t value  Pr(>|t|)
#> (Intercept)    27.83      0.440   63.27 1.48e-308
#> diethf          3.88      0.624    6.22  8.02e-10
#> sexM            7.53      0.627   12.02  1.27e-30
#> diethf:sexM     2.66      0.891    2.99  2.91e-03

请注意,雄性效应大于饮食效应,饮食效应对两种性别都具有统计学意义,饮食对雄性的影响在 1 到 4.5 克之间。

当认为多个因素会影响测量时,常用的方法是为每个因素简单地包含一个加性效应,如下所示:

\[Y_i = \beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \varepsilon_i \]

在此模型中,\(\beta_1\) 是一个通用的饮食效应,无论性别如何都适用。在 R 中,我们使用以下代码,使用 + 而不是 *

fit <- lm(body_weight ~ diet + sex, data = mice_weights)

请注意,此模型没有考虑男性和女性之间饮食效应的差异。诊断图将揭示这一缺陷,通过显示残差是有偏的:对于饮食的女性,平均而言是负的,而对于饮食的男性,平均而言是正的,而不是围绕 0 中心。

boxplot(fit$residuals ~ diet + sex, data = mice_weights) 
abline(h = 0, lty = 2)

* *科学研究表明,尤其是在流行病学和社会科学领域,由于变量数量众多,经常从模型中省略交互项。添加交互项需要众多参数,在极端情况下可能会阻止模型拟合。然而,省略交互项隐含地假设它们为 0,这可能导致无法很好地描述数据的模型,因此不适合使用。相反,当我们能够捍卫没有交互效应的假设时,排除交互项的模型更容易解释,因为参数通常被视为结果随着分配的治疗程度增加的幅度。

线性模型在许多场景中都非常灵活且适用。例如,我们不仅可以包含超过两个的因素。我们仅仅只是触及了线性模型在估计治疗效果方面的应用表面。我们强烈建议通过探索涵盖lmcontrastsmodel.matrix等函数的线性模型教科书和 R 手册来了解更多关于这方面的知识。

17.5 对比

在我们在例子中检查的例子中,每种治疗只有两组:饮食有 chow/高脂肪,性别有女性/男性。然而,感兴趣的变量通常有多个水平。例如,我们可能在老鼠身上测试了第三种饮食。在统计学教科书中,这些变量被称为因素,每个因素中的组被称为其水平

当一个因素被包含在公式中时,lm的默认行为是将截距项定义为第一水平的期望值,其他系数则表示与其他水平与第一水平之间的差异,或称为对比。我们可以通过以下方式使用lm来估计性别效应:

fit <- lm(body_weight ~ sex, data = mice_weights)
coefficients(fit)
#> (Intercept)        sexM 
#>       29.76        8.82

为了恢复男性的期望均值,我们可以简单地添加两个系数:

sum(fit$coefficients[1:2])
#> [1] 38.6

emmeans包简化了计算,并计算标准误差:

library(emmeans)
emmeans(fit, ~sex)
#>  sex emmean    SE  df lower.CL upper.CL
#>  F     29.8 0.339 778     29.1     30.4
#>  M     38.6 0.346 778     37.9     39.3
#> 
#> Confidence level used: 0.95

现在,如果我们真的不想定义参考水平怎么办?如果我们想有一个参数来表示每个组与总体均值之间的差异怎么办?我们能写出一个这样的模型吗:

\[ Y_i = \beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \varepsilon_i $$ 其中 $x_{i1} = 1$,如果观察 $i$ 是女性否则为 0,$x_{i2}=1$,如果观察 $i$ 是男性否则为 0? 不幸的是,这种表示有问题。注意,女性和男性的均值分别用 $\beta_0 + \beta_1$ 和 $\beta_0 + \beta_2$ 表示。这是一个问题,因为每个组的期望值只是一个数字,比如说 $\mu_f$ 和 $\mu_m$,而 $\beta_0 + \beta_1 = \mu_f$ 和 $\beta_0 +\beta_2 = \mu_m$ 有无限多种方式(三个未知数,两个方程)。这意味着我们无法获得唯一的平方最小估计。当这种情况发生时,我们说模型或参数是不可识别的。R 的默认行为通过要求 $\beta_1 = 0$ 来解决这个问题,迫使 $\beta_0 = \mu_m$,这允许我们解这个方程组。 请记住,这并不是允许估计参数的唯一约束。任何线性约束都可以,因为它为我们系统添加了第三个方程。广泛使用的约束是要求 $\beta_1 + \beta_2 = 0$。在 R 中,我们可以使用以下方式使用 `contrasts` 参数来实现这一点: ```r fit <- lm(body_weight ~ sex, data = mice_weights, contrasts = list(sex=contr.sum)) coefficients(fit) #> (Intercept) sex1 #> 34.17 -4.41 ``` 我们看到截距现在更大,反映了整体均值而不是仅女性的均值。另一个系数,$\beta_1$,代表模型中女性与整体均值之间的对比。男性的系数没有显示,因为它冗余:$\beta_1= -\beta_2$. 如果我们想查看所有估计值,**emmeans** 包也为我们进行了计算: ```r contrast(emmeans(fit, ~sex)) #> contrast estimate SE df t.ratio p.value #> F effect -4.41 0.242 778 -18.200 <.0001 #> M effect 4.41 0.242 778 18.200 <.0001 #> #> P value adjustment: fdr method for 2 tests ``` 当因素有多个水平时,使用这种替代约束更为实用,选择基线变得不那么方便。此外,我们可能对系数的方差比对组间和参考水平之间的对比更感兴趣。 作为例子,考虑我们的数据集中的老鼠实际上来自几个代: ```r table(mice_weights$gen) #> #> 4 7 8 9 11 #> 97 195 193 97 198 ``` 为了估计不同代际引起的可变性,一个方便的模型是: $$ Y_i = \beta_0 + \sum_{j=1}^J \beta_j x_{ij} + \varepsilon_i \]

使用 \(x_{ij}\) 指示变量:\(x_{ij}=1\) 如果老鼠 \(i\) 在水平 \(j\) 上,否则为 0,\(J\) 代表水平数量,在我们的例子中是 5 代,并且使用以下方式对水平效应进行约束:

\[\frac{1}{J} \sum_{j=1}^J \beta_j = 0 \implies \sum_{j=1}^J \beta_j = 0. \]

这个约束使得模型可识别,同时也允许我们量化由于代际差异引起的可变性:

\[\sigma²_{\text{gen}} \equiv \frac{1}{J}\sum_{j=1}^J \beta_j² \]

我们可以使用以下方法查看估计的系数:

fit <- lm(body_weight ~ gen,  data = mice_weights, contrasts = list(gen=contr.sum))
contrast(emmeans(fit, ~gen)) 
#>  contrast     estimate    SE  df t.ratio p.value
#>  gen4 effect    -0.122 0.705 775  -0.174  0.8620
#>  gen7 effect    -0.812 0.542 775  -1.497  0.3370
#>  gen8 effect    -0.113 0.544 775  -0.207  0.8620
#>  gen9 effect     0.149 0.705 775   0.212  0.8620
#>  gen11 effect    0.897 0.540 775   1.663  0.3370
#> 
#> P value adjustment: fdr method for 5 tests

在下一节中,我们将简要描述一种用于研究与该因素相关的可变性的有用技术。

17.6 方差分析(ANOVA)

当因素有多个水平时,通常希望确定是否存在水平间的显著可变性,而不是任何给定水平对的特定差异。方差分析(ANOVA)提供了进行这种分析的工具。

ANOVA(方差分析)提供了对\(\sigma²_{\text{gen}}\)的估计,并对原假设进行统计检验,即该因素不贡献任何变异性:\(\sigma²_{\text{gen}} =0\)

一旦使用一个或多个因素拟合了线性模型,就可以使用aov函数进行方差分析。具体来说,计算因素变异性估计的同时,还会计算一个可用于假设检验的统计量:

summary(aov(fit))
#>              Df Sum Sq Mean Sq F value Pr(>F)
#> gen           4    294    73.5    1.13   0.34
#> Residuals   775  50479    65.1

记住,如果给出一个模型公式,aov将拟合该模型:

summary(aov(body_weight ~ gen, data = mice_weights))

我们不需要指定约束条件,因为 ANOVA 需要将总和约束为 0,以便结果可解释。

这项分析表明,代际效应在统计上并不显著。

我们不包括许多细节,例如,关于aov显示的汇总统计量和 p 值的定义和动机。有几本书专门讨论方差分析,线性模型的教科书通常包括关于这个主题的章节。对学习这些主题感兴趣的人可以查阅推荐阅读部分列出的教科书。* *### 多个因素

方差分析最初是为了分析农业数据而开发的,这些数据通常包括几个因素,如肥料、地块和植物品种。

注意,我们可以对多个因素进行方差分析:

summary(aov(body_weight ~ sex + diet + gen,  data = mice_weights))
#>              Df Sum Sq Mean Sq F value Pr(>F) 
#> sex           1  15165   15165  389.80 <2e-16 *
#> diet          1   5238    5238  134.64 <2e-16 *
#> gen           4    295      74    1.89   0.11 
#> Residuals   773  30074      39 
#> ---
#> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

这项分析表明,性别是最大的变异性来源,这与之前做出的探索性绘图一致。

方差分析(ANOVA)的一个关键方面是其将数据中的总方差分解为每个因素在研究中的个别贡献的能力,这由\(\sum_{i=1}^N y_i²\)表示。然而,为了使方差分析的数学基础有效,实验设计必须是平衡的。这意味着对于任何给定因素的每个水平,都必须有其他所有因素水平的相等代表性。在我们的涉及老鼠的研究中,设计是不平衡的,因此在解释方差分析结果时需要谨慎。

数组表示

当模型包括多个因素时,写下线性模型可能会变得繁琐。例如,在我们的二因素模型中,我们必须包括两个因素的指示变量:

\[Y_i = \beta_0 + \sum_{j=1}^J \beta_j x_{i,j} + \sum_{k=1}^K \beta_{J+k} x_{i,J+k} + \varepsilon_i \mbox{ with }\sum_{j=1}^J \beta_j=0 \mbox{ and } \sum_{k=1}^K \beta_{J+k} = 0, \]

第一因素中\(J\)个水平的\(x_{i,1},\dots,x_{i,J}\)指示函数和第二因素中\(K\)个水平的\(x_{i,J+1},\dots,x_{i,J+K}\)指示函数。

在方差分析中广泛使用的一种替代方法,以避免指示变量,是将数据保存在一个数组中,使用不同的希腊字母表示因素,用索引表示水平:

\[Y_{ijk} = \mu + \alpha_j + \beta_k + \varepsilon_{ijk}, \mbox{ with } i = 1,\dots,I, \, j = 1,\dots,J, \mbox{ and } k= 1,\dots,K \]

\(\mu\) 表示总体均值,\(\alpha_j\) 表示第一个因素中 \(j\) 级别的效应,\(\beta_k\) 表示第二个因素中 \(k\) 级别的效应。约束条件现在可以写成如下形式:

\[\sum_{j=1}^J \alpha_j = 0 \text{ 和 } \sum_{k=1}^K \beta_k = 0 \]

这种数组表示法自然导致通过计算数组维度的平均值来估计效应。

注意,在这里,我们隐含地假设了一个 平衡设计,这意味着对于每一个因素级别 \(j\)\(k\) 的组合,我们观察到的重复次数 \(i\) 是相同的。例如,如果因素是性别和饮食,平衡设计将包括每个性别-饮食组合中相同数量的老鼠。在这种情况下,对于每个三元组 \((i,j,k)\) 都有一个值 \(Y_{ijk}\),并且设计中的每个单元格具有相同的样本量。这确保了组均值定义良好,并且可以使用简单平均值直接估计模型参数。

如果设计是 不平衡的,即某些级别组合的观察值多于其他组合,我们仍然可以用相同的形式写出模型,但重复次数 \(I_{jk}\) 会根据级别对变化。在这种情况下,一些方便的 ANOVA 总结解释不再直接适用,因为单元格均值对估计的贡献不平等。

17.7 练习

1. 一旦拟合了模型,标准误差 \(\sigma\) 的估计值可以按以下方式获得:

fit <- lm(body_weight ~ diet, data = mice_weights)
summary(fit)$sigma

使用只包括饮食和考虑性别的模型来计算 \(\sigma\) 的估计值。这些估计值是否相同?如果不相同,原因是什么?

2. lm 拟合线性模型的一个假设是,误差 \(\varepsilon_i\) 的标准差对所有 \(i\) 是相同的。这意味着它不依赖于期望值。像这样按老鼠的重量分组:

breaks <- with(mice_weights, seq(min(body_weight), max(body_weight), 1))
dat <- mutate(mice_weights, group = cut(body_weight, breaks, include_lowest=TRUE))

计算具有超过 10 个观察值的组的 body_weight 的平均值和标准差,并使用数据探索来验证此假设是否成立。

3. 数据集还包括一个变量,表示老鼠来自哪个窝。创建一个按窝展示重量的箱线图。使用分面图来为每个饮食和性别组合创建单独的图表。

4. 使用线性模型测试窝效应,同时考虑性别和饮食。使用 ANOVA 比较窝解释的变异性与其他因素的变异性。

5. mice_weights 数据还包括两个其他结果:骨密度和脂肪百分比。创建一个箱线图,按性别和饮食展示骨密度。比较可视化揭示的饮食效应与性别之间的关系。

6. 拟合线性模型并对每个性别进行单独的饮食效应对骨密度的测试。注意,饮食效应对女性具有统计学意义,但对男性没有。然后将模型拟合到包含饮食、性别及其交互作用的整个数据集。注意,饮食效应是显著的,但交互效应不是。解释这种情况是如何发生的。提示:要拟合包含男性和女性单独效应的整个数据集的模型,可以使用公式 ~ sex + diet:sex

7. 在 第十章 中,我们讨论了调查员偏差并使用可视化来激发这种偏差的存在。在这里,我们将对其进行更严格的处理。我们将考虑进行了每日调查的两个调查员。我们将查看选举前一个月的全国调查:

library(dslabs)
polls <- polls_us_election_2016 |> 
 filter(pollster %in% c("Rasmussen Reports/Pulse Opinion Research",
 "The Times-Picayune/Lucid") &
 enddate >= "2016-10-15" &
 state == "U.S.") |> 
 mutate(spread = rawpoll_clinton/100 - rawpoll_trump/100) 

我们想要回答的问题是:是否存在调查员偏差?绘制每个调查员的分布图。

8. 数据似乎确实表明存在差异。然而,这些数据受到变异性影响。也许我们观察到的差异是由于偶然性造成的。

灶模型理论没有提及调查员效应。在灶模型下,两位调查员都有相同的期望值:选举日差异,我们称之为 \(\mu\)

要回答“是否存在灶模型?”的问题,我们将以以下方式对观察到的数据 \(Y_{ij}\) 进行建模:

\[Y_{ij} = \mu + b_i + \varepsilon_{ij} \]

其中 \(i=1,2\) 表示两位调查员,\(b_i\) 是调查员 \(i\) 的偏差,\(\varepsilon_{ij}\) 是调查之间的随机变化。我们假设 \(\varepsilon\) 之间相互独立,期望值为 \(0\),标准差为 \(\sigma_i\),无论 \(j\) 如何。

以下哪个选项最能代表我们的问题?

  1. \(\varepsilon_{ij}\) 是否等于 0?

  2. \(Y_{ij}\)\(\mu\) 的接近程度如何?

  3. \(b_1 \neq b_2\) 吗?

  4. \(b_1 = 0\)\(b_2 = 0\) 吗?

9. 在此模型的右侧,只有 \(\varepsilon_{ij}\) 是随机变量;其他两个是常数。\(Y_{1,j}\) 的期望值是多少?

10. 假设我们将 \(\bar{Y}_1\) 定义为第一个调查员进行的调查结果的平均值,\(Y_{1,1},\dots,Y_{1,N_1}\),其中 \(N_1\) 是第一个调查员进行的调查数量:

polls |> 
 filter(pollster=="Rasmussen Reports/Pulse Opinion Research") |> 
 summarize(N_1 = n())

\(\bar{Y}_1\) 的期望值是多少?

11. \(\bar{Y}_1\) 的标准误差是多少?

12. 假设我们将 \(\bar{Y}_2\) 定义为第二个调查员进行的调查结果的平均值,\(Y_{2,1},\dots,Y_{2,N_2}\),其中 \(N_2\) 是第二个调查员进行的调查数量。\(\bar{Y}_2\) 的期望值是多少?

13. \(\bar{Y}_2\) 的标准误差是多少?

14. 通过回答上述问题所学的知识,\(\bar{Y}_{2} - \bar{Y}_1\) 的期望值是多少?

15. 通过回答上述问题所学的知识,\(\bar{Y}_{2} - \bar{Y}_1\) 的标准误差是多少?

  1. 上述问题的答案取决于 \(\sigma_1\)\(\sigma_2\),这些我们不知道。我们了解到我们可以用样本标准差来估计这些值。编写代码来计算这两个估计值。

  2. CLT 告诉我们关于 \(\bar{Y}_2 - \bar{Y}_1\) 分布的什么信息?

  3. 没有因为这不是样本的平均值。

  4. 因为 \(Y_{ij}\) 大约服从正态分布,所以平均值也服从正态分布。

  5. 注意到 \(\bar{Y}_2\)\(\bar{Y}_1\) 是样本平均值,所以如果我们假设 \(N_2\)\(N_1\) 足够大,每个都是近似正态分布的。正态分布变量的差也是正态分布的。

  6. 数据不是 0 或 1,所以中心极限定理不适用。

  7. 我们构造了一个期望值为 \(b_2 - b_1\) 的随机变量,表示民意调查偏差的差异。如果我们的模型成立,那么这个随机变量将具有近似正态分布,并且我们知道其标准误差。标准误差取决于 \(\sigma_1\)\(\sigma_2\),但我们可以插入我们上面计算出的样本标准差。我们最初的问题是:\(b_2 - b_1\) 是否与 0 不同?使用我们上面收集到的所有信息,构建 \(b_2 - b_1\) 差异的 95% 置信区间。

  8. 置信区间告诉我们存在相对较强的民意调查员效应,导致差异约为 5%。随机变异性似乎不能解释这一点。我们可以计算一个 p 值来传达事实,即偶然性不能解释这一点。p 值是多少?

  9. \(b_2-b_1\) 的估计值除以其估计的标准误差得到的统计量是 t 统计量:

\[\frac{\bar{Y}_2 - \bar{Y}_1}{\sqrt{s_2²/N_2 + s_1²/N_1}} \]

现在请注意,我们不止有两个民意调查员。我们还可以使用所有民意调查员来测试民意调查员效应,而不仅仅是两个。想法是比较调查之间的变异性与调查内部的变异性。

对于这个练习,创建一个新的表格:

polls <- polls_us_election_2016 |> 
 filter(enddate >= "2016-10-15" &
 state == "U.S.") |>
 group_by(pollster) |>
 filter(n() >= 5) |> 
 mutate(spread = rawpoll_clinton/100 - rawpoll_trump/100) |>
 ungroup()

计算每个民意调查员的平均值和标准差,并检查平均值之间的变异性。将其与民意调查员内部的标准差总结的变异性进行比较。

18  广义线性模型

原文:rafalab.dfci.harvard.edu/dsbook-part-2/linear-models/glm.html

  1. 线性模型

  2. 18  广义线性模型

我们迄今为止使用的统计模型都假设感兴趣的结果是连续的。然而,在许多实际应用中,结果可能是二元的、分类的或计数的。在这些情况下,我们通常对结果如何随着一个或多个解释变量而变化感兴趣。

例如,我们可能想要研究冠心病发病概率如何随年龄、收缩压或其他临床测量值变化;泰坦尼克号沉没事件中生存机会如何取决于乘客特征;或者不同杀虫剂如何影响农业田地观测到的昆虫数量。

虽然这些结果不再是连续的,但线性模型框架中引入的许多想法仍然适用。通过稍作调整,我们可以扩展用于线性模型的数学方法,使其适用于所有这些情况,同时继续在 R 中使用相同的建模工具。

这些扩展模型被称为广义线性模型。为了激发它们,我们以两个分类变量的关联性检验开始本章。然后我们展示这些检验如何自然地从逻辑回归中产生,这是我们的第一个二元结果广义线性模型示例。最后,我们通过例子说明相同的框架可以应用于各种情境,包括结果为二元或计数的情况。

18.1 关联性检验

我们从最简单的情况开始:评估两个分类变量是否相关。这些方法在 GLM 之前就已经开发出来,但它们可以被理解为逻辑回归的特殊情况。一旦我们建立了这种联系,通用的 GLM 框架就会自然而然地出现。

案例研究:资助成功率

2014 年 PNAS 论文¹分析了荷兰资助机构的成功率,并得出结论,他们的

结果揭示在“研究者质量”(但不是“提案质量”)评估和成功率以及教学和评估材料中的语言使用上存在性别偏见,有利于男性申请者而非女性申请者。

支持这一结论的主要证据是基于百分比的比较,我们可以使用论文中提供的数据来计算这些百分比:

library(tidyverse
library(dslabs)
totals <- research_funding_rates |> select(-discipline) |> 
 summarize_all(sum) |>
 summarize(yes_men = awards_men, 
 no_men = applications_men - awards_men, 
 yes_women = awards_women, 
 no_women = applications_women - awards_women) 
 totals |> summarize(percent_men = yes_men/(yes_men + no_men),
 percent_women = yes_women/(yes_women + no_women))
#>   percent_men percent_women
#> 1       0.177         0.149

但这仅仅是因为随机变异性吗?在这里,我们学习如何对这类数据进行推断。

在第 18.1.3 节中,我们介绍了允许我们回答这个问题的关联性检验。在这样做之前,我们提供了一个具有历史意义的例子,说明了这种方法的必要性。

《女士品茶》

关联检验的经典激励例子是 R.A.费舍尔设计的女士品茶实验。罗瑟姆斯特德实验站的同事声称她能判断牛奶是在倒入杯子之前还是之后倒入的。费舍尔提出了一种随机测试,并推导出在假设她只是在猜测的情况下,每种可能结果的概率。这导致了现在所知的基于超几何分布的费舍尔精确检验

例如,假设她正确地识别出了 4 个杯子中的 3 个:

tab <- table(guess =  c("m", "t", "m", "t", "t", "m", "t", "m"),
 actual = c("m", "t", "m", "t", "t", "t", "m", "m"))
tab
#>      actual
#> guess m t
#>     m 3 1
#>     t 1 3

为了评估这是否提供了真实能力的证据,我们考虑她是在猜测的零假设。因为她知道每种类型的杯子都有四个,猜测相当于从包含 4 个先加牛奶和 4 个先加茶*的杯子中随机抽取 4 个杯子。

正确猜测\(k\)个杯子的概率由超几何公式给出:

\[\frac{\binom{4}{k}\binom{4}{4-k}}{\binom{8}{4}}. \]

因此,偶然正确猜测 3 个或更多的概率是:$$ \frac{\binom{4}{3}\binom{4}{1}}{\binom{8}{4}} + \frac{\binom{4}{4}\binom{4}{0}}{\binom{8}{4}} = \frac{16}{70} \approx 0.24. $$ 因此,正确识别 3 个杯子可能很容易是偶然发生的,并不是所声称能力的有力证据。

这个计算正是费舍尔精确检验所执行的。在 R 中,我们可以输入:

fisher.test(tab, alternative = "greater")$p.value
#> [1] 0.243

历史记录表明,这位女士在原始演示中确实表现良好。费舍尔的目标不是确认或反驳她的能力,而是说明如何设计一个公平的实验,以及如何用概率评估证据。

二列联表和卡方检验

注意,我们的资金分配例子与女士品茶类似:在零假设下,资金的分配是随机的,无论性别如何,正如费舍尔的例子中,杯子是随机猜测的,无论牛奶是在什么时候倒入的。

然而,在女士品茶例子中,我们能够使用超几何分布,因为设计固定了每种类型的杯子数量和每个类别的选择数量。换句话说,tab的行总数和列总数是由实验预先确定的。

在资金分配的例子中,以及大多数现实世界的数据中,情况并非如此。资助申请的数量以及男性和女性申请者的数量都不是预先固定的;这些总数是从我们观察到的数据中产生的。因为行和列的总数不是固定的,超几何模型就不再适用,也很少在实际中应用。

在这种情况下,我们使用的是卡方检验,它提供了一种测试两个分类变量之间关联的方法,而不需要固定的边缘。让我们将其应用于资金率例子。

卡方检验的第一步是创建观察到的二列联表

o <- with(totals, data.frame(men = c(no_men, yes_men), 
 women = c(no_women, yes_women),
 row.names = c("no", "yes")))

然后我们估计整体资助率,我们将用它来确定如果成功资助独立于性别分配,我们会期望看到什么:

rate <- with(totals, (yes_men + yes_women))/sum(totals)

我们使用这个来计算我们期望偶然看到的结果:

e <- with(totals, data.frame(men = (no_men + yes_men)*c(1 - rate, rate),
 women = (no_women + yes_women)*c(1 - rate, rate),
 row.names = c("no", "yes")))

我们可以看到,比预期更多的男性获得了资助,而比预期更少的女性获得了资助:

cbind(o, e)
#>      men women  men women
#> no  1345  1011 1365   991
#> yes  290   177  270   197

然而,在零假设下,这些观察值是随机变量。卡方统计量量化了观察表与期望表之间的差异,通过:

  1. 计算每个观察值和期望值单元格值的差值。

  2. 将这个差值平方。

  3. 将每个平方差值除以期望值。

  4. 将所有这些值相加得到最终统计量。

sum((o - e)²/e)
#> [1] 4.01

我们使用这个汇总统计量,因为它的抽样分布可以被一个已知的参数分布所近似:卡方分布。

R 函数chisq.test接受一个二乘二表,并返回测试结果:

chisq_test <- chisq.test(o, correct = FALSE)

我们看到 p 值是 0.045:

chisq_test$p.value
#> [1] 0.0451

默认情况下,chisq.test函数应用了一个连续性校正。这种校正从观察值和期望值之间的绝对偏差中减去 0.5:

sum((abs(o - e) - 0.5)² / e)
#> [1] 3.81

这符合默认行为:

chisq.test(o)$statistic
#> X-squared 
#>      3.81

这种调整的原因是,卡方检验基于一个连续概率分布,而二乘二表计数是离散的。当样本量较小时,连续近似和离散分布之间的差异可能是明显的。连续性校正稍微降低了测试统计量,以考虑到这种不匹配,使近似更加保守(也就是说,不太可能偶然产生小的 p 值)。

我们之前使用correct = FALSE参数来避免这种调整,因为在中等到大样本中,这种调整不是必需的,并且可能会稍微降低测试的效力。

优势比

卡方检验提供了一个 p 值,但它并不量化效应的大小。如第十三章 Chapter 13 中讨论的,我们通常更喜欢置信区间而不是 p 值,因为它们传达了效应大小和不确定性。那么我们在这里如何量化效应呢?

对于两个分类组,我们的数据可以总结在一个二乘二表中:

女性 男性
资助 a b
未资助 c d

一个选择是比较比例的差异:

\[\frac{a}{a+c} - \frac{b}{b+d}. \]

然而,比例的差异在基线水平上并不直接可比。例如,从 1%到 2%的变化在概率尺度上与从 49%到 50%的变化一样大,但实际解释却非常不同。

因此,在我们这个环境中量化关联的更常见方式是优势比

\[ \text{优势比} = \frac{a/c}{b/d} = \frac{ad}{bc} $$ 这个量度比较了女性获得资助的优势与男性获得资助的优势。 对数*几率比经常被使用,因为它在 0 周围是对称的: $$ \log\left(\frac{ad}{bc}\right) = 0 \quad \text{当组间没有差异时。} \]

此外,对数几率比有一个方便的近似标准误差:

\[\mathrm{SE}\left[\log\left(\frac{ad}{bc}\right)\right] \approx\sqrt{\frac{1}{a} + \frac{1}{b} + \frac{1}{c} + \frac{1}{d}}. \]

并且可以证明其渐近正态。这使我们能够构建置信区间。95% 的置信区间将是:

\[ \log\left(\frac{ad}{bc}\right) \pm 1.96 \sqrt{\frac{1}{a} + \frac{1}{b} + \frac{1}{c} + \frac{1}{d}} $$ 一旦我们构建了对数几率比的置信区间,我们可以通过对两个端点进行指数运算来获得几率比的置信区间。 在这里,我们计算我们的资助数据的几率比并构建一个 95% 的置信区间: ```r or <- o[1,1]*o[2,2]/(o[2,1]*o[1,2]) se <- sqrt(sum(1/o)) exp(log(or) + c(-1,1) * qnorm(0.975) * se) #> [1] 0.662 0.996 ``` 这使我们能够评估关联的幅度及其不确定性。估计的几率比小于 1,表明在这个数据集中,女性的获得资助的几率低于男性。95% 的置信区间不包括 1,这意味着 p 值小于 0.05。 对数几率比在 2x2 表的任何单元格为 0 时未定义。这是因为如果 $a$、$b$、$c$ 或 $d$ 为 0,$\log(\frac{ad}{bc})$ 要么是 0 的对数,要么分母中有 0。对于这种情况,通常的做法是通过向每个单元格加 0.5 来避免 0。这被称为 *Haldane-Anscombe 修正*,并且在实践和理论中都已证明其有效性。 ## 18.2 逻辑回归 我们现在将上述关联测试与第十七章(treatment-effect-models.html)中引入的线性模型框架联系起来。一旦我们建立这种联系,同样的想法可以自然地扩展到更复杂的设置,包括具有连续预测器和多个解释变量的模型。 如果我们将 $Y_i$ 定义为申请者 $i$ 获得资助时为 1,否则为 0,并将 $x_i = 1$ 设定为女性,$x_i = 0$ 设定为男性,我们可能会倾向于编写一个线性模型: $$ Y_i = \beta_0 + \beta_1 x_i + \varepsilon_i. \]

然而,这个模型不合适,因为左侧只取 0 或 1 的值,而右侧可以取任何实数值。特别是,隐含的期望值

\[\mathrm{E}[Y_i] = \Pr(Y_1=1) = \beta_0 + \beta_1 x_i \]

可能小于 0 或大于 1,这对于概率来说是不可能的。

如果我们用 \(x_i\) 的线性函数来建模对数几率

\[\log\left\{\frac{\Pr(Y_i=1)}{1 - \Pr(Y_i=1)}\right\}= \beta_0 + \beta_1 x_i. \]

我们可以得到以下有用的参数解释:

  • \(e^{\beta_0}\) 是男性获得资助的几率,

  • \(e^{\beta_0} e^{\beta_1}\) 是女性获得资助的几率。

  • \(e^{\beta_1}\) 是比较女性和男性的几率比。

因此,参数 \(\beta_1\)对数几率比

“逻辑回归”这个名字来源于这样一个事实,

\[ g(p) = \log\frac{p}{1-p} $$ 被称为*逻辑函数*或*logit 变换*。* 注意,我们可以通过使用 logit 变换的逆来将概率 $p_i = Pr(Y_i = 1)$ 写成参数 $\beta_0+\beta_1x_i$ 的函数,对于任何 $x_i$: $$ \Pr(Y_i=1) = g^{-1}(g(p_i)) = g^{-1}\left(\beta_0+\beta_1x_i\right) = \frac{e^{\beta_0+\beta_1x_i}}{1 + e^{\beta_0+\beta_1x_i}} = \frac{1}{1 + e^{-\beta_0-\beta_1x_i}} \]

估计

与线性回归不同,最小二乘法在这里不是最优的,因为结果的方差取决于其均值,而期望值的模型是非线性的。相反,参数是通过最大似然估计(MLE)来估计的。MLE 的基本思想是选择使观察数据最可能符合假设的统计模型的参数值。对于逻辑回归,这意味着找到使数据中零和一的特定模式出现的概率最大化的\(\beta_0\)\(\beta_1\)的值。

我们分析两行两列表的例子是一个特殊情况,其中估计值可以直接写成观察比例的函数。但一般来说,对于逻辑回归模型,估计值不能写成简单的封闭形式表达式。相反,使用数值优化算法来找到最大化似然值的值。在标准条件下,中心极限定理的一个版本适用于这些估计值。这意味着对于大样本量,估计值近似正态分布,并且可以从似然函数在最大值附近的曲率计算其标准误差。这反过来又允许构建置信区间和假设检验。

对 MLE 和逻辑回归有更深入和更广泛了解的读者可以参考推荐阅读部分,其中我们指向那些更详细地发展似然框架、渐近结果和数值算法的教科书。

在 R 中拟合模型

我们可以使用 R 中的glm函数,通过设置family = binomial来拟合逻辑回归模型。

尽管我们没有个体层面的记录,但我们确实有每个组的总数。这是足够的,因为男性的结果总和是一个具有\(N_0\)次试验(男性申请人的数量)和成功概率\(p_0\)的二项随机变量,它满足

\[\log\left(\frac{p_0}{1-p_0}\right) = \beta_0. \]

同样,女性的结果总和是二项分布,有\(N_1\)次试验和成功概率\(p_1\),其中

\[\log\left(\frac{p_1}{1-p_1}\right) = \beta_0 + \beta_1. \]

glm函数可以通过使用成功和失败的两列矩阵直接处理分组计数:

success <- with(totals, c(yes_men, yes_women))
failure <- with(totals, c(no_men, no_women))
y <- cbind(success, failure)

并编码组指示符:

x <- factor(c("men", "women"))

然后我们使用glm拟合模型:

fit <- glm(y ~ x, family = binomial)
coefficients(summary(fit))
#>             Estimate Std. Error z value  Pr(>|z|)
#> (Intercept)   -1.534     0.0647   -23.7 3.83e-124
#> xwomen        -0.208     0.1041    -2.0  4.54e-02

为了获得估计的比值比,我们对方程系数进行指数化:

exp(fit$coef[2])
#> xwomen 
#>  0.812

这个值表示女性相对于男性的获得资助几率的乘性变化。

通过对 \(\beta_1\) 的对应置信区间进行指数化,可以得到这个比率的 95% 置信区间:

exp(confint(fit, 2))
#>  2.5 % 97.5 % 
#>  0.661  0.995

这个区间提供了效应大小和不确定性的度量。

与先前方法的关系

我们可以确认,逻辑回归得到的比率估计值与简单二乘表(保存在 o 中)的计算结果相同:并保存在 orse 中:

or <- o[1,1]*o[2,2]/(o[2,1]*o[1,2])
se <- sqrt(sum(1/o))
c(log(or),se)
#> [1] -0.208  0.104
 c(tidy(fit)$estimate[2], tidy(fit)$std.error[2])
#> [1] -0.208  0.104

chisq.test 和逻辑回归得到 p 值

chisq.test(o, correct = FALSE)$p.value
#> [1] 0.0451
tidy(fit)$p.value[2]
#> [1] 0.0454

略有不同,因为它们基于不同的近似。

18.3 模型推广

乍一看,逻辑回归可能看起来是一种不必要的复杂方法,用以获得我们使用比率及标准误差近似所得到的结果。那么为什么引入这个更通用的框架呢?

当解释变量不仅仅是两个类别时,关键优势变得明显。例如,回到冠心病的问题,年龄是一个连续变量。没有简单的方法为每个可能的年龄值形成二乘二表。同样,如果我们希望同时调整几个解释变量(如我们在第十七章治疗效应模型中所做的那样),我们需要一个基于模型的方法,而不是基于表的方法。

通用策略是假设 \(Y_i\) 遵循一个已知的分布,例如二项分布或指数分布,并使用预测因子的线性组合来建模结果的期望值的转换

\[g\left(\mathrm{E}[Y_i]\right) = \beta_0 + \sum_{j=1}^J \beta_j x_{ij} \]

这里 \(x_{ij}\) 是个体 \(i\) 的第 \(j\) 个解释变量的值,而 \(g(\cdot)\) 被称为连接函数。对于二元结果,我们使用了逻辑函数,因为它将概率(必须在 0 和 1 之间)映射到所有实数。对于计数数据,我们通常使用泊松分布来建模,期望值 \(\mathrm{E}[Y_i]\) 可以超过 1,因此对数连接函数不适用。相反,通常使用对数连接函数:\(g(\lambda) = \log(\lambda)\)。这个连接函数将正值(如比率)映射到实数轴,并提供了一个可解释的乘性形式:如果 \(\exp(\beta_1)= 1.10\),那么 \(x_{i1}\) 的一个单位增加对应于比率的 10% 增加。

这个框架被称为广义线性模型(GLM)。逻辑回归是其一个特例;泊松回归是另一个。

逻辑回归示例:冠心病

SAheart 数据集包含来自南非高风险地区成年男性的回顾性样本观察结果:

data("SAheart", package = "bestglm")

如果我们计算每个年龄层中心脏病患者的比例,并检查对数几率,关系看起来大约是线性的:

SAheart |> 
 group_by(age) |>
 summarize(chd = mean(chd)) |>
 ggplot](https://ggplot2.tidyverse.org/reference/ggplot.html)(aes(age, [log(chd/(1 - chd)))) + 
 geom_point()

* *这表明逻辑回归模型:

\[ \log\left\{\frac{\Pr(Y_i=1)}{1 - \Pr(Y_i=1)}\right\} = \beta_0 + \beta_1 x_i $$ 其中 $Y_i$ 是冠心病的一个指标,$x_i$ 代表个体 $i$ 的年龄。$\beta_1$ 的估计值转换为 *优势比*。$\exp(\beta_1)$ 表示年龄每增加一岁,心脏病优势增加的乘性因子。 拟合可以使用以下方法拟合模型: ```r fit <- glm(chd ~ age, family = binomial, data = SAheart) tidy(fit)[2,] #> # A tibble: 1 × 5 #> term estimate std.error statistic p.value #> <chr> <dbl> <dbl> <dbl> <dbl> #> 1 age 0.0641 0.00853 7.51 5.76e-14 ``` 然后我们可以使用广义线性模型(GLM)理论来构建一个 95%置信区间的优势比: ```r exp(confint(fit))[2, ] #> Waiting for profiling to be done... #> 2.5 % 97.5 % #> 1.05 1.08 ``` 我们还可以使用拟合的模型来计算特定年龄的预测概率: ```r predict(fit, data.frame(age = seq(20, 80, 10)), type = "response") #> 1 2 3 4 5 6 7 #> 0.0963 0.1682 0.2774 0.4216 0.5805 0.7243 0.8330 ``` 当`type = response`时,`predict`函数使用逆对数变换将$\hat{\beta}_0 + \hat{\beta}_1 x_i$转换为估计概率。 注意,`SAheart`数据集包括一些可能关联到心脏病风险的额外解释变量,例如吸烟、胆固醇水平和家族病史。使用`glm`函数,我们可以通过将它们添加到公式的右侧来简单地将这些预测变量包含在模型中。然而,在解释结果系数时我们必须谨慎。逻辑回归模型总是会返回参数估计、p 值和置信区间,但这些数字只有在模型是数据生成机制的合理近似时才有意义。在许多生物医学环境中,没有保证预测变量与疾病对数优势之间关系线性的潜在生理理论。因此,应批判性地检查模型假设。这包括探索性数据分析、考虑可能的混杂变量,以及通过诊断工具评估模型拟合度,而不是简单地接受拟合模型。 ### 泊松回归示例:昆虫喷雾的效果 `InsectSprays`数据集记录了使用不同喷雾处理后农业田地单位中发现的昆虫数量: ```r InsectSprays |> ggplot(aes(spray, count)) + geom_boxplot() + geom_jitter() ``` ![](https://github.com/OpenDocCN/dsai-notes-zh/raw/master/docs/hvd-cs109-intd-ds-2e/img/547b224c054f3e08e5ae076b675cb245.png)* *喷雾 C 效果最为显著。我们可以比较不同喷雾的平均昆虫数量: ```r with(InsectSprays, tapply(count, spray, mean)) ``` 但是,我们如何评估不确定性?我们能构建置信区间吗?请注意,计数很小(通常小于 10),每组样本量仅为 12,因此正态近似可能不可靠。 泊松回归提供了一个替代方案。首先,我们将 C 作为参照组,然后拟合模型: ```r InsectSprays$spray <- relevel(InsectSprays$spray, ref = "C") fit <- glm(count ~ spray, family = poisson, data = InsectSprays) tidy(fit, conf.int = TRUE) #> # A tibble: 6 × 7 #> term estimate std.error statistic p.value conf.low conf.high #> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> #> 1 (Intercept) 0.734 0.200 3.67 2.43e- 4 0.315 1.10 #> 2 sprayA 1.94 0.214 9.07 1.18e-19 1.54 2.38 #> 3 sprayB 2.00 0.213 9.36 7.65e-21 1.60 2.44 #> 4 sprayD 0.859 0.239 3.60 3.20e- 4 0.404 1.34 #> 5 sprayE 0.519 0.253 2.05 4.00e- 2 0.0315 1.03 #> # ℹ 1 more row ``` 在这里,参数衡量的是相对于喷雾 C 的预期计数的乘性变化的对数。如果一个喷雾的效果与喷雾 C 相同,其相应的参数估计将是 0。估计的系数及其置信区间表明,几种喷雾与喷雾 C 有显著差异,这些差异太大,不能仅用泊松抽样变异性来解释。换句话说,喷雾 C 的卓越性能不太可能是偶然的。 推理依赖于类似于中心极限定理的渐近近似,但通常在计数较小时比直接将中心极限定理应用于均值表现更好。 在这个例子中,简单地比较组均值也会有效。当解释变量是连续的时,泊松回归的优势变得更加明显,例如,将计数建模为暴露时间或浓度水平的函数,在这种情况下,按组平均是不实际的。 ### 除了逻辑回归和泊松回归 逻辑回归和泊松回归是最广泛使用的广义线性模型,因为二进制和计数结果在科学应用中很常见。然而,广义线性模型框架更通用: + *负二项回归*处理变异性大于泊松模型预测的计数数据。 + *伽马回归*对正的连续结果很有用,例如反应时间或保险索赔规模。 + *准似然广义线性模型*允许在不完全指定分布的情况下建模均值-方差关系。 在实践中,广义线性模型需要仔细的模型检查。诊断图和残差分析在这里特别重要,因为我们明确假设了一个特定的结果分布,这与普通最小二乘法不同。 要进一步了解,请参阅章节末尾列出的推荐阅读。它们详细介绍了链接函数、似然理论、诊断和扩展。 ## 18.4 大样本,小 p 值 如前所述,仅报告 p 值并不总是报告数据分析结果的有用方式。在科学期刊中,一些研究似乎过分强调 p 值。其中一些研究样本量很大,并报告了令人印象深刻的非常小的 p 值。然而,通过仔细观察结果,我们发现优势比相当适度:略大于 1。在这种情况下,差异可能不是*实际显著*或*科学显著*。 注意,优势比和 p 值之间的关系不是一对一的;它取决于样本大小。因此,一个非常小的 p 值并不一定意味着一个很大的优势比。观察如果我们将我们的二乘二表乘以 10 会发生什么,这不会改变优势比: ```r ox10 <- o |> mutate(men = men*10, women = women*10) c(chisq.test(o)$p.value, chisq.test(ox10)$p.value) #> [1] 5.09e-02 2.63e-10 ``` ## 18.5 练习 1. 一位著名的运动员拥有令人印象深刻的职业生涯,赢得了她 500 场比赛中的 70%。然而,这位运动员因在重要赛事中,如奥运会,有 8 胜 9 负的失利记录而受到批评。进行卡方检验以确定这种失利记录是否可以简单地归因于偶然,而不是在压力下表现不佳。 2. 为什么我们在前面的练习中使用了卡方检验而不是费舍尔的精确检验? 1. 实际上这并不重要,因为它们给出了完全相同的 p 值。 1. 费舍尔的精确检验和卡方检验是同一测试的不同名称。 1. 因为二维表的行和列之和不是固定的,所以超几何分布不是对零假设的适当假设。因此,Fisher 精确检验很少适用于观察数据。 1. 因为卡方检验运行得更快。 3. 现在计算“在压力下失败”的几率比,并使用我们学到的近似值构建 95%置信区间。你对那些批评运动员的人有什么看法?这种批评公平吗? 4. 重复练习 3,但使用`glm`函数。比较结果。 5. 使用`research_funding_rates`数据估计每个学科中女性与男性的对数几率比和标准误差。为每个学科计算置信区间。绘制对数几率比,并使用误差线表示 95%置信区间 6. 报告所有似乎有一方性别比另一方更受青睐的学科。 7. 将对数几率比估计值除以其相应的标准误差,并生成一个与标准正态分布比较的 qq 图。是否有任何学科明显偏离了随机预期的结果? 8. 在 2016 年美国总统选举期间,当时的候选人唐纳德·J·特朗普使用他的推特账户作为与潜在选民沟通的方式。Todd Vaziri 假设“每个非夸张的推文都来自 iPhone(他的工作人员)。每个夸张的推文都来自 Android(他本人)。”我们将使用关联检验来测试这个假设。**dslabs**对象`sentiment_counts`提供了一个表格,其中包含来自每个来源(Android 或 iPhone)的几个情感计数: ```r library(tidyverse library(dslabs) sentiment_counts ``` 计算每个情感与 Android 和 iPhone 相比的几率比,并将其添加到表中。 9. 为每个几率比计算 95%置信区间。 10. 生成一个显示估计几率比及其置信区间的图表。 11. 对于每个情感,测试没有差异的零假设,即来自 Android 和 iPhone 的推文之间没有差异,并报告 p 值小于 0.05 且更有可能来自 Android 的情感。 12. 对于每个情感,找出分配给该情感的单词,保留至少出现 25 次的单词,计算每个的几率比,并显示几率比大于 2 或小于 1/2 的条形图。 13. **titanic**包中的`titanic_train`数据集包含 891 名乘客的数据,他们曾在泰坦尼克号上: ```r library(titanic) titanic_train ``` 在此数据集中: + `Survived`是一个二元变量(`是` / `否`) + `性别`、`年龄`和`舱位`是解释变量 拟合一个以生存为结果、性别为预测因子的逻辑回归模型。 ```r fit_gender <- glm(Survived ~ Sex, family = binomial, data = titanic_train) summary(fit_gender) ``` 1. 解释`性别`系数的符号。 1. 计算并解释*几率比*。 1. 为几率比构建 95%置信区间。 14. 现在拟合一个同时包含`性别`和`舱位`的模型: ```r titanic_train$Pclass <- factor(titanic_train$Pclass) fit_class <- glm(Survived ~ Sex + Pclass, family = binomial, data = titanic_train) summary(fit_class) ``` 1. 根据估计,哪个乘客舱的生存几率最高? 1. 在调整舱位后,性别估计的影响如何变化?解释原因。 15\. 拟合一个包含交互项的模型: ```r fit_interaction <- glm(Survived ~ Sex * Pclass, family = binomial, data = titanic_train) summary(fit_interaction) ``` 1. 性别效应是否随班级而变化? 1. 使用 `exp(coef(fit_interaction))` 来解释交互模式,以优势比的形式。 16\. `InsectSprays` 数据集记录了六种喷雾类型的昆虫数量。 回忆一下泊松回归模型: ```r fit_glm <- glm(count ~ spray, family = poisson, data = InsectSprays) summary(fit_glm) ``` 将线性回归模型拟合到对数转换后的计数(我们必须添加 0.5,因为有些计数为 0): ```r InsectSprays$log_count <- log(InsectSprays$count + 0.5) fit_lm <- lm(log_count ~ spray, data = InsectSprays) summary(fit_lm) ``` 1. 比较 `fit_glm` 和 `fit_lm` 估计的喷雾效应。 1. 讨论一个模型相对于另一个模型的优点。 17\. 在 GLM 中没有单独的误差项 $\varepsilon$。然而,我们仍然可以通过比较观察到的结果 $y_i$ 与拟合值 $\hat{y}_i$ 来定义 *残差*。但是,与标准线性回归不同,$Y_i$ 的方差不是常数。例如: + 在逻辑回归中,$\mathrm{Var}[Y_i] = p_i(1 - p_i)$,这取决于拟合的概率 $p_i=\Pr(Y_i=1)$。 + 在泊松回归中,$\mathrm{Var}[Y_i]= \mathrm{E}[Y_i]$,因此标准误可以用 $\sqrt{\hat{y}_i}$ 来估计。 因为可变性在观测之间变化,所以原始残差 $y_i - \hat{y}_i$ 不能直接比较。*皮尔逊残差* 通过除以它们标准差的估计来解决此问题: $$ r_i = \frac{y_i - \hat{y}_i}{\sqrt{\widehat{\mathrm{Var}}(Y_i)}}. \]

对于具有拟合均值 \(\hat{\mu}_i = \exp(\hat{\beta}_0 + \hat{\beta}_1 x_i)\) 的泊松回归模型,皮尔逊残差是:

\[r_i = \frac{y_i - \hat{\mu}_i}{\sqrt{\hat{\mu}_i}}. \]

这些残差被缩放,使得它们大约集中在 0,方差为 1,这使得它们对诊断图很有用。

您可以使用以下方法在 R 中绘制皮尔逊残差:

plot(fit_glm, which = 1)

为了将此与拟合对数转换后的计数线性模型进行比较:

plot(fit_lm, which = 1)
  1. 哪个模型看起来更好地描述了数据?

  2. 在什么情况下对数转换方法可能失败,而泊松回归仍然有效?

18. 用你自己的话解释:

  • 为什么在建模二元或计数数据时,逻辑回归和泊松回归是有用的。

  • 为什么不总是适合将这些标准线性回归应用于这些结果。

  • 为什么在使用 GLM 时检查模型假设(例如,绘图,拟合诊断)是至关重要的。


  1. http://www.pnas.org/content/112/40/12349.abstract↩︎

19  关联并非因果关系

原文:rafalab.dfci.harvard.edu/dsbook-part-2/linear-models/association-not-causation.html

  1. 线性模型

  2. 19  关联并非因果关系

在本书的这一部分,我们专注于量化变量之间的关联的方法,即一个变量的变化如何与另一个变量的变化相关。线性模型、相关系数和假设检验都提供了正式描述和评估这些关系的方法。将此类结果解释为因果关系证据是诱人的,尤其是当发现似乎直观或符合我们的预期时。然而,这是一个严重的错误:关联并非因果关系

统计工具可以揭示模式,但它们本身并不能确定这些模式存在的原因。确定因果关系需要观察到的经验关系之外的因素。至少,必须满足以下条件:

  • 经验关联: 必须存在感兴趣变量之间的关系。

  • 时间优先性: 原因必须在效果之前发生。

  • 剂量-反应关系: 原因的剂量变化应导致效果的变化。

  • 可能性: 必须有一个可信的机制来解释原因如何产生效果。

  • 排除其他解释: 必须考虑并排除其他潜在原因,例如混杂因素。

本章的一个主要重点是混杂因素,这是两个变量即使没有因果关系也可能看起来相关的原因中最常见且最具欺骗性的原因之一。我们还讨论了其他可能产生误导性关联的几种情况,强调了谨慎解释的重要性。

最后,我们指出,随机对照试验,其中受试者随机分配到治疗,是推断数据因果关系的最可靠方式,因为随机化最小化了其他解释。然而,科学、医学和社会科学中的大多数分析都依赖于观察性现实世界数据,在这些数据中不可能进行随机化,因果推断必须依赖于通常难以或无法验证的假设。理解这些限制对于负责任的数据分析至关重要。

19.1 假相关

以下这个滑稽的例子强调了相关性不等于因果关系的概念。它显示了离婚率和人造黄油消费之间非常强的相关性。

这是否意味着人造黄油消费导致离婚?或者离婚导致人们消费更多人造黄油?当然不是。这只是一个我们称之为假相关的例子。

你可以在 Spurious Correlations 网站上看到更多荒谬的例子¹。

网站上展示的案例都是通常被称为数据挖掘、数据钓鱼或数据窥探的实例。这基本上是美国所说的“挑拣樱桃”的一种形式。数据挖掘的一个例子可能是,如果你通过随机过程产生的许多结果中寻找,并挑选出那些显示支持你想要捍卫的理论的关联性的一个。

可以使用蒙特卡洛模拟来展示数据挖掘如何导致在非相关变量中找到高相关性。我们将把我们的模拟结果保存到 tibble 中:

library(data.table)
library(ggplot2
N <- 25
g <- 1000000
sim_data <- data.table(group = rep(1:g, each = N), x = rnorm(N*g), y = rnorm(N*g))

注意我们创建了组,并为每个组生成了一对独立的向量,\(X\)\(Y\),每个向量有 25 个观测值。因为我们构建了模拟,所以我们知道\(X\)\(Y\)是不相关的。

接下来,我们计算每个组中\(X\)\(Y\)之间的相关性并寻找最大值:

res <- sim_data, .(r = [cor(x, y)), by = group][order(-r)]
max(res$r)
#> [1] 0.814

我们看到了最大相关性为 0.81。如果你只绘制实现这一相关性的组的数据,它将显示一个令人信服的图表,表明\(X\)\(Y\)实际上相关:

sim_datagroup == res[[which.max(r), group]] |>
 ggplot(aes(x, y)) +
 geom_point() + 
 geom_smooth(formula = 'y ~ x', method = "lm")

* *记住,相关性总结是一个随机变量。以下是蒙特卡洛模拟生成的分布:

res |> ggplot(aes(x = r)) + geom_histogram(binwidth = 0.1, color = "black")

* *这是一个简单的数学事实:如果我们观察 100 万个预期为 0 但标准误差为 0.2 的随机相关性,最大的一个将接近 1。

如果我们对这个组进行回归并解释 p 值,我们会错误地声称这是一个具有统计学意义的关联:

library(broom)
sim_datagroup == res[which.max(r), group], broom::tidy([lm(y ~ x))][2,]
#> # A tibble: 1 × 5
#>   term  estimate std.error statistic     p.value
#>   <chr>    <dbl>     <dbl>     <dbl>       <dbl>
#> 1 x        0.794     0.118      6.73 0.000000732

这种被称为 p-hacking 的实践被广泛讨论,因为它可能会损害科学发现的可靠性。由于期刊通常更倾向于统计显著的结果而非无结果,研究人员有动力强调显著性。例如,在流行病学和社会科学等领域,分析师可能会探索结果与许多暴露之间的关联,但只报告那些具有较小 p 值的一个。同样,他们可能会尝试几种模型规格来调整混杂因素并选择给出最强结果的一个。在实验设置中,一项研究可能会重复多次,但只报告“成功”的运行。这些做法并不总是故意的违规行为;它们通常源于有限的统计理解或一厢情愿的思考。更高级的统计学课程涵盖了调整分析以考虑这些多重比较的方法。

19.2 个异常值

假设我们测量两个独立的结果,\(X\)\(Y\),并对每一组测量进行标准化。现在想象一下,我们犯了一个错误,忘记对其中一个值进行标准化,比如说第 23 个条目。我们可以通过以下模拟数据来展示这种情况:

set.seed(1985)
x <- rnorm(100, 100, 1)
y <- rnorm(100, 84, 1)
x-23] <- [scale(x[-23])
y-23] <- [scale(y[-23])

数据看起来是这样的:

plot(x, y)

* *不出所料,相关性非常高:

cor(x,y)
#> [1] 0.988

但是,这是由一个异常值驱动的。如果我们移除这个异常值,相关性会大大降低,几乎接近于 0,这正是它应有的样子:

cor(x[-23], y[-23])
#> [1] -0.0442

对于估计对异常值稳健的总体相关性,存在一种不同于样本相关性的方法。它被称为斯皮尔曼相关系数。其思想很简单:计算值的排名之间的相关性。以下是排名相互之间绘制的图:

plot(rank(x), rank(y))

* *异常值不再与一个非常大的值相关联,相关性显著降低:

cor(rank(x), rank(y))
#> [1] 0.00251

斯皮尔曼相关系数也可以这样计算:

cor(x, y, method = "spearman")
#> [1] 0.00251

虽然斯皮尔曼相关系数受异常值的影响较小,但这种稳健性是有代价的。因为它依赖于排名而不是实际值,所以对变量之间细微的线性关系不太敏感。因此,当确实存在但程度适中的相关性时,斯皮尔曼估计可能比皮尔逊相关系数更接近于零。在实践中,皮尔逊相关系数在检测没有异常值的数据中的真实线性关联时更有效,而斯皮尔曼相关系数在存在异常值或非线性单调关系时更受欢迎。** * *在推荐阅读部分,我们包括了关于对异常值稳健且适用于广泛情况的估计技术的参考文献。

19.3 因果倒置

另一种将关联与因果混淆的方式是当因果关系被倒置时。这种情况的一个例子是声称辅导使学生的表现更差,因为他们测试成绩低于未接受辅导的同伴。在这种情况下,辅导并不是导致低测试成绩的原因,而是相反。

这种说法实际上被一篇发表在《纽约时报》的社论中,标题为《家长的参与被高估了》²。考虑以下文章中的引用:

当我们检查定期帮助做作业是否对儿童的学业表现有积极影响时,我们发现的结果让我们非常震惊。无论家庭的社会阶层、种族或民族背景,或孩子的年级水平如何,一致的作业帮助几乎从未提高过测试分数或成绩… 更令我们惊讶的是,当父母定期帮助做作业时,孩子们通常表现得更差。

一个非常可能的情况是,需要定期家长帮助的孩子,正是因为他们在学校表现不佳才得到这种帮助。

我们可以通过拟合回归

\[X = \beta_0 + \beta_1 y + \varepsilon \]

到父亲-儿子身高数据中,其中 \(X\)\(y\) 分别代表父亲和儿子的身高。在这里因果性地解释 \(\beta_1\) 是倒退的,因为它会暗示儿子的身高决定了父亲的身高。使用先前定义的 galton 数据集,我们确实得到了一个具有统计学意义的斜率,这表明统计显著性并不等同于因果关系或正确的效应方向。

tidy](https://generics.r-lib.org/reference/tidy.html)([lm(father ~ son, data = galton))[2,]
#> # A tibble: 1 × 5
#>   term  estimate std.error statistic       p.value
#>   <chr>    <dbl>     <dbl>     <dbl>         <dbl>
#> 1 son      0.407    0.0636      6.40 0.00000000136

该模型与数据拟合得非常好,估计值、标准误差和 p 值都正确地显示了儿子身高与父亲身高之间存在关联。然而,如果我们只看其数学公式,它很容易被误解为暗示儿子的身高导致了父亲的身高。根据我们对遗传学和生物学的了解,我们知道影响的方向是相反的。统计模型本身并没有错误,我们也没有编码错误。这里误导的是解释,而不是模型或计算。

19.4 混杂因子

混杂因子可能是导致关联被误解的最常见原因。

如果 \(X\)\(Y\) 相关,我们称 \(Z\)混杂因子,如果它或与之相关的某个因素是 \(X\)\(Y\) 的原因,从而在没有正确考虑 \(Z\) 的情况下,在它们之间产生虚假的关联。

由于混杂因子导致的错误解释在大众媒体中普遍存在,并且它们往往很难检测。在这里,我们提供了一个与大学入学相关广泛使用的例子。

示例:加州大学伯克利分校的入学情况

1973 年从加州大学伯克利分校六个专业的入学数据中显示,男性被录取的人数多于女性:男性录取率为 44%,女性为 30%³。我们可以加载数据并计算一个统计检验,这明显拒绝了性别和录取独立性的假设:

library(dplyr
two_by_two <- admissions |> group_by(gender) |> 
 summarize(total_admitted = round(sum(admitted / 100 * applicants)), 
 not_admitted = sum(applicants) - sum(total_admitted)) |>
 select(-gender) 
chisq.test(two_by_two)$p.value
#> [1] 1.06e-21

但更仔细的检查显示了一个矛盾的结果。以下是按专业划分的入学百分比:

admissions |> select(major, gender, admitted) |>
 pivot_wider(names_from = "gender", values_from = "admitted") |>
 mutate(women_minus_men = women - men) |> print(n = 6)
#> # A tibble: 6 × 4
#>   major   men women women_minus_men
#>   <chr> <dbl> <dbl>           <dbl>
#> 1 A        62    82              20
#> 2 B        63    68               5
#> 3 C        37    34              -3
#> 4 D        33    35               2
#> 5 E        28    24              -4
#> 6 F         6     7               1

对于六个专业中的五个,差异很小,专业 A 倾向于女性。更重要的是,这些差异都远远小于在查看总体总数时观察到的男性 14.2 分的优势。

矛盾之处在于,分析总数似乎表明录取和性别之间存在依赖关系,但当数据按专业分组时,这种依赖关系似乎消失了。这是怎么回事?实际上,如果未计数的混杂因子驱动了大部分的变异性,这种情况就会发生。

发现混杂因子,以及理解它们如何导致误导性结论,通常需要探索性数据分析和分析性思维。查看上表,我们注意到不同专业之间的入学率存在很大的差异。这会不会影响整体结果?为了调查,我们在入学表中添加了一个 选择性 列,即每个专业内的整体入学率。

admissions <- group_by(admissions, major) |>
 summarize(selectivity = sum(admitted*applicants/100)/sum(applicants)) |>
 right_join(admissions, by = "major")

接下来,我们考察申请人数与专业选择性之间的关系。如果选择性是一个混杂因素,那么不同选择性的专业之间可能存在性别差异的模式。

admissions |>
 ggplot(aes(selectivity, applicants, label = major)) +
 geom_text() +
 facet_wrap(~gender) 

* *我们可以立即看到关键问题:女性向不那么选择性的专业(A 和 B)提交的申请远远少于男性。因此,系部选择性是一个影响性别(不同的申请模式)和录取结果的双重混杂因素。

分层

对整体录取率的解释出现偏差的第一个迹象来自按专业进行分层。按已知或潜在的混杂因素进行分层是检查两个其他变量之间关系的一种强大技术。在探索性数据分析中,分层可以帮助我们检测混杂因素如何扭曲分析,并提供如何调整它的想法。

这里有一个例子,我们按专业进行分层绘制录取情况,并显示女性倾向于申请更选择性的专业:

admissions |> ggplot(aes(major, admitted, col = gender, size = applicants)) + 
 geom_point()

* *我们看到,按专业划分,性别之间的录取率没有太大差异。然而,大量男性申请专业 B,该专业录取超过 60%的申请者,导致整体比较出现混淆。这个图表表明,需要更复杂的分析来调整专业因素。我们将在下一章学习如何进行这种分析,在第二十章。

辛普森悖论

我们刚刚讨论的案例是辛普森悖论的一个例子。它被称为悖论,因为我们发现在将整个出版物与特定层进行比较时,相关性的符号发生了翻转。作为一个说明性的例子,假设你有三个随机变量 \(X\)\(Y\)\(Z\),并且我们观察了这些变量的实现。以下是 \(X\)\(Y\) 的模拟观察结果以及样本相关性的图表:

您可以看到 \(X\)\(Y\) 是负相关的。然而,一旦我们按 \(Z\) 进行分层(如下所示的不同颜色),另一种模式就会出现:

实际上,是 \(Z\)\(X\) 负相关。如果我们按 \(Z\) 进行分层,\(X\)\(Y\) 实际上是正相关的,如上图所示。

19.5 其他偏差来源

我们讨论了混杂和反向因果关系,但还有许多其他机制可以产生非因果关联。例如,碰撞偏差发生在我们对受两个或更多其他变量影响的变量进行条件设定时,无意中诱导了它们之间虚假的关系。一个经典的例子是,在精英大学的学生中,有时观察到的学术能力和运动技能之间的负相关。在普通人群中,这些特征可能是独立的,但它们都增加了被录取的可能性。在录取这一碰撞条件下进行条件设定,可以创建一个虚假的负相关:在录取的学生中,那些不太擅长运动的人可能学术能力更强,反之亦然。在医院中也可能发生类似的效果,其中两种无关的健康状况,如糖尿病和心脏病,在患者中可能表现出负相关,仅仅是因为它们都增加了住院的可能性。

选择偏差**是另一个常见的虚假关联来源。它发生在确定谁或什么进入数据集的过程依赖于与暴露/治疗和结果都相关的因素时。例如,比较两家医院的死亡率可能会表明医院 A 提供的护理比医院 B 差。然而,如果医院 A 是一个主要创伤中心,治疗更严重病患,而医院 B 主要处理常规病例,那么这种比较是误导性的。这种差异反映了谁被接纳,而不是护理质量的差异。

测量偏差**(或称误分类)发生在变量测量出现误差时,尤其是在这些误差在不同群体之间存在差异时。考虑一项关于吸烟和肺癌的研究:如果一些参与者由于耻辱感而低估吸烟,吸烟者可能会被错误地归类为非吸烟者。如果这种低估在各个群体中均匀发生,那么偏差是非差异性的,往往会减弱观察到的关联。如果它在各个群体之间存在差异,例如,肺癌患者更有可能承认吸烟,那么偏差是差异性的,可能会夸大或甚至逆转关联。

对探索这些偏差来源(包括它们如何在因果图中表示)感兴趣并希望深入了解的读者,可以在推荐阅读部分找到推荐的参考文献。这些作品提供了识别和避免此类偏差的清晰解释和实用指导。

19.6 练习

对于下一组练习,我们检查了 2014 年 PNAS 论文⁴中的数据,该论文分析了荷兰资助机构的成功率,并得出以下结论:

我们的结果揭示了性别偏差,这种偏差在评估“研究人员的质量”(但不是“提案的质量”)以及成功率方面,以及在教学和评估材料中使用的语言上,都倾向于男性申请人而非女性申请人。

几个月后,一篇名为《在荷兰,性别对个人研究资金成功没有贡献:对 Van der Lee 和 Ellemers 的回应》的回应⁵被发表,其结论是:

然而,尽管样本量很大,整体性别效应几乎达到统计显著性。此外,他们的结论可能是辛普森悖论的典型例子;如果女性在更具竞争力的科学学科(即男女申请成功率都很低)中申请资助的比例更高,那么跨所有学科的分析可能会错误地显示出性别不平等的证据。

谁是正确的,原始论文还是回应?下面,你将检查数据并得出自己的结论。

  1. 原始论文结论的主要证据依赖于百分比的比较。论文中的表 S1 包含了我们需要的信息:
library(dslabs)
research_funding_rates

构建用于关于性别奖项差异结论的二维表。

  1. 计算二维表中的百分比差异。

  2. 在之前的练习中,我们注意到女性的成功率较低。但这是否具有统计学意义?使用卡方检验计算 p 值。

  3. 我们看到 p 值大约是 0.05。因此,似乎有一些关联的证据。但在这里我们可以推断因果关系吗?性别偏见是否导致了这种观察到的差异?对原始论文的回应声称,我们在这里看到的是与加州大学伯克利分校招生例子相似的情况。具体来说,他们表示这“可能是辛普森悖论的典型例子;如果女性在更具竞争力的科学学科中申请资助的比例更高,那么跨所有学科的分析可能会错误地显示出性别不平等的证据。”为了解决这一争议,创建一个包含每个性别申请数量、奖项和成功率的数据库。按整体成功率重新排序学科。提示:首先使用reorder函数重新排序学科,然后使用pivot_longerseparatepivot_wider创建所需的表格。

  4. 为了检查这是否是辛普森悖论的情况,绘制成功率和学科的关系图,这些学科已经按整体成功率排序,颜色表示性别,大小表示申请数量。

  5. 我们在加州大学伯克利分校的例子中并没有看到相同程度的混杂因素。很难说这里有一个明显的混杂因素。然而,我们确实看到,根据观察到的比率,某些领域可能更倾向于男性,而其他领域可能更倾向于女性。我们还看到,两个在男性偏好方面差异最大的领域也是申请最多的领域。但与加州大学伯克利分校的例子不同,女性不太可能申请难度较大的科目。是否有可能一些选拔委员会存在偏见,而另一些则没有?

为了回答这个问题,我们首先检查上述观察到的任何差异是否具有统计学意义。记住,即使没有偏差,我们也会因为审查过程中的随机变异性以及候选人之间的随机变异性而看到差异。对每个学科进行卡方检验。提示:定义一个函数,该函数接收一个 2x2 表的总量,并返回一个包含 p 值的 data frame。使用 0.5 校正。然后使用summarize函数。

  1. 在医学科学中,似乎存在统计学上的显著差异,但这可能是虚假相关性吗?我们进行了 9 次测试。仅报告 p 值小于 0.05 的一个案例可能被认为是 cherry picking 的例子。重复上述练习,但用对数优势比除以其标准误差代替 p 值。然后使用 qq-plot 来查看这些对数优势比偏离我们预期标准正态分布的程度:一个标准正态分布。

  1. 虚假相关性↩︎

  2. 家长参与被高估了↩︎

  3. pubmed.ncbi.nlm.nih.gov/17835295/↩︎

  4. PNAS 112/40/12349 摘要↩︎

  5. PNAS 112/51/E7036 提取↩︎

20 多元回归

原文:rafalab.dfci.harvard.edu/dsbook-part-2/linear-models/multivariable-regression.html

  1. 线性模型

  2. 20 多元回归

自从高尔顿最初的发展以来,回归分析已经成为数据分析中最广泛使用的工具之一。一个原因是基于线性模型的原始回归方法的改编,使我们能够找到两个变量之间的关系,同时考虑到影响这两个变量的其他变量的影响。这在随机实验难以进行的领域尤其受欢迎,如经济学和流行病学。

当我们无法随机将每个人分配到治疗组或对照组时,混杂因素变得尤为普遍。例如,考虑使用从纽约市随机抽样的人群收集的数据来估计吃快餐对预期寿命的影响。快餐消费者更有可能吸烟、饮酒,并且收入较低。因此,一个简单的回归模型可能会导致对快餐负面健康影响的过度估计。那么,我们在实践中如何考虑混杂因素呢?在本章中,我们学习如何使用 多元回归 来帮助处理这种情况,并可以用来描述一个或多个变量如何影响结果变量。我们通过一个现实世界的例子来说明,其中数据被用来帮助挑选被低估的球员,以改善资源有限的运动队。

20.1 案例研究:点球成金

《点球成金:不公平游戏的胜利艺术》* 由迈克尔·刘易斯所著,聚焦于奥克兰运动家队(A's)及其总经理比利·比恩,他负责组建球队。

传统上,棒球队伍会使用 球探 来帮助他们决定聘请哪些球员。这些球探通过观察球员的表现来评估他们,往往更倾向于那些具有可观察的身体能力的运动员。因此,球探们通常对谁是最佳球员达成一致意见,结果这些球员往往需求量大。这反过来又推高了他们的薪水。

从 1989 年到 1991 年,奥克兰运动队(A's)拥有棒球界最高的薪资之一。他们能够雇佣最好的球员,在那段时间里,他们是最好的球队之一。然而,在 1995 年,A 队的所有者发生了变化,新管理层大幅削减了预算,当时的主管桑迪·阿尔德森(Sandy Alderson)成为了棒球界薪资最低的球队之一:例如,在 2002 年,纽约洋基队的薪资 125,928,583 美元是奥克兰运动队薪资 39,679,746 美元的三倍多。A 队再也负担不起最抢手的球员。因此,阿尔德森开始使用统计方法来寻找市场中的低效之处。阿尔德森是比利·比恩的导师,比恩在 1998 年接替了他的位置,并完全接受了数据科学,而不是仅仅依赖球探,作为寻找低成本球员的方法,这些球员的数据预测将有助于球队获胜。如今,这种策略已被大多数棒球球队采用。正如我们将看到的,回归在这一方法中发挥着重要作用。在本节中,我们将展示如何使用数据来支持这一方法。

棒球数据

自棒球诞生以来,统计数据就已经被记录。我们将使用的数据库,包含在Lahman库中,可以追溯到 19 世纪。例如,我们很快将要描述的汇总统计量,打击率(AVG),几十年来一直被用来总结球员的成功。其他统计数据¹,如本垒打(HR)和跑垒得分(RBI),在新闻媒体体育版块的游戏总结中为每位球员报告,高数值的球员会得到奖励。尽管像这样的汇总统计数据在棒球中得到了广泛应用,但数据分析本身并没有。这些统计数据是随意选择的,没有充分考虑它们是否真的预测了任何事情,或者是否与帮助球队获胜有关。

这种情况随着比尔·詹姆斯²的出现而改变。在 20 世纪 70 年代末,他开始发表文章,描述对棒球数据的更深入分析。他将使用数据来确定哪些结果最能预测一支球队是否会获胜的方法命名为 sabermetrics³。然而,直到比利·比恩将 sabermetrics 作为其棒球运营的核心,比尔·詹姆斯的工作在棒球界基本上被忽视。如今,sabermetrics 已不再局限于棒球;其原则已扩展到其他运动,现在被称为 体育分析

在本章中,我们将进行数据分析,以评估比利·比恩的关键策略之一,即雇佣“上垒”的球员,在统计上是否有效。我们将分解这一概念的含义,并通过多元回归的视角,证明这不仅从统计上是有效的,而且是一种经济上有效的方法。为了理解这一分析,我们需要了解一些棒球运作的基本知识。

棒球基础

要理解回归如何帮助我们找到被低估的球员,我们不需要深入了解棒球这项拥有超过 100 条规则的运动的全部细节。在这里,我们提炼出有效应对数据科学挑战所需的基本知识。

棒球的目标很简单:比对方得分更多(得分)。球员通过从称为本垒板的位置开始,依次通过三个(一垒、二垒和三垒),然后返回本垒板来得分。比赛开始于对方队伍的投手将球投向站在本垒板上的打击手,打击手试图击球。如果打击手一击中的球足够远,可以在一次击球中跑遍所有三个垒并回到本垒板,这被称为全垒打。如果打击手没有击出全垒打,他们将停在某个垒上。从那里,他们等待队友击球,以便他们可以移动到下一个垒。如果投手投球不佳,打击手将作为惩罚走到一垒,这被称为保送(BB)。在垒上的球员可以尝试在不等待队友击球的情况下跑向下一个垒。这被称为偷垒(SB)。

打击手也可能未能到达垒,导致出局。另一种出局的方式是偷垒失败。每个队伍继续打击,直到累积三个出局。一旦发生这种情况,另一队将轮到打击。每个队伍有九次打击机会,称为局,以得分。

每次打击手尝试到达垒,目标是最终得分,这被称为击球上垒(PA)。在罕见的情况下,有五种成功的方式:

  1. 单打 – 打击手到达一垒。

  2. 双打 – 打击手到达二垒。

  3. 三垒打 – 打击手到达三垒。

  4. 全垒打(HR) – 打击手绕过所有垒并回到本垒板。

  5. 保送(BB) – 投手投球不佳,打击手被允许走到一垒以惩罚投手。

前四种结果(单打、双打、三打和全垒打)都被认为是击球,而保送不是。这种区别对于理解后续的数据和分析非常重要。

没有基于球的奖项

从历史上看,打击率(AVG)一直被认为是进攻统计中最重要的一项。为了定义这个平均数,我们将总击数(H)除以总打击数(AB),定义为获得击球或出局的总次数;保送除外。今天,这个成功率在 20%到 38%之间。

图片

比尔·詹姆斯的第一个重要洞察之一是,击球平均数忽略了保送(BB),但保送也是一种成功。詹姆斯没有使用击球平均数,而是提出了使用上垒率(OBP),他将其定义为(H+BB)/PA,或者简单地说是没有导致出局的上垒次数的比例,这是一个非常直观的度量。他指出,如果一个球员的保送次数远多于平均水平,那么如果击球手在击球平均数上没有表现出色,他可能会被忽视。

但是,这位球员不是在帮助产生得分吗?虽然最高的平均数获得了击球冠军奖项,但获得最多保送数的球员没有获得任何奖项。然而,坏习惯很难改掉,棒球并没有立即将 OBP 视为一个重要的统计数据。我们可以使用数据来尝试证明保送确实有助于产生得分,并且应该被重视。

使用棒球数据

为了说明数据如何用于回答棒球中的问题,我们从一个简单的例子开始。我们将比较保送和盗垒(SB)。与保送不同,总盗垒数被认为很重要,并且授予了获得最多盗垒数的球员奖项⁶。但是,拥有高盗垒总数的球员也做出了更多的出局,因为他们并不总是成功。拥有高盗垒总数的球员是否有助于产生得分?我们能否使用数据来确定支付高保送或高盗垒球员是否更好?

在这项分析中,一个挑战是,确定一个球员是否产生得分并不明显,因为这很大程度上取决于他的队友。虽然我们记录了球员得分的次数,但请记住,如果球员 X 在击出很多全垒打的球员之前击球,球员 X 将获得很多得分。注意,这些得分并不一定会在我们雇佣球员 X 而不是他的全垒打击球队友时发生。

然而,我们可以考察团队层面的统计数据。拥有许多盗垒的球队与拥有很少盗垒的球队相比如何?保送呢?让我们看看一些数据!我们首先创建一个包含从 1962 年(所有球队首次像今天一样打 162 场比赛的第一年)到 2001 年(Money Ball 中球队组建的前一年)的统计数据的数据框。我们将数据转换为每场比赛的比率,因为由于罢工,一小部分赛季的比赛少于平常,而一些球队由于平局决定者而进行了额外的比赛。我们还定义了一个单打列,以供以后使用。

library(tidyverse
library(Lahman)
dat <- Teams |> filter(yearID %in% 1962:2001) |>
 mutate(singles = H - X2B - X3B - HR) |>
 select(teamID, yearID, G, R, SB, singles, BB, HR) |>
 mutate(across(-c](https://rdrr.io/r/base/c.html)(teamID, yearID), ~ ./G)) |> [select(-G)

现在让我们从一个问题开始:击出更多全垒打的球队是否得分更多?

dat |> ggplot(aes(HR, R)) + geom_point(alpha = 0.5)

* *这个图显示了强烈的关联:拥有更多全垒打的球队往往得分更多。现在让我们考察盗垒和得分之间的关系:

dat |> ggplot(aes(SB, R)) + geom_point(alpha = 0.5)

* *这里的关系并不那么明显。

最后,让我们考察保送和得分之间的关系:

dat |> ggplot(aes(BB, R)) + geom_point(alpha = 0.5)

* *在这里,我们再次看到了明显的关联。但这是否意味着增加一支球队的保送数会导致得分增加?正如我们在第十九章中学到的,关联并不等同于因果关系。实际上,保送和全垒打之间似乎也存在关联:

dat |> ggplot(aes(HR, BB)) + geom_point(alpha = 0.5)

* 我们知道全垒打会导致得分,因为当一名球员击出全垒打时,他们至少能保证得到一分。这可能意味着全垒打也会导致保送,这使得保送看起来像是导致得分的原因?保送和全垒打是共同原因*吗?线性回归可以帮助我们解析信息并量化关联。这反过来又可以帮助我们确定要招募哪些球员。具体来说,我们将尝试预测如果我们增加保送数但保持全垒打数不变,球队将多得分?

应用于棒球统计的回归分析

我们可以使用这些数据进行回归分析吗?首先,注意上面显示的全垒打和得分数据似乎呈双变量正态分布。具体来说,qq 图确认正态分布是每个全垒打层得分分布的有用近似:

dat |> mutate(hr_strata = round(scale(HR))) |>
 filter(hr_strata %in% -2:3) |>
 ggplot() + 
 stat_qq(aes(sample = R)) +
 facet_wrap(~hr_strata) 

* *因此,我们准备使用线性回归来预测一支球队将得多少分,如果我们知道球队使用回归击出的全垒打数:

hr_fit  <- lm(R ~ HR, data = dat)
summary(hr_fit)$coef[2,]
#>   Estimate Std. Error    t value   Pr(>|t|) 
#>   1.86e+00   4.97e-02   3.74e+01  8.90e-193

估计斜率为 1.86。这意味着,平均而言,每场比赛多击出一支全垒打的球队,比同样比赛数但全垒打数少的球队每场比赛多得 1.86 分。考虑到许多比赛仅由一分决定,这种差异可以转化为大幅增加胜利。不出所料,全垒打打者非常昂贵。因为亚特兰大勇士队正在预算限制下工作,他们需要找到其他方法来增加胜利。在下一节中,我们将更仔细地考察这一点。

20.2 混淆

之前,我们注意到得分和保送之间存在强烈的关联。如果我们找到从保送预测得分的回归线,我们得到斜率为:

bb_slope <- lm(R ~ BB, data = dat)$coef[2]
bb_slope 
#>    BB 
#> 0.743

这意味着如果我们去雇佣低薪但保送数多的球员,并且每场比赛增加两次保送,我们的球队每场比赛将多得 1.5 分吗?关联并不等同于因果关系:尽管数据显示,一支每场比赛比平均球队多两个保送数的球队,每场比赛多得 1.5 分,但这并不意味着保送是原因。

注意,如果我们计算单打的回归线斜率,我们得到:

lm(R ~ singles, data = dat)$coef[2]
#> singles 
#>   0.452

这个值比我们得到的保送数要低。记住,单打和保送一样,都能让你上到一垒。棒球迷会指出,与保送相比,有跑者的单打有更好的得分机会。那么,为什么保送能更好地预测得分呢?原因在于混淆。

这里我们展示了全垒打、保送和单打之间的相关性:

dat |> select(singles, BB, HR) |> cor()
#>         singles      BB     HR
#> singles  1.0000 -0.0495 -0.171
#> BB      -0.0495  1.0000  0.406
#> HR      -0.1714  0.4064  1.000

打击率(HR)和保送(BB)高度相关!专家们会指出,投手会被擅长打击 HR 的球员吓倒,这导致投手的表现不佳,从而获得保送。因此,打击 HR 的球员往往会有更多的保送,拥有许多 HR 的球队也会有更多的保送。尽管看起来保送会导致得分,但实际上是 HR 导致了这些得分的大部分。保送和 HR 是混淆的。尽管如此,保送是否仍然有帮助?为了找出答案,我们必须要调整 HR 的影响。多元回归可以帮助我们做到这一点。

一种方法是保持 HR 在某个固定值,然后检查 BB 和得分之间的关系。就像我们按最接近的英寸对父亲进行分层时做的那样,这里我们可以按每场比赛最接近的十分之一对 HR 进行分层。我们过滤掉点数很少的分层,以避免高度可变的估计,然后为每个分层制作散点图:

dat |> mutate(hr_strata = round(HR, 1)) |> 
 filter(hr_strata >= 0.4 & hr_strata <= 1.2) |>
 ggplot(aes(BB, R)) + 
 geom_point(alpha = 0.5) +
 geom_smooth(formula = "y~x", method = "lm") +
 facet_wrap(~hr_strata) 

* *一旦我们根据 HR 进行分层,这些斜率就会显著降低:

dat |> mutate(hr_strata = round(HR, 1)) |> 
 filter(hr_strata >= 0.5 & hr_strata <= 1.2) |> 
 group_by(hr_strata) |>
 summarize(coef = lm(R ~ BB)$coef[2])
#> # A tibble: 8 × 2
#>   hr_strata  coef
#>       <dbl> <dbl>
#> 1       0.5 0.566
#> 2       0.6 0.405
#> 3       0.7 0.284
#> 4       0.8 0.370
#> 5       0.9 0.266
#> # ℹ 3 more rows

记住,用保送预测得分的回归斜率是 0.7。

斜率降低了,但不是 0,这表明 BB 对于产生得分是有帮助的,只是没有单变量分析所暗示的那么多。事实上,上面的值更接近我们从单打中获得的斜率,0.5,这与我们的直觉更一致。由于单打和保送都能让我们到达一垒,它们应该具有大约相同的预测能力。

虽然我们的应用理解告诉我们 HR 会导致保送,而不是反过来,我们仍然可以检查按 BB 分层是否会使 BB 的效果降低。为此,我们使用相同的代码,只是交换了 HR 和 BB。在这种情况下,斜率与原始值变化不大:

dat |> mutate(bb_strata = round(BB, 1)) |> 
 filter(bb_strata >= 2.5 & bb_strata <= 3.2) |> 
 group_by(bb_strata) |>
 summarize(coef = lm(R ~ HR)$coef[2])
#> # A tibble: 8 × 2
#>   bb_strata  coef
#>       <dbl> <dbl>
#> 1       2.5  1.98
#> 2       2.6  1.07
#> 3       2.7  1.61
#> 4       2.8  1.50
#> 5       2.9  1.57
#> # ℹ 3 more rows

它们从 1.86 略微降低,这与保送确实会导致一些得分的事实一致。

不论如何,如果我们按 HR 分层,我们就有得分与 BB 的双变量分布。同样,如果我们按 BB 分层,我们就有 HR 与得分的大致双变量正态分布。

20.3 多元回归

计算每个分层的回归线有些复杂。我们实际上是在拟合如下模型:

\[\mathrm{E}[R \mid BB = x_1, \, HR = x_2] = \beta_0 + \beta_1(x_2) x_1 + \beta_2(x_1) x_2 \]

对于不同的 \(x_2\) 值,\(x_1\) 的斜率会发生变化,反之亦然。但是,有没有更简单的方法呢?

如果我们考虑随机变异性,分层中的斜率似乎没有太大变化。如果这些斜率实际上相同,这表明 \(\beta_1(x_2)\)\(\beta_2(x_1)\) 是常数。这反过来又表明,在 HR 和 BB 条件下的得分期望可以写成以下形式:

\[\mathrm{E}[R \mid BB = x_1, \, HR = x_2] = \beta_0 + \beta_1 x_1 + \beta_2 x_2 \]

该模型表明,如果 HR 的数量固定在\(x_2\),我们观察到得分和 BB 之间存在线性关系,截距为\(\beta_0 + \beta_2 x_2\)。我们的探索性数据分析表明情况确实如此。该模型还表明,随着 HR 数量的增加,截距的增长也是线性的,并由\(\beta_1\)决定。在这项分析中,被称为多元回归,你经常会听到人们说 BB 斜率\(\beta_1\)是针对 HR 效应进行调整的。

由于数据近似正态分布,并且条件分布也是正态的,因此我们使用线性模型是合理的:

\[Y_i = \beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \varepsilon_i \]

\(Y_i\)表示球队\(i\)每场比赛的得分,\(x_{i,1}\)表示每场比赛的 BB,\(x_{i,2}\)表示每场比赛的 HR,以及\(\varepsilon_i\)假设是独立同分布的。这符合我们的线性模型框架,这意味着我们可以使用lm来拟合模型。

要在这里使用lm,我们需要让函数知道我们有两个预测变量。我们使用以下+符号:

fit <- lm(R ~ BB + HR, data = dat)
summary(fit)$coef[2:3,]
#>    Estimate Std. Error t value  Pr(>|t|)
#> BB    0.391     0.0273    14.3  1.55e-42
#> HR    1.570     0.0495    31.7 2.39e-153

当我们只使用一个变量拟合模型时,估计的斜率分别为 BB 的 0.74 和 HR 的 1.86。请注意,在拟合多元模型时,这两个斜率都会下降,其中 BB 效应下降得更多。

如果你想在继续之前练习,请完成练习 1-12。

20.4 案例研究继续:点球大战

在上一节中,我们使用了多元回归来确认步行(BB)是得分的重要预测因子。在本节中,我们将采用数据驱动的方法来开发一个用于评估棒球运动员进攻产出的指标。具体来说,我们将构建一个回归模型,根据运动员的进攻统计数据预测其贡献的得分数量。通过将薪资信息纳入这一分析,我们可以识别出 2002 年那些预计能产生得分但薪酬不足的球员。重要的是,这一分析使用的数据排除了 2002 赛季的数据,以模拟为即将到来的赛季组建球队时的挑战。

在个人球员层面,区分得分产出对于准确评估进攻贡献至关重要。虽然得分是直接记录的,但它们并不能完全捕捉到球员的影响程度。例如,如果球员 X 击出一垒打并在队友的轰出本垒打后得分,这个得分会被归功于球员 X,但队友的努力对于这一得分的发生是至关重要的。这种重叠突出了进攻产出的共享性质,使得个人表现分析变得复杂。

建模球队得分产出

为了解决这个问题,我们首先将在团队层面拟合模型,这样个人层面的细微差别就会平均化,不会影响整体结果。一旦模型在团队层面得到验证,我们就会将其应用于估计球员层面的贡献。

球队被分为两个联盟,美国联盟和国家联盟。由于在所讨论的时期内它们有略微不同的规则,我们将模型仅拟合到美国联盟(AL),在那里奥克兰运动家队比赛。* 由于球队累积的击球次数远多于个人球员,我们将以每击球次数(PA)的比率来建模。这使我们能够将模型拟合到球队并应用于球员。此外,由于三垒打相对较少,我们将它们与二垒打合并为一个单独的分类,额外垒(XB)*,以简化模型。由于先前分析表明它们与得分增加的相关性不可靠,我们将排除盗垒。因此,模型中的预测因子包括:全垒打(BB)、单打、额外垒(XB)和本垒打(HR)。以下是该模型的数据准备步骤:

dat <- Teams |> 
 filter(yearID %in% 1962:2002 & lgID == "AL") |>
 mutate(XB = X2B + X3B, singles = H - XB - HR, PA = AB + BB) |>
 select(yearID, teamID, R, BB, singles, XB, HR, PA) |>
 mutate(across(-c(yearID, teamID, PA), ~ ./PA)) 

为了构建模型,我们做出一个合理的假设,即我们的结果变量(每击球次数的得分)和四个预测变量(BB、单打、额外垒和本垒打)是联合正态分布的。这意味着对于任何单个预测变量,当其他预测变量保持不变时,它与结果之间的关系是线性的。在这个假设下,线性回归模型可以表示为:

\[Y_i = \beta_0 + \beta_1 x_{i1} + \beta_2 x_{i2} + \beta_3 x_{i3}+ \beta_4 x_{i4} + \varepsilon_i \]

其中 \(Y_i\) 代表每击球次数产生的得分,\(x_{i1}, x_{i2}, x_{i3}, x_{i4}\) 分别代表每击球次数的全垒打、单打、额外垒和本垒打。

我们可以在 2002 赛季之前拟合模型,因为我们是在赛季开始之前用我们拥有的信息为 2002 年组建球队的:

fit <- dat |> filter(yearID < 2002) |> lm(R ~ BB + singles + XB + HR, data = _)

我们注意到拟合的模型对全垒打(BB)和单打赋予了相似的权重:

fit$coefficients[-1]
#>      BB singles      XB      HR 
#>   0.454   0.586   0.914   1.452

这证实了我们的怀疑,即步行得分在为球队产生得分方面几乎与单打一样有价值。

模型诊断

由于此模型并非源自任何基本物理定律,因此检查它是否充分拟合数据并且可用于预测是至关重要的。我们将完整的诊断评估留作练习,但在此我们展示一个关键评估:测试在 2002 年之前拟合到数据中的模型是否可以成功预测 2002 年的球队表现。

为了做到这一点,我们使用拟合的模型来估计每个球队在 2002 赛季的预期得分次数:

R_hat <- dat |> filter(yearID == 2002) |> predict(fit, newdata = _)

结果显示,该模型在 1962-2001 年的数据上训练得非常好,可以预测 2002 年球队的得分:

基于模型的球员指标

现在让我们将模型应用于球员。Batting数据框包括球员特定的统计数据。我们准备数据,以便保存模型拟合时的相同每 PA(每击球机会)率。由于球员的能力会随时间变化,我们只使用接近 2002 年的数据,但为了提高估计的精确度,我们使用 2002 年之前的三年数据,而不是仅仅使用 2001 年的数据。我们还排除了击球机会少于 100 次的球员,以避免不精确的总结:

players <- Batting |> 
 filter(yearID %in% 1999:2001) |> 
 group_by(playerID) |>
 summarize(XB = sum(X2B + X3B), PA = sum(AB + BB), HR = sum(HR), H = sum(H), 
 singles = H - XB - HR, BB = sum(BB), AVG = sum(H)/sum(AB)) |>
 filter(PA >= 100) |>
 mutate(across(-c(playerID, PA, AVG), ~ ./PA)) 

现在统计数据是每 PA(每击球机会)率,我们可以使用拟合到团队级数据的模型来预测每位球员将产生多少每 PA(每击球机会)跑动:

players$R_hat <- predict(fit, players)

实践中的《点球成金》

在之前显示预测和观察到的跑动的散点图中,我们看到尽管奥克兰的工资支出在联盟中属于最低之一,但他们的表现却是一个高于平均水平的进攻球队。在这里,我们描述了他们如何实现这一点的细节。

在构建了一个模型来估算每位球员预期跑动贡献之后,我们现在可以将其应用于现实世界的决策中,就像 2002 赛季的奥克兰运动队所做的那样。为了将我们的预测置于适当的背景中,我们需要将性能指标与关于球员的额外信息相结合,包括他们的薪水和使用状态。这使我们能够不仅确定谁是最有生产力的,而且谁能为球队有限的预算提供最大的价值

为了做到这一点,我们将我们的球员级性能估计与来自Salary(薪水)、People(人员)和Appearances(出场)表的数据合并。这些数据集包括合同信息、传记细节和位置数据,这些数据共同为在现实团队组建场景中评估和比较球员提供了基础。

我们首先添加了球员在 2002 年获得的薪水,并排除了在那个赛季没有出场的球员:

players <-  Salaries |> 
 filter(yearID == 2002) |> 
 select(playerID, salary) |>
 right_join(players, by = "playerID") |>
 filter(!is.na(salary))

然后,我们添加他们的名字和姓氏以提供背景信息。此外,由于美国职业棒球大联盟的球员在积累了六年的比赛时间并成为自由球员之前不能谈判合同或选择他们要为哪个球队效力,因此我们包括他们的首次亮相年份,因为在我们的分析中需要考虑这一点。

players <- People |> 
 select(playerID, nameFirst, nameLast, debut) |>
 mutate(debut = year](https://lubridate.tidyverse.org/reference/year.html)([as.Date(debut))) |>
 right_join(players, by = "playerID")

最后,我们移除了投手,因为我们只对击球手感兴趣。

players <- Appearances |> filter(yearID == 2002) |> 
 group_by(playerID) |>
 summarize(G_p = sum(G_p)) |>
 right_join(players, by = "playerID") |>
 filter(G_p == 0) |> select(-G_p)

如果你关注了那个时代的棒球,你会认出顶尖的跑动产生者,并且不会对那些获得高薪感到惊讶:

players |> select(nameFirst, nameLast, R_hat, salary) |> 
 arrange(desc(R_hat)) |> head()
#> # A tibble: 6 × 4
#>   nameFirst nameLast R_hat   salary
#>   <chr>     <chr>    <dbl>    <int>
#> 1 Barry     Bonds    0.239 15000000
#> 2 Todd      Helton   0.218  5000000
#> 3 Manny     Ramirez  0.216 15462727
#> 4 Jason     Giambi   0.215 10428571
#> 5 Larry     Walker   0.213 12666667
#> # ℹ 1 more row

注意,由于每个球队平均每场比赛有 37.5 次击球机会,每次击球机会产生 0.24 个跑动,这意味着每场比赛产生 9 个跑动,几乎是联盟平均水平的两倍!

如果我们将预测的跑动次数与薪水进行对比,我们可以看到在预测产生的跑动次数上,球员之间存在很大的差异,并且不出所料,产生跑动的球员获得了更高的薪水。

该图还突出了四位球员。在 2002 赛季之前,奥克兰队失去了他们最好的得分者贾森·贾姆比,因为他们无法与纽约洋基队提供的超过 1000 万美元的报价竞争。另一位表现良好的得分者约翰尼·达蒙(虚线表示平均水平)也离开了奥克兰队,因为奥克兰队不愿意支付波士顿红袜队提供的 725 万美元的薪水。奥克兰队必须弥补这种得分损失,但预算非常有限。为此,他们的第一个补充是斯科特·哈特伯格,他的薪水仅为 90 万美元,是联盟中最低的之一,但预计将产生超过平均水平的得分。请注意,在我们的数据框中,哈特伯格虽然平均击球率(AVG)低于联盟平均水平,但在每打一次球时的保送(BB)排名接近前 10%。同样,大卫·贾斯蒂斯的平均击球率接近联盟平均水平,但在每打一次球时的保送排名接近前 5%。这些节省下来的资金使阿斯队能够将他们的球员弗兰克·梅内奇诺升级为雷·达灵厄姆。

filter(players, playerID %in% c("damonjo01", "giambja01", "menecfr01")) |> 
 bind_rows(filter(players, playerID %in% c("justida01", "durhara01", "hattesc01"))) |> 
 select(nameFirst, nameLast, AVG, BB,  R_hat) 
#> # A tibble: 6 × 5
#>   nameFirst nameLast    AVG     BB R_hat
#>   <chr>     <chr>     <dbl>  <dbl> <dbl>
#> 1 Johnny    Damon     0.296 0.0930 0.141
#> 2 Jason     Giambi    0.330 0.188  0.215
#> 3 Frank     Menechino 0.245 0.137  0.126
#> 4 Ray       Durham    0.281 0.103  0.141
#> 5 Scott     Hatteberg 0.257 0.131  0.128
#> # ℹ 1 more row

尽管失去了两名明星球员,奥克兰队的每场比赛预测得分(R_hat)仅略有下降,从 0.482 降至 0.431,节省下来的资金使他们能够收购投手比利·科奇,这有助于降低对手的得分。

总结

通过金钱球案例,我们看到了多变量回归如何使我们能够解开相关变量之间的关系,并在保持其他变量不变的情况下估计一个因素的影响。这种方法使我们能够识别出那些即使传统统计数据未能捕捉到其价值的、对球队有实质性贡献的低估值球员。更广泛地说,多变量回归是调整混杂因素并揭示在复杂、现实世界数据中否则可能隐藏的模式的常用工具。

然而,正如我们之前讨论的,尽管回归模型非常强大,但它们只能描述关联,并不能证明因果关系。两个变量之间的统计显著关系并不一定意味着一个变量导致另一个变量。金钱球的故事很好地说明了这一点:虽然数据分析揭示了新的见解,但这些结论之所以可信,仅仅是因为它们得到了棒球基本机制的支撑。

钱球理论的成功不仅仅在于认识到高上垒率的球员被低估了。他们的团队收集、清洗和分析了大量数据,不仅来自职业比赛,还包括大学和低级别联赛的球员,以发现被低估的球员。他们使用统计模型来预测未来的表现,数据整理将分散的记录组织成可用的格式,以及定量分析来指导球员招募和策略。数据分析甚至影响了教练决策,例如偏好哪种投球或如何调整击球策略。最初作为棒球实验的它,现在已经几乎蔓延到所有主要运动,改变了球队如何做决策和评估表现。

这个案例研究展示了多元回归的强大和多功能性。通过扩展简单的线性模型框架,我们能够调整混杂变量并估计在保持其他变量不变的情况下每个因素的贡献。之前介绍过的相同数学思想,期望、标准差、条件分布,是这个分析的基础,R 中的相同 lm 函数提供了拟合这些模型的计算工具。简而言之,钱球理论的例子展示了我们在这本书的这一部分开发出的线性建模工具如何应用于现实世界数据,以做出基于证据的明智决策。

20.5 练习

我们已经展示了全垒打和单打在得分方面具有相似的预测能力。另一种比较这些棒球指标有用性的方法是评估它们在多年间的稳定性。由于我们必须根据球员之前的成绩来挑选球员,我们更喜欢更稳定的指标。在这些练习中,我们将比较单打和全垒打的稳定性。

  1. 在我们开始之前,我们想要生成两张表格。一张是 2002 年的表格,另一张是 1999-2001 赛季平均值的表格。我们想要定义每局出场的统计数据。以下是创建 2002 年表格的方法,仅保留出场次数超过 100 次的球员:
library(Lahman)
dat <- Batting |> filter(yearID == 2002) |>
 mutate(pa = AB + BB, 
 singles = (H - X2B - X3B - HR)/pa, bb = BB/pa) |>
 filter(pa >= 100) |>
 select(playerID, singles, bb)

现在,计算一个类似的表格,称为 avg,但计算的是 1999-2001 年的比率。

  1. 您可以使用 inner_join 函数将 2002 年的数据和平均值合并到同一张表中:
dat <- inner_join(dat, avg, by = "playerID")

计算 2002 年和之前赛季的单打和全垒打之间的相关性。

  1. 注意,全垒打的相关性更高。为了快速了解与这个相关性估计相关的不确定性,我们将拟合一个线性模型并计算斜率系数的置信区间。然而,首先绘制散点图以确认拟合线性模型是合适的。

  2. 现在为每个指标拟合一个线性模型,并使用 confint 函数来比较估计值。

  3. 在前面的部分中,我们计算了母亲和女儿、母亲和儿子、父亲和女儿以及父亲和儿子之间的相关性。我们注意到,父亲和儿子之间的相关性最高,而母亲和儿子之间的相关性最低。我们可以使用以下方法来计算这些相关性:

library(HistData)
set.seed(1)
galton <- GaltonFamilies |>
 group_by(family, gender) |>
 sample_n(1) |>
 ungroup()
 cors <- galton |> 
 pivot_longer(father:mother, names_to = "parent", values_to = "parentHeight") |>
 mutate(child = ifelse(gender == "female", "daughter", "son")) |>
 unite](https://tidyr.tidyverse.org/reference/unite.html)(pair, [c("parent", "child")) |> 
 group_by(pair) |>
 summarize(cor = cor(parentHeight, childHeight))

这些差异在统计上是否显著?为了回答这个问题,我们将计算回归线的斜率和标准误差。首先使用lm计算斜率 LSE 和标准误差。

  1. 重复练习 5,但也要计算置信区间。

  2. 绘制置信区间,并注意它们有重叠,这意味着数据支持身高遗传与性别无关的假设。

  3. 由于我们是随机选择儿童,我们实际上可以进行一种类似于排列检验的操作。重复计算相关性 100 次,每次取不同的样本。提示:使用与我们在模拟中使用的类似代码。

  4. 在 1971 年使用线性回归模型来获取 BB 和 HR 对跑动次数(在团队层面)的影响。使用broom包中的tidy函数将结果输出到数据框中。

  5. 现在让我们对自 1962 年以来每年的数据进行重复上述操作,并绘制一个图表。使用summarizebroom包来为自 1962 年以来每年的数据拟合此模型。

  6. 使用前一个练习的结果来绘制 BB 对跑动次数的估计效应图。

  7. 编写一个函数,该函数接受 R、HR 和 BB 作为参数,并拟合两个线性模型:R ~ BBR~BB+HR。然后使用summary函数获取自 1962 年以来每年两个模型的BB。然后将这些数据作为时间函数绘制出来。

  8. 自 1980 年代以来,统计学家使用与击球率不同的汇总统计量来评估球员。他们意识到步行很重要,并且双打、三打和本垒打应该比单打更重要。因此,他们提出了以下指标:

\[\frac{\mbox{BB}}{\mbox{PA}} + \frac{\mbox{Singles} + 2 \mbox{Doubles} + 3 \mbox{Triples} + 4\mbox{HR}}{\mbox{AB}} \]

他们称之为上垒率加长打率(OPS)。尽管统计学家可能没有使用回归,但在这里我们展示了这个指标如何与回归结果紧密相关。

计算每个队在 2001 赛季的 OPS。然后绘制每场比赛的跑动次数与 OPS 之间的关系图。

  1. 对于自 1962 年以来每年的数据,计算每场比赛的跑动次数与 OPS 之间的相关性。然后将这些相关性作为年份的函数绘制出来。

  2. 请记住,我们可以将 OPS 重写为 BB、单打、双打、三打和本垒打的加权平均值。我们知道双打、三打和本垒打的权重是单打的 2 倍、3 倍和 4 倍。那么 BB 呢?BB 相对于单打的权重是多少?提示:BB 相对于单打的权重将是 AB 和 PA 的函数。

  3. 考虑到 BB(全垒打)的权重 \(\frac{\mbox{AB}}{\mbox{PA}}\) 会因队伍而异。为了评估其变异性,计算并绘制自 1962 年以来每个队伍每年的这个量值。然后再次绘制,但这次不是为每个队伍计算,而是计算并绘制整个年份的比率。一旦你确信没有太多时间或队伍趋势,报告整体平均值。

  4. 现在我们知道 OPS(总打击率)的公式与 \(0.91 \times \mbox{BB} + \mbox{singles} + 2 \times \mbox{doubles} + 3 \times \mbox{triples} + 4 \times \mbox{HR}\) 成正比。让我们看看这些系数与回归分析得到的系数相比如何。在 1962 年之后的数据上拟合一个回归模型,就像之前做的那样:使用每个队伍每年每场比赛的统计数据。拟合这个模型后,报告系数作为单打系数的权重。

  5. 我们看到我们的线性回归模型系数与 OPS 使用的趋势相同,但除了单打以外的指标权重略低。在 1962 年之后的每年,计算每个队伍的 OPS、回归模型预测的得分,并计算这两个量值之间的相关性,以及与每场比赛得分的相关性。

  6. 我们看到使用回归方法预测得分略优于 OPS,但并不太多。然而,请注意,我们一直在计算 OPS 和预测队伍得分,当这些指标被用来评估球员时。让我们展示 OPS 在球员层面与回归分析得到的值相当相似。从 1962 赛季开始,计算每个球员的 OPS 和模型预测的得分,并绘制它们。使用我们在上一章中使用的每场比赛 PA(击球数)校正:

  7. 哪些球员在预测得分和 OPS 之间的排名差异最大?

  8. 对第 20.4.1 节中描述的模型进行仔细的诊断,以评估模型。


  1. https://www.mlb.com/stats/↩︎

  2. https://en.wikipedia.org/wiki/Bill_James↩︎

  3. https://en.wikipedia.org/wiki/Sabermetrics↩︎

  4. https://www.youtube.com/watch?v=JSE5kfxkzfk↩︎

  5. https://www.youtube.com/watch?v=JSE5kfxkzfk↩︎

  6. http://www.baseball-almanac.com/awards/lou_brock_award.shtml↩︎

推荐阅读

原文:rafalab.dfci.harvard.edu/dsbook-part-2/linear-models/reading-lm.html

  1. 线性模型

  2. 推荐阅读

  • **Montgomery, D. C. (2017). 实验设计与分析 (第 9 版). Wiley.

    实验设计、随机化和治疗效果估计的标准参考书,从随机区组设计到析因设计。

  • **Freedman, D. A. (2009). 统计模型:理论与应用. 剑桥大学出版社.

    对回归模型的意义以及假设如何影响推断的概念性清晰且严谨的介绍。

  • **Kutner, M. H., Nachtsheim, C. J., & Neter, J. (2004). 应用线性回归模型 (第 4 版). 麦格劳-希尔.

    一本广泛使用的本科/研究生教材,强调线性模型的表达式、假设和实际应用。

  • **James, G., Witten, D., Hastie, T., & Tibshirani, R. (2021). 统计学习导论及其在 R 中的应用 (第 2 版). Springer.

    第 3-4 章以 R 代码示例,对简单和多重回归进行了温和的现代介绍。

  • **Agresti, A. (2013). 分类数据分析 (第 3 版).

    关于关联测试和分类数据的权威资源。

  • **Dobson, A. J. & Barnett, A. G. (2018). 广义线性模型导论 (第 4 版).

    对广义线性模型的一个清晰、易于理解的介绍。

  • **McCullagh, P., & Nelder, J. A. (1989). 广义线性模型 (第 2 版).

    关于广义线性模型的经典和权威文本;更偏向理论,但对于深入理解是必不可少的。

  • **Tyler Vigen. 虚假相关性.

    以轻松愉快但富有教育意义的方式审视看似无关的变量如何表现出强烈的相关性。有助于培养关于关联与因果关系的批判性思维。

    网站

  • **Rothman, K. J., Greenland, S., & Lash, T. L. (2021). 现代流行病学 (第 4 版).

    一本关于混杂因素、偏差和实验设计的详尽参考书,常用于流行病学和公共卫生领域。

高维数据

原文:rafalab.dfci.harvard.edu/dsbook-part-2/highdim/intro-highdim.html

在现代数据分析中,高维数据集越来越普遍,尤其是在基因组学、图像处理、自然语言处理和推荐系统等领域。存在各种计算技术和统计概念,这些对于分析每个观测值都关联有大量数值变量的数据集非常有用。在本部分书中,我们介绍了在分析这些高维数据集时有用的想法。具体来说,我们提供了对线性代数、降维、矩阵分解和正则化的简要介绍。作为激励示例,我们使用了手写数字识别和电影推荐系统,这两个系统都涉及具有数百或数千个变量的高维数据集。我们通过演示如何在 R 中处理矩阵来开始本书的这一部分。

我们使用的一个特定任务来激发线性代数的应用是测量两个手写数字之间的相似度。因为每个数字都由 \(28 \times 28 = 784\) 个像素值表示,所以我们不能像在一维设置中那样简单地减去两个向量。相反,我们将每个观测值视为一个 在一个 高维 空间中,并使用 距离 的数学定义来量化相似度。书中后面介绍的大多数机器学习技术都依赖于这种几何解释。

我们还使用这种高维距离概念来激发降维的动机,降维是一组技术,它以低维表示总结高维数据,这些表示更容易可视化和分析,同时保留关键 信息。观测值之间的距离提供了一个具体的例子:我们旨在减少变量的数量,同时尽可能多地保留观测值之间的成对距离。这自然地导致了矩阵分解方法,这些方法源于这些技术背后的数学结构。

最后,我们引入正则化的概念,这在分析高维数据时非常有用。在许多应用中,大量变量增加了过拟合或选择性地挑选出偶然看似显著的结果的风险。正则化提供了一种数学上原则性的方法来约束模型,提高泛化能力,并避免误导性的结论。

这些主题共同为理解并实现本书下一部分所涵盖的许多机器学习技术奠定了基础。

21 在 R 中操作矩阵

原文:rafalab.dfci.harvard.edu/dsbook-part-2/highdim/matrices-in-R.html

  1. 高维数据

  2. 21 在 R 中操作矩阵

当与每个观测值相关的变量数量很大且它们都可以表示为数字时,将数据存储在矩阵中并使用线性代数运算进行分析通常更方便,而不是将数据存储在数据框中并使用 tidyversedata.table 函数。矩阵运算构成了数据分析中线性代数应用的计算基础。

事实上,许多最广泛使用的机器学习算法,包括线性回归、主成分分析、神经网络和深度学习,都是围绕线性代数概念构建的,它们的实现高度依赖于高效的矩阵运算。在 R 中熟练地创建、操作和解释矩阵将使您更容易理解和实现这些方法。

尽管我们在下一章介绍了线性代数的数学框架,但我们在那里使用的例子依赖于能够在 R 中操作矩阵。因此,在深入数学概念之前,我们首先介绍在 R 中创建和操作矩阵所需的工具。

在 第 21.5 节 中,本章末尾,我们展示了激励性的示例和机器学习工作流程中常见的具体实际任务,例如变量中心化和缩放、计算距离以及应用线性变换。这些任务可以通过矩阵运算高效完成,而本章前面部分开发的技能将帮助您解决这些问题。如果您想了解前面部分介绍的矩阵运算的学习动机,您可能首先阅读 第 21.5 节 会很有帮助。

21.1 符号表示

矩阵是由其行数和列数定义的两个维度的对象。在数据分析中,通常将数据组织成每行代表一个观测值,每列代表在这些观测值上测量的变量。这种结构使我们能够通过矩阵运算在观测值或变量之间进行计算,这些运算既高效又与下一章中介绍的线性代数技术概念上相一致。

注意,矩阵是 array 的特例,可以具有超过两个维度。对于本书涵盖的概念和应用,矩阵是足够的。然而,在更高级的设置中,如基于张量的计算,数据通常具有三个或更多维度时,数组是有用的。

在机器学习中,变量通常被称为 特征,而在统计学中,它们通常被称为 协变量。无论术语如何,当与矩阵一起工作时,特征通常由矩阵的列表示,每一列对应于在所有观察中测量的一个变量。在数学符号中,矩阵通常用粗体大写字母表示:

\[\mathbf{X} = \begin{bmatrix} x_{11}&x_{12}&\dots & x_{1p}\\ x_{21}&x_{22}&\dots & x_{2p}\\ \vdots & \vdots & \ddots & \vdots\\ x_{n1}&x_{n2}&\dots&x_{np}\\ \end{bmatrix} \]

其中 \(x_{ij}\) 代表第 \(i\) 个观察的第 \(j\) 个变量。矩阵的维度被称为 \(n \times p\),意味着它有 \(n\) 行和 \(p\) 列。

我们用小写粗体字母表示向量,并将它们表示为一列矩阵,通常称为 列向量。R 在将向量转换为矩阵时遵循此约定。例如 as.matrix(1:n) 将具有维度 n 1

然而,列向量不应与矩阵的列混淆。它们之所以被称为这个名字,仅仅是因为它们有一列。

机器学习的数学描述经常引用表示 \(p\) 个变量的向量:

\[\mathbf{x} = \begin{bmatrix} x_1\\\ x_2\\\ \vdots\\\ x_p \end{bmatrix} \]

为了区分与观察 \(i=1,\dots,n\) 相关的变量,我们添加一个索引:

\[\mathbf{x}_i = \begin{bmatrix} x_{i1}\\ x_{i2}\\ \vdots\\ x_{ip} \end{bmatrix} \]

粗体小写字母也常用来表示矩阵的列而不是行。这可能会造成混淆,因为 \(\mathbf{x}_1\) 可以代表 \(\mathbf{X}\) 的第一行或第一列。一种区分的方法是使用类似于计算机代码的符号。具体来说,使用冒号 \(:\) 来表示 所有。所以 \(\mathbf{X}_{1,:}\) 表示第一行,而 \(\mathbf{X}_{:,1}\) 是第一列。另一种方法是使用索引字母来区分,其中 \(i\) 用于行,\(j\) 用于列。所以 \(\mathbf{x}_i\) 是第 \(i\) 行,而 \(\mathbf{x}_j\) 是第 \(j\) 列。使用这种方法时,重要的是要明确表示的是哪个维度,行还是列。进一步的混淆可能源于,如前所述,通常将所有向量,包括矩阵的行,都表示为一列矩阵。

21.2 案例研究:MNIST

处理邮局收到的邮件的第一步是按邮编对信件进行分类:

在本书的机器学习部分,我们将描述如何构建计算机算法来读取手写数字,然后机器人使用这些数字来分类信件。为此,我们首先需要收集数据,在这种情况下是一个高维数据集,最好以矩阵的形式存储。

MNIST 数据集是通过数字化数千个手写数字生成的,这些数字已经被人类读取并标注¹。以下是三个手写数字的图像。

图像被转换为 \(28 \times 28 = 784\) 像素,对于每个像素,我们获得一个介于 0(白色)和 255(黑色)之间的灰度强度。以下图显示了每个图像的单独变量:

每个数字化的图像,通过索引 \(i\) 来标识,由 784 个数值变量表示,对应于像素强度,以及一个分类结果或标签,指定图像中描绘的数字 (\(0\)-\(9\))。

使用 dslabs 包加载数据:

library(tidyverse
library(dslabs)
mnist <- read_mnist()

像素强度被保存在一个矩阵中:

class(mnist$train$images)
#> [1] "matrix" "array"

每个图像的标签包含在一个向量中:

table(mnist$train$labels)
#> 
#>    0    1    2    3    4    5    6    7    8    9 
#> 5923 6742 5958 6131 5842 5421 5918 6265 5851 5949

为了简化下面的代码,我们将分别将这些 xy 重命名:

x <- mnist$train$images
y <- mnist$train$labels

21.3 R 中的矩阵运算

在我们定义并完成旨在教授矩阵运算的任务之前,我们首先回顾 R 中一些基本的矩阵功能。

创建矩阵

标量、向量和矩阵是线性代数的基本构建块。我们在前面的章节中已经遇到了向量,在 R 中,标量被表示为长度为 1 的向量。我们现在将这种理解扩展到矩阵,从如何在 R 中创建它们开始。

我们可以使用 matrix 函数创建矩阵。第一个参数是一个包含将填充矩阵的元素的向量的参数。第二个和第三个参数分别确定行数和列数。因此,创建矩阵的典型方法是将包含矩阵元素的数字向量获取并传递给 matrix 函数。例如,要创建一个 \(100 \times 2\) 的正态分布随机变量矩阵,我们写:

mat <- matrix(rnorm(100*2), 100, 2)

请注意,默认情况下矩阵是按列填充的:

matrix(1:15, 3, 5)
#>      [,1] [,2] [,3] [,4] [,5]
#> [1,]    1    4    7   10   13
#> [2,]    2    5    8   11   14
#> [3,]    3    6    9   12   15

要按行填充矩阵,我们可以使用 byrow 参数:

matrix(1:15, 3, 5, byrow = TRUE)
#>      [,1] [,2] [,3] [,4] [,5]
#> [1,]    1    2    3    4    5
#> [2,]    6    7    8    9   10
#> [3,]   11   12   13   14   15

函数 as.vector 将矩阵转换回向量:

as.vector(matrix(1:15, 3, 5))
#>  [1]  1  2  3  4  5  6  7  8  9 10 11 12 13 14 15

如果列数和行数的乘积与第一个参数提供的向量长度不匹配,matrix 函数会循环使用值。如果向量的长度是行数的子倍数或倍数,这会发生 而不会发出警告

matrix(1:3, 3, 5)
#>      [,1] [,2] [,3] [,4] [,5]
#> [1,]    1    1    1    1    1
#> [2,]    2    2    2    2    2
#> [3,]    3    3    3    3    3
```  **函数 `as.matrix()` 尝试将输入强制转换为矩阵:

```r
df <- data.frame(a = 1:2, b = 3:4, c = 5:6)
as.matrix(df)
#>      a b c
#> [1,] 1 3 5
#> [2,] 2 4 6

矩阵的维度

矩阵的维度是确保可以执行某些线性代数运算的重要特征。维度是一个由行数 \(\times\) 列数定义的两个数字摘要。

函数 nrow 告诉我们该矩阵有多少行。以下是之前定义的用于存储 MNIST 训练数据的行数 x

nrow(x)
#> [1] 60000

函数 ncol 告诉我们有多少列:

ncol(x)
#> [1] 784

我们了解到我们的数据集包含 60,000 个观察值(图像)和 784 个变量(像素)。

dim 函数返回行和列:

dim(x)
#> [1] 60000   784

现在我们可以确认 R 遵循定义长度为 \(n\) 的向量作为 \(n\times 1\) 矩阵或 列向量 的惯例:

vec <- 1:10
dim(matrix(vec))
#> [1] 10  1

子集操作

要从矩阵中提取特定条目,例如第 100 列的第 300 行,我们这样写:

x[300, 100]

我们可以通过使用索引向量来提取矩阵的子集。例如,我们可以这样提取前 300 个观察值的前 100 个像素:

x[1:300, 1:100]

要提取整个行或行子集,我们留出列维度为空。所以以下代码返回了前 300 个观察值的所有像素:

x[1:300,]

同样,我们可以通过保持第一个维度为空来子集任意数量的列。以下是提取前 100 个像素的代码:

x[,1:100]

如果我们只对一行或一列进行子集操作,结果对象就不再是矩阵了。例如,注意这里发生了什么:

dim(x[300,])
#> NULL

为了避免这种情况,我们可以使用 drop 参数:

dim(x[100,,drop = FALSE])
#> [1]   1 784

转置

在处理矩阵时,一个常见的操作是 转置。我们使用转置来理解下一节中描述的几个概念。这个操作简单地将矩阵的行转换为列。我们在粗体大写字母旁边使用符号 \(\top\)\('\) 来表示转置:

\[\text{如果 } \, \mathbf{X} = \begin{bmatrix} x_{11}&\dots & x_{1p} \\ x_{21}&\dots & x_{2p} \\ \vdots & \ddots & \vdots & \\ x_{n1}&\dots & x_{np} \end{bmatrix} \text{ then }\, \mathbf{X}^\top = \begin{bmatrix} x_{11}&x_{21}&\dots & x_{n1} \\ \vdots & \vdots & \ddots & \vdots \\ x_{1p}&x_{2p}&\dots & x_{np} \end{bmatrix} \]

在 R 中,我们使用函数 t 来计算转置

dim(x)
#> [1] 60000   784
dim(t(x))
#> [1]   784 60000

转置的一个用途是我们可以将矩阵 \(\mathbf{X}\) 写成以下方式表示每个个体观察值的变量的列向量的行:

\[\mathbf{X} = \begin{bmatrix} \mathbf{x}_1^\top\\ \mathbf{x}_2^\top\\ \vdots\\ \mathbf{x}_n^\top \end{bmatrix} \]

行和列摘要

与矩阵一起执行的一个常见操作是将相同的函数应用于每一行或每一列。例如,我们可能想要计算行平均值和标准差。apply 函数允许你这样做。第一个参数是矩阵,第二个参数是维度,1 表示行,2 表示列,第三个是要应用的函数。

因此,例如,要计算每行的平均值和标准差,我们这样写:

avgs <- apply(x, 1, mean)
sds <- apply(x, 1, sd)

要计算列的这些值,我们只需将 1 改为 2:

avgs <- apply(x, 2, mean)
sds <- apply(x, 2, sd)

因为这些操作非常常见,所以有特殊函数来执行它们。所以,例如,函数 rowMeans 计算每行的平均值:

avg <- rowMeans(x)

并且 rowSds 函数来自 matrixStats 包,它计算每行的标准差:

library(matrixStats)
sds <- rowSds(x)

函数 colMeanscolSds 提供了列的版本。

对于其他常见操作的快速实现,请查看 matrixStats 包中可用的内容。

条件过滤

矩阵操作相对于 tidyverse 操作的一个优点是我们可以根据列的摘要轻松选择列。

注意,逻辑过滤器可以以类似的方式用于矩阵的子集,就像它们可以用于向量的子集一样。以下是一个简单的示例,使用逻辑子集列:

matrix(1:15, 3, 5),[c(FALSE, TRUE, TRUE, FALSE, TRUE)]
#>      [,1] [,2] [,3]
#> [1,]    4    7   13
#> [2,]    5    8   14
#> [3,]    6    9   15

这意味着我们可以使用条件表达式选择行。在以下示例中,我们删除包含至少一个 NA 的所有观测值:

x[apply(!is.na(x), 1, all),]

这是一个常见的操作,我们有一个 matrixStats 函数来更快地完成它:

x![rowAnyNAs(x),]

使用矩阵进行索引

一种便于高效编码的操作是,我们可以根据应用于同一矩阵的条件来更改矩阵的条目。以下是一个简单的示例:

mat <- matrix(1:15, 3, 5)
mat[mat > 6 & mat < 12] <- 0
mat
#>      [,1] [,2] [,3] [,4] [,5]
#> [1,]    1    4    0    0   13
#> [2,]    2    5    0    0   14
#> [3,]    3    6    0   12   15

这种方法的实用应用之一是,我们可以将矩阵中所有的 NA 条目更改为其他值:

x[is.na(x)] <- 0

21.4 矩阵的向量化

向量化是 R 在数值计算中如此高效的主要原因之一。许多原本需要显式循环的操作可以直接应用于整个矩阵或向量,这使得代码既更快又更容易阅读。

矩阵-向量操作

矩阵向量化的一个常见例子是行中心化:从每行的对应元素中减去该行的平均值。尽管这可以通过 scale() 函数来完成

scale(x, scale = FALSE)

R 的向量化规则使得我们可以更直接、更高效地实现这一点。我们以行中心化为例,但这种方法适用于本章讨论的许多操作。

在 R 中,如果我们从矩阵 x 中减去向量 ax-a,则 a 的第一个元素从第一行减去,第二个元素从第二行减去,依此类推。从数学上讲,这个操作可以写成:

\[\begin{bmatrix} X_{11}&\dots & X_{1p} \\ X_{21}&\dots & X_{2p} \\ \vdots & \ddots & \vdots \\ X_{n1}&\dots & X_{np} \end{bmatrix} - \begin{bmatrix} a_1\\ a_2\\ \vdots \\ a_n \end{bmatrix} = \begin{bmatrix} X_{11}-a_1&\dots & X_{1p}-a_1\\ X_{21}-a_2&\dots & X_{2p}-a_2\\ \vdots & \ddots & \vdots\\ X_{n1}-a_n&\dots & X_{np}-a_n \end{bmatrix} \]

这意味着我们可以用这一行代码来对 x 的每一行进行中心化:

x - rowMeans(x)

相同的向量化也适用于其他算术操作,例如除法。例如,我们可以使用以下方法同时中心化和缩放行:

(x - rowMeans(x))/rowSds(x)

这相当于 scale(x),但这里显式地执行以展示底层的向量化操作。

sweep 函数

如果我们想缩放列,以下代码将不会按预期工作,因为减法是按行应用的:

x - colMeans(x)

一种解决方案是将矩阵转置,应用操作,然后再转置回来:

t(t(x) - colMeans(x))

这可行,但对于大型矩阵来说,由于重复转置,操作繁琐且效率低下。

sweep 函数为在矩阵上执行矢量化操作提供了一个更清晰、更通用的解决方案。它与 apply 类似,但专门为类似数组的数据和逐元素算术设计。

当与矩阵 x 一起使用时,sweep 函数有三个主要参数:

  • MARGIN - 要操作的维度(1 表示行,2 表示列)

  • STATS - 用于扫除(例如,均值或标准差)的值向量

  • FUN - 要应用的运算(默认为减法 "-")

例如,要减去每一列的均值,我们编写:

sweep(x, MARGIN = 2, STATS = colMeans(x), FUN = "-")

这使每一列居中,使其平均值为零。因为 R 允许在默认值明确时省略参数名称,所以一个简洁且常见的缩写是:

sweep(x, 2, colMeans(x))

同样,为了居中行,我们编写:

sweep(x, 1, rowMeans(x))

(尽管请注意,对于这个特定操作,x - rowMeans(x) 通常更快。)

要进行缩放而不是居中,只需更改操作:

sweep(x, 2, colSds(x), "/")

sweep 函数也超越了矩阵的范畴,它可以处理超过两个维度的数组以及匹配维度的统计向量 (STATS)。

矩阵-矩阵运算

在 R 中,如果你对两个矩阵进行加、减、乘或除运算,操作是逐元素进行的。例如,如果两个矩阵存储在 xy 中,那么 x*y 并不导致 第 22.1 节 中定义的矩阵乘法。相反,这个乘积的行 \(i\) 和列 \(j\) 的条目是 x 中行 \(i\) 和列 \(j\) 的条目与 y 中相应条目的乘积。

21.5 激励任务

为了激励在 R 中使用矩阵,我们将提出六个任务,说明矩阵运算如何帮助探索手写数字数据集。每个任务突出显示了一个在 R 中常用的基本矩阵运算。通过看到这些操作如何以快速简单的方式实现,你将获得在处理高维数据分析中经常出现的计算的实际经验。这些任务的主要目标是帮助你了解矩阵运算在实际中的工作方式。

可视化原始图像

像素强度以矩阵的行提供。使用我们在第 21.3.1 节中学到的知识,我们可以将每一行转换成一个 \(28 \times 28\) 的矩阵,我们可以将其可视化为图像。作为一个例子,我们将使用第三个观测值。从标签中,我们知道这是一个:

y[3]
#> [1] 4

矩阵 x[3,] 的第三行包含 784 个像素强度。如果我们假设这些是按顺序输入的,我们可以使用以下方式将它们转换回一个 \(28 \times 28\) 的矩阵:

grid <- matrix(x[3,], 28, 28)

为了可视化数据,我们可以使用以下方式 image

image(1:28, 1:28, grid)

然而,因为 image 中的 y 轴是从下到上,而 x 存储像素是从上到下,上面的代码显示了翻转的图像。要将其翻转回来,我们可以使用:

image(1:28, 1:28, grid[, 28:1])

有些数字需要比其他数字更多的墨水来书写吗?

让我们研究总像素黑暗度的分布以及它如何随数字而变化。

我们可以使用我们在第 21.3.5 节中学到的知识计算每个像素的平均值,并使用箱线图显示每个数字的值:

avg <- rowMeans(x)
boxplot(avg ~ y)

* *从这个图中我们可以看到,不出所料,1s 使用的墨水比其他数字少。

有些像素是无信息的吗?

我们现在将检查每个像素在数字之间的变异性,以识别和移除那些对分类提供很少信息的像素。在图像之间变异性小的像素无法帮助区分数字,可以安全地排除。在许多情况下,移除无信息特征不仅简化了分析,而且大大减少了数据集的大小,提高了计算速度。

使用我们在第 21.3.5 节和第 21.3.6 节中学到的知识,我们可以有效地计算和过滤出这样的列。为了量化每个像素的可变性,我们使用matrixStats包中的colSds函数计算其在所有图像中的标准差:

sds <- colSds(x)

快速查看这些值的分布显示,一些像素的逐个值变异性非常低:

hist(sds, breaks = 30, main = "SDs")

* *这很合理,因为我们不写盒子的某些部分。这里是我们使用我们在第 21.3.1 节中学到的知识创建的 \(28 \times 28\) 矩阵后按位置绘制的方差:

image(1:28, 1:28, matrix(sds, 28, 28)[, 28:1])

* *我们看到角落的变异性很小。

我们可以移除没有变化的特征,因为这些特征无法帮助我们预测。

因此,如果我们想从矩阵中移除无信息预测因子,我们可以写这一行代码:

new_x <- x,[colSds(x) > 60]
dim(new_x)
#> [1] 60000   322

只有标准差高于 60 的列被保留,这移除了超过一半的预测因子。

我们可以移除模糊吗?

我们将首先查看所有像素值的分布。

hist(as.vector(x), breaks = 30, main = "Pixel intensities")

* *这显示了一个明显的二分法,它被解释为有墨水的图像部分和无墨水的图像部分。如果我们认为低于,比如说,50 的值是模糊的,我们可以快速将它们设置为 0,使用我们在第 21.3.7 节中学到的知识:

new_x <- x
new_x[new_x < 50] <- 0

二值化数据

上面的直方图似乎表明这些数据大多是二进制的。一个像素要么有墨水,要么没有。应用我们在第 21.3.7 节中学到的知识,我们可以仅使用矩阵运算来二值化数据:

bin_x <- x
bin_x[bin_x < 255/2] <- 0 
bin_x[bin_x > 255/2] <- 1

我们还可以将它们转换为逻辑矩阵,然后使用我们在第 21.4 节中学到的知识将它们强制转换为数字:

bin_X <- (x > 255/2)*1

标准化数字

最后,我们将对每一列进行缩放,使其具有相同的平均值和标准差。

使用我们在第 21.4 节中学到的知识意味着我们可以按如下方式缩放矩阵的每一行:

(x - rowMeans(x))/rowSds(x)

对于列,我们可以组合两次调用sweep

sweep(sweep(x, 2, colMeans(x)), 2, colSds(x), FUN = "/")

注意我们可以通过将2替换为1并使用rowMeansrowSds来标准化行。

最后,请注意,通常可以通过以下方式实现等效功能:

t(scale(t(x)))

这种版本对于大型矩阵可能更快,尽管它对正在发生的事情的描述不够明确。

21.6 练习

  1. 创建一个 100 行 10 列的随机正态数矩阵。将结果放入x中。

  2. 应用三个 R 函数,分别给出x的维度、x的行数和x的列数。

  3. 将标量 1 加到矩阵x的第一行,标量 2 加到第二行,依此类推。

  4. 将标量 1 加到矩阵x的第一列,标量 2 加到第二列,依此类推。提示:使用sweep函数,FUN = "+"

  5. 计算x每一行的平均值。

  6. 计算x每一列的平均值。

  7. 对于 MNIST 训练数据中的每个数字,计算处于灰色区域的像素比例,定义为介于 50 和 205 之间的值。按数字类别制作箱线图。提示:使用逻辑运算符和rowMeans


  1. http://yann.lecun.com/exdb/mnist/↩︎

22  应用线性代数

原文:rafalab.dfci.harvard.edu/dsbook-part-2/highdim/linear-algebra.html

  1. 高维数据

  2. 22  应用线性代数

线性代数是描述和启发统计方法和机器学习方法的数学技术的主要技术。在本章中,我们介绍了理解这些技术所需的一些数学概念。我们将在本书的其余部分使用这些概念和技术。

22.1 矩阵乘法

数据分析中常用的操作之一是矩阵乘法。在这里,我们定义并解释这个操作。

线性代数起源于数学家们发展出系统化解决线性方程组的方法。例如:

\[\begin{aligned} x + 3 y - 2 z &= 5\\ 3x + 5y + 6z &= 7\\ 2x + 4y + 3z &= 8 \end{aligned} \]

数学家们发现,通过使用矩阵和向量表示这些线性方程组,可以设计预定义的算法来解决任何线性方程组。基本的线性代数课程将教授一些这些算法,例如高斯消元法、高斯-若尔当消元法以及 LU 和 QR 分解。这些方法通常在大学水平的线性代数课程中详细讲解。

为了解释矩阵乘法,定义两个矩阵:\(\mathbf{A}\)\(\mathbf{B}\)

\[\mathbf{A} = \begin{pmatrix} a_{11}&a_{12}&\dots&a_{1n}\\ a_{21}&a_{22}&\dots&a_{2n}\\ \vdots&\vdots&\ddots&\vdots\\ a_{m1}&a_{m2}&\dots&a_{mn} \end{pmatrix}, \, \mathbf{B} = \begin{pmatrix} b_{11}&b_{12}&\dots&b_{1p}\\ b_{21}&b_{22}&\dots&b_{2p}\\ \vdots&\vdots&\ddots&\vdots\\ b_{n1}&b_{n2}&\dots&b_{np} \end{pmatrix} \]

并定义矩阵 \(\mathbf{A}\)\(\mathbf{B}\) 的乘积为矩阵 \(\mathbf{C} = \mathbf{A}\mathbf{B}\),其元素 \(c_{ij}\) 等于 \(\mathbf{A}\) 的第 \(i\) 行与 \(\mathbf{B}\) 的第 \(j\) 列分量乘积之和。

我们可以定义矩阵乘法 \(\mathbf{C}= \mathbf{A}\mathbf{B}\) 对于任何两个矩阵,比如

A <- matrix(1:12, 4, 3); B <- matrix(1:18, 3, 6)

使用以下 R 代码:

m <- nrow(A)
p <- ncol(B)
C <- matrix(0, m, p)
for(i in 1:m){
 for(j in 1:p){
 Ci,j] <- [sum(A[i,] * B[,j])
 }
}

因为这个操作非常常见,R 包含一个用于矩阵乘法的数学运算符 %*%

C <- A %*% B

使用数学符号 \(\mathbf{C} = \mathbf{A}\mathbf{B}\) 看起来是这样的:

\[\begin{pmatrix} a_{11}b_{11} + \dots + a_{1n}b_{n1}& a_{11}b_{12} + \dots + a_{1n}b_{n2}& \dots& a_{11}b_{1p} + \dots + a_{1n}b_{np}\\ a_{21}b_{11} + \dots + a_{2n}b_{n1}& a_{21}b_{12} + \dots + a_{2n}b_{n2}& \dots& a_{21}b_{1p} + \dots + a_{2n}b_{np}\\ \vdots&\vdots&\ddots&\vdots\\ a_{m1}b_{11} + \dots +a_{mn}b_{n1}& a_{m1}b_{12} + \dots + a_{mn}b_{n2}& \dots& a_{m1}b_{1p} + \dots + a_{mn}b_{np}\\ \end{pmatrix} \]

注意这个定义意味着矩阵乘法 \(\mathbf{A}\mathbf{B}\) 只在 \(\mathbf{A}\) 的行数与 \(\mathbf{B}\) 的列数相匹配时才可能进行。当这种情况发生时,我们说这两个矩阵是兼容的

那么,这个矩阵乘法定义是如何帮助我们解决方程组的?

要理解这一点,可以考虑以下带有未知数 \(x_1, \dots x_n\) 的方程组:

\[\begin{aligned} a_{11} x_1 + a_{12} x_2 \dots + a_{1n}x_n &= b_1\\ a_{21} x_1 + a_{22} x_2 \dots + a_{2n}x_n &= b_2\\ \vdots\\ a_{n1} x_1 + a_{n2} x_2 \dots + a_{nn}x_n &= b_n\\ \end{aligned} \]

我们可以通过定义以下矩阵来表示这种矩阵乘法,

\[\mathbf{A} =\begin{pmatrix} a_{11}&a_{12}&\dots&a_{1n}\\ a_{21}&a_{22}&\dots&a_{2n}\\ \vdots&\vdots&\ddots&\vdots\\ a_{n1}&a_{n2}&\dots&a_{nn} \end{pmatrix} ,\, \mathbf{b} = \begin{pmatrix} b_1\\ b_2\\ \vdots\\ b_n \end{pmatrix} ,\, \mbox{ and } \mathbf{x} = \begin{pmatrix} x_1\\ x_2\\ \vdots\\ x_n \end{pmatrix} \]

重新写这个方程就是:

\[\mathbf{A}\mathbf{x} = \mathbf{b} \]

列出的线性代数算法,如高斯消元法,提供了一种计算解方程 \(\mathbf{x}\) 的逆矩阵 \(\mathbf{A}^{-1}\) 的方法:

\[\mathbf{A}^{-1}\mathbf{A}\mathbf{x} = \mathbf{x} = \mathbf{A}^{-1} \mathbf{b} \]

要解决我们在 R 中写出的第一个方程,我们可以使用solve函数:

A <- matrix(c(1, 3, -2, 3, 5, 6, 2, 4, 3), 3, 3, byrow = TRUE)
b <- matrix(c(5, 7, 8))
solve(A, b)

当处理小到中等大小的矩阵,并且每列的范围相似且 0 的数量不多时,solve函数表现良好。当这种情况不成立时,可以使用qr.solve函数。

22.2 单位矩阵

用粗体 \(\mathbf{I}\) 表示的单位矩阵就像数字 1,但对于矩阵来说:如果你将一个矩阵乘以单位矩阵,你将得到原来的矩阵。

\[\mathbf{I}\mathbf{X} = \mathbf{X} \]

如果你将 \(\mathbf{I}\) 定义为具有相同行数和列数(称为方阵)的矩阵,除了对角线上的数字为 1 外,其余均为 0,

\[\mathbf{I}=\begin{pmatrix} 1&0&\dots&0\\ 0&1&\dots&0\\ \vdots&\vdots&\ddots&\vdots\\ 0&0&\dots&1 \end{pmatrix}, \]

你将获得所需属性。

注意,逆矩阵的定义意味着:

\[\mathbf{A}^{-1}\mathbf{A} = \mathbf{1} \]

因为solve函数中第二个参数的默认值是单位矩阵,如果我们简单地输入solve(A),我们将得到逆矩阵 \(\mathbf{A}^{-1}\)。这意味着我们也可以用以下方式得到方程组的解:

solve(A) %*% b

22.3 距离

我们在处理高维数据时进行的许多分析直接或间接与距离相关。例如,大多数机器学习技术都依赖于能够定义观测之间的距离,使用特征或预测因子。例如,聚类算法寻找的是相似的观测。但在数学上这又意味着什么呢?

为了定义距离,我们引入另一个线性代数概念:范数。回想一下,二维空间中的一个点可以用极坐标表示:

其中 \(\theta = \arctan{\frac{x2}{x1}}\)\(r = \sqrt{x_1² + x_2²}\)。如果我们把点看作二维列向量 \(\mathbf{x} = (x_1, x_2)^\top\)\(r\) 定义了 \(\mathbf{x}\) 的范数。范数可以看作是二维向量的大小,不考虑方向:如果我们改变角度,向量会改变,但大小不会变。定义范数的目的是我们可以将大小的概念推广到高维。具体来说,我们为任何向量 \(\mathbf{x}\) 写出范数如下:

\[\|\mathbf{x}\| = \sqrt{x_1² + x_2² + \dots + x_p²} \]

注意,我们可以使用我们学到的线性代数概念来定义范数,如下所示:

\[\|\mathbf{x}\|² = \mathbf{x}^\top\mathbf{x} \]

为了定义距离,假设我们有两个二维点:\(\mathbf{x}_1\)\(\mathbf{x}_2\)。我们可以通过简单地使用欧几里得距离来定义它们之间的相似性:

我们知道距离等于斜边的长度:

\[\sqrt{(x_{11} - x_{12})² + (x_{21} - x_{22})²} \]

我们引入范数的原因是因为这个距离是两点之间向量的长度,并且这可以推广到任何维度。两点之间的距离,无论维度如何,都定义为差分的范数

\[\| \mathbf{x}_1 - \mathbf{x}_2\|. \]

如果我们使用数字数据,第一和第二次观察之间的距离将使用所有 784 个特征来计算:

\[\| \mathbf{x}_1 - \mathbf{x}_2 \| = \sqrt{ \sum_{j=1}^{784} (x_{1j}-x_{2j })² } \]

为了演示,让我们选择三个数字的特征:

x_1 <- x[6,]
x_2 <- x[17,]
x_3 <- x[16,]

我们可以使用我们刚刚学到的定义来计算每对之间的距离:

c(sum((x_1 - x_2)²), sum((x_1 - x_3)²), sum((x_2 - x_3)²)) |> sqrt()
#> [1] 2320 2331 2519

在 R 中,函数 crossprod(x) 对于计算范数来说很方便。它将 t(x) 乘以 x

c(crossprod(x_1 - x_2), crossprod(x_1 - x_3), crossprod(x_2 - x_3)) |> sqrt()
#> [1] 2320 2331 2519

注意** crossprod 函数接受一个矩阵作为第一个参数。因此,这里使用的向量被强制转换为单列矩阵。另外,注意 crossprod(x,y)t(x) 乘以 y

我们可以看到,前两个之间的距离更小。这与前两个是 2,第三个是 7 的事实相符。

y[c(6, 17, 16)]
#> [1] 2 2 7

我们也可以使用函数 dist 来一次性相对快速地计算所有距离,该函数接受一个矩阵作为输入,并计算每行之间的距离,生成一个 dist 类型的对象:

d <- dist(x[c(6,17,16),])
class(d)
#> [1] "dist"

这很方便,因为 R 中有几个机器学习相关的函数接受 dist 类型的对象作为输入。

我们可以像这样查看我们计算出的距离:

d
#>      1    2
#> 2 2320 
#> 3 2331 2519

请注意,对角线被省略,因为所有自距离都是零,并且由于距离矩阵的对称性(这是由距离的可交换性得出的),上三角形也被排除。

要使用行和列索引访问条目,我们需要将其强制转换为矩阵。

image 函数允许我们快速查看观察之间的距离图像。例如,我们这样计算前 300 个观察值之间的距离:

d <- dist(x[1:300,])

要查看图像,我们只需将 d 转换为矩阵并发送到函数:image(as.matrix(d))

如果我们按标签排序这个距离,我们可以在对角线附近看到黄色的正方形。这是因为来自相同数字的观察值往往比来自不同数字的观察值更接近:

image(as.matrix(d)[order(y1:300]), [order(y[1:300])])

22.4 空间

预测空间* 是一个常用于描述机器学习算法的概念。术语 空间 指的是一个高级数学定义,我们提供了一种简化的解释,以帮助理解在机器学习算法的上下文中使用预测空间时的术语。

我们可以将所有观察到的预测因子 \((x_{i1}, \dots, x_{ip})^\top\) 对于所有观察 \(i=1,\dots,n\) 视为 \(n\)\(p\) 维的点。一个空间可以被视为所有可能点的集合,这些点应该被考虑用于相关数据分析。这包括我们可以看到但尚未观察到的点。在处理手写数字的情况下,我们可以将预测空间视为任何点 \((x_{1}, \dots, x_{p})^\top\),只要每个条目 \(x_i, \, i = 1, \dots, p\) 都在 0 到 255 之间。

一些机器学习算法也定义子空间。在机器学习中常见的一个子空间是由接近预定 中心 的点组成的 邻域。我们通过选择一个中心 \(\mathbf{x}_0\)、一个最小距离 \(r\),并将子空间定义为满足以下条件的点 \(\mathbf{x}\) 的集合来完成此操作:

\[\| \mathbf{x} - \mathbf{x}_0 \| \leq r. \]

我们可以将这个子空间视为一个多维球体,因为每个点到中心的距离都是相同的。

其他机器学习算法将预测空间划分为非重叠的区域,然后使用该区域的数据对每个区域进行不同的预测。我们将在 第 30.4 节 中了解这些内容。

22.5 练习

1. 生成两个矩阵 AB,包含随机生成的正态分布数字。这两个矩阵的维度应分别为 \(4 \times 3\)\(3 \times 6\)。确认 C <- A %*% B 产生与以下相同的结果:

m <- nrow(A)
p <- ncol(B)
C <- matrix(0, m, p)
for(i in 1:m){
 for(j in 1:p){
 Ci,j] <- [sum(A[i,] * B[,j])
 }
}

2. 使用 R 解以下方程组:

\[\begin{aligned} x + y + z + w &= 10\\ 2x + 3y - z - w &= 5\\ 3x - y + 4z - 2w &= 15\\ 2x + 2y - 2z - 2w &= 20\\ \end{aligned} \]

3. 定义 xy

mnist <- read_mnist()
x <- mnist$train$images[1:300,] 
y <- mnist$train$labels[1:300]

并计算距离矩阵:

d <- dist(x)
class(d)

生成一个箱线图,显示d的第二行的距离,按数字分层。不要包括到自身的距离,我们知道这个距离是 0。你能预测出x的第二行代表哪个数字吗?

  1. 使用apply函数和矩阵代数来计算第四个数字mnist$train$images[4,]与其他在mnist$train$images中表示的数字之间的距离。然后生成与练习 2 中类似的箱线图,并预测第四行代表的数字是什么。

  2. 计算每个特征与代表中间像素(第 14 行第 14 列)的特征之间的距离。创建一个图像,用颜色显示距离在像素位置上的显示。

23  降维

原文:rafalab.dfci.harvard.edu/dsbook-part-2/highdim/dimension-reduction.html

  1. 高维数据

  2. 23  降维

高维数据可以使数据分析变得具有挑战性,尤其是在可视化和模式发现方面。例如,在 MNIST 数据集中,每个图像由 784 个像素表示。为了探索所有像素对之间的关系,我们需要检查超过 300,000 个散点图。这很快变得不切实际。

在本章中,我们介绍了一组被称为降维的强大技术。其核心思想是在保留重要特征,如观测之间的距离的同时,减少数据集中的变量数量。维度减少后,可视化变得更加可行,数据中的模式也更容易被发现。

我们主要关注的技术是主成分分析(PCA),这是一种广泛用于无监督学习和探索性数据分析的方法。PCA 基于一个称为奇异值分解(SVD)的数学工具,它在降维之外也有应用。

我们从一个简单的示例开始,以建立直观感受,然后介绍理解主成分分析(PCA)所需的数学概念。我们通过将 PCA 应用于两个更复杂、真实世界的数据集来展示其实际价值,以此结束本章。

23.1 动机:保留距离

为了说明目的,我们考虑一个简单的双胞胎身高示例。一些对是成年人,其他的是儿童。在这里,我们模拟了 100 个二维点,每个点代表一对双胞胎。我们使用MASS包中的mvrnorm函数来模拟双变量正态数据。

set.seed(1983)
library(MASS)
n <- 100
rho <- 0.9
sigma <- 3
s <- sigma²*matrix(c(1, rho, rho, 1), 2, 2)
x <- rbind(mvrnorm](https://rdrr.io/pkg/MASS/man/mvrnorm.html)(n/2, [c(69, 69), s),
 mvrnorm](https://rdrr.io/pkg/MASS/man/mvrnorm.html)(n/2, [c(60, 60), s))

散点图迅速揭示出相关性很高,并且存在两组双胞胎,成年人(右上角的点)和儿童(左下角的点):

图片

我们的特征是\(n\)个二维点,即两个身高。为了说明目的,我们将假设可视化二维过于困难,我们希望通过一维变量的直方图来探索数据。因此,我们希望降低维度从二维到一维,但仍然能够理解数据的重要特征,特别是观测会聚成两组:成年人和儿童。

为了表明这里提出的思想具有普遍的实用性,我们将标准化数据,使观测值处于标准单位而不是英寸:

library(matrixStats)
x <- sweep(sweep(x, 2, colMeans(x)), 2, colSds(x), "/")

在上面的散点图中,我们还显示了观测 1 和 2 之间的距离(蓝色),以及观测 1 和 51 之间的距离(红色)。注意,蓝色线较短,这意味着 1 和 2 更接近。

我们可以使用dist来计算这些距离:

d <- as.matrix(dist(x))
c(d[1, 2], d[2, 51])
#> [1] 0.595 1.388

这种距离基于两个维度,我们需要一个仅基于一个维度的距离近似。

如果我们回顾上面的高度散点图,并想象在任意两点之间画一条线,这条线的长度就代表了这两点之间的距离。许多这些线倾向于沿着对角线方向对齐,这表明数据中的大部分变异性并不是纯粹的水平或垂直,而是在两个轴上呈对角线分布。

现在想象一下,我们将整个点云旋转,使得之前沿对角线的变异性现在与水平轴对齐。在这个旋转视图中,点之间的最大差异将出现在第一个(水平)维度上。这将使得仅使用一个变量来描述数据变得更容易,这个变量能够捕捉到最有意义的变异性。

在下一节中,我们介绍了一种数学方法,使这种直觉变得精确。它允许我们找到一个旋转数据的方法,在重新定位轴以突出显示最重要的变化方向的同时,保持点之间的距离。这是称为主成分分析(PCA)的方法的基础,这是最广泛使用的降维技术。

23.2 旋转

任何二维点 \((x_1, x_2)^\top\) 可以写成从 \((0,0)^\top\)\((x_1, x_2)^\top\) 的斜边为底边和高的三角形的底边和高度:

\[x_1 = r \cos\phi, \,\, x_2 = r \sin\phi \]

其中 \(r\) 是斜边的长度,\(\phi\) 是斜边与 x 轴之间的角度。

要将点 \((x_1, x_2)^\top\) 围绕中心 \((0,0)^\top\) 和半径 \(r\) 的圆旋转一个角度 \(\theta\),我们只需将前面方程中的角度改为 \(\phi + \theta\)

\[z_1 = r \cos(\phi+ \theta), \,\, z_2 = r \sin(\phi + \theta) \]

我们可以使用三角恒等式将 \((z_1, z_2)\) 重写如下:

\[\begin{aligned} z_1 = r \cos(\phi + \theta) = r \cos \phi \cos\theta - r \sin\phi \sin\theta = x_1 \cos(\theta) - x_2 \sin(\theta)\\ z_2 = r \sin(\phi + \theta) = r \cos\phi \sin\theta + r \sin\phi \cos\theta = x_1 \sin(\theta) + x_2 \cos(\theta) \end{aligned} \]

现在,我们可以通过将上述公式应用于每个对 \((x_{i1}, x_{i2})^\top\) 来旋转数据集中的每个点。

在将每个点旋转 \(-45\) 度后,双标准高度看起来是这样的:

注意,虽然 \(x_1\)\(x_2\) 的变异性相似,但 \(z_1\) 的变异性远大于 \(z_2\) 的变异性。此外,请注意,点之间的距离似乎得到了保留。在下一节中,我们将从数学上证明这一点。

23.3 线性变换

每次矩阵 \(\mathbf{X}\) 与另一个矩阵 \(\mathbf{A}\) 相乘时,我们称乘积 \(\mathbf{Z} = \mathbf{X}\mathbf{A}\)\(\mathbf{X}\) 的线性变换。下面,我们展示了上述旋转是线性变换。为了看到这一点,请注意,对于任何行 \(i\),第一个条目是:

\[z_{i1} = a_{11} x_{i1} + a_{21} x_{i2} \]

其中 \(a_{11} = \cos\theta\)\(a_{21} = -\sin\theta\).

第二个条目也是一个线性变换:

\[z_{i2} = a_{12} x_{i1} + a_{22} x_{i2} \]

其中 \(a_{12} = \sin\theta\)\(a_{22} = \cos\theta\).

我们可以使用矩阵符号来编写这些方程:

\[\begin{pmatrix} z_1\\z_2 \end{pmatrix} = \begin{pmatrix} a_{11}&a_{12}\\ a_{21}&a_{22} \end{pmatrix}^\top \begin{pmatrix} x_1\\x_2 \end{pmatrix} \]

使用线性代数的优点之一是我们可以通过将所有观测值保存在一个 \(N \times 2\) 矩阵中来为整个数据集编写转换:

\[\mathbf{X} \equiv \begin{bmatrix} \mathbf{x}_1^\top\\ \vdots\\ \mathbf{x}_n^\top \end{bmatrix} = \begin{bmatrix} x_{11}&x_{12}\\ \vdots&\vdots\\ x_{n1}&x_{n2} \end{bmatrix} \]

然后,我们可以通过应用 \(X\)线性变换 来获得每个行 \(i\) 的旋转值 \(\mathbf{z}_i\)

\[\mathbf{Z} = \mathbf{X} \mathbf{A} \mbox{ with } \mathbf{A} = \, \begin{pmatrix} a_{11}&a_{12}\\ a_{21}&a_{22} \end{pmatrix} = \begin{pmatrix} \cos \theta&\sin \theta\\ -\sin \theta&\cos \theta \end{pmatrix} . \]

如果我们定义:

theta <- -45 * pi/180 #convert degrees to radians
A <- matrix(c(cos(theta), -sin(theta), sin(theta), cos(theta)), 2, 2)

我们可以使用线性代数编写任何角度 \(\theta\) 的旋转代码:

rotate <- function(x, theta){
 theta <- theta * pi/180
 A <- matrix(c(cos(theta), -sin(theta), sin(theta), cos(theta)), 2, 2)
 x %*% A
}

\(\mathbf{A}\) 的列被称为 方向,因为如果我们从 \((0,0)\) 画一个向量到 \((a_{1j}, a_{2j})\),它指向将成为第 \(j\) 维度的直线方向。

线性代数的另一个优点是,如果我们能找到 \(\mathbf{A}^{-1}\) 的逆矩阵,我们可以通过线性变换将 \(\mathbf{Z}\) 转换回 \(\mathbf{X}\),再次使用线性变换。

在这个特定情况下,我们可以使用三角学来证明:

\[\begin{aligned} x_{i1} = b_{11} z_{i1} + b_{21} z_{i2}\\ x_{i2} = b_{12} z_{i1} + b_{22} z_{i2} \end{aligned} \]

其中 \(b_{21} = \cos\theta\)\(b_{21} = \sin\theta\)\(b_{12} = -\sin\theta\),和 \(b_{22} = \cos\theta\)

这意味着:

\[\mathbf{X} = \mathbf{Z} \begin{pmatrix} \cos \theta&-\sin \theta\\ \sin \theta&\cos \theta \end{pmatrix}. \]

注意,上面使用的转换实际上是 \(\mathbf{A}^\top\),这意味着

\[\mathbf{Z} \mathbf{A}^\top = \mathbf{X} \mathbf{A}\mathbf{A}^\top\ = \mathbf{X} \]

因此,在这个特定情况下,\(\mathbf{A}^\top\)\(\mathbf{A}\) 的逆。这也意味着 \(\mathbf{X}\) 中的所有信息都包含在旋转 \(\mathbf{Z}\) 中,并且可以通过线性变换检索。一个结果是,对于任何旋转,距离都得到保留。以下是一个 30 度旋转的例子,尽管它适用于任何角度:

all.equal(as.matrix(dist(rotate(x, 30))), as.matrix(dist(x)))
#> [1] TRUE

下一节将解释为什么会发生这种情况。

23.4 正交变换

回想一下,两点之间的距离,比如说变换 \(\mathbf{Z}\) 的行 \(h\)\(i\),可以写成这样:

\[\|\mathbf{z}_h - \mathbf{z}_i\| = (\mathbf{z}_h - \mathbf{z}_i)^\top(\mathbf{z}_h - \mathbf{z}_i) \]

其中,\(\mathbf{z}_h\)\(\mathbf{z}_i\) 分别是存储在 \(\mathbf{X}\) 的第 \(h\) 行和第 \(i\) 行的 \(p \times 1\) 列向量。

记住,我们用列向量表示矩阵的行。这就是为什么在展示矩阵 \(\mathbf{Z}=\mathbf{X}\mathbf{A}\) 的乘法时使用 \(\mathbf{A}\),但在展示单个观察值的变换时进行转置操作:\(\mathbf{z}_i = \mathbf{A}^\top\mathbf{x}_i\)* 使用线性代数,我们可以将上述量重写为:

\[\|\mathbf{z}_h - \mathbf{z}_i\| = \|\mathbf{A}^\top \mathbf{x}_h - \mathbf{A}^\top\mathbf{x}_i\|² = (\mathbf{x}_h - \mathbf{x}_i)^\top \mathbf{A} \mathbf{A}^\top (\mathbf{x}_h - \mathbf{x}_i) \]

注意,如果 \(\mathbf{A} \mathbf{A} ^\top= \mathbf{I}\),则原始数据和转换数据中第 \(h\) 行和第 \(i\) 行之间的距离是相同的。

我们将具有性质 \(\mathbf{A} \mathbf{A}^\top = \mathbf{I}\) 的变换称为 正交变换。这些变换保证保持任意两点之间的距离。

我们之前已经证明了我们的旋转具有这种性质。我们可以使用 R 来确认:

all.equal(A %*% t(A), diag(2))
#> [1] TRUE

要了解这个名称的由来,请回忆在线性代数中,如果两个向量,比如说 xy,被称为 正交,那么

\[\mathbf{x}^\top \mathbf{y} = 0. \]

如果一个矩阵 \(\mathbf{A}\) 满足 \(\mathbf{A}\mathbf{A}^\top = \mathbf{I}\),那么 \(\mathbf{A}\) 的每一行都与每一行正交。

“正交”一词源于希腊语中的“orthós”,意为“直”或“正确”,以及“gōnía”,意为“角”。在二维和三维空间中,可以证明内积为零的向量在直角相交——即它们是垂直的。该术语被推广到更高维,其中“直角”是通过内积而不是空间直觉来定义的。* 注意,\(\mathbf{A}\) 是正交的也保证了 \(\mathbf{X}\) 的总平方和(TSS),定义为 $$ \mbox{TSS} = \sum_{i=1}^n \sum_{j=1}^p x_{ij}² $$

等于旋转 \(\mathbf{Z} = \mathbf{X}\mathbf{A}^\top\) 的总平方和。为了说明这一点,观察如果我们用 \(\mathbf{z}_1, \dots, \mathbf{z}_n\) 表示 \(\mathbf{Z}\) 的行,那么平方和可以写成:

\[\sum_{1=1}^n \|\mathbf{z}_i\|² = \sum_{i=1}^n \|\mathbf{A}^\top\mathbf{x}_i\|² = \sum_{i=1}^n \mathbf{x}_i^\top \mathbf{A}\mathbf{A}^\top \mathbf{x}_i = \sum_{i=1}^n \mathbf{x}_i^\top\mathbf{x}_i = \sum_{i=1}^n\|\mathbf{x}_i\|² \]

我们可以使用 R 来确认:

theta <- -45
z <- rotate(x, theta) # works for any theta
sum(x²)
#> [1] 198
sum(z²)
#> [1] 198

这可以解释为正交变换保证所有信息都得到保留的事实。

然而,尽管总和保持不变,但各个列的平方和发生了变化。在这里,我们计算了分配给每个列的 TSS 比例,称为 \(\mathbf{X}\)解释方差捕获方差

colSums(x²)/sum(x²)
#> [1] 0.5 0.5

\(\mathbf{Z}\)

colSums(z²)/sum(z²)
#> [1] 0.9848 0.0152

在下一节中,我们描述了如何利用这个最后的数学结果。

23.5 主成分分析 (PCA)

我们已经证明正交变换既保留了观测之间的距离,也保留了总平方和 (TSS)。然而,虽然 TSS 保持不变,但它在列之间的分布方式可能因变换而异。

主成分分析 (PCA) 的主要思想是找到一个正交变换,尽可能地将方差集中在前几个列上。这允许我们通过仅关注那些捕获数据中大部分变化性的列来降低问题的维度。

在我们的例子中,我们旨在找到一个旋转,以最大化第一列的解释方差。以下代码在 -90 到 0 度的旋转范围内进行网格搜索,以识别这种变换:

angles <- seq(0, -90)
v <- sapply(angles, function(angle) colSums(rotate(x, angle)²))
variance_explained <- v1,]/[sum(x²)
plot(angles, variance_explained, type = "l")

* *我们发现 -45 度的旋转似乎达到了最大值,第一维解释了超过 98% 的总可变性。我们用 \(\mathbf{V}\) 表示这个旋转矩阵:

theta <- -45 * pi/180
V <- matrix(c(cos(theta), -sin(theta), sin(theta), cos(theta)), 2, 2)

我们可以使用以下方法旋转整个数据集:

\[\mathbf{Z} = \mathbf{X}\mathbf{V} \]

z <- x %*% V

以下动画进一步说明了不同旋转如何影响旋转数据维度的可变性:

z 的第一维被称为 第一主成分 (PC)。因为几乎所有变化都可以由这个第一主成分解释,所以 x 中行之间的距离可以用仅用 z[,1] 计算的距离很好地近似。

我们还注意到,两组,成人和儿童,可以通过一个数字摘要清楚地观察到,比任何两个原始维度都要好。

hist(x,1], breaks = [seq(-4,4,0.5))
hist(x,2], breaks = [seq(-4,4,0.5))
hist(z,1], breaks = [seq(-4,4,0.5))

* *我们可以可视化这些,以了解第一成分如何总结数据。在下面的图中,红色代表高值,蓝色代表负值:

这个想法自然地扩展到超过两个维度。与二维情况一样,我们首先找到一个 \(p \times 1\) 的向量 \(\mathbf{v}_1\),其 \(\|\mathbf{v}_1\| = 1\),并使它最大化

\[|\mathbf{X}\mathbf{v}_1|. \]

投影 \(\mathbf{X}\mathbf{v}_1\) 定义了第一个主成分(PC1);方向 \(\mathbf{v}_1\) 捕获了数据中可能的最大变化。

为了定义第二个主成分,我们寻找另一个与 \(\mathbf{v}_1\) 正交的单位向量 \(\mathbf{v}_2\),并最大化

\[|\mathbf{X}\mathbf{v}_2|. \]

投影 \(\mathbf{X}\mathbf{v}_2\) 是 PC2,它捕捉了在移除由 PC1 解释的任何变化后的最大剩余变化。

此过程继续:对于每个 \(k\)\(\mathbf{v}_k\) 是一个单位向量,与 \(\mathbf{v}_1, \dots, \mathbf{v}_{k-1}\) 正交,并且最大化投影 \(\mathbf{X}\mathbf{v}_k\) 的方差。

一旦找到所有 \(p\) 个方向,我们就构建完整的 旋转矩阵 \(\mathbf{V}\) 和相应的 主成分矩阵 \(\mathbf{Z}\)

\[\mathbf{V} = \begin{bmatrix} \mathbf{v}_1 & \dots & \mathbf{v}_p \end{bmatrix}, \quad \mathbf{Z} = \mathbf{X}\mathbf{V} \]

\(\mathbf{Z}\) 的每一列都是一个主成分,而 \(\mathbf{V}\) 的列定义了新的旋转坐标系。

距离保持的概念自然地扩展到高维空间。对于一个有 \(p\) 列的矩阵 \(\mathbf{X}\),变换 \(\mathbf{Z} = \mathbf{X}\mathbf{V}\) 保持行之间的成对距离,同时重新定位数据,使得 \(\mathbf{Z}\) 的每一列所解释的方差按从大到小的顺序排列。如果 \(\mathbf{Z}_j\) 列的方差对于 \(j > k\) 非常小,那么这些维度对数据的整体变化以及观测之间的距离的贡献很小。

在这种情况下,我们可以仅使用 \(\mathbf{Z}\) 的前 \(k\) 列来近似原始行之间的距离。如果 \(k \ll p\),这将得到一个维度更低的数据表示,同时仍然保留了数据的本质结构。这是主成分分析(PCA)的关键优势:它允许我们有效地总结高维数据,使得可视化、计算和建模变得更加容易,同时不丢失数据中的主要模式。

PCA 最大化问题的解不是唯一的。例如,\(\|\mathbf{X} \mathbf{v}\| = \|-\mathbf{X} \mathbf{v}\|\),所以 \(\mathbf{v}\)\(-\mathbf{v}\) 都产生相同的主成分方向。同样,如果我们翻转 \(\mathbf{Z}\)(主成分)中任何列的符号,只要我们也翻转 \(\mathbf{V}\)(旋转矩阵)中相应的列的符号,我们就可以保持等式 \(\mathbf{X} = \mathbf{Z} \mathbf{V}^\top\) 的相等性。这意味着我们可以自由地改变 \(\mathbf{Z}\)\(\mathbf{V}\) 中任何列的符号,而不会影响 PCA 的结果。* 在 R 中,我们可以使用 prcomp 函数找到任何矩阵的主成分:

pca <- prcomp(x, center = FALSE)

请注意,默认行为是在计算主成分之前将 x 的列居中,在我们的例子中我们不需要这个操作,因为我们的矩阵已经缩放。

对象 pca 包含旋转后的数据 \(Z\)pca$x 中,以及旋转 \(\mathbf{V}\)pca$rotation 中。

我们可以看到,pca$rotation 的列确实是获得 -45 度旋转的结果(记住符号是任意的):

pca$rotation
#>         PC1    PC2
#> [1,] -0.707  0.707
#> [2,] -0.707 -0.707

每个列的变化量的平方根包含在 pca$sdev 组件中。这意味着我们可以使用以下方法计算每个主成分解释的方差:

pca$sdev²/sum(pca$sdev²)
#> [1] 0.9848 0.0152

summary 函数执行此计算:

summary(pca)
#> Importance of components:
#>                          PC1    PC2
#> Standard deviation     1.403 0.1745
#> Proportion of Variance 0.985 0.0152
#> Cumulative Proportion  0.985 1.0000

我们还可以看到,我们可以像上面所解释的那样旋转 x (\(\mathbf{X}\)) 和 pca$x (\(\mathbf{Z}\)):

all.equal(pca$x, x %*% pca$rotation)
#> [1] TRUE
all.equal(x, pca$x %*% t(pca$rotation))
#> [1] TRUE

23.6 示例

鸢尾花示例

iris 数据集是数据分析课程中广泛使用的示例。它包含四个植物测量值,即花瓣长度、花瓣宽度、花萼长度和花萼宽度,来自三个不同物种的花:

names(iris)
#> [1] "Sepal.Length" "Sepal.Width"  "Petal.Length" "Petal.Width" 
#> [5] "Species"

如果你使用 iris$Species 检查物种列,你会看到观察结果按物种分组。

当我们可视化观察之间的成对距离时,一个清晰的模式出现:数据似乎聚集成三个不同的组,其中一个物种与其他两个物种明显分离。这种结构与已知的物种标签一致:

x <- as.matrix(iris[,1:4])
d <- dist(x)
image(as.matrix(d), col = rev(RColorBrewer::brewer.pal(9, "RdBu")))

* *我们的特征矩阵有四个维度,但其中三个维度高度相关:

cor(x)
#>              Sepal.Length Sepal.Width Petal.Length Petal.Width
#> Sepal.Length        1.000      -0.118        0.872       0.818
#> Sepal.Width        -0.118       1.000       -0.428      -0.366
#> Petal.Length        0.872      -0.428        1.000       0.963
#> Petal.Width         0.818      -0.366        0.963       1.000

如果我们对 iris 数据集应用 PCA,我们预计前几个主成分将捕捉到数据中的大部分变化。由于原始变量高度相关,PCA 应该允许我们仅使用两个维度来近似观察之间的距离,从而有效地压缩数据同时保留其结构。

我们使用 summary 函数来检查每个主成分解释了多少方差:

pca <- prcomp(x)
summary(pca)
#> Importance of components:
#>                          PC1    PC2    PC3     PC4
#> Standard deviation     2.056 0.4926 0.2797 0.15439
#> Proportion of Variance 0.925 0.0531 0.0171 0.00521
#> Cumulative Proportion  0.925 0.9777 0.9948 1.00000

前两个维度解释了几乎 98% 的变异性。因此,我们应该能够用两个维度很好地近似距离。我们通过仅使用前两个维度计算距离并与原始数据进行比较来确认这一点:

d_approx <- dist(pca$x[, 1:2])
plot(d, d_approx); abline(0, 1, col = "red")

* *这个结果的一个有用应用是,我们现在可以在二维图中可视化观察之间的距离。在这个图中,任何一对点之间的距离近似于原始高维空间中相应观察之间的实际距离。

data.frame(pca$x[,1:2], Species = iris$Species) |>
 ggplot(aes(PC1, PC2, fill = Species)) +
 geom_point(cex = 3, pch = 21) +
 coord_fixed(ratio = 1)

* *我们根据标签给观察结果着色,并注意到,使用这两个维度,我们几乎实现了完美的分离。

更仔细地观察生成的主成分和旋转:

我们了解到第一个 PC 是通过取花萼长度、花瓣长度和花瓣宽度(第一列中的红色)的加权平均值,并减去与花萼宽度成比例的量(第一列中的蓝色)得到的。第二个 PC 是花瓣长度和花瓣宽度的加权平均值,减去花萼长度和花萼宽度的加权平均值。

MNIST 示例

手写数字示例有 784 个特征。是否有数据降维的空间?我们将使用 PCA 来回答这个问题。

如果尚未加载,让我们先加载数据:

library(dslabs)
if (!exists("mnist")) mnist <- read_mnist()

由于像素非常小,我们预计网格上彼此靠近的像素是相关的,这意味着可以进行降维。

让我们计算主成分(PCs)(这需要几秒钟,因为这个矩阵相当大)并检查每个 PC 解释的方差:

pca <- prcomp(mnist$train$images)
plot(pca$sdev²/sum(pca$sdev²), xlab = "PC", ylab = "Variance explained")

图片* *我们可以看到前几个 PC 已经解释了大部分的变异性。此外,仅通过查看前两个 PC,我们已看到有关标签的信息。这里是一个 500 个数字的随机样本:

图片

我们还可以看到28 \(\times\) 28 网格上的旋转值,以了解像素在导致 PCs 的变换中的加权情况:

图片

我们可以清楚地看到第一个 PC 似乎将 1(红色)和 0(蓝色)分开。我们还可以在其他三个 PC 中模糊地辨认出数字或数字的部分。通过按数字分层查看 PC,我们获得更深入的见解。例如,我们看到第二个 PC 将 4、7 和 9 与其他数字分开:

图片

我们还可以确认,具有较低方差的 PCs 似乎与不重要的变异性相关,主要是角落的污迹:

图片

23.7 练习

  1. 我们想通过绘图来探索tissue_gene_expression预测值。
dim(tissue_gene_expression$x)

我们希望了解哪些观察值彼此接近,但预测值是 500 维的,因此绘图很困难。用表示组织类型的颜色绘制前两个主成分。

  1. 每个观察值的预测值是在相同的测量设备(基因表达微阵列)上通过实验程序测量的。每个观察值使用不同的设备和程序。这可能会引入影响每个观察值所有预测值的偏差。为了探索这种潜在偏差的影响,对于每个观察值,计算所有预测者的平均值,然后将其与第一个 PC 用颜色表示的组织类型进行绘图。报告相关性。

  2. 我们看到第一个 PC 与观察值平均值之间存在关联。重新进行 PCA,但先去除中心。

  3. 对于前 10 个 PC,制作一个箱线图,显示每个组织的值。

  4. 绘制由 PC 编号解释的百分比方差。提示:使用summary函数。

24  正则化

原文:rafalab.dfci.harvard.edu/dsbook-part-2/highdim/regularization.html

  1. 高维数据

  2. 24  正则化

统计模型旨在捕捉数据中的有意义结构。然而,当模型包含许多参数,或者当某些参数仅由少量观测值提供信息时,拟合值可能会变得不稳定。在这些情况下,最小二乘估计值可能远大于潜在的真实效应。这种现象是一种过拟合形式,可能会导致误导性的解释。

当我们使用拟合模型对新数据进行预测时,这些数据在模型拟合过程中并未使用,此时问题变得尤为明显。一个过拟合的模型通常会生成样本间差异过大的估计值。在本书的下一部分,我们将更详细地描述过拟合,并介绍诊断和防止过拟合的工具。

正则化提供了减少过拟合的最有效通用策略之一。其思路是修改拟合过程,使得大的参数估计值受到惩罚。在实践中,这是通过向我们要最小化的量添加一个惩罚项来实现的,例如平方误差之和。惩罚项会阻止极端估计,尤其是在它们仅由有限数据支持时。结果是得到一个更稳定的模型,其参数估计值在幅度上更小,并且对于训练数据之外的用途更加可靠。

在本章中,我们以电影评分推荐系统为背景介绍正则化。我们从用户和电影特定效应开始,观察最小二乘如何产生不切实际的估计,特别是对于评分非常少的电影。正则化通过将这些不稳定的估计值缩小到零来帮助解决这个问题。在下一章中,我们将在此基础上构建,并展示如何通过潜在因子模型扩展这一思想,这些模型是现代推荐系统的基础。

24.1 案例研究:推荐系统

推荐系统,如亚马逊所使用的,通过分析客户对各种产品的评分来运行。这些评分形成了一个大型数据集。系统使用这些数据来预测特定用户对特定产品给予好评的可能性。例如,如果系统预测用户可能会给某本书或小工具一个高评分,它就会向用户推荐该商品。本质上,系统试图根据用户和其他客户对各种商品的评分来猜测用户可能会喜欢的商品。这种方法有助于根据个人偏好个性化推荐。

在其运营的最初几年,Netflix 使用了一个 5 星推荐系统。一颗星表示用户不喜欢这部电影,而五颗星则表示用户非常喜欢它。在这里,我们提供这些推荐是如何做出的基本原理,这受到了Netflix 挑战获胜者采取的一些方法的启发。

2006 年 10 月,Netflix 向数据科学界提出了一个挑战:将我们的推荐算法提高 10%,赢得 10 万美元。2009 年 9 月,获胜者揭晓¹。在本章中,我们将展示正则化是如何成为获胜团队策略的重要组成部分²³。

Movielens 数据

Netflix 数据不是公开可用的,但 GroupLens 研究实验室⁴使用超过 138,000 个用户对 27,000 多部电影进行的超过 2000 万次评分生成了自己的数据库。我们通过dslabs包提供这些数据的一个小子集:

library(data.table)
library(dslabs)
movielens |> tibble:::tibble](https://tibble.tidyverse.org/reference/tibble.html)() |> [head(5)
#> # A tibble: 5 × 7
#>   movieId title                      year genres userId rating timestamp
#>     <int> <chr>                     <int> <fct>   <int>  <dbl>     <int>
#> 1      31 Dangerous Minds            1995 Drama       1    2.5    1.26e9
#> 2    1029 Dumbo                      1941 Anima…      1    3      1.26e9
#> 3    1061 Sleepers                   1996 Thril…      1    3      1.26e9
#> 4    1129 Escape from New York       1981 Actio…      1    2      1.26e9
#> 5    1172 Cinema Paradiso (Nuovo c…  1989 Drama       1    4      1.26e9

每一行代表一个用户对一部电影给出的评分。

由于我们正在处理一个相对较大的数据集,我们将数据框转换为data.table对象,以利用更高效的数据处理。

dt <- as.data.table(movielens)

我们可以看到提供了评分的唯一用户数量以及被评分的唯一电影数量:

dt, .(n_users = [uniqueN(userId), n_movies = uniqueN(movieId))]
#>    n_users n_movies
#>      <int>    <int>
#> 1:     671     9066

如果我们把这两个数字相乘,得到的数字超过 500 万,但我们的数据集只有大约 10 万行。这告诉我们,并非每个用户都对每部电影进行了评分。事实上,每个用户平均评分的电影数量是 71 部。我们可以将数据视为一个非常大的矩阵,其中用户是行,电影是列,其中包含许多缺失项。电影推荐系统的目标是准确预测这些缺失值。

数据探索

让我们来看看数据的一些一般特性,以便通过检查电影评分数量和用户评分数量的直方图来更好地了解挑战:

hist(log2(table(dt$movieId)), 0:9, xlab = "# Ratings (log 2)", main = "Movies")
hist(table(dt$userId), seq(0, 2400, 50), xlab = "# Ratings", main = "Users")

* *我们首先注意到,一些电影的评分远多于其他电影。大多数电影只被评分几次,而少数电影则积累了数百次评分。这种模式并不令人惊讶。流行的、广泛发行的电影往往被许多用户观看和评分,而小众或独立电影则吸引着更小的观众群体。

我们还看到用户之间存在差异,尽管这种差异并不极端。一些用户只对大约 10 部电影进行评分,而其他人则对数十部甚至数百部电影进行评分。

这些不平衡在我们构建模型时将非常重要,因为基于非常少观察的估计本身就不太可靠。

准备数据

在我们可以拟合模型或做出预测之前,我们需要将数据塑造成适合分析的形式。我们的目标是开发一个使用历史评分的算法,然后评估它对未用于训练的评分的预测效果有多好。

我们首先关注那些为我们提供了足够信息以便从他们的行为中学习到的用户。在这种情况下,我们只保留至少有 100 个评分的用户:

dt <- as.data.table(movielens)[, if (.N >= 100) .SD, by = userId]

为了方便起见,我们将userIdmovieId都转换为字符字符串。这样我们就可以在向量中使用它们作为名称。我们还只保留与这次分析相关的列,以保持打印输出的可管理性:

dt, `:=`(userId = [as.character(userId), movieId = as.character(movieId))]
dt <- dt, [c("userId", "movieId", "title", "rating")]

电影标题不一定唯一。例如,这个数据集中包含三部不同的电影,标题都是《金刚》。因此,我们保留movieId作为唯一标识符,只保留title用于解释和可视化**。为了评估我们的模型,我们将数据分为两部分:

  • 一个训练集,用于估计参数

  • 一个测试集,仅用于评估预测准确性

我们在用户级别创建分割,将每个用户的 80%的评分分配给训练集,20%分配给测试集:

set.seed(2006)
indexes <- split(1:nrow(dt), dt$userId)
test_ind <- sapply(indexes, function(i) sample(i, ceiling(length(i) * 0.2)))
test_ind <- sort(unlist(test_ind))

然后我们构建两个数据集**:

test_set  <- dt[test_ind,]
train_set <- dt[-test_ind,]

由于模型无法对它从未见过的电影做出预测,我们从测试集中移除了不在训练集中的任何电影**:

test_set <- test_setmovieId [%in% train_set$movieId]

这为我们提供了一个一致的训练和测试框架,我们将在本章中一直使用**。

损失函数

为了描述我们的模型及其估计,我们使用在第 17.6 节中引入的数组表示法。我们用\(y_{ij}\)表示用户\(i\)对电影\(j\)给出的评分**。

虽然符号\(y_{ij}\)暗示了一个完整的\(I \times J\)评分矩阵,但在实践中,我们并没有观察到这些值中的大多数。如果有\(I\)个用户和\(J\)部电影,完整的评分矩阵将包含\(I \times J\)个条目。然而,观察到的评分数量,我们用\(N\)表示,要小得多,因为大多数用户只对所有电影中的一小部分进行评分。以下,一个写成\(\sum_{i,j}\)的求和是理解在\(N\)个观察到的对\((i,j)\)上运行,而不是在所有可能的组合上运行**。

注意,我们以data.table而不是矩阵的形式存储数据,因为完整的矩阵表示几乎完全是NA。*Netflix 挑战定义了获胜的条目为在测试集上最小化均方根误差(RMSE)的条目。如果\(y_{ij}\)是测试集中的观察评分,\(\hat{y}_{ij}\)是仅使用训练集做出的相应预测,则 RMSE 为

\[\mbox{RMSE} = \sqrt{\frac{1}{N} \sum_{i,j} (y_{ij} - \hat{y}_{ij})²} \]

在这里,\(N\)是测试集中的评分数量,求和是针对这些评分进行的。

我们可以将 RMSE 解释为与评分相同的单位。它的行为很像标准差:它代表我们预测中的典型误差大小。大于 1 的值意味着,平均而言,我们的预测评分偏离了一个以上的星级,这是不希望的。

为了方便计算任何残差向量的 RMSE,我们定义:

rmse <- function(r) sqrt(mean(r²))

我们在第二十九章更正式地讨论了均方误差**。

最简单的模型

我们开始于最简单的推荐系统。假设我们忽略所有关于用户和电影的信息,并为每个用户-电影对预测相同的评分。在这种情况下,唯一的问题是:我们应该使用哪个单一数字?

我们可以使用一个模型来回答这个问题。假设所有评分都来自相同的潜在值 \(\mu\),随机变化解释了我们观察到的差异:

\[Y_{ij} = \mu + \varepsilon_{ij} \]

这里 \(\varepsilon_{ij}\) 代表具有均值为 0 的独立误差,而 \(\mu\) 是所有用户和电影的真实平均评分。

使 RMSE 最小的 \(\mu\) 值是最小二乘估计,在这种情况下,它只是训练集中所有评分的平均值:

mu <- mean(train_set$rating)

如果我们用这个单一值预测测试集中的每个评分,得到的 RMSE 是:

rmse(test_set$rating - mu)
#> [1] 1.04

从数学上讲,这个选择对这个模型是最优的。任何其他数字都会导致更大的 RMSE。例如:

rmse(test_set$rating - 3)
#> [1] 1.17

这种简单的方法提供了一个有用的基线。然而,它远远达不到赢得 Netflix 挑战赛所需的性能,该挑战要求 RMSE 大约为 0.857 才能赢得一百万美元的奖金。我们应该通过将更多结构纳入模型来做得更好**。

用户效应

一个自然的问题是所有用户是否都以相同的方式评分电影。一些用户倾向于给出非常高的评分,而其他人很少给出超过三个星级的评分。为了看到这一点,我们计算每个用户的平均评分:

hist(with(dt, tapply(rating, userId, mean)), nclass = 30, main = "User averages")

* *直方图显示了显著的变化。这表明,评分之间的某些差异可以通过用户对评分的慷慨程度或严格程度来解释,而不管被评分的电影是什么。

为了解决这个问题,我们将我们的模型扩展到包括用户特定的效应 \(\alpha_i\)

\[Y_{ij} = \mu + \alpha_i + \varepsilon_{ij} \]

在这里,\(\mu\) 代表整体平均评分,而 \(\alpha_i\) 捕捉用户 \(i\) 偏离该平均程度的大小。统计学教科书通常将 \(\alpha_i\) 称为处理效应。在 Netflix 奖项文献中,它们通常被称为 偏差项

这是一个线性模型,因此我们可以使用 lm() 来拟合模型:

fit <- lm(rating ~ userId, data = train_set)

然而,在这个数据集中,用户效应参数的数量是:

uniqueN(train_set$userId)
#> [1] 263

因此,我们需要数百个虚拟变量,这使得拟合模型在计算上效率低下。

幸运的是,对于这个模型,我们可以直接推导出最小二乘估计。估计 \(\hat{\alpha}_i\) 简单地是用户 \(i\)\(y_{ij} - \hat{\mu}\) 的平均值:

mu <- mean(train_set$rating)
user_avgs <- train_set, .(avg = [mean(rating - mu)), by = userId]

我们将这些值存储在一个命名向量中,这使得我们可以轻松地查找每个用户的效应**:

a <- setNames(user_avgs$avg, user_avgs$userId)

从这一点开始,我们使用a来表示用户效果估计向量\(\hat{\alpha}_i\),稍后我们将使用b来表示电影效果估计。在代码中,为了方便,我们省略了帽子符号,但在数学表达式中,我们将继续写\(\hat{\alpha}_i\)\(\hat{\beta}_j\)以明确这些是估计值。

\(\hat{\alpha}\)的估计到位后,我们使用以下方法预测评分:

\[\hat{y}_{ij} = \hat{\mu} + \hat{\alpha}_i \]

由于评分总是在 0.5 到 5 之间,我们定义一个辅助函数来强制执行此约束:

clamp <- function(x, lower = 0.5, upper = 5) pmax(pmin(x, upper), lower)

我们使用在训练集上获得的估计值来预测测试集,然后评估 RMSE:

resid <- with(test_set, rating - clamp(mu + a[userId]))
rmse(resid)
#> [1] 0.949

RMSE 下降,这证实了用户效果提高了我们的预测。但用户并不是唯一的变量来源。有些电影比其他电影更受欢迎。我们接下来解决这个问题。

电影效果

用户在评分电影方面存在差异,但电影本身在接收方面也存在差异。有些电影广受欢迎,而有些电影则始终收到低评分。为了捕捉这种变量来源,我们通过添加一个特定于电影的效应\(\beta_j\)来扩展我们的模型:

\[Y_{ij} = \mu + \alpha_i + \beta_j + \varepsilon_{ij} \]

这里,\(\beta_j\)表示电影\(j\)在考虑用户倾向后,偏离整体平均评分\(\mu\)的程度。

在原则上,我们可以使用最小二乘法一次性估计所有用户和电影效果:

fit <- lm(rating ~ userId + movieId, data = train_set)

然而,这需要创建数千个指示变量,每个用户和一个电影。结果的设计矩阵很大且稀疏,使得lm()对于这个目的效率低下。

相反,我们使用一个交替最小二乘法(ALS)算法。这是一个依赖于两个事实的迭代方法:

  • 如果\(\mu\)\(\alpha_i\)是已知的,\(\beta_j\)的最小二乘估计是用户评分电影\(j\)\(y_{ij} - \mu - \alpha_i\)的平均值。

  • 如果\(\mu\)\(\beta_j\)是已知的,\(\alpha_i\)的最小二乘估计是用户\(i\)评分的电影上\(y_{ij} - \mu - \beta_j\)的平均值。

通过交替这些更新,估计值收敛到最小二乘解。

fit <- copy(train_set)
mu <- mean(fit$rating)
fit[, `:=`(a = 0, b = 0)]  # starting values
 for (iter in 1:100) {
 a_old <- copy(fit$a)
 b_old <- copy(fit$b)

 fit, a := [mean(rating - mu - b), by = userId]
 fit, b := [mean(rating - mu - a), by = movieId]

 if (max(c(abs(fit$a - a_old), abs(fit$b - b_old))) < 1e-6) break
}

然后我们将用户和电影效果提取到一个命名向量中:

a <- setNames(fit$a, fit$userId)
b <- setNames(fit$b, fit$movieId)

当用户和电影效果都到位时,我们的预测为:

\[\hat{y}_{ij} = \hat{\mu} + \hat{\alpha}_i + \hat{\beta}_j \]

测试集预测的 RMSE 结果为:

resid <- with(test_set, rating - clamp(mu + a[userId] + b[movieId]))
rmse(resid)
#> [1] 0.882

这进一步减少了误差,证实了包括用户和电影效果可以提高预测准确性。然而,这些估计仍然可能不稳定,特别是对于只有少数评分的电影。

过度拟合

如果我们检查具有最大估计电影效果\(\hat{\beta}_j\)的电影,我们会发现它们都是冷门电影,只有单一评分:

top_movies <- names(bb == [max(b)])
train_set, .(n = .N), by = .(movieId, title)][movieId [%in% top_movies]
#>    movieId                                         title     n
#>     <char>                                        <char> <int>
#> 1:    1450 Prisoner of the Mountains (Kavkazsky plennik)     1
#> 2:    4076                                     Two Ninas     1
#> 3:    4591                               Erik the Viking     1
#> 4:    4930                             Funeral in Berlin     1
#> 5:    5427                                       Caveman     1

我们真的相信这些是数据库中最好的电影吗?这些所谓的顶级评分电影都没有出现在测试集中。为了进一步调查,让我们检查所有估计效应 \(\hat{\beta}_j \ge 2\) 的电影:

top_movies <- names(b[b > 2])
length(top_movies)
#> [1] 16

这 16 部电影中所有电影的评分最多只有两次:

range(table(train_setmovieId [%in% top_movies]$movieId))
#> [1] 1 2

我们在测试集中对这些电影的每一个预测都是一个高估:

range(resid[with(test_set, which(movieId %in% top_movies))])
#> [1] -1.41 -0.50

这是一个明显的 过拟合 例子。仅被评分一次或两次的电影可以通过偶然的机会获得非常大的正(或负)估计。支持的观察结果越少,估计的变异性就越大。这些不稳定的估计导致预测误差很大,进而增加了 RMSE。

在本书的早期,当我们面临不确定性时,我们依赖于标准误差或置信区间来传达它。但在进行预测时,我们无法提供一个范围。我们必须选择一个单一的数字,即使估计是不可靠的。

这就是 正则化 变得至关重要的地方。正则化在基于小样本时将极端估计缩小到零,从而产生更稳定和可靠的预测。在下一节中,我们将介绍惩罚最小二乘法,这是一种实现这种缩放的原则性方法。

24.2 惩罚最小二乘法

在前一节中,我们看到了最大的电影效应估计来自只有一两个评分的电影。这些估计是不稳定的,由此产生的预测可能非常不准确。这是一个经典的 过拟合 例子,其中估计变得过大,因为它们依赖于太少的样本数据。

在线性模型中解决过拟合的一个常见方法是添加一个将大系数缩小到零的 惩罚。这种方法被称为 惩罚最小二乘法,当惩罚是二次的时,也称为 岭回归

一个通用的公式

为了在一个更熟悉的环境中描述这个想法,我们暂时从推荐问题中使用的矩阵符号 \(y_{ij}\) 切换到具有结果 \(y_i\) 和协变量 \(x_{i1}, \dots, x_{ip}\) 的标准线性模型:

\[Y_i = \beta_0 + \sum_{j=1}^p x_{ij}\beta_j + \varepsilon_i, \quad i = 1, \dots, N \]

如果 \(p\) 很大,普通最小二乘法可能会过拟合。惩罚最小二乘法通过最小化来解决此问题:

\[\frac{1}{N}\sum_{i=1}^N \left[ y_i - \left(\beta_0 + \sum_{j=1}^p x_{ij}\beta_j\right)\right]² + \lambda \sum_{j=1}^p \beta_j² \]

第一个项是通常的均方误差。第二个项通过调整参数 \(\lambda\) 控制我们如何强烈地惩罚它们,来阻止大系数。截距 \(\beta_0\) 通常不受惩罚,因为我们通常知道数据不是以 0 为中心的。

这种方法被称为 岭回归。R 中的 MASS 包提供了 lm.ridge 函数,用于使用惩罚最小二乘法拟合模型:

library(MASS)
fit <- lm.ridge(y ~ x, lambda = 0.001)

然而,这个函数对于有数千个指示变量的模型效率不高,就像我们在电影评分应用中那样。在下面的章节中,我们使用受罚最小二乘法,但为了找到估计值,我们实现了 第 24.1.7 节 中描述的 ALS 算法。

你可能注意到,一些受罚最小二乘的定义包括未除以 \(N\) 的残差平方和(RSS),而另一些则除以 \(N\) 以形成均方误差(MSE)。两种公式在数学上是有效的,并且如果 \(\lambda\) 被适当缩放,它们会导致相同的极小化值。

然而,除以 \(N\) 有一个重要的实际优势。它使得第一项的规模对数据集大小不那么依赖,因此相同的 \(\lambda\) 值在不同的数据集大小中具有类似的解释。MASS 包中的 lm.ridge 函数使用这个约定,我们在这里遵循它以保持一致性。

对电影效应应用罚则

回到我们的设置,我们希望将电影效应 \(\beta_j\) 收缩到零,尤其是当它们只基于少量评分时。我们不是最小化:

\[\sum_{i,j} \left[y_{ij} - (\mu + \alpha_i + \beta_j) \right]², \]

我们最小化受罚版本:

\[\frac{1}{N}\sum_{i,j} \left[y_{ij} - (\mu + \alpha_i + \beta_j) \right]² + \lambda \left( \sum_i \alpha_i² + \sum_j \beta_j²\right) \]

对于这个模型,如果我们知道 \(\mu\)\(\beta_j\),我们可以推导出一个闭式解,以最小化受罚的平方和:

\[ \hat{\alpha}_i(\lambda) = \frac{1}{n_i + N\lambda} \sum_{j=1}^{n_i} \left(y_{ij} - \mu - \beta_j\right) $$ 其中 $n_i$ 是用户 $i$ 的评分数量,$N$ 是总的评分数量。 类似地,如果我们知道 $\mu$ 和 $\alpha_i$,我们可以推导出 $\beta_j$ 的解: $$ \hat{\beta}_j(\lambda) = \frac{1}{n_j + N\lambda} \sum_{i=1}^{n_j} \left(y_{ij} - \mu - \alpha_i\right) \]

其中 \(n_j\) 是电影 \(j\) 的评分数量。

罚款的效果是明显的。如果 \(n_j\) 很大,\(n_j + N\lambda \approx n_j\),收缩最小,\(\hat{\beta}_j(\lambda)\) 实质上成为一个平均值。如果 \(n_j\) 很小,估计值会被强烈地拉向 0。\(\lambda\) 越大,拉力越大。

通过 ALS 估计参数

由于 lm.ridge 在我们的设置中效率不高,我们有数千个指示变量和一个非常稀疏的设计,所以我们改用之前介绍过的相同交替最小二乘(ALS)策略。关键的区别在于,我们现在用受罚版本替换电影效应更新,当电影只有少量评分时,这种更新会更强地收缩估计值。

为了方便起见,我们定义了一个辅助函数 fit_als,它接受一个 lambda 值并返回一个包含用户和电影效果的正规化估计值的 train_set 的副本。这个函数专门针对我们的电影评分示例。它假设 train_set 在环境中可用。

fit_als <- function(lambda, tol = 1e-6, max_iter = 100) {
 fit <- copy(train_set)
 N <- nrow(fit)
 mu <- mean(fit$rating)
 fit[, `:=`(a = 0, b = 0)]  # starting values
 for (iter in 1:max_iter) {
 a_old <- copy(fit$a)
 b_old <- copy(fit$b)

 fit, a := [sum(rating - mu - b)/(.N + N*lambda) , by = userId]
 fit, b := [sum(rating - mu - a)/(.N + N*lambda), by = movieId]

 delta <- max(c(abs(fit$a - a_old), abs(fit$b - b_old)))
 if (delta < tol) break
 }
 return(with(fit, list(mu = mu, a = setNames(a, userId), b = setNames(b, movieId))))
}

这种实现交替更新用户效果 a 和电影效果 b 的惩罚估计,直到更新小于 tol 或我们达到 max_iter。最终结果是每个电影的正规化电影效果:

fit <- fit_als(lambda = 0.0001)
mu <- fit$mu; a_reg <- fit$a; b_reg <- fit$b

注意,在这个例子中我们使用 \(\lambda = 0.0001\)。在 第 24.3 节 中,我们描述了一种数据驱动的方法来选择 \(\lambda\),以优化预测精度。

收缩行为

我们可以通过将它们相互绘制来比较惩罚估计和未惩罚估计:

我们注意到评分较少的电影被大幅缩小:正则化版本比原始版本更接近于 0。评分较多的电影基本保持不变:点接近于身份线。

改进的结果

让我们看看在惩罚模型下的前 5 部电影:

top_movies <- names(sort(-b_reg[unique(names(b_reg))])[1:5])
train_set, .(n = .N), by = .(movieId, title)][movieId [%in% top_movies]
#>    movieId                     title     n
#>     <char>                    <char> <int>
#> 1:     296              Pulp Fiction   155
#> 2:     858            Godfather, The   112
#> 3:      50       Usual Suspects, The   111
#> 4:     318 Shawshank Redemption, The   131
#> 5:    1252                 Chinatown    49

这些比之前的一评级电影列表更有说服力。

最后,我们评估了 RMSE:

resid <- with(test_set, rating - clamp(mu + a_reg[userId] + b_reg[movieId]))
rmse(resid)
#> [1] 0.868

RMSE 有所提高,证实了正则化稳定了估计并减少了过度

24.3 选择惩罚项

正则化参数 \(\lambda\) 控制我们缩小电影效果的程度。如果 \(\lambda\) 太小,我们不会减少过度拟合。如果它太大,我们会缩小太多,从而失去有用的信号。

在 第二十九章 中,我们介绍了选择调整参数的正式方法。在这里,为了说明目的,我们采取了一种简单的方法:我们计算了一系列 \(\lambda\) 值的 RMSE,并选择在测试集上表现最好的一个。

我们考虑了 0 到 0.0002 之间的 100 个值:

lambdas <- seq(0, 0.0002, length.out = 100)
rmses <- sapply(lambdas, function(lambda) { 
 fit <- fit_als(lambda)
 resid <- with(test_set, rating - clamp(fit$mu + fit$a[userId] + fit$b[movieId]))
 rmse(resid)
})

然后我们将 RMSE 作为 \(\lambda\) 的函数绘制出来:

plot(lambdas, rmses, type = "l", xlab = expression(lambda), ylab = "RMSE")

* *我们看到一个明显的最小值。最佳值是:

best_lambda <- lambdas[which.min(rmses)]
best_lambda
#> [1] 3.64e-05

我们现在使用这个值重新拟合模型:

fit <- fit_als(best_lambda)

并计算最终的 RMSE:

resid <- with(test_set, rating - clamp(fit$mu + fit$a[userId] + fit$b[movieId]))
rmse(resid)
#> [1] 0.863

这个结果证实了正则化提高了我们的预测精度。下表比较了这一 RMSE 与早期模型获得的值。

模型 RMSE
仅平均值 1.040
用户效果 0.949
用户 + 电影效果 0.882

| 正规化 | 0.863 |

24.4 练习

对于练习 1-8,我们将使用以下模拟:一位教育专家正在倡导建立较小的学校。这位专家的推荐基于这样一个事实:在表现最好的学校中,许多都是小规模的。让我们为 1000 所学校模拟一个数据集。首先,让我们模拟每所学校的学生人数。

set.seed(1986)
n <- round(2^rnorm(1000, 8, 1))

现在让我们为每所学校分配一个真实的质量,这个质量完全独立于规模。这是我们想要估计的参数,它代表了该校平均学生在能力测试中会得到的分数。我们模拟这个值如下:

mu <- round(80 + 2 * rt(1000, 5))

现在让我们构建一个数据集:

schools <- data.frame(id = sprintf("PS%03d", 0:999), size = n, quality = mu, 
 rank = rank(-mu))

我们可以看到,前 10 所学校是:

library(dplyr
schools |> top_n(10, quality) |> arrange(desc(quality))

现在让该校的学生参加考试。每个学校的学生之间存在差异,因此考试分数也会有随机性,所以我们将模拟考试分数,使其呈正态分布,平均成绩由学校质量决定,标准差为 30 个百分比点:

scores <- sapply(1:nrow(schools), function(i)
 rnorm(schools$size[i], schools$quality[i], 30))
schools <- schools |> mutate(score = sapply(scores, mean))
  1. 根据平均分数,哪些是顶尖学校?只显示 ID、规模和平均分数。

  2. 将中等学校规模与基于分数的前 10 所学校的平均学校规模进行比较。

  3. 根据这次测试,看起来小学校比大学校好。前 10 所学校中有 5 所学生人数在 100 人或以下。但这怎么可能呢?我们构建的模拟使得质量和规模是独立的。重复进行最差的 10 所学校的练习。

  4. 对于最差的学校也是如此:它们也很小。绘制平均分数与学校规模的关系图,看看发生了什么。突出显示基于真实质量的前 10 所学校。使用对数尺度转换规模。

  5. 我们可以看到,当学校规模较小时,分数的标准误差变异性更大。这是我们学习概率和推理部分的基本统计现实。

让我们使用正则化来挑选最好的学校。记住,正则化会将偏离平均值的差异缩小到 0。因此,为了应用正则化,我们首先需要将我们要缩小的值居中。让我们称它为theta

schools <- mutate(schools, theta = score - mean(score))

使用本章中显示的惩罚最小二乘估计的封闭形式解来证明我们可以通过将theta乘以size/(size + 1000*lambda)来找到正则化估计。尝试lambda = 0.05并检查基于此正则化估计的前 10 所学校。

  1. 注意这略微提高了我们的结果:错误地高度排名的小学校数量减少了。但是,是否有更好的lambda?找到使 RMSE = \(1/100 \sum_{i=1}^{100} (\mbox{school quality} - \mbox{estimate})²\)最小化的lambda

  2. 根据最佳平均成绩对学校进行排名。请注意,没有小学校被错误地包含在内。

  3. 使用正则化时常见的错误是将不围绕 0 的值缩小到 0。例如,如果我们不先减去整体平均值,实际上并没有改善结果。通过重新运行练习 5、6 和 7 中的代码来确认这一点,但不移除整体平均值。

剩余的练习使用movielens数据。请注意,并非所有这些练习都需要正则化来解决,一些练习侧重于数据探索或使用本章中介绍的其他想法构建模型。

  1. 在本章中给出的示例中,我们移除了评分少于 100 的用户。现在重新进行分析,不要过滤掉这些用户。实现一个同时惩罚用户和电影效果的分析:

\[\frac{1}{N}\sum_{i,j} \left[y_{ij} - (\mu + \alpha_i + \beta_j) \right]² + \lambda \left( \sum_i \alpha_i² + \sum_j \beta_j²\right). \]

注意,您将不得不编辑fit_als函数。

  1. 重复练习 9,但现在使用具有两个单独惩罚项的模型:

\[\frac{1}{N}\sum_{i,j} \left[y_{ij} - (\mu + \alpha_i + \beta_j)\right]² + \lambda_1 \sum_i \alpha_i² + \lambda_2 \sum_j \beta_j². \]

在这里,\(\lambda_1\)控制应用于用户效果\(\alpha_i\)的收缩量,而\(\lambda_2\)控制应用于电影效果\(\beta_j\)的收缩量。

为了优化两个调整参数,在合理的\((\lambda_1, \lambda_2)\)对范围内进行网格搜索,并选择在测试集上最小化 RMSE 的组合。

我们建议在开发解决方案时降低你的 ALS 实现中的tol参数,因为这将在网格搜索期间加快计算速度。

  1. 使用movielens数据集来探索评分如何随三个关键因素变化:评分数量上映年份*类型

执行以下操作:

  • 计算每部电影的评分数量,并将其与电影的上映年份进行比较。对计数使用平方根转换。

  • 只保留 1993 年或之后上映的电影。在这些电影中,计算自上映以来的每年评分数量。报告评分数量最高的 25 部电影及其平均评分。

  • 按每年评分数量(例如,20 个区间或分位数)对这些相同的电影进行分层,并计算每个分层的平均评分。

  • 绘制平均评分与每年评分数量的关系图。

  1. movielens数据还包括一个时间戳。将时间戳转换为日期(提示:使用lubridate::as_datetime)。 计算每周的平均评分(提示:使用round_dategroup_by)。在 x 轴上绘制时间,在 y 轴上绘制每周平均评分。* 解释趋势。时间效应看起来合理吗?

  2. movielens数据还包括一个genresgenres中的每个唯一组合视为一个类别。计算每个类别的平均评分及其标准误差。*制作误差条形图。

  3. 根据你在练习 12 中的探索,以下哪个模型看起来最合适?

  4. \(Y_{ij} = \mu + \alpha_i + \beta_j + t_{ij} + \varepsilon_{ij}\)

  5. \(Y_{ij} = \mu + \alpha_i + \beta_j + \gamma , t_{ij} + \varepsilon_{ij}\)

  6. \(Y_{ij} = \mu + \alpha_i + \beta_j + \gamma , t_{ij} + \delta_{g(j)} + \varepsilon_{ij}\)

  7. \(Y_{ij} = \mu + \alpha_i + \beta_j + f(t_{ij}) + \delta_{g(j)} + \varepsilon_{ij}\)

其中 \(g(j)\) 是电影 \(j\) 的类型类别,\(f(t)\)\(t\) 的平滑函数。

  1. 使用你上面学到的知识,至少拟合一个比本章中展示的最佳模型(用户+电影效果模型,带有正则化)改进的模型:

你可以选择(可选)包括:

  • a. 类型效应

  • a. 时间效应

  • 年度评分作为电影级别的协变量

  • 或者你发现的任何其他内容

使用 RMSE 评估你的模型在保留的测试集上的表现。

将你的结果与本章前面报告的 RMSE 值进行比较。

简短地反思以下内容:

  • 哪些变量帮助最大?

  • 是否有任何添加了噪声而不是信号?

  • 你取得了多少改进?


  1. http://bits.blogs.nytimes.com/2009/09/21/netflix-awards-1-million-prize-and-starts-a-new-contest/↩︎

  2. http://blog.echen.me/2011/10/24/winning-the-netflix-prize-a-summary/↩︎

  3. https://www2.seas.gwu.edu/~simhaweb/champalg/cf/papers/KorenBellKor2009.pdf↩︎

  4. https://grouplens.org/↩︎

25  隐含因子模型

原文:rafalab.dfci.harvard.edu/dsbook-part-2/highdim/latent-factor-models.html

  1. 高维数据

  2. 25  隐含因子模型

许多数据问题涉及我们无法直接看到结构的大型矩阵。隐含因子模型提供了一种强大的方法来揭示这种隐藏结构。关键思想是,观察到的数据通常反映了少量未观察(潜在)特征的影响。这些潜在因素捕捉到诸如相似个体群体、相似物品群体或驱动观察值共享的潜在属性的模式。

在上一章中,我们介绍了该模型

\[Y_{ij} = \mu + \alpha_i + \beta_j + \varepsilon_{ij} \]

用于描述电影评分。此模型通过 \(\alpha_i\) 考虑用户之间的差异,通过 \(\beta_j\) 考虑电影之间的差异。然而,它将所有电影和所有用户视为在这些简单平均值之外无关。在实践中,我们知道电影群体往往受到相同类型用户的喜爱,用户群体也倾向于对相同类型的电影给出相似的评分。这种共享结构仅由用户和电影效应本身无法捕捉。

为了看到这一点,我们计算残差

\[r_{ij} = y_{ij} - (\hat{\mu} + \hat{\alpha}_i + \hat{\beta}_j) \]

通过使用上一章中定义的 fit_als 函数,将模型拟合到 train_set 数据集,

fit <- fit_als(lambda = 3.636364e-05)
train_set[, resid := rating - clamp(fit$mu + fit$a[userId] + fit$b[movieId])]

并与三部其他电影的残差进行比较:

这些相关性从强正相关到负相关不等。具体来说,教父教父续集好家伙西雅图未眠夜的相关性分别为 0.84、0.49 和-0.42。喜欢教父的用户也倾向于喜欢教父续集好家伙,但通常不喜欢西雅图未眠夜。这类关系是因为电影共享潜在特征:类型、风格、时代或受众吸引力。同样,用户也有潜在特质,使他们更倾向于某些类型的电影而不是其他类型的电影。

隐含因子模型旨在捕捉这种类型的结构。它们不是估计数千个单独的参数,而是通过少量潜在特征定义的低维空间来表示用户和电影。Netflix Prize 竞赛获胜团队在很大程度上依赖于这一想法,使用隐含因子模型提高了预测准确性。

尽管推荐系统是最著名的应用之一,但隐含因子模型在许多其他领域也有广泛的应用,例如基因组学(建模隐藏的生物结构)、文本分析(文档中的主题)、市场营销(客户细分)和金融(资产回报背后的风险因素)。

在本章中,我们在推荐系统的背景下介绍潜在因素模型,将其与主成分分析(PCA)联系起来,并展示奇异值分解(SVD)如何提供一个统一的数学基础。我们将构建捕捉评分数据中隐藏结构的模型,并展示这些模型如何进一步提高预测精度。

25.1 因子分析

我们从一个简单的模拟例子开始。具体来说,我们模拟了六部电影和 120 个用户的评分 \(Y_{ij}\)。为了简单起见,我们假设这些评分已经根据第二十四章中描述的电影和用户效应进行了调整。我们将 120 个用户和 6 个电影效应的结果存储在对象 y 中:

dim(y)
#> [1] 120   6

如果我们检查基于用户评分的电影之间的相关性,我们会注意到一个模式:

cor(y)
#>                      Godfather Godfather 2 Goodfellas Scent of a Woman You've Got Mail Sleepless in Seattle
#> Godfather                1.000       0.671      0.558           -0.527          -0.734               -0.721
#> Godfather 2              0.671       1.000      0.471           -0.450          -0.649               -0.739
#> Goodfellas               0.558       0.471      1.000           -0.888          -0.487               -0.505
#> Scent of a Woman        -0.527      -0.450     -0.888            1.000           0.451                0.475
#> You've Got Mail         -0.734      -0.649     -0.487            0.451           1.000                0.756
#> Sleepless in Seattle    -0.721      -0.739     -0.505            0.475           0.756                1.000

似乎评分在类型内呈正相关,例如,在动作片或浪漫片中,而在两个类型之间呈负相关。在统计学中,这种模式通常由因素来解释:未观察到的或潜在的变量,这些变量解释了观察变量之间的相关性或关联。在一定的假设下,我们将在下面描述,这些潜在因素可以从数据中估计出来,并用于捕捉驱动相关性的潜在结构。

一种方法是利用我们对电影类型的了解来定义一个因素,以区分动作片和浪漫片:

q <- c(1, 1, 1, -1, -1, -1)

我们将动作片编码为-1,将浪漫片编码为1

为了量化每个用户的类型偏好,我们为每个用户 \(i\)y 的行)拟合一个单独的线性模型,使用 apply

p <- apply(y, 1, function(y_i) lm(y_i ~ q - 1)$coef)

我们在公式中包含-1,因为残差被构建为具有均值为 0,因此不需要截距。

每次调用lm都会返回一个估计系数,表示动作片和浪漫片之间的平均评分差异。正值表示偏好动作片,负值表示偏好浪漫片,而接近 0 的值表示没有强烈的偏好。

将这些跨用户收集起来,我们得到 p,一个向量,每个用户有一个条目。下面的直方图显示用户聚类成这三种类型:

hist(p, breaks = seq(-2, 2, 0.1))

* *我们现在表明,我们可以仅使用两个向量的乘积来近似每个评分 \(Y_{ij}\):一个描述用户偏好(\(p\)),另一个描述电影特征(\(q\))。为了与矩阵代数一起工作,我们将向量转换为矩阵并相乘:

p <- matrix(p)
q <- matrix(q)
plot(p %*% t(q), y)

* *点大致沿着对角线分布,这意味着模型捕捉到了数据中相当多的结构。然而,仍然存在大量的未解释变异。即使在移除了“动作片与浪漫片”效应之后,我们仍然看到相关性模式:

cor(y - p %*% t(q))
#>                      Godfather Godfather 2 Goodfellas Scent of a Woman You've Got Mail Sleepless in Seattle
#> Godfather                1.000       0.185     -0.545            0.557          -0.280               -0.198
#> Godfather 2              0.185       1.000     -0.618            0.594          -0.186               -0.364
#> Goodfellas              -0.545      -0.618      1.000           -0.671           0.619                0.650
#> Scent of a Woman         0.557       0.594     -0.671            1.000          -0.641               -0.656
#> You've Got Mail         -0.280      -0.186      0.619           -0.641           1.000                0.353
#> Sleepless in Seattle    -0.198      -0.364      0.650           -0.656           0.353                1.000

仔细观察,这种结构似乎来自于阿尔·帕西诺是否出现在电影中。这表明我们可以通过添加一个第二个潜在因素来改进模型,这个因素可以区分帕西诺电影和非帕西诺电影。我们通过在 q 中添加第二个列来实现这一点:

q <- cbind(c(1, 1, 1, -1, -1, -1),
 c(1, 1, -1, 1, -1, -1))

现在每部电影都由两个潜在因素来描述:

  1. 暴力 vs. 浪漫

  2. 阿尔·帕西诺 vs. 非阿尔·帕西诺

然后,我们为每个用户拟合一个模型来估计他们对每个因素的偏好:

p <- t(apply(y, 1, function(y_i) lm(y_i ~ q-1)$coef))

因为 q 现在有两组列,lm 为每个用户返回两个系数。第一个系数捕捉群体/浪漫偏好,第二个系数捕捉用户是否倾向于喜欢阿尔·帕西诺的电影。

我们使用 t(转置)是因为 apply 将结果堆叠为列,但我们希望每行有一个用户。

使用两个因素,我们的预测能力提高了:

plot(p %*% t(q), y)

* *现在,如果我们看看残差相关性

cor(y - p %*% t(q))
#>                      Godfather Godfather 2 Goodfellas Scent of a Woman You've Got Mail Sleepless in Seattle
#> Godfather              1.00000     -0.3597    0.00716          0.00716          0.2412              0.41857
#> Godfather 2           -0.35970      1.0000   -0.04429         -0.04429          0.4955              0.20741
#> Goodfellas             0.00716     -0.0443    1.00000          1.00000         -0.0339             -0.00554
#> Scent of a Woman       0.00716     -0.0443    1.00000          1.00000         -0.0339             -0.00554
#> You've Got Mail        0.24118      0.4955   -0.03395         -0.03395          1.0000             -0.27145
#> Sleepless in Seattle   0.41857      0.2074   -0.00554         -0.00554         -0.2714              1.00000

现在没有明显的模式了。这两个潜在因素现在解释了数据中的主要结构。

这个包含两个因素的近似可以写成:

\[ Y_{ij} \approx p_{i1}q_{j1} + p_{i2}q_{j2}, i = 1 \dots, I \mbox{ 和 } j = 1, \dots, J $$ 其中 $I$ 是用户数量,$J$ 是电影数量。 使用矩阵表示,我们可以将上述问题重写如下: $$ \mathbf{Y} \approx \mathbf{P}\mathbf{Q}^\top $$ 其中 $\mathbf{Y}$ 是一个 $I\times J$ 矩阵,其条目为 $Y_{ij}$,$\mathbf{P}$ 是一个 $I\times K$ 矩阵,其条目为 $p_{ik}$,而 $\mathbf{Q}$ 是一个 $J\times K$ 矩阵,其条目为 $q_{jk}$。 这种分析揭示了生成我们数据的过程的见解,因为 $\mathbf{P}$ 包含用户特定的参数,而 $\mathbf{Q}$ 包含电影特定的参数。这种方法通常被称为 *矩阵分解*,因为评分矩阵已经被 *分解* 成两个低维、可解释的矩阵。 注意,这种方法还提供了压缩,因为 $120 \times 6 = 720$ 个观测值可以被一个 $120 \times 2$ 矩阵 $\mathbf{P}$ 和一个 $6 \times 2$ 矩阵 $\mathbf{Q}$ 的矩阵乘积很好地近似,总共 $252$ 个参数。 在我们使用模拟数据的例子中,我们从样本相关性和我们对电影的了解中推导出了因素 $\mathbf{q}_1$ 和 $\mathbf{q}_2$。这些因素最终表现良好。然而,一般来说,推导因素并不那么简单。此外,提供良好近似的因素可能比只包含两个值更复杂。例如,*《教父 III》* 有一个浪漫副线,所以我们可能不知道在 `q_1` 中给它分配什么值。 那么,我们能否估计这些因素?一个挑战是,如果 $\mathbf{P}$ 是未知的,我们的模型就不再是线性的:我们无法使用 `lm` 来估计 $\mathbf{P}$ 和 $\mathbf{Q}$。在下一节中,我们将描述如何使用 PCA 来估计 $\mathbf{P}$ 和 $\mathbf{Q}$。 我们使用了 `apply` 和 `lm` 来估计 `p`。但有一种更快的方法来使用线性代数计算这些估计。 `lm` 函数通过最小化残差的平方和来工作。在矩阵形式中,这导致正则方程: $$ (\mathbf{q}^\top\mathbf{q}) \, \hat{\mathbf{p}}_i = \mathbf{q}^\top \mathbf{y}_i \]

其中 \(\mathbf{y}_i\) 是表示传递给 apply 调用的 y 的第 \(i\) 行的列向量。

关键观察结果是 \(\mathbf{q}\) 对每个用户都是相同的,因此反复运行 lm 是浪费的:它每次都重新计算相同的矩阵运算。相反,我们可以通过使用矩阵运算一次求解系统,然后高效地将其应用于所有用户:

p <- t(qr.solve(crossprod(q)) %*% t(q) %*% t(y))

这同时计算所有 \(\hat{\mathbf{p}}_i\) 值,避免了数千个单独的模型拟合。

25.2 与 PCA 的联系

注意,在第 23.5 节中我们了解到,如果我们对矩阵 \(\mathbf{Y}\) 执行 PCA,我们得到一个变换 \(\mathbf{V}\),它允许我们重新编写:*

\[\mathbf{Y} = \mathbf{Z} \mathbf{V}^\top \]

其中 \(\mathbf{Z}\) 是主成分矩阵。

让我们在前一部分构建的 \(\mathbf{Y}\) 上执行主成分分析(PCA)并检查结果:

pca <- prcomp(y, center = FALSE)

首先,请注意,前两个主成分解释了超过 85% 的变异性*:

pca$sdev²/sum(pca$sdev²)
#> [1] 0.6939 0.1790 0.0402 0.0313 0.0303 0.0253

接下来,请注意 \(\mathbf{V}\) 的第一列*:

pca$rotation[,1]
#>            Godfather          Godfather 2           Goodfellas 
#>                0.306                0.261                0.581 
#>     Scent of a Woman      You've Got Mail Sleepless in Seattle 
#>               -0.570               -0.294               -0.300

将正值分配给动作电影,将负值分配给浪漫电影*。

第二列:

pca$rotation[,2]
#>            Godfather          Godfather 2           Goodfellas 
#>                0.354                0.377               -0.382 
#>     Scent of a Woman      You've Got Mail Sleepless in Seattle 
#>                0.437               -0.448               -0.442

这是为阿尔·帕西诺的电影编码*。

PCA 自动发现了我们使用对电影的知识推断出的结构。这不是巧合,存在一个数学联系,解释了为什么 PCA 与这些潜在模式相一致。

为了看到这一点,假设数据 \(\mathbf{Y}\) 遵循以下模型:

\[ Y_{ij} = \sum_{k=1}^K p_{ik}q_{jk} + \varepsilon_{ij}, i=1,\dots,I, \, j = 1,\dots J \mbox{ 或 } \mathbf{Y} = \mathbf{P}\mathbf{Q} ^\top + \boldsymbol{\varepsilon} $$ 带有约束条件 $$ \mathbf{Q}^\top\mathbf{Q} = \mathbf{I} \]

为了理解为什么我们需要这个约束条件,请注意,如果没有这个约束条件,模型不是唯一确定的,它是不可识别的。例如,我们可以将 \(\mathbf{P}\) 的任何列乘以一个常数 \(c > 0\),并将 \(\mathbf{Q}\) 的相应列除以相同的常数,而 \(\mathbf{P}\mathbf{Q}^\top\) 的乘积保持不变。这个约束条件消除了缩放的不确定性,并确保了分解有明确的格式。* 主成分的前 \(K\) 列和相关的旋转矩阵分别提供了 \(\mathbf{P}\)\(\mathbf{Q}\) 的估计。换句话说,PCA 可以被视为一个潜在因子模型的特例,其中潜在因子被选择为正交的,并按它们解释的方差进行排序。这解释了为什么 PCA 可以自然地恢复可解释的模式,例如类型偏好或特定演员的影响,而无需我们将其显式地编码到模型中

另一种看到这种联系的方法是通过优化。主成分分析(PCA)可以表述为最小二乘问题的解:在所有可能的 \(K\) 维数据投影中,PCA 找到最小化重建误差的那个,即原始数据 \(\mathbf{Y}\) 和其近似 \(\mathbf{P}\mathbf{Q}^\top\) 之间平方差的和。从这个意义上讲,PCA 在最小二乘意义上提供了 \(\mathbf{Y}\) 的最佳 \(K\) 阶近似。

这种双重解释,即作为因子模型和最小二乘优化器,突出了为什么 PCA 是揭示高维数据中隐藏结构如此强大的工具:它有效地压缩了数据,同时提供了捕捉主要变异来源的因子。

即使有正交约束,PCA 的解也不是完全唯一的。

还存在一个符号不确定性:我们可以翻转 \(\mathbf{P}\) 中任何列和 \(\mathbf{Q}\) 中相应列的符号(一个乘以 \(-1\),另一个也乘以 \(-1\)),而不会改变乘积 \(\mathbf{P}\mathbf{Q}^\top\)

因此,因式分解只能识别到这些符号变化为止。

25.3 案例研究:推荐系统

我们现在从小的模拟示例回到真实的 Movielens 数据集。在上一节中,我们使用一个玩具设置来说明潜在因子如何捕捉“黑帮 vs. 爱情片”或“阿尔·帕西诺 vs. 非阿尔·帕西诺”等模式。在这里,我们展示真实的电影评分中也存在类似的潜在结构,并且我们可以利用它来进一步提高预测。

从我们正则化的用户 + 电影模型(在第二十四章中介绍)的残差开始,我们可以检查在考虑整体平均值和用户及电影效应后,电影评分的相关性。我们关注一小部分知名标题,计算这些残差的相关矩阵:

#>                      Godfather Godfather II Goodfellas Scent of a Woman You've Got Mail Sleepless in Seattle
#> Godfather                 1.00         0.84       0.49             0.46           -0.36                -0.42
#> Godfather II              0.84         1.00       0.47             0.21           -0.25                -0.43
#> Goodfellas                0.49         0.47       1.00             0.04           -0.27                -0.45
#> Scent of a Woman          0.46         0.21       0.04             1.00           -0.37                -0.50
#> You've Got Mail          -0.36        -0.25      -0.27            -0.37            1.00                 0.43
#> Sleepless in Seattle     -0.42        -0.43      -0.45            -0.50            0.43                 1.00

正如模拟示例中所示,我们看到了明显的相关性模式。例如,教父教父续集好家伙具有相似的残差,而浪漫喜剧则表现出不同的行为。这表明评分受到影响潜在特征,这些特征以有意义的方式将电影和用户分组。

电影评分的潜在因子模型

这促使我们扩展早期的模型。我们现在纳入了捕捉电影和用户之间相似性的潜在因子。这使我们采用了获奖 Netflix 奖团队使用的方法的简化版本,该团队结合了潜在因子模型和在第二十四章中开发的正则化方法。

为了考虑这些交互作用,我们使用一个潜在因子模型。具体来说,我们将模型从第二十四章扩展到包括捕捉电影之间相似性的因子:

\[Y_{ij} = \mu + \alpha_i + \beta_j + \sum_{k=1}^K p_{ik}q_{jk} + \varepsilon_{ij} \]

回想一下,正如前一章所解释的,我们并没有观察到所有 \((i,j)\) 组合。将数据表示为一个 \(I \times J\) 矩阵 \(\mathbf{Y}\),其中 \(I\) 是用户,\(J\) 是电影,会产生许多缺失值。

然而,求和 \(\sum_{k=1}^K p_{ik}q_{jk}\) 可以表示为 \(I \times J\) 矩阵乘积 \(\mathbf{P}\mathbf{Q}^\top\),其中 \(\mathbf{P}\) 是一个 \(I \times K\) 矩阵,\(\mathbf{Q}\) 是一个 \(J \times K\) 矩阵。估计所有参数实际上是在 \(I \times J\) 矩阵的每个单元格中填充,为所有 \((i,j)\) 对提供预测。

因此,如果这个潜在因子模型是一个好的近似,我们实际上不需要观察每个用户-电影评分来做出预测。相反,我们只需要足够的观察评分来估计潜在因子矩阵 \(\mathbf{P}\)(用于用户)和 \(\mathbf{Q}\)(用于电影)。一旦我们有了这些矩阵,我们就可以通过计算来预测任何用户-电影组合,即使这些组合在训练数据中没有出现过:

\[\hat{y}_{ij} \approx \hat{\mu} + \hat{\alpha}_i + \hat{\beta}_j + \hat{p}_i^\top \hat{q}_j \]

正则化

由于参数数量众多且数据稀疏,特别是对于评分较少的电影,使用惩罚最小二乘法是合适的。因此,我们最小化:

\[\frac{1}{N} \sum_{i,j} \left[Y_{ij} - \left(\mu + \alpha_i + \beta_j + \sum_{k=1}^K p_{ik}q_{jk}\right)\right]² + \lambda_1 \left( \|\boldsymbol{\alpha}\|² + \|\boldsymbol{\beta}\|² \right) + \lambda_2 \sum_{k=1}^K \left( \|\mathbf{p}_k\|²+ \|\mathbf{q}_k\|² \right) \]

在这里,\(N\) 表示观察到的评分数量,\(I\) 表示用户数量,\(J\) 表示电影数量。向量 \(\boldsymbol{\alpha}\)\(\boldsymbol{\beta}\) 分别是用户和电影效应,\(\boldsymbol{\alpha} = (\alpha_1,\dots,\alpha_I)^\top\)\(\boldsymbol{\beta} = (\beta_1,\dots,\beta_J)^\top\)。向量 \(\mathbf{p}_k = (p_{1k}, \dots, p_{Ik})^\top\)\(\mathbf{q}_k = (q_{1k}, \dots, q_{Jk})^\top\) 是第 \(k\) 个潜在因子成分。回想一下,\(|\boldsymbol{\alpha}|²\) 表示平方和,\(\sum_{i=1}^I \alpha_i²\)

我们使用两种惩罚:\(\lambda_1\) 用于线性效应(\(\alpha\)\(\beta\)),\(\lambda_2\) 用于潜在因子。这使我们能够对两个组件进行不同的正则化,反映了它们在模型中的不同角色。

我们如何估计这个模型中的参数?挑战来自于 \(p\)\(q\) 都是未知的,并且它们以乘积的形式出现,使得模型在参数上是非线性的。在前面几节中,我们强调了因子分析与主成分分析(PCA)之间的联系,并展示了在完整数据的情况下,PCA 可以用来估计因子分析模型的潜在因子。

然而,推荐系统提出了一个关键复杂性:评分矩阵非常稀疏。大多数用户只对一小部分电影进行评分,因此数据矩阵有许多缺失项。PCA 和相关方法需要完全观察的矩阵,因此它们不能直接应用于此上下文。此外,虽然 PCA 提供了最小二乘估计,但在这里我们想使用 惩罚最小二乘法,这允许我们对参数进行正则化,并在某些用户或电影的数据有限时避免过拟合。

为了解决这些挑战,我们再次转向 第 24.1.7 节 中描述的 交替最小二乘法 (ALS) 算法。关键思想是交替估计 \(\mathbf{P}\)\(\mathbf{Q}\) 的每一列:固定所有列除了一个,使用惩罚最小二乘法求解那一列,然后切换到所有列。这种交替方法也自然地扩展到模型的惩罚版本,其中用户和电影效应以及潜在因子分别进行正则化。因此,ALS 已成为推荐系统中拟合潜在因子模型的标准方法之一,因为在推荐系统中,稀疏性使得直接应用 PCA 不可行。

dslabs** 包提供了 fit_recommender_model 函数,该函数实现了一个带有正则化的潜在因子模型:

fit <- with(train_set, fit_recommender_model(rating, userId, movieId, reltol=1e-6))

您可以通过运行 ?fit_recommender_model 并检查源代码来了解更多关于该函数的信息。

一旦我们有一个拟合的模型,我们就可以为测试集构建预测并计算 RMSE:

i <- test_set$userId
j <- test_set$movieId
resid <- test_set$rating - with(fit, mu + ai] + b[j] + [rowSums(p[i,]*q[j,]))
rmse(resid)
#> [1] 0.859

这提高了准确性,超过了 第二十四章 中的正则化用户 + 电影效应模型。它以简化的形式说明了 Netflix 奖获奖者如何改进预测:通过结合正则化与学习用户-电影交互中隐藏结构的潜在因子模型。

重要的是,这种改进是在没有调整惩罚项 \(\lambda_1\)\(\lambda_2\)、潜在因子数量 \(K\) 或其他后续讨论的模型设置的情况下实现的。

可视化因子

检查估计的电影因子 \(\mathbf{q}_k\) 可以发现它们是可解释的。

这些因子的估计包含在 fit$q 中。由于一些电影没有足够的评分来稳定估计潜在因子,fit_recommender_model 函数将这些电影排除在估计之外。因此,我们使用以下方法获得估计的电影因子 \(\mathbf{q}_k\)

factors <- fit$q[fit$n_item >= fit$min_ratings,] 

为了使解释更容易,我们用电影标题替换了行名(即电影 ID):

rownames(factors) <- movielens[match(rownames(factors), movieId)]$title

绘制前两个因子可以揭示几个见解。首先注意,我们早期示例中的六部电影用蓝色突出显示:我们可以看到这两个因子开始解释我们之前看到的关联,动作电影聚集在一起,爱情电影远离这些电影,而阿尔·帕西诺则位于中间某个位置:

观察具有最极端因子值(红色)的电影提供了对因子的明确解释。

第一个因子区分标志性黑暗经典

names(sort(factors[,1], decreasing = TRUE)[1:5])
#> [1] "2001: A Space Odyssey" 
#> [2] "Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb"
#> [3] "Clockwork Orange, A" 
#> [4] "Taxi Driver" 
#> [5] "Silence of the Lambs, The"

来自好莱坞大片

names(sort(factors[,1])[1:5])
#> [1] "Armageddon" 
#> [2] "Pearl Harbor" 
#> [3] "Con Air" 
#> [4] "Star Wars: Episode I - The Phantom Menace"
#> [5] "Mission: Impossible II"

第二个因子区分奇怪的文化电影

names(sort(factors[,2], decreasing = TRUE)[1:5])
#> [1] "Showgirls"         "Scary Movie"       "Leaving Las Vegas"
#> [4] "Dogma"             "Rosemary's Baby"

来自大预算史诗历史和奇幻史诗

names(sort(factors[,2])[1:5])
#> [1] "Lord of the Rings: The Two Towers, The" 
#> [2] "Lord of the Rings: The Return of the King, The" 
#> [3] "Lord of the Rings: The Fellowship of the Ring, The"
#> [4] "Dances with Wolves" 
#> [5] "Matrix, The"

这些结果表明,潜在因子捕捉到了电影类型和风格中的有意义区别,展示了潜在因子模型如何从稀疏评分数据中揭示可解释的结构。

实际考虑

fit_recommender_model 函数包括几个可调整的参数:

  • 惩罚项:默认情况下,\(\lambda_1 = 0.00005\)\(\lambda_2 = 0.0001\)。与 第 24.3 节 中的网格搜索一样,可以识别更好的选择。

  • 潜在因子的数量:默认值是 \(K = 8\),但这可能不是最优的。如果 \(K\) 太小,模型可能会错过结构。如果 \(K\) 太大,模型可能会过拟合且不稳定。

  • 每部电影的最小评分:该函数仅估计至少有 20 个评分的电影的潜在因子。这有助于避免 \(q\) 向量的不稳定估计。然而,20 可能太高或太低,这取决于数据集。

  • 收敛容忍度:默认的停止规则要求模型改进低于 reltol。降低容忍度会使结果更好,但提高容忍度通常会加快计算速度,而精度损失很小。

通过添加更多预测因子,如评分时间戳或电影类型,可以进行进一步的改进。

最后,获胜团队的突破在于认识到缺失的评分是有信息的。用户倾向于避免他们预期会不喜欢的电影,这意味着评分的缺失不是随机的。通过建模这种结构,他们实现了最先进的性能,最终导致了胜利。

25.4 奇异值分解

在潜在因子分析中常用的一种相关技术是奇异值分解(SVD)。它表明任何 \(N \times p\) 矩阵都可以写成:

\[\mathbf{Y} = \mathbf{U}\mathbf{D}\mathbf{V}^\top \]

其中 \(\mathbf{U}\) 是一个正交的 \(N \times p\) 矩阵,\(\mathbf{V}\) 是一个正交的 \(p \times p\) 矩阵,而 \(\mathbf{D}\) 是对角线上的 \(d_{1,1} \geq d_{2,2} \geq \dots \geq d_{p,p}\)。SVD 与 PCA 相关联,因为 \(\mathbf{V}\) 提供了主成分的旋转,而 \(\mathbf{U}\mathbf{D}\) 本身就是主成分。对 \(\mathbf{D}\) 的对角线元素进行平方得到平方和:

\[\mathbf{U}^\top \mathbf{D} \mathbf{U} = \mathbf{D}² \]

在 R 中,我们可以使用 svd 来计算 SVD 并确认其与 PCA 的关系:

x <- matrix(rnorm(1000), 100, 10)
pca <- prcomp(x, center = FALSE)
s <- svd(x)
 all.equal(pca$rotation, s$v, check.attributes = FALSE)
#> [1] TRUE
all.equal(pca$sdev², s$d²/(nrow(x) - 1))
#> [1] TRUE
all.equal(pca$x, s$u %*% diag(s$d), check.attributes = FALSE)
#> [1] TRUE

作为一个优化点,请注意 s$u %*% diag(s$d) 可以更高效地写成:

sweep(s$u, 2, s$d, "*")

奇异值分解是现代数据分析中最广泛使用的工具之一,它出现在信号处理、基因组学、图像压缩、自然语言处理、协同过滤和数值优化等众多领域。每次我们想要降低维度、揭示隐藏结构或处理大型、噪声矩阵时,SVD 都提供了一个原则性的基础。事实上,许多最成功的机器学习算法,包括 Netflix Prize 中使用的潜在因素方法,都是直接建立在它之上的。

25.5 练习

  1. 检查因素 3、4、5、6、7 和 8 的极端值中的电影名称。描述你观察到的任何有趣模式。

  2. 使用几个不同的值拟合推荐模型

lambdas <- expand.grid(lambda_1 = 10^-(3:6), lambda_2 = 10^-c(2:5))

哪一对在验证集上给出了最低的 RMSE?简要解释为什么过少的或过多的惩罚会损害性能。

  1. 使用 \(K = 2, 4, 8, 16, 32\) 来拟合模型。绘制验证 RMSE 与 \(K\) 的关系图。在什么点上收益趋于平稳?根据图表,你推荐哪个 \(K\) 值?

  2. 使用练习 2 和 3 的结果,对每个参数 \(\lambda_1\)\(\lambda_2\)\(K\) 的三个值进行网格搜索,总共有 \(3 \times 3 \times 3 = 27\) 种组合。报告最低的 RMSE。

  3. 默认模型仅对至少有 20 个评分的电影估计电影因素。尝试阈值 5、10、20 和 50。RMSE 如何变化?哪个阈值似乎在稳定性和覆盖范围之间取得了最佳平衡?

  4. 使用 reltol = 10^-seq(1, 8, 0.5) 作为停止规则运行 ALS 算法。记录迭代的次数和最终的 RMSE。更紧密的收敛是否值得额外的计算?

在接下来的练习中,我们使用奇异值分解(SVD)来估计学校表现示例中的潜在因素。

我们模拟了 100 名学生在 24 门不同课程中的成绩,这些课程分为三个学科领域:数学、科学和艺术。列表示单个课程(例如,微积分、线性代数、物理、化学、音乐理论、绘画),标记为 Math_1 …, Math_kScience_1 …, Science_k,和 Arts_1 …, Arts_k

每个条目代表该测试的整体平均分之上/之下:

  • 0 = 平均(C),

  • 25 = 非常高(A+),

  • −25 = 非常低(F)。

我们假设以下模型成立:

\[ Y_{ij} = \mu_{ij} + \varepsilon_{ij} $$ 其中 $\mu_{ij}$ 是学生在班级 $j$ 的预期成绩,$\varepsilon_{ij}$ 是由于随机机会在回答考试问题(例如猜测或愚蠢的错误)而产生的随机变化。我们假设 $\varepsilon_{ij}$ 之间相互独立。我们将使用以下方式模拟数据: ```r set.seed(1987) n <- 100; k <- 8 s <- 64*matrix(c(1, .85, .5, .85, 1, .5, .5, .5, 1), 3, 3) m <- MASS::mvrnorm](https://rdrr.io/pkg/MASS/man/mvrnorm.html)(n, [rep(0, 3), s) m <- m[order(rowMeans(m), decreasing = TRUE), ] %x% matrix(rep(1, k), nrow = 1) y <- m + matrix(rnorm(n * k * 3, 0, 3), n, k * 3) colnames(y) <- paste(rep(c("Math", "Science", "Arts"), each = k), 1:k, sep = "_") ``` 我们希望尽可能简单地总结每个学生的 24 个测试分数。学生在所有科目中是否表现相同,或者我们看到了哪些模式?在某一科目中表现良好是否预示着在另一科目中也表现良好?SVD 如何帮助我们看到这种结构? 我们将展示只有三对向量可以解释这个 $100 \times 24$ 矩阵的大部分变化。 首先定义一个辅助函数来可视化矩阵: ```r nice_image <- function(x, zlim = range(x), cex.axis = 0.5, ...) { cols <- seq_len(ncol(x)) rows <- seq_len(nrow(x)) image(cols, rows, t(x[rev(rows), , drop = FALSE]), col = rev(colorRampPalette(RColorBrewer::brewer.pal(9, "RdBu"))(500)), xaxt = "n", yaxt = "n", xlab = "", ylab = "", zlim = zlim, ...) abline(h = rows + 0.5, v = cols + 0.5) axis(1, at = cols, labels = colnames(x), las = 2, cex.axis = cex.axis) } ``` 7. 使用 `nice_image(y)` 可视化 100 名学生的 24 个测试分数。以下哪个选项最能描述你所看到的内容? 1. 测试分数彼此独立。 1. 最强的学生出现在图像的顶部,24 个测试似乎分为三个科目块。 1. 数学好的学生显然在科学方面表现不佳。 1. 数学好的学生在艺术方面显然表现不佳。 8. 现在查看 24 个测试的相关矩阵: ```r nice_image(cor(y), zlim = c(-1, 1)) axis(side = 2, at = 1:ncol(y), labels = rev(colnames(y)), las = 2, cex.axis = 0.5) ``` 哪个选项最能描述相关性模式? 1. 测试分数是独立的。 1. 数学与科学高度相关,但艺术与任何事物都不相关。 1. 同一科目内的测试是相关的,但科目之间没有相关性。 1. 所有测试之间都有相关性,每个科目内部的相关性更强,数学与科学之间的相关性比与艺术之间的相关性更强。 9. 首先计算 `y` 中的总平方和: ```r tss_y <- sum(colSums(y²)) ``` 现在计算 SVD $$ \mathbf{Y} = \mathbf{U}\mathbf{D}\mathbf{V}^\top. \]

使用 SVD 在 R 中验证:

  • \(\mathbf{Y}\mathbf{V} = \mathbf{U}\mathbf{D}\) 中所有元素的平方和与 \(\mathbf{Y}\) 中的相同。

  • 奇异值的平方和(\(\mathbf{D}\) 的对角线元素)也等于 tss_y

在 R 中检查这些等式,并确保你理解为什么它们必须成立(提示:\(\mathbf{V}\) 是正交的,SVD 保持总平方和)。

  1. \(\mathbf{Z} = \mathbf{Y}\mathbf{V} = \mathbf{U}\mathbf{D}\)
  • 绘制 \(\mathbf{Y}\) 的每一列平方和的平方根与列索引的对比图。

  • 在相同的尺度上,绘制 \(\mathbf{Z}\) 的每一列平方和的平方根。

对你所看到的内容进行评论。列之间的变异性是否在 \(\mathbf{Z}\) 的前几列变得更加集中?

  1. 在练习 10 中,你使用了 colSums 计算了 \(\mathbf{Z} = \mathbf{Y}\mathbf{V} = \mathbf{U}\mathbf{D}\) 的列平方和。然而,我们实际上已经从 SVD 中知道了这些数字:
  • 在 R 中展示,奇异值的平方 \((d_{kk}²)\) 等于 \(\mathbf{Z}\) 的列平方和。

  • 通过绘制 \(\mathbf{Z}\) 的列平方和的平方根与 \(\mathbf{D}\) 的对角线元素之间的对比来数值上确认这一点。

  1. \(\mathbf{Z} = \mathbf{Y}\mathbf{V} = \mathbf{U}\mathbf{D}\) 的列是 \(\mathbf{Y}\) 的主成分(PCs)。\(\mathbf{D}\) 的对角线告诉我们每个 PC 解释了多少变异性。

计算每个 PC 解释的方差百分比:

s <- svd(y)
plot(1:ncol(y), s$d²/sum(s$d²)*100, xlab = "PC", ylab = "Variance explained (%)")

你应该看到第一个 PC 解释了超过 60% 的变异性。将第一个 PC 与每个学生的平均成绩进行比较:

pc1 <- s$u[, 1]*s$d[1]
plot(rowMeans(y), pc1)

你将如何用通俗易懂的语言解释第一个主成分?(记住符号是任意的,所以高 PC1 可以对应于优秀学生或差学生,取决于符号。)

  1. SVD 可以表示为

\[\mathbf{Y} = \mathbf{u}_1 d_{1,1} \mathbf{v}_1^\top + \mathbf{u}_2 d_{2,2} \mathbf{v}_2^\top + \dots + \mathbf{u}_p d_{p,p} \mathbf{v}_p^\top, \]

其中 \(\mathbf{u}_k\)\(\mathbf{U}\) 的第 \(k\) 列,\(d_{k,k}\)\(\mathbf{D}\) 的第 \(k\) 个对角线元素,\(\mathbf{v}_k\)\(\mathbf{V}\) 的第 \(k\) 列。

仅使用第一项来近似所有学生成绩 \(\mu_{ij}\)

\[ \hat{\mu}_{ij} = \mathbf{u}_1 d_{1,1} \mathbf{v}_1^\top. $$ 计算残差矩阵 $\mathbf{r} = \mathbf{y} - \mathbf{u}_1 d_{1,1} \mathbf{v}_1^\top$,并使用 `nice_image` 可视化残差及其与相关性的关系。你还在残差中看到科目级别的相关性结构吗? 14. 现在用前两个成分来近似成绩: $$ \hat{\mu}_{ij} = \sum_{k=1}² \mathbf{u}_k d_{k,k} \mathbf{v}_k^\top. \]

并重复第 13 题中生成的探索性绘图。大部分的相关性结构已经消失,还是仍然存在个体内的模式?

  1. 现在重复第 14 题但使用前三个成分:

\[\hat{\mu}_{ij} = \sum_{k=1}³ \mathbf{u}_k d_{k,k} \mathbf{v}_k^\top. \]

现在的相关性几乎消失了?为了捕捉这 24 门课程的主结构 \(\mu_{ij}\),似乎我们需要多少个因子?

  1. 最后,检查前三个右奇异向量(\(\mathbf{V}\) 的列):
v <- s$v
rownames(v) <- colnames(y)
nice_image(t(v[, 1:3]))

每一行对应一门课程(例如,Math_1Science_3Arts_5),每一列对应一个因子(PC1,PC2,PC3)。你将如何解释 \(\mathbf{V}\) 的前三列?请用通俗易懂的语言为这三个因子各写一个简短的解释。

  1. 基于第 16 题中观察到的模式,你将如何解释学生 \(i\) 的值 \(u_{i1}\),,\(u_{ip}\)
  • 是在粗略量化整体学术能力吗?

  • 是在量化学生STEM 与艺术方面的进步程度吗?

  • 是在量化学生数学与科学方面的进步程度吗?

  • 其中一些只是随机噪声吗?

推荐阅读

原文:rafalab.dfci.harvard.edu/dsbook-part-2/highdim/reading-highdim.html

  1. 高维数据

  2. 推荐阅读

  • **Hefferon, J. (2017). 线性代数.

    一本为初学者设计的免费且易于获取的开源教科书,包含清晰的解释、示例和大量练习。非常适合自学者和寻求建立直觉和实际技能的学生。

    在线可用

  • **Strang, G. (2019). 线性代数与数据学习.

    专注于线性代数在现代机器学习和数据科学中的作用。是统计和算法方法的实用伴侣。

  • **Seber, G. A. F. (2003). 线性回归分析 (第 2 版).

    对回归的简洁且数学上严谨的探索,基于矩阵代数。提供了对诊断、模型拟合、选择和预测的扩展覆盖。

  • **James, G., Witten, D., Hastie, T., & Tibshirani, R. (2021). 统计学习导论 (第 2 版).

    一本高度易读的统计学习入门书籍,包括对正则化技术(如岭回归和 Lasso)的清晰解释和示例。可在网上免费获取

  • **Hastie, T., Tibshirani, R., & Friedman, J. (2009). 统计学习的要素:数据挖掘、推理与预测 (第 2 版).

    正则化和其他机器学习技术的权威参考书。更理论化和深入,适合寻求更深入数学理解的读者。

  • Koren, Y., Bell, R., & Volinsky, C. (2009). Netflix Prize 的 BellKor 解决方案. 对赢得 Netflix Prize 的模型集进行了技术性但易于理解的解释,详细介绍了矩阵分解、邻域模型和混合集成技术。

    在线可用

机器学习

原文:rafalab.dfci.harvard.edu/dsbook-part-2/ml/intro-ml.html

机器学习在各种应用中取得了显著的成功。这些应用范围从邮局使用机器学习读取手写邮编到开发像苹果的 Siri 这样的语音识别系统。其他重要的进步包括垃圾邮件和恶意软件检测、房价预测算法,以及自动驾驶汽车的持续发展。

人工智能(AI)* 领域已经发展了几十年。传统的 AI 系统,包括一些下棋机器,通常依赖于基于预设规则和知识表示的决策。然而,随着数据可用性的出现,机器学习变得突出。它侧重于通过用数据训练的算法进行决策。近年来,在许多情况下,AI 和机器学习这两个术语被互换使用,尽管它们有明显的区别。AI 广泛地指代表现出智能行为的系统或应用,包括基于规则的途径和机器学习。机器学习专门涉及从数据中学习以做出决策或预测。

在本书的这一部分,我们将深入探讨机器学习的概念、思想和方法论。我们还将通过识别手写数字的例子来展示它们的实际应用,这是一个经典问题,它展示了机器学习技术的力量和实用性。

26 符号和术语

原文:rafalab.dfci.harvard.edu/dsbook-part-2/ml/notation-and-terminology.html

  1. 机器学习

  2. 26 符号和术语

在 第 21.2 节 中,我们介绍了 MNIST 手写数字数据集。我们现在回到这个数据集,并使用识别手写数字的任务作为我们接下来几章的运行示例。本节的目标还不是开发一个完整的算法,而是介绍我们将在这部分书中依赖的机器学习符号和术语。

手写数字识别是一个经典的机器学习问题。几十年前,邮局分拣邮件需要人工阅读信封上写的每个邮政编码。今天,机器自动完成这项任务:计算机读取邮政编码的数字化图像,机器人据此分拣信件。

在接下来的页面中,我们将描述如何将这个问题形式化为数学问题,定义我们需要处理的对象,并介绍用于描述输入、输出、特征、预测和学习算法的语言。这将为本章后面开发的机器学习方法奠定基础。

26.1 术语

在机器学习中,数据以我们想要预测的 结果 和我们将用于预测结果的 特征 的形式出现。我们构建算法,当不知道结果时,以特征值作为输入并返回对结果的预测。机器学习方法是用我们知道结果的数据集来 训练 算法,然后在未来应用此算法来预测我们不知道结果的情况。

预测问题可以分为分类和连续结果。分类结果可以是 \(K\) 个类别中的任何一个。类别的数量在应用中可能有很大的变化。例如,在数字识别数据中,\(K=10\),类别是数字 0, 1, 2, 3, 4, 5, 6, 7, 8, 和 9。在语音识别中,结果是所有可能的单词或短语,这是我们试图检测的内容。垃圾邮件检测有两个结果:垃圾邮件或非垃圾邮件。在这本书中,我们用索引 \(k=1,\dots,K\) 表示 \(K\) 个类别。然而,对于二元数据,我们将使用 \(k=0,1\),以便于我们稍后展示的数学便利。

26.2 符号

我们将用 \(Y\) 表示结果,用 \(X_1, \dots, X_p\) 表示特征。这些特征有时也被称为 预测器协变量,我们将这些术语视为同义词。

构建机器学习算法的第一步是明确识别结果和特征。在第 21.2 节中,我们展示了每个数字化的图像 \(i\) 都与一个分类结果 \(Y_i\) 和一组特征 \(X_{i1}, \dots, X_{ip}\) 相关联,其中 \(p=784\)。为了方便起见,我们经常使用粗体符号 \(\mathbf{X}_i = (X_{i1}, \dots, X_{ip})^\top\) 来表示预测因子的向量,遵循第 21.1 节中引入的符号。

当提到一组任意的特征而不是一个特定的图像时,我们省略索引 \(i\),并简单地使用 \(Y\)\(\mathbf{X} = (X_1, \dots, X_p)\)。我们使用大写字母来强调这些是随机变量。观测值用小写字母表示,例如 \(Y=y\)\(\mathbf{X} = \mathbf{x}\)。在实践中,当编写代码时,我们通常总是使用小写字母。

为了表示预测,我们使用帽子符号,就像我们用于参数估计一样。对于具有预测因子 \(\mathbf{x}_i\) 的特定观测 \(i\),预测写作 \(\hat{y}_i\)。对于任意的预测因子向量 \(\mathbf{x}\),我们写作预测 \(\hat{y}(\mathbf{x})\),强调预测是预测因子的函数。

26.3 机器学习挑战

机器学习任务是要构建一个算法,根据任何特征的组合预测结果。乍一看,这似乎是不可能的,但我们将从非常简单的例子开始,逐步构建到更复杂的案例。我们从单个预测因子开始,然后扩展到两个预测因子,最终解决涉及数千个预测因子的现实世界挑战。

一般设置如下。我们有一系列特征和一个我们想要预测的未知结果:

结果 特征 1 特征 2 特征 3 \(\dots\) 特征 p
? \(X_1\) \(X_2\) \(X_3\) \(\dots\) \(X_p\)

要构建一个从观测特征 \(X_1=x_1, X_2=x_2, \dots, X_p=x_p\) 预测结果的模型,我们需要一个已知结果的数据库:

结果 特征 1 特征 2 特征 3 \(\dots\) 特征 p
\(y_{1}\) \(x_{1,1}\) \(x_{1,2}\) \(x_{1,3}\) \(\dots\) \(x_{1,p}\)
\(y_{2}\) \(x_{2,1}\) \(x_{2,2}\) \(x_{2,3}\) \(\dots\) \(x_{2,p}\)
\(\vdots\) \(\vdots\) \(\vdots\) \(\vdots\) \(\ddots\) \(\vdots\)
\(y_n\) \(x_{n1}\) \(x_{n2}\) \(x_{n3}\) \(\dots\) \(x_{np}\)

当结果为连续时,我们将该任务称为预测。模型返回一个函数 \(f\),该函数为任何特征向量生成一个预测 \(\hat{y}(\mathbf{x}) = f(x_1, x_2, \dots, x_p)\)。我们称 \(y\) 为实际结果。预测 \(\hat{y}(\mathbf{x})\) 很少是精确的,所以我们通过误差来衡量准确性,定义为 \(y - \hat{y}(\mathbf{x})\)

当结果为分类时,任务被称为分类。模型产生一个决策规则,规定应该预测 \(K\) 个可能类别中的哪一个。

通常,一个分类模型为每个类别输出一个分数,用 \(f_k(x_1, \dots, x_p)\) 表示类别 \(k\)。为了在预测变量向量 \(\mathbf{x} = (x_1,\dots,x_p)\) 上进行预测,我们选择分数最大的类别:

\[\hat{y}(\mathbf{x}) = \arg\max_{k \in {1,\dots,K}} f_k(\mathbf{x}). \]

在二元情况下(\(K=2\)),这通常使用一个截止值来表示:如果 \(f_1(x_1,\dots,x_p) > c\),我们预测类别 1,否则我们预测类别 2。在这里,预测与真实类别相比,只是正确或错误

值得注意的是,术语在教科书和课程中有所不同。有时预测用于分类和连续结果。回归一词也用于连续结果,但在这里我们避免使用它,以防止与线性回归混淆。在大多数情况下,结果是否为分类或连续将很清楚,因此我们将根据需要简单地使用预测或分类。

26.4 统计学与机器学习语言对比

到本书的这一部分,我们使用的术语来自统计学:模型估计量参数。当我们过渡到机器学习时,我们会遇到新的词汇。其中一些新术语与熟悉的统计思想重叠,而另一些则反映了两个领域之间的重点差异。在本节中,我们明确了将在机器学习章节中使用的术语。

在统计学中,模型指的是数据的数学生成描述,例如,具有代表预测变量与结果之间关系的系数的线性回归模型。在机器学习中,模型一词的使用方式类似,但更侧重于实用性:模型是我们用来进行预测的拟合对象。例如,当我们拟合逻辑回归来预测二元结果时,得到的估计回归函数就是机器学习者所说的模型

关键的区别在于算法一词。在统计学中,算法只是用于计算估计值的计算程序。然而,在机器学习中,算法一词通常被更广泛地使用。它可以指用于拟合模型的计算过程,例如,用于估计逻辑回归中系数的迭代步骤,但它也通常指整体的预测策略本身。因此,在机器学习实践中,人们会谈论“逻辑回归算法”,尽管在统计学中,逻辑回归更多地被视为模型而不是算法。这种术语的双重使用在该领域是标准的,我们遵循这一惯例。

最后,机器学习文献通常使用 方法方法程序 作为将预测变量映射到预测的任何系统方法的通用术语。在早期章节中,我们介绍了回归和逻辑回归作为统计模型。从机器学习的角度来看,这些也是 预测方法,我们从数据中学习到的函数,可以应用于新案例。

另一对重要的术语是 监督学习无监督学习。在监督学习中,目标是如上所述:预测在训练数据中可以观察到的结果。回归和逻辑回归属于这一类,我们将在下一章讨论的大多数机器学习技术也将是监督方法。相比之下,在无监督学习中,没有结果变量;目标是揭示预测变量本身的结构。聚类是最常见的例子。我们将首先研究监督学习,因为它与我们的主要目标预测直接相关,然后在最后一章简要描述无监督学习。

26.5 练习

  1. 你被给了一个包含对 \((x_i, y_i)\) 的数据集,其中 \(x_i\) 是一个描述手写数字图像宽度和高度的二维特征向量,而 \(y_i\) 表示数字是否是“0”。

使用本章引入的符号写出以下对象:

  • 观察到的特征集。

  • 对应的输出集。

  • 一个预测函数,它接受一个新的特征向量 \(x\) 并预测它是否是“0”。

  • 由阈值 \(c\) 定义的决定规则,写成数学函数的形式。

  1. 假设我们想要构建一个算法来预测一个数字图像是否是“4”。让图像由一个特征向量 \(x\) 总结,预测函数为 \(\hat{f}(x)\),它返回一个介于 0 和 1 之间的数字。
  • 写出一个使用截止值 \(c\) 将预测 \(\hat{f}(x)\) 转换为类别标签 \(\hat{y}(x) \in \{0,1\}\)决策规则

  • 使用本章引入的符号,写出可以通过改变截止值 \(c\) 创建的所有 决策规则 集合。

  • 简要解释一句话或两句话,说明改变截止值如何影响假阳性和假阴性。

  1. \(\hat{p}(x)\) 是一个估计图像是“1”的概率的模型。考虑以下具有截止值(c)的决定规则:

\[\hat{y}(x) = \begin{cases} 1 & \text{if } \hat{p}(x) > c \\ 0 & \text{otherwise.} \end{cases} \]

  • 写出一个单次观察(\(x_i, y_i\))的 0-1 损失。

  • 写出大小为(m)的测试集上的平均损失。

  • 如果 \(c\) 增加,模型是否更频繁地预测“1”或更少?简要解释使用符号。

27 性能指标

原文:rafalab.dfci.harvard.edu/dsbook-part-2/ml/evaluation-metrics.html

  1. 机器学习

  2. 27 性能指标

在我们开始描述优化算法构建方式的方法之前,我们首先需要定义当我们说一种方法比另一种方法更好时,我们指的是什么。在本节中,我们专注于描述评估机器学习算法的方式。具体来说,我们需要量化我们所说的“更好”的含义。

对于我们对机器学习概念的第一次介绍,我们将从一个无聊且简单的例子开始:如何使用身高预测性别。随着我们解释如何使用这个例子构建预测算法,我们将开始奠定理解机器学习所需的第一块基石。不久,我们将面对更有趣的挑战。

我们介绍了caret包,它提供了有用的函数来促进 R 中的机器学习,我们将在第 31.2 节中更详细地描述它。在我们的第一个例子中,我们使用dslabs包提供的高度数据。

library(caret)
library(dslabs)

我们首先指出,heights$sex提供了结果(Y),而heights$height是我们的预测变量(X)。为了简化本章中使用的代码,我们重命名了变量:

names(heights) <- c("y", "x")

在这个例子中,我们有一个单一的预测变量,结果为分类:每个观测值要么是Male,要么是Female。由于男性和女性之间的平均身高差异相对于每个组内的变异性是适度的,我们不应期望用非常高的准确性预测\(Y\)。但我们能否比随机猜测做得更好?为了回答这个问题,我们首先需要一个量化的方法来定义“更好”的含义。

在 R 中的机器学习应用中,尤其是在使用caret包时,分类结果应存储为因子。这样做可以确保它们被视为标签而不是数值,从而防止将数值代码错误地解释为定量值时出现的错误。

对于二元结果,caret将第一个因子级别视为\(Y=1\),第二个级别视为\(Y=0\)。因为 R 默认按字母顺序分配因子级别,在我们的例子中这意味着女性编码为\(Y=1\),男性编码为\(Y=0\)。*## 27.1 训练集和测试集

最终,机器学习算法的评估标准是它在现实世界中使用全新的数据集时的表现。然而,在开发算法时,我们通常有一个已知结果的数据集,就像我们对身高那样:我们知道数据集中每个学生的性别。因此,为了模仿最终的评估过程,我们通常将数据分成两部分,并假装我们不知道其中一部分的结果。我们在完成算法构建后停止假装不知道结果来评估算法,但只有在构建完成后。我们将我们知道结果并用于开发算法的那组数据称为训练集。我们将假装不知道结果的那组数据称为测试集

生成训练集和测试集的标准方法是通过随机分割数据。caret包包括一个名为createDataPartition的函数,它帮助我们生成用于将数据随机分割成训练集和测试集的索引:

set.seed(2010)
train_index <- createDataPartition(heights$y, times = 1, p = 0.5, list = FALSE)

times参数用于定义要返回多少个随机索引样本,p参数用于定义索引代表数据集的比例,而list参数用于决定我们是否希望返回索引列表。

我们可以使用createDataPartition函数调用的结果来定义训练集和测试集,如下所示:

train_set <- heights[train_index, ]
test_set <- heights[-train_index, ]

我们现在将使用训练集开发一个算法。一旦我们完成算法的开发,我们将冻结它,并使用测试集来评估它。当结果为分类时,评估算法的最简单方法就是简单地报告在测试集中正确预测的案例比例。这个指标通常被称为总体准确率

27.2 总体准确率

为了演示总体准确率的使用,我们将构建两个相互竞争的算法并比较它们。

让我们先开发最简单的机器算法:猜测结果。

y_hat <- factor(sample(c("Female", "Male"), nrow(test_set), replace = TRUE))

请注意,我们完全忽略了预测变量,只是在猜测性别。

总体准确率*简单地定义为预测正确的总体比例:

mean(y_hat == test_set$y)
#> [1] 0.482

不出所料,我们的准确率大约是 50%。我们只是在猜测!

我们能做得更好吗?探索性数据分析表明我们可以,因为平均而言,男性的身高略高于女性。但我们如何利用这个洞察力呢?让我们尝试另一种简单的方法:如果身高低于平均男性身高两个标准差,则预测 1(女性):

cutoff <- with(train_set, mean(x) - 2*sd(x))
y_hat <- factor(ifelse(test_set$x <= cutoff, "Female", "Male"))

准确率从 0.5 上升到大约 0.8:

mean(test_set$y == y_hat)
#> [1] 0.771

但我们能否做得更好?在上面的例子中,我们使用了 62 的截止值,但我们可以检查其他截止值所获得的准确性,然后选择提供最佳结果的那个值。但请记住,我们只应使用训练集来优化截止值:测试集仅用于评估。尽管对于这个简单的例子来说这不是一个大问题,但稍后我们将了解到,在训练集上评估算法可能会导致过拟合,这通常会导致过于乐观的评估。

在这里,我们检查了 10 个不同截止值的准确性,并选择了产生最佳结果的那个:

cutoffs <- seq(61, 70)
accuracy <- sapply(cutoffs, function(cutoff){
 y_hat <- factor(ifelse(train_set$x <= cutoff, "Female", "Male"))
 mean(y_hat == train_set$y)
})

我们可以绘制一个图,显示训练集上男性和女性的准确性:

我们看到最大值是:

max(accuracy)
#> [1] 0.832

这个值远高于 0.5。产生这个准确性的截止值是:

best_cutoff <- cutoffs[which.max(accuracy)]
best_cutoff
#> [1] 64

现在我们可以将这个截止值测试在我们的测试集上,以确保我们的准确性不是过于乐观的:

y_hat <- factor(ifelse(test_set$x <= best_cutoff, "Female", "Male"))
mean(y_hat == test_set$y)
#> [1] 0.821

我们发现它略低于训练集观察到的准确性。这是因为我们使用了训练集来选择截止值,所以存在一些过度训练。然而,我们的基于截止值的方法仍然比猜测要好。而且通过在一个我们没有训练过的数据集上进行测试,我们知道我们的改进不是由于过度训练。

27.3 混淆矩阵

我们在上一节中开发的预测规则预测如果学生的身高低于 64 英寸,则为女性。鉴于平均女性的身高约为 64 英寸,这个预测规则似乎是不正确的。发生了什么?如果一个学生的身高与平均女性相同,我们不应该预测为女性吗?

一般而言,整体准确性可能会误导。为了理解原因,我们首先构建一个混淆矩阵,这是一个表格,它统计了每种预测和真实结果的组合出现的频率。在 R 中,我们可以使用table创建这个表格,但caret包提供了一个方便的函数confusionMatrix,它不仅计算混淆矩阵,还报告了我们将稍后使用的几个额外的性能指标:

cm <- confusionMatrix(data = y_hat, reference = test_set$y)
cm$table
#>           Reference
#> Prediction Female Male
#>     Female     54   29
#>     Male       65  377

如果我们仔细研究这个表格,就会发现一个问题。如果我们分别计算每个性别的准确性(在下一节中,我们将解释,在这个上下文中,灵敏度特异性分别等同于女性和男性的准确性),我们得到:

cm$byClass[c("Sensitivity", "Specificity")]
#> Sensitivity Specificity 
#>       0.454       0.929

我们注意到一个不平衡:预测为男性的女性太多。我们几乎将一半的女性错误地标记为男性!那么我们的整体准确性怎么会这么高呢?这是因为患病率,定义为数据中\(Y=1\)的比例,很低。这些身高是从三个数据科学课程中收集的,其中两个课程的男性入学率更高:

cm$byClass["Prevalence"]
#> Prevalence 
#>      0.227

因此,在计算整体准确性时,对女性犯的错误的高比例被对男性正确预测的收益所抵消。

这种类型的偏差在实践中实际上可能是一个大问题。如果你的训练数据以某种方式存在偏差,你很可能会开发出同样存在偏差的算法。我们使用了测试集的事实并不重要,因为它也是从原始的偏差数据集中推导出来的。这就是我们在评估机器学习算法时除了整体准确率之外还要查看其他指标的原因之一。

有几种指标我们可以用来评估算法,这样流行度就不会影响我们的评估,并且这些都可以从混淆矩阵中推导出来。使用整体准确率的一般改进是分别研究 敏感性特异性

27.4 敏感性和特异性

为了定义敏感性和特异性,我们需要一个二元结果。当结果为分类时,我们可以通过关注一个感兴趣的特定类别来使用这些术语。例如,在一个数字识别任务中,我们可能会问:正确识别数字 2 而不是其他任何数字的特异性是什么?一旦我们选择了一个特定类别,我们就将那个类别中的观察值视为阳性案例 (\(Y=1\)),而所有其他观察值视为阴性案例 (\(Y=0\))。这种二元框架使我们能够以通常的方式计算敏感性和特异性。

通常,敏感性 定义为算法在实际结果为正时预测阳性结果的能力:\(\hat{Y}=1\)\(Y=1\)。因为一个将所有东西都称为阳性的算法 (\(\hat{Y}=1\) 不论什么情况) 具有完美的敏感性,这个指标本身不足以判断一个算法。

因此,我们也考察 特异性,这通常定义为算法在实际结果为负 \(Y=0\) 时预测负 \(\hat{Y}=0\) 的能力。我们可以以下述方式总结:

  • 高敏感性:\(Y=1 \implies \hat{Y}=1\)

  • 高特异性:\(Y=0 \implies \hat{Y} = 0\)

虽然上述内容通常被认为是特异性的定义,但另一种思考特异性的方式是考虑实际为正的阳性呼叫的比例:

  • 高特异性:\(\hat{Y}=1 \implies Y=1\)

为了提供精确的定义,我们命名混淆矩阵的四个条目:

实际为阳性 实际为阴性
预测为阳性 真阳性 (TP) 假阳性 (FP)
预测为阴性 假阴性 (FN) 真阴性 (TN)

敏感性* 通常通过 \(TP/(TP+FN)\) 来量化,这是实际阳性(第一列 = \(TP+FN\))的比例,被称为阳性 (\(TP\))。这个量被称为 真正的阳性率 (TPR) 或 召回率

特异性定义为 \(TN/(TN+FP)\) 或称为负例的比例(第二列 = \(FP+TN\)),这些被称为负例 (\(TN\))。这个量也称为真正的负例率 (TNR)。

另一种量化 特异度 的方法是 \(TP/(TP+FP)\) 或实际为正例(\(TP\))的预测结果(第一行或 \(TP+FP\))的比例。这个量被称为 阳性预测值(PPV),也称为 精确度。请注意,与 TPR 和 TNR 不同,精确度取决于发病率,因为高发病率意味着即使猜测也能获得更高的精确度。

多种名称可能会令人困惑,因此我们包括一个表格来帮助我们记住这些术语。该表格包括一个列,如果我们将比例视为概率,则显示定义。

衡量指标 名称 1 名称 2 定义 概率表示
灵敏度 真阳性率 召回率 \(\frac{\mbox{TP}}{\mbox{TP} + \mbox{FN}}\) \(\mathrm{Pr}(\hat{Y}=1 \mid Y=1)\)
特异度 真阴性率 1-假阳性率 \(\frac{\mbox{TN}}{\mbox{TN}+\mbox{FP}}\) \(\mathrm{Pr}(\hat{Y}=0 \mid Y=0)\)
特异度 阳性预测值 精确度 \(\frac{\mbox{TP}}{\mbox{TP}+\mbox{FP}}\) \(\mathrm{Pr}(Y=1 \mid \hat{Y}=1)\)

caret** 函数 confusionMatrix 在我们定义哪个类别是正类(\(Y=1\))后为我们计算所有这些指标。该函数期望输入因子,第一级被视为正结果,尽管可以通过 positive 参数重新定义。

如果你将此输入 R:

cm <- confusionMatrix(data = y_hat, reference = test_set$sex)
print(cm)

你将看到几个指标,包括准确率、灵敏度、特异性和阳性预测值(PPV)。

你可以直接访问这些指标,例如,像这样:

cm$overall["Accuracy"]
#> Accuracy 
#>    0.821
cm$byClass[c("Sensitivity","Specificity", "Prevalence", "Pos Pred Value")]
#>    Sensitivity    Specificity     Prevalence Pos Pred Value 
#>          0.454          0.929          0.227          0.651

我们可以看到,尽管灵敏度相对较低,但仍然可能实现较高的整体准确率。正如我们上面所暗示的,这种情况发生的原因是由于低发病率(0.23):女性的比例较低。由于发病率低,未能正确预测女性(灵敏度低)并不会像未能正确预测男性(特异度低)那样大幅降低整体准确率。这是一个为什么检查灵敏度和特异度而不仅仅是准确率很重要的例子。

在将此算法应用于通用数据集之前,我们需要问自己,总体人群中的发病率是否会与我们的训练数据集相同。

27.5 平衡准确率和 \(F_1\) 分数

尽管我们通常推荐研究特异度和灵敏度,但有时有一个数字总结是有用的,例如,用于优化目的。一个比整体准确率更受欢迎的指标是特异度和灵敏度的平均值,称为 平衡准确率。由于特异度和灵敏度是比率,因此计算 调和平均数 更为合适。事实上,\(F_1\)-分数,一个广泛使用的数字总结,是精确度和召回率的调和平均数:

\[\frac{1}{\frac{1}{2}\left(\frac{1}{\mbox{召回率}} + \frac{1}{\mbox{精确度}}\right) } = 2 \times \frac{\mbox{精确度} \cdot \mbox{召回率}} {\mbox{精确度} + \mbox{召回率}}. \]

\(F_1\) 分数可以调整以不同方式权衡特异性和灵敏度。这在实践中很有用,因为根据上下文,某些类型的错误可能比其他类型的错误代价更高。例如,在飞机安全的情况下,最大化灵敏度比最大化特异性更重要:未能预测飞机在坠毁前发生故障是一个代价更高的错误,而实际上飞机处于完美状态时将飞机停飞。在一个死刑刑事案件中,情况正好相反,因为假阳性可能导致无辜的人被处决。

为了适应 \(F_1\),我们定义一个权重 \(\beta\) 来表示灵敏度相对于特异性的重要性,并考虑加权调和平均:

\[\frac{1}{\frac{\beta²}{1+\beta²}\frac{1}{\mbox{召回率}} + \frac{1}{1+\beta²}\frac{1}{\mbox{精确率}} } \]

caret** 包中的 F_meas 函数使用默认的 \(\beta\) 值为 1 来计算这个摘要。

让我们重新构建我们的预测算法,但这次是最大化 F 分数而不是整体准确性:

cutoffs <- seq(61, 70)
F_1 <- sapply(cutoffs, function(cutoff){
 y_hat <- factor(ifelse(train_set$x <= cutoff, "Female", "Male"))
 F_meas(y_hat, train_set$y)
})

与之前一样,我们可以绘制这些 \(F_1\) 衡量值与截止值的对比图:

我们看到它在 \(F_1\) 值最大化时:

max(F_1)
#> [1] 0.617

这个最大值是在我们使用以下截止值时达到的:

best_cutoff <- cutoffs[which.max(F_1)]
best_cutoff
#> [1] 66

66 的截止值比 64 更有意义,因为它更接近男性和女性平均身高的中点。此外,它还在结果混淆矩阵中提供了灵敏度和特异性之间的更好平衡。

y_hat <- factor(ifelse(test_set$x <= best_cutoff, "Female", "Male"))
sensitivity(y_hat, test_set$y)
#> [1] 0.672
specificity(y_hat, test_set$y)
#> [1] 0.837

现在我们可以看到,我们的表现比猜测要好得多,灵敏度和特异性都相对较高。

27.6 ROC 和精确率-召回率曲线

当我们比较两种方法(猜测和使用身高截止值)时,我们考虑了准确性和 \(F_1\)。第二种方法明显优于第一种。然而,虽然我们考虑了第二种方法的几个截止值,但对于第一种,我们只考虑了一种方法:以等概率猜测。请注意,以更高的概率猜测“男性”会由于样本中的偏差而提高准确性,但如上所述,这将以降低灵敏度为代价。本节中描述的曲线将帮助我们看到这一点。

记住,对于每个截止值,我们都可以得到不同的灵敏度和特异性。因此,评估方法的一个非常常见的方法是通过绘制两者来图形化比较。

做这件事的一个广泛使用的图是 接收者操作特征(ROC)曲线。如果你对这个名字感到好奇,可以查阅 ROC 维基百科页面¹。

ROC 曲线绘制了灵敏度(表示为 TPR)与 1 - 特异性(表示为假阳性率 FPR)的对比。在这里,我们计算了不同猜测男性概率所需的 TPR 和 FPR:

probs <- seq(0, 1, length.out = 10)
guessing <- sapply(probs, function(p){
 y_hat <- sample(c("Male", "Female"), nrow(test_set), TRUE, c(p, 1 - p)) 
 y_hat <- factor(y_hat, levels = c("Female", "Male"))
 c(FPR = 1 - specificity(y_hat, test_set$y),
 TPR = sensitivity(y_hat, test_set$y))
})

我们可以使用类似的代码来计算我们第二种方法的这些值。通过同时绘制两条曲线,我们能够比较不同特异性值下的灵敏度:

图片

我们可以看到,对于所有特异性值,我们通过截止方法获得了更高的灵敏度,这意味着它实际上是一个比猜测更好的方法。记住,猜测的 ROC 曲线总是落在身份线上。此外,请注意,在制作 ROC 曲线时,通常很好添加每个点的截止值。

pROCplotROC**包对于生成这些图表很有用。

ROC 曲线有一个弱点,那就是图中绘制的两个指标都不依赖于发病率。在发病率很重要的情况下,我们可能需要制作一个精度-召回率图。想法是相似的,但我们绘制的是精度对召回率的图:

图片

从左边的图表中,我们立即可以看出猜测的精度并不高。这是因为发病率低。从右边的图表中,我们看到如果我们把\(Y=1\)改为表示“男性”而不是“女性”,精度就会提高。请注意,ROC 曲线将保持不变。

27.7 发病率在实践中很重要

一个具有非常高的 TPR 和 TNR 的机器学习算法,当发病率接近 0 或 1 时,在实践上可能并不有用。为了看到这一点,考虑一个专注于罕见疾病并希望开发预测疾病患者的算法的医生的情况。

医生与大约一半的病例和一半的对照组以及一些预测因子共享数据。然后你开发了一个算法,其 TPR 为 0.99,TNR 为 0.99。你兴奋地向医生解释说,这意味着如果一个患者患有该疾病,算法很可能预测正确。医生并不感到印象深刻,并解释说你的 TNR 对于这个算法在实际应用中来说太低了。

这是因为,尽管研究数据集是按照病例和对照组数量相等构建的,但在普通人群中,该疾病的发病率非常低,只有大约 0.5%。

我们可以使用贝叶斯定理来计算你算法在真实人群中的预期精度:$$ \begin{aligned} &\mathrm{Pr}(Y = 1\mid \hat{Y}=1) = \mathrm{Pr}(\hat{Y}=1 \mid Y=1) \frac{\mathrm{Pr}(Y=1)}{\mathrm{Pr}(\hat{Y}=1)} \implies\ &\text{Precision} = \text{TPR} \times \frac{\text{Prevalence}}{\text{TPR}\times \text{Prevalence} + \text{FPR}\times(1-\text{Prevalence})} \approx 0.33 \end{aligned} $$

这里是精度作为发病率函数的图表,其中 TPR 和 TNR 都等于 99%:

图片

虽然你的算法在平衡的训练数据(50%的患病率)上实现了大约 99% 的精确度,但当应用于一般人群时,其精确度会下降到大约 33%,其中疾病患病率仅为 0.5%。医生不能依赖于三分之二阳性结果为假阳性的测试。即使具有完美的灵敏度,在这些条件下精确度仍然大约为 33%。为了使算法在临床上有用,你需要大幅降低假阳性率(FPR)。

27.8 均方误差

到目前为止,我们已描述了仅适用于分类数据的评估指标。具体来说,对于二元结果,我们已描述了如何使用灵敏度、特异性、准确性和 \(F_1\) 来量化性能。然而,这些指标对于连续结果来说并不适用。

在本节中,我们描述了在机器学习中定义“最佳”的一般方法,即定义一个 损失函数,该函数可以应用于分类和连续数据。

最常用的损失函数是平方损失函数。如果 \(\hat{y}\) 是我们的预测值,\(y\) 是观察到的结果,则平方损失函数简单地为:\((\hat{y} - y)²\)

因为我们通常将 \(y\) 模型化为一个随机过程的输出,从理论上讲,基于 \((\hat{y} - y)²\) 来比较算法是没有意义的,因为最小值可能从样本到样本而变化。因此,我们最小化均方误差(MSE):

\[\text{MSE} \equiv \mathrm{E}[(\hat{Y} - Y)² ] \]

假设结果为二元,MSE 等于 1 减去预期准确率,因为如果预测正确,\((\hat{y} - y)²\) 为 0,否则为 1。

不同的算法会导致不同的预测 \(\hat{Y}\),因此不同的 MSE。一般来说,我们的目标是构建一个算法,使其损失最小化,尽可能接近 0。

然而,请注意,均方误差(MSE)是一个理论量,它依赖于未知的数据生成过程。我们在实践中如何估计它?因为我们通常有一个由许多独立观察值 \(y_1, \dots, y_N\) 组成的测试集,一个自然且广泛使用的 MSE 估计是基于测试集中平方误差的平均值:

\[\hat{\mbox{MSE}} = \frac{1}{N}\sum_{i=1}^N (\hat{y}_i - y_i)² \]

使用完全独立于 \(y_i\)\(\hat{y}_i\) 生成。

然而,估计值 \(\hat{\text{MSE}}\) 是一个随机变量。实际上,\(\text{MSE}\)\(\hat{\text{MSE}}\) 通常分别被称为真实误差和表面误差。由于某些机器学习算法的复杂性,很难推导出表面误差如何估计真实误差的统计特性。在第二十九章中,我们介绍了交叉验证,这是一种估计 MSE 的方法。

我们通过指出平方损失不是唯一可能的损失函数选择来结束本章。例如,平均绝对误差(MAE) 将平方误差 \((\hat{Y}_i - Y_i)²\) 替换为其绝对值 \(|\hat{Y}_i - Y_i|\)。根据上下文和分析目标,还有其他可能的损失函数。然而,在这本书中,我们专注于平方损失,因为它是最广泛使用的,并且提供了简化理论和计算的重要数学便利。

在实践中,我们经常报告均方根误差(RMSE),它只是 \(\sqrt{\mbox{MSE}}\),因为它与结果具有相同的单位。

27.9 练习

在以下练习中,我们使用 Titanic 数据集来探索使用基于连续预测因子的简单决策规则进行分类的评价指标。我们的目标不是构建一个复杂的模型,而是了解准确率、敏感性、特异性、精确率和患病率如何取决于截止值的选择。我们将使用票价(票价)、年龄和乘客等级(Pclass)等变量来预测生存,将生存(Survived)视为二元结果。

library(tidyverse
library(caret)
library(titanic)
 titanic <- titanic_train |> select(Survived, Pclass, Sex, Age, Fare) |> drop_na() |>
 mutate(Survived = factor(Survived, levels = c(1,0)), 
 Pclass = factor(Pclass), Sex = factor(Sex))

请注意,我们将级别定义为 c(1,0) 以避免由于 caret 将正 \(Y=1\) 定义为第一个级别而导致的默认行为。

  1. titanic 数据集分为 train_set(80% 的数据)和 test_set(剩余的 20%)。

  2. 使用 Fare(票价)作为连续得分来预测生存。创建一个简单的规则:如果 Fare > cutoff,则预测 1(生存),否则预测 0。取 cutoff = median(train_set$Fare)。在 test_set 上计算准确率。提示:当使用 factor() 时,R 默认按字母顺序对级别进行排序。这意味着0将排在1之前,所以如果你在未指定级别的情况下将预测转换为因子,正类可能会被错误地分配。然而,在这个数据集中,Survived 的第一个级别是 1。确保你的预测因子的级别与 test_set$Survived 的级别相匹配,在计算准确率或混淆矩阵之前。

  3. 练习 2 中实现的准确率与简单地预测每个人为 0(未生存)相比如何?

  4. 对于相同的规则(如果 Fare > cutoff,则预测 1),计算 cutoff = median(Fare) 时的 敏感性特异性患病率。使用 caret 中的 sensitivityspecificityposPredValue。在仅基于票价预测谁会生存的上下文中解释每个量。

  5. 现在重复练习 3,但使用不同的截止值:

cutoffs <- quantile(train_set$Fare, probs = seq(0.05, 0.95, 0.1))

对于每个截止值,使用 Fare > cutoff 将乘客分类为生存/未生存,并绘制在 train_set 上计算的准确率与截止值的关系图。

  1. 使用练习 5 的结果,找到最佳截止值,并在 train_settest_set 上将准确率与练习 4 中选择的规则进行比较。

  2. 根据在测试集上计算出的特异性和灵敏度生成一个 ROC 曲线。对于曲线上的每个点,显示与该点相关的截止值。简要描述当你移动截止值时看到的权衡:当你提高截止值时,灵敏度和特异性会发生什么变化,为什么这在评估谁倾向于在泰坦尼克号上购买昂贵的票时是有意义的?

  3. 现在用一个逻辑回归模型作为更精细的连续得分。拟合模型:

fit <- glm(Survived == "1" ~ ., family = binomial, data = train_set)

我们可以使用predict函数从训练集和测试集中为每个个体获得生存的预测概率:

p_hat_train <- predict(fit, newdata = train_set, type = "response")
p_hat_test <- predict(fit, newdata = test_set, type = "response")

如果这个概率大于 0.5,则将乘客分类为生存。当在训练集和测试集上计算准确率时,这种比较与我们在第 2 题和第 3 题中的比较有何不同?请评论为什么差异比我们之前的比较更大。

  1. 考虑cutofs <- seq(0.05, 0.95, 0.05)这个概率的不同截止值,并在测试集上生成一个 ROC 曲线。基于这个 ROC 曲线和第 7 题计算出的 ROC 曲线,你认为哪种方法表现更好?

  2. 该数据集中生存的整体普及率是:

mean(titanic$Survived == "1")

假设你在生存普及率仅为 20%的新环境中部署你的基于 glm 的规则(第 8 题)。无需代码,定性解释这种普及率的变化会如何影响精确度(阳性预测值),即使灵敏度和特异性保持不变。为什么在评估模型时考虑普及率很重要?


  1. https://en.wikipedia.org/wiki/Receiver_operating_characteristic↩︎

28 条件期望与平滑

原文:rafalab.dfci.harvard.edu/dsbook-part-2/ml/conditionals-and-smoothing.html

  1. 机器学习

  2. 28 条件期望与平滑

在机器学习应用中,我们很少能够完美地预测结果。垃圾邮件过滤器错过了明显的垃圾邮件,Siri 误听了单词,有时你的银行错误地将合法购买标记为欺诈。这些错误发生的主要原因通常是完美预测往往是不可能的。在大多数真实数据集中,我们发现一些观察值组具有相同的预测值但有不同的结果。因为预测规则是一个函数,相同的输入必须产生相同的输出。所以当相同的预测值与不同的结果同时出现时,就像前一章中提到的,男性和女性的身高可以完全相同,没有任何算法可以适用于所有这些情况。

但这并不意味着我们不能构建有用的算法,这些算法比猜测要好得多,有时甚至比人类专家还要好。为了做到这一点,我们使用在第 15.3 节中引入的概率框架。即使个别结果不同,我们也假设具有相同预测值的观察值属于每个类别的相同潜在概率。对于分类结果,这自然导致对诸如

\[\Pr(Y=1 \mid X=x), \]

这总结了我们可以希望做出的最佳预测。

这引出了平滑的概念。在实践中,这些条件概率是未知的,必须从噪声数据中估计。平滑,也称为曲线拟合低通滤波,是一种在趋势的确切形状未知时揭示潜在趋势的通用技术。其思想是,虽然预测变量和结果之间的真实关系变化平滑,但数据本身包括随机波动。平滑方法利用这种平滑性来估计潜在模式,为我们依赖的机器学习算法提供稳定且可解释的近似,例如 \(\Pr(Y=1 \mid X=x)\)。事实上,许多最广泛使用的机器学习算法可以直接或间接地被视为平滑过程,这就是为什么平滑是这个领域的基础概念。

28.1 条件概率与期望

我们使用符号 \((X_1 = x_1,\dots,X_p=x_p)\) 来表示我们已观察到协变量 \(X_1, \dots, X_p\) 的值 \(x_1,\dots,x_p\)。这并不暗示结果 \(Y\) 将取特定的值。相反,它暗示了一个特定的概率。特别是,我们用以下方式表示每个类别 \(k\)条件概率

\[\mathrm{Pr}(Y=k \mid X_1 = x_1,\dots,X_p=x_p), \, \mbox{for}\,k=1,\dots,K. \]

为了避免写出所有的预测器,我们将使用粗体字母,例如:\(\mathbf{X} \equiv (X_1,\dots,X_p)^\top\)\(\mathbf{x} \equiv (x_1,\dots,x_p)^\top\)。我们还将使用以下符号表示属于类别 \(k\) 的条件概率:

\[ p_k(\mathbf{x}) = \mathrm{Pr}(Y=k \mid \mathbf{X}=\mathbf{x}), \, \mbox{for}\, k=1,\dots,K. $$ 注意到 $p_k(\mathbf{x})$ 必须对于每个 $\mathbf{x}$ 相加等于 1,所以一旦我们知道 $K-1$,我们就知道所有 $K$。 当结果为二元时,我们只需要知道 1,所以我们省略 $k$ 并使用符号 $p(\mathbf{x}) = \mathrm{Pr}(Y=1 \mid \mathbf{X}=\mathbf{x})$。 不要被我们用字母 $p$ 表示两件不同的事情所困惑:条件概率 $p(\mathbf{x})$ 和预测器的数量 $p$。** 这些概率指导着构建一个做出最佳预测的算法:对于任何给定的 $\mathbf{x}$,我们将预测在 $p_1(\mathbf{x}), p_2(\mathbf{x}), \dots p_K(\mathbf{x})$ 中概率最大的类别 $k$。用数学符号表示,我们写成这样: $$\hat{y}(\mathbf{x}) = \max_k p_k(\mathbf{x})\]

在机器学习中,我们称之为贝叶斯定理。但这是一个理论规则,因为在实践中,我们不知道 \(p_k(\mathbf{x}), k=1,\dots,K\)。实际上,估计这些条件概率可以被认为是机器学习的主要挑战。我们的概率估计 \(\hat{p}_k(\mathbf{x})\) 越好,我们的预测器就越准确。

因此,我们的预测能力取决于两个因素:1) \(\max_k p_k(\mathbf{x})\) 与 1 或 0(完美确定性)的距离有多近,2) 我们的估计 \(\hat{p}_k(\mathbf{x})\)\(p_k(\mathbf{x})\) 的距离有多近。我们无法改变第一个限制,因为它由问题的本质决定,所以我们把精力投入到寻找最佳估计条件概率的方法上。

第一个限制确实意味着我们对于最佳算法的性能有极限。你应该习惯这样的想法:在某些挑战中,例如使用数字阅读器,我们几乎可以达到完美的准确性,但在其他情况下,我们的成功受到过程随机性的限制,比如从生物特征数据中进行医疗诊断。

请记住,通过最大化概率来定义我们的预测在实践上并不总是最优的,这取决于上下文。正如在第二十七章评估指标中讨论的那样,敏感度和特异性可能在不同情况下具有不同的重要性。但即使在这些情况下,只要我们有一个关于 \(p_k(x), k=1,\dots,K\) 的良好估计,就足以构建最优的预测模型,因为我们可以在任何我们希望的情况下控制特异性和敏感度之间的平衡。例如,我们可以简单地改变用于预测一个结果或另一个结果的截止值。在平面示例中,我们可以在故障概率低于一百万分之一时接地飞机,而不是在错误类型同样不受欢迎时使用的默认值 1/2。

对于二元数据,你可以将概率 \(\mathrm{Pr}(Y=1 \mid \mathbf{X}=\mathbf{x})\) 视为总体中 \(\mathbf{X}=\mathbf{x}\) 层次中 1 的比例。我们将学习的许多算法都可以应用于分类和连续数据,这得益于条件概率和条件期望之间的联系。

因为期望值是总体中值 \(y_1,\dots,y_n\) 的平均值,在 \(y\) 值为 0 或 1 的情况下,期望值等同于随机抽取一个 1 的概率,因为平均值仅仅是 1 的比例:

\[\mathrm{E}[Y \mid \mathbf{X}=\mathbf{x}]=\mathrm{Pr}(Y=1 \mid \mathbf{X}=\mathbf{x}). \]

这意味着条件概率是条件期望。因此,我们通常只使用期望来表示条件概率和条件期望。

就像分类结果一样,在大多数应用中,相同的观察预测因子并不保证相同的连续结果。相反,我们假设结果遵循相同的条件分布。现在我们将解释为什么我们使用条件期望来定义我们的预测因子。

28.2 条件期望最小化平方损失函数

我们为什么在机器学习中关心条件期望?这是因为期望值具有一个吸引人的数学特性:它最小化均方误差(MSE)。具体来说,在所有可能的预测 \(\hat{Y}\) 中,

\[\hat{Y} = \mathrm{E}[Y \mid \mathbf{X}=\mathbf{x}] \, \mbox{ minimizes } \, \mathrm{E}[ (\hat{Y} - Y)² \mid \mathbf{X}=\mathbf{x}] \]

由于这一特性,机器学习的主要任务可以简洁地描述为:我们使用数据来估计:

\[f(\mathbf{x}) \equiv \mathrm{E}[Y \mid \mathbf{X}=\mathbf{x} ] \]

对于任何一组特征 \(\mathbf{x} = (x_1, \dots, x_p)^\top\)

这比说起来容易,因为这个函数可以采取任何形状,而 \(p\) 可能非常大。考虑一个我们只有一个预测变量 \(x\) 的情况。期望 \(\mathrm{E}[Y \mid X=x]\) 可以是 \(x\) 的任何函数:一条线,一个抛物线,一个正弦波,一个阶梯函数,任何东西。当我们考虑具有大 \(p\) 的实例时,事情变得更加复杂,在这种情况下,\(f(\mathbf{x})\) 是一个多维向量 \(\mathbf{x}\) 的函数。例如,在我们的数字识别器示例中 \(p = 784\)

竞争的机器学习算法之间的主要区别在于它们估计这种条件期望的方法。因为我们必须从噪声的、有限的数据中估计这样一个灵活且可能高维的函数,我们需要策略来提取底层信号,而不会被随机性所淹没。一个常见且强大的想法是假设尽管数据本身可能看起来分散,但真实函数 \(f(\mathbf{x})\) 随着 \(\mathbf{x}\) 的变化而平滑变化。这个假设允许我们从附近的点借用力量,即使在精确预测值很少重复的情况下也能产生稳定的估计。这把我们带到了平滑的概念,这是机器学习中最基本工具之一,支撑着我们将要研究的许多算法。

28.3 平滑

平滑是机器学习和统计建模中的一个核心思想:当数据有噪声时,我们通常假设潜在的趋势是逐渐变化的,而不是从一点跳到另一点。平滑的目标是从数据中恢复这种隐藏的结构。

为了激发这个想法,请考虑下面的图表:噪声掩盖了趋势,但并没有破坏它,我们的任务是揭示从噪声数据中的趋势。

在接下来的章节中,我们将探讨平滑技术是如何实现这一点的,从一个简单的案例研究开始,该研究说明了方法背后的挑战和直觉。

示例:这是一个 2 还是 7?

为了激发平滑的需求并与机器学习建立联系,我们将构建 MNIST 数据集的简化版本,其中结果有两个类别,预测变量也有两个。具体来说,我们将挑战定义为构建一个算法,该算法可以从左上象限(\(X_1\))和右下象限(\(X_2\))中暗像素的比例确定一个数字是 2 还是 7。我们还选择了一个包含 1,000 个数字的随机样本,分为训练集和测试集。这两个集合几乎均匀地分布着 2 和 7。

dslabs** 包含了这个例子在对象 mnist_27 中。在训练集中,我们有 800 个观察值,在测试集中我们有 200 个观察值。每个观察值都有:

  • 一个结果 \(y_i\),指示数字是 2 还是 7,以及

  • 一个特征向量 \(\mathbf{x}_i = (x_{i,1}, x_{i,2})^\top\),这是从图像中提取出的二维空间中的一个点。

因此,数据集由成对的 \((\mathbf{x}_i, y_i)\) 组成,其中每个 \(\mathbf{x}_i\) 是一个二维特征,每个 \(y_i\) 是相应的数字标签。

为了说明如何解释 \(X_1\)\(X_2\),我们包括四个示例图像。左边是两个数字中 \(X_1\) 最大和最小值的原始图像,右边是对应于 \(X_2\) 最大和最小值的图像:

下面是观察到的 \(X_2\) 与观察到的 \(X_1\) 的关系图,颜色表示 \(y\) 是 2(红色)还是 7(蓝色):

library(caret)
library(dslabs)
mnist_27$train |> ggplot(aes(x_1, x_2, color = y)) + geom_point()

* *我们可以立即看到一些模式。例如,如果 \(x_1\) 很大(图像的左上角有大量的墨水),那么这个数字很可能是 7。另外,对于 \(x_1\) 较小的值,2 似乎出现在 \(x_2\) 的中间值范围内。

我们可以开始了解为什么这些预测因子是有用的,但同时也为什么这个问题将具有一定的挑战性。

我们还没有真正学习任何算法,所以让我们尝试使用 GLM 构建一个算法。模型很简单:

\[\log\frac{p(\mathbf{x})}{1-p(\mathbf{x})} = \beta_0 + \beta_1 x_1 + \beta_2 x_2 \]

我们可以使用glm函数来拟合此模型,从而获得一个估计 \(\hat{p}(\mathbf{x})\)。我们通过预测 \(\hat{y}(\mathbf{x})=1\) 如果 \(\hat{p}(\mathbf{x})>0.5\),否则为 0 来定义一个决策规则。

如果我们这样做,我们得到的准确率为 0.775,远高于 50%。对于我们第一次尝试来说,还不错。但我们能做得更好吗?

因为我们已经构建了mnist_27示例,并且我们仅使用 MNIST 数据集就拥有了 60,000 个数字,所以我们利用这些数据来构建真实的条件分布 \(p(\mathbf{x})\)。请记住,在实践中,我们无法访问真实的条件分布。我们将其包含在这个教育示例中,因为它允许我们将 \(\hat{p}(\mathbf{x})\) 与真实的 \(p(\mathbf{x})\) 进行比较。这种比较教会了我们不同算法的局限性。

我们已经将真实的 \(p(\mathbf{x})\) 存储在mnist_27中,可以将其作为图像绘制。我们绘制一条曲线,将 \(p(\mathbf{x}) > 0.5\)\(\mathbf{x}\) 值与 \(p(\mathbf{x}) < 0.5\)\(\mathbf{x}\) 值分开:

要开始理解回归的局限性,首先要注意,因为 \(p(\mathbf{x}) = 0.5 \iff \log p(\mathbf{x})/(1-p(\mathbf{x})) = 0\),使用 GLM \(\hat{p}(\mathbf{x})\),由 \(p(\mathbf{x}) = 0.5\) 定义的边界必须满足:

\[\hat{\beta}_0 + \hat{\beta}_1 x_1 + \hat{\beta}_2 x_2 = 0 \implies x_2 = -\hat{\beta}_0/\hat{\beta}_2 -\hat{\beta}_1/\hat{\beta}_2 x_1 \]

这意味着 \(x_2\) 必须是 \(x_1\) 的线性函数。

这表明我们的 GLM 方法没有机会捕捉到真实 \(p(\mathbf{x})\) 的非线性性质。以下是 \(\hat{p}(\mathbf{x})\) 的可视化表示,它清楚地显示了它如何未能捕捉到 \(p(\mathbf{x})\) 的形状:

图片

我们需要更灵活的东西:一种允许估计形状不是平面的方法。平滑技术允许这种灵活性。我们将首先描述最近邻和核方法。为了理解为什么我们要涵盖这个主题,请记住,平滑技术背后的概念在机器学习中非常有用,因为条件期望/概率可以被视为未知形状的** 趋势 **,我们需要在存在不确定性的情况下估计它。

28.4 局部平滑方法

为了解释平滑概念背后的主要思想,我们首先关注一个只有一个预测因子的问题。我们选择一个具有明显趋势但非线性的例子:2008 年美国总统选举中奥巴马和麦凯恩之间的民调差距。

polls_2008 |> ggplot(aes(day, margin)) + geom_point()

图片** **稍后我们将学习如何将平滑思想扩展到更高维度。

在民调示例中,不要将其视为预测问题。相反,我们只是对学习趋势的形状感兴趣,一旦我们有了所有数据。

我们假设对于任何给定的一天 \(x\),选民中存在一个真实的偏好 \(f(x)\),但由于民调引入的不确定性,每个数据点都伴随着一个误差 \(\varepsilon\)。观察到的民调差距的数学模型是:

\[Y_i = f(x_i) + \varepsilon_i \]

将这个问题视为一个机器学习问题,考虑我们想要根据一天 \(x\) 预测 \(Y\)。我们感兴趣的是找到真实趋势 \(f(x) = \mathrm{E}[Y \mid X=x]\),但由于我们不知道这个条件期望,我们必须估计它。

让我们首先使用回归,因为这是我们迄今为止学到的唯一适用于此类数据的方法。以下是我们的估计:

图片

拟合的回归线似乎并不能很好地描述趋势。例如,在 9 月 4 日(第-62 天),共和党全国代表大会举行,数据表明这给了约翰·麦凯恩在民调中的提升。然而,回归线并没有捕捉到这种潜在的趋势。为了更清楚地看到** 拟合不足 **,我们注意到位于拟合线(蓝色)上方的点以及位于下方的点(红色)在每天并不是均匀分布的。因此,我们需要一个替代的、更灵活的方法。

接下来,我们将描述可以克服这种局限性的平滑技术。

箱式平滑

箱式平滑的一般思想是将数据点分组到层中,其中 \(f(x)\) 的值可以假设是恒定的。当我们认为 \(f(x)\) 变化缓慢,并且因此 \(f(x)\)\(x\) 的小窗口中几乎恒定时,我们可以做出这个假设。

对于 poll_2008 数据,平滑假设意味着相信公众舆论不会在一天之内发生剧烈变化。例如,我们可能会假设支持在一周内大致保持恒定。有了这个假设,连续几天的大约相同的预期值会给我们提供多个观察结果,帮助我们估计该点的潜在趋势。

如果我们将一周中的某一天设为中心,称之为 \(x_0\),那么对于任何其他满足 \(|x - x_0| \leq h\) 的日子 \(x\),其中 \(h = 3.5\),我们假设 \(f(x)\) 是一个常数 \(f(x) = \mu\)。这个假设意味着:

\[E[Y_i | X_i = x_i ] \approx \mu \mbox{ if } |x_i - x_0| \leq 3.5 \]

在平滑过程中,我们将 \(h\) 称为 带宽。满足 \(|x_i - x_0| \le h\) 的点的区间称为 窗口大小跨度

术语 带宽核半径窗口大小跨度 有时可以互换使用,但它们的精确含义取决于惯例。根据上述定义,窗口大小是 带宽的两倍

一些 R 函数,如 ksmooth(),使用 完整窗口大小 作为带宽。按照这个惯例,带宽为 7 对应于我们记法中的 \(h = 3.5\)。*这个假设意味着 \(f(x_0)\) 的良好估计是窗口中 \(y_i\) 值的平均值。如果我们定义 \(A_0\) 为满足 \(|x_i - x_0| \leq 3.5\) 的索引 \(i\) 的集合,并且 \(N_0\)\(A_0\) 中的索引数量,那么我们的估计是:

\[\hat{f}(x_0) = \frac{1}{N_0} \sum_{i \in A_0} y_i \]

我们用 \(x\) 的每个值作为中心进行这个计算。

在民意调查的例子中,对于每一天,我们会计算以那天为中心的一周内数值的平均值。这里有两个例子:\(x_0 = -125\)\(x_0 = -55\)。蓝色部分代表结果平均值。

图片

通过计算每个点的这个平均值,我们形成对潜在曲线 \(f(x)\) 的估计。以下是我们从 -155 移动到 0 的过程。在 \(x_0\) 的每个值上,我们保持估计 \(\hat{f}(x_0)\) 并移动到下一个点:

图片

最终的代码和估计结果如下所示:

span <- 7 
fit <- with(polls_2008, ksmooth(day, margin, kernel = "box", bandwidth = span))
 polls_2008 |> mutate(fit = fit$y) |>
 ggplot(aes(x = day)) +
 geom_point(aes(y = margin), size = 3, alpha = .5, color = "grey") + 
 geom_line(aes(y = fit), color = "red")

图片

核平滑器

“箱子灭火器”的估计看起来可能相当曲折。一个原因是,当窗口滑动时,点会突然进入或离开箱子,导致平均值的跳跃。我们可以通过一个 核平滑器 来减少这些不连续性。核平滑器根据数据点与目标位置 \(x_0\) 的距离分配一个 权重,然后形成一个加权平均值**。

形式上,设 \(K\) 为一个非负核函数,设 \(h>0\)带宽。定义权重

\[w_{x_0}(x_i) = K\!\left(\frac{x_i - x_0}{h}\right), \]

并用此估计 \(x_0\) 处的趋势

\[\hat{f}(x_0) \;=\; \frac{\sum_{i=1}^N w_{x_0}(x_i)\,y_i}{\sum_{i=1}^N w_{x_0}(x_i)}. \]

箱平滑器是一个特殊情况,具有 箱形(或 均匀)核 \(K(u) = 1\) 如果 \(|u| \leq 1\) 否则为 0,这对应于在窗口内分配权重 1,在窗口外分配权重 0。这就是为什么在上面的代码中,我们使用 ksmooth 时的 kernel = "box"。为了减少由于点突然进入和退出而产生的波动,我们可以使用一个平滑核,该核对 \(x_0\) 附近的点给予更多权重,并对远离 \(x_0\) 的点快速衰减。ksmooth 中的 kernel = "normal" 选项正是通过使用标准正态密度 \(K\) 来做到这一点。

在下面,我们可视化 \(x_0 = -125\)\(h = 3.5\) 时的箱形核和正态核,展示了箱形核如何使所有箱内点等权重,而正态核则降低边缘附近点的权重。

正态核的最终代码和结果图看起来像这样:

fit <- with(polls_2008, ksmooth(day, margin, kernel = "normal", bandwidth = 7))
 polls_2008 |> mutate(smooth = fit$y) |>
 ggplot(aes(day, margin)) +
 geom_point(size = 3, alpha = .5, color = "grey") + 
 geom_line(aes(day, smooth), color = "red")

* *注意这个版本看起来更平滑。

R 中有几个函数实现了箱平滑器。一个例子是上面显示的 ksmooth。然而,在实践中,我们通常更喜欢使用比拟合常数稍微复杂一些的模型的方法。例如,上面的最终结果在预期不会出现波动的部分仍然有些许波动(例如在 -125 和 -75 之间)。我们接下来要解释的 loess 方法可以改进这一点。

局部加权回归(loess)

所描述的箱平滑器方法的一个局限性是,我们需要小窗口来保持近似常数的假设。因此,我们最终得到的数据点数量很少,得到的 \(\hat{f}(x)\) 估计值不够精确。在这里,我们描述如何通过 局部加权回归(loess)来考虑更大的窗口大小。为此,我们将使用一个称为泰勒定理的数学结果,它告诉我们,如果你足够仔细地观察任何光滑函数 \(f(x)\),它将看起来像一条线。为了理解这一点,考虑园艺师用直边铲子做的弯曲边缘:

(由 Flickr 用户 Number 10 拍摄的“Downing Street garden path edge”¹。CC-BY 2.0 许可证²。)

我们不是假设函数在窗口内近似为常数,而是假设函数是局部线性的。与常数假设相比,我们可以考虑更大的窗口大小。我们不再考虑一周的窗口,而是考虑一个趋势近似线性的更大窗口。我们从一个三周的窗口开始,后来考虑并评估其他选项:

\[E[Y_i | X_i = x_i ] = \beta_0 + \beta_1 (x_i-x_0) \mbox{ if } |x_i - x_0| \leq 10.5 \]

对于每个点 \(x_0\),loess 定义一个窗口并在该窗口内拟合一条线。以下是一个示例,展示了 \(x_0=-125\)\(x_0 = -55\) 的拟合情况:

图片

\(x_0\) 处的拟合值成为我们的估计 \(\hat{f}(x_0)\)。以下是我们从 -155 移动到 0 的过程中发生的步骤:

图片

由于我们使用更大的样本量来估计局部参数,最终结果比箱式平滑器更平滑:

total_days <- diff(range(polls_2008$day))
span <- 21/total_days
fit <- loess(margin ~ day, degree = 1, span = span, data = polls_2008)
polls_2008 |> mutate(smooth = fit$fitted) |>
 ggplot(aes(day, margin)) +
 geom_point(size = 3, alpha = .5, color = "grey") +
 geom_line(aes(day, smooth), color = "red")

图片* *不同的跨度给出了不同的估计。我们可以看到不同的窗口大小如何导致不同的估计:

图片

这里是最终的估计:

图片

loess 与典型的箱式平滑器之间有三个其他的不同点。

  1. 与保持箱式大小相同不同,loess 保持局部拟合中使用的点数相同。这个数字通过 span 参数来控制,它期望一个比例。例如,如果 N 是数据点的数量,且 span=0.5,那么对于给定的 \(x\)loess 将使用 \(0.5*N\) 个最接近 \(x\) 的点来进行拟合。

  2. 当局部拟合直线时,loess 使用一个 加权 方法。基本上,我们不是最小化残差平方和,而是最小化一个加权的版本:

\[\sum_{i=1}^N w_0(x_i) \left[y_i - \left\{\beta_0 + \beta_1 (x_i-x_0)\right\}\right]² \]

  1. 与高斯核函数不同,loess 使用一个称为 Tukey 三重权重的函数:

\[K(u)= \left( 1 - |u|³\right)³ \mbox{ if } |u| \leq 1 \mbox{ and } K(u) = 0 \mbox{ if } |u| > 1 \]

为了定义权重,我们表示 \(2h\) 为窗口大小,并定义 \(w_0(x_i)\) 如上所示:\(w_0(x_i) = K\left(\frac{x_i - x_0}{h}\right)\)

这个核函数与高斯核函数的不同之处在于,更多的点会得到接近最大值的值:

图片

  1. loess 有一个选项可以 稳健地 拟合局部模型。实现了一个迭代算法,在该算法中,在每次迭代中拟合一个模型后,会检测异常值并将它们在下次迭代中降权。要使用此选项,我们使用参数 family="symmetric"

loess 也可以拟合局部抛物线而不是直线。泰勒定理也告诉我们,如果你足够仔细地观察任何数学函数,它看起来就像一个抛物线。该定理还指出,在用抛物线近似时,你不需要像用直线近似时那样仔细观察。这意味着我们可以使窗口更大,并拟合抛物线而不是直线。

\[E[Y_i | X_i = x_i ] = \beta_0 + \beta_1 (x_i-x_0) + \beta_2 (x_i-x_0)² \mbox{ if } |x_i - x_0| \leq h \]

你可能已经注意到,当我们展示使用 loess 的代码时,我们设置了degree = 1。这告诉 loess 拟合一阶多项式,也就是线的花哨名称。如果你阅读 loess 的帮助页面,你会看到参数degree默认为 2。默认情况下,loess 拟合抛物线而不是线。以下是拟合线(红色虚线)和拟合抛物线(橙色实线)的比较:

total_days <- diff(range(polls_2008$day))
span <- 28/total_days
fit_1 <- loess(margin ~ day, degree = 1, span = span, data = polls_2008)
fit_2 <- loess(margin ~ day, span = span, data = polls_2008)
 polls_2008 |> mutate(smooth_1 = fit_1$fitted, smooth_2 = fit_2$fitted) |>
 ggplot(aes(day, margin)) +
 geom_point(size = 3, alpha = .5, color = "grey") +
 geom_line(aes(day, smooth_1), color = "red", lty = 2) +
 geom_line(aes(day, smooth_2), color = "orange", lty = 1) 

* *degree = 2给我们带来了更多波动的结果。一般来说,我们实际上更喜欢degree = 1,因为它不太容易受到这种噪声的影响。

注意默认平滑参数

ggplot2包中的geom_smooth函数支持多种平滑方法。默认情况下,它使用 loess 或相关方法,即广义加性模型,如果任何数据窗口超过 1000 个观测值时使用后者。我们可以通过method函数请求使用 loess:

polls_2008 |> ggplot(aes(day, margin)) +
 geom_point() + 
 geom_smooth(method = loess, formula = y ~ x)

* *但是要注意默认参数,因为它们很少是最优的。然而,你可以方便地更改。例如,使用loess你可以使用以下代码:

polls_2008 |> ggplot(aes(day, margin)) +
 geom_point() + 
 geom_smooth](https://ggplot2.tidyverse.org/reference/geom_smooth.html)(method = loess, formulat = y ~ x, method.args = [list(span = 0.15, degree = 1))

28.5 将平滑与机器学习联系起来

为了具体了解平滑如何与机器学习相关,再次考虑第 28.3.1 节中的例子。如果我们定义结果\(Y = 1\)为数字 7,\(Y=0\)为数字 2,那么我们感兴趣的估计条件概率是:

\[p(\mathbf{x}) = \mathrm{Pr}(Y=1 \mid X_1=x_1 , X_2 = x_2). \]

使用\(x_1\)\(x_2\)作为在第 28.3.1 节中定义的两个预测因子。在这个例子中,我们观察到的 0 和 1 是“噪声”,因为对于某些区域,概率\(p(\mathbf{x})\)并不接近 0 或 1。因此,我们需要估计\(p(\mathbf{x})\)。平滑是完成这一目标的另一种方法。在第 28.3.1 节中,我们看到线性回归不足以捕捉\(p(\mathbf{x})\)的非线性性质,因此平滑方法提供了改进。在第 29.1 节中,我们描述了一种流行的机器学习算法,即 k 最近邻算法,它基于平滑的概念。

28.6 练习

  1. heights数据集计算男性条件的概率。将身高四舍五入到最接近的英寸。为每个\(x\)绘制估计的条件概率 \(P(x) = \mathrm{Pr}(\mbox{Male} | \mbox{height}=x)\)

  2. 在我们刚刚制作的图中,我们看到身高低值时存在高变异性。这是因为在这些层中我们的数据点很少。这次使用quantile函数来计算分位数 \(0.1,0.2,\dots,0.9\)cut函数来确保每个组有相同数量的点。提示:对于任何数值向量x,你可以根据以下示例创建基于分位数的组。

cut(x, quantile(x, seq(0, 1, 0.1)), include.lowest = TRUE)
  1. 使用MASS包生成来自双变量正态分布的数据,如下所示:
Sigma <- 9*matrix(c(1,0.5,0.5,1), 2, 2)
dat <- MASS::mvrnorm](https://rdrr.io/pkg/MASS/man/mvrnorm.html)(n = 10000, [c(69, 69), Sigma) |>
 data.frame() |> setNames(c("x", "y"))

你可以使用plot(dat)快速绘制数据。使用与上一个练习类似的方法来估计条件期望并绘制图形。

  1. dslabs包提供了以下数据集,其中包含 2015-2018 年波多黎各的死亡计数。
library(dslabs)
head(pr_death_counts)

从 2018 年 5 月之前的数据中移除,然后使用loess函数获取预期死亡人数作为日期函数的平滑估计。绘制这个结果平滑函数。使跨度大约为两个月。

  1. 将平滑估计值与一年中的天数绘制在同一张图上,但使用不同的颜色。

  2. 假设我们只想使用第二个协变量来预测mnist_27数据集中的 2s 和 7s。我们能这样做吗?初步观察似乎数据没有多少预测能力。实际上,如果我们拟合一个常规逻辑回归,x_2的系数并不显著!

library(dslabs)
glm(y ~ x_2, family = "binomial", data = mnist_27) 

在这里绘制散点图没有用,因为y是二元的:

with(mnist_27$train, plot(x_2, y)

将一个局部加权回归线拟合到上面的数据,并绘制结果。注意,这里存在预测能力,但条件概率不是线性的。


  1. https://www.flickr.com/photos/49707497@N06/7361631644↩︎

  2. https://www.flickr.com/photos/number10gov/↩︎

  3. https://creativecommons.org/licenses/by/2.0/↩︎

29 重采样与模型评估

原文:rafalab.dfci.harvard.edu/dsbook-part-2/ml/resampling-methods.html

  1. 机器学习

  2. 29 重采样与模型评估

在本章中,我们介绍了重采样,这是机器学习中最重要思想之一。在这里,我们关注概念和数学方面。我们将在第 31.2 节中稍后描述如何在实践中实现重采样方法。为了激发这个概念,我们将使用第 28.3.1 节中提出的两个预测数字数据,并介绍 k 近邻(kNN),以展示这些思想。

29.1 使用 k 近邻的动机

我们感兴趣的是估计条件概率函数:

\[p(\mathbf{x}) = \mathrm{Pr}(Y = 1 \mid X_1 = x_1 , X_2 = x_2). \]

如在第 28.5 节中定义。

在 k 近邻(kNN)中,我们以与箱式平滑类似的方式估计 \(p(\mathbf{x})\)。首先,我们根据特征定义所有观察值之间的距离。然后,对于任何点 \(\mathbf{x}_0\),我们通过识别 \(\mathbf{x}_0\)\(k\) 个最近点并取这些点相关的 \(y\) 值的平均值来估计 \(p(\mathbf{x}_0)\)。我们将用于计算平均值的点集称为 邻域

由于我们之前描述的关于条件期望和条件概率之间的联系,这给我们提供了 \(\hat{p}(\mathbf{x}_0)\),就像箱式平滑器给我们提供了一个趋势的估计。与箱式平滑器一样,我们可以通过一个参数来控制我们估计的灵活性,在这种情况下是邻居的数量 \(k\):较大的 \(k\) 值会导致更平滑的估计,而较小的 \(k\) 值会导致更灵活和波动的估计。

为了拟合 \(k\) 近邻模型,我们使用来自 caret 包的 knn3 函数。帮助文件显示了调用此函数的两种方式;我们将使用 公式接口,它允许我们将模型写成以下形式

outcome ~ predictor_1 + predictor_2 + ...

由于我们的训练集存储在数据框中,我们可以使用缩写 y ~ . 来表示“使用 y 作为结果,所有其他列作为预测变量。”

我们还必须选择邻居的数量 \(k\)。默认值是 k = 5,我们将在这里使用。将这一点结合起来,我们的调用看起来像:

library(dslabs)
library(caret)
#> Loading required package: lattice
knn_fit <- knn3(y ~ ., data = mnist_27$train, k = 5)

在这种情况下,由于我们的数据集是平衡的,我们同样关心敏感性和特异性,我们将使用准确率来量化性能。

knn3predict 函数返回 \(p(\mathbf{x})\) 的估计(type = "prob")或预测(type = "class"):

y_hat_knn <- predict(knn_fit, mnist_27$test, type = "class")

我们看到,默认参数的 kNN 已经击败了 GLM:

mean(y_hat_knn == mnist_27$test$y)
#> [1] 0.815

为了了解为什么是这样,我们绘制 \(\hat{p}(\mathbf{x})\) 并将其与真实的条件概率 \(p(\mathbf{x})\) 进行比较:

图片

我们看到 kNN 更好地适应了\(p(\mathbf{x})\)的非线性形状。然而,我们的估计在红色区域中有一些蓝色的孤岛,这在直观上并不合理。我们注意到,与测试集相比,我们在训练集中有更高的准确率:

y_hat_knn <- predict(knn_fit, mnist_27$train, type = "class")
mean(y_hat_knn == mnist_27$train$y)
#> [1] 0.858
 y_hat_knn <- predict(knn_fit, mnist_27$test, type = "class")
mean(y_hat_knn == mnist_27$test$y)
#> [1] 0.815

这是由于我们所说的过拟合

29.2 过拟合

使用 kNN 时,当我们将\(k\)设置为 1 时,过拟合问题最为严重。当\(k = 1\)时,训练集中每个\(\mathbf{x}\)的估计值仅通过该点的\(y\)值获得。在这种情况下,如果\(x_1\)\(x_2\)是唯一的,我们将在训练集中获得完美的准确率,因为每个点都用于预测自身(如果预测因子不是唯一的,并且至少有一组预测因子有不同的结果,那么无法完美预测)。

在这里,我们使用\(k = 1\)拟合 kNN 模型,并确认我们在训练集中获得了接近完美的准确率:

knn_fit_1 <- knn3(y ~ ., data = mnist_27$train, k = 1)
y_hat_knn_1 <- predict(knn_fit_1, mnist_27$train, type = "class")
mean(y_hat_knn_1 == mnist_27$train$y)
#> [1] 0.994

但在测试集中,准确率实际上比我们使用回归得到的准确率还要差:

y_hat_knn_1 <- predict(knn_fit_1, mnist_27$test, type = "class")
mean(y_hat_knn_1 == mnist_27$test$y)
#> [1] 0.81

我们可以通过绘制\(\hat{p}(\mathbf{x})\)产生的决策规则边界来看到过拟合问题:

图片

估计\(\hat{p}(\mathbf{x})\)与训练数据过于接近(左)。您可以看到,在训练集中,边界被绘制以完美地包围蓝色海洋中的单个红色点。由于大多数点\(\mathbf{x}\)是唯一的,预测结果要么是 1 要么是 0,该点的预测就是其关联的标签。然而,一旦我们引入测试集(右),我们会看到许多这些小岛现在具有相反的颜色,我们最终做出了几个错误的预测。

29.3 过平滑

尽管不如\(k=1\)时严重,但我们看到当\(k = 5\)时,我们也出现了过拟合。因此,我们应该考虑一个更大的\(k\)。让我们尝试一个更大的数字,例如\(k = 401\)

knn_fit_401 <- knn3(y ~ ., data = mnist_27$train, k = 401)
y_hat_knn_401 <- predict(knn_fit_401, mnist_27$test, type = "class")
mean(y_hat_knn_401 == mnist_27$test$y)
#> [1] 0.76

估计结果与使用回归得到的估计相似:

图片

在这种情况下,\(k\)值太大,不允许足够的灵活性。我们称之为过平滑

29.4 调参参数

机器学习算法通常需要我们在拟合模型之前设置一个或多个值。一个简单的例子是在 k-Nearest Neighbors (kNN)中选择\(k\)。在第三十章中,我们将看到更多的例子。这些值被称为调参参数,将机器学习应用于实践的一个重要部分是选择它们,通常称为调参模型

那么,我们如何选择调整参数呢?例如,我们如何决定 kNN 中的最佳 \(k\) 值?原则上,我们希望 \(k\) 的值最大化准确率,或者等价地,最小化第 27.8 节中定义的预期均方误差。挑战在于我们不知道真正的预期误差。重采样方法的目标是估计任何给定算法和调整参数集(如 \(k\))的此误差。

为了了解为什么我们需要重采样,让我们重复我们之前所做的事情:比较训练集和测试集的准确率,但现在对于一系列的 \(k\) 值。然后我们可以绘制每个 \(k\) 值选择的准确率估计:

图片

首先,请注意,从训练集获得的估计通常比测试集的估计更乐观,准确率更高,对于较小的 \(k\) 值,差距更大。这是过拟合的经典症状。

我们是否应该简单地选择在测试集上给出最高准确率的 \(k\) 值并报告其准确率?这种方法有两个重要的缺点:

  1. 准确率与(k)曲线是嘈杂的。 我们不期望 \(k\) 的小幅度变化会导致准确率或 MSE 的大幅度变化。锯齿状模式发生是因为每个准确率估计都是基于有限的测试样本,因此由于随机变化而波动。因此,“最佳” \(k\) 可能只是偶然看起来最好。

  2. 我们正在两次使用测试集。 虽然我们没有在测试集上拟合模型,但我们正在使用它来选择 \(k\),然后再次使用它来报告准确率。这会导致对性能的乐观估计,通常在真正新的数据上不会成立。

重采样方法*通过减少变异性并确保测试数据不重复使用(一次用于评估,一次用于调整)来提供解决这两个问题的原则性解决方案。

29.5 重采样方法的数学描述

在第 29.1 节第 29.1 节中,我们介绍了 \(k\)-最近邻(kNN)作为本章的一个简单示例,以激发对该章节的兴趣。在那个设置中,算法只依赖于一个调整参数 \(k\),它直接影响性能。更一般地,机器学习算法通常涉及多个调整参数。在这里,我们使用符号 \(\lambda\) 来表示定义算法的全部参数集。

为了跟踪这些选择如何影响性能,我们将给定参数集对观测 \(i\) 生成的预测写成 \(\hat{y}_i(\lambda)\),相应的均方误差为 \(\mathrm{MSE}(\lambda)\)。我们的目标是找到使 \(\mathrm{MSE}(\lambda)\) 最小的 \(\lambda\) 值。

重采样方法提供了估计 \(\mathrm{MSE}(\lambda)\) 的实用方法,因此引导我们找到最佳的调整参数。

直观的第一种尝试是第 27.8 节中定义的明显误差,并在上一节中使用:

\[\hat{\mbox{MSE}}(\lambda) = \frac{1}{N}\sum_{i = 1}^N \left\{\hat{y}_i(\lambda) - y_i\right\}² \]

如前节所述,这个估计是一个随机变量,基于仅一个测试集,具有足够的变异性,可以显著影响最佳 \(\lambda\) 的选择。

现在想象一个我们可以反复获取数据的世界,比如来自新的随机样本。我们可以获取一个非常大的新样本数量 \(B\),将每个样本分成训练集和测试集,并定义:

\[\frac{1}{B} \sum_{b=1}^B \frac{1}{N}\sum_{i=1}^N \left\{\hat{y}_i^b(\lambda) - y_i^b\right\}² \]

其中 \(y_i^b\) 是测试样本 \(b\) 中的第 \(i\) 个观测值,\(\hat{y}_{i}^b(\lambda)\) 是使用参数 \(\lambda\) 定义并由训练集 \(b\) 训练的算法获得的预测。大数定律告诉我们,随着 \(B\) 的增大,这个量越来越接近 \(\mbox{MSE}(\lambda)\)。这当然是一个理论上的考虑,因为我们很少能接触到超过一个数据集来开发算法,但这个概念启发了重采样方法。

重采样方法背后的基本思想是从现有数据生成一系列不同的随机样本。有几种实现这种方法的方法,但所有方法都是随机生成几个较小的数据集,这些数据集不用于训练,而是用于估计 MSE。接下来,我们将描述 交叉验证,这是最广泛使用的重采样方法之一。

29.6 交叉验证

总体而言,我们得到了一个数据集(蓝色)并需要构建一个算法,使用这个数据集,该算法最终将用于完全独立的(黄色)数据集,我们甚至可能看不到。

图片

因此,为了模仿这种情况,我们首先从我们的数据集中划分出一部分,并假装它是一个独立的数据集:我们将数据集划分为一个 训练集(蓝色)和一个 测试集(红色)。我们将完全在训练集上训练我们的算法,包括参数 \(\lambda\) 的选择,并仅将测试集用于评估目的。

我们通常尝试选择数据集的一小部分,以便尽可能多地拥有数据来训练。然而,我们还想让测试集足够大,以便在不拟合不切实际数量的模型的情况下获得稳定的 MSE 估计。典型的选择是使用 10%-20% 的数据用于测试。

图片

让我们重申,我们绝对不能使用测试集:无论是用于过滤行、选择特征还是用于任何其他目的!

但然后我们如何优化 \(\lambda\)?在交叉验证中,我们通过将训练集分成两部分:训练集和验证集来实现这一点。

图片

我们将多次进行此操作,以确保每个数据集中获得的多项式均方误差(MSE)估计相互独立。为此,已经提出了几种方法。在这里,我们详细描述其中一种方法,即 K 折交叉验证,以提供所有方法中使用的通用思路。

K 折交叉验证

作为提醒,我们将模仿在介绍这种 MSE 版本时所使用的概念:

\[\mbox{MSE}(\lambda) \approx\frac{1}{B} \sum_{b = 1}^B \frac{1}{N}\sum_{i = 1}^N \left(\hat{y}_i^b(\lambda) - y_i^b\right)² \]

我们希望生成一个可以被视为独立随机样本的数据集,并重复此操作\(B\)次。K 折交叉验证中的 K 代表\(B\)的次数。在下面的示例中,我们展示了使用\(B = 5\)的例子。

我们最终将得到\(B\)个样本,但让我们先描述如何构建第一个:我们简单地随机选择\(M = N/B\)个观察值(如果\(M\)不是整数,则进行四舍五入),并将这些视为一个随机样本\(y_1^b, \dots, y_M^b\),其中\(b = 1\)。我们称这为验证集。

现在我们可以将模型拟合到训练集中,然后在独立集上计算明显的误差:

\[\hat{\mbox{MSE}}_b(\lambda) = \frac{1}{M}\sum_{i = 1}^M \left(\hat{y}_i^b(\lambda) - y_i^b\right)² \]

作为提醒,这只是一个样本,因此将返回一个关于真实错误的噪声估计。在 K 折交叉验证中,我们将观察随机分成\(B\)个非重叠集:

现在,我们为这些集合中的每一个重复上述计算\(b = 1,\dots,B\),并得到\(\hat{\mbox{MSE}}_1(\lambda),\dots, \hat{\mbox{MSE}}_B(\lambda)\)。然后,为了我们的最终估计,我们计算平均值:

\[\hat{\mbox{MSE}}(\lambda) = \frac{1}{B} \sum_{b = 1}^B \hat{\mbox{MSE}}_b(\lambda) \]

并获得我们损失的估计。最后一步将是选择使 MSE 最小的\(\lambda\)

有多少折?

现在如何选择交叉验证的折数?较大的\(B\)值更可取,因为训练数据更好地模仿原始数据集。然而,较大的\(B\)值将导致计算时间大大减慢:例如,100 折交叉验证将比 10 折交叉验证慢 10 倍。因此,\(B = 5\)\(B = 10\)的选择很受欢迎。

估计优化算法的 MSE

我们已经描述了如何使用交叉验证来优化参数。然而,我们现在必须考虑优化是在训练数据上进行的,因此我们需要基于未用于优化选择的数据来估计我们的最终算法。这就是我们使用早期分离的测试集的地方:

实际上,我们可以再次进行交叉验证,比如五次:

通过平均五个 MSE 估计值,我们获得我们预期损失的最终估计。然而,请注意,最后一个交叉验证迭代意味着我们的整个计算时间乘以重复次数。你很快就会了解到,拟合每个算法都需要时间,因为我们正在执行许多复杂的计算。因此,我们总是在寻找减少这种时间的方法。对于最终评估,我们通常只使用一个测试集。

一旦我们对这个模型感到满意并希望将其提供给他人,我们可以在不改变优化参数的情况下,在整个数据集上重新拟合模型。

图片

29.7 自助重采样

通常,交叉验证涉及将原始数据集划分为训练集以训练模型和测试集以评估它。根据第十四章中描述的思想,使用自助方法可以创建多个不同的训练数据集。这种方法有时被称为自助聚合或袋装。

在自助重采样中,我们从原始训练数据集中创建大量自助样本。每个自助样本是通过随机选择观察值并替换它们创建的,通常与原始训练数据集的大小相同。对于每个自助样本,我们在随机采样中未选择的观察值上拟合模型并计算 MSE 估计值,这些观察值被称为袋外观察值。这些袋外观察值在标准交叉验证中起着类似验证集的作用。

我们然后将从每个自助样本中获得的袋外观察到的均方误差(MSEs)进行平均,以估计模型的表现。

这种方法实际上是caret包中的默认方法。我们将在第三十一章 Chapter 31 中描述如何使用caret包实现重采样方法。

29.8 MSE 估计比较

在第 29.1 节 Section 29.1 中,我们仅基于提供的测试集(在下面的图中以红色显示)计算了 MSE 的估计值。这里我们展示了上述交叉验证技术如何帮助减少变异性。下面的绿色曲线显示了应用 10 折交叉验证的结果,留出 10%的数据用于验证。我们可以看到,方差显著降低。蓝色曲线是使用 100 个自助样本估计 MSE 的结果。变异性进一步降低,但代价是计算时间增加了 10 倍。

图片

注意,三种重采样方法导致不同的\(\lambda\)选择。使用准确度曲线:

  • 简单方法选择 \(\lambda =\) 9。

  • 10 折交叉验证方法选择 \(\lambda =\) 55。

  • 自助方法选择 \(\lambda =\) 65。

这些差异突出了一个重要观点:你选择的\(\lambda\)的好坏取决于你对测试误差的估计。可靠的重采样方法是找到真正泛化良好的调整参数所必需的。

29.9 练习

以下是练习集的更清晰、更易于学生理解的版本,扩展到总共10 个练习,语言更加严谨,动机更佳,并对学生期望完成的内容进行了仔细的解释。

我还增加了一个额外的练习(现在是练习 7),使得整个练习集逻辑连贯,达到总共 10 个练习。


30 练习:过度拟合、特征选择和重采样*****

我们将处理一个预测因子和结果完全无关的数据集。这种设置有助于我们理解当我们意外地“窥视”测试集或使用完整数据集进行特征选择时,误导性结果是如何产生的。

生成数据集:

set.seed(1996)
n <- 1000
p <- 10000
x <- matrix(rnorm(n * p), n, p)
colnames(x) <- paste0("x_", 1:p)
y <- factor(rbinom(n, 1, 0.5))   # completely random binary outcome
 # smaller subset for initial exercises
x_subset <- x, [sample(p, 100)]

因为xy是独立的,没有分类器应该达到比 0.5 大得多的精度。

  1. 使用仅x_subset,随机将数据分割为 80%训练和 20%测试。

在训练集上拟合逻辑回归模型(glm,family = “binomial”),并计算测试集的精度。

重复此整个程序五次,使用新的随机分割,并报告平均测试精度

  1. 我们现在使用 t 检验来选择预测因子,尽管所有预测因子都是噪声。对于x的每一列,计算一个 t 检验,比较y = 1组中的值与y = 0组中的值,并提取 p 值:
pvals <- apply(x, 2, function(col) t.test(col ~ y)$p.value)

创建一个索引ind,包含 p 值小于 0.01 的预测因子。有多少个预测因子通过了这个截止值?

  1. 定义:
x_subset <- x[, ind]

重复练习 1,但使用这个新的x_subset

现在的平均测试精度是多少?它是否高于 0.5?

  1. 为什么数据独立时精度大于 0.5?

选择最佳的解释:

  1. 函数train估计模型训练数据上的精度。

  2. 我们过度拟合,因为模型使用了 100 个预测因子。

  3. 我们使用了整个数据集进行特征选择,但在选择预测因子后进行了交叉验证。

  4. 高精度仅仅是随机变化。

  5. 重新进行分析,但这次在每个重采样分割中进行特征选择

这意味着:

  • 对于每个折或重采样,

  • 仅使用训练部分选择预测因子,

  • 使用所选预测因子拟合模型,

  • 在相应的测试/验证部分进行评估。

你现在应该获得接近 0.5 的精度。解释原因。

  1. 加载tissue_gene_expression数据集。

分割数据:

  • 80%训练

  • 20%测试

然后,在训练集中,反复分割为:

  • 90%训练

  • 10%验证

使用这些验证分割来选择 kNN 分类器中最佳的k值(使用train)。

报告表现最佳的k值。

  1. 使用在练习 6 中找到的最佳k值,在整个训练集上重新训练 kNN 模型,并在保留的测试集上评估其精度。

与你在验证集上观察到的准确性相比,是差得多、好得多,还是大致相同?请解释为什么这个比较很重要。

  1. 我们可以使用 createResample 创建重采样样本。
set.seed(1995)
indexes <- createResample(mnist_27$train$y, 10)

在第一个重采样样本中,索引 3、4 和 7 出现了多少次?

  1. 对所有 10 个重采样样本重复之前的练习。

哪些索引在每个样本中根本不会出现?平均有多少个观察结果被省略?

  1. 用你自己的话解释为什么重采样样本(带替换的抽样):
  • 必须包含一些观察结果多次出现,

  • 必须完全省略其他所有内容,并且

  • 仍然被认为是一个有效的“重采样数据集”。

将这个解释与使用重采样样本来估计预测误差的想法联系起来。

30  监督学习方法

原文:rafalab.dfci.harvard.edu/dsbook-part-2/ml/algorithms.html

  1. 机器学习

  2. 30  监督学习方法

机器学习包括大量算法,超过一百种常用的方法,每种方法都有其独特的优势、假设和调整参数。在本章中,我们介绍了一小部分但具有代表性的算法,这些算法展示了监督学习背后的主要思想。我们的目标不是全面覆盖,而是建立对不同方法如何从数据中学习以及为什么它们会做出不同预测的直觉。

为了使示例具体且易于理解,我们使用第 28.3.1 节中引入的两个预测者数字数据集作为我们的示例。这个数据集足够小以进行可视化,同时足够丰富以揭示真实算法的行为。所有代码示例都依赖于dslabs包中的函数。稍后,在第三十一章中,我们将展示如何使用提供统一接口进行拟合和调整许多不同机器学习方法的caret包来有效地实现这些想法。

library(caret)
library(dslabs)

30.1 线性和逻辑回归

我们在书的线性模型部分介绍了线性回归和逻辑回归作为量化变量之间关联的工具。然而,这些模型也可以被视为预测算法。一旦我们从训练数据中估计了它们的参数,我们就可以通过评估拟合模型在该点来对新预测变量值 \(\mathbf{x}\) 进行预测。这种预测视角将线性回归和逻辑回归明确地纳入监督学习方法的范畴中。

线性回归

线性回归可以被视为用于连续结果的监督学习方法。给定一个数值结果 \(Y\) 和预测变量 \(\mathbf{X} = (X_1,\dots,X_p)^\top\),我们建模条件期望如下:

\[ \mathrm{E}[Y \mid \mathbf{X} = \mathbf{x}] = \beta_0 + \sum_{j=1}^p \beta_j x_j, $$ 这是一个预测变量的线性函数。线性回归提供了一个简单、可解释的基线,许多监督学习算法可以被视为这一想法的扩展或修改。 然而,当结果为分类时,线性回归是不合适的。在第 28.3.1 节中,我们介绍了**逻辑回归**作为一种专门为建模二元分类问题中的类别概率而设计的方法。 ### 逻辑回归 对于第 28.3.1 节中提到的两类,我们可以使用逻辑函数来建模类别 1 的概率: $$ \log\frac{p(\mathbf{x})}{1 - p(\mathbf{x})} = \beta_0 + \beta_1 x_1 + \beta_2 x_2, \,\, p(\mathbf{x}) = \Pr(Y = 1 \mid X_1 = x_1, X_2 = x_2), \]

这估计了观察属于类别 1 的概率。这是二元逻辑回归模型。

对于超过两个类别 (\(K > 2\)),逻辑回归可以通过两种常见方式扩展:一对多 (OvR) 和 多项式逻辑回归

在一对多方法中,我们拟合 \(K\) 个独立的二元逻辑回归模型。对于每个类别 \(k\),我们建模:

\[\log\frac{q_k(\mathbf{x})}{1 - q_k(\mathbf{x})} = \beta_{k,0} + \sum_{j=1}^p \beta_{k,j} x_j, \qquad k = 1,\dots,K. \]

在这里,\(q_k(\mathbf{x})\) 估计观察属于类别 \(k\) 相对于其他类别 的概率,但这些量不一定总和为 1,也不一定相互一致。

在拟合所有 \(K\) 个模型后,对于新的观察 \(\mathbf{x}\) 的预测类别是

\[\hat{y}(\mathbf{x}) = \arg\max_k q_k(\mathbf{x}). \]

OvR 简单,但不产生连贯的概率分布。

多项式方法通过联合建模所有类别概率来解决这个问题的不一致性。我们选择一个类别,例如类别 \(K\),作为参考,并拟合:

\[\log\frac{p_k(\mathbf{x})}{p_K(\mathbf{x})} = \beta_{k,0} + \sum_{j=1}^p \beta_{k,j} x_j, \qquad k = 1,\dots,K-1. \]

\[\eta_k(\mathbf{x}) = \beta_{k,0} + \sum_{j=1}^p \beta_{k,j} x_j, \qquad \eta_K(\mathbf{x}) = 0. \]

类别概率是通过 softmax 函数计算的:

\[p_k(\mathbf{x}) = \frac{\exp(\eta_k(\mathbf{x}))} {\sum_{j=1}^K \exp(\eta_j(\mathbf{x}))}, \qquad k=1,\dots,K. \]

这些概率自动满足

\[\sum_{k=1}^K p_k(\mathbf{x}) = 1. \]

预测的类别是

\[\hat{y}(\mathbf{x}) = \arg\max_k p_k(\mathbf{x}). \]

多项式逻辑回归通常更受欢迎,因为它产生总和为 1 的连贯概率,并联合建模类别之间的关系。一对多方法在 \(K\) 较大时计算上更简单,并且通常产生相似的 类别预测,即使相应的概率估计不同。

在 第二十八章 中,我们看到了逻辑回归在近似 \(p(\mathbf{x})\) 方面不够灵活,对于两位数的例子来说,其准确率仅为 0.775。在接下来的几节中,我们将介绍一些算法,通过提供所需的灵活性来改进这一点,每个算法都使用不同的方法来估计条件类别概率。

30.2 k-最近邻

我们在第 29.1 节中介绍了 kNN 算法。在第 29.8 节中,我们指出 \(k=65\) 提供了最高的估计准确率。使用 \(k=65\),我们获得准确率 0.825,超过了回归。估计条件概率的图表明,kNN 估计足够灵活,确实捕捉到了真实条件概率的形状。

你准备好做练习 1 - 13 了。

30.3 概率分类模型

我们已经描述了当使用平方损失时,条件期望提供了制定决策规则的最佳方法。在二元情况下,我们可以达到的最小真实错误是由贝叶斯定理决定的,这是一个基于真实条件概率的决策规则:

\[p(\mathbf{x}) = \mathrm{Pr}(Y = 1 \mid \mathbf{X}=\mathbf{x}) \]

我们已经描述了几种估计 \(p(\mathbf{x})\) 的方法。在这些方法中,我们直接估计条件概率,不考虑预测因子的分布。在机器学习中,这些被称为判别性方法。

然而,贝叶斯定理告诉我们,了解预测因子 \(\mathbf{X}\) 的分布可能是有用的。建模 \(Y\)\(\mathbf{X}\) 联合分布的方法通常被称为生成模型(我们建模整个数据,\(\mathbf{X}\)\(Y\) 的生成方式)。我们首先描述最一般的生成模型,朴素贝叶斯,然后继续描述两个具体案例,二次判别分析(QDA)和线性判别分析(LDA)。

朴素贝叶斯

回想一下,贝叶斯定理告诉我们我们可以将 \(p(\mathbf{x})\) 重新写为以下形式:

\[p(\mathbf{x}) = \mathrm{Pr}(Y = 1|\mathbf{X}=\mathbf{x}) = \frac{f_{\mathbf{X}|Y = 1}(\mathbf{x}) \mathrm{Pr}(Y = 1)} { f_{\mathbf{X}|Y = 0}(\mathbf{x})\mathrm{Pr}(Y = 0) + f_{\mathbf{X}|Y = 1}(\mathbf{x})\mathrm{Pr}(Y = 1) } \]

其中 \(f_{\mathbf{X}|Y = 1}\)\(f_{\mathbf{X}|Y = 0}\) 分别代表预测因子 \(\mathbf{X}\) 在两个类别 \(Y = 1\)\(Y = 0\) 下的分布函数。该公式意味着如果我们能够估计这些条件分布,我们就可以制定一个强大的决策规则。然而,这是一个很大的“如果”。

随着我们继续前进,我们将遇到 \(\mathbf{X}\) 具有许多维度而我们对其分布知之甚少的情况。在这些情况下,朴素贝叶斯将实际上无法实现。然而,也有一些情况,我们只有少数几个预测因子(不超过 2 个)和许多类别,在这些情况下,生成模型可以非常强大。我们描述了两个具体的例子,并使用我们之前描述的案例研究来展示它们。

让我们从一个非常简单且无趣,但具有说明性的案例开始:与预测身高相关的例子。

set.seed(1995)
y <- heights$height
train_index <- createDataPartition(y, times = 1, p = 0.5, list = FALSE)
train_set <- heights[train_index,]
test_set <- heights[-train_index,]

在这种情况下,朴素贝叶斯方法特别合适,因为我们知道正态分布是性别对身高条件分布的良好近似,对于两个类别 \(Y = 1\)(女性)和 \(Y = 0\)(男性)。这意味着我们可以通过简单地从数据中估计平均值和标准差来近似条件分布 \(f_{X|Y = 1}\)\(f_{X|Y = 0}\)

param <- train_set |> group_by(sex) |> summarize(m = mean(height), s = sd(height))

发病率,我们将用 \(\pi = \mathrm{Pr}(Y = 1)\) 表示,可以从数据中估计出来:

pi <- mean(train_set$sex == "Female")

现在我们可以使用我们对平均值和标准差的估计来得到一个实际的规则:

x <- test_set$height
f0 <- dnorm(x, param$m[2], param$s[2])
f1 <- dnorm(x, param$m[1], param$s[1])
p_hat_bayes <- f1*pi / (f1*pi + f0*(1 - pi))

我们的朴素贝叶斯估计 \(\hat{p}(x)\) 与逻辑回归估计非常相似:

事实上,我们可以证明朴素贝叶斯方法在数学上与逻辑回归预测相似。然而,我们将证明留给更高级的文本,如《统计学习基础》¹。我们可以通过比较两个结果曲线来经验性地看到它们是相似的。

控制发病率

朴素贝叶斯方法的一个有用特性是它包含一个参数来考虑发病率的不同。使用我们的样本,我们估计了 \(f_{X|Y = 1}\)\(f_{X|Y = 0}\)\(\pi\)。如果我们用帽子表示估计值,我们可以将 \(\hat{p}(x)\) 写作:

\[\hat{p}(x)= \frac{\hat{f}_{X|Y = 1}(x) \hat{\pi}} { \hat{f}_{X|Y = 0}(x)(1-\hat{\pi}) + \hat{f}_{X|Y = 1}(x)\hat{\pi} } \]

如我们之前讨论的,我们的样本发病率远低于一般人群,仅为 0.24。因此,如果我们使用规则 \(\hat{p}(x) > 0.5\) 来预测女性,我们的准确率将因低敏感性而受到影响:

y_hat_bayes <- ifelse(p_hat_bayes > 0.5, "Female", "Male")
sensitivity](https://rdrr.io/pkg/caret/man/sensitivity.html)(data = [factor(y_hat_bayes), reference = factor(test_set$sex))
#> [1] 0.324

再次强调,这是因为算法更重视特异性以应对低发病率:

specificity](https://rdrr.io/pkg/caret/man/sensitivity.html)(data = [factor(y_hat_bayes), reference = factor(test_set$sex))
#> [1] 0.947

这主要是因为 \(\hat{\pi}\) 大大低于 0.5,所以我们更倾向于预测 男性。在我们的样本中,这样做是有道理的,因为我们确实有较高的男性比例。但如果我们将这一结果外推到一般人群,我们的整体准确率将受到低敏感性的影响。

朴素贝叶斯方法为我们提供了一种直接纠正这一问题的方法,因为我们只需简单地将 \(\hat{\pi}\) 设置为我们想要的任何值。因此,为了平衡特异性和敏感性,我们不必改变决策规则中的截止值,而可以简单地像这样将 \(\hat{\pi}\) 设置为 0.5:

p_hat_bayes_unbiased <- f1 * 0.5 / (f1 * 0.5 + f0 * (1 - 0.5)) 
y_hat_bayes_unbiased <- ifelse(p_hat_bayes_unbiased > 0.5, "Female", "Male")

注意敏感性的差异,平衡得更好:

sensitivity](https://rdrr.io/pkg/caret/man/sensitivity.html)([factor(y_hat_bayes_unbiased), factor(test_set$sex))
#> [1] 0.775
specificity](https://rdrr.io/pkg/caret/man/sensitivity.html)([factor(y_hat_bayes_unbiased), factor(test_set$sex))
#> [1] 0.717

新的规则还给我们提供了一个非常直观的 66-67 之间的截止值,这大约是女性和男性平均身高的中间值:

二次判别分析

二次判别分析(QDA)是朴素贝叶斯的一种版本,其中我们假设分布 \(f_{\mathbf{X}|Y = 1}(x)\)\(f_{\mathbf{X}|Y = 0}(\mathbf{x})\) 是多元正态分布。我们在上一节中描述的简单例子实际上是 QDA。现在让我们看看一个稍微复杂一些的情况:2 或 7 的例子。

在这个例子中,我们有两个预测变量,所以我们假设每个都是二元正态分布。这意味着我们需要为每个案例 \(Y = 1\)\(Y = 0\) 估计两个平均值、两个标准差和一个相关系数。一旦我们有了这些,我们就可以近似分布 \(f_{X_1,X_2|Y = 1}\)\(f_{X_1, X_2|Y = 0}\)。我们可以轻松地从数据中估计参数,并且有了这些估计,我们只需要 prevalence \(\hat{\pi}\) 来计算:

\[\hat{p}(\mathbf{x})= \frac{\hat{f}_{\mathbf{X}|Y = 1}(\mathbf{x}) \hat{\pi}} { \hat{f}_{\mathbf{X}|Y = 0}(x)(1-\hat{\pi}) + \hat{f}_{\mathbf{X}|Y = 1}(\mathbf{x})\hat{\pi} } \]

注意,密度 \(f\) 是二元正态分布。这里我们提供了一种直观的方法来展示这种方法。我们绘制数据并使用等高线图来给出两个估计的正态密度看起来是什么样的(我们显示代表包含 95%点的区域的曲线):

我们可以使用MASS包中的qda函数来拟合 QDA:

train_qda <- MASS::qda(y ~ ., data = mnist_27$train)
y_hat <- predict(train_qda, mnist_27$test)$class

我们发现我们获得了相对较好的准确率:

confusionMatrix(y_hat, mnist_27$test$y)$overall["Accuracy"] 
#> Accuracy 
#>    0.815

条件概率看起来相对较好,尽管它不如核平滑器拟合得那么好:

QDA 不如核方法表现好的一个原因是正态性的假设并不完全成立。尽管对于 2s 来说似乎是合理的,但对于 7s 来说似乎并不准确。注意 7s 点的轻微曲率:

QDA 在这里可以工作得很好,但随着预测变量数量的增加,它变得难以使用。这里我们有 2 个预测变量,不得不计算 4 个均值、4 个标准差和 2 个相关系数。请注意,如果我们有 10 个预测变量,我们需要为每个类别估计 45 个相关系数。一般来说,我们需要估计的相关系数的数量是每个类别的 \(p(p-1)/2\),这会很快变得很大。一旦参数的数量接近数据的大小,由于过拟合,该方法变得不切实际。

线性判别分析

为了解决 QDA 参数过多的问题,一个相对简单的解决方案是假设所有类别的相关结构相同,这减少了我们需要估计的参数数量。在这种情况下,分布看起来是这样的:

我们可以使用MASS包的lda函数来拟合 LDA:

train_lda <- MASS::lda(y ~ ., data = mnist_27$train)
y_hat <- predict(train_lda, mnist_27$test)$class

现在椭圆的大小以及角度都是相同的。这是因为它们被假定为具有相同的标准差和相关性。尽管这个附加约束降低了参数的数量,但刚性降低了我们的准确性:

confusionMatrix(y_hat, mnist_27$test$y)$overall["Accuracy"]
#> Accuracy 
#>    0.775

当我们强制这个假设时,我们可以从数学上证明边界是一条线,就像逻辑回归一样。因此,我们称这种方法为 线性 判别分析(LDA)。同样,对于 QDA,我们可以证明边界必须是一个二次函数。

尽管 LDA 对于简单的数据集通常表现良好,但在这种情况下,缺乏灵活性不允许我们捕捉真实条件概率函数中的非线性。

扩展到多个类别

上述双类情况中引入的思想可以自然地扩展到多于两个类别的情形。对于 \(K\) 个类别,贝叶斯定理给出

\[p_k(\mathbf{x}) = \Pr(Y = k \mid \mathbf{X} = \mathbf{x}) = \frac{f_{\mathbf{X}\mid Y=k}(\mathbf{x}),\Pr(Y=k)}{ \sum_{l=1}^K f_{\mathbf{X}\mid Y=l}(\mathbf{x})\Pr(Y=l) }, \qquad k = 1,\dots,K. \]

构建分类器**,我们只需从训练数据中估计这个表达式中的量。对于朴素贝叶斯、LDA 或 QDA:

  • 我们使用假设的模型(朴素贝叶斯中的独立性,LDA 中的共享协方差的正态分布,或 QDA 中的类别特定协方差的正态分布)来估计类条件密度 \(f_{\mathbf{X}\mid Y=k}(\mathbf{x})\)

  • 我们使用经验频率 \(\hat{\pi}_k = \frac{1}{n}\sum_{i=1}^n \mathbf{1}(y_i = k)\) 来估计类比例 \(\Pr(Y = k)\)

一旦这些成分被估计,我们计算所有 \(k\)\(\hat{p}_k(\mathbf{x})\),并将 \(\mathbf{x}\) 分配给后验概率最大的类别。

这种泛化不需要超出双类情况的新想法,唯一的区别是我们为所有 \(K\) 个类别重复相同的估计。

与距离的关系

在 LDA 和 QDA 中,我们假设每个类别都来自从 训练数据 中估计的多变量正态分布。当我们对一个新观测 \(\mathbf{x}\) 进行分类时,算法为每个类别 \(k\) 计算:

\[f_{\mathbf{X}\mid Y=k}(\mathbf{x}),\Pr(Y=k) \]

并预测具有最大值的类别。

因此,理解正态密度如何变化有助于解释为什么 LDA 和 QDA 表现得像基于距离的分类器。为了简化,我们考虑类别平衡的情况:\(\Pr(Y=k) = 1/K\) 对于所有 \(k\)

让我们先考察一维正态密度和

\[f(x) = \frac{1}{\sqrt{2\pi}\sigma} \exp\left\{-\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)²\right\}. \]

如果我们忽略乘法常数 \(1/(\sqrt{2\pi}\sigma)\) 并取对数,我们得到:

\[\log f(x) \propto -\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)² \]

关键点是:

\[\log f(x) \text{ 随着 } (x-\mu)² \text{ 的增加而减少。} \]

换句话说,密度最高的类就是均值最接近(x)的类(在 \(\sigma\),即类内标准差缩放后)。

对于多元高斯类,同样的想法适用,但涉及更一般的距离概念。对数密度变为:

\[\log f(\mathbf{x}) \propto (\mathbf{x} - \boldsymbol{\mu})^\top\Sigma^{-1}(\mathbf{x} - \boldsymbol{\mu}), \]

这是对马氏距离平方的负值。

  • 在 LDA 中,所有类使用相同的协方差 \(\Sigma\)。因此,每个类通过其(缩放后的)平方距离到类均值来给 \(\mathbf{x}\) 打分。

  • 在 QDA 中,每个类都有自己的协方差,因此每个类使用不同的距离度量。

因此,LDA 和 QDA 都可以被视为基于距离的分类器:一个新观测值被分配给其高斯模型在马氏距离上最接近的类。

你现在可以开始做 14-17 题练习了*****。

30.4 分类和回归树(CART)

分类和回归树(CART)通过反复将预测空间分割成更小、更均匀的区域,提供了一种灵活、直观的预测方法。CART 不是拟合一个全局模型,而是构建一个树形决策规则集,以适应数据中的复杂模式。在本节中,我们将阐述这一想法,并提供一些关于其实现的细节。

维度灾难

我们描述了为什么 LDA 和 QDA 等方法不适用于许多预测因子 \(p\),因为我们需要估计的参数数量变得过大。例如,在数字示例中 \(p = 784\),我们会有超过 600,000 个参数使用 LDA,而对于 QDA,这个数字会乘以类的数量。核方法,如 kNN 或局部回归,没有模型参数需要估计。然而,由于所谓的“维度灾难”,当使用多个预测因子时,它们也面临挑战。这里的“维度”指的是当我们有 \(p\) 个预测因子时,两个观测值之间的距离是在 \(p\) 维空间中计算的。

理解维度灾难的一个有用方法是考虑为了包含给定百分比的样本,我们需要将跨度/邻域/窗口做得有多大。记住,随着邻域的增大,我们的方法会失去灵活性,而为了保持灵活性,我们需要保持邻域较小。

为了看到这如何成为高维问题,假设我们有一个在[0,1]区间内等间距的连续预测因子,我们想要创建包含 1/10th 数据的窗口。那么很容易看出我们的窗口大小必须是 0.1:

现在对于两个预测因子,如果我们决定保持邻域大小不变,每个维度 10%,我们只包含 1 个点。如果我们想包含 10%的数据,那么我们需要将正方形的每一边的长度增加到\(\sqrt{.10} \approx .316\)

使用相同的逻辑,如果我们想在三维空间中包含 10%的数据,那么每个立方体的边长是\(\sqrt[3]{.10} \approx 0.464\)。一般来说,为了在\(p\)维的情况下包含 10%的数据,我们需要一个边长为\(\sqrt[p]{.10}\)的区间,这是总体的 10%。这个比例很快就会接近 1,如果比例是 1,这意味着我们包含了所有数据,并且不再进行平滑处理。

当我们达到 100 个预测因子时,邻域就不再非常局部了,因为每一侧几乎覆盖了整个数据集。

在这里,我们探讨一系列优雅且多功能的算法,这些算法适用于高维空间,并允许这些区域采取更复杂的形状,同时仍然产生可解释的模型。这些方法非常流行,广为人知,并且得到了广泛的研究。我们将专注于回归和决策树,以及它们在随机森林中的扩展。

CART 动机

为了激励本节,我们将使用一个新的数据集,该数据集包括橄榄油成分分解为 8 种脂肪酸的详细情况:

names(olive)
#>  [1] "region"      "area"        "palmitic"    "palmitoleic"
#>  [5] "stearic"     "oleic"       "linoleic"    "linolenic" 
#>  [9] "arachidic"   "eicosenoic"

为了说明目的,我们将尝试使用脂肪酸成分值作为预测因子来预测区域。

table(olive$region)
#> 
#> Northern Italy       Sardinia Southern Italy 
#>            151             98            323

我们移除了area列,因为我们不会将其用作预测因子。

olive <- select(olive, -area)

使用 kNN,我们可以达到测试集准确率 0.97。然而,一些数据探索揭示,我们应该能够做得更好。例如,如果我们按地区对每个预测因子的分布进行分层,我们会看到 eicosenoic 仅在意大利南部存在,而 linoleic 将北意大利与撒丁岛分开。

这意味着我们应该能够构建一个完美预测的算法!我们可以通过绘制 eicosenoic 和 linoleic 的值来清楚地看到这一点。

在第 22.4 节中,我们定义了预测空间,在这种情况下,它由八个维度上的点组成,这些点的值在 0 到 100 之间。在上面的图中,我们展示了由两个预测因子 eicosenoic 和 linoleic 定义的空间,并且通过目测,我们可以构建一个预测规则,将预测空间划分为每个部分只包含一个类别的结果。

这反过来可以用来定义一个具有完美准确性的算法。具体来说,我们定义以下决策规则:如果 eicosenoic 大于 0.065,预测南意大利。如果不是,那么如果 linoleic 大于\(10.535\),预测撒丁岛,如果更低,预测北意大利。我们可以如下绘制这个决策树:

图片

这种决策树在实践中的应用很常见。例如,为了决定心脏病发作后一个人的不良结果风险,医生会使用以下方法:

图片

(来源:Walton 2010 非正式逻辑,第 30 卷,第 2 期,第 159-184 页²。)

树形图本质上是一个是或否的问题流程图。我们描述的方法的总体思路是定义一个算法,使用数据来创建这些带有预测结果的树,这些结果被称为节点。回归树和决策树通过通过划分预测变量来预测结果变量 \(y\)

回归树

当使用树形图,并且结果为连续时,我们称这种方法为回归树。为了介绍回归树,我们将使用 2008 年的民意调查数据,这些数据在 @ec-smoothing 中使用,来描述我们构建这些算法的基本思想。与其他机器学习算法一样,我们将尝试估计条件期望 \(f(x) = \mathrm{E}(Y | X = x)\),其中 \(Y\) 是投票差额,\(x\) 是日期。

图片

这里的基本思路是构建一个决策树,并在每个 节点 的末尾获得一个预测变量 \(\hat{y}\)。用数学方式描述就是:我们将预测空间划分为 \(J\) 个非重叠区域,\(R_1, R_2, \ldots, R_J\),然后对于任何位于区域 \(R_j\) 内的预测变量 \(x\),我们使用与相关预测变量 \(x_i\) 也在 \(R_j\) 内的训练观察值 \(y_i\) 的平均值来估计 \(f(x)\)

但我们如何决定分区 \(R_1, R_2, \ldots, R_J\) 以及如何选择 \(J\)?这正是算法变得复杂的地方。

回归树递归地创建分区。我们以一个分区开始算法,即整个预测空间。在我们简单的第一个例子中,这个空间是区间 [-155, 1]。但在第一步之后,我们将有两个分区。第二步后,我们将其中一个分区分成两个,并将有三个分区,然后是四个,以此类推。稍后我们将描述如何选择分区以及何时停止。

对于每个现有的分区,我们找到一个预测变量 \(j\) 和值 \(s\),定义两个新的分区,我们将它们称为 \(R_1(j,s)\)\(R_2(j,s)\),通过询问 \(x_j\) 是否大于 \(s\) 来分割当前分区:

\[R_1(j,s) = \{\mathbf{x} \mid x_j < s\} \mbox{ 和 } R_2(j,s) = \{\mathbf{x} \mid x_j \geq s\} \]

在我们当前的例子中,我们只有一个预测变量,所以我们总是选择 \(j = 1\),但在一般情况下,情况并非如此。现在,在我们定义了新的分区 \(R_1\)\(R_2\) 并决定停止分区后,我们将通过取所有相关 \(\mathbf{x}\)\(R_1\)\(R_2\) 中的观察值 \(y\) 的平均值来计算预测变量。我们分别将这些称为 \(\hat{y}_{R_1}\)\(\hat{y}_{R_2}\)

但我们如何选择 \(j\)\(s\) 呢?基本上,我们要找到使残差平方和(RSS)最小的那一对:

\[\sum_{i:\, x_i \in R_1(j,s)} (y_i - \hat{y}_{R_1})² + \sum_{i:\, x_i \in R_2(j,s)} (y_i - \hat{y}_{R_2})² \]

然后将此递归应用于新的区域 \(R_1\)\(R_2\)。我们稍后会描述如何停止,但一旦我们将预测空间分割成区域,在每个区域中,我们使用该区域的观测值进行预测。

让我们看看这个算法在 2008 年总统选举民意调查数据上的表现。我们将使用 rpart 包中的 rpart 函数。

library(rpart)
fit <- rpart(margin ~ ., data = polls_2008)

在这种情况下,只有一个预测器。因此我们不需要决定通过哪个预测器 \(j\) 进行分割,我们只需要决定使用什么值 \(s\) 进行分割。我们可以直观地看到分割的位置:

plot(fit, margin = 0.1)
text(fit, cex = 0.75)

* *第一次分割发生在第 39.5 天。然后其中一个区域在第 86.5 天被分割。这两个新的分区分别在第 49.5 天和第 117.5 天进行分割,以此类推。我们最终得到 8 个分区。

您也可以通过输入以下命令来查看分区:

summary(fit)

最终的估计 \(\hat{f}(x)\) 看起来是这样的:

polls_2008 |> 
 mutate(y_hat = predict(fit)) |> 
 ggplot() +
 geom_point(aes(day, margin)) +
 geom_step(aes(day, y_hat), col = "red")

* *请注意,算法在 8 个节点处停止了分区。这个决定是基于一个称为复杂度参数(cp)的度量。每次数据被分成两个新的分区时,训练集的 RSS 都会减少,因为模型获得了适应数据的灵活性。如果继续分割直到每个观测值都孤立在自己的分区中,RSS 最终会达到 0,因为单个值的平均值就是该值本身。为了防止这种过度拟合,算法要求每个新的分割至少减少 RSS 的 cp 倍。因此,较大的 cp 值会提前停止算法,导致节点数量减少。

然而,cp 不是控制分割的唯一因素。另一个重要的参数是在节点可以分割之前所需的最小观测数,由 rpart 中的 minsplit 参数设置(默认:20)。此外,rpart 允许用户指定终端节点(叶子)中允许的最小观测数,由 minbucket 参数控制。默认情况下,minbucket = round(minsplit / 3)

如预期的那样,如果我们设置 cp = 0minsplit = 2,那么我们的预测将尽可能灵活,我们的预测器就是我们的原始数据:

直观上我们知道这不是一个好的方法,因为它通常会导致过度训练。这些 cpminsplitminbucket 参数可以用来控制最终预测器的变异性。这些值越大,用于计算预测器的数据平均就越多,从而减少变异性。缺点是它限制了灵活性。

那么,我们如何选择这些参数呢?我们可以使用交叉验证,就像任何调整参数一样。以下是使用交叉验证选择 cp 值的结果树:

图片

注意,如果我们已经有一个树并且想要应用更高的 cp 值,我们可以使用prune函数。我们称这种操作为剪枝,因为我们正在剪掉不符合cp标准的分区。以下是一个例子,我们创建了一个使用cp = 0的树,然后将其剪枝回来:

fit <- rpart(margin ~ ., data = polls_2008, control = rpart.control(cp = 0))
pruned_fit <- prune(fit, cp = 0.01)

分类(决策)树

分类树,或决策树,用于预测问题,其中结果为分类。我们使用相同的分区原则,但有一些不同,以解释我们现在处理的是分类结果**。

第一个不同之处在于,我们通过计算分区中训练集观测值中最常见的类别来形成预测,而不是在每个分区中取平均值(因为我们不能对类别取平均值)。

第二个限制是我们不能再使用 RSS 来选择分区。虽然我们可以使用寻找最小化训练错误的朴素方法来寻找分区,但更好的方法使用更复杂的指标。其中两个更受欢迎的指标是基尼指数

在一个完美的场景中,我们每个分区中的结果都属于同一类别,因为这将允许达到完美的准确性。在这种情况下,基尼指数将为 0,并且随着我们偏离这个场景的程度增加而变大。为了定义基尼指数,我们定义 \(\hat{p}_{j,k}\) 为分区 \(j\) 中属于类别 \(k\) 的观测值的比例。基尼指数定义为:

\[\mbox{Gini}(j) = \sum_{k = 1}^K \hat{p}_{j,k}(1-\hat{p}_{j,k}) \]

如果你仔细研究这个公式,你会看到在上述描述的完美场景中,它实际上为 0。

熵*是一个相关的量,定义为:

\[\mbox{entropy}(j) = -\sum_{k = 1}^K \hat{p}_{j,k}\log(\hat{p}_{j,k}), \mbox{ with } 0 \times \log(0) \mbox{ defined as }0 \]

如果我们在 2 或 7 的例子上使用分类树,我们达到了 0.81 的准确率,这比回归更好,但不如我们使用核方法达到的好。

估计条件概率的图示向我们展示了分类树的限制:

图片

注意,在使用决策树时,由于每个分区都会产生不连续性,因此很难使边界平滑。

分类树具有某些优势,使它们非常有用。它们高度可解释,甚至比线性模型更可解释。它们也容易可视化(如果足够小)。最后,它们可以模拟人类决策过程,并且不需要为分类变量使用虚拟预测器。另一方面,通过递归划分的方法很容易过拟合,因此比线性回归或 kNN 更难训练。此外,在准确性方面,它很少是表现最好的方法,因为它不太灵活,并且对训练数据的变化高度不稳定。接下来解释的随机森林改进了这些不足之处。

30.5 随机森林

随机森林是一种 非常流行 的机器学习方法,它通过一个巧妙的思想来解决决策树的不足。目标是通过对多个决策树进行 平均 来提高预测性能和减少不稳定性:一个由 随机性 构建的 森林。它有两个有助于实现这一目标的特点。

第一步是 自助聚集袋装法。一般思路是生成许多预测器,每个预测器使用回归或分类树,然后根据所有这些树的平均预测形成最终预测。为了确保单个树不相同,我们使用自助法来引入随机性。这两个特点结合起来解释了名称:自助法使单个树 随机 不同,而树的组合就是 森林。具体步骤如下。

  1. 使用训练集构建 \(B\) 个决策树。我们将拟合的模型称为 \(T_1, T_2, \dots, T_B\)

  2. 对于测试集中的每个观测值,使用树 \(T_j\) 形成预测 \(\hat{y}_j\)

  3. 对于连续结果,使用平均 \(\hat{y} = \frac{1}{B} \sum_{j = 1}^B \hat{y}_j\) 形成最终预测。对于分类数据分类,使用多数投票(\(\hat{y}_1, \dots, \hat{y}_B\) 中最频繁的类别)来预测 \(\hat{y}\)

那么,我们如何从一个单个训练集中得到不同的决策树?为此,我们使用两种方式来引入随机性,以下步骤中会进行解释。设 \(N\) 为训练集中的观测数。为了从训练集中创建 \(T_j, \, j = 1,\ldots,B\),我们执行以下操作:

  1. 通过从训练集中有放回地抽取 \(N\) 个观测值来创建一个自助训练集。这是引入随机性的第一种方式。

  2. 在机器学习挑战中,通常有许多特征。通常,许多特征可能是有信息的,但将它们全部包含在模型中可能会导致过拟合。随机森林引入随机性的第二种方式是通过随机选择要包含在每个树构建中的特征。每个树选择一个不同的随机子集。这减少了森林中树之间的相关性,从而提高了预测精度。

为了说明第一步如何导致更平滑的估计,我们将对 2008 年的民意调查数据进行随机森林拟合。我们将使用randomForest包中的randomForest函数:

library(randomForest)
fit <- randomForest(margin ~ ., data = polls_2008) 

注意,如果我们将plot函数应用于结果对象,我们可以看到随着我们添加树,我们算法的错误率如何变化:

plot(fit)

在这种情况下,随着我们添加更多的树,准确率提高,直到我们使用了大约 300 棵树之后,准确率稳定下来。

使用这种方法得到的随机森林估计结果,如上所示:

y_hat <-  predict(fit, newdata = polls_2008)

如以下红色曲线所示:

注意,随机森林的估计比我们在上一节中使用的回归树要平滑得多。这是因为许多阶梯函数的平均值可以是平滑的。我们可以通过观察随着我们添加更多树时估计如何变化来看到这一点。在下面的图中,你可以看到每个\(b\)值的多个自助样本,对于每一个,我们看到用灰色拟合的树,用较浅灰色拟合的前面的树,以及到那个点为止所有估计的树的平均值。

library(randomForest)
train_rf <- randomForest(y ~ ., data = mnist_27$train)

对于我们的 2 或 7 个示例,随机森林拟合的准确率为 0.825。以下是条件概率的图示:

可视化估计显示,尽管我们获得了高精度,但似乎通过使估计更平滑,仍有改进的空间。这可以通过改变控制树节点中数据点最小数量的参数来实现。这个最小值越大,最终的估计就越平滑。如果我们使用 65 个节点的节点大小,使用 kNN 时使用的邻居数量,我们得到 0.815 的准确率。所选模型提高了准确率,并提供了更平滑的估计:

变量重要性

随机森林在所有我们考虑的例子中都优于树。然而,随机森林的一个缺点是我们失去了可解释性。有助于提高可解释性的方法之一是检查变量重要性。为了定义变量重要性,我们计算预测器在单个树中使用的频率。你可以在高级机器学习书中了解更多关于变量重要性的信息³。randomForest包包括一个名为importance的函数,可以从拟合的随机森林模型中提取变量重要性。caret包包括一个名为varImp的函数,可以从任何实现了计算的模型中提取变量重要性。我们将在下一节中给出如何使用变量重要性的示例。

30.6 练习

  1. 使用以下代码创建数据集:
n <- 100
Sigma <- 9*matrix(c(1.0, 0.5, 0.5, 1.0), 2, 2)
dat <- MASS::mvrnorm](https://rdrr.io/pkg/MASS/man/mvrnorm.html)(n = 100, [c(69, 69), Sigma) |>
 data.frame() |> setNames(c("x", "y"))

使用caret包将数据集划分为大小相等的测试集和训练集。训练一个线性模型并报告 RMSE。重复此练习 100 次,并绘制 RMSE 的直方图,报告平均值和标准差。提示:像这样修改前面显示的代码:

library(caret)
y <- dat$y
test_index <- createDataPartition(y, times = 1, p = 0.5, list = FALSE)
train_set <- dat[train_index,]
test_set <- dat[-train_index,]
fit <- lm(y ~ x, data = train_set)
y_hat <- fit$coef[1] + fit$coef[2]*test_set$x
sqrt(mean((y_hat - test_set$y)²))

将其放入对replicate的调用中。

  1. 现在我们将重复上述操作,但使用更大的数据集。重复练习 1,但对于n <- c(100, 500, 1000, 5000, 10000)的数据集。保存 100 次重复的 RMSE 平均值和标准差。提示:使用sapplymap函数。

  2. 描述随着数据集大小的增加,RMSE 的观察结果。

  3. 平均而言,随着n的增大,RMSE 变化不大,而 RMSE 的变异性确实在减小。

  4. 由于大数定律,RMSE 减小:数据越多,估计越精确。

  5. n = 10000不够大。为了看到 RMSE 的下降,我们需要将其做得更大。

  6. RMSE 不是一个随机变量。

  7. 现在重复练习 1,但这次通过改变Sigma来使xy之间的相关性更大:

n <- 100
Sigma <- 9*matrix(c(1, 0.95, 0.95, 1), 2, 2)
dat <- MASS::mvrnorm](https://rdrr.io/pkg/MASS/man/mvrnorm.html)(n = 100, [c(69, 69), Sigma) |>
 data.frame() |> setNames(c("x", "y"))

重复练习并注意 RMSE 现在发生了什么。

  1. 以下哪个选项最好地解释了为什么练习 4 中的 RMSE 比练习 1 低得多:

  2. 这只是运气。如果我们再试一次,它可能会更大。

  3. 中心极限定理告诉我们 RMSE 是正态分布的。

  4. 当我们增加xy之间的相关性时,x具有更强的预测能力,从而提供了对y的更好估计。这种相关性对 RMSE 的影响比n大得多。大的n只是为我们提供了更精确的线性模型系数估计。

  5. 这两个都是回归的例子,所以 RMSE 必须相同。

  6. 使用以下代码创建数据集:

n <- 1000
Sigma <- matrix(c(1, 3/4, 3/4, 3/4, 1, 0, 3/4, 0, 1), 3, 3)
dat <- MASS::mvrnorm](https://rdrr.io/pkg/MASS/man/mvrnorm.html)(n = 100, [c(0, 0, 0), Sigma) |>
 data.frame() |> setNames(c("y", "x_1", "x_2"))

请注意,yx_1x_2都相关,但两个预测因子彼此独立。

cor(dat)

使用caret包将数据集划分为大小相等的测试集和训练集。比较仅使用x_1、仅使用x_2以及同时使用x_1x_2时的 RMSE。训练一个线性模型并报告 RMSE。

  1. 重复练习 6,但现在创建一个x_1x_2高度相关的例子:
n <- 1000
Sigma <- matrix(c(1.0, 0.75, 0.75, 0.75, 1.0, 0.95, 0.75, 0.95, 1.0), 3, 3)
dat <- MASS::mvrnorm](https://rdrr.io/pkg/MASS/man/mvrnorm.html)(n = 100, [c(0, 0, 0), Sigma) |>
 data.frame() |> setNames(c("y", "x_1", "x_2"))

使用caret包将数据集划分为大小相等的测试集和训练集。比较仅使用x_1、仅使用x_2以及同时使用x_1x_2时的 RMSE。训练一个线性模型并报告 RMSE。

  1. 比较第 6 和第 7 的结果,并选择你同意的陈述:

  2. 添加额外的预测因子可以显著提高 RMSE,但不是当它们与另一个预测因子高度相关时。

  3. 添加额外的预测因子在两个练习中都能同样提高预测。

  4. 添加额外的预测因子会导致过拟合。

  5. 除非我们包含所有预测因子,否则我们没有预测能力。

  6. 定义以下数据集:

make_data <- function(n = 1000, p = 0.5, 
 mu_0 = 0, mu_1 = 2, 
 sigma_0 = 1,  sigma_1 = 1){
 y <- rbinom(n, 1, p)
 f_0 <- rnorm(n, mu_0, sigma_0)
 f_1 <- rnorm(n, mu_1, sigma_1)
 x <- ifelse(y == 1, f_1, f_0)
 test_index <- createDataPartition(y, times = 1, p = 0.5, list = FALSE)
 list(train = data.frame(x = x, y = as.factor(y)) |> 
 slice(-test_index),
 test = data.frame(x = x, y = as.factor(y)) |> 
 slice(test_index))
}

请注意,我们已定义了一个变量x,它可以预测二元结果y

dat$train |> ggplot(aes(x, color = y)) + geom_density()

通过将(Y)视为连续结果来拟合线性回归模型,然后通过预测值超过 0.5 时将观测值分类为 1,来拟合一个逻辑回归模型。接下来,对相同的数据拟合逻辑回归模型,并比较这两种方法的分类准确率。

  1. 将练习 9 的模拟重复 100 次,并比较每种方法的平均准确率,注意它们给出几乎相同的答案。

  2. 生成 25 个不同的数据集,改变两个类别之间的差异:delta <- seq(0, 3, len = 25)。绘制准确率与delta的关系图。

  3. dslabs包包含一个类似于mnist_27的示例数据集,但也包括 1。您可以加载并探索它如下:

library(dslabs)
mnist_127$train |> ggplot(aes(x_1, x_2, color = y)) + geom_point()

使用MASS包中的qda函数拟合 QDA,然后为测试预测创建混淆矩阵。以下哪项最能描述混淆矩阵:

  1. 它是一个二乘二的表格。

  2. 因为我们有三个类别,所以它是一个二乘三的表格。

  3. 因为我们有三个类别,所以它是一个三乘三的表格。

  4. 混淆矩阵仅在结果为二元时才有意义。

  5. confusionMatrix对象返回的byClass组件为每个类别提供敏感性和特异性。因为这些术语仅在数据为二元时才有意义,所以每一行表示当特定类别为 1(阳性)而其他两个被视为 0(阴性)时的敏感性和特异性。根据confusionMatrix返回的值,以下哪项是最常见的错误:

  6. 将 1s 称为 2 或 7。

  7. 将 2s 称为 1 或 7。

  8. 将 7s 称为 1 或 2。

  9. 所有错误都同样常见。

  10. 要可视化 QDA 决策边界,首先创建一个密集的点网格,覆盖\((x_1, x_2)\)空间:

GS <- 150
new_x <- with(mnist_127$train,
 expand.grid(
 x_1 = seq(min(x_1), max(x_1), length.out = GS),
 x_2 = seq(min(x_2), max(x_2), length.out = GS)
 )
)

使用拟合的 QDA 模型通过predict(qda_fit, newdata = new_x)计算网格上的预测概率。这将给出网格中每个点的\(\hat{p}(x_1, x_2)\)

最后,绘制网格,并根据\(\hat{p}(x_1, x_2) > 0.5\)(预测类别 1)或\(\hat{p}(x_1, x_2) \le 0.5\)(预测类别 0)来为每个点着色。生成的图像显示了由 QDA 确定的决策区域。

  1. 重复练习 14,但对于 LDA。以下哪项解释了为什么 LDA 的准确率较差:

  2. LDA 通过线分隔空间,使其过于僵化。

  3. LDA 将空间分为两部分,有三种类别。

  4. LDA 与 QDA 非常相似,差异是由于偶然性。

  5. LDA 不能用于超过一个类别。

  6. 现在重复练习 12,对于 kNN 使用\(k=31\),并计算和比较三种方法的总体准确率。

  7. 要了解为什么像 kNN 这样的简单方法可以优于明确尝试模拟贝叶斯规则的模型,探索x_1x_2的条件分布,以查看正态近似是否成立。生成模型可以非常强大,但只有当我们能够成功近似每个类别的预测变量的联合分布时。

  8. 之前我们使用逻辑回归从身高预测性别。使用 kNN 来做同样的事情。使用本章中描述的代码选择 \(F_1\) 测量值,并将其与 \(k\) 进行比较。与使用回归获得的约 0.6 的 \(F_1\) 值进行比较。

  9. 创建一个简单的数据集,其中结果在预测变量每增加一个单位时平均增长 0.75 个单位。

n <- 1000
sigma <- 0.25
x <- rnorm(n, 0, 1)
y <- 0.75 * x + rnorm(n, 0, sigma)
dat <- data.frame(x = x, y = y)

使用 rpart 拟合回归树并将结果保存到 fit 中。

  1. 绘制最终的树,以便可以看到分区发生的位置。

  2. 绘制 yx 的散点图,并展示基于拟合的预测值。

  3. 现在使用来自 randomForest 包的 randomForest 函数来训练一个随机森林而不是回归树,并重新绘制带有预测线的散点图。

  4. 使用 plot 函数查看随机森林是否收敛,或者是否需要更多的树。

  5. 默认的随机森林参数导致估计结果过于灵活(不平滑)。重新运行随机森林,但这次将 nodesize 设置为 50,将 maxnodes 设置为 25。重新绘制图表。

  6. 这个 dslabs 数据集包括 tissue_gene_expression,其中有一个矩阵 x

library(dslabs)
dim(tissue_gene_expression$x)

使用在 189 个生物样本上测量的 500 个基因的表达来表示七个不同的组织。组织类型存储在 tissue_gene_expression$y 中。

table(tissue_gene_expression$y)

使用包 randomForest 中的 randomForest 函数拟合随机森林。然后使用 varImp 函数查看哪些是前 10 个最具预测性的基因。绘制报告的重要性直方图,以了解重要性值的分布。


  1. https://web.stanford.edu/~hastie/Papers/ESLII.pdf↩︎

  2. https://papers.ssrn.com/sol3/Delivery.cfm/SSRN_ID1759289_code1486039.pdf?abstractid = 1759289&mirid = 1&type = 2↩︎

  3. https://web.stanford.edu/~hastie/Papers/ESLII.pdf↩︎

31  构建机器学习模型

原文:rafalab.dfci.harvard.edu/dsbook-part-2/ml/ml-in-practice.html

  1. 机器学习

  2. 31  构建机器学习模型

构建机器学习模型需要不仅仅是理解底层数学。理论帮助我们避免概念错误并选择适当的方法,但将这些想法转化为有效、现实世界的模型还需要额外的实际考虑。我们必须决定如何预处理和转换特征,结合上下文知识来选择相关预测因子,管理计算约束,调整和比较算法,并且通常通过集成等技术结合多个模型以提升预测性能。

在本章中,我们通过使用 MNIST 手写数字数据集进行完整的案例研究,将这些内容整合在一起,展示了早期章节中开发的工具概念如何转化为实际的机器学习工作流程。

31.1 案例研究:手写数字识别

我们将使用在第 21.2 节中介绍的 MNIST 数据集。我们可以使用以下dslabs包函数加载数据:

library(dslabs)
mnist <- read_mnist()

该数据集包括两个部分,一个训练集和一个测试集:

names(mnist)
#> [1] "train" "test"

每个组件都包含一个矩阵,其中列包含特征:

dim(mnist$train$images)
#> [1] 60000   784

以及一个包含类别的整数向量:

class(mnist$train$labels)
#> [1] "integer"
table(mnist$train$labels)
#> 
#>    0    1    2    3    4    5    6    7    8    9 
#> 5923 6742 5958 6131 5842 5421 5918 6265 5851 5949

因为我们希望这个例子能在笔记本电脑上运行,并且在一小时内完成,所以我们将考虑数据集的一个子集。我们将从训练集中随机抽取 10,000 行,从测试集中随机抽取 1,000 行:

set.seed(1990)
index <- sample(nrow(mnist$train$images), 10000)
x <- mnist$train$images[index,]
y <- factor(mnist$train$labels[index])
index <- sample(nrow(mnist$test$images), 1000)
x_test <- mnist$test$images[index,]
y_test <- factor(mnist$test$labels[index])

但您也可以尝试在整个数据集上应用这些想法。

当将模型拟合到大型数据集时,我们建议使用矩阵而不是数据框,因为矩阵操作通常更快。在caret包中,预测矩阵必须具有列名,以便在测试集上的预测过程中准确跟踪特征。如果矩阵缺少列名,可以根据它们的位置分配名称:

colnames(x) <- 1:ncol(mnist$train$images)
colnames(x_test) <- colnames(x)

31.2 caret 包

我们已经学习了多种机器学习算法。其中许多算法在 R 中实现。然而,它们由不同的作者通过不同的包分发,并且通常使用不同的语法。caret包试图统一这些差异并提供一致性。它目前包括 200 多种不同的方法,这些方法在caret包手册¹中进行了总结。请注意,caret不包括运行每个可能算法所需的包。要通过caret应用机器学习方法,您仍然需要安装实现该方法的库。每种方法的所需包在包手册中描述。

caret** 包还提供了一个为我们执行交叉验证的函数。这里我们提供一些示例,说明我们如何使用这个有用的包。我们首先使用 2 或 7 个示例来演示,在后面的章节中,我们将使用该包在更大的 MNIST 数据集上运行算法。

train 函数

R 中拟合机器算法的函数都略有不同。例如,lmglmqdaldaknn3rpartrandomForest 使用不同的语法,具有不同的参数名称,并生成不同类型的对象。

caret** 的 train 函数让我们可以使用类似的语法训练不同的算法。因此,例如,我们可以键入以下内容来训练三个不同的模型:

library(caret)
train_glm <- train(y ~ ., method = "glm", data = mnist_27$train)
train_qda <- train(y ~ ., method = "qda", data = mnist_27$train)
train_knn <- train(y ~ ., method = "knn", data = mnist_27$train)

正如我们在 第 31.2.3 节 中更详细地解释的那样,train 函数使用重采样方法为您选择参数,以估计 MSE,默认使用 bootstrap。

predict 函数

predict 函数是 R 中机器学习和统计建模中最有用的工具之一。它接受一个拟合的模型对象和一个包含我们想要进行预测的特征 \(\mathbf{x}\) 的数据框,然后返回相应的预测值。

这里有一个逻辑回归的例子:

fit <- glm(y ~ ., family = "binomial", data = mnist_27$train)
p_hat <- predict(fit, newdata = mnist_27$test)

在这种情况下,该函数只是简单地计算

\[\hat{p}(\mathbf{x}) = g^{-1}\left(\hat{\beta}_0 + \hat{\beta}_1 x_1 + \hat{\beta}_2 x_2 \right) \text{ with } g(p) = \log\frac{p}{1-p} \implies g^{-1}(\mu) = \frac{1}{1-e^{-\mu}} \]

对于测试集 mnist_27$test 中的 x_1x_2,有了这些估计值,我们可以做出预测并计算我们的准确率:

y_hat <- factor(ifelse(p_hat > 0.5, 7, 2))

然而,请注意,predict 并不总是返回相同类型的对象;这取决于它应用的对象类型。要了解具体细节,您需要查看特定于所使用的拟合对象类型的帮助文件。

predict 实际上是 R 中一种特殊类型的函数,称为 泛型函数。泛型函数根据接收到的对象类型调用其他函数。因此,如果 predict 接收到来自 lm 函数的对象,它将调用 predict.lm。如果它接收来自 glm 的对象,它将调用 predict.glm。如果拟合来自 knn3,它将调用 predict.knn3,依此类推。这些函数相似但不完全相同。您可以通过阅读帮助文件了解更多关于它们之间差异的信息:

?predict.glm
?predict.qda
?predict.knn3

predict 函数有多个版本,许多机器学习算法定义了自己的 predict 函数。

train 函数一样,caret 包统一了 predict 函数的使用,通过 predict.train 函数。此函数接受 train 函数的输出,并产生类别预测或 \(p(\mathbf{x})\) 的估计。

所有方法的代码看起来都一样:

y_hat_glm <- predict(train_glm, mnist_27$test, type = "raw")
y_hat_qda <- predict(train_qda, mnist_27$test, type = "raw")
y_hat_knn <- predict(train_knn, mnist_27$test, type = "raw")

这使我们能够快速比较算法。例如,我们可以这样比较准确率:

fits <- list(glm = train_glm, qda = train_qda, knn = train_knn)
sapply(fits, function(fit) 
 mean(predict(fit, mnist_27$test, type = "raw") == mnist_27$test$y))
#>   glm   qda   knn 
#> 0.775 0.815 0.835

重采样

当算法包含一个调整参数时,train会自动使用重采样方法来估计均方误差(MSE)并在几个默认候选值之间进行选择。要找出哪些参数被优化,可以阅读caret手册²或研究以下输出的结果:

modelLookup("knn")

要了解caret如何实现 kNN 的所有细节,可以使用以下方法:

getModelInfo("knn")

如果我们使用默认值运行它:

train_knn <- train(y ~ ., method = "knn", data = mnist_27$train)

你可以使用ggplot函数快速查看交叉验证的结果。highlight参数突出显示最大值:

ggplot(train_knn, highlight = TRUE)

* *默认情况下,重采样是通过取 25 个自助样本来进行的,每个样本包含 25%的观测值。

对于knn方法,默认值是尝试\(k=5,7,9\)。我们可以使用tuneGrid参数来更改这一点。值网格必须由一个数据框提供,其中参数名称与modelLookup输出中指定的名称相匹配。

在这里,我们提供了一个例子,我们尝试了 1 到 75 之间的 38 个值。要在caret中这样做,我们需要定义一个名为k的列,所以我们使用这个:data.frame(k = seq(11, 105, 2))。请注意,在运行此代码时,我们正在将 38 个 kNN 版本拟合到 25 个自助样本。由于我们正在拟合\(38 \times 25 = 950\)个 kNN 模型,运行此代码将需要几秒钟。

train_knn <- train(y ~ ., method = "knn", 
 data = mnist_27$train,
 tuneGrid = data.frame(k = seq(11, 105, 4)))
ggplot(train_knn, highlight = TRUE)

* *重采样方法涉及随机采样步骤,因此相同代码的重复运行可能会产生不同的结果。为了确保你的结果可以被精确复制,请在分析开始时始终设置随机种子,就像我们在本章开头所做的那样。 *要访问最大化准确率的参数,可以使用以下方法:

train_knn$bestTune
#>     k
#> 13 59

以及表现最好的模型如下:

train_knn$finalModel

predict函数将使用这个表现最好的模型。以下是该模型在测试集上的准确率,我们尚未使用测试集,因为交叉验证是在训练集上进行的:

mean(predict(train_knn, mnist_27$test, type = "raw") == mnist_27$test$y)
#> [1] 0.825

自助法并不总是重采样的最佳方法(例如,参见第 31.5 节)。如果我们想更改我们的重采样方法,可以使用trainControl函数。例如,下面的代码运行了 10 折交叉验证。这意味着我们有 10 个样本,每个样本使用 90%的观测值进行训练。我们使用以下代码实现这一点:

control <- trainControl(method = "cv", number = 10, p = .9)
train_knn_cv <- train(y ~ ., method = "knn", 
 data = mnist_27$train,
 tuneGrid = data.frame(k = seq(11, 105, 4)),
 trControl = control)

train输出的results组件包括与交叉验证估计的变异性相关的几个汇总统计量:

names(train_knn$results)
#> [1] "k"          "Accuracy"   "Kappa"      "AccuracySD" "KappaSD"

你可以从手册³中了解更多关于caret包的详细信息。

31.3 预处理

我们在运行机器算法之前通常会转换预测变量。我们还会移除显然没有用的预测变量。我们把这些步骤称为预处理

预处理示例包括标准化预测变量、对某些预测变量进行对数变换、移除与其他预测变量高度相关的预测变量,以及移除具有非常少非唯一值或接近零变异的预测变量。

例如,我们可以运行 caret 包中的 nearZeroVar 函数,以查看有几个特征在观测之间变化不大。我们可以看到有大量特征接近于 0 的变异性:

library(matrixStats)
hist(colSds(x), breaks = 256)

* *这是预期的,因为图像中有一部分很少包含文字(暗像素)。

caret** 包包括一个推荐移除因 接近零变异 而应移除特征的函数:

nzv <- nearZeroVar(x)

我们可以看到建议移除的列靠近边缘:

image(matrix(1:784 %in% nzv, 28, 28))

* *因此,我们最终移除了 length(nzv) = 532 个预测变量。

caret** 包提供了 preProcess 函数,允许用户根据训练集建立一组预定义的预处理操作。此函数旨在将这些操作应用于新的数据集,而无需在验证集上重新计算任何内容,确保所有预处理步骤都是一致的,并且仅从训练数据中派生。

下面是一个示例,演示了如何移除接近零变异的预测变量,然后对剩余的预测变量进行中心化:

pp <- preProcess](https://rdrr.io/pkg/caret/man/preProcess.html)(x, method = [c("nzv", "center"))
centered_subsetted_x_test <- predict(pp, newdata = x_test)
dim(centered_subsetted_x_test)
#> [1] 1000  252

此外,caret 中的 train 函数包括一个 preProcess 参数,允许用户指定在模型训练期间自动应用哪些预处理步骤。我们将在第 31.5 节中进一步探讨这一功能。

31.4 并行化

在交叉验证或自助法中,将模型拟合到不同的样本或使用不同的参数的过程可以独立进行。想象一下你正在拟合 100 个模型;如果你有 100 台计算机,理论上你可以通过在每台计算机上分别拟合每个模型并汇总结果来将过程速度提高 100 倍。实际上,大多数现代计算机,包括许多个人计算机,都配备了多个处理器,允许进行此类并行执行。这种方法称为并行化,它利用这些多个处理器同时执行多个计算任务,显著加速模型训练过程。通过将工作负载分配到不同的处理器,并行化使得有效地管理大型数据集和复杂的建模过程成为可能。

caret** 包旨在利用并行处理,但您需要明确告诉 R 并行运行任务。为此,我们使用 doParallel 包:

library(doParallel)
nc <- detectCores() - 1   # it is convention to leave 1 core for the OS
cl <- makeCluster(nc)
registerDoParallel(cl)

如果你使用并行化,一旦你完成模型的拟合,请确保使用以下代码让 R 知道你已经完成:

stopCluster(cl)
stopImplicitCluster()

当在多个处理器之间并行化任务时,考虑内存耗尽的风险是很重要的。每个处理器可能需要数据的副本或其大部分,这可能会使整体内存需求成倍增加。如果数据或模型很大,这尤其具有挑战性。

31.5 k 近邻

在第 Section 29.1 节中,我们介绍了 k 近邻(kNN)算法,并描述了其直观性和数学公式。在这里,我们将关注将 kNN 应用于实际数据的应用方面。我们将探讨如何在 R 中训练和测试 kNN 模型,选择合适的 k 值,并使用交叉验证和调优技术来评估性能。

在开始本节之前,请注意,下面代码中train函数的前两次调用可能需要几个小时才能运行。这是训练机器学习算法时的一个常见挑战,因为我们必须为每个交叉验证分割和每个考虑的调优参数运行算法。在下一节中,我们将提供一些预测过程持续时间以及减少方法的一些建议。* *### kNN 的 Bootstrap 局限性

如我们很快将看到的,MNIST 数据的最佳 \(k\) 值在 1 到 7 之间。对于 \(k\) 的较小值,对于 k-近邻(kNN)估计 MSE,bootstrap 可能会出现问题。这是因为 bootstrap 涉及从原始数据集中有放回地抽样,这意味着最近的邻居通常会多次出现,但 kNN 将其视为两个独立的观察。这是一个不切实际的场景,当例如 \(k=3\) 时,它可能会扭曲估计的 MSE。因此,我们使用交叉验证来估计我们的 MSE。

优化 \(k\)

第一步是优化 \(k\)

train_knn <- train(x, y, method = "knn", 
 preProcess = "nzv",
 trControl = trainControl("cv", number = 20, p = 0.95),
 tuneGrid = data.frame(k = seq(1, 7, 2)))

一旦我们优化了我们的算法,predict函数默认使用与整个训练数据拟合的最佳性能算法:

y_hat_knn <- predict(train_knn, x_test, type = "raw")

我们实现了相对较高的准确率:

mean(y_hat_knn == y_test)
#> [1] 0.953

使用 PCA 进行降维

直接删除低方差列的替代方法是使用主成分分析(PCA)降低特征矩阵的维度。对于这个数据集,总预测变量的大部分变化可以通过仅使用少数几个主成分(PCs)来捕捉。

例如,假设我们想要保留至少解释 90%变异性的最小 PC 数量:

pca <- prcomp(x)
p <- which(cumsum(pca$sdev²) / sum(pca$sdev²) >= 0.9)[1]

现在我们可以仅使用这些 87 个转换特征重新运行我们的算法:

fit_knn_pca <- knn3(pca$x[, 1:p], y, k = train_knn$bestTune)

使用 PCA 进行预测的一个关键点是,PCA 变换必须仅从训练集中学习。如果我们使用验证集或测试集来计算主成分,甚至用于中心化的均值,我们无意中将从这些集中泄露信息到训练过程中,导致过拟合。

为了避免这种情况,我们在训练集上计算必要的中心化和旋转矩阵:

newdata <- sweep(x_test, 2, colMeans(x)) %*% pca$rotation[, 1:p]
y_hat_knn_pca <- predict(fit_knn_pca, newdata, type = "class")

得到的准确度与使用完整特征集得到的相似,但只使用了 87 个维度:

mean(y_hat_knn_pca == y_test)
#> [1] 0.954

在这个例子中,我们使用了针对原始数据优化的 \(k\) 值,而不是 PCA 变换后的数据。为了在使用 PCA 时获得无偏的 MSE 估计,我们必须在每个交叉验证分割中重新计算 PCA,并将结果变换应用于相应的验证折叠。

幸运的是,caret 包可以自动化这个过程。train 函数将 PCA 作为其可用的预处理步骤之一。我们可以使用固定数量的组件请求 PCA:

preProcOptions = list(pcaComp = p)

或者让 caret 选择解释固定变异分数(例如 90%)的组件数量,使用

preProcOptions = list(thresh = 0.9).

这里是如何修改我们之前的代码,让 caret 在预处理期间执行 PCA:

train_knn_pca <- train(x, y, method = "knn", 
 preProcess = c("nzv", "pca"),
 trControl = trainControl("cv", number = 20, p = 0.95,
 preProcOptions = list(thresh = 0.9)),
 tuneGrid = data.frame(k = seq(1, 7, 2)))
y_hat_knn_pca <- predict(train_knn_pca, x_test, type = "raw")
mean(y_hat_knn_pca == y_test)
#> [1] 0.955

这引发了一个自然的问题:我们为什么选择了 0.9?另一个阈值是否会带来更好的性能?

这种方法的局限性之一是 caret 不会自动优化 PC 的数量。要直接调整这个数量,就像我们调整 \(k\) 等参数一样,我们需要定义自己的模型接口。caret 手册⁴ 描述了如何构建允许完全优化 PCA 维度的自定义模型。

31.6 随机森林

使用随机森林算法,可以优化多个参数,但主要的一个是 mtry,即每个树随机选择的预测因子数量。这也是** caret 函数 train 在使用 randomForest 包的默认实现时允许的唯一调整参数

library(randomForest)
train_rf <- train(x, y, method = "rf", 
 preProcess = "nzv",
 tuneGrid = data.frame(mtry = seq(5, 15)))
y_hat_rf <- predict(train_rf, x_test, type = "raw")

现在我们已经优化了我们的算法,我们准备拟合我们的最终模型:

y_hat_rf <- predict(train_rf, x_test, type = "raw")

与 kNN 一样,我们也实现了高准确度:

mean(y_hat_rf == y_test)
#> [1] 0.956

通过优化其他一些算法参数,我们可以实现更高的准确度。

测试和改进计算时间

train 函数用于估计准确度的默认方法是测试 25 个自助样本的预测。这可能会导致计算时间较长。例如,如果我们正在考虑几个调整参数的值,比如 10,那么我们将拟合算法 250 次。我们可以使用 system.time 函数来估计运行算法一次需要多长时间:

nzv <- nearZeroVar(x)
system.time({fit_rf <- randomForest(x[, -nzv], y,  mtry = 9)})
#>    user  system elapsed 
#>  61.657   0.598  62.356

并使用此来估计 250 次迭代的总时间。在这种情况下,它将是几个小时。

减少运行时间的一种方法是通过使用具有较少测试集的 k 折交叉验证。一个流行的选择是排除 5 个测试集,占 20% 的数据。为了使用这个,我们将 trControl 参数在 train 中设置为 trainControl(method = "cv", number = 5, p = .8)

对于随机森林,我们还可以通过每拟合一次运行较少的树来加快训练步骤。运行算法一次后,我们可以使用 plot 函数来查看错误率如何随着树的数量增长。

这里我们可以看到,错误率在大约 200 棵树后稳定下来:

plot(fit_rf)

* *我们可以利用这个发现来加速交叉验证过程。具体来说,因为默认值是 500,通过在上面的train调用中添加ntree = 200参数,该过程将快 2.5 倍。

变量重要性

我们在第 30.5.1 节中描述了变量重要性。以下函数计算每个特征的重要性:

imp <- varImp(fit_rf)

我们可以通过绘制图像来查看哪些特征被使用得最多:

mat <- rep(0, ncol(x))
mat[-nzv] <- imp$Overall
image(matrix(mat, 28, 28))

31.7 诊断

数据分析的一个重要部分是可视化结果以确定我们失败的原因。我们如何做这取决于应用。以下是我们对做出错误预测的数字图像的展示。以下是随机森林的一些错误:

通过检查这样的错误,我们通常会发现算法或参数选择的具体弱点,并尝试纠正它们。

31.8 集成

集成思想类似于结合不同民意调查者的数据以获得每个候选人真实支持度更好估计的思想。

在机器学习中,通过结合不同算法的结果,通常可以大大提高最终结果。

这里有一个简单的例子,我们通过取随机森林和 kNN 的平均值来计算新的类概率。我们可以看到,准确率提高了:

p_rf <- predict(fit_rf, x_test[,-nzv], type = "prob") 
p_rf <- p_rf / rowSums(p_rf)
p_knn_pca  <- predict(train_knn_pca, x_test, type = "prob")
p <- (p_rf + p_knn_pca)/2
y_pred <- factor(apply(p, 1, which.max) - 1)
mean(y_pred == y_test)
#> [1] 0.963

我们刚刚只用两种算法构建了一个集成。通过结合更多表现相似但相互独立的算法,我们可以进一步提高准确性。

31.9 练习

  1. 创建一个简单的数据集,其中结果在预测变量每增加 0.75 个单位时平均增长:
n <- 1000
sigma <- 0.25
x <- rnorm(n, 0, 1)
y <- 0.75 * x + rnorm(n, 0, sigma)
dat <- data.frame(x = x, y = y)

在第三十章的练习中,我们看到了在randomForest函数中更改maxnodesnodesize可以改进这个数据集的估计。让我们使用train函数来帮助我们选择这些值。从caret手册中我们可以看到,我们无法使用randomForest调整maxnodes参数或nodesize参数,因此我们将使用Rborist包并调整minNode参数。使用train函数尝试minNode <- seq(5, 250, 25)的值。查看哪个值可以最小化估计的 RMSE。

  1. 绘制xy的散点图,并添加第 1 个练习中最佳拟合模型的预测。

  2. 这个dslabs tissue_gene_expression数据集包括一个矩阵x

library(dslabs)
dim(tissue_gene_expression$x)

使用在 500 个基因上测量 189 个生物样本的基因表达。组织类型存储在y中:

table(tissue_gene_expression$y)

将数据分为训练集和测试集,然后使用 kNN 预测组织类型并查看你获得的准确率。尝试\(k = 1, 3, \dots, 11\)

  1. 我们将应用 LDA 和 QDA 到tissue_gene_expression数据集。我们将从这个数据集的简单示例开始,然后开发一个现实世界的示例。

创建一个仅包含cerebellumhippocampus(大脑的两个部分)类和一个包含 10 个随机选择列的预测矩阵的数据集。

set.seed(1993)
tissues <- c("cerebellum", "hippocampus")
ind <- which(tissue_gene_expression$y %in% tissues)
y <- droplevels(tissue_gene_expression$y[ind])
x <- tissue_gene_expression$x[ind, ]
x <- x, [sample(ncol(x), 10)]

估计 LDA 的准确率。

  1. 在这种情况下,LDA 拟合了两个 10 维正态分布。通过查看 train 结果的finalModel组件来查看拟合的模型。注意有一个名为means的组件,其中包含两个分布的估计means。将均值向量相互对比,并确定哪些预测因子(基因)似乎在驱动算法。

  2. 使用 QDA 重复练习 4。它比 LDA 有更高的准确率吗?

  3. 是否有相同的预测因子(基因)驱动算法?制作与练习 5 中相同的图。

  4. 在之前的图中,我们看到预测值在两组中的值是相关的:一些预测值在两组中都很低,而其他预测值在两组中都较高。每个预测值的平均值colMeans(x)对于预测没有信息或用途,并且通常,为了解释目的,对每个列进行中心化或缩放是有用的。这可以通过train函数中的preProcessing参数实现。用preProcessing = "scale"重新运行 LDA。注意准确率没有变化,但看看在练习 5 中制作的图中如何更容易地识别两组之间差异更大的预测值。

  5. 在之前的练习中,我们看到了两种方法都工作得很好。在散点图中绘制两个基因在两组之间差异最大的预测值,以查看它们似乎遵循 LDA 和 QDA 方法所假设的双变量分布。根据结果着色点。

  6. 现在我们将稍微增加挑战的复杂性:我们将考虑所有组织类型。

set.seed(1993)
y <- tissue_gene_expression$y
x <- tissue_gene_expression$x
x <- x, [sample(ncol(x), 10)]

使用 LDA 能得到多少准确率?

  1. 我们看到结果略有下降。使用confusionMatrix函数了解我们正在犯什么类型的错误。

  2. 绘制 LDA 模型在练习 10 中拟合的七个 10 维正态分布中心的图像。

  3. 使用rpart函数将分类树拟合到包含所有基因的tissue_gene_expression数据集,即重新定义

x <- tissue_gene_expression$x

使用train函数估计准确率。尝试cp值为seq(0, 0.05, 0.01)。绘制准确率以报告最佳模型的结果。

  1. 研究最佳拟合分类树的混淆矩阵。对于胎盘,你观察到发生了什么?

  2. 注意胎盘通常被称为子宫内膜,而且胎盘的数量只有六个。默认情况下,rpart 在分割节点之前需要 20 个观测值。因此,使用这些参数不可能有一个节点中胎盘占多数。重新运行上述分析,但这次允许rpart使用control = rpart.control(minsplit = 0)参数来分割任何节点。准确性是否提高?再次查看混淆矩阵。

  3. 绘制在练习 13 中获得的最佳拟合模型的树。

  4. 我们可以看到,仅使用 CART 选择的基因,我们就能预测组织类型。现在让我们看看是否可以使用随机森林做得更好。使用caret包中的train函数和rf方法来训练随机森林。尝试mtry的值,从至少seq(50, 200, 25)开始。哪个mtry值最大化了准确性?为了允许小的nodesize像我们在分类树中做的那样增长,使用以下参数:nodesize = 1。这将需要几秒钟的时间来运行。如果你想测试它,尝试使用更小的ntree值。将种子设置为 1990。

  5. train的输出上使用varImp函数,并将其保存到名为imp的对象中。

  6. 在练习 15 中运行的rpart模型产生了一个只使用了少数预测因子的树。提取预测因子名称并不直接,但可以做到。如果train调用的输出是fit_rpart,我们可以这样提取名称:

ind <- !(fit_rpart$finalModel$frame$var == "<leaf>")
tree_terms <- 
 fit_rpart$finalModel$frame$var[ind] |>
 unique() |>
 as.character()
tree_terms

对于这些预测因子,随机森林调用中的变量重要性是什么?它们排名如何?

  1. 根据重要性提取前 50 个预测因子,从x中提取只包含这些预测因子的子集,并应用heatmap函数来查看这些基因在组织中的行为。我们将在第三十二章中介绍heatmap函数。

  2. 在第三十章中,对于 2 或 7 的示例,我们通过绘制图像来比较给定两个预测因子 \(\mathbf{x} = (x_1, x_2)^\top\) 的条件概率 \(p(\mathbf{x})\) 与使用机器学习算法得到的拟合 \(\hat{p}(\mathbf{x})\)。以下代码可以用来生成这些图像,并在 \(x_1\)\(x_2\) 的函数值为 \(0.5\) 的点上绘制曲线:

plot_cond_prob <- function(x_1, x_2, p){
 data.frame(x_1 = x_1, x_2 = x_2, p = p) |>
 ggplot(aes(x_1, x_2)) +
 geom_raster(aes(fill = p), show.legend = FALSE) +
 stat_contour(aes(z = p), breaks = 0.5, color = "black") +
 scale_fill_gradientn](https://ggplot2.tidyverse.org/reference/scale_gradient.html)(colors = [c("#F8766D", "white", "#00BFC4"))
}

我们可以这样看到 2 或 7 示例的真实条件概率:

with(mnist_27$true_p, plot_cond_prob(x_1, x_2, p))

将 kNN 模型拟合并绘制估计的条件概率图。提示:使用newdata = mnist_27$train参数来获取网格点的预测结果。

  1. 注意,在练习 21 中制作的图中,边界有些曲折。这是因为 kNN,就像基本的二进制平滑器一样,不使用核。为了改进这一点,我们可以尝试使用 loess。通过阅读caret手册中可用的模型部分,我们看到我们可以使用gamLoess方法。如果我们还没有安装,我们需要安装gam包。我们看到我们有两个参数需要优化:
modelLookup("gamLoess")
#>      model parameter  label forReg forClass probModel
#> 1 gamLoess      span   Span   TRUE     TRUE      TRUE
#> 2 gamLoess    degree Degree   TRUE     TRUE      TRUE

使用重采样选择介于 0.15 和 0.75 之间的跨度。保持degree = 1。选择了哪个跨度?

  1. 展示由第 22 个练习中模型拟合得到的估计 \(\hat{p}(x,y)\) 的图像。准确度与 kNN 相比如何?对使用 kNN 获得的估计与差异进行评论。

  2. 使用mnist_27训练集来拟合通过caret包提供的各种模型。你的目标仅仅是尝试许多不同的算法并比较它们的性能,即使我们还没有解释它们。

这里是一个你可以尝试的模型名称向量:

models <- c(
 "glm", "lda", "naive_bayes", "svmLinear", "gamboost",
 "gamLoess", "qda", "knn", "kknn", "loclda", "gam", "rf",
 "ranger", "wsrf", "Rborist", "avNNet", "mlp", "monmlp",
 "gbm", "adaboost", "svmRadial", "svmRadialCost", "svmRadialSigma"
)

使用默认的调整参数使用train()拟合每个模型,并将拟合的模型对象保存在一个列表中。你可能需要根据你尝试的模型安装额外的包,并且一些模型将产生警告——这是预期的。

  1. 现在你已经有一个包含所有训练模型的列表,使用sapplymap为测试集创建一个预测矩阵。你应该得到一个有length(mnist_27$test$y)行和length(models)列的矩阵。

  2. 在测试集上计算每个模型的准确度。

  3. 通过多数投票构建集成预测并计算集成的准确度。

  4. 之前我们计算了每个方法在训练集上的准确度,并注意到它们是变化的。哪些单独的方法比集成做得更好?

  5. 移除表现不佳的方法并重新构建集成是有诱惑力的。这种方法的问题是我们正在使用测试数据来做决定。然而,我们可以使用从训练数据中获得的交叉验证准确度估计。获取这些估计并将它们保存在一个对象中。

  6. 现在让我们在构建集成时只考虑估计准确度为 0.8 的方法。现在集成的准确度是多少?

  7. 注意,如果两个机器学习算法方法预测相同的精确结果,集成它们将不会改变预测。对于每一对算法,比较它们做出相同预测的观测值的百分比。使用这个来定义一个函数,然后使用heatmap函数来可视化结果。提示:在dist函数中使用method = "binary"参数。

  8. 注意,每个方法也可以产生一个估计的条件概率。我们可以取这些估计的条件概率的平均值,而不是多数投票。对于大多数方法,我们可以在train函数中使用type = "prob"。注意,一些方法在调用train时需要使用trControl=trainControl(classProbs=TRUE)参数。此外,如果类别有数字作为名称,这些方法将不起作用。提示:像这样更改级别:

dat$train$y <- recode_factor(dat$train$y, "2"="two", "7"="seven")
dat$test$y <- recode_factor(dat$test$y, "2"="two", "7"="seven")
  1. 在本章中,我们在 MNIST 数据集的一个子集上展示了几个机器学习算法。尝试将模型拟合到整个数据集上。

  1. https://topepo.github.io/caret/available-models.html↩︎

  2. http://topepo.github.io/caret/available-models.html↩︎

  3. https://topepo.github.io/caret/available-models.html↩︎

  4. 在训练中使用自己的模型↩︎

32 无监督学习:聚类

原文:rafalab.dfci.harvard.edu/dsbook-part-2/ml/clustering.html

  1. 机器学习

  2. 32 无监督学习:聚类

我们迄今为止研究过的算法代表了机器学习中最广泛使用的分支:监督学习。在监督学习中,训练数据包括预测因子和结果,目标是学习一个函数,该函数可以预测新观测的结果。前几章中开发的许多数学和实用框架旨在理解、评估和构建这些监督预测模型。

然而,监督学习只是广泛称为机器学习的一部分。第二个主要分支是 无监督学习,其中不提供结果(标签)。目标不再是预测,而是揭示数据中的结构:识别组、检测模式或揭示低维表示。这一系列技术在实践中非常重要,包括本书中未涉及到的许多方法。在这里,我们提供了一个简要介绍,并建议读者参考推荐文本进行深入研究。

无监督学习包含了许多不同的方法,但其中最常见的一种是 聚类:将具有相似特征的观测分组。在基于身高的性别预测或二对七位数的例子中,聚类可能会很具挑战性,因为组自然重叠。但在许多科学和应用领域,如基因组学、市场营销、图像分析和推荐系统,无监督方法可以揭示其他情况下可能隐藏的有意义模式。

在本章中,我们重点关注两种经典的聚类算法,每个算法都展示了非常不同的策略:

  • 层次聚类,通过递归合并最近的成对观测来分组。

  • k-means 聚类,通过迭代优化聚类中心来划分数据。

这两种方法都是基础性的,因为许多高级无监督学习方法都建立在相同的思想之上。

32.1 例子

为了使概念具体化,我们研究了两个真实例子。这些例子说明了无监督学习如何在没有结果标签的情况下,揭示真实数据中的潜在结构。

电影评分

我们的第一个例子是基于电影评分。在这里,我们快速构建了一个矩阵 movies,它包含了至少有 100 个评分的用户中评分最高的 50 部电影:

library(dslabs)
library(data.table)
library(stringr
dt <- as.data.table(movielens)
dt <- dt[, if (.N >= 100) .SD, by = userId]
top_movies <- dt[, .N, by = .(movieId, title)]
dt <- dtmovieId [%in% top_movies[order(-N)][1:50, movieId]]
dt <- dcast(dt, title ~ userId, value.var = "rating") 
movies <- as.matrix(dt[,-1])
rownames(movies) <- str_remove(dt$title, ": Episode") |> str_trunc(20)

请注意,如果我们直接从原始评分中计算距离,评分普遍较高的电影往往会彼此靠近,评分普遍较低的电影也是如此。然而,这并不是我们真正感兴趣的。相反,我们想要捕捉 263 位不同用户之间的评分相关性。为了实现这一点,我们通过去除电影效应来对每部电影的评分进行中心化:

movies <- movies - rowMeans(movies, na.rm = TRUE)

如第二十四章所述,由于并非每个用户都对每部电影进行评分,因此许多条目缺失,所以我们使用参数na.rm = TRUE。有了中心化数据,我们现在可以根据电影的评分模式对电影进行聚类。

基因表达测量

我们的第二个例子使用了来自dslabstissue_gene_expression数据集。该数据集包含 189 个组织样本的基因表达测量值。预测变量存储在组件x中,形成一个具有 500 列的矩阵,每列代表一个基因,因此每一行代表单个组织样本的表达谱。结果标签存储在组件y中,指示每个样本来自七个组织类型中的哪一个。

尽管聚类通常在标签未知时应用,但这个数据集在教学中很有用,因为我们确实知道真实的组织类型。这让我们可以检查我们找到的聚类是否很好地反映了潜在的生物结构:不同的组织具有不同的基因表达模式,尽管所有细胞都共享相同的基因组。不同的基因在不同的组织中扮演不同的功能角色,这些模式应该可以通过聚类检测到。从生物学的角度来看,所有这些组织都共享相同的基因组,每个细胞都包含相同的 DNA。使组织不同的是哪些基因被开启或关闭,以及开启或关闭的程度。这些组织特异性的表达模式反映了每种细胞类型的独特功能。因此,我们预计具有相似生物学角色的组织样本将显示出相似的基因表达谱。

在聚类之前,我们通过减去每个基因的平均表达水平来创建基因表达矩阵的中心化版本。这确保我们关注基因表达的相对差异而不是绝对大小:

genex <- sweep(tissue_gene_expression$x, 2, colMeans(tissue_gene_expression$x))

当我们后来探索genex时,即使没有给出算法的组织标签,我们也应该看到结构的出现:来自同一组织的样本往往聚集在一起,因为它们共享特征的表达模式。

32.2 层次聚类

我们首先描述的聚类算法是层次聚类。与许多聚类算法一样,第一步是计算每对观测之间的距离。我们以movies矩阵为例进行此操作:

d <- dist(movies)

dist函数自动处理缺失值并标准化距离测量,确保计算的两个电影之间的距离不依赖于可用的评分数量。

在计算了每对电影之间的距离后,我们需要一个算法来定义基于这些距离的组。层次聚类首先将每个观测值定义为一个单独的组,然后最近的两个组被合并成新的组。然后我们继续迭代地将最近的组合并成新的组,直到只剩下一个包含所有观测值的组。

hclust函数实现了这个算法,并接受距离作为输入:

h <- hclust(d)

我们可以使用树状图来查看生成的组。将plot函数应用于hclust对象会创建一个树状图:

plot(h, cex = 0.75, main = "", xlab = "")

* *这个图给出了任意两部电影之间的距离近似。为了找到这个距离,我们找到从上到下这些电影首次分成两个不同组的位置。这个位置的高度是这两个组之间的距离。例如,三部《星球大战》电影之间的距离是 8 或更少,而《失落文明的探险者》和《沉默的羔羊》之间的距离大约是 17。

为了生成实际的组,我们可以做两件事之一:1)决定观测值在同一个组中所需的最小距离,或者 2)决定你想要的组数,然后找到实现这一目标的最小距离。

函数cutree可以应用于hclust的输出,以执行这两个操作之一并生成组。参数k可以用来指定组数,或者,参数h可以用来表示组内成员之间的最小距离。以下是请求形成 10 个组的方法:

groups <- cutree(h, k = 10)

请注意,聚类提供了一些关于电影类型的见解。第 2 组看起来像是备受赞誉的电影:

names(groups)[groups == 2]
#> [1] "American Beauty"      "Fargo"                "Godfather, The" 
#> [4] "Pulp Fiction"         "Silence of the La..." "Usual Suspects, The"

并且第 9 组看起来像是极客电影:

names(groups)[groups == 9]
#> [1] "Lord of the Rings..." "Lord of the Rings..." "Lord of the Rings..."
#> [4] "Star Wars IV - A ..." "Star Wars V - The..." "Star Wars VI - Re..."

我们可以通过使k更大或h更小来改变组的大小。

32.3 热图

热图是揭示高维数据中簇或模式的有力视觉工具。热图在观测值和预测变量都可以形成有意义的组时特别有用。基因表达数据集是一个很好的例子:不仅组织样本可以根据生物类型进行聚类,基因本身也经常聚集成功能组,这些组是共表达的。

基本思想很简单:我们使用颜色来表示数据矩阵中的每个值,创建数据矩阵的图像,并根据聚类算法的结果重新排序行和列。这种重新排序将相似的样本和基因放在一起,使得数据中的结构更容易看到。

我们使用来自dslabstissue_gene_expression数据集来说明这种方法。

h_1 <- hclust(dist(genex))
h_2 <- hclust(dist(t(genex)))

请注意,我们进行了两次聚类:我们对样本(h_1)和基因(h_2)进行了聚类。

现在,我们可以使用这个聚类的结果来排序行和列:

image(genex[h_1$order, h_2$order])

执行所有这些操作的heatmap函数:

heatmap(genex)

注意我们没有展示上面两行代码的结果,因为特征太多,绘图将没有意义。因此,我们将过滤一些列并重新绘制图表。

如果聚类之间只有少数特征不同,包括所有特征可能会添加足够的噪声,使得聚类检测变得困难。一个简单的避免方法是假设低变异性特征不是信息性的,只包括高变异性特征。例如,在电影示例中,评分变异性低的用户实际上并没有真正区分电影:对他们来说,所有电影看起来都差不多。同样,在基因表达示例中,在样本之间没有变化的基因不太可能提供生物上有意义的模式信息。

在这里,我们展示了如何将热图限制在仅显示最易变的基因上,这些基因通常揭示了最强的生物模式。我们首先计算每个基因的标准差,选择前 25 个最易变的基因,然后使用更合适的发散颜色方案绘制热图:

library(matrixStats)
sds <- colSds(genex, na.rm = TRUE)
o <- order(sds, decreasing = TRUE)[1:25]
heatmap(genex,o], col = rev(RColorBrewer::[brewer.pal(11, "Spectral")))

* *我们在热图中看到了清晰的模式:观测值聚集成组,这些组在很大程度上对应于组织类型,基因也形成聚类,反映了生物上有意义的结构。例如,SV2B、GPM6B、LRP4 和 DOCK4 等基因聚集在一起,因为它们都与神经元功能有关。

注意 R 中有几个其他的热图函数。一个流行的例子是 gplots 包中的 heatmap.2

32.4 k-means

我们描述的第二种聚类算法是 k-means

k-means 算法要求我们预先定义 \(k\),即我们想要创建的聚类数量。一旦 \(k\) 被设置,算法就会迭代进行:

  1. 初始化: 定义 \(k\) 个中心(随机选择)。

  2. 分配步骤: 每个观测值被分配到最近的中心所在的聚类。

  3. 更新步骤: 通过取每个聚类中观测值的平均值来重新定义中心。这些新的中心被称为 质心

  4. 迭代: 步骤 2 和 3 重复进行,直到中心稳定(收敛)。

我们可以运行 k-means 来看看它是否可以发现不同的组织:

set.seed(2001)
km <- kmeans(genex, centers = 7)

聚类分配存储在 cluster 组件中:

km$cluster[1:5]
#> cerebellum_1 cerebellum_2 cerebellum_3 cerebellum_4 cerebellum_5 
#>            7            7            7            7            7

由于初始中心是随机选择的(这就是为什么我们使用 set.seed),生成的聚类可能会有所不同。为了提高稳定性,我们可以多次重复这个过程,使用不同的随机起始点,并保留最佳结果。随机起始点的数量由 nstart 参数设置:

set.seed(2001)
km <- kmeans(genex, centers = 7, nstart = 100)

由于我们这个数据集有真实的组织标签,我们可以评估 k-means 生成的聚类与已知生物组之间的匹配程度。最简单的方法是检查列联表,并评估每个聚类的纯度,即大多数样本是否来自单一的组织类型:

table(km$cluster, tissue_gene_expression$y)
#> 
#>     cerebellum colon endometrium hippocampus kidney liver placenta
#>   1         33     0           0           0      0     0        0
#>   2          0    34           0           0      1     0        0
#>   3          0     0           0           0      0    26        0
#>   4          0     0          15           0      0     0        0
#>   5          0     0           0           0     38     0        0
#>   6          5     0           0          31      0     0        0
#>   7          0     0           0           0      0     0        6

结果显示,k-means 在恢复组织结构方面做得相当不错。例如,簇 1、3、4、5 和 7 分别大致对应于小脑、肝脏、子宫内膜、肾脏和胎盘。所有海马样本都落入簇 6,尽管有五个小脑样本被错误地包含在内。簇 2 几乎捕获了所有结肠样本,只有一个肾脏样本分配错误。

在下一节中,我们将讨论使用单个数字来总结这种协议的正式方法,例如 纯度 和其他常用的聚类评估指标。

32.5 使用类别标签评估簇

在监督学习中,我们使用准确率、敏感度和 RMSE 等量来评估性能。聚类提出了不同的挑战:因为该方法是无监督的,算法从未看到真正的结果,也没有“正确”标签的明确概念。然而,在许多实际数据设置中,我们 确实 有用于评估目的的地面实况标签——例如,基因表达研究中的组织类型。在这些情况下,我们可以有意义地询问:算法恢复的簇与已知类别之间的匹配程度如何?这种外部评估在比较给定科学或应用背景下的聚类方法的基准测试研究中很常见。

一个方便的第一步是使用列联表(或混淆矩阵)可视化簇质量,就像我们上面用 table 做的那样。在这里,行代表簇,列代表真实类别。在可选地重新排序行或列以使结构更容易理解之后:

tab <- table(km$cluster, tissue_gene_expression$y)
tab,[apply(tab, 1, which.max)]
#> 
#>     cerebellum colon liver endometrium kidney hippocampus placenta
#>   1         33     0     0           0      0           0        0
#>   2          0    34     0           0      1           0        0
#>   3          0     0    26           0      0           0        0
#>   4          0     0     0          15      0           0        0
#>   5          0     0     0           0     38           0        0
#>   6          5     0     0           0      0          31        0
#>   7          0     0     0           0      0           0        6

一个“纯”簇看起来像一行只有一个主导条目,其余条目相对较小。这表明分配给该簇的观测几乎共享相同的真实标签。

如果我们想用一个数字来总结这种协议,最广泛使用的度量是 簇纯度

簇纯度

假设我们有:

  • 每个观测 \(i=1\dots N\) 的真实类别标签 \(Y_i \in {1,\dots,J}\),以及

  • 一种将每个观测 \(i\) 分配到 \(K\) 个簇 \(C_1,\dots,C_K\) 之一的聚类算法。

对于每个簇 \(C_k\),我们查看该簇内真实标签的分布,并找到最常见的标签。簇 \(k\)纯度 是属于这个多数类别的观测在 \(C_k\) 中的比例。整体纯度定义为:

\[\text{Purity} = \frac{1}{N} \sum_{k=1}^K \max_j | C_k \cap L_j | \]

其中 \(L_j\) 是类别 \(j\) 的观测集合,且 \(|C_k \cap L_j|\)\(C_k\)\(L_j\) 中观测的数量。

纯度很容易理解:纯度为 1 表示每个簇仅由一个类别的观测组成;纯度为 0.6 表示,平均而言,每个簇中的 60% 的点来自其主导类别,40% 是从其他类别“混合”进来的。

我们可以计算我们的基因表达示例的纯度,并注意到它非常接近 1:

tab <- table(km$cluster, tissue_gene_expression$y)
sum(apply(tab, 1, max))/sum(tab)
#> [1] 0.968

其他指标:ARI 和 NMI

纯度简单直观,但它没有校正机会,并且可能偏向于有许多小聚类。在更正式的评估中,特别是在机器学习文献中,还有两个其他指标是常见的:

  • 调整兰德指数(ARI):将聚类与真实标签进行比较,并调整由机会产生的协议。它从 0(随机分配)到 1(完美匹配)。

  • 归一化互信息(NMI):使用信息论中的思想来衡量知道聚类标签可以减少对真实标签的不确定性。它通常在 0 到 1 之间,值越高表示对齐越好。

在实践中,纯度和相应的列联表通常是首先用来理解聚类如何与已知类别相关联的工具,当需要时,ARI 和 NMI 提供更正式、校正机会的总结。

32.6 练习题

  1. 使用标准距离(例如,欧几里得距离)计算movies数据集中用户之间的成对距离。你会得到一个错误,因为某些用户对没有共同评价的电影,产生所有值为 NAs 的行或列。为了诊断这个问题,创建一个矩阵in_common,其中条目\((i, j)\)是用户\(i\)\(j\)共同评价的电影数量。提示: 使用is.na()crossprod()一起使用。

  2. 创建一个movies矩阵的简化版本,其中只包含与子集内每个其他用户至少共享一部共同评价电影的用户。通过减去他们的平均值来对每个用户的评分进行中心化,计算距离矩阵,并运行层次聚类(hclust)。检查生成的树状图:你是否观察到任何结构?是否有用户组聚在一起?

  3. 使用cutreek = 7或等价的cutreeh = 14来获得 7 个聚类。创建一个表格,显示有多少用户属于这 7 个聚类中的每一个。

  4. 对于最大和第二大聚类,检查用户的喜爱电影(例如,平均评分最高的电影)。描述每个组中的电影观看者的类型。这些聚类是否对应于明显的偏好模式(例如,动作爱好者、浪漫爱好者等)?

  5. 对基因表达矩阵genex进行层次聚类,并使用组织类型标记树状图叶子。两个小脑样本(2930)和两个肾脏样本(3839)与肝脏聚类在一起,请在你的图中验证这一点。

  6. 练习 5 的结果与使用以下方法获得的 k-means 聚类结果不同:

set.seed(2001)
km <- kmeans(genex, centers = 7, nstart = 100)

检查km$centers中的聚类中心。将小脑样本(例如,"cerebellum_29")的基因表达轮廓与每个七个中心进行比较,以查看它最相似的中心:

par(mfrow = c(4, 2))
for (i in 1:7) {
 plot(km$centers[i,], genex["cerebellum_29",],
 xlim = range(genex), ylim = range(genex))
}

对于 "cerebellum_30""kidney_38""kidney_39""liver_13""liver_14" 重复上述操作,并将这些样本直接进行比较。使用这些比较来提出对练习 5 中观察到的不匹配的可能解释。

  1. 将 MNIST 数字的一个子集聚类成10个簇:
mnist <- read_mnist()
set.seed(1990)
index <- sample(nrow(mnist$train$images), 1000)
x <- mnist$train$images[index,]
y <- factor(mnist$train$labels[index])
km <- kmeans(x, centers = 10, nstart = 100)

使用 table() 比较簇分配与真实标签。计算所得聚类的簇纯度。当要求找到 10 个数字簇时,k-means 算法表现如何?

  1. 以图像的形式检查簇中心。例如,注意与“3”最相关的簇中心可能是“3”和“5”的混合。评论你的观察结果。

  2. 重新运行 k-means 算法,使用 25 个簇,以便算法能够表示同一数字的多种书写风格。检查列联表、簇纯度和簇中心图像。评论结果是否有所改进,以及你从人们书写数字的多样性中学到了什么。

  3. 如 第 32.5 节 所述,纯度通常随着簇数的增加而增加。对于 kseq(10, 100, 10) 范围内重复练习 9。计算每个 k 值的纯度,并绘制纯度与簇数的关系图。评论在什么情况下纯度的增加似乎有意义,以及它们似乎仅由于偶然性而出现的情况。

推荐阅读

原文:rafalab.dfci.harvard.edu/dsbook-part-2/ml/reading-ml.html

  1. 机器学习

  2. 推荐阅读

  • **Hastie, T., Tibshirani, R., & Friedman, J. (2009). The Elements of Statistical Learning: Data Mining, Inference, and Prediction (2nd ed.). Springer.

    这是一本基础教材,涵盖了现代机器学习方法的背后理论和数学,包括回归、分类和集成技术。

  • James, G., Witten, D., Hastie, T., & Tibshirani, R. (2023). An Introduction to Statistical Learning with Applications in R (3rd ed.). Springer.

    这本书是《统计学习基础》的更易读的伴侣,专注于在 R 中的实际应用,并提供了大量的代码示例。

  • **Bishop, C. M. (2006). Pattern Recognition and Machine Learning. Springer.

    这是一本全面介绍概率和贝叶斯方法在机器学习中的应用的参考书,适合寻求更深入数学理解的读者。

  • **Ripley, B. D. (1996). Pattern Recognition and Neural Networks. Cambridge University Press.

    这是一本经典教材,将传统统计建模与早期神经网络方法联系起来,强调机器学习与统计学之间的联系。

  • **Wood, S. N. (2017). Generalized Additive Models: An Introduction with R (2nd ed.). CRC Press.

    这本书是理解和应用 GAMs(广义加性模型)使用mgcv包的权威参考。Wood 通过清晰的 R 示例介绍了基于样条的光滑、惩罚、模型选择和诊断。

  • **Wand, M. P. & Jones, M. C. (1995). Kernel Smoothing. Chapman & Hall/CRC.

    一本经典教材,对核平滑方法、带宽选择和偏差-方差权衡进行了严谨的论述,适合寻求数学基础的读者。

  • **Simonoff, J. S. (1996). Smoothing Methods in Statistics. Springer.

    这本书全面概述了回归、密度估计和非参数模型中的平滑技术,提供了直观的解释和大量的示例。

  • Kaufman, L. & Rousseeuw, P. J. (2005). Finding Groups in Data: An Introduction to Cluster Analysis. Wiley. — 这是一本关于聚类的奠基性教材,介绍了诸如划分、层次聚类和基于密度的方法等关键方法,并提供了实用的见解和示例。

  • Maechler, M., Rousseeuw, P., Struyf, A., Hubert, M., & Hornik, K. (2025). cluster: Cluster Analysis Basics and Extensions. R 包版本 2.1.8。 可在:cran.r-project.org/package=cluster

    这是 R 语言中用于聚类的核心包,提供了 Kaufman & Rousseeuw (2005)中描述的许多方法的实现,以及实际应用工作的扩展工具和工具。

  • **Kuhn, M. & Johnson, K. (2013). Applied Predictive Modeling. Springer.

    R 语言中构建、调整和评估预测模型的实用指南,全面涵盖caret包的内容。

posted @ 2026-01-04 09:32  绝不原创的飞龙  阅读(26)  评论(0)    收藏  举报