R-编程学习指南-全-
R 编程学习指南(全)
原文:
zh.annas-archive.org/md5/5176cdbcf4637b6c69e95148a9131e64译者:飞龙
前言
R 是为统计计算、数据分析和可视化设计的。近年来,它已经成为数据科学和统计学中最受欢迎的语言。R 编程大量涉及数据处理,对于那些不熟悉 R 语言行为的人来说,在 R 中编程可能是一个挑战。
作为一种动态语言,R 允许极其灵活地使用数据结构,这些数据结构不像编译语言(如 C++、Java 和 C#)那样严格。当我开始使用 R 来处理和分析数据时,我发现 R 的行为古怪、不可预测,有时非常不一致。
在那些数据分析项目中,大部分精力并没有花在运行模型上。相反,数据清洗、整理和可视化占了我大部分的时间。实际上,找到产生奇怪结果或意外错误的代码中的问题是最耗时的。处理编程问题而不是领域问题可能会很沮丧,尤其是当你对抗了几个小时虫子却毫无头绪时。
然而,随着我参与更多的项目并积累更多的经验,我逐渐更多地了解了对象和函数的行为,并发现 R 比我想的更加美丽和一致。这就是我写这本书的原因——分享我对 R 编程的看法。
通过这本书,你将发展出一个关于 R 作为编程语言及其庞大工具集的通用和一致的理解。你将学习到提高生产力的最佳实践,更深入地理解数据处理,并更加自信地使用 R 编程和用正确的技术解决问题。
本书涵盖的内容
第一章, 快速入门,讨论了一些关于 R 的基本事实,如何部署 R 环境,以及如何在 RStudio 中编码。
第二章, 基本对象,介绍了基本 R 对象及其行为。
第三章, 管理你的工作空间,介绍了管理工作目录、R 环境和扩展包库的方法。
第四章,基本表达式,涵盖了 R 语言的基本表达式:赋值、条件和循环。
第五章, 处理基本对象,讨论了每个分析师为了在 R 中处理基本对象应该知道的基本函数。
第六章, 处理字符串,讨论了与字符串相关的 R 对象以及一系列字符串操作技术。
第七章, 处理数据,解释了一些简单的读写数据函数,并使用基本对象和函数在各个主题上提供了一些实际示例。
第八章,R 内部,通过介绍懒加载评估、环境、函数和词法作用域的工作原理来讨论 R 的评估模型。
第九章,元编程,介绍了元编程技术,以帮助理解语言对象和非标准评估。
第十章,面向对象编程,描述了 R 中的众多面向对象编程系统:S3、S4、RefClass 以及社区提供的 R6。
第十一章,与数据库协同工作,展示了 R 如何与流行的关系型数据库(如 SQLite 和 MySQL)以及非关系型数据库(如 MongoDB 和 Redis)协同工作。
第十二章,数据处理,介绍了使用 data.table 和 dplyr 处理关系型数据的技术,以及使用 rlist 处理非关系型数据的技术。
第十三章,高性能计算,讨论了 R 中的性能问题以及几种提高计算性能的方法。
第十四章,网络爬虫,讨论了网页的基本结构、CSS 和 XPath 选择器,以及如何使用 rvest 包从简单的网页中抓取数据。
第十五章,提高生产力,演示了如何通过结合 R Markdown 和 shiny 应用以及交互式图形来提高数据分析报告和演示的生产力。
您需要为本书准备什么
要运行本书中的示例代码,您需要安装 R 3.3.0 或更高版本。RStudio 是推荐的开发环境。
对于第十一章,与数据库协同工作,需要运行示例需要一个可工作的 MongoDB 服务器和一个 Redis 实例。
对于第十三章,高性能计算,在 Windows 下构建 Rcpp 代码需要 Rtools 3.3,而在 Linux 或 macOS 下则需要 gcc 工具链。
本书面向的对象
本书针对那些从事数据相关项目并希望提高生产力但可能不熟悉编程语言和相关工具的人。
本书也针对希望系统地学习 R 编程语言、相关技术和推荐包及实践的资深数据分析师。
虽然有几章对初学者来说可能有些高级,但您不必是计算机专家或专业数据分析师就能阅读这些章节,但我假设您将有一个基本编程概念的一般概念和数据处理的基本经验。
习惯用法
在这本书中,您将找到许多用于区分不同类型信息的文本样式。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 标签以以下方式显示:“apply函数也支持数组输入和矩阵输出。”
行内代码单词(变量和函数名)和代码块的样式设置如下:
x <- c(1, 2, 3)
class(x)
## [1] "numeric"
typeof(x)
## [1] "double"
str(x)
## num [1:3] 1 2 3
当指出某个要点时,代码的某些区域将会突出显示:
x <- rnorm(100)
y <- 2 * x + rnorm(100) * 0.5
m <- lm(y ~ x)
coef(m)
新术语和重要词汇以粗体显示。
注意
注意事项或重要说明以这种方式出现在框中。
小贴士
小贴士和技巧看起来像这样。
读者反馈
我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。
要向我们发送一般性反馈,请简单地发送电子邮件到 feedback@packtpub.com,并在邮件主题中提及书籍的标题。
如果您在某个主题领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经是 Packt 书籍的骄傲拥有者,我们有一些可以帮助您充分利用购买的东西。
下载示例代码
您可以从www.packtpub.com下载本书的示例代码文件,从您的账户中操作。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持标签上。
-
点击代码下载与错误清单。
-
在搜索框中输入书籍的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
您也可以通过点击 Packt Publishing 网站上书籍网页上的代码文件按钮下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录到您的 Packt 账户。
下载文件后,请确保您使用最新版本的以下软件解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
书籍的代码包也托管在 GitHub 上github.com/PacktPublishing/learningrprogramming。我们还有其他来自我们丰富图书和视频目录的代码包可供在github.com/PacktPublishing/找到。查看它们吧!
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
侵权
在互联网上侵犯版权材料是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 copyright@packtpub.com 联系我们,并提供疑似侵权材料的链接。
我们感谢您在保护我们作者和我们提供有价值内容的能力方面所提供的帮助。
问题
如果您对本书的任何方面有问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。
第一章. 快速入门
没有适当的工具,数据分析是困难的。从一大堆按行和列排列的数字中直接提取模式并得出任何结论几乎是不可能的,即使是专家也是如此。一个合适的工具,如 R,将显著提高你在处理数据方面的生产力。根据我的经验,学习编程语言有点像学习人类语言。在了解大局、获得动力并从小处着手之前,直接跳入词汇和语法的细节可能不是一个好主意。本章通过深入概述 R 编程语言,为你提供一个快速入门。
在本章中,我们将涵盖以下主题:
-
介绍 R
-
R 的需求
-
安装 R
-
编写 R 代码所需的工具
一旦软件和工具准备就绪,你将编写一个简单的 R 程序来体验它基本的工作方式。一旦完成,R 之旅将从基础知识到高级技术和应用展开。
介绍 R
R 是一种强大的编程语言和环境,用于统计计算、数据探索、分析和可视化。它是免费的、开源的,并且有一个强大且快速增长的社区,用户和开发者在这里分享他们的经验,并积极为超过 7,500 个包的开发做出贡献,从而使 R 能够处理广泛领域的各种问题(参考cran.r-project.org/web/views/)。
尽管 R 编程语言的起源可以追溯到 1993 年,但其在 R 编程语言数据相关研究行业的普及在过去十年中迅速增长,并已成为数据科学的通用语言。
通常,R 应该被视为不仅仅是编程语言;它是一个全面的计算环境、一个强大而活跃的社区,以及一个快速发展和扩张的生态系统。
R 作为一种编程语言
R 作为一种编程语言,在过去 20 年里一直在发展和演变。其目标是相当明确的,即使其易于灵活地进行全面的统计计算、数据探索和可视化。
然而,易用性和灵活性通常会产生冲突。在统计分析中,点击几个按钮轻松完成各种任务可能非常简单,但如果需要定制、自动化,并且你的工作需要可重复性,那么它就不会灵活。使用数十个功能来转换数据和制作复杂的图形可能非常灵活,但正确学习和组合这些功能却不会容易。R 因其恰到好处的平衡而脱颖而出。
R 作为一个计算环境
R 作为一个计算环境,轻量级且易于使用。与其他一些著名的统计软件,例如 Matlab 和 SAS 相比,R 要小得多,部署也更容易。
在这本书中,我们将使用 RStudio 来处理我们几乎所有在 R 中的工作。这个集成开发环境提供了诸如语法高亮、自动完成、包管理、图形查看器、帮助查看器、环境查看器和调试等丰富功能。这些功能极大地提高了你的生产力。
R 作为一个社区
R 作为一个社区,强大而活跃。你可以立即访问 Try R (tryr.codeschool.com/),并通过一个交互式教程来获得 R 基础的第一印象。在实践中,当你编码时,你可能不会自己解决每一个问题。你可能会在 Google 上搜索一个 R 问题,并发现它几乎总是有 StackOverflow (stackoverflow.com/questions/tagged/r)上的答案。如果你的问题没有得到完全解决,你可以提出问题,并且可能几分钟内就能得到答案。
如果你需要使用一个包,但又想详细了解其工作原理,你可以访问其在线仓库(或 repo)的源代码。许多仓库由 GitHub 托管(www.github.com)。在 GitHub 上,你可以做很多事情。当你发现一个包运行不正常时,你可以通过在问题报告上提交一个 bug 来报告问题。如果你需要一个符合包用途的功能,你也可以通过提交一个需求报告来请求功能。如果你有兴趣通过解决 bug 和实现功能来为这个包做出贡献,你可以 fork 项目,编辑代码,并发送合并请求,以便你的更改可以被所有者接受。如果你的更改被接受,恭喜你,你已经成为了这个包的贡献者!令人惊讶的是,R 及其成千上万的包是由世界各地的贡献者共同构建的。
R 作为一个生态系统
R 作为一个生态系统,正在快速成长和扩展,涵盖了所有与数据相关的领域,而不仅仅是 IT 行业。其大多数用户不是专业开发者,而是数据分析师和统计学家。这些用户可能不会编写最佳质量的代码,但他们可能会用 R 语言为生态系统贡献前沿的工具,而其他人可以免费访问这些工具,无需重新发明轮子。
例如,假设一个计量经济学家编写了一个扩展包,其中包含了一种检测时间序列模式类别的新方法;它可能会吸引一些觉得它有趣和有用的用户。一些专业用户可能会改进原始代码,使其运行更快、更通用。过了一段时间,一个量化投资者可能会发现将这种方法纳入交易策略很有帮助,因为它可以检测通常会导致其投资组合风险的模式。最终,计量经济学家的工具被应用于现实世界的行业,而投资者发现其投资组合的风险降低了。
这就是生态系统的运作方式。这也是为什么 R 在这些领域如此受欢迎的原因之一:它能够快速将 IT 行业(通常是数据科学、学术界和工业)外的尖端知识转化为生态系统中普遍可用和适用的工具。换句话说,它促进了从领域知识和数据科学到生产力和价值的转换。
对 R 的需求
R 在众多统计软件中脱颖而出,原因如下:
-
免费:R 完全免费。您不需要购买许可证,因此使用它以及其大多数扩展包没有财务门槛。
-
开源:R 语言及其大多数包都是完全开源的。成千上万的开发者持续审查这些包的源代码,以检查是否有需要修复的错误或需要改进的地方。如果您遇到异常,甚至可以深入源代码,找到问题所在,并贡献于修复它。
-
流行:R 语言是非常流行,如果不是最受欢迎的,用于数据挖掘、分析和可视化的统计编程语言和平台。高流行度通常意味着您与其他用户之间的沟通更加容易,因为您“说”的是同一种语言。
-
灵活:R 是一种动态脚本语言。它高度灵活,允许使用多种范式进行编程,包括函数式编程和面向对象编程。它还支持灵活的元编程。其灵活性使您能够执行高度定制和全面的数据转换和可视化。
-
可重复性:当使用基于图形用户界面的软件时,您只需从菜单中选择并点击按钮即可。然而,如果不编写脚本,很难自动准确地重现您所做的工作。
在大多数科学研究和许多工业应用中,可重复性因多种原因而成为必要。R 脚本可以精确描述您在计算环境和数据上所做的工作,从而可以从头开始完全重复。
-
丰富的资源:R 语言拥有大量且不断增长的在线资源。其中一种资源是扩展包。截至撰写本文时,CRAN(即综合 R 档案网络)上已有超过 7,500 个包可供使用,这是一个全球镜像服务器网络,您可以从这里获取相同且最新的 R 分布和包。
这些包是由近 4,500 名包开发者创建和维护的,几乎涵盖了所有与数据相关的领域,例如多元分析、时间序列分析、计量经济学、贝叶斯推断、优化、金融、遗传学、化学计量学、计算物理学等。您可以查看 CRAN 任务视图([
cran.r-project.org/web/views/](https://cran.r-project.org/web/views/))以获得良好的总结。除了大量的包之外,还有许多作者定期撰写个人博客和 Stack Overflow 回答,分享他们的想法、经验和推荐的最佳实践。此外,还有很多专注于 R 的网站,例如 R 博客园(http://www.r-bloggers.com/)、R 文档(
www.rdocumentation.org/)和 METACRAN(www.r-pkg.org/)。 -
强大的社区:R 社区不仅包括 R 的开发者,还包括来自统计学、计量经济学、金融、生物信息学、机械工程、物理学、医学等众多背景的 R 用户(大多数)。
许多 R 开发者积极为开源项目或用 R 编写的包做出贡献。社区的目标是使数据分析、探索和可视化变得更加容易和有趣。
如果你遇到 R 中的问题,只需谷歌一下让你困惑的问题;可能,已经有了一些关于你问题的答案。如果没有,只需在 Stack Overflow 上提问,你将在很短的时间内得到回复。
-
前沿技术:许多 R 用户是统计学、计量经济学或其他学科的专业研究人员。他们经常在发布新包的同时发表新论文,这些包中包含了论文中展示的前沿技术。这可能是一个新的统计测试、一种模式识别方法,或者是一个更好的优化算法。
无论是什么,R 社区都有特权在其他人之前将前沿的数据科学知识应用于现实世界,从而提高其功能并揭示其潜力。
安装 R
要安装 R,你需要访问 R 的官方网站(www.r-project.org/),下载 R(cran.r-project.org/mirrors.html),选择一个附近的镜像,并下载适合你操作系统的版本。在撰写本文时,最新版本是 3.2.3。本书中的示例是在 Windows 和 Linux 下创建和运行的,但与先前版本或其他支持的操作系统中的输出应该没有显著差异。
如果你使用 Windows,只需下载最新版本的安装程序。要安装 R,运行你刚刚下载的 Windows 安装程序。安装过程易于处理,但许多用户可能仍会在几个步骤上遇到问题。
在 Windows 下拉菜单中,当选择要安装的组件时,安装程序列出了四个组件。在这里,核心文件意味着 R 的核心库,而消息翻译组件提供了支持语言列表中许多警告和错误消息的多个版本。然而,可能让你感到困惑的是32 位文件和64 位文件选项。只需不用担心;你只需要知道 64 位 R 可以在单个进程中处理比其 32 位对应版本多得多的数据。如果你使用的是近年来购买的现代计算机,它很可能支持 64 位程序,并且应该运行 64 位操作系统,所以默认选项将是 64 位文件。如果你使用的是 32 位操作系统,很遗憾,除非你的硬件支持,否则你无法使用 64 位 R。
无论如何,我建议你安装默认选项,如下面的截图所示:

你可能感到困惑的另一个选项是是否在注册表中保存 R 版本号。检查这些选项使得其他程序更容易检测已安装的 R 版本。如果你确定你只在自己的环境中使用 R,那么就继续使用默认设置。

然后,安装程序开始将文件复制到你的硬盘上。

最后,R 被部署到你的计算机上。目前,你只有两种方式可以使用 R:在命令提示符(或终端)中或在 R GUI 中。
如果你允许安装程序在你的桌面上创建程序快捷方式,你将在那里找到两个 R 快捷方式。R 在命令提示符中运行,而 RGUI 在一个非常简单的 GUI 中运行。
虽然你现在可以开始使用 R,但这并不意味着你必须以这种方式使用它。我强烈推荐使用 RStudio 来编辑和调试 R 脚本。实际上,这本书也是用 RStudio 中的 R Markdown 编写的。虽然 RStudio 功能强大,但没有正确安装 R 是无法工作的。换句话说,R 是后端,而 RStudio 是一个前端,它可以帮助你更好地与后端协同工作。
如果你使用的是 Windows,你也可以安装 Rtools (cran.rstudio.com/bin/windows/Rtools/),这样你就可以编写 C++ 代码,编译它,并在 R 中调用它,你还可以从它们的源代码中安装和编译包含 C/C++ 代码的包。
RStudio
RStudio 是 R 编程的强大用户界面。它是免费的、开源的,并且可以在包括 Windows、Mac 和 Linux 在内的多个平台上运行。
RStudio 具有非常强大的功能,这些功能极大地提高了你在数据分析和可视化中的生产力。它支持语法高亮、自动完成、多标签视图、文件管理、图形视口、包管理、集成帮助查看器、代码格式化、版本控制、交互式调试以及许多其他功能。
您可以在www.rstudio.com/products/rstudio/download下载最新版本的 RStudio。如果您想尝试带有新功能的预览版本,请从www.rstudio.com/products/rstudio/download/preview下载。请注意,RStudio 不包括 R,因此在 RStudio 中工作时,您需要确保已安装 R。
在接下来的章节中,我将为您简要介绍 RStudio 的用户界面。
RStudio 的用户界面
以下截图显示了 Windows 操作系统中的 RStudio 用户界面。如果您使用 Mac OS X 或支持的 Linux 版本,屏幕应该看起来几乎相同。

您可能会注意到主窗口由几个部分组成。每个部分被称为面板,并执行不同的功能。这些面板为数据分析师处理数据而精心设计。
控制台
以下截图显示了嵌入在 RStudio 中的 R 控制台。在大多数情况下,控制台的工作方式与命令提示符或终端完全相同。实际上,当您在控制台中输入命令时,RStudio 会将请求提交给 R 引擎。执行所有命令的是 R 引擎。RStudio 的作用是站在中间,从用户那里获取输入到 R 引擎,并展示它返回的结果。

使用控制台,您可以轻松执行命令、定义变量或交互式地评估表达式以计算统计量、转换数据或生成图表。
编辑器
在控制台中输入命令不是我们通常处理数据的方式。相反,我们编写脚本,一组代表逻辑流程的命令,可以从文件中读取并由 R 引擎执行。编辑器用于编辑 R 脚本、Markdown 文档、网页、许多类型的配置文件,甚至 C++源代码。

代码编辑器的功能远不止是一个纯文本编辑器:它支持语法高亮、R 代码的自动完成、带有断点的调试等。更具体地说,当编辑 R 脚本时,您可以使用以下快捷键:
-
按Ctrl + Enter以执行所选行
-
按Ctrl + Shift + S以源当前文档,即按顺序评估当前文档中的所有表达式
-
按Tab 键或 Ctrl + Space以显示与您当前输入匹配的变量和函数的自动完成列表
-
点击行号左侧的边缘设置断点;现在,当此行再次执行时,程序将暂停并等待您进行检查
环境面板
环境面板显示了您创建并可供重复使用的变量和函数。默认情况下,它显示全局环境中的变量,即您在其中工作的用户工作区。

每次您创建一个新的对象(变量或函数),环境面板中都会出现一个新的条目。该条目显示了变量名及其值的简短描述。当您更改符号的值或甚至删除该符号时,您实际上是在修改环境,以便环境面板反映您的更改。
历史面板
历史面板显示了在控制台中评估过的先前表达式。您可以通过在控制台中向上键来重复之前执行的任务。

历史记录可能存储在工作目录中的.Rhistory文件中。
文件面板
文件面板显示了文件夹中的文件。您可以在文件夹之间导航,创建新文件夹,删除或重命名文件夹或文件,等等。

如果您正在处理 RStudio 项目,文件面板对于查看和组织项目文件非常方便。
绘图面板
绘图面板用于显示由 R 代码生成的图形。如果您生成多个绘图,之前的绘图将被存储,您可以导航来回查看所有绘图(直到您清除它们)。

当您调整绘图面板的大小时,图形将适应其大小,以便它们看起来与调整大小之前一样好。您还可以将绘图导出为文件以供将来使用。
包面板
R 的许多功能都源于其包。包面板显示了所有已安装的包。您还可以轻松地从 CRAN 安装或更新包,或从您的库中删除现有的包。

帮助面板
R 的许多功能也源于其详细的文档。帮助面板显示了文档,以便您可以轻松学习如何使用函数。

有许多方式可以查看函数的文档:
-
在搜索框中输入函数名并直接查找
-
在控制台中输入函数名并按F1
-
在函数名前输入
?并执行它
在实践中,您不必记住 R 的所有函数;您只需要记住如何获取不熟悉的函数的帮助。
查看器面板
查看器面板是一个新功能;它是在越来越多的 R 包结合 R 和现有的 JavaScript 库的功能以制作丰富和交互式数据展示时引入的。
以下截图是我可格式化的(renkun.me/formattable)包的示例,该包提供了在 Excel 中使用 R 数据框进行条件格式化的一种简单实现:

RStudio Server
如果你使用的是支持的 Linux 版本,你可以轻松地设置 RStudio 的服务器版本或 RStudio Server。它运行在主机服务器上(可能比你的笔记本电脑更强大和稳定),你可以在你的网络浏览器中运行 RStudio 的 R 会话。用户界面大致相同,但你可以使用服务器的计算和内存资源,就像你使用本地计算机一样。
快速示例
在本节中,我将通过在控制台中输入命令来演示计算、模型拟合和生成图形的简单示例。
首先,让我们创建一个包含100个正态分布随机数的向量x。然后,创建另一个包含100个数字的向量y,每个数字是x中相应元素的 3 倍加上2和一些随机噪声。注意,<-是赋值运算符,我们将在后面介绍。我使用str()来打印向量的结构:
x <- rnorm(100) y <- 2 + 3 * x + rnorm(100) * 0.5 str(x)
## num [1:100] -0.4458 -1.2059 0.0411 0.6394 -0.7866 ...
str(y)
## num [1:100] -0.022 -1.536 2.067 4.348 -0.295 ...
由于我们知道X和Y之间的真实关系是
,因此我们可以在样本X和Y上运行简单的线性回归,看看线性模型如何恢复模型的线性参数(即2和3)。我们通过调用lm(y ~ x)来拟合这样的模型:
model1 <- lm(y ~ x)
模型拟合的结果存储在一个名为model1的对象中。我们可以通过简单地输入model1或明确输入print(model1)来查看模型拟合:
model1
##
## Call:
## lm(formula = y ~ x)
##
## Coefficients:
## (Intercept) x
## 2.051 2.973
如果你想查看更多细节,请使用model1调用summary():
summary(model1)
##
## Call:
## lm(formula = y ~ x)
##
## Residuals:
## Min 1Q Median 3Q Max
## -1.14529 -0.30477 0.03154 0.30042 0.98045
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 2.05065 0.04533 45.24 <2e-16 ***
## x 2.97343 0.04525 65.71 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.4532 on 98 degrees of freedom
## Multiple R-squared: 0.9778, Adjusted R-squared: 0.9776
## F-statistic: 4318 on 1 and 98 DF, p-value: < 2.2e-16
我们可以将点和拟合的模型一起绘制:
plot(x, y, main = "Simple linear regression")
abline(model1$coefficients, col = "blue")

上述截图演示了一些简单函数,以便你能够对使用 R 有一个初步的了解。如果你不熟悉示例中的符号和函数,不要担心:接下来的几章将涵盖你需要了解的基本对象和函数。
摘要
在本章中,你了解了一些关于 R 及其主要优势的基本事实。我们学习了如何在 Windows 操作系统上安装 R。为了使 R 编程更简单,我们选择使用 RStudio,并了解了 RStudio 的用户界面,你了解到每个窗格的功能是其主窗口。最后,我们运行了几个 R 命令来拟合模型和绘制简单的图形,从而对使用 R 的方式有一个初步的了解。
在下一章中,我们将介绍 R 中的基本概念和数据结构,以帮助你熟悉基本 R 对象的行为。只有在你熟悉了这些之后,你才能轻松地表示、操作和处理各种数据。
第二章:基本对象
学习 R 编程的第一步是熟悉基本 R 对象及其行为。在本章中,您将学习以下主题:
-
创建和子集原子向量(例如,数值向量、字符向量和逻辑向量)、矩阵、数组、列表和数据框。
-
定义和使用函数
“存在的一切都是对象。发生的一切都是函数。” —— 约翰·查普曼*
例如,在统计分析中,我们经常将一组数据输入到线性回归模型中,并得到一组线性系数。
假设 R 中有不同类型的对象,当我们这样做时,在 R 中基本上发生的事情是,我们提供一个包含数据集的数据框对象,将其传递给线性模型函数,并得到一个包含回归结果属性的列表对象,最后从列表中提取一个数值向量,这是另一种类型的对象,用来表示线性系数。
每项任务都涉及各种不同类型的对象。每个对象都有不同的目标和行为。为了解决现实世界的问题,特别是用更优雅的代码和更少的步骤,理解基本对象的工作方式非常重要。更重要的是,对对象行为的更具体理解,让您有更多时间来解决您的问题,而不是在编写正确代码时被无数的小问题所困扰。
在以下章节中,我们将看到 R 中代表不同类型数据的基本对象,这些对象使得分析和可视化数据集变得容易。您将基本了解这些对象是如何工作的以及它们之间是如何相互作用的。
向量
向量是一组相同类型的原始值。它可以是一组数字、真/假值、文本或其他类型的值。它是所有 R 对象的基本构建块之一。
R 中有几种不同类型的向量。它们在存储的元素类型上彼此不同。在以下章节中,我们将看到最常用的向量类型,包括数值向量、逻辑向量和字符向量。
数值向量
数值向量是数值值的向量。一个标量数是最简单的数值向量。以下是一个示例:
1.5
## [1] 1.5
数值向量是最常用的数据类型,是几乎所有数据分析的基础。在其他流行的编程语言中,有一些标量类型,如整数、双精度和字符串,这些标量类型是容器类型(如向量)的构建块。然而,在 R 中,没有正式的标量类型定义。标量数只是数值向量的一个特殊情况,它之所以特殊,仅仅是因为它的长度为 1。
当我们创建一个值时,自然会想到如何将其存储起来以供将来使用。为了存储值,我们可以使用 <- 将值赋给一个符号。换句话说,我们创建了一个名为 x 的值为 1.5 的变量:
x <- 1.5
然后,值被赋给符号 x,从现在起我们可以使用 x 来表示这个值:
x
## [1] 1.5
创建数值向量的方法有多种。我们可以调用 numeric() 来创建给定长度的零向量:
numeric (10)
## [1] 0 0 0 0 0 0 0 0 0 0
我们也可以使用 c() 来组合几个向量,使其成为一个向量。最简单的情况是,例如,将几个单元素向量组合成一个多元素向量:
c(1, 2, 3, 4, 5)
## [1] 1 2 3 4 5
我们还可以组合单元素向量和多元素向量,并得到一个与我们之前创建的具有相同元素的向量:
c(1, 2, c(3, 4, 5))
## [1] 1 2 3 4 5
要创建一系列连续的整数,: 运算符可以轻松完成这个任务。
1:5
## [1] 1 2 3 4 5
严格来说,前面的代码生成的是一个整数向量,而不是数值向量。在许多情况下,它们的区别并不重要。我们将在后面讨论这个话题。
生成数值序列的更通用方法是 seq()。例如,以下代码通过增量 2 生成从 1 到 10 的数值向量:
seq(1, 10, 2)
## [1] 1 3 5 7 9
类似于 seq() 的函数有很多参数。我们可以通过提供所有参数来调用这样的函数,但在大多数情况下并不需要。大多数函数为某些参数提供了合理的默认值,这使得我们更容易调用它们。在这种情况下,我们只需要指定我们想要从其默认值修改的参数。
例如,我们可以通过指定 length.out 参数来创建一个从 3 开始长度为 10 的另一个数值向量:
seq(3, length.out = 10)
## [1] 3 4 5 6 7 8 9 10 11 12
类似于上面的函数调用使用命名参数 length.out,这样其他参数保持默认值,只修改这个参数。
我们可以定义数值向量的方式有很多,但我们在使用 : 时应该始终小心,以下是一个示例:
1 + 1:5
## [1] 2 3 4 5 6
如结果所示,1 + 1:5 并不表示从 2 到 5 的序列,而是从 2 到 6。这是因为 : 的优先级高于 +,导致首先计算 1:5,然后将 1 加到每个元素上,得到结果中看到的序列。我们将在后面讨论运算符的优先级。
逻辑向量
与数值向量不同,逻辑向量存储了一组 TRUE 或 FALSE 值。它们基本上是针对一组逻辑问题的“是”或“否”的回答。
最简单的逻辑向量就是 TRUE 和 FALSE 本身:
TRUE
## [1] TRUE
获取逻辑向量的更常见方式是询问 R 对象的逻辑问题。例如,我们可以询问 R 是否 1 大于 2:
1 > 2
## [1] FALSE
答案是肯定的,用 TRUE 表示。有时,写 TRUE 和 FALSE 有点啰嗦;因此,我们可以用 T 作为 TRUE 的缩写,用 F 作为 FALSE 的缩写。如果我们想同时进行多个比较,我们可以直接在问题中使用数值向量:
c(1, 2) > 2
## [1] FALSE FALSE
R 将此表达式解释为 c(1, 2) 和 2 的逐元素比较。换句话说,它等同于 c(1 > 2, 2 > 2)。
只要较长向量的长度是较短向量长度的倍数,我们就可以比较两个多元素数值向量:
c(1, 2) > c(2, 1)
## [1] FALSE TRUE
之前的代码等同于 c(1 > 2, 2 > 1)。为了演示不同长度的向量是如何比较的,请看以下示例:
c(2, 3) > c(1, 2, -1, 3)
## [1] TRUE TRUE TRUE FALSE
这可能会让你有些困惑。计算机制会回收较短的向量,并像 c(2 > 1, 3 > 2, 2 > -1, 3 > 3) 一样工作。更具体地说,较短的向量将被回收以完成较长向量中每个元素的比较。
在 R 中,定义了几个逻辑二元运算符,例如 == 表示相等,> 表示大于,>= 表示大于等于,< 表示小于,以及 <= 表示小于等于。此外,R 还提供了一些其他附加的逻辑运算符,如 %in%,用于判断左侧向量中的每个元素是否包含在右侧向量中:
1 %in% c(1, 2, 3)
## [1] TRUE
c(1, 4) %in% c(1, 2, 3)
## [1] TRUE FALSE
你可能会注意到,所有相等运算符都执行回收,但 %in% 不执行。相反,它总是通过迭代左边的向量来工作,就像前面的例子中的 c(1 %in% c(1, 2, 3), 4 %in% c(1, 2, 3))。
字符向量
字符向量是一组字符串。在这里,字符并不意味着在语言中字面上是一个单独的字母或符号,而是指像 this is a string 这样的字符串。双引号和单引号都可以用来创建字符向量,如下所示:
"hello, world!"
## [1] "hello, world!"
'hello, world!'
## [1] "hello, world!"
我们还可以使用组合函数 c() 来构建一个多元素字符向量:
c("Hello", "World")
## [1] "Hello" "World"
我们可以使用 == 来判断两个向量在对应位置上的值是否相等;这也适用于字符向量:
c("Hello", "World") == c('Hello', 'World')
## [1] TRUE TRUE
字符向量相等,因为 " 和 " 都用于创建字符串,并且不影响其值:
c("Hello", "World") == "Hello, World"
## [1] FALSE FALSE
之前的表达式返回 FALSE,因为 Hello 和 World 都不等于 Hello, World。两个引号之间的唯一区别在于创建包含引号的字符串时的行为。
如果你使用 " 创建一个包含 " 本身的字符串(一个单元素字符向量),你需要输入 " 来转义字符串中的 ",以防止解释器将字符串中的 " 视为字符串的结束引号。
以下示例演示了引号的转义。代码使用 cat() 打印给定的文本:
cat("Is "You" a Chinese name?")
## Is "You" a Chinese name?
如果你觉得这不容易阅读,你可以很好地使用 ' 来创建字符串,这可能更容易:
cat('Is "You" a Chinese name?')
## Is "You" a Chinese name?
换句话说," 允许在字符串中包含 " 而无需转义,而 " 允许在字符串中包含 " 而无需转义。
现在我们已经了解了创建数值向量、逻辑向量和字符向量的基本知识。实际上,在 R 中我们还有复数向量和原始向量。复数向量是复数值的向量,例如c(1 + 2i, 2 + 3i)。原始向量基本上存储原始二进制数据,以十六进制形式表示。这两种类型的向量使用得较少,但它们与我们已经覆盖的三种类型的向量共享许多行为。
在下一节中,你将学习几种访问向量一部分的方法。通过子集向量,你应该开始理解不同类型的向量如何相互关联。
子集向量
如果我们想要访问一些特定的条目或向量的一个子集,对向量进行子集操作意味着访问向量的一些特定条目或其子集。在本节中,我们将演示各种对向量进行子集操作的方法。
首先,我们创建一个简单的数值向量并将其赋值给v1:
v1 <- c(1, 2, 3, 4)
每一行都获取v1的一个特定子集。
例如,我们可以获取第二个元素:
v1[2]
## [1] 2
我们可以获取第二个到第四个元素:
v1[2:4]
## [1] 2 3 4
我们可以获取除了第三个元素之外的所有元素:
v1[-3]
## [1] 1 2 4
模式是清晰的——我们可以在向量后面的方括号中放入任何数值向量以提取相应的子集:
a <- c(1, 3)v1[a]
## [1] 1 3
所有的前述示例都是通过位置进行子集操作,也就是说,我们通过指定元素的位置来获取向量的一个子集。使用负数将排除这些元素。需要注意的一点是,你不能同时使用正数和负数:
v1[c(1, 2, -3)]
## Error in v1[c(1, 2, -3)]: only 0's may be mixed with negative subscripts
如果我们使用超出向量范围的索引来子集向量,会怎样呢?以下示例尝试从第三个元素到不存在的第六个元素获取v1的子集:
v1[3:6]
## [1] 3 4 NA NA
如我们所见,不存在的位置最终以缺失值表示,用 NA 表示。在现实世界的数据中,缺失值很常见。好处是,所有与 NA 进行的算术计算也会得到 NA,以保证一致性。然而,另一方面,处理数据需要额外的努力,因为不能安全地假设数据中不包含缺失值。
另一种子集向量的方法是使用逻辑向量。我们可以提供一个等长的逻辑向量来决定每个条目是否应该被提取:
v1[c(TRUE, FALSE, TRUE, FALSE)]
## [1] 1 3
除了子集之外,我们还可以像这样覆盖向量的一个特定子集:
v1[2] <- 0
在这种情况下,v1变为以下形式:
v1
## [1] 1 0 3 4
我们也可以同时覆盖不同位置上的多个元素:
v1[2:4] <- c(0, 1, 3)
现在,v1变为以下形式:
v1
## [1] 1 0 1 3
与子集类似,逻辑选择器也可以用于覆盖:
v1[c(TRUE, FALSE, TRUE, FALSE)] <- c(3, 2)
如您所预期,v1变为以下形式:
v1
## [1] 3 0 2 3
这个操作的用途之一是按照逻辑标准选择条目。例如,以下代码挑选出所有在v1中不大于2的元素:
v1[v1 <= 2]
## [1] 0 2
更复杂的选择标准也适用。以下示例挑选出所有满足x² - x + 1 > 0的v1元素:
v1[v1 ^ 2 - v1 + 1 >= 0]
## [1] 3 0 2 3
要将所有满足 x <= 2 的条目替换为 0,我们可以调用以下代码:
v1[v1 <= 2] <- 0
如你所预期,v1 变成了以下内容:
v1
## [1] 3 0 0 3
如果我们在一个不存在的条目上覆盖向量,向量将自动扩展,未分配的值作为缺失值 NA:
v1[10] <- 8
v1
## [1] 3 0 0 3 NA NA NA NA NA 8
命名向量
命名向量不是与数值向量或逻辑向量平行的特定类型的向量。它是一个具有与元素对应的名称的向量。我们可以在创建向量时为其命名:
x <- c(a = 1, b = 2, c = 3)
x
## a b c
## 1 2 3
然后,我们可以使用单值字符向量访问元素:
x["a"]
## a
## 1
我们也可以使用字符向量获取多个元素:
x[c("a", "c")]
## a c
## 1 3
如果字符向量有重复的元素,选择将导致选择重复的元素:
x[c("a", "a", "c")]
## a a c
## 1 1 3
此外,对向量进行的所有其他操作也完全适用于命名向量。
我们可以使用 names() 函数获取向量的名称:
names(x)
## [1] "a" "b" "c"
向量的名称不是固定的。我们可以通过将另一个字符向量赋给其名称来更改向量的名称。
names(x) <- c("x", "y", "z")
x["z"]
## z
## 3
如果不再需要名称,我们可以简单地使用 NULL(一个表示未定义值的特殊对象)来移除向量的名称:
names(x) <- NULL
x
## [1] 1 2 3
你可能会想知道当名称根本不存在时会发生什么。让我们用原始的 x 值进行实验:
x <- c(a = 1, b = 2, c = 3)
x["d"]
## <NA>
## NA
根据直觉,访问一个不存在的元素应该会产生错误。然而,结果不是一个错误,而是一个包含单个缺失值的向量,并且具有缺失的名称:
names(x["d"])
## [1] NA
如果你提供一个包含一些名称但其他名称不存在的字符向量,结果向量将保留选择向量的长度:
x[c("a", "d")]
## a <NA>
## 1 NA
提取元素
虽然 [] 创建向量的子集,但 [[]] 从向量中提取一个元素。向量就像十盒糖果,[] 可以让你得到三盒糖果,但 [[]] 打开一盒并从中得到一颗糖果。
对于简单向量,使用 [] 和 [[]] 获取一个元素会产生相同的结果。然而,在某些情况下,它们有不同的行为。例如,使用单个条目对命名向量进行子集操作和从中提取元素将产生不同的结果:
x <- c(a = 1, b = 2, c = 3)
x["a"]
## a
## 1
x[["a"]]
## [1] 1
糖果盒的比喻使理解更容易。x["a"] 参数给你标有 "a" 的糖果盒,而 x[["a"]] 给你标有 "a" 的盒子中的糖果。
由于 [[]] 只提取一个元素,因此它不适用于包含多个元素的向量:
x[[c(1, 2)]]
## Error in x[[c(1, 2)]]: attempt to select more than one element
此外,它不适用于负整数,这意味着排除特定位置的元素:
x[[-1]]
## Error in x[[-1]]: attempt to select more than one element
我们已经知道,使用不存在的位置或名称对向量进行子集操作会产生缺失值。然而,当我们使用超出范围的索引提取元素时,[[]] 简单地不起作用,与不存在的名称一起使用时也不起作用:
x[["d"]]
## Error in x[["d"]]: subscript out of bounds
对于许多初学者来说,看到代码中同时使用 [[]] 和 [] 可能会感到困惑,并且很容易误用它们。只需记住糖果盒的比喻。
识别向量的类别
有时候在采取行动之前我们需要知道我们正在处理哪种类型的向量。class() 函数告诉我们任何 R 对象的类别:
class(c(1, 2, 3))
## [1] "numeric"
class(c(TRUE, TRUE, FALSE))
## [1] "logical"
class(c("Hello", "World"))
## [1] "character"
如果我们需要确保一个对象确实是一个特定类别的向量,我们可以使用 is.numeric、is.logical、is.character 和一些具有类似名称的其他函数:
is.numeric(c(1, 2, 3))
## [1] TRUE
is.numeric(c(TRUE, TRUE, FALSE))
## [1] FALSE
is.numeric(c("Hello", "World"))
## [1] FALSE
向量转换
不同类别的向量可以被强制转换为特定类别的向量。例如,一些数据是数字的字符串表示,如 1 和 20。如果我们保留这些字符串不变,我们就无法用它们进行数值计算。幸运的是,这两个字符串可以被转换为数值向量。这将使 R 将它们视为数字而不是字符串,这样我们就可以用它们进行数学运算。
为了演示一个典型的转换,我们首先创建一个字符向量:
strings <- c("1", "2", "3")
class(strings)
## [1] "character"
正如我提到的,字符串不能直接用于数学运算:
strings + 10
## Error in strings + 10: non-numeric argument to binary operator
我们可以使用 as.numeric() 将字符向量转换为数值向量:
numbers <- as.numeric(strings)
numbers
## [1] 1 2 3
class(numbers)
## [1] "numeric"
现在,我们可以用数字进行数学运算:
numbers + 10
## [1] 11 12 13
与检查给定对象类别的 is.* 函数(例如,is.numeric、is.logical 和 is.character)类似,我们可以使用 as.* 函数族将向量从其原始类别转换为另一个类别:
as.numeric(c("1", "2", "3", "a"))
## Warning: NAs introduced by coercion
## [1] 1 2 3 NA
as.logical(c(-1, 0, 1, 2))
## [1] TRUE FALSE TRUE TRUE
as.character(c(1, 2, 3))
## [1] "1" "2" "3"
as.character(c(TRUE, FALSE))
## [1] "TRUE" "FALSE"
看起来每种类型的向量都可以以某种方式转换为所有其他类型。然而,转换遵循一套规则。
上述代码块中的第一行尝试将字符向量转换为数值向量,就像我们在上一个例子中所做的那样。显然,最后一个元素 a 无法转换为数字。转换除了最后一个元素之外都已完成,因此产生了缺失值。
对于将数值向量转换为逻辑向量,规则是只有 0 对应于 FALSE,所有非零数字都将产生 TRUE。
每种类型的向量都可以转换为字符向量,因为一切都有字符表示。然而,如果数值向量或逻辑向量被强制转换为字符向量,除非转换回,否则它不能直接参与与其他数值或逻辑向量的算术运算。这就是为什么以下代码不起作用,正如我刚才提到的:
c(2, 3) + as.character(c(1, 2))
## Error in c(2, 3) + as.character(c(1, 2)): non-numeric argument to binary operator
从前面的例子中,我强调虽然 R 不强制类型规则,但这并不意味着 R 足够智能,可以自动执行你想要的精确操作。在大多数情况下,最好确保在计算中向量的类型是正确的;否则,可能会发生意外的错误。换句话说,只有当你得到正确的数据对象类型时,你才能进行正确的数学运算。
数值向量的算术运算符
数值向量的算术运算非常简单。它们基本上遵循两个规则:逐元素计算和回收较短的向量。以下示例演示了运算符与数值向量一起工作的行为:
c(1, 2, 3, 4) + 2
## [1] 3 4 5 6
c(1, 2, 3) - c(2, 3, 4)
## [1] -1 -1 -1
c(1, 2, 3) * c(2, 3, 4)
## [1] 2 6 12
c(1, 2, 3) / c(2, 3, 4)
## [1] 0.5000000 0.6666667 0.7500000
c(1, 2, 3) ^ 2
## [1] 1 4 9
c(1, 2, 3) ^ c(2, 3, 4)
## [1] 1 8 81
c(1, 2, 3, 14) %% 2
## [1] 1 0 1 0
尽管向量可以有名称,但操作并不与相应的名称一起使用。只有左侧向量的名称将保留,而右侧向量的名称将被忽略:
c(a = 1, b = 2, c = 3) + c(b = 2, c = 3, d = 4)
## a b c
## 3 5 7
c(a = 1, b = 2, 3) + c(b = 2, c = 3, d = 4)
## a b
## 3 5 7
我们看到了数值向量、逻辑向量和字符向量的基本行为。它们是最常用的数据结构,是各种其他有用对象的构建块之一。其中之一是矩阵,它在统计和计量经济学理论的制定中得到了广泛的应用,并且在表示二维数据和解决线性系统中非常有用。在下一章中,我们将看到如何在 R 中创建矩阵以及它与向量的深厚联系。
矩阵
矩阵是一个在二维中表示和可访问的向量。因此,适用于向量的东西很可能也适用于矩阵。例如,每种类型的向量(例如,数值向量或逻辑向量)都有其矩阵版本,即存在数值矩阵、逻辑矩阵等等。
创建矩阵
我们可以通过设置其两个维度之一来使用 matrix() 从向量创建矩阵:
matrix(c(1, 2, 3, 2, 3, 4, 3, 4, 5), ncol = 3)
## [,1] [,2] [,3]
## [1,] 1 2 3
## [2,] 2 3 4
## [3,] 3 4 5
通过指定ncol = 3,我们的意思是提供的向量应被视为一个有 3 列(和自动的 3 行)的矩阵。你可能觉得原始向量并不像它的表示那样直接。为了使代码更易于用户使用,我们可以将向量写成多行:
matrix(c(1, 2, 3, 4, 5, 6, 7, 8, 9), nrow = 3, byrow = FALSE)
## [,1] [,2] [,3]
## [1,] 1 4 7
## [2,] 2 5 8
## [3,] 3 6 9
matrix(c(1, 2, 3, 4, 5, 6, 7, 8, 9), nrow = 3, byrow = TRUE)
## [,1] [,2] [,3]
## [1,] 1 2 3
## [2,] 4 5 6
## [3,] 7 8 9
通常,我们可能需要创建一个对角矩阵。在这里,diag() 是完成此操作最方便的方法:
diag(1, nrow = 5)
## [,1] [,2] [,3] [,4] [,5]
## [1,] 1 0 0 0 0
## [2,] 0 1 0 0 0
## [3,] 0 0 1 0 0
## [4,] 0 0 0 1 0
## [5,] 0 0 0 0 1
命名行和列
默认情况下,创建矩阵不会自动为其行和列命名。有时,当不同的行和列有不同的含义时,这样做是有用且直接的。在创建矩阵时,我们可以给出行名称和/或列名称:
matrix(c(1, 2, 3, 4, 5, 6, 7, 8, 9), nrow = 3, byrow = TRUE, dimnames = list(c("r1", "r2", "r3"), c("c1", "c2", "c3")))
## c1 c2 c3
## r1 1 2 3
## r2 4 5 6
## r3 7 8 9
或者,我们可以在矩阵创建后使用行名称和/或列名称:
m1 <- matrix(c(1, 2, 3, 4, 5, 6, 7, 8, 9), ncol = 3)
rownames(m1) <- c("r1", "r2", "r3")
colnames(m1) <- c("c1", "c2", "c3")
这里,我们遇到了两个新事物:一个列表和一种函数类型,例如 rownames(x) <-。我们将在本章后面讨论它们。
矩阵子集
正如我们处理向量一样,我们不仅需要创建矩阵,还需要从矩阵中提取数据。这被称为矩阵子集。
注意,矩阵是一个在二维中表示和可访问的向量;我们不仅以二维的方式查看矩阵,还使用二维访问器 [,] 来访问它,这与用于子集向量的单维访问器 [] 非常相似。
要使用它,我们可以为每个维度提供两个向量以确定矩阵的子集。方括号中的第一个参数是行选择器,第二个是列选择器。正如我们在子集向量中所尝试的那样,我们可以在两个维度中使用数值向量、逻辑向量和字符向量。
以下代码演示了以下矩阵的多种子集方法:
m1
## c1 c2 c3
## r1 1 4 7
## r2 2 5 8
## r3 3 6 9
我们可以只提取第一行和第二列中的一个元素:
m1[1, 2]
## [1] 4
我们可以使用一系列位置来对其进行子集操作:
m1[1:2, 2:3]
## c2 c3
## r1 4 7
## r2 5 8
如果一个维度留空,则将选择该维度中的所有值:
m1[1,]
## c1 c2 c3
## 1 4 7
m1[,2]
## r1 r2 r3
## 4 5 6
m1[1:2,]
## c1 c2 c3
## r1 1 4 7
## r2 2 5 8
m1[, 2:3]
## c2 c3
## r1 4 7
## r2 5 8
## r3 6 9
负数在子集矩阵中排除位置,这与处理向量完全相同:
m1[-1,]
## c1 c2 c3
## r2 2 5 8
## r3 3 6 9
m1[,-2]
## c1 c3
## r1 1 7
## r2 2 8
## r3 3 9
注意,矩阵有行名和列名,我们可以使用字符向量来对其进行子集操作:
m1[c("r1", "r3"), c("c1", "c3")]
## c1 c3
## r1 1 7
## r3 3 9
再次注意,矩阵是一个在二维中表示和可访问的向量;然而,本质上它仍然是一个向量。这允许我们使用一维访问器来对矩阵进行子集操作:
m1[1]
## [1] 1
m1[9]
## [1] 9
m1[3:7]
## [1] 3 4 5 6 7
由于向量只包含相同类型的条目,矩阵也是如此。因此,它们的操作非常相似。如果你输入一个不等式,它将返回另一个大小相同的逻辑矩阵:
m1 > 3
## c1 c2 c3
## r1 FALSE TRUE TRUE
## r2 FALSE TRUE TRUE
## r3 FALSE TRUE TRUE
我们可以使用大小相等的逻辑矩阵进行子集操作,就像它是向量一样:
m1[m1 > 3]
## [1] 4 5 6 7 8 9
使用矩阵运算符
向量的所有算术运算符也可以与矩阵一起使用,就像它们是向量一样。这些运算符逐元素执行计算,除了矩阵特有的运算符,如矩阵乘法 %*%:
m1 + m1
## c1 c2 c3
## r1 2 8 14
## r2 4 10 16
## r3 6 12 18
m1 - 2 * m1
## c1 c2 c3
## r1 -1 -4 -7
## r2 -2 -5 -8
## r3 -3 -6 -9
m1 * m1
## c1 c2 c3
## r1 1 16 49
## r2 4 25 64
## r3 9 36 81
m1 / m1
## c1 c2 c3
## r1 1 1 1
## r2 1 1 1
## r3 1 1 1
m1 ^ 2
## c1 c2 c3
## r1 1 16 49
## r2 4 25 64
## r3 9 36 81
m1 %*% m1
## c1 c2 c3
## r1 30 66 102
## r2 36 81 126
## r3 42 96 150
我们还可以使用 t() 来转置矩阵:
t(m1)
## r1 r2 r3
## c1 1 2 3
## c2 4 5 6
## c3 7 8 9
向量和矩阵对于许多用例来说已经足够了。然而,一些特定的问题需要更高维度的数据结构。在下一节中,我们将简要介绍数组,并展示这些数据结构如何具有相似的行为。
数组
数组是矩阵在维度数量上的自然扩展。更具体地说,数组是一个在给定维度中表示和可访问的向量(通常是超过两个维度)。
如果你已经熟悉向量和矩阵,你不会对数组的行为感到惊讶。
创建一个数组
要创建一个数组,我们通过提供数据向量、数据在不同维度中的排列方式以及有时这些维度的行和列的名称来调用 array():
假设我们有一些数据(从 0 到 9 的 10 个整数)并且我们需要在三维中排列它们:第一维为 1,第二维为 5,第三维为 2:
a1 <- array(c(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), dim = c(1, 5, 2))
a1
## , , 1
##
## [,1] [,2] [,3] [,4] [,5]
## [1,] 0 1 2 3 4
##
## , , 2
##
## [,1] [,2] [,3] [,4] [,5]
## [1,] 5 6 7 8 9
通过观察它们周围的符号,我们可以清楚地看到如何访问这些条目。
此外,我们可以在创建数组时为这些维度添加名称:
a1 <- array(c(0, 1, 2, 3, 4, 5, 6, 7, 8, 9), dim = c(1, 5, 2), dimnames = list(c("r1"), c("c1", "c2", "c3", "c4", "c5"), c("k1", "k2")))
a1
## , , k1
##
## c1 c2 c3 c4 c5
## r1 0 1 2 3 4
##
## , , k2
##
## c1 c2 c3 c4 c5
## r1 5 6 7 8 9
或者,对于已经创建的数组,我们可以调用 dimnames(x) <- 来通过提供几个字符向量的列表来为每个维度设置名称:
a0 <- array(c(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10), dim = c(1, 5, 2))
dimnames(a0) <- list(c("r1"), c("c1", "c2", "c3", "c4", "c5"), c("k1", "k2"))
a0
## , , k1
##
## c1 c2 c3 c4 c5
## r1 0 1 2 3 4
##
## , , k2
##
## c1 c2 c3 c4 c5
## r1 5 6 7 8 9
数组的子集
数组的子集原则与矩阵的子集原则完全相同。在这里,我们可以为每个维度提供一个向量来提取数组的子集:
a1[1,,]
## k1 k2
## c1 0 5
## c2 1 6
## c3 2 7
## c4 3 8
## c5 4 9
a1[, 2,]
## k1 k2
## 1 6
a1[,,1]
## c1 c2 c3 c4 c5
## 0 1 2 3 4
a1[1, 1, 1]
## [1] 0
a1[1, 2:4, 1:2]
## k1 k2
## c2 1 6
## c3 2 7
## c4 3 8
a1[c("r1"), c("c1", "c3"), "k1"]
## c1 c3
## 0 2
正如你可能注意到的,原子向量、矩阵和数组几乎共享相同的行为集合。它们共享的一个基本共同特征是它们都是同质数据类型,即它们存储的元素类型必须相同。然而,R 中也有异质数据类型,即它们可以存储不同类型的元素,这使得它们更加灵活,但它们在内存效率和操作速度上较低。
列表
列表是一种通用的向量,允许包含不同类型的对象,甚至其他列表。
它的灵活性使其非常有用。例如,R 中线性模型拟合的结果基本上是一个包含线性回归丰富结果的列表对象,如线性系数(数值向量)、残差(数值向量)、QR 分解(包含矩阵和其他对象的列表)等等。
不需要每次调用不同的函数就能提取信息非常方便,因为这些结果都打包在一个列表中。
创建列表
根据函数名,我们可以使用 list() 创建列表。不同类型的对象可以放入一个列表中。例如,以下代码创建了一个包含单个元素数值向量、两个条目的逻辑向量和三个值的字符向量列表:
l0 <- list(1, c(TRUE, FALSE), c("a", "b", "c"))
l0
## [[1]]
## [1] 1
##
## [[2]]
## [1] TRUE FALSE
##
## [[3]]
## [1] "a" "b" "c"
我们可以使用命名参数为每个列表条目分配名称:
l1 <- list(x = 1, y = c(TRUE, FALSE), z = c("a", "b", "c"))
l1
## $x
## [1] 1
##
## $y
## [1] TRUE FALSE
##
## $z
## [1] "a" "b" "c"
从列表中提取元素
访问列表的元素有多种方式。最常见的方式是使用美元符号 $ 通过名称提取列表元素的值:
l1 <- list(x = 1, y = c(TRUE, FALSE), z = c("a", "b", "c"), m = NULL)
l1$x
## [1] 1
l1$y
## [1] TRUE FALSE
l1$z
## [1] "a" "b" "c"
l1$m
## NULL
注意,如果我们请求一个不存在的元素 m,将返回 NULL。
或者,我们可以使用双方括号中的数字来提取第 n 个列表成员的值。例如,我们可以提取列表 l1 的第二个成员的值,如下所示:
l1[[2]]
## [1] TRUE FALSE
使用相同的符号,我们也可以提供一个名称来提取具有该名称的列表成员的值,就像使用美元符号一样:
l1[["y"]]
## [1] TRUE FALSE
使用双方括号从列表中提取值可以更加灵活,因为有时在计算之前我们可能不知道需要提取哪个成员:
member <- "z" # you can dynamically determine which member to extract
l1[[member]]
## [1] "a" "b" "c"
这里,我们将一个运行时评估的单元素字符向量提供给方括号。但为什么在这里要使用双括号?单括号在哪里?
列表的子集化
在许多情况下,我们需要从列表中提取多个元素。这些多个成员也作为原始列表的子集构建一个列表。
要对列表进行子集化,我们可以使用单方括号符号,就像我们用于向量和矩阵一样。我们可以从列表中提取一些元素并将它们放入一个新的列表中。
符号与向量操作的方式非常一致。我们可以通过字符向量使用名称提取列表元素,或通过数值向量使用位置提取,或通过逻辑向量使用标准提取:
l1["x"]
## $x
## [1] 1
l1[c("x", "y")]
## $x
## [1] 1
##
## $y
## [1] TRUE FALSE
l1[1]
## $x
## [1] 1
l1[c(1, 2)]
## $x
## [1] 1
##
## $y
## [1] TRUE FALSE
l1[c(TRUE, FALSE, TRUE)]
## $x
## [1] 1
##
## $z
## [1] "a" "b" "c"
总结来说,我们可以这样表述:[[ 表示从向量或列表中提取一个元素,而 [ 表示对向量或列表进行子集操作。对向量进行子集操作将得到一个新的向量。同样,对列表进行子集操作将得到一个新的列表。
命名列表
无论列表成员在创建列表时是否已经具有名称,我们都可以通过简单地命名一个向量来命名或重命名列表成员:
names(l1) <- c("A","B","C")
l1
## $A
## [1] 1
##
## $B
## [1] TRUE FALSE
##
## $C
## [1] "a" "b" "c"
要移除它们的名称,我们将 l1 的名称替换为 NULL:
names(l1) <- NULL
l1
## [[1]]
## [1] 1
##
## [[2]]
## [1] TRUE FALSE
##
## [[3]]
## [1] "a" "b" "c"
一旦移除了列表成员的名称,我们就不能再通过名称访问列表成员,而只能通过位置和逻辑条件访问。
设置值
设置列表中的值与处理向量一样简单:
l1 <- list(x = 1, y = c(TRUE, FALSE), z = c("a", "b", "c"))
l1$x <- 0
如果我们给一个不存在的成员赋值,我们将添加一个新成员到列表中,其名称或位置由给定值决定:
l1$m <- 4
l1
## $x
## [1] 0
##
## $y
## [1] TRUE FALSE
##
## $z
## [1] "a" "b" "c"
##
## $m
## [1] 4
此外,我们还可以同时设置多个值:
l1[c("y", "z")] <- list(y = "new value for y", z = c(1, 2))
l1
## $x
## [1] 0
##
## $y
## [1] "new value for y"
##
## $z
## [1] 1 2
##
## $m
## [1] 4
如果我们需要从列表中删除一些成员,只需将它们的值赋为 NULL:
l1$x <- NULL
l1
## $y
## [1] "new value for y"
##
## $z
## [1] 1 2
##
## $m
## [1] 4
我们可以从列表中一次性删除多个成员:
l1[c("z", "m")] <- NULL
l1
## $y
## [1] "new value for y"
其他函数
R 中的许多函数都与列表相关。例如,如果我们不确定一个对象是否是列表,我们可以调用 is.list() 来查询:
l2 <- list(a = c(1, 2, 3), b = c("x", "y", "z", "w"))
is.list(l2)
## [1] TRUE
is.list(l2$a)
## [1] FALSE
在这里,l2 是一个列表,而 butl2$a 是一个数值向量,而不是列表。
我们还可以使用 as.list() 将向量转换为列表:
l3 <- as.list(c(a = 1, b =2, c = 3))
l3
## $a
## [1] 1
##
## $b
## [1] 2
##
## $c
## [1] 3
通过调用 unlist 将列表强制转换为向量也很容易,它基本上将所有列表成员转换为兼容类型的向量:
l4 <- list(a = 1, b = 2, c = 3)
unlist(l4)
## a b c
## 1 2 3
如果我们将混合数字和文本的列表进行反序列化,所有成员都将转换为它们各自可以转换到的最接近的类型:
l4 <- list(a = 1, b = 2, c = "hello")
unlist(l4)
## a b c
## "1" "2" "hello"
在这里,l4$a 和 l4$b 是数字,可以转换为字符;然而,butl4$c 是字符向量,不能转换为数值。因此,它们与所有元素兼容的最接近的类型是字符向量。
数据框
数据框表示具有行数和列数的数据集。它看起来像矩阵,但其列不一定具有相同的类型。这与数据集最常见的格式一致:每一行,或数据记录,由多个不同类型的列描述。
下表是一个可以完全由数据框表征的示例。
| 姓名 | 性别 | 年龄 | 专业 |
|---|---|---|---|
| Ken | 男性 | 24 | 金融 |
| Ashley | 女性 | 25 | 统计学 |
| Jennifer | 女性 | 23 | 计算机科学 |
创建数据框
要创建数据框,我们可以调用 data.frame() 并通过相应类型的向量提供每列的数据:
persons <- data.frame(Name = c("Ken", "Ashley", "Jennifer"),
Gender = c("Male", "Female", "Female"),
Age = c(24, 25, 23),
Major = c("Finance", "Statistics", "Computer Science"))
persons
## Name Gender Age Major
## 1 Ken Male 24 Finance
## 2 Ashley Female 25 Statistics
## 3 Jennifer Female 23 Computer Science
注意,创建数据框与创建列表的过程完全相同。这是因为,本质上,数据框是一个列表,其中每个元素都是一个向量,代表一个表格列,并且具有相同数量的元素。
除了从原始数据创建数据框外,我们还可以通过直接调用 data.frame 或 as.data.frame 来从列表创建数据框:
l1 <- list(x = c(1, 2, 3), y = c("a", "b", "c"))
data.frame(l1)
## x y
## 1 1 a
## 2 2 b
## 3 3 c
as.data.frame(l1)
## x y
## 1 1 a
## 2 2 b
## 3 3 c
我们也可以使用相同的方法从一个矩阵创建数据框:
m1 <- matrix(c(1, 2, 3, 4, 5, 6, 7, 8, 9), nrow = 3, byrow = FALSE)
data.frame(m1)
## X1 X2 X3
## 1 1 4 7
## 2 2 5 8
## 3 3 6 9
as.data.frame(m1)
## V1 V2 V3
## 1 1 4 7
## 2 2 5 8
## 3 3 6 9
注意,转换还会自动将列名分配给新的数据框。实际上,正如你可能验证的那样,如果矩阵已经具有列名或行名,它们将在转换中保留。
命名行和列
由于数据框是一个列表,但看起来也像矩阵,因此访问这两种类型对象的两种方法都适用于数据框:
df1 <- data.frame(id = 1:5, x = c(0, 2, 1, -1, -3), y = c(0.5, 0.2, 0.1, 0.5, 0.9))
df1
## id x y
## 1 1 0 0.5
## 2 2 2 0.2
## 3 3 1 0.1
## 4 4 -1 0.5
## 5 5 -3 0.9
我们可以像处理矩阵一样重命名列和行:
colnames(df1) <- c("id", "level", "score")
rownames(df1) <- letters[1:5]
df1
## id level score
## a 1 0 0.5
## b 2 2 0.2
## c 3 1 0.1
## d 4 -1 0.5
## e 5 -3 0.9
数据框的子集操作
由于数据框是列向量类似矩阵的列表,我们可以使用这两组符号来访问数据框中的元素和子集。
将数据框作为列表进行子集操作
如果我们想将数据框视为向量的列表,我们可以使用列表符号来提取值或进行子集操作。
例如,我们可以使用 $ 通过名称提取一列的值,或者使用 [[ 通过位置进行操作:
df1$id
## [1] 1 2 3 4 5
df1[[1]]
## [1] 1 2 3 4 5
列表子集完美适用于数据框,并产生一个新的数据框。子集操作符 ([) 允许我们使用数值向量通过位置提取列,使用字符向量通过名称提取列,或使用逻辑向量通过 TRUE 和 FALSE 选择提取列:
df1[1]
## id
## a 1
## b 2
## c 3
## d 4
## e 5
df1[1:2]
## id level
## a 1 0
## b 2 2
## c 3 1
## d 4 -1
## e 5 -3
df1["level"]
## level
## a 0
## b 2
## c 1
## d -1
## e -3
df1[c("id", "score")]
## id score
## a 1 0.5
## b 2 0.2
## c 3 0.1
## d 4 0.5
## e 5 0.9
df1[c(TRUE, FALSE, TRUE)]
## id score
## a 1 0.5
## b 2 0.2
## c 3 0.1
## d 4 0.5
## e 5 0.9
将数据框作为矩阵进行子集操作
然而,列表符号不支持行选择。相比之下,矩阵符号提供了更多的灵活性。如果我们将数据框视为矩阵,二维访问器使我们能够轻松访问子集的条目,这支持列选择和行选择。
换句话说,我们可以使用 [row, column] 符号通过指定行选择器和列选择器来子集数据框,这些选择器可以是数值向量、字符向量或逻辑向量。
例如,我们可以指定列选择器:
df1[, "level"]
## [1] 0 2 1 -1 -3
df1[, c("id", "level")]
## id level
## a 1 0
## b 2 2
## c 3 1
## d 4 -1
## e 5 -3
df1[, 1:2]
## id level
## a 1 0
## b 2 2
## c 3 1
## d 4 -1
## e 5 -3
或者,我们可以指定行选择器:
df1[1:4,]
## id level score
## a 1 0 0.5
## b 2 2 0.2
## c 3 1 0.1
## d 4 -1 0.5
df1[c("c", "e"),]
## id level score
## c 3 1 0.1
## e 5 -3 0.9
我们甚至可以同时指定两个选择器:
df1[1:4, "id"]
## [1] 1 2 3 4
df1[1:3, c("id", "score")]
## id score
## a 1 0.5
## b 2 0.2
## c 3 0.1
注意,矩阵符号会自动简化输出。也就是说,如果只选择一列,结果将不会是数据框,而是该列的值。为了始终将结果保持为数据框,即使它只有一列,我们也可以同时使用这两种符号:
df1[1:4,]["id"]
## id
## a 1
## b 2
## c 3
## d 4
在这里,第一组括号将数据框作为矩阵子集,选择前四行和所有列。第二组括号将结果数据框作为列表子集,只选择 id 列,从而得到一个数据框。
另一种方法是指定 drop = FALSE 以避免简化结果:
df1[1:4, "id", drop = FALSE]
## id
## a 1
## b 2
## c 3
## d 4
如果你期望数据框子集的输出始终是数据框,你应该始终设置 drop = FALSE;否则,如果假设你会得到数据框但实际上得到向量,一些边缘情况(如用户输入只选择一列)可能会导致意外的行为。
数据过滤
以下代码通过 criterionscore >= 0.5 过滤 df1 的行,并选择 id 和 level 列:
df1$score >= 0.5
## [1] TRUE FALSE FALSE TRUE TRUE
df1[df1$score >= 0.5, c("id", "level")]
## id level
## a 1 0
## d 4 -1
## e 5 -3
以下代码通过一个标准过滤df1的行,该标准是行名必须是a、d或e之一,并选择id和score列:
rownames(df1) %in% c("a", "d", "e")
## [1] TRUE FALSE FALSE TRUE TRUE
df1[rownames(df1) %in% c("a", "d", "e"), c("id", "score")]
## id score
## a 1 0.5
## d 4 0.5
## e 5 0.9
这两个例子基本上都使用矩阵表示法通过逻辑向量选择行,通过字符向量选择列。
设置值
设置数据框子集的值允许同时使用列表和矩阵的方法。
将值作为列表设置
我们可以使用$和<-一起给列表成员赋新值:
df1$score <- c(0.6, 0.3, 0.2, 0.4, 0.8)
df1
## id level score
## a 1 0 0.6
## b 2 2 0.3
## c 3 1 0.2
## d 4 -1 0.4
## e 5 -3 0.8
另外,[也可以使用,并且它还允许在一个表达式中进行多个更改,而[[只能一次修改一列:
df1["score"] <- c(0.8, 0.5, 0.2, 0.4, 0.8)
df1
## id level score
## a 1 0 0.8
## b 2 2 0.5
## c 3 1 0.2
## d 4 -1 0.4
## e 5 -3 0.8
df1[["score"]] <- c(0.4, 0.5, 0.2, 0.8, 0.4)
df1
## id level score
## a 1 0 0.4
## b 2 2 0.5
## c 3 1 0.2
## d 4 -1 0.8
## e 5 -3 0.4
df1[c("level", "score")] <- list(level = c(1, 2, 1, 0, 0), score = c(0.1, 0.2, 0.3, 0.4, 0.5))
df1
## id level score
## a 1 1 0.1
## b 2 2 0.2
## c 3 1 0.3
## d 4 0 0.4
## e 5 0 0.5
将值作为矩阵设置
使用列表表示法设置数据框的值与子集操作存在相同的问题——我们只能访问列。如果我们需要更灵活地设置值,我们可以使用矩阵表示法:
df1[1:3, "level"] <- c(-1, 0, 1)
df1
## id level score
## a 1 -1 0.1
## b 2 0 0.2
## c 3 1 0.3
## d 4 0 0.4
## e 5 0 0.5
df1[1:2, c("level", "score")] <- list(level = c(0, 0), score = c(0.9, 1.0))
df1
## id level score
## a 1 0 0.9
## b 2 0 1.0
## c 3 1 0.3
## d 4 0 0.4
## e 5 0 0.5
因子
注意一点是,数据框的默认行为试图更有效地使用内存。有时,这种行为可能会默默地导致意外的问题。
例如,当我们通过提供一个字符向量作为列来创建数据框时,它将默认将该字符向量转换为因子,该因子只存储一次相同的值,这样重复就不会占用太多内存。实际上,因子本质上是一个整数向量,它有一个预先指定的可能值集合,称为级别,用于表示有限可能性的值。
我们可以通过在最初创建的persons数据框上调用str()来验证这一点:
str(persons)
## 'data.frame': 3 obs. of 4 variables:
## $ Name : Factor w/ 3 levels "Ashley","Jennifer",..: 3 1 2
## $ Gender: Factor w/ 2 levels "Female","Male": 2 1 1
## $ Age : num 24 25 23
## $ Major : Factor w/ 3 levels "Computer Science",..: 2 3 1
我们可以清楚地发现Name、Gender和Major不是字符向量,而是因子对象。Gender用因子表示是合理的,因为它可能只能是Female或Male,所以使用两个整数来表示这两个值比使用字符向量存储所有值(无论是否重复)更有效率。
然而,这可能会对其他列(不仅限于具有几个可能值的列)引起问题。例如,如果我们想在persons中设置一个名字:
persons[1, "Name"] <- "John"
## Warning in `[<-.factor`(`*tmp*`, iseq, value = "John"): invalid factor
## level, NA generated
persons
## Name Gender Age Major
## 1 <NA> Male 24 Finance
## 2 Ashley Female 25 Statistics
## 3 Jennifer Female 23 Computer Science
出现了一个警告信息。这是因为在我们最初定义数据框时,Name字典中没有名为John的单词,因此我们无法将第一个人的名字设置为这样一个不存在的值。当我们设置任何Gender为Unknown时,也会发生同样的事情。原因完全相同:当我们从字符向量创建列时,该列默认将是一个因子,其值必须来自从该字符向量中唯一值创建的字典。
这种行为有时非常令人烦恼,实际上并没有多大帮助,尤其是在今天内存便宜的情况下。避免这种行为的简单方法是在使用data.frame()创建数据框时设置stringsAsFactors = FALSE:
persons <- data.frame(Name = c("Ken", "Ashley", "Jennifer"),
Gender = factor(c("Male", "Female", "Female")),
Age = c(24, 25, 23),
Major = c("Finance", "Statistics", "Computer Science"),
stringsAsFactors = FALSE)
str(persons)
## 'data.frame': 3 obs. of 4 variables:
## $ Name : chr "Ken" "Ashley" "Jennifer"
## $ Gender: Factor w/ 2 levels "Female","Male": 2 1 1
## $ Age : num 24 25 23
## $ Major : chr "Finance" "Statistics" "Computer Science"
如果我们真的想让因子对象发挥作用,我们可以在特定的列中显式调用factor(),就像我们之前在Gender列上所做的那样。
适用于数据框的有用函数
对于数据框,有许多有用的函数。在这里,我们只介绍一些但最常用的函数。
summary()函数通过生成一个显示每列汇总统计信息的表格与数据框一起工作:
summary(persons)
## Name Gender Age Major
## Length:3 Female:2 Min. :23.0 Length:3
## Class :character Male :1 1st Qu.:23.5 Class :character
## Mode :character Median :24.0 Mode :character
## Mean :24.0
## 3rd Qu.:24.5
## Max. :25.0
对于因子Gender,汇总计算每个值或级别的行数。对于数值向量,汇总显示数字的重要分位数。对于其他类型的列,它显示它们的长度、类别和模式。另一个常见的需求是通过行或列将多个数据框绑定在一起。为此,我们可以使用rbind()和cbind(),正如它们的名称所暗示的,分别执行行绑定和列绑定。
如果我们想向数据框中添加一些行,在这种情况下,添加一个人的新记录,我们可以使用rbind():
rbind(persons, data.frame(Name = "John", Gender = "Male", Age = 25, Major = "Statistics"))
## Name Gender Age Major
## 1 Ken Male 24 Finance
## 2 Ashley Female 25 Statistics
## 3 Jennifer Female 23 Computer Science
## 4 John Male 25 Statistics
如果我们想向数据框中添加一些列,在这种情况下,添加两个新列以指示每个人是否注册以及手头项目的数量,我们可以使用cbind():
cbind(persons, Registered = c(TRUE, TRUE, FALSE), Projects = c(3, 2, 3))
## Name Gender Age Major Registered Projects
## 1 Ken Male 24 Finance TRUE 3
## 2 Ashley Female 25 Statistics TRUE 2
## 3 Jennifer Female 23 Computer Science FALSE 3
注意,rbind()和cbind()不会修改原始数据,而是创建一个新的数据框,其中附加了指定的行或列。
另一个有用的函数是expand.grid()。它生成一个包含列中所有值组合的数据框:
expand.grid(type = c("A", "B"), class = c("M", "L", "XL"))
## type class
## 1 A M
## 2 B M
## 3 A L
## 4 B L
## 5 A XL
## 6 B XL
有许多其他与数据框一起工作的有用函数。我们将在数据处理章节中讨论这些函数。
在磁盘上加载和写入数据
在实践中,数据通常存储在文件中。R 提供了一些函数来从文件中读取表格或将数据框写入文件。如果一个文件存储了一个表格,它通常是组织良好的,并遵循一些约定,该约定指定了行和列的排列方式。在大多数情况下,我们不需要逐字节读取文件,而是调用read.table()或read.csv()等函数。
最流行的软件无关数据格式是CSV(逗号分隔值)。该格式基本上是按这种方式组织的,即不同列中的值由逗号分隔,默认情况下,第一行被视为标题行。例如,人员可能以以下 CSV 格式表示:
Name,Gender,Age,MajorKen,Male,24,FinanceAshley,Female,25,StatisticsJennifer,Female,23,Computer Science
要将数据读入 R 环境,我们只需调用read.csv(file),其中 file 是文件的路径。为确保数据文件可以被找到,请将data文件夹直接放置在您的当前工作目录中,调用getwd()以查找。我们将在下一章详细讨论这一点:
read.csv("data/persons.csv")
## Name Gender Age Major
## 1 Ken Male 24 Finance
## 2 Ashley Female 25 Statistics
## 3 Jennifer Female 23 Computer Science
如果我们需要将数据框保存到 CSV 文件中,我们可以调用write.csv(file)并添加一些额外的参数:
write.csv(persons, "data/persons.csv", row.names = FALSE, quote = FALSE)
参数row.names = FALSE避免了存储不必要的行名,而argumentquote = FALSE避免了在输出中引用文本,这两者在大多数情况下都是不必要的。
有许多内置函数和几个与不同格式数据读取和写入相关的包。我们将在后面的章节中介绍这个主题。
函数
函数是一个可以调用的对象。基本上,它是一个具有内部逻辑的机器,它接受一组输入(参数或参数)并返回一个值作为输出。
在前面的章节中,我们遇到了一些 R 的内置函数。例如,is.numeric()接受任何 R 对象作为参数,并返回一个逻辑值,指示该对象是否为数值向量。同样,is.function()可以判断给定的 R 对象是否为函数对象。
事实上,在 R 环境中,我们使用的每一件事都是对象,我们做的每一件事都是函数,也许会令你惊讶,所有函数仍然是对象。甚至<-和+都是接受两个参数的函数。尽管它们被称为二元运算符,但本质上它们是函数。
当我们进行随意的交互式数据分析时,有时我们不需要自己编写任何函数,因为内置函数以及成千上万的包提供的函数通常已经足够。
然而,如果您需要在数据处理或分析中重复逻辑或过程,这些函数可能无法完全满足您的需求,因为它们不是为满足特定任务或特定数据集的格式而设计的。那么,您需要创建自己的函数,针对特定的需求集。
创建函数
在 R 中创建函数很容易。假设我们定义一个名为add的函数,该函数简单地分别将两个数字x和y相加:
add <- function(x, y) { x + y}
函数语法(x, y)指定了函数的参数。换句话说,该函数接受两个名为x和y的参数。{ x + y }是函数体,它包含一系列以x、y和其他符号表示的表达式。函数返回的值是最后一个表达式的值,除非函数内部调用return()。最后,函数被赋值给add,这样我们就可以稍后使用add调用此函数。
创建这样一个简单的函数,或者任何更复杂的函数,都不会对评估向量产生任何影响。R 中的函数就像另一个对象。要查看add引用的对象,只需在控制台输入add:
add
## function(x, y) {
## x + y
## }
调用函数
函数定义后,我们可以像在数学中一样调用函数。调用需要相同的语法:名称(arg1, arg2, ...)。看看下面的例子:
add(2, 3)
## [1] 5
调用非常透明。当我们评估这样的调用时,R 会找出环境中是否有名为add的函数。然后,它会确定add指的是我们刚刚创建的函数,并创建一个局部环境,其中x取值为2,y取值为3。然后根据参数的值评估函数体内的表达式。最后,函数返回该表达式的值,即5。
动态类型
R 中的函数可以非常灵活,因为它不是强类型。换句话说,在调用之前,输入的类型不是固定的。即使函数最初是为标量数字设计的,只要+可以与它们一起工作,它也会自动推广到也可以与所有向量一起工作。例如,我们可以运行以下代码而不改变函数:
add(c(2, 3), 4)
## [1] 6 7
前面的例子并没有真正展示动态类型的灵活性,因为标量在 R 中也是一个向量。一个更有资格的例子是:
add(as.Date("2014-06-01"), 1)
## [1] "2014-06-02"
函数将两个参数放入表达式而不进行任何类型检查。as.Date()创建一个Date对象,它具有日期表示。在不改变add的任何代码的情况下,它与Date完美地工作。函数只有在+对于两个参数没有明确定义时才会失败:
add(list(a = 1), list(a = 2))
## Error in x + y: non-numeric argument to binary operator
泛化一个函数
函数是对特定逻辑或过程的明确抽象,旨在解决某些特定问题。开发者通常希望函数足够通用,以适应广泛的用例,这样我们就可以轻松地使用它来解决类似的问题,而无需为每个问题编写太多的专用函数。
使函数更广泛适用的过程称为泛化。在像 R 这样的弱类型编程语言中泛化函数非常方便,但如果实现不正确,则可能会出错。
为了使add()更通用,以便它可以处理各种原始代数运算,我们可以定义另一个函数,称为calc。这个新函数接受三个参数,其中x和y是两个向量,而type接受一个字符向量,表示用户想要执行哪种代数运算。
以下代码使用流程控制实现这样一个函数,我们将在不久的将来介绍,但一开始看起来应该很容易理解。在这段代码中,要评估的表达式的选择取决于type的值:
calc <- function(x, y, type) {
if (type == "add") {
x + y
} else if (type == "minus") {
x - y
} else if (type == "multiply") {
x * y
} else if (type == "divide") {
x / y
} else {
stop("Unknown type of operation")
}
}
一旦函数被定义,我们就可以通过提供适当的参数来调用它:
calc(2, 3, "minus")
## [1] -1
函数自动与数值向量一起工作:
calc(c(2, 5), c(3, 6), "divide")
## [1] 0.6666667 0.8333333
函数也被推广到可以与非数值向量一起工作,其中+是明确定义的:
calc(as.Date("2014-06-01"), 3, "add")
## [1] "2014-06-04"
考虑提供一些无效的参数:
calc(1, 2, "what")
## Error in calc(1, 2, "what"): Unknown type of operation
在这种情况下,没有任何条件得到满足,因此最后 else 块中的表达式将被评估。stop()调用将产生一个错误信息并立即终止整个评估。
函数似乎工作得很好,并考虑了所有可能的无效参数的情况。然而,这并不正确:
calc(1, 2, c("add", "minue"))
## Warning in if (type == "add") {: the condition has length > 1 and only the
## first element will be used
## [1] 3
在这里,我们没有考虑类型被给定为多元素向量的情况。问题是:当这样的向量与另一个向量比较时,它也会产生一个多元素逻辑向量,这将为if条件造成模糊。考虑if(c(TRUE, FALSE))的含义?
为了明确避免这种歧义,我们需要改进函数,以便错误信息更加详细和透明。为了进行下去,我们只需要检查向量是否有长度1:
calc <- function(x, y, type) {
if (length(type) > 1L) stop("Only a single type is accepted")
if (type == "add") {
x + y
} else if (type == "minus") {
x - y
} else if (type == "multiply") {
x * y
} else if (type == "divide") {
x / y
} else {
stop("Unknown type of operation")
}
}
然后,我们重新尝试那个麻烦的调用,看看异常是如何通过参数的预检查来处理的:
calc(1, 2, c("add", "minue"))
## Error in calc(1, 2, c("add", "minue")): Only a single type is accepted
函数参数的默认值
一些函数非常灵活,因为它们接受广泛的输入并满足各种需求。在很多情况下,更多的灵活性意味着参数数量的增加。
如果我们必须在每次使用非常灵活的函数时指定数十个参数,那么查看代码肯定会很混乱。在这种情况下,为参数设置合理的默认值将极大地简化调用函数的代码。
要设置参数的默认值,使用arg = value。这将使参数成为可选的。以下示例创建了一个具有可选参数的函数:
increase <- function(x, y = 1) {
x + y
}
新函数increase()允许我们仅使用x来调用它。在这种情况下,如果未明确指定,y将自动取值为1。
increase(1)
## [1] 2
increase(c(1, 2, 3))
## [1] 2 3 4
许多 R 函数都有多个参数,其中一些参数有默认值。有时,确定参数的默认值可能很棘手,因为这很大程度上依赖于大多数用户的意图。
概述
在本章中,你学习了数值向量、逻辑向量和字符向量的基本行为。这些向量是同质数据类型,只能存储相同类型的元素。相比之下,列表和数据框更加灵活,因为它们可以存储不同类型的元素。你学习了如何对这些数据结构进行子集化并从中提取元素。最后,你学习了如何创建和调用函数。
现在你已经了解了游戏的规则,你需要熟悉游戏场地。在下一章中,我们将介绍一些关于管理工作空间的基本但重要的事项。我会向你展示一些管理工作目录、环境和包库的常见做法。
第三章. 管理你的工作空间
如果将 R 对象的行性行为比作游戏规则,那么工作空间可以比作游乐场。为了玩好这个游戏,你需要熟悉规则,也要熟悉游乐场。在本章中,我将向你介绍一些基本但重要的技能来管理你的工作空间。这些技能包括:
-
使用工作目录
-
检查工作环境
-
修改全局选项
-
管理包的库
R 的工作目录
不论是作为 R 终端还是 RStudio 启动,R 会话总是从一个目录开始。R 正在运行的目录被称为 R 会话的工作目录。当你访问硬盘上的其他文件时,你可以使用绝对路径(例如,D:\Workspaces\test-project\data\2015.csv)在大多数情况下,或者使用正确的工作目录(在这种情况下,D:\Workspaces\test-project)的相对路径(例如,data\2015.csv)。
使用相对于工作目录的路径并不会改变文件路径,但指定它们的方式更短。这也会使你的脚本更易于移植。想象一下,你正在编写一些 R 脚本,根据目录中的一系列数据文件生成图形。如果你将目录作为绝对路径写入,那么任何想要在自己的电脑上运行你的脚本的人都需要修改代码中的路径,以匹配他们硬盘上数据的位置。然而,如果你将目录作为相对路径写入,那么如果数据保持在相同的相对位置,脚本将无需任何修改即可运行。
在 R 终端中,你可以使用getwd()函数获取当前 R 会话的工作目录。默认情况下,commandR 从你的用户目录启动一个新的 R 会话,而 RStudio 则在你的用户文档目录的背景中运行一个 R 会话。
除了默认设置外,你还可以选择一个目录,并在 RStudio 中创建一个 R 项目。然后,每次你打开该项目时,工作目录就是项目位置,这使得使用相对路径访问项目目录中的文件变得非常容易,这提高了项目的可移植性。
在 RStudio 中创建 R 项目
要创建一个新项目,只需转到文件|新建项目或点击主窗口右上角的项目下拉菜单并选择新建项目。会出现一个窗口,你可以在其中创建一个新的目录或选择硬盘上的现有目录作为项目目录:

一旦选择了一个本地目录,项目就会在那里创建。R 项目实际上是一个.Rproj文件,它存储了一些设置。如果你在 RStudio 中打开这样的项目文件,其中的设置将被应用,并且工作目录将被设置为项目文件所在的目录。
在使用 RStudio 在项目中工作时,另一个有用的点是自动完成使编写文件路径变得更加高效。当你输入一个绝对或相对文件路径的字符串时,按 Tab 键,RStudio 将列出该目录中的文件:

比较绝对路径和相对路径
由于我使用 RStudio 中的 RMarkdown 编写这本书,工作目录是我的书项目目录:
getwd()
## [1] "D:/Workspaces/learn-r-programming"
你可能会注意到前面提到的工作目录使用 / 而不是 \。在 Windows 操作系统中,\ 是默认路径分隔符,但这个符号已经被用来表示特殊字符。例如,当你创建一个字符向量时,你可以使用 \n 来表示一个新行:
"Hello\nWorld"
## [1] "Hello\nWorld"
当字符向量直接打印为字符串表示时,特殊字符会被保留。然而,如果你给它添加 cat(),字符串将在控制台中写入,转义字符将被转换为它们所代表的字符:
cat("Hello\nWorld")
## Hello
## World
第二个单词从新的一行(\\n)开始,就像平常一样。然而,如果 \ 是如此特殊,我们应该如何写出 \ 本身呢?只需使用 \\:
cat("The string with '' is translated")
## The string with '' is translated
正因如此,在 Windows 操作系统中,我们应该使用 \\ 或 / 作为路径分隔符,因为两者都受到支持。在类似 Unix 的操作系统中,例如 macOS 和 Linux,事情要简单得多:始终使用 /。如果你使用 Windows 并且错误地使用 \ 来引用文件,将会发生错误:
filename <- "d:\data\test.csv"
## Error: '\d' is an unrecognized escape in character string starting ""d:\d"
相反,你需要这样写:
filename <- "d:\\data\\test.csv"
幸运的是,在大多数情况下,我们可以在 Windows 中使用 /,这使得相同的代码在几乎所有流行的操作系统上使用相对路径时都能运行:
absolute_filename <- "d:/data/test.csv"
relative_filename <- "data/test.csv"
我们不仅可以通过 getwd() 获取工作目录,还可以使用 setwd() 设置当前 R 会话的工作目录。然而,这几乎总是不被推荐,因为它可以将脚本中的所有相对路径直接指向另一个目录,导致一切出错。
因此,一个好的做法是创建一个 R 项目来开始你的工作。
管理项目文件
一旦我们在 RStudio 中创建了一个项目,项目目录中也会创建一个 .Rproj 文件,目前该目录中没有其他文件。由于 R 与统计计算和数据可视化相关,R 项目主要包含进行统计计算(或其他编程任务)的 R 脚本,数据文件(如 .csv 文件),文档(如 Markdown 文件),有时还包括输出图形。
如果在项目目录中混合了不同类型的文件,随着输入数据的积累或输出数据和图形使目录变得杂乱,管理这些项目文件将会越来越困难。
一个推荐的做法是创建子目录来包含不同类型任务产生的不同类型的文件。
例如,以下目录结构很简单,所有文件都在一起:
project/
- household.csv
- population.csv
- national-income.png
- popluation-density.png
- utils.R
- import-data.R
- check-data.R
- plot.R
- README.md
- NOTES.md
与此相反,以下目录结构要干净得多,也更易于使用:
project/
- data/
- household.csv
- population.csv
- graphics/
- national-income.png
- popluation-density.png
- R/
- utils.R
- import-data.R
- check-data.R
- plot.R
- README.md
- NOTES.md
在前面的目录结构中,目录以 directory/ 的形式表示,文件以 file-name.ext 的形式表示。在大多数情况下,建议使用第二种结构,因为随着项目需求和任务的复杂化,第一种结构最终会变得混乱,而第二种结构将保持整洁。
除了结构问题之外,通常会在 README.md 中编写项目介绍,并在 NOTES.md 中放置额外的注释。这两个文档都是 Markdown 文档(.md),熟悉其极其简单的语法是值得的。阅读 Daring Fireball: Markdown 语法文档 (daringfireball.net/projects/markdown/syntax) 和 GitHub 帮助:Markdown 基础 (help.github.com/articles/markdown-basics/) 以获取详细信息。我们将在第十五章 提高生产力 中介绍 R 和 Markdown 的结合。
现在工作目录已准备就绪。在下一节中,你将学习在 R 会话中检查工作环境的各种方法。
检查环境
在 R 中,每个表达式都是在特定的环境中评估的。环境是一组符号及其绑定的集合。当我们将值绑定到符号、调用函数或引用名称时,R 将在当前环境中查找符号。如果你在 RStudio 控制台中输入命令,你的命令将在 全局环境 中评估。
例如,当我们在一个终端或 RStudio 中启动一个新的 R 会话时,我们开始在空的全局环境中工作。换句话说,在这个环境中没有定义任何符号。如果我们运行 x <- c(1, 2, 3),数值向量 c(1, 2, 3) 将绑定到全局环境中的符号 x。然后,全局环境有一个绑定,将 x 映射到向量 c(1, 2, 3)。换句话说,如果你评估 x,那么你将得到它的值。
检查现有符号
除了在上一章中我们处理向量列表之外,我们还需要了解一些基本函数来检查和操作我们的工作环境。检查我们正在处理的对象集合的最基本但有用的函数是 objects()。该函数返回当前环境中现有对象名称的字符向量。
在一个新的 R 会话中,当前环境中不应该有任何符号:
objects()
## character(0)
让我们假设我们创建了以下对象:
x <- c(1, 2, 3)
y <- c("a", "b", "c")
z <- list(m = 1:5, n = c("x", "y", "z"))
然后,你将得到一个包含现有对象名称的字符向量:
objects()
## [1] "x" "y" "z"
许多开发者更喜欢将 ls() 作为 objects() 的别名:
ls()
## [1] "x" "y" "z"
在大多数情况下,尤其是在你使用 RStudio 的情况下,你不需要使用 objects() 或 ls() 来查看创建了哪些符号,因为 环境 面板显示了全局环境中所有可用的符号:

环境面板以紧凑的形式显示符号及其值。你可以通过展开列表或数据帧并查看其中的向量与之交互。
除了列表视图外,环境面板还提供了一个替代的网格视图。网格视图不仅显示现有对象的名字、类型和值结构,还显示它们的对象大小:

虽然 RStudio 中的环境面板使得检查所有现有变量变得简单,但在 RStudio 不可用、编写与它们的名称一起工作的函数或对象以不同方式动态提供时,objects()或ls()仍然很有用。
查看对象的结构
在环境面板中,对象的紧凑表示来自str()函数,该函数打印给定对象的结构。
例如,当str()应用于一个简单的数值向量时,它显示其类型、位置和值预览:
x
## [1] 1 2 3
str(x)
## num [1:3] 1 2 3
如果向量有超过 10 个元素,str()将只显示前 10 个:
str(1:30)
## int [1:30] 1 2 3 4 5 6 7 8 9 10 ...
对于列表,直接在控制台或使用print()评估它将显示其元素并以冗长形式显示:
z
## $m
## [1] 1 2 3 4 5
##
## $n
## [1] "x" "y" "z"
或者,str()显示其类型、长度和元素结构预览:
str(z)
## List of 2
## $ m: int [1:5] 1 2 3 4 5
## $ n: chr [1:3] "x" "y" "z"
假设我们创建了以下嵌套列表:
nested_list <- list(m = 1:15, n = list("a", c(1, 2, 3)),
p = list(x = 1:10, y = c("a", "b")),
q = list(x = 0:9, y = c("c", "d")))
直接打印将显示所有元素并告诉我们如何访问它们,这在大多数情况下可能既长又没有必要:
nested_list
## $m
## [1] 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
##
## $n
## $n[[1]]
## [1] "a"
##
## $n[[2]]
## [1] 1 2 3
##
##
## $p
## $p$x
## [1] 1 2 3 4 5 6 7 8 9 10
##
## $p$y
## [1] "a" "b"
##
##
## $q
## $q$x
## [1] 0 1 2 3 4 5 6 7 8 9
##
## $q$y
## [1] "c" "d"
要获取一个更易于查看和工作的紧凑表示,请使用列表调用str():
str(nested_list)
## List of 4
## $ m: int [1:15] 1 2 3 4 5 6 7 8 9 10 ...
## $ n:List of 2
## ..$ : chr "a"
## ..$ : num [1:3] 1 2 3
## $ p:List of 2
## ..$ x: int [1:10] 1 2 3 4 5 6 7 8 9 10
## ..$ y: chr [1:2] "a" "b"
## $ q:List of 2
## ..$ x: int [1:10] 0 1 2 3 4 5 6 7 8 9
## ..$ y: chr [1:2] "c" "d"
虽然str()显示了对象的结构,但ls.str()显示了当前环境的结构:
ls.str()
## nested_list : List of 4
## $ m: int [1:15] 1 2 3 4 5 6 7 8 9 10 ...
## $ n:List of 2
## $ p:List of 2
## $ q:List of 2
## x : num [1:3] 1 2 3
## y : chr [1:3] "a" "b" "c"
## z : List of 2
## $ m: int [1:5] 1 2 3 4 5
## $ n: chr [1:3] "x" "y" "z"
其功能类似于 RStudio 中的环境面板,当需要检查自定义环境或仅显示某些特定变量的结构时,可能很有用。
ls.str()的一个过滤器是模式参数。你可以显示所有值为列表对象的结构:
ls.str(mode = "list")
## nested_list : List of 4
## $ m: int [1:15] 1 2 3 4 5 6 7 8 9 10 ...
## $ n:List of 2
## $ p:List of 2
## $ q:List of 2
## z : List of 2
## $ m: int [1:5] 1 2 3 4 5
## $ n: chr [1:3] "x" "y" "z"
另一个过滤器是模式参数,它指定要匹配的名称模式。该模式用正则表达式表示。如果你想显示所有只包含一个字符的变量结构,你可以运行以下命令:
ls.str(pattern = "^\\w$")
## x : num [1:3] 1 2 3
## y : chr [1:3] "a" "b" "c"
## z : List of 2
## $ m: int [1:5] 1 2 3 4 5
## $ n: chr [1:3] "x" "y" "z"
如果你想显示所有只包含一个字符的列表对象结构,你可以同时使用模式和模式:
ls.str(pattern = "^\\w$", mode = "list")
## z : List of 2
## $ m: int [1:5] 1 2 3 4 5
## $ n: chr [1:3] "x" "y" "z"
如果你被像^\\w$这样的命令吓到,不要担心。此模式匹配所有形式为(string begin)(any one word character like a, b, c)(string end)的字符串。我们将在第六章“处理字符串”中介绍这个强大的工具。
移除符号
到目前为止,我们只创建了符号。有时,移除它们可能很有用。remove()函数,或等价的rm(),从环境中移除现有符号。
在移除 x 之前,环境中的符号如下:
ls()
## [1] "nested_list" "x" "y" "z"
然后,我们将使用 rm() 从环境中移除 x:
rm(x)
ls()
## [1] "nested_list" "y" "z"
注意,该函数也适用于字符串中的变量名。因此,rm("x") 有完全相同的效果。我们也可以在一个函数调用中移除多个符号:
rm(y, z)
ls()
## [1] "nested_list"
如果要移除的符号在环境中不存在,将会出现警告:
rm(x)
## Warning in rm(x): object 'x' not found
rm() 函数也可以通过符号名称的字符向量移除指定的所有符号:
p <- 1:10
q <- seq(1, 20, 5)
v <- c("p", "q")
rm(list = v)
## [1] "nested_list" "v"
如果我们想清除环境中的所有绑定,我们可以结合 rm() 和 ls() 并像这样调用函数:
rm(list = ls())
ls()
## character(0)
现在环境中没有符号。
在许多情况下,移除符号不是必需的,但移除占用大量内存的大对象可能很有用。如果 R 感觉到内存压力,它将清理没有绑定的未使用对象。
修改全局选项
与在工作环境中创建、检查和删除对象相比,R 选项在当前 R 会话的全局范围内产生影响。我们可以调用 getOption() 来查看给定选项的值,并调用 options() 来修改它。
修改打印数字的位数
在 RStudio 中,当你输入 getOption(<Tab>) 时,你可以看到一个可用选项及其描述的列表。例如,常用的一个选项是显示的数字位数。有时,当我们处理需要更高精度的数字时,这并不足够。在一个 R 会话中,屏幕上打印的数字位数完全由 digits 管理。我们可以调用 getOption() 来查看 digits 的当前值,并调用 options() 将 digits 设置为更大的数字:

当 R 会话开始时,digits 的默认值是 7。为了演示其效果,运行以下代码:
123.12345678
## [1] 123.1235
显然,11 位的数字只显示了 7 位。这意味着最后几位小数位已经丢失;打印机只显示 7 位的数字。为了验证 digits = 7 不会因为精度丢失,请查看以下代码的输出:
0.10000002
## [1] 0.1
0.10000002 -0.1
## [1] 2e-08
如果数字默认四舍五入到第七位小数,那么 0.10000002 应该四舍五入到 0.1,第二个表达式应该得到 0。然而,显然这不是因为 digits = 7 只意味着要显示的数字位数,而不是向上舍入。
然而,在某些情况下,小数点前的数字可能很大,我们不希望忽略小数点后的数字。在不修改数字的情况下,以下数字将只显示整数部分:
1234567.12345678
## [1] 1234567
如果我们想打印更多的数字,我们需要将数字从默认值 7 增加到一个更高的数字:
getOption("digits")
## [1] 7
1e10 + 0.5
## [1] 1e + 10
options(digits = 15)
1e10 + 0.5
## [1] 10000000000.5
注意,一旦调用 options(),修改后的选项将立即生效,可能会影响所有后续命令的行为。要重置选项,请使用此命令:
options(digits = 7)
1e10 + 0.5
## [1] 1e + 10
修改警告级别
另一个通过指定warn选项值来管理警告级别的选项示例:
getOption("warn")
## [1] 0
默认情况下,警告级别是0,这意味着警告就是警告,错误就是错误。在这种状态下,警告会被显示,但不会停止代码,而错误会立即终止代码。如果发生多个警告,它们将被合并并一起显示。例如,以下从字符串到数值向量的转换将产生警告并导致缺失值:
as.numeric("hello")
## Warning: NAs introduced by coercion
## [1] NA
我们可以使它完全静音,并且仍然从失败的转换中获取缺失值:
options(warn = -1)
as.numeric("hello")
## [1] NA
然后,警告信息消失了。使警告信息消失几乎总是个坏主意。它会使得潜在的错误变得无声。你可能(或不可能)从最终结果中意识到有什么地方出了问题。建议对代码严格要求,这样可以节省大量调试时间。
将warn设置为 1 或 2 将使有缺陷的代码快速失败。当warn = 0时,评估函数调用的默认行为是首先返回值,然后如果有的话,一起显示所有警告信息。为了演示这种行为,以下函数使用两个字符串被调用:
f <- function(x, y) {
as.numeric(x) + as.numeric(y)
}
在默认的警告级别下,所有警告信息都在函数返回后显示:
options(warn = 0)
f("hello", "world")
## [1] NA
## Warning messages:
## 1: In f("hello", "world") : NAs introduced by coercion
## 2: In f("hello", "world") : NAs introduced by coercion
该函数将两个输入参数强制转换为数值向量。由于输入参数都是字符串,将产生两个警告,但它们只会在函数返回后出现。如果前面的函数执行了一些繁重的工作并花费了相当长的时间,那么在得到最终结果之前你不会看到任何警告,但实际上中间计算已经偏离正确结果有一段时间了。
这促使使用warn = 1,它强制警告信息在产生警告时立即打印出来:
options(warn = 1)
f("hello", "world")
## Warning in f("hello", "world") : NAs introduced by coercion
## Warning in f("hello", "world") : NAs introduced by coercion
## [1] NA
结果相同,但警告信息出现在结果之前。如果函数耗时较长,我们应该能够先看到警告信息。因此,我们可以选择停止代码并检查是否有问题。
警告级别甚至更严格。warn = 2参数直接将任何警告视为错误。
options(warn = 2)
f("hello", "world")
## Error in f("hello", "world") :
## (converted from warning) NAs introduced by coercion
这些选项在全局范围内有影响。因此,它们便于管理 R 会话的常见方面,但改变选项也可能很危险。就像改变工作目录可能会使脚本中所有相对路径无效一样,改变全局选项可能会破坏所有基于全局选项不兼容假设的后续代码。
通常情况下,除非绝对必要,否则不建议修改全局选项。
管理包库
在 R 语言中,包在数据分析和可视化中扮演着不可或缺的角色。实际上,R 本身只是一个微小的核心,它建立在几个基本包的基础上。包是一个预定义函数的容器,这些函数通常设计得足够通用,可以解决一定范围内的问题。使用一个设计良好的包,我们不必一次次地重新发明轮子,这使我们能够更多地关注我们试图解决的问题。
R 语言之所以强大,不仅因为其丰富的包资源,还因为有一个维护良好的包存档系统,称为综合 R 存档网络,或简称 CRAN (cran.r-project.org/)。R 的源代码和数千个包都存档在这个系统中。在撰写本文时,CRAN 上有 7,750 个活跃的包,由全球超过 4,500 名包维护者维护。每周,将有超过 100 个包被更新,超过 200 万次包下载。您可以在cran.rstudio.com/web/packages/查看包的表格,其中列出了目前所有可用的包。
当你听到 CRAN 上包的数量时,请不要慌张!数量很大,覆盖面很广,但你只需要学习其中的一小部分。如果你专注于特定领域的工作,那么与你工作和领域高度相关的包可能不超过 10 个。因此,你完全没有必要了解所有的包(没有人能够或甚至需要这样做),只需要了解最有用和与领域相关的那些。
而不是在表格中寻找包,这并不那么有信息量,我建议你访问 CRAN 任务视图(cran.rstudio.com/web/views/)和 METACRAN www.r-pkg.org/,并从学习与你工作领域最常用或最相关的包开始。在学习如何使用特定包之前,我们需要对从不同来源安装包有一个大致的了解,并理解包的基本工作原理。
了解一个包
包是一组用于解决一定范围内问题的函数。它可以是一个统计估计器系列、数据挖掘方法、数据库接口或优化工具的实现。要了解更多关于某个包的信息,例如功能强大的图形包 ggplot2,以下信息来源很有用:
-
软件包描述页面(
cran.rstudio.com/web/packages/ggplot2/):该页面包含软件包的基本信息,包括名称、描述、版本、发布日期、作者、相关网站、参考手册、示例、与其他软件包的关系等。软件包的描述页面不仅由 CRAN 提供,还由一些其他第三方软件包信息网站提供。METACRAN 还在www.r-pkg.org/pkg/ggplot2上提供了 ggplot2 的描述。 -
软件包网站(
ggplot2.org/):该网页包含软件包的描述和相关资源,如博客、教程和书籍。并非每个软件包都有网站,但如果有,该网站是了解软件包的官方起点。 -
软件包源代码(
github.com/hadley/ggplot2):作者在 GitHub(github.com)上托管软件包的源代码,该页面是软件包的源代码。如果您对软件包函数的实现感兴趣,您可以查看源代码并查看。如果您发现一些看起来像错误的不寻常行为,您可以在github.com/hadley/ggplot2/issues处报告它。您也可以在同一个地方提交问题以请求新功能。
在阅读软件包描述后,您可以通过将软件包安装到 R 库中来尝试它。
从 CRAN 安装软件包
CRAN 存档 R 软件包并将它们分发到全球超过 120 个镜像站点。您可以访问 CRAN 镜像(cran.r-project.org/mirrors.html)并查看附近的镜像。如果您找到了一个,您可以去工具 | 全局选项并打开以下对话框:

您可以将 CRAN 镜像更改为附近的镜像或简单地使用默认镜像。通常,如果您使用附近的镜像,您将体验到非常快的下载速度。在最近几个月,一些镜像开始使用 HTTPS 来保护数据传输。如果勾选了使用安全的 HTTP 下载方法,那么您就只能查看 HTTPS 镜像。
一旦选择了镜像,在 R 中下载和安装软件包就变得极其简单。只需调用 install.packages("ggplot2"),R 将自动下载软件包,安装它,有时还会编译它。
RStudio 也提供了一个安装软件包的简单方法。只需转到“软件包”面板并点击安装。将出现以下对话框:

如包描述所示,一个包可能依赖于其他包。换句话说,当你调用包中的函数时,该函数也会调用其他包中的某些函数,这要求你也安装那些包。幸运的是,install.packages()足够智能,能够知道要安装的包的依赖结构,并将首先安装这些包。
在 METACRAN 的主页(www.r-pkg.org/)上,特色包是那些在 GitHub 上拥有最多星标的包。也就是说,这些包被许多 GitHub 用户标记。你可能希望在一次调用中安装多个特色包,如果你将包名作为字符向量写入,这是自然允许的:
install.packages(c("ggplot2", "shiny", "knitr", "dplyr", "data.table"))
然后,install.packages()函数会自动解析所有这些包的联合依赖结构,并安装它们。
从 CRAN 更新包
默认情况下,install.packages()函数安装指定包的最新版本。一旦安装,包的版本就保持固定。然而,包可能会更新以修复错误或添加新功能。有时,包的更新版本可能会在警告中废弃旧版本中的函数。在这些情况下,我们可能保留包的过时状态,或者阅读包描述中的NEWS包后更新它,该包可以在包描述中找到(例如,cran.r-project.org/web/packages/ggplot2/news.html;有关重大更改的新版本详情,请参阅此链接)。
RStudio 在包面板中“安装”旁边的“更新”按钮提供。我们也可以使用以下函数并选择要更新的包:
update.packages()
RStudio 和前面的函数会扫描包的新版本,并在必要时安装这些包及其依赖项。
从在线仓库安装包
现在,许多包作者在 GitHub 上托管他们的工作,因为版本控制和社区开发非常容易,这得益于精心设计的 issue-tracking 系统和 merge request 系统。一些作者不将他们的工作发布到 CRAN,而其他人只发布稳定的版本到 CRAN,并将新版本的开发保持在 GitHub 上。
如果你想要尝试最新的开发版本,它通常包含新功能或修复了一些错误,你可以直接使用 devtools 包从在线仓库安装包。
首先,如果 devtools 包不在你的库中,请安装它:
install.packages("devtools")
然后,使用 devtools 包中的install_github()安装 ggplot2 的最新开发版本:
library(devtools)
install_github("hadley/ggplot2")
devtools 包将从 GitHub 下载源代码,并将其作为你的库中的一个包。如果你的库中已经有了这个包,安装将替换它而不会询问。如果你想将开发版本回滚到最新的 CRAN 版本,你可以再次运行 CRAN 安装代码:
install.packages("ggplot2")
然后,本地版本(GitHub 版本)被 CRAN 版本所取代。
使用包函数
使用包中的函数有两种方法。首先,我们可以调用 library() 来附加包,这样其中的函数就可以直接调用。其次,我们可以调用 package::function() 来仅使用函数,而无需将整个包附加到环境中。
例如,一些统计估计器不是在基础 R 中作为内置函数实现的,而是在其他包中实现。一个例子是偏度;统计函数由 moments 包提供。
要计算数值向量 x 的偏度,我们首先可以附加包,然后直接调用函数:
library(moments)skewness(x)
或者,我们可以不附加包就调用包函数,使用 :::
moments::skewness(x)
这两种方法返回相同的结果,但它们以不同的方式工作,并对环境有不同的影响。更具体地说,第一种方法(使用 library())修改了符号的搜索路径,而第二种方法(使用 ::)则不会。当你调用 library(moments) 时,包会被附加并添加到搜索路径中,这样包中的函数就可以在后续代码中直接使用。
有时,通过调用 sessionInfo() 来查看我们正在使用的包是有用的:
sessionInfo()
## R version 3.2.3 (2015-12-10)
## Platform: x86_64-w64-mingw32/x64 (64-bit)
## Running under: Windows 10 x64 (build 10586)
##
## locale:
## [1] LC_COLLATE=English_UnitedStates.1252
## [2] LC_CTYPE=English_UnitedStates.1252
## [3] LC_MONETARY=English_UnitedStates.1252
## [4] LC_NUMERIC=C
## [5] LC_TIME=English_UnitedStates.1252
##
## attached base packages:
## [1] stats graphics grDevicesutils datasets
## [6] methods base
##
## loaded via a namespace (and not attached):
## [1] magrittr_1.5formatR_1.2.1tools_3.2.3
## [4] htmltools_0.3yaml_2.1.13stringi_1.0-1
## [7] rmarkdown_0.9.2knitr_1.12stringr_1.0.0
## [10] digest_0.6.8evaluate_0.8
会话信息显示了 R 版本,并列出了附加包和已加载包。当我们使用 :: 访问包中的函数时,包未附加但已加载到内存中。在这种情况下,包中的其他函数仍然不可直接使用:
moments::skewness(c(1, 2, 3, 2, 1))
## [1] 0.3436216
sessionInfo()
## R version 3.2.3 (2015-12-10)
## Platform: x86_64-w64-mingw32/x64 (64-bit)
## Running under: Windows 10 x64 (build 10586)
##
## locale:
## [1] LC_COLLATE=English_UnitedStates.1252
## [2] LC_CTYPE=English_UnitedStates.1252
## [3] LC_MONETARY=English_UnitedStates.1252
## [4] LC_NUMERIC=C
## [5] LC_TIME=English_UnitedStates.1252
##
## attached base packages:
## [1] stats graphics grDevicesutils datasets
## [6] methods base
##
## loaded via a namespace (and not attached):
## [1] magrittr_1.5formatR_1.2.1tools_3.2.3
## [4] htmltools_0.3yaml_2.1.13stringi_1.0-1
## [7] rmarkdown_0.9.2knitr_1.12stringr_1.0.0
## [10] digest_0.6.8moments_0.14evaluate_0.8
这表明 moments 包已被加载但未附加。当我们 calllibrary(moments) 时,包将被附加:
library(moments)sessionInfo()
## R version 3.2.3 (2015-12-10)
## Platform: x86_64-w64-mingw32/x64 (64-bit)
## Running under: Windows 10 x64 (build 10586)
##
## locale:
## [1] LC_COLLATE=English_UnitedStates.1252
## [2] LC_CTYPE=English_UnitedStates.1252
## [3] LC_MONETARY=English_UnitedStates.1252
## [4] LC_NUMERIC=C
## [5] LC_TIME=English_UnitedStates.1252
##
## attached base packages:
## [1] stats graphics grDevicesutils datasets
## [6] methods base
##
## other attached packages:
## [1] moments_0.14
##
## loaded via a namespace (and not attached):
## [1] magrittr_1.5formatR_1.2.1tools_3.2.3
## [4] htmltools_0.3yaml_2.1.13stringi_1.0-1
## [7] rmarkdown_0.9.2knitr_1.12stringr_1.0.0
## [10] digest_0.6.8evaluate_0.8
skewness(c(1, 2, 3, 2, 1))
## [1] 0.3436216
然后,skewness() 以及 moments 中的其他包函数都可直接使用。
查看附加包的一个更简单的方法是使用 search():
search()
## [1] ".GlobalEnv" "package:moments"
## [3] "package:stats" "package:graphics"
## [5] "package:grDevices" "package:utils"
## [7] "package:datasets" "package:methods"
## [9] "Autoloads" "package:base"
该函数返回符号的当前搜索路径。当你评估使用偏度的函数调用时,它首先在当前环境中查找偏度符号。然后,它转到 package:moment 并找到符号。如果包未附加,则符号将找不到,因此会发生错误。我们将在后面的章节中介绍这种符号查找机制。
要附加包,require() 与 library() 类似,但它返回一个逻辑值以指示包是否成功附加:
loaded <- require(moments)
## Loading required package: moments
loaded
## [1] TRUE
此功能允许以下代码在包已安装的情况下附加包,如果没有安装,则安装它:
if (!require(moments)) { install.packages("moments") library(moments)}
然而,用户代码中 require() 函数的大多数使用并不像这样。以下是一个典型的例子:
require(moments)
这看起来与使用 library() 相似,但有一个无声的缺点:
require(testPkg)
## Loading required package: testPkg
## Warning in library(package, lib.loc = lib.loc,
## character.only = TRUE, logical.return = TRUE, : there is no
## package called 'testPkg'
如果要附加的包未安装或甚至根本不存在(可能是拼写错误),require() 只会产生警告而不是像 library() 那样产生错误:
library(testPkg)
## Error in library(testPkg): there is no package called 'testPkg'
想象你正在运行一个漫长且耗时的 R 脚本,它依赖于几个包。如果你使用require(),不幸的是运行你的脚本的计算机没有安装所需的包,脚本将仅在稍后失败,当调用包函数而找不到函数时。然而,如果你使用library()代替,如果运行计算机上不存在这些包,脚本将立即停止。Yihui Xie 就这个问题写了一篇博客(yihui.name/en/2014/07/library-vs-require/),并提出了快速失败原则:如果任务必须失败,最好是快速失败。
掩盖和名称冲突
一个新的 R 会话以自动附加的基本包开始。基本包指的是 base、stats、graphics 等。附加这些包后,你可以使用mean()计算数值向量的平均值,使用median()计算其中位数,而无需使用base::mean()和stats::median()或手动附加base和stats包。
实际上,从自动附加的包中立即可用数千个函数,每个包都定义了为特定目的而设计的函数。因此,两个包中的函数可能存在冲突。例如,假设两个包 A 和 B 都有一个名为 X 的函数。如果你先附加 A 然后附加 B,函数 A::X 将被函数 B::X 掩盖。换句话说,当你附加 A 并调用X()时,调用的是 A 的 X。然后,你附加 B 并调用X();现在调用的是 B 的 X。这种机制被称为掩盖。以下示例显示了掩盖发生时的情况。
强大的数据处理包 dplyr 定义了一系列函数,使操作表格数据更加容易。当我们附加该包时,会打印一些消息来显示一些现有函数被具有相同名称的包函数掩盖:
library(dplyr)
##
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
幸运的是,dplyr中这些函数的实现并没有改变其意义和用法,而是泛化了它们。这些函数与被掩盖的版本兼容。因此,你不必担心被掩盖的函数已损坏且不再工作。
将几乎掩盖基本功能的包函数打包几乎总是泛化而不是替换。然而,如果你必须使用两个包含一些函数具有相同名称的包,你最好不要附加任何包;相反,从你需要的两个包中提取函数,如下所示:
fun1 <- package1::some_function
fun2 <- pacakge2::some_function
如果你偶然附加了一个包并想要卸载它,你可以调用unloadNamespace()。例如,我们已经附加了 moments,我们可以卸载它:
unloadNamespace("moments")
一旦包被卸载,包函数就不再直接可用:
skewness(c(1, 2, 3, 2, 1))
## Error in eval(expr, envir, enclos): could not find function "skewness"
然而,你仍然可以使用::来调用函数:
moments::skewness(c(1, 2, 3, 2, 1))
## [1] 0.3436216
检查包是否已安装
有用的一点是,install.packages() 执行安装,而 installed.packages() 显示已安装软件包的信息,这是一个包含 16 列的矩阵,涵盖了广泛的信息:
pkgs <- installed.packages()
colnames(pkgs)
## [1] "Package" "LibPath"
## [3] "Version" "Priority"
## [5] "Depends" "Imports"
## [7] "LinkingTo" "Suggests"
## [9] "Enhances" "License"
## [11] "License_is_FOSS" "License_restricts_use"
## [13] "OS_type" "MD5sum"
## [15] "NeedsCompilation" "Built"
当你需要检查一个软件包是否已安装时,这可能很有用:
c("moments", "testPkg") %in% installed.packages()[, "Package"]
## [1] TRUE FALSE
有时,你需要检查软件包的版本:
installed.packages()["moments", "Version"]
## [1] "0.14"
获取软件包版本的一个更简单的方法是使用以下命令:
packageVersion("moments")
## [1] '0.14'
我们可以比较两个软件包版本,以便检查软件包是否比给定版本新:
packageVersion("moments") >= package_version("0.14")
## [1] TRUE
事实上,我们可以直接使用字符串版本来进行比较:
packageVersion("moments") >= "0.14"
## [1] TRUE
如果你的脚本依赖于某些必须等于或更新于特定版本的软件包,检查软件包版本可能很有用。如果脚本依赖于该版本中引入的一些新功能,这也可能成立。此外,如果软件包未安装,packageVersion() 将产生错误,这也使得检查软件包安装状态成为可能。
摘要
在本章中,你了解了工作目录的概念以及处理它的工具。你还探索了检查工作环境、修改全局选项和管理软件包库的函数。现在,你有了管理工作空间的基本知识。
在下一章中,你将学习到几个基本表达式,包括赋值、条件和循环。这些表达式是程序逻辑的构建块。我将在下一章中向你展示如何编写高效且健壮的控制流表达式。
第四章:基本表达式
表达式是函数的构建块。R 有一个非常清晰的语法,表明一个表达式要么是一个符号,要么是一个函数调用。
尽管我们做的所有事情本质上都是由函数实现的,但 R 给一些函数提供了特殊的语法,以便编写更易于阅读的 R 代码。
在接下来的几节中,我们将看到以下一些基本表达式,它们具有特殊的语法:
-
赋值表达式
-
条件表达式
-
循环表达式
赋值表达式
赋值可能是所有编程语言中最基本的表达式之一。它所做的就是将值分配或绑定到符号上,这样我们就可以通过该符号稍后引用该值。
尽管相似,R 使用 <- 操作符进行赋值。这与许多使用 = 的其他语言略有不同,尽管在 R 中也允许使用 =:
x <- 1
y <- c(1, 2, 3)
z <- list(x, y)
我们在给符号赋值之前不需要声明其符号和类型。如果一个符号在环境中不存在,赋值将创建该符号。如果符号已经存在,赋值不会导致冲突,而是将新值重新绑定到该符号上。
替代赋值操作符
我们可以使用一些等价的替代操作符。与 x <- f(z) 相比,它将 f(z) 的值绑定到符号 x 上,我们也可以使用 -> 来执行相反方向的赋值:
2 -> x1
我们甚至可以将赋值操作符链式使用,以便一组符号都取相同的值:
x3 <- x2 <- x1 <- 0
表达式 0 只计算一次,因此相同的值被分配给三个符号。为了验证其工作原理,我们可以将 0 改为一个随机数生成器:
x3 <- x2 <- x1 <- rnorm(1)
c(x1, x2, x3)
## [1] 1.585697 1.585697 1.585697
rnorm(1) 方法生成一个符合标准正态分布的随机数。如果每次赋值都重新调用随机数生成器,每个符号将具有不同的值。然而,实际上并不会发生这种情况。稍后,我将解释究竟发生了什么,并帮助你更好地理解这一点。
与其他编程语言一样,= 也可以执行赋值:
x2 = c(1, 2, 3)
如果你熟悉其他流行的编程语言,如 Python、Java 和 C#,你可能会发现使用 = 作为赋值操作符几乎成为行业标准,可能会觉得使用 <- 不舒服,因为需要更多输入。然而,谷歌的 R 风格指南 (google.github.io/styleguide/Rguide.xml#assignment) 建议使用 <- 而不是 =,尽管两者都被允许,并且当它们用作赋值操作符时具有完全相同的效果。
在这里,我将提供一个简单的解释来说明 <- 和 = 之间的细微差别。让我们首先创建一个 f() 函数,它接受两个参数:
f <- function(input, data = NULL) {
cat("input:\n")
print(input)
cat("data:\n")
print(data)
}
该函数基本上打印两个参数的值。然后,让我们使用这个函数来展示两个操作符之间的区别:
x <- c(1, 2, 3)
y <- c("some", "text")
f(input = x)
## input:
## [1] 1 2 3
## data:
## NULL
上述代码使用了 <- 和 = 操作符,但它们扮演不同的角色。前两行中的 <- 操作符用作赋值操作符,而第三行中的 = 在 f() 方法中指定了一个命名参数 input。
更具体地说,<- 操作符评估其右侧的表达式 c(1, 2, 3) 并将评估后的值赋给左侧的符号(变量)x。= 操作符不作为赋值操作符使用,而是用于通过名称匹配函数参数。
我们知道当作为赋值操作符使用时,<- 和 = 操作符是可以互换的。因此,前面的代码等同于以下代码:
x = c(1, 2, 3)
y = c("some", "text")
f(input = x)
## input:
## [1] 1 2 3
## data:
## NULL
在这里,我们只使用了 = 操作符,但用于两个不同的目的:在前两行中,= 执行赋值,而在第三行中 = 指定了命名参数。
现在,让我们看看如果我们将所有的 = 都改为 <- 会发生什么:
x <- c(1, 2, 3)
y <- c("some", "text")
f(input <- x)
## input:
## [1] 1 2 3
## data:
## NULL
如果你运行这段代码,你会发现输出相似。然而,如果你检查环境,你会观察到差异:现在环境中创建了一个新的 input 变量,并获得了 c(1, 2, 3) 的值:
input
## [1] 1 2 3
那么,发生了什么?实际上,在第三行中发生了两件事:首先,赋值 input <- x 将一个新的 input 符号引入环境并得到 x。然后,input 的值提供给函数 f() 的第一个参数。换句话说,第一个函数参数不是通过名称匹配,而是通过位置匹配。
为了进一步阐述,我们将进行更多实验。函数的标准用法如下:
f(input = x, data = y)
## input:
## [1] 1 2 3
## data:
## [1] "some" "text"
如果我们将两个 = 都替换为 <-,结果看起来相同:
f(input <- x, data <- y)
## input:
## [1] 1 2 3
## data:
## [1] "some" "text"
对于使用 = 的代码,我们可以交换两个命名参数而不改变结果:
f(data = y, input = x)
## input:
## [1] 1 2 3
## data:
## [1] "some" "text"
然而,在这种情况下,如果我们用 <- 代替 =,input 和 data 的值也会交换:
f(data <- y, input <- x)
## input:
## [1] "some" "text"
## data:
## [1] 1 2 3
以下代码与前面代码具有相同的效果:
data <- y
input <- x
f(y, x)
## input:
## [1] "some" "text"
## data:
## [1] 1 2 3
这段代码不仅导致 f(y, x),而且在不必要的情况下在当前环境中创建了额外的 data 和 input 变量。
从前面的例子和实验中,我们可以得出结论。为了减少歧义,允许使用 <- 或 = 作为赋值操作符,并且只使用 = 为函数指定命名参数。总之,为了提高 R 代码的可读性,正如 Google 风格指南所建议的,只使用 <- 进行赋值,使用 = 指定命名参数。
使用非标准名称的反引号
赋值操作符允许我们将值赋给变量(或符号或名称)。然而,直接赋值限制了名称的格式。它只包含从 a 到 z,从 A 到 Z 的字母(R 区分大小写),下划线(_)和点(.),并且不应包含空格或以下划线开头(_)。
以下是一些有效的名称:
students <- data.frame()
us_population <- data.frame()
sales.2015 <- data.frame()
以下名称无效,因为违反了命名规则:
some data <- data.frame()
## Error: unexpected symbol in "some data"
_data <- data.frame()
## Error: unexpected input in "_"
Population(Millions) <- data.frame()
## Error in Population(Millions) <- data.frame() :
## object 'Millions' not found
前面的名称以不同的方式违反了规则。some data变量名包含空格,_data以下划线开头,而Population(Millions)不是一个符号名,而是一个函数调用。在实践中,某些无效的名称确实可能是数据表中的列名,例如第三个名称。
为了绕过这个问题,我们需要使用反引号来引用无效的名称,使其变得有效:
`some data` <- c(1, 2, 3)
`_data` <- c(4, 5, 6)
`Population(Millions)` <- c(city1 = 50, city2 = 60)
要引用这些变量,也使用反引号;否则,它们仍然被视为无效:
`some data`
## [1] 1 2 3
`_data`
## [1] 4 5 6
`Population(Millions)`
## city1city2
## 50 60
反引号可以在我们创建符号的任何地方使用,无论它是否是一个函数:
`Tom's secret function` <- function(a, d) {
(a ^ 2 - d ^ 2) / (a ^ 2 + d ^ 2)
}
即使它是一个列表:
l1 <- list(`Group(A)` = rnorm(10), `Group(B)` = rnorm(10))
如果符号名不能直接有效引用,我们还需要使用引号来引用该符号:
`Tom's secret function`(1,2)
## [1] -0.6
l1$`Group(A)`
## [1] -0.8255922 -1.1508127 -0.7093875 0.5977409 -0.5503219 -1.0826915
## [7] 2.8866138 0.6323885 -1.5265957 0.9926590
data.frame()是一个例外:
results <- data.frame(`Group(A)` = rnorm(10), `Group(B)` = rnorm(10))
results
## Group.A. Group.B.
## 1 -1.14318956 1.66262403
## 2 -0.54348588 0.08932864
## 3 0.95958053 -0.45835235
## 4 0.05661183 -1.01670316
## 5 -0.03076004 0.11008584
## 6 -0.05672594 -2.16722176
## 7 -1.31293264 1.69768806
## 8 -0.98761119 -0.71073080
## 9 2.04856454 -1.41284611
## 10 0.09207977 -1.16899586
不幸的是,即使我们在具有不寻常符号的名称周围使用反引号,生成的data.frame变量也会用点替换这些符号或使用make.names()方法,这一点可以通过查看生成的data.frame的列名来确认:
colnames(results)
## [1] "Group.A." "Group.B."
这种情况通常发生在你导入如下 CSV 数据表时,该数据表是实验结果:
ID,Category,Population(before),Population(after)
0,A,10,12
1,A,12,13
2,A,13,16
3,B,11,12
4,C,13,12
当你使用read.csv()读取 CSV 数据时,Population(before)和Population(after)变量将不会保留它们的原始名称,而是会使用make.names()方法在 R 中将它们更改为有效的名称。要了解我们将得到什么名称,我们可以运行以下命令:
make.names(c("Population(before)", "Population(after)"))
## [1] "Population.before." "Population.after."
有时,这种行为是不希望的。要禁用它,在调用read.csv()或data.frame()时设置check.names = FALSE:
results <- data.frame(
ID = c(0, 1, 2, 3, 4),
Category = c("A", "A", "A", "B", "C"),
`Population(before)` = c(10, 12, 13, 11, 13),
`Population(after)` = c(12, 13, 16, 12, 12),
stringsAsFactors = FALSE,
check.names = FALSE)
results
## ID Category Population(before) Population(after)
## 1 0 A 10 12
## 2 1 A 12 13
## 3 2 A 13 16
## 4 3 B 11 12
## 5 4 C 13 12
colnames(results)
## [1] "ID" "Category" "Population(before)"
## [4] "Population(after)"
在前面的调用中,stringAsFactors = FALSE避免了将字符向量转换为因子,check.names = FALSE避免了在列名上应用make.names()。有了这两个参数,创建的data.frame变量将保留输入数据的大部分方面。
正如我提到的,要访问具有特殊符号的列,使用反引号来引用名称:
results$`Population(before)`
## [1] 10 12 13 11 13
反引号使得创建和访问变量成为可能,直接赋值时不允许使用符号。这并不意味着使用这样的名称是推荐的。相反,它可能会使代码更难以阅读和更容易出错,并且使得与强制执行严格命名规则的外部工具一起工作变得更加困难。
总之,除非绝对必要,否则应避免使用反引号创建特殊变量名。
条件表达式
通常,程序的逻辑不是完全顺序的,而是包含依赖于某些条件的几个分支。因此,典型编程语言中最基本的构造之一是其条件表达式。在 R 中,if可以通过逻辑条件来分支逻辑流程。
使用 if 作为语句
与许多其他编程语言一样,if表达式与逻辑条件一起工作。在 R 中,逻辑条件由产生单个元素逻辑向量的表达式表示。例如,我们可以编写一个简单的函数check_positive,如果提供正数则返回1,否则不返回任何内容:
check_positive <- function(x) {
if (x > 0) {
return(1)
}
}
在前面的函数中,x > 0是检查的条件。如果条件满足,则函数返回1。让我们用各种输入来验证这个函数:
check_positive(1)
## [1] 1
check_positive(0)
看起来函数按预期工作。如果我们添加一些else if和else分支,函数可以概括为返回1的正输入,负输入返回-1,0 返回 0 的符号函数:
check_sign <- function(x) {
if (x > 0) {
return(1)
} else if (x < 0) {
return(-1)
} else {
return(0)
}
}
前面的函数与内置函数sign()具有相同的功能。为了验证其逻辑,只需用不同输入调用它,并全面覆盖条件分支即可:
check_sign(15)
## [1] 1
check_sign(-3.5)
## [1] -1
check_sign(0)
## [1] 0
函数不需要返回任何内容。我们也可以根据各种条件执行不返回任何内容(更准确地说,是NULL)的操作。以下函数始终不显式返回值,但会在控制台发送消息。消息的类型取决于输入数字的符号:
say_sign <- function(x) {
if (x > 0) {
cat("The number is greater than 0")
} else if (x < 0) {
cat("The number is less than 0")
} else {
cat("The number is 0")
}
}
我们可以使用类似的方法,即say_sign(),来测试其逻辑:
say_sign(0)
## The number is 0
say_sign(3)
## The number is greater than 0
say_sign(-9)
## The number is less than 0
评估if语句分支的工作流程相当直接:
-
首先,在第一个
if (cond1) { expr1 }中评估cond1。 -
如果
cond1是TRUE,则评估其对应的表达式{ expr1 }。否则,评估下一个else if (cond2)分支中的cond2条件,依此类推。 -
如果所有
if和else if分支的条件都被违反,那么如果存在,评估else分支中的表达式。
根据工作流程,if语句可能比你想象的更灵活。例如,if语句可以有以下几种形式之一。
最简单的形式是一个简单的if语句分支:
if (cond1) {
# do something
}
更完整的形式是包含一个else分支,用于处理cond1不是TRUE的情况:
if (cond1) {
# do something
} else {
# do something else
}
更复杂的形式是包含一个或多个else if分支:
if (cond1) {
expr1
} else if (cond2) {
expr2
} else if (cond3) {
expr3
} else {
expr4
}
在前面的条件分支中,分支条件(cond1、cond2和cond3)可能相关也可能不相关。例如,简单的评分策略完美地符合前面模板中的分支逻辑,其中每个分支条件都是分数范围的切片:
grade <- function(score) {
if (score >= 90) {
return("A")
} else if (score >= 80) {
return("B")
} else if (score >= 70) {
return("C")
} else if (score >= 60) {
return("D")
} else {
return("F")
}
}
c(grade(65), grade(59), grade(87), grade(96))
## [1] "D" "F" "B" "A"
在这种情况下,else if中的每个分支条件实际上隐含地假设前面的条件不成立;也就是说,score >= 80实际上意味着score < 90和score >= 80,这依赖于前面的条件。因此,我们无法在不明确陈述假设并使所有分支独立的情况下改变这些分支的顺序。
假设我们切换了一些分支:
grade2 <- function(score) {
if (score >= 60) {
return("D")
} else if (score >= 70) {
return("C")
} else if (score >= 80) {
return("B")
} else if (score >= 90) {
return("A")
} else {
return("F")
}
}
c(grade2(65), grade2(59), grade2(87), grade2(96))
## [1] "D" "F" "D" "D"
很明显,只有grade(59)得到了正确的成绩,而其他所有都失败了。为了在不重新排序条件的情况下修复函数,我们需要重新编写条件,使它们不依赖于评估顺序:
grade2 <- function(score) {
if (score >= 60 && score < 70) {
return("D")
} else if (score >= 70 && score < 80) {
return("C")
} else if (score >= 80 && score < 90) {
return("B")
} else if (score >= 90) {
return("A")
} else {
return("F")
}
}
c(grade2(65), grade2(59), grade2(87), grade2(96))
## [1] "D" "F" "B" "A"
这使得函数比第一个正确版本更冗长。因此,确定分支条件的正确顺序并注意每个分支的依赖性非常重要。
幸运的是,R 提供了方便的函数,如cut(),它正好做同样的事情。通过输入?cut来阅读文档以获取更多详细信息。
将 if 用作表达式
由于if本质上是一个原始函数,其返回值是满足条件的分支中的表达式值。因此,if也可以用作内联表达式。以check_positive()方法为例。我们可以在条件表达式中不写return(),而是在函数体中返回if语句表达式的值,以达到相同的目的:
check_positive <- function(x) {
return(if (x > 0) {
1
})
}
实际上,表达式语法可以简化为仅仅一行:
check_positive <- function(x) {
return(if (x > 0) 1)
}
由于函数的返回值是函数体中最后一个表达式的值,在这种情况下可以省略return():
check_positive <- function(x) {
if (x > 0) 1
}
同样的原则也适用于check_sign()方法。check_sign()的一个更简单的形式如下:
check_sign <- function(x) {
if (x > 0) 1 else if (x < 0) -1 else 0
}
为了显式获取if表达式的值,我们可以实现一个成绩报告函数,该函数根据学生姓名和他们的分数提及学生的成绩:
say_grade <- function(name, score) {
grade <- if (score >= 90) "A"
else if (score >= 80) "B"
else if (score >= 70) "C"
else if (score >= 60) "D"
else "F"
cat("The grade of", name, "is", grade)
}
say_grade("Betty", 86)
## The grade of Betty is B
将if语句用作表达式看起来更紧凑,也更简洁。然而,在实践中,并非所有条件都是简单的数值比较并返回简单值。对于更复杂的条件和分支,我建议您使用if作为语句来清楚地说明不同的分支,并且不要省略{}以避免不必要的错误。以下是一个不好的例子:
say_grade <- function(name, score) {
if (score >= 90) grade <- "A"
cat("Congratulations!\n")
else if (score >= 80) grade <- "B"
else if (score >= 70) grade <- "C"
else if (score >= 60) grade <- "D"
else grade <- "F"
cat("What a pity!\n")
cat("The grade of", name, "is", grade)
}
函数作者想要向某些分支添加一些说明。如果没有在分支表达式周围使用{}括号,当您向条件分支添加更多行为时,您很可能编写出有语法错误的代码。如果您在控制台中评估前面的代码,您将得到足够的错误,让您困惑一段时间:
>say_grade <- function(name, score) {
+ if (score >= 90) grade <- "A"
+ cat("Congratulations!\n")
+ else if (score >= 80) grade <- "B"
Error: unexpected 'else' in:
" cat("Congratulations!\n")
else"
> else if (score >= 70) grade <- "C"
Error: unexpected 'else' in " else"
> else if (score >= 60) grade <- "D"
Error: unexpected 'else' in " else"
> else grade <- "F"
Error: unexpected 'else' in " else"
> cat("What a pity!\n")
What a pity!
> cat("The grade of", name, "is", grade)
Error in cat("The grade of", name, "is", grade) : object 'name' not found
> }
Error: unexpected '}' in "}"
避免这种潜在陷阱的函数更好形式如下:
say_grade <- function(name, score) {
if (score >= 90) {
grade <- "A"
cat("Congratulations!\n")
} else if (score >= 80) {
grade <- "B"
}
else if (score >= 70) {
grade <- "C"
}
else if (score >= 60) {
grade <- "D"
} else {
grade <- "F"
cat("What a pity!\n")
}
cat("The grade of", name, "is", grade)
}
say_grade("James", 93)
## Congratulations!
## The grade of James is A
函数看起来有点冗长,但它对变化的鲁棒性更强,逻辑也更清晰。记住,总是正确比简洁更重要。
将 if 与向量一起使用
所有的示例函数都只适用于单值输入。如果我们提供一个向量,函数将产生警告,因为if不适用于多元素向量:
check_positive(c(1, -1, 0))
## Warning in if (x > 0) 1: the condition has length > 1 and only the first
## element will be used
## [1] 1
从前面的输出中,我们可以看到,如果提供了多元素逻辑向量,if语句会忽略除了第一个元素之外的所有元素:
num <- c(1, 2, 3)
if (num > 2) {
cat("num > 2!")
}
## Warning in if (num > 2) {: the condition has length > 1 and only the first
## element will be used
表达式会抛出一个警告,说明只会使用第一个元素(1 > 2)。实际上,当我们尝试在逻辑向量上条件化表达式时,其逻辑是不清晰的,因为其值可能会与TRUE和FALSE值混淆。
一些逻辑函数有助于避免这种歧义。例如,any()方法如果给定向量中至少有一个元素是TRUE,则返回TRUE:
any(c(TRUE, FALSE, FALSE))
## [1] TRUE
any(c(FALSE, FALSE))
## [1] FALSE
因此,如果我们真正想要打印消息,如果任何单个值大于2,我们应该在条件中调用any()方法:
if (any(num > 2)) {
cat("num > 2!")
}
## num > 2!
如果我们想要打印第一个消息,如果所有值都大于2,我们应该改用调用all()方法:
if (all(num > 2)) {
cat("num > 2!")
} else {
cat("Not all values are greater than 2!")
}
## Not all values are greater than 2!
因此,每次我们使用if表达式分支工作流程时,我们应该确保条件是一个单值逻辑向量。否则,可能会发生意外的情况。
另一个例外是NA,它也是一个单值逻辑向量,但如果没有注意可能会在if条件中引起错误:
check <- function(x) {
if (all(x > 0)) {
cat("All input values are positive!")
} else {
cat("Some values are not positive!")
}
}
check()函数对于没有缺失值的典型数值向量工作得很好。然而,如果参数x包含缺失值,函数最终可能会出错:
check(c(1, 2, 3))
## All input values are positive!
check(c(1, 2, NA, -1))
## Some values are not positive!
check(c(1, 2, NA))
## Error in if (all(x > 0)) {: missing value where TRUE/FALSE needed
从这个例子中,我们应该在编写if条件时小心缺失值。如果逻辑复杂且输入数据多样,您不能轻易地以适当的方式处理缺失值。请注意,any()和all()方法都接受na.rm来处理缺失值。我们在编写条件时也应该考虑这一点。
简化条件检查的一种方法是用isTRUE(x),它内部调用identical(TRUE, x)。在这种情况下,只有单个TRUE值会满足条件,而所有其他值则不会。
使用向量化的ifelse:ifelse
分支计算的另一种方法是ifelse()。这个函数接受一个逻辑向量作为测试条件,并返回一个向量。对于逻辑测试条件中的每个元素,如果值为TRUE,则选择第二个参数yes中的相应元素。如果值为FALSE,则选择第三个参数no中的相应元素。换句话说,ifelse()是if的向量版本,如下所示:
ifelse(c(TRUE, FALSE, FALSE), c(1, 2, 3), c(4, 5, 6))
## [1] 1 5 6
由于yes和no参数可以被回收利用,我们可以使用ifelse()重新编写check_positive()函数:
check_positive2 <- function(x) {
ifelse(x, 1, 0)
}
check_positive()(使用if语句)和check_positive2()(使用ifelse)之间的一个细微差别是:check_positive(-1)不会显式地返回值,但chek_positive2(-1)返回 0。if语句不一定通过仅使用一个if而不是else来显式地返回一个值。相比之下,ifelse()总是返回一个向量,因为您必须在yes和no参数中指定值。
另一个提醒是,如果你只是简单地将一个替换为另一个,ifelse() 和 if 并不一定总是能够达到相同的目标。例如,假设你想根据条件返回一个包含两个元素的向量。让我们假设我们使用 ifelse():
ifelse(TRUE, c(1,2), c(2,3))
## [1] 1
只有 yes 参数的第一个元素被返回。如果你想返回 yes 参数,你需要修改条件为 c(TRUE, TRUE),这看起来有点不自然。
如果我们使用 if,那么表达式看起来会更加自然:
if (TRUE) c(1,2) else c(2,3)
## [1] 1 2
如果需求是矢量化输入和输出,那么另一个问题是,如果 yes 参数是数值向量而 no 参数是字符向量,则具有混合 TRUE 和 FALSE 值的条件将强制输出向量中的所有元素能够表示所有值。因此,将产生一个字符向量:
ifelse(c(TRUE, FALSE), c(1, 2), c("a", "b"))
## [1] "1" "b"
使用 switch 分支值
与处理 TRUE 和 FALSE 条件的 if 相比,switch 使用数字或字符串,并根据输入选择一个分支返回。
假设输入是一个整数 n。switch 关键字以返回第一个参数之后 n^(th) 个参数值的方式工作:
switch(1, "x", "y")
## [1] "x"
switch(2, "x", "y")
## [1] "y"
如果输入的整数超出范围且不匹配任何给定参数,则不会显式返回任何可见值(实际上,返回了一个不可见的 NULL):
switch(3, "x", "y")
当与字符串输入一起工作时,switch() 方法的行为有所不同。它返回与输入名称匹配的第一个参数的值:
switch("a", a = 1, b = 2)
## [1] 1
switch("b", a = 1, b = 2)
## [1] 2
对于第一个 switch,a = 1 匹配变量 a。对于第二个,b = 2 匹配变量 b。如果没有参数匹配输入,将返回一个不可见的 NULL 值:
switch("c", a = 1, b = 2)
为了涵盖所有可能性,我们可以添加一个最后一个参数(不带参数名称),以捕获所有其他输入:
switch("c", a = 1, b = 2, 3)
## [1] 3
与 ifelse() 方法相比,switch() 的行为更类似于 if() 方法。它只接受单个值输入(字符串数量),但它可以返回任何内容:
switch_test <- function(x) {
switch(x,
a = c(1, 2, 3),
b = list(x = 0, y = 1),
c = {
cat("You choose c!\n")
list(name = "c", value = "something")
})
}
switch_test("a")
## [1] 1 2 3
switch_test("b")
## $x
## [1] 0
##
## $y
## [1] 1
switch_test("c")
## You choose c!
## $name
## [1] "c"
##
## $value
## [1] "something"
总结来说,if、ifelse() 和 switch() 有略微不同的行为。你应该根据不同的情况相应地应用它们。
循环表达式
循环(或迭代)通过遍历向量(for)或检查条件是否被违反(while)来重复评估表达式。
如果相同的任务每次运行时都进行一些输入上的更改,这种语言结构在很大程度上减少了代码的冗余。
使用 for 循环
for 循环通过遍历给定的向量或列表来评估表达式。for 循环的语法如下:
for (var in vector) {
expr
}
然后,expr 将迭代评估,var 依次取 vector 中每个元素的值。如果 vector 有 n 个元素,则前面的循环相当于评估以下表达式:
var <- vector[[1]]
expr
var <- vector[[2]]
expr
...
var <- vector[[n]]
expr
例如,我们可以创建一个循环来遍历 1:3,迭代变量为 i。在每次迭代中,我们将在屏幕上显示文本以指示 i 的值:
for (i in 1:3) {
cat("The value of i is", i, "\n")
}
## The value of i is 1
## The value of i is 2
## The value of i is 3
迭代器不仅与数值向量一起工作,还与所有向量一起工作。例如,我们可以用字符向量替换整数向量1:3:
for (word in c("hello","new", "world")) {
cat("The current word is", word, "\n")
}
## The current word is hello
## The current word is new
## The current word is world
我们也可以用列表替换它:
loop_list <- list(
a = c(1, 2, 3),
b = c("a", "b", "c", "d"))
for (item in loop_list) {
cat("item:\n length:", length(item),
"\n class: ", class(item), "\n")
}
## item:
## length: 3
## class: numeric
## item:
## length: 4
## class: character
或者,我们可以用数据框替换它:
df <- data.frame(
x = c(1, 2, 3),
y = c("A", "B", "C"),
stringsAsFactors = FALSE)
for (col in df) {
str(col)
}
## num [1:3] 1 2 3
## chr [1:3] "A" "B" "C"
之前我们提到,数据框是一个列表,其中每个元素(列)必须具有相同的长度。因此,前面的循环是遍历列而不是行,这与for遍历普通列表时的行为一致。
然而,在许多情况下,我们希望逐行遍历数据框。我们可以使用for循环来实现这一点,但遍历的是从 1 到数据框行数的整数序列。
只要i获取行号,我们就可以从数据框中单独提取那一行并对其进行操作。以下代码逐行遍历数据框并使用str()打印每行的结构:
for (i in 1:nrow(df)) {
row <- df[i,]
cat("row", i, "\n")
str(row)
cat("\n")
}
## row 1
## 'data.frame': 1 obs. of 2 variables:
## $ x: num 1
## $ y: chr "A"
##
## row 2
## 'data.frame': 1 obs. of 2 variables:
## $ x: num 2
## $ y: chr "B"
##
## row 3
## 'data.frame': 1 obs. of 2 variables:
## $ x: num 3
## $ y: chr "C"
我应该在这里提醒一下,逐行遍历数据框通常不是一个好主意,因为它可能很慢且冗长。更好的方法是使用第五章中介绍的apply族函数,或者第十二章中介绍的更强大、更高级的包函数。
在前面的例子中,for循环的每次迭代都是独立的。然而,在某些情况下,迭代会改变循环外的变量以跟踪某些状态或记录累积。最简单的例子是从 1 加到 100 的求和:
s <- 0
for (i in 1:100) {
s <- s + i
}
s
## [1] 5050
前面的例子演示了使用for循环进行累积。下面的例子使用从正态分布rnorm()采样的随机数生成器产生一个简单的随机游走实现:
set.seed(123)
x <- numeric(1000)
for (t in 1:(length(x) - 1)) {
x[[t + 1]] <- x[[t]] + rnorm(1, 0, 0.1)
}
plot(x, type = "s", main = "Random walk", xlab = "t")
生成的图如下所示:

尽管前面两个例子中的for循环对前一个结果有一个一步的依赖关系,但可以使用现有的函数(如sum()方法和cumsum())来简化它们:
sum100 <- sum(1:100)
random_walk <- cumsum(rnorm(1000, 0, 0.1))
这些函数的实现基本思想与前面的for循环类似,但它们是向量化的,并在 C 语言中实现,因此它们比 R 中的for循环要快得多。因此,如果可能的话,你应该首先考虑使用这些内置函数。
管理 for 循环的流程
有时,在for循环中干预是有用的。在每次迭代中,我们可以选择中断for循环,跳过当前迭代,或者什么都不做并完成循环。
我们可以使用break来终止for循环:
for (i in 1:5) {
if (i == 3) break
cat("message ", i, "\n")
}
## message 1
## message 2
这可以用来,例如,找到一个问题的解决方案。以下代码尝试找到满足(i ^ 2) %% 11等于(i ^ 3) %% 17的数字,其中^是幂运算符,%%是取模运算符,它返回除法的余数:
m <- integer()
for (i in 1000:1100) {
if ((i ^ 2) %% 11 == (i ^ 3) %% 17) {
m <- c(m, i)
}
}
m
## [1] 1055 1061 1082 1086 1095
如果你只需要范围内的一个数字来满足条件,你可以用简单的 break 语句替换记录跟踪表达式:
for (i in 1000:1100) {
if ((i ^ 2) %% 11 == (i ^ 3) %% 17) break
}
i
## [1] 1055
一旦找到解决方案,for 循环就会中断,并将 i 的最后一个值保留在当前环境中,这样你就可以知道满足条件的解决方案。
在某些其他情况下,跳过 for 循环中的迭代也是有用的。我们可以使用 next 关键字跳过当前迭代中的其余表达式,并直接跳转到循环的下一个迭代:
for (i in 1:5) {
if (i == 3) next
cat("message ", i, "\n")
}
## message 1
## message 2
## message 4
## message 5
创建嵌套的 for 循环
for 循环中的表达式可以是任何东西,包括另一个 for 循环。例如,如果我们想穷尽向量中所有元素的排列组合,我们可以编写一个两层的嵌套 for 循环来解决问题:
x <- c("a", "b", "c")
combx <- character()
for (c1 in x) {
for (c2 in x) {
combx <- c(combx, paste(c1, c2, sep = ",", collapse = ""))
}
}
combx
## [1] "a,a" "a,b" "a,c" "b,a" "b,b" "b,c" "c,a" "c,b" "c,c"
如果你只需要包含不同元素的排列,你可以在内层 for 循环中添加一个测试条件:
combx2 <- character()
for (c1 in x) {
for (c2 in x) {
if (c1 == c2) next
combx2 <- c(combx2, paste(c1, c2, sep = ",", collapse = ""))
}
}
combx2
## [1] "a,b" "a,c" "b,a" "b,c" "c,a" "c,b"
或者,你也可以简单地否定条件,并将内层 for 循环中的表达式替换为以下代码以获得相同的结果:
if (c1 != c2) {
combx2 <- c(combx2, paste(c1, c2, sep = ",", collapse = ""))
}
上述代码演示了嵌套循环的工作原理,但用这种方法解决问题并不最优。一些内置函数可以帮助生成向量元素的组合或排列。combn() 方法根据原子向量和每个组合中的元素数量生成向量元素的组合矩阵:
combn(c("a", "b", "c"), 2)
## [,1] [,2] [,3]
## [1,] "a" "a" "b"
## [2,] "b" "c" "c"
与使用 for 循环实现的先前列举的例子类似,expand.grid() 生成一个包含多个向量中所有排列的数据框:
expand.grid(n = c(1, 2, 3), x = c("a", "b"))
## n x
## 1 1 a
## 2 2 a
## 3 3 a
## 4 1 b
## 5 2 b
## 6 3 b
虽然for循环可能很强大,但有一些函数是为特定任务设计的。最好考虑使用内置函数,而不是直接将所有内容放入 for 循环中。在下一章中,我将介绍 lapply() 和相关函数来替换许多 for 循环,这使得代码更容易编写和理解。
使用 while 循环
与 for 循环相反,while 循环不会在给定条件违反之前停止运行。
例如,以下 while 循环从 x = 0 开始。每次循环都会检查 x <= 10 是否成立。如果是,则评估内层表达式;否则,while 循环终止:
x <- 0
while (x <= 5) {
cat(x, " ", sep = "")
x <- x + 1
}
## 0 1 2 3 4 5
如果我们移除 x <- x + 1 以使 x 不再增加任何增量,代码将无限运行(直到强制终止 R)。因此,如果实现不当,while 循环有时可能很危险。
与 for 循环一样,流程控制语句(break 和 next)也适用于 while:
x <- 0
while (TRUE) {
x <- x + 1
if (x == 4) break
else if (x == 2) next
else cat(x, '\n')
}
## 1
## 3
在实践中,while 循环通常用于迭代次数未知的情况。这通常发生在我们从数据库查询的结果集中分块获取行时。代码可能看起来如下所示:
res <- dbSendQuery(con, "SELECT * FROM table1 WHERE type = 1")
while (!dbHasCompleted(res)) {
chunk <- dbFetch(res, 10000)
process(chunk)
}
首先,我们通过con连接从数据库中查询类型为 1 的所有记录。一旦数据库返回结果集res,我们就可以分块从结果集中获取数据,每次处理一个数据块。由于在查询之前不知道记录的数量,我们需要使用一个 while 循环,当所有数据完全获取时通过dbHasCompleted()来终止循环。
这样做,我们避免了将(可能很大的)数据框加载到内存中。相反,我们处理小块数据。这使我们能够仅使用内存中的小工作集处理大量数据。然而,主要前提是算法process()必须支持分块处理数据。
你可能不熟悉前面的代码示例或术语,但不用担心。我们将在后面的章节中详细讲解数据库主题。
除了for循环和while循环之外,R 还提供了repeat循环。像while (TRUE)一样,repeat关键字也是一个真正的循环,因为它不需要显式的终止条件或边界,除非遇到break:
x <- 0
repeat {
x <- x + 1
if (x == 4) break
else if (x == 2) next
else cat(x, '\n')
}
## 1
## 3
然而,repeat关键字可能非常危险,并且在实践中并不推荐使用。
摘要
在本章中,你学习了赋值、条件表达式和循环的语法。在赋值部分,你了解了变量的命名规则以及如何避免命名冲突。在条件表达式部分,你学习了如何将if语句用作语句或表达式,以及ifelse()在处理向量时与if的区别。在循环部分,你学习了for循环和while循环的相似之处和不同之处。现在,我们已经拥有了控制 R 程序逻辑流程的基本表达式。
在下一章中,你将使用前面章节中学到的知识,看看你可以用表示数据和表示我们逻辑的基本对象以及基本表达式做什么。你将学习各种类别中的基本函数,作为数据转换和统计分析的构建块。
第五章:处理基本对象
在前面的章节中,你学习了如何创建几种基本类型的对象,包括原子向量、列表和数据框来存储数据。你学习了如何创建函数来存储逻辑。在了解了 R 脚本的这些构建块之后,你学习了不同类型的表达式来控制涉及基本对象逻辑流程。现在,我们正在熟悉 R 编程语言的基本语法和句法。是时候使用内置函数构建 R 的词汇表,以处理基本对象了。
R 的真正力量在于它提供的巨大数量的函数。了解各种基本函数非常有用,这将节省你的时间并提高你的生产力。
虽然 R 主要是一个统计计算环境,但许多基本功能与任何统计无关,而是与更基础的任务相关,例如检查环境、将文本转换为数字以及执行逻辑运算。
在本章中,你将了解 R 中广泛的基本但非常有用的函数,包括:
-
对象函数
-
逻辑函数
-
数学函数
-
数值方法
-
统计函数
-
Apply 族函数
使用对象函数
在前面的章节中,你学习了关于与环境和包一起工作的某些函数。在本节中,我们将了解一些处理对象的基本函数。更具体地说,我将向你介绍更多用于访问数据对象类型和大小的函数。你将了解这些概念如何结合以及它们是如何协同工作的。
测试对象类型
虽然 R 中的所有内容都是一个对象,但对象有不同的类型。
假设我们正在处理的对象是用户定义的。我们将创建一个函数,该函数根据输入对象的类型以不同的方式表现。例如,我们需要创建一个名为take_it的函数,如果输入对象是一个原子向量(例如,数值向量、字符向量或逻辑向量),则返回第一个元素,但如果输入对象是一个包含数据和索引的列表,则返回用户定义的元素。
例如,如果输入是一个数值向量,如c(1, 2, 3),则该函数应返回其第一个元素1。如果输入是一个字符向量,如c("a", "b", "c"),则该函数应返回a。然而,如果输入是一个列表list(data = c("a", "b", "c"), index = 3),则该函数应返回data的第三个元素(索引=3),即c。
要创建这样的函数,我们可以想象其中可能出现的函数和逻辑流程。首先,由于函数的输出取决于输入类型,我们需要使用 is.* 中的一个函数来判断输入是否为特定类型。其次,由于函数的行为因输入类型而异,我们需要使用条件表达式如 if else 来分支逻辑。最后,如果函数基本上是从输入中提取一个元素,我们需要使用元素提取运算符。现在,函数的实现变得相当清晰:
take_it <- function(x) {
if (is.atomic(x)) {
x[[1]]
} else if (is.list(x)) {
x$data[[x$index]]
} else {
stop("Not supported input type")
}
}
上述函数的行为因 x 的不同类型而异。当 x 是一个原子向量(例如,一个数值向量)时,函数提取其第一个元素。当 x 是 data 和 index 的列表时,函数从 x$data 中提取 index 的元素:
take_it(c(1, 2, 3))
## [1] 1
take_it(list(data = c("a", "b", "c"), index = 3))
## [1] "c"
对于不支持输入类型,函数应该停止并显示错误消息,而不是返回任何值。例如,take_it 无法处理 function 输入。请注意,我们可以将任何函数作为参数传递给其他函数,就像传递任何其他对象一样。然而,在这种情况下,如果将 mean 作为函数传递给它,那么它将变成 else 条件并停止:
take_it(mean)
## Error in take_it(mean): Not supported input type
如果输入确实是一个列表但不包含任何预期的元素,data 和 index,会怎样呢?只需对 input(而不是 data)的列表进行实验,没有任何 index 元素:
take_it(list(input = c("a", "b", "c")))
## NULL
可能会让你感到惊讶的是,函数没有产生错误。输出是 NULL,因为 x$data 是 NULL,并且从 NULL 中提取任何值也是 NULL:
NULL[[1]]
## NULL
NULL[[NULL]]
## NULL
然而,如果列表只包含 data 但缺少 index,函数最终将导致错误:
take_it(list(data = c("a", "b", "c")))
## Error in x$data[[x$index]]: attempt to select less than one element
错误发生是因为 x$index 结果为 NULL,并且通过 NULL 提取向量中的值会产生错误:
c("a", "b", "c")[[NULL]]
## Error in c("a", "b", "c")[[NULL]]: attempt to select less than one element
第三种可能性与第一种情况有点相似,其中 NULL[[2]] 返回 NULL:
take_it(list(index = 2))
## NULL
从早期的异常中,如果你不太熟悉涉及 NULL 的这些边缘情况,通常你会看到错误消息不是很具有信息性。对于更复杂的情况,如果确实发生了这些错误,你可能无法在短时间内找出确切的错误原因。一个很好的解决方案是在函数的实现中自己检查输入,并将所做的假设反映到参数上。
为了处理上述误用的情况,以下实现考虑了每个参数的类型是否所需:
take_it2 <- function(x) {
if (is.atomic(x)) {
x[[1]]
} else if (is.list(x)) {
if (!is.null(x$data) && is.atomic(x$data)) {
if (is.numeric(x$index) && length(x) == 1) {
x$data[[x$index]]
} else {
stop("Invalid index")
}
} else {
stop("Invalid data")
}
} else {
stop("Not supported input type")
}
}
对于 x 是列表的情况,我们检查 x$data 是否不为空且是一个原子向量。如果是这样,那么我们检查 x$index 是否被正确指定为一个单元素数值向量或一个标量。如果任何条件被违反,函数将停止并显示一个有信息的错误消息,告诉用户输入有什么问题。
内置检查函数也有一些古怪的行为。例如,is.atomic(NULL) 返回 TRUE。因此,如果列表 x 不包含名为 data 的元素,if (is.atomic(x$data)) 的正分支仍然会被触发,这也会导致 NULL。通过一些参数检查,现在的代码更加健壮,当假设被违反时可以产生更详细的错误信息:
take_it2(list(data = c("a", "b", "c")))
## Error in take_it2(list(data = c("a", "b", "c"))): Invalid index
take_it2(list(index = 2))
## Error in take_it2(list(index = 2)): Invalid data
此函数的另一种可能的实现是使用 S3 分发,这将在后面的面向对象编程章节中讲解。
访问对象类和类型
除了使用 is.* 函数外,我们还可以使用 class() 或 typeof() 来实现此功能。在直接访问对象的类型之前,了解这两个函数之间的区别是有用的。
以下示例展示了当 class() 和 typeof() 被调用在不同类型的对象上时,它们输出的区别。
对于每个对象 x,会调用 class() 和 typeof(),然后调用 str() 来显示其结构。
对于数值向量:
x <- c(1, 2, 3)
class(x)
## [1] "numeric"
typeof(x)
## [1] "double"
str(x)
## num [1:3] 1 2 3
对于整数向量:
x <- 1:3
class(x)
## [1] "integer"
typeof(x)
## [1] "integer"
str(x)
## int [1:3] 1 2 3
对于字符向量:
x <- c("a", "b", "c")
class(x)
## [1] "character"
typeof(x)
## [1] "character"
str(x)
## chr [1:3] "a" "b" "c"
对于列表:
x <- list(a = c(1, 2), b = c(TRUE, FALSE))
class(x)
## [1] "list"
typeof(x)
## [1] "list"
str(x)
## List of 2
## $ a: num [1:2] 1 2
## $ b: logi [1:2] TRUE FALSE
对于数据框:
x <- data.frame(a = c(1, 2), b = c(TRUE, FALSE))
class(x)
## [1] "data.frame"
typeof(x)
## [1] "list"
str(x)
## 'data.frame': 2 obs. of 2 variables:
## $ a: num 1 2
## $ b: logi TRUE FALSE
我们可以看到,typeof() 返回对象的低级内部类型,而 class() 返回对象的高级类别。我们之前提到的一个对比是,data.frame 本质上是一个具有等长列表元素的 list。因此,数据框具有 data.frame 类别,以便相关函数可以识别,但 typeof() 仍然从内部告知它是一个 list。
这个主题与 S3 面向对象编程机制相关,将在后面的章节中详细讲解。然而,在这里提及 class() 和 typeof() 之间的区别仍然是有用的。
从前面的输出中,也可以清楚地看到 str(),我们在上一章中介绍过,显示了对象的结构。对于对象中的向量,它通常显示它们的内部类型(typeof())。
访问数据维度
矩阵、数组和数据框除了具有类和类型外,还具有维度的属性。
获取数据维度
在 R 中,向量是按构造为一维数据结构:
vec <- c(1, 2, 3, 2, 3, 4, 3, 4, 5, 4, 5, 6)
class(vec)
## [1] "numeric"
typeof(vec)
## [1] "double"
相同的底层数据可以用更多的维度来表示,这些维度可以通过 dim()、nrow() 或 ncol() 访问:
sample_matrix <- matrix(vec, ncol = 4)
sample_matrix
## [,1] [,2] [,3] [,4]
## [1,] 1 2 3 4
## [2,] 2 3 4 5
## [3,] 3 4 5 6
class(sample_matrix)
## [1] "matrix"
typeof(sample_matrix)
## [1] "double"
dim(sample_matrix)
## [1] 3 4
nrow(sample_matrix)
## [1] 3
ncol(sample_matrix)
## [1] 4
第一个前置表达式从数值向量 vec 创建了一个四列矩阵。该矩阵具有 matrix 类别,而 typoef() 保留了 vec 中的 double。由于矩阵是一个维度的数据结构,dim() 以向量形式显示其维度。nrow() 和 ncol() 函数是访问其行数和列数的快捷方式。如果你阅读这两个快捷方式的源代码,你会发现它们并没有什么特别之处,但它们分别返回相同输入的 dim() 的第一和第二个元素。
高维数据通常用数组表示。例如,相同的 vec 数据也可以在三个维度上表示,即要访问一个元素,需要依次指定三个维度中的位置:
sample_array <- array(vec, dim = c(2, 3, 2))
sample_array
## , , 1
##
## [,1] [,2] [,3]
## [1,] 1 3 3
## [2,] 2 2 4
##
## , , 2
##
## [,1] [,2] [,3]
## [1,] 3 5 5
## [2,] 4 4 6
class(sample_array)
## [1] "array"
typeof(sample_array)
## [1] "double"
dim(sample_array)
## [1] 2 3 2
nrow(sample_array)
## [1] 2
ncol(sample_array)
## [1] 3
与 matrix 类似,数组有一个 array 类,但仍然保留了底层数据的类型。dim() 的输出长度是表示数据所需的维度数。
另一个具有维度概念的数据结构是数据框。然而,数据框与矩阵在本质上是有区别的。矩阵是从向量派生出来的,但增加了维度属性。另一方面,数据框是从列表派生出来的,但增加了每个列表元素必须具有相同长度的约束:
sample_data_frame <- data.frame(a = c(1, 2, 3), b = c(2, 3, 4))
class(sample_data_frame)
## [1] "data.frame"
typeof(sample_data_frame)
## [1] "list"
dim(sample_data_frame)
## [1] 3 2
nrow(sample_data_frame)
## [1] 3
ncol(sample_data_frame)
## [1] 2
然而,dim()、nrow() 和 ncol() 对于数据框仍然很有用。
重新塑形数据结构
dim(x) <- y 的语法表示将 x 的维度值更改为 y。
对于一个普通向量,该表达式将向量转换为具有指定维度的矩阵:
sample_data <- vec
dim(sample_data) <- c(3, 4)
sample_data
## [,1] [,2] [,3] [,4]
## [1,] 1 2 3 4
## [2,] 2 3 4 5
## [3,] 3 4 5 6
class(sample_data)
## [1] "matrix"
typeof(sample_data)
## [1] "double"
你可以看到,对象的类从 numeric 变为 matrix,而对象的类型保持不变。
对于矩阵,该表达式会重新塑形矩阵:
dim(sample_data) <- c(4, 3)
sample_data
## [,1] [,2] [,3]
## [1,] 1 3 5
## [2,] 2 4 4
## [3,] 3 3 5
## [4,] 2 4 6
有助于理解的是,改变向量、矩阵或数组的维度只会改变对象的表示和访问方法,而不会改变存储在内存中的底层数据。因此,矩阵被重塑为以下数组的情况并不令人惊讶:
dim(sample_data) <- c(3, 2, 2)
sample_data
## , , 1
##
## [,1] [,2]
## [1,] 1 2
## [2,] 2 3
## [3,] 3 4
##
## , , 2
##
## [,1] [,2]
## [1,] 3 4
## [2,] 4 5
## [3,] 5 6
class(sample_data)
## [1] "array"
显然,dim(x) <- y 仅在 prod(y) 等于 length(x) 时才有效,也就是说,所有维度的乘积必须等于数据元素的长度。否则,将发生错误:
dim(sample_data) <- c(2, 3, 4)
## Error in dim(sample_data) <- c(2, 3, 4): dims [product 24] do not match the length of object [12]
遍历一个维度
数据框通常是一组记录的集合,每一行代表一条记录。遍历数据框中存储的所有记录是很常见的。让我们看看以下数据框:
sample_data_frame
## a b
## 1 1 2
## 2 2 3
## 3 3 4
对于这个数据框,我们可以通过使用 for 循环遍历 1:nrow(x) 来打印变量的值:
for (i in 1:nrow(sample_data_frame)) {
# sample text:
# row #1, a: 1, b: 2
cat("row #", i, ", ",
"a: ", sample_data_frame[i, "a"],
", b: ", sample_data_frame[i, "b"],
"\n", sep = "")
}
## row #1, a: 1, b: 2
## row #2, a: 2, b: 3
## row #3, a: 3, b: 4
使用逻辑函数
逻辑向量只接受 TRUE 或 FALSE,主要用于过滤数据。在实践中,通常通过多个逻辑向量创建联合条件,其中可能涉及多个逻辑运算符和函数。
逻辑运算符
与许多其他编程语言一样,R 允许使用一些运算符进行基本的逻辑计算。以下表格展示了它们的功能:
| 符号 | 描述 | 示例 | 结果 |
|---|---|---|---|
& |
向量化 AND | c(T, T) & c(T, F) |
c(TRUE, FALSE) |
| ` | ` | 向量化 OR | `c(T, T) |
&& |
单变量 AND | c(T, T) && c(F, T) |
FALSE |
| ` | ` | 单变量 OR | |
! |
向量化 NOT | !c(T, F) |
c(FALSE, TRUE) |
%in% |
向量化 IN | c(1, 2) %in% c(1, 3, 4, 5) |
c(TRUE, FALSE) |
注意,在if表达式中,&&和||通常用于执行仅需要产生单个元素逻辑向量的逻辑计算。然而,使用&&的潜在风险是,如果它被用来与多元素向量一起工作,它将默默地忽略两侧向量中除了第一个元素之外的所有元素。以下示例演示了在条件语句中使用&&或&时的行为差异。
以下代码创建了一个test_direction函数,用于判断提供的参数值的单调性。我们将在下一节中基于此示例进行构建。如果x、y和z的值单调递增,则函数返回1;如果它们单调递减,则函数返回-1。否则,它返回0。请注意,该函数使用&执行向量化的 AND 操作:
test_direction <- function(x, y, z) {
if (x < y & y < z) 1
else if (x > y & y > z) -1
else 0
}
如果提供的参数是标量数字,该函数将完美运行:
test_direction(1, 2, 3)
## [1] 1
注意,&执行向量化计算,因此如果其中一个参数有多个元素,则返回多元素向量。然而,if只与单个值的逻辑向量一起工作;否则,它会产生警告:
test_direction(c(1, 2), c(2, 3), c(3, 4))
## Warning in if (x < y & y < z) 1 else if (x > y & y > z)
## -1 else 0: the condition has length > 1 and only the first
## element will be used
## [1] 1
如果我们将test_direction2中出现的两个&运算符都替换为&&并创建一个新的函数test_direction2,该函数将如下所示:
test_direction2 <- function(x, y, z) {
if (x < y && y < z) 1
else if (x > y && y > z) -1
else 0
}
然后,这两个示例测试用例可能会有不同的行为。对于标量输入,两个版本的行为完全相同:
test_direction2(1, 2, 3)
## [1] 1
然而,对于多元素输入,test_direction2会默默地忽略每个输入向量的第二个元素,因此不会产生任何警告:
test_direction2(c(1, 2), c(2, 3), c(3, 4))
## [1] 1
最后,哪种用法是正确的,&还是&&?这完全取决于你的需求。你期望在所有情况下有什么行为?如果输入是标量值或多元素向量,你期望什么?如果你期望函数告诉你每个输入向量中相同位置的元素是否具有单调性,那么这两种用法在部分情况下都是不正确的,需要使用将在下一节中介绍的逻辑聚合函数。
逻辑函数
在本节中,我们将查看聚合逻辑向量和查找真元素。
聚合逻辑向量
此外,除了二元逻辑运算符之外,还有一些逻辑聚合函数非常有用,正如我们之前提到的。
最常用的两个逻辑聚合函数是any()和all()。any()函数如果给定逻辑向量中的任何(例如,至少一个)元素为TRUE,则返回TRUE;否则,它将返回FALSE。all()函数如果给定逻辑向量中的所有元素都是TRUE,则返回TRUE;否则,它将返回FALSE:
x <- c(-2, -3, 2, 3, 1, 0, 0, 1, 2)
any(x > 1)
## [1] TRUE
all(x <= 1)
## [1] FALSE
这两个函数的一个共同点是,它们只返回一个TRUE或FALSE值,永远不会返回多元素逻辑向量。因此,为了实现上一节中所需的功能,请在if条件中使用all()和&一起:
test_all_direction <- function(x, y, z) {
if (all(x < y & y < z)) 1
else if (all(x > y & y > z)) -1
else 0
}
对于标量输入,test_all_direction() 与 test_direction() 和 test_direction2() 函数的行为完全相同:
test_all_direction(1, 2, 3)
## [1] 1
对于向量输入,该函数测试 c(1, 2, 3) 和 c(2, 3, 4) 是否具有(相同的)单调性:
test_all_direction(c(1, 2), c(2, 3), c(3, 4))
## [1] 1
以下代码是一个反例,其中位置 2 的元素,即 c(2, 4, 4),没有单调性:
test_all_direction(c(1, 2), c(2, 4), c(3, 4))
## [1] 0
函数返回的值因此是有意义的,因为它正确实现了测试三个输入向量中每个位置的所有元素是否具有单调性的需求。
该函数有几种可能的变体,它们使用 any() 或 &&。你可以尝试弄清楚每个以下版本的潜在需求(这些函数试图做什么?):
test_any_direction <- function(x, y, z) {
if (any(x < y & y < z)) 1
else if (any(x > y & y > z)) -1
else 0
}
test_all_direction2 <- function(x, y, z) {
if (all(x < y) && all(y < z)) 1
else if (all(x > y) && all(y > z)) -1
else 0
}
test_any_direction2 <- function(x, y, z) {
if (any(x < y) && any(y < z)) 1
else if (any(x > y) && any(y > z)) -1
else 0
}
查询哪些元素是 TRUE
我们之前介绍的逻辑运算通常返回一个逻辑向量,以指示某个条件是否为 TRUE 或 FALSE。了解哪些元素满足这些条件也是有用的。which() 函数返回逻辑向量中 TRUE 元素的位置(或索引):
x
## [1] -2 -3 2 3 1 0 0 1 2
abs(x) >= 1.5
## [1] TRUE TRUE TRUE TRUE FALSE FALSE FALSE FALSE TRUE
which(abs(x) >= 1.5)
## [1] 1 2 3 4 9
如果我们仔细观察发生了什么,应该很清楚,首先,abs(x) >= 1.5 被评估为一个逻辑向量,然后,which() 返回该逻辑向量中 TRUE 元素的位置。
当我们使用逻辑条件从向量或列表中过滤元素时,机制相当类似:
x[x >= 1.5]
## [1] 2 3 2
在前面的例子中,x >= 1.5 被评估为一个逻辑向量。然后,它被用来选择 x 中对应于 TRUE 值的元素。
一个特殊情况是,我们甚至可以使用所有值为 FALSE 的逻辑向量。返回一个零长度的数值向量,因为逻辑向量只包含 FALSE 值,因此 x 中没有元素被单独选中:
x[x >= 100]
## numeric(0)
处理缺失值
实际数据通常包含用 NA 表示的缺失值。以下是一个简单的数值向量示例:
x <- c(-2, -3, NA, 2, 3, 1, NA, 0, 1, NA, 2)
使用缺失值的算术计算也会产生缺失值:
x + 2
## [1] 0 -1 NA 4 5 3 NA 2 3 NA 4
为了考虑这一点,逻辑向量必须接受不仅 TRUE 和 FALSE 值,还要接受 NA 值来表示未知真实性:
x > 2
## [1] FALSE FALSE NA FALSE TRUE FALSE NA FALSE FALSE
## [10] NA FALSE
因此,像 any() 和 all() 这样的逻辑聚合函数也必须处理缺失值:
x
## [1] -2 -3 NA 2 3 1 NA 0 1 NA 2
any(x > 2)
## [1] TRUE
any(x < -2)
## [1] TRUE
any(x < -3)
## [1] NA
前面的输出展示了 any() 在处理包含缺失值的逻辑向量时的默认行为。更具体地说,如果输入向量中的任何元素是 TRUE,则该函数将返回 TRUE。如果输入向量中没有 TRUE 元素且存在任何缺失值,则该函数将返回 NA。否则,如果输入向量只包含 FALSE,则该函数将返回 FALSE。要验证前面的逻辑,只需运行以下代码:
any(c(TRUE, FALSE, NA))
## [1] TRUE
any(c(FALSE, FALSE, NA))
## [1] NA
any(c(FALSE, FALSE))
## [1] FALSE
要直接忽略所有缺失值,只需在调用中指定 na.rm = TRUE:
any(x < -3, na.rm = TRUE)
## [1] FALSE
对于 all(),类似的但某种程度上相反的逻辑适用:
x
## [1] -2 -3 NA 2 3 1 NA 0 1 NA 2
all(x > -3)
## [1] FALSE
all(x >= -3)
## [1] NA
all(x < 4)
## [1] NA
如果输入向量中的任何元素为FALSE,则该函数将返回FALSE。如果输入向量中没有FALSE元素,但存在任何缺失值,则该函数将返回NA。否则,如果输入向量只包含TRUE,则它将返回TRUE。为了验证逻辑,只需运行以下代码:
all(c(TRUE, FALSE, NA))
## [1] FALSE
all(c(TRUE, TRUE, NA))
## [1] NA
all(c(TRUE, TRUE))
## [1] TRUE
类似地,na.rm = TRUE强制函数直接忽略所有缺失值:
all(x >= -3, na.rm = TRUE)
## [1] TRUE
除了逻辑聚合函数外,当涉及缺失值时,数据过滤的行为也会有所不同。例如,以下代码将保留由x >= 0产生的逻辑向量中相应位置的缺失值:
x
## [1] -2 -3 NA 2 3 1 NA 0 1 NA 2
x[x >= 0]
## [1] NA 2 3 1 NA 0 1 NA 2
相比之下,which()不会保留输入逻辑向量中存在的缺失值:
which(x >= 0)
## [1] 4 5 6 8 9 11
因此,在以下情况下,通过索引子集的向量不包含缺失值:
x[which(x >= 0)]
## [1] 2 3 1 0 1 2
逻辑强制转换
一些应该接受逻辑输入的函数也接受非逻辑向量,例如数值向量。然而,函数的行为可能与它们与逻辑向量处理的行为不同。这是因为非逻辑向量被强制转换为逻辑值。
例如,如果我们把一个数值向量放入if条件中,它将被强制转换:
if (2) 3
## [1] 3
if (0) 0 else 1
## [1] 1
在 R 中,数值向量或整数向量中的所有非零值都可以被强制转换为TRUE,只有零值会被强制转换为FALSE,字符串值不能被强制转换为逻辑值:
if ("a") 1 else 2
## Error in if ("a") 1 else 2: argument is not interpretable as logical
使用数学函数
数学函数是所有计算环境中的基本组成部分。R 提供了几组基本数学函数。
基本函数
基本函数包括平方根、指数和对数函数,如下表所示:

注意,sqrt()只适用于实数。如果提供负数,将产生NaN:
sqrt(-1)
## Warning in sqrt(-1): NaNs produced
## [1] NaN
在 R 中,数值可以是有限的、无限的(Inf和-Inf)或NaN值。以下代码将产生无限值。
首先,产生一个正无穷值:
1 / 0
## [1] Inf
然后,产生一个负无穷值:
log(0)
## [1] -Inf
有几个测试函数可以检查一个数值是否有限、无限或NaN:
is.finite(1 / 0)
## [1] FALSE
is.infinite(log(0))
## [1] TRUE
使用is.infinite(),我们如何检查一个数值是否为-Inf?在 R 中,不等式仍然适用于无限值:
1 / 0 < 0
## [1] FALSE
1 / 0 > 0
## [1] TRUE
log(0) < 0
## [1] TRUE
log(0) > 0
## [1] FALSE
因此,我们可以使用is.infinite()测试数字,并同时比较元素与 0:
is.pos.infinite <- function(x) {
is.infinite(x) & x > 0
}
is.neg.infinite <- function(x) {
is.infinite(x) & x < 0
}
is.pos.infinite(1/0)
## [1] TRUE
is.neg.infinite(log(0))
## [1] TRUE
与sqrt()类似,如果输入值超出了log函数的定义域,即x > 0,则该函数将返回带有警告的NaN:
log(-1)
## Warning in log(-1): NaNs produced
## [1] NaN
数字舍入函数
以下函数用于以不同方式舍入数字:
| 符号 | 示例 | 值 |
|---|---|---|
| [x] log | ceiling(10.6) |
11 |
| [x] log | floor(9.5) |
9 |
| truncate | trunc(1.5) |
1 |
| round | round(pi,3) |
3.142 |
| 有效数字 | signif(pi, 3) |
3.14 |
之前,我们展示了使用 options(digits =) 可以修改显示的数字位数,但这不会改变实际要记住的数字位数。前面的函数会四舍五入数字,可能会造成潜在的信息丢失。
例如,如果输入数字 1.50021 已经很精确,那么将其四舍五入到 1 位将得到 1.5,而其他数字(信息)则丢失。因此,在执行任何四舍五入之前,你应该确保要丢弃的数字确实因为不精确或噪声而可以忽略。
三角函数
以下表格列出了最常用的三角函数:
| 符号 | 示例 | 值 |
|---|---|---|
sin (x) |
sin(0) |
0 |
cos (x) |
cos(0) |
1 |
tan (x) |
tan(0) |
0 |
arcsin (x) |
asin(1) |
1.5707963 |
arco (x) |
acos(1) |
0 |
arctan (x) |
atan(1) |
0.7853982 |
R 还提供了 π 的数值版本:
pi
## [1] 3.141593
在数学中,方程 sin (π) = 0 严格成立。然而,由于浮点数的某些精度问题,相同的公式在 R 或任何其他典型的数值计算软件中不会导致 0:
sin(pi)
## [1] 1.224606e-16
要比较近似相等的数字,请使用 all.equal()。虽然 sin(pi) == 0 返回 FALSE,但 all.equal(sin(pi), 0) 在默认容差 1.5e-8 下返回 TRUE。
提供了另外三个函数,以便在输入是 π 的倍数时进行精确计算:
| 符号 | 示例 | 值 |
|---|---|---|
sin (πx) |
sinpi(1) |
0 |
cos (πx) |
cospi(0) |
1 |
tan (πx) |
tanpi(1) |
0 |
双曲函数
与其他计算软件类似,双曲函数提供如下表所示:
| 符号 | 示例 | 值 |
|---|---|---|
sinh (x) |
sinh(1) |
1.1752012 |
cosh (x) |
cosh(1) |
1.5430806 |
tanh (x) |
tanh(1) |
0.7615942 |
arcsinh (x) |
asinh(1) |
0.8813736 |
arccosh (x) |
acosh(1) |
0 |
arctanh (x) |
atanh(0) |
0 |
极端函数
计算某些数字的最大值或最小值是很常见的。以下表格列出了 max() 和 min() 的简单用法:
| 符号 | 示例 | 值 |
|---|---|---|
max(...) |
max(1, 2, 3) |
3 |
min(...) |
min(1, 2, 3) |
1 |
这两个函数不仅支持多个标量参数,还支持向量输入:
max(c(1, 2, 3))
## [1] 3
此外,它们还支持多个向量输入:
max(c(1, 2, 3),
c(2, 1, 2),
c(1, 3, 4))
## [1] 4
min(c(1, 2, 3),
c(2, 1, 2),
c(1, 3, 4))
## [1] 1
输出表明 max() 返回所有输入向量的所有值中的最大值,而 min() 返回相反的值。
如果我们想要获得所有向量中每个位置的最大值或最小值呢?看看下面的代码行:
pmax(c(1, 2, 3),
c(2, 1, 2),
c(1, 3, 4))
## [1] 2 3 4
这基本上是在位置 1 的所有数字中找到最大值,然后在位置 2,依此类推,其输出与以下代码相同:
x <- list(c(1, 2, 3),
c(2, 1, 2),
c(1, 3, 4))
c(max(x[[1]][[1]], x[[2]][[1]], x[[3]][[1]]),
max(x[[1]][[2]], x[[2]][[2]], x[[3]][[2]]),
max(x[[1]][[3]], x[[2]][[3]], x[[3]][[3]]))
## [1] 2 3 4
这被称为并行最大值。双胞胎函数 pmin() 用于找到并行最小值:
pmin(c(1, 2, 3),
c(2, 1, 2),
c(1, 3, 4))
## [1] 1 1 2
这两个函数可以非常有用,可以快速组合具有特定函数(如 floor 和/或 ceiling)的向量化函数。例如,假设 spread() 是一个分段函数。如果输入小于 -5,则值为 -5。如果输入在 -5 到 5 之间,则值为输入。如果输入大于 5,则值为 5。
一种简单的实现是使用 if 来分支片段:
spread <- function(x) {
if (x < -5) -5
else if (x > 5) 5
else x
}
该函数与标量输入一起工作,但它不会自动向量化:
spread(1)
## [1] 1
spread(seq(-8, 8))
## Warning in if (x < -5) -5 else if (x > 5) 5 else x: the
## condition has length > 1 and only the first element will be
## used
## [1] -5
一种方法是使用 pmin() 和 pmax(),函数将自动向量化:
spread2 <- function(x) {
pmin(5, pmax(-5, x))
}
spread2(seq(-8, 8))
## [1] -5 -5 -5 -5 -4 -3 -2 -1 0 1 2 3 4 5 5 5 5
另一种方法是使用 ifelse():
spread3 <- function(x) {
ifelse(x < -5, -5, ifelse(x > 5, 5, x))
}
spread3(seq(-8, 8))
## [1] -5 -5 -5 -5 -4 -3 -2 -1 0 1 2 3 4 5 5 5 5
前两个函数 spread2() 和 spread3() 都有相同的图形:

应用数值方法
在前面的章节中,你学习了关于从检查数据结构到数学和逻辑运算的许多函数。这些函数对于解决诸如根查找和微积分等问题是基本的。作为一个计算环境,R 已经实现了各种高性能工具,以便用户不必重新发明轮子。在接下来的章节中,你将学习为根查找和微积分设计的内置函数。
根查找
根查找是一个常见任务。假设我们想找到以下方程的根:
x2 + x - 2= 0
要手动查找根,我们可以将前面的方程转换为乘积形式:
(x+2)(x-1)= 0
因此,方程的根是 x1= -2 和 x[2]= 1。
在 R 中,polyroot() 可以找到以下形式的多项式方程的根:

对于前面的问题,我们需要指定从零次项到方程中出现的最高次项的系数向量。在这种情况下,向量是 c(-2, 1, 1),以表示按幂次增加的系数:
polyroot(c(-2, 1, 1))
## [1] 1-0i -2+0i
该函数总是返回一个复数向量,其中每个元素都是形式为 a + bi 的复数。一方面,如果函数确实只有实根,你可以使用 Re() 提取复根的实部:
Re(polyroot(c(-2, 1, 1)))
## [1] 1 -2
另一方面,输出类型表明 polyroot() 有能力找到多项式方程的复根。最简单的一个如下:

要找到其复根,只需指定一个多项式系数向量:
polyroot(c(1, 0, 1))
## [1] 0+1i 0-1i
一个稍微复杂一点的例子是找到以下方程的根:

r <- polyroot(c(-1, -2, -1, 1))
r
## [1] -0.5739495+0.3689894i -0.5739495-0.3689894i
## [3] 2.1478990-0.0000000i
注意,所有复根都被找到了。为了验证,只需将 x 替换为 r:
r ^ 3 - r ^ 2 - 2 * r - 1
## [1] 8.881784e-16+1.110223e-16i 8.881784e-16+2.220446e-16i
## [3] 8.881784e-16-4.188101e-16i
由于一些数值计算问题,前面的表达式并没有严格趋近于零,但它非常接近。如果你只关心 8 位误差,使用 round() 函数,你会发现根是有效的:
round(r ^ 3 - r ^ 2 - 2 * r - 1, 8)
## [1] 0+0i 0+0i 0+0i
对于方程f(x)=0的一般数值根查找,uniroot()函数,正如其名所示,可以用来找到一个根。一个简单的例子是找到以下方程的根:

在以下范围内:

.
生成的图示如下:
在以下范围内:
函数的曲线显示根位于[-1.0,0.5]。使用带有函数和区间的uniroot()将返回一个包含近似根、该点的函数值、迭代次数和根的估计精度的列表:
uniroot(function(x) x ^ 2 - exp(x), c(-2, 1))
## $root
## [1] -0.7034583
##
## $f.root
## [1] -1.738305e-05
##
## $iter
## [1] 6
##
## $init.it
## [1] NA
##
## $estim.prec
## [1] 6.103516e-05
一个更复杂的例子是找到以下方程的根:



.
生成的图示如下:

很明显,该方程在-2到2之间有两个根。然而,uniroot()一次只能找到一个根,并且最好是在搜索区间内函数是单调的。如果我们直接让它在这个[-2,2]区间内找到一个根,函数会产生错误:
f <- function(x) exp(x) - 3 * exp(-x ^ 2 + x) + 1
uniroot(f, c(-2, 2))
## Error in uniroot(f, c(-2, 2)): f() values at end points not of opposite sign
我们必须确保区间两端的函数值具有相反的符号。我们可以将区间分成两个更小的区间,并分别找到根:
uniroot(f, c(-2, 0))$root
## [1] -0.4180424
uniroot(f, c(0, 2))$root
## [1] 0.8643009
一个更复杂的方程如下:

在以下范围内:

.
生成的图示如下:

曲线显示该方程还有更多的根。以下代码只在[0,1]区间找到了一个:
uniroot(function(x) x ^ 2 - 2 * x + 4 * cos(x ^ 2) - 3, c(0, 1))$root
## [1] 0.5593558
在一些先前的根查找函数调用中,我们直接将一个函数传递给uniroot()而没有给函数命名。它们被称为匿名函数。我们将在后面的章节中详细讨论这个概念。
微积分
除了根查找之外,R 的基础数值方法还包括计算基本微积分。
导数
D()函数可以针对给定的变量符号地计算函数的导数。
例如,求导 dx²/dx:
D(quote (x ^ 2), "x")
## 2 * x
求导 dsin(x)cos(xy)/dx:
D(quote(sin(x) * cos(x * y)), "x")
## cos(x) * cos(x * y) - sin(x) * (sin(x * y) * y)
多亏了quote()函数,它保持表达式未评估,因此符号可以直接以它们书写的方式直接访问。
由于导数也是一个未评估的表达式,我们可以通过调用eval()来评估它,给定所有必要的符号:
z <- D(quote(sin(x) * cos(x * y)), "x")
z
## cos(x) * cos(x * y) - sin(x) * (sin(x * y) * y)
eval(z, list(x = 1, y = 2))
## [1] -1.75514
在前面的例子中,quote()创建了一个表达式对象,而eval()使用指定的符号评估一个给定的表达式。表达式对象赋予了 R 元编程的能力。我们将在第九章元编程中讨论这个主题。
积分
R 还支持数值积分。在这里,我们不需要编写表达式,只需提供一个函数即可,因为这不是符号计算。例如,以下公式是一个定积分问题。它基本上计算了从 0 到 pi/2 的正弦曲线下的面积。R 提供了内置函数 integrate(),可以灵活地解决这类问题,只要数学函数可以用 R 函数表示:

result <- integrate(function(x) sin(x), 0, pi / 2)
result
## 1 with absolute error < 1.1e-14
结果看起来像是一个数值,但它似乎还包含了一些其他信息。实际上,它是一个列表:
str(result)
## List of 5
## $ value : num 1
## $ abs.error : num 1.11e-14
## $ subdivisions: int 1
## $ message : chr "OK"
## $ call : language integrate(f = function(x) sin(x), lower = 0, upper = pi/2)
## - attr(*, "class")= chr "integrate"
由于这是一个数值计算,它继承了此类计算技术的所有优点和缺点。
使用统计函数
R 在进行统计计算和建模方面非常高效,因为它提供了从随机抽样到统计测试的丰富函数。同一类别的函数具有共同的接口。在本节中,我将演示一些示例,以便您可以推断出其他类似函数的用法。
从向量中抽样
在统计学中,对总体进行研究通常从对其的随机样本开始。sample() 函数旨在从给定的向量或列表中抽取随机样本。默认情况下,sample() 进行无替换抽样。例如,以下代码从数值向量中抽取五个样本,不进行替换:
sample(1:6, size = 5)
## [1] 2 6 3 1 4
使用 replace = TRUE,抽样是带替换进行的:
sample(1:6, size = 5, replace = TRUE)
## [1] 3 5 3 4 2
虽然 sample() 函数通常用于从数值向量中抽取样本,但它也适用于其他类型的向量:
sample(letters, size = 3)
## [1] "q" "w" "g"
它甚至可以与列表一起使用:
sample(list(a = 1, b = c(2, 3), c = c(3, 4, 5)), size = 2)
## $b
## [1] 2 3
##
## $c
## [1] 3 4 5
实际上,sample() 能够从任何支持使用方括号 ([]) 进行子集操作的任何对象中进行抽样。此外,它支持加权抽样,即您可以指定每个元素的概率:
grades <- sample(c("A", "B", "C"), size = 20, replace = TRUE,
prob = c(0.25, 0.5, 0.25))
grades
## [1] "C" "B" "B" "B" "C" "C" "C" "C" "C" "B" "B" "A" "A" "C"
## [15] "B" "B" "A" "B" "A" "C"
我们可以使用 table() 来查看每个值的出现次数:
table(grades)
## grades
## A B C
## 4 8 8
处理随机分布
在数值模拟中,我们通常需要从随机分布中抽取样本,而不是从给定的向量中抽取。R 提供了丰富的内置函数来处理流行的概率分布。在本节中,我们将看到 R 如何提供基本的统计工具来处理代表样本数据的 R 对象。这些工具主要用于处理数值向量。
在 R 中,生成遵循统计分布的随机数非常容易。最常用的两种分布是均匀分布和正态分布。
在统计学上,从给定范围内的均匀分布中抽取任何值是等可能的。我们可以调用 runif(n) 来生成来自 [0,1] 范围内的均匀分布的 n 个随机数:
runif(5)
## [1] 0.8894535 0.1804072 0.6293909 0.9895641 0.1302889
要在非默认区间内生成随机数,请指定 min 和 max:
runif(5, min = -1, max = 1)
## [1] -0.3386789 0.7302411 0.5551689 0.6546069 0.2066487
如果我们使用 runif(1000) 生成 1000 个随机数并绘制点,我们将得到一个散点图(用于显示 X-Y 点的图)如下:

直方图显示,我们生成的随机数在从 0 到 1 的每个区间内分布几乎均匀,这与均匀分布一致。
在现实世界中最常见的另一个分布是正态分布。类似于runif(),我们可以使用rnorm()来生成遵循标准正态分布的随机数:
rnorm(5)
## [1] 0.7857579 1.1820321 -0.9558760 -1.0316165 0.4336838
您可能会注意到随机生成器函数具有相同的接口。runif()和rnorm()的第一个参数都是n,表示要生成的值的数量,其余参数是随机分布本身的参数。对于正态分布,其参数是均值和标准差(sd):
rnorm(5, mean = 2, sd = 0.5)
## [1] 1.597106 1.971534 2.374846 3.023233 2.033357
生成的图形如下所示:

从前面的图形中可以看出,点并不是均匀分布,而是集中在均值附近。众所周知,统计分布可以用某些公式来描述。为了在理论上访问这些公式,R 为每个内置随机分布提供了一组函数。更具体地说,对于均匀分布,R 提供了其概率密度函数dunif(),累积密度函数punif(),分位数函数qunif()和随机生成器runif()。对于正态分布,相应的名称是dnorm(),pnorm()和qnorm()。密度函数、累积密度函数、分位数函数以及随机生成器的相同命名方案也适用于 R 支持的其他分布。
除了这两种最常用的统计分布之外,R 还提供了用于离散分布(如二项分布)和连续分布(如指数分布)的函数。您可以通过运行?Distributions来查看支持的完整分布列表。这些分布的特性超出了本书的范围。如果您不熟悉它们但对这些分布的特性感兴趣,您可以阅读任何概率论教科书或访问维基百科(en.wikipedia.org/wiki/Probability_distribution)以获取更多详细信息。
R 支持许多分布,每个分布都有相应的函数。幸运的是,我们不需要记住很多不同的函数名,因为它们都遵循相同的命名约定。
计算汇总统计量
对于给定的数据集,我们通常需要一些汇总统计量来对其有一个初步的了解。R 提供了一套函数来计算数值向量的汇总统计量,包括均值、中位数、标准差、方差、最大值、最小值、范围和分位数。对于多个数值向量,我们可以计算协方差矩阵和相关性矩阵。
以下示例展示了我们如何使用内置函数来计算这些汇总统计量。首先,我们从一个标准正态分布中生成一个长度为 50 的随机数值向量:
x <- rnorm(50)
要计算x的算术样本均值,我们调用mean():
mean(x)
## [1] -0.1051295
这相当于:
sum(x) / length(x)
## [1] -0.1051295
然而,mean()支持从输入数据的两端修剪一定比例的观测值:
mean(x, trim = 0.05)
## [1] -0.141455
如果x包含一些远离其他值的异常值,从前面方程获得的平均值应该更稳健,因为异常值被从输入中省略了。
样本数据代表性位置的另一种度量方法是样本中位数。对于一个给定的样本,一半的观测值高于中位数,另一半的观测值低于中位数。如果数据中存在一些极端值,中位数可以是一个稳健的度量。对于x,样本中位数是:
median(x)
## [1] -0.2312157
除了均值和中位数等位置度量值之外,变异度量值也很重要。要计算数值向量的标准差,我们使用sd():
sd(x)
## [1] 0.8477752
要计算方差,我们使用var():
var(x)
## [1] 0.7187228
要简单地获取数据中的极端值,我们使用min()和max():
c(min = min(x), max = max(x))
## min max
## -1.753655 2.587579
或者,您可以使用range()直接获取这两个值:
range(x)
## [1] -1.753655 2.587579
有时,数据不是正态分布的。在这种情况下,位置度量值和变异度量值会受到这种不规则性的影响,并可能产生误导性的结果。在这里,我们可能需要查看数据的临界分位数处的值:
quantile(x)
## 0% 25% 50% 75% 100%
## -1.7536547 -0.6774037 -0.2312157 0.2974412 2.5875789
要查看更多分位数,为probs参数指定更多值:
quantile(x, probs = seq(0, 1, 0.1))
## 0% 10% 20% 30%
## -1.753654706 -1.116231750 -0.891186551 -0.504630513
## 40% 50% 60% 70%
## -0.412239924 -0.231215699 0.009806393 0.177344522
## 80% 90% 100%
## 0.550510144 0.968607716 2.587578887
如果数据不是正态分布的,两个分位数之间的值差可能非常大或非常小,与其他值相比。一个快捷方法是使用summary(),它直接给出最常用的汇总统计量,包括四个分位数、中位数和平均值:
summary(x)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## -1.7540 -0.6774 -0.2312 -0.1051 0.2974 2.5880
注意,最小值和最大值分别是 0 百分位数和 100 百分位数。
实际上,summary()是一个通用函数,适用于许多类型的对象,并且具有不同的行为。例如,summary()与数据框一起工作:
df <- data.frame(score = round(rnorm(100, 80, 10)),
grade = sample(letters[1:3], 100, replace = TRUE))
summary(df)
## score grade
## Min. : 60.00 a:34
## 1st Qu.: 73.00 b:38
## Median : 79.00 c:28
## Mean : 79.65
## 3rd Qu.: 86.00
## Max. :107.00
可以看出,对于数值列,summary()显示汇总统计量。对于其他类型的列,它可能只是简单地显示值出现的表格。
计算协方差和相关性矩阵
前面的例子介绍了单个向量最常用的汇总统计量。对于两个或更多向量,我们可以计算协方差矩阵和相关性矩阵。
以下代码生成另一个与x相关的向量:
y <- 2 * x + 0.5 * rnorm(length(x))
我们可以计算x和y之间的协方差:
cov(x, y)
## [1] 1.419859
我们还可以计算相关系数:
cor(x, y)
## [1] 0.9625964
这两个函数也适用于超过两个向量。如果我们需要计算超过两个向量的协方差和相关性矩阵,我们需要输入一个矩阵或数据框。在以下示例中,我们生成另一个长度与 x 相同的随机向量 z。这次,z 遵循均匀分布,并且不依赖于 x 或 y。我们使用 cbind() 创建一个三列矩阵,并计算它们的协方差矩阵:
z <- runif(length(x))
m1 <- cbind(x, y, z)
cov(m1)
## x y z
## x 0.7187228 1.41985899 0.04229950
## y 1.4198590 3.02719645 0.07299981
## z 0.0422995 0.07299981 0.08005535
同样,我们可以直接使用矩阵调用 cor() 来计算相关性矩阵。
cor(m1)
## x y z
## x 1.0000000 0.9625964 0.1763434
## y 0.9625964 1.0000000 0.1482881
## z 0.1763434 0.1482881 1.0000000
由于 y 是由与 x 的线性关系以及一些噪声生成的,我们应该预期 x 和 y 之间高度相关,但与 z 发生相同的事情不应该发生。相关矩阵看起来与我们的预期一致。要在统计意义上得出这样的结论,我们需要进行严格的统计测试,但这超出了本书的范围。
使用 apply-family 函数
此前,我们讨论了使用 for 循环在向量或列表上重复评估带有迭代器的表达式。然而,在实践中,for 循环几乎是最后的选择,因为当每个迭代独立于其他迭代时,另一种方式要干净得多,也更容易编写和阅读。
例如,以下代码使用 for 创建一个包含三个独立、正态分布随机向量(其长度由向量 len 指定)的列表:
len <- c(3, 4, 5)
# first, create a list in the environment.
x <- list()
# then use `for` to generate the random vector for each length
for (i in 1:3) {
x[[i]] <- rnorm(len[i])
}
x
## [[1]]
## [1] 1.4572245 0.1434679 -0.4228897
##
## [[2]]
## [1] -1.4202269 -0.7162066 -1.6006179 -1.2985130
##
## [[3]]
## [1] -0.6318412 1.6784430 0.1155478 0.2905479 -0.7363817
上述示例很简单,但与使用 lapply 的实现相比,代码相当冗余:
lapply(len, rnorm)
## [[1]]
## [1] -0.3258354 -1.4658116 -0.1461097
##
## [[2]]
## [1] -0.1715198 0.5215857 -0.3178271 -0.3967798
##
## [[3]]
## [1] -0.2047106 -1.2009772 1.4859955 0.1940920 0.3758798
lapply 版本要简单得多。它将 rnorm() 应用于 len 中的每个元素,并将每个结果放入一个列表中。
从前面的示例中,我们应该意识到,这只有在 R 允许我们将函数作为普通对象传递时才可能。幸运的是,这是真的。R 中的函数被当作对象对待,可以作为参数传递,就像我们在数值方法部分所展示的那样。这个特性在很大程度上提高了代码的灵活性。
每个 apply-family 函数都是一个所谓的 高阶函数,它接受一个函数作为参数。我们将在稍后详细介绍这个概念。
lapply
lapply() 函数,正如我们之前所演示的,它接受一个向量和函数作为其参数。它简单地将函数应用于给定向量的每个元素,并最终返回一个包含所有结果的列表。
当每个迭代独立于其他迭代时,此函数非常有用。在这种情况下,我们不必显式创建迭代器。
它不仅适用于向量,也适用于列表。假设我们有一个学生列表:
students <- list(
a1 = list(name = "James", age = 25,
gender = "M", interest = c("reading", "writing")),
a2 = list(name = "Jenny", age = 23,
gender = "F", interest = c("cooking")),
a3 = list(name = "David", age = 24,
gender = "M", interest = c("running", "basketball")))
现在,我们需要创建一个字符向量,其中每个元素格式如下:
James, 25 year-old man, loves reading, writing.
注意,sprintf() 有助于通过替换占位符(例如,%s 用于字符串,%d 用于整数)来格式化文本。以下是一个示例:
sprintf("Hello, %s! Your number is %d.", "Tom", 3)
## [1] "Hello, Tom! Your number is 3."
现在,首先,我们确信迭代正在对students进行操作,并且每个迭代都是独立的。换句话说,对詹姆斯的计算与珍妮的计算没有任何关系,以此类推。因此,我们可以使用lapply来完成这项工作:
lapply(students, function(s) {
type <- switch(s$gender, "M" = "man", "F" = "woman")
interest <- paste(s$interest, collapse = ", ")
sprintf("%s, %d year-old %s, loves %s.", s$name, s$age, type, interest)
})
## $a1
## [1] "James, 25 year-old man, loves reading, writing."
##
## $a2
## [1] "Jenny, 23 year-old woman, loves cooking."
##
## $a3
## [1] "David, 24 year-old man, loves running, basketball."
上述代码使用了一个匿名函数,这是一种没有分配给符号的函数。换句话说,这个函数是临时的,没有名字。当然,我们可以明确地将函数绑定到符号上,即给它一个名字,并在lapply中使用这个名字。
尽管如此,代码相当简单。对于students中的每个元素s,函数会决定学生的类型并将他们的兴趣粘贴在一起,用逗号分隔。然后,它将信息放入我们想要的格式中。
幸运的是,我们使用lapply的大部分方法也适用于其他 apply 家族函数,但它们的迭代机制或结果类型可能不同。
sapply
列表并不总是结果的有利容器。有时,我们希望它们被放入一个简单的向量或矩阵中。sapply函数根据其结构简化结果。
假设我们对1:10中的每个元素应用平方。如果我们用lapply来做,我们将得到一个平方数的列表。这个结果看起来有点冗余,因为结果列表实际上是一个单值数值向量的列表。然而,我们可能仍然希望将结果保持为向量:
sapply(1:10, function(i) i ^ 2)
## [1] 1 4 9 16 25 36 49 64 81 100
如果应用函数每次都返回一个多元素向量,sapply会将结果放入一个矩阵中,其中每个返回的向量占据一列:
sapply(1:10, function(i) c(i, i ^ 2))
## [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10]
## [1,] 1 2 3 4 5 6 7 8 9 10
## [2,] 1 4 9 16 25 36 49 64 81 100
vapply
虽然sapply非常方便且智能,但有时这种智能可能会变成风险。假设我们有一个输入数字的列表:
x <- list(c(1, 2), c(2, 3), c(1, 3))
如果我们想要获取x中每个数字的平方数的数值向量,sapply可以很容易地使用,因为它会自动尝试简化结果的数据结构:
sapply(x, function(x) x ^ 2)
## [,1] [,2] [,3]
## [1,] 1 4 1
## [2,] 4 9 9
然而,如果输入数据存在一些错误或损坏,sapply()将静默地接受输入并可能返回一个意外的值。例如,假设x的第三个元素错误地多了一个元素:
x1 <- list(c(1, 2), c(2, 3), c(1, 3, 3))
然后,sapply()发现它不能再简化为一个矩阵,因此返回一个列表:
sapply(x1, function(x) x ^ 2)
## [[1]]
## [1] 1 4
##
## [[2]]
## [1] 4 9
##
## [[3]]
## [1] 1 9 9
如果我们首先使用vapply(),错误将很快被发现。vapply()函数有一个额外的参数,指定了每次迭代返回值的模板。在以下代码中,模板是numeric(2),这意味着每个迭代应该返回一个包含两个元素的数值向量。如果模板被违反,函数将最终出错:
vapply(x1, function(x) x ^ 2, numeric(2))
## Error in vapply(x1, function(x) x², numeric(2)): values must be length 2,
## but FUN(X[[3]]) result is length 3
对于原始和正确的输入,vapply()返回与sapply()完全相同的矩阵:
vapply(x, function(x) x ^ 2, numeric(2))
## [,1] [,2] [,3]
## [1,] 1 4 1
## [2,] 4 9 9
总之,vapply是sapply更安全版本,因为它执行额外的模板检查。在实际应用中,如果可以确定模板,最好使用vapply()而不是sapply()。
mapply
虽然lappy()和sapply()都遍历一个向量,但mapply()遍历多个向量。换句话说,mapply是sapply的多变量版本:
mapply(function(a, b, c) a * b + b * c + a * c,
a = c(1, 2, 3), b = c(5, 6, 7), c = c(-1, -2, -3))
## [1] -1 -4 -9
迭代函数不仅可以返回标量值,还可以返回多元素向量。然后,mapply()将简化结果,就像sapply()做的那样:
df <- data.frame(x = c(1, 2, 3), y = c(3, 4, 5))
df
## x y
## 1 1 3
## 2 2 4
## 3 3 5
mapply(function(xi, yi) c(xi, yi, xi + yi), df$x, df$y)
## [,1] [,2] [,3]
## [1,] 1 2 3
## [2,] 3 4 5
## [3,] 4 6 8
Map是lapply的多变量版本,因此总是返回一个列表:
Map(function(xi, yi) c(xi, yi, xi + yi), df$x, df$y)
## [[1]]
## [1] 1 3 4
##
## [[2]]
## [1] 2 4 6
##
## [[3]]
## [1] 3 5 8
apply
apply函数在给定的矩阵或数组的给定边缘或维度上应用一个函数。例如,要计算每一行的总和,即第一维度,我们需要指定MARGIN = 1,这样sum函数就会在每次迭代中对从矩阵中切分的一行(数值向量)应用:
mat <- matrix(c(1, 2, 3, 4), nrow = 2)
mat
## [,1] [,2]
## [1,] 1 3
## [2,] 2 4
apply(mat, 1, sum)
## [1] 4 6
要计算每一列的总和,即第二维度,我们需要指定MARGIN=2,这样sum函数就会在每次迭代中对从mat中切分的每一列应用:
apply(mat, 2, sum)
## [1] 3 7
apply函数也支持数组输入和矩阵输出:
mat2 <- matrix(1:16, nrow = 4)
mat2
## [,1] [,2] [,3] [,4]
## [1,] 1 5 9 13
## [2,] 2 6 10 14
## [3,] 3 7 11 15
## [4,] 4 8 12 16
要构建一个显示每列最大和最小值的矩阵,运行以下代码:
apply(mat2, 2, function(col) c(min = min(col), max = max(col)))
## [,1] [,2] [,3] [,4]
## min 1 5 9 13
## max 4 8 12 16
要构建一个显示每行最大和最小值的矩阵,运行以下代码:
apply(mat2, 1, function(col) c(min = min(col), max = max(col)))
## [,1] [,2] [,3] [,4]
## min 1 2 3 4
## max 13 14 15 16
摘要
在本章中,你通过演示内置函数的使用来学习如何处理基本对象。它们是 R 在实际应用中的词汇。你学习了测试和获取对象类型的基本函数,以及访问和重塑数据维度的函数。你还了解了一些逻辑运算符和函数,用于过滤数据。
为了处理数值数据结构,你学习了基本的数学函数,内置的数值方法来寻找根和进行微积分,以及一些统计函数来执行随机抽样和数据的总结。你还了解了 apply 族函数,这些函数使得迭代和收集结果变得更加容易。
另一个重要的数据类型是字符串,它由字符向量表示。在下一章中,你将学习字符串操作技术,以促进文本分析。
第六章. 处理字符串
在上一章中,你学习了多个类别中的许多内置函数,用于处理基本对象。你学习了如何访问对象类、类型和维度;如何进行逻辑、数学和基本统计计算;以及如何执行简单的分析任务,如求根。这些函数是我们解决特定问题的基石。
与字符串相关的函数是一个非常重要的函数类别。它们将在本章中介绍。在 R 中,文本存储在字符向量中,许多函数和技术对于操作和分析文本非常有用。在本章中,你将学习处理字符串的基本和有用技术,包括以下主题:
-
字符向量的基本操作
-
在日期/时间对象与其字符串表示之间进行转换
-
使用正则表达式从文本中提取信息
字符串入门
R 中的字符向量用于存储文本数据。你之前了解到,与许多其他编程语言相比,字符向量不是一个由单个字符、字母或字母符号(如 a、b、c)组成的向量。相反,它是一个字符串的向量。
R 还提供了一系列内置函数来处理字符向量。其中许多也执行向量化操作,因此它们可以一步处理多个字符串值。
在本节中,你将学习更多关于打印、组合和转换存储在字符向量中的文本的内容。
打印文本
也许我们可以用文本做的最基本的事情就是查看它们。R 提供了几种在控制台中查看文本的方法。
最简单的方法是直接在引号内键入字符串:
"Hello"
## [1] "Hello"
就像浮点数的数值向量一样,字符向量是一个字符值或字符串的向量。"Hello"位于第一个位置,是我们之前创建的字符向量的唯一元素。
我们也可以通过简单地评估它来打印存储在变量中的字符串值:
str1 <- "Hello"
str1
## [1] "Hello"
然而,仅仅在循环中写入字符值并不会迭代地打印它。它根本不会打印任何内容:
for (i in 1:3) {
"Hello"
}
这是因为 R 只会在控制台中键入表达式时自动打印表达式的值。for 循环不会显式地返回一个值。这种行为也解释了当调用以下两个函数时打印行为之间的差异:
test1 <- function(x) {
"Hello"
x
}
test1("World")
## [1] "World"
在前面的输出中,test1没有打印Hello,而是打印了World,因为test1("World")返回最后一个表达式x的值,即World,这是函数调用的值,R 自动打印了这个值。让我们假设我们按照以下方式从函数中移除x:
test2 <- function(x) {
"Hello"
}
test2("World")
## [1] "Hello"
然后,test2无论x取什么值,总是返回Hello。因此,R 自动打印表达式test2("World")的值,即Hello。
如果我们想显式地打印一个对象,我们应该使用print():
print(str1)
## [1] "Hello"
然后,字符向量以位置 [1] 打印出来。这同样适用于循环:
for (i in 1:3) {
print(str1)
}
## [1] "Hello"
## [1] "Hello"
## [1] "Hello"
它也可以在函数中使用:
test3 <- function(x) {
print("Hello")
x
}
test3("World")
## [1] "Hello"
## [1] "World"
在某些情况下,我们希望文本以消息的形式出现,而不是带有索引的字符向量。在这种情况下,我们可以调用 cat() 或 message() 函数:
cat("Hello")
## Hello
我们可以更灵活地构建消息:
name <- "Ken"
language <- "R"
cat("Hello,", name, "- a user of", language)
## Hello, Ken - a user of R
我们更改输入以打印一个更正式的句子:
cat("Hello, ", name, ", a user of ", language, ".")
## Hello, Ken , a user of R .
看起来连接的字符串似乎在不同的参数之间使用了不必要的空格。这是因为默认情况下,空格字符被用作输入字符串之间的分隔符。我们可以通过指定 sep= 参数来更改它。在下面的例子中,我们将避免默认的空格分隔符,并在输入字符串中手动写入空格以创建正确的版本:
cat("Hello, ", name, ", a user of ", language, ".", sep = "")
## Hello, Ken, a user of R.
另一个函数是 message(),它通常用于重要事件等严肃场合。输出文本具有更明显的显示效果。它与 cat() 不同,因为它在连接输入字符串时不会自动使用空格分隔符:
message("Hello, ", name, ", a user of ", language, ".")
## Hello, Ken, a user of R.
使用 message(),我们需要手动写入分隔符,以便显示与之前相同的文本。
cat() 和 message() 之间的另一个行为差异是,message() 会自动在文本末尾添加一个新行,而 cat() 则不会。
以下两个例子演示了差异。我们想要打印相同的内容,但得到不同的结果:
for (i in 1:3) {
cat(letters[[i]])
}
## abc
for (i in 1:3) {
message(letters[[i]])
}
## a
## b
## c
很明显,每次调用 cat() 时,它都会打印输入字符串而不添加新行。结果是三个字母显示在同一行上。相比之下,每次调用 message() 时,它都会在输入字符串后添加一个新行。因此,三个字母显示在三个行上。要使用 cat() 在新的一行上打印每个字母,我们需要在输入中显式添加一个换行符。下面的代码打印了与上一个例子中 message() 相同的内容:
for (i in 1:3) {
cat(letters[[i]], "\n", sep = "")
}
## a
## b
## c
字符串连接
在实际应用中,我们经常需要连接几个字符串来构建一个新的字符串。paste() 函数用于将几个字符向量连接在一起。此函数也使用空格作为默认的分隔符:
paste("Hello", "world")
## [1] "Hello world"
paste("Hello", "world", sep = "-")
## [1] "Hello-world"
如果我们不想使用分隔符,可以设置 sep="" 或者使用 paste0() 函数:
paste0("Hello", "world")
## [1] "Helloworld"
可能你会对 paste() 和 cat() 混淆,因为它们都能连接字符串。但它们有什么区别呢?尽管这两个函数都能连接字符串,但区别在于 cat() 只会将字符串打印到控制台,而 paste() 则返回字符串以供进一步使用。以下代码演示了 cat() 打印连接的字符串但返回 NULL:
value1 <- cat("Hello", "world")
## Hello world
value1
## NULL
换句话说,cat() 只打印字符串,而 paste() 创建一个新的字符向量。
之前的例子展示了 paste() 函数与单元素字符向量一起工作的行为。那么,与多元素向量一起工作又是怎样的呢?让我们看看这是如何实现的:
paste(c("A", "B"), c("C", "D"))
## [1] "A C" "B D"
我们可以看到paste()是按元素工作的,即首先paste("A", "C"),然后paste("B", "D"),最后将结果收集起来构建一个包含两个元素的字符向量。
如果我们想将结果合并成一个字符串,我们可以通过设置collapse=来指定这两个元素再次连接的方式:
paste(c("A", "B"), c("C", "D"),collapse = ", ")
## [1] "A C, B D"
如果我们想将它们放在两行中,可以将collapse设置为\n(新行):
result <- paste(c("A", "B"), c("C", "D"), collapse = "\n") result
## [1] "A C\nB D"
新的字符向量result是一个两行字符串,但它的文本表示仍然写在一行中。新行由我们指定的\n表示。要查看我们创建的文本,我们需要调用cat():
cat(result)
## A C ## B D
现在,两行字符串按照预期格式打印到控制台。同样,paste0()也适用。
文本转换
将文本转换为另一种形式在许多情况下都很有用。对文本执行多种基本类型的转换很容易。
改变大小写
当我们处理文本数据时,输入可能不符合我们的标准。例如,我们期望所有产品都按大写字母评级,从 A 到 F,但实际输入可能包含这些字母的大小写形式。改变大小写有助于确保输入字符串在大小写上的一致性。
tolower()函数将文本转换为小写字母,而toupper()则相反:
tolower("Hello")
## [1] "hello"
toupper("Hello")
## [1] "HELLO"
这在函数接受字符输入时尤其有用。例如,我们可以定义一个函数,在所有可能的情况下,当type为add时返回x + y,当type为times时返回x * y。最好的做法是无论输入值如何,总是将type转换为小写或大写:
calc <- function(type, x, y) {
type <- tolower(type)
if (type == "add") {
x + y
}else if (type == "times") {
x * y
} else {
stop("Not supported type of command")
}
}
c(calc("add", 2, 3), calc("Add", 2, 3), calc("TIMES", 2, 3))
## [1] 5 5 6
这使得对相似输入(仅在大小写不同的情况下)有更多的容错性,从而使type不区分大小写。
此外,这两个函数是向量化的,也就是说,它会改变给定字符向量中每个字符串元素的大小写:
toupper(c("Hello", "world"))
## [1] "HELLO" "WORLD"
计数字符
另一个有用的函数是nchar(),它简单地计算字符向量中每个元素的字符数:
nchar("Hello")
## [1] 5
与toupper()和tolower()类似,nchar()也是向量化的:
nchar(c("Hello", "R", "User"))
## [1] 5 1 4
这个函数通常用于检查是否向其提供了有效的字符串参数。例如,以下函数接受学生的某些个人信息并将其存储到数据库中:
store_student <- function(name, age) {
stopifnot(length(name) == 1, nchar(name) >= 2,
is.numeric(age), age > 0)
# store the information in the database
}
在将信息存储到数据库之前,函数使用stopifnot()来检查name和age是否提供了有效的值。如果用户没有提供有意义的名称(例如,不少于两个字母),函数将停止并显示错误:
store_student("James", 20)
store_student("P", 23)
## Error: nchar(name) >= 2 is not TRUE
注意,nchar(x) == 0等价于x == ""。要检查空字符串,两种方法都适用。
去除前后空白字符
在前面的例子中,我们使用了 nchar() 来检查 name 是否有效。然而,有时输入数据会包含无用的空白字符。这增加了数据的噪声,并需要仔细检查字符串参数。例如,上一节中的 store_student() 函数对 "P" 这样的名字进行了处理,它和直接的 "P" 参数一样无效,但 nchar(" P") 返回 3:
store_student(" P", 23)
为了考虑可能性,我们需要改进 store_student() 函数。在 R 3.2.0 中,引入了 trimws() 函数,用于去除给定字符串的前导和/或尾随空白字符:
store_student2 <- function(name, age) {
stopifnot(length(name) == 1, nchar(trimws(name)) >= 2,
is.numeric(age), age > 0)
# store the information in the database
}
现在,函数对噪声数据更加鲁棒:
store_student2(" P", 23)
## Error: nchar(trimws(name)) >= 2 is not TRUE
默认情况下,该函数会去除前导和尾随空白字符,包括空格和制表符。您可以指定“left”或“right”以仅去除字符串的一侧:
trimws(c(" Hello", "World "), which = "left")
## [1] "Hello" "World "
子字符串
在前面的章节中,你学习了如何对向量和列表进行子集操作。我们还可以通过调用 substr() 来对字符向量中的文本进行子集操作。假设我们有以下形式的几个日期:
dates <- c("Jan 3", "Feb 10", "Nov 15")
所有月份都由三个字母的缩写表示。我们可以使用 substr() 来提取月份:
substr(dates, 1, 3)
## [1] "Jan" "Feb" "Nov"
要提取日期,我们需要同时使用 substr() 和 nchar():
substr(dates, 5, nchar(dates))
## [1] "3" "10" "15"
现在我们可以从输入字符串中提取月份和日期,因此编写一个函数将此类格式的字符串转换为数值表示相同的日期是有用的。以下函数使用了你之前学到的许多函数和思想:
get_month_day <- function(x) {
months <- vapply(substr(tolower(x), 1, 3), function(md) {
switch(md, jan = 1, feb = 2, mar = 3, apr = 4, may = 5,
jun = 6, jul = 7, aug = 8, sep = 9, oct = 10, nov = 11, dec = 12)
}, numeric(1), USE.NAMES = FALSE)
days <- as.numeric(substr(x, 5,nchar(x)))
data.frame(month = months, day = days)
}
get_month_day(dates)
## month day
## 1 1 3
## 2 2 10
## 3 11 15
substr() 函数还有一个对应的函数,用于用给定的字符向量替换子字符串:
substr(dates, 1, 3) <- c("Feb", "Dec", "Mar") dates
## [1] "Feb 3" "Dec 10" "Mar 15"
文本分割
在许多情况下,要提取的字符串部分的长度不是固定的。例如,人名如 "Mary Johnson" 或 "Jack Smiths" 首名和姓氏的长度没有固定值。使用 substr(),如你在上一节中学到的,来分离和提取这两部分会更困难。此类格式的文本有一个常规分隔符,如空格或逗号。为了提取有用的部分,我们需要分割文本并使每个部分可访问。strsplit() 函数用于通过指定的字符向量分隔符分割文本:
strsplit("a,bb,ccc", split = ",")
## [[1]]
## [1] "a" "bb" "ccc"
该函数返回一个列表。列表中的每个元素都是从原始字符向量中分割该元素产生的字符向量。这是因为 strsplit(),就像我们之前介绍的所有字符串函数一样,也是向量化的,即它返回一个分割的字符向量列表作为结果:
students <- strsplit(c("Tony, 26, Physics", "James, 25, Economics"),
split = ", ")
students
## [[1]]
## [1] "Tony" "26" "Physics"
##
## [[2]]
## [1] "James" "25" "Economics"
strsplit() 函数通过逐元素工作返回一个包含分割部分的字符向量列表。在实践中,分割只是提取或重新组织数据的第一步。为了继续,我们可以使用 rbind 将数据放入矩阵中,并为列赋予适当的名称:
students_matrix <- do.call(rbind, students)colnames(students_matrix) <- c("name", "age", "major")students_matrix
## name age major
## [1,] "Tony" "26" "Physics"
## [2,] "James" "25" "Economics"
然后,我们将矩阵转换为数据框,这样我们就可以将每一列转换为更合适的类型:
students_df <- data.frame(students_matrix, stringsAsFactors = FALSE)students_df$age <- as.numeric(students_df$age)students_df
## name age major
## 1 Tony 26 Physics
## 2 James 25 Economics
现在,原始字符串输入的学生已经转换为更组织化和更有用的数据框 students_df。
一个将整个字符串拆分为单个字符的小技巧是使用一个空的 split 参数:
strsplit(c("hello", "world"), split = "")
## [[1]]
## [1] "h" "e" "l" "l" "o"
##
## [[2]]
## [1] "w" "o" "r" "l" "d"
实际上,strsplit() 的功能比显示的还要强大。它还支持 正则表达式,这是一种非常强大的文本数据处理框架。我们将在本章的最后部分介绍这个主题。
格式化文本
使用 paste() 连接文本有时不是一个好主意,因为文本需要被分割成多个部分,随着格式的变长,阅读起来会变得更加困难。
例如,假设我们需要以下格式打印 students_df 中的每条记录:
#1, name: Tony, age: 26, major: Physics
在这种情况下,使用 paste() 将会非常麻烦:
cat(paste("#", 1:nrow(students_df), ", name: ", students_df$name, ", age: ", students_df$age, ", major: ", students_df$major, sep = ""), sep = "\n")
## #1, name: Tony, age: 26, major: Physics
## #2, name: James, age: 25, major: Economics
代码看起来很杂乱,初次看很难一眼看出通用模板。相比之下,sprintf() 支持格式化模板,并以一种优雅的方式解决了这个问题:
cat(sprintf("#%d, name: %s, age: %d, major: %s",
1:nrow(students_df), students_df$name, students_df$age,
students_df$major), sep = "\n")
#1, name: Tony, age: 26, major: Physics
## #2, name: James, age: 25, major: Economics
在前面的代码中,#%d, name: %s, age: %d, major: %s 是格式化模板,其中 %d 和 %s 是占位符,用于表示字符串中要出现的输入参数。sprintf() 函数特别易于使用,因为它防止模板字符串被拆分,并且每个要替换的部分都指定为函数参数。实际上,这个函数使用的是在 en.wikipedia.org/wiki/Printf_format_string 中详细描述的 C 风格格式化规则。
在前面的例子中,%s 表示字符串,%d 表示数字(整数)。此外,sprintf() 在使用 %f 格式化数值时也非常灵活。例如,%.1f 表示将数字四舍五入到 0.1:
sprintf("The length of the line is approximately %.1fmm", 12.295)
## [1] "The length of the line is approximately 12.3mm"
实际上,存在不同类型值的格式化语法。以下表格显示了最常用的语法:
| 格式 | 输出 |
|---|---|
sprintf("%s", "A") |
A |
sprintf("%d", 10) |
10 |
sprintf("%04d", 10) |
0010 |
sprintf("%f", pi) |
3.141593 |
sprintf("%.2f", pi) |
3.14 |
sprintf("%1.0f", pi) |
3 |
sprintf("%8.2f", pi) |
3.14 |
sprintf("%08.2f", pi) |
00003.14 |
sprintf("%+f", pi) |
+3.141593 |
sprintf("%e", pi) |
3.141593e+00 |
sprintf("%E", pi) |
3.141593E+00 |
注意
官方文档(stat.ethz.ch/R-manual/R-devel/library/base/html/sprintf.html)提供了对支持格式的完整描述。
注意,格式文本中的 % 是一个特殊字符,将被解释为占位符的初始字符。如果我们真的想在字符串中表示 %,为了避免格式化解释,我们需要使用 %% 来表示字面量 %。以下代码是一个示例:
sprintf("The ratio is %d%%", 10)
## [1] "The ratio is 10%"
在 R 中使用 Python 字符串函数
sprintf() 函数功能强大,但并非所有用例都完美。例如,如果模板中某些部分需要多次出现,您将需要多次编写相同的参数。这通常会使代码更加冗余,并且修改起来有些困难:
sprintf("%s, %d years old, majors in %s and loves %s.", "James", 25, "Physics", "Physics")
## [1] "James, 25 years old, majors in Physics and loves Physics."
表示占位符还有其他方法。pystr 包提供了 pystr_format() 函数,用于使用数字或命名的占位符以 Python 格式化风格格式化字符串。前面的例子可以用这个函数以两种方式重写:
一种方法是使用数字占位符:
# install.packages("pystr")
library(pystr)
pystr_format("{1}, {2} years old, majors in {3} and loves {3}.", "James", 25, "Physics", "Physics")
## [1] "James, 25 years old, majors in Physics and loves Physics."
另一种方法是使用命名的占位符:
pystr_format("{name}, {age} years old, majors in {major} and loves {major}.",
name = "James", age = 25, major = "Physics")
## [1] "James, 25 years old, majors in Physics and loves Physics."
在这两种情况下,不需要重复任何参数,输入出现的位置可以轻松地移动到模板字符串的其他地方。
日期/时间格式化
在数据分析中,经常会遇到日期和时间数据类型。也许,与日期最相关的最简单函数是 Sys.Date(),它返回当前日期,以及 Sys.time(),它返回当前时间。
当书籍正在渲染时,日期的打印方式如下:
Sys.Date() ## [1] "2016-02-26"
时间如下:
Sys.time() ## [1] "2016-02-26 22:12:25 CST"
从输出来看,日期和时间看起来像字符向量,但实际上它们不是:
current_date <- Sys.Date()
as.numeric(current_date)
## [1] 16857
current_time <- Sys.time()
as.numeric(current_time)
## [1] 1456495945
它们本质上是以原点为基准的数值,并且具有执行日期/时间计算的特殊方法。对于一个日期,它的数值表示自 1970-01-01 之后的总天数。对于一个时间,它的数值表示自 1970-01-01 00:00.00 UTC 之后的总秒数。
将文本解析为日期/时间
我们可以创建一个相对于自定义原点的日期:
as.Date(1000, "1970-01-01")
## [1] "1972-09-27"
然而,在更多情况下,我们从一个标准的文本表示形式创建日期和时间:
my_date <- as.Date("2016-02-10")
my_date
## [1] "2016-02-10"
但如果我们可以在字符串中代表时间,如 2016-02-10,那么为什么我们需要创建一个像之前那样创建的 Date 对象呢?这是因为日期具有更多功能:我们可以用它们进行日期计算。假设我们有一个日期对象,我们可以添加或减去一定数量的天数来得到一个新的日期:
my_date + 3
## [1] "2016-02-13"
my_date + 80
## [1] "2016-04-30"
my_date - 65
## [1] "2015-12-07"
我们可以直接从另一个日期中减去一个日期,以得到两个日期之间天数差:
date1 <- as.Date("2014-09-28")
date2 <- as.Date("2015-10-20")
date2 - date1
## Time difference of 387 days
date2 - date1 的输出看起来像一条消息,但实际上是一个数值。我们可以使用 as.numeric() 来使其更明确:
as.numeric(date2 - date1)
## [1] 387
时间类似,但没有名为 as.Time() 的函数。要从文本表示形式创建日期时间,我们可以使用 as.POSIXct() 或 as.POSIXlt()。这两个函数是 POSIX 标准下日期/时间对象的两种不同实现。在以下示例中,我们使用 as.POSIXlt 来创建日期/时间对象:
my_time <- as.POSIXlt("2016-02-10 10:25:31")
my_time
## [1] "2016-02-10 10:25:31 CST"
这种类型的对象还定义了 + 和 - 用于简单的时计算。与日期对象不同,它以秒为单位而不是以天为单位:
my_time + 10
## [1] "2016-02-10 10:25:41 CST"
my_time + 12345
## [1] "2016-02-10 13:51:16 CST"
my_time - 1234567
## [1] "2016-01-27 03:29:24 CST"
在数据中给定日期或时间的字符串表示形式时,我们必须将其转换为日期或日期/时间对象,这样我们就可以进行计算。然而,在原始数据中,我们得到的内容并不总是 as.Date() 或 as.POSIXlt() 可以直接识别的格式。在这种情况下,我们需要使用一组特殊字母作为占位符来表示日期或时间的某些部分,就像我们使用 sprintf() 一样。
例如,对于输入 2015.07.25,如果没有提供格式字符串,as.Date() 将产生错误:
as.Date("2015.07.25")
## Error in charToDate(x): character string is not in a standard unambiguous format
我们可以使用格式字符串作为模板来告诉 as.Date() 如何将字符串解析为日期:
as.Date("2015.07.25", format = "%Y.%m.%d")
## [1] "2015-07-25"
类似地,对于非标准的日期/时间字符串,我们还需要指定一个模板字符串来告诉as.POSIXlt()如何处理它:
as.POSIXlt("7/25/2015 09:30:25", format = "%m/%d/%Y %H:%M:%S")
## [1] "2015-07-25 09:30:25 CST"
将字符串转换为日期/时间的另一种(更直接)的函数是strptime():
strptime("7/25/2015 09:30:25", "%m/%d/%Y %H:%M:%S")
## [1] "2015-07-25 09:30:25 CST"
实际上,as.POSIXlt()只是字符输入的strptime()的一个包装器,但strptime()始终要求你提供格式字符串,而as.POSIXlt()在没有提供模板的情况下适用于标准格式。
就像数值向量一样,日期和日期/时间也是向量。你可以向as.Date()提供一个字符向量,并得到一个日期向量:
as.Date(c("2015-05-01", "2016-02-12"))
## [1] "2015-05-01" "2016-02-12"
数学运算也是向量化的。在以下代码中,我们将一些连续的整数添加到日期上,并得到预期的连续日期:
as.Date("2015-01-01") + 0:2
## [1] "2015-01-01" "2015-01-02" "2015-01-03"
同样的功能也适用于日期/时间对象:
strptime("7/25/2015 09:30:25", "%m/%d/%Y %H:%M:%S") + 1:3
## [1] "2015-07-25 09:30:26 CST" "2015-07-25 09:30:27 CST" ## [3] "2015-07-25 09:30:28 CST"
有时,数据使用日期和时间的整数表示。这使得解析日期和时间变得更加复杂。例如,为了解析20150610,我们将运行以下代码:
as.Date("20150610", format = "%Y%m%d")
## [1] "2015-06-10"
为了解析20150610093215,我们可以指定模板来描述这种格式:
strptime("20150610093215", "%Y%m%d%H%M%S")
## [1] "2015-06-10 09:32:15 CST"
一个更复杂一点的例子是解析以下数据框中的日期/时间:
datetimes <- data.frame(
date = c(20150601, 20150603),
time = c(92325, 150621))
如果我们在datetimes的列上使用paste0(),并直接使用之前示例中使用的模板调用strptime(),我们将得到一个缺失值,这表明第一个元素与格式不一致:
dt_text <- paste0(datetimes$date, datetimes$time)dt_text
## [1] "2015060192325" "20150603150621"
strptime(dt_text, "%Y%m%d%H%M%S")
## [1] NA "2015-06-03 15:06:21 CST"
问题出在92325上,它应该是092325。我们需要使用sprintf()来确保在必要时存在前导零:
dt_text2 <- paste0(datetimes$date, sprintf("%06d", datetimes$time))dt_text2
## [1] "20150601092325" "20150603150621"
strptime(dt_text2, "%Y%m%d%H%M%S")
## [1] "2015-06-01 09:23:25 CST" "2015-06-03 15:06:21 CST"
最后,转换工作如预期的那样进行。
格式化日期/时间为字符串
在上一节中,你学习了如何将字符串转换为日期和日期/时间对象。在本节中,你将学习相反的操作:根据特定的模板将日期和日期/时间对象转换回字符串。
一旦创建了一个日期对象,每次我们打印它时,它总是以标准格式表示:
my_date
## [1] "2016-02-10"
我们可以使用as.character()将日期转换为标准表示:
date_text <- as.character(my_date)
date_text
## [1] "2016-02-10"
从输出来看,my_date看起来相同,但现在字符串只是一个纯文本,不再支持日期计算:
date_text + 1
## Error in date_text + 1: non-numeric argument to binary operator
有时,我们需要以非标准的方式格式化日期:
as.character(my_date, format = "%Y.%m.%d")
## [1] "2016.02.10"
实际上,as.character()在幕后直接调用format()。我们将得到完全相同的结果,这在大多数情况下是推荐的:
format(my_date, "%Y.%m.%d")
## [1] "2016.02.10"
同样,这也适用于日期/时间对象。我们可以进一步自定义模板,以包含除了占位符之外的其他文本:
my_time
## [1] "2016-02-10 10:25:31 CST"
format(my_time, "date: %Y-%m-%d, time: %H:%M:%S")
## [1] "date: 2016-02-10, time: 10:25:31"
注意
格式占位符远不止我们提到的那么多。通过输入?strptime来阅读文档,以获取详细信息。
有许多包可以使处理日期和时间变得更容易。我推荐使用lubridate包(cran.r-project.org/web/packages/lubridate),因为它提供了几乎所有你需要用来处理日期和时间对象的函数。
在前面的章节中,你学习了处理字符串和日期/时间对象的一些基本函数。这些函数很有用,但与正则表达式相比,它们的灵活性要小得多。你将在下一节学习这种非常强大的技术。
使用正则表达式
对于研究,你可能需要从开放获取网站或需要认证的数据库下载数据。这些数据源提供的数据格式多种多样,而且大部分提供的数据很可能组织得很好。例如,许多经济和金融数据库提供 CSV 格式的数据,这是一种广泛支持的文本格式,用于表示表格数据。典型的 CSV 格式如下所示:
id,name,score
1,A,20
2,B,30
3,C,25
在 R 中,使用read.csv()将 CSV 文件导入为具有正确标题和数据类型的 data frame 非常方便,因为这种格式是 data frame 的自然表示形式。
然而,并非所有数据文件都组织得很好,处理组织不良的数据非常费力。内置函数如read.table()和read.csv()在许多情况下都有效,但它们可能对这种无格式数据根本无济于事。
例如,如果你需要分析如下所示以 CSV 格式组织的原始数据(messages.txt),在调用read.csv()时你最好要小心:
2014-02-01,09:20:25,James,Ken,Hey, Ken!
2014-02-01,09:20:29,Ken,James,Hey, how are you?
2014-02-01,09:20:41,James,Ken, I'm ok, what about you?
2014-02-01,09:21:03,Ken,James,I'm feeling excited!
2014-02-01,09:21:26,James,Ken,What happens?
假设你想要以以下格式将此文件导入为 data frame,该格式组织得很好:
Date Time Sender Receiver Message
1 2014-02-01 09:20:25 James Ken Hey, Ken!
2 2014-02-01 09:20:29 Ken James Hey, how are you?
3 2014-02-01 09:20:41 James Ken I'm ok, what about you?
4 2014-02-01 09:21:03 Ken James I'm feeling excited!
5 2014-02-01 09:21:26 James Ken What happens?
然而,如果你盲目地调用read.csv(),你会发现它并没有正确工作。这个数据集在消息列中有些特殊。存在额外的逗号,这些逗号会被错误地解释为 CSV 文件中的分隔符。以下是翻译自原始文本的数据框:
read.csv("data/messages.txt", header = FALSE)
## V1V2V3V4V5V6
## 1 2014-02-01 09:20:25 James Ken Hey Ken!
## 2 2014-02-01 09:20:29 Ken James Hey how are you?
## 3 2014-02-01 09:20:41 James Ken I'm ok what about you?
## 4 2014-02-01 09:21:03 Ken James I'm feeling excited!
## 5 2014-02-01 09:21:26 James Ken What happens?
解决这个问题有多种方法。你可能考虑对每一行使用strsplit(),手动取出前几个元素,并将其他部分粘贴到每一行分割成多个部分。但其中最简单、最稳健的方法是使用所谓的正则表达式(en.wikipedia.org/wiki/Regular_expression)。如果你对术语感到陌生,不要担心。它的用法非常简单:描述匹配文本的模式,并从该文本中提取所需的部分。
在我们应用技术之前,我们需要一些基本知识。最好的激励自己的方式是看看一个更简单的问题,并考虑解决该问题需要什么。
假设我们正在处理以下文本(fruits.txt),它描述了一些水果的数量或状态:
apple: 20
orange: missing
banana: 30
pear: sent to Jerry
watermelon: 2
blueberry: 12
strawberry: sent to James
现在,我们想要挑选出带有数字而不是状态信息的所有水果。虽然我们可以轻松地通过视觉完成这项任务,但对于计算机来说并不那么容易。如果行数超过两千行,使用适当的技术处理对于计算机来说可能很容易,而相比之下,对于人类来说则可能很困难、耗时且容易出错。
我们首先应该想到的是,我们需要区分带数字和不带数字的水果。一般来说,我们需要区分匹配特定模式和不匹配模式的文本。在这里,正则表达式无疑是处理这个问题的正确技术。
正则表达式通过两个步骤解决问题:第一步是找到一个匹配文本的模式,第二步是分组模式以提取所需的信息。
寻找字符串模式
为了解决问题,我们的计算机不需要理解水果实际上是什么。我们只需要找出一个描述我们想要的模式的规律。字面上,我们想要获取所有以下列单词、分号和空格开始的行,并以整数结束而不是单词或其他符号。
正则表达式提供了一套符号来表示模式。前面的模式可以用 ^\w+:\s\d+$ 来描述,其中元符号用于表示符号类:
-
^:这个符号用于行的开头 -
\w:这个符号代表一个单词字符 -
\s:这个符号是一个空格字符 -
\d:这个符号是一个数字字符 -
$:这个符号用于行的末尾
此外,\w+ 表示一个或多个单词字符,: 是我们期望在单词后面看到的符号,而 \d+ 表示一个或多个数字字符。看,这个模式如此神奇,它代表了所有我们想要的案例,并排除了所有我们不想要的案例。
更具体地说,这个模式匹配如 abc: 123 这样的行,但不包括其他行。为了在 R 中挑选出所需的案例,我们使用 grep() 来获取哪些字符串匹配该模式:
fruits <- readLines("data/fruits.txt") fruits
## [1] "apple: 20" "orange: missing"
## [3] "banana: 30" "pear: sent to Jerry"
## [5] "watermelon: 2" "blueberry: 12"
## [7] "strawberry: sent to James"
matches <- grep("^\\w+:\\s\\d+$", fruits)
matches
## [1] 1 3 5 6
注意,在 R 中,\ 应该写成 \\ 以避免转义。然后,我们可以通过 matches 过滤 fruits:
fruits[matches]
## [1] "apple: 20" "banana: 30" "watermelon: 2" "blueberry: 12"
现在,我们已经成功地区分了所需的行和不需要的行。匹配模式的行被选中,不匹配模式的行被省略。
注意,我们指定了一个以 ^ 开头以 $ 结尾的模式,因为我们不希望进行部分匹配。实际上,正则表达式默认执行部分匹配,即如果字符串的任何部分匹配模式,整个字符串就被认为是匹配模式的。例如,以下代码尝试分别找出哪些字符串匹配两个模式:
grep("\\d", c("abc", "a12", "123", "1"))
## [1] 2 3 4
grep("^\\d$", c("abc", "a12", "123", "1"))
## [1] 4
第一个模式匹配包含任何数字的字符串(部分匹配),而第二个模式使用 ^ 和 $ 匹配只有单个数字的字符串。
一旦模式正确工作,我们就进行下一步:使用组来提取数据。
使用组提取数据
在模式字符串中,我们可以用括号标记出我们想要从文本中提取的部分。在这个问题中,我们可以将模式修改为 (\w+):\s(\d+),其中标记了两组:一组是匹配 \w+ 的水果名称,另一组是匹配 \d+ 的水果数量。
现在,我们可以使用这个修改后的模式来提取我们想要的信息。虽然使用 R 的内置函数做这个工作是完全可能的,但我强烈建议使用stringr包中的函数。这个包使得使用正则表达式变得容易得多。我们使用修改后的模式并带有组的str_match():
library(stringr)
matches <- str_match(fruits, "^(\\w+):\\s(\\d+)$")
matches
## [,1] [,2] [,3]
## [1,] "apple: 20" "apple" "20"
## [2,] NA NA NA
## [3,] "banana: 30" "banana" "30"
## [4,] NA NA NA
## [5,] "watermelon: 2" "watermelon" "2"
## [6,] "blueberry: 12" "blueberry" "12"
## [7,] NA NA NA
这次匹配的是一个有多列的矩阵。括号内的组是从文本中提取出来的,并放置在第二列和第三列。现在,我们可以轻松地将这个字符矩阵转换成带有正确标题和数据类型的 data frame:
# transform to data frame
fruits_df <- data.frame(na.omit(matches[, -1]), stringsAsFactors =FALSE)
# add a header
colnames(fruits_df) <- c("fruit","quantity")
# convert type of quantity from character to integer
fruits_df$quantity <- as.integer(fruits_df$quantity)
现在,fruits_df是一个带有正确标题和数据类型的 data frame:
fruits_df
## fruit quantity
## 1 apple 20
## 2 banana 30
## 3 watermelon 2
## 4 blueberry 12
如果你不确定前面代码的中间结果,你可以逐行运行代码,看看每一步发生了什么。最后,这个问题完全可以用正则表达式解决。
从前面的例子中,我们看到正则表达式的魔力只是一组标识符,用于表示不同类型的字符和符号。除了我们提到的元符号外,以下也是很有用的:
-
[0-9]:这个符号代表从 0 到 9 的单个整数 -
[a-z]:这个符号代表从 a 到 z 的单个小写字母 -
[A-Z]:这个符号代表从 A 到 Z 的单个大写字母 -
.:这个符号代表任何单个符号 -
*:这个符号代表一个模式,可能出现零次、一次或多次 -
+:这是一个模式,出现一次或多次 -
{n}:这是一个出现n次的模式 -
{m,n}:这是一个至少出现m次且最多出现n次的模式
使用这些元符号,我们可以轻松地检查或过滤字符串数据。例如,假设我们有一些来自两个国家的电话号码混合在一起。如果一个国家的电话号码模式与另一个国家不同,正则表达式可以帮助将它们分成两类:
telephone <- readLines("data/telephone.txt")
telephone
## [1] "123-23451" "1225-3123" "121-45672" "1332-1231" "1212-3212" "123456789"
注意,数据中有一个例外。数字中间没有-。对于非例外情况,应该很容易找出两种电话号码的模式:
telephone[grep("^\\d{3}-\\d{5}$", telephone)]
## [1] "123-23451" "121-45672"
telephone[grep("^\\d{4}-\\d{4}$", telephone)]
## [1] "1225-3123" "1332-1231" "1212-3212"
要找出例外情况,grepl()更有用,因为它返回一个逻辑向量,指示每个元素是否与给定的模式匹配。因此,我们可以使用这个函数来选择所有不匹配给定模式的记录:
telephone[!grepl("^\\d{3}-\\d{5}$", telephone) & !grepl("^\\d{4}-\\d{4}$", telephone)]
## [1] "123456789"
上述代码基本上表示所有不匹配两种模式的记录都被认为是例外。想象一下,我们有一百万条记录要检查。例外情况可能以任何格式存在,因此使用这种方法(排除所有有效记录以找出无效记录)更为稳健。
以可定制的方式读取数据
现在,让我们回到本节开头遇到的问题。这个程序与水果示例中的程序完全相同:找到模式并分组。
首先,让我们看看原始数据中的一条典型行:
2014-02-01,09:20:29,Ken,James,Hey, how are you?
显然,所有行都是基于相同的格式,即日期、时间、发件人、收件人和消息由逗号分隔。唯一特殊的是,消息中可能包含逗号,但我们不希望我们的代码将其解释为分隔符。
注意,正则表达式与前面的例子一样完美地适用于这个目的。要表示一个或多个遵循相同模式的符号,只需在符号标识符后放置一个加号(+)。例如,\d+ 表示由一个或多个介于 "0" 和 "9" 之间的数字字符组成的字符串。例如,“1”,“23”,和“456”都匹配这个模式,而“word”则不匹配。也存在某些模式可能或可能不出现的情况。然后,我们需要在符号标识符后放置一个 * 来标记这个特定模式可能出现一次或多次,或者可能不出现,以便匹配广泛的文本。
现在,让我们回到我们的问题。我们需要识别典型行的足够通用的模式。以下是我们应该解决的分组模式:
(\d+-\d+-\d+),(\d+:\d+:\d+),(\w+),(\w+),\s*(.+)
现在,我们需要以与我们在水果示例中使用 readLines() 的相同方式导入原始文本:
messages <- readLines("data/messages.txt")
然后,我们需要找出代表我们想要从文本中提取的文本和信息模式的模式:
pattern <- "^(\\d+-\\d+-\\d+),(\\d+:\\d+:\\d+),(\\w+),(\\w+),\\s*(.+)$"
matches <- str_match(messages, pattern)
messages_df <- data.frame(matches[, -1]) colnames(messages_df) <- c("Date", "Time", "Sender", "Receiver", "Message")
这里的模式看起来像某种秘密代码。别担心,这正是正则表达式的工作方式,如果你看过了前面的例子,现在应该能理解一些了。
正则表达式工作得非常完美。messages_df 文件看起来像以下结构:
messages_df
## Date Time Sender Receiver Message
## 1 2014-02-01 09:20:25 James Ken Hey, Ken!
## 2 2014-02-01 09:20:29 Ken James Hey, how are you?
## 3 2014-02-01 09:20:41 James Ken I'm ok, what about you?
## 4 2014-02-01 09:21:03 Ken James I'm feeling excited!
## 5 2014-02-01 09:21:26 James Ken What happens?
我们使用的模式可以比作一把钥匙。任何正则表达式应用的难点在于找到这把钥匙。一旦我们找到了它,我们就能打开门,并从混乱的文本中提取我们想要的信息。一般来说,找到这把钥匙的难度很大程度上取决于正例和反例之间的差异。如果差异非常明显,几个符号就能解决问题。如果差异微妙且涉及许多特殊情况,就像大多数现实世界问题一样,你需要更多的经验,更深入的思考,以及许多尝试和错误来找到解决方案。
通过前面提到的激励性例子,你现在应该已经掌握了正则表达式的概念。你不需要理解它是如何内部工作的,但熟悉相关的函数非常有用,无论是内置的还是某些包提供的。
如果你想了解更多,RegexOne (regexone.com/) 是一个非常好的地方,可以以交互式的方式学习基础知识。要了解更多具体的示例和完整的标识符集合,这个网站 (www.regular-expressions.info/) 是一个很好的参考。为了找到解决你问题的良好模式,你可以访问 RegExr (www.regexr.com/) 以在线交互式地测试你的模式。
摘要
在本章中,你学习了关于操作字符向量以及在不同日期/时间对象及其字符串表示之间进行转换的许多内置函数。你还了解了正则表达式的基本概念,这是一个非常强大的工具,用于检查和过滤字符串数据,并从原始文本中提取信息。
通过在本章和前几章中构建的词汇,我们现在能够处理基本的数据结构。在下一章中,你将学习一些用于处理数据的工具和技术。我们将从读取和写入简单的数据文件开始,生成各种类型的图形,对简单数据集应用基本的统计分析和数据挖掘模型,以及使用数值方法来解决根求解和优化问题。
第七章。处理数据
在前面的章节中,你学习了在 R 中工作的最常用的对象类型和函数。我们知道如何创建和修改向量、列表和数据框,如何定义我们自己的函数以及如何使用适当的表达式将我们心中的逻辑翻译成编辑器中的 R 代码。有了这些对象、函数和表达式,我们可以开始处理数据。
在本章中,我们将开始一段处理数据之旅,并涵盖以下主题:
-
在文件中读取和写入数据
-
使用绘图函数可视化数据
-
使用简单的统计模型和数据挖掘工具分析数据
读取和写入数据
在 R 中任何类型的数据分析的第一步是加载数据,即导入一个数据集到环境中。在此之前,我们必须确定数据文件的类型并选择合适的工具来读取数据。
在文件中读取和写入文本格式数据
在所有用于存储数据的文件类型中,可能最广泛使用的是 CSV。在一个典型的 CSV 文件中,第一行是列标题,每一行后续的行代表一个数据记录,列之间由逗号分隔。以下是一个以这种格式编写的学生记录示例:
Name,Gender,Age,Major
Ken,Male,24,Finance
Ashley,Female,25,Statistics
Jennifer,Female,23,Computer Science
通过 RStudio IDE 导入数据
RStudio 提供了一个交互式的方式来导入数据。你可以导航到工具 | 导入数据集 | 从本地文件,并选择一个本地文件,如.csv和.txt格式的文本文件。然后,你可以调整参数并预览结果数据框:

注意,只有当你打算将字符串列转换为因子时,才应该检查字符串作为因子。
文件导入器并非魔法,它将文件路径和选项转换为 R 代码。一旦你设置了数据导入参数并点击导入,它将执行对read.csv()的调用。使用这个交互式工具导入数据非常方便,并有助于你在第一次导入数据文件时避免许多错误。
使用内置函数导入数据
当你编写脚本时,你不能期望用户每次都与文件导入器交互。你可以将生成的代码复制到你的脚本中,这样每次运行脚本时它都会自动工作。因此,了解如何使用内置函数导入数据是有用的。
如前所述,最简单的内置函数是readLines(),它读取一个文本文件并返回一个字符向量作为多行:
readLines("data/persons.csv")
## [1] "Name,Gender,Age,Major"
## [2] "Ken,Male,24,Finance"
## [3] "Ashley,Female,25,Statistics"
## [4] "Jennifer,Female,23,Computer Science"
默认情况下,它将读取文件的所有行。要预览前两行,请运行以下代码:
readLines("data/persons.csv", n = 2)
## [1] "Name,Gender,Age,Major" "Ken,Male,24,Finance"
对于实际的数据导入,readLines()在大多数情况下太简单了。它通过读取字符串行而不是将它们解析成数据框来工作。如果你想导入类似于前面代码的 CSV 文件中的数据,直接调用read.csv():
persons1 <- read.csv("data/persons.csv", stringsAsFactors = FALSE)str(persons1)
## 'data.frame': 3 obs. of 4 variables:
## $ Name : chr "Ken" "Ashley" "Jennifer"
## $ Gender: chr "Male" "Female" "Female"
## $ Age : int 24 25 23
## $ Major : chr "Finance" "Statistics" "Computer Science"
注意,我们希望字符串值保持不变,因此在函数调用中设置 stringsAsFactors = FALSE 以避免将字符串转换为因子。
该函数提供了许多有用的参数来定制导入。例如,我们可以使用 colClasses 来显式指定列的类型,并使用 col.names 来替换数据文件中的原始列名:
persons2 <- read.csv("data/persons.csv", colClasses = c("character", "factor", "integer", "character"), col.names = c("name", "sex", "age", "major")) str(persons2)
## 'data.frame': 3 obs. of 4 variables:
## $ name : chr "Ken" "Ashley" "Jennifer"
## $ sex : Factor w/ 2 levels "Female","Male": 2 1 1
## $ age : int 24 25 23
## $ major: chr "Finance" "Statistics" "Computer Science"
注意,CSV 是分隔数据格式的一个特例。技术上讲,CSV 格式是一种使用逗号 (,) 分隔列和换行符分隔行的分隔数据格式。更普遍地说,任何字符都可以作为列分隔符和行分隔符。许多数据集以制表符分隔的格式存储,即它们使用制表符字符来分隔列。在这种情况下,您可能尝试使用基于此的更通用版本 read.table(),read.csv() 就是基于它实现的。
使用 readr 包导入数据
由于历史原因,read.* 函数存在一些不一致性,在某些情况下不太友好。readr 包是快速且一致地导入表格数据的良好选择。
要安装该包,请运行 install.packages("readr")。然后您可以使用一系列 read_* 函数导入表格数据:
persons3 <- readr::read_csv("data/persons.csv")str(persons3)
## Classes 'tbl_df', 'tbl' and 'data.frame': 3 obs. of 4 variables:
## $ Name : chr "Ken" "Ashley" "Jennifer"
## $ Gender: chr "Male" "Female" "Female"
## $ Age : int 24 25 23
## $ Major : chr "Finance" "Statistics" "Computer Science"
在这里,我们使用 readr::read_csv 而不是先使用 library(readr),然后直接调用 read_csv,因为它们的行为略有不同,容易混淆 read_csv 与内置的 read.csv:
此外,请注意,read_csv 的默认行为足够智能,可以处理大多数情况。为了与内置函数进行对比,让我们导入一个格式不规则的数据文件(data/persons.txt):
Name Gender Age Major
Ken Male 24 Finance
Ashley Female 25 Statistics
Jennifer Female 23 Computer Science
文件内容看起来非常标准且表格化,但每列之间的空格数量在行之间不等,这使得 read.table() 无法使用 sep = " ":
read.table("data/persons.txt", sep = " ")
## Error in scan(file, what, nmax, sep, dec, quote, skip, nlines, na.strings, : line 1 did not have 20 elements
如果您坚持使用 read.table() 导入数据,您可能会浪费大量时间试图找出控制行为的正确参数。然而,对于相同的输入,readr 中的 read_table 默认行为足够智能,因此可以帮助您节省时间:
readr::read_table("data/persons.txt")
## Name Gender Age Major
## 1 Ken Male 24 Finance
## 2 Ashley Female 25 Statistics
## 3 Jennifer Female 23 Computer Science
正因如此,我强烈建议您使用 readr 中的函数将表格数据导入 R。readr 中的函数速度快、智能且一致,并支持内置函数的功能,这些内置函数更容易使用。要了解更多关于 readr 包的信息,请访问 github.com/hadley/readr。
将数据框写入文件
数据分析中的一个典型程序是从数据源导入数据,转换数据,应用适当的工具和模型,最后创建一些新的数据以供决策存储。将数据写入文件的接口与读取数据的接口非常相似——我们使用 write.* 函数将数据框导出到文件。
例如,我们可以创建一个任意的数据框并将其存储在 CSV 文件中:
some_data <- data.frame(
id = 1:4,
grade = c("A", "A", "B", NA), width = c(1.51, 1.52, 1.46, NA),check_date = as.Date(c("2016-03-05", "2016-03-06", "2016-03-10", "2016-03-11")))some_data
## id grade width check_date
## 1 1 A 1.51 2016-03-05
## 2 2 A 1.52 2016-03-06
## 3 3 B 1.46 2016-03-10
## 4 4 <NA> NA 2016-03-11
write.csv(some_data, "data/some_data.csv")
要检查 CSV 文件是否正确保留了缺失值和日期,我们可以以纯文本格式读取输出文件:
cat(readLines("data/some_data.csv"), sep = "\n")
## "","id","grade","width","check_date"
## "1",1,"A",1.51,2016-03-05
## "2",2,"A",1.52,2016-03-06
## "3",3,"B",1.46,2016-03-10
## "4",4,NA,NA,2016-03-11
尽管数据是正确的,有时我们可能对存储此类数据有不同的标准。write.csv() 函数允许我们修改写入行为。从前面的输出中,我们可能会认为其中有一些不必要的组件。例如,我们通常不希望导出行名,因为它们似乎有点冗余,因为 id 已经完成了它的任务。我们不需要字符串值周围的引号。我们希望缺失值用 - 而不是 NA 表示。为了进行下一步,我们可以运行以下代码以以我们想要的特性和标准导出相同的数据框:
write.csv(some_data, "data/some_data.csv", quote =FALSE, na = "-", row.names = FALSE)
现在,输出数据是一个简化的 CSV 文件:
cat(readLines("data/some_data.csv"), sep = "\n")
## id,grade,width,check_date
## 1,A,1.51,2016-03-05
## 2,A,1.52,2016-03-06
## 3,B,1.46,2016-03-10
## 4,-,-,2016-03-11
我们可以使用 readr::read_csv() 导入具有自定义缺失值和日期列的此类 CSV 文件:
readr::read_csv("data/some_data.csv", na = "-")
## id grade width check_date
## 1 1 A 1.51 2016-03-05
## 2 2 A 1.52 2016-03-06
## 3 3 B 1.46 2016-03-10
## 4 4 <NA> NA 2016-03-11
注意,- 被正确转换为缺失值,日期列也被正确导入为日期对象:
## [1] TRUE
阅读和编写 Excel 工作表
文本格式数据(如 CSV)的一个导入优势是软件中立性,也就是说,你不需要依赖特定的软件来读取数据,文件可以直接由人类阅读。然而,其缺点也很明显——我们无法直接在文本编辑器中对表示在文本编辑器中的数据进行计算,因为内容是纯文本。
存储表格数据的另一种流行格式是 Excel 工作簿。Excel 工作簿包含一个或多个工作表。每个工作表是一个网格,你可以在其中填写文本和值来制作表格。有了表格,你可以在表格内、表格之间,甚至跨工作表轻松地进行计算。Microsoft Excel 是一款功能强大的软件,但它的数据格式(.xls 用于 Excel 97-2003 和 .xlsx 自 Excel 2007 以来)不能直接读取。
例如,data/prices.xlsx 是一个简单的 Excel 工作簿,如下面的截图所示:

虽然没有内置函数可以读取 Excel 工作簿,但有几个 R 包被设计用来与之配合工作。最简单的一个是 readxl (github.com/hadley/readxl),它使得从 Excel 工作簿的单个工作表中提取表格变得容易得多。要从 CRAN 安装此包,使用 install.package("readxl"):
readxl::read_excel("data/prices.xlsx")
## Date Price Growth
## 1 2016-03-01 85 NA
## 2 2016-03-02 88 0.03529412
## 3 2016-03-03 84 -0.04545455
## 4 2016-03-04 81 -0.03571429
## 5 2016-03-05 83 0.02469136
## 6 2016-03-06 87 0.04819277
从前面的数据框中可以看出,read_excel() 自动将 Excel 中的日期转换为 R 中的日期,并正确保留了 Growth 列中的缺失值。
另一个用于处理 Excel 工作簿的包是 openxlsx。此包可以读取、写入和编辑 XLSX 文件,比 readr 设计的功能更全面。要安装此包,运行 install.package("openxlsx")。
使用 openxlsx,我们可以调用 read.xlsx 从指定的工作簿中读取数据到数据框中,就像 readr::read_excel() 一样:
openxlsx::read.xlsx("data/prices.xlsx", detectDates = TRUE)
## Date Price Growth
## 1 2016-03-01 85 NA
## 2 2016-03-02 88 0.03529412
## 3 2016-03-03 84 -0.04545455
## 4 2016-03-04 81 -0.03571429
## 5 2016-03-05 83 0.02469136
## 6 2016-03-06 87 0.04819277
为了确保日期值被正确导入,我们需要指定 detectDates = TRUE;否则,日期将保留为数字,就像你可能尝试的那样。除了读取数据外,openxlsx 还能够创建包含现有数据框的工作簿:
openxlsx::write.xlsx(mtcars, "data/mtcars.xlsx")
该包支持更高级的功能,例如通过创建样式和插入图表来编辑现有工作簿,但这些功能超出了本书的范围。更多详情,请参阅该包的文档。
有其他包是为处理 Excel 工作簿而设计的。XLConnect (cran.r-project.org/web/packages/XLConnect) 是另一个跨平台的 Excel 连接器,它不依赖于现有的 Microsoft Excel 安装,但确实依赖于现有的 Java 运行时环境 (JRE)。RODBC (cran.r-project.org/web/packages/RODBC) 是一个更通用的数据库连接器,能够在 Windows 上安装了适当的 ODBC 驱动程序后连接到 Access 数据库和 Excel 工作簿。由于这两个包有更重的依赖性,我们不会在本节中介绍它们。
读取和写入原生数据文件
在前面的章节中,我们介绍了 CSV 文件和 Excel 工作簿的读取器和写入器函数。这些是非原生数据格式,即 R 与原始数据对象和输出文件之间存在差距。
例如,如果我们将具有许多不同类型列的数据框导出到 CSV 文件中,列类型的信息将被丢弃。无论列是数值型、字符串型还是日期型,它总是以文本格式表示。这当然使得人类可以直接从输出文件中读取数据变得更容易,但我们将不得不依赖于计算机如何猜测每列的类型。换句话说,有时读取器函数很难将 CSV 格式的数据恢复成与原始数据框完全相同的数据,因为写入过程会丢弃列类型以换取可移植性(例如,其他软件也可以读取数据)。
如果你不在乎可移植性,并且仅使用 R 处理数据,你可以使用原生格式来读取和写入数据。你不能再使用任意文本编辑器来读取数据,也不能从其他软件中读取数据,但可以高效地读写单个对象,甚至整个环境,而不会丢失数据。换句话说,原生格式允许你将对象保存到文件中,并恢复完全相同的数据,无需担心诸如缺失值的符号、列的类型、类和属性等问题。
以原生格式读取和写入单个对象
与原生数据格式相关的有两组函数。一组是设计用来将单个对象写入 RDS 文件或从 RDS 文件中读取单个对象,这是一种以序列化形式存储单个 R 对象的文件格式。另一组与多个 R 对象一起工作,我们将在下一节中介绍。在以下示例中,我们将 some_data 写入 RDS 文件,并从同一文件中读取它,以查看两个数据框是否完全相同。
首先,我们使用 saveRDS 将 some_data 保存到 data/some_data.rds:
saveRDS(some_data, "data/some_data.rds")
然后,我们从同一文件中读取数据,并将数据框存储在 some_data2 中:
some_data2 <- readRDS("data/some_data.rds")
最后,我们使用 identical() 测试两个数据框是否完全相同:
identical(some_data, some_data2)
## [1] TRUE
两个数据框完全相同,正如预期的那样。
原生格式有两个显著优点:空间效率和时间效率。在以下示例中,我们将创建一个包含 200,000 行随机数据的大型数据框。然后,我们分别计时将数据框保存到 CSV 文件和 RDS 文件的过程:
rows <- 200000
large_data <- data.frame(id = 1:rows, x = rnorm(rows), y = rnorm(rows))system.time(write.csv(large_data, "data/large_data.csv"))
## user system elapsed
## 1.33 0.06 1.41
system.time(saveRDS(large_data, "data/large_data.rds"))
## user system elapsed
## 0.23 0.03 0.26
很明显,saveRDS 的写入效率比 write.csv 高得多。
然后,我们使用 file.info() 来查看两个输出文件的大小:
fileinfo <- file.info("data/large_data.csv", "data/large_data.rds")fileinfo[, "size", drop = FALSE]
## size
## data/large_data.csv 10442030
## data/large_data.rds 3498284
两个文件大小之间的差距很大——CSV 文件的大小几乎是 RDS 文件的三倍,这表明原生格式具有更高的存储或空间效率。
最后,我们读取 CSV 和 RDS 文件,看看这两种格式消耗了多少时间。为了读取 CSV 文件,我们使用了内置函数 read.csv 和由 readr 包提供的更快实现 read_csv():
system.time(read.csv("data/large_data.csv"))
## user system elapsed
## 1.46 0.07 1.53
system.time(readr::read_csv("data/large_data.csv"))
## user system elapsed
## 0.17 0.01 0.19
在这种情况下,看到 read_csv() 的速度几乎比内置的 read.csv() 快四倍可能会令人惊讶。但使用原生格式,这两个 CSV 读取函数的性能是不可比的:
system.time(readRDS("data/large_data.rds"))
## user system elapsed
## 0.03 0.00 0.03
原生格式显然具有更高的写入效率。
此外,saveRDS 和 readRDS 不仅与数据框一起工作,还可以与任何 R 对象一起工作。例如,我们创建了一个包含缺失值的数值向量和具有嵌套结构的列表。然后,我们将它们分别保存到单独的 RDS 文件中:
nums <- c(1.5, 2.5, NA, 3)
list1 <- list(x = c(1, 2, 3),
y = list(a =c("a", "b"),
b = c(NA, 1, 2.5)))
saveRDS(nums, "data/nums.rds")
saveRDS(list1, "data/list1.rds")
现在,我们读取 RDS 文件,这两个对象分别被完全恢复:
readRDS("data/nums.rds")
## [1] 1.5 2.5 NA 3.0
readRDS("data/list1.rds")
## $x
## [1] 1 2 3
##
## $y
## $y$a
## [1] "a" "b"
##
## $y$b
## [1] NA 1.0 2.5
保存和恢复工作环境
当 RDS 格式用于存储单个 R 对象时,RData 格式用于存储多个 R 对象。我们可以调用 save() 将 some_data、nums 和 list1 一起保存到单个 RData 文件中:
save(some_data, nums, list1, file = "data/bundle1.RData")
为了验证三个对象是否已存储并可恢复,我们首先将它们移除,然后调用 load() 从文件中恢复对象:
rm(some_data, nums, list1)
load("data/bundle1.RData")
现在,三个对象已完全恢复:
some_data
## id grade width check_date
## 1 1 A 1.51 2016-03-05
## 2 2 A 1.52 2016-03-06
## 3 3 B 1.46 2016-03-10
## 4 4 <NA> NA 2016-03-11
nums
## [1] 1.5 2.5 NA 3.0
list1
## $x
## [1] 1 2 3
##
## $y
## $y$a
## [1] "a" "b"
##
## $y$b
## [1] NA 1.0 2.5
## [1] TRUE TRUE TRUE TRUE TRUE TRUE
加载内置数据集
在 R 中,已经存在大量内置数据集。它们可以轻松加载并投入使用,主要用于演示和测试目的。内置数据集大多是数据框,并附带详细说明。
例如,爱丽丝和 mtcars 可能是 R 中最著名的两个数据集之一。你可以使用 ? iris 和 ? mtcars 分别读取数据集的描述。通常,描述非常具体——它不仅告诉你数据中有什么,如何收集和格式化,以及每一列的含义,还提供了相关的来源和参考。阅读描述有助于你更多地了解数据集。
在内置数据集上对数据分析工具进行实验非常方便,因为这些数据集一旦 R 准备好就可以立即使用。例如,你可以直接使用爱丽丝和 mtcars,而无需从某处显式加载它们。
以下是爱丽丝的前六行视图:
head(iris)
## Sepal.Length Sepal.Width Petal.Length Petal.Width Species
## 1 5.1 3.5 1.4 0.2 setosa
## 2 4.9 3.0 1.4 0.2 setosa
## 3 4.7 3.2 1.3 0.2 setosa
## 4 4.6 3.1 1.5 0.2 setosa
## 5 5.0 3.6 1.4 0.2 setosa
## 6 5.4 3.9 1.7 0.4 setosa
以下代码显示了其结构:
str(iris)
## 'data.frame': 150 obs. of 5 variables:
## $ Sepal.Length: num 5.1 4.9 4.7 4.6 5 5.4 4.6 5 4.4 4.9 ...
## $ Sepal.Width : num 3.5 3 3.2 3.1 3.6 3.9 3.4 3.4 2.9 3.1 ...
## $ Petal.Length: num 1.4 1.4 1.3 1.5 1.4 1.7 1.4 1.5 1.4 1.5 ...
## $ Petal.Width : num 0.2 0.2 0.2 0.2 0.2 0.4 0.3 0.2 0.2 0.1 ...
## $ Species : Factor w/ 3 levels "setosa","versicolor",..: 1 1 1 1 1 1 1 1 1 1 ...
爱丽丝的结构很简单。你可以通过打印 iris 来在控制台查看整个数据框,或者在一个网格面板或窗口中使用 View(iris)。
要查看 mtcars 的前六行并查看其结构:
head(mtcars)
## mpg cyl disp hp drat wt qsec vs am
## Mazda RX4 21.0 6 160 110 3.90 2.620 16.46 0 1
## Mazda RX4 Wag 21.0 6 160 110 3.90 2.875 17.02 0 1
## Datsun 710 22.8 4 108 93 3.85 2.320 18.61 1 1
## Hornet 4 Drive 21.4 6 258 110 3.08 3.215 19.44 1 0
## Hornet Sportabout 18.7 8 360 175 3.15 3.440 17.02 0 0
## Valiant 18.1 6 225 105 2.76 3.460 20.22 1 0
## gear carb
## Mazda RX4 4 4
## Mazda RX4 Wag 4 4
## Datsun 710 4 1
## Hornet 4 Drive 3 1
## Hornet Sportabout 3 2
## Valiant 3 1
str(mtcars)
## 'data.frame': 32 obs. of 11 variables:
## $ mpg : num 21 21 22.8 21.4 18.7 18.1 14.3 24.4 22.8 19.2 ...
## $ cyl : num 6 6 4 6 8 6 8 4 4 6 ...
## $ disp: num 160 160 108 258 360 ...
## $ hp : num 110 110 93 110 175 105 245 62 95 123 ...
## $ drat: num 3.9 3.9 3.85 3.08 3.15 2.76 3.21 3.69 3.92 3.92 ...
## $ wt : num 2.62 2.88 2.32 3.21 3.44 ...
## $ qsec: num 16.5 17 18.6 19.4 17 ...
## $ vs : num 0 0 1 1 0 1 0 1 1 1 ...
## $ am : num 1 1 1 0 0 0 0 0 0 0 ...
## $ gear: num 4 4 4 3 3 3 3 4 4 4 ...
## $ carb: num 4 4 1 1 2 1 4 2 2 4 ...
如你所见,爱丽丝和 mtcars 都很小且简单。实际上,大多数内置数据集只有几十行或几百行,以及几列。它们通常用于演示特定数据分析工具的使用。
如果你想要实验更大的数据,你可以转向一些附带数据集的 R 包。例如,最著名的数据可视化包 ggplot2 提供了一个名为 diamonds 的数据集,其中包含大量钻石的价格和其他属性。使用 ?ggplot2::diamonds 来了解数据规范。如果你还没有安装该包,运行 install.package("ggplot2")。
要在包中加载数据,我们可以使用 data():
data("diamonds", package = "ggplot2")dim(diamonds)
## [1] 53940 10
输出显示 diamonds 有 53940 行和 10 列。这里是一个预览:
head(diamonds)
## carat cut color clarity depth table price x y
## 1 0.23 Ideal E SI2 61.5 55 326 3.95 3.98
## 2 0.21 Premium E SI1 59.8 61 326 3.89 3.84
## 3 0.23 Good E VS1 56.9 65 327 4.05 4.07
## 4 0.29 Premium I VS2 62.4 58 334 4.20 4.23
## 5 0.31 Good J SI2 63.3 58 335 4.34 4.35
## 6 0.24 Very Good J VVS2 62.8 57 336 3.94 3.96
## z
## 1 2.43
## 2 2.31
## 3 2.31
## 4 2.63
## 5 2.75
## 6 2.48
除了提供有用函数的包之外,还有一些只提供数据集的包。例如,nycflights13 和 babynames 每个都只包含几个数据集。在其中加载数据的方法与前面的例子完全相同。要安装这两个包,运行 install.package(c("nycflights13", "babynames"))。
在接下来的几节中,我们将使用这些数据集来演示基本的图形工具和数据分析工具。
数据可视化
在上一节中,我们介绍了一些导入数据的函数,这是大多数数据分析的第一步。通常,在将数据倒入模型之前查看数据是一个好的做法,这就是我们下一步要做的。原因很简单——不同的模型有不同的优势,而且没有哪个模型是所有情况下都最佳的选择,因为它们有不同的假设集。在没有检查数据是否符合其假设的情况下任意应用模型通常会导致误导性的结论。
选择一个模型并进行此类检查的一种初始方法是通过观察数据的边界和模式来直观地检查数据。换句话说,我们首先需要可视化数据。在本节中,你将学习基本图形函数来生成简单的图表以可视化给定的数据集。
我们将使用nycflights13和babynames包中的数据集。如果您还没有安装它们,请运行以下代码:
install.package(c("nycflights13", "babynames"))
创建散点图
在 R 中,可视化数据的基本函数是plot()。如果我们仅仅向plot()提供一个数值或整数向量,它将根据索引生成值的散点图。例如,以下代码创建了一个按递增顺序排列的 10 个点的散点图:
plot(1:10)
生成的图表如下:

我们可以通过生成两个线性相关的随机数值向量来创建一个更逼真的散点图:
x <- rnorm(100)
y <- 2 * x + rnorm(100)
plot(x, y)
生成的图表如下:

自定义图表元素
在图表中,有许多可以自定义的图表元素。最常见的是标题(主标题或title())、x轴的标签(xlab)、y轴的标签(ylab)、x轴的范围(xlim)和y轴的范围(ylim):
plot(x, y,
main = "Linearly correlated random numbers",
xlab = "x", ylab = "2x + noise",
xlim = c(-4, 4), ylim = c(-4, 4))
生成的图表如下:

图表标题可以通过main参数或单独的title()函数调用来指定。因此,前面的代码等同于以下代码:
plot(x, y,
xlim = c(-4, 4), ylim = c(-4, 4),
xlab = "x", ylab = "2x + noise")
title("Linearly correlated random numbers")
自定义点样式
散点图默认的点样式是圆圈。通过指定pch参数(绘图字符),我们可以更改点样式。共有 26 种点样式可供选择:
plot(0:25, 0:25, pch = 0:25,
xlim = c(-1, 26), ylim = c(-1, 26),
main = "Point styles (pch)")
text(0:25+1, 0:25, 0:25)

上述代码生成了一个包含所有可用点样式及其相应pch编号的散点图。首先,plot()创建了一个简单的散点图,然后text()在每个点的右侧打印pch编号。
与许多其他内置函数一样,plot()在pch和几个其他参数上进行了向量化。这使得我们可以自定义散点图中每个点的样式。例如,最简单的情况是我们通过设置pch = 16为所有点使用一个非默认的点样式:
x <- rnorm(100)
y <- 2 * x + rnorm(100)
plot(x, y, pch = 16,
main = "Scatter plot with customized point style")
生成的图表如下:

有时候,我们需要通过逻辑条件来区分两组点。知道pch是向量化的,我们可以使用ifelse()函数通过检查一个点是否满足条件来指定每个观察值的点样式。在下面的例子中,我们希望将pch = 16应用于满足x * y > 1条件的点,否则使用pch = 1:
plot(x, y,
pch = ifelse(x * y > 1, 16, 1),
main = "Scatter plot with conditional point styles")
生成的图表如下:

我们还可以使用 plot() 和 points() 在两个共享相同 x 轴的不同数据集中绘制点。在先前的例子中,我们生成了一个正态分布的随机向量 x 和一个线性相关的随机向量 y。现在,我们生成另一个与 x 具有非线性关系的随机向量 z,并将 y 和 z 分别与 x 绘制,但使用不同的点样式:
z <- sqrt(1 + x ^ 2) + rnorm(100)
plot(x, y, pch = 1,
xlim = range(x), ylim = range(y, z),
xlab = "x", ylab = "value")
points(x, z, pch = 17)
title("Scatter plot with two series")
生成的图形如下:

在我们生成 z 后,首先创建 x 和 y 的图形,然后添加另一组具有不同 pch 的点 z。请注意,如果我们不指定 ylim = range(y, z),绘图构建器将只考虑 y 的范围,因此 y 轴的范围可能比 z 的范围窄。不幸的是,points() 并不会自动扩展由 plot() 创建的轴,因此任何超出轴范围的点将消失。前面的代码设置了一个合适的 y 轴范围,以便在绘图区域内显示 y 和 z 中的所有点。
自定义点颜色
如果图形不受灰度打印的限制,我们还可以通过设置 plot() 的列来使用不同的点颜色:
plot(x, y, pch = 16, col = "blue",
main = "Scatter plot with blue points")
生成的图形如下:

就像 pch 一样,col 也是一个向量化参数。使用相同的方法,我们可以根据是否满足某个条件,将不同的颜色应用到不同的点上,将它们分为两个不同的类别:
plot(x, y, pch = 16,
col = ifelse(y >= mean(y), "red", "green"),
main = "Scatter plot with conditional colors")
生成的图形如下:

注意,如果散点图以灰度打印,颜色只能以不同灰度的强度来查看。
此外,我们还可以再次使用 plot() 和 points(),但使用不同的 col 来区分不同组别的点:
plot(x, y, col = "blue", pch = 0,
xlim = range(x), ylim = range(y, z),
xlab = "x", ylab = "value")
points(x, z, col = "red", pch = 1)
title("Scatter plot with two series")
生成的图形如下:

R 支持常用的颜色名称以及许多其他颜色(总共 657 种)。通过调用 colors() 获取 R 支持的所有颜色列表。
创建线图
对于时间序列数据,线图更有助于展示随时间的变化趋势和变化。要创建线图,我们只需要在调用 plot() 时设置 type = "l":
t <- 1:50
y <- 3 * sin(t * pi / 60) + rnorm(t)
plot(t, y, type = "l",
main = "Simple line plot")
生成的图形如下:

自定义线型和宽度
就像散点图的 pch 一样,lty 用于指定线图的线型。以下展示了 R 支持的六种线型预览:
lty_values <- 1:6
plot(lty_values, type = "n", axes = FALSE, ann = FALSE)
abline(h =lty_values, lty = lty_values, lwd = 2
mtext(lty_values, side = 2, at = lty_values)
title("Line types (lty)")
生成的图形如下:

前面的代码创建了一个带有type = "n"的空画布,具有适当的坐标轴范围,并关闭了坐标轴,另一个标签elements.abline()用于绘制具有不同线型但相同线宽(lwd = 2)的水平线。mtext()函数用于在边缘绘制文本。请注意,abline()和mtext()在它们的参数上是向量化的,因此我们不需要使用for循环依次绘制每条线和边缘文本。
以下示例演示了abline()如何有助于在图表中绘制辅助线。首先,我们创建了一个y与时间t的线图,这是我们之前创建第一个线图时定义的。假设我们还想让图表显示y的均值和范围,以及最大值和最小值出现的时间。使用abline(),我们可以轻松地用不同类型和颜色的线绘制这些辅助线,以避免歧义:
plot(t, y, type = "l", lwd = 2)
abline(h = mean(y), lty = 2, col = "blue")
abline(h = range(y), lty = 3, col = "red")
abline(v = t[c(which.min(y), which.max(y))], lty = 3, col = "darkgray")
title("Line plot with auxiliary lines")
生成的图表如下:

多周期折线图绘制
另一种混合不同线型的折线图是多周期折线图。典型形式是第一个周期是历史数据,第二个周期是预测数据。假设y的第一个周期包含前 40 个观测值,其余点是基于历史数据的预测。我们希望用实线表示历史数据,用虚线表示预测。在这里,我们绘制第一个周期的数据,并为图表的第二周期添加虚线lines()。请注意,lines()对于折线图就像points()对于散点图:
p <- 40
plot(t[t <= p], y[t <= p], type = "l",
xlim = range(t), xlab = "t")
lines(t[t >= p], y[t >= p], lty = 2)
title("Simple line plot with two periods")
生成的图表如下:

使用点和线绘制折线图
有时,在同一张图表中绘制线和点很有用,以强调观测值是离散的,或者简单地使图表更清晰。方法是简单的,只需绘制一个折线图,并将相同数据的points()再次添加到图表中:
plot(y, type = "l")
points(y, pch = 16)
title("Lines with points")
生成的图表如下所示:

完成此操作的另一种方法是首先使用plot()函数绘制散点图,然后使用相同数据的lines()函数再次将线添加到图表中。因此,以下代码应产生与上一个示例完全相同的图形:
plot(y, pch = 16)
lines(y)
title("Lines with points")
绘制带有图例的多序列图表
一个完整的多序列图表应包括由线和点表示的多个序列,以及一个图例来说明图表中的序列。
以下代码随机生成两个时间序列y和z,以及时间x,并将这些数据组合在一起创建图表:
x <- 1:30
y <- 2 * x + 6 * rnorm(30)
z <- 3 * sqrt(x) + 8 * rnorm(30)
plot(x, y, type = "l",
ylim = range(y, z), col = "black")
points(y, pch = 15)
lines(z, lty = 2, col = "blue")
points(z, pch = 16, col = "blue")
title ("Plot of two series")
legend("topleft",
legend = c("y", "z"),
col = c("black", "blue"),
lty = c(1, 2), pch = c(15, 16),
cex = 0.8, x.intersp = 0.5, y.intersp = 0.8)
生成的图表如下:

之前的代码使用 plot() 创建 y 的线点图,并添加了 z 的 lines() 和 points()。最后,我们在左上角添加了一个 legend() 来展示 y 和 z 的线条和点样式,注意 cex 用于调整图例的字体大小,而 x.intersp 和 y.intersp 用于对图例进行细微调整。
另一种有用的折线图类型是阶梯线图。我们在 plot() 和 lines() 中使用 type = "s" 来创建阶梯线图:
plot(x, y, type = "s",
main = "A simple step plot")
生成的图表如下:

创建条形图
在前面的章节中,你学习了如何创建散点图和折线图。还有一些其他类型的图表很有用,值得提及。条形图是最常用的之一。条形图的高度可以在不同类别之间进行定量的对比。
我们可以创建的最简单的条形图如下。在这里,我们使用 barplot() 而不是 plot():
barplot(1:10, names.arg = LETTERS[1:10])
生成的图表如下:

如果数值向量有名称,则名称将自动成为 x 轴上的名称。因此,以下代码将产生与上一个图表完全相同的条形图:
ints <- 1:10
names(ints) <- LETTERS[1:10]
barplot(ints)
制作条形图看起来很简单。现在我们有了 nycflights13 中的航班数据集,我们可以创建记录中最常飞行的前八个承运人的条形图:
data("flights", package = "nycflights13")
carriers <- table(flights$carrier)
carriers
##
## 9E AA AS B6 DL EV F9 FL HA MQ
## 18460 32729 714 54635 48110 54173 685 3260 342 26397
## OO UA US VX WN YV
## 32 58665 20536 5162 12275 601
在前面的代码中,table() 用于计算每个承运人在记录中的航班数量:
sorted_carriers <- sort(carriers, decreasing = TRUE)
sorted_carriers
##
## UA B6 EV DL AA MQ US 9E WN VX
## 58665 54635 54173 48110 32729 26397 20536 18460 12275 5162
## FL AS F9 YV HA OO
## 3260 714 685 601 342 32
如前述代码所示,承运人按降序排列。我们可以从表中取出前 8 个元素,并制作一个条形图:
barplot(head(sorted_carriers, 8),
ylim = c(0, max(sorted_carriers) * 1.1),
xlab = "Carrier", ylab = "Flights",
main ="Top 8 carriers with the most flights in record")
生成的图表如下:

创建饼图
另一种有用的图表是饼图。创建饼图的函数 pie() 的工作方式与 barplot() 类似。它使用带有标签的数值向量;它也可以直接与命名数值向量一起使用。以下是一个简单的示例:
grades <- c(A = 2, B = 10, C = 12, D = 8)
pie(grades, main = "Grades", radius = 1)
生成的图表如下:

创建直方图和密度图
之前,你学习了如何创建几种不同类型的图表。散点图和折线图是数据集中观察的直接展示。条形图和饼图通常用于展示不同类别数据点的粗略总结。
图表有两个限制:散点图和折线图传达了过多的信息,难以从中得出见解,而条形图和饼图则丢失了过多的信息,因此使用这些图表时也难以自信地得出结论。
直方图显示了数值向量的分布,它总结了数据中的信息,而没有丢失太多,因此更容易使用。以下示例演示了如何使用 hist() 生成正态分布的随机数值向量的直方图和正态分布的密度函数:
random_normal <- norm(10000)
hist(random_normal)
生成的图表如下:

默认情况下,直方图的 y 轴是数据中值的频率。我们可以验证直方图与从 random_normal 生成的标准正态分布非常接近。为了叠加标准正态分布概率密度函数的曲线 dnorm(),我们需要确保直方图的 y 轴是概率,并且曲线要添加到直方图上:
hist(random_normal, probability = TRUE, col = "lightgray")
curve(dnorm, add = TRUE, lwd = 2, col ="blue")
生成的图表如下:
生成的图表如下:
现在,让我们绘制飞行中飞机速度的直方图。基本上,飞机在旅行中的平均速度是旅行距离(distance)除以飞行时间(air_time):
flight_speed <- flights$distance / flights$air_time
hist(flight_speed, main = "Histogram of flight speed")

生成的图表如下:
直方图似乎与正态分布略有不同。在这种情况下,我们使用 density() 来估计速度的经验分布,从中绘制出平滑的概率分布曲线,并添加一条垂直线来指示所有观察值的全局平均值:
plot(density(flight_speed, from = 2, na.rm = TRUE),
main ="Empirical distribution of flight speed")
abline(v = mean(flight_speed, na.rm = TRUE),
col = "blue", lty = 2)
生成的图表如下:

就像第一个直方图和曲线示例一样,我们可以将两个图形结合起来,以获得更好的数据视图:
hist(flight_speed,
probability = TRUE, ylim = c(0, 0.5),
main ="Histogram and empirical distribution of flight speed",
border ="gray", col = "lightgray")
lines(density(flight_speed, from = 2, na.rm = TRUE),
col ="darkgray", lwd = 2)
abline(v = mean(flight_speed, na.rm = TRUE),
col ="blue", lty =2)
生成的图表如下:

创建箱线图
直方图和密度图是展示数据分布的两种方式。通常,我们只需要几个关键分位数就能对整个分布有一个印象。箱线图(或箱线图)是一种简单的方法来做这件事。对于随机生成的数值向量,我们可以调用 boxplot() 来绘制箱线图:
x <- rnorm(1000)
boxplot(x)
生成的图表如下:

箱线图包含几个组成部分,用于显示数据的临界四分位数水平和异常值。以下图像清楚地解释了箱线图的意义:

以下代码绘制了每个航空公司的飞行速度箱线图。一张图表中将有 16 个箱子,这使得粗略比较不同航空公司的分布变得更容易。为了进行,我们使用公式 distance / air_time ~carrier 来表示 y 轴表示从 distance / air_time 计算出的飞行速度,而 x 轴表示航空公司。通过这种表示,我们得到以下箱线图:
boxplot(distance / air_time ~ carrier, data =flights,
main = "Box plot of flight speed by carrier")
生成的图表如下:

注意,我们在boxplot()中使用创建图形的公式界面。在这里,distance / air_time ~ carrier基本上意味着y轴应该表示distance / air_time的值,即飞行速度,而x轴应该表示不同的运营商。data = flights告诉boxplot()在哪里找到我们指定的公式中的符号。因此,飞行速度的箱线图被创建并按运营商分组。
数据可视化和分析公式界面非常表达性和强大。在下一节中,我们将介绍一些基本工具和模型来分析数据。实现这些工具和模型的函数背后不仅包含算法,还有一个用户友好的界面(公式),以便更容易地指定模型拟合的关系。
还有其他专门针对数据可视化的包。一个很好的例子是ggplot2,它实现了一种非常强大的图形语法,用于创建、组合和自定义不同类型的图表。然而,这超出了本书的范围。要了解更多信息,我建议您阅读 Hadley Wickham 的《ggplot2: Elegant Graphics for Data Analysis》。
数据分析
在实际数据分析中,大部分时间都花在数据清洗上,即过滤和转换原始数据(或原始数据)到一个更容易分析的形式。过滤和转换过程也称为数据处理。我们将专门用一章来介绍这个主题。
在本节中,我们直接假设数据已经准备好进行分析。我们不会深入探讨模型,而是应用一些简单的模型,让您了解如何用数据拟合模型,如何与拟合的模型交互,以及如何将拟合的模型应用于预测。
拟合线性模型
R 中最简单的模型是线性模型,即我们使用线性函数来描述在一定假设下两个随机变量之间的关系。在以下示例中,我们将创建一个将x映射到3 + 2 * x的线性函数。然后我们生成一个正态分布的随机数值向量x,并通过f(x)加上一些独立噪声生成y:
f <- function(x) 3 + 2 * x
x <- rnorm(100)
y <- f(x) + 0.5 * rnorm(100)
如果我们假装不知道y是如何由x生成的,我们能否使用线性模型来恢复它们之间的关系,即恢复线性函数的系数?以下代码使用slm()拟合x和y的线性模型。请注意,公式y ~ x是向m()传达线性回归是因变量y和单个回归变量x之间关系的一种可访问表示:
model1 <- lm(y ~ x)model1
##
## Call:
## lm(formula = y ~ x)
##
## Coefficients:
## (Intercept) x
## 2.969 1.972
真实的系数是 3(截距)和 2(斜率),并且使用样本数据x和y,拟合的模型具有系数2.9692146(截距)和1.9716588(斜率),这两个系数非常接近真实的系数。
我们将模型存储在model1中。要访问模型的系数,我们可以使用以下代码:
coef(model1)
## (Intercept) x
## 2.969215 1.971659
或者,我们可以使用model1$系数,因为model1本质上是一个列表。
然后,我们可以调用summary()来了解更多关于线性模型的统计特性:
summary(model1)
##
## Call:
## lm(formula = y ~ x)
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.96258 -0.31646 -0.04893 0.34962 1.08491
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 2.96921 0.04782 62.1 <2e-16 ***
## x 1.97166 0.05216 37.8 <2e-16 ***
## ---
## Signif. codes:
## 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.476 on 98 degrees of freedom
## Multiple R-squared: 0.9358, Adjusted R-squared: 0.9352
## F-statistic: 1429 on 1 and 98 DF, p-value: < 2.2e-16
要解释摘要,你最好复习一下统计学教材中关于线性回归的章节。下面的图表将数据和拟合模型放在一起:
plot(x, y, main = "A simple linear regression")
abline(coef(model1), col = "blue")
生成的图表如下所示:

在前面的代码中,我们直接向abline()提供一个包含估计回归系数的两个元素的数值向量,因此它智能地绘制了回归线。
然后,我们可以调用predict()来使用拟合的模型进行预测。当x = -1和x = 0.5时,预测y并带有标准误差,请运行以下代码:
predict(model1, list(x = c(-1, 0.5)), se.fit = TRUE)
## $fit
## 1 2
## 0.9975559 3.9550440
##
## $se.fit
## 1 2
## 0.06730363 0.05661319
##
## $df
## [1] 98
##
## $residual.scale
## [1] 0.4759621
预测结果是y的预测值列表($fit)、拟合值的标准误差($se.fit)、自由度($df)和$residual.scale。
现在你已经了解了如何根据一些数据拟合线性模型的基本方法,是时候看看一些现实世界的数据了。在下面的例子中,我们尝试使用不同复杂度的线性模型来预测航班的飞行时间。对于预测飞行时间来说,最明显的有帮助的变量是距离。
首先,我们加载数据集并绘制距离和飞行时间的散点图。我们使用pch = "."来使每个点非常小,因为数据集中的记录数量很大:
data("flights", package = "nycflights13")
plot(air_time ~ distance, data = flights,
pch = ".",
main = "flight speed plot")
生成的图表如下:

图表清楚地表明距离和飞行时间之间存在正相关关系。因此,在两个变量之间拟合线性模型是合理的。
在将整个数据集放入线性模型之前,我们将数据集分为两部分:一个训练集和一个测试集。划分数据集的目的是我们不仅想要进行样本评估,还想要进行模型的外部样本评估。更具体地说,我们将 75%的数据放入训练集,剩余的 25%数据放入测试集。在下面的代码中,我们使用sample()从原始数据中抽取 75%的随机样本,并使用setdiff()获取剩余的记录:
rows <- nrow(flights)
rows_id <- 1:rows
sample_id <- sample(rows_id, rows * 0.75, replace = FALSE)
flights_train <- flights[sample_id,]
flights_test <- flights[setdiff (rows_id, sample_id), ]
注意,setdiff(rows_id, sample_id)返回rows_id中但不在sample_id中的索引。
现在flights_train是训练集,而flights_test是测试集。有了划分的数据集,模型拟合和模型评估的过程就变得简单直接。首先,使用训练集来拟合模型,然后进行样本预测以查看样本误差的大小:
model2 <- lm(air_time ~ distance, data = flights_train)
predict2_train <- predict(model2, flights_train)
error2_train <- flights_train$air_time - predict2_train
为了评估误差的大小,我们定义了一个名为evaluate_error()的函数来计算平均绝对误差和误差的标准差:
evaluate_error <- function(x) {
c(abs_err = mean(abs(x), na.rm = TRUE),
std_dev = sd(x, na.rm = TRUE))
}
使用这个函数,我们可以评估model2的样本预测误差:
evaluate_error(error2_train)
## abs_err std_dev
## 9.413836 12.763126
绝对平均误差表明,平均而言,预测值与正确值在绝对值上偏差约为 9.45 分钟,标准差为 12.8 分钟。
然后,我们通过使用模型对测试集进行预测来执行一个简单的样本外评估:
predict2_test <- predict (model2, flights_test)
error2_test <- flights_test$air_time - predict2_test
evaluate_error(error2_test)
## abs_err std_dev
## 9.482135 12.838225
预测结果是一个包含预测值的数值向量。绝对平均误差和误差的标准差略有上升,这表明样本外预测的质量没有显著变差,表明model2似乎不是过拟合的结果。
由于model2只有一个回归器,即distance,因此考虑是否更多的回归器会提高预测质量是自然的。以下代码拟合了一个新的线性模型,不仅包括距离,还包括carrier、month和出发时间(dep_time)作为回归器:
model3 <- lm(air_time ~ carrier + distance + month + dep_time,
data = flights_train)predict3_train
<- predict(model3, flights_train)
error3_train <- flights_train$air_time - predict3_train
evaluate_error(error3_train)
## abs_err std_dev
## 9.312961 12.626790
样本内误差在幅度和变化上都有所降低:
predict3_test <- predict(model3, flights_test)
error3_test <- flights_test$air_time - predict3_test
evaluate_error(error3_test)
## abs_err std_dev
## 9.38309 12.70168
此外,样本外误差看起来略好于model2。为了比较在将新回归器添加到线性模型之前和之后样本外误差的分布,我们叠加了两个密度曲线:
plot(density(error2_test, na.rm = TRUE),
main = "Empirical distributions of out-of-sample errors")
lines(density(error3_test, na.rm = TRUE), lty = 2)
legend("topright", legend = c("model2", "model3"),
lty = c(1, 2), cex = 0.8,
x.intersp = 0.6, y.intersp = 0.6)
生成的图表如下:

从前面的密度图中可以看出,从model2到model3的改进非常小,几乎无法察觉,也就是说,没有任何显著的改进。
拟合回归树
在本节中,我们尝试使用另一个模型来拟合数据。该模型被称为回归树 (en.wikipedia.org/wiki/Decision_tree_learning),是机器学习模型之一。它不是简单的线性回归,而是使用决策树来拟合数据。
假设我们想要根据太阳辐射(Solar.R)、平均风速(Wind)和最高日温度(Temp)预测每日空气质量(Ozone),内置数据集airquality作为训练集。以下图表说明了拟合回归树的工作原理:

在树中,每个圆圈代表一个问题,有两个可能的答案。为了预测Ozone,我们需要从上到下沿着树提问,每个观测值位于底部的某个案例中。每个底部的节点都有一个与其他节点不同的分布,这通过箱线图表示。每个箱子中的中位数或平均值应该是每个案例的合理预测。
有许多软件包实现了决策树学习算法。在本节中,我们使用一个名为 party (cran.r-project.org/web/packages/party) 的简单软件包。如果您还没有安装它,请运行 install.package("party")。
现在我们使用相同的公式和数据来训练一个回归树模型。请注意,我们选取了没有air_time缺失值的数据子集,因为ctree不接受响应变量中的缺失值:
model4 <- party::ctree(air_time ~ distance + month + dep_time,
data = subset(flights_train, !is.na(air_time)))
predict4_train <- predict(model4, flights_train)
error4_train <- flights_train$air_time - predict4_train[, 1]
evaluate_error(error4_train)
## abs_err std_dev
## 7.418982 10.296528
看起来model4的表现优于model3。然后,我们来看看它的样本外表现:
predict4_test <- predict(model4, flights_test)
error4_test <- flights_test$air_time - predict4_test[, 1]
evaluate_error(error4_test)
## abs_err std_dev
## 7.499769 10.391071
前面的输出表明,回归树平均来说可以更好地预测这个问题。以下密度图对比了model3和model4的样本外预测误差分布:
plot(density(error3_test, na.rm = TRUE),
ylim = range(0, 0.06),
main = "Empirical distributions of out-of-sample errors")
lines(density(error4_test, na.rm = TRUE), lty = 2)
legend("topright", legend = c("model3", "model4"),
lty = c(1, 2), cex = 0.8,
x.intersp = 0.6, y.intersp = 0.6)
生成的情节如下:

对于前面的图,我们可以看到model4的预测误差的方差低于model3。
前面的例子可能存在许多问题,因为我们应用线性模型和机器学习模型时没有对数据进行任何严肃的检查。这些章节的目的不在于模型,而是为了展示在 R 中拟合模型的常见过程和界面。对于现实世界的问题,您需要对数据进行更仔细的分析,而不是直接将它们倒入任意模型并得出结论。
摘要
在本章中,您学习了如何以各种格式读取和写入数据,如何使用绘图函数可视化数据,以及如何将基本模型应用于数据。现在,您已经了解了与数据工作相关的基本工具和界面。然而,您可能需要从其他来源学习更多的数据分析工具。
对于统计和计量经济学模型,我建议您不仅阅读统计学和计量经济学的教科书,还要阅读专注于统计分析的 R 语言书籍。对于如人工神经网络、支持向量机和随机森林等机器学习模型,我建议您阅读机器学习书籍并访问 CRAN 任务视图:机器学习与统计学习 (cran.r-project.org/web/views/MachineLearning.html)。
由于本书专注于 R 编程语言而不是任何特定模型,我们将在下一章继续我们的旅程,深入探讨 R。如果您不熟悉 R 代码的工作方式,您几乎无法预测会发生什么,这会减慢您的编码速度,一个小问题可能会浪费您很多时间。
接下来的几章将帮助您建立对 R 的评估模型、元编程设施、面向对象系统和 R 选择以促进数据分析的几个其他机制的直观理解,这使您能够使用更高级的数据操作包,并处理更复杂的任务。
第八章。R 内部
在前面的章节中,你学习了 R 编程语言的基础知识,了解了如何使用向量、矩阵、列表和数据框以不同的形状表示数据。你也看到了我们如何使用内置函数来解决简单问题。然而,仅仅了解这些特性并不能帮助你解决每一个问题。现实世界的数据分析通常涉及对数据进行仔细和详细地转换和聚合,这可以通过各种函数来完成,无论是内置的还是由扩展包提供的。
为了最好地使用这些函数而不是让它们因意外结果而让你困惑,你需要对 R 函数的工作方式有一个基本但具体的理解。在本章中,我们将涵盖以下主题:
-
惰性求值
-
复制修改机制
-
词法作用域
-
环境
如果你理解了这些概念及其在代码中的作用,大多数 R 代码应该对你来说非常可预测,这意味着在查找错误和编写正确功能的代码方面有更高的生产力。
理解惰性求值
理解 R 语言的工作原理很大一部分可以通过弄清楚 R 函数的工作方式来实现。在阅读了前面的章节之后,你应该已经了解了最常用的基本函数。然而,你可能仍然对它们的精确行为感到困惑。假设我们创建以下函数:
test0 <- function(x, y) {
if (x > 0) x else y
}
函数有些特殊,因为y似乎只在x大于零时才需要。如果我们只为x提供一个正数而忽略y,函数会因为我们没有提供定义中的所有参数而失败吗?让我们通过调用以下函数来找出答案:
test0(1)
## [1] 1
函数在没有提供y的情况下也能正常工作。看起来当我们调用一个函数时,我们不需要为所有参数提供值,而只需要为那些需要的参数提供值。如果我们用负数调用test0,则需要y:
test0(-1)
## Error in test0(-1): argument "y" is missing, with no default
由于我们没有指定y的值,函数停止了,报告说y缺失。
从前面的例子中,你了解到如果一个函数不需要所有参数来返回值,那么它不需要指定所有参数。如果我们坚持指定那些在函数中未使用的参数,它们会在我们调用函数之前被评估,或者根本不会被评估?让我们通过在参数y的位置放置一个stop()函数来找出答案。如果表达式以任何方式在任何地方被评估,它应该在x返回之前立即停止:
test0(1, stop("Stop now"))
## [1] 1
输出表明stop()没有发生,这表明它根本就没有被评估。如果我们将x的值改为负数,函数应该停止执行:
test0(-1, stop("Stop now"))
## Error in test0(-1, stop("Stop now")): Stop now
现在,很明显,在这种情况下stop()被评估了。机制变得相当透明。在函数调用中,只有当需要参数的值时,才会评估参数的表达式。这种机制被称为惰性求值,因此,我们也可以说函数调用的参数是惰性求值的,即只在需要时评估。
如果你不知道惰性求值机制,你可能会认为以下函数调用将非常耗时,并且可能会耗尽你的所有计算机内存。然而,惰性求值阻止了这种情况的发生,因为rnorm(1000000)永远不会被评估。这是因为当评估if (x > 0) x else y时,它永远不会被需要,这可以通过使用system.time()依次计时函数调用来验证:
system.time(rnorm(10000000))
## user system elapsed
## 0.91 0.01 0.92
生成一千万个随机数不是一件容易的事情。它需要超过一秒钟。相比之下,评估一个数字应该是 R 能做的最容易的事情,而且它非常快,以至于计时器本身都无法感知:
system.time(1)
## user system elapsed
## 0 0 0
如果我们根据test0的逻辑和惰性求值的了解来计时以下表达式,那么根据合理的猜测,它应该是零:
system.time(test0(1, rnorm(10000000)))
## user system elapsed
## 0 0 0
另一个可能发生的惰性求值场景是参数的默认值。更准确地说,函数参数的默认值应该真正是默认表达式,因为值只有在表达式实际评估时才可用。考虑以下函数:
test1 <- function(x, y = stop("Stop now")) {
if (x > 0) x else y
}
我们给y一个默认值,该值调用stop()。如果惰性求值不适用,即无论是否需要,y都会被评估,那么只要我们不提供y就调用test1(),我们应该收到一个错误。然而,如果惰性求值适用,使用正数x参数调用test1()不应该导致错误,因为y的stop()表达式永远不会被评估。
让我们做一个实验来找出哪个是正确的。首先,我们将使用正数x参数调用test1():
test1(1)
## [1] 1
输出表明惰性求值在这里也起作用。函数只使用x,而y的默认表达式根本不会被评估。如果我们提供一个负数的x参数,函数应该像预期的那样停止:
test1(-1)
## Error in test1(-1): Stop now
上述示例展示了惰性求值的一个优点:它使得节省时间和避免不必要的表达式评估成为可能。此外,它还允许更灵活地指定函数参数的默认值。例如,你可以在函数参数的表达式中使用其他参数:
test2 <- function(x, n = floor(length(x) / 2)) {
x[1:n]
}
这允许你以更合理或更期望的方式设置函数的默认行为,同时函数参数仍然可以像没有这些默认值一样进行自定义。
如果我们不指定n就调用test2,默认行为会取出x的前半部分元素:
test2(1:10)
## [1] 1 2 3 4 5
函数保持灵活,因为你总是可以通过指定另一个n的值来覆盖其默认行为:
test2(1:10, 3)
## [1] 1 2 3
就像所有其他特性一样,惰性求值也有其优缺点。由于函数的参数在函数调用时只进行解析而不进行求值,我们只能确保提供给参数的表达式在语法上是正确的。很难确保参数将按预期工作。
例如,如果默认值中出现未定义的变量,则在创建函数时不会出现警告或错误。在以下示例中,我们创建了一个test3函数,它与test2完全相同,除了n中的x被错误地写成了未定义的变量m。
test3 <- function(x, n = floor(length(m) / 2)) {
x[1:n]
}
当我们创建test3时,没有警告或错误,因为floor(length(m) / 2)在调用test3之前从未被求值,而n的值是由1:n要求的。函数只有在实际调用时才会停止:
test3(1:10)
## Error in test3(1:10): object 'm' not found
如果我们在调用test3之前定义了m,函数将正常工作,但方式出乎意料:
m <- c(1, 2, 3)
test3(1:10)
## [1] 1
另一个使惰性求值的工作方式更加明确的例子如下:
test4 <- function(x, y = p) {
p <- x + 1
c(x, y)
}
注意,y的默认值是p,这与之前的例子一样,在函数调用之前没有定义。这两个例子之间的一个显著区别是,第二个参数默认值中缺失的符号何时被提供。在之前的例子中,p在函数调用之前被定义。然而,在这个例子中,p是在函数内部定义的,在y被使用之前。
让我们看看调用函数时会发生什么:
test4(1)
## [1] 1 2
看起来函数是正常工作的,而不是导致错误。如果我们通过test4(1)的执行详细过程来分析,这将更容易理解:
-
找到一个名为
test4的函数。 -
匹配给定的参数,但
x和y都没有被求值。 -
p <- x + 1求值x + 1并将值赋给新变量p。 -
c(x, y)求值x和y,其中x取值为1,y取值为p,恰好得到x + 1的值,即2。 -
函数返回一个数值向量
c(1, 2)。
因此,在整个test4(1)的评估过程中,没有出现警告或错误,因为没有违反任何规则。这里最重要的技巧是p的定义是在使用y之前。
上述例子有助于解释惰性求值的工作方式,但这确实是一种不良实践。我不会推荐以这种方式编写函数,因为这种技巧只会使函数的行为更不透明。一种好的做法是简化参数,并避免在函数外部使用未定义的符号。否则,由于其对外部环境的依赖,可能会很难预测其行为或调试函数。
尽管如此,也有一些明智地使用懒加载的情况。例如,stop()可以与switch()一起在最后一个参数中使用,以便在没有匹配到任何情况时停止函数。以下函数check_input()使用switch()来规范x的输入,使其只接受y或n,并在提供其他字符串时停止:
check_input <- function(x) {
switch(x,
y = message("yes"),
n = message("no"),
stop("Invalid input"))
}
当x取y时,显示一条消息说yes:
check_input("y")
## yes
当x取n时,显示一条消息说no:
check_input("n")
## no
否则,函数停止:
check_input("what")
## Error in check_input("what"): Invalid input
这个例子之所以有效,是因为stop()作为switch()的一个参数被懒加载评估。
作为这些例子的总结,这里提醒的是,你不能过分依赖解析器来检查代码。它只检查代码的语法,并且不会告诉你代码是否编写得很好。为了避免懒加载可能引起的潜在陷阱,在函数中进行必要的检查,以确保可以正确处理输入。
理解复制修改机制
在上一节中,我们展示了懒加载评估的工作原理以及它如何通过避免不必要的函数参数评估来节省计算时间和工作内存。在本节中,我将向您展示 R 的一个重要特性,这使得处理数据更加安全。假设我们创建一个简单的数值向量x1:
x1 <- c(1, 2, 3)
然后,我们将x1的值赋给x2:
x2 <- x1
现在,x1和x2具有完全相同的值。如果我们修改两个向量中的一个元素会发生什么?两个向量都会改变吗?
x1[1] <- 0
x1
## [1] 0 2 3
x2
## [1] 1 2 3
输出显示,当x1被修改时,x2将保持不变。你可能猜测赋值会自动复制值并使新变量指向数据的副本而不是原始数据。让我们使用tracemem()来跟踪数据在内存中的足迹。
让我们重置向量并通过跟踪x1和x2的内存地址来进行实验:
x1 <- c(1, 2, 3)
x2 <- x1
当我们对两个向量调用tracemem()时,它显示了数据的当前内存地址。如果被跟踪的内存地址发生变化,将显示一个文本,其中包含原始地址和新地址,表明数据已被复制:
tracemem(x1)
## [1] "<0000000013597028>"
tracemem(x2)
## [1] "<0000000013597028>"
现在,两个向量具有相同的值,x1和x2共享相同的地址,这意味着它们指向内存中完全相同的同一块数据,并且赋值操作不会自动复制数据。但是数据何时被复制?
现在,我们将x1的第一个元素修改为0:
x1[1] <- 0
## tracemem[0x0000000013597028 -> 0x00000000170c7968]
内存跟踪显示x1的地址已更改为一个新的地址。更具体地说,原始向量x1和x2所指向的那块内存被复制到了一个新的位置。现在我们在两个不同的位置有了相同数据的两个副本。然后,修改副本的第一个元素,最后,使x1指向修改后的副本。
现在,x1和x2有不同的值:x1指向修改后的向量,而x2仍然指向原始向量。
换句话说,如果多个变量引用同一个对象,修改一个变量将导致对象的复制。这种机制被称为 复制修改。
复制修改机制发生的另一个场景是在修改函数参数时。假设我们创建以下函数:
modify_first <- function(x) {
x[1] <- 0
x
}
当函数执行时,它试图修改参数 x 的第一个元素。让我们通过向量和列表做一些实验,看看 modify_first() 是否可以修改它们。
对于一个数字向量 v1:
v1 <- c(1, 2, 3)
modify_first(v1)
## [1] 0 2 3
v1
## [1] 1 2 3
对于一个列表 v2:
v2 <- list(x = 1, y = 2)
modify_first(v2)
## $x
## [1] 0
##
## $y
## [1] 2
v2
## $x
## [1] 1
##
## $y
## [1] 2
在这两个实验中,函数只返回了原始对象的修改版本,但没有修改原始对象。然而,在函数外部直接修改向量是有效的:
v1[1] <- 0
v1
## [1] 0 2 3
v2[1] <- 0
v2
## $x
## [1] 0
##
## $y
## [1] 2
要使用修改后的版本,我们需要将其分配给原始变量:
v3 <- 1:5
v3 <- modify_first(v3)
v3
## [1] 0 2 3 4 5
前面的例子表明,修改函数参数也会导致复制,以确保修改不会影响函数外部的事物。
当修改属性时,复制修改机制也会发生。以下函数删除数据框的行名,并将其列名替换为大写字母:
change_names <- function(x) {
if (is.data.frame(x)) {
rownames(x) <- NULL
if (ncol(x) <= length(LETTERS)) {
colnames(x) <- LETTERS[1:ncol(x)]
} else {
stop("Too many columns to rename")
}
} else {
stop("x must be a data frame")
}
x
}
为了测试该函数,我们将创建一个具有随机生成数据的简单数据框:
small_df <- data.frame(
id = 1:3,
width = runif(3, 5, 10),
height = runif(3, 5, 10))
small_df
## id width height
## 1 1 7.605076 9.991836
## 2 2 8.763025 7.360011
## 3 3 9.689882 8.550459
现在,我们将使用数据框调用该函数并查看修改后的版本:
change_names(small_df)
## A B C
## 1 1 7.605076 9.991836
## 2 2 8.763025 7.360011
## 3 3 9.689882 8.550459
根据复制修改机制,small_df 在其行名被移除时第一次被复制,然后,所有后续的更改都是对复制的版本而不是原始版本进行的。我们可以通过查看 small_df 来验证这一点:
small_df
## id width height
## 1 1 7.605076 9.991836
## 2 2 8.763025 7.360011
## 3 3 9.689882 8.550459
原始版本没有任何变化。
函数外部的对象修改
尽管有复制修改机制,仍然可以在函数外部修改一个向量。<<- 运算符被设计来执行这项任务。假设我们有一个变量 x 并创建一个函数 modify_x(),该函数简单地分配一个新的值给 x:
x <- 0
modify_x <- function(value) {
x <<- value
}
当我们调用函数时,x 的值将被替换:
modify_x(3)
x
## [1] 3
当你尝试将一个向量映射到一个新的列表并同时进行计数时,这可能会很有用。以下代码创建了一个具有递增元素数量的向量列表。在 lapply() 的每次迭代中,count 用于计算生成的向量中元素的总数:
count <- 0
lapply(1:3, function(x) {
result <- 1:x
count <<- count + length(result)
result
})
## [[1]]
## [1] 1
##
## [[2]]
## [1] 1 2
##
## [[3]]
## [1] 1 2 3
count
## [1] 6
<<- 运算符有用的另一个例子是将嵌套列表展平。假设我们有一个像下面这样的嵌套列表:
nested_list <- list(
a = c(1, 2, 3),
b = list(
x = c("a", "b", "c"),
y = list(
z = c(TRUE, FALSE),
w = c(2, 3, 4))
)
)
str(nested_list)
## List of 2
## $ a: num [1:3] 1 2 3
## $ b:List of 2
## ..$ x: chr [1:3] "a" "b" "c"
## ..$ y:List of 2
## .. ..$ z: logi [1:2] TRUE FALSE
## .. ..$ w: num [1:3] 2 3 4
我们希望将列表展平,使得嵌套的级别都提升到第一级。以下代码使用 rapply() 和 <<- 解决了这个问题:
首先,我们需要知道 rapply() 是 lapply() 的递归版本。在每次迭代中,提供的函数都会在列表的特定级别上调用一个原子向量,直到所有级别的原子向量都耗尽。调用 rapply(nested_list, f) 基本上按以下方式运行:
f(c(1, 2, 3))
f(c("a", "b", "c"))
f(c(TRUE, FALSE))
f(c(2, 3, 4))
请记住,我们应该找出一个解决方案来扁平化nested_list。我们将讨论的解决方案受到了 Stackoverflow 答案(stackoverflow.com/a/8139959/2906900)的启发,该答案巧妙地使用了rapply()。首先,我们将创建一个空列表来接收嵌套列表中的单个向量和一个计数器:
flat_list <- list()
i <- 1
然后,我们将使用rapply()递归地将函数应用于nested_list。在每次迭代中,函数通过x接收nested_list中的原子向量。函数将flat_list的第i个元素设置为x并增加计数器i:
res <- rapply(nested_list, function(x) {
flat_list[[i]] <<- x
i <<- i + 1
})
迭代完成后,所有原子向量都存储在flat_list的第一级。rapply()返回的值如下:
res
## a b.x b.y.z b.y.w
## 2 3 4 5
由于i <<- i + 1,res中的值并不重要。然而,res的名称对于指示flat_list中每个元素的原始级别和名称是有用的。因此,我们让flat_list也具有res的名称,以指示每个元素的来源:
names(flat_list) <- names(res)
str(flat_list)
## List of 4
## $ a : num [1:3] 1 2 3
## $ b.x : chr [1:3] "a" "b" "c"
## $ b.y.z: logi [1:2] TRUE FALSE
## $ b.y.w: num [1:3] 2 3 4
最后,nested_list中的所有元素都以扁平的方式存储在flat_list中。
理解词法作用域
在上一节中,我们介绍了复制修改机制。示例演示了这种机制发生的两种情况。当一个对象有多个名称或作为函数的参数传递时,修改它会导致对象被复制,并且实际上被修改的是复制的版本。
要修改函数外部的对象,我们引入了<<-的使用,它会首先找到函数外部的变量并修改该对象,而不是复制一个局部对象。这引出了一个重要观点,即函数内部和外部。在函数内部,我们可以以某种方式引用外部变量和函数。
例如,以下函数使用了两个外部变量:
start_num <- 1
end_num <- 10
fun1 <- function(x) {
c(start_num, x, end_num)
}
我们首先创建两个变量并定义一个名为fun1的函数。该函数简单地将start_num、参数x和end_num组合成一个新的向量。很明显,start_num和end_num在函数外部定义,而x是函数的参数。让我们看看它是否工作:
fun1(c(4, 5, 6))
## [1] 1 4 5 6 10
该函数通过成功获取函数外部的两个变量的值来工作。你可能猜到,当我们定义函数时,值被捕获,所以fun1中的start_num和end_num只是从外部获取值。实际上,可以进行两个实验来证明它是错误的。
第一个实验很简单。让我们移除这两个变量:
rm(start_num, end_num)
fun1(c(4, 5, 6))
## Error in fun1(c(4, 5, 6)): object 'start_num' not found
然后,该函数不再工作。如果函数定义时捕获了两个变量的值,那么移除它们不应该使函数瘫痪。
第二个实验是相反的。让我们同时移除函数和两个变量。我们首先定义函数:
rm(fun1, start_num, end_num)
## Warning in rm(fun1, start_num, end_num): object 'start_num'
## not found
## Warning in rm(fun1, start_num, end_num): object 'end_num'
## not found
fun1 <- function(x) {
c(start_num, x, end_num)
}
如果函数的创建必须捕获它内部不存在的两个变量,前面的代码应该会产生一个错误,指出start_num和end_num缺失。显然,没有错误,函数已经成功创建。现在让我们调用它:
fun1(c(4, 5, 6))
## Error in fun1(c(4, 5, 6)): object 'start_num' not found
函数无法工作是因为没有找到两个变量。然后我们将定义这两个变量,并再次使用相同的参数调用函数:
start_num <- 1
end_num <- 10
fun1(c(4, 5, 6))
## [1] 1 4 5 6 10
函数再次工作。这导致结论,函数实际上是在被调用时尝试寻找变量的。实际上,在函数执行过程中,当遇到一个符号时,它首先会在函数内部寻找。更具体地说,如果符号作为参数传入或在函数内部创建,符号将被解析并使用其值。
假设我们首先创建一个变量p,然后定义一个函数fun2,在这个函数中创建另一个p变量并用于返回值:
p <- 0
fun2 <- function(x) {
p <- 1
x + p
}
当我们调用函数时,fun2在x + p中将使用哪个p?让我们找出答案;
fun2(1)
## [1] 2
输出清楚地表明x + p使用了函数内部定义的p。流程很简单。首先,p <- 1创建了一个新的变量p,其值为1,而不是改变函数外部的p。然后,计算x + p,其中x被解析为传入的参数,p为刚刚定义的局部变量。规则是,只有当变量在函数内部不存在时,才会在外部搜索。
然而,“外部”究竟是什么意思?这个问题比它看起来要微妙。假设我们创建了以下两个函数:
f1 <- function(x) {
x + p
}
g1 <- function(x) {
p <- 1
f1(x)
}
第一个函数f1简单地添加两个变量:x是一个参数,p是一个尚未在外部找到的变量。第二个函数g1在内部定义了一个p变量并调用了f1。问题是,“当g1被调用时,f1是否会在g1内部找到p?”
g1(0)
## [1] 0
不幸的是,即使在g1中调用了f1,f1也无法在g1内部找到p。如果我们定义p然后再次调用g1,函数就能正常工作:
p <- 1
g1(0)
## [1] 1
使g1工作的是,当调用f1且在f1内部找不到p时,它会搜索f1的定义位置而不是调用位置。这种机制被称为词法作用域。在前面的代码中,我们在与f1定义相同的范围内定义了p。然后,当在g1内部调用f1时,f1可以找到p。
相同的作用域规则也适用于<<-如何查找变量。例如,以下代码在相同的作用域中定义了一个变量m和两个函数f2和g2。在f2中,m被设置为2。然而,在g2中,定义了一个局部变量m,然后调用了f2:
m <- 1
f2 <- function(x) {
m <<- 2
x
}
g2 <- function(x) {
m <- 1
f2(x)
cat(sprintf("[g2] m: %d\n", m))
}
一旦调用f2,g2中的m的值就会被打印出来。让我们调用g2看看会发生什么:
g2(1)
## [g2] m: 1
打印的文本显示,g2中的m的值保持不变,但f2和g2外部的m的值发生了变化,这可以通过验证得到:
m
## [1] 2
前面的实验证实了m <<- 2遵循词法作用域的规则。
以下两个例子看起来更加复杂。函数是嵌套的。在f中,我们不仅创建了局部变量如p和q,还创建了一个局部函数f2,在其中定义了另一个局部p变量:
f <- function(x) {
p <- 1
q <- 2
cat(sprintf("1\. [f1] p: %d, q: %d\n", p, q))
f2 <- function(x) {
p <- 3
cat(sprintf("2\. [f2] p: %d, q: %d\n", p, q))
c(x = x, p = p, q = q)
}
cat(sprintf("3\. [f1] p: %d, q: %d\n", p, q))
f2(x)
}
如果你理解了词法作用域,你应该能够根据任意输入x预测结果。我们添加了一些cat()函数来更容易地跟踪每个作用域级别的变量值。cat()消息包括顺序、函数作用域和p和q的值。现在,我们将运行f(0),你可以预测结果:
f(0)
## 1\. [f1] p: 1, q: 2
## 3\. [f1] p: 1, q: 2
## 2\. [f2] p: 3, q: 2
## x p q
## 0 3 2
三个cat()函数的执行顺序是1、3和2,每个作用域中p和q的值都符合词法作用域规则。在以下示例中,我们也将使用<<-:
g <- function(x) {
p <- 1
q <- 2
cat(sprintf("1\. [f1] p: %d, q: %d\n", p, q))
g2 <- function(x) {
p <<- 3
p <- 2
cat(sprintf("2\. [f2] p: %d, q: %d\n", p, q))
c(x = x, p = p, q = q)
}
cat(sprintf("3\. [f1] p: %d, q: %d\n", p, q))
result <- g2(x)
cat(sprintf("4\. [f1] p: %d, q: %d\n", p, q))
result
}
你可以通过预测执行顺序和打印变量的值来分析函数的流程:
g(0)
## 1\. [f1] p: 1, q: 2
## 3\. [f1] p: 1, q: 2
## 2\. [f2] p: 2, q: 2
## 4\. [f1] p: 3, q: 2
## x p q
## 0 2 2
如果你没有成功预测前面函数的行为,请更仔细地阅读本节中的例子。
理解环境的工作原理
在前面的章节中,你学习了懒加载、修改时复制和词法作用域。这些机制与一种称为环境的对象高度相关。实际上,词法作用域正是由环境实现的。尽管环境看起来与列表非常相似,但它们在几个方面确实有根本的不同。在接下来的章节中,我们将通过创建和操作环境来了解环境对象的行为,并看到其结构如何决定 R 函数的工作方式。
了解环境对象
环境是一个包含一组名称的对象,并有一个父环境。每个名称(也称为符号或变量)指向一个对象。当我们在一个环境中查找符号时,它将搜索符号集,并在环境中存在该符号时返回符号指向的对象。否则,它将继续在其父环境中查找。以下图示说明了环境的结构和环境之间的关系:

在前面的图中,环境 1包含两个名称(id和grades),其父环境是环境 0,包含一个名称(scores)。这些环境中的每个名称都指向内存中某个位置存储的对象。如果我们查找环境 1中的id,我们将直接得到它指向的数值向量。如果我们查找scores,环境 1不包含scores,因此它将在其父环境环境 0中查找,并成功获取其值。对于其他名称,它将沿着父环境链查找,直到找到或最终出现符号未找到的错误。
在接下来的章节中,我们将详细讲解这些概念。
创建和链接环境
我们可以使用 new.env() 函数创建一个新的环境:
e1 <- new.env()
环境通常用十六进制数字表示,这是一个内存地址:
e1
## <environment: 0x0000000014a45748>
提取运算符 ($ 和 [[) 可以用来在环境中创建变量,就像修改列表一样:
e1$x <- 1
e1[["x"]]
## [1] 1
然而,环境与列表之间有三个主要区别:
-
环境没有索引
-
环境有一个父环境
-
环境具有引用语义
在接下来的章节中,我们将详细解释它们。
访问环境
环境没有索引。这意味着我们既不能对环境进行子集操作,也不能通过索引从它中提取元素。如果我们尝试使用位置范围来子集环境,我们会得到一个错误:
e1[1:3]
## Error in e1[1:3]: object of type 'environment' is not subsettable
当我们尝试使用索引从环境中提取变量时,我们会得到不同的错误:
e1[[1]]
## Error in e1[[1]]: wrong arguments for subsetting an environment
正确处理环境的方式是使用名称和环境访问函数。例如,我们可以使用 exists() 来检测一个变量是否存在于环境中:
exists("x", e1)
## [1] TRUE
对于一个已存在的变量,我们可以调用 get() 来检索其值:
get("x", e1)
## [1] 1
我们可以调用 ls() 来查看给定环境中的所有变量名,就像我们在第三章中提到的,管理你的工作空间:
ls(e1)
## [1] "x"
如果我们使用 $ 或 [[ 来访问环境中不存在的变量,我们会得到 NULL,就像我们使用非存在的名称从列表中提取元素时得到的结果一样:
e1$y
## NULL
e1[["y"]]
## NULL
然而,如果我们在一个不存在的变量上使用 get() 函数,我们肯定会收到一个错误,就像我们在不小心引用一个不存在的变量时发生的情况一样:
get("y", e1)
## Error in get("y", e1): object 'y' not found
为了更好地处理错误发生之前的情况,我们可以在使用 get() 函数访问变量之前使用 exists() 来进行检测:
exists("y", e1)
## [1] FALSE
环境链
环境有一个父环境,这是在原始环境中不存在符号时查找符号的下一个地方。假设我们正在尝试使用 get() 函数访问环境中的变量。如果变量直接在其中找到,我们就得到值。否则,get() 将在其父环境中查找变量。
在以下示例中,我们将创建一个新的环境 e2,其父环境(或封装环境)是 e1,就像我们在上一节中创建的那样:
e2 <- new.env(parent = e1)
不同的环境有不同的内存地址:
e2
## <environment: 0x000000001772ef70>
e1
## <environment: 0x0000000014a45748>
然而,e2 的父环境,按照定义,与 e1 指向的环境完全相同,这可以通过 parent.env() 来验证:
parent.env(e2)
## <environment: 0x0000000014a45748>
现在,我们在 e2 中创建一个变量 y:
e2$y <- 2
我们可以使用 ls() 来检查 e2 中的所有变量名:
ls(e2)
## [1] "y"
我们也可以使用 $、[[、exists() 或 get() 来访问变量的值:
e2$y
## [1] 2
e2[["y"]]
## [1] 2
exists("y", e2)
## [1] TRUE
get("y", e2)
## [1] 2
然而,提取运算符 ($ 和 [[) 和环境访问函数有一个显著的区别。运算符仅在单个环境的作用域内工作,但函数在环境链中工作。
注意,我们在 e2 中没有定义任何名为 x 的变量。不出所料,提取 x 的两个运算符都导致 NULL:
e2$x
## NULL
e2[["x"]]
## NULL
然而,当我们使用 exists() 和 get() 时,父环境会发挥作用。由于 x 在 e2 中未找到,函数将在其父环境 e1 中继续搜索:
exists("x", e2)
## [1] TRUE
get("x", e1)
## [1] 1
这就是为什么我们从前面的函数调用中得到了积极的结果。如果我们不希望函数搜索父环境,我们可以设置 inherits = FALSE。在这种情况下,如果变量在给定环境中不可用,则搜索不会继续。相反,exists() 将返回 FALSE:
exists("x", e2, inherits = FALSE)
## [1] FALSE
此外,get() 函数将导致错误:
get("x", e2, inherits = FALSE)
## Error in get("x", e2, inherits = FALSE): object 'x' not found
环境的链式工作在许多层面上。例如,你可能会创建一个名为 e3 的环境,其父环境为 e2。当你使用 get() 函数从 e3 中获取变量时,搜索将沿着环境链进行。
使用环境进行引用语义
环境具有引用语义。这意味着与原子向量、列表等数据类型不同,当修改环境时,环境不会被复制,无论它具有多个名称还是作为函数的参数传递。
例如,我们将 e1 的值赋给另一个变量 e3:
ls(e1)
## [1] "x"
e3 <- e1
如果我们有两个变量指向同一个列表,修改其中一个变量会首先创建一个副本,然后修改副本,这不会影响另一个列表。引用语义的行为则不同。当我们通过任一变量修改环境时,不会创建副本。因此,我们可以通过 e1 和 e3 都看到变化,因为它们指向完全相同的环境。
以下代码演示了引用语义是如何工作的:
e3$y
## NULL
e1$y <- 2
e3$y
## [1] 2
首先,在 e3 中没有定义 y。然后,我们在 e1 中创建了一个新的变量 y。由于 e1 和 e3 指向完全相同的环境,我们也可以通过 e3 访问 y。
当我们将环境作为函数的参数传递时,也会发生相同的情况。假设我们定义以下函数,尝试将 e 的 z 设置为 10:
modify <- function(e) {
e$z <- 10
}
如果我们将列表传递给此函数,则修改将不起作用。相反,将创建并修改一个局部版本,但在函数调用结束后将被丢弃:
list1 <- list(x = 1, y = 2)
list1$z
## NULL
modify(list1)
list1$z
## NULL
然而,如果我们将环境传递给函数,修改环境不会产生局部副本,而是直接在环境中创建一个新的变量 z:
e1$z
## NULL
modify(e1)
e1$z
## [1] 10
了解内置环境
环境是 R 中一种相当特殊类型的对象,但它从函数调用的实现到词法作用域的机制无处不在。实际上,当你运行一段 R 代码时,你是在某个环境中运行的。为了知道我们在哪个环境中运行代码,我们可以调用 environment():
environment()
## <environment: R_GlobalEnv>
输出表明当前环境是全局环境。实际上,当一个新的 R 会话准备好用户输入时,工作环境总是全局环境。正是在这个环境中,我们通常在数据分析中创建变量和函数。
如前例所示,环境也是一个我们可以创建并与之交互的对象。例如,我们可以将当前环境赋值给一个变量,并在该环境中创建新的符号:
global <- environment()
global$some_obj <- 1
之前的赋值等同于直接调用some_obj <- 1,因为这在全局环境中已经存在。只要运行前面的代码,全局环境就会被修改,并且some_obj将获得一个值:
some_obj
## [1] 1
有其他方法可以访问全局环境。例如,globalenv()和.GlobalEnv都指向全局环境:
globalenv()
## <environment: R_GlobalEnv>
.GlobalEnv
## <environment: R_GlobalEnv>
全局环境(globalenv())是用户工作空间,而基础环境(baseenv())提供基本函数和操作符:
baseenv()
## <environment: base>
如果你在 RStudio 编辑器中输入base::,应该会显示一个长列表的函数。我们之前章节中介绍的大多数函数都是在基础环境中定义的,包括例如创建基本数据结构(例如,list()和data.frame())的函数以及操作这些数据结构的操作符(例如,[, :甚至+)。
全局环境和基础环境是最重要的内置环境。现在,你可能想知道“全局环境的父环境是什么?基础环境的呢?他们的祖父母又是谁?”
以下函数可以用来找出给定环境的链:
parents <- function(env) {
while (TRUE) {
name <- environmentName(env)
txt <- if (nzchar(name)) name else format(env)
cat(txt, "\n")
env <- parent.env(env)
}
}
之前的功能递归地打印环境的名称,每个环境的父环境是下一个。现在,我们可以找出全局环境的所有父环境级别:
parents(globalenv())
## R_GlobalEnv
## package:stats
## package:graphics
## package:grDevices
## package:utils
## package:datasets
## package:methods
## Autoloads
## base
## R_EmptyEnv
## Error in parent.env(env): the empty environment has no parent
注意,链在被称为空环境的环境中终止,这是唯一一个没有任何内容且没有父环境的环境。还有一个emptyenv()函数指向空环境,但parent.env(emptyenv())将导致错误。这也解释了为什么parents()最终会出错。
环境链是内置环境和包环境的组合。我们可以调用search()来获取从全局环境角度的符号查找搜索路径:
search()
## [1] ".GlobalEnv" "package:stats"
## [3] "package:graphics" "package:grDevices"
## [5] "package:utils" "package:datasets"
## [7] "package:methods" "Autoloads"
## [9] "package:base"
基于对环境链中符号查找的了解,我们可以详细地了解以下代码在全局环境中是如何评估的:
median(c(1, 2, 1 + 3))
表达式看起来很简单,但它的评估过程比看起来要复杂。首先,沿着链查找median。它在stats包环境中找到。然后,查找c。它在基础环境中找到。最后,当你查找+(这同样也是一个函数!)时,你可能会感到惊讶,因为它也在基础环境中找到。
事实上,每次你附加一个包时,包环境都会在搜索路径中插入到全局环境之前。如果有两个包导出具有冲突名称的函数,那么后来附加的包中定义的函数将覆盖先前定义的函数,因为它们成为全局环境更近的父环境。
理解与函数相关的环境
环境不仅控制全局级别的符号查找,也控制函数级别的符号查找。与函数及其执行过程相关的有三个重要的环境:执行环境、封闭环境和调用环境。
每次函数被调用时,都会创建一个新的环境来承载执行过程。这就是函数调用的执行环境。函数的参数以及我们在函数中创建的变量实际上都是执行环境中的变量。
与所有其他环境一样,函数的执行环境是用父环境创建的。这个父环境也称为函数的封闭环境,是函数被定义的环境。这意味着在函数执行期间,任何未在执行环境中定义的变量都会在封闭环境中查找。这正是词法作用域成为可能的原因。
有时了解调用环境也很有用,即函数被调用的环境。我们可以使用parent.frame()来获取当前正在执行函数的调用环境。
为了演示这些概念,假设我们定义以下函数:
simple_fun <- function() {
cat("Executing environment: ")
print(environment())
cat("Enclosing environment: ")
print(parent.env(environment()))
}
函数在被调用时什么也不做,只是打印执行环境和封闭环境:
simple_fun()
## Executing environment: <environment: 0x0000000014955db0>
## Enclosing environment: <environment: R_GlobalEnv>
simple_fun()
## Executing environment: <environment: 0x000000001488f430>
## Enclosing environment: <environment: R_GlobalEnv>
simple_fun()
## Executing environment: <environment: 0x00000000146a23c8>
## Enclosing environment: <environment: R_GlobalEnv>
注意,每次函数被调用时,执行环境是不同的,但封闭环境保持不变。实际上,当函数被定义时,其封闭环境就已经确定。我们可以通过在函数上调用environment()来获取其封闭环境:
environment(simple_fun)
## <environment: R_GlobalEnv>
以下示例涉及三个嵌套函数的三个环境。在每个函数中,执行环境、封闭环境和调用环境都会被打印出来。如果你对这些概念有深刻的理解,我建议你预测哪些是相同的,哪些是不同的:
f1 <- function() {
cat("[f1] Executing in ")
print(environment())
cat("[f1] Enclosed by ")
print(parent.env(environment()))
cat("[f1] Calling from ")
print(parent.frame())
f2 <- function() {
cat("[f2] Executing in ")
print(environment())
cat("[f2] Enclosed by ")
print(parent.env(environment()))
cat("[f2] Calling from ")
print(parent.frame())
}
f3 <- function() {
cat("[f3] Executing in ")
print(environment())
cat("[f3] Enclosed by ")
print(parent.env(environment()))
cat("[f3] Calling from ")
print(parent.frame())
f2()
}
f3()
}
让我们调用f1并找出每次消息打印的时间。原始形式的输出需要一些努力才能阅读。我们将其分成几块以便更容易阅读,同时保持输出的顺序以保持一致性。
注意,临时创建的环境只有内存地址(例如,0x0000000016a39fe8),而不是像全局环境(R_GlobalEnv)那样的通用名称。为了更容易地识别相同的环境,我们在环境文本输出的每一行末尾给出相同的内存地址相同的标签(例如,*A):
f1()
## [f1] Executing in <environment: 0x0000000016a39fe8> *A
## [f1] Enclosed by <environment: R_GlobalEnv>
## [f1] Calling from <environment: R_GlobalEnv>
当我们调用 f1 时,其关联的环境按预期打印,然后定义 f2 和 f3,最后调用 f3,继续产生以下文本输出:
## [f3] Executing in <environment: 0x0000000016a3def8> *B
## [f3] Enclosed by <environment: 0x0000000016a39fe8> *A
## [f3] Calling from <environment: 0x0000000016a39fe8> *A
然后,在 f3 中调用 f2,进一步产生以下文本输出:
## [f2] Executing in <environment: 0x0000000016a41f90> *C
## [f2] Enclosed by <environment: 0x0000000016a39fe8> *A
## [f2] Calling from <environment: 0x0000000016a3def8> *B
打印的消息显示了以下事实:
-
f1的封装环境和调用环境都是全局环境 -
f3的封装环境和调用环境,以及f2的封装环境,是f1的执行环境 -
f2的调用环境是f3的执行环境
上述事实与以下事实一致:
-
f1在全局环境中既被定义又被调用 -
f3在f1中既被定义又被调用 -
f2在f1中定义但在f3中调用
如果你成功地做出了正确的预测,说明你对环境和函数的基本工作原理有很好的理解。要进一步深入,我强烈推荐 Hadley Wickham 的《Advanced R》(amzn.com/1466586966?tag=devtools-20)。
摘要
在本章中,我们深入 R 中学习了 R 函数的基本工作原理。更具体地说,你学习了惰性求值、修改时复制、词法作用域以及环境是如何工作以允许这些机制。对 R 代码运行方式的直观理解不仅有助于你编写正确的代码,而且使你更容易从意外结果中找到错误。
在下一章中,我们将在此基础上构建。你将学习元编程的基础,这为交互式分析提供了强大的功能。
第九章。元编程
在上一章中,你学习了环境和其特性的结构,也学习了如何创建和访问一个环境。环境在懒加载、修改时复制和词法作用域中扮演着重要角色,这些都是在函数创建和调用时通过环境实现的。
现在我们对函数的工作原理有了坚实的理解,我们将在本章中进一步学习如何以更高级的形式与函数一起工作。你将学习使 R 在交互式分析中灵活的元编程功能。更具体地说,本章将涵盖以下主题:
-
函数式编程:闭包和高阶函数
-
使用语言对象进行语言计算
-
理解非标准评估
理解函数式编程
在上一章中,你详细学习了函数的行为,包括当参数被评估时(懒加载)、尝试修改参数时会发生什么(修改时复制),以及在哪里查找函数内部未定义的变量(词法作用域)。描述这些行为的术语可能看起来比实际要难。在接下来的章节中,你将了解两种类型的函数:在函数中定义的函数和与函数一起工作的函数。
创建和使用闭包
在函数中定义的函数称为闭包。它很特别,因为在闭包的函数体中,不仅可以使用局部参数,还可以使用父函数中创建的变量。
例如,假设我们有以下函数:
add <- function(x, y) {
x + y
}
这个函数有两个参数。每次我们调用add()时,我们都应该提供两个参数。如果我们使用闭包,我们可以生成具有预指定参数的特殊版本的这个函数。在下一节中,我们将创建一个简单的闭包来完成这个任务。
创建简单的闭包
在这里,我们将创建一个名为addn的函数,它有一个参数y。这个函数并不执行实际的加法计算,而是创建一个子函数,该子函数将y加到提供的任何数字x上:
addn <- function(y) {
function(x) {
x + y
}
}
可能需要额外的努力才能意识到addn并不像典型函数那样返回一个数字,而是返回一个闭包:即在函数中定义的函数。闭包计算x + y,其中x指的是局部参数,而y指的是其封装环境中的参数。换句话说,addn()不再是一个计算器,而是一个制造计算器的计算器工厂。
工厂函数使我们能够创建计算器的专用版本。例如,我们可以创建两个函数,分别将1和2加到数值向量上:
add1 <- addn(1)
add2 <- addn(2)
这两个函数表现得好像add(x, y)的第二个参数是固定的。以下代码验证了addn()制作的计算器:
add1(10)
## [1] 11
add2(10)
## [1] 12
以add1为例。add1 <- addn(1)代码评估addn(1),结果将一个函数分配给add1:
add1
## function(x) {
## x + y
## }
## <environment: 0x00000000139b0e58>
当我们打印add1时,它略有不同,因为add1的环境也被附加了。如果函数的环境不是当前环境,则函数的环境将被打印出来——在这种情况下,是全局环境。在add1的环境中,y是在addn(1)中指定的,可以通过运行以下代码来验证:
environment(add1)$y
## [1] 1
我们可以用add1调用environment()来访问其封闭环境,该环境捕获y。这正是闭包的工作方式。我们可以对add2做同样的事情,看看我们用addn(2)指定的y的值:
environment(add2)$y
## [1] 2
制作专用函数
闭包对于制作专用函数很有用。例如,由于图形生产的灵活性,绘图函数通常提供大量的参数。如果我们经常只使用所有参数的一个特定子集,我们可以制作专门的版本,使代码更容易编写和阅读。
以下color_line函数是plot的一个版本,专门用于颜色选择,但固定了绘图类型和线条类型。它相当于一个制造所有颜色笔的工厂:
color_line<- function(col) {
function(...) {
plot(..., type = "l", lty = 1, col = col)
}
}
如果我们想要一支红笔,我们调用color_line并得到一个专门绘制红色线条的函数。生成的函数也接受其他参数,例如标题和字体:
red_line<- color_line("red")
red_line(rnorm(30), main = "Red line plot")
此函数生成以下线图:

上述代码比没有使用此类专用函数的原始版本更易于阅读:
plot(rnorm(30), type = "l", lty = 1, col = "red",
main = "Red line plot")
使用最大似然估计(MLE)拟合正态分布
当我们与某些给定数据一起工作算法时,闭包很有用。例如,优化是一个寻找一组参数的问题,这些参数在满足某些约束和数据的情况下最大化或最小化预定义的目标函数。在统计学中,许多参数估计问题本质上都是优化问题。一个很好的例子是最大似然估计(MLE),它展示了闭包的使用。当我们用数据估计统计模型的参数时,我们通常使用最大似然估计法(MLE,见en.wikipedia.org/wiki/Maximum_likelihood)。MLE 背后的思想很简单:参数的估计值应该使观察到的数据在给定模型下最有可能。
要执行最大似然估计(MLE),我们需要一个函数来衡量在特定模型下观察给定数据集的可能性。然后,我们应用优化技术来找出最大化概率的参数值。
例如,我们知道一组观测数据是由正态分布生成的,但问题在于,我们不知道参数:均值和标准差。然后,我们可以使用最大似然估计(MLE)来估计它们,给定观测数据。
首先,我们知道均值为μ[0]和标准差σ[0]的正态分布的概率密度函数是:

因此,给定观测数据x的似然函数是:

为了使优化更容易,我们将取自然对数并在两边取负,得到负对数似然函数:

负对数似然函数与原始函数具有相同的单调性。此函数的优化解与原始函数相同,但可能更容易解决。这就是为什么我们在估计中使用此函数的原因。
以下nloglik R 函数根据观测数据x返回正态分布的两个参数的闭包:
nloglik<- function(x) {
n <- length(x)
function(mean, sd) {
log(2 * pi) * n / 2 + log(sd ^ 2) * n / 2 + sum((x - mean) ^ 2) / (2 * sd ^ 2)
}
}
以这种方式,对于任何给定的观测数据集,我们调用nloglike来获取关于均值和标准差的负对数似然函数。它告诉我们,在假设真实模型取我们指定的mean和sd值的情况下,观察到给定数据x有多不可能。
例如,我们使用rnorm()生成 10,000 个服从均值为1和标准差为2的正态分布的随机数。因此,mean = 1和sd = 2是分布参数的真实值:
data <- rnorm(10000, 1, 2)
然后,我们转向stats4包中的mle()函数。此函数实现了一系列数值方法来找到给定负对数似然函数的极小值,并带有某些参数。它接受数值搜索的起点,以及解的下界和上界:
fit <- stats4::mle(nloglik(data),
start = list(mean = 0, sd = 1), method = "L-BFGS-B",
lower =c(-5, 0.01), upper = c(5, 10))
经过几次迭代后,它找到了最大似然估计解并返回一个 S4 对象,其中包含与解相关的数据。为了查看估计值与真实值有多接近,我们将从对象中提取coef槽:
fit@coef
## mean sd
## 1.007548 1.990121
显然,估计值非常接近真实值。相对而言,两个估计值都有低于 1%的误差,这可以在下面验证:
(fit@coef - c(1, 2)) / c(1, 2)
## mean sd
## 0.007547752 -0.004939595
以下函数是data的直方图和具有真实参数(红色曲线)和估计参数(蓝色曲线)的正态分布密度函数的组合:
hist(data, freq =FALSE, ylim =c(0, 0.25))
curve(dnorm(x, 1, 2), add =TRUE, col =rgb(1, 0, 0, 0.5), lwd =6)
curve(dnorm(x, fit@coef[["mean"]], fit@coef[["sd"]]),
add =TRUE, col ="blue", lwd =2)
这产生了以下直方图,以及一个拟合的正态密度曲线:

我们可以看到,由估计参数产生的密度函数非常接近真实模型。
使用高阶函数
在上一节中,我们讨论了闭包,即在父函数中定义的函数。在本节中,我们将讨论高阶函数,即接受另一个函数作为参数的函数。
在深入这个主题之前,我们需要更多关于函数在作为变量或函数参数传递时的行为的知识。
为函数创建别名
第一个问题:如果我们将现有函数赋值给另一个变量,它会影响函数的封装环境吗?如果是这样,那么未在局部定义的符号的搜索路径将不同。
以下代码演示了为什么将函数赋值给另一个符号时,封装环境不会改变。我们定义了一个简单的函数f1,它在被调用时打印执行环境、封装环境和调用环境。然后,我们定义了f2,它也打印了这三个环境,但除此之外,它还将f1的函数赋值给局部变量p,并在f2内部调用p。
如果p <- f1在局部定义了函数,则p的封装环境将是f2的执行环境。否则,封装环境将保持为定义f1的全局环境:
f1 <- function() {
cat("[f1] executing in ")
print(environment())
cat("[f1] enclosed by ")
print(parent.env(environment()))
cat("[f1] calling from ")
print(parent.frame())
}
f2 <- function() {
cat("[f2] executing in ")
print(environment())
cat("[f2] enclosed by ")
print(parent.env(environment()))
cat("[f2] calling from ")
print(parent.frame())
p <- f1
p()
}
f1()
## [f1] executing in <environment: 0x000000001435d700>
## [f1] enclosed by <environment: R_GlobalEnv>
## [f1] calling from <environment: R_GlobalEnv>
f2()
## [f2] executing in <environment: 0x0000000014eb2200>
## [f2] enclosed by <environment: R_GlobalEnv>
## [f2] calling from <environment: R_GlobalEnv>
## [f1] executing in <environment: 0x0000000014eaedf0>
## [f1] enclosed by <environment: R_GlobalEnv>
## [f1] calling from <environment: 0x0000000014eb2200>
我们依次调用了这两个函数,发现p是在f2的执行环境中被调用的,但封装的环境没有改变。换句话说,p和f1的搜索路径完全相同。实际上,p <- f1将f1表示的相同函数赋值给p,然后它们都指向同一个函数。
将函数用作变量
R 中的函数并不像在其他编程语言中那样特殊。一切都是对象。函数也是对象,并且可以通过变量引用。
假设我们有一个这样的函数:
f1 <- function(x, y) {
if (x > y) {
x + y
} else {
x - y
}
}
在前面的函数中,两个条件分支导致不同的表达式,可能产生不同的值。为了达到相同的目的,我们也可以让条件分支产生不同的函数,将结果存储在变量中,最后调用变量表示的函数以获取结果:
f2 <- function(x, y) {
op <- if (x > y) `+` else `-`
op(x, y)
}
注意,在 R 中,我们做的所有事情都是通过函数完成的。最基本的运算符+和-也是函数。它们可以被赋值给变量op,如果op确实是一个函数,我们就可以调用它。
将函数作为参数传递
之前的例子表明,我们可以像传递其他任何东西一样轻松地传递函数,包括在参数中传递函数。
在以下示例中,我们将定义两个函数,分别称为add和product:
add <- function(x, y, z) {
x + y + z
}
product <- function(x, y, z) {
x * y * z
}
然后,我们将定义另一个名为combine的函数,它试图以参数f指定的某种方式组合x、y和z。在这里,f被假设为一个接受三个参数的函数,正如我们调用它时那样。这样,combine就更加灵活。它不仅限于特定的组合输入方式,还允许用户指定:
combine <- function(f, x, y, z) {
f(x, y, z)
}
我们可以将我们刚刚定义的add和product传递过去,看看它是否工作:
combine(add, 3, 4, 5)
## [1] 12
combine(product, 3, 4, 5)
## [1] 60
当我们调用combine(add, 3, 4, 5)时,函数体有f = add和f(x, y, z),这导致add(x, y, z)。同样的逻辑也适用于使用product调用combine。由于combine接受一个函数作为其第一个参数,它确实是一个高阶函数。
另一个我们需要高阶函数的原因是,在更高层次的抽象级别上,代码更容易阅读和编写。在许多情况下,使用高阶函数可以使代码更短,但表达更丰富。例如,for 循环是一个普通的流程控制设备,它沿着向量或列表进行迭代。
假设我们需要将一个名为f的函数应用到向量x的每个元素上。如果函数本身是向量化的,最好直接调用f(x)。然而,并不是每个函数都支持向量化操作,也不是每个函数都需要向量化。如果我们想这样做,像以下这样的 for 循环可以解决问题:
result<-list()
for (i in seq_along(x)) {
result[[i]] <-f(x[[i]])
}
result
在前面的循环中,seq_along(x)生成从1到x长度的序列,这相当于1:length(x)。代码看起来简单且易于实现,但如果我们总是使用它,缺点就会变得明显。
假设每次迭代的操作变得更加复杂:这将很难阅读。如果你仔细想想,你会发现代码告诉 R 如何完成任务,而不是任务本身是什么。当你查看非常长,有时嵌套的循环时,你可能会很难弄清楚它实际上在做什么。
相反,我们可以通过调用我们在前面的章节中介绍的lapply,将函数(f)应用到向量或列表(x)的每个元素上:
lapply(x, f)
实际上,lapply本质上与以下代码相同,尽管它是用 C 实现的:
lapply <- function(x, f, ...) {
result <- list()
for (i in seq_along(x)) {
result[[i]] <-f(x[i], ...)
}
}
这个函数是一个高阶函数,因为它在更高层次的抽象级别上工作。尽管它仍然在内部使用 for 循环,但它将工作分为两个抽象级别,使得每个级别看起来都很简单。
实际上,lapply也支持通过额外的参数扩展f。例如,+有两个参数,如下面的代码所示:
lapply(1:3, `+`, 3)
## [[1]]
## [1] 4
##
## [[2]]
## [1] 5
##
## [[3]]
## [1] 6
上一行代码等同于:
list(1 +3, 2 +3, 3 +3)
上一行代码也等同于我们使用闭包来生成x+3函数的情况:
lapply(1:3, addn(3))
## [[1]]
## [1] 4
##
## [[2]]
## [1] 5
##
## [[3]]
## [1] 6
正如我们在前面的章节中提到的,lapply只返回一个列表。如果我们想得到一个向量,我们应该在交互模式下使用sapply:
sapply(1:3, addn(3))
## [1] 4 5 6
或者,我们应该在编程代码中使用类型检查的vapply:
vapply(1:3, addn(3), numeric(1))
## [1] 4 5 6
除了这些函数之外,R 还提供了几个其他 apply 家族函数,正如我们在前面的章节中提到的,以及Filter、Map、Reduce、Find、Position和Negate。更多详细信息,请参阅文档中的?Filter。
此外,使用高阶函数不仅使代码更容易阅读和表达,而且这些函数还分离了每个抽象级别的实现,使它们相互独立。比一个逻辑耦合的整体更容易改进简单的组件。
例如,我们可以使用 apply-family 函数执行向量映射,给定一个函数。如果每个迭代与其他迭代独立,我们可以使用多个 CPU 核心并行化映射,以便同时执行更多任务。然而,如果我们最初没有使用高阶函数,而是使用 for 循环,那么将其转换为并行代码需要一段时间。
例如,让我们假设我们使用 for 循环来获取结果。在每次迭代中,我们执行一个繁重的计算任务。即使我们发现每个迭代与其他迭代独立,也不总是容易将其转换为并行代码:
result <- list()
for (i in seq_along(x)) {
# heavy computing task
result[[i]] <- f(x[[i]])
}
result
然而,如果我们使用高阶函数 lapply(),事情将会简单得多:
result <- lapply(x, f)
只需对代码进行一点小的修改,就可以将其转换为并行版本。使用 parallel::mclapply(),我们可以使用多个核心将 f 应用到 x 的每个元素上:
result <- parallel::mclapply(x, f)
很遗憾,mclapply() 不支持 Windows 系统。在 Windows 上执行并行应用函数需要更多的代码。我们将在高性能计算章节中介绍这个话题。
语言上的计算
在上一节中,我们介绍了 R 中的函数式编程设施。你了解到函数只是我们可以传递的另一种类型的对象。当我们创建一个新的函数,比如 fun,我们创建的环境将与该函数相关联。这个环境被称为函数的封装环境,可以通过 environment(fun) 访问。每次我们调用函数时,都会创建一个新的执行环境,该环境包含未评估的参数(承诺),以托管函数的执行,这实现了惰性求值。执行环境的父环境是函数的封装环境,这实现了词法作用域。
函数式编程允许我们在更高的抽象级别编写代码。元编程更进一步。它允许我们调整语言本身,并使某些语言结构在某些场景下更容易使用。一些流行的 R 包在其函数中使用元编程来简化事物。在本节中,我将向你展示元编程的力量以及其优缺点,以便你了解相关包和函数是如何工作的。
在深入了解事物是如何工作的知识之前,我们可能先看看一些使用元编程来简化事物的内置函数。
假设我们想要过滤内置数据集 iris,以找到每个数值列都大于所有记录 80% 的记录。
标准方法是通过对逻辑向量进行组合来对数据框的行进行子集化:
iris[iris$Sepal.Length > quantile(iris$Sepal.Length, 0.8) &
iris$Sepal.Width > quantile(iris$Sepal.Width, 0.8) &
iris$Petal.Length > quantile(iris$Petal.Length, 0.8) &
iris$Petal.Width > quantile(iris$Petal.Width, 0.8), ]
## Sepal.Length Sepal.Width Petal.Length Petal.Width
## 110 7.2 3.6 6.1 2.5
## 118 7.7 3.8 6.7 2.2
## 132 7.9 3.8 6.4 2.0
## Species
## 110 virginica
## 118 virginica
## 132 virginica
在前面的代码中,每次调用quantile()都会为一个列提供一个 80%的阈值。尽管代码可以工作,但它相当冗余,因为每次我们使用一个列时,都必须以iris$开始。总共,iris$出现了九次。
内置函数subset非常有用,可以使事情变得更简单:
subset(iris,
Sepal.Length > quantile(Sepal.Length, 0.8) &
Sepal.Width > quantile(Sepal.Width, 0.8) &
Petal.Length > quantile(Petal.Length, 0.8) &
Petal.Width > quantile(Petal.Width, 0.8))
## Sepal.Length Sepal.Width Petal.Length Petal.Width
## 110 7.2 3.6 6.1 2.5
## 118 7.7 3.8 6.7 2.2
## 132 7.9 3.8 6.4 2.0
## Species
## 110 virginica
## 118 virginica
## 132 virginica
前面的代码返回了完全相同的结果,但代码更简洁。但为什么在先前的例子中省略iris$却不起作用呢?
iris[Sepal.Length > quantile(Sepal.Length, 0.8) &
Sepal.Width > quantile(Sepal.Width, 0.8) &
Petal.Length > quantile(Petal.Length, 0.8) &
Petal.Width > quantile(Petal.Width, 0.8), ]
## Error in `[.data.frame`(iris, Sepal.Length > quantile(Sepal.Length, 0.8) & : object 'Sepal.Length' not found
前面的代码无法正常工作,因为Sepal.Length和其他列没有定义在我们评估子集表达式的范围(或环境)中。神奇的函数subset使用元编程技术调整其参数的评估环境,以便Sepal.Length>quantile(Sepal.Length, 0.8)在iris的列的环境中评估。
此外,subset不仅与行一起工作,而且在选择列时也非常有用。例如,我们也可以通过直接使用列名作为变量来指定select参数,而不是使用字符向量来选择列:
subset(iris,
Sepal.Length > quantile(Sepal.Length, 0.8) &
Sepal.Width > quantile(Sepal.Width, 0.8) &
Petal.Length > quantile(Petal.Length, 0.8) &
Petal.Width > quantile(Petal.Width, 0.8),
select = c(Sepal.Length, Petal.Length, Species))
## Sepal.Length Petal.Length Species
## 110 7.2 6.1 virginica
## 118 7.7 6.7 virginica
## 132 7.9 6.4 virginica
看看subset如何调整其第二个参数(subset)和第三个参数(select)的评估方式。结果是我们可以用更简洁的代码和更少的冗余来编写代码。
在接下来的几节中,你将了解幕后发生的事情以及它是如何设计的来工作的。
捕获和修改表达式
当我们输入一个表达式并按下Enter(或回车)键时,R 将评估该表达式并显示输出。以下是一个示例:
rnorm(5)
## [1] 0.54744813 1.15202065 0.74930997 -0.02514251
## [5] 0.99714852
它显示了生成的五个随机数。subset的神奇之处在于它调整了参数评估的环境。这分为两个步骤:首先捕获表达式,然后干扰表达式的评估。
将表达式作为语言对象捕获
捕获一个表达式意味着阻止表达式被评估,但将表达式本身存储为变量。执行此操作的是quote()函数;我们可以调用quote()来捕获括号之间的表达式:
call1 <- quote(rnorm(5))
call1
## rnorm(5)
前面的代码并没有产生五个随机数,而是函数调用本身。我们可以使用typeof()和class()来查看结果对象call1的类型和类:
typeof(call1)
## [1] "language"
class(call1)
## [1] "call"
我们可以看到call1本质上是一个语言对象,它是一个调用。我们也可以在quote()中写入一个函数名:
name1 <- quote(rnorm)
name1
## rnorm
typeof(name1)
## [1] "symbol"
class(name1)
## [1] "name"
在这种情况下,我们没有得到一个调用,而是一个符号(或名称)。
事实上,如果捕获了一个函数调用,quote()将返回一个调用,如果捕获了一个变量名,则返回一个符号。唯一的要求是捕获代码的有效性;也就是说,只要代码在语法上是正确的,quote()就会返回代表捕获表达式的语言对象。
即使函数不存在或变量尚未定义,表达式也可以单独捕获:
quote(pvar)
## pvar
quote(xfun(a = 1:n))
## xfun(a = 1:n)
在前面的语言对象中,可能 pvar、xfun 和 n 都尚未定义,但我们仍然可以 quote() 它们。
理解变量和符号对象,以及函数和调用对象之间的区别是很重要的。变量是一个对象的名称,而符号对象是名称本身。函数是一个可调用的对象,而调用对象是一个表示这种函数调用的语言对象,它尚未评估。在这种情况下,rnorm 是一个函数,它是可调用的(例如,rnorm(5) 返回五个随机数),但 quote(rnorm) 返回一个符号对象,而 quote(rnorm(5)) 返回一个调用对象,它们都只是语言本身的表示。
我们可以将调用对象转换为列表,这样我们就可以看到其内部结构:
as.list(call1)
## [[1]]
## rnorm
##
## [[2]]
## [1] 5
这表明调用由两个组件组成:函数的符号和一个参数。我们可以从调用对象中提取对象:
call1[[1]]
## rnorm
typeof(call1[[1]])
## [1] "symbol"
class(call1[[1]])
## [1] "name"
call1 的第一个元素是一个符号:
call1[[2]]
## [1] 5
typeof(call1[[2]])
## [1] "double"
class(call1[[2]])
## [1] "numeric"
call1 的第二个元素是一个数值。从前面的例子中,我们知道 quote() 将变量名称捕获为符号对象,将函数调用捕获为调用对象。它们都是语言对象。像典型数据结构一样,我们可以使用 is.symbol()/is.name() 和 is.call() 来检测一个对象是否是符号或调用,更普遍地,我们也可以使用 is.language() 来检测符号和调用。
另一个问题,“如果我们对字面值调用 quote() 会怎样?如果是数字或字符串呢?”以下代码创建了一个数值 num1 和一个引用的数值 num2:
num1 <- 100
num2 <- quote(100)
它们有完全相同的表示:
num1
## [1] 100
num2
## [1] 100
实际上,它们的值完全相同:
identical(num1, num2)
## [1] TRUE
因此,quote() 并不会将字面值(如数字、逻辑值、字符串等)转换为语言对象,而是保持原样。然而,将几个字面值组合成向量的表达式仍然会被转换为调用对象。以下是一个例子:
call2 <- quote(c("a", "b"))
call2
## c("a", "b")
它是一致的,因为 c() 确实是一个结合值和向量的函数。此外,如果你使用 as.list() 查看调用列表的表示,我们可以看到调用的结构:
as.list(call2)
## [[1]]
## c
##
## [[2]]
## [1] "a"
##
## [[3]]
## [1] "b"
调用中元素的类型可以通过 str() 来揭示:
str(as.list(call2))
## List of 3
## $ : symbol c
## $ : chr "a"
## $ : chr "b"
另一个值得注意的事实是,简单的算术计算也被捕获为调用,因为它们肯定是算术运算符(如 + 和 *)的函数调用,这些运算符本质上都是内置函数。例如,我们可以使用 quote() 函数对最简单的算术计算进行操作,执行 1 + 1:
call3 <- quote(1 + 1)
call3
## 1 + 1
运算表示被保留,但它是一个调用,并且具有与调用完全相同的结构:
is.call(call3)
## [1] TRUE
str(as.list(call3))
## List of 3
## $ : symbol +
## $ : num 1
## $ : num 1
在了解了所有关于捕获表达式的知识之后,我们现在可以捕获嵌套调用;也就是说,一个包含更多调用的调用。
call4 <- quote(sqrt(1 + x ^ 2))
call4
## sqrt(1 + x ^ 2)
我们可以使用 pryr 包中的函数来查看调用的递归结构。要安装该包,运行 install.package("pryr")。一旦包准备就绪,我们可以调用 pryr::call_tree 来实现:
pryr::call_tree(call4)
## \- ()
## \- `sqrt
## \- ()
## \- `+
## \- 1
## \- ()
## \- `^
## \- `x
## \- 2
对于 call4,递归结构以树状结构打印。\- () 操作符表示一个调用,然后 `var 表示一个符号对象 var,其余的是文字值。在前面的输出中,我们可以看到符号和调用被捕获,而文字值被保留。
如果你好奇一个表达式的调用树,你总是可以使用这个函数,因为它精确地反映了 R 处理表达式的方式。
修改表达式
当我们将表达式捕获为调用对象时,调用可以像列表一样进行修改。例如,我们可以通过将调用的第一个元素替换为另一个符号来更改要调用的函数:
call1
## rnorm(5)
call1[[1]] <- quote(runif)
call1
## runif(5)
因此,rnorm(5) 被更改为 runif(5)。
我们还可以向调用中添加新参数:
call1[[3]] <- -1
names(call1)[[3]] <- "min"
call1
## runif(5, min = -1)
然后,调用现在有另一个参数:min = -1。
捕获函数参数的表达式
在前面的示例中,你学习了如何使用 quote() 捕获已知表达式,但 subset 与任意用户输入的表达式一起工作。假设我们想捕获参数 x 的表达式。
第一种实现使用 quote():
fun1 <- function(x) {
quote(x)
}
让我们看看当使用 rnorm(5) 调用函数时,fun1 是否可以捕获输入表达式:
fun1(rnorm(5))
## x
显然,quote(x) 只捕获 x,与输入表达式 rnorm(5) 没有关系。为了正确捕获它,我们需要使用 substitute()。该函数捕获一个表达式,并用它们的表达式替换现有符号。此函数的最简单用法是捕获函数参数的表达式:
fun2 <- function(x) {
substitute(x)
}
fun2(rnorm(5))
## rnorm(5)
使用这种实现,fun2 返回输入表达式而不是 x,因为 x 被替换为输入表达式,在这种情况下,rnorm(5)。
以下示例演示了当我们提供一个语言对象或文字值的列表时,substitute 的行为。在第一个示例中,我们将给定表达式中每个符号 x 替换为 1:
substitute(x + y + x ^ 2, list(x = 1))
## 1 + y + 1 ^ 2
在第二个示例中,我们将应该作为函数名的每个符号 f 替换为另一个引用函数名 sin:
substitute(f(x + f(y)), list(f = quote(sin)))
## sin(x + sin(y))
现在,我们能够使用 quote() 捕获某些表达式,并使用 substitute() 捕获用户输入的表达式。
构造函数调用
除了捕获表达式外,我们还可以直接使用内置函数构建语言对象。例如,call1 是使用 quote() 捕获的调用:
call1 <- quote(rnorm(5, mean = 3))
call1
## rnorm(5, mean = 3)
我们可以使用 call() 创建具有相同参数的相同函数的调用:
call2 <- call("rnorm", 5, mean = 3)
call2
## rnorm(5, mean = 3)
或者,我们可以使用 as.call() 将调用组件列表转换为调用:
call3 <- as.call(list(quote(rnorm), 5, mean = 3))
call3
## rnorm(5, mean = 3)
这三种方法创建相同的调用;也就是说,它们调用具有相同名称和相同参数的函数,这可以通过使用 identical() 调用三个结果调用对象来确认:
identical(call1, call2)
## [1] TRUE
identical(call2, call3)
## [1] TRUE
评估表达式
在捕获一个表达式之后,下一步是评估它。这可以通过eval()函数来完成。
例如,如果我们输入sin(1)并回车,值将立即显示:
sin(1)
## [1] 0.841471
为了控制sin(1)的评估,我们可以使用quote()来捕获表达式,然后使用eval()来评估函数调用:
call1 <- quote(sin(1))
call1
## sin(1)
eval(call1)
## [1] 0.841471
我们可以捕获任何语法正确的表达式,这允许我们quote()一个使用未定义变量的表达式:
call2 <- quote(sin(x))
call2
## sin(x)
在call2中,sin(x)使用了一个未定义的变量x。如果我们直接评估它,将发生错误:
eval(call2)
## Error in eval(expr, envir, enclos): object 'x' not found
这个错误类似于当我们直接运行未定义的x的sin(x)时发生的情况:
sin(x)
## Error in eval(expr, envir, enclos): object 'x' not found
直接在控制台运行和使用eval()之间的区别在于eval()允许我们提供一个列表来评估给定的表达式。在这种情况下,我们不需要创建变量x,而是提供一个包含x的临时列表,这样表达式就可以在列表中查找符号:
eval(call2, list(x = 1))
## [1] 0.841471
另外,eval()函数也接受一个用于符号查找的环境。在这里,我们将创建一个新的环境e1,在其中我们创建一个值为1的变量x,然后我们在e1中的调用中使用eval():
e1 <- new.env()
e1$x <- 1
eval(call2, e1)
## [1] 0.841471
当捕获的表达式有更多未定义变量时,同样的逻辑也适用:
call3 <- quote(x ^ 2 + y ^ 2)
call3
## x ^ 2 + y ^ 2
如果没有完全指定未定义符号的表达式,直接评估表达式将导致错误:
eval(call3)
## Error in eval(expr, envir, enclos): object 'x' not found
部分指定也是如此:
eval(call3, list(x = 2))
## Error in eval(expr, envir, enclos): object 'y' not found
只有当我们完全指定表达式中的符号值时,评估才能得到一个值:
eval(call3, list(x = 2, y = 3))
## [1] 13
eval(expr, envir, enclos)的评估模型与调用函数相同。函数体是expr,执行环境是envir。如果envir被给定为列表,则封装环境是enclos,否则封装环境是envir的父环境:
这个模型意味着符号查找的确切行为。假设我们使用一个环境来评估call3。由于e1只包含变量x,评估不会继续:
e1 <- new.env()
e1$x <- 2
eval(call3, e1)
## Error in eval(expr, envir, enclos): object 'y' not found
然后,我们创建一个新的环境,其父环境是e1并包含变量y。如果我们现在在e2中评估call3,x和y都被找到,评估工作正常:
e2 <- new.env(parent = e1)
e2$y <- 3
eval(call3, e2)
## [1] 13
在前面的代码中,eval(call3, e2)尝试评估call3,其中e2是执行环境。现在,我们可以通过评估过程来更好地理解它是如何工作的。评估过程通过pryr::call_tree()产生的调用树递归地反映出来:
pryr::call_tree(call3)
## \- ()
## \- `+
## \- ()
## \- `^
## \- `x
## \- 2
## \- ()
## \- `^
## \- `y
## \- 2
首先,它试图找到一个名为 + 的函数。它遍历 e2 和 e1,直到达到基础环境(baseenv()),在那里定义了所有基本算术运算符,才找到 +。然后,+ 需要评估其参数,因此它寻找另一个名为 ^ 的函数,并通过相同的流程找到它。然后,^ 再次需要评估其参数,因此它寻找 e2 中的符号 x。环境 e2 不包含变量 x,因此它继续在 e2 类的父环境 e1 中搜索,并在那里找到 x。最后,它在 e2 中寻找符号 y 并立即找到它。当调用所需的参数准备就绪时,调用可以评估为结果。
另一种方法是向 envir 提供一个列表和一个封装的环境:
e3 <- new.env()
e3$y <- 3
eval(call3, list(x = 2), e3)
## [1] 13
评估过程从从列表生成的执行环境开始,其父环境是按指定方式指定的 e3。然后,过程与前面的示例完全相同。
由于我们做的所有事情本质上都是调用函数,quote() 和 substitute() 可以捕获一切,包括赋值和其他看起来不像调用函数的操作。事实上,例如,x <- 1 实质上是调用 <- 并传递 (x, 1),而 length(x) <- 10 实质上是调用 length<- 并传递 (x, 10)。
为了说明这一点,我们可能构造另一个示例,其中我们创建一个新的变量。
在以下示例中,我们提供一个列表来生成执行环境,并将 e3 作为封装环境:
eval(quote(z <- x + y + 1), list(x = 1), e3)
e3$z
## NULL
因此,z 不会在 e3 中创建,而是在从列表创建的临时执行环境中创建。如果我们,相反,指定 e3 作为执行环境,变量将在其中创建:
eval(quote(z <- y + 1), e3)
e3$z
## [1] 4
总之,eval() 的工作方式与函数调用的行为极为相似,但 eval() 允许我们通过调整其执行和封装环境来自定义表达式的评估,这使我们能够做一些好事,比如 subset,以及一些坏事,如下所示:
eval(quote(1 + 1), list(`+` = `-`))
## [1] 0
理解非标准评估
在前面的章节中,你学习了如何使用 quote() 和 substitute() 来捕获一个表达式作为语言对象,以及如何使用 eval() 在给定的列表或环境中评估它。这些函数构成了 R 中的元编程功能,允许我们调整标准评估。元编程的主要应用是执行非标准评估,以使某些使用更加容易。在接下来的章节中,我们将讨论一些示例,以更好地理解它是如何工作的。
使用非标准评估实现快速子集选择
通常,我们需要从一个向量中提取某个子集。子集的范围可能是前几个元素,最后几个元素,或者中间的一些元素。
前两种情况可以通过 head(x, n) 和 tail(x, n) 容易地处理。第三种情况需要输入向量的长度。
例如,假设我们有一个整数向量,并想要取出从第三个到最后第五个的元素:
x <- 1:10
x[3:(length(x) -5)]
## [1] 3 4 5
前面的子集表达式使用了 x 两次,看起来有点冗余。我们可以定义一个快速子集函数,它使用元编程功能提供一个特殊符号来引用输入向量的长度。以下函数 qs 是这个想法的简单实现,它允许我们使用点 (.) 来表示输入向量 x 的长度:
qs <- function(x, range) {
range <- substitute(range)
selector <- eval(range, list(. =length(x)))
x[selector]
}
使用这个函数,我们可以用 3:(. - 5) 来表示与激励示例相同的范围:
qs(x, 3:(. -5))
## [1] 3 4 5
我们也可以很容易地通过从最后一个元素开始计数来挑选出一个数字:
qs(x, . -1)
## [1] 9
基于 qs(),以下函数被设计用来从输入向量 x 中修剪 n 个元素的边缘;也就是说,它返回一个没有 x 的第一个 n 个和最后一个 n 个元素的向量:
trim_margin <- function(x, n) {
qs(x, (n + 1):(. -n -1))
}
函数看起来没问题,但当我们用普通输入调用它时,会发生错误:
trim_margin(x, 3)
## Error in eval(expr, envir, enclos): object 'n' not found
为什么找不到 n?为了理解为什么会发生这种情况,我们需要分析 trim_margin 被调用时符号查找的路径。在下一节中,我们将详细讨论这个问题,并介绍动态作用域的概念来解决该问题。
理解动态作用域
在尝试解决这个问题之前,让我们用你所学到的知识来分析发生了什么错误。当我们调用 trim_margin(x, 3) 时,我们在一个新的执行环境中调用 qs(x, (n + 1):(. - n - 1)),并带有 x 和 n。qs() 是特殊的,因为它使用非标准评估。更具体地说,它首先将 range 作为语言对象捕获,然后使用一个包含要提供的额外符号的列表来评估它,此时,这个列表只包含 . = length(x)。
错误刚好发生在 eval(range, list(. = length(x)))。要修剪的边缘元素数量 n 在这里找不到。评估的包围环境肯定有问题。现在,我们将更仔细地查看 eval() 函数的 enclos 参数的默认值:
eval
## function (expr, envir = parent.frame(), enclos = if (is.list(envir) ||
## is.pairlist(envir)) parent.frame() else baseenv())
## .Internal(eval(expr, envir, enclos))
## <bytecode: 0x00000000106722c0>
## <environment: namespace:base>
eval() 的定义说明,如果我们向 envir 提供一个列表,这正是我们所做的,enclos 将默认取 parent.frame(),即 eval() 的调用环境;也就是说,当我们调用 qs() 时执行的 环境。当然,qs() 的任何执行环境中都没有 n。
在这里,我们暴露了在 trim_margin() 中使用 substitute() 的一个缺点,因为表达式只有在正确的上下文中才有完全的意义,即 trim_margin() 的执行环境,也是 qs() 的调用环境。不幸的是,substitute() 只捕获表达式;它不捕获表达式有意义的上下文。因此,我们必须自己来做这件事。
现在,我们知道问题出在哪里了。解决方案很简单:始终使用捕获表达式定义的正确封装环境。在这种情况下,我们指定 enclos = parent.frame(),这样 eval() 就会在 qs() 的调用环境中寻找所有除 . 之外的符号,即 trim_margin() 的执行环境,其中提供了 n。
以下代码行是 qs() 的固定版本:
qs <- function(x, range) {
range <- substitute(range)
selector <- eval(range, list(. =length(x)), parent.frame())
x[selector]
}
我们可以用之前出错的相同代码测试这个函数:
trim_margin(x, 3)
## [1] 4 5 6
现在,函数以正确的方式工作。实际上,这种机制就是所谓的动态作用域。回想一下你在上一章中学到的内容。每次函数被调用时,都会创建一个执行环境。如果一个符号在执行环境中找不到,它将搜索封装环境。
在标准评估中使用的词法作用域中,函数的封装环境在函数定义时确定,其定义的环境也是如此。
然而,与在非标准评估中使用的动态作用域相比,封装环境应该是捕获表达式定义的调用环境,以便符号可以在自定义执行环境或封装环境中找到,包括其父环境。
总之,当一个函数使用非标准评估时,确保动态作用域正确实现是非常重要的。
使用公式捕获表达式和环境
正确实现动态作用域,我们使用 parent.frame() 来追踪 substitute() 捕获的表达式。一个更简单的方法是使用公式同时捕获表达式和环境。
在数据处理章节中,我们看到了公式通常用来表示变量之间的关系。大多数模型函数(如 lm())接受一个公式来指定响应变量和解释变量之间的关系。
实际上,公式对象比这要简单得多。它自动捕获 ~ 旁边的表达式及其创建的环境。例如,我们可以直接创建一个公式并将其存储在一个变量中:
formula1 <- z ~ x ^ 2 + y ^ 2
我们可以看到,公式本质上是一个具有 formula 类的语言对象:
typeof(formula1)
## [1] "language"
class(formula1)
## [1] "formula"
如果我们将公式转换为列表,我们可以更仔细地查看其结构:
str(as.list(formula1))
## List of 3
## $ : symbol ~
## $ : symbol z
## $ : language x² + y²
## - attr(*, "class")= chr "formula"
## - attr(*, ".Environment") =< environment: R_GlobalEnv>
我们可以看到,formula1 不仅捕获了 ~ 两边的语言对象表达式,还捕获了其创建的环境。实际上,一个公式仅仅是捕获了参数和调用环境的 ~ 函数调用。如果 ~ 的两边都指定了,调用的长度是 3:
is.call(formula1)
## [1] TRUE
length(formula1)
## [1] 3
要访问它捕获的语言对象,我们可以提取第二个和第三个元素:
formula1[[2]]
## z
formula1[[3]]
## x² + y²
要访问其创建的环境,我们可以调用 environment():
environment(formula1)
## <environment: R_GlobalEnv>
公式也可以是右侧的,即只指定 ~ 的右侧。以下是一个例子:
formula2 <- ~x + y
str(as.list(formula2))
## List of 2
## $ : symbol ~
## $ : language x + y
## - attr(*, "class")= chr "formula"
## - attr(*, ".Environment")=<environment: R_GlobalEnv>
在这种情况下,只提供了一个~的参数并捕获了它,因此我们有两个语言对象的调用,我们可以通过提取其第二个元素来访问它捕获的表达式:
length(formula2)
## [1] 2
formula2[[2]]
## x + y
了解了公式的运作方式,我们可以使用该公式实现qs()和trim_margin()的另一个版本。
以下函数qs2在range是公式时与qs的行为一致,否则它直接使用range来子集x:
qs2 <- function(x, range) {
selector <- if (inherits(range, "formula")) {
eval(range[[2]], list(. = length(x)), environment(range))
} else range
x[selector]
}
注意,我们使用inherits(range, "formula")来检查range是否是公式,并使用environment(range)来实现动态作用域。然后,我们可以使用右侧公式来激活非标准评估:
qs2(1:10, ~3:(. -2))
## [1] 3 4 5 6 7 8
否则,我们可以使用标准评估:
qs2(1:10, 3)
## [1] 3
现在,我们可以使用公式重新实现trim_margin,使用qs2:
trim_margin2 <- function(x, n) {
qs2(x, ~ (n + 1):(. -n -1))
}
可以验证,动态作用域工作正常,因为trim_margin2中使用的公式自动捕获执行环境,这也是公式和n定义的环境:
trim_margin2(x, 3)
## [1] 4 5 6
使用元编程实现子集
在了解了语言对象、评估函数和动态作用域之后,我们现在有能力实现subset的一个版本。
实现的底层思想很简单:
-
捕获行子集表达式,并在本质上是一个列表的数据框内评估它:
-
捕获列选择表达式,并在命名整数索引列表中评估它:
-
使用生成的行选择器(逻辑向量)和列选择器(整数向量)来子集数据框:
这里是前面逻辑的实现:
subset2 <- function(x, subset = TRUE, select = TRUE) {
enclos <- parent.frame()
subset <- substitute(subset)
select <- substitute(select)
row_selector <- eval(subset, x, enclos)
col_envir <- as.list(seq_along(x))
names(col_envir) <- colnames(x)
col_selector <- eval(select, col_envir, enclos)
x[row_selector, col_selector]
}
行子集的功能比列选择部分更容易实现。要执行行子集,我们只需要捕获subset并在数据框内评估它。
在这里,列子集更复杂。我们将创建一个整数索引列表,并为它们提供相应的名称。例如,具有三列(例如,x、y和z)的数据框需要一个索引列表,如list(a = 1, b = 2, c = 3),这允许我们以select = c(x, y)的形式选择行,因为c(x, y)是在列表内评估的。
现在,subset2的行为与内置函数subset非常接近:
subset2(mtcars, mpg >= quantile(mpg, 0.9), c(mpg, cyl, qsec))
## mpg cyl qsec
## Fiat 128 32.4 4 19.47
## Honda Civic 30.4 4 18.52
## Toyota Corolla 33.9 4 19.90
## Lotus Europa 30.4 4 16.90
两种实现都允许我们使用a:b来选择a和b之间的所有列,包括两边:
subset2(mtcars, mpg >= quantile(mpg, 0.9), mpg:drat)
## mpg cyl disp hp drat
## Fiat 128 32.4 4 78.7 66 4.08
## Honda Civic 30.4 4 75.7 52 4.93
## Toyota Corolla 33.9 4 71.1 65 4.22
## Lotus Europa 30.4 4 95.1 113 3.77
摘要
在本章中,你学习了函数式编程的概念和用法,包括闭包和高阶函数。我们进一步深入研究了元编程功能,包括语言对象、评估函数、公式以及动态作用域的实现,以确保在自定义评估行为时正确处理用户输入的表达式。由于许多流行的包使用元编程和非标准评估来简化交互式分析,因此了解其工作原理非常重要,这样我们才能更有信心预测和调试代码。
在下一章中,我们将步入 R 的另一个基础设施:面向对象编程系统。你将学习面向对象编程的基本理念,这一理念如何在 R 中实现,以及它如何有用。更具体地说,我们将从较为宽松的 S3 系统开始,涵盖提供更丰富功能的严格系统 S4,并介绍参考类以及新实现的 R5 系统。
第十章:面向对象编程
在上一章中,你学习了函数式编程和元编程如何使自定义函数的行为成为可能。我们可以在某个特定上下文中创建一个函数,这被称为闭包。我们还可以通过传递函数就像传递其他对象一样来使用高阶函数。
在本章中,你将学习如何通过进入面向对象编程的世界来自定义对象的行为。R 提供了几个不同的面向对象系统来工作。乍一看,它们与其他编程语言中的面向对象系统看起来相当不同。然而,基本思想大多是相同的。我将简要解释对象类和方法的观念,并展示它们如何有助于统一我们处理数据和模型的方式。
在接下来的几节中,我们将从入门级别介绍以下主题:
-
面向对象编程的思想
-
S3 系统
-
S4 系统
-
参考类
-
R6 包
最后,我们将从几个方面比较这些系统。
介绍面向对象编程
如果你来自 Java、Python、C++、C#等编程语言的开发者,你应该对面向对象的编程风格感到熟悉。然而,如果你不熟悉任何其他面向对象的编程语言,你可能对这个术语感到困惑,因为它听起来有点抽象。但是,不用担心;如果我们从编程的核心来考虑,这比看起来要容易理解得多。
当我们谈论编程时,我们实际上是在谈论使用编程工具来解决问题。在解决问题之前,我们需要首先对问题进行建模。传统上,我们通常找出一个需要几个步骤来解决数值计算问题的算法。然后,我们编写一些过程式代码来实现该算法。例如,大多数统计算法都是以过程式风格实现的,也就是说,根据理论将输入转换为输出,一步一步地。
然而,许多问题与现实世界紧密相连,以至于通过定义一些对象类以及它们之间的交互来建模问题可能非常直观。换句话说,通过面向对象的编程方式,我们只是试图在适当的抽象级别上模拟相关对象的重要特征。
面向对象编程涉及许多概念。在这里,我们只关注其中最重要的。
理解类和方法
本章最重要的概念是类和方法。类描述了对象是什么,方法定义了它能够做什么。这些概念在现实世界中有着无数的实际例子。例如,animal 可以是一个类。在这个类中,我们可以定义诸如发出声音和移动等方法。vehicle 也可以是一个类。在这个类中,我们可以定义诸如启动、移动和停止等方法。person 可以是一个具有诸如醒来、与人交谈和去某个地方等方法的类。
对于特定的问题,我们可以根据我们的需求定义类来模拟我们正在处理的对象,并为它们定义方法来模拟对象之间的交互。对象不需要是物理的或可触摸的。一个实际的例子是银行账户。它只存在于银行的数据库中,但使用一些数据字段(如余额和所有者)和一些方法(如存款、取款和账户间转账)来模拟银行账户是有用的。
理解继承
面向对象编程的另一个重要概念是继承,即我们可以定义一个继承自基类(或超类)行为的类,并具有一些新的行为。通常,基类在概念上更抽象和通用,而继承类更具体和特定。这在我们的日常生活中是一个简单的真理。
例如,dog 和 cat 是从 animal 类继承的两个类。animal 类定义了诸如发出声音和移动等方法。dog 和 cat 类继承了这些方法,但以不同的方式实现,以便它们发出不同的声音并以不同的方式移动。
此外,car、bus 和 airplane 是从 vehicle 类继承的类。vehicle 类定义了诸如 start、move 和 stop 等方法。car、bus 和 airplane 类继承了这些功能,但以不同的方式工作。car 和 bus 可以在表面上以二维方式移动,而飞机可以在空中以三维方式移动。
面向对象编程系统中还有一些其他概念,但在这章中我们不会对它们进行重点讨论。让我们记住我们提到的概念,并看看这些概念在 R 编程中的工作方式。
与 S3 对象系统一起工作
R 中的 S3 对象系统是一个简单、松散的面向对象系统。每个基本对象类型都有一个 S3 类名。例如,integer、numeric、character、logical、list、data.frame 等都是 S3 类。
例如,vec1 类型的类型是 double,这意味着 vec1 的内部类型或存储模式是双精度浮点数。然而,它的 S3 类是 numeric:
vec1 <- c(1, 2, 3)
typeof(vec1)
## [1] "double"
class(vec1)
## [1] "numeric"
data1 类型的类型是 list,这意味着 data1 的内部类型或存储模式是一个列表,但它的 S3 类是 data.frame:
data1 <- data.frame(x = 1:3, y = rnorm(3))
typeof(data1)
## [1] "list"
class(data1)
## [1] "data.frame"
在以下章节中,我们将解释对象内部类型与其 S3 类之间的区别。
理解泛型函数和方法调度
如我们本章前面提到的,一个类可以拥有许多方法来定义其行为,主要是与其他对象一起。在 S3 系统中,我们可以创建泛型函数并为不同的类实现它们作为方法。这就是 S3 方法调度如何使对象类变得重要的。
R 中有很多 S3 泛型函数的简单例子。每个都是为通用目的定义的,允许不同类的对象为该目的有自己的实现。让我们首先看看head()和tail()函数。它们的功能很简单:head()获取数据对象的前n条记录,而tail()获取数据对象的后n条记录。它与x[1:n]不同,因为它为不同类的对象定义了不同的记录。对于一个原子向量(数值、字符等),前n条记录仅意味着前n个元素。然而,对于一个数据框,前n条记录意味着前n行而不是列。因为数据框本质上是一个列表,直接从数据框中取出前n个元素实际上是取出前n列,这不是head()的意图。
首先,让我们输入head并看看函数内部有什么内容:
head
## function (x, ...)
## UseMethod("head")
## <bytecode: 0x000000000f052e10>
## <environment: namespace:utils>
我们发现这个函数中没有任何实际的实现细节。相反,它调用UseMethod("head")使head成为一个所谓的泛型函数以执行方法调度,即它可能对不同的类有不同的行为。
现在,让我们创建两个数据对象,一个是numeric类,另一个是data.frame类,然后看看当我们把每个对象传递给泛型函数head时,方法调度是如何工作的:
num_vec <- c(1, 2, 3, 4, 5)
data_frame <- data.frame(x = 1:5, y = rnorm(5))
对于数值向量,head简单地取其前几个元素。
head(num_vec, 3)
## [1] 1 2 3
然而,对于数据框,head取其前几行而不是列:
head(data_frame, 3)
## x y
## 1 1 0.8867848
## 2 2 0.1169713
## 3 3 0.3186301
这里,我们可以使用一个函数来模拟head的行为。以下代码是一个简单的实现,它取任何给定对象x的前n个元素:
simple_head <- function(x, n) {
x[1:n]
}
对于数值向量,它的工作方式与head完全相同:
simple_head(num_vec, 3)
## [1] 1 2 3
然而,对于数据框,它实际上试图取出前n列。回想一下,数据框是一个列表,数据框的每一列都是列表的一个元素。如果n超过了数据框的列数或等价地列表的元素数,可能会出错:
simple_head(data_frame, 3)
## Error in `[.data.frame`(x, 1:n): undefined columns selected
为了改进实现,我们可以在采取任何措施之前检查输入对象x是否是数据框:
simple_head2 <- function(x, n) {
if (is.data.frame(x)) {
x[1:n,]
} else {
x[1:n]
}
}
现在,对于原子向量和数据框,simple_head2的行为几乎与head相同:
simple_head2(num_vec, 3)
## [1] 1 2 3
simple_head2(data_frame, 3)
## x y
## 1 1 0.8867848
## 2 2 0.1169713
## 3 3 0.3186301
然而,head提供了更多功能。要查看为head实现的函数,我们可以调用methods(),它返回一个字符向量:
methods("head")
## [1] head.data.frame* head.default* head.ftable*
## [4] head.function* head.matrix head.table*
## see '?methods' for accessing help and source code
这表明除了向量和数据框之外,head 已经为许多其他类提供了一些内置方法。请注意,这些方法都是以 method.class 的形式存在的。如果我们输入一个 data.frame 对象,head 将内部调用 head.data.frame。同样,如果我们输入一个 table 对象,它将内部调用 head.table。如果我们输入一个数值向量呢?当找不到与输入对象类匹配的方法时,如果定义了 method.default,它将转向 method.default。在这种情况下,所有原子向量都通过 head.default 匹配。泛型函数找到特定输入对象适当方法的这个过程称为 方法调度。
看起来我们可以在函数中始终检查输入对象的类以实现方法调度的目标。然而,为另一个类实现方法以扩展泛型函数的功能更容易,因为你不必每次都通过添加特定的类检查条件来修改原始的泛型函数。我们将在本节稍后讨论这一点。
使用内置类和方法
S3 泛型函数和方法在统一我们处理各种模型的方式上最有用。例如,我们可以创建一个线性模型,并使用泛型函数从不同的角度查看模型:
lm1 <- lm(mpg ~ cyl + vs, data = mtcars)
在前面的章节中,我们提到线性模型本质上是由模型拟合产生的数据字段的列表。这就是为什么 lm1 的类型是 list,但它的类是 lm,这样泛型函数就会为 lm 选择方法:
typeof(lm1)
## [1] "list"
class(lm1)
## [1] "lm"
S3 方法调度甚至在没有显式调用 S3 泛型函数的情况下发生。如果我们输入 lm1 并查看其内容,模型对象将被打印出来:
lm1
##
## Call:
## lm(formula = mpg ~ cyl + vs, data = mtcars)
##
## Coefficients:
## (Intercept) cyl vs
## 39.6250 -3.0907 -0.9391
实际上,print 是隐式调用的:
print(lm1)
##
## Call:
## lm(formula = mpg ~ cyl + vs, data = mtcars)
##
## Coefficients:
## (Intercept) cyl vs
## 39.6250 -3.0907 -0.9391
我们知道 lm1 本质上是一个列表。为什么打印时它看起来不像一个列表?这是因为 print 是一个泛型函数,它有一个为 lm 打印线性模型最重要信息的 lm 方法。我们可以通过 getS3method("print", "lm") 获取我们实际调用的方法。实际上,print(lm1) 转向 stats:::print.lm,这可以通过检查它们是否相同来验证:
identical(getS3method("print", "lm"), stats:::print.lm)
## [1] TRUE
注意,print.lm 定义在 stats 包中,但并未公开导出,因此我们必须使用 ::: 来访问它。通常,访问包中的内部对象是一个坏主意,因为它们可能在不同的版本中发生变化,并且用户看不到这些变化。在大多数情况下,我们根本不需要这样做,因为像 print 这样的泛型函数会自动选择正确的调用方法。
在 R 中,print 方法为许多类实现了。以下代码展示了为不同类实现了多少种方法:
length(methods("print"))
## [1] 198
你可以调用 methods("print") 来查看整个列表。实际上,如果加载了更多的包,这些包中的类将定义更多的方法。
虽然print显示了模型的简短版本,但summary显示了详细的信息。此函数也是一个泛型函数,它为所有类型的模型类提供了许多方法:
summary(lm1)
##
## Call:
## lm(formula = mpg ~ cyl + vs, data = mtcars)
##
## Residuals:
## Min 1Q Median 3Q Max
## -4.923 -1.953 -0.081 1.319 7.577
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 39.6250 4.2246 9.380 2.77e-10 ***
## cyl -3.0907 0.5581 -5.538 5.70e-06 ***
## vs -0.9391 1.9775 -0.475 0.638
## ---
## Signif. codes:
## 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 3.248 on 29 degrees of freedom
## Multiple R-squared: 0.7283, Adjusted R-squared: 0.7096
## F-statistic: 38.87 on 2 and 29 DF, p-value: 6.23e-09
线性模型的总结不仅显示了print显示的内容,还提供了系数和整体模型的一些重要统计数据。实际上,summary的输出是另一个可以访问其包含数据的对象。在这种情况下,它是一个summary.lm类的列表,并且它有自己的print方法:
lm1summary <- summary(lm1)
typeof(lm1summary)
## [1] "list"
class(lm1summary)
## [1] "summary.lm"
要列出lm1summary包含的元素,我们可以查看列表中的名称:
names(lm1summary)
## [1] "call" "terms" "residuals"
## [4] "coefficients" "aliased" "sigma"
## [7] "df" "r.squared" "adj.r.squared"
##[10] "fstatistic" "cov.unscaled"
我们可以以完全相同的方式访问每个元素,就像从典型的列表中提取元素一样。例如,要访问线性模型的估计系数,我们可以使用lm1$coefficients。或者,我们可以使用以下代码来访问估计系数:
coef(lm1)
## (Intercept) cyl vs
## 39.6250234 -3.0906748 -0.9390815
在这里,coef也是一个泛型函数,它从模型对象中提取系数向量。要访问模型总结中的详细系数表,我们可以使用lm1summary$coefficients或再次使用coef:
coef(lm1summary)
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 39.6250234 4.2246061 9.3795782 2.765008e-10
## cyl -3.0906748 0.5580883 -5.5379676 5.695238e-06
## vs -0.9390815 1.9775199 -0.4748784 6.384306e-01
还有其他有用的模型相关泛型函数,如plot、predict等。我们提到的所有这些泛型函数都是 R 中用户与估计模型交互的标准方式。不同的内置模型和第三方包提供的模型都试图实现这些泛型函数,这样我们就不需要记住不同的函数集来处理每个模型。
例如,我们可以使用plot函数对线性模型进行 2x2 分区:
oldpar <- par(mfrow = c(2, 2))
plot(lm1)
par(oldpar)
这产生了以下包含四个部分的图像:

我们可以看到我们使用了plot函数对线性模型进行操作,这将导致四个诊断图,显示残差的特征,这有助于判断模型拟合是好是坏。注意,如果我们直接在控制台中调用plot函数到lm,四个图将依次交互式完成。为了避免这种情况,我们调用par()将绘图区域划分为 2x2 的子区域。
大多数统计模型都是有用的,因为它们可以用新数据来预测。为此,我们使用predict。在这种情况下,我们可以将线性模型和新数据提供给predict,它将找到使用新数据进行预测的正确方法:
predict(lm1, data.frame(cyl = c(6, 8), vs = c(1, 1)))
## 1 2
## 20.14189 13.96054
此函数可以在样本内和样本外使用。如果我们向模型提供新数据,则是样本外预测。如果我们提供的数据已经在样本中,则是样本内预测。在这里,我们可以创建实际值(mtcars$mpg)和拟合值之间的散点图,以查看拟合的线性模型预测得有多好:
plot(mtcars$mpg, fitted(lm1))
生成的图如下所示:

在这里,fitted 也是一个泛型函数,在这种情况下,它等同于 lm1$fitted.values,拟合值也等于使用 predict(lm1, mtcars) 在原始数据集上预测的值。
响应变量的实际值与拟合值之间的差异称为残差。我们可以使用另一个泛型函数 residuals 来访问数值向量,或者等价地使用 lm1$residuals。在这里,我们将对残差进行密度图绘制:
plot(density(residuals(lm1)),
main = "Density of lm1 residuals")
生成的图如下所示:

在前面的函数调用中,所有涉及到的函数都是泛型函数。residuals 函数从 lm1 中提取残差并返回一个数值向量。density 函数创建一个类为 density 的列表来存储残差密度函数的估计数据。最后,plot 转换为 plot.density 来创建密度图。
这些泛型函数不仅与 lm、glm 和其他内置模型一起工作,还与其他包提供的模型一起工作。例如,我们使用 rpart 包使用与前面例子中相同的数据和公式来拟合回归树模型。
如果你还没有安装该包,你需要运行以下代码:
install.packages("rpart")
现在,包已经准备好附加。我们以与 lm 完全相同的方式调用 rpart:
library(rpart)
tree_model <- rpart(mpg ~ cyl + vs, data = mtcars)
我们之所以能够这样做,是因为包的作者希望函数调用与我们在 R 中调用内置函数的方式保持一致。结果对象是一个类为 rpart 的列表,它与 lm 类似,也是一个类为 rpart 的列表:
typeof(tree_model)
## [1] "list"
class(tree_model)
## [1] "rpart"
与 lm 对象一样,rpart 也实现了一系列泛型方法。例如,我们使用 print 函数以自己的方式打印模型:
print(tree_model)
## n = 32
##
## node), split, n, deviance, yval
## * denotes terminal node
##
## 1) root 32 1126.04700 20.09062
## 2) cyl >= 5 21 198.47240 16.64762
## 4) cyl >= 7 14 85.20000 15.10000 *
## 5) cyl < 7 7 12.67714 19.74286 *
## 3) cyl < 5 11 203.38550 26.66364 *
输出表明 print 为 rpart 提供了方法,它简要地显示了回归树的外观。除了 print 之外,summary 还提供了关于模型拟合的更详细信息:
summary(tree_model)
## Call:
## rpart(formula = mpg ~ cyl + vs, data = mtcars)
## n = 32
##
## CP nsplit rel error xerror xstd
## 1 0.64312523 0 1.0000000 1.0844542 0.25608044
## 2 0.08933483 1 0.3568748 0.3858990 0.07230642
## 3 0.01000000 2 0.2675399 0.3875795 0.07204598
##
## Variable importance
## cyl vs
## 65 35
##
## Node number 1: 32 observations, complexity param=0.6431252
## mean=20.09062, MSE=35.18897
## left son=2 (21 obs) right son=3 (11 obs)
## Primary splits:
## cyl < 5 to the right, improve=0.6431252, (0 missing)
## vs < 0.5 to the left, improve=0.4409477, (0 missing)
## Surrogate splits:
## vs < 0.5 to the left, agree=0.844, adj=0.545, (0 split)
##
## Node number 2: 21 observations, complexity param=0.08933483
## mean=16.64762, MSE=9.451066
## left son=4 (14 obs) right son=5 (7 obs)
## Primary splits:
## cyl < 7 to the right, improve=0.5068475, (0 missing)
## Surrogate splits:
## vs < 0.5 to the left, agree=0.857, adj=0.571, (0 split)
##
## Node number 3: 11 observations
## mean=26.66364, MSE=18.48959
##
## Node number 4: 14 observations
## mean=15.1, MSE=6.085714
##
## Node number 5: 7 observations
## mean=19.74286, MSE=1.81102
同样,plot 和 text 也为 rpart 提供了方法来可视化它:
oldpar <- par(xpd = NA)
plot(tree_model)
text(tree_model, use.n = TRUE)
par(oldpar)
然后,我们得到以下树状图:

我们可以使用 predict 使用新数据进行预测,就像我们在前面的例子中处理线性模型一样:
predict(tree_model, data.frame(cyl = c(6, 8), vs = c(1, 1)))
## 1 2
## 19.74286 15.10000
注意,并非所有模型都实现了所有泛型函数的方法。例如,由于回归树不是一个简单的参数模型,它没有实现 coef 的方法:
coef(tree_model)
## NULL
为现有类定义泛型函数
在上一节中,你学习了如何使用现有类和方法来处理模型对象。然而,S3 系统还允许我们创建自己的类和泛型函数。
回想一下我们使用条件表达式来模拟head的方法调度示例。我们提到它工作,但通常不是最佳实践。S3 通用函数更灵活且更容易扩展。要定义一个通用函数,我们通常创建一个函数,在其中调用UseMethod来触发方法调度。然后,我们为想要通用函数与之一起工作的类创建形式为method.class的方法函数,通常还有一个形式为method.default的默认方法来捕获所有其他情况。以下是使用通用函数和方法重写此示例的简单示例。在这里,我们将创建一个新的通用函数generic_head,它有两个参数:输入对象x和要取的记录数n。通用函数只调用UseMethod("generic_head")来请求 R 根据x的类进行方法调度:
generic_head <- function(x, n)
UseMethod("generic_head")
对于原子向量(数值、字符、逻辑等),应该取前n个元素。我们可以分别定义generic_head.numeric、generic_head.character等,但在这种情况下,定义一个默认方法来捕获所有其他generic_head.class方法未匹配的案例看起来更好:
generic_head.default <- function(x, n) {
x[1:n]
}
现在,generic_head只有一个方法,这相当于根本不使用通用函数:
generic_head(num_vec, 3)
## [1] 1 2 3
由于我们没有为data.frame类定义方法,提供数据框将回退到generic_head.default,这会导致由于越界列索引的无效访问而引发错误:
generic_head(data_frame, 3)
## Error in `[.data.frame`(x, 1:n): undefined columns selected
然而,假设我们为data.frame定义了一个方法:
generic_head.data.frame <- function(x, n) {
x[1:n,]
}
通用函数按预期工作:
generic_head(data_frame, 3)
## x y
## 1 1 0.8867848
## 2 2 0.1169713
## 3 3 0.3186301
你可能会注意到我们之前实现的方法不够健壮,因为我们没有检查参数。例如,如果n大于输入对象的元素数量,函数的行为将不同,通常是不希望的方式。我将把它留给你作为练习,使方法更健壮,并适当地处理边缘情况。
创建新类的对象
现在,是时候给出一些定义新类的示例了。请注意,class(x)获取x的类,而class(x) <- "some_class"将x的类设置为some_class。
使用列表作为底层数据结构
就像lm和rpart一样,列表可能是创建新类最广泛使用的底层数据结构。这是因为一个类代表一种可以存储不同类型数据且长度不同的对象类型,并且有一些方法可以与其他对象交互。
在以下示例中,我们将定义一个名为product的函数,它创建一个具有名称、价格和库存的product类列表。我们将定义它自己的print方法,并在继续的过程中添加更多行为:
product <- function(name, price, inventory) {
obj <- list(name = name,
price = price,
inventory = inventory)
class(obj) <- "product"
obj
}
注意,我们首先创建了一个列表,将其类替换为product,然后最终返回对象。实际上,对象的类是一个字符向量。另一种方法是使用structure():
product <- function(name, price, inventory) {
structure(list(name = name,
price = price,
inventory = inventory),
class = "product")
}
现在,我们有一个生成product类对象的函数。在下面的代码中,我们将调用product()并创建这个类的实例:
laptop <- product("Laptop", 499, 300)
就像所有之前的对象一样,我们可以看到它的内部数据结构和它的 S3 类以进行方法调度:
typeof(laptop)
## [1] "list"
class(laptop)
## [1] "product"
显然,laptop是一个类product的列表,正如我们创建的那样。由于我们没有为这个类定义任何方法,它的行为与普通列表对象没有区别。如果我们输入它,它将以列表的形式打印出来,并带有其自定义的类属性:
laptop
## $name
## [1] "Laptop"
##
## $price
## [1] 499
##
## $inventory
## [1] 300
##
## attr(,"class")
## [1] "product"
首先,我们可以为这个类实现print方法。在这里,我们希望类及其中的数据字段以紧凑的样式打印出来:
print.product <- function(x, ...) {
cat("<product>\n")
cat("name:", x$name, "\n")
cat("price:", x$price, "\n")
cat("inventory:", x$inventory, "\n")
invisible(x)
}
这是一个惯例,print方法返回输入对象本身以供进一步使用。如果打印被定制,那么我们通常使用invisible来抑制函数返回的相同对象的重复打印。你可以尝试直接返回x并看看会发生什么。
然后,我们再次输入变量。由于print方法已经被定义,它将被调度到print.product。
laptop
## <product>
## name: Laptop
## price: 499
## inventory: 300
我们可以像从列表中提取元素一样访问laptop中的元素:
laptop$name
## [1] "Laptop"
laptop$price
## [1] 499
laptop$inventory
## [1] 300
如果我们创建另一个实例并将这两个实例放入一个列表中,当列表被打印时,print.product仍然会被调用:
cellphone <- product("Phone", 249, 12000)
products <- list(laptop, cellphone)
products
## [[1]]
## <product>
## name: Laptop
## price: 499
## inventory: 300
##
## [[2]]
## <product>
## name: Phone
## price: 249
## inventory: 12000
这是因为当products作为列表打印时,它会为每个元素调用print,这也会导致方法调度。
创建 S3 类比大多数需要正式定义类的编程语言要简单得多。对参数进行充分的检查以确保创建的对象在内部与类所表示的内容一致是很重要的。
例如,如果没有适当的检查,我们可以创建一个具有负数和非整数的库存的产品:
product("Basket", 150, -0.5)
## <product>
## name: Basket
## price: 150
## inventory: -0.5
为了避免这种情况,我们需要在对象生成函数product中添加一些检查条件:
product <- function(name, price, inventory) {
stopifnot(
is.character(name), length(name) == 1,
is.numeric(price), length(price) == 1,
is.numeric(inventory), length(inventory) == 1,
price > 0, inventory >= 0)
structure(list(name = name,
price = as.numeric(price),
inventory = as.integer(inventory)),
class = "product")
}
函数得到了增强,其中name必须是一个单独的字符串,price必须是一个单独的正数,而inventory必须是一个单独的非负数。有了这个函数,我们不会错误地创建荒谬的产品,并且这样的错误可以在早期被发现:
product("Basket", 150, -0.5)
## Error: inventory >= 0 is not TRUE
除了定义新的类,我们还可以定义新的泛型函数。在下面的代码中,我们将定义一个新的泛型函数,称为value,并通过测量产品的库存值来实现product的方法:
value <- function(x, ...)
UseMethod("value")
value.default <- function(x, ...) {
stop("value is undefined")
}
value.product <- function(x, ...) {
x$price * x$inventory
}
对于其他类,它调用value.default并停止。现在,value可以与所有我们创建的product实例一起使用:
value(laptop)
## [1] 149700
value(cellphone)
## [1] 2988000
泛型函数还可以通过为输入向量或列表中的每个元素执行方法调度与 apply 家族函数一起工作:
sapply(products, value)
## [1] 149700 2988000
另一个问题是一旦我们创建了一个特定类的对象,这意味着我们不能再改变它了吗?不,我们仍然可以改变它。在这种情况下,我们可以修改laptop中现有的元素:
laptop$price <- laptop$price * 0.85
我们也可以在laptop中创建一个新的元素:
laptop$value <- laptop$price * laptop$inventory
现在,我们可以查看它,并且更改是有效的:
laptop
## <product>
## name: Laptop
## price: 424.15
## inventory: 300
更糟糕的是,我们甚至可以通过将其设置为 NULL 来删除一个元素。这就是为什么 S3 系统被认为比较宽松。你不能确保特定类型的对象具有一组固定的数据字段和方法。
使用原子向量作为底层数据结构
在上一节中,我们演示了从列表对象创建新类的示例。实际上,有时从原子向量创建新类对象也是有用的。在本节中,我将向您展示一系列创建具有百分比表示的向量的步骤。
我们首先定义一个函数,percent。这个函数简单地检查输入是否为数值向量,并将其类更改为 percent,它继承自 numeric:
percent <- function(x) {
stopifnot(is.numeric(x))
class(x) <- c("percent", "numeric")
x
}
这里的继承意味着方法调度首先寻找 percent 的方法。如果没有找到,那么它将寻找 numeric 的方法。因此,类名的顺序很重要。S3 继承将在下一节中详细讨论。
现在,我们可以从数值向量创建一个百分比向量:
pct <- percent(c(0.1, 0.05, 0.25, 0.23))
pct
## [1] 0.10 0.05 0.25 0.23
## attr(,"class")
## [1] "percent" "numeric"
目前,尚未实现 percent 的方法。因此,pct 看起来像是一个具有自定义类属性的普通数值向量。这个类的作用是显示其值的百分比形式,例如 25%,而不是其原始的十进制表示。
为了实现这个目标,我们首先为 percent 类实现 as.character,生成正确的百分比形式的字符串表示:
as.character.percent <- function(x, ...) {
paste0(as.numeric(x) * 100, "%")
}
现在,我们可以得到给定百分比向量的所需字符串表示:
as.character(pct)
## [1] "10%" "5%" "25%" "23%"
同样,我们需要通过直接调用 as.character 来为 percent 实现 format:
format.percent <- function(x, ...) {
as.character(x, ...)
}
现在,format 有相同的效果:
format(pct)
## [1] "10%" "5%" "25%" "23%"
现在,我们可以通过直接调用 format.percent 来为 percent 实现 print:
print.percent <- function(x, ...) {
print(format.percent(x), quote = FALSE)
}
注意,当我们打印格式化的字符串时,我们指定 quote = FALSE,使其看起来像数字而不是字符串。这正是我们想要的效果:
pct
## [1] 10% 5% 25% 23%
注意,算术运算符如 + 和 * 会自动保留输出向量的类。因此,输出向量仍然以百分比形式打印:
pct + 0.2
## [1] 30% 25% 45% 43%
pct * 0.5
## [1] 5% 2.5% 12.5% 11.5%
不幸的是,其他函数可能不会保留其输入的类。例如,sum、mean、max 和 min 将丢弃自定义类,并返回一个普通的数值向量:
sum(pct)
## [1] 0.63
mean(pct)
## [1] 0.1575
max(pct)
## [1] 0.25
min(pct)
## [1] 0.05
为了确保在执行这些计算时百分比形式得到保留,我们需要为 percent 类实现这些方法:
sum.percent <- function(...) {
percent(NextMethod("sum"))
}
mean.percent <- function(x, ...) {
percent(NextMethod("mean"))
}
max.percent <- function(...) {
percent(NextMethod("max"))
}
min.percent <- function(...) {
percent(NextMethod("max"))
}
在第一个方法中,NextMethod("sum") 调用数值类的 sum,并将输出数值向量再次包裹在 percent 中。同样的逻辑也适用于其他三个方法的实现:
sum(pct)
## [1] 63%
mean(pct)
## [1] 15.75%
max(pct)
## [1] 25%
min(pct)
## [1] 5%
现在,这些函数也以百分比形式返回值。然而,如果我们将百分比向量与其他数值值结合,百分比类就会消失:
c(pct, 0.12)
## [1] 0.10 0.05 0.25 0.23 0.12
我们可以对 c 做同样的事情:
c.percent <- function(x, ...) {
percent(NextMethod("c"))
}
现在,将百分比向量与数值值结合也会产生百分比向量:
c(pct, 0.12, -0.145)
## [1] 10% 5% 25% 23% 12% -14.5%
然而,从另一方面来看,当我们对百分比向量进行子集操作或从中提取值时,百分比类将被丢弃:
pct[1:3]
## [1] 0.10 0.05 0.25
pct[[2]]
## [1] 0.05
为了解决这个问题,我们需要以完全相同的方式实现percent的[和[[。您可能会惊讶地看到一个名为[.percent的方法,但当我们使用这些运算符对百分比向量进行操作时,它确实会匹配percent类:
`[.percent` <- function(x, i) {
percent(NextMethod("["))
}
`[[.percent` <- function(x, i) {
percent(NextMethod("[["))
}
现在,子集和提取都保留了百分比类:
pct[1:3]
## [1] 10% 5% 25%
pct[[2]]
## [1] 5%
在实现所有这些方法之后,我们可以将百分比向量作为数据框的列放置:
data.frame(id = 1:4, pct)
## id pct
## 1 1 10%
## 2 2 5%
## 3 3 25%
## 4 4 23%
百分比形式正确地保留为数据框中的列。
理解 S3 继承
S3 系统是宽松的。您只需要创建一个形式为method.class的函数来实现泛型函数的方法。您只需要提供一个包含多个元素的字符向量,以指示向量上的继承关系。
如前所述,类向量决定了方法调度中匹配类的顺序。为了演示这一点,我们将使用一个简单的例子,其中我们将构建具有继承关系的多个类。
假设我们想要模拟一些车辆,如汽车、公交车和飞机。这些车辆有一些共同之处。它们都有名称、速度和位置,并且可以移动。为了模拟它们,我们可以定义一个基类vehicle,它存储了共同的部分。我们还定义了继承自vehicle的car、bus和airplane,但具有自定义的行为。
首先,我们将定义一个函数来创建vehicle对象,这本质上是一个环境。我们选择列表中的环境,因为我们需要它的引用语义,即我们传递对象,修改它不会导致对象的副本。因此,无论在哪里传递,对象始终指向同一辆车:
Vehicle <- function(class, name, speed) {
obj <- new.env(parent = emptyenv())
obj$name <- name
obj$speed <- speed
obj$position <- c(0, 0, 0)
class(obj) <- c(class, "vehicle")
obj
}
注意,class(obj) <- c(class, "vehicle")可能看起来有些模糊,因为class既是函数参数也是基本函数。实际上,class(obj) <-将寻找class<-函数,这样使用就不会造成歧义。Vehicle函数是一个通用车辆类对象的创建器,具有常见的数据字段。以下是一些专门函数,用于创建继承自vehicle的car、bus和airplane:
Car <- function(...) {
Vehicle(class = "car", ...)
}
Bus <- function(...) {
Vehicle(class = "bus", ...)
}
Airplane <- function(...) {
Vehicle(class = "airplane", ...)
}
在这三个先前的函数的基础上,我们可以创建car、bus和airplane对象。所有这些对象都继承自vehicle类。现在,我们为每个类创建一个实例:
car <- Car("Model-A", 80)
bus <- Bus("Medium-Bus", 45)
airplane <- Airplane("Big-Plane", 800)
现在,我们将为vehicle实现一个通用的print方法:
print.vehicle <- function(x, ...) {
cat(sprintf("<vehicle: %s>\n", class(x)[[1]]))
cat("name:", x$name, "\n")
cat("speed:", x$speed, "km/h\n")
cat("position:", paste(x$position, collapse = ", "))
}
由于没有定义print.car、print.bus或print.airplane,输入这些变量将使用print.vehicle打印它们:
car
## <vehicle: car>
## name: Model-A
## speed: 80 km/h
## position: 0, 0, 0
bus
## <vehicle: bus>
## name: Medium-Bus
## speed: 45 km/h
## position: 0, 0, 0
airplane
## <vehicle: airplane>
## name: Big-Plane
## speed: 800 km/h
## position: 0, 0, 0
车辆是一种设计用来驾驶和移动的载体。自然地,我们定义了一个名为 move 的通用函数,该函数修改车辆的位置以反映用户提供的在三维空间中的移动。由于不同的车辆以不同的方式移动,具有不同的限制,因此我们可以为刚刚定义的各种车辆类进一步实现几个 move 方法:
move <- function(vehicle, x, y, z) {
UseMethod("move")
}
move.vehicle <- function(vehicle, movement) {
if (length(movement) != 3) {
stop("All three dimensions must be specified to move a vehicle")
}
vehicle$position <- vehicle$position + movement
vehicle
}
在这里,我们将限制汽车和公共汽车的移动为二维。因此,我们将通过检查 movement 向量的长度来实现 move.bus 和 move.car,该长度只允许为 2。如果移动有效,那么,我们将强制 movement 的第三维为 0,然后调用 NextMethod("move") 来调用 move.vehicle,带有 vehicle 和 movement 的最新值:
move.bus <- move.car <- function(vehicle, movement) {
if (length(movement) != 2) {
stop("This vehicle only supports 2d movement")
}
movement <- c(movement, 0)
NextMethod("move")
}
飞机可以在两个或三个维度上移动。因此,move.airplane 可以灵活地接受两者。如果 movement 向量是二维的,那么第三维度的移动被视为零:
move.airplane <- function(vehicle, movement) {
if (length(movement) == 2) {
movement <- c(movement, 0)
}
NextMethod("move")
}
对于所有三种车辆都实现了 move,我们可以用三个实例来测试它们。首先,让我们看看以下表达式是否会产生错误,如果我们想让汽车用三维向量移动:
move(car, c(1, 2, 3))
## Error in move.car(car, c(1, 2, 3)): This vehicle only supports 2d movement
前一个函数调用的方法分派找到 move.car 并停止对无效移动的处理。以下代码是二维移动,这是有效的:
move(car, c(1, 2))
## <vehicle: car>
## name: Model-A
## speed: 80 km/h
## position: 1, 2, 0
同样,我们可以在两个维度上移动飞机:
move(airplane, c(1, 2))
## <vehicle: airplane>
## name: Big-Plane
## speed: 800 km/h
## position: 1, 2, 0
我们也可以在三个维度上移动它:
move(airplane, c(20, 50, 80))
## <vehicle: airplane>
## name: Big-Plane
## speed: 800 km/h
## position: 21, 52, 80
注意,由于 airplane 实质上是一个环境,其位置是累积的,因此在 move.vehicle 中修改 position 不会导致它的副本。因此,无论你将它传递到哪里,都只有一个实例。如果你不熟悉环境的引用语义,请参阅第八章,R 内部。
使用 S4
在上一节中,我们介绍了 S3 系统。与大多数其他编程语言中的面向对象系统不同,S3 系统比使用固定结构和某些方法分派作为程序编译的系统要宽松得多。当我们定义一个 S3 类时,几乎没有什么可以确定的。我们不仅可以在任何时候添加或删除类的方法,还可以根据我们的意愿从对象中插入或删除数据元素。此外,S3 只支持单分派,即根据单个参数的类来选择方法,通常是第一个参数。
然后,R 引入了一个更正式和更严格的面向对象系统,S4。这个系统允许我们定义具有预先指定定义和继承结构的正式类。它还支持多分派,即根据多个参数的类来选择方法。
在本节中,你将学习如何定义 S4 类和方法。
定义 S4 类
与仅由字符向量表示的 S3 类不同,S4 类需要正式定义类和方法。要定义 S4 类,我们需要调用 setClass 并提供类成员的表示,这些成员被称为 槽位。表示由每个槽位的名称和类指定。在本节中,我们将使用 S4 类重新定义产品对象:
setClass("Product",
representation(name = "character",
price = "numeric",
inventory = "integer"))
一旦定义了类,我们就可以通过 getSlots() 从其类定义中获取槽位:
getSlots("Product")
## name price inventory
## "character" "numeric" "integer"
S4 比 S3 更严格,不仅因为 S4 需要类定义,而且因为 R 将确保创建新实例的成员类与类表示一致。现在,我们将使用 new() 创建 S4 类的新实例并指定槽位的值:
laptop <- new("Product", name = "Laptop-A", price = 299, inventory = 100)
## Error in validObject(.Object): invalid class "Product" object: invalid object for slot "inventory" in class "Product": got class "numeric", should be or extend class "integer"
可能会让你惊讶的是,前面的代码会产生错误。如果你仔细查看类表示,你会发现 inventory 必须是整数。换句话说,100 是一个数值,它不属于 integer 类。它需要 100L:
laptop <- new("Product", name = "Laptop-A", price = 299, inventory = 100L)
laptop
## An object of class "Product"
## Slot "name":
## [1] "Laptop-A"
##
## Slot "price":
## [1] 299
##
## Slot "inventory":
## [1] 100
现在,创建了一个新的 Product 实例 laptop。它被打印为 Product 类的对象。所有槽位的值都会自动打印出来。
对于 S4 对象,我们仍然可以使用 typeof() 和 class() 来获取一些类型信息:
typeof(laptop)
## [1] "S4"
class(laptop)
## [1] "Product"
## attr(,"package")
## [1] ".GlobalEnv"
这次,类型是 S4 而不是 list 或其他数据类型,类名是 S4 类的名称。S4 对象在 R 中也是一个一等公民,因为它有一个检查函数:
isS4(laptop)
## [1] TRUE
与使用 $ 访问列表或环境不同,我们需要使用 @ 来访问 S4 对象的槽位:
laptop@price * laptop@inventory
## [1] 29900
或者,我们可以调用 slot() 使用其名称作为字符串来访问槽位。这相当于使用双括号 ([[]]) 访问列表或环境中的元素:
slot(laptop, "price")
## [1] 299
我们也可以像修改列表一样修改 S4 对象:
laptop@price <- 289
然而,我们不能向槽位提供与类表示不一致的东西:
laptop@inventory <- 200
## Error in (function (cl, name, valueClass) : assignment of an object of class "numeric" is not valid for @'inventory' in an object of class "Product"; is(value, "integer") is not TRUE
我们也不能像向列表添加新元素一样创建新的槽位,因为 S4 对象的结构固定为其类表示:
laptop@value <- laptop@price * laptop@inventory
## Error in (function (cl, name, valueClass) : 'value' is not a slot in class "Product"
现在,我们将创建另一个部分提供槽位值的实例:
toy <- new("Product", name = "Toys", price = 10)
toy
## An object of class "Product"
## Slot "name":
## [1] "Toys"
##
## Slot "price":
## [1] 10
##
## Slot "inventory":
## integer(0)
前面的代码没有指定 inventory,因此生成的对象 toy 将一个空整数向量作为 inventory。如果你认为这不是一个好的默认值,我们可以指定类的原型,这样每个实例都将从这个模板创建:
setClass("Product",
representation(name = "character",
price = "numeric",
inventory = "integer"),
prototype(name = "Unnamed", price = NA_real_, inventory = 0L))
在前面的原型中,我们将 price 的默认值设置为数值缺失值,将库存设置为整数零。请注意,NA 是逻辑值,不能在这里使用,因为它与类表示不一致。
然后,我们将使用相同的代码重新创建 toy:
toy <- new("Product", name = "Toys", price = 5)
toy
## An object of class "Product"
## Slot "name":
## [1] "Toys"
##
## Slot "price":
## [1] 5
##
## Slot "inventory":
## [1] 0
这次,inventory从原型中获取默认值0L。但是,如果我们需要对输入参数有更多的约束呢?尽管检查了参数的类,我们仍然可以提供对Product实例来说没有意义的值。例如,我们可以创建一个具有负库存的bottle类:
bottle <- new("Product", name = "Bottle", price = 1.5, inventory = -2L)
bottle
## An object of class "Product"
## Slot "name":
## [1] "Bottle"
##
## Slot "price":
## [1] 1.5
##
## Slot "inventory":
## [1] -2
以下代码是一个验证函数,它确保Product对象的槽位是有意义的。验证函数有些特殊,因为当输入对象没有错误时,它应该返回TRUE。当存在错误时,它应该返回一个描述错误的字符向量。因此,当槽位无效时,最好不使用stop()或warning()。
在这里,我们将通过检查每个槽位的长度以及它们是否为缺失值来验证对象。此外,价格必须是正数,库存必须是非负数:
validate_product <- function(object) {
errors <- c(
if (length(object@name) != 1)
"Length of name should be 1"
else if (is.na(object@name))
"name should not be missing value",
if (length(object@price) != 1)
"Length of price should be 1"
else if (is.na(object@price))
"price should not be missing value"
else if (object@price <= 0)
"price must be positive",
if (length(object@inventory) != 1)
"Length of inventory should be 1"
else if (is.na(object@inventory))
"inventory should not be missing value"
else if (object@inventory < 0)
"inventory must be non-negative")
if (length(errors) == 0) TRUE else errors
}
我们编写一个长的值组合来构成错误消息。这之所以有效,是因为if (FALSE) expr返回NULL,而c(x, NULL)返回x。最后,如果没有产生错误消息,函数返回TRUE,否则返回错误消息。
定义了这个函数后,我们可以直接使用它来验证bottle:
validate_product(bottle)
## [1] "inventory must be non-negative"
验证结果是一个错误消息,正如预期的那样。现在,我们需要让类在每次创建实例时执行验证。我们只需要在为Product类使用setClass时指定validity参数:
setClass("Product",
representation(name = "character",
price = "numeric",
inventory = "integer"),
prototype(name = "Unnamed",
price = NA_real_, inventory = 0L),
validity = validate_product)
然后,每次我们尝试创建Product类的实例时,提供的值都会自动进行检查。即使是原型也会进行检查。以下是两个验证失败的例子:
bottle <- new("Product", name = "Bottle")
## Error in validObject(.Object): invalid class "Product" object: price should not be missing value
前面的代码失败是因为原型中price的默认值是NA_real_。然而,在验证过程中,价格不能是缺失值:
bottle <- new("Product", name = "Bottle", price = 3, inventory = -2L)
## Error in validObject(.Object): invalid class "Product" object: inventory must be non-negative
这失败了,因为inventory必须是非负整数。
注意,验证只在创建 S4 类的新实例时发生。然而,一旦对象被创建,验证就不会再发生。换句话说,除非我们显式验证,否则我们仍然可以将槽位设置为不良值。
理解 S4 继承
S3 系统是宽松和灵活的。同一类的每个 S3 对象可能有不同的成员。对于 S4,这种情况不会发生,也就是说,当我们创建类的新实例时,我们不能随意添加不在类定义中的槽位。
例如,当我们创建Product的新实例时,我们不能放置volume槽位:
bottle <- new("Product", name = "Bottle",
price = 3, inventory = 100L, volume = 15)
## Error in initialize(value, ...): invalid name for slot of class "Product": volume
相反,我们只能通过适当的继承来完成这个操作。我们需要创建一个新的类,该类包含(或继承自)原始类。在这种情况下,我们可以定义一个继承自Product并具有名为volume的新数值槽的Container类:
setClass("Container",
representation(volume = "numeric"),
contains = "Product")
由于Container继承自Product,任何Container的实例都具有Product的所有槽位。我们可以使用getSlots()来查看它们:
getSlots("Container")
## volume name price inventory
## "numeric" "character" "numeric" "integer"
现在,我们可以创建一个具有 volume 插槽的 Container 实例:
bottle <- new("Container", name = "Bottle",
price = 3, inventory = 100L, volume = 15)
注意,当我们创建 Container 实例时,Product 的验证仍然有效:
bottle <- new("Container", name = "Bottle",
price = 3, inventory = -10L, volume = 15)
## Error in validObject(.Object): invalid class "Container" object: inventory must be non-negative
因此,检查确保它是一个有效的 Product 类,但它仍然不检查 Container 的任何内容:
bottle <- new("Container", name = "Bottle",
price = 3, inventory = 100L, volume = -2)
就像我们为 Product 定义了一个验证函数一样,我们也可以为 Container 定义另一个:
validate_container <- function(object) {
errors <- c(
if (length(object@volume) != 1)
"Length of volume must be 1",
if (object@volume <= 0)
"volume must be positive"
)
if (length(errors) == 0) TRUE else errors
}
然后,我们将使用此验证函数重新定义 Container:
setClass("Container",
representation(volume = "numeric"),
contains = "Product",
validity = validate_container)
注意,我们不需要在 validate_container 中调用 validate_product,因为两个验证函数将依次被调用,以确保继承链中的所有类都通过它们的验证函数得到适当的检查。你可以在验证函数中添加一些文本打印代码,以确认当我们创建 Container 实例时,validate_product 总是在 validate_container 之前被调用:
bottle <- new("Container", name = "Bottle",
price = 3, inventory = 100L, volume = -2)
## Error in validObject(.Object): invalid class "Container" object: volume must be positive
bottle <- new("Container", name = "Bottle",
price = 3, inventory = -5L, volume = 10)
## Error in validObject(.Object): invalid class "Container" object: inventory must be non-negative
定义 S4 泛型函数
在前面的例子中,我们看到了 S4 比 S3 更正式,因为 S4 类需要类定义。同样,S4 泛型函数也更正式。
这里有一个例子,我们定义了一系列具有简单继承关系的 S4 类。这个例子是关于形状的。首先,Shape 是一个根类。Polygon 和 Circle 都继承自 Shape,而 Triangle 和 Rectangle 继承自 Polygon。这些形状的继承结构在此处展示:

除了 Shape 之外,每个类都有一些必要的插槽来描述自己:
setClass("Shape")
setClass("Polygon",
representation(sides = "integer"),
contains = "Shape")
setClass("Triangle",
representation(a = "numeric", b = "numeric", c = "numeric"),
prototype(a = 1, b = 1, c = 1, sides = 3L),
contains = "Polygon")
setClass("Rectangle",
representation(a = "numeric", b = "numeric"),
prototype(a = 1, b = 1, sides = 4L),
contains = "Polygon")
setClass("Circle",
representation(r = "numeric"),
prototype(r = 1, sides = Inf),
contains = "Shape")
定义了这些类之后,我们可以设置一个泛型函数来计算 Shape 对象的面积。为此,我们需要在 area 上调用 setGeneric() 并提供一个函数,该函数调用 standardGeneric("area") 使 area 成为泛型函数,并准备好进行 S4 方法调度。valueClass 用于确保每个方法的返回值必须是 numeric 类:
setGeneric("area", function(object) {
standardGeneric("area")
}, valueClass = "numeric")
## [1] "area"
一旦设置了泛型函数,我们就继续实现不同形状的不同方法。对于 Triangle,我们使用海伦公式 (en.wikipedia.org/wiki/Heron's_formula) 来计算其面积,给定三边的长度:
setMethod("area", signature("Triangle"), function(object) {
a <- object@a
b <- object@b
c <- object@c
s <- (a + b + c) / 2
sqrt(s * (s - a) * (s - b) * (s - c))
})
## [1] "area"
对于 Rectangle 和 Circle,很容易写出它们各自的面积公式:
setMethod("area", signature("Rectangle"), function(object) {
object@a * object@b
})
## [1] "area"
setMethod("area", signature("Circle"), function(object) {
pi * object@r ^ 2
})
## [1] "area"
现在,我们可以创建一个 Triangle 实例,并查看 area() 是否调度到正确的方法并返回正确的结果:
triangle <- new("Triangle", a = 3, b = 4, c = 5)
area(triangle)
## [1] 6
我们还创建了一个 Circle 实例,并查看方法调度是否工作:
circle <- new("Circle", r = 3)
area(circle)
## [1] 28.27433
两个答案都是正确的。area() 函数就像一个执行根据输入对象类进行方法调度的 S3 泛型函数一样工作。
理解多态调度
S4 泛型函数更灵活,因为它还支持多态调度,即它可以根据多个参数的类执行方法调度。
在这里,我们将定义另一组 S4 类:具有数值height的Object。Cylinder和Cone都继承自Object。稍后,我们将使用多分派来计算具有特定底部形状的几何对象的体积:
setClass("Object", representation(height = "numeric"))
setClass("Cylinder", contains = "Object")
setClass("Cone", contains = "Object")
现在,我们将定义一个新的泛型函数,名为volume。正如其名所示,此函数用于计算由底部形状和对象形式描述的物体的体积:
setGeneric("volume",
function(shape, object) standardGeneric("volume"))
## [1] "volume"
在下面的代码中,我们将实现两种情况:一种是矩形形状的圆柱体,另一种是矩形形状的圆锥体:
setMethod("volume", signature("Rectangle", "Cylinder"),
function(shape, object) {
shape@a * shape@b * object@height
})
## [1] "volume"
setMethod("volume", signature("Rectangle", "Cone"),
function(shape, object) {
shape@a * shape@b * object@height / 3
})
## [1] "volume"
注意,所有现有的volume方法都需要两个参数。因此,方法分派发生在两个参数上,即它需要两个输入对象的类匹配以选择正确的方法。现在,我们将使用Rectagle的一个实例和Cylinder的一个实例来测试volume:
rectangle <- new("Rectangle", a = 2, b = 3)
cylinder <- new("Cylinder", height = 3)
volume(rectangle, cylinder)
## [1] 18
由于具有相同高度和底部形状的圆柱体和圆锥体之间存在关系,圆柱体的体积是圆锥体的三倍。为了简化volume方法的实现,我们可以在方法签名中直接放置Shape,并调用形状的area(),然后直接使用其面积进行计算:
setMethod("volume", signature("Shape", "Cylinder"),
function(shape, object) {
area(shape) * object@height
})
## [1] "volume"
setMethod("volume", signature("Shape", "Cone"),
function(shape, object) {
area(shape) * object@height / 3
})
## [1] "volume"
现在,volume可以自动应用于Circle:
circle <- new("Circle", r = 2)
cone <- new("Cone", height = 3)
volume(circle, cone)
## [1] 12.56637
为了使volume更容易使用,我们还可以定义一个方法,该方法接受一个Shape的实例和一个表示圆柱体高度的数值:
setMethod("volume", signature("Shape", "numeric"),
function(shape, object) {
area(shape) * object
})
## [1] "volume"
然后,我们可以直接使用数值来计算给定形状和高度的圆柱体的体积:
volume(rectangle, 3)
## [1] 18
此外,我们可以通过实现一个*方法来简化表示法:
setMethod("*", signature("Shape", "Object"),
function(e1, e2) {
volume(e1, e2)
})
## [1] "*"
现在,我们可以通过简单地乘以形状和对象形式来计算体积:
rectangle * cone
## [1] 6
注意,S4 对象不是一个列表或环境,但它具有修改时复制的语义。从这个意义上讲,当函数中使用<-修改 S4 对象的槽值时,它表现得更像一个列表,即 S4 对象在函数中被复制,而原始对象没有被修改。
例如,在下面的代码中,我们将定义一个函数,该函数尝试通过将Object的高度与一个数值因子相乘来延长Object:
lengthen <- function(object, factor) {
object@height <- object@height * factor
object
}
当我们将此函数应用于我们之前创建的cylinder时,其高度完全没有改变。相反,它在函数内部被复制:
cylinder
## An object of class "Cylinder"
## Slot "height":
## [1] 3
lengthen(cylinder, 2)
## An object of class "Cylinder"
## Slot "height":
## [1] 6
cylinder
## An object of class "Cylinder"
## Slot "height":
## [1] 3
与引用类一起工作
此外,还有一个具有引用语义的类系统。它更类似于其他面向对象编程语言中的类系统。
首先,为了定义一个引用类(RC),我们向setRefClass()提供一个类定义。与使用new()创建实例的 S4 类系统不同,setRefClass()返回一个实例生成器。例如,我们定义一个名为Vehicle的类,它有两个字段:一个数值位置和一个数值距离。我们将实例生成器存储在一个名为Vehicle的变量中:
Vehicle <- setRefClass("Vehicle",
fields = list(position = "numeric", distance = "numeric"))
要创建实例,我们使用Vehicle$new创建Vehicle类的新实例:
car <- Vehicle$new(position = 0, distance = 0)
与 S4 不同,RC 的字段不是槽,因此我们可以使用$来访问它们:
car$position
## [1] 0
我们使用Vehicle$new创建的每个实例都是引用语义的对象。它类似于 S4 对象和环境的组合。
在以下代码中,我们将创建一个函数来修改Vehicle对象中的字段。更具体地说,我们定义了move来修改相对术语中的position,并且所有移动都累积到distance:
move <- function(vehicle, movement) {
vehicle$position <- vehicle$position + movement
vehicle$distance <- vehicle$distance + abs(movement)
}
现在,我们将使用car调用move,我们创建的实例被修改而不是复制:
move(car, 10)
car
## Reference class object of class "Vehicle"
## Field "position":
## [1] 10
## Field "distance":
## [1] 10
由于 RC 本身更像是一个普通面向对象系统中的类系统,更好的做法是定义它自己的类方法:
Vehicle <- setRefClass("Vehicle",
fields = list(position = "numeric", distance = "numeric"),
methods = list(move = function(x) {
stopifnot(is.numeric(x))
position <<- position + x
distance <<- distance + abs(x)
}))
与 S3 和 S4 系统不同,其中方法存储在环境中,RC 直接包含其方法。因此,我们可以直接在实例内部调用方法。请注意,要修改方法中字段的值,我们需要使用<<-而不是<-。以下代码是一个简单的测试,用于检查方法是否工作以及引用对象是否被修改:
bus <- Vehicle(position = 0, distance = 0)
bus$move(5)
bus
## Reference class object of class "Vehicle"
## Field "position":
## [1] 5
## Field "distance":
## [1] 5
从前面的示例中,我们可以看到 RC 看起来更像 C++和 Java 中的对象。对于更详细的介绍,请阅读?ReferenceClasses。
与 R6 一起工作
RC 的增强版本是 R6,这是一个实现更高效参考类的包,支持公共和私有字段和方法,以及一些其他强大功能。
运行以下代码来安装包:
install.packages("R6")
R6 类允许我们定义更类似于流行面向对象编程语言的类。以下代码是一个示例,其中我们定义了Vehicle类。它为用户提供了一些公共字段和方法,并为内部使用定义了一些私有字段和方法:
library(R6)
Vehicle <- R6Class("Vehicle",
public = list(
name = NA,
model = NA,
initialize = function(name, model) {
if (!missing(name)) self$name <- name
if (!missing(model)) self$model <- model
},
move = function(movement) {
private$start()
private$position <- private$position + movement
private$stop()
},
get_position = function() {
private$position
}
),
private = list(
position = 0,
speed = 0,
start = function() {
cat(self$name, "is starting\n")
private$speed <- 50
},
stop = function() {
cat(self$name, "is stopping\n")
private$speed <- 0
}
))
从用户的角度来看,我们只能访问公共字段和方法。只有类方法可以访问私有字段和方法。尽管车辆有一个位置,但我们不希望用户修改其值。因此,我们将其放在私有部分,并通过get_position()公开其值,这样用户就很难从外部修改位置:
car <- Vehicle$new(name = "Car", model = "A")
car
## <Vehicle>
## Public:
## clone: function (deep = FALSE)
## get_position: function ()
## initialize: function (name, model)
## model: A
## move: function (movement)
## name: Car
## Private:
## position: 0
## speed: 0
## start: function ()
## stop: function ()
当打印car时,所有公共和私有字段和方法都会显示出来。然后,我们将调用move()方法,我们可以通过get_position()找到位置已改变:
car$move(10)
## Car is starting
## Car is stopping
car$get_position()
## [1] 10
为了演示 R6 类的继承,我们定义一个新的类名为MeteredVehicle,该类记录其在历史中移动的总距离。为了定义该类,我们需要添加一个私有字段distance,一个公共重写的move方法,该方法首先调用super$move()将车辆移动到正确位置,然后累积由此产生的绝对距离:
MeteredVehicle <- R6Class("MeteredVehicle",
inherit = Vehicle,
public = list(
move = function(movement) {
super$move(movement)
private$distance <<- private$distance + abs(movement)
},
get_distance = function() {
private$distance
}
),
private = list(
distance = 0
))
现在,我们可以对MeteredVehicle进行一些实验。在以下代码中,我们将创建一个bus:
bus <- MeteredVehicle$new(name = "Bus", model = "B")
bus
## <MeteredVehicle>
## Inherits from: <Vehicle>
## Public:
## clone: function (deep = FALSE)
## get_distance: function ()
## get_position: function ()
## initialize: function (name, model)
## model: B
## move: function (movement)
## name: Bus
## Private:
## distance: 0
## position: 0
## speed: 0
## start: function ()
## stop: function ()
首先,让bus向前移动10个单位,然后,位置改变,距离累积:
bus$move(10)
## Bus is starting
## Bus is stopping
bus$get_position()
## [1] 10
bus$get_distance()
## [1] 10
然后,让bus向后移动5个单位。位置更接近原点,而所有移动的总距离变得更大:
bus$move(-5)
## Bus is starting
## Bus is stopping
bus$get_position()
## [1] 5
bus$get_distance()
## [1] 15
注意
R6 还支持一些其他强大的功能。更多详情,请阅读其 vignettes 在cran.r-project.org/web/packages/R6/vignettes/Introduction.html。
摘要
在本章中,你学习了面向对象编程的基本概念:类和方法以及它们如何通过 R 中的泛型函数通过方法调度相互连接。你学习了如何创建 S3、S4、RC 和 R6 类和方法。这些系统在理念上相似,但在实现和使用上有所不同。Hadley Wickham 在挑选系统方面提供了一些不错的建议(adv-r.had.co.nz/OO-essentials.html#picking-a-system)。
在熟悉 R 最重要的特性之后,我们将在后续章节中讨论更多实际话题。在下一章中,你将学习用于访问流行数据库的包和技术。你将获得连接 R 到关系数据库(如 SQLite 和 MySQL)以及即将到来的非关系数据库(如 MongoDB 和 Redis)所必需的知识和技术。
第十一章。与数据库一起工作
在上一章中,你学习了面向对象编程的基本概念。这包括类和方法,以及它们如何通过 R 中的泛型函数通过方法调度相互连接。你了解了 S3、S4、RC 和 R6 的基本用法,包括定义类和泛型函数以及为特定类实现方法。
现在我们已经涵盖了 R 的大部分重要特性,是时候继续讨论更多实际的话题了。在本章中,我们将从 R 如何用于与数据库一起工作开始讨论,这可能是许多数据分析项目的第一步:从数据库中提取数据。更具体地说,我们将涵盖以下主题:
-
理解关系型数据库
-
使用 SQL 查询关系型数据库,如 SQLite 和 MySQL
-
与 NoSQL 数据库一起工作,如 MongoDB 和 Redis
与关系型数据库一起工作
在之前的章节中,我们使用了一系列内置函数,如read.csv和read.table,从分隔符分隔的文件中导入数据,例如 csv 格式的文件。使用文本格式存储数据很方便且易于携带。然而,当数据文件很大时,这种存储方法可能不是最佳选择。
文本格式不再容易使用有三个主要原因。如下所述:
-
read.csv()等函数主要用于将整个文件加载到内存中,即 R 中的数据框。如果数据太大而无法适应计算机内存,我们就无法完成它。 -
即使数据集很大,我们在处理任务时通常也不需要将整个数据集加载到内存中。相反,我们通常需要提取满足一定条件的数据集子集。内置的数据导入函数根本不支持查询 csv 文件。
-
数据集仍在更新中,也就是说,我们需要定期将记录插入数据集中。如果我们使用 csv 格式,插入数据可能会很痛苦,尤其是如果我们想在文件的中间插入记录并保持其顺序。
在这些场景中使用数据库是最佳解决方案。它使得存储可能超过计算机内存的数据变得容易得多。数据库中的数据可以根据用户提供的条件进行查询,这也使得更新现有记录和在数据库中插入新记录变得更加容易。
关系型数据库是一组表和表之间的关系。关系型数据库中的表与 R 中的数据框具有相同的表示。表可以具有关系,这使得连接多个表的信息变得更容易。
在本节中,我们将从最简单的数据库 SQLite(sqlite.org/)开始,它是一个便携式、轻量级的数据库引擎。
要在 R 中使用RSQLite包与 SQLite 数据库一起工作,请从 CRAN 安装它,运行以下代码:
install.packages("RSQLite")
创建 SQLite 数据库
首先,让我们看看如何创建一个 SQLite 数据库。如果我们想在 data/example.sqlite 创建一个示例数据库,我们需要确保目录可用。如果目录不存在,我们必须创建一个:
if (!dir.exists("data")) dir.create("data")
现在,data/ 目录可用。接下来,我们将加载 RSQLite 包,并通过提供数据库驱动程序(SQLite())和数据库文件(data/example.sqlite)来创建连接。尽管文件不存在,但驱动程序会创建一个空文件,这是一个空的 SQLite 数据库:
library(RSQLite)
## Loading required package: DBI
con <- dbConnect(SQLite(), "data/example.sqlite")
数据库连接 con 是用户和系统之间的一个层。我们可以通过它创建到关系数据库的连接,并通过它查询、检索或更新数据。连接将在所有后续操作中使用,直到我们关闭连接。在一个典型的关系数据库中,我们可以使用名称和某些名称和数据类型的列创建表,将记录作为行插入到表中,并更新现有记录。关系数据库中的表看起来非常类似于 R 中的数据框。
现在,我们将创建一个简单的数据框,该数据框将被插入到数据库中的表中:
example1 <- data.frame(
id = 1:5,
type = c("A", "A", "B", "B", "C"),
score = c(8, 9, 8, 10, 9),
stringsAsFactors = FALSE)
example1
## id type score
## 1 1 A 8
## 2 2 A 9
## 3 3 B 8
## 4 4 B 10
## 5 5 C 9
数据框已准备好,我们将调用 dbWriteTable() 将此数据框作为表写入数据库:
dbWriteTable(con, "example1", example1)
## [1] TRUE
在前面的代码中,我们可能使用其他表名,但仍然存储相同的数据。最后,我们将使用 dbDisconnect() 断开数据库连接,这样 con 就不再可用于数据操作:
dbDisconnect(con)
## [1] TRUE
将多个表格写入数据库
SQLite 数据库是一组表。因此,我们可以在一个数据库中存储多个表。
这次,我们将 diamonds 数据集放入 ggplot2,将 flights 数据集放入 nycflights13,作为一个数据库中的两个表。如果您还没有安装这两个包,请运行以下代码:
install.packages(c("ggplot2", "nycflights13"))
当包可用时,我们将调用 data() 来加载两个数据框:
data("diamonds", package ="ggplot2")
data("flights", package ="nycflights13")
我们将重复之前所做的相同操作,但 dbWriteTable() 最终会出错:
con <- dbConnect(SQLite(), "data/datasets.sqlite")
dbWriteTable(con, "diamonds", diamonds, row.names = FALSE)
## Error in (function (classes, fdef, mtable) : unable to find an inherited method for function 'dbWriteTable' for signature '"SQLiteConnection", "character", "tbl_df"'
dbWriteTable(con, "flights", flights, row.names = FALSE)
## Error in (function (classes, fdef, mtable) : unable to find an inherited method for function 'dbWriteTable' for signature '"SQLiteConnection", "character", "tbl_df"'
dbDisconnect(con)
## [1] TRUE
查看这两个变量的类可能很有用:
class(diamonds)
## [1] "tbl_df" "tbl" "data.frame"
class(flights)
## [1] "tbl_df" "tbl" "data.frame"
注意,diamonds 和 flights 并不仅仅是 data.frame 类,而是更复杂的东西。要将它们写入数据库,我们需要使用 as.data.frame() 将它们转换为普通的 data.frame 对象:
con <- dbConnect(SQLite(), "data/datasets.sqlite")
dbWriteTable(con, "diamonds", as.data.frame(diamonds), row.names = FALSE)
## [1] TRUE
dbWriteTable(con, "flights", as.data.frame(flights), row.names = FALSE)
## [1] TRUE
dbDisconnect(con)
## [1] TRUE
现在,数据库包含两个表。
将数据追加到表
如本节开头所述,将记录追加到数据库中的表相对简单。这里有一个简单的例子,我们生成几个数据块,并依次将它们追加到数据库表:
con <- dbConnect(SQLite(), "data/example2.sqlite")
chunk_size <- 10
id <- 0
for (i in 1:6) {
chunk <- data.frame(id = ((i - 1L) * chunk_size):(i * chunk_size -1L),
type = LETTERS[[i]],
score =rbinom(chunk_size, 10, (10 - i) /10),
stringsAsFactors =FALSE)
dbWriteTable(con, "products", chunk,
append = i > 1, row.names = FALSE)
}
dbDisconnect(con)
## [1] TRUE
注意,每个块都是一个数据框,包含一些确定的数据和一些随机数。每次,我们将这些记录追加到名为 products 的表中。与之前的示例不同的是,当我们调用 dbWriteTable() 时,我们为第一个块使用 append = FALSE 来在数据库中创建该表,并为每个后续块使用 append = TRUE 来追加到现有表中。
访问表和表字段
一旦我们有了 SQLite 数据库,我们不仅可以访问存储在表中的数据,还可以访问一些元数据,例如所有表的名称和表的列。
为了演示,我们将连接到之前创建的 SQLite 数据库:
con <- dbConnect(SQLite(), "data/datasets.sqlite")
我们可以使用dbExistsTable()来检测数据库中是否存在表:
dbExistsTable(con, "diamonds")
## [1] TRUE
dbExistsTable(con, "mtcars")
## [1] FALSE
由于我们之前只在datasets.sqlite中写入了diamonds和flights,所以dbExistsTable()返回正确的值。与检测表存在性相反,我们可以使用dbListTables()来列出数据库中所有现有的表:
dbListTables(con)
## [1] "diamonds" "flights"
对于某个表,我们也可以使用dbListFields()列出所有列(或字段)的名称:
dbListFields(con, "diamonds")
## [1] "carat" "cut" "color" "clarity" "depth"
## [6] "table" "price" "x" "y" "z"
与dbWriteTable()相反,dbReadTable()将整个表读入一个数据框:
db_diamonds <- dbReadTable(con, "diamonds")
dbDisconnect(con)
## [1] TRUE
我们可以将我们从数据库中读取的数据框(db_diamonds)与原始版本(diamonds)进行比较:
head(db_diamonds, 3)
## carat cut color clarity depth table price x y
## 1 0.23 Ideal E SI2 61.5 55 326 3.95 3.98
## 2 0.21 Premium E SI1 59.8 61 326 3.89 3.84
## 3 0.23 Good E VS1 56.9 65 327 4.05 4.07
## z
## 1 2.43
## 2 2.31
## 3 2.31
head(diamonds, 3)
## carat cut color clarity depth table price x y
## 1 0.23 Ideal E SI2 61.5 55 326 3.95 3.98
## 2 0.21 Premium E SI1 59.8 61 326 3.89 3.84
## 3 0.23 Good E VS1 56.9 65 327 4.05 4.07
## z
## 1 2.43
## 2 2.31
## 3 2.31
两个数据框中的数据看起来完全相同。然而,如果我们使用identical()来比较它们,它们实际上并不相同:
identical(diamonds, db_diamonds)
## [1] FALSE
为了找出差异,我们可以调用str()来揭示两个数据框的结构。首先,这是数据库中数据框的结构:
str(db_diamonds)
## 'data.frame': 53940 obs. of 10 variables:
## $ carat : num 0.23 0.21 0.23 0.29 0.31 0.24 0.24...
## $ cut : chr "Ideal" "Premium" "Good" "Premium" ...
## $ color : chr "E" "E" "E" "I" ...
## $ clarity: chr "SI2" "SI1" "VS1" "VS2" ...
## $ depth : num 61.5 59.8 56.9 62.4 63.3 62.8 62.3...
## $ table : num 55 61 65 58 58 57 57 55 61 61 ...
## $ price : int 326 326 327 334 335 336 336 337 337 ...
## $ x : num 3.95 3.89 4.05 4.2 4.34 3.94 3.95...
## $ y : num 3.98 3.84 4.07 4.23 4.35 3.96 3.98...
## $ z : num 2.43 2.31 2.31 2.63 2.75 2.48 2.47...
然后,这是原始版本的结构:
str(diamonds)
## Classes 'tbl_df', 'tbl' and 'data.frame': 53940 obs. of 10 variables:
## $ carat : num 0.23 0.21 0.23 0.29 0.31 0.24 0.24...
## $ cut : Ord.factor w/ 5 levels "Fair"<"Good"<..: 5 4 2 4 2 3 3 3 1 3 ...
## $ color : Ord.factor w/ 7 levels "D"<"E"<"F"<"G"<..: 2 2 2 6 7 7 6 5 2 5 ...
## $ clarity: Ord.factor w/ 8 levels "I1"<"SI2"<"SI1"<..: 2 3 5 4 2 6 7 3 4 5 ...
## $ depth : num 61.5 59.8 56.9 62.4 63.3 62.8 62.3 61.9 65.1 59.4 ...
## $ table : num 55 61 65 58 58 57 57 55 61 61 ...
## $ price : int 326 326 327 334 335 336 336 337 337...
## $ x : num 3.95 3.89 4.05 4.2 4.34 3.94 3.95...
## $ y : num 3.98 3.84 4.07 4.23 4.35 3.96 3.98...
## $ z : num 2.43 2.31 2.31 2.63 2.75 2.48 2.47...
现在,差异很明显。在原始版本中,cut、color和clarity是有序因子变量,本质上是有一些元数据的整数(有序级别)。相比之下,在数据库版本中,这些列以文本形式存储。这种变化仅仅是因为 SQLite 没有内置对有序因子的支持。因此,除了常见的数据类型(数字、文本、逻辑等)之外,R 特定的类型将在数据框插入之前转换为 SQLite 支持的类型。
学习 SQL 以查询关系型数据库
在上一节中,你学习了如何将数据写入 SQLite 数据库。在本节中,你将学习如何查询此类数据库,以便我们可以根据我们的需求从中获取数据。以下示例我们将使用data/datasets.sqlite(我们之前创建的)。
首先,我们需要与数据库建立连接:
con <- dbConnect(SQLite(), "data/datasets.sqlite")
dbListTables(con)
## [1] "diamonds" "flights"
数据库中有两个表。然后,我们可以使用select语句从diamonds中选择所有数据。在这里,我们想要选择所有列(或字段)。因此,我们将使用数据库连接con和查询字符串调用dbGetQuery():
db_diamonds <- dbGetQuery(con,
"select * from diamonds")
head(db_diamonds, 3)
## carat cut color clarity depth table price x y
## 1 0.23 Ideal E SI2 61.5 55 326 3.95 3.98
## 2 0.21 Premium E SI1 59.8 61 326 3.89 3.84
## 3 0.23 Good E VS1 56.9 65 327 4.05 4.07
## z
## 1 2.43
## 2 2.31
## 3 2.31
注意,*表示所有字段(或等价地,列)。如果我们只需要字段的子集,我们可以依次命名字段:
db_diamonds <-dbGetQuery(con,
"select carat, cut, color, clarity, depth, price
from diamonds")
head(db_diamonds, 3)
## carat cut color clarity depth price
## 1 0.23 Ideal E SI2 61.5 326
## 2 0.21 Premium E SI1 59.8 326
## 3 0.23 Good E VS1 56.9 327
如果我们想要选择数据中出现的所有不同情况,我们可以使用select distinct。例如,以下代码返回diamonds中cut的所有不同值:
dbGetQuery(con, "select distinct cut from diamonds")
## cut
## 1 Ideal
## 2 Premium
## 3 Good
## 4 Very Good
## 5 Fair
注意,dbGetQuery()始终返回data.frame,即使有时只有一个列。要检索作为原子向量的值,只需从数据框中提取第一列:
dbGetQuery(con, "select distinct clarity from diamonds")[[1]]
## [1] "SI2" "SI1" "VS1" "VS2" "VVS2" "VVS1" "I1" "IF"
当我们使用 select 来选择查询的列时,有时列名可能不是我们想要的。在这种情况下,我们可以使用 A as B 来获取与 A 相同数据的列 B:
db_diamonds <- dbGetQuery(con,
"select carat, price, clarity as clarity_level from diamonds")
head(db_diamonds, 3)
## carat price clarity_level
## 1 0.23 326 SI2
## 2 0.21 326 SI1
## 3 0.23 327 VS1
在某些其他情况下,我们想要的值不在数据库中,但需要一些计算来确定。现在,我们将使用 A as B,其中 A 可以是现有列之间的算术计算:
db_diamonds <- dbGetQuery(con,
"select carat, price, x * y * z as size from diamonds")
head(db_diamonds, 3)
## carat price size
## 1 0.23 326 38.20203
## 2 0.21 326 34.50586
## 3 0.23 327 38.07688
如果我们创建一个新列,该列由现有列组成,然后再创建一个新列,该列由新列组成,就像以下示例一样?
db_diamonds <- dbGetQuery(con,
"select carat, price, x * y * z as size,
price / size as value_density
from diamonds")
## Error in sqliteSendQuery(con, statement, bind.data): error in statement: no such column: size
我们根本无法做到这一点。在 A as B 中,A 必须由现有列组成。然而,如果我们坚持这样做,我们可以使用嵌套查询,即从嵌套 select 生成的临时表中 select 列:
db_diamonds <- dbGetQuery(con,
"select *, price / size as value_density from
(select carat, price, x * y * z as size from diamonds)")
head(db_diamonds, 3)
## carat price size value_density
## 1 0.23 326 38.20203 8.533578
## 2 0.21 326 34.50586 9.447672
## 3 0.23 327 38.07688 8.587887
在这种情况下,size 是在计算 price / size 时在临时表中定义的。
数据库查询的下一个重要组成部分是条件。我们可以使用 where 来指定结果必须满足的条件。例如,我们可以选择 Good 切工的钻石:
good_diamonds <- dbGetQuery(con,
"select carat, cut, price from diamonds where cut = 'Good'")
head(good_diamonds, 3)
## carat cut price
## 1 0.23 Good 327
## 2 0.31 Good 335
## 3 0.30 Good 339
注意,具有良好切工的记录只占所有记录的一小部分:
nrow(good_diamonds) /nrow(diamonds)
## [1] 0.09095291
如果我们有多个必须同时满足的条件,我们可以使用 and 来组合这些条件。例如,我们将选择所有 Good 切工和颜色为 E 的记录:
good_e_diamonds <- dbGetQuery(con,
"select carat, cut, color, price from diamonds
where cut = 'Good' and color = 'E'")
head(good_e_diamonds, 3)
## carat cut color price
## 1 0.23 Good E 327
## 2 0.23 Good E 402
## 3 0.26 Good E 554
nrow(good_e_diamonds) /nrow(diamonds)
## [1] 0.017297
类似的逻辑运算还包括 or 和 not。
除了简单的逻辑运算外,我们还可以使用 in 通过检查字段的值是否包含在给定的集合中来过滤记录。例如,我们可以选择颜色为 E 和 F 的记录:
color_ef_diamonds <- dbGetQuery(con,
"select carat, cut, color, price from diamonds
where color in ('E','F')")
nrow(color_ef_diamonds)
## [1] 19339
我们可以通过以下表格验证结果:
table(diamonds$color)
##
## D E F G H I J
## 6775 9797 9542 11292 8304 5422 2808
要使用 in,我们需要指定一个集合。类似于 in,我们还可以使用 between and,它允许我们指定一个范围:
some_price_diamonds <- dbGetQuery(con,
"select carat, cut, color, price from diamonds
where price between 5000 and 5500")
nrow(some_price_diamonds) /nrow(diamonds)
## [1] 0.03285132
实际上,范围不必是数字。只要字段的数据类型是可比较的,我们就可以指定一个范围。对于字符串列,我们可以写 between 'string1' to 'string2' 来通过词序过滤记录。
另一个对字符串列有用的运算符是 like,它使我们能够通过简单的字符串模式过滤记录。例如,我们可以选择所有 cut 变量以 Good 结尾的记录。它可以是 Good 或 Very Good。表示法是 like '%Good',其中 % 匹配所有字符串:
good_cut_diamonds <- dbGetQuery(con,
"select carat, cut, color, price from diamonds
where cut like '%Good'")
nrow(good_cut_diamonds) /nrow(diamonds)
## [1] 0.3149425
数据库查询的另一个主要功能是使用指定列对数据进行排序。我们可以使用 order by 来做这件事。例如,我们可以按 price 的升序获取所有记录的 carat 和 price:
cheapest_diamonds <- dbGetQuery(con,
"select carat, price from diamonds
order by price")
因此,我们有一个按从最便宜到最贵的顺序排列的钻石数据帧:
head(cheapest_diamonds)
## carat price
## 1 0.23 326
## 2 0.21 326
## 3 0.23 327
## 4 0.29 334
## 5 0.31 335
## 6 0.24 336
我们可以通过在排序列中添加 desc 来做相反的操作,这样我们得到的数据帧就是按相反的顺序排序的:
most_expensive_diamonds <- dbGetQuery(con,
"select carat, price from diamonds
order by price desc")
head(most_expensive_diamonds)
## carat price
## 1 2.29 18823
## 2 2.00 18818
## 3 1.51 18806
## 4 2.07 18804
## 5 2.00 18803
## 6 2.29 18797
我们也可以按多个列对记录进行排序。例如,以下结果首先按价格升序排序。如果两个记录的价格相同,则具有更大 carat 的记录将被放在前面:
cheapest_diamonds <- dbGetQuery(con,
"select carat, price from diamonds
order by price, carat desc")
head(cheapest_diamonds)
## carat price
## 1 0.23 326
## 2 0.21 326
## 3 0.23 327
## 4 0.29 334
## 5 0.31 335
## 6 0.24 336
与select类似,排序的列可以由现有列计算得出:
dense_diamonds <- dbGetQuery(con,
"select carat, price, x * y * z as size from diamonds
order by carat / size desc")
head(dense_diamonds)
## carat price size
## 1 1.07 5909 47.24628
## 2 1.41 9752 74.41726
## 3 1.53 8971 85.25925
## 4 1.51 7188 133.10400
## 5 1.22 3156 108.24890
## 6 1.12 6115 100.97448
我们还可以同时使用where和order by查询所有记录的排序子集:
head(dbGetQuery(con,
"select carat, price from diamonds
where cut = 'Ideal' and clarity = 'IF' and color = 'J'
order by price"))
## carat price
## 1 0.30 489
## 2 0.30 489
## 3 0.32 521
## 4 0.32 533
## 5 0.32 533
## 6 0.35 569
如果我们只关心前几个结果,我们可以使用limit来限制要检索的记录数:
dbGetQuery(con,
"select carat, price from diamonds
order by carat desc limit 3")
## carat price
## 1 5.01 18018
## 2 4.50 18531
## 3 4.13 17329
除了列选择、条件过滤和排序之外,我们还可以在数据库中以组的形式聚合记录。例如,我们可以计算每种颜色的记录数量:
dbGetQuery(con,
"select color, count(*) as number from diamonds
group by color")
## color number
## 1 D 6775
## 2 E 9797
## 3 F 9542
## 4 G 11292
## 5 H 8304
## 6 I 5422
## 7 J 2808
可以通过调用table()与原始数据一起验证结果:
table(diamonds$color)
##
## D E F G H I J
## 6775 9797 9542 11292 8304 5422 2808
除了计数之外,我们还有如avg()、max()、min()和sum()之类的聚合函数。例如,我们可以通过查看每个清晰度级别的平均价格来总结数据:
dbGetQuery(con,
"select clarity, avg(price) as avg_price
from diamonds
group by clarity
order by avg_price desc")
## clarity avg_price
## 1 SI2 5063.029
## 2 SI1 3996.001
## 3 VS2 3924.989
## 4 I1 3924.169
## 5 VS1 3839.455
## 6 VVS2 3283.737
## 7 IF 2864.839
## 8 VVS1 2523.115
我们还可以检查五个最低价格的最大克拉数:
dbGetQuery(con,
"select price, max(carat) as max_carat
from diamonds
group by price
order by price
limit 5")
## price max_carat
## 1 326 0.23
## 2 327 0.23
## 3 334 0.29
## 4 335 0.31
## 5 336 0.24
我们还可以在组内执行多个计算。以下代码计算了每个清晰度级别的价格范围及其平均值:
dbGetQuery(con,
"select clarity,
min(price) as min_price,
max(price) as max_price,
avg(price) as avg_price
from diamonds
group by clarity
order by avg_price desc")
## clarity min_price max_price avg_price
## 1 SI2 326 18804 5063.029
## 2 SI1 326 18818 3996.001
## 3 VS2 334 18823 3924.989
## 4 I1 345 18531 3924.169
## 5 VS1 327 18795 3839.455
## 6 VVS2 336 18768 3283.737
## 7 IF 369 18806 2864.839
## 8 VVS1 336 18777 2523.115
以下示例计算了每个清晰度级别的加权平均价格,即克拉数更大的价格具有更大的权重:
dbGetQuery(con,
"select clarity,
sum(price * carat) / sum(carat) as wprice
from diamonds
group by clarity
order by wprice desc")
## clarity wprice
## 1 SI2 7012.257
## 2 VS2 6173.858
## 3 VS1 6059.505
## 4 SI1 5919.187
## 5 VVS2 5470.156
## 6 I1 5233.937
## 7 IF 5124.584
## 8 VVS1 4389.112
就像按多个列排序一样,我们也可以按多个列对数据进行分组。以下代码计算了每个清晰度和颜色对的平均价格,并显示了平均价格最高的前五个对:
dbGetQuery(con,
"select clarity, color,
avg(price) as avg_price
from diamonds
group by clarity, color
order by avg_price desc
limit 5")
## clarity color avg_price
## 1 IF D 8307.370
## 2 SI2 I 7002.649
## 3 SI2 J 6520.958
## 4 SI2 H 6099.895
## 5 VS2 I 5690.506
在关系数据库中最相关的操作应该是表连接,即通过某些列将多个表连接在一起。例如,我们将创建一个包含cut、color和clarity的数据帧,以选择与diamond_selector中三个案例完全相同的字段值的记录:
diamond_selector <- data.frame(
cut = c("Ideal", "Good", "Fair"),
color = c("E", "I", "D"),
clarity = c("VS1", "I1", "IF"),
stringsAsFactors = FALSE
)
diamond_selector
## cut color clarity
## 1 Ideal E VS1
## 2 Good I I1
## 3 Fair D IF
创建数据帧后,我们将其写入数据库,以便我们可以将diamonds和diamond_selector连接起来以过滤所需的记录:
dbWriteTable(con, "diamond_selector", diamond_selector,
row.names = FALSE, overwrite = TRUE)
## [1] TRUE
我们可以在连接子句中指定要匹配的列:
subset_diamonds <- dbGetQuery(con,
"select cut, color, clarity, carat, price
from diamonds
join diamond_selector using (cut, color, clarity)")
head(subset_diamonds)
## cut color clarity carat price
## 1 Ideal E VS1 0.60 2774
## 2 Ideal E VS1 0.26 556
## 3 Ideal E VS1 0.70 2818
## 4 Ideal E VS1 0.70 2837
## 5 Good I I1 1.01 2844
## 6 Ideal E VS1 0.26 556
总体来说,我们只有所有记录中极小的一部分满足以下三种情况之一:
nrow(subset_diamonds) /nrow(diamonds)
## [1] 0.01121617
最后,别忘了断开数据库连接,以确保所有资源都得到适当释放:
dbDisconnect(con)
## [1] TRUE
在前面的示例中,我们只展示了使用 SQL 查询关系数据库(如 SQLite)的基本用法。实际上,SQL 比我们展示的更丰富、更强大。有关更多详细信息,请访问www.w3schools.com/sql并了解更多。
分块获取查询结果
在本节的开头,我们提到使用关系数据库的一个优点是我们可以存储大量数据。通常,我们只取出数据库的子集进行一些研究。然而,有时我们需要处理的数据量超出了计算机内存的容量。显然,我们不能将所有数据加载到内存中,而必须分块处理数据。
大多数合理的关联数据库都支持分块获取查询结果集。在下面的示例中,我们将使用 dbSendQuery() 而不是 dbGetQuery() 来获取结果集。然后,我们将重复从结果集中获取数据块(行数)直到所有结果都被获取。这样,我们可以分块处理数据,而不需要使用大量的工作内存:
con <- dbConnect(SQLite(), "data/datasets.sqlite")
res <- dbSendQuery(con,
"select carat, cut, color, price from diamonds
where cut = 'Ideal' and color = 'E'")
while (!dbHasCompleted(res)) {
chunk <- dbFetch(res, 800)
cat(nrow(chunk), "records fetched\n")
# do something with chunk
}
## 800 records fetched
## 800 records fetched
## 800 records fetched
## 800 records fetched
## 703 records fetched
dbClearResult(res)
## [1] TRUE
dbDisconnect(con)
## [1] TRUE
在实践中,数据库可能有数十亿条记录。查询可能返回数千万条记录。如果你使用 dbGetQuery() 一次性获取整个结果集,你的内存可能不足。如果任务可以通过处理数据块来完成,那么分块处理会便宜得多。
使用事务保证一致性
流行的关系数据库具有强大的确保一致性的能力。当我们插入或更新数据时,我们通过事务来完成。如果事务失败,我们可以撤销事务并回滚数据库,以确保一切保持一致。
以下示例是对可能在过程中失败的数据累积过程的简单模拟。假设我们需要累积某些产品的数据并将其存储在 data/products.sqlite 中。每次产生数据块时,我们需要将其追加到数据库中的表中。然而,在每次迭代中,这个过程有 20%的概率会失败:
set.seed(123)
con <- dbConnect(SQLite(), "data/products.sqlite")
chunk_size <- 10
for (i in 1:6) {
cat("Processing chunk", i, "\n")
if (runif(1) <= 0.2) stop("Data error")
chunk <- data.frame(id = ((i - 1L) * chunk_size):(i * chunk_size - 1L),
type = LETTERS[[i]],
score = rbinom(chunk_size, 10, (10 - i) /10),
stringsAsFactors = FALSE)
dbWriteTable(con, "products", chunk,
append = i > 1, row.names = FALSE)
}
## Processing chunk 1
## Processing chunk 2
## Processing chunk 3
## Processing chunk 4
## Processing chunk 5
## Error in eval(expr, envir, enclos): Data error
在处理第 5 个数据块时,累积失败。然后,我们将计算表中的记录数:
dbGetQuery(con, "select COUNT(*) from products")
## COUNT(*)
## 1 40
dbDisconnect(con)
## [1] TRUE
我们可以发现表中存储了许多记录。在某些情况下,我们希望所有记录都得到适当的存储,或者我们希望什么也不放入数据库。在这两种情况下,数据库都是一致的。然而,如果只有一半的数据被存储,可能会出现其他问题。为了确保一系列数据库更改成功或失败作为一个整体,我们可以在写入任何数据之前调用 dbBegin(),在所有更改完成后调用 dbCommit(),如果出现问题,则调用 dbRollback():
以下代码是前一个示例的增强版本。我们使用事务来确保所有数据块要么都写入数据库,要么一个都不写。更具体地说,我们将数据写入过程放在 tryCatch 中。在写入开始之前,我们通过调用 dbBegin() 开始一个事务。然后,在 tryCatch 中,我们将数据块逐个写入数据库。如果一切顺利,我们将调用 dbCommit() 提交事务,以便所有更改都得到提交。如果出现问题,错误将被错误函数捕获,我们产生一个警告并通过 dbRollback() 回滚:
set.seed(123)
file.remove("data/products.sqlite")
## [1] TRUE
con <- dbConnect(SQLite(), "data/products.sqlite")
chunk_size <- 10
dbBegin(con)
## [1] TRUE
res <- tryCatch({
for (i in 1:6) {
cat("Processing chunk", i, "\n")
if (runif(1) <= 0.2) stop("Data error")
chunk <- data.frame(id = ((i - 1L) * chunk_size):(i * chunk_size - 1L),
type = LETTERS[[i]],
score = rbinom(chunk_size, 10, (10 - i) /10),
stringsAsFactors = FALSE)
dbWriteTable(con, "products", chunk,
append = i > 1, row.names = FALSE)
}
dbCommit(con)
}, error = function(e) {
warning("An error occurs: ", e, "\nRolling back", immediate. = TRUE)
dbRollback(con)
})
## Processing chunk 1
## Processing chunk 2
## Processing chunk 3
## Processing chunk 4
## Processing chunk 5
## Warning in value[[3L]](cond): An error occurs: Error in doTryCatch(return(expr), name, parentenv, handler): Data error
##
## Rolling back
我们可以看到同样的错误再次发生。然而,这次,错误被捕获,事务被取消,数据库回滚。为了验证,我们再次计算 products 表中的记录数:
dbGetQuery(con, "select COUNT(*) from products")
## Error in sqliteSendQuery(con, statement, bind.data): error in statement: no such table: products
dbDisconnect(con)
## [1] TRUE
可能会令人惊讶的是,计数查询会导致错误。为什么它不返回 0?如果我们仔细观察示例,我们应该理解,当我们第一次调用 dbWriteTable() 时,它首先创建一个新表,然后插入第一块数据。换句话说,表创建包含在事务中。因此,当我们回滚时,表创建也会被撤销。结果,先前的计数查询产生错误,因为 products 实际上并不存在。如果我们开始事务之前表已经存在,计数应该等于事务之前的记录数,就像什么都没发生一样。
另一个需要强一致性的例子是账户转账。当我们从一个账户向另一个账户转账一定金额时,我们需要确保系统从第一个账户中扣除相应金额,并将相同金额存入第二个账户。这两个变更必须同时发生或同时失败以保持一致性。这可以通过关系型数据库的事务轻松实现。
假设我们定义一个函数来创建一个虚拟银行的 SQLite 数据库。我们将使用 dbSendQuery() 发送命令来创建账户表和事务表:
create_bank <- function(dbfile) {
if (file.exists(dbfile)) file.remove(dbfile)
con <- dbConnect(SQLite(), dbfile)
dbSendQuery(con,
"create table accounts
(name text primary key, balance real)")
dbSendQuery(con,
"create table transactions
(time text, account_from text, account_to text, value real)")
con
}
账户表有两个列:name 和 balance。事务表有四个列:time、account_from、account_to 和 value。第一个表存储所有账户信息,第二个表存储所有历史交易。
我们还将定义一个函数,用于创建一个具有名称和初始余额的账户。该函数使用 insert into 语句将一条新记录写入账户表:
create_account <- function(con, name, balance) {
dbSendQuery(con,
sprintf("insert into accounts (name, balance) values ('%s', %.2f)", name, balance))
TRUE
}
注意,我们使用 sprintf 生成前面的 SQL 语句。它适用于本地和个人使用,但通常不适用于网络应用程序,因为黑客可以轻松地编写部分表达式来运行任何灾难性的语句来操纵整个数据库。
接下来,我们将定义一个转账函数。该函数检查提款账户和收款账户是否都存在于数据库中。它确保提款账户的余额足以进行此类转账。如果转账有效,则更新两个账户的余额,并将一条事务记录添加到数据库中:
transfer <- function(con, from, to, value) {
get_account <- function(name) {
account <- dbGetQuery(con,
sprintf("select * from accounts
where name = '%s'", name))
if (nrow(account) == 0)
stop(sprintf("Account '%s' does not exist", name))
account
}
account_from <- get_account(from)
account_to <- get_account(to)
if (account_from$balance < value) {
stop(sprintf("Insufficient money to transfer from '%s'",
from))
} else {
dbSendQuery(con,
sprintf("update accounts set balance = %.2f
where name = '%s'",
account_from$balance - value, from))
dbSendQuery(con,
sprintf("update accounts set balance = %.2f
where name = '%s'",
account_to$balance + value, to))
dbSendQuery(con,
sprintf("insert into transactions (time, account_from,
account_to, value) values
('%s', '%s', '%s', %.2f)",
format(Sys.time(), "%Y-%m-%d %H:%M:%S"),
from, to, value))
}
TRUE
}
虽然我们对提款账户可能的资金不足进行了一些基本检查,但我们仍然不能确保转账的安全性,因为它可能被其他原因中断。因此,我们将实现一个安全的 transfer 版本,其中我们将使用事务来确保如果发生任何错误,transfer 所做的任何更改都可以撤销:
safe_transfer <- function(con, ...) {
dbBegin(con)
tryCatch({
transfer(con, ...)
dbCommit(con)
}, error = function(e) {
message("An error occurs in the transaction. Rollback...")
dbRollback(con)
stop(e)
})
}
实际上,safe_transfer 是 transfer 的包装函数。它只是将 transfer 放入 tryCatch 的沙盒中。如果发生错误,我们调用 dbRollback() 来确保数据库的一致性。
在将函数放入测试之前,我们需要函数来查看给定账户的余额以及账户之间发生的所有成功交易:
get_balance <- function(con, name) {
res <- dbGetQuery(con,
sprintf("select balance from accounts
where name = '%s'", name))
res$balance
}
get_transactions <- function(con, from, to) {
dbGetQuery(con,
sprintf("select * from transactions
where account_from = '%s' and account_to = '%s'",
from, to))
}
现在,我们可以进行一些测试。首先,我们将使用 create_bank() 创建一个虚拟银行,该函数返回数据库文件的 SQLite 连接。然后,我们将创建两个账户并设置一些初始余额:
con <- create_bank("data/bank.sqlite")
create_account(con, "David", 5000)
## [1] TRUE
create_account(con, "Jenny", 6500)
## [1] TRUE
get_balance(con, "David")
## [1] 5000
get_balance(con, "Jenny")
## [1] 6500
然后,我们将使用 safe_transfer() 将一些钱从大卫的账户转到珍妮的账户:
safe_transfer(con, "David", "Jenny", 1500)
## [1] TRUE
get_balance(con, "David")
## [1] 3500
get_balance(con, "Jenny")
## [1] 8000
转账成功,两个账户的余额以一致的方式改变。现在,我们将进行另一笔转账。这次,大卫的账户余额不足,所以转账将以错误结束:
safe_transfer(con, "David", "Jenny", 6500)
## An error occurs in the transaction. Rollback...
## Error in transfer(con, ...): Insufficient money to transfer from 'David'
get_balance(con, "David")
## [1] 3500
get_balance(con, "Jenny")
## [1] 8000
错误被捕获,函数回滚数据库。两个账户的余额都没有变化。现在,我们将查询所有成功的交易:
get_transactions(con, "David", "Jenny")
## time account_from account_to value
## 1 2016-06-08 23:24:39 David Jenny 1500
我们可以看到第一笔交易,但失败的交易没有出现在数据库中。最后,我们应始终记得关闭数据库连接:
dbDisconnect(con)
## [1] TRUE
将数据存储在文件到数据库中
当我们处理大型数据文件时,我们可能会遇到读写数据的问题。在实践中,有两种极端情况。一种极端是极其庞大的文本格式数据源,几乎不可能将其加载到内存中。另一种是大量的小数据文件,需要一些努力将它们整合到一个数据框中。
对于第一种情况,我们可以分块读取大源数据,并将每个块追加到数据库中的一个特定表中。以下函数是为从大源文件向数据库表追加行而设计的,给定输入文件、输出数据库、表名和块大小。考虑到输入数据可能太大而无法加载到内存中,所以该函数将每次读取一个块以写入数据库,因此只需要很少的工作内存:
chunk_rw <- function(input, output, table, chunk_size = 10000) {
first_row <- read.csv(input, nrows = 1, header = TRUE)
header <- colnames(first_row)
n <- 0
con <- dbConnect(SQLite(), output)
on.exit(dbDisconnect(con))
while (TRUE) {
df <- read.csv(input,
skip = 1 + n * chunk_size, nrows = chunk_size,
header = FALSE, col.names = header,
stringsAsFactors = FALSE)
if (nrow(df) == 0) break;
dbWriteTable(con, table, df, row.names = FALSE, append = n > 0)
n <- n + 1
cat(sprintf("%d records written\n", nrow(df)))
}
}
这里的技巧是正确计算输入文件中每个块的位置偏移量。
为了测试函数,我们首先将 diamonds 写入一个 csv 文件,并使用 chunk_rw() 将 csv 文件分块写入 SQLite 数据库。使用这种方法,写入过程只需要比将整个数据加载到内存中所需的内存小得多:
write.csv(diamonds, "data/diamonds.csv", quote = FALSE, row.names = FALSE)
chunk_rw("data/diamonds.csv", "data/diamonds.sqlite", "diamonds")
## 10000 records written
## 10000 records written
## 10000 records written
## 10000 records written
## 10000 records written
## 3940 records written
加载数据的另一种极端情况是我们需要从许多小数据文件中读取。在这种情况下,我们可以将这些文件中分布的所有数据放入数据库中,这样我们就可以轻松地查询数据。以下函数旨在将文件夹中所有 csv 文件的数据放入一个数据库中:
batch_rw <- function(dir, output, table, overwrite = TRUE) {
files <- list.files(dir, "\\.csv$", full.names = TRUE)
con <- dbConnect(SQLite(), output)
on.exit(dbDisconnect(con))
exist <- dbExistsTable(con, table)
if (exist) {
if (overwrite) dbRemoveTable(con, table)
else stop(sprintf("Table '%s' already exists", table))
}
exist <- FALSE
for (file in files) {
cat(file, "... ")
df <- read.csv(file, header = TRUE,
stringsAsFactors = FALSE)
dbWriteTable(con, table, df, row.names = FALSE,
append = exist)
exist <- TRUE
cat("done\n")
}
}
为了演示,我们在 data/groups 中有一些小的 csv 文件,并使用 batch_rw() 将所有数据放入数据库中:
batch_rw("data/groups", "data/groups.sqlite", "groups")
## data/groups/group1.csv ... done
## data/groups/group2.csv ... done
## data/groups/group3.csv ... done
现在,所有文件中的数据都已放入数据库中。我们可以查询或读取整个表,看看它看起来像什么:
con <- dbConnect(SQLite(), "data/groups.sqlite")
dbReadTable(con, "groups")
## group id grade
## 1 1 I-1 A
## 2 1 I-2 B
## 3 1 I-3 A
## 4 2 II-1 C
## 5 2 II-2 C
## 6 3 III-1 B
## 7 3 III-2 B
## 8 3 III-3 A
## 9 3 III-4 C
dbDisconnect(con)
## [1] TRUE
在本节中,你了解了一些 SQLite 数据库的基本知识和用法。然而,许多流行的关系型数据库在功能和查询语言方面有许多共同特征。几乎相同的知识,你可以通过 RMySQL 使用 MySQL,通过 RPostges 使用 PostgreSQL,通过 RSQLServer 使用 Microsoft SQL Server,以及通过 RODBC 使用 ODBC 兼容的数据库(Microsoft Access 和 Excel)。它们具有几乎相同的操作功能,所以如果你熟悉其中一个,你就不应该有在使用其他数据库时的问题。
与 NoSQL 数据库一起工作
在本章的前一节中,你学习了关系型数据库的基础知识以及如何使用 SQL 查询数据。关系型数据通常以表格形式组织,即作为具有关系的表集合。
然而,当数据量超过服务器的容量时,由于传统的关系型数据库模型不容易支持水平扩展,即不是存储在单个服务器上而是在服务器集群中存储数据,因此会出现问题。这增加了数据库管理的复杂性,因为数据以分布式形式存储,同时仍然作为一个逻辑数据库访问。
近年来,由于新数据库模型的引入以及它们在大数据分析实时应用中展现的卓越性能,NoSQL 或非关系型数据库比以前更加流行。一些非关系型数据库旨在实现高可用性、可扩展性和灵活性,而另一些则旨在实现高性能。
关系型数据库和非关系型数据库在存储模型上的差异非常明显。例如,对于一个购物网站,商品和评论可以存储在关系型数据库的两个表中:商品和评论。所有商品信息存储在一个表中,每个商品的评论存储在另一个表中。以下代码显示了此类表的基本结构:
products:
code,name,type,price,amount
A0000001,Product-A,Type-I,29.5,500
每条评论都有一个字段指向它所针对的产品:
comments:
code,user,score,text
A0000001,david,8,"This is a good product"
A0000001,jenny,5,"Just so so"
当一个产品有许多相关表且记录数量如此之大以至于数据库必须分布到大量服务器上时,查询这样的数据库会非常困难,因为执行一个简单的查询可能非常低效。如果我们使用 MongoDB 来存储这样的数据,每个商品都将作为一个文档存储,而这个商品的评论都作为文档的字段存储在一个数组中。因此,查询数据将变得非常容易,数据库可以轻松地分布到大量服务器上。
与 MongoDB 一起工作
MongoDB 是一个流行的非关系型数据库,它提供了一种面向文档的数据存储方式。每个产品都是一个集合中的文档。产品有一些描述性信息的字段,还有一个包含评论的数组字段。所有评论都是子文档,这样每个逻辑项都可以以它们自己的逻辑形式存储。
这里是集合中一个商品的 JSON (en.wikipedia.org/wiki/JSON) 表示:
{
"code":"A0000001",
"name":"Product-A",
"type":"Type-I",
"price":29.5,
"amount":500,
"comments":[
{
"user":"david",
"score":8,
"text":"This is a good product"
},
{
"user":"jenny",
"score":5,
"text":"Just so so"
}
]
}
关系型数据库可能包含许多模式。每个模式(或数据库)可能包含许多表。每个表可能包含许多记录。同样,MongoDB 实例可以托管许多数据库。每个数据库可以包含许多集合。每个集合可能包含许多文档。主要区别在于关系型数据库表中的记录需要具有相同的结构,但 MongoDB 数据库集合中的文档是无模式的,并且足够灵活,可以具有嵌套结构。
在前面的 JSON 代码中,例如,一个商品由以下文档表示,其中 code、name、type、price 和 amount 是具有简单数据类型的数据字段,而 comments 是对象的数组。每个评论由 comments 中的一个对象表示,并具有 user、score 和 text 的结构。一个商品的所有评论都存储在 comments 中的一个对象中。因此,在产品信息和评论方面,一个商品高度自包含。如果我们需要产品的信息,我们不再需要连接两个表,只需挑选几个字段即可。
要安装 MongoDB,请访问 docs.mongodb.com/manual/installation/ 并按照说明操作。它支持几乎所有主要平台。
从 MongoDB 查询数据
假设我们有一个在本地机器上运行的正常 MongoDB 实例。我们可以使用 mongolite 包来与 MongoDB 一起工作。要安装该包,请运行以下代码:
install.packages("mongolite")
一旦安装了包,我们就可以通过指定集合、数据库和 MongoDB 地址来创建 Mongo 连接:
library(mongolite)
m <- mongo("students", "test", "mongodb://localhost")
首先,我们将创建到本地 MongoDB 实例的连接。最初,products 集合没有文档:
m$count()
## [1] 0
要插入带有注释的产品,我们可以直接将 JSON 文档作为字符串传递给 m$insert():
m$insert('
{
"code": "A0000001",
"name": "Product-A",
"type": "Type-I",
"price": 29.5,
"amount": 500,
"comments": [
{
"user": "david",
"score": 8,
"text": "This is a good product"
},
{
"user": "jenny",
"score": 5,
"text": "Just so so"
}
]
}')
现在,集合中有一个文档:
m$count()
## [1] 1
或者,我们可以使用 R 中的列表对象来表示相同的结构。以下代码使用 list 插入第二个产品:
m$insert(list(
code = "A0000002",
name = "Product-B",
type = "Type-II",
price = 59.9,
amount = 200L,
comments = list(
list(user = "tom", score = 6L,
text = "Just fine"),
list(user = "mike", score = 9L,
text = "great product!")
)
), auto_unbox = TRUE)
注意,R 不提供标量类型,因此默认情况下,所有向量在 MongoDB 中都被解释为 JSON 数组,除非 auto_unbox = TRUE,这会将单元素向量转换为 JSON 中的标量。如果没有 auto_unbox = TRUE,就必须使用 jsonlite::unbox() 来确保标量输出或使用 I() 来确保数组输出。
现在,集合中有两个文档:
m$count()
## [1] 2
然后,我们可以使用 m$find() 来检索集合中的所有文档,结果会自动简化为数据框,以便更容易地进行数据处理:
products <- m$find()
##
Found 2 records...
Imported 2 records. Simplifying into dataframe...
str(products)
## 'data.frame': 2 obs. of 6 variables:
## $ code : chr "A0000001" "A0000002"
## $ name : chr "Product-A" "Product-B"
## $ type : chr "Type-I" "Type-II"
## $ price : num 29.5 59.9
## $ amount : int 500 200
## $ comments:List of 2
## ..$ :'data.frame': 2 obs. of 3 variables:
## .. ..$ user : chr "david" "jenny"
## .. ..$ score: int 8 5
## .. ..$ text : chr "This is a good product" "Just so so"
## ..$ :'data.frame': 2 obs. of 3 variables:
## .. ..$ user : chr "tom" "mike"
## .. ..$ score: int 6 9
## .. ..$ text : chr "Just fine" "great product!"
为了避免自动转换,我们可以使用 m$iterate() 遍历集合,并获取表示原始存储形式的列表对象:
iter <- m$iterate()
products <- iter$batch(2)
str(products)
## List of 2
## $ :List of 6
## ..$ code : chr "A0000001"
## ..$ name : chr "Product-A"
## ..$ type : chr "Type-I"
## ..$ price : num 29.5
## ..$ amount : int 500
## ..$ comments:List of 2
## .. ..$ :List of 3
## .. .. ..$ user : chr "david"
## .. .. ..$ score: int 8
## .. .. ..$ text : chr "This is a good product"
## .. ..$ :List of 3
## .. .. ..$ user : chr "jenny"
## .. .. ..$ score: int 5
## .. .. ..$ text : chr "Just so so"
## $ :List of 6
## ..$ code : chr "A0000002"
## ..$ name : chr "Product-B"
## ..$ type : chr "Type-II"
## ..$ price : num 59.9
## ..$ amount : int 200
## ..$ comments:List of 2
## .. ..$ :List of 3
## .. .. ..$ user : chr "tom"
## .. .. ..$ score: int 6
## .. .. ..$ text : chr "Just fine"
## .. ..$ :List of 3
## .. .. ..$ user : chr "mike"
## .. .. ..$ score: int 9
## .. .. ..$ text : chr "great product!"
要过滤集合,我们可以在 m$find() 中指定条件查询和字段。
首先,我们将查询code为A0000001的文档,并检索name、price和amount字段:
m$find('{ "code": "A0000001" }',
'{ "_id": 0, "name": 1, "price": 1, "amount": 1 }')
##
Found 1 records...
Imported 1 records. Simplifying into dataframe...
## name price amount
## 1 Product-A 29.5 500
然后,我们将查询price大于或等于40的文档,这是通过条件查询中的$gte运算符完成的:
m$find('{ "price": { "$gte": 40 } }',
'{ "_id": 0, "name": 1, "price": 1, "amount": 1 }')
##
Found 1 records...
Imported 1 records. Simplifying into dataframe...
## name price amount
## 1 Product-B 59.9 200
我们不仅可以查询文档字段,还可以查询数组字段中的对象字段。以下代码检索所有包含任何给出 9 分评论的文档:
m$find('{ "comments.score": 9 }',
'{ "_id": 0, "code": 1, "name": 1}')
##
Found 1 records...
Imported 1 records. Simplifying into dataframe...
## code name
## 1 A0000002 Product-B
类似地,以下代码检索所有包含任何给出低于 6 分评论的文档:
m$find('{ "comments.score": { "$lt": 6 }}',
'{ "_id": 0, "code": 1, "name": 1}')
##
Found 1 records...
Imported 1 records. Simplifying into dataframe...
## code name
## 1 A0000001 Product-A
注意,通过使用.符号,可以轻松访问子文档的字段,这使得处理嵌套结构变得相当容易:
## [1] TRUE
m$insert()函数也适用于 R 中的数据框。现在,我们将创建一个新的 MongoDB 连接到另一个集合:
m <- mongo("students", "test", "mongodb://localhost")
我们将创建一个 MongoDB 连接m,以在本地 MongoDB 实例中的test数据库的students集合中工作:
m$count()
## [1] 0
初始时,集合中没有文档。为了插入一些数据,我们将创建一个简单的数据框:
students <- data.frame(
name = c("David", "Jenny", "Sara", "John"),
age = c(25, 23, 26, 23),
major = c("Statistics", "Physics", "Computer Science", "Statistics"),
projects = c(2, 1, 3, 1),
stringsAsFactors = FALSE
)
students
## name age major projects
## 1 David 25 Statistics 2
## 2 Jenny 23 Physics 1
## 3 Sara 26 Computer Science 3
## 4 John 23 Statistics 1
然后,我们将作为文档将行插入到集合中:
m$insert(students)
##
Complete! Processed total of 4 rows.
现在,集合中已经有了一些文档:
m$count()
## [1] 4
我们可以使用find()从集合中检索所有文档:
m$find()
##
Found 4 records...
Imported 4 records. Simplifying into dataframe...
## name age major projects
## 1 David 25 Statistics 2
## 2 Jenny 23 Physics 1
## 3 Sara 26 Computer Science 3
## 4 John 23 Statistics 1
正如我们在前面的示例中提到的,文档在 MongoDB 集合中的存储方式与关系数据库表中列的存储方式不同。MongoDB 集合中的一个文档更像是 JSON 文档,但实际上它是以二进制形式存储的,以实现超级性能和紧凑性。m$find()函数首先以类似 JSON 的形式检索数据,并将其简化为数据形式,以便于数据操作。
为了过滤数据,我们可以通过向find()提供查询条件来指定查询条件。例如,我们想要找到所有名为Jenny的文档:
m$find('{ "name": "Jenny" }')
##
Found 1 records...
Imported 1 records. Simplifying into dataframe...
## name age major projects
## 1 Jenny 23 Physics 1
结果会自动转换为数据框,以便更容易使用。然后,我们将查询所有项目数量大于或等于2的文档:
m$find('{ "projects": { "$gte": 2 }}')
##
Found 2 records...
Imported 2 records. Simplifying into dataframe...
## name age major projects
## 1 David 25 Statistics 2
## 2 Sara 26 Computer Science 3
要选择字段,我们将指定find()的fields参数:
m$find('{ "projects": { "$gte": 2 }}',
'{ "_id": 0, "name": 1, "major": 1 }')
##
Found 2 records...
Imported 2 records. Simplifying into dataframe...
## name major
## 1 David Statistics
## 2 Sara Computer Science
我们还可以通过指定sort参数对数据进行排序:
m$find('{ "projects": { "$gte": 2 }}',
fields ='{ "_id": 0, "name": 1, "age": 1 }',
sort ='{ "age": -1 }')
##
Found 2 records...
Imported 2 records. Simplifying into dataframe...
## name age
## 1 Sara 26
## 2 David 25
为了限制返回的文档数量,我们将指定limit:
m$find('{ "projects": { "$gte": 2 }}',
fields ='{ "_id": 0, "name": 1, "age": 1 }',
sort ='{ "age": -1 }',
limit =1)
##
Found 1 records...
Imported 1 records. Simplifying into dataframe...
## name age
## 1 Sara 26
此外,我们还可以获取所有文档中某个字段的全部唯一值:
m$distinct("major")
## [1] "Statistics" "Physics" "Computer Science"
我们可以获取满足条件的唯一值:
m$distinct("major", '{ "projects": { "$gte": 2 } }')
## [1] "Statistics" "Computer Science"
要更新文档,我们将调用update(),找到选择中的文档,并设置某些字段的值:
m$update('{ "name": "Jenny" }', '{ "$set": { "age": 24 } }')
## [1] TRUE
m$find()
##
Found 4 records...
Imported 4 records. Simplifying into dataframe...
## name age major projects
## 1 David 25 Statistics 2
## 2 Jenny 24 Physics 1
## 3 Sara 26 Computer Science 3
## 4 John 23 Statistics 1
创建和删除索引
与关系数据库类似,MongoDB 也支持索引。每个集合可能有多个索引,索引字段被缓存在内存中以实现快速查找。正确创建的索引可以使文档查找非常高效。
使用mongolite在 MongoDB 中创建索引很容易。可以在将数据导入集合之前或之后进行。然而,如果我们已经导入数十亿个文档,创建索引可能需要很长时间。如果我们创建许多索引,然后在集合中放入任何文档之前,插入文档的性能可能会受到影响。
在这里,我们将为students集合创建一个索引:
m$index('{ "name": 1 }')
## v key._id key.name name ns
## 1 1 1 NA _id_ test.students
## 2 1 NA 1 name_1 test.students
现在,如果我们找到具有索引字段的文档,性能将非常出色:
m$find('{ "name": "Sara" }')
##
Found 1 records...
Imported 1 records. Simplifying into dataframe...
## name age major projects
## 1 Sara 26 Computer Science 3
如果没有文档满足条件,将返回一个空的数据框:
m$find('{ "name": "Jane" }')
##
Imported 0 records. Simplifying into dataframe...
## data frame with 0 columns and 0 rows
最后,可以使用drop()方法丢弃集合:
m$drop()
## [1] TRUE
如果数据量小,使用索引带来的性能提升并不明显。在下一个示例中,我们将创建一个具有许多行的数据框,以便我们可以比较使用索引和不使用索引查找文档的性能:
在这里,我们将使用expand.grid()创建一个数据框,该数据框穷尽了所有可能的参数中提供的向量的组合:
set.seed(123)
m <- mongo("simulation", "test")
sim_data <- expand.grid(
type = c("A", "B", "C", "D", "E"),
category = c("P-1", "P-2", "P-3"),
group = 1:20000,
stringsAsFactors = FALSE)
head(sim_data)
## type category group
## 1 A P-1 1
## 2 B P-1 1
## 3 C P-1 1
## 4 D P-1 1
## 5 E P-1 1
## 6 A P-2 1
索引列已创建。接下来,我们需要模拟一些随机数:
sim_data$score1 <- rnorm(nrow(sim_data), 10, 3)
sim_data$test1 <- rbinom(nrow(sim_data), 100, 0.8)
现在的数据框看起来像这样:
head(sim_data)
## type category group score1 test1
## 1 A P-1 1 8.318573 80
## 2 B P-1 1 9.309468 75
## 3 C P-1 1 14.676125 77
## 4 D P-1 1 10.211525 79
## 5 E P-1 1 10.387863 80
## 6 A P-2 1 15.145195 76
然后,我们将所有数据插入到simulation集合中:
m$insert(sim_data)
Complete! Processed total of 300000 rows.
[1] TRUE
第一次测试试图回答在没有索引的情况下查询文档需要多长时间:
system.time(rec <- m$find('{ "type": "C", "category": "P-3", "group": 87 }'))
##
Found 1 records...
Imported 1 records. Simplifying into dataframe...
## user system elapsed
## 0.000 0.000 0.104
rec
## type category group score1 test1
## 1 C P-3 87 6.556688 72
第二次测试是关于使用联合条件查找文档的性能:
system.time({
recs <- m$find('{ "type": { "$in": ["B", "D"] },
"category": { "$in": ["P-1", "P-2"] },
"group": { "$gte": 25, "$lte": 75 } }')
})
##
Found 204 records...
Imported 204 records. Simplifying into dataframe...
## user system elapsed
## 0.004 0.000 0.094
然后,生成的数据框看起来像这样:
head(recs)
## type category group score1 test1
## 1 B P-1 25 11.953580 80
## 2 D P-1 25 13.074020 84
## 3 B P-2 25 11.134503 76
## 4 D P-2 25 12.570769 74
## 5 B P-1 26 7.009658 77
## 6 D P-1 26 9.957078 85
第三次测试是关于使用非索引字段查找文档的性能:
system.time(recs2 <- m$find('{ "score1": { "$gte": 20 } }'))
##
Found 158 records...
Imported 158 records. Simplifying into dataframe...
## user system elapsed
## 0.000 0.000 0.096
生成的数据框看起来像这样:
head(recs2)
## type category group score1 test1
## 1 D P-1 89 20.17111 76
## 2 B P-3 199 20.26328 80
## 3 E P-2 294 20.33798 75
## 4 E P-2 400 21.14716 83
## 5 A P-3 544 21.54330 73
## 6 A P-1 545 20.19368 80
所有三个测试都是在没有为集合创建索引的情况下完成的。为了对比,我们现在将创建一个索引:
m$index('{ "type": 1, "category": 1, "group": 1 }')
## v key._id key.type key.category key.group
## 1 1 1 NA NA NA
## 2 1 NA 1 1 1
## name ns
## 1 _id_ test.simulation
## 2 type_1_category_1_group_1 test.simulation
一旦创建了索引,使用索引字段进行第一次测试查询就变得快速:
system.time({
rec <- m$find('{ "type": "C", "category": "P-3", "group": 87 }')
})
##
Found 1 records...
Imported 1 records. Simplifying into dataframe...
## user system elapsed
## 0.000 0.000 0.001
第二次测试也很快得出结果:
system.time({
recs <- m$find('{ "type": { "$in": ["B", "D"] },
"category": { "$in": ["P-1", "P-2"] },
"group": { "$gte": 25, "$lte": 75 } }')
})
##
Found 204 records...
Imported 204 records. Simplifying into dataframe...
## user system elapsed
## 0.000 0.000 0.002
然而,非索引字段不会对文档的索引搜索做出贡献:
system.time({
recs2 <- m$find('{ "score1": { "$gte": 20 } }')
})
##
Found 158 records...
Imported 158 records. Simplifying into dataframe...
## user system elapsed
## 0.000 0.000 0.095
MongoDB 的另一个重要特性是其聚合管道。当我们进行数据聚合时,我们提供一个聚合操作的数组,以便它们由 MongoDB 实例调度。例如,以下代码按type对数据进行分组。每个组都有一个计数字段、平均分数、最低测试分数和最高测试分数。由于输出可能很长,这里没有打印出来。您可以自己执行代码并查看结果:
m$aggregate('[
{ "$group": {
"_id": "$type",
"count": { "$sum": 1 },
"avg_score": { "$avg": "$score1" },
"min_test": { "$min": "$test1" },
"max_test": { "$max": "$test1" }
}
}
]')
我们也可以使用多个字段作为组的键,这类似于 SQL 中的group by A, B:
m$aggregate('[
{ "$group": {
"_id": { "type": "$type", "category": "$category" },
"count": { "$sum": 1 },
"avg_score": { "$avg": "$score1" },
"min_test": { "$min": "$test1" },
"max_test": { "$max": "$test1" }
}
}
]')
聚合管道支持在流式处理中运行聚合操作:
m$aggregate('[
{ "$group": {
"_id": { "type": "$type", "category": "$category" },
"count": { "$sum": 1 },
"avg_score": { "$avg": "$score1" },
"min_test": { "$min": "$test1" },
"max_test": { "$max": "$test1" }
}
},
{
"$sort": { "_id.type": 1, "avg_score": -1 }
}
]')
我们可以通过添加更多操作来延长管道。例如,以下代码创建组并聚合数据。然后,它按平均分数降序排序文档,取出前三个文档,并将字段投影到有用的内容中:
m$aggregate('[
{ "$group": {
"_id": { "type": "$type", "category": "$category" },
"count": { "$sum": 1 },
"avg_score": { "$avg": "$score1" },
"min_test": { "$min": "$test1" },
"max_test": { "$max": "$test1" }
}
},
{
"$sort": { "avg_score": -1 }
},
{
"$limit": 3
},
{
"$project": {
"_id.type": 1,
"_id.category": 1,
"avg_score": 1,
"test_range": { "$subtract": ["$max_test", "$min_test"] }
}
}
]')
除了我们在示例中使用的聚合运算符之外,还有很多其他更强大的运算符。更多详情,请访问 docs.mongodb.com/manual/reference/operator/aggregation-pipeline/ 和 docs.mongodb.com/manual/reference/operator/aggregation-arithmetic/.
MongoDB 的另一个重要特性是它在内部级别支持 MapReduce (en.wikipedia.org/wiki/MapReduce)。MapReduce 模型在分布式集群的大数据分析中得到了广泛应用。在我们的环境中,我们可以编写一个非常简单的 MapReduce 代码,尝试生成某些数据的直方图:
bins <- m$mapreduce(
map = 'function() {
emit(Math.floor(this.score1 / 2.5) * 2.5, 1);
}',
reduce = 'function(id, counts) {
return Array.sum(counts);
}'
)
MapReduce 的第一步是 map。在这一步中,所有值都被映射到一个键值对。然后,reduce 步骤聚合键值对。在上面的例子中,我们简单地计算了每个 bin 的记录数:
bins
## _id value
## 1 -5.0 6
## 2 -2.5 126
## 3 0.0 1747
## 4 2.5 12476
## 5 5.0 46248
## 6 7.5 89086
## 7 10.0 89489
## 8 12.5 46357
## 9 15.0 12603
## 10 17.5 1704
## 11 20.0 153
## 12 22.5 5
我们还可以从 bins 创建条形图:
with(bins, barplot(value /sum(value), names.arg = `_id`,
main = "Histogram of scores",
xlab = "score1", ylab = "Percentage"))
生成的图表如下所示:

如果集合不再使用,我们可以使用 drop() 函数将其删除:
m$drop()
## [1] TRUE
由于本节是入门级内容,MongoDB 的更高级用法超出了本书的范围。如果您对 MongoDB 感兴趣,请通过官方教程进行学习 docs.mongodb.com/manual/tutorial/.
使用 Redis
Redis (redis.io/) 与存储数据为表格形式的 SQLite 或允许存储和查询嵌套结构的 MongoDB 不同,它是一个内存数据结构存储。它将键值存储在内存中,因此具有非常高的键查找性能。然而,它不支持像 SQL 数据库或 MongoDB 中使用的查询语言。
Redis 通常用作高性能数据缓存。我们可以在其中存储和操作一系列基本数据结构。要安装 Redis,请访问 redis.io/download。不幸的是,Windows 操作系统未得到官方支持,但微软开源技术组开发和维护了 Redis 的 Win64 端口,请访问 github.com/MSOpenTech/redis。
虽然 SQL 数据库存储表,MongoDB 存储文档,但 Redis 将键值对存储如下:
name: Something
type: 1
grade: A
值可以是更复杂的数据结构(例如,哈希表、集合和有序集合),而不仅仅是简单值,Redis 提供了一个简单的接口,以高性能和低延迟的方式与这些数据结构交互。
从 R 访问 Redis
要从 R 访问 Redis 实例,我们可以使用提供简单函数与 Redis 交互的 rredis 包。要安装此包,请运行以下代码:
install.packages("rredis")
一旦包准备就绪,我们就可以连接到 Redis 实例:
library(rredis)
redisConnect()
如果我们留空参数,它默认连接到本地 Redis 实例。它还允许我们连接到远程实例。
从 Redis 服务器设置和获取值
Redis 最基本的使用方法是调用redisSet(key, value)来存储值。在 R 中,默认情况下,值会被序列化,这样我们就可以在 Redis 中存储任何 R 对象:
redisSet("num1", 100)
## [1] "OK"
命令成功后,我们可以使用相同的键来检索值:
redisGet("num1")
## [1] 100
我们可以存储一个整数向量:
redisSet("vec1", 1:5)
## [1] "OK"
redisGet("vec1")
## [1] 1 2 3 4 5
我们甚至可以存储一个数据框:
redisSet("mtcars_head", head(mtcars, 3))
## [1] "OK"
redisGet("mtcars_head")
## mpg cyl disp hp drat wt qsec vs am gear
## Mazda RX4 21.0 6 160 110 3.90 2.620 16.46 0 1 4
## Mazda RX4 Wag 21.0 6 160 110 3.90 2.875 17.02 0 1 4
## Datsun 710 22.8 4 108 93 3.85 2.320 18.61 1 1 4
## carb
## Mazda RX4 4
## Mazda RX4 Wag 4
## Datsun 710 1
实际上,如果其他计算机可以访问您的 Redis 实例,它们将使用redisGet()在 R 中得到相同的数据:
然而,如果键根本不存在,我们只能得到NULL:
redisGet("something")
## NULL
我们可以使用redisExists()来检测一个键是否已定义,而不是得到NULL:
redisExists("something")
## [1] FALSE
redisExists("num1")
## [1] TRUE
如果我们不再需要键,我们可以使用redisDelete()来删除它:
redisDelete("num1")
## [1] "1"
## attr(,"redis string value")
## [1] TRUE
redisExists("num1")
## [1] FALSE
除了简单的键值对之外,Redis 还支持更高级的数据结构。例如,我们可以使用redisHSet()来创建一个水果哈希表,其中不同的水果有不同的数量:
redisHSet("fruits", "apple", 5)
## [1] "1"
## attr(,"redis string value")
## [1] TRUE
redisHSet("fruits", "pear", 2)
## [1] "1"
## attr(,"redis string value")
## [1] TRUE
redisHSet("fruits", "banana", 9)
## [1] "1"
## attr(,"redis string value")
## [1] TRUE
我们可以通过调用redisHGet()来获取哈希表字段的值:
redisHGet("fruits", "banana")
## [1] 9
我们还可以获取一个列表来表示哈希表的结构:
redisHGetAll("fruits")
## $apple
## [1] 5
##
## $pear
## [1] 2
##
## $banana
## [1] 9
或者,我们可以获取哈希表的键:
redisHKeys("fruits")
## [[1]]
## [1] "apple"
## attr(,"redis string value")
## [1] TRUE
##
## [[2]]
## [1] "pear"
## attr(,"redis string value")
## [1] TRUE
##
## [[3]]
## [1] "banana"
## attr(,"redis string value")
## [1] TRUE
我们还可以只获取哈希表的值:
redisHVals("fruits")
## [[1]]
## [1] 5
##
## [[2]]
## [1] 2
##
## [[3]]
## [1] 9
此外,我们还可以简单地获取哈希表中的字段数量:
redisHLen("fruits")
## [1] "3"
## attr(,"redis string value")
## [1] TRUE
我们可以一次性获取多个字段的值:
redisHMGet("fruits", c("apple", "banana"))
## $apple
## [1] 5
##
## $banana
## [1] 9
我们还可以通过提供一个列表来设置多个字段的值:
redisHMSet("fruits", list(apple = 4, pear = 1))
## [1] "OK"
现在,字段的值已更新:
redisHGetAll("fruits")
## $apple
## [1] 4
##
## $pear
## [1] 1
##
## $banana
## [1] 9
除了哈希表之外,Redis 还支持队列。我们可以从队列的左侧或右侧推送值。例如,我们可以从队列的右侧推送整数1到3:
for (qi in 1:3) {
redisRPush("queue", qi)
}
我们可以使用redisLLen()来获取队列的当前长度:
redisLLen("queue")
## [1] "3"
## attr(,"redis string value")
## [1] TRUE
现在,队列有三个元素。请注意,值是一个字符向量,而不是整数。因此,如果我们需要在其他地方将其用作数字,我们需要将其转换。
然后,我们可以从队列的左侧持续弹出值:
redisLPop("queue")
## [1] 1
redisLPop("queue")
## [1] 2
redisLPop("queue")
## [1] 3
redisLPop("queue")
## NULL
注意,队列只有三个元素可以弹出。第四次尝试返回NULL,这可以作为一个检查队列是否为空的准则。
最后,我们应该关闭与 Redis 的连接以释放所有资源:
redisClose()
Redis 具有本章范围之外的高级功能。它不仅支持数据结构存储,还支持消息代理,即我们可以使用它在不同程序之间传递消息。有关更高级的使用方法,请参阅官方文档redis.io/documentation。
摘要
在本章中,你学习了如何从 R 访问不同类型的数据库。我们介绍了关系型数据库(如 SQLite)和非关系型数据库(如 MongoDB 和 Redis)的基本用法。在了解它们的功能和特性集的主要差异后,我们需要根据我们的目的和需求选择一个合适的数据库来在我们的项目中使用。
在许多与数据相关的项目中,数据存储和数据导入是初始步骤,但数据清洗和数据操作占据了大部分时间。在下一章中,我们将继续探讨数据操作技术。你将了解一些专门为便捷但强大的数据操作而量身定制的软件包。为了更好地使用这些软件包,我们需要更深入地了解它们的工作原理,这需要前面章节中介绍的良好知识基础。
第十二章:数据操作
在上一章中,你学习了访问不同类型数据库的方法,例如关系型数据库(SQLite 和 MySQL)和非关系型数据库(MongoDB 和 Redis)。关系型数据库通常以表格形式返回数据,而非关系型数据库可能支持嵌套数据结构和其他功能。
即使数据已经加载到内存中,通常也远未准备好进行数据分析。在这个阶段,大多数数据仍然需要清洗和转换,实际上,这可能占去在应用任何统计模型和可视化之前的大部分时间。在本章中,你将学习到一组内置函数和几个用于数据操作的工具包。这些工具包非常强大。然而,为了更好地使用这些工具包,我们需要对前几章中介绍的知识有一个具体的理解。
在本章中,我们将涵盖以下主题:
-
使用基本函数操作数据框
-
使用 SQL 通过
sqldf包查询数据框 -
使用
data.table操作数据 -
使用
dplyr管道操作数据框 -
使用
rlist处理嵌套数据结构
使用内置函数操作数据框
之前,你学习了数据框的基本知识。在这里,我们将回顾用于过滤数据框的内置函数。尽管数据框本质上是一个向量的列表,但由于所有列向量长度相同,我们可以像访问矩阵一样访问它。为了选择满足某些条件的行,我们将提供一个逻辑向量作为 [] 的第一个参数,而第二个参数留空。
在 R 中,这些操作可以使用内置函数完成。在本节中,我们将介绍一些最有助于将数据操作成所需形式作为模型输入或用于展示的内置函数。其中一些函数或技术已在之前的章节中介绍过。
本节和随后的部分中的大部分代码都是基于一组关于某些产品的虚构数据。我们将使用 readr 包来加载数据,以便更好地处理列类型。如果你还没有安装此包,请运行 install.packages("readr"):
library(readr)
product_info <- read_csv("data/product-info.csv")
product_info
## id name type class released
## 1 T01 SupCar toy vehicle yes
## 2 T02 SupPlane toy vehicle no
## 3 M01 JeepX model vehicle yes
## 4 M02 AircraftX model vehicle yes
## 5 M03 Runner model people yes
## 6 M04 Dancer model people no
一旦数据以数据框的形式加载到内存中,我们可以查看其列类型:
sapply(product_info, class)
## id name type class released
## "character" "character" "character" "character" "character"
readr::read_csv 参数与内置函数 read.csv 的行为不同。例如,它不会自动将字符串列转换为因子(这可能会引起问题,但价值不大)。因此,我建议你使用 readr 提供的函数从文件中读取表格数据到 R。如果我们使用 read.csv 文件,那么所有这些列都会是具有有限可能值的因子。
使用内置函数操作数据框
之前,你学习了数据框的基础知识。在本节中,我们将回顾用于过滤数据框的内置函数。尽管数据框本质上是一系列向量,但由于所有列向量长度相同,我们可以像访问矩阵一样访问它。要选择满足某些条件的行,我们将提供一个逻辑向量作为 [] 的第一个参数,而第二个参数留空。在以下示例中,我们将使用我们之前介绍的一系列产品信息和统计数据来演示基本的数据过滤方法和汇总技术。
例如,我们将取出所有 toy 类型的行:
product_info[product_info$type == "toy", ]
## id name type class released
## 1 T01 SupCar toy vehicle yes
## 2 T02 SupPlane toy vehicle no
或者,我们可以取出所有未发布的行:
product_info[product_info$released == "no", ]
## id name type class released
## 2 T02 SupPlane toy vehicle no
## 6 M04 Dancer model people no
要过滤列,我们将提供一个字符向量作为第二个参数,而第一个参数留空,这正是我们在子集化矩阵时所做的:
product_info[, c("id", "name", "type")]
## id name type
## 1 T01 SupCar toy
## 2 T02 SupPlane toy
## 3 M01 JeepX model
## 4 M02 AircraftX model
## 5 M03 Runner model
## 6 M04 Dancer model
或者,我们可以将数据框视为列表来过滤数据。我们只提供一个包含列名的字符向量 [],并省略逗号:
product_info[c("id", "name", "class")]
## id name class
## 1 T01 SupCar vehicle
## 2 T02 SupPlane vehicle
## 3 M01 JeepX vehicle
## 4 M02 AircraftX vehicle
## 5 M03 Runner people
## 6 M04 Dancer people
要通过行和列过滤数据框,我们将提供一个向量作为第一个参数来选择行,并提供一个向量作为第二个参数来选择列:
product_info[product_info$type == "toy", c("name", "class", "released")]
## name class released
## 1 SupCar vehicle yes
## 2 SupPlane vehicle no
如果行过滤条件基于某些列的值,前面的代码可能会非常冗余,尤其是当条件变得更加复杂时。另一个简化代码的内置函数是 subset,正如我们之前所介绍的:
subset(product_info,
subset = type == "model" & released == "yes",
select = name:class)
## name type class
## 3 JeepX model vehicle
## 4 AircraftX model vehicle
## 5 Runner model people
subset 函数使用非标准评估,这样我们就可以直接使用数据框的列,而无需多次输入 product_info,因为表达式是在数据框的上下文中被评估的。
同样,我们可以使用 with 在数据框的上下文中评估一个表达式,也就是说,数据框的列可以在表达式中作为符号使用,而无需反复指定数据框:
with(product_info, name[released == "no"])
## [1] "SupPlane" "Dancer"
表达式可以不仅仅是简单的子集化。我们可以通过计算向量的每个可能值的出现次数来总结数据。例如,我们可以创建一个记录类型的出现次数表:
with(product_info, table(type[released == "yes"]))
##
## model toy
## 3 1
除了产品信息表之外,我们还有一个描述每个产品某些属性的产品统计表:
product_stats <- read_csv("data/product-stats.csv")
product_stats
## id material size weight
## 1 T01 Metal 120 10.0
## 2 T02 Metal 350 45.0
## 3 M01 Plastics 50 NA
## 4 M02 Plastics 85 3.0
## 5 M03 Wood 15 NA
## 6 M04 Wood 16 0.6
现在,思考一下我们如何获取最大三个尺寸的产品名称。一种方法是对 product_stats 中的记录按尺寸降序排序,选择前三个记录的 id 值,并使用这些值通过 id 过滤 product_info 的行:
top_3_id <- product_stats[order(product_stats$size, decreasing = TRUE), "id"][1:3]
product_info[product_info$id %in% top_3_id, ]
## id name type class released
## 1 T01 SupCar toy vehicle yes
## 2 T02 SupPlane toy vehicle no
## 4 M02 AircraftX model vehicle yes
虽然它按预期工作,但这种方法看起来相当冗余。请注意,product_info 和 product_stats 实际上是从不同角度描述相同产品集合的。这两个表之间的联系是 id 列。每个 id 都是唯一的,并指代相同的产品。要访问这两组信息,我们可以将两个表合并到一个数据框中。最简单的方法是使用 merge:
product_table <- merge(product_info, product_stats, by = "id")
product_table
## id name type class released material size weight
## 1 M01 JeepX model vehicle yes Plastics 50 NA
## 2 M02 AircraftX model vehicle yes Plastics 85 3.0
## 3 M03 Runner model people yes Wood 15 NA
## 4 M04 Dancer model people no Wood 16 0.6
## 5 T01 SupCar toy vehicle yes Metal 120 10.0
## 6 T02 SupPlane toy vehicle no Metal 350 45.0
现在,我们创建一个新的数据框,它是product_table和product_info的合并版本,具有共享的id列。实际上,如果你重新排列第二个表中的记录,这两个表仍然可以正确合并。
使用合并版本,我们可以更容易地做事情。例如,使用合并版本,我们可以对任何我们加载的表格中的任何列进行排序,而无需手动处理其他列:
product_table[order(product_table$size), ]
## id name type class released material size weight
## 3 M03 Runner model people yes Wood 15 NA
## 4 M04 Dancer model people no Wood 16 0.6
## 1 M01 JeepX model vehicle yes Plastics 50 NA
## 2 M02 AircraftX model vehicle yes Plastics 85 3.0
## 5 T01 SupCar toy vehicle yes Metal 120 10.0
## 6 T02 SupPlane toy vehicle no Metal 350 45.0
为了解决问题,我们可以直接使用合并后的表格并得到相同的结果:
product_table[order(product_table$size, decreasing = TRUE), "name"][1:3]
## [1] "SupPlane" "SupCar" "AircraftX"
合并后的数据框允许我们根据一个数据框中的列对记录进行排序,并按另一个数据框中的列过滤记录。例如,我们将首先按重量降序对产品记录进行排序,并选择所有model类型的记录:
product_table[order(product_table$weight, decreasing = TRUE), ][
product_table$type == "model",]
## id name type class released material size weight
## 6 T02 SupPlane toy vehicle no Metal 350 45.0
## 5 T01 SupCar toy vehicle yes Metal 120 10.0
## 2 M02 AircraftX model vehicle yes Plastics 85 3.0
## 4 M04 Dancer model people no Wood 16 0.6
有时,列值是字面值,但可以转换为标准的 R 数据结构以更好地表示数据。例如,product_info中的released列只接受yes和no,这可以用逻辑向量更好地表示。我们可以使用<-来修改列值,就像你之前学过的那样。然而,通常更好的做法是创建一个新的数据框,其中现有的列已适当调整,并添加了新列,而不污染原始数据。为此,我们可以使用transform:
transform(product_table,
released = ifelse(released == "yes", TRUE, FALSE),
density = weight / size)
## id name type class released material size weight
## 1 M01 JeepX model vehicle TRUE Plastics 50 NA
## 2 M02 AircraftX model vehicle TRUE Plastics 85 3.0
## 3 M03 Runner model people TRUE Wood 15 NA
## 4 M04 Dancer model people FALSE Wood 16 0.6
## 5 T01 SupCar toy vehicle TRUE Metal 120 10.0
## 6 T02 SupPlane toy vehicle FALSE Metal 350 45.0
## density
## 1 NA
## 2 0.03529412
## 3 NA
## 4 0.03750000
## 5 0.08333333
## 6 0.12857143
结果是一个新的数据框,其中released被转换为逻辑向量,并添加了一个新的列density。你可以轻松验证product_table没有被修改。
此外,请注意,transform的工作方式与subset类似,因为这两个函数都使用非标准评估,允许直接在参数中使用数据框列作为符号,这样我们就不必在所有列前都输入product_table$。
在前面的数据中,许多列包含用NA表示的缺失值。在许多情况下,我们不想在我们的数据中存在任何缺失值。因此,我们需要以某种方式处理它们。为了展示各种技术,我们将加载另一个包含缺失值的表。这个表是我们之前使用的数据集中每个产品的质量、耐用性和防水测试结果。它是每个产品的质量、耐用性和防水测试结果。我们将数据存储在product_tests中:
product_tests <- read_csv("data/product-tests.csv")
product_tests
## id quality durability waterproof
## 1 T01 NA 10 no
## 2 T02 10 9 no
## 3 M01 6 4 yes
## 4 M02 6 5 yes
## 5 M03 5 NA yes
## 6 M04 6 6 yes
注意,quality和durability中的值都包含缺失值(NA)。为了排除所有包含缺失值的行,我们可以使用na.omit():
na.omit(product_tests)
## id quality durability waterproof
## 2 T02 10 9 no
## 3 M01 6 4 yes
## 4 M02 6 5 yes
## 6 M04 6 6 yes
另一种方法是使用complete.cases()来获取一个逻辑向量,指示所有完整行(没有任何缺失值):
complete.cases(product_tests)
## [1] FALSE TRUE TRUE TRUE FALSE TRUE
然后,我们可以使用这个逻辑向量来过滤数据框。例如,我们可以获取所有完整行的id:
product_tests[complete.cases(product_tests), "id"]
## [1] "T02" "M01" "M02" "M04"
或者,我们可以获取所有不完整行的id:
product_tests[!complete.cases(product_tests), "id"]
## [1] "T01" "M03"
注意,product_info、product_stats和product_tests都共享一个id列;我们可以将它们全部合并在一起。不幸的是,没有内置的函数可以合并任意数量的数据框。我们一次只能合并两个现有的数据框,或者我们必须递归地合并它们:
product_full <- merge(product_table, product_tests, by = "id")
product_full
## id name type class released material size weight
## 1 M01 JeepX model vehicle yes Plastics 50 NA
## 2 M02 AircraftX model vehicle yes Plastics 85 3.0
## 3 M03 Runner model people yes Wood 15 NA
## 4 M04 Dancer model people no Wood 16 0.6
## 5 T01 SupCar toy vehicle yes Metal 120 10.0
## 6 T02 SupPlane toy vehicle no Metal 350 45.0
## quality durability waterproof
## 1 6 4 yes
## 2 6 5 yes
## 3 5 NA yes
## 4 6 6 yes
## 5 NA 10 no
## 6 10 9 no
在完全合并的表中,我们可以使用tapply,另一个专门用于处理表格数据的 apply 族函数,通过给定的列使用某些方法来总结数据。例如,我们可以计算每个type的quality的平均值:
mean_quality1 <- tapply(product_full$quality,
list(product_full$type),
mean, na.rm = TRUE)
mean_quality1
## model toy
## 5.75 10.00
注意,我们不仅提供了mean,还指定了na.rm = TRUE来忽略quality中的缺失值。结果看起来像一个数值向量。我们将使用str(),让我们看看它的结构:
str(mean_quality1)
## num [1:2(1d)] 5.75 10
## - attr(*, "dimnames")=List of 1
## ..$ : chr [1:2] "model" "toy"
实际上,它是一个一维数组:
is.array(mean_quality1)
## [1] TRUE
tapply函数产生一个数组而不是简单的数值向量,因为它可以很容易地推广到处理多个分组。例如,我们可以计算每个type和class对的quality的平均值:
mean_quality2 <- tapply(product_full$quality,
list(product_full$type, product_full$class),
mean, na.rm = TRUE)
mean_quality2
## people vehicle
## model 5.5 6
## toy NA 10
现在,我们有一个二维数组,其值可以通过两个参数提取:
mean_quality2["model", "vehicle"]
## [1] 6
此外,我们可以提供更多用于分组的列。在下面的代码中,我们将使用with()函数来减少对product_full的冗余输入:
mean_quality3 <- with(product_full,
tapply(quality, list(type, material, released),
mean, na.rm = TRUE))
mean_quality3
## , , no
##
## Metal Plastics Wood
## model NA NA 6
## toy 10 NA NA
##
## , , yes
##
## Metal Plastics Wood
## model NA 6 5
## toy NaN NA NA
现在,生成了一个三维数组。尽管指定了na.rm = TRUE,但许多单元格仍然有缺失值。这是因为分组中没有值:
str(mean_quality3)
## num [1:2, 1:3, 1:2] NA 10 NA NA 6 NA NA NaN 6 NA ...
## - attr(*, "dimnames")=List of 3
## ..$ : chr [1:2] "model" "toy"
## ..$ : chr [1:3] "Metal" "Plastics" "Wood"
## ..$ : chr [1:2] "no" "yes"
我们可以通过提供三个参数来访问单元格值:
mean_quality3["model", "Wood", "yes"]
## [1] 5
总结来说,tapply使用n个指定的变量对输入数据框进行分组,并产生一个具有n维度的数组。这种方法总结数据可能很难处理,特别是当有更多用于分组的列时。这主要是因为数组通常是高维的,难以表示,并且不灵活,不适合进一步的操作。在本章的后面部分,你将学习几种不同的方法,这些方法可以使分组总结变得更容易。
使用 reshape2 重塑数据框
之前,你学习了如何过滤、排序、合并和总结数据框。这些操作仅在行和列上单独工作。然而,有时我们需要做更复杂的事情。
例如,以下代码加载了两个产品在不同日期上关于质量和耐久性的测试数据集:
toy_tests <- read_csv("data/product-toy-tests.csv")
toy_tests
## id date sample quality durability
## 1 T01 20160201 100 9 9
## 2 T01 20160302 150 10 9
## 3 T01 20160405 180 9 10
## 4 T01 20160502 140 9 9
## 5 T02 20160201 70 7 9
## 6 T02 20160303 75 8 8
## 7 T02 20160403 90 9 8
## 8 T02 20160502 85 10 9
前面的数据框的每一行代表了一个特定产品(id)在特定date上的测试记录。如果我们需要同时比较两个产品的质量或耐用性,处理这种数据格式可能会很困难。相反,我们需要数据像以下代码那样转换,以便更容易地比较两个产品的值:
date T01 T02
20160201 9 9
20160301 10 9
reshape2包就是为了这种转换而设计的。如果您还没有安装它,请运行以下命令:
install.packages("reshape2")
一旦安装了包,我们就可以使用reshape2::dcast转换数据,这样我们就可以轻松地比较同一date上不同产品的quality。更具体地说,它将toy_tests重新排列,使得date列是共享的,id中的值作为列展开,每个date和id的值是quality数据:
library(reshape2)
toy_quality <- dcast(toy_tests, date ~ id, value.var = "quality")
toy_quality
## date T01 T02
## 1 20160201 9 7
## 2 20160302 10 NA
## 3 20160303 NA 8
## 4 20160403 NA 9
## 5 20160405 9 NA
## 6 20160502 9 10
如您所见,toy_tests立即被转换。两个产品的quality值与date对齐。尽管每个月两个产品都会进行测试,但日期可能并不完全匹配。如果一个产品在某一天有值,而另一个产品在同一天没有相应的值,这会导致缺失值。
填充缺失值的一种方法是用称为最后观测值前推(LOCF)的方法,这意味着如果一个非缺失值后面跟着一个缺失值,那么非缺失值将被前推以替换缺失值,直到所有后续的缺失值都被替换。zoo包提供了一个 LOCF 的实现。如果您还没有安装此包,请运行以下命令进行安装:
install.packages("zoo")
为了演示它是如何工作的,我们将使用zoo::na.locf()在具有缺失值的非常简单的数值向量上执行此技术:
zoo::na.locf(c(1, 2, NA, NA, 3, 1, NA, 2, NA))
## [1] 1 2 2 2 3 1 1 2 2
显然,所有缺失值都被替换为前面的非缺失值。为了用T01和T02列的toy_quality做同样的事情,我们可以将处理后的向量子赋值给这些列:
toy_quality$T01 <- zoo::na.locf(toy_quality$T01)
toy_quality$T02 <- zoo::na.locf(toy_quality$T02)
然而,如果toy_tests包含成千上万的产品,编写数千行代码来做类似的事情是荒谬的。更好的做法是使用专用的子赋值,如下所示:
toy_quality[-1] <- lapply(toy_quality[-1], zoo::na.locf)
toy_quality
## date T01 T02
## 1 20160201 9 7
## 2 20160302 10 7
## 3 20160303 10 8
## 4 20160403 10 9
## 5 20160405 9 9
## 6 20160502 9 10
我们将使用lapply()对toy_quality的所有列(除了date列)执行 LOCF,并将结果列表分配给没有date列的toy_quality子集。这是因为数据框的子赋值接受一个列表,并且仍然保留数据框的类。
然而,尽管数据中不包含任何缺失值,但每一行的含义发生了变化。最初,产品T01在20160303这一天没有进行测试。这个值应该被解释为在该日或之前最后一次质量测试的值。另一个缺点是,在原始数据中,两种产品每个月都会进行测试,但重新排列后的数据框并没有与date的常规频率对齐。
修复这些缺点的一种方法是用年月数据而不是确切的日期。在下面的代码中,我们将创建一个新的列ym,即toy_tests的前 6 个字符。例如,substr(20160101, 1, 6)将得到201601:
toy_tests$ym <- substr(toy_tests$date, 1, 6)
toy_tests
## id date sample quality durability ym
## 1 T01 20160201 100 9 9 201602
## 2 T01 20160302 150 10 9 201603
## 3 T01 20160405 180 9 10 201604
## 4 T01 20160502 140 9 9 201605
## 5 T02 20160201 70 7 9 201602
## 6 T02 20160303 75 8 8 201603
## 7 T02 20160403 90 9 8 201604
## 8 T02 20160502 85 10 9 201605
这次,我们将使用ym列进行对齐而不是date:
toy_quality <- dcast(toy_tests, ym ~ id,
value.var = "quality")
toy_quality
## ym T01 T02
## 1 201602 9 7
## 2 201603 10 8
## 3 201604 9 9
## 4 201605 9 10
现在,缺失值已经消失,每个月两个产品的质量分数自然呈现。
有时,我们需要将多个列组合成一个表示度量,另一个存储值的列。例如,以下代码使用 reshape2::melt 将原始数据的两个度量(quality 和 durability)组合到一个名为 measure 的列和一个测量值列中:
toy_tests2 <- melt(toy_tests, id.vars = c("id", "ym"),
measure.vars = c("quality", "durability"),
variable.name = "measure")
toy_tests2
## id ym measure value
## 1 T01 201602 quality 9
## 2 T01 201603 quality 10
## 3 T01 201604 quality 9
## 4 T01 201605 quality 9
## 5 T02 201602 quality 7
## 6 T02 201603 quality 8
## 7 T02 201604 quality 9
## 8 T02 201605 quality 10
## 9 T01 201602 durability 9
## 10 T01 201603 durability 9
## 11 T01 201604 durability 10
## 12 T01 201605 durability 9
## 13 T02 201602 durability 9
## 14 T02 201603 durability 8
## 15 T02 201604 durability 8
## 16 T02 201605 durability 9
变量名现在包含在数据中,可以直接被一些包使用。例如,我们可以使用 ggplot2 来绘制这种格式的数据。以下代码是一个具有不同因素组合的面板网格散点图的示例:
library(ggplot2)
ggplot(toy_tests2, aes(x = ym, y = value)) +
geom_point() +
facet_grid(id ~ measure)
然后,我们可以看到一个按产品 id 和 measure 分组的散点图,其中 ym 作为 x 值,value 作为 y 值:

由于分组因素(measure)包含在数据中而不是列中,因此绘图可以轻松操作,这从 ggplot2 包的角度来看更容易表示:
ggplot(toy_tests2, aes(x = ym, y = value, color = id)) +
geom_point() +
facet_grid(. ~ measure)
这次,我们将以不同的颜色展示两个产品的点:

通过 sqldf 包使用 SQL 查询数据框
在上一章中,你学习了如何编写 SQL 语句来查询诸如 SQLite 和 MySQL 这样的关系型数据库中的数据。有没有一种方法可以直接使用 SQL 来查询 R 中的数据框,就像这些数据框是关系型数据库中的表一样?sqldf 包表示这是可能的。
这个包利用了 SQLite 的优势,得益于其轻量级结构和易于嵌入 R 会话的特性。如果你还没有这个包,请运行以下命令来安装它:
install.packages("sqldf")
首先,让我们按照以下代码所示附加该包:
library(sqldf)
## Loading required package: gsubfn
## Loading required package: proto
## Loading required package: RSQLite
## Loading required package: DBI
注意,当我们附加 sqldf 时,会自动加载许多其他包。sqldf 包依赖于这些包,因为它基本上是在 R 和 SQLite 之间传输数据和转换数据类型。
然后,我们将重新加载我们在前几节中使用的产品表:
product_info <- read_csv("data/product-info.csv")
product_stats <- read_csv("data/product-stats.csv")
product_tests <- read_csv("data/product-tests.csv")
toy_tests <- read_csv("data/product-toy-tests.csv")
这个包的神奇之处在于我们可以直接使用 SQL 查询我们工作环境中的数据框。例如,我们可以选择 product_info 的所有记录:
sqldf("select * from product_info")
## Loading required package: tcltk
## id name type class released
## 1 T01 SupCar toy vehicle yes
## 2 T02 SupPlane toy vehicle no
## 3 M01 JeepX model vehicle yes
## 4 M02 AircraftX model vehicle yes
## 5 M03 Runner model people yes
## 6 M04 Dancer model people no
sqldf 包支持 SQLite 支持的简单选择查询。例如,我们可以选择一组特定的列:
sqldf("select id, name, class from product_info")
## id name class
## 1 T01 SupCar vehicle
## 2 T02 SupPlane vehicle
## 3 M01 JeepX vehicle
## 4 M02 AircraftX vehicle
## 5 M03 Runner people
## 6 M04 Dancer people
我们可以根据特定条件过滤记录:
sqldf("select id, name from product_info where released = 'yes'")
## id name
## 1 T01 SupCar
## 2 M01 JeepX
## 3 M02 AircraftX
## 4 M03 Runner
我们可以计算一个新的列并给它命名:
sqldf("select id, material, size / weight as density from product_stats")
## id material density
## 1 T01 Metal 12.000000
## 2 T02 Metal 7.777778
## 3 M01 Plastics NA
## 4 M02 Plastics 28.333333
## 5 M03 Wood NA
## 6 M04 Wood 26.666667
我们可以按给定顺序对记录进行排序:
sqldf("select * from product_stats order by size desc")
## id material size weight
## 1 T02 Metal 350 45.0
## 2 T01 Metal 120 10.0
## 3 M02 Plastics 85 3.0
## 4 M01 Plastics 50 NA
## 5 M04 Wood 16 0.6
## 6 M03 Wood 15 NA
该包还支持查询多个数据框,如 join。在以下代码中,我们将通过 id 合并 product_info 和 product_stats,就像我们之前使用 merge() 所做的那样:
sqldf("select * from product_info join product_stats using (id)")
## id name type class released material size weight
## 1 T01 SupCar toy vehicle yes Metal 120 10.0
## 2 T02 SupPlane toy vehicle no Metal 350 45.0
## 3 M01 JeepX model vehicle yes Plastics 50 NA
## 4 M02 AircraftX model vehicle yes Plastics 85 3.0
## 5 M03 Runner model people yes Wood 15 NA
## 6 M04 Dancer model people no Wood 16 0.6
此外,它还支持嵌套查询。在以下代码中,我们将选择所有由木材制成的 product_info 中的记录:
sqldf("select * from product_info where id in
(select id from product_stats where material = 'Wood')")
## id name type class released
## 1 M03 Runner model people yes
## 2 M04 Dancer model people no
或者,我们可以使用具有相同 where 条件的 join 来达到相同的目的。对于许多关系型数据库,当数据量大时,join 通常比 in 更快:
sqldf("select * from product_info join product_stats using (id)
where material = 'Wood'")
## id name type class released material size weight
## 1 M03 Runner model people yes Wood 15 NA
## 2 M04 Dancer model people no Wood 16 0.6
除了join之外,我们还可以轻松地按组汇总数据。例如,我们将product_tests按waterproof分组为两组:yes和no。对于每个组,我们分别计算quality和durability的平均值:
sqldf("select waterproof, avg(quality), avg(durability) from product_tests
group by waterproof")
## waterproof avg(quality) avg(durability)
## 1 no 10.00 9.5
## 2 yes 5.75 5.0
对于toy_tests数据,按每个产品汇总数据很容易。以下是一个示例,展示如何计算每个产品随时间变化的quality和durability值的平均值:
sqldf("select id, avg(quality), avg(durability) from toy_tests
group by id")
## id avg(quality) avg(durability)
## 1 T01 9.25 9.25
## 2 T02 8.50 8.50
为了使结果更加信息丰富,我们可以将product_info与分组汇总表连接起来,这样就可以一起展示产品信息和平均度量:
sqldf("select * from product_info join
(select id, avg(quality), avg(durability) from toy_tests
group by id) using (id)")
## id name type class released avg(quality)
## 1 T01 SupCar toy vehicle yes 9.25
## 2 T02 SupPlane toy vehicle no 8.50
## avg(durability)
## 1 9.25
## 2 8.50
使用sqldf和 SQL 查询数据框看起来非常方便,但局限性也很明显。
首先,由于sqldf默认基于 SQLite,该包的限制也是 SQLite 数据库的限制,即内置的分组聚合函数有限。官方网页(sqlite.org/lang_aggfunc.html)提供了一系列函数:avg()、count()、group_concat()、max()、min()、sum()和total()。如果我们需要更多,例如quantile(),那就不会很容易。在 R 中,我们可以使用更先进的算法来聚合列。
第二,由于我们需要提供一个选择语句的字符串来查询数据,当其中一部分由 R 变量确定时,动态生成 SQL 并不方便。因此,我们需要使用sprintf()来允许 R 变量的值出现在 SQL 语句中。
第三,sqldf的限制也是 SQL 的限制。使用更复杂的算法计算新列比较困难。例如,如果我们需要根据现有的数值列计算排名列,那么实现起来可能不会很容易。然而,在 R 中,我们只需要order()。另一件事是,实现更复杂的过滤操作,如基于排名的数据过滤,可能很困难或很冗长。例如,如何根据material分组按size降序选择前一个或两个产品?这样的查询需要更多的思考和技巧。
然而,如果我们使用plyr包,这样的任务就变得轻而易举。如果您已经安装了该包,请运行以下代码:
install.packages("plyr")
为了展示其简单性,我们将使用plyr::ddply来完成这个任务。我们将material作为数据拆分器,也就是说,product_stats根据material的每个值被分成几个部分。我们还提供了一个函数来将输入数据框(每个部分)转换为新的数据框。然后,ddply函数将这些数据框组合在一起:
plyr::ddply(product_stats, "material",
function(x) {
head(x[order(x$size, decreasing = TRUE),], 1L)
})
## id material size weight
## 1 T02 Metal 350 45.0
## 2 M02 Plastics 85 3.0
## 3 M04 Wood 16 0.6
我们提供的匿名函数使用三个不同的product_stats部分调用,每个部分具有不同的material,每个部分都有相同的material。
另一个例子是选择具有最多样本的前两个测试结果:
plyr::ddply(toy_tests, "id",
function(x) {
head(x[order(x$sample, decreasing = TRUE), ], 2)
})
## id date sample quality durability
## 1 T01 20160405 180 9 10
## 2 T01 20160302 150 10 9
## 3 T02 20160403 90 9 8
## 4 T02 20160502 85 10 9
我们提供的匿名函数使用 toy_tests 的两部分调用:一部分是 id 为 T01 的数据框,另一部分是 T02。对于每一部分,我们按 sample 降序对子数据框进行排序,并取前两条记录。任务很容易完成。
此外,ddply、plyr 提供了多种输入-输出数据类型对的功能。要了解更多信息,请访问 had.co.nz/plyr/ 和 github.com/hadley/plyr。
使用 data.table 操作数据
在第一部分,我们回顾了一些用于操作数据框的内置函数。然后,我们介绍了 sqldf,它使简单的数据查询和汇总变得更容易。然而,这两种方法都有其局限性。使用内置函数可能会很冗长且速度较慢,而且由于 SQL 不如 R 函数的全谱系强大,因此很难汇总数据。
data.table 包提供了一种强大的增强版 data.frame。它速度极快,能够处理适合内存的大型数据。它通过使用 [] 创造了一种自然的数据操作语法。如果您还没有安装此包,请运行以下命令从 CRAN 安装:
install.packages("data.table")
一旦成功安装了包,我们将加载包并查看它提供了什么:
library(data.table)
##
## Attaching package: 'data.table'
## The following objects are masked from 'package:reshape2':
##
## dcast, melt
注意,我们之前加载了 reshape2 包,其中定义了 dcast 和 melt。data.table 包也提供了 dcast 和 melt 的增强版本,具有更强大的功能、更好的性能和更高的内存效率。我们将在本节稍后查看它们。
创建 data.table 与创建 data.frame 非常相似:
dt <- data.table(x = 1:3, y = rnorm(3), z = letters[1:3])
dt
## x y z
## 1: 1 -0.50219235 a
## 2: 2 0.13153117 b
## 3: 3 -0.07891709 c
我们可以使用 str() 来查看其结构:
str(dt)
## Classes 'data.table' and 'data.frame': 3 obs. of 3 variables:
## $ x: int 1 2 3
## $ y: num -0.5022 0.1315 -0.0789
## $ z: chr "a" "b" "c"
## - attr(*, ".internal.selfref")=<externalptr>
很明显,dt 是 data.table 和 data.frame 类,这意味着 data.table 继承自 data.frame。换句话说,它继承了 data.frame 的一些行为,但作为增强也覆盖了其他行为。
首先,我们仍然加载产品数据。然而,这次我们将使用 data.table 包提供的 fread()。fread() 函数非常快,内存效率高,并直接返回 data.table:
product_info <- fread("data/product-info.csv")
product_stats <- fread("data/product-stats.csv")
product_tests <- fread("data/product-tests.csv")
toy_tests <- fread("data/product-toy-tests.csv")
如果我们查看 product_info,它的外观与数据框的略有不同:
product_info
## id name type class released
## 1: T01 SupCar toy vehicle yes
## 2: T02 SupPlane toy vehicle no
## 3: M01 JeepX model vehicle yes
## 4: M02 AircraftX model vehicle yes
## 5: M03 Runner model people yes
## 6: M04 Dancer model people no
再次,我们将查看其结构:
str(product_info)
## Classes 'data.table' and 'data.frame': 6 obs. of 5 variables:
## $ id : chr "T01" "T02" "M01" "M02" ...
## $ name : chr "SupCar" "SupPlane" "JeepX" "AircraftX" ...
## $ type : chr "toy" "toy" "model" "model" ...
## $ class : chr "vehicle" "vehicle" "vehicle" "vehicle" ...
## $ released: chr "yes" "no" "yes" "yes" ...
## - attr(*, ".internal.selfref") =< externalptr>
与 data.frame 相比,如果我们只为 data.table 的子集提供单个参数,这意味着选择行而不是列:
product_info[1]
## id name type class released
## 1: T01 SupCar toy vehicle yes
product_info[1:3]
## id name type class released
## 1: T01 SupCar toy vehicle yes
## 2: T02 SupPlane toy vehicle no
## 3: M01 JeepX model vehicle yes
如果我们在 [] 中提供的数字是负数,这意味着排除记录,这与子集向量的操作完全一致:
product_info[-1]
## id name type class released
## 1: T02 SupPlane toy vehicle no
## 2: M01 JeepX model vehicle yes
## 3: M02 AircraftX model vehicle yes
## 4: M03 Runner model people yes
## 5: M04 Dancer model people no
此外,data.table 还提供了一些表示 data.table 重要组件的符号。其中最有用的符号之一是 .N,它表示行数。如果我们想选择最后一行,我们不再需要 nrow(product_info):
product_info[.N]
## id name type class released
## 1: M04 Dancer model people no
我们可以轻松地选择第一行和最后一行:
product_info[c(1, .N)]
## id name type class released
## 1: T01 SupCar toy vehicle yes
## 2: M04 Dancer model people no
data.table 子集的语法会自动评估数据上下文中的表达式,即我们可以直接使用列名作为符号,就像我们使用 subset、transform 和 with 一样。例如,我们可以直接使用 released 作为第一个参数来选择已发布产品的行:
product_info[released == "yes"]
## id name type class released
## 1: T01 SupCar toy vehicle yes
## 2: M01 JeepX model vehicle yes
## 3: M02 AircraftX model vehicle yes
## 4: M03 Runner model people yes
方括号中的第一个参数是一个行过滤器,而第二个参数在过滤数据的上下文中进行评估。例如,我们可以直接使用 id 来代表 product_info$id,因为 id 在 product_info 的上下文中进行评估:
product_info[released == "yes", id]
## [1] "T01" "M01" "M02" "M03"
选择数据框列的方法在这里不适用。如果我们把一个字符向量放在第二个参数中,那么我们会得到一个字符向量本身,因为字符串确实是一个字符串:
product_info[released == "yes", "id"]
## [1] "id"
要禁用此行为,我们可以指定 with = FALSE,这样第二个参数接受一个字符向量来选择列,并且无论指定多少列,它总是返回一个 data.table:
product_info[released == "yes", "id", with = FALSE]
## id
## 1: T01
## 2: M01
## 3: M02
## 4: M03
product_info[released == "yes", c("id", "name"), with = FALSE]
## id name
## 1: T01 SupCar
## 2: M01 JeepX
## 3: M02 AircraftX
## 4: M03 Runner
我们也可以将一些其他表达式作为第二个参数。例如,我们可以生成一个按 type 和 class 的组合生成的发布产品数量的表格:
product_info[released == "yes", table(type, class)]
## class
## type people vehicle
## model 1 2
## toy 0 1
然而,如果生成了一个列表,它将被转换为 data.table:
product_info[released == "yes", list(id, name)]
## id name
## 1: T01 SupCar
## 2: M01 JeepX
## 3: M02 AircraftX
## 4: M03 Runner
这样,我们可以轻松地创建一个新的 data.table 包,并用现有列替换:
product_info[, list(id, name, released = released == "yes")]
## id name released
## 1: T01 SupCar TRUE
## 2: T02 SupPlane FALSE
## 3: M01 JeepX TRUE
## 4: M02 AircraftX TRUE
## 5: M03 Runner TRUE
## 6: M04 Dancer FALSE
我们也可以轻松地基于现有列创建一个新的 data.table 包,并添加新列:
product_stats[, list(id, material, size, weight,
density = size / weight)]
## id material size weight density
## 1: T01 Metal 120 10.0 12.000000
## 2: T02 Metal 350 45.0 7.777778
## 3: M01 Plastics 50 NA NA
## 4: M02 Plastics 85 3.0 28.333333
## 5: M03 Wood 15 NA NA
## 6: M04 Wood 16 0.6 26.666667
为了简化,data.table 提供了 .() 来代表 list():
product_info[, .(id, name, type, class)]
## id name type class
## 1: T01 SupCar toy vehicle
## 2: T02 SupPlane toy vehicle
## 3: M01 JeepX model vehicle
## 4: M02 AircraftX model vehicle
## 5: M03 Runner model people
## 6: M04 Dancer model people
product_info[released == "yes", .(id, name)]
## id name
## 1: T01 SupCar
## 2: M01 JeepX
## 3: M02 AircraftX
## 4: M03 Runner
通过提供有序索引,我们可以轻松地按给定标准对记录进行排序:
product_stats[order(size, decreasing = TRUE)]
## id material size weight
## 1: T02 Metal 350 45.0
## 2: T01 Metal 120 10.0
## 3: M02 Plastics 85 3.0
## 4: M01 Plastics 50 NA
## 5: M04 Wood 16 0.6
## 6: M03 Wood 15 NA
以前,我们总是在子集化后创建一个新的 data.table 包。data.table 包还提供了 := 用于列的就地赋值。例如,product_stats 的原始数据如下所示:
product_stats
## id material size weight
## 1: T01 Metal 120 10.0
## 2: T02 Metal 350 45.0
## 3: M01 Plastics 50 NA
## 4: M02 Plastics 85 3.0
## 5: M03 Wood 15 NA
## 6: M04 Wood 16 0.6
我们将使用 := 在 product_stats 中直接创建一个新列:
product_stats[, density := size / weight]
这里没有显示任何内容,但原始的 data.table 包已被修改:
product_stats
## id material size weight density
## 1: T01 Metal 120 10.0 12.000000
## 2: T02 Metal 350 45.0 7.777778
## 3: M01 Plastics 50 NA NA
## 4: M02 Plastics 85 3.0 28.333333
## 5: M03 Wood 15 NA NA
## 6: M04 Wood 16 0.6 26.666667
我们可以使用 := 来替换现有列:
product_info[, released := released == "yes"]
product_info
## id name type class released
## 1: T01 SupCar toy vehicle TRUE
## 2: T02 SupPlane toy vehicle FALSE
## 3: M01 JeepX model vehicle TRUE
## 4: M02 AircraftX model vehicle TRUE
## 5: M03 Runner model people TRUE
## 6: M04 Dancer model people FALSE
data.table 包提供 := 主要是因为就地修改具有更高的性能,因为它避免了数据的不必要复制。
使用键访问行
data.table 的另一个独特特性是支持索引,即我们可以在 data.table 上创建键,因此通过键访问记录可以非常高效。例如,我们将使用 setkey() 将 id 设置为 product_info 的键:
setkey(product_info, id)
注意,该函数的行为与大多数 R 函数非常不同。它不会返回数据表的新副本,而是直接在原始输入上安装一个键。然而,数据框看起来没有变化:
product_info
## id name type class released
## 1: M01 JeepX model vehicle TRUE
## 2: M02 AircraftX model vehicle TRUE
## 3: M03 Runner model people TRUE
## 4: M04 Dancer model people FALSE
## 5: T01 SupCar toy vehicle TRUE
## 6: T02 SupPlane toy vehicle FALSE
此外,它的键也被创建:
key(product_info)
## [1] "id"
现在,我们可以使用键来访问 product_info 中的记录。例如,我们可以直接写入一个 id 的值来获取具有该 id 的记录:
product_info["M01"]
## id name type class released
## 1: M01 JeepX model vehicle TRUE
如果我们在没有键的 data.table 包上使用此操作,将发生错误并提示您设置键:
product_stats["M01"]
## Error in `[.data.table`(product_stats, "M01"): When i is a data.table (or character vector), x must be keyed (i.e. sorted, and, marked as sorted) so data.table knows which columns to join to and take advantage of x being sorted. Call setkey(x,...) first, see ?setkey.
我们也可以使用 setkeyv() 来设置键,但它只接受字符向量:
setkeyv(product_stats, "id")
如果我们有一个动态确定的向量作为键,这个函数将更容易使用。现在,我们可以使用键来访问 product_stats:
product_stats["M02"]
## id material size weight density
## 1: M02 Plastics 85 3 28.33333
如果两个表有相同的键,我们可以轻松地将它们连接起来:
product_info[product_stats]
## id name type class released material size
## 1: M01 JeepX model vehicle TRUE Plastics 50
## 2: M02 AircraftX model vehicle TRUE Plastics 85
## 3: M03 Runner model people TRUE Wood 15
## 4: M04 Dancer model people FALSE Wood 16
## 5: T01 SupCar toy vehicle TRUE Metal 120
## 6: T02 SupPlane toy vehicle FALSE Metal 350
## weight density
## 1: NA NA
## 2: 3.0 28.333333
## 3: NA NA
## 4: 0.6 26.666667
## 5: 10.0 12.000000
## 6: 45.0 7.777778
data.table 包的键可以包含多个元素。例如,为了定位 toy_tests 的记录,我们需要指定 id 和 date。在下面的代码中,我们将为 toy_tests 设置两个列的键:
setkey(toy_tests, id, date)
现在,我们可以通过提供键中的两个元素来获取一行:
toy_tests[.("T01", 20160201)]
## id date sample quality durability
## 1: T01 20160201 100 9 9
如果我们只提供第一个元素,我们将得到一个数据子集,其中包含所有与第一个元素匹配的记录:
toy_tests["T01"]
## id date sample quality durability
## 1: T01 20160201 100 9 9
## 2: T01 20160302 150 10 9
## 3: T01 20160405 180 9 10
## 4: T01 20160502 140 9 9
然而,如果我们只提供第二个元素,我们除了错误之外什么也得不到。这是因为背后的算法要求键是有序的:
toy_tests[.(20160201)]
## Error in bmerge(i, x, leftcols, rightcols, io, xo, roll, rollends, nomatch, : x.'id' is a character column being joined to i.'V1' which is type 'double'. Character columns must join to factor or character columns.
此外,如果我们提供一个错误的键顺序,我们无法获取任何数据:
toy_tests[.(20160201, "T01")]
## Error in bmerge(i, x, leftcols, rightcols, io, xo, roll, rollends, nomatch, : x.'id' is a character column being joined to i.'V1' which is type 'double'. Character columns must join to factor or character columns.
按组汇总数据
子集化 data.table 的另一个重要参数是 by,它用于将数据分割成多个部分,并对每个部分评估第二个参数。在本节中,我们将演示 by 语法如何使按组汇总数据变得更加容易。例如,by 的最简单用法是计算每个组中的记录数。在下面的代码中,我们将计算已发布和未发布产品的数量:
product_info[, .N, by = released]
## released N
## 1: TRUE 4
## 2: FALSE 2
组可以由多个变量定义。例如,type 和 class 的元组可以是一个组,并且对于每个组,我们将计算记录数:
product_info[, .N, by = .(type, class)]
## type class N
## 1: model vehicle 2
## 2: model people 2
## 3: toy vehicle 2
我们也可以为每个组执行统计计算。在这里,我们将计算防水产品和非防水产品的质量平均值:
product_tests[, mean(quality, na.rm = TRUE),
by = .(waterproof)]
## waterproof V1
## 1: no 10.00
## 2: yes 5.75
注意,平均值存储在 V1 中,因为我们没有为该列提供名称,因此包使用了其默认列名。为了避免这种情况,我们将使用形式为 .(y = f(x)) 的表达式:
product_tests[, .(mean_quality = mean(quality, na.rm = TRUE)),
by = .(waterproof)]
## waterproof mean_quality
## 1: no 10.00
## 2: yes 5.75
我们可以依次链式使用多个 []。在以下示例中,我们将首先通过共享键 id 将 product_info 和 product_tests 连接起来,然后计算已发布产品中 type 和 class 每个组的 quality 和 durability 的平均值:
product_info[product_tests][released == TRUE,
.(mean_quality = mean(quality, na.rm = TRUE),
mean_durability = mean(durability, na.rm = TRUE)),
by = .(type, class)]
## type class mean_quality mean_durability
## 1: toy vehicle NaN 10.0
## 2: model vehicle 6 4.5
## 3: model people 5 NaN
注意,by 列的值在结果 data.table 中将是唯一的。我们可以使用 keyby 代替 by 来确保它自动用作结果 data.table 的键:
type_class_tests <- product_info[product_tests][released == TRUE,
.(mean_quality = mean(quality, na.rm = TRUE),
mean_durability = mean(durability, na.rm = TRUE)),
keyby = .(type, class)]
type_class_tests
## type class mean_quality mean_durability
## 1: model people 5 NaN
## 2: model vehicle 6 4.5
## 3: toy vehicle NaN 10.0
key(type_class_tests)
## [1] "type" "class"
然后,我们可以直接使用键值的元组来访问记录:
type_class_tests[.("model", "vehicle"), mean_quality]
## [1] 6
您可以清楚地看到,当我们试图在表中查找特定记录时,使用键比使用逻辑比较要方便得多。然而,它的真正优势尚未体现,因为数据量不够大。对于大量数据,使用键搜索记录可以比迭代逻辑比较快得多,因为键搜索利用了二分搜索,而迭代则浪费了大量时间进行不必要的计算。
这里有一个例子来做一个对比。首先,我们将创建一个包含 1000 万行、索引列id和两个填充随机数的数值列的数据:
n <- 10000000
test1 <- data.frame(id = 1:n, x = rnorm(n), y = rnorm(n))
现在,我们想找到id为8765432的行。让我们看看这需要多长时间:
system.time(row <- test1[test1$id == 876543, ])
## user system elapsed
## 0.156 0.036 0.192
row
## id x y
## 876543 876543 0.02300419 1.291588
这似乎没什么大不了的,但假设你需要频繁地这样做,比如说每秒几百次,那么你的机器就无法及时返回结果。
然后,我们将使用data.table来完成这个任务。首先,我们将调用setDT()将data.frame转换为data.table。这个函数会对对象进行一些魔法般的转换,就地转换,不创建副本。当我们使用setDT()函数时,我们还提供了一个键id,以便结果data.table以id作为其键列:
setDT(test1, key = "id")
class(test1)
## [1] "data.table" "data.frame"
现在,test1已转换为data.table。然后,我们将搜索相同的元素:
system.time(row <- test1[.(8765432)])
## user system elapsed
## 0.000 0.000 0.001
row
## id x y
## 1: 8765432 0.2532357 -2.121696
结果相同,但data.table所需的时间比data.frame短得多。
重塑 data.table
之前,你学习了如何使用reshape2包重塑数据框。data.table包为data.table对象提供了更快、更强大的dcast和melt实现。
例如,我们将通过将每个产品的质量分数与年月元组对齐来重塑toy_tests。
toy_tests[, ym := substr(date, 1, 6)]
toy_quality <- dcast(toy_tests, ym ~ id, value.var = "quality")
toy_quality
## ym T01 T02
## 1: 201602 9 7
## 2: 201603 10 8
## 3: 201604 9 9
## 4: 201605 9 10
首先,我们在toy_tests中直接使用:=创建了一个新的列ym,并使用dcast以与之前reshape2的示例相同的方式进行转换。结果看起来与reshape2::dcast对data.frame的输出相同。
虽然reshape2::dcast不支持多值value.var,但data.table::dcast可以处理多个值变量,如下所示:
toy_tests2 <- dcast(toy_tests, ym ~ id, value.var = c("quality", "durability"))
toy_tests2
## ym quality_T01 quality_T02 durability_T01
## 1: 201602 9 7 9
## 2: 201603 10 8 9
## 3: 201604 9 9 10
## 4: 201605 9 10 9
## durability_T02
## 1: 9
## 2: 8
## 3: 8
## 4: 9
除了第一个之外,列名不再是id的值,而是由下划线符号连接的id值变量。此外,输出data.table的键自动设置为重塑公式ym ~ id左侧出现的变量:
key(toy_tests2)
## [1] "ym"
键意味着我们可以通过提供一个ym的值直接访问记录。然而,以下代码最终导致错误:
toy_tests2[.(201602)]
## Error in bmerge(i, x, leftcols, rightcols, io, xo, roll, rollends, nomatch, : x.'ym' is a character column being joined to i.'V1' which is type 'double'. Character columns must join to factor or character columns.
数据类型存在问题。我们可以运行以下代码来查看每列的数据类型:
sapply(toy_tests2, class)
## ym quality_T01 quality_T02 durability_T01
## "character" "integer" "integer" "integer"
## durability_T02
## "integer"
问题出在ym的类上。它是一个字符向量,但我们提供了一个数值型键。因此,由于数据类型不匹配,搜索失败。如果我们提供一个字符串,我们就可以得到相应的记录:
toy_tests2["201602"]
## ym quality_T01 quality_T02 durability_T01
## 1: 201602 9 7 9
## durability_T02
## 1: 9
但ym最初是如何成为字符向量的?回想一下ym := substr(date, 1, 6),其中date是一个整数向量,但substr()会将date强制转换为字符向量,然后取出前六个字符。因此,结果是字符向量是自然的。这简单地如下所示:
class(20160101)
## [1] "numeric"
class(substr(20160101, 1, 6))
## [1] "character"
这里的问题是,我们需要注意关键列的数据类型。
使用就地设置函数
如果我们使用 data.frame,更改名称或列顺序将导致数据结构副本。在最近的 R 版本中,当我们重命名列时,复制的次数较少,但仍然很难在不创建新副本的情况下重新排序数据框的列。当数据量较小时,这通常不会成为问题,但如果数据量非常大,它施加的性能和内存压力可能真的成为一个问题。
data.table 是 data.frame 的增强版本,提供了一组具有引用语义的 set 函数,即它们就地修改 data.table 并避免不必要的复制,从而展现出惊人的性能。
以 product_stats 为例。我们可以调用 setDF() 在不创建副本的情况下就地将 data.table 转换为 data.frame:
product_stats
## id material size weight density
## 1: M01 Plastics 50 NA NA
## 2: M02 Plastics 85 3.0 28.333333
## 3: M03 Wood 15 NA NA
## 4: M04 Wood 16 0.6 26.666667
## 5: T01 Metal 120 10.0 12.000000
## 6: T02 Metal 350 45.0 7.777778
setDF(product_stats)
class(product_stats)
## [1] "data.frame"
我们可以通过调用 setDT() 将任何 data.frame 转换为 data.table,并如果指定的话设置一个键:
setDT(product_stats, key = "id")
class(product_stats)
## [1] "data.table" "data.frame"
我们可以通过调用 setnames 来更改指定列的名称为其新名称:
setnames(product_stats, "size", "volume")
product_stats
## id material volume weight density
## 1: M01 Plastics 50 NA NA
## 2: M02 Plastics 85 3.0 28.333333
## 3: M03 Wood 15 NA NA
## 4: M04 Wood 16 0.6 26.666667
## 5: T01 Metal 120 10.0 12.000000
## 6: T02 Metal 350 45.0 7.777778
如果我们添加一个新列,该列应作为最后一列出现。例如,我们将使用代表 1:.N 的 .I 为所有行添加一个索引列:
product_stats[, i := .I]
product_stats
## id material volume weight density i
## 1: M01 Plastics 50 NA NA 1
## 2: M02 Plastics 85 3.0 28.333333 2
## 3: M03 Wood 15 NA NA 3
## 4: M04 Wood 16 0.6 26.666667 4
## 5: T01 Metal 120 10.0 12.000000 5
## 6: T02 Metal 350 45.0 7.777778 6
按照惯例,索引列在大多数情况下应作为第一列出现。我们可以向 setcolorder() 提供新的列名顺序,以便直接重新排序列而不创建副本:
setcolorder(product_stats,
c("i", "id", "material", "weight", "volume", "density"))
product_stats
## i id material weight volume density
## 1: 1 M01 Plastics NA 50 NA
## 2: 2 M02 Plastics 3.0 85 28.333333
## 3: 3 M03 Wood NA 15 NA
## 4: 4 M04 Wood 0.6 16 26.666667
## 5: 5 T01 Metal 10.0 120 12.000000
## 6: 6 T02 Metal 45.0 350 7.777778
理解 data.table 的动态作用域
data.table 最常用的语法是 data[i, j, by],其中 i、j 和 by 都使用动态作用域进行评估。换句话说,我们不仅可以直接使用列,还可以使用预定义的符号,如 .N、.I 和 .SD 来引用数据的重要组件,以及可以在调用环境中访问的符号和函数。
在演示这一点之前,我们将创建一个新的名为 market_data 的 data.table,其中包含连续的 date 列:
market_data <- data.table(date = as.Date("2015-05-01") + 0:299)
head(market_data)
## date
## 1: 2015-05-01
## 2: 2015-05-02
## 3: 2015-05-03
## 4: 2015-05-04
## 5: 2015-05-05
## 6: 2015-05-06
然后,我们将通过调用 := 作为函数来向 market_data 添加两个新列:
set.seed(123)
market_data[, `:=`(
price = round(30 * cumprod(1 + rnorm(300, 0.001, 0.05)), 2),
volume = rbinom(300, 5000, 0.8)
)]
注意,price 是一个简单的随机游走,而 volume 是从二项分布中随机抽取的:
head(market_data)
## date price volume
## 1: 2015-05-01 29.19 4021
## 2: 2015-05-02 28.88 4000
## 3: 2015-05-03 31.16 4033
## 4: 2015-05-04 31.30 4036
## 5: 2015-05-05 31.54 3995
## 6: 2015-05-06 34.27 3955
然后,我们将绘制数据:
plot(price ~ date, data = market_data,
type = "l",
main = "Market data")
生成的图表如下所示:

一旦数据准备就绪,我们就可以汇总数据,看看动态作用域如何被用来使事情变得更简单。
首先,我们将查看 date 列的范围:
market_data[, range(date)]
## [1] "2015-05-01" "2016-02-24"
通过分组聚合,数据可以轻松地减少到每月的 开盘价-最高价-最低价-收盘价(OHLC)数据:
monthly <- market_data[,
.(open = price[[1]], high = max(price),
low = min(price), close = price[[.N]]),
keyby = .(year = year(date), month = month(date))]
head(monthly)
## year month open high low close
## 1: 2015 5 29.19 37.71 26.15 28.44
## 2: 2015 6 28.05 37.63 28.05 37.21
## 3: 2015 7 36.32 40.99 32.13 40.99
## 4: 2015 8 41.52 50.00 30.90 30.90
## 5: 2015 9 30.54 34.46 22.89 27.02
## 6: 2015 10 25.68 33.18 24.65 29.32
在 j 表达式中,我们可以通过按 year 和 month 分组每个 data.table 来生成一个 OHLC 记录。如果 j 的输出是 list、data.frame 或 data.table,则输出将堆叠在一起,最终形成一个 data.table。
实际上,j 表达式可以是任何内容,甚至当指定了 by 时也是如此。更具体地说,j 在每个 data.table 的上下文中被评估,作为原始数据的一个子集,该子集由 by 表达式的值分割。例如,以下代码不是按组聚合数据,而是为每年的价格绘制图表:
oldpar <- par(mfrow = c(1, 2))
market_data[, {
plot(price ~ date, type = "l",
main = sprintf("Market data (%d)", year))
}, by = .(year = year(date))]
par(oldpar)
生成的图表如下所示:

注意,我们没有指定 plot 的 data 参数,因为它在按 year 分组的 market_data 上下文中进行评估,其中 price 和 date 已经定义。
此外,j 表达式可以是模型拟合代码。以下是一个批量拟合线性模型的示例。首先,我们将从 ggplot2 包中加载 diamonds 数据:
data("diamonds", package = "ggplot2")
setDT(diamonds)
head(diamonds)
## carat cut color clarity depth table price x
## 1: 0.23 Ideal E SI2 61.5 55 326 3.95
## 2: 0.21 Premium E SI1 59.8 61 326 3.89
## 3: 0.23 Good E VS1 56.9 65 327 4.05
## 4: 0.29 Premium I VS2 62.4 58 334 4.20
## 5: 0.31 Good J SI2 63.3 58 335 4.34
## 6: 0.24 Very Good J VVS2 62.8 57 336 3.94
## y z
## 1: 3.98 2.43
## 2: 3.84 2.31
## 3: 4.07 2.31
## 4: 4.23 2.63
## 5: 4.35 2.75
## 6: 3.96 2.48
数据包含 53940 条钻石记录,具有 10 个属性。在这里,我们将对每个 cut 组拟合线性回归模型,以查看 carat 和 depth 如何为每个组提供 log(price) 的信息。
在以下代码中,j 表达式涉及拟合线性模型并将其系数强制转换为列表。请注意,j 表达式为 keyby 表达式的每个值进行评估。由于返回了一个列表,因此每个组的估计线性系数将堆叠为一个 data.table,如下所示:
diamonds[, {
m <- lm(log(price) ~ carat + depth)
as.list(coef(m))
}, keyby = .(cut)]
## cut (Intercept) carat depth
## 1: Fair 7.730010 1.264588 -0.014982439
## 2: Good 7.077469 1.973600 -0.014601101
## 3: Very Good 6.293642 2.087957 -0.002890208
## 4: Premium 5.934310 1.852778 0.005939651
## 5: Ideal 8.495409 2.125605 -0.038080022
动态作用域还允许我们结合使用在 data.table 内部或外部预定义的符号。例如,我们可以定义一个函数来计算 market_data 中用户定义列的年度平均值:
average <- function(column) {
market_data[, .(average = mean(.SD[[column]])),
by = .(year = year(date))]
}
在前面的 j 表达式中,.SD 表示每个 year 值的分组 data.table。我们可以使用 .SD[[x]] 来提取列 x 的值,就像从列表中按名称提取元素一样。
然后,我们可以运行以下代码来计算每年平均价格:
average("price")
## year average
## 1: 2015 32.32531
## 2: 2016 32.38364
我们只需将参数更改为 volume,就可以计算每年的平均数量:
average("volume")
## year average
## 1: 2015 3999.931
## 2: 2016 4003.382
此外,我们可以使用特别发明的语法来创建具有动态数量的列,这些列的名称也是动态确定的。
假设我们添加了三个新的替代价格列,每个列都向原始 price 值添加了一些随机噪声。我们不必重复调用 market_data[, price1 := ...] 和 market_data[, price2 := ...],而是可以使用 market_data[, (columns) := list(...)] 来动态设置列,其中 columns 是列名的字符向量,list(...) 是 columns 中每个相应列的值:
price_cols <- paste0("price", 1:3)
market_data[, (price_cols) := lapply(1:3,
function(i) round(price + rnorm(.N, 0, 5), 2))]
head(market_data)
## date price volume price1 price2 price3
## 1: 2015-05-01 29.19 4021 30.55 27.39 33.22
## 2: 2015-05-02 28.88 4000 29.67 20.45 36.00
## 3: 2015-05-03 31.16 4033 34.31 26.94 27.24
## 4: 2015-05-04 31.30 4036 29.32 29.01 28.04
## 5: 2015-05-05 31.54 3995 36.04 32.06 34.79
## 6: 2015-05-06 34.27 3955 30.12 30.96 35.19
另一方面,如果我们得到一个具有许多列的表,并且我们需要对其中的一小部分进行一些计算,我们也可以使用类似的语法来解决问题。想象一下,价格相关的列可能存在缺失值。我们需要对每个价格列执行 zoo::na.locf()。首先,我们将使用正则表达式获取所有价格列:
cols <- colnames(market_data)
price_cols <- cols[grep("^price", cols)]
price_cols
## [1] "price" "price1" "price2" "price3"
然后,我们将使用类似的语法,但添加一个额外的参数.SDcols = price_cols,以限制.SD的列仅为我们获取的价格列。以下代码对每个价格列调用zoo::na.locf(),并替换每个列的旧值:
market_data[, (price_cols) := lapply(.SD, zoo::na.locf),
.SDcols = price_cols]
在本节中,我们展示了data.table的使用方法以及它如何使数据处理变得更加容易。要查看data.table的完整功能列表,请访问github.com/Rdatatable/data.table/wiki。要快速复习使用方法,请查看数据表速查表(www.datacamp.com/community/tutorials/data-table-cheat-sheet)。
使用 dplyr 管道操作数据框
另一个流行的包是dplyr,它发明了一种数据处理语法。而不是使用子集函数([]),dplyr定义了一组基本erb函数作为数据操作的基本构建块,并引入了一个管道操作符来将这些函数链接起来以执行复杂的多步骤任务。
如果您还没有安装dplyr,请运行以下代码从 CRAN 安装:
install.packages("dplyr")
首先,我们将重新加载产品表以重置所有数据到其原始形式:
library(readr)
product_info <- read_csv("data/product-info.csv")
product_stats <- read_csv("data/product-stats.csv")
product_tests <- read_csv("data/product-tests.csv")
toy_tests <- read_csv("data/product-toy-tests.csv")
然后,我们将加载dplyr包:
library(dplyr)
##
## Attaching package: 'dplyr'
## The following objects are masked from 'package:data.table':
##
## between, last
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
以下输出表明dplyr泛化了许多内置函数,因此在包附加后它们会被遮蔽。
现在,我们可以开始使用它提供的动词函数进行操作。首先,我们将使用select通过创建一个包含给定列的新表来从提供的数据框中选择列:
select(product_info, id, name, type, class)
## Source: local data frame [6 x 4]
##
## id name type class
## (chr) (chr) (chr) (chr)
## 1 T01 SupCar toy vehicle
## 2 T02 SupPlane toy vehicle
## 3 M01 JeepX model vehicle
## 4 M02 AircraftX model vehicle
## 5 M03 Runner model people
## 6 M04 Dancer model people
前一个表的打印方式与data.frame和data.table的打印方式略有不同。它不仅显示了表本身,还包括一个标题,指示数据框的大小以及每列的数据类型。
很明显,select()使用非标准评估,允许我们直接使用给定数据框的列名作为参数。它的工作方式与subset()、transform()和with()类似。
我们可以使用filter通过逻辑条件过滤数据框,该条件也在数据框的上下文中进行评估:
filter(product_info, released == "yes")
## Source: local data frame [4 x 5]
##
## id name type class released
## (chr) (chr) (chr) (chr) (chr)
## 1 T01 SupCar toy vehicle yes
## 2 M01 JeepX model vehicle yes
## 3 M02 AircraftX model vehicle yes
## 4 M03 Runner model people yes
如果我们要根据多个条件过滤记录,我们只需要将每个条件作为filter()函数的参数:
filter(product_info,
released == "yes", type == "model")
## Source: local data frame [3 x 5]
##
## id name type class released
## (chr) (chr) (chr) (chr) (chr)
## 1 M01 JeepX model vehicle yes
## 2 M02 AircraftX model vehicle yes
## 3 M03 Runner model people yes
mutate函数用于创建一个新的数据框,添加新列或替换现有列,类似于transform,但如果提供的数据是data.table,也支持就地赋值,:=:
mutate(product_stats, density = size / weight)
## Source: local data frame [6 x 5]
##
## id material size weight density
## (chr) (chr) (int) (dbl) (dbl)
## 1 T01 Metal 120 10.0 12.000000
## 2 T02 Metal 350 45.0 7.777778
## 3 M01 Plastics 50 NA NA
## 4 M02 Plastics 85 3.0 28.333333
## 5 M03 Wood 15 NA NA
## 6 M04 Wood 16 0.6 26.666667
arrange函数用于根据一个或多个列创建一个新的数据框,按顺序排列。desc()函数表示降序:
arrange(product_stats, material, desc(size), desc(weight))
## Source: local data frame [6 x 4]
##
## id material size weight
## (chr) (chr) (int) (dbl)
## 1 T02 Metal 350 45.0
## 2 T01 Metal 120 10.0
## 3 M02 Plastics 85 3.0
## 4 M01 Plastics 50 NA
## 5 M04 Wood 16 0.6
## 6 M03 Wood 15 NA
dplyr 函数提供了一套丰富的连接操作,包括 inner_join、left_join、right_join、full_join、semi_join 和 anti_join。如果两个要连接的表中有不匹配的记录,这些连接操作可能会有很大的不同。对于 product_info 和 product_tests,记录完全匹配,因此 left_join 应该返回与 merge 相同的结果:
product_info_tests <- left_join(product_info, product_tests, by = "id")
product_info_tests
## Source: local data frame [6 x 8]
##
## id name type class released quality durability
## (chr) (chr) (chr) (chr) (chr) (int) (int)
## 1 T01 SupCar toy vehicle yes NA 10
## 2 T02 SupPlane toy vehicle no 10 9
## 3 M01 JeepX model vehicle yes 6 4
## 4 M02 AircraftX model vehicle yes 6 5
## 5 M03 Runner model people yes 5 NA
## 6 M04 Dancer model people no 6 6
## Variables not shown: waterproof (chr)
要了解更多关于这些连接操作之间的区别,请运行 ?dplyr::join。
要按组总结数据,我们首先需要通过 group_by() 创建一个分组表。然后,我们将使用 summarize() 来聚合数据。例如,我们将按 type 和 class 对 product_info_tests 进行划分,然后对于每个类型类别的组,我们将计算 quality 和 durability 的平均值:
summarize(group_by(product_info_tests, type, class),
mean_quality = mean(quality, na.rm = TRUE),
mean_durability = mean(durability, na.rm = TRUE))
## Source: local data frame [3 x 4]
## Groups: type [?]
##
## type class mean_quality mean_durability
## (chr) (chr) (dbl) (dbl)
## 1 model people 5.5 6.0
## 2 model vehicle 6.0 4.5
## 3 toy vehicle 10.0 9.5
从前面的代码示例中,你学习了动词函数 select()、filter()、mutate()、arrange()、group_by() 和 summarize()。每个函数都设计来做一件小事情,但在一起,当适当组合时,它们可以执行全面的数据操作。除了这些函数之外,dplyr 从 magrittr 包中导入了管道操作符 %>% 来将函数组合成管道。
假设我们有两个表 product_info 和 product_tests。我们需要通过计算每个类型类组的质量和耐用性的平均值来分析发布的产品,并以平均质量的降序展示汇总数据。这可以通过由管道操作符组成的 dplyr 动词函数很好地完成:
product_info %>%
filter(released == "yes") %>%
inner_join(product_tests, by = "id") %>%
group_by(type, class) %>%
summarize(
mean_quality = mean(quality, na.rm = TRUE),
mean_durability = mean(durability, na.rm = TRUE)) %>%
arrange(desc(mean_quality))
## Source: local data frame [3 x 4]
## Groups: type [2]
##
## type class mean_quality mean_durability
## (chr) (chr) (dbl) (dbl)
## 1 model vehicle 6 4.5
## 2 model people 5 NaN
## 3 toy vehicle NaN 10.0
但 %>% 是如何工作的呢?管道操作符基本上只做一件事:将结果放在右侧函数调用第一个参数的左侧,即 x %>% f(...) 将基本上被评估为 f(x, ...)。由于 %>% 是一个包定义的二进制操作符,它允许我们将函数调用链式连接,以避免冗余的中间值或分解嵌套调用。
假设我们需要通过三个步骤将 d0 转换为 d3。在每一步中,我们需要调用一个函数,该函数使用前一个结果和一个参数。如果我们这样处理数据,将会有很多中间结果,而且当数据量大时,有时会消耗大量的内存:
d1 <- f1(d0, arg1)
d2 <- f2(d1, arg2)
d3 <- f3(d2, arg3)
如果我们想要避免中间结果,我们就必须编写嵌套调用。这项任务看起来一点也不直接,尤其是在每个函数调用中都有许多参数时:
f3(f2(f1(d0, arg1), arg2), arg3)
使用管道操作符,工作流程可以重新排列如下:
d0 %>%
f1(arg1) %>%
f2(arg2) %>%
f3(arg3)
代码看起来更简洁、更直接。整个表达式不仅看起来像管道,而且实际上也像管道一样工作。d0 %>% f1(arg1) 等式被评估为 f1(d0, arg1),然后被发送到 f2(., arg2),再发送到 f3(., arg3)。每个步骤的输出成为下一个步骤的输入。
因此,管道操作符不仅与dplyr函数一起工作,还与其他所有函数一起工作。假设我们想要绘制钻石价格的密度图:
data(diamonds, package = "ggplot2")
plot(density(diamonds$price, from = 0),
main = "Density plot of diamond prices")
生成的图表如下所示:

使用管道操作符,我们可以将代码重写如下:
diamonds$price %>%
density(from = 0) %>%
plot(main = "Density plot of diamonds prices")
与data.table类似,dplyr也提供了do()来对每组数据执行任意操作。例如,我们可以按cut对diamonds进行分组,并且对于每个组,我们可以拟合log(price) ~ carat的线性模型。与data.table不同,我们需要指定此类操作的名称,以便结果可以存储为列。此外,do()中的表达式不是在分组数据的上下文中直接评估的。相反,我们需要使用.来表示数据:
models <- diamonds %>%
group_by(cut) %>%
do(lmod = lm(log(price) ~ carat, data = .))
models
## Source: local data frame [5 x 2]
## Groups: <by row>
##
## cut lmod
## (fctr) (chr)
## 1 Fair <S3:lm>
## 2 Good <S3:lm>
## 3 Very Good <S3:lm>
## 4 Premium <S3:lm>
## 5 Ideal <S3:lm>
注意,创建了一个新的列lmod。它不是一个典型的原子向量的数据列。相反,它是一个线性模型对象的列表,即每个cut值的模型存储在列表类型的列lmod中。我们可以使用索引访问每个模型:
models$lmod[[1]]
##
## Call:
## lm(formula = log(price) ~ carat, data = .)
##
## Coefficients:
## (Intercept) carat
## 6.785 1.251
do()函数在执行高度定制化的操作时非常有帮助。例如,假设我们需要通过总结每个产品的质量和耐用性来分析toy_tests数据。考虑一下,如果我们只需要具有最多样本的前三个测试记录,并且每个产品的质量和耐用性应该是测量值和样本的加权平均值,我们应该怎么做。
使用dplyr函数和管道,前面的任务可以很容易地用以下代码完成:
toy_tests %>%
group_by(id) %>%
arrange(desc(sample)) %>%
do(head(., 3)) %>%
summarize(
quality = sum(quality * sample) / sum(sample),
durability = sum(durability * sample) / sum(sample))
## Source: local data frame [2 x 3]
##
## id quality durability
## (chr) (dbl) (dbl)
## 1 T01 9.319149 9.382979
## 2 T02 9.040000 8.340000
注意,当数据分组时,所有后续操作都是按组执行的。为了查看中间结果,我们将运行do(head(., 3))之前的代码:
toy_tests %>%
group_by(id) %>%
arrange(desc(sample))
## Source: local data frame [8 x 5]
## Groups: id [2]
##
## id date sample quality durability
## (chr) (int) (int) (int) (int)
## 1 T01 20160405 180 9 10
## 2 T01 20160302 150 10 9
## 3 T01 20160502 140 9 9
## 4 T01 20160201 100 9 9
## 5 T02 20160403 90 9 8
## 6 T02 20160502 85 10 9
## 7 T02 20160303 75 8 8
## 8 T02 20160201 70 7 9
我们按sample降序排序获取所有记录。然后,do(head(., 3))将为每个组中的head(., 3)进行评估,其中.代表组中的数据:
toy_tests %>%
group_by(id) %>%
arrange(desc(sample)) %>%
do(head(., 3))
## Source: local data frame [6 x 5]
## Groups: id [2]
##
## id date sample quality durability
## (chr) (int) (int) (int) (int)
## 1 T01 20160405 180 9 10
## 2 T01 20160302 150 10 9
## 3 T01 20160502 140 9 9
## 4 T02 20160403 90 9 8
## 5 T02 20160502 85 10 9
## 6 T02 20160303 75 8 8
现在,我们将获取具有最多样本的前三个记录。将数据总结如下是方便的。
dplyr函数定义了一种非常直观的数据操作语法,并提供了一组为在管道中使用而设计的性能动词函数。要了解更多信息,我建议您阅读该包的 vignettes(cran.rstudio.com/web/packages/dplyr/vignettes/introduction.html)并访问 DataCamp 上的交互式教程(www.datacamp.com/courses/dplyr-data-manipulation-r-tutorial)。
使用 rlist 处理嵌套数据结构
在上一章中,你学习了存储在表中的关系型数据库和非关系型数据库,后者支持嵌套数据结构。在 R 中,最常用的嵌套数据结构是列表对象。所有前面的部分都集中在操作表格数据上。在本节中,让我们玩一玩我开发的 rlist 包,该包旨在操作非表格数据。
rlist 的设计非常类似于 dplyr。它为列表对象提供映射、过滤、选择、排序、分组和聚合功能。运行以下代码从 CRAN 安装 rlist 包:
install.packages("rlist")
我们将非表格版本的产品数据存储在 data/products.json 中。在这个文件中,每个产品都有一个如下的 JSON 表示:
{
"id": "T01",
"name": "SupCar",
"type": "toy",
"class": "vehicle",
"released": true,
"stats": {
"material": "Metal",
"size": 120,
"weight": 10
},
"tests": {
"quality": null,
"durability": 10,
"waterproof": false
},
"scores": [8, 9, 10, 10, 6, 5]
}
所有产品都存储在一个 JSON 数组中,例如 [ {...}, {...} ]。我们不是将数据存储在不同的表中,而是将所有与产品相关的信息放在一个对象中。要处理这种格式的数据,我们可以使用 rlist 函数。首先,让我们加载 rlist 包:
library(rlist)
要将数据加载到 R 中作为列表,我们可以使用 jsonlite::fromJSON() 或简单地使用 rlist 提供的 list.load():
products <- list.load("data/products.json")
str(products[[1]])
## List of 8
## $ id : chr "T01"
## $ name : chr "SupCar"
## $ type : chr "toy"
## $ class : chr "vehicle"
## $ released: logi TRUE
## $ stats :List of 3
## ..$ material: chr "Metal"
## ..$ size : int 120
## ..$ weight : int 10
## $ tests :List of 3
## ..$ quality : NULL
## ..$ durability: int 10
## ..$ waterproof: logi FALSE
## $ scores : int [1:6] 8 9 10 10 6 5
现在,products 包含了所有产品的信息。products 的每个元素代表一个包含所有相关信息的产品。
要在元素上下文中评估表达式,我们可以调用 list.map():
str(list.map(products, id))
## List of 6
## $ : chr "T01"
## $ : chr "T02"
## $ : chr "M01"
## $ : chr "M02"
## $ : chr "M03"
## $ : chr "M04"
它迭代地在 products 的每个元素上评估 id 并返回一个包含所有相应结果的新列表。list.mapv() 函数简化了列表并仅返回一个向量:
list.mapv(products, name)
## [1] "SupCar" "SupPlane" "JeepX" "AircraftX"
## [5] "Runner" "Dancer"
要过滤 products,我们可以使用带有逻辑条件的 list.filter()。对于 products 中条件返回 TRUE 的所有元素将被挑选出来:
released_products <- list.filter(products, released)
list.mapv(released_products, name)
## [1] "SupCar" "JeepX" "AircraftX" "Runner"
注意,rlist 函数的设计类似于 dplyr 函数,即输入数据总是第一个参数。因此,我们可以使用管道操作符将结果传递下去:
products %>%
list.filter(released) %>%
list.mapv(name)
## [1] "SupCar" "JeepX" "AircraftX" "Runner"
我们可以使用 list.select() 来选择输入列表中每个元素的给定字段:
products %>%
list.filter(released, tests$waterproof) %>%
list.select(id, name, scores) %>%
str()
## List of 3
## $ :List of 3
## ..$ id : chr "M01"
## ..$ name : chr "JeepX"
## ..$ scores: int [1:6] 6 8 7 9 8 6
## $ :List of 3
## ..$ id : chr "M02"
## ..$ name : chr "AircraftX"
## ..$ scores: int [1:7] 9 9 10 8 10 7 9
## $ :List of 3
## ..$ id : chr "M03"
## ..$ name : chr "Runner"
## ..$ scores: int [1:10] 6 7 5 6 5 8 10 9 8 9
或者,我们可以在 list.select() 中根据现有字段创建新字段:
products %>%
list.filter(mean(scores) >= 8) %>%
list.select(name, scores, mean_score = mean(scores)) %>%
str()
## List of 3
## $ :List of 3
## ..$ name : chr "SupCar"
## ..$ scores : int [1:6] 8 9 10 10 6 5
## ..$ mean_score: num 8
## $ :List of 3
## ..$ name : chr "SupPlane"
## ..$ scores : int [1:5] 9 9 10 10 10
## ..$ mean_score: num 9.6
## $ :List of 3
## ..$ name : chr "AircraftX"
## ..$ scores : int [1:7] 9 9 10 8 10 7 9
## ..$ mean_score: num 8.86
我们还可以使用 list.sort() 和 list.stack() 将列表元素按某些字段或值排序,并将所有元素堆叠到一个数据框中:
products %>%
list.select(name, mean_score = mean(scores)) %>%
list.sort(-mean_score) %>%
list.stack()
## name mean_score
## 1 SupPlane 9.600000
## 2 AircraftX 8.857143
## 3 SupCar 8.000000
## 4 Dancer 7.833333
## 5 JeepX 7.333333
## 6 Runner 7.300000
要对列表进行分组,我们将调用 list.group() 来创建一个嵌套列表,其中所有元素都按字段的值进行划分:
products %>%
list.select(name, type, released) %>%
list.group(type) %>%
str()
## List of 2
## $ model:List of 4
## ..$ :List of 3
## .. ..$ name : chr "JeepX"
## .. ..$ type : chr "model"
## .. ..$ released: logi TRUE
## ..$ :List of 3
## .. ..$ name : chr "AircraftX"
## .. ..$ type : chr "model"
## .. ..$ released: logi TRUE
## ..$ :List of 3
## .. ..$ name : chr "Runner"
## .. ..$ type : chr "model"
## .. ..$ released: logi TRUE
## ..$ :List of 3
## .. ..$ name : chr "Dancer"
## .. ..$ type : chr "model"
## .. ..$ released: logi FALSE
## $ toy :List of 2
## ..$ :List of 3
## .. ..$ name : chr "SupCar"
## .. ..$ type : chr "toy"
## .. ..$ released: logi TRUE
## ..$ :List of 3
## .. ..$ name : chr "SupPlane"
## .. ..$ type : chr "toy"
## .. ..$ released: logi FALSE
rlist 函数还提供了许多其他函数,旨在使非表格数据操作更容易。例如,list.table() 增强 table() 以直接处理元素列表:
products %>%
list.table(type, class)
## class
## type people vehicle
## model 2 2
## toy 0 2
它还支持通过在输入列表的上下文中评估每个参数来评估多维表格:
products %>%
list.filter(released) %>%
list.table(type, waterproof = tests$waterproof)
## waterproof
## type FALSE TRUE
## model 0 3
## toy 1 0
尽管数据存储不是表格形式,但我们仍然可以轻松地进行全面的数据操作,并将结果以表格形式呈现。例如,假设我们需要计算平均分数最高的前两个产品的平均分数和分数数量,但至少有五个分数。
我们可以将这样的任务分解成更小的数据处理子任务,这可以通过 rlist 函数轻松完成。由于数据操作中涉及的步骤数量,我们将使用管道来组织工作流程:
products %>%
list.filter(length(scores) >= 5) %>%
list.sort(-mean(scores)) %>%
list.take(2) %>%
list.select(name,
mean_score = mean(scores),
n_score = length(scores)) %>%
list.stack()
## name mean_score n_score
## 1 SupPlane 9.600000 5
## 2 AircraftX 8.857143 7
代码看起来很简单,可以很容易地预测或分析每一步会发生什么。如果最终结果可以用表格形式表示,我们就可以调用 list.stack() 来将所有列表元素绑定在一起形成一个数据框。
要了解更多关于 rlist 函数的信息,请阅读 rlist 教程(renkun.me/rlist-tutorial/)。还有其他处理嵌套数据结构的包,但可能具有不同的哲学,例如 purrr (github.com/hadley/purrr)。如果您感兴趣,请访问并了解他们的网站。
摘要
在本章中,您学习了大量的基本函数和用于数据操作的各种包。使用内置函数来操作数据可能是多余的。有几个包专门针对基于不同技术和哲学的数据过滤和聚合。sqldf 包使用嵌入式 SQLite 数据库,这样我们就可以直接在我们的工作环境中编写 SQL 语句来查询数据框。另一方面,data.table 提供了 data.frame 的增强版本和强大的语法,而 dplyr 通过提供一组管道友好的动词函数来定义数据操作语法。rlist 类提供了一组针对非表格数据操作的管道友好函数。没有哪个包适用于所有情况。每个包都代表了一种思维方式,最适合某个问题的方法取决于您如何理解问题以及您与数据工作的经验。
数据处理和模拟需要相当的计算能力。然而,从开始到今天,性能并不是 R 的首要任务。尽管 R 在交互式分析、可视化和报告方面非常强大,但与其他一些流行的脚本语言相比,当用于处理大量数据时,其实现被认为是较慢的。在下一章中,我们将介绍从性能度量到分析、向量化、MKL 加速的 R 内核、并行计算和 Rcpp 等几种技术。这些技术将帮助您在真正需要时实现高性能。
第十三章。高性能计算
在上一章中,您学习了关于数据操作的一些内置函数和针对数据操作的各种包。尽管这些包依赖于不同的技术,并且可能在不同的哲学指导下构建,但它们都使得数据过滤和聚合变得容易得多。
然而,数据处理不仅仅是简单的过滤和聚合。有时,它涉及到模拟和其他计算密集型任务。与 C 和 C++ 等高性能编程语言相比,R 由于其动态设计以及当前实现中优先考虑稳定性和在统计分析与可视化中的易用性和强大功能,而不是性能和语言特性,运行速度要慢得多。然而,编写良好的 R 代码仍然足够快,可以满足大多数用途。
在本章中,我将展示以下技术,以帮助您以高性能编写 R 代码:
-
测量代码性能
-
分析代码以找到瓶颈
-
使用内置函数和向量化
-
通过并行计算使用多个核心
-
使用 Rcpp 和相关包编写 C++
理解代码性能问题
从一开始,R 就是为统计计算和数据可视化而设计的,并且被学术界和工业界广泛使用。对于大多数数据分析目的,正确性比性能更重要。换句话说,在 1 分钟内得到正确的结果应该比在 20 秒内得到错误的结果要好。一个速度快三倍的结果并不自动比一个慢但正确的结果多三倍的有效性。因此,在您确信代码的正确性之前,性能不应成为担忧的问题。
假设您百分之百确信您的代码是正确的,但运行速度有点慢。现在,您是否需要优化代码以使其运行更快?嗯,这取决于。在做出决定之前,将问题解决的时间分为三个部分是有帮助的:开发时间、执行时间和未来的维护时间。
假设我们已经在一个问题上工作了一个小时。由于我们在一开始没有考虑性能,代码运行得并不快。我们花了 50 分钟来思考问题并实现解决方案。然后,又花了 1 分钟来运行并产生答案。由于代码与问题匹配得很好,看起来很直接,未来的改进可以很容易地集成到解决方案中,因此我们花费的时间较少来维护。
然后,假设另一位开发者一直在处理相同的问题,但一开始就试图编写一个极端高性能的代码。解决问题需要时间,但优化代码结构以使其运行更快则需要更多时间。可能需要两小时来思考和实现一个高性能解决方案。然后,运行并产生答案只需要 0.1 秒。由于代码特别优化以挤压硬件,它可能不适合未来的改进,尤其是在问题更新时,这会花费更多时间来维护。
第二位开发者可以高兴地宣称她的代码的性能是我们的代码的 600 倍,但这可能不值得这样做,因为它可能需要更多的人工时间。在许多情况下,人工时间比计算机时间更昂贵。
然而,如果代码经常被使用,比如说,如果需要数十亿次迭代,每次迭代的性能微小提升可以帮助节省大量时间。在这种情况下,代码性能真的很重要。
让我们以一个简单的算法为例,该算法生成一个累积和的数值向量,即输出向量的每个元素是输入向量所有前一个元素的累加和。接下来的讨论中,代码将在不同的上下文中被检查。
虽然 R 提供了一个内置函数cumsum来完成这个任务,但我们现在将实现一个 R 版本来帮助理解性能问题。算法的实现如下:
x <- c(1, 2, 3, 4, 5)
y <- numeric()
sum_x <- 0
for (xi in x) {
sum_x <- sum_x + xi
y <- c(y, sum_x)
}
y
## [1] 1 3 6 10 15
该算法仅使用 for 循环将输入向量 x 的每个元素累加到 sum_x 中。在每次迭代中,它将 sum_x 添加到输出向量 y 中。我们可以将算法重写为以下函数:
my_cumsum1 <- function(x) {
y <- numeric()
sum_x <- 0
for (xi in x) {
sum_x <- sum_x + xi
y <- c(y, sum_x)
}
y
}
另一种实现方法是使用索引来访问输入向量 x 并访问/修改输出向量 y:
my_cumsum2 <- function(x) {
y <- numeric(length(x))
if (length(y)) {
y[[1]] <- x[[1]]
for (i in 2:length(x)) {
y[[i]] <- y[[I - 1]] + x[[i]]
}
}
y
}
我们知道 R 提供了一个内置函数 cumsum() 来执行完全相同的事情。前面的两种实现应该会产生与 cumsum() 完全相同的结果。在这里,我们将生成一些随机数并检查它们是否一致:
x <- rnorm(100)
all.equal(cumsum(x), my_cumsum1(x))
## [1] TRUE
all.equal(cumsum(x), my_cumsum2(x))
## [1] TRUE
在前面的代码中,all.equal() 检查两个向量的所有对应元素是否相等。从结果来看,我们可以确信 my_cumsum1()、my_cumsum2() 和 cumsum() 是一致的。在下一节中,我们将测量 cumsum 的每个版本的执行时间。
测量代码性能
虽然这三个函数在给定相同输入的情况下会输出相同的结果,但它们的性能差异可能非常明显。为了揭示性能差异,我们需要工具来测量代码的执行时间。最简单的一个是 system.time()。
要测量任何表达式的执行时间,我们只需用函数将其包装起来。在这里,我们将测量 my_cumsum1() 计算一个包含 100 个元素的数值向量所需的时间:
x <- rnorm(100)
system.time(my_cumsum1(x))
## user system elapsed
## 0 0 0
计时器结果显示了三列:user、system 和 elapsed。我们应该更加关注的是 user 时间。它衡量的是执行代码所花费的 CPU 时间。对于更多细节,运行 ?proc.time 并查看这些度量之间的差异。
结果表明代码运行得太快,无法进行测量。我们可以尝试计时 my_cumsum2(),结果大致相同:
system.time(my_cumsum2(x))
## user system elapsed
## 0.000 0.000 0.001
对于内置函数 cumsum() 也是如此:
system.time(cumsum(x))
## user system elapsed
## 0 0 0
由于输入太小,计时并没有真正起作用。现在,我们将生成一个包含 1000 个数字的向量并再次进行测试:
x <- rnorm(1000)
system.time(my_cumsum1(x))
## user system elapsed
## 0.000 0.000 0.003
system.time(my_cumsum2(x))
## user system elapsed
## 0.004 0.000 0.001
system.time(cumsum(x))
## user system elapsed
## 0 0 0
现在,我们确信 my_cumsum1() 和 my_cumsum2() 确实需要一些时间来计算结果,但它们之间没有明显的差异。然而,cumsum() 函数仍然非常快,难以测量。
我们将再次使用更大的输入值来测试这三个函数,看看它们的性能差异是否可以被揭示:
x <- rnorm(10000)
system.time(my_cumsum1(x))
## user system elapsed
## 0.208 0.000 0.211
system.time(my_cumsum2(x))
## user system elapsed
## 0.012 0.004 0.013
system.time(cumsum(x))
## user system elapsed
## 0 0 0
结果非常清晰:my_cumsum1() 的速度看起来比 my_cumsum2() 慢 10 多倍,而 cumsum() 函数的速度仍然远远超过我们的两个实现。
注意,性能差异可能不是恒定的,尤其是当我们提供更大的输入时,如下所示:
x <- rnorm(100000)
system.time(my_cumsum1(x))
## user system elapsed
## 25.732 0.964 26.699
system.time(my_cumsum2(x))
## user system elapsed
## 0.124 0.000 0.123
system.time(cumsum(x))
## user system elapsed
## 0 0 0
前面的结果形成了一个相当惊人的对比:当输入向量的长度达到 100,000 级别时,my_cumsum1() 可以比 my_cumsum2() 慢 200 倍。在所有之前的结果中,cumsum() 函数始终非常快。
system.time() 函数可以帮助测量代码块执行时间,但它并不非常准确。一方面,每次测量可能会得到不同的值,因此我们应该重复计时足够多次,以便进行有效的比较。另一方面,计时器的分辨率可能不足以反映我们感兴趣的代码的实际性能差异。
一个名为 microbenchmark 的包可以作为比较不同表达式性能的更准确解决方案。要安装该包,请运行以下代码:
install.packages("microbenchmark")
当包准备就绪时,我们将加载该包并调用 microbenchmark() 来直接比较这三个函数的性能:
library(microbenchmark)
x <- rnorm(100)
microbenchmark(my_cumsum1(x), my_cumsum2(x), cumsum(x))
## Unit: nanoseconds
## expr min lq mean median uq
## my_cumsum1(x) 58250 64732.5 68353.51 66396.0 71840.0
## my_cumsum2(x) 120150 127634.5 131042.40 130739.5 133287.5
## cumsum(x) 295 376.5 593.47 440.5 537.5
## max neval cld
## 88228 100 b
## 152845 100 c
## 7182 100 a
注意,默认情况下,microbenchmark() 函数会运行每个表达式 100 次,以便提供更多执行时间的分位数。也许会让你感到惊讶,当输入向量为 100 个元素时,my_cumsum1() 比起 my_cumsum2() 来说要快一点。另外,请注意,时间数字的单位是纳秒(1 秒等于 1,000,000,000 纳秒)。
然后,我们将尝试一个包含 1000 个数字的输入:
x <- rnorm(1000)
microbenchmark(my_cumsum1(x), my_cumsum2(x), cumsum(x))
## Unit: microseconds
## expr min lq mean median
## my_cumsum1(x) 1600.186 1620.5190 2238.67494 1667.5605
## my_cumsum2(x) 1034.973 1068.4600 1145.00544 1088.4090
## cumsum(x) 1.806 2.1505 3.43945 3.4405
## uq max neval cld
## 3142.4610 3750.516 100 c
## 1116.2280 2596.908 100 b
## 4.0415 11.007 100 a
现在,my_cumsum2() 的速度比 my_cumsum1() 快一点,但两者都比内置的 cumsum() 慢得多。请注意,单位现在变成了微秒。
对于 5000 个数字的输入,my_cumsum1() 和 my_cumsum2() 之间的性能差异变得更大:
x <- rnorm(5000)
microbenchmark(my_cumsum1(x), my_cumsum2(x), cumsum(x))
## Unit: microseconds
## expr min lq mean median
## my_cumsum1(x) 42646.201 44043.050 51715.59988 44808.9745
## my_cumsum2(x) 5291.242 5364.568 5718.19744 5422.8950
## cumsum(x) 10.183 11.565 14.52506 14.6765
## uq max neval cld
## 46153.351 135805.947 100 c
## 5794.821 10619.352 100 b
## 15.536 37.202 100 a
当输入为 10000 个元素时,同样的事情发生了:
x <- rnorm(10000)
microbenchmark(my_cumsum1(x), my_cumsum2(x), cumsum(x), times = 10)
## Unit: microseconds
## expr min lq mean median
## my_cumsum1(x) 169609.730 170687.964 198782.7958 173248.004
## my_cumsum2(x) 10682.121 10724.513 11278.0974 10813.395
## cumsum(x) 20.744 25.627 26.0943 26.544
## uq max neval cld
## 253662.89 264469.677 10 b
## 11588.99 13487.812 10 a
## 27.64 29.163 10 a
在所有之前的基准测试中,cumsum() 的性能看起来非常稳定,并且随着输入长度的增加,性能没有显著增加。
为了更好地理解三个函数的性能动态,我们将创建以下函数来可视化它们在不同长度输入下的表现:
library(data.table)
benchmark <- function(ns, times = 30) {
results <- lapply(ns, function(n) {
x <- rnorm(n)
result <- microbenchmark(my_cumsum1(x), my_cumsum2(x), cumsum(x),
times = times, unit = "ms")
data <- setDT(summary(result))
data[, n := n]
data
})
rbindlist(results)
}
函数的逻辑非常直接:ns 是一个向量,包含了我们想要用这些函数测试的所有输入向量的长度。请注意,microbenchmark() 返回一个包含所有测试结果的数据框,而 summary(microbenchmark()) 返回我们之前看到的摘要表。我们用 n 标记每个摘要,堆叠所有基准测试结果,并使用 ggplot2 包来可视化结果。
首先,我们将从 100 到 3000 个元素,步长为 100 进行基准测试:
benchmarks <- benchmark(seq(100, 3000, 100))
然后,我们将创建一个图表来展示三个函数性能的对比:
library(ggplot2)
ggplot(benchmarks, aes(x = n, color = expr)) +
ggtitle("Microbenchmark on cumsum functions") +
geom_point(aes(y = median)) +
geom_errorbar(aes(ymin = lq, ymax = uq))
这产生了以下三个版本的 cumsum 的基准测试,我们打算进行比较:

在前面的图表中,我们将三个函数的结果汇总在一起。点表示中位数,误差条表示第 75 分位数和第 25 分位数。
很明显,my_cumsum1() 在较长输入下的性能下降得更快,my_cumsum2() 的性能几乎随着输入长度的增加而线性下降,而 cumsum(x) 非常快,其性能似乎不会随着输入长度的增加而显著下降。
对于小输入,my_cumsum1() 可以比 my_cumsum2() 快,正如我们之前所展示的。我们可以进行一个更专注于小输入的基准测试:
benchmarks2 <- benchmark(seq(2, 600, 10), times = 50)
这次,我们将输入向量的长度限制在 2 到 500 个元素,停止条件为 10。由于函数将执行的次数几乎是之前基准测试的两倍,为了保持总执行时间较低,我们将 times 从默认的 100 减少到 50:
ggplot(benchmarks2, aes(x = n, color = expr)) +
ggtitle("Microbenchmark on cumsum functions over small input") +
geom_point(aes(y = median)) +
geom_errorbar(aes(ymin = lq, ymax = uq))
下面的图形说明了较小输入下的性能差异:

从图表中,我们可以看到,对于小于大约 400 个数字的小输入,my_cumsum1() 比较快。随着输入元素的增加,my_cumsum1() 的性能衰减比 my_cumsum2() 快得多。
通过对从 10 到 800 个元素的输入进行基准测试,可以更好地说明性能排名的动态:
benchmarks3 <- benchmark(seq(10, 800, 10), times = 50)
ggplot(benchmarks3, aes(x = n, color = expr)) +
ggtitle("Microbenchmark on cumsum functions with break even") +
geom_point(aes(y = median)) +
geom_errorbar(aes(ymin = lq, ymax = uq))
生成的图表如下所示:

总之,实现上的微小差异可能会导致性能差距很大。对于小输入,差距通常不明显,但当输入变大时,性能差异可能非常显著,因此不应被忽视。为了比较多个表达式的性能,我们可以使用 microbenchmark 而不是 system.time() 来获得更准确和更有用的结果。
性能分析代码
在前面的章节中,你学习了如何使用 microbenchmark() 来基准测试表达式。这在我们有几个问题解决方案的替代方案并想看到哪个性能更好,或者当我们优化一个表达式并想看到性能是否真的比原始代码更好时非常有用。
然而,通常情况下,当我们觉得代码运行缓慢时,很难定位到对整个程序减慢贡献最大的表达式。这样的表达式被称为“性能瓶颈”。为了提高代码性能,最好先解决瓶颈。
幸运的是,R 提供了分析工具来帮助我们找到瓶颈,即运行最慢的代码,这应该是提高代码性能的首要关注点。
使用 Rprof 进行代码分析
R 提供了一个内置函数 Rprof() 用于代码分析。当分析开始时,会运行一个采样过程,直到分析结束。默认情况下,采样会查看 R 每隔 20 毫秒执行哪个函数。这样,如果一个函数非常慢,那么大部分执行时间可能都花在了这个函数调用上。
样本方法可能不会产生非常准确的结果,但在大多数情况下它都能满足我们的需求。在下面的例子中,我们将使用 Rprof() 来分析调用 my_cumsum1() 的代码,并尝试找出哪个部分减慢了代码。
使用 Rprof() 的方法非常简单:调用 Rprof() 开始分析,运行你想要分析的代码,调用 Rprof(NULL) 停止分析,最后调用 summaryRprof() 查看分析摘要:
x <- rnorm(1000)
tmp <- tempfile(fileext = ".out")
Rprof(tmp)
for (i in 1:1000) {
my_cumsum1(x)
}
Rprof(NULL)
summaryRprof(tmp)
## $by.self
## self.time self.pct total.time total.pct
## "c" 2.42 82.88 2.42 82.88
## "my_cumsum1" 0.46 15.75 2.92 100.00
## "+" 0.04 1.37 0.04 1.37
## $by.total
## total.time total.pct self.time self.pct
## "my_cumsum1" 2.92 100.00 0.46 15.75
## "c" 2.42 82.88 2.42 82.88
## "+" 0.04 1.37 0.04 1.37
##
## $sample.interval
## [1] 0.02
##
## $sampling.time
## [1] 2.92
注意,我们使用了 tempfile() 创建一个临时文件来存储分析数据。如果我们不向 Rprof() 提供这样的文件,它将自动在当前工作目录中创建 Rprof.out。默认情况下,这也适用于 summaryRprof()。
分析结果将分析数据总结成可读的格式:$by.self 按照执行时间 self.time 排序,而 $by.total 按照总执行时间 total.time 排序。更具体地说,一个函数的 self.time 是该函数中执行代码所花费的时间,而一个函数的 total.time 是该函数的总执行时间。
为了找出哪个部分减慢了函数,我们应该更加关注 self.time,因为它涉及到每个函数执行的独立时间。
前面的分析结果显示,c 占用了主要的执行时间,也就是说,y <- c(y, sum_x) 对函数的减慢贡献最大。
我们可以对 my_cumsum2() 做同样的事情。分析结果显示,大部分时间都花在了 my_cumsum2() 上,这是正常的,因为代码中我们只做了这件事。my_cumsum2() 中没有特定的函数占用了大量的执行时间:
tmp <- tempfile(fileext = ".out")
Rprof(tmp)
for (i in 1:1000) {
my_cumsum2(x)
}
Rprof(NULL)
summaryRprof(tmp)
## $by.self
## self.time self.pct total.time total.pct
## "my_cumsum2" 1.42 97.26 1.46 100.00
## "-" 0.04 2.74 0.04 2.74
##
## $by.total
## total.time total.pct self.time self.pct
## "my_cumsum2" 1.46 100.00 1.42 97.26
## "-" 0.04 2.74 0.04 2.74
##
## $sample.interval
## [1] 0.02
##
## $sampling.time
## [1] 1.46
在实际情况下,我们想要分析的性能代码通常足够复杂。它可能涉及许多不同的函数。如果我们只看到跟踪的每个函数的计时,这样的分析总结可能不太有帮助。幸运的是,Rprof() 支持行分析,也就是说,当我们指定 line.profiling = TRUE 并使用 source(..., keep.source = TRUE) 时,它可以告诉我们每行代码的计时。
我们将在 code/my_cumsum1.R 创建一个脚本文件,包含以下代码:
my_cumsum1 <- function(x) {
y <- numeric()
sum_x <- 0
for (xi in x) {
sum_x <- sum_x + xi
y <- c(y, sum_x)
}
y
}
x <- rnorm(1000)
for (i in 1:1000) {
my_cumsum1(x)
}
然后,我们将使用 Rprof() 和 source() 来分析这个脚本文件:
tmp <- tempfile(fileext = ".out")
Rprof(tmp, line.profiling = TRUE)
source("code/my_cumsum1.R", keep.source = TRUE)
Rprof(NULL)
summaryRprof(tmp, lines = "show")
## $by.self
## self.time self.pct total.time total.pct
## my_cumsum1.R#6 2.38 88.15 2.38 88.15
## my_cumsum1.R#5 0.26 9.63 0.26 9.63
## my_cumsum1.R#4 0.06 2.22 0.06 2.22
##
## $by.total
## total.time total.pct self.time self.pct
## my_cumsum1.R#14 2.70 100.00 0.00 0.00
## my_cumsum1.R#6 2.38 88.15 2.38 88.15
## my_cumsum1.R#5 0.26 9.63 0.26 9.63
## my_cumsum1.R#4 0.06 2.22 0.06 2.22
##
## $by.line
## self.time self.pct total.time total.pct
## my_cumsum1.R#4 0.06 2.22 0.06 2.22
## my_cumsum1.R#5 0.26 9.63 0.26 9.63
## my_cumsum1.R#6 2.38 88.15 2.38 88.15
## my_cumsum1.R#14 0.00 0.00 2.70 100.00
##
## $sample.interval
## [1] 0.02
##
## $sampling.time
## [1] 2.7
这次,它不再显示函数名,而是显示脚本文件中的行号。我们可以通过查看 $by.self 的顶部行轻松地定位耗时最多的行。my_cumsum1.R#6 文件指的是 y <- c(y, sum_x),这与之前的分析结果一致。
使用 profvis 分析代码
Rprof() 函数提供了有用的信息,帮助我们找到代码中太慢的部分,以便我们可以改进实现。RStudio 还发布了一个增强的分析工具 profvis (rstudio.github.io/profvis/),它为 R 代码提供了交互式可视化分析。
它是一个 R 包,并且已经集成到 RStudio 中。要安装包,请运行以下代码:
install.packages("profvis")
一旦安装了包,我们就可以使用 profvis 来分析一个表达式并可视化结果:
library(profvis)
profvis({
my_cumsum1 <- function(x) {
y <- numeric()
sum_x <- 0
for (xi in x) {
sum_x <- sum_x + xi
y <- c(y, sum_x)
}
y
}
x <- rnorm(1000)
for (i in 1:1000) {
my_cumsum1(x)
}
})
分析完成后,将出现一个带有交互式用户界面的新标签页:

上半部分显示代码、内存使用和计时,而下半部分显示函数调用的时序以及垃圾回收发生的时间。我们可以点击并选择特定的代码行,查看函数执行的时序。与 summaryRprof() 生成的结果相比,这种交互式可视化提供了更丰富的信息,使我们能够更多地了解代码在长时间内的执行情况。这样,我们可以轻松地识别出耗时的代码和一些可能引起问题的模式。
我们可以用 my_cumsum2() 做同样的事情:
profvis({
my_cumsum2 <- function(x) {
y <- numeric(length(x))
y[[1]] <- x[[1]]
for (i in 2:length(x)) {
y[[i]] <- y[[i-1]] + x[[i]]
}
y
}
x <- rnorm(1000)
for (i in 1:1000) {
my_cumsum2(x)
}
})
这次,分析结果如下所示:

我们可以轻松地识别出哪个部分耗时最多,并决定是否可以接受。在所有代码中,总有一部分耗时最多,但这并不一定意味着它太慢。如果代码满足我们的需求且性能可接受,那么可能没有必要为了修改代码而冒出错误版本的风险来优化性能。
理解代码为什么可能慢
在前面的章节中,你学习了关于代码计时和分析的工具。为了解决相同的问题,一个函数可能运行得非常快,而另一个可能运行得非常慢。了解什么因素会导致代码慢是有帮助的。
首先,R 是一种动态编程语言。按照设计,它提供了高度灵活的数据结构和代码执行机制。因此,代码解释器在函数实际被调用之前很难预先知道如何处理下一个函数调用。这与强类型静态编程语言(如 C 和 C++)的情况不同。许多事情都是在编译时而不是在运行时确定的,因此程序在事先知道很多信息的情况下,可以进行密集的优化。相比之下,R 以灵活性换取性能,但编写良好的 R 代码可以表现出可接受,甚至良好的性能。
R 代码可能运行缓慢的最主要原因是我们可能密集地创建、分配或复制数据结构。这正是为什么当输入变长时,my_cumsum1()和my_cumsum2()在性能上表现出巨大差异的原因。my_cumsum1()函数始终扩展向量,这意味着在每个迭代中,向量都会被复制到新的地址,并添加一个新元素。因此,迭代次数越多,它需要复制的元素就越多,然后代码就会变慢。
这可以通过以下基准测试来明确:grow_by_index表示我们初始化一个空列表。preallocated函数表示我们初始化一个带有预分配位置的列表,即一个包含n个NULL值的列表,所有位置都已分配。在两种情况下,我们都会修改列表的第i个元素,但区别在于我们将在每次迭代中扩展第一个列表,而第二个列表不会发生这种情况,因为它已经完全分配了:
n <- 10000
microbenchmark(grow_by_index = {
x <- list()
for (i in 1:n) x[[i]] <- i
}, preallocated = {
x <- vector("list", n)
for (i in 1:n) x[[i]] <- i
}, times = 20)
## Unit: milliseconds
## expr min lq mean median
## grow_by_index 258.584783 261.639465 299.781601 263.896162
## preallocated 7.151352 7.222043 7.372342 7.257661
## uq max neval cld
## 351.887538 375.447134 20 b
## 7.382103 8.612665 20 a
结果是清晰的:密集地扩展列表可以显著减慢代码,而在范围内修改预分配的列表则很快。同样的逻辑也适用于原子向量和矩阵。在 R 中扩展数据结构通常很慢,因为它会触发重新分配,即把原始数据结构复制到新的内存地址。这在 R 中非常昂贵,尤其是当数据量很大时。
然而,精确的预分配并不总是可行的,因为这要求我们在迭代之前知道总数。有时,我们可能只能反复请求一个结果来存储,而不知道确切的总量。在这种情况下,预先分配一个合理长度的列表或向量可能仍然是一个好主意。当迭代结束时,如果迭代次数没有达到预分配的长度,我们可以取列表或向量的子集。这样,我们可以避免数据结构的密集重新分配。
提升代码性能
在上一节中,我们展示了如何使用分析工具来识别代码中的性能瓶颈。在本节中,你将了解许多提升代码性能的方法。
使用内置函数
之前,我们展示了my_cumsum1()、my_cumsum2()和内置函数cumsum()之间的性能差异。尽管my_cumsum2()比my_cumsum1()快,但当输入向量包含许多数字时,cumsum()比它们快得多。而且,即使输入变长,其性能也不会显著下降。如果我们评估cumsum,我们可以看到它是一个原始函数:
cumsum
## function (x) .Primitive("cumsum")
R 中的原始函数是用 C/C++/Fortran 实现的,编译成本地指令,因此效率极高。另一个例子是diff()。在这里,我们将实现 R 中的向量差分序列计算:
diff_for <- function(x) {
n <- length(x) - 1
res <- numeric(n)
for (i in seq_len(n)) {
res[[i]] <- x[[i + 1]] - x[[i]]
}
res
}
我们可以验证实现是否正确:
diff_for(c(2, 3, 1, 5))
## [1] 1 -2 4
因此,diff_for()和内置的diff()必须对相同的输入返回相同的结果:
x <- rnorm(1000)
all.equal(diff_for(x), diff(x))
## [1] TRUE
然而,这两个函数在性能上存在很大的差距。
microbenchmark(diff_for(x), diff(x))
## Unit: microseconds
## expr min lq mean median
## diff_for(x) 1034.028 1078.9075 1256.01075 1139.1270
## diff(x) 12.057 14.2535 21.72772 17.5705
## uq max neval cld
## 1372.1145 2634.128 100 b
## 25.4525 75.850 100 a
内置函数在大多数情况下都比等效的 R 实现要快得多。这不仅适用于向量函数,也适用于矩阵。例如,这里是一个简单的 3 行 4 列的整数矩阵:
mat <- matrix(1:12, nrow = 3)
mat
## [,1] [,2] [,3] [,4]
## [1,] 1 4 7 10
## [2,] 2 5 8 11
## [3,] 3 6 9 12
我们可以编写一个函数来转置矩阵:
my_transpose <- function(x) {
stopifnot(is.matrix(x))
res <- matrix(vector(mode(x), length(x)),
nrow = ncol(x), ncol = nrow(x),
dimnames = dimnames(x)[c(2, 1)])
for (i in seq_len(ncol(x))) {
for (j in seq_len(nrow(x))) {
res[i, j] <- x[j, i]
}
}
res
}
在函数中,我们首先创建一个与输入相同类型的矩阵,但行和列的数量和名称分别交换。然后,我们将遍历列和行以转置矩阵:
my_transpose(mat)
## [,1] [,2] [,3]
## [1,] 1 2 3
## [2,] 4 5 6
## [3,] 7 8 9
## [4,] 10 11 12
矩阵转置的内置函数是t()。我们可以很容易地验证这两个函数返回相同的结果:
all.equal(my_transpose(mat), t(mat))
## [1] TRUE
然而,它们在性能上可能存在很大差异:
microbenchmark(my_transpose(mat), t(mat))
## Unit: microseconds
## expr min lq mean median uq
## my_transpose(mat) 22.795 24.633 29.47941 26.0865 35.5055
## t(mat) 1.576 1.978 2.87349 2.3375 2.7695
## max neval cld
## 71.509 100 b
## 16.171 100 a
当输入矩阵更大时,性能差异变得更加显著。在这里,我们将创建一个新的矩阵,具有1000行和25列。虽然结果相同,但性能可能会有很大差异:
mat <- matrix(rnorm(25000), nrow = 1000)
all.equal(my_transpose(mat), t(mat))
## [1] TRUE
microbenchmark(my_transpose(mat), t(mat))
## Unit: microseconds
## expr min lq mean
## my_transpose(mat) 21786.241 22456.3990 24466.055
## t(mat) 36.611 46.2045 61.047
## median uq max neval cld
## 23821.5905 24225.142 113395.811 100 b
## 57.7505 68.694 142.126 100 a
注意,t()是一个通用函数,它既适用于矩阵也适用于数据框。S3 调度以找到适合输入的正确方法,这也带来了一些开销。因此,直接在矩阵上调用t.default()会稍微快一些:
microbenchmark(my_transpose(mat), t(mat), t.default(mat))
## Unit: microseconds
## expr min lq mean
## my_transpose(mat) 21773.751 22498.6420 23673.26089
## t(mat) 37.853 48.8475 63.57713
## t.default(mat) 35.518 41.0305 52.97680
## median uq max neval cld
## 23848.6625 24139.7675 29034.267 100 b
## 61.3565 69.6655 140.061 100 a
## 46.3095 54.0655 146.755 100 a
所有之前的例子都表明,在大多数情况下,如果提供了内置函数,使用它们要比在 R 中重新发明轮子要好得多。这些函数消除了 R 代码的开销,因此即使输入很大,也可以非常高效。
使用向量操作
内置函数的特殊子集是算术运算符,如+、-、*、/、^和%%。这些运算符不仅效率极高,而且支持向量操作。
假设我们在 R 中实现+操作符:
add <- function(x, y) {
stopifnot(length(x) == length(y),
is.numeric(x), is.numeric(y))
z <- numeric(length(x))
for (i in seq_along(x)) {
z[[i]] <- x[[i]] + y[[i]]
}
z
}
然后,我们将随机生成x和y。add(x, y)和x + y参数应该返回完全相同的结果:
x <- rnorm(10000)
y <- rnorm(10000)
all.equal(add(x, y), x + y)
## [1] TRUE
以下基准测试表明性能差异巨大:
microbenchmark(add(x, y), x + y)
## Unit: microseconds
## expr min lq mean median
## add(x, y) 9815.495 10055.7045 11478.95003 10712.7710
## x + y 10.260 12.0345 17.31862 13.3995
## uq max neval cld
## 12598.366 18754.504 100 b
## 22.208 56.969 100 a
现在,假设我们需要计算前n个正整数的倒数平方和。我们可以很容易地使用for循环实现算法,如下面的函数algo1_for所示:
algo1_for <- function(n) {
res <- 0
for (i in seq_len(n)) {
res <- res + 1 /i ^ 2
}
res
}
该函数接受一个输入n,迭代n次以累积,然后返回结果。
更好的方法是直接使用向量化计算,而不需要任何 for 循环的必要性,就像 algo1_vec() 的实现一样:
algo1_vec <- function(n) {
sum(1 / seq_len(n) ^ 2)
}
这两个函数在普通输入下产生相同的结果:
algo1_for(10)
## [1] 1.549768
algo1_vec(10)
## [1] 1.549768
然而,它们的性能却非常不同:
microbenchmark(algo1_for(200), algo1_vec(200))
## Unit: microseconds
## expr min lq mean median uq
## algo1_for(200) 91.727 101.2285 104.26857 103.6445 105.632
## algo1_vec(200) 2.465 2.8015 3.51926 3.0355 3.211
## max neval cld
## 206.295 100 b
## 19.426 100 a
microbenchmark(algo1_for(1000), algo1_vec(1000))
## Unit: microseconds
## expr min lq mean median
## algo1_for(1000) 376.335 498.9320 516.63954 506.859
## algo1_vec(1000) 8.718 9.1175 9.82515 9.426
## uq max neval cld
## 519.2420 1823.502 100 b
## 9.8955 20.564 100 a
向量化是编写 R 代码的强烈推荐方式。它不仅性能高,而且使代码更容易理解。
使用字节码编译器
在上一节中,我们看到了向化的强大功能。然而,有时问题要求使用 for 循环,而代码难以向量化。在这种情况下,我们可能考虑使用 R 字节码编译器编译函数,这样函数就不再需要解析,可能运行得更快。
首先,我们将加载与 R 一起分发的编译器包。我们将使用 cmpfun() 编译给定的 R 函数。例如,我们将编译 diff_for() 并将编译后的函数存储为 diff_cmp():
library(compiler)
diff_cmp <- cmpfun(diff_for)
diff_cmp
## function(x) {
## n <- length(x) - 1
## res <- numeric(n)
## for (i in seq_len(n)) {
## res[[i]] <- x[[i + 1]] - x[[i]]
## }
## res
## }
## <bytecode: 0x93aec08>
当我们查看 diff_cmp() 时,它与 diff_for() 并没有太大的不同,但它有一个额外的 bytecode 地址标签。
然后,我们将再次使用 diff_cmp() 运行基准测试:
x <- rnorm(10000)
microbenchmark(diff_for(x), diff_cmp(x), diff(x))
## Unit: microseconds
## expr min lq mean median
## diff_for(x) 10664.387 10940.0840 11684.3285 11357.9330
## diff_cmp(x) 732.110 740.7610 760.1985 751.0295
## diff(x) 80.824 91.2775 107.8473 103.8535
## uq max neval cld
## 12179.98 16606.291 100 c
## 763.66 1015.234 100 b
## 115.11 219.396 100 a
看起来很神奇,编译版本 diff_cmp() 比原始的 diff_for() 快得多,尽管我们没有修改任何东西,只是将其编译成字节码。
现在,我们将使用 algo1_for() 做同样的事情:
algo1_cmp <- cmpfun(algo1_for)
algo1_cmp
## function(n) {
## res <- 0
## for (i in seq_len(n)) {
## res <- res + 1 / i ^ 2
## }
## res
## }
## <bytecode: 0xa87e2a8>
然后,我们将包含编译版本进行基准测试:
n <- 1000
microbenchmark(algo1_for(n), algo1_cmp(n), algo1_vec(n))
## Unit: microseconds
## expr min lq mean median uq
## algo1_for(n) 490.791 499.5295 509.46589 505.7560 517.5770
## algo1_cmp(n) 55.588 56.8355 58.10490 57.8270 58.7140
## algo1_vec(n) 8.688 9.2150 9.79685 9.4955 9.8895
## max neval cld
## 567.680 100 c
## 69.734 100 b
## 19.765 100 a
再次,编译版本比原始版本快了六倍以上,即使我们没有改变任何代码。
然而,如果用于编译完全向量化函数,编译并不是魔法。在这里,我们将编译 algo1_vec() 并将其性能与原始版本进行比较:
algo1_vec_cmp <- cmpfun(algo1_vec)
microbenchmark(algo1_vec(n), algo1_vec_cmp(n), times = 10000)
## Unit: microseconds
## expr min lq mean median uq
## algo1_vec(n) 8.47 8.678 20.454858 8.812 9.008
## algo1_vec_cmp(n) 8.35 8.560 9.701012 8.687 8.864
## max neval cld
## 96376.483 10000 a
## 1751.431 10000 a
注意,编译函数没有显示出明显的性能提升。要了解更多关于编译器如何工作,请输入 ?compile 并阅读文档。
使用 Intel MKL 驱动的 R 分发
我们通常使用的 R 分发是单线程的,也就是说,只有一个 CPU 线程用于执行所有 R 代码。好处是执行模型简单且安全,但它没有利用多核计算。
Microsoft R Open (MRO,见 mran.microsoft.com/open/) 是 R 的增强版分发。由 Intel Math Kernel Library (MKL,见 software.intel.com/en-us/intel-mkl) 驱动,MRO 通过自动利用多线程计算来增强矩阵算法。在多核计算机上,MRO 在矩阵乘法、Cholesky 分解、QR 分解、奇异值分解、主成分分析和线性判别分析等方面,可以比官方 R 实现快 10-80 倍。更多详情,请访问 mran.microsoft.com/documents/rro/multithread/ 并查看基准测试。
使用并行计算
正如我们在上一节中提到的,R 在设计上是单线程的,但仍然允许多进程并行计算,即运行多个 R 会话进行计算。这项技术由一个并行库支持,该库也随 R 一起分发。
假设我们需要进行一个模拟:我们需要生成一个遵循特定随机过程的随机路径,并查看在任意一点,值是否超出围绕起点的固定边界。
以下代码生成一个实现:
set.seed(1)
sim_data <- 100 * cumprod(1 + rnorm(500, 0, 0.006))
plot(sim_data, type = "s", ylim = c(85, 115),
main = "A simulated random path")
abline(h = 100, lty = 2, col = "blue")
abline(h = 100 * (1 + 0.1 * c(1, -1)), lty = 3, col = "red")
生成的图表如下所示:

前面的图表显示了路径和 10% 的边界。很明显,在索引 300 到 500 之间,值多次超出上限边界。
这只是其中一条路径。一个有效的模拟需要生成器运行足够多的次数,以产生具有统计意义的成果。以下函数参数化了随机路径生成器,并返回一系列感兴趣的总结指标。请注意,signal 表示路径上的任何一点是否超出边界:
simulate <- function(i, p = 100, n = 10000,
r = 0, sigma = 0.0005, margin = 0.1) {
ps <- p * cumprod(1 + rnorm(n, r, sigma))
list(id = i,
first = ps[[1]],
high = max(ps),
low = min(ps),
last = ps[[n]],
signal = any(ps > p * (1 + margin) | ps < p * (1 - margin)))
}
然后,我们可以运行生成器一次,并查看其总结结果:
simulate(1)
## $id
## [1] 1
##
## $first
## [1] 100.0039
##
## $high
## [1] 101.4578
##
## $low
## [1] 94.15108
##
## $last
## [1] 96.13973
##
## $signal
## [1] FALSE
要进行模拟,我们需要多次运行该函数。在实践中,我们可能需要运行至少数百万次实现,这可能会花费我们相当多的时间。在这里,我们将测量运行这个模拟的一万次迭代的耗时:
system.time(res <- lapply(1:10000, simulate))
## user system elapsed
## 8.768 0.000 8.768
模拟完成后,我们可以将所有结果转换成一个数据表:
library(data.table)
res_table <- rbindlist(res)
head(res_table)
## id first high low last signal
## 1: 1 100.03526 100.7157 93.80330 100.55324 FALSE
## 2: 2 100.03014 104.7150 98.85049 101.97831 FALSE
## 3: 3 99.99356 104.9834 95.28500 95.59243 FALSE
## 4: 4 99.93058 103.4315 96.10691 97.22223 FALSE
## 5: 5 99.99785 100.6041 94.12958 95.97975 FALSE
## 6: 6 100.03235 102.1770 94.65729 96.49873 FALSE
我们可以计算 signal == TRUE 的实现概率:
res_table[, sum(signal) /.N]
## [1] 0.0881
如果问题变得更加实际,需要我们运行数百万次,在这种情况下,一些研究人员可能会转向使用性能更高的编程语言,如 C 和 C++,这些语言非常高效且灵活。它们在实现算法方面是很好的工具,但需要更多努力来处理编译器、链接器和数据输入/输出。
注意,前面模拟中的每次迭代都是完全独立的,因此最好通过并行计算来完成。
由于不同的操作系统对进程和线程模型有不同的实现,Linux 和 MacOS 上可用的某些功能在 Windows 上不可用。因此,在 Windows 上进行并行计算可能需要更多的说明。
在 Windows 上使用并行计算
在 Windows 上,我们需要创建一个由多个 R 会话组成的本地集群以运行并行计算:
library(parallel)
cl <- makeCluster(detectCores())
detectCores() 函数返回计算机配备的核心数。创建超过该数量节点的集群是允许的,但通常没有好处,因为计算机不能同时执行超过该数量的任务。
然后,我们可以调用 parLapply(),它是 lapply() 的并行版本:
system.time(res <- parLapply(cl, 1:10000, simulate))
## user system elapsed
## 0.024 0.008 3.772
注意,消耗的时间减少到原始时间的一半以上。现在,我们不再需要集群。我们可以调用 stopCluster() 来终止刚刚创建的 R 会话:
stopCluster(cl)
当我们调用 parLapply() 时,它将自动为每个集群节点调度任务。更具体地说,所有集群节点同时以 1:10000 之一独占运行 simulate(),以便并行计算。最后,收集所有结果,以便我们得到一个类似于 lapply() 的结果列表:
length(res)
## [1] 10000
res_table <- rbindlist(res)
res_table[, sum(signal) /.N]
## [1] 0.0889
并行代码看起来很简单,因为 simulate() 是自包含的,并且不依赖于用户定义的外部变量或数据集。如果我们并行运行一个引用主会话(创建集群的当前会话)中变量的函数,它将找不到该变量:
cl <- makeCluster(detectCores())
n <- 1
parLapply(cl, 1:3, function(x) x + n)
## Error in checkForRemoteErrors(val): 3 nodes produced errors; first error: object 'n' not found
stopCluster(cl)
所有节点都失败了,因为每个节点都是以一个全新的 R 会话启动的,没有定义用户变量。为了让集群节点获取它们需要的变量值,我们必须将它们导出到所有节点。
以下示例演示了它是如何工作的。假设我们有一个数字的数据框。我们想要从这个数据框中抽取随机样本:
n <- 100
data <- data.frame(id = 1:n, x = rnorm(n), y = rnorm(n))
take_sample <- function(n) {
data[sample(seq_len(nrow(data)),
size = n, replace = FALSE), ]
}
如果我们在并行中进行抽样,所有节点必须共享数据框和函数。为此,我们可以使用 clusterEvalQ() 在每个集群节点上评估一个表达式。首先,我们将创建一个集群,就像我们之前做的那样:
cl <- makeCluster(detectCores())
Sys.getpid() 函数返回当前 R 会话的进程 ID。由于集群中有四个节点,每个节点都是一个具有唯一进程 ID 的 R 会话。我们可以使用 clusterEvalQ() 和 Sys.getpid() 来查看每个节点的进程 ID:
clusterEvalQ(cl, Sys.getpid())
## [[1]]
## [1] 20714
##
## [[2]]
## [1] 20723
##
## [[3]]
## [1] 20732
##
## [[4]]
## [1] 20741
要查看每个节点的全局环境中的变量,我们可以调用 ls(),就像我们在自己的工作环境中调用一样:
clusterEvalQ(cl, ls())
## [[1]]
## character(0)
##
## [[2]]
## character(0)
##
## [[3]]
## character(0)
##
## [[4]]
## character(0)
正如我们提到的,所有集群节点默认情况下都是初始化为空的全球环境。要将 data 和 take_sample 导出至每个节点,我们可以调用 clusterExport():
clusterExport(cl, c("data", "take_sample"))
clusterEvalQ(cl, ls())
## [[1]]
## [1] "data" "take_sample"
##
## [[2]]
## [1] "data" "take_sample"
##
## [[3]]
## [1] "data" "take_sample"
##
## [[4]]
## [1] "data" "take_sample"
现在,我们可以看到所有节点都有 data 和 take_sample。现在,我们可以让每个节点调用 take_sample():
clusterEvalQ(cl, take_sample(2))
## [[1]]
## id x y
## 88 88 0.6519981 1.43142886
## 80 80 0.7985715 -0.04409101
##
## [[2]]
## id x y
## 65 65 -0.4705287 -1.0859630
## 35 35 0.6240227 -0.3634574
##
## [[3]]
## id x y
## 75 75 0.3994768 -0.1489621
## 8 8 1.4234844 1.8903637
##
## [[4]]
## id x y
## 77 77 0.4458477 1.420187
## 9 9 0.3943990 -0.196291
或者,我们可以使用 clusterCall() 和 <<- 在每个节点中创建全局变量,而 <- 只在函数中创建局部变量:
invisible(clusterCall(cl, function() {
local_var <- 10
global_var <<- 100
}))
clusterEvalQ(cl, ls())
## [[1]]
## [1] "data" "global_var" "take_sample"
##
## [[2]]
## [1] "data" "global_var" "take_sample"
##
## [[3]]
## [1] "data" "global_var" "take_sample"
##
## [[4]]
## [1] "data" "global_var" "take_sample"
注意,clusterCall() 返回每个节点的返回值。在前面代码中,我们将使用 invisible() 来抑制它们返回的值。
由于每个集群节点都是以一个全新的状态启动的,它们只加载基本包。为了让每个节点加载给定的包,我们也可以使用 clusterEvalQ()。以下代码让每个节点附加 data.table 包,以便 parLapply() 可以在每个节点上运行使用 data.table 函数的函数:
clusterExport(cl, "simulate")
invisible(clusterEvalQ(cl, {
library(data.table)
}))
res <- parLapply(cl, 1:3, function(i) {
res_table <- rbindlist(lapply(1:1000, simulate))
res_table[, id := NULL]
summary(res_table)
})
返回一个数据摘要列表:
res
## [[1]]
## first high low
## Min. : 99.86 Min. : 99.95 Min. : 84.39
## 1st Qu.: 99.97 1st Qu.:101.44 1st Qu.: 94.20
## Median :100.00 Median :103.32 Median : 96.60
## Mean :100.00 Mean :103.95 Mean : 96.04
## 3rd Qu.:100.03 3rd Qu.:105.63 3rd Qu.: 98.40
## Max. :100.17 Max. :121.00 Max. :100.06
## last signal
## Min. : 84.99 Mode :logical
## 1st Qu.: 96.53 FALSE:911
## Median : 99.99 TRUE :89
## Mean : 99.92 NA's :0
## 3rd Qu.:103.11
## Max. :119.66
##
## [[2]]
## first high low
## Min. : 99.81 Min. : 99.86 Min. : 83.67
## 1st Qu.: 99.96 1st Qu.:101.48 1st Qu.: 94.32
## Median :100.00 Median :103.14 Median : 96.42
## Mean :100.00 Mean :103.91 Mean : 96.05
## 3rd Qu.:100.04 3rd Qu.:105.76 3rd Qu.: 98.48
## Max. :100.16 Max. :119.80 Max. :100.12
## last signal
## Min. : 85.81 Mode :logical
## 1st Qu.: 96.34 FALSE:914
## Median : 99.69 TRUE :86
## Mean : 99.87 NA's :0
## 3rd Qu.:103.31
## Max. :119.39
##
## [[3]]
## first high low
## Min. : 99.84 Min. : 99.88 Min. : 85.88
## 1st Qu.: 99.97 1st Qu.:101.61 1st Qu.: 94.26
## Median :100.00 Median :103.42 Median : 96.72
## Mean :100.00 Mean :104.05 Mean : 96.12
## 3rd Qu.:100.03 3rd Qu.:105.89 3rd Qu.: 98.35
## Max. :100.15 Max. :117.60 Max. :100.03
## last signal
## Min. : 86.05 Mode :logical
## 1st Qu.: 96.70 FALSE:920
## Median :100.16 TRUE :80
## Mean :100.04 NA's :0
## 3rd Qu.:103.24
## Max. :114.80
当我们不再需要集群时,我们将运行以下代码来释放它:
stopCluster(cl)
在 Linux 和 MacOS 上使用并行计算
在 Linux 和 MacOS 上使用并行计算可能比在 Windows 上更容易。无需手动创建基于套接字的集群,mclapply() 直接将当前的 R 会话分叉成多个 R 会话,保留所有内容以继续并行运行,并为每个子 R 会话调度任务:
system.time(res <- mclapply(1:10000, simulate,
mc.cores = detectCores()))
## user system elapsed
## 9.732 0.060 3.415
因此,我们不需要导出变量,因为它们在每个分叉过程中都是立即可用的:
mclapply(1:3, take_sample, mc.cores = detectCores())
## [[1]]
## id x y
## 62 62 0.1679572 -0.5948647
##
## [[2]]
## id x y
## 56 56 1.5678983 0.08655707
## 39 39 0.1015022 -1.98006684
##
## [[3]]
## id x y
## 98 98 0.13892696 -0.1672610
## 4 4 0.07533799 -0.6346651
## 76 76 -0.57345242 -0.5234832
此外,我们可以创建具有很大灵活性的并行作业。例如,我们将创建一个生成 10 个随机数的作业:
job1 <- mcparallel(rnorm(10), "job1")
只要创建了作业,我们就可以选择使用 mccollect() 收集作业的结果。然后,函数将不会返回,直到作业完成:
mccollect(job1)
## $`20772`
## [1] 1.1295953 -0.6173255 1.2859549 -0.9442054 0.1482608
## [6] 0.4242623 0.9463755 0.6662561 0.4313663 0.6231939
我们还可以通过编程创建多个并行运行的作业。例如,我们创建 8 个作业,每个作业随机休眠一段时间。然后,mccollect() 不会返回,直到所有作业都完成休眠。由于作业是并行运行的,mccollect() 所花费的时间不会太长:
jobs <- lapply(1:8, function(i) {
mcparallel({
t <- rbinom(1, 5, 0.6)
Sys.sleep(t)
t
}, paste0("job", i))
})
system.time(res <- mccollect(jobs))
## user system elapsed
## 0.012 0.040 4.852
这允许我们自定义任务调度机制。
使用 Rcpp
正如我们提到的,并行计算在每次迭代都是独立的情况下才会工作,这样最终结果就不会依赖于执行顺序。然而,并非所有任务都像这样理想。因此,并行计算的使用可能会受到影响。如果我们真的希望算法运行得快并且容易与 R 交互,答案是通过 Rcpp 将算法用 C++ 编写(www.rcpp.org/)。
C++ 代码通常运行得非常快,因为它被编译成本地指令,因此比像 R 这样的脚本语言更接近硬件级别。Rcpp 是一个包,它使我们能够以无缝的方式将 R 和 C++ 集成在一起编写 C++ 代码。通过 Rcpp,我们可以编写可以调用 R 函数并利用 R 数据结构的 C++ 代码。它允许我们编写高性能的代码,同时保留 R 中数据操作的能力。
要使用 Rcpp,我们首先需要确保系统已经准备好使用正确的工具链来计算本地代码。在 Windows 上,需要 Rtools,可以在 cran.r-project.org/bin/windows/Rtools/ 找到。在 Linux 和 MacOS 上,需要正确安装的 C/C++ 工具链。
一旦工具链安装正确,运行以下代码来安装包:
install.packages("Rcpp")
然后,我们将在 code/rcpp-demo.cpp 创建一个 C++ 源文件,其中包含以下代码:
#include <Rcpp.h>
usingnamespace Rcpp;
// [[Rcpp::export]]
NumericVector timesTwo(NumericVector x) {
return x * 2;
}
上述代码是用 C++ 编写的。如果你不熟悉 C++ 语法,可以通过访问 www.learncpp.com/ 快速掌握最简单的部分。C++ 的语言设计和支持的功能比 R 更丰富、更复杂。不要期望在短时间内成为专家,但通常通过学习基础知识,你可以编写简单的算法。
如果你阅读前面的代码,它看起来与典型的 R 代码非常不同。由于 C++是强类型语言,我们需要指定函数参数的类型和函数的返回类型。带有[[Rcpp::export]]注释的函数将被 Rcpp 捕获,当我们使用 RStudio 中的source命令或直接使用Rcpp::sourceCpp时,这些 C++函数将被自动编译并移植到我们的 R 工作环境中。
前面的 C++函数简单地接受一个数值向量,并返回一个新的数值向量,其中所有x元素都被加倍。请注意,NumericVector类是由源文件开头包含的Rcpp.h提供的。实际上,Rcpp.h提供了所有常用 R 数据结构的 C++代理。现在,我们将调用Rcpp::sourceCpp()来编译和加载源文件:
Rcpp::sourceCpp("code/rcpp-demo.cpp")
函数编译源代码,将其链接到必要的共享库,并将 R 函数暴露给环境。美的是,所有这些操作都是自动完成的,这使得非专业 C++开发者编写算法变得更加容易。现在,我们有一个可以调用的 R 函数:
timesTwo
## function (x)
## .Primitive(".Call")(<pointer: 0x7f81735528c0>, x)
我们可以看到,R 中的timeTwo看起来不像一个普通函数,而是对 C++函数的本地调用。该函数与单个数值输入一起工作:
timesTwo(10)
## [1] 20
它也可以与多元素数值向量一起工作:
timesTwo(c(1, 2, 3))
## [1] 2 4 6
现在,我们可以使用非常简单的 C++语言结构重新实现algo1_for算法。现在,我们将在code/rcpp-algo1.cpp创建一个 C++源文件,包含以下代码:
#include <Rcpp.h>
using namespace Rcpp;
// [[Rcpp::export]]
double algo1_cpp(int n) {
double res = 0;
for (double i = 1; i < n; i++) {
res += 1 / (i * i);
}
return res;
}
注意,我们在algo1_cpp中没有使用任何 R 数据结构,而是使用 C++数据结构。当我们使用source命令时,Rcpp 将为我们处理所有移植工作:
Rcpp::sourceCpp("code/rcpp-algo1.cpp")
函数与单个数值输入一起工作:
algo1_cpp(10)
## [1] 1.539768
如果我们提供一个数值向量,将会发生错误:
algo1_cpp(c(10, 15))
## Error in eval(expr, envir, enclos): expecting a single value
现在,我们可以再次进行基准测试。这次,我们将algo1_cpp添加到替代实现列表中。在这里,我们将比较使用 R 中的for循环的版本、使用 R 中的 for 循环的字节码编译版本、矢量化版本和 C++版本:
n <- 1000
microbenchmark(
algo1_for(n),
algo1_cmp(n),
algo1_vec(n),
algo1_cpp(n))
## Unit: microseconds
## expr min lq mean median uq
## algo1_for(n) 493.312 507.7220 533.41701 513.8250 531.5470
## algo1_cmp(n) 57.262 59.1375 61.44986 60.0160 61.1190
## algo1_vec(n) 10.091 10.8340 11.60346 11.3045 11.7735
## algo1_cpp(n) 5.493 6.0765 7.13512 6.6210 7.2775
## max neval cld
## 789.799 100 c
## 105.260 100 b
## 23.007 100 a
## 22.131 100 a
真是令人惊讶,C++版本甚至比矢量化版本还要快。尽管矢量化版本使用的函数是原始函数并且已经非常快,但由于方法调度和参数检查,它们仍然有一些开销。我们的 C++版本针对任务进行了优化,因此可以稍微快于矢量化版本。
另一个例子是diff_for()的 C++实现,如下所示:
#include <Rcpp.h>
usingnamespace Rcpp;
// [[Rcpp::export]]
NumericVector diff_cpp(NumericVector x) {
NumericVector res(x.size() - 1);
for (int i = 0; i < x.size() - 1; i++) {
res[i] = x[i + 1] - x[i];
}
return res;
}
在前面的 C++代码中,diff_cpp()接受一个数值向量并返回一个数值向量。该函数简单地创建一个新的向量,并迭代地计算并存储x中连续两个元素之间的差异。然后,我们将源代码文件:
Rcpp::sourceCpp("code/rcpp-diff.cpp")
很容易验证函数是否按预期工作:
diff_cpp(c(1, 2, 3, 5))
## [1] 1 1 2
然后,我们将使用五种不同的调用再次进行基准测试:在 R 中使用循环的版本(diff_for)、字节码编译版本(diff_cmp)、向量化版本(diff)、不带方法调用的向量化版本(diff.default)以及我们的 C++版本(diff_cpp):
x <- rnorm(1000)
microbenchmark(
diff_for(x),
diff_cmp(x),
diff(x),
diff.default(x),
diff_cpp(x))
## Unit: microseconds
## expr min lq mean median
## diff_for(x) 1055.177 1113.8875 1297.82994 1282.9675
## diff_cmp(x) 75.511 78.4210 88.46485 88.2135
## diff(x) 12.899 14.9340 20.64854 18.3975
## diff.default(x) 10.750 11.6865 13.90939 12.6400
## diff_cpp(x) 5.314 6.4260 8.62119 7.5330
## uq max neval cld
## 1400.8250 2930.690 100 c
## 90.3485 179.620 100 b
## 24.2335 65.172 100 a
## 15.3810 25.455 100 a
## 8.9570 54.455 100 a
看起来 C++版本是最快的。
近年来,越来越多的 R 包开始使用 Rcpp 来提高性能或直接链接到提供高性能算法的流行库。例如,RcppArmadillo 和 RcppEigen 提供了高性能的线性代数算法,RcppDE 提供了 C++中全局优化差分演化的快速实现,等等。
要了解更多关于 Rcpp 和相关包的信息,请访问其官方网站 (www.rcpp.org/)。我还推荐 Rcpp 的作者 Dirk Eddelbuettel 所著的书籍Seamless R and C++ Integration with Rcpp,可在www.rcpp.org/book/找到。
OpenMP
正如我们在并行计算部分提到的,R 会话在一个单独的线程中运行。然而,在 Rcpp 代码中,我们可以使用多线程来提高性能。一种多线程技术是 OpenMP (openmp.org),它被大多数现代 C++编译器支持(参见openmp.org/wp/openmp-compilers/))。
几篇文章讨论并展示了 Rcpp 与 OpenMP 的使用,可在gallery.rcpp.org/tags/openmp/找到。在这里,我们将提供一个简单的示例。我们将在code/rcpp-diff-openmp.cpp创建一个包含以下代码的 C++源文件:
// [[Rcpp::plugins(openmp)]]
#include <omp.h>
#include <Rcpp.h>
usingnamespace Rcpp;
// [[Rcpp::export]]
NumericVector diff_cpp_omp(NumericVector x) {
omp_set_num_threads(3);
NumericVector res(x.size() - 1);
#pragma omp parallel for
for (int i = 0; i < x.size() - 1; i++) {
res[i] = x[i + 1] - x[i];
}
return res;
}
注意,Rcpp 将识别第一行的注释并为编译器添加必要的选项,以便启用 OpenMP。要使用 OpenMP,我们需要包含omp.h。然后,我们可以通过调用omp_set_num_threads(n)来设置线程数,并使用#pragma omp parallel for来指示下面的循环应该并行化。如果线程数设置为1,则代码也可以正常运行。
我们将源 C++代码文件:
Rcpp::sourceCpp("code/rcpp-diff-openmp.cpp")
首先,让我们看看这个函数是否工作正常:
diff_cpp_omp(c(1, 2, 4, 8))
## [1] 1 2 4
然后,我们将使用 1000 个数字的输入向量开始基准测试:
x <- rnorm(1000)
microbenchmark(
diff_for(x),
diff_cmp(x),
diff(x),
diff.default(x),
diff_cpp(x),
diff_cpp_omp(x))
## Unit: microseconds
## expr min lq mean median
## diff_for(x) 1010.367 1097.9015 1275.67358 1236.7620
## diff_cmp(x) 75.729 78.6645 88.20651 88.9505
## diff(x) 12.615 16.4200 21.13281 20.5400
## diff.default(x) 10.555 12.1690 16.07964 14.8210
## diff_cpp(x) 5.640 6.4825 8.24118 7.5400
## diff_cpp_omp(x) 3.505 4.4390 26.76233 5.6625
## uq max neval cld
## 1393.5430 2839.485 100 c
## 94.3970 186.660 100 b
## 24.4260 43.893 100 a
## 18.4635 72.940 100 a
## 8.6365 50.533 100 a
## 13.9585 1430.605 100 a
不幸的是,即使使用多线程,diff_cpp_omp()也比其单线程的 C++实现慢。这是因为使用多线程有一些开销。如果输入较小,初始化多个线程所需的时间可能会占整个计算时间的很大一部分。然而,如果输入足够大,多线程的优势将超过其成本。在这里,我们将使用100000个数字作为输入向量:
x <- rnorm(100000)
microbenchmark(
diff_for(x),
diff_cmp(x),
diff(x),
diff.default(x),
diff_cpp(x),
diff_cpp_omp(x))
## Unit: microseconds
## expr min lq mean
## diff_for(x) 112216.936 114617.4975 121631.8135
## diff_cmp(x) 7355.241 7440.7105 8800.0184
## diff(x) 863.672 897.2060 1595.9434
## diff.default(x) 844.186 877.4030 3451.6377
## diff_cpp(x) 418.207 429.3125 560.3064
## diff_cpp_omp(x) 125.572 149.9855 237.5871
## median uq max neval cld
## 115284.377 116165.3140 214787.857 100 c
## 7537.405 8439.9260 102712.582 100 b
## 1029.642 2195.5620 8020.990 100 a
## 931.306 2365.6920 99832.513 100 a
## 436.638 552.5110 2165.091 100 a
## 166.834 190.7765 1983.299 100 a
创建多个线程的成本相对于使用它们带来的性能提升来说是小的。因此,由 OpenMP 驱动的版本甚至比简单的 C++版本还要快。
实际上,OpenMP 的功能集比我们所展示的丰富得多。更多详情,请参阅官方文档。更多示例,我推荐阅读 Joel Yliluoma 所著的《OpenMP 指南:C++的简单多线程编程》,链接为bisqwit.iki.fi/story/howto/openmp/。
RcppParallel
利用 Rcpp 利用多线程的另一种方法是 RcppParallel (rcppcore.github.io/RcppParallel/)。这个包包括 Intel TBB (www.threadingbuildingblocks.org/) 和 TinyThread (tinythreadpp.bitsnbites.eu/)。它提供了线程安全的向量和矩阵包装数据结构以及高级并行函数。
要使用 RcppParallel 执行多线程并行计算,我们需要实现一个Worker来处理如何将输入的一部分转换为输出。然后,RcppParallel 将负责其余的工作,例如多线程任务调度。
下面是一个简短的演示。我们将在code/rcpp-parallel.cpp创建一个包含以下代码的 C++源文件。请注意,我们需要向 Rcpp 声明它依赖于 RcppParallel,并使用 C++ 11 来使用 lambda 函数。
在这里,我们将实现一个名为Transformer的Worker,它将矩阵中的每个元素x转换为1 / (1 + x ^ 2)。然后,在par_transform中,我们将创建一个Transformer实例,并使用它调用parallelFor,以便自动利用多线程:
// [[Rcpp::plugins(cpp11)]]
// [[Rcpp::depends(RcppParallel)]]
#include <Rcpp.h>
#include <RcppParallel.h>
using namespace Rcpp;
using namespace RcppParallel;
struct Transformer : public Worker {
const RMatrix<double> input;
RMatrix<double> output;
Transformer(const NumericMatrix input, NumericMatrix output)
: input(input), output(output) {}
void operator()(std::size_t begin, std::size_t end) {
std::transform(input.begin() + begin, input.begin() + end,
output.begin() + begin, [](double x) {
return 1 / (1 + x * x);
});
}
};
// [[Rcpp::export]]
NumericMatrix par_transform (NumericMatrix x) {
NumericMatrix output(x.nrow(), x.ncol());
Transformer transformer(x, output);
parallelFor(0, x.length(), transformer);
return output;
}
我们可以轻松地通过一个小矩阵验证该函数是否工作:
mat <- matrix(1:12, nrow = 3)
mat
## [,1] [,2] [,3] [,4]
## [1,] 1 4 7 10
## [2,] 2 5 8 11
## [3,] 3 6 9 12
par_transform(mat)
## [,1] [,2] [,3] [,4]
## [1,] 0.5 0.05882353 0.02000000 0.009900990
## [2,] 0.2 0.03846154 0.01538462 0.008196721
## [3,] 0.1 0.02702703 0.01219512 0.006896552
all.equal(par_transform(mat), 1 /(1 + mat ^ 2))
## [1] TRUE
它产生的结果与向量化的 R 表达式完全相同。现在,我们可以看看当输入矩阵非常大时它的性能表现:
mat <- matrix(rnorm(1000 * 2000), nrow = 1000)
microbenchmark(1 /(1 + mat ^ 2), par_transform(mat))
## Unit: milliseconds
## expr min lq mean median
## 1/(1 + mat ^ 2) 14.50142 15.588700 19.78580 15.768088
## par_transform(mat) 7.73545 8.654449 13.88619 9.277798
## uq max neval cld
## 18.79235 127.1912 100 b
## 11.65137 110.6236 100 a
看起来,多线程版本几乎比向量化版本快 1 倍。
RcppParallel 的功能比我们所展示的更强大。更多详细介绍和示例,请访问rcppcore.github.io/RcppParallel。
摘要
在本章中,你学习了性能可能重要或不重要的时刻,如何测量 R 代码的性能,如何使用分析工具来识别代码中最慢的部分,以及为什么这样的代码可能会慢。然后,我们介绍了提高代码性能最重要的方法:如果可能,使用内置函数,利用向量化,使用字节码编译器,使用并行计算,通过 Rcpp 用 C++编写代码,以及在 C++中使用多线程技术。高性能计算是一个相当高级的话题,如果你想在实践中应用它,还有很多东西要学习。本章表明,使用 R 并不意味着代码总是慢。相反,如果我们想,我们可以实现高性能。
在下一章中,我们将介绍另一个有用的主题:网页抓取。要从网页中抓取数据,我们需要了解网页的结构以及如何从它们的源代码中提取数据。你将学习 HTML、XML 和 CSS 的基本概念和表示方法,以及如何分析目标网页,以便我们能够正确地从网页中提取所需的信息。
第十四章。网络爬取
R 提供了一个易于访问统计计算和数据分析的平台。给定一个数据集,使用灵活的数据结构或高性能,可以方便地进行数据转换并应用分析模型和数值方法,如前几章所述。
然而,输入数据集并不总是像有组织商业数据库提供的表格那样立即可用。有时,我们必须自己收集数据。网络内容是众多研究领域的重要数据来源。为了从互联网收集(爬取或收割)数据,我们需要适当的技术和工具。在本章中,我们将介绍网络爬取的基本知识和工具,包括:
-
查看网页内部
-
学习 CSS 和 XPath 选择器
-
分析 HTML 代码并提取数据
查看网页内部
网页是为了展示信息而制作的。以下截图显示了位于 data/simple-page.html 的简单网页,它包含一个标题和一个段落:

所有现代网络浏览器都支持此类网页。如果你用任何文本编辑器打开 data/simple-page.html,它将显示网页背后的代码如下:
<!DOCTYPE html>
<html>
<head>
<title>Simple page</title>
</head>
<body>
<h1>Heading 1</h1>
<p>This is a paragraph.</p>
</body>
</html>
上述代码是 HTML(超文本标记语言)的一个示例。它是互联网上使用最广泛的语言。与任何最终被翻译成计算机指令的编程语言不同,HTML 描述了网页的布局和内容,而网络浏览器被设计成根据网络标准将代码渲染成网页。
现代网络浏览器使用 HTML 的第一行来确定使用哪种标准来渲染网页。在这种情况下,使用的是最新的标准,HTML 5。
如果你阅读代码,你可能会注意到 HTML 仅仅是一系列嵌套的标签,如 <html>、<title>、<body>、<h1> 和 <p>。每个标签以 <tag> 开头,并以 </tag> 结尾。
实际上,这些标签并不是随意命名的,也不允许它们包含其他任意标签。每个标签对网络浏览器都有特定的含义,并且只允许包含标签的子集,甚至没有任何标签。
<html> 标签是所有 HTML 的根元素。它通常包含 <head> 和 <body>。<head> 标签通常包含 <title> 以显示在标题栏和浏览器标签页上,以及网页的其他元数据,而 <body> 在确定网页布局和内容方面扮演主要角色。
在 <body> 标签中,标签可以更自由地嵌套。简单的页面只包含一级标题 (<h1>) 和一个段落 (<p>),而下面的网页包含一个有两行两列的表格:

网页背后的 HTML 代码存储在 data/single-table.html 中:
<!DOCTYPE html>
<html>
<head>
<title>Single table</title>
</head>
<body>
<p>The following is a table</p>
<table id="table1" border="1">
<thead>
<tr>
<th>Name</th>
<th>Age</th>
</tr>
</thead>
<tbody>
<tr>
<td>Jenny</td>
<td>18</td>
</tr>
<tr>
<td>James</td>
<td>19</td>
</tr>
</tbody>
</table>
</body>
</html>
注意,<table> 标签是按行构建的:<tr> 代表表格行,<th> 代表表头单元格,<td> 代表表格单元格。
注意,HTML 元素如<table>可能有额外的属性,形式为<table attr1="value1" attr2="value2">。这些属性不是任意定义的。相反,每个属性都有根据标准指定的特定含义。在前面代码中,id是表格的标识符,border控制其边框宽度。
以下页面与之前的页面不同,因为它显示了内容的某些样式:

如果你查看其源代码data/simple-products.html,你会发现一些新的标签,如<div>(一个部分),<ul>(未记录的列表),<li>(列表项),以及<span>(也是一个用于应用样式的部分);此外,许多 HTML 元素都有一个名为style的属性来定义其外观:
<!DOCTYPE html>
<html>
<head>
<title>Products</title>
</head>
<body>
<h1 style="color: blue;">Products</h1>
<p>The following lists some products</p>
<div id="table1" style="width: 50px;">
<ul>
<li>
<span style="font-weight: bold;">Product-A</span>
<span style="color: green;">$199.95</span>
</li>
<li>
<span style="font-weight: bold;">Product-B</span>
<span style="color: green;">$129.95</span>
</li>
<li>
<span style="font-weight: bold;">Product-C</span>
<span style="color: green;">$99.95</span>
</li>
</ul>
</div>
</body>
</html>
样式值以property1: value1; property2: value2;的形式书写。然而,列表项的样式有些冗余,因为所有产品名称都共享相同的样式,这也适用于所有产品价格。以下data/products.html中的 HTML 使用 CSS(层叠样式表)来避免冗余的样式定义:
<!DOCTYPE html>
<html>
<head>
<title>Products</title>
<style>
h1 {
color: darkblue;
}
.product-list {
width: 50px;
}
.product-list li.selected .name {
color: 1px blue solid;
}
.product-list .name {
font-weight: bold;
}
.product-list .price {
color: green;
}
</style>
</head>
<body>
<h1>Products</h1>
<p>The following lists some products</p>
<div id="table1" class="product-list">
<ul>
<li>
<span class="name">Product-A</span>
<span class="price">$199.95</span>
</li>
<li class="selected">
<span class="name">Product-B</span>
<span class="price">$129.95</span>
</li>
<li>
<span class="name">Product-C</span>
<span class="price">$99.95</span>
</li>
</ul>
</div>
</body>
</html>
注意,我们在<head>中添加了<style>来声明网页中的全局样式表。我们还把内容元素(div、li和span)的style切换到class,以使用这些预定义的样式。CSS 的语法在以下代码中简要介绍。
匹配所有<h1>元素:
h1 {
color: darkblue;
}
匹配所有具有product-list类的元素:
.product-list {
width: 50px;
}
匹配所有具有product-list类的元素,然后匹配所有嵌套的具有name类的元素:
.product-list .name {
font-weight: bold;
}
匹配所有具有product-list类的元素,然后匹配所有嵌套的<li>元素具有selected类,最后匹配所有嵌套的具有name类的元素:
.product-list li.selected .name {
color: 1px blue solid;
}
注意,仅使用style无法实现这一点。以下截图显示了渲染的网页:

每个 CSS 条目由一个 CSS 选择器(例如,.product-list)组成,用于匹配 HTML 元素,以及要应用的样式(例如,color: red;)。CSS 选择器不仅用于应用样式,而且通常用于从网页中提取内容,以便正确匹配感兴趣的 HTML 元素。这是网络爬取背后的基本技术。
CSS 比前面代码中展示的要丰富得多。对于网络爬取,我们使用以下示例来展示最常用的 CSS 选择器:
| 语法 | 匹配 |
|---|---|
* |
所有元素 |
h1, h2, h3 |
<h1>,<h2>,<h3> |
#table1 |
<* id="table1"> |
.product-list |
<* class="product-list"> |
div#container |
<div id="container"> |
div a |
<div><a>和<div><p><a> |
div > a |
<div><a>,但不包括<div><p><a> |
div > a.new |
<div><a class="new"> |
ul > li:first-child |
<ul>中的第一个<li> |
ul > li:last-child |
<ul>中的最后一个<li> |
ul > li:nth-child(3) |
<ul>中的第三个<li> |
p + * |
<p> 的下一个元素 |
img[title] |
带有标题属性的 <img> |
table[border=1] |
<table border="1"> |
在每个级别,tag#id.class[] 可以与 tag、#id.class 以及可选的 [] 一起使用。有关 CSS 选择器的更多信息,请访问 developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors。要了解更多关于 HTML 标签的信息,请访问 www.w3schools.com/tags/.
使用 CSS 选择器从网页中提取数据
在 R 中,用于网络抓取的最易用的包是 rvest。运行以下代码从 CRAN 安装该包:
install.packages("rvest")
首先,我们加载包并使用 read_html() 读取 data/single-table.html 并尝试从网页中提取表格:
library(rvest)
## Loading required package: xml2
single_table_page <- read_html("data/single-table.html")
single_table_page
## {xml_document}
## <html>
## [1] <head>\n <title>Single table</title>\n</head>
## [2] <body>\n <p>The following is a table</p>\n <table i ...
注意,single_table_page 是一个解析后的 HTML 文档,它是一个嵌套的 HTML 节点数据结构。
使用 rvest 函数从此类网页抓取信息的典型过程是:首先,定位我们需要从中提取数据的 HTML 节点。然后,使用 CSS 选择器或 XPath 表达式过滤 HTML 节点,以便所需的节点被选中,而不需要的节点被省略。最后,使用适当的选择器通过 html_nodes() 获取节点子集,html_attrs() 提取属性,以及 html_text() 从解析的网页中提取文本。
该包还提供了直接从网页中提取数据并返回数据框的简单函数。例如,要从中提取所有 <table> 元素,我们直接调用 html_table():
html_table(single_table_page)
## [[1]]
## Name Age
## 1 Jenny 18
## 2 James 19
要提取第一个 <table> 元素,我们使用 html_node() 通过 CSS 选择器 table 选择第一个节点,然后使用 html_table() 与节点一起获取数据框:
html_table(html_node(single_table_page, "table"))
## Name Age
## 1 Jenny 18
## 2 James 19
做这件事的一个更自然的方法是使用管道,就像使用 dplyr 函数中的 %>% 一样,如第十二章中介绍的 Chapter 12,数据操作。回想一下,%>% 实际上评估 x %>% f(...) 为 f(x, ...),这样嵌套调用可以展开并变得更加易读。前面的代码可以重写为以下使用 %>%:
single_table_page %>%
html_node("table") %>%
html_table()
## Name Age
## 1 Jenny 18
## 2 James 19
现在,我们读取 data/products.html 并使用 html_nodes() 匹配 <span class="name"> 节点:
products_page <- read_html("data/products.html")
products_page %>%
html_nodes(".product-list li .name")
## {xml_nodeset (3)}
## [1] <span class="name">Product-A</span>
## [2] <span class="name">Product-B</span>
## [3] <span class="name">Product-C</span>
注意,我们想要选择的节点是 product-list 类节点中的 <li> 节点的 name 类,因此我们可以使用 .product-list li.name 来选择所有这样的节点。如果你对这种表示法不熟悉,请查看 CSS 表。
要从选定的节点中提取内容,我们使用 html_text(),它返回一个字符向量:
products_page %>%
html_nodes(".product-list li .name") %>%
html_text()
## [1] "Product-A" "Product-B" "Product-C"
类似地,以下代码提取产品价格:
products_page %>%
html_nodes(".product-list li .price") %>%
html_text()
## [1] "$199.95" "$129.95" "$99.95"
在前面的代码中,html_nodes() 返回一组 HTML 节点,而 html_text() 足够智能,可以从每个 HTML 节点中提取内部文本,并返回一个字符向量。
注意,这些价格仍然以原始格式表示为字符串,而不是数字。以下代码提取相同的数据并将其转换为更有用的形式:
product_items <- products_page %>%
html_nodes(".product-list li")
products <- data.frame(
name = product_items %>%
html_nodes(".name") %>%
html_text(),
price = product_items %>%
html_nodes(".price") %>%
html_text() %>%
gsub("$", "", ., fixed = TRUE) %>%
as.numeric(),
stringsAsFactors = FALSE
)
products
## name price
## 1 Product-A 199.95
## 2 Product-B 129.95
## 3 Product-C 99.95
注意,所选节点的中间结果可以存储为变量并重复使用。然后后续的 html_nodes() 和 html_node() 调用仅匹配内部节点。
由于产品价格应该是数值,我们使用 gsub() 从原始价格中删除 $ 并将结果转换为数值向量。在管道中 gsub() 的调用有些特殊,因为前一个结果(用 . 表示)应该放在第三个参数而不是第一个参数。
在这种情况下,.product-list li .name 可以简化为 .name,同样也适用于 .product-list li .price。然而,在实际应用中,CSS 类可能被广泛使用,这样的通用选择器可能会匹配到太多不希望匹配的元素。因此,最好使用更描述性和足够严格的选择器来匹配感兴趣的节点。
学习 XPath 选择器
在上一节中,我们学习了 CSS 选择器以及如何使用它们,以及 rvest 包提供的函数来从网页中提取内容。
CSS 选择器足够强大,可以满足大多数 HTML 节点匹配的需求。然而,有时需要更强大的技术来选择满足更特殊条件的节点。
看一下以下网页,它比 data/products.html 稍微复杂一些:

这个网页存储在 data/new-products.html 的独立 HTML 文件中。完整的源代码很长,我们只显示 <body> 部分。请查看源代码以了解其结构:
<body>
<h1>New Products</h1>
<p>The following is a list of products</p>
<div id="list" class="product-list">
<ul>
<li>
<span class="name">Product-A</span>
<span class="price">$199.95</span>
<div class="info bordered">
<p>Description for Product-A</p>
<ul>
<li><span class="info-key">Quality</span> <span class="info-value">Good</span></li>
<li><span class="info-key">Duration</span> <span class="info-value">5</span><span class="unit">years</span></li>
</ul>
</div>
</li>
<li class="selected">
<span class="name">Product-B</span>
<span class="price">$129.95</span>
<div class="info">
<p>Description for Product-B</p>
<ul>
<li><span class="info-key">Quality</span> <span class="info-value">Medium</span></li>
<li><span class="info-key">Duration</span> <span class="info-value">2</span><span class="unit">years</span></li>
</ul>
</div>
</li>
<li>
<span class="name">Product-C</span>
<span class="price">$99.95</span>
<div class="info">
<p>Description for Product-C</p>
<ul>
<li><span class="info-key">Quality</span> <span class="info-value">Good</span></li>
<li><span class="info-key">Duration</span> <span class="info-value">4</span><span class="unit">years</span></li>
</ul>
</div>
</li>
</ul>
</div>
<p>All products are available for sale!</p>
</body>
网页的源代码包含样式表和详细信息的商品列表。每个商品都有一个描述和更多属性以显示。在以下代码中,我们以与之前示例相同的方式加载网页:
page <- read_html("data/new-products.html")
HTML 代码的结构简单明了。在深入研究 XPath 之前,我们需要了解一些关于 XML 的知识。编写良好且组织有序的 HTML 文档基本上可以被视为 XML(扩展标记语言)文档的专门化。与 HTML 不同,XML 允许任意标签和属性。以下是一个简单的 XML 文档:
<?xml version="1.0"?>
<root>
<product id="1">
<name>Product-A<name>
<price>$199.95</price>
</product>
<product id="2">
<name>Product-B</name>
<price>$129.95</price>
</product>
</root>
XPath 是一种专为从 XML 文档中提取数据而设计的技巧。在本节中,我们将比较 XPath 表达式与 CSS 选择器,并了解它们如何有助于从网页中提取数据。
html_node() 和 html_nodes() 通过 xpath= 参数支持 XPath 表达式。以下表格显示了 CSS 选择器和等效 XPath 表达式之间的一些重要比较:
| CSS | XPath | 匹配 |
|---|---|---|
li > * |
//li/* |
<li> 的所有子节点 |
li[attr] |
//li[@attr] |
所有具有 attr 属性的 <li> |
li[attr=value] |
//li[@attr='value'] |
<li attr="value"> |
li#item |
//li[@id='item'] |
<li id="item"> |
li.info |
//li[contains(@class,'info')] |
<li class="info"> |
li:first-child |
//li[1] |
第一个<li> |
li:last-child |
//li[last()] |
最后一个<li> |
li:nth-child(n) |
//li[n] |
第n个<li> |
| (N/A) | //p[a] |
所有包含子<a>的<p>元素 |
| (N/A) | //p[position() <= 5] |
前五个<p>节点 |
| (N/A) | //p[last()-2] |
最后第三个<p> |
| (N/A) | //li[value>0.5] |
所有具有子<value>且其值> 0.5的<li>元素 |
注意,CSS 选择器通常匹配所有子级别的节点。在 XPath 中,//标签和/标签被定义为以不同的方式匹配节点。更具体地说,//标签指的是所有子级别的<tag>节点,而/标签仅指第一子级别的<tag>节点。
为了展示用法,以下是一些示例:
选择所有<p>节点:
page %>% html_nodes(xpath = "//p")
## {xml_nodeset (5)}
## [1] <p>The following is a list of products</p>
## [2] <p>Description for Product-A</p>
## [3] <p>Description for Product-B</p>
## [4] <p>Description for Product-C</p>
## [5] <p>All products are available for sale!</p>
选择所有具有class属性的<li>元素:
page %>% html_nodes(xpath = "//li[@class]")
## {xml_nodeset (1)}
## [1] <li class="selected">\n <span class="name">Pro ...
选择<div id="list"><ul>的子元素<li>:
page %>% html_nodes(xpath = "//div[@id='list']/ul/li")
## {xml_nodeset (3)}
## [1] <li>\n <span class="name">Product-A</span>\n ...
## [2] <li class="selected">\n <span class="name">Pro ...
## [3] <li>\n <span class="name">Product-C</span>\n ...
选择<div id="list">内部<li>的子元素<span class="name">:
page %>% html_nodes(xpath = "//div[@id='list']//li/span[@class='name']")
## {xml_nodeset (3)}
## [1] <span class="name">Product-A</span>
## [2] <span class="name">Product-B</span>
## [3] <span class="name">Product-C</span>
在<li class="selected">中,选择所有作为子元素的<span class="name">:
page %>%
html_nodes(xpath = "//li[@class='selected']/span[@class='name']")
## {xml_nodeset (1)}
## [1] <span class="name">Product-B</span>
所有的前述示例都可以用等效的 CSS 选择器实现。然而,以下示例无法用 CSS 选择器实现。
选择所有具有子<p>的<div>:
page %>% html_nodes(xpath = "//div[p]")
## {xml_nodeset (3)}
## [1] <div class="info bordered">\n <p>Description ...
## [2] <div class="info">\n <p>Description for Prod ...
## [3] <div class="info">\n <p>Description for Prod ...
选择所有<span class="info-value">Good</span>:
page %>%
html_nodes(xpath = "//span[@class='info-value' and text()='Good']")
## {xml_nodeset (2)}
## [1] <span class="info-value">Good</span>
## [2] <span class="info-value">Good</span>
选择所有品质优良的产品名称:
page %>%
html_nodes(xpath = "//li[div/ul/li[1]/span[@class='info-value' and text()='Good']]/span[@class='name']")
## {xml_nodeset (2)}
## [1] <span class="name">Product-A</span>
## [2] <span class="name">Product-C</span>
选择所有持续时间超过三年的产品名称:
page %>%
html_nodes(xpath = "//li[div/ul/li[2]/span[@class='info-value' and text()>3]]/span[@class='name']")
## {xml_nodeset (2)}
## [1] <span class="name">Product-A</span>
## [2] <span class="name">Product-C</span>
XPath 非常灵活,可以成为匹配网页节点的一个强大工具。要了解更多信息,请访问www.w3schools.com/xsl/xpath_syntax.aspac。
分析 HTML 代码和提取数据
在前面的章节中,我们学习了 HTML、CSS 和 XPath 的基础知识。为了抓取现实世界的网页,现在的问题变成了编写正确的 CSS 或 XPath 选择器。在本节中,我们将介绍一些简单的方法来确定有效选择器。
假设我们想要抓取cran.rstudio.com/web/packages/available_packages_by_name.html上所有可用的 R 包。网页看起来很简单。为了确定选择器表达式,在表格上右键单击,并在上下文菜单中选择检查元素,这通常在大多数现代网络浏览器中可用:

然后会出现检查器面板,我们可以看到网页的底层 HTML。在 Firefox 和 Chrome 中,选中的节点会被突出显示,以便更容易定位:

HTML 中包含一个唯一的<table>,因此我们可以直接使用table来选择它,并使用html_table()将其提取出来作为数据框:
page <- read_html("https://cran.rstudio.com/web/packages/available_packages_by_name.html")
pkg_table <- page %>%
html_node("table") %>%
html_table(fill = TRUE)
head(pkg_table, 5)
## X1
## 1
## 2 A3
## 3 abbyyR
## 4 abc
## 5 ABCanalysis
## X2
## 1 <NA>
## 2 Accurate, Adaptable, and Accessible Error Metrics for Predictive\nModels
## 3 Access to Abbyy Optical Character Recognition (OCR) API
## 4 Tools for Approximate Bayesian Computation (ABC)
## 5 Computed ABC Analysis
注意,原始表格没有标题。结果的数据框使用默认标题,并且第一行是空的。以下代码是为了修复这些问题:
pkg_table <- pkg_table[complete.cases(pkg_table), ]
colnames(pkg_table) <- c("name", "title")
head(pkg_table, 3)
## name
## 2 A3
## 3 abbyyR
## 4 abc
## title
## 2 Accurate, Adaptable, and Accessible Error Metrics for Predictive\nModels
## 3 Access to Abbyy Optical Character Recognition (OCR) API
## 4 Tools for Approximate Bayesian Computation (ABC)
下一个例子是提取 MSFT 在finance.yahoo.com/quote/MSFT的最新股价。使用元素检查器,我们发现价格包含在一个由程序生成的非常长的类别的<span>中:

向上查看几个层级,我们可以找到一个路径,div#quote-header-info > section > span,用来导航到这个节点。因此,我们可以使用这个 CSS 选择器来查找并提取股价:
page <- read_html("https://finance.yahoo.com/quote/MSFT")
page %>%
html_node("div#quote-header-info > section > span") %>%
html_text() %>%
as.numeric()
## [1] 56.68
在网页的右侧,有一个公司关键统计数据的表格:

在提取之前,我们再次检查表格及其包围的节点,并尝试找到一个选择器可以导航到这个表格:

显然,感兴趣的<table>被一个<div id="key-statistics">包围。因此,我们可以直接使用#key-statistics table来匹配表格节点并将其转换为数据框:
page %>%
html_node("#key-statistics table") %>%
html_table()
## X1 X2
## 1 Market Cap 442.56B
## 2 P/E Ratio (ttm) 26.99
## 3 Diluted EPS N/A
## 4 Beta 1.05
## 5 Earnings Date N/A
## 6 Dividend & Yield 1.44 (2.56%)
## 7 Ex-Dividend Date N/A
## 8 1y Target Est N/A
使用类似的技术,我们可以创建一个函数,根据股票代码(例如,MSFT)返回公司名称和价格:
get_price <- function(symbol) {
page <- read_html(sprintf("https://finance.yahoo.com/quote/%s", symbol))
list(symbol = symbol,
company = page %>%
html_node("div#quote-header-info > div:nth-child(1) > h6") %>%
html_text(),
price = page %>%
html_node("div#quote-header-info > section > span:nth-child(1)") %>%
html_text() %>%
as.numeric())
}
CSS 选择器足够限制性,可以导航到正确的 HTML 节点。为了测试这个函数,我们运行以下代码:
get_price("AAPL")
## $symbol
## [1] "AAPL"
##
## $company
## [1] "Apple Inc."
##
## $price
## [1] 104.19
另一个例子是抓取stackoverflow.com/questions/tagged/r?sort=votes上的顶级 R 问题,如下所示:

使用类似的方法,很容易发现问题列表包含在一个id为questions的容器中。因此,我们可以加载页面并使用#questions选择器和存储问题容器:
page <- read_html("https://stackoverflow.com/questions/tagged/r?sort=votes&pageSize=5")
questions <- page %>%
html_node("#questions")
为了提取问题标题,我们仔细查看第一个问题的 HTML 结构:

很容易发现每个问题标题都包含在<div class="summary"><h3>中:
questions %>%
html_nodes(".summary h3") %>%
html_text()
## [1] "How to make a great R reproducible example?"
## [2] "How to sort a dataframe by column(s)?"
## [3] "R Grouping functions: sapply vs. lapply vs. apply. vs. tapply vs. by vs. aggregate"
## [4] "How to join (merge) data frames (inner, outer, left, right)?"
## [5] "How can we make xkcd style graphs?"
注意,<a class="question-hyperlink">也提供了一个更简单的 CSS 选择器,可以返回相同的结果:
questions %>%
html_nodes(".question-hyperlink") %>%
html_text()
如果我们对每个问题的投票也感兴趣,我们可以再次检查投票,看看它们如何用 CSS 选择器描述:

幸运的是,所有投票面板具有相同的结构,并且找出它们的模式相当直接。每个问题都包含在一个具有question-summary类的<div>中,其中投票在一个具有.vote-count-post类的<span>中:
questions %>%
html_nodes(".question-summary .vote-count-post") %>%
html_text() %>%
as.integer()
## [1] 1429 746 622 533 471
类似地,以下代码提取了答案的数量:
questions %>%
html_nodes(".question-summary .status strong") %>%
html_text() %>%
as.integer()
## [1] 21 15 8 11 7
如果我们继续提取每个问题的标签,这会变得有点棘手,因为不同的问题可能有不同数量的标签。在下面的代码中,我们首先选择所有问题的标签容器,并通过迭代提取每个容器中的标签。
questions %>%
html_nodes(".question-summary .tags") %>%
lapply(function(node) {
node %>%
html_nodes(".post-tag") %>%
html_text()
}) %>%
str
## List of 5
## $ : chr [1:2] "r" "r-faq"
## $ : chr [1:4] "r" "sorting" "dataframe" "r-faq"
## $ : chr [1:4] "r" "sapply" "tapply" "r-faq"
## $ : chr [1:5] "r" "join" "merge" "dataframe" ...
## $ : chr [1:2] "r" "ggplot2"
所有的前序抓取都在一个网页中完成。如果我们需要跨多个网页收集数据怎么办?假设我们访问每个问题的页面(例如,stackoverflow.com/q/5963269/2906900)。注意,右上角有一个信息框。我们需要提取列表中每个问题的此类信息框:

检查告诉我们 #qinfo 是每个问题页面上信息框的关键。然后我们可以选择所有问题超链接,提取所有问题的 URL,遍历它们,读取每个问题页面,并使用该键提取信息框:
questions %>%
html_nodes(".question-hyperlink") %>%
html_attr("href") %>%
lapply(function(link) {
paste0("https://stackoverflow.com", link) %>%
read_html() %>%
html_node("#qinfo") %>%
html_table() %>%
setNames(c("item", "value"))
})
## [[1]]
## item value
## 1 asked 5 years ago
## 2 viewed 113698 times
## 3 active 7 days ago
##
## [[2]]
## item value
## 1 asked 6 years ago
## 2 viewed 640899 times
## 3 active 2 months ago
##
## [[3]]
## item value
## 1 asked 5 years ago
## 2 viewed 221964 times
## 3 active 1 month ago
##
## [[4]]
## item value
## 1 asked 6 years ago
## 2 viewed 311376 times
## 3 active 15 days ago
##
## [[5]]
## item value
## 1 asked 3 years ago
## 2 viewed 53232 times
## 3 active 4 months ago
除了所有这些,rvest 还支持创建 HTTP 会话以模拟页面导航。要了解更多信息,请阅读rvest文档。对于许多抓取任务,你也可以通过使用selectorgadget.com/提供的工具来简化选择器的查找。
网络抓取还有更多高级技术,如使用 JavaScript 处理 AJAX 和动态网页,但这些内容超出了本章的范围。更多用法,请参阅rvest包的文档。
注意,rvest 主要受到 Python 包 Robobrowser 和 BeautifulSoup 的启发。这些包在某些方面比rvest更强大,因此在某些方面的网络抓取中更受欢迎。如果源代码复杂且规模大,你可能需要学习如何使用这些 Python 包。更多信息请访问www.crummy.com/software/BeautifulSoup/。
摘要
在本章中,我们学习了网页是如何用 HTML 编写的,以及如何通过 CSS 进行样式化。CSS 选择器可以用来匹配 HTML 节点,以便提取其内容。编写良好的 HTML 文档也可以通过 XPath 表达式进行查询,它具有更多功能和更大的灵活性。然后我们学习了如何使用现代网络浏览器的元素检查器来确定一个限制性选择器来匹配感兴趣的 HTML 节点,从而可以从网页中提取所需的数据。
在下一章中,我们将学习一系列提高你生产力的技术,从 R Markdown 文档、图表到交互式 shiny 应用。这些工具使得创建高质量、可重复和交互式的文档变得容易得多,这是展示数据、思想和原型的好方法。
第十五章。提高生产力
在前一章中,我们学习了如何使用 R 从网页中提取信息。为了理解其工作原理,我们学习了 HTML、CSS 和 XPath 等几种语言。实际上,R 提供的不仅仅是统计计算环境。R 社区提供了从数据收集、数据操作、统计建模、可视化到报告和演示等一切工具。
在本章中,我们将了解一些提高我们生产力的软件包。我们将回顾本书中我们学习过的几种语言,并了解另一种:Markdown。我们将看到 R 和 Markdown 如何结合以生成强大的动态文档。具体来说,我们将:
-
了解 Markdown 和 R Markdown
-
嵌入表格、图表、图表和交互式图表
-
创建交互式应用程序
编写 R Markdown 文档
数据分析师的工作不仅仅是将数据放入模型并得出一些结论。我们通常需要经历一个完整的工作流程,从数据收集、数据清洗、可视化、建模,最后编写报告或进行演示。
在前几章中,我们从不同方面学习了 R 编程语言,从而提高了我们的生产力。在本章中,我们将通过关注最后一步:报告和演示,进一步提升我们的生产力。在接下来的几节中,我们将学习一种非常简单的语言来编写文档:Markdown。
了解 Markdown
在本书的整个过程中,我们已经学习了许多语言。这些语言各不相同,可能会让初学者感到困惑。但如果你记住它们的目的,使用起来就不会太难。在学习 Markdown 之前,我们将快速回顾一下前几章中我们学习过的语言。
第一当然是 R 编程语言。编程语言是为了解决问题而设计的。R 专门设计和定制用于统计计算,并由社区赋予能力,能够做许多其他事情;以下是一个示例:
n <- 100
x <- rnorm(n)
y <- 2 * x + rnorm(n)
m <- lm(y ~ x)
coef(m)
在第十二章《数据操作》中,我们学习了 SQL 来查询关系型数据库。它被设计成一种编程语言,但用于表达关系型数据库操作,如插入或更新记录和查询数据:
SELECT name, price
FROM products
WHERE category = 'Food'
ORDER BY price desc
R 编程语言由 R 解释器执行,SQL 由数据库引擎执行。然而,我们也学习了不是为执行而设计,而是为了表示数据的语言。在编程世界中,最常用的数据表示语言可能是 JSON 和 XML:
[
{
"id": 1,
"name": "Product-A",
"price": 199.95
},
{
"id": 2,
"name": "Product-B",
"price": 129.95
}
]
JSON 的规范定义了诸如值(1, "text")、数组[]和对象{}等元素,而 XML 不提供类型支持,但允许使用属性和节点:
<?xml version="1.0"?>
<root>
<product id="1">
<name>Product-A<name>
<price>$199.95</price>
</product>
<product id="2">
<name>Product-B</name>
<price>$129.95</price>
</product>
</root>
在上一章关于网络爬取的内容中,我们学习了 HTML 的基础知识,它与 XML 非常相似。由于 HTML 在内容表示和布局方面的灵活性,大多数网页都是用 HTML 编写的:
<!DOCTYPE html>
<html>
<head>
<title>Simple page</title>
</head>
<body>
<h1>Heading 1</h1>
<p>This is a paragraph.</p>
</body>
</html>
在本章中,我们将学习 Markdown,这是一种轻量级的标记语言,其语法专为纯文本格式化设计,并且可以转换为许多其他文档格式。熟悉 Markdown 后,我们将进一步学习 R Markdown,它专为动态文档设计,并得到 RStudio 及其社区的大力支持。该格式非常简单,我们可以使用任何纯文本编辑器来编写 Markdown 文档。
以下代码块显示了其语法:
# Heading 1
This is a top level section. This paragraph contains both __bold__ text and _italic_ text. There are more than one syntax to represent **bold** text and *italic* text.
## Heading 2
This is a second level section. The following are some bullets.
* Point 1
* Point 2
* Point 3
### Heading 3
This is a third level section. Here are some numbered bullets.
1\. hello
2\. world
Link: [click here](https://r-project.org)
Image: 
Image link: [](https://r-project.org)
语法极其简单:一些字符用于表示不同的格式。在纯文本编辑器中,我们无法预览这些格式,正如它所指示的那样。但是当转换为 HTML 文档时,文本将根据语法进行格式化。
以下截图显示了 Markdown 文档在 Abricotine (abricotine.brrd.fr/) 中的预览,这是一个开源的 Markdown 编辑器,具有实时预览功能:

此外,还有具有出色功能的在线 Markdown 编辑器。我最喜欢的一个是 StackEdit (stackedit.io/)。您可以在编辑器中创建一个新的空白文档,并将上面的 Markdown 文本复制进去,然后您就可以看到作为 HTML 页面的即时预览:

Markdown 在在线讨论中被广泛使用。最大的开源代码托管平台 GitHub (github.com) 支持使用 Markdown 编写问题,如下面的截图所示:

注意,反引号(`)用于创建源代码符号,以及三个反引号(py` X ```py`) are used to contain a code block written in language X. Code blocks are shown in fixed-width font which is better for presenting program code. Also, we can preview what we have written so far:

Another special symbol, $, is used to quote math formulas. Single dollar ($) indicates inline math whereas double dollars ($$) displays math (in a new line). The math formula should be written in LaTeX math syntax (en.wikibooks.org/wiki/LaTeX/Mathematics).
The following math equation is not that simple: $$x²+y²=z²$$, where \(x\),\(y\), and \(z\) are integers.
Not all markdown editors support the preview of math formulas. In StackEdit, the preceding markdown is previewed as follows:

In addition, many markdown renderers support the table syntax shown as follows:
| Sepal.Length | Sepal.Width | Petal.Length | Petal.Width | Species |
| --- | --- | --- | --- | --- |
|------------:|-----------:|------------:|-----------:|:-------|
| 5.1 | 3.5 | 1.4 | 0.2 | setosa |
| --- | --- | --- | --- | --- |
| 4.9 | 3.0 | 1.4 | 0.2 | setosa |
| 4.7 | 3.2 | 1.3 | 0.2 | setosa |
| 4.6 | 3.1 | 1.5 | 0.2 | setosa |
| 5.0 | 3.6 | 1.4 | 0.2 | setosa |
| 5.4 | 3.9 | 1.7 | 0.4 | setosa |
```py
In StackEdit, the preceding text table is rendered as follows:

## Integrating R into Markdown
Markdown is easy to write and read, and has most necessary features for writing reports such as simple text formatting, embedding images, links, tables, quotes, math formula, and code blocks.
Although writing plain texts in markdown is easy, creating reports with many images and tables is not, especially when the images and tables are produced dynamically by code. R Markdown is the killer app that integrates R into markdown.
More specifically, the markdowns we showed earlier in this chapter are all static documents; that is, they were determined when we wrote them. However, R Markdown is a combination of R code and markdown texts. The output of R code can be text, table, images, and interactive widgets. It can be rendered as an HTML web page, a PDF document, and even a Word document. Visit [`rmarkdown.rstudio.com/formats.html`](http://rmarkdown.rstudio.com/formats.html) to learn more about supported formats.
To create an R Markdown document, click the menu item, as shown in the following screenshot:

If you don't have `rmarkdown` and `knitr` installed, RStudio will install these necessary packages automatically. Then you can write a title and author and choose a default output format, as shown in the following screenshot:

Then a new R Markdown document will be created. The new document is not empty but a demo document that shows the basics of writing texts and embedding R code which produces images. In the template document, we can see some code chunks like:

The preceding chunk evaluates `summary(cars)` and will produce some text output:

The preceding chunk evaluates `plot(pressure)` and will produce an image. Note that we can specify options for each chunk in the form of `{r [chunk_name], [options]}` where `[chunk_name]` is optional and is used to name the produced image and `[options]` is optional and may specify whether the code should appear in the output document, the width and height of the produced graphics, and so on. To find more options, visit [`yihui.name/knitr/options/`](http://yihui.name/knitr/options/).
To render the document, just click on the **Knit** button:

When the document is properly saved to disk, RStudio will call functions to render the document into a web page. More specifically, the document is rendered in two steps:
1. The `knitr` module runs the code of each chunk and places the code and output according to the chunk options so that `Rmd` is fully rendered as a static markdown document.
2. The `pandoc` module renders the resulted markdown document as HTML, PDF, or DOCX according to the `Rmd` options specified in file header.
As we are editing an R Markdown document in RStudio, we can choose which format to produce anytime and then it will automatically call the `knitr` module to render the document into markdown and then run the `pandoc` module with the proper arguments to produce a document in that format. This can also be done with code using functions provided by `knitr` and `rmarkdown` modules.
In the new document dialog, we can also choose presentation and create slides using R Markdown. Since writing documents and writing slides are similar, we won't go into detail on this topic.
## Embedding tables and charts
Without R code chunks, R Markdown is no different from a plain markdown document. With code chunks, the output of code is embedded into the document so that the final content is dynamic. If a code chunk uses a random number generator without fixing the random seed, each time we knit the document we will get different results.
By default, the output of a code chunk is put directly beneath the code in fixed-width font starting with `##` as if the code is run in the console. This form of output works but is not always satisfactory, especially when we want to present the data in more straightforward forms.
### Embedding tables
When writing a report, we often need to put tables within the contents. In an R Markdown document, we can directly evaluate a `data.frame` variable. Suppose we have the following `data.frame`:
toys <- data.frame(
id = 1:3,
name = c("Car", "Plane", "Motocycle"),
price = c(15, 25, 14),
share = c(0.3, 0.1, 0.2),
stringsAsFactors = FALSE
)
To output the variable in plain text, we only need to type the variable name in a code chunk:
toys
id name price share
1 1 Car 15 0.3
2 2 Plane 25 0.1
3 3 Motocycle 14 0.2
Note that HTML, PDF, and Word documents all support native tables. To produce a native table for the chosen format, we can use `knitr::kable()` to produce the markdown representation of the table just like the following:
| id|name | price| share|
|--😐:---------|-----😐-----😐
| 1|Car | 15| 0.3|
| 2|Plane | 25| 0.1|
| 3|Motocycle | 14| 0.2|
When `pandoc` renders the resulted markdown document to other formats, it will produce a native table from the markdown representation:
knitr::kable(toys)
The table generated native table is shown as follows:
| **id** | **name** | **price** | **share** |
| 1 | Car | 15 | 0.3 |
| 2 | Plane | 25 | 0.1 |
| 3 | Motocycle | 14 | 0.2 |
There are other packages that produce native tables but with enhanced features. For example, the `xtable` package not only supports converting `data.frame` to LaTeX, it also provides pre-defined templates to present the results of a number of statistical models.
xtable::xtable(lm(mpg ~ cyl + vs, data = mtcars))
When the preceding code is knitted with the `results='asis'` option, the linear model will be shown as the following table in the output PDF document:

The most well-known data software is perhaps Microsoft Excel. A very interesting feature of Excel is conditional formatting. To implement such features in R, I developed `formattable` package. To install, run `install.packages("formattable")`. It enables cell formatting in a data frame to exhibit more comparative information:
library(formattable)
formattable(toys,
list(price = color_bar("lightpink"), share = percent))
The generated table is shown as follows:

Sometimes, the data has many rows, which makes embedding such a table into the document not a good idea. But JavaScript libraries such as DataTables ([`datatables.net/`](https://datatables.net/)) make it easier to embed large data sets in a web page because it automatically performs paging and also supports search and filtering. Since an R Markdown document can be rendered into an HTML web page, it is natural to leverage the JavaScript package. An R package called DT ([`rstudio.github.io/DT/`](http://rstudio.github.io/DT/)) ports DataTables to R data frames and we can easily put a large data set into a document to let the reader explore and inspect the data in detail:
library(DT)
datatable(mtcars)
The generated table is shown as follows:

The preceding packages, `formattable` and `DT` are two examples of a wide range of HTML widgets ([`www.htmlwidgets.org/`](http://www.htmlwidgets.org/)). Many of them are adapted from popular JavaScript libraries since there are already a good number of high quality JavaScript libraries in the community.
### Embedding charts and diagrams
Embedding charts is as easy as embedding tables as we demonstrated. If a code chunk produces a plot, `knitr` will save the image to a file with the name of the code chunk and write `name` below the code so that when `pandoc` renders the document the image will be found and inserted to the right place:
set.seed(123)
x <- rnorm(1000)
y <- 2 * x + rnorm(1000)
m <- lm(y ~ x)
plot(x, y, main = "线性回归", col = "darkgray")
abline(coef(m))
The plot generated is shown as follows:

The default image size may not apply to all scenarios. We can specify chunk options `fig.height` and `fig.width` to alter the size of the image.
In addition to creating charts with basic graphics and packages like `ggplot2`, we can also create diagrams and graphs using `DiagrammeR` package. To install the package from CRAN, run `install.packages("DiagrammeR")`.
This package uses Graphviz ([`en.wikipedia.org/wiki/Graphviz`](https://en.wikipedia.org/wiki/Graphviz)) to describe the relations and styling of a diagram. The following code produces a very simple directed graph:
library(DiagrammeR)
grViz("
digraph rmarkdown {
A -> B;
B -> C;
C -> A;
}")
The generated graph is shown as follows:

DiagrammeR also provides a more programmable way to construct diagrams. It exports a set of functions to perform operations on a graph. Each function takes a graph and outputs a modified graph. Therefore it is easy to use pipeline to connect all operations to produce a graph in a streamline. For more details, visit the package website at [`rich-iannone.github.io/DiagrammeR`](http://rich-iannone.github.io/DiagrammeR).
### Embedding interactive plots
Previously, we demonstrated both static tables (`knitr::kable`, `xtable`, and `formattable`) and interactive tables (`DT`). Similar things happen to plots too. We can not only place static images in the document as we did in the previous section, but also create dynamic and interactive plots in either the viewer or the output document.
In fact, there are more packages designed to produce interactive graphics than tables. Most of them take advantage of existing JavaScript libraries and make R data structures easier to work with them. In the following code, we introduce some of the most popular packages used to create interactive graphics.
The ggvis ([`ggvis.rstudio.com/`](http://ggvis.rstudio.com/)) developed by RStudio uses Vega ([`vega.github.io/vega/`](https://vega.github.io/vega/)) as its graphics backend:
library(ggvis)
mtcars %>%
ggvis(~mpg, ~disp, opacity := 0.6) %>%
layer_points(size := input_slider(1, 100, value = 50, label = "size")) %>%
layer_smooths(span = input_slider(0.5, 1, value = 1, label = "span"))
The plot generated is shown as follows:

Note that its grammar is a bit like `ggplot2`. It best works with a pipeline operator.
Another package is called `dygraphs` ([`rstudio.github.io/dygraphs/`](https://rstudio.github.io/dygraphs/)) which uses the JavaScript library ([`dygraphs.com/`](http://dygraphs.com/)) of the same name. This package specializes in plotting time series data with interactive capabilities.
In the following example, we use the temperature data of airports provided in the `nycflights13` package. To plot the daily temperature time series of each airport present in the data, we need to summarize the data by computing the mean temperature on each day, reshape the long-format data to wide-format, and convert the results to an `xts` time series object with a date index and temperature columns corresponding to each airport:
library(dygraphs)
library(xts)
library(dplyr)
library(reshape2)
data(weather, package = "nycflights13")
temp <- weather %>%
group_by(origin, year, month, day) %>%
summarize(temp = mean(temp)) %>%
ungroup() %>%
mutate(date = as.Date(sprintf("%d-%02d-%02d",
year, month, day))) %>%
select(origin, date, temp) %>%
dcast(date ~ origin, value.var = "temp")
temp_xts <- as.xts(temp[-1], order.by = temp[[1]])
head(temp_xts)
EWR JFK LGA
2013-01-01 38.4800 38.8713 39.23913
2013-01-02 28.8350 28.5425 28.72250
2013-01-03 29.4575 29.7725 29.70500
2013-01-04 33.4775 34.0325 35.26250
2013-01-05 36.7325 36.8975 37.73750
2013-01-06 37.9700 37.4525 39.70250
Then we supply `temp_xts` to `dygraph()` to create an interactive time series plot with a range selector and dynamic highlighting:
dygraph(temp_xts, main = "机场温度") %>%
dyRangeSelector() %>%
dyHighlight(highlightCircleSize = 3,
highlightSeriesBackgroundAlpha = 0.3,
hideOnMouseOut = FALSE)
The plot generated is shown as follows:

If the code is run in R terminal, the web browser will launch and show a web page containing the plot. If the code is run in RStudio, the plot will show up in the **Viewer** pane. If the code is a chunk in R Markdown document, the plot will be embedded into the rendered document.
The main advantage of interactive graphics over static plots is that interactivity allows users to further examine and explore the data rather than forcing users to view it from a fixed perspective.
There are other remarkable packages of interactive graphics. For example, `plotly` ([`plot.ly/r/`](https://plot.ly/r/)) and `highcharter` ([`jkunst.com/highcharter/`](http://jkunst.com/highcharter/)) are nice packages to produce a wide range of interactive plots based on JavaScript backends.
In addition to the features we demonstrated in the previous sections, R Markdown can also be used to create presentation slides, journal articles, books and websites. Visit the official website at [`rmarkdown.rstudio.com`](http://rmarkdown.rstudio.com) to learn more.
# Creating interactive apps
In the previous section, we demonstrated the use of R Markdown that is designed for creating dynamic documents. In this section, we will take a quick tour of creating interactive apps where we use a graphical user interface to interact with the data.
## Creating a shiny app
R itself is a great environment for data analysis and visualization. However, it is not usual to deliver R and some analytic scripts to the customers to run by themselves. The outcome of data analysis can be presented not only in a HTML page, PDF document, or a Word document, but also in an interactive app that allows readers to interact with the data by modifying some parameters and see what happens with the outcome.
A powerful package, `shiny` ([`shiny.rstudio.com/`](http://shiny.rstudio.com/)), developed by RStudio, is designed exactly for this purpose. A shiny app is different from the interactive graphics we demonstrated previously. It works in a web browser and the developer has all the say about what appears in the web page and how users can interact with it. To achieve this, a shiny app basically consists of two important parts: An HTTP server that interacts with the web browser, and an R session that interacts with the HTTP server.
The following is a minimal shiny app. We write an R script to define its user interface (`ui`) and `server` logic. The user interface is a `boostrapPage` which contains a `numericInput` to take an integer of sample size and a `textOutput` to show the mean of the randomly generated sample. The logic behind `server` is to simply generate random numbers according to the sample size (`n`) in the `input` and put the mean of the random sample to the `output`:
library(shiny)
ui <- bootstrapPage(
numericInput("n", label = "Sample size", value = 10, min = 10, max = 100),
textOutput("mean")
)
server <- function(input, output) {
output\(mean <- renderText(mean(rnorm(input\)n)))
}
app <- shinyApp(ui, server)
runApp(app)
The definition is now complete and we can source the code in RStudio to play with this minimal shiny app, as shown in the following screenshot:

Each time we change the number of the sample size, the HTTP server will ask the R backend to rerun the server logic and refresh the output mean.
Although the preceding example is not useful, it at least demonstrates the basic components of a shiny app. Now we look at a more complicated but useful example.
The following example is a visualizer of many paths generated by geometric Brownian motion which is often used to model stock prices. As we know, a geometric Brownian motion is characterized by starting value, expected growth rate (`r`), volatility (`sigma`), duration (`T`) and the number of `periods`. Expect for `T = 1`, we allow users to modify all other parameters.
Now we can define the user interface of the shiny app according to the parameters we want to expose to users. The `shiny` package provides a rich set of input controls listed as follows:
shiny_vars <- ls(getNamespace("shiny"))
shiny_vars[grep("Input$", shiny_vars)]
[1] "checkboxGroupInput" "checkboxInput"
[3] "dateInput" "dateRangeInput"
[5] "fileInput" "numericInput"
[7] "passwordInput" "selectInput"
[9] "selectizeInput" "sliderInput"
[11] "textInput" "updateCheckboxGroupInput"
[13] "updateCheckboxInput" "updateDateInput"
[15] "updateDateRangeInput" "updateNumericInput"
[17] "updateSelectInput" "updateSelectizeInput"
[19] "updateSliderInput" "updateTextInput"
To control the randomness of the generated paths, we allow users to specify the random seed (`seed`) so that the same seed produces the same paths. In the following code where `ui` is defined, we use `numericInput` for `seed` and `sliderInput` for other parameters. The `sliderInput` control has a certain range and step so that we can force a parameter to take reasonable values.
The user interface not only defines the input part but also the output part, that is, where to show what. The following is all output types shiny provides:
shiny_vars[grep("Output$", shiny_vars)]
[1] "dataTableOutput" "htmlOutput"
[3] "imageOutput" "plotOutput"
[5] "tableOutput" "textOutput"
[7] "uiOutput" "verbatimTextOutput"
In this example, the shiny app only shows a plot of all paths put together to indicate different possibilities with the same set of parameters:
library(shiny)
ui <- fluidPage(
titlePanel("Random walk"),
sidebarLayout(
sidebarPanel(
numericInput("seed", "Random seed", 123),
sliderInput("paths", "Paths", 1, 100, 1),
sliderInput("start", "Starting value", 1, 10, 1, 1),
sliderInput("r", "Expected return", -0.1, 0.1, 0, 0.001),
sliderInput("sigma", "Sigma", 0.001, 1, 0.01, 0.001),
sliderInput("periods", "Periods", 10, 1000, 200, 10)),
mainPanel(
plotOutput("plot", width = "100%", height = "600px")
))
)
Once the user interface is defined, we need to implement the server logic which is basically about generating random paths according to user-specified parameters and put them together in the same plot.
The following code is a simple implementation of the server logic. First we set the random seed. Then we iteratively call `sde::GBM` to generate random paths from geometric Brownian motion. To install the package, run `install.packages("sde")` before calling `GBM`:
The `GBM` package is responsible for generating one path while `sapply` is used to combine all generated paths into a matrix (`mat`) where each column represents a path. Finally, we use `matplot` to plot each path in different colors together in one chart.
The calculation is done in `render*` functions no matter whether it is a text, image, or a table. The following lists all the render functions shiny provides:
shiny_vars[grep("^render", shiny_vars)]
[1] "renderDataTable" "renderImage" "renderPage"
[4] "renderPlot" "renderPrint" "renderReactLog"
[7] "renderTable" "renderText" "renderUI"
In this example, we only need `renderPlot()` and to put the plotting code in it. The `output$plot` function will go to `plotOutput("plot")` in the user interface when the input is modified:
server <- function(input, output) {
output$plot <- renderPlot({
set.seed(input$seed)
mat <- sapply(seq_len(input$paths), function(i) {
sde::GBM(input$start,
input$r, input$sigma, 1, input$periods)
})
matplot(mat, type = "l", lty = 1,
main = "Geometric Brownian motions")
})
}
Now both user interface and server logic are ready. We can combine them together to create a shiny app and run it in the web browser.
app <- shinyApp(ui, server)
runApp(app)
When the parameters are modified, the plot will be refreshed automatically:

If we set a significantly positive annualized expected return, the generated paths will tend to grow more than decline:

## Using shinydashboard
In addition to the functions `shiny` provides, RStudio also develops `shinydashboard` ([`rstudio.github.io/shinydashboard/`](http://rstudio.github.io/shinydashboard/)) which is specialized in presenting data for overview or monitoring purposes.
The following example demonstrates how easy it is to create a simple dashboard to show the most popular R packages on CRAN with the most downloads in weekly and monthly time scale.
The data source is provided by `cranlogs` ([`cranlogs.r-pkg.org`](http://cranlogs.r-pkg.org)). First run the following code to install the packages we need:
install_packages(c("shinydashboard", "cranlogs"))
Then we take a quick view of the data source of CRAN downloads:
library(cranlogs)
cran_top_downloads()
No encoding supplied: defaulting to UTF-8.
rank package count from to
1 1 Rcpp 9682 2016-08-18 2016-08-18
2 2 digest 8937 2016-08-18 2016-08-18
3 3 ggplot2 8269 2016-08-18 2016-08-18
4 4 plyr 7816 2016-08-18 2016-08-18
5 5 stringi 7471 2016-08-18 2016-08-18
6 6 stringr 7242 2016-08-18 2016-08-18
7 7 jsonlite 7100 2016-08-18 2016-08-18
8 8 magrittr 6824 2016-08-18 2016-08-18
9 9 scales 6397 2016-08-18 2016-08-18
10 10 curl 6383 2016-08-18 2016-08-18
cran_top_downloads("last-week")
No encoding supplied: defaulting to UTF-8.
rank package count from to
1 1 Rcpp 50505 2016-08-12 2016-08-18
2 2 digest 46086 2016-08-12 2016-08-18
3 3 ggplot2 39808 2016-08-12 2016-08-18
4 4 plyr 38593 2016-08-12 2016-08-18
5 5 jsonlite 36984 2016-08-12 2016-08-18
6 6 stringi 36271 2016-08-12 2016-08-18
7 7 stringr 34800 2016-08-12 2016-08-18
8 8 curl 33739 2016-08-12 2016-08-18
9 9 DBI 33595 2016-08-12 2016-08-18
10 10 magrittr 32880 2016-08-12 2016-08-18
After getting familiar with the form of data we want to present in the dashboard, we can now think about constructing the dashboard in exactly the same way as constructing a typical shiny app. To make the most of `shinydashboard`, it is better to go through [`rstudio.github.io/shinydashboard/structure.html`](http://rstudio.github.io/shinydashboard/structure.html) to get a general idea of the nice components it provides.
Similarly to shiny app, we start by creating the user interface. This time, we use `dashboardPage`, `dashboardSidebar` and `dashboardBody`. In the dashboard, we want to present the package download dynamics and tables of the most popular packages with top downloads in both monthly and weekly scales.
We put the menu of monthly and weekly in the side bar so users can choose which to see. In each tab page, we can put plots and tables together. In this example, we use `formattable` to add color bars on the download column to make the numbers more comparable and straightforward.
library(shiny)
library(shinydashboard)
library(formattable)
library(cranlogs)
ui <- dashboardPage(
dashboardHeader(title = "CRAN Downloads"),
dashboardSidebar(sidebarMenu(
menuItem("Last week",
tabName = "last_week", icon = icon("list")),
menuItem("Last month",
tabName = "last_month", icon = icon("list"))
)),
dashboardBody(tabItems(
tabItem(tabName = "last_week",
fluidRow(tabBox(title = "Total downloads",
tabPanel("Total", formattableOutput("last_week_table"))),
tabBox(title = "Top downloads",
tabPanel("Top", formattableOutput("last_week_top_table"))))),
tabItem(tabName = "last_month",
fluidRow(tabBox(title = "Total downloads",
tabPanel("Total", plotOutput("last_month_barplot"))),
tabBox(title = "Top downloads",
tabPanel("Top", formattableOutput("last_month_top_table"))))),
))
)
Note that `plotOutput` is provided by `shiny` while `formattableOutput` is provided by `formattable` package. In fact, developers can create all kinds of HTML widgets that can be embedded into a shiny app as long as the package properly defines the `render*` function and `*Output` function to produce the correct HTML code.
Then we define the server logic. Since the output relies purely on the data source, we download the data before calling `formattable` and `plot`.
server <- function(input, output) {
output$last_week_table <- renderFormattable({
data <- cran_downloads(when = "last-week")
formattable(data, list(count = color_bar("lightblue")))
})
output$last_week_top_table <- renderFormattable({
data <- cran_top_downloads("last-week")
formattable(data, list(count = color_bar("lightblue"),
package = formatter("span",
style = "font-family: monospace;")))
})
output$last_month_barplot <- renderPlot({
data <- subset(cran_downloads(when = "last-month"),
count > 0)
with(data, barplot(count, names.arg = date),
main = "Last month downloads")
})
output$last_month_top_table <- renderFormattable({
data <- cran_top_downloads("last-month")
formattable(data, list(count = color_bar("lightblue"),
package = formatter("span",
style = "font-family: monospace;")))
})
}
In fact, if the data is updating, we can create a dynamic dashboard where the tables and charts periodically refresh. Using `?reactiveTimer` and `?reactive` will be the key to achieve this. Read the documentation for more information.
Both the user interface and the server logic are ready, so we can run the app now:
runApp(shinyApp(ui, server))
默认情况下,shiny 应用在首次访问时显示第一页。以下是一个截图,展示了**上周**标签页,它由两个`formattable`数据框的标签面板组成:

以下截图显示了**上个月**标签页,它由一个直方图和一个`formattable`数据框组成:

### 注意
要查看更多示例及其背后的代码,请访问[`rstudio.github.io/shinydashboard/examples.html`](http://rstudio.github.io/shinydashboard/examples.html)。
# 摘要
在本章中,我们展示了如何使用 R Markdown 生成动态文档,其中可以轻松嵌入表格、图形和交互式图表。然后我们看到了几个简单的 shiny 应用示例,这些应用基本上是基于 R 后端的网络交互式应用。有了这些强大的生产力工具,数据分析可以变得更加有趣和充满乐趣,因为结果可以通过一种优雅、交互的方式展示出来,这通常更有利于传达更多信息、获得更多见解和做出更好的决策。
现在我们已经完成了这本书。我们通过熟悉基本概念、数据结构和语言构造与特性来开始学习 R。我们通过一系列的例子来理解这些是如何满足实际数据分析需求的。为了对 R 编程语言和数据结构的行为有一个具体和一致的理解,我们讨论了几个高级主题,例如 R 的评估模型、元编程和面向对象系统。在上述知识的基础上,我们进一步探索了一系列更实用的主题,例如与数据库工作、数据操作技术、高性能计算、网络爬虫技术、动态文档和交互式应用。
本书涵盖了各种主题,以拓宽对 R 及其扩展包可能性的视野。现在你应该感到更有能力,并且在使用 R 解决数据分析问题时更有信心。更重要的是,我希望这本书能帮助你更好地处理数据,并在可视化、专业统计建模和机器学习等其他有用主题上更进一步。如果你有兴趣深入了解,我强烈推荐你阅读 Hadley Wickham 的《Advanced R》。


浙公网安备 33010602011771号