在 R 中遍历变量生成数据框的最佳实践

在做数据分析的时候循环遍历某一变量,并且生成一个数据框(data.frame)作为报告是一个非常常见的事情,但是究竟怎么写才能让程序跑得比西方记者还快却非常麻烦。众所周知 R 是一个很慢而且语法怪异的语言,用「常规」的思路来写一段 R 脚本,性能可能会非常不理想,而写「性能很好」的脚本,又可能把你的代码变成自带精神污染效果的魔法道具。因此在本文当中我将分享一个用于循环遍历变量生成数据框的编程模板,以期在性能和可读性之间取得平衡。

大方向很简单,把 for loop 禁用掉,尽量使用向量化循环(lapply),同时不使用 sapply;不使用 「Scoping Assignment Operator」(<<-)。

立下这量项基本原则的原因如下:

  • 在 R 里面 for loop 是万恶之源,性能烂到根本没法看,所以在任何情况下都不应当用它;
  • sapply 的行为不稳定,返回结果会做「化简」,但是这种化简并不总是我们想要的(我非常讨厌 R 这种无端的「化简」行为,给 data.frame 切片的时候也有莫名其妙的化简导致切片返回数据类型不稳定);
  • Scoping Assignment Operator 会极大影响代码的可读性,因为跨上下文变量赋值在大多数情况下都很不直观。之所以提这点是希望 ban 掉这样的一种写法:先创建一个空白的 data.frame 作为结果变量,然后每次循环遍历的时候都把本次输出的结果和这个结果变量做拼接,最后再输出。这么做真的很慢,非常慢,而且代码会被写的很丑(在 R 里面建一个 0 行的空白数据框是很恶心的事情)。

除此以外还有一项可选准则:使用 tibble 而非 data.frame。换言之,能用 tidyverse 全家桶的地方不用原生的函数。因为 tidyverse 那一套东西是直接用 RCpp 搞的,性能比原生的方法和变量类型好很多,而且在控制台的输出也比原生 data.frame 更加直观。但是之所以把这项准则设为可选,是因为一些可以直接在 data.frame 上用的函数不能拿到 tibble 上用,隔壁课题组的同学就被这事情坑过,所以如果你对 tibble 不够熟悉也懒得学的话,可以弃之不用。

如果你不想用 tibble 的话

【例1】如果不想用 tibble 的话最基本的模板是这样的:

1
2
3
4
5
6
7
8
9
10
11
library(magrittr)

x <- 1:100

lapply(x, function(i) {
// Do something here

data.frame(
x: i
)
}) %>% do.call(rbind, .)

