tidyverse工具链 (2):数据操作

更新日志:

  • 2018.4.10
    • 初版;

这一篇我们主要介绍 dplyr 这个包。dplyr 主要提供了一种类似 SQL 的语法来操作数据,可以把一个 data.frame 看成一张表,然后从里面进行查询和计算。

我认为掌握一个工具的使用方法应该有两方面:

  1. 了解这个工具到底能干什么;
  2. 了解在如何组合这个工具的功能来实现需要的效果;

其中第 2 点是建立在第 1 点的基础之上的。如果你发现工具用着不顺手,要么是你用错了工具,要么是你没有选对使用的方法。 所以只有对 tidyverse 生态充分熟悉才能让我们用起它来更加得心应手。我们要从它的功能框架出发去思考如何用它来完成我们的任务。

本篇与上一篇不同的地方在于,它不是结合具体的使用场景来介绍相关的函数,而是从函数出发去了解 dplyr 的功能。它更像是一个手册,在需要的时候查阅。希望从这个“手册”之中可以理清我们对数据进行操作所应具有的思维方式。

为了举例方便,我们在这里设定两个示例表。第一张表是全年级成绩单 score,第二张是全年级学生的资料 students。表的结构如下:

student_id subject score
1001 math 87
1001 chinese 92
1001 english 70
1002 chinese 88
student_id name gender age class telephone height weight address
1001 John M 16 2 1234567 172 69 Philadelphia
1002 Peter M 15 1 5757884 174 72 Seattle
1003 Mary F 15 2 4458927 163 49 Washington

新的数据结构:tibble

R 语言内置的数据结构中,对数据分析最有用的就是 data.frame 了。在 RPython 之间做艰难抉择的时候,我也是由于 R 内置了这一数据结构而倒向了 Rdata.frame 本身其实就是一张。广义上来讲(即数据不一定清洁),表的每行都是一条记录,每列都是一个变量。

然而,data.frame 本身内置于 R 中也经历了十几二十年了。有些当初觉得有用的设计如今已经落伍,但出于向下兼容性的考虑依然被保留在其中。所以 tibble 作为一个更“好用”的 data.frame 被纳入到 tidyverse 系列中来,广泛用于在整个工具链中数据的交换和流转。所以在使用 tibble 之后,我们将很少再使用原来对 data.frame 的那一套操作了。 tibble 将作为所有数据操作函数输入和输出的标准格式

