R-数据科学第二版-全-

R 数据科学第二版(全)

原文:zh.annas-archive.org/md5/37a90c3d5f5f73a9edfcae3460d8352e

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:引言

数据科学是一门激动人心的学科,它让你能够将原始数据转化为理解、洞察和知识。《R 数据科学》的目标是帮助你学习 R 语言中最重要的工具,以便你能够高效和可重复地进行数据科学工作,并在学习过程中享受乐趣!阅读本书后,你将掌握处理各种数据科学挑战所需的工具,使用 R 语言的最佳部分。

第二版序言

欢迎阅读《R 数据科学(R4DS)》第二版!这是第一版的重大改编,我们删除了我们认为不再有用的内容,添加了我们希望在第一版中包括的内容,并总体上更新了文本和代码以反映最佳实践的变化。我们也非常高兴地欢迎新的合著者:Mine Çetinkaya-Rundel,一位著名的数据科学教育家,也是我们在 Posit(以前被称为 RStudio 的公司)的同事之一。

下面是最大变化的简要总结:

  • 书籍的第一部分已更名为“整体游戏”。本节的目标是在深入细节之前为你提供数据科学的整体概述。

  • 书籍的第二部分是“可视化”。与第一版相比,本部分对数据可视化工具和最佳实践进行了更全面的覆盖。获取所有细节的最佳地点仍然是ggplot2 书籍,但现在《R4DS》覆盖了更多最重要的技术。

  • 书籍的第三部分现在被称为“变换”,并增加了关于数字、逻辑向量和缺失值的新章节。这些内容以前是数据转换章节的一部分,但需要更多的空间来涵盖所有细节。

  • 书籍的第四部分称为“导入”。这是一套新的章节,不仅涵盖了从平面文本文件读取数据的方法,还包括了与电子表格的工作、从数据库中获取数据、处理大数据、整理层次化数据以及从网站抓取数据的方法。

  • “程序”部分保留了下来,但已从头到尾重写,重点放在函数编写和迭代的最重要部分上。函数编写现在包括如何包装 tidyverse 函数的详细信息(处理 tidy 评估的挑战),因为这在过去几年变得更加容易和重要。我们新增了一章介绍了基本 R 函数的重要内容,这些函数在你可能遇到的 R 代码中也常见。

  • “建模”部分已被移除。我们以前没有足够的空间来全面展示建模的全部,而现在有更好的资源可用。我们一般建议使用tidymodels packages并阅读《Tidy Modeling with R》(Max Kuhn 和 Julia Silge 著,O’Reilly)。

  • “沟通”部分保留了下来,但已彻底更新,使用Quarto代替 R Markdown。本书的这一版已经使用 Quarto 编写,显然这是未来的工具。

你将学到什么

数据科学是一个广阔的领域,通过阅读一本书是无法掌握所有内容的。本书旨在为您提供最重要工具的坚实基础,并在必要时提供足够的知识来寻找更多学习资源。我们的典型数据科学项目步骤模型类似于图 I-1。

展示数据科学循环的图表:导入 -> 整理 -> 理解(其中包括阶段变换 -> 可视化 -> 建模循环) -> 交流。这些都围绕着交流。

图 I-1. 在我们的数据科学过程模型中,您从数据导入和整理开始。接下来,通过转换、可视化和建模的迭代循环理解您的数据。最后,通过向其他人交流您的结果来完成整个过程。

首先,您必须将数据导入到 R 中。这通常意味着您从文件、数据库或 Web 应用程序编程接口(API)中获取数据,并将其加载到 R 中的数据框中。如果您无法将数据导入 R,则无法对其进行数据科学分析!

一旦您导入了数据,将其整理是一个好主意。整理数据意味着将其存储在与数据集语义匹配的一致形式中。简而言之,当数据整洁时,每列都是一个变量,每行都是一个观察结果。整洁的数据很重要,因为一致的结构使您能够集中精力回答关于数据的问题,而不是为了不同功能而争取将数据放入正确的形式。

一旦您有了整洁的数据,常见的下一步是对其进行转换。转换包括关注感兴趣的观察结果(例如一个城市的所有人或去年的所有数据)、创建新变量作为现有变量的函数(例如从距离和时间计算速度)、以及计算一组汇总统计数据(例如计数或均值)。整理和转换一起被称为整理,因为将数据整理成自然工作的形式通常感觉像是一场斗争!

一旦您有了需要的变量的整洁数据,知识生成的两个主要引擎是可视化和建模。它们具有互补的优势和劣势,因此任何真实的数据分析将在它们之间进行多次迭代。

可视化是一种根本上的人类活动。一个好的可视化将展示出你意想不到的事情,或者对数据提出新的问题。一个好的可视化也可能暗示你正在问错误的问题,或者需要收集不同的数据。可视化可能会让你感到惊讶,但它们并不特别适合扩展,因为它们需要人类来解释。

模型是可视化的补充工具。一旦你的问题变得足够精确,你可以使用模型来回答它们。模型基本上是数学或计算工具,所以它们通常可以很好地扩展。即使它们不能,买更多的计算机通常比买更多的大脑便宜!但每个模型都有假设,根据其本质,模型无法质疑自己的假设。这意味着模型基本上不会让你感到惊讶。

数据科学的最后一步是沟通,这是任何数据分析项目中绝对关键的部分。除非你能将结果有效地传达给他人,否则你对数据的理解,无论多么深入,都是无用的。

包围所有这些工具的是编程。编程是你几乎在数据科学项目的每个部分都会用到的横切工具。你不需要成为一个专业程序员才能成为一名成功的数据科学家,但学习更多关于编程的知识会带来回报,因为成为更好的程序员可以让你更轻松地自动化常见任务并解决新问题。

你会在每一个数据科学项目中使用这些工具,但对于大多数项目来说,它们并不足够。这里有一个粗略的 80/20 法则:你可以使用本书中学到的工具解决大约 80%的每个项目,但你需要其他工具来解决剩余的 20%。在本书中,我们会指引你去了解更多资源。

如何组织本书

关于数据科学工具的前一描述大致按照在分析中使用它们的顺序进行组织(当然,你会多次迭代使用它们)。然而根据我们的经验,先学习数据导入和整理是次优的,因为 80%的时间是例行公事和无聊的,另外 20%的时间则是奇怪和令人沮丧的。这不是学习新科目的好起点!相反,我们将从已经导入和整理过的数据的可视化和转换开始。这样,当你处理自己的数据时,你的动力会保持高涨,因为你知道付出的努力是值得的。

每一章内部,我们都力求保持一致的模式:从一些激励性的例子开始,让你看到整体图景,然后深入细节。本书的每个部分都配有练习,帮助你实践所学。虽然跳过练习可能很诱人,但没有比在真实问题上实践更好的学习方法。

你不会学到的内容

本书没有涵盖几个重要的主题。我们认为保持无情地专注于基本内容非常重要,这样你就能尽快投入使用。这意味着本书无法涵盖每一个重要主题。

建模

建模对于数据科学非常重要,但它是一个庞大的主题,不幸的是,我们在这里无法为它提供应有的覆盖面。想要了解更多关于建模的内容,我们强烈推荐Tidy Modeling with R,这本书由我们的同事 Max Kuhn 和 Julia Silge(O’Reilly)撰写。这本书将教你 tidymodels 系列的包,顾名思义,它们与我们在本书中使用的 tidyverse 包共享许多约定。

大数据

本书自豪地并主要关注小型内存数据集。这是开始的正确地方,因为除非你有小数据的经验,否则无法处理大数据。你在本书的大部分内容中学到的工具将轻松处理数百兆字节的数据,并且通过一些小心的处理,你通常可以使用它们处理几吉字节的数据。我们还会向你展示如何从数据库和 Parquet 文件中获取数据,这两者通常用于存储大数据。你不一定能够处理整个数据集,但这并不是问题,因为你只需处理感兴趣问题的子集或子样本。

如果你经常处理较大的数据(例如 10–100 GB),我们建议你了解更多关于data.table的信息。我们在这里不教授它,因为它使用的界面与 tidyverse 不同,并需要你学习一些不同的约定。然而,它的运行速度非常快,性能回报值得你投入一些时间去学习,特别是在处理大数据时。

Python、Julia 和朋友们

在本书中,你不会学习任何关于 Python、Julia 或其他对数据科学有用的编程语言的内容。这并不是因为我们认为这些工具不好。它们并不差!实际上,大多数数据科学团队都同时使用多种语言,通常至少包括 R 和 Python。但我们坚信,最好一次只精通一种工具,而 R 是一个很好的起点。

先决条件

我们做了一些关于你已经掌握的知识的假设,以便你能从本书中获取最大收益。你应该具备一般的数字素养,如果你已经有一些基本的编程经验会更有帮助。如果你以前从未编程过,你可能会发现Hands-On Programming with R(O'Reilly)由 Garrett Grolemund 编写,对本书是一个有价值的补充。

为了在本书中运行代码,你需要四样东西:R,RStudio,一个名为tidyverse的 R 包集合,以及少数其他包。包是可重复使用的 R 代码的基本单元。它们包括可重复使用的函数、描述如何使用它们的文档以及示例数据。

R

要下载 R,请访问 CRANcomprehensive R archive network。R 每年发布一个新的主要版本,每年还会发布两到三个次要版本。定期更新是个好主意。升级可能有点麻烦,特别是对于需要重新安装所有包的主要版本来说,但拖延只会让情况变得更糟。我们建议使用本书的 R 4.2.0 或更高版本。

RStudio

RStudio 是一个用于 R 编程的集成开发环境(IDE),你可以从 RStudio 下载页面 下载。RStudio 每年更新几次,并且会在新版本发布时自动通知你,所以无需经常检查。定期升级以利用最新和最好的功能是个好主意。确保至少安装了 RStudio 2022.02.0。

当你启动 RStudio,图 I-2,你会看到界面中的两个关键区域:控制台窗格和输出窗格。目前,你只需知道在控制台窗格中输入 R 代码并按 Enter 键运行即可。随着我们的学习,你会了解更多!¹

带有窗格控制台和输出窗格的 RStudio IDE。

图 I-2. RStudio IDE 有两个关键区域:左侧的控制台窗格输入 R 代码,右侧的输出窗格查看图形。

Tidyverse

你还需要安装一些 R 包。R 包是扩展基本 R 能力的函数、数据和文档的集合。使用包对成功使用 R 是至关重要的。你将在本书中学习的大多数包都属于所谓的 tidyverse。tidyverse 中的所有包都共享一种数据和 R 编程的共同理念,并且设计成可以共同工作。

你可以用一行代码安装完整的 tidyverse:

install.packages("tidyverse")

在你的电脑上,在控制台中键入该行代码,然后按 Enter 键运行。R 将从 CRAN 下载这些包并安装到你的电脑上。

在加载包之前,你将无法使用包中的函数、对象或帮助文件。安装完包后,可以使用 library() 函数加载它:

library(tidyverse)
#> ── Attaching core tidyverse packages ───────────────────── tidyverse 2.0.0 ──
#> ✔ dplyr     1.1.0.9000     ✔ readr     2.1.4 
#> ✔ forcats   1.0.0          ✔ stringr   1.5.0 
#> ✔ ggplot2   3.4.1          ✔ tibble    3.1.8 
#> ✔ lubridate 1.9.2          ✔ tidyr     1.3.0 
#> ✔ purrr     1.0.1 
#> ── Conflicts ─────────────────────────────────────── tidyverse_conflicts() ──
#> ✖ dplyr::filter() masks stats::filter()
#> ✖ dplyr::lag()    masks stats::lag()
#> ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all 
#>   conflicts to become errors

这告诉你 tidyverse 加载了九个包:dplyr, forcats, ggplot2, lubridate, purrr, readr, stringr, tibble 和 tidyr。这些被认为是 tidyverse 的核心,因为你几乎在每次分析中都会使用它们。

tidyverse 中的包经常变化。你可以通过运行 tidyverse_update() 查看是否有更新。

其他包

有许多其他优秀的包不属于 tidyverse,因为它们解决的问题领域不同,或者设计原则不同。这并不意味着它们更好或更差;这只是使它们不同。换句话说,与 tidyverse 相对应的不是 messyverse,而是许多其他互相关联的包的宇宙。随着您使用 R 处理更多数据科学项目,您将学习到新的包和处理数据的新方法。

在本书中,我们将使用许多 tidyverse 之外的包。例如,我们将使用以下包,因为它们为我们提供了在学习 R 过程中使用的有趣数据集:

install.packages(c("arrow", "babynames", "curl", "duckdb", "gapminder", "ggrepel", 
"ggridges", "ggthemes", "hexbin", "janitor", "Lahman", "leaflet", "maps", 
"nycflights13", "openxlsx", "palmerpenguins", "repurrrsive", "tidymodels", "writexl"))

我们还将使用一些其他包作为临时示例。您现在不需要安装它们,只需记住,无论何时看到像这样的错误:

library(ggrepel)
#> Error in library(ggrepel) : there is no package called ‘ggrepel’

这意味着您需要运行 install.packages("ggrepel") 来安装该包。

运行 R 代码

前一节向您展示了运行 R 代码的几个示例。书中的代码看起来像这样:

1 + 2
#> [1] 3

如果您在本地控制台中运行相同的代码,它将如下所示:

> 1 + 2
[1] 3

主要有两个区别。在控制台中,您在>后面输入,称为提示;我们在书中不显示提示。在书中,输出用#>注释掉;在控制台中,它直接出现在您的代码之后。这两个差异意味着,如果您使用电子版书籍,您可以轻松地从书中复制代码并粘贴到控制台中。

本书中,我们使用一致的约定来引用代码:

  • 函数显示为代码字体,并带括号,例如sum()mean()

  • 其他 R 对象(例如数据或函数参数)以代码字体显示,不带括号,如flightsx

  • 有时为了明确对象来自哪个包,我们会使用包名后跟两个冒号,例如dplyr::mutate()nycflights13::flights。这也是有效的 R 代码。

本书中使用的其他约定。

本书使用以下排版约定:

斜体

指示网址和电子邮件地址。

常宽

用于程序列表,以及段落中用于引用变量或函数名称、数据库、数据类型、环境变量、语句、关键字和文件名等程序元素的文本。

常宽粗体

显示用户应按照字面意思输入的命令或其他文本。

常宽斜体

显示应替换为用户提供的值或由上下文确定的值的文本。

注意

这个元素表示一般注释。

警告

这个元素表示一个警告或注意。

O’Reilly 在线学习

注意

超过 40 年来,O’Reilly Media 提供技术和商业培训、知识和洞察,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、互动编码环境,以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。更多信息,请访问https://oreilly.com

如何联系我们

有关本书的评论和问题,请联系出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-889-8969(美国或加拿大)

  • 707-829-7019(国际或本地)

  • 707-829-0104(传真)

  • support@oreilly.com

  • https://www.oreilly.com/about/contact.xhtml

我们为本书设有网页,列出勘误、示例和任何额外信息。您可以访问此页面:https://oreil.ly/r-for-data-science-2e

获取有关我们的书籍和课程的新闻和信息,请访问https://oreilly.com

在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media

在 Twitter 上关注我们:https://twitter.com/oreillymedia

在 YouTube 上关注我们:https://www.youtube.com/oreillymedia

致谢

本书不仅仅是 Hadley、Mine 和 Garrett 的产品,而是与 R 社区许多人(面对面和在线)进行的许多对话的结果。我们非常感激与大家的每一次交流;非常感谢!

我们要感谢我们的技术审阅者们提供的宝贵反馈:Ben Baumer、Lorna Barclay、Richard Cotton、Emma Rand 和 Kelly Bodwin。

这本书是在公开平台上编写的,许多人通过拉取请求做出了贡献。特别感谢所有 259 位通过 GitHub 拉取请求进行改进的人(按用户名字母顺序排列):@a-rosenberg,Tim Becker(@a2800276),Abinash Satapathy(@Abinashbunty),Adam Gruer(@adam-gruer),adi pradhan(@adidoit),A. s.(@Adrianzo),Aep Hidyatuloh(@aephidayatuloh),Andrea Gilardi(@agila5),Ajay Deonarine(@ajay-d),@AlanFeder,Daihe Sui(@alansuidaihe),@alberto-agudo,@AlbertRapp,@aleloi,pete(@alonzi),Alex(@ALShum),Andrew M.(@amacfarland),Andrew Landgraf(@andland),@andyhuynh92,Angela Li(@angela-li),Antti Rask(@AnttiRask),LOU Xun(@aquarhead),@ariespirgel,@august-18,Michael Henry(@aviast),Azza Ahmed(@azzaea),Steven Moran(@bambooforest),Brian G. Barkley(@BarkleyBG),Mara Averick(@batpigandme),Oluwafemi OYEDELE(@BB1464),Brent Brewington(@bbrewington),Bill Behrman(@behrman),Ben Herbertson(@benherbertson),Ben Marwick(@benmarwick),Ben Steinberg(@bensteinberg),Benjamin Yeh(@bentyeh),Betul Turkoglu(@betulturkoglu),Brandon Greenwell(@bgreenwell),Bianca Peterson(@BinxiePeterson),Birger Niklas(@BirgerNi),Brett Klamer(@bklamer),@boardtc,Christian(@c-hoh),Caddy(@caddycarine),Camille V Leonard(@camillevleonard),@canovasjm,Cedric Batailler(@cedricbatailler),Christina Wei(@christina-wei),Christian Mongeau(@chrMongeau),Cooper Morris(@coopermor),Colin Gillespie(@csgillespie),Rademeyer Vermaak(@csrvermaak),Chloe Thierstein(@cthierst),Chris Saunders(@ctsa),Abhinav Singh(@curious-abhinav),Curtis Alexander(@curtisalexander),Christian G. Warden(@cwarden),Charlotte Wickham(@cwickham),Kenny Darrell(@darrkj),David Kane(@davidkane9),David(@davidrsch),David Rubinger(@davidrubinger),David Clark(@DDClark),Derwin McGeary(@derwinmcgeary),Daniel Gromer(@dgromer),@Divider85,@djbirke,Danielle Navarro(@djnavarro),Russell Shean(@DOH-RPS1303),Zhuoer Dong(@dongzhuoer),Devin Pastoor(@dpastoor),@DSGeoff,Devarshi Thakkar(@dthakkar09),Julian During(@duju211),Dylan Cashman(@dylancashman),Dirk Eddelbuettel(@eddelbuettel),Edwin Thoen(@EdwinTh),Ahmed El-Gabbas(@elgabbas),Henry Webel(@enryH),Ercan Karadas(@ercan7),Eric Kitaif(@EricKit),Eric Watt(@ericwatt),Erik Erhardt(@erikerhardt),Etienne B. Racine(@etiennebr),Everett Robinson(@evjrob),@fellennert,Flemming Miguel(@flemmingmiguel),Floris Vanderhaeghe(@florisvdh),@funkybluehen,@gabrivera,Garrick Aden-Buie(@gadenbuie),Peter Ganong(@ganong123),Gerome Meyer(@GeroVanMi),Gleb Ebert(@gl-eb),Josh Goldberg(@GoldbergData),bahadir cankardes(@gridgrad),Gustav W Delius(@gustavdelius),Hao Chen(@hao-trivago),Harris McGehee(@harrismcgehee),@hendrikweisser,Hengni Cai(@hengnicai),Iain(@Iain-S),Ian Sealy(@iansealy),Ian Lyttle(@ijlyttle),Ivan Krukov(@ivan-krukov),Jacob Kaplan(@jacobkap),Jazz Weisman(@jazzlw),John Blischak(@jdblischak),John D. Storey(@jdstorey),Gregory Jefferis(@jefferis),Jeffrey Stevens(@JeffreyRStevens),蒋雨蒙(@JeldorPKU),Jennifer(Jenny)Bryan(@jennybc),Jen Ren(@jenren),Jeroen Janssens(@jeroenjanssens),@jeromecholewa,Janet Wesner(@jilmun),Jim Hester(@jimhester),JJ Chen(@jjchern),Jacek Kolacz(@jkolacz),Joanne Jang(@joannejang),@johannes4998,John Sears(@johnsears),@jonathanflint,Jon Calder(@jonmcalder),Jonathan Page(@jonpage),Jon Harmon(@jonthegeek),JooYoung Seo(@jooyoungseo),Justinas Petuchovas(@jpetuchovas),Jordan(@jrdnbradford),Jeffrey Arnold(@jrnold),Jose Roberto Ayala Solares(@jroberayalas),Joyce Robbins(@jtr13),@juandering,Julia Stewart Lowndes(@jules32),Sonja(@kaetschap),Kara Woo(@karawoo),Katrin Leinweber(@katrinleinweber),Karandeep Singh(@kdpsingh),Kevin Perese(@kevinxperese),Kevin Ferris(@kferris10),Kirill Sevastyanenko(@kirillseva),Jonathan Kitt(@KittJonathan),@koalabearski,Kirill Müller(@krlmlr),Rafał Kucharski(@kucharsky),Kevin Wright(@kwstat),Noah Landesberg(@landesbergn),Lawrence Wu(@lawwu),@lindbrook,Luke W Johnston(@lwjohnst86),Kara de la Marck(@MarckK),Kunal Marwaha(@marwahaha),Matan Hakim(@matanhakim),Matthias Liew(@MatthiasLiew),Matt Wittbrodt(@MattWittbrodt),Mauro Lepore(@maurolepore),Mark Beveridge(@mbeveridge),@mcewenkhundi,mcsnowface,PhD(@mcsnowface),Matt Herman(@mfherman),Michael Boerman(@michaelboerman),Mitsuo Shiota(@mitsuoxv),Matthew Hendrickson(@mjhendrickson),@MJMarshall,Misty Knight-Finley(@mkfin7),Mohammed Hamdy(@mmhamdy),Maxim Nazarov(@mnazarov),Maria Paula Caldas(@mpaulacaldas),Mustafa Ascha(@mustafaascha),Nelson Areal(@nareal),Nate Olson(@nate-d-olson),Nathanael(@nateaff),@nattalides,Ned Western(@NedJWestern),Nick Clark(@nickclark1000),@nickelas,Nirmal Patel(@nirmalpatel),Nischal Shrestha(@nischalshrestha),Nicholas Tierney(@njtierney),Jakub Nowosad(@Nowosad),Nick Pullen(@nstjhp),@olivier6088,Olivier Cailloux(@oliviercailloux),Robin Penfold(@p0bs),Pablo E. Garcia(@pabloedug),Paul Adamson(@padamson),Penelope Y(@penelopeysm),Peter Hurford(@peterhurford),Peter Baumgartner(@petzi53),Patrick Kennedy(@pkq),Pooya Taherkhani(@pooyataher),Y. Yu(@PursuitOfDataScience),Radu Grosu(@radugrosu),Ranae Dietzel(@Ranae),Ralph Straumann(@rastrau),Rayna M Harris(@raynamharris),@ReeceGoding,

在线版本

这本书的在线版本可以在书的GitHub 仓库找到。在实体书重印之间,它将继续更新。书的源代码可以在https://oreil.ly/Q8z_O找到。这本书是由Quarto驱动的,它使得编写结合文本和可执行代码的书籍变得简单。

¹ 如果您想全面了解 RStudio 的所有功能,请参阅RStudio 用户指南

第一部分:整个游戏

本书的这一部分的目标是为您快速概述数据科学的主要工具:导入整理转换可视化数据,如图 I-1 所示。我们希望向您展示数据科学的“整个游戏”,为您提供足够的所有主要部分,以便您能够处理真实,尽管简单的数据集。本书的后续部分将更深入地讨论每个主题,扩展您处理数据科学挑战的能力。

显示数据科学周期的图表:导入 -> 整理 -> 理解(其中包括阶段转换 -> 可视化 -> 建模的循环) -> 传达。周围的所有这些都是程序导入,整理,转换和可视化突出显示。

图 I-1. 在本书的这一部分中,您将学习如何导入、整理、转换和可视化数据。

四章专注于数据科学工具:

  • 以可视化开始 R 编程是一个很好的选择,因为其效果非常明显:您可以制作优雅且信息丰富的图表,帮助您理解数据。在第一章中,您将深入学习可视化,了解 ggplot2 图表的基本结构以及将数据转化为图表的强大技术。

  • 单凭可视化通常不足够,因此在第三章中,您将学习关键动词,使您能够选择重要变量,筛选关键观察结果,创建新变量并计算摘要。

  • 在第五章中,您将学习整洁数据,这是一种一致的存储数据的方式,使得转换、可视化和建模更加容易。您将学习其基本原则以及如何将数据整理为整洁形式。

  • 在您能够转换和可视化数据之前,您需要首先将数据导入到 R 中。在第七章中,您将学习将 .csv 文件导入 R 的基础知识。

在这些章节中间,还有四章专注于您的 R 工作流。在第二章,第四章和第六章中,您将学习编写和组织 R 代码的良好工作流实践。这些将为您长远成功打下基础,因为它们将为您在处理实际项目时保持组织提供工具。最后,第八章将教您如何获取帮助和持续学习。

第一章:数据可视化

引言

“简单的图表给数据分析师带来的信息比任何其他设备都多。” —John Tukey

R 有几种制作图形的系统,但 ggplot2 是其中最优雅和最多才多艺的之一。ggplot2 实现了图形语法,这是一种描述和构建图形的一致系统。通过学习并在多个场景中应用 ggplot2,你能更快速地完成更多工作。

本章将教你如何使用 ggplot2 来可视化你的数据。我们将从创建一个简单的散点图开始,介绍美学映射和几何对象——ggplot2 的基本构建块。接着,我们将指导你如何可视化单变量的分布以及两个或多个变量之间的关系。最后,我们将讲解如何保存你的图形以及故障排除技巧。

先决条件

本章重点介绍 ggplot2,这是 tidyverse 中的核心包之一。要访问本章中使用的数据集、帮助页面和函数,请运行:

library(tidyverse)
#> ── Attaching core tidyverse packages ───────────────────── tidyverse 2.0.0 ──
#> ✔ dplyr     1.1.0.9000     ✔ readr     2.1.4 
#> ✔ forcats   1.0.0          ✔ stringr   1.5.0 
#> ✔ ggplot2   3.4.1          ✔ tibble    3.1.8 
#> ✔ lubridate 1.9.2          ✔ tidyr     1.3.0 
#> ✔ purrr     1.0.1 
#> ── Conflicts ─────────────────────────────────────── tidyverse_conflicts() ──
#> ✖ dplyr::filter() masks stats::filter()
#> ✖ dplyr::lag()    masks stats::lag()
#> ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all 
#>   conflicts to become errors

那一行代码加载了核心 tidyverse,这些包几乎在每次数据分析中都会用到。它还告诉你 tidyverse 中的哪些函数与基础 R(或其他可能已加载的包)中的函数冲突。¹

如果运行此代码出现错误消息 there is no package called 'tidyverse',你需要首先安装它,然后再次运行 library()

install.packages("tidyverse")
library(tidyverse)

你只需要安装一次软件包,但每次启动新会话时都需要加载它。

除了 tidyverse,我们还将使用 palmerpenguins 包,其中包括 penguins 数据集,其中包含了南极洲帕尔默群岛上三个岛屿上企鹅的体测量数据,以及 ggthemes 包,提供色盲安全的配色方案。

library(palmerpenguins)
library(ggthemes)

第一步

长翼鳐鸟的鳍比短翼鳐鸟的鳍更重还是更轻?你可能已经有了答案,但试着让你的答案更精确。鳍长和体重之间的关系是什么样子的?是正相关?负相关?线性?非线性?这种关系是否因鳐鸟的物种而异?以及它们所在的岛屿是否有影响?让我们创建可视化图表来回答这些问题。

企鹅数据帧

你可以使用 palmerpenguins 中的 penguins 数据帧来测试这些问题的答案(又名 palmerpenguins::penguins)。数据帧是一个包含变量(列)和观测(行)的矩形集合。penguins 包含了由 Kristen Gorman 博士和帕尔默站南极洲 LTER 团队收集并提供的 344 个观测数据。²

为了讨论更容易,让我们先定义一些术语:

变量

可以测量的数量、质量或属性。

当你测量它时变量的状态。变量的值可能会从一次测量到一次测量发生变化。

观察

一组在相似条件下进行的测量(通常您在一个观察中同时进行所有测量并在同一个对象上进行)。一个观察将包含几个值,每个与不同的变量相关联。我们有时将观察称为数据点

表格数据

一组值,每个与一个变量和一个观测相关联。如果每个值放在自己的“单元格”中,每个变量放在自己的列中,每个观测放在自己的行中,则表格数据是tidy

在这种情况下,变量指的是所有企鹅的属性,而观察指的是单个企鹅的所有属性。

在控制台中输入数据框的名称,R 会打印其内容的预览。请注意,预览顶部写着tibble。在整洁的宇宙中,我们使用称为tibbles的特殊数据框,你很快就会了解到。

penguins
#> # A tibble: 344 × 8
#>   species island    bill_length_mm bill_depth_mm flipper_length_mm
#>   <fct>   <fct>              <dbl>         <dbl>             <int>
#> 1 Adelie  Torgersen           39.1          18.7               181
#> 2 Adelie  Torgersen           39.5          17.4               186
#> 3 Adelie  Torgersen           40.3          18                 195
#> 4 Adelie  Torgersen           NA            NA                  NA
#> 5 Adelie  Torgersen           36.7          19.3               193
#> 6 Adelie  Torgersen           39.3          20.6               190
#> # … with 338 more rows, and 3 more variables: body_mass_g <int>, sex <fct>,
#> #   year <int>

此数据框包含八列。要查看所有变量和每个变量的前几个观测的另一种视图,请使用glimpse()。或者,如果您在 RStudio 中,请运行View(penguins)以打开交互式数据查看器。

glimpse(penguins)
#> Rows: 344
#> Columns: 8
#> $ species           <fct> Adelie, Adelie, Adelie, Adelie, Adelie, Adelie, A…
#> $ island            <fct> Torgersen, Torgersen, Torgersen, Torgersen, Torge…
#> $ bill_length_mm    <dbl> 39.1, 39.5, 40.3, NA, 36.7, 39.3, 38.9, 39.2, 34.…
#> $ bill_depth_mm     <dbl> 18.7, 17.4, 18.0, NA, 19.3, 20.6, 17.8, 19.6, 18.…
#> $ flipper_length_mm <int> 181, 186, 195, NA, 193, 190, 181, 195, 193, 190, …
#> $ body_mass_g       <int> 3750, 3800, 3250, NA, 3450, 3650, 3625, 4675, 347…
#> $ sex               <fct> male, female, female, NA, female, male, female, m…
#> $ year              <int> 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2007, 2…

penguins中的变量包括:

species

企鹅的物种(阿德利企鹅、燕鸥企鹅或巴拉鸭企鹅)

flipper_length_mm

企鹅鳍长度,以毫米为单位

body_mass_g

企鹅的体重,以克为单位

要了解更多关于penguins的信息,请通过运行?penguins打开其帮助页面。

终极目标

本章的最终目标是重新创建以下可视化,显示企鹅的鳍长度与体重之间的关系,考虑到企鹅的物种。

企鹅体重与鳍长度的散点图,同时还有这两个变量之间关系的最佳拟合线。图显示这两个变量之间的正相关、相对线性和相对强的关系。物种(阿德利、燕鸥和巴拉鸭)用不同的颜色和形状表示。这三个物种之间体重和鳍长度的关系大致相同,而巴拉鸭企鹅比其他两个物种的企鹅要大。

创建一个 ggplot

让我们一步一步重新创建这个图。

使用 ggplot2,您可以使用函数ggplot()开始绘制图表,定义一个图表对象,然后添加图层ggplot()的第一个参数是图表中要使用的数据集,因此ggplot(data = penguins)创建了一个空图表,准备显示penguins数据,但由于我们还没有告诉它如何可视化数据,所以目前它是空的。这并不是一个非常令人兴奋的图表,但您可以把它想象成一个空画布,在这里您将绘制图表的其余部分的图层。

ggplot(data = penguins)

一个空白的灰色绘图区域。

接下来,我们需要告诉ggplot()如何将我们的数据信息在视觉上呈现出来。ggplot()函数的mapping参数定义了如何将数据集中的变量映射到图表的视觉属性(美学)。mapping参数总是在aes()函数中定义,aes()函数的xy参数指定了要映射到 x 轴和 y 轴的变量。现在,我们将仅将翻转器长度映射到x美学属性,将身体质量映射到y美学属性。ggplot2 在data参数中查找映射的变量,此处为penguins数据集。

以下图表显示了添加这些映射的结果。

ggplot(
  data = penguins,
  mapping = aes(x = flipper_length_mm, y = body_mass_g)
)

图表显示翻转器长度在 x 轴上,值范围从 170 到 230,身体质量在 y 轴上,值范围从 3000 到 6000。

现在我们的空画布具有更多的结构——清楚地显示了翻转器长度将显示在 x 轴上,身体质量将显示在 y 轴上。但是企鹅自身还没有显示在图表上。这是因为我们还没有在我们的代码中表达如何在图表上表示数据帧中的观测值。

为此,我们需要定义一个几何对象(geom):图表用于表示数据的几何对象。在 ggplot2 中,这些几何对象通过以geom_开头的函数提供。人们通常通过图表使用的几何对象类型来描述图表。例如,柱状图使用柱形几何对象(geom_bar()),折线图使用线条几何对象(geom_line()),箱线图使用箱线图几何对象(geom_boxplot()),散点图使用点几何对象(geom_point()),依此类推。

函数geom_point()将一个点层添加到您的图表中,从而创建一个散点图。ggplot2 提供了许多 geom 函数,每个函数都向图表添加不同类型的层。您将在本书中学习到许多这样的 geoms,特别是在第九章中。

ggplot(
  data = penguins,
  mapping = aes(x = flipper_length_mm, y = body_mass_g)
) +
  geom_point()
#> Warning: Removed 2 rows containing missing values (`geom_point()`).

企鹅体重与翼鳍长度的散点图。该图显示了这两个变量之间的正向、线性和相对强的关系。

现在我们得到了看起来像我们可能认为的“散点图”的东西。它还不完全符合我们的“最终目标”图,但是使用这个图,我们可以开始回答促使我们探索的问题:“翼鳍长度和体重之间的关系是什么样的?”这种关系似乎是正向的(随着翼鳍长度的增加,体重也增加),相当线性(点围绕一条线而不是曲线聚集),并且适度强(围绕这样一条线没有太多的散布)。翼鳍更长的企鹅通常在体重上更大。

在我们向这张图添加更多图层之前,让我们暂停一下,回顾一下我们收到的警告消息:

删除了包含缺失值的 2 行数据(geom_point())。

我们看到这条消息是因为我们的数据集中有两只企鹅的体重和/或翼鳍长度数值缺失,ggplot2 无法在图表中表示它们。与 R 一样,ggplot2 秉持缺失值不应该悄悄消失的理念。当处理真实数据时,这种警告消息可能是您会遇到的最常见的警告之一——缺失值是一个常见问题,您将在本书中更多地了解到它们,特别是在第十八章中。在本章的其余绘图中,我们将抑制此警告,以便它不会在每个单独的绘图旁边打印出来。

添加美学和图层

散点图对于展示两个数值变量之间的关系非常有用,但对于任何两个变量之间的表面关系都保持怀疑是个好主意,并询问是否有其他变量可以解释或改变这种表面关系的性质。例如,翼鳍长度和体重之间的关系是否因物种而异?让我们将物种纳入我们的图表中,看看这是否揭示了这些变量之间表面关系的任何额外洞见。我们将通过用不同颜色的点表示物种来实现这一点。

要达到这一点,我们需要修改美观或者几何?如果你猜到了“在美学映射中,aes()内部”,那么你已经开始学习如何使用 ggplot2 创建数据可视化了!如果没有,不用担心。在本书中,你将制作更多的 ggplots,并有更多机会在制作它们时检查你的直觉。

ggplot(
  data = penguins,
  mapping = aes(x = flipper_length_mm, y = body_mass_g, color = species)
) +
  geom_point()

企鹅体重与鳍长散点图。图显示这两个变量之间存在正向、相对线性且相对强的关系。不同物种(阿德利企鹅、黑眉企鹅和根趾企鹅)用不同颜色表示。

当将分类变量映射到美学时,ggplot2 会自动为变量的每个唯一级别(三个物种)分配美学的唯一值(这里是唯一颜色),这个过程称为缩放。ggplot2 还会添加一个解释哪些值对应哪些级别的图例。

现在让我们再添加一层:显示体重与鳍长之间关系的平滑曲线。在继续之前,请参考先前的代码,并考虑如何将其添加到我们现有的图形中。

由于这是表示我们数据的新几何对象,我们将在我们的点几何层之上添加一个新的几何层:geom_smooth()。我们将指定基于linear model 和method = "lm"绘制最佳拟合线。

ggplot(
  data = penguins,
  mapping = aes(x = flipper_length_mm, y = body_mass_g, color = species)
) +
  geom_point() +
  geom_smooth(method = "lm")

一张企鹅体重与鳍长散点图。在散点图上叠加了三条平滑曲线,显示了这些变量在每个物种(阿德利企鹅、黑眉企鹅和根趾企鹅)中的关系。不同的企鹅物种用不同的颜色表示。

我们成功地添加了线条,但这个图形看起来不像“最终目标”中的图形,后者只有一条线代表整个数据集,而不是每个企鹅物种分别的线条。

当在ggplot()中定义美学映射时,它们是在全局级别传递给绘图的每个后续几何层。然而,ggplot2 中的每个几何函数也可以接受一个mapping参数,允许在局部级别进行美学映射,这些映射会添加到从全局级别继承的映射中。由于我们希望根据物种对点进行着色,但不希望将线分隔开来,我们应该仅为geom_point()指定color = species

ggplot(
  data = penguins,
  mapping = aes(x = flipper_length_mm, y = body_mass_g)
) +
  geom_point(mapping = aes(color = species)) +
  geom_smooth(method = "lm")

一张关于企鹅体重与鳍长的散点图。在散点图上叠加了一条最佳拟合线,显示了每个物种(阿德利企鹅、燕带企鹅和根趾企鹅)之间这两个变量的关系。点的颜色只是不同的企鹅物种的标识。

瞧!我们得到了一些非常接近我们终极目标的东西,尽管它还不完美。我们仍然需要为每个企鹅物种使用不同的形状,并改进标签。

通常不建议仅使用颜色在图表上表示信息,因为人们由于色盲或其他色觉差异可能会对颜色有不同的感知。因此,除了颜色外,我们还可以将 species 映射到 shape 美学上。

ggplot(
  data = penguins,
  mapping = aes(x = flipper_length_mm, y = body_mass_g)
) +
  geom_point(mapping = aes(color = species, shape = species)) +
  geom_smooth(method = "lm")

一张关于企鹅体重与鳍长的散点图。在散点图上叠加了一条最佳拟合线,显示了每个物种(阿德利企鹅、燕带企鹅和根趾企鹅)之间这两个变量的关系。点的颜色和形状分别表示不同的企鹅物种。

注意,图例会自动更新以反映点的不同形状。

最后,我们可以使用 labs() 函数在新的层中改善图表的标签。labs() 的一些参数可能是不言自明的:title 添加标题,subtitle 添加副标题到图表中。其他参数匹配美学映射:x 是 x 轴标签,y 是 y 轴标签,colorshape 定义图例的标签。此外,我们可以使用 ggthemes 包中的 scale_color_colorblind() 函数改善配色方案,以适应色盲人群。

ggplot(
  data = penguins,
  mapping = aes(x = flipper_length_mm, y = body_mass_g)
) +
  geom_point(aes(color = species, shape = species)) +
  geom_smooth(method = "lm") +
  labs(
    title = "Body mass and flipper length",
    subtitle = "Dimensions for Adelie, Chinstrap, and Gentoo Penguins",
    x = "Flipper length (mm)", y = "Body mass (g)",
    color = "Species", shape = "Species"
  ) +
  scale_color_colorblind()

一张关于企鹅体重与鳍长的散点图,同时还有一条最佳拟合线显示这两个变量之间的关系。图表展示了这两个变量之间正向、相当线性且比较强的关系。不同的物种(阿德利企鹅、燕带企鹅和根趾企鹅)用不同的颜色和形状表示。体重与鳍长的关系对这三个物种来说大致相同,而根趾企鹅比其他两个物种的企鹅体型更大。

我们终于得到了一个完美匹配我们“终极目标”的图表!

练习

  1. penguins 中有多少行?多少列?

  2. penguins 数据框中的 bill_depth_mm 变量是描述什么的?阅读 ?penguins 的帮助文件来了解。

  3. 制作一个 bill_depth_mmbill_length_mm 的散点图。即,将 bill_depth_mm 放在 y 轴,bill_length_mm 放在 x 轴。描述这两个变量之间的关系。

  4. 如果你绘制speciesbill_depth_mm的散点图会发生什么?可能有更好的几何图形选择吗?

  5. 为什么以下代码会报错,你会如何修复它?

    ggplot(data = penguins) + 
      geom_point()
    
  6. geom_point()中的na.rm参数是做什么的?这个参数的默认值是什么?创建一个散点图,其中成功使用此参数设置为TRUE

  7. 在你之前绘制的图表上添加以下标题:“数据来自 palmerpenguins 包。”提示:查看labs()的文档。

  8. 重新创建以下可视化。bill_depth_mm应该映射到哪个美学特征?它应该在全局级别还是在几何级别映射?

    企鹅的体重与鳍长的散点图,按脖深着色。叠加了体重与鳍长之间关系的平滑曲线。关系是正向的,相当线性,且适度强。

  9. 在脑中运行此代码并预测输出的样子。然后,在 R 中运行代码并检查你的预测。

    ggplot(
      data = penguins,
      mapping = aes(x = flipper_length_mm, y = body_mass_g, color = island)
    ) +
      geom_point() +
      geom_smooth(se = FALSE)
    
  10. 这两个图看起来会有不同吗?为什么/为什么不?

    ggplot(
      data = penguins,
      mapping = aes(x = flipper_length_mm, y = body_mass_g)
    ) +
      geom_point() +
      geom_smooth()
    
    ggplot() +
      geom_point(
        data = penguins,
        mapping = aes(x = flipper_length_mm, y = body_mass_g)
      ) +
      geom_smooth(
        data = penguins,
        mapping = aes(x = flipper_length_mm, y = body_mass_g)
      )
    

ggplot2调用

随着我们从这些介绍性部分过渡,我们将转向更简洁的表达ggplot2代码。到目前为止,我们一直非常明确,这在学习过程中很有帮助:

ggplot(
  data = penguins,
  mapping = aes(x = flipper_length_mm, y = body_mass_g)
) +
  geom_point()

通常,函数的前一个或两个参数非常重要,你应该牢记它们。ggplot()函数的前两个参数分别是datamapping;在本书的其余部分,我们不会提供这些名称。这样做可以节省输入,减少额外文本的数量,更容易看出图之间的差异。这是一个非常重要的编程问题,我们将在第二十五章回顾。

将上一个图表以更简洁的方式重写如下:

ggplot(penguins, aes(x = flipper_length_mm, y = body_mass_g)) + 
  geom_point()

在未来,你还将学习管道符号|>,它将允许你使用以下代码创建该图表:

penguins |> 
  ggplot(aes(x = flipper_length_mm, y = body_mass_g)) + 
  geom_point()

可视化分布

如何可视化变量的分布取决于变量的类型:分类或数值型。

一个分类变量

如果一个变量只能取少量值中的一个,那么它是categorical变量。要检查分类变量的分布,可以使用条形图。条的高度显示了每个x值发生了多少次观察。

ggplot(penguins, aes(x = species)) +
  geom_bar()

一个企鹅物种频率条形图:阿德利(约 150 只)、帝企鹅(约 90 只)、金斑企鹅(约 125 只)。

在具有非顺序级别的分类变量的条形图中,像之前的企鹅species一样,通常最好根据它们的频率重新排序条形。这需要将变量转换为因子(R 如何处理分类数据),然后重新排序该因子的级别。

ggplot(penguins, aes(x = fct_infreq(species))) +
  geom_bar()

一张企鹅物种频率条形图,柱子按高度(频率)降序排列:阿德利(约 150)、根足(约 125)、黑脸金鱼(约 90)。

你将在第十六章中学习更多关于因子及其处理函数(如fct_infreq())的内容。

数值变量

如果一个变量能够取一系列数值,并且可以对这些数值进行加减和平均,那么这个变量就是数值(或定量)变量。数值变量可以是连续的或离散的。

处理连续变量分布常用的一种可视化方法是直方图。

ggplot(penguins, aes(x = body_mass_g)) +
  geom_histogram(binwidth = 200)

一张企鹅体重直方图。分布单峰,呈右偏,范围约为 2500 至 6500 克。

直方图将 x 轴等间隔地划分为多个区间,并使用柱的高度显示落入每个区间的观测次数。在上一个图中,最高的柱子表明有 39 个观测值的body_mass_g在 3500 至 3700 克之间,这是该柱子的左右边缘。

你可以使用binwidth参数设置直方图中的间隔宽度,单位为x变量的单位。在处理直方图时,应当尝试多种binwidth值,因为不同的binwidth值可以展现出不同的模式。在以下图表中,binwidth为 20 时太窄,导致了太多的柱子,使得难以确定分布的形状。同样,binwidth为 2,000 时太高,导致所有数据只分为三个柱子,同样使得难以确定分布的形状。binwidth为 200 则提供了一个合理的平衡。

ggplot(penguins, aes(x = body_mass_g)) +
  geom_histogram(binwidth = 20)
ggplot(penguins, aes(x = body_mass_g)) +
  geom_histogram(binwidth = 2000)

两张企鹅体重直方图,左侧为为 20,右侧为为 2000。为 20 的直方图显示出许多柱子的起伏,创建了一个锯齿状的轮廓。为 2000 的直方图只显示了三个柱子。

数值变量分布的另一种可视化方式是密度图。密度图是直方图的平滑版本,特别适用于来自平滑分布的连续数据。我们不会深入讨论geom_density()如何估计密度(您可以在函数文档中阅读更多),但让我们用一个类比来解释密度曲线是如何绘制的。想象一个由木块组成的直方图。然后,想象你在上面放一根熟意面条。意面条掉在木块上的形状可以看作是密度曲线的形状。它显示的细节比直方图少,但可以更快速地了解分布的形状,特别是关于众数和偏度方面。

ggplot(penguins, aes(x = body_mass_g)) +
  geom_density()
#> Warning: Removed 2 rows containing non-finite values (`stat_density()`).

企鹅体重密度图。分布是单峰且右偏,范围大约在 2500 到 6500 克之间。

练习

  1. 制作一个penguinsspecies条形图,将species分配给y美学。这个图与之前的有何不同?

  2. 下面这两个图有何不同?哪个美学,color还是fill,更适合改变条形图的颜色?

    ggplot(penguins, aes(x = species)) +
      geom_bar(color = "red")
    
    ggplot(penguins, aes(x = species)) +
      geom_bar(fill = "red")
    
  3. geom_histogram()中的bins参数是做什么用的?

  4. 制作diamonds数据集中carat变量的直方图,该数据集在加载 tidyverse 包时可用。尝试不同的binwidth值。哪个值显示出最有趣的模式?

可视化关系

要可视化一个关系,我们需要将至少两个变量映射到绘图的美学上。在接下来的几节中,您将学习关于常用绘图来可视化两个或多个变量之间关系的绘图和用于创建它们的几何对象。

数值变量和分类变量

要可视化数值和分类变量之间的关系,我们可以使用并列箱线图。箱线图是描述分布的位置(百分位数)的一种视觉简写。它还有助于识别潜在的异常值。如图 1-1 所示,每个箱线图包括:

  • 一个显示数据中间一半范围的方框,这个距离被称为四分位距(IQR),从分布的第 25 百分位到第 75 百分位延伸。在方框中间有一条显示分布中位数,即第 50 百分位的线。这三条线让你了解分布的扩展情况,以及分布是否关于中位数对称或偏向一侧。

  • 显示落在箱子任一边缘 1.5 倍 IQR 之外的观察点的可视点。这些异常点很不寻常,因此单独绘制。

  • 每个箱线图上都有一条线(或者叫做须),它从箱子的两端延伸出去,直到分布中最远的非异常点。

展示了如何按照上述步骤创建箱线图的示意图。

图 1-1. 展示了如何创建箱线图的示意图。

让我们来看看使用 geom_boxplot() 显示的各种企鹅体重分布:

ggplot(penguins, aes(x = species, y = body_mass_g)) +
  geom_boxplot()

Adelie、Chinstrap 和 Gentoo 企鹅体重分布的并列箱线图。Adelie 和 Chinstrap 企鹅的体重分布呈对称分布,中位数约为 3750 克。Gentoo 企鹅的中位体重要高得多,约为 5000 克,体重分布呈现出稍微右偏态。

或者,我们可以使用 geom_density() 制作密度图:

ggplot(penguins, aes(x = body_mass_g, color = species)) +
  geom_density(linewidth = 0.75)

按照企鹅物种制作的体重密度图。每种企鹅(Adelie、Chinstrap 和 Gentoo)在密度曲线中用不同颜色的轮廓表示。

我们还使用 linewidth 参数自定义了线条的厚度,使其在背景中更加突出。

另外,我们还可以将 species 同时映射到 colorfill 美学,并使用 alpha 美学为填充的密度曲线增加透明度。该美学接受介于 0(完全透明)和 1(完全不透明)之间的值。在以下图中,它被设置为 0.5:

ggplot(penguins, aes(x = body_mass_g, color = species, fill = species)) +
  geom_density(alpha = 0.5)

按照企鹅物种制作的体重密度图。每种企鹅(Adelie、Chinstrap 和 Gentoo)在密度曲线中用不同颜色的轮廓表示,密度曲线也填充有相同颜色,透明度有所增加。

注意我们在这里使用的术语:

  • 如果我们希望视觉属性根据变量的值而变化,我们可以将变量映射到美学。

  • 否则,我们可以设置美学的值。

两个分类变量

我们可以使用堆叠条形图来可视化两个分类变量之间的关系。例如,以下两个堆叠条形图都显示了 islandspecies 之间的关系,或者具体来说,显示了每个岛上 species 的分布情况。

第一个图显示了每个岛上各种企鹅的频率。频率图显示每个岛上 Adelie 数量相等,但我们对每个岛内的百分比分布没有很好的感知。

ggplot(penguins, aes(x = island, fill = species)) +
  geom_bar()

按照岛屿(Biscoe、Dream 和 Torgersen)显示的企鹅物种的条形图

第二个图表是一个相对频率图,通过在几何体中设置position = "fill"创建,并且更适合比较物种在不同岛屿上的分布,因为它不受各岛屿企鹅数量不均匀分布的影响。使用此图表,我们可以看到 Gentoo 企鹅全部生活在 Biscoe 岛上,并占该岛企鹅总数的约 75%,Chinstrap 全部生活在 Dream 岛上,并占该岛企鹅总数的约 50%,而 Adelie 企鹅则生活在三个岛屿上,并占 Torgersen 岛上的所有企鹅。

ggplot(penguins, aes(x = island, fill = species)) +
  geom_bar(position = "fill")

企鹅物种按岛屿(Biscoe、Dream 和 Torgersen)分的条形图。条形的高度缩放到相同高度,形成一个相对频率图。

在创建这些条形图时,我们将将要分隔成条的变量映射到x美学属性上,将用于填充条形内颜色的变量映射到fill美学属性上。

两个数值变量

到目前为止,您已经学习了关于散点图(由geom_point()创建)和平滑曲线(由geom_smooth()创建)用于可视化两个数值变量之间关系的知识。散点图可能是最常用于展示两个数值变量之间关系的图表类型。

ggplot(penguins, aes(x = flipper_length_mm, y = body_mass_g)) +
  geom_point()

企鹅体重与鳍长度的散点图。图表显示这两个变量之间呈现出正线性、相对强烈的关系。

三个或更多变量

正如我们在“添加美学和图层”中看到的那样,我们可以通过将更多变量映射到额外的美学属性来将更多变量合并到一个图表中。例如,在以下散点图中,点的颜色表示物种,点的形状表示岛屿:

ggplot(penguins, aes(x = flipper_length_mm, y = body_mass_g)) +
  geom_point(aes(color = species, shape = island))

一张企鹅体重与鳍长度的散点图。图表显示这两个变量之间呈现出正线性、相对强烈的关系。点的颜色基于企鹅的物种,点的形状代表岛屿(圆形点为 Biscoe 岛,三角形为 Dream 岛,方形为 Torgersen 岛)。图表非常繁忙,很难辨别点的形状。

然而,向图表中添加过多的美学映射会使其变得混乱且难以理解。另一个选择,特别适用于分类变量的情况,是将图表拆分为分面,每个分面显示数据的一个子集。

要通过单一变量来划分你的图,使用facet_wrap()facet_wrap()的第一个参数是一个公式,³,你用~后跟一个变量名来创建它。你传递给facet_wrap()的变量应该是分类的。

ggplot(penguins, aes(x = flipper_length_mm, y = body_mass_g)) +
  geom_point(aes(color = species, shape = species)) +
  facet_wrap(~island)

企鹅体重与鳍长的散点图。点的形状和颜色代表物种。每个岛上的企鹅在不同的分面上。在每个分面内,体重与鳍长之间的关系呈正线性,相对较强。

你将在第九章学习关于用于可视化变量分布和它们之间关系的许多其他几何图形。

练习

  1. 与 ggplot2 软件包捆绑的mpg数据框包含了由美国环境保护局收集的 38 个车型的 234 个观测值。mpg中的哪些变量是分类变量?哪些是数值变量?(提示:输入?mpg来阅读数据集的文档。)当你运行mpg时,如何查看这些信息?

  2. 利用mpg数据框绘制hwydispl的散点图。接下来,将第三个数值变量映射到colorsizecolorsize以及shape。这些美学在分类变量和数值变量上表现有何不同?

  3. hwydispl的散点图中,如果将第三个变量映射到linewidth会发生什么?

  4. 如果将同一变量映射到多个美学上会发生什么?

  5. 制作bill_depth_mmbill_length_mm的散点图,并按species着色。按物种着色的效果揭示了这两个变量之间的关系的什么?按物种分面又有什么不同?

  6. 为什么以下操作会生成两个单独的图例?如何修复以合并这两个图例?

    ggplot(
      data = penguins,
      mapping = aes(
        x = bill_length_mm, y = bill_depth_mm, 
        color = species, shape = species
      )
    ) +
      geom_point() +
      labs(color = "Species")
    
  7. 创建以下两个堆积条形图。第一个可以回答哪个问题?第二个可以回答哪个问题?

    ggplot(penguins, aes(x = island, fill = species)) +
      geom_bar(position = "fill")
    ggplot(penguins, aes(x = species, fill = island)) +
      geom_bar(position = "fill")
    

保存你的图

一旦你绘制了图,你可能想通过将其保存为可以在其他地方使用的图像来将其导出 R。这就是ggsave()的工作方式,它将最近创建的图保存到磁盘上:

ggplot(penguins, aes(x = flipper_length_mm, y = body_mass_g)) +
  geom_point()
ggsave(filename = "penguin-plot.png")

这将把你的图保存到你的工作目录中,关于这个概念,你将在第六章中学到更多。

如果你没有指定widthheight,它们将从当前绘图设备的尺寸中取值。为了可重现的代码,你需要指定它们。你可以在文档中了解更多关于ggsave()的信息。

然而,我们推荐您使用 Quarto 来汇编最终报告,Quarto 是一个可复制的创作系统,允许您交替使用代码和散文,并自动将图表包含在您的写作中。您将在第二十八章更多地了解 Quarto。

练习

  1. 运行以下代码行。哪一个图表保存为mpg-plot.png?为什么?

    ggplot(mpg, aes(x = class)) +
      geom_bar()
    ggplot(mpg, aes(x = cty, y = hwy)) +
      geom_point()
    ggsave("mpg-plot.png")
    
  2. 在前面的代码中,您需要更改什么以将图表保存为 PDF 而不是 PNG?您如何找出ggsave()支持哪些图像文件类型?

常见问题

当您开始运行 R 代码时,可能会遇到问题。别担心——这种情况发生在每个人身上。我们多年来一直在写 R 代码,但每天我们仍然会写出不起作用的代码!

首先,仔细比较您运行的代码与书中的代码。R 非常挑剔,一个错位的字符可能导致截然不同的结果。确保每个都与一个匹配,每个"都与另一个"配对。有时您运行代码却什么也不发生。检查您控制台的左侧:如果出现+,这意味着 R 认为您尚未输入完整的表达式,并且正在等待您完成它。在这种情况下,通过按下 Escape 键中止当前命令的处理,通常很容易重新开始。

创建 ggplot2 图形时的一个常见问题是将+放错位置:它必须放在行末,而不是行首。换句话说,请确保您没有意外地编写类似以下代码:

ggplot(data = mpg) 
+ geom_point(mapping = aes(x = displ, y = hwy))

如果您仍然被卡住,请尝试获取帮助。您可以通过在控制台中运行?function_name或在 RStudio 中高亮显示函数名称并按下 F1 来获取有关任何 R 函数的帮助。如果帮助似乎不太有用,请跳转到示例部分,并查找与您尝试完成的代码匹配的代码。

如果这些方法都无效,请仔细阅读错误消息。有时答案可能隐藏在其中!但是,当您刚开始使用 R 时,即使答案在错误消息中,您可能还不知道如何理解它。另一个好的工具是 Google:尝试搜索错误消息,因为很可能有人遇到过相同的问题并在线获得了帮助。

总结

在本章中,您已经学习了使用 ggplot2 进行数据可视化的基础知识。我们从支持 ggplot2 的基本思想开始:可视化是将数据中的变量映射到美学属性(如位置、颜色、大小和形状)的过程。然后,您学习了通过逐层添加图层来增加复杂性和改善图表的展示。您还了解了用于可视化单个变量分布以及可视化两个或多个变量之间关系的常用图表,通过额外的美学映射和/或将图表分割成小多个部分进行绘制。

我们将在整本书中反复使用可视化技术,根据需要引入新技术,并在第九章到第十一章深入探讨使用 ggplot2 创建可视化图表。

现在你已经了解了可视化的基础知识,接下来的章节我们将转变一下思路,给你一些实用的工作流建议。我们在本书的这一部分穿插工作流建议和数据科学工具,因为这将帮助你在编写越来越多的 R 代码时保持组织有序。

¹ 你可以通过使用 conflicted 包来消除该消息,并在需要时强制解决冲突。随着加载更多的包,使用 conflicted 包变得更为重要。你可以在包网站上了解更多有关 conflicted 的信息。

² Horst AM, Hill AP, Gorman KB (2020). palmerpenguins:帕尔默群岛(南极洲)企鹅数据。R 包版本 0.1.0。https://oreil.ly/ncwc5。doi: 10.5281/zenodo.3960218。

³ 这里,“formula”是由~创建的对象名称,而不是“equation”的同义词。

第二章:工作流:基础

现在你已经有一些运行 R 代码的经验了。我们没有给你太多细节,但显然你已经掌握了基础,否则你早就会因为挫败而扔掉这本书了!开始在 R 中编程时自然会感到挫折,因为它对标点符号要求严格,即使一个字符放错位置也会导致报错。但是,尽管你可能会有些挫折,要记住这是正常且暂时的经历:每个人都会经历,唯一的克服方法就是继续努力。

在我们继续之前,让我们确保你对运行 R 代码有坚实的基础,并且了解一些最有用的 RStudio 功能。

编码基础

让我们回顾一些基础知识,这些知识我们之前为了尽快让你绘图而省略了。你可以使用 R 进行基本的数学计算:

1 / 200 * 30
#> [1] 0.15
(59 + 73 + 2) / 3
#> [1] 44.66667
sin(pi / 2)
#> [1] 1

你可以用赋值操作符 <- 创建新的对象:

x <- 3 * 4

注意,变量 x 的值并不会被打印出来,它只是被存储起来了。如果你想查看这个值,在控制台输入 x 即可。

你可以使用 c() 将多个元素组合成一个向量:

primes <- c(2, 3, 5, 7, 11, 13)

对向量的基本算术运算会被应用到向量的每个元素上:

primes * 2
#> [1]  4  6 10 14 22 26
primes - 1
#> [1]  1  2  4  6 10 12

所有创建对象的 R 语句,赋值 语句,都具有相同的形式:

object_name <- value

在阅读代码时,可以在脑海中说 “对象名得到值”。

你会做很多赋值操作,而 <- 输入起来有些麻烦。你可以使用 RStudio 的键盘快捷键来节省时间:Alt+–(减号)。注意,RStudio 会自动在 <- 周围加上空格,这是良好的代码格式化实践。即使在好日子里阅读代码也可能令人沮丧,所以让你的眼睛休息一下,使用空格。

注释

R 将忽略每行中 # 后面的文本。这允许你编写 注释,这些文本会被 R 忽略,但是会被人类读取。我们有时在示例中包含注释来解释代码的执行过程。

注释可以帮助简要描述代码的功能:

# create vector of primes
primes <- c(2, 3, 5, 7, 11, 13)

# multiply primes by 2
primes * 2
#> [1]  4  6 10 14 22 26

对于像这样的短小代码,可能并不需要为每一行代码都留下注释。但是当你写的代码变得更加复杂时,注释可以节省你(和你的合作者)大量的时间,用来弄清楚代码的执行过程。

使用注释来解释代码的 为什么 而不是 如何什么。代码的 如何什么 总是可以通过仔细阅读来弄清楚,即使这可能有些繁琐。如果你在注释中描述每一个步骤,然后又改变了代码,你就必须记得更新注释,否则当你将来回到代码时会感到困惑。

弄清楚为什么做某事要困难得多,甚至不可能。例如,geom_smooth()有一个名为span的参数,用于控制曲线的平滑度,较大的值会产生更平滑的曲线。假设您决定将span的值从默认值 0.75 更改为 0.9:对于未来的读者来说,理解正在发生什么很容易,但除非您在注释中记录您的思考,否则没有人会理解为什么您更改了默认值。

对于数据分析代码,请使用注释来解释您的整体攻击计划,并在遇到重要见解时记录它们。无法从代码本身重新捕获这些知识。

名称的重要性?

对象名称必须以字母开头,只能包含字母、数字、_.。您希望对象名称具有描述性,因此需要采用多词的约定。我们推荐使用snake_case,其中您用_分隔小写单词。

i_use_snake_case
otherPeopleUseCamelCase
some.people.use.periods
And_aFew.People_RENOUNCEconvention

我们将在讨论代码风格的第四章时再次谈到名称。

您可以通过输入其名称来检查对象:

x
#> [1] 12

再做一个赋值:

this_is_a_really_long_name <- 2.5

要检查此对象,请尝试使用 RStudio 的完成功能:输入this,按 Tab 键,添加字符直到有一个唯一的前缀,然后按 Enter 键。

假设您犯了一个错误,this_is_a_really_long_name的值应该是 3.5,而不是 2.5。您可以使用另一个键盘快捷键来帮助您进行更正。例如,您可以按↑键来获取您最后输入的命令并进行编辑。或者,输入this,然后按 Cmd/Ctrl+↑列出以这些字母开头的所有命令。使用箭头键进行导航,然后按 Enter 键重新输入命令。将 2.5 更改为 3.5 并重新运行。

再做一个赋值:

r_rocks <- 2³

让我们尝试检查它:

r_rock
#> Error: object 'r_rock' not found
R_rocks
#> Error: object 'R_rocks' not found

这说明了您与 R 之间的隐含契约:R 将为您执行繁琐的计算,但作为交换,您必须在指令中完全精确。如果不这样做,您可能会收到一个错误消息,指出您要查找的对象未找到。拼写错误很重要;R 无法猜测你的意图并说:“哦,他们在键入r_rock时可能指的是r_rocks。”大小写很重要;类似地,R 无法猜测你的意图并说:“哦,他们在键入R_rocks时可能指的是r_rocks。”

调用函数

R 具有大量内置函数,可以这样调用它们:

function_name(argument1 = value1, argument2 = value2, ...)

让我们尝试使用seq(),它可以创建数字的seq序列,并且在我们进行学习 RStudio 的更多有用功能时。输入se并按 Tab 键。弹出窗口会显示可能的完成项。通过键入更多内容(如q)来明确或使用↑/↓箭头选择seq()。注意浮动工具提示窗口,提醒您函数的参数和目的。如果需要更多帮助,请按 F1 键获取右下角帮助选项卡上的所有详细信息。

选择了您想要的函数后,再次按 Tab。RStudio 会为您添加匹配的开括号(和闭括号)。键入第一个参数from并设置为1。然后,键入第二个参数to并设置为10。最后,按回车。

seq(from = 1, to = 10)
#>  [1]  1  2  3  4  5  6  7  8  9 10

我们经常省略函数调用中前几个参数的名称,因此我们可以将其重写如下:

seq(1, 10)
#>  [1]  1  2  3  4  5  6  7  8  9 10

输入以下代码,并注意 RStudio 提供了与成对引号相似的帮助:

x <- "hello world"

引号和括号必须成对出现。RStudio 会尽力帮助您,但仍然有可能出错,导致括号不匹配。如果出现这种情况,R 会显示续行字符+:

> x <- "hello
+

+表示 R 正在等待更多输入;它认为您还没有完成。通常,这意味着您忘记了")。要么添加丢失的配对,要么按 Esc 中止表达式,然后重试。

注意,右上角的环境选项卡显示了您创建的所有对象:

RStudio 的环境选项卡显示了全局环境中的 r_rocks、this_is_a_really_long_name、x 和 y。

练习

  1. 为什么这段代码不起作用?

    my_variable <- 10
    my_varıable
    #> Error in eval(expr, envir, enclos): object 'my_varıable' not found
    

    仔细观察!(这可能看起来毫无意义,但是当您编程时,训练大脑注意甚至最微小的差异将会带来回报。)

  2. 调整以下每个 R 命令,使其正确运行:

    libary(todyverse)
    
    ggplot(dTA = mpg) + 
      geom_point(maping = aes(x = displ y = hwy)) +
      geom_smooth(method = "lm)
    
  3. 按 Option+Shift+K/Alt+Shift+K。会发生什么?如何使用菜单达到相同的位置?

  4. 让我们回顾一下“保存您的图形”中的练习。运行以下代码行。哪个图表保存为mpg-plot.png?为什么?

    my_bar_plot <- ggplot(mpg, aes(x = class)) +
      geom_bar()
    my_scatter_plot <- ggplot(mpg, aes(x = cty, y = hwy)) +
      geom_point()
    ggsave(filename = "mpg-plot.png", plot = my_bar_plot)
    

摘要

现在您对 R 代码的工作原理有了更多了解,并获得了一些帮助您在未来回顾代码时理解的提示,在下一章中,我们将继续您的数据科学之旅,教您有关 dplyr 的内容,这是一个帮助您转换数据的 tidyverse 包,无论是选择重要变量,过滤感兴趣的行,还是计算摘要统计数据。

第三章:数据转换

简介

可视化是生成洞察力的重要工具,但很少有您能直接获取您需要的数据来制作想要的图形。通常,您需要创建一些新的变量或总结来回答您的数据问题,或者您可能只是想重新命名变量或重新排序观察以使数据更容易处理。在本章中,您将学习如何执行所有这些操作(以及更多!),介绍使用 dplyr 包和 2013 年离开纽约市的航班数据集进行数据转换。

本章的目标是为您概述转换数据框的所有关键工具。我们将从操作数据框行和列的函数开始,然后我们将回到更多讨论管道,这是一个重要的工具,用于组合动词。然后,我们将介绍如何使用分组进行工作。最后,我们将以一个展示这些功能实际应用的案例研究结束,并在后续章节中更详细地回顾这些函数,深入挖掘特定类型的数据(例如数字、字符串、日期)。

先决条件

在本章中,我们将专注于 dplyr 包,这是 tidyverse 的另一个核心成员。我们将使用 nycflights13 包的数据来说明关键思想,并使用 ggplot2 帮助我们理解数据。

library(nycflights13)
library(tidyverse)
#> ── Attaching core tidyverse packages ───────────────────── tidyverse 2.0.0 ──
#> ✔ dplyr     1.1.0.9000     ✔ readr     2.1.4 
#> ✔ forcats   1.0.0          ✔ stringr   1.5.0 
#> ✔ ggplot2   3.4.1          ✔ tibble    3.1.8 
#> ✔ lubridate 1.9.2          ✔ tidyr     1.3.0 
#> ✔ purrr     1.0.1 
#> ── Conflicts ─────────────────────────────────────── tidyverse_conflicts() ──
#> ✖ dplyr::filter() masks stats::filter()
#> ✖ dplyr::lag()    masks stats::lag()
#> ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all 
#>   conflicts to become errors

在加载 tidyverse 时,务必注意冲突信息消息的内容。它告诉您,dplyr 覆盖了 base R 中的某些函数。如果您希望在加载 dplyr 后使用这些函数的基本版本,则需要使用它们的全名:stats::filter()stats::lag()。到目前为止,我们大多数时间忽略了函数来自哪个包,因为大多数情况下这并不重要。然而,知道包的来源可以帮助您找到帮助以及相关函数,因此当我们需要准确指定函数来自哪个包时,我们将使用与 R 相同的语法:包名::函数名()

nycflights13

要探索基本的 dplyr 动词,我们将使用nycflights13::flights。此数据集包含了 2013 年从纽约市起飞的所有 336,776 架次航班。数据来自美国运输统计局,并在?flights中有详细记录。

flights
#> # A tibble: 336,776 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 336,770 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

flights 是一个 tibble,这是 tidyverse 中用来避免一些常见问题的一种特殊数据框。tibble 和数据框之间最重要的区别是它们的打印方式;它们被设计用于大型数据集,因此只显示前几行和能在一个屏幕上显示的列。有几种方法可以查看所有内容。如果你使用 RStudio,最方便的可能是 View(flights),它将打开一个交互式可滚动和可过滤的视图。否则,你可以使用 print(flights, width = Inf) 来显示所有列,或者使用 glimpse()

glimpse(flights)
#> Rows: 336,776
#> Columns: 19
#> $ year           <int> 2013, 2013, 2013, 2013, 2013, 2013, 2013, 2013, 2013…
#> $ month          <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
#> $ day            <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
#> $ dep_time       <int> 517, 533, 542, 544, 554, 554, 555, 557, 557, 558, 55…
#> $ sched_dep_time <int> 515, 529, 540, 545, 600, 558, 600, 600, 600, 600, 60…
#> $ dep_delay      <dbl> 2, 4, 2, -1, -6, -4, -5, -3, -3, -2, -2, -2, -2, -2,…
#> $ arr_time       <int> 830, 850, 923, 1004, 812, 740, 913, 709, 838, 753, 8…
#> $ sched_arr_time <int> 819, 830, 850, 1022, 837, 728, 854, 723, 846, 745, 8…
#> $ arr_delay      <dbl> 11, 20, 33, -18, -25, 12, 19, -14, -8, 8, -2, -3, 7,…
#> $ carrier        <chr> "UA", "UA", "AA", "B6", "DL", "UA", "B6", "EV", "B6"…
#> $ flight         <int> 1545, 1714, 1141, 725, 461, 1696, 507, 5708, 79, 301…
#> $ tailnum        <chr> "N14228", "N24211", "N619AA", "N804JB", "N668DN", "N…
#> $ origin         <chr> "EWR", "LGA", "JFK", "JFK", "LGA", "EWR", "EWR", "LG…
#> $ dest           <chr> "IAH", "IAH", "MIA", "BQN", "ATL", "ORD", "FLL", "IA…
#> $ air_time       <dbl> 227, 227, 160, 183, 116, 150, 158, 53, 140, 138, 149…
#> $ distance       <dbl> 1400, 1416, 1089, 1576, 762, 719, 1065, 229, 944, 73…
#> $ hour           <dbl> 5, 5, 5, 5, 6, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 5, 6, 6…
#> $ minute         <dbl> 15, 29, 40, 45, 0, 58, 0, 0, 0, 0, 0, 0, 0, 0, 0, 59…
#> $ time_hour      <dttm> 2013-01-01 05:00:00, 2013-01-01 05:00:00, 2013-01-0…

在两种视图中,变量名后面跟着缩写,告诉你每个变量的类型:<int> 代表整数,<dbl> 代表双精度数(也就是实数),<chr> 代表字符(也就是字符串),<dttm> 代表日期时间。这些很重要,因为你可以对列执行的操作很大程度上取决于它的“类型”。

dplyr 基础

你即将学习到主要的 dplyr 动词(函数),这将使你能够解决绝大多数的数据操作挑战。但在讨论它们的个别差异之前,值得声明它们的共同点:

  • 第一个参数始终是数据框。

  • 后续的参数通常描述要操作的列,使用变量名(无需引号)。

  • 输出始终是一个新的数据框。

因为每个动词都能做好一件事情,解决复杂问题通常需要结合多个动词,并且我们将使用管道 |> 进行操作。我们将在“管道”中详细讨论管道,但简单来说,管道接受左边的内容并将其传递给右边的函数,所以 x |> f(y) 等同于 f(x, y)x |> f(y) |> g(z) 等同于 g(f(x, y), z)。最简单的方法是将管道读作“then”。这使得即使你尚未学习细节,也能对以下代码有所了解:

flights |>
  filter(dest == "IAH") |> 
  group_by(year, month, day) |> 
  summarize(
    arr_delay = mean(arr_delay, na.rm = TRUE)
  )

dplyr 的动词按照它们操作的内容分为四组:。在接下来的章节中,你将学习行、列和组的最重要的动词;然后我们将回到在第十九章中操作表的连接动词。让我们开始吧!

操作数据集行的最重要动词是filter(),它更改出现的行而不更改它们的顺序,以及arrange(),它更改行的顺序而不更改出现的行。这两个函数只影响行,而列保持不变。我们还将讨论distinct(),它查找具有唯一值的行,但与arrange()filter()不同,它还可以选择修改列。

filter()

filter()允许您基于列的值保留行。¹ 第一个参数是数据框。第二个及后续参数是必须为真的条件以保留行。例如,我们可以找到所有延误超过 120 分钟(两小时)的航班:

flights |> 
  filter(dep_delay > 120)
#> # A tibble: 9,723 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      848           1835       853     1001           1950
#> 2  2013     1     1      957            733       144     1056            853
#> 3  2013     1     1     1114            900       134     1447           1222
#> 4  2013     1     1     1540           1338       122     2020           1825
#> 5  2013     1     1     1815           1325       290     2120           1542
#> 6  2013     1     1     1842           1422       260     1958           1535
#> # … with 9,717 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

除了 >(大于),您还可以使用 >=(大于或等于),<(小于),<=(小于或等于),==(等于)和 !=(不等于)。您还可以使用 &, 结合条件表示“并且”(检查两个条件)或使用 | 表示“或”(检查任一条件):

# Flights that departed on January 1
flights |> 
  filter(month == 1 & day == 1)
#> # A tibble: 842 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 836 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

# Flights that departed in January or February
flights |> 
  filter(month == 1 | month == 2)
#> # A tibble: 51,955 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 51,949 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

当您结合 |== 时,有一个有用的快捷方式:%in%。它保留变量等于右侧值之一的行:

# A shorter way to select flights that departed in January or February
flights |> 
  filter(month %in% c(1, 2))
#> # A tibble: 51,955 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 51,949 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

我们将在第十二章中更详细地讨论这些比较和逻辑运算符。

运行filter()时,dplyr 执行过滤操作,创建一个新的数据框,然后将其打印出来。它不会修改现有的 flights 数据集,因为 dplyr 函数从不修改它们的输入。要保存结果,您需要使用赋值运算符 <-

jan1 <- flights |> 
  filter(month == 1 & day == 1)

常见错误

在开始使用 R 时,最容易犯的错误是在测试相等性时使用 = 而不是 ==filter()会在发生这种情况时通知您:

flights |> 
  filter(month = 1)
#> Error in `filter()`:
#> ! We detected a named input.
#> ℹ This usually means that you've used `=` instead of `==`.
#> ℹ Did you mean `month == 1`?

另一个错误是像在英语中那样编写“或”语句:

flights |> 
  filter(month == 1 | 2)

这“有效”,意味着它不会报错,但它不会做你想要的事情,因为 | 首先检查条件 month == 1,然后检查条件 2,这不是一个明智的条件。我们将在“布尔运算”中详细了解这里发生的情况和原因。

arrange()

arrange()根据列的值更改行的顺序。 它接受数据框和一组列名(或更复杂的表达式)来排序。 如果提供多个列名,则每个额外的列将用于打破前面列值的关系。 例如,以下代码按出发时间排序,该时间分布在四列中。 我们首先得到最早的年份,然后在一年内得到最早的月份,等等。

flights |> 
  arrange(year, month, day, dep_time)
#> # A tibble: 336,776 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 336,770 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

您可以在arrange()内部的列上使用desc()来按降序(从大到小)重新排序数据框。 例如,此代码按最长延误的航班排序:

flights |> 
  arrange(desc(dep_delay))
#> # A tibble: 336,776 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     9      641            900      1301     1242           1530
#> 2  2013     6    15     1432           1935      1137     1607           2120
#> 3  2013     1    10     1121           1635      1126     1239           1810
#> 4  2013     9    20     1139           1845      1014     1457           2210
#> 5  2013     7    22      845           1600      1005     1044           1815
#> 6  2013     4    10     1100           1900       960     1342           2211
#> # … with 336,770 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

请注意,行数没有改变。 我们只是排列数据; 我们没有过滤它。

distinct()

distinct()在数据集中找到所有唯一的行,因此在技术上,它主要操作行。 大多数情况下,但是,您将希望某些变量的唯一组合,因此还可以选择提供列名:

# Remove duplicate rows, if any
flights |> 
  distinct()
#> # A tibble: 336,776 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 336,770 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

# Find all unique origin and destination pairs
flights |> 
  distinct(origin, dest)
#> # A tibble: 224 × 2
#>   origin dest 
#>   <chr>  <chr>
#> 1 EWR    IAH 
#> 2 LGA    IAH 
#> 3 JFK    MIA 
#> 4 JFK    BQN 
#> 5 LGA    ATL 
#> 6 EWR    ORD 
#> # … with 218 more rows

或者,如果您想在过滤唯一行时保留其他列,可以使用.keep_all = TRUE选项:

flights |> 
  distinct(origin, dest, .keep_all = TRUE)
#> # A tibble: 224 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 218 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

所有这些独特航班都是在 1 月 1 日,这并非偶然:distinct()将找到数据集中唯一行的第一次出现并且丢弃其余。

如果您想要找到发生的次数,最好将distinct()换成count(),并且通过sort = TRUE参数,您可以按发生次数降序排列它们。 您将在“计数”中了解更多信息。

flights |>
  count(origin, dest, sort = TRUE)
#> # A tibble: 224 × 3
#>   origin dest      n
#>   <chr>  <chr> <int>
#> 1 JFK    LAX   11262
#> 2 LGA    ATL   10263
#> 3 LGA    ORD    8857
#> 4 JFK    SFO    8204
#> 5 LGA    CLT    6168
#> 6 EWR    ORD    6100
#> # … with 218 more rows

练习

  1. 对于每个条件的单一管道,请查找满足条件的所有航班:

    • 到达延误两个或更多小时

    • 飞往休斯顿(IAHHOU

    • 由联合、美国或达美运营

    • 在夏季(7 月、8 月和 9 月)起飞

    • 到达晚于两小时,但未晚点离开

    • 起飞至少推迟了一个小时,但在飞行中超过 30 分钟

  2. flights进行排序,以找到出发延误最长的航班。 找到早晨出发最早的航班。

  3. flights进行排序,以找到最快的航班。(提示:尝试在函数内部包含数学计算。)

  4. 2013 年每天都有航班吗?

  5. 哪些航班飞行的距离最远? 哪些飞行的距离最短?

  6. 如果同时使用filter()arrange(),使用顺序是否重要?为什么?考虑结果以及函数需要执行的工作量。

有四个重要的动词会影响列而不改变行:mutate()创建新列,这些列是从现有列派生出来的;select()改变存在的列;rename()改变列的名称;以及relocate()改变列的位置。

mutate()

mutate()的作用是添加新列,这些列是从现有列计算而来的。在后续的转换章节中,您将学习一系列函数,可以用来操作不同类型的变量。目前,我们将继续使用基本的代数,这样可以计算gain(延误航班在空中弥补的时间量)和以英里每小时计算的speed

flights |> 
  mutate(
    gain = dep_delay - arr_delay,
    speed = distance / air_time * 60
  )
#> # A tibble: 336,776 × 21
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 336,770 more rows, and 13 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

默认情况下,mutate()会在数据集的右侧添加新列,这使得难以理解正在发生的情况。我们可以使用.before参数将变量添加到左侧:

flights |> 
  mutate(
    gain = dep_delay - arr_delay,
    speed = distance / air_time * 60,
    .before = 1
  )
#> # A tibble: 336,776 × 21
#>    gain speed  year month   day dep_time sched_dep_time dep_delay arr_time
#>   <dbl> <dbl> <int> <int> <int>    <int>          <int>     <dbl>    <int>
#> 1    -9  370\.  2013     1     1      517            515         2      830
#> 2   -16  374\.  2013     1     1      533            529         4      850
#> 3   -31  408\.  2013     1     1      542            540         2      923
#> 4    17  517\.  2013     1     1      544            545        -1     1004
#> 5    19  394\.  2013     1     1      554            600        -6      812
#> 6   -16  288\.  2013     1     1      554            558        -4      740
#> # … with 336,770 more rows, and 12 more variables: sched_arr_time <int>,
#> #   arr_delay <dbl>, carrier <chr>, flight <int>, tailnum <chr>, …

.是一个标志,指示.before是函数的参数,而不是我们正在创建的第三个新变量的名称。您还可以使用.after在变量后添加,.before.after都可以使用变量名而不是位置。例如,我们可以在day后添加新变量:

flights |> 
  mutate(
    gain = dep_delay - arr_delay,
    speed = distance / air_time * 60,
    .after = day
  )

或者,您可以使用.keep参数来控制保留哪些变量。特别有用的参数是"used",它指定我们仅保留在mutate()步骤中涉及或创建的列。例如,以下输出仅包含变量dep_delayarr_delayair_timegainhoursgain_per_hour

flights |> 
  mutate(
    gain = dep_delay - arr_delay,
    hours = air_time / 60,
    gain_per_hour = gain / hours,
    .keep = "used"
  )

请注意,由于我们尚未将上述计算的结果分配回flights,新变量gainhoursgain_per_hour只会被打印出来,并不会存储在数据框中。如果我们希望它们在未来的使用中可用于数据框,我们应该仔细考虑是否希望将结果分配回flights,覆盖原始数据框并添加更多变量,或者分配到一个新对象。通常情况下,正确答案是创建一个具有信息性命名的新对象,例如delay_gain,但您可能也有充分的理由覆盖flights

select()

很常见的情况是得到包含数百甚至数千个变量的数据集。在这种情况下,第一个挑战通常是集中精力处理你感兴趣的变量。select()允许你通过基于变量名称的操作迅速缩小范围,聚焦于有用的子集:

  • 根据名称选择列:

    flights |> 
      select(year, month, day)
    
  • 选择从年到日之间的所有列(包括年和日):

    flights |> 
      select(year:day)
    
  • 选择除了年到日之间的所有列之外的列:

    flights |> 
      select(!year:day)
    

    你也可以使用-代替!(在实际应用中可能会看到),但我们建议使用!,因为它表示“非”,并且与&|结合使用效果更佳。

  • 选择所有字符类型的列

    flights |> 
      select(where(is.character))
    

select()中可以使用多个辅助函数:

starts_with("abc")

匹配以“abc”开头的名称

ends_with("xyz")

匹配以“xyz”结尾的名称

contains("ijk")

匹配包含“ijk”的名称

num_range("x", 1:3)

匹配x1x2x3

查看?select获取更多细节。一旦你掌握了正则表达式(第十五章的主题),你还可以使用matches()选择与模式匹配的变量。

你可以在select()中通过使用=来重命名变量。新名称显示在=的左侧,旧变量显示在右侧:

flights |> 
  select(tail_num = tailnum)
#> # A tibble: 336,776 × 1
#>   tail_num
#>   <chr> 
#> 1 N14228 
#> 2 N24211 
#> 3 N619AA 
#> 4 N804JB 
#> 5 N668DN 
#> 6 N39463 
#> # … with 336,770 more rows

rename()

如果你想保留所有现有的变量,只想重新命名其中的几个,可以使用rename(),而不是select()

flights |> 
  rename(tail_num = tailnum)
#> # A tibble: 336,776 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 336,770 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tail_num <chr>, origin <chr>, dest <chr>, …

如果你有一堆命名不一致的列,并且手动修复它们都很麻烦,可以查看janitor::clean_names(),它提供了一些有用的自动清理功能。

relocate()

使用relocate()移动变量。你可能希望将相关变量集中在一起或将重要变量移到前面。默认情况下,relocate()将变量移到最前面:

flights |> 
  relocate(time_hour, air_time)
#> # A tibble: 336,776 × 19
#>   time_hour           air_time  year month   day dep_time sched_dep_time
#>   <dttm>                 <dbl> <int> <int> <int>    <int>          <int>
#> 1 2013-01-01 05:00:00      227  2013     1     1      517            515
#> 2 2013-01-01 05:00:00      227  2013     1     1      533            529
#> 3 2013-01-01 05:00:00      160  2013     1     1      542            540
#> 4 2013-01-01 05:00:00      183  2013     1     1      544            545
#> 5 2013-01-01 06:00:00      116  2013     1     1      554            600
#> 6 2013-01-01 05:00:00      150  2013     1     1      554            558
#> # … with 336,770 more rows, and 12 more variables: dep_delay <dbl>,
#> #   arr_time <int>, sched_arr_time <int>, arr_delay <dbl>, carrier <chr>, …

你还可以像在mutate()中一样使用.before.after参数指定它们放置的位置:

flights |> 
  relocate(year:dep_time, .after = time_hour)
flights |> 
  relocate(starts_with("arr"), .before = dep_time)

练习

  1. 比较dep_timesched_dep_timedep_delay。你会预期这三个数字有什么关系?

  2. 思考尽可能多的方法来从flights中选择dep_timedep_delayarr_timearr_delay

  3. 如果在select()调用中多次指定同一个变量的名称会发生什么?

  4. any_of()函数的作用是什么?为什么它在与这个向量结合时可能有帮助?

    variables <- c("year", "month", "day", "dep_delay", "arr_delay")
    
  5. 运行以下代码的结果是否让您惊讶?选择助手如何默认处理大写和小写?如何更改该默认设置?

    flights |> select(contains("TIME"))
    
  6. air_time重命名为air_time_min以指示测量单位,并将其移到数据框的开头。

  7. 为什么以下方法不起作用,错误的含义是什么?

    flights |> 
      select(tailnum) |> 
      arrange(arr_delay)
    #> Error in `arrange()`:
    #> ℹ In argument: `..1 = arr_delay`.
    #> Caused by error:
    #> ! object 'arr_delay' not found
    

管道

我们向您展示了管道的简单示例,但其真正的力量是当您开始组合多个动词时。

例如,想象一下,您想找到飞往休斯顿 IAH 机场的快速航班:您需要结合filter()mutate()select()arrange()

flights |> 
  filter(dest == "IAH") |> 
  mutate(speed = distance / air_time * 60) |> 
  select(year:day, dep_time, carrier, flight, speed) |> 
  arrange(desc(speed))
#> # A tibble: 7,198 × 7
#>    year month   day dep_time carrier flight speed
#>   <int> <int> <int>    <int> <chr>    <int> <dbl>
#> 1  2013     7     9      707 UA         226  522.
#> 2  2013     8    27     1850 UA        1128  521.
#> 3  2013     8    28      902 UA        1711  519.
#> 4  2013     8    28     2122 UA        1022  519.
#> 5  2013     6    11     1628 UA        1178  515.
#> 6  2013     8    27     1017 UA         333  515.
#> # … with 7,192 more rows

尽管此管道有四个步骤,但它很容易浏览,因为每行的动词都在开头:从flights数据开始,然后过滤,然后变异,然后选择,最后安排。

如果没有管道会发生什么?我们可以将每个函数调用嵌套在前一个调用内:

arrange(
  select(
    mutate(
      filter(
        flights, 
        dest == "IAH"
      ),
      speed = distance / air_time * 60
    ),
    year:day, dep_time, carrier, flight, speed
  ),
  desc(speed)
)

或者我们可以使用一堆中间对象:

flights1 <- filter(flights, dest == "IAH")
flights2 <- mutate(flights1, speed = distance / air_time * 60)
flights3 <- select(flights2, year:day, dep_time, carrier, flight, speed)
arrange(flights3, desc(speed))

虽然两种形式各有各的时间和场合,但管道通常生成更容易编写和阅读的数据分析代码。

要将管道符添加到您的代码中,我们建议使用内置键盘快捷键 Ctrl/Cmd+Shift+M。您需要对您的 RStudio 选项进行一次更改,以使用|>代替%>%,如图 3-1 所示;稍后详细介绍%>%

显示“使用本地管道运算符”选项的截图,该选项可以在“代码”选项的“编辑”面板中找到。

图 3-1. 要插入|>,请确保选中“使用本地管道运算符”选项。

magrittr

如果您已经使用 tidyverse 一段时间,您可能已经熟悉 magrittr 包提供的%>%管道。magrittr 包包含在核心 tidyverse 中,因此您可以在加载 tidyverse 时使用%>%

library(tidyverse)

mtcars %>% 
  group_by(cyl) %>%
  summarize(n = n())

对于简单的情况,|>%>%行为完全相同。那么为什么我们推荐基本管道呢?首先,因为它是 base R 的一部分,所以您在不使用 tidyverse 时仍然可以使用它。其次,|>%>%简单得多:在 2014 年发明%>%和在 2021 年 R 4.1.0 中包含|>之间的时间内,我们对管道有了更好的理解。这使得基本实现可以舍弃不常用和不重要的功能。

到目前为止,你已经学习了如何处理行和列的函数。当你添加对分组的处理能力时,dplyr 变得更加强大。在这一节中,我们将重点介绍最重要的函数:group_by()summarize()以及切片函数系列。

group_by()

使用group_by()将数据集分成对你的分析有意义的组:

flights |> 
  group_by(month)
#> # A tibble: 336,776 × 19
#> # Groups:   month [12]
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 336,770 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

group_by()不会改变数据,但如果你仔细观察输出,你会注意到输出表明它是按月份分组的(Groups: month [12])。这意味着接下来的操作将按“月份”进行。group_by()将这种分组特性(称为class)添加到数据框中,这会改变对数据应用后续动词时的行为。

summarize()

最重要的分组操作是摘要,如果用于计算单一摘要统计量,则将数据框减少到每组一个行。在 dplyr 中,这个操作由summarize()³执行,如下例所示,计算每月的平均出发延误:

flights |> 
  group_by(month) |> 
  summarize(
    avg_delay = mean(dep_delay)
  )
#> # A tibble: 12 × 2
#>   month avg_delay
#>   <int>     <dbl>
#> 1     1        NA
#> 2     2        NA
#> 3     3        NA
#> 4     4        NA
#> 5     5        NA
#> 6     6        NA
#> # … with 6 more rows

啊哦!出了点问题,我们所有的结果都变成了NA(读作“N-A”),R 中代表缺失值的符号。这是因为观察到的一些航班在延误列中有缺失数据,因此当我们计算包括这些值的均值时,得到了NA结果。我们将在第十八章中详细讨论缺失值,但现在我们告诉mean()函数通过将参数na.rm设置为TRUE来忽略所有缺失值:

flights |> 
  group_by(month) |> 
  summarize(
    delay = mean(dep_delay, na.rm = TRUE)
  )
#> # A tibble: 12 × 2
#>   month delay
#>   <int> <dbl>
#> 1     1  10.0
#> 2     2  10.8
#> 3     3  13.2
#> 4     4  13.9
#> 5     5  13.0
#> 6     6  20.8
#> # … with 6 more rows

在单次调用summarize()中,你可以创建任意数量的摘要。在接下来的章节中,你将学习到各种有用的摘要方式,但其中一个有用的摘要是n(),它返回每个组中的行数:

flights |> 
  group_by(month) |> 
  summarize(
    delay = mean(dep_delay, na.rm = TRUE), 
    n = n()
  )
#> # A tibble: 12 × 3
#>   month delay     n
#>   <int> <dbl> <int>
#> 1     1  10.0 27004
#> 2     2  10.8 24951
#> 3     3  13.2 28834
#> 4     4  13.9 28330
#> 5     5  13.0 28796
#> 6     6  20.8 28243
#> # … with 6 more rows

在数据科学中,均值和计数可以帮助你走得更远!

切片函数

有五个便捷的函数允许你在每个组内提取特定的行:

df |> slice_head(n = 1)

取每个组的第一行。

df |> slice_tail(n = 1)

取每个组的最后一行

df |> slice_min(x, n = 1)

x列中值最小的行。

df |> slice_max(x, n = 1)

x列中值最大的行。

df |> slice_sample(n = 1)

取一个随机行。

您可以变化 n 以选择多于一个行,或者,您可以使用 prop = 0.1 代替 n = 来选择每个组中的,例如,10% 的行。例如,以下代码查找到达每个目的地时最延迟的航班:

flights |> 
  group_by(dest) |> 
  slice_max(arr_delay, n = 1) |>
  relocate(dest)
#> # A tibble: 108 × 19
#> # Groups:   dest [105]
#>   dest   year month   day dep_time sched_dep_time dep_delay arr_time
#>   <chr> <int> <int> <int>    <int>          <int>     <dbl>    <int>
#> 1 ABQ    2013     7    22     2145           2007        98      132
#> 2 ACK    2013     7    23     1139            800       219     1250
#> 3 ALB    2013     1    25      123           2000       323      229
#> 4 ANC    2013     8    17     1740           1625        75     2042
#> 5 ATL    2013     7    22     2257            759       898      121
#> 6 AUS    2013     7    10     2056           1505       351     2347
#> # … with 102 more rows, and 11 more variables: sched_arr_time <int>,
#> #   arr_delay <dbl>, carrier <chr>, flight <int>, tailnum <chr>, …

请注意,这里有 105 个目的地,但我们在这里得到了 108 行。怎么回事?slice_min()slice_max() 保留相同的值,所以 n = 1 意味着给我们所有具有最高值的行。如果您希望每组确实只有一行,您可以设置 with_ties = FALSE

这类似于使用 summarize() 计算最大延迟,但您将得到整个相应的行(如果有并列的话,可能是多行),而不是单个摘要统计。

按多个变量分组

您可以使用多个变量创建分组。例如,我们可以为每个日期创建一个分组:

daily <- flights |>  
  group_by(year, month, day)
daily
#> # A tibble: 336,776 × 19
#> # Groups:   year, month, day [365]
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 336,770 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

当您对多个变量进行汇总后的表分组时,每个汇总都会去掉最后一个分组。事后看来,这不是使此函数工作的好方法,但如果不破坏现有代码,这很难更改。为了明确正在发生的事情,dplyr 显示一条消息,告诉您如何更改此行为:

daily_flights <- daily |> 
  summarize(n = n())
#> `summarise()` has grouped output by 'year', 'month'. You can override using
#> the `.groups` argument.

如果您对此行为感到满意,您可以显式请求它以抑制消息:

daily_flights <- daily |> 
  summarize(
    n = n(), 
    .groups = "drop_last"
  )

或者通过设置不同的值(例如 "drop" 以删除所有分组或 "keep" 以保留相同的分组)来改变默认行为:

取消分组

您可能还希望在不使用 summarize() 的情况下从数据框中移除分组。您可以使用 ungroup() 来做到这一点:

daily |> 
  ungroup()
#> # A tibble: 336,776 × 19
#>    year month   day dep_time sched_dep_time dep_delay arr_time sched_arr_time
#>   <int> <int> <int>    <int>          <int>     <dbl>    <int>          <int>
#> 1  2013     1     1      517            515         2      830            819
#> 2  2013     1     1      533            529         4      850            830
#> 3  2013     1     1      542            540         2      923            850
#> 4  2013     1     1      544            545        -1     1004           1022
#> 5  2013     1     1      554            600        -6      812            837
#> 6  2013     1     1      554            558        -4      740            728
#> # … with 336,770 more rows, and 11 more variables: arr_delay <dbl>,
#> #   carrier <chr>, flight <int>, tailnum <chr>, origin <chr>, dest <chr>, …

现在让我们看看当您对未分组的数据框进行汇总时会发生什么:

daily |> 
  ungroup() |>
  summarize(
    avg_delay = mean(dep_delay, na.rm = TRUE), 
    flights = n()
  )
#> # A tibble: 1 × 2
#>   avg_delay flights
#>       <dbl>   <int>
#> 1      12.6  336776

您会得到一行,因为 dplyr 将未分组的数据框的所有行视为属于一个组。

.by

dplyr 1.1.0 包含一个新的、实验性的句法,用于每个操作的分组,即 .by 参数。group_by()ungroup() 并未被淘汰,但现在你也可以使用 .by 参数在单个操作内进行分组:

flights |> 
  summarize(
    delay = mean(dep_delay, na.rm = TRUE), 
    n = n(),
    .by = month
  )

或者如果您想按多个变量分组:

flights |> 
  summarize(
    delay = mean(dep_delay, na.rm = TRUE), 
    n = n(),
    .by = c(origin, dest)
  )

.by 适用于所有动词,并且具有这样的优势,即您在完成时不需要使用 .groups 参数来抑制分组消息或使用 ungroup()

我们在本章中没有专注于此句法,因为在我们编写书籍时它是非常新的。我们想提一下它,因为我们认为它具有很大的潜力,并且很可能会相当受欢迎。您可以在 dplyr 1.1.0 博客文章 中了解更多信息。

练习

  1. 哪个承运商的平均延误最严重?挑战:你能分辨出恶劣机场与糟糕航空公司的影响吗?为什么/为什么不?(提示:思考flights |> group_by(carrier, dest) |> summarize(n())。)

  2. 找出从每个目的地出发时延误最严重的航班。

  3. 一天中延误如何变化。用图表说明你的答案。

  4. 如果你给slice_min()等函数提供一个负的n会发生什么?

  5. 解释count()在你刚学习的 dplyr 动词中的作用。count()中的sort参数有何作用?

  6. 假设我们有以下微小的数据框:

    df <- tibble(
      x = 1:5,
      y = c("a", "b", "a", "a", "b"),
      z = c("K", "K", "L", "L", "K")
    )
    
    1. 写下你认为输出会是什么样子;然后检查你是否正确,并描述group_by()的作用。

      df |>
        group_by(y)
      
    2. 写下你认为输出会是什么样子;然后检查你是否正确,并描述arrange()的作用。还评论它与第(a)部分的group_by()有何不同。

      df |>
        arrange(y)
      
    3. 写下你认为输出会是什么样子;然后检查你是否正确,并描述管道的作用。

      df |>
        group_by(y) |>
        summarize(mean_x = mean(x))
      
    4. 写下你认为输出会是什么样子;然后检查你是否正确,并描述管道的作用。然后,评论消息的内容。

      df |>
        group_by(y, z) |>
        summarize(mean_x = mean(x))
      
    5. 写下你认为输出会是什么样子;然后检查你是否正确,并描述管道的作用。输出与第(d)部分的有何不同?

      df |>
        group_by(y, z) |>
        summarize(mean_x = mean(x), .groups = "drop")
      
    6. 写下你认为输出会是什么样子;然后检查你是否正确,并描述每个管道的作用。这两个管道的输出有何不同?

      df |>
        group_by(y, z) |>
        summarize(mean_x = mean(x))
      
      df |>
        group_by(y, z) |>
        mutate(mean_x = mean(x))
      

案例研究:聚合和样本大小

每当进行任何聚合操作时,都建议包括一个计数(n())。这样,你可以确保不是基于非常少的数据得出结论。我们将使用 Lahman 包中的一些棒球数据来演示这一点。具体来说,我们将比较球员击中球(H)的次数与他们尝试击球(AB)的次数的比例:

batters <- Lahman::Batting |> 
  group_by(playerID) |> 
  summarize(
    performance = sum(H, na.rm = TRUE) / sum(AB, na.rm = TRUE),
    n = sum(AB, na.rm = TRUE)
  )
batters
#> # A tibble: 20,166 × 3
#>   playerID  performance     n
#>   <chr>           <dbl> <int>
#> 1 aardsda01      0          4
#> 2 aaronha01      0.305  12364
#> 3 aaronto01      0.229    944
#> 4 aasedo01       0          5
#> 5 abadan01       0.0952    21
#> 6 abadfe01       0.111      9
#> # … with 20,160 more rows

当我们将击球手的技能(以击球率performance衡量)与击球次数(以击球数n衡量)进行绘图时,我们看到两种模式:

  • 在击球手的表现中,少量击球的球员变化较大。这种图形的形状非常特征化:每当你绘制平均值(或其他汇总统计信息)与组大小的关系时,你会看到随着样本大小增加,变化会减少。⁴

  • 技能(performance)和击球机会(n)之间存在正相关,因为球队希望让他们最擅长击球的击球手获得最多的击球机会。

batters |> 
  filter(n > 100) |> 
  ggplot(aes(x = n, y = performance)) +
  geom_point(alpha = 1 / 10) + 
  geom_smooth(se = FALSE)

一个散点图,显示击球表现次数与击球机会之间的关系,并覆盖一个平滑线。平均表现从 n 为 1 时的 0.2 急剧增加到 n 约为 1000 时的 0.25。当 n 约为 15000 时,平均表现继续线性增长,但斜率更缓和,约为 0.3。

注意将 ggplot2 和 dplyr 结合的方便模式。你只需记住从|>用于数据集处理,转换成+用于添加图层到你的图表。

这对排名也有重要影响。如果你天真地按照desc(performance)排序,那些击球率最高的人显然是那些很少尝试击球但偶尔击中的人;他们不一定是最有技能的球员:

batters |> 
  arrange(desc(performance))
#> # A tibble: 20,166 × 3
#>   playerID  performance     n
#>   <chr>           <dbl> <int>
#> 1 abramge01           1     1
#> 2 alberan01           1     1
#> 3 banisje01           1     1
#> 4 bartocl01           1     1
#> 5 bassdo01            1     1
#> 6 birasst01           1     2
#> # … with 20,160 more rows

你可以在 David RobinsonEvan Miller 的博客文章中找到对这个问题及其解决方法的详细解释。

概要

在本章中,你学习了 dplyr 提供的用于处理数据框的工具。这些工具大致分为三类:操作行的(如filter()arrange()),操作列的(如select()mutate()),以及操作分组的(如group_by()summarize())。在本章中,我们专注于这些“整个数据框”工具,但你还没有学到如何处理单个变量的内容。我们将在第三部分回到这个问题。

在下一章中,我们将回到工作流程,讨论代码风格的重要性,保持代码的良好组织,以便你和他人能够轻松地阅读和理解你的代码。

¹ 后来,你将学习到slice_*()系列函数,允许你根据它们的位置选择行。

² 请记住,在 RStudio 中,查看具有多列的数据集的最简单方法是使用View()函数。

³ 或者如果你更喜欢英式英语,可以使用summarise()

咳咳 大数定律 咳咳

第四章:工作流:代码风格

良好的编码风格就像正确的标点符号一样重要:没有也能用,但它确实使事情更易读。即使是刚入门的程序员,也应该注意自己的代码风格。使用一致的风格可以让他人(包括未来的自己!)更容易阅读你的作品,如果需要从他人那里获得帮助,则尤为重要。本章将介绍《整洁宇宙风格指南》中的最重要内容点,该指南贯穿本书始终。

虽然一开始为代码添加样式会感觉有点乏味,但如果你练习一下,它很快就会变得自然。此外,有一些很棒的工具可以快速重置现有的代码格式,比如 Lorenz Walthert 的 styler 包。安装完毕后,你可以通过 RStudio 的 命令面板 很容易地使用它。命令面板让你使用任何内置的 RStudio 命令和许多包提供的插件。按下 Cmd/Ctrl+Shift+P 即可打开面板,然后输入 styler,以查看 styler 提供的所有快捷方式。图 4-1 展示了结果。

显示输入“styler”后命令面板的屏幕截图,展示该包提供的四种样式工具。

图 4-1. RStudio 的命令面板使得只用键盘就能轻松访问每一个 RStudio 命令。

在本章中,我们将使用 tidyverse 和 nycflights13 包作为代码示例。

library(tidyverse)
library(nycflights13)

名字

我们简要讨论了 “名字的重要性”。请记住,变量名(通过 <- 创建的和通过 mutate() 创建的)应该只使用小写字母、数字和 _。用 _ 分隔名称中的单词。

# Strive for:
short_flights <- flights |> filter(air_time < 60)

# Avoid:
SHORTFLIGHTS <- flights |> filter(air_time < 60)

一般而言,最好使用长而描述清晰的名称,而不是简短而便于输入的名称。在编写代码时,使用简短名称节约的时间相对较少(特别是因为自动完成会帮助你完成输入),但当你回到旧代码并被迫解决晦涩的缩写时,会耗费大量时间。

如果你有一堆相关事物的名称,请尽量保持一致。当你忘记之前的约定时,不一致很容易出现,所以如果需要,回过头重新命名并不可耻。一般来说,如果你有一堆变量,它们都是主题的变体,最好给它们一个共同的前缀,而不是一个共同的后缀,因为自动完成在变量的开头效果更好。

空格

数学运算符两侧都要加空格,除了 ^(即 +-==< 等等),并且赋值操作符(<-)的周围也要加空格。

# Strive for
z <- (a + b)² / d

# Avoid
z<-( a + b ) ^ 2/d

在常规函数调用的括号内外不要加空格。逗号后始终加一个空格,就像标准英语一样。

# Strive for
mean(x, na.rm = TRUE)

# Avoid
mean (x ,na.rm=TRUE)

如果加入额外的空格可以改善对齐,这是可以的。例如,如果你在 mutate() 中创建多个变量,可能需要添加空格,以便所有的 = 对齐。¹ 这样做可以让代码更易于快速浏览。

flights |> 
  mutate(
    speed      = distance / air_time,
    dep_hour   = dep_time %/% 100,
    dep_minute = dep_time %%  100
  )

管道

|> 前应始终有一个空格,并且通常应该是行末的最后一个元素。这样可以更轻松地添加新步骤,重新排列现有步骤,修改步骤内的元素,并通过快速浏览左侧动词来获得全局视图。

# Strive for 
flights |>  
  filter(!is.na(arr_delay), !is.na(tailnum)) |> 
  count(dest)

# Avoid
flights|>filter(!is.na(arr_delay), !is.na(tailnum))|>count(dest)

如果你要传递到管道中的函数具有命名参数(如 mutate()summarize()),每个参数放在新的一行上。如果函数没有命名参数(如 select()filter()),除非不适合,否则保持一行。在这种情况下,应将每个参数放在单独的行上。

# Strive for
flights |>  
  group_by(tailnum) |> 
  summarize(
    delay = mean(arr_delay, na.rm = TRUE),
    n = n()
  )

# Avoid
flights |>
  group_by(
    tailnum
  ) |> 
  summarize(delay = mean(arr_delay, na.rm = TRUE), n = n())

管道的第一步完成后,每行缩进两个空格。在 |> 后的换行符后,RStudio 会自动为你添加空格。如果你将每个参数放在单独的行上,额外缩进两个空格。确保 ) 单独一行,并且不缩进以匹配函数名的水平位置。

# Strive for 
flights |>  
  group_by(tailnum) |> 
  summarize(
    delay = mean(arr_delay, na.rm = TRUE),
    n = n()
  )

# Avoid
flights|>
  group_by(tailnum) |> 
  summarize(
             delay = mean(arr_delay, na.rm = TRUE), 
             n = n()
           )

# Avoid
flights|>
  group_by(tailnum) |> 
  summarize(
  delay = mean(arr_delay, na.rm = TRUE), 
  n = n()
  )

如果你的管道可以轻松放在一行上,那么放弃一些规则是可以接受的。但根据我们的集体经验,短代码片段通常会变得更长,因此从一开始就使用所需的所有垂直空间通常能节省时间。

# This fits compactly on one line
df |> mutate(y = x + 1)

# While this takes up 4x as many lines, it's easily extended to 
# more variables and more steps in the future
df |> 
  mutate(
    y = x + 1
  )

最后,要注意编写非常长的管道,例如超过 10–15 行。尝试将其分解为更小的子任务,并为每个任务赋予一个信息性的名称。名称将帮助读者了解正在发生的事情,并且使得检查中间结果是否符合预期更加容易。每当可以为某个东西提供一个信息性的名称时,都应该这样做,例如在基本上改变数据结构后,例如在旋转或汇总之后。不要指望第一次就做对!这意味着如果有中间状态可以获得良好的名称,则应拆分长管道。

ggplot2

与管道相同的基本规则也适用于 ggplot2;只需将 +|> 一样处理:

flights |> 
  group_by(month) |> 
  summarize(
    delay = mean(arr_delay, na.rm = TRUE)
  ) |> 
  ggplot(aes(x = month, y = delay)) +
  geom_point() + 
  geom_line()

如果不能将所有函数的参数放在一行上,请将每个参数放在单独的行上:

flights |> 
  group_by(dest) |> 
  summarize(
    distance = mean(distance),
    speed = mean(distance / air_time, na.rm = TRUE)
  ) |> 
  ggplot(aes(x = distance, y = speed)) +
  geom_smooth(
    method = "loess",
    span = 0.5,
    se = FALSE, 
    color = "white", 
    linewidth = 4
  ) +
  geom_point()

注意从 |>+ 的过渡。我们希望这种过渡不是必需的,但不幸的是,ggplot2 是在发现管道之前编写的。

分段注释

当你的脚本变得更长时,可以使用分段注释将文件分解成可管理的片段:

# Load data --------------------------------------

# Plot data --------------------------------------

RStudio 提供了一个键盘快捷键来创建这些标题(Cmd/Ctrl+Shift+R),并会在编辑器左下角的代码导航下拉菜单中显示,如图 4-2 所示。

图 4-2. 在给脚本添加分区注释后,您可以使用脚本编辑器左下角的代码导航工具轻松导航到它们。

练习

  1. 根据前面的准则重新设计以下流水线:

    flights|>filter(dest=="IAH")|>group_by(year,month,day)|>summarize(n=n(),
    delay=mean(arr_delay,na.rm=TRUE))|>filter(n>10)
    
    flights|>filter(carrier=="UA",dest%in%c("IAH","HOU"),sched_dep_time>
    0900,sched_arr_time<2000)|>group_by(flight)|>summarize(delay=mean(
    arr_delay,na.rm=TRUE),cancelled=sum(is.na(arr_delay)),n=n())|>filter(n>10)
    

摘要

在本章中,您学习了代码风格的最重要原则。这些可能一开始感觉像一套任意的规则(因为它们确实是!),但随着时间的推移,随着您编写更多的代码并与更多人分享代码,您将会意识到一致的风格是多么重要。而且不要忘记 styler 包:它是快速提高低质量代码质量的好方法。

在下一章中,我们将切换回数据科学工具,学习关于整洁数据的知识。整洁数据是一种一致的组织数据框的方式,贯穿于整洁宇宙。这种一致性使您的生活更轻松,因为一旦有了整洁的数据,它就能与大多数整洁宇宙函数完美配合。当然,生活从来不会轻松,你在野外遇到的大多数数据集都不会是整洁的。因此,我们还会教您如何使用 tidyr 包来整理您的混乱数据。

¹ 因为dep_time是以HMMHHMM格式表示的,我们使用整数除法(%/%)来获取小时和余数(也称为模数,%%)来获取分钟。

第五章:数据整理

介绍

“所有幸福的家庭都是相似的;每个不幸的家庭都有各自的不幸。”

—列夫·托尔斯泰

“整洁的数据集都是相似的,但每个混乱的数据集都有其自身的混乱之处。”

—哈德利·威克姆

在本章中,您将学习使用一种称为tidy 数据的系统在 R 中组织数据的一致方式。将数据整理到这种格式需要一些前期工作,但长远来看会得到回报。一旦您有了整洁的数据和 tidyverse 包提供的整洁工具,您将花费更少的时间从一种表示转换数据,从而有更多时间处理您关心的数据问题。

在本章中,您将首先了解整洁数据的定义,并看到它应用于一个简单的玩具数据集。然后我们将深入研究您用于整理数据的主要工具:数据透视。数据透视允许您在不改变任何值的情况下改变数据的形式。

先决条件

在本章中,我们将专注于 tidyr,这是一个提供大量工具帮助您整理混乱数据集的包。tidyr 是核心 tidyverse 成员之一。

library(tidyverse)

从本章开始,我们将抑制来自library(tidyverse)的加载消息。

整洁数据

你可以用多种方式表示相同的基础数据。以下示例展示了相同数据以三种不同方式组织的情况。每个数据集显示了四个变量的相同值:国家年份人口和肺结核(TB)的记录病例数,但每个数据集以不同的方式组织这些值。

table1
#> # A tibble: 6 × 4
#>   country      year  cases population
#>   <chr>       <dbl>  <dbl>      <dbl>
#> 1 Afghanistan  1999    745   19987071
#> 2 Afghanistan  2000   2666   20595360
#> 3 Brazil       1999  37737  172006362
#> 4 Brazil       2000  80488  174504898
#> 5 China        1999 212258 1272915272
#> 6 China        2000 213766 1280428583

table2
#> # A tibble: 12 × 4
#>   country      year type           count
#>   <chr>       <dbl> <chr>          <dbl>
#> 1 Afghanistan  1999 cases            745
#> 2 Afghanistan  1999 population  19987071
#> 3 Afghanistan  2000 cases           2666
#> 4 Afghanistan  2000 population  20595360
#> 5 Brazil       1999 cases          37737
#> 6 Brazil       1999 population 172006362
#> # … with 6 more rows

table3
#> # A tibble: 6 × 3
#>   country      year rate 
#>   <chr>     <dbl>     <chr> 
#> 1 Afghanistan  1999 745/19987071 
#> 2 Afghanistan  2000 2666/20595360 
#> 3 Brazil       1999 37737/172006362 
#> 4 Brazil       2000 80488/174504898 
#> 5 China        1999 212258/1272915272
#> 6 China        2000 213766/1280428583

这些都是相同基础数据的表示,但它们并不同样易于使用。其中一个,table1,因为其是整洁的,将在 tidyverse 内部处理起来更容易。

有三条相互关联的规则使数据集变得整洁:

  1. 每个变量是一列;每一列是一个变量。

  2. 每个观测是一行;每一行是一个观测。

  3. 每个值是一个单元格;每个单元格是一个单值。

图 5-1 以图像方式展示了规则。

三个面板,每个面板代表一个整洁的数据框。第一个面板显示每个变量是一列。第二个面板显示每个观测是一行。第三个面板显示每个值是一个单元格。

图 5-1. 三条规则使数据集整洁:变量是列,观测是行,值是单元格。

为什么要确保您的数据是整洁的?有两个主要优点:

  1. 选择一种一致的数据存储方式有普遍的优势。如果您有一种一致的数据结构,学习与其配套的工具会更容易,因为它们具有基础的统一性。

    • 将变量放在列中有一个特定的优势,因为这样可以展现 R 的向量化特性。就像你在“mutate()”和“summarize()”中学到的那样,大多数内置的 R 函数都可以处理值向量。这使得转换整洁数据感觉特别自然。
  • dplyr、ggplot2 和 tidyverse 中的所有其他包都设计用来处理整洁数据。

  • 这里有几个小例子,展示了如何与table1一起工作:

# Compute rate per 10,000
table1 |>
  mutate(rate = cases / population * 10000)
#> # A tibble: 6 × 5
#>   country      year  cases population  rate
#>   <chr>       <dbl>  <dbl>      <dbl> <dbl>
#> 1 Afghanistan  1999    745   19987071 0.373
#> 2 Afghanistan  2000   2666   20595360 1.29 
#> 3 Brazil       1999  37737  172006362 2.19 
#> 4 Brazil       2000  80488  174504898 4.61 
#> 5 China        1999 212258 1272915272 1.67 
#> 6 China        2000 213766 1280428583 1.67

# Compute total cases per year
table1 |> 
  group_by(year) |> 
  summarize(total_cases = sum(cases))
#> # A tibble: 2 × 2
#>    year total_cases
#>   <dbl>       <dbl>
#> 1  1999      250740
#> 2  2000      296920

# Visualize changes over time
ggplot(table1, aes(x = year, y = cases)) +
  geom_line(aes(group = country), color = "grey50") +
  geom_point(aes(color = country, shape = country)) +
  scale_x_continuous(breaks = c(1999, 2000)) # x-axis breaks at 1999 and 2000

这张图显示了阿富汗、巴西和中国在 1999 年和 2000 年的病例数,横轴是年份,纵轴是病例数。图中每个点代表特定国家特定年份的病例数。每个国家的点通过颜色和形状区分,并用线连接,形成三条不平行、不交叉的线。中国在 1999 年和 2000 年的病例数最高,两年均超过 20 万。巴西在 1999 年约为 4 万,在 2000 年约为 7.5 万。阿富汗在 1999 年和 2000 年的病例数最低,接近于这个比例尺上的 0。

- 练习

    • 对于每个样本表格,描述每个观察值和每列代表什么。
    • 描绘出你将用来计算table2table3rate的过程。你需要执行四个操作:
      • 提取每个国家每年的结核病病例数。
      • 提取每年每个国家的匹配人口。
      • 将病例数除以人口,乘以 10,000。
      • 存回适当的位置。
    • 你还没有学习到执行这些操作所需的所有函数,但你应该能够思考出你需要的转换过程。

- 数据的长度

  • 整洁数据的原则可能显得如此显而易见,以至于你会想知道是否会遇到不整洁的数据集。然而,不幸的是,大多数真实数据都是不整洁的。主要有两个原因:
    • 数据通常是为了促进除了分析以外的某个目标而组织的。例如,数据常常被结构化为便于数据输入,而不是分析。
    • 大多数人对整洁数据的原则不熟悉,而且除非你花费大量时间处理数据,否则很难自己推导出它们。
  • 这意味着大多数真实分析都需要至少进行一点整理。你将首先确定基础变量和观察对象是什么。有时这很容易;其他时候你可能需要咨询最初生成数据的人。接下来,你将把数据展开成整洁形式,变量在列中,观察值在行中。

tidyr 提供了两个用于数据转置的函数:pivot_longer()pivot_wider()。我们将首先从pivot_longer()开始,因为这是最常见的情况。让我们深入一些示例。

列名中的数据

billboard数据集记录了 2000 年歌曲的 Billboard 排名:

billboard
#> # A tibble: 317 × 79
#>   artist       track               date.entered   wk1   wk2   wk3   wk4   wk5
#>   <chr>        <chr>               <date>       <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 2 Pac        Baby Don't Cry (Ke… 2000-02-26      87    82    72    77    87
#> 2 2Ge+her      The Hardest Part O… 2000-09-02      91    87    92    NA    NA
#> 3 3 Doors Down Kryptonite          2000-04-08      81    70    68    67    66
#> 4 3 Doors Down Loser               2000-10-21      76    76    72    69    67
#> 5 504 Boyz     Wobble Wobble       2000-04-15      57    34    25    17    17
#> 6 98⁰         Give Me Just One N… 2000-08-19      51    39    34    26    26
#> # … with 311 more rows, and 71 more variables: wk6 <dbl>, wk7 <dbl>,
#> #   wk8 <dbl>, wk9 <dbl>, wk10 <dbl>, wk11 <dbl>, wk12 <dbl>, wk13 <dbl>, …

在这个数据集中,每个观察结果是一首歌曲。前三列(artisttrackdate.entered)是描述歌曲的变量。然后我们有 76 列(wk1-wk76),描述了歌曲每周的排名。¹在这里,列名是一个变量(week),单元格值是另一个变量(rank)。

为了整理这些数据,我们将使用pivot_longer()

billboard |> 
  pivot_longer(
    cols = starts_with("wk"), 
    names_to = "week", 
    values_to = "rank"
  )
#> # A tibble: 24,092 × 5
#>    artist track                   date.entered week   rank
#>    <chr>  <chr>                   <date>       <chr> <dbl>
#>  1 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk1      87
#>  2 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk2      82
#>  3 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk3      72
#>  4 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk4      77
#>  5 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk5      87
#>  6 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk6      94
#>  7 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk7      99
#>  8 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk8      NA
#>  9 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk9      NA
#> 10 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk10     NA
#> # … with 24,082 more rows

数据后,有三个关键参数:

cols

指定需要转置的列(即不是变量的列)。此参数使用与select()相同的语法,因此在这里我们可以使用!c(artist, track, date.entered)starts_with("wk")

names_to

指定存储在列名中的变量;我们将该变量命名为week

values_to

指定储存在单元格值中的变量名称;我们将该变量命名为rank

请注意,在代码中"week""rank"被引号引用,因为这些是我们正在创建的新变量;在我们运行pivot_longer()调用时,它们在数据中尚不存在。

现在让我们转向生成的长数据框。如果一首歌在前 100 名不到 76 周会发生什么?以 2 Pac 的“Baby Don’t Cry”为例。先前的输出表明它只在前 100 名中停留了 7 周,其余的周数都填充了缺失值。这些NA实际上并不代表未知的观察结果;它们是由数据集结构强制存在的,²因此我们可以要求pivot_longer()通过设置values_drop_na = TRUE来去除它们:

billboard |> 
  pivot_longer(
    cols = starts_with("wk"), 
    names_to = "week", 
    values_to = "rank",
    values_drop_na = TRUE
  )
#> # A tibble: 5,307 × 5
#>   artist track                   date.entered week   rank
#>   <chr>  <chr>                   <date>       <chr> <dbl>
#> 1 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk1      87
#> 2 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk2      82
#> 3 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk3      72
#> 4 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk4      77
#> 5 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk5      87
#> 6 2 Pac  Baby Don't Cry (Keep... 2000-02-26   wk6      94
#> # … with 5,301 more rows

现在行数大大减少,表明许多带有NA的行已被删除。

你可能还想知道,如果一首歌在前 100 名超过 76 周会发生什么。从这些数据中我们无法得知,但你可能会猜测会向数据集添加额外的列,比如wk77wk78等。

这些数据现在已经整洁了,但通过使用mutate()readr::parse_number(),将week的值从字符转换为数字,可以使未来的计算更加容易。parse_number()是一个方便的函数,可以从字符串中提取第一个数字,忽略所有其他文本。

billboard_longer <- billboard |> 
  pivot_longer(
    cols = starts_with("wk"), 
    names_to = "week", 
    values_to = "rank",
    values_drop_na = TRUE
  ) |> 
  mutate(
    week = parse_number(week)
  )
billboard_longer
#> # A tibble: 5,307 × 5
#>   artist track                   date.entered  week  rank
#>   <chr>  <chr>                   <date>       <dbl> <dbl>
#> 1 2 Pac  Baby Don't Cry (Keep... 2000-02-26       1    87
#> 2 2 Pac  Baby Don't Cry (Keep... 2000-02-26       2    82
#> 3 2 Pac  Baby Don't Cry (Keep... 2000-02-26       3    72
#> 4 2 Pac  Baby Don't Cry (Keep... 2000-02-26       4    77
#> 5 2 Pac  Baby Don't Cry (Keep... 2000-02-26       5    87
#> 6 2 Pac  Baby Don't Cry (Keep... 2000-02-26       6    94
#> # … with 5,301 more rows

现在我们已经将所有周数放在一个变量中,所有排名值放在另一个变量中,我们可以很好地可视化歌曲排名随时间的变化。这里显示了代码,结果在图 5-2 中。我们可以看到很少有歌曲在前 100 名停留超过 20 周。

billboard_longer |> 
  ggplot(aes(x = week, y = rank, group = track)) + 
  geom_line(alpha = 0.25) + 
  scale_y_reverse()

一条线图,横轴是周数,纵轴是排名,每条线代表一首歌曲。大多数歌曲似乎从高排名开始,迅速加速至低排名,然后再次衰退。当周数 > 20 且排名 > 50 时,该地区的歌曲数量令人惊讶地少。

图 5-2. 显示了一条线图,展示了歌曲排名随时间变化的情况。

数据透视是如何工作的?

现在您已经看到了我们如何使用数据透视重塑数据,让我们花点时间直观地理解数据透视的作用。我们从一个简单的数据集开始,以便更容易看到发生了什么。假设我们有三个患者,其id分别为 A、B 和 C,每位患者测量了两次血压。我们将使用tribble(),这是一个方便的函数,可以手动构建小型的 tibble:

df <- tribble(
  ~id,  ~bp1, ~bp2,
   "A",  100,  120,
   "B",  140,  115,
   "C",  120,  125
)

我们希望我们的新数据集有三个变量:id(已存在),measurement(列名),和value(单元格值)。为了实现这一点,我们需要对df进行更长时间的数据透视:

df |> 
  pivot_longer(
    cols = bp1:bp2,
    names_to = "measurement",
    values_to = "value"
  )
#> # A tibble: 6 × 3
#>   id    measurement value
#>   <chr> <chr>       <dbl>
#> 1 A     bp1           100
#> 2 A     bp2           120
#> 3 B     bp1           140
#> 4 B     bp2           115
#> 5 C     bp1           120
#> 6 C     bp2           125

重塑工作原理是怎样的?如果我们逐列思考会更容易理解。如图 5-3 所示,原始数据集中已经是变量的列(id)的值需要重复,以适应每一列的数据透视。

展示了如何使用转换简单数据集的图表,使用颜色突出显示列中的值("A", "B", "C"),因为有两列进行了数据透视("bp1"和"bp2"),所以每个值都在输出中重复了两次。

图 5-3. 需要重复的已经是变量的列,以适应每一列的数据透视。

列名成为了一个新变量的值,其名称由names_to定义,如图 5-4 所示。它们需要针对原始数据集中的每一行重复一次。

展示了如何使用转换简单数据集的图表,使用颜色突出显示列名("bp1"和"bp2")如何成为新列中的值。它们被重复了三次,因为输入中有三行。

图 5-4. 被轴线列的列名成为新列中的值。这些值需要在原始数据集的每一行中重复一次。

单元格的值也变成了一个新变量的值,这个变量的名称由values_to定义。它们按行展开。图 5-5 说明了这个过程。

显示如何转换数据的图表,使用颜色突出显示单元格值(血压测量)如何成为新列中的值。它们按行展开,因此原始行(100,120),然后(140,115),然后(120,125),变成一个从 100 到 125 的列。

图 5-5. 值的数量被保留(不重复),但按行展开。

列名中的多个变量

当列名中有多个信息塞入,并且希望将它们存储在单独的新变量中时,情况就变得更具挑战性。例如,看看who2数据集,table1的来源,以及之前看到的朋友们:

who2
#> # A tibble: 7,240 × 58
#>   country      year sp_m_014 sp_m_1524 sp_m_2534 sp_m_3544 sp_m_4554
#>   <chr>       <dbl>    <dbl>     <dbl>     <dbl>     <dbl>     <dbl>
#> 1 Afghanistan  1980       NA        NA        NA        NA        NA
#> 2 Afghanistan  1981       NA        NA        NA        NA        NA
#> 3 Afghanistan  1982       NA        NA        NA        NA        NA
#> 4 Afghanistan  1983       NA        NA        NA        NA        NA
#> 5 Afghanistan  1984       NA        NA        NA        NA        NA
#> 6 Afghanistan  1985       NA        NA        NA        NA        NA
#> # … with 7,234 more rows, and 51 more variables: sp_m_5564 <dbl>,
#> #   sp_m_65 <dbl>, sp_f_014 <dbl>, sp_f_1524 <dbl>, sp_f_2534 <dbl>, …

这个由世界卫生组织收集的数据集记录了关于结核病诊断的信息。有两列已经是变量并且容易解释:countryyear。接下来是 56 列,如sp_m_014ep_m_4554rel_m_3544。如果你仔细看这些列足够长时间,你会注意到有一个模式。每个列名由三个由_分隔的部分组成。第一部分,sp/rel/ep,描述了诊断方法;第二部分,m/f,是性别(在这个数据集中编码为二进制变量);第三部分,014/1524/2534/3544/4554/65,是年龄范围(例如014代表 0-14 岁)。

因此,在这种情况下,我们在who2中记录了六个信息片段:国家和年份(已经是列);诊断方法、性别类别和年龄范围类别(包含在其他列名中);以及该类别中的患者计数(单元格值)。为了将这六个信息片段组织成六个单独的列,我们使用pivot_longer(),其中包含names_to的列名向量和用于将原始变量名拆分为names_sep片段的指示符的列名:

who2 |> 
  pivot_longer(
    cols = !(country:year),
    names_to = c("diagnosis", "gender", "age"), 
    names_sep = "_",
    values_to = "count"
  )
#> # A tibble: 405,440 × 6
#>   country      year diagnosis gender age   count
#>   <chr>       <dbl> <chr>     <chr>  <chr> <dbl>
#> 1 Afghanistan  1980 sp        m      014      NA
#> 2 Afghanistan  1980 sp        m      1524     NA
#> 3 Afghanistan  1980 sp        m      2534     NA
#> 4 Afghanistan  1980 sp        m      3544     NA
#> 5 Afghanistan  1980 sp        m      4554     NA
#> 6 Afghanistan  1980 sp        m      5564     NA
#> # … with 405,434 more rows

除了names_sep,还有names_pattern的替代方法,你可以在学习第十五章中的正则表达式后,从更复杂的命名场景中提取变量。

从概念上讲,这只是你已经看过的更简单情况的一个小变化。图 5-6 展示了基本思想:现在,列名不再被轴线到单个列中,而是被轴线到多个列中。你可以想象这发生在两个步骤中(首先是轴向,然后是分离),但在底层,它是在一个步骤中发生的,因为这样更快。

一个使用颜色来说明如何通过提供  和多个  在输出中创建多个变量的图表。输入具有变量名 "x_1" 和 "y_2",它们被 "_" 分割以在输出中创建名称和编号列。这与单个  的情况类似,但一个单一输出变量现在分成多个变量。

图 5-6. 在列名中存在多个信息部分进行轴向变换意味着现在每个列名填充多个输出列的值。

列头中的数据和变量名

复杂度上升的下一步是当列名包含变量值和变量名的混合。例如,看看household数据集:

household
#> # A tibble: 5 × 5
#>   family dob_child1 dob_child2 name_child1 name_child2
#>    <int> <date>     <date>     <chr>       <chr> 
#> 1      1 1998-11-26 2000-01-29 Susan       Jose 
#> 2      2 1996-06-22 NA         Mark        <NA> 
#> 3      3 2002-07-11 2004-04-05 Sam         Seth 
#> 4      4 2004-10-10 2009-08-27 Craig       Khai 
#> 5      5 2000-12-05 2005-02-28 Parker      Gracie

这个数据集包含了五个家庭的数据,包括最多两个孩子的姓名和出生日期。这个数据集的新挑战在于列名包含两个变量的名称(dobname),以及另一个变量(child,取值为 1 或 2)的值。为了解决这个问题,我们再次需要向names_to提供一个向量,但这次我们使用特殊的".value"标记;这不是一个变量名,而是告诉pivot_longer()要做一些不同的事情。这会覆盖通常的values_to参数,以使用旋转后的列名的第一个组件作为输出中的变量名。

household |> 
  pivot_longer(
    cols = !family, 
    names_to = c(".value", "child"), 
    names_sep = "_", 
    values_drop_na = TRUE
  )
#> # A tibble: 9 × 4
#>   family child  dob        name 
#>    <int> <chr>  <date>     <chr>
#> 1      1 child1 1998-11-26 Susan
#> 2      1 child2 2000-01-29 Jose 
#> 3      2 child1 1996-06-22 Mark 
#> 4      3 child1 2002-07-11 Sam 
#> 5      3 child2 2004-04-05 Seth 
#> 6      4 child1 2004-10-10 Craig
#> # … with 3 more rows

我们再次使用values_drop_na = TRUE,因为输入的形状导致必须创建显式的缺失变量(例如,只有一个孩子的家庭)。

Figure 5-7 用一个更简单的例子说明了基本思想。当您在names_to中使用".value"时,输入中的列名同时影响输出中的值和变量名。

一个使用颜色来说明特殊 ".value" 标记如何工作的图表。输入具有名称 "x_1"、"x_2"、"y_1" 和 "y_2",我们希望将第一个组件("x"、"y")用作新 "id" 列的变量名,第二个("1"、"2")用作值。

图 5-7. 使用names_to = c(".value", "num")进行轴向变换将列名分为两个部分:第一部分确定输出列名(xy),第二部分确定num列的值。

数据扩展

到目前为止,我们已经使用pivot_longer()解决了常见的问题类别,即值最终出现在列名中。接下来我们将(哈哈)转向pivot_wider(),这将通过增加列和减少行使数据集变得更,在处理一个观察跨越多行时非常有用。在野外似乎较少见,但在处理政府数据时确实经常遇到。

我们将从 Centers for Medicare and Medicaid Services 的cms_patient_experience数据集开始,该数据集收集有关患者体验的数据:

cms_patient_experience
#> # A tibble: 500 × 5
#>   org_pac_id org_nm                     measure_cd   measure_title   prf_rate
#>   <chr>      <chr>                      <chr>        <chr>              <dbl>
#> 1 0446157747 USC CARE MEDICAL GROUP INC CAHPS_GRP_1  CAHPS for MIPS…       63
#> 2 0446157747 USC CARE MEDICAL GROUP INC CAHPS_GRP_2  CAHPS for MIPS…       87
#> 3 0446157747 USC CARE MEDICAL GROUP INC CAHPS_GRP_3  CAHPS for MIPS…       86
#> 4 0446157747 USC CARE MEDICAL GROUP INC CAHPS_GRP_5  CAHPS for MIPS…       57
#> 5 0446157747 USC CARE MEDICAL GROUP INC CAHPS_GRP_8  CAHPS for MIPS…       85
#> 6 0446157747 USC CARE MEDICAL GROUP INC CAHPS_GRP_12 CAHPS for MIPS…       24
#> # … with 494 more rows

被研究的核心单元是一个组织,但每个组织分布在六行中,每一行对应调查组织中采取的一项测量。通过使用distinct(),我们可以看到measure_cdmeasure_title的完整数值集合。

cms_patient_experience |> 
  distinct(measure_cd, measure_title)
#> # A tibble: 6 × 2
#>   measure_cd   measure_title 
#>   <chr>        <chr> 
#> 1 CAHPS_GRP_1  CAHPS for MIPS SSM: Getting Timely Care, Appointments, and In…
#> 2 CAHPS_GRP_2  CAHPS for MIPS SSM: How Well Providers Communicate 
#> 3 CAHPS_GRP_3  CAHPS for MIPS SSM: Patient's Rating of Provider 
#> 4 CAHPS_GRP_5  CAHPS for MIPS SSM: Health Promotion and Education 
#> 5 CAHPS_GRP_8  CAHPS for MIPS SSM: Courteous and Helpful Office Staff 
#> 6 CAHPS_GRP_12 CAHPS for MIPS SSM: Stewardship of Patient Resources

这两列都不会成为特别好的变量名称:measure_cd不提示变量的含义,而measure_title是一个包含空格的长句子。我们暂时将使用measure_cd作为新列名的来源,但在实际分析中,您可能希望创建既简短又有意义的自定义变量名称。

pivot_wider()pivot_longer()接口相反:我们需要提供定义数值(values_from)和列名(names_from)的现有列,而不是选择新的列名。

cms_patient_experience |> 
  pivot_wider(
    names_from = measure_cd,
    values_from = prf_rate
  )
#> # A tibble: 500 × 9
#>   org_pac_id org_nm                   measure_title   CAHPS_GRP_1 CAHPS_GRP_2
#>   <chr>      <chr>                    <chr>                 <dbl>       <dbl>
#> 1 0446157747 USC CARE MEDICAL GROUP … CAHPS for MIPS…          63          NA
#> 2 0446157747 USC CARE MEDICAL GROUP … CAHPS for MIPS…          NA          87
#> 3 0446157747 USC CARE MEDICAL GROUP … CAHPS for MIPS…          NA          NA
#> 4 0446157747 USC CARE MEDICAL GROUP … CAHPS for MIPS…          NA          NA
#> 5 0446157747 USC CARE MEDICAL GROUP … CAHPS for MIPS…          NA          NA
#> 6 0446157747 USC CARE MEDICAL GROUP … CAHPS for MIPS…          NA          NA
#> # … with 494 more rows, and 4 more variables: CAHPS_GRP_3 <dbl>,
#> #   CAHPS_GRP_5 <dbl>, CAHPS_GRP_8 <dbl>, CAHPS_GRP_12 <dbl>

输出看起来不太对;我们似乎仍然对每个组织有多行。这是因为我们还需要告诉pivot_wider(),哪些列包含唯一标识每行的值;在这种情况下,这些变量以"org"开头:

cms_patient_experience |> 
  pivot_wider(
    id_cols = starts_with("org"),
    names_from = measure_cd,
    values_from = prf_rate
  )
#> # A tibble: 95 × 8
#>   org_pac_id org_nm           CAHPS_GRP_1 CAHPS_GRP_2 CAHPS_GRP_3 CAHPS_GRP_5
#>   <chr>      <chr>                  <dbl>       <dbl>       <dbl>       <dbl>
#> 1 0446157747 USC CARE MEDICA…          63          87          86          57
#> 2 0446162697 ASSOCIATION OF …          59          85          83          63
#> 3 0547164295 BEAVER MEDICAL …          49          NA          75          44
#> 4 0749333730 CAPE PHYSICIANS…          67          84          85          65
#> 5 0840104360 ALLIANCE PHYSIC…          66          87          87          64
#> 6 0840109864 REX HOSPITAL INC          73          87          84          67
#> # … with 89 more rows, and 2 more variables: CAHPS_GRP_8 <dbl>,
#> #   CAHPS_GRP_12 <dbl>

这给了我们想要的输出。

pivot_wider()如何工作?

要理解pivot_wider()的工作原理,让我们再次从一个简单的数据集开始。这次我们有两位患者,分别是id为 A 和 B;患者 A 有三次血压测量,患者 B 有两次:

df <- tribble(
  ~id, ~measurement, ~value,
  "A",        "bp1",    100,
  "B",        "bp1",    140,
  "B",        "bp2",    115, 
  "A",        "bp2",    120,
  "A",        "bp3",    105
)

我们将从value列获取值,从measurement列获取名称:

df |> 
  pivot_wider(
    names_from = measurement,
    values_from = value
  )
#> # A tibble: 2 × 4
#>   id      bp1   bp2   bp3
#>   <chr> <dbl> <dbl> <dbl>
#> 1 A       100   120   105
#> 2 B       140   115    NA

要开始这个过程,pivot_wider()首先需要弄清楚哪些内容将放在行和列中。新的列名将是measurement的唯一值:

df |> 
  distinct(measurement) |> 
  pull()
#> [1] "bp1" "bp2" "bp3"

默认情况下,输出中的行由不进入新名称或值的所有变量确定。这些称为id_cols。这里只有一列,但通常可以有任意数量:

df |> 
  select(-measurement, -value) |> 
  distinct()
#> # A tibble: 2 × 1
#>   id 
#>   <chr>
#> 1 A 
#> 2 B

pivot_wider()然后结合这些结果生成一个空数据框架:

df |> 
  select(-measurement, -value) |> 
  distinct() |> 
  mutate(x = NA, y = NA, z = NA)
#> # A tibble: 2 × 4
#>   id    x     y     z 
#>   <chr> <lgl> <lgl> <lgl>
#> 1 A     NA    NA    NA 
#> 2 B     NA    NA    NA

然后使用输入中的数据填充所有缺失的值。在这种情况下,输出中的每个单元格并没有对应的输入值,因为患者 B 没有第三次血压测量,所以该单元格保持缺失状态。我们将回到pivot_wider()可以在第十八章中“生成”缺失值的想法。

您可能还想知道,如果输入中有多行对应输出中的一个单元格会发生什么。以下示例有两行对应于id Ameasurement bp1

df <- tribble(
  ~id, ~measurement, ~value,
  "A",        "bp1",    100,
  "A",        "bp1",    102,
  "A",        "bp2",    120,
  "B",        "bp1",    140, 
  "B",        "bp2",    115
)

如果我们尝试转换此数据,我们将得到一个包含列表列的输出,有关此内容,您将在第二十三章中进一步了解:

df |>
  pivot_wider(
    names_from = measurement,
    values_from = value
  )
#> Warning: Values from `value` are not uniquely identified; output will contain
#> list-cols.
#> • Use `values_fn = list` to suppress this warning.
#> • Use `values_fn = {summary_fun}` to summarise duplicates.
#> • Use the following dplyr code to identify duplicates.
#>   {data} %>%
#>   dplyr::group_by(id, measurement) %>%
#>   dplyr::summarise(n = dplyr::n(), .groups = "drop") %>%
#>   dplyr::filter(n > 1L)
#> # A tibble: 2 × 3
#>   id    bp1       bp2 
#>   <chr> <list>    <list> 
#> 1 A     <dbl [2]> <dbl [1]>
#> 2 B     <dbl [1]> <dbl [1]>

由于您还不知道如何处理此类数据,您需要遵循警告中的提示来找出问题所在:

df |> 
  group_by(id, measurement) |> 
  summarize(n = n(), .groups = "drop") |> 
  filter(n > 1)
#> # A tibble: 1 × 3
#>   id    measurement     n
#>   <chr> <chr>       <int>
#> 1 A     bp1             2

现在,由您来找出数据出了什么问题,并修复底层损害或使用分组和汇总技能确保每个行和列值的组合只有一个行。

概要

在本章中,您学习了关于整洁数据的概念:数据中变量在列中,观察结果在行中。整洁数据使得在 tidyverse 中工作更容易,因为它是一个大多数函数都理解的一致结构;主要挑战在于将数据从接收到的任何结构转换为整洁格式。为此,您了解了pivot_longer()pivot_wider(),这两个函数可以帮助您整理许多不整洁的数据集。我们在这里展示的示例是从vignette("pivot", package = "tidyr")中选出的一部分,所以如果本章无法解决您遇到的问题,可以尝试阅读那个 vignette。

另一个挑战是,对于给定的数据集,将较长或较宽版本标记为“整洁”的一个版本可能是不可能的。这在一定程度上反映了我们对整洁数据的定义,其中我们说整洁数据每列有一个变量,但我们实际上没有定义变量是什么(这其实很难定义)。如果您在解决某些计算问题时遇到困难,可以考虑调整数据的组织方式;不要害怕需要时对数据进行整理、转换和重新整理!

如果您喜欢本章内容并希望进一步了解底层理论,您可以在《整洁数据》论文中了解更多历史和理论基础,该论文发表在《统计软件杂志》上。

现在您正在编写大量的 R 代码,是时候学习更多关于如何将代码组织到文件和目录中的信息了。在下一章中,您将学习有关脚本和项目的优势以及提供的许多工具,这些工具将使您的生活更轻松。

¹ 只要歌曲曾在 2000 年某个时刻进入过前 100 名,并且在出现后的 72 周内被跟踪,它就会被包括在内。

² 我们将在第十八章中回到这个概念。

第六章:工作流程:脚本和项目

本章将向您介绍两种组织代码的基本工具:脚本和项目。

脚本

到目前为止,您已经使用控制台运行了代码。这是一个很好的开始,但随着创建更复杂的 ggplot2 图形和更长的 dplyr 管道,您会发现空间很快变得狭窄。为了获得更多的工作空间,请使用脚本编辑器。通过单击文件菜单,选择新建文件,然后选择 R 脚本,或使用键盘快捷键 Cmd/Ctrl+Shift+N 来打开它。现在您将看到四个窗格,如图 6-1 所示。脚本编辑器是实验代码的好地方。当您想要更改内容时,您不必重新键入整个内容;您只需编辑脚本并重新运行即可。一旦编写出符合要求并且正常运行的代码,您可以将其保存为脚本文件,以便以后轻松返回。

RStudio IDE with Editor, Console, and Output highlighted.

图 6-1。打开脚本编辑器将在 IDE 的左上角添加一个新窗格。

运行代码

脚本编辑器非常适合构建复杂的 ggplot2 图或长序列的 dplyr 操作。有效使用脚本编辑器的关键是记住最重要的键盘快捷键之一:Cmd/Ctrl+Enter。这会在控制台中执行当前的 R 表达式。例如,看以下代码:

library(dplyr)
library(nycflights13)

not_cancelled <- flights |> 
  filter(!is.na(dep_delay)█, !is.na(arr_delay))

not_cancelled |> 
  group_by(year, month, day) |> 
  summarize(mean = mean(dep_delay))

如果光标在█处,按下 Cmd/Ctrl+Enter 将运行生成not_cancelled的完整命令。它还将移动光标到以下语句(以not_cancelled |>开头)。这样,通过反复按下 Cmd/Ctrl+Enter,您可以轻松地逐步执行完整的脚本。

不要逐个表达式地运行代码,您可以通过一步操作(Cmd/Ctrl+Shift+S)执行完整的脚本。定期执行此操作是确保您已捕获脚本中所有重要部分的好方法。

我们建议您始终从您需要的包开始编写脚本。这样,如果您与他人分享代码,他们可以轻松看到需要安装哪些包。但请注意,您不应在您分享的脚本中包含install.packages()。如果他们不小心操作,将在他们的计算机上安装东西是不礼貌的!

在未来的章节中工作时,我们强烈建议您从脚本编辑器开始,并练习键盘快捷键。随着时间的推移,以这种方式将代码发送到控制台将变得如此自然,以至于您甚至不会考虑它。

RStudio 诊断

在脚本编辑器中,RStudio 会使用红色波浪线和侧边栏中的交叉标记突出显示语法错误:

带有脚本 x y <- 10 的脚本编辑器。红色的 X 表示有语法错误。语法错误还用红色波浪线进行了突出显示。

将鼠标悬停在十字架上以查看问题所在:

脚本编辑器显示脚本 x y <- 10. 一个红色的 X 表示语法错误。语法错误还用红色波浪线进行了标记。将鼠标悬停在 X 上会显示一个文本框,其中包含文本“unexpected token y”和“unexpected token <-”。

RStudio 还会提醒您可能存在的问题:

脚本编辑器显示脚本 3 == NA。一个黄色的感叹号表示可能存在潜在问题。将鼠标悬停在感叹号上会显示一个文本框,其中包含文本“use is.na to check whether expression evaluates to NA”。

保存和命名

RStudio 在退出时会自动保存脚本编辑器的内容,并在重新打开时自动重新加载。尽管如此,最好避免使用 Untitled1、Untitled2、Untitled3 等无意义的名称,而是使用信息丰富的名称保存脚本。

或许给文件命名为 code.Rmyscript.R 看起来很诱人,但在选择文件名之前,你应该认真考虑一下。文件命名的三个重要原则如下:

  1. 文件名应该是机器可读的:避免使用空格、符号和特殊字符。不要依赖大小写敏感性来区分文件。

  2. 文件名应该是人类可读的:使用文件名描述文件中的内容。

  3. 文件名应该与默认排序相容:以数字开头,以便按字母顺序排列它们的顺序。

例如,假设在项目文件夹中有以下文件:

alternative model.R
code for exploratory analysis.r
finalreport.qmd
FinalReport.qmd
fig 1.png
Figure_02.png
model_first_try.R
run-first.r
temp.txt

这里存在各种问题:难以找到应首先运行哪个文件,文件名包含空格,有两个文件同名但大小写不同(finalreportFinalReport¹),有些名称并未描述其内容(run-firsttemp)。

这是同一组文件更好的命名和组织方式:

01-load-data.R
02-exploratory-analysis.R
03-model-approach-1.R
04-model-approach-2.R
fig-01.png
fig-02.png
report-2022-03-20.qmd
report-2022-04-02.qmd
report-draft-notes.txt

编号关键脚本使其运行顺序明显,而一致的命名方案则使得变量更易于识别。此外,图表采用类似的标签,报告则通过文件名中包含的日期进行区分,temp 被重命名为 report-draft-notes 以更好地描述其内容。如果目录中有大量文件,建议进一步组织,将不同类型的文件(脚本、图表等)放置在不同的目录中。

项目

终有一天,你将需要退出 R,去做其他事情,稍后回来继续分析。终有一天,你将同时处理多个分析,并希望将它们保持分隔。终有一天,你将需要将外界的数据带入 R 中,并将 R 的数值结果和图表发送回外界。

为了处理这些现实生活中的情况,你需要做出两个决定:

  • 事实的来源是什么?你将保存什么作为记录下来发生了什么的持久记录?

  • 你的分析在哪里?

真相之源是什么?

对于初学者,依赖当前环境包含你在分析中创建的所有对象是可以的。然而,为了更轻松地处理大型项目或与他人合作,你的真相之源应该是 R 脚本。有了你的 R 脚本(和数据文件),你可以重新创建环境。但如果只有环境而没有代码,重新创建你的 R 脚本就更加困难:你要么必须从记忆中重新输入大量代码(不可避免地会出错),要么必须仔细查看你的 R 历史记录。

为了帮助保持你的 R 脚本成为分析的真相之源,我们强烈建议你指示 RStudio 在会话之间不保留你的工作空间。你可以通过运行usethis::use_blank_slate()²或者模仿 图 6-2 中显示的选项来实现这一点。这会带来一些短期的不便,因为现在当你重新启动 RStudio 时,它将不再记住你上次运行的代码,也不会保留你创建的对象或读取的数据集。但这种短期的不便可以避免长期的痛苦,因为它迫使你在代码中捕捉所有重要的过程。发现重要计算的结果只存储在你的环境中,而没有在你的代码中存储计算本身,三个月后再去发现这种情况是非常糟糕的。

RStudio 全局选项窗口,选项“在启动时不恢复 .RData 到工作空间”未选中。同时,“在退出时将工作空间保存为 .RData”的选项设置为“从不”。

图 6-2. 复制这些选项到你的 RStudio 选项中,以始终在启动时使用干净的环境。

有一组很棒的键盘快捷键可以一起使用,确保你在编辑器中捕捉到代码的重要部分:

  1. 按下 Cmd/Ctrl+Shift+0/F10 来重新启动 R。

  2. 按下 Cmd/Ctrl+Shift+S 来重新运行当前的脚本。

每周我们总共使用这种模式数百次。

或者,如果你不使用键盘快捷键,你可以选择会话 > 重新启动 R,然后重新运行你当前的脚本。

RStudio Server

如果你正在使用 RStudio Server,默认情况下不会重新启动你的 R 会话。当你关闭 RStudio Server 标签页时,可能会感觉你关闭了 R,但实际上服务器会在后台继续运行。下次返回时,你将会回到你离开时的同样位置。这使得定期重启 R 变得更加重要,以确保你从一个干净的状态开始。

你的分析数据存放在哪里?

R 拥有一个强大的工作目录概念。这是 R 查找你要加载的文件和保存文件的地方。RStudio 在控制台顶部显示你当前的工作目录:

控制台选项卡显示当前工作目录为 ~/Documents/r4ds。

您可以通过运行getwd()在 R 代码中打印此内容:

getwd()
#> [1] "/Users/hadley/Documents/r4ds"

在此 R 会话中,当前工作目录(可以将其视为“主目录”)位于 Hadley 的文档文件夹中的一个名为r4ds的子文件夹中。当您运行此代码时,由于您的计算机具有不同的目录结构,此代码将返回不同的结果!

作为初学者的 R 用户,允许将工作目录设置为主目录、文档目录或计算机上任何其他奇怪的目录都是可以的。但是在本书的第七章中,您已经不再是初学者了。很快,您应该开始将项目组织到目录中,并在处理项目时将 R 的工作目录设置为相关目录。

您可以从 R 内设置工作目录,但我们不建议这样做

setwd("/path/to/my/CoolProject")

有一种更好的方式——一种也能让您像专家一样管理您的 R 工作的方式。这种方式就是RStudio 项目

RStudio 项目

将与给定项目相关的所有文件(输入数据、R 脚本、分析结果和图表)放在一个目录中是一种明智且常见的做法,RStudio 通过项目内置支持此功能。在您阅读本书的其余部分时,让我们为您创建一个项目。选择“文件” > “新建项目”,然后按照图 6-3 中显示的步骤进行操作。

新项目菜单的三个截图。第一张截图显示创建项目窗口并选择了新目录。第二张截图显示了项目类型窗口并选择了空项目。第三张截图显示了创建新项目窗口,将目录名指定为 r4ds,并将项目创建为桌面的子目录。

图 6-3. 创建新项目:(顶部)首先点击“新建目录”,然后(中部)点击“新建项目”,最后(底部)填写目录(项目)名称,选择一个适合其主目录的子目录,并点击“创建项目”。

将您的项目命名为r4ds,并仔细考虑将项目放在哪个子目录中。如果不将其存储在合理的位置,将来找到它将会很困难!

当此过程完成后,您将获得一个专门为本书而设的新的 RStudio 项目。确保您项目的“主目录”是当前工作目录:

getwd()
#> [1] /Users/hadley/Documents/r4ds

现在在脚本编辑器中输入以下命令并保存文件,命名为diamonds.R。然后,创建一个名为data的新文件夹。您可以在 RStudio 的文件面板中点击“新建文件夹”按钮来完成此操作。最后,运行完整的脚本,它将保存一个 PNG 和 CSV 文件到您的项目目录中。不必担心细节;您会在本书后面学到它们。

library(tidyverse)

ggplot(diamonds, aes(x = carat, y = price)) + 
  geom_hex()
ggsave("diamonds.png")

write_csv(diamonds, "data/diamonds.csv")

退出 RStudio。检查与你的项目相关联的文件夹——注意.Rproj文件。双击该文件重新打开项目。注意你回到了离开时的地方:同样的工作目录和命令历史记录,所有你正在工作的文件仍然是打开的。因为你遵循了我们的指示,你将有一个完全新鲜的环境,确保你是从一个干净的状态开始。

在你喜欢的操作系统特定方式中,搜索你的计算机中的diamonds.png,你会找到这个 PNG 文件(毫不奇怪),但也会找到创建它的脚本diamonds.R)。这是一个巨大的胜利!总有一天,你会想要重新制作一个图表或者仅仅是理解它的来源。如果你严格地将图表保存为文件带有 R 代码,而不是使用鼠标或剪贴板,那么你将能够轻松地重现旧作品!

相对路径和绝对路径

一旦你进入一个项目,你应该只使用相对路径,而不是绝对路径。两者有什么区别呢?相对路径是相对于工作目录的,也就是项目的根目录。当 Hadley 之前写了data/diamonds.csv时,它是/Users/hadley/Documents/r4ds/data/diamonds.csv的一个快捷方式。但是重要的是,如果 Mine 在她的计算机上运行这段代码,它将指向/Users/Mine/Documents/r4ds/data/diamonds.csv。这就是为什么相对路径很重要的原因:它们将在 R 项目文件夹最终结束的任何地方都能够工作。

绝对路径指向相同的位置,不管你的工作目录在哪里。它们在不同的操作系统下看起来有些不同。在 Windows 上,它们以驱动器号开头(例如C:)或两个反斜杠(例如\\servername),而在 Mac/Linux 上,它们以斜杠/开头(例如/users/hadley)。你在脚本中永远不应该使用绝对路径,因为它们会阻碍分享:没有人会有完全相同的目录配置像你一样。

操作系统之间还有另一个重要的区别:路径组件的分隔符不同。Mac 和 Linux 使用斜杠(例如data/diamonds.csv),而 Windows 使用反斜杠(例如data\diamonds.csv)。R 可以处理任何类型的路径(无论你当前使用哪个平台),但不幸的是,反斜杠对于 R 来说有特殊意义,为了在路径中得到单个反斜杠,你需要输入两个反斜杠!这让生活变得很烦人,所以我们建议始终使用 Linux/Mac 风格的斜杠。

练习

  1. 前往RStudio Tips Twitter 账号,找到一个看起来有趣的提示。练习使用它!

  2. RStudio 诊断还会报告哪些其他常见的错误?阅读这篇关于代码诊断的文章去找出答案。

摘要

在本章中,你学会了如何将你的 R 代码组织成脚本(文件)和项目(目录)。就像代码风格一样,这起初可能感觉是繁琐的工作。但随着在多个项目中积累更多的代码,你会逐渐意识到,稍微前期的组织可以在以后节省大量时间。

总之,脚本和项目为你提供了一个稳固的工作流程,将在未来为你服务。

  • 每个数据分析项目都应创建一个 RStudio 项目。

  • 将你的脚本(用信息性的名称命名)保存在项目中,编辑它们,并逐部或整体运行它们。经常重新启动 R 以确保你已经捕获了脚本中的所有内容。

  • 只使用相对路径,而不是绝对路径。

这样,你需要的一切都集中在一个地方,并与你正在进行的所有其他项目清晰分开。

到目前为止,我们已经使用了 R 包中捆绑的数据集。这使得在预准备的数据上练习变得更加容易,但显然你的数据不会以这种方式可用。因此,在下一章中,你将学习如何使用 readr 包将数据从磁盘加载到你的 R 会话中。

¹ 更不用说在名称中使用“final”会招来厄运。漫画《Piled Higher and Deeper》有一篇有趣的条漫讲述了这一点。

² 如果你还没有安装这个包,你可以使用install.packages("usethis")来安装它。

第七章:数据导入

简介

使用 R 包提供的数据是学习数据科学工具的好方法,但您想要将所学应用于自己的数据。在本章中,您将学习将数据文件读入 R 的基础知识。

具体来说,本章将重点介绍读取纯文本矩形文件。我们将从处理列名、类型和缺失数据的实用建议开始。然后,您将了解如何一次从多个文件读取数据,并将数据从 R 写入文件。最后,您将学习如何在 R 中手工制作数据框架。

先决条件

在本章中,您将学习如何使用 readr 包在 R 中加载平面文件,该包是核心 tidyverse 的一部分:

library(tidyverse)

从文件中读取数据

首先,我们将重点放在最常见的矩形数据文件类型上:CSV,即“逗号分隔值”。这是一个简单的 CSV 文件的示例。第一行,通常称为标题行,给出列名,接下来的六行提供数据。列由逗号分隔。

Student ID,Full Name,favourite.food,mealPlan,AGE
1,Sunil Huffmann,Strawberry yoghurt,Lunch only,4
2,Barclay Lynn,French fries,Lunch only,5
3,Jayendra Lyne,N/A,Breakfast and lunch,7
4,Leon Rossini,Anchovies,Lunch only,
5,Chidiegwu Dunkel,Pizza,Breakfast and lunch,five
6,Güvenç Attila,Ice cream,Lunch only,6

表 7-1 表示相同的数据作为表格。

表 7-1. students.csv 文件中的数据表

学生 ID 全名 最喜欢的食物 餐饮计划 年龄
1 Sunil Huffmann Strawberry yoghurt 仅午餐 4
2 Barclay Lynn French fries 仅午餐 5
3 Jayendra Lyne N/A 早餐和午餐 7
4 Leon Rossini Anchovies 仅午餐 NA
5 Chidiegwu Dunkel Pizza 早餐和午餐 five
6 Güvenç Attila Ice cream 仅午餐 6

我们可以使用read_csv()将这个文件读入 R。第一个参数是最重要的:文件的路径。你可以把路径看作是文件的地址:文件名为students.csv,存放在data文件夹中。

students <- read_csv("data/students.csv")
#> Rows: 6 Columns: 5
#> ── Column specification ─────────────────────────────────────────────────────
#> Delimiter: ","
#> chr (4): Full Name, favourite.food, mealPlan, AGE
#> dbl (1): Student ID
#> 
#> ℹ Use `spec()` to retrieve the full column specification for this data.
#> ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.

如果您在项目的data文件夹中有students.csv文件,前面的代码将起作用。您可以下载students.csv文件,或者直接从该 URL 读取它:

students <- read_csv("https://pos.it/r4ds-students-csv")

当您运行read_csv()时,它会输出一条消息,告诉您数据的行数和列数,使用的分隔符以及列规格(按列包含的数据类型命名的列名)。它还打印出一些有关检索完整列规格及如何静音此消息的信息。这条消息是 readr 的一个重要部分,我们将在“控制列类型”中返回它。

实用建议

读取数据后,通常的第一步是以某种方式转换数据,以便在后续分析中更容易处理。让我们再次查看students数据:

students
#> # A tibble: 6 × 5
#>   `Student ID` `Full Name`      favourite.food     mealPlan            AGE 
#>          <dbl> <chr>            <chr>              <chr>               <chr>
#> 1            1 Sunil Huffmann   Strawberry yoghurt Lunch only          4 
#> 2            2 Barclay Lynn     French fries       Lunch only          5 
#> 3            3 Jayendra Lyne    N/A                Breakfast and lunch 7 
#> 4            4 Leon Rossini     Anchovies          Lunch only          <NA> 
#> 5            5 Chidiegwu Dunkel Pizza              Breakfast and lunch five 
#> 6            6 Güvenç Attila    Ice cream          Lunch only          6

favourite.food列中,有一堆食物项目,以及字符字符串N/A,本应是 R 识别的真正NA,表示“不可用”。我们可以使用na参数来解决这个问题。默认情况下,read_csv()仅识别此数据集中的空字符串("")为NA;我们希望它也能识别字符字符串"N/A"

students <- read_csv("data/students.csv", na = c("N/A", ""))

students
#> # A tibble: 6 × 5
#>   `Student ID` `Full Name`      favourite.food     mealPlan            AGE 
#>          <dbl> <chr>            <chr>              <chr>               <chr>
#> 1            1 Sunil Huffmann   Strawberry yoghurt Lunch only          4 
#> 2            2 Barclay Lynn     French fries       Lunch only          5 
#> 3            3 Jayendra Lyne    <NA>               Breakfast and lunch 7 
#> 4            4 Leon Rossini     Anchovies          Lunch only          <NA> 
#> 5            5 Chidiegwu Dunkel Pizza              Breakfast and lunch five 
#> 6            6 Güvenç Attila    Ice cream          Lunch only          6

您可能还注意到Student IDFull Name列被反引号包围。这是因为它们包含空格,违反了 R 变量名称的常规规则;它们是nonsyntactic名称。要引用这些变量,您需要使用反引号 ` 包围它们:

students |> 
  rename(
    student_id = `Student ID`,
    full_name = `Full Name`
  )
#> # A tibble: 6 × 5
#>   student_id full_name        favourite.food     mealPlan            AGE 
#>        <dbl> <chr>            <chr>              <chr>               <chr>
#> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only          4 
#> 2          2 Barclay Lynn     French fries       Lunch only          5 
#> 3          3 Jayendra Lyne    <NA>               Breakfast and lunch 7 
#> 4          4 Leon Rossini     Anchovies          Lunch only          <NA> 
#> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch five 
#> 6          6 Güvenç Attila    Ice cream          Lunch only          6

另一种方法是使用janitor::clean_names()一次性使用一些启发式方法将它们全部转换为蛇形命名:¹

students |> janitor::clean_names()
#> # A tibble: 6 × 5
#>   student_id full_name        favourite_food     meal_plan           age 
#>        <dbl> <chr>            <chr>              <chr>               <chr>
#> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only          4 
#> 2          2 Barclay Lynn     French fries       Lunch only          5 
#> 3          3 Jayendra Lyne    <NA>               Breakfast and lunch 7 
#> 4          4 Leon Rossini     Anchovies          Lunch only          <NA> 
#> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch five 
#> 6          6 Güvenç Attila    Ice cream          Lunch only          6

读取数据后的另一个常见任务是考虑变量类型。例如,meal_plan是一个具有已知可能值集合的分类变量,在 R 中应表示为因子:

students |>
  janitor::clean_names() |>
  mutate(meal_plan = factor(meal_plan))
#> # A tibble: 6 × 5
#>   student_id full_name        favourite_food     meal_plan           age 
#>        <dbl> <chr>            <chr>              <fct>               <chr>
#> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only          4 
#> 2          2 Barclay Lynn     French fries       Lunch only          5 
#> 3          3 Jayendra Lyne    <NA>               Breakfast and lunch 7 
#> 4          4 Leon Rossini     Anchovies          Lunch only          <NA> 
#> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch five 
#> 6          6 Güvenç Attila    Ice cream          Lunch only          6

注意,meal_plan 变量中的值保持不变,但是在变量名下标有所不同,从字符(<chr>)变为因子(<fct>)。有关因子的详细信息请参见第十六章。

在分析这些数据之前,您可能希望修复ageid列。当前,age是一个字符变量,因为其中一条观察结果被输入为five而不是数字5。我们将在第二十章讨论解决此问题的详细信息。

students <- students |>
  janitor::clean_names() |>
  mutate(
    meal_plan = factor(meal_plan),
    age = parse_number(if_else(age == "five", "5", age))
  )

students
#> # A tibble: 6 × 5
#>   student_id full_name        favourite_food     meal_plan             age
#>        <dbl> <chr>            <chr>              <fct>               <dbl>
#> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only              4
#> 2          2 Barclay Lynn     French fries       Lunch only              5
#> 3          3 Jayendra Lyne    <NA>               Breakfast and lunch     7
#> 4          4 Leon Rossini     Anchovies          Lunch only             NA
#> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch     5
#> 6          6 Güvenç Attila    Ice cream          Lunch only              6

新的函数是if_else(),它有三个参数。第一个参数test应该是一个逻辑向量。当testTRUE时,结果将包含第二个参数yes的值;当testFALSE时,结果将包含第三个参数no的值。在这里我们说,如果age是字符字符串"five",则将其变为"5",否则保持为age。有关if_else()和逻辑向量的更多信息,请参见第十二章。

其他参数

还有几个重要的参数需要提到,如果我们首先向您展示一个方便的技巧将更容易演示:read_csv()可以读取您创建并格式化为 CSV 文件样式的文本字符串:

read_csv(
  "a,b,c
 1,2,3
 4,5,6"
)
#> # A tibble: 2 × 3
#>       a     b     c
#>   <dbl> <dbl> <dbl>
#> 1     1     2     3
#> 2     4     5     6

通常,read_csv()使用数据的第一行作为列名,这是一种常见的约定。但是在文件顶部可能包含几行元数据并不罕见。您可以使用skip = n跳过前n行,或使用comment = "#"删除以#开头的所有行,例如:

read_csv(
  "The first line of metadata
 The second line of metadata
 x,y,z
 1,2,3",
  skip = 2
)
#> # A tibble: 1 × 3
#>       x     y     z
#>   <dbl> <dbl> <dbl>
#> 1     1     2     3

read_csv(
  "# A comment I want to skip
 x,y,z
 1,2,3",
  comment = "#"
)
#> # A tibble: 1 × 3
#>       x     y     z
#>   <dbl> <dbl> <dbl>
#> 1     1     2     3

在其他情况下,数据可能没有列名。您可以使用 col_names = FALSE 来告诉 read_csv() 不将第一行视为标题,而是按顺序从 X1Xn 进行标记:

read_csv(
  "1,2,3
 4,5,6",
  col_names = FALSE
)
#> # A tibble: 2 × 3
#>      X1    X2    X3
#>   <dbl> <dbl> <dbl>
#> 1     1     2     3
#> 2     4     5     6

或者,您可以将 col_names 传递为字符向量,用作列名:

read_csv(
  "1,2,3
 4,5,6",
  col_names = c("x", "y", "z")
)
#> # A tibble: 2 × 3
#>       x     y     z
#>   <dbl> <dbl> <dbl>
#> 1     1     2     3
#> 2     4     5     6

这些参数是你在实践中大部分会遇到的 CSV 文件所需知道的全部内容。(对于其余部分,你需要仔细检查你的 .csv 文件并阅读 read_csv() 的文档中的许多其他参数。)

其他文件类型

一旦掌握了 read_csv(),使用 readr 的其他函数就很简单了;关键是知道要使用哪个函数:

read_csv2()

读取以分号分隔的文件。这些文件使用 ; 而不是 , 来分隔字段,是在使用 , 作为小数点分隔符的国家中很常见的。

read_tsv()

读取制表符分隔的文件。

read_delim()

读取具有任何分隔符的文件,如果不指定分隔符,则尝试自动猜测分隔符。

read_fwf()

读取固定宽度文件。您可以使用 fwf_widths() 指定字段的宽度,或者使用 fwf_positions() 指定字段的位置。

read_table()

读取常见的固定宽度文件变体,其中列由空格分隔。

read_log()

读取 Apache 风格的日志文件。

练习

  1. 用什么函数可以读取以 | 分隔字段的文件?

  2. 除了 fileskipcomment 外,read_csv()read_tsv() 还有哪些共同的参数?

  3. read_fwf() 最重要的参数是什么?

  4. 有时 CSV 文件中的字符串包含逗号。为了防止它们引起问题,它们需要用引号字符(如 "')括起来。默认情况下,read_csv() 假定引号字符是 ". 要将以下文本读入数据框,您需要指定 read_csv() 的哪个参数?

    "x,y\n1,'a,b'"
    
  5. 确定以下内联 CSV 文件中的问题。运行代码时会发生什么?

    read_csv("a,b\n1,2,3\n4,5,6")
    read_csv("a,b,c\n1,2\n1,2,3,4")
    read_csv("a,b\n\"1")
    read_csv("a,b\n1,2\na,b")
    read_csv("a;b\n1;3")
    
  6. 在以下数据框中,通过练习引用非语法名称:

    1. 提取名为 1 的变量。

    2. 绘制 12 的散点图。

    3. 创建一个名为 3 的新列,它是 2 除以 1

    4. 将列重命名为 onetwothree

    annoying <- tibble(
      `1` = 1:10,
      `2` = `1` * 2 + rnorm(length(`1`))
    )
    

控制列类型

CSV 文件不包含每个变量的类型信息(即它是逻辑值、数字、字符串等),因此 readr 将尝试猜测类型。本节描述了猜测过程的工作方式,如何解决一些导致猜测失败的常见问题,以及如果需要的话如何自己提供列类型。最后,我们将提及一些一般策略,如果 readr 失败严重,您需要获取有关文件结构的更多信息时可以使用。

猜测类型

readr 使用一种启发式方法来确定列类型。对于每一列,它从第一行到最后一行均匀地抽取 1,000² 个值,忽略缺失值。然后它按以下问题逐步进行:

  • 它只包含 FTFALSETRUE(忽略大小写)吗?如果是,那么它是逻辑值。

  • 它只包含数字(例如,1-4.55e6Inf)吗?如果是,那么它是一个数字。

  • 它是否符合 ISO8601 标准?如果是,那么它是日期或日期时间(我们将在“创建日期/时间”中详细讨论日期时间)。

  • 否则,它必须是一个字符串。

您可以在这个简单的例子中看到这种行为:

read_csv("
 logical,numeric,date,string
 TRUE,1,2021-01-15,abc
 false,4.5,2021-02-15,def
 T,Inf,2021-02-16,ghi
")
#> # A tibble: 3 × 4
#>   logical numeric date       string
#>   <lgl>     <dbl> <date>     <chr> 
#> 1 TRUE        1   2021-01-15 abc 
#> 2 FALSE       4.5 2021-02-15 def 
#> 3 TRUE      Inf   2021-02-16 ghi

如果您有一个干净的数据集,这种启发式方法效果很好,但是在实际生活中,您会遇到各种奇怪而美丽的失败。

缺失值、列类型和问题

列检测最常见的失败方式是,一列包含意外值,导致您得到一个字符列而不是更具体的类型。其中最常见的原因之一是缺失值,使用的不是 readr 期望的 NA

以这个简单的单列 CSV 文件为例:

simple_csv <- "
 x
 10
 .
 20
 30"

如果我们没有添加任何额外的参数读取它,x 将成为一个字符列:

read_csv(simple_csv)
#> # A tibble: 4 × 1
#>   x 
#>   <chr>
#> 1 10 
#> 2 . 
#> 3 20 
#> 4 30

在这个小例子中,您可以轻松看到缺失值 .。但是,如果您有成千上万行中间夹杂着几个 . 表示的缺失值会发生什么?一种方法是告诉 readr x 是一个数字列,然后查看它失败的地方。您可以使用 col_types 参数来做到这一点,该参数接受一个命名列表,其中名称与 CSV 文件中的列名匹配:

df <- read_csv(
  simple_csv, 
  col_types = list(x = col_double())
)
#> Warning: One or more parsing issues, call `problems()` on your data frame for
#> details, e.g.:
#>   dat <- vroom(...)
#>   problems(dat)

现在read_csv() 报告了问题,并告诉我们可以通过problems()了解更多信息:

problems(df)
#> # A tibble: 1 × 5
#>     row   col expected actual file 
#>   <int> <int> <chr>    <chr>  <chr> 
#> 1     3     1 a double .      /private/tmp/RtmpAYlSop/file392d445cf269

这告诉我们,在第 3 行第 1 列出现了问题,readr 期望是一个 double,但得到了一个 .。这表明该数据集使用 . 表示缺失值。因此,我们设置 na = ".",自动猜测成功,给我们了我们想要的数字列:

read_csv(simple_csv, na = ".")
#> # A tibble: 4 × 1
#>       x
#>   <dbl>
#> 1    10
#> 2    NA
#> 3    20
#> 4    30

列类型

readr 提供了总共九种列类型供您使用:

  • col_logical()col_double() 分别读取逻辑值和实数。它们相对较少使用(除非像前面展示的那样),因为 readr 通常会为你猜测这些类型。

  • col_integer() 读取整数。在这本书中我们很少区分整数和双精度浮点数,因为它们在功能上是等效的,但明确读取整数有时也很有用,因为它们只占双精度浮点数内存的一半。

  • col_character() 读取字符串。当你有一个列是数字标识符,即长序列的数字,用来标识一个对象但不适合进行数学运算时,这将会很有用。例如电话号码、社会保险号、信用卡号等。

  • col_factor(), col_date(), 和 col_datetime() 分别创建因子、日期和日期时间;当我们讨论到这些数据类型时(请见第十六章 和 第十七章),你会进一步学习这些内容。

  • col_number() 是一种宽松的数值解析器,它会忽略非数值组件,特别适用于货币。你将在第十三章中进一步了解它。

  • col_skip() 跳过一个列,使其不包含在结果中,如果你有一个大的 CSV 文件,并且只想使用部分列,这将会很有用。

也可以通过从 list() 切换到 cols() 并指定 .default 来覆盖默认列。

another_csv <- "
x,y,z
1,2,3"

read_csv(
  another_csv, 
  col_types = cols(.default = col_character())
)
#> # A tibble: 1 × 3
#>   x     y     z 
#>   <chr> <chr> <chr>
#> 1 1     2     3

另一个有用的辅助函数是 cols_only(),它将只读取你指定的列:

read_csv(
  another_csv,
  col_types = cols_only(x = col_character())
)
#> # A tibble: 1 × 1
#>   x 
#>   <chr>
#> 1 1

从多个文件读取数据

有时候你的数据分布在多个文件中,而不是单个文件。例如,你可能有多个月份的销售数据,每个月份的数据保存在单独的文件中:01-sales.csv 表示一月,02-sales.csv 表示二月,03-sales.csv 表示三月。使用 read_csv(),你可以一次性读取这些数据,并将它们堆叠在一个单一的数据框中。

sales_files <- c("data/01-sales.csv", "data/02-sales.csv", "data/03-sales.csv")
read_csv(sales_files, id = "file")
#> # A tibble: 19 × 6
#>   file              month    year brand  item     n
#>   <chr>             <chr>   <dbl> <dbl> <dbl> <dbl>
#> 1 data/01-sales.csv January  2019     1  1234     3
#> 2 data/01-sales.csv January  2019     1  8721     9
#> 3 data/01-sales.csv January  2019     1  1822     2
#> 4 data/01-sales.csv January  2019     2  3333     1
#> 5 data/01-sales.csv January  2019     2  2156     9
#> 6 data/01-sales.csv January  2019     2  3987     6
#> # … with 13 more rows

如果您的 CSV 文件位于项目的 data 文件夹中,前面的代码将再次起作用。您可以从 https://oreil.ly/jVd8ohttps://oreil.ly/RYsgMhttps://oreil.ly/4uZOm 下载这些文件,或直接读取它们:

sales_files <- c(
  "https://pos.it/r4ds-01-sales",
  "https://pos.it/r4ds-02-sales",
  "https://pos.it/r4ds-03-sales"
)
read_csv(sales_files, id = "file")

id 参数会向结果数据框中添加一个名为 file 的新列,用于标识数据来自哪个文件。在文件没有可帮助您追溯观察结果到原始来源的标识列的情况下,这尤为有用。

如果您要读取许多文件,逐个写出它们的名称作为列表可能会很麻烦。相反,您可以使用基础函数 list.files() 通过匹配文件名中的模式来查找文件。您将在 第十五章 中更多了解这些模式。

sales_files <- list.files("data", pattern = "sales\\.csv$", full.names = TRUE)
sales_files
#> [1] "data/01-sales.csv" "data/02-sales.csv" "data/03-sales.csv"

写入文件

readr 还提供了两个将数据写入磁盘的实用函数:write_csv()write_tsv()。这些函数的最重要参数是 x(要保存的数据框)和 file(保存位置)。您还可以使用 na 指定如何写入缺失值,以及是否要 append 到现有文件中。

write_csv(students, "students.csv")

现在让我们再次读取该 CSV 文件。请注意,由于从普通文本文件重新开始读取,您刚刚设置的变量类型信息将丢失:

students
#> # A tibble: 6 × 5
#>   student_id full_name        favourite_food     meal_plan             age
#>        <dbl> <chr>            <chr>              <fct>               <dbl>
#> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only              4
#> 2          2 Barclay Lynn     French fries       Lunch only              5
#> 3          3 Jayendra Lyne    <NA>               Breakfast and lunch     7
#> 4          4 Leon Rossini     Anchovies          Lunch only             NA
#> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch     5
#> 6          6 Güvenç Attila    Ice cream          Lunch only              6
write_csv(students, "students-2.csv")
read_csv("students-2.csv")
#> # A tibble: 6 × 5
#>   student_id full_name        favourite_food     meal_plan             age
#>        <dbl> <chr>            <chr>              <chr>               <dbl>
#> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only              4
#> 2          2 Barclay Lynn     French fries       Lunch only              5
#> 3          3 Jayendra Lyne    <NA>               Breakfast and lunch     7
#> 4          4 Leon Rossini     Anchovies          Lunch only             NA
#> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch     5
#> 6          6 Güvenç Attila    Ice cream          Lunch only              6

这使得 CSV 在缓存中间结果时略显不可靠 — 每次加载时都需要重新创建列规范。有两种主要替代方法:

  • write_rds()read_rds() 是围绕基础函数 readRDS()saveRDS() 的统一包装器。这些函数使用 R 的自定义二进制格式 RDS 存储数据。这意味着当重新加载对象时,您加载的是完全相同的 R 对象。

    write_rds(students, "students.rds")
    read_rds("students.rds")
    #> # A tibble: 6 × 5
    #>   student_id full_name        favourite_food     meal_plan             age
    #>        <dbl> <chr>            <chr>              <fct>               <dbl>
    #> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only              4
    #> 2          2 Barclay Lynn     French fries       Lunch only              5
    #> 3          3 Jayendra Lyne    <NA>               Breakfast and lunch     7
    #> 4          4 Leon Rossini     Anchovies          Lunch only             NA
    #> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch     5
    #> 6          6 Güvenç Attila    Ice cream          Lunch only              6
    
  • arrow 包允许您读取和写入 parquet 文件,这是一种快速的二进制文件格式,可以在多种编程语言之间共享。我们将在 第二十二章 中更深入地讨论 arrow。

    library(arrow)
    write_parquet(students, "students.parquet")
    read_parquet("students.parquet")
    #> # A tibble: 6 × 5
    #>   student_id full_name        favourite_food     meal_plan             age
    #>        <dbl> <chr>            <chr>              <fct>               <dbl>
    #> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only              4
    #> 2          2 Barclay Lynn     French fries       Lunch only              5
    #> 3          3 Jayendra Lyne    NA                 Breakfast and lunch     7
    #> 4          4 Leon Rossini     Anchovies          Lunch only             NA
    #> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch     5
    #> 6          6 Güvenç Attila    Ice cream          Lunch only              6
    

Parquet 比 RDS 更快,并且可在 R 之外使用,但需要 arrow 包。

数据输入

有时您需要在 R 脚本中手动组装 tibble 进行一些数据输入。有两个有用的函数可以帮助您完成此操作,这两个函数在按列或按行布局 tibble 方面有所不同。tibble() 按列工作:

tibble(
  x = c(1, 2, 5), 
  y = c("h", "m", "g"),
  z = c(0.08, 0.83, 0.60)
)
#> # A tibble: 3 × 3
#>       x y         z
#>   <dbl> <chr> <dbl>
#> 1     1 h      0.08
#> 2     2 m      0.83
#> 3     5 g      0.6

按列排列数据可能会使行之间的关系难以看清,因此另一种选择是tribble(),即transposed tibble,它允许你逐行布置数据。tribble()专为在代码中进行数据输入而定制:列标题以~开头,条目之间用逗号分隔。这使得可以以易于阅读的形式布置少量数据:

tribble(
  ~x, ~y, ~z,
  1, "h", 0.08,
  2, "m", 0.83,
  5, "g", 0.60
)
#> # A tibble: 3 × 3
#>   x         y     z
#>   <chr> <dbl> <dbl>
#> 1        1 h  0.08
#> 2        2 m  0.83
#> 3        5 g  0.6

摘要

在本章中,你学会了如何使用read_csv()加载 CSV 文件,并使用tibble()tribble()进行自己的数据输入。你已经了解了 CSV 文件的工作原理,可能会遇到的一些问题以及如何克服它们。我们将在本书中多次涉及数据导入:第二十章将向你展示如何从 Excel 和 Google Sheets 加载数据,第二十一章从数据库中加载,第二十二章从 Parquet 文件中加载,第二十三章从 JSON 中加载,以及第二十四章从网站中加载。

我们几乎已经完成了本书章节的结尾,但还有一个重要的主题需要讨论:如何获取帮助。因此,在下一章中,你将学习一些寻求帮助的好方法,如何创建一个示范性代码以最大化获取良好帮助的机会,以及一些关于跟上 R 世界的一般建议。

¹ janitor 包不属于 tidyverse 的一部分,但它提供了方便的数据清理功能,并且在使用|>的数据管道中运行良好。

² 你可以使用guess_max参数覆盖默认的 1,000。

第八章:工作流:获取帮助

这本书不是一个孤岛;没有单一的资源能让你精通 R。当你开始将本书描述的技术应用到自己的数据中时,你很快会发现我们没有回答的问题。本节描述了一些关于如何获取帮助和帮助你继续学习的技巧。

谷歌是你的朋友

如果遇到困难,请先使用 Google。通常在查询中添加“R”就足以限制结果为相关结果:如果搜索结果无用,则通常意味着没有针对 R 特定的结果可用。此外,添加像“tidyverse”或“ggplot2”这样的包名将有助于缩小结果范围,使得代码对你来说更加熟悉,例如,“如何在 R 中制作箱线图”与“如何使用 ggplot2 在 R 中制作箱线图”。Google 对于错误消息尤其有用。如果你收到错误消息并且不知道它的含义,试着 Google 搜索一下!很有可能以前有人也被这个错误消息困扰过,在网络上会有帮助的地方。(如果错误消息不是英文,运行Sys.setenv(LANGUAGE = "en")然后重新运行代码;这样你更有可能找到英文错误消息的帮助。)

如果 Google 没能帮助到你,尝试Stack Overflow。首先花点时间搜索现有答案,包括[R],以将搜索限制为使用 R 的问题和答案。

制作一个 reprex

如果你的 Google 搜索没有找到有用的内容,准备一个reprex是个非常好的主意,它是最小可复现示例的缩写。一个好的 reprex 使得其他人更容易帮助你,而且通常在制作它的过程中你会自己找到问题的答案。创建 reprex 有两个部分:

  • 首先,你需要让你的代码可复现。这意味着你需要捕获一切,即包括任何library()调用和创建所有必要的对象。确保你已经做到这一点的最简单方法是使用 reprex 包。

  • 其次,你需要让它变得最小化。剥离与你的问题无关的所有内容。这通常涉及创建一个比实际情况中更小更简单的 R 对象,甚至使用内置数据。

听起来工作量很大!而且确实如此,但它会带来巨大的回报:

  • 80%的情况下,创建一个优秀的 reprex 能揭示出问题的根源。写一个自包含和最小化示例的过程经常让你能够回答自己的问题,这是多么令人惊讶的。

  • 其余 20%的时间,你将以一种易于他人处理的方式捕捉到问题的本质。这极大地提高了你获取帮助的机会!

当手动创建 reprex 时,很容易偶然遗漏某些内容,这意味着你的代码在别人的计算机上无法运行。通过使用作为 tidyverse 的一部分安装的 reprex 包来避免这个问题。假设你将这段代码复制到剪贴板上(或者在 RStudio Server 或云端上,选择它):

y <- 1:4
mean(y)

然后调用 reprex(),其中默认输出已格式化为 GitHub 格式:

reprex::reprex()

在 RStudio 的 Viewer(如果你在使用 RStudio)或默认浏览器中,将会显示一个漂亮的 HTML 预览。reprex 会自动复制到你的剪贴板(在 RStudio Server 或云端,你需要手动复制):

``` r

y <- 1:4

mean(y)

#> [1] 2.5


此文本以 Markdown 的特殊方式格式化,可以粘贴到类似 StackOverflow 或 GitHub 的网站上,这些网站会自动渲染为代码样式。以下是在 GitHub 上渲染的 Markdown 样式:

y <- 1:4
mean(y)

> [1] 2.5


任何人都可以立即复制、粘贴并运行此代码。

要使你的示例可复现,有三样东西你需要包含:所需的包、数据和代码。

+   *包* 应该在脚本的顶部加载,这样很容易看到示例需要哪些包。现在是检查你是否使用每个包的最新版本的好时机;你可能发现了一个自从你安装或上次更新包后修复的 bug。对于 tidyverse 中的包,检查的最简单方法是运行 `tidyverse_update()`。

+   包含 *数据* 的最简单方法是使用 [`dput()`](https://rdrr.io/r/base/dput.xhtml) 生成重新创建所需数据的 R 代码。例如,在 R 中重新创建 `mtcars` 数据集,执行以下步骤:

    +   在 R 中运行 `dput(mtcars)`。

    +   复制输出。

    +   在 reprex 中,输入 `mtcars <-`,然后粘贴。

    尽量使用最小的数据子集来展示问题。

+   花点时间确保你的 *代码* 对他人易读:

    +   确保你已经使用了空格,并且你的变量名简洁但具有信息性。

    +   使用注释标明问题所在的位置。

    +   尽你最大努力删除与问题无关的所有内容。

    编写简短的代码,有助于理解和修复问题。

最后,通过开始一个新的 R 会话并复制粘贴你的脚本来检查是否真的创建了一个可复现的示例。

创建 reprex 并不简单,需要一些练习才能学会创建好的、真正最小化的 reprex。然而,学会提出包含代码并投入时间使其可复现的问题,将会在你学习和掌握 R 的过程中不断产生回报。

# 投资于自己

在问题出现之前,你也应该花些时间准备自己去解决问题。每天投资一点时间学习 R,从长远来看将会带来丰厚的回报。一种方法是关注 tidyverse 团队在[tidyverse 博客](https://oreil.ly/KS82J)上的最新动态。为了更广泛地了解 R 社区,我们建议阅读[R Weekly](https://oreil.ly/uhknU):这是一个社区努力,每周汇总 R 社区中最有趣的新闻。

# 总结

本章总结了书中“整个游戏”部分。现在你已经看到了数据科学过程中最重要的部分:可视化、转换、整理和导入。既然你已经全面了解了整个过程,我们可以开始深入了解各个细节部分了。

书的下一部分,“可视化”,更深入地探讨了图形语法和使用 ggplot2 创建数据可视化,展示了如何利用你迄今学到的工具进行探索性数据分析,并介绍了创建沟通图形的良好实践。


# 第二部分:可视化

在阅读本书的第一部分之后,您(至少表面上)理解了进行数据科学最重要的工具。现在是时候开始深入细节了。在本书的这一部分中,您将进一步深入了解数据可视化,包括图 II-1。

![我们的数据科学模型,高亮显示为蓝色的可视化部分。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_p200.png)

###### 图 II-1\. 数据可视化通常是数据探索的第一步。

每一章都涉及创建数据可视化的一个或几个方面:

+   在第九章中,您将学习关于图形的分层语法。

+   在第十章中,您将结合可视化与您的好奇心和怀疑精神,提出并回答有趣的关于数据的问题。

+   最后,在第十一章中,您将学习如何将您的探索性图形提升,并将它们转化为阐释性图形,这些图形可以帮助分析中的新手尽快轻松地理解正在发生的事情。

这三章让您开始进入可视化的世界,但还有更多要学习。学习更多的绝佳地方是《ggplot2: 数据分析的优雅图形》(Springer)[*ggplot2: Elegant Graphics for Data Analysis*](https://oreil.ly/SO1yG)。它更深入地讲解了底层理论,并有许多示例,展示如何组合单个组件来解决实际问题。另一个很好的资源是[ggplot2 扩展库](https://oreil.ly/m0OW5)。该网站列出了许多扩展 ggplot2 的包,其中包括新的 geoms 和 scales。如果您尝试使用 ggplot2 完成看似困难的任务,这是一个很好的起点。


# 第九章:层

# 简介

在第一章中,您不仅学会了如何制作散点图、条形图和箱形图,还学会了可以使用 ggplot2 制作*任何*类型的图形的基础知识。

在本章中,您将深入了解图形语法的分层。我们将从对美学映射、几何对象和面板的深入探讨开始。接着,您将了解 ggplot2 在创建图时在幕后进行的统计变换。这些变换用于计算要绘制的新值,例如条形图中的条形高度或箱形图中的中位数。您还将了解位置调整,这些调整修改了如何在图中显示几何对象。最后,我们会简要介绍坐标系。

我们不会涵盖每个层的每个函数和选项,但我们会为您介绍 ggplot2 提供的最重要和常用的功能,并介绍扩展 ggplot2 的包。

## 先决条件

本章专注于 ggplot2。要访问本章中使用的数据集、帮助页面和函数,请通过运行以下代码加载 tidyverse:

library(tidyverse)


# 美学映射

> “一幅图片最大的价值在于它迫使我们注意到我们从未预料到的东西。” —约翰·图基

请记住,ggplot2 包中捆绑的 `mpg` 数据框包含了 38 个汽车型号的 234 个观测值。

mpg

> # A tibble: 234 × 11

> manufacturer model displ year cyl trans drv cty hwy fl

>

> 1 audi a4 1.8 1999 4 auto(l5) f 18 29 p

> 2 audi a4 1.8 1999 4 manual(m5) f 21 29 p

> 3 audi a4 2 2008 4 manual(m6) f 20 31 p

> 4 audi a4 2 2008 4 auto(av) f 21 30 p

> 5 audi a4 2.8 1999 6 auto(l5) f 16 26 p

> 6 audi a4 2.8 1999 6 manual(m5) f 18 26 p

> # … with 228 more rows, and 1 more variable: class


在 `mpg` 中的变量包括:

`displ`

一辆汽车的发动机大小,以升为单位。一个数值变量。

`hwy`

一辆汽车在高速公路上的燃油效率,以每加仑英里数(mpg)表示。当两辆车行驶相同距离时,燃油效率低的汽车消耗的燃料比燃油效率高的汽车多。一个数值变量。

`class`

车辆类型。一个分类变量。

让我们从可视化汽车不同 `class` 类别的 `displ` 和 `hwy` 之间的关系开始。我们可以通过散点图来实现这一点,其中数值变量被映射到 `x` 和 `y` 美学,而分类变量则被映射到美学如 `color` 或 `shape`。

Left

ggplot(mpg, aes(x = displ, y = hwy, color = class)) +
geom_point()

Right

ggplot(mpg, aes(x = displ, y = hwy, shape = class)) +
geom_point()

> Warning: The shape palette can deal with a maximum of 6 discrete values

> because more than 6 becomes difficult to discriminate; you have 7.

> Consider specifying shapes manually if you must have them.

> Warning: Removed 62 rows containing missing values (geom_point()).


![并排显示的两个散点图,都用于可视化汽车的高速公路燃油效率与发动机大小,并显示负相关。左图中 `class` 被映射到颜色美学,结果是每个类别有不同的颜色。右图中 `class` 被映射到形状美学,结果是每个类别有不同的绘图字符形状,除了 SUV。每个图都带有图例,显示颜色或形状与 `class` 变量级别之间的映射关系。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in01.png)

当 `class` 被映射到 `shape` 时,我们会收到两个警告:

> 1:形状调色板最多可以处理 6 个离散值,因为超过 6 个会变得难以区分;你有 7 个。如果必须使用它们,请考虑手动指定形状。
> 
> 2: 删除了 62 行包含缺失值的观测([`geom_point()`](https://ggplot2.tidyverse.org/reference/geom_point.xhtml))。

由于 ggplot2 默认一次只使用六种形状,使用形状美学时,额外的分组将不会被绘制出来。第二个警告与此相关——数据集中有 62 辆 SUV,它们没有被绘制出来。

同样地,我们也可以将 `class` 映射到 `size` 或 `alpha` 美学,分别控制点的形状和透明度。

Left

ggplot(mpg, aes(x = displ, y = hwy, size = class)) +
geom_point()

> Warning: Using size for a discrete variable is not advised.

Right

ggplot(mpg, aes(x = displ, y = hwy, alpha = class)) +
geom_point()

> Warning: Using alpha for a discrete variable is not advised.


![两个相邻的散点图,分别可视化汽车的高速公路燃油效率与引擎尺寸,显示出负相关关系。在左侧的图中,类别被映射到了大小美学,导致每个类别的点大小不同。在右侧的图中,类别被映射到了透明度美学,导致每个类别的透明度(alpha)水平不同。每个图都附带了一个图例,显示了大小或透明度级别与类别变量级别之间的映射关系。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in02.png)

这两者也会产生警告:

> 不建议为离散变量使用 alpha。

将一个无序的离散(分类)变量(`class`)映射到有序的美学(`size`或`alpha`)通常不是一个好主意,因为它暗示了一个实际上并不存在的排名。

一旦映射了美学,ggplot2 会处理其余部分。它选择一个合理的比例尺与美学一起使用,并构建一个解释级别与值之间映射的图例。对于 x 和 y 美学,ggplot2 不会创建图例,但会创建带有刻度线和标签的坐标轴线。坐标轴线提供与图例相同的信息;它解释了位置与值之间的映射。

您还可以手动设置您的几何图形的视觉属性作为几何函数的参数(*不在* [`aes()`](https://ggplot2.tidyverse.org/reference/aes.xhtml) 内)。例如,我们可以将图中的所有点都设为蓝色:

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(color = "blue")


![汽车的高速公路燃油效率与引擎尺寸的散点图,显示出负相关关系。所有点都是蓝色。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in03.png)

在这里,颜色并不传达有关变量的信息;它只是改变了绘图的外观。您需要选择一个对该美学有意义的值:

+   颜色名称作为字符串,例如,`color = "blue"`

+   点的大小以毫米为单位,例如,`size = 1`

+   点的形状作为一个数字,例如,`shape = 1`,如 图 9-1 所示。

![形状与代表它们的数字之间的映射:0 - 正方形,1 - 圆形,2 - 三角形朝上,3 - 加号,4 - 叉号,5 - 钻石形,6 - 三角形朝下,7 - 方形叉叉,8 - 星星,9 - 钻石加,10 - 圆加,11 - 上下三角形,12 - 方形加,13 - 圆叉叉,14 - 方形和下三角形,15 - 填充的正方形,16 - 填充的圆形,17 - 填充的三角形朝上,18 - 填充的钻石形,19 - 实心圆形,20 - 子弹(较小圆形),21 - 填充的蓝色圆形,22 - 填充的蓝色正方形,23 - 填充的蓝色钻石形,24 - 填充的蓝色三角形朝上,25 - 填充的蓝色三角形朝下。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_0901.png)

###### 图 9-1\. R 有 25 种内置形状,用数字标识。有些看起来重复:例如,0、15 和 22 都是正方形。区别在于 `color` 和 `fill` 美学的交互作用。空心形状(0–14)的边界由 `color` 决定;实心形状(15–20)填充有 `color`;填充形状(21–24)边界由 `color` 决定,填充有 `fill`。形状排列以保持相似形状相邻。

到目前为止,我们讨论了我们可以在散点图中映射或设置的美学,当使用点几何对象时。你可以在 [美学规范小册子](https://oreil.ly/SP6zV) 中了解更多所有可能的美学映射。

你可以用于图形的具体美学取决于你用来表示数据的几何对象。在接下来的部分中,我们将更深入地探讨几何对象。

## 练习

1.  创建一个散点图,其中 `hwy` 对 `displ`,点为粉色填充的三角形。

1.  为什么以下代码没有产生蓝色点的图形?

    ```
    ggplot(mpg) + 
      geom_point(aes(x = displ, y = hwy, color = "blue"))
    ```

1.  `stroke` 美学是做什么的?它适用于什么形状?(提示:参考 [`?geom_point`](https://ggplot2.tidyverse.org/reference/geom_point.xhtml)。)

1.  如果将美学映射到除变量名之外的东西,比如 `aes(color = displ < 5)`,会发生什么?注意,你还需要指定 x 和 y。

# 几何对象

这两个图形有何相似之处?

![有两个图。左边的图是汽车的高速公路燃油效率与发动机尺寸之间的散点图,右边的图显示了这些变量之间关系的平滑曲线。平滑曲线周围还显示了一个置信区间。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in04.png)

两个图都包含相同的 x 变量和相同的 y 变量,并且描述的都是相同的数据。但是这两个图并不相同。每个图使用不同的几何对象(geom)来表示数据。左边的图使用点几何对象(geom),右边的图使用平滑曲线几何对象(geom),这是根据数据拟合得到的平滑线。

要更改绘图中的几何对象,请更改添加到 [`ggplot()`](https://ggplot2.tidyverse.org/reference/ggplot.xhtml) 的几何函数。例如,要创建前面的图形,你可以使用以下代码:

Left

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point()

Right

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_smooth()

> geom_smooth() using method = 'loess' and formula = 'y ~ x'


ggplot2 中的每个几何函数都接受一个`mapping`参数,可以在几何图层内局部定义,也可以在全局的[`ggplot()`](https://ggplot2.tidyverse.org/reference/ggplot.xhtml)图层中定义。然而,并非每个美学特征都适用于每个几何图形。你可以设置点的形状,但不能设置线的“形状”。如果尝试设置,ggplot2 将默默忽略该美学映射。另一方面,*可以*设置线的线型。[`geom_smooth()`](https://ggplot2.tidyverse.org/reference/geom_smooth.xhtml)将为您映射到线型的每个唯一值绘制不同的线条。

Left

ggplot(mpg, aes(x = displ, y = hwy, shape = drv)) +
geom_smooth()

Right

ggplot(mpg, aes(x = displ, y = hwy, linetype = drv)) +
geom_smooth()


![汽车引擎大小与公路燃油效率的两个绘图。数据用光滑曲线表示。左侧为三条相同线型的光滑曲线。右侧为三条不同线型(实线、虚线或长虚线)的光滑曲线,每种驱动类型一条。在两个图中,还显示了光滑曲线周围的置信区间。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in05.png)

在这里,[`geom_smooth()`](https://ggplot2.tidyverse.org/reference/geom_smooth.xhtml)基于其`drv`值将汽车分为三条线。一条线描述所有具有`4`值的点,一条线描述所有具有`f`值的点,还有一条线描述所有具有`r`值的点。在此,`4`代表四轮驱动,`f`代表前轮驱动,`r`代表后轮驱动。

如果这听起来很奇怪,我们可以通过将线条叠加在原始数据之上并根据`drv`进行着色来使其更清晰。

ggplot(mpg, aes(x = displ, y = hwy, color = drv)) +
geom_point() +
geom_smooth(aes(linetype = drv))


![汽车引擎大小与公路燃油效率的绘图。数据用点表示(按驱动类型着色),以及光滑曲线(线型基于驱动类型确定)。还显示了光滑曲线周围的置信区间。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in06.png)

注意,此图包含同一图中的两个几何图形。

许多几何图形,例如[`geom_smooth()`](https://ggplot2.tidyverse.org/reference/geom_smooth.xhtml),使用单个几何对象来显示多行数据。对于这些几何图形,可以将`group`美学设置为分类变量,以绘制多个对象。ggplot2 将为每个唯一的分组变量值绘制一个单独的对象。在实践中,每当将美学映射到离散变量(如`linetype`示例中)时,ggplot2 会自动对数据进行分组,依赖此特性非常方便。因为单独的`group`美学不会为几何图形添加图例或区分特征。

Left

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_smooth()

Middle

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_smooth(aes(group = drv))

Right

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_smooth(aes(color = drv), show.legend = FALSE)


![三个图表,每个图表的 y 轴是高速公路燃油效率,x 轴是汽车引擎尺寸,数据由平滑曲线表示。第一个图表仅包含这两个变量,中间的图表有三条单独的平滑曲线,分别表示每个驱动系统级别,右边的图表不仅有三条分别表示每个驱动系统级别的平滑曲线,而且这些曲线以不同的颜色绘制,图例解释了每种颜色映射到哪个级别。平滑曲线周围还显示了置信区间。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in07.png)

如果你将映射放置在 geom 函数中,ggplot2 将把它们视为该层的本地映射。它将使用这些映射来扩展或覆盖该层的全局映射*。这使得在不同的层中显示不同的美学成为可能。

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = class)) +
geom_smooth()


![汽车引擎尺寸与高速公路燃油效率散点图,根据汽车类别着色。覆盖了汽车高速公路燃油效率与引擎尺寸关系的平滑曲线及其置信区间。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in08.png)

你可以使用相同的方法为每一层指定不同的`data`。在这里,我们使用红色点和空心圆圈来突出两座位汽车。[`geom_point()`](https://ggplot2.tidyverse.org/reference/geom_point.xhtml) 中的本地 data 参数仅覆盖了该层中 [`ggplot()`](https://ggplot2.tidyverse.org/reference/ggplot.xhtml) 的全局 data 参数。

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
geom_point(
data = mpg |> filter(class == "2seater"),
color = "red"
) +
geom_point(
data = mpg |> filter(class == "2seater"),
shape = "circle open", size = 3, color = "red"
)


![汽车引擎尺寸与高速公路燃油效率散点图,根据汽车类别着色。覆盖了超迷你车型高速公路燃油效率与引擎尺寸关系的平滑曲线及其置信区间。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in09.png)

Geom 是 ggplot2 的基本构建块。通过改变其 geom,你可以完全改变图表的外观,并且不同的 geom 可以展示数据的不同特征。例如,以下直方图和密度图显示了高速公路里程分布是双峰性且右偏,而箱线图则显示了两个潜在的异常值:

Left

ggplot(mpg, aes(x = hwy)) +
geom_histogram(binwidth = 2)

Middle

ggplot(mpg, aes(x = hwy)) +
geom_density()

Right

ggplot(mpg, aes(x = hwy)) +
geom_boxplot()


![高速公路里程的直方图、密度图和箱线图。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in10.png)

ggplot2 提供了超过 40 种几何图形,但这些图形并不能涵盖所有可能的图表类型。如果你需要不同的几何图形,请先查看[扩展包](https://oreil.ly/ARL_4),看看是否有其他人已经实现了。例如,[ggridges 包](https://oreil.ly/pPIuL)对制作脊线图很有用,这对于可视化数值变量在分类变量不同级别上的密度非常有用。在下图中,我们不仅使用了一个新的几何图形([`geom_density_ridges()`](https://wilkelab.org/ggridges/reference/geom_density_ridges.xhtml)),还将同一变量映射到多个美学属性(`drv`映射到`y`、`fill`和`color`),并设置了一个美学属性(`alpha = 0.5`)来使密度曲线透明化。

library(ggridges)

ggplot(mpg, aes(x = hwy, y = drv, fill = drv, color = drv)) +
geom_density_ridges(alpha = 0.5, show.legend = FALSE)

> Picking joint bandwidth of 1.28


![对于具有后轮驱动、前轮驱动和四轮驱动的汽车的公路里程密度曲线分别绘制。分布对于实际和四轮驱动汽车是双峰且大致对称的,对于前轮驱动汽车是单峰且右偏。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in11.png)

获取关于 ggplot2 提供的所有几何图形以及包中所有函数的全面概述的最佳途径是[参考页面](https://oreil.ly/cIFgm)。要了解有关任何单个几何图形的更多信息,请使用帮助(例如,[`?geom_smooth`](https://ggplot2.tidyverse.org/reference/geom_smooth.xhtml))。

## 练习

1.  你会用什么几何图形来绘制线图?箱线图?直方图?面积图?

1.  本章前面我们使用了`show.legend`而没有解释它:

    ```
    ggplot(mpg, aes(x = displ, y = hwy)) +
      geom_smooth(aes(color = drv), show.legend = FALSE)
    ```

    这里的`show.legend = FALSE`是做什么用的?如果去掉它会发生什么?你认为我们之前为什么使用它?

1.  [`geom_smooth()`](https://ggplot2.tidyverse.org/reference/geom_smooth.xhtml)中的`se`参数是什么意思?

1.  重新创建生成以下图表所需的 R 代码。请注意,在图中使用分类变量时,它是`drv`。

![这个图中有六个散点图,排列成 3x2 的网格。在所有的图中,汽车的公路燃油效率在 y 轴上,发动机尺寸在 x 轴上。第一个图显示所有点都是黑色的,并在它们上面叠加了一个平滑曲线。在第二个图中,点也是全黑色的,但为每个驱动类型分别叠加了平滑曲线。在第三个图中,点和平滑曲线用不同的颜色表示了每个驱动类型。在第四个图中,点用不同的颜色表示每个驱动类型,但是整个数据集只拟合了一条平滑曲线。在第五个图中,点用不同的颜色表示每个驱动类型,每个驱动类型拟合了不同线型的平滑曲线。最后,在第六个图中,点用不同的颜色表示每个驱动类型,并且它们有厚厚的白色边框。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in12.png)

# 多面板

在第一章中,您学习了如何使用[`facet_wrap()`](https://ggplot2.tidyverse.org/reference/facet_wrap.xhtml)进行分面,它将图表分成子图,每个子图根据分类变量显示一个数据子集。

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
facet_wrap(~cyl)


![散点图显示了汽车的高速公路燃油效率与发动机大小之间的关系,按类别分组,子图跨越两行。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in13.png)

要使用两个变量的组合分面绘制图表,请从[`facet_wrap()`](https://ggplot2.tidyverse.org/reference/facet_wrap.xhtml)切换到[`facet_grid()`](https://ggplot2.tidyverse.org/reference/facet_grid.xhtml)。[`facet_grid()`](https://ggplot2.tidyverse.org/reference/facet_grid.xhtml)的第一个参数也是一个公式,但现在是双向公式:`行 ~ 列`。

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
facet_grid(drv ~ cyl)


![散点图显示了汽车的高速公路燃油效率与发动机大小之间的关系,按汽缸数分组显示在行上,按驱动类型分组显示在列上。这导致了一个 4x3 的网格,共 12 个子图。其中一些子图没有观察结果:5 汽缸和四轮驱动,4 或 5 汽缸和前轮驱动。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in14.png)

默认情况下,每个子图在 x 轴和 y 轴上共享相同的比例和范围。这在您希望跨子图比较数据时非常有用,但在更好地可视化每个子图内关系时可能有所限制。将分面函数中的`scales`参数设置为`"free"`将允许跨行和列使用不同的轴标尺,`"free_x"`将允许跨行使用不同的标尺,`"free_y"`将允许跨列使用不同的标尺。

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
facet_grid(drv ~ cyl, scales = "free_y")


![散点图显示了汽车的高速公路燃油效率与发动机大小之间的关系,按汽缸数分组显示在行上,按驱动类型分组显示在列上。这导致了一个 4x3 的网格,共 12 个子图。其中一些子图没有观察结果:5 汽缸和四轮驱动,4 或 5 汽缸和前轮驱动。行内的子图共享相同的 y 轴标尺,列内的子图共享相同的 x 轴标尺。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in15.png)

## 练习

1.  如果在一个连续变量上进行分面会发生什么?

1.  在带有`facet_grid(drv ~ cyl)`的图中,空单元格代表什么?运行以下代码。这些单元格与结果图有何关系?

    ```
    ggplot(mpg) + 
      geom_point(aes(x = drv, y = cyl))
    ```

1.  以下代码生成了什么图形?`.`代表什么?

    ```
    ggplot(mpg) + 
      geom_point(aes(x = displ, y = hwy)) +
      facet_grid(drv ~ .)

    ggplot(mpg) + 
      geom_point(aes(x = displ, y = hwy)) +
      facet_grid(. ~ cyl)
    ```

1.  看一下本节的第一个分面图:

    ```
    ggplot(mpg) + 
      geom_point(aes(x = displ, y = hwy)) + 
      facet_wrap(~ class, nrow = 2)
    ```

    使用分面绘图而不是颜色美学有什么优点?有什么缺点?如果数据集更大会如何平衡?

1.  阅读[`?facet_wrap`](https://ggplot2.tidyverse.org/reference/facet_wrap.xhtml)。`nrow`是什么?`ncol`是什么?其他选项控制单个面板的布局吗?为什么[`facet_grid()`](https://ggplot2.tidyverse.org/reference/facet_grid.xhtml)没有`nrow`和`ncol`参数?

1.  以下哪种图表更容易比较不同驱动方式的汽车引擎大小(`displ`)?这反映了何时应该在行或列上放置分面变量?

    ```
    ggplot(mpg, aes(x = displ)) + 
      geom_histogram() + 
      facet_grid(drv ~ .)

    ggplot(mpg, aes(x = displ)) + 
      geom_histogram() +
      facet_grid(. ~ drv)
    ```

1.  使用 [`facet_wrap()`](https://ggplot2.tidyverse.org/reference/facet_wrap.xhtml) 重新创建以下图表,而不是使用 [`facet_grid()`](https://ggplot2.tidyverse.org/reference/facet_grid.xhtml)。分面标签的位置如何改变?

    ```
    ggplot(mpg) + 
      geom_point(aes(x = displ, y = hwy)) +
      facet_grid(drv ~ .)
    ```

# 统计变换

考虑使用 [`geom_bar()`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml) 或 [`geom_col()`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml) 绘制基本条形图。下图显示了`diamonds`数据集中按`cut`分组的钻石总数。`diamonds`数据集位于 ggplot2 包中,包括每颗钻石的 `price`、`carat`、`color`、`clarity` 和 `cut` 信息,约 54,000 颗钻石。该图表显示,高质量切割的钻石比低质量切割的钻石更多。

ggplot(diamonds, aes(x = cut)) +
geom_bar()


![每种切割钻石数量的条形图。大约有 1500 颗 Fair,5000 颗 Good,12000 颗 Very Good,14000 颗 Premium 和 22000 颗 Ideal 切割的钻石。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in16.png)

在 x 轴上,图表显示了来自 `diamonds` 的变量 `cut`。在 y 轴上,它显示 count,但 `diamonds` 中没有 count 变量!count 是从哪里来的?许多图表(如散点图)绘制数据集的原始值。其他图表(如条形图)计算新值以绘制:

+   条形图、直方图和频率多边形将数据进行分组,然后绘制每个分组的点数。

+   平滑曲线将模型拟合到您的数据,然后绘制模型预测。

+   箱线图计算分布的五数总结,然后以特殊格式显示该总结。

用于计算图表新值的算法称为 *stat*,即统计变换的简称。图 9-2 显示了使用 [`geom_bar()`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml) 的这一过程。

![展示创建条形图三个步骤的图示。第 1 步:`geom_bar()` 从钻石数据集开始。第 2 步:`geom_bar()` 使用 count 统计函数转换数据,返回一个包含切割值和计数的数据集。第 3 步:`geom_bar()` 使用转换后的数据绘制图表。cut 映射到 x 轴,count 映射到 y 轴。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_0902.png)

###### 图 9-2。创建条形图时,首先从原始数据开始,然后对数据进行汇总以计算每个条的观测次数,最后将这些计算的变量映射到绘图美学上。

你可以通过检查`stat`参数的默认值来了解一个几何对象使用的统计方法。例如,[`?geom_bar`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml)显示`stat`的默认值是`count`,这意味着[`geom_bar()`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml)使用[`stat_count()`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml)。[`stat_count()`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml)的文档可以在与[`geom_bar()`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml)同一页上找到。如果你向下滚动,标题为“计算变量”的部分解释了它计算两个新变量:`count`和`prop`。

每个几何对象都有一个默认的统计方法,而每个统计方法都有一个默认的几何对象。这意味着通常可以使用几何对象而不必担心底层的统计变换。但是,您可能需要显式地使用统计方法有以下三个原因:

1.  你可能希望覆盖默认的统计方法。在下面的代码中,我们将[`geom_bar()`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml)的统计方法从默认的计数改为标识。这样可以将柱状图的高度映射到 y 变量的原始值。

    ```
    diamonds |>
      count(cut) |>
      ggplot(aes(x = cut, y = n)) +
      geom_bar(stat = "identity")
    ```

    ![钻石切割种类数量的柱状图。大约有 1500 颗 Fair、5000 颗 Good、12000 颗 Very Good、14000 颗 Premium 和 22000 颗 Ideal 切割的钻石。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in17.png)

1.  您可能希望覆盖从转换变量到美学属性的默认映射。例如,您可能希望显示比例柱状图,而不是计数:

    ```
    ggplot(diamonds, aes(x = cut, y = after_stat(prop), group = 1)) + 
      geom_bar()
    ```

    ![每种钻石切割的比例柱状图。大约,Fair 钻石占 0.03,Good 占 0.09,Very Good 占 0.22,Premium 占 0.26,Ideal 占 0.40。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in18.png)

    要查找统计方法可以计算的可能变量,请查看[`geom_bar()`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml)帮助文档中标题为“计算变量”的部分。

1.  你可能希望在你的代码中更加关注统计变换。例如,你可以使用[`stat_summary()`](https://ggplot2.tidyverse.org/reference/stat_summary.xhtml),它可以对每个唯一的 x 值进行 y 值的总结,以便突出你正在计算的汇总信息:

    ```
    ggplot(diamonds) + 
      stat_summary(
        aes(x = cut, y = depth),
        fun.min = min,
        fun.max = max,
        fun = median
      )
    ```

![钻石深度(y 轴)与切割种类(x 轴,包括 fair、good、very good、premium 和 ideal)的关系图。对于每种切割等级,垂直线条表示该切割类别内钻石的最小和最大深度,中位深度用点表示。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in19.png)

ggplot2 提供了超过 20 种统计方法供您使用。每种统计方法都是一个函数,因此您可以像通常一样获取帮助,例如[`?stat_bin`](https://ggplot2.tidyverse.org/reference/geom_histogram.xhtml)。

## 练习

1.  默认与[`stat_summary()`](https://ggplot2.tidyverse.org/reference/stat_summary.xhtml)关联的几何对象是什么?你如何重写前面的图表以使用该几何函数代替统计函数?

1.  [`geom_col()`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml)的作用是什么?它与[`geom_bar()`](https://ggplot2.tidyverse.org/reference/geom_bar.xhtml)有什么不同?

1.  大多数几何对象和统计函数都成对出现,几乎总是一起使用。列出所有这些配对。它们有什么共同点?(提示:阅读文档。)

1.  [`stat_smooth()`](https://ggplot2.tidyverse.org/reference/geom_smooth.xhtml)计算哪些变量?控制它行为的参数是什么?

1.  在我们的比例条形图中,我们需要设置`group = 1`。为什么?换句话说,这两个图有什么问题?

    ```
    ggplot(diamonds, aes(x = cut, y = after_stat(prop))) + 
      geom_bar()
    ggplot(diamonds, aes(x = cut, fill = color, y = after_stat(prop))) + 
      geom_bar()
    ```

# 位置调整

条形图还有一个更神奇的功能。可以使用`color`美学或更实用的`fill`美学来着色条形图:

Left

ggplot(mpg, aes(x = drv, color = drv)) +
geom_bar()

Right

ggplot(mpg, aes(x = drv, fill = drv)) +
geom_bar()


![两个汽车驱动类型的条形图。在第一个图中,条形有彩色边框。在第二个图中,它们填充有颜色。条形的高度对应每个切割类别中的汽车数量。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in20.png)

注意一下,如果将填充美学映射到另一个变量,例如`class`,则条形将自动堆叠。每个彩色矩形代表了`drv`和`class`的组合。

ggplot(mpg, aes(x = drv, fill = class)) +
geom_bar()


![汽车驱动类型的分段条形图,每根条形都填充有代表汽车类别的颜色。条形的高度对应每个驱动类型中汽车的数量,而彩色段的高度则与给定驱动类型级别内给定类别级别的汽车数量成比例。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in21.png)

自动堆叠是通过`position`参数指定的*位置调整*来完成的。如果不想要堆叠的条形图,可以使用三种其他选项之一:`"identity"`、`"dodge"`或`"fill"`。

+   `position = "identity"`会将每个对象放置在其在图形上下文中的确切位置。这对于条形图并不是很有用,因为它们会重叠。要查看重叠效果,我们需要通过将`alpha`设置为一个小值或通过设置`fill = NA`使条形图变得稍微透明或完全透明。

    ```
    # Left
    ggplot(mpg, aes(x = drv, fill = class)) + 
      geom_bar(alpha = 1/5, position = "identity")

    # Right
    ggplot(mpg, aes(x = drv, color = class)) + 
      geom_bar(fill = NA, position = "identity")
    ```

    ![汽车驱动类型的分段条形图,每根条形都填充有代表汽车类别的颜色。条形的高度对应每个驱动类型中汽车的数量,而彩色段的高度则与给定驱动类型级别内给定类别级别的汽车数量成比例。然而,这些段重叠。在第一个图中,条形填充有透明颜色,在第二个图中,它们仅用颜色轮廓勾勒。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in22.png)

    身份位置调整对于二维几何对象(例如点)更有用,因为它是默认设置。

+   `position = "fill"`类似于堆叠,但使每组堆叠的条形图高度相同。这样可以更容易地比较不同组的比例。

+   `position = "dodge"`将重叠对象直接*相邻*放置。这样可以更容易地比较单个值。

    ```
    # Left
    ggplot(mpg, aes(x = drv, fill = class)) + 
      geom_bar(position = "fill")

    # Right
    ggplot(mpg, aes(x = drv, fill = class)) + 
      geom_bar(position = "dodge")
    ```

    ![汽车驱动类型的分段条形图,每根条形填充不同颜色表示不同级别的分类。每根条形的高度为 1,而彩色段的高度表示具有给定驱动类型内给定分类级别的汽车比例。右侧是汽车驱动类型的分组条形图。分组的条形以驱动类型级别分组。在每个组内,条形代表每个分类级别。某些分类在某些驱动类型中表示,而在其他类型中则没有,导致每个组内的条形数量不均匀。这些条形的高度表示具有给定驱动类型和分类级别的汽车数量。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in23.png)

还有一种调整类型对条形图没有用处,但对散点图非常有用。回想一下我们的第一个散点图。您是否注意到,尽管数据集中有 234 个观测值,但图中仅显示了 126 个点?

![汽车高速公路燃油效率与发动机尺寸的散点图,显示负相关关系。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in24.png)

`hwy`和`displ`的基础值被舍入,因此点位于网格上,并且许多点彼此重叠。这个问题被称为*重叠绘制*。这种排列使得难以看到数据的分布。数据点是否均匀分布在整个图表中,或者是否存在一个特定的`hwy`和`displ`组合包含 109 个值?

您可以通过将位置调整设置为`“jitter”`来避免此网格化。使用`position = "jitter"`会向每个点添加少量随机噪声。这会使点分散开,因为不太可能有两个点接收相同数量的随机噪声。

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(position = "jitter")


![汽车高速公路燃油效率与发动机尺寸的抖动散点图。图示显示负相关关系。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in25.png)

添加随机性似乎是改善图形的奇怪方法,但这样做会使您的图形在小尺度上变得不太准确,在大尺度上却更加具有*显现*性。由于这是一个非常有用的操作,ggplot2 提供了`geom_point(position = "jitter")`的简写:[`geom_jitter()`](https://ggplot2.tidyverse.org/reference/geom_jitter.xhtml)。

要了解有关位置调整的更多信息,请查阅与每种调整相关联的帮助页面:

+   [`?position_dodge`](https://ggplot2.tidyverse.org/reference/position_dodge.xhtml)

+   [`?position_fill`](https://ggplot2.tidyverse.org/reference/position_stack.xhtml)

+   [`?position_identity`](https://ggplot2.tidyverse.org/reference/position_identity.xhtml)

+   [`?position_jitter`](https://ggplot2.tidyverse.org/reference/position_jitter.xhtml)

+   [`?position_stack`](https://ggplot2.tidyverse.org/reference/position_stack.xhtml)

## 练习

1.  下列图的问题是什么?您如何改进它?

    ```
    ggplot(mpg, aes(x = cty, y = hwy)) + 
      geom_point()
    ```

1.  这两个图有什么区别(如果有的话)?为什么?

    ```
    ggplot(mpg, aes(x = displ, y = hwy)) +
      geom_point()
    ggplot(mpg, aes(x = displ, y = hwy)) +
      geom_point(position = "identity")
    ```

1.  [`geom_jitter()`](https://ggplot2.tidyverse.org/reference/geom_jitter.xhtml)控制抖动量的参数是什么?

1.  比较和对比[`geom_jitter()`](https://ggplot2.tidyverse.org/reference/geom_jitter.xhtml)和[`geom_count()`](https://ggplot2.tidyverse.org/reference/geom_count.xhtml)。

1.  [`geom_boxplot()`](https://ggplot2.tidyverse.org/reference/geom_boxplot.xhtml)的默认位置调整是什么?创建一个`mpg`数据集的可视化示例来演示它。

# 坐标系统

坐标系统可能是 ggplot2 最复杂的部分。默认坐标系是笛卡尔坐标系,其中 x 和 y 位置独立作用于确定每个点的位置。偶尔还有两种其他有帮助的坐标系。

+   [`coord_quickmap()`](https://ggplot2.tidyverse.org/reference/coord_map.xhtml)可为地理地图设置正确的纵横比。这在使用 ggplot2 绘制空间数据时非常重要。我们在本书中无法详细讨论地图,但你可以在《ggplot2: Data Analysis》(Springer)的[地图章节](https://oreil.ly/45GHE)中了解更多。

    ```
    nz <- map_data("nz")

    ggplot(nz, aes(x = long, y = lat, group = group)) +
      geom_polygon(fill = "white", color = "black")

    ggplot(nz, aes(x = long, y = lat, group = group)) +
      geom_polygon(fill = "white", color = "black") +
      coord_quickmap()
    ```

    ![新西兰边界的两幅地图。第一幅图的纵横比不正确,第二幅图则正确。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in26.png)

    ![新西兰边界的两幅地图。第一幅图的纵横比不正确,第二幅图则正确。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in27.png)

+   [`coord_polar()`](https://ggplot2.tidyverse.org/reference/coord_polar.xhtml)使用极坐标。极坐标揭示了条形图和 Coxcomb 图之间的有趣联系。

    ```
    bar <- ggplot(data = diamonds) + 
      geom_bar(
        mapping = aes(x = clarity, fill = clarity), 
        show.legend = FALSE,
        width = 1
      ) + 
      theme(aspect.ratio = 1)

    bar + coord_flip()
    bar + coord_polar()
    ```

![左侧是钻石清晰度的条形图,右侧是同一数据的 Coxcomb 图。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_09in28.png)

## 练习

1.  使用[`coord_polar()`](https://ggplot2.tidyverse.org/reference/coord_polar.xhtml)将堆叠条形图转换为饼图。

1.  [`coord_quickmap()`](https://ggplot2.tidyverse.org/reference/coord_map.xhtml)和[`coord_map()`](https://ggplot2.tidyverse.org/reference/coord_map.xhtml)有什么区别?

1.  以下图形告诉你城市和高速公路 mpg 之间的关系?[`coord_fixed()`](https://ggplot2.tidyverse.org/reference/coord_fixed.xhtml)的重要性是什么?[`geom_abline()`](https://ggplot2.tidyverse.org/reference/geom_abline.xhtml)的作用是什么?

    ```
    ggplot(data = mpg, mapping = aes(x = cty, y = hwy)) +
      geom_point() + 
      geom_abline() +
      coord_fixed()
    ```

# 图形语法的分层

我们可以在你在“ggplot2 Calls”学到的图形模板上扩展,添加位置调整、统计、坐标系和分面:

ggplot(data = ) +
<GEOM_FUNCTION>(
mapping = aes(),
stat = ,
position =
) +
<COORDINATE_FUNCTION> +
<FACET_FUNCTION>


我们的新模板需要七个参数,即模板中出现的方括号内的单词。实际上,为了制作图表,你很少需要提供所有七个参数,因为 ggplot2 会为除数据、映射和几何函数之外的所有内容提供有用的默认值。

模板中的七个参数组成了图形语法,这是一种构建图形的形式系统。图形语法基于这样的洞察力:你可以将**任何**图形唯一地描述为数据集、几何对象、一组映射、统计函数、位置调整、坐标系、分面方案和主题的组合。

要了解其工作原理,考虑如何从头开始构建一个基本的图:你可以从一个数据集开始,然后将其转换为你想要显示的信息(使用一个统计函数)。接下来,你可以选择一个几何对象来表示转换后数据中的每个观测。然后,你可以使用这些几何对象的美学属性来表示数据中的变量。你会将每个变量的值映射到一个美学的层次上。这些步骤在图 9-3 中有所说明。然后,你可以选择一个坐标系将这些几何对象放置其中,利用对象的位置(本身就是美学属性)来显示 x 和 y 变量的值。

![展示从原始数据到频率表的步骤,其中每行表示一个切割级别,一个计数列显示该切割级别中有多少钻石。然后,这些值映射到柱子的高度。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_0903.png)

###### 图 9-3\. 这些是从原始数据到频率表到柱状图的步骤,其中柱子的高度代表频率。

在这一点上,你将拥有一个完整的图形,但你可以进一步调整几何对象在坐标系内的位置(位置调整)或将图形分成子图(分面)。你还可以通过添加一个或多个额外的图层来扩展图形,其中每个额外的图层使用一个数据集,一个几何对象,一组映射,一个统计函数和一个位置调整。

你可以使用这种方法构建*任何*你想象的图形。换句话说,你可以使用本章学到的代码模板来构建数十万个独特的图形。

如果你想更深入了解 ggplot2 的理论基础,可以阅读[“图形的分层语法”](https://oreil.ly/8fZzE),这篇科学论文详细描述了 ggplot2 的理论。

# 概要

在本章中,你学习了图形的分层语法,从美学和几何开始构建一个简单的图形,将图形分割成子集的分面,理解如何计算几何对象的统计数据,控制几何对象重叠时的精细位置调整,以及允许你从根本上改变 x 和 y 含义的坐标系。我们还未触及的一层是主题,在“主题”中将进行介绍。

获取完整的 ggplot2 功能概述的两个非常有用的资源是[ggplot2 速查表](https://oreil.ly/NlKZF)和[ggplot2 包网站](https://oreil.ly/W6ci8)。

从本章中你应该学到的一条重要教训是,当你感觉到需要一个 ggplot2 没有提供的几何图形时,最好查看是否有人已经通过创建一个 ggplot2 扩展包来解决你的问题。


# 第十章:探索性数据分析

# 介绍

本章将向你展示如何系统地使用可视化和转换来探索你的数据,这是统计学家称之为*探索性数据分析*或简称 EDA 的任务。EDA 是一个迭代循环。你:

1.  提出关于你的数据的问题。

1.  通过视觉化、转换和建模数据来寻找答案。

1.  利用你所学到的知识来完善你的问题和/或生成新问题。

EDA 不是一个具有严格规则集的正式过程。比任何其他东西都重要的是,EDA 是一种心态。在 EDA 的初始阶段,你应该随心所欲地调查每一个想法。其中一些想法会得到验证,而另一些则是死胡同。随着探索的继续,你将聚焦于几个特别有生产力的见解,最终会将它们写下并传达给其他人。

EDA 是任何数据分析的重要部分,即使主要的研究问题已经摆在你面前,因为你始终需要调查数据的质量。数据清洗只是 EDA 的一个应用:你要问关于你的数据是否符合期望的问题。要进行数据清洗,你需要使用 EDA 的所有工具:视觉化、转换和建模。

## 先决条件

在本章中,我们将结合你已学到的 dplyr 和 ggplot2,以互动方式提出问题、用数据回答问题,然后提出新问题。

library(tidyverse)


# 问题

> “没有例行统计问题,只有可疑的统计惯例。” —David Cox 爵士
> 
> “对正确问题的近似答案要好得多,即使这些问题通常模糊,也要好过对错误问题的精确答案,因为后者总是可以被明确化。” —John Tukey

在 EDA 过程中,你的目标是深入了解你的数据。做到这一点最简单的方法是将问题作为指导调查的工具。当你提出一个问题时,问题会集中你的注意力在数据集的特定部分,并帮助你决定制作哪些图表、模型或转换。

EDA 从根本上讲是一个创造性的过程。就像大多数创造性过程一样,提出*高质量*问题的关键在于生成大量的*问题*。在分析开始时很难提出具有启发性的问题,因为你不知道数据集中可以获取什么洞见。另一方面,每个新问题的提出都会让你接触数据的一个新方面,并增加你发现的机会。如果你根据所发现的内容提出新问题,你可以迅速深入探究数据中最有趣的部分,并形成一系列发人深省的问题。

没有关于应该问什么问题来引导你的研究的规则。然而,有两种类型的问题将始终有助于在你的数据中发现新发现。你可以笼统地用以下问题表达这些问题:

1.  我的变量内部存在哪种类型的变异?

1.  我的变量之间发生了什么类型的协变?

本章的其余部分将探讨这两个问题。我们将解释变异和协变是什么,并展示几种回答每个问题的方式。

# 变异

*变异* 是变量值在测量之间变化的倾向。您可以很容易地在现实生活中看到变异;如果您两次测量任何连续变量,您将得到两个不同的结果。即使您测量恒定的量,如光速,也是如此。每次测量都会包括一小部分从测量到测量变化的误差。变量还可以因为在不同主体(例如,不同人的眼睛颜色)或不同时间(例如,电子的能级在不同时刻)上测量而变化。每个变量都有其自己的变异模式,这可以揭示关于它如何在同一观察的测量之间以及跨观察之间变化的有趣信息。理解该模式的最佳方法是可视化变量值的分布,这是您在第一章中学到的。

我们将通过可视化约 54,000 颗钻石的重量(`carat`)分布来开始我们的探索,来自`diamonds`数据集。由于`carat`是一个数值变量,我们可以使用直方图:

ggplot(diamonds, aes(x = carat)) +
geom_histogram(binwidth = 0.5)


![一幅钻石克拉数的直方图,x 轴范围从 0 到 4.5,y 轴范围从 0 到 30000。分布右偏,中心在 0 的箱中几乎没有钻石,中心在 0.5 的箱中约有 30000 颗钻石,中心在 1 的箱中约有 15000 颗钻石,中心在 1.5 的箱中少得多,约有 5000 颗钻石。此外还有尾部延伸。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in01.png)

现在您可以可视化变异,您在图中应该寻找什么?以及您应该提出什么类型的后续问题?我们在下一节中列出了您将在图表中找到的最有用的信息类型及其相应的后续问题。提出良好后续问题的关键将是依靠您的好奇心(您想更多了解什么?)以及您的怀疑心(这可能是误导的方式?)。

## 典型值

在条形图和直方图中,高柱显示变量的常见值,而较短的柱显示不常见的值。没有柱的地方显示未在数据中看到的值。要将此信息转化为有用的问题,寻找任何意外的事物:

+   哪些值最常见?为什么?

+   哪些值是罕见的?为什么?这是否符合你的期望?

+   您能看到任何不寻常的模式吗?可能是什么原因?

让我们来看看较小钻石的`carat`分布:

smaller <- diamonds |>
filter(carat < 3)

ggplot(smaller, aes(x = carat)) +
geom_histogram(binwidth = 0.01)


![一幅钻石克拉数的直方图,x 轴范围从 0 到 3,y 轴大约从 0 到 2500。箱宽很窄(0.01),导致了大量细长的条。分布右偏,有许多峰值后面是高度递减的条,直到下一个峰值处急剧增加。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in02.png)

这幅直方图提出了几个有趣的问题:

+   为什么整数克拉和常见克拉分数的钻石较多?

+   为什么每个峰值右侧的钻石比左侧稍多?

可视化也可以显示出聚类,这表明数据中存在子群体。要理解这些子群体,可以问:

+   每个子群体内的观察结果如何相似?

+   分离聚类中的观察结果如何不同?

+   你如何解释或描述这些聚类?

+   为什么聚类的外观可能会误导?

其中一些问题可以通过数据回答,而一些则需要关于数据的领域专业知识。其中许多问题将促使你探索*变量之间*的关系,例如看看一个变量的值是否可以解释另一个变量的行为。我们很快就会讨论这个问题。

## 不寻常的值

离群值是不寻常的观察结果,换句话说,是不符合模式的数据点。有时离群值是数据输入错误,有时它们只是极端值在数据收集中被观察到,而有时则暗示重要的新发现。当你有大量数据时,在直方图中有时很难看到离群值。例如,看一下`diamonds`数据集中`y`变量的分布。唯一显示离群值的证据是 x 轴上异常宽的限制。

ggplot(diamonds, aes(x = y)) +
geom_histogram(binwidth = 0.5)


![一幅钻石长度的直方图,x 轴范围从 0 到 60,y 轴范围从 0 到 12000。在约为 5 的地方有一个峰值,数据完全聚集在峰值周围。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in03.png)

在常见箱中有很多观察结果,罕见箱非常短,使得很难看到它们(虽然也许如果你仔细盯着 0 看,你会发现一些东西)。为了便于看到不寻常的值,我们需要使用[`coord_cartesian()`](https://ggplot2.tidyverse.org/reference/coord_cartesian.xhtml)来缩小 y 轴的值:

ggplot(diamonds, aes(x = y)) +
geom_histogram(binwidth = 0.5) +
coord_cartesian(ylim = c(0, 50))


![一幅钻石长度的直方图,x 轴范围从 0 到 60,y 轴范围从 0 到 50。在约为 5 的地方有一个峰值,数据完全聚集在峰值周围。除了这些数据外,还有一个高约为 8 的 0 箱,一个略高于 30 高度约为 1 的箱,以及一个略低于 60 高度约为 1 的箱。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in04.png)

[`coord_cartesian()`](https://ggplot2.tidyverse.org/reference/coord_cartesian.xhtml) 还有一个 [`xlim()`](https://ggplot2.tidyverse.org/reference/lims.xhtml) 参数,用于缩放 x 轴。ggplot2 还有 [`xlim()`](https://ggplot2.tidyverse.org/reference/lims.xhtml) 和 [`ylim()`](https://ggplot2.tidyverse.org/reference/lims.xhtml) 函数,它们的工作方式略有不同:它们会丢弃超出限制范围的数据。

这使我们看到有三个异常值:0、约 30 和约 60。我们使用 dplyr 将它们挑出:

unusual <- diamonds |>
filter(y < 3 | y > 20) |>
select(price, x, y, z) |>
arrange(y)
unusual

> # A tibble: 9 × 4

> price x y z

>

> 1 5139 0 0 0

> 2 6381 0 0 0

> 3 12800 0 0 0

> 4 15686 0 0 0

> 5 18034 0 0 0

> 6 2130 0 0 0

> 7 2130 0 0 0

> 8 2075 5.15 31.8 5.12

> 9 12210 8.09 58.9 8.06


`y` 变量以毫米测量这些钻石的三个尺寸之一。我们知道钻石不能有 0 毫米的宽度,因此这些值必定是不正确的。通过探索数据分析,我们发现编码为 0 的缺失数据,这是我们仅仅搜索 `NA` 时无法发现的。未来,我们可能选择重新编码这些值为 `NA`,以防止误导性计算。我们还可能怀疑,32 毫米和 59 毫米的测量值是不可能的:这些钻石超过一英寸长,但价格不会成百上千美元!

反复进行分析,有无异常值。如果异常值对结果影响不大,并且您无法弄清楚它们存在的原因,则删除它们并继续是合理的。但是,如果它们对结果有重大影响,则不应在没有理由的情况下删除它们。您需要找出引起它们的原因(例如,数据输入错误),并在报告中披露您已删除它们。

## 练习

1.  探索 `diamonds` 中 `x`、`y` 和 `z` 变量的分布。您学到了什么?考虑一颗钻石,您可能如何确定长度、宽度和深度。

1.  探索 `price` 的分布。您发现了什么异常或令人惊讶的事情吗?(提示:仔细考虑 `binwidth` 并确保尝试一系列值。)

1.  有多少颗钻石是 0.99 克拉?有多少是 1 克拉?您认为造成这种差异的原因是什么?

1.  比较和对比 [`coord_cartesian()`](https://ggplot2.tidyverse.org/reference/coord_cartesian.xhtml) 和 [`xlim()`](https://ggplot2.tidyverse.org/reference/lims.xhtml) 或 [`ylim()`](https://ggplot2.tidyverse.org/reference/lims.xhtml) 在缩放直方图时的情况。如果不设置 `binwidth` 会发生什么?如果试图缩放以显示一半条柱子会发生什么?

# 异常值

如果您的数据集中出现异常值,并且只想继续分析剩余部分,则有两个选择:

1.  删除具有奇怪值的整行:

    ```
    diamonds2 <- diamonds |> 
      filter(between(y, 3, 20))
    ```

    我们不建议选择此选项,因为一个无效值并不意味着该观察的所有其他值也无效。此外,如果您的数据质量较低,当您将此方法应用于每个变量时,您可能会发现没有任何数据剩余!

1.  相反,我们建议将异常值替换为缺失值。最简单的方法是使用 [`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml) 来替换变量为修改后的副本。你可以使用 [`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml) 函数将异常值替换为 `NA`:

    ```
    diamonds2 <- diamonds |> 
      mutate(y = if_else(y < 3 | y > 20, NA, y))
    ```

不明显的是你应该在哪里绘制缺失值,因此 ggplot2 不会在图中包含它们,但会警告它们已被移除:

ggplot(diamonds2, aes(x = x, y = y)) +
geom_point()

> Warning: Removed 9 rows containing missing values (geom_point()).


![一张钻石宽度与长度的散点图。两个变量之间有强烈的线性关系。除了一个钻石外,其余的钻石长度均大于 3。这个异常值的长度为 0,宽度约为 6.5。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in05.png)

要消除该警告,请设置 `na.rm = TRUE`:

ggplot(diamonds2, aes(x = x, y = y)) +
geom_point(na.rm = TRUE)


有时候,你想了解带有缺失值的观测与带有记录值的观测有何不同。例如,在 [`nycflights13::flights`](https://rdrr.io/pkg/nycflights13/man/flights.xhtml)¹ 中,`dep_time` 变量中的缺失值表明航班被取消。因此,你可能想比较已取消和未取消航班的计划起飞时间。你可以通过创建一个新变量来实现此目的,使用 [`is.na()`](https://rdrr.io/r/base/NA.xhtml) 来检查 `dep_time` 是否缺失。

nycflights13::flights |>
mutate(
cancelled = is.na(dep_time),
sched_hour = sched_dep_time %/% 100,
sched_min = sched_dep_time %% 100,
sched_dep_time = sched_hour + (sched_min / 60)
) |>
ggplot(aes(x = sched_dep_time)) +
geom_freqpoly(aes(color = cancelled), binwidth = 1/4)


![航班计划起飞时间的频率多边形图。两条线分别代表取消和未取消的航班。x 轴范围从 0 到 25 分钟,y 轴范围从 0 到 10000。未取消航班的数量远远超过取消航班的数量。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in06.png)

然而,这个图表并不理想,因为未取消航班比取消航班要多得多。在下一节中,我们将探讨一些改进这种比较的技术。

## 练习

1.  缺失值在直方图中会发生什么?缺失值在条形图中会发生什么?为什么直方图和条形图在处理缺失值时会有差异?

1.  在 [`mean()`](https://rdrr.io/r/base/mean.xhtml) 和 [`sum()`](https://rdrr.io/r/base/sum.xhtml) 中,`na.rm = TRUE` 是什么作用?

1.  重新创建 `scheduled_dep_time` 的频率图,并根据航班是否取消进行着色。还要按 `cancelled` 变量进行分面。尝试在分面函数中使用不同的 `scales` 变量值,以减轻未取消航班比取消航班更多的影响。

# 协变

如果变异描述了变量内部的行为,那么协变描述了变量之间的行为。*协变* 是两个或多个变量值一起以相关方式变化的倾向。发现协变的最佳方法是可视化两个或多个变量之间的关系。

## 一个分类变量和一个数值变量

例如,让我们探索一下钻石价格如何随其品质(由 `cut` 衡量)变化,使用 [`geom_freqpoly()`](https://ggplot2.tidyverse.org/reference/geom_histogram.xhtml):

ggplot(diamonds, aes(x = price)) +
geom_freqpoly(aes(color = cut), binwidth = 500, linewidth = 0.75)


![一幅频率多边形图,显示了不同克拉切割(Fair、Good、Very Good、Premium 和 Ideal)的钻石价格。x 轴范围从 0 到 30000,y 轴范围从 0 到 5000。线条重叠较多,表明钻石价格频率分布相似。一个显著特点是 Ideal 钻石在约 1500 价格处有最高峰值。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in07.png)

注意,ggplot2 对 `cut` 使用有序颜色比例尺,因为它在数据中被定义为有序因子变量。您将在“有序因子”中了解更多相关内容。

在这里,默认的 [`geom_freqpoly()`](https://ggplot2.tidyverse.org/reference/geom_histogram.xhtml) 外观并不那么有用,因为高度由总计决定,在 `cut` 中差异很大,使得难以看出它们分布形状的差异。

为了更容易比较,我们需要交换显示在 y 轴上的内容。而不是显示计数,我们将显示 *density*,这是将每个频率多边形下面积标准化为 1 的计数:

ggplot(diamonds, aes(x = price, y = after_stat(density))) +
geom_freqpoly(aes(color = cut), binwidth = 500, linewidth = 0.75)


![一幅频率多边形图,显示了不同克拉切割(Fair、Good、Very Good、Premium 和 Ideal)的钻石价格密度。x 轴范围从 0 到 20000。线条重叠较多,表明钻石价格密度分布相似。一个显著特点是除了 Fair 钻石外,其他钻石在价格约为 1500 时有很高的峰值,而 Fair 钻石的平均价格高于其他品质。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in08.png)

请注意,我们将 `density` 映射到 `y` 轴,但由于 `density` 不是 `diamonds` 数据集中的变量,我们需要先计算它。我们使用 [`after_stat()`](https://ggplot2.tidyverse.org/reference/aes_eval.xhtml) 函数来完成这一点。

对于这幅图有一点令人惊讶:似乎低质量的 Fair 钻石拥有最高的平均价格!但也许这是因为频率多边形图有点难以解释;这幅图中有很多信息。

使用并排箱线图探索这种关系的可视化简化图:

ggplot(diamonds, aes(x = cut, y = price)) +
geom_boxplot()


![并排箱线图,显示了不同切割的钻石价格。每个切割的价格分布都呈右偏态。中位数相互接近,Ideal 钻石的中位数最低,Fair 钻石的最高。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in09.png)

我们关于分布的信息要少得多,但是箱线图更为紧凑,因此我们可以更轻松地比较它们(并且在一幅图上容纳更多)。这支持了一个反直觉的发现,即更高质量的钻石通常更便宜!在练习中,您将被挑战找出其中原因。

`cut`是一个有序因子:fair 比 good 差,good 比 very good 差,依此类推。许多分类变量没有这种固有顺序,因此您可能希望重新排序它们以获得更具信息量的显示。一种方法是使用[`fct_reorder()`](https://forcats.tidyverse.org/reference/fct_reorder.xhtml)。您将在“修改因子顺序”中了解更多关于该函数的信息,但我们在这里提前为您展示它是如此有用。例如,考虑`mpg`数据集中的`class`变量。您可能想知道不同类别的汽车在公路里程上的变化:

ggplot(mpg, aes(x = class, y = hwy)) +
geom_boxplot()


![不同类别汽车的公路里程的并列箱线图。类别在 x 轴上(两座车、紧凑型车、中型车、面包车、皮卡车、小型车和 SUV)。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in10.png)

为了更容易看到趋势,我们可以基于`hwy`的中位数值重新排序`class`:

ggplot(mpg, aes(x = fct_reorder(class, hwy, median), y = hwy)) +
geom_boxplot()


![不同类别汽车的公路里程的并列箱线图。类别在 x 轴上,按中位数公路里程递增排序(皮卡车、SUV、面包车、两座车、小型车、紧凑型车和中型车)。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in11.png)

如果您有较长的变量名称,[`geom_boxplot()`](https://ggplot2.tidyverse.org/reference/geom_boxplot.xhtml)在将其翻转 90°后将表现得更好。您可以通过交换 x 和 y 的美学映射来实现:

ggplot(mpg, aes(x = hwy, y = fct_reorder(class, hwy, median))) +
geom_boxplot()


![不同类别汽车的公路里程的并列箱线图。类别在 y 轴上,按中位数公路里程递增排序。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in12.png)

### 练习

1.  利用所学知识改进取消与未取消航班的起飞时间可视化。

1.  基于探索性数据分析,钻石数据集中哪个变量似乎对预测钻石价格最重要?该变量与切割质量的相关性如何?为什么这两种关系的结合导致较低质量的钻石更昂贵?

1.  与交换变量相比,将[`coord_flip()`](https://ggplot2.tidyverse.org/reference/coord_flip.xhtml)作为垂直箱线图的新图层添加以创建水平箱线图。这与交换变量有何不同?

1.  箱线图的一个问题是它们是在数据集较小的时代开发的,往往显示出过多的“异常值”。解决这个问题的一种方法是使用字母值图。安装 lvplot 包,并尝试使用`geom_lv()`来显示价格与切割质量的分布。您从中学到了什么?如何解释这些图形?

1.  使用[`geom_violin()`](https://ggplot2.tidyverse.org/reference/geom_violin.xhtml)创建钻石价格与`diamonds`数据集中的分类变量的可视化,然后使用分面[`geom_histogram()`](https://ggplot2.tidyverse.org/reference/geom_histogram.xhtml),接着使用着色的[`geom_freqpoly()`](https://ggplot2.tidyverse.org/reference/geom_histogram.xhtml),最后使用着色的[`geom_density()`](https://ggplot2.tidyverse.org/reference/geom_density.xhtml)。比较和对比这四种可视化数值变量分布的方法。基于分类变量级别的优缺点是什么?

1.  如果数据集较小,有时使用[`geom_jitter()`](https://ggplot2.tidyverse.org/reference/geom_jitter.xhtml)可以避免过度绘制,更容易看到连续变量与分类变量之间的关系。ggbeeswarm 包提供了几种类似于[`geom_jitter()`](https://ggplot2.tidyverse.org/reference/geom_jitter.xhtml)的方法。列出它们并简要描述每种方法的作用。

## 两个分类变量

要可视化分类变量之间的协变量关系,您需要计算每个分类变量级别组合的观察次数。一种方法是依赖内置的[`geom_count()`](https://ggplot2.tidyverse.org/reference/geom_count.xhtml):

ggplot(diamonds, aes(x = cut, y = color)) +
geom_count()


![钻石颜色与切割的散点图。每个点表示切割(Fair,Good,Very Good,Premium 和 Ideal)和颜色(D,E,F,G,G,I 和 J)级别的组合中的一个,点的大小表示该组合的观察次数。图例显示这些点的大小范围在 1000 到 4000 之间。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in13.png)

图中每个圆圈的大小显示了每个值组合的观察次数。协变量将显示为特定 x 值和特定 y 值之间的强相关性。

另一种探索这些变量之间关系的方法是使用 dplyr 计算计数:

diamonds |>
count(color, cut)

> # A tibble: 35 × 3

> color cut n

>

> 1 D Fair 163

> 2 D Good 662

> 3 D Very Good 1513

> 4 D Premium 1603

> 5 D Ideal 2834

> 6 E Fair 224

> # … with 29 more rows


然后使用[`geom_tile()`](https://ggplot2.tidyverse.org/reference/geom_tile.xhtml)和填充美学:

diamonds |>
count(color, cut) |>
ggplot(aes(x = color, y = cut)) +
geom_tile(aes(fill = n))


![钻石切割与颜色的瓷砖图。每个瓷砖表示一个切割/颜色组合,并根据每个瓷砖中的观察数量进行着色。Ideal 钻石比其他切割更多,其中最多的是颜色为 G 的 Ideal 钻石。Fair 钻石和颜色为 I 的钻石出现频率最低。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in14.png)

如果分类变量无序,可能需要使用 seriation 包同时重新排序行和列,以更清晰地显示有趣的模式。对于较大的图表,您可以尝试使用 heatmaply 包,该包创建交互式图表。

### 练习

1.  如何重新调整先前的计数数据集以更清晰地显示切割在颜色内部的分布或颜色在切割内部的分布?

1.  如果将颜色映射到`x`美学,将`cut`映射到`fill`美学,分段条形图可以为您提供不同的数据洞见?计算落入每个段的计数。(在这一部分,我们将使用“smaller”数据集,专注于克拉数小于 3 的大部分钻石。)

1.  使用 [`geom_tile()`](https://ggplot2.tidyverse.org/reference/geom_tile.xhtml) 结合 dplyr 探索飞行延误平均时间如何随目的地和年份的月份变化。什么使得绘图难以阅读?您如何改进它?

## 两个数值变量

您已经看到了一个很好的方法来可视化两个数值变量之间的协变关系:使用 [`geom_point()`](https://ggplot2.tidyverse.org/reference/geom_point.xhtml) 绘制散点图。您可以将协变关系视为点的模式。例如,您可以看到钻石的克拉数与价格之间的正相关关系:克拉数更大的钻石价格更高。这种关系是指数的。

ggplot(smaller, aes(x = carat, y = price)) +
geom_point()


![一个价格与克拉数的散点图。关系为正向,较为强烈且呈指数形态。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in15.png)

(在这一部分,我们将使用`smaller`数据集,专注于克拉数小于 3 的大部分钻石。)

随着数据集的规模增长,散点图在数据重叠和堆积方面变得不那么有用,因为点开始堆积成均匀黑色的区域,这使得难以判断数据在二维空间中密度的差异,也难以发现趋势。您已经看到解决这个问题的一种方法:使用`alpha`美学添加透明度。

ggplot(smaller, aes(x = carat, y = price)) +
geom_point(alpha = 1 / 100)


![一个价格与克拉数的散点图。关系为正向,较为强烈且呈指数形态。点是透明的,显示了点数较高的簇集,最明显的簇集是克拉数为 1、1.5 和 2 的钻石。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in16.png)

但是对于非常大的数据集,使用透明度可能会很具挑战性。另一种解决方案是使用网格。之前您使用了[`geom_histogram()`](https://ggplot2.tidyverse.org/reference/geom_histogram.xhtml) 和 [`geom_freqpoly()`](https://ggplot2.tidyverse.org/reference/geom_histogram.xhtml) 在一个维度上进行网格化。现在您将学习如何使用 [`geom_bin2d()`](https://ggplot2.tidyverse.org/reference/geom_bin_2d.xhtml) 和 [`geom_hex()`](https://ggplot2.tidyverse.org/reference/geom_hex.xhtml) 在两个维度上进行网格化。

[`geom_bin2d()`](https://ggplot2.tidyverse.org/reference/geom_bin_2d.xhtml) 和 [`geom_hex()`](https://ggplot2.tidyverse.org/reference/geom_hex.xhtml) 将坐标平面分成 2D 网格,并使用填充颜色显示每个网格中有多少个点。[`geom_bin2d()`](https://ggplot2.tidyverse.org/reference/geom_bin_2d.xhtml) 创建矩形网格。[`geom_hex()`](https://ggplot2.tidyverse.org/reference/geom_hex.xhtml) 创建六角形网格。您需要安装 hexbin 包来使用 [`geom_hex()`](https://ggplot2.tidyverse.org/reference/geom_hex.xhtml)。

ggplot(smaller, aes(x = carat, y = price)) +
geom_bin2d()

install.packages("hexbin")

ggplot(smaller, aes(x = carat, y = price)) +
geom_hex()


![图 1:价格与克拉的分箱密度图。图 2:价格与克拉的六边形图。这两个图显示,密度最高的钻石克拉和价格都较低。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in17.png)

另一个选项是将一个连续变量分箱,使其像分类变量一样运行。然后,您可以使用您学到的技术之一来可视化分类和连续变量的组合。例如,您可以对`克拉`进行分箱,然后为每个组显示一个箱线图:

ggplot(smaller, aes(x = carat, y = price)) +
geom_boxplot(aes(group = cut_width(carat, 0.1)))


![按克拉分组的并列箱形图。每个箱形图代表相隔`0.1`克拉的钻石的价格。箱形图显示,随着克拉数的增加,价格中位数也增加。此外,克拉数为`1.5`或更低的钻石价格分布右偏,`1.5`到`2`的价格分布大致对称,而重量更大的钻石价格分布左偏。更便宜、更小的钻石在高端有异常值,更昂贵、更大的钻石在低端有异常值。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in18.png)

`cut_width(x, width)`,如此使用,将`x`分为宽度为`width`的箱子。默认情况下,除了异常值的数量不同外,箱线图看起来大致相同,因此很难告诉每个箱线图总结了不同数量的点。显示这一点的一种方法是使箱线图的宽度与点数成比例,使用`varwidth = TRUE`。

### 练习

1.  不使用箱线图总结条件分布,而是使用频率多边形。使用[`cut_width()`](https://ggplot2.tidyverse.org/reference/cut_interval.xhtml)与[`cut_number()`](https://ggplot2.tidyverse.org/reference/cut_interval.xhtml)时需要考虑什么?这如何影响`克拉`和`价格`的二维分布可视化?

1.  可视化按`价格`分区的`克拉`分布。

1.  很大钻石的价格分布与小钻石相比如何?是否符合您的预期,还是让您感到惊讶?

1.  结合你学到的两种技术,可视化切割、克拉和价格的联合分布。

1.  二维图表揭示了在一维图表中看不到的异常值。例如,以下图中的一些点具有不寻常的`x`和`y`值组合,这使得这些点在单独检查`x`和`y`值时看起来正常,但在散点图中则成为异常值。为什么散点图对于这种情况比分箱图更好?

    ```
    diamonds |> 
      filter(x >= 4) |> 
      ggplot(aes(x = x, y = y)) +
      geom_point() +
      coord_cartesian(xlim = c(4, 11), ylim = c(4, 11))
    ```

1.  不使用[`cut_width()`](https://ggplot2.tidyverse.org/reference/cut_interval.xhtml)创建等宽的箱子,而是使用[`cut_number()`](https://ggplot2.tidyverse.org/reference/cut_interval.xhtml)创建大致包含相同数量点的箱子。这种方法的优缺点是什么?

    ```
    ggplot(smaller, aes(x = carat, y = price)) + 
      geom_boxplot(aes(group = cut_number(carat, 20)))
    ```

# 模式和模型

如果两个变量之间存在系统关系,它将在数据中显示为一种模式。如果您发现了一个模式,请问自己:

+   这种模式可能是巧合造成的吗(即随机事件)?

+   您如何描述由模式暗示的关系?

+   这种模式所暗示的关系有多强?

+   其他变量可能会影响这种关系吗?

+   如果您查看数据的各个子组,关系是否会发生变化?

您数据中的模式提供了有关关系的线索;即,它们显示协变。如果您将变异视为创建不确定性的现象,协变则是减少不确定性的现象。如果两个变量协变,您可以使用一个变量的值来更好地预测第二个变量的值。如果协变是由因果关系引起的(一种特殊情况),则可以使用一个变量的值来控制第二个变量的值。

模型是从数据中提取模式的工具。例如,考虑钻石数据。要理解切割和价格之间的关系很困难,因为切割与克拉、克拉与价格之间紧密相关。可以使用模型去除价格和克拉之间非常强的关系,以探索剩余的微妙差异。以下代码拟合一个模型,预测`price`基于`carat`,然后计算残差(预测值与实际值之间的差异)。残差使我们能够看到钻石的价格,一旦克拉的影响被消除。请注意,我们不使用`price`和`carat`的原始值,而是先对它们进行对数变换,然后拟合对数变换后的值的模型。然后,我们将残差指数化,以将它们放回到原始价格的尺度。

library(tidymodels)

diamonds <- diamonds |>
mutate(
log_price = log(price),
log_carat = log(carat)
)

diamonds_fit <- linear_reg() |>
fit(log_price ~ log_carat, data = diamonds)

diamonds_aug <- augment(diamonds_fit, new_data = diamonds) |>
mutate(.resid = exp(.resid))

ggplot(diamonds_aug, aes(x = carat, y = .resid)) +
geom_point()


![钻石残差与克拉的散点图。 x 轴范围从 0 到 5,y 轴范围从 0 到接近 4。大部分数据聚集在低克拉和残差的低值周围。有一个明显的曲线模式,显示随着克拉增加残差减少。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in19.png)

一旦消除了克拉与价格之间的强关系,您可以看到切割与价格之间的预期关系:相对于它们的大小,更高质量的钻石更贵。

ggplot(diamonds_aug, aes(x = cut, y = .resid)) +
geom_boxplot()


![残差的并列箱线图。 x 轴显示各种切割(从优到良),y 轴范围从 0 到接近 5。中位数非常相似,大约在 0.75 到 1.25 之间。每个残差分布都是右偏的,高端有许多异常值。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_10in20.png)

我们不在本书中讨论建模,因为理解模型及其工作方式最容易是在掌握了数据整理和编程工具之后。

# 概要

在本章中,你学习了各种工具,帮助你理解数据中的变异性。你看到了一种每次只处理一个变量或一对变量的技术。如果你的数据中有数十个甚至数百个变量,这可能看起来非常受限,但它们是所有其他技术构建的基础。

在下一章中,我们将专注于可以用来传达我们结果的工具。

¹ 请记住,当我们需要明确函数(或数据集)来自何处时,我们将使用特殊形式 `package::function()` 或 `package::dataset`。


# 第十一章:传达

# 简介

在第十章中,您学习了如何将图表用作*探索*工具。在制作探索性图表时,您甚至在查看之前就知道图表将显示哪些变量。您为每个图表设定了特定目的,可以快速查看它,然后转移到下一个图表。在大多数分析过程中,您将生成数十甚至数百个图表,其中大多数立即被丢弃。

现在您了解了您的数据,需要*传达*给其他人您的理解。您的受众可能不会共享您的背景知识,也不会对数据深感兴趣。为了帮助他人快速建立起对数据的良好心理模型,您需要付出相当大的努力,使您的图形尽可能自解释。在本章中,您将学习到 ggplot2 提供的一些工具来实现这一点。

本章重点介绍创建优质图形所需的工具。我们假设您知道自己想要什么,只需知道如何做。因此,我们强烈建议将本章与一本优秀的通用可视化书籍配对。我们特别推荐阅读[*真实的艺术*](https://oreil.ly/QIr_w),作者是阿尔伯特·开罗(New Riders)。它不会教授创建可视化图形的机制,而是专注于创建有效图形时需要考虑的内容。

## 先决条件

在本章中,我们再次专注于 ggplot2。我们还会使用一些 dplyr 进行数据操作;*scales*用于覆盖默认的间断、标签、转换和调色板;以及一些 ggplot2 扩展包,包括由卡米尔·斯洛维科夫斯基(Kamil Slowikowski)开发的[ggrepel](https://oreil.ly/IVSL4)和由托马斯·林·佩德森(Thomas Lin Pedersen)开发的[patchwork](https://oreil.ly/xWxVV)。不要忘记,如果您尚未安装这些包,您需要使用[`install.packages()`](https://rdrr.io/r/utils/install.packages.xhtml)来安装它们。

library(tidyverse)
library(scales)
library(ggrepel)
library(patchwork)


# 标签

将探索性图形转换为解释性图形的最简单方法是添加良好的标签。您可以使用[`labs()`](https://ggplot2.tidyverse.org/reference/labs.xhtml)函数添加标签:

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = class)) +
geom_smooth(se = FALSE) +
labs(
x = "Engine displacement (L)",
y = "Highway fuel economy (mpg)",
color = "Car type",
title = "Fuel efficiency generally decreases with engine size",
subtitle = "Two seaters (sports cars) are an exception because of their light weight",
caption = "Data from fueleconomy.gov"
)


![汽车引擎大小与高速公路燃油效率散点图,点根据汽车类别着色。覆盖着沿着引擎大小与燃油效率关系轨迹的平滑曲线。x 轴标签为“引擎排量(升)”,y 轴标签为“高速公路燃油经济性(mpg)”。图例标签为“汽车类型”。标题为“燃油效率通常随引擎大小减少”。副标题为“两座(跑车)是由于其轻量化而例外”。图表来自 fueleconomy.gov。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in01.png)

图表标题的目的是总结主要发现。避免仅描述图表内容的标题,例如“引擎排量与燃油经济性的散点图”。

如果您需要添加更多文本,还有两个有用的标签:`subtitle` 在标题下方以较小的字体添加额外细节,`caption` 则在图的右下角添加文本,通常用于描述数据来源。您还可以使用 [`labs()`](https://ggplot2.tidyverse.org/reference/labs.xhtml) 替换坐标轴和图例标题。通常建议用更详细的描述替换简短的变量名,并包含单位。

可以使用数学方程替代文本字符串。只需将 `""` 替换为 [`quote()`](https://rdrr.io/r/base/substitute.xhtml),并在 [`?plotmath`](https://rdrr.io/r/grDevices/plotmath.xhtml) 中了解可用选项:

df <- tibble(
x = 1:10,
y = cumsum(x²)
)

ggplot(df, aes(x, y)) +
geom_point() +
labs(
x = quote(x[i]),
y = quote(sum(x[i] ^ 2, i == 1, n))
)


![在 x 和 y 轴标签上带有数学文本的散点图。X 轴标签为 x_i,Y 轴标签为 x_i 的平方和,其中 i 从 1 到 n。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in02.png)

## 练习

1.  在燃油经济数据上创建一个图,自定义 `title`、`subtitle`、`caption`、`x`、`y` 和 `color` 标签。

1.  使用燃油经济数据重新创建以下图。请注意,点的颜色和形状都因驱动类型而异。

    ![公路与城市燃油效率的散点图。点的形状和颜色由驱动类型确定。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in03.png)

1.  取出您在上个月创建的探索性图形,并添加信息性标题,以便他人更容易理解。

# 注释

除了标记绘图的主要组件外,通常还有必要标记单个观察值或观察值组。您可以使用 [`geom_text()`](https://ggplot2.tidyverse.org/reference/geom_text.xhtml)。[`geom_text()`](https://ggplot2.tidyverse.org/reference/geom_text.xhtml) 类似于 [`geom_point()`](https://ggplot2.tidyverse.org/reference/geom_point.xhtml),但具有额外的美学:`label`。这使得可以向绘图添加文本标签。

标签有两种可能的来源。首先,您可能有一个提供标签的 tibble。在以下图中,我们提取每种驱动类型中引擎尺寸最大的汽车,并将其信息保存为一个名为 `label_info` 的新数据框:

label_info <- mpg |>
group_by(drv) |>
arrange(desc(displ)) |>
slice_head(n = 1) |>
mutate(
drive_type = case_when(
drv == "f" ~ "front-wheel drive",
drv == "r" ~ "rear-wheel drive",
drv == "4" ~ "4-wheel drive"
)
) |>
select(displ, hwy, drv, drive_type)

label_info

> # A tibble: 3 × 4

> # Groups: drv [3]

> displ hwy drv drive_type

>

> 1 6.5 17 4 4-wheel drive

> 2 5.3 25 f front-wheel drive

> 3 7 24 r rear-wheel drive


然后,我们使用这个新数据框直接标记三个组,以取代图例并直接放置在图上。使用 `fontface` 和 `size` 参数可以自定义文本标签的外观。它们比图上其余文本更大并加粗。(`theme(legend.position = "none"`) 关闭所有图例 — 我们将很快讨论更多内容。)

ggplot(mpg, aes(x = displ, y = hwy, color = drv)) +
geom_point(alpha = 0.3) +
geom_smooth(se = FALSE) +
geom_text(
data = label_info,
aes(x = displ, y = hwy, label = drive_type),
fontface = "bold", size = 5, hjust = "right", vjust = "bottom"
) +
theme(legend.position = "none")

> geom_smooth() using method = 'loess' and formula = 'y ~ x'


![公路里程与引擎尺寸的散点图,点的颜色由驱动类型确定。每种驱动类型的平滑曲线叠加在一起。文本标签标识曲线为前轮驱动、后轮驱动和四轮驱动。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in04.png)

注意使用 `hjust`(水平对齐)和 `vjust`(垂直对齐)来控制标签的对齐方式。

然而,我们刚刚制作的带注释的图表很难阅读,因为标签彼此重叠且与点重叠。我们可以使用 ggrepel 包中的[`geom_label_repel()`](https://rdrr.io/pkg/ggrepel/man/geom_text_repel.xhtml)函数来解决这两个问题。这个有用的包会自动调整标签,以避免重叠:

ggplot(mpg, aes(x = displ, y = hwy, color = drv)) +
geom_point(alpha = 0.3) +
geom_smooth(se = FALSE) +
geom_label_repel(
data = label_info,
aes(x = displ, y = hwy, label = drive_type),
fontface = "bold", size = 5, nudge_y = 2
) +
theme(legend.position = "none")

> geom_smooth() using method = 'loess' and formula = 'y ~ x'


![汽车引擎尺寸与公路燃油效率的散点图,根据车辆类别着色。某些点带有车辆名称的标签。标签为白色透明背景的框,位置不重叠。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in05.png)

您还可以使用 ggrepel 包中的[`geom_text_repel()`](https://rdrr.io/pkg/ggrepel/man/geom_text_repel.xhtml)来突出显示图表上的某些点。注意这里使用的另一个有用技巧:我们添加了第二层大空心点以进一步突出标记点。

potential_outliers <- mpg |>
filter(hwy > 40 | (hwy > 20 & displ > 5))

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
geom_text_repel(data = potential_outliers, aes(label = model)) +
geom_point(data = potential_outliers, color = "red") +
geom_point(
data = potential_outliers,
color = "red", size = 3, shape = "circle open"
)


![汽车公路燃油效率与引擎尺寸的散点图。高速公路里程超过 40 和引擎尺寸超过 5 的点为红色,空心红圆圈,并带有车型名称的标签。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in06.png)

请记住,除了[`geom_text()`](https://ggplot2.tidyverse.org/reference/geom_text.xhtml)和[`geom_label()`](https://ggplot2.tidyverse.org/reference/geom_text.xhtml),ggplot2 中还有许多其他 geoms 可用于帮助注释您的图表。一些想法:

+   使用[`geom_hline()`](https://ggplot2.tidyverse.org/reference/geom_abline.xhtml)和[`geom_vline()`](https://ggplot2.tidyverse.org/reference/geom_abline.xhtml)添加参考线。通常我们会将它们设置为粗线(`linewidth = 2`),白色(`color = white`),并在主数据层下绘制。这样可以使它们易于看见,而不会分散数据的注意力。

+   使用[`geom_rect()`](https://ggplot2.tidyverse.org/reference/geom_tile.xhtml)绘制围绕感兴趣点的矩形。矩形的边界由美学`xmin`、`xmax`、`ymin`和`ymax`定义。或者,可以查看[ggforce 包](https://oreil.ly/DZtL1),特别是[`geom_mark_hull()`](https://ggforce.data-imaginist.com/reference/geom_mark_hull.xhtml),它允许您使用凸包标注子集点。

+   使用[`geom_segment()`](https://ggplot2.tidyverse.org/reference/geom_segment.xhtml)和`arrow`参数来用箭头突出显示一个点。使用美学`x`和`y`定义起始位置,使用`xend`和`yend`定义结束位置。

添加注释到图表的另一个便捷函数是[`annotate()`](https://ggplot2.tidyverse.org/reference/annotate.xhtml)。一般而言,geoms 通常用于突出显示数据的子集,而[`annotate()`](https://ggplot2.tidyverse.org/reference/annotate.xhtml)则用于向图表添加一个或几个注释元素。

为了演示如何使用[`annotate()`](https://ggplot2.tidyverse.org/reference/annotate.xhtml),让我们创建一些文本添加到我们的绘图中。文本有点长,所以我们将使用[`stringr::str_wrap()`](https://stringr.tidyverse.org/reference/str_wrap.xhtml)根据每行字符数自动添加换行符:

trend_text <- "Larger engine sizes tend to\nhave lower fuel economy." |>
str_wrap(width = 30)
trend_text

> [1] "Larger engine sizes tend to\nhave lower fuel economy."


然后,我们添加两层注释:一个使用标签 geom,另一个使用段 geom。两者中的`x`和`y`美学定义了注释的起始位置,而段注释中的`xend`和`yend`美学定义了段的结束位置。还请注意,该段被设计成箭头样式。

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
annotate(
geom = "label", x = 3.5, y = 38,
label = trend_text,
hjust = "left", color = "red"
) +
annotate(
geom = "segment",
x = 3, y = 35, xend = 5, yend = 25, color = "red",
arrow = arrow(type = "closed")
)


![汽车的公路燃油效率与发动机大小的散点图。一只红色箭头向下沿着点的趋势,旁边的注释读到“较大的发动机尺寸通常具有较低的燃油经济性”。箭头和注释文字都是红色的。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in07.png)

注释是传达可视化主要观点和有趣特征的强大工具。唯一的限制是你的想象力(以及调整注释位置以美观地呈现的耐心)!

## 练习

1.  使用[`geom_text()`](https://ggplot2.tidyverse.org/reference/geom_text.xhtml)将文本放置在绘图的四个角落。

1.  使用[`annotate()`](https://ggplot2.tidyverse.org/reference/annotate.xhtml)在不需要创建 tibble 的情况下在最后一个绘图中添加一个点 geom。定制点的形状、大小或颜色。

1.  如何使用[`geom_text()`](https://ggplot2.tidyverse.org/reference/geom_text.xhtml)标签与分面互动?如何在单个分面中添加标签?如何在每个分面中放置不同的标签?(提示:考虑传递给[`geom_text()`](https://ggplot2.tidyverse.org/reference/geom_text.xhtml)的数据集。)

1.  控制背景框外观的[`geom_label()`](https://ggplot2.tidyverse.org/reference/geom_text.xhtml)的参数是什么?

1.  [`arrow()`](https://rdrr.io/r/grid/arrow.xhtml)的四个参数是什么?它们如何工作?创建一系列示例图来演示最重要的选项。

# 比例尺

第三种使图表更适合传达信息的方法是调整比例尺。比例尺控制美学映射在视觉上的表现方式。

## 默认比例尺

通常情况下,ggplot2 会自动为您添加比例尺。例如,当您输入:

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = class))


ggplot2 在幕后自动添加默认比例尺:

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = class)) +
scale_x_continuous() +
scale_y_continuous() +
scale_color_discrete()


注意比例尺的命名方案:以`scale_`开头,然后是美学名称,接着是`_`,最后是比例尺名称。默认的比例尺根据其对齐的变量类型命名:连续型、离散型、日期时间型或日期型。[`scale_x_continuous()`](https://ggplot2.tidyverse.org/reference/scale_continuous.xhtml) 将`displ`的数值放置在 x 轴的连续数线上,[`scale_color_discrete()`](https://ggplot2.tidyverse.org/reference/scale_colour_discrete.xhtml) 为每个汽车`class`选择颜色等。还有许多非默认比例尺,接下来你将了解更多。

默认比例尺已经经过精心选择,以便为广泛的输入提供良好的效果。然而,由于两个原因你可能想要覆盖默认设置:

+   你可能想调整默认比例尺的一些参数。这使你可以做一些改变,比如更改轴上的刻度间断点或图例上的键标签。

+   你可能想完全替换比例尺并使用完全不同的算法。通常你可以优于默认设置,因为你对数据了解更多。

## 轴刻度和图例键

轴和图例总称为*guides*。轴用于`x`和`y`美学;图例用于其他所有内容。

有两个主要参数影响轴上刻度和图例上的键的外观:`breaks`和`labels`。`breaks`参数控制刻度的位置或与键相关联的值。`labels`参数控制与每个刻度/键关联的文本标签。最常见的`breaks`用法是覆盖默认选择:

ggplot(mpg, aes(x = displ, y = hwy, color = drv)) +
geom_point() +
scale_y_continuous(breaks = seq(15, 40, by = 5))


![汽车高速公路燃油效率与发动机尺寸的散点图,按驱动方式着色。y 轴从 15 开始到 40,每 5 增加一个间断点。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in08.png)

你可以以相同方式使用`labels`(一个与`breaks`长度相同的字符向量),但也可以将其设置为`NULL`以完全禁止标签。这对于地图或发布图表(不能共享绝对数字)非常有用。你还可以使用`breaks`和`labels`来控制图例的外观。对于分类变量的离散比例尺,`labels`可以是现有级别名称和所需标签的命名列表。

ggplot(mpg, aes(x = displ, y = hwy, color = drv)) +
geom_point() +
scale_x_continuous(labels = NULL) +
scale_y_continuous(labels = NULL) +
scale_color_discrete(labels = c("4" = "4-wheel", "f" = "front", "r" = "rear"))


![汽车高速公路燃油效率与发动机尺寸的散点图,按驱动方式着色。x 轴和 y 轴没有任何标签在轴刻度上。图例具有自定义标签:4 轮驱动、前驱动、后驱动。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in09.png)

`labels`参数与 scales 包中的标签函数结合使用,用于将数字格式化为货币、百分比等。左侧图显示了使用[`label_dollar()`](https://scales.r-lib.org/reference/label_dollar.xhtml)的默认标签,它添加了美元符号和千位分隔符逗号。右侧图通过将美元值除以 1,000 并添加后缀“K”(表示“千”),以及添加自定义刻度线进一步定制。请注意,`breaks`在原始数据的比例尺上。

Left

ggplot(diamonds, aes(x = price, y = cut)) +
geom_boxplot(alpha = 0.05) +
scale_x_continuous(labels = label_dollar())

Right

ggplot(diamonds, aes(x = price, y = cut)) +
geom_boxplot(alpha = 0.05) +
scale_x_continuous(
labels = label_dollar(scale = 1/1000, suffix = "K"),
breaks = seq(1000, 19000, by = 6000)
)


![钻石价格与切割方式的并排箱线图。异常值为透明。两个图的 x 轴标签格式为美元。左侧图的 x 轴标签从$0 到$15,000,每增加$5,000。右侧图的 x 轴标签从$1K 到$19K,每增加$6K。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in10.png)

另一个方便的标签函数是[`label_percent()`](https://scales.r-lib.org/reference/label_percent.xhtml):

ggplot(diamonds, aes(x = cut, fill = clarity)) +
geom_bar(position = "fill") +
scale_y_continuous(name = "Percentage", labels = label_percent())


![根据清晰度水平划分的分段条形图。y 轴标签从 0%到 100%,每增加 25%。y 轴标签名为“百分比”。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in11.png)

当您的数据点较少且希望准确突出观察发生的位置时,`breaks`的另一个用途是。例如,以下图显示了每位美国总统任职的开始和结束时间:

presidential |>
mutate(id = 33 + row_number()) |>
ggplot(aes(x = start, y = id)) +
geom_point() +
geom_segment(aes(xend = end, yend = id)) +
scale_x_date(name = NULL, breaks = presidential$start, date_labels = "'%y")


![总统编号与他们开始任期的年份的折线图。起始年份用点标记,从那里开始到任期结束。x 轴标签格式为两位数年份,以撇号开头,例如,'53。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in12.png)

注意,对于`breaks`参数,我们将`start`变量作为一个向量从`presidential$start`中提取出来,因为我们无法对此参数进行美学映射。还要注意,日期和日期时间刻度的断点和标签规范略有不同:

+   `date_labels`采用与[`parse_datetime()`](https://readr.tidyverse.org/reference/parse_datetime.xhtml)相同格式规范。

+   `date_breaks`(此处未显示)接受像“2 days”或“1 month”这样的字符串。

## 图例布局

您最常使用`breaks`和`labels`来调整坐标轴。虽然它们也适用于图例,但您更有可能使用一些其他技术。

要控制图例的整体位置,需要使用[`theme()`](https://ggplot2.tidyverse.org/reference/theme.xhtml)设置。我们会在本章末尾回到主题,但简而言之,它们控制绘图的非数据部分。主题设置`legend.position`控制图例绘制位置:

base <- ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = class))

base + theme(legend.position = "right") # the default
base + theme(legend.position = "left")
base +
theme(legend.position = "top") +
guides(col = guide_legend(nrow = 3))
base +
theme(legend.position = "bottom") +
guides(col = guide_legend(nrow = 3))


![四个汽车发动机尺寸与公路燃油效率的散点图,点按车辆类别着色。顺时针,图例分别放置在右侧、左侧、顶部和底部。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in13.png)

如果您的图形短而宽,请将图例放置在顶部或底部;如果图形高而窄,请将图例放置在左侧或右侧。您还可以使用`legend.position = "none"`来完全隐藏图例的显示。

控制单个图例的显示,请使用[`guides()`](https://ggplot2.tidyverse.org/reference/guides.xhtml),结合[`guide_legend()`](https://ggplot2.tidyverse.org/reference/guide_legend.xhtml)或[`guide_colorbar()`](https://ggplot2.tidyverse.org/reference/guide_colourbar.xhtml)。以下示例展示了两个重要设置:使用`nrow`控制图例使用的行数,以及覆盖一个美学元素使点变大。如果在图中使用了低`alpha`显示多个点,这尤其有用。

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = class)) +
geom_smooth(se = FALSE) +
theme(legend.position = "bottom") +
guides(color = guide_legend(nrow = 2, override.aes = list(size = 4)))

> geom_smooth() using method = 'loess' and formula = 'y ~ x'


![汽车引擎大小与公路燃油效率的散点图,点的颜色基于汽车类别。图中还叠加了平滑曲线。图例位于底部,类别以两行水平列出。图例中的点比图中的点大。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in14.png)

注意,在[`guides()`](https://ggplot2.tidyverse.org/reference/guides.xhtml)中的参数名与美学名字匹配,就像在[`labs()`](https://ggplot2.tidyverse.org/reference/labs.xhtml)中一样。

## 替换比例尺

而不仅仅是微调细节,您可以完全替换比例尺。您最有可能想要替换的两种比例尺是:连续位置比例尺和颜色比例尺。幸运的是,所有其他美学原则都适用于这些比例尺替换,因此一旦掌握了位置和颜色,您将能够快速掌握其他比例尺替换。

对您的变量进行转换是很有用的。例如,如果我们对`carat`和`price`进行对数转换,就更容易看到它们之间的精确关系:

Left

ggplot(diamonds, aes(x = carat, y = price)) +
geom_bin2d()

Right

ggplot(diamonds, aes(x = log10(carat), y = log10(price))) +
geom_bin2d()


![两个价格与钻石克拉数的图表。数据被分 bin 处理,每个 bin 的颜色表示落入该 bin 的点数。在右侧图中,价格和克拉值被对数化,轴标签显示了对数值。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in15.png)

然而,这种转换的缺点是轴现在标记为转换后的值,使得解释图形变得困难。与在美学映射中进行转换不同,我们可以使用比例尺进行转换。这在视觉上是相同的,只是轴标记在原始数据比例上。

ggplot(diamonds, aes(x = carat, y = price)) +
geom_bin2d() +
scale_x_log10() +
scale_y_log10()


![价格与钻石克拉数的图表。数据被分 bin 处理,每个 bin 的颜色表示落入该 bin 的点数。轴标签采用原始数据比例。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in16.png)

另一个经常定制的比例是颜色。默认的分类比例选择颜色均匀分布在色轮周围。有用的替代方案是 ColorBrewer 比例,这些比例经过手工调整,更适合普通类型的色盲人士。下面的两个图看起来相似,但红色和绿色的色调差异足够大,即使是红绿色盲的人也能区分右边的点。¹

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = drv))

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = drv)) +
scale_color_brewer(palette = "Set1")


![两个散点图,公路里程与发动机大小相关,点的颜色基于驱动类型。左边的图使用默认的 ggplot2 颜色调色板,右边的图使用不同的颜色调色板。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in17.png)

不要忘记更简单的技术来改善可访问性。如果只有几种颜色,您可以添加多余的形状映射。这也有助于确保您的图在黑白情况下也能被解读。

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = drv, shape = drv)) +
scale_color_brewer(palette = "Set1")


![两个散点图,公路里程与发动机大小相关,点的颜色和形状都基于驱动类型。颜色调色板不是默认的 ggplot2 调色板。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in18.png)

ColorBrewer 比例可以在[网上文档中查看](https://oreil.ly/LNHAy),并通过 Erich Neuwirth 的 RColorBrewer 包在 R 中使用。图 11-1 展示了所有调色板的完整列表。顺序(顶部)和发散(底部)调色板特别适用于您的分类值有序或具有“中间”情况。如果您已使用[`cut()`](https://rdrr.io/r/base/cut.xhtml)将连续变量变成分类变量,这种情况经常发生。

![所有 ColorBrewer 比例。一个组从浅色到深色。另一组是一组非有序颜色。最后一组有发散比例(从深到浅再到深)。在每组中都有许多调色板。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1101.png)

###### 图 11-1\. 所有 ColorBrewer 比例。

当您有预定义的值与颜色之间的映射时,请使用[`scale_color_manual()`](https://ggplot2.tidyverse.org/reference/scale_manual.xhtml)。例如,如果我们将总统党派映射到颜色,我们希望使用标准的映射,即共和党人为红色,民主党人为蓝色。为分配这些颜色的一种方法是使用十六进制颜色代码:

presidential |>
mutate(id = 33 + row_number()) |>
ggplot(aes(x = start, y = id, color = party)) +
geom_point() +
geom_segment(aes(xend = end, yend = id)) +
scale_color_manual(values = c(Republican = "#E81B23", Democratic = "#00AEF3"))


![ID 号码总统与他们开始总统任期的年份的线图。开始年份用一个点标记,并从那里开始的段到总统任期结束。民主总统用蓝色表示,共和党总统用红色表示。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in19.png)

对于连续颜色,你可以使用内置的[`scale_color_gradient()`](https://ggplot2.tidyverse.org/reference/scale_gradient.xhtml)或者[`scale_fill_gradient()`](https://ggplot2.tidyverse.org/reference/scale_gradient.xhtml)。如果你有一个发散尺度,可以使用[`scale_color_gradient2()`](https://ggplot2.tidyverse.org/reference/scale_gradient.xhtml)。例如,这允许你给正负值赋予不同的颜色。有时候,这也非常有用,如果你想区分高于或低于平均值的点。

另一个选项是使用 viridis 颜色尺度。设计师 Nathaniel Smith 和 Stéfan van der Walt 精心设计了适合各种形式色盲人士感知的连续色彩方案,以及在彩色和黑白模式下都是感知均匀的尺度。这些尺度在 ggplot2 中作为连续(`c`)、离散(`d`)和分组(`b`)调色板可用。

df <- tibble(
x = rnorm(10000),
y = rnorm(10000)
)

ggplot(df, aes(x, y)) +
geom_hex() +
coord_fixed() +
labs(title = "Default, continuous", x = NULL, y = NULL)

ggplot(df, aes(x, y)) +
geom_hex() +
coord_fixed() +
scale_fill_viridis_c() +
labs(title = "Viridis, continuous", x = NULL, y = NULL)

ggplot(df, aes(x, y)) +
geom_hex() +
coord_fixed() +
scale_fill_viridis_b() +
labs(title = "Viridis, binned", x = NULL, y = NULL)


![三个六边形图,其中六边形的颜色显示落入该六边形区域的观测次数。第一个图使用默认的连续 ggplot2 尺度。第二个图使用 viridis 连续尺度,第三个图使用 viridis 分组尺度。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in20.png)

注意所有颜色尺度都有两种变体:`scale_color_*()`和`scale_fill_*()`分别用于`color`和`fill`美学(颜色尺度在英国和美国拼写都可用)。

## 缩放

有三种控制绘图限制的方法:

+   调整绘制的数据

+   在每个尺度中设置限制

+   在[`coord_cartesian()`](https://ggplot2.tidyverse.org/reference/coord_cartesian.xhtml)中设置`xlim`和`ylim`。

我们将在一系列图中演示这些选项。左边的图显示了引擎尺寸与燃油效率之间的关系,按驱动类型着色。右边的图显示了相同的变量,但子集绘制的数据。子集数据影响了 x 和 y 轴的刻度以及平滑曲线。

Left

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = drv)) +
geom_smooth()

Right

mpg |>
filter(displ >= 5 & displ <= 6 & hwy >= 10 & hwy <= 25) |>
ggplot(aes(x = displ, y = hwy)) +
geom_point(aes(color = drv)) +
geom_smooth()


![左边是公路里程与排量的散点图,带有平滑曲线叠加显示出先减少,然后增加的趋势,如同曲棍球棒。右边相同的变量用于绘制,但是仅限于排量从 5 到 6,公路里程从 10 到 25。叠加的平滑曲线显示出先略微增加,然后减少的趋势。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in21.png)

让我们将这些与下面的两个图进行比较,左边的图设置了各自刻度的`limits`,右边的图在[`coord_cartesian()`](https://ggplot2.tidyverse.org/reference/coord_cartesian.xhtml)中设置了它们。我们可以看到,减少限制等同于子集数据。因此,为了放大绘图区域,通常最好使用[`coord_cartesian()`](https://ggplot2.tidyverse.org/reference/coord_cartesian.xhtml)。

Left

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = drv)) +
geom_smooth() +
scale_x_continuous(limits = c(5, 6)) +
scale_y_continuous(limits = c(10, 25))

Right

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = drv)) +
geom_smooth() +
coord_cartesian(xlim = c(5, 6), ylim = c(10, 25))


![左侧,高速公路里程与排量的散点图,排量从 5 到 6,高速公路里程从 10 到 25。叠加的平滑曲线显示了一种略微先增加然后减少的趋势。右侧,相同变量的散点图,使用相同的限制;然而,叠加的平滑曲线显示了一个相对平坦的趋势,并在最后略微增加。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in22.png)

另一方面,如果要扩展限制(例如,使不同图形之间的比例尺匹配),则在单个比例尺上设置`limits`通常更有用。例如,如果我们提取两类汽车并分别绘制它们,由于三个比例尺(x 轴、y 轴和颜色美学)具有不同的范围,很难比较这些图形。

suv <- mpg |> filter(class == "suv")
compact <- mpg |> filter(class == "compact")

Left

ggplot(suv, aes(x = displ, y = hwy, color = drv)) +
geom_point()

Right

ggplot(compact, aes(x = displ, y = hwy, color = drv)) +
geom_point()


![左侧是 SUV 高速公路里程与排量的散点图。右侧是紧凑型车辆相同变量的散点图。点按驱动类型着色。在 SUV 中,更多车辆是四轮驱动,其他的是后轮驱动,而在紧凑型车辆中,更多的车辆是前轮驱动,其他的是四轮驱动。SUV 图显示了高速公路里程与排量之间明显的负相关关系,而在紧凑型车辆图中,关系要平缓得多。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in23.png)

克服这个问题的一种方法是在多个图形之间共享比例尺,使用完整数据的`limits`训练比例尺。

x_scale <- scale_x_continuous(limits = range(mpg\(displ)) y_scale <- scale_y_continuous(limits = range(mpg\)hwy))
col_scale <- scale_color_discrete(limits = unique(mpg$drv))

Left

ggplot(suv, aes(x = displ, y = hwy, color = drv)) +
geom_point() +
x_scale +
y_scale +
col_scale

Right

ggplot(compact, aes(x = displ, y = hwy, color = drv)) +
geom_point() +
x_scale +
y_scale +
col_scale


![左侧是 SUV 高速公路里程与排量的散点图。右侧是紧凑型车辆相同变量的散点图。点按驱动类型着色。尽管 SUV 中没有前轮驱动车辆,紧凑型车中也没有后轮驱动车辆,但两个图中的图例显示了所有三种类型(前轮驱动、后轮驱动和四轮驱动)。由于 x 和 y 轴的比例相同,并且远远超出了最小或最大的高速公路里程和排量,点没有占据整个绘图区域。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in24.png)

在这种特定情况下,你可以简单地使用分面,但如果你想在报告的多个页面上分散绘图,这种技术通常也很有用。

## 练习

1.  为什么以下代码没有覆盖默认比例尺?

    ```
    df <- tibble(
      x = rnorm(10000),
      y = rnorm(10000)
    )

    ggplot(df, aes(x, y)) +
      geom_hex() +
      scale_color_gradient(low = "white", high = "red") +
      coord_fixed()
    ```

1.  每个比例尺的第一个参数是什么?它与[`labs()`](https://ggplot2.tidyverse.org/reference/labs.xhtml)有什么区别?

1.  更改总统任期的显示方法:

    1.  结合定制颜色和 x 轴分割的两个变体

    1.  改善 y 轴的显示

    1.  用总统姓名标记每个任期

    1.  添加信息丰富的图形标签

    1.  每隔四年设置分割点(这比看起来更复杂!)

1.  首先创建以下图形。然后,使用`override.aes`修改代码,以使图例更容易看到。

    ```
    ggplot(diamonds, aes(x = carat, y = price)) +
      geom_point(aes(color = cut), alpha = 1/20)
    ```

# 主题

最后,您可以使用主题定制图表的非数据元素:

ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point(aes(color = class)) +
geom_smooth(se = FALSE) +
theme_bw()


![汽车公路里程与汽车排量的散点图,按车辆类别着色。图的背景为白色,带有灰色网格线。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in25.png)

ggplot2 包含了八种主题,如 图 11-2 所示,其中 [`theme_gray()`](https://ggplot2.tidyverse.org/reference/ggtheme.xhtml) 是默认主题。² 还有许多其他主题包含在像 [ggthemes](https://oreil.ly/F1nga) 这样的附加包中,由 Jeffrey Arnold 提供。如果您想匹配特定的公司或期刊风格,您也可以创建自己的主题。

![使用 ggplot2 创建的八个条形图,每个图使用一种内置主题:theme_bw() - 白色背景带有网格线,theme_light() - 浅色轴线和网格线,theme_classic() - 经典主题,仅有轴线而无网格线,theme_linedraw() - 仅有黑线,theme_dark() - 深色背景以增加对比度,theme_minimal() - 极简主题,无背景,theme_gray() - 灰色背景(默认主题),theme_void() - 空主题,只有几何图形可见。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1102.png)

###### 图 11-2\. ggplot2 内置的八种主题。

您还可以控制每个主题的各个组件,例如用于 y 轴字体的大小和颜色。我们已经看到 `legend.position` 控制图例绘制的位置。还有许多其他图例的方面可以通过 [`theme()`](https://ggplot2.tidyverse.org/reference/theme.xhtml) 进行自定义。例如,在下面的图中,我们改变了图例的方向,并在其周围添加了黑色边框。请注意,主题的图例框和图表标题元素的自定义是使用 `element_*()` 函数完成的。这些函数指定非数据组件的样式;例如,标题文本在 [`element_text()`](https://ggplot2.tidyverse.org/reference/element.xhtml) 的 `face` 参数中加粗,图例边框颜色在 [`element_rect()`](https://ggplot2.tidyverse.org/reference/element.xhtml) 的 `color` 参数中定义。控制标题位置和说明的主题元素分别为 `plot.title.position` 和 `plot.caption.position`。在下面的图中,将它们设置为 `"plot"` 表示这些元素与整个图区对齐,而不是图面板(默认设置)。其他几个有用的 [`theme()`](https://ggplot2.tidyverse.org/reference/theme.xhtml) 组件用于更改标题和说明文本的格式和放置位置。

ggplot(mpg, aes(x = displ, y = hwy, color = drv)) +
geom_point() +
labs(
title = "Larger engine sizes tend to have lower fuel economy",
caption = "Source: https://fueleconomy.gov."
) +
theme(
legend.position = c(0.6, 0.7),
legend.direction = "horizontal",
legend.box.background = element_rect(color = "black"),
plot.title = element_text(face = "bold"),
plot.title.position = "plot",
plot.caption.position = "plot",
plot.caption = element_text(hjust = 0)
)


![汽车公路燃油效率与发动机大小的散点图,按驱动类型着色。图的标题为“较大的发动机尺寸通常具有较低的燃油经济性”,说明指向数据来源 fueleconomy.gov。说明和标题左对齐,图例位于图内,带有黑色边框。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in26.png)

要查看所有 [`theme()`](https://ggplot2.tidyverse.org/reference/theme.xhtml) 组件的概述,请参阅 [`?theme`](https://ggplot2.tidyverse.org/reference/theme.xhtml) 的帮助。此外,[ggplot2 书籍](https://oreil.ly/T4Jxn)也是获取主题详细信息的好地方。

## 练习

1.  选择 ggthemes 包提供的主题,并将其应用到您制作的最后一个图中。

1.  使您图的轴标签变为蓝色和粗体。

# 布局

到目前为止,我们讨论了如何创建和修改单个图。如果您有多个图想要以某种方式布局呢?patchwork 包允许您将单独的图合并到同一图形中。我们在本章前面加载了这个包。

要将两个图放置在一起,您可以简单地将它们添加到一起。请注意,您首先需要创建图并将它们保存为对象(在下面的示例中称为 `p1` 和 `p2`)。然后,您可以使用 `+` 将它们放置在一起。

p1 <- ggplot(mpg, aes(x = displ, y = hwy)) +
geom_point() +
labs(title = "Plot 1")
p2 <- ggplot(mpg, aes(x = drv, y = hwy)) +
geom_boxplot() +
labs(title = "Plot 2")
p1 + p2


![两个图(一张是高速公路里程与发动机大小的散点图,另一张是高速公路里程与驱动系统的并排箱线图)并排放置在一起。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in27.png)

在前面的代码块中,重要的是我们没有使用 patchwork 包的新功能。相反,该包向 `+` 操作符添加了新功能。

您还可以使用 patchwork 创建复杂的图形布局。在下面的示例中,`|` 将 `p1` 和 `p3` 并排放置,`/` 将 `p2` 移到下一行:

p3 <- ggplot(mpg, aes(x = cty, y = hwy)) +
geom_point() +
labs(title = "Plot 3")
(p1 | p3) / p2


![三个图的布局,第一个和第三个图并排放置,第二个图在它们下面拉伸。第一个图是高速公路里程与发动机大小的散点图,第三个图是高速公路里程与城市里程的散点图,第三个图是高速公路里程与驱动系统的并排箱线图。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in28.png)

此外,patchwork 还允许您将多个图的图例收集到一个共同的图例中,自定义图例的位置以及图的尺寸,并向您的图添加共同的标题、副标题、说明等。在这里,我们创建了五个图。我们关闭了箱线图和散点图的图例,并在图的顶部收集了密度图的图例,使用了 `& theme(legend.position = "top")`。请注意,这里使用的是 `&` 运算符,而不是通常的 `+`。这是因为我们要修改 patchwork 图的主题,而不是单个 ggplot 的主题。图例位于顶部,在 [`guide_area()`](https://patchwork.data-imaginist.com/reference/guide_area.xhtml) 内。最后,我们还自定义了 patchwork 的各个组件的高度——指南的高度为 1,箱线图为 3,密度图为 2,分面散点图为 4。Patchwork 使用此比例划分您为图分配的区域,并相应地放置组件。

p1 <- ggplot(mpg, aes(x = drv, y = cty, color = drv)) +
geom_boxplot(show.legend = FALSE) +
labs(title = "Plot 1")

p2 <- ggplot(mpg, aes(x = drv, y = hwy, color = drv)) +
geom_boxplot(show.legend = FALSE) +
labs(title = "Plot 2")

p3 <- ggplot(mpg, aes(x = cty, color = drv, fill = drv)) +
geom_density(alpha = 0.5) +
labs(title = "Plot 3")

p4 <- ggplot(mpg, aes(x = hwy, color = drv, fill = drv)) +
geom_density(alpha = 0.5) +
labs(title = "Plot 4")

p5 <- ggplot(mpg, aes(x = cty, y = hwy, color = drv)) +
geom_point(show.legend = FALSE) +
facet_wrap(~drv) +
labs(title = "Plot 5")

(guide_area() / (p1 + p2) / (p3 + p4) / p5) +
plot_annotation(
title = "City and highway mileage for cars with different drivetrains",
caption = "Source: https://fueleconomy.gov."
) +
plot_layout(
guides = "collect",
heights = c(1, 3, 2, 4)
) &
theme(legend.position = "top")


![五个图表布局如下:前两个图表并列显示。第三和第四个图表位于它们的下方。第五个图表横跨它们的下方。这个合成图表的标题是“不同驱动系统汽车的城市和高速里程”,注释是“来源:https://fueleconomy.gov”。前两个图表是并排的箱线图。第三和第四个图表是密度图。第五个图表是分面散点图。这些图表展示了根据驱动系统着色的几何图形,但合成图表只有一个适用于所有图表的图例,位于图表上方和标题下方。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in29.png)

如果你想了解如何结合和布局多个图表,我们建议查阅[包网站](https://oreil.ly/xWxVV)上的指南。

## 练习

1.  如果你在以下图表布局中省略了括号会发生什么?你能解释为什么会发生这种情况吗?

    ```
    p1 <- ggplot(mpg, aes(x = displ, y = hwy)) + 
      geom_point() + 
      labs(title = "Plot 1")
    p2 <- ggplot(mpg, aes(x = drv, y = hwy)) + 
      geom_boxplot() + 
      labs(title = "Plot 2")
    p3 <- ggplot(mpg, aes(x = cty, y = hwy)) + 
      geom_point() + 
      labs(title = "Plot 3")

    (p1 | p2) / p3
    ```

使用前一练习中的三个图表,重新创建以下的 patchwork:

![三个图表:图表 1 是高速里程与引擎尺寸的散点图。图表 2 是侧面的箱线图,显示不同驱动系统的高速里程。图表 3 是侧面的箱线图,显示不同驱动系统的城市里程。图表 1 位于第一行。图表 2 和图表 3 位于下一行,每个图表占图表 1 宽度的一半。图表 1 标为“图 A”,图表 2 标为“图 B”,图表 3 标为“图 C”。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_11in30.png)

# 总结

在本章中,你学习了如何添加图表标签,如标题、副标题和说明,以及修改默认轴标签,使用注释添加信息文本到你的图表或突出显示特定数据点,定制轴标尺,以及改变图表的主题。你还学习了如何使用简单和复杂的图表布局将多个图表合并到单个图形中。

尽管你迄今已学习了如何制作许多不同类型的图表,并且如何使用各种技术进行定制,但我们仅仅触及了 ggplot2 能创造的冰山一角。如果你希望全面理解 ggplot2,我们建议阅读[*ggplot2: Elegant Graphics for Data Analysis*](https://oreil.ly/T4Jxn)(Springer)这本书。其他有用的资源包括 Winston Chang 的[*R Graphics Cookbook*](https://oreil.ly/CK_sd)(O’Reilly)以及 Claus Wilke 的[*Fundamentals of Data Visualization*](https://oreil.ly/uJRYK)(O’Reilly)。

¹ 你可以使用像[SimDaltonism](https://oreil.ly/i11yd)这样的工具来模拟色盲,以测试这些图像。

² 许多人想知道为什么默认主题有灰色背景。这是一个有意为之的选择,因为它突出了数据,同时使网格线可见。白色网格线是可见的(这很重要,因为它们显著地帮助位置判断),但它们在视觉上影响较小,我们可以轻松忽略它们。灰色背景使得绘图与文本的排版颜色相似,确保图形与文档流畅融合,而不是用明亮的白色背景突兀地跳出来。最后,灰色背景形成了连续的色彩场,确保绘图被感知为一个单一的视觉实体。


# 第三部分: 转换

本书的第二部分是对数据可视化的深入探讨。在本书的这一部分中,你将了解到数据框中遇到的最重要的变量类型,并学习可以用来处理它们的工具。

![我们的数据科学模型,高亮显示的转换部分。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_p300.png)

###### 图 III-1. 数据转换选项取决于涉及的数据类型,这本书的重点部分。

你可以按需阅读这些章节;它们被设计为基本上是独立的,因此可以无需顺序阅读。

+   第十二章教你逻辑向量。这些是最简单的向量类型,但它们非常强大。你将学习如何使用数值比较创建它们,如何用布尔代数组合它们,如何在摘要中使用它们,以及如何在条件转换中使用它们。

+   第十三章深入探讨了数字向量的工具,数据科学的核心。你将学到更多关于计数以及一些重要的转换和摘要函数。

+   第十四章提供了处理字符串的工具:你将切割它们,你将切块它们,你将它们重新组合在一起。本章主要关注 stringr 包,但你也将学习一些更多 tidyr 函数,专门用于从字符字符串中提取数据。

+   第十五章向你介绍了正则表达式,这是一个强大的字符串操作工具。本章将带你从认为键盘上走过一只猫的感觉,到读写复杂的字符串模式。

+   第十六章介绍了因子:R 用来存储分类数据的数据类型。当一个变量具有一组固定的可能值或者当你想要使用字符串的非字母顺序时,你使用因子。

+   第十七章提供了处理日期和日期时间的关键工具。不幸的是,你学习的日期时间越多,它们似乎就越复杂,但是有了 lubridate 包的帮助,你将学会如何克服最常见的挑战。

+   第十八章深入讨论了缺失值。我们之前在若干场合中讨论过它们,但现在是全面讨论的时候了,帮助你理解隐式和显式缺失值之间的区别,以及为什么以及如何在它们之间转换。

+   第十九章结束了本书的这一部分,为你提供了将两个(或更多)数据框连接在一起的工具。学习连接将促使你理解关键的概念,并考虑如何在数据集中识别每一行。


# 第十二章:逻辑向量

# 介绍

在本章中,您将学习处理逻辑向量的工具。逻辑向量是最简单的向量类型,因为每个元素只能是三个可能值之一:`TRUE`、`FALSE`和`NA`。在原始数据中找到逻辑向量相对较少,但在几乎每次分析过程中,您都会创建和操作它们。

我们将从讨论创建逻辑向量的最常见方法开始:使用数字比较。然后,您将了解如何使用布尔代数来组合不同的逻辑向量,以及一些有用的摘要。我们将以[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml)和[`case_when()`](https://dplyr.tidyverse.org/reference/case_when.xhtml)两个使用逻辑向量进行条件更改的实用函数结束。

## 先决条件

您将在本章学习的大多数函数由基本 R 提供,因此我们不需要 tidyverse,但仍将加载它以便使用[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)、[`filter()`](https://dplyr.tidyverse.org/reference/filter.xhtml)和其伙伴处理数据框。我们还将继续从[`nycflights13::flights`](https://rdrr.io/pkg/nycflights13/man/flights.xhtml)数据集中提取示例。

library(tidyverse)
library(nycflights13)


然而,随着我们开始涵盖更多工具,就不会总是有一个完美的实际示例。因此,我们将开始使用[`c()`](https://rdrr.io/r/base/c.xhtml)创建一些虚拟数据:

x <- c(1, 2, 3, 5, 7, 11, 13)
x * 2

> [1] 2 4 6 10 14 22 26


这样做可以更轻松地解释单个函数,但代价是更难看到它如何适用于您的数据问题。只需记住,我们对自由浮动向量进行的任何操作,您都可以通过[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)和其伙伴在数据框中对变量执行相同的操作。

df <- tibble(x)
df |>
mutate(y = x * 2)

> # A tibble: 7 × 2

> x y

>

> 1 1 2

> 2 2 4

> 3 3 6

> 4 5 10

> 5 7 14

> 6 11 22

> # … with 1 more row


# 比较

创建逻辑向量的常见方法是通过与`<`、`<=`、`>`、`>=`、`!=`和`==`的数字比较。到目前为止,我们主要在[`filter()`](https://dplyr.tidyverse.org/reference/filter.xhtml)内部临时创建逻辑变量——它们被计算、使用,然后丢弃。例如,以下过滤器找到所有准时到达的白天出发航班:

flights |>
filter(dep_time > 600 & dep_time < 2000 & abs(arr_delay) < 20)

> # A tibble: 172,286 × 19

> year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time

>

> 1 2013 1 1 601 600 1 844 850

> 2 2013 1 1 602 610 -8 812 820

> 3 2013 1 1 602 605 -3 821 805

> 4 2013 1 1 606 610 -4 858 910

> 5 2013 1 1 606 610 -4 837 845

> 6 2013 1 1 607 607 0 858 915

> # … with 172,280 more rows, and 11 more variables: arr_delay ,

> # carrier , flight , tailnum , origin , dest , …


了解这是一个快捷方式是有用的,您可以使用[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)显式创建底层逻辑变量:

flights |>
mutate(
daytime = dep_time > 600 & dep_time < 2000,
approx_ontime = abs(arr_delay) < 20,
.keep = "used"
)

> # A tibble: 336,776 × 4

> dep_time arr_delay daytime approx_ontime

>

> 1 517 11 FALSE TRUE

> 2 533 20 FALSE FALSE

> 3 542 33 FALSE FALSE

> 4 544 -18 FALSE TRUE

> 5 554 -25 FALSE FALSE

> 6 554 12 FALSE TRUE

> # … with 336,770 more rows


这对于更复杂的逻辑特别有用,因为命名中间步骤使您能够更轻松地阅读代码并检查每个步骤是否已正确计算。

总之,初始过滤相当于以下操作:

flights |>
mutate(
daytime = dep_time > 600 & dep_time < 2000,
approx_ontime = abs(arr_delay) < 20,
) |>
filter(daytime & approx_ontime)


## 浮点数比较

谨防使用`==`与数字。例如,看起来这个向量包含数字 1 和 2:

x <- c(1 / 49 * 49, sqrt(2) ^ 2)
x

> [1] 1 2


但是,如果您将它们进行相等性测试,将会得到`FALSE`:

x == c(1, 2)

> [1] FALSE FALSE


发生了什么?计算机以固定的小数位数存储数字,所以无法精确表示 1/49 或 `sqrt(2)`,随后的计算将会稍微偏离。我们可以通过调用 [`print()`](https://rdrr.io/r/base/print.xhtml) 并使用 `digits`¹ 参数来查看精确值:

print(x, digits = 16)

> [1] 0.9999999999999999 2.0000000000000004


你可以看到为什么 R 默认四舍五入这些数字;它们确实非常接近你所期望的值。

现在你已经看到为什么 `==` 失败了,你能做些什么?一种选择是使用 [`dplyr::near()`](https://dplyr.tidyverse.org/reference/near.xhtml),它忽略小差异:

near(x, c(1, 2))

> [1] TRUE TRUE


## 缺失值

缺失值代表未知,因此它们是“传染性”的:几乎任何涉及未知值的操作也将是未知的:

NA > 5

> [1] NA

10 == NA

> [1] NA


最令人困惑的结果是这个:

NA == NA

> [1] NA


如果我们人为地提供一点更多的上下文,就可以更容易理解这是为什么:

We don't know how old Mary is

age_mary <- NA

We don't know how old John is

age_john <- NA

Are Mary and John the same age?

age_mary == age_john

> [1] NA

We don't know!


因此,如果你想找到所有 `dep_time` 缺失的航班,下面的代码不起作用,因为 `dep_time == NA` 会对每一行都返回 `NA`,而 [`filter()`](https://dplyr.tidyverse.org/reference/filter.xhtml) 会自动丢弃缺失值:

flights |>
filter(dep_time == NA)

> # A tibble: 0 × 19

> # … with 19 variables: year , month , day , dep_time ,

> # sched_dep_time , dep_delay , arr_time , …


相反,我们需要一个新的工具:[`is.na()`](https://rdrr.io/r/base/NA.xhtml)。

## is.na()

`is.na(x)` 适用于任何类型的向量,并返回缺失值为 `TRUE`,其他值为 `FALSE`:

is.na(c(TRUE, NA, FALSE))

> [1] FALSE TRUE FALSE

is.na(c(1, NA, 3))

> [1] FALSE TRUE FALSE

is.na(c("a", NA, "b"))

> [1] FALSE TRUE FALSE


我们可以使用 [`is.na()`](https://rdrr.io/r/base/NA.xhtml) 找到所有具有缺失 `dep_time` 的行:

flights |>
filter(is.na(dep_time))

> # A tibble: 8,255 × 19

> year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time

>

> 1 2013 1 1 NA 1630 NA NA 1815

> 2 2013 1 1 NA 1935 NA NA 2240

> 3 2013 1 1 NA 1500 NA NA 1825

> 4 2013 1 1 NA 600 NA NA 901

> 5 2013 1 2 NA 1540 NA NA 1747

> 6 2013 1 2 NA 1620 NA NA 1746

> # … with 8,249 more rows, and 11 more variables: arr_delay ,

> # carrier , flight , tailnum , origin , dest , …


[`is.na()`](https://rdrr.io/r/base/NA.xhtml) 在 [`arrange()`](https://dplyr.tidyverse.org/reference/arrange.xhtml) 中也很有用。[`arrange()`](https://dplyr.tidyverse.org/reference/arrange.xhtml) 通常将所有缺失值放在最后,但你可以通过首先按 [`is.na()`](https://rdrr.io/r/base/NA.xhtml) 排序来覆盖此默认行为:

flights |>
filter(month == 1, day == 1) |>
arrange(dep_time)

> # A tibble: 842 × 19

> year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time

>

> 1 2013 1 1 517 515 2 830 819

> 2 2013 1 1 533 529 4 850 830

> 3 2013 1 1 542 540 2 923 850

> 4 2013 1 1 544 545 -1 1004 1022

> 5 2013 1 1 554 600 -6 812 837

> 6 2013 1 1 554 558 -4 740 728

> # … with 836 more rows, and 11 more variables: arr_delay ,

> # carrier , flight , tailnum , origin , dest , …

flights |>
filter(month == 1, day == 1) |>
arrange(desc(is.na(dep_time)), dep_time)

> # A tibble: 842 × 19

> year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time

>

> 1 2013 1 1 NA 1630 NA NA 1815

> 2 2013 1 1 NA 1935 NA NA 2240

> 3 2013 1 1 NA 1500 NA NA 1825

> 4 2013 1 1 NA 600 NA NA 901

> 5 2013 1 1 517 515 2 830 819

> 6 2013 1 1 533 529 4 850 830

> # … with 836 more rows, and 11 more variables: arr_delay ,

> # carrier , flight , tailnum , origin , dest , …


我们将在 第十八章 中更深入地讨论缺失值。

## 练习

1.  [`dplyr::near()`](https://dplyr.tidyverse.org/reference/near.xhtml) 是如何工作的?输入 `near` 查看源代码。`sqrt(2)²` 是否接近于 2?

1.  使用 [`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml),[`is.na()`](https://rdrr.io/r/base/NA.xhtml),和 [`count()`](https://dplyr.tidyverse.org/reference/count.xhtml) 一起描述 `dep_time`,`sched_dep_time`,和 `dep_delay` 中缺失值的关联。

# 布尔代数

一旦你有了多个逻辑向量,你可以使用布尔代数将它们组合起来。在 R 中,`&` 表示“与”,`|` 表示“或”,`!` 表示“非”,而 [`xor()`](https://rdrr.io/r/base/Logic.xhtml) 是异或运算。² 例如,`df |> filter(!is.na(x))` 找到所有 `x` 不缺失的行,而 `df |> filter(x < -10 | x > 0)` 找到所有 `x` 小于 -10 或大于 0 的行。图 12-1 显示了完整的布尔操作集合及其工作原理。

![六个维恩图,每个解释一个给定的逻辑运算符。每个维恩图中的圆圈(集合)代表 x 和 y。1\. y & !x 是 y 但不包含 x;x & y 是 x 和 y 的交集;x & !y 是 x 但不包含 y;x 是 x 的全部,不包含 y;xor(x, y) 是除了 x 和 y 的交集外的所有内容;y 是 y 的全部,不包含 x;而 x | y 是所有内容。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1201.png)

###### 图 12-1\. 完整的布尔运算集合。`x` 是左圆圈,`y` 是右圆圈,阴影区域显示了每个运算符选择的部分。

除了 `&` 和 `|` 外,R 还有 `&&` 和 `||`。不要在 dplyr 函数中使用它们!这些被称为 *短路操作符*,只会返回一个单一的 `TRUE` 或 `FALSE`。它们对编程很重要,但不适合数据科学。

## 缺失值

布尔代数中缺失值的规则有点难以解释,因为乍看起来似乎不一致:

df <- tibble(x = c(TRUE, FALSE, NA))

df |>
mutate(
and = x & NA,
or = x | NA
)

> # A tibble: 3 × 3

> x and or

>

> 1 TRUE NA TRUE

> 2 FALSE FALSE NA

> 3 NA NA NA


要理解发生了什么,请考虑 `NA | TRUE`。逻辑向量中的缺失值意味着该值可以是 `TRUE` 或 `FALSE`。`TRUE | TRUE` 和 `FALSE | TRUE` 都是 `TRUE`,因为至少其中一个是 `TRUE`。因此 `NA | TRUE` 也必须是 `TRUE`,因为 `NA` 可以是 `TRUE` 或 `FALSE`。然而,`NA | FALSE` 是 `NA`,因为我们不知道 `NA` 是 `TRUE` 还是 `FALSE`。类似的推理也适用于 `NA & FALSE`。

## 操作顺序

注意操作顺序与英语不同。考虑以下代码,找到所有在十一月或十二月出发的航班:

flights |>
filter(month == 11 | month == 12)


你可能会倾向于像用英语说的那样写:“找到所有在十一月或十二月出发的航班”:

flights |>
filter(month == 11 | 12)

> # A tibble: 336,776 × 19

> year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time

>

> 1 2013 1 1 517 515 2 830 819

> 2 2013 1 1 533 529 4 850 830

> 3 2013 1 1 542 540 2 923 850

> 4 2013 1 1 544 545 -1 1004 1022

> 5 2013 1 1 554 600 -6 812 837

> 6 2013 1 1 554 558 -4 740 728

> # … with 336,770 more rows, and 11 more variables: arr_delay ,

> # carrier , flight , tailnum , origin , dest , …


这段代码没有错误,但似乎也没有起作用。出了什么问题?在这里,R 首先评估 `month == 11` 创建一个逻辑向量,我们称之为 `nov`。它计算 `nov | 12`。当你在逻辑运算符中使用一个数字时,除了 0 外的所有内容都会转换为 `TRUE`,因此这相当于 `nov | TRUE`,这将始终为 `TRUE`,因此每一行都将被选中:

flights |>
mutate(
nov = month == 11,
final = nov | 12,
.keep = "used"
)

> # A tibble: 336,776 × 3

> month nov final

>

> 1 1 FALSE TRUE

> 2 1 FALSE TRUE

> 3 1 FALSE TRUE

> 4 1 FALSE TRUE

> 5 1 FALSE TRUE

> 6 1 FALSE TRUE

> # … with 336,770 more rows


## %in%

避免在正确顺序中使用 `==` 和 `|` 的简单方法是使用 `%in%`。`x %in% y` 返回与 `x` 相同长度的逻辑向量,只要 `x` 中的值在 `y` 中的任何位置,就为 `TRUE`。

1:12 %in% c(1, 5, 11)

> [1] TRUE FALSE FALSE FALSE TRUE FALSE FALSE FALSE FALSE FALSE TRUE FALSE

letters[1:10] %in% c("a", "e", "i", "o", "u")

> [1] TRUE FALSE FALSE FALSE TRUE FALSE FALSE FALSE TRUE FALSE


所以要找到所有在十一月和十二月出发的航班,我们可以写成:

flights |>
filter(month %in% c(11, 12))


注意 `%in%` 在处理 `NA` 时与 `==` 有不同的规则,因为 `NA %in% NA` 结果为 `TRUE`。

c(1, 2, NA) == NA

> [1] NA NA NA

c(1, 2, NA) %in% NA

> [1] FALSE FALSE TRUE


这可以作为一个有用的快捷方式:

flights |>
filter(dep_time %in% c(NA, 0800))

> # A tibble: 8,803 × 19

> year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time

>

> 1 2013 1 1 800 800 0 1022 1014

> 2 2013 1 1 800 810 -10 949 955

> 3 2013 1 1 NA 1630 NA NA 1815

> 4 2013 1 1 NA 1935 NA NA 2240

> 5 2013 1 1 NA 1500 NA NA 1825

> 6 2013 1 1 NA 600 NA NA 901

> # … with 8,797 more rows, and 11 more variables: arr_delay ,

> # carrier , flight , tailnum , origin , dest , …


## 练习

1.  查找所有 `arr_delay` 缺失但 `dep_delay` 不缺失的航班。查找所有 `arr_time` 和 `sched_arr_time` 都不缺失,但 `arr_delay` 缺失的航班。

1.  有多少个航班的 `dep_time` 缺失?这些行中还有哪些变量缺失?这些行可能代表什么?

1.  假设缺失的 `dep_time` 表示航班取消,看一看每天取消的航班数。是否存在某种模式?取消航班的比例与非取消航班的平均延误之间是否有联系?

# 摘要

下面的章节描述了一些有用的逻辑向量摘要技术。除了专门用于逻辑向量的函数外,还可以使用适用于数值向量的函数。

## 逻辑摘要

主要有两种逻辑摘要:[`any()`](https://rdrr.io/r/base/any.xhtml) 和 [`all()`](https://rdrr.io/r/base/all.xhtml)。`any(x)` 相当于 `|`;如果 `x` 中有任何 `TRUE`,则返回 `TRUE`。`all(x)` 相当于 `&`;只有当 `x` 的所有值都是 `TRUE` 时才返回 `TRUE`。与所有摘要函数一样,如果存在任何缺失值,它们会返回 `NA`,通常你可以通过 `na.rm = TRUE` 来处理缺失值。

例如,我们可以使用 [`all()`](https://rdrr.io/r/base/all.xhtml) 和 [`any()`](https://rdrr.io/r/base/any.xhtml) 来查找是否每次航班的出发都延误不超过一小时,或者是否有航班的到达延误超过五小时。通过 [`group_by()`](https://dplyr.tidyverse.org/reference/group_by.xhtml) 允许我们按天来做到这一点:

flights |>
group_by(year, month, day) |>
summarize(
all_delayed = all(dep_delay <= 60, na.rm = TRUE),
any_long_delay = any(arr_delay >= 300, na.rm = TRUE),
.groups = "drop"
)

> # A tibble: 365 × 5

> year month day all_delayed any_long_delay

>

> 1 2013 1 1 FALSE TRUE

> 2 2013 1 2 FALSE TRUE

> 3 2013 1 3 FALSE FALSE

> 4 2013 1 4 FALSE FALSE

> 5 2013 1 5 FALSE TRUE

> 6 2013 1 6 FALSE FALSE

> # … with 359 more rows


在大多数情况下,[`any()`](https://rdrr.io/r/base/any.xhtml) 和 [`all()`](https://rdrr.io/r/base/all.xhtml) 太过粗糙,希望能够更详细地了解有多少值是 `TRUE` 或 `FALSE`。这引导我们进入了数值摘要的领域。

## 逻辑向量的数值摘要

当你在数值上下文中使用逻辑向量时,`TRUE` 变为 1,`FALSE` 变为 0。这使得 [`sum()`](https://rdrr.io/r/base/sum.xhtml) 和 [`mean()`](https://rdrr.io/r/base/mean.xhtml) 在逻辑向量中非常有用,因为 `sum(x)` 给出了 `TRUE` 的数量,`mean(x)` 给出了 `TRUE` 的比例(因为 [`mean()`](https://rdrr.io/r/base/mean.xhtml) 只是 [`sum()`](https://rdrr.io/r/base/sum.xhtml) 除以 [`length()`](https://rdrr.io/r/base/length.xhtml))。

例如,这使我们可以查看在出发延误不超过一小时的航班中延误比例以及到达延误超过五小时的航班数量:

flights |>
group_by(year, month, day) |>
summarize(
all_delayed = mean(dep_delay <= 60, na.rm = TRUE),
any_long_delay = sum(arr_delay >= 300, na.rm = TRUE),
.groups = "drop"
)

> # A tibble: 365 × 5

> year month day all_delayed any_long_delay

>

> 1 2013 1 1 0.939 3

> 2 2013 1 2 0.914 3

> 3 2013 1 3 0.941 0

> 4 2013 1 4 0.953 0

> 5 2013 1 5 0.964 1

> 6 2013 1 6 0.959 0

> # … with 359 more rows


## 逻辑子集

逻辑向量在摘要中还有一种最终用途:你可以使用逻辑向量来过滤感兴趣的子集中的单个变量。这利用了基本的 ``(子集)运算符,你可以在 [“使用 `[` 选择多个元素” 中了解更多相关内容。

假设我们想要查看实际延误的航班的平均延误时间。一种方法是首先筛选航班,然后计算平均延误:

flights |>
filter(arr_delay > 0) |>
group_by(year, month, day) |>
summarize(
behind = mean(arr_delay),
n = n(),
.groups = "drop"
)

> # A tibble: 365 × 5

> year month day behind n

>

> 1 2013 1 1 32.5 461

> 2 2013 1 2 32.0 535

> 3 2013 1 3 27.7 460

> 4 2013 1 4 28.3 297

> 5 2013 1 5 22.6 238

> 6 2013 1 6 24.4 381

> # … with 359 more rows


这有效,但如果我们想要计算提前到达的航班的平均延误呢?我们需要执行一个单独的过滤步骤,然后找出如何将两个数据框合并在一起。³ 相反,您可以使用`[`来执行内联过滤:`arr_delay[arr_delay > 0]`将仅产生正到达延误。

这导致:

flights |>
group_by(year, month, day) |>
summarize(
behind = mean(arr_delay[arr_delay > 0], na.rm = TRUE),
ahead = mean(arr_delay[arr_delay < 0], na.rm = TRUE),
n = n(),
.groups = "drop"
)

> # A tibble: 365 × 6

> year month day behind ahead n

>

> 1 2013 1 1 32.5 -12.5 842

> 2 2013 1 2 32.0 -14.3 943

> 3 2013 1 3 27.7 -18.2 914

> 4 2013 1 4 28.3 -17.0 915

> 5 2013 1 5 22.6 -14.0 720

> 6 2013 1 6 24.4 -13.6 832

> # … with 359 more rows


还要注意群组大小的差异:在第一个块中,[`n()`](https://dplyr.tidyverse.org/reference/context.xhtml)给出每天延误航班的数量;在第二个块中,[`n()`](https://dplyr.tidyverse.org/reference/context.xhtml)给出总航班数。

## 练习

1.  `sum(is.na(x))`告诉你什么?`mean(is.na(x))`又会告诉你什么?

1.  当应用于逻辑向量时,[`prod()`](https://rdrr.io/r/base/prod.xhtml)返回什么?它等同于哪个逻辑汇总函数?当应用于逻辑向量时,[`min()`](https://rdrr.io/r/base/Extremes.xhtml)返回什么?它等同于哪个逻辑汇总函数?阅读文档并进行一些实验。

# 条件转换

逻辑向量最强大的特性之一是它们在条件变换中的使用,即针对条件 x 执行一件事,针对条件 y 执行另一件事。有两个重要的工具可以做到这一点:[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml) 和 [`case_when()`](https://dplyr.tidyverse.org/reference/case_when.xhtml)。

## if_else()

如果你想要在条件为`TRUE`时使用一个值,在条件为`FALSE`时使用另一个值,你可以使用[`dplyr::if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml)。⁴ 你总是会使用[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml)的前三个参数。第一个参数`condition`是一个逻辑向量;第二个参数`true`在条件为真时输出;第三个参数`false`在条件为假时输出。

让我们从一个简单的例子开始,将数值向量标记为“+ve”(正数)或“-ve”(负数):

x <- c(-3:3, NA)
if_else(x > 0, "+ve", "-ve")

> [1] "-ve" "-ve" "-ve" "-ve" "+ve" "+ve" "+ve" NA


有一个可选的第四个参数,`missing`,如果输入为`NA`,将会使用它:

if_else(x > 0, "+ve", "-ve", "???")

> [1] "-ve" "-ve" "-ve" "-ve" "+ve" "+ve" "+ve" "???"


你也可以使用向量作为`true`和`false`参数。例如,这使我们能够创建[`abs()`](https://rdrr.io/r/base/MathFun.xhtml)的最小实现:

if_else(x < 0, -x, x)

> [1] 3 2 1 0 1 2 3 NA


到目前为止,所有参数都使用了相同的向量,但你当然可以混合和匹配。例如,您可以像这样实现[`coalesce()`](https://dplyr.tidyverse.org/reference/coalesce.xhtml)的简单版本:

x1 <- c(NA, 1, 2, NA)
y1 <- c(3, NA, 4, 6)
if_else(is.na(x1), y1, x1)

> [1] 3 1 2 6


您可能已经注意到我们先前标记示例中的一个小错误:零既不是正数也不是负数。我们可以通过添加额外的[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml)来解决这个问题:

if_else(x == 0, "0", if_else(x < 0, "-ve", "+ve"), "???")

> [1] "-ve" "-ve" "-ve" "0" "+ve" "+ve" "+ve" "???"


这已经有点难以阅读了,你可以想象如果有更多条件,阅读起来将变得更加困难。相反,你可以切换到[`dplyr::case_when()`](https://dplyr.tidyverse.org/reference/case_when.xhtml)。

## case_when()

dplyr 的[`case_when()`](https://dplyr.tidyverse.org/reference/case_when.xhtml)受 SQL 的`CASE`语句启发,并提供了一种灵活的方式根据不同条件执行不同的计算。它有一个特殊的语法,不幸的是看起来与 tidyverse 中的其他内容完全不同。它接受看起来像`condition ~ output`的成对输入。`condition`必须是一个逻辑向量;当它为`TRUE`时,将使用`output`。

这意味着我们可以将之前嵌套的[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml)重新创建如下:

x <- c(-3:3, NA)
case_when(
x == 0 ~ "0",
x < 0 ~ "-ve",
x > 0 ~ "+ve",
is.na(x) ~ "???"
)

> [1] "-ve" "-ve" "-ve" "0" "+ve" "+ve" "+ve" "???"


这是更多的代码,但也更加明确。

为了解释[`case_when()`](https://dplyr.tidyverse.org/reference/case_when.xhtml)的工作原理,让我们探索一些更简单的情况。如果没有一种情况匹配,输出将会是`NA`:

case_when(
x < 0 ~ "-ve",
x > 0 ~ "+ve"
)

> [1] "-ve" "-ve" "-ve" NA "+ve" "+ve" "+ve" NA


如果你想创建一个“默认”/捕获所有值,可以在左侧使用`TRUE`:

case_when(
x < 0 ~ "-ve",
x > 0 ~ "+ve",
TRUE ~ "???"
)

> [1] "-ve" "-ve" "-ve" "???" "+ve" "+ve" "+ve" "???"


注意,如果多个条件匹配,只会使用第一个:

case_when(
x > 0 ~ "+ve",
x > 2 ~ "big"
)

> [1] NA NA NA NA "+ve" "+ve" "+ve" NA


就像使用[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml)一样,你可以在`~`的两侧使用变量,并且可以根据问题需要混合和匹配变量。例如,我们可以使用[`case_when()`](https://dplyr.tidyverse.org/reference/case_when.xhtml)为到达延迟提供一些可读的标签:

flights |>
mutate(
status = case_when(
is.na(arr_delay) ~ "cancelled",
arr_delay < -30 ~ "very early",
arr_delay < -15 ~ "early",
abs(arr_delay) <= 15 ~ "on time",
arr_delay < 60 ~ "late",
arr_delay < Inf ~ "very late",
),
.keep = "used"
)

> # A tibble: 336,776 × 2

> arr_delay status

>

> 1 11 on time

> 2 20 late

> 3 33 late

> 4 -18 early

> 5 -25 early

> 6 12 on time

> # … with 336,770 more rows


当编写这种复杂的[`case_when()`](https://dplyr.tidyverse.org/reference/case_when.xhtml)语句时要小心;我的前两次尝试使用了`<`和`>`的混合,并且我不断地创建重叠的条件。

## 兼容的类型

注意,[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml)和[`case_when()`](https://dplyr.tidyverse.org/reference/case_when.xhtml)在输出中需要*兼容的*类型。如果它们不兼容,你会看到类似这样的错误:

if_else(TRUE, "a", 1)

> Error in if_else():

> ! Can't combine true and false .

case_when(
x < -1 ~ TRUE,
x > 0 ~ now()
)

> Error in case_when():

> ! Can't combine ..1 (right) and ..2 (right) <datetime>.


总体上,兼容的类型相对较少,因为自动将一个类型的向量转换为另一种类型是常见的错误来源。以下是一些最重要的兼容情况:

+   数字和逻辑向量是兼容的,正如我们在“逻辑向量的数值摘要”中讨论的那样。

+   字符串和因子(第十六章)是兼容的,因为你可以将因子视为具有受限值集的字符串。

+   日期和日期时间,我们将在第十七章中讨论,因为你可以将日期视为日期时间的特殊情况,所以它们是兼容的。

+   `NA`,技术上是一个逻辑向量,因为每个向量都有表示缺失值的方式,所以它与所有其他类型兼容。

我们不希望你记住这些规则,但随着时间的推移,它们应该会变得自然而然,因为它们在整个 tidyverse 中应用得很一致。

## 练习

1.  如果一个数能被两整除,那么它是偶数,在 R 中你可以用`x %% 2 == 0`来判断。利用这个事实和[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml)来确定 0 到 20 之间每个数是偶数还是奇数。

1.  给定一个类似`x <- c("Monday", "Saturday", "Wednesday")`的天数向量,使用[`ifelse()`](https://rdrr.io/r/base/ifelse.xhtml)语句将它们标记为周末或工作日。

1.  使用[`ifelse()`](https://rdrr.io/r/base/ifelse.xhtml)计算数值向量`x`的绝对值。

1.  编写一个[`case_when()`](https://dplyr.tidyverse.org/reference/case_when.xhtml)语句,使用`flights`中的`month`和`day`列来标记一些重要的美国节日(例如,新年、独立日、感恩节和圣诞节)。首先创建一个逻辑列,该列为`TRUE`或`FALSE`,然后创建一个字符列,该列要么给出假期的名称,要么是`NA`。

# 概要

逻辑向量的定义很简单,因为每个值必须是`TRUE`、`FALSE`或`NA`。但是逻辑向量提供了很大的功能。在这一章中,你学会了如何用`>`, `<`, `<=`, `=>`, `==`, `!=`和[`is.na()`](https://rdrr.io/r/base/NA.xhtml)创建逻辑向量;如何用`!`, `&`和`|`组合它们;以及如何用[`any()`](https://rdrr.io/r/base/any.xhtml)、[`all()`](https://rdrr.io/r/base/all.xhtml)、[`sum()`](https://rdrr.io/r/base/sum.xhtml)和[`mean()`](https://rdrr.io/r/base/mean.xhtml)对它们进行总结。你还学习了强大的[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml)和[`case_when()`](https://dplyr.tidyverse.org/reference/case_when.xhtml)函数,它们允许你根据逻辑向量的值返回不同的值。

在接下来的章节中我们会反复看到逻辑向量。例如,在第十四章中,你将学习`str_detect(x, pattern)`,它返回一个逻辑向量,对于与`pattern`匹配的`x`元素为`TRUE`;在第十七章中,你将通过比较日期和时间创建逻辑向量。但现在,我们将继续讨论下一个最重要的向量类型:数值向量。

¹ 在 R 中,通常`print`函数会为你调用(即,`x`是`print(x)`的一种简写),但显式调用它在你想要提供其他参数时很有用。

² 也就是说,`xor(x, y)`在`x`为真或`y`为真时为真,但不是同时为真。这通常是我们在英语中使用“或”的方式。“两者都是”通常不是对于问题“你想要冰淇淋还是蛋糕?”的可接受回答。

³ 我们将在第十九章中讨论这个。

⁴ dplyr 的 [`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml) 类似于基础 R 的 [`ifelse()`](https://rdrr.io/r/base/ifelse.xhtml)。[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml) 相较于 [`ifelse()`](https://rdrr.io/r/base/ifelse.xhtml) 有两个主要优势:你可以选择如何处理缺失值,并且如果变量类型不兼容,[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml) 更有可能给出有意义的错误提示。


# 第十三章:数字

# 介绍

数值向量是数据科学的支柱,你已经在本书的前面多次使用过它们。现在是时候系统地调查你在 R 中可以用它们做什么,以确保你能够很好地解决涉及数值向量的任何未来问题。

我们将从几个工具开始,如果你有字符串,可以将它们转换为数字,然后更详细地讨论[`count()`](https://dplyr.tidyverse.org/reference/count.xhtml)。接下来,我们将深入探讨与[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)配对的各种数字转换,包括更一般的转换,这些转换可以应用于其他类型的向量,但通常与数字向量一起使用。最后,我们将介绍与[`summarize()`](https://dplyr.tidyverse.org/reference/summarise.xhtml)配对良好的汇总函数,并展示它们如何与[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)一起使用。

## 先决条件

本章主要使用基本的 R 函数,这些函数无需加载任何包。但我们仍然需要 tidyverse,因为我们将在 tidyverse 函数内部使用这些基本的 R 函数,比如[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)和[`filter()`](https://dplyr.tidyverse.org/reference/filter.xhtml)。就像在上一章中一样,我们将使用来自 nycflights13 的真实示例,以及使用[`c()`](https://rdrr.io/r/base/c.xhtml)和[`tribble()`](https://tibble.tidyverse.org/reference/tribble.xhtml)创建的玩具示例。

library(tidyverse)
library(nycflights13)


# 制作数字

在大多数情况下,你将得到已记录在 R 的数值类型(整数或双精度)中的数字。然而,在某些情况下,你可能会遇到它们作为字符串,可能是因为你通过列标题进行了旋转,或者是因为在数据导入过程中出了问题。

readr 提供了两个将字符串解析为数字的有用函数:[`parse_double()`](https://readr.tidyverse.org/reference/parse_atomic.xhtml)和[`parse_number()`](https://readr.tidyverse.org/reference/parse_number.xhtml)。当你有以字符串形式编写的数字时,请使用[`parse_double()`](https://readr.tidyverse.org/reference/parse_atomic.xhtml):

x <- c("1.2", "5.6", "1e3")
parse_double(x)

> [1] 1.2 5.6 1000.0


当字符串包含你想要忽略的非数值文本时,请使用[`parse_number()`](https://readr.tidyverse.org/reference/parse_number.xhtml)。这对货币数据和百分比特别有用:

x <- c("$1,234", "USD 3,513", "59%")
parse_number(x)

> [1] 1234 3513 59


# 计数

仅仅通过计数和一点基本算术,你可以做多少数据科学工作,所以 dplyr 致力于通过[`count()`](https://dplyr.tidyverse.org/reference/count.xhtml)尽可能地简化计数。这个函数非常适合在分析过程中进行快速探索和检查:

flights |> count(dest)

> # A tibble: 105 × 2

> dest n

>

> 1 ABQ 254

> 2 ACK 265

> 3 ALB 439

> 4 ANC 8

> 5 ATL 17215

> 6 AUS 2439

> # … with 99 more rows


(尽管在第四章中的建议中,我们通常将[`count()`](https://dplyr.tidyverse.org/reference/count.xhtml)放在单行上,因为它通常用于控制台,用于快速检查计算是否按预期工作。)

如果您想查看最常见的值,请添加`sort = TRUE`:

flights |> count(dest, sort = TRUE)

> # A tibble: 105 × 2

> dest n

>

> 1 ORD 17283

> 2 ATL 17215

> 3 LAX 16174

> 4 BOS 15508

> 5 MCO 14082

> 6 CLT 14064

> # … with 99 more rows


要记住的是,如果想查看所有的值,可以使用`|> View()`或`|> print(n = Inf)`。

您可以使用[`group_by()`](https://dplyr.tidyverse.org/reference/group_by.xhtml)、[`summarize()`](https://dplyr.tidyverse.org/reference/summarise.xhtml)和[`n()`](https://dplyr.tidyverse.org/reference/context.xhtml)手动执行相同的计算。这很有用,因为它允许您同时计算其他摘要信息:

flights |>
group_by(dest) |>
summarize(
n = n(),
delay = mean(arr_delay, na.rm = TRUE)
)

> # A tibble: 105 × 3

> dest n delay

>

> 1 ABQ 254 4.38

> 2 ACK 265 4.85

> 3 ALB 439 14.4

> 4 ANC 8 -2.5

> 5 ATL 17215 11.3

> 6 AUS 2439 6.02

> # … with 99 more rows


[`n()`](https://dplyr.tidyverse.org/reference/context.xhtml)是一个特殊的汇总函数,不接受任何参数,而是访问有关“当前”组的信息。这意味着它仅在 dplyr 动词内部工作:

n()

> Error in n():

> ! Must only be used inside data-masking verbs like mutate(),

> filter(), and group_by().


[`n()`](https://dplyr.tidyverse.org/reference/context.xhtml)和[`count()`](https://dplyr.tidyverse.org/reference/count.xhtml)有几个变体可能对您有用:

+   `n_distinct(x)`计算一个或多个变量的不同(唯一)值的数量。例如,我们可以弄清楚哪些目的地由最多的航空公司服务:

    ```
    flights |> 
      group_by(dest) |> 
      summarize(carriers = n_distinct(carrier)) |> 
      arrange(desc(carriers))
    #> # A tibble: 105 × 2
    #>   dest  carriers
    #>   <chr>    <int>
    #> 1 ATL          7
    #> 2 BOS          7
    #> 3 CLT          7
    #> 4 ORD          7
    #> 5 TPA          7
    #> 6 AUS          6
    #> # … with 99 more rows
    ```

+   加权计数是一个总和。例如,您可以“计算”每架飞机飞行的英里数:

    ```
    flights |> 
      group_by(tailnum) |> 
      summarize(miles = sum(distance))
    #> # A tibble: 4,044 × 2
    #>   tailnum  miles
    #>   <chr>    <dbl>
    #> 1 D942DN    3418
    #> 2 N0EGMQ  250866
    #> 3 N10156  115966
    #> 4 N102UW   25722
    #> 5 N103US   24619
    #> 6 N104UW   25157
    #> # … with 4,038 more rows
    ```

    加权计数是一个常见的问题,因此[`count()`](https://dplyr.tidyverse.org/reference/count.xhtml)具有`wt`参数,执行相同的操作:

    ```
    flights |> count(tailnum, wt = distance)
    ```

+   通过组合[`sum()`](https://rdrr.io/r/base/sum.xhtml)和[`is.na()`](https://rdrr.io/r/base/NA.xhtml),可以统计缺失值。在`flights`数据集中,这表示取消的航班:

    ```
    flights |> 
      group_by(dest) |> 
      summarize(n_cancelled = sum(is.na(dep_time))) 
    #> # A tibble: 105 × 2
    #>   dest  n_cancelled
    #>   <chr>       <int>
    #> 1 ABQ             0
    #> 2 ACK             0
    #> 3 ALB            20
    #> 4 ANC             0
    #> 5 ATL           317
    #> 6 AUS            21
    #> # … with 99 more rows
    ```

## 练习

1.  您如何使用[`count()`](https://dplyr.tidyverse.org/reference/count.xhtml)计算具有给定变量的缺失值的行数?

1.  将以下对[`count()`](https://dplyr.tidyverse.org/reference/count.xhtml)的调用扩展为使用[`group_by()`](https://dplyr.tidyverse.org/reference/group_by.xhtml)、[`summarize()`](https://dplyr.tidyverse.org/reference/summarise.xhtml)和[`arrange()`](https://dplyr.tidyverse.org/reference/arrange.xhtml):

    1.  `flights |> count(dest, sort = TRUE)`

    1.  `flights |> count(tailnum, wt = distance)`

# 数值转换

转换函数与[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)很搭配,因为它们的输出与输入长度相同。绝大多数转换函数已经内置在基础 R 中。列出它们是不切实际的,因此本节将展示最有用的函数。例如,虽然 R 提供了您可能梦想的所有三角函数,但我们在这里没有列出它们,因为在数据科学中很少需要它们。

## 算术和循环规则

我们在第二章中介绍了算术的基础(`+`, `-`, `*`, `/`, `^`),并在之后的很多地方使用过它们。这些函数不需要太多的解释,因为它们就是你在小学学到的内容。但我们需要简要谈谈*循环规则*,这些规则决定了当左右两侧的长度不同时会发生什么。这对于像 `flights |> mutate(air_time = air_time / 60)` 这样的操作非常重要,因为左侧有 336,776 个数字,但右侧只有 1 个。

R 通过 *循环* 或重复来处理不匹配的长度。如果我们在数据框之外创建一些向量,我们可以更容易地看到它的运作方式:

x <- c(1, 2, 10, 20)
x / 5

> [1] 0.2 0.4 2.0 4.0

is shorthand for

x / c(5, 5, 5, 5)

> [1] 0.2 0.4 2.0 4.0


通常情况下,你只想要循环单个数字(即长度为 1 的向量),但 R 会循环任何较短的向量。如果较长的向量不是较短向量的整数倍,通常(但不总是)会给出一个警告:

x * c(1, 2)

> [1] 1 4 10 40

x * c(1, 2, 3)

> Warning in x * c(1, 2, 3): longer object length is not a multiple of shorter

> object length

> [1] 1 4 30 20


这些循环规则也适用于逻辑比较(`==`, `<`, `<=`, `>`, `>=`, `!=`),如果你意外地使用 `==` 而不是 `%in%`,并且数据框的行数不恰当,可能会导致令人惊讶的结果。例如,看下这段代码,试图找出所有一月和二月的航班:

flights |>
filter(month == c(1, 2))

> # A tibble: 25,977 × 19

> year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time

>

> 1 2013 1 1 517 515 2 830 819

> 2 2013 1 1 542 540 2 923 850

> 3 2013 1 1 554 600 -6 812 837

> 4 2013 1 1 555 600 -5 913 854

> 5 2013 1 1 557 600 -3 838 846

> 6 2013 1 1 558 600 -2 849 851

> # … with 25,971 more rows, and 11 more variables: arr_delay ,

> # carrier , flight , tailnum , origin , dest , …


代码可以无误地运行,但它并没有返回你想要的结果。因为循环规则的存在,它会找到奇数行中在一月出发的航班和偶数行中在二月出发的航班。不幸的是,由于 `flights` 有偶数行,所以并不会有任何警告。

为了保护你免受这种无声失败,大多数 tidyverse 函数使用一种更严格的循环形式,只会循环单个值。不幸的是,在这里或其他许多情况下,这并没有起到作用,因为关键的计算是由基本的 R 函数 `==` 而不是 [`filter()`](https://dplyr.tidyverse.org/reference/filter.xhtml) 执行的。

## 最小值和最大值

算术函数可以使用变量对。两个密切相关的函数是 [`pmin()`](https://rdrr.io/r/base/Extremes.xhtml) 和 [`pmax()`](https://rdrr.io/r/base/Extremes.xhtml),当给定两个或多个变量时,它们将返回每行中的最小值或最大值:

df <- tribble(
~x, ~y,
1, 3,
5, 2,
7, NA,
)

df |>
mutate(
min = pmin(x, y, na.rm = TRUE),
max = pmax(x, y, na.rm = TRUE)
)

> # A tibble: 3 × 4

> x y min max

>

> 1 1 3 1 3

> 2 5 2 2 5

> 3 7 NA 7 7


注意,这些函数与 [`min()`](https://rdrr.io/r/base/Extremes.xhtml) 和 [`max()`](https://rdrr.io/r/base/Extremes.xhtml) 这些汇总函数不同,后者取多个观察值并返回一个值。如果所有的最小值和最大值都相同,那么你可以知道已经选择了错误的形式:

df |>
mutate(
min = min(x, y, na.rm = TRUE),
max = max(x, y, na.rm = TRUE)
)

> # A tibble: 3 × 4

> x y min max

>

> 1 1 3 1 7

> 2 5 2 1 7

> 3 7 NA 1 7


## 模运算

模运算是你在学习小数位之前所做的数学的技术名称,即能够得到一个整数和余数的除法。在 R 语言中,`%/%` 执行整数除法,而 `%%` 计算余数:

1:10 %/% 3

> [1] 0 0 1 1 1 2 2 2 3 3

1:10 %% 3

> [1] 1 2 0 1 2 0 1 2 0 1


对于 `flights` 数据集来说,模运算对于解压 `sched_dep_time` 变量为 `hour` 和 `minute` 很方便:

flights |>
mutate(
hour = sched_dep_time %/% 100,
minute = sched_dep_time %% 100,
.keep = "used"
)

> # A tibble: 336,776 × 3

> sched_dep_time hour minute

>

> 1 515 5 15

> 2 529 5 29

> 3 540 5 40

> 4 545 5 45

> 5 600 6 0

> 6 558 5 58

> # … with 336,770 more rows


我们可以将其与“总结”中的`mean(is.na(x))`技巧结合起来,以查看一天中取消航班的比例如何变化。结果显示在图 13-1 中。

flights |>
group_by(hour = sched_dep_time %/% 100) |>
summarize(prop_cancelled = mean(is.na(dep_time)), n = n()) |>
filter(hour > 1) |>
ggplot(aes(x = hour, y = prop_cancelled)) +
geom_line(color = "grey50") +
geom_point(aes(size = n))


![一条折线图显示一天中取消航班比例的变化。这个比例从早上 6 点的约 0.5%开始,然后在一天的过程中稳步增加,直到晚上 7 点的 4%左右。然后,取消航班的比例迅速下降,到午夜左右约为 1%。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1301.png)

###### 图 13-1\. 一条折线图,x 轴为计划出发小时,y 轴为取消航班的比例。取消似乎随着一天的进行而累积,直到晚上 8 点,而非常晚的航班则取消的可能性要小得多。

## 对数

对数是处理跨越多个数量级的数据和将指数增长转换为线性增长的非常有用的转换。在 R 中,您可以选择三种对数:[`log()`](https://rdrr.io/r/base/Log.xhtml)(自然对数,以 e 为底),[`log2()`](https://rdrr.io/r/base/Log.xhtml)(以 2 为底),以及[`log10()`](https://rdrr.io/r/base/Log.xhtml)(以 10 为底)。我们推荐使用[`log2()`](https://rdrr.io/r/base/Log.xhtml)或[`log10()`](https://rdrr.io/r/base/Log.xhtml)。[`log2()`](https://rdrr.io/r/base/Log.xhtml)易于解释,因为对数尺度上的 1 的差异对应于原始尺度上的加倍,-1 的差异对应于减半,而[`log10()`](https://rdrr.io/r/base/Log.xhtml)易于反向转换,例如,3 是 10³ = 1000。[`log()`](https://rdrr.io/r/base/Log.xhtml)的反函数是[`exp()`](https://rdrr.io/r/base/Log.xhtml);要计算[`log2()`](https://rdrr.io/r/base/Log.xhtml)或[`log10()`](https://rdrr.io/r/base/Log.xhtml)的反函数,您需要使用`2^`或`10^`。

## 舍入

使用`round(x)`将数字舍入到最近的整数:

round(123.456)

> [1] 123


您可以使用第二个参数`digits`来控制舍入的精度。`round(x, digits)`会舍入到最近的`10^-n`,因此`digits = 2`将会舍入到最近的 0.01。这个定义很有用,因为它意味着`round(x, -3)`将会舍入到最近的千位数,实际上确实是这样:

round(123.456, 2) # two digits

> [1] 123.46

round(123.456, 1) # one digit

> [1] 123.5

round(123.456, -1) # round to nearest ten

> [1] 120

round(123.456, -2) # round to nearest hundred

> [1] 100


[`round()`](https://rdrr.io/r/base/Round.xhtml) 在第一次看起来似乎有些奇怪:

round(c(1.5, 2.5))

> [1] 2 2


[`round()`](https://rdrr.io/r/base/Round.xhtml) 使用的是“四舍五入到偶数”或“银行家舍入”:如果一个数字正好处于两个整数之间,它将被舍入到*偶数*整数。这是一个很好的策略,因为它保持了舍入的公正性:所有的 0.5 都会被舍入为上整数和下整数的一半。

[`round()`](https://rdrr.io/r/base/Round.xhtml) 与[`floor()`](https://rdrr.io/r/base/Round.xhtml)(总是向下舍入)和[`ceiling()`](https://rdrr.io/r/base/Round.xhtml)(总是向上舍入)配对使用:

x <- 123.456

floor(x)

> [1] 123

ceiling(x)

> [1] 124


这些函数没有`digits`参数,因此您可以缩小、四舍五入,然后再放大:

Round down to nearest two digits

floor(x / 0.01) * 0.01

> [1] 123.45

Round up to nearest two digits

ceiling(x / 0.01) * 0.01

> [1] 123.46


如果你想要对一个数四舍五入到另一个数的倍数,可以使用相同的技巧:

Round to nearest multiple of 4

round(x / 4) * 4

> [1] 124

Round to nearest 0.25

round(x / 0.25) * 0.25

> [1] 123.5


## 将数字切割成范围

使用[`cut()`](https://rdrr.io/r/base/cut.xhtml)¹将数值向量分成离散的桶(又称*bin*):

x <- c(1, 2, 5, 10, 15, 20)
cut(x, breaks = c(0, 5, 10, 15, 20))

> [1] (0,5] (0,5] (0,5] (5,10] (10,15] (15,20]

> Levels: (0,5] (5,10] (10,15] (15,20]


断点不需要均匀间隔:

cut(x, breaks = c(0, 5, 10, 100))

> [1] (0,5] (0,5] (0,5] (5,10] (10,100] (10,100]

> Levels: (0,5] (5,10] (10,100]


您可以选择提供自己的`labels`。注意`labels`应比`breaks`少一个。

cut(x,
breaks = c(0, 5, 10, 15, 20),
labels = c("sm", "md", "lg", "xl")
)

> [1] sm sm sm md lg xl

> Levels: sm md lg xl


超出断点范围的任何值将变为`NA`:

y <- c(NA, -10, 5, 10, 30)
cut(y, breaks = c(0, 5, 10, 15, 20))

> [1] (0,5] (5,10]

> Levels: (0,5] (5,10] (10,15] (15,20]


查看其他有用参数的文档,例如`right`和`include.lowest`,它们控制间隔是`[a, b)`还是`(a, b]`以及最低间隔是否应为`[a, b]`。

## 累积和滚动聚合

基础 R 提供[`cumsum()`](https://rdrr.io/r/base/cumsum.xhtml),[`cumprod()`](https://rdrr.io/r/base/cumsum.xhtml),[`cummin()`](https://rdrr.io/r/base/cumsum.xhtml),以及[`cummax()`](https://rdrr.io/r/base/cumsum.xhtml)用于累积求和、乘积、最小值和最大值。dplyr 提供[`cummean()`](https://dplyr.tidyverse.org/reference/cumall.xhtml)用于累积均值。累积求和在实践中经常出现:

x <- 1:10
cumsum(x)

> [1] 1 3 6 10 15 21 28 36 45 55


如果需要更复杂的滚动或滑动聚合,请尝试[slider 包](https://oreil.ly/XPnjF)。

## 练习

1.  解释用于生成图 13-1 的每行代码的功能。

1.  R 提供哪些三角函数?猜测一些名称并查阅文档。它们使用角度还是弧度?

1.  目前`dep_time`和`sched_dep_time`方便查看,但难以计算,因为它们不是真正的连续数。可以通过运行以下代码看到基本问题;每个小时之间存在间隙:

    ```
    flights |> 
      filter(month == 1, day == 1) |> 
      ggplot(aes(x = sched_dep_time, y = dep_delay)) +
      geom_point()
    ```

    将它们转换为更真实的时间表示(分数小时或自午夜以来的分钟)。

1.  将`dep_time`和`arr_time`四舍五入到最接近的五分钟。

# 一般转换

下面的部分描述了一些通常用于数值向量的一般转换,但可以应用于所有其他列类型。

## 等级

dplyr 提供了许多受 SQL 启发的排名函数,但您应始终从[`dplyr::min_rank()`](https://dplyr.tidyverse.org/reference/row_number.xhtml)开始。它使用处理并列的典型方法,例如,第 1、第 2、第 2、第 4。

x <- c(1, 2, 2, 3, 4, NA)
min_rank(x)

> [1] 1 2 2 4 5 NA


注意,最小值获得最低的等级;使用`desc(x)`将最大值获得最小的等级:

min_rank(desc(x))

> [1] 5 3 3 2 1 NA


如果 [`min_rank()`](https://dplyr.tidyverse.org/reference/row_number.xhtml) 不符合你的需求,请查看其变体 [`dplyr::row_number()`](https://dplyr.tidyverse.org/reference/row_number.xhtml),[`dplyr::dense_rank()`](https://dplyr.tidyverse.org/reference/row_number.xhtml),[`dplyr::percent_rank()`](https://dplyr.tidyverse.org/reference/percent_rank.xhtml) 和 [`dplyr::cume_dist()`](https://dplyr.tidyverse.org/reference/percent_rank.xhtml)。详细信息请参阅文档。

df <- tibble(x = x)
df |>
mutate(
row_number = row_number(x),
dense_rank = dense_rank(x),
percent_rank = percent_rank(x),
cume_dist = cume_dist(x)
)

> # A tibble: 6 × 5

> x row_number dense_rank percent_rank cume_dist

>

> 1 1 1 1 0 0.2

> 2 2 2 2 0.25 0.6

> 3 2 3 2 0.25 0.6

> 4 3 4 3 0.75 0.8

> 5 4 5 4 1 1

> 6 NA NA NA NA NA


你可以通过选择适当的 `ties.method` 参数来实现与基础 R 的 [`rank()`](https://rdrr.io/r/base/rank.xhtml) 类似的结果;你可能还希望设置 `na.last = "keep"` 来保留 `NA` 作为 `NA`。

[`row_number()`](https://dplyr.tidyverse.org/reference/row_number.xhtml) 在 dplyr 动词内部没有任何参数时也可以使用。在这种情况下,它将给出“当前”行的编号。与 `%%` 或 `%/%` 结合使用时,这对于将数据分成大小相似的组是一个有用的工具:

df <- tibble(id = 1:10)

df |>
mutate(
row0 = row_number() - 1,
three_groups = row0 %% 3,
three_in_each_group = row0 %/% 3
)

> # A tibble: 10 × 4

> id row0 three_groups three_in_each_group

>

> 1 1 0 0 0

> 2 2 1 1 0

> 3 3 2 2 0

> 4 4 3 0 1

> 5 5 4 1 1

> 6 6 5 2 1

> # … with 4 more rows


## 偏移

[`dplyr::lead()`](https://dplyr.tidyverse.org/reference/lead-lag.xhtml) 和 [`dplyr::lag()`](https://dplyr.tidyverse.org/reference/lead-lag.xhtml) 允许你引用“当前”值之前或之后的值。它们返回与输入相同长度的向量,在开头或结尾用 `NA` 填充:

x <- c(2, 5, 11, 11, 19, 35)
lag(x)

> [1] NA 2 5 11 11 19

lead(x)

> [1] 5 11 11 19 35 NA


+   `x - lag(x)` 给出当前值与前一个值之间的差异:

    ```
    x - lag(x)
    #> [1] NA  3  6  0  8 16
    ```

+   `x == lag(x)` 告诉你当前值何时发生变化:

    ```
    x == lag(x)
    #> [1]    NA FALSE FALSE  TRUE FALSE FALSE
    ```

你可以通过使用第二个参数 `n` 来引导或滞后超过一个位置:

## 连续标识符

有时候你希望在某个事件发生时开始一个新的组。例如,在查看网站数据时,常常希望将事件分割成会话,即在距离上次活动超过 `x` 分钟的间隔后开始一个新会话。例如,假设你有访问网站时间的记录:

events <- tibble(
time = c(0, 1, 2, 3, 5, 10, 12, 15, 17, 19, 20, 27, 28, 30)
)


你已经计算了每个事件之间的时间,并找出是否存在足够大的间隙来满足条件:

events <- events |>
mutate(
diff = time - lag(time, default = first(time)),
has_gap = diff >= 5
)
events

> # A tibble: 14 × 3

> time diff has_gap

>

> 1 0 0 FALSE

> 2 1 1 FALSE

> 3 2 1 FALSE

> 4 3 1 FALSE

> 5 5 2 FALSE

> 6 10 5 TRUE

> # … with 8 more rows


但是,如何将逻辑向量转换为可以 [`group_by()`](https://dplyr.tidyverse.org/reference/group_by.xhtml) 的内容?[`cumsum()`](https://rdrr.io/r/base/cumsum.xhtml),来自“Cumulative and Rolling Aggregates”,在存在间隙时,即 `has_gap` 为 `TRUE` 时,将组递增一次(“Numeric Summaries of Logical Vectors”](ch12.xhtml#sec-numeric-summaries-of-logicals)):

events |> mutate(
group = cumsum(has_gap)
)

> # A tibble: 14 × 4

> time diff has_gap group

>

> 1 0 0 FALSE 0

> 2 1 1 FALSE 0

> 3 2 1 FALSE 0

> 4 3 1 FALSE 0

> 5 5 2 FALSE 0

> 6 10 5 TRUE 1

> # … with 8 more rows


创建分组变量的另一种方法是 [`consecutive_id()`](https://dplyr.tidyverse.org/reference/consecutive_id.xhtml),它在其参数变化时开始一个新的组。例如,受[这个 StackOverflow 问题](https://oreil.ly/swerV)的启发,假设你有一个包含大量重复值的数据框:

df <- tibble(
x = c("a", "a", "a", "b", "c", "c", "d", "e", "a", "a", "b", "b"),
y = c(1, 2, 3, 2, 4, 1, 3, 9, 4, 8, 10, 199)
)


如果你想保留每个重复的`x`的第一行,可以使用[`group_by()`](https://dplyr.tidyverse.org/reference/group_by.xhtml)、[`consecutive_id()`](https://dplyr.tidyverse.org/reference/consecutive_id.xhtml)和[`slice_head()`](https://dplyr.tidyverse.org/reference/slice.xhtml):

df |>
group_by(id = consecutive_id(x)) |>
slice_head(n = 1)

> # A tibble: 7 × 3

> # Groups: id [7]

> x y id

>

> 1 a 1 1

> 2 b 2 2

> 3 c 4 3

> 4 d 3 4

> 5 e 9 5

> 6 a 4 6

> # … with 1 more row


## 练习

1.  使用排名函数找出延误最严重的 10 个航班。你想如何处理并列情况?仔细阅读[`min_rank()`](https://dplyr.tidyverse.org/reference/row_number.xhtml)的文档。

1.  哪架飞机(`tailnum`)的准点记录最差?

1.  如果你希望尽可能避免延误,你应该在一天中的什么时段飞行?

1.  `flights |> group_by(dest) |> filter(row_number() < 4)`是什么意思?`flights |> group_by(dest) |> filter(row_number(dep_delay) < 4)`又是什么意思?

1.  对于每个目的地,计算延误的总分钟数。对于每次航班,计算其目的地总延误的比例。

1.  延误通常具有时间相关性:即使解决了导致初始延误的问题,稍后的航班也会延误以便让早期的航班起飞。使用[`lag()`](https://dplyr.tidyverse.org/reference/lead-lag.xhtml),探索每小时的平均航班延误与前一个小时的平均延误之间的关系。

    ```
    flights |> 
      mutate(hour = dep_time %/% 100) |> 
      group_by(year, month, day, hour) |> 
      summarize(
        dep_delay = mean(dep_delay, na.rm = TRUE),
        n = n(),
        .groups = "drop"
      ) |> 
      filter(n > 5)
    ```

1.  查看每个目的地。你能找到那些飞行速度异常快的航班吗(即可能表示数据输入错误的航班)?计算每个目的地的航空时间相对于最短航班的情况。哪些航班在空中延误最严重?

1.  找到所有由至少两家航空公司飞行的目的地。利用这些目的地来根据相同目的地的表现对航空公司进行相对排名。

# 数字摘要

仅仅使用我们已经介绍过的计数、均值和求和就可以走得很远,但是 R 提供了许多其他有用的摘要函数。以下是一些你可能会发现有用的选择。

## 中心

到目前为止,我们大多数使用[`mean()`](https://rdrr.io/r/base/mean.xhtml)来总结值向量的中心。正如我们在“案例研究:聚合和样本大小”中所看到的,因为均值是总和除以计数,它对于少数异常高或低值也很敏感。另一个选择是使用[`median()`](https://rdrr.io/r/stats/median.xhtml),它找到一个位于向量“中间”的值,即有 50%的值高于它,50%的值低于它。根据你感兴趣的变量分布形状,均值或中位数可能是更好的中心测量。例如,对于对称分布,通常报告均值,而对于偏斜分布,通常报告中位数。

图 13-2 比较每个目的地的平均和中位离港延误时间(以分钟为单位)。中位数延误时间始终小于平均延误时间,因为航班有时会晚数小时,但它们不会提前数小时。

flights |>
group_by(year, month, day) |>
summarize(
mean = mean(dep_delay, na.rm = TRUE),
median = median(dep_delay, na.rm = TRUE),
n = n(),
.groups = "drop"
) |>
ggplot(aes(x = mean, y = median)) +
geom_abline(slope = 1, intercept = 0, color = "white", linewidth = 2) +
geom_point()


![所有点都落在 45°线以下,这意味着中位数延误始终小于平均延误。大多数点聚集在平均值 [0, 20] 和中位数 [0, 5] 的密集区域。随着平均延误的增加,中位数的散布也增加。有两个离群点,平均延误约为 60,中位数约为 50,平均延误约为 85,中位数约为 55。](assets/rds2_1302.png)

###### 图 13-2\. 散点图显示了使用中位数而不是平均数总结每小时离港延误的差异。

您可能还想知道*众数*,即最常见的值。这是一个仅对非常简单情况有效的摘要(这就是为什么您可能在高中学习过它),但它不适用于许多实际数据集。如果数据是离散的,则可能存在多个最常见值,如果数据是连续的,则可能不存在最常见值,因为每个值都可能略有不同。因此,统计学家不倾向于使用众数,并且基本的 R 中也没有众数功能。²

## 最小值、最大值和分位数

如果您对中心以外的位置感兴趣,会怎样?[`min()`](https://rdrr.io/r/base/Extremes.xhtml) 和 [`max()`](https://rdrr.io/r/base/Extremes.xhtml) 将为您提供最大和最小值。另一个强大的工具是 [`quantile()`](https://rdrr.io/r/stats/quantile.xhtml),它是中位数的一般化:`quantile(x, 0.25)` 将找到大于 25%值的 `x` 值,`quantile(x, 0.5)` 等同于中位数,而 `quantile(x, 0.95)` 将找到大于 95%值的值。

对于`flights`数据,您可能希望查看延误的 95%分位数,而不是最大值,因为它会忽略 5%的最延误航班,这可能相当极端。

flights |>
group_by(year, month, day) |>
summarize(
max = max(dep_delay, na.rm = TRUE),
q95 = quantile(dep_delay, 0.95, na.rm = TRUE),
.groups = "drop"
)

> # A tibble: 365 × 5

> year month day max q95

>

> 1 2013 1 1 853 70.1

> 2 2013 1 2 379 85

> 3 2013 1 3 291 68

> 4 2013 1 4 288 60

> 5 2013 1 5 327 41

> 6 2013 1 6 202 51

> # … with 359 more rows


## 散布

有时您对数据的大部分位于何处不感兴趣,而是关心其如何分布。两个常用的摘要是标准差,`sd(x)`,和四分位距,[`IQR()`](https://rdrr.io/r/stats/IQR.xhtml)。我们不会在这里解释[`sd()`](https://rdrr.io/r/stats/sd.xhtml),因为您可能已经熟悉它,但[`IQR()`](https://rdrr.io/r/stats/IQR.xhtml)可能是新的——它是`quantile(x, 0.75) - quantile(x, 0.25)`,给出包含数据中间 50%的范围。

我们可以使用这个来揭示`flights`数据中的一个小怪现象。您可能期望起始点和目的地之间的距离分布为零,因为机场始终在同一位置。但以下代码显示了有关机场[EGE](https://oreil.ly/Zse1Q)的数据异常:

flights |>
group_by(origin, dest) |>
summarize(
distance_sd = IQR(distance),
n = n(),
.groups = "drop"
) |>
filter(distance_sd > 0)

> # A tibble: 2 × 4

> origin dest distance_sd n

>

> 1 EWR EGE 1 110

> 2 JFK EGE 1 103


## 分布

记住,前面描述的所有汇总统计都是将分布简化为一个单一数字的方法。这意味着它们基本上是还原的,如果选择了错误的汇总统计,可能会轻易地忽略掉不同组之间的重要差异。这就是为什么在确定汇总统计之前,始终建议先可视化分布的原因。

图 13-3 显示了出发延误的整体分布。分布如此倾斜,以至于我们必须放大才能看到大部分数据。这表明平均值不太可能是一个好的汇总,我们可能更喜欢中位数。

![`dep_delay`的两个直方图。左边,除了在零附近有一个非常大的峰值外,几乎看不到任何模式,柱状图的高度迅速下降,并且大部分情况下,你看不到任何柱状图,因为它们太短了。右边,我们丢弃了超过两小时的延误,可以看到峰值略低于零(即大多数航班提前几分钟离开),但之后仍然有非常陡的下降。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1303.png)

###### 图 13-3。 (左)完整数据的直方图极度偏斜,很难得到任何细节。(右)缩放到延误小于两小时,可以看到大部分观测数据的情况。

同时,检查子组的分布是否类似于整体分布也是一个好主意。在下面的图中,365 个`dep_delay`的频率多边形,每天一个,重叠在一起。分布似乎遵循一个共同的模式,表明可以为每天使用相同的汇总方法。

flights |>
filter(dep_delay < 120) |>
ggplot(aes(x = dep_delay, group = interaction(day, month))) +
geom_freqpoly(binwidth = 5, alpha = 1/5)


![`dep_delay`的分布是右偏的,略低于 0,有一个明显的峰值。365 个频率多边形大部分重叠,形成一个厚重的黑色带。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_13in01.png)

不要害怕探索适用于你正在处理的数据的自定义汇总方法。在这种情况下,可能意味着分别汇总提前离开和延迟离开的航班,或者考虑到值非常倾斜,可以尝试对数转换。最后,不要忘记在“案例研究:聚合和样本大小”中学到的内容:每当创建数值汇总时,最好包括每个组中的观测数。

## 位置

还有一种最终的汇总类型对于数值向量很有用,但也适用于任何其他类型的值:提取特定位置的值:`first(x)`、`last(x)` 和 `nth(x, n)`。

例如,我们可以找到每天的第一个和最后一个出发时间:

flights |>
group_by(year, month, day) |>
summarize(
first_dep = first(dep_time, na_rm = TRUE),
fifth_dep = nth(dep_time, 5, na_rm = TRUE),
last_dep = last(dep_time, na_rm = TRUE)
)

> summarise() has grouped output by 'year', 'month'. You can override using

> the .groups argument.

> # A tibble: 365 × 6

> # Groups: year, month [12]

> year month day first_dep fifth_dep last_dep

>

> 1 2013 1 1 517 554 2356

> 2 2013 1 2 42 535 2354

> 3 2013 1 3 32 520 2349

> 4 2013 1 4 25 531 2358

> 5 2013 1 5 14 534 2357

> 6 2013 1 6 16 555 2355

> # … with 359 more rows


(请注意,因为 dplyr 函数使用 `_` 来分隔函数和参数名称的组件,这些函数使用 `na_rm` 而不是 `na.rm`。)

如果您熟悉 ``,我们将在 [“使用 [ 选择多个元素” 中回到它,您可能会想知道是否需要这些函数。有三个理由:`default` 参数允许您在指定的位置不存在时提供默认值,`order_by` 参数允许您在本地覆盖行的顺序,`na_rm` 参数允许您删除缺失值。

在位置值提取与排名过滤互补。过滤会给您所有的变量,每个观察值分别在一行中:

flights |>
group_by(year, month, day) |>
mutate(r = min_rank(sched_dep_time)) |>
filter(r %in% c(1, max(r)))

> # A tibble: 1,195 × 20

> # Groups: year, month, day [365]

> year month day dep_time sched_dep_time dep_delay arr_time sched_arr_time

>

> 1 2013 1 1 517 515 2 830 819

> 2 2013 1 1 2353 2359 -6 425 445

> 3 2013 1 1 2353 2359 -6 418 442

> 4 2013 1 1 2356 2359 -3 425 437

> 5 2013 1 2 42 2359 43 518 442

> 6 2013 1 2 458 500 -2 703 650

> # … with 1,189 more rows, and 12 more variables: arr_delay ,

> # carrier , flight , tailnum , origin , dest , …


## 使用 mutate()

正如名称所示,摘要函数通常与 [`summarize()`](https://dplyr.tidyverse.org/reference/summarise.xhtml) 配对使用。但是,由于我们在 “算术和回收规则” 中讨论的回收规则,它们也可以与 [`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml) 有用地配对使用,特别是当您想要进行某种组标准化时。例如:

`x / sum(x)`

计算总量的比例。

`(x - mean(x)) / sd(x)`

计算 Z 得分(标准化为均值 0 和标准偏差 1)。

`(x - min(x)) / (max(x) - min(x))`

标准化为范围 [0, 1]。

`x / first(x)`

基于第一个观察值计算指数。

## 练习

1.  对一组航班的典型延迟特征进行至少五种评估方式的头脑风暴。[`mean()`](https://rdrr.io/r/base/mean.xhtml) 什么时候有用?[`median()`](https://rdrr.io/r/stats/median.xhtml) 什么时候有用?什么时候可能需要使用其他方法?应该使用到达延误还是出发延误?为什么可能要使用 `planes` 的数据?

1.  哪些目的地显示出空速变化最大?

1.  创建一个图表来进一步探索 EGE 的冒险。您能找到机场是否搬迁的证据吗?您能找到另一个可能解释差异的变量吗?

# 摘要

您已经熟悉了许多处理数字的工具,在阅读本章后,您现在知道如何在 R 中使用它们。您还学习了一些有用的一般转换,这些转换通常但不专门应用于数值向量,例如排名和偏移量。最后,您处理了许多数字摘要,并讨论了一些您应该考虑的统计挑战。

在接下来的两章中,我们将深入介绍使用 stringr 包处理字符串。字符串是一个大主题,因此它们有两章,一章讲述字符串的基础知识,另一章讲述正则表达式。

¹ ggplot2 在 [`cut_interval()`](https://ggplot2.tidyverse.org/reference/cut_interval.xhtml)、[`cut_number()`](https://ggplot2.tidyverse.org/reference/cut_interval.xhtml) 和 [`cut_width()`](https://ggplot2.tidyverse.org/reference/cut_interval.xhtml) 中提供了一些常见情况的辅助功能。ggplot2 作为这些函数存在的地方确实有些奇怪,但它们作为直方图计算的一部分非常有用,并且在 tidyverse 的其他部分存在之前就已编写完成。

² [`mode()`](https://rdrr.io/r/base/mode.xhtml) 函数的功能完全不同!


# 第十四章:字符串

# 引言

到目前为止,你已经使用了大量的字符串,但没有学到太多细节。现在是时候深入研究它们,了解字符串的工作原理,并掌握一些强大的字符串操作工具了。

我们将从创建字符串和字符向量的详细信息开始。然后,你将深入学习如何从数据创建字符串,然后是相反操作:从数据中提取字符串。然后我们将讨论处理单个字母的工具。本章最后讨论处理单个字母的函数,并简要讨论在处理其他语言时,你对英语的期望可能会导致错误。

我们将在下一章继续使用字符串,您将更多地了解正则表达式的威力。

## 先决条件

在本章中,我们将使用 stringr 包中的函数,这是核心 tidyverse 的一部分。我们还将使用 babynames 数据,因为它提供了一些有趣的字符串来操作。

library(tidyverse)
library(babynames)


你可以快速判断你是否在使用 stringr 函数,因为所有的 stringr 函数都以`str_`开头。如果你使用 RStudio 的话,这特别有用,因为输入`str_`将会触发自动完成,让你能够回忆起可用函数。

![在 RStudio 控制台中键入 str_c 并显示自动完成提示,提示显示以 str_c 开头的函数列表。右侧面板显示了自动完成列表中突出显示的函数的函数签名和主页面的开头。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_14in01.png)

# 创建字符串

我们在本书的早些时候就创建了字符串,但没有讨论细节。首先,你可以使用单引号(`'`)或双引号(`"`)来创建字符串。两者之间在行为上没有区别,因此为了一致性起见,[tidyverse 风格指南](https://oreil.ly/_zF3d)建议使用`"`,除非字符串包含多个`"`。

string1 <- "This is a string"
string2 <- 'If I want to include a "quote" inside a string, I use single quotes'


如果你忘记关闭引号,你将看到`+`,即续行提示:

"This is a string without a closing quote

  • HELP I'M STUCK IN A STRING

如果发生这种情况,你无法确定哪个引号是要关闭的,请按 Escape 键取消然后重试。

## 转义字符

要在字符串中包含字面的单引号或双引号,你可以使用`\`来“转义”它:

double_quote <- """ # or '"'
single_quote <- ''' # or "'"


所以如果你想在字符串中包含字面的反斜杠,你需要转义它:`"\\"`:

backslash <- "\"


注意,字符串的打印表示与字符串本身不同,因为打印表示会显示转义(换句话说,当你打印一个字符串时,你可以复制并粘贴输出来重新创建该字符串)。要查看字符串的原始内容,请使用[`str_view()`](https://stringr.tidyverse.org/reference/str_view.xhtml):¹

x <- c(single_quote, double_quote, backslash)
x

> [1] "'" """ "\"

str_view(x)

> [1] │ '

> [2] │ "

> [3] │ \


## 原始字符串

创建一个包含多个引号或反斜杠的字符串会很快令人困惑。为了说明这个问题,让我们创建一个包含代码块内容的字符串,其中定义了`double_quote`和`single_quote`变量:

tricky <- "double_quote <- "\"" # or '"'
single_quote <- '\'' # or "'""
str_view(tricky)

> [1] │ double_quote <- """ # or '"'

> │ single_quote <- ''' # or "'"


那是很多反斜杠!(有时这被称为[倾斜牙签综合症](https://oreil.ly/Fs-YL)。)为了消除转义,您可以使用*原始字符串*代替:²

tricky <- r"(double_quote <- """ # or '"'
single_quote <- ''' # or "'")"
str_view(tricky)

> [1] │ double_quote <- """ # or '"'

> │ single_quote <- ''' # or "'"


原始字符串通常以`r"(`开始,以`)"`结束。但是,如果您的字符串包含`)"`,则可以使用`r"[]"`或`r"{}"`,如果这仍然不够,可以插入任意数量的破折号使开放和关闭对成对唯一,例如,`` `r"--()--" ``, `` `r"---()---" ``, 等等。原始字符串足够灵活,可以处理任何文本。

## 其他特殊字符

除了`\\"`、`\\'`和`\\\\`之外,还有一些其他可能会派上用场的特殊字符。最常见的是`\n`,表示换行,以及`\t`,表示制表符。有时您还会看到包含以`\u`或`\U`开头的 Unicode 转义序列的字符串。这是一种在所有系统上都能工作的写非英文字符的方式。您可以在[`“引用”`](https://rdrr.io/r/base/Quotes.xhtml)中查看其他特殊字符的完整列表。

x <- c("one\ntwo", "one\ttwo", "\u00b5", "\U0001f604")
x

> [1] "one\ntwo" "one\ttwo" "µ" "ߘ䢊str_view(x)

> [1] │ one

> │ two

> [2] │ one{\t}two

> [3] │ µ

> [4] │ ߘ伯


请注意,[`str_view()`](https://stringr.tidyverse.org/reference/str_view.xhtml)使用蓝色背景来突出显示制表符,以便更容易发现它们。处理文本的一个挑战是文本中可能存在各种空白字符,因此这种背景帮助您识别出正在发生某些奇怪的事情。

## 练习

1.  创建包含以下值的字符串:

    1.  `他说:“那太神奇了!”`

    1.  `\a\b\c\d`

    1.  `\\\\\\`

1.  在您的 R 会话中创建以下字符串并打印它。特殊字符`\u00a0`会发生什么?[`str_view()`](https://stringr.tidyverse.org/reference/str_view.xhtml)如何显示它?您能查找一下这个特殊字符是什么吗?

    ```
    x <- "This\u00a0is\u00a0tricky"
    ```

# 从数据创建多个字符串

现在您已经学会了手工创建一个或两个字符串的基础知识,我们将详细介绍如何从其他字符串创建字符串。这将帮助您解决一个常见问题,即您有一些您编写的文本,希望将其与数据帧中的字符串结合起来。例如,您可以将“Hello”与`name`变量结合起来创建一个问候语。我们将展示您如何使用[`str_c()`](https://stringr.tidyverse.org/reference/str_c.xhtml)和[`str_glue()`](https://stringr.tidyverse.org/reference/str_glue.xhtml),以及如何将它们与[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)一起使用。这自然引出了您可能与[`summarise()`](https://dplyr.tidyverse.org/reference/summarise.xhtml)一起使用的 stringr 函数的问题,因此我们将在本节结束时讨论[`str_flatten()`](https://stringr.tidyverse.org/reference/str_flatten.xhtml),这是字符串的汇总函数。

## str_c()

[`str_c()`](https://stringr.tidyverse.org/reference/str_c.xhtml)接受任意数量的向量作为参数,并返回一个字符向量:

str_c("x", "y")

> [1] "xy"

str_c("x", "y", "z")

> [1] "xyz"

str_c("Hello ", c("John", "Susan"))

> [1] "Hello John" "Hello Susan"


[`str_c()`](https://stringr.tidyverse.org/reference/str_c.xhtml) 类似于基础的 [`paste0()`](https://rdrr.io/r/base/paste.xhtml),但设计用于与 [`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml) 结合使用,遵循通常的 tidyverse 规则进行循环使用和传播缺失值:

df <- tibble(name = c("Flora", "David", "Terra", NA))
df |> mutate(greeting = str_c("Hi ", name, "!"))

> # A tibble: 4 × 2

> name greeting

>

> 1 Flora Hi Flora!

> 2 David Hi David!

> 3 Terra Hi Terra!

> 4


如果你希望以另一种方式显示缺失值,请使用 [`coalesce()`](https://dplyr.tidyverse.org/reference/coalesce.xhtml) 进行替换。根据你的需求,你可以将它用在 [`str_c()`](https://stringr.tidyverse.org/reference/str_c.xhtml) 的内部或外部:

df |>
mutate(
greeting1 = str_c("Hi ", coalesce(name, "you"), "!"),
greeting2 = coalesce(str_c("Hi ", name, "!"), "Hi!")
)

> # A tibble: 4 × 3

> name greeting1 greeting2

>

> 1 Flora Hi Flora! Hi Flora!

> 2 David Hi David! Hi David!

> 3 Terra Hi Terra! Hi Terra!

> 4 Hi you! Hi!


## `str_glue()`

如果你在使用 [`str_c()`](https://stringr.tidyverse.org/reference/str_c.xhtml) 混合许多固定和变量字符串时,会发现你需要输入许多 `"`,这使得难以看到代码的整体目标。一个替代方法由 [glue 包](https://oreil.ly/NHBNe) 提供,通过 [`str_glue()`](https://stringr.tidyverse.org/reference/str_glue.xhtml)。³ 你只需提供一个具有特殊特性的单个字符串:任何在 [`{}`](https://rdrr.io/r/base/Paren.xhtml) 内的内容将会被评估,就像在引号外一样:

df |> mutate(greeting = str_glue("Hi {name}!"))

> # A tibble: 4 × 2

> name greeting

>

> 1 Flora Hi Flora!

> 2 David Hi David!

> 3 Terra Hi Terra!

> 4 Hi NA!


如你所见,[`str_glue()`](https://stringr.tidyverse.org/reference/str_glue.xhtml) 目前将缺失值转换为字符串 `"NA"`,这使其与 [`str_c()`](https://stringr.tidyverse.org/reference/str_c.xhtml) 不一致,遗憾的是:

你可能也会想知道,如果需要在字符串中包含普通的 `{` 或 `}` 会发生什么。如果你猜想需要以某种方式转义它们,你是对的。诀窍在于 glue 使用略有不同的转义技术:你需要将特殊字符连续双写:

df |> mutate(greeting = str_glue("{{Hi {name}!}}"))

> # A tibble: 4 × 2

> name greeting

>

> 1 Flora

> 2 David

> 3 Terra

> 4


## `str_flatten()`

[`str_c()`](https://stringr.tidyverse.org/reference/str_c.xhtml) 和 [`str_glue()`](https://stringr.tidyverse.org/reference/str_glue.xhtml) 与 [`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml) 结合使用效果很好,因为它们的输出与它们的输入长度相同。如果你想要一个能够与 [`summarize()`](https://dplyr.tidyverse.org/reference/summarise.xhtml) 很好地配合的函数,即始终返回单个字符串的函数,那就是 [`str_flatten()`](https://stringr.tidyverse.org/reference/str_flatten.xhtml):⁴ 它接受一个字符向量,并将向量的每个元素组合成单个字符串:

str_flatten(c("x", "y", "z"))

> [1] "xyz"

str_flatten(c("x", "y", "z"), ", ")

> [1] "x, y, z"

str_flatten(c("x", "y", "z"), ", ", last = ", and ")

> [1] "x, y, and z"


这使得它与 [`summarize()`](https://dplyr.tidyverse.org/reference/summarise.xhtml) 很好地配合使用:

df <- tribble(
name, ~ fruit,
"Carmen", "banana",
"Carmen", "apple",
"Marvin", "nectarine",
"Terence", "cantaloupe",
"Terence", "papaya",
"Terence", "mandarin"
)
df |>
group_by(name) |>
summarize(fruits = str_flatten(fruit, ", "))

> # A tibble: 3 × 2

> name fruits

>

> 1 Carmen banana, apple

> 2 Marvin nectarine

> 3 Terence cantaloupe, papaya, mandarin


## 练习

1.  比较并对比 [`paste0()`](https://rdrr.io/r/base/paste.xhtml) 和 [`str_c()`](https://stringr.tidyverse.org/reference/str_c.xhtml) 的结果,针对以下输入:

    ```
    str_c("hi ", NA)
    str_c(letters[1:2], letters[1:3])
    ```

1.  [`paste()`](https://rdrr.io/r/base/paste.xhtml)和[`paste0()`](https://rdrr.io/r/base/paste.xhtml)之间有什么区别?如何用[`str_c()`](https://stringr.tidyverse.org/reference/str_c.xhtml)重新创建[`paste()`](https://rdrr.io/r/base/paste.xhtml)的等效操作?

1.  将以下表达式从[`str_c()`](https://stringr.tidyverse.org/reference/str_c.xhtml)转换为[`str_glue()`](https://stringr.tidyverse.org/reference/str_glue.xhtml)或反之:

    1.  `str_c("The price of ", food, " is ", price)`

    1.  `str_glue("I'm {age} years old and live in {country}")`

    1.  `str_c("\\section{", title, "}")`

# 从字符串中提取数据

多个变量常常被挤在一个字符串中。在本节中,您将学习如何使用四个 tidyr 函数来提取它们:

+   `df |> separate_longer_delim(col, delim)`

+   `df |> separate_longer_position(col, width)`

+   `df |> separate_wider_delim(col, delim, names)`

+   `df |> separate_wider_position(col, widths)`

如果你仔细观察,你会发现这里有一个共同模式:`separate_`,然后是 `longer` 或 `wider`,然后 `_`,然后是 `delim` 或 `position`。这是因为这四个函数由两个更简单的基元组成:

+   就像[`pivot_longer()`](https://tidyr.tidyverse.org/reference/pivot_longer.xhtml)和[`pivot_wider()`](https://tidyr.tidyverse.org/reference/pivot_wider.xhtml)一样, `_longer` 函数通过创建新行使输入数据框变长,而 `_wider` 函数通过生成新列使输入数据框变宽。

+   `delim` 使用分隔符(例如 `", "` 或 `" "`)拆分字符串;`position` 根据指定的宽度(例如 `c(3, 5, 2)`)拆分。

我们将在第十五章中再次回到这个家族的最后一位成员 `separate_wider_regex()`。它是 `wider` 函数中最灵活的,但在使用之前需要了解一些正则表达式知识。

接下来的两个部分将为您介绍这些分隔函数背后的基本思想,首先是分为行(稍微简单一些),然后是分为列。我们最后讨论 `wider` 函数提供的工具来诊断问题。

## 分为行

将字符串分成行通常在每行的组件数量不同时最有用。最常见的情况是需要 [`separate_longer_delim()`](https://tidyr.tidyverse.org/reference/separate_longer_delim.xhtml) 根据分隔符进行拆分:

df1 <- tibble(x = c("a,b,c", "d,e", "f"))
df1 |>
separate_longer_delim(x, delim = ",")

> # A tibble: 6 × 1

> x

>

> 1 a

> 2 b

> 3 c

> 4 d

> 5 e

> 6 f


在实际应用中较少见[`separate_longer_delim()`](https://tidyr.tidyverse.org/reference/separate_longer_delim.xhtml),但某些旧数据集确实使用了一种紧凑的格式,其中每个字符用于记录一个值:

df2 <- tibble(x = c("1211", "131", "21"))
df2 |>
separate_longer_position(x, width = 1)

> # A tibble: 9 × 1

> x

>

> 1 1

> 2 2

> 3 1

> 4 1

> 5 1

> 6 3

> # … with 3 more rows


## 分列

将字符串分隔成列在每个字符串中有固定数量的组件且您希望将它们展开到列中时,通常是最有用的。它们比其`longer`等效稍微复杂,因为您需要命名列。例如,在以下数据集中,`x`由代码、版本号和年份组成,它们用`.`分隔。要使用[`separate_wider_delim()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml),我们提供分隔符和名称的两个参数:

df3 <- tibble(x = c("a10.1.2022", "b10.2.2011", "e15.1.2015"))
df3 |>
separate_wider_delim(
x,
delim = ".",
names = c("code", "edition", "year")
)

> # A tibble: 3 × 3

> code edition year

>

> 1 a10 1 2022

> 2 b10 2 2011

> 3 e15 1 2015


如果特定的片段无用,您可以使用`NA`名称将其从结果中省略:

df3 |>
separate_wider_delim(
x,
delim = ".",
names = c("code", NA, "year")
)

> # A tibble: 3 × 2

> code year

>

> 1 a10 2022

> 2 b10 2011

> 3 e15 2015


[`separate_wider_position()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)的工作方式有些不同,因为您通常希望指定每列的宽度。因此,您给它一个命名的整数向量,其中名称给出新列的名称,而值是它占据的字符数。您可以通过不命名它们来从输出中省略值:

df4 <- tibble(x = c("202215TX", "202122LA", "202325CA"))
df4 |>
separate_wider_position(
x,
widths = c(year = 4, age = 2, state = 2)
)

> # A tibble: 3 × 3

> year age state

>

> 1 2022 15 TX

> 2 2021 22 LA

> 3 2023 25 CA


## 诊断扩展问题

[`separate_wider_delim()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)⁵需要一个固定和已知的列集。如果某些行没有预期数量的片段会发生什么?存在两种可能的问题,片段过少或者片段过多,因此[`separate_wider_delim()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)提供了两个参数来帮助:`too_few`和`too_many`。让我们首先看一下具有以下示例数据集的`too_few`情况:

df <- tibble(x = c("1-1-1", "1-1-2", "1-3", "1-3-2", "1"))

df |>
separate_wider_delim(
x,
delim = "-",
names = c("x", "y", "z")
)

> Error in separate_wider_delim():

> ! Expected 3 pieces in each element of x.

> ! 2 values were too short.

> ℹ Use too_few = "debug" to diagnose the problem.

> ℹ Use too_few = "align_start"/"align_end" to silence this message.


您会注意到我们收到了一个错误,但错误会给出一些关于如何继续的建议。让我们从调试问题开始:

debug <- df |>
separate_wider_delim(
x,
delim = "-",
names = c("x", "y", "z"),
too_few = "debug"
)

> Warning: Debug mode activated: adding variables x_ok, x_pieces, and

> x_remainder.

debug

> # A tibble: 5 × 6

> x y z x_ok x_pieces x_remainder

>

> 1 1-1-1 1 1 TRUE 3 ""

> 2 1-1-2 1 2 TRUE 3 ""

> 3 1-3 3 FALSE 2 ""

> 4 1-3-2 3 2 TRUE 3 ""

> 5 1 FALSE 1 ""


当您使用调试模式时,将会在输出中添加三列额外的列:`x_ok`、`x_pieces`和`x_remainder`(如果您使用不同名称分隔变量,则会得到不同的前缀)。在这里,`x_ok`允许您快速找到失败的输入:

debug |> filter(!x_ok)

> # A tibble: 2 × 6

> x y z x_ok x_pieces x_remainder

>

> 1 1-3 3 FALSE 2 ""

> 2 1 FALSE 1 ""


`x_pieces`告诉我们找到了多少个片段,与预期的三个(即`names`的长度)进行比较。当片段过少时,`x_remainder`并不有用,但我们很快会再次看到它。

有时查看此调试信息将显示出与您的定界策略有关的问题,或者建议您在分隔之前需要进行更多预处理。在这种情况下,请修复上游问题,并确保删除`too_few = "debug"`以确保新问题变为错误。

在其他情况下,您可能希望用`NA`填补缺失的部分并继续前进。这就是`too_few = "align_start"`和`too_few = "align_end"`的作用,它们允许您控制`NA`的放置位置:

df |>
separate_wider_delim(
x,
delim = "-",
names = c("x", "y", "z"),
too_few = "align_start"
)

> # A tibble: 5 × 3

> x y z

>

> 1 1 1 1

> 2 1 1 2

> 3 1 3

> 4 1 3 2

> 5 1


如果片段过多,同样的原则适用:

df <- tibble(x = c("1-1-1", "1-1-2", "1-3-5-6", "1-3-2", "1-3-5-7-9"))

df |>
separate_wider_delim(
x,
delim = "-",
names = c("x", "y", "z")
)

> Error in separate_wider_delim():

> ! Expected 3 pieces in each element of x.

> ! 2 values were too long.

> ℹ Use too_many = "debug" to diagnose the problem.

> ℹ Use too_many = "drop"/"merge" to silence this message.


但现在,当我们调试结果时,您可以看到`x_remainder`的目的:

debug <- df |>
separate_wider_delim(
x,
delim = "-",
names = c("x", "y", "z"),
too_many = "debug"
)

> Warning: Debug mode activated: adding variables x_ok, x_pieces, and

> x_remainder.

debug |> filter(!x_ok)

> # A tibble: 2 × 6

> x y z x_ok x_pieces x_remainder

>

> 1 1-3-5-6 3 5 FALSE 4 -6

> 2 1-3-5-7-9 3 5 FALSE 5 -7-9


处理过多片段的选项略有不同:您可以悄悄地“丢弃”任何额外的片段,或者将它们全部“合并”到最后一列中:

df |>
separate_wider_delim(
x,
delim = "-",
names = c("x", "y", "z"),
too_many = "drop"
)

> # A tibble: 5 × 3

> x y z

>

> 1 1 1 1

> 2 1 1 2

> 3 1 3 5

> 4 1 3 2

> 5 1 3 5

df |>
separate_wider_delim(
x,
delim = "-",
names = c("x", "y", "z"),
too_many = "merge"
)

> # A tibble: 5 × 3

> x y z

>

> 1 1 1 1

> 2 1 1 2

> 3 1 3 5-6

> 4 1 3 2

> 5 1 3 5-7-9


# 字母

在这一部分,我们将介绍一些函数,允许您处理字符串中的每个字母。您将学习如何找出字符串的长度,提取子字符串,并处理图表和表格中的长字符串。

## 长度

[`str_length()`](https://stringr.tidyverse.org/reference/str_length.xhtml) 告诉您字符串中的字母数:

str_length(c("a", "R for data science", NA))

> [1] 1 18 NA


您可以使用 [`count()`](https://dplyr.tidyverse.org/reference/count.xhtml) 查找美国婴儿姓名长度的分布,然后使用 [`filter()`](https://dplyr.tidyverse.org/reference/filter.xhtml) 查看最长的姓名,这些姓名恰好有 15 个字母:⁶

babynames |>
count(length = str_length(name), wt = n)

> # A tibble: 14 × 2

> length n

>

> 1 2 338150

> 2 3 8589596

> 3 4 48506739

> 4 5 87011607

> 5 6 90749404

> 6 7 72120767

> # … with 8 more rows

babynames |>
filter(str_length(name) == 15) |>
count(name, wt = n, sort = TRUE)

> # A tibble: 34 × 2

> name n

>

> 1 Franciscojavier 123

> 2 Christopherjohn 118

> 3 Johnchristopher 118

> 4 Christopherjame 108

> 5 Christophermich 52

> 6 Ryanchristopher 45

> # … with 28 more rows


## 子集

您可以使用 `str_sub(string, start, end)` 提取字符串的部分,其中 `start` 和 `end` 是子字符串应该开始和结束的位置。`start` 和 `end` 参数是包容的,因此返回字符串的长度将是 `end - start + 1`:

x <- c("Apple", "Banana", "Pear")
str_sub(x, 1, 3)

> [1] "App" "Ban" "Pea"


您可以使用负值从字符串末尾开始计数:-1 是最后一个字符,-2 是倒数第二个字符,依此类推。

str_sub(x, -3, -1)

> [1] "ple" "ana" "ear"


请注意,[`str_sub()`](https://stringr.tidyverse.org/reference/str_sub.xhtml) 如果字符串过短不会失败:它将尽可能返回尽可能多的内容:

str_sub("a", 1, 5)

> [1] "a"


我们可以使用 [`str_sub()`](https://stringr.tidyverse.org/reference/str_sub.xhtml) 和 [`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml) 找出每个名称的第一个和最后一个字母:

babynames |>
mutate(
first = str_sub(name, 1, 1),
last = str_sub(name, -1, -1)
)

> # A tibble: 1,924,665 × 7

> year sex name n prop first last

>

> 1 1880 F Mary 7065 0.0724 M y

> 2 1880 F Anna 2604 0.0267 A a

> 3 1880 F Emma 2003 0.0205 E a

> 4 1880 F Elizabeth 1939 0.0199 E h

> 5 1880 F Minnie 1746 0.0179 M e

> 6 1880 F Margaret 1578 0.0162 M t

> # … with 1,924,659 more rows


## 练习

1.  在计算婴儿姓名长度的分布时,为什么我们使用 `wt = n`?

1.  使用 [`str_length()`](https://stringr.tidyverse.org/reference/str_length.xhtml) 和 [`str_sub()`](https://stringr.tidyverse.org/reference/str_sub.xhtml) 提取每个婴儿姓名的中间字母。如果字符串长度为偶数,您会怎么做?

1.  随着时间的推移,婴儿姓名的长度有什么主要趋势?名字的首尾字母的受欢迎程度如何?

# 非英文文本

到目前为止,我们专注于英语文本,这在两个方面特别容易处理。首先,英语字母表相对简单:只有 26 个字母。其次(也许更重要的是),我们今天使用的计算基础设施主要由英语使用者设计。不幸的是,我们没有足够的空间来全面处理非英语语言。尽管如此,我们希望引起您对可能遇到的一些最大挑战的注意:编码、字母变体和依赖于地区的函数。

## 编码

在处理非英文文本时,第一个挑战通常是*编码*。要理解发生了什么,我们需要深入了解计算机如何表示字符串。在 R 中,我们可以使用 [`charToRaw()`](https://rdrr.io/r/base/rawConversion.xhtml) 获取字符串的底层表示:

charToRaw("Hadley")

> [1] 48 61 64 6c 65 79


每个十六进制数都代表一个字母:`48`代表 H,`61`代表 a,依此类推。从十六进制数到字符的映射称为编码,在这种情况下,编码称为 ASCII。ASCII 在表示英文字符方面表现出色,因为它是*美国*信息交换标准代码。

对于非英语的语言来说,事情并不那么简单。在计算机的早期阶段,有许多竞争的编码标准用于编码非英语字符。例如,欧洲有两种不同的编码:Latin1(也称为 ISO-8859-1)用于西欧语言,而 Latin2(也称为 ISO-8859-2)用于中欧语言。在 Latin1 中,字节`b1`是±,但在 Latin2 中,它是ą!幸运的是,今天几乎每个地方都支持一种标准:UTF-8。UTF-8 可以编码今天人类使用的几乎每个字符,以及许多额外的符号,如表情符号。

readr 在任何地方都使用 UTF-8。这是一个很好的默认设置,但对于不使用 UTF-8 的旧系统生成的数据,可能会失败。如果发生这种情况,在打印字符串时,它们会看起来很奇怪。有时可能只会出现一两个字符混乱,而其他时候则会得到完全无法理解的内容。例如,这里有两个内联 CSV 示例,使用了不同的编码:⁷

x1 <- "text\nEl Ni\xf1o was particularly bad this year"
read_csv(x1)

> # A tibble: 1 × 1

> text

>

> 1 "El Ni\xf1o was particularly bad this year"

x2 <- "text\n\x82\xb1\x82\xf1\x82\xc9\x82\xbf\x82\xcd"
read_csv(x2)

> # A tibble: 1 × 1

> text

>

> 1 "\x82\xb1\x82\xf1\x82\xc9\x82\xbf\x82\xcd"


要正确地读取这些内容,您可以通过`locale`参数指定编码:

read_csv(x1, locale = locale(encoding = "Latin1"))

> # A tibble: 1 × 1

> text

>

> 1 El Niño was particularly bad this year

read_csv(x2, locale = locale(encoding = "Shift-JIS"))

> # A tibble: 1 × 1

> text

>

> 1 こんにちは


如何找到正确的编码?如果幸运的话,它可能包含在数据文档的某处。不幸的是,这种情况很少见,所以 readr 提供了[`guess_encoding()`](https://readr.tidyverse.org/reference/encoding.xhtml)来帮助您找到答案。它不是绝对可靠的,在处理少量文本时效果较差,但作为一个合理的起点是可以接受的。预计在找到正确编码之前,您可能需要尝试几种不同的编码。

编码是一个丰富而复杂的主题;我们在这里只是浅尝辄止。如果您想了解更多信息,建议阅读[详细说明](https://oreil.ly/v8ZQf)。

## 字母变体

在使用带有重音的语言时,确定字母位置(例如,使用[`str_length()`](https://stringr.tidyverse.org/reference/str_length.xhtml)和[`str_sub()`](https://stringr.tidyverse.org/reference/str_sub.xhtml))是一个重要挑战,因为重音字母可以被编码为一个单独的字符(例如,ü),或者通过结合未带重音的字母(例如,u)与变音符号(例如,¨)来编码为两个字符。例如,此代码展示了两种看起来相同的表示ü的方式:

u <- c("\u00fc", "u\u0308")
str_view(u)

> [1] │ ü

> [2] │ ü


但这两个字符串在长度上不同,并且它们的第一个字符也不同:

str_length(u)

> [1] 1 2

str_sub(u, 1, 1)

> [1] "ü" "u"


最后,请注意,使用`==`比较这些字符串时会将它们视为不同,而 stringr 中方便的[`str_equal()`](https://stringr.tidyverse.org/reference/str_equal.xhtml)函数会认识到它们具有相同的外观:

u[[1]] == u[[2]]

> [1] FALSE

str_equal(u[[1]], u[[2]])

> [1] TRUE


## 依赖区域设置的函数

最后,有一些 stringr 函数的行为取决于你的*语言环境*。语言环境类似于语言,但包括一个可选的地区标识符,以处理语言内的地区变化。语言环境由小写语言缩写指定,可选地跟着一个 `_` 和一个大写地区标识符。例如,“en” 是英语,“en_GB” 是英国英语,“en_US” 是美国英语。如果你还不知道你的语言的代码,[Wikipedia](https://oreil.ly/c1P2g) 有一个很好的列表,并且你可以查看哪些被 stringr 支持,通过查看 [`stringi::stri_locale_list()`](https://rdrr.io/pkg/stringi/man/stri_locale_list.xhtml)。

基础 R 字符串函数会自动使用操作系统设置的语言环境。这意味着基础 R 字符串函数会根据你的语言环境执行操作,但如果与其他国家的人共享代码,你的代码可能会有不同的运行结果。为了避免这个问题,stringr 默认使用英语规则,通过使用 “en” 语言环境,并要求你指定 `locale` 参数来覆盖它。幸运的是,有两组函数特别依赖于语言环境:大小写转换和排序。

不同语言的大小写转换规则各不相同。例如,土耳其语有两个 i:一个有点,一个没有点。因为它们是两个不同的字母,它们的大写形式也不同:

str_to_upper(c("i", "ı"))

> [1] "I" "I"

str_to_upper(c("i", "ı"), locale = "tr")

> [1] "İ" "I"


对字符串进行排序依赖于字母表的顺序,而不是每种语言都相同!⁸ 举个例子:在捷克语中,“ch” 是一个复合字母,按照字母表的顺序,它出现在 `h` 之后。

str_sort(c("a", "c", "ch", "h", "z"))

> [1] "a" "c" "ch" "h" "z"

str_sort(c("a", "c", "ch", "h", "z"), locale = "cs")

> [1] "a" "c" "h" "ch" "z"


当使用 [`dplyr::arrange()`](https://dplyr.tidyverse.org/reference/arrange.xhtml) 对字符串进行排序时也会遇到这个问题,这也是它有 `locale` 参数的原因。

# 总结

在本章中,你了解了 stringr 包的一些强大功能,如如何创建、组合和提取字符串,以及在处理非英语字符串时可能遇到的一些挑战。现在是时候学习处理字符串中最重要和最强大的工具之一了:正则表达式。正则表达式是一种简洁而表达力强的语言,用于描述字符串中的模式,并且是下一章的主题。

¹ 或者使用基础 R 函数 [`writeLines()`](https://rdrr.io/r/base/writeLines.xhtml)。

² 适用于 R 4.0.0 及更新版本。

³ 如果你不使用 stringr,也可以直接访问它,使用 [`glue::glue()`](https://glue.tidyverse.org/reference/glue.xhtml)。

⁴ 基础 R 的等效函数是 [`paste()`](https://rdrr.io/r/base/paste.xhtml),使用 `collapse` 参数。

⁵ 相同的原则也适用于[`separate_wider_position()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)和[`separate_wider_regex()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)。

⁶ 查看这些条目,我们猜测 babynames 数据会删除空格或连字符,并在 15 个字母后截断。

⁷ 在这里,我正在使用特殊字符 `\x` 将二进制数据直接编码到字符串中。

⁸ 在像中文这样没有字母的语言中进行排序,问题就更加复杂了。


# 第十五章:正则表达式

# 介绍

在第十四章中,你学习了许多处理字符串的有用函数。本章将重点介绍使用*正则表达式*描述字符串模式的简洁而强大的语言的函数。术语*正则表达式*有点冗长,因此大多数人将其缩写为*regex*¹或*regexp*。

本章从正则表达式的基础知识和最有用的字符串分析函数开始。然后,我们将扩展您对模式的理解,并介绍七个重要的新主题(转义、锚定、字符类、简写类、量词、优先级和分组)。接下来,我们将讨论一些其他类型的模式,stringr 函数可以处理的以及各种“标志”,这些标志允许您调整正则表达式的操作。最后,我们将概述 tidyverse 和基础 R 中可能使用正则表达式的其他位置。

## 先决条件

在本章中,我们将使用来自 stringr 和 tidyr 的正则表达式函数,它们都是 tidyverse 的核心成员,以及来自 babynames 包的数据:

library(tidyverse)
library(babynames)


本章中,我们将使用简单的内联示例混合,以便您可以了解基本概念,以及来自 stringr 的婴儿姓名数据和三个字符向量:

+   `fruit` 包含 80 种水果的名称。

+   `words` 包含 980 个常见的英文单词。

+   `sentences` 包含 720 个短句子。

# 模式基础

我们将使用[`str_view()`](https://stringr.tidyverse.org/reference/str_view.xhtml)来学习正则表达式模式的工作原理。在前一章中,我们使用[`str_view()`](https://stringr.tidyverse.org/reference/str_view.xhtml)更好地理解了字符串与其打印表示之间的区别,现在我们将使用它的第二个参数,即正则表达式。当提供这个参数时,[`str_view()`](https://stringr.tidyverse.org/reference/str_view.xhtml)将仅显示与之匹配的字符串向量的元素,并用`<>`包围每个匹配项,并在可能时以蓝色突出显示匹配项。

最简单的模式由精确匹配这些字符的字母和数字组成:

str_view(fruit, "berry")

> [6] │ bil

> [7] │ black

> [10] │ blue

> [11] │ boysen

> [19] │ cloud

> [21] │ cran

> ... and 8 more


字母和数字精确匹配并称为*字面字符*。大多数标点符号字符,如`.`、`+`、`*`、`[`、`]`和`?`,具有特殊含义²,称为*元字符*。例如,`.`将匹配任何字符³,因此`"a."`将匹配包含“a”后跟另一个字符的任何字符串:

str_view(c("a", "ab", "ae", "bd", "ea", "eab"), "a.")

> [2] │

> [3] │

> [6] │ e


或者我们可以找到所有包含“a”后跟三个字母,再跟一个“e”的水果:

str_view(fruit, "a...e")

> [1] │

> [7] │ blrry

> [48] │ mand

> [51] │ nect

> [62] │ pine

> [64] │ pomegr

> ... and 2 more


*量词*控制模式可以匹配多少次:

+   `?` 使模式变为可选的(即匹配 0 次或 1 次)。

+   `+` 允许模式重复(即至少匹配一次)。

+   `*` 允许模式是可选的或重复的(即匹配任意次数,包括 0 次)。

ab? matches an "a", optionally followed by a "b".

str_view(c("a", "ab", "abb"), "ab?")

> [1] │

> [2] │

> [3] │ b

ab+ matches an "a", followed by at least one "b".

str_view(c("a", "ab", "abb"), "ab+")

> [2] │

> [3] │

ab* matches an "a", followed by any number of "b"s.

str_view(c("a", "ab", "abb"), "ab*")

> [1] │

> [2] │

> [3] │


*字符类*由`[]`定义,允许您匹配一组字符;例如,`[abcd]`匹配“a”、“b”、“c”或“d”。您还可以通过使用`^`来反转匹配:`[^abcd]`匹配除了“a”、“b”、“c”或“d”之外的任何字符。我们可以利用这个思路来查找包含元音字母“x”或辅音字母“y”的单词:

str_view(words, "[aeiou]x[aeiou]")

> [284] │ ct

> [285] │ mple

> [288] │ rcise

> [289] │ st

str_view(words, "[aeiou]y[aeiou]")

> [836] │ tem

> [901] │ e


您可以使用*交替*,`|`,来选择一个或多个备选模式。例如,以下模式寻找包含“apple”、“melon”或“nut”或重复元音字母的水果:

str_view(fruit, "apple|melon|nut")

> [1] │

> [13] │ canary

> [20] │ coco

> [52] │

> [62] │ pine

> [72] │ rock

> ... and 1 more

str_view(fruit, "aa|ee|ii|oo|uu")

> [9] │ bld orange

> [33] │ gseberry

> [47] │ lych

> [66] │ purple mangostn


正则表达式非常紧凑,使用了许多标点字符,因此一开始可能看起来令人生畏且难以阅读。不要担心:通过练习,您会变得更好,简单的模式很快就会变得熟悉起来。让我们通过使用一些有用的 stringr 函数来开始这个过程。

# 关键功能

现在您已经了解了正则表达式的基础知识,让我们在一些 stringr 和 tidyr 函数中使用它们。在接下来的部分中,您将学习如何检测匹配的存在或不存在,如何计算匹配的数量,如何用固定文本替换匹配项,以及如何使用模式提取文本。

## 检测匹配项

[`str_detect()`](https://stringr.tidyverse.org/reference/str_detect.xhtml)返回一个逻辑向量,如果模式与字符向量的元素匹配,则为`TRUE`,否则为`FALSE`:

str_detect(c("a", "b", "c"), "[aeiou]")

> [1] TRUE FALSE FALSE


由于[`str_detect()`](https://stringr.tidyverse.org/reference/str_detect.xhtml)返回与初始向量相同长度的逻辑向量,因此它与[`filter()`](https://dplyr.tidyverse.org/reference/filter.xhtml)配合使用效果很好。例如,这段代码找出所有包含小写字母“x”的最流行的名字:

babynames |>
filter(str_detect(name, "x")) |>
count(name, wt = n, sort = TRUE)

> # A tibble: 974 × 2

> name n

>

> 1 Alexander 665492

> 2 Alexis 399551

> 3 Alex 278705

> 4 Alexandra 232223

> 5 Max 148787

> 6 Alexa 123032

> # … with 968 more rows


我们还可以使用[`str_detect()`](https://stringr.tidyverse.org/reference/str_detect.xhtml)与[`summarize()`](https://dplyr.tidyverse.org/reference/summarise.xhtml)配合使用,再配合[`sum()`](https://rdrr.io/r/base/sum.xhtml)或[`mean()`](https://rdrr.io/r/base/mean.xhtml):`sum(str_detect(x, pattern))`告诉您匹配的观测数量,而`mean(str_detect(x, pattern))`告诉您匹配的比例。例如,以下代码片段计算并可视化了包含“x”的婴儿姓名⁴的比例,按年份分解。看起来它们最近的流行度有了显著增加!

babynames |>
group_by(year) |>
summarize(prop_x = mean(str_detect(name, "x"))) |>
ggplot(aes(x = year, y = prop_x)) +
geom_line()


![一个时间序列图显示包含字母 x 的婴儿姓名比例。该比例从 1880 年的每千个名字中的 8 个逐渐下降到 1980 年的每千个名字中的 4 个,然后迅速增加到 2019 年的每千个名字中的 16 个。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_15in01.png)

有两个与[`str_detect()`](https://stringr.tidyverse.org/reference/str_detect.xhtml)密切相关的函数:[`str_subset()`](https://stringr.tidyverse.org/reference/str_subset.xhtml)和[`str_which()`](https://stringr.tidyverse.org/reference/str_which.xhtml)。[`str_subset()`](https://stringr.tidyverse.org/reference/str_subset.xhtml)返回一个只包含匹配字符串的字符向量。[`str_which()`](https://stringr.tidyverse.org/reference/str_which.xhtml)返回一个整数向量,给出匹配的字符串位置。

## 计算匹配次数

比[`str_detect()`](https://stringr.tidyverse.org/reference/str_detect.xhtml)更复杂的下一个步骤是[`str_count()`](https://stringr.tidyverse.org/reference/str_count.xhtml):它不像返回真或假,而是告诉你每个字符串中有多少匹配项。

x <- c("apple", "banana", "pear")
str_count(x, "p")

> [1] 2 0 1


注意每个匹配从前一个匹配的末尾开始;即,正则表达式匹配不重叠。例如,在`"abababa"`中,模式`"aba"`会匹配多少次?正则表达式说是两次,而不是三次:

str_count("abababa", "aba")

> [1] 2

str_view("abababa", "aba")

> [1] │ b


自然而然地使用[`str_count()`](https://stringr.tidyverse.org/reference/str_count.xhtml)和[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)。以下示例使用[`str_count()`](https://stringr.tidyverse.org/reference/str_count.xhtml)和字符类来计算每个名称中元音和辅音的数量:

babynames |>
count(name) |>
mutate(
vowels = str_count(name, "[aeiou]"),
consonants = str_count(name, "[^aeiou]")
)

> # A tibble: 97,310 × 4

> name n vowels consonants

>

> 1 Aaban 10 2 3

> 2 Aabha 5 2 3

> 3 Aabid 2 2 3

> 4 Aabir 1 2 3

> 5 Aabriella 5 4 5

> 6 Aada 1 2 2

> # … with 97,304 more rows


如果你仔细观察,会发现我们的计算有些问题:“Aaban”包含三个 a,但我们的汇总报告只有两个元音。这是因为正则表达式区分大小写。我们有三种方法可以解决这个问题:

+   将大写元音添加到字符类中:`str_count(name, "[aeiouAEIOU]")`。

+   告诉正则表达式忽略大小写:`str_count(name, regex("[aeiou]", ignore_case = TRUE))`。我们将在“正则表达式标志”中详细讨论。

+   使用[`str_to_lower()`](https://stringr.tidyverse.org/reference/case.xhtml)将名称转换为小写:`str_count(str_to_lower(name), "[aeiou]")`。

处理字符串时,常见的方法有多种——通常可以通过使模式更复杂或对字符串进行预处理来达到目标。如果尝试一种方法时遇到困难,通常切换角度从不同的视角解决问题会更有用。

因为我们对名称应用了两个函数,我认为先将其转换会更容易:

babynames |>
count(name) |>
mutate(
name = str_to_lower(name),
vowels = str_count(name, "[aeiou]"),
consonants = str_count(name, "[^aeiou]")
)

> # A tibble: 97,310 × 4

> name n vowels consonants

>

> 1 aaban 10 3 2

> 2 aabha 5 3 2

> 3 aabid 2 3 2

> 4 aabir 1 3 2

> 5 aabriella 5 5 4

> 6 aada 1 3 1

> # … with 97,304 more rows


## 替换值

除了检测和计数匹配项,我们还可以使用[`str_replace()`](https://stringr.tidyverse.org/reference/str_replace.xhtml)和[`str_replace_all()`](https://stringr.tidyverse.org/reference/str_replace.xhtml)修改它们。[`str_replace()`](https://stringr.tidyverse.org/reference/str_replace.xhtml)替换第一个匹配项,而[`str_replace_all()`](https://stringr.tidyverse.org/reference/str_replace.xhtml)则替换所有匹配项:

x <- c("apple", "pear", "banana")
str_replace_all(x, "[aeiou]", "-")

> [1] "-ppl-" "p--r" "b-n-n-"


[`str_remove()`](https://stringr.tidyverse.org/reference/str_remove.xhtml)和[`str_remove_all()`](https://stringr.tidyverse.org/reference/str_remove.xhtml)是`str_replace(x, pattern, "")`的便捷快捷方式:

x <- c("apple", "pear", "banana")
str_remove_all(x, "[aeiou]")

> [1] "ppl" "pr" "bnn"


在数据清洗时,这些函数与[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)自然配对,你经常会重复应用它们来逐层去除不一致的格式。

## 提取变量

我们将讨论的最后一个函数使用正则表达式从一列中提取数据到一个或多个新列:[`separate_wider_regex()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)。它与你在“分列”章节学到的[`separate_wider_position()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)和[`separate_wider_delim()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)函数是同类。这些函数存在于 tidyr 中,因为它们操作(列的)数据框,而不是单个向量。

让我们创建一个简单的数据集来展示它的工作原理。这里有一些从`babynames`衍生出的数据,其中我们有一些人的姓名、性别和年龄,格式相当奇怪:⁵

df <- tribble(
~str,
"-F_34",
"-F_45",
"-N_33",
"-F_38",
"-F_58",
"-M_41",
"-F_84",
)


要使用[`separate_wider_regex()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)提取这些数据,我们只需构造一系列正则表达式,匹配每个片段。如果我们希望输出中出现该片段的内容,我们给它一个名称:

df |>
separate_wider_regex(
str,
patterns = c(
"<",
name = "[A-Za-z]+",
">-",
gender = ".", "_",
age = "[0-9]+"
)
)

> # A tibble: 7 × 3

> name gender age

>

> 1 Sheryl F 34

> 2 Kisha F 45

> 3 Brandon N 33

> 4 Sharon F 38

> 5 Penny F 58

> 6 Justin M 41

> # … with 1 more row


如果匹配失败,你可以使用`too_short = "debug"`来找出问题所在,就像[`separate_wider_delim()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)和[`separate_wider_position()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)一样。

## 练习

1.  哪个婴儿名字有最多的元音?哪个名字的元音比例最高?(提示:分母是什么?)

1.  在`"a/b/c/d/e"`中用反斜杠替换所有正斜杠。如果尝试通过替换所有反斜杠为正斜杠来撤销转换,会发生什么?(我们很快会讨论这个问题。)

1.  使用[`str_replace_all()`](https://stringr.tidyverse.org/reference/str_replace.xhtml)实现一个简单版本的[`str_to_lower()`](https://stringr.tidyverse.org/reference/case.xhtml)。

1.  创建一个正则表达式,匹配你国家中常见的电话号码写法。

# 模式细节

现在你理解了模式语言的基础以及如何与一些 stringr 和 tidyr 函数一起使用它的基础,现在是时候深入探讨更多细节了。首先,我们将从*转义*开始,这允许你匹配本应特殊处理的元字符。接下来,你将了解*锚点*,它允许你匹配字符串的开始或结束。然后,你将更多地了解*字符类*及其快捷方式,它们允许你匹配来自集合的任何字符。接下来,你将学习*量词*的最后细节,它控制模式可以匹配的次数。然后,我们需要涵盖*运算符优先级*和括号这个重要(但复杂)的主题。最后,我们会详细讨论模式组件的*分组*细节。

这里使用的术语是每个组件的技术名称。它们并不总是最具感染力的用途,但如果以后你想要搜索更多细节,知道正确的术语是有帮助的。

## 转义

要匹配一个字面上的`.`,你需要进行*转义*,这告诉正则表达式匹配元字符⁶的字面意思。与字符串类似,正则表达式使用反斜杠进行转义。因此,要匹配`.`,你需要正则表达式`\.`。不幸的是,这会造成问题。我们使用字符串表示正则表达式,而`\`也用作字符串中的转义符号。因此,要创建正则表达式`\.`,我们需要字符串`"\\."`,如下面的示例所示:

To create the regular expression ., we need to use \.

dot <- "\."

But the expression itself only contains one \

str_view(dot)

> [1] │ .

And this tells R to look for an explicit .

str_view(c("abc", "a.c", "bef"), "a\.c")

> [2] │ <a.c>


在本书中,我们通常会写出没有引号的正则表达式,例如`\.`。如果我们需要强调你实际上要输入的内容,我们会用引号括起来并添加额外的转义符号,例如`"\\."`。

如果`\`在正则表达式中用作转义字符,那么如何匹配一个字面上的`\`?嗯,你需要对它进行转义,创建正则表达式`\\`。要创建这样的正则表达式,你需要使用一个字符串,而在字符串中,你也需要转义`\`。这意味着要匹配一个字面上的`\`,你需要写成`"\\\\"`——你需要四个反斜杠来匹配一个!

x <- "a\b"
str_view(x)

> [1] │ a\b

str_view(x, "\\")

> [1] │ a<>b


或者,你可能会发现使用你在“原始字符串”中学到的原始字符串更容易些。这样可以避免一层转义:

str_view(x, r"{\}")

> [1] │ a<>b


如果你尝试匹配一个字面上的`.`、`$`、`|`、`*`、`+`、`?`、`{`、`}`、`(`、`)`,使用反斜杠转义的替代方法。你可以使用字符类:`[.]`、`[$]`、`[|]`,...都可以匹配字面值:

str_view(c("abc", "a.c", "a*c", "a c"), "a[.]c")

> [2] │ <a.c>

str_view(c("abc", "a.c", "ac", "a c"), ".[]c")

> [3] │ <a*c>


## 锚点

默认情况下,正则表达式将匹配字符串的任何部分。如果你想匹配开头或结尾,你需要使用`^`锚定正则表达式的开始或使用`$`锚定结束:

str_view(fruit, "^a")

> [1] │ pple

> [2] │ pricot

> [3] │ vocado

str_view(fruit, "a$")

> [4] │ banan

> [15] │ cherimoy

> [30] │ feijo

> [36] │ guav

> [56] │ papay

> [74] │ satsum


很诱人地认为`$`应该匹配字符串的开始,因为这是我们写金额的方式,但这并不是正则表达式想要的。

要强制正则表达式仅匹配整个字符串,请同时使用`^`和`$`锚定它:

str_view(fruit, "apple")

> [1] │

> [62] │ pine

str_view(fruit, "^apple$")

> [1] │


您还可以使用 `\b` 匹配单词边界(即单词的开始或结束)。在使用 RStudio 的查找和替换工具时,这可能特别有用。例如,要查找所有使用 [`sum()`](https://rdrr.io/r/base/sum.xhtml) 的地方,您可以搜索 `\bsum\b` 以避免匹配 `summarize`、`summary`、`rowsum` 等:

x <- c("summary(x)", "summarize(df)", "rowsum(x)", "sum(x)")
str_view(x, "sum")

> [1] │ mary(x)

> [2] │ marize(df)

> [3] │ row(x)

> [4] │ (x)

str_view(x, "\bsum\b")

> [4] │ (x)


当单独使用时,锚点将产生零宽匹配:

str_view("abc", c("$", "^", "\b"))

> [1] │ abc<>

> [2] │ <>abc

> [3] │ <>abc<>


这帮助您了解替换独立锚点时会发生什么:

str_replace_all("abc", c("$", "^", "\b"), "--")

> [1] "abc--" "--abc" "--abc--"


## 字符类

*字符类* 或 *字符集* 允许您匹配集合中的任何字符。正如我们讨论过的,您可以使用 `[]` 构建自己的集合,其中 `[abc]` 匹配“a”、“b”或“c”,`[^abc]` 匹配除“a”、“b”或“c”之外的任何字符。除了 `^` 之外,还有两个其他在 `[]` 内具有特殊含义的字符:

+   `-` 定义一个范围;例如 `[a-z]` 匹配任何小写字母,`[0-9]` 匹配任何数字。

+   `\` 转义特殊字符,因此 `[\^\-\]]` 匹配 `^`、`-` 或 `]`。

这里有几个例子:

x <- "abcd ABCD 12345 -!@#%."
str_view(x, "[abc]+")

> [1] │ d ABCD 12345 -!@#%.

str_view(x, "[a-z]+")

> [1] │ ABCD 12345 -!@#%.

str_view(x, "[^a-z0-9]+")

> [1] │ abcd< ABCD >12345< -!@#%.>

You need an escape to match characters that are otherwise

special inside of []

str_view("a-b-c", "[a-c]")

> [1] │ --

str_view("a-b-c", "[a\-c]")

> [1] │ <->b<->


一些字符类使用得如此普遍,它们得到了自己的快捷方式。您已经看到了 `.`,它匹配除换行符之外的任何字符。还有另外三对特别有用的字符:⁷

+   `\d` 匹配任何数字字符。

    `\D` 匹配任何非数字字符。

+   `\s` 匹配任何空白字符(例如空格、制表符、换行符)。

    `\S` 匹配任何非空白字符。

+   `\w` 匹配任何“单词”字符,即字母和数字。

    `\W` 匹配任何“非单词”字符。

以下代码演示了六个快捷方式,包括一些字母、数字和标点符号的选择:

x <- "abcd ABCD 12345 -!@#%."
str_view(x, "\d+")

> [1] │ abcd ABCD <12345> -!@#%.

str_view(x, "\D+")

> [1] │ 12345< -!@#%.>

str_view(x, "\s+")

> [1] │ abcd< >ABCD< >12345< >-!@#%.

str_view(x, "\S+")

> [1] │ <12345> <-!@#%.>

str_view(x, "\w+")

> [1] │ <12345> -!@#%.

str_view(x, "\W+")

> [1] │ abcd< >ABCD< >12345< -!@#%.>


## 量词

*量词* 控制模式匹配的次数。在 “模式基础” 中,您学到了 `?`(0 或 1 次匹配)、`+`(1 次或多次匹配)和 `*`(0 次或多次匹配)。例如,`colou?r` 将匹配美式或英式拼写,`\d+` 将匹配一个或多个数字,`\s?` 将可选择匹配一个空格项。您还可以使用 `{}` 精确指定匹配次数:[`{}`](https://rdrr.io/r/base/Paren.xhtml)

+   `{n}` 精确匹配 n 次。

+   `{n,}` 至少匹配 n 次。

+   `{n,m}` 匹配 n 到 m 次。

## 运算符优先级和括号

`ab+` 匹配什么?它是匹配以一个或多个“b”后跟“a”,还是匹配任意次数重复的“ab”?`^a|b$` 匹配什么?它是匹配完整的字符串 a 或完整的字符串 b,还是匹配以 a 开头或以 b 结尾的字符串?

这些问题的答案取决于操作符优先级,类似于您可能在学校学到的 PEMDAS 或 BEDMAS 规则。您知道 `a + b * c` 等同于 `a + (b * c)` 而不是 `(a + b) * c`,因为 `*` 有更高的优先级,而 `+` 有较低的优先级:您在 `+` 之前计算 `*`。

同样,正则表达式有其自己的优先级规则:量词具有较高的优先级,而交替具有较低的优先级,这意味着`ab+`等价于`a(b+)`,`^a|b$`等价于`(^a)|(b$)`。就像代数一样,您可以使用括号来覆盖通常的顺序。但与代数不同的是,您不太可能记住正则表达式的优先级规则,因此请随意大量使用括号。

## 分组和捕获

除了覆盖运算符优先级外,括号还具有另一个重要的作用:它们创建*捕获组*,允许您使用匹配的子组件。

使用捕获组的第一种方法是在匹配中引用它:*反向引用* `\1` 指的是第一个括号中包含的匹配,`\2` 指的是第二个括号中的匹配,依此类推。例如,以下模式找到所有具有重复的字母对的水果:

str_view(fruit, "(..)\1")

> [4] │ ba

> [20] │ nut

> [22] │ mber

> [41] │ be

> [56] │ ya

> [73] │ s berry


这个模式找到所有以相同的字母对开头和结尾的单词:

str_view(words, "^(..).*\1$")

> [152] │

> [217] │

> [617] │

> [699] │

> [739] │


您还可以在[`str_replace()`](https://stringr.tidyverse.org/reference/str_replace.xhtml)中使用反向引用。例如,此代码交换了`sentences`中第二个和第三个单词的顺序:

sentences |>
str_replace("(\w+) (\w+) (\w+)", "\1 \3 \2") |>
str_view()

> [1] │ The canoe birch slid on the smooth planks.

> [2] │ Glue sheet the to the dark blue background.

> [3] │ It's to easy tell the depth of a well.

> [4] │ These a days chicken leg is a rare dish.

> [5] │ Rice often is served in round bowls.

> [6] │ The of juice lemons makes fine punch.

> ... and 714 more


如果要提取每个组的匹配项,可以使用[`str_match()`](https://stringr.tidyverse.org/reference/str_match.xhtml)。但[`str_match()`](https://stringr.tidyverse.org/reference/str_match.xhtml)返回一个矩阵,因此使用起来并不是特别方便:⁸

sentences |>
str_match("the (\w+) (\w+)") |>
head()

> [,1] [,2] [,3]

> [1,] "the smooth planks" "smooth" "planks"

> [2,] "the sheet to" "sheet" "to"

> [3,] "the depth of" "depth" "of"

> [4,] NA NA NA

> [5,] NA NA NA

> [6,] NA NA NA


您可以转换为一个 tibble 并命名列:

sentences |>
str_match("the (\w+) (\w+)") |>
as_tibble(.name_repair = "minimal") |>
set_names("match", "word1", "word2")

> # A tibble: 720 × 3

> match word1 word2

>

> 1 the smooth planks smooth planks

> 2 the sheet to sheet to

> 3 the depth of depth of

> 4

> 5

> 6

> # … with 714 more rows


但这样你基本上重新创建了自己版本的[`separate_wider_regex()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)。事实上,[`separate_wider_regex()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)在幕后将您的模式向量转换为使用分组以捕获命名组件的单一正则表达式。

有时,您会想在不创建匹配组的情况下使用括号。您可以使用 `(?:)` 创建一个非捕获组。

x <- c("a gray cat", "a grey dog")
str_match(x, "gr(e|a)y")

> [,1] [,2]

> [1,] "gray" "a"

> [2,] "grey" "e"

str_match(x, "gr(?:e|a)y")

> [,1]

> [1,] "gray"

> [2,] "grey"


## 练习

1.  如何匹配文字字符串`"'`?以及`"$^$"`?

1.  解释为什么以下每个模式都不匹配`\`:`"\"`,`"\\"`,`"\\\"`。

1.  给定[`stringr::words`](https://stringr.tidyverse.org/reference/stringr-data.xhtml)中的常用词库,创建正则表达式以找到所有满足以下条件的单词:

    1.  以“y”开头。

    1.  不以“y”开头。

    1.  以“x”结尾。

    1.  恰好长三个字母。(不要作弊使用[`str_length()`](https://stringr.tidyverse.org/reference/str_length.xhtml)!)

    1.  有七个或更多个字母。

    1.  包含元音辅音对。

    1.  至少包含两个元音辅音对。

    1.  只由重复的元音辅音对组成。

1.  创建 11 个正则表达式,匹配以下每个词的英式或美式拼写:airplane/aeroplane, aluminum/aluminium, analog/analogue, ass/arse, center/centre, defense/defence, donut/doughnut, gray/grey, modeling/modelling, skeptic/sceptic, summarize/summarise。尽量制作最短的正则表达式!

1.  切换`words`中的第一个和最后一个字母。哪些字符串仍然是`words`?

1.  用文字描述这些正则表达式匹配的内容(仔细阅读以查看每个条目是正则表达式还是定义正则表达式的字符串):

    1.  `^.*$`

    1.  `"\\{.+\\}"`

    1.  `\d{4}-\d{2}-\d{2}`

    1.  `"\\\\{4}"`

    1.  `\..\..\..`

    1.  `(.)\1\1`

    1.  `"(..)\\1"`

1.  解决[初学者正则表达式跨字谜](https://oreil.ly/Db3NF)。

# 模式控制

通过使用模式对象而不仅仅是字符串,可以对匹配的详细信息进行额外控制。这允许你控制所谓的正则表达式标志,并匹配各种类型的固定字符串,如下所述。

## 正则表达式标志

可以使用多种设置来控制正则表达式的详细信息。这些设置在其他编程语言中通常称为*标志*。在 stringr 中,可以通过将模式包装在[`regex()`](https://stringr.tidyverse.org/reference/modifiers.xhtml)调用中来使用它们。可能最有用的标志是`ignore_case = TRUE`,因为它允许字符匹配它们的大写或小写形式:

bananas <- c("banana", "Banana", "BANANA")
str_view(bananas, "banana")

> [1] │

str_view(bananas, regex("banana", ignore_case = TRUE))

> [1] │

> [2] │

> [3] │


如果你正在处理大量包含`\n`的多行字符串(即多行文本),`dotall`和`multiline`可能也会有用:

+   `dotall = TRUE`允许`.`匹配所有内容,包括`\n`:

    ```
    x <- "Line 1\nLine 2\nLine 3"
    str_view(x, ".Line")
    str_view(x, regex(".Line", dotall = TRUE))
    #> [1] │ Line 1<
    #>     │ Line> 2<
    #>     │ Line> 3
    ```

+   `multiline = TRUE`使得`^`和`$`分别匹配每行的开头和结尾,而不是整个字符串的开头和结尾:

    ```
    x <- "Line 1\nLine 2\nLine 3"
    str_view(x, "^Line")
    #> [1] │ <Line> 1
    #>     │ Line 2
    #>     │ Line 3
    str_view(x, regex("^Line", multiline = TRUE))
    #> [1] │ <Line> 1
    #>     │ <Line> 2
    #>     │ <Line> 3
    ```

最后,如果你正在编写复杂的正则表达式,并且担心将来可能不理解它,可以尝试使用`comments = TRUE`。它调整模式语言,忽略空格、换行以及`#`后的所有内容。这允许你使用注释和空白来使复杂的正则表达式更易于理解,⁹ 如下例所示:

phone <- regex(
r"(
(? # optional opening parens
(\d{3}) # area code
[)-]? # optional closing parens or dash
\ ? # optional space
(\d{3}) # another three numbers
[\ -]? # optional space or dash
(\d{4}) # four more numbers
)",
comments = TRUE
)

str_extract(c("514-791-8141", "(123) 456 7890", "123456"), phone)

> [1] "514-791-8141" "(123) 456 7890" NA


如果你正在使用注释并希望匹配空格、换行或`#`,则需要用`\`转义它。

## 固定匹配

你可以通过使用[`fixed()`](https://stringr.tidyverse.org/reference/modifiers.xhtml)来退出正则表达式规则:

str_view(c("", "a", "."), fixed("."))

> [3] │ <.>


[`fixed()`](https://stringr.tidyverse.org/reference/modifiers.xhtml)也允许你忽略大小写:

str_view("x X", "X")

> [1] │ x

str_view("x X", fixed("X", ignore_case = TRUE))

> [1] │


如果你处理非英文文本,可能需要使用[`coll()`](https://stringr.tidyverse.org/reference/modifiers.xhtml)而不是[`fixed()`](https://stringr.tidyverse.org/reference/modifiers.xhtml),因为它按照指定的`locale`实现大写规则的完整规则。详见“非英文文本”获取更多关于 locales 的详细信息。

str_view("i İ ı I", fixed("İ", ignore_case = TRUE))

> [1] │ i <İ> ı I

str_view("i İ ı I", coll("İ", ignore_case = TRUE, locale = "tr"))

> [1] │ <İ> ı I


# 练习

要将这些想法付诸实践,我们将接下来解决几个半真实的问题。我们将讨论三种常用的技术:

+   通过创建简单的正负控制来检查你的工作

+   将正则表达式与布尔代数结合使用

+   使用字符串操作创建复杂的模式

## 检查你的工作

首先,让我们找到所有以“The”开头的句子。仅使用`^`锚点是不够的:

str_view(sentences, "^The")

> [1] │ birch canoe slid on the smooth planks.

> [4] │ se days a chicken leg is a rare dish.

> [6] │ juice of lemons makes fine punch.

> [7] │ box was thrown beside the parked truck.

> [8] │ hogs were fed chopped corn and garbage.

> [11] │ boy was there when the sun rose.

> ... and 271 more


该模式还匹配以`They`或`These`开头的句子。我们需要确保“e”是单词的最后一个字母,可以通过添加单词边界来实现:

str_view(sentences, "^The\b")

> [1] │ birch canoe slid on the smooth planks.

> [6] │ juice of lemons makes fine punch.

> [7] │ box was thrown beside the parked truck.

> [8] │ hogs were fed chopped corn and garbage.

> [11] │ boy was there when the sun rose.

> [13] │ source of the huge river is the clear spring.

> ... and 250 more


如何找到所有以代词开头的句子?

str_view(sentences, "^She|He|It|They\b")

> [3] │ 's easy to tell the depth of a well.

> [15] │ lp the woman get back to her feet.

> [27] │ r purse was full of useless trash.

> [29] │ snowed, rained, and hailed the same morning.

> [63] │ ran half way to the hardware store.

> [90] │ lay prone and hardly moved a limb.

> ... and 57 more


快速检查结果显示我们得到了一些误匹配。这是因为我们忘记使用括号:

str_view(sentences, "^(She|He|It|They)\b")

> [3] │ 's easy to tell the depth of a well.

> [29] │ snowed, rained, and hailed the same morning.

> [63] │ ran half way to the hardware store.

> [90] │ lay prone and hardly moved a limb.

> [116] │ ordered peach pie with ice cream.

> [127] │ caught its hind paw in a rusty trap.

> ... and 51 more


如果在最初几次匹配中没有出现这样的错误,你可能会想知道如何发现这样的错误。一个好的技巧是创建几个正面和负面的匹配,并使用它们来测试你的模式是否按预期工作:

pos <- c("He is a boy", "She had a good time")
neg <- c("Shells come from the sea", "Hadley said 'It's a great day'")

pattern <- "^(She|He|It|They)\b"
str_detect(pos, pattern)

> [1] TRUE TRUE

str_detect(neg, pattern)

> [1] FALSE FALSE


通常,提供好的正面示例比负面示例更容易,因为在熟练掌握正则表达式之前,预测自己的弱点需要一段时间。尽管如此,负面示例仍然很有用:在解决问题时,您可以逐渐积累自己的错误集合,确保不再犯同样的错误。

## 布尔运算

假设我们想找到只包含辅音字母的单词。一种技术是创建一个包含所有字母但不包含元音字母的字符类(`[^aeiou]`),然后允许它匹配任意数量的字母(`[^aeiou]+`),然后通过锚定到字符串的开头和结尾来确保匹配整个字符串(`^[^aeiou]+$`):

str_view(words, "[aeiou]+$")

> [123] │

> [249] │

> [328] │

> [538] │

> [895] │

> [952] │


但是,如果将问题稍微转变一下,你可以使这个问题变得更简单。我们不再寻找只包含辅音字母的单词,而是寻找不包含任何元音字母的单词:

str_view(words[!str_detect(words, "[aeiou]")])

> [1] │ by

> [2] │ dry

> [3] │ fly

> [4] │ mrs

> [5] │ try

> [6] │ why


这是一种在处理逻辑组合时特别有用的技术,特别是涉及“与”或“非”运算的情况。例如,想象一下,如果你想找到所有包含“a”和“b”的单词。正则表达式中没有“与”运算符,因此我们必须通过查找所有包含“a”后跟“b”或“b”后跟“a”的单词来解决这个问题:

str_view(words, "a.b|b.a")

> [2] │ le

> [3] │ out

> [4] │ solute

> [62] │ le

> [66] │ by

> [67] │ ck

> ... and 24 more


使用两次调用[`str_detect()`](https://stringr.tidyverse.org/reference/str_detect.xhtml)的结果组合起来更简单:

words[str_detect(words, "a") & str_detect(words, "b")]

> [1] "able" "about" "absolute" "available" "baby" "back"

> [7] "bad" "bag" "balance" "ball" "bank" "bar"

> [13] "base" "basis" "bear" "beat" "beauty" "because"

> [19] "black" "board" "boat" "break" "brilliant" "britain"

> [25] "debate" "husband" "labour" "maybe" "probable" "table"


如果我们想要查看是否有一个单词包含所有元音字母怎么办?如果我们使用模式来做,我们需要生成`5!`(120)种不同的模式:

words[str_detect(words, "a.e.i.o.u")]

...

words[str_detect(words, "u.o.i.e.a")]


使用[`str_detect()`](https://stringr.tidyverse.org/reference/str_detect.xhtml)的五次调用来组合要素显然更为简单:

words[
str_detect(words, "a") &
str_detect(words, "e") &
str_detect(words, "i") &
str_detect(words, "o") &
str_detect(words, "u")
]

> character(0)


一般来说,如果你试图创建一个单一的正则表达式来解决问题,而陷入困境,那么退一步思考一下,看看是否可以将问题分解成更小的部分,在解决每个挑战之前解决它们。

## 使用代码创建模式

如果我们想找出所有提到颜色的 `sentences`,基本思路很简单:我们只需将选择与单词边界结合起来:

str_view(sentences, "\b(red|green|blue)\b")

> [2] │ Glue the sheet to the dark background.

> [26] │ Two fish swam in the tank.

> [92] │ A wisp of cloud hung in the air.

> [148] │ The spot on the blotter was made by ink.

> [160] │ The sofa cushion is and of light weight.

> [174] │ The sky that morning was clear and bright .

> ... and 20 more


但随着颜色数量的增加,手动构建这个模式会很快变得乏味。如果我们能将颜色存储在一个向量中,那不是更好吗?

rgb <- c("red", "green", "blue")


好吧,我们可以!我们只需使用 [`str_c()`](https://stringr.tidyverse.org/reference/str_c.xhtml) 和 [`str_flatten()`](https://stringr.tidyverse.org/reference/str_flatten.xhtml) 从向量中创建模式:

str_c("\b(", str_flatten(rgb, "|"), ")\b")

> [1] "\b(red|green|blue)\b"


如果我们有一个好的颜色列表,我们可以使这个模式更加全面。我们可以从 R 用于绘图的内置颜色列表开始:

str_view(colors())

> [1] │ white

> [2] │ aliceblue

> [3] │ antiquewhite

> [4] │ antiquewhite1

> [5] │ antiquewhite2

> [6] │ antiquewhite3

> ... and 651 more


但让我们首先消除编号变体:

cols <- colors()
cols <- cols[!str_detect(cols, "\d")]
str_view(cols)

> [1] │ white

> [2] │ aliceblue

> [3] │ antiquewhite

> [4] │ aquamarine

> [5] │ azure

> [6] │ beige

> ... and 137 more


然后我们可以将这些内容整合成一个巨大的模式。我们不会在这里展示模式,因为它非常庞大,但你可以看到它在运行时的效果:

pattern <- str_c("\b(", str_flatten(cols, "|"), ")\b")
str_view(sentences, pattern)

> [2] │ Glue the sheet to the dark background.

> [12] │ A rod is used to catch .

> [26] │ Two fish swam in the tank.

> [66] │ Cars and busses stalled in drifts.

> [92] │ A wisp of cloud hung in the air.

> [112] │ Leaves turn and in the fall.

> ... and 57 more


在这个例子中,`cols` 只包含数字和字母,所以你不需要担心元字符。但通常情况下,当你从现有字符串创建模式时,最好通过 [`str_escape()`](https://stringr.tidyverse.org/reference/str_escape.xhtml) 确保它们的字面匹配。

## 练习

1.  对于以下每个挑战,尝试通过单个正则表达式和多个 [`str_detect()`](https://stringr.tidyverse.org/reference/str_detect.xhtml) 调用来解决它们:

    1.  找出所有以 `x` 开头或结尾的 `words`。

    1.  找出所有以元音开头并以辅音结尾的 `words`。

    1.  是否有任何包含每个不同元音字母的 `words`?

1.  构建模式以找到支持和反对“在 c 之后 e 之前的 i”规则的证据。

1.  [`colors()`](https://rdrr.io/r/grDevices/colors.xhtml) 包含一些修饰符,如“lightgray”和“darkblue”。你如何自动识别这些修饰符?(思考如何检测并删除被修饰的颜色。)

1.  创建一个正则表达式,可以找到任何基本的 R 数据集。你可以通过 [`data()`](https://rdrr.io/r/utils/data.xhtml) 函数的特殊用法获取这些数据集的列表:`data(package = "datasets")$results[, "Item"]`。请注意,一些旧的数据集是单独的向量;它们包含了带括号的分组“数据框”的名称,因此你需要去掉它们。

# 其他地方的正则表达式

就像在 stringr 和 tidyr 函数中一样,R 中有许多其他地方可以使用正则表达式。下面的章节描述了更广泛的 tidyverse 和 base R 中一些其他有用的函数。

## Tidyverse

还有三个特别有用的地方,你可能想使用正则表达式:

+   `matches(pattern)`将选择所有名称与提供的模式匹配的变量。这是一个“tidyselect”函数,您可以在选择变量的任何 tidyverse 函数中使用它(例如[`select()`](https://dplyr.tidyverse.org/reference/select.xhtml)、[`rename_with()`](https://dplyr.tidyverse.org/reference/rename.xhtml)和[`across()`](https://dplyr.tidyverse.org/reference/across.xhtml))。

+   `pivot_longer()`的`names_pattern`参数接受正则表达式向量,就像[`separate_wider_regex()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)一样。在从具有复杂结构的变量名中提取数据时非常有用。

+   [`separate_longer_delim()`](https://tidyr.tidyverse.org/reference/separate_longer_delim.xhtml)和[`separate_wider_delim()`](https://tidyr.tidyverse.org/reference/separate_wider_delim.xhtml)中的`delim`参数通常匹配固定字符串,但您可以使用[`regex()`](https://stringr.tidyverse.org/reference/modifiers.xhtml)使其匹配模式。例如,如果您想匹配一个可能后跟空格的逗号,即`regex(", ?")`,这将非常有用。

## 基础 R

`apropos(pattern)`搜索全局环境中与给定模式匹配的所有可用对象。如果您记不清函数的名称,这将非常有用:

apropos("replace")

> [1] "%+replace%" "replace" "replace_na"

> [4] "setReplaceMethod" "str_replace" "str_replace_all"

> [7] "str_replace_na" "theme_replace"


`list.files(path, pattern)`列出了路径`path`中与正则表达式`pattern`匹配的所有文件。例如,您可以在当前目录中找到所有 R Markdown 文件:

head(list.files(pattern = "\.Rmd$"))

> character(0)


值得注意的是,基础 R 使用的模式语言与 stringr 使用的略有不同。这是因为 stringr 建立在[stringi 包](https://oreil.ly/abQNx)的基础上,而 stringi 包则建立在[ICU 引擎](https://oreil.ly/A9Gbl)的基础上,而基础 R 函数则使用[TRE 引擎](https://oreil.ly/yGQ5U)或[PCRE 引擎](https://oreil.ly/VhVuy),具体取决于您是否设置了`perl = TRUE`。幸运的是,正则表达式的基础已经非常成熟,所以在使用本书学到的模式时,您很少会遇到差异。只有在开始依赖像复杂的 Unicode 字符范围或使用`(?…)`语法的特殊功能时,您才需要注意这种差异。

# 概要

由于每个标点符号都可能具有多重含义,正则表达式是最紧凑的语言之一。起初它们确实令人困惑,但随着您的眼睛和大脑的训练,您将能够解读并理解它们,从而掌握一项在 R 和许多其他领域中都可以使用的强大技能。

在本章中,通过学习最有用的 stringr 函数和正则表达式语言的最重要组成部分,您已经开始了成为正则表达式大师的旅程。还有许多资源可以进一步学习。

一个很好的起点是 [`vignette("regular-expressions", package = "stringr")`](https://stringr.tidyverse.org/articles/regular-expressions.xhtml):它记录了 stringr 支持的完整语法集。另一个有用的参考是 [*https://oreil.ly/MVwoC*](https://oreil.ly/MVwoC)。它并非专门针对 R,但你可以用它来了解正则表达式的最高级功能及其内部工作原理。

字符串包 stringr 是基于 Marek Gagolewski 的 stringi 包实现的。如果你在 stringr 中找不到需要的功能,不要害怕去看看 stringi。由于 stringr 遵循许多相同的约定,你会很容易掌握 stringi。

在下一章中,我们将讨论与字符串密切相关的数据结构:因子。因子用于在 R 中表示分类数据,即由字符串向量标识的具有固定和已知可能值集合的数据。

¹ 你可以用硬音 g(“reg-x”)或软音 g(“rej-x”)来发音它。

² 你将学会如何在 “转义” 中避免这些特殊含义。

³ 嗯,除了 `\n` 之外的任何字符。

⁴ 这给出了包含 “x” 的*名称*比例;如果你想要包含 “x” 的名字比例,你需要进行加权平均。

⁵ 我们希望能够向你保证,你在实际生活中不会看到这么奇怪的东西,但不幸的是,在你的职业生涯中,你可能会遇到更多奇怪的事情!

⁶ 元字符的完整集合是 `.^$\|*+?{}[]()`。

⁷ 记住,要创建包含 `\d` 或 `\s` 的正则表达式,你需要为字符串转义 `\`,因此你会输入 `"\\d"` 或 `"\\s"`。

⁸ 主要是因为我们在这本书中从未讨论过矩阵!

⁹ 在这里我们使用的原始字符串中,`comments = TRUE` 特别有效。


# 第十六章:因子

# 引言

因子用于分类变量,即具有固定和已知可能值集合的变量。当您希望以非字母顺序显示字符向量时,它们也很有用。

我们将首先阐明为什么在数据分析中需要因子¹,以及如何使用[`factor()`](https://rdrr.io/r/base/factor.xhtml)来创建它们。然后我们将向您介绍`gss_cat`数据集,其中包含一些分类变量供您进行实验。接下来,您将使用该数据集来练习修改因子的顺序和值,最后我们将讨论有序因子。

## 先决条件

基础 R 提供了一些基本工具来创建和操作因子。我们将通过 forcats 包进行补充,该包是核心 tidyverse 的一部分。它提供了处理*cat*egorical 变量的工具(并且它是因子的字谜!),使用各种助手处理因子。

library(tidyverse)


# 因子基础知识

想象一下,您有一个记录月份的变量:

x1 <- c("Dec", "Apr", "Jan", "Mar")


使用字符串记录此变量存在两个问题:

1.  只有 12 个可能的月份,没有任何东西可以防止您打字错误:

    ```
    x2 <- c("Dec", "Apr", "Jam", "Mar")
    ```

1.  它并不以有用的方式排序:

    ```
    sort(x1)
    #> [1] "Apr" "Dec" "Jan" "Mar"
    ```

您可以用一个因子来解决这两个问题。要创建一个因子,您必须首先创建一个有效*级别*的列表:

month_levels <- c(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
)


现在您可以创建一个因子:

y1 <- factor(x1, levels = month_levels)
y1

> [1] Dec Apr Jan Mar

> Levels: Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec

sort(y1)

> [1] Jan Mar Apr Dec

> Levels: Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec


不在级别中的任何值将会被静默转换为`NA`:

y2 <- factor(x2, levels = month_levels)
y2

> [1] Dec Apr Mar

> Levels: Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec


这看起来很冒险,所以你可能想使用[`forcats::fct()`](https://forcats.tidyverse.org/reference/fct.xhtml)代替:

y2 <- fct(x2, levels = month_levels)

> Error in fct():

> ! All values of x must appear in levels or na

> ℹ Missing level: "Jam"


如果省略级别,它们将按照字母顺序从数据中获取:

factor(x1)

> [1] Dec Apr Jan Mar

> Levels: Apr Dec Jan Mar


按字母顺序排序稍微有风险,因为不是每台计算机都会以相同的方式排序字符串。因此,[`forcats::fct()`](https://forcats.tidyverse.org/reference/fct.xhtml)根据首次出现顺序排序:

fct(x1)

> [1] Dec Apr Jan Mar

> Levels: Dec Apr Jan Mar


如果您需要直接访问有效级别集合,可以使用[`levels()`](https://rdrr.io/r/base/levels.xhtml):

levels(y2)

> [1] "Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec"


您还可以在使用 readr 读取数据时使用[`col_factor()`](https://readr.tidyverse.org/reference/parse_factor.xhtml)创建因子:

csv <- "
month,value
Jan,12
Feb,56
Mar,12"

df <- read_csv(csv, col_types = cols(month = col_factor(month_levels)))
df$month

> [1] Jan Feb Mar

> Levels: Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec


# 普遍社会调查

在本章的其余部分,我们将使用[`forcats::gss_cat`](https://forcats.tidyverse.org/reference/gss_cat.xhtml)。这是来自[General Social Survey](https://oreil.ly/3qBI5)的数据样本,是由芝加哥大学的独立研究组织 NORC 长期进行的美国调查。该调查有成千上万个问题,所以在`gss_cat`中,Hadley 选择了一些将说明在处理因子时可能遇到的常见挑战。

gss_cat

> # A tibble: 21,483 × 9

> year marital age race rincome partyid

>

> 1 2000 Never married 26 White $8000 to 9999 Ind,near rep

> 2 2000 Divorced 48 White $8000 to 9999 Not str republican

> 3 2000 Widowed 67 White Not applicable Independent

> 4 2000 Never married 39 White Not applicable Ind,near rep

> 5 2000 Divorced 25 White Not applicable Not str democrat

> 6 2000 Married 25 White $20000 - 24999 Strong democrat

> # … with 21,477 more rows, and 3 more variables: relig , denom ,

> # tvhours


(记住,由于这个数据集是由一个包提供的,你可以通过[`?gss_cat`](https://forcats.tidyverse.org/reference/gss_cat.xhtml)获取关于变量的更多信息。)

当因子存储在 tibble 中时,您不能轻松地查看它们的级别。一种查看它们的方法是使用[`count()`](https://dplyr.tidyverse.org/reference/count.xhtml):

gss_cat |>
count(race)

> # A tibble: 3 × 2

> race n

>

> 1 Other 1959

> 2 Black 3129

> 3 White 16395


在处理因子时,最常见的两个操作是改变水平的顺序和改变水平的值。这些操作在以下部分中进行描述。

## 练习

1.  探索`rincome`(报告收入)的分布。默认的条形图为何难以理解?如何改进绘图?

1.  这项调查中最常见的`relig`是什么?最常见的`partyid`是什么?

1.  `denom`(教派)适用于哪种`relig`(宗教)?如何通过表格找出?如何通过可视化找出?

# 修改因子顺序

在可视化中改变因子水平的顺序通常很有用。例如,想象一下你想要探索每天平均看电视时间跨越宗教的情况:

relig_summary <- gss_cat |>
group_by(relig) |>
summarize(
tvhours = mean(tvhours, na.rm = TRUE),
n = n()
)

ggplot(relig_summary, aes(x = tvhours, y = relig)) +
geom_point()


![一个散点图,X 轴是 tvhours,Y 轴是宗教。Y 轴似乎是任意排序的,这使得很难理解总体模式。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_16in01.png)

由于没有总体模式,阅读这个图很困难。我们可以通过使用[`fct_reorder()`](https://forcats.tidyverse.org/reference/fct_reorder.xhtml)重新排序`relig`的水平来改进它。[`fct_reorder()`](https://forcats.tidyverse.org/reference/fct_reorder.xhtml)有三个参数:

+   `f`,你想要修改其水平的因子。

+   `x`,你想要用来重新排序水平的数值向量。

+   可选,`fun`,如果每个`f`值有多个`x`值,则使用的函数。默认值是`median`。

ggplot(relig_summary, aes(x = tvhours, y = fct_reorder(relig, tvhours))) +
geom_point()


![和上面相同的散点图,但现在宗教按 tvhours 递增的顺序显示。“其他东方”在 2 以下有最少的 tvhours,而“不知道”的最高(超过 5)。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_16in02.png)

重新排序宗教使得更容易看出,“不知道”类别的人看更多电视,印度教和其他东方宗教则看得较少。

当你开始进行更复杂的转换时,我们建议将它们从[`aes()`](https://ggplot2.tidyverse.org/reference/aes.xhtml)移出,放到单独的[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)步骤中。例如,你可以将前一个图重新编写为:

relig_summary |>
mutate(
relig = fct_reorder(relig, tvhours)
) |>
ggplot(aes(x = tvhours, y = relig)) +
geom_point()


如果我们创建一个类似的图,看看平均年龄如何随报告收入水平变化?

rincome_summary <- gss_cat |>
group_by(rincome) |>
summarize(
age = mean(age, na.rm = TRUE),
n = n()
)

ggplot(rincome_summary, aes(x = age, y = fct_reorder(rincome, age))) +
geom_point()


![一个散点图,X 轴是年龄,Y 轴是收入。收入按平均年龄排序,这没有太多意义。Y 轴的一部分从$6000-6999,然后是<\$1000,接着是$8000-9999。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_16in03.png)

在这里,任意重新排序水平不是一个好主意!这是因为`rincome`已经有一个原则性的顺序,我们不应该搞乱它。保留[`fct_reorder()`](https://forcats.tidyverse.org/reference/fct_reorder.xhtml)用于那些水平是任意排序的因子。

然而,将“不适用”与其他特殊级别一起移到前面确实有意义。您可以使用[`fct_relevel()`](https://forcats.tidyverse.org/reference/fct_relevel.xhtml)。它接受一个因子`f`,然后是您希望移到开头的任意数量级别。

ggplot(rincome_summary, aes(x = age, y = fct_relevel(rincome, "Not applicable"))) +
geom_point()


![同样的散点图,但现在“不适用”显示在 y 轴底部。通常,收入与年龄之间存在正相关,具有最高平均年龄的收入段是“不适用”。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_16in04.png)

您认为“不适用”的平均年龄为什么如此之高?

另一种重新排序类型在为绘图着色时非常有用。`fct_reorder2(f, x, y)`通过最大`x`值关联的`y`值重新排序因子`f`。这样做使得图表更易读,因为图表最右侧的线条颜色将与图例对齐。

by_age <- gss_cat |>
filter(!is.na(age)) |>
count(age, marital) |>
group_by(age) |>
mutate(
prop = n / sum(n)
)

ggplot(by_age, aes(x = age, y = prop, color = marital)) +
geom_line(linewidth = 1) +
scale_color_brewer(palette = "Set1")

ggplot(by_age, aes(x = age, y = prop, color = fct_reorder2(marital, age, prop))) +
geom_line(linewidth = 1) +
scale_color_brewer(palette = "Set1") +
labs(color = "marital")


![一个线图,x 轴是年龄,y 轴是比例。每个婚姻状况类别都有一条线:未答复,从未结婚,分居,离婚,丧偶和已婚。图表读取起来有点困难,因为图例的顺序与图表上的线条无关。重新排列图例使图表更易读,因为现在图例的颜色与图表右侧的线条顺序相匹配。您可以看到一些不足为奇的模式:从未结婚的比例随着年龄减少,已婚形成倒置的 U 形,而丧偶在 60 岁后急剧增加。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_16in05.png)

最后,对于条形图,您可以使用[`fct_infreq()`](https://forcats.tidyverse.org/reference/fct_inorder.xhtml)按降频顺序排列级别:这是最简单的重新排序类型,因为不需要额外变量。如果希望按增频顺序排列,可以与[`fct_rev()`](https://forcats.tidyverse.org/reference/fct_rev.xhtml)结合使用,这样在条形图中,最大值将位于右侧而不是左侧。

gss_cat |>
mutate(marital = marital |> fct_infreq() |> fct_rev()) |>
ggplot(aes(x = marital)) +
geom_bar()


![婚姻状况的条形图,按从最不常见到最常见排序:未答复(~0),分居(~1,000),丧偶(~2,000),离婚(~3,000),从未结婚(~5,000),已婚(~10,000)。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_16in06.png)

## 练习

1.  `tvhours`中有一些可疑的高数值。平均数是否是一个良好的摘要?

1.  对于`gss_cat`中的每个因子,确定级别的顺序是任意的还是有原则的。

1.  为什么将“不适用”移至级别的前面后,它会移动到绘图的底部?

# 修改因子级别

更改级别顺序比改变级别值更强大。这允许您在发布标签时澄清,并在高级显示中折叠级别。最通用和强大的工具是[`fct_recode()`](https://forcats.tidyverse.org/reference/fct_recode.xhtml)。它允许您重新编码或更改每个级别的值。例如,从`gss_cat`数据框架中获取`partyid`变量:

gss_cat |> count(partyid)

> # A tibble: 10 × 2

> partyid n

>

> 1 No answer 154

> 2 Don't know 1

> 3 Other party 393

> 4 Strong republican 2314

> 5 Not str republican 3032

> 6 Ind,near rep 1791

> # … with 4 more rows


级别简洁且不一致。让我们将它们调整为更长并使用并列结构。像 tidyverse 中大多数重命名和重编码函数一样,新值放在左边,旧值放在右边:

gss_cat |>
mutate(
partyid = fct_recode(partyid,
"Republican, strong" = "Strong republican",
"Republican, weak" = "Not str republican",
"Independent, near rep" = "Ind,near rep",
"Independent, near dem" = "Ind,near dem",
"Democrat, weak" = "Not str democrat",
"Democrat, strong" = "Strong democrat"
)
) |>
count(partyid)

> # A tibble: 10 × 2

> partyid n

>

> 1 No answer 154

> 2 Don't know 1

> 3 Other party 393

> 4 Republican, strong 2314

> 5 Republican, weak 3032

> 6 Independent, near rep 1791

> # … with 4 more rows


[`fct_recode()`](https://forcats.tidyverse.org/reference/fct_recode.xhtml)会保留未明确提及的级别,并在您意外引用不存在级别时发出警告。

要组合组,可以将多个旧级别分配到同一个新级别:

gss_cat |>
mutate(
partyid = fct_recode(partyid,
"Republican, strong" = "Strong republican",
"Republican, weak" = "Not str republican",
"Independent, near rep" = "Ind,near rep",
"Independent, near dem" = "Ind,near dem",
"Democrat, weak" = "Not str democrat",
"Democrat, strong" = "Strong democrat",
"Other" = "No answer",
"Other" = "Don't know",
"Other" = "Other party"
)
)


要小心使用这种技术:如果将真正不同的级别分组,最终会导致误导性结果。

如果要折叠许多级别,[`fct_collapse()`](https://forcats.tidyverse.org/reference/fct_collapse.xhtml)是[`fct_recode()`](https://forcats.tidyverse.org/reference/fct_recode.xhtml)的有用变体。对于每个新变量,可以提供一个旧级别的向量:

gss_cat |>
mutate(
partyid = fct_collapse(partyid,
"other" = c("No answer", "Don't know", "Other party"),
"rep" = c("Strong republican", "Not str republican"),
"ind" = c("Ind,near rep", "Independent", "Ind,near dem"),
"dem" = c("Not str democrat", "Strong democrat")
)
) |>
count(partyid)

> # A tibble: 4 × 2

> partyid n

>

> 1 other 548

> 2 rep 5346

> 3 ind 8409

> 4 dem 7180


有时您只想将小组合并在一起,以使绘图或表格更简单。这就是`fct_lump_*()`函数族的工作。[`fct_lump_lowfreq()`](https://forcats.tidyverse.org/reference/fct_lump.xhtml)是一个简单的起点,它将最小组的类别逐渐合并为“Other”,始终将“Other”保持为最小类别。

gss_cat |>
mutate(relig = fct_lump_lowfreq(relig)) |>
count(relig)

> # A tibble: 2 × 2

> relig n

>

> 1 Protestant 10846

> 2 Other 10637


在这种情况下,它并不是很有帮助:虽然这次调查中大多数美国人是新教徒,但我们可能希望看到更多细节!相反,我们可以使用[`fct_lump_n()`](https://forcats.tidyverse.org/reference/fct_lump.xhtml)指定我们想要确切的 10 个组:

gss_cat |>
mutate(relig = fct_lump_n(relig, n = 10)) |>
count(relig, sort = TRUE)

> # A tibble: 10 × 2

> relig n

>

> 1 Protestant 10846

> 2 Catholic 5124

> 3 None 3523

> 4 Christian 689

> 5 Other 458

> 6 Jewish 388

> # … with 4 more rows


阅读文档以了解有关[`fct_lump_min()`](https://forcats.tidyverse.org/reference/fct_lump.xhtml)和[`fct_lump_prop()`](https://forcats.tidyverse.org/reference/fct_lump.xhtml)的信息,它们在其他情况下非常有用。

## 练习

1.  随着时间的推移,认同民主党、共和党和独立派的人数比例如何变化?

1.  您如何将`rincome`合并为少量类别?

1.  注意在前述`fct_lump`示例中有 9 个组(不包括其他)。为什么不是 10 个?(提示:输入[`?fct_lump`](https://forcats.tidyverse.org/reference/fct_lump.xhtml),找到参数`other_level`的默认值为“Other”。)

# 有序因子

在继续之前,需要简要提到一种特殊类型的因子:有序因子。使用[`ordered()`](https://rdrr.io/r/base/factor.xhtml)创建的有序因子暗示严格的排序和级别之间的等距:第一个级别“小于”第二个级别,与第二个级别“小于”第三个级别的量相同,依此类推。打印时可以通过级别之间的`<`来识别它们:

ordered(c("a", "b", "c"))

> [1] a b c

> Levels: a < b < c


在实践中,[`ordered()`](https://rdrr.io/r/base/factor.xhtml)因子的行为与常规因子类似。只有两个地方可能会注意到不同的行为:

+   如果您将有序因子映射到 ggplot2 中的颜色或填充,它将默认使用`scale_color_viridis()`/`scale_fill_viridis()`,这是一种暗示排名的颜色比例尺。

+   如果您在线性模型中使用有序函数,它将使用“多边形对比”。这些对比略有用处,但您可能从未听说过,除非您拥有统计学博士学位,即使如此,您可能也不会经常解释它们。如果您想了解更多信息,我们建议查阅`vignette("contrasts", package = "faux")`,作者是 Lisa DeBruine。

鉴于这些差异的争议性,我们通常不建议使用有序因子。

# 总结

本章向您介绍了用于处理因子的实用 forcats 包,并解释了最常用的函数。forcats 包含许多其他辅助工具,这里我们没有讨论的空间,因此每当您面临以前未遇到的因子分析挑战时,我强烈建议浏览[参考索引](https://oreil.ly/J_IIg),看看是否有预设函数可以帮助解决您的问题。

如果您在阅读本章后想了解更多关于因子的知识,我们建议阅读 Amelia McNamara 和 Nicholas Horton 的论文,《在 R 中处理分类数据》([“Wrangling categorical data in R”](https://oreil.ly/zPh8E))。该论文概述了在[“stringsAsFactors: An unauthorized biography”](https://oreil.ly/Z9mkP)和[“stringsAsFactors = <sigh>”](https://oreil.ly/phWQo)中讨论的部分历史,并将本书中的整洁方法与基础 R 方法进行了比较。该论文的早期版本有助于激发并确定了 forcats 包的范围;感谢 Amelia 和 Nick!

在下一章中,我们将转换方向,开始学习 R 中的日期和时间。日期和时间看起来似乎很简单,但正如您很快会发现的那样,您学到的越多,它们似乎就变得越复杂!

¹ 对于建模来说,它们也非常重要。


# 第十七章:日期和时间

# 介绍

本章将向您展示如何在 R 中处理日期和时间。乍一看,日期和时间似乎很简单。您在日常生活中经常使用它们,并且似乎并没有引起太多混淆。然而,您越了解日期和时间,它们似乎就越复杂!

为了热身,想想一年有多少天,一天有多少小时。你可能记得大多数年份有 365 天,但闰年有 366 天。你知道如何准确判断一个年份是否是闰年吗?¹ 一天有多少小时不那么明显:大多数日子有 24 小时,但在使用夏令时(DST)的地方,每年会有一天有 23 小时,另一天有 25 小时。

日期和时间之所以困难,是因为它们需要协调两个物理现象(地球的自转和绕太阳公转),以及一系列地缘政治现象,包括月份、时区和夏令时。本章不会教会您关于日期和时间的每一个细节,但它将为您提供实用技能的坚实基础,帮助您解决常见的数据分析挑战。

我们将从展示如何从不同输入创建日期时间开始,一旦您有了日期时间,您将学习如何提取年、月和日等组件。然后我们将深入讨论处理时间跨度的复杂主题,这取决于您尝试做什么,有多种不同的选择。最后我们将简要讨论时区带来的额外挑战。

## 先决条件

本章将专注于 lubridate 包,在 R 中更轻松地处理日期和时间。截至最新的 tidyverse 发布版本,lubridate 已成为核心 tidyverse 的一部分。我们还需要 nycflights13 作为实践数据。

library(tidyverse)
library(nycflights13)


# 创建日期/时间

有三种时间/日期数据类型指代某一时刻:

+   *日期*。Tibbles 打印为 `<date>`。

+   *一天内的时间*。Tibbles 打印为 `<time>`。

+   *日期时间*是日期加时间:它唯一标识一个时刻(通常精确到最近的秒)。Tibbles 打印为 `<dttm>`。基础 R 称之为 POSIXct,但这个术语不太口语化。

在本章中,我们将专注于日期和日期时间,因为 R 没有用于存储时间的本机类。如果需要,您可以使用 hms 包。

您应该始终使用最简单可能的数据类型来满足您的需求。这意味着如果可以使用日期而不是日期时间,您应该这样做。由于需要处理时区,日期时间要复杂得多,我们将在本章末尾回顾这一点。

要获取当前日期或日期时间,您可以使用[`today()`](https://lubridate.tidyverse.org/reference/now.xhtml)或[`now()`](https://lubridate.tidyverse.org/reference/now.xhtml):

today()

> [1] "2023-03-12"

now()

> [1] "2023-03-12 13:07:31 CDT"


此外,以下各节描述了您可能会创建日期/时间的四种常见方式:

+   在使用 readr 读取文件时

+   从字符串

+   从单独的日期时间组件

+   从现有的日期/时间对象

## 在导入期间

如果您的 CSV 包含 ISO8601 日期或日期时间,则无需进行任何操作;readr 将自动识别它:

csv <- "
date,datetime
2022-01-02,2022-01-02 05:12
"
read_csv(csv)

> # A tibble: 1 × 2

> date datetime

>

> 1 2022-01-02 2022-01-02 05:12:00


如果您之前没有听说过*ISO8601*,它是一个[国际标准](https://oreil.ly/19K7t),用于编写日期,其中日期的组成部分从最大到最小排列,用`-`分隔。例如,在 ISO8601 中,2022 年 5 月 3 日写作`2022-05-03`。ISO8601 日期还可以包括时间,其中小时、分钟和秒以`:`分隔,日期和时间组件以`T`或空格分隔。例如,您可以将 2022 年 5 月 3 日下午 4 点 26 分写为`2022-05-03 16:26`或`2022-05-03T16:26`。

对于其他日期时间格式,您需要使用`col_types`加上[`col_date()`](https://readr.tidyverse.org/reference/parse_datetime.xhtml)或[`col_datetime()`](https://readr.tidyverse.org/reference/parse_datetime.xhtml),以及日期时间格式。readr 使用的日期时间格式是许多编程语言中通用的标准,描述日期组件的方式为`%`后跟单个字符。例如,`%Y-%m-%d`指定了一个日期,年份`-`月份(数字)`-`日。表 17-1 列出了所有选项。

表 17-1。readr 理解的所有日期格式

| 类型 | 代码 | 意义 | 示例 |
| --- | --- | --- | --- |
| 年份 | `%Y` | 4 位数年份 | 2021 |
|  | `%y` | 2 位数年份 | 21 |
| 月份 | `%m` | 数字 | 2 |
|  | `%b` | 缩写名 | 二月 |
|  | `%B` | 全名 | 二月 |
| 日 | `%d` | 两位数 | 02 |
|  | `%e` | 一位或两位数字 | 2 |
| 时间 | `%H` | 24 小时制小时 | 13 |
|  | `%I` | 12 小时制小时 | 1 |
|  | `%p` | 上午/下午 | 下午 |
|  | `%M` | 分钟 | 35 |
|  | `%S` | 秒数 | 45 |
|  | `%OS` | 带小数部分的秒 | 45.35 |
|  | `%Z` | 时区名称 | 美国/芝加哥 |
|  | `%z` | 与 UTC 的偏移 | +0800 |
| 其他 | `%.` | 跳过一个非数字 | : |
|  | `%*` | 跳过任意数量的非数字 |  |

此代码显示应用于非常模糊日期的几个选项:

csv <- "
date
01/02/15
"

read_csv(csv, col_types = cols(date = col_date("%m/%d/%y")))

> # A tibble: 1 × 1

> date

>

> 1 2015-01-02

read_csv(csv, col_types = cols(date = col_date("%d/%m/%y")))

> # A tibble: 1 × 1

> date

>

> 1 2015-02-01

read_csv(csv, col_types = cols(date = col_date("%y/%m/%d")))

> # A tibble: 1 × 1

> date

>

> 1 2001-02-15


请注意,无论您如何指定日期格式,一旦将其输入到 R 中,它始终以相同的方式显示。

如果您使用`%b`或`%B`并处理非英文日期,则还需要提供[`locale()`](https://readr.tidyverse.org/reference/locale.xhtml)。请参阅[`date_names_langs()`](https://readr.tidyverse.org/reference/date_names.xhtml)中的内置语言列表,或使用[`date_names()`](https://readr.tidyverse.org/reference/date_names.xhtml)创建自己的语言。

## 从字符串

日期时间规范语言功能强大,但需要仔细分析日期格式。另一种方法是使用 lubridate 的辅助函数,它们会在你指定组件顺序后尝试自动确定格式。要使用它们,确定年、月和日在你的日期中的顺序;然后以相同的顺序排列“y”、“m”和“d”。这将为你提供一个 lubridate 函数的名称,该函数将解析你的日期。例如:

ymd("2017-01-31")

> [1] "2017-01-31"

mdy("January 31st, 2017")

> [1] "2017-01-31"

dmy("31-Jan-2017")

> [1] "2017-01-31"


[`ymd()`](https://lubridate.tidyverse.org/reference/ymd.xhtml) 和其它函数用于创建日期。要创建日期时间,只需在解析函数的名称后面加上一个或多个“h”、“m”和“s”的下划线:

ymd_hms("2017-01-31 20:11:59")

> [1] "2017-01-31 20:11:59 UTC"

mdy_hm("01/31/2017 08:01")

> [1] "2017-01-31 08:01:00 UTC"


你也可以通过指定时区强制创建一个日期时间:

ymd("2017-01-31", tz = "UTC")

> [1] "2017-01-31 UTC"


我使用的时区是 UTC²,你可能也知道它作为 GMT 或格林尼治标准时间,即 0°经线处的时间³。这个时区不使用夏令时,计算起来更加简单。

## 从个别组件

有时你会有日期时间的各个组件分布在多个列中,这就是我们在`flights`数据中遇到的情况:

flights |>
select(year, month, day, hour, minute)

> # A tibble: 336,776 × 5

> year month day hour minute

>

> 1 2013 1 1 5 15

> 2 2013 1 1 5 29

> 3 2013 1 1 5 40

> 4 2013 1 1 5 45

> 5 2013 1 1 6 0

> 6 2013 1 1 5 58

> # … with 336,770 more rows


要从这种输入创建日期/时间,请使用[`make_date()`](https://lubridate.tidyverse.org/reference/make_datetime.xhtml)用于日期,或使用[`make_datetime()`](https://lubridate.tidyverse.org/reference/make_datetime.xhtml)用于日期时间:

flights |>
select(year, month, day, hour, minute) |>
mutate(departure = make_datetime(year, month, day, hour, minute))

> # A tibble: 336,776 × 6

> year month day hour minute departure

>

> 1 2013 1 1 5 15 2013-01-01 05:15:00

> 2 2013 1 1 5 29 2013-01-01 05:29:00

> 3 2013 1 1 5 40 2013-01-01 05:40:00

> 4 2013 1 1 5 45 2013-01-01 05:45:00

> 5 2013 1 1 6 0 2013-01-01 06:00:00

> 6 2013 1 1 5 58 2013-01-01 05:58:00

> # … with 336,770 more rows


让我们对`flights`中的四个时间列做同样的事情。这些时间以稍微奇怪的格式表示,因此我们使用模数运算来提取小时和分钟组件。创建了日期时间变量后,我们将重点放在本章余下部分将要探索的变量上。

make_datetime_100 <- function(year, month, day, time) {
make_datetime(year, month, day, time %/% 100, time %% 100)
}

flights_dt <- flights |>
filter(!is.na(dep_time), !is.na(arr_time)) |>
mutate(
dep_time = make_datetime_100(year, month, day, dep_time),
arr_time = make_datetime_100(year, month, day, arr_time),
sched_dep_time = make_datetime_100(year, month, day, sched_dep_time),
sched_arr_time = make_datetime_100(year, month, day, sched_arr_time)
) |>
select(origin, dest, ends_with("delay"), ends_with("time"))

flights_dt

> # A tibble: 328,063 × 9

> origin dest dep_delay arr_delay dep_time sched_dep_time

>

> 1 EWR IAH 2 11 2013-01-01 05:17:00 2013-01-01 05:15:00

> 2 LGA IAH 4 20 2013-01-01 05:33:00 2013-01-01 05:29:00

> 3 JFK MIA 2 33 2013-01-01 05:42:00 2013-01-01 05:40:00

> 4 JFK BQN -1 -18 2013-01-01 05:44:00 2013-01-01 05:45:00

> 5 LGA ATL -6 -25 2013-01-01 05:54:00 2013-01-01 06:00:00

> 6 EWR ORD -4 12 2013-01-01 05:54:00 2013-01-01 05:58:00

> # … with 328,057 more rows, and 3 more variables: arr_time ,

> # sched_arr_time , air_time


有了这些数据,我们可以可视化整年出发时间的分布情况:

flights_dt |>
ggplot(aes(x = dep_time)) +
geom_freqpoly(binwidth = 86400) # 86400 seconds = 1 day


![一个频率多边形图,横轴是出发时间(2013 年 1 月至 12 月),纵轴是航班数(0-1000)。频率多边形按天进行分箱,因此可以看到航班的时间序列。图案由周模式主导;周末航班较少。少数日期在二月初、七月初、十一月末和十二月末的航班明显较少。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_17in01.png)

或者在同一天内:

flights_dt |>
filter(dep_time < ymd(20130102)) |>
ggplot(aes(x = dep_time)) +
geom_freqpoly(binwidth = 600) # 600 s = 10 minutes


![一个频率多边形图,横轴是出发时间(6 点到午夜 1 月 1 日),纵轴是航班数(0-17),分为 10 分钟间隔。由于高度变异性,很难看出明显模式,但大多数区段有 8-12 次航班,而在凌晨 6 点前和晚上 8 点后的航班明显较少。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_17in02.png)

注意,当你在数值上下文中(比如直方图)使用日期时间时,1 代表 1 秒,因此 86400 的区间宽度代表一天。对于日期,1 表示 1 天。

## 从其他类型

您可能需要在日期时间和日期之间切换。这就是[`as_datetime()`](https://lubridate.tidyverse.org/reference/as_date.xhtml)和[`as_date()`](https://lubridate.tidyverse.org/reference/as_date.xhtml)的作用:

as_datetime(today())

> [1] "2023-03-12 UTC"

as_date(now())

> [1] "2023-03-12"


有时您会得到作为从“Unix 纪元”1970-01-01 开始的数值偏移的日期/时间。如果偏移以秒为单位,请使用[`as_datetime()`](https://lubridate.tidyverse.org/reference/as_date.xhtml);如果以天数为单位,请使用[`as_date()`](https://lubridate.tidyverse.org/reference/as_date.xhtml)。

as_datetime(60 * 60 * 10)

> [1] "1970-01-01 10:00:00 UTC"

as_date(365 * 10 + 2)

> [1] "1980-01-01"


## 练习

1.  如果解析包含无效日期的字符串会发生什么?

    ```
    ymd(c("2010-10-10", "bananas"))
    ```

1.  [`today()`](https://lubridate.tidyverse.org/reference/now.xhtml)函数的`tzone`参数是做什么的?为什么它很重要?

1.  对于以下每个日期时间,展示您如何使用 readr 列规范和 lubridate 函数来解析它。

    ```
    d1 <- "January 1, 2010"
    d2 <- "2015-Mar-07"
    d3 <- "06-Jun-2017"
    d4 <- c("August 19 (2015)", "July 1 (2015)")
    d5 <- "12/30/14" # Dec 30, 2014
    t1 <- "1705"
    t2 <- "11:15:10.12 PM"
    ```

# 日期时间组件

现在您已经知道如何将日期时间数据输入到 R 的日期时间数据结构中,让我们探讨您可以用它们做什么。本节将重点介绍让您获取和设置单个组件的访问器函数。接下来的部分将介绍日期时间的算术运算如何工作。

## 获取组件

您可以使用访问器函数[`year()`](https://lubridate.tidyverse.org/reference/year.xhtml)、[`month()`](https://lubridate.tidyverse.org/reference/month.xhtml)、[`mday()`](https://lubridate.tidyverse.org/reference/day.xhtml)(月中的某一天)、[`yday()`](https://lubridate.tidyverse.org/reference/day.xhtml)(年中的某一天)、[`wday()`](https://lubridate.tidyverse.org/reference/day.xhtml)(星期几)、[`hour()`](https://lubridate.tidyverse.org/reference/hour.xhtml)、[`minute()`](https://lubridate.tidyverse.org/reference/minute.xhtml)和[`second()`](https://lubridate.tidyverse.org/reference/second.xhtml)来获取日期的各个部分。这些函数实际上是[`make_datetime()`](https://lubridate.tidyverse.org/reference/make_datetime.xhtml)的反向操作。

datetime <- ymd_hms("2026-07-08 12:34:56")

year(datetime)

> [1] 2026

month(datetime)

> [1] 7

mday(datetime)

> [1] 8

yday(datetime)

> [1] 189

wday(datetime)

> [1] 4


对于[`month()`](https://lubridate.tidyverse.org/reference/month.xhtml)和[`wday()`](https://lubridate.tidyverse.org/reference/day.xhtml),您可以设置`label = TRUE`以返回月份或星期几的缩写名称。将`abbr = FALSE`设置为返回完整名称。

month(datetime, label = TRUE)

> [1] Jul

> 12 Levels: Jan < Feb < Mar < Apr < May < Jun < Jul < Aug < Sep < ... < Dec

wday(datetime, label = TRUE, abbr = FALSE)

> [1] Wednesday

> 7 Levels: Sunday < Monday < Tuesday < Wednesday < Thursday < ... < Saturday


我们可以使用[`wday()`](https://lubridate.tidyverse.org/reference/day.xhtml)来查看工作日比周末更多的航班离港情况:

flights_dt |>
mutate(wday = wday(dep_time, label = TRUE)) |>
ggplot(aes(x = wday)) +
geom_bar()


![一张柱状图,x 轴显示星期几,y 轴显示航班数量。星期一至星期五的航班数量大约相同,约 48,0000,随着一周的进展略有下降。星期日略低(约 45,000),星期六更低(约 38,000)。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_17in03.png)

我们还可以查看每小时内按分钟计算的平均出发延误时间。有一个有趣的模式:在 20-30 分钟和 50-60 分钟离开的航班的延误要比整点其他时间段的航班低得多!

flights_dt |>
mutate(minute = minute(dep_time)) |>
group_by(minute) |>
summarize(
avg_delay = mean(dep_delay, na.rm = TRUE),
n = n()
) |>
ggplot(aes(x = minute, y = avg_delay)) +
geom_line()


![在 x 轴上显示实际出发时间(0-60 分钟)的线性图,y 轴显示平均延误(4-20 分钟)。平均延误从 (0, 12) 开始,稳步增加到 (18, 20),然后急剧下降,在小时过后大约 23 分钟和 9 分钟的延误。然后再次增加到 (17, 35),急剧下降到 (55, 4),最后增加到 (60, 9)。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_17in04.png)

有趣的是,如果我们看的是*计划*出发时间,我们就看不到这样强烈的模式:

sched_dep <- flights_dt |>
mutate(minute = minute(sched_dep_time)) |>
group_by(minute) |>
summarize(
avg_delay = mean(arr_delay, na.rm = TRUE),
n = n()
)

ggplot(sched_dep, aes(x = minute, y = avg_delay)) +
geom_line()


![在 x 轴上显示计划出发时间(0-60 分钟)的线性图,y 轴显示平均延误(4-16 分钟)。没有明显的模式,只有一个小小的倾向,即平均延误从大约 10 分钟降到 8 分钟左右。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_17in05.png)

那么为什么我们在实际出发时间中看到这样的模式呢?嗯,像许多由人类收集的数据一样,这些数据显示出对“好”出发时间的强烈偏好,如 图 17-1 所示。在处理涉及人类判断的数据时,务必警惕这种模式!

![在 x 轴上显示出发分钟(0-60 分钟)的线性图,y 轴显示航班数量(0-60000)。大多数航班计划在整点(约 60,000)或半小时(约 35,000)出发。除此之外,所有航班几乎都计划在 5 的倍数出发,15、45 和 55 分钟也有一些额外的航班。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1701.png)

###### 图 17-1\. 一个频率多边形展示每小时计划出发的航班数量。可以看到对于 0 和 30 这样的整数以及通常为 5 的倍数有很强的偏好。

## 舍入

另一种绘制单独组件的方法是将日期四舍五入到附近的时间单位,使用 [`floor_date()`](https://lubridate.tidyverse.org/reference/round_date.xhtml)、[`round_date()`](https://lubridate.tidyverse.org/reference/round_date.xhtml) 和 [`ceiling_date()`](https://lubridate.tidyverse.org/reference/round_date.xhtml)。每个函数接受需要调整的日期向量,然后是要向下舍入(floor)、向上舍入(ceiling)或四舍五入到的单位名称。例如,这使我们可以绘制每周的航班数量:

flights_dt |>
count(week = floor_date(dep_time, "week")) |>
ggplot(aes(x = week, y = n)) +
geom_line() +
geom_point()


![在 x 轴上显示 2013 年 1 月至 12 月的周数,y 轴显示航班数量(2,000-7,000)。从 2 月到 11 月,每周航班数大致保持稳定在约 7,000。年初(大约 4,500 航班)和年末(大约 2,500 航班)的航班数量要少得多。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_17in06.png)

你可以使用四舍五入来展示一天内航班的分布,方法是计算 `dep_time` 和当天最早时间的差:

flights_dt |>
mutate(dep_hour = dep_time - floor_date(dep_time, "day")) |>
ggplot(aes(x = dep_hour)) +
geom_freqpoly(binwidth = 60 * 30)

> Don't know how to automatically pick scale for object of type .

> Defaulting to continuous.


![在 x 轴上显示出发时间的线性图。这是自午夜以来的秒数单位,因此很难解释。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_17in07.png)

计算一对日期时间之间的差异会产生一个 difftime(关于此更多内容请参见“间隔”)。我们可以将其转换为一个`hms`对象以获得更有用的 x 轴:

flights_dt |>
mutate(dep_hour = hms::as_hms(dep_time - floor_date(dep_time, "day"))) |>
ggplot(aes(x = dep_hour)) +
geom_freqpoly(binwidth = 60 * 30)


![一条线图,以起飞时间(午夜到午夜)为 x 轴,航班数量为 y 轴(0 到 15,000)。在凌晨 5 点之前几乎没有(<100)航班。然后,航班数量迅速上升到每小时 12,000 次,于早上 9 点达到最高峰,然后在上午 10 点到下午 2 点期间下降到每小时约 8,000 次。然后航班数量再次上升,直到晚上 8 点左右迅速下降。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_17in08.png)

## 修改组件

你也可以使用每个访问函数来修改日期/时间的组件。这在数据分析中并不常见,但在清理明显错误日期的数据时可能很有用。

(datetime <- ymd_hms("2026-07-08 12:34:56"))

> [1] "2026-07-08 12:34:56 UTC"

year(datetime) <- 2030
datetime

> [1] "2030-07-08 12:34:56 UTC"

month(datetime) <- 01
datetime

> [1] "2030-01-08 12:34:56 UTC"

hour(datetime) <- hour(datetime) + 1
datetime

> [1] "2030-01-08 13:34:56 UTC"


或者,而不是修改现有的变量,你可以使用[`update()`](https://rdrr.io/r/stats/update.xhtml)创建一个新的日期时间。这还允许你一次设置多个值:

update(datetime, year = 2030, month = 2, mday = 2, hour = 2)

> [1] "2030-02-02 02:34:56 UTC"


如果值太大,它们会回滚:

update(ymd("2023-02-01"), mday = 30)

> [1] "2023-03-02"

update(ymd("2023-02-01"), hour = 400)

> [1] "2023-02-17 16:00:00 UTC"


## 练习

1.  一年中一天内航班时间分布如何变化?

1.  比较`dep_time`、`sched_dep_time`和`dep_delay`。它们是否一致?解释你的发现。

1.  比较`air_time`和起飞到达之间的时间间隔。解释你的发现。(提示:考虑机场的位置。)

1.  一天中平均延误时间如何变化?应该使用`dep_time`还是`sched_dep_time`?为什么?

1.  如果想要最小化延误的机会,一周中哪一天应该出发?

1.  什么使得`diamonds$carat`和`flights$sched_dep_time`的分布类似?

1.  确认我们的假设,即航班在 20-30 分钟和 50-60 分钟的早期离开是由提前出发的计划航班引起的。提示:创建一个二元变量,告诉你航班是否延误。

# 时间跨度

接下来,你将学习日期运算的工作原理,包括减法、加法和除法。在此过程中,你将了解三个重要的表示时间跨度的类别:

持续时间

表示精确的秒数

时间段

表示像周和月这样的人类单位

间隔

表示起点和终点

在持续时间、时间段和间隔之间如何选择?与往常一样,选择最简单的数据结构来解决你的问题。如果你只关心物理时间,使用持续时间;如果你需要添加人类时间,使用时间段;如果你需要计算时间跨度的长度,则使用间隔。

## 持续时间

在 R 语言中,当你减去两个日期时,会得到一个`difftime`对象:

How old is Hadley?

h_age <- today() - ymd("1979-10-14")
h_age

> Time difference of 15855 days


`difftime`类对象记录以秒、分钟、小时、天或周为单位的时间跨度。这种模棱两可可能会使 difftime 对象在处理时有点麻烦,因此 lubridate 提供了一种始终使用秒的替代方案:*持续时间*。

as.duration(h_age)

> [1] "1369872000s (~43.41 years)"


持续时间配有一系列便捷的构造函数:

dseconds(15)

> [1] "15s"

dminutes(10)

> [1] "600s (~10 minutes)"

dhours(c(12, 24))

> [1] "43200s (~12 hours)" "86400s (~1 days)"

ddays(0:5)

> [1] "0s" "86400s (~1 days)" "172800s (~2 days)"

> [4] "259200s (~3 days)" "345600s (~4 days)" "432000s (~5 days)"

dweeks(3)

> [1] "1814400s (~3 weeks)"

dyears(1)

> [1] "31557600s (~1 years)"


持续时间总是以秒为单位记录时间跨度。较大的单位通过将分钟、小时、天、周和年转换为秒来创建:1 分钟 60 秒,1 小时 60 分钟,1 天 24 小时,1 周 7 天。较大的时间单位更加复杂。一年使用了“平均”每年的天数,即 365.25。没有办法将一个月转换为持续时间,因为变化太大。

您可以对持续时间进行加法和乘法运算:

2 * dyears(1)

> [1] "63115200s (~2 years)"

dyears(1) + dweeks(12) + dhours(15)

> [1] "38869200s (~1.23 years)"


您可以将持续时间加减到天数中:

tomorrow <- today() + ddays(1)
last_year <- today() - dyears(1)


但是,因为持续时间表示确切的秒数,有时您可能会得到意外的结果:

one_am <- ymd_hms("2026-03-08 01:00:00", tz = "America/New_York")

one_am

> [1] "2026-03-08 01:00:00 EST"

one_am + ddays(1)

> [1] "2026-03-09 02:00:00 EDT"


为什么 3 月 8 日凌晨 1 点后一天是 3 月 9 日凌晨 2 点?如果您仔细查看日期,您可能还会注意到时区已经发生了变化。3 月 8 日仅有 23 小时,因为这是夏令时开始的时候,因此如果我们增加一整天的秒数,最终得到的时间会有所不同。

## 时期

要解决这个问题,lubridate 提供了*时期*。时期是时间跨度,但没有固定的秒数长度;相反,它们处理像天和月这样的“人类”时间,这使它们以更直观的方式工作:

one_am

> [1] "2026-03-08 01:00:00 EST"

one_am + days(1)

> [1] "2026-03-09 01:00:00 EDT"


类似于持续时间,时期可以使用多个友好的构造函数创建:

hours(c(12, 24))

> [1] "12H 0M 0S" "24H 0M 0S"

days(7)

> [1] "7d 0H 0M 0S"

months(1:6)

> [1] "1m 0d 0H 0M 0S" "2m 0d 0H 0M 0S" "3m 0d 0H 0M 0S" "4m 0d 0H 0M 0S"

> [5] "5m 0d 0H 0M 0S" "6m 0d 0H 0M 0S"


您可以对时期进行加法和乘法运算:

10 * (months(6) + days(1))

> [1] "60m 10d 0H 0M 0S"

days(50) + hours(25) + minutes(2)

> [1] "50d 25H 2M 0S"


当然,也可以将它们添加到日期中。与持续时间相比,时期更有可能做出您所期望的事情:

A leap year

ymd("2024-01-01") + dyears(1)

> [1] "2024-12-31 06:00:00 UTC"

ymd("2024-01-01") + years(1)

> [1] "2025-01-01"

Daylight savings time

one_am + ddays(1)

> [1] "2026-03-09 02:00:00 EDT"

one_am + days(1)

> [1] "2026-03-09 01:00:00 EDT"


让我们使用时期来修正与我们的航班日期相关的奇怪现象。有些飞机似乎在从纽约市出发之前就已经到达了目的地:

flights_dt |>
filter(arr_time < dep_time)

> # A tibble: 10,633 × 9

> origin dest dep_delay arr_delay dep_time sched_dep_time

>

> 1 EWR BQN 9 -4 2013-01-01 19:29:00 2013-01-01 19:20:00

> 2 JFK DFW 59 NA 2013-01-01 19:39:00 2013-01-01 18:40:00

> 3 EWR TPA -2 9 2013-01-01 20:58:00 2013-01-01 21:00:00

> 4 EWR SJU -6 -12 2013-01-01 21:02:00 2013-01-01 21:08:00

> 5 EWR SFO 11 -14 2013-01-01 21:08:00 2013-01-01 20:57:00

> 6 LGA FLL -10 -2 2013-01-01 21:20:00 2013-01-01 21:30:00

> # … with 10,627 more rows, and 3 more variables: arr_time ,

> # sched_arr_time , air_time


这些是过夜航班。我们在出发时间和到达时间都使用了相同的日期信息,但这些航班到达时已经是第二天了。我们可以通过在每个过夜航班的到达时间上添加`days(1)`来修正这个问题:

flights_dt <- flights_dt |>
mutate(
overnight = arr_time < dep_time,
arr_time = arr_time + days(overnight),
sched_arr_time = sched_arr_time + days(overnight)
)


现在我们所有的航班都遵循物理定律:

flights_dt |>
filter(arr_time < dep_time)

> # A tibble: 0 × 10

… with 10 variables: origin , dest , dep_delay ,

arr_delay , dep_time , sched_dep_time , …

ℹ Use colnames() to see all variable names

> # … with 10,627 more rows, and 4 more variables:


## 区间

`dyears(1) / ddays(365)`返回什么?它不完全是 1,因为`dyears()`被定义为平均每年的秒数,即 365.25 天。

`years(1) / days(1)`返回什么?好吧,如果是 2015 年,应该返回 365,但如果是 2016 年,应该返回 366!lubridate 没有足够的信息给出一个单一明确的答案。它所做的是给出一个估计值:

years(1) / days(1)

> [1] 365.25


如果您想要更准确的测量结果,您将需要使用*区间*。区间是一对起始和结束日期时间,或者您可以将其视为具有起始点的持续时间。

您可以通过编写`start %--% end`来创建一个区间:

y2023 <- ymd("2023-01-01") %--% ymd("2024-01-01")
y2024 <- ymd("2024-01-01") %--% ymd("2025-01-01")

y2023

> [1] 2023-01-01 UTC--2024-01-01 UTC

y2024

> [1] 2024-01-01 UTC--2025-01-01 UTC


您可以通过[`days()`](https://lubridate.tidyverse.org/reference/period.xhtml)来将其除以,以找出一年中有多少天:

y2023 / days(1)

> [1] 365

y2024 / days(1)

> [1] 366


## 练习

1.  向刚开始学习 R 的人解释`days(!overnight)`和`days(overnight)`,你需要知道的关键事实是什么?

1.  创建一个向量,其中包含 2015 年每个月的第一天的日期。创建一个向量,其中包含*当前*年每个月的第一天的日期。

1.  编写一个函数,给定您的生日(作为日期),返回您的年龄。

1.  为什么 `(today() %--% (today() + years(1))) / months(1)` 不能工作?

# 时区

时区是一个极其复杂的主题,因为它们与地缘政治实体的互动。 幸运的是,我们不需要深入研究所有细节,因为它们对于数据分析并不都重要,但我们需要直面一些挑战。

第一个挑战是时区的日常名称往往是模糊的。 例如,如果您是美国人,您可能熟悉东部标准时间(EST)。 但是,澳大利亚和加拿大也都有 EST! 为了避免混淆,R 使用国际标准 IANA 时区。 这些使用一致的命名方案 `{area}/{location}`,通常以 `{continent}/{city}` 或 `{ocean}/{city}` 的形式。 示例包括“America/New_York”、“Europe/Paris” 和 “Pacific/Auckland”。

您可能想知道为什么时区使用城市,而通常您认为时区与国家或国家内的地区相关联。 这是因为 IANA 数据库必须记录数十年的时区规则。 在几十年的时间里,国家经常更改名称(或分裂),但城市名称倾向于保持不变。 另一个问题是,名称需要反映不仅当前行为还完整的历史。 例如,有“America/New_York”和“America/Detroit”的时区。 这些城市目前都使用东部标准时间,但在 1969 年至 1972 年间,密歇根州(底特律所在的州)不遵循夏令时,因此需要不同的名称。 值得阅读 [原始时区数据库](https://oreil.ly/NwvsT) 以了解其中一些故事!

您可以使用 [`Sys.timezone()`](https://rdrr.io/r/base/timezones.xhtml) 找出 R 认为您当前的时区是什么:

Sys.timezone()

> [1] "America/Chicago"


(如果 R 不知道,您将获得 `NA`。)

并查看带有 [`OlsonNames()`](https://rdrr.io/r/base/timezones.xhtml) 的所有时区名称的完整列表:

length(OlsonNames())

> [1] 597

head(OlsonNames())

> [1] "Africa/Abidjan" "Africa/Accra" "Africa/Addis_Ababa"

> [4] "Africa/Algiers" "Africa/Asmara" "Africa/Asmera"


在 R 中,时区是仅控制打印的日期时间的属性。 例如,这三个对象代表相同的时间瞬间:

x1 <- ymd_hms("2024-06-01 12:00:00", tz = "America/New_York")
x1

> [1] "2024-06-01 12:00:00 EDT"

x2 <- ymd_hms("2024-06-01 18:00:00", tz = "Europe/Copenhagen")
x2

> [1] "2024-06-01 18:00:00 CEST"

x3 <- ymd_hms("2024-06-02 04:00:00", tz = "Pacific/Auckland")
x3

> [1] "2024-06-02 04:00:00 NZST"


您可以通过减法验证它们是否是同一时间:

x1 - x2

> Time difference of 0 secs

x1 - x3

> Time difference of 0 secs


除非另有说明,否则 lubridate 总是使用 UTC。 UTC 是科学界使用的标准时区,大致相当于 GMT。 它没有夏令时,这使得它成为计算的方便表示。 像 [`c()`](https://rdrr.io/r/base/c.xhtml) 这样的组合日期时间的操作通常会丢弃时区。 在这种情况下,日期时间将以第一个元素的时区显示:

x4 <- c(x1, x2, x3)
x4

> [1] "2024-06-01 12:00:00 EDT" "2024-06-01 12:00:00 EDT"

> [3] "2024-06-01 12:00:00 EDT"


您可以以两种方式更改时区:

+   保持时间瞬间相同,更改其显示方式。 当瞬间是正确的,但您希望获得更自然的显示时,请使用此选项。

    ```
    x4a <- with_tz(x4, tzone = "Australia/Lord_Howe")
    x4a
    #> [1] "2024-06-02 02:30:00 +1030" "2024-06-02 02:30:00 +1030"
    #> [3] "2024-06-02 02:30:00 +1030"
    x4a - x4
    #> Time differences in secs
    #> [1] 0 0 0
    ```

    (这也说明了时区的另一个挑战:它们不全是整点偏移!)

+   更改底层的时间点。当你有一个带有错误时区标签的时间点,并且需要修正时使用这个选项。

    ```
    x4b <- force_tz(x4, tzone = "Australia/Lord_Howe")
    x4b
    #> [1] "2024-06-01 12:00:00 +1030" "2024-06-01 12:00:00 +1030"
    #> [3] "2024-06-01 12:00:00 +1030"
    x4b - x4
    #> Time differences in hours
    #> [1] -14.5 -14.5 -14.5
    ```

# 总结

本章向你介绍了 lubridate 提供的工具,帮助你处理日期时间数据。处理日期和时间可能比必要的更难,但我们希望这一章能帮助你看清楚原因——日期时间比一开始看起来的更复杂,处理每种可能的情况都增加了复杂性。即使你的数据从不跨越夏令时边界或涉及闰年,函数也需要能够处理。

下一章将总结缺失值。你已经在几个地方见过它们,并且无疑在自己的分析中遇到过,现在是时候提供一些有用的技术来处理它们了。

¹ 如果一个年份能被 4 整除,则是闰年,除非它也能被 100 整除,除非它也能被 400 整除。换句话说,在每 400 年的周期中,有 97 个闰年。

² 你可能想知道 UTC 是什么意思。它是英文“Coordinated Universal Time”和法文“Temps Universel Coordonné”的折衷结果。

³ 猜猜看是哪个国家提出了经度系统,答对没有奖励。


# 第十八章:缺失值

# 介绍

你在本书的前面已经学习了缺失值的基础知识。你首次在第一章中看到它们,在绘制图时会出现警告,以及在“summarize()”中看到它们干扰了计算汇总统计信息,还学会了它们的传染性以及如何检查它们在“缺失值”中的存在。现在我们将更深入地回顾它们,以便你能更详细地了解。

我们将首先讨论一些通用工具,用于处理记录为`NA`的缺失值。然后我们将探讨隐含缺失值的概念,即在数据中简单地缺失的值,并展示一些可以使它们显式的工具。最后,我们将讨论由于数据中不存在的因子水平而导致的空组的相关讨论。

## 先决条件

处理缺失数据的函数主要来自于 dplyr 和 tidyr,它们是 tidyverse 的核心成员之一。

library(tidyverse)


# 显式缺失值

首先,让我们探索一些方便的工具来创建或消除显式缺失值,即看到`NA`的单元格。

## 向前填充最后观测

缺失值的常见用途是作为数据输入的便利性。当数据手动输入时,缺失值有时表示前一行的值已重复(或向前填充):

treatment <- tribble(
~person, ~treatment, ~response,
"Derrick Whitmore", 1, 7,
NA, 2, 10,
NA, 3, NA,
"Katherine Burke", 1, 4
)


你可以使用[`tidyr::fill()`](https://tidyr.tidyverse.org/reference/fill.xhtml)来填补这些缺失值。它的工作方式类似于[`select()`](https://dplyr.tidyverse.org/reference/select.xhtml),接受一组列:

treatment |>
fill(everything())

> # A tibble: 4 × 3

> person treatment response

>

> 1 Derrick Whitmore 1 7

> 2 Derrick Whitmore 2 10

> 3 Derrick Whitmore 3 10

> 4 Katherine Burke 1 4


这种处理有时称为“向前填充最后观测”,简称*locf*。你可以使用`.direction`参数来填补通过更奇特方式生成的缺失值。

## 固定值

有时缺失值代表某个固定和已知的值,最常见为 0。你可以使用[`dplyr::coalesce()`](https://dplyr.tidyverse.org/reference/coalesce.xhtml)来替换它们:

x <- c(1, 4, 5, 7, NA)
coalesce(x, 0)

> [1] 1 4 5 7 0


有时你会遇到相反的问题,即某些具体值实际上表示缺失值。这通常出现在由旧软件生成的数据中,因为它没有适当的方法来表示缺失值,所以必须使用一些特殊值如 99 或-999。

如果可能,在读取数据时处理这些问题,例如通过在[`readr::read_csv()`](https://readr.tidyverse.org/reference/read_delim.xhtml)中使用`na`参数,例如`read_csv(path, na = "99")`。如果稍后才发现问题,或者你的数据源没有提供处理方法,你可以使用[`dplyr::na_if()`](https://dplyr.tidyverse.org/reference/na_if.xhtml):

x <- c(1, 4, 5, 7, -99)
na_if(x, -99)

> [1] 1 4 5 7 NA


## NaN

在我们继续之前,有一种特殊类型的缺失值你可能会遇到:`NaN`(读作“nan”),或称为非数字。虽然不那么重要,因为它通常的行为与`NA`类似:

x <- c(NA, NaN)
x * 10

> [1] NA NaN

x == 1

> [1] NA NA

is.na(x)

> [1] TRUE TRUE


在您需要区分`NA`和`NaN`的罕见情况下,您可以使用`is.nan(x)`。

当您执行具有不确定结果的数学运算时,通常会遇到`NaN`:

0 / 0

> [1] NaN

0 * Inf

> [1] NaN

Inf - Inf

> [1] NaN

sqrt(-1)

> Warning in sqrt(-1): NaNs produced

> [1] NaN


# 隐式缺失值

到目前为止,我们已经讨论了*显式*缺失的值;即,你可以在数据中看到`NA`。但是,如果整行数据在数据中完全缺失,缺失的值也可以是*隐式*的。让我们通过一个简单的数据集来说明它们的区别,该数据集记录了某只股票每个季度的价格:

stocks <- tibble(
year = c(2020, 2020, 2020, 2020, 2021, 2021, 2021),
qtr = c( 1, 2, 3, 4, 2, 3, 4),
price = c(1.88, 0.59, 0.35, NA, 0.92, 0.17, 2.66)
)


此数据集有两个缺失观测:

+   2020 年第四季度的`price`是显式缺失,因为其值为`NA`。

+   2021 年第一季度的`price`是隐式缺失,因为它在数据集中根本不存在。

用这个类似禅宗的公案来思考这种差异的一种方式是:

> 显式缺失值是缺失的存在。
> 
> 隐式缺失值是存在的缺失。

有时,您希望将隐式缺失变为显式以便进行物理处理。在其他情况下,显式缺失是由数据结构强加的,而您希望摆脱它们。以下几节讨论了在隐式和显式缺失之间切换的一些工具。

## 透视

您已经看到了一个工具,可以使隐式缺失变为显式,反之亦然:透视。通过使数据变得更宽,可以使隐式缺失的值变得显式,因为每一行和新列的组合必须具有某个值。例如,如果我们将`stocks`透视以将`quarter`放入列中,则两个缺失值都变得显式:

stocks |>
pivot_wider(
names_from = qtr,
values_from = price
)

> # A tibble: 2 × 5

> year 1 2 3 4

>

> 1 2020 1.88 0.59 0.35 NA

> 2 2021 NA 0.92 0.17 2.66


默认情况下,使数据更长会保留显式缺失值,但如果它们是结构性缺失值,仅因为数据不整洁而存在,您可以通过设置`values_drop_na = TRUE`来删除它们(使它们变为隐式)。有关更多详细信息,请参阅“整洁数据”中的示例。

## 完整

[`tidyr::complete()`](https://tidyr.tidyverse.org/reference/complete.xhtml)允许您通过提供一组定义应存在的行组合的变量来生成显式缺失值。例如,我们知道`stocks`数据中应存在所有`year`和`qtr`的组合:

stocks |>
complete(year, qtr)

> # A tibble: 8 × 3

> year qtr price

>

> 1 2020 1 1.88

> 2 2020 2 0.59

> 3 2020 3 0.35

> 4 2020 4 NA

> 5 2021 1 NA

> 6 2021 2 0.92

> # … with 2 more rows


通常,您将使用现有变量的名称来调用[`complete()`](https://tidyr.tidyverse.org/reference/complete.xhtml),填充缺失的组合。然而,有时个别变量本身就是不完整的,因此您可以提供自己的数据。例如,您可能知道`stocks`数据集应该从 2019 年到 2021 年运行,因此可以显式地为`year`提供这些值:

stocks |>
complete(year = 2019:2021, qtr)

> # A tibble: 12 × 3

> year qtr price

>

> 1 2019 1 NA

> 2 2019 2 NA

> 3 2019 3 NA

> 4 2019 4 NA

> 5 2020 1 1.88

> 6 2020 2 0.59

> # … with 6 more rows


如果变量的范围是正确的,但并非所有值都存在,您可以使用`full_seq(x, 1)`来生成从`min(x)`到`max(x)`每隔 1 的所有值。

在某些情况下,一组简单的变量组合不能生成完整的观察结果。这时,你可以手动完成[`complete()`](https://tidyr.tidyverse.org/reference/complete.xhtml)的功能:创建一个数据框,包含所有应该存在的行(使用你需要的任何组合技术),然后与你的原始数据集使用[`dplyr::full_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)进行合并。

## 连接

这就引出了揭示隐式缺失观察的另一个重要方法:连接。你将在第十九章中学到更多关于连接的内容,但我们想在这里快速提到它们,因为当你比较两个数据集时,你经常会知道一个数据集中缺少的值仅仅是与另一个数据集的比较。

`dplyr::anti_join(x, y)` 在这里非常有用,因为它只选择在 `x` 中没有匹配的行。例如,我们可以使用两个[`anti_join()`s](https://dplyr.tidyverse.org/reference/filter-joins.xhtml)发现在 `flights` 中有 4 个机场和 722 架飞机的信息缺失:

library(nycflights13)

flights |>
distinct(faa = dest) |>
anti_join(airports)

> Joining with by = join_by(faa)

> # A tibble: 4 × 1

> faa

>

> 1 BQN

> 2 SJU

> 3 STT

> 4 PSE

flights |>
distinct(tailnum) |>
anti_join(planes)

> Joining with by = join_by(tailnum)

> # A tibble: 722 × 1

> tailnum

>

> 1 N3ALAA

> 2 N3DUAA

> 3 N542MQ

> 4 N730MQ

> 5 N9EAMQ

> 6 N532UA

> # … with 716 more rows


## 练习

1.  你能找到载体和在 `planes` 中缺失的行之间的任何关系吗?

# 因子和空组

最后一种类型的缺失是空组,即不包含任何观察结果的组,这可能在处理因子时出现。例如,想象我们有一个包含一些人员健康信息的数据集:

health <- tibble(
name = c("Ikaia", "Oletta", "Leriah", "Dashay", "Tresaun"),
smoker = factor(c("no", "no", "no", "no", "no"), levels = c("yes", "no")),
age = c(34, 88, 75, 47, 56),
)


假设我们想要计算有多少吸烟者,可以使用 [`dplyr::count()`](https://dplyr.tidyverse.org/reference/count.xhtml):

health |> count(smoker)

> # A tibble: 1 × 2

> smoker n

>

> 1 no 5


这个数据集只包含非吸烟者,但我们知道吸烟者也存在;非吸烟者组是空的。我们可以通过使用 `.drop = FALSE` 来请求[`count()`](https://dplyr.tidyverse.org/reference/count.xhtml)保留所有分组,即使在数据中没有看到它们:

health |> count(smoker, .drop = FALSE)

> # A tibble: 2 × 2

> smoker n

>

> 1 yes 0

> 2 no 5


同样的原则也适用于 ggplot2 的离散轴,它们也会删除没有任何值的水平。你可以通过在适当的离散轴上提供 `drop = FALSE` 来强制显示它们:

ggplot(health, aes(x = smoker)) +
geom_bar() +
scale_x_discrete()

ggplot(health, aes(x = smoker)) +
geom_bar() +
scale_x_discrete(drop = FALSE)


![一个柱状图,x 轴上只有一个值“no”。同样的柱状图,现在 x 轴上有两个值“yes”和“no”,“yes”类别没有柱子。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_18in01.png)

与[`dplyr::group_by()`](https://dplyr.tidyverse.org/reference/group_by.xhtml)相似的问题也更为一般化地出现。同样,你可以使用 `.drop = FALSE` 来保留所有因子水平:

health |>
group_by(smoker, .drop = FALSE) |>
summarize(
n = n(),
mean_age = mean(age),
min_age = min(age),
max_age = max(age),
sd_age = sd(age)
)

> # A tibble: 2 × 6

> smoker n mean_age min_age max_age sd_age

>

> 1 yes 0 NaN Inf -Inf NA

> 2 no 5 60 34 88 21.6


我们在这里得到一些有趣的结果,因为当对一个空组进行汇总时,汇总函数会应用于长度为零的向量。空向量的长度为 0,而缺失值的长度为 1,这是一个重要的区别。

A vector containing two missing values

x1 <- c(NA, NA)
length(x1)

> [1] 2

A vector containing nothing

x2 <- numeric()
length(x2)

> [1] 0


所有汇总函数都可以处理长度为零的向量,但它们的结果可能会让人感到意外。在这里,我们看到`mean(age)`返回`NaN`,因为`mean(age)` = `sum(age)/length(age)`,在这种情况下是 0/0。[`max()`](https://rdrr.io/r/base/Extremes.xhtml) 和 [`min()`](https://rdrr.io/r/base/Extremes.xhtml) 对于空向量返回 -Inf 和 Inf,因此,如果你将这些结果与非空向量的新数据组合并重新计算,你将得到新数据的最小值或最大值。¹

有时,一个更简单的方法是进行汇总,然后使用[`complete()`](https://tidyr.tidyverse.org/reference/complete.xhtml)使隐式缺失变为显式缺失。

health |>
group_by(smoker) |>
summarize(
n = n(),
mean_age = mean(age),
min_age = min(age),
max_age = max(age),
sd_age = sd(age)
) |>
complete(smoker)

> # A tibble: 2 × 6

> smoker n mean_age min_age max_age sd_age

>

> 1 yes NA NA NA NA NA

> 2 no 5 60 34 88 21.6


这种方法的主要缺点是计数为`NA`,即使你知道它应该是零。

# 总结

缺失值很奇怪!有时它们被记录为明确的`NA`,但有时你只能通过它们的缺席来注意到它们。本章为您提供了一些处理显式缺失值的工具,以及一些揭示隐式缺失值的工具,并且我们讨论了隐式变成显式的一些方式,反之亦然。

在接下来的章节中,我们将处理本书这部分的最后一章:连接操作。这与迄今为止的章节有些不同,因为我们将讨论与数据框整体相关的工具,而不是将某些内容放入数据框中。

¹ 换句话说,`min(c(x, y))`总是等于`min(min(x), min(y))`。


# 第十九章:连接

# 介绍

数据分析很少仅涉及单个数据框。通常情况下,您会有多个数据框,并且必须将它们连接在一起以回答您感兴趣的问题。本章将向您介绍两种重要的连接类型:

+   变异连接是一种操作,它会从另一个数据框中匹配的观测中向一个数据框中添加新变量。

+   过滤连接是一种操作,它根据是否与另一个数据框中的观测相匹配来过滤观测。

我们将从讨论键开始,这些变量用于在连接操作中连接一对数据框。我们将通过检查来自 nycflights13 包中数据集中的键来巩固理论,并利用这些知识开始将数据框连接在一起。接下来,我们将讨论连接操作的工作原理,重点放在它们对行的影响上。最后,我们将讨论非等连接,这是一组提供更灵活的键匹配方式的连接操作,不同于默认的等式关系。

## 先决条件

在本章中,我们将使用 dplyr 中的连接函数来探索来自 nycflights13 的五个相关数据集。

library(tidyverse)
library(nycflights13)


# 键

要理解连接操作,首先需要了解如何通过两个表中的一对键连接它们。在本节中,您将学习关于两种类型的键的知识,并在 nycflights13 数据集中看到具体示例。您还将学习如何验证您的键是否有效,以及在表中缺少键时该如何处理。

## 主键和外键

每个连接都涉及一对键:一个主键和一个外键。*主键* 是一个变量或一组变量,可以唯一标识每个观测。当需要多个变量时,这个键被称为*复合键*。例如,在 nycflights13 中:

+   `airlines` 记录了每家航空公司的两个数据:其运营代码和全称。您可以通过两字母运营代码来识别每家航空公司,使 `carrier` 成为主键。

    ```
    airlines
    #> # A tibble: 16 × 2
    #>   carrier name 
    #>   <chr>   <chr> 
    #> 1 9E      Endeavor Air Inc. 
    #> 2 AA      American Airlines Inc. 
    #> 3 AS      Alaska Airlines Inc. 
    #> 4 B6      JetBlue Airways 
    #> 5 DL      Delta Air Lines Inc. 
    #> 6 EV      ExpressJet Airlines Inc.
    #> # … with 10 more rows
    ```

+   `airports` 记录了每个机场的数据。您可以通过三字母机场代码来识别每个机场,使 `faa` 成为主键。

    ```
    airports
    #> # A tibble: 1,458 × 8
    #>   faa   name                            lat   lon   alt    tz dst 
    #>   <chr> <chr>                         <dbl> <dbl> <dbl> <dbl> <chr>
    #> 1 04G   Lansdowne Airport              41.1 -80.6  1044    -5 A 
    #> 2 06A   Moton Field Municipal Airport  32.5 -85.7   264    -6 A 
    #> 3 06C   Schaumburg Regional            42.0 -88.1   801    -6 A 
    #> 4 06N   Randall Airport                41.4 -74.4   523    -5 A 
    #> 5 09J   Jekyll Island Airport          31.1 -81.4    11    -5 A 
    #> 6 0A9   Elizabethton Municipal Airpo…  36.4 -82.2  1593    -5 A 
    #> # … with 1,452 more rows, and 1 more variable: tzone <chr>
    ```

+   `planes` 记录了每架飞机的数据。您可以通过其尾号来识别一架飞机,从而使 `tailnum` 成为主键。

    ```
    planes
    #> # A tibble: 3,322 × 9
    #>   tailnum  year type              manufacturer    model     engines
    #>   <chr>   <int> <chr>             <chr>           <chr>       <int>
    #> 1 N10156   2004 Fixed wing multi… EMBRAER         EMB-145XR       2
    #> 2 N102UW   1998 Fixed wing multi… AIRBUS INDUSTR… A320-214        2
    #> 3 N103US   1999 Fixed wing multi… AIRBUS INDUSTR… A320-214        2
    #> 4 N104UW   1999 Fixed wing multi… AIRBUS INDUSTR… A320-214        2
    #> 5 N10575   2002 Fixed wing multi… EMBRAER         EMB-145LR       2
    #> 6 N105UW   1999 Fixed wing multi… AIRBUS INDUSTR… A320-214        2
    #> # … with 3,316 more rows, and 3 more variables: seats <int>,
    #> #   speed <int>, engine <chr>
    ```

+   `weather` 记录了起飞机场的天气数据。您可以通过位置和时间的组合来识别每个观测,从而使 `origin` 和 `time_hour` 成为复合主键。

    ```
    weather
    #> # A tibble: 26,115 × 15
    #>   origin  year month   day  hour  temp  dewp humid wind_dir
    #>   <chr>  <int> <int> <int> <int> <dbl> <dbl> <dbl>    <dbl>
    #> 1 EWR     2013     1     1     1  39.0  26.1  59.4      270
    #> 2 EWR     2013     1     1     2  39.0  27.0  61.6      250
    #> 3 EWR     2013     1     1     3  39.0  28.0  64.4      240
    #> 4 EWR     2013     1     1     4  39.9  28.0  62.2      250
    #> 5 EWR     2013     1     1     5  39.0  28.0  64.4      260
    #> 6 EWR     2013     1     1     6  37.9  28.0  67.2      240
    #> # … with 26,109 more rows, and 6 more variables: wind_speed <dbl>,
    #> #   wind_gust <dbl>, precip <dbl>, pressure <dbl>, visib <dbl>, …
    ```

*外键* 是一个变量(或一组变量),对应于另一张表中的主键。例如:

+   `flights$tailnum` 是一个外键,对应于主键 `planes$tailnum`。

+   `flights$carrier` 是一个外键,对应于主键 `airlines$carrier`。

+   `flights$origin` 是一个外键,对应于主键 `airports$faa`。

+   `flights$dest` 是一个外键,对应于主键 `airports$faa`。

+   `flights$origin`-`flights$time_hour` 是对应于 `weather$origin`-`weather$time_hour` 复合主键的复合外键。

这些关系在图 19-1 中以视觉方式总结。

![nycflights13 包中 airports、planes、flights、weather 和 airlines 数据集之间的关系。airports$faa 连接到 flights$origin 和 flights$dest。planes$tailnum 连接到 flights$tailnum。weather$time_hour 和 weather$origin 共同连接到 flights$time_hour 和 flights$origin。airlines$carrier 连接到 flights$carrier。airports、planes、airlines 和 weather 数据框之间没有直接连接。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1901.png)

###### 图 19-1\. nycflights13 包中所有五个数据框之间的连接。构成主键的变量为灰色,并用箭头连接到其对应的外键。

你会注意到这些键设计中的一个好特点:主键和外键几乎总是具有相同的名称,这将在稍后使得连接更加容易。还值得注意的是相反的关系:几乎每个在多个表中使用的变量名称在每个地方的含义都相同。只有一个例外:`year`在`flights`中表示起飞年份,在`planes`中表示制造年份。这在我们开始实际连接表格时将变得重要。

## 检查主键

现在我们已经确定了每个表中的主键,验证它们确实可以唯一标识每个观察值是一个好习惯。一种方法是使用[`count()`](https://dplyr.tidyverse.org/reference/count.xhtml)计算主键,并查找`n`大于一的条目。这显示`planes`和`weather`看起来都不错:

planes |>
count(tailnum) |>
filter(n > 1)

> # A tibble: 0 × 2

> # … with 2 variables: tailnum , n

weather |>
count(time_hour, origin) |>
filter(n > 1)

> # A tibble: 0 × 3

> # … with 3 variables: time_hour , origin , n


您还应该检查主键中是否有缺失值——如果值丢失,那么它无法标识一个观察值!

planes |>
filter(is.na(tailnum))

> # A tibble: 0 × 9

> # … with 9 variables: tailnum , year , type ,

> # manufacturer , model , engines , seats , …

weather |>
filter(is.na(time_hour) | is.na(origin))

> # A tibble: 0 × 15

> # … with 15 variables: origin , year , month , day ,

> # hour , temp , dewp , humid , wind_dir , …


## 代理键

到目前为止,我们还没有讨论`flights`的主键。在这里它并不是特别重要,因为没有数据框将其用作外键,但仍然有必要考虑,因为如果我们有一些方法可以向其他人描述观察值,那么工作起来会更容易。

经过一点思考和实验,我们确定有三个变量联合唯一标识每次航班:

flights |>
count(time_hour, carrier, flight) |>
filter(n > 1)

> # A tibble: 0 × 4

> # … with 4 variables: time_hour , carrier , flight , n


缺少重复项是否自动使`time_hour`-`carrier`-`flight`成为主键?这当然是个好开始,但不能保证。例如,海拔和纬度是否适合作为`airports`的主键?

airports |>
count(alt, lat) |>
filter(n > 1)

> # A tibble: 1 × 3

> alt lat n

>

> 1 13 40.6 2


通过海拔和纬度来识别机场显然是个坏主意,总体而言,仅凭数据无法知道组合变量是否构成一个良好的主键。但对于航班来说,`time_hour`、`carrier`和`flight`的组合似乎是合理的,因为如果有多个相同航班号的航班同时在空中,对航空公司及其乘客来说会非常混乱。

话虽如此,引入一个简单的数值代理键,使用行号:

flights2 <- flights |>
mutate(id = row_number(), .before = 1)
flights2

> # A tibble: 336,776 × 20

> id year month day dep_time sched_dep_time dep_delay arr_time

>

> 1 1 2013 1 1 517 515 2 830

> 2 2 2013 1 1 533 529 4 850

> 3 3 2013 1 1 542 540 2 923

> 4 4 2013 1 1 544 545 -1 1004

> 5 5 2013 1 1 554 600 -6 812

> 6 6 2013 1 1 554 558 -4 740

> # … with 336,770 more rows, and 12 more variables: sched_arr_time ,

> # arr_delay , carrier , flight , tailnum , …


当与其他人沟通时,代理键特别有用:告诉别人查看 2001 航班要比说查看 2013 年 1 月 3 日上午 9 点起飞的 UA430 航班容易得多。

## 练习

1.  我们忘记在图 19-1 中绘制`weather`和`airports`之间的关系。这种关系是什么,图示应该如何呈现?

1.  `weather`仅包含纽约市三个起飞机场的信息。如果它包含了美国所有机场的天气记录,它将对`flights`有什么额外的连接?

1.  `year`、`month`、`day`、`hour`和`origin`变量几乎形成了`weather`的复合键,但有一个小时有重复的观测值。你能找出这个小时有什么特别之处吗?

1.  我们知道一年中有些特别的日子,人们会比平常少乘坐飞机(例如,圣诞节前夕和圣诞节)。你会如何将这些数据表示为一个数据框?主键将是什么?它将如何连接到现有的数据框?

1.  绘制一个图示,展示`Batting`、`People`和`Salaries`数据框之间的连接。再画一个图示,展示`People`、`Managers`和`AwardsManagers`之间的关系。你如何描述`Batting`、`Pitching`和`Fielding`数据框之间的关系?

# 基本连接

现在你了解了如何通过键连接数据框,我们可以开始使用连接操作更好地理解`flights`数据集。dplyr 提供了六个连接函数:

+   [`left_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)

+   [`inner_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)

+   [`right_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)

+   [`full_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)

+   [`semi_join()`](https://dplyr.tidyverse.org/reference/filter-joins.xhtml)

+   [`anti_join()`](https://dplyr.tidyverse.org/reference/filter-joins.xhtml)

它们都有相同的接口:它们接受一对数据框(`x`和`y`),并返回一个数据框。输出中的行和列的顺序主要由`x`决定。

在本节中,您将学习如何使用一个变异连接[`left_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml),以及两个过滤连接[`semi_join()`](https://dplyr.tidyverse.org/reference/filter-joins.xhtml)和[`anti_join()`](https://dplyr.tidyverse.org/reference/filter-joins.xhtml)。在下一节中,您将了解这些函数的确切工作方式,以及关于剩余的[`inner_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)、[`right_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)和[`full_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)。

## 变异连接

*变异连接*允许您从两个数据框中合并变量:它首先通过它们的键匹配观测值,然后从一个数据框复制变量到另一个数据框。与[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)类似,连接函数将变量添加到右侧,因此如果您的数据集有很多变量,您将看不到新变量。为了这些示例,我们将创建一个只有六个变量的更窄的数据集,以便更容易看到发生了什么:¹

flights2 <- flights |>
select(year, time_hour, origin, dest, tailnum, carrier)
flights2

> # A tibble: 336,776 × 6

> year time_hour origin dest tailnum carrier

>

> 1 2013 2013-01-01 05:00:00 EWR IAH N14228 UA

> 2 2013 2013-01-01 05:00:00 LGA IAH N24211 UA

> 3 2013 2013-01-01 05:00:00 JFK MIA N619AA AA

> 4 2013 2013-01-01 05:00:00 JFK BQN N804JB B6

> 5 2013 2013-01-01 06:00:00 LGA ATL N668DN DL

> 6 2013 2013-01-01 05:00:00 EWR ORD N39463 UA

> # … with 336,770 more rows


有四种类型的变异连接,但几乎所有时间您都会使用一种:[`left_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)。它很特别,因为输出将始终与`x`具有相同的行。² [`left_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)的主要用途是添加额外的元数据。例如,我们可以使用[`left_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)将完整的航空公司名称添加到`flights2`数据中:

flights2 |>
left_join(airlines)

> Joining with by = join_by(carrier)

> # A tibble: 336,776 × 7

> year time_hour origin dest tailnum carrier name

>

> 1 2013 2013-01-01 05:00:00 EWR IAH N14228 UA United Air Lines In…

> 2 2013 2013-01-01 05:00:00 LGA IAH N24211 UA United Air Lines In…

> 3 2013 2013-01-01 05:00:00 JFK MIA N619AA AA American Airlines I…

> 4 2013 2013-01-01 05:00:00 JFK BQN N804JB B6 JetBlue Airways

> 5 2013 2013-01-01 06:00:00 LGA ATL N668DN DL Delta Air Lines Inc.

> 6 2013 2013-01-01 05:00:00 EWR ORD N39463 UA United Air Lines In…

> # … with 336,770 more rows


或者我们可以找出每架飞机起飞时的温度和风速:

flights2 |>
left_join(weather |> select(origin, time_hour, temp, wind_speed))

> Joining with by = join_by(time_hour, origin)

> # A tibble: 336,776 × 8

> year time_hour origin dest tailnum carrier temp wind_speed

>

> 1 2013 2013-01-01 05:00:00 EWR IAH N14228 UA 39.0 12.7

> 2 2013 2013-01-01 05:00:00 LGA IAH N24211 UA 39.9 15.0

> 3 2013 2013-01-01 05:00:00 JFK MIA N619AA AA 39.0 15.0

> 4 2013 2013-01-01 05:00:00 JFK BQN N804JB B6 39.0 15.0

> 5 2013 2013-01-01 06:00:00 LGA ATL N668DN DL 39.9 16.1

> 6 2013 2013-01-01 05:00:00 EWR ORD N39463 UA 39.0 12.7

> # … with 336,770 more rows


或者飞机的尺寸是多少:

flights2 |>
left_join(planes |> select(tailnum, type, engines, seats))

> Joining with by = join_by(tailnum)

> # A tibble: 336,776 × 9

> year time_hour origin dest tailnum carrier type

>

> 1 2013 2013-01-01 05:00:00 EWR IAH N14228 UA Fixed wing multi en…

> 2 2013 2013-01-01 05:00:00 LGA IAH N24211 UA Fixed wing multi en…

> 3 2013 2013-01-01 05:00:00 JFK MIA N619AA AA Fixed wing multi en…

> 4 2013 2013-01-01 05:00:00 JFK BQN N804JB B6 Fixed wing multi en…

> 5 2013 2013-01-01 06:00:00 LGA ATL N668DN DL Fixed wing multi en…

> 6 2013 2013-01-01 05:00:00 EWR ORD N39463 UA Fixed wing multi en…

> # … with 336,770 more rows, and 2 more variables: engines , seats


当[`left_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)无法在`x`中找到某一行的匹配时,它会用缺失值填充新变量。例如,关于机尾号为`N3ALAA`的飞机没有信息,因此`type`、`engines`和`seats`将会缺失:

flights2 |>
filter(tailnum == "N3ALAA") |>
left_join(planes |> select(tailnum, type, engines, seats))

> Joining with by = join_by(tailnum)

> # A tibble: 63 × 9

> year time_hour origin dest tailnum carrier type engines seats

>

> 1 2013 2013-01-01 06:00:00 LGA ORD N3ALAA AA NA NA

> 2 2013 2013-01-02 18:00:00 LGA ORD N3ALAA AA NA NA

> 3 2013 2013-01-03 06:00:00 LGA ORD N3ALAA AA NA NA

> 4 2013 2013-01-07 19:00:00 LGA ORD N3ALAA AA NA NA

> 5 2013 2013-01-08 17:00:00 JFK ORD N3ALAA AA NA NA

> 6 2013 2013-01-16 06:00:00 LGA ORD N3ALAA AA NA NA

> # … with 57 more rows


我们将在本章的其余部分多次回到这个问题。

## 指定连接键

默认情况下,[`left_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)将使用两个数据框中出现的所有变量作为连接键,即所谓的*自然*连接。这是一种有用的启发式方法,但并不总是有效。例如,如果我们尝试使用完整的`planes`数据集来连接`flights2`会发生什么?

flights2 |>
left_join(planes)

> Joining with by = join_by(year, tailnum)

> # A tibble: 336,776 × 13

> year time_hour origin dest tailnum carrier type manufacturer

>

> 1 2013 2013-01-01 05:00:00 EWR IAH N14228 UA

> 2 2013 2013-01-01 05:00:00 LGA IAH N24211 UA

> 3 2013 2013-01-01 05:00:00 JFK MIA N619AA AA

> 4 2013 2013-01-01 05:00:00 JFK BQN N804JB B6

> 5 2013 2013-01-01 06:00:00 LGA ATL N668DN DL

> 6 2013 2013-01-01 05:00:00 EWR ORD N39463 UA

> # … with 336,770 more rows, and 5 more variables: model ,

> # engines , seats , speed , engine


我们得到很多缺失的匹配,因为我们的连接尝试使用 `tailnum` 和 `year` 作为复合键。`flights` 和 `planes` 都有一个 `year` 列,但它们的含义不同:`flights$year` 是飞行发生的年份,而 `planes$year` 是飞机建造的年份。我们只想在 `tailnum` 上进行连接,因此我们需要使用 [`join_by()`](https://dplyr.tidyverse.org/reference/join_by.xhtml) 提供显式规范:

flights2 |>
left_join(planes, join_by(tailnum))

> # A tibble: 336,776 × 14

> year.x time_hour origin dest tailnum carrier year.y

>

> 1 2013 2013-01-01 05:00:00 EWR IAH N14228 UA 1999

> 2 2013 2013-01-01 05:00:00 LGA IAH N24211 UA 1998

> 3 2013 2013-01-01 05:00:00 JFK MIA N619AA AA 1990

> 4 2013 2013-01-01 05:00:00 JFK BQN N804JB B6 2012

> 5 2013 2013-01-01 06:00:00 LGA ATL N668DN DL 1991

> 6 2013 2013-01-01 05:00:00 EWR ORD N39463 UA 2012

> # … with 336,770 more rows, and 7 more variables: type ,

> # manufacturer , model , engines , seats , …


注意输出中的 `year` 变量使用后缀(`year.x` 和 `year.y`)进行消歧义,告诉您变量是来自 `x` 还是 `y` 参数。您可以使用 `suffix` 参数覆盖默认后缀。

`join_by(tailnum)` 是 `join_by(tailnum == tailnum)` 的简写。了解这种更完整的形式有两个原因很重要。首先,它描述了两个表之间的关系:键必须相等。这就是为什么这种连接通常被称为*等连接*。您将在“过滤连接”中了解非等连接。

第二,这是您如何在每个表中指定不同的连接键。例如,有两种方法可以将 `flight2` 表和 `airports` 表连接起来:要么通过 `dest`,要么通过 `origin`:

flights2 |>
left_join(airports, join_by(dest == faa))

> # A tibble: 336,776 × 13

> year time_hour origin dest tailnum carrier name

>

> 1 2013 2013-01-01 05:00:00 EWR IAH N14228 UA George Bush Interco…

> 2 2013 2013-01-01 05:00:00 LGA IAH N24211 UA George Bush Interco…

> 3 2013 2013-01-01 05:00:00 JFK MIA N619AA AA Miami Intl

> 4 2013 2013-01-01 05:00:00 JFK BQN N804JB B6

> 5 2013 2013-01-01 06:00:00 LGA ATL N668DN DL Hartsfield Jackson …

> 6 2013 2013-01-01 05:00:00 EWR ORD N39463 UA Chicago Ohare Intl

> # … with 336,770 more rows, and 6 more variables: lat , lon ,

> # alt , tz , dst , tzone

flights2 |>
left_join(airports, join_by(origin == faa))

> # A tibble: 336,776 × 13

> year time_hour origin dest tailnum carrier name

>

> 1 2013 2013-01-01 05:00:00 EWR IAH N14228 UA Newark Liberty Intl

> 2 2013 2013-01-01 05:00:00 LGA IAH N24211 UA La Guardia

> 3 2013 2013-01-01 05:00:00 JFK MIA N619AA AA John F Kennedy Intl

> 4 2013 2013-01-01 05:00:00 JFK BQN N804JB B6 John F Kennedy Intl

> 5 2013 2013-01-01 06:00:00 LGA ATL N668DN DL La Guardia

> 6 2013 2013-01-01 05:00:00 EWR ORD N39463 UA Newark Liberty Intl

> # … with 336,770 more rows, and 6 more variables: lat , lon ,

> # alt , tz , dst , tzone


在旧代码中,您可能会看到使用字符向量指定连接键的不同方法:

+   `by = "x"` 对应于 `join_by(x)`。

+   `by = c("a" = "x")` 对应于 `join_by(a == x)`。

现在有了 [`join_by()`](https://dplyr.tidyverse.org/reference/join_by.xhtml),我们更喜欢它,因为它提供了更清晰和更灵活的规范。

[`inner_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml),[`right_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml) 和 [`full_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml) 与 [`left_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml) 拥有相同的接口。它们之间的区别在于它们保留的行数:左连接保留 `x` 中的所有行,右连接保留 `y` 中的所有行,全连接保留 `x` 或 `y` 中的所有行,而内连接仅保留同时出现在 `x` 和 `y` 中的行。我们稍后会更详细地回到这些。

## 过滤连接

正如您可能猜到的那样,*过滤连接*的主要操作是过滤行。有两种类型:半连接和反连接。*半连接*保留 `x` 中与 `y` 中匹配的所有行。例如,我们可以使用半连接来过滤 `airports` 数据集,只显示起始机场:

airports |>
semi_join(flights2, join_by(faa == origin))

> # A tibble: 3 × 8

> faa name lat lon alt tz dst tzone

>

> 1 EWR Newark Liberty Intl 40.7 -74.2 18 -5 A America/New_York

> 2 JFK John F Kennedy Intl 40.6 -73.8 13 -5 A America/New_York

> 3 LGA La Guardia 40.8 -73.9 22 -5 A America/New_York


或者只需目的地:

airports |>
semi_join(flights2, join_by(faa == dest))

> # A tibble: 101 × 8

> faa name lat lon alt tz dst tzone

>

> 1 ABQ Albuquerque Internati… 35.0 -107. 5355 -7 A America/Denver

> 2 ACK Nantucket Mem 41.3 -70.1 48 -5 A America/New_Yo…

> 3 ALB Albany Intl 42.7 -73.8 285 -5 A America/New_Yo…

> 4 ANC Ted Stevens Anchorage… 61.2 -150. 152 -9 A America/Anchor…

> 5 ATL Hartsfield Jackson At… 33.6 -84.4 1026 -5 A America/New_Yo…

> 6 AUS Austin Bergstrom Intl 30.2 -97.7 542 -6 A America/Chicago

> # … with 95 more rows


*反连接*则相反:它们返回 `x` 中没有在 `y` 中找到匹配的所有行。它们对于查找数据中*隐式*缺失值很有用,这是“隐式缺失值”的主题。隐式缺失值不显示为 `NA`,而是仅存在于缺失。例如,我们可以通过查找没有与目的地机场匹配的航班来找到缺少的行:

flights2 |>
anti_join(airports, join_by(dest == faa)) |>
distinct(dest)

> # A tibble: 4 × 1

> dest

>

> 1 BQN

> 2 SJU

> 3 STT

> 4 PSE


或者我们可以找出哪些`tailnum`在`planes`中不存在:

flights2 |>
anti_join(planes, join_by(tailnum)) |>
distinct(tailnum)

> # A tibble: 722 × 1

> tailnum

>

> 1 N3ALAA

> 2 N3DUAA

> 3 N542MQ

> 4 N730MQ

> 5 N9EAMQ

> 6 N532UA

> # … with 716 more rows


## 练习

1.  找出全年中**最严重延误**的 48 小时(以全年为单位)。与`weather`数据进行交叉参考。你能看到任何规律吗?

1.  想象一下,你已经使用了这段代码找到了前 10 个最受欢迎的目的地:

    ```
    top_dest <- flights2 |>
      count(dest, sort = TRUE) |>
      head(10)
    ```

    你如何找到所有飞往那些目的地的航班?

1.  每一个出发的航班都有对应的那一小时的天气数据吗?

1.  那些在`planes`中没有匹配记录的尾号有什么共同点?(提示:一个变量解释了大约 90%的问题。)

1.  在`planes`中添加一列,列出每个飞机已经飞过的每个`carrier`。你可能会预期每架飞机与航空公司之间有一个隐含的关系,因为每架飞机只由一家航空公司飞行。使用你在前几章学到的工具来确认或者拒绝这个假设。

1.  向`flights`添加起飞和目的地机场的纬度和经度。在连接之前还是之后更容易重命名列名?

1.  计算每个目的地的平均延误时间,然后在`airports`数据框上进行连接,以便展示延误的空间分布。这里有一个绘制美国地图的简单方法:

    ```
    airports |>
      semi_join(flights, join_by(faa == dest)) |>
      ggplot(aes(x = lon, y = lat)) +
        borders("state") +
        geom_point() +
        coord_quickmap()
    ```

    你可能想要使用点的`size`或`color`来显示每个机场的平均延误时间。

1.  2013 年 6 月 13 日发生了什么?绘制延误地图,然后使用 Google 进行与天气的交叉参考。

# 连接是如何工作的?

现在你已经多次使用了连接,是时候学习更多关于它们如何工作的知识了,重点是`x`中的每一行如何与`y`中的行匹配。我们将首先介绍连接的视觉表示,使用接下来定义并在图 19-2 中显示的简单数据框。

x <- tribble(
~key, ~val_x,
1, "x1",
2, "x2",
3, "x3"
)
y <- tribble(
~key, ~val_y,
1, "y1",
2, "y2",
4, "y3"
)


![x 和 y 是两个包含 2 列和 3 行的数据框,内容如文本所述。关键值的颜色为:1 是绿色,2 是紫色,3 是橙色,4 是黄色。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1902.png)

###### 图 19-2\. 两个简单表格的图形表示。有颜色的`key`列将背景颜色映射到关键值。灰色列表示伴随旅行的“value”列。

图 19-3 介绍了我们视觉表示的基础。它展示了`x`和`y`之间所有潜在的匹配,即从`x`的每一行到`y`的每一行的交集。输出中的行和列主要由`x`决定,因此`x`表是水平的,并与输出对齐。

![x 和 y 形成直角,x 上有水平线,y 上有垂直线。x 有 3 行,y 有 3 行,共有 9 个交点表示九个潜在匹配点。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1903.png)

###### 图 19-3\. 要理解连接操作的工作原理,考虑每种可能的匹配是很有用的。这里我们展示了一个连接线网格。

要描述特定类型的连接,我们用点表示匹配。这些匹配确定了输出中的行,即一个包含键、x 值和 y 值的新数据框。例如,图 19-4 显示了一个内连接,仅当键相等时保留行。

![x 和 y 形成直角放置,并通过连接线形成一个潜在匹配的网格。键 1 和 2 在 x 和 y 中都出现,因此我们得到一个匹配,用点表示。每个点对应输出中的一行,因此结果的连接数据帧有两行。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1904.png)

###### 图 19-4\. 内连接将`x`中的每一行与具有相同`key`值的`y`中的行匹配。每个匹配项成为输出中的一行。

我们可以应用相同的原则来解释*外连接*,它保留出现在至少一个数据框中的观测值。这些连接通过向每个数据框添加一个额外的“虚拟”观测值来工作。如果没有其他键匹配,则此观测值具有与之匹配的键,以及填充了`NA`的值。外连接有三种类型:

+   *左连接*保留`x`中的所有观测值,如图 19-5 所示。输出中保留了`x`的每一行,因为它可以回退到在`y`中匹配到一行`NA`的情况。

    ![与之前显示内连接的图表相比,y 表现现在增加了一个新的虚拟行,其中包含 NA,它将匹配任何未匹配到的 x 中的行。这意味着输出现在有三行。对于 key = 3,与此虚拟行匹配,val_y 取值 NA。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1905.png)

    ###### 图 19-5\. 左连接的可视化表示,`x`中的每一行都出现在输出中。

+   *右连接*保留`y`中的所有观测值,如图 19-6 所示。输出中保留了`y`的每一行,因为它可以回退到在`x`中匹配到一行`NA`的情况。输出仍然尽可能与`x`匹配;任何额外来自`y`的行都添加到最后。

    ![与之前显示左连接的图表相比,x 表现现在增加了一个虚拟行,以便每个 y 中的行在 x 中都获得匹配。对于未匹配到 x 的 y 行,val_x 包含 NA。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1906.png)

    ###### 图 19-6\. 右连接的可视化表示,`y`的每一行都出现在输出中。

+   *完全连接*保留出现在`x`或`y`中的所有观测值,如图 19-7 所示。输出中包含了`x`和`y`的每一行,因为`x`和`y`都有一行`NA`的备用。同样,输出从`x`的所有行开始,然后是剩余未匹配的`y`行。

    ![现在,无论是 x 还是 y,都有一个始终匹配的虚拟行。结果包含 4 行:键 1、2、3 和 4,其值全部来自于 val_x 和 val_y,但键 2 的 val_y 和键 4 的 val_x 因为这些键在另一个数据框中没有匹配而为 NA。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1907.png)

    ###### 图 19-7\. 全连接的视觉表示,其中 `x` 和 `y` 中的每一行都出现在输出中。

另一种显示外连接类型不同的方式是使用维恩图,如图 19-8。然而,这并不是一个很好的表示,因为虽然它可能会唤起你对保留哪些行的记忆,但它未能说明列中发生了什么。

![维恩图表示内连接、全连接、左连接和右连接。每个连接用两个相交的圆表示数据框 x 和 y,x 在右侧,y 在左侧。阴影表示连接的结果。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1908.png)

###### 图 19-8\. 维恩图显示内连接、左连接、右连接和全连接的区别。

这里显示的是所谓的*等连接*,其中如果键相等,则行匹配。等连接是最常见的连接类型,因此我们通常会省略等前缀,只说“内连接”而不是“等内连接”。我们将在“过滤连接”中回到非等连接。

## 行匹配

到目前为止,我们已经探讨了如果行在 `y` 中匹配零或一行会发生什么。如果它匹配多于一行会发生什么?要理解正在发生的情况,让我们首先将焦点缩小到[`inner_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml),然后绘制一幅图,如图 19-9 所示。

![一个联接图,其中 x 具有键值 1、2 和 3,y 具有键值 1、2、2。输出有三行,因为键 1 匹配一行,键 2 匹配两行,键 3 没有匹配行。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1909.png)

###### 图 19-9\. 行 `x` 匹配的三种方式。`x1` 匹配 `y` 中的一行,`x2` 匹配 `y` 中的两行,`x3` 不匹配 `y` 中的任何行。请注意,虽然 `x` 中有三行且输出中也有三行,但行之间没有直接的对应关系。

一行在 `x` 中有三种可能的结果:

+   如果它没有匹配项,它将被丢弃。

+   如果它在 `y` 中匹配一行,它将被保留。

+   如果它在 `y` 中匹配多于一行,它将被复制,每个匹配一次。

原则上,这意味着输出中的行与 `x` 中的行之间没有保证的对应关系,但实际上,这很少会引起问题。然而,有一种特别危险的情况可能会导致行的组合爆炸。想象一下,联接以下两个表:

df1 <- tibble(key = c(1, 2, 2), val_x = c("x1", "x2", "x3"))
df2 <- tibble(key = c(1, 2, 2), val_y = c("y1", "y2", "y3"))


尽管 `df1` 中的第一行仅与 `df2` 中的一行匹配,但第二行和第三行都与两行匹配。这有时被称为*多对多*连接,并将导致 dplyr 发出警告:

df1 |>
inner_join(df2, join_by(key))

> Warning in inner_join(df1, df2, join_by(key)):

> Detected an unexpected many-to-many relationship between x and y.

> ℹ Row 2 of x matches multiple rows in y.

> ℹ Row 2 of y matches multiple rows in x.

> ℹ If a many-to-many relationship is expected, set `relationship =

> "many-to-many"` to silence this warning.

> # A tibble: 5 × 3

> key val_x val_y

>

> 1 1 x1 y1

> 2 2 x2 y2

> 3 2 x2 y3

> 4 2 x3 y2

> 5 2 x3 y3


如果你是故意这样做的,你可以设置 `relationship = "many-to-many"`,就像警告建议的那样。

## 过滤连接

匹配的数量还决定了过滤连接的行为。半连接保留在 y 中具有一个或多个匹配项的 x 中的行,如图 19-10。反连接保留在 y 中没有匹配项的 x 中的行,如图 19-11。在这两种情况下,只有匹配的存在性是重要的;它不关心匹配了多少次。这意味着过滤连接不会像变异连接那样重复行。

![一个显示了老朋友 x 和 y 的连接图。在半连接中,只有匹配的存在性是重要的,因此输出包含与 x 相同的列。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1910.png)

###### 图 19-10\. 在半连接中,只有匹配的存在性是重要的;否则,y 中的值不会影响输出。

![反向关联是半关联的反面,因此匹配通过红线指示,表明它们将从输出中删除。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1911.png)

###### 图 19-11\. 反连接是半连接的反向操作,从 x 中删除在 y 中具有匹配项的行。

# 非等值连接

到目前为止,您只看到了等值连接,即只有在 x 的键等于 y 的键时才匹配的连接。现在我们将放宽这个限制,讨论确定一对行是否匹配的其他方法。

但在此之前,我们需要重新审视先前所做的简化。在等值关联中,x 的键和 y 总是相等的,因此我们只需要在输出中显示一个。我们可以请求 dplyr 保留两个键,使用`keep = TRUE`,导致以下代码和重新绘制的[`inner_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)在图 19-12 中。

x |> left_join(y, by = "key", keep = TRUE)

> # A tibble: 3 × 4

> key.x val_x key.y val_y

>

> 1 1 x1 1 y1

> 2 2 x2 2 y2

> 3 3 x3 NA


![显示了 x 和 y 之间内连接的连接图。结果现在包括四列:key.x、val_x、key.y 和 val_y。key.x 和 key.y 的值相同,这就是为什么我们通常只显示一个的原因。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1912.png)

###### 图 19-12\. 一个内连接显示了输出中的 x 和 y 两个键。

当我们移开从等值连接时,我们总是显示键,因为键值通常会有所不同。例如,不再只匹配 x$key 和 y$key 相等时,而是当 x$key 大于或等于 y$key 时匹配,这会导致图 19-13。dplyr 的连接函数理解等值连接和非等值连接之间的区别,因此在执行非等值连接时始终会显示两个键。

![一个关联图示说明了按照(key >= key)进行关联的方法。x 的第一行与 y 的一行匹配,第二行和第三行各自与两行匹配。这意味着输出包含五行,每行都包含以下(key.x, key.y)对:(1, 1), (2, 1), (2, 2), (3, 1), (3, 2)。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1913.png)

###### 图 19-13\. 非等值连接,其中 x 键必须大于或等于 y 键。许多行会生成多个匹配项。

非等连接并不是一个特别有用的术语,因为它只告诉你这个连接不是什么,而不是什么。

交叉连接

匹配每一对行。

不等连接

使用 `<`、`<=`、`>` 和 `>=` 而不是 `==`。

滚动连接

类似于不等连接,但只找到最接近的匹配。

重叠连接

一种特殊类型的不等连接,设计用于处理范围。

下面的各个连接类型将在接下来的章节中详细描述。

## 交叉连接

交叉连接匹配所有内容,如 图 19-14 所示,生成行的笛卡尔积。这意味着输出将有 `nrow(x) * nrow(y)` 行。

![一个连接图示,显示每个 `x` 和 `y` 的组合点。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1914.png)

###### 图 19-14\. 交叉连接将`x`中的每一行与`y`中的每一行匹配。

交叉连接在生成排列时非常有用。例如,以下代码生成了每对名称的所有可能组合。由于我们将 `df` 与自身连接,这有时被称为*自连接*。交叉连接使用不同的连接函数,因为在匹配每一行时不存在内部/左侧/右侧/完整的区别。

df <- tibble(name = c("John", "Simon", "Tracy", "Max"))
df |> cross_join(df)

> # A tibble: 16 × 2

> name.x name.y

>

> 1 John John

> 2 John Simon

> 3 John Tracy

> 4 John Max

> 5 Simon John

> 6 Simon Simon

> # … with 10 more rows


## 不等连接

不等连接使用 `<`、`<=`、`>=` 或 `>` 来限制可能匹配的集合,如 图 19-13 和 图 19-15 中所示。

![一个图示,描绘了一个不等连接的图,其中数据框 `x` 与数据框 `y` 连接,其中 `x` 的键小于 `y` 的键,结果在左上角呈三角形状。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1915.png)

###### 图 19-15\. 不等连接,其中 `x` 与 `y` 在 `x` 的键小于 `y` 的键的行上连接。这在左上角形成一个三角形。

不等连接非常普遍,以至于很难想出有意义的具体用例。一个小而有用的技巧是使用它们来限制交叉连接,以便不生成所有排列,而是生成所有组合:

df <- tibble(id = 1:4, name = c("John", "Simon", "Tracy", "Max"))

df |> left_join(df, join_by(id < id))

> # A tibble: 7 × 4

> id.x name.x id.y name.y

>

> 1 1 John 2 Simon

> 2 1 John 3 Tracy

> 3 1 John 4 Max

> 4 2 Simon 3 Tracy

> 5 2 Simon 4 Max

> 6 3 Tracy 4 Max

> # … with 1 more row


## 滚动连接

滚动连接是一种特殊类型的不等连接,不是获取满足不等式的每一行,而是获取最接近的行,如 图 19-16 所示。你可以通过添加 `closest()` 将任何不等连接转换为滚动连接。例如,`join_by(closest(x <= y))` 匹配大于或等于 `x` 的最小 `y`,而 `join_by(closest(x > y))` 匹配小于 `x` 的最大 `y`。

![滚动连接是不等连接的子集,因此一些匹配被灰色表示,表示它们未被使用,因为它们不是“最接近的”。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_1916.png)

###### 图 19-16\. 滚动连接类似于大于或等于的不等连接,但只匹配第一个值。

滚动连接在你有两个日期表格并且它们不完全对齐时特别有用,你想要找到例如第一个表格中在第二个表格某个日期之前(或之后)的最接近的日期。

例如,想象一下,您负责办公室派对策划委员会。您的公司相当吝啬,所以没有单独的派对,每季度只举办一次派对。确定何时举行派对的规则有点复杂:派对总是在星期一举行,您跳过一月份的第一周,因为很多人在度假,2022 年第三季度的第一个星期一是 7 月 4 日,所以必须推迟一周。这导致以下派对日期:

parties <- tibble(
q = 1:4,
party = ymd(c("2022-01-10", "2022-04-04", "2022-07-11", "2022-10-03"))
)


现在想象一下,您有一个员工生日表:

employees <- tibble(
name = sample(babynames::babynames$name, 100),
birthday = ymd("2022-01-01") + (sample(365, 100, replace = TRUE) - 1)
)
employees

> # A tibble: 100 × 2

> name birthday

>

> 1 Case 2022-09-13

> 2 Shonnie 2022-03-30

> 3 Burnard 2022-01-10

> 4 Omer 2022-11-25

> 5 Hillel 2022-07-30

> 6 Curlie 2022-12-11

> # … with 94 more rows


对于每位员工,我们希望找到在他们生日之后(或者当天)的第一个派对日期。我们可以通过滚动连接来表达这一点:

employees |>
left_join(parties, join_by(closest(birthday >= party)))

> # A tibble: 100 × 4

> name birthday q party

>

> 1 Case 2022-09-13 3 2022-07-11

> 2 Shonnie 2022-03-30 1 2022-01-10

> 3 Burnard 2022-01-10 1 2022-01-10

> 4 Omer 2022-11-25 4 2022-10-03

> 5 Hillel 2022-07-30 3 2022-07-11

> 6 Curlie 2022-12-11 4 2022-10-03

> # … with 94 more rows


然而,这种方法有一个问题:在 1 月 10 日之前过生日的人就不能参加派对:

employees |>
anti_join(parties, join_by(closest(birthday >= party)))

> # A tibble: 0 × 2

> # … with 2 variables: name , birthday


要解决这个问题,我们需要以不同的方式处理它,采用重叠连接的方法。

## 重叠连接

重叠连接提供了三个助手函数,使用不等连接使得处理间隔更加容易:

+   `between(x, y_lower, y_upper)` 的意思是 `x >= y_lower, x <= y_upper`。

+   `within(x_lower, x_upper, y_lower, y_upper)` 的意思是 `x_lower >= y_lower, x_upper <= y_upper`。

+   `overlaps(x_lower, x_upper, y_lower, y_upper)` 的意思是 `x_lower <= y_upper, x_upper >= y_lower`。

让我们继续使用生日的例子来看看您可能如何使用它们。我们之前使用的策略有一个问题:没有派对在 1 月 1 日到 9 日的生日之前。因此,最好明确每个派对跨越的日期范围,并为这些早期生日制定一个特殊情况:

parties <- tibble(
q = 1:4,
party = ymd(c("2022-01-10", "2022-04-04", "2022-07-11", "2022-10-03")),
start = ymd(c("2022-01-01", "2022-04-04", "2022-07-11", "2022-10-03")),
end = ymd(c("2022-04-03", "2022-07-11", "2022-10-02", "2022-12-31"))
)
parties

> # A tibble: 4 × 4

> q party start end

>

> 1 1 2022-01-10 2022-01-01 2022-04-03

> 2 2 2022-04-04 2022-04-04 2022-07-11

> 3 3 2022-07-11 2022-07-11 2022-10-02

> 4 4 2022-10-03 2022-10-03 2022-12-31


哈德利在数据输入方面非常糟糕,因此他还想检查派对期间是否重叠。一种方法是使用自连接来检查任何开始-结束间隔是否与另一个重叠:

parties |>
inner_join(parties, join_by(overlaps(start, end, start, end), q < q)) |>
select(start.x, end.x, start.y, end.y)

> # A tibble: 1 × 4

> start.x end.x start.y end.y

>

> 1 2022-04-04 2022-07-11 2022-07-11 2022-10-02


糟糕,有重叠,让我们修复这个问题并继续:

parties <- tibble(
q = 1:4,
party = ymd(c("2022-01-10", "2022-04-04", "2022-07-11", "2022-10-03")),
start = ymd(c("2022-01-01", "2022-04-04", "2022-07-11", "2022-10-03")),
end = ymd(c("2022-04-03", "2022-07-10", "2022-10-02", "2022-12-31"))
)


现在我们可以将每位员工与他们的派对匹配。这是一个使用 `unmatched = "error"` 的好地方,因为我们想要快速找出是否有任何员工没有被分配到派对:

employees |>
inner_join(parties, join_by(between(birthday, start, end)), unmatched = "error")

> # A tibble: 100 × 6

> name birthday q party start end

>

> 1 Case 2022-09-13 3 2022-07-11 2022-07-11 2022-10-02

> 2 Shonnie 2022-03-30 1 2022-01-10 2022-01-01 2022-04-03

> 3 Burnard 2022-01-10 1 2022-01-10 2022-01-01 2022-04-03

> 4 Omer 2022-11-25 4 2022-10-03 2022-10-03 2022-12-31

> 5 Hillel 2022-07-30 3 2022-07-11 2022-07-11 2022-10-02

> 6 Curlie 2022-12-11 4 2022-10-03 2022-10-03 2022-12-31

> # … with 94 more rows


## 练习

1.  您能解释这个等连接中键发生了什么吗?它们为什么不同?

    ```
    x |> full_join(y, by = "key")
    #> # A tibble: 4 × 3
    #>     key val_x val_y
    #>   <dbl> <chr> <chr>
    #> 1     1 x1    y1 
    #> 2     2 x2    y2 
    #> 3     3 x3    <NA> 
    #> 4     4 <NA>  y3

    x |> full_join(y, by = "key", keep = TRUE)
    #> # A tibble: 4 × 4
    #>   key.x val_x key.y val_y
    #>   <dbl> <chr> <dbl> <chr>
    #> 1     1 x1        1 y1 
    #> 2     2 x2        2 y2 
    #> 3     3 x3       NA <NA> 
    #> 4    NA <NA>      4 y3
    ```

1.  在查找是否有任何派对期间重叠时,我们在[`join_by()`](https://dplyr.tidyverse.org/reference/join_by.xhtml)中使用了`q < q`,为什么?如果删除这个不等式会发生什么?

# 摘要

在本章中,您学会了如何使用变异和过滤连接来合并来自一对数据框的数据。在此过程中,您学会了如何识别键,以及主键和外键之间的区别。您还了解了连接的工作原理以及如何确定输出将有多少行。最后,您深入了解了非等连接的强大功能,并看到了一些有趣的用例。

本章结束了本书“转换”部分的内容,重点是你可以使用的工具,包括处理单独列和 tibble 的 dplyr 和基础函数;处理逻辑向量、数字和完整表格的 base 函数;处理字符串的 stringr 函数;处理日期时间的 lubridate 函数;以及处理因子的 forcats 函数。

本书的下一部分将进一步学习如何以整洁的形式将各种类型的数据导入到 R 中。

¹ 请记住,在 RStudio 中,你也可以使用[`View()`](https://rdrr.io/r/utils/View.xhtml)来避免这个问题。

² 这并非百分之百正确,但如果不是的话,你会收到警告。


# 第四部分:导入

在本书的这一部分,您将学习如何将更广泛范围的数据导入到 R 中,并将其转换为便于分析的形式。有时这只是调用适当的数据导入包中的函数的问题。但在更复杂的情况下,可能需要进行整理和转换,以获得您希望使用的整洁矩形数据。

![我们的数据科学模型,以蓝色突出显示的导入部分。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_p400.png)

###### 图 IV-1。数据导入是数据科学过程的开始;没有数据就无法进行数据科学!

在本书的这一部分,您将学习如何访问以下方式存储的数据:

+   在第二十章中,您将学习如何从 Excel 电子表格和 Google Sheets 中导入数据。

+   在第二十一章中,您将学习如何从数据库中获取数据并将其导入到 R 中(还将学习一些如何从 R 中获取数据并将其导入到数据库中的知识)。

+   在第二十二章中,您将学习 Arrow,这是一个强大的工具,用于处理大内存数据,特别是存储在 parquet 格式中的数据。

+   在第二十三章中,您将学习如何处理分层数据,包括存储在 JSON 格式中数据产生的深度嵌套列表。

+   在第二十四章中,您将学习网页“抓取”,即从网页中提取数据的艺术和科学。

在这里我们没有讨论的两个重要的 tidyverse 包是 haven 和 xml2。如果你正在处理来自 SPSS、Stata 和 SAS 文件的数据,请查看[haven 包](https://oreil.ly/cymF4)。如果你正在处理 XML 数据,请查看[xml2 包](https://oreil.ly/lQNBa)。否则,您需要做一些研究来确定需要使用哪个包;在这里,Google 是您的朋友。


# 第二十章:电子表格

# 简介

在第七章中,你学习了如何从纯文本文件(如 `.csv` 和 `.tsv`)导入数据。现在是学习如何从电子表格(无论是 Excel 电子表格还是 Google 表格)中获取数据的时候了。这将基于你在第七章学到的内容,但我们还将讨论在处理电子表格数据时的其他注意事项和复杂性。

如果你或你的合作者使用电子表格来组织数据,强烈推荐阅读卡尔·布罗曼和卡拉·吴的论文[《电子表格中的数据组织》](https://oreil.ly/Ejuen)。这篇论文中提出的最佳实践将在你将数据从电子表格导入到 R 进行分析和可视化时节省大量麻烦。

# Excel

Microsoft Excel 是一款广泛使用的电子表格软件程序,数据以工作表的形式存储在电子表格文件中。

## 先决条件

在本节中,你将学习如何使用 readxl 包在 R 中从 Excel 电子表格中加载数据。这个包不是 tidyverse 的核心包,因此你需要显式加载它,但在安装 tidyverse 包时它会被自动安装。稍后,我们还将使用 writexl 包,它允许我们创建 Excel 电子表格。

library(readxl)
library(tidyverse)
library(writexl)


## 入门指南

大多数 readxl 的函数允许你将 Excel 电子表格加载到 R 中:

+   [`read_xls()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 用于读取 `XLS` 格式的 Excel 文件。

+   [`read_xlsx()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 用于读取 `XLSX` 格式的 Excel 文件。

+   [`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 可以读取 `XLS` 和 `XLSX` 格式的文件。它会根据输入自动猜测文件类型。

这些函数的语法与我们之前介绍的读取其他类型文件的函数类似,例如[`read_csv()`](https://readr.tidyverse.org/reference/read_delim.xhtml),[`read_table()`](https://readr.tidyverse.org/reference/read_table.xhtml),等等。本章的其余部分将重点介绍使用[`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml)。

## 读取 Excel 电子表格

图 20-1 展示了我们将要在 R 中读取的电子表格在 Excel 中的样子。

![查看 Excel 中的学生电子表格。该电子表格包含 6 名学生的信息,包括他们的 ID、全名、喜爱的食物、餐饮计划和年龄。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2001.png)

###### 图 20-1\. Excel 中名为 `students.xlsx` 的电子表格。

[`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 的第一个参数是要读取的文件路径。

students <- read_excel("data/students.xlsx")


[`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 将文件读取为一个 tibble。

students

> # A tibble: 6 × 5

> Student ID Full Name favourite.food mealPlan AGE

>

> 1 1 Sunil Huffmann Strawberry yoghurt Lunch only 4

> 2 2 Barclay Lynn French fries Lunch only 5

> 3 3 Jayendra Lyne N/A Breakfast and lunch 7

> 4 4 Leon Rossini Anchovies Lunch only

> 5 5 Chidiegwu Dunkel Pizza Breakfast and lunch five

> 6 6 Güvenç Attila Ice cream Lunch only 6


我们的数据中有六名学生,每名学生有五个变量。然而,在这个数据集中可能有一些需要解决的问题:

1.  列名乱七八糟。你可以提供遵循一致格式的列名;我们建议使用 `col_names` 参数,采用 `snake_case`。

    ```
    read_excel(
      "data/students.xlsx",
      col_names = c(
        "student_id", "full_name", "favourite_food", "meal_plan", "age")
    )
    #> # A tibble: 7 × 5
    #>   student_id full_name        favourite_food     meal_plan           age 
    #>   <chr>      <chr>            <chr>              <chr>               <chr>
    #> 1 Student ID Full Name        favourite.food     mealPlan            AGE 
    #> 2 1          Sunil Huffmann   Strawberry yoghurt Lunch only          4 
    #> 3 2          Barclay Lynn     French fries       Lunch only          5 
    #> 4 3          Jayendra Lyne    N/A                Breakfast and lunch 7 
    #> 5 4          Leon Rossini     Anchovies          Lunch only          <NA> 
    #> 6 5          Chidiegwu Dunkel Pizza              Breakfast and lunch five 
    #> 7 6          Güvenç Attila    Ice cream          Lunch only          6
    ```

    不幸的是,这也没能达到效果。现在我们有了想要的变量名,但以前的标题行现在显示为数据的第一个观察结果。你可以使用 `skip` 参数显式跳过该行。

    ```
    read_excel(
      "data/students.xlsx",
      col_names = c("student_id", "full_name", "favourite_food", "meal_plan", "age"),
      skip = 1
    )
    #> # A tibble: 6 × 5
    #>   student_id full_name        favourite_food     meal_plan           age 
    #>        <dbl> <chr>            <chr>              <chr>               <chr>
    #> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only          4 
    #> 2          2 Barclay Lynn     French fries       Lunch only          5 
    #> 3          3 Jayendra Lyne    N/A                Breakfast and lunch 7 
    #> 4          4 Leon Rossini     Anchovies          Lunch only          <NA> 
    #> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch five 
    #> 6          6 Güvenç Attila    Ice cream          Lunch only          6
    ```

1.  在 `favourite_food` 列中,其中一个观察结果是 `N/A`,表示“不可用”,但目前没有被识别为 `NA`(请注意这个 `N/A` 与列表中第四个学生的年龄之间的对比)。你可以使用 `na` 参数指定应该将哪些字符串识别为 `NA`。默认情况下,仅会将 `""`(空字符串,或者在从电子表格中读取时,空单元格或带有 `=NA()` 公式的单元格)识别为 `NA`。

    ```
    read_excel(
      "data/students.xlsx",
      col_names = c("student_id", "full_name", "favourite_food", "meal_plan", "age"),
      skip = 1,
      na = c("", "N/A")
    )
    #> # A tibble: 6 × 5
    #>   student_id full_name        favourite_food     meal_plan           age 
    #>        <dbl> <chr>            <chr>              <chr>               <chr>
    #> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only          4 
    #> 2          2 Barclay Lynn     French fries       Lunch only          5 
    #> 3          3 Jayendra Lyne    <NA>               Breakfast and lunch 7 
    #> 4          4 Leon Rossini     Anchovies          Lunch only          <NA> 
    #> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch five 
    #> 6          6 Güvenç Attila    Ice cream          Lunch only          6
    ```

1.  另一个剩下的问题是,`age` 被读取为字符变量,但实际上应该是数值型的。就像使用 [`read_csv()`](https://readr.tidyverse.org/reference/read_delim.xhtml) 和其它用于从平面文件中读取数据的函数一样,你可以给 [`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 提供一个 `col_types` 参数,并指定你要读取的变量的列类型。尽管语法有点不同。你的选项有 `"skip"`、`"guess"`、`"logical"`、`"numeric"`、`"date"`、`"text"` 或 `"list"`。

    ```
    read_excel(
      "data/students.xlsx",
      col_names = c("student_id", "full_name", "favourite_food", "meal_plan", "age"),
      skip = 1,
      na = c("", "N/A"),
      col_types = c("numeric", "text", "text", "text", "numeric")
    )
    #> Warning: Expecting numeric in E6 / R6C5: got 'five'
    #> # A tibble: 6 × 5
    #>   student_id full_name        favourite_food     meal_plan             age
    #>        <dbl> <chr>            <chr>              <chr>               <dbl>
    #> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only              4
    #> 2          2 Barclay Lynn     French fries       Lunch only              5
    #> 3          3 Jayendra Lyne    <NA>               Breakfast and lunch     7
    #> 4          4 Leon Rossini     Anchovies          Lunch only             NA
    #> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch    NA
    #> 6          6 Güvenç Attila    Ice cream          Lunch only              6
    ```

    然而,这样做也没有完全产生期望的结果。通过指定 `age` 应该是数值型,我们已经将包含非数值条目(其值为 `five`)的单元格转换为了 `NA`。在这种情况下,我们应该将 age 读取为 `"text"`,然后在数据加载到 R 后进行更改。

    ```
    students <- read_excel(
      "data/students.xlsx",
      col_names = c("student_id", "full_name", "favourite_food", "meal_plan", "age"),
      skip = 1,
      na = c("", "N/A"),
      col_types = c("numeric", "text", "text", "text", "text")
    )

    students <- students |>
      mutate(
        age = if_else(age == "five", "5", age),
        age = parse_number(age)
      )

    students
    #> # A tibble: 6 × 5
    #>   student_id full_name        favourite_food     meal_plan             age
    #>        <dbl> <chr>            <chr>              <chr>               <dbl>
    #> 1          1 Sunil Huffmann   Strawberry yoghurt Lunch only              4
    #> 2          2 Barclay Lynn     French fries       Lunch only              5
    #> 3          3 Jayendra Lyne    <NA>               Breakfast and lunch     7
    #> 4          4 Leon Rossini     Anchovies          Lunch only             NA
    #> 5          5 Chidiegwu Dunkel Pizza              Breakfast and lunch     5
    #> 6          6 Güvenç Attila    Ice cream          Lunch only              6
    ```

我们花了多个步骤和反复试验才成功以期望的格式加载数据,这并不奇怪。数据科学是一个迭代过程,与从其他纯文本、矩形数据文件中读取数据相比,从电子表格中读取数据的迭代过程可能更加繁琐,因为人们倾向于将数据输入到电子表格中,并使用它们不仅仅作为数据存储,还用于分享和交流。

在加载和查看数据之前,没有办法确切地知道数据的样子。嗯,其实还有一种方式。你可以在 Excel 中打开文件并窥视一下。如果你要这样做,我们建议在打开和浏览交互式地进行的同时,制作 Excel 文件的副本,以便保持原始数据文件的不变,并从未更改的文件中读入 R。这样做可以确保你在检查时不会意外地覆盖任何电子表格中的内容。你也不必害怕像我们在这里所做的那样:加载数据,查看一下,对代码进行调整,再次加载,如此反复,直到你满意为止。

## 读取工作表

电子表格与平面文件的一个重要特征是多工作表的概念,称为*工作表*。 图 20-2 显示了一个包含多个工作表的 Excel 电子表格。数据来自 palmerpenguins 包。每个工作表包含从不同岛屿收集到的企鹅信息。

您可以通过 [`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 中的 `sheet` 参数读取电子表格中的单个工作表。直到现在,我们一直依赖的是默认的第一个工作表。

read_excel("data/penguins.xlsx", sheet = "Torgersen Island")

> # A tibble: 52 × 8

> species island bill_length_mm bill_depth_mm flipper_length_mm

>

> 1 Adelie Torgersen 39.1 18.7 181

> 2 Adelie Torgersen 39.5 17.399999999999999 186

> 3 Adelie Torgersen 40.299999999999997 18 195

> 4 Adelie Torgersen NA NA NA

> 5 Adelie Torgersen 36.700000000000003 19.3 193

> 6 Adelie Torgersen 39.299999999999997 20.6 190

> # … with 46 more rows, and 3 more variables: body_mass_g , sex ,

> # year


![查看 Excel 中的企鹅电子表格。 电子表格包含三个工作表:托尔格森岛、比斯科岛和梦幻岛。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2002.png)

###### 图 20-2\. Excel 中名为 `penguins.xlsx` 的电子表格包含三个工作表。

由于字符字符串 `"NA"` 不被识别为真正的 `NA`,一些看起来包含数值数据的变量被读取为字符。

penguins_torgersen <- read_excel(
"data/penguins.xlsx", sheet = "Torgersen Island", na = "NA"
)

penguins_torgersen

> # A tibble: 52 × 8

> species island bill_length_mm bill_depth_mm flipper_length_mm

>

> 1 Adelie Torgersen 39.1 18.7 181

> 2 Adelie Torgersen 39.5 17.4 186

> 3 Adelie Torgersen 40.3 18 195

> 4 Adelie Torgersen NA NA NA

> 5 Adelie Torgersen 36.7 19.3 193

> 6 Adelie Torgersen 39.3 20.6 190

> # … with 46 more rows, and 3 more variables: body_mass_g , sex ,

> # year


或者,您可以使用 [`excel_sheets()`](https://readxl.tidyverse.org/reference/excel_sheets.xhtml) 获取 Excel 电子表格中所有工作表的信息,然后读取您感兴趣的一个或多个工作表。

excel_sheets("data/penguins.xlsx")

> [1] "Torgersen Island" "Biscoe Island" "Dream Island"


一旦您知道工作表的名称,您可以使用 [`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 单独读取它们。

penguins_biscoe <- read_excel("data/penguins.xlsx", sheet = "Biscoe Island", na = "NA")
penguins_dream <- read_excel("data/penguins.xlsx", sheet = "Dream Island", na = "NA")


在这种情况下,完整的企鹅数据集分布在电子表格的三个工作表中。每个工作表具有相同数量的列,但不同数量的行。

dim(penguins_torgersen)

> [1] 52 8

dim(penguins_biscoe)

> [1] 168 8

dim(penguins_dream)

> [1] 124 8


我们可以使用 [`bind_rows()`](https://dplyr.tidyverse.org/reference/bind_rows.xhtml) 将它们合并在一起:

penguins <- bind_rows(penguins_torgersen, penguins_biscoe, penguins_dream)
penguins

> # A tibble: 344 × 8

> species island bill_length_mm bill_depth_mm flipper_length_mm

>

> 1 Adelie Torgersen 39.1 18.7 181

> 2 Adelie Torgersen 39.5 17.4 186

> 3 Adelie Torgersen 40.3 18 195

> 4 Adelie Torgersen NA NA NA

> 5 Adelie Torgersen 36.7 19.3 193

> 6 Adelie Torgersen 39.3 20.6 190

> # … with 338 more rows, and 3 more variables: body_mass_g , sex ,

> # year


在 第二十六章 中,我们将讨论如何在不重复编码的情况下执行此类任务的方法。

## 读取工作表的部分内容

由于许多人将 Excel 电子表格用于演示和数据存储,因此在电子表格中找到不属于您想读入 R 的数据的单元格条目是非常常见的。 图 20-3 展示了这样一张电子表格:表中间看起来像是数据框架,但是在数据上方和下方的单元格中有多余的文本。

![查看 Excel 中的死亡电子表格。 电子表格顶部有四行,包含非数据信息;文本“为了数据布局的一致性,这真是一件美妙的事情,我将继续在这里做笔记。”分布在这四行顶部的单元格中。然后,数据框架包括有关 10 位著名人士死亡的信息,包括他们的姓名、职业、年龄、是否有子女、出生日期和死亡日期。在底部,还有四行非数据信息;文本“这真的很有趣,但我们现在要签退了!”分布在这四行底部的单元格中。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2003.png)

###### 图 20-3\. Excel 中名为 `deaths.xlsx` 的电子表格。

此电子表格是 readxl 包提供的示例电子表格之一。你可以使用 [`readxl_example()`](https://readxl.tidyverse.org/reference/readxl_example.xhtml) 函数在安装包的目录中找到电子表格。该函数返回电子表格的路径,你可以像往常一样在 [`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 中使用它。

deaths_path <- readxl_example("deaths.xlsx")
deaths <- read_excel(deaths_path)

> New names:

> • `` -> ...2

> • `` -> ...3

> • `` -> ...4

> • `` -> ...5

> • `` -> ...6

deaths

> # A tibble: 18 × 6

> Lots of people ...2 ...3 ...4 ...5 ...6

>

> 1 simply cannot resi… some notes

> 2 at the top of their spreadsh…

> 3 or merging cells

> 4 Name Profession Age Has kids Date of birth Date of death

> 5 David Bowie musician 69 TRUE 17175 42379

> 6 Carrie Fisher actor 60 TRUE 20749 42731

> # … with 12 more rows


前三行和后四行不属于数据框。可以使用 `skip` 和 `n_max` 参数消除这些多余的行,但我们建议使用单元格范围。在 Excel 中,左上角的单元格是 `A1`。随着你向右移动列,单元格标签向字母表的下面移动,即 `B1`、`C1` 等。当你向下移动一列时,单元格标签中的数字增加,即 `A2`、`A3` 等。

我们想要读取的数据从单元格 `A5` 开始,到单元格 `F15` 结束。在电子表格表示法中,这是 `A5:F15`,我们将其提供给 `range` 参数:

read_excel(deaths_path, range = "A5:F15")

> # A tibble: 10 × 6

> Name Profession Age Has kids Date of birth

>

> 1 David Bowie musician 69 TRUE 1947-01-08 00:00:00

> 2 Carrie Fisher actor 60 TRUE 1956-10-21 00:00:00

> 3 Chuck Berry musician 90 TRUE 1926-10-18 00:00:00

> 4 Bill Paxton actor 61 TRUE 1955-05-17 00:00:00

> 5 Prince musician 57 TRUE 1958-06-07 00:00:00

> 6 Alan Rickman actor 69 FALSE 1946-02-21 00:00:00

> # … with 4 more rows, and 1 more variable: Date of death


## 数据类型

在 CSV 文件中,所有的值都是字符串。这并不完全符合数据本身,但却很简单:一切皆为字符串。

Excel 电子表格中的底层数据更加复杂。一个单元格可以是以下四种类型之一:

+   布尔值,比如 `TRUE`、`FALSE` 或 `NA`

+   数字,例如 “10” 或 “10.5”

+   日期时间,也可以包括时间,如 “11/1/21” 或 “11/1/21 下午 3:00”

+   文本字符串,比如 “ten”

当处理电子表格数据时,重要的是要记住底层数据可能与单元格中所见的内容非常不同。例如,Excel 没有整数的概念。所有的数字都以浮点数存储,但你可以选择以可定制的小数位数显示数据。类似地,日期实际上以数字形式存储,具体来说是自 1970 年 1 月 1 日以来的秒数。你可以通过在 Excel 中应用格式设置来自定义日期的显示方式。令人困惑的是,有时你可能看到的是一个看似数字的东西,实际上是一个字符串(例如,在 Excel 中输入 `'10`)。

底层数据存储方式与显示方式之间的这些差异可能会在将数据加载到 R 中时引起意外。默认情况下,readxl 将猜测给定列的数据类型。建议的工作流程是让 readxl 猜测列类型,确认你对猜测的列类型满意,如果不满意,可以返回并重新导入,指定 `col_types`,如 “读取 Excel 电子表格” 中所示。

另一个挑战是当你的 Excel 电子表格中有一列混合了这些类型,例如,有些单元格是数值,其他是文本,还有日期。在将数据导入 R 时,readxl 必须做出一些决策。在这些情况下,你可以将此列的类型设置为 `"list"`,这将把列加载为长度为 1 的向量列表,其中猜测每个元素的类型。

###### 注意

*有时数据以更多样的方式存储,例如单元格背景的颜色或文本是否加粗。在这种情况下,您可能会发现 [tidyxl 包](https://oreil.ly/CU5XP) 很有用。有关处理来自 Excel 的非表格数据的更多策略,请参见 [*https://oreil.ly/jNskS*](https://oreil.ly/jNskS)。*  *## 写入 Excel

让我们创建一个小数据框,然后将其写出。请注意,`item` 是一个因子,而 `quantity` 是一个整数。

bake_sale <- tibble(
item = factor(c("brownie", "cupcake", "cookie")),
quantity = c(10, 5, 8)
)

bake_sale

> # A tibble: 3 × 2

> item quantity

>

> 1 brownie 10

> 2 cupcake 5

> 3 cookie 8


您可以使用 [`write_xlsx()`](https://docs.ropensci.org/writexl/reference/write_xlsx.xhtml) 函数从 [writexl 包](https://oreil.ly/Gzphe)将数据写回磁盘作为 Excel 文件:

write_xlsx(bake_sale, path = "data/bake-sale.xlsx")


图 20-4 展示了 Excel 中数据的外观。请注意,列名已包括并加粗显示。通过将 `col_names` 和 `format_headers` 参数设置为 `FALSE`,可以关闭这些列名。

![先前在 Excel 中创建的烘焙销售数据框。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2004.png)

###### 图 20-4\. Excel 中名为 `bake_sale.xlsx` 的电子表格。

就像从 CSV 文件中读取一样,当我们重新读取数据时,数据类型的信息会丢失。这使得 Excel 文件在缓存中间结果时不可靠。有关替代方法,请参见“写入文件”。

read_excel("data/bake-sale.xlsx")

> # A tibble: 3 × 2

> item quantity

>

> 1 brownie 10

> 2 cupcake 5

> 3 cookie 8


## 格式化输出

writexl 包是写入简单 Excel 电子表格的轻量级解决方案,但如果您对写入到电子表格内不同工作表以及设置样式等附加功能感兴趣,则需要使用 [openxlsx 包](https://oreil.ly/JtHOt)。我们不会在此详细讨论使用该包的细节,但我们建议阅读 [*https://oreil.ly/clwtE*](https://oreil.ly/clwtE),详细讨论使用 openxlsx 从 R 向 Excel 中进一步格式化数据的功能。

请注意,此包不属于 tidyverse,因此其函数和工作流可能感觉不熟悉。例如,函数名称采用驼峰命名法,多个函数无法组合在管道中,参数的顺序也与 tidyverse 中的不同。但这没关系。随着您在 R 的学习和使用扩展到本书之外,您将会遇到许多不同的风格,这些风格可能会用于各种 R 包中,以完成 R 中特定目标的任务。熟悉新包中使用的编码风格的一个好方法是运行函数文档中提供的示例,以了解语法、输出格式以及阅读可能附带的任何小册子。

## 练习

1.  在一个 Excel 文件中创建以下数据集,并将其另存为 `survey.xlsx`。或者,您可以将其作为[Excel 文件](https://oreil.ly/03oQy)下载。

    ![一个包含 3 列(组、子组和 ID)和 12 行的电子表格。组列有两个值:1(跨越 7 合并行)和 2(跨越 5 合并行)。子组列有四个值:A(跨越 3 合并行)、B(跨越 4 合并行)、A(跨越 2 合并行)和 B(跨越 3 合并行)。ID 列有 12 个值,从 1 到 12。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_20in01.png)

    然后,将其读入 R 中,其中 `survey_id` 是字符变量,`n_pets` 是数值变量。

    ```
    #> # A tibble: 6 × 2
    #>   survey_id n_pets
    #>     <chr>  <dbl>
    #>   1 1      0
    #>   2 2      1
    #>   3 3     NA
    #>   4 4      2
    #>   5 5      2
    #>   6 6     NA
    ```

1.  在另一个 Excel 文件中创建以下数据集,并将其保存为 `roster.xlsx`。或者,您可以将其下载为 [Excel 文件](https://oreil.ly/E4dIi)。

    ![一个包含 3 列(组、子组和 ID)和 12 行的电子表格。组列有两个值:1(跨越 7 合并行)和 2(跨越 5 合并行)。子组列有四个值:A(跨越 3 合并行)、B(跨越 4 合并行)、A(跨越 2 合并行)和 B(跨越 3 合并行)。ID 列有 12 个值,从 1 到 12。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_20in02.png)

    然后,将其读入 R。生成的数据框应该称为 `roster`,并应如下所示:

    ```
    #> # A tibble: 12 × 3
    #>    group subgroup    id
    #>    <dbl> <chr>    <dbl>
    #>  1     1 A            1
    #>  2     1 A            2
    #>  3     1 A            3
    #>  4     1 B            4
    #>  5     1 B            5
    #>  6     1 B            6
    #>  7     1 B            7
    #>  8     2 A            8
    #>  9     2 A            9
    #> 10     2 B           10
    #> 11     2 B           11
    #> 12     2 B           12
    ```

1.  在一个新的 Excel 文件中创建以下数据集,并将其保存为 `sales.xlsx`。或者,您可以将其下载为 [Excel 文件](https://oreil.ly/m6q7i)。

    ![一个包含 2 列和 13 行的电子表格。前两行包含有关工作表信息的文本。第 1 行说“此文件包含有关销售的信息”。第 2 行说“数据按品牌名称组织,对于每个品牌,我们有出售的项目的 ID 号和出售数量”。然后有两行空行,然后是 9 行数据。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_20in03.png)

    a. 读取 `sales.xlsx` 并保存为 `sales`。数据框应如下所示,其中 `id` 和 `n` 是列名,共有九行:

    ```
    #> # A tibble: 9 × 2
    #>   id      n    
    #>   <chr>   <chr>
    #> 1 Brand 1 n    
    #> 2 1234    8    
    #> 3 8721    2    
    #> 4 1822    3    
    #> 5 Brand 2 n    
    #> 6 3333    1    
    #> 7 2156    3    
    #> 8 3987    6    
    #> 9 3216    5
    ```

    b. 进一步修改 `sales`,使其达到以下整洁的格式,包括三列(`brand`、`id` 和 `n`)和七行数据。请注意,`id` 和 `n` 是数值型变量,`brand` 是字符型变量。

    ```
    #> # A tibble: 7 × 3
    #>   brand      id     n
    #>   <chr>   <dbl> <dbl>
    #> 1 Brand 1  1234     8
    #> 2 Brand 1  8721     2
    #> 3 Brand 1  1822     3
    #> 4 Brand 2  3333     1
    #> 5 Brand 2  2156     3
    #> 6 Brand 2  3987     6
    #> 7 Brand 2  3216     5
    ```

1.  重新创建 `bake_sale` 数据框,并使用 openxlsx 包的 `write.xlsx()` 函数将其写入 Excel 文件。

1.  在 第七章 中,您学习了 [`janitor::clean_names()`](https://rdrr.io/pkg/janitor/man/clean_names.xhtml) 函数,将列名转换为蛇形命名法。请阅读我们在本节早些时候介绍的 `students.xlsx` 文件,并使用此函数“清理”列名。

1.  如果尝试使用 [`read_xls()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 函数读取一个带有 `.xlsx` 扩展名的文件会发生什么?*  *# Google Sheets

Google Sheets 是另一种广泛使用的电子表格程序。它是免费的,基于 Web。就像 Excel 一样,Google Sheets 中的数据是组织在工作表(也称为 *sheets*)中的。

## 先决条件

本节还将关注电子表格,但这次您将使用 googlesheets4 包从 Google Sheets 中加载数据。这个包也不是 tidyverse 的核心包,因此您需要显式加载它:

library(googlesheets4)
library(tidyverse)


关于包名称的简短说明:googlesheets4 使用 [Sheets API v4](https://oreil.ly/VMlBY) 提供 R 接口以访问 Google Sheets。

## 入门指南

googlesheets4 包的主要函数是 [`read_sheet()`](https://googlesheets4.tidyverse.org/reference/range_read.xhtml),它从 URL 或文件 ID 读取 Google Sheet。此函数也被称为 [`range_read()`](https://googlesheets4.tidyverse.org/reference/range_read.xhtml)。

您也可以使用 [`gs4_create()`](https://googlesheets4.tidyverse.org/reference/gs4_create.xhtml) 创建一个新工作表,或者使用 [`sheet_write()`](https://googlesheets4.tidyverse.org/reference/sheet_write.xhtml) 和相关函数向现有工作表写入内容。

在本节中,我们将与 Excel 部分中相同的数据集一起工作,以突出显示从 Excel 和 Google Sheets 中读取数据的工作流程之间的相似性和差异。readxl 和 googlesheets4 包都旨在模仿 readr 包的功能,该包提供了您在 第七章 中看到的 [`read_csv()`](https://readr.tidyverse.org/reference/read_delim.xhtml) 函数。因此,许多任务可以简单地用 [`read_sheet()`](https://googlesheets4.tidyverse.org/reference/range_read.xhtml) 替换 [`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 完成。但是您也会发现 Excel 和 Google Sheets 的行为方式不同,因此,其他任务可能需要进一步更新函数调用。

## 读取 Google Sheets

图 20-5 显示了我们将要在 R 中读取的电子表格的外观。这与 图 20-1 中的数据集相同,只是它存储在 Google Sheets 中而不是 Excel 中。

![查看 Google Sheets 中的学生电子表格。 电子表格包含 6 名学生的信息,包括他们的 ID、全名、喜爱的食物、饮食计划和年龄。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2005.png)

###### 图 20-5\. Google Sheets 中名为 students 的电子表格在浏览器窗口中显示。

[`read_sheet()`](https://googlesheets4.tidyverse.org/reference/range_read.xhtml) 的第一个参数是要读取的文件的 URL,它返回一个 [tibble](https://oreil.ly/c7DEP)。

这些网址不好操作,因此您通常会想要通过其 ID 来识别一个工作表。

students_sheet_id <- "1V1nPp1tzOuutXFLb3G9Eyxi3qxeEhnOXUzL5_BcCQ0w"
students <- read_sheet(students_sheet_id)

> ✔ Reading from students.

> ✔ Range Sheet1.

students

> # A tibble: 6 × 5

> Student ID Full Name favourite.food mealPlan AGE

>

> 1 1 Sunil Huffmann Strawberry yoghurt Lunch only

> 2 2 Barclay Lynn French fries Lunch only

> 3 3 Jayendra Lyne N/A Breakfast and lunch

> 4 4 Leon Rossini Anchovies Lunch only

> 5 5 Chidiegwu Dunkel Pizza Breakfast and lunch

> 6 6 Güvenç Attila Ice cream Lunch only


就像我们在 [`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 中所做的那样,我们可以为 [`read_sheet()`](https://googlesheets4.tidyverse.org/reference/range_read.xhtml) 提供列名、`NA` 字符串和列类型。

students <- read_sheet(
students_sheet_id,
col_names = c("student_id", "full_name", "favourite_food", "meal_plan", "age"),
skip = 1,
na = c("", "N/A"),
col_types = "dcccc"
)

> ✔ Reading from students.

> ✔ Range 2:10000000.

students

> # A tibble: 6 × 5

> student_id full_name favourite_food meal_plan age

>

> 1 1 Sunil Huffmann Strawberry yoghurt Lunch only 4

> 2 2 Barclay Lynn French fries Lunch only 5

> 3 3 Jayendra Lyne Breakfast and lunch 7

> 4 4 Leon Rossini Anchovies Lunch only

> 5 5 Chidiegwu Dunkel Pizza Breakfast and lunch five

> 6 6 Güvenç Attila Ice cream Lunch only 6


请注意,我们在这里稍微不同地定义了列类型,使用了简短的代码。例如,“dcccc”代表“双精度、字符、字符、字符、字符”。

还可以从 Google Sheets 中读取单独的表格。让我们从[penguins Google Sheet](https://oreil.ly/qgKTY)中读取“Torgersen Island”表格:

penguins_sheet_id <- "1aFu8lnD_g0yjF5O-K6SFgSEWiHPpgvFCF0NY9D6LXnY"
read_sheet(penguins_sheet_id, sheet = "Torgersen Island")

> ✔ Reading from penguins.

> ✔ Range ''Torgersen Island''.

> # A tibble: 52 × 8

> species island bill_length_mm bill_depth_mm flipper_length_mm

>

> 1 Adelie Torgersen <dbl [1]> <dbl [1]> <dbl [1]>

> 2 Adelie Torgersen <dbl [1]> <dbl [1]> <dbl [1]>

> 3 Adelie Torgersen <dbl [1]> <dbl [1]> <dbl [1]>

> 4 Adelie Torgersen <chr [1]> <chr [1]> <chr [1]>

> 5 Adelie Torgersen <dbl [1]> <dbl [1]> <dbl [1]>

> 6 Adelie Torgersen <dbl [1]> <dbl [1]> <dbl [1]>

> # … with 46 more rows, and 3 more variables: body_mass_g , sex ,

> # year


您可以使用[`sheet_names()`](https://googlesheets4.tidyverse.org/reference/sheet_properties.xhtml)获取 Google Sheet 中所有表格的列表:

sheet_names(penguins_sheet_id)

> [1] "Torgersen Island" "Biscoe Island" "Dream Island"


最后,就像使用[`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml)一样,我们可以通过在[`read_sheet()`](https://googlesheets4.tidyverse.org/reference/range_read.xhtml)中定义一个`range`来读取 Google Sheet 的一部分。请注意,我们还使用[`gs4_example()`](https://googlesheets4.tidyverse.org/reference/gs4_examples.xhtml)函数来找到一个包含 googlesheets4 包的示例 Google Sheet:

deaths_url <- gs4_example("deaths")
deaths <- read_sheet(deaths_url, range = "A5:F15")

> ✔ Reading from deaths.

> ✔ Range A5:F15.

deaths

> # A tibble: 10 × 6

> Name Profession Age Has kids Date of birth

>

> 1 David Bowie musician 69 TRUE 1947-01-08 00:00:00

> 2 Carrie Fisher actor 60 TRUE 1956-10-21 00:00:00

> 3 Chuck Berry musician 90 TRUE 1926-10-18 00:00:00

> 4 Bill Paxton actor 61 TRUE 1955-05-17 00:00:00

> 5 Prince musician 57 TRUE 1958-06-07 00:00:00

> 6 Alan Rickman actor 69 FALSE 1946-02-21 00:00:00

> # … with 4 more rows, and 1 more variable: Date of death


## 向 Google Sheets 写入

您可以使用[`write_sheet()`](https://googlesheets4.tidyverse.org/reference/sheet_write.xhtml)将 R 中的数据写入 Google Sheets。第一个参数是要写入的数据框,第二个参数是要写入的 Google Sheet 的名称(或其他标识符):

write_sheet(bake_sale, ss = "bake-sale")


如果您想将数据写入 Google Sheet 中的特定(工作)表中,您也可以使用 `sheet` 参数指定:

write_sheet(bake_sale, ss = "bake-sale", sheet = "Sales")


## 认证

尽管您可以在不使用 Google 帐号进行认证的情况下从公共 Google Sheet 中读取,但要读取私人表格或向表格写入,需要认证,以便 googlesheets4 可以查看和管理*您的* Google Sheets。

当您尝试读取需要认证的表格时,googlesheets4 将引导您进入一个网页浏览器,并提示您登录到您的 Google 帐号,并授予权限代表您使用 Google Sheets 进行操作。但是,如果您想指定特定的 Google 帐号、认证范围等,您可以使用[`gs4_auth()`](https://googlesheets4.tidyverse.org/reference/gs4_auth.xhtml),例如,`gs4_auth(email = "mine@example.com")`,这将强制使用与特定电子邮件相关联的令牌。有关进一步的认证细节,我们建议阅读[googlesheets4 认证文档](https://oreil.ly/G28nV)。

## 练习

1.  从本章前面的 Excel 和 Google Sheets 中读取`students`数据集,[`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml)和[`read_sheet()`](https://googlesheets4.tidyverse.org/reference/range_read.xhtml)函数没有提供额外的参数。在 R 中生成的数据框是否完全相同?如果不同,它们有什么不同之处?

1.  读取[名为 survey 的 Google Sheet](https://oreil.ly/PYENq),其中`survey_id`为字符变量,`n_pets`为数值变量。

1.  读取[名为 roster 的 Google Sheet](https://oreil.ly/sAjBM)。生成的数据框应命名为`roster`,并且应如下所示:

    ```
    #> # A tibble: 12 × 3
    #>    group subgroup    id
    #>    <dbl> <chr>    <dbl>
    #>  1     1 A            1
    #>  2     1 A            2
    #>  3     1 A            3
    #>  4     1 B            4
    #>  5     1 B            5
    #>  6     1 B            6
    #>  7     1 B            7
    #>  8     2 A            8
    #>  9     2 A            9
    #> 10     2 B           10
    #> 11     2 B           11
    #> 12     2 B           12
    ```

# 总结

Microsoft Excel 和 Google Sheets 是两种最流行的电子表格系统。直接从 R 中与存储在 Excel 和 Google Sheets 文件中的数据交互,是一种超级能力!在本章中,您学习了如何使用 [`read_excel()`](https://readxl.tidyverse.org/reference/read_excel.xhtml) 函数(来自 readxl 包)从 Excel 读取电子表格中的数据,并使用 [`read_sheet()`](https://googlesheets4.tidyverse.org/reference/range_read.xhtml) 函数(来自 googlesheets4 包)从 Google Sheets 中读取数据。这两个函数的工作方式非常相似,并且在指定列名、`NA` 字符串、要跳过的顶部行等方面具有类似的参数。此外,这两个函数都可以读取电子表格中的单个工作表。

另一方面,要将数据写入 Excel 文件则需要使用不同的包和函数([`writexl::write_xlsx()`](https://docs.ropensci.org/writexl/reference/write_xlsx.xhtml)),而要将数据写入 Google Sheets 则可以使用 googlesheets4 包中的 [`write_sheet()`](https://googlesheets4.tidyverse.org/reference/sheet_write.xhtml) 函数。

在下一章中,您将学习另一种数据源——数据库,以及如何将该数据源中的数据读取到 R 中。


# 第二十一章:数据库

# 简介

大量数据存储在数据库中,因此你了解如何访问它是至关重要的。有时你可以请求某人为你下载一个 `.csv` 文件的快照,但这很快就变得痛苦:每次需要进行更改时,你都必须与另一个人沟通。你希望能够直接进入数据库,按需获取你所需的数据。

在本章中,你将首先学习 DBI 包的基础知识:如何使用它连接数据库,然后通过 SQL¹ 查询检索数据。*SQL*,即结构化查询语言,是数据库的通用语言,对所有数据科学家来说都是一种重要的语言。话虽如此,我们不会从 SQL 入手,而是教你 dbplyr,它可以将你的 dplyr 代码转换为 SQL。我们将以此方式教授你一些 SQL 最重要的特性。虽然在本章结束时你不会成为 SQL 大师,但你将能够识别最重要的组件并理解它们的作用。

## 先决条件

在本章中,我们将介绍 DBI 和 dbplyr。DBI 是一个低级接口,用于连接数据库并执行 SQL;dbplyr 是一个高级接口,将你的 dplyr 代码转换为 SQL 查询,然后使用 DBI 执行这些查询。

library(DBI)
library(dbplyr)
library(tidyverse)


# 数据库基础知识

在最简单的层面上,你可以把数据库看作是一个数据框的集合,在数据库术语中称为*表*。就像 `data.frame` 一样,数据库表是一组具有命名列的集合,其中每个列中的值都是相同类型的。数据框与数据库表之间有三个高级别的区别:

+   数据库表存储在磁盘上,可以任意大。数据框存储在内存中,并且基本上是有限的(尽管这个限制对于许多问题来说仍然很大)。

+   数据库表几乎总是有索引的。就像书的索引一样,数据库索引使得能够快速找到感兴趣的行,而无需查看每一行。数据框和 tibble 没有索引,但数据表有,这也是它们如此快速的原因之一。

+   大多数经典数据库优化于快速收集数据,而不是分析现有数据。这些数据库被称为*面向行*,因为数据是按行存储的,而不像 R 那样按列存储。近年来,出现了许多*面向列*数据库的发展,这使得分析现有数据变得更加快速。

数据库由数据库管理系统(*DBMS* 简称)管理,基本上有三种形式:

+   *客户端-服务器* 数据库管理系统运行在强大的中央服务器上,你可以从你的计算机(客户端)连接到它们。它们非常适合在组织中与多人共享数据。流行的客户端-服务器 DBMS 包括 PostgreSQL、MariaDB、SQL Server 和 Oracle。

+   *云* DBMS,如 Snowflake、Amazon 的 RedShift 和 Google 的 BigQuery,类似于客户端-服务器 DBMS,但运行在云端。这意味着它们可以轻松处理极大的数据集,并根据需要自动提供更多的计算资源。

+   *进程内* DBMS,如 SQLite 或 duckdb,在你的计算机上完全运行。它们非常适合处理大数据集,你是主要用户。

# 连接到数据库

要从 R 连接到数据库,你将使用一对包:

+   你总是会使用 DBI(数据库接口),因为它提供了一组通用函数,用于连接到数据库、上传数据、运行 SQL 查询等等。

+   你还会使用专门为你连接的 DBMS 定制的包。这个包将通用的 DBI 命令转换为特定于给定 DBMS 所需的命令。通常每个 DBMS 都有一个包,例如,RPostgres 用于 PostgreSQL,RMariaDB 用于 MySQL。

如果找不到特定 DBMS 的包,通常可以使用 odbc 包代替。这使用许多 DBMS 支持的 ODBC 协议。odbc 需要更多的设置,因为你还需要安装 ODBC 驱动程序并告诉 odbc 包在哪里找到它。

具体来说,你可以使用[`DBI::dbConnect()`](https://dbi.r-dbi.org/reference/dbConnect.xhtml)来创建数据库连接。第一个参数选择 DBMS,² 然后第二个及后续参数描述如何连接到它(即它的位置和需要访问它所需的凭据)。以下代码展示了几个典型的例子:

con <- DBI::dbConnect(
RMariaDB::MariaDB(),
username = "foo"
)
con <- DBI::dbConnect(
RPostgres::Postgres(),
hostname = "databases.mycompany.com",
port = 1234
)


从 DBMS 到 DBMS 连接的详细信息会有很大的不同,所以遗憾的是我们无法在这里涵盖所有的细节。这意味着你需要自己做一些研究。通常你可以向团队中的其他数据科学家询问,或者与你的数据库管理员(DBA)交流。最初的设置通常需要一点调试(也许需要一些搜索),但通常只需做一次。

## 在本书中

为了本书而设立客户端-服务器或云 DBMS 可能会很麻烦,因此我们将使用一个完全驻留在 R 包中的进程内 DBMS:duckdb。多亏了 DBI 的魔力,使用 duckdb 和任何其他 DBMS 之间唯一的区别在于如何连接到数据库。这使得它非常适合教学,因为你可以轻松运行这段代码,同时也可以轻松地将所学内容应用到其他地方。

连接到 duckdb 特别简单,因为默认情况下创建一个临时数据库,退出 R 时会删除。这对学习来说非常好,因为它保证每次重新启动 R 时都从一个干净的状态开始:

con <- DBI::dbConnect(duckdb::duckdb())


duckdb 是专为数据科学家需求设计的高性能数据库。我们在这里使用它是因为它很容易入门,但它也能以极快的速度处理千兆字节的数据。如果你想在真实的数据分析项目中使用 duckdb,你还需要提供 `dbdir` 参数来创建一个持久的数据库,并告诉 duckdb 在哪里保存它。假设你正在使用一个项目(第六章),把它存储在当前项目的 `duckdb` 目录中是合理的:

con <- DBI::dbConnect(duckdb::duckdb(), dbdir = "duckdb")


## 加载一些数据

因为这是一个新的数据库,我们需要首先添加一些数据。在这里,我们将使用 [`DBI::dbWriteTable()`](https://dbi.r-dbi.org/reference/dbWriteTable.xhtml) 添加 ggplot2 的 `mpg` 和 `diamonds` 数据集。[`dbWriteTable()`](https://dbi.r-dbi.org/reference/dbWriteTable.xhtml) 的最简单用法需要三个参数:一个数据库连接,要在数据库中创建的表的名称,以及一个数据框。

dbWriteTable(con, "mpg", ggplot2::mpg)
dbWriteTable(con, "diamonds", ggplot2::diamonds)


如果你在一个真实的项目中使用 duckdb,我们强烈推荐学习 `duckdb_read_csv()` 和 `duckdb_register_arrow()`。它们为你提供了强大和高效的方式,直接将数据快速加载到 duckdb 中,而无需先加载到 R 中。我们还将展示一种有用的技术,用于将多个文件写入数据库,详见 “Writing to a Database”。

## DBI 基础

你可以通过使用几个其他的 DBI 函数来检查数据是否加载正确:`dbListTable()` 列出数据库中的所有表,³ 和 [`dbReadTable()`](https://dbi.r-dbi.org/reference/dbReadTable.xhtml) 检索表的内容。

dbListTables(con)

> [1] "diamonds" "mpg"

con |>
dbReadTable("diamonds") |>
as_tibble()

> # A tibble: 53,940 × 10

> carat cut color clarity depth table price x y z

>

> 1 0.23 Ideal E SI2 61.5 55 326 3.95 3.98 2.43

> 2 0.21 Premium E SI1 59.8 61 326 3.89 3.84 2.31

> 3 0.23 Good E VS1 56.9 65 327 4.05 4.07 2.31

> 4 0.29 Premium I VS2 62.4 58 334 4.2 4.23 2.63

> 5 0.31 Good J SI2 63.3 58 335 4.34 4.35 2.75

> 6 0.24 Very Good J VVS2 62.8 57 336 3.94 3.96 2.48

> # … with 53,934 more rows


[`dbReadTable()`](https://dbi.r-dbi.org/reference/dbReadTable.xhtml) 返回一个 `data.frame`,所以我们使用 [`as_tibble()`](https://tibble.tidyverse.org/reference/as_tibble.xhtml) 将其转换为 tibble,以便它可以漂亮地打印出来。

如果你已经了解 SQL,你可以使用 [`dbGetQuery()`](https://dbi.r-dbi.org/reference/dbGetQuery.xhtml) 来获取在数据库上运行查询的结果:

sql <- "
SELECT carat, cut, clarity, color, price
FROM diamonds
WHERE price > 15000
"
as_tibble(dbGetQuery(con, sql))

> # A tibble: 1,655 × 5

> carat cut clarity color price

>

> 1 1.54 Premium VS2 E 15002

> 2 1.19 Ideal VVS1 F 15005

> 3 2.1 Premium SI1 I 15007

> 4 1.69 Ideal SI1 D 15011

> 5 1.5 Very Good VVS2 G 15013

> 6 1.73 Very Good VS1 G 15014

> # … with 1,649 more rows


如果你以前没见过 SQL,别担心!你很快就会了解更多。但是如果你仔细阅读,你可能会猜到它选择了 `diamonds` 数据集的五列,并选择了所有 `price` 大于 15,000 的行。

# dbplyr 基础

现在我们已经连接到数据库并加载了一些数据,我们可以开始学习 dbplyr。dbplyr 是 dplyr 的一个 *后端*,这意味着你可以继续编写 dplyr 代码,但后端会以不同的方式执行它。在这里,dbplyr 转换为 SQL;其他后端包括 [dtplyr](https://oreil.ly/9Dq5p),它转换为 [data.table](https://oreil.ly/k3EaP),以及 [multidplyr](https://oreil.ly/gmDpk),它可以在多个核心上执行你的代码。

要使用 dbplyr,你必须先使用 [`tbl()`](https://dplyr.tidyverse.org/reference/tbl.xhtml) 来创建一个表示数据库表的对象:

diamonds_db <- tbl(con, "diamonds")
diamonds_db

> # Source: table [?? x 10]

> # Database: DuckDB 0.6.1 [root@Darwin 22.3.0:R 4.2.1/:memory:]

> carat cut color clarity depth table price x y z

>

> 1 0.23 Ideal E SI2 61.5 55 326 3.95 3.98 2.43

> 2 0.21 Premium E SI1 59.8 61 326 3.89 3.84 2.31

> 3 0.23 Good E VS1 56.9 65 327 4.05 4.07 2.31

> 4 0.29 Premium I VS2 62.4 58 334 4.2 4.23 2.63

> 5 0.31 Good J SI2 63.3 58 335 4.34 4.35 2.75

> 6 0.24 Very Good J VVS2 62.8 57 336 3.94 3.96 2.48

> # … with more rows


###### 注意

与数据库互动的另外两种常见方法。首先,许多企业数据库非常庞大,因此您需要一些层次结构来组织所有的表。在这种情况下,您可能需要提供一个模式或者一个目录和模式,以选择您感兴趣的表:

diamonds_db <- tbl(con, in_schema("sales", "diamonds"))
diamonds_db <- tbl(
con, in_catalog("north_america", "sales", "diamonds")
)


有时您可能希望使用自己的 SQL 查询作为起点:

diamonds_db <- tbl(con, sql("SELECT * FROM diamonds"))


这个对象是*惰性*的;当你在其上使用 dplyr 动词时,dplyr 不会进行任何操作:它只记录你希望执行的操作序列,并在需要时执行它们。例如,考虑以下管道:

big_diamonds_db <- diamonds_db |>
filter(price > 15000) |>
select(carat:clarity, price)

big_diamonds_db

> # Source: SQL [?? x 5]

> # Database: DuckDB 0.6.1 [root@Darwin 22.3.0:R 4.2.1/:memory:]

> carat cut color clarity price

>

> 1 1.54 Premium E VS2 15002

> 2 1.19 Ideal F VVS1 15005

> 3 2.1 Premium I SI1 15007

> 4 1.69 Ideal D SI1 15011

> 5 1.5 Very Good G VVS2 15013

> 6 1.73 Very Good G VS1 15014

> # … with more rows


您可以通过打印顶部的 DBMS 名称来确定这个对象表示一个数据库查询,尽管它告诉您列的数量,但通常不知道行的数量。这是因为找到总行数通常需要执行完整的查询,我们正试图避免这样做。

您可以使用 [`show_query()`](https://dplyr.tidyverse.org/reference/explain.xhtml) 函数查看 dplyr 生成的 SQL 代码。如果你了解 dplyr,这是学习 SQL 的好方法!编写一些 dplyr 代码,让 dbplyr 将其转换为 SQL,然后尝试弄清这两种语言如何匹配。

big_diamonds_db |>
show_query()

>

> SELECT carat, cut, color, clarity, price

> FROM diamonds

> WHERE (price > 15000.0)


要将所有数据带回 R,您可以调用[`collect()`](https://dplyr.tidyverse.org/reference/compute.xhtml)。在幕后,这会生成 SQL 代码,调用 [`dbGetQuery()`](https://dbi.r-dbi.org/reference/dbGetQuery.xhtml) 获取数据,然后将结果转换为 tibble:

big_diamonds <- big_diamonds_db |>
collect()
big_diamonds

> # A tibble: 1,655 × 5

> carat cut color clarity price

>

> 1 1.54 Premium E VS2 15002

> 2 1.19 Ideal F VVS1 15005

> 3 2.1 Premium I SI1 15007

> 4 1.69 Ideal D SI1 15011

> 5 1.5 Very Good G VVS2 15013

> 6 1.73 Very Good G VS1 15014

> # … with 1,649 more rows


通常情况下,您将使用 dbplyr 从数据库中选择所需的数据,使用下面描述的翻译执行基本的过滤和聚合。然后,一旦准备用 R 特有的函数分析数据,您可以使用 [`collect()`](https://dplyr.tidyverse.org/reference/compute.xhtml) 收集数据到内存中的 tibble,并继续您的工作与纯 R 代码。

# SQL

本章的其余部分将通过 dbplyr 的视角向你介绍一点 SQL。这是一个非常非传统的 SQL 入门,但我们希望它能让你迅速掌握基础知识。幸运的是,如果你了解 dplyr,那你很容易掌握 SQL,因为许多概念是相同的。

我们将使用 nycflights13 包中的两个老朋友 `flights` 和 `planes` 来探索 dplyr 和 SQL 之间的关系。这些数据集很容易导入我们的学习数据库,因为 dbplyr 自带一个将 nycflights13 中的表复制到我们数据库中的函数:

dbplyr::copy_nycflights13(con)

> Creating table: airlines

> Creating table: airports

> Creating table: flights

> Creating table: planes

> Creating table: weather

flights <- tbl(con, "flights")
planes <- tbl(con, "planes")


## SQL 基础知识

SQL 的顶层组件被称为*语句*。常见的语句包括用于定义新表的`CREATE`,用于添加数据的`INSERT`,以及用于检索数据的`SELECT`。我们将重点关注`SELECT`语句,也称为*查询*,因为这几乎是数据科学家经常使用的。

查询由 *子句* 组成。有五个重要的子句:`SELECT`、`FROM`、`WHERE`、`ORDER BY` 和 `GROUP BY`。每个查询必须包含 `SELECT`⁴ 和 `FROM`⁵ 子句,最简单的查询是 `SELECT * FROM table`,它从指定的表中选择所有列。这是未经修改的表时 `dbplyr` 生成的内容:

flights |> show_query()

>

> SELECT *

> FROM flights

planes |> show_query()

>

> SELECT *

> FROM planes


`WHERE` 和 `ORDER BY` 控制包含哪些行以及如何排序:

flights |>
filter(dest == "IAH") |>
arrange(dep_delay) |>
show_query()

>

> SELECT *

> FROM flights

> WHERE (dest = 'IAH')

> ORDER BY dep_delay


`GROUP BY` 将查询转换为摘要,导致聚合操作发生:

flights |>
group_by(dest) |>
summarize(dep_delay = mean(dep_delay, na.rm = TRUE)) |>
show_query()

>

> SELECT dest, AVG(dep_delay) AS dep_delay

> FROM flights

> GROUP BY dest


在 `dplyr` 动词和 `SELECT` 子句之间存在两个重要的区别:

+   在 SQL 中,大小写不重要:可以写成 `select`、`SELECT`,甚至 `SeLeCt`。本书中我们将坚持使用大写字母来写 SQL 关键字,以区分它们与表或变量名。

+   在 SQL 中,顺序很重要:必须按照 `SELECT`、`FROM`、`WHERE`、`GROUP BY` 和 `ORDER BY` 的顺序书写子句。令人困惑的是,这个顺序并不匹配子句实际执行的顺序,实际上是先执行 `FROM`,然后是 `WHERE`、`GROUP BY`、`SELECT` 和 `ORDER BY`。

下面的部分详细探讨了每个子句。

###### 注意

请注意,尽管 SQL 是一个标准,但它非常复杂,没有数据库完全遵循该标准。尽管我们在本书中将关注的主要组件在不同 DBMS 之间相似,但存在许多细小的变化。幸运的是,`dbplyr` 被设计来处理这个问题,并为不同的数据库生成不同的翻译。它并非完美,但在不断改进,如果遇到问题,您可以在 [GitHub](https://oreil.ly/xgmg8) 上提出问题以帮助我们做得更好。

## SELECT

`SELECT` 子句是查询的核心,执行与 [`select()`](https://dplyr.tidyverse.org/reference/select.xhtml),[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml),[`rename()`](https://dplyr.tidyverse.org/reference/rename.xhtml),[`relocate()`](https://dplyr.tidyverse.org/reference/relocate.xhtml) 和你将在下一节中学到的 [`summarize()`](https://dplyr.tidyverse.org/reference/summarise.xhtml) 相同的工作。

[`select()`](https://dplyr.tidyverse.org/reference/select.xhtml),[`rename()`](https://dplyr.tidyverse.org/reference/rename.xhtml),和 [`relocate()`](https://dplyr.tidyverse.org/reference/relocate.xhtml) 都有非常直接的对应 `SELECT` 的翻译,因为它们只影响列出现的位置(如果有的话)以及列的名称:

planes |>
select(tailnum, type, manufacturer, model, year) |>
show_query()

>

> SELECT tailnum, "type", manufacturer, model, "year"

> FROM planes

planes |>
select(tailnum, type, manufacturer, model, year) |>
rename(year_built = year) |>
show_query()

>

> SELECT tailnum, "type", manufacturer, model, "year" AS year_built

> FROM planes

planes |>
select(tailnum, type, manufacturer, model, year) |>
relocate(manufacturer, model, .before = type) |>
show_query()

>

> SELECT tailnum, manufacturer, model, "type", "year"

> FROM planes


该示例还展示了 SQL 如何进行重命名。在 SQL 术语中,重命名称为 *别名*,并使用 `AS` 进行操作。请注意,与 [`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml) 不同的是,旧名称在左侧,新名称在右侧。

###### 注意

在前面的例子中,请注意`"year"`和`"type"`被包裹在双引号中。这是因为它们在 duckdb 中是*保留词*,所以 dbplyr 将它们用引号括起来,以避免在列/表名和 SQL 操作符之间产生任何潜在混淆。

当与其他数据库一起工作时,你可能会看到每个变量名都被引用,因为只有少数客户端包(如 duckdb)知道所有的保留词,所以它们为了安全起见将所有内容都加了引号:

SELECT "tailnum", "type", "manufacturer", "model", "year"
FROM "planes"


其他一些数据库系统使用反引号而不是引号:

SELECT tailnum, type, manufacturer, model, year
FROM planes


对于[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)的翻译同样很简单:每个变量在`SELECT`中成为一个新的表达式:

flights |>
mutate(
speed = distance / (air_time / 60)
) |>
show_query()

>

> SELECT *, distance / (air_time / 60.0) AS speed

> FROM flights


我们将在“函数翻译”中回顾个别组件(如`/`)的翻译。

## FROM

`FROM`子句定义了数据源。一开始它可能不是很有趣,因为我们只是使用单表。一旦涉及到连接函数,你会看到更复杂的例子。

## GROUP BY

[`group_by()`](https://dplyr.tidyverse.org/reference/group_by.xhtml)被翻译为`GROUP BY`⁶子句,而[`summarize()`](https://dplyr.tidyverse.org/reference/summarise.xhtml)则被翻译为`SELECT`子句:

diamonds_db |>
group_by(cut) |>
summarize(
n = n(),
avg_price = mean(price, na.rm = TRUE)
) |>
show_query()

>

> SELECT cut, COUNT(*) AS n, AVG(price) AS avg_price

> FROM diamonds

> GROUP BY cut


我们将在“函数翻译”中回顾[`n()`](https://dplyr.tidyverse.org/reference/context.xhtml)和[`mean()`](https://rdrr.io/r/base/mean.xhtml)的翻译过程。

## WHERE

[`filter()`](https://dplyr.tidyverse.org/reference/filter.xhtml)被翻译为`WHERE`子句:

flights |>
filter(dest == "IAH" | dest == "HOU") |>
show_query()

>

> SELECT *

> FROM flights

> WHERE (dest = 'IAH' OR dest = 'HOU')

flights |>
filter(arr_delay > 0 & arr_delay < 20) |>
show_query()

>

> SELECT *

> FROM flights

> WHERE (arr_delay > 0.0 AND arr_delay < 20.0)


在这里需要注意几个重要的细节:

+   `|`变成`OR`,`&`变成`AND`。

+   SQL 使用`=`进行比较,而不是`==`。SQL 中没有赋值操作,所以在这方面没有混淆的可能性。

+   SQL 中只使用`''`表示字符串,而不是`""`。在 SQL 中,`""`用于标识变量,就像 R 的``` `` ```一样。

另一个有用的 SQL 操作符是`IN`,它与 R 的`%in%`相似:

flights |>
filter(dest %in% c("IAH", "HOU")) |>
show_query()

>

> SELECT *

> FROM flights

> WHERE (dest IN ('IAH', 'HOU'))


SQL 使用`NULL`而不是`NA`。`NULL`在比较和算术运算中的行为类似于`NA`。主要的区别在于,当进行汇总时,它们会被静默地丢弃。当你第一次遇到时,dbplyr 会提醒你这种行为:

flights |>
group_by(dest) |>
summarize(delay = mean(arr_delay))

> Warning: Missing values are always removed in SQL aggregation functions.

> Use na.rm = TRUE to silence this warning

> This warning is displayed once every 8 hours.

> # Source: SQL [?? x 2]

> # Database: DuckDB 0.6.1 [root@Darwin 22.3.0:R 4.2.1/:memory:]

> dest delay

>

> 1 ATL 11.3

> 2 ORD 5.88

> 3 RDU 10.1

> 4 IAD 13.9

> 5 DTW 5.43

> 6 LAX 0.547

> # … with more rows


如果你想了解更多关于`NULL`如何工作的信息,你可能会喜欢 Markus Winand 的[“SQL 的三值逻辑”](https://oreil.ly/PTwQz)。

总的来说,你可以使用在 R 中处理`NA`时使用的函数来处理`NULL`:

flights |>
filter(!is.na(dep_delay)) |>
show_query()

>

> SELECT *

> FROM flights

> WHERE (NOT((dep_delay IS NULL)))


这个 SQL 查询展示了 dbplyr 的一个缺点:虽然 SQL 是正确的,但并不像你手写的那样简单。在这种情况下,你可以去掉括号,并使用一个更容易阅读的特殊操作符:

WHERE "dep_delay" IS NOT NULL


注意,如果你用`summarize`创建的变量进行[`filter()`](https://dplyr.tidyverse.org/reference/filter.xhtml),`dbplyr`会生成`HAVING`子句,而不是`WHERE`子句。这是 SQL 的一个特殊之处:`WHERE`在`SELECT`和`GROUP BY`之前评估,所以 SQL 需要另一个在其后评估的子句。

diamonds_db |>
group_by(cut) |>
summarize(n = n()) |>
filter(n > 100) |>
show_query()

>

> SELECT cut, COUNT(*) AS n

> FROM diamonds

> GROUP BY cut

> HAVING (COUNT(*) > 100.0)


## ORDER BY

对行进行排序涉及从[`arrange()`](https://dplyr.tidyverse.org/reference/arrange.xhtml)到`ORDER BY`子句的直接翻译:

flights |>
arrange(year, month, day, desc(dep_delay)) |>
show_query()

>

> SELECT *

> FROM flights

> ORDER BY "year", "month", "day", dep_delay DESC


注意[`desc()`](https://dplyr.tidyverse.org/reference/desc.xhtml)如何翻译成`DESC`:这是许多`dplyr`函数之一,其命名直接受到 SQL 启发。

## 子查询

有时候无法将一个`dplyr`管道翻译成单个`SELECT`语句,需要使用子查询。*子查询*就是作为`FROM`子句中数据源的查询,而不是通常的表格。

`dbplyr`通常使用子查询来解决 SQL 的限制。例如,`SELECT`子句中的表达式不能引用刚刚创建的列。这意味着以下(愚蠢的)`dplyr`管道需要分两步进行:第一个(内部)查询计算`year1`,然后第二个(外部)查询可以计算`year2`:

flights |>
mutate(
year1 = year + 1,
year2 = year1 + 1
) |>
show_query()

>

> SELECT *, year1 + 1.0 AS year2

> FROM (

> SELECT *, "year" + 1.0 AS year1

> FROM flights

> ) q01


如果你尝试对刚创建的变量进行[`filter()`](https://dplyr.tidyverse.org/reference/filter.xhtml),也会看到这种情况。记住,尽管`WHERE`在`SELECT`之后编写,但它在之前评估,因此在这个(愚蠢的)例子中我们需要一个子查询:

flights |>
mutate(year1 = year + 1) |>
filter(year1 == 2014) |>
show_query()

>

> SELECT *

> FROM (

> SELECT *, "year" + 1.0 AS year1

> FROM flights

> ) q01

> WHERE (year1 = 2014.0)


有时候`dbplyr`会创建不必要的子查询,因为它尚不知道如何优化这种转换。随着时间推移,`dbplyr`的改进会使这类情况变得更少,但可能永远不会消失。

## 连接

如果你熟悉`dplyr`的连接操作,SQL 的连接操作也是类似的。这里有一个简单的例子:

flights |>
left_join(planes |> rename(year_built = year), by = "tailnum") |>
show_query()

>

> SELECT

> flights.*,

> planes."year" AS year_built,

> "type",

> manufacturer,

> model,

> engines,

> seats,

> speed,

> engine

> FROM flights

> LEFT JOIN planes

> ON (flights.tailnum = planes.tailnum)


这里需要注意的主要是语法:SQL 连接使用`FROM`子句的子子句引入额外的表格,并使用`ON`来定义表格之间的关系。

`dplyr`中这些函数的命名与 SQL 密切相关,因此你可以轻松推测[`inner_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)、[`right_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)和[`full_join()`](https://dplyr.tidyverse.org/reference/mutate-joins.xhtml)的等效 SQL 语句:

SELECT flights.*, "type", manufacturer, model, engines, seats, speed
FROM flights
INNER JOIN planes ON (flights.tailnum = planes.tailnum)

SELECT flights.*, "type", manufacturer, model, engines, seats, speed
FROM flights
RIGHT JOIN planes ON (flights.tailnum = planes.tailnum)

SELECT flights.*, "type", manufacturer, model, engines, seats, speed
FROM flights
FULL JOIN planes ON (flights.tailnum = planes.tailnum)


当从数据库中处理数据时,你可能需要许多连接。这是因为数据库表通常以高度规范化的形式存储,每个“事实”都存储在一个地方,为了保持完整的数据集进行分析,你需要浏览通过主键和外键连接的复杂网络表。如果遇到这种情况,Tobias Schieferdecker、Kirill Müller 和 Darko Bergant 的[dm 包](https://oreil.ly/tVS8h)会帮上大忙。它可以自动确定表之间的连接,使用 DBA 通常提供的约束条件可视化连接情况,生成你需要连接一个表到另一个表的连接。

## 其他动词

dbplyr 还翻译其他动词,如[`distinct()`](https://dplyr.tidyverse.org/reference/distinct.xhtml),`slice_*()`,以及[`intersect()`](https://generics.r-lib.org/reference/setops.xhtml),还有越来越多的 tidyr 函数,如[`pivot_longer()`](https://tidyr.tidyverse.org/reference/pivot_longer.xhtml)和[`pivot_wider()`](https://tidyr.tidyverse.org/reference/pivot_wider.xhtml)。查看目前提供的完整集合最简单的方法是访问[dbplyr 网站](https://oreil.ly/A8OGW)。

## 练习

1.  [`distinct()`](https://dplyr.tidyverse.org/reference/distinct.xhtml)被翻译成什么?[`head()`](https://rdrr.io/r/utils/head.xhtml)呢?

1.  解释下面每个 SQL 查询的作用,并尝试使用 dbplyr 重新创建它们:

    ```
    SELECT * 
    FROM flights
    WHERE dep_delay < arr_delay

    SELECT *, distance / (airtime / 60) AS speed
    FROM flights
    ```

# 函数翻译

到目前为止,我们已经集中讨论了 dplyr 动词如何翻译成查询语句的主要内容。现在我们将稍微深入一点,讨论与单个列一起工作的 R 函数的翻译;例如,在[`summarize()`](https://dplyr.tidyverse.org/reference/summarise.xhtml)中使用`mean(x)`时会发生什么?

为了帮助理解正在发生的事情,我们将使用几个小助手函数来运行[`summarize()`](https://dplyr.tidyverse.org/reference/summarise.xhtml)或[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml),并显示生成的 SQL。这将使探索几种变化,以及摘要和转换如何不同,变得更加容易。

summarize_query <- function(df, ...) {
df |>
summarize(...) |>
show_query()
}
mutate_query <- function(df, ...) {
df |>
mutate(..., .keep = "none") |>
show_query()
}


让我们通过一些摘要来深入研究!看看以下代码,你会注意到一些摘要函数,比如[`mean()`](https://rdrr.io/r/base/mean.xhtml),其翻译相对简单,而像[`median()`](https://rdrr.io/r/stats/median.xhtml)这样的函数则复杂得多。这种复杂性通常更高,适用于统计学中常见但在数据库中不太常见的操作。

flights |>
group_by(year, month, day) |>
summarize_query(
mean = mean(arr_delay, na.rm = TRUE),
median = median(arr_delay, na.rm = TRUE)
)

> summarise() has grouped output by "year" and "month". You can override

> using the .groups argument.

>

> SELECT

> "year",

> "month",

> "day",

> AVG(arr_delay) AS mean,

> PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY arr_delay) AS median

> FROM flights

> GROUP BY "year", "month", "day"


当你在[`mutate()`](https://dplyr.tidyverse.org/reference/mutate.xhtml)中使用它们时,摘要函数的翻译会变得更加复杂,因为它们必须转变为所谓的*窗口*函数。在 SQL 中,你可以通过在普通聚合函数后添加`OVER`来将普通聚合函数转变为窗口函数:

flights |>
group_by(year, month, day) |>
mutate_query(
mean = mean(arr_delay, na.rm = TRUE),
)

>

> SELECT

> "year",

> "month",

> "day",

> AVG(arr_delay) OVER (PARTITION BY "year", "month", "day") AS mean

> FROM flights


在 SQL 中,`GROUP BY`子句专门用于汇总,因此您可以看到分组已从`PARTITION BY`参数移至`OVER`。

窗口函数包括所有向前或向后查找的函数,例如[`lead()`](https://dplyr.tidyverse.org/reference/lead-lag.xhtml)和[`lag()`](https://dplyr.tidyverse.org/reference/lead-lag.xhtml),它们分别查看“前一个”或“后一个”值:

flights |>
group_by(dest) |>
arrange(time_hour) |>
mutate_query(
lead = lead(arr_delay),
lag = lag(arr_delay)
)

>

> SELECT

> dest,

> LEAD(arr_delay, 1, NULL) OVER (PARTITION BY dest ORDER BY time_hour) AS lead,

> LAG(arr_delay, 1, NULL) OVER (PARTITION BY dest ORDER BY time_hour) AS lag

> FROM flights

> ORDER BY time_hour


在这里,重要的是对数据进行[`arrange()`](https://dplyr.tidyverse.org/reference/arrange.xhtml),因为 SQL 表没有固有的顺序。实际上,如果不使用[`arrange()`](https://dplyr.tidyverse.org/reference/arrange.xhtml),每次都可能以不同的顺序返回行!请注意,对于窗口函数,排序信息是重复的:主查询的`ORDER BY`子句不会自动应用于窗口函数。

另一个重要的 SQL 函数是`CASE WHEN`。它被用作[`if_else()`](https://dplyr.tidyverse.org/reference/if_else.xhtml)和[`case_when()`](https://dplyr.tidyverse.org/reference/case_when.xhtml)的翻译,这两个是直接受其启发的 dplyr 函数。这里有几个简单的示例:

flights |>
mutate_query(
description = if_else(arr_delay > 0, "delayed", "on-time")
)

>

> SELECT CASE WHEN

> (arr_delay > 0.0) THEN 'delayed'

> WHEN NOT (arr_delay > 0.0) THEN 'on-time' END AS description

> FROM flights

flights |>
mutate_query(
description =
case_when(
arr_delay < -5 ~ "early",
arr_delay < 5 ~ "on-time",
arr_delay >= 5 ~ "late"
)
)

>

> SELECT CASE

> WHEN (arr_delay < -5.0) THEN 'early'

> WHEN (arr_delay < 5.0) THEN 'on-time'

> WHEN (arr_delay >= 5.0) THEN 'late'

> END AS description

> FROM flights


`CASE WHEN`也用于一些从 R 到 SQL 没有直接翻译的其他函数。其中一个很好的例子是[`cut()`](https://rdrr.io/r/base/cut.xhtml)。

flights |>
mutate_query(
description = cut(
arr_delay,
breaks = c(-Inf, -5, 5, Inf),
labels = c("early", "on-time", "late")
)
)

>

> SELECT CASE

> WHEN (arr_delay <= -5.0) THEN 'early'

> WHEN (arr_delay <= 5.0) THEN 'on-time'

> WHEN (arr_delay > 5.0) THEN 'late'

> END AS description

> FROM flights


dbplyr 还可以翻译常见的字符串和日期时间操作函数,您可以在[`vignette("translation-function", package = "dbplyr")`](https://dbplyr.tidyverse.org/articles/translation-function.xhtml)中了解这些。dbplyr 的翻译虽然不完美,但对于您大部分时间使用的函数来说,效果出奇的好。

# 摘要

在本章中,您学习了如何从数据库访问数据。我们专注于 dbplyr,这是一个 dplyr 的“后端”,允许您编写熟悉的 dplyr 代码,并自动将其转换为 SQL。我们利用这种转换教您了一些 SQL;学习一些 SQL 很重要,因为它是处理数据最常用的语言,掌握一些 SQL 会使您更容易与其他不使用 R 的数据专业人员交流。如果您完成了本章并希望了解更多关于 SQL 的知识,我们有两个推荐:

+   [*数据科学家的 SQL*](https://oreil.ly/QfAat)由 Renée M. P. Teate 编写,专为数据科学家的需求设计的 SQL 介绍,并包括您在真实组织中可能遇到的高度互联数据的示例。

+   [*实用 SQL*](https://oreil.ly/-0Usp)由安东尼·德巴罗斯(Anthony DeBarros)从数据记者的视角(一位专门讲述引人入胜故事的数据科学家)撰写,详细介绍了如何将数据导入数据库并运行自己的数据库管理系统(DBMS)。

在下一章中,我们将学习另一个用于处理大数据的 dplyr 后端:arrow。arrow 包设计用于处理磁盘上的大文件,是数据库的自然补充。

¹ SQL 可以发音为 “s”-“q”-“l” 或 “sequel”。

² 通常,这是你从客户端包中使用的唯一函数,因此我们建议使用 `::` 来提取那个函数,而不是用 [`library()`](https://rdrr.io/r/base/library.xhtml) 加载整个包。

³ 至少是你有权限查看的所有表。

⁴ 令人困惑的是,根据上下文,`SELECT` 可能是语句,也可能是子句。为避免混淆,我们通常使用 `SELECT` 查询而不是 `SELECT` 语句。

⁵ 严格来说,只需要 `SELECT`,因为你可以编写像 `SELECT 1+1` 这样的查询来进行基本计算。但如果你想处理数据(正如你总是需要的!),你还需要 `FROM` 子句。

⁶ 这并非巧合:dplyr 函数名受到了 SQL 语句的启发。


# 第二十二章:Arrow

# 介绍

CSV 文件设计成易于人类阅读。它们是一种很好的交换格式,因为它们简单且可以被各种工具读取。但 CSV 文件不够高效:您需要做相当多的工作才能将数据读入 R。在本章中,您将了解到一个强大的替代方案:[parquet 格式](https://oreil.ly/ClE7D),这是一个基于开放标准的格式,在大数据系统中被广泛使用。

我们将 parquet 文件与 [Apache Arrow](https://oreil.ly/TGrH5) 配对使用,后者是一款专为高效分析和传输大数据集而设计的多语言工具包。我们将通过 [arrow 软件包](https://oreil.ly/g60F8) 使用 Apache Arrow,它提供了 dplyr 后端,允许您使用熟悉的 dplyr 语法分析超内存数据集。此外,arrow 运行速度极快;本章后面将展示一些示例。

arrow 和 dbplyr 都提供了 dplyr 后端,因此您可能会想知道何时使用每一个。在许多情况下,选择已经为您做出,例如数据已经在数据库中或者在 parquet 文件中,并且您希望按原样处理它。但如果您从自己的数据开始(也许是 CSV 文件),您可以将其加载到数据库中或将其转换为 parquet。总之,在早期分析阶段,很难知道哪种方法最有效,因此我们建议您尝试两种方法,并选择最适合您的那一种。

(特别感谢 Danielle Navarro 提供本章的初始版本。)

## 先决条件

在本章中,我们将继续使用 tidyverse,特别是 dplyr,但我们将与 arrow 软件包配对使用,该软件包专门设计用于处理大数据:

library(tidyverse)
library(arrow)


本章稍后我们还将看到 arrow 和 duckdb 之间的一些连接,因此我们还需要 dbplyr 和 duckdb。

library(dbplyr, warn.conflicts = FALSE)
library(duckdb)

> Loading required package: DBI


# 获取数据

我们首先获取一个值得使用这些工具的数据集:来自西雅图公共图书馆的项目借阅数据集,可在线获取,网址为 [Seattle Open Data](https://oreil.ly/u56DR)。该数据集包含 41,389,465 行数据,告诉您每本书从 2005 年 4 月至 2022 年 10 月每月被借阅的次数。

以下代码将帮助您获取数据的缓存副本。数据是一个 9 GB 的 CSV 文件,因此下载可能需要一些时间。我强烈建议您使用 `curl::multidownload()` 来获取非常大的文件,因为它专为此目的而设计:它会提供进度条,并且如果下载中断,它可以恢复下载。

dir.create("data", showWarnings = FALSE)

curl::multi_download(
"
https://r4ds.s3.us-west-2.amazonaws.com/seattle-library-checkouts.csv",
"data/seattle-library-checkouts.csv",
resume = TRUE
)


# 打开数据集

让我们先来看一下数据。9 GB 的文件足够大,我们可能不希望将整个文件加载到内存中。一个好的经验法则是,您通常希望内存大小至少是数据大小的两倍,而许多笔记本电脑最大只能支持 16 GB。这意味着我们应避免使用 [`read_csv()`](https://readr.tidyverse.org/reference/read_delim.xhtml),而应使用 [`arrow::open_dataset()`](https://arrow.apache.org/docs/r/reference/open_dataset.xhtml):

seattle_csv <- open_dataset(
sources = "data/seattle-library-checkouts.csv",
format = "csv"
)


当运行此代码时会发生什么?[`open_dataset()`](https://arrow.apache.org/docs/r/reference/open_dataset.xhtml)将扫描几千行以确定数据集的结构。然后记录它所找到的内容并停止;只有在您明确请求时才会继续读取更多行。这些元数据是我们在打印`seattle_csv`时看到的内容:

seattle_csv

> FileSystemDataset with 1 csv file

> UsageClass: string

> CheckoutType: string

> MaterialType: string

> CheckoutYear: int64

> CheckoutMonth: int64

> Checkouts: int64

> Title: string

> ISBN: null

> Creator: string

> Subjects: string

> Publisher: string

> PublicationYear: string


输出的第一行告诉您`seattle_csv`作为单个 CSV 文件存储在本地磁盘上,只有在需要时才会加载到内存中。其余输出告诉您 Arrow 为每列推断的列类型。

我们可以通过[`glimpse()`](https://pillar.r-lib.org/reference/glimpse.xhtml)看到实际情况。这显示了大约 4100 万行和 12 列,并展示了一些值。

seattle_csv |> glimpse()

> FileSystemDataset with 1 csv file

> 41,389,465 rows x 12 columns

> $ UsageClass "Physical", "Physical", "Digital", "Physical", "Ph…

> $ CheckoutType "Horizon", "Horizon", "OverDrive", "Horizon", "Hor…

> $ MaterialType "BOOK", "BOOK", "EBOOK", "BOOK", "SOUNDDISC", "BOO…

> $ CheckoutYear 2016, 2016, 2016, 2016, 2016, 2016, 2016, 2016, 20…

> $ CheckoutMonth 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,…

> $ Checkouts 1, 1, 1, 1, 1, 1, 1, 1, 4, 1, 1, 2, 3, 2, 1, 3, 2,…

> $ Title "Super rich : a guide to having it all / Russell S…

> $ ISBN "", "", "", "", "", "", "", "", "", "", "", "", ""…

> $ Creator "Simmons, Russell", "Barclay, James, 1965-", "Tim …

> $ Subjects "Self realization, Conduct of life, Attitude Psych…

> $ Publisher "Gotham Books,", "Pyr,", "Random House, Inc.", "Di…

> $ PublicationYear "c2011.", "2010.", "2015", "2005.", "c2004.", "c20…


我们可以开始使用 dplyr 动词处理此数据集,使用[`collect()`](https://dplyr.tidyverse.org/reference/compute.xhtml)强制 Arrow 执行计算并返回一些数据。例如,此代码告诉我们每年的总借阅次数:

seattle_csv |>
count(CheckoutYear, wt = Checkouts) |>
arrange(CheckoutYear) |>
collect()

> # A tibble: 18 × 2

> CheckoutYear n

>

> 1 2005 3798685

> 2 2006 6599318

> 3 2007 7126627

> 4 2008 8438486

> 5 2009 9135167

> 6 2010 8608966

> # … with 12 more rows


由于 Arrow 的支持,无论基础数据集有多大,此代码都将有效运行。但目前速度较慢:在 Hadley 的计算机上运行大约需要 10 秒。考虑到我们有多少数据,这并不糟糕,但如果切换到更好的格式,可以使其运行速度更快。

# Parquet 格式

为了使这些数据更易于处理,让我们切换到 Parquet 文件格式,并将其拆分成多个文件。接下来的部分将首先介绍 Parquet 和分区,然后应用我们学到的内容到 Seattle 图书馆数据。

## Parquet 的优势

像 CSV 一样,Parquet 用于矩形数据,但不是可以用任何文件编辑器读取的文本格式,而是一个专门为大数据需求设计的自定义二进制格式。这意味着:

+   Parquet 文件通常比等价的 CSV 文件更小。Parquet 依赖于[高效编码](https://oreil.ly/OzpFo)来减少文件大小,并支持文件压缩。这有助于使 Parquet 文件快速,因为需要从磁盘移到内存的数据更少。

+   Parquet 文件具有丰富的类型系统。正如我们在“控制列类型”中讨论的那样,CSV 文件不提供任何关于列类型的信息。例如,CSV 读取器必须猜测`"08-10-2022"`应该被解析为字符串还是日期。相比之下,Parquet 文件以记录类型及其数据的方式存储数据。

+   Parquet 文件是“列导向”的。这意味着它们按列组织,类似于 R 的数据框架。与按行组织的 CSV 文件相比,这通常会导致数据分析任务的性能更好。

+   Parquet 文件是“分块”的,这使得可以同时处理文件的不同部分,并且如果幸运的话,甚至可以跳过一些块。

## 分区

随着数据集变得越来越大,将所有数据存储在单个文件中变得越来越痛苦,将大型数据集分割成许多文件通常非常有用。当这种结构化工作得聪明时,这种策略可以显著提高性能,因为许多分析只需要一部分文件的子集。

没有关于如何分区数据集的硬性规则:结果将取决于您的数据、访问模式和读取数据的系统。在找到适合您情况的理想分区之前,您可能需要进行一些实验。作为一个粗略的指南,Arrow 建议避免小于 20 MB 和大于 2 GB 的文件,并避免产生超过 10,000 个文件的分区。您还应尝试按照您进行过滤的变量进行分区;正如您马上将看到的那样,这样可以使 Arrow 跳过许多工作,仅读取相关文件。

## 重写西雅图图书馆数据

让我们将这些想法应用到西雅图图书馆的数据中,看看它们在实践中如何运作。我们将按照 `CheckoutYear` 进行分区,因为一些分析可能只想查看最近的数据,并且按年份分区得到了 18 个合理大小的块。

为了重写数据,我们使用 [`dplyr::group_by()`](https://dplyr.tidyverse.org/reference/group_by.xhtml) 定义分区,然后使用 [`arrow::write_dataset()`](https://arrow.apache.org/docs/r/reference/write_dataset.xhtml) 将分区保存到目录中。[`write_dataset()`](https://arrow.apache.org/docs/r/reference/write_dataset.xhtml) 有两个重要的参数:我们将创建文件的目录和我们将使用的格式。

pq_path <- "data/seattle-library-checkouts"


seattle_csv |>
group_by(CheckoutYear) |>
write_dataset(path = pq_path, format = "parquet")


这需要大约一分钟才能运行;正如我们马上将看到的,这是一个初始投资,通过使未来的操作更快来得到回报。

让我们看看我们刚刚产生的内容:

tibble(
files = list.files(pq_path, recursive = TRUE),
size_MB = file.size(file.path(pq_path, files)) / 1024²
)

> # A tibble: 18 × 2

> files size_MB

>

> 1 CheckoutYear=2005/part-0.parquet 109.

> 2 CheckoutYear=2006/part-0.parquet 164.

> 3 CheckoutYear=2007/part-0.parquet 178.

> 4 CheckoutYear=2008/part-0.parquet 195.

> 5 CheckoutYear=2009/part-0.parquet 214.

> 6 CheckoutYear=2010/part-0.parquet 222.

> # … with 12 more rows


我们的单个 9 GB CSV 文件已经被重写为 18 个 parquet 文件。文件名使用了由 [Apache Hive 项目](https://oreil.ly/kACzC) 使用的“自描述”约定。Hive 风格的分区将文件夹命名为“key=value”的约定,因此您可能猜到,`CheckoutYear=2005` 目录包含所有 `CheckoutYear` 是 2005 年的数据。每个文件大小在 100 到 300 MB 之间,总大小现在约为 4 GB,略大于原始 CSV 文件大小的一半。这正如我们预期的那样,因为 parquet 是一种更高效的格式。

# 使用 dplyr 与 Arrow

现在我们已经创建了这些 parquet 文件,我们需要再次读取它们。我们再次使用 [`open_dataset()`](https://arrow.apache.org/docs/r/reference/open_dataset.xhtml),但这次我们给它一个目录:

seattle_pq <- open_dataset(pq_path)


现在我们可以编写我们的 dplyr 流水线。例如,我们可以计算过去五年每个月借出的书籍总数:

query <- seattle_pq |>
filter(CheckoutYear >= 2018, MaterialType == "BOOK") |>
group_by(CheckoutYear, CheckoutMonth) |>
summarize(TotalCheckouts = sum(Checkouts)) |>
arrange(CheckoutYear, CheckoutMonth)


为 Arrow 数据编写 dplyr 代码在概念上与 dbplyr 类似,如 第二十一章 中讨论的:你编写 dplyr 代码,它会自动转换为 Apache Arrow C++ 库理解的查询,当你调用 [`collect()`](https://dplyr.tidyverse.org/reference/compute.xhtml) 时执行。

query

> FileSystemDataset (query)

> CheckoutYear: int32

> CheckoutMonth: int64

> TotalCheckouts: int64

>

> * Grouped by CheckoutYear

> * Sorted by CheckoutYear [asc], CheckoutMonth [asc]

> See $.data for the source Arrow object


调用 [`collect()`](https://dplyr.tidyverse.org/reference/compute.xhtml) 即可获取结果:

query |> collect()

> # A tibble: 58 × 3

> # Groups: CheckoutYear [5]

> CheckoutYear CheckoutMonth TotalCheckouts

>

> 1 2018 1 355101

> 2 2018 2 309813

> 3 2018 3 344487

> 4 2018 4 330988

> 5 2018 5 318049

> 6 2018 6 341825

> # … with 52 more rows


与 dbplyr 类似,Arrow 仅理解某些 R 表达式,因此可能无法像平常那样编写完全相同的代码。不过,支持的操作和函数列表相当广泛,并且在不断增加中;可以在 [`?acero`](https://arrow.apache.org/docs/r/reference/acero.xhtml) 中找到目前支持的完整列表。

## 性能

让我们快速看一下从 CSV 切换到 Parquet 对性能的影响。首先,让我们计算将数据存储为单个大型 CSV 文件时,在 2021 年每个月的借阅书籍数量所需的时间:

seattle_csv |>
filter(CheckoutYear == 2021, MaterialType == "BOOK") |>
group_by(CheckoutMonth) |>
summarize(TotalCheckouts = sum(Checkouts)) |>
arrange(desc(CheckoutMonth)) |>
collect() |>
system.time()

> user system elapsed

> 11.997 1.189 11.343


现在让我们使用我们的新版本数据集,在这个版本中,西雅图图书馆的借阅数据已经被分成了 18 个更小的 Parquet 文件:

seattle_pq |>
filter(CheckoutYear == 2021, MaterialType == "BOOK") |>
group_by(CheckoutMonth) |>
summarize(TotalCheckouts = sum(Checkouts)) |>
arrange(desc(CheckoutMonth)) |>
collect() |>
system.time()

> user system elapsed

> 0.272 0.063 0.063


性能提升约 100 倍归因于两个因素:多文件分区和单个文件的格式:

+   分区可以提高性能,因为此查询使用 `CheckoutYear == 2021` 来过滤数据,而 Arrow 足够智能,能识别出它只需读取 18 个 Parquet 文件中的一个。

+   Parquet 格式通过以二进制格式存储数据来提高性能,可以更直接地读入内存。按列存储的格式和丰富的元数据意味着 Arrow 只需读取查询中实际使用的四列(`CheckoutYear`、`MaterialType`、`CheckoutMonth` 和 `Checkouts`)。

这种性能上的巨大差异是将大型 CSV 文件转换为 Parquet 格式是值得的!

## 使用 dbplyr 和 Arrow

Parquet 和 Arrow 的最后一个优势是很容易将 Arrow 数据集转换成 DuckDB 数据库(第二十一章)只需调用 [`arrow::to_duckdb()`](https://arrow.apache.org/docs/r/reference/to_duckdb.xhtml):

seattle_pq |>
to_duckdb() |>
filter(CheckoutYear >= 2018, MaterialType == "BOOK") |>
group_by(CheckoutYear) |>
summarize(TotalCheckouts = sum(Checkouts)) |>
arrange(desc(CheckoutYear)) |>
collect()

> Warning: Missing values are always removed in SQL aggregation functions.

> Use na.rm = TRUE to silence this warning

> This warning is displayed once every 8 hours.

> # A tibble: 5 × 2

> CheckoutYear TotalCheckouts

>

> 1 2022 2431502

> 2 2021 2266438

> 3 2020 1241999

> 4 2019 3931688

> 5 2018 3987569


[`to_duckdb()`](https://arrow.apache.org/docs/r/reference/to_duckdb.xhtml) 的好处在于转移过程中不涉及任何内存复制,并且符合 Arrow 生态系统的目标:实现从一个计算环境到另一个计算环境的无缝过渡。

# 摘要

在本章中,您已经初步了解了 arrow 包,它为处理大型磁盘数据集提供了 dplyr 后端支持。它可以处理 CSV 文件,并且如果您将数据转换为 parquet 格式,速度会快得多。Parquet 是一种二进制数据格式,专为在现代计算机上进行数据分析而设计。与 CSV 文件相比,能够处理 parquet 文件的工具较少,但是其分区、压缩和列式结构使其分析效率大大提高。

接下来,您将学习有关您的第一个非矩形数据源的内容,您将使用 tidyr 包提供的工具处理此类数据。我们将重点关注来自 JSON 文件的数据,但是无论数据源如何,一般的原则都适用于类似树形的数据。


# 第二十三章:层次数据

# 介绍

在本章中,您将学习数据*矩形化*的艺术,将基本上是分层或类似树状的数据转换为由行和列组成的矩形数据框。这很重要,因为分层数据出人意料地常见,特别是在处理来自网络的数据时。

要了解矩形化,您首先需要了解列表,这种数据结构使得分层数据成为可能。然后您将学习两个关键的 tidyr 函数:[`tidyr::unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml)和[`tidyr::unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml)。接着,我们将展示几个案例研究,一遍又一遍地应用这些简单函数来解决实际问题。最后,我们将讨论 JSON,这是分层数据集的最常见来源,也是网络数据交换的常见格式。

## 先决条件

在本章中,我们将使用许多来自 tidyr 的函数,这是 tidyverse 的核心成员之一。我们还将使用*repurrrsive*提供一些有趣的数据集用于矩形化练习,并且最后使用*jsonlite*将 JSON 文件读入 R 列表。

library(tidyverse)
library(repurrrsive)
library(jsonlite)


# 列表

到目前为止,您已经处理过包含简单向量(例如整数、数字、字符、日期时间和因子)的数据框。这些向量之所以简单,是因为它们是同构的:每个元素都是相同的数据类型。如果您想在同一个向量中存储不同类型的元素,您将需要一个*列表*,可以使用[`list()`](https://rdrr.io/r/base/list.xhtml)来创建:

x1 <- list(1:4, "a", TRUE)
x1

> [[1]]

> [1] 1 2 3 4

>

> [[2]]

> [1] "a"

>

> [[3]]

> [1] TRUE


命名列表的组成部分或*子级*通常很方便,您可以像命名 tibble 的列一样做:

x2 <- list(a = 1:2, b = 1:3, c = 1:4)
x2

> $a

> [1] 1 2

>

> $b

> [1] 1 2 3

>

> $c

> [1] 1 2 3 4


即使对于这些简单的列表,打印也会占用相当多的空间。一个有用的替代方法是[`str()`](https://rdrr.io/r/utils/str.xhtml),它会生成一个*str*ucture 的紧凑显示,弱化内容:

str(x1)

> List of 3

> $ : int [1:4] 1 2 3 4

> $ : chr "a"

> $ : logi TRUE

str(x2)

> List of 3

> $ a: int [1:2] 1 2

> $ b: int [1:3] 1 2 3

> $ c: int [1:4] 1 2 3 4


正如您所见,[`str()`](https://rdrr.io/r/utils/str.xhtml)会在单独的行上显示列表的每个子级。如果存在名称,则显示名称;然后是类型的缩写;然后是前几个值。

## 层次结构

列表可以包含任何类型的对象,包括其他列表。这使它们适合表示分层(类似树状)结构:

x3 <- list(list(1, 2), list(3, 4))
str(x3)

> List of 2

> $ :List of 2

> ..$ : num 1

> ..$ : num 2

> $ :List of 2

> ..$ : num 3

> ..$ : num 4


这与[`c()`](https://rdrr.io/r/base/c.xhtml)明显不同,后者生成一个平面向量:

c(c(1, 2), c(3, 4))

> [1] 1 2 3 4

x4 <- c(list(1, 2), list(3, 4))
str(x4)

> List of 4

> $ : num 1

> $ : num 2

> $ : num 3

> $ : num 4


随着列表变得更复杂,[`str()`](https://rdrr.io/r/utils/str.xhtml)变得更有用,因为它让您能一目了然地看到层次结构:

x5 <- list(1, list(2, list(3, list(4, list(5)))))
str(x5)

> List of 2

> $ : num 1

> $ :List of 2

> ..$ : num 2

> ..$ :List of 2

> .. ..$ : num 3

> .. ..$ :List of 2

> .. .. ..$ : num 4

> .. .. ..$ :List of 1

> .. .. .. ..$ : num 5


随着列表变得越来越大和更复杂,[`str()`](https://rdrr.io/r/utils/str.xhtml)最终会开始失败,您将需要切换到[`View()`](https://rdrr.io/r/utils/View.xhtml)。¹ 图 23-1 展示了调用`View(x5)`的结果。查看器首先显示列表的顶层,但您可以交互地展开任何组件以查看更多内容,如图 23-2 所示。RStudio 还会显示访问该元素所需的代码,如图 23-3 所示。我们将在“使用 $ 和 [[ 选择单个元素”]中回到这些代码的工作方式。

![RStudio 显示列表查看器的截图。它显示了 x4 的两个子元素:第一个子元素是一个双向向量,第二个子元素是一个列表。右向三角形表示第二个子元素本身具有子元素,但您无法看到它们。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2301.png)

###### 图 23-1。RStudio 视图允许您交互式地探索复杂列表。查看器开始时只显示列表的顶层。

![另一个列表查看器的截图,展开了 x2 的第二个子元素。它也有两个子元素,一个双向向量和另一个列表。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2302.png)

###### 图 23-2。单击右向三角形展开列表的该组件,以便您还可以看到其子元素。

![另一个截图,展开了 x4 的孙子以查看其两个子元素,再次是一个双向向量和一个列表。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2303.png)

###### 图 23-3。您可以重复此操作多次以获取您感兴趣的数据。请注意左下角:如果单击列表的元素,RStudio 将给出访问它所需的子集代码,在本例中为`x5[[2]][[2]][[2]]`。

## 列表列

列表也可以存在于一个数据框中,我们称之为列表列。列表列非常有用,因为它允许您将对象放置在数据框中,这些对象通常不属于数据框。特别是在[tidymodels 生态系统](https://oreil.ly/0giAa)中,列表列被广泛使用,因为它们允许您存储诸如模型输出或重采样之类的东西在数据框中。

这里是一个列表列的简单示例:

df <- tibble(
x = 1:2,
y = c("a", "b"),
z = list(list(1, 2), list(3, 4, 5))
)
df

> # A tibble: 2 × 3

> x y z

>

> 1 1 a <list [2]>

> 2 2 b <list [3]>


在数据框中,列表没有任何特殊之处;它们的行为就像任何其他列一样:

df |>
filter(x == 1)

> # A tibble: 1 × 3

> x y z

>

> 1 1 a <list [2]>


使用列表列进行计算更加困难,但这是因为通常情况下使用列表进行计算本身就比较困难;我们将在第二十六章回到这个话题。在本章中,我们将专注于将列表列展开成常规变量,这样您就可以在其上使用现有的工具。

默认打印方法只显示内容的粗略摘要。列表列可能非常复杂,因此没有好的打印方法。如果您想查看它,您需要提取出一个列表列,并应用您之前学到的技术之一,例如`df |> pull(z) |> str()`或`df |> pull(z) |> View()`。

# 基础 R

可以将列表放入`data.frame`的列中,但操作起来更复杂,因为[`data.frame()`](https://rdrr.io/r/base/data.frame.xhtml)将列表视为列的列表:

data.frame(x = list(1:3, 3:5))

> x.1.3 x.3.5

> 1 1 3

> 2 2 4

> 3 3 5


你可以通过将其包装在列表[`I()`](https://rdrr.io/r/base/AsIs.xhtml)中,强制[`data.frame()`](https://rdrr.io/r/base/data.frame.xhtml)将列表视为行列表,但结果并不打印得特别好:

data.frame(
x = I(list(1:2, 3:5)),
y = c("1, 2", "3, 4, 5")
)

> x y

> 1 1, 2 1, 2

> 2 3, 4, 5 3, 4, 5


使用 tibbles 更容易使用列表列,因为[`tibble()`](https://tibble.tidyverse.org/reference/tibble.xhtml)将列表视为向量,并且打印方法已经设计了列表的处理方式。

# 展开列表

现在你已经了解了列表和列表列的基础知识,让我们探讨如何将它们转换回常规的行和列。这里我们将使用简单的样本数据,以便你可以基本了解;在下一节中,我们将切换到真实数据。

列表列通常有两种基本形式:命名和未命名。当子元素是*命名*时,在每一行中它们往往具有相同的名称。例如,在`df1`中,列表列`y`的每个元素都有两个名为`a`和`b`的元素。命名列表列自然地展开为列:每个命名元素成为一个新的命名列。

df1 <- tribble(
~x, ~y,
1, list(a = 11, b = 12),
2, list(a = 21, b = 22),
3, list(a = 31, b = 32),
)


当子元素是*未命名*时,元素的数量往往会从一行到另一行变化。例如,在`df2`中,列表列`y`的元素是未命名的,并且在长度上从一到三不等。未命名列表列自然地展开为行:每个子元素都会得到一行。

df2 <- tribble(
~x, ~y,
1, list(11, 12, 13),
2, list(21),
3, list(31, 32),
)


tidyr 为这两种情况提供了两个函数:[`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml)和[`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml)。下面的部分将解释它们的工作原理。

## unnest_wider()

当每一行具有相同数量和相同名称的元素时,比如`df1`,使用[`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml)将每个组件自然地放入自己的列中:

df1 |>
unnest_wider(y)

> # A tibble: 3 × 3

> x a b

>

> 1 1 11 12

> 2 2 21 22

> 3 3 31 32


默认情况下,新列的名称仅来自列表元素的名称,但您可以使用`names_sep`参数要求它们结合列名和元素名。这对于消除重复名称很有用。

df1 |>
unnest_wider(y, names_sep = "_")

> # A tibble: 3 × 3

> x y_a y_b

>

> 1 1 11 12

> 2 2 21 22

> 3 3 31 32


## unnest_longer()

当每一行包含一个未命名列表时,最自然的做法是使用[`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml)将每个元素放入自己的行中:

df2 |>
unnest_longer(y)

> # A tibble: 6 × 2

> x y

>

> 1 1 11

> 2 1 12

> 3 1 13

> 4 2 21

> 5 3 31

> 6 3 32


注意`x`如何对每个`y`内部的元素重复:我们为列表列内的每个元素获得一行输出。但是如果其中一个元素为空会发生什么,如下例所示?

df6 <- tribble(
~x, ~y,
"a", list(1, 2),
"b", list(3),
"c", list()
)
df6 |> unnest_longer(y)

> # A tibble: 3 × 2

> x y

>

> 1 a 1

> 2 a 2

> 3 b 3


我们在输出中得到零行,因此行实际上消失了。如果想保留该行,在`y`中添加`NA`,设置`keep_empty = TRUE`。

## 不一致的类型

如果你展开一个包含不同类型向量的列表列会发生什么?例如,考虑以下数据集,列表列`y`包含两个数字,一个字符和一个逻辑,通常不能混合在单个列中:

df4 <- tribble(
~x, ~y,
"a", list(1),
"b", list("a", TRUE, 5)
)


[`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml)始终保持列集不变,同时更改行数。所以会发生什么?[`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml)如何在保持`y`中所有内容的同时产生五行?

df4 |>
unnest_longer(y)

> # A tibble: 4 × 2

> x y

>

> 1 a <dbl [1]>

> 2 b <chr [1]>

> 3 b <lgl [1]>

> 4 b <dbl [1]>


正如你所见,输出包含一个列表列,但列表列的每个元素都包含一个单一元素。因为[`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml)找不到一个共同类型的向量,它将保留列表列中的原始类型。你可能会想知道这是否违反了每列元素必须是相同类型的命令。它不会:每个元素都是一个列表,尽管内容是不同类型的。

处理不一致类型是具有挑战性的,详细信息取决于问题的确切性质和你的目标,但你可能需要来自第二十六章的工具。

## 其他函数

tidyr 还有一些其他有用的矩形函数,本书不打算覆盖:

+   [`unnest_auto()`](https://tidyr.tidyverse.org/reference/unnest_auto.xhtml)根据列表列的结构自动选择[`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml)和[`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml)之间的操作。这对于快速探索很有用,但最终这是一个不好的想法,因为它不强制你理解数据结构,使你的代码更难理解。

+   [`unnest()`](https://tidyr.tidyverse.org/reference/unnest.xhtml)扩展行和列。当你有一个列表列包含像数据框这样的二维结构时,这是有用的,尽管本书未涉及,但如果你使用[tidymodels 生态系统](https://oreil.ly/ytJvP)可能会遇到。

当你阅读他人的代码或处理较少见的矩形化挑战时,你会遇到这些函数。

## 练习

1.  当你像`df2`这样使用[`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml)时,没有命名列表列会发生什么?现在需要什么参数?缺失值会发生什么?

1.  当你像`df1`这样使用[`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml)时,名为列表列会发生什么?输出中有什么额外信息?如何抑制这些额外细节?

1.  不时地,您会遇到具有多个列表列和对齐值的数据框。例如,在以下数据框中,`y` 和 `z` 的值是对齐的(即 `y` 和 `z` 在同一行内始终具有相同的长度,`y` 的第一个值对应于 `z` 的第一个值)。如果您对此数据框应用两次 [`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml),会发生什么?如何保持 `x` 和 `y` 之间的关系?(提示:仔细阅读文档。)

    ```
    df4 <- tribble(
      ~x, ~y, ~z,
      "a", list("y-a-1", "y-a-2"), list("z-a-1", "z-a-2"),
      "b", list("y-b-1", "y-b-2", "y-b-3"), list("z-b-1", "z-b-2", "z-b-3")
    )
    ```

# 案例研究

早期使用的简单示例与真实数据之间的主要区别在于,真实数据通常包含多层嵌套,需要多次调用 [`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml) 和/或 [`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml)。为了展示实际情况,本节将通过 repurrrsive 包中的数据集解决三个真实的矩形化挑战。

## 非常广泛的数据

我们将从 `gh_repos` 开始。这是一个列表,其中包含使用 GitHub API 检索的一组 GitHub 仓库的数据。它是一个深度嵌套的列表,所以在本书中很难展示其结构;我们建议在继续之前稍微自行探索一下,使用 `View(gh_repos)`。

`gh_repos` 是一个列表,但我们的工具适用于列表列,因此我们将首先将其放入一个 tibble 中。出于后续原因,我们称此列为 `json`。

repos <- tibble(json = gh_repos)
repos

> # A tibble: 6 × 1

> json

>

> 1 <list [30]>

> 2 <list [30]>

> 3 <list [30]>

> 4 <list [26]>

> 5 <list [30]>

> 6 <list [30]>


这个 tibble 包含六行,每一行都包含一个未命名列表,其中每个列表包含 26 或 30 行。由于这些未命名,我们将使用 [`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml) 将每个子元素放入自己的行中:

repos |>
unnest_longer(json)

> # A tibble: 176 × 1

> json

>

> 1 <named list [68]>

> 2 <named list [68]>

> 3 <named list [68]>

> 4 <named list [68]>

> 5 <named list [68]>

> 6 <named list [68]>

> # … with 170 more rows


乍一看,可能会觉得我们没有改进情况:虽然我们有了更多的行(176 行而不是 6 行),但 `json` 的每个元素仍然是一个列表。然而,有一个重要的区别:现在每个元素都是一个 *具名* 列表,所以我们可以使用 [`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml) 将每个元素放入自己的列中:

repos |>
unnest_longer(json) |>
unnest_wider(json)

> # A tibble: 176 × 68

> id name full_name owner private html_url

>

> 1 61160198 after gaborcsardi/after FALSE https://github

> 2 40500181 argufy gaborcsardi/argu… FALSE https://github

> 3 36442442 ask gaborcsardi/ask FALSE https://github

> 4 34924886 baseimports gaborcsardi/base… FALSE https://github

> 5 61620661 citest gaborcsardi/cite… FALSE https://github

> 6 33907457 clisymbols gaborcsardi/clis… FALSE https://github

> # … with 170 more rows, and 62 more variables: description ,

> # fork , url , forks_url , keys_url , …


这种方法有效,但结果有点令人不知所措:列太多,tibble 甚至无法打印全部!我们可以使用 [`names()`](https://rdrr.io/r/base/names.xhtml) 来查看它们,并且我们在这里查看前 10 个:

repos |>
unnest_longer(json) |>
unnest_wider(json) |>
names() |>
head(10)

> [1] "id" "name" "full_name" "owner" "private"

> [6] "html_url" "description" "fork" "url" "forks_url"


让我们挑几个看起来有趣的出来:

repos |>
unnest_longer(json) |>
unnest_wider(json) |>
select(id, full_name, owner, description)

> # A tibble: 176 × 4

> id full_name owner description

>

> 1 61160198 gaborcsardi/after <named list [17]> Run Code in the Backgro…

> 2 40500181 gaborcsardi/argufy <named list [17]> Declarative function ar…

> 3 36442442 gaborcsardi/ask <named list [17]> Friendly CLI interactio…

> 4 34924886 gaborcsardi/baseimports <named list [17]> Do we get warnings for …

> 5 61620661 gaborcsardi/citest <named list [17]> Test R package and repo…

> 6 33907457 gaborcsardi/clisymbols <named list [17]> Unicode symbols for CLI…

> # … with 170 more rows


您可以使用此方法回溯以了解 `gh_repos` 的结构:每个子元素都是一个 GitHub 用户,包含他们创建的最多 30 个 GitHub 仓库的列表。

`owner` 是另一个列表列,由于它包含一个具名列表,我们可以使用 [`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml) 获取其值:

repos |>
unnest_longer(json) |>
unnest_wider(json) |>
select(id, full_name, owner, description) |>
unnest_wider(owner)

> Error in unnest_wider():

> ! Can't duplicate names between the affected columns and the original

> data.

> ✖ These names are duplicated:

> ℹ id, from owner.

> ℹ Use names_sep to disambiguate using the column name.

> ℹ Or use names_repair to specify a repair strategy.


啊哦,这个列表列还包含一个 `id` 列,而且在同一个数据框中我们不能有两个 `id` 列。建议使用 `names_sep` 来解决这个问题:

repos |>
unnest_longer(json) |>
unnest_wider(json) |>
select(id, full_name, owner, description) |>
unnest_wider(owner, names_sep = "_")

> # A tibble: 176 × 20

> id full_name owner_login owner_id owner_avatar_url

>

> 1 61160198 gaborcsardi/after gaborcsardi 660288 https://avatars.gith

> 2 40500181 gaborcsardi/argufy gaborcsardi 660288 https://avatars.gith

> 3 36442442 gaborcsardi/ask gaborcsardi 660288 https://avatars.gith

> 4 34924886 gaborcsardi/baseimports gaborcsardi 660288 https://avatars.gith

> 5 61620661 gaborcsardi/citest gaborcsardi 660288 https://avatars.gith

> 6 33907457 gaborcsardi/clisymbols gaborcsardi 660288 https://avatars.gith

> # … with 170 more rows, and 15 more variables: owner_gravatar_id ,

> # owner_url , owner_html_url , owner_followers_url , …


这给了另一个宽数据集,但你可以感觉到 `owner` 包含了关于“拥有”仓库的人的大量额外数据。

## 关系数据

嵌套数据有时用于表示通常分布在多个数据框之间的数据。例如,考虑 `got_chars`,其中包含关于出现在 *权力的游戏* 书籍和电视系列中的角色的数据。像 `gh_repos` 一样,它是一个列表,因此我们首先将其转换为一个 tibble 的列表列:

chars <- tibble(json = got_chars)
chars

> # A tibble: 30 × 1

> json

>

> 1 <named list [18]>

> 2 <named list [18]>

> 3 <named list [18]>

> 4 <named list [18]>

> 5 <named list [18]>

> 6 <named list [18]>

> # … with 24 more rows


`json` 列包含命名元素,因此我们将从扩展它开始:

chars |>
unnest_wider(json)

> # A tibble: 30 × 18

> url id name gender culture born

>

> 1 https://www.anapio… 1022 Theon Greyjoy Male "Ironborn" "In 278 AC or …

> 2 https://www.anapio… 1052 Tyrion Lannist… Male "" "In 273 AC, at…

> 3 https://www.anapio… 1074 Victarion Grey… Male "Ironborn" "In 268 AC or …

> 4 https://www.anapio… 1109 Will Male "" ""

> 5 https://www.anapio… 1166 Areo Hotah Male "Norvoshi" "In 257 AC or …

> 6 https://www.anapio… 1267 Chett Male "" "At Hag's Mire"

> # … with 24 more rows, and 12 more variables: died , alive ,

> # titles , aliases , father , mother , …


然后我们选择几列以便阅读更轻松:

characters <- chars |>
unnest_wider(json) |>
select(id, name, gender, culture, born, died, alive)
characters

> # A tibble: 30 × 7

> id name gender culture born died

>

> 1 1022 Theon Greyjoy Male "Ironborn" "In 278 AC or 27… ""

> 2 1052 Tyrion Lannister Male "" "In 273 AC, at C… ""

> 3 1074 Victarion Greyjoy Male "Ironborn" "In 268 AC or be… ""

> 4 1109 Will Male "" "" "In 297 AC, at…

> 5 1166 Areo Hotah Male "Norvoshi" "In 257 AC or be… ""

> 6 1267 Chett Male "" "At Hag's Mire" "In 299 AC, at…

> # … with 24 more rows, and 1 more variable: alive


这个数据集还包含许多列表列:

chars |>
unnest_wider(json) |>
select(id, where(is.list))

> # A tibble: 30 × 8

> id titles aliases allegiances books povBooks tvSeries playedBy

>

> 1 1022 <chr [2]> <chr [4]> <chr [1]> <chr [3]>

> 2 1052 <chr [2]> <chr [11]> <chr [1]> <chr [2]>

> 3 1074 <chr [2]> <chr [1]> <chr [1]> <chr [3]>

> 4 1109 <chr [1]> <chr [1]> <chr [1]>

> 5 1166 <chr [1]> <chr [1]> <chr [1]> <chr [3]>

> 6 1267 <chr [1]> <chr [1]> <chr [2]>

> # … with 24 more rows


让我们探索 `titles` 列。它是一个未命名的列表列,因此我们将其展开为行:

chars |>
unnest_wider(json) |>
select(id, titles) |>
unnest_longer(titles)

> # A tibble: 59 × 2

> id titles

>

> 1 1022 Prince of Winterfell

> 2 1022 Lord of the Iron Islands (by law of the green lands)

> 3 1052 Acting Hand of the King (former)

> 4 1052 Master of Coin (former)

> 5 1074 Lord Captain of the Iron Fleet

> 6 1074 Master of the Iron Victory

> # … with 53 more rows


你可能希望看到这些数据在自己的表中,因为可以根据需要轻松地与字符数据连接。我们来做这个,需要进行一些清理:删除包含空字符串的行,并将 `titles` 重命名为 `title`,因为现在每行只包含一个标题。

titles <- chars |>
unnest_wider(json) |>
select(id, titles) |>
unnest_longer(titles) |>
filter(titles != "") |>
rename(title = titles)
titles

> # A tibble: 52 × 2

> id title

>

> 1 1022 Prince of Winterfell

> 2 1022 Lord of the Iron Islands (by law of the green lands)

> 3 1052 Acting Hand of the King (former)

> 4 1052 Master of Coin (former)

> 5 1074 Lord Captain of the Iron Fleet

> 6 1074 Master of the Iron Victory

> # … with 46 more rows


你可以想象为每个列表列创建这样的表,并使用连接将它们与字符数据结合起来,根据需要使用。

## 深度嵌套

我们将以一个非常深度嵌套的列表列结束这些案例研究,需要重复使用 [`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml) 和 [`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml) 来解开:`gmaps_cities`。这是一个包含五个城市名称及其使用 Google 的 [geocoding API](https://oreil.ly/cdBWZ) 确定位置的结果的两列 tibble:

gmaps_cities

> # A tibble: 5 × 2

> city json

>

> 1 Houston <named list [2]>

> 2 Washington <named list [2]>

> 3 New York <named list [2]>

> 4 Chicago <named list [2]>

> 5 Arlington <named list [2]>


`json` 是一个包含内部名称的列表列,因此我们从 [`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml) 开始:

gmaps_cities |>
unnest_wider(json)

> # A tibble: 5 × 3

> city results status

>

> 1 Houston <list [1]> OK

> 2 Washington <list [2]> OK

> 3 New York <list [1]> OK

> 4 Chicago <list [1]> OK

> 5 Arlington <list [2]> OK


这给我们了 `status` 和 `results`。我们将删除 `status` 列,因为它们都是 `OK`;在实际分析中,你还需要捕获所有 `status != "OK"` 的行,并找出问题所在。`results` 是一个未命名的列表,其中有一个或两个元素(我们很快就会看到原因),所以我们将其展开为行:

gmaps_cities |>
unnest_wider(json) |>
select(-status) |>
unnest_longer(results)

> # A tibble: 7 × 2

> city results

>

> 1 Houston <named list [5]>

> 2 Washington <named list [5]>

> 3 Washington <named list [5]>

> 4 New York <named list [5]>

> 5 Chicago <named list [5]>

> 6 Arlington <named list [5]>

> # … with 1 more row


现在 `results` 是一个命名列表,因此我们将使用 [`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml):

locations <- gmaps_cities |>
unnest_wider(json) |>
select(-status) |>
unnest_longer(results) |>
unnest_wider(results)
locations

> # A tibble: 7 × 6

> city address_compone…¹ formatted_address geometry place_id

>

> 1 Houston <list [4]> Houston, TX, USA ChIJAYWNSLS4QI…

> 2 Washington <list [2]> Washington, USA ChIJ-bDD5__lhV…

> 3 Washington <list [4]> Washington, DC, … ChIJW-T2Wt7Gt4…

> 4 New York <list [3]> New York, NY, USA ChIJOwg_06VPwo…

> 5 Chicago <list [4]> Chicago, IL, USA ChIJ7cv00DwsDo…

> 6 Arlington <list [4]> Arlington, TX, U… ChIJ05gI5NJiTo…

> # … with 1 more row, 1 more variable: types , and abbreviated variable

> # name ¹address_components


现在我们可以看到为什么两个城市得到了两个结果:Washington 匹配了华盛顿州和华盛顿特区,Arlington 匹配了弗吉尼亚州的阿灵顿和德克萨斯州的阿灵顿。

现在 `geometry` 列中存储的是匹配项的确切位置,我们可以从这里几个不同的地方开始。

locations |>
select(city, formatted_address, geometry) |>
unnest_wider(geometry)

> # A tibble: 7 × 6

> city formatted_address bounds location location_type

>

> 1 Houston Houston, TX, USA <named list [2]> APPROXIMATE

> 2 Washington Washington, USA <named list [2]> APPROXIMATE

> 3 Washington Washington, DC, USA <named list [2]> APPROXIMATE

> 4 New York New York, NY, USA <named list [2]> APPROXIMATE

> 5 Chicago Chicago, IL, USA <named list [2]> APPROXIMATE

> 6 Arlington Arlington, TX, USA <named list [2]> APPROXIMATE

> # … with 1 more row, and 1 more variable: viewport


这给我们新的 `bounds`(一个矩形区域)和 `location`(一个点)。我们可以展开 `location` 看到纬度(`lat`)和经度(`lng`):

locations |>
select(city, formatted_address, geometry) |>
unnest_wider(geometry) |>
unnest_wider(location)

> # A tibble: 7 × 7

> city formatted_address bounds lat lng location_type

>

> 1 Houston Houston, TX, USA <named list [2]> 29.8 -95.4 APPROXIMATE

> 2 Washington Washington, USA <named list [2]> 47.8 -121. APPROXIMATE

> 3 Washington Washington, DC, USA <named list [2]> 38.9 -77.0 APPROXIMATE

> 4 New York New York, NY, USA <named list [2]> 40.7 -74.0 APPROXIMATE

> 5 Chicago Chicago, IL, USA <named list [2]> 41.9 -87.6 APPROXIMATE

> 6 Arlington Arlington, TX, USA <named list [2]> 32.7 -97.1 APPROXIMATE

> # … with 1 more row, and 1 more variable: viewport


提取边界需要几个步骤:

locations |>
select(city, formatted_address, geometry) |>
unnest_wider(geometry) |>

focus on the variables of interest

select(!location:viewport) |>
unnest_wider(bounds)

> # A tibble: 7 × 4

> city formatted_address northeast southwest

>

> 1 Houston Houston, TX, USA <named list [2]> <named list [2]>

> 2 Washington Washington, USA <named list [2]> <named list [2]>

> 3 Washington Washington, DC, USA <named list [2]> <named list [2]>

> 4 New York New York, NY, USA <named list [2]> <named list [2]>

> 5 Chicago Chicago, IL, USA <named list [2]> <named list [2]>

> 6 Arlington Arlington, TX, USA <named list [2]> <named list [2]>

> # … with 1 more row


然后我们重命名 `southwest` 和 `northeast`(矩形的角),这样我们可以使用 `names_sep` 创建简短但富有表现力的名称:

locations |>
select(city, formatted_address, geometry) |>
unnest_wider(geometry) |>
select(!location:viewport) |>
unnest_wider(bounds) |>
rename(ne = northeast, sw = southwest) |>
unnest_wider(c(ne, sw), names_sep = "_")

> # A tibble: 7 × 6

> city formatted_address ne_lat ne_lng sw_lat sw_lng

>

> 1 Houston Houston, TX, USA 30.1 -95.0 29.5 -95.8

> 2 Washington Washington, USA 49.0 -117. 45.5 -125.

> 3 Washington Washington, DC, USA 39.0 -76.9 38.8 -77.1

> 4 New York New York, NY, USA 40.9 -73.7 40.5 -74.3

> 5 Chicago Chicago, IL, USA 42.0 -87.5 41.6 -87.9

> 6 Arlington Arlington, TX, USA 32.8 -97.0 32.6 -97.2

> # … with 1 more row


注意如何通过向[`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml)提供变量名的向量来同时展开两列。

一旦发现了获取感兴趣组件的路径,你可以使用另一个 tidyr 函数[`hoist()`](https://tidyr.tidyverse.org/reference/hoist.xhtml)直接提取它们:

locations |>
select(city, formatted_address, geometry) |>
hoist(
geometry,
ne_lat = c("bounds", "northeast", "lat"),
sw_lat = c("bounds", "southwest", "lat"),
ne_lng = c("bounds", "northeast", "lng"),
sw_lng = c("bounds", "southwest", "lng"),
)


如果这些案例研究激起了你进一步了解实际整理的兴趣,你可以在`vignette("rectangling", package = "tidyr")`中看到更多例子。

## 练习

1.  大致估计`gh_repos`的创建时间。为什么你只能大致估计日期?

1.  `gh_repo`的`owner`列包含大量重复信息,因为每个所有者可以有多个仓库。你能构建一个包含每个所有者一行的`owners`数据框吗?(提示:`distinct()`与`list-cols`一起使用会起作用吗?)

1.  按照用于`titles`的步骤来创建相似的表格,以列举*权力的游戏*角色的别名、效忠、书籍和电视系列。

1.  逐行解释以下代码。为什么它有趣?为什么它对`got_chars`起作用但在一般情况下可能不起作用?

    ```
    tibble(json = got_chars) |> 
      unnest_wider(json) |> 
      select(id, where(is.list)) |> 
      pivot_longer(
        where(is.list), 
        names_to = "name", 
        values_to = "value"
      ) |>  
      unnest_longer(value)
    ```

1.  在`gmaps_cities`中,`address_components`包含什么?为什么长度在行之间变化?适当展开它来找出答案。(提示:`types`似乎总是包含两个元素。使用[`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml)比[`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml)更容易处理吗?)

# JSON

前一节中的所有案例研究都来自野生 JSON。JSON 是 JavaScript 对象表示法的简称,是大多数 Web API 返回数据的方式。了解它很重要,因为虽然 JSON 和 R 的数据类型非常相似,但并没有完美的一对一映射,所以如果出现问题,了解 JSON 的一些内容是很有帮助的。

## 数据类型

JSON 是一种简单的格式,设计成易于机器读写,而不是人类。它有六种关键的数据类型。其中四种是标量:

+   最简单的类型是空值(`null`),在 R 中扮演与`NA`相同的角色。它表示数据的缺失。

+   字符串类似于 R 中的字符串,但必须始终使用双引号。

+   数字类似于 R 的数字:它们可以使用整数(例如 123)、小数(例如 123.45)或科学计数法(例如 1.23e3)表示。JSON 不支持`Inf`、`-Inf`或`NaN`。

+   布尔值类似于 R 的`TRUE`和`FALSE`,但使用小写的`true`和`false`。

JSON 的字符串、数字和布尔值与 R 的字符、数值和逻辑向量非常相似。主要区别在于 JSON 的标量只能表示单个值。要表示多个值,需要使用其余两种类型之一:数组和对象。

数组和对象在 R 中类似于列表;它们的区别在于是否命名。*数组* 类似于未命名的列表,并用 `[]` 写出。例如,`[1, 2, 3]` 是一个包含三个数字的数组,`[null, 1, "string", false]` 是一个包含 null、数字、字符串和布尔值的数组。*对象* 类似于命名列表,并用 [`{}`](https://rdrr.io/r/base/Paren.xhtml) 写出。名称(在 JSON 术语中称为键)是字符串,因此必须用引号括起来。例如,`{"x": 1, "y": 2}` 是一个将 `x` 映射到 1 而 `y` 映射到 2 的对象。

请注意,JSON 没有任何本地方式来表示日期或日期时间,因此它们通常作为字符串存储,您需要使用 [`readr::parse_date()`](https://readr.tidyverse.org/reference/parse_datetime.xhtml) 或 [`readr::parse_datetime()`](https://readr.tidyverse.org/reference/parse_datetime.xhtml) 将它们转换为正确的数据结构。同样,JSON 对浮点数的表示规则有些不精确,因此有时您会发现数字存储为字符串。根据需要应用 [`readr::parse_double()`](https://readr.tidyverse.org/reference/parse_atomic.xhtml) 来获取正确的变量类型。

## jsonlite

要将 JSON 转换为 R 数据结构,我们推荐 Jeroen Ooms 的 jsonlite 包。我们仅使用两个 jsonlite 函数:[`read_json()`](https://rdrr.io/pkg/jsonlite/man/read_json.xhtml) 和 [`parse_json()`](https://rdrr.io/pkg/jsonlite/man/read_json.xhtml)。在实际中,您会使用 [`read_json()`](https://rdrr.io/pkg/jsonlite/man/read_json.xhtml) 从磁盘读取 JSON 文件。例如,repurrsive 包还提供了 `gh_user` 的源作为 JSON 文件,您可以使用 [`read_json()`](https://rdrr.io/pkg/jsonlite/man/read_json.xhtml) 读取它:

A path to a json file inside the package:

gh_users_json()

> [1] "/Users/hadley/Library/R/arm64/4.2/library/repurrrsive/extdata/gh_users.json"

Read it with read_json()

gh_users2 <- read_json(gh_users_json())

Check it's the same as the data we were using previously

identical(gh_users, gh_users2)

> [1] TRUE


在本书中,我们还将使用[`parse_json()`](https://rdrr.io/pkg/jsonlite/man/read_json.xhtml),因为它接受包含 JSON 的字符串,非常适合生成简单的示例。要开始,这里有三个简单的 JSON 数据集,首先是一个数字,然后将几个数字放入数组中,最后将该数组放入对象中:

str(parse_json('1'))

> int 1

str(parse_json('[1, 2, 3]'))

> List of 3

> $ : int 1

> $ : int 2

> $ : int 3

str(parse_json('{"x": [1, 2, 3]}'))

> List of 1

> $ x:List of 3

> ..$ : int 1

> ..$ : int 2

> ..$ : int 3


jsonlite 还有另一个重要的函数叫做 [`fromJSON()`](https://rdrr.io/pkg/jsonlite/man/fromJSON.xhtml)。我们这里没有使用它,因为它执行自动简化 (`simplifyVector = TRUE`)。这在简单情况下通常效果良好,但我们认为最好自己进行矩形化,这样您就可以准确知道发生了什么,并且更容易处理最复杂的嵌套结构。

## 开始矩形化过程

在大多数情况下,JSON 文件包含一个顶级数组,因为它们旨在提供关于多个“事物”的数据,例如多个页面、多个记录或多个结果。在这种情况下,您将以 `tibble(json)` 开始您的矩形化,以便每个元素成为一行:

json <- '[
{"name": "John", "age": 34},
{"name": "Susan", "age": 27}
]'
df <- tibble(json = parse_json(json))
df

> # A tibble: 2 × 1

> json

>

> 1 <named list [2]>

> 2 <named list [2]>

df |>
unnest_wider(json)

> # A tibble: 2 × 2

> name age

>

> 1 John 34

> 2 Susan 27


在更罕见的情况下,JSON 文件由单个顶级 JSON 对象组成,代表一个“事物”。在这种情况下,您需要通过将其包装在列表中来启动矩形化过程,然后将其放入一个 tibble 中:

json <- '{
"status": "OK",
"results": [
{"name": "John", "age": 34},
{"name": "Susan", "age": 27}
]
}
'
df <- tibble(json = list(parse_json(json)))
df

> # A tibble: 1 × 1

> json

>

> 1 <named list [2]>

df |>
unnest_wider(json) |>
unnest_longer(results) |>
unnest_wider(results)

> # A tibble: 2 × 3

> status name age

>

> 1 OK John 34

> 2 OK Susan 27


或者,您可以深入解析的 JSON 并从您实际关心的部分开始:

df <- tibble(results = parse_json(json)$results)
df |>
unnest_wider(results)

> # A tibble: 2 × 2

> name age

>

> 1 John 34

> 2 Susan 27


## 练习

1.  矩形化以下 `df_col` 和 `df_row`。它们代表了在 JSON 中编码数据框的两种方式。

    ```
    json_col <- parse_json('
     {
     "x": ["a", "x", "z"],
     "y": [10, null, 3]
     }
    ')
    json_row <- parse_json('
     [
     {"x": "a", "y": 10},
     {"x": "x", "y": null},
     {"x": "z", "y": 3}
     ]
    ')

    df_col <- tibble(json = list(json_col)) 
    df_row <- tibble(json = json_row)
    ```

# 总结

在本章中,您学习了什么是列表,如何从 JSON 文件生成列表,以及如何将它们转换为矩形数据框。令人惊讶的是,我们只需要两个新函数:[`unnest_longer()`](https://tidyr.tidyverse.org/reference/unnest_longer.xhtml) 将列表元素放入行中,以及 [`unnest_wider()`](https://tidyr.tidyverse.org/reference/unnest_wider.xhtml) 将列表元素放入列中。不管列表列嵌套多深,您只需重复调用这两个函数即可。

JSON 是 Web API 返回的最常见数据格式。如果网站没有 API,但您可以在网站上看到所需数据,会发生什么?这是下一章节的主题:Web 抓取,从 HTML 网页中提取数据。

¹ 这是一个 RStudio 的特性。


# 第二十四章:网络抓取

# 介绍

本章将向您介绍使用[rvest](https://oreil.ly/lUNa6)进行网络抓取的基础知识。网络抓取是从网页中提取数据的有用工具。一些网站会提供 API,这是一组结构化的 HTTP 请求,返回 JSON 格式的数据,您可以使用第二十三章中介绍的技术处理这些数据。在可能的情况下,应该使用 API,¹ 因为通常它会提供更可靠的数据。不过,本书的范围不包括使用 Web API 进行编程。相反,我们正在教授抓取技术,这是一种无论网站是否提供 API 都可以使用的技术。

在本章中,我们将首先讨论抓取的道德和法律问题,然后深入讨论 HTML 的基础知识。然后,您将学习 CSS 选择器的基础知识,以定位页面上的特定元素,并了解如何使用 rvest 函数将数据从 HTML 的文本和属性中提取出来,并将其导入到 R 中。接着,我们将讨论一些确定您需要的 CSS 选择器的技术,最后,通过几个案例研究和对动态网站的简要讨论,结束本章。

## 先决条件

在本章中,我们将专注于 rvest 提供的工具。rvest 是 tidyverse 的成员之一,但不是核心成员,因此您需要显式加载它。我们还将加载完整的 tidyverse,因为在处理我们抓取的数据时,它通常会很有用。

library(tidyverse)
library(rvest)


# 抓取的道德和法律问题

在讨论您需要执行网页抓取的代码之前,我们需要讨论这样做是否合法和道德。总体而言,涉及这两者的情况都很复杂。

法律问题在很大程度上取决于您所在的地方。然而,作为一个通用原则,如果数据是公开的、非个人的和事实性的,您可能不会有问题。² 这三个因素很重要,因为它们与网站的条款和条件、个人可识别信息以及版权有关,我们将在后文讨论。

如果数据不是公开的、非个人的或不是事实性的,或者如果您抓取数据是为了盈利,您需要咨询律师。无论如何,您都应该尊重托管网页的服务器资源。最重要的是,如果您正在抓取许多页面,应该确保在每个请求之间等待一会儿。一个简单的方法是使用[Dmytro Perepolkin 的 polite 包](https://oreil.ly/rlujg),它将自动在请求之间暂停,并缓存结果,以便您不会重复请求同一页面。

## 服务条款

如果您仔细查看,会发现许多网站在页面的某个位置包含“条款和条件”或“服务条款”的链接,如果您仔细阅读该页面,您通常会发现该网站明确禁止网络抓取。这些页面往往是公司做出非常广泛声明的法律攫取。在可能的情况下,遵守这些服务条款是一种礼貌,但要对任何主张保持怀疑态度。

美国法院通常认为,仅仅将服务条款放在网站页脚中是不足以使您受其约束的,例如,[*HiQ Labs v. LinkedIn*](https://oreil.ly/mDAin)案件。一般来说,要受到服务条款的约束,您必须采取一些明确的行动,比如创建账户或勾选一个框框。这就是为什么数据是否*公开*如此重要;如果您无需账户即可访问它们,那么您很可能不受服务条款的约束。但请注意,在欧洲情况大不相同,法院认为即使您没有明确同意,服务条款也是可以强制执行的。

## 个人可识别信息

即使数据是公开的,您也应该非常小心地收集个人可识别信息,如姓名、电子邮件地址、电话号码、出生日期等。欧洲对此类数据的收集和存储有着特别严格的法律([GDPR](https://oreil.ly/nzJwO)),而不论您身处何地,您都可能陷入伦理泥潭。例如,在 2016 年,一组研究人员从约 7 万名 OkCupid 约会网站用户的公开资料(例如用户名、年龄、性别、位置等)中收集了数据,并公开发布,而且并未尝试匿名化这些数据。虽然研究人员认为这没有什么问题,因为这些数据已经是公开的,但由于涉及用户可识别性的伦理问题,这项工作遭到了广泛谴责。如果您的工作涉及收集个人可识别信息,我们强烈建议您阅读关于 OkCupid 研究³以及涉及获取和发布个人可识别信息的类似研究,这些研究在研究伦理方面存在争议。

## 版权

最后,您还需要担心版权法。版权法很复杂,但值得看一看的是美国法律(https://oreil.ly/OqUgO),它明确了什么受到保护:“[…]以任何有形表现形式固定的原创作品,[…]”,然后进一步描述了适用于此类作品的具体类别,如文学作品、音乐作品、电影等。版权保护不包括的显著缺失的是数据。这意味着只要您将抓取限制在事实上,版权保护就不适用。(但请注意,欧洲有一个独立的[“独创性”权利](https://oreil.ly/0ewJe),用于保护数据库。)

举个简单的例子,在美国,成分列表和说明书不受版权保护,因此版权不能用于保护食谱。但如果该食谱列表伴随着大量独特的文学内容,那就是受版权保护的。这就是为什么当您在互联网上寻找食谱时,总是会有这么多内容出现的原因。

如果您确实需要抓取原始内容(如文本或图像),您可能仍然受到[公平使用原则](https://oreil.ly/oFh0-)的保护。公平使用不是一个严格的规则,而是权衡多个因素。如果您是为研究或非商业目的收集数据,并且限制您抓取的内容仅限于您所需的内容,那么公平使用更有可能适用。

# HTML 基础知识

要抓取网页,您首先需要了解一些关于 *HTML* 的知识,这是描述网页的语言。HTML 代表超文本标记语言,看起来像这样:

Page title

A heading

Some text & some bold text.

```

HTML 由 元素 组成的分层结构,包括开始标签(例如 <tag>),可选的 属性id='first'),结束标签⁴(如 </tag>)和 内容(开始和结束标签之间的所有内容)。

由于 <> 用于开始和结束标签,您不能直接写入它们。相反,您必须使用 HTML 转义 &gt;(大于)和 &lt;(小于)。而且,由于这些转义使用 &,如果您需要一个字面上的和符号 &,您必须将其转义为 &amp;。有各种可能的 HTML 转义,但您不需要过多担心,因为 rvest 会自动处理它们。

Web 抓取是可能的,因为大多数包含您想要抓取数据的页面通常具有一致的结构。

元素

HTML 元素超过 100 种。其中一些最重要的包括:

  • 每个 HTML 页面必须位于 <html> 元素中,并且必须有两个子元素:<head>,其中包含文档元数据,如页面标题,以及 <body>,其中包含在浏览器中看到的内容。

  • 块标签如 <h1>(标题 1),<section>(章节),<p>(段落)和 <ol>(有序列表)构成页面的整体结构。

  • 内联标签如 <b>(粗体),<i>(斜体)和 <a>(链接)会在块标签内部格式化文本。

如果您遇到一个以前从未见过的标签,您可以通过一点搜索找出它的用途。另一个很好的起点是 MDN Web 文档,该文档描述了网页编程的几乎所有方面。

大多数元素可以在它们的开始和结束标签之间有内容。此内容可以是文本或更多元素。例如,以下 HTML 包含了一个文本段落,其中有一个词是粗体的:

<p>
  Hi! My <b>name</b> is Hadley.
</p>

子元素 是它包含的元素,因此前面的 <p> 元素有一个子元素,即 <b> 元素。<b> 元素没有子元素,但它确实有内容(文本“name”)。

属性

标签可以有命名的属性,看起来像name1='value1' name2='value2'。其中两个最重要的属性是idclass,它们与层叠样式表(CSS)结合使用来控制页面的视觉外观。在从页面抓取数据时,这些属性通常很有用。属性还用于记录链接的目标(<a>元素的href属性)和图像的源(<img>元素的src属性)。

提取数据

要开始抓取数据,你需要页面的 URL,通常可以从浏览器中复制。然后你需要使用read_html()将该页面的 HTML 读入 R。这将返回一个xml_document⁵对象,然后你可以使用 rvest 函数对其进行操作:

html <- read_html("http://rvest.tidyverse.org/")
html
#> {html_document}
#> <html lang="en">
#> [1] <head>\n<meta http-equiv="Content-Type" content="text/html; charset=UT ...
#> [2] <body>\n    <a href="#container" class="visually-hidden-focusable">Ski ...

rvest 还包括一个允许你在行内编写 HTML 的函数。在本章中,我们将使用这个函数来演示各种 rvest 函数如何与简单示例配合使用。

html <- minimal_html("
 <p>This is a paragraph</p>
 <ul>
 <li>This is a bulleted list</li>
 </ul>
")
html
#> {html_document}
#> <html>
#> [1] <head>\n<meta http-equiv="Content-Type" content="text/html; charset=UT ...
#> [2] <body>\n<p>This is a paragraph</p>\n<p>\n  </p>\n<ul>\n<li>This is a b ...

现在你已经在 R 中有了 HTML,是时候提取感兴趣的数据了。你首先会学习到 CSS 选择器,它们允许你识别感兴趣的元素以及可以用来从中提取数据的 rvest 函数。然后我们简要介绍一下 HTML 表格,它们有一些特殊工具。

查找元素

CSS 是用于定义 HTML 文档视觉样式的工具。CSS 包括一种用于选择页面上元素的迷你语言,称为CSS 选择器。CSS 选择器定义了定位 HTML 元素的模式,对于抓取数据很有用,因为它们提供了一种简洁的描述想要提取哪些元素的方式。

我们将在“找到正确的选择器”中更详细地讨论 CSS 选择器,但幸运的是,你可以只用三种就能走得很远:

p

选择所有<p>元素。

.title

选择所有class为“title”的元素。

#title

选择具有id属性等于“title”的元素。id属性在文档内必须是唯一的,因此这将仅选择单个元素。

让我们用一个简单的例子来尝试这些选择器:

html <- minimal_html("
 <h1>This is a heading</h1>
 <p id='first'>This is a paragraph</p>
 <p class='important'>This is an important paragraph</p>
")

使用html_elements()来查找所有匹配该选择器的元素:

html |> html_elements("p")
#> {xml_nodeset (2)}
#> [1] <p id="first">This is a paragraph</p>
#> [2] <p class="important">This is an important paragraph</p>
html |> html_elements(".important")
#> {xml_nodeset (1)}
#> [1] <p class="important">This is an important paragraph</p>
html |> html_elements("#first")
#> {xml_nodeset (1)}
#> [1] <p id="first">This is a paragraph</p>

另一个重要的函数是html_element(),它始终返回与输入相同数量的输出。如果将其应用于整个文档,它将给出第一个匹配项:

html |> html_element("p")
#> {html_node}
#> <p id="first">

当你使用一个不匹配任何元素的选择器时,html_element()html_elements() 之间有一个重要的区别。html_elements() 返回长度为 0 的向量,而 html_element() 返回一个缺失值。这很快就会变得重要。

html |> html_elements("b")
#> {xml_nodeset (0)}
html |> html_element("b")
#> {xml_missing}
#> <NA>

嵌套选择

在大多数情况下,您将一起使用 html_elements()html_element(),通常使用 html_elements() 标识将成为观察值的元素,然后使用 html_element() 查找将成为变量的元素。让我们通过一个简单的例子来看看这一点。这里我们有一个无序列表(<ul>),其中每个列表项(<li>)包含一些关于四个 Star Wars 角色的信息:

html <- minimal_html("
 <ul>
 <li><b>C-3PO</b> is a <i>droid</i> that weighs <span class='weight'>167 kg</span></li>
 <li><b>R4-P17</b> is a <i>droid</i></li>
 <li><b>R2-D2</b> is a <i>droid</i> that weighs <span class='weight'>96 kg</span></li>
 <li><b>Yoda</b> weighs <span class='weight'>66 kg</span></li>
 </ul>
 ")

我们可以使用 html_elements() 创建一个向量,其中每个元素对应不同的字符:

characters <- html |> html_elements("li")
characters
#> {xml_nodeset (4)}
#> [1] <li>\n<b>C-3PO</b> is a <i>droid</i> that weighs <span class="weight"> ...
#> [2] <li>\n<b>R4-P17</b> is a <i>droid</i>\n</li>
#> [3] <li>\n<b>R2-D2</b> is a <i>droid</i> that weighs <span class="weight"> ...
#> [4] <li>\n<b>Yoda</b> weighs <span class="weight">66 kg</span>\n</li>

要提取每个角色的名称,我们使用 html_element(),因为当应用于 html_elements() 的输出时,它保证每个元素都会返回一个响应:

characters |> html_element("b")
#> {xml_nodeset (4)}
#> [1] <b>C-3PO</b>
#> [2] <b>R4-P17</b>
#> [3] <b>R2-D2</b>
#> [4] <b>Yoda</b>

对于名称来说,html_element()html_elements() 的区别并不重要,但对于权重来说很重要。我们希望每个角色都有一个权重,即使没有权重 <span>。这就是 html_element() 的作用:

characters |> html_element(".weight")
#> {xml_nodeset (4)}
#> [1] <span class="weight">167 kg</span>
#> [2] <NA>
#> [3] <span class="weight">96 kg</span>
#> [4] <span class="weight">66 kg</span>

html_elements() 查找 characters 的所有权重 <span>,只有三个,因此我们失去了名称与权重之间的连接:

characters |> html_elements(".weight")
#> {xml_nodeset (3)}
#> [1] <span class="weight">167 kg</span>
#> [2] <span class="weight">96 kg</span>
#> [3] <span class="weight">66 kg</span>

现在您已经选择了感兴趣的元素,需要从文本内容或某些属性中提取数据。

文本和属性

html_text2()⁶ 提取 HTML 元素的纯文本内容:

characters |> 
  html_element("b") |> 
  html_text2()
#> [1] "C-3PO"  "R4-P17" "R2-D2"  "Yoda"

characters |> 
  html_element(".weight") |> 
  html_text2()
#> [1] "167 kg" NA       "96 kg"  "66 kg"

请注意,任何转义都将自动处理;您只会在源 HTML 中看到 HTML 转义,而不会在 rvest 返回的数据中看到。

html_attr() 提取属性中的数据:

html <- minimal_html("
 <p><a href='https://en.wikipedia.org/wiki/Cat'>cats</a></p>
 <p><a href='https://en.wikipedia.org/wiki/Dog'>dogs</a></p>
")

html |> 
  html_elements("p") |> 
  html_element("a") |> 
  html_attr("href")
#> [1] "https://en.wikipedia.org/wiki/Cat" "https://en.wikipedia.org/wiki/Dog"

html_attr()总是返回一个字符串,所以如果你要提取数字或日期,你需要进行一些后处理。

表格

如果你很幸运,你的数据可能已经存储在一个 HTML 表中,那么读取数据只是从那个表中读取就可以了。通常在浏览器中很容易识别表格:它通常具有行和列的矩形结构,你可以复制粘贴到像 Excel 这样的工具中。

HTML 表格由四个主要元素构成:<table><tr>(表行)、<th>(表头)和<td>(表格数据)。以下是一个简单的 HTML 表格,有两列和三行:

html <- minimal_html("
 <table class='mytable'>
 <tr><th>x</th>   <th>y</th></tr>
 <tr><td>1.5</td> <td>2.7</td></tr>
 <tr><td>4.9</td> <td>1.3</td></tr>
 <tr><td>7.2</td> <td>8.1</td></tr>
 </table>
 ")

rvest 提供了一个函数,它知道如何读取这种类型的数据:html_table()。它返回一个列表,包含页面上找到的每个表的 tibble。使用html_element()来识别你想要提取的表:

html |> 
  html_element(".mytable") |> 
  html_table()
#> # A tibble: 3 × 2
#>       x     y
#>   <dbl> <dbl>
#> 1   1.5   2.7
#> 2   4.9   1.3
#> 3   7.2   8.1

请注意,xy已经自动转换为数字。这种自动转换并不总是有效,因此在更复杂的情况下,你可能希望使用convert = FALSE来关闭它,然后自己进行转换。

查找正确的选择器

弄清楚你需要的数据选择器通常是问题中最难的部分。你通常需要进行一些实验来找到既具体(即不选择你不关心的事物)又敏感(即选择你关心的一切)的选择器。大量的试验和错误是这个过程中的正常部分!有两个主要工具可以帮助你解决这个问题:SelectorGadget 和你浏览器的开发者工具。

SelectorGadget是一个 JavaScript 书签工具,它根据你提供的正负例自动生成 CSS 选择器。它并不总是有效,但当有效时,它就像魔术一样!你可以通过阅读用户指南或观看我的视频来学习如何安装和使用 SelectorGadget。

每个现代浏览器都带有一些开发工具包,但我们推荐使用 Chrome,即使它不是你常用的浏览器:它的 Web 开发者工具是最好的,而且它们立即可用。右键单击页面上的一个元素,然后选择检查。这将打开一个可展开的视图,显示完整的 HTML 页面,以所点击的元素为中心。你可以使用这个工具来探索页面,了解哪些选择器可能有效。特别注意classid属性,因为它们通常用于形成页面的视觉结构,从而为提取你寻找的数据提供良好的工具。

在 Elements 视图中,你还可以右键单击一个元素,选择 Copy as Selector 来生成一个可以唯一标识感兴趣元素的选择器。

如果 SelectorGadget 或 Chrome DevTools 生成了你不理解的 CSS 选择器,请尝试 Selectors Explained,该网站将 CSS 选择器翻译为简单的英语。如果你经常需要这样做,可能需要更多地了解 CSS 选择器的知识。我们建议从有趣的 CSS dinner 教程开始,然后参考 MDN web docs

将所有内容汇总起来

让我们把这些内容整合起来,爬取一些网站。这些示例在你运行时可能不再起作用,这是网络爬取的基本挑战;如果网站的结构发生变化,你将不得不改变你的爬取代码。

星球大战

rvest 在 vignette("starwars") 中包含了一个非常简单的例子。这是一个具有最少 HTML 的简单页面,非常适合入门。我们鼓励你现在转到该页面,使用检查元素工具检查一个 Star Wars 电影的标题,使用键盘或鼠标探索 HTML 的层次结构,看看能否理解每部电影所使用的共享结构。

你应该能看到,每部电影都有一个共享的结构,看起来像这样:

<section>
  <h2 data-id="1">The Phantom Menace</h2>
  <p>Released: 1999-05-19</p>
  <p>Director: <span class="director">George Lucas</span></p>

  <div class="crawl">
    <p>...</p>
    <p>...</p>
    <p>...</p>
  </div>
</section>

我们的目标是将这些数据转换为一个包含 titleyeardirectorintro 变量的七行数据框架。我们将从读取 HTML 并提取所有 <section> 元素开始:

url <- "https://rvest.tidyverse.org/articles/starwars.xhtml"
html <- read_html(url)

section <- html |> html_elements("section")
section
#> {xml_nodeset (7)}
#> [1] <section><h2 data-id="1">\nThe Phantom Menace\n</h2>\n<p>\nReleased: 1 ...
#> [2] <section><h2 data-id="2">\nAttack of the Clones\n</h2>\n<p>\nReleased: ...
#> [3] <section><h2 data-id="3">\nRevenge of the Sith\n</h2>\n<p>\nReleased:  ...
#> [4] <section><h2 data-id="4">\nA New Hope\n</h2>\n<p>\nReleased: 1977-05-2 ...
#> [5] <section><h2 data-id="5">\nThe Empire Strikes Back\n</h2>\n<p>\nReleas ...
#> [6] <section><h2 data-id="6">\nReturn of the Jedi\n</h2>\n<p>\nReleased: 1 ...
#> [7] <section><h2 data-id="7">\nThe Force Awakens\n</h2>\n<p>\nReleased: 20 ...

这将检索出与该页面上找到的七部电影相匹配的七个元素,表明使用 section 作为选择器是很好的。提取单个元素非常简单,因为数据总是在文本中。只需找到正确的选择器即可:

section |> html_element("h2") |> html_text2()
#> [1] "The Phantom Menace"      "Attack of the Clones" 
#> [3] "Revenge of the Sith"     "A New Hope" 
#> [5] "The Empire Strikes Back" "Return of the Jedi" 
#> [7] "The Force Awakens"

section |> html_element(".director") |> html_text2()
#> [1] "George Lucas"     "George Lucas"     "George Lucas" 
#> [4] "George Lucas"     "Irvin Kershner"   "Richard Marquand"
#> [7] "J. J. Abrams"

一旦我们对每个组件都做完这些操作,我们可以将所有结果汇总到一个 tibble 中:

tibble(
  title = section |> 
    html_element("h2") |> 
    html_text2(),
  released = section |> 
    html_element("p") |> 
    html_text2() |> 
    str_remove("Released: ") |> 
    parse_date(),
  director = section |> 
    html_element(".director") |> 
    html_text2(),
  intro = section |> 
    html_element(".crawl") |> 
    html_text2()
)
#> # A tibble: 7 × 4
#>   title                   released   director         intro 
#>   <chr>                   <date>     <chr>            <chr> 
#> 1 The Phantom Menace      1999-05-19 George Lucas     "Turmoil has engulfed …
#> 2 Attack of the Clones    2002-05-16 George Lucas     "There is unrest in th…
#> 3 Revenge of the Sith     2005-05-19 George Lucas     "War! The Republic is …
#> 4 A New Hope              1977-05-25 George Lucas     "It is a period of civ…
#> 5 The Empire Strikes Back 1980-05-17 Irvin Kershner   "It is a dark time for…
#> 6 Return of the Jedi      1983-05-25 Richard Marquand "Luke Skywalker has re…
#> # … with 1 more row

我们对 released 做了更多处理,以获得一个稍后在分析中易于使用的变量。

IMDb Top Films

对于我们的下一个任务,我们将处理一些更复杂的事情,从 IMDb 提取前 250 部电影。在我们撰写本章时,该页面看起来像 Figure 24-1。

这张截图显示了一个带有“排名与标题”、“IMDb 评分”和“你的评分”列的表格。排名前 250 名的 9 部电影显示在上面。前五名是“肖申克的救赎”、“教父”、“黑暗骑士”、“教父续集”和“十二怒汉”。

图 24-1. 2022-12-05 拍摄的 IMDb 最佳电影网页。

这些数据具有明确的表格结构,因此值得从 html_table() 开始:

url <- "https://www.imdb.com/chart/top"
html <- read_html(url)

table <- html |> 
  html_element("table") |> 
  html_table()
table
#> # A tibble: 250 × 5
#>   ``    `Rank & Title`                    `IMDb Rating` `Your Rating`   `` 
#>   <lgl> <chr>                                     <dbl> <chr>           <lgl>
#> 1 NA    "1.\n      The Shawshank Redempt…           9.2 "12345678910\n… NA 
#> 2 NA    "2.\n      The Godfather\n      …           9.2 "12345678910\n… NA 
#> 3 NA    "3.\n      The Dark Knight\n    …           9   "12345678910\n… NA 
#> 4 NA    "4.\n      The Godfather Part II…           9   "12345678910\n… NA 
#> 5 NA    "5.\n      12 Angry Men\n       …           9   "12345678910\n… NA 
#> 6 NA    "6.\n      Schindler's List\n   …           8.9 "12345678910\n… NA 
#> # … with 244 more rows

这包括一些空列,但总体上可以很好地捕捉表格中的信息。然而,我们需要做一些额外的处理以使其更易于使用。首先,我们将使用 select()(而不是 rename())重命名列名,并在一个步骤中选择这两列。然后,我们将删除换行符和额外的空格,并应用 separate_wider_regex()(来自 “提取变量”)将标题、年份和排名分离为它们自己的变量。

ratings <- table |>
  select(
    rank_title_year = `Rank & Title`,
    rating = `IMDb Rating`
  ) |> 
  mutate(
    rank_title_year = str_replace_all(rank_title_year, "\n +", " ")
  ) |> 
  separate_wider_regex(
    rank_title_year,
    patterns = c(
      rank = "\\d+", "\\. ",
      title = ".+", " +\\(",
      year = "\\d+", "\\)"
    )
  )
ratings
#> # A tibble: 250 × 4
#>   rank  title                    year  rating
#>   <chr> <chr>                    <chr>  <dbl>
#> 1 1     The Shawshank Redemption 1994     9.2
#> 2 2     The Godfather            1972     9.2
#> 3 3     The Dark Knight          2008     9 
#> 4 4     The Godfather Part II    1974     9 
#> 5 5     12 Angry Men             1957     9 
#> 6 6     Schindler's List         1993     8.9
#> # … with 244 more rows

即使在大部分数据来自表格单元的情况下,查看原始 HTML 仍然是值得的。如果您这样做,您将发现我们可以通过使用其中的一个属性添加一些额外的数据。这是值得花一点时间来探索页面源代码的原因之一;您可能会发现额外的数据或稍微更容易的解析路径。

html |> 
  html_elements("td strong") |> 
  head() |> 
  html_attr("title")
#> [1] "9.2 based on 2,712,990 user ratings"
#> [2] "9.2 based on 1,884,423 user ratings"
#> [3] "9.0 based on 2,685,826 user ratings"
#> [4] "9.0 based on 1,286,204 user ratings"
#> [5] "9.0 based on 801,579 user ratings" 
#> [6] "8.9 based on 1,370,458 user ratings"

我们可以将这与表格数据结合起来,并再次应用 separate_wider_regex() 来提取我们关心的数据片段:

ratings |>
  mutate(
    rating_n = html |> html_elements("td strong") |> html_attr("title")
  ) |> 
  separate_wider_regex(
    rating_n,
    patterns = c(
      "[0-9.]+ based on ",
      number = "[0-9,]+",
      " user ratings"
    )
  ) |> 
  mutate(
    number = parse_number(number)
  )
#> # A tibble: 250 × 5
#>   rank  title                    year  rating  number
#>   <chr> <chr>                    <chr>  <dbl>   <dbl>
#> 1 1     The Shawshank Redemption 1994     9.2 2712990
#> 2 2     The Godfather            1972     9.2 1884423
#> 3 3     The Dark Knight          2008     9   2685826
#> 4 4     The Godfather Part II    1974     9   1286204
#> 5 5     12 Angry Men             1957     9    801579
#> 6 6     Schindler's List         1993     8.9 1370458
#> # … with 244 more rows

动态网站

到目前为止,我们专注于网站,html_elements() 返回您在浏览器中看到的内容,并讨论了如何解析它返回的内容,以及如何在整洁的数据框中组织这些信息。然而,偶尔会遇到一个网站,html_elements() 和相关方法返回的内容与浏览器中看到的内容差异很大。在许多情况下,这是因为您试图抓取的网站使用 JavaScript 动态生成页面内容。目前 rvest 无法处理这种情况,因为 rvest 只下载原始 HTML,不执行任何 JavaScript。

网站的这些类型仍然可以进行抓取,但 rvest 需要使用一种更昂贵的过程:完全模拟包括运行所有 JavaScript 在内的 Web 浏览器。在撰写本文时,这个功能尚不可用,但我们正在积极开发中,并可能在您阅读此文时提供。它使用了 chromote 软件包,实际上在后台运行 Chrome 浏览器,并提供额外的工具与网站进行交互,如人类输入文本和点击按钮。请查看 rvest 网站 获取更多详细信息。

总结

在本章中,你学到了从网页中抓取数据的原因、不适合的情况以及如何操作。首先,你了解了 HTML 的基础知识以及使用 CSS 选择器引用特定元素,然后学习了如何使用 rvest 包将数据从 HTML 中提取到 R。接着,我们通过两个案例研究演示了网页抓取:一个是从 rvest 包网站抓取 星球大战 电影数据的简单情况,另一个是从 IMDb 抓取前 250 部电影数据的复杂情况。

从网页上抓取数据的技术细节可能很复杂,特别是处理网站时;然而,法律和道德考虑可能更加复杂。在开始抓取数据之前,了解这两者非常重要。

这使我们来到了书中关于从数据源(电子表格、数据库、JSON 文件和网站)中获取数据并在 R 中整理成整洁形式的重要部分的结束。现在是时候把目光转向一个新话题了:充分利用 R 作为一种编程语言。

¹ 许多流行的 API 已经有 CRAN 包装,因此首先进行一些研究!

² 显然我们不是律师,这也不是法律建议。但这是我们在这个话题上读了很多后能给出的最好总结。

³ OkCupid 研究的一篇文章例子由 Wired 发表。

⁴ 一些标签(包括 <p><li>)不需要结束标签,但我们认为最好包括它们,因为这样可以更容易地看到 HTML 的结构。

⁵ 这个类来自于 xml2 包。xml2 是一个 rvest 基于其上构建的低级包。

⁶ rvest 还提供了 html_text(),但你几乎总是应该使用 html_text2(),因为它更好地将嵌套的 HTML 转换为文本。

第五部分: 程序

在本书的这一部分,你将提高你的编程技能。编程是所有数据科学工作都需要的横向技能:你必须使用计算机来进行数据科学;你无法仅凭头脑或纸和笔来做到这一点。

我们的数据科学流程模型,程序(导入、整理、转换、可视化、建模和沟通,即所有内容)用蓝色标出。

图 V-1. 编程是所有其他组件都依赖的基础。

编程产生代码,而代码是沟通的工具。显然,代码告诉计算机你想让它做什么。但它也向其他人类传达意义。考虑代码作为沟通工具是重要的,因为你做的每个项目本质上都是协作的。即使你不与其他人合作,你肯定会与未来的自己合作!编写清晰的代码很重要,这样其他人(比如未来的你)可以理解你为什么以这种方式进行分析。这意味着提高编程技能也包括提高沟通能力。随着时间的推移,你希望你的代码不仅更容易编写,而且更容易被他人阅读理解。

在接下来的三章中,你将学习提升编程技能的技巧:

  • 复制粘贴是一个强大的工具,但是你应该避免超过两次。在代码中重复自己是危险的,因为这很容易导致错误和不一致性。相反,在第二十五章中,你将学习如何编写函数,它可以让你提取重复的 tidyverse 代码,从而可以轻松重用。

  • 函数可以提取重复的代码,但是你经常需要在不同的输入上重复相同的操作。你需要迭代工具,它可以让你一遍又一遍地做类似的事情。这些工具包括 for 循环和函数式编程,你将在第二十六章中学习到。

  • 随着你阅读其他人编写的代码越来越多,你会看到更多不使用 tidyverse 的代码。在第二十七章中,你将学习到一些在实践中最重要的基础 R 函数。

这些章节的目标是教会你进行数据科学所需的最低限度的编程技能。一旦你掌握了这里的材料,我们强烈建议你继续投资于你的编程技能。我们写了两本可能对你有帮助的书籍。Hands on Programming with R 由 Garrett Grolemund(O’Reilly)编写,介绍 R 作为编程语言,如果 R 是你的第一门编程语言,这是一个很好的起点。Advanced R 由 Hadley Wickham(CRC Press)编写,深入探讨 R 编程语言的细节;如果你有现有的编程经验,并且在这些章节中内化了相关思想后,这是一个很好的下一步。

第二十五章:函数

介绍

提高作为数据科学家的影响力的最佳方式之一是编写函数。函数允许您以比复制粘贴更强大和通用的方式自动执行常见任务。编写函数比使用复制和粘贴具有三个重大优势:

  • 您可以为函数指定一个富有表现力的名称,使您的代码更易于理解。

  • 随着需求的变化,您只需要在一个地方更新代码,而不是在多个地方更新。

  • 当您复制粘贴时,消除发生偶然错误的可能性(即在一个地方更新变量名称,但在另一个地方没有更新)。

  • 它使得您可以从项目到项目中重复使用工作,随着时间的推移提高您的生产力。

一个很好的经验法则是每当您复制并粘贴一个代码块超过两次(即现在您有三个相同的代码副本),就考虑编写一个函数。在本章中,您将学习三种有用的函数类型:

  • 向量函数以一个或多个向量作为输入,并返回一个向量作为输出。

  • 数据框函数以数据框作为输入,并返回数据框作为输出。

  • 绘图函数以数据框作为输入,并返回绘图作为输出。

每个部分都包含许多示例,以帮助您总结所见到的模式。这些示例没有 Twitter 的帮助就不可能存在,我们鼓励您跟随评论中的链接查看原始灵感。您可能还想阅读关于通用函数绘图函数的原始推文,以查看更多函数。

先决条件

我们将汇总来自 tidyverse 的各种函数。我们还将使用 nycflights13 作为熟悉数据源来使用我们的函数:

library(tidyverse)
library(nycflights13)

向量函数

我们将从向量函数开始:这些函数接受一个或多个向量,并返回一个向量结果。例如,看一下这段代码。它做什么?

df <- tibble(
  a = rnorm(5),
  b = rnorm(5),
  c = rnorm(5),
  d = rnorm(5),
)

df |> mutate(
  a = (a - min(a, na.rm = TRUE)) / 
    (max(a, na.rm = TRUE) - min(a, na.rm = TRUE)),
  b = (b - min(b, na.rm = TRUE)) / 
    (max(b, na.rm = TRUE) - min(a, na.rm = TRUE)),
  c = (c - min(c, na.rm = TRUE)) / 
    (max(c, na.rm = TRUE) - min(c, na.rm = TRUE)),
  d = (d - min(d, na.rm = TRUE)) / 
    (max(d, na.rm = TRUE) - min(d, na.rm = TRUE)),
)
#> # A tibble: 5 × 4
#>       a     b     c     d
#>   <dbl> <dbl> <dbl> <dbl>
#> 1 0.339  2.59 0.291 0 
#> 2 0.880  0    0.611 0.557
#> 3 0      1.37 1     0.752
#> 4 0.795  1.37 0     1 
#> 5 1      1.34 0.580 0.394

您可能能够猜出这会将每列重新调整为 0 到 1 的范围。但是您发现错误了吗?当 Hadley 编写这段代码时,他在复制粘贴时犯了一个错误,并忘记将 a 更改为 b。学习如何编写函数的一个很好的理由是防止这种类型的错误。

编写函数

要编写一个函数,您需要首先分析您重复的代码,以确定哪些部分是常量,哪些部分是变化的。如果我们将前面的代码提取到 mutate() 外部,那么更容易看到模式,因为每次重复现在都是一行:

(a - min(a, na.rm = TRUE)) / (max(a, na.rm = TRUE) - min(a, na.rm = TRUE))
(b - min(b, na.rm = TRUE)) / (max(b, na.rm = TRUE) - min(b, na.rm = TRUE))
(c - min(c, na.rm = TRUE)) / (max(c, na.rm = TRUE) - min(c, na.rm = TRUE))
(d - min(d, na.rm = TRUE)) / (max(d, na.rm = TRUE) - min(d, na.rm = TRUE))  

为了使这更清晰一些,我们可以用 替换那部分变化的内容:

(█ - min(█, na.rm = TRUE)) / (max(█, na.rm = TRUE) - min(█, na.rm = TRUE))

要将其转换为函数,您需要三件事:

  • 一个 名称。这里我们将使用 rescale01,因为这个函数将一个向量重新调整到 0 到 1 之间。

  • 参数。参数是在调用之间变化的内容,我们的分析告诉我们只有一个。我们将其称为x,因为这是数字向量的传统名称。

  • 主体。主体是在所有调用中重复的代码。

然后按照模板创建一个函数:

name <- function(arguments) {
  body
}

对于这种情况,结果是:

rescale01 <- function(x) {
  (x - min(x, na.rm = TRUE)) / (max(x, na.rm = TRUE) - min(x, na.rm = TRUE))
}

在此时,您可能会使用几个简单的输入进行测试,以确保正确捕获逻辑:

rescale01(c(-10, 0, 10))
#> [1] 0.0 0.5 1.0
rescale01(c(1, 2, 3, NA, 5))
#> [1] 0.00 0.25 0.50   NA 1.00

然后,您可以将对mutate()的调用重写为:

df |> mutate(
  a = rescale01(a),
  b = rescale01(b),
  c = rescale01(c),
  d = rescale01(d),
)
#> # A tibble: 5 × 4
#>       a     b     c     d
#>   <dbl> <dbl> <dbl> <dbl>
#> 1 0.339 1     0.291 0 
#> 2 0.880 0     0.611 0.557
#> 3 0     0.530 1     0.752
#> 4 0.795 0.531 0     1 
#> 5 1     0.518 0.580 0.394

(在第二十六章中,您将学习如何使用across()来进一步减少重复,因此您只需要df |> mutate(across(a:d, rescale01))。)

改进我们的函数

您可能会注意到rescale01()函数做了一些不必要的工作——与其两次计算min()和一次计算max()相比,我们可以使用range()一次计算最小值和最大值:

rescale01 <- function(x) {
  rng <- range(x, na.rm = TRUE)
  (x - rng[1]) / (rng[2] - rng[1])
}

或者您可能会尝试在包含无限值的向量上使用此函数:

x <- c(1:10, Inf)
rescale01(x)
#>  [1]   0   0   0   0   0   0   0   0   0   0 NaN

结果并不特别有用,所以我们可以要求range()忽略无限值:

rescale01 <- function(x) {
  rng <- range(x, na.rm = TRUE, finite = TRUE)
  (x - rng[1]) / (rng[2] - rng[1])
}

rescale01(x)
#>  [1] 0.0000000 0.1111111 0.2222222 0.3333333 0.4444444 0.5555556 0.6666667
#>  [8] 0.7777778 0.8888889 1.0000000       Inf

这些更改说明了函数的一个重要好处:因为我们将重复的代码移到函数中,所以我们只需要在一个地方进行更改。

变异函数

现在您理解了函数的基本概念,让我们看看一堆示例。我们将从“mutate”函数开始,即在mutate()filter()内部运行良好的函数,因为它们返回与输入长度相同的输出。

让我们从rescale01()的简单变化开始。也许您想计算 Z 分数,将向量重新缩放为均值为 0,标准差为 1:

z_score <- function(x) {
  (x - mean(x, na.rm = TRUE)) / sd(x, na.rm = TRUE)
}

或者您可能想包装一个直接的case_when()并为其命名。例如,此clamp()函数确保向量的所有值位于最小值或最大值之间:

clamp <- function(x, min, max) {
  case_when(
    x < min ~ min,
    x > max ~ max,
    .default = x
  )
}

clamp(1:10, min = 3, max = 7)
#>  [1] 3 3 3 4 5 6 7 7 7 7

当然,函数不仅仅需要处理数值变量。您可能希望进行一些重复的字符串操作。也许您需要将第一个字符大写:

first_upper <- function(x) {
  str_sub(x, 1, 1) <- str_to_upper(str_sub(x, 1, 1))
  x
}

first_upper("hello")
#> [1] "Hello"

或者您可能希望从字符串中删除百分号、逗号和美元符号,然后将其转换为数字:

# https://twitter.com/NVlabormarket/status/1571939851922198530
clean_number <- function(x) {
  is_pct <- str_detect(x, "%")
  num <- x |> 
    str_remove_all("%") |> 
    str_remove_all(",") |> 
    str_remove_all(fixed("$")) |> 
    as.numeric(x)
  if_else(is_pct, num / 100, num)
}

clean_number("$12,300")
#> [1] 12300
clean_number("45%")
#> [1] 0.45

有时,您的函数可能会高度专门化,适用于一个数据分析步骤。例如,如果您有一堆变量,记录缺失值为 997、998 或 999,您可能希望编写一个函数来将它们替换为NA

fix_na <- function(x) {
  if_else(x %in% c(997, 998, 999), NA, x)
}

我们关注的例子主要是针对单个向量的,因为我们认为这是最常见的情况。但你的函数也可以接受多个向量输入,没有任何理由不这样做。

汇总函数

另一个重要的向量函数族是汇总函数,即返回单个值以供在summarize()中使用的函数。有时候这可能只是设置一个或两个默认参数的问题:

commas <- function(x) {
  str_flatten(x, collapse = ", ", last = " and ")
}

commas(c("cat", "dog", "pigeon"))
#> [1] "cat, dog and pigeon"

或者你可以包装一个简单的计算,比如变异系数,它将标准差除以均值:

cv <- function(x, na.rm = FALSE) {
  sd(x, na.rm = na.rm) / mean(x, na.rm = na.rm)
}

cv(runif(100, min = 0, max = 50))
#> [1] 0.5196276
cv(runif(100, min = 0, max = 500))
#> [1] 0.5652554

或者也许你只是想通过给它起一个容易记住的名字来使一个常见模式更容易记住:

# https://twitter.com/gbganalyst/status/1571619641390252033
n_missing <- function(x) {
  sum(is.na(x))
} 

你也可以编写接受多个向量输入的函数。例如,也许你想计算平均绝对预测误差,以帮助你将模型预测与实际值进行比较:

# https://twitter.com/neilgcurrie/status/1571607727255834625
mape <- function(actual, predicted) {
  sum(abs((actual - predicted) / actual)) / length(actual)
}

RStudio

一旦你开始编写函数,有两个非常有用的 RStudio 快捷键:

  • 要查找你编写的函数的定义,请将光标放在函数名称上,然后按 F2。

  • 要快速跳转到一个函数,按 Ctrl+.来打开模糊文件和函数查找器,并输入函数名称的前几个字母。你也可以导航到文件、Quarto 章节等,使其成为一个便捷的导航工具。

练习

  1. 练习将以下代码片段转换为函数。考虑每个函数的作用是什么。你会如何命名它?它需要多少个参数?

    mean(is.na(x))
    mean(is.na(y))
    mean(is.na(z))
    
    x / sum(x, na.rm = TRUE)
    y / sum(y, na.rm = TRUE)
    z / sum(z, na.rm = TRUE)
    
    round(x / sum(x, na.rm = TRUE) * 100, 1)
    round(y / sum(y, na.rm = TRUE) * 100, 1)
    round(z / sum(z, na.rm = TRUE) * 100, 1)
    
  2. rescale01()的第二个变体中,无穷大的值保持不变。你能否重写rescale01(),使得-Inf映射为 0,而Inf映射为 1?

  3. 给定一个生日向量,编写一个函数来计算年龄(以年为单位)。

  4. 编写你自己的函数来计算数值向量的方差和偏度。你可以在维基百科或其他地方查找定义。

  5. 编写both_na(),一个汇总函数,接受两个长度相同的向量,并返回两个向量中都有NA的位置数。

  6. 阅读文档以弄清楚以下函数的作用。尽管它们很短,但它们为什么很有用?

    is_directory <- function(x) {
      file.info(x)$isdir
    }
    is_readable <- function(x) {
      file.access(x, 4) == 0
    }
    

数据框函数

向量函数对于从 dplyr 动词中重复的代码很有用。但通常你也会重复使用动词本身,特别是在大型流水线中。当你注意到自己多次复制粘贴多个动词时,你可以考虑编写一个数据框函数。数据框函数类似于 dplyr 动词:它们将数据框作为第一个参数,并接受一些额外的参数来指定要对其执行什么操作,并返回一个数据框或向量。

为了让你编写一个使用 dplyr 动词的函数,我们将首先向你介绍间接引用的挑战,以及如何通过引入{{ }}来克服它。然后,我们将展示一些例子来说明你可以如何使用它。

间接引用和整洁评估

当你开始编写使用 dplyr 动词的函数时,你会迅速遇到间接性的问题。让我们通过一个简单的函数 grouped_mean() 来说明这个问题。这个函数的目标是按 group_var 分组计算 mean_var 的平均值:

grouped_mean <- function(df, group_var, mean_var) {
  df |> 
    group_by(group_var) |> 
    summarize(mean(mean_var))
}

如果我们尝试使用它,会出现错误:

diamonds |> grouped_mean(cut, carat)
#> Error in `group_by()`:
#> ! Must group by variables found in `.data`.
#> ✖ Column `group_var` is not found.

为了更清楚地说明问题,我们可以使用一个虚构的数据框:

df <- tibble(
  mean_var = 1,
  group_var = "g",
  group = 1,
  x = 10,
  y = 100
)

df |> grouped_mean(group, x)
#> # A tibble: 1 × 2
#>   group_var `mean(mean_var)`
#>   <chr>                <dbl>
#> 1 g                        1
df |> grouped_mean(group, y)
#> # A tibble: 1 × 2
#>   group_var `mean(mean_var)`
#>   <chr>                <dbl>
#> 1 g                        1

无论我们如何调用 grouped_mean(),它总是执行 df |> group_by(group_var) |> summarize(mean(mean_var)),而不是 df |> group_by(group) |> summarize(mean(x))df |> group_by(group) |> summarize(mean(y))。这是一个间接性的问题,它是因为 dplyr 使用 tidy evaluation 允许你在不特殊处理的情况下引用数据框内变量的名称。

Tidy evaluation 在大多数情况下非常棒,因为它使得你的数据分析非常简洁,你不需要指明变量来自哪个数据框;从上下文中显而易见。然而,tidy evaluation 的缺点在于当我们想将重复使用的 tidyverse 代码封装成函数时会显现出来。在这种情况下,我们需要一些方法来告诉 group_mean()summarize() 不要把 group_varmean_var 当作变量名,而是要查找我们实际想要使用的变量。

Tidy evaluation 包含了一个称为 embracing 的解决方案来解决这个问题。Embrace 一个变量意味着用大括号包裹它,因此,例如 var 变成 {{ var }}。Embrace 一个变量告诉 dplyr 使用参数内存储的值,而不是将参数作为字面变量名。记住正在发生的事情的一种方法是把 {{ }} 想象成看下一个隧道 — {{ var }} 会使得 dplyr 函数查找 var 内部的值,而不是查找一个名为 var 的变量。

因此,为了使 grouped_mean() 起作用,我们需要用 {{ }} 包裹 group_varmean_var

grouped_mean <- function(df, group_var, mean_var) {
  df |> 
    group_by({{ group_var }}) |> 
    summarize(mean({{ mean_var }}))
}

df |> grouped_mean(group, x)
#> # A tibble: 1 × 2
#>   group `mean(x)`
#>   <dbl>     <dbl>
#> 1     1        10

成功!

何时接受?

编写数据框函数的关键挑战是确定哪些参数需要被 embrace。幸运的是,这很容易,因为你可以在文档中查找。文档中有两个术语对应两种最常见的 tidy evaluation 子类型的解决方案:

数据屏蔽

这用于诸如 arrange()filter()summarize() 这样计算变量的函数中。

Tidy selection

这用于诸如 select()relocate()rename() 这样选择变量的函数中。

对于许多常见的函数,你关于哪些参数使用 tidy evaluation 的直觉应该是正确的 — 只需考虑你是否可以计算(例如 x + 1)或选择(例如 a:x)。

在接下来的几节中,我们将探讨一旦理解拥抱,您可能编写的便捷函数类型。

常见用例

如果在进行初始数据探索时经常执行相同的摘要集合,您可以考虑将它们包装在辅助函数中:

summary6 <- function(data, var) {
  data |> summarize(
    min = min({{ var }}, na.rm = TRUE),
    mean = mean({{ var }}, na.rm = TRUE),
    median = median({{ var }}, na.rm = TRUE),
    max = max({{ var }}, na.rm = TRUE),
    n = n(),
    n_miss = sum(is.na({{ var }})),
    .groups = "drop"
  )
}

diamonds |> summary6(carat)
#> # A tibble: 1 × 6
#>     min  mean median   max     n n_miss
#>   <dbl> <dbl>  <dbl> <dbl> <int>  <int>
#> 1   0.2 0.798    0.7  5.01 53940      0

(每当您在辅助函数中包装summarize()时,我们认为将.groups = "drop"设置为良好的实践,以避免消息并使数据保持未分组状态。)

此函数的好处在于,因为它包装了summarize(),所以您可以在分组数据上使用它:

diamonds |> 
  group_by(cut) |> 
  summary6(carat)
#> # A tibble: 5 × 7
#>   cut         min  mean median   max     n n_miss
#>   <ord>     <dbl> <dbl>  <dbl> <dbl> <int>  <int>
#> 1 Fair       0.22 1.05    1     5.01  1610      0
#> 2 Good       0.23 0.849   0.82  3.01  4906      0
#> 3 Very Good  0.2  0.806   0.71  4    12082      0
#> 4 Premium    0.2  0.892   0.86  4.01 13791      0
#> 5 Ideal      0.2  0.703   0.54  3.5  21551      0

此外,由于总结的参数是数据屏蔽,因此summary6()函数的var参数也是数据屏蔽。这意味着您还可以总结计算变量:

diamonds |> 
  group_by(cut) |> 
  summary6(log10(carat))
#> # A tibble: 5 × 7
#>   cut          min    mean  median   max     n n_miss
#>   <ord>      <dbl>   <dbl>   <dbl> <dbl> <int>  <int>
#> 1 Fair      -0.658 -0.0273  0      0.700  1610      0
#> 2 Good      -0.638 -0.133  -0.0862 0.479  4906      0
#> 3 Very Good -0.699 -0.164  -0.149  0.602 12082      0
#> 4 Premium   -0.699 -0.125  -0.0655 0.603 13791      0
#> 5 Ideal     -0.699 -0.225  -0.268  0.544 21551      0

要总结多个变量,您需要等到“修改多列”部分,那里您将学习如何使用across()

另一个流行的summarize()辅助函数是count()的一个版本,还计算比例:

# https://twitter.com/Diabb6/status/1571635146658402309
count_prop <- function(df, var, sort = FALSE) {
  df |>
    count({{ var }}, sort = sort) |>
    mutate(prop = n / sum(n))
}

diamonds |> count_prop(clarity)
#> # A tibble: 8 × 3
#>   clarity     n   prop
#>   <ord>   <int>  <dbl>
#> 1 I1        741 0.0137
#> 2 SI2      9194 0.170 
#> 3 SI1     13065 0.242 
#> 4 VS2     12258 0.227 
#> 5 VS1      8171 0.151 
#> 6 VVS2     5066 0.0939
#> # … with 2 more rows

此函数有三个参数:dfvarsort。只需拥抱var,因为它传递给count(),该函数对所有变量使用数据屏蔽。请注意,我们对sort使用默认值,因此如果用户未提供自己的值,它将默认为FALSE

或者,您可能希望查找变量的排序唯一值,用于数据子集。而不是提供一个变量和一个值来进行过滤,我们允许用户提供一个条件:

unique_where <- function(df, condition, var) {
  df |> 
    filter({{ condition }}) |> 
    distinct({{ var }}) |> 
    arrange({{ var }})
}

# Find all the destinations in December
flights |> unique_where(month == 12, dest)
#> # A tibble: 96 × 1
#>   dest 
#>   <chr>
#> 1 ABQ 
#> 2 ALB 
#> 3 ATL 
#> 4 AUS 
#> 5 AVL 
#> 6 BDL 
#> # … with 90 more rows

在这里,我们拥抱condition,因为它传递给filter(),并且拥抱var,因为它传递给distinct()arrange()

我们已经做了所有这些示例,将数据框作为第一个参数,但是如果您反复使用相同的数据,硬编码它可能是有意义的。例如,以下函数始终使用flights数据集,并且始终选择time_hourcarrierflight,因为它们形成复合主键,使您能够识别行:

subset_flights <- function(rows, cols) {
  flights |> 
    filter({{ rows }}) |> 
    select(time_hour, carrier, flight, {{ cols }})
}

数据屏蔽与整洁选择

有时您希望在使用数据屏蔽的函数中选择变量。例如,想象一下,您希望编写一个count_missing()方法,用于计算行中缺失观测的数量。您可以尝试编写如下内容:

count_missing <- function(df, group_vars, x_var) {
  df |> 
    group_by({{ group_vars }}) |> 
    summarize(
      n_miss = sum(is.na({{ x_var }})),
      .groups = "drop"
    )
}

flights |> 
  count_missing(c(year, month, day), dep_time)
#> Error in `group_by()`:
#> ℹ In argument: `c(year, month, day)`.
#> Caused by error:
#> ! `c(year, month, day)` must be size 336776 or 1, not 1010328.

这不起作用,因为group_by() 使用的是数据屏蔽,而不是整洁选择。我们可以通过使用方便的pick() 函数来解决这个问题,该函数允许您在数据屏蔽函数内部使用整洁选择:

count_missing <- function(df, group_vars, x_var) {
  df |> 
    group_by(pick({{ group_vars }})) |> 
    summarize(
      n_miss = sum(is.na({{ x_var }})),
      .groups = "drop"
  )
}

flights |> 
  count_missing(c(year, month, day), dep_time)
#> # A tibble: 365 × 4
#>    year month   day n_miss
#>   <int> <int> <int>  <int>
#> 1  2013     1     1      4
#> 2  2013     1     2      8
#> 3  2013     1     3     10
#> 4  2013     1     4      6
#> 5  2013     1     5      3
#> 6  2013     1     6      1
#> # … with 359 more rows

另一个方便的使用pick() 的方法是制作一个计数的二维表格。在这里,我们使用 rowscolumns 中的所有变量进行计数,然后使用pivot_wider() 将计数重新排列为网格:

# https://twitter.com/pollicipes/status/1571606508944719876
count_wide <- function(data, rows, cols) {
  data |> 
    count(pick(c({{ rows }}, {{ cols }}))) |> 
    pivot_wider(
      names_from = {{ cols }}, 
      values_from = n,
      names_sort = TRUE,
      values_fill = 0
    )
}

diamonds |> count_wide(c(clarity, color), cut)
#> # A tibble: 56 × 7
#>   clarity color  Fair  Good `Very Good` Premium Ideal
#>   <ord>   <ord> <int> <int>       <int>   <int> <int>
#> 1 I1      D         4     8           5      12    13
#> 2 I1      E         9    23          22      30    18
#> 3 I1      F        35    19          13      34    42
#> 4 I1      G        53    19          16      46    16
#> 5 I1      H        52    14          12      46    38
#> 6 I1      I        34     9           8      24    17
#> # … with 50 more rows

虽然我们的例子主要集中在 dplyr 上,整洁评估也支持 tidyr,如果您查看pivot_wider() 文档,您会发现 names_from 使用整洁选择。

练习

  1. 使用来自 nycflights13 的数据集,编写一个函数,该函数能够:

    1. 找到所有被取消(即is.na(arr_time))或延误超过一小时的航班:

      flights |> filter_severe()
      
    2. 计算取消航班的数量和延误超过一小时的航班数量:

      flights |> group_by(dest) |> summarize_severe()
      
    3. 找到所有因用户提供的超过一定小时数而取消或延误的航班:

      flights |> filter_severe(hours = 2)
      
    4. 总结天气情况,计算用户提供变量的最小值、平均值和最大值:

      weather |> summarize_weather(temp)
      
    5. 将使用时钟时间的用户提供的变量(例如dep_timearr_time等)转换为十进制时间(即小时 + [分钟 / 60]):

      weather |> standardize_time(sched_dep_time)
      
  2. 对于以下每个函数,请列出所有使用整洁评估的参数,并描述它们是否使用数据屏蔽或整洁选择:distinct()count()group_by()rename_with()slice_min()slice_sample()

  3. 泛化以下函数,以便您可以提供任意数量的变量来计数:

    count_prop <- function(df, var, sort = FALSE) {
      df |>
        count({{ var }}, sort = sort) |>
        mutate(prop = n / sum(n))
    }
    

绘图函数

您可能不想返回数据框,而是希望返回一个图。幸运的是,您可以使用 ggplot2 的相同技术,因为aes() 是一个数据屏蔽函数。例如,想象一下你正在制作许多直方图:

diamonds |> 
  ggplot(aes(x = carat)) +
  geom_histogram(binwidth = 0.1)

diamonds |> 
  ggplot(aes(x = carat)) +
  geom_histogram(binwidth = 0.05)

如果您可以将这些内容封装到直方图函数中会很好吗?一旦您知道aes() 是一个数据屏蔽函数,并且您需要接受:

histogram <- function(df, var, binwidth = NULL) {
  df |> 
    ggplot(aes(x = {{ var }})) + 
    geom_histogram(binwidth = binwidth)
}

diamonds |> histogram(carat, 0.1)

一幅显示钻石克拉数从 0 到 5 范围内的直方图,显示出峰值位于 0 到 1 克拉之间的单峰右偏分布。

请注意,histogram() 返回一个 ggplot2 绘图,这意味着您仍然可以添加组件(如果需要的话)。只需记住从 |> 切换到 +

diamonds |> 
  histogram(carat, 0.1) +
  labs(x = "Size (in carats)", y = "Number of diamonds")

更多变量

将更多变量简单地添加到混合中也很简单。例如,也许您想要通过叠加平滑线和直线来轻松地检视数据集是否线性:

# https://twitter.com/tyler_js_smith/status/1574377116988104704
linearity_check <- function(df, x, y) {
  df |>
    ggplot(aes(x = {{ x }}, y = {{ y }})) +
    geom_point() +
    geom_smooth(method = "loess", formula = y ~ x, color = "red", se = FALSE) +
    geom_smooth(method = "lm", formula = y ~ x, color = "blue", se = FALSE) 
}

starwars |> 
  filter(mass < 1000) |> 
  linearity_check(mass, height)

星球大战角色的身高与体重的散点图,显示正相关关系。红色绘制了关系的平滑曲线,蓝色绘制了最佳拟合线。

或者您可能希望针对非常大的数据集,其中超过绘图是一个问题,寻找彩色散点图的替代方案:

# https://twitter.com/ppaxisa/status/1574398423175921665
hex_plot <- function(df, x, y, z, bins = 20, fun = "mean") {
  df |> 
    ggplot(aes(x = {{ x }}, y = {{ y }}, z = {{ z }})) + 
    stat_summary_hex(
      aes(color = after_scale(fill)), # make border same color as fill
      bins = bins, 
      fun = fun,
    )
}

diamonds |> hex_plot(carat, price, depth)

钻石价格与克拉的六边形图表,显示正相关关系。少于 2 克拉的钻石比大于 2 克拉的钻石多。

与其他 Tidyverse 包结合使用

一些最有用的辅助工具将少量数据操作与 ggplot2 结合起来。例如,您可能希望做一个垂直条形图,其中使用 fct_infreq() 自动按频率排序条形。由于条形图是垂直的,我们还需要反转通常的顺序以便将最高值放在顶部:

sorted_bars <- function(df, var) {
  df |> 
    mutate({{ var }} := fct_rev(fct_infreq({{ var }})))  |>
    ggplot(aes(y = {{ var }})) +
    geom_bar()
}

diamonds |> sorted_bars(clarity)

钻石澄清度的条形图,其中澄清度在 y 轴上,计数在 x 轴上,并且按频率排序:SI1,VS2,SI2,VS1,VVS2,VVS1,IF,I1。

我们必须在这里使用一个新的运算符,:=,因为我们根据用户提供的数据生成变量名。变量名位于 = 的左边,但是 R 的语法除了单个字面名称之外,不允许在 = 的左边放置任何东西。为了解决这个问题,我们使用特殊的运算符 :=,tidy evaluation 将其视为与 = 相同的方式处理。

或者你可能想要为数据的子集轻松绘制条形图:

conditional_bars <- function(df, condition, var) {
  df |> 
    filter({{ condition }}) |> 
    ggplot(aes(x = {{ var }})) + 
    geom_bar()
}

diamonds |> conditional_bars(cut == "Good", clarity)

钻石澄清度的条形图。最常见的是 SI1,然后是 SI2,然后是 VS2,然后是 VS1,然后是 VVS2,然后是 VVS1,然后是 I1,最后是 IF。

您还可以发挥创意,并以其他方式显示数据汇总信息。您可以在 https://oreil.ly/MV4kQ 找到一个很酷的应用程序;它使用轴标签显示最高值。随着您对 ggplot2 的了解增加,您的函数的功能也将不断增强。

我们将以一个更复杂的情况结束:为创建的图表标记。

标签

还记得我们之前展示给你的直方图函数吗?

histogram <- function(df, var, binwidth = NULL) {
  df |> 
    ggplot(aes(x = {{ var }})) + 
    geom_histogram(binwidth = binwidth)
}

如果我们能够用变量和使用的 bin 宽度标记输出就太好了?为了做到这一点,我们需要深入了解 tidy evaluation 并使用 tidyverse 中尚未讨论的包中的函数:rlang。rlang 是一个低级别的包,因为它实现了 tidy evaluation(以及许多其他有用的工具),所以几乎每个 tidyverse 中的其他包都在使用它。

要解决标签问题,我们可以使用rlang::englue()。它的工作方式类似于str_glue(),因此任何包含在{ }中的值都将插入到字符串中。但它还理解{{ }},它会自动插入适当的变量名:

histogram <- function(df, var, binwidth) {
  label <- rlang::englue("A histogram of {{var}} with binwidth {binwidth}")

  df |> 
    ggplot(aes(x = {{ var }})) + 
    geom_histogram(binwidth = binwidth) + 
    labs(title = label)
}

diamonds |> histogram(carat, 0.1)

0 到 5 克拉钻石的直方图。分布是单峰的,右偏,峰值在 0 到 1 克拉之间。

您可以在希望在 ggplot2 绘图中提供字符串的任何其他地方使用相同的方法。

练习

通过逐步实现以下每个步骤来构建丰富的绘图函数:

  1. 给定数据集和xy变量,绘制散点图。

  2. 添加最佳拟合线(即无标准误的线性模型)。

  3. 添加标题。

样式

R 不关心您的函数或参数叫什么,但是对于人类来说,名称对于理解很重要。理想情况下,函数的名称应该简短而清楚地描述函数的作用。这很难!但是清晰比简短更重要,因为 RStudio 的自动完成使得输入长名称变得容易。

一般来说,函数名应该是动词,参数应该是名词。有些例外情况:如果函数计算一个众所周知的名词,则mean()compute_mean()更好,或者访问对象的某些属性(即coef()get_coefficients()更好)。请自行判断,如果后来发现更好的名称,不要害怕重命名函数。

# Too short
f()

# Not a verb, or descriptive
my_awesome_function()

# Long, but clear
impute_missing()
collapse_years()

R 对函数中的空白使用方式并不关心,但是未来的读者会关心。继续遵循来自第四章的规则。此外,function() 应始终后跟大括号({}),内容应缩进两个额外的空格。这样可以通过浏览左边缘更容易地看到代码的层次结构。

# Missing extra two spaces
density <- function(color, facets, binwidth = 0.1) {
diamonds |> 
  ggplot(aes(x = carat, y = after_stat(density), color = {{ color }})) +
  geom_freqpoly(binwidth = binwidth) +
  facet_wrap(vars({{ facets }}))
}

# Pipe indented incorrectly
density <- function(color, facets, binwidth = 0.1) {
  diamonds |> 
  ggplot(aes(x = carat, y = after_stat(density), color = {{ color }})) +
  geom_freqpoly(binwidth = binwidth) +
  facet_wrap(vars({{ facets }}))
}

正如您所见,我们建议在{{ }}内部放置额外的空格。这样很明显,表示发生了一些不同寻常的事情。

练习

  1. 阅读以下两个函数的源代码,弄清它们的作用,然后为它们想出更好的名称:

    f1 <- function(string, prefix) {
      str_sub(string, 1, str_length(prefix)) == prefix
    }
    
    f3 <- function(x, y) {
      rep(y, length.out = length(x))
    }
    
  2. 取一个你最近编写的函数,并花五分钟为它及其参数想一个更好的名称。

  3. 为什么norm_r()norm_d()等比rnorm()dnorm()更好?为相反的情况辩护。如何使名称更清晰?

摘要

在本章中,您学习了如何为三种有用的情景编写函数:创建向量、创建数据框或创建图表。在此过程中,您看到了许多例子,这些例子理想情况下应该激发您的创造力,并为您的分析代码提供了一些想法。

我们只向您展示了开始使用函数的最基本内容,还有很多东西等待学习。要了解更多信息,请参考以下几个地方:

在接下来的章节中,我们将深入探讨迭代,为您提供进一步减少代码重复的工具。

第二十六章:迭代

简介

在本章中,您将学习迭代工具,重复对不同对象执行相同操作。在 R 中,迭代通常看起来与其他编程语言大不相同,因为其中很多都是隐含的并且我们可以免费获取。例如,如果您想在 R 中将数值向量 x 加倍,您只需写 2 * x。而在大多数其他语言中,您需要使用某种循环显式地将 x 的每个元素加倍。

本书已经为您提供了一些小而强大的工具,用于对多个“事物”执行相同操作:

现在是时候学习一些更通用的工具了,通常称为函数式编程工具,因为它们围绕接受其他函数作为输入的函数构建。学习函数式编程很容易偏向抽象,但在本章中,我们将通过专注于三个常见任务来保持具体性:修改多个列、读取多个文件和保存多个对象。

先决条件

在本章中,我们将专注于 tidyverse 的核心成员 dplyr 和 purrr 提供的工具。您之前见过 dplyr,但是purrr是新的。在本章中,我们只会使用 purrr 的一些函数,但随着您提高编程技能,它是一个探索的好工具包:

library(tidyverse)

修改多个列

假设您有一个简单的 tibble,并且您想计算每列的观察数量并计算中位数:

df <- tibble(
  a = rnorm(10),
  b = rnorm(10),
  c = rnorm(10),
  d = rnorm(10)
)

你可以通过复制粘贴来完成:

df |> summarize(
  n = n(),
  a = median(a),
  b = median(b),
  c = median(c),
  d = median(d),
)
#> # A tibble: 1 × 5
#>       n      a      b       c     d
#>   <int>  <dbl>  <dbl>   <dbl> <dbl>
#> 1    10 -0.246 -0.287 -0.0567 0.144

这违反了我们的经验法则,即不要复制粘贴超过两次,您可以想象,如果您有数十甚至数百列,这将变得很乏味。相反,您可以使用 across()

df |> summarize(
  n = n(),
  across(a:d, median),
)
#> # A tibble: 1 × 5
#>       n      a      b       c     d
#>   <int>  <dbl>  <dbl>   <dbl> <dbl>
#> 1    10 -0.246 -0.287 -0.0567 0.144

across() 有三个特别重要的参数,我们将在以下部分详细讨论。每次使用 across() 时,您都会使用前两个参数:第一个参数 .cols 指定要迭代的列,第二个参数 .fns 指定要对每列执行的操作。当您在 mutate() 中使用 across() 时,使用 .names 参数能够对输出列的名称进行额外控制,这尤其重要。我们还将讨论两个重要的变体,if_any()if_all(),它们与 filter() 一起使用。

使用 .cols 选择列

across() 的第一个参数 .cols 选择要转换的列。这使用与 select() 相同的规范,所以您可以使用诸如 starts_with()ends_with() 的函数根据列名选择列。

有两种额外的选择技术特别适用于across()everything()where()everything() 很简单:它选择每个(非分组)列:

df <- tibble(
  grp = sample(2, 10, replace = TRUE),
  a = rnorm(10),
  b = rnorm(10),
  c = rnorm(10),
  d = rnorm(10)
)

df |> 
  group_by(grp) |> 
  summarize(across(everything(), median))
#> # A tibble: 2 × 5
#>     grp       a       b     c     d
#>   <int>   <dbl>   <dbl> <dbl> <dbl>
#> 1     1 -0.0935 -0.0163 0.363 0.364
#> 2     2  0.312  -0.0576 0.208 0.565

注意分组列(此处为 grp)不包括在 across() 中,因为它们会被 summarize() 自动保留。

where() 允许您根据列的类型进行选择:

where(is.numeric)

选择所有数值列。

where(is.character)

选择所有字符串列。

where(is.Date)

选择所有日期列。

where(is.POSIXct)

选择所有日期时间列。

where(is.logical)

选择所有逻辑列。

就像其他选择器一样,您可以将它们与布尔代数结合使用。例如,!where(is.numeric) 选择所有非数值列,而 starts_with("a") & where(is.logical) 选择所有名称以“a”开头的逻辑列。

调用单个函数

across() 的第二个参数定义了每一列将如何转换。在简单的情况下,如上所示,这将是一个单一的现有函数。这是 R 的一个非常特殊的功能:我们将一个函数(medianmeanstr_flatten,…)传递给另一个函数(across)。

需要注意的是,我们将这个函数传递给across(),所以across() 可以调用它;我们不是自己调用它。这意味着函数名后面不应跟着()。如果忘记了,会收到一个错误:

df |> 
  group_by(grp) |> 
  summarize(across(everything(), median()))
#> Error in `summarize()`:
#> ℹ In argument: `across(everything(), median())`.
#> Caused by error in `is.factor()`:
#> ! argument "x" is missing, with no default

这个错误的原因是您在没有输入的情况下调用了函数,例如:

median()
#> Error in is.factor(x): argument "x" is missing, with no default

调用多个函数

在更复杂的情况下,您可能需要提供额外的参数或执行多个转换。让我们用一个简单的例子来激发这个问题:如果我们的数据中有一些缺失值会发生什么?median() 会传播这些缺失值,给我们一个次优的输出:

rnorm_na <- function(n, n_na, mean = 0, sd = 1) {
  sample(c(rnorm(n - n_na, mean = mean, sd = sd), rep(NA, n_na)))
}

df_miss <- tibble(
  a = rnorm_na(5, 1),
  b = rnorm_na(5, 1),
  c = rnorm_na(5, 2),
  d = rnorm(5)
)
df_miss |> 
  summarize(
    across(a:d, median),
    n = n()
  )
#> # A tibble: 1 × 5
#>       a     b     c     d     n
#>   <dbl> <dbl> <dbl> <dbl> <int>
#> 1    NA    NA    NA  1.15     5

如果我们能够将na.rm = TRUE传递给median()来删除这些缺失值就好了。为了做到这一点,我们需要创建一个新的函数,该函数使用所需的参数调用median()而不是直接调用它:

df_miss |> 
  summarize(
    across(a:d, function(x) median(x, na.rm = TRUE)),
    n = n()
  )
#> # A tibble: 1 × 5
#>       a     b      c     d     n
#>   <dbl> <dbl>  <dbl> <dbl> <int>
#> 1 0.139 -1.11 -0.387  1.15     5

这有点啰嗦,所以 R 提供了一个方便的快捷方式:对于这种一次性(或匿名)¹函数,您可以用\代替function:²

df_miss |> 
  summarize(
    across(a:d, \(x) median(x, na.rm = TRUE)),
    n = n()
  )

无论哪种情况,across() 实际上会扩展为以下代码:

df_miss |> 
  summarize(
    a = median(a, na.rm = TRUE),
    b = median(b, na.rm = TRUE),
    c = median(c, na.rm = TRUE),
    d = median(d, na.rm = TRUE),
    n = n()
  )

当我们从median()中移除缺失值时,了解被移除的值的数量是很有用的。我们可以通过向across()提供两个函数来实现:一个用于计算中位数,另一个用于计算缺失值的数量。您可以使用命名列表.fns来提供多个函数:

df_miss |> 
  summarize(
    across(a:d, list(
      median = \(x) median(x, na.rm = TRUE),
      n_miss = \(x) sum(is.na(x))
    )),
    n = n()
  )
#> # A tibble: 1 × 9
#>   a_median a_n_miss b_median b_n_miss c_median c_n_miss d_median d_n_miss
#>      <dbl>    <int>    <dbl>    <int>    <dbl>    <int>    <dbl>    <int>
#> 1    0.139        1    -1.11        1   -0.387        2     1.15        0
#> # … with 1 more variable: n <int>

如果您仔细观察,您可能会直觉到列名是使用一个 glue 规范(“str_glue()”)命名的,例如 {.col}_{.fn},其中.col是原始列的名称,.fn是函数的名称。这不是巧合!正如您将在下一节中了解到的那样,您可以使用.names参数来提供自己的 glue 规范。

列名

across() 的结果根据.names参数中提供的规范命名。如果需要,我们可以指定自己的规范,使函数名首先出现:³

df_miss |> 
  summarize(
    across(
      a:d,
      list(
        median = \(x) median(x, na.rm = TRUE),
        n_miss = \(x) sum(is.na(x))
      ),
      .names = "{.fn}_{.col}"
    ),
    n = n(),
  )
#> # A tibble: 1 × 9
#>   median_a n_miss_a median_b n_miss_b median_c n_miss_c median_d n_miss_d
#>      <dbl>    <int>    <dbl>    <int>    <dbl>    <int>    <dbl>    <int>
#> 1    0.139        1    -1.11        1   -0.387        2     1.15        0
#> # … with 1 more variable: n <int>

当您在mutate()中使用across()时,.names参数尤为重要。默认情况下,across()的输出与输入具有相同的名称。这意味着在mutate()中使用的across()将替换现有列。例如,在这里我们使用coalesce()NA替换为0

df_miss |> 
  mutate(
    across(a:d, \(x) coalesce(x, 0))
  )
#> # A tibble: 5 × 4
#>        a      b      c     d
#>    <dbl>  <dbl>  <dbl> <dbl>
#> 1  0.434 -1.25   0     1.60 
#> 2  0     -1.43  -0.297 0.776
#> 3 -0.156 -0.980  0     1.15 
#> 4 -2.61  -0.683 -0.785 2.13 
#> 5  1.11   0     -0.387 0.704

如果您想创建新的列,可以使用.names参数为输出命名:

df_miss |> 
  mutate(
    across(a:d, \(x) abs(x), .names = "{.col}_abs")
  )
#> # A tibble: 5 × 8
#>        a      b      c     d  a_abs  b_abs  c_abs d_abs
#>    <dbl>  <dbl>  <dbl> <dbl>  <dbl>  <dbl>  <dbl> <dbl>
#> 1  0.434 -1.25  NA     1.60   0.434  1.25  NA     1.60 
#> 2 NA     -1.43  -0.297 0.776 NA      1.43   0.297 0.776
#> 3 -0.156 -0.980 NA     1.15   0.156  0.980 NA     1.15 
#> 4 -2.61  -0.683 -0.785 2.13   2.61   0.683  0.785 2.13 
#> 5  1.11  NA     -0.387 0.704  1.11  NA      0.387 0.704

过滤

across()非常适合与summarize()mutate()配对使用,但与filter()一起使用时则比较尴尬,因为通常需要使用|&组合多个条件。显然,across()可以帮助创建多个逻辑列,但接下来呢?因此,dplyr 提供了两个变体的across()称为if_any()if_all()

# same as df_miss |> filter(is.na(a) | is.na(b) | is.na(c) | is.na(d))
df_miss |> filter(if_any(a:d, is.na))
#> # A tibble: 4 × 4
#>        a      b      c     d
#>    <dbl>  <dbl>  <dbl> <dbl>
#> 1  0.434 -1.25  NA     1.60 
#> 2 NA     -1.43  -0.297 0.776
#> 3 -0.156 -0.980 NA     1.15 
#> 4  1.11  NA     -0.387 0.704

# same as df_miss |> filter(is.na(a) & is.na(b) & is.na(c) & is.na(d))
df_miss |> filter(if_all(a:d, is.na))
#> # A tibble: 0 × 4
#> # … with 4 variables: a <dbl>, b <dbl>, c <dbl>, d <dbl>

across()中的函数

across()在编程中特别有用,因为它允许您操作多个列。例如,Jacob Scott使用了一个小助手,该助手将一系列 lubridate 函数包装起来,以将所有日期列扩展为年、月和日列:

expand_dates <- function(df) {
  df |> 
    mutate(
      across(where(is.Date), list(year = year, month = month, day = mday))
    )
}

df_date <- tibble(
  name = c("Amy", "Bob"),
  date = ymd(c("2009-08-03", "2010-01-16"))
)

df_date |> 
  expand_dates()
#> # A tibble: 2 × 5
#>   name  date       date_year date_month date_day
#>   <chr> <date>         <dbl>      <dbl>    <int>
#> 1 Amy   2009-08-03      2009          8        3
#> 2 Bob   2010-01-16      2010          1       16

across()还可以轻松地在单个参数中提供多列,因为第一个参数使用 tidy-select;您只需记住在该参数周围加上括号,正如我们在“何时使用括号?”中讨论的那样。例如,此函数将默认计算数值列的均值。但通过提供第二个参数,您可以选择仅总结所选列:

summarize_means <- function(df, summary_vars = where(is.numeric)) {
  df |> 
    summarize(
      across({{ summary_vars }}, \(x) mean(x, na.rm = TRUE)),
      n = n()
    )
}
diamonds |> 
  group_by(cut) |> 
  summarize_means()
#> # A tibble: 5 × 9
#>   cut       carat depth table price     x     y     z     n
#>   <ord>     <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <int>
#> 1 Fair      1.05   64.0  59.1 4359\.  6.25  6.18  3.98  1610
#> 2 Good      0.849  62.4  58.7 3929\.  5.84  5.85  3.64  4906
#> 3 Very Good 0.806  61.8  58.0 3982\.  5.74  5.77  3.56 12082
#> 4 Premium   0.892  61.3  58.7 4584\.  5.97  5.94  3.65 13791
#> 5 Ideal     0.703  61.7  56.0 3458\.  5.51  5.52  3.40 21551

diamonds |> 
  group_by(cut) |> 
  summarize_means(c(carat, x:z))
#> # A tibble: 5 × 6
#>   cut       carat     x     y     z     n
#>   <ord>     <dbl> <dbl> <dbl> <dbl> <int>
#> 1 Fair      1.05   6.25  6.18  3.98  1610
#> 2 Good      0.849  5.84  5.85  3.64  4906
#> 3 Very Good 0.806  5.74  5.77  3.56 12082
#> 4 Premium   0.892  5.97  5.94  3.65 13791
#> 5 Ideal     0.703  5.51  5.52  3.40 21551

与 pivot_longer()相比

在继续之前,值得指出across()pivot_longer()(“数据扩展”)之间有一个有趣的关联。在许多情况下,您通过首先将数据进行透视,然后按组而不是按列执行操作,来执行相同的计算。例如,看看这个多功能摘要:

df |> 
  summarize(across(a:d, list(median = median, mean = mean)))
#> # A tibble: 1 × 8
#>   a_median a_mean b_median b_mean c_median c_mean d_median d_mean
#>      <dbl>  <dbl>    <dbl>  <dbl>    <dbl>  <dbl>    <dbl>  <dbl>
#> 1   0.0380  0.205  -0.0163 0.0910    0.260 0.0716    0.540  0.508

我们可以通过更长的透视来计算相同的值,然后进行总结:

long <- df |> 
  pivot_longer(a:d) |> 
  group_by(name) |> 
  summarize(
    median = median(value),
    mean = mean(value)
  )
long
#> # A tibble: 4 × 3
#>   name   median   mean
#>   <chr>   <dbl>  <dbl>
#> 1 a      0.0380 0.205 
#> 2 b     -0.0163 0.0910
#> 3 c      0.260  0.0716
#> 4 d      0.540  0.508

如果您希望与across()相同的结构,您可以再次进行数据透视:

long |> 
  pivot_wider(
    names_from = name,
    values_from = c(median, mean),
    names_vary = "slowest",
    names_glue = "{name}_{.value}"
  )
#> # A tibble: 1 × 8
#>   a_median a_mean b_median b_mean c_median c_mean d_median d_mean
#>      <dbl>  <dbl>    <dbl>  <dbl>    <dbl>  <dbl>    <dbl>  <dbl>
#> 1   0.0380  0.205  -0.0163 0.0910    0.260 0.0716    0.540  0.508

这是一个有用的技术,因为有时您会遇到一个问题,目前使用across()无法解决:当您有一组列,想要同时进行计算时。例如,假设我们的数据框同时包含值和权重,并且我们想计算加权平均值:

df_paired <- tibble(
  a_val = rnorm(10),
  a_wts = runif(10),
  b_val = rnorm(10),
  b_wts = runif(10),
  c_val = rnorm(10),
  c_wts = runif(10),
  d_val = rnorm(10),
  d_wts = runif(10)
)

目前没有办法使用across()来做到这一点,⁴但使用pivot_longer()却相对简单:

df_long <- df_paired |> 
  pivot_longer(
    everything(), 
    names_to = c("group", ".value"), 
    names_sep = "_"
  )
df_long
#> # A tibble: 40 × 3
#>   group    val   wts
#>   <chr>  <dbl> <dbl>
#> 1 a      0.715 0.518
#> 2 b     -0.709 0.691
#> 3 c      0.718 0.216
#> 4 d     -0.217 0.733
#> 5 a     -1.09  0.979
#> 6 b     -0.209 0.675
#> # … with 34 more rows

df_long |> 
  group_by(group) |> 
  summarize(mean = weighted.mean(val, wts))
#> # A tibble: 4 × 2
#>   group    mean
#>   <chr>   <dbl>
#> 1 a      0.126 
#> 2 b     -0.0704
#> 3 c     -0.360 
#> 4 d     -0.248

如果需要,您可以使用pivot_wider()将其转换回原始形式。

练习

  1. 通过以下方式练习您的across()技能:

    1. 计算palmerpenguins::penguins中每一列的唯一值数量。

    2. 计算mtcars中每一列的平均值。

    3. 通过cutclaritycolordiamonds进行分组,并计算每个数值列的观测数和平均值。

  2. 如果在across()中使用一个函数列表,但不给它们命名,会发生什么?输出会如何命名?

  3. 调整expand_dates()函数以在展开日期列后自动移除它们。您需要使用哪些参数吗?

  4. 解释这个函数中每个步骤的管道是做什么的。我们利用了where()的哪个特殊特性?

    show_missing <- function(df, group_vars, summary_vars = everything()) {
      df |> 
        group_by(pick({{ group_vars }})) |> 
        summarize(
          across({{ summary_vars }}, \(x) sum(is.na(x))),
          .groups = "drop"
        ) |>
        select(where(\(x) any(x > 0)))
    }
    nycflights13::flights |> show_missing(c(year, month, day))
    

读取多个文件

在上一节中,您学会了如何使用dplyr::across()在多列上重复转换。在本节中,您将学习如何使用purrr::map()来对目录中的每个文件执行某些操作。让我们先看一些动机:想象一下,您有一个充满 Excel 电子表格的目录⁵,您想要读取。您可以通过复制粘贴来完成:

data2019 <- readxl::read_excel("data/y2019.xlsx")
data2020 <- readxl::read_excel("data/y2020.xlsx")
data2021 <- readxl::read_excel("data/y2021.xlsx")
data2022 <- readxl::read_excel("data/y2022.xlsx")

然后使用dplyr::bind_rows()将它们全部合并:

data <- bind_rows(data2019, data2020, data2021, data2022)

你可以想象,如果有数百个文件而不仅仅是四个,这将很快变得乏味。接下来的章节将向你展示如何自动化这类任务。有三个基本步骤:使用list.files()来列出目录中的所有文件,然后使用purrr::map()将它们中的每一个读入列表,最后使用purrr::list_rbind()将它们合并成一个单一的数据框。然后我们将讨论如何处理日益多样化的情况,即你不能对每个文件都采取相同的操作。

列出目录中的文件

正如其名,list.files()会列出目录中的文件。你几乎总是会使用三个参数:

  • 第一个参数path是要查找的目录。

  • pattern是用于过滤文件名的正则表达式。最常见的模式之一是类似于[.]xlsx$[.]csv$,用于查找具有特定扩展名的所有文件。

  • full.names决定了是否包括目录名在输出中。你几乎总是希望这个参数为TRUE

为了使我们的示例更具体化,本书包含一个包含来自 gapminder 包的数据的文件夹,其中有 12 个 Excel 电子表格文件。每个文件包含 142 个国家一年的数据。我们可以使用适当的list.files()调用列出它们所有:

paths <- list.files("data/gapminder", pattern = "[.]xlsx$", full.names = TRUE)
paths
#>  [1] "data/gapminder/1952.xlsx" "data/gapminder/1957.xlsx"
#>  [3] "data/gapminder/1962.xlsx" "data/gapminder/1967.xlsx"
#>  [5] "data/gapminder/1972.xlsx" "data/gapminder/1977.xlsx"
#>  [7] "data/gapminder/1982.xlsx" "data/gapminder/1987.xlsx"
#>  [9] "data/gapminder/1992.xlsx" "data/gapminder/1997.xlsx"
#> [11] "data/gapminder/2002.xlsx" "data/gapminder/2007.xlsx"

列表

现在我们有了这 12 个路径,我们可以调用read_excel() 12 次来获取 12 个数据框:

gapminder_1952 <- readxl::read_excel("data/gapminder/1952.xlsx")
gapminder_1957 <- readxl::read_excel("data/gapminder/1957.xlsx")
gapminder_1962 <- readxl::read_excel("data/gapminder/1962.xlsx")
 ...,
gapminder_2007 <- readxl::read_excel("data/gapminder/2007.xlsx")

但是,将每个工作表放入自己的变量中将使得稍后的几个步骤变得难以处理。相反,如果我们将它们放入一个单一对象中,那么它们将更容易处理。列表就是这项工作的完美工具:

files <- list(
  readxl::read_excel("data/gapminder/1952.xlsx"),
  readxl::read_excel("data/gapminder/1957.xlsx"),
  readxl::read_excel("data/gapminder/1962.xlsx"),
  ...,
  readxl::read_excel("data/gapminder/2007.xlsx")
)

现在你把这些数据框都放在一个列表里了,那么怎么取出其中一个呢?你可以使用files[[i]]来提取第i个元素:

files[[3]]
#> # A tibble: 142 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         32.0 10267083      853.
#> 2 Albania     Europe       64.8  1728137     2313.
#> 3 Algeria     Africa       48.3 11000948     2551.
#> 4 Angola      Africa       34    4826015     4269.
#> 5 Argentina   Americas     65.1 21283783     7133.
#> 6 Australia   Oceania      70.9 10794968    12217.
#> # … with 136 more rows

我们稍后会更详细地讨论[,见[“使用 $ 和 [[ 选择单个元素”。

purrr::map() 和 list_rbind()

“手动”收集这些数据框的代码基本上和逐个读取文件的代码一样乏味。幸运的是,我们可以使用purrr::map()更好地利用我们的paths向量。map()类似于across(),但不是对数据框中的每一列进行操作,而是对向量中的每一个元素进行操作。map(x, f)是以下代码的简写:

list(
  f(x[[1]]),
  f(x[[2]]),
  ...,
  f(x[[n]])
)

因此,我们可以使用map()来获得一个包含 12 个数据框的列表:

files <- map(paths, readxl::read_excel)
length(files)
#> [1] 12

files[[1]]
#> # A tibble: 142 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.
#> 2 Albania     Europe       55.2  1282697     1601.
#> 3 Algeria     Africa       43.1  9279525     2449.
#> 4 Angola      Africa       30.0  4232095     3521.
#> 5 Argentina   Americas     62.5 17876956     5911.
#> 6 Australia   Oceania      69.1  8691212    10040.
#> # … with 136 more rows

(这是另一种数据结构,使用str()显示时并不特别紧凑,因此您可能希望将其加载到 RStudio 中,并使用View()进行检查)。

现在我们可以使用purrr::list_rbind()将这些数据框的列表合并成一个单独的数据框:

list_rbind(files)
#> # A tibble: 1,704 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.
#> 2 Albania     Europe       55.2  1282697     1601.
#> 3 Algeria     Africa       43.1  9279525     2449.
#> 4 Angola      Africa       30.0  4232095     3521.
#> 5 Argentina   Americas     62.5 17876956     5911.
#> 6 Australia   Oceania      69.1  8691212    10040.
#> # … with 1,698 more rows

或者我们可以一次在管道中完成两个步骤:

paths |> 
  map(readxl::read_excel) |> 
  list_rbind()

如果我们想要传递额外的参数给read_excel()怎么办?我们使用与across()相同的技术。例如,使用n_max = 1查看数据的前几行通常是有用的:

paths |> 
  map(\(path) readxl::read_excel(path, n_max = 1)) |> 
  list_rbind()
#> # A tibble: 12 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.
#> 2 Afghanistan Asia         30.3  9240934      821.
#> 3 Afghanistan Asia         32.0 10267083      853.
#> 4 Afghanistan Asia         34.0 11537966      836.
#> 5 Afghanistan Asia         36.1 13079460      740.
#> 6 Afghanistan Asia         38.4 14880372      786.
#> # … with 6 more rows

这清楚地表明缺少了一些内容:没有year列,因为该值记录在路径中,而不是单独的文件中。我们将在下一步解决这个问题。

路径中的数据

有时文件的名称本身就是数据。在这个例子中,文件名包含年份,这在单独的文件中没有记录。为了将该列添加到最终的数据框中,我们需要做两件事。

首先,我们给路径向量命名。最简单的方法是使用set_names()函数,它可以使用函数。在这里,我们使用basename()从完整路径中提取文件名:

paths |> set_names(basename) 
#>                  1952.xlsx                  1957.xlsx 
#> "data/gapminder/1952.xlsx" "data/gapminder/1957.xlsx" 
#>                  1962.xlsx                  1967.xlsx 
#> "data/gapminder/1962.xlsx" "data/gapminder/1967.xlsx" 
#>                  1972.xlsx                  1977.xlsx 
#> "data/gapminder/1972.xlsx" "data/gapminder/1977.xlsx" 
#>                  1982.xlsx                  1987.xlsx 
#> "data/gapminder/1982.xlsx" "data/gapminder/1987.xlsx" 
#>                  1992.xlsx                  1997.xlsx 
#> "data/gapminder/1992.xlsx" "data/gapminder/1997.xlsx" 
#>                  2002.xlsx                  2007.xlsx 
#> "data/gapminder/2002.xlsx" "data/gapminder/2007.xlsx"

这些名称将自动通过所有的映射函数传递,因此数据框的列表将具有相同的名称:

files <- paths |> 
  set_names(basename) |> 
  map(readxl::read_excel)

这使得对map()的调用成为一种简写形式:

files <- list(
  "1952.xlsx" = readxl::read_excel("data/gapminder/1952.xlsx"),
  "1957.xlsx" = readxl::read_excel("data/gapminder/1957.xlsx"),
  "1962.xlsx" = readxl::read_excel("data/gapminder/1962.xlsx"),
  ...,
  "2007.xlsx" = readxl::read_excel("data/gapminder/2007.xlsx")
)

您还可以使用[[按名称提取元素:

files[["1962.xlsx"]]
#> # A tibble: 142 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         32.0 10267083      853.
#> 2 Albania     Europe       64.8  1728137     2313.
#> 3 Algeria     Africa       48.3 11000948     2551.
#> 4 Angola      Africa       34    4826015     4269.
#> 5 Argentina   Americas     65.1 21283783     7133.
#> 6 Australia   Oceania      70.9 10794968    12217.
#> # … with 136 more rows

然后我们使用names_to参数来告诉list_rbind()将名称保存到一个名为year的新列中,然后使用readr::parse_number()从字符串中提取数字:

paths |> 
  set_names(basename) |> 
  map(readxl::read_excel) |> 
  list_rbind(names_to = "year") |> 
  mutate(year = parse_number(year))
#> # A tibble: 1,704 × 6
#>    year country     continent lifeExp      pop gdpPercap
#>   <dbl> <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1  1952 Afghanistan Asia         28.8  8425333      779.
#> 2  1952 Albania     Europe       55.2  1282697     1601.
#> 3  1952 Algeria     Africa       43.1  9279525     2449.
#> 4  1952 Angola      Africa       30.0  4232095     3521.
#> 5  1952 Argentina   Americas     62.5 17876956     5911.
#> 6  1952 Australia   Oceania      69.1  8691212    10040.
#> # … with 1,698 more rows

在更复杂的情况下,目录名称中可能存储有其他变量,或者文件名包含多个数据位。在这种情况下,使用set_names()(不带任何参数)记录完整路径,然后使用tidyr::separate_wider_delim()等函数将它们转换为有用的列:

paths |> 
  set_names() |> 
  map(readxl::read_excel) |> 
  list_rbind(names_to = "year") |> 
  separate_wider_delim(year, delim = "/", names = c(NA, "dir", "file")) |> 
  separate_wider_delim(file, delim = ".", names = c("file", "ext"))
#> # A tibble: 1,704 × 8
#>   dir       file  ext   country     continent lifeExp      pop gdpPercap
#>   <chr>     <chr> <chr> <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 gapminder 1952  xlsx  Afghanistan Asia         28.8  8425333      779.
#> 2 gapminder 1952  xlsx  Albania     Europe       55.2  1282697     1601.
#> 3 gapminder 1952  xlsx  Algeria     Africa       43.1  9279525     2449.
#> 4 gapminder 1952  xlsx  Angola      Africa       30.0  4232095     3521.
#> 5 gapminder 1952  xlsx  Argentina   Americas     62.5 17876956     5911.
#> 6 gapminder 1952  xlsx  Australia   Oceania      69.1  8691212    10040.
#> # … with 1,698 more rows

保存你的工作

现在你已经完成了所有这些艰苦的工作,得到了一个整洁的数据框,现在是保存你的工作的好时机:

gapminder <- paths |> 
  set_names(basename) |> 
  map(readxl::read_excel) |> 
  list_rbind(names_to = "year") |> 
  mutate(year = parse_number(year))

write_csv(gapminder, "gapminder.csv")

现在当您将来再次遇到这个问题时,您可以读取一个单独的 CSV 文件。对于大型和更丰富的数据集,使用 Parquet 格式可能比.csv更好,如"Parquet 格式"中讨论的那样。

如果您正在一个项目中工作,我们建议称呼执行这种数据准备工作的文件为0-cleanup.R之类的名称。文件名中的0表示应在任何其他操作之前运行。

如果你的输入数据文件随时间变化,你可能考虑学习像targets这样的工具,以便将你的数据清理代码设置为在输入文件修改时自动重新运行。

许多简单的迭代

在这里,我们直接从磁盘加载数据,幸运地获得了一个整洁的数据集。在大多数情况下,你需要做一些额外的整理工作,你有两个基本选择:你可以用一个复杂的函数做一轮迭代,或者用简单的函数做多轮迭代。根据我们的经验,大多数人首先选择一次复杂的迭代,但你通常最好选择多次简单的迭代。

例如,假设你想读入一堆文件,过滤掉缺失值,进行数据透视,然后组合。解决问题的一种方法是编写一个接受文件并执行所有这些步骤的函数,然后只调用map()一次:

process_file <- function(path) {
  df <- read_csv(path)

  df |> 
    filter(!is.na(id)) |> 
    mutate(id = tolower(id)) |> 
    pivot_longer(jan:dec, names_to = "month")
}

paths |> 
  map(process_file) |> 
  list_rbind()

或者,你可以对每个文件执行process_file()的每一步:

paths |> 
  map(read_csv) |> 
  map(\(df) df |> filter(!is.na(id))) |> 
  map(\(df) df |> mutate(id = tolower(id))) |> 
  map(\(df) df |> pivot_longer(jan:dec, names_to = "month")) |> 
  list_rbind()

我们推荐这种方法,因为它可以防止你在处理第一个文件之前陷入固定思维。在整理和清理数据时,考虑所有数据,你更有可能进行全面思考,并获得更高质量的结果。

在这个特定的例子中,你可以进行另一种优化,即更早地将所有数据框架绑定在一起。然后你可以依赖于正常的 dplyr 行为:

paths |> 
  map(read_csv) |> 
  list_rbind() |> 
  filter(!is.na(id)) |> 
  mutate(id = tolower(id)) |> 
  pivot_longer(jan:dec, names_to = "month")

异构数据

不幸的是,有时从map()直接转到list_rbind()是不可能的,因为数据框架之间差异很大,list_rbind()可能会失败,或者生成一个没有用的数据框架。在这种情况下,最好还是从加载所有文件开始:

files <- paths |> 
  map(readxl::read_excel) 

然后,一个有用的策略是捕捉数据框架的结构,这样你就可以利用你的数据科学技能进行探索。一种方法是使用这个方便的df_types函数⁶,它返回一个 tibble,每列为一个行:

df_types <- function(df) {
  tibble(
    col_name = names(df), 
    col_type = map_chr(df, vctrs::vec_ptype_full),
    n_miss = map_int(df, \(x) sum(is.na(x)))
  )
}

df_types(gapminder)
#> # A tibble: 6 × 3
#>   col_name  col_type  n_miss
#>   <chr>     <chr>      <int>
#> 1 year      double         0
#> 2 country   character      0
#> 3 continent character      0
#> 4 lifeExp   double         0
#> 5 pop       double         0
#> 6 gdpPercap double         0

然后,你可以将此函数应用于所有文件,也许进行一些数据透视以便更容易看到差异的位置。例如,这使得验证我们一直在处理的 gapminder 电子表格是相当同质的变得容易:

files |> 
  map(df_types) |> 
  list_rbind(names_to = "file_name") |> 
  select(-n_miss) |> 
  pivot_wider(names_from = col_name, values_from = col_type)
#> # A tibble: 12 × 6
#>   file_name country   continent lifeExp pop    gdpPercap
#>   <chr>     <chr>     <chr>     <chr>   <chr>  <chr> 
#> 1 1952.xlsx character character double  double double 
#> 2 1957.xlsx character character double  double double 
#> 3 1962.xlsx character character double  double double 
#> 4 1967.xlsx character character double  double double 
#> 5 1972.xlsx character character double  double double 
#> 6 1977.xlsx character character double  double double 
#> # … with 6 more rows

如果文件具有异构格式,您可能需要在成功合并它们之前进行更多处理。不幸的是,我们现在要让您自己来解决这个问题,但您可能需要阅读关于map_if()map_at()的内容。map_if()允许您基于其值选择性地修改列表的元素;map_at()允许您基于其名称选择性地修改元素。

处理失败

有时候您的数据结构可能非常复杂,甚至无法使用单个命令读取所有文件。然后您会遇到map()的一个缺点:它要么全部成功,要么全部失败。map()要么成功读取目录中的所有文件,要么出现错误,一无所获。这很烦人:为什么一个失败会阻止您访问所有其他成功的文件呢?

幸运的是,purrr 提供了一个助手来解决这个问题:possibly()possibly()是一个称为函数操作符的东西:它接受一个函数并返回一个具有修改行为的函数。特别地,possibly()将一个会出错的函数改变成返回您指定的值的函数:

files <- paths |> 
  map(possibly(\(path) readxl::read_excel(path), NULL))

data <- files |> list_rbind()

这在这里特别有效,因为list_rbind()等许多 tidyverse 函数会自动忽略NULL

现在您已经可以轻松读取所有数据了,是时候着手解决难点了,弄清楚为什么某些文件加载失败以及如何处理。首先,获取失败的路径:

failed <- map_vec(files, is.null)
paths[failed]
#> character(0)

然后对每个失败再次调用导入函数,并找出出了什么问题。

保存多个输出

在前一节中,您学习了map(),它用于将多个文件读取到单个对象中。在本节中,我们将探讨相反的问题:如何将一个或多个 R 对象保存到一个或多个文件中?我们将使用三个示例来探讨这个挑战:

  • 将多个数据框保存到一个数据库中

  • 将多个数据框保存为多个.csv文件

  • 将多个图表保存为多个.png文件

写入数据库

有时候一次处理多个文件时,不可能一次将所有数据装入内存,也不能使用map(files, read_csv)。解决这个问题的一种方法是将数据加载到数据库中,这样你就可以使用 dbplyr 仅访问你需要的部分。

如果您很幸运,您正在使用的数据库包将提供一个方便的函数,它接受一个路径向量并将它们全部加载到数据库中。这在 duckdb 的duckdb_read_csv()中就是这种情况:

con <- DBI::dbConnect(duckdb::duckdb())
duckdb::duckdb_read_csv(con, "gapminder", paths)

这种方法在这里很有效,但我们没有 CSV 文件;相反,我们有 Excel 电子表格。因此,我们将不得不手工进行操作。学会手动操作还将帮助您处理一堆 CSV 文件和您正在使用的数据库没有可以加载它们全部的功能的情况。

我们需要首先创建一个将填充数据的表格。这样做的最简单方法是创建一个模板,一个虚拟的数据框,包含我们想要的所有列,但只有部分数据。对于 gapminder 数据,我们可以通过读取一个文件并将年份添加到其中来制作该模板:

template <- readxl::read_excel(paths[[1]])
template$year <- 1952
template
#> # A tibble: 142 × 6
#>   country     continent lifeExp      pop gdpPercap  year
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl> <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779\.  1952
#> 2 Albania     Europe       55.2  1282697     1601\.  1952
#> 3 Algeria     Africa       43.1  9279525     2449\.  1952
#> 4 Angola      Africa       30.0  4232095     3521\.  1952
#> 5 Argentina   Americas     62.5 17876956     5911\.  1952
#> 6 Australia   Oceania      69.1  8691212    10040\.  1952
#> # … with 136 more rows

现在我们可以连接到数据库并使用DBI::dbCreateTable()将我们的模板转换为数据库表:

con <- DBI::dbConnect(duckdb::duckdb())
DBI::dbCreateTable(con, "gapminder", template)

dbCreateTable()不使用template中的数据,只使用变量名和类型。因此,如果现在检查gapminder表,您将看到它是空的,但具有我们需要的变量和我们期望的类型:

con |> tbl("gapminder")
#> # Source:   table<gapminder> [0 x 6]
#> # Database: DuckDB 0.6.1 [root@Darwin 22.3.0:R 4.2.1/:memory:]
#> # … with 6 variables: country <chr>, continent <chr>, lifeExp <dbl>,
#> #   pop <dbl>, gdpPercap <dbl>, year <dbl>

接下来,我们需要一个函数,它接受一个文件路径,将其读入 R,并将结果添加到gapminder表中。我们可以通过结合read_excel()DBI::dbAppendTable()来实现这一点:

append_file <- function(path) {
  df <- readxl::read_excel(path)
  df$year <- parse_number(basename(path))

  DBI::dbAppendTable(con, "gapminder", df)
}

现在我们需要为paths的每个元素调用append_file()一次。使用map()肯定是可能的:

paths |> map(append_file)

但是我们不关心append_file()的输出,所以与其使用map(),使用walk()会稍微更好一些。walk()map()完全相同,但会丢弃输出:

paths |> walk(append_file)

现在我们可以看看我们的表中是否有所有数据:

con |> 
  tbl("gapminder") |> 
  count(year)
#> # Source:   SQL [?? x 2]
#> # Database: DuckDB 0.6.1 [root@Darwin 22.3.0:R 4.2.1/:memory:]
#>    year     n
#>   <dbl> <dbl>
#> 1  1952   142
#> 2  1957   142
#> 3  1962   142
#> 4  1967   142
#> 5  1972   142
#> 6  1977   142
#> # … with more rows

写入 CSV 文件

如果我们想为每个组编写多个 CSV 文件,同样的基本原则适用。让我们想象一下,我们想要取ggplot2::diamonds数据,并为每个clarity保存一个 CSV 文件。首先,我们需要制作这些单独的数据集。有许多方法可以做到这一点,但有一种方法我们特别喜欢:group_nest()

by_clarity <- diamonds |> 
  group_nest(clarity)

by_clarity
#> # A tibble: 8 × 2
#>   clarity               data
#>   <ord>   <list<tibble[,9]>>
#> 1 I1               [741 × 9]
#> 2 SI2            [9,194 × 9]
#> 3 SI1           [13,065 × 9]
#> 4 VS2           [12,258 × 9]
#> 5 VS1            [8,171 × 9]
#> 6 VVS2           [5,066 × 9]
#> # … with 2 more rows

这给了我们一个新的 tibble,有八行和两列。clarity是我们的分组变量,data是一个列表列,包含每个clarity唯一值的一个 tibble:

by_clarity$data[[1]]
#> # A tibble: 741 × 9
#>   carat cut       color depth table price     x     y     z
#>   <dbl> <ord>     <ord> <dbl> <dbl> <int> <dbl> <dbl> <dbl>
#> 1  0.32 Premium   E      60.9    58   345  4.38  4.42  2.68
#> 2  1.17 Very Good J      60.2    61  2774  6.83  6.9   4.13
#> 3  1.01 Premium   F      61.8    60  2781  6.39  6.36  3.94
#> 4  1.01 Fair      E      64.5    58  2788  6.29  6.21  4.03
#> 5  0.96 Ideal     F      60.7    55  2801  6.37  6.41  3.88
#> 6  1.04 Premium   G      62.2    58  2801  6.46  6.41  4 
#> # … with 735 more rows

在这里的同时,让我们创建一个列,它给出输出文件的名称,使用mutate()str_glue()

by_clarity <- by_clarity |> 
  mutate(path = str_glue("diamonds-{clarity}.csv"))

by_clarity
#> # A tibble: 8 × 3
#>   clarity               data path 
#>   <ord>   <list<tibble[,9]>> <glue> 
#> 1 I1               [741 × 9] diamonds-I1.csv 
#> 2 SI2            [9,194 × 9] diamonds-SI2.csv 
#> 3 SI1           [13,065 × 9] diamonds-SI1.csv 
#> 4 VS2           [12,258 × 9] diamonds-VS2.csv 
#> 5 VS1            [8,171 × 9] diamonds-VS1.csv 
#> 6 VVS2           [5,066 × 9] diamonds-VVS2.csv
#> # … with 2 more rows

因此,如果我们要手动保存这些数据框,我们可以编写如下内容:

write_csv(by_clarity$data[[1]], by_clarity$path[[1]])
write_csv(by_clarity$data[[2]], by_clarity$path[[2]])
write_csv(by_clarity$data[[3]], by_clarity$path[[3]])
...
write_csv(by_clarity$by_clarity[[8]], by_clarity$path[[8]])

这与我们之前使用map()的用法有些不同,因为有两个参数在变化,而不只是一个。这意味着我们需要一个新的函数:map2(),它同时变化第一个和第二个参数。因为我们再次不关心输出,所以我们想要walk2()而不是map2()。这给我们带来了:

walk2(by_clarity$data, by_clarity$path, write_csv)

保存图表

我们可以采用相同的基本方法来创建多个图表。首先让我们创建一个绘制所需图表的函数:

carat_histogram <- function(df) {
  ggplot(df, aes(x = carat)) + geom_histogram(binwidth = 0.1)  
}

carat_histogram(by_clarity$data[[1]])

来自 by_clarity 数据集中钻石克拉数的直方图,范围从 0 到 5 克拉。分布是单峰的,右偏,峰值约为 1 克拉。

现在我们可以使用map()来创建许多图表的列表⁷及其最终文件路径:

by_clarity <- by_clarity |> 
  mutate(
    plot = map(data, carat_histogram),
    path = str_glue("clarity-{clarity}.png")
  )

然后使用walk2()ggsave()来保存每个图表:

walk2(
  by_clarity$path,
  by_clarity$plot,
  \(path, plot) ggsave(path, plot, width = 6, height = 6)
)

这是一个简写:

ggsave(by_clarity$path[[1]], by_clarity$plot[[1]], width = 6, height = 6)
ggsave(by_clarity$path[[2]], by_clarity$plot[[2]], width = 6, height = 6)
ggsave(by_clarity$path[[3]], by_clarity$plot[[3]], width = 6, height = 6)
...
ggsave(by_clarity$path[[8]], by_clarity$plot[[8]], width = 6, height = 6)

总结

在本章中,你学习了如何使用显式迭代来解决数据科学中经常遇到的三个问题:操作多列、读取多个文件和保存多个输出。但总体而言,迭代是一种超能力:如果你掌握了正确的迭代技巧,你可以轻松地从解决一个问题过渡到解决所有问题。一旦你掌握了本章的技巧,我们强烈建议通过阅读《Advanced R》的“Functionals”章节和咨询purrr 网站来进一步学习。

如果你对其他语言中的迭代了解很多,你可能会对我们没有讨论 for 循环感到惊讶。这是因为 R 对数据分析的取向改变了我们如何进行迭代:在大多数情况下,你可以依赖现有的惯用语来对每一列或每一组执行某些操作。当你无法做到这一点时,你通常可以使用函数式编程工具如map(),它可以对列表的每个元素执行某些操作。然而,在野外捕获的代码中,你会看到 for 循环,所以我们会在下一章讨论一些重要的基础 R 工具时学习它们。

¹ 匿名的,因为我们从未用 <- 明确地给它命名。程序员用的另一个术语是lambda 函数

² 在旧代码中,你可能会看到类似 ~ .x + 1 的语法。这是另一种匿名函数的写法,但它只在 tidyverse 函数内部起作用,并始终使用变量名 .x。我们现在推荐使用基本语法 \(x) x + 1

³ 目前无法更改列的顺序,但可以在事后使用relocate()或类似方法重新排序。

⁴ 或许有一天会有,但目前我们看不出来怎么做。

⁵ 如果你有一个具有相同格式的 CSV 文件目录,可以使用来自“从多个文件读取数据”的技术。

⁶ 我们不会解释它的工作原理,但如果你查看使用的函数文档,应该能够自己弄清楚。

⁷ 你可以打印by_clarity$plot来获得一个简单的动画 —— 每个plots元素对应一个图表。

第二十七章:基础 R 实用指南

介绍

为了完成编程部分,我们将快速介绍一下在本书中未详细讨论的最重要的基础 R 函数。随着您进行更多编程,这些工具将特别有用,并将帮助您阅读在实际应用中遇到的代码。

这是一个提醒您的好地方,整洁宇宙(tidyverse)并不是解决数据科学问题的唯一途径。我们在本书中教授整洁宇宙的原因是整洁宇宙包共享一个共同的设计理念,增加了函数之间的一致性,并使每个新的函数或包都更容易学习和使用。不可能在不使用基础 R 的情况下使用整洁宇宙,因此我们实际上已经教过您很多基础 R 函数,包括 library() 用于加载包;sum()mean() 用于数字汇总;因子、日期和 POSIXct 数据类型;当然还包括所有基本运算符,如 +, -, /, *, |, &!。到目前为止,我们还没有专注于基础 R 工作流程,所以我们将在本章节中突出几个重点。

在您阅读本书后,您将学习使用基础 R、data.table 和其他包解决同一问题的其他方法。当您开始阅读他人编写的 R 代码时,特别是在使用 StackOverflow 时,您无疑会遇到这些其他方法。编写混合使用各种方法的代码完全没问题,不要让任何人告诉您其他!

在本章中,我们将专注于四个主要主题:使用 [ 进行子集化、使用 [[$ 进行子集化、使用 apply 函数族以及使用 for 循环。最后,我们将简要讨论两个必要的绘图函数。

先决条件

本包专注于基础 R,因此没有任何真正的先决条件,但我们将加载整洁宇宙以解释一些差异:

library(tidyverse)

选择多个元素的方式 [

[ 用于从向量和数据框中提取子组件,像 x[i]x[i, j] 这样调用。在本节中,我们将向您介绍 [ 的强大功能,首先展示如何在向量中使用它,然后展示相同的原理如何直接扩展到数据框等二维结构。然后,我们将通过展示各种 dplyr 动词如何是 [ 的特殊情况来帮助您巩固这些知识。

子集向量

有五种主要类型的东西,您可以使用向量进行子集化,即可以作为 x[i] 中的 i

  • 正整数向量。使用正整数进行子集化将保留这些位置的元素:

    x <- c("one", "two", "three", "four", "five")
    x[c(3, 2, 5)]
    #> [1] "three" "two"   "five"
    

    通过重复位置,您实际上可以生成比输入更长的输出,使术语“子集”有点不准确:

    x[c(1, 1, 5, 5, 5, 2)]
    #> [1] "one"  "one"  "five" "five" "five" "two"
    
  • 负整数向量。负值将删除指定位置的元素:

    x[c(-1, -3, -5)]
    #> [1] "two"  "four"
    
  • 逻辑向量。使用逻辑向量进行子集化会保留所有对应于 TRUE 值的数值。这在与比较函数一起使用时通常非常有用:

    x <- c(10, 3, NA, 5, 8, 1, NA)
    
    # All non-missing values of x
    x[!is.na(x)]
    #> [1] 10  3  5  8  1
    
    # All even (or missing!) values of x
    x[x %% 2 == 0]
    #> [1] 10 NA  8 NA
    

    filter() 不同,NA 索引将作为 NA 包含在输出中。

  • 字符向量。如果你有一个命名向量,你可以用字符向量对其进行子集化:

    x <- c(abc = 1, def = 2, xyz = 5)
    x[c("xyz", "def")]
    #> xyz def 
    #>   5   2
    

    与使用正整数进行子集化一样,可以使用字符向量复制单个条目。

  • 空白。最后一种子集化类型是什么都不做,x[],它返回完整的 x。这对于子集化向量并不有用,但正如我们后面将看到的,当子集化 2D 结构如 tibbles 时是有用的。

子集化数据框

有很多不同的方法¹ 可以使用 [ 与数据框,但最重要的方法是独立选择行和列,使用 df[rows, cols]。这里 rowscols 是前面描述的向量。例如,df[rows, ]df[, cols] 仅选择行或列,使用空子集来保留其他维度。

这里有几个例子:

df <- tibble(
  x = 1:3, 
  y = c("a", "e", "f"), 
  z = runif(3)
)

# Select first row and second column
df[1, 2]
#> # A tibble: 1 × 1
#>   y 
#>   <chr>
#> 1 a

# Select all rows and columns x and y
df[, c("x" , "y")]
#> # A tibble: 3 × 2
#>       x y 
#>   <int> <chr>
#> 1     1 a 
#> 2     2 e 
#> 3     3 f

# Select rows where `x` is greater than 1 and all columns
df[df$x > 1, ]
#> # A tibble: 2 × 3
#>       x y         z
#>   <int> <chr> <dbl>
#> 1     2 e     0.834
#> 2     3 f     0.601

不久我们会回到 $,但你应该能猜出 df$x 在上下文中的作用:它从 df 中提取 x 变量。我们需要在这里使用它,因为 [ 不使用整洁评估,所以你需要明确 x 变量的来源。

当涉及 [ 时,tibble 和数据框之间有重要的区别。在本书中,我们主要使用了 tibble,它们 数据框,但它们调整了一些行为以使您的生活更轻松。在大多数情况下,您可以互换使用“tibble”和“数据框”,因此当我们想特别注意 R 内置数据框时,我们将写 data.frame。如果 dfdata.frame,那么 df[, cols] 将在选择单个列时返回一个向量,并在选择多个列时返回一个数据框。如果 df 是一个 tibble,那么 [ 将始终返回一个 tibble。

df1 <- data.frame(x = 1:3)
df1[, "x"]
#> [1] 1 2 3

df2 <- tibble(x = 1:3)
df2[, "x"]
#> # A tibble: 3 × 1
#>       x
#>   <int>
#> 1     1
#> 2     2
#> 3     3

避免与 data.frame 的这种歧义的一种方法是显式指定 drop = FALSE

df1[, "x" , drop = FALSE]
#>   x
#> 1 1
#> 2 2
#> 3 3

dplyr 等价操作

几个 dplyr 动词是 [ 的特殊情况之一:

  • filter() 等同于使用逻辑向量对行进行子集化,注意排除缺失值:

    df <- tibble(
      x = c(2, 3, 1, 1, NA), 
      y = letters[1:5], 
      z = runif(5)
    )
    df |> filter(x > 1)
    
    # same as
    df[!is.na(df$x) & df$x > 1, ]
    

    在实际应用中另一种常见技术是使用 which(),它有副作用可以删除缺失值:df[which(df$x > 1), ]

  • arrange() 相当于使用整数向量对行进行子集化,通常使用 order() 创建:

    df |> arrange(x, y)
    
    # same as
    df[order(df$x, df$y), ]
    

    你可以使用 order(decreasing = TRUE) 对所有列按降序排序,或者使用 -rank(col) 以递减顺序单独排序列。

  • select()relocate() 与使用字符向量子集化列类似:

    df |> select(x, z)
    
    # same as
    df[, c("x", "z")]
    

基础 R 还提供了一个结合了 filter()select()² 功能的函数,称为 subset()

df |> 
  filter(x > 1) |> 
  select(y, z)
#> # A tibble: 2 × 2
#>   y           z
#>   <chr>   <dbl>
#> 1 a     0.157 
#> 2 b     0.00740
# same as
df |> subset(x > 1, c(y, z))

这个函数启发了 dplyr 很多的语法。

练习

  1. 创建接受向量作为输入并返回的函数:

    1. 偶数位置的元素

    2. 除最后一个值外的每个元素

    3. 只有偶数值(且无缺失值)

  2. 为什么 x[-which(x > 0)] 不等同于 x[x <= 0]?阅读 which() 的文档并进行一些实验来弄清楚。

使用 $ 和 [[ 选择单个元素

[ 选择多个元素,与 [[$ 配对使用,这两者提取单个元素。在本节中,我们将展示如何使用 [[$ 从数据框中提取列,并讨论 data.frames 和 tibbles 之间的一些区别,并强调使用列表时 [[[ 之间的一些重要区别。

数据框

[[$ 可用于从数据框中提取列。[[ 可以按位置或名称访问,而 $ 则专门用于按名称访问:

tb <- tibble(
  x = 1:4,
  y = c(10, 4, 1, 21)
)

# by position
tb[[1]]
#> [1] 1 2 3 4

# by name
tb[["x"]]
#> [1] 1 2 3 4
tb$x
#> [1] 1 2 3 4

它们还可以用于创建新列,这是基础 R 中 mutate() 的等效操作:

tb$z <- tb$x + tb$y
tb
#> # A tibble: 4 × 3
#>       x     y     z
#>   <int> <dbl> <dbl>
#> 1     1    10    11
#> 2     2     4     6
#> 3     3     1     4
#> 4     4    21    25

使用基础 R 中的几种方法创建新列,包括 transform()with(),以及 within()。Hadley 收集了一些 示例

在进行快速汇总时,直接使用 $ 很方便。例如,如果只想找到最大钻石的大小或cut的可能值,则无需使用 summarize()

max(diamonds$carat)
#> [1] 5.01

levels(diamonds$cut)
#> [1] "Fair"      "Good"      "Very Good" "Premium"   "Ideal"

dplyr 还提供了一个与 [/$ 等效的函数,在 [第三章 中未提到:pull()pull() 接受变量名或变量位置,仅返回该列。这意味着我们可以重写以使用管道的先前代码:

diamonds |> pull(carat) |> mean()
#> [1] 0.7979397

diamonds |> pull(cut) |> levels()
#> [1] "Fair"      "Good"      "Very Good" "Premium"   "Ideal"

Tibbles

tibbles 和基础 data.frames 之间在使用 $ 时有几个重要区别。数据框会匹配任何变量名称的前缀(所谓的部分匹配),如果列不存在也不会报错:

df <- data.frame(x1 = 1)
df$x
#> Warning in df$x: partial match of 'x' to 'x1'
#> [1] 1
df$z
#> NULL

Tibbles 更严格:它们只匹配变量名的确切名称,并且如果尝试访问不存在的列,则会生成警告:

tb <- tibble(x1 = 1)

tb$x
#> Warning: Unknown or uninitialised column: `x`.
#> NULL
tb$z
#> Warning: Unknown or uninitialised column: `z`.
#> NULL

因此,我们有时开玩笑说 tibble 懒惰又暴躁:它们做得少,抱怨得多。

列表

[[$在处理列表时也非常重要,理解它们与[的区别至关重要。让我们通过一个名为l的列表来说明它们的不同:

l <- list(
  a = 1:3, 
  b = "a string", 
  c = pi, 
  d = list(-1, -5)
)
  • [提取一个子列表。不管你提取多少元素,结果始终是一个列表。

    str(l[1:2])
    #> List of 2
    #>  $ a: int [1:3] 1 2 3
    #>  $ b: chr "a string"
    
    str(l[1])
    #> List of 1
    #>  $ a: int [1:3] 1 2 3
    
    str(l[4])
    #> List of 1
    #>  $ d:List of 2
    #>   ..$ : num -1
    #>   ..$ : num -5
    

    就像向量一样,你可以使用逻辑、整数或字符向量来进行子集选择。

  • [[$从列表中提取单个组件。它们从列表中删除一个层级。

    str(l[[1]])
    #>  int [1:3] 1 2 3
    
    str(l[[4]])
    #> List of 2
    #>  $ : num -1
    #>  $ : num -5
    
    str(l$a)
    #>  int [1:3] 1 2 3
    

``和[[之间的区别对于列表尤其重要,因为[[会深入到列表中,而[会返回一个新的、较小的列表。为了帮助你记住这个区别,看看在 [图 27-1 中展示的不寻常的胡椒瓶。如果这个胡椒瓶是你的列表pepper,那么pepper[1]是一个装有单个胡椒包的胡椒瓶。pepper[2]看起来一样,但是会包含第二个胡椒包。pepper[1:2]是一个装有两个胡椒包的胡椒瓶。pepper[[1]]会提取胡椒包本身。

三张照片。左边是一个玻璃胡椒瓶的照片。胡椒瓶里面不是胡椒,而是一个单独的胡椒包。中间是一个单独的胡椒包的照片。右边是胡椒包的内容的照片。

图 27-1. (左) Hadley 在他的酒店房间里找到的一个胡椒瓶。 (中) pepper[1]。 (右) pepper[[1]]

当你在数据框中使用 1D [时,这个原则也适用:df["x"]返回一个一列的数据框,而df[["x"]]返回一个向量。

练习

  1. 当你用正整数作为[[的索引,这个整数大于向量长度时会发生什么?当你用一个不存在的名称作为子集时会发生什么?

  2. pepper[[1]][1]会是什么?pepper[[1]][[1]]又会是什么?

应用家族

在 第 26 章 中,你学到了迭代的 tidyverse 技术,比如 dplyr::across() 和 map 函数族。在本节中,你将学习它们的基本等效物,即 apply family。在这个上下文中,apply 和 map 是同义词,因为另一种说法是“在每个向量元素上映射一个函数”。我们将为你快速概述这个家族,以便你在实际中能够识别它们。

这个家族中最重要的成员是lapply(),它类似于purrr::map()³。实际上,因为我们没有使用任何map()的更高级特性,你可以在第二十六章中用lapply()替换每个map()调用。

在 base R 中没有与across()完全等效的函数,但可以通过使用lapply()[接近。这是因为在底层,数据框架是列的列表,所以在数据框架上调用lapply()会将函数应用到每一列。

df <- tibble(a = 1, b = 2, c = "a", d = "b", e = 4)

# First find numeric columns
num_cols <- sapply(df, is.numeric)
num_cols
#>     a     b     c     d     e 
#>  TRUE  TRUE FALSE FALSE  TRUE

# Then transform each column with lapply() then replace the original values
df[, num_cols] <- lapply(df[, num_cols, drop = FALSE], \(x) x * 2)
df
#> # A tibble: 1 × 5
#>       a     b c     d         e
#>   <dbl> <dbl> <chr> <chr> <dbl>
#> 1     2     4 a     b         8

上面的代码使用了一个新函数sapply()。它类似于lapply(),但它总是尝试简化结果,这就是其名称中s的原因,在这里产生一个逻辑向量而不是列表。我们不建议在编程中使用它,因为简化可能会失败并给出意外的类型,但通常在交互使用中是可以的。purrr 有一个类似的函数叫map_vec(),我们在第二十六章没有提到它。

Base R 提供了一个更严格的版本的sapply(),称为vapply(),简称vector apply。它接受一个额外的参数,指定了期望的类型,确保简化的方式与输入无关。例如,我们可以用这个vapply()替换之前的sapply()调用,其中我们指定我们期望is.numeric()返回一个长度为 1 的逻辑向量:

vapply(df, is.numeric, logical(1))
#>     a     b     c     d     e 
#>  TRUE  TRUE FALSE FALSE  TRUE

当它们在函数内部时,sapply()vapply()之间的区别真的很重要(因为对于异常输入,这对函数的鲁棒性有很大影响),但在数据分析中通常并不重要。

apply 家族的另一个重要成员是tapply(),它计算单个分组摘要:

diamonds |> 
  group_by(cut) |> 
  summarize(price = mean(price))
#> # A tibble: 5 × 2
#>   cut       price
#>   <ord>     <dbl>
#> 1 Fair      4359.
#> 2 Good      3929.
#> 3 Very Good 3982.
#> 4 Premium   4584.
#> 5 Ideal     3458.

tapply(diamonds$price, diamonds$cut, mean)
#>      Fair      Good Very Good   Premium     Ideal 
#>  4358.758  3928.864  3981.760  4584.258  3457.542

不幸的是,tapply()返回其结果为命名向量,如果你想将多个摘要和分组变量收集到数据框中,则需要进行一些技巧(当然也可以选择不这样做,只是与自由浮动的向量一起工作,但根据我们的经验,这只会延迟工作)。如果你想看看如何使用tapply()或其他基本技术来执行其他分组摘要,Hadley 在gist中收集了一些技术。

apply 家族的最后一位成员是名义上的apply(),它与矩阵和数组一起工作。特别是要注意apply(df, 2, something),这是做lapply(df, something)的一种缓慢且潜在危险的方式。在数据科学中很少遇到这种情况,因为我们通常使用数据框而不是矩阵。

对于循环

for循环是迭代的基本构建块,apply 和 map 系列在幕后使用。for循环是强大且通用的工具,作为你成为更有经验的 R 程序员时学习的重要工具。for循环的基本结构如下:

for (element in vector) {
  # do something with element
}

for循环最直接的用法是实现与walk()相同的效果:对列表的每个元素调用具有副作用的某个函数。例如,在“写入数据库”中,可以不使用walk()

paths |> walk(append_file)

我们本可以使用for循环:

for (path in paths) {
  append_file(path)
}

如果你想保存for循环的输出,例如像我们在第二十六章中读取目录中所有的 Excel 文件一样,情况就会变得有些棘手:

paths <- dir("data/gapminder", pattern = "\\.xlsx$", full.names = TRUE)
files <- map(paths, readxl::read_excel)

有几种不同的技术可以使用,但我们建议提前明确输出的形式。在这种情况下,我们将需要一个与paths相同长度的列表,可以使用vector()创建:

files <- vector("list", length(paths))

然后,我们不是迭代paths的元素,而是迭代它们的索引,使用seq_along()paths的每个元素生成一个索引:

seq_along(paths)
#>  [1]  1  2  3  4  5  6  7  8  9 10 11 12

使用索引很重要,因为它允许我们将输入中的每个位置链接到输出中的相应位置:

for (i in seq_along(paths)) {
  files[[i]] <- readxl::read_excel(paths[[i]])
}

要将 tibbles 列表合并为单个 tibble,可以使用do.call() + rbind()

do.call(rbind, files)
#> # A tibble: 1,704 × 5
#>   country     continent lifeExp      pop gdpPercap
#>   <chr>       <chr>       <dbl>    <dbl>     <dbl>
#> 1 Afghanistan Asia         28.8  8425333      779.
#> 2 Albania     Europe       55.2  1282697     1601.
#> 3 Algeria     Africa       43.1  9279525     2449.
#> 4 Angola      Africa       30.0  4232095     3521.
#> 5 Argentina   Americas     62.5 17876956     5911.
#> 6 Australia   Oceania      69.1  8691212    10040.
#> # … with 1,698 more rows

与其制作列表并在进行保存的同时保存结果,一个更简单的方法是逐步构建数据框:

out <- NULL
for (path in paths) {
  out <- rbind(out, readxl::read_excel(path))
}

我们建议避免这种模式,因为当向量很长时可能会变慢。这是关于for循环速度慢的持久谬论的根源:实际上不是这样,但是当迭代增长向量时会变慢。

图形

许多不使用 tidyverse 的 R 用户更喜欢 ggplot2 进行绘图,因为它具有诸如合理的默认设置、自动图例和现代外观等有用功能。然而,基础 R 绘图函数仍然很有用,因为它们非常简洁——进行基本的探索性绘图所需的输入很少。

野外常见的两种基础绘图类型是散点图和直方图,分别使用plot()hist()生成。这里是来自diamonds数据集的一个快速示例:

# Left
hist(diamonds$carat)

# Right
plot(diamonds$carat, diamonds$price)

左侧是钻石克拉数的直方图,范围从 0 到 5 克拉。分布是单峰且右偏。右侧是价格与钻石克拉数的散点图,显示随着价格和克拉数增加呈现正相关关系。与 0 到 3 克拉之间的钻石相比,散点图显示很少大于 3 克拉的钻石。

注意基础绘图函数使用向量,因此您需要使用$或其他技术从数据框中提取列。

摘要

在本章中,我们向您展示了一些对子集和迭代有用的基础 R 函数。与本书其他部分讨论的方法相比,这些函数更倾向于“向量”风格,而不是“数据框”风格,因为基础 R 函数往往处理单独的向量,而不是数据框和某些列规范。这通常使编程更容易,因此在编写更多函数并开始编写自己的包时变得更加重要。

本章结束了本书的编程部分。您已经在成为不仅仅使用 R 的数据科学家,而是能够在 R 中编程的数据科学家的旅程上取得了坚实的开端。我们希望这些章节激发了您对编程的兴趣,并且您期待在本书之外继续学习更多。

¹ 阅读《Advanced R》中的选择多个元素部分,看看您如何将数据框子集化为 1D 对象,以及如何使用矩阵对其进行子集化。

² 但它不会区别处理分组数据框,并且不支持像starts_with()这样的选择辅助函数。

³ 它只是缺乏方便的功能,比如进度条和报告哪个元素引起了问题(如果有错误的话)。

第六部分: 交流

到目前为止,您已经学会了将数据导入 R、整理成方便分析的形式,然后通过转换和可视化了解数据。然而,除非您能够向他人解释,否则您的分析再好也没有用:您需要交流您的结果。

一张显示数据科学循环并用蓝色突出显示“交流”的图表。

图 VI-1. 交流是数据科学过程的最后一部分;如果您不能将结果传达给其他人,那么您的分析再好也没有用。

交流是接下来两章的主题:

  • 在第二十八章,您将学习到关于 Quarto 的内容,这是一个将散文、代码和结果整合在一起的工具。您可以将 Quarto 用于分析师与分析师之间的交流,也可以用于分析师与决策者之间的交流。由于 Quarto 格式的强大功能,您甚至可以在同一个文档中实现这两种目的。

  • 在第二十九章,您将了解到使用 Quarto 可以生成的许多其他输出形式,包括仪表板、网站和书籍等。

这些章节主要关注通信的技术机制,而不是将您的想法传达给其他人时遇到的真正困难的问题。不过,有很多其他关于交流的好书,我们会在每章末尾为您指引。

第二十八章:Quarto

简介

Quarto 为数据科学提供了一个统一的创作框架,结合了您的代码、其结果和您的文本。Quarto 文档完全可重现,并支持多种输出格式,如 PDF、Word 文件、演示文稿等。

Quarto 文件设计用于三种方式:

  • 为了向决策者传达重点结论,而非分析背后的代码

  • 为了与其他数据科学家(包括未来的自己)合作,他们对您的结论和您达到这些结论的方式(即代码)都感兴趣

  • 作为一个环境,用于进行数据科学,作为一个现代化的实验笔记本,在这里您不仅可以记录您的操作,还可以记录您的思考过程

Quarto 是一个命令行界面工具,而不是一个 R 包。这意味着帮助通常不能通过?获得。因此,在您阅读本章并在将来使用 Quarto 时,您应参考Quarto 文档

如果您是 R Markdown 用户,您可能会想:“Quarto 听起来很像 R Markdown。” 您没有错! Quarto 将 R Markdown 生态系统中许多包的功能统一到一个一致的系统中,并通过本地支持多种编程语言(例如 Python 和 Julia,除了 R)来扩展它。在某种程度上,Quarto 反映了十年来扩展和支持 R Markdown 生态系统所学到的一切。

先决条件

您需要使用 Quarto 命令行界面(Quarto CLI),但不需要显式安装或加载它,因为 RStudio 在需要时会自动完成。

Quarto 基础

这是一个 Quarto 文件,即扩展名为.qmd的纯文本文件:

---
title: "Diamond sizes"
date: 2022-09-12
format: html
---

```{r}

#| label: setup

#| include: false

library(tidyverse)

smaller <- diamonds |>

filter(carat <= 2.5)

We have data about r nrow(diamonds) diamonds.
Only r nrow(diamonds) - nrow(smaller) are larger than 2.5 carats.
The distribution of the remainder is shown below:


#| label: plot-smaller-diamonds

#| echo: false

smaller |>

ggplot(aes(x = carat)) +

geom_freqpoly(binwidth = 0.01)


它包含三种重要类型的内容:

+   由`---`包围的(可选的)*YAML 头部*

+   R 代码*块*,用```` ``` ````包围

+   文本与简单文本格式化,如`# heading`和 `_italics_`

图 28-1 显示了在 RStudio 中具有笔记本界面的`.qmd`文档,其中代码和输出交错显示。您可以通过单击代码块顶部的播放按钮(看起来像一个播放按钮)或按下 Cmd/Ctrl+Shift+Enter 来运行每个代码块。 RStudio 会执行代码并将结果与代码内联显示。

![在左侧显示名为“diamond-sizes.qmd”的 Quarto 文档的 RStudio 窗口,右侧是空白的 Viewer 窗口。Quarto 文档包含一个代码块,用于创建少于 2.5 克拉钻石频率图。该图表明随着重量增加,频率减少。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2801.png)

###### 图 28-1\. 在 RStudio 中显示的 Quarto 文档。代码和输出交错显示,绘图输出紧跟在代码下方。

如果您不喜欢在文档中看到图表和输出,并且宁愿使用 RStudio 的 Console 和 Plot 窗格,您可以单击“Render”旁边的齿轮图标,切换到 Console 中显示块输出选项,如 图 28-2 所示。

![RStudio 窗口左侧显示名为 "diamond-sizes.qmd" 的 Quarto 文档,右下方显示 Plot 窗格。Quarto 文档包含一个代码块,用于创建少于 2.5 克拉钻石的频率图。Plot 窗格显示频率随重量增加而减少的情况。RStudio 中显示在 Console 中显示块输出选项也被突出显示。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2802.png)

###### 图 28-2\. 在 RStudio 中显示的带有绘图输出的 Quarto 文档。

要生成包含所有文本、代码和结果的完整报告,请单击“Render”或按下 Cmd/Ctrl+Shift+K。您还可以使用 `quarto::quarto_render("diamond-sizes.qmd")` 进行编程方式操作。这将在 图 28-3 中显示报告,并创建一个 HTML 文件。

![RStudio 窗口左侧显示名为 "diamond-sizes.qmd" 的 Quarto 文档,右下方显示 Plot 窗格。渲染后的文档不显示任何代码,但源文件中可见代码。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2803.png)

###### 图 28-3\. 在 RStudio 中使用渲染后的 Viewer 窗格显示的 Quarto 文档。

当您渲染文档时,Quarto 将 `.qmd` 文件发送到 [knitr](https://oreil.ly/HvFDz),后者执行所有代码块并创建包含代码及其输出的新 Markdown(`.md`)文档。knitr 生成的 Markdown 文件随后由 [pandoc](https://oreil.ly/QxUsn) 处理,负责创建最终的文件。图 28-4 展示了这一过程。这种两步工作流的优势在于您可以创建多种输出格式,详细内容请参见 第二十九章。

![工作流程图从 qmd 文件开始,然后经过 knitr、md、pandoc,最终生成 PDF、MS Word 或 HTML 文件。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2804.png)

###### 图 28-4\. Quarto 工作流程图,从 qmd 到 knitr,再到 md,最后由 pandoc 输出为 PDF、MS Word 或 HTML 格式。

要开始使用您自己的 `.qmd` 文件,请在菜单栏中选择“文件 > 新建文件 > Quarto 文档…”。RStudio 将启动一个向导,您可以使用它来预填充文件,以提醒您 Quarto 的关键功能如何工作。

下面的章节详细介绍了 Quarto 文档的三个组成部分:Markdown 文本、代码块和 YAML 头部。

## 练习

1.  通过选择 文件 > 新文件 > Quarto 文档 创建一个新的 Quarto 文档。阅读说明。练习逐个运行代码块。然后通过单击适当的按钮以及使用适当的键盘快捷键来渲染文档。验证您可以修改代码、重新运行它并查看修改后的输出。

1.  为每种内置格式(HTML、PDF 和 Word)创建一个新的 Quarto 文档。渲染这三个文档。它们的输出有什么不同?它们的输入又有什么不同?(如果需要,您可能需要安装 LaTeX 来构建 PDF 输出 —— 如果需要,RStudio 将提示您。)

# 可视化编辑器

RStudio 中的可视化编辑器为编写 Quarto 文档提供了所见即所得的界面。在幕后,Quarto 文档(`.qmd` 文件)中的文本是用 Markdown 编写的,这是一种用于格式化纯文本文件的轻量级约定。事实上,Quarto 使用 Pandoc markdown(Quarto 理解的略微扩展版本的 Markdown),包括表格、引用、交叉引用、脚注、div/span、定义列表、属性、原始 HTML/TeX 等,以及支持执行代码单元格并在行内查看其输出。虽然 Markdown 设计成易于阅读和书写,正如您将在 “源编辑器” 中看到的那样,但仍然需要学习新的语法。因此,如果您是新手,对于像 `.qmd` 文件这样的计算文档但有使用 Google Docs 或 MS Word 等工具的经验,开始使用 RStudio 中的 Quarto 最简单的方法是使用可视化编辑器。

在可视化编辑器中,您可以使用菜单栏上的按钮来插入图片、表格、交叉引用等,或者您可以使用全能的 `Cmd/Ctrl+/` 快捷键来插入几乎任何内容。如果您在一行的开头(如在 图 28-5 中所示),您还可以仅输入 `/` 来调用该快捷方式。

![一个 Quarto 文档展示了视觉编辑器的各种功能,如文本格式(斜体、粗体、下划线、小型大写字母、代码、上标和下标)、一到三级标题、项目符号和编号列表、链接、链接短语和图片(包括用于自定义图片大小、添加标题和 alt 文本等的弹出窗口)、带有标题行的表格,以及插入任何内容工具,可选项包括插入 R 代码块、Python 代码块、div、项目符号列表、编号列表或一级标题(工具中的前几个选择)。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_2805.png)

###### 图 28-5\. Quarto 可视化编辑器。

插入图像并自定义其显示方式也可以通过视觉编辑器轻松完成。您可以直接将图像从剪贴板粘贴到视觉编辑器中(RStudio 将在项目目录中放置该图像的副本并链接到它),或者您可以使用视觉编辑器的插入 > 图片/图形菜单浏览要插入的图像或粘贴其 URL。此外,使用相同的菜单,您还可以调整图像的大小,并添加标题、替代文本和链接。

视觉编辑器有许多我们未在此列出的功能,当您使用它撰写文稿时,您可能会发现它们非常有用。

最重要的是,虽然视觉编辑器显示带有格式的内容,但在幕后,它会将您的内容保存为纯 Markdown,并且您可以在视觉和源编辑器之间切换,以查看和编辑您的内容。

## 练习

1.  使用视觉编辑器重新创建图 28-5 中的文档。

1.  使用视觉编辑器,使用插入菜单和插入任何工具插入代码块。

1.  使用视觉编辑器,找出如何:

    1.  添加一个脚注。

    1.  添加一个水平分隔线。

    1.  添加一个块引用。

1.  在视觉编辑器中,选择插入 > 引用,并使用其数字对象标识符(DOI)插入标题为[“欢迎来到 Tidyverse”](https://oreil.ly/I9_I7)的论文的引用,其 DOI 是[10.21105/joss.01686](https://oreil.ly/H_Xn-)。渲染文档并观察引用如何显示在文档中。您在文档的 YAML 中观察到了什么变化?

# 源编辑器

您还可以在 RStudio 中使用源编辑器编辑 Quarto 文档,无需视觉编辑器的帮助。虽然对于那些习惯于使用 Google Docs 等工具进行编写的人来说,视觉编辑器会感觉很熟悉,但对于那些有编写 R 脚本或 R Markdown 文档经验的人来说,源编辑器会更为熟悉。源编辑器还可以用于调试任何 Quarto 语法错误,因为通常更容易在纯文本中捕捉这些错误。

以下指南展示了如何在源编辑器中使用 Pandoc 的 Markdown 来撰写 Quarto 文档:

Text formatting

italic bold strikeout code

superscript²^ subscript2

[underline]{.underline} [small caps]{.smallcaps}

Headings

1st Level Header

2nd Level Header

3rd Level Header

Lists

  • Bulleted list item 1

  • Item 2

    • Item 2a

    • Item 2b

1. Numbered list item 1

2. Item 2.
The numbers are incremented automatically in the output.

http://example.com

linked phrase

optional caption text{
fig-alt="Quarto logo and the word quarto spelled in small case letters"}

Tables

First Header Second Header
Content Cell Content Cell
Content Cell Content Cell

学习这些内容的最佳方式就是简单地尝试它们。这可能需要几天时间,但很快它们就会成为第二天性,您不需要再去考虑它们。如果您忘记了,您可以通过帮助 > Markdown 快速参考来获取便捷的参考表。

## 练习

1.  通过创建一个简要的简历来实践所学内容。标题应为您的姓名,并包括至少教育或就业的标题。每个部分应包括职位/学位的项目列表。用粗体标出年份。

1.  使用源编辑器和 Markdown 快速参考,找出如何:

    1.  添加一个脚注。

    1.  添加一个水平分隔线。

    1.  添加一个块引用。

1.  将[`diamond-sizes.qmd`](https://oreil.ly/Auuh2)的内容复制并粘贴到本地的 R Quarto 文档中。检查是否可以运行它,然后在频率多边形之后添加文本,描述其最引人注目的特点。

1.  在 Google Docs 或 MS Word 中创建文档(或找到之前创建的文档),其中包含标题、超链接、格式化文本等内容。将此文档的内容复制并粘贴到视觉编辑器中的 Quarto 文档中。然后切换到源编辑器并检查源代码。

# 代码块

要在 Quarto 文档中运行代码,您需要插入一个代码块。有三种方法可以做到这一点:

+   按下键盘快捷键 Cmd+Option+I/Ctrl+Alt+I

+   点击编辑器工具栏中的插入按钮图标

+   手动键入代码块界定符```` ```{r} ````和```` ``` ````

我们建议您学习这个键盘快捷键。长远来看,这将为您节省大量时间!

您可以继续使用键盘快捷键运行代码,到现在为止(我们希望如此!)您已经熟悉并喜爱:Cmd/Ctrl+Enter。然而,代码块有一个新的键盘快捷键,Cmd/Ctrl+Shift+Enter,它运行代码块中的所有代码。将代码块视为函数。代码块应该相对独立,并围绕单个任务进行重点。

下面的部分描述了由```` ```{r} ````组成的代码块头部,后跟可选的代码块标签和各种其他代码块选项,每个选项占据一行,由`#|`标记。

## 代码块标签

代码块可以有一个可选的标签:


#| 标签:simple-addition

1 + 1


> [1] 2


这有三个优点:

+   您可以更轻松地使用位于脚本编辑器左下角的下拉式代码导航器导航到特定的代码块:

    ![显示仅显示三个代码块的 RStudio IDE 截图。第 1 个块是设置。第 2 个块是汽车,位于 Quarto 部分。第 3 个块是压力,位于包含图表的部分。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_28in01.png)

+   由代码块生成的图形将具有有用的名称,使它们更容易在其他地方使用。更多信息请参阅“图形”。

+   您可以设置缓存代码块的网络,以避免在每次运行时重新执行昂贵的计算。更多信息请参阅“缓存”。

您的代码块标签应该简短而富有启发性,并且不应包含空格。我们建议使用破折号(`-`)来分隔单词(而不是下划线`_`),并避免在代码块标签中使用其他特殊字符。

通常可以自由地对代码块进行标记,但有一个特殊行为的代码块名称:`setup`。当您处于笔记本模式时,名为`setup`的代码块将在运行任何其他代码之前自动运行一次。

另外,代码块标签不能重复。每个代码块标签必须是唯一的。

## 代码块选项

代码块输出可以通过 *选项* 自定义,这些选项是提供给代码块标题的字段。Knitr 提供了近 60 个选项,您可以使用它们来自定义代码块。在这里,我们将涵盖您经常使用的最重要的代码块选项。您可以在 [这里](https://oreil.ly/38bld) 查看完整列表。

最重要的一组选项控制代码块是否执行以及在最终报告中插入的结果:

`eval: false`

防止代码被评估。(显然,如果代码未运行,则不会生成结果。)这对显示示例代码或禁用大块代码而不是每行注释非常有用。

`include: false`

运行代码,但不在最终文档中显示代码或结果。用于不想在报告中看到的设置代码。

`echo: false`

防止代码,但结果不会出现在最终文件中。用于写给不想看到底层 R 代码的人的报告。

`message: false` 或 `warning: false`

防止消息或警告出现在最终文件中。

`results: hide`

隐藏打印输出。

`fig-show: hide`

隐藏绘图。

`error: true`

导致渲染继续即使代码返回错误。这在最终版本的报告中很少出现,但如果需要调试 `.qmd` 中发生的情况,则非常有用。如果您正在教学 R 并希望故意包含错误,则也很有用。默认情况下,`error: false` 会导致文档中有一个错误时渲染失败。

每个代码块选项都添加到代码块标题后面,例如,在下面的代码块中,由于 `eval` 设置为 false,结果不会打印:


#| label: simple-multiplication

#| eval: false

2 * 2


下表总结了每个选项抑制的输出类型:

| 选项 | 运行代码 | 显示代码 | 输出 | 绘图 | 消息 | 警告 |
| --- | --- | --- | --- | --- | --- | --- |
| `eval: false` | X |   | X | X | X | X |
| `include: false` |   | X | X | X | X | X |
| `echo: false` |   | X |   |   |   |   |
| `results: hide` |   |   | X |   |   |   |
| `fig-show: hide` |   |   |   | X |   |   |
| `message: false` |   |   |   |   | X |   |
| `warning: false` |   |   |   |   |   | X |

## 全局选项

随着您与 knitr 的更多工作,您会发现一些默认代码块选项不符合您的需求,您希望更改它们。

您可以通过在文档 YAML 下的 `execute` 中添加首选选项来实现此目的。例如,如果您正在为一个不需要看到您的代码但只需要看到结果和叙述的受众准备报告,可以在文档级别设置 `echo: false`。这将默认隐藏代码,并仅显示您选择显示的块(`echo: true`)。您可能考虑设置 `message: false` 和 `warning: false`,但这会使调试问题更困难,因为您在最终文档中看不到任何消息。

title: "My report"
execute:
echo: false


由于 Quarto 被设计为多语言(它与 R 以及其他语言如 Python,Julia 等一起工作),文档执行级别上并不可用所有的 knitr 选项,因为其中一些只能与 knitr 一起使用,并不能与 Quarto 用于其他语言的引擎(例如 Jupyter)一起使用。然而,您仍然可以将它们作为文档的全局选项设置在 `knitr` 字段下的 `opts_chunk` 下。例如,在编写书籍和教程时,我们设置:

title: "Tutorial"
knitr:
opts_chunk:
comment: "#>"
collapse: true


这使用了我们首选的注释格式,并确保代码和输出紧密相关。

## 内联代码

有另一种方法可以将 R 代码嵌入到 Quarto 文档中:直接在文本中使用 `` `r ` ``。如果您在文本中提及数据的属性,这将非常有用。例如,在本章开头使用的示例文档中有:

> 我们有关于 `` `r nrow(diamonds)` `` 颗钻石的数据。只有 `` `r nrow(diamonds) - nrow(smaller)` `` 颗大于 2.5 克拉。其余的分布如下所示:

当报告被渲染时,这些计算结果将插入到文本中:

> 我们有关于 53940 颗钻石的数据。只有 126 颗大于 2.5 克拉。其余的分布如下所示:

当将数字插入文本时,[`format()`](https://rdrr.io/r/base/format.xhtml) 是你的好帮手。它允许你设置 `digits` 的数量,以避免打印到荒谬的精度,并且你可以使用 `big.mark` 使数字更容易阅读。你可以将它们组合成一个辅助函数:

comma <- function(x) format(x, digits = 2, big.mark = ",")
comma(3452345)

> [1] "3,452,345"

comma(.12358124331)

> [1] "0.12"


## 练习

1.  添加一个部分,探讨钻石尺寸如何根据切割,颜色和净度变化。假设你为一个不懂 R 语言的人写报告,而不是在每个块上设置 `echo: false`,可以设置一个全局选项。

1.  下载 [`diamond-sizes.qmd`](https://oreil.ly/Auuh2)。添加一个部分,描述最大的 20 颗钻石,包括显示它们最重要属性的表格。

1.  修改 `diamonds-sizes.qmd` 使用 `label_comma()` 生成格式良好的输出。还包括大于 2.5 克拉的钻石百分比。

# 图形

Quarto 文档中的图形可以嵌入(例如 PNG 或 JPEG 文件)或作为代码块的结果生成。

要嵌入来自外部文件的图像,您可以在 RStudio 的可视化编辑器中使用“插入”菜单,选择“图像”。这将弹出一个菜单,您可以浏览到要插入的图像,并添加替代文本或标题,并调整其大小。在可视化编辑器中,您还可以简单地将图像从剪贴板粘贴到文档中,RStudio 将在项目文件夹中放置该图像的副本。

如果你包含一个生成图形的代码块(例如包含`ggplot()`调用),生成的图形将自动包含在你的 Quarto 文档中。

## 图形大小

在 Quarto 中图形的最大挑战是使您的图形大小和形状正确。有五个主要选项可以控制图形大小:`fig-width`、`fig-height`、`fig-asp`、`out-width`和`out-height`。图像大小具有挑战性,因为存在两种尺寸(由 R 创建的图形大小以及它在输出文档中插入的大小),以及多种指定大小的方式(即高度、宽度和纵横比:选择三个中的两个)。

我们推荐五个选项中的三个:

+   如果图形具有一致的宽度,通常更美观。为了实现这一点,在默认情况下设置`fig-width: 6`(6 英寸)和`fig-asp: 0.618`(黄金比例)。然后在单个代码块中,仅调整`fig-asp`。

+   使用`out-width`控制输出大小,并将其设置为输出文档正文宽度的百分比。我们建议使用`out-width: "70%"`和`fig-align: center`。这样可以使图形有足够的空间呼吸,而不会占用太多空间。

+   要在单行中放置多个图形,请将`layout-ncol`设置为 2(两个图形)、3(三个图形)等。根据您试图说明的内容(例如显示数据或显示图形变化),您可能还需要调整`fig-width`,如下所讨论的。

如果发现您需要眯起眼睛才能阅读图中的文本,则需要调整`fig-width`。如果`fig-width`大于图形在最终文档中呈现的大小,则文本将太小;如果`fig-width`较小,则文本将太大。通常需要进行一些实验来找出`fig-width`与文档最终宽度之间的正确比例。为了说明这个原则,以下三个图的`fig-width`分别为 4、6 和 8:

![汽车排量与公路里程散点图,点的大小正常,轴文本和标签与周围文本大小相似。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_28in02.png)![汽车排量与公路里程散点图,点比前一个图小,轴文本和标签比周围文本小。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_28in03.png)![汽车排量与公路里程散点图,点比前一个图甚至更小,轴文本和标签比周围文本还要小。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_28in04.png)

如果您希望确保所有图形的字体大小保持一致,每当您设置`out-width`时,您还需要调整`fig-width`以保持与默认`out-width`相同的比例。例如,如果您的默认`fig-width`为 6,而`out-width`为“70%”,当您设置`out-width: "50%"`时,您需要将`fig-width`设置为 4.3(6 * 0.5 / 0.7)。

图片的大小和缩放是一门艺术和科学,正确调整可能需要迭代的试错方法。您可以在[“控制绘图缩放”博客文章](https://oreil.ly/EfKFq)中了解更多关于图片大小的信息。

## 其他重要选项

在像本书这样混合代码和文本的情况下,您可以设置`fig-show: hold`,以便在代码后显示绘图。这样做的一个愉快的副作用是强迫您用解释来打破大块的代码。

要向图表添加标题,请使用`fig-cap`。在 Quarto 中,这将使图表从内联变为“浮动”。

如果您要生成 PDF 输出,则默认的图形类型是 PDF。这是一个很好的默认设置,因为 PDF 是高质量的矢量图形。但是,如果您显示数千个点,则可能会产生大型和缓慢的绘图。在这种情况下,设置`fig-format: "png"`以强制使用 PNG。它们的质量略低,但文件会更紧凑。

即使您不经常标记其他块,为生成图形的代码块命名是一个好主意。代码块标签用于生成磁盘上图形的文件名,因此为代码块命名使得在其他情况下(例如,如果您想快速将单个图形插入电子邮件中),更容易选择和重用图形。

## 练习

1.  在视觉编辑器中打开`diamond-sizes.qmd`,找到一张钻石的图像,复制并粘贴到文档中。双击图像并添加标题。调整图像大小并渲染您的文档。观察图像如何保存在当前工作目录中。

1.  编辑生成绘图的`diamond-sizes.qmd`中代码块的标签,以`fig-`作为前缀开始,并通过插入 > 交叉引用在代码块上面的文本中添加一个标题。然后,编辑代码块上方的文本,添加到图表的交叉引用。

1.  使用以下代码块选项之一更改图形的大小;渲染您的文档;并描述图形的变化。

    1.  `fig-width: 10`

    1.  `fig-height: 3`

    1.  `out-width: "100%"`

    1.  `out-width: "20%"`

# 表格

与图表类似,您可以在 Quarto 文档中包含两种类型的表格。它们可以是您直接在 Quarto 文档中创建的 Markdown 表格(使用插入表格菜单),也可以是作为代码块结果生成的表格。在本节中,我们将重点放在后者上,即通过计算生成的表格。

默认情况下,Quarto 将数据框架和矩阵打印为您在控制台中看到的样子。

mtcars[1:5, ]

> mpg cyl disp hp drat wt qsec vs am gear carb

> Mazda RX4 21.0 6 160 110 3.90 2.620 16.46 0 1 4 4

> Mazda RX4 Wag 21.0 6 160 110 3.90 2.875 17.02 0 1 4 4

> Datsun 710 22.8 4 108 93 3.85 2.320 18.61 1 1 4 1

> Hornet 4 Drive 21.4 6 258 110 3.08 3.215 19.44 1 0 3 1

> Hornet Sportabout 18.7 8 360 175 3.15 3.440 17.02 0 0 3 2


如果你希望数据显示带有额外的格式,可以使用[`knitr::kable()`](https://rdrr.io/pkg/knitr/man/kable.xhtml)函数。下面的代码生成 Table 28-1:

knitr::kable(mtcars[1:5, ], )


表 28-1\. 一个 knitr kable

|   | mpg | cyl | disp | hp | drat | wt | qsec | vs | am | gear | carb |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Mazda RX4 | 21.0 | 6 | 160 | 110 | 3.90 | 2.620 | 16.46 | 0 | 1 | 4 | 4 |
| Mazda RX4 Wag | 21.0 | 6 | 160 | 110 | 3.90 | 2.875 | 17.02 | 0 | 1 | 4 | 4 |
| Datsun 710 | 22.8 | 4 | 108 | 93 | 3.85 | 2.320 | 18.61 | 1 | 1 | 4 | 1 |
| Hornet 4 Drive | 21.4 | 6 | 258 | 110 | 3.08 | 3.215 | 19.44 | 1 | 0 | 3 | 1 |
| Hornet Sportabout | 18.7 | 8 | 360 | 175 | 3.15 | 3.440 | 17.02 | 0 | 0 | 3 | 2 |

阅读[`?knitr::kable`](https://rdrr.io/pkg/knitr/man/kable.xhtml)的文档,了解如何使用其他方式自定义表格。要进行更深入的定制,考虑使用 gt、huxtable、reactable、kableExtra、xtable、stargazer、pander、tables 和 ascii 包。每个包都提供了一套工具,用于从 R 代码返回格式化的表格。

## 练习

1.  在可视编辑器中打开`diamond-sizes.qmd`,插入一个代码块,并使用[`knitr::kable()`](https://rdrr.io/pkg/knitr/man/kable.xhtml)创建一个显示`diamonds`数据帧前五行的表格。

1.  使用[`gt::gt()`](https://gt.rstudio.com/reference/gt.xhtml)显示相同的表格。

1.  添加一个以`tbl-`前缀开头的块标签,并使用块选项`tbl-cap`为表格添加标题。然后,编辑代码块上方的文本,使用插入 > 交叉引用来引用表格。

# 缓存

通常,文档的每次渲染都从一个完全干净的状态开始。这对于可重现性很重要,因为它确保你已经在代码中捕获了每一个重要的计算过程。然而,如果有一些需要很长时间的计算,这可能会很痛苦。解决方案是`cache: true`。

您可以使用标准的 YAML 选项在文档级别启用 knitr 缓存,用于缓存文档中所有计算的结果:


title: "My Document"
execute:
cache: true


您还可以在块级别启用缓存,用于缓存特定块中计算的结果:


#| cache: true

# 长时间计算的代码...


设置后,这将把块的输出保存到一个特别命名的文件中。在后续运行中,knitr 会检查代码是否发生了变化,如果没有,它将重用缓存的结果。

缓存系统必须小心使用,因为默认情况下仅基于代码而不是其依赖项。例如,这里的`processed_data`块依赖于`raw-data`块:


#| label: raw-data

#| cache: true

rawdata <- readr::read_csv("a_very_large_file.csv")


#| label: processed_data

#| cache: true

processed_data <- rawdata |>

filter(!is.na(import_var)) |>

mutate(new_variable = complicated_transformation(x, y, z))


缓存`processed_data`块意味着如果 dplyr 管道发生更改,它将重新运行,但如果`read_csv()`调用发生更改,则不会重新运行。您可以使用`dependson`块选项避免这个问题:


#| label: processed-data

#| cache: true

#| dependson: "raw-data"

processed_data <- rawdata |>

filter(!is.na(import_var)) |>

mutate(new_variable = complicated_transformation(x, y, z))


`dependson`应该包含一个字符向量,其中包含缓存块依赖的*每个*块。Knitr 会在检测到其依赖项发生更改时更新缓存块的结果。

注意,如果`a_very_large_file.csv`发生更改,块将不会更新,因为 knitr 缓存仅跟踪`.qmd`文件内的更改。如果您还想跟踪该文件的更改,可以使用`cache.extra`选项。这是一个任意的 R 表达式,每当它更改时都会使缓存无效。可以使用一个好的函数[`file.mtime()`](https://rdrr.io/r/base/file.info.xhtml):它返回文件的最后修改时间。然后您可以写:


#| label: raw-data

#| cache: true

#| cache.extra: !expr file.mtime("a_very_large_file.csv")

rawdata <- readr::read_csv("a_very_large_file.csv")


我们遵循了[David Robinson](https://oreil.ly/yvPFt)的建议,为这些块命名:每个块都以它创建的主要对象命名。这样更容易理解`dependson`规范。

随着您的缓存策略变得越来越复杂,定期使用[`knitr::clean_cache()`](https://rdrr.io/pkg/knitr/man/clean_cache.xhtml)清除所有缓存是一个好主意。

## 练习

1.  设置一个基于网络块的网络,其中`d`依赖于`c`和`b`,而`b`和`c`都依赖于`a`。让每个块打印[`lubridate::now()`](https://lubridate.tidyverse.org/reference/now.xhtml),设置`cache: true`,然后验证你对缓存的理解。

# 故障排除

调试 Quarto 文档可能会很具有挑战性,因为您不再处于交互式 R 环境中,您需要学习一些新技巧。此外,错误可能是由于 Quarto 文档本身的问题或 Quarto 文档中的 R 代码引起的。

代码块中常见的一个错误是重复的块标签,如果您的工作流程涉及复制和粘贴代码块,则这种错误尤其普遍。要解决此问题,您只需更改其中一个重复的标签即可。

如果错误是由文档中的 R 代码引起的,你应该尝试的第一件事是在交互式会话中重新创建问题。重新启动 R,然后从代码菜单下的运行区域或按键盘快捷键 Ctrl+Alt+R 选择“运行所有块”。如果你幸运的话,这将重新创建问题,然后你可以进行交互式的问题排查。

如果这没有帮助,可能是您的交互环境与 Quarto 环境之间有所不同。您需要系统地探索选项。最常见的差异是工作目录:Quarto 的工作目录是它所在的目录。通过在一个代码块中包含 [`getwd()`](https://rdrr.io/r/base/getwd.xhtml) 来检查您所期望的工作目录是否正确。

接下来,列出可能导致错误的所有事项。您需要系统地检查这些事项在您的 R 会话和 Quarto 会话中是否相同。完成这项工作的最简单方法是在引起问题的代码块上设置 `error: true`,然后使用 [`print()`](https://rdrr.io/r/base/print.xhtml) 和 [`str()`](https://rdrr.io/r/utils/str.xhtml) 检查设置是否符合您的期望。

# YAML 头部

通过调整 YAML 头部参数,您可以控制许多其他“整个文档”设置。您可能会好奇 YAML 是什么意思:它代表“YAML Ain’t Markup Language”,旨在以一种易于人类阅读和编写的方式表示分层数据。Quarto 使用它来控制输出的许多细节。在这里,我们将讨论三个方面:自包含文档、文档参数和文献目录。

## 自包含

HTML 文档通常具有许多外部依赖项(例如图片、CSS 样式表、JavaScript 等),默认情况下,Quarto 将这些依赖项放在与您的 `.qmd` 文件相同目录下的 `_files` 文件夹中。如果您将 HTML 文件发布到托管平台(例如 [QuartoPub](https://oreil.ly/SF3Pm)),此目录中的依赖项将与您的文档一起发布,因此在发布的报告中可用。然而,如果您希望将报告通过电子邮件发送给同事,您可能更喜欢拥有一个单一的、自包含的 HTML 文档,其中嵌入了所有的依赖项。您可以通过指定 `embed-resources` 选项来实现这一点。

format:
html:
embed-resources: true


生成的文件将是自包含的,因此它将不需要外部文件,也不需要通过互联网访问才能由浏览器正确显示。

## 参数

Quarto 文档可以包含一个或多个参数,这些参数的值可以在渲染报告时设置。当您希望使用不同的值重新渲染相同的报告以获取各种关键输入时,参数非常有用。例如,您可能正在生成分部门的销售报告,按学生的考试成绩报告,或者按国家的人口统计摘要报告。要声明一个或多个参数,请使用 `params` 字段。

本示例使用一个 `my_class` 参数来确定要显示哪个车型类别:


format: html
params:
my_class: "suv"


#| 标签: setup

#| 包含: false

library(tidyverse)

class <- mpg |> filter(class == params$my_class)

Fuel economy for r params$my_classs


#| 消息: false

ggplot(class, aes(x = displ, y = hwy)) +

geom_point() +

geom_smooth(se = FALSE)


正如您所看到的,参数在代码块内部作为一个名为 `params` 的只读列表可用。

您可以直接将原子向量写入 YAML 头文件中。您还可以通过在参数值前加上 `!expr` 来运行任意的 R 表达式。这是指定日期/时间参数的好方法。

params:
start: !expr lubridate::ymd("2015-01-01")
snapshot: !expr lubridate::ymd_hms("2015-01-01 12:30:00")


## 参考文献和引文

Quarto 可以自动以多种样式生成引文和参考文献。向 Quarto 文档添加引文和参考文献的最简单方式是使用 RStudio 中的视觉编辑器。

要在视觉编辑器中添加引用,请选择插入 > 引用。可以从多种来源插入引用:

+   [DOI](https://oreil.ly/sxxlC) 引用

+   [Zotero](https://oreil.ly/BDpHv) 个人或群组库。

+   [Crossref](https://oreil.ly/BpPdW)、[DataCite](https://oreil.ly/vSwdK) 或 [PubMed](https://oreil.ly/Hd2Ey) 的搜索。

+   你的文档参考文献(位于文档目录中的 `.bib` 文件)

在视觉模式下,使用标准的 Pandoc Markdown 表示来引用(例如,`[@citation]`)。

如果使用前三种方法之一添加引用,视觉编辑器将自动为您创建一个 `bibliography.bib` 文件,并将引用添加到其中。它还将在文档 YAML 中添加一个 `bibliography` 字段。随着您添加更多引用,该文件将填充其引文。您还可以直接使用包括 BibLaTeX、BibTeX、EndNote 和 Medline 在内的多种常见参考文献格式编辑此文件。

要在源编辑器中的 `.qmd` 文件中创建引用,请使用由参考文献文件中引用标识符组成的键。然后将引用放在方括号内。以下是一些示例:

Separate multiple citations with a ;: Blah blah [@smith04; @doe99].

You can add arbitrary comments inside the square brackets:
Blah blah [see @doe99, pp. 33-35; also @smith04, ch. 1].

Remove the square brackets to create an in-text citation: @smith04
says blah, or @smith04 [p. 33] says blah.

Add a - before the citation to suppress the author's name:
Smith says blah [-@smith04].


当 Quarto 渲染您的文件时,它将构建并附加参考文献到文档的末尾。参考文献将包含您的参考文献文件中每个引用的引文,但不会包含章节标题。因此,通常建议在文档的末尾加上一个参考文献的章节标题,例如 `# References` 或 `# Bibliography`。

你可以通过引用引文样式语言(CSL)文件中的 `csl` 字段来更改引文和参考文献的样式:

bibliography: rmarkdown.bib
csl: apa.csl


与参考文献字段一样,您的 CSL 文件应包含指向文件的路径。在这里,我们假设 CSL 文件与 `.qmd` 文件在同一个目录中。查找常见参考文献样式的 CSL 样式文件的好地方是 [citation styles 的官方仓库](https://oreil.ly/bYJez)。

# 工作流程

早些时候,我们讨论了一个捕获您的 R 代码的基本工作流程,您可以在其中以交互方式在 *控制台* 中工作,然后在 *脚本编辑器* 中捕获有效的内容。Quarto 将控制台和脚本编辑器结合在一起,模糊了交互式探索和长期代码捕获之间的界限。您可以在一个代码块内快速迭代,编辑并重新执行,快捷键为 Cmd/Ctrl+Shift+Enter。当您满意时,可以继续并开始一个新的代码块。

Quarto 也很重要,因为它紧密集成了文本和代码。这使它成为一个很棒的*分析笔记本*,因为它允许你开发代码并记录你的想法。分析笔记本与物理科学中的经典实验室笔记有许多相同的目标。它:

+   记录你做了什么以及为什么这样做。无论你的记忆力有多好,如果不记录你的行动,总会有一天你会忘记重要的细节。记下来,这样你就不会忘记!

+   支持严谨的思维。如果你记录下你的思考过程并持续反思,你很可能会得出一个强大的分析结果。这也会节省你在最终撰写分析报告时的时间,以便与他人分享。

+   帮助他人理解你的工作。很少有人单独进行数据分析,你经常会作为团队的一部分工作。实验室笔记帮助你与同事或实验室伙伴分享你做了什么以及为什么这样做。

许多关于有效使用实验室笔记的好建议也可以转化为分析笔记。我们借鉴了我们自己的经验和 Colin Purrington 关于[实验室笔记](https://oreil.ly/n1pLD)的建议,提出以下几点建议:

+   确保每个笔记本都有一个描述性的标题,一个唤起兴趣的文件名,以及一个简要描述分析目标的第一个段落。

+   使用 YAML 头部的日期字段记录你开始在笔记本上工作的日期:

    ```
    date: 2016-08-23
    ```

    使用 ISO8601 的 YYYY-MM-DD 格式以消除任何歧义。即使你平时不以这种方式写日期也要使用它!

+   如果你花费了大量时间在一个分析想法上,结果发现是死胡同,不要删除它!写下一个简短的说明,解释为什么失败,并将其留在笔记本中。这样做将有助于你在未来回到分析时避免陷入同样的死胡同。

+   一般来说,最好在 R 之外进行数据输入。但如果你确实需要记录一小段数据,清晰地使用[`tibble::tribble()`](https://tibble.tidyverse.org/reference/tribble.xhtml)布局它。

+   如果你发现数据文件中的错误,永远不要直接修改它,而是编写代码来更正值。解释为什么进行了修正。

+   在一天结束前,确保你可以渲染笔记本。如果你使用了缓存,请确保清除缓存。这样做可以让你在代码还在脑海中清晰时解决任何问题。

+   如果你希望你的代码长期可复制(即,你可以在下个月或明年回来运行它),你需要追踪你的代码使用的包的版本。一个严谨的方法是使用[*renv*](https://oreil.ly/_I4xb),它将包存储在你的项目目录中。一个快速而不太正式的方法是包含一个运行[`sessionInfo()`](https://rdrr.io/r/utils/sessionInfo.xhtml)的代码块——这不会让你轻松地重新创建今天的包,但至少你会知道它们是什么。

+   在您的职业生涯中,您将创建许多分析笔记本。您要如何组织它们以便将来再次找到?我们建议将它们存储在单独的项目中,并制定一个良好的命名方案。

# 总结

本章为您介绍了 Quarto,用于编写和发布包含代码和文本的可重现计算文档。您了解了如何在 RStudio 中使用可视化或源代码编辑器编写 Quarto 文档,代码块的工作原理以及如何自定义其选项,如何在 Quarto 文档中包含图形和表格,以及计算缓存的选项。此外,您还学习了如何调整 YAML 头部选项以创建自包含或参数化文档,以及如何包含引用和参考文献。我们还为您提供了一些故障排除和工作流程提示。

尽管本介绍足以让您开始使用 Quarto,但还有很多内容需要学习。Quarto 目前仍然比较年轻,并且正在快速发展中。保持最新的最佳方式是访问官方[Quarto 网站](https://oreil.ly/_6LNH)。

还有两个重要的主题我们在这里没有涉及到:协作和准确传达您的想法细节给其他人。协作是现代数据科学的重要组成部分,您可以通过使用像 Git 和 GitHub 这样的版本控制工具大大简化生活。我们推荐由 Jenny Bryan 撰写的《Happy Git with R》,这是一本面向 R 用户的用户友好的介绍 Git 和 GitHub 的书籍,可以在[网上免费阅读](https://oreil.ly/bzjrw)。

我们还没有讨论您实际应该写什么来清楚地传达分析结果。为了提高您的写作水平,我们强烈推荐阅读约瑟夫·M·威廉姆斯和约瑟夫·比扎普合著的《风格:清晰与优雅的写作课程》(Pearson),或者乔治·戈彭的《结构的感觉:从读者的角度写作》(Pearson)。这两本书将帮助您理解句子和段落的结构,并为您提供使写作更清晰的工具。(这些书籍如果新购买会比较昂贵,但很多英语课程使用,因此有很多便宜的二手复印本。)乔治·戈彭还有一些关于写作的[短文](https://oreil.ly/qS7tS),虽然主要面向律师,但几乎所有内容也适用于数据科学家。


# 第二十九章:Quarto 格式

# 简介

到目前为止,您已经看到 Quarto 用于生成 HTML 文档。本章简要概述了 Quarto 可以生成的许多其他类型的输出。

有两种设置文档输出的方法:

+   永久地,通过修改 YAML 头部:

    ```
    title: "Diamond sizes"
    format: html
    ```

+   临时地,通过手动调用 `quarto::quarto_render()`:

    ```
    quarto::quarto_render("diamond-sizes.qmd", output_format = "docx")
    ```

    如果要以编程方式生成多种类型的输出,这是很有用的,因为 `output_format` 参数也可以接受一个值列表:

    ```
    quarto::quarto_render(
      "diamond-sizes.qmd", output_format = c("docx", "pdf")
    )
    ```

# 输出选项

Quarto 提供了多种输出格式。你可以在[Quarto 文档中查看所有格式](https://oreil.ly/mhYNQ)的完整列表。许多格式共享一些输出选项(例如,`toc: true`用于包含目录),但其他格式具有特定于格式的选项(例如,`code-fold: true`将代码块折叠到 HTML 输出的 `<details>` 标签中,用户可以按需显示;在 PDF 或 Word 文档中不适用)。

覦盖默认选项,需要使用扩展的 `format` 字段。例如,如果要呈现带有浮动目录的 HTML 文档,可以使用:

format:
html:
toc: true
toc_float: true


通过提供格式列表,甚至可以呈现多个输出:

format:
html:
toc: true
toc_float: true
pdf: default
docx: default


注意特殊语法(`pdf: default`),如果不想覆盖任何默认选项。

要将文档中 YAML 中指定的所有格式呈现出来,可以使用 `output_format = "all"`:

quarto::quarto_render("diamond-sizes.qmd", output_format = "all")


# 文档

前一章重点介绍了默认的 `html` 输出。有几种基本的变体,生成不同类型的文档。例如:

+   使用 LaTeX 制作 PDF(一种开源文档布局系统),您需要安装。如果尚未安装,RStudio 会提示您。

+   用于 Microsoft Word(`.docx`)文档的 `docx`。

+   用于 OpenDocument Text(`.odt`)文档的 `odt`。

+   用于 Rich Text Format(`.rtf`)文档的 `rtf`。

+   用于 GitHub Flavored Markdown(`.md`)文档的 `gfm`。

+   用于 Jupyter Notebooks(`.ipynb`)的 `ipynb`。

请记住,在生成要与决策者分享的文档时,您可以通过在文档的 YAML 中设置全局选项来关闭默认显示代码。

execute:
echo: false


对于 HTML 文档,另一种选择是默认隐藏代码块,但可以通过点击显示:

format:
html:
code: true


# 演示文稿

您还可以使用 Quarto 制作演示文稿。与 Keynote 或 PowerPoint 等工具相比,您的视觉控制较少,但自动将 R 代码的结果插入演示文稿可以节省大量时间。演示文稿通过将内容分成幻灯片来工作,每个第二级标题(`##`)开始一个新幻灯片。此外,第一级标题(`#`)指示新部分的开始,具有默认情况下在中间居中的部分标题幻灯片。

Quarto 支持多种演示文稿格式,包括:

`revealjs`

使用 revealjs 制作 HTML 演示文稿

`pptx`

PowerPoint 演示文稿

`beamer`

使用 LaTeX Beamer 制作 PDF 演示文稿

您可以阅读更多关于使用 [Quarto](https://oreil.ly/Jg7T9) 创建演示文稿的信息。

# 交互性

就像任何 HTML 文档一样,使用 Quarto 创建的 HTML 文档也可以包含交互组件。这里我们介绍两种在 Quarto 文档中包含交互性的选项:htmlwidgets 和 Shiny。

## htmlwidgets

HTML 是一种交互格式,您可以利用 *htmlwidgets* 来实现交互式 HTML 可视化。例如,下面显示的 *leaflet* 地图。如果您在网页上查看此页面,可以拖动地图,放大和缩小等操作。在书籍中当然无法做到这一点,因此 Quarto 会自动为您插入静态截图。

library(leaflet)
leaflet() |>
setView(174.764, -36.877, zoom = 16) |>
addTiles() |>
addMarkers(174.764, -36.877, popup = "Maungawhau")


![Maungawhau/Mount Eden 的 leaflet 地图。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_29in01.png)

htmlwidgets 的一个很棒之处在于,您不需要了解 HTML 或 JavaScript 就能使用它们。所有细节都封装在包内部,因此您无需担心这些。

有许多提供 htmlwidgets 的包,包括:

+   [dygraphs](https://oreil.ly/SE3qV) 用于交互式时间序列可视化

+   [DT](https://oreil.ly/l3tFl) 用于交互式表格

+   [threejs](https://oreil.ly/LQZud) 用于交互式 3D 绘图

+   [DiagrammeR](https://oreil.ly/gQork) 用于制作图表(如流程图和简单的节点链接图)

要了解更多关于 htmlwidgets 并查看提供它们的完整包列表,请访问 [*https://oreil.ly/lmdha*](https://oreil.ly/lmdha)。

## Shiny

htmlwidgets 提供 *客户端* 交互性——所有交互都在浏览器中完成,与 R 独立运行。这很棒,因为您可以在没有与 R 的任何连接的情况下分发 HTML 文件。但这基本上限制了您只能做那些已经在 HTML 和 JavaScript 中实现的事情。另一种方法是使用 shiny,这是一个允许您使用 R 代码创建交互性的包,而不是 JavaScript。

要从 Quarto 文档调用 Shiny 代码,请在 YAML 头部添加 `server: shiny`:

title: "Shiny Web App"
format: html
server: shiny


然后,您可以使用“input”功能向文档添加交互组件:

library(shiny)

textInput("name", "What is your name?")
numericInput("age", "How old are you?", NA, min = 0, max = 150)


![两个叠放在一起的输入框。顶部显示“你叫什么名字?”,底部显示“你多大了?”。](https://github.com/OpenDocCN/ibooker-ds-zh/raw/master/docs/r-ds-2e/img/rds2_29in02.png)

您还需要一个带有选项 `context: server` 的代码块,其中包含需要在 Shiny 服务器中运行的代码。

您可以通过 `input$name` 和 `input$age` 引用值,并且每当它们更改时,使用它们的代码将自动重新运行。

我们无法在这里展示一个实时的 Shiny 应用程序,因为 Shiny 的交互发生在*服务器端*。这意味着您可以在不了解 JavaScript 的情况下编写交互式应用程序,但您需要一个服务器来运行它们。这引入了一个后勤问题:Shiny 应用程序需要 Shiny 服务器才能在线运行。当您在自己的计算机上运行 Shiny 应用程序时,Shiny 会自动为您设置一个 Shiny 服务器,但如果您想要发布此类交互性在线,您需要一个面向公众的 Shiny 服务器。这就是 Shiny 的基本权衡:您可以在 Shiny 文档中做任何在 R 中可以做的事情,但它需要有人在运行 R。

要了解更多关于 Shiny 的信息,我们建议阅读[*Mastering Shiny*](https://oreil.ly/4Id6V),由 Hadley Wickham 撰写。

# 网站和书籍

借助一些额外的基础设施,您可以使用 Quarto 生成一个完整的网站或书籍:

+   将您的`.qmd`文件放在一个单独的目录中。`index.qmd`将成为主页。

+   添加一个名为`_quarto.yml`的 YAML 文件,为网站提供导航。在此文件中,将`project`类型设置为`book`或`website`,例如:

    ```
    project:
      type: book
    ```

例如,以下`_quarto.yml`文件从三个源文件创建一个网站:`index.qmd`(主页)、`viridis-colors.qmd`和`terrain-colors.qmd`。

project:
type: website

website:
title: "A website on color scales"
navbar:
left:
- href: index.qmd
text: Home
- href: viridis-colors.qmd
text: Viridis colors
- href: terrain-colors.qmd
text: Terrain colors


你需要为书籍准备的`_quarto.yml`文件结构类似。下面的示例展示了如何创建一个包含四章的书籍,可以渲染为三种不同的输出(`html`,`pdf`和`epub`)。再次强调,源文件是`.qmd`文件。

project:
type: book

book:
title: "A book on color scales"
author: "Jane Coloriste"
chapters:
- index.qmd
- intro.qmd
- viridis-colors.qmd
- terrain-colors.qmd

format:
html:
theme: cosmo
pdf: default
epub: default


我们建议您为您的网站和书籍使用 RStudio 项目。基于`_quarto.yml`文件,RStudio 将识别您正在处理的项目类型,并添加一个“Build”选项卡到 IDE,您可以使用它来渲染和预览您的网站和书籍。网站和书籍也可以使用`quarto::render()`来渲染。

了解更多关于[Quarto 网站](https://oreil.ly/P-n37)和[书籍](https://oreil.ly/fiB1h)的信息。

# 其他格式

Quarto 提供了更多的输出格式:

+   您可以使用[Quarto Journal Templates](https://oreil.ly/ovWgb)来撰写期刊文章。

+   您可以使用[`format: ipynb`](https://oreil.ly/q-E7l)将 Quarto 文档输出为 Jupyter 笔记本。

查看[Quarto 格式文档](https://oreil.ly/-iGxF)以获取更多格式的列表。

# 总结

在本章中,我们为您介绍了使用 Quarto 与外界交流结果的多种选择,从静态和交互式文档到演示文稿、网站和书籍。

要了解这些不同格式中的有效沟通更多信息,我们推荐以下资源:

+   要提高您的演讲技能,尝试[*Presentation Patterns*](https://oreil.ly/JnOwJ),由 Neal Ford、Matthew McCollough 和 Nathaniel Schutta 撰写。它提供了一组有效的模式(低级和高级),可供您应用于改进您的演示文稿。

+   如果您进行学术演讲,您可能会喜欢[“The Leek group guide to giving talks”](https://oreil.ly/ST4yc)。

+   我们自己并没有参加过,但我们听说过 Matt McGarrity 关于[公众演讲](https://oreil.ly/lXY9u)的在线课程很受好评。

+   如果你正在创建许多仪表板,请务必阅读斯蒂芬·费的*《信息仪表板设计:有效的视觉数据沟通》*(O’Reilly 出版社)。它将帮助你创建真正有用的仪表板,而不仅仅是好看的外表。

+   有效地传达你的思想通常受益于一些关于图形设计的知识。罗宾·威廉姆斯的*《非设计师的设计书》*(Peachpit 出版社)是一个很好的入门书籍。
posted @ 2025-11-19 09:21  绝不原创的飞龙  阅读(32)  评论(0)    收藏  举报