这里的 %>%magrittR 包里面的管道操作符,x %>% f 等价于 f(x)x %>% f(y) 等价于 f(x, y)。这玩意可以有效避免你的代码出现无限个圆括号套娃,增强代码可读性(数括号真的很烦,尤其是改代码的时候数括号看哪里的括号缺了一半更烦 ヽ(#`Д´)ノ)。

do.call(rbind, list(x, y))rbind(x, y) 等价,只是这里我们没办法确定传入参数的个数,所以才需要使用这个函数。

【例2】有些情况下,并不是被遍历的每个变量都能输出有效结果,这个时候我们可以通过 Filter 函数来滤掉无效项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
library(magrittr)

x <- data.frame(
value = 1:100,
valid = rnorm(100) > 0.5
)

lapply(1:nrow(x), function(row) {
if (!x[i, ]$valid) return(NULL)

# Do something here

data.frame(
x: x[i, ]$value
)
}) %>% Filter(Negate(is.null), .) %>% do.call(rbind, .)

这一例中有几个比较 tricky 的地方。首先,如果想要遍历一个 data.frame,很多人可能直觉性的会想到用 apply(x, margin = 1),但实际上这种做法是非常不可取的,因为 apply 这个函数被设计的本意是用来遍历矩阵(matrix)的,它会自动的将每行数据简化成一个向量,众所周知 R 当中的向量无法存储多个类型的数据,因此所有数据都会被简化为同种类型的数据,这会为程序带来很大的不确定性。 比较安全的做法是遍历一个数值向量 1:nrow(x) 之后在 lapply 内部使用切片器。

另外,Filter(Negate(is.null), .) 是一个性能比较好的滤除 NULL 的方法,参考了这位老哥的答案

使用 tibble 的版本

【例3】如果你不介意多调两个包的话,可以这么写:

1
2
3
4
5
6
7
8
9
10
11
library(magrittr, tibble, dplyr)

x <- 1:100

lapply(x, function(i) {
# Do something here

tibble(
x: i
)
}) %>% bind_rows

tibble 函数来自 tibble 包;bind_rows 函数来自 dplyr 包,是 do.call(rbind, .) 的便利版本。

附加题:如果要生成多个 data.frame 的话

【例4】有的时候我们可能希望通过一次遍历生成多个 data.frame,最后再把这些结果拼接在一起,这个时候可以借助一个小函数:

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
26
27
28
29
30
31
32
33
34
35
36
37
merge_df <- function(x, unit_names = NULL, safe_deduce = T) {
# Automatically filter out the `NULL` value
x <- Filter(Negate(is.null), x)

# Type safe: if `x` is not a list, return `NULL`.
# Type safe: the unit of returned list is based on the first sub-list,
# so it must be a list.
if (!is.list(x) | !is.list(x[[1]])) return(NULL)

if (is.null(unit_names)) {
# Get `names` of first sub-list

if (safe_deduce) {
# Type safe: item in sub-list should be a `data.frame`.
list_names <- lapply(names(x[[1]]), function(i) {
if (is.data.frame(x[[1]][[i]])) return(i)
}) %>% Filter(Negate(is.null), .) %>% unlist
} else {
list_names <- names(x[[1]])
}
} else {
list_names <- unit_names
}

# Loop among all list_names
result <- lapply(list_names, function(item_name) {
# Get item from sub-list
# Type safe: all item in sub-list should be `data.frame`.
lapply(x, function(unit_of_x) unit_of_x[[item_name]]) %>%
Filter(is.data.frame, .) %>%
do.call(rbind, .)
})

names(result) <- list_names

result
}

虽然注释非常多,但实际上这个函数真正在做的事情非常简单,merge_df 期待你会:

  • 利用 lapply 遍历一个变量;
  • 每次遍历时,返回一个包含若干 data.framelist
  • 每次返回的 list 应当具有相同的结构,即 listnames 应当一致。

merge_df 会把每次迭代返回的,name 一致的 data.frame 沿着行(row)的方向拼接成一个大的 data.frame

你可以手动传入 unit_names 参数来告诉这个函数每次迭代返回的 list 究竟是什么结构。如果没有提供这个参数,那么函数会进行自动推断,这时函数假定每次遍历返回的 list 结构一致,将第一次迭代返回值的结构视作接下来所有遍历结果的结构。如果第一次迭代没有返回一个 list 那么程序将抛出 NULL。

在自动推断遍历结果结构时,你可以选择进行「安全推断」(默认开启,与 R 的 API 设计调性一致但我并不喜欢这样),即第一次返回的 list 中,只有元素类型为 data.frame 才将之视作为返回结果的有效结构,否则则被抛弃。比如如果第一次遍历返回的结果是这样的:

1
2
3
4
5
6
7
8
9
list(
report_a = "nah, not work",
report_b = NULL,
report_c = data.frame(
a = 1,
b = 2,
c = 3
)
)

那么开启「安全推断」,函数认为迭代返回的数据结构是 c('report_c'),否则则认迭代返回数据的结构是 c('report_a', 'report_b', 'report_c')

除此之外该函数还做了很多变量类型安全检查

  • 输入的数据 x 必须是一个 list
  • 对于每次迭代的结果,如果与预先定义的迭代结果结构一致,且为 data.frame,则参与结果拼合,否则被抛弃。

你可以这样使用这个函数:

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
26
library(magrittr)

x <- data.frame(
a = rnorm(5),
b = rnorm(5),
c = rnorm(5)
)

lapply(1:nrow(x), function(row) {

# 这里生成了一个包含两个 `data.frame` 的报告
return(
list(
report_a = data.frame(
a = x$a[row],
b = x$b[row]
),
report_b = data.frame(
b = x$b[row],
c = x$c[row]
)
)
)
}) %>% merge_df

# 我们在这里将两个报告重新拼合,输出一个`list`,包含了两个 `data.frame`

【附加题】试试把上面的函数改成 tidyverse 版本的?(没有参考答案哦~)

以上就是本次的分享啦,本文是遗迹计划的一部分,希望能够为你提供一些帮助,莉莉爱你 ♥ヽ( ^ω^ ゞ )~

Comments