跳到正文

purrr 函数式编程:用 map 家族替代 for 循环

分析脚本里出现 for 循环,通常意味着要对一组对象重复做同一件事:对 11 个变量各拟合一个模型、对 30 个受试者各画一张图、对 60 个文件各跑一遍清洗函数。循环本身没问题,问题在于循环的样板代码——先建一个长度正确的容器、再按下标逐个填、还得保证类型不出错。填错一个下标,结果就是错的,而且不一定报错。

purrrmap 家族把这套样板拿掉:你只需要说明「对每个元素做什么」,循环和容器交给函数处理。

library(tidyverse)
map(1:3, \(x) x^2)
[[1]]
[1] 1
[[2]]
[1] 4
[[3]]
[1] 9

\(x) x^2 是 R 4.1 起的匿名函数简写,等价于 function(x) x^2map() 的返回值永远是列表,长度和输入一致。列表的好处是能装下任何类型的结果——一次回归的 lm 对象、一张图、一个长度不定的向量。lm() 结果这种复杂对象只能用列表装,这也是 map() 而不是 map_dbl() 的用武之地。

数据框在 R 里就是「每个元素是一列」的列表,所以 map() 可以直接按列迭代:

map_dbl(mtcars, mean)
mpg cyl disp hp drat wt qsec
20.090625 6.187500 230.721875 146.687500 3.596563 3.217250 17.848750
vs am gear carb
0.437500 0.406250 3.687500 2.812500

这行代码算出了 mtcars 全部 11 个变量的均值。map_dbl()map() 的唯一区别是返回值:它要求每个结果都是长度为 1 的数值,并把这些结果拼成一个数值向量——能直接参与后续计算,打印时也不会刷屏。

类型稳定的 map_dbl、map_chr、map_lgl、map_int

Section titled “类型稳定的 map_dbl、map_chr、map_lgl、map_int”

这四个函数把类型写进了名字,也写进了检查:结果类型不匹配时立刻报错,而不是悄悄降级。base R 的 sapply() 正相反——输入长度不同或类型不一,它会返回一个列表,下游代码就得额外判断返回值到底是什么。map_dbl() 报错虽然让人烦躁,但它把「结果类型失控」这类 bug 提前到了出错的那一行。

按组迭代是最常见的用法,配合 split() 使用:

map_dbl(split(mtcars, mtcars$cyl), nrow)
4 6 8
11 7 14

split()cyl 把数据拆成三个数据框组成的列表,map_dbl() 对每个元素数行数,返回带名字的向量:四缸 11 辆、六缸 7 辆、八缸 14 辆。这个模式(split() + map_*() + 汇总)在 R 里就是「分组统计」的通用写法,dplyrgroup_by() + summarise() 能覆盖的场合优先用后者,但涉及模型对象、绘图对象这些 summarise() 装不下的东西时,还是得回到 split() + map()

map_dbl(1:3, function(x) x + 1) # 完整写法,任何版本都支持
map_dbl(1:3, \(x) x + 1) # R 4.1 起,最简洁
map_dbl(1:3, ~ .x + 1) # purrr 的公式写法,.x 代表当前元素

三种写法完全等价,选一种在项目里用到底就行。~ 写法里 .x 是第一个参数、.y 是第二个,超过两个参数就用 ..1..2..3 按位置引用——这是它相对 \(x) 的缺点:参数多了可读性会明显下降。公式写法在 pmap() 里很常见,但要留意 .x 在多层嵌套时容易指错对象。

map() 返回列表,要进一步分析通常得拼成数据框。map_dfr() 就是「map + 按行合并」,.id 参数还能把列表元素的名字变成一列:

split(ToothGrowth, ToothGrowth$supp) |>
map_dfr(\(df) data.frame(n = nrow(df), mean_len = mean(df$len)), .id = "supp")
# A tibble: 2 × 3
supp n mean_len
<chr> <int> <dbl>
1 OJ 30 20.7
2 VC 30 17.0

每个元素算出一个单行数据框,map_dfr() 把它们摞起来,.id = "supp" 把列表名(OJVC)写成第一列。注意回调函数必须返回数据框data.frame()tibble() 都行),返回向量会报错。

