精通-R-数据分析-全-

精通 R 数据分析(全)

原文:zh.annas-archive.org/md5/22efa1b41959b6151f9fc20a2abde17b

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

R 已经成为统计分析的通用语言,它不仅在起源地超过 20 年的学术领域被广泛和积极地使用,还已经在许多其他行业中得到了积极和广泛的应用。如今,越来越多的企业开始在生产中使用 R,它已经成为数据分析师和科学家最常用的工具之一,提供了访问数千个用户贡献的包的便捷途径。

用 R 掌握数据分析将帮助您熟悉这个开源生态系统以及一些统计背景知识,尽管对数学问题的关注相对较少。我们将主要关注如何使用 R 实际完成工作。

由于数据科学家大部分时间都在进行数据获取、清洗和重构,因此这里给出的第一个实战示例主要集中在从文件、数据库和在线来源加载数据。然后,本书将重点转向数据重构和清洗——仍然没有进行实际的数据分析。后面的章节描述了特殊的数据类型,然后还涵盖了经典的统计模型,以及一些机器学习算法。

本书涵盖的内容

第一章, 你好,数据!,从每个数据相关任务中最重要的任务开始:从文本文件和数据库中加载数据。本章涵盖了使用改进的 CSV 解析器、预过滤数据和比较各种数据库后端支持来将大量数据加载到 R 中的一些问题。

第二章, 从网络获取数据,扩展了您使用旨在与 Web 服务和 API 通信的包导入数据的知识,展示了如何从主页抓取和提取数据,并给出了处理 XML 和 JSON 数据格式的一般概述。

第三章, 过滤和汇总数据,通过介绍多种过滤和聚合数据的方法和方式,继续讲解数据处理的基础知识,并对备受推崇的data.tabledplyr包的性能和语法进行了比较。

第四章, 数据重构,涵盖了更复杂的数据转换,例如在数据集的子集上应用函数、合并数据以及将数据转换为长表和宽表格式,以完美匹配您所需的数据工作流程与源数据。

第五章"), 构建模型(由 Renata Nemeth 和 Gergely Toth 撰写),是第一章节,它处理实际的统计模型,并介绍了回归和一般模型的概念。这一简短的章节解释了如何通过在一个实际数据集上构建线性多元回归模型来测试模型的假设并解释结果。

第六章"), 超越线性趋势线(由 Renata Nemeth 和 Gergely Toth 撰写),基于前一章的内容,但涵盖了预测变量非线性关联的问题,并提供了关于广义线性模型(如逻辑回归和泊松回归)的进一步示例。

第七章, 非结构化数据,介绍了新的数据类型。这些数据可能不会以结构化的方式包含任何信息。在这里,你将学习如何通过一些关于文本挖掘算法的实际示例,使用统计方法来处理此类非结构化数据,并可视化结果。

第八章, 数据抛光,涵盖了原始数据源中另一个常见问题。大多数时候,数据科学家处理脏数据问题,例如尝试从错误、异常值和其他异常中净化数据。另一方面,也很重要去填补或最小化缺失值的影响。

第九章, 从大数据到小数据,假设你的数据已经加载、清洗并转换为正确的格式。现在你可以开始分析通常数量众多的变量,为此我们涵盖了关于降维和其他连续变量数据转换的统计方法,例如主成分分析、因子分析和多维尺度。

第十章, 分类和聚类,讨论了使用监督和未监督的统计和机器学习方法(如层次聚类和 k-means 聚类、潜在类别模型、判别分析、逻辑回归和 k 近邻算法以及分类和回归树)对样本中的观测值进行分组的方法。

第十一章, R 生态系统的社会网络分析,专注于一种特殊的数据结构,并介绍了网络分析的基本概念和可视化技术,特别关注igraph包。

第十二章, 时间序列分析,展示了如何处理时间日期对象,并通过平滑、季节分解和 ARIMA 分析相关值,包括一些预测和异常值检测。

第十三章, 周围的数据,涵盖了数据的另一个重要维度,主要关注使用主题、交互式、等高线和 Voronoi 地图来可视化空间数据。

第十四章,分析 R 社区提供了一个更完整的案例研究,它结合了前几章的许多不同方法,以突出您在本书中学到的内容以及您可能在未来的项目中遇到的问题和挑战。

附录,参考文献提供了对所使用 R 包的引用以及每个上述章节的一些进一步建议阅读材料。

您需要这本书什么

本书提供的所有代码示例都应该在 R 控制台中运行,该控制台需要安装在您的计算机上。您可以免费下载软件,并在r-project.org找到所有主要操作系统的安装说明。

虽然我们不会涵盖高级主题,例如如何在集成开发环境(IDE)中使用 R,但除了其他编辑器之外,还有许多出色的插件和扩展适用于 Emacs、Eclipse、vi 和 Notepad++。此外,我们强烈建议您尝试 RStudio,这是一个免费且开源的 IDE,专门用于 R,您可以在www.rstudio.com/products/RStudio找到它。

除了一个工作的 R 安装之外,我们还将使用一些用户贡献的 R 包。在大多数情况下,这些包可以轻松地从综合 R 档案网络(CRAN)安装。本书中所需包的来源和用于生成输出的版本列表在附录,参考文献中。

要从 CRAN 安装包,您需要一个互联网连接。要下载二进制文件或源代码,请在 R 控制台中使用install.packages命令,如下所示:

> install.packages('pander')

本书提到的某些包目前尚未在 CRAN 上提供,但可以从 Bitbucket 或 GitHub 安装。这些包可以通过devtools包中的install_bitbucketinstall_github函数安装。Windows 用户应首先从cran.r-project.org/bin/windows/Rtools安装rtools

安装完成后,在您开始使用之前,应将包加载到当前的 R 会话中。所有必需的包都在附录中列出,但代码示例在每个章节的第一次出现时也包括每个包的相关 R 命令:

> library(pander)

我们强烈建议下载本书的代码示例文件(参考下载示例代码部分),这样您就可以轻松地将命令复制粘贴到 R 控制台,而无需显示示例和书中输出的 R 提示符。

如果您没有 R 的经验,您应该从 R 主页上的免费入门文章和手册开始,本书附录中也提供了一份建议材料清单。

这本书面向谁

如果您是一位想要探索和优化 R 高级功能和工具的数据科学家或 R 开发者,那么这本书就是为您准备的。需要具备 R 的基本知识,以及数据库逻辑的理解。如果您是一位想要探索和优化 R 高级功能的数据科学家、工程师或分析师,这本书也是为您准备的。尽管需要具备 R 的基本知识,但本书通过提供入门材料的参考,可以帮助您快速上手。

惯例

您将找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。

函数名、参数、变量和其他在文本中的代码引用如下所示:"read.big.matrix函数的header参数默认为FALSE"。

在 R 控制台中显示的任何命令行输入或输出都按照以下方式编写:

> set.seed(42)
> data.frame(
+   A = runif(2),
+   B = sample(letters, 2))
 A B
1 0.9148060 h
2 0.9370754 u

>字符代表提示符,这意味着 R 控制台正在等待评估命令。多行表达式以第一行的相同符号开始,但所有其他行在开头都有一个+符号,以表明最后一个 R 表达式尚未完成(例如,缺少关闭括号或引号)。输出返回时没有额外的前置字符,且使用相同的等宽字体样式。

新术语重要词汇以粗体显示。

注意

警告或重要注意事项以如下方框形式出现。

小贴士

小技巧和窍门以如下形式出现。

读者反馈

我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。

要向我们发送一般反馈,请简单地发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书籍的标题。

如果您在某个主题领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

既然您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大价值。

下载示例代码

您可以从您在www.packtpub.com的账户中下载示例代码文件,适用于您购买的所有 Packt 出版图书。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便直接将文件通过电子邮件发送给您。

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。这些彩色图像将帮助您更好地理解输出中的变化。您可以从www.packtpub.com/sites/default/files/downloads/1234OT_ColorImages.pdf下载此文件。

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

侵权

在互联网上,版权材料的侵权是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上遇到任何形式的我们作品的非法副本,请立即向我们提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供疑似侵权材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您对本书的任何方面有问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章. 嗨,数据!

在 R 中,大多数项目都是从将至少一些数据加载到运行中的 R 会话开始的。由于 R 支持多种文件格式和数据库后端,因此有多种方法可以实现这一点。在本章中,我们不会处理您已经熟悉的基本数据结构,而是将重点放在加载大型数据集的性能问题和处理特殊文件格式上。

注意

为了快速了解标准工具并刷新您对导入通用数据的知识,请参阅 CRAN 官方手册《R 简介》的第七章 cran.r-project.org/doc/manuals/R-intro.html#Reading-data-from-files 或 Rob Kabacoff 的 Quick-R 网站,该网站提供了 R 中大多数通用任务的关键字和速查表 www.statmethods.net/input/importingdata.html。有关进一步的材料,请参阅附录中的参考文献部分。

虽然 R 有自己的(序列化的)二进制RDatards文件格式,这对所有 R 用户来说都非常方便,因为这些格式也以高效的方式存储 R 对象元信息,但大多数时候我们不得不处理其他输入格式——这些格式由我们的雇主或客户提供。

最受欢迎的数据文件格式之一是平面文件,这些文件是简单的文本文件,其中的值由空格、管道字符、逗号或更常见的是在欧洲的分号分隔。本章将讨论 R 提供的一些选项来加载这类文档,并将基准测试这些选项中哪个是导入大型文件最有效的方法。

有时我们只对数据集的一个子集感兴趣;因此,没有必要从源中加载所有数据。在这种情况下,数据库后端可以提供最佳性能,其中数据以结构化的方式预先加载到我们的系统中,这样我们就可以使用简单高效的操作查询该数据的任何子集。本章的第二部分将重点介绍三种最流行的数据库(MySQL、PostgreSQL 和 Oracle 数据库),以及如何在 R 中与这些数据库交互。

除了其他一些辅助工具和对其他数据库后端的快速概述外,我们还将讨论如何将 Excel 电子表格加载到 R 中——无需在 Excel 或 Open/LibreOffice 中将这些文件预先转换为文本文件。

当然,本章不仅仅关于数据文件格式、数据库连接等枯燥的内部结构。但请记住,数据分析总是从加载数据开始的。这是不可避免的,这样我们的计算机和统计环境在执行一些真正的分析之前就能知道数据的结构。

加载合理大小的文本文件

本章的标题也可能是你好,大数据!因为现在我们专注于在 R 会话中加载相对大量的数据。但什么是大数据,在 R 中处理多少数据是问题性的?什么是合理的大小?

R 被设计用来处理适合单个计算机物理内存的数据。因此,处理小于实际可用 RAM 的数据集应该没问题。但请注意,在执行某些计算时,如主成分分析,处理数据所需的内存可能会变得更大,这也应予以考虑。我将把这部分数据称为合理大小的数据集。

使用 R 从文本文件加载数据相当简单,通过调用古老的read.table函数,可以加载任何合理大小的数据集。这里可能唯一的问题是性能:读取例如 25 万行数据需要多长时间?让我们看看:

> library('hflights')
> write.csv(hflights, 'hflights.csv', row.names = FALSE)

注意

作为提醒,请注意,本书中所有 R 命令和返回的输出都格式化得与本书前面的格式相同。命令从第一行的>开始,多行表达式的其余部分从+开始,就像在 R 控制台中一样。要在您的机器上复制和粘贴这些命令,请从 Packt 主页下载代码示例。有关更多详细信息,请参阅前言中的您需要为此书准备的内容部分。

是的,我们刚刚从hflights包中将一个 18.5 MB 的文本文件写入您的磁盘,其中包含有关 2011 年从休斯顿出发的所有航班的某些数据:

> str(hflights)
'data.frame':  227496 obs. of  21 variables:
 $ Year             : int  2011 2011 2011 2011 2011 2011 2011 ...
 $ Month            : int  1 1 1 1 1 1 1 1 1 1 ...
 $ DayofMonth       : int  1 2 3 4 5 6 7 8 9 10 ...
 $ DayOfWeek        : int  6 7 1 2 3 4 5 6 7 1 ...
 $ DepTime          : int  1400 1401 1352 1403 1405 1359 1359 ...
 $ ArrTime          : int  1500 1501 1502 1513 1507 1503 1509 ...
 $ UniqueCarrier    : chr  "AA" "AA" "AA" "AA" ...
 $ FlightNum        : int  428 428 428 428 428 428 428 428 428 ...
 $ TailNum          : chr  "N576AA" "N557AA" "N541AA" "N403AA" ...
 $ ActualElapsedTime: int  60 60 70 70 62 64 70 59 71 70 ...
 $ AirTime          : int  40 45 48 39 44 45 43 40 41 45 ...
 $ ArrDelay         : int  -10 -9 -8 3 -3 -7 -1 -16 44 43 ...
 $ DepDelay         : int  0 1 -8 3 5 -1 -1 -5 43 43 ...
 $ Origin           : chr  "IAH" "IAH" "IAH" "IAH" ...
 $ Dest             : chr  "DFW" "DFW" "DFW" "DFW" ...
 $ Distance         : int  224 224 224 224 224 224 224 224 224 ...
 $ TaxiIn           : int  7 6 5 9 9 6 12 7 8 6 ...
 $ TaxiOut          : int  13 9 17 22 9 13 15 12 22 19 ...
 $ Cancelled        : int  0 0 0 0 0 0 0 0 0 0 ...
 $ CancellationCode : chr  "" "" "" "" ...
 $ Diverted         : int  0 0 0 0 0 0 0 0 0 0 ...

注意

hflights包提供了一个简单的方法来加载美国交通统计局研究与创新技术管理局的庞大航空公司数据集的一个子集。原始数据库包括自 1987 年以来所有美国航班的预定和实际起飞/到达时间,以及一些其他有趣的信息,常用于演示机器学习和大数据技术。有关数据集的更多详细信息,请参阅列描述和其他元数据,见www.transtats.bts.gov/DatabaseInfo.asp?DB_ID=120&Link=0

我们将使用这 21 列数据来基准数据导入时间。例如,让我们看看使用read.csv导入 CSV 文件需要多长时间:

> system.time(read.csv('hflights.csv'))
 user  system elapsed 
 1.730   0.007   1.738

在这里从 SSD 加载数据花费了超过一秒半的时间。这相当不错,但我们可以通过识别并指定列的类别来达到更好的效果,而不是调用默认的type.convert(有关更多详细信息,请参阅read.table中的文档或搜索 StackOverflow,其中read.csv的性能似乎是一个相当常见且受欢迎的问题):

> colClasses <- sapply(hflights, class)
> system.time(read.csv('hflights.csv', colClasses = colClasses))
 user  system elapsed 
 1.093   0.000   1.092

这要好得多!但我们是否应该相信这个观察结果?在我们掌握 R 中的数据分析的过程中,我们应该实施一些更可靠的测试——通过简单地重复任务n次,并提供模拟结果的总览。这种方法为我们提供了具有多个观察结果的可视化数据,可以用来识别结果中的统计显著性差异。microbenchmark包提供了一个很好的框架来完成此类任务:

> library(microbenchmark)
> f <- function() read.csv('hflights.csv')
> g <- function() read.csv('hflights.csv', colClasses = colClasses,
+                        nrows = 227496, comment.char = '')
> res <- microbenchmark(f(), g())
> res
Unit: milliseconds
 expr       min        lq   median       uq      max neval
 f() 1552.3383 1617.8611 1646.524 1708.393 2185.565   100
 g()  928.2675  957.3842  989.467 1044.571 1284.351   100

因此,我们定义了两个函数:f代表read.csv的默认设置,而在g函数中,我们传递了上述列类以及两个其他参数以提高性能。comment.char参数告诉 R 不要在导入的数据文件中查找注释,而nrows参数定义了从文件中读取的确切行数,这可以在内存分配上节省一些时间和空间。将stringsAsFactors设置为FALSE也可能略微加快导入速度。

注意

可以使用一些第三方工具识别文本文件中的行数,例如 Unix 中的wc,或者一个稍微慢一点的替代方法是R.utils包中的countLines函数。

但回到结果。让我们也可视化测试用例的中位数和相关描述性统计,这些测试用例默认运行了 100 次:

> boxplot(res, xlab  = '',
+   main = expression(paste('Benchmarking ', italic('read.table'))))

合理大小的文本文件加载

差异似乎非常显著(请随意进行一些统计测试以验证这一点),所以我们仅仅通过微调read.table的参数就实现了 50%以上的性能提升。

大于物理内存的数据文件

将大量数据从 CSV 文件加载到 R 中,这些文件无法适应内存,可以通过为这种情况创建的定制包来完成。例如,sqldf包和ff包都有它们自己的解决方案,可以从块到块地以自定义数据格式加载数据。前者使用 SQLite 或其他类似 SQL 的数据库后端,而后者创建了一个带有ffdf类的自定义数据框,可以存储在磁盘上。bigmemory包提供了类似的方法。以下是一些使用示例(待基准测试):

> library(sqldf)
> system.time(read.csv.sql('hflights.csv'))
 user  system elapsed 
 2.293   0.090   2.384 
> library(ff)
> system.time(read.csv.ffdf(file = 'hflights.csv'))
 user  system elapsed 
 1.854   0.073   1.918 
> library(bigmemory)
> system.time(read.big.matrix('hflights.csv', header = TRUE))
 user  system elapsed 
 1.547   0.010   1.559

请注意,使用bigmemory包的read.big.matrix时,默认情况下标题为FALSE,所以在进行自己的基准测试之前,务必阅读相关函数的说明书。其中一些函数也支持性能调整,就像read.table一样。有关更多示例和用例,请参阅 CRAN 任务视图中的High-Performance and Parallel Computing with RLarge memory and out-of-memory data部分,网址为cran.r-project.org/web/views/HighPerformanceComputing.html

文本文件解析器的基准测试

另一个处理和从平面文件加载合理大小数据的显著替代方案是data.table包。尽管它具有与传统基于 S 的 R 标记不同的独特语法,但该包提供了出色的文档、示例和案例研究,展示了它可以为各种数据库操作提供的真正令人印象深刻的加速。这些用例和示例将在第三章过滤和汇总数据和第四章重构数据中讨论。

该包提供了一种自定义 R 函数来读取具有改进性能的文本文件:

> library(data.table)
> system.time(dt <- fread('hflights.csv'))
 user  system elapsed 
 0.153   0.003   0.158

与先前的示例相比,加载数据非常快,尽管它产生了一个具有自定义data.table类的 R 对象,如果需要,可以轻松转换为传统的data.frame

> df <- as.data.frame(dt)

或者通过使用setDF函数,它提供了一种非常快速且就地转换对象的方法,而不实际在内存中复制数据。同样,请注意:

> is.data.frame(dt)
[1] TRUE

这意味着data.table对象可以回退以作为传统用途的data.frame。是否保留导入的数据不变或将其转换为data.frame取决于后续的使用。使用前者聚合、合并和重构数据比 R 的标准数据框格式更快。另一方面,用户必须学习data.table的定制语法——例如,DT[i, j, by]代表“从 DT 中按i子集,然后按by分组执行j”。我们将在第三章过滤和汇总数据中稍后讨论它。

现在,让我们比较所有上述数据导入方法:它们的速度如何?最终赢家似乎仍然是来自data.tablefread。首先,我们通过声明测试函数来定义要基准测试的方法:

> .read.csv.orig   <- function() read.csv('hflights.csv')
> .read.csv.opt    <- function() read.csv('hflights.csv',
+     colClasses = colClasses, nrows = 227496, comment.char = '',
+     stringsAsFactors = FALSE)
> .read.csv.sql    <- function() read.csv.sql('hflights.csv')
> .read.csv.ffdf   <- function() read.csv.ffdf(file = 'hflights.csv')
> .read.big.matrix <- function() read.big.matrix('hflights.csv',
+     header = TRUE)
> .fread           <- function() fread('hflights.csv')

现在,让我们将这些函数各运行 10 次,而不是像之前那样运行数百次迭代——只是为了节省一些时间:

> res <- microbenchmark(.read.csv.orig(), .read.csv.opt(),
+   .read.csv.sql(), .read.csv.ffdf(), .read.big.matrix(), .fread(),
+   times = 10)

并以预定义的位数打印基准测试的结果:

> print(res, digits = 6)
Unit: milliseconds
 expr      min      lq   median       uq      max neval
 .read.csv.orig() 2109.643 2149.32 2186.433 2241.054 2421.392    10
 .read.csv.opt() 1525.997 1565.23 1618.294 1660.432 1703.049    10
 .read.csv.sql() 2234.375 2265.25 2283.736 2365.420 2599.062    10
 .read.csv.ffdf() 1878.964 1901.63 1947.959 2015.794 2078.970    10
 .read.big.matrix() 1579.845 1603.33 1647.621 1690.067 1937.661    10
 .fread()  153.289  154.84  164.994  197.034  207.279    10

请注意,现在我们正在处理适合实际物理内存的数据集,而一些基准测试的包是为远大于数据库的数据库设计的和优化的。因此,优化read.table函数似乎在默认设置之上提供了很大的性能提升,尽管如果我们追求真正快速导入合理大小的数据,使用data.table包是最佳解决方案。

加载文本文件的子集

有时我们只需要数据集的一部分来进行分析,存储在数据库后端或平面文件中。在这种情况下,仅加载数据框的相关子集将比任何性能调整和自定义包讨论的更快。

让我们假设我们只对 2012 年useR!会议(在纳什维尔举行)的航班感兴趣。这意味着我们只需要 CSV 文件中Dest等于BNA(这个国际航空运输协会机场代码代表纳什维尔国际机场)的行。

而不是在 160 到 2,000 毫秒内(见上一节)加载整个数据集,然后删除无关的行(见第三章,过滤和汇总数据),让我们看看在加载数据的同时过滤数据的方法。

已经提到的sqldf包可以通过指定一个要在为导入任务创建的临时 SQLite 数据库上运行的 SQL 语句来帮助完成这个任务:

> df <- read.csv.sql('hflights.csv',
+   sql = "select * from file where Dest = '\"BNA\"'")

这个sql参数默认为"select * from file",这意味着加载每行的所有字段而不进行任何过滤。现在我们通过添加一个filter语句扩展了这一点。请注意,在我们的更新 SQL 语句中,我们还添加了双引号到搜索词中,因为sqldf不会自动识别引号为特殊字符;它将它们视为字段的一部分。也可以通过提供自定义的过滤参数来解决这个问题,例如以下 Unix-like 系统中的示例:

> df <- read.csv.sql('hflights.csv',
+   sql = "select * from file where Dest = 'BNA'",
+   filter = 'tr -d ^\\" ')

结果数据框只包含原始数据集中的 227,496 个案例中的 3,481 个观测值,当然,在临时 SQLite 数据库中进行过滤当然会稍微加快数据导入的速度:

> system.time(read.csv.sql('hflights.csv'))
 user  system elapsed 
 2.117   0.070   2.191 
> system.time(read.csv.sql('hflights.csv',
+   sql = "select * from file where Dest = '\"BNA\"'"))
 user  system elapsed 
 1.700   0.043   1.745

稍微的改进是由于两个 R 命令首先将 CSV 文件加载到一个临时的 SQLite 数据库中;这个过程当然需要一些时间,并且无法从这个过程中消除。为了加快这部分评估的速度,你可以将dbname指定为NULL以获得性能提升。这样,SQLite 数据库就会在内存中创建,而不是在tempfile中,这可能不是处理大型数据集的最佳解决方案。

在将文件加载到 R 之前过滤平面文件

有没有更快或更智能的方法来只加载这样的文本文件的一部分?一个人可以在将它们传递给 R 之前对平面文件应用一些基于正则表达式的过滤。例如,grepack可能在 Unix 环境中是一个很好的工具,但在 Windows 机器上默认不可用,并且通过正则表达式解析 CSV 文件可能会导致一些意外的副作用。相信我,你永远不想从头开始编写 CSV、JSON 或 XML 解析器!

无论如何,现在的数据科学家在处理数据方面应该是一个真正的多面手,所以这里有一个快速且简单的例子来展示如何在 100 毫秒内读取过滤后的数据:

> system.time(system('cat hflights.csv | grep BNA', intern = TRUE))
 user  system elapsed 
 0.040   0.050   0.082

嗯,与我们的任何先前结果相比,这是一个非常好的运行时间!但如果我们想过滤出到达延误超过 13.5 分钟的航班怎么办?

另一种方法,可能是一个更易于维护的方法,就是首先将数据加载到数据库后端,并在需要任何数据子集时查询它。这样,例如,我们只需在文件中一次性填充 SQLite 数据库,然后以后可以以 read.csv.sql 默认运行时间的片段来检索任何子集。

因此,让我们创建一个持久的 SQLite 数据库:

> sqldf("attach 'hflights_db' as new")

这个命令已经在当前工作目录中创建了一个名为 hflights_db 的文件。接下来,让我们创建一个名为 hflights 的表,并将 CSV 文件的內容填充到之前创建的数据库中:

> read.csv.sql('hflights.csv',
+   sql = 'create table hflights as select * from file',
+   dbname = 'hflights_db')

目前还没有进行基准测试,因为这些步骤只会运行一次,而数据集子部分的查询可能稍后会多次运行:

> system.time(df <- sqldf(
+   sql = "select * from hflights where Dest = '\"BNA\"'",
+   dbname = "hflights_db"))
 user  system elapsed 
 0.070   0.027   0.097

我们已经将所需的数据库子集在不到 100 毫秒内加载完成!但如果计划经常查询持久数据库,我们可以做得更好:为什么不专门为我们的数据集分配一个真实的数据库实例,而不是一个简单的基于文件和无需服务器的 SQLite 后端呢?

从数据库加载数据

使用专用数据库后端而不是按需从磁盘加载数据的巨大优势在于,数据库提供:

  • 更快地访问整个或所选部分的大表

  • 在将数据加载到 R 之前,强大的快速聚合和过滤数据的方法

  • 相比于传统的电子表格和 R 对象的矩阵模型,提供了一种在关系型、更结构化的方案中存储数据的基础设施

  • 连接和合并相关数据的程序

  • 同时从多个客户端进行并发和网络访问

  • 访问数据的安全策略和限制

  • 可扩展和可配置的后端以存储数据

DBI 包提供了一个数据库接口,它是 R 与各种关系数据库管理系统RDBMS)之间的通信渠道,例如 MySQL、PostgreSQL、MonetDB、Oracle,以及例如 Open Document Databases 等,等等。实际上,没有必要单独安装此包,因为它作为接口,如果需要,将作为依赖项自动安装。

连接到数据库并检索数据与所有这些后端都相当相似,因为它们都基于关系模型并使用 SQL 来管理和查询数据。请务必注意,上述数据库引擎之间存在一些重要差异,并且还存在更多开源和商业替代方案。但我们不会深入探讨如何选择数据库后端或如何构建数据仓库以及提取、转换和加载ETL)工作流程的细节,我们只会专注于从 R 中建立连接和管理数据。

注意

SQL,最初由 IBM 开发,拥有超过 40 年的历史,是目前最重要的编程语言之一——有各种方言和实现。作为全球最受欢迎的声明性语言之一,有许多在线教程和免费课程教授如何使用 SQL 查询和管理数据,这无疑是每位数据科学家瑞士军刀中最重要的工具之一。

因此,除了 R 之外,了解 RDBMS(关系数据库管理系统)也非常值得,这在您作为数据分析师或类似职位在任何行业中工作的地方都非常常见。

设置测试环境

数据库后端通常运行在远离进行数据分析的用户的服务器上,但出于测试目的,在运行 R 的机器上安装本地实例可能是个好主意。由于安装过程在不同的操作系统上可能极其不同,我们不会进入安装步骤的任何细节,而是会参考软件的下载位置以及一些有关安装的优质资源和文档的链接。

请注意,安装并尝试从这些数据库加载数据完全是可选的,您不必遵循每个步骤——本书的其余部分将不依赖于任何数据库知识或与数据库相关的先前经验。另一方面,如果您不想在测试目的的多个数据库应用程序的临时安装中弄乱您的工作空间,使用虚拟机可能是一个最佳解决方案。Oracle 的 VirtualBox 提供了一种免费且简单的方法来运行多个虚拟机,每个虚拟机都有其专用的操作系统和用户空间。

注意

有关如何下载并导入 VirtualBox 镜像的详细说明,请参阅 Oracle 部分。

这样,您可以快速部署一个完全功能但可丢弃的数据库环境来测试本章的以下示例。在下面的图像中,您可以看到 VirtualBox 中安装了四个虚拟机,其中三个在后台运行,为测试目的提供一些数据库后端:

设置测试环境

注意

VirtualBox 可以通过您的操作系统包管理器在 Linux 上安装,或者从 www.virtualbox.org/wiki/Downloads 下载安装二进制文件/源代码。有关详细和特定操作系统的安装信息,请参阅手册的 第二章安装细节www.virtualbox.org/manual/.

现在,设置和运行虚拟机非常直观和简单;基本上,您只需要加载并启动虚拟机镜像。一些虚拟机,所谓的虚拟机,已经包含了操作系统,通常已经配置了一些软件以便工作,以便简单、容易和快速分发。

小贴士

再次强调,如果您不喜欢安装和测试新软件或花时间学习支持您数据需求的基础设施,以下步骤不是必需的,您可以自由跳过这些主要针对全栈开发人员/数据科学家描述的可选任务。

这些可以在任何计算机上运行的预配置虚拟机可以从互联网上的多个提供商处下载,格式多种多样,例如 OVF 或 OVA。例如,可以从 virtualboximages.com/vdi/indexvirtualboxes.org/images/ 下载通用的 VirtualBox 虚拟设备。

注意

虚拟设备应在 VirtualBox 中导入,而非 OVF/OVA 的磁盘镜像应附加到新创建的虚拟机上;因此,可能还需要一些额外的手动配置。

Oracle 还有一个包含大量对数据科学家学徒和其他开发人员有用的虚拟镜像的存储库,位于 www.oracle.com/technetwork/community/developer-vm/index.html,例如,Oracle Big Data Lite VM 开发者虚拟设备包含以下最重要的组件:

  • Oracle 数据库

  • Cloudera 分发中的 Apache Hadoop 和各种工具

  • Oracle R 分发

  • 基于 Oracle Enterprise Linux 构建

免责声明:Oracle 不会是我个人的首选,但他们在其平台无关的虚拟化环境中做得很好,就像他们基于其商业产品提供免费开发人员 VM 一样。简而言之,提供的 Oracle 工具绝对值得使用。

注意

如果您无法在网络上访问已安装的虚拟机,请更新您的网络设置,如果不需要互联网连接,请使用 仅主机适配器,或者对于更稳定的设置使用 桥接网络。后者设置将在您的本地网络上为虚拟机保留一个额外的 IP 地址;这样,它就很容易访问了。请参阅 Oracle 数据库 部分以获取更多详细信息及示例截图。

另一个为开源数据库引擎创建的虚拟设备的好来源是 Turnkey GNU/Linux 存储库,位于 www.turnkeylinux.org/database。这些镜像基于 Debian Linux,完全免费使用,目前支持 MySQL、PostgreSQL、MongoDB 和 CouchDB 数据库。

Turnkey Linux 媒体的一个巨大优势是它只包含开源、免费软件和非专有内容。此外,磁盘镜像要小得多,只包含一个专用数据库引擎所需的组件。这也导致安装速度更快,在所需的磁盘和内存空间方面开销更小。

更多类似的虚拟应用可以在www.webuzo.com/sysapps/databases找到,这里提供了更广泛的数据库后端选择,例如 Cassandra、HBase、Neo4j、Hypertable 或 Redis,尽管一些 Webuzo 虚拟应用可能需要付费订阅才能部署。

而作为新兴的酷炫技术 Docker,我更建议你熟悉其快速部署软件容器的概念。这样的容器可以被描述为一个包含操作系统、库、工具、数据和独立文件系统的独立文件系统,它基于 Docker 镜像的抽象层。在实践中,这意味着你可以在本地主机上使用一行命令启动一个包含一些示例数据的数据库,开发这样的自定义镜像同样简单。请参阅一些简单示例和进一步参考,我在github.com/cardcorp/card-rocker描述的 R 和 Pandoc 相关的 Docker 镜像。

MySQL 和 MariaDB

MySQL 是全球最受欢迎的开源数据库引擎,这是基于 DB-Engines 排名总结的提及次数、工作机会、Google 搜索等,db-engines.com/en/ranking。主要用于 Web 开发,其高人气可能是因为 MySQL 是免费的、平台无关的,并且相对容易设置和配置——就像其替代品分支MariaDB一样。

注意

MariaDB 是由 MySQL 的创始人 Michael Widenius 发起和领导的社区开发的开源分支,后来与 SkySQL 合并;因此,前 MySQL 的高管和投资者也加入了这个分支。在 Sun Microsystems 收购 MySQL(目前由 Oracle 拥有)之后,数据库引擎的开发发生了变化。

在书中,我们将这两个引擎都称为 MySQL 以保持简单,因为 MariaDB 可以被视为 MySQL 的替代品,所以请随意使用 MySQL 或 MariaDB 重放以下示例。

尽管在大多数操作系统上安装 MySQL 服务器相当简单(dev.mysql.com/downloads/mysql/),但有人可能更愿意在虚拟机上安装数据库。Turnkey Linux 提供了免费的小型但完全配置好的虚拟应用:www.turnkeylinux.org/mysql

R 语言提供了多种从 MySQL 数据库查询数据的方法。一种选择是使用RMySQL包,对于一些用户来说,安装这个包可能有点棘手。如果你使用 Linux,请确保安装 MySQL 的开发包和 MySQL 客户端,以便该包可以在你的系统上编译。另外,由于 MySQL 版本的高变异性,CRAN 上没有可用的二进制包用于 Windows 安装,因此 Windows 用户也应该从源代码编译该包:

> install.packages('RMySQL', type = 'source')

Windows 用户可能会发现以下关于在 Windows 下安装 rmysql 的详细步骤的博客文章很有用:www.ahschulz.de/2013/07/23/installing-rmysql-under-windows/

注意

为了简化起见,我们将把 MySQL 服务器称为监听默认 3306 端口的 localhost;在所有数据库连接中,用户将是 user,密码将是 password。我们将使用 hflights_db 数据库中的 hflights 表,就像在前面几页的 SQLite 示例中一样。如果你在一个远程或虚拟服务器上工作,请相应地修改以下代码示例中的 hostusername 等参数。

在成功安装并启动 MySQL 服务器后,我们必须设置一个测试数据库,稍后我们可以在 R 中填充它。为此,让我们启动 MySQL 命令行工具来创建数据库和测试用户。

请注意,以下示例是在 Linux 上运行的,Windows 用户可能还需要提供路径以及可能还需要 exe 文件扩展名来启动 MySQL 命令行工具:

MySQL 和 MariaDB

在之前的屏幕截图中,我们可以看到这次快速会话,我们首先以 root(管理员)用户身份在命令行中连接到 MySQL 服务器。然后我们创建了一个名为 hflights_db 的数据库,并将该数据库的所有权限和权限授予了一个名为 user 的新用户,密码设置为 password。然后我们简单地验证是否可以与新创建的用户连接到数据库,并退出了命令行 MySQL 客户端。

要将数据从 MySQL 数据库加载到 R 中,首先我们必须连接到服务器,并且通常还需要进行身份验证。这可以通过在附加 RMySQL 时自动加载的 DBI 包来完成:

> library(RMySQL)
Loading required package: DBI
> con <- dbConnect(dbDriver('MySQL'),
+   user = 'user', password = 'password', dbname = 'hflights_db')

现在,我们可以将我们的 MySQL 连接称为 con,其中我们想要部署 hflights 数据集以供后续访问:

> dbWriteTable(con, name = 'hflights', value = hflights)
[1] TRUE
> dbListTables(con)
[1] "hflights"

dbWriteTable 函数将具有相同名称的 hflights 数据框写入先前定义的连接。后一个命令显示了当前使用的数据库中的所有表,相当于 SQL 命令 SHOW TABLES。现在我们已经将原始的 CVS 文件导入到 MySQL 中,让我们看看读取整个数据集需要多长时间:

> system.time(dbReadTable(con, 'hflights'))
 user  system elapsed 
 0.993   0.000   1.058

或者,我们可以通过将直接 SQL 命令传递给 dbGetQuery 的同一 DBI 包来完成:

> system.time(dbGetQuery(con, 'select * from hflights'))
 user  system elapsed 
 0.910   0.000   1.158

为了使后续的示例更加简单,让我们回到 sqldf 包,它代表“数据框上的 SQL 选择”。实际上,sqldf 是围绕 DBI 的 dbSendQuery 函数的一个方便的包装器,带有一些有用的默认设置,并返回 data.frame。这个包装器可以查询各种数据库引擎,如 SQLite、MySQL、H2 或 PostgreSQL,默认使用全局 sqldf.driver 选项中指定的引擎;如果没有指定,它将检查是否有为上述后端加载了任何 R 包。

由于我们已加载 RMySQL,现在 sqldf 将默认使用 MySQL 而不是 SQLite。但我们仍然需要指定要使用哪个连接;否则,函数将尝试打开一个新的连接——没有任何关于我们复杂的用户名和密码组合的想法,更不用说神秘的数据库名称了。连接可以在每个 sqldf 表达式中传递,或者在一个全局选项中定义一次:

> options('sqldf.connection' = con)
> system.time(sqldf('select * from hflights'))
 user  system elapsed 
 0.807   0.000   1.014

在前三个相同任务的版本之间的差异似乎并不显著。与之前测试的方法相比,1 秒的计时似乎是一个相当不错的结果——尽管使用 data.table 加载数据集仍然优于这个结果。如果我们只需要数据集的子集怎么办?让我们只获取以纳什维尔结束的航班,就像我们之前的 SQLite 示例一样:

> system.time(sqldf('SELECT * FROM hflights WHERE Dest = "BNA"'))
 user  system elapsed 
 0.000   0.000   0.281

与我们之前的 SQLite 测试相比,这似乎并不令人信服,因为后者可以在不到 100 毫秒内重现相同的结果。但请注意,用户和系统经过的时间都是零,这与 SQLite 不同。

注意

system.time 返回的返回时间表示自评估开始以来经过的毫秒数。用户和系统时间稍微复杂一些;它们由操作系统报告。大致来说,user 表示被调用进程(如 R 或 MySQL 服务器)所花费的 CPU 时间,而 system 报告内核和其他操作系统进程(如打开文件进行读取)所需的 CPU 时间。有关更多详细信息,请参阅 ?proc.time

这意味着返回所需数据子集根本未使用任何 CPU 时间,使用 SQLite 需要 100 毫秒左右。这是怎么回事?如果我们对 Dest 上的数据库进行索引会怎样?

> dbSendQuery(con, 'CREATE INDEX Dest_idx ON hflights (Dest(3));')

这个 SQL 查询代表在我们表的 Dest 列的前三个字母上创建一个名为 Dest_idx 的索引。

注意

SQL 索引可以显著提高带有 WHERE 子句的 SELECT 语句的性能,因为以这种方式,MySQL 不必读取整个数据库来匹配每一行,但它可以确定相关搜索结果的位置。随着数据库的增大,这种性能提升变得越来越显著,尽管也值得提一下,如果大多数情况下查询的是数据子集,则索引才有意义。如果需要大多数或所有数据,顺序读取会更快。

实例演示:

> system.time(sqldf('SELECT * FROM hflights WHERE Dest = "BNA"'))
 user  system elapsed 
 0.024   0.000   0.034

这似乎要好得多!当然,我们也可以对 SQLite 数据库进行索引,而不仅仅是 MySQL 实例。为了再次测试,我们必须将默认的 sqldf 驱动程序还原为 SQLite,这被加载 RMySQL 包所覆盖:

> options(sqldf.driver = 'SQLite')
> sqldf("CREATE INDEX Dest_idx ON hflights(Dest);",
+   dbname = "hflights_db"))
NULL
> system.time(sqldf("select * from hflights where
+   Dest = '\"BNA\"'", dbname = "hflights_db"))
 user  system elapsed 
 0.034   0.004   0.036

因此,似乎这两种数据库引擎都能够以秒分之一的时间返回所需的数据子集,这甚至比我们之前使用令人印象深刻的 data.table 所达到的结果要好得多。

尽管在某些早期的例子中 SQLite 被证明比 MySQL 更快,但在大多数情况下,有许多原因选择后者。首先,SQLite 是一个基于文件的数据库,这意味着数据库应该位于连接到运行 R 的计算机的文件系统上。这通常意味着 SQLite 数据库和运行中的 R 会话在同一台计算机上。同样,MySQL 可以处理更大的数据量;它具有用户管理和基于规则的权限控制,以及并发访问相同的数据集。聪明的数据科学家知道如何选择他的武器——根据任务,另一个数据库后端可能是最佳解决方案。让我们看看 R 中我们还有哪些其他选项!

PostgreSQL

虽然 MySQL 被称为最受欢迎的开源关系型数据库管理系统,但 PostgreSQL 以“世界上最先进的开源数据库”而闻名。这意味着 PostgreSQL 通常被认为比简单但更快的 MySQL 具有更多功能,包括分析函数,这也导致了 PostgreSQL 常常被描述为 Oracle 的开源版本。

现在听起来相当有趣,因为 Oracle 现在拥有 MySQL。所以,在过去的 20-30 年的关系型数据库管理系统历史中,许多事情都发生了变化,PostgreSQL 也不再那么慢了。另一方面,MySQL 也获得了一些很好的新功能——例如,MySQL 也通过 InnoDB 引擎成为 ACID 兼容,允许回滚到数据库的先前状态。这两个流行的数据库服务器之间还有一些其他差异,这些差异可能支持选择其中的任何一个。现在让我们看看如果我们的数据提供商更喜欢 PostgreSQL 而不是 MySQL 会发生什么!

安装 PostgreSQL 与 MySQL 类似。您可以使用操作系统的包管理器安装软件,从 www.enterprisedb.com/products-services-training/pgdownload 下载图形安装程序,或者使用例如免费的 Turnkey Linux 运行虚拟设备,Turnkey Linux 在 www.turnkeylinux.org/postgresql 提供了一个免费的小型但完全配置的磁盘镜像。

小贴士

下载示例代码

您可以从 www.packtpub.com 下载您购买的所有 Packt 出版物的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。

在成功安装并启动服务器后,让我们设置测试数据库——就像我们在 MySQL 安装后所做的那样:

PostgreSQL

在某些情况下,语法略有不同,我们已使用了一些命令行工具用于用户和数据库的创建。这些辅助程序默认随 PostgreSQL 一起提供,MySQL 也提供了一些类似的功能,例如 mysqladmin

在设置初始测试环境之后,或者如果我们已经有一个可连接的工作数据库实例,我们可以借助 RPostgreSQL 包重复之前描述的数据管理任务:

> library(RPostgreSQL)
Loading required package: DBI

注意

如果你的 R 会话在以下示例中开始抛出奇怪的错误消息,那么加载的 R 包冲突的可能性非常高。你可以简单地启动一个干净的 R 会话,或者断开之前附加的包——例如,detach('package:RMySQL', unload = TRUE)

连接到数据库(监听默认端口号 5432)再次变得熟悉:

> con <- dbConnect(dbDriver('PostgreSQL'), user = 'user',
+   password = 'password', dbname = 'hflights_db')

让我们验证我们是否连接到了正确的数据库实例,它目前应该没有 hflights 表且为空:

> dbListTables(con)
character(0)
> dbExistsTable(con, 'hflights')
[1] FALSE

然后让我们在 PostgreSQL 中编写我们的演示表,看看关于它比 MySQL 慢的旧传闻是否仍然成立:

> dbWriteTable(con, 'hflights', hflights)
[1] TRUE
> system.time(dbReadTable(con, 'hflights'))
 user  system elapsed 
 0.590   0.013   0.921

看起来很令人印象深刻!那么加载部分数据呢?

> system.time(dbGetQuery(con,
+ statement = "SELECT * FROM hflights WHERE \"Dest\" = 'BNA';"))
 user  system elapsed 
 0.026   0.000   0.082

没有索引的情况下大约 100 毫秒!请注意 Dest 周围额外的转义引号,因为默认的 PostgreSQL 行为会将未引用的列名折叠为小写,这会导致出现列 dest 不存在的错误。根据 MySQL 示例,创建索引并运行前面的查询以大幅提高速度可以轻松重现。

Oracle 数据库

Oracle 数据库 Express Edition 可以从 www.oracle.com/technetwork/database/database-technologies/express-edition/downloads/index.html 下载和安装。虽然这不是一个功能齐全的 Oracle 数据库,并且存在严重的限制,但 Express Edition 是一种免费且不太占用资源的在家构建测试环境的方法。

注意

据说 Oracle 数据库是世界上最受欢迎的数据库管理系统,尽管它只能通过专有许可证获得,与之前讨论的两个 RDBMS 不同,这意味着 Oracle 以条款许可的形式提供产品。另一方面,付费许可证还附带来自开发商公司的优先支持,这在企业环境中通常是一个严格的要求。Oracle 数据库自 1980 年首次发布以来就支持了许多优秀的功能,例如分片、主-主复制和完全 ACID 属性。

另一种获取用于测试目的的 Oracle 数据库的方法是从www.oracle.com/technetwork/community/developer-vm/index.html下载 Oracle 预构建开发者虚拟机,或者从Oracle 技术网络开发者日动手数据库应用开发定制的更小的镜像:www.oracle.com/technetwork/database/enterprise-edition/databaseappdev-vm-161299.html。我们将遵循后者的说明。

接受许可协议并在 Oracle 免费注册后,我们可以下载OTN_Developer_Day_VM.ova虚拟设备。让我们通过文件菜单中的导入设备来将其导入 VirtualBox,然后选择ova文件,并点击下一步

Oracle 数据库

点击导入后,您需要再次同意软件许可协议。导入虚拟磁盘镜像(15 GB)可能需要几分钟时间:

Oracle 数据库

导入完成后,我们应该首先更新网络配置,以便我们可以从外部访问虚拟机的内部数据库。所以让我们在设置中将NAT切换到桥接适配器

Oracle 数据库

然后,我们可以在 VirtualBox 中简单地启动新创建的虚拟机。Oracle Linux 启动后,我们可以使用默认的oracle密码登录。

虽然我们已经为我们的虚拟机设置了一个桥接网络接口,这意味着虚拟机直接连接到我们的真实子网络并具有真实 IP 地址,但该机器在网络中尚不可访问。要使用默认的 DHCP 设置进行连接,只需导航到顶部红色栏并查找网络图标,然后选择系统 eth0。几秒钟后,虚拟机可以从您的宿主机访问,因为虚拟系统应该连接到您的网络。您可以通过在已运行的控制台中运行ifconfigip addr show eth0命令来验证这一点:

Oracle 数据库

不幸的是,这个已经运行的 Oracle 数据库在虚拟机外部尚不可访问。开发者虚拟机默认有一个相当严格的防火墙,首先需要将其禁用。要查看生效的规则,运行标准命令iptables -L -n,要清除所有规则,执行iptables -F

Oracle 数据库

现在我们有一个运行且可远程访问的 Oracle 数据库,让我们准备 R 客户端。在某些操作系统上安装 ROracle 包可能会变得复杂,因为没有预构建的二进制包,你必须手动安装 Oracle Instant Client Lite 和 SDK 库,然后再从源代码编译包。如果编译器抱怨你之前安装的 Oracle 库的路径,请使用 --with-oci-lib--with-oci-inc 参数,并通过 --configure-args 参数传递你的自定义路径。更多详细信息可以在包安装文档中找到:cran.r-project.org/web/packages/ROracle/INSTALL

例如,在 Arch Linux 上,你可以从 AUR 安装 Oracle 库,然后从 CRAN 下载 R 包后,在 bash 中运行以下命令:

# R CMD INSTALL --configure-args='--with-oci-lib=/usr/include/    \
>  --with-oci-inc=/usr/share/licenses/oracle-instantclient-basic' \
>  ROracle_1.1-11.tar.gz

安装并加载包后,打开连接与之前的 DBI::dbConnect 示例极其相似。这里我们只传递一个额外的参数。首先,让我们指定包含在 dbname 参数中的 Oracle 数据库的主机名或直接 IP 地址。然后我们可以连接到开发机器上已经存在的 PDB1 数据库,而不是之前使用的 hflights_db——只是为了在书中节省一些关于稍微偏离主题的数据库管理任务的时间和空间:

> library(ROracle)
Loading required package: DBI
> con <- dbConnect(dbDriver('Oracle'), user = 'pmuser',
+   password = 'oracle', dbname = '//192.168.0.16:1521/PDB1')

我们已经有一个到 Oracle RDBMS 的工作连接:

> summary(con)
User name:             pmuser 
Connect string:        //192.168.0.16:1521/PDB1 
Server version:        12.1.0.1.0 
Server type:           Oracle RDBMS 
Results processed:     0 
OCI prefetch:          FALSE 
Bulk read:             1000 
Statement cache size:  0 
Open results:          0 

让我们看看开发虚拟机上的捆绑数据库里有什么:

> dbListTables(con)
[1] "TICKER_G" "TICKER_O" "TICKER_A" "TICKER" 

因此,似乎我们有一个名为 TICKER 的表,它包含了三个符号的 tick 数据的三个视图。在同一个数据库中保存 hflights 表不会造成任何伤害,我们还可以立即测试 Oracle 数据库读取整个表的速度:

> dbWriteTable(con, 'hflights', hflights)
[1] TRUE
> system.time(dbReadTable(con, 'hflights'))
 user  system elapsed 
 0.980   0.057   1.256

以及一个包含 3,481 个案例的极其熟悉的子集:

> system.time(dbGetQuery(con,
+ "SELECT * FROM \"hflights\" WHERE \"Dest\" = 'BNA'"))
 user  system elapsed
 0.046   0.003   0.131

请注意表名周围的引号。在之前的 MySQL 和 PostgreSQL 示例中,SQL 语句运行良好,无需那些引号。然而,在 Oracle 数据库中需要引号,因为我们以全小写名称保存了表,Oracle DB 的默认规则是将对象名称存储为大写。唯一的另一种选择是使用双引号来创建它们,这就是我们所做的;因此,我们必须用引号引用小写名称。

注意

我们从 MySQL 中的未引用的表和列名开始,然后在从 R 运行 PostgreSQL 查询时必须将变量名周围的引号转义,现在在 Oracle 数据库中我们必须将两个名称都放在引号之间——这展示了各种 SQL 方言(如 MySQL、PostgreSQL、Oracle 的 PL/SQL 或微软的 Transact-SQL)在 ANSI SQL 之上的细微差别。

更重要的是:不要让你的所有项目都坚持使用一个数据库引擎,而是如果公司政策不允许你这样做,就选择最适合任务的 DB。

与我们看到的 PostgreSQL 相比,这些结果并不那么令人印象深刻,所以让我们也看看索引查询的结果:

> dbSendQuery(con, 'CREATE INDEX Dest_idx ON "hflights" ("Dest")')
Statement:            CREATE INDEX Dest_idx ON "hflights" ("Dest") 
Rows affected:        0 
Row count:            0 
Select statement:     FALSE 
Statement completed:  TRUE 
OCI prefetch:         FALSE 
Bulk read:            1000 
> system.time(dbGetQuery(con, "SELECT * FROM \"hflights\"
+ WHERE \"Dest\" = 'BNA'"))
 user  system elapsed 
 0.023   0.000   0.069

我将全面的比较测试和基准测试留给你,这样你就可以在适合你确切需求的测试中运行自定义查询。不同的数据库引擎在特殊用例中表现不同的可能性非常高。

为了使这个过程更加无缝和易于实现,让我们看看另一种 R 连接数据库的方法,尽管可能略有性能折衷。为了快速可扩展性和性能比较,请参阅使用不同方法在 R 中连接 Oracle 数据库的blogs.oracle.com/R/entry/r_to_oracle_database_connectivity

ODBC 数据库访问

如前所述,为不同的数据库安装本机客户端软件、库和头文件,以便可以从源构建自定义 R 包,这可能会很繁琐,而且在某些情况下相当棘手。幸运的是,我们也可以尝试做这个过程的相反。一个替代方案是在数据库中安装一个中间件应用程序编程接口API),这样 R,或者更确切地说,任何其他工具,都可以以标准化和更方便的方式与它们通信。然而,请注意,这种更方便的方式由于应用程序和数据库管理系统之间的转换层而损害了性能。

RODBC包实现了对这一层的访问。开放数据库连接ODBC)驱动程序适用于大多数数据库管理系统,甚至适用于 CSV 和 Excel 文件,因此如果安装了 ODBC 驱动程序,RODBC提供了访问几乎所有数据库的标准方式。这个平台无关的接口在 Windows 和 Linux 上可用于 SQLite、MySQL、MariaDB、PostgreSQL、Oracle 数据库、Microsoft SQL Server、Microsoft Access 和 IBM DB2。

为了快速示例,让我们连接到运行在localhost(或虚拟机)上的 MySQL。首先,我们必须设置一个数据库源名称DSN),包括连接细节,例如:

  • 数据库驱动程序

  • 主机名或地址和端口号,可选的 Unix 套接字

  • 数据库名称

  • 可选的用于连接的用户名和密码

这可以通过在安装unixODBC程序后,在 Linux 上编辑odbc.iniodbcinst.ini文件来完成。后者应该在您的/etc文件夹中包含以下针对 MySQL 驱动程序的配置:

[MySQL]
Description     = ODBC Driver for MySQL
Driver          = /usr/lib/libmyodbc.so
Setup           = /usr/lib/libodbcmyS.so
FileUsage       = 1

odbc.ini文件包含了上述针对特定数据库和服务器 DSN 配置:

[hflights]
Description     = MySQL hflights test
Driver          = MySQL
Server          = localhost
Database        = hflights_db
Port            = 3306
Socket          = /var/run/mysqld/mysqld.sock

或者,在 Mac OS 或 Windows 上使用图形用户界面,如下面的截图所示:

ODBC 数据库访问

配置 DSN 后,我们可以通过一条命令进行连接:

> library(RODBC)
> con <- odbcConnect("hflights", uid = "user", pwd = "password")

让我们获取之前保存在数据库中的数据:

> system.time(hflights <- sqlQuery(con, "select * from hflights"))
 user  system elapsed 
 3.180   0.000   3.398

好吧,完成这个过程只花了几秒钟。这就是使用更方便、更高级的界面与数据库交互的权衡。除了odbc*函数提供低级数据库访问之外,还可以使用类似的高级函数(如sqlFetch)来删除和上传数据库中的数据。以下是一些快速示例:

> sqlDrop(con, 'hflights')
> sqlSave(con, hflights, 'hflights')

您可以使用完全相同的命令查询任何其他支持的数据库引擎;只需确保为每个后端设置 DSN,并在不再需要时关闭您的连接:

> close(con)

RJDBC包可以使用Java 数据库连接JDBC)驱动程序提供类似数据库管理系统的接口。

使用图形用户界面连接到数据库

说到高级接口,R 在dbConnect包中也有一个用于连接 MySQL 的图形用户界面:

> library(dbConnect)
Loading required package: RMySQL
Loading required package: DBI
Loading required package: gWidgets
> DatabaseConnect()
Loading required package: gWidgetsRGtk2
Loading required package: RGtk2

没有参数,控制台中没有自定义配置,只有一个简单的对话框窗口:

使用图形用户界面连接到数据库

在提供所需的连接信息后,我们可以轻松查看原始数据和列/变量类型,并运行自定义 SQL 查询。基本查询构建器还可以帮助新手用户从数据库中获取子样本:

使用图形用户界面连接到数据库

该包附带一个名为sqlToR的便捷函数,它可以将 SQL 结果通过 GUI 点击转换为 R 对象。不幸的是,dbConnect严重依赖于RMySQL,这意味着它是一个仅适用于 MySQL 的包,并且没有计划扩展此接口的功能。

其他数据库后端

除了之前提到的流行数据库之外,还有一些其他实现,我们在这里无法详细讨论。

例如,列式数据库管理系统,如 MonetDB,通常用于存储包含数百万行和数千列的大型数据集,为高性能数据挖掘提供后端支持。它还提供了强大的 R 支持,MonetDB.R包在 2013 年 useR!会议上的讨论中是最激动人心的之一。

NoSQL 生态系统的持续增长也提供了类似的方法,尽管通常不支持 SQL 并提供无模式的数据存储。Apache Cassandra 是一个很好的例子,它是一个类似的、以列为主的、主要分布式的数据库管理系统,具有高可用性和性能,运行在通用硬件上。RCassandra包以方便的方式提供了对 Cassandra 基本功能和 Cassandra 查询语言的基本访问,使用RC.*函数族。另一个受 Google Bigtable 启发的类似数据库引擎是 HBase,它由rhbase包支持,是RHadoop项目的一部分:github.com/RevolutionAnalytics/RHadoop/wiki

说到大规模并行处理,HP 的 Vertica 和 Cloudera 的开源 Impala 也都可以从 R 访问,因此您可以轻松地以相对良好的性能访问和查询大量数据。

最受欢迎的 NoSQL 数据库之一是 MongoDB,它以类似 JSON 的格式提供面向文档的数据存储,提供动态模式的基础设施。MongoDB 正在积极开发,并具有一些类似 SQL 的功能,如查询语言和索引,还有多个 R 包提供对此后端访问。RMongo 包使用 mongo-java-driver,因此依赖于 Java,但提供了相当高级的数据库接口。另一个实现是 rmongodb 包,由 MongoDB 团队开发和维护。后者更新更频繁,文档更详细,但与第一个包相比,R 集成似乎更加无缝,因为 rmongodb 提供了对原始 MongoDB 函数和 BSON 对象的访问,而不是专注于为一般 R 用户提供翻译层。一个更近期的、非常有前景的 MongoDB 支持包是由 Jeroen Ooms 开发的 mongolite

CouchDB,我最喜欢的无模式项目之一,提供了非常方便的文档存储,使用 JSON 对象和 HTTP API,这意味着在应用程序中集成,例如任何 R 脚本,都非常容易,例如使用 RCurl 包,尽管您可能会发现 R4CouchDB 在与数据库交互时更快速。虽然您可能会发现 R4CouchDB 在与数据库交互时更快速。

Google BigQuery 还提供了一个类似的基于 REST 的 HTTP API,使用类似 SQL 的语言查询托管在 Google 基础设施中的甚至达到千兆字节的数据。尽管 bigrquery 包尚未在 CRAN 上提供,但您可以使用同一作者 Hadley Wickham 的 devtools 包轻松地从 GitHub 安装它:

> library(devtools)
> install_github('bigrquery', 'hadley')

要测试本包和 Google BigQuery 的功能,您可以注册一个免费账户以获取并处理 Google 提供的演示数据集,但请注意,免费使用的每日请求限制为 10,000 次。请注意,当前实现是数据库的只读接口。

对于相当类似的数据库引擎和比较,例如查看 db-engines.com/en/systems。大多数流行的数据库已经支持 R,但如果还没有,我相当确信已经有某人在开发它。值得检查 CRAN 软件包 cran.r-project.org/web/packages/available_packages_by_name.html 或在 GitHub 或 R-bloggers.com 上搜索,看看其他 R 用户是如何与您选择的数据库交互的。

从其他统计系统导入数据

在一个最近的项目中,我的任务是使用 R 实现一些金融模型,我得到了要分析的演示数据集,以 Stata dta 文件的形式。作为大学的一名承包商,没有访问任何 Stata 安装权限,读取其他统计软件的二进制文件格式可能是个问题,但鉴于 dta 文件格式有文档记录,规范在 www.stata.com/help.cgi?dta 公开可用,Core R 团队的一些成员已经在 foreign 软件包中以 read.dta 函数的形式实现了 R 解析器。

因此,在 R 中加载(和经常写入)Stata——或者例如 SPSS、SAS、Weka、Minitab、Octave 或 dBase 文件——简直不能更容易。请参阅软件包文档或 R 数据导入/导出 手册中的完整支持文件格式和示例:cran.r-project.org/doc/manuals/r-release/R-data.html#Importing-from-other-statistical-systems

加载 Excel 工作表

在学术机构和商业领域,除了 CSV 文件外,存储和传输相对少量数据最流行的文件格式仍然是 Excel xls(或 xlsx,较新版本)。第一个是由微软拥有的二进制文件格式,它有详尽的文档(xls 规范包含在一篇超过 1,100 页和 50 兆字节的文档中!),但即使现在导入多个工作表、宏和公式也不是一件简单的事情。本节将仅涵盖与 Excel 交互的最常用的平台无关的软件包。

一种选择是使用之前讨论过的 RODBC 软件包和 Excel 驱动程序来查询 Excel 工作表。访问 Excel 数据的其他方法依赖于第三方工具,例如使用 Perl 自动将 Excel 文件转换为 CSV,然后使用 gdata 软件包中的 read.xls 函数将其导入 R。但在 Windows 上安装 Perl 有时似乎很繁琐;因此,RODBC 在该平台上可能是一个更方便的方法。

一些基于 Java 的平台无关解决方案不仅提供读取 Excel 文件的方法,还提供写入 Excel 文件的方法,尤其是写入 xlsx,即 Office Open XML 格式。CRAN 上存在两个独立的实现来读取和写入 Excel 2007 以及 97/2000/XP/2003 文件格式:xlConnectxlsx 软件包。这两个软件包都在积极维护中,并使用 Apache POI Java API 项目。这意味着它可以在支持 Java 的任何平台上运行,无需在计算机上安装 Microsoft Excel 或 Office;这两个软件包都可以独立读取和写入 Excel 文件。

另一方面,如果你不想依赖 Perl 或 Java,最近发布的 openxlsx 包提供了一种平台无关的(由 C++ 驱动的)读取和写入 xlsx 文件的方法。Hadley Wickham 发布了一个类似的包,但范围略有不同:readxl 包可以读取(但不能写入)xlsxlsx 文件格式。

记住:根据你的需求选择最合适的工具!例如,为了读取没有许多外部依赖的 Excel 文件,我会选择 readxl;但是,对于需要单元格格式化和更多高级功能的 Excel 2003 工作表,可能我们不能保存 Java 依赖,而应该使用 xlConnectxlsx 包,而不是仅支持 xlsxopenxlsx 包。

摘要

本章重点介绍了一些相当无聊但重要的任务,这是我们通常每天都会做的。导入数据是每个数据科学项目的第一步,因此掌握数据分析应该从如何在 R 会话中高效地加载数据开始。

但在这个意义上,效率是一个模糊的概念:从技术角度来看,加载数据应该快速,这样我们才不会浪费时间,尽管长时间编码来加快导入过程也没有太多意义。

本章概述了读取文本文件、与数据库交互以及查询 R 中的数据子集的最流行选项。现在你应该能够处理所有最常用的不同数据源,也许你还可以选择在你的项目中哪个数据源将是理想的候选者,然后像我们之前做的那样进行基准测试。

下一章将通过提供从网络和不同 API 获取数据的用例来进一步扩展这些知识。这意味着你将能够在你的项目中使用公共数据,即使你还没有那些二进制数据集文件或数据库后端。

第二章:从网络获取数据

我们经常会遇到这样的情况,即我们想在项目中使用的数据尚未存储在我们的数据库或磁盘上,但可以在互联网上找到。在这种情况下,一个选择可能是让 IT 部门或我们公司的数据工程师扩展我们的数据仓库,以爬取、处理并将数据加载到我们的数据库中,如下面的图所示:

从网络获取数据

另一方面,如果我们没有ETL系统(用于提取、转换和加载数据)或者简单地无法等待几周时间让 IT 部门实现我们的请求,我们就只能自己动手了。这对于数据科学家来说很常见,因为大多数时候我们都在开发可以由软件开发人员后来转化为产品的原型。为此,在日常工作需要各种技能,包括以下我们将在本章中涵盖的主题:

  • 从网络中以编程方式下载数据

  • 处理 XML 和 JSON 格式

  • 从原始 HTML 源中抓取和解析数据

  • 与 API 交互

尽管被称为 21 世纪最性感的工作之一(来源:hbr.org/2012/10/data-scientist-the-sexiest-job-of-the-21st-century/),但大多数数据科学任务与数据分析无关。更糟糕的是,有时这份工作似乎很无聊,或者日常工作中只需要基本的 IT 技能,根本不需要机器学习。因此,我更愿意称这个角色为数据黑客而不是数据科学家,这也意味着我们经常不得不亲自动手。

例如,抓取和清洗数据无疑是分析过程中最不性感的一部分,但它是最重要的步骤之一;据说,大约 80%的数据分析时间都花在数据清洗上。在垃圾数据上运行最先进的机器学习算法是没有意义的,所以请确保花时间从你的数据源中获取有用且整洁的数据。

注意

本章还将依赖于广泛使用互联网浏览器调试工具和一些 R 包。这些包括 Chrome 的DevTools或 Firefox 中的FireBug。尽管使用这些工具的步骤将非常直接,并且也会在屏幕截图上展示,但掌握这些工具绝对值得,因此我建议如果你对从在线来源获取数据感兴趣,可以查看一些关于这些工具的教程。一些起点列在书末附录的参考文献部分。

要快速了解从网络抓取数据的相关 R 包以及与 Web 服务交互的信息,请参阅cran.r-project.org/web/views/WebTechnologies.html上的Web 技术和服务 CRAN 任务视图

从互联网加载数据集

最明显的任务是从网络下载数据集,并通过两个手动步骤将其加载到我们的 R 会话中:

  1. 将数据集保存到磁盘。

  2. 使用标准函数,如 read.table 或例如 foreign::read.spss,来读取 sav 文件。

但我们通常可以通过跳过第一步并直接从 URL 加载平面文本数据文件来节省一些时间。以下示例从 opengeocode.org美洲开放地理编码AOG)数据库中获取一个逗号分隔的文件,该数据库包含世界各国的政府、国家统计数据、地质信息和邮政网站:

> str(read.csv('http://opengeocode.org/download/CCurls.txt'))
'data.frame':  249 obs. of  5 variables:
 $ ISO.3166.1.A2                  : Factor w/ 248 levels "AD" ...
 $ Government.URL                 : Factor w/ 232 levels ""  ...
 $ National.Statistics.Census..URL: Factor w/ 213 levels ""  ...
 $ Geological.Information.URL     : Factor w/ 116 levels ""  ...
 $ Post.Office.URL                : Factor w/ 156 levels ""  ...

在此示例中,我们向 read.tablefile 参数传递了一个超链接,这实际上在处理之前下载了文本文件。read.table 在后台使用的 url 函数支持 HTTP 和 FTP 协议,并可以处理代理,但它有自己的限制。例如,url 除了在 Windows 上的一些例外情况外,不支持 安全超文本传输协议HTTPS),这对于访问处理敏感数据的 Web 服务通常是必需的。

注意

HTTPS 不是与 HTTP 并列的独立协议,而是通过加密的 SSL/TLS 连接在 HTTP 之上。虽然 HTTP 由于客户端和服务器之间传输的未加密数据包被认为是不安全的,但 HTTPS 不允许第三方通过签名和受信任的证书发现敏感信息。

在这种情况下,安装并使用 RCurl 包是明智的,曾经这也是唯一合理的选项,该包是 R 的 curl 客户端接口:curl.haxx.se。Curl 支持广泛的协议和 URI 方案,并处理 cookies、身份验证、重定向、超时等。

例如,让我们检查美国政府的开放数据目录 catalog.data.gov/dataset。尽管可以不使用 SSL 访问一般网站,但大多数生成的下载 URL 都遵循 HTTPS URI 方案。在以下示例中,我们将从消费者金融保护局获取消费者投诉数据库的 逗号分隔值CSV)文件,该数据库可通过 catalog.data.gov/dataset/consumer-complaint-database 访问。

注意

此 CSV 文件包含自 2011 年以来关于金融产品和服务的约 25 万条投诉的元数据。请注意,该文件大小约为 35-40 兆字节,因此下载可能需要一些时间,你可能不希望在移动设备或有限互联网环境下重现以下示例。如果 getURL 函数因证书错误而失败(这可能在 Windows 上发生),请通过 options(RCurlOptions = list(cainfo = system.file("CurlSSL", "cacert.pem", package = "RCurl"))) 手动提供证书路径,或者尝试由 Jeroen Ooms 或 Hadley Wickham(RCurl 前端)最近发布的 curl 软件包——详情见后。

让我们查看从 R 中直接获取和加载 CSV 文件后,按产品类型分布的这些投诉情况:

> library(RCurl)
Loading required package: bitops
> url <- 'https://data.consumerfinance.gov/api/views/x94z-ydhh/rows.csv?accessType=DOWNLOAD'
> df  <- read.csv(text = getURL(url))
> str(df)
'data.frame':  236251 obs. of  14 variables:
 $ Complaint.ID        : int  851391 851793 ...
 $ Product             : Factor w/ 8 levels ...
 $ Sub.product         : Factor w/ 28 levels ...
 $ Issue               : Factor w/ 71 levels "Account opening ...
 $ Sub.issue           : Factor w/ 48 levels "Account status" ...
 $ State               : Factor w/ 63 levels "","AA","AE",,..
 $ ZIP.code            : int  14220 64119 ...
 $ Submitted.via       : Factor w/ 6 levels "Email","Fax" ...
 $ Date.received       : Factor w/ 897 levels  ...
 $ Date.sent.to.company: Factor w/ 847 levels "","01/01/2013" ...
 $ Company             : Factor w/ 1914 levels ...
 $ Company.response    : Factor w/ 8 levels "Closed" ...
 $ Timely.response.    : Factor w/ 2 levels "No","Yes" ...
 $ Consumer.disputed.  : Factor w/ 3 levels "","No","Yes" ...
> sort(table(df$Product))

 Money transfers         Consumer loan              Student loan 
 965                  6564                      7400 
 Debt collection      Credit reporting   Bank account or service 
 24907                 26119                     30744 
 Credit card              Mortgage 
 34848                104704

虽然知道大多数投诉是关于抵押贷款的很好,但这里的重点是使用 curl 下载具有 HTTPS URI 的 CSV 文件,然后将内容传递给 read.csv 函数(或我们在上一章中讨论的任何其他解析器)作为文本。

注意

除了 GET 请求外,您还可以通过使用 RCurl 软件包中的 postForm 函数或 httpDELETEhttpPUThttpHEAD 函数,轻松通过 POSTDELETEPUT 请求与 RESTful API 端点进行交互——有关 httr 软件包的详细信息将在后面介绍。

Curl 还可以帮助从需要授权的安全站点下载数据。这样做最简单的方法是在浏览器中登录主页,将 cookie 保存到文本文件中,然后将该路径传递给 getCurlHandle 中的 cookiefile。您还可以在其他选项中指定 useragent。有关更多详细信息以及最重要的 RCurl 功能的总体(且非常有用)概述,请参阅 www.omegahat.org/RCurl/RCurlJSS.pdf

尽管 curl 功能非常强大,但其语法和众多选项以及技术细节可能对没有良好 IT 背景的人来说过于复杂。httr 软件包是 RCurl 的简化包装,提供了一些合理的默认值和更简单的配置选项,用于常见操作和日常行动。

例如,cookies 通过在所有对同一网站的请求中共享相同的连接来自动处理;错误处理得到了显著改善,这意味着如果出现问题,调试将更加容易;该软件包包含各种辅助函数,例如设置头部信息、使用代理以及轻松发出 GETPOSTPUTDELETE 和其他方法。更重要的是,它还以更加用户友好的方式处理身份验证——包括 OAuth 支持。

注意

OAuth 是一种通过中间服务提供商进行授权的开放标准。这简单意味着用户不必共享实际凭证,而是可以委托访问服务提供商存储的一些信息。例如,可以授权 Google 与第三方共享真实姓名、电子邮件地址等信息,而无需披露任何其他敏感信息或密码。最普遍地,OAuth 用于各种 Web 服务和 API 的无密码登录。有关更多信息,请参阅第十四章,分析 R 社区,其中我们将使用 OAuth 与 Twitter 授权 R 会话以获取数据。

但如果数据无法以 CSV 文件的形式下载怎么办?

其他流行的在线数据格式

结构化数据通常以 XML 或 JSON 格式在网络上可用。这两种格式之所以如此受欢迎,是因为它们都是可读的,从程序的角度来看易于处理,并且可以管理任何类型的分层数据结构,而不仅仅是简单的表格设计,就像 CSV 文件那样。

注意

JSON 最初来源于 JavaScript 对象表示法,最近已成为最受欢迎、最常用的数据交换格式标准之一。JSON 被认为是具有属性值对的 XML 的低开销替代品,尽管它也支持多种对象类型,如数字、字符串、布尔值、有序列表和关联数组。JSON 在 Web 应用程序、服务和 API 中得到了广泛的使用。

当然,R 也支持以 JSON 格式加载数据(以及保存数据)。让我们通过从上一个示例中通过 Socrata API 获取一些数据来演示这一点(关于这一点,本章的 与数据源 API 交互的 R 包 部分将进行更多介绍),该 API 由消费者金融保护局提供。API 的完整文档可在 www.consumerfinance.gov/complaintdatabase/technical-documentation 获取。

API 的端点是 URL,我们可以在这里查询背景数据库而无需认证,即 data.consumerfinance.gov/api/views。为了获得数据的结构概览,以下是在浏览器中打开的返回的 JSON 列表:

其他流行的在线数据格式

由于 JSON 非常易于阅读,在解析之前手动浏览其结构通常非常有帮助。现在让我们使用 rjson 包将这个树形列表加载到 R 中:

> library(rjson)
> u <- 'http://data.consumerfinance.gov/api/views'
> fromJSON(file = u)
[[1]]
[[1]]$id
[1] "25ei-6bcr"

[[1]]$name
[1] "Credit Card Complaints"

[[1]]$averageRating
[1] 0
…

嗯,这似乎与我们之前在逗号分隔值文件中看到的数据不同!经过仔细查看文档,我们可以清楚地看到 API 的端点返回的是可用视图的元数据,而不是我们在 CSV 文件中看到的原始表格数据。所以,现在让我们通过在浏览器中打开相关 URL 来查看 ID 为 25ei-6bcr 的视图的前五行:

其他流行的在线数据格式

结果的 JSON 列表结构确实发生了变化。现在让我们将这个分层列表读入 R:

> res <- fromJSON(file = paste0(u,'/25ei-6bcr/rows.json?max_rows=5'))
> names(res)
[1] "meta" "data"

我们成功地获取了数据以及关于视图、列等的进一步元信息,这不是我们目前感兴趣的东西。由于 fromJSON 返回了一个 list 对象,我们可以简单地删除元数据,并从现在开始处理 data 行:

> res <- res$data
> class(res)
[1] "list"

这仍然是一个 list,我们通常希望将其转换为 data.frame。所以,我们有包含五个元素的 list,每个元素包含 19 个嵌套子元素。请注意,其中之一,第 13 个子元素,又是一个包含 5-5 向量的 list。这意味着将树形列表转换为表格格式并不简单,尤其是当我们意识到其中一个向量以未处理的 JSON 格式包含多个值时。所以,为了简单起见,以及作为概念验证演示,现在让我们简单地丢弃与位置相关的值,并将所有其他值转换为 data.frame

> df <- as.data.frame(t(sapply(res, function(x) unlist(x[-13]))))
> str(df)
'data.frame':  5 obs. of  18 variables:
 $ V1 : Factor w/ 5 levels "16756","16760",..: 3 5 ...
 $ V2 : Factor w/ 5 levels "F10882C0-23FC-4064-979C-07290645E64B" ...
 $ V3 : Factor w/ 5 levels "16756","16760",..: 3 5 ...
 $ V4 : Factor w/ 1 level "1364270708": 1 1 ...
 $ V5 : Factor w/ 1 level "403250": 1 1 ...
 $ V6 : Factor w/ 5 levels "1364274327","1364274358",..: 5 4 ...
 $ V7 : Factor w/ 1 level "546411": 1 1 ...
 $ V8 : Factor w/ 1 level "{\n}": 1 1 ...
 $ V9 : Factor w/ 5 levels "2083","2216",..: 1 2 ...
 $ V10: Factor w/ 1 level "Credit card": 1 1 ...
 $ V11: Factor w/ 2 levels "Referral","Web": 1 1 ...
 $ V12: Factor w/ 1 level "2011-12-01T00:00:00": 1 1 ...
 $ V13: Factor w/ 5 levels "Application processing delay",..: 5 1 ...
 $ V14: Factor w/ 3 levels "2011-12-01T00:00:00",..: 1 1 ...
 $ V15: Factor w/ 5 levels "Amex","Bank of America",..: 2 5 ...
 $ V16: Factor w/ 1 level "Closed without relief": 1 1 ...
 $ V17: Factor w/ 1 level "Yes": 1 1 ...
 $ V18: Factor w/ 2 levels "No","Yes": 1 1 ...

因此,我们应用了一个简单的函数,从列表的每个元素中删除位置信息(通过删除每个 x 的第 13 个元素),自动简化为 matrix(通过使用 sapply 而不是 lapply 来迭代列表中的每个元素),然后通过 t 进行转置,最后将结果对象强制转换为 data.frame

嗯,我们也可以使用一些辅助函数来代替手动调整所有列表元素,就像之前那样。plyr 包(请参阅第三章过滤和汇总数据,过滤和汇总数据和第四章重构数据,重构数据)包含一些非常实用的函数来分割和组合数据:

> library(plyr)
> df <- ldply(res, function(x) unlist(x[-13]))

现在看起来更熟悉了,尽管我们缺少变量名,所有值都被转换成了字符向量或因子——甚至存储为 UNIX 时间戳的日期也是如此。我们可以借助提供的元数据(res$meta)轻松地修复这些问题:例如,让我们通过提取(通过 [ 操作符)所有列(除了被删除的 13th 个位置数据)的名称字段来设置变量名:

> names(df) <- sapply(res$meta$view$columns, `[`, 'name')[-13]

可以借助提供的元数据来识别对象类别。例如,renderTypeName 字段是一个很好的起点来检查,使用 as.numeric 对数字进行转换,以及使用 as.POSIXct 对所有 calendar_date 字段进行转换,可以解决前面的大部分问题。

嗯,你有没有听说过大约 80% 的数据分析时间都花在了数据准备上?

解析和重构 JSON 和 XML 到data.frame可能需要很长时间,尤其是在你主要处理层次列表时。jsonlite包试图通过将 R 对象转换为传统的 JSON 数据结构以及相反的操作来克服这个问题,而不是进行原始转换。这意味着从实际的角度来看,如果可能的话,jsonlite::fromJSON将产生data.frame而不是原始列表,这使得数据交换格式更加无缝。不幸的是,我们并不总能将列表转换为表格格式;在这种情况下,可以通过例如rlist包来加速列表转换。请参阅第十四章,分析 R 社区中关于列表操作的更多细节。

注意

可扩展标记语言 (XML)最初于 1996 年由万维网联盟开发,用于以人类可读和机器可读的格式存储文档。这种流行的语法被用于例如 Microsoft Office Open XML 和 Open/LibreOffice OpenDocument 文件格式、RSS 源和各种配置文件中。由于该格式也高度用于在互联网上交换数据,数据通常以 XML 作为唯一选项提供——特别是对于一些较旧的 API。

让我们看看我们如何处理除了 JSON 之外另一种流行的在线数据交换格式。XML API 可以以类似的方式使用,但我们必须在端点 URL 中定义所需的输出格式:data.consumerfinance.gov/api/views.xml,正如你可以在下面的屏幕截图中所看到的那样:

其他流行的在线数据格式

看起来 API 的 XML 输出与我们在 JSON 格式中看到的不同,它仅仅包括我们感兴趣的行。这样,我们就可以简单地解析 XML 文档,从响应中提取行,然后将它们转换为data.frame

> library(XML)
> doc <- xmlParse(paste0(u, '/25ei-6bcr/rows.xml?max_rows=5'))
> df  <- xmlToDataFrame(nodes = getNodeSet(doc,"//response/row/row"))
> str(df)
'data.frame':  5 obs. of  11 variables:
 $ complaint_id        : Factor w/ 5 levels "2083","2216",..: 1 2 ...
 $ product             : Factor w/ 1 level "Credit card": 1 1 ...
 $ submitted_via       : Factor w/ 2 levels "Referral","Web": 1 1 ...
 $ date_recieved       : Factor w/ 1 level "2011-12-01T00:00:00" ...
 $ zip_code            : Factor w/ 1 level "": 1 1 ...
 $ issue               : Factor w/ 5 levels  ...
 $ date_sent_to_company: Factor w/ 3 levels "2011-12-01T00:00:00" ...
 $ company             : Factor w/ 5 levels "Amex" ....
 $ company_response    : Factor w/ 1 level "Closed without relief"...
 $ timely_response     : Factor w/ 1 level "Yes": 1 1 ...
 $ consumer_disputed   : Factor w/ 2 levels "No","Yes": 1 1 ...

尽管我们可以在传递给xmlToDataFramecolClasses参数中手动设置变量的所需类别,就像在read.tables中一样,我们也可以通过一个快速的helper函数来修复这个问题:

> is.number <- function(x)
+     all(!is.na(suppressWarnings(as.numeric(as.character(x)))))
> for (n in names(df))
+     if (is.number(df[, n]))
+         df[, n] <- as.numeric(as.character(df[, n]))

因此,我们尝试猜测一列是否只包含数字,并在我们的辅助函数返回TRUE时将这些数字转换为numeric类型。请注意,我们在将factor转换为数字之前,首先将其转换为character类型,因为直接从factor转换为numeric会返回factor的顺序而不是实际值。有人也可能尝试使用type.convert函数解决这个问题,该函数是read.table默认使用的。

注意

要测试类似的 API 和 JSON 或 XML 资源,你可能对检查 Twitter、GitHub 或其他在线服务提供商的 API 感兴趣。另一方面,还有一个基于 R 的开源服务,可以从任何 R 代码返回 XML、JSON 或 CSV 文件。请参阅www.opencpu.org获取更多详细信息。

因此,现在我们可以处理来自各种可下载数据格式的结构化数据,但还有一些其他数据源选项需要掌握,我保证继续阅读是值得的。

从 HTML 表格中读取数据

根据万维网上的传统文档格式,大多数文本和数据都是以 HTML 页面的形式提供的。我们经常可以在 HTML 表格中找到有趣的信息,例如,很容易将数据复制粘贴到 Excel 电子表格中,将其保存到磁盘上,然后加载到 R 中。但这很耗时,很无聊,而且可以自动化。

这些 HTML 表格可以很容易地通过上述客户投诉数据库 API 生成。如果我们没有设置之前使用的 XML 或 JSON 所需的输出格式,那么浏览器将返回一个 HTML 表格,正如你可以在以下屏幕截图中所看到的那样:

从 HTML 表格中读取数据

嗯,在 R 控制台中,这要复杂一些,因为当使用 curl 时,浏览器会发送一些非默认的 HTTP 头信息,所以先前的 URL 会简单地返回一个 JSON 列表。要获取 HTML,让服务器知道我们期望 HTML 输出。为此,只需设置查询的适当 HTTP 头信息:

> doc <- getURL(paste0(u, '/25ei-6bcr/rows?max_rows=5'),
+   httpheader = c(Accept = "text/html"))

XML包提供了一个非常简单的方法,通过readHTMLTable函数从文档或特定节点中解析所有 HTML 表格,该函数默认返回data.frameslist

> res <- readHTMLTable(doc)

要获取页面上的第一个表格,我们可以在之后过滤res或者将which参数传递给readHTMLTable。以下两个 R 表达式具有完全相同的结果:

> df <- res[[1]]
> df <- readHTMLTable(doc, which = 1)

从静态 Web 页面中读取表格数据

好吧,到目前为止,我们已经看到了同一主题的许多变体,但如果我们没有在任何流行的数据格式中找到可下载的数据集怎么办?例如,有人可能对 CRAN 上托管的可用 R 包感兴趣,其列表可在cran.r-project.org/web/packages/available_packages_by_name.html找到。我们如何抓取这些数据?不需要调用RCurl或指定自定义头信息,更不用说先下载文件了;只需将 URL 传递给readHTMLTable即可:

> res <- readHTMLTable('http://cran.r-project.org/Web/packages/available_packages_by_name.html')

因此,readHTMLTable可以直接获取 HTML 页面,然后将其中的所有 HTML 表格提取到 R 的data.frame对象中,并返回这些表格的list。在先前的例子中,我们得到了一个只包含所有软件包名称和描述列的data.framelist

嗯,使用str函数,这么多的文本信息实际上并不具有很高的信息量。为了快速展示处理和可视化这类原始数据,并展示 CRAN 上可用的众多 R 包功能,现在我们可以使用wordcloudtm包的一些巧妙函数来创建包描述的词云:

> library(wordcloud)
Loading required package: Rcpp
Loading required package: RColorBrewer
> wordcloud(res[[1]][, 2])
Loading required package: tm

这个简短的命令产生了以下屏幕截图,显示了在 R 包描述中找到的最频繁的单词。单词的位置没有特殊意义,但字体越大,频率越高。请参阅屏幕截图后的技术描述:

从静态网页中读取表格数据

因此,我们简单地将第一个list元素的第二列中的所有字符串传递给wordcloud函数,该函数自动在文本上运行tm包的一些文本挖掘脚本。你可以在第七章非结构化数据中找到更多关于这个主题的详细信息。然后,它根据包描述中出现的次数以相对大小渲染单词。这似乎表明 R 包确实主要针对构建模型和在数据上应用多元测试。

从其他在线来源抓取数据

虽然readHTMLTable函数非常有用,但有时数据不是以表格形式结构化,而是仅作为 HTML 列表提供。让我们通过检查在相关 CRAN 任务视图cran.r-project.org/web/views/WebTechnologies.html中列出的所有 R 包来演示这种数据格式,就像你在下面的屏幕截图中所看到的那样:

从其他在线来源抓取数据

因此,我们看到一个包含包名称的 HTML 列表,以及指向 CRAN 或在某些情况下指向 GitHub 仓库的 URL。为了继续操作,我们首先需要熟悉一下 HTML 源代码,看看我们如何解析它们。你可以很容易地在 Chrome 或 Firefox 中做到这一点:只需在列表顶部的CRAN包标题上右键单击,然后选择检查元素,就像你在下面的屏幕截图中所看到的那样:

从其他在线来源抓取数据

因此,我们在包含“CRAN 包”字符串的h3(三级标题)标签之后,有一个ul(无序列表)HTML 标签中有了相关 R 包的列表。

简而言之:

  • 我们必须解析这个 HTML 文件

  • 寻找包含搜索词的第三级标题

  • 从随后的无序列表 HTML 中获取所有列表元素

这可以通过例如 XML 路径语言来完成,它具有特殊的语法,可以通过查询在 XML/HTML 文档中选择节点。

注意

更多细节和 R 驱动的示例,请参阅 Deborah Nolan 和 Duncan Temple Lang 所著的《使用 R 进行数据科学中的 XML 和 Web 技术》一书的第四章,XPath, XPointer, and XInclude,Springer 的 Use R!系列。请参阅书末的附录中的更多参考资料。

XPath 一开始看起来可能相当丑陋和复杂。例如,前面的列表可以用以下方式描述:

//h3[text()='CRAN packages:']/following-sibling::ul[1]/li

让我详细说明一下:

  1. 我们正在寻找一个文本为CRAN packagesh3标签,因此我们在整个文档中搜索具有这些属性的特定节点。

  2. 然后following-siblings表达式代表与所选h3标签处于同一层次级别的所有后续节点。

  3. 过滤以找到仅包含ul HTML 标签。

  4. 因为我们有多个这样的标签,所以我们只选择括号中索引为(1)的后续兄弟中的第一个。

  5. 然后我们简单地选择那个内部的全部li标签(列表元素)。

让我们在 R 中试试:

> page <- htmlParse(file = 
+   'http://cran.r-project.org/Web/views/WebTechnologies.html')
> res  <- unlist(xpathApply(doc = page, path =
+   "//h3[text()='CRAN packages:']/following-sibling::ul[1]/li",
+   fun  = xmlValue))

我们有相关 118 个 R 包的字符向量:

> str(res)
 chr [1:118] "acs" "alm" "anametrix" "AWS.tools" "bigml" ...

XPath 在选择和搜索 HTML 文档中的节点方面非常强大,xpathApply也是如此。后者是libxml中大多数 XPath 功能的 R 包装器,这使得整个过程相当快速和高效。有人可能会更愿意使用xpathSApply,它试图简化返回的元素列表,就像sapplylapply函数相比那样。因此,我们也可以更新之前的代码以保存unlist调用:

> res <- xpathSApply(page, path =
+ "//h3[text()='CRAN packages:']/following-sibling::ul[1]/li", 
+   fun  = xmlValue)

仔细的读者一定已经注意到,返回的列表是一个简单的字符向量,而原始 HTML 列表还包括上述包的 URL。这些 URL 在哪里,为什么消失了?

我们可以责怪xmlValue导致这个结果,我们调用它而不是默认的NULL作为在xpathSApply调用中从原始文档中提取节点时的评估函数。这个函数简单地提取每个叶节点的原始文本内容,没有任何子节点,这解释了这种行为。如果我们更感兴趣的是包的 URL 呢?

在没有指定 fun 的情况下调用xpathSapply会返回所有原始子节点,这对我们没有直接帮助,我们也不应该尝试在这些节点上应用正则表达式。xmlValue的帮助页面可以指引我们一些在类似任务中非常有用的类似函数。在这里,我们肯定想使用xmlAttrs

> xpathSApply(page,
+   "//h3[text()='CRAN packages:']/following-sibling::ul[1]/li/a",
+   xmlAttrs, 'href')

请注意,这里使用了更新的路径,现在我们选择了所有的a标签而不是之前的li父标签。而且,现在我们调用xmlAttrs而不是之前引入的xmlValue,并添加了'href'额外参数。这简单地提取了所有相关a节点的href参数。

使用这些原语,您将能够从在线来源获取任何公开可用的数据,尽管有时实现可能相当复杂。

注意

另一方面,请务必始终查阅所有潜在数据源的相关条款和条件以及其他法律文件,因为获取数据通常被版权所有者禁止。

除了法律问题之外,从服务提供商的技术角度考虑获取和爬取数据也是明智的。如果你在事先没有咨询管理员的情况下开始向服务器发送大量查询,这种行为可能会被视为网络攻击,或者可能导致服务器上出现不希望的压力。为了简化问题,始终在查询之间使用合理的延迟。这应该是例如,在查询之间至少有 2 秒的暂停,但最好检查网站robot.txt中设置的Crawl-delay指令,如果有的话,可以在根路径中找到。此文件还包含其他指令,如果允许或限制爬取。大多数数据提供者网站也提供有关数据爬取的技术文档;请务必搜索速率限制和节流。

有时候我们只是简单地幸运,因为其他人已经编写了棘手的 XPath 选择器或其他接口,因此我们可以借助本机 R 包从 Web 服务和主页加载数据。

R 包用于与数据源 API 交互

虽然我们可以读取 HTML 表格、CSV 文件和 JSON 以及 XML 数据,甚至解析原始 HTML 文档以将其中一些部分存储在数据集中,但在没有其他选择之前,花费太多时间开发定制工具是没有意义的。首先,始终从 Web 技术和服务 CRAN 任务视图快速浏览开始;在动手使用自定义 XPath 选择器和 JSON 列表魔法之前,也请在 R 博客、StackOverflow 和 GitHub 上搜索任何可能的解决方案。

Socrata 开放数据 API

让我们通过搜索消费者金融保护局的开放数据应用程序程序接口 Socrata 来为之前的示例做这件事。是的,为此有一个包:

> library(RSocrata)
Loading required package: httr
Loading required package: RJSONIO

Attaching package: 'RJSONIO'

The following objects are masked from 'package:rjson':

 fromJSON, toJSON

实际上,RSocrata包使用与我们之前相同的 JSON 源(或 CSV 文件)。请注意警告信息,它表示RSocrata依赖于另一个 JSON 解析 R 包,而不是我们使用的包,因此一些函数名称存在冲突。在自动加载RJSONIO包之前,可能明智的做法是detach('package:rjson')

使用RSocrata通过给定的 URL 加载客户投诉数据库相当简单:

> df <- read.socrata(paste0(u, '/25ei-6bcr'))
> str(df)
'data.frame':  18894 obs. of  11 variables:
 $ Complaint.ID        : int  2240 2243 2260 2254 2259 2261 ...
 $ Product             : chr  "Credit card" "Credit card" ...
 $ Submitted.via       : chr  "Web" "Referral" "Referral" ...
 $ Date.received       : chr  "12/01/2011" "12/01/2011" ...
 $ ZIP.code            : chr  ...
 $ Issue               : chr  ...
 $ Date.sent.to.company: POSIXlt, format: "2011-12-19" ...
 $ Company             : chr  "Citibank" "HSBC" ...
 $ Company.response    : chr  "Closed without relief" ...
 $ Timely.response.    : chr  "Yes" "Yes" "No" "Yes" ...
 $ Consumer.disputed.  : chr  "No" "No" "" "No" ...

我们得到了数字的numeric值,日期也被自动处理为POSIXlt

类似地,Web 技术和服务 CRAN 任务视图包含超过一百个 R 包,用于与自然科学(如生态学、遗传学、化学、天气、金融、经济学和营销)中的网络数据源进行交互,但我们还可以找到用于获取文本、参考文献资源、网络分析、新闻以及地图和社交媒体数据的 R 包,除了其他一些主题。由于页面限制,这里我们只关注最常用的包。

金融 API

Yahoo! 和 Google Finance 是所有在该行业工作的人的标准免费数据源。例如,使用 quantmod 包和上述服务提供商,获取股票、金属或外汇价格非常容易。例如,让我们看看 Agilent Technologies 的最新股票价格,其交易代码为 A

> library(quantmod)
Loading required package: Defaults
Loading required package: xts
Loading required package: zoo

Attaching package: 'zoo'

The following objects are masked from 'package:base':

 as.Date, as.Date.numeric

Loading required package: TTR
Version 0.4-0 included new data defaults. See ?getSymbols.
> tail(getSymbols('A', env = NULL))
 A.Open A.High A.Low A.Close A.Volume A.Adjusted
2014-05-09  55.26  55.63 54.81   55.39  1287900      55.39
2014-05-12  55.58  56.62 55.47   56.41  2042100      56.41
2014-05-13  56.63  56.98 56.40   56.83  1465500      56.83
2014-05-14  56.78  56.79 55.70   55.85  2590900      55.85
2014-05-15  54.60  56.15 53.75   54.49  5740200      54.49
2014-05-16  54.39  55.13 53.92   55.03  2405800      55.03

默认情况下,getSymbols 将获取的结果分配给 parent.frame(通常是全局)环境,并以符号名称命名,而将 NULL 作为所需环境将简单地返回前面看到的 xts 时间序列对象。

可以轻松获取外汇汇率:

> getFX("USD/EUR")
[1] "USDEUR"
> tail(USDEUR)
 USD.EUR
2014-05-13  0.7267
2014-05-14  0.7281
2014-05-15  0.7293
2014-05-16  0.7299
2014-05-17  0.7295
2014-05-18  0.7303

getSymbols 返回的字符串指的是数据保存在 .GlobalEnv 中的 R 变量。要查看所有可用的数据源,让我们查询相关的 S3 方法:

> methods(getSymbols)
 [1] getSymbols.csv    getSymbols.FRED   getSymbols.google
 [4] getSymbols.mysql  getSymbols.MySQL  getSymbols.oanda 
 [7] getSymbols.rda    getSymbols.RData  getSymbols.SQLite
[10] getSymbols.yahoo

因此,除了某些离线数据源外,我们还可以查询 Google、Yahoo! 和 OANDA 获取最新的金融信息。要查看可用符号的完整列表,已加载的 TTR 包可能会有所帮助:

> str(stockSymbols())
Fetching AMEX symbols...
Fetching NASDAQ symbols...
Fetching NYSE symbols...
'data.frame':  6557 obs. of  8 variables:
 $ Symbol   : chr  "AAMC" "AA-P" "AAU" "ACU" ...
 $ Name     : chr  "Altisource Asset Management Corp" ...
 $ LastSale : num  841 88.8 1.3 16.4 15.9 ...
 $ MarketCap: num  1.88e+09 0.00 8.39e+07 5.28e+07 2.45e+07 ...
 $ IPOyear  : int  NA NA NA 1988 NA NA NA NA NA NA ...
 $ Sector   : chr  "Finance" "Capital Goods" ...
 $ Industry : chr  "Real Estate" "Metal Fabrications" ...
 $ Exchange : chr  "AMEX" "AMEX" "AMEX" "AMEX" ...

注意

在 第十二章 分析时间序列 中找到更多关于如何处理和分析类似数据集的信息。

使用 Quandl 获取时间序列

Quandl 提供了通过自定义 API 从约 500 个数据源获取数百万类似时间序列数据的标准格式访问权限。在 R 中,Quandl 包提供了轻松访问全球各个行业中所有这些开放数据的途径。让我们以美国证券交易委员会发布的 Agilent Technologies 分红为例。要做到这一点,只需在 www.quandl.com 首页搜索“Agilent Technologies”,并将搜索结果中所需数据的代码提供给 Quandl 函数:

> library(Quandl)
> Quandl('SEC/DIV_A')
 Date Dividend
1 2013-12-27    0.132
2 2013-09-27    0.120
3 2013-06-28    0.120
4 2013-03-28    0.120
5 2012-12-27    0.100
6 2012-09-28    0.100
7 2012-06-29    0.100
8 2012-03-30    0.100
9 2006-11-01    2.057
Warning message:
In Quandl("SEC/DIV_A") :
 It would appear you aren't using an authentication token. Please visit http://www.quandl.com/help/r or your usage may be limited.

如您所见,没有有效的身份验证令牌,API 相对有限,该令牌可以在 Quandl 首页免费兑换。要设置您的令牌,只需将其传递给 Quandl.auth 函数。

此包还允许您:

  • 通过时间过滤数据

  • 在服务器端对数据进行一些转换——例如累积总和和一阶微分

  • 对数据进行排序

  • 定义返回对象的所需类别——例如 tszooxts

  • 下载有关数据源的一些元信息

后者被保存为返回 R 对象的 attributes。例如,要查看查询数据集的频率,请调用:

> attr(Quandl('SEC/DIV_A', meta = TRUE), 'meta')$frequency
[1] "quarterly"

Google 文档和分析

您可能更感兴趣的是从 Google Docs 加载您自己的或自定义数据,为此 RGoogleDocs 包非常有帮助,并且可以在 www.omegahat.org/ 网站主页上下载。它提供了对 Google 电子表格的认证访问,具有读写权限。

不幸的是,这个包相当过时,并使用了一些已弃用的 API 函数,因此您可能最好尝试一些较新的替代方案,例如最近发布的 googlesheets 包,它可以从 R 中管理 Google 电子表格(但不能管理其他文档)。

对于所有希望使用 R 分析页面访问或广告性能的人来说,也有类似的包可以与 Google Analytics 或 Google Adwords 交互。

在线搜索趋势

另一方面,我们通过交互 API 下载公共数据。Google 还在 www.google.com/publicdata/directory 提供了世界银行、国际货币基金组织、美国人口普查局等机构的公共数据访问权限,以及一些以搜索趋势形式存在的他们自己的内部数据 google.com/trends

使用 GTrendsR 包可以非常容易地查询后者,该包尚未在 CRAN 上提供,但我们可以至少练习如何从其他来源安装 R 包。GTrendR 代码仓库可以在 BitBucket 上找到,从那里使用 devtools 包安装它非常方便:

小贴士

为了确保您安装的 GTrensR 与以下使用的版本相同,您可以在 install_bitbucket(或 install_github)函数的 ref 参数中指定 branchcommit 或其他引用。请参阅本书末尾附录中的 参考文献 部分以获取提交哈希值。

> library(devtools)
> install_bitbucket('GTrendsR', 'persican', quiet = TRUE)
Installing bitbucket repo(s) GTrendsR/master from persican
Downloading master.zip from https://bitbucket.org/persican/gtrendsr/get/master.zip
arguments 'minimized' and 'invisible' are for Windows only 

从 BitBucket 或 GitHub 安装 R 包就像提供代码仓库的名称和作者的用户名,然后允许 devtools 做剩下的工作:下载源代码并编译它们。

Windows 用户在从源代码编译包之前应安装 Rtoolscran.r-project.org/bin/windows/Rtools/。我们还启用了静默模式,以抑制编译日志和无聊的细节。

在包安装完成后,我们可以以传统方式加载它:

> library(GTrendsR)

首先,我们必须使用有效的 Google 用户名和密码进行身份验证,然后才能查询 Google Trends 数据库。我们的搜索词将是 "如何安装 R":

小贴士

请确保您提供有效的用户名和密码;否则,以下查询将失败。

> conn <- gconnect('some Google username', 'some Google password')
> df   <- gtrends(conn, query = 'how to install R')
> tail(df$trend)
 start        end how.to.install.r
601 2015-07-05 2015-07-11               86
602 2015-07-12 2015-07-18               70
603 2015-07-19 2015-07-25              100
604 2015-07-26 2015-08-01               75
605 2015-08-02 2015-08-08               73
606 2015-08-09 2015-08-15               94 

返回的数据集包括关于 R 安装相对搜索查询量的每周指标。数据显示,最高活动记录在七月中旬,而只有大约 75%的这些搜索查询是在下个月初触发的。所以 Google 不发布原始搜索查询统计数据,而是可以通过不同的搜索词和时间周期进行对比研究。

历史天气数据

同样,也有各种包为地球科学中的所有 R 用户提供访问数据源的方式。例如,RNCEP包可以从国家环境预测中心下载超过一百年的历史天气数据,分辨率为每六小时一次。weatherData包提供直接访问wunderground.com。作为一个快速示例,让我们下载伦敦过去七天的每日平均温度:

> library(weatherData)
> getWeatherForDate('London', start_date = Sys.Date()-7, end_date = Sys.Date())
Retrieving from: http://www.wunderground.com/history/airport/London/2014/5/12/CustomHistory.html?dayend=19&monthend=5&yearend=2014&req_city=NA&req_state=NA&req_statename=NA&format=1 
Checking Summarized Data Availability For London
Found 8 records for 2014-05-12 to 2014-05-19
Data is Available for the interval.
Will be fetching these Columns:
[1] "Date"              "Max_TemperatureC"  "Mean_TemperatureC"
[4] "Min_TemperatureC" 
 Date Max_TemperatureC Mean_TemperatureC Min_TemperatureC
1 2014-05-12               18                13                9
2 2014-05-13               16                12                8
3 2014-05-14               19                13                6
4 2014-05-15               21                14                8
5 2014-05-16               23                16                9
6 2014-05-17               23                17               11
7 2014-05-18               23                18               12
8 2014-05-19               24                19               13

请注意,前面输出中的不重要部分已被抑制,但这里发生的事情相当简单:包抓取了指定的 URL,顺便提一下,这是一个 CSV 文件,然后使用一些附加信息进行解析。将opt_detailed设置为TRUE也会返回日内数据,分辨率为 30 分钟。

其他在线数据源

当然,这个简短的章节不能提供查询所有可用在线数据源和 R 实现的概述,但请在创建自己的 R 爬虫脚本之前,咨询 Web 技术和服务 CRAN 任务视图、R 博客、StackOverflow 以及本书末尾的参考文献章节中的资源,以寻找任何现有的 R 包或辅助函数。

摘要

本章重点介绍了如何直接从网络中获取和处理数据,包括下载文件、处理 XML 和 JSON 格式、解析 HTML 表格、应用 XPath 选择器从 HTML 页面中提取数据以及与 RESTful API 交互的问题。

虽然本章中的一些示例可能看起来是对 Socrata API 的无效斗争,但结果证明RSocrata包提供了对所有这些数据的生产级访问。然而,请记住,你将面临一些没有现成 R 包的情况;因此,作为一名数据黑客,你将不得不亲手处理所有的 JSON、HTML 和 XML 源。

在下一章中,我们将发现如何使用重塑和重构数据的顶级、最常用方法来过滤和聚合已经获取和加载的数据。

第三章. 过滤和汇总数据

在从平面文件或数据库(如我们在第一章中看到的)或通过某些 API 直接从网络(如第二章中所述)加载数据后,我们通常必须在实际数据分析之前对原始数据集进行聚合、转换或过滤。

在本章中,我们将重点关注如何:

  • 在数据框中过滤行和列

  • 汇总并聚合数据

  • 除了基础 R 方法外,使用dplyrdata.table包提高此类任务的性能

删除不需要的数据

虽然不加载不需要的数据是最佳解决方案(见第一章中的加载文本文件的子集从数据库加载数据部分),但我们通常必须在 R 中过滤原始数据集。这可以通过使用基础 R 的传统工具和函数,如subset,通过使用which[[[运算符(见以下代码)来完成,或者例如使用sqldf包的类似 SQL 的方法:

> library(sqldf)
> sqldf("SELECT * FROM mtcars WHERE am=1 AND vs=1")
 mpg cyl  disp  hp drat    wt  qsec vs am gear carb
1 22.8   4 108.0  93 3.85 2.320 18.61  1  1    4    1
2 32.4   4  78.7  66 4.08 2.200 19.47  1  1    4    1
3 30.4   4  75.7  52 4.93 1.615 18.52  1  1    4    2
4 33.9   4  71.1  65 4.22 1.835 19.90  1  1    4    1
5 27.3   4  79.0  66 4.08 1.935 18.90  1  1    4    1
6 30.4   4  95.1 113 3.77 1.513 16.90  1  1    5    2
7 21.4   4 121.0 109 4.11 2.780 18.60  1  1    4    2

我相信所有有良好 SQL 背景并且刚刚接触 R 的读者都会欣赏这种过滤数据的不同方法,但我个人更喜欢以下类似、原生且更简洁的 R 版本:

> subset(mtcars, am == 1 & vs == 1)
 mpg cyl  disp  hp drat    wt  qsec vs am gear carb
Datsun 710     22.8   4 108.0  93 3.85 2.320 18.61  1  1    4    1
Fiat 128       32.4   4  78.7  66 4.08 2.200 19.47  1  1    4    1
Honda Civic    30.4   4  75.7  52 4.93 1.615 18.52  1  1    4    2
Toyota Corolla 33.9   4  71.1  65 4.22 1.835 19.90  1  1    4    1
Fiat X1-9      27.3   4  79.0  66 4.08 1.935 18.90  1  1    4    1
Lotus Europa   30.4   4  95.1 113 3.77 1.513 16.90  1  1    5    2
Volvo 142E     21.4   4 121.0 109 4.11 2.780 18.60  1  1    4    2

请注意结果之间的细微差异。这归因于sqldfrow.names参数默认为FALSE,当然,可以通过覆盖它来获得完全相同的结果:

> identical(
+     sqldf("SELECT * FROM mtcars WHERE am=1 AND vs=1",
+       row.names = TRUE),
+     subset(mtcars, am == 1 & vs == 1)
+     )
[1] TRUE

这些例子侧重于如何从data.frame中删除行,但如果我们还想删除一些列怎么办?

SQL 方法非常直接;只需在SELECT语句中指定所需的列,而不是使用*。另一方面,subset通过select参数也支持这种方法,它可以接受向量或 R 表达式,例如,描述一列范围的列:

> subset(mtcars, am == 1 & vs == 1, select = hp:wt)
 hp drat    wt
Datsun 710      93 3.85 2.320
Fiat 128        66 4.08 2.200
Honda Civic     52 4.93 1.615
Toyota Corolla  65 4.22 1.835
Fiat X1-9       66 4.08 1.935
Lotus Europa   113 3.77 1.513
Volvo 142E     109 4.11 2.780

小贴士

通过c函数将未引用的列名作为向量传递,以按给定顺序选择任意列列表,或者使用-运算符排除指定的列,例如,subset(mtcars, select = -c(hp, wt))

让我们更进一步,看看我们如何可以在一些较大的数据集上应用前面提到的过滤器,当我们面对base函数的性能问题时。

高效地删除不需要的数据

R 最适合可以实际物理内存中容纳的数据集,并且一些 R 包提供了对这么多数据的极快访问。

注意

一些基准测试(见本书末尾的参考文献部分)提供了比当前开源(例如 MySQL、PostgreSQL 和 Impala)和商业数据库(如 HP Vertica)提供的更有效的汇总 R 函数的实际例子。

一些相关的包已经在第一章,你好,数据!中提到,我们在那里对从hflights包中读取相对大量数据到 R 进行了基准测试。

让我们看看先前的示例在这个有 25 万行数据的集合上的表现:

> library(hflights)
> system.time(sqldf("SELECT * FROM hflights WHERE Dest == 'BNA'", 
+   row.names = TRUE))
 user  system elapsed 
 1.487   0.000   1.493 
> system.time(subset(hflights, Dest == 'BNA'))
 user  system elapsed 
 0.132   0.000   0.131

base::subset函数似乎表现得很不错,但我们能否让它更快?嗯,plyr包的第二代,称为dplyr(相关细节在本章的高性能辅助函数部分和第四章,重构数据)以相当直观的方式提供了最常见数据库操作方法的极快 C++实现:

> library(dplyr)
> system.time(filter(hflights, Dest == 'BNA'))
 user  system elapsed 
 0.021   0.000   0.022

此外,我们可以通过删除数据集的一些列来扩展这个解决方案,就像我们之前使用subset做的那样,尽管现在我们调用的是select函数而不是传递同名参数:

> str(select(filter(hflights, Dest == 'BNA'), DepTime:ArrTime))
'data.frame':  3481 obs. of  2 variables:
 $ DepTime: int  1419 1232 1813 900 716 1357 2000 1142 811 1341 ...
 $ ArrTime: int  1553 1402 1948 1032 845 1529 2132 1317 945 1519 ...

因此,这就像调用filter函数而不是subset,我们得到的速度比眨眼还快!dplyr包可以与传统的data.framedata.table对象一起工作,或者可以直接与最广泛使用的数据库引擎进行交互。请注意,dplyr中不保留行名,所以如果您需要它们,在将它们传递给dplyr或直接传递给data.table之前,值得将名称复制到显式变量中,如下所示:

> mtcars$rownames <- rownames(mtcars)
> select(filter(mtcars, hp > 300), c(rownames, hp))
 rownames  hp
1 Maserati Bora 335

以另一种高效的方式删除不必要的数据

让我们看看一个单独的data.table解决方案的快速示例,不使用dplyr

注意

data.table包提供了一种极高效的方式来处理基于列、自动索引的内存数据结构中的大数据集,同时向后兼容传统的data.frame方法。

在加载包之后,我们必须将hflights传统的data.frame转换为data.table。然后,我们创建一个新列,称为rownames,我们将原始数据集的rownames分配给这个新列,这得益于data.table特有的:=赋值运算符:

> library(data.table)
> hflights_dt <- data.table(hflights)
> hflights_dt[, rownames := rownames(hflights)]
> system.time(hflights_dt[Dest == 'BNA'])
 user  system elapsed 
 0.021   0.000   0.020

嗯,适应定制的data.table语法需要一些时间,对于传统的 R 用户来说,一开始可能看起来有点奇怪,但从长远来看,这绝对值得掌握。您将获得出色的性能,并且经过前几个示例相对陡峭的学习曲线后,语法最终会变得自然和灵活。

实际上,data.table语法与 SQL 非常相似:

DT[i, j, ... , drop = TRUE]

这可以用以下 SQL 命令来描述:

DT[where, select | update, group by][having][order by][ ]...[ ]

因此,.data.table(表示将[运算符应用于data.table对象)与传统的[.data.frame语法相比有一些不同的参数,正如您在先前的示例中已经看到的。

注意

现在,我们不会详细处理赋值操作符,因为这个例子可能对于本书的这一介绍性部分来说过于复杂,我们可能已经超出了我们的舒适区。因此,请参阅第四章([Chapter 4),重构数据,或前往 ?data.table 以获取一个相当技术性的概述。

[.data.table 操作符的第一个参数(i)似乎代表过滤,或者说在 SQL 术语中,代表 WHERE 语句,而 [.data.frame 则期望指定从原始数据集中保留哪些行的索引。这两个参数之间的真正区别在于,前者可以接受任何 R 表达式,而后一种传统方法主要期望整数或逻辑值。

无论如何,过滤操作就像是将 R 表达式传递给 data.table 特有的 [ 操作符的 i 参数一样简单。进一步来说,让我们看看如何在 data.table 语法中选取列,这应该在基于上述通用 data.table 语法的调用中的第二个参数(j)中完成:

> str(hflights_dt[Dest == 'BNA', list(DepTime, ArrTime)]) 
Classes 'data.table' and 'data.frame':     3481 obs. of 2 variables:
 $ DepTime: int  1419 1232 1813 900 716 1357 2000 1142 811 1341 ...
 $ ArrTime: int  1553 1402 1948 1032 845 1529 2132 1317 945 1519 ...
 - attr(*, ".internal.selfref")=<externalptr>

好的,现在我们有了两个预期的列,包含 3,481 个观测值。请注意,这里使用了 list 来定义需要保留的列,尽管使用 c(来自基础 R 的一个用于连接向量元素的函数)在 [.data.frame 中更为传统。后者在 [.data.table 中也是可能的,但那时,你必须传递变量名作为一个字符向量,并将 with 设置为 FALSE

> hflights_dt[Dest == 'BNA', c('DepTime', 'ArrTime'), with = FALSE] 

注意

除了 list 之外,你还可以使用点作为函数名,模仿 plyr 包的风格;例如:hflights_dt[, .(DepTime, ArrTime)]

现在我们对在实时 R 会话中过滤数据的选项有了一定的了解,并且我们知道 dplyrdata.table 包的总体语法,让我们看看这些如何在实际中用于聚合和总结数据。

聚合

总结数据最直接的方法是调用 stats 包中的 aggregate 函数,它正好符合我们的需求:通过分组变量将数据分割成子集,然后分别计算它们的摘要统计量。调用 aggregate 函数的最基本方法是传递要聚合的数值向量,以及一个因子变量来定义 FUN 参数中传递给函数的分割。现在,让我们看看每个工作日的被改道航班平均比率:

> aggregate(hflights$Diverted, by = list(hflights$DayOfWeek),
+   FUN = mean)
 Group.1           x
1       1 0.002997672
2       2 0.002559323
3       3 0.003226211
4       4 0.003065727
5       5 0.002687865
6       6 0.002823121
7       7 0.002589057

嗯,运行前面的脚本花了一些时间,但请记住,我们只是对大约二十五万行数据进行了聚合,以查看 2011 年从休斯顿机场出发的每日被改道的航班数量的平均值。

换句话说,这也适用于所有不热衷于统计学的人,即每周工作日中转航班的百分比。结果相当有趣,因为似乎航班在中周(大约 0.3%)比周末(大约少 0.05%)更常被转飞,至少从休斯顿来看。

调用上述函数的另一种方法是向with函数中提供参数,这最终似乎是一种更符合人类表达习惯的方式,因为它避免了反复提及hflights数据库:

> with(hflights, aggregate(Diverted, by = list(DayOfWeek),
+   FUN = mean))

这里没有显示结果,因为它们与之前显示的完全相同。aggregate函数的手册(见?aggregate)指出,它以方便的形式返回结果。然而,检查上述返回数据的列名似乎并不方便,对吧?我们可以通过使用公式符号而不是单独定义数值和因子变量来克服这个问题:

> aggregate(Diverted ~ DayOfWeek, data = hflights, FUN = mean)
 DayOfWeek    Diverted
1         1 0.002997672
2         2 0.002559323
3         3 0.003226211
4         4 0.003065727
5         5 0.002687865
6         6 0.002823121
7         7 0.002589057

使用公式符号的好处至少有两倍:

  • 需要输入的字符相对较少

  • 结果中的标题和行名是正确的

  • 这个版本也比之前的aggregate调用运行得快一些;请参阅本节末尾的全面基准测试。

使用公式符号的唯一缺点是你必须学习它,一开始可能会觉得有点尴尬,但鉴于公式在许多 R 函数和包中高度使用,尤其是在定义模型时,从长远来看,学习如何使用它们绝对是值得的。

注意

公式符号是从 S 语言继承而来的,其一般语法如下:response_variable ~ predictor_variable_1 + … + predictor_variable_n。该符号还包括一些其他符号,例如-用于排除变量,以及:*用于包括变量之间的交互,无论是否包括自身。有关更多详细信息,请参阅第五章"), 构建模型(由 Renata Nemeth 和 Gergely Toth 编写),以及 R 控制台中的?formula

使用基础 R 命令进行更快的聚合

聚合数据的另一种解决方案可能是调用tapplyby函数,这些函数可以将 R 函数应用于一个错落有致的数组。这意味着我们可以提供一个或多个INDEX变量,这些变量将被强制转换为因子,然后,在每个子集中的所有单元格上分别运行提供的 R 函数。以下是一个快速示例:

> tapply(hflights$Diverted, hflights$DayOfWeek, mean)
 1        2        3        4        5        6        7 
0.002998 0.002559 0.003226 0.003066 0.002688 0.002823 0.002589 

请注意,tapply返回一个array对象而不是方便的数据框;另一方面,它比上述聚合调用运行得快得多。因此,对于计算,使用tapply然后,将结果转换为具有适当列名的data.frame可能是合理的。

方便的辅助函数

这样的转换可以通过例如使用 plyr 包,这是 dplyr 包的通用版本,代表 plyr 专门用于数据框 来轻松且用户友好地完成。

plyr 包提供了一系列函数,用于从 data.framelistarray 对象中应用数据,并且可以以任何提到的格式返回结果。这些函数的命名方案易于记忆:函数名的第一个字符代表输入数据的类别,第二个字符代表输出格式,所有这些都在 ply 后面。除了上述三个 R 类别之外,还有一些由字符编码的特殊选项:

  • d 代表 data.frame

  • s 代表 array

  • l 代表 list

  • m 是一个特殊的输入类型,这意味着我们以表格格式为函数提供多个参数

  • r 输入类型期望一个整数,该整数指定函数将被复制的次数

  • _ 是一个特殊的输出类型,该函数不返回任何内容

因此,以下最常用的组合是可用的:

  • ddplydata.frame 作为输入并返回 data.frame

  • ldplylist 作为输入但返回 data.frame

  • l_ply 不返回任何内容,但它非常有用,例如,用于遍历多个元素而不是 for 循环;与 .progress 参数集一起,该函数可以显示迭代的当前状态和剩余时间

请在第四章中查找更多关于 plyr 的详细信息、示例和使用案例,数据重构。在这里,我们将只关注如何总结数据。为此,我们将使用 ddply(不要与 dplyr 包混淆)在所有后续示例中:以 data.frame 作为输入参数,并返回具有相同类别的数据。

那么,让我们加载这个包,并通过对每个 DayOfWeek 子集的 Diverted 列应用 mean 函数来使用 ddply

> library(plyr)
> ddply(hflights, .(DayOfWeek), function(x) mean(x$Diverted))
 DayOfWeek          V1
1         1 0.002997672
2         2 0.002559323
3         3 0.003226211
4         4 0.003065727
5         5 0.002687865
6         6 0.002823121
7         7 0.002589057

注意

plyr 包中的 . 函数为我们提供了一种方便的方式来直接引用变量(名称);否则,DayOfWeek 列的内容将被 ddply 解释,从而导致错误。

这里需要注意的一个重要事项是,ddply 比我们第一次使用 aggregate 函数时的尝试要快得多。另一方面,我对结果还不满意,V1 和其他如此有创意的列名总是让我感到不安。与其在后处理中更新 data.frame 的名称,不如调用 summarise 辅助函数而不是之前应用的匿名函数;在这里,我们也可以为我们的新计算列提供所需的名称:

> ddply(hflights, .(DayOfWeek), summarise, Diverted = mean(Diverted))
 DayOfWeek    Diverted
1         1 0.002997672
2         2 0.002559323
3         3 0.003226211
4         4 0.003065727
5         5 0.002687865
6         6 0.002823121
7         7 0.002589057

好多了。但是,我们能否做得更好?

高性能辅助函数

ggplotreshape和几个其他 R 包的作者 Hadley Wickham,从 2008 年开始着手开发plyr的第二代,或者说是一个专门的版本。基本概念是plyr最常用于将一个data.frame转换成另一个data.frame;因此,它的操作需要额外的注意。dplyr包,专门针对数据框的plyr,提供了一个更快的plyr函数实现,使用原始 C++编写,并且dplyr还可以处理远程数据库。

然而,性能提升也伴随着一些其他的变化;例如,与plyr相比,dplyr的语法发生了很大变化。尽管之前提到的summarise函数在dplyr中仍然存在,但我们不再有ddplyr函数,因为该包中的所有函数都致力于作为plyr::ddplyr的一些组件。

无论如何,为了使理论背景简短,如果我们想总结数据集的子组,我们必须在聚合之前定义这些组:

> hflights_DayOfWeek <- group_by(hflights, DayOfWeek)

结果对象与我们之前拥有的data.frame完全相同,只有一个例外:通过属性的方式将一些元数据合并到了对象中。为了使接下来的输出更简洁,我们没有列出对象的整个结构(str),只显示了属性:

> str(attributes(hflights_DayOfWeek))
List of 9
 $ names             : chr [1:21] "Year" "Month" "DayofMonth" ...
 $ class             : chr [1:4] "grouped_df" "tbl_df" "tbl" ...
 $ row.names         : int [1:227496] 5424 5425 5426 5427 5428 ...
 $ vars              :List of 1
 ..$ : symbol DayOfWeek
 $ drop              : logi TRUE
 $ indices           :List of 7
 ..$ : int [1:34360] 2 9 16 23 30 33 40 47 54 61 ...
 ..$ : int [1:31649] 3 10 17 24 34 41 48 55 64 70 ...
 ..$ : int [1:31926] 4 11 18 25 35 42 49 56 65 71 ...
 ..$ : int [1:34902] 5 12 19 26 36 43 50 57 66 72 ...
 ..$ : int [1:34972] 6 13 20 27 37 44 51 58 67 73 ...
 ..$ : int [1:27629] 0 7 14 21 28 31 38 45 52 59 ...
 ..$ : int [1:32058] 1 8 15 22 29 32 39 46 53 60 ...
 $ group_sizes       : int [1:7] 34360 31649 31926 34902 34972 ...
 $ biggest_group_size: int 34972
 $ labels            :'data.frame':  7 obs. of  1 variable:
 ..$ DayOfWeek: int [1:7] 1 2 3 4 5 6 7
 ..- attr(*, "vars")=List of 1
 .. ..$ : symbol DayOfWeek

从这些元数据中,indices属性很重要。它简单地列出了一个工作日中每行的 ID,因此后续操作可以轻松地从整个数据集中选择子组。那么,让我们看看使用dplyrsummarise而不是plyr带来的性能提升,看看被转移的航班比例是什么样的:

> dplyr::summarise(hflights_DayOfWeek, mean(Diverted))
Source: local data frame [7 x 2]

 DayOfWeek mean(Diverted)
1         1    0.002997672
2         2    0.002559323
3         3    0.003226211
4         4    0.003065727
5         5    0.002687865
6         6    0.002823121
7         7    0.002589057 

结果相当熟悉,这很好。然而,在运行这个示例时,你是否测量了执行时间?这几乎是瞬间的,这使得dplyr更加出色。

使用data.table进行聚合

你还记得[.data.table的第二个参数吗?它被称为j,代表一个SELECTUPDATE SQL 语句,最重要的特性是它可以是一个任意的 R 表达式。因此,我们只需在那里传递一个函数,并通过by参数设置组:

> hflights_dt[, mean(Diverted), by = DayOfWeek]
 DayOfWeek          V1
1:         6 0.002823121
2:         7 0.002589057
3:         1 0.002997672
4:         2 0.002559323
5:         3 0.003226211
6:         4 0.003065727
7:         5 0.002687865

我很确定你对data.table返回结果的速度不会感到丝毫惊讶,因为人们可以非常快地习惯于优秀的工具。此外,与之前的两行dplyr调用相比,它非常简洁,对吧?这个解决方案的唯一缺点是,工作日是按照一些几乎无法理解的排名顺序排列的。请参阅第四章,重构数据,了解更多关于此问题的细节;现在,让我们通过设置一个键来快速解决这个问题,这意味着我们首先按DayOfWeekdata.table进行排序:

> setkey(hflights_dt, 'DayOfWeek')
> hflights_dt[, mean(Diverted), by = DayOfWeek]
 DayOfWeek          V1
1:         1 0.002997672
2:         2 0.002559323
3:         3 0.003226211
4:         4 0.003065727
5:         5 0.002687865
6:         6 0.002823121
7:         7 0.002589057

注意

要为结果表格的第二列指定一个名称而不是V1,你可以将summary对象指定为一个命名列表,例如,hflights_dt[, list('mean(Diverted)' = mean(Diverted)), by = DayOfWeek],其中你可以使用.(点)而不是list,就像在ply r中一样。

除了以预期的顺序获得结果外,通过已存在的键对数据进行汇总也相对较快。让我们用一些在你的机器上的经验证据来验证这一点!

运行基准测试

如前几章所述,借助microbenchmark包,我们可以在同一台机器上对指定次数的不同函数进行多次运行,以获得一些可重复的性能结果。

为了达到这个目的,我们首先需要定义我们想要基准测试的函数。这些是从前面的例子中编译出来的:

> AGGR1     <- function() aggregate(hflights$Diverted,
+   by = list(hflights$DayOfWeek), FUN = mean)
> AGGR2     <- function() with(hflights, aggregate(Diverted,
+   by = list(DayOfWeek), FUN = mean))
> AGGR3     <- function() aggregate(Diverted ~ DayOfWeek,
+   data = hflights, FUN = mean)
> TAPPLY    <- function() tapply(X = hflights$Diverted, 
+   INDEX = hflights$DayOfWeek, FUN = mean)
> PLYR1     <- function() ddply(hflights, .(DayOfWeek),
+   function(x) mean(x$Diverted))
> PLYR2     <- function() ddply(hflights, .(DayOfWeek), summarise,
+   Diverted = mean(Diverted))
> DPLYR     <- function() dplyr::summarise(hflights_DayOfWeek,
+   mean(Diverted))

然而,如前所述,dplyr中的summarise函数需要一些先前的数据重构,这也需要时间。为此,让我们定义另一个函数,它包括创建新的数据结构以及实际的聚合:

> DPLYR_ALL <- function() {
+     hflights_DayOfWeek <- group_by(hflights, DayOfWeek)
+     dplyr::summarise(hflights_DayOfWeek, mean(Diverted))
+ }

同样,对data.table进行基准测试也需要测试环境中的一些额外变量;由于hlfights_dt已经按DayOfWeek排序,让我们创建一个新的data.table对象进行基准测试:

> hflights_dt_nokey <- data.table(hflights)

此外,验证它没有键可能是有意义的:

> key(hflights_dt_nokey)
NULL

好的,现在,我们可以定义data.table测试用例,以及一个包括转换到data.table的函数,并且为了公平起见,也为dplyr添加索引:

> DT     <- function() hflights_dt_nokey[, mean(FlightNum),
+   by = DayOfWeek]
> DT_KEY <- function() hflights_dt[, mean(FlightNum),
+   by = DayOfWeek]
> DT_ALL <- function() {
+     setkey(hflights_dt_nokey, 'DayOfWeek')
+     hflights_dt[, mean(FlightNum), by = DayOfWeek]
+     setkey(hflights_dt_nokey, NULL)
+ }

现在我们已经准备好所有描述的实现以供测试,让我们加载microbenchmark包来完成其工作:

> library(microbenchmark)
> res <- microbenchmark(AGGR1(), AGGR2(), AGGR3(), TAPPLY(), PLYR1(),
+          PLYR2(), DPLYR(), DPLYR_ALL(), DT(), DT_KEY(), DT_ALL())
> print(res, digits = 3)
Unit: milliseconds
 expr     min      lq  median      uq     max neval
 AGGR1() 2279.82 2348.14 2462.02 2597.70 2719.88    10
 AGGR2() 2278.15 2465.09 2528.55 2796.35 2996.98    10
 AGGR3() 2358.71 2528.23 2726.66 2879.89 3177.63    10
 TAPPLY()   19.90   21.05   23.56   29.65   33.88    10
 PLYR1()   56.93   59.16   70.73   82.41  155.88    10
 PLYR2()   58.31   65.71   76.51   98.92  103.48    10
 DPLYR()    1.18    1.21    1.30    1.74    1.84    10
 DPLYR_ALL()    7.40    7.65    7.93    8.25   14.51    10
 DT()    5.45    5.73    5.99    7.75    9.00    10
 DT_KEY()    5.22    5.45    5.63    6.26   13.64    10
 DT_ALL()   31.31   33.26   35.19   38.34   42.83    10

结果相当引人注目:从超过 2,000 毫秒,我们能够将我们的工具改进到只需略多于 1 毫秒就能提供完全相同的结果。这种差异可以通过对数刻度的小提琴图轻松展示:

> autoplot(res)

运行基准测试

因此,dplyr似乎是最有效的解决方案,尽管如果我们还考虑额外的步骤(将data.frame分组),那么它原本的明显优势就变得不那么有说服力了。事实上,如果我们已经有一个data.table对象,并且我们可以将传统的data.frame对象转换为data.table,那么data.table的性能将优于dplyr。然而,我相当确信你不会真正注意到这两种高性能解决方案之间的时间差异;这两个都能很好地处理更大的数据集。

值得注意的是,dplyr也可以与data.table对象一起工作;因此,为了确保你不会局限于任何一个包,如果需要的话,使用两个包绝对是有价值的。以下是一个 POC 示例:

> dplyr::summarise(group_by(hflights_dt, DayOfWeek), mean(Diverted))
Source: local data table [7 x 2]

 DayOfWeek mean(Diverted)
1         1    0.002997672
2         2    0.002559323
3         3    0.003226211
4         4    0.003065727
5         5    0.002687865
6         6    0.002823121
7         7    0.002589057 

好的,所以现在我们相当确信在将来计算分组平均值时将使用data.tabledplyr。然而,对于更复杂的操作呢?

汇总函数

如我们之前所讨论的,所有聚合函数都可以接受任何有效的 R 函数来应用于数据的子集。一些 R 包使得用户使用起来极其简单,而一些函数则要求你完全理解包的概念、自定义语法和选项,以便充分利用高性能的机会。

对于这些更高级的主题,请参阅第四章重构数据,重构数据,以及书中末尾参考文献部分列出的进一步阅读材料。

现在,我们将集中讨论一个非常简单的summary函数,这在任何一般数据分析项目中都非常常见:按组计数案例数量。这个快速示例也将突出本章中提到的参考替代方案之间的某些差异。

在子组中累加案例数量

现在,让我们关注plyrdplyrdata.table,因为我非常确信你可以构建aggregatetapply版本而不会遇到任何严重问题。基于之前的示例,当前任务似乎相当简单:我们可以简单地调用length函数来返回Diverted列中的元素数量,而不是使用mean函数:

> ddply(hflights, .(DayOfWeek), summarise, n = length(Diverted))
 DayOfWeek     n
1         1 34360
2         2 31649
3         3 31926
4         4 34902
5         5 34972
6         6 27629
7         7 32058

现在,我们也知道周六从休斯顿起飞的航班相对较少。然而,我们真的需要输入这么多来回答这样一个简单的问题吗?进一步说,我们真的需要命名一个变量来计算案例数量吗?你已经知道答案:

> ddply(hflights, .(DayOfWeek), nrow)
 DayOfWeek    V1
1         1 34360
2         2 31649
3         3 31926
4         4 34902
5         5 34972
6         6 27629
7         7 32058

简而言之,没有必要从data.frame中选择一个变量来确定其长度,因为简单地检查(子)数据集中的行数要容易得多(并且更快)。

然而,我们也可以以更简单、更快捷的方式得到相同的结果。可能你已经想到了使用古老的table函数来完成这样的直接任务:

> table(hflights$DayOfWeek)

 1     2     3     4     5     6     7 
34360 31649 31926 34902 34972 27629 32058

这个结果对象唯一的问题是,我们还需要进一步转换它,例如,在大多数情况下转换为data.frame。嗯,plyr已经有一个辅助函数可以在一步中完成这个操作,名字非常直观:

> count(hflights, 'DayOfWeek')
 DayOfWeek  freq
1         1 34360
2         2 31649
3         3 31926
4         4 34902
5         5 34972
6         6 27629
7         7 32058

因此,我们得到了一些相当简单的计数数据示例,但让我们也看看如何使用dplyr实现汇总表。如果你简单地尝试修改我们之前的dplyr命令,你很快就会意识到,像在plyr中那样传递lengthnrow函数,根本不起作用。然而,阅读手册或 StackOverflow 上的一些相关问题很快就会将我们的注意力引到一个方便的辅助函数n

> dplyr::summarise(hflights_DayOfWeek, n())
Source: local data frame [7 x 2]

 DayOfWeek   n()
1         1 34360
2         2 31649
3         3 31926
4         4 34902
5         5 34972
6         6 27629
7         7 32058

然而,坦白说,我们真的需要这种相对复杂的方法吗?如果你记得hflights_DayOfWeek的结构,你很快就会意识到,有一种更容易、更快的方法可以找出每个工作日的总航班数:

> attr(hflights_DayOfWeek, 'group_sizes')
[1] 34360 31649 31926 34902 34972 27629 32058

此外,为了确保我们不会忘记data.table的自定义(但相当漂亮)语法,让我们使用另一个辅助函数来计算结果:

> hflights_dt[, .N, by = list(DayOfWeek)]
 DayOfWeek     N
1:         1 34360
2:         2 31649
3:         3 31926
4:         4 34902
5:         5 34972
6:         6 27629
7:         7 32058

摘要

在本章中,我们介绍了一些有效且便捷的数据过滤和汇总方法。我们讨论了一些关于过滤数据集行和列的用例。我们还学习了如何汇总数据以进行进一步分析。在熟悉了此类任务最流行的实现方式后,我们通过可重复的示例和基准测试包对它们进行了比较。

在下一章中,我们将继续这段重构数据集和创建新变量的旅程。

第四章。重新结构化数据

我们已经在第三章中介绍了重新结构化数据的最基本方法,过滤和汇总数据,但当然,还有几个其他更复杂的任务,我们将在接下来的几页中掌握。

只为了快速举例说明,为了得到可以用于实际数据分析的数据形式,需要多样化的工具:Hadley Wickham,最知名的 R 开发者和用户之一,将他的博士论文三分之一的时间花在重塑数据上。正如他所说,“在进行任何探索性数据分析或可视化之前是不可避免的。”

因此,现在,除了之前提到的重新结构化数据的例子,比如每个组中元素的计数,我们还将关注一些更高级的功能,如下所示:

  • 矩阵转置

  • 数据的拆分、应用和连接

  • 计算表格的边缘

  • 数据框的合并

  • 数据的铸造和熔化

矩阵转置

重新结构化数据最常用但常常不为人提及的方法之一是矩阵转置。这简单意味着通过t函数交换列和行,反之亦然:

> (m <- matrix(1:9, 3))
 [,1] [,2] [,3]
[1,]    1    4    7
[2,]    2    5    8
[3,]    3    6    9
> t(m)
 [,1] [,2] [,3]
[1,]    1    2    3
[2,]    4    5    6
[3,]    7    8    9

当然,这个S3方法也适用于data.frame,实际上,适用于任何表格对象。对于更高级的功能,例如转置多维表格,请查看base包中的aperm函数。

通过字符串匹配过滤数据

虽然一些过滤算法已经在之前的章节中讨论过,但dplyr包包含一些尚未介绍且值得在此提及的神奇功能。正如我们到这个时候所知道的,base中的subset函数或dplyr中的filter函数用于过滤行,而select函数可以用来选择列的子集。

过滤行的函数通常需要一个 R 表达式,该表达式返回要删除的行的 ID,类似于which函数。另一方面,为select函数提供这样的 R 表达式来描述列名通常更成问题;在列名上评估 R 表达式更困难,甚至不可能。

dplyr包提供了一些有用的函数,可以根据列名模式选择数据的一些列。例如,我们可以只保留以字符串delay结尾的变量:

> library(dplyr)
> library(hflights)
> str(select(hflights, ends_with("delay")))
'data.frame':  227496 obs. of  2 variables:
 $ ArrDelay: int  -10 -9 -8 3 -3 -7 -1 -16 44 43 ...
 $ DepDelay: int  0 1 -8 3 5 -1 -1 -5 43 43 ...

当然,还有一个类似的辅助函数starts_with来检查列名的前几个字符,并且这两个函数都可以通过ignore.case参数忽略(默认)或考虑字符的大小写。我们还有更通用的contains函数,它在列名中查找子字符串:

> str(select(hflights, contains("T", ignore.case = FALSE)))
'data.frame':  227496 obs. of  7 variables:
 $ DepTime          : int  1400 1401 1352 1403 1405 ...
 $ ArrTime          : int  1500 1501 1502 1513 1507 ...
 $ TailNum          : chr  "N576AA" "N557AA" "N541AA" "N403AA" ...
 $ ActualElapsedTime: int  60 60 70 70 62 64 70 59 71 70 ...
 $ AirTime          : int  40 45 48 39 44 45 43 40 41 45 ...
 $ TaxiIn           : int  7 6 5 9 9 6 12 7 8 6 ...
 $ TaxiOut          : int  13 9 17 22 9 13 15 12 22 19 ...

另一种选择是,我们可能需要一个更复杂的正则表达式方法,这对于数据科学家来说是一项极其重要的技能。现在,我们将正则表达式提供给matches函数,该函数将与所有列名进行匹配。让我们选择所有名称包含 5 个或 6 个字符的列:

> str(select(hflights, matches("^[[:alpha:]]{5,6}$")))
'data.frame':  227496 obs. of  3 variables:
 $ Month : int  1 1 1 1 1 1 1 1 1 1 ...
 $ Origin: chr  "IAH" "IAH" "IAH" "IAH" ...
 $ TaxiIn: int  7 6 5 9 9 6 12 7 8 6 ...

我们可以通过在表达式前使用负号来保留所有不匹配正则表达式的列名。例如,让我们确定列名中最常见的字符数:

> table(nchar(names(hflights)))
 4  5  6  7  8  9 10 13 16 17 
 2  1  2  5  4  3  1  1  1  1

然后,让我们从数据集中删除所有具有 7 个或 8 个字符的列。现在,我们将显示过滤后的数据集的列名:

> names(select(hflights, -matches("^[[:alpha:]]{7,8}$")))
 [1] "Year"              "Month"             "DayofMonth" 
 [4] "DayOfWeek"         "UniqueCarrier"     "FlightNum" 
 [7] "ActualElapsedTime" "Origin"            "Dest" 
[10] "TaxiIn"            "Cancelled"         "CancellationCode"

数据重排

有时候,我们不想过滤数据的任何部分(既不是行,也不是列),但由于便利性或性能问题,数据简单地不是最有用的顺序,正如我们在第三章中看到的,过滤和汇总数据

除了基本的sortorder函数,或者提供传递给``操作符的变量的顺序之外,我们还可以使用sqldf包中的类似 SQL 的解决方案,或者直接从数据库中以正确的格式查询数据。之前提到的dplyr包还提供了一种有效的方法来排序数据。让我们根据每百万次航班的实际耗时对hflights数据进行排序:

> str(arrange(hflights, ActualElapsedTime))
'data.frame':  227496 obs. of  21 variables:
 $ Year             : int  2011 2011 2011 2011 2011 2011 ...
 $ Month            : int  7 7 8 9 1 4 5 6 7 8 ...
 $ DayofMonth       : int  24 25 13 21 3 29 9 21 8 2 ...
 $ DayOfWeek        : int  7 1 6 3 1 5 1 2 5 2 ...
 $ DepTime          : int  2005 2302 1607 1546 1951 2035 ...
 $ ArrTime          : int  2039 2336 1641 1620 2026 2110 ...
 $ UniqueCarrier    : chr  "WN" "XE" "WN" "WN" ...
 $ FlightNum        : int  1493 2408 912 2363 2814 2418 ...
 $ TailNum          : chr  "N385SW" "N12540" "N370SW" "N524SW" ...
 $ ActualElapsedTime: int  34 34 34 34 35 35 35 35 35 35 ...
 $ AirTime          : int  26 26 26 26 23 23 27 26 25 25 ...
 $ ArrDelay         : int  9 -8 -4 15 -19 20 35 -15 86 -9 ...
 $ DepDelay         : int  20 2 7 26 -4 35 45 -8 96 1 ...
 $ Origin           : chr  "HOU" "IAH" "HOU" "HOU" ...
 $ Dest             : chr  "AUS" "AUS" "AUS" "AUS" ...
 $ Distance         : int  148 140 148 148 127 127 148 ...
 $ TaxiIn           : int  3 3 4 3 4 4 5 3 5 4 ...
 $ TaxiOut          : int  5 5 4 5 8 8 3 6 5 6 ...
 $ Cancelled        : int  0 0 0 0 0 0 0 0 0 0 ...
 $ CancellationCode : chr  "" "" "" "" ...
 $ Diverted         : int  0 0 0 0 0 0 0 0 0 0 ...

好吧,很明显,飞往奥斯汀的航班记录是显示的前几条记录之一。为了提高可读性,上述三个 R 表达式可以通过自动导入的magrittr包中的管道操作符以更优雅的方式调用,该包提供了一种简单的方法将 R 对象作为后续 R 表达式的第一个参数传递:

> hflights %>% arrange(ActualElapsedTime) %>% str

因此,我们不再需要嵌套 R 函数,现在我们可以从核心对象开始我们的 R 命令,并将每个评估的 R 表达式的结果传递给链中的下一个。在大多数情况下,这使得代码更易于阅读。尽管大多数核心 R 程序员已经习惯了从内到外阅读嵌套函数调用,但请相信我,适应这个巧妙的功能非常容易!不要让我用雷内·马格利特的启发式画作混淆你,这幅画成为了一句口号,“这不是一个烟斗”,也是magrittr包的象征:

![数据重排

一个 R 表达式和对象可以链式调用的数量没有限制。例如,让我们也过滤一些案例和变量,看看使用dplyr跟随数据重构步骤有多容易:

> hflights %>%
+     arrange(ActualElapsedTime) %>%
+     select(ActualElapsedTime, Dest) %>%
+     subset(Dest != 'AUS') %>%
+     head %>%
+     str
'data.frame':  6 obs. of  2 variables:
 $ ActualElapsedTime: int  35 35 36 36 37 37
 $ Dest             : chr  "LCH" "LCH" "LCH" "LCH" ...

因此,现在我们已经对原始数据集进行了几次过滤,以查看奥斯汀之后的最近机场,代码确实易于阅读和理解。这是一种很好的高效过滤数据的方法,尽管有些人更喜欢使用data.table包的巧妙单行代码:

> str(head(data.table(hflights, key = 'ActualElapsedTime')[Dest !=
+   'AUS', c('ActualElapsedTime', 'Dest'), with = FALSE]))
Classes 'data.table' and 'data.frame':  6 obs. of  2 variables:
 $ ActualElapsedTime: int  NA NA NA NA NA NA
 $ Dest             : chr  "MIA" "DFW" "MIA" "SEA" ...
 - attr(*, "sorted")= chr "ActualElapsedTime"
 - attr(*, ".internal.selfref")=<externalptr>

几乎完美!唯一的问题是,由于缺失值,我们得到了不同的结果,这些缺失值在数据集的开头被排序,而我们在定义 data.table 对象时将其索引设置为 ActualElapsedTime。为了克服这个问题,让我们删除 NA 值,并且不是将列名作为字符串指定,同时强制 with 参数为 FALSE,而是传递一个列名列表:

> str(head(na.omit(
+   data.table(hflights, key = 'ActualElapsedTime'))[Dest != 'AUS',
+     list(ActualElapsedTime, Dest)]))
Classes 'data.table' and 'data.frame':  6 obs. of  2 variables:
 $ ActualElapsedTime: int  35 35 36 36 37 37
 $ Dest             : chr  "LCH" "LCH" "LCH" "LCH" ...
 - attr(*, "sorted")= chr "ActualElapsedTime"
 - attr(*, ".internal.selfref")=<externalptr>

这正是我们之前看到的结果。请注意,在这个例子中,我们在将 data.frame 转换为 data.table 后,省略了 NA 值,这些值是根据 ActualElapsedTime 变量索引的,与首先在 hflights 上调用 na.omit 然后评估所有其他 R 表达式相比要快得多:

> system.time(str(head(data.table(na.omit(hflights),
+   key = 'ActualElapsedTime')[Dest != 'AUS',
+     c('ActualElapsedTime', 'Dest'), with = FALSE])))
 user  system elapsed 
 0.374   0.017   0.390 
> system.time(str(head(na.omit(data.table(hflights,
+   key = 'ActualElapsedTime'))[Dest != 'AUS',
+     c('ActualElapsedTime', 'Dest'), with = FALSE])))
 user  system elapsed 
 0.22    0.00    0.22

dplyr 与 data.table 的比较

你现在可能想知道,“我们应该使用哪个包?”

dplyrdata.table 包提供了截然不同的语法,以及略微不那么确定的性能差异。尽管 data.table 在处理大型数据集时似乎略有效率,但在这一范围内并没有明显的胜者——除非是在对大量组进行聚合操作时。坦白说,dplyr 的语法,由 magrittr 包提供,也可以用于 data.table 对象,如果需要的话。

此外,还有一个名为 pipeR 的 R 包,它提供了 R 中的管道功能,声称在处理大型数据集时比 magrittr 更有效率。这种性能提升归因于 pipeR 操作符不像 magrittr 中的 F# 语言兼容的 |> 操作符那样试图变得聪明。有时,这种性能开销估计是没有使用管道时的 5-15 倍。

在花费合理的时间学习其用法之前,应该考虑到 R 包背后的社区和支持。简而言之,data.table 包现在无疑已经足够成熟,可以用于生产环境,因为它的开发始于大约 6 年前,当时 Matt Dowle(当时在一家大型对冲基金工作)开始了开发。从那时起,开发一直在持续进行。Matt 和 Arun(包的共同开发者)不时发布新功能和性能调整,并且他们似乎都热衷于在公共 R 论坛和渠道,如邮件列表和 StackOverflow 上提供支持。

另一方面,dplyr 由 Hadley Wickham 和 RStudio 提供,他们是 R 社区中最知名的人物和趋势公司之一,这意味着拥有更大的用户群、社区,以及在 StackOverflow 和 GitHub 上的即时支持。

简而言之,我建议在花时间发现它们提供的功能和力量之后,使用最适合您需求的包。如果您来自 SQL 背景,您可能会发现 data.table 非常方便,而其他人则更倾向于选择 Hadleyverse(查看具有此名称的 R 包;它安装了一组由 Hadley 开发的有用 R 包)。您不应该在单个项目中混合这两种方法,因为从可读性和性能的角度来看,最好一次只坚持一种语法。

为了更深入地了解不同方法的优缺点,我将在接下来的几页中继续提供相同问题的多个实现。

计算新变量

在重构数据集时,我们通常执行的最简单操作之一就是创建一个新变量。对于一个传统的 data.frame,这就像是将一个 vector 分配给 R 对象的新变量一样简单。

嗯,这种方法也适用于 data.table,但由于有更高效的方法来创建一个或多个数据集中的列,因此该用法已被弃用:

> hflights_dt <- data.table(hflights)
> hflights_dt[, DistanceKMs := Distance / 0.62137]

我们刚刚通过简单的除法计算了起点和目的地机场之间的距离,单位为公里;尽管所有核心用户都可以转向 udunits2 包,该包包含基于 Unidata 的 udunits 库的一组转换工具。

如前所述,data.table 在方括号内使用特殊的 := 赋值运算符,这乍一看可能有些奇怪,但你会爱上它的!

注意

:= 运算符的速度可以比传统的 <- 赋值快 500 多倍,这是根据官方的 data.table 文档。这种加速是由于不像 R 在 3.1 版本之前那样将整个数据集复制到内存中。从那时起,R 使用浅拷贝,这极大地提高了列更新的性能,但仍然被 data.table 强大的就地更新所击败。

比较以下计算使用传统 <- 运算符和 data.table 的速度:

> system.time(hflights_dt$DistanceKMs <-
+   hflights_dt$Distance / 0.62137)
 user  system elapsed 
 0.017   0.000   0.016 
> system.time(hflights_dt[, DistanceKMs := Distance / 0.62137])
 user  system elapsed 
 0.003   0.000   0.002

这很令人印象深刻,对吧?但值得我们仔细检查我们刚刚做了什么。当然,第一个传统调用创建了/更新了 DistanceKMs 变量,但在第二个调用中发生了什么?data.table 语法没有返回任何内容(明显地),但由于 := 运算符,后台的 hflights_dt R 对象被就地更新了。

注意

请注意,:= 运算符在 knitr 内部使用时可能会产生意外的结果,例如在创建新变量后返回可见的 data.table,或者当 echo = TRUE 时命令的渲染很奇怪。作为解决方案,Matt Dowle 建议增加 data.tabledepthtrigger 选项,或者可以简单地用相同名称重新分配 data.table 对象。另一个解决方案可能是使用我的 pander 包而不是 knitr。 😃

但再次强调,它为什么会这么快?

内存分析

data.table 包的魔法——除了源代码中超过 50%是 C 代码之外——就是仅在真正必要时才在内存中复制对象。这意味着 R 在更新对象时通常会复制内存中的对象,而data.table试图将这些资源密集型操作保持在最低水平。让我们通过pryr包来验证这一点,该包提供了一些方便的内存分析辅助函数,以了解 R 的内部机制。

首先,让我们重新创建data.table对象,并记录指针值(对象在内存中的位置地址),这样我们就可以在稍后验证新变量是否只是简单地更新了相同的 R 对象,或者在进行操作时在内存中进行了复制:

> library(pryr)
> hflights_dt <- data.table(hflights)
> address(hflights_dt)
[1] "0x62c88c0"

好的,所以0x62c88c0指的是hflights_dt当前存储的位置。现在,让我们检查它是否会因为传统的赋值操作符而改变:

> hflights_dt$DistanceKMs <- hflights_dt$Distance / 0.62137
> address(hflights_dt)
[1] "0x2c7b3a0"

这确实是一个不同的位置,这意味着向 R 对象添加新列也需要 R 在内存中复制整个对象。想象一下,我们现在因为添加了一个新列而在内存中移动了 21 列。

现在,让我们看看如何在data.table中使用:=

> hflights_dt <- data.table(hflights)
> address(hflights_dt)
[1] "0x8ca2340"
> hflights_dt[, DistanceKMs := Distance / 0.62137]
> address(hflights_dt)
[1] "0x8ca2340"

R 对象在内存中的位置没有改变!并且内存中的对象复制可能会消耗大量资源,从而耗费大量时间。看看以下示例,这是上述传统变量赋值调用的略微更新版本,但增加了一个within的便利层:

> system.time(within(hflights_dt, DistanceKMs <- Distance / 0.62137))
 user  system elapsed 
 0.027   0.000   0.027

这里,使用within函数可能在内存中再次复制 R 对象,从而带来相对严重的性能开销。尽管前述示例之间的绝对时间差异可能看起来并不非常显著(在统计环境中并不显著),但想象一下,不必要的内存更新如何影响大型数据集的数据分析处理时间!

一次创建多个变量

data.table的一个不错特性是可以通过单个命令创建多个列,这在某些情况下可能非常有用。例如,我们可能对机场的英尺距离感兴趣:

> hflights_dt[, c('DistanceKMs', 'DiastanceFeets') :=
+   list(Distance / 0.62137, Distance * 5280)]

因此,这就像在:=操作符的左侧提供一个所需的变量名称的字符向量,在右侧提供一个适当的值的list。这个特性可以很容易地用于一些更复杂的任务。例如,让我们创建航空公司的虚拟变量:

> carriers <- unique(hflights_dt$UniqueCarrier)
> hflights_dt[, paste('carrier', carriers, sep = '_') :=
+   lapply(carriers, function(x) as.numeric(UniqueCarrier == x))]
> str(hflights_dt[, grep('^carrier', names(hflights_dt)),
+   with = FALSE])
Classes 'data.table' and 'data.frame': 227496 obs. of  15 variables:
 $ carrier_AA: num  1 1 1 1 1 1 1 1 1 1 ...
 $ carrier_AS: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_B6: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_CO: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_DL: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_OO: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_UA: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_US: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_WN: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_EV: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_F9: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_FL: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_MQ: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_XE: num  0 0 0 0 0 0 0 0 0 0 ...
 $ carrier_YV: num  0 0 0 0 0 0 0 0 0 0 ...
 - attr(*, ".internal.selfref")=<externalptr>

虽然它不是一行代码,并且它还引入了一个辅助变量,但看到我们做了什么并不复杂:

  1. 首先,我们将唯一的航空公司名称保存到一个字符向量中。

  2. 然后,我们利用这个来定义新变量的名称。

  3. 我们还迭代了匿名函数对这个字符向量,以返回 TRUEFALSE,如果承运人名称与给定列匹配。

  4. 给定的列通过 as.numeric 转换为 01

  5. 然后,我们简单地检查了所有以 carrier 开头的列的结构。

这并不完美,因为我们通常省略一个标签从虚拟变量中,以减少冗余。在当前情况下,最后一个新列只是其他新创建列的线性组合,因此信息被重复。为此,通常一个好的做法是通过将 -1 传递给 head 函数中的 n 参数,省略例如最后一个类别。

使用 dplyr 计算新变量

dplyr 包中 mutate 的用法与基础 within 函数相同,尽管 mutate 比起 within 来说要快得多:

> hflights <- hflights %>%
+     mutate(DistanceKMs = Distance / 0.62137)

如果之前的例子没有清楚地说明 mutatewithin 的类比,那么也许展示一个不使用管道的相同例子也会很有用:

> hflights <- mutate(hflights, DistanceKMs = Distance / 0.62137)

合并数据集

除了之前描述的对单个数据集的基本操作外,将多个数据源连接起来是日常操作中最常用的方法之一。对于此类任务,最常用的解决方案是简单地调用 merge S3 方法,它可以作为传统 SQL 内部、左/右/全外连接操作器——由 C.L. Moffatt (2008) 以以下简短总结表示:

合并数据集

dplyr 包提供了一些简单的方法,可以直接从 R 中以简单的方式执行之前展示的连接操作:

  • inner_join:这会将两个数据集中都找到的行变量连接起来

  • left_join:这包括第一个数据集中的所有行,并连接来自其他表的变量

  • semi_join:这仅包括那些在第一个数据集中也存在于其他数据集中的行

  • anti_join:这与 semi_join 类似,但仅包括那些在第一个数据集中不存在于其他数据集中的行

    注意

    更多示例,请参阅 Two-table verbs dplyr 章节和书中末尾的 参考文献 章节中列出的数据整理速查表。

这些特性也由 data.table 调用 `` 操作符的 mult 参数支持,但在此阶段,让我们坚持使用更简单的用例。

在以下示例中,我们将合并一个微小的数据集与 hflights 数据。让我们通过为 DayOfWeek 变量的可能值命名来创建 data.frame 示例:

> (wdays <- data.frame(
+     DayOfWeek       = 1:7,
+     DayOfWeekString = c("Sunday", "Monday", "Tuesday",
+         "Wednesday", "Thursday", "Friday", "Saturday")
+     ))
 DayOfWeek DayOfWeekString
1         1          Sunday
2         2          Monday
3         3         Tuesday
4         4       Wednesday
5         5        Thursday
6         6          Friday
7         7        Saturday

让我们看看如何将之前定义的 data.frame 与另一个 data.frame 以及其他表格对象进行左连接,因为 merge 也支持对例如 data.table 的快速操作:

> system.time(merge(hflights, wdays))
 user  system elapsed 
 0.700   0.000   0.699 
> system.time(merge(hflights_dt, wdays, by = 'DayOfWeek'))
 user  system elapsed 
 0.006   0.000   0.009

之前的例子自动通过DayOfWeek变量合并了两个表,这是两个数据集的一部分,并在原始的hflights数据集中产生了额外的变量。然而,在第二个例子中,我们必须传递变量名,因为merge.data.tableby参数默认为对象的关键变量,当时该变量缺失。需要注意的是,使用data.table进行合并比传统的表格对象类型要快得多。

注意

对于如何改进之前的教例有什么想法吗?除了合并之外,新变量也可以被计算。例如,查看基础 R 中的weekdays函数:weekdays(as.Date(with(hflights, paste(Year, Month, DayofMonth, sep = '-'))))

当你只想向数据集添加具有相同结构的新行或列时,合并数据集的简单方法。为此,rbindcbind,或者对于稀疏矩阵的rBindcBind,都能做得很好。

与这些基本命令一起最常使用的函数之一是do.call,它可以在一个list的所有元素上执行rbindcbind调用,从而使我们能够,例如,连接一系列数据框。这样的列表通常由lapplyplyr包的相关函数创建。同样,可以通过调用rbindlist以更快的方式合并一个data.table对象的list

以灵活的方式重塑数据

Hadley Wickham 编写了几个 R 包来调整数据结构,例如,他论文的主要部分集中在如何使用他的reshape包来重塑数据框。从那时起,这个通用的聚合和重构包经过更新,以更高效地处理最常用的任务,并且它附带新的版本号被命名为reshape2包。

这是对reshape包的全面重写,它以牺牲功能为代价提高了速度。目前,reshape2最重要的特性是能够在所谓的长(窄)表和宽表数据格式之间进行转换。这基本上涉及到列彼此堆叠或并排排列。

这些特性在 Hadley 的作品中通过以下关于数据重构的图像进行了展示,其中包含了相关的reshape函数和简单的用例:

![以灵活的方式重塑数据由于reshape包不再处于积极开发状态,并且其部分已外包给reshape2plyr和最近的dplyr,我们将在以下页面中仅关注reshape2的常用特性。这基本上包括meltcast函数,它们提供了一种将数据熔化成测量和标识变量标准形式(长表格式)的智能方式,这些数据可以随后被铸造成新的形状以进行进一步分析。## 将宽表转换为长表格式将数据框熔化意味着我们将表格数据转换为基于给定标识变量键值对。原始的列名成为新创建的variable列的分类,而所有这些(测量变量)的数值都包含在新创建的value列中。这里有一个快速示例:py> library(reshape2)> head(melt(hflights))Using UniqueCarrier, TailNum, Origin, Dest, CancellationCode as id variables UniqueCarrier TailNum Origin Dest CancellationCode variable value1 AA N576AA IAH DFW Year 20112 AA N557AA IAH DFW Year 20113 AA N541AA IAH DFW Year 20114 AA N403AA IAH DFW Year 20115 AA N492AA IAH DFW Year 20116 AA N262AA IAH DFW Year 2011因此,我们刚刚重新构建了原始的data.frame,它有 21 个变量和 25 万条记录,现在只有 7 列和超过 350 万条记录。其中六列是因子类型的标识变量,最后一列存储所有值。但为什么它有用?为什么我们要将传统的宽表格格式转换为更长的数据类型?例如,我们可能对比较飞行时间与实际飞行时间的分布感兴趣,这可能不是用原始数据格式直接绘制的。尽管使用ggplot2包绘制上述变量的散点图非常容易,但你怎么创建两个单独的箱线图来比较分布呢?这里的问题是我们有两个独立的测量时间变量,而ggplot需要一个numeric和一个factor变量,后者将用于在x轴上提供标签。为此,让我们通过指定两个要作为测量变量处理的数值变量并删除所有其他列来使用melt重新构建我们的数据集——换句话说,不保留任何标识变量:py> hflights_melted <- melt(hflights, id.vars = 0,+ measure.vars = c('ActualElapsedTime', 'AirTime'))> str(hflights_melted)'data.frame': 454992 obs. of 2 variables: $ variable: Factor w/ 2 levels "ActualElapsedTime",..: 1 1 1 1 1 ... $ value : int 60 60 70 70 62 64 70 59 71 70 ...### 注意通常情况下,在没有标识变量的情况下熔化数据集不是一个好主意,因为后来将其铸造变得繁琐,甚至是不可能的。请注意,现在我们的行数是之前的两倍,而variable列是一个只有两个级别的因子,代表两个测量变量。现在这个结果data.frame使用这两个新创建的列绘图变得容易:py> library(ggplot2)> ggplot(hflights_melted, aes(x = variable, y = value)) ++ geom_boxplot()将宽表格转换为长表格格式

好吧,前面的例子可能看起来不是那么关键,说实话,我第一次使用reshape包是因为我需要一些类似的转换来能够制作一些巧妙的ggplot2图表——因为如果有人使用base图形,前面的这个问题根本就不存在。例如,你可以简单地将原始数据集的两个单独变量传递给boxplot函数。

因此,这就像是进入了 Hadley Wickham 的 R 包的世界,这次旅程确实提供了一些优秀的数据分析实践。因此,我强烈建议进一步阅读,例如,了解如果不了解如何有效地重塑数据集,使用ggplot2可能并不容易,甚至是不可能的。

将长表格转换为宽表格格式

转换数据集是熔化的相反过程,就像将键值对转换为表格数据格式。但请记住,键值对可以以各种方式组合在一起,因此这个过程可以产生极其多样化的输出。因此,你需要一个表和一个公式来转换,例如:

> hflights_melted <- melt(hflights, id.vars = 'Month',
+   measure.vars = c('ActualElapsedTime', 'AirTime'))
> (df <- dcast(hflights_melted, Month ~ variable,
+   fun.aggregate = mean, na.rm = TRUE))
 Month ActualElapsedTime  AirTime
1      1          125.1054 104.1106
2      2          126.5748 105.0597
3      3          129.3440 108.2009
4      4          130.7759 109.2508
5      5          131.6785 110.3382
6      6          130.9182 110.2511
7      7          130.4126 109.2059
8      8          128.6197 108.3067
9      9          128.6702 107.8786
10    10          128.8137 107.9135
11    11          129.7714 107.5924
12    12          130.6788 108.9317

这个示例展示了如何通过熔化和转换 hflights 数据集来聚合 2011 年每个月测量的飞行时间:

  1. 首先,我们将 data.frame 转换为熔化形式,其中 ID 是 Month,我们只保留了两个表示飞行时间的数值变量。

  2. 然后,我们使用一个简单的公式将结果 data.frame 转换为宽表格式,以显示所有测量变量每个月的平均值。

我很确定你现在可以快速重新结构这些数据,以便能够绘制两条独立的线来表示这个基本的时间序列:

> ggplot(melt(df, id.vars = 'Month')) +
+   geom_line(aes(x = Month, y = value, color = variable)) +
+   scale_x_continuous(breaks = 1:12) +
+   theme_bw() + 
+   theme(legend.position = 'top')

将长表格转换为宽表格式

当然,除了聚合之外,熔化和转换还可以用于各种其他用途。例如,我们可以重新结构我们的原始数据库,使其具有特殊的 Month,该 Month 包含所有数据记录。当然,这会使数据集的行数翻倍,但同时也让我们能够轻松地生成带有边界的表格。以下是一个快速示例:

> hflights_melted <- melt(add_margins(hflights, 'Month'),
+    id.vars = 'Month',
+    measure.vars = c('ActualElapsedTime', 'AirTime'))
> (df <- dcast(hflights_melted, Month ~ variable,
+    fun.aggregate = mean, na.rm = TRUE))
 Month ActualElapsedTime  AirTime
1      1          125.1054 104.1106
2      2          126.5748 105.0597
3      3          129.3440 108.2009
4      4          130.7759 109.2508
5      5          131.6785 110.3382
6      6          130.9182 110.2511
7      7          130.4126 109.2059
8      8          128.6197 108.3067
9      9          128.6702 107.8786
10    10          128.8137 107.9135
11    11          129.7714 107.5924
12    12          130.6788 108.9317
13 (all)          129.3237 108.1423

这与我们之前看到的情况非常相似,但作为一个中间步骤,我们将 Month 变量转换为具有特殊级别的因子,这导致了这张表的最后一行。这一行代表相关测量变量的整体算术平均值。

调整性能

关于 reshape2 的另一个好消息是 data.table 对熔化和转换有相当的支持,并且性能有了显著提升。Matt Dowle 发布了一些基准测试,使用 castmeltdata.table 对象上而不是传统数据框上的处理时间提高了 5-10%,这非常令人印象深刻。

要验证你自己的数据集上的这些结果,只需在调用 reshape2 函数之前将 data.frame 对象转换为 data.table,因为 data.table 包已经包含了适当的 S3 方法来扩展 reshape2

reshape 包的发展历程

如前所述,reshape2 是基于大约 5 年使用和开发 reshape 包的经验而进行的完全重写。这次更新也包含了一些权衡,因为原始的 reshape 任务被分散到多个包中。因此,与 reshape 支持的那种神奇功能相比,reshape2 现在提供的功能要少得多。例如,检查一下 reshape::cast;特别是 marginsadd.missing 参数!

但实际上,即使是 reshape2 也提供了比仅仅熔化和铸造数据框多得多的功能。tidyr 包的诞生正是受到这一事实的启发:在 Hadleyverse 中有一个支持轻松进行数据清理和长宽表格式之间转换的包。在 tidyr 的术语中,这些操作被称为 gatherspread

只为了快速展示这个新语法的一个例子,让我们重新实现之前的示例:

> library(tidyr)
> str(gather(hflights[, c('Month', 'ActualElapsedTime', 'AirTime')],
+   variable, value, -Month))
'data.frame':  454992 obs. of  3 variables:
 $ Month   : int  1 1 1 1 1 1 1 1 1 1 ...
 $ variable: Factor w/ 2 levels "ActualElapsedTime",..: 1 1 1 1 ...
 $ value   : int  60 60 70 70 62 64 70 59 71 70 ...

摘要

在本章中,我们专注于如何在运行统计测试之前将原始数据转换成适当的格式结构。这个过程是我们日常行动中非常重要的一部分,占据了数据科学家大部分的时间。但在阅读本章之后,你应该对如何在大多数情况下重新结构化你的数据有信心——因此,现在是专注于构建一些模型的时候了,我们将在下一章中这样做。

第五章。构建模型(由 Renata Nemeth 和 Gergely Toth 撰写)

"所有模型都应该尽可能简单...但不要过于简单。"

– 归功于阿尔伯特·爱因斯坦

"所有模型都是错误的...但有些是有用的。"

– 乔治·博克斯

在加载数据并进行转换后,本章将重点介绍如何构建统计模型。模型是现实的表示,正如前面的引用所强调的,总是简化的表示。虽然你不可能考虑所有因素,但你应该了解在提供有意义结果的良好模型中应该包含和排除什么。

在本章中,我们将基于线性回归模型和标准建模讨论回归模型。广义线性模型GLM)将这些扩展到允许响应变量具有不同的分布,这将在第六章"), 超越线性趋势线(由 Renata Nemeth 和 Gergely Toth 撰写)中介绍。总的来说,我们将讨论三种最著名的回归模型:

  • 线性回归用于连续结果(以克为单位的出生体重)

  • 逻辑回归用于二元结果(低出生体重与正常出生体重)

  • 泊松回归用于计数数据(每年或每个国家的低出生体重婴儿数量)

虽然还有许多其他回归模型,例如我们在此处不讨论的Cox 回归,但模型构建的逻辑和解释是相似的。因此,阅读本章后,你将毫无疑问地理解那些模型。

到本章结束时,你将了解关于回归模型最重要的内容:如何避免混杂因素,如何拟合,如何解释,以及如何在众多不同选项中选择最佳模型。

多变量模型背后的动机

如果你想要衡量响应变量和预测变量之间关联的强度,你可以选择一个简单的双向关联度量,例如相关系数或优势比,这取决于你数据的性质。但是,如果你的目标是通过考虑其他预测变量来建模复杂机制,你需要回归模型。

如《卫报》的证据基础专栏作家 Ben Goldacre 在他的精彩 TED 演讲中所说,橄榄油消费与年轻外观皮肤之间的强烈关联并不意味着橄榄油对我们的皮肤有益。在建模复杂的关联结构时,我们也应该控制其他预测变量,例如吸烟状况或身体活动,因为那些消费更多橄榄油的人更有可能总体上过上健康的生活,因此可能不是橄榄油本身防止皮肤皱纹。简而言之,似乎这种生活方式可能混淆了感兴趣变量之间的关系,使得看起来可能存在因果关系,而实际上并不存在。

注意

混合因素是一个第三变量,它会影响(增加或减少)我们感兴趣的关联。混合因素总是与响应和预测因子相关联。

如果我们再次通过固定吸烟状态来检查橄榄油和皮肤皱纹的关联,即为吸烟者和非吸烟者建立单独的模型,这种关联可能会消失。固定混合因素是控制混合因素通过回归模型的主要思想。

一般而言,回归模型旨在通过控制其他因素来衡量响应和预测因子之间的关联。潜在的混合因素作为预测因子进入模型,预测因子的回归系数(部分系数)衡量了调整混合因素后的效应。

具有连续预测因子的线性回归

让我们从实际且富有启发性的混合因素例子开始。假设我们想要根据城市的大小(以人口规模衡量,以千人为单位)来预测空气污染量。空气污染通过空气中二氧化硫(SO2)浓度来衡量,以每立方米的毫克为单位。我们将使用来自gamlss.data包的美国空气污染数据集(Hand 等人,1994 年):

> library(gamlss.data)
> data(usair)

模型解释

让我们通过构建一个公式来绘制我们非常第一个线性回归模型。stats包中的lm函数用于拟合线性模型,这是回归建模的重要工具:

> model.0 <- lm(y ~ x3, data = usair)
> summary(model.0)

Residuals:
 Min      1Q  Median      3Q     Max 
-32.545 -14.456  -4.019  11.019  72.549 

Coefficients:
 Estimate Std. Error t value Pr(>|t|) 
(Intercept) 17.868316   4.713844   3.791 0.000509 ***
x3           0.020014   0.005644   3.546 0.001035 ** 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 20.67 on 39 degrees of freedom
Multiple R-squared:  0.2438,    Adjusted R-squared:  0.2244 
F-statistic: 12.57 on 1 and 39 DF,  p-value: 0.001035

注意

公式表示法是 R 语言最好的特性之一,它允许你以人性化的方式定义灵活的模型。一个典型的模型具有response ~ terms的形式,其中response是连续的响应变量,而terms提供了一或一系列数值变量,这些变量指定了响应的线性预测因子。

在前面的例子中,变量y表示空气污染,而x3代表人口规模。x3的系数表明人口规模每增加一个单位(一千人),二氧化硫浓度就会增加0.02个单位(每立方米的 0.02 毫克),并且这种效应在p值为0.001035的情况下具有统计学意义。

注意

线如何拟合数据?部分中了解更多关于 p 值的细节。为了保持简单,现在我们将把 p 值低于0.05的模型称为具有统计学意义的模型。

通常,截距是当每个预测因子等于 0 时响应变量的值,但在这个例子中,没有没有居民的城市,所以截距(17.87)没有直接的解释。两个回归系数定义了回归线:

> plot(y ~ x3, data = usair, cex.lab = 1.5)
> abline(model.0, col = "red", lwd = 2.5)
> legend('bottomright', legend = 'y ~ x3', lty = 1, col = 'red',
+   lwd = 2.5, title = 'Regression line')

模型解释

如你所见,截距(17.87)是回归线与 y 轴相交的值。另一个系数(0.02)是回归线的斜率:它衡量线的陡峭程度。在这里,函数向上运行,因为斜率是正的(y 随着 x3 的增加而增加)。同样,如果斜率是负的,函数就会向下运行。

如果你意识到线是如何绘制的,你就可以轻松理解估计值是如何得到的。这是最适合数据点的线。在这里,我们将“最佳拟合”称为线性最小二乘法,这也是为什么该模型也被称为普通最小二乘法OLS)回归。

最小二乘法通过最小化残差的平方和来找到最佳拟合线,其中残差代表误差,即观测值(散点图中的一个原始点)与拟合值或预测值(与相同 x 值的线上的点)之间的差异:

> usair$prediction <- predict(model.0)
> usair$residual<- resid(model.0)
> plot(y ~ x3, data = usair, cex.lab = 1.5)
> abline(model.0, col = 'red', lwd = 2.5)
> segments(usair$x3, usair$y, usair$x3, usair$prediction,
+   col = 'blue', lty = 2)
> legend('bottomright', legend = c('y ~ x3', 'residuals'),
+   lty = c(1, 2), col = c('red', 'blue'), lwd = 2.5,
+   title = 'Regression line')

模型解释

线性回归中的“线性”术语指的是我们感兴趣的是线性关系,这比更复杂的方法更自然、更容易理解,也更简单从数学上进行处理。

多个预测因子

另一方面,如果我们旨在通过分离人口规模的影响和工业存在的影响来模拟更复杂的机制,我们必须控制变量 x2,它描述了雇佣超过 20 名工人的制造商数量。现在,我们可以通过 lm(y ~ x3 + x2, data = usair) 创建一个新的模型,或者使用 update 函数重新拟合先前的模型:

> model.1 <- update(model.0, . ~ . + x2)
> summary(model.1)

Residuals:
 Min      1Q  Median      3Q     Max 
-22.389 -12.831  -1.277   7.609  49.533 

Coefficients:
 Estimate Std. Error t value Pr(>|t|) 
(Intercept) 26.32508    3.84044   6.855 3.87e-08 ***
x3          -0.05661    0.01430  -3.959 0.000319 ***
x2           0.08243    0.01470   5.609 1.96e-06 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 15.49 on 38 degrees of freedom
Multiple R-squared:  0.5863,    Adjusted R-squared:  0.5645 
F-statistic: 26.93 on 2 and 38 DF,  p-value: 5.207e-08

现在,x3 的系数是 -0.06!在先前的模型中,空气污染与城市规模之间的粗略关联是正的,但在控制了制造商数量之后,这种关联变为负的。这意味着人口增加一千会降低二氧化硫浓度 0.06 单位,这是一个具有统计学意义的效应。

初看之下,这种从正到负的符号变化可能令人惊讶,但在仔细观察后,这相当合理;这绝对不是人口规模,而是工业化水平直接影响了空气污染。在第一个模型中,人口规模显示出正效应,因为它隐含地测量了工业化。当我们保持工业化不变时,人口规模的影响变为负,以固定的工业化水平增长的城市会将空气污染扩散到更广泛的范围。

因此,我们可以得出结论,x2 在这里是一个混杂因素,因为它会偏倚 yx3 之间的关联。尽管它超出了我们当前研究问题的范围,我们也可以解释 x2 的系数。它表示,在保持城市规模恒定的情况下,制造商数量的每增加一个单位,SO2 浓度会增加 0.08 毫克。

根据模型,我们可以预测任何组合预测变量的响应的期望值。例如,我们可以预测一个有 40 万居民和 150 家制造商(每家制造商雇佣超过 20 名工人)的城市中二氧化硫浓度的预期水平:

> as.numeric(predict(model.1, data.frame(x2 = 150, x3 = 400)))
[1] 16.04756

你也可以自己计算预测值,将值乘以斜率,然后将它们与常数相加——所有这些数字都简单地从先前的模型摘要中复制粘贴过来:

> -0.05661 * 40
0 + 0.08243 * 150 + 26.32508 [1] 16.04558

备注

在数据范围之外进行预测被称为外推。值离数据越远,你的预测风险就越高。问题是,你无法检查模型假设(例如,线性)在样本数据之外。

如果你有两个预测变量,回归线在三维空间中由一个曲面表示,这可以通过 scatterplot3d 包轻松展示:

> library(scatterplot3d)
> plot3d <- scatterplot3d(usair$x3, usair$x2, usair$y, pch = 19,
+   type = 'h', highlight.3d = TRUE, main = '3-D Scatterplot') 
> plot3d$plane3d(model.1, lty = 'solid', col = 'red')

多个预测变量

由于这个图很难解释,让我们绘制这个三维图的二维投影,这可能会在所有方面都更有信息量。在这里,第三个未呈现变量的值被保持在零:

> par(mfrow = c(1, 2))
> plot(y ~ x3, data = usair, main = '2D projection for x3')
> abline(model.1, col = 'red', lwd = 2.5)
> plot(y ~ x2, data = usair, main = '2D projection for x2')
> abline(lm(y ~ x2 + x3, data = usair), col = 'red', lwd = 2.5)

多个预测变量

根据斜率的符号变化,值得一提的是,y-x3 回归线也发生了变化;从上升趋势变为下降趋势。

模型假设

使用标准估计技术的线性回归模型对结果变量、预测变量及其关系做出了一些假设:

  1. Y 是一个连续变量(不是二进制、名义或有序的)

  2. 错误(残差)在统计上是独立的

  3. Y 与每个 X 之间存在随机线性关系

  4. 在固定每个 X 的情况下,Y 服从正态分布

  5. 无论 X 的固定值如何,Y 都具有相同的方差

如果我们使用时间作为预测变量,则在趋势分析中,如果违反假设 2,则会出现问题。由于连续年份不是独立的,误差将不会相互独立。例如,如果我们有一年因特定疾病死亡率高,那么我们可以预期下一年的死亡率也会很高。

假设3的违反意味着关系不是完全线性的,而是偏离线性趋势线。假设45要求Y的条件分布是正态的,并且具有相同的方差,无论X的固定值如何。它们是回归推断(置信区间、F检验和t检验)所需的。假设5被称为同方差性假设。如果它被违反,则存在异方差性。

以下图表有助于使用模拟数据集可视化这些假设:

> library(Hmisc)
> library(ggplot2)
> library(gridExtra)
> set.seed(7)
> x  <- sort(rnorm(1000, 10, 100))[26:975]
> y  <- x * 500 + rnorm(950, 5000, 20000)
> df <- data.frame(x = x, y = y, cuts = factor(cut2(x, g = 5)),
+                               resid = resid(lm(y ~ x)))
> scatterPl <- ggplot(df, aes(x = x, y = y)) +
+    geom_point(aes(colour = cuts, fill = cuts), shape = 1,
+  show_guide = FALSE) + geom_smooth(method = lm, level = 0.99)
> plot_left <- ggplot(df,  aes(x = y, fill = cuts)) +
+    geom_density(alpha = .5) + coord_flip() + scale_y_reverse()
> plot_right <- ggplot(data = df, aes(x = resid, fill = cuts)) +
+    geom_density(alpha = .5) + coord_flip()
> grid.arrange(plot_left, scatterPl, plot_right,
+    ncol=3, nrow=1, widths=c(1, 3, 1))

模型假设

小贴士

可从 Packt Publishing 主页下载的代码包包含一个稍长的代码块,用于前面的图表,并对图表边距、图例和标题进行了一些调整。前面的代码块专注于可视化的主要部分,没有在打印的书籍中过多地浪费空间在样式细节上。

我们将在第九章“从大数据到小数据”中更详细地讨论如何评估模型假设。如果某些假设失败,一个可能的解决方案是寻找异常值。如果你有一个异常值,不要该观测值进行回归分析,并确定结果如何不同。异常值检测的方法将在第八章“数据抛光”中更详细地讨论。

以下示例说明,删除异常值(第 31 个观测值)可能会使假设有效。为了快速验证模型假设是否满足,请使用gvlma包:

> library(gvlma)
> gvlma(model.1)

Coefficients:
(Intercept)           x3           x2 
 26.32508     -0.05661      0.08243 

ASSESSMENT OF THE LINEAR MODEL ASSUMPTIONS
USING THE GLOBAL TEST ON 4 DEGREES-OF-FREEDOM:
Level of Significance =  0.05 

 Value  p-value                   Decision
Global Stat        14.1392 0.006864 Assumptions NOT satisfied!
Skewness            7.8439 0.005099 Assumptions NOT satisfied!
Kurtosis            3.9168 0.047805 Assumptions NOT satisfied!
Link Function       0.1092 0.741080    Assumptions acceptable.
Heteroscedasticity  2.2692 0.131964    Assumptions acceptable.

看起来有五个假设中的三个没有得到满足。然而,如果我们从同一数据集中排除第 31 个观测值构建完全相同的模型,我们会得到更好的结果:

> model.2 <- update(model.1, data = usair[-31, ])
> gvlma(model.2)

Coefficients:
(Intercept)           x3           x2 
 22.45495     -0.04185      0.06847 

ASSESSMENT OF THE LINEAR MODEL ASSUMPTIONS
USING THE GLOBAL TEST ON 4 DEGREES-OF-FREEDOM:
Level of Significance =  0.05 

 Value p-value                Decision
Global Stat        3.7099  0.4467 Assumptions acceptable.
Skewness           2.3050  0.1290 Assumptions acceptable.
Kurtosis           0.0274  0.8685 Assumptions acceptable.
Link Function      0.2561  0.6128 Assumptions acceptable.
Heteroscedasticity 1.1214  0.2896 Assumptions acceptable.

这表明,在未来的章节中构建回归模型时,我们必须始终排除第 31 个观测值。

然而,需要注意的是,仅仅因为观测值是异常值就排除它是不被接受的。在你做出决定之前,调查具体情况。如果发现异常值是由于数据错误导致的,你应该将其删除。否则,进行带有和不带有该观测值的分析,并在你的研究报告中说清楚结果如何变化以及你决定排除极端值的原因。

注意

你可以为任何一组数据点拟合一条线;最小二乘法将找到最优解,趋势线将是可解释的。即使模型假设失败,回归系数和 R 平方系数也是有意义的。假设仅在你想要解释 p 值或你旨在做出良好预测时才是必需的。

这条线在数据中拟合得有多好?

尽管我们知道趋势线是在可能的线性趋势线中拟合得最好的,但我们不知道它对实际数据的拟合程度如何。回归参数的显著性是通过检验零假设获得的,该假设指出给定的参数等于零。输出中的F 检验涉及每个回归参数为零的假设。简而言之,它测试了回归的一般显著性。一个低于 0.05 的p 值可以解释为“回归线是显著的”。否则,拟合回归模型几乎没有任何意义。

然而,即使你有显著的 F 值,你也不能对回归线的拟合说太多。我们已经看到,残差描述了拟合的误差。R 平方系数将它们总结成一个单一指标。R 平方是回归解释响应变量方差的比率。数学上,它定义为预测Y值的方差除以观察到的Y值的方差。

注意

在某些情况下,尽管 F 检验显著,但根据 R 平方,预测因子仅解释了总方差的一小部分(<10%)。你可以这样解释:尽管预测因子对响应有统计学上的显著影响,但响应是由一个比你的模型所暗示的机制更为复杂的机制形成的。这种现象在医学或生物学领域很常见,在这些领域中,复杂的生物过程被建模,而在计量经济学领域则较少见,在计量经济学领域,通常是宏观层面的聚合变量,这些变量通常平滑了数据中的小变化。

如果我们在空气污染的例子中只使用人口规模作为唯一的预测因子,R 平方等于 0.37,因此我们可以这样说:37%的 SO2 浓度变化可以由城市规模来解释:

> model.0 <- update(model.0, data = usair[-31, ])
> summary(model.0)[c('r.squared', 'adj.r.squared')]
$r.squared
[1] 0.3728245
$adj.r.squared
[1] 0.3563199

在模型中添加制造商数量后,R 平方显著增加,几乎翻了一番:

> summary(model.2)[c('r.squared', 'adj.r.squared')]
$r.squared
[1] 0.6433317
$adj.r.squared
[1] 0.6240523

注意

这里需要注意的是,每次你向模型中添加一个额外的预测因子,R 平方都会增加,仅仅是因为你有了更多预测响应的信息,即使最后添加的预测因子没有重要的影响。因此,具有更多预测因子的模型可能看起来拟合得更好,仅仅是因为它更大。

解决方案是使用调整后的 R 平方,它考虑了预测因子的数量。在先前的例子中,不仅 R 平方,调整后的 R 平方也显示出对后者的巨大优势。

前两个模型是嵌套的,这意味着扩展模型包含第一个模型中的每个预测因子。但不幸的是,调整后的 R 平方不能用作选择非嵌套模型最佳模型的基础。如果你有非嵌套模型,你可以使用赤池信息量准则AIC)来选择最佳模型。

AIC 基于信息理论。它为模型中参数的数量引入了一个惩罚项,为更大的模型倾向于显示更好的拟合问题提供了一个解决方案。当使用这个标准时,你应该选择 AIC 最小的模型。作为一个经验法则,如果两个模型的 AIC 差异小于 2,那么这两个模型基本上是不可区分的。在下面的例子中,我们有两个合理的替代模型。考虑到 AIC,model.4model.3更好,因为它的优势大约是 10:

> summary(model.3 <- update(model.2, .~. -x2 + x1))$coefficients 
 Estimate   Std. Error   t value     Pr(>|t|)
(Intercept) 77.429836 19.463954376  3.978114 3.109597e-04
x3           0.021333  0.004221122  5.053869 1.194154e-05
x1          -1.112417  0.338589453 -3.285444 2.233434e-03

> summary(model.4 <- update(model.2, .~. -x3 + x1))$coefficients 
 Estimate   Std. Error   t value     Pr(>|t|)
(Intercept) 64.52477966 17.616612780  3.662723 7.761281e-04
x2           0.02537169  0.003880055  6.539004 1.174780e-07
x1          -0.85678176  0.304807053 -2.810899 7.853266e-03

> AIC(model.3, model.4)
 df      AIC
model.3  4 336.6405
model.4  4 326.9136

备注

注意,AIC 在绝对意义上无法说明模型的品质;你的最佳模型可能仍然拟合得不好。它也不提供测试模型拟合的测试。它本质上是为了对不同模型进行排名。

离散预测因子

到目前为止,我们只看到了响应变量和预测变量都是连续的简单情况。现在,让我们将模型推广一点,并将一个离散预测因子纳入模型。以usair数据为例,添加x5(降水:每年湿天气的平均天数)作为有三个类别(低、中、高降水水平)的预测因子,使用 30 和 45 作为切分点。研究问题是这些降水组如何与 SO2 浓度相关联。这种关联不一定是线性的,如下面的图所示:

> plot(y ~ x5, data = usair, cex.lab = 1.5)
> abline(lm(y ~ x5, data = usair), col = 'red', lwd = 2.5, lty = 1)
> abline(lm(y ~ x5, data = usair[usair$x5<=45,]),
+   col = 'red', lwd = 2.5, lty = 3)
> abline(lm(y ~ x5, data = usair[usair$x5 >=30, ]),
+   col = 'red', lwd = 2.5, lty = 2)
> abline(v = c(30, 45), col = 'blue', lwd = 2.5)
> legend('topleft', lty = c(1, 3, 2, 1), lwd = rep(2.5, 4),
+   legend = c('y ~ x5', 'y ~ x5 | x5<=45','y ~ x5 | x5>=30',
+     'Critical zone'), col = c('red', 'red', 'red', 'blue'))

离散预测因子

切分点 30 和 45 大致是临时的。定义最佳切分点的一个高级方法是使用回归树。R 中有多种分类树的实现;常用的函数是来自同名包的rpart。回归树遵循一个迭代过程,将数据分割成分区,然后继续将每个分区分割成更小的组。在每一步中,算法选择在连续降水尺度上的最佳分割,最佳点最小化从组水平 SO2 均值的平方偏差之和:

> library(partykit)
> library(rpart)
> plot(as.party(rpart(y ~ x5, data = usair)))

离散预测因子

对先前结果的理解相当直接;如果我们正在寻找两个在 SO2 方面高度不同的组,则最佳切分点是 45.34 的降水水平,如果我们正在寻找三个组,那么我们将不得不使用 30.91 的切分点来分割第二个组,依此类推。四个箱线图描述了四个分区中 SO2 的分布。因此,这些结果证实了我们的先前假设,并且我们有三个在 SO2 浓度水平上强烈不同的降水组。

小贴士

查看第十章,分类与聚类,以获取更多关于决策树的细节和示例。

下面的散点图也显示,三个组之间存在很大的差异。似乎中间组的 SO2 浓度最高,而其他两组非常相似:

> usair$x5_3 <- cut2(usair$x5, c(30, 45))
> plot(y ~ as.numeric(x5_3), data = usair, cex.lab = 1.5,
+   xlab = 'Categorized annual rainfall(x5)', xaxt = 'n')
> axis(1, at = 1:3, labels = levels(usair$x5_3))
> lines(tapply(usair$y, usair$x5_3, mean), col='red', lwd=2.5, lty=1)
> legend('topright', legend = 'Linear prediction', col = 'red')

离散预测因子

现在,让我们通过添加三个降水类别到预测因子中来重新拟合我们的线性回归模型。技术上,这通过添加两个与第二和第三组相关的虚拟变量(在第十章,分类与聚类)来实现,如下表所示:

虚拟变量
类别 第一
--- ---
低(0-30) 0
中间(30-45) 1
高(45+) 0

在 R 中,你可以使用glm(广义线性模型)函数运行此模型,因为经典线性回归不允许非连续预测因子:

> summary(glmmodel.1 <- glm(y ~ x2 + x3 + x5_3, data = usair[-31, ]))
Deviance Residuals: 
 Min       1Q   Median       3Q      Max 
-26.926   -4.780    1.543    5.481   31.280 

Coefficients:
 Estimate Std. Error t value Pr(>|t|) 
(Intercept)       14.07025    5.01682   2.805  0.00817 ** 
x2                 0.05923    0.01210   4.897 2.19e-05 ***
x3                -0.03459    0.01172  -2.952  0.00560 ** 
x5_3[30.00,45.00) 13.08279    5.10367   2.563  0.01482 * 
x5_3[45.00,59.80]  0.09406    6.17024   0.015  0.98792 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for gaussian family taken to be 139.6349)

 Null deviance: 17845.9  on 39  degrees of freedom
Residual deviance:  4887.2  on 35  degrees of freedom
AIC: 317.74

Number of Fisher Scoring iterations: 2

第二组(30 到 45 天的湿润天气)的平均值比第一组高 15.2 个 SO2 单位。这是由人口规模和制造商数量控制的。这种差异在统计学上是显著的。

相反,第三组与第一组相比(低 0.04 单位),只有轻微的差异,这不具有统计学意义。三个组均值显示一个倒 U 形曲线。请注意,如果你使用原始的连续形式的降水,你隐含地假设了一个线性关系,因此你不会发现这种形状。另一个需要注意的重要事项是,这里的 U 形曲线描述了部分关联(控制了x2x3),但粗略的关联,在前面的散点图中呈现,显示了非常相似的图像。

回归系数被解释为组均值之间的差异,两组都与省略的类别(第一个)进行比较。这就是为什么省略的类别通常被称为参考类别。这种进入离散预测因子的方式被称为参考类别编码。一般来说,如果你有一个具有n个类别的离散预测因子,你必须定义(n-1)个虚拟变量。当然,如果你对其他对比感兴趣,你可以通过输入其他(n-1)个类别的虚拟变量来轻松修改模型。

备注

如果你使用离散预测因子拟合线性回归,回归斜率是组均值之间的差异。如果你还有其他预测因子,那么组均值差异将受这些预测因子的控制。记住,多元回归模型的关键特征是它们在固定其他预测因子的条件下,建模部分双向关联。

你可以通过输入任何其他类型和任何数量的预测变量来进一步深入。如果你有一个有序预测变量,你可以决定是否以原始形式输入,假设线性关系,或者形成虚拟变量并分别输入每个变量,允许任何类型的关系。如果你没有关于如何做出这个决定的背景知识,你可以尝试两种解决方案并比较模型的拟合情况。

摘要

本章介绍了如何构建和解释基本模型的概念,例如线性回归模型。到目前为止,你应该熟悉线性回归模型背后的动机;你应该知道如何控制混杂因素,如何输入离散预测变量,如何在 R 中拟合模型,以及如何解释结果。

在下一章中,我们将通过广义模型扩展这一知识,并分析模型拟合情况。

第六章. 超越线性趋势线(由 Renata Nemeth 和 Gergely Toth 撰写)

我们在上一章中讨论的线性回归模型可以处理与预测变量具有线性关联的连续响应变量。在本章中,我们将扩展这些模型,允许响应变量在分布上有所不同。但在我们深入探讨广义线性模型之前,我们需要停下来讨论一下回归模型的一般情况。

建模工作流程

首先,让我们谈谈术语。统计学家将 Y 变量称为响应变量、结果变量或因变量。X 变量通常被称为预测变量、解释变量或自变量。其中一些预测变量是我们主要感兴趣的,而其他预测变量只是因为它们可能是潜在的混杂因素而被添加。连续预测变量有时被称为协变量。

广义线性模型(GLM)是线性回归的推广。GLM(在 R 中称为glm,来自stats包)允许预测变量通过链接函数与响应变量相关联,并通过允许每个测量的方差的大小是预测值的函数。

无论你使用哪种回归模型,主要问题是:“我们可以在什么形式下将连续预测因子添加到模型中?”如果响应变量和预测变量之间的关系不符合模型假设,你可以通过某种方式变换变量。例如,在线性回归模型中,对数或二次变换是一种非常常见的通过线性公式解决独立变量和因变量之间非线性关系问题的方法。

或者,你可以通过适当细分其范围将连续预测因子转换为离散的。在选择类别时,最好的选择之一是遵循某些惯例,例如在年龄的情况下选择 18 岁作为截断点。或者,你可以遵循更技术的方法,例如通过将预测变量分类到分位数。处理此过程的更高级方法之一是使用某些分类或回归树,你可以在第十章分类和聚类中了解更多信息。

离散预测因子可以通过参考类别编码添加到模型中,就像我们在上一章中为线性回归模型所做的那样。

但我们实际上如何构建模型呢?我们已经编制了一个通用工作流程来回答这个问题:

  1. 首先,用主要预测因子和所有相关混杂因素拟合模型,然后通过删除非显著的混杂因素来减少混杂因素的数量。为此有一些自动程序(例如向后消除)。

    注意

    给定的样本大小限制了预测因子的数量。一个关于所需样本大小的经验法则是,你应该每个预测因子至少有 20 个观测值。

  2. 决定是否使用连续变量在其原始形式或分类形式中。

  3. 如果它们在实用上相关,尝试通过测试非线性关系来提高拟合度。

  4. 最后,检查模型假设。

那我们如何找到最佳模型呢?是不是拟合度越好,模型就越好?不幸的是并非如此。我们的目标是找到最佳拟合模型,但尽可能少地使用预测变量。良好的模型拟合和独立变量的低数量是相互矛盾的。

正如我们之前看到的,将新的预测变量输入到线性回归模型中总是会增加 R-squared 的值,这可能会导致过度拟合的模型。过度拟合意味着模型描述的是样本的随机噪声,而不是潜在的数据生成过程。例如,当我们模型中的预测变量太多,以至于无法适应样本大小时,就会发生过度拟合。

因此,最佳模型以尽可能少的预测变量给出所需的拟合水平。AIC 是那些考虑拟合和简洁性的适当度量之一。我们强烈建议在比较不同模型时使用它,这可以通过stats包中的AIC函数非常容易地完成。

逻辑回归

到目前为止,我们讨论了线性回归模型,这是建模连续响应变量的适当方法。然而,非连续的二进制响应(如生病或健康,忠诚或决定换工作,移动供应商或合作伙伴)也非常常见。与连续情况相比,主要区别在于现在我们应该而不是建模响应变量的期望值,而是建模概率。

天真的解决方案是在线性模型中将概率作为结果。但这个解决方案的问题在于概率应该始终在 0 和 1 之间,而使用线性模型时,这个有界范围根本无法保证。更好的解决方案是拟合逻辑回归模型,它不仅建模概率,还建模称为logit的赔率的自然对数。logit 可以是任何(正或负)数字,因此消除了范围有限的问题。

让我们用一个简单的例子来说明如何预测死刑的概率,使用一些关于被告种族的信息。这个模型与死刑执行中的种族主义问题密切相关,这是一个在美国有着悠久历史的问题。我们将使用来自catdata包的deathpenalty数据集,关于 1976 年至 1987 年佛罗里达州多起谋杀案中被告的判决。这些案件根据死刑(其中 0 表示没有,1 表示有)进行分类,被告的种族和受害者的种族(黑人表示 0,白人表示 1)。

首先,我们通过vcdExtra包中的expand.dtf函数将频率表扩展成案例形式,然后在数据集中拟合我们的第一个广义模型:

> library(catdata)
> data(deathpenalty)
> library(vcdExtra)
> deathpenalty.expand <- expand.dft(deathpenalty)
> binom.model.0 <- glm(DeathPenalty ~ DefendantRace,
+   data = deathpenalty.expand, family = binomial)
> summary(binom.model.0)

Deviance Residuals: 
 Min       1Q   Median       3Q      Max 
-0.4821  -0.4821  -0.4821  -0.4044   2.2558 

Coefficients:
 Estimate Std. Error z value Pr(>|z|) 
(Intercept)    -2.4624     0.2690  -9.155   <2e-16 ***
DefendantRace   0.3689     0.3058   1.206    0.228 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

 Null deviance: 440.84  on 673  degrees of freedom
Residual deviance: 439.31  on 672  degrees of freedom
AIC: 443.31

Number of Fisher Scoring iterations: 5

回归系数在统计上不显著,所以乍一看,我们看不到数据中的种族偏见。无论如何,为了教学目的,让我们解释回归系数。它是0.37,这意味着从黑人类别移动到白人类别时,获得死刑机会的自然对数增加 0.37。如果您取其指数,这个差异很容易解释,因为它是优势的比率:

> exp(cbind(OR = coef(binom.model.0), confint(binom.model.0)))
 OR      2.5 %    97.5 %
(Intercept)   0.08522727 0.04818273 0.1393442
DefendantRace 1.44620155 0.81342472 2.7198224

与被告种族相关的优势比是1.45,这意味着白人被告获得死刑的机会比黑人被告高出 45%。

注意

虽然 R 生成了这个结果,但截距的优势比通常不被解释。

我们可以说得更加普遍。我们已经看到在线性回归模型中,回归系数b可以解释为 X 增加一个单位时,Y 增加b。但是,在逻辑回归模型中,X 增加一个单位会使 Y 的优势乘以exp(b)

请注意,前面的预测变量是一个离散变量,其值为 0(黑人)和 1(白人),所以它基本上是一个白人的虚拟变量,而黑人则是参照类别。我们已经看到了在线性回归模型中输入离散变量的相同解决方案。如果您有超过两个种族类别,您应该为第三个种族定义第二个虚拟变量,并将其也输入到模型中。每个虚拟变量系数的指数等于优势比,它比较给定类别与参照类别。如果您有一个连续预测变量,系数的指数等于预测变量增加一个单位时的优势比。

现在,让我们将受害者种族纳入考虑,因为它是一个可能的混杂因素。让我们控制它,并使用DefendantRaceVictimRace作为预测变量拟合逻辑回归模型:

> binom.model.1 <- update(binom.model.0, . ~ . + VictimRace)
> summary(binom.model.1)

Deviance Residuals: 
 Min       1Q   Median       3Q      Max 
-0.7283  -0.4899  -0.4899  -0.2326   2.6919 

Coefficients:
 Estimate Std. Error z value Pr(>|z|) 
(Intercept)    -3.5961     0.5069  -7.094 1.30e-12 ***
DefendantRace  -0.8678     0.3671  -2.364   0.0181 * 
VictimRace      2.4044     0.6006   4.003 6.25e-05 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

 Null deviance: 440.84  on 673  degrees of freedom
Residual deviance: 418.96  on 671  degrees of freedom
AIC: 424.96

Number of Fisher Scoring iterations: 6

> exp(cbind(OR = coef(binom.model.1), confint(binom.model.1)))
 OR       2.5 %      97.5 %
(Intercept)    0.02743038 0.008433309  0.06489753
DefendantRace  0.41987565 0.209436976  0.89221877
VictimRace    11.07226549 3.694532608 41.16558028

当控制VictimRace时,DefendantRace的影响变得显著!优势比是0.42,这意味着白人被告获得死刑的机会只是黑人被告机会的 42%,在受害者种族固定的情况下。此外,VictimRace的优势比(11.07)显示出极其强烈的影响:杀害白人受害者的凶手获得死刑的可能性是杀害黑人受害者的 11 倍。

因此,DefendantRace的影响与我们在一预测变量模型中得到的影响正好相反。这种逆转的关联可能看起来是矛盾的,但它可以解释。让我们看一下以下输出:

> prop.table(table(factor(deathpenalty.expand$VictimRace,
+              labels = c("VictimRace=0", "VictimRace=1")),
+            factor(deathpenalty.expand$DefendantRace, 
+              labels = c("DefendantRace=0", "DefendantRace=1"))), 1)

 DefendantRace=0 DefendantRace=1
 VictimRace=0      0.89937107      0.10062893
 VictimRace=1      0.09320388      0.90679612

数据似乎在某种程度上具有同质性:黑人被告更有可能遇到黑人受害者,反之亦然。如果你将这些信息放在一起,你就会开始看到,黑人被告产生更小的死刑判决比例,仅仅是因为他们更有可能遇到黑人受害者,而那些有黑人受害者的人不太可能被判死刑。这种悖论消失了:粗略的死刑和DefendantRace(被告种族)关联被VictimRace(受害者种族)所混淆。

总结一下,似乎在考虑可用信息的情况下,你可以得出以下结论:

  • 黑人被告更有可能被判死刑

  • 杀死一个白人被认为比杀死一个黑人更严重的罪行

当然,你应该非常谨慎地得出这样的结论,因为种族偏见的问题需要使用所有与犯罪情况相关的相关信息进行非常彻底的分析,以及更多。

数据考虑

逻辑回归模型基于观察值之间完全独立的假设。例如,如果你的观察值是连续的年份,这个假设就被违反了。偏差残差和其他诊断统计量可以帮助验证模型并检测诸如链接函数误指定等问题。有关进一步参考,请参阅LogisticDx包。

一般而言,逻辑回归模型需要每个预测因子至少有 10 个事件,其中事件表示属于响应中较少出现类别的观察值。在我们的死刑案例中,死刑是响应中较少出现的类别,我们在数据库中有 68 个死刑判决。因此,规则建议最多允许 6-7 个预测因子。

回归系数是通过最大似然法估计的。由于没有封闭的数学形式来获取这些最大似然估计,R 使用优化算法。在某些情况下,你可能会收到一个错误消息,表明算法没有达到收敛。在这种情况下,它无法找到合适的解决方案。这可能是由多种原因造成的,例如预测因子太多、事件太少等等。

模型拟合优度

评估模型性能的一个指标是整体模型的显著性。相应的似然比检验表明,给定的模型与仅包含截距项的模型相比,拟合得更好,我们称之为零模型。

要获得测试结果,你必须查看输出中的残差偏差。它衡量了观察到的最大值和拟合的对数似然函数之间的不一致性。

注意

由于逻辑回归遵循最大似然原理,目标是使偏差残差的总和最小化。因此,这个残差与线性回归中的原始残差平行,在线性回归中,目标是使残差平方和最小化。

空偏差表示仅由截距预测的模型对响应的预测效果如何。为了评估模型,你必须将残差偏差与空偏差进行比较;差异遵循卡方分布。相应的检验在lmtest包中可用:

> library(lmtest)
> lrtest(binom.model.1)
Likelihood ratio test

Model 1: DeathPenalty ~ DefendantRace + VictimRace
Model 2: DeathPenalty ~ 1
 #Df  LogLik Df  Chisq Pr(>Chisq) 
1   3 -209.48 
2   1 -220.42 -2 21.886  1.768e-05 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

p值表示偏差的显著降低。这意味着模型是显著的,预测因子对响应概率有显著影响。

你可以将似然比视为线性回归模型中的 F 检验。它揭示了模型是否显著,但它没有提供关于拟合优度方面的任何信息,这在线性情况下是由调整 R 平方测量的。

对于逻辑回归模型,不存在等效的统计量,但已经开发出几种伪 R 平方。这些值通常在 0 到 1 之间,值越高表示拟合度越好。我们将使用来自BaylorEdPsych包的PseudoR2函数来计算这个值:

> library(BaylorEdPsych)
> PseudoR2(binom.model.1)
 McFadden     Adj.McFadden        Cox.Snell       Nagelkerke 
 0.04964600       0.03149893       0.03195036       0.06655297
McKelvey.Zavoina           Effron            Count        Adj.Count 
 0.15176608       0.02918095               NA               NA 
 AIC    Corrected.AIC 
 424.95652677     424.99234766 

但请注意,伪 R 平方不能解释为 OLS R 平方,而且它们也存在一些已记录的问题,但它们为我们提供了一个大致的图景。在我们的案例中,它们表示模型的解释力相当低,如果我们考虑到在如此复杂的过程(如判断犯罪)中只使用了两个预测因子,这一点并不令人惊讶。

模型比较

正如我们在上一章中看到的,当处理嵌套线性回归模型时,调整 R 平方为模型比较提供了一个良好的基础。对于嵌套逻辑回归模型,你可以使用似然比检验(例如来自lmtest库的lrtest函数),它比较残差偏差的差异。

> lrtest(binom.model.0, binom.model.1)
Likelihood ratio test

Model 1: DeathPenalty ~ DefendantRace
Model 2: DeathPenalty ~ DefendantRace + VictimRace
 #Df  LogLik Df Chisq Pr(>Chisq) 
1   2 -219.65 
2   3 -209.48  1 20.35   6.45e-06 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

注意

在前面的输出中,LogLiK表示模型的对数似然;你通过将其乘以 2 得到残差偏差。

对于非嵌套模型,你可以使用 AIC,就像我们在线性回归模型的情况中所做的那样,但在逻辑回归模型中,AIC 是标准输出的一部分,因此不需要单独调用 AIC 函数。在这里,binom.model.1的 AIC 比binom.model.0低,差异不可忽视,因为它大于 2。

计数数据的模型

逻辑回归只能处理二元响应。如果你有计数数据,例如在特定时间段或特定地理区域内死亡或失败的数量,你可以使用泊松或负二项式回归。这些数据类型在处理作为不同类别事件数量提供的汇总数据时尤其常见。

泊松回归

泊松回归模型是具有对数作为链接函数的广义线性模型,并且假设响应具有泊松分布。泊松分布只取整数值。它适用于计数数据,例如在固定时间段内发生的事件,即如果事件相对罕见,例如每天硬盘故障的数量。

在以下示例中,我们将使用 2013 年的硬盘数据集。数据集是从docs.backblaze.com/public/hard-drive-data/2013_data.zip下载的,但我们对其进行了一些打磨和简化。原始数据库中的每条记录对应一个硬盘的每日快照。故障变量,我们主要感兴趣的部分,可以是零(如果驱动器正常),或者一(在硬盘发生故障前的最后一天)。

让我们尝试确定哪些因素会影响故障的出现。潜在的预测因素如下:

  • model: 驱动器制造商指定的型号编号

  • capacity_bytes: 驱动器容量(以字节为单位)

  • age_month: 平均月份的驱动年龄

  • temperature: 硬盘驱动器的温度

  • PendingSector: 一个逻辑值,表示不稳定扇区的发生(在给定的硬盘上,在给定的那天等待重映射)

我们通过这些变量对这些原始数据集进行了聚合,其中freq变量表示给定类别中的记录数。现在是时候加载这个最终、清洗和聚合的数据集了:

> dfa <- readRDS('SMART_2013.RData')

快速查看按型号划分的故障数量:

> (ct <- xtabs(~model+failure, data=dfa))
 failure
model             0    1    2    3    4    5    8
 HGST          136    1    0    0    0    0    0
 Hitachi      2772   72    6    0    0    0    0
 SAMSUNG       125    0    0    0    0    0    0
 ST1500DL001    38    0    0    0    0    0    0
 ST1500DL003   213   39    6    0    0    0    0
 ST1500DM003    84    0    0    0    0    0    0
 ST2000DL001    51    4    0    0    0    0    0
 ST2000DL003    40    7    0    0    0    0    0
 ST2000DM001    98    0    0    0    0    0    0
 ST2000VN000    40    0    0    0    0    0    0
 ST3000DM001   771  122   34   14    4    2    1
 ST31500341AS 1058   75    8    0    0    0    0
 ST31500541AS 1010  106    7    1    0    0    0
 ST32000542AS  803   12    1    0    0    0    0
 ST320005XXXX  209    1    0    0    0    0    0
 ST33000651AS  323   12    0    0    0    0    0
 ST4000DM000   242   22   10    2    0    0    0
 ST4000DX000   197    1    0    0    0    0    0
 TOSHIBA       126    2    0    0    0    0    0
 WDC          1874   27    1    2    0    0    0

现在,让我们通过删除前表中只有第一列旁边有零的所有行,来去除那些没有发生任何故障的硬盘驱动器型号:

> dfa <- dfa[dfa$model %in% names(which(rowSums(ct) - ct[, 1] > 0)),]

为了快速了解故障数量,让我们通过型号编号在对数尺度上绘制一个直方图,借助ggplot2包:

> library(ggplot2)
> ggplot(rbind(dfa, data.frame(model='All', dfa[, -1] )), 
+   aes(failure)) + ylab("log(count)") + 
+   geom_histogram(binwidth = 1, drop=TRUE, origin = -0.5)  + 
+   scale_y_log10() + scale_x_continuous(breaks=c(0:10)) + 
+   facet_wrap( ~ model, ncol = 3) +
+   ggtitle("Histograms by manufacturer") + theme_bw()

泊松回归

现在,是时候将泊松回归模型拟合到数据中,使用model编号作为预测因子。可以使用带有选项family=poissonglm函数来拟合模型。默认情况下,期望的对数计数是模型化的,因此我们使用log链接。

在数据库中,每个观测值对应一个具有不同数量硬盘的组。由于我们需要处理不同的组大小,我们将使用offset函数:

> poiss.base <- glm(failure ~ model, offset(log(freq)),
+   family = 'poisson', data = dfa)
> summary(poiss.base)

Deviance Residuals: 
 Min       1Q   Median       3Q      Max 
-2.7337  -0.8052  -0.5160  -0.3291  16.3495 

Coefficients:
 Estimate Std. Error z value Pr(>|z|) 
(Intercept)        -5.0594     0.5422  -9.331  < 2e-16 ***
modelHitachi        1.7666     0.5442   3.246  0.00117 ** 
modelST1500DL003    3.6563     0.5464   6.692 2.20e-11 ***
modelST2000DL001    2.5592     0.6371   4.017 5.90e-05 ***
modelST2000DL003    3.1390     0.6056   5.183 2.18e-07 ***
modelST3000DM001    4.1550     0.5427   7.656 1.92e-14 ***
modelST31500341AS   2.7445     0.5445   5.040 4.65e-07 ***
modelST31500541AS   3.0934     0.5436   5.690 1.27e-08 ***
modelST32000542AS   1.2749     0.5570   2.289  0.02208 * 
modelST320005XXXX  -0.4437     0.8988  -0.494  0.62156 
modelST33000651AS   1.9533     0.5585   3.497  0.00047 ***
modelST4000DM000    3.8219     0.5448   7.016 2.29e-12 ***
modelST4000DX000  -12.2432   117.6007  -0.104  0.91708 
modelTOSHIBA        0.2304     0.7633   0.302  0.76279 
modelWDC            1.3096     0.5480   2.390  0.01686 * 
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for poisson family taken to be 1)

 Null deviance: 22397  on 9858  degrees of freedom
Residual deviance: 17622  on 9844  degrees of freedom
AIC: 24717

Number of Fisher Scoring iterations: 15

首先,让我们解释系数。型号是一个离散预测因子,所以我们输入了一些虚拟变量来表示它作为预测因子。默认情况下,参考类别不在输出中,但我们可以随时查询它:

> contrasts(dfa$model, sparse = TRUE)
HGST         . . . . . . . . . . . . . .
Hitachi      1 . . . . . . . . . . . . .
ST1500DL003  . 1 . . . . . . . . . . . .
ST2000DL001  . . 1 . . . . . . . . . . .
ST2000DL003  . . . 1 . . . . . . . . . .
ST3000DM001  . . . . 1 . . . . . . . . .
ST31500341AS . . . . . 1 . . . . . . . .
ST31500541AS . . . . . . 1 . . . . . . .
ST32000542AS . . . . . . . 1 . . . . . .
ST320005XXXX . . . . . . . . 1 . . . . .
ST33000651AS . . . . . . . . . 1 . . . .
ST4000DM000  . . . . . . . . . . 1 . . .
ST4000DX000  . . . . . . . . . . . 1 . .
TOSHIBA      . . . . . . . . . . . . 1 .
WDC          . . . . . . . . . . . . . 1

因此,结果证明参考类别是 HGST,虚拟变量将每个模型与 HGST 硬盘驱动器进行比较。例如,Hitachi 的系数是 1.77,所以 Hitachi 驱动器的预期对数计数比 HGST 驱动器大约高 1.77。或者,当讨论比率而不是差异时,可以计算其指数:

> exp(1.7666)
[1] 5.850926

因此,Hitachi 驱动的预期故障次数是 HGST 驱动的 5.85 倍。一般来说,解释如下:X 单位增加会使 Y 乘以 exp(b)

与逻辑回归类似,让我们确定模型的显著性。为此,我们将当前模型与没有任何预测因子的空模型进行比较,因此可以识别残差偏差和空偏差之间的差异。我们期望差异足够大,相应的卡方检验是显著的:

> lrtest(poiss.base)
Likelihood ratio test

Model 1: failure ~ model
Model 2: failure ~ 1
 #Df LogLik  Df  Chisq Pr(>Chisq) 
1  15 -12344 
2   1 -14732 -14 4775.8  < 2.2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

看起来模型是显著的,但我们也应该尝试确定是否有任何模型假设可能失败。

就像我们对线性回归和逻辑回归模型所做的那样,我们有一个独立性假设,泊松回归假设事件是独立的。这意味着一个故障的发生不会使另一个故障更有可能或更不可能。在驱动器故障的情况下,这个假设成立。另一个重要的假设来自于响应具有具有相等均值和方差的泊松分布。我们的模型假设,在预测变量的条件下,方差和均值将大致相等。

为了决定假设是否成立,我们可以将残差偏差与其自由度进行比较。对于一个拟合良好的模型,它们的比率应该接近于 1。不幸的是,报告的残差偏差是 17622,自由度是 9844,所以它们的比率远高于 1,这表明方差远大于均值。这种现象称为 过度分散

负二项式回归

在这种情况下,可以使用负二项式分布来模拟过度分散的计数响应,这是泊松回归的推广,因为它有一个额外的参数来模拟过度分散。换句话说,泊松和负二项式模型是嵌套模型;前者是后者的子集。

在以下输出中,我们使用 MASS 包中的 glm.nb 函数来拟合我们的驱动器故障数据的负二项式回归:

> library(MASS)
> model.negbin.0 <- glm.nb(failure ~ model,
+  offset(log(freq)), data = dfa)

要将此模型的性能与泊松模型进行比较,我们可以使用似然比检验,因为这两个模型是嵌套的。负二项式模型显示出显著更好的拟合度:

> lrtest(poiss.base,model.negbin.0)
Likelihood ratio test

Model 1: failure ~ model
Model 2: failure ~ model
 #Df LogLik Df Chisq Pr(>Chisq) 
1  15 -12344 
2  16 -11950  1 787.8  < 2.2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

这个结果清楚地表明应选择负二项式模型。

多元非线性模型

到目前为止,我们模型中唯一的预测变量是模型名称,但我们还有关于驱动器的其他可能重要的信息,例如容量、年龄和温度。现在让我们将这些添加到模型中,并确定新模型是否比原始模型更好。

此外,让我们也检查PendingSector的重要性。简而言之,我们定义了一个包含嵌套模型的两个步骤模型构建过程;因此,我们可以使用似然比统计量来检验模型拟合在两个步骤中是否显著增加:

> model.negbin.1 <- update(model.negbin.0, . ~ . + capacity_bytes + 
+   age_month + temperature)
> model.negbin.2 <- update(model.negbin.1, . ~ . + PendingSector)
> lrtest(model.negbin.0, model.negbin.1, model.negbin.2)
Likelihood ratio test

Model 1: failure ~ model
Model 2: failure ~ model + capacity_bytes + age_month + temperature
Model 3: failure ~ model + capacity_bytes + age_month + temperature + 
 PendingSector
 #Df LogLik Df  Chisq Pr(>Chisq) 
1  16 -11950 
2  19 -11510  3 878.91  < 2.2e-16 ***
3  20 -11497  1  26.84  2.211e-07 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

这两个步骤都很重要,因此将每个预测变量添加到模型中是值得的。现在,让我们解释最佳模型:

> summary(model.negbin.2)

Deviance Residuals: 
 Min       1Q   Median       3Q      Max 
-2.7147  -0.7580  -0.4519  -0.2187   9.4018 

Coefficients:
 Estimate Std. Error z value Pr(>|z|) 
(Intercept)       -8.209e+00  6.064e-01 -13.537  < 2e-16 ***
modelHitachi       2.372e+00  5.480e-01   4.328 1.50e-05 ***
modelST1500DL003   6.132e+00  5.677e-01  10.801  < 2e-16 ***
modelST2000DL001   4.783e+00  6.587e-01   7.262 3.81e-13 ***
modelST2000DL003   5.313e+00  6.296e-01   8.440  < 2e-16 ***
modelST3000DM001   4.746e+00  5.470e-01   8.677  < 2e-16 ***
modelST31500341AS  3.849e+00  5.603e-01   6.869 6.49e-12 ***
modelST31500541AS  4.135e+00  5.598e-01   7.387 1.50e-13 ***
modelST32000542AS  2.403e+00  5.676e-01   4.234 2.29e-05 ***
modelST320005XXXX  1.377e-01  9.072e-01   0.152   0.8794 
modelST33000651AS  2.470e+00  5.631e-01   4.387 1.15e-05 ***
modelST4000DM000   3.792e+00  5.471e-01   6.931 4.17e-12 ***
modelST4000DX000  -2.039e+01  8.138e+03  -0.003   0.9980 
modelTOSHIBA       1.368e+00  7.687e-01   1.780   0.0751 . 
modelWDC           2.228e+00  5.563e-01   4.006 6.19e-05 ***
capacity_bytes     1.053e-12  5.807e-14  18.126  < 2e-16 ***
age_month          4.815e-02  2.212e-03  21.767  < 2e-16 ***
temperature       -5.427e-02  3.873e-03 -14.012  < 2e-16 ***
PendingSectoryes   2.240e-01  4.253e-02   5.267 1.39e-07 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for Negative Binomial(0.8045) family taken to be 1)

 Null deviance: 17587  on 9858  degrees of freedom
Residual deviance: 12525  on 9840  degrees of freedom
AIC: 23034

Number of Fisher Scoring iterations: 1

 Theta:  0.8045 
 Std. Err.:  0.0525 

 2 x log-likelihood:  -22993.8850.

每个预测变量都是显著的——除了模型类型中的一些对比例外。例如,在控制年龄、温度等因素的情况下,Toshiba与参考类别HGST没有显著差异。

负二项回归参数的解释与泊松模型类似。例如,age_month的系数为 0.048,这表明年龄增加一个月,预期故障的对数计数会增加 0.048。或者,你也可以选择使用指数:

> exp(data.frame(exp_coef = coef(model.negbin.2)))
 exp_coef
(Intercept)       2.720600e-04
modelHitachi      1.071430e+01
modelST1500DL003  4.602985e+02
modelST2000DL001  1.194937e+02
modelST2000DL003  2.030135e+02
modelST3000DM001  1.151628e+02
modelST31500341AS 4.692712e+01
modelST31500541AS 6.252061e+01
modelST32000542AS 1.106071e+01
modelST320005XXXX 1.147622e+00
modelST33000651AS 1.182098e+01
modelST4000DM000  4.436067e+01
modelST4000DX000  1.388577e-09
modelTOSHIBA      3.928209e+00
modelWDC          9.283970e+00
capacity_bytes    1.000000e+00
age_month         1.049329e+00
temperature       9.471743e-01
PendingSectoryes  1.251115e+00

因此,似乎一生中的一年会增加预期故障次数的 4.9%,而更大的容量也会增加故障次数。另一方面,温度显示出相反的效果:系数的指数为 0.947,这意味着温度每增加一度,预期故障次数会减少 5.3%。

模型名称的影响可以通过与参考类别进行比较来判断,在我们的例子中是HGST。有人可能想改变这个参考。例如,对于最常见的驱动器:WDC。这可以通过改变硬盘驱动器模型中因子水平的顺序或简单地通过极其有用的relevel函数在因子中定义参考类别来实现:

> dfa$model <- relevel(dfa$model, 'WDC')

现在,让我们验证HGST是否确实替换了系数列表中的WDC,但为了不输出冗长的摘要,我们将使用来自broom包的tidy函数,该函数可以提取不同统计模型的最重要特征(对于模型摘要,请查看glance函数):

> model.negbin.3 <- update(model.negbin.2, data = dfa)
> library(broom)
> format(tidy(model.negbin.3), digits = 4)
 term   estimate std.error statistic    p.value
1        (Intercept) -5.981e+00 2.173e-01 -27.52222 9.519e-167
2          modelHGST -2.228e+00 5.563e-01  -4.00558  6.187e-05
3       modelHitachi  1.433e-01 1.009e-01   1.41945  1.558e-01
4   modelST1500DL003  3.904e+00 1.353e-01  28.84295 6.212e-183
5   modelST2000DL001  2.555e+00 3.663e-01   6.97524  3.054e-12
6   modelST2000DL003  3.085e+00 3.108e-01   9.92496  3.242e-23
7   modelST3000DM001  2.518e+00 9.351e-02  26.92818 1.028e-159
8  modelST31500341AS  1.620e+00 1.069e-01  15.16126  6.383e-52
9  modelST31500541AS  1.907e+00 1.016e-01  18.77560  1.196e-78
10 modelST32000542AS  1.751e-01 1.533e-01   1.14260  2.532e-01
11 modelST320005XXXX -2.091e+00 7.243e-01  -2.88627  3.898e-03
12 modelST33000651AS  2.416e-01 1.652e-01   1.46245  1.436e-01
13  modelST4000DM000  1.564e+00 1.320e-01  11.84645  2.245e-32
14  modelST4000DX000 -1.862e+01 1.101e+03  -0.01691  9.865e-01
15      modelTOSHIBA -8.601e-01 5.483e-01  -1.56881  1.167e-01
16    capacity_bytes  1.053e-12 5.807e-14  18.12597  1.988e-73
17         age_month  4.815e-02 2.212e-03  21.76714 4.754e-105
18       temperature -5.427e-02 3.873e-03 -14.01175  1.321e-44
19  PendingSectoryes  2.240e-01 4.253e-02   5.26709  1.386e-07

注意

使用broom包提取模型系数,比较模型拟合和其他指标,例如传递给ggplot2

温度的效应表明,温度越高,硬盘故障的数量越低。然而,日常经验显示了一个非常不同的画面,例如,在www.backblaze.com/blog/hard-drive-temperature-does-it-matter中描述的那样。谷歌工程师发现温度并不是故障的良好预测因子,而微软和弗吉尼亚大学发现它有显著影响。磁盘驱动器制造商建议保持磁盘在较低的温度下。

因此,让我们更仔细地看看这个有趣的问题,我们将把温度作为驱动器故障的预测因子。首先,让我们将温度分为六个相等的类别,然后我们将绘制一个条形图来展示每个类别的平均故障数量。请注意,我们必须考虑到不同组的大小,因此我们将通过freq进行加权,并且由于我们正在进行一些数据聚合,现在是将我们的数据集转换为data.table对象的时候了:

> library(data.table)
> dfa <- data.table(dfa)
> dfa[, temp6 := cut2(temperature, g = 6)]
> temperature.weighted.mean <- dfa[, .(wfailure = 
+     weighted.mean(failure, freq)), by = temp6] 
> ggplot(temperature.weighted.mean, aes(x = temp6, y = wfailure)) + 
+     geom_bar(stat = 'identity') + xlab('Categorized temperature') +
+     ylab('Weighted mean of disk faults') + theme_bw()

多元非线性模型

线性关系的假设显然没有得到支持。条形图建议在模型中输入时使用这种分类形式的温度,而不是原始的连续变量。为了真正看到哪个模型更好,让我们比较一下!由于它们不是嵌套的,我们必须使用 AIC,它强烈支持分类版本:

> model.negbin.4 <- update(model.negbin.0, .~. + capacity_bytes +
+   age_month + temp6 + PendingSector, data = dfa)
> AIC(model.negbin.3,model.negbin.4)
 df      AIC
model.negbin.3 20 23033.88
model.negbin.4 24 22282.47

嗯,对温度进行分类真的很有价值!现在,让我们检查其他两个连续预测因子。同样,我们将使用freq作为权重因子:

> weighted.means <- rbind(
+     dfa[, .(l = 'capacity', f = weighted.mean(failure, freq)),
+         by = .(v = capacity_bytes)],
+     dfa[, .(l = 'age', f = weighted.mean(failure, freq)),
+         by = .(v = age_month)])

与之前的图表一样,我们将使用ggplot2来绘制这些离散变量的分布,但我们将使用阶梯线图而不是条形图来克服条形图固定宽度的缺点:

> ggplot(weighted.means, aes(x = l, y = f)) + geom_step() +
+   facet_grid(. ~ v, scales = 'free_x') + theme_bw() +
+   ylab('Weighted mean of disk faults') + xlab('')

多元非线性模型

这些关系显然不是线性的。年龄的情况尤其有趣;硬盘寿命中似乎存在高度危险的时期。现在,让我们强制 R 使用容量作为名义变量(它只有五个值,所以实际上没有必要对其进行分类),并将年龄分为 8 个大小相等的类别:

> dfa[, capacity_bytes := as.factor(capacity_bytes)]
> dfa[, age8 := cut2(age_month, g = 8)]
> model.negbin.5 <- update(model.negbin.0, .~. + capacity_bytes +
+   age8 + temp6 + PendingSector, data = dfa)

根据 AIC,最后一个具有分类年龄和容量的模型要好得多,是目前为止的最佳拟合模型:

> AIC(model.negbin.5, model.negbin.4)
 df      AIC
model.negbin.5 33 22079.47
model.negbin.4 24 22282.47

如果你看参数估计,你可以看到容量上的第一个虚拟变量与参考值显著不同:

> format(tidy(model.negbin.5), digits = 3)
 term estimate std.error statistic   p.value
1                  (Intercept)  -6.1648  1.84e-01 -3.34e+01 2.69e-245
2                    modelHGST  -2.4747  5.63e-01 -4.40e+00  1.10e-05
3                 modelHitachi  -0.1119  1.21e-01 -9.25e-01  3.55e-01
4             modelST1500DL003  31.7680  7.05e+05  4.51e-05  1.00e+00
5             modelST2000DL001   1.5216  3.81e-01  3.99e+00  6.47e-05
6             modelST2000DL003   2.1055  3.28e-01  6.43e+00  1.29e-10
7             modelST3000DM001   2.4799  9.54e-02  2.60e+01 5.40e-149
8            modelST31500341AS  29.4626  7.05e+05  4.18e-05  1.00e+00
9            modelST31500541AS  29.7597  7.05e+05  4.22e-05  1.00e+00
10           modelST32000542AS  -0.5419  1.93e-01 -2.81e+00  5.02e-03
11           modelST320005XXXX  -2.8404  7.33e-01 -3.88e+00  1.07e-04
12           modelST33000651AS   0.0518  1.66e-01  3.11e-01  7.56e-01
13            modelST4000DM000   1.2243  1.62e-01  7.54e+00  4.72e-14
14            modelST4000DX000 -29.6729  2.55e+05 -1.16e-04  1.00e+00
15                modelTOSHIBA  -1.1658  5.48e-01 -2.13e+00  3.33e-02
16 capacity_bytes1500301910016 -27.1391  7.05e+05 -3.85e-05  1.00e+00
17 capacity_bytes2000398934016   1.8165  2.08e-01  8.73e+00  2.65e-18
18 capacity_bytes3000592982016   2.3515  1.88e-01  1.25e+01  8.14e-36
19 capacity_bytes4000787030016   3.6023  2.25e-01  1.60e+01  6.29e-58
20                 age8[ 5, 9)  -0.5417  7.55e-02 -7.18e+00  7.15e-13
21                 age8[ 9,14)  -0.0683  7.48e-02 -9.12e-01  3.62e-01
22                 age8[14,19)   0.3499  7.24e-02  4.83e+00  1.34e-06
23                 age8[19,25)   0.7383  7.33e-02  1.01e+01  7.22e-24
24                 age8[25,33)   0.5896  1.14e-01  5.18e+00  2.27e-07
25                 age8[33,43)   1.5698  1.05e-01  1.49e+01  1.61e-50
26                 age8[43,60]   1.9105  1.06e-01  1.81e+01  3.59e-73
27                temp6[22,24)   0.7582  5.01e-02  1.51e+01  8.37e-52
28                temp6[24,27)   0.5005  4.78e-02  1.05e+01  1.28e-25
29                temp6[27,30)   0.0883  5.40e-02  1.64e+00  1.02e-01
30                temp6[30,33)  -1.0627  9.20e-02 -1.15e+01  7.49e-31
31                temp6[33,50]  -1.5259  1.37e-01 -1.11e+01  1.23e-28
32            PendingSectoryes   0.1301  4.12e-02  3.16e+00  1.58e-03

接下来的三个容量更有可能引起故障,但趋势并非线性。年龄的影响似乎也不是线性的。总的来说,老化会增加故障数量,但也有一些例外。例如,驱动器在第一个(参考)年龄组比第二个年龄组更有可能发生故障。这一发现是合理的,因为驱动器在其操作初期有更高的故障率。温度的影响表明,中等温度(22-30 摄氏度)比低温或高温更有可能引起故障。记住,每个效应都是控制其他每个预测变量的。

判断不同预测变量的效应大小也很重要,将它们相互比较。毕竟,一张图胜过千言万语,让我们用一个图表来总结系数及其置信区间。

首先,我们必须从模型中提取显著的术语:

> tmnb5 <- tidy(model.negbin.5)
> str(terms <- tmnb5$term[tmnb5$p.value < 0.05][-1])
 chr [1:22] "modelHGST" "modelST2000DL001" "modelST2000DL003" ...

然后,让我们使用 confint 函数和古老的 plyr 包来识别系数的置信区间:

> library(plyr)
> ci <- ldply(terms, function(t) confint(model.negbin.5, t))

很遗憾,这个生成的数据框还不完整。我们需要添加术语名称,并且,让我们通过一个简单的正则表达式提取分组变量:

> names(ci) <- c('min', 'max')
> ci$term <- terms
> ci$variable <- sub('[A-Z0-9\\]\\[,() ]*$', '', terms, perl = TRUE)

现在我们已经得到了一个格式良好的数据集中系数的置信区间,这些区间可以用 ggplot 很容易地绘制出来:

> ggplot(ci, aes(x = factor(term), color = variable)) + 
+     geom_errorbar(ymin = min, ymax = max) + xlab('') +
+     ylab('Coefficients (95% conf.int)') + theme_bw() + 
+     theme(axis.text.x = element_text(angle = 90, hjust = 1),
+         legend.position = 'top')

多元非线性模型

很容易看出,尽管每个预测变量都很显著,但它们的影响大小差异很大。例如,PendingSector 对故障数量的影响微乎其微,但 agecapacitytemperature 的影响则要大得多,硬盘型号是区分故障数量最好的预测变量。

正如我们在 逻辑回归 部分中提到的,非线性模型也有不同的伪 R 平方度量。我们再次提醒您,对这些指标要持保留态度。无论如何,在我们的案例中,它们一致表明模型的解释力相当好:

> PseudoR2(model.negbin.6 )
 McFadden     Adj.McFadden        Cox.Snell       Nagelkerke 
 0.3352654        0.3318286        0.4606953        0.5474952 
McKelvey.Zavoina           Effron            Count        Adj.Count 
 NA        0.1497521        0.9310444       -0.1943522 
 AIC    Corrected.AIC 
 12829.5012999    12829.7044941 

摘要

本章介绍了三种著名的非线性回归模型:逻辑回归、泊松回归和负二项回归模型,并且你对建模的一般逻辑已经熟悉。还展示了相同的概念,如预测变量的影响、拟合优度、解释力、嵌套和非嵌套模型的模型比较以及模型构建,在不同情境中的应用。现在,在掌握数据分析技能上花费了一些时间之后,在下一章中,我们将回到一些核心的数据科学问题,例如数据的清洗和结构化。

第七章。非结构化数据

在上一章中,我们探讨了在结构化数据上构建和拟合模型的不同方法。不幸的是,这些在其他情况下非常有用的方法在处理例如一堆 PDF 文档时(目前)毫无用处。因此,接下来的几页将重点介绍处理非表格数据的方法,例如:

  • 从文本文档集合中提取度量

  • 过滤和解析自然语言文本NLP

  • 以结构化的方式可视化非结构化数据

文本挖掘是分析自然语言文本的过程;在大多数情况下来自在线内容,如电子邮件和社交媒体流(Twitter 或 Facebook)。在本章中,我们将介绍tm包中最常用的方法——尽管还有许多其他类型的非结构化数据,如文本、图像、音频、视频、非数字内容等,我们目前无法讨论。

导入语料库

语料库基本上是你想要包含在分析中的文本文档集合。使用getSources函数查看使用tm包导入语料库的可用选项:

> library(tm)
> getSources()
[1] "DataframeSource" "DirSource"  "ReutersSource"   "URISource"
[2] "VectorSource" 

因此,我们可以使用URISource函数从data.framevector或直接从统一资源标识符导入文本文档。后者代表一组超链接或文件路径,尽管使用DirSource处理起来要容易一些,因为DirSource可以导入硬盘上引用目录中找到的所有文本文档。在 R 控制台中调用getReaders函数,你可以看到支持的文本文件格式:

> getReaders()
[1] "readDOC"                 "readPDF" 
[3] "readPlain"               "readRCV1" 
[5] "readRCV1asPlain"         "readReut21578XML" 
[7] "readReut21578XMLasPlain" "readTabular" 
[9] "readXML" 

因此,有一些巧妙的函数可以读取和解析 MS Word、PDF、纯文本或 XML 文件等几种其他文件格式。之前的Reut读取器代表与tm包捆绑的 Reuter 演示语料库。

但我们不要局限于一些工厂默认的演示文件!你可以在 vignette 或参考手册中查看包示例。因为我们已经在第二章中获取了一些文本数据,即“从网络获取数据”,让我们看看我们如何处理和分析这些内容:

> res <- XML::readHTMLTable(paste0('http://cran.r-project.org/',
+                   'web/packages/available_packages_by_name.html'),
+               which = 1)

小贴士

之前的命令需要一个活跃的互联网连接,可能需要 15-120 秒来下载和解析引用的 HTML 页面。请注意,下载的 HTML 文件的内容可能与本章中显示的内容不同,因此请准备好在 R 会话中可能出现的略微不同的输出,与我们在这本书中发布的内容相比。

现在,我们有一个包含超过 5,000 个 R 包名称和简短描述的data.frame。让我们从包描述的向量源构建语料库,这样我们就可以进一步解析它们并查看包开发中的最重要趋势:

> v <- Corpus(VectorSource(res$V2))

我们刚刚创建了一个VCorpus(内存中)对象,它目前包含 5,880 个包描述:

> v
<<VCorpus (documents: 5880, metadata (corpus/indexed): 0/0)>>

如默认的print方法(参见前面的输出)所示,它对语料库提供了一个简洁的概述,因此我们需要使用另一个函数来检查实际内容:

> inspect(head(v, 3))
<<VCorpus (documents: 3, metadata (corpus/indexed): 0/0)>>

[[1]]
<<PlainTextDocument (metadata: 7)>>
A3: Accurate, Adaptable, and Accessible Error Metrics for
Predictive Models

[[2]]
<<PlainTextDocument (metadata: 7)>>
Tools for Approximate Bayesian Computation (ABC)

[[3]]
<<PlainTextDocument (metadata: 7)>>
ABCDE_FBA: A-Biologist-Can-Do-Everything of Flux Balance
Analysis with this package

在这里,我们可以看到语料库中的前三个文档,以及一些元数据。到目前为止,我们做的没有比在第二章,从网络获取数据时更多,我们可视化了一个用于包描述的表达式的词云。但那正是文本挖掘之旅的开始!

清理语料库

tm包最令人愉悦的特性之一是它提供了多种捆绑的转换,可以应用于语料库(corpuses)。tm_map函数提供了一种方便的方式来对语料库执行转换,以过滤掉实际研究中所有不相关的数据。要查看可用的转换方法列表,只需调用getTransformations函数:

> getTransformations()
[1] "as.PlainTextDocument" "removeNumbers"
[3] "removePunctuation"    "removeWords"
[5] "stemDocument"         "stripWhitespace" 

我们通常应该从移除语料库中最常用的所谓停用词开始。这些是最常见的、简短的函数术语,它们通常比语料库中的其他表达式(尤其是关键词)的意义不那么重要。该包已经包含了不同语言的此类单词列表:

> stopwords("english")
 [1] "i"          "me"         "my"         "myself"     "we" 
 [6] "our"        "ours"       "ourselves"  "you"        "your" 
 [11] "yours"      "yourself"   "yourselves" "he"         "him" 
 [16] "his"        "himself"    "she"        "her"        "hers" 
 [21] "herself"    "it"         "its"        "itself"     "they" 
 [26] "them"       "their"      "theirs"     "themselves" "what" 
 [31] "which"      "who"        "whom"       "this"       "that" 
 [36] "these"      "those"      "am"         "is"         "are" 
 [41] "was"        "were"       "be"         "been"       "being" 
 [46] "have"       "has"        "had"        "having"     "do" 
 [51] "does"       "did"        "doing"      "would"      "should" 
 [56] "could"      "ought"      "i'm"        "you're"     "he's" 
 [61] "she's"      "it's"       "we're"      "they're"    "i've" 
 [66] "you've"     "we've"      "they've"    "i'd"        "you'd" 
 [71] "he'd"       "she'd"      "we'd"       "they'd"     "i'll" 
 [76] "you'll"     "he'll"      "she'll"     "we'll"      "they'll" 
 [81] "isn't"      "aren't"     "wasn't"     "weren't"    "hasn't" 
 [86] "haven't"    "hadn't"     "doesn't"    "don't"      "didn't" 
 [91] "won't"      "wouldn't"   "shan't"     "shouldn't"  "can't" 
 [96] "cannot"     "couldn't"   "mustn't"    "let's"      "that's" 
[101] "who's"      "what's"     "here's"     "there's"    "when's" 
[106] "where's"    "why's"      "how's"      "a"          "an" 
[111] "the"        "and"        "but"        "if"         "or" 
[116] "because"    "as"         "until"      "while"      "of" 
[121] "at"         "by"         "for"        "with"       "about" 
[126] "against"    "between"    "into"       "through"    "during" 
[131] "before"     "after"      "above"      "below"      "to" 
[136] "from"       "up"         "down"       "in"         "out" 
[141] "on"         "off"        "over"       "under"      "again" 
[146] "further"    "then"       "once"       "here"       "there" 
[151] "when"       "where"      "why"        "how"        "all" 
[156] "any"        "both"       "each"       "few"        "more" 
[161] "most"       "other"      "some"       "such"       "no" 
[166] "nor"        "not"        "only"       "own"        "same" 
[171] "so"         "than"       "too"        "very" 

快速浏览这个列表可以验证,移除这些相对不重要的词并不会真正改变 R 包描述的意义。尽管有些罕见的情况,移除停用词根本不是个好主意!仔细检查以下 R 命令的输出:

> removeWords('to be or not to be', stopwords("english"))
[1] "     "

注意

这并不暗示莎士比亚的著名引言没有意义,或者我们可以在所有情况下忽略任何停用词。有时,这些词在上下文中扮演着非常重要的角色,用空格替换这些词并不有用,反而会降低质量。尽管如此,我建议,在大多数情况下,移除停用词对于将需要处理的单词数量保持在较低水平是非常实用的。

要迭代地对语料库中的每个文档应用之前的调用,tm_map函数非常有用:

> v <- tm_map(v, removeWords, stopwords("english"))

简单地将语料库、转换函数及其参数传递给tm_map,它接受并返回任何数量的文档的语料库:

> inspect(head(v, 3))
<<VCorpus (documents: 3, metadata (corpus/indexed): 0/0)>>

[[1]]
<<PlainTextDocument (metadata: 7)>>
A3 Accurate Adaptable Accessible Error Metrics Predictive Models

[[2]]
<<PlainTextDocument (metadata: 7)>>
Tools Approximate Bayesian Computation ABC

[[3]]
<<PlainTextDocument (metadata: 7)>>
ABCDEFBA ABiologistCanDoEverything Flux Balance Analysis package

我们可以看到,最常见的函数词和一些特殊字符现在已从包描述中消失。但如果有人以大写停用词开始描述呢?以下是一个示例:

> removeWords('To be or not to be.', stopwords("english"))
[1] "To     ."

很明显,句子中并没有移除to这个常用词的大写版本,并且句尾的点也被保留了。为此,通常我们只需将大写字母转换为小写,并用空格替换标点符号,以将关键词之间的杂乱程度降至最低:

> v <- tm_map(v, content_transformer(tolower))
> v <- tm_map(v, removePunctuation)
> v <- tm_map(v, stripWhitespace)
> inspect(head(v, 3))
<<VCorpus (documents: 3, metadata (corpus/indexed): 0/0)>>

[[1]]
[1] a3 accurate adaptable accessible error metrics predictive models

[[2]]
[1] tools approximate bayesian computation abc

[[3]]
[1] abcdefba abiologistcandoeverything flux balance analysis package

因此,我们首先从base包中调用了tolower函数来将所有字符从大写转换为小写。请注意,我们必须将tolower函数包装在content_transformer函数中,以便我们的转换真正符合tm包的对象结构。通常,在使用tm包之外的转换函数时,这是必需的。

然后,我们使用removePunctutation函数帮助移除了文本中的所有标点符号。这些标点符号是正则表达式中所指的[:punct:],包括以下字符:! " # $ % & ' ( ) * + , - . / : ; < = > ? @ [ \ ] ^ _ ` { | } ~'。通常,移除这些分隔符是安全的,尤其是在我们单独分析单词而不是分析它们之间的关系时。

我们还从文档中移除了多余的空白字符,这样我们只会在过滤后的单词之间找到单个空格。

可视化语料库中最常见的单词

现在我们已经清理了我们的语料库,我们可以生成一个比我们在第二章中生成的概念验证演示更有用的词云:

> wordcloud::wordcloud(v)

可视化语料库中最常见的单词

进一步清理

单词列表中仍然存在一些小的干扰错误。也许,我们根本不想在包描述中保留数字(或者我们可能想用占位文本,如NUM替换所有数字),还有一些可以忽略的常见技术词汇,例如package。显示名词的复数形式也是多余的。让我们逐步通过一些进一步的调整来改进我们的语料库!

从包描述中移除数字相当直接,如前例所示:

> v <- tm_map(v, removeNumbers)

为了移除一些意义不重要的常见领域特定词汇,让我们看看文档中最常见的单词。为此,我们首先必须计算TermDocumentMatrix函数,该函数可以稍后传递给findFreqTerms函数,以根据频率识别语料库中最流行的术语:

> tdm <- TermDocumentMatrix(v)

这个对象基本上是一个矩阵,包括行中的单词和列中的文档,其中单元格显示出现次数。例如,让我们看看前 20 个文档中前 5 个单词的出现次数:

> inspect(tdm[1:5, 1:20])
<<TermDocumentMatrix (terms: 5, documents: 20)>>
Non-/sparse entries: 5/95
Sparsity           : 95%
Maximal term length: 14
Weighting          : term frequency (tf)

 Docs
Terms            1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
 aalenjohansson 0 0 0 0 0 0 0 0 0  0  0  0  0  0  0  0  0  0  0  0
 abc            0 1 0 1 1 0 1 0 0  0  0  0  0  0  0  0  0  0  0  0
 abcdefba       0 0 1 0 0 0 0 0 0  0  0  0  0  0  0  0  0  0  0  0
 abcsmc         0 0 0 0 0 0 0 0 0  0  0  0  0  0  0  0  0  0  0  0
 aberrations    0 0 0 0 0 0 0 0 0  0  0  0  0  0  0  0  0  0  0  0

提取每个单词的总出现次数相当简单。从理论上讲,我们可以计算这个稀疏矩阵的rowSums函数。但让我们简单地调用findFreqTerms函数,它正好是我们想要做的。让我们展示那些在描述中至少出现 100 次的术语:

> findFreqTerms(tdm, lowfreq = 100)
 [1] "analysis"     "based"        "bayesian"     "data" 
 [5] "estimation"   "functions"    "generalized"  "inference" 
 [9] "interface"    "linear"       "methods"      "model" 
[13] "models"       "multivariate" "package"      "regression" 
[17] "series"       "statistical"  "test"         "tests" 
[21] "time"         "tools"        "using" 

手动审查这个列表建议忽略basedusing这两个词,除了之前建议的package术语:

> myStopwords <- c('package', 'based', 'using')
> v <- tm_map(v, removeWords, myStopwords)

词干提取

现在,让我们去除名词的复数形式,这些形式也出现在前面最常用的 20 个单词列表中!这并不像听起来那么简单。我们可能应用一些正则表达式来从单词中剪切掉尾部的s,但这种方法有很多缺点,例如没有考虑到一些明显的英语语法规则。

但我们可以使用一些词干算法,特别是可用的SnowballC包中的 Porter 词干算法。wordStem函数支持 16 种语言(详细信息请参阅getStemLanguages),可以像调用函数一样轻松地识别字符向量的词干:

> library(SnowballC)
> wordStem(c('cats', 'mastering', 'modelling', 'models', 'model'))
[1] "cat"    "master" "model"  "model"  "model"

这里唯一的缺点是 Porter 算法并不总是在所有情况下提供真正的英语单词:

> wordStem(c('are', 'analyst', 'analyze', 'analysis'))
[1] "ar"      "analyst" "analyz"  "analysi"

因此,稍后我们还需要进一步调整结果;通过帮助语言词典数据库来重建单词。构建此类数据库的最简单方法是从已存在的语料库中复制单词:

> d <- v

然后,让我们对文档中的所有单词进行词干提取:

> v <- tm_map(v, stemDocument, language = "english")

现在,我们调用了stemDocument函数,这是一个围绕SnowballC包的wordStem函数的包装器。我们只指定了一个参数,该参数设置了词干算法的语言。现在,让我们在我们的先前定义的目录上调用stemCompletion函数,并将每个词干与数据库中找到的最短相关单词相匹配。

不幸的是,这并不像前面的例子那么简单,因为stemCompletion函数接受一个单词字符向量而不是我们语料库中的文档。因此,我们必须编写自己的转换函数,使用之前使用的content_transformer辅助函数。基本思想是将每个文档通过空格分割成单词,应用stemCompletion函数,然后将单词再次连接成句子:

> v <- tm_map(v, content_transformer(function(x, d) {
+         paste(stemCompletion(
+                 strsplit(stemDocument(x), ' ')[[1]],
+                 d),
+         collapse = ' ')
+       }), d)

小贴士

前面的例子相当占用资源,所以请准备好在标准 PC 上大约 30 到 60 分钟的高 CPU 使用率。由于你可以(技术上)运行即将到来的代码示例而不实际执行此步骤,如果你赶时间,可以自由跳到下一个代码块。

这花了些时间,对吧?好吧,我们必须遍历语料库中找到的每个文档中的所有单词,但这很值得麻烦!让我们看看清理后的语料库中最常用的术语:

> tdm <- TermDocumentMatrix(v)
> findFreqTerms(tdm, lowfreq = 100)
 [1] "algorithm"     "analysing"     "bayesian"      "calculate" 
 [5] "cluster"       "computation"   "data"          "distributed" 
 [9] "estimate"      "fit"           "function"      "general" 
[13] "interface"     "linear"        "method"        "model" 
[17] "multivariable" "network"       "plot"          "random" 
[21] "regression"    "sample"        "selected"      "serial" 
[25] "set"           "simulate"      "statistic"     "test" 
[29] "time"          "tool"          "variable" 

虽然之前相同的命令返回了 23 个术语,我们从中去掉了 3 个,但现在我们在语料库中看到了超过 30 个单词出现超过 100 次。我们去掉了名词的复数形式和一些其他类似的术语变体,因此文档术语矩阵的密度也增加了:

> tdm
<<TermDocumentMatrix (terms: 4776, documents: 5880)>>
Non-/sparse entries: 27946/28054934
Sparsity           : 100%
Maximal term length: 35
Weighting          : term frequency (tf)

我们不仅减少了在下一步中需要索引的不同单词数量,而且还识别出了一些在进一步分析中需要忽略的新术语,例如,set似乎在包描述中不是一个重要的单词。

词形还原

在进行词干提取时,我们开始从单词的末尾移除字符,希望能找到词干,这是一个启发式过程,通常会导致出现之前未见过的单词,正如我们之前所看到的。我们试图通过使用词典将这些词干补充到最短的有意义单词,从而克服这个问题,这可能会导致术语意义的派生,例如,移除ness后缀。

另一种减少不同术语屈折形式数量的方法,而不是先分解然后尝试重建单词,是借助词典进行形态分析。这个过程被称为词元化,它寻找的是词元(单词的规范形式)而不是词干。

斯坦福 NLP 小组创建并维护了一个基于 Java 的 NLP 工具,称为 Stanford CoreNLP,它支持词元化,除了许多其他 NLP 算法,如分词、句子分割、词性标注和句法分析。

小贴士

您可以通过rJava包使用 CoreNLP,或者您可能安装coreNLP包,该包包括围绕CoreNLP Java 库的一些包装函数,旨在提供对例如词元化的简单访问。请注意,在安装 R 包之后,您必须使用downloadCoreNLP函数来实际安装并使 Java 库的功能可用。

分析术语之间的关联

之前计算出的TermDocumentMatrix也可以用来识别语料库中发现的清洁术语之间的关联。这仅仅意味着在相同文档中词对联合出现时计算的关联系数,这可以通过findAssocs函数轻松查询。

让我们看看哪些单词与data相关联:

> findAssocs(tdm, 'data', 0.1)
 data
set          0.17
analyzing    0.13
longitudinal 0.11
big          0.10

只有四个术语似乎具有高于 0.1 的相关系数,而“分析”是其中之一,位于关联词的前列,这并不令人惊讶。可能我们可以忽略set这个术语,但longitudinalbig数据似乎在包描述中相当常见。那么,我们还有哪些big术语呢?

> findAssocs(tdm, 'big', 0.1)
 big
mpi           0.38
pbd           0.33
program       0.32
unidata       0.19
demonstration 0.17
netcdf        0.15
forest        0.13
packaged      0.13
base          0.12
data          0.10

检查原始语料库揭示,有几个以pbd开头的 R 包,这代表Programming with Big Datapbd包通常与 Open MPI 相关联,这很好地解释了这些术语之间的高关联性。

一些其他指标

当然,在量化我们的包描述之后,我们也可以使用标准的数据分析工具。让我们看看,例如,语料库中文档的长度:

> vnchar <- sapply(v, function(x) nchar(x$content))
> summary(vnchar)
 Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
 2.00   27.00   37.00   39.85   50.00  168.00

因此,平均包描述大约由 40 个字符组成,而有一个包的描述中只有两个字符。好吧,去掉数字、标点符号和常用词后,两个字符。为了查看哪个包有如此简短的描述,我们可能简单地调用which.min函数:

> (vm <- which.min(vnchar))
[1] 221

而这正是它的奇怪之处:

> v[[vm]]
<<PlainTextDocument (metadata: 7)>>
NA
> res[vm, ]
 V1   V2
221    <NA>

因此,这根本不是一个真正的包,而是一个原始表格中的空行。让我们直观地检查包描述中的总字符数:

> hist(vnchar, main = 'Length of R package descriptions',
+     xlab = 'Number of characters')

一些其他指标

直方图表明,大多数包的描述相当简短,不超过一句话,这是基于平均英语句子包含大约 15-20 个单词,75-100 个字符的事实。

文档的分割

为了根据语料库文档中术语的频率和关联识别不同的清洗术语组,可以直接使用我们的tdm矩阵运行,例如,经典的层次聚类算法。

另一方面,如果你更愿意根据描述对 R 包进行聚类,我们应该使用DocumentTermMatrix计算一个新的矩阵,而不是之前使用的TermDocumentMatrix。然后,在这个矩阵上调用聚类算法将导致包的分割。

关于可用的方法、算法以及选择聚类适当函数的指导,请参阅第十章,分类和聚类。现在,我们将退回到传统的hclust函数,它提供了一种在距离矩阵上运行层次聚类的内置方式。为了快速演示,让我们在所谓的Hadleyverse上展示这一点,它描述了由 Hadley Wickham 开发的有用 R 包集合:

> hadleyverse <- c('ggplot2', 'dplyr', 'reshape2', 'lubridate',
+   'stringr', 'devtools', 'roxygen2', 'tidyr')

现在,让我们确定v语料库中哪些元素包含了之前列出的包的清洗后的术语:

> (w <- which(res$V1 %in% hadleyverse))
[1] 1104 1230 1922 2772 4421 4658 5409 5596

然后,我们可以简单地计算使用术语的(不)相似度矩阵:

> plot(hclust(dist(DocumentTermMatrix(v[w]))),
+   xlab = 'Hadleyverse packages')

文档的分割

除了我们在第四章中介绍的reshape2tidyr包,数据重构,我们还可以在之前的图表中看到两个单独的聚类(以下列表中突出显示的术语是从包描述中复制的):

  • 使事情变得更容易的包

  • 其他处理语言、文档语法

为了验证这一点,你可能对每个包的清洗术语感兴趣:

> sapply(v[w], function(x) structure(content(x),
+   .Names = meta(x, 'id')))
 devtools 
 "tools make developing r code easier" 
 dplyr 
 "a grammar data manipulation" 
 ggplot2 
 "an implementation grammar graphics" 
 lubridate 
 "make dealing dates little easier" 
 reshape2 
 "flexibly reshape data reboot reshape " 
 roxygen2 
 "insource documentation r" 
 stringr 
 "make easier work strings" 
 tidyr 
"easily tidy data spread gather functions"

基于 NLP 算法对文档进行聚类的另一种可能更合适、长期的方法是拟合主题模型,例如,通过topicmodels包。这个 R 包附带了一个详细且非常有用的 vignette,其中包含一些理论背景和一些实际示例。但为了快速入门,你可能会尝试在我们的先前创建的DocumentTermMatrix上运行LDACTM函数,并指定要构建的模型的主题数量。根据我们之前的聚类示例,一个好的起点可能是k=3

摘要

前面的示例和快速理论背景介绍了文本挖掘算法,将普通英文文本结构化为数字以便进一步分析。在下一章中,我们将集中讨论数据分析过程中一些同样重要的方法,例如如何通过识别异常值、极值来打磨这类数据,以及如何处理缺失数据。

第八章:数据精炼

当处理数据时,你通常会发现数据可能并不总是完美的或干净的,在缺失值、异常值和类似异常方面。处理和清理不完美或所谓的脏数据是每位数据科学家日常生活中的一个部分,甚至更多,这可能会占用我们实际处理数据时间的多达 80%!

数据集错误通常是由于数据采集方法不充分,但与其重复和调整数据采集过程,通常更好(在节省金钱、时间和其他资源方面)或不可避免的是通过几个简单的函数和算法来精炼数据。在本章中,我们将涵盖:

  • 不同函数中na.rm参数的不同用法

  • 用于去除缺失数据的na.action和相关函数

  • 几个提供用户友好数据插补方式的软件包

  • 包含多个用于极端值统计测试的outliers软件包

  • 如何作为脑筋急转弯自己实现 Lund 的异常值测试

  • 参考一些稳健的方法

缺失数据的类型和来源

首先,我们必须快速查看可能的缺失数据的不同来源,以确定我们通常如何以及为什么得到缺失值。数据丢失的原因有很多,可以分为 3 种不同类型。

例如,缺失数据的主要原因可能是设备故障或人为错误地输入数据。完全随机缺失(MCAR)意味着数据集中的每个值都有相同的概率被遗漏,因此我们不应该期望由于缺失数据而出现系统错误或扭曲,也无法解释缺失值的模式。如果我们数据集中有NA(意为:无回答、不适用或不可用)值,这是最好的情况。

但与完全随机缺失(MCAR)相比,更常见且不幸的一种缺失数据类型是随机缺失(MAR)。在 MAR 的情况下,缺失值的模式是已知的或至少可以识别的,尽管它与实际的缺失值无关。例如,可以想象一个男性比女性更孤独或更懒惰的群体,因此他们可能不愿意回答调查中的所有问题——无论实际问题是怎样的。所以并不是因为男性比女性赚得多或少,他们只是倾向于随机跳过问卷中的几个问题。

注意

这种缺失数据的分类和类型学最初是由 Donald B. Rubin 在 1976 年提出的,他在《Biometrika 63(3): 581—592》上发表的《Inference and Missing Data》一文中进行了阐述,后来在 Roderick J. A. Little(2002 年)合著的书中进行了回顾和扩展:《Statistical Analysis with Missing Data》,Wiley – 这本书对于深入了解细节非常值得一读。

最糟糕的情况是缺失非随机MNAR),其中数据缺失是由于与实际问题高度相关的特定原因,将缺失值分类为不可忽视的非响应。

在包含敏感问题的调查或研究准备设计缺陷的情况下,这种情况相当常见。在这种情况下,数据缺失是由于背景中某些潜在过程导致的,这通常是我们在研究帮助下想要更好地了解的事情——这可能会变成一个相当麻烦的情况。

那么,我们如何解决这些问题呢?有时这相对简单。例如,如果我们有很多观测值,由于大数定律,MCAR 根本不是问题,因为每个观测值缺失值的概率是相同的。我们基本上有两个选项来处理未知或缺失的数据:

  • 删除缺失值和/或观测值

  • 用一些估计值替换缺失值

识别缺失数据

处理缺失值的最简单方法,特别是对于 MCAR 数据,就是简单地删除任何有缺失值的观测值。如果我们想排除matrixdata.frame对象中至少有一个缺失值的每一行,我们可以使用stats包中的complete.cases函数来识别这些行。

为了快速入门,让我们看看有多少行至少有一个缺失值:

> library(hflights)
> table(complete.cases(hflights))
 FALSE   TRUE 
 3622 223874

这大约是 25 万行中的 1.5%:

> prop.table(table(complete.cases(hflights))) * 100
 FALSE      TRUE 
 1.592116 98.407884

让我们看看NA在不同列中的分布情况:

> sort(sapply(hflights, function(x) sum(is.na(x))))
 Year             Month        DayofMonth 
 0                 0                 0 
 DayOfWeek     UniqueCarrier         FlightNum 
 0                 0                 0 
 TailNum            Origin              Dest 
 0                 0                 0 
 Distance         Cancelled  CancellationCode 
 0                 0                 0 
 Diverted           DepTime          DepDelay 
 0              2905              2905 
 TaxiOut           ArrTime            TaxiIn 
 2947              3066              3066 
ActualElapsedTime           AirTime          ArrDelay 
 3622              3622              3622

跳过缺失值

因此,似乎缺失数据相对频繁地出现在与时间相关的变量中,但在航班标识符和日期中我们没有缺失值。另一方面,如果一个航班的某个值缺失,那么其他一些变量也缺失的可能性相当高——在总共 3,622 个至少有一个缺失值的案例中:

> mean(cor(apply(hflights, 2, function(x)
+    as.numeric(is.na(x)))), na.rm = TRUE)
[1] 0.9589153
Warning message:
In cor(apply(hflights, 2, function(x) as.numeric(is.na(x)))) :
 the standard deviation is zero

好吧,让我们看看我们在这里做了什么!首先,我们调用了apply函数将data.frame的值转换为01,其中0表示观测值,而1表示缺失值。然后我们计算了这个新创建矩阵的相关系数,由于一些列只有一个唯一值且没有任何变化,因此返回了大量的缺失值,正如警告信息所示。为此,我们必须将na.rm参数指定为TRUE,这样mean函数就会返回一个真实值而不是NA,通过删除cor函数返回的相关系数中的缺失值。

因此,一个选择是大量使用na.rm参数,这是大多数对缺失数据敏感的函数所支持的——以下是从basestats包中的一些例子:meanmediansummaxmin

要编译包含在基础包中具有 na.rm 参数的所有函数的完整列表,我们可以遵循位于 stackoverflow.com/a/17423072/564164 的一个非常有趣的 SO 答案中描述的步骤。我发现这个答案很有启发性,因为我真正相信分析我们用于分析的工具的力量,换句话说,花些时间理解 R 在后台是如何工作的。

首先,让我们列出 baseenvbase 包的环境)中找到的所有函数,以及完整的函数参数和主体:

> Funs <- Filter(is.function, sapply(ls(baseenv()), get, baseenv()))

然后,我们可以通过以下方式从返回的列表中 Filter 所有那些具有 na.rm 作为形式参数的函数:

> names(Filter(function(x)
+    any(names(formals(args(x))) %in% 'na.rm'), Funs))
 [1] "all"                     "any" 
 [3] "colMeans"                "colSums" 
 [5] "is.unsorted"             "max" 
 [7] "mean.default"            "min" 
 [9] "pmax"                    "pmax.int" 
[11] "pmin"                    "pmin.int" 
[13] "prod"                    "range" 
[15] "range.default"           "rowMeans" 
[17] "rowsum.data.frame"       "rowsum.default" 
[19] "rowSums"                 "sum" 
[21] "Summary.data.frame"      "Summary.Date" 
[23] "Summary.difftime"        "Summary.factor" 
[25] "Summary.numeric_version" "Summary.ordered" 
[27] "Summary.POSIXct"         "Summary.POSIXlt" 

这可以很容易地应用于任何 R 包,只需更改环境变量,例如将 stats 包的情况更改为 'package:stats'

> names(Filter(function(x)
+   any(names(formals(args(x))) %in% 'na.rm'),
+     Filter(is.function,
+       sapply(ls('package:stats'), get, 'package:stats'))))
 [1] "density.default" "fivenum"         "heatmap" 
 [4] "IQR"             "mad"             "median" 
 [7] "median.default"  "medpolish"       "sd" 
[10] "var" 

因此,这些是在 basestats 包中具有 na.rm 参数的函数,我们已经看到,在单个函数调用中忽略缺失值(实际上并不从数据集中删除 NA 值)最快和最简单的方法是将 na.rm 设置为 TRUE。但为什么 na.rm 默认不是 TRUE

覆盖函数的默认参数

如果你因为大多数函数在 R 对象包含缺失值时返回 NA 而感到烦恼,那么你可以通过使用一些自定义包装函数来覆盖这些函数,例如:

> myMean <- function(...) mean(..., na.rm = TRUE)
> mean(c(1:5, NA))
[1] NA
> myMean(c(1:5, NA))
[1] 3

另一个选择可能是编写一个自定义包,该包将覆盖 basestats 函数的工厂默认设置,就像 rapportools 包一样,它包含了一些具有合理默认值的辅助函数,用于报告:

> library(rapportools)
Loading required package: reshape

Attaching package: 'rapportools'

The following objects are masked from 'package:stats':

 IQR, median, sd, var

The following objects are masked from 'package:base':

 max, mean, min, range, sum

> mean(c(1:5, NA))
[1] 3

这种方法的缺点在于你已永久覆盖了列出的那些函数,因此你需要重新启动你的 R 会话或断开 rapportools 包来重置为标准参数,例如:

> detach('package:rapportools')
> mean(c(1:5, NA))
[1] NA

要覆盖函数的默认参数,一个更通用的解决方案是依赖 Defaults 包的一些巧妙特性,尽管它不再处于积极维护状态,但它确实完成了工作:

> library(Defaults)
> setDefaults(mean.default, na.rm = TRUE)
> mean(c(1:5, NA))
[1] 3

请注意,在这里我们必须更新 mean.default 的默认参数值,而不是简单地尝试调整 mean,因为后者会导致错误:

> setDefaults(mean, na.rm = TRUE)
Warning message:
In setDefaults(mean, na.rm = TRUE) :
 'na.rm' was not set, possibly not a formal arg for 'mean'

这是因为 mean 是一个没有任何形式参数的 S3 方法:

> mean
function (x, ...) 
{
 if (exists(".importDefaults")) 
 .importDefaults(calling.fun = "mean")
 UseMethod("mean")
}
<environment: namespace:base>
> formals(mean)
$x

$...

无论你更喜欢哪种方法,你都可以通过在 Rprofile 文件中添加几行代码来自动在 R 启动时调用这些函数。

注意

你可以通过全局或用户特定的Rprofile文件来定制 R 环境。这是一个普通的 R 脚本,通常放在用户的家目录中,文件名以点开头,每次启动新的 R 会话时都会运行。在那里,你可以调用在.First.Last函数中包装的任何 R 函数,这些函数将在 R 会话的开始或结束时运行。这些有用的添加可能包括加载一些 R 包,从数据库中打印自定义问候语或 KPI 指标,或者例如安装所有 R 包的最新版本。

但可能最好不要以这种方式非标准地调整你的 R 环境,因为你可能会很快在分析中遇到一些神秘和意外的错误或无声的故障。

例如,我已经习惯了在所有时候都在临时目录中工作,通过在Rprofile中指定setwd('/tmp')来实现,这对于频繁启动 R 会话进行一些快速工作非常有用。另一方面,当你花费 15 分钟的时间调试为什么某个随机的 R 函数似乎不起作用,以及为什么它返回一些文件未找到的错误消息时,这真的很令人沮丧。

所以请务必注意:如果你更新了 R 函数的工厂默认参数,在尝试在带有--vanilla命令行选项启动的纯 R 会话中重现这些错误之前,不要在 R 邮件列表上对你在 base R 的一些主要函数中发现的新错误进行抱怨。

消除缺失数据

在 R 函数中使用na.rm参数的另一种方法是,在将数据集传递给分析函数之前从数据集中删除NA。这意味着我们永久性地从数据集中删除缺失值,这样它们就不会在分析的后阶段引起任何问题。为此,我们可以使用na.omitna.exclude函数:

> na.omit(c(1:5, NA))
[1] 1 2 3 4 5
attr(,"na.action")
[1] 6
attr(,"class")
[1] "omit"
> na.exclude(c(1:5, NA))
[1] 1 2 3 4 5
attr(,"na.action")
[1] 6
attr(,"class")
[1] "exclude"

这两个函数之间的唯一区别是返回的 R 对象的na.action属性的类别,分别是omitexclude。这个细微的区别仅在建模时很重要。na.exclude函数对于残差和预测返回NA,而na.omit抑制这些向量的元素:

> x <- rnorm(10); y <- rnorm(10)
> x[1] <- NA; y[2] <- NA
> exclude <- lm(y ~ x, na.action = "na.exclude")
> omit <- lm(y ~ x, na.action = "na.omit")
> residuals(exclude)
 1     2     3     4     5     6     7     8     9    10 
 NA    NA -0.89 -0.98  1.45 -0.23  3.11 -0.23 -1.04 -1.20 

> residuals(omit)
 3     4     5     6     7     8     9    10 
-0.89 -0.98  1.45 -0.23  3.11 -0.23 -1.04 -1.20

在表格数据的情况下,如matrixdata.frame,这些函数会删除包含至少一个缺失值的整个行。为了快速演示,让我们创建一个 3 列 3 行的矩阵,值从 1 递增到 9,但将所有能被 4 整除的值替换为NA

> m <- matrix(1:9, 3)
> m[which(m %% 4 == 0, arr.ind = TRUE)] <- NA
> m
 [,1] [,2] [,3]
[1,]    1   NA    7
[2,]    2    5   NA
[3,]    3    6    9
> na.omit(m)
 [,1] [,2] [,3]
[1,]    3    6    9
attr(,"na.action")
[1] 1 2
attr(,"class")
[1] "omit"

如此可见,我们可以在na.action属性中找到已删除案例的行号。

在实际分析之前或期间过滤缺失数据

假设我们想要计算实际飞行长度的平均值

> mean(hflights$ActualElapsedTime)
[1] NA

结果当然是NA,因为如前所述,这个变量包含缺失值,并且几乎所有的 R 操作与NA结合都会得到NA。所以让我们这样解决这个问题:

> mean(hflights$ActualElapsedTime, na.rm = TRUE)
[1] 129.3237
> mean(na.omit(hflights$ActualElapsedTime))
[1] 129.3237

那里有没有性能问题?或者有其他决定使用哪种方法的方式?

> library(microbenchmark)
> NA.RM   <- function()
+              mean(hflights$ActualElapsedTime, na.rm = TRUE)
> NA.OMIT <- function()
+              mean(na.omit(hflights$ActualElapsedTime))
> microbenchmark(NA.RM(), NA.OMIT())
Unit: milliseconds
 expr       min        lq    median        uq       max neval
 NA.RM()  7.105485  7.231737  7.500382  8.002941  9.850411   100
 NA.OMIT() 12.268637 12.471294 12.905777 13.376717 16.008637   100

这些选项的性能,通过microbenchmark包的帮助计算(请参阅第一章中的加载合理大小的文本文件部分,欢迎数据了解更多细节)表明,在单个函数调用的情况下,使用na.rm是更好的解决方案。

另一方面,如果我们想在分析的一些后续阶段重新使用数据,从数据集中一次性省略缺失值和观测值会更可行和有效,而不是总是指定na.rmTRUE

数据插补

有时候省略缺失值可能不合理或根本不可能,例如由于观测值数量少或似乎缺失数据不是随机的。在这种情况下,数据插补是一个真正的替代方案,这种方法可以根据各种算法将NA替换为一些真实值,例如通过以下方式填充空单元格:

  • 已知的标量

  • 在(hot-deck)列中出现的上一个值

  • 同一列中的随机元素

  • 列中最频繁的值

  • 同一列中给定概率的不同值

  • 基于回归或机器学习模型的预测值

热插补方法通常在合并多个数据集时使用。在这种情况下,data.tableroll参数可能非常有用和高效,否则请确保查看VIM包中的hotdeck函数,它提供了一些可视化缺失数据的有用方法。但当我们处理数据集的给定列时,我们还有一些其他简单的选项。

例如,插补一个已知的标量是一个相当简单的情况,其中我们知道所有缺失值都是由于某些研究设计模式。让我们考虑一个数据库,该数据库存储了每个工作日到达和离开办公室的时间,通过计算这两个时间之间的差异,我们可以分析每天在办公室花费的工作小时数。如果这个变量在某个时间段返回NA,实际上意味着我们整天都在办公室外,因此计算出的值应该是零而不是NA

而且不仅是在理论上,在 R 中实现这一点也很简单(以下示例从之前的演示代码继续,其中我们用两个缺失值定义了m):

> m[which(is.na(m), arr.ind = TRUE)] <- 0
> m
 [,1] [,2] [,3]
[1,]    1    0    7
[2,]    2    5    0
[3,]    3    6    9

类似地,用随机数、其他值的sample或变量的mean替换缺失值可以相对容易地完成:

> ActualElapsedTime <- hflights$ActualElapsedTime
> mean(ActualElapsedTime, na.rm = TRUE)
[1] 129.3237
> ActualElapsedTime[which(is.na(ActualElapsedTime))] <-
+   mean(ActualElapsedTime, na.rm = TRUE)
> mean(ActualElapsedTime)
[1] 129.3237

使用Hmisc包中的impute函数甚至可以更容易:

> library(Hmisc)
> mean(impute(hflights$ActualElapsedTime, mean))
[1] 129.3237

当然,我们似乎保留了算术平均值的值,但你应该意识到一些非常严重的副作用:

> sd(hflights$ActualElapsedTime, na.rm = TRUE)
[1] 59.28584
> sd(ActualElapsedTime)
[1] 58.81199

当用平均值替换缺失值时,转换变量的方差将自然低于原始分布。在某些情况下,这可能会非常成问题,需要更复杂的方法。

模型缺失值

除了前面提到的单变量方法之外,你还可以在数据集中的完整案例上拟合模型,而不是在剩余的行上拟合这些模型来估计缺失值。或者简单地说,我们用多变量预测来替换缺失值。

有许多相关的函数和包,例如你可能对Hmisc包中的transcan函数感兴趣,或者imputeR包,它包括用于插补分类和连续变量的广泛模型。

大多数的插补方法和模型都是针对一种类型的变量:要么是连续的,要么是分类的。在混合类型数据集的情况下,我们通常使用不同的算法来处理不同类型的缺失数据。这种方法的问题是一些可能存在于不同类型数据之间的关系可能会被忽略,从而导致一些部分模型。

为了克服这个问题,并且为了在书中节省一些关于传统回归和其他相关数据插补方法的描述页面(尽管你可以在第五章"), 构建模型(由 Renata Nemeth 和 Gergely Toth 编写) 和第六章"), 超越线性趋势线(由 Renata Nemeth 和 Gergely Toth 编写) 中找到一些相关方法),我们将专注于一个非参数方法,该方法可以通过missForest包中非常用户友好的界面同时处理分类和连续变量。

这个迭代过程在可用数据上拟合随机森林模型,以预测缺失值。由于我们的hflights数据集对于这个过程来说相对较大,运行示例代码会花费很长时间,所以我们将在下一个示例中使用标准的iris数据集。

首先,让我们看看数据集的原始结构,它不包含任何缺失值:

> summary(iris)
 Sepal.Length    Sepal.Width     Petal.Length    Petal.Width 
 Min.   :4.300   Min.   :2.000   Min.   :1.000   Min.   :0.100 
 1st Qu.:5.100   1st Qu.:2.800   1st Qu.:1.600   1st Qu.:0.300 
 Median :5.800   Median :3.000   Median :4.350   Median :1.300 
 Mean   :5.843   Mean   :3.057   Mean   :3.758   Mean   :1.199 
 3rd Qu.:6.400   3rd Qu.:3.300   3rd Qu.:5.100   3rd Qu.:1.800 
 Max.   :7.900   Max.   :4.400   Max.   :6.900   Max.   :2.500 
 Species 
 setosa    :50 
 versicolor:50 
 virginica :50 

现在让我们加载这个包,并在数据集中添加一些缺失值(完全随机地),以产生一个可重复的最小示例,用于后续模型的构建:

> library(missForest)
> set.seed(81)
> miris <- prodNA(iris, noNA = 0.2)
> summary(miris)
 Sepal.Length    Sepal.Width     Petal.Length    Petal.Width 
 Min.   :4.300   Min.   :2.000   Min.   :1.100   Min.   :0.100 
 1st Qu.:5.200   1st Qu.:2.800   1st Qu.:1.600   1st Qu.:0.300 
 Median :5.800   Median :3.000   Median :4.450   Median :1.300 
 Mean   :5.878   Mean   :3.062   Mean   :3.905   Mean   :1.222 
 3rd Qu.:6.475   3rd Qu.:3.300   3rd Qu.:5.100   3rd Qu.:1.900 
 Max.   :7.900   Max.   :4.400   Max.   :6.900   Max.   :2.500 
 NA's   :28      NA's   :29      NA's   :32      NA's   :33 
 Species 
 setosa    :40 
 versicolor:38 
 virginica :44 
 NA's      :28 

因此,现在每个列中大约有 20%的缺失值,这也在前面的摘要的最后一行中提到。完全随机缺失值的数量在 28 到 33 个案例之间。

下一步应该是构建随机森林模型,用实数和因子水平替换缺失值。因为我们也有原始数据集,我们可以使用这个完整的矩阵通过xtrue参数来测试方法的性能,该参数在调用函数时计算并返回错误率。这在这样的教学示例中很有用,可以展示模型和预测如何从迭代到迭代地改进:

> iiris <- missForest(miris, xtrue = iris, verbose = TRUE)
 missForest iteration 1 in progress...done!
 error(s): 0.1512033 0.03571429 
 estimated error(s): 0.1541084 0.04098361 
 difference(s): 0.01449533 0.1533333 
 time: 0.124 seconds

 missForest iteration 2 in progress...done!
 error(s): 0.1482248 0.03571429 
 estimated error(s): 0.1402145 0.03278689 
 difference(s): 9.387853e-05 0 
 time: 0.114 seconds

 missForest iteration 3 in progress...done!
 error(s): 0.1567693 0.03571429 
 estimated error(s): 0.1384038 0.04098361 
 difference(s): 6.271654e-05 0 
 time: 0.152 seconds

 missForest iteration 4 in progress...done!
 error(s): 0.1586195 0.03571429 
 estimated error(s): 0.1419132 0.04918033 
 difference(s): 3.02275e-05 0 
 time: 0.116 seconds

 missForest iteration 5 in progress...done!
 error(s): 0.1574789 0.03571429 
 estimated error(s): 0.1397179 0.04098361 
 difference(s): 4.508345e-05 0 
 time: 0.114 seconds

算法运行了 5 次迭代后停止,此时似乎错误率没有进一步改善。返回的missForest对象除了填充后的数据集外,还包括一些其他值:

> str(iiris)
List of 3
 $ ximp    :'data.frame':  150 obs. of  5 variables:
 ..$ Sepal.Length: num [1:150] 5.1 4.9 4.7 4.6 5 ...
 ..$ Sepal.Width : num [1:150] 3.5 3.3 3.2 3.29 3.6 ...
 ..$ Petal.Length: num [1:150] 1.4 1.4 1.3 1.42 1.4 ...
 ..$ Petal.Width : num [1:150] 0.2 0.218 0.2 0.2 0.2 ...
 ..$ Species     : Factor w/ 3 levels "setosa","versicolor",..: ...
 $ OOBerror: Named num [1:2] 0.1419 0.0492
 ..- attr(*, "names")= chr [1:2] "NRMSE" "PFC"
 $ error   : Named num [1:2] 0.1586 0.0357
 ..- attr(*, "names")= chr [1:2] "NRMSE" "PFC"
 - attr(*, "class")= chr "missForest"

箱外误差是对我们的模型有多好的估计,基于数值的归一化均方根误差计算NRMSE)和因子的错误分类比例PFC)。而且,因为我们也为之前运行过的模型提供了完整的数据集,所以我们还得到了真实的填充误差比率——这非常接近上述估计。

注意

请在第十章 分类和聚类 中查找有关随机森林和相关机器学习主题的更多详细信息。

但这种方法和一个更简单的填充方法,比如用平均值替换缺失值,相比如何呢?

比较不同的填充方法

在比较中,我们只会使用iris数据集的前四列,因此目前并没有处理因子变量。让我们准备这个演示数据集:

> miris <- miris[, 1:4]

iris_mean中,我们将所有缺失值替换为实际列的平均值:

> iris_mean <- impute(miris, fun = mean)

iris_forest中,我们通过拟合随机森林模型来预测缺失值:

> iris_forest <- missForest(miris)
 missForest iteration 1 in progress...done!
 missForest iteration 2 in progress...done!
 missForest iteration 3 in progress...done!
 missForest iteration 4 in progress...done!
 missForest iteration 5 in progress...done!

现在,让我们简单地通过比较iris_meaniris_forest与完整的iris数据集的相关性来检查两个模型的准确性。对于iris_forest,我们将从ximp属性中提取实际的填充数据集,并且我们将默默地忽略原始iris表中的因子变量:

> diag(cor(iris[, -5], iris_mean))
Sepal.Length  Sepal.Width Petal.Length  Petal.Width 
 0.6633507    0.8140169    0.8924061    0.4763395 
> diag(cor(iris[, -5], iris_forest$ximp))
Sepal.Length  Sepal.Width Petal.Length  Petal.Width 
 0.9850253    0.9320711    0.9911754    0.9868851

这些结果表明,非参数随机森林模型与用平均值替换缺失值的简单单变量解决方案相比,做得更好。

未填充缺失值

请注意,这些方法同样有其缺点。用预测值替换缺失值通常缺乏任何误差项和残差方差,这在大多数模型中都是如此。

这也意味着我们在降低变异性,同时在数据集中高估了一些关联,这可能会严重影响我们的数据分析结果。为此,过去引入了一些模拟技术来克服由于一些任意模型而扭曲数据集和我们的假设检验的问题。

多次填充

多重插补背后的基本思想是连续多次对缺失值进行模型拟合。这种蒙特卡洛方法通常创建一些(如 3 到 10 个)模拟完整数据集的并行版本,每个版本分别进行分析,然后我们将结果结合起来产生实际的估计值和置信区间。例如,查看 Hmisc 包中的 aregImpute 函数以获取更多详细信息。

另一方面,我们是否真的需要在所有情况下都删除或插补缺失值?关于这个问题的更多细节,请参阅本章的最后部分。但在那之前,让我们了解一下对数据进行润色的一些其他要求。

极端值和异常值

异常值或极端值被定义为与其它观测值差异如此之大,以至于它变得可疑,可能是完全不同的机制或简单地由错误生成的。识别异常值很重要,因为这些极端值可以:

  • 增加误差方差

  • 影响估计

  • 降低正态性

或者换句话说,假设你的原始数据集是一块要用于某些游戏中完美球体的圆形石头,在使用之前必须进行清洁和抛光。石头表面有一些小孔,就像数据中的缺失值一样,应该用数据插补来填补。

另一方面,这块石头不仅在表面上有很多孔,而且还有一些泥土覆盖了物品的一些部分,这些部分需要被去除。但我们是怎样区分泥土和真正的石头的呢?在本节中,我们将关注 outliers 包和一些相关方法在识别极端值方面能提供什么。

由于这个包与 randomForest 包(由 missForest 包自动加载)有一些冲突的函数名,所以在进入以下示例之前,明智的做法是先卸载后者:

> detach('package:missForest')
> detach('package:randomForest')

outlier 函数返回与平均值差异最大的值,尽管其名称与之相反,这个值不一定是异常值。相反,该函数可以用来给分析师一个关于哪些值可能是异常值的想法:

> library(outliers)
> outlier(hflights$DepDelay)
[1] 981

因此,有一架航班在起飞前延误了超过 16 小时!这很令人印象深刻,不是吗?让我们看看它是否正常这么晚起飞:

> summary(hflights$DepDelay)
 Min. 1st Qu.  Median    Mean 3rd Qu.    Max.    NA's 
-33.000  -3.000   0.000   9.445   9.000 981.000    2905

好吧,平均值大约是 10 分钟,但由于它甚至比第三季度还要大,而中位数是零,所以不难猜测相对较大的平均值是由于一些极端值:

> library(lattice)
> bwplot(hflights$DepDelay)

极端值和异常值

前面的箱线图清楚地显示,大多数航班仅延误了几分钟,四分位距大约是 10 分钟:

> IQR(hflights$DepDelay, na.rm = TRUE)
[1] 12

前面的图像中所有的蓝色圆圈都是可能的极端值,因为它们高于上四分位数 1.5 的四分位距。但我们如何(从统计学的角度)测试一个值呢?

测试极端值

outliers 包含几个捆绑的极端值检测算法,例如:

  • Dixon 的 Q 测试(dixon.test

  • Grubb 的测试(grubbs.test

  • 异常值和内嵌方差(cochran.test

  • 卡方检验(chisq.out.test

这些函数极其容易使用。只需将一个向量传递给统计测试,显著性测试返回的 p 值将清楚地表明数据中是否存在异常值。例如,让我们测试 0 到 1 之间的 10 个随机数与一个相对较大的数,以验证它在这个小样本中是否是极端值:

> set.seed(83)
> dixon.test(c(runif(10), pi))

 Dixon test for outliers

data:  c(runif(10), pi)
Q = 0.7795, p-value < 2.2e-16
alternative hypothesis: highest value 3.14159265358979 is an outlier

但不幸的是,我们无法在我们的实际数据集中使用这些方便的函数,因为这些方法假设正态分布,而我们都知道,在我们的情况下这绝对不是真的:航班往往比预定时间晚到,而不是提前到达目的地。

对于这个,我们应该使用一些更稳健的方法,比如 mvoutlier 包,或者像 Lund 在 40 年前建议的一些非常简单的方法。这个测试基本上是通过一个非常简单的线性回归来计算每个值与平均值的距离:

> model <- lm(hflights$DepDelay ~ 1)

只是为了验证我们现在确实是在测量与平均值之间的距离:

> model$coefficients
(Intercept) 
 9.444951 
> mean(hflights$DepDelay, na.rm = TRUE)
[1] 9.444951

现在让我们根据 F 分布和两个辅助变量(其中 a 代表 alpha 值,n 代表案例数)来计算临界值:

> a <- 0.1
> (n <- length(hflights$DepDelay))
[1] 227496
> (F <- qf(1 - (a/n), 1, n-2, lower.tail = TRUE))
[1] 25.5138

这可以被传递到 Lund 的公式中:

> (L <- ((n - 1) * F / (n - 2 + F))⁰.5)
[1] 5.050847

现在让我们看看有多少值的标准化残差高于这个计算出的临界值:

> sum(abs(rstandard(model)) > L)
[1] 1684

但我们真的需要从我们的数据中移除这些异常值吗?极端值不是正常的吗?有时这些原始数据中的人为编辑,比如填补缺失值或移除异常值,带来的麻烦比它值得的还要多。

使用稳健的方法

幸运的是,有一些稳健的方法可以分析数据集,这些方法通常对极端值不太敏感。这些稳健的统计方法自 1960 年以来就已经发展起来,但还有一些更早的知名相关方法,比如使用中位数而不是平均值作为中心趋势。当我们的数据的基本分布被认为不遵循高斯曲线时,通常会使用稳健的方法,因此大多数好的旧回归模型都不适用(更多细节请见第五章 Chapter 5"), Buildings Models (authored by Renata Nemeth and Gergely Toth) 和第六章 Chapter 6"), Beyond the Linear Trend Line (authored by Renata Nemeth and Gergely Toth))。

让我们以传统的线性回归为例,预测鸢尾花的花瓣长度,基于花瓣长度并有一些缺失数据。为此,我们将使用之前定义的 miris 数据集:

> summary(lm(Sepal.Length ~ Petal.Length, data = miris))

Call:
lm(formula = Sepal.Length ~ Petal.Length, data = miris)

Residuals:
 Min       1Q   Median       3Q      Max 
-1.26216 -0.36157  0.01461  0.35293  1.01933 

Coefficients:
 Estimate Std. Error t value Pr(>|t|) 
(Intercept)   4.27831    0.11721   36.50   <2e-16 ***
Petal.Length  0.41863    0.02683   15.61   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 0.4597 on 92 degrees of freedom
 (56 observations deleted due to missingness)
Multiple R-squared:  0.7258,  Adjusted R-squared:  0.7228 
F-statistic: 243.5 on 1 and 92 DF,  p-value: < 2.2e-16

因此,我们的花瓣与萼片长度比率的估计大约为 0.42,顺便说一句,这并不太远离真实值:

> lm(Sepal.Length ~ Petal.Length, data = iris)$coefficients
 (Intercept) Petal.Length 
 4.3066034    0.4089223

估计系数与实际系数之间的差异是由于前一个章节中人为引入的缺失值造成的。我们能否产生更好的估计值?我们可以用之前提到的任何一种方法来估计缺失数据,或者我们更应该拟合一个来自MASS包的稳健线性回归,预测Sepal.LengthPetal.Length变量:

> library(MASS)
> summary(rlm(Sepal.Length ~ Petal.Length, data = miris))

Call: rlm(formula = Sepal.Length ~ Petal.Length, data = miris)
Residuals:
 Min       1Q   Median       3Q      Max 
-1.26184 -0.36098  0.01574  0.35253  1.02262 

Coefficients:
 Value   Std. Error t value
(Intercept)   4.2739  0.1205    35.4801
Petal.Length  0.4195  0.0276    15.2167

Residual standard error: 0.5393 on 92 degrees of freedom
 (56 observations deleted due to missingness)

现在我们来比较针对原始(完整)数据和模拟数据(包含缺失值)运行的模型的系数:

> f <- formula(Sepal.Length ~ Petal.Length)
> cbind(
+     orig =  lm(f, data = iris)$coefficients,
+     lm   =  lm(f, data = miris)$coefficients,
+     rlm  = rlm(f, data = miris)$coefficients)
 orig        lm       rlm
(Intercept)  4.3066034 4.2783066 4.2739350
Petal.Length 0.4089223 0.4186347 0.4195341

说实话,标准线性回归和稳健版本之间没有太大的区别。惊讶吗?嗯,数据集包含的缺失值是完全随机出现的,但如果数据集包含其他类型的缺失值或异常值会怎样呢?让我们通过模拟一些更脏的数据问题(将第一个观察值的萼片长度从1.4更新到14——让我们假设是由于数据输入错误)并重新构建模型来验证这一点:

> miris$Sepal.Length[1] <- 14
> cbind(
+     orig = lm(f, data = iris)$coefficients,
+     lm   = lm(f, data = miris)$coefficients,
+     rlm  = rlm(f, data = miris)$coefficients)
 orig        lm       rlm
(Intercept)  4.3066034 4.6873973 4.2989589
Petal.Length 0.4089223 0.3399485 0.4147676

看起来lm模型的性能下降了很多,而稳健模型的系数几乎与原始模型相同,无论数据中的异常值如何。我们可以得出结论,稳健方法在处理极端值时是非常令人印象深刻且强大的工具!有关 R 中已实现的有关方法的更多信息,请访问相关的 CRAN 任务视图cran.r-project.org/web/views/Robust.html

摘要

本章重点介绍了数据分析中的一些最具挑战性的问题,特别是在数据清洗方面,我们涵盖了关于缺失值和极端值的最重要主题。根据你的兴趣领域或你所在行业的不同,脏数据可能是一个罕见或主要问题(例如,我过去看到过一些项目,其中正则表达式被应用于JSON文件以使其有效),但我相信,无论你的背景如何,你都会发现下一章既有趣又有用——我们将学习多元统计分析技术。

第九章. 从大数据到小数据

现在我们有一些准备好的清洁数据用于分析,让我们首先看看我们如何在我们数据集中的大量变量中找到我们的方向。本章将介绍一些统计技术,通过降维和特征提取来减少变量的数量,例如:

  • 主成分分析PCA

  • 因子分析FA

  • 多维尺度分析MDS)和其他一些技术

注意

大多数降维方法都需要数据集中的两个或多个数值变量高度相关或相关,因此我们矩阵中的列并不是完全相互独立的。在这种情况下,降维的目标是将数据集中的列数减少到实际的矩阵秩;或者换句话说,变量的数量可以减少,同时保留大部分信息内容。在线性代数中,矩阵秩指的是由矩阵生成的向量空间的维度——或者说,在二次矩阵中独立列和行的数量。可能通过一个简单的例子更容易理解秩:想象一个关于学生的数据集,其中我们知道受访者的性别、年龄和出生日期。这些数据是冗余的,因为年龄可以通过线性变换从出生日期计算出来。同样,hflights数据集中的年份变量是静态的(没有任何变化),并且可以通过出发和到达时间计算经过的时间。

这些变换基本上集中在变量之间识别出的共同方差上,并排除了剩余的总(独特)方差。这导致数据集的列数减少,这可能更容易维护和处理,但代价是损失一些信息,并创建一些人工变量,这些变量通常比原始列更难以理解。

在完全依赖的情况下,除了一个完全相关的变量外,其他所有完全相关的变量都可以省略,因为其余的变量没有提供关于数据集的额外信息。尽管这种情况并不常见,但在大多数情况下,只保留从一组问题中提取的一个或几个成分,例如在调查中用于进一步分析,是完全可接受的。

充分性检验

当你考虑通过多元统计分析减少数据集中的维度或寻找潜在变量时,首先想做的事情是检查变量是否相关,数据是否呈正态分布。

正态性

后者通常不是一个严格的要求。例如,如果我们没有多元正态性,PCA 的结果仍然可能是有效的,并且可以解释;另一方面,最大似然因子分析确实有这个强烈的假设。

小贴士

你应该始终根据你数据的特征,使用适当的方法来实现你的数据分析目标。

无论如何,你可以使用(例如)qqplot来进行变量的成对比较,以及使用qqnorm来进行变量的单变量正态性检验。首先,让我们用一个hflights的子集来演示这一点:

> library(hlfights)
> JFK <- hflights[which(hflights$Dest == 'JFK'),
+                 c('TaxiIn', 'TaxiOut')]

因此,我们过滤我们的数据集,只保留那些飞往约翰·肯尼迪国际机场的航班,并且我们只对描述进出滑行时间(以分钟为单位)的两个变量感兴趣。使用传统的``索引的先前命令可以用subset重构,以获得更易读的源代码:

> JFK <- subset(hflights, Dest == 'JFK', select = c(TaxiIn, TaxiOut))

请注意,现在在subset调用中不需要引用变量名或提及data.frame的名称。关于这方面的更多细节,请参阅[第三章,过滤和汇总数据。现在让我们看看这两个列值的分布情况:

> par(mfrow = c(1, 2))
> qqnorm(JFK$TaxiIn, ylab = 'TaxiIn')
> qqline(JFK$TaxiIn)
> qqnorm(JFK$TaxiOut, ylab = 'TaxiOut')
> qqline(JFK$TaxiOut)

正态性

为了生成前面的图,我们创建了一个新的图形设备(使用par在一行中保持两个图),然后调用qqnorm来显示经验变量的分位数与正态分布的对比,并且还添加了qqline来便于比较。如果数据之前已经缩放,qqline将渲染一条 45 度的线。

检查 QQ 图表明数据与正态分布不太吻合,这也可以通过如 Shapiro-Wilk 正态性检验之类的分析测试来验证:

> shapiro.test(JFK$TaxiIn)

 Shapiro-Wilk normality test

data:  JFK$TaxiIn
W = 0.8387, p-value < 2.2e-16

p 值非常小,因此零假设(即数据是正态分布的)被拒绝。但如果没有以及超出单独的统计检验,我们如何测试一串变量的正态性呢?

多元正态性

对于多个变量也存在类似的统计检验;这些方法提供了不同的方式来检查数据是否符合多元正态分布。为此,我们将使用MVN包,但类似的方法也可以在mvnormtest包中找到。后者包括之前讨论过的 Shapiro-Wilk 检验的多变量版本。

但 Mardia 的检验更常用于检查多元正态性,而且更好,它不限制样本量低于 5,000。在加载了MVN包之后,调用适当的 R 函数相当直接,并且具有非常直观的解释——在清除我们数据集中的缺失值之后:

> JFK <- na.omit(JFK)
> library(MVN)
> mardiaTest(JFK)
 Mardia's Multivariate Normality Test 
--------------------------------------- 
 data : JFK 

 g1p            : 20.84452 
 chi.skew       : 2351.957 
 p.value.skew   : 0 

 g2p            : 46.33207 
 z.kurtosis     : 124.6713 
 p.value.kurt   : 0 

 chi.small.skew : 2369.368 
 p.value.small  : 0 

 Result          : Data is not multivariate normal. 
---------------------------------------

提示

关于处理和过滤缺失值的更多细节,请参阅第八章,精炼数据

在三个 p 值中,第三个指的是样本量极小(<20)的情况,所以现在我们只关注前两个值,两者都低于 0.05。这意味着数据似乎不是多元正态的。不幸的是,Mardia 的检验在某些情况下表现不佳,因此可能更适合使用更稳健的方法。

MVN 包还可以运行 Henze-Zirkler 和 Royston 的多元正态性检验。两者都返回用户友好且易于解释的结果:

> hzTest(JFK)
 Henze-Zirkler's Multivariate Normality Test 
--------------------------------------------- 
 data : JFK 

 HZ      : 42.26252 
 p-value : 0 

 Result  : Data is not multivariate normal. 
--------------------------------------------- 

> roystonTest(JFK)
 Royston's Multivariate Normality Test 
--------------------------------------------- 
 data : JFK 

 H       : 264.1686 
 p-value : 4.330916e-58 

 Result  : Data is not multivariate normal. 
---------------------------------------------

更直观地测试多元正态性的方法是绘制与之前使用的类似的 Q-Q 图。但是,我们不仅仅比较一个变量与理论正态分布,而是首先计算我们变量之间的平方马氏距离,它应该遵循具有自由度为变量数量的卡方分布。MVN 包可以自动计算所有必需的值,并使用任何前面的正态性检验 R 函数渲染这些值;只需将 qqplot 参数设置为 TRUE

> mvt <- roystonTest(JFK, qqplot = TRUE)

多元正态性

如果数据集是正态分布的,前面图形中显示的点应该适合直线。其他替代的图形方法可以使用之前创建的 mvt R 对象生成更直观和用户友好的图形。MVN 包提供了 mvnPlot 函数,它可以渲染两个变量的透视和等高线图,从而为测试双变量正态性提供了一种很好的方法:

> par(mfrow = c(1, 2))
> mvnPlot(mvt, type = "contour", default = TRUE)
> mvnPlot(mvt, type = "persp", default = TRUE)

多元正态性

在右侧的图中,你可以看到两个变量在透视图上的经验分布,其中大多数情况都位于左下角。这意味着大多数航班只有相对较短的 TaxiInTaxiOut 时间,这表明分布有较重的尾部。左侧的图显示了类似的情况,但以鸟瞰图的形式:等高线代表右手边 3D 图的横截面。多元正态分布看起来更集中,类似于二维的钟形曲线:

> set.seed(42)
> mvt <- roystonTest(MASS::mvrnorm(100, mu = c(0, 0),
+          Sigma = matrix(c(10, 3, 3, 2), 2)))
> mvnPlot(mvt, type = "contour", default = TRUE)
> mvnPlot(mvt, type = "persp", default = TRUE)

多元正态性

查看第十三章,我们周围的数据,了解如何在空间数据上创建类似的等高线图。

变量的依赖性

除了正态性之外,在应用降维方法时,还希望相对较高的相关系数。原因是,如果没有变量之间的统计关系,例如,PCA 将返回没有太多变换的精确相同值。

为了达到这个目的,让我们看看 hflights 数据集中的数值变量是如何相关的(由于输出是一个大型矩阵,这次将其省略):

> hflights_numeric <- hflights[, which(sapply(hflights, is.numeric))]
> cor(hflights_numeric, use = "pairwise.complete.obs")

在前面的例子中,我们创建了一个新的 R 对象,仅包含原始 hflights 数据框的数值列,省略了五个字符向量。然后,我们使用成对删除缺失值的方式运行 cor,返回一个 16 列 16 行的矩阵:

> str(cor(hflights_numeric, use = "pairwise.complete.obs"))
 num [1:16, 1:16] NA NA NA NA NA NA NA NA NA NA ...
 - attr(*, "dimnames")=List of 2
 ..$ : chr [1:16] "Year" "Month" "DayofMonth" "DayOfWeek" ...
 ..$ : chr [1:16] "Year" "Month" "DayofMonth" "DayOfWeek" ...

结果相关矩阵中的缺失值数量似乎非常高。这是因为所有情况下的Year都是 2011 年,因此导致标准差为零。明智的做法是将Year以及非数值变量从数据集中排除——不仅通过过滤数值值,还要检查方差:

> hflights_numeric <- hflights[,which(
+     sapply(hflights, function(x)
+         is.numeric(x) && var(x, na.rm = TRUE) != 0))]

现在缺失值的数量要低得多:

> table(is.na(cor(hflights_numeric, use = "pairwise.complete.obs")))
FALSE  TRUE 
 209    16

尽管进行了成对删除缺失值的操作,但您能猜到为什么这里仍然有一些缺失值吗?嗯,运行前面的命令会产生一个相当有用的警告,但我们将稍后回到这个问题:

Warning message:
In cor(hflights_numeric, use = "pairwise.complete.obs") :
 the standard deviation is zero

现在我们来分析 15x15 相关矩阵中的实际数字,这个矩阵太大,无法在这本书中打印出来。为此,我们没有展示之前提到的原始cor命令的结果,而是用ellipse包的图形功能来可视化这 225 个数字:

> library(ellipse)
> plotcorr(cor(hflights_numeric, use = "pairwise.complete.obs"))

变量依赖性

现在我们看到相关矩阵的值通过椭圆表示,其中:

  • 完美的圆圈代表零相关系数

  • 面积较小的椭圆反映了相关系数与零之间的相对较大距离

  • 正切代表系数的负/正符号

为了帮助您分析前面的结果,让我们绘制一个具有一些人工生成的、更容易解释的数字的类似图表:

> plotcorr(cor(data.frame(
+     1:10,
+     1:10 + runif(10),
+     1:10 + runif(10) * 5,
+     runif(10),
+     10:1,
+     check.names = FALSE)))

变量依赖性

可以使用corrgram包在相关矩阵上创建类似的图表。

但让我们回到hflights数据集!在前面的图表中,为时间相关的变量绘制了一些狭窄的椭圆,这表明相关系数相对较高,甚至Month变量似乎与FlightNum函数有轻微的关联:

> cor(hflights$FlightNum, hflights$Month)
[1] 0.2057641

另一方面,大多数情况下,图表显示的是完美的圆圈,这代表相关系数约为零。这表明大多数变量之间根本不相关,因此由于共同方差的比例较低,计算原始数据集的主成分可能不会很有帮助。

KMO 和巴特利特检验

我们可以通过多种统计测试来验证低共同度假设;例如,SAS 和 SPSS 用户倾向于使用 KMO 或巴特利特检验来查看数据是否适合主成分分析。这两种算法在 R 中也可以使用,例如通过psych包:

> library(psych)
> KMO(cor(hflights_numeric, use = "pairwise.complete.obs"))
Error in solve.default(r) : 
 system is computationally singular: reciprocal condition number = 0
In addition: Warning message:
In cor(hflights_numeric, use = "pairwise.complete.obs") :
 the standard deviation is zero
matrix is not invertible, image not found
Kaiser-Meyer-Olkin factor adequacy
Call: KMO(r = cor(hflights_numeric, use = "pairwise.complete.obs"))
Overall MSA = NA
MSA for each item = 
 Month    DayofMonth     DayOfWeek 
 0.5        0.5        0.5 
 DepTime      ArrTime     FlightNum 
 0.5        NA        0.5 
ActualElapsedTime      AirTime     ArrDelay 
 NA        NA        NA 
 DepDelay     Distance      TaxiIn 
 0.5        0.5        NA 
 TaxiOut     Cancelled     Diverted 
 0.5        NA        NA

不幸的是,由于之前识别的相关矩阵缺失值,前述输出中不可用“总体 MSA”(抽样充分性度量,表示变量之间的平均相关系数)。让我们选择一对变量,其中相关系数为NA,以进行进一步分析!这样的对可以从之前的图中轻松识别;例如,对于“取消”和AirTime,没有绘制圆圈或椭圆:

> cor(hflights_numeric[, c('Cancelled', 'AirTime')])
 Cancelled AirTime
Cancelled         1      NA
AirTime          NA       1

这可以通过以下事实来解释,即如果航班被取消,那么在空中度过的时间变化不大;此外,这些数据不可用:

> cancelled <- which(hflights_numeric$Cancelled == 1)
> table(hflights_numeric$AirTime[cancelled], exclude = NULL)
<NA> 
2973

因此,当我们调用cor时由于这些NA而得到缺失值;同样,当我们使用成对删除调用cor时也会得到NA,因为数据集中只剩下未取消的航班,导致“取消”变量的方差为零:

> table(hflights_numeric$Cancelled)
 0      1 
224523   2973

这表明在运行之前讨论的假设测试之前,我们应该从数据集中删除“取消”变量,因为该变量中存储的信息在其他数据集的列中也是冗余可用的。或者换句话说,“取消”列可以通过其他列的线性变换来计算,这些列可以省略在进一步分析中:

> hflights_numeric <- subset(hflights_numeric, select = -Cancelled)

让我们看看在相关矩阵中是否还有任何缺失值:

> which(is.na(cor(hflights_numeric, use = "pairwise.complete.obs")),
+   arr.ind = TRUE)
 row col
Diverted           14   7
Diverted           14   8
Diverted           14   9
ActualElapsedTime   7  14
AirTime             8  14
ArrDelay            9  14

看起来“改道”列是造成类似情况的原因,其他三个变量在航班改道时不可用。经过另一个子集后,我们现在可以调用 KMO 来对完整的相关矩阵进行分析:

> hflights_numeric <- subset(hflights_numeric, select = -Diverted)
> KMO(cor(hflights_numeric[, -c(14)], use = "pairwise.complete.obs"))
Kaiser-Meyer-Olkin factor adequacy
Call: KMO(r = cor(hflights_numeric[, -c(14)], use = "pairwise.complete.obs"))
Overall MSA =  0.36
MSA for each item = 
 Month        DayofMonth         DayOfWeek 
 0.42              0.37              0.35 
 DepTime           ArrTime         FlightNum 
 0.51              0.49              0.74 
ActualElapsedTime           AirTime          ArrDelay 
 0.40              0.40              0.39 
 DepDelay          Distance            TaxiIn 
 0.38              0.67              0.06 
 TaxiOut 
 0.06

“总体 MSA”,或所谓的卡特尔-梅耶-奥金KMO)指数,是一个介于 0 和 1 之间的数字;这个值表明变量的部分相关系数是否足够小,可以继续使用数据降维方法。KMO 的一般评价体系或经验法则可以在以下表格中找到,如凯撒建议:

描述
KMO < 0.5 不可接受
0.5 < KMO < 0.6 糟糕
0.6 < KMO < 0.7 一般
0.7 < KMO < 0.8 中等
0.8 < KMO < 0.9 优秀
KMO > 0.9 极佳

KMO 指数低于 0.5 被认为是不可接受的,这基本上意味着从相关矩阵计算出的部分相关系数表明变量之间的相关性不足以进行有意义的降维或潜在变量模型。

虽然省略一些具有最低 MSA 的变量可以提高“总体 MSA”,我们可以在接下来的几页中构建一些适当的模型,但出于教学目的,我们目前不会在数据转换上花费更多时间,我们将使用在第三章中介绍的mtcars数据集,过滤和汇总数据

> KMO(mtcars)
Kaiser-Meyer-Olkin factor adequacy
Call: KMO(r = mtcars)
Overall MSA =  0.83
MSA for each item = 
 mpg  cyl disp   hp drat   wt qsec   vs   am gear carb 
0.93 0.90 0.76 0.84 0.95 0.74 0.74 0.91 0.88 0.85 0.62

看起来,mtcars数据库是多元统计分析的一个很好的选择。这也可以通过所谓的 Bartlett 测试来验证,该测试建议是否协方差矩阵与单位矩阵相似。或者换句话说,如果变量之间存在统计关系。另一方面,如果协方差矩阵除了对角线外只有零,那么变量之间是独立的;因此,考虑多元方法就没有太多意义了。《psych》包提供了一个易于使用的函数来计算 Bartlett 测试:

> cortest.bartlett(cor(mtcars))
$chisq
[1] 1454.985

$p.value
[1] 3.884209e-268

$df
[1] 
55

非常低的p-value表明我们拒绝了 Bartlett 测试的零假设。这意味着协方差矩阵与单位矩阵不同,因此变量之间的相关系数似乎比 0 更接近于 1。这与高 KMO 值是一致的。

注意

在专注于实际的统计方法之前,请务必注意,尽管前面的假设在大多数情况下是有意义的,并且应该作为经验法则遵循,但 KMO 和 Bartlett 测试并不总是必需的。高共同性对于因子分析和其他潜在模型很重要,而例如 PCA 是一种数学变换,即使 KMO 值较低也能工作。

主成分分析

在具有大量变量的数据库中找到真正重要的字段可能对数据科学家来说是一项具有挑战性的任务。这就是主成分分析PCA)发挥作用的地方:找到数据的核心成分。它是由 Karl Pearson 在 100 多年前发明的,自那时以来,它已经在各个领域得到了广泛的应用。

PCA 的目标是借助正交变换,以更有意义的结构解释数据。这种线性变换旨在通过在向量空间中任意设计的新基中揭示数据集的内部结构,以最佳地解释数据的方差。用简单的话说,这仅仅意味着我们从原始数据中计算新的变量,这些新变量按降序包含原始变量的方差。

这可以通过协方差矩阵的特征分解(所谓的 R 模式 PCA)或数据集的奇异值分解(所谓的 Q 模式 PCA)来完成。每种方法都有很大的优势,例如计算性能、内存需求,或者简单地避免在使用协方差矩阵进行特征分解时,在将数据传递给 PCA 之前对数据进行预先标准化。

无论哪种方式,PCA 都可以成功地将数据的低维图像传递出去,其中不相关的主成分是原始变量的线性组合。这个信息概览对于分析员在识别变量的潜在结构时非常有帮助;因此,这种技术经常被用于探索性数据分析。

PCA 产生的提取组件数量与原始变量完全相同。第一个组件包括大部分共同方差,因此在描述原始数据集时具有最高的重要性,而最后一个组件通常只包括来自一个原始变量的某些独特信息。基于这一点,我们通常会只保留 PCA 的前几个组件进行进一步分析,但我们也会看到一些专注于提取独特方差的用例。

PCA 算法

R 提供了多种函数来运行 PCA。尽管可以通过eigensvd手动计算 R 模式或 Q 模式的组件,但为了简化,我们将关注高级函数。凭借我的统计学教师背景,我认为有时集中精力分析如何运行和解释结果比花大量时间在线性代数背景上更有效率——尤其是在给定的时间/页面限制下。

R 模式 PCA 可以通过psych包中的princompprincipal进行,而更受欢迎的 Q 模式 PCA 可以通过prcomp调用。现在让我们专注于后者,看看mtcars的组件是什么样的:

> prcomp(mtcars, scale = TRUE)
Standard deviations:
 [1] 2.57068 1.62803 0.79196 0.51923 0.47271 0.46000 0.36778 0.35057
 [9] 0.27757 0.22811 0.14847

Rotation:
 PC1       PC2       PC3        PC4       PC5       PC6
mpg   -0.36253  0.016124 -0.225744 -0.0225403  0.102845 -0.108797
cyl    0.37392  0.043744 -0.175311 -0.0025918  0.058484  0.168554
disp   0.36819 -0.049324 -0.061484  0.2566079  0.393995 -0.336165
hp     0.33006  0.248784  0.140015 -0.0676762  0.540047  0.071436
drat  -0.29415  0.274694  0.161189  0.8548287  0.077327  0.244497
wt     0.34610 -0.143038  0.341819  0.2458993 -0.075029 -0.464940
qsec  -0.20046 -0.463375  0.403169  0.0680765 -0.164666 -0.330480
vs    -0.30651 -0.231647  0.428815 -0.2148486  0.599540  0.194017
am    -0.23494  0.429418 -0.205767 -0.0304629  0.089781 -0.570817
gear  -0.20692  0.462349  0.289780 -0.2646905  0.048330 -0.243563
carb   0.21402  0.413571  0.528545 -0.1267892 -0.361319  0.183522
 PC7        PC8       PC9      PC10      PC11
mpg   0.367724 -0.7540914  0.235702  0.139285 -0.1248956
cyl   0.057278 -0.2308249  0.054035 -0.846419 -0.1406954
disp  0.214303  0.0011421  0.198428  0.049380  0.6606065
hp   -0.001496 -0.2223584 -0.575830  0.247824 -0.2564921
drat  0.021120  0.0321935 -0.046901 -0.101494 -0.0395302
wt   -0.020668 -0.0085719  0.359498  0.094394 -0.5674487
qsec  0.050011 -0.2318400 -0.528377 -0.270673  0.1813618
vs   -0.265781  0.0259351  0.358583 -0.159039  0.0084146
am   -0.587305 -0.0597470 -0.047404 -0.177785  0.0298235
gear  0.605098  0.3361502 -0.001735 -0.213825 -0.0535071
carb -0.174603 -0.3956291  0.170641  0.072260  0.3195947

注意

请注意,我们已经将prcompscale设置为TRUE,这是由于与 S 语言的向后兼容性而默认为FALSE。但一般来说,缩放是非常推荐的。使用缩放选项相当于在先前缩放数据集后运行 PCA,例如:prcomp(scale(mtcars)),这将产生具有单位方差的数值。

首先,prcomp返回了主成分的标准差,这显示了 11 个组件保留了多少信息。第一个组件的标准差远大于任何后续值,解释了超过 60%的方差:

> summary(prcomp(mtcars, scale = TRUE))
Importance of components:
 PC1   PC2   PC3    PC4    PC5    PC6    PC7
Standard deviation     2.571 1.628 0.792 0.5192 0.4727 0.4600 0.3678
Proportion of Variance 0.601 0.241 0.057 0.0245 0.0203 0.0192 0.0123
Cumulative Proportion  0.601 0.842 0.899 0.9232 0.9436 0.9628 0.9751
 PC8   PC9    PC10  PC11
Standard deviation     0.3506 0.278 0.22811 0.148
Proportion of Variance 0.0112 0.007 0.00473 0.002
Cumulative Proportion  0.9863 0.993 0.99800 1.000

除了第一个组件外,只有第二个组件的标准差高于 1,这意味着只有前两个组件至少包含与原始变量一样多的信息。或者换句话说:只有前两个变量的特征值高于 1。特征值可以通过主成分标准差的平方来计算,总数应与原始变量的数量一致:

> sum(prcomp(scale(mtcars))$sdev²)
[1] 11

确定组件数量

PCA 算法总是计算与原始数据集中变量数量相同数量的主成分。组件的重要性从第一个到最后一个逐渐降低。

作为经验法则,我们可以简单地保留所有标准差高于 1 的组件。这意味着我们保留了那些至少解释了与原始变量一样多的方差的组件:

> prcomp(scale(mtcars))$sdev²
 [1] 6.608400 2.650468 0.627197 0.269597 0.223451 0.211596 0.135262
 [8] 0.122901 0.077047 0.052035 0.022044

因此,前面的总结建议只保留 11 个组件中的两个,这几乎解释了 85%的方差:

> (6.6 + 2.65) / 11
[1] 0.8409091

一个帮助我们确定最佳成分数量的替代且优秀的可视化工具是 scree 图。幸运的是,在psych包中至少有两个优秀的函数可以在这里使用:screeVSS.scree函数:

> VSS.scree(cor(mtcars))

确定成分数量

> scree(cor(mtcars))

确定成分数量

前两个图之间的唯一区别是scree除了 PCA 之外还显示了因子分析的特征值。关于这一点,请参阅本章下一节的相关内容。

如所示,VSS.scree提供了主成分特征值的视觉概述,并且通过一条水平线突出了 1 的临界值。这通常被称为 Kaiser 标准。

除了这个经验法则之外,正如之前讨论的那样,人们还可以依赖所谓的“肘部法则”,它简单地说,线图代表一条臂,最佳成分数量是这条臂的肘部所在的位置。因此,我们必须寻找曲线变得不那么陡峭的点。在这种情况下,这个尖锐的转折可能是在 3 而不是 2,正如我们使用 Kaiser 标准所发现的那样。

除了 Cattell 的原始 scree 测试之外,我们还可以将之前描述的scree与一些随机数据比较,以确定要保留的最佳成分数量:

> fa.parallel(mtcars)

确定成分数量

Parallel analysis suggests that the number of factors = 2 
and the number of components =  2

现在我们已经验证了用于进一步分析的保留主成分的最佳数量,我们可以只使用两个变量而不是 11 个,这真是太好了!但人工创建的这些变量实际上意味着什么呢?

解释成分

减少我们数据维度的问题在于,发现我们新创建的、高度压缩和转换的数据实际上是什么可能会非常令人沮丧。现在我们有PC1PC2用于我们的 32 辆汽车:

> pc <- prcomp(mtcars, scale = TRUE)
> head(pc$x[, 1:2])
 PC1      PC2
Mazda RX4         -0.646863  1.70811
Mazda RX4 Wag     -0.619483  1.52562
Datsun 710        -2.735624 -0.14415
Hornet 4 Drive    -0.306861 -2.32580
Hornet Sportabout  1.943393 -0.74252
Valiant           -0.055253 -2.74212

这些值是通过将原始数据集与识别的权重(所谓的负载或旋转)或成分矩阵相乘得到的。这是一个标准的线性变换:

> head(scale(mtcars) %*% pc$rotation[, 1:2])
 PC1      PC2
Mazda RX4         -0.646863  1.70811
Mazda RX4 Wag     -0.619483  1.52562
Datsun 710        -2.735624 -0.14415
Hornet 4 Drive    -0.306861 -2.32580
Hornet Sportabout  1.943393 -0.74252
Valiant           -0.055253 -2.74212

两个变量都经过缩放,均值为零,标准差如前所述:

> summary(pc$x[, 1:2])
 PC1              PC2 
 Min.   :-4.187   Min.   :-2.742 
 1st Qu.:-2.284   1st Qu.:-0.826 
 Median :-0.181   Median :-0.305 
 Mean   : 0.000   Mean   : 0.000 
 3rd Qu.: 2.166   3rd Qu.: 0.672 
 Max.   : 3.892   Max.   : 4.311 
> apply(pc$x[, 1:2], 2, sd)
 PC1    PC2 
2.5707 1.6280 
> pc$sdev[1:2]
[1] 2.5707 1.6280

PCA 计算的所有得分都是经过缩放的,因为它总是返回转换到新坐标系(具有正交基)的值,这意味着成分之间不相关且已缩放:

> round(cor(pc$x))
 PC1 PC2 PC3 PC4 PC5 PC6 PC7 PC8 PC9 PC10 PC11
PC1    1   0   0   0   0   0   0   0   0    0    0
PC2    0   1   0   0   0   0   0   0   0    0    0
PC3    0   0   1   0   0   0   0   0   0    0    0
PC4    0   0   0   1   0   0   0   0   0    0    0
PC5    0   0   0   0   1   0   0   0   0    0    0
PC6    0   0   0   0   0   1   0   0   0    0    0
PC7    0   0   0   0   0   0   1   0   0    0    0
PC8    0   0   0   0   0   0   0   1   0    0    0
PC9    0   0   0   0   0   0   0   0   1    0    0
PC10   0   0   0   0   0   0   0   0   0    1    0
PC11   0   0   0   0 
0   0   0   0   0    0    1

要了解主成分实际上意味着什么,检查负载矩阵非常有帮助,正如我们之前所看到的:

> pc$rotation[, 1:2]
 PC1       PC2
mpg  -0.36253  0.016124
cyl   0.37392  0.043744
disp  0.36819 -0.049324
hp    0.33006  0.248784
drat -0.29415  0.274694
wt    0.34610 -0.143038
qsec -0.20046 -0.463375
vs   -0.30651 -0.231647
am   -0.23494  0.429418
gear -0.20692  0.462349
carb  0.21402  0.413571

可能这个分析表在某些视觉方式上可能更有意义,例如作为一个biplot,它不仅显示了原始变量,还显示了基于主成分(红色标签)的新坐标系上的观测值(黑色标签):

> biplot(pc, cex = c(0.8, 1.2))
> abline(h = 0, v = 0, lty = 'dashed')

解释成分

我们可以得出结论,PC1主要包含来自气缸数(cyl)、排量(disp)、重量(wt)和油耗(mpg)的信息,尽管后者可能降低PC1的值。这是通过检查PC1轴上的最高和最低值发现的。同样,我们发现PC2是由加速(qsec)、档位数(gear)、化油器(carb)和传动类型(am)构成的。

为了验证这一点,我们可以轻松地计算原始变量和主成分之间的相关系数:

> cor(mtcars, pc$x[, 1:2])
 PC1       PC2
mpg  -0.93195  0.026251
cyl   0.96122  0.071216
disp  0.94649 -0.080301
hp    0.84847  0.405027
drat -0.75617  0.447209
wt    0.88972 -0.232870
qsec -0.51531 -0.754386
vs   -0.78794 -0.377127
am   -0.60396  0.699103
gear -0.53192  0.752715
carb  0.55017  0.673304

这有意义吗?你会如何命名PC1PC2?气缸数和排量看起来像是发动机参数,而重量可能更多地受到车身的影响。油耗应该受这两个规格的影响。其他组件的变量处理悬挂,但我们也有速度,更不用说前面矩阵中一串平庸的相关系数了。现在怎么办?

旋转方法

基于旋转方法是在子空间中进行的这一事实,旋转总是比之前讨论的 PCA 次优。这意味着旋转后的新轴将解释的方差少于原始组件。

另一方面,旋转简化了组件的结构,因此使得理解和解释结果变得更加容易;因此,这些方法在实践中经常被使用。

注意

旋转方法可以(并且通常是)应用于 PCA 和 FA(关于这一点稍后还会讨论)。正交方法更受欢迎。

旋转主要有两种类型:

  • 正交,其中新轴相互垂直。组件/因子之间没有相关性。

  • 斜交,其中新轴不一定相互垂直;因此,变量之间可能存在一些相关性。

Varimax 旋转是最受欢迎的旋转方法之一。它由凯撒于 1958 年开发,并且自那时起一直很受欢迎。它经常被使用,因为该方法最大化了载荷矩阵的方差,从而得到更可解释的得分:

> varimax(pc$rotation[, 1:2])
$loadings
 PC1    PC2 
mpg  -0.286 -0.223
cyl   0.256  0.276
disp  0.312  0.201
hp           0.403
drat -0.402 
wt    0.356  0.116
qsec  0.148 -0.483
vs          -0.375
am   -0.457  0.174
gear -0.458  0.217
carb -0.106  0.454

 PC1   PC2
SS loadings    1.000 1.000
Proportion Var 0.091 0.091
Cumulative Var 0.091 0.182

$rotmat
 [,1]    [,2]
[1,]  0.76067 0.64914
[2,] -0.64914 0.76067

现在第一个成分似乎主要受到传动类型、档位数和后轴比(negatively dominated)的影响,而第二个成分则受到加速、马力和化油器数量的影响。这表明将PC2命名为power,而PC1则指代transmission。让我们看看在这个新坐标系中的这 32 辆汽车:

> pcv <- varimax(pc$rotation[, 1:2])$loadings
> plot(scale(mtcars) %*% pcv, type = 'n',
+     xlab = 'Transmission', ylab = 'Power')
> text(scale(mtcars) %*% pcv, labels = rownames(mtcars))

旋转方法

基于前面的图,每个数据科学家都应该从左上象限选择一辆车来搭配顶级车型,对吧?这些车在y轴上拥有强大的动力,在x轴上显示了良好的传动系统——不要忘记传动与原始变量呈负相关。但让我们看看其他旋转方法及其优势吧!

Quartimax 旋转也是一种正交方法,它最小化了解释每个变量所需组件的数量。这通常会导致一个一般成分和额外的较小成分。当需要 Varimax 和 Quartimax 旋转方法之间的折衷方案时,你可能选择 Equimax 旋转。

斜旋转方法包括 Oblimin 和 Promax,这些方法在基本统计或高度使用的psych包中不可用。相反,我们可以加载GPArotation包,它为 PCA 和 FA 提供了广泛的旋转方法。为了演示目的,让我们看看 Promax 旋转是如何工作的,它比例如 Oblimin 快得多:

> library(GPArotation)
> promax(pc$rotation[, 1:2])
$loadings

Loadings:
 PC1    PC2 
mpg  -0.252 -0.199
cyl   0.211  0.258
disp  0.282  0.174
hp           0.408
drat -0.416 
wt    0.344 
qsec  0.243 -0.517
vs          -0.380
am   -0.502  0.232
gear -0.510  0.276
carb -0.194  0.482

 PC1   PC2
SS loadings    1.088 1.088
Proportion Var 0.099 0.099
Cumulative Var 0.099 0.198

$rotmat
 [,1]    [,2]
[1,]  0.65862 0.58828
[2,] -0.80871 0.86123

> cor(promax(pc$rotation[, 1:2])$loadings)
 PC1      PC2
PC1  1.00000 -0.23999
PC2 -0.23999  1.00000

最后一条命令的结果支持这样的观点:斜旋转方法生成的分数可能相关,这与运行正交旋转时不同。

PCA 中的异常值检测

PCA 除了用于探索性数据分析之外,还可以用于各种目标。例如,我们可以使用 PCA 生成特征脸、压缩图像、分类观察结果,或通过图像过滤在多维空间中检测异常值。现在,我们将构建一个在 2012 年 R-bloggers 上发布的相关研究帖子中讨论的简化模型:www.r-bloggers.com/finding-a-pin-in-a-haystack-pca-image-filtering

帖子中描述的挑战是在火星上由好奇号漫游车拍摄的沙子照片中检测到异物。该图像可以在官方 NASA 网站上找到,网址为www.nasa.gov/images/content/694811main_pia16225-43_full.jpg,我为未来使用创建了缩短的 URL:bit.ly/nasa-img

在以下图像中,你可以看到一个在沙子中用黑色圆圈突出显示的奇怪金属物体,以确保你知道我们在寻找什么。在前面 URL 中找到的图像没有这个突出显示:

PCA 中的异常值检测

现在让我们使用一些统计方法来识别该物体,而不需要(太多)人为干预!首先,我们需要从互联网上下载图像并将其加载到 R 中。jpeg包在这里将非常有帮助:

>
 library(jpeg)
> t <- tempfile()
> download.file('http://bit.ly/nasa-img', t)
trying URL 'http://bit.ly/nasa-img'
Content type 'image/jpeg' length 853981 bytes (833 Kb)
opened URL
==================================================
downloaded 833 Kb

>
 img <- readJPEG(t)
> str(img)
 num [1:1009, 1:1345, 1:3] 0.431 0.42 0.463 0.486 0.49 ...

readJPEG函数返回图片中每个像素的 RGB 值,结果是一个三维数组,其中第一个维度是行,第二个维度是列,第三个维度包括三个颜色值。

注意

RGB 是一种加色模型,可以通过给定强度和可选透明度混合红色、绿色和蓝色来重现各种颜色。这种颜色模型在计算机科学中高度使用。

由于 PCA 需要一个矩阵作为输入,我们必须将这个三维数组转换为二维数据集。为此,让我们暂时不要担心像素的顺序,因为我们稍后可以重建它,但让我们简单地列出所有像素的 RGB 值,一个接一个:

> h <- dim(img)[1]
> w <- dim(img)[2]
> m <- matrix(img, h*w)
> str(m)
 num [1:1357105, 1:3] 0.431 0.42 0.463 0.486 0.49 ...

简而言之,我们将图像的原始高度(以像素为单位)保存在变量h中,将宽度保存在w中,然后将 3D 数组转换为具有 1,357,105 行的矩阵。然后,经过四行数据加载和三行数据转换,我们最后可以调用实际的、相对简化的统计方法:

> pca <- prcomp(m)

正如我们之前看到的,数据科学家确实大多数时间都在处理数据准备,而实际的数据分析可以轻松完成,对吧?

提取的成分似乎表现相当好;第一个成分解释了超过 96%的方差:

> summary(pca)
Importance of components:
 PC1    PC2     PC3
Standard deviation     0.277 0.0518 0.00765
Proportion of Variance 0.965 0.0338 0.00074
Cumulative Proportion  0.965 0.9993 1.00000

以前,解释 RGB 值相当直接,但这些成分又意味着什么呢?

> pca$rotation
 PC1      PC2      PC3
[1,] -0.62188  0.71514  0.31911
[2,] -0.57409 -0.13919 -0.80687
[3,] -0.53261 -0.68498  0.49712

看起来第一个成分与所有三种颜色混合得相当多,第二个成分缺少绿色,而第三个成分几乎只包含绿色。为什么不可视化这些人工值,而不是试图想象它们看起来如何呢?为此,让我们通过以下快速辅助函数从先前的成分/加载矩阵中提取颜色强度:

> extractColors <- function(x)
+     rgb(x[1], x[2], x[3])

在成分矩阵的绝对值上调用此方法会产生描述主成分的十六进制颜色代码:

> (colors <- apply(abs(pca$rotation), 2, extractColors))
 PC1       PC2       PC3 
"#9F9288" "#B623AF" "#51CE7F"

这些颜色代码可以轻松渲染——例如,在饼图中,饼的面积代表主成分的解释方差:

> pie(pca$sdev, col = colors, labels = colors)

PCA 中的异常值检测

现在我们不再有存储在pca$x中的红色、绿色或蓝色强度或实际颜色,而是主成分用之前显示的可视化颜色描述每个像素。正如之前讨论的,第三个成分代表绿色色调,第二个成分缺少绿色(导致紫色),而第一个成分包含来自所有 RGB 颜色的相当高的值,结果产生棕黄色调,这在知道照片是在火星沙漠中拍摄的时并不令人惊讶。

现在我们可以用单色颜色渲染原始图像,以显示主成分的强度。以下几行代码基于PC1PC2生成了好奇号及其环境的两张修改后的照片:

> par(mfrow = c(1, 2), mar = rep(0, 4))
> image(matrix(pca$x[, 1], h), col = gray.colors(100))
> image(matrix(pca$x[, 2], h), col = gray.colors(100), yaxt = 'n')

PCA 中的异常值检测

虽然图像在一些线性变换中被旋转了 90 度,但很明显,第一张图像在寻找沙子中的异物方面并没有真正有帮助。事实上,这张图像代表了沙漠地区的噪声,因为PC1包含了类似沙子的颜色强度,所以这个成分对于描述棕黄色调的多样性是有用的。

另一方面,第二个成分很好地突出了沙子中的金属物体!由于正常沙子中紫色比例低,所有周围的像素都很暗,而异常物体则相当暗。

我真的很喜欢这段 R 代码和简化的例子:虽然它们仍然足够基本以便于理解,但它们也展示了 R 的力量以及如何使用标准数据分析方法从原始数据中提取信息。

因子分析

尽管关于验证性因子分析FA)的文献非常令人印象深刻,并且在社会科学等领域被广泛使用,但我们将只关注探索性 FA,我们的目标是根据其他经验数据识别一些未知、未观察到的变量。

因子分析的潜在变量模型最早由 Spearman 于 1904 年提出,用于一个因子,然后在 1947 年 Thurstone 将该模型推广到多个因子。这个统计模型假设数据集中可用的显变量是未观察到的潜在变量的结果,这些潜在变量可以通过观察数据追踪。

FA 可以处理连续(数值)变量,并且模型表明每个观察到的变量是某些未知、潜在因子的总和。

注意

请注意,在执行 FA 之前,检查正态性、KMO 和 Bartlett 的测试比 PCA 更重要;后者是一种描述性方法,而在 FA 中,我们实际上是在构建一个模型。

最常用的探索性因子分析(FA)方法是最大似然 FA,这在已安装的stats包中的factanal函数中也是可用的。其他分解方法由psych包中的fa函数提供,例如普通最小二乘法OLS)、加权最小二乘法WLS)、广义加权最小二乘法GLS)或主因子解。这些函数以原始数据或协方差矩阵作为输入。

为了演示目的,让我们看看默认的分解方法在mtcars的一个子集上的表现。让我们提取所有与性能相关的变量,除了排量,因为排量可能负责所有其他相关指标:

> m <- subset(mtcars, select = c(mpg, cyl, hp, carb))

现在只需调用并保存前面data.frame上的fa结果:

> (f <- fa(m))
Factor Analysis using method =  minres
Call: fa(r = m)
Standardized loadings (pattern matrix) based upon correlation matrix
 MR1   h2   u2 com
mpg  -0.87 0.77 0.23   1
cyl   0.91 0.83 0.17   1
hp    0.92 0.85 0.15   1
carb  0.69 0.48 0.52   1

 MR1
SS loadings    2.93
Proportion Var 0.73

Mean item complexity =  1
Test of the hypothesis that 1 factor is sufficient.

The degrees of freedom for the null model are  6 
and the objective function was  3.44 with Chi Square of  99.21
The degrees of freedom for the model are 2
and the objective function was  0.42 

The root mean square of the residuals (RMSR) is  0.07 
The df corrected root mean square of the residuals is  0.12 

The harmonic number of observations is  32
with the empirical chi square  1.92  with prob <  0.38 
The total number of observations was  32
with MLE Chi Square =  11.78  with prob <  0.0028 

Tucker Lewis Index of factoring reliability =  0.677
RMSEA index =  0.42
and the 90 % confidence intervals are  0.196 0.619
BIC =  4.84
Fit based upon off diagonal values = 0.99
Measures of factor score adequacy 
 MR1
Correlation of scores with factors             0.97
Multiple R square of scores with factors       0.94
Minimum correlation of possible factor scores  0.87

嗯,这是一份相当令人印象深刻的信息量,包含了很多细节!MR1代表第一个提取的因子,该因子以默认的分解方法(最小残差或 OLS)命名。由于模型中只包含一个因子,因此因子旋转不是一个选项。有一个测试或假设来检查因子的数量是否足够,并且一些系数代表了一个非常好的模型拟合。

结果可以在以下图表中总结:

> fa.diagram(f)

因子分析

我们可以看到潜在变量和观察变量之间的高相关系数,箭头的方向表明因子对我们的经验数据集中找到的值有影响。猜测这个因子与汽车发动机位移之间的关系!

> cor(f$scores, mtcars$disp)
0.87595

嗯,这似乎是一个很好的匹配。

主成分分析 versus 因子分析

不幸的是,主成分经常与因子混淆,这两个术语及其相关方法有时被用作同义词,尽管这两种方法的数学背景和目标实际上是非常不同的。

主成分分析(PCA)通过创建主成分来减少变量的数量,这些主成分可以用于后续项目,而不是原始变量。这意味着我们试图通过人工创建的变量来提取数据集的本质,这些变量最好地描述了数据的方差:

主成分分析 versus 因子分析

FA 是另一种方法,因为它试图识别未知、潜在变量来解释原始数据。用简单的话说,我们使用来自我们的经验数据集的显性变量来猜测数据的内部结构:

主成分分析 versus 因子分析

多维尺度

多维尺度MDS)是一种多变量技术,最初用于地理学。多维尺度(MDS)的主要目标是绘制二维的多变量数据点,通过可视化观察值的相对距离来揭示数据集的结构。MDS 在心理学、社会学和市场研究等众多领域得到应用。

虽然MASS包通过isoMDS函数提供非度量多维尺度(MDS),但我们将专注于由stats包提供的cmdscale函数提供的经典度量多维尺度(MDS)。这两种类型的多维尺度都以距离矩阵作为主要参数,并且可以通过dist函数从任何数值表格数据创建。

在探索更复杂的例子之前,让我们看看在使用已经存在的距离矩阵时,多维尺度(MDS)能为我们提供什么,例如内置的eurodist数据集:

> as.matrix(eurodist)[1:5, 1:5]
 Athens Barcelona Brussels Calais Cherbourg
Athens         0      3313     2963   3175      3339
Barcelona   3313         0     1318   1326      1294
Brussels    2963      1318        0    204       583
Calais      3175      1326      204      0       460
Cherbourg   3339      1294      583    460         0

前面的值代表 21 个欧洲城市之间的旅行距离(千米),尽管只显示了前 5-5 个值。运行经典的多维尺度(MDS)相当简单:

> (mds <- cmdscale(eurodist))
 [,1]      [,2]
Athens           2290.2747  1798.803
Barcelona        -825.3828   546.811
Brussels           59.1833  -367.081
Calais            -82.8460  -429.915
Cherbourg        -352.4994  -290.908
Cologne           293.6896  -405.312
Copenhagen        681.9315 -1108.645
Geneva             -9.4234   240.406
Gibraltar       -2048.4491   642.459
Hamburg           561.1090  -773.369
Hook of Holland   164.9218  -549.367
Lisbon          -1935.0408    49.125
Lyons            -226.4232   187.088
Madrid          -1423.3537   305.875
Marseilles       -299.4987   388.807
Milan             260.8780   416.674
Munich            587.6757    81.182
Paris            -156.8363  -211.139
Rome              709.4133  1109.367
Stockholm         839.4459 -1836.791
Vienna            911.2305   205.930

这些得分与两个主成分非常相似,例如运行prcomp(eurodist)$x[, 1:2]。事实上,主成分分析(PCA)可以被认为是多维尺度(MDS)最基本的方法。

总之,我们已经将 21 维空间转换成了 2 维,这可以很容易地绘制出来(与之前的 21 行 21 列的矩阵不同):

> plot(mds)

多维尺度

这让你想起什么了吗?如果没有,请随意查看以下图像,其中以下两行代码也显示了城市名称而不是匿名点:

> plot(mds, type = 'n')
> text(mds[, 1], mds[, 2], labels(eurodist))

多维尺度

尽管 y 轴被翻转了,你可以通过将文本的第二个参数乘以-1 来修复这个问题,但我们已经根据距离矩阵渲染了一张欧洲城市地图——没有使用任何其他地理数据。我觉得这相当令人印象深刻。

请在第十三章,我们周围的数据中查找更多数据可视化技巧和方法。

现在我们来看看如何将 MDS 应用于非地理数据,这些数据并非为成为距离矩阵而准备。让我们回到mtcars数据集:

> mds <- cmdscale(dist(mtcars))
> plot(mds, type = 'n')
> text(mds[, 1], mds[, 2], rownames(mds))

多维尺度

图表显示了原始数据集中的 32 辆汽车在二维空间中的分布。元素之间的距离是通过 MDS 计算的,它考虑了所有 11 个原始变量,因此很容易识别出相似和非常不同的汽车类型。我们将在下一章中更详细地介绍这些主题,第十章,分类和聚类

摘要

在本章中,我们介绍了几种处理多元数据的方法,以减少人工计算连续变量中的可用维度,并识别潜在的、隐含的和类似的数值变量。另一方面,有时用数字描述现实相当困难,我们更应该按类别思考。

下一章将介绍新的方法来定义数据类型(聚类),并还将演示如何利用可用训练数据对元素进行分类。

第十章:分类与聚类

在上一章中,我们关注了如何将多个连续变量中找到的信息压缩成更小的数字集,但当处理分类数据时,例如分析调查数据时,这些统计方法就有些局限了。

尽管一些方法试图将离散变量转换为数值变量,例如通过使用多个虚拟变量或指示变量,但在大多数情况下,简单地考虑我们的研究设计目标而不是试图强行在分析中使用先前学到的方 法会更好。

注意

我们可以通过为原始离散变量的每个标签创建一个新变量来用多个虚拟变量替换一个分类变量,然后为相关列分配1,为所有其他列分配0。这些值可以用作统计分析中的数值变量,尤其是在回归模型中。

当我们通过分类变量分析样本和目标总体时,我们通常对单个案例不感兴趣,而是对相似元素和组感兴趣。相似元素可以定义为数据集中具有相似列值的行。

在本章中,我们将讨论不同的监督无监督方法来识别数据集中的相似案例,例如:

  • 层次聚类

  • K-均值聚类

  • 一些机器学习算法

  • 隐含类模型

  • 判别分析

  • 逻辑回归

聚类分析

聚类是一种无监督数据分析方法,在多个领域得到应用,如模式识别、社会科学和药学。聚类分析的目标是创建同质子组,称为簇,其中同一簇中的对象相似,而簇之间相互不同。

层次聚类

聚类分析是已知的最著名和最受欢迎的图案识别方法之一;因此,有许多聚类模型和算法在数据集中分析分布、密度、可能的中心点等。在本节中,我们将探讨一些层次聚类方法。

层次聚类可以是聚合的或划分的。在聚合方法中,每个案例最初都是一个单独的簇,然后通过迭代方式将最近的簇合并在一起,直到最终合并成一个包含原始数据集所有元素的单一簇。这种方法的最大问题是每次迭代都必须重新计算簇之间的距离,这使得在大数据集上非常慢。我宁愿不推荐尝试在hflights数据集上运行以下命令。

相反,划分方法采用自上而下的方法。它们从一个单一簇开始,然后迭代地将其划分为更小的组,直到它们都是单例。

stats包包含用于层次聚类的hclust函数,它接受距离矩阵作为输入。为了了解它是如何工作的,让我们使用已经分析过的mtcars数据集,它在第三章,过滤和汇总数据和第九章,从大数据到小数据中已经分析过。dist函数在后面的章节中也是熟悉的:

> d <- dist(mtcars)
> h <- hclust(d)
> h

Call:
hclust(d = d)

Cluster method   : complete 
Distance         : euclidean 
Number of objects: 32

好吧,这是一个过于简略的输出,仅表明我们的距离矩阵包含了 32 个元素以及聚类方法。对于如此小的数据集,结果的视觉表示将更有用:

> plot(h)

层次聚类

通过绘制这个hclust对象,我们得到了一个树状图,它显示了聚类是如何形成的。这有助于确定簇的数量,尽管在包含大量案例的数据集中,它变得难以解释。可以在y轴的任何给定高度上画一条水平线,这样与线的交点数量n就提供了一个 n 簇的解决方案。

R 可以提供非常方便的方式来在树状图上可视化簇。在下面的图表中,红色方框显示了在前面图表之上的三个簇解决方案的簇成员资格:

> plot(h)
> rect.hclust(h, k=3, border = "red")

层次聚类

虽然这个图表看起来很漂亮,并且将相似元素分组在一起非常有用,但对于更大的数据集,它变得难以看透。相反,我们可能更感兴趣的是在向量中实际表示的簇成员资格:

> (cn <- cutree(h, k = 3))
 Mazda RX4       Mazda RX4 Wag          Datsun 710 
 1                   1                   1 
 Hornet 4 Drive   Hornet Sportabout             Valiant 
 2                   3                   2 
 Duster 360           Merc 240D            Merc 230 
 3                   1                   1 
 Merc 280           Merc 280C          Merc 450SE 
 1                   1                   2 
 Merc 450SL         Merc 450SLC  Cadillac Fleetwood 
 2                   2                   3 
Lincoln Continental   Chrysler Imperial            Fiat 128 
 3                   3                   1 
 Honda Civic      Toyota Corolla       Toyota Corona 
 1                   1                   1 
 Dodge Challenger         AMC Javelin          Camaro Z28 
 2                   2                   3 
 Pontiac Firebird           Fiat X1-9       Porsche 914-2 
 3                   1                   1 
 Lotus Europa      Ford Pantera L        Ferrari Dino 
 1                   3                   1 
 Maserati Bora          Volvo 142E 
 3                   1

以及结果簇中元素数量的频率表:

> table(cn)
 1  2  3 
16  7  9

看起来,簇 1,在前面图表中的第三个簇,包含的元素最多。你能猜出这个组与其他两个簇有什么不同吗?好吧,那些熟悉汽车名称的读者可能能够猜出答案,但让我们看看数字实际上显示了什么:

注意

请注意,在以下示例中,我们使用round函数将代码输出中的小数位数限制为 1 或 4,以适应页面宽度。

> round(aggregate(mtcars, FUN = mean, by = list(cn)), 1)
 Group.1  mpg cyl  disp    hp drat  wt qsec  vs  am gear carb
1       1 24.5 4.6 122.3  96.9  4.0 2.5 18.5 0.8 0.7  4.1  2.4
2       2 17.0 7.4 276.1 150.7  3.0 3.6 18.1 0.3 0.0  3.0  2.1
3       3 14.6 8.0 388.2 232.1  3.3 4.2 16.4 0.0 0.2  3.4  4.0

在簇之间的平均性能和油耗之间有一个非常显著的区别!那么组内的标准差如何呢?

> round(aggregate(mtcars, FUN = sd, by = list(cn)), 1)
 Group.1 mpg cyl disp   hp drat  wt qsec  vs  am gear carb
1       1 5.0   1 34.6 31.0  0.3 0.6  1.8 0.4 0.5  0.5  1.5
2       2 2.2   1 30.2 32.5  0.2 0.3  1.2 0.5 0.0  0.0  0.9
3       3 3.1   0 58.1 49.4  0.4 0.9  1.3 0.0 0.4  0.9  1.7

这些值与原始数据集中的标准差相比相当低:

> round(sapply(mtcars, sd), 1)
 mpg   cyl  disp    hp  drat    wt  qsec    vs    am  gear  carb 
 6.0   1.8 123.9  68.6   0.5   1.0   1.8   0.5   0.5   0.7   1.6

同样,当比较组之间的标准差时也是如此:

> round(apply(
+   aggregate(mtcars, FUN = mean, by = list(cn)),
+   2, sd), 1)
Group.1     mpg     cyl    disp      hp    drat      wt    qsec 
 1.0     5.1     1.8   133.5    68.1     0.5     0.8     1.1 
 vs      am    gear    carb 
 0.4     0.4     0.6     1.0

这意味着我们实现了我们的原始目标,即识别数据中的相似元素并将它们组织成彼此不同的组。但为什么我们要将原始数据分成恰好三个人为定义的组?为什么不是两个、四个,甚至更多?

确定理想簇的数量

NbClust 包提供了一种非常方便的方法,在运行实际的聚类分析之前,可以对我们的数据进行一些探索性数据分析。该包的主要功能可以计算 30 种不同的指标,所有这些指标都是为了确定理想的组数。这些包括:

  • 单链接

  • 平均值

  • 完全链接

  • McQuitty

  • 质心(聚类中心)

  • 中位数

  • K-means

  • Ward

在加载包之后,让我们从一种表示数据中可能聚类数量的可视化方法开始——膝形图,这可能在第九章中很熟悉,从大数据到小数据,在那里你还可以找到关于以下肘部规则的更多信息:

> library(NbClust)
> NbClust(mtcars, method = 'complete', index = 'dindex')

确定理想聚类数量

在前面的图表中,我们传统上寻找 肘部,但右侧的第二差分图可能对大多数读者来说更直接。在那里,我们感兴趣的是最显著的峰值在哪里,这表明在聚类 mtcars 数据集时选择三个组将是理想的。

不幸的是,在如此小的数据集上运行所有 NbClust 方法都失败了。因此,为了演示目的,我们现在只运行一些标准方法,并通过相关列表元素过滤结果以建议的聚类数量:

> NbClust(mtcars, method = 'complete', index = 'hartigan')$Best.nc
All 32 observations were used. 

Number_clusters     Value_Index 
 3.0000         34.1696 
> NbClust(mtcars, method = 'complete', index = 'kl')$Best.nc
All 32 observations were used. 

Number_clusters     Value_Index 
 3.0000          6.8235

Hartigan 和 Krzanowski-Lai 指数都建议坚持三个聚类。让我们也查看 iris 数据集,它包含更多案例且数值列较少,因此我们可以运行所有可用方法:

> NbClust(iris[, -5], method = 'complete', index = 'all')$Best.nc[1,]
All 150 observations were used. 

******************************************************************* 
* Among all indices: 
* 2 proposed 2 as the best number of clusters 
* 13 proposed 3 as the best number of clusters 
* 5 proposed 4 as the best number of clusters 
* 1 proposed 6 as the best number of clusters 
* 2 proposed 15 as the best number of clusters 

 ***** Conclusion ***** 

* According to the majority rule, the best number of clusters is  3 

 ******************************************************************* 
 KL         CH   Hartigan        CCC      Scott    Marriot 
 4          4          3          3          3          3 
 TrCovW     TraceW   Friedman      Rubin     Cindex         DB 
 3          3          4          6          3          3 
Silhouette       Duda   PseudoT2      Beale  Ratkowsky       Ball 
 2          4          4          3          3          3 
PtBiserial       Frey    McClain       Dunn     Hubert    SDindex 
 3          1          2         15          0          3 
 Dindex       SDbw 
 0         15

输出总结表明,基于返回该数字的 13 种方法,有五种进一步的方法建议四个聚类,还有一些其他聚类数量也由更少的方法计算得出。

这些方法不仅适用于之前讨论过的层次聚类,而且通常也用于 k-means 聚类分析,其中在运行分析之前需要定义聚类数量——与层次方法不同,在重计算已经完成之后我们才切割树状图。

K-means 聚类分析

K-means 聚类分析是一种非层次方法,最早由 MacQueen 在 1967 年描述。它相对于层次聚类的最大优势是其出色的性能。

注意

与层次聚类分析不同,k-means 聚类分析要求你在实际分析运行之前确定聚类数量。

算法简要运行以下步骤:

  1. 在空间中初始化一个预定义的(k)数量随机选择的质心。

  2. 将每个对象分配到最近的质心的聚类中。

  3. 重新计算质心。

  4. 重复第二步和第三步,直到收敛。

我们将使用stats包中的kmeans函数。由于 k 均值聚类需要对簇的数量做出先前的决定,我们可以使用之前描述的NbClust函数,或者我们可以提出一个符合分析目标的任意数字。

根据上一节中定义的最佳簇数量,我们将坚持三个组,其中簇内平方和不再显著下降:

> (k <- kmeans(mtcars, 3))
K-means clustering with 3 clusters of sizes 16, 7, 9

Cluster means:
 mpg      cyl     disp       hp     drat       wt     qsec
1 24.50000 4.625000 122.2937  96.8750 4.002500 2.518000 18.54312
2 17.01429 7.428571 276.0571 150.7143 2.994286 3.601429 18.11857
3 14.64444 8.000000 388.2222 232.1111 3.343333 4.161556 16.40444
 vs        am     gear     carb
1 0.7500000 0.6875000 4.125000 2.437500
2 0.2857143 0.0000000 3.000000 2.142857
3 0.0000000 0.2222222 3.444444 4.000000

Clustering vector:
 Mazda RX4       Mazda RX4 Wag          Datsun 710 
 1                   1                   1 
 Hornet 4 Drive   Hornet Sportabout             Valiant 
 2                   3                   2 
 Duster 360           Merc 240D            Merc 230 
 3                   1                   1 
 Merc 280           Merc 280C          Merc 450SE 
 1                   1                   2 
 Merc 450SL         Merc 450SLC  Cadillac Fleetwood 
 2                   2                   3 
Lincoln Continental   Chrysler Imperial            Fiat 128 
 3                   3                   1 
 Honda Civic      Toyota Corolla       Toyota Corona 
 1                   1                   1 
 Dodge Challenger         AMC Javelin          Camaro Z28 
 2                   2                   3 
 Pontiac Firebird           Fiat X1-9       Porsche 914-2 
 3                   1                   1 
 Lotus Europa      Ford Pantera L        Ferrari Dino 
 1                   3                   1 
 Maserati Bora          Volvo 142E 
 3                   1 

Within cluster sum of squares by cluster:
[1] 32838.00 11846.09 46659.32
 (between_SS / total_SS =  85.3 %)

Available components:

[1] "cluster"      "centers"      "totss"        "withinss" 
[5] "tot.withinss" "betweenss"    "size"         "iter" 
[9] "ifault" 

簇均值显示了每个簇的一些非常重要的特征,我们在上一节中手动为层次聚类生成了这些特征。我们可以看到,在第一个簇中,汽车的平均油耗(低耗油量)很高,平均有四个汽缸(与六个或八个汽缸相比),性能相对较低,等等。输出还自动揭示了实际的簇编号。

让我们比较这些与层次方法定义的簇:

> all(cn == k$cluster)
[1] TRUE

结果似乎相当稳定,对吧?

小贴士

簇编号没有意义,它们的顺序是任意的。换句话说,簇成员资格是一个名义变量。基于此,当簇编号以不同的顺序分配时,前面的 R 命令可能会返回FALSE而不是TRUE,但比较实际的簇成员资格将验证我们已经找到了完全相同的群体。例如,查看cbind(cn, k$cluster)以生成包括簇成员资格的表格。

可视化簇

绘制这些簇也是理解分组的一种很好的方式。为此,我们将使用clusplot函数,该函数来自cluster包。为了更容易理解,此函数将维度数量减少到两个,类似于我们在进行 PCA 或 MDS(在第九章中描述,从大数据到小数据)时的情况:

> library(cluster) 
> clusplot(mtcars, k$cluster, color = TRUE, shade = TRUE, labels = 2)

可视化簇

如您所见,在降维后,两个成分解释了 84.17%的方差,因此这种小的信息损失是易于理解簇的一个很好的权衡。

使用shade参数可视化椭圆的相对密度也可以帮助我们了解同一组元素之间的相似性。我们还使用了标签参数来显示点和簇标签。在可视化大量元素时,请务必坚持默认的0(无标签)或4(仅椭圆标签)。

潜在类别模型

潜在类别分析LCA)是一种识别多色结果变量中潜在变量的方法。它与因子分析类似,但可以用于离散/分类数据。为此,LCA 主要在分析调查时使用。

在本节中,我们将使用poLCA包中的poLCA函数。它使用期望最大化算法和牛顿-拉夫森算法来寻找参数的最大似然值。

poLCA函数要求数据编码为从一开始的整数或因子,否则将产生错误信息。为此,让我们将mtcars数据集中的某些变量转换为因子:

> factors <- c('cyl', 'vs', 'am', 'carb', 'gear')
> mtcars[, factors] <- lapply(mtcars[, factors], factor)

小贴士

上述命令将覆盖当前 R 会话中的mtcars数据集。要恢复到本章其他示例中的原始数据集,请通过rm(mtcars)删除此更新后的数据集,如果需要的话。

潜在类别分析

现在数据已处于适当的格式,我们可以进行 LCA。相关的函数附带了许多重要的参数:

  • 首先,我们必须定义一个描述模型的公式。根据公式,我们可以定义 LCA(类似于聚类,但使用离散变量)或潜在类别回归LCR)模型。

  • nclass参数指定模型中假设的潜在类别数量,默认为 2。根据本章前面的示例,我们将将其覆盖为 3。

  • 我们可以使用maxitertolprobs.startnrep参数来微调模型。

  • graphs参数可以显示或抑制参数估计。

让我们从由所有可用离散变量定义的三个潜在类的基本 LCA 开始:

> library(poLCA)
> p <- poLCA(cbind(cyl, vs, am, carb, gear) ~ 1,
+   data = mtcars, graphs = TRUE, nclass = 3)

输出的第一部分(也可以通过先前保存的poLCA列表的probs元素访问)总结了每个潜在类别对结果变量的概率:

> p$probs
Conditional item response (column) probabilities,
 by outcome variable, for each class (row) 

$cyl
 4      6 8
class 1:  0.3333 0.6667 0
class 2:  0.6667 0.3333 0
class 3:  0.0000 0.0000 1

$vs
 0      1
class 1:  0.0000 1.0000
class 2:  0.2667 0.7333
class 3:  1.0000 0.0000

$am
 0      1
class 1:  1.0000 0.0000
class 2:  0.2667 0.7333
class 3:  0.8571 0.1429

$carb
 1      2      3      4      6      8
class 1:  1.0000 0.0000 0.0000 0.0000 0.0000 0.0000
class 2:  0.2667 0.4000 0.0000 0.2667 0.0667 0.0000
class 3:  0.0000 0.2857 0.2143 0.4286 0.0000 0.0714
$gear
 3   4      5
class 1:  1.0000 0.0 0.0000
class 2:  0.0000 0.8 0.2000
class 3:  0.8571 0.0 0.1429

从这些概率中,我们可以看到所有 8 缸汽车都属于第三类,第一类只包括自动变速、一个化油器、三个档位等的汽车。通过在函数调用中将图形参数设置为TRUE,或者直接在调用后调用绘图函数,也可以绘制出完全相同的值:

潜在类别分析

该图也很有用,可以突出显示与其他类别相比,第一个潜在类别只包含少数几个元素(也称为“估计类别人口份额”):

> p$P
[1] 0.09375 0.46875 0.43750

poLCA对象还可以揭示关于结果的其他许多重要信息。仅举几例,让我们看看对象的命名列表部分,可以通过标准的$运算符提取:

  • predclass返回最可能的类别成员资格

  • 另一方面,后验元素是一个矩阵,包含每个案例的类别成员概率

  • 赤池信息准则aic)、贝叶斯信息准则bic)、偏差Gsq)和Chisq值代表不同的拟合优度度量

LCR 模型

另一方面,LCR 模型是一种监督方法,在探索性数据分析尺度上,我们主要不感兴趣的是解释我们观察到的潜在变量,而是使用训练数据,其中一个或多个协变量预测潜在类别成员的概率。

判别分析

判别函数分析DA)指的是确定哪些连续的独立(预测)变量可以区分离散的依赖(响应)变量的类别,这可以被视为反向的多元方差分析MANOVA)。

这表明 DA 与逻辑回归非常相似(见第六章"), 超越线性趋势线 (由 Renata Nemeth 和 Gergely Toth 撰写)以及以下章节),由于其灵活性而更广泛地使用。虽然逻辑回归可以处理分类和连续数据,但 DA 需要数值独立变量,并且有一些逻辑回归没有的进一步要求:

  • 假设正态分布

  • 应消除异常值

  • 两个变量不应高度相关(多重共线性)

  • 最小类别的样本量应高于预测值的数量

  • 独立变量的数量不应超过样本量

有两种不同的 DA 类型,我们将使用MASS包中的lda进行线性判别函数,以及qda进行二次判别函数。

让我们从依赖变量是齿轮数量开始,并将所有其他数值作为独立变量。为了确保我们从标准mtcars数据集开始,该数据集在前面的示例中没有覆盖,让我们清除命名空间并更新齿轮列,以包含类别而不是实际的数值:

> rm(mtcars)
> mtcars$gear <- factor(mtcars$gear)

由于观察值数量较少(并且我们已经讨论了第九章, 从大数据到小数据中的相关选项),我们现在可以暂时搁置进行正态性和其他测试。让我们继续实际分析。

我们调用lda函数,将交叉验证CV)设置为TRUE,以便我们可以测试预测的准确性。公式中的点代表所有变量,除了明确提到的齿轮:

> library(MASS)
> d <- lda(gear ~ ., data = mtcars, CV =TRUE)

因此,现在我们可以通过比较混淆矩阵来检查预测的准确性:

> (tab <- table(mtcars$gear, d$class)) 
 3  4  5
 3 14  1  0
 4  2 10  0
 5  1  1  3

要表示相对百分比而不是原始数字,我们可以进行一些快速转换:

> tab / rowSums(tab)
 3          4          5
 3 0.93333333 0.06666667 0.00000000
 4 0.16666667 0.83333333 0.00000000
 5 0.20000000 0.20000000 0.60000000

我们还可以计算未预测的百分比:

> sum(diag(tab)) / sum(tab)
[1] 0.84375

最终,大约 84%的案例被分类到最有可能的相应类别中,这些类别由列表中可以提取的实际概率组成:

> round(d$posterior, 4)
 3      4      5
Mazda RX4           0.0000 0.8220 0.1780
Mazda RX4 Wag       0.0000 0.9905 0.0095
Datsun 710          0.0018 0.6960 0.3022
Hornet 4 Drive      0.9999 0.0001 0.0000
Hornet Sportabout   1.0000 0.0000 0.0000
Valiant             0.9999 0.0001 0.0000
Duster 360          0.9993 0.0000 0.0007
Merc 240D           0.6954 0.2990 0.0056
Merc 230            1.0000 0.0000 0.0000
Merc 280            0.0000 1.0000 0.0000
Merc 280C           0.0000 1.0000 0.0000
Merc 450SE          1.0000 0.0000 0.0000
Merc 450SL          1.0000 0.0000 0.0000
Merc 450SLC         1.0000 0.0000 0.0000
Cadillac Fleetwood  1.0000 0.0000 0.0000
Lincoln Continental 1.0000 0.0000 0.0000
Chrysler Imperial   1.0000 0.0000 0.0000
Fiat 128            0.0000 0.9993 0.0007
Honda Civic         0.0000 1.0000 0.0000
Toyota Corolla      0.0000 0.9995 0.0005
Toyota Corona       0.0112 0.8302 0.1586
Dodge Challenger    1.0000 0.0000 0.0000
AMC Javelin         1.0000 0.0000 0.0000
Camaro Z28          0.9955 0.0000 0.0044
Pontiac Firebird    1.0000 0.0000 0.0000
Fiat X1-9           0.0000 0.9991 0.0009
Porsche 914-2       0.0000 1.0000 0.0000
Lotus Europa        0.0000 0.0234 0.9766
Ford Pantera L      0.9965 0.0035 0.0000
Ferrari Dino        0.0000 0.0670 0.9330
Maserati Bora       0.0000 0.0000 1.0000
Volvo 142E          0.0000 0.9898 0.0102

现在,我们可以再次运行lda而不进行交叉验证,以查看实际的判别函数以及不同类别的gear是如何结构的:

> d <- lda(gear ~ ., data = mtcars)
> plot(d)

判别分析

前面图表中的数字代表mtcars数据集中由实际档位数表示的汽车。两个判别因子所渲染的元素非常直观地突出了具有相同档位数的汽车之间的相似性,以及gear列中值不等的汽车之间的差异。

这些判别因子也可以通过调用predictd对象中提取,或者可以直接在直方图上直接渲染,以查看独立变量类别的连续变量的分布:

> plot(d, dimen = 1, type = "both" )

判别分析

Logistic 回归

尽管 logistic 回归在第六章")中有所涉及,超越线性趋势线(由 Renata Nemeth 和 Gergely Toth 撰写),因为它常用于解决分类问题,我们将再次通过一些相关示例和一些注意事项来回顾这个主题,例如 logistic 回归的多项式版本,这在之前的章节中没有介绍。

我们的数据通常不符合判别分析的要求。在这种情况下,使用 logistic、logit 或 probit 回归可以是一个合理的选择,因为这些方法对非正态分布和每个组内不等方差不敏感;另一方面,它们需要更大的样本量。对于小样本量,判别分析要可靠得多。

按照惯例,你应该至少有 50 个观测值每个自变量,这意味着,如果我们想为之前的mtcars数据集建立 logistic 回归模型,我们至少需要 500 个观测值——但我们只有 32 个。

因此,我们将本节限制在一两个快速示例上,说明如何进行 logit 回归——例如,根据汽车的性能和重量来估计汽车是自动变速箱还是手动变速箱:

> lr <- glm(am ~ hp + wt, data = mtcars, family = binomial)
> summary(lr)

Call:
glm(formula = am ~ hp + wt, family = binomial, data = mtcars)

Deviance Residuals: 
 Min       1Q   Median       3Q      Max 
-2.2537  -0.1568  -0.0168   0.1543   1.3449 

Coefficients:
 Estimate Std. Error z value Pr(>|z|) 
(Intercept) 18.86630    7.44356   2.535  0.01126 * 
hp           0.03626    0.01773   2.044  0.04091 * 
wt          -8.08348    3.06868  -2.634  0.00843 **
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

 Null deviance: 43.230  on 31  degrees of freedom
Residual deviance: 10.059  on 29  degrees of freedom
AIC: 16.059

Number of Fisher Scoring iterations: 8

前面输出中最重要的表格是系数表,它描述了模型和自变量是否对自变量的值有显著贡献。我们可以得出以下结论:

  • 马力每增加 1 单位,拥有手动变速箱的对数几率就会增加(至少在 1974 年数据收集时是这样的)

  • 另一方面,重量(以磅为单位)每增加 1 单位,相同的对数几率就会减少 8

看起来,尽管(或者更确切地说,正因为)样本量较小,模型与数据拟合得非常好,汽车的马力与重量可以解释汽车是自动变速箱还是手动换挡:

> table(mtcars$am, round(predict(lr, type = 'response')))
 0  1
 0 18  1
 1  1 12

但在齿轮数量而不是变速器上运行前面的命令会失败,因为默认情况下,逻辑回归期望的是二元变量。我们可以通过在数据上拟合多个模型来克服这个问题,例如使用虚拟变量来验证一辆车是否有 3/4/5 个齿轮,或者通过拟合多项式逻辑回归。nnet包有一个非常方便的函数来完成这个任务:

> library(nnet) 
> (mlr <- multinom(factor(gear) ~ ., data = mtcars)) 
# weights:  36 (22 variable)
initial  value 35.155593 
iter  10 value 5.461542
iter  20 value 0.035178
iter  30 value 0.000631
final  value 0.000000 
converged
Call:
multinom(formula = factor(gear) ~ ., data = mtcars)

Coefficients:
 (Intercept)       mpg       cyl      disp         hp     drat
4  -12.282953 -1.332149 -10.29517 0.2115914 -1.7284924 15.30648
5    7.344934  4.934189 -38.21153 0.3972777 -0.3730133 45.33284
 wt        qsec        vs       am     carb
4 21.670472   0.1851711  26.46396 67.39928 45.79318
5 -4.126207 -11.3692290 -38.43033 32.15899 44.28841

Residual Deviance: 4.300374e-08 
AIC: 44

如预期,它返回了一个高度拟合我们小数据集的模型:

> table(mtcars$gear, predict(mlr))
 3  4  5
 3 15  0  0
 4  0 12  0
 5  0  0  5

然而,由于样本量小,这个模型非常有限。在继续下一个示例之前,请从当前的 R 会话中删除更新的mtcars数据集,以避免意外错误:

> rm(mtcars)

机器学习算法

机器学习ML)是一组数据驱动算法,它们在没有为特定任务明确编程的情况下工作。与无机器学习算法不同,它们需要(并通过)训练数据来学习。机器学习算法分为监督学习和无监督学习两种类型。

监督学习意味着训练数据包括输入向量和它们对应的输出值。这意味着任务是建立历史数据库中输入和输出之间的关系,称为训练集,从而能够预测未来输入值的输出。

例如,银行拥有大量关于以前贷款交易详情的数据库。输入向量包括个人信息,如年龄、薪水、婚姻状况等,而输出(目标)变量显示是否按时支付了款项。在这种情况下,监督算法可能会检测到不同的人群,这些人可能无法按时支付,这可能作为申请人的筛选。

无监督学习有不同的目标。由于历史数据集中没有输出值,目标是识别输入之间的潜在相关性,并定义任意案例组。

K-Nearest Neighbors 算法

K-Nearest Neighborsk-NN),与层次聚类或 k-means 聚类不同,是一种监督分类算法。尽管它经常与 k-means 聚类混淆,但 k-NN 分类是一种完全不同的方法。它主要用于模式识别和商业分析。k-NN 的一个大优点是它对异常值不敏感,使用起来极其简单——就像大多数机器学习算法一样。

k-NN 的主要思想是它识别历史数据集中观察到的 k 个最近邻,然后定义观察到的类别以匹配前面提到的多数邻居。

作为样本分析,我们将使用来自 class 包的 knn 函数。knn 函数接受 4 个参数,其中 traintest 分别是训练集和测试集,cl 是训练数据的类别成员资格,而 k 是在分类测试数据集中的元素时考虑的邻居数量。

k 的默认值是 1,这通常没有问题——尽管通常准确性相当低。当定义用于分析的更高数量的邻居以提高准确性时,选择一个不是类别数量倍数的整数是明智的。

让我们将 mtcars 数据集分成两部分:训练数据和测试数据。为了简单起见,一半的汽车将属于训练集,另一半将属于测试集:

> set.seed(42)
> n     <- nrow(mtcars)
> train <- mtcars[sample(n, n/2), ]

小贴士

我们使用 set.seed 配置随机生成器的状态为一个(已知)的数字,以便于可重复性:这样,所有机器上都会生成完全相同的 随机 数字。

因此,我们采样了 16 个介于 1 和 32 之间的整数,以从 mtcars 数据集中选择 50% 的行。有些人可能会认为以下 dplyr(在第三章过滤和汇总数据和第四章重构数据中讨论)代码片段更适合这项任务:

> library(dplyr)
> train <- sample_n(mtcars, n / 2)

然后,让我们选择与原始数据相比新创建的 data.frame 差异的其他行:

> test <- mtcars[setdiff(row.names(mtcars), row.names(train)), ]

现在,我们必须定义训练数据中观测值的类别成员资格,这是我们希望在测试数据集中通过分类进行预测的内容。为此,我们可以使用我们在上一节中学到的知识,而不是汽车已经知道的特征,我们可以运行聚类方法来定义训练数据中每个元素的类别成员资格——但这不是我们应该用于教学目的的事情。你也可以在你的测试数据上运行聚类算法,对吧?监督学习和无监督方法之间的主要区别在于,前者方法我们有经验数据来喂养分类模型。

因此,让我们使用汽车中的齿轮数作为类别成员资格,并根据训练数据中找到的信息,预测测试数据集中的齿轮数:

> library(class)
> (cm <- knn(
+     train = subset(train, select = -gear),
+     test  = subset(test, select = -gear),
+     cl    = train$gear,
+     k     = 5))
[1] 4 4 4 4 3 4 4 3 3 3 3 3 4 4 4 3
Levels: 3 4 5

测试用例刚刚被分类到前面的类别中。我们可以通过计算实际齿轮数与预测齿轮数之间的相关系数来检查分类的准确性:

> cor(test$gear, as.numeric(as.character(cm)))
[1] 0.5459487

嗯,这可能要好得多,特别是如果训练数据量很大。机器学习算法通常使用历史数据库中的数百万行数据,而我们的数据集只有 16 个案例。但让我们通过计算混淆矩阵来看看模型在哪里未能提供准确的预测:

> table(test$gear, as.numeric(as.character(cm)))
 3 4
 3 6 1
 4 0 6
 5 1 2

因此,似乎 k-NN 分类算法可以非常准确地预测具有三个或四个档位的所有汽车的档位数(13 个中只有一个错误),但对于具有五个档位的汽车最终失败了。这可以通过原始数据集中相关汽车的数量来解释:

> table(train$gear)
3 4 5 
8 6 2

嗯,训练数据中只有两辆汽车有 5 个档位,这在构建一个提供准确预测的模型时确实非常紧张。

分类树

另一种用于监督分类的机器学习方法是通过决策树进行递归分区。这种方法的一个巨大优势是,可视化决策规则可以显著提高对底层数据的理解,并且在大多数情况下运行算法可以非常简单。

让我们加载rpart包并再次使用响应变量gear函数构建一个分类树:

> library(rpart)
> ct <- rpart(factor(gear) ~ ., data = train, minsplit = 3)
> summary(ct)
Call:
rpart(formula = factor(gear) ~ ., data = train, minsplit = 3)
 n= 16 

 CP nsplit rel error xerror      xstd
1 0.75      0      1.00  1.000 0.2500000
2 0.25      1      0.25  0.250 0.1653595
3 0.01      2      0.00  0.125 0.1210307

Variable importance
drat qsec  cyl disp   hp  mpg   am carb 
 18   16   12   12   12   12    9    9 

Node number 1: 16 observations,    complexity param=0.75
 predicted class=3  expected loss=0.5  P(node) =1
 class counts:     8     6     2
 probabilities: 0.500 0.375 0.125 
 left son=2 (10 obs) right son=3 (6 obs)
 Primary splits:
 drat < 3.825 to the left,  improve=6.300000, (0 missing)
 disp < 212.8 to the right, improve=4.500000, (0 missing)
 am   < 0.5   to the left,  improve=3.633333, (0 missing)
 hp   < 149   to the right, improve=3.500000, (0 missing)
 qsec < 18.25 to the left,  improve=3.500000, (0 missing)
 Surrogate splits:
 mpg  < 22.15 to the left,  agree=0.875, adj=0.667, (0 split)
 cyl  < 5     to the right, agree=0.875, adj=0.667, (0 split)
 disp < 142.9 to the right, agree=0.875, adj=0.667, (0 split)
 hp   < 96    to the right, agree=0.875, adj=0.667, (0 split)
 qsec < 18.25 to the left,  agree=0.875, adj=0.667, (0 split)

Node number 2: 10 observations,    complexity param=0.25
 predicted class=3  expected loss=0.2  P(node) =0.625
 class counts:     8     0     2
 probabilities: 0.800 0.000 0.200 
 left son=4 (8 obs) right son=5 (2 obs)
 Primary splits:
 am   < 0.5   to the left,  improve=3.200000, (0 missing)
 carb < 5     to the left,  improve=3.200000, (0 missing)
 qsec < 16.26 to the right, improve=1.866667, (0 missing)
 hp   < 290   to the left,  improve=1.422222, (0 missing)
 disp < 325.5 to the right, improve=1.200000, (0 missing)
 Surrogate splits:
 carb < 5     to the left,  agree=1.0, adj=1.0, (0 split)
 qsec < 16.26 to the right, agree=0.9, adj=0.5, (0 split)

Node number 3: 6 observations
 predicted class=4  expected loss=0  P(node) =0.375
 class counts:     0     6     0
 probabilities: 0.000 1.000 0.000 

Node number 4: 8 observations
 predicted class=3  expected loss=0  P(node) =0.5
 class counts:     8     0     0
 probabilities: 1.000 0.000 0.000 

Node number 5: 2 observations
 predicted class=5  expected loss=0  P(node) =0.125
 class counts:     0     0     2
 probabilities: 0.000 0.000 1.000 

生成的对象是一个相当简单的决策树——尽管我们指定了一个非常低的minsplit参数,以便能够生成多个节点。在没有这个参数的情况下运行前面的调用甚至不会产生决策树,因为我们的训练数据中的 16 个案例会由于节点默认的最小值是 20 个元素而适合在一个节点中。

但我们已经构建了一个决策树,其中确定档位数的最重要的规则是后轴比以及汽车是否有自动或手动变速箱:

> plot(ct); text(ct)

分类树

将其翻译成简单明了的英语:

  • 高后轴比的汽车有四个档位

  • 所有其他自动挡汽车有三个档位

  • 手动换挡的汽车有五个档位

嗯,由于案例数量较少,这个规则确实非常基础,混淆矩阵也揭示了模型的严重局限性,即它无法成功识别具有 5 个档位的汽车:

> table(test$gear, predict(ct, newdata = test, type = 'class'))
 3 4 5
 3 7 0 0
 4 1 5 0
 5 0 2 1

但有 13 辆中的 16 辆被完美分类,这相当令人印象深刻,并且比之前的 k-NN 示例要好一些!

让我们改进前面代码,稍微改进一下这个非常简约的图形,可以通过从rpart.plot包中调用前面的main函数,或者加载party包来实现,该包为party对象提供了非常整洁的绘图函数。一个选项可能是通过partykit包在先前计算的ct对象上调用as.party;或者,我们可以使用其ctree函数重新创建分类树。根据之前的经验,让我们只将前面突出显示的变量传递给模型:

> library(party)
> ct <- ctree(factor(gear) ~ drat, data = train,
+   controls = ctree_control(minsplit = 3)) 
> plot(ct, main = "Conditional Inference Tree")

分类树

看起来这个模型完全基于后轴比来决定档位数,准确度相当低:

> table(test$gear, predict(ct, newdata = test, type = 'node'))
 2 3
 3 7 0
 4 1 5
 5 0 3

现在我们来看看哪些额外的机器学习算法可以提供更准确和/或更可靠的模型!

随机森林

随机森林背后的主要思想是,我们不是构建一个具有不断增长节点数量的深度决策树,这可能会风险过度拟合数据,而是生成多个树来最小化方差而不是最大化准确率。这样,预期的结果与训练良好的决策树相比可能会更嘈杂,但平均而言,这些结果更可靠。

这可以通过与 R 中先前的示例类似的方式实现,例如通过randomForest包,该包提供了对经典随机森林算法非常用户友好的访问:

> library(randomForest)
> (rf <- randomForest(factor(gear) ~ ., data = train, ntree = 250))
Call:
 randomForest(formula = factor(gear) ~ ., data = train, ntree = 250) 
 Type of random forest: classification
 Number of trees: 250
No. of variables tried at each split: 3

 OOB estimate of  error rate: 25%
Confusion matrix:
 3 4 5 class.error
3 7 1 0   0.1250000
4 1 5 0   0.1666667
5 2 0 0   1.0000000

这个函数非常方便使用:它自动返回混淆矩阵,并计算估计的错误率——尽管我们当然可以根据mtcars的其他子集生成自己的:

> table(test$gear, predict(rf, test)) 
 3 4 5
 3 7 0 0
 4 1 5 0
 5 1 2 0

但这次,绘图函数返回了一些新内容:

> plot(rf)
> legend('topright',
+   legend = colnames(rf$err.rate),
+   col    = 1:4,
+   fill   = 1:4,
+   bty    = 'n')

随机森林

我们看到,当我们对训练数据的随机子样本生成越来越多的决策树时,模型的均方误差是如何随时间变化的,经过一段时间后,错误率似乎没有变化,生成超过给定数量的随机样本似乎没有太多意义。

嗯,对于如此小的例子来说,这确实非常直接,因为可能的子样本组合是有限的。还值得一提的是,具有五个档位(蓝色线)的汽车的错误率在时间上没有任何变化,这再次突出了我们训练数据集的主要限制。

其他算法

尽管继续讨论广泛的关联机器学习算法(例如来自gbmxgboost包的 ID3 和梯度提升算法)以及如何从 R 控制台调用 Weka 来使用 C4.5 会很棒,但在本章中,我只能专注于最后一个实际示例,即如何通过caret包使用这些算法的通用接口:

> library(caret)

这个包捆绑了一些非常有用的函数和方法,可以作为预测模型的通用、算法无关的工具使用。这意味着所有之前的模型都可以在不实际调用rpartctreerandomForest函数的情况下运行,我们只需简单地依赖 caret 的train函数,该函数将算法定义作为参数。

为了快速举例,让我们看看改进版和开源的 C4.5 实现在我们训练数据上的表现:

> library(C50)
> C50 <- train(factor(gear) ~ ., data = train, method = 'C5.0')
> summary(C50)

C5.0 [Release 2.07 GPL Edition]    Fri Mar 20 23:22:10 2015
-------------------------------

Class specified by attribute `outcome'

Read 16 cases (11 attributes) from undefined.data

-----  Trial 0:  -----

Rules:

Rule 0/1: (8, lift 1.8)
 drat <= 3.73
 am <= 0
 ->  class 3  [0.900]

Rule 0/2: (6, lift 2.3)
 drat > 3.73
 ->  class 4  [0.875]

Rule 0/3: (2, lift 6.0)
 drat <= 3.73
 am > 0
 ->  class 5  [0.750]

Default class: 3

*** boosting reduced to 1 trial since last classifier is very accurate

*** boosting abandoned (too few classifiers)

Evaluation on training data (16 cases):

 Rules 
 ----------------
 No      Errors

 3    0( 0.0%)   <<

 (a)   (b)   (c)    <-classified as
 ----  ----  ----
 8                (a): class 3
 6          (b): class 4
 2    (c): class 5

 Attribute usage:

 100.00%  drat
 62.50%  am

这个输出看起来非常令人信服,因为错误率正好为零,这意味着我们刚刚创建了一个模型,它仅用三条简单规则就完美地拟合了我们的训练数据:

  • 车辆后轴比大的有四个档位

  • 其他车辆要么有三个(手动变速)要么有五个(自动变速)

嗯,再次审视结果揭示,我们还没有找到圣杯:

> table(test$gear, predict(C50, test))
 3 4 5
 3 7 0 0
 4 1 5 0
 5 0 3 0

因此,这个算法在我们测试数据集上的整体性能结果是 16 辆车中有 12 次命中,这是一个单棵决策树可能过度拟合训练数据的良好例子。

摘要

本章介绍了多种聚类和分类数据的方法,讨论了哪些分析程序和模型非常重要,并通常使用了数据科学家工具箱的元素。在下一章中,我们将关注一个不那么通用但仍然重要的领域——如何分析图和网络数据。

第十一章。R 生态系统的社会网络分析

尽管社会网络的概念有着相当长的历史,始于上个世纪初,但社会网络分析SNA)仅在最近十年变得极其流行,这可能是由于大型社交媒体网站的成功以及相关数据的可用性。在本章中,我们将探讨如何检索和加载数据,然后通过大量使用 igraph 包来分析和可视化这些网络。

Igraph 是由 Gábor Csárdi 开发的一个开源网络分析工具。该软件包含各种网络分析方法,并且可以在 R、C、C++ 和 Python 中使用。

在本章中,我们将通过一些 R 生态系统的示例来介绍以下主题:

  • 加载和处理网络数据

  • 网络中心性度量

  • 可视化网络图

加载网络数据

可能获取 R 生态系统网络信息最简单的方法就是分析 R 包之间的依赖关系。基于第二章,获取数据,我们可以尝试通过 CRAN 镜像的 HTTP 解析来加载数据,但幸运的是,R 有一个内置函数可以返回 CRAN 上所有可用的 R 包以及一些有用的元信息:

小贴士

CRAN 上托管的包数量每天都在增长。由于我们正在处理实时数据,您看到的实际结果可能会有所不同。

> library(tools)
> pkgs <- available.packages()
> str(pkgs)
 chr [1:6548, 1:17] "A3" "abc" "ABCanalysis" "abcdeFBA" ...
 - attr(*, "dimnames")=List of 2
 ..$ : chr [1:6548] "A3" "abc" "ABCanalysis" "abcdeFBA" ...
 ..$ : chr [1:17] "Package" "Version" "Priority" "Depends" ...

因此,我们有一个包含超过 6,500 行的矩阵,第四列包含以逗号分隔的依赖列表。与其解析这些字符串并从包版本和其他相对不重要的字符中清理数据,不如使用工具包中的另一个便捷函数来完成这项脏活:

> head(package.dependencies(pkgs), 2)
$A3
 [,1]      [,2] [,3] 
[1,] "R"       ">=" "2.15.0"
[2,] "xtable"  NA   NA 
[3,] "pbapply" NA   NA 

$abc
 [,1]       [,2] [,3] 
[1,] "R"        ">=" "2.10"
[2,] "nnet"     NA   NA 
[3,] "quantreg" NA   NA 
[4,] "MASS"     NA   NA 
[5,] "locfit"   NA   NA 

因此,package.dependencies 函数返回一个长的命名列表矩阵:每个 R 包一个,包括安装和加载引用包所需的包名和版本。除了相同的函数可以通过 depLevel 参数检索被导入或建议的包列表。我们将使用这些信息来构建一个包含 R 包之间不同类型连接的更丰富的数据集。

以下脚本创建了一个 data.frame,其中每一行代表两个 R 包之间的连接。src 列显示哪个 R 包引用了 dep 包,标签描述了连接的类型:

> library(plyr)
> edges <- ldply(
+   c('Depends', 'Imports', 'Suggests'), function(depLevel) {
+     deps <- package.dependencies(pkgs, depLevel = depLevel)
+     ldply(names(deps), function(pkg)
+         if (!identical(deps[[pkg]], NA))
+             data.frame(
+                 src   = pkg,
+                 dep   = deps[[pkg]][, 1],
+                 label = depLevel,
+                 stringsAsFactors = FALSE))
+ })

虽然这个代码片段一开始看起来可能很复杂,但我们只是查找每个包的依赖关系(就像在一个循环中),返回一行 data.frame,并在另一个循环中嵌套它,该循环遍历所有之前提到的 R 包连接类型。生成的 R 对象非常容易理解:

> str(edges)
'data.frame':  26960 obs. of  3 variables:
 $ src  : chr  "A3" "A3" "A3" "abc" ...
 $ dep  : chr  "R" "xtable" "pbapply" "R" ...
 $ label: chr  "Depends" "Depends" "Depends" "Depends" ...

网络中心性度量

因此,我们在我们的 6,500 个包之间识别了几乎 30,000 个关系。这是一个稀疏网络还是密集网络?换句话说,在所有可能的包依赖关系中,我们有多少个连接?如果所有包都相互依赖会怎样?我们实际上不需要任何功能丰富的包来计算这个:

> nrow(edges) / (nrow(pkgs) * (nrow(pkgs) - 1))
[1] 0.0006288816

这是一个相当低的百分比,这使得与维护一个密集的 R 软件网络相比,R 系统管理员的生活要容易得多。但谁是这场游戏中的核心玩家?哪些是最依赖的顶级 R 包?

我们也可以计算一个相当简单的指标来回答这个问题,而无需任何严肃的 SNA 知识,因为这个指标可以这样定义:“在边的数据集的dep列中,哪个 R 包被提及的次数最多?”或者,用简单的英语来说:“哪个包有最多的反向依赖?”

> head(sort(table(edges$dep), decreasing = TRUE))
 R  methods     MASS    stats testthat  lattice 
 3702      933      915      601      513      447

看起来大约有 50%的包依赖于 R 的最小版本。为了不扭曲我们的有向网络,让我们移除这些边:

> edges <- edges[edges$dep != 'R', ]

现在是时候将我们的连接列表转换成一个真正的图对象,以计算更高级的指标,并可视化数据了:

> library(igraph)
> g <- graph.data.frame(edges)
> summary(g)
IGRAPH DN-- 5811 23258 -- 
attr: name (v/c), label (e/c)

在加载包之后,graph.data.frame函数将各种数据源转换为igraph对象。这是一个具有各种支持方法的极其有用的类。摘要简单地打印出顶点和边的数量,这表明大约有 700 个 R 包没有依赖关系。让我们使用igraph计算之前讨论的和手动计算的指标:

> graph.density(g)
[1] 0.0006888828
> head(sort(degree(g), decreasing = TRUE))
 methods     MASS    stats testthat  ggplot2  lattice 
 933      923      601      516      459      454

在列表的顶部看到methods包并不令人惊讶,因为它通常在具有复杂S4方法和类的包中是必需的。MASSstats包包含了大多数常用的统计方法,但其他包呢?latticeggplot2包是极其智能且功能丰富的绘图引擎,而testthat是 R 中最受欢迎的单元测试扩展之一;在提交新包到中央 CRAN 服务器之前,必须在包描述中提到这一点。

但“度”只是社会网络中可用的中心性指标之一。不幸的是,当涉及到依赖关系时,计算每个节点与其他节点的距离的“接近度”并没有太大的意义,但“中介度”与前面的结果相比确实是一个有趣的比较:

> head(sort(betweenness(g), decreasing = TRUE))
 Hmisc     nlme  ggplot2     MASS multcomp      rms 
943085.3 774245.2 769692.2 613696.9 453615.3 323629.8

这个指标显示了每个包在连接其他包的最短路径中作为桥梁(连接两个其他节点的唯一连接节点)的次数。所以这并不是关于拥有很多依赖包的问题;相反,它从更全局的角度展示了包的重要性。想象一下,如果一个具有高中介度的包被弃用并从 CRAN 中删除;不仅直接依赖的包,而且依赖树中的所有其他包也会处于一个非常尴尬的境地。

可视化网络数据

为了比较这两个指标,让我们绘制一个简单的散点图,展示每个 R 包的degreebetweenness

> plot(degree(g), betweenness(g), type = 'n',
+   main = 'Centrality of R package dependencies')
> text(degree(g), betweenness(g), labels = V(g)$name)

可视化网络数据

放松;我们将在几分钟内能够生成更多壮观且富有教育意义的图表!但前一个图表明,有一些包的直接依赖项数量相当少,但仍然对全球 R 生态系统有重大影响。

在我们继续之前,让我们通过构建igraph包的依赖树,包括它所依赖或从中导入的所有包,来过滤我们的数据集和图,以包含更少的顶点:

小贴士

以下简短的igraph依赖项列表是在 2015 年 4 月生成的。从那时起,由于从magrittrNMF包中导入,igraph发布了一个主要的新版本,具有更多的依赖项,因此您计算机上重复的以下示例将返回一个更大的网络和图。出于教育目的,我们在以下输出中显示了较小的网络。

> edges <- edges[edges$label != 'Suggests', ]
> deptree <- edges$dep[edges$src == 'igraph']
> while (!all(edges$dep[edges$src %in% deptree] %in% deptree))
+   deptree <- union(deptree, edges$dep[edges$src %in% deptree])
> deptree
[1] "methods"   "Matrix"    "graphics"  "grid"      "stats"
[6] "utils"     "lattice"   "grDevices"

因此,我们需要之前提到的八个包才能使用igraph包。请注意,这些并不都是直接依赖项;其中一些是来自其他包的依赖项。为了绘制这个依赖树的视觉表示,让我们创建相关的图对象并绘制它:

> g <- graph.data.frame(edges[edges$src %in% c('igraph', deptree), ])
> plot(g)

可视化网络数据

好吧,igraph包实际上只依赖于一个包,尽管它也从Matrix包中导入了一些函数。所有其他之前提到的包都是后者的依赖项。

为了绘制一个更直观的版本的前一个图来表明这个陈述,我们可能考虑移除依赖标签,并通过颜色来表示这一方面,我们还可以通过顶点颜色来强调igraph的直接依赖。我们可以通过VE函数修改顶点和边的属性:

> V(g)$label.color <- 'orange'
> V(g)$label.color[V(g)$name == 'igraph'] <- 'darkred'
> V(g)$label.color[V(g)$name %in%
+        edges$dep[edges$src == 'igraph']] <- 'orangered'
> E(g)$color <- c('blue', 'green')[factor(df$label)]
> plot(g, vertex.shape = 'none', edge.label = NA)

可视化网络数据

太好了!我们的中心主题,igraph包,以深红色突出显示,两个直接依赖项以深橙色标记,所有其他依赖项都以较浅的橙色着色。同样,我们将Depends关系与大多数其他Imports连接相比,以蓝色强调。

交互式网络图

如果你不喜欢前一个图中顶点的顺序?请随意重新运行最后一个命令以生成新的结果,或者使用tkplot绘制动态图,在那里你可以通过拖放顶点来设计自定义布局:

> tkplot(g, edge.label = NA)

交互式网络图

我们能做得更好吗?尽管这个结果非常有用,但它缺乏目前流行的、由 JavaScript 提供支持的交互式图表的即时吸引力。所以,让我们用 JavaScript 重新创建这个交互式图表,从 R 开始!在第十三章“我们周围的数据”中详细讨论的 htmlwidgetsvisNetwork 包,即使没有 JavaScript 知识,也能帮助我们完成这项任务。只需将提取的节点和边数据集传递给 visNetwork 函数:

> library(visNetwork)
> nodes <- get.data.frame(g, 'vertices')
> names(nodes) <- c('id', 'color')
> edges <- get.data.frame(g)
> visNetwork(nodes, edges)

交互式网络图表

自定义图表布局

或者,我们也可以通过程序化方式生成这样的分层图表,通过绘制这个有向图的分母树:

> g <- dominator.tree(g, root = "igraph")$domtree
> plot(g, layout = layout.reingold.tilford(g, root = "igraph"), 
+   vertex.shape = 'none')

自定义图表布局

使用 R 包分析 R 包依赖关系

由于我们使用的是 R,这是一个统计编程环境,其最令人兴奋和有用的功能是其社区,我们可能更喜欢寻找其他已经实现的研究解决方案。经过快速 Google 搜索,并在 StackOverflow 上查阅了一些问题,以及在 www.r-bloggers.com/ 上的帖子后,很容易找到 Revolution Analytics 的 miniCRAN 包,它包含一些相关且有用的函数:

> library(miniCRAN)
> pkgs <- pkgAvail()
> pkgDep('igraph', availPkgs = pkgs, suggests = FALSE,
+   includeBasePkgs = TRUE)
[1] "igraph"    "methods"   "Matrix"    "graphics"  "grid"
[6] "stats"     "utils"     "lattice"   "grDevices"
> plot(makeDepGraph('igraph', pkgs, suggests = FALSE,
+   includeBasePkgs = TRUE))

使用 R 包分析 R 包依赖关系

但让我们回到原始问题:我们如何分析网络数据?

进一步的网络分析资源

除了其令人印象深刻且实用的数据可视化之外,igraph 包还有更多功能。不幸的是,这个简短的章节无法提供对网络分析理论的适当介绍,但我建议您浏览一下该包的文档,因为它包含有用的、自解释的示例和良好的参考。

简而言之,网络分析提供了各种计算中心性和密度指标的方法,就像我们在本章开头所做的那样,还可以识别桥梁和模拟网络中的变化;网络中节点分割的强大方法也很多。

例如,在我合著的《定量金融的 R 语言入门》一书的“金融网络”章节中,我们开发了 R 脚本来根据银行间借贷市场的交易级网络数据识别匈牙利中的系统性重要金融机构SIFI)。这个数据集和网络理论帮助我们建模并可能预测未来的金融危机,以及模拟中央干预的影响。

在芝加哥的 R/Finance 2015 会议上,介绍了这项研究的更详细、免费摘要www.rinfinance.com/agenda/2015/talk/GergelyDaroczi.pdf,同时展示了一个 Shiny 应用程序bit.ly/rfin2015-hunbanks,并在《精通 R 语言进行量化金融》一书的“系统性风险”章节中描述了一个基于模拟的感染模型。

这项联合研究的主要思想是根据由银行间贷款交易形成的网络,识别核心、外围和半外围金融机构。节点是银行,边被定义为这些银行之间的贷款事件,因此我们可以将外围节点之间的桥梁解释为介于小型银行之间的中介银行,这些小型银行通常不会直接相互贷款。

在解决数据集的一些技术问题之后,有趣的问题是模拟如果一家中介银行违约会发生什么,以及这个不幸的事件是否也可能影响其他金融机构。

摘要

这一小节介绍了以图数据集形式的新数据结构,我们使用各种 R 包,包括静态和交互式方法,可视化了小型网络。在接下来的两章中,我们将熟悉两种其他常用的数据类型:首先我们将分析时间序列数据,然后是空间数据。

第十二章:分析时间序列

时间序列是一系列按时间顺序排列的数据点,常用于经济学或例如社会科学。与横截面观测值相比,收集长时间数据的一个巨大优势是我们可以分析同一对象随时间收集的值,而不是比较不同的观测值。

数据的这一特殊特性需要新的方法和数据结构来进行时间序列分析。我们将在本章中介绍这些内容:

  • 首先,我们学习如何将观测值加载或转换成时间序列对象

  • 然后我们可视化它们,并尝试通过平滑和过滤观测值来改进图表

  • 除了季节分解,我们还介绍了基于时间序列模型的预测方法,同时也涵盖了识别时间序列中的异常值、极端值和异常的方法

创建时间序列对象

大多数关于时间序列分析的教程都是从stats包的ts函数开始的,它可以非常直接地创建时间序列对象。只需传递一个数值向量或矩阵(时间序列分析主要处理连续变量),指定数据频率,然后一切就绪!

频率指的是数据的自然时间跨度。因此,对于月度数据,你应该将其设置为 12,季度数据为 4,对于每日数据为 365 或 7,具体取决于事件的最显著季节性。例如,如果你的数据具有显著的周季节性,这在社会科学中很常见,那么它应该是 7,但如果日历日期是主要区分因素,例如与天气数据一样,那么它应该是 365。

在接下来的几页中,我们将使用hflights数据集的每日汇总统计数据。首先,让我们加载相关数据集并将其转换为data.table以方便聚合。我们还需要从提供的YearMonthDayofMonth列中创建一个日期变量:

> library(hflights)
> library(data.table)
> dt <- data.table(hflights)
> dt[, date := ISOdate(Year, Month, DayofMonth)]

现在,让我们计算 2011 年每一天的航班数量、总到达延误时间、取消航班数量以及相关航班的平均距离:

> daily <- dt[, list(
+     N         = .N,
+     Delays    = sum(ArrDelay, na.rm = TRUE),
+     Cancelled = sum(Cancelled),
+     Distance  = mean(Distance)
+ ), by = date]
> str(daily)
Classes 'data.table' and 'data.frame':	365 obs. of  5 variables:
 $ date     : POSIXct, format: "2011-01-01 12:00:00" ...
 $ N        : int  552 678 702 583 590 660 661 500 602 659 ...
 $ Delays   : int  5507 7010 4221 4631 2441 3994 2571 1532 ...
 $ Cancelled: int  4 11 2 2 3 0 2 1 21 38 ...
 $ Distance : num  827 787 772 755 760 ...
 - attr(*, ".internal.selfref")=<externalptr>

可视化时间序列

这是在一个非常熟悉的数据结构中:2011 年每一天有 365 行,五列用于存储存储在第一个变量中的四个指标。让我们将其转换为时间序列对象并立即绘制:

> plot(ts(daily))

可视化时间序列

很简单,对吧?我们已经在折线图上绘制了几个独立的时间序列。但第一个图表上显示的是什么?由于ts没有自动识别第一列存储的是我们的日期,因此x轴从 1 到 365 进行索引。另一方面,我们在y轴上找到了转换为时间戳的日期。点不应该形成一条直线吗?

这就是数据可视化的美妙之处:一个简单的图表揭示了我们的数据中的一个主要问题。看起来我们不得不按日期对数据进行排序:

> setorder(daily, date)
> plot(ts(daily))

可视化时间序列

现在值已经按正确顺序排列,我们可以一次一个地关注实际的时间序列数据。首先让我们看看 2011 年第一天以每日频率的航班数量:

> plot(ts(daily$N, start = 2011, frequency = 365),
+      main = 'Number of flights from Houston in 2011')

可视化时间序列

季节分解

嗯,看起来工作日的航班数量波动很大,这确实是与人类活动相关的主要特征。让我们通过分解这个时间序列为季节性、趋势和随机成分,并使用移动平均来识别和去除周季节性来验证这一点。

虽然这可以通过利用difflag函数手动完成,但使用stats包中的decompose函数来做会更直接:

> plot(decompose(ts(daily$N, frequency = 7)))

季节分解

去除每周季节性均值中的峰值揭示了 2011 年航班数量的整体趋势。正如x轴所示,自 1 月 1 日以来的周数(基于频率为 7)的峰值间隔在 25 到 35 之间指的是夏季,而第 46 周的航班数量最少——可能是因为感恩节。

但每周季节性可能更有趣。嗯,在先前的图表上很难找到任何东西,因为同样的 7 天重复可以在季节性图表上看到 52 次。所以,让我们提取这些数据,并用适当的标题在表格中显示:

> setNames(decompose(ts(daily$N, frequency = 7))$figure,
+         weekdays(daily$date[1:7]))
 Saturday      Sunday      Monday     Tuesday   Wednesday 
-102.171776   -8.051328   36.595731  -14.928941   -9.483886 
 Thursday      Friday 
 48.335226   49.704974

因此,季节性影响(前面的数字表示相对于平均值的相对距离)表明,航班数量最多的是星期一和最后两个工作日,而星期六的航班数量相对较少。

不幸的是,我们无法分解这个时间序列的年度季节性成分,因为我们只有一年的数据,而我们至少需要两个时间周期的数据来给出给定的频率:

> decompose(ts(daily$N, frequency = 365))
Error in decompose(ts(daily$N, frequency = 365)) : 
 time series has no or less than 2 periods

对于更高级的季节分解,请参阅stats包中的stl函数,它使用多项式回归模型对时间序列数据进行处理。下一节将涵盖一些这方面的背景知识。

Holt-Winters 过滤

我们可以通过 Holt-Winters 过滤类似地去除时间序列的季节性影响。将HoltWinters函数的beta参数设置为FALSE将导致一个模型,其中指数平滑几乎抑制了所有异常值;将gamma参数设置为FALSE将导致一个非季节性模型。以下是一个快速示例:

> nts <- ts(daily$N, frequency = 7)
> fit <- HoltWinters(nts, beta = FALSE, gamma = FALSE)
> plot(fit)

Holt-Winters 过滤

红线代表过滤后的时间序列。我们还可以通过启用betagamma参数,在时间序列上拟合双指数或三指数模型,从而得到更好的拟合:

> fit <- HoltWinters(nts)
> plot(fit)

Holt-Winters 过滤

由于这个模型与我们的原始数据相比提供了极其相似的价值,它可以用来预测未来的值。为此,我们将使用forecast包。默认情况下,forecast函数返回未来 2*频率值的预测:

> library(forecast)
> forecast(fit)
 Point Forecast    Lo 80    Hi 80    Lo 95    Hi 95
53.14286       634.0968 595.4360 672.7577 574.9702 693.2235
53.28571       673.6352 634.5419 712.7286 613.8471 733.4233
53.42857       628.2702 588.7000 667.8404 567.7528 688.7876
53.57143       642.5894 602.4969 682.6820 581.2732 703.9057
53.71429       678.2900 637.6288 718.9511 616.1041 740.4758
53.85714       685.8615 644.5848 727.1383 622.7342 748.9889
54.00000       541.2299 499.2901 583.1697 477.0886 605.3712
54.14286       641.8039 598.0215 685.5863 574.8445 708.7633
54.28571       681.3423 636.8206 725.8639 613.2523 749.4323
54.42857       635.9772 590.6691 681.2854 566.6844 705.2701
54.57143       650.2965 604.1547 696.4382 579.7288 720.8642
54.71429       685.9970 638.9748 733.0192 614.0827 757.9113
54.85714       693.5686 645.6194 741.5178 620.2366 766.9005
55.00000       548.9369 500.0147 597.8592 474.1169 623.7570

这些是 2012 年第一周和第二周的估计值,其中(除了精确的点预测外)我们还得到了置信区间。在这个时候,可视化这些预测和置信区间可能更有意义:

> plot(forecast(HoltWinters(nts), 31))

Holt-Winters 滤波

蓝色点表示对 31 个未来时间段的估计,灰色区域覆盖了由forecast函数返回的置信区间。

自回归积分移动平均模型

我们可以使用自回归积分移动平均ARIMA)模型达到类似的结果。为了预测时间序列的未来值,我们通常必须首先对其进行平稳化,这意味着数据在时间上有恒定的均值、方差和自相关。在前两个部分中,我们使用了季节分解和 Holt-Winters 滤波器来实现这一点。现在让我们看看自回归移动平均ARMA)模型的推广版本如何帮助进行这种数据转换。

ARIMA(p, d, q)实际上包括三个模型,有三个非负整数参数:

  • p代表模型的自回归部分

  • d代表积分部分

  • q代表移动平均部分

由于 ARIMA 模型也包括了 ARMA 模型上的一个积分(差分)部分,因此它也可以处理非平稳时间序列,因为它们在差分后自然变得平稳——换句话说,当d参数大于零时。

传统上,为时间序列选择最佳 ARIMA 模型需要构建具有各种参数的多个模型并比较模型拟合度。另一方面,forecast包提供了一个非常有用的函数,可以通过运行单位根测试并最小化模型的最大似然ML)和赤池信息量准则AIC)来选择最佳拟合的 ARIMA 模型:

> auto.arima(nts)
Series: ts 
ARIMA(3,0,0)(2,0,0)[7] with non-zero mean 

Coefficients:
 ar1      ar2     ar3    sar1    sar2  intercept
 0.3205  -0.1199  0.3098  0.2221  0.1637   621.8188
s.e.  0.0506   0.0538  0.0538  0.0543  0.0540     8.7260

sigma² estimated as 2626:  log likelihood=-1955.45
AIC=3924.9   AICc=3925.21   BIC=3952.2

看起来AR(3)模型具有最高的 AIC 值,并且具有AR(2)季节性效应。但检查auto.arima的说明书发现,由于观测值数量(超过 100)较多,用于模型选择的信度标准被近似了。重新运行算法并禁用近似会得到不同的模型:

> auto.arima(nts, approximation = FALSE)
Series: ts 
ARIMA(0,0,4)(2,0,0)[7] with non-zero mean 

Coefficients:
 ma1      ma2     ma3     ma4    sar1    sar2  intercept
 0.3257  -0.0311  0.2211  0.2364  0.2801  0.1392   621.9295
s.e.  0.0531   0.0531  0.0496  0.0617  0.0534  0.0557     7.9371

sigma² estimated as 2632:  log likelihood=-1955.83
AIC=3927.66   AICc=3928.07   BIC=3958.86

虽然看起来先前的季节性 ARIMA 模型具有很高的 AIC 值,但我们可能希望通过指定D参数来构建一个真正的 ARIMA 模型,从而通过以下估计得到一个积分模型:

> plot(forecast(auto.arima(nts, D = 1, approximation = FALSE), 31))

自回归积分移动平均模型

尽管时间序列分析有时可能很棘手(并且找到具有适当参数的最佳模型需要对这些统计方法有合理的经验),但前面的简短示例证明了即使对时间序列对象和相关方法有基本理解,通常也会在数据模式和适当预测方面提供一些令人印象深刻的结果。

异常值检测

除了预测之外,另一个与时间序列相关的主要任务是识别一系列观测值中的可疑或异常数据,这些数据可能会扭曲我们的分析结果。这样做的一种方法是通过构建 ARIMA 模型并分析预测值和实际值之间的距离。tsoutliers包提供了一个非常方便的方式来做到这一点。让我们在 2011 年取消航班的数量上构建一个模型:

> cts <- ts(daily$Cancelled)
> fit <- auto.arima(cts)
> auto.arima(cts)
Series: ts 
ARIMA(1,1,2)

Coefficients:
 ar1      ma1      ma2
 -0.2601  -0.1787  -0.7752
s.e.   0.0969   0.0746   0.0640

sigma² estimated as 539.8:  log likelihood=-1662.95
AIC=3333.9   AICc=3334.01   BIC=3349.49

因此,现在我们可以使用一个ARIMA(1,1,2)模型和tso函数来突出(并可选地移除)数据集中的异常值:

小贴士

请注意,以下tso调用可能需要几分钟才能在 CPU 核心上完全加载运行,因为它可能在后台执行大量计算。

> library(tsoutliers)
> outliers <- tso(cts, tsmethod = 'arima',
+   args.tsmethod  = list(order = c(1, 1, 2)))
> plot(outliers)

异常值检测

或者,我们可以通过在tso中自动调用auto.arima而无需指定除时间序列对象之外的任何额外参数,一次性运行所有前面的步骤:

> plot(tso(ts(daily$Cancelled)))

总之,结果显示,所有具有大量取消航班观测值的观测值都是异常值,因此应该从数据集中移除。好吧,将具有许多取消航班的任何一天视为异常值听起来非常乐观!但这是非常有用的信息;它表明,例如,使用先前讨论的方法来预测异常事件是不可管理的。

传统上,时间序列分析处理数据的趋势和季节性,以及如何平稳化时间序列。如果我们对正常事件的偏差感兴趣,则需要使用其他一些方法。

Twitter 最近发布了一个 R 包,用于检测时间序列中的异常。现在我们将使用其AnomalyDetection包以更快的速度识别先前的异常值。正如你可能已经注意到的,tso函数运行速度非常慢,并且实际上无法处理大量数据——而AnomalyDetection包的表现相当不错。

我们可以将输入数据作为data.frame向量的输入,其中第一列存储时间戳。不幸的是,AnomalyDetectionTs函数与data.table对象配合得并不好,所以我们还是回到传统的data.frame类:

> dfc <- as.data.frame(daily[, c('date', 'Cancelled'), with = FALSE])

现在让我们加载这个包并绘制观测值中识别出的异常:

> library(AnomalyDetection)
> AnomalyDetectionTs(dfc, plot = TRUE)$plot

异常值检测

结果与前面的图非常相似,但有两点需要注意,你可能已经注意到了。计算速度极快,另一方面,这个图包含了人类友好的日期,而不是x轴上的一些无趣的索引。

更复杂的时序对象

ts时序 R 对象类的主要局限性(除了前面提到的x轴问题之外)是它无法处理不规则时序。为了克服这个问题,我们在 R 中有几个替代方案。

zoo包及其反向依赖的xts包是ts兼容的类,拥有大量极其有用的方法。为了快速举例,让我们从我们的数据中构建一个zoo对象,并看看它如何通过默认的图表来表示:

> library(zoo)
> zd <- zoo(daily[, -1, with = FALSE], daily[[1]])
> plot(zd)

更复杂的时序对象

由于我们已经将date列定义为观测的时戳,因此这里没有显示。x轴有一个非常人性化的日期标注,这在检查了前面几页大量整数标注的图表之后,感觉非常愉快。

当然,zoo支持大多数ts方法,如difflag或累计总和;这些对于可视化数据速度非常有用:

> plot(cumsum(zd))

更复杂的时序对象

在这里,N变量的线性线表明我们没有缺失值,并且我们的数据集每天恰好包含一个数据点。另一方面,取消线在二月份的陡峭上升表明,某一天对 2011 年整体取消航班数量的贡献非常大。

高级时序分析

很遗憾,这一简短的章节无法提供对时序分析的更详细介绍。说实话,即使将本章的长度增加到两三倍,也还不够进行一个不错的教程,因为时序分析、预测和异常检测是统计分析中最复杂的话题之一。

但好消息是,关于这些主题有很多优秀的书籍!其中最好的资源——以及这个主题上最全面的免费在线教程——可以在www.otexts.org/fpp找到。这是一份非常实用且详细的在线教程,关于预测和一般时序分析,我强烈推荐给任何希望在未来构建更复杂和可实现的时序模型的人。

摘要

本章重点介绍了如何加载、可视化和建模与时间相关的数据。尽管我们无法涵盖这个具有挑战性的主题的所有方面,但我们讨论了最广泛使用的平滑和滤波算法、季节分解和 ARIMA 模型;我们还基于这些计算了一些预测和估计。

下一章与这一章有些相似,因为我们将在数据集的另一个重要维度上覆盖另一个领域独立的区域:而不是关注“何时”,我们将关注观测是在哪里捕获的。

第十三章。我们周围的数据

空间数据,也称为地理空间数据,标识了地理位置,例如我们周围的天然或人工特征。尽管所有观测值都有一些空间内容,例如观测值的位置,但由于空间信息的复杂性质,这通常超出了大多数数据分析工具的范围;或者,在给定的研究主题中,空间性可能不是那么有趣(乍一看)。

另一方面,分析空间数据可以揭示数据的一些非常重要的潜在结构,并且花时间可视化接近或远离数据点之间的差异和相似性是非常值得的。

在本章中,我们将帮助您完成这项工作,并使用各种 R 包来:

  • 从互联网检索地理空间信息

  • 在地图上可视化点和多边形

  • 计算一些空间统计量

地理编码

如前几章所述,我们将使用 hflights 数据集来演示如何处理包含空间信息的数据。为此,让我们像在 第十二章 分析时间序列 中所做的那样,对我们的数据集进行聚合,但这次不是生成每日数据,而是查看机场的聚合特征。为了性能考虑,我们将再次使用在 第三章 过滤和汇总数据 和 第四章 重构数据 中介绍的 data.table 包:

> library(hflights)
> library(data.table)
> dt <- data.table(hflights)[, list(
+     N         = .N,
+     Cancelled = sum(Cancelled),
+     Distance  = Distance[1],
+     TimeVar   = sd(ActualElapsedTime, na.rm = TRUE),
+     ArrDelay  = mean(ArrDelay, na.rm = TRUE)) , by = Dest]

因此,我们已经加载了 hfights 数据集,并将其立即转换为 data.table 对象。同时,我们按航班的目的地进行聚合,以计算:

  • 行数数量

  • 取消的航班数量

  • 距离

  • 飞行延误时间的标准差

  • 延误的算术平均值

结果的 R 对象看起来像这样:

> str(dt)
Classes 'data.table' and 'data.frame': 116 obs. of 6 variables:
 $ Dest     : chr  "DFW" "MIA" "SEA" "JFK" ...
 $ N        : int  6653 2463 2615 695 402 6823 4893 5022 6064 ...
 $ Cancelled: int  153 24 4 18 1 40 40 27 33 28 ...
 $ Distance : int  224 964 1874 1428 3904 305 191 140 1379 862 ...
 $ TimeVar  : num  10 12.4 16.5 19.2 15.3 ...
 $ ArrDelay : num  5.961 0.649 9.652 9.859 10.927 ...
 - attr(*, ".internal.selfref")=<externalptr>

因此,我们拥有全球 116 个观测值和五个描述这些观测值的变量。尽管这似乎是一个空间数据集,但我们没有计算机可以理解的地理空间标识符,所以让我们通过 ggmap 包从 Google Maps API 中获取这些机场的 地理编码。首先,让我们看看当我们寻找休斯顿的地理坐标时它是如何工作的:

> library(ggmap)
> (h <- geocode('Houston, TX'))
Information from URL : http://maps.googleapis.com/maps/api/geocode/json?address=Houston,+TX&sensor=false
 lon      lat
1 -95.3698 29.76043

因此,geocode 函数可以返回我们发送给 Google 的字符串匹配的纬度和经度。现在让我们为所有航班目的地做同样的事情:

> dt[, c('lon', 'lat') := geocode(Dest)]

好吧,这花了一些时间,因为我们不得不对 Google Maps API 进行了 116 次单独的查询。请注意,Google 对未经身份验证的用户每天的限制是 2,500 次查询,所以不要在大型数据集上运行此操作。该包中有一个辅助函数,称为 geocodeQueryCheck,可以用来检查当天剩余的免费查询次数。

在本章的一些后续部分中,我们计划使用的一些方法和函数不支持data.table,因此让我们退回到传统的data.frame格式,并打印当前对象的结构:

> str(setDF(dt))
'data.frame':  116 obs. of  8 variables:
 $ Dest     : chr  "DFW" "MIA" "SEA" "JFK" ...
 $ N        : int  6653 2463 2615 695 402 6823 4893 5022 6064 ...
 $ Cancelled: int  153 24 4 18 1 40 40 27 33 28 ...
 $ Distance : int  224 964 1874 1428 3904 305 191 140 1379 862 ...
 $ TimeVar  : num  10 12.4 16.5 19.2 15.3 ...
 $ ArrDelay : num  5.961 0.649 9.652 9.859 10.927 ...
 $ lon      : num  -97 136.5 -122.3 -73.8 -157.9 ...
 $ lat      : num  32.9 34.7 47.5 40.6 21.3 ...

这非常快且简单,不是吗?现在我们已经有了所有机场的经纬度值,我们可以尝试在地图上显示这些点。

在空间中可视化点数据

第一次,让我们保持简单,加载一些捆绑的包多边形作为基础地图。为此,我们将使用maps包。加载后,我们使用map函数渲染美国的 polygons,添加标题,然后添加一些代表机场和休斯顿的符号(略有修改):

> library(maps)
> map('state')
> title('Flight destinations from Houston,TX')
> points(h$lon, h$lat, col = 'blue', pch = 13)
> points(dt$lon, dt$lat, col = 'red', pch = 19)

在空间中可视化点数据

在图上显示机场名称也很容易:我们可以使用基graphics包中众所周知的功能。让我们将三个字符名称作为标签传递给text函数,并稍微增加 y 值以将先前的文本移至先前渲染的数据点:

> text(dt$lon, dt$lat + 1, labels = dt$Dest, cex = 0.7)

在空间中可视化点数据

现在,我们还可以指定要渲染的点颜色。这个功能可以用来绘制我们第一张有意义的地图,以突出 2011 年飞往美国不同地区的航班数量:

> map('state')
> title('Frequent flight destinations from Houston,TX')
> points(h$lon, h$lat, col = 'blue', pch = 13)
> points(dt$lon, dt$lat, pch = 19,
+   col = rgb(1, 0, 0, dt$N / max(dt$N)))
> legend('bottomright', legend = round(quantile(dt$N)), pch = 19, 
+   col = rgb(1, 0, 0, quantile(dt$N) / max(dt$N)), box.col = NA)

在空间中可视化点数据

因此,红色的强度表示飞往给定点的航班数量(机场);值从 1 到近 10,000。可能按州级别计算这些值更有意义,因为有许多机场彼此非常接近,可能更适合在更高级行政区域级别进行聚合。为此,我们加载了州的 polygons,将感兴趣的点(机场)与叠加的多边形(州)匹配,并将 polygons 作为主题地图渲染,就像我们在前面的页面所做的那样。

寻找点数据的多边形叠加

我们已经拥有了所有识别每个机场的母州所需的数据。dt数据集包括位置的地理坐标,我们使用map函数成功地将 states 作为 polygons 渲染出来。实际上,这个后一个函数可以返回底层数据集而不进行绘图:

> str(map_data <- map('state', plot = FALSE, fill = TRUE))
List of 4
 $ x    : num [1:15599] -87.5 -87.5 -87.5 -87.5 -87.6 ...
 $ y    : num [1:15599] 30.4 30.4 30.4 30.3 30.3 ...
 $ range: num [1:4] -124.7 -67 25.1 49.4
 $ names: chr [1:63] "alabama" "arizona" "arkansas" "california" ...
 - attr(*, "class")= chr "map"

因此,我们有大约 16,000 个点描述了美国各州的边界,但这个地图数据比我们实际需要的更详细(例如,以华盛顿为开头的多边形名称):

> grep('^washington', map_data$names, value = TRUE)
[1] "washington:san juan island" "washington:lopez island"
[3] "washington:orcas island"    "washington:whidbey island"
[5] "washington:main"

简而言之,一个州的非连接部分被定义为单独的多边形。为此,让我们保存一个不带冒号后字符串的州名称列表:

> states <- sapply(strsplit(map_data$names, ':'), '[', 1)

从现在开始,我们将使用这个列表作为聚合的基础。让我们将这个map数据集转换成另一种类的对象,以便我们可以使用sp包的强大功能。我们将使用maptools包来完成这个转换:

> library(maptools)
> us <- map2SpatialPolygons(map_data, IDs = states,
+    proj4string = CRS("+proj=longlat +datum=WGS84"))

注意

获取州多边形的一种替代方法可能是直接加载这些多边形,而不是像之前描述的那样从其他数据格式转换。为此,您可能会发现raster包特别有用,可以通过getData函数从gadm.org下载免费的地图shapefiles。尽管这些地图对于如此简单的任务来说过于详细,但您总是可以通过例如rgeos包的gSimplify函数来简化它们。

因此,我们刚刚创建了一个名为us的对象,它包括每个州的map_data的多边形,以及给定的投影。这个对象可以像我们之前做的那样显示在地图上,尽管您应该使用通用的plot方法而不是map函数:

> plot(us)

![寻找点数据的多边形叠加

然而,除了这个之外,sp包支持许多强大的功能!例如,通过over函数很容易识别提供的点的叠加多边形。由于该函数名与grDevices包中的函数冲突,最好使用双冒号同时引用函数和命名空间:

> library(sp)
> dtp <- SpatialPointsDataFrame(dt[, c('lon', 'lat')], dt,
+   proj4string = CRS("+proj=longlat +datum=WGS84"))
> str(sp::over(us, dtp))
'data.frame':  49 obs. of  8 variables:
 $ Dest     : chr  "BHM" "PHX" "XNA" "LAX" ...
 $ N        : int  2736 5096 1172 6064 164 NA NA 2699 3085 7886 ...
 $ Cancelled: int  39 29 34 33 1 NA NA 35 11 141 ...
 $ Distance : int  562 1009 438 1379 926 NA NA 1208 787 689 ...
 $ TimeVar  : num  10.1 13.61 9.47 15.16 13.82 ...
 $ ArrDelay : num  8.696 2.166 6.896 8.321 -0.451 ...
 $ lon      : num  -86.8 -112.1 -94.3 -118.4 -107.9 ...
 $ lat      : num  33.6 33.4 36.3 33.9 38.5 ...

这里发生了什么?首先,我们将坐标和整个数据集传递给SpatialPointsDataFrame函数,该函数将我们的数据以给定经纬度值存储为空间点。接下来,我们调用了over函数,将dtp的值左连接到美国各州。

注意

识别给定机场状态的另一种方法是向 Google Maps API 请求更详细的信息。通过更改geocode函数的默认output参数,我们可以获取匹配空间对象的全部地址组件,当然也包括州信息。例如,查看以下代码片段:

geocode('LAX','all')$results[[1]]$address_components

基于此,您可能希望为所有机场获取类似的输出,并过滤出州的简称。rlist包在这个任务中会非常有用,因为它提供了在 R 中操作列表的一些非常方便的方法。

这里唯一的问题是,我们只匹配了一个机场到各州,这显然是不正确的。例如,查看早期输出的第四列:它显示LAX加利福尼亚的匹配机场(由states[4]返回),尽管那里还有很多其他机场。

为了克服这个问题,我们可以至少做两件事。首先,我们可以使用over函数的returnList参数来返回dtp的所有匹配行,然后我们将后处理这些数据:

> str(sapply(sp::over(us, dtp, returnList = TRUE),
+   function(x) sum(x$Cancelled)))
 Named int [1:49] 51 44 34 97 23 0 0 35 66 149 ...
 - attr(*, "names")= chr [1:49] "alabama" "arizona" "arkansas" ...

因此,我们创建并调用了一个匿名函数,该函数将对over函数返回的列表中的每个元素的data.frame中的Cancelled值进行求和。

另一种可能更干净的方法是重新定义dtp以仅包含相关值,并将函数传递给over以进行汇总:

> dtp <- SpatialPointsDataFrame(dt[, c('lon', 'lat')],
+    dt[, 'Cancelled', drop = FALSE],
+    proj4string = CRS("+proj=longlat +datum=WGS84"))
> str(cancels <- sp::over(us, dtp, fn = sum))
'data.frame':  49 obs. of  1 variable:
 $ Cancelled: int  51 44 34 97 23 NA NA 35 66 149 ...

无论哪种方式,我们都有一个向量可以合并回美国各州名称:

> val <- cancels$Cancelled[match(states, row.names(cancels))]

并且将所有缺失的值更新为零(因为在没有任何机场的州中取消的航班数量不是缺失数据,而是一定为零):

> val[is.na(val)] <- 0

绘制主题地图

现在我们已经拥有了创建我们的第一个主题地图所需的一切。让我们将val向量传递给之前使用的map函数(或使用us对象进行绘图),指定一个绘图标题,添加一个代表休斯顿的蓝色点,然后创建一个图例,该图例显示整体取消航班数量的分位数作为参考:

> map("state", col = rgb(1, 0, 0, sqrt(val/max(val))), fill = TRUE)
> title('Number of cancelled flights from Houston to US states')
> points(h$lon, h$lat, col = 'blue', pch = 13)
> legend('bottomright', legend = round(quantile(val)),
+   fill = rgb(1, 0, 0, sqrt(quantile(val)/max(val))), box.col = NA)

绘制主题地图

请注意,我们决定计算相对值的平方根来定义填充颜色的强度,而不是使用线性刻度,这样我们可以直观地突出显示各州之间的差异。这是必要的,因为大多数航班取消发生在德克萨斯州(748),其他任何州的取消航班不超过 150 次(平均约为 45 次)。

注意

您也可以轻松地将 ESRI 形状文件或其他地理空间矢量数据格式加载到 R 中,作为点或多边形,使用已经讨论过的许多包以及一些其他包,例如maptoolsrgdaldismorastershapefile包。

另一种可能更容易的方法来生成国家层面的主题地图,特别是等值线地图,是加载由 Andy South 制作的rworldmap包,并依赖方便的mapCountryData函数。

绘制点周围的多边形

除了主题地图之外,另一种非常有用的展示空间数据的方式是根据数据值在数据点周围绘制人工多边形。这在没有可用的多边形形状文件来生成主题地图时尤其有用。

等高线图、轮廓图或等值线,可能是一些从旅游地图中已经熟悉的设计,其中山脉的高度由围绕山丘中心的线条表示,这些线条处于完全相同的水平。这是一种非常聪明的做法,因为地图展示了小山的高度——将这个第三维度投影到二维图像上。

现在,让我们尝试通过将我们的数据点视为平坦地图上的山脉来复制这种设计。我们已经知道这些小山(机场)的精确高度和几何中心的地理坐标;这里的唯一挑战是绘制这些物体的实际形状。换句话说:

  • 这些山脉是否相连?

  • 山坡的陡峭程度如何?

  • 我们是否应该考虑数据中的任何潜在的空间效应?换句话说,我们能否实际上将这些渲染为具有 3D 形状的山脉,而不是在空间中绘制独立的点?

如果对最后一个问题的答案是肯定的,那么我们可以开始尝试通过微调绘图参数来回答其他问题。现在,让我们简单地假设底层数据中存在空间效应,并且以这种方式可视化数据是有意义的。稍后,我们将有机会通过分析生成的图表或构建一些地理空间模型来证明或反驳这个陈述——其中一些将在后面的 空间统计 部分讨论。

等高线

首先,让我们使用 fields 包将我们的数据点扩展到一个矩阵。结果 R 对象的大小是任意定义的,但对于给定的行数和列数,为了生成更高分辨率的图像,256 是一个好的起点:

> library(fields)
> out <- as.image(dt$ArrDelay, x = dt[, c('lon', 'lat')],
+   nrow = 256, ncol = 256)

as.image 函数生成一个特殊的 R 对象,简而言之,它包括一个类似于三维矩阵的数据结构,其中 xy 轴分别代表原始数据的经纬度范围。为了进一步简化,我们有一个 256 行 256 列的矩阵,其中每一行和每一列都代表经纬度最低值和最高值之间均匀分布的离散值。而在 z 轴上,我们有 ArrDelay 值——当然,在大多数情况下这些值是缺失的:

> table(is.na(out$z))
FALSE  TRUE 
 112 65424

这个矩阵看起来是什么样子?最好是看看我们目前拥有的内容:

> image(out)

等高线

嗯,这似乎一点用处都没有。那里展示了什么?我们在这里用 z 颜色渲染了矩阵的 xy 维度,由于 z 轴上缺失值的高数量,这张地图的大部分图块都是空的。此外,现在很明显,数据集中还包括许多位于美国以外的机场。如果我们只关注美国,它会是什么样子?

> image(out, xlim = base::range(map_data$x, na.rm = TRUE),
+            ylim = base::range(map_data$y, na.rm = TRUE))

等高线

注意

另一种更优雅的方法是,在实际上创建 out R 对象之前,从数据库中删除非美国机场。虽然我们将继续使用这个例子进行教学,但在实际数据中,请确保您专注于数据的目标子集,而不是尝试平滑和建模无关的数据点。

好多了!所以我们的数据点现在以图块的形式呈现,现在让我们尝试识别这些山峰的斜率,以便能够在未来的地图上渲染它们。这可以通过平滑矩阵来完成:

> look <- image.smooth(out, theta = .5)
> table(is.na(look$z))
FALSE  TRUE 
14470 51066

如前表所示,该算法成功从矩阵中消除了许多缺失值。image.smooth 函数基本上在我们的初始数据点值中重新使用了相邻图块,并计算了一些冲突覆盖的平均值。这种平滑算法产生了以下任意地图,它不尊重任何政治或地理边界:

> image(look)

等高线

如果能将这些人工多边形与行政边界一起绘制出来,那就太好了。让我们清除所有不属于美国领土的单元。我们将使用sp包中的point.in.polygon函数来完成此操作:

> usa_data <- map('usa', plot = FALSE, region = 'main')
> p <- expand.grid(look$x, look$y)
> library(sp)
> n <- which(point.in.polygon(p$Var1, p$Var2,
+  usa_data$x, usa_data$y) == 0)
> look$z[n] <- NA

简而言之,我们加载了美国的主要多边形,没有包含任何次级行政区域,并在look对象中验证了我们的单元,如果它们与多边形重叠。然后,如果没有重叠,我们简单地重置单元的值。

下一步是渲染美国的边界,绘制我们的平滑等高线图,然后在地图上添加一些关于美国各州的美观元素,以及主要关注点——机场:

> map("usa")
> image(look, add = TRUE)
> map("state", lwd = 3, add = TRUE)
> title('Arrival delays of flights from Houston')
> points(dt$lon, dt$lat, pch = 19, cex = .5)
> points(h$lon, h$lat, pch = 13)

等高线

现在看起来相当不错,不是吗?

Voronoi 图

使用多边形可视化点数据的一种替代方法是生成它们之间的 Voronoi 单元。简而言之,Voronoi 地图通过将地图的所有部分对齐到其中一个区域以最小化中心数据点的距离,将空间划分为围绕数据点的区域。这在 R 中实现起来非常容易,deldir包提供了一个具有相同名称的函数用于 Delaunay 三角剖分:

> library(deldir)
> map("usa")
> plot(deldir(dt$lon, dt$lat), wlines = "tess", lwd = 2,
+   pch = 19, col = c('red', 'darkgray'), add = TRUE)

Voronoi 图

在这里,我们用红色点表示机场,就像之前一样,但还添加了以深灰色虚线渲染的 Dirichlet 划分(Voronoi 单元)。有关如何微调结果的更多选项,请参阅plot.deldir方法。

在下一节中,让我们看看如何通过添加更详细的背景地图来改进这个图表。

卫星地图

CRAN 上有许多 R 包可以从 Google Maps、Stamen、Bing 或 OpenStreetMap 获取数据,甚至我们在这章中之前使用的一些包,如ggmap包,也可以这样做。同样,dismo包也提供了地理编码和 Google Maps API 集成功能,还有一些其他专注于这一领域的包,如RgoogleMaps包。

现在我们将使用OpenStreetMap包,主要是因为它不仅支持令人惊叹的 OpenStreetMap 数据库后端,还支持许多其他格式。例如,我们可以通过 Stamen 渲染出非常漂亮的地面地图:

> library(OpenStreetMap)
> map <- openmap(c(max(map_data$y, na.rm = TRUE),
+                  min(map_data$x, na.rm = TRUE)),
+                c(min(map_data$y, na.rm = TRUE),
+                  max(map_data$x, na.rm = TRUE)),
+                type = 'stamen-terrain')

因此,我们定义了所需地图的左上角和右下角,并指定地图样式为卫星地图。由于默认情况下数据来自远程服务器,使用墨卡托投影,我们首先必须将其转换为 WGS84(我们之前使用过),这样我们才能在获取的地图上渲染点和多边形:

> map <- openproj(map,
+   projection = '+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs')

最后,展示时间到了:

> plot(map)
> plot(deldir(dt$lon, dt$lat), wlines = "tess", lwd = 2,
+   col = c('red', 'black'), pch = 19, cex = 0.5, add = TRUE)

卫星地图

与我们之前创建的轮廓地图相比,这似乎要好得多。现在你也可以尝试一些其他的地图样式,比如mapquest-aerial,或者一些看起来非常漂亮的cloudMade设计。

交互式地图

除了能够使用 Web 服务下载在 R 中创建的地图背景图块之外,我们还可以依赖其中的一些来生成真正交互式的地图。其中最知名的相关服务之一是 Google Visualization API,它为社区制作的可视化提供了一个托管平台;你也可以用它与他人分享你创建的地图。

查询谷歌地图

在 R 中,你可以通过由 Markus Gesmann 和 Diego de Castillo 编写和维护的googleVis包访问这个 API。该包的大多数函数生成 HTML 和 JavaScript 代码,我们可以直接在 Web 浏览器中以SVG对象的形式通过base绘图函数查看;或者,我们也可以通过例如 IFRAME HTML 标签将它们集成到网页中。

gvisIntensityMap函数接受一个包含国家 ISO 或美国州代码以及实际数据的data.frame,以创建一个简单的强度地图。我们将使用在寻找点数据的多边形叠加部分创建的cancels数据集,但在那之前,我们必须进行一些数据转换。让我们向data.frame添加一个新列作为州名,并用零替换缺失值:

> cancels$state <- rownames(cancels)
> cancels$Cancelled[is.na(cancels$Cancelled)] <- 0

现在是时候加载包并传递数据以及一些额外的参数,表示我们想要生成一个州级别的美国地图:

> library(googleVis)
> plot(gvisGeoChart(cancels, 'state', 'Cancelled',
+                   options = list(
+                       region      = 'US',
+                       displayMode = 'regions', 
+                       resolution  = 'provinces')))

查询谷歌地图

该包还提供了通过gvisMap函数查询 Google Map API 的机会。我们将使用这个功能将dt数据集中的机场渲染为谷歌地图上的点,并自动生成变量的工具提示。

但首先,像往常一样,我们又要进行一些数据转换。gvisMap函数的位置参数接受由冒号分隔的纬度和经度值:

> dt$LatLong <- paste(dt$lat, dt$lon, sep = ':')

我们还必须生成一个新的变量作为工具提示,这可以通过一个apply调用轻松完成。我们将变量名称和实际值通过 HTML 换行符连接起来:

> dt$tip <- apply(dt, 1, function(x)
+                  paste(names(dt), x, collapse = '<br/ >'))

现在我们只需将这些参数传递给函数,就可以立即得到一个交互式地图:

> plot(gvisMap(dt, 'LatLong', tipvar = 'tip'))

查询谷歌地图

googleVis包的另一个巧妙功能是,你可以通过使用gvisMerge函数轻松地将不同的可视化合并为一个。这个函数的使用相当简单:指定任何两个你想要合并的gvis对象,以及它们是水平还是垂直放置。

JavaScript 映射库

趋势 JavaScript 数据可视化库的成功不仅仅是因为它们优秀的设计。我怀疑其他因素也促进了这些工具的普遍传播:创建和部署完整的数据模型非常容易,尤其是在 Mike Bostock 的 D3.js 发布和持续开发之后。

尽管也有许多非常实用和智能的 R 包可以直接与 D3 和 topojson 交互(例如,请参阅我的 R 用户活动汇编bit.ly/countRies)。现在我们只关注如何使用 Leaflet——可能是最常用的 JavaScript 交互式地图库。

我真正喜欢 R 的是,有许多包封装了其他工具,这样 R 用户就可以只依赖一种编程语言,我们可以轻松地使用 C++程序和 Hadoop MapReduce 作业或构建 JavaScript 驱动的仪表板,而实际上对底层技术一无所知。这尤其适用于 Leaflet!

至少有两个非常棒的包可以从 R 控制台生成 Leaflet 图,而不需要一行 JavaScript。rCharts包的Leaflet参考类是由 Ramnath Vaidyanathan 开发的,包括一些创建新对象、设置视口和缩放级别、向地图添加一些点或多边形的方法,然后将生成的 HTML 和 JavaScript 代码渲染或打印到控制台或文件中。

不幸的是,这个包还没有在 CRAN 上,所以您必须从 GitHub 上安装它:

> devtools::install_github('ramnathv/rCharts')

作为快速示例,让我们生成一个带有一些工具提示的 Leaflet 机场地图,就像我们在上一节中使用 Google Maps API 所做的那样。由于setView方法期望将数值地理坐标作为地图的中心,我们将使用堪萨斯城的机场作为参考:

> library(rCharts)
> map <- Leaflet$new()
> map$setView(as.numeric(dt[which(dt$Dest == 'MCI'),
+   c('lat', 'lon')]), zoom = 4)
> for (i in 1:nrow(dt))
+     map$marker(c(dt$lat[i], dt$lon[i]), bindPopup = dt$tip[i])
> map$show()

JavaScript 映射库

类似地,RStudio 的leaflet包和更通用的htmlwidgets包也提供了一些简单的方法来生成 JavaScript 驱动的数据可视化。让我们加载库,并使用magrittr包中的管道操作符一步一步定义步骤,这对于所有由 RStudio 或 Hadley Wickham 创建或受其启发的包来说都是相当标准的:

> library(leaflet)
> leaflet(us) %>%
+   addProviderTiles("Acetate.terrain") %>%
+   addPolygons() %>%
+   addMarkers(lng = dt$lon, lat = dt$lat, popup = dt$tip)

JavaScript 映射库

我特别喜欢这张地图,因为我们可以在背景中加载第三方卫星地图,然后以多边形的形式渲染各州;我们还添加了原始数据点以及一些有用的工具提示,在同一个地图上仅用一行 R 命令即可实现。我们甚至可以根据我们在上一节中计算出的汇总结果来着色州的多边形!你尝试过在 Java 中做同样的事情吗?

不同的地图设计

除了能够使用第三方工具外,我倾向于使用 R 来完成所有数据分析任务的主要原因之一是 R 在创建自定义数据探索、可视化和建模设计方面非常强大。

例如,让我们基于我们的数据创建一个流图,我们将根据实际和取消的航班数量突出显示休斯顿的航班。我们将使用线条和圆圈在二维地图上渲染这两个变量,并且我们还将根据平均延误时间在背景中添加一个等高线图。

但,像往常一样,我们先进行一些数据转换!为了将流量数量保持在最低水平,最后让我们去掉美国以外的机场:

> dt <- dt[point.in.polygon(dt$lon, dt$lat,
+                           usa_data$x, usa_data$y) == 1, ]

我们将需要diagram包(用于从休斯顿到目的地的机场绘制曲线箭头)和scales包来创建透明颜色:

> library(diagram)
> library(scales)

然后,让我们绘制等高线部分中描述的等高线图:

> map("usa")
> title('Number of flights, cancellations and delays from Houston')
> image(look, add = TRUE)
> map("state", lwd = 3, add = TRUE)

然后从休斯顿到每个目的地机场添加一条曲线,其中线的宽度代表取消的航班数量,目标圆的直径显示实际航班的数量:

> for (i in 1:nrow(dt)) {
+   curvedarrow(
+     from       = rev(as.numeric(h)),
+     to         = as.numeric(dt[i, c('lon', 'lat')]),
+     arr.pos    = 1,
+     arr.type   = 'circle',
+     curve      = 0.1,
+     arr.col    = alpha('black', dt$N[i] / max(dt$N)),
+     arr.length = dt$N[i] / max(dt$N),
+     lwd        = dt$Cancelled[i] / max(dt$Cancelled) * 25,
+     lcol       = alpha('black',
+                    dt$Cancelled[i] / max(dt$Cancelled)))
+ }

替代地图设计

好吧,这一章最终是关于可视化空间数据,而不是真正通过拟合模型、过滤原始数据和寻找空间效应来分析空间数据。在这一章的最后部分,让我们看看如何开始使用空间数据分析方法。

空间统计学

大多数处理空间数据的数据探索分析项目都是从寻找和潜在过滤空间自相关开始的。简单来说,这意味着我们在寻找数据中的空间效应——例如,某些数据点的相似性可以(部分)由它们之间的短距离解释;更远的点似乎差异更大。这个陈述并不令人惊讶;可能你们所有人都同意这一点。但我们如何使用分析工具在真实数据上测试这一点呢?

Moran's I 指数是众所周知且普遍使用的衡量标准,用于测试感兴趣变量中是否存在空间自相关。这是一个相当简单的统计检验,其零假设是数据集中不存在空间自相关。

根据我们目前的数据结构,计算 Moran's I 最简单的方法可能是加载ape包,并将相似性矩阵以及感兴趣的变量传递给Moran.I函数。首先,让我们通过欧几里得距离矩阵的逆来计算这个相似性矩阵:

> dm <- dist(dt[, c('lon', 'lat')])
> dm <- as.matrix(dm)
> idm <- 1 / dm
> diag(idm) <- 0
> str(idm)
 num [1:88, 1:88] 0 0.0343 0.1355 0.2733 0.0467 ...
 - attr(*, "dimnames")=List of 2
 ..$ : chr [1:88] "1" "3" "6" "7" ...
 ..$ : chr [1:88] "1" "3" "6" "7" ...

然后让我们替换掉TimeVar列中所有可能缺失的值(因为航班数量也可能是一个,导致方差为零),并看看航班的实际耗时方差中是否存在任何空间自相关:

> dt$TimeVar[is.na(dt$TimeVar)] <- 0
> library(ape)
> Moran.I(dt$TimeVar, idm)
$observed
[1] 0.1895178

$expected
[1] -0.01149425

$sd
[1] 0.02689139

$p.value
[1] 7.727152e-14

这相当简单,不是吗?基于返回的P值,我们可以拒绝零假设,而0.19的 Moran's I 指数表明,航班耗时变化受到目的地机场位置的影响,这可能是由于非常不同的距离造成的。

之前提到的sp包的反向依赖项,spdep包也可以计算这个指数,尽管我们首先必须将相似性矩阵转换为列表对象:

> library(spdep)
> idml <- mat2listw(idm)
> moran.test(dt$TimeVar, idml)

 Moran's I test under randomisation

data:  dt$TimeVar 
weights: idml 

Moran I statistic standard deviate = 1.7157, p-value = 0.04311
alternative hypothesis: greater
sample estimates:
Moran I statistic       Expectation          Variance 
 0.108750656      -0.011494253       0.004911818

尽管测试结果与之前的运行相似,我们可以拒绝数据中零空间自相关的零假设,但 Moran's I 指数和P值并不相同。这主要是因为ape包使用了权重矩阵进行计算,而moran.test函数旨在与多边形数据一起使用,因为它需要数据的邻域列表。嗯,由于我们的例子包括点数据,这不是一个干净利落的解决方案。这两种方法之间的另一个主要区别是,ape包使用正态近似,而spdep实现随机化。但这个差异仍然太高,不是吗?

阅读函数文档可以发现,我们可以改进spdep方法:在将matrix转换为listw对象时,我们可以指定原始矩阵的实际类型。在我们的案例中,因为我们使用的是逆距离矩阵,所以行标准化样式似乎更合适:

> idml <- mat2listw(idm, style = "W")
> moran.test(dt$TimeVar, idml)

 Moran's I test under randomisation

data:  dt$TimeVar 
weights: idml 
Moran I statistic standard deviate = 7.475, p-value = 3.861e-14
alternative hypothesis: greater
sample estimates:
Moran I statistic       Expectation          Variance 
 0.1895177587     -0.0114942529      0.0007231471

现在的ape结果与我们的差异在可接受的范围内,对吧?

很遗憾,本节无法涵盖与空间数据相关的其他问题或统计方法,但市面上有许多专门针对这一主题的非常有用的书籍。请务必查看本书末尾的附录,以获取一些推荐的标题。

摘要

恭喜你,你已经完成了本书的最后一章系统性的章节!在这里,我们主要关注了如何使用数据可视化工具来分析空间数据。

现在,让我们看看如何将前几章学到的方法结合起来。在本书的最后部分,我们将使用各种数据科学工具来分析 R 社区。如果你喜欢这一章,我确信你也会喜欢最后一章。

第十四章。分析 R 社区

在这一最后一章中,我将尝试总结你过去 13 章中学到的内容。为此,我们将创建一个实际案例研究,独立于之前使用的hflightsmtcars数据集,并尝试估计 R 社区的规模。这是一个相当困难的任务,因为世界上没有 R 用户的列表;因此,我们将在多个部分数据集上构建一些预测模型。

为了达到这个目的,我们将在本章做以下几件事情:

  • 从互联网上的不同数据源收集实时数据

  • 清洗数据并将其转换为标准格式

  • 运行一些快速描述性和探索性分析方法

  • 可视化提取的数据

  • 基于独立名单构建一些基于 R 用户数量的对数线性模型

R 基金会成员

我们能做的最简单的事情之一是计算 R 基金会的成员数量——该组织协调核心 R 程序的开发。由于基金会的普通成员仅包括R 开发核心团队,我们最好检查支撑成员。任何人都可以通过支付象征性的年度费用成为基金会的支撑成员——顺便说一句,我强烈建议你这样做。名单可在r-project.org网站上找到,我们将使用XML包(更多细节,请参阅第二章“从网络获取数据”,“从网络获取数据”)来解析 HTML 页面:

> library(XML)
> page <- htmlParse('http://r-project.org/foundation/donors.html')

现在我们已经将 HTML 页面加载到 R 中,我们可以使用 XML 路径语言提取基金会的支撑成员名单,通过读取“支撑成员”标题之后的列表:

> list <- unlist(xpathApply(page,
+     "//h3[@id='supporting-members']/following-sibling::ul[1]/li", 
+     xmlValue))
> str(list)
 chr [1:279] "Klaus Abberger (Germany)" "Claudio Agostinelli (Italy)" 

从这个包含 279 个名称和国家的字符向量中,让我们分别提取支撑成员名单和国家名单:

> supporterlist <- sub(' \\([a-zA-Z ]*\\)$', '', list)
> countrylist   <- substr(list, nchar(supporterlist) + 3,
+                               nchar(list) - 1)

因此,我们首先通过移除字符串中从开括号开始的所有内容来提取名称,然后通过从名称中的字符数和原始字符串中计算出的字符位置来匹配国家。

除了 R 基金会 279 名支撑成员的名单外,我们还知道成员的国籍或居住地的比例:

> tail(sort(prop.table(table(countrylist)) * 100), 5)
 Canada Switzerland          UK     Germany         USA 
 4.659498    5.017921    7.168459   15.770609   37.992832 

可视化全球的支撑成员

可能并不令人惊讶,大多数支撑成员来自美国,一些欧洲国家也位于这个名单的顶端。让我们保存这个表格,以便在快速数据转换后,我们可以根据这个计数数据生成一张地图:

> countries <- as.data.frame(table(countrylist))

如第十三章“我们周围的数据”所述,“我们周围的数据”,rworldmap包可以非常容易地渲染国家级地图;我们只需将值映射到一些多边形上。在这里,我们将使用joinCountryData2Map函数,首先启用verbose选项以查看哪些国家名称被遗漏:

> library(rworldmap)
> joinCountryData2Map(countries, joinCode = 'NAME',
+    nameJoinColumn = 'countrylist', verbose = TRUE)
32 codes from your data successfully matched countries in the map
4 codes from your data failed to match with a country code in the map
 failedCodes failedCountries
[1,] NA          "Brasil" 
[2,] NA          "CZ" 
[3,] NA          "Danmark" 
[4,] NA          "NL" 
213 codes from the map weren't represented in your data

因此,我们尝试将存储在countries数据框中的国家名称进行匹配,但前述四个字符串失败了。尽管我们可以手动修复这个问题,但在大多数情况下,最好自动化我们可以处理的事情,所以让我们将所有失败的字符串传递给谷歌地图地理编码 API,看看它返回什么:

> library(ggmap)
> for (fix in c('Brasil', 'CZ', 'Danmark', 'NL')) {
+   countrylist[which(countrylist == fix)] <-
+       geocode(fix, output = 'more')$country
+ }

现在我们已经借助谷歌地理编码服务固定了国家名称,让我们重新生成频率表,并使用rworldmap包将这些值映射到多边形名称:

> countries <- as.data.frame(table(countrylist))
> countries <- joinCountryData2Map(countries, joinCode = 'NAME',
+   nameJoinColumn = 'countrylist')
36 codes from your data successfully matched countries in the map
0 codes from your data failed to match with a country code in the map
211 codes from the map weren't represented in your data

这些结果令人满意得多!现在我们已经将 R 基金会的支持成员数量映射到各个国家,因此我们可以轻松地绘制这些数据:

> mapCountryData(countries, 'Freq', catMethod = 'logFixedWidth',
+   mapTitle = 'Number of R Foundation supporting members')

可视化全球支持成员分布

好吧,很明显,R 基金会的多数支持成员都位于美国、欧洲、澳大利亚和新西兰(R 在这里诞生已有 20 多年)。

但支持者的数量非常低,遗憾的是,所以让我们看看我们可以找到和利用的其他数据源来估计全球 R 用户数量。

R 包维护者

另一个类似简单直接的数据源可能是 R 包维护者的列表。我们可以从 CRAN 的公共页面下载包维护者的名称和电子邮件地址,这些数据存储在一个结构良好的 HTML 表中,非常容易解析:

> packages <- readHTMLTable(paste0('http://cran.r-project.org', 
+   '/web/checks/check_summary.html'), which = 2)

Maintainer列中提取名称可以通过一些快速的数据清洗和转换来完成,主要使用正则表达式。请注意,列名以空格开头——这就是为什么我们引用了列名:

> maintainers <- sub('(.*) <(.*)>', '\\1', packages$' Maintainer')
> maintainers <- gsub(' ', ' ', maintainers)
> str(maintainers)
 chr [1:6994] "Scott Fortmann-Roe" "Gaurav Sood" "Blum Michael" ...

这个包含近 7000 个包维护者的列表中包含一些重复的名称(他们维护多个包)。让我们看看最顶尖、最多产的 R 包开发者的列表:

> tail(sort(table(maintainers)), 8)
 Paul Gilbert     Simon Urbanek Scott Chamberlain   Martin Maechler 
 22                22                24                25 
 ORPHANED       Kurt Hornik    Hadley Wickham Dirk Eddelbuettel 
 26                29                31                36 

尽管前面列表中有一个奇怪的名字(孤儿包没有维护者——值得一提的是,在 6994 个不再积极维护的包中只有 26 个是一个相当好的比例),但其他名字在 R 社区中确实很知名,并且致力于开发多个有用的包。

每个维护者拥有的包的数量

另一方面,列表中与仅一个或几个 R 包相关联的名称有很多。与其在简单的条形图或直方图上可视化每个维护者拥有的包的数量,不如加载fitdistrplus包,我们将在接下来的页面中使用它来拟合分析数据集上的各种理论分布:

> N <- as.numeric(table(maintainers))
> library(fitdistrplus)
> plotdist(N)

每个维护者拥有的包的数量

前面的图表还显示,列表中的大多数人只维护一个包,但不超过两个或三个包。如果我们对分布的尾部长度/重尾长度感兴趣,我们可能想调用descdist函数,该函数返回关于经验分布的一些重要描述性统计信息,并在偏度-峰度图上绘制不同的理论分布如何拟合我们的数据:

> descdist(N, boot = 1e3)
summary statistics
------
min:  1   max:  36 
median:  1 
mean:  1.74327 
estimated sd:  1.963108 
estimated skewness:  7.191722 
estimated kurtosis:  82.0168 

每个维护者拥有的包的数量

我们的实证分布似乎非常长尾,峰度非常高,看起来伽马分布是此数据集的最佳拟合。让我们看看这个伽马分布的估计参数:

> (gparams <- fitdist(N, 'gamma'))
Fitting of the distribution ' gamma ' by maximum likelihood 
Parameters:
 estimate Std. Error
shape 2.394869 0.05019383
rate  1.373693 0.03202067

我们可以使用这些参数通过rgamma函数模拟出更多的 R 包维护者。让我们看看在例如有 10 万名包维护者的情况下,CRAN 上会有多少 R 包可用:

> gshape <- gparams$estimate[['shape']]
> grate  <- gparams$estimate[['rate']]
> sum(rgamma(1e5, shape = gshape, rate = grate))
[1] 173655.3
> hist(rgamma(1e5, shape = gshape, rate = grate))

每个维护者拥有的包的数量

很明显,这个分布不像我们的实际数据集那样长尾:即使进行 10 万次模拟,最大的数量也低于 10,正如我们可以在前面的图中看到的那样;然而,现实中 R 包维护者的生产力要高得多,可以达到 20 个或 30 个包。

让我们通过基于前面的伽马分布估计不超过两个包的 R 包维护者的比例来验证这一点:

> pgamma(2, shape = gshape, rate = grate)
[1] 0.6672011

但在实际数据集中,这个百分比要高得多:

> prop.table(table(N <= 2))
 FALSE      TRUE 
0.1458126 0.8541874 

这可能意味着尝试拟合一个更长尾的分布。让我们看看例如帕累托分布将如何拟合我们的数据。为此,让我们通过使用最低值作为分布的位置,以及所有这些值与位置的对数差之和除以值的数量作为形状参数来遵循分析方法:

> ploc <- min(N)
> pshp <- length(N) / sum(log(N) - log(ploc))

不幸的是,基础stats包中没有ppareto函数,因此我们不得不首先加载actuarVGAM包来计算分布函数:

> library(actuar)
> ppareto(2, pshp, ploc)
[1] 0.9631973

嗯,现在这个比例甚至更高了!看起来前面的理论分布没有一个能完美地拟合我们的数据——但事实上这也是很正常的。但让我们看看这些分布如何在联合图上拟合我们的原始数据集:

> fg <- fitdist(N, 'gamma')
> fw <- fitdist(N, 'weibull')
> fl <- fitdist(N, 'lnorm')
> fp <- fitdist(N, 'pareto', start = list(shape = 1, scale = 1))
> par(mfrow = c(1, 2))
> denscomp(list(fg, fw, fl, fp), addlegend = FALSE)
> qqcomp(list(fg, fw, fl, fp),
+   legendtext = c('gamma', 'Weibull', 'Lognormal', 'Pareto')) 

每个维护者拥有的包的数量

总的来说,似乎帕累托分布是最接近我们长尾数据的拟合。但更重要的是,我们除了之前确定的 279 位 R 基金会支持成员外,还了解到了 4000 多位 R 用户:

> length(unique(maintainers))
[1] 4012

我们还能使用哪些数据源来获取关于(R 用户数量)的信息?

R-help 邮件列表

R-help 是官方的、主要的邮件列表,提供有关使用 R 解决问题的通用讨论,有众多活跃用户,每天有几十封电子邮件。幸运的是,这个公开的邮件列表在几个网站上都有存档,我们可以轻松地从例如 ETH Zurich 的 R-help 存档中下载压缩的月度文件:

> library(RCurl)
> url <- getURL('https://stat.ethz.ch/pipermail/r-help/')

现在让我们通过 XPath 查询从这个页面中提取月度压缩存档的 URL:

> R.help.toc <- htmlParse(url)
> R.help.archives <- unlist(xpathApply(R.help.toc,
+      "//table//td[3]/a", xmlAttrs), use.names = FALSE)

现在让我们将这些文件下载到我们的计算机上以供将来解析:

> dir.create('r-help')
> for (f in R.help.archives)
+     download.file(url = paste0(url, f),
+          file.path('help-r', f), method = 'curl'))

注意

根据您的操作系统和 R 版本,我们用于通过 HTTPS 协议下载文件的curl选项可能不可用。在这种情况下,您可以尝试其他方法或更新查询以使用RCurlcurlhttr包。

下载这些约 200 个文件需要一些时间,您可能还希望在循环中添加Sys.sleep调用,以避免服务器过载。无论如何,经过一段时间,您将在r-help文件夹中拥有R-help邮件列表的本地副本,准备好解析一些有趣的数据:

> lines <- system(paste0(
+     "zgrep -E '^From: .* at .*' ./help-r/*.txt.gz"),
+                 intern = TRUE)
> length(lines)
[1] 387218
> length(unique(lines))
[1] 110028

注意

我不是将所有文本文件加载到 R 中并使用grep,而是通过 Linux 命令行zgrep实用程序预先过滤了文件,该实用程序可以有效地搜索gzipped(压缩)文本文件。如果您没有安装zgrep(它在 Windows 和 Mac 上都是可用的),您可以首先提取文件,然后使用带有相同正则表达式的标准grep方法。

因此,我们筛选了所有包含电子邮件地址和姓名中发送者信息的电子邮件和标题行,从From字符串开始。在约 387,000 封电子邮件中,我们找到了大约 110,000 个独特的电子邮件来源。为了理解以下正则表达式,让我们看看这些行中的一行是如何看的:

> lines[26]
[1] "./1997-April.txt.gz:From: pcm at ptd.net (Paul C. Murray)"

现在我们通过移除静态前缀并提取电子邮件地址后面的括号中的名称来处理这些行:

> lines    <- sub('.*From: ', '', lines)
> Rhelpers <- sub('.*\\((.*)\\)', '\\1', lines)

我们还可以看到最活跃的R-help发帖者列表:

> tail(sort(table(Rhelpers)), 6)
 jim holtman     Duncan Murdoch         Uwe Ligges 
 4284               6421               6455 
Gabor Grothendieck  Prof Brian Ripley    David Winsemius 
 8461               9287              10135

这个列表看起来似乎是合法的,对吧?尽管我最初的猜测是教授布莱恩·里普利(Brian Ripley)以其简短的邮件内容将是这个列表中的第一个。由于一些早期的经验,我知道匹配名称可能会很棘手且繁琐,所以让我们验证我们的数据是否足够干净,并且教授的姓名只有一个版本:

> grep('Brian( D)? Ripley', names(table(Rhelpers)), value = TRUE)
 [1] "Brian D Ripley"
 [2] "Brian D Ripley [mailto:ripley at stats.ox.ac.uk]"
 [3] "Brian Ripley"
 [4] "Brian Ripley <ripley at stats.ox.ac.uk>"
 [5] "Prof Brian D Ripley"
 [6] "Prof Brian D Ripley [mailto:ripley at stats.ox.ac.uk]"
 [7] "         Prof Brian D Ripley <ripley at stats.ox.ac.uk>"
 [8] "\"Prof Brian D Ripley\" <ripley at stats.ox.ac.uk>"
 [9] "Prof Brian D Ripley <ripley at stats.ox.ac.uk>"
[10] "Prof Brian Ripley"
[11] "Prof. Brian Ripley"
[12] "Prof Brian Ripley [mailto:ripley at stats.ox.ac.uk]"
[13] "Prof Brian Ripley [mailto:ripley at stats.ox.ac.uk] "
[14] "          \tProf Brian Ripley <ripley at stats.ox.ac.uk>"
[15] "  Prof Brian Ripley <ripley at stats.ox.ac.uk>"
[16] "\"Prof Brian Ripley\" <ripley at stats.ox.ac.uk>"
[17] "Prof Brian Ripley<ripley at stats.ox.ac.uk>"
[18] "Prof Brian Ripley <ripley at stats.ox.ac.uk>"
[19] "Prof Brian Ripley [ripley at stats.ox.ac.uk]"
[20] "Prof Brian Ripley <ripley at toucan.stats>"
[21] "Professor Brian Ripley"
[22] "r-help-bounces at r-project.org [mailto:r-help-bounces at r-project.org] On Behalf Of Prof Brian Ripley" 
[23] "r-help-bounces at stat.math.ethz.ch [mailto:r-help-bounces at stat.math.ethz.ch] On Behalf Of Prof Brian Ripley"

好吧,看起来教授还使用了某些替代的From地址,因此对他消息数量的更准确估计可能应该是这样的:

> sum(grepl('Brian( D)? Ripley', Rhelpers))
[1] 10816

因此,使用快速的正则表达式从电子邮件中提取名称返回了我们感兴趣的大部分信息,但似乎我们需要花费更多的时间来获取整个信息集。像往常一样,帕累托法则适用:我们可以将大约 80%的时间用于准备数据,我们可以在整个项目时间线的约 20%内获得大约 80%的数据。

由于篇幅限制,我们在此不会更详细地介绍这个数据集的数据清洗,但我强烈建议检查 Mark van der Loo 的stringdist包,它可以计算字符串距离和相似度,例如,在这种情况下合并类似名称。

R-help 邮件列表的容量

但是除了发送者之外,这些电子邮件还包含一些其他非常有趣的数据。例如,我们可以提取电子邮件发送的日期和时间——以模拟邮件列表的频率和时序模式。

为了这个目的,让我们在压缩的文本文件中过滤一些其他行:

> lines <- system(paste0(
+     "zgrep -E '^Date: [A-Za-z]{3}, [0-9]{1,2} [A-Za-z]{3} ",
+     "[0-9]{4} [0-9]{2}:[0-9]{2}:[0-9]{2} [-+]{1}[0-9]{4}' ",
+     "./help-r/*.txt.gz"),
+                 intern = TRUE)

与之前提取的From行相比,这返回的行数更少:

> length(lines)
[1] 360817

这是因为电子邮件标题中使用了各种日期和时间格式,有时字符串中不包括星期几,或者年、月、日的顺序与大多数其他邮件相比是错误的。无论如何,我们只会关注这部分具有标准日期和时间格式的邮件,但如果你对转换这些其他时间格式感兴趣,你可能想查看 Hadley Wickham 的lubridate包以帮助你的工作流程。但请注意,没有通用的算法来猜测十进制年、月和日的顺序——所以你肯定会进行一些手动数据清洗!

让我们看看这些(子集)行看起来如何:

> head(sub('.*Date: ', '', lines[1]))
[1] "Tue, 1 Apr 1997 20:35:48 +1200 (NZST)"

然后,我们可以简单地去掉Date前缀,并通过strptime解析时间戳:

> times <- strptime(sub('.*Date: ', '', lines),
+            format = '%a, %d %b %Y %H:%M:%S %z')

现在数据已经解析格式化(即使是本地时区也被转换成了 UTC),相对容易看到,例如,每年邮件列表中的电子邮件数量:

> plot(table(format(times, '%Y')), type = 'l')

R-help 邮件列表的体积

注意

虽然过去几年R-help邮件列表的体积似乎有所下降,但这并不是由于 R 活动的减少:R 用户,无论是好是坏,还是互联网上的其他人,现在更倾向于使用其他信息渠道,而不是电子邮件——例如:StackOverflow 和 GitHub(甚至是 Facebook 和 LinkedIn)。有关相关研究,请参阅 Bogdan Vasilescu 等人发表在web.cs.ucdavis.edu/~filkov/papers/r_so.pdf的论文。

嗯,我们可以做得更好,对吧?让我们稍微调整一下我们的数据,并通过一个更优雅的图表来可视化基于星期几和一天中的小时数的邮件频率——灵感来自 GitHub 的打卡图:

> library(data.table)
> Rhelp <- data.table(time = times)
> Rhelp[, H := hour(time)]
> Rhelp[, D := wday(time)]

使用ggplot可视化这个数据集相对简单:

> library(ggplot2)
> ggplot(na.omit(Rhelp[, .N, by = .(H, D)]),
+      aes(x = factor(H), y = factor(D), size = N)) + geom_point() +
+      ylab('Day of the week') + xlab('Hour of the day') +
+      ggtitle('Number of mails posted on [R-help]') +
+      theme_bw() + theme('legend.position' = 'top')

R-help 邮件列表的体积

由于时间是按照 UTC 计算的,早上早些时候的邮件可能表明大多数R-help发件人所在的地区有正的 GMT 偏差——如果我们假设大多数电子邮件都是在工作时间写的。嗯,至少周末电子邮件数量较少似乎也支持这个说法。

看起来 UTC、UTC+1 和 UTC+2 时区确实相当常见,但美国时区对于R-help发件人来说也很常见:

> tail(sort(table(sub('.*([+-][0-9]{4}).*', '\\1', lines))), 22)
-1000 +0700 +0400 -0200 +0900 -0000 +0300 +1300 +1200 +1100 +0530 
 164   352   449  1713  1769  2585  2612  2917  2990  3156  3938 
-0300 +1000 +0800 -0600 +0000 -0800 +0200 -0500 -0400 +0100 -0700 
 4712  5081  5493 14351 28418 31661 42397 47552 50377 51390 55696

预测未来的电子邮件体积

我们还可以使用这个相对干净的数据库来预测R-help邮件列表的未来体积。为此,让我们将原始数据库聚合起来,按日计数,就像我们在第三章中看到的那样,过滤和汇总数据

> Rhelp[, date := as.Date(time)]
> Rdaily <- na.omit(Rhelp[, .N, by = date])

现在,让我们通过引用实际的邮件计数作为值和日期作为索引来将这个data.table对象转换成时间序列对象:

> Rdaily <- zoo(Rdaily$N, Rdaily$date)

嗯,这个每日数据集的波动性比之前渲染的年度图表要大得多:

> plot(Rdaily)

预测未来电子邮件量

但我们不会像在 第十二章 分析时间序列 中所做的那样,尝试平滑或分解这个时间序列,而是看看我们如何使用一些自动模型来提供一些基于历史数据的快速估计(关于这个邮件列表即将到来的邮件数量)。为此,我们将使用 forecast 包:

> library(forecast)
> fit <- ets(Rdaily)

ets 函数实现了一种完全自动的方法,可以选择给定时间序列的最佳趋势、季节和误差类型。然后我们可以简单地调用 predictforecast 函数来查看指定数量的估计,在这种情况下,仅针对下一天:

> predict(fit, 1)
 Point Forecast   Lo 80    Hi 80        Lo 95    Hi 95
5823       28.48337 9.85733 47.10942 -0.002702251 56.96945

因此,对于第二天,我们的模型估计大约有 28 封电子邮件,80% 的置信区间在 10 到 47 之间。通过使用标准 plot 函数和一些有用的新参数,可以可视化稍长时间段内的预测和历史数据:

> plot(forecast(fit, 30), include = 365)

预测未来电子邮件量

分析我们 R 用户列表之间的重叠

但我们的原始想法是预测全球 R 用户的数量,而不是关注一些较小的细分市场,对吧?现在我们有了多个数据源,我们可以开始构建一些模型,结合这些数据来提供全球 R 用户数量的估计。

这种方法背后的基本思想是捕获-再捕获方法,这在生态学中是众所周知的,我们首先尝试确定从人群中捕获一个单位的概率,然后我们使用这个概率来估计未捕获的单位数量。

在我们当前的研究中,单位将是 R 用户,样本是之前捕获的以下名称列表:

  • R 基金会 的支持者

  • 至少向 CRAN 提交了一个软件包的 R 包维护者

  • R-help 邮件列表的邮件发送者

让我们使用一个引用数据源的标签来合并这些列表:

> lists <- rbindlist(list(
+     data.frame(name = unique(supporterlist), list = 'supporter'),
+     data.frame(name = unique(maintainers),   list = 'maintainer'),
+     data.frame(name = unique(Rhelpers),      list = 'R-help')))

接下来,让我们看看我们可以在一个、两个或所有三个组中找到多少个名字:

> t <- table(lists$name, lists$list)
> table(rowSums(t))
 1     2     3 
44312   860    40

因此,至少有 40 人支持 R 基金会,至少在 CRAN 上维护了一个 R 软件包,并且自 1997 年以来至少发布了一封邮件到 R-help!我很高兴和自豪能成为这些人中的一员——尤其是我的名字中带有口音,这通常会使字符串匹配更加复杂。

现在,如果我们假设这些列表指的是同一人群,即全球的 R 用户,那么我们可以使用这些共同出现的情况来预测那些以某种方式错过了支持 R 基金会、维护 CRAN 上的软件包和向 R-help 邮件列表发送邮件的 R 用户数量。尽管这个假设显然是错误的,但让我们进行这个快速实验,稍后再回到这些悬而未决的问题。

R 中最好的事情之一是我们几乎为任何问题都有一个包。让我们加载Rcapture包,它提供了一些复杂但易于访问的捕获-再捕获模型方法:

> library(Rcapture)
> descriptive(t)

Number of captured units: 45212 

Frequency statistics:
 fi     ui     vi     ni 
i = 1  44312    279    157    279
i = 2    860   3958   3194   4012
i = 3     40  40975  41861  41861
fi: number of units captured i times
ui: number of units captured for the first time on occasion i
vi: number of units captured for the last time on occasion i
ni: number of units captured on occasion i 

这些来自第一列fi的数字与之前的表格中的数字相似,代表在一份、两份或三份列表上识别出的 R 用户数量。用简单的调用拟合一些模型会更有趣,例如:

> closedp(t)

Number of captured units: 45212 

Abundance estimations and model fits:
 abundance     stderr  deviance df       AIC       BIC
M0              750158.4    23800.7 73777.800  5 73835.630 73853.069
Mt              192022.2     5480.0   240.278  3   302.109   336.986
Mh Chao (LB)    806279.2    26954.8 73694.125  4 73753.956 73780.113
Mh Poisson2    2085896.4   214443.8 73694.125  4 73753.956 73780.113
Mh Darroch     5516992.8  1033404.9 73694.125  4 73753.956 73780.113
Mh Gamma3.5   14906552.8  4090049.0 73694.125  4 73753.956 73780.113
Mth Chao (LB)   205343.8     6190.1    30.598  2    94.429   138.025
Mth Poisson2   1086549.0   114592.9    30.598  2    94.429   138.025
Mth Darroch    6817027.3  1342273.7    30.598  2    94.429   138.025
Mth Gamma3.5  45168873.4 13055279.1    30.598  2    94.429   138.025
Mb                 -36.2        6.2   107.728  4   167.559   193.716
Mbh               -144.2       25.9    84.927  3   146.758   181.635

再次强调,这些估计实际上并不是针对全球所有 R 用户的丰富程度,因为:

  • 我们的非独立列表指的是更具体的群体

  • 模型假设并不成立

  • R 社区肯定不是一个封闭的群体,一些开放群体模型可能会更可靠

  • 我们遗漏了一些非常重要的数据清理步骤,正如所注

关于扩展捕获-再捕获模型的其他想法

尽管这个轻松的例子并没有真正帮助我们找出全球 R 用户数量,但通过一些扩展,基本想法肯定是可行的。首先,我们可能考虑分析源数据的小块——例如,在 R-help 存档的不同年份中寻找相同的电子邮件地址或姓名。这可能有助于估计那些考虑向R-help提交问题但最终没有发送电子邮件的人数(例如,因为另一个发帖者的问题已经得到解答,或者她/他未寻求外部帮助就解决了问题)。

另一方面,我们也可以向模型中添加许多其他数据源,这样我们就可以对一些没有向 R 基金会、CRAN 或 R-help 做出贡献的其他 R 用户进行更可靠的估计。

在过去的两年里,我一直在进行一项类似的研究,收集以下数据:

  • R 基金会的普通和赞助会员、捐赠者和资助者

  • 2004 年至 2015 年每年 R 会议的与会者人数

  • 2013 年和 2014 年按包和国家的 CRAN 下载量

  • R 用户组和成员人数的聚会

  • 2013 年的www.r-bloggers.com访客

  • 至少有一个包含 R 源代码存储库的 GitHub 用户

  • R 相关术语的 Google 搜索趋势

您可以在交互式地图上找到结果,并在 CSV 文件中找到按国家汇总的数据,该文件位于rapporter.net/custom/R-activity,以及在过去两届useR!会议上展示的离线数据可视化,位于bit.ly/useRs2015

社交媒体中的 R 用户数量

尝试估计 R 用户数量的另一种方法可能是分析社交媒体上相关术语的出现频率。在 Facebook 上这相对容易,因为营销 API 允许我们查询所谓的目标受众的大小,我们可以用这些信息来定义一些付费广告的目标。

好吧,我们现在实际上并不感兴趣在 Facebook 上创建付费广告,尽管这可以通过fbRads包轻松完成,但我们可以使用这个功能来查看对 R 感兴趣的人的目标群体的估计规模:

> library(fbRads)
> fbad_init(FB account ID, FB API token)
> fbad_get_search(q = 'rstats', type = 'adinterest')
 id                       name audience_size path description
6003212345926 R (programming language)       1308280 NULL          NA

当然,要运行这个快速示例,你需要拥有一个(免费)的 Facebook 开发者账户、一个注册的应用程序以及一个生成的令牌(请参阅包文档以获取更多详细信息),但这绝对值得:我们刚刚发现,全世界有超过 130 万用户对 R 感兴趣!这真的很令人印象深刻,尽管对我来说这似乎相当高,尤其是与其他一些统计软件相比,例如:

> fbad_get_search(fbacc = fbacc, q = 'SPSS', type = 'adinterest')
 id      name audience_size path description
1 6004181236095      SPSS        203840 NULL          NA
2 6003262140109 SPSS Inc.          2300 NULL          NA

话虽如此,将 R 与其他编程语言进行比较表明,受众规模可能实际上是正确的:

> res <- fbad_get_search(fbacc = fbacc, q = 'programming language',
+                        type = 'adinterest')
> res <- res[order(res$audience_size, decreasing = TRUE), ]
> res[1:10, 1:3]
 id                          name audience_size
1  6003030200185          Programming language     295308880
71 6004131486306                           C++      27812820
72 6003017204650                           PHP      23407040
73 6003572165103               Lazy evaluation      18251070
74 6003568029103   Object-oriented programming      14817330
2  6002979703120   Ruby (programming language)      10346930
75 6003486129469                      Compiler      10101110
76 6003127967124                    JavaScript       9629170
3  6003437022731   Java (programming language)       8774720
4  6003682002118 Python (programming language)       7932670

世界上似乎有很多程序员!但他们都在谈论什么,哪些是热门话题?我们将在下一节中探讨这些问题。

社交媒体中的 R 相关帖子

收集过去几天社交媒体帖子的一种选择是处理 Twitter 的全局 Tweet 数据流。这些流数据和分析 API 提供了访问所有推文的大约 1%的能力。如果你对所有这些数据感兴趣,那么需要一个商业 Twitter Firehouse 账户。在以下示例中,我们将使用免费的 Twitter 搜索 API,它基于任何搜索查询提供不超过 3,200 条推文的访问权限——但这将足以对 R 用户中的热门话题进行一些快速分析。

因此,让我们加载twitteR包,并通过提供我们的应用程序令牌和密钥来初始化与 API 的连接,这些令牌和密钥是在apps.twitter.com生成的:

> library(twitteR)
> setup_twitter_oauth(...)

现在,我们可以开始使用searchTwitter函数搜索任何关键词的推文,包括标签和提及。这个查询可以通过几个参数进行微调。Sinceuntiln分别设置开始和结束日期,以及要返回的推文数量。可以通过lang属性设置语言,使用 ISO 639-1 格式——例如,使用en表示英语。

让我们搜索带有官方 R 标签的最近推文:

> str(searchTwitter("#rstats", n = 1, resultType = 'recent'))
Reference class 'status' [package "twitteR"] with 17 fields
 $ text         : chr "7 #rstats talks in 2014"| __truncated__
 $ favorited    : logi FALSE
 $ favoriteCount: num 2
 $ replyToSN    : chr(0) 
 $ created      : POSIXct[1:1], format: "2015-07-21 19:31:23"
 $ truncated    : logi FALSE
 $ replyToSID   : chr(0) 
 $ id           : chr "623576019346280448"
 $ replyToUID   : chr(0) 
 $ statusSource : chr "Twitter Web Client"
 $ screenName   : chr "daroczig"
 $ retweetCount : num 2
 $ isRetweet    : logi FALSE
 $ retweeted    : logi FALSE
 $ longitude    : chr(0) 
 $ latitude     : chr(0) 
 $ urls         :'data.frame':	2 obs. of  5 variables:
 ..$ url         : chr [1:2] 
 "http://t.co/pStTeyBr2r" "https://t.co/5L4wyxtooQ"
 ..$ expanded_url: chr [1:2] "http://budapestbiforum.hu/2015/en/cfp" 
 "https://twitter.com/BudapestBI/status/623524708085067776"
 ..$ display_url : chr [1:2] "budapestbiforum.hu/2015/en/cfp" 
 "twitter.com/BudapestBI/sta…"
 ..$ start_index : num [1:2] 97 120
 ..$ stop_index  : num [1:2] 119 143

对于一个不超过 140 个字符的字符串来说,这确实是一个相当惊人的信息量。除了包括实际推文的文本外,我们还获得了一些元信息——例如,作者、发布时间、其他用户点赞或转发该帖子的次数、Twitter 客户端名称以及帖子中的 URL(包括缩短、展开和显示格式)。在某些情况下,如果用户启用了该功能,推文的地理位置信息也是可用的。

基于这条信息,我们可以以非常不同的方式关注 Twitter R 社区。以下是一些例子:

  • 计算提及 R 的用户数量

  • 分析社交网络或 Twitter 互动

  • 基于帖子时间的时序分析

  • 推文位置的时空分析

  • 推文内容的文本挖掘

可能是这些(和其他)方法的混合体将是最佳方法,我强烈建议你作为练习来做这件事,以巩固你在本书中学到的知识。然而,在接下来的几页中,我们只会专注于最后一项。

因此,首先,我们需要一些关于 R 编程语言的最新推文。为了搜索#rstats帖子,我们不仅可以提供相关的标签(就像我们之前做的那样),还可以使用Rtweets包装函数:

> tweets <- Rtweets(n = 500)

这个函数返回了 500 个与之前看到的类似的参考类。我们可以计算不包括转发的原始推文数量:

> length(strip_retweets(tweets))
[1] 149

但是,因为我们正在寻找热门话题,所以我们感兴趣的是原始推文列表,其中转发也很重要,因为它们为热门帖子提供了自然的权重。所以让我们将参考类列表转换为data.frame

> tweets <- twListToDF(tweets)

这个数据集包含 500 行(推文)和 16 个变量,涉及推文的内容、作者和位置,如前所述。现在,因为我们只对推文的实际文本感兴趣,所以让我们加载tm包并将我们的语料库导入,正如在第七章中看到的,非结构化数据

> library(tm)
Loading required package: NLP
> corpus <- Corpus(VectorSource(tweets$text))

由于数据格式正确,我们可以开始从常见英语单词中清理数据,并将所有内容转换为小写格式;我们可能还想删除任何额外的空白:

> corpus <- tm_map(corpus, removeWords, stopwords("english"))
> corpus <- tm_map(corpus, content_transformer(tolower))
> corpus <- tm_map(corpus, removePunctuation)
> corpus <- tm_map(corpus, stripWhitespace)

还明智地删除 R 标签,因为这是所有推文的一部分:

> corpus <- tm_map(corpus, removeWords, 'rstats')

然后,我们可以使用wordcloud包来绘制最重要的单词:

> library(wordcloud)
Loading required package: RColorBrewer
> wordcloud(corpus)

社交媒体中的 R 相关帖子

摘要

在过去的几页中,我试图涵盖各种数据科学和 R 编程主题,尽管由于篇幅限制,许多重要方法和问题都没有涉及。为此,我在书的参考文献章节中整理了一份简短的阅读清单。而且别忘了:现在轮到你自己练习前面章节中学到的所有内容了。我祝愿你在这一旅程中玩得开心,取得成功!

再次感谢阅读这本书;我希望你觉得它有用。如果你有任何问题、评论或任何形式的反馈,请随时联系,我期待着你的回复!

附录 A. 参考文献

虽然互联网上关于 R 和数据科学有很多优秀且免费的资源(例如 StackOverflow、GitHub 维基、www.r-bloggers.com/和一些免费电子书),但有时购买一本结构化的书籍会更好——就像你做的那样。

在这个附录中,我列出了一些我在学习 R 时发现有用的书籍和其他参考资料。如果你希望成为一个拥有良好 R 背景的专业数据科学家,并且不喜欢自学的方式,我建议你至少浏览一下这些材料。

为了确保可重复性,本书中使用的所有 R 包都列出了实际包版本和安装来源。

R 的通用阅读材料

虽然即将到来的列表与本书的不同章节相关,但以下是一些关于 R 入门和高级主题的优秀资源列表:

第一章:– 嗨,数据!

本章提到的 R 包版本(按顺序列出):

  • hflights 0.1 (CRAN)

  • microbenchmark 1.4-2 (CRAN)

  • R.utils 2.0.2 (CRAN)

  • sqldf 0.4-10 (CRAN)

  • ff 2.2-13 (CRAN)

  • bigmemory 4.4.6 (CRAN)

  • data.table 1.9.4 (CRAN)

  • RMySQL 0.10.3 (CRAN)

  • RPostgreSQL 0.4 (CRAN)

  • ROracle 1.1-12 (CRAN)

  • dbConnect 1.0 (CRAN)

  • XLConnect 0.2-11 (CRAN)

  • xlsx 0.5.7 (CRAN)

相关 R 包:

  • mongolite 0.4 (CRAN)

  • MonetDB.R 0.9.7 (CRAN)

  • RcppRedis 0.1.5 (CRAN)

  • RCassandra 0.1-3 (CRAN)

  • RSQLite 1.0.0 (CRAN)

相关阅读:

第二章:– 从网络获取数据

加载的 R 包版本(按章节中提到的顺序):

  • RCurl 1.95-4.1 (CRAN)

  • rjson 0.2.13 (CRAN)

  • plyr 1.8.1 (CRAN)

  • XML 3.98-1.1 (CRAN)

  • wordcloud 2.4 (CRAN)

  • RSocrata 1.4 (CRAN)

  • quantmod 0.4 (CRAN)

  • Quandl 2.3.2 (CRAN)

  • devtools 1.5 (CRAN)

  • GTrendsR(BitBucket @ d507023f81b17621144a2bf2002b845ffb00ed6d)

  • weatherData 0.4 (CRAN)

相关 R 包:

  • jsonlite 0.9.16 (CRAN)

  • curl 0.6 (CRAN)

  • bitops 1.0-6 (CRAN)

  • xts 0.9-7 (CRAN)

  • RJSONIO 1.2-0.2 (CRAN)

  • RGoogleDocs 0.7 (OmegaHat.org)

相关阅读:

第三章:– 过滤和汇总数据

加载的 R 包版本(按章节中提到的顺序):

  • sqldf 0.4-10 (CRAN)

  • hflights 0.1 (CRAN)

  • dplyr 0.4.1 (CRAN)

  • data.table 1.9.4. (CRAN)

  • plyr 1.8.2 (CRAN)

  • microbenchmark 1.4-2 (CRAN)

进一步阅读:

第四章:– 数据重构

加载的 R 包版本(按章节中提到的顺序):

  • hflights 0.1 (CRAN)

  • dplyr 0.4.1 (CRAN)

  • data.table 1.9.4. (CRAN)

  • pryr 0.1 (CRAN)

  • reshape 1.4.2 (CRAN)

  • ggplot2 1.0.1 (CRAN)

  • tidyr 0.2.0 (CRAN)

进一步的 R 包:

  • jsonlite 0.9.16 (CRAN)

进一步阅读:

第五章:– 构建模型(由 Renata Nemeth 和 Gergely Toth 撰写)

加载的 R 包版本(按章节中提到的顺序):

  • gamlss.data 4.2-7 (CRAN)

  • scatterplot3d 0.3-35 (CRAN)

  • Hmisc 3.16-0 (CRAN)

  • ggplot2 1.0.1 (CRAN)

  • gridExtra 0.9.1 (CRAN)

  • gvlma 1.0.0.2 (CRAN)

  • partykit 1.0-1 (CRAN)

  • rpart 4.1-9 (CRAN)

进一步阅读:

第六章:– 超越线性趋势线(由 Renata Nemeth 和 Gergely Toth 撰写)

加载的 R 包版本(按章节中提到的顺序):

  • catdata 1.2.1 (CRAN)

  • vcdExtra 0.6.8 (CRAN)

  • lmtest 0.9-33 (CRAN)

  • BaylorEdPsych 0.5 (CRAN)

  • ggplot2 1.0.1 (CRAN)

  • MASS 7.3-40 (CRAN)

  • broom 0.3.7 (CRAN)

  • data.table 1.9.4. (CRAN)

  • plyr 1.8.2 (CRAN)

进一步的 R 包:

  • LogisticDx 0.2 (CRAN)

进一步阅读:

第七章:– 非结构化数据

加载的 R 包版本(按章节中提到的顺序):

  • tm 0.6-1 (CRAN)

  • wordcloud 2.5 (CRAN)

  • SnowballC 0.5.1 (CRAN)

进一步的 R 包:

  • coreNLP 0.4-1 (CRAN)

  • topicmodels 0.2-2 (CRAN)

  • textcat 1.0-3 (CRAN)

进一步阅读:

第八章:– 数据精炼

加载的 R 包版本(按章节中提到的顺序):

  • hflights 0.1 (CRAN)

  • rapportools 1.0 (CRAN)

  • Defaults 1.1-1 (CRAN)

  • microbenchmark 1.4-2 (CRAN)

  • Hmisc 3.16-0 (CRAN)

  • missForest 1.4 (CRAN)

  • outliers 0.14 (CRAN)

  • lattice 0.20-31 (CRAN)

  • MASS 7.3-40 (CRAN)

进一步的 R 包:

  • imputeR 1.0.0 (CRAN)

  • VIM 4.1.0 (CRAN)

  • mvoutlier 2.0.6 (CRAN)

  • randomForest 4.6-10 (CRAN)

  • AnomalyDetection 1.0 (GitHub @ c78f0df02a8e34e37701243faf79a6c00120e797)

进一步阅读:

  • 推断和缺失数据, Biometrika 63(3), 581-592, Donald B. Rubin (1976)

  • 缺失数据的统计分析, Wiley Roderick, J. A. Little (2002)

  • 缺失数据灵活插补, CRC, Stef van Buuren (2012)

  • 稳健统计方法 CRAN 任务视图, Martin Maechler cran.r-project.org/web/views/Robust.html

第九章:– 从大数据到小数据

加载的 R 包版本(按章节中提到的顺序):

  • hflights 0.1 (CRAN)

  • MVN 3.9 (CRAN)

  • ellipse 0.3-8 (CRAN)

  • psych 1.5.4 (CRAN)

  • GPArotation 2014.11-1 (CRAN)

  • jpeg 0.1-8 (CRAN)

进一步的 R 包:

  • mvnormtest 0.1-9 (CRAN)

  • corrgram 1.8 (CRAN)

  • MASS 7.3-40 (CRAN)

  • sem 3.1-6 (CRAN)

  • ca 0.58 (CRAN)

进一步阅读:

第十章:- 分类与聚类

在本章中提到的顺序中加载的 R 包版本:

  • NbClust 3.0 (CRAN)

  • cluster 2.0.1 (CRAN)

  • poLCA 1.4.1 (CRAN)

  • MASS 7.3-40 (CRAN)

  • nnet 7.3-9 (CRAN)

  • dplyr 0.4.1 (CRAN)

  • class 7.3-12 (CRAN)

  • rpart 4.1-9 (CRAN)

  • rpart.plot 1.5.2 (CRAN)

  • partykit 1.0-1 (CRAN)

  • party 1.0-2- (CRAN)

  • randomForest 4.6-10 (CRAN)

  • caret 6.0-47 (CRAN)

  • C50 0.1.0-24 (CRAN)

进一步的 R 包:

  • glmnet 2.0-2 (CRAN)

  • gbm 2.1.1 (CRAN)

  • xgboost 0.4-2 (CRAN)

  • h2o 3.0.0.30 (CRAN)

进一步阅读:

第十一章:- R 生态系统的社会网络分析

在本章中提到的顺序中加载的 R 包版本:

  • tools 3.2

  • plyr 1.8.2 (CRAN)

  • igraph 0.7.1 (CRAN)

  • visNetwork 0.3 (CRAN)

  • miniCRAN 0.2.4 (CRAN)

进一步阅读:

  • 使用 R 进行网络数据统计分析Springer,由Eric D. KolaczykGábor Csárdi2014编写

  • 链接Plume PublishingAlbert-László Barabási2003

  • 使用 R 和 SoNIA 进行社会网络分析实验室,由Sean J. Westwood2010编写,可在sna.stanford.edu/rlabs.php找到

第十二章:– 时间序列分析

加载的 R 包版本(按章节中提到的顺序):

  • hflights 0.1 (CRAN)

  • data.table 1.9.4 (CRAN)

  • forecast 6.1 (CRAN)

  • tsoutliers 0.6 (CRAN)

  • AnomalyDetection 1.0 (GitHub)

  • zoo 1.7-12 (CRAN)

进一步的 R 包:

  • xts 0.9-7 (CRAN)

进一步阅读:

第十三章:– 周围的数据

加载的 R 包版本(按章节中提到的顺序):

  • hflights 0.1 (CRAN)

  • data.table 1.9.4 (CRAN)

  • ggmap 2.4 (CRAN)

  • maps 2.3-9 (CRAN)

  • maptools 0.8-36 (CRAN)

  • sp 1.1-0 (CRAN)

  • fields 8.2-1 (CRAN)

  • deldir 0.1-9 (CRAN)

  • OpenStreetMap 0.3.1 (CRAN)

  • rCharts 0.4.5 (GitHub @ 389e214c9e006fea0e93d73621b83daa8d3d0ba2)

  • leaflet 0.0.16 (CRAN)

  • diagram 1.6.3 (CRAN)

  • scales 0.2.4 (CRAN)

  • ape 3.2 (CRAN)

  • spdep 0.5-88 (CRAN)

进一步的 R 包:

  • raster 2.3-40 (CRAN)

  • rgeos 0.3-8 (CRAN)

  • rworldmap 1.3-1 (CRAN)

  • countrycode 0.18 (CRAN)

进一步阅读:

  • 使用 R 进行应用空间数据分析Springer,由Roger BivandEdzer PebesmaVirgilio Gómez-Rubio2013编写

  • 使用 R 进行生态和农业中的空间数据分析CRCRichard E. Plant2012

  • 使用 R 进行数值生态学Springer,由Daniel BorcardFrancois Gillet,和Pierre Legendre2012编写

  • R 空间分析和制图入门Sage,由Chris BrunsdonLex Comber2015编写

  • 地理计算:实践入门Sage,由Chris BrunsdonAlex David Singleton2015编写

  • 空间数据分析 CRAN 任务视图,由Roger Bivandcran.r-project.org/web/views/Spatial.html提供

第十四章:– 分析 R 社区

加载的 R 包版本(按章节中提到的顺序):

  • XML 3.98-1.1 (CRAN)

  • rworldmap 1.3-1 (CRAN)

  • ggmap 2.4 (CRAN)

  • fitdistrplus 1.0-4 (CRAN)

  • actuar 1.1-9 (CRAN)

  • RCurl 1.95-4.6 (CRAN)

  • data.table 1.9.4 (CRAN)

  • ggplot2 1.0.1 (CRAN)

  • forecast 6.1 (CRAN)

  • Rcapture 1.4-2 (CRAN)

  • fbRads 0.1 (GitHub @ 4adbfb8bef2dc49b80c87de604c420d4e0dd34a6)

  • twitteR 1.1.8 (CRAN)

  • tm 0.6-1 (CRAN)

  • wordcloud 2.5 (CRAN)

其他 R 包:

  • jsonlite 0.9.16 (CRAN)

  • curl 0.6 (CRAN)

  • countrycode 0.18 (CRAN)

  • VGAM 0.9-8 (CRAN)

  • stringdist 0.9.0 (CRAN)

  • lubridate 1.3.3 (CRAN)

  • rgithub 0.9.6 (GitHub @ 0ce19e539fd61417718a664fc1517f9f9e52439c)

  • Rfacebook 0.5 (CRAN)

进一步阅读:

posted @ 2025-10-26 09:01  绝不原创的飞龙  阅读(5)  评论(0)    收藏  举报