tibbledata.frame 在很多地方要先进。我们感受最强烈的应该是列的命名方面,因为它支持很多非法字符出现在列名之中,比如空格;也支持一些原来不支持的命名方式,比如以数字作为列名的开头。这些“非法”的列名在引用的时候必须要用“ ` ”字符引起来(键盘上上方的那个键)。如果想具体了解 tibble,可以阅读 R for Data Science 中的相关章节

于是,今后我们说到一张“表”的时候,我们都将默认它是一个 tibble

SQL 与 dplyr

如果熟悉 SQL 的话,理解 dplyr 应该是一件非常容易的事。SQL 为我们提供的基本查询有如下几种:

  1. SELECT:选择一张表里面的某些列;
  2. WHEREHAVING):选择满足某些条件的行(组);
  3. GROUP BY:按照某个变量进行分组;
  4. JOIN:以某个键值关联另一张表进行跨表查询;
  5. DISTINCT:选择表中记录内容不同的行;
  6. ORDER BY:按照一定的顺序排列查询结果;

我们依次来看这些操作应该如何实现。

SELECT 操作

在 SQL 的SELECT 操作中,我们可以实现两方面的功能。其一是选择我们需要的列,其二是将其中部分列重命名。这些功能都可以通过 select() 函数来实现。

选择需要的列

在 SQL 中,我们这样选择需要的列:

1
SELECT student_id, name FROM students;

如果在 R 中使用 select() 函数,用法是:

1
select(students, student_id, name)

其中 student_idname 就是列的名字,不需要加引号,并且可以加任意多个。在经过这一操作之后,没有被选的列就都会消失。这里我们也可以用列的序号来选择列,比如 select(students, 1, 2) 就会选择第一列和第二列。而 select(students, student_id:telephone) 就可以选择从 student_idtelephone 之间的所有列。

这样的选择方式之余,我们还可以以列的名字作为标准来进行选择。例如我想选择所有以 col 打头的列,就可以用 select(table1, starts_with("col"))。类似的函数有很多,可以查看这个文档。在面对批量选择的时候,里面的函数都是非常有用的。

重命名和列的重排

在使用 select() 函数的时候,我们可以顺路更改它们原来的名字。比如 select(students, name1 = name, age1 = age) 就可以把原来的 nameage 分别改成 name1age1,而保持它们的内容不变。如果只是单纯地想重命名(一般用不到这个功能),可以使用 rename() 函数。

在使用 select() 的时候,输出的表中列的顺序与你在函数中输入的顺序有关。所以可以用这种方式去在输出数据之前对列的顺序进行调整。

WHEREHAVING 操作

在 SQL 中,WHERE 子句是跟在 SELECT 后面的。在 dplyr 中我们用 filter() 来过滤出满足条件的列。它可以添加多个过滤条件,每个条件都用一个逻辑表达式来定义。将它们用逗号分隔则会用逻辑与 (&) 来对条件进行连接。如果想用逻辑或 (|) 的话就不能用逗号。这里一定注意逻辑与的优先级比逻辑或要高。

注:在 C 系语言中逻辑与是用 && 来表示的。但在 R 中这一运算符表示的是标量之间的逻辑与运算,只能计算一个值和一个值的逻辑与结果,不能计算一列值和一列值的逻辑与结果。因此在 R 中尽量用 &| 来表示逻辑与和逻辑或。

在 SQL 中 WHEREHAVING 的意义是有严格不同的,HAVING 是针对组的操作。而在 dplyr 中这两者不作区分,任何时候你想要过滤行,就可以用 filter() 函数来操作。例如我想选出所有 2 班的学生,就可以用 filter(students, class == 2)。如果想要 2 班的男同学,就可以用 filter(students, class == 2, gender == 'M')

GROUP BY 操作

GROUP BY 对应的函数是 group_by()。一般我们放进 group_by() 的变量都是离散型的。如果放入多个变量,那么只要它们的组合不一样,就会分为多个组。如果对它输出的结果进行计算,那么所有的计算都将分组进行。在经过一次分组操作后如果想重新分组,必须先进行 ungroup() 操作。由于分组会在 tibble 中一直保留,所以我建议在任何情况下都务必记得在计算完之后把分组取消。

如果要对组进行聚合,可以使用 summarise(),输出数据中每一组都将只保留一行。例如我们有一张全年级本次考试的成绩单 score,想计算每个科目的平均分,就可以用如下语句:

1
2
scores_grouped <- group_by(score, subject)
scores_avg <- summarise(scores_grouped, mean_score = mean(score))

在分组或聚合的时候,以下函数会非常有用:

  • n():相当于 SQL 的 COUNT(*),计算组内行的数量;
  • n_distinct():计算组内不同记录的数量;
  • lead()/lag():返回一列值的前趋和后继;
  • nth()/first()/last():返回一列值中的第 $n$ 个,第一个或最后一个;
  • 排序类函数:按不同的标准和规则进行分组排序;

JOIN 操作

在 SQL 中的关联操作有四种:

  • 内联结 INNER JOIN:只保留匹配成功的行;
  • 左联结 LEFT JOIN:左表全部保留,存在未匹配的行;
  • 右联结 RIGHT JOIN:右表全部保留,存在未匹配的行;
  • 全联结 FULL JOIN:所有行全部保留;

dplyr 中有一系列函数来进行关联:

  • inner_join():内联结;
  • left_join():左联结;
  • right_join():右联结;
  • full_join():全联结;
  • anti_join():反联结,保留左表中所有没有匹配成功的行(相当于 SQL 中的 NOT EXISTS 子句);
  • semi_join():半联结,只保留左表中和右表有匹配的行(如果有多个匹配不重复,相当于 SQL 中的 EXISTS 子句);

它们的具体描述和差异可以参见这个文档。比较简单地,如果我想知道成绩单里面的每个同学都在哪个班,但班级信息在 students 表里面,这时我就需要用关联操作:

1
inner_join(score_math, students, by = "student_id")

DISTINCT 操作

在数据清理好之后我们有可能遇到重复的记录,这个时候我们就需要用 distinct() 来处理。比如我们有两个成绩单,分别记录了本次考试所有学生的语文、数学成绩(score_12)和数学、英语成绩(score_23),那么我们把它们按行合并在一起的时候(score_123),所有人的数学成绩就会出现两条记录。这个时候我们就需要过滤出独特的学号和学科的组合,即

1
distinct(score_123, student_id, subject)

但是我们会发现,这个语句的输出里面把分数给丢了,因为默认情况下它只保留你选取的列。这里我们有两种做法,一种是设置 .keep_all = TRUE,即

1
distinct(score_123, student_id, subject, .keep_all = TRUE)

这种做法下,对于重复的记录,它只会保留对应其他列的第一行。比如某个学生的数学成绩有两条不一样的记录,在操作之后只有第一条会被保留下来。它的效果相当于

1
summarise(group_by(score_123, student_id, subject), score = first(score))

另一种是把分数也加入到组合里面来, 即

1
distinct(score_123, student_id, subject, score)

但如果出现了前面提到的记录不一致的问题,这种做法是无法完成去重的。

ORDER BY 操作

对数据的排序我们用 arrange() 函数来完成。例如要对全年级数学成绩单中所有的学生按成绩降序排列,就可以用

1
arrange(score_math, desc(score))

这里 desc() 函数可以把升序改为降序。如果在后面再跟一些变量的话,它会优先按照靠前的变量进行排序。

计算变量

当我们不满足于原有的数据,希望对当前表进行计算的话,就需要使用 mutate() 函数。mutate() 函数就可以根据当前表中的列计算出新的列。一次调用可以创建多个变量,并且你可以用刚刚算出的新变量来再进行计算。如果你的计算变量和分组有关,先分组再计算也是支持的。后面我们会给出具体的例子。

其他的函数

SQL 里面还有两个关于数的运算符:INBETWEENR 里面提供了 %in% 运算符(等效于 match() 函数)和 between() 函数,方便我们进行类似的操作。另外一个有趣的函数是 near(),专门用于判断浮点数是否相等。

注:如果 == 运算符的左值和右值都是浮点数的话,由于精度的限制,即使是数学上完全相等的两个数(比如 $2$$(\sqrt{2})^2$)它们有的时候并不能严格地相等。所以在面对这样的情况时,使用 near() 会是一个比较好的选择。

dplyr 还为我们提供了一些比较实用的函数,让我们完全离开原来的 data.frame 操作。

  • pull():返回 tibble 的某一个列,返回值为一个向量。
  • bind_rows():等效于原来的 rbind() 函数,按行将两个 tibble 合并到一起,相当于在 SQL 里面的 UNION ALL
  • bind_cols():等效于原来的 cbind() 函数,按列将两个 tibble 合并到一起。这里只是简单的合并在一起,不会进行关联。
  • sample_n()/sample_frac():按数量或比例从数据集中抽样。

链式操作

管道运算符 %>%

在上一节我们提到了很多函数,除了 pull() 之外都无一例外地以一个 tibble 作为输入的第一个参数,并以一个 tibble 作为输出。所有的函数几乎都和 SQL 中的基础查询功能一一对应。但我们知道,在实际的操作过程中,它们是需要被按照一定顺序连续使用的,也就是说我们需要把一个函数的输出作为下一个函数的输入。在原生的 R 中我们有两种方式实现:

  • 写很长很长的嵌套语句,把整个操作合并在一个语句中写完;
  • 存储很多个临时变量,每一条语句进行一个操作;

这两种方式都是有明显弊端的。第一种方案写出来的语句由于多层的嵌套和大量参数的干扰几乎不可读,也很难维护。第二种方案如果面对一个比较大的表,对空间的消耗就会非常可观,而手动去处理这些临时变量也是一件非常头疼的事。于是 magrittr 包(tidyverse 的依赖之一)为我们提供了一个管道运算符 %>%,可以把前一个语句的运算结果作为后一个函数调用的第一个参数

这为我们写代码和读代码都提供了巨大的便利。例如我们想计算每个班各科的平均分,我们就可以这么写:

1
2
3
4
5
scores %>%
inner_join(students, by = "student_id") %>%
group_by(subject, class) %>%
summarise(avg_score = mean(score)) %>%
ungroup()

这样看来,整个的操作就变得非常地清晰可读。score 作为整个处理的原始数据,经历如下四步处理:

  1. 关联 students 表,取得每个学生的资料;
  2. 按照科目和班级进行分组;
  3. 对每个组里面的分数求均值;
  4. 解除分组;

再举一例,如果我们要对每个人的分数计算标准分,设一个人的得分是 $x$,该科均分为 $\bar x$,标准差为 $\sigma$,则标准分 $S$ 为:

1
S=\frac{x-\bar x}{\sigma}\times 100 + 500

每个学生三科的标准分的均值为其最终得分。于是我们可以这样操作:

1
2
3
4
5
6
7
8
9
10
11
score %>%
group_by(subject) %>%
mutate(
avg_score = mean(score),
sd_score = sd(score),
z_score = (score - avg_score) / sd_score,
S = 100 * z_score + 500
) %>%
ungroup() %>%
group_by(student_id) %>%
summarise(S_final = mean(S))

这里我们注意到,所有的函数调用都少了第一个参数,这个参数是被处理的表,而它正是上一步返回的表。由此来看,这个操作并不是 tidyverse 的专利。比如我想求 100 个随机变量 $x_i\in (0,1)$ 的和,就可以用如下语句:

1
rand(100) %>% sum()

尤其是当前面的语句非常长的时候,我们好不容易写完了发现还要做嵌套,得按一下敲完新的函数名还得按一下回到末尾补上括号,这个时候管道运算符就可以让我们连续流畅地输入了。

分支管道 %T>%

数据从最开始的表到图中间可能经历很多步流水操作,我们有时候也希望保留或者输出中间结果。但是,查看数据的 View() 函数、输出到文本的 write_csv() 函数等,它们的返回值并不是 tibble。这就意味着我们不能直接把它们直接放在链式操作中,需要分支操作。这里我们就需要分支管道 %T>%。于是我们想要的操作可能是这样的:

1
2
3
4
5
6
7
8
9
10
graph LR
A((data)) -->|%>%| B[filter]
B -->|%>%| C[mutate]
B -->|%T>%| E{View}
C -->|%>%| D[select]
D -->|%>%| F[group_by]
F -->|%>%| G[summarise]
G -->|%>%| H[spread]
G -->|%T>%| I{write_csv}
H -->|%>%| J[ggplot]

写成代码就是:

1
2
3
4
5
6
7
8
data %>%
filter(...) %>%
mutate(...) %T>% View() %>%
select(...) %>%
group_by(...) %>%
summarise(...) %>%
spread(...) %T>% write_csv(...) %>%
ggplot() + ...

这样我们可以很方便地查看管道语句的中间结果,也可以方便地把中间结果存储出来。这在调试方面有很大的作用,也可以提高语句运行的效率,避免过多中间变量的产生。

Jeldor wechat
欢迎关注我和天一维护的公众号:两个少年的奇幻漂流!