purrr 1.0 起,map_dfr() 被标记为「已替代」(superseded),新的等价写法是 map(...) |> list_rbind(),功能相同,只是把「映射」和「合并」两步分开了。老代码里的 map_dfr() 不会失效,两种写法学一种即可。

有些操作的目的是「做点什么」而不是「返回什么」:把每张图存成 PNG、把每个数据框写成 CSV。这时候用 walk()——它和 map() 一样逐个执行,但返回的是输入本身(不可见),不会在控制台刷出一堆结果:

split(mtcars, mtcars$cyl) |>
walk(\(df) cat(nrow(df), "行已处理\n"))
11 行已处理
7 行已处理
14 行已处理

画图时这个区别很实际:map() 会把每个 ggplot 对象都打印一遍,walk() 只画到设备上。想同时拿到下标,用 iwalk()(传 .x.y,分别是元素和它的名字)或者 walk2()

map2() 同时遍历两个等长向量,\(x, y) 里分别接两个序列的元素:

map2_dbl(mtcars$mpg[1:4], mtcars$wt[1:4], \(m, w) round(m / w, 2))
[1] 8.02 7.30 9.83 6.66

这是「每加仑英里数 ÷ 车重」的前四辆车。map2() 要求两个输入长度相同,长度不等会报错。超过两个输入就用 pmap():它接收一个列表(或数据框),数据框按迭代,正好可以当作参数表来用:

params <- data.frame(x = 1:3, y = 4:6, z = 7:9)
pmap_dbl(params, \(x, y, z) x + y * z)
[1] 29 42 57

数据框的列名自动对应到函数的参数名,所以 pmap() 的回调可以直接写 \(x, y, z) 而不用 ..1..2。用 ~ 写法时参数名取不到,只能写 ~ ..1 + ..2 * ..3——这也是 pmap() 里优先用 \(...) 的原因。

possibly 与 safely:让一处失败不拖垮整轮

Section titled “possibly 与 safely:让一处失败不拖垮整轮”

批量拟合 60 个模型,第 37 个因为数据太稀疏而报错,整个 map() 就会中断,前 36 个结果一起丢掉。possibly() 把函数包装成「出错时返回兜底值」:

safe_log <- possibly(log, otherwise = NA_real_)
map_dbl(list(1, 10, "a"), safe_log)
[1] 0.000000 2.302585 NA

log("a") 会报错,包装之后这一处变成 NA,其余结果照常返回。safely() 更细一些:它返回一个列表,里面 result 是正常结果、error 是错误信息,适合事后统计「到底哪几个失败了、为什么」。quietly() 处理的是警告和消息而不是错误,批量建模时用来收警告。

兜底值的选择要看语义:NA 表示「这里没有结果」,NULL 表示「这里什么都没有」,数字 0 则会让下游的 mean() 静默失真。用 0 之前先想清楚,它对统计量的影响是实打实的。

  • 向量化运算能直接做的事x^2sqrt(x) 对向量本来就是逐元素计算的,写成 map_dbl(x, \(i) i^2) 只是绕远路,还更慢。
  • 结果依赖上一次迭代。累积求和、递推这类逻辑,用 accumulate()reduce(),或者老实写 for 循环。map() 的设计前提是每一步互相独立。
  • 需要中途跳出map() 不能 break。要「找到第一个满足条件的就停」,用循环加 break,或者先 detect()keep()purrr 的筛选函数。
  • 性能敏感且结果简单map() 不比 for 循环快,两者本质上都是 R 层面的循环。真正的加速来自向量化、data.table 或者把内层函数写成 Rcpp——map 换来的是可读性和类型稳定,不是速度。

purrr 的循环抽象和 Python 的列表推导、numpy 的向量化解决的是同一类问题:把「对每个元素做什么」和「怎么遍历」分开。如果你也用 Python,可以对照 /python/basics/numpy 看向量化在另一边是怎么做的;函数本身怎么写、默认参数和返回值怎么设计,则在 /r/basics/functions 里有更基础的一遍。