添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

【R语言】优雅的循环迭代:purrr包

用 R 写 循环 从低到高有三种境界:手动 for 循环,apply 函数族,purrr 包泛函式编程。

补充一点, 关于purrr 与 apply 族 :purrr 提供了更多的一致性、规范性和便利性,更容易记住和使用。速度来说,apply 族稍微快可以忽略不计的一丢丢。

本篇来谈一谈用 purrr 包优雅地做循环迭代。

先总述一下 purrr 包做泛函式循环迭代的核心思想,以及将要介绍的常用操作:

循环迭代, 就是将一个函数依次应用(映射)到序列的每一个元素上。
  • map():依次应用一元函数到一个序列的每个元素上,基本等同 lapply()
  • map2():依次应用二元函数到两个序列的每对元素上
  • pmap():应用多元函数到多个序列的每组元素上,可以实现对数据框逐行迭代
  • map 系列默认返回列表型,可根据想要的返回类型添加后缀:_int, _dbl, _lgl, _chr, _df, 甚至可以接着对返回的数据框df做行/列合并:_dfr, _dfc
  • 如果只想要函数依次作用的过程,而不需要返回结果,改用 walk 系列即可
  • 所应用的函数,有 purrr公式风格简写(匿名函数),支持一元,二元,多元函数
  • purrr 包中的其它有用函数

下面结合实例具体展开。

先加载包:

library(tidyverse)

一. 预备知识

  1. 几个必要的概念

(1) 序列 :姑且这么叫吧,即可根据位置或名字进行索引的数据结构,包括

  • 原子向量 (各个值都是同类型的,包括 6 种类型:logical、integer、double、character、complex、raw,其中 integer 和 double 也统称为numeric)
  • 列表 (各个值是不同类型的)

所谓循环迭代,就是依次在序列上做相同的操作。

(2) 泛函式编程: 函数的函数称为泛函,在编程中表示函数作用在函数上,或者说函数包含其它函数作为参数。

循环迭代,本质上就是将一个函数依次应用(映射)到序列的每一个元素上。表示出来不就是泛函式:map(x, f)

(3) 管道 :管道可以将数据从一个函数传给另一个函数,从而用若干函数构成的管道就能依次变换你的数据。例如:

x %>% f() %>% g()    # 等同于 g(f(x))

使用管道的好处是:提高程序可读性,避免引入不必要的中间变量。

对该管道示例应该这样理解:

依次对数据进行若干操作:先对 x 进行 f 操作, 接着对结果进行 g 操作

注: 数据经过管道默认传递给函数的第一个参数(表现为省略);若在非第一个参数处使用该数据,用 "." 代替,这使得管道作用更加强大和灵活。


2. 循环迭代返回类型的控制

map 系列函数都有后缀形式,以决定循环迭代之后返回的数据类型,这是 purrr 比 apply函数族更先进和便利的一大优势。常用后缀如下:

  • map_chr(.x, .f): 返回字符型向量
  • map_lgl(.x, .f): 返回逻辑型向量
  • map_dbl(.x, .f): 返回实数型向量
  • map_int(.x, .f): 返回整数型向量
  • map_dfr(.x, .f): 返回数据框列表,再 bind_rows 按行合并为一个数据框
  • map_dfc(.x, .f): 返回数据框列表,再 bind_cols 按列合并为一个数据框


3. purrr 风格公式(匿名函数)

在序列上做循环迭代(应用函数),经常需要自定义函数,但有些简单的函数也用 function 定义一番,毕竟是麻烦和啰嗦。所以,purrr 包提供了对 purrr 风格公式(匿名函数)的支持。

熟悉其它语言的匿名函数的话,很自然地就能习惯。

前面说了,purrr 包实现迭代循环是用 map(x, f),f 是要应用的函数,想用匿名函数来写它,它要应用在序列 x 上,就是要和序列 x 相关联,那么就限定用序列参数名关联好了,即 将该 序列参数名 作为匿名函数的参数使用

  • 一元函数:序列参数是 .x

比如,f(x) = x^2 + 1, 其 purrr 风格公式(匿名函数)就写为:~ .x ^ 2 + 1

  • 二元函数:序列参数是 .x, .y

比如,f(x, y) = x^2 - 3 y, 其 purrr 风格公式(匿名函数)就写为:~ .x ^ 2 - 3 * .y

  • 多元函数:序列参数是 ..1, ..2, ..3, 等

比如,f(x, y, z) = ln(x + y + z), 其 purrr 风格公式(匿名函数)就写为:~ log(..1 + ..2 + ..3)

:所有序列参数,可以用 ... 代替,比如,sum(..1, ..2, ..3) 同 sum(...)


二. map(): 依次应用一元函数到一个序列的每个元素上

map(.x, .f, ...)
map_*(.x, .f, ...)

其中,.x 为序列

.f 为要应用的一元函数,或 purrr 风格公式(匿名函数)

... 可设置函数 .f 的其它参数

例1 计算 iris 前4列,每列的均值

即依次将 mean() 函数,应用到第1列,第2列,...

df = iris[, 1:4]
map(df, mean)

说明 :df 是数据框(特殊的列表),作为序列其元素依次是:df[[1]], df[[2]], ...... 所以,map(df, mean) 相当于依次计算:mean(df[[1]]), mean(df[[2]]), ......

可见,返回结果是相同的数值,所以更好的做法是,控制返回结果为数值向量,只需:

map_dbl(df, mean)

mean()函数还有其它参数,如 na.rm,若上述计算过程需要设置忽略缺失值,只需:

map_dbl(df, mean, na.rm = TRUE)  # 因为数据不含NA, 故结果同上()

purrr风格公式写法:

map_dbl(df, ~mean(.x, na.rm = TRUE))   # 结果同上()

例2 批量读取数据文件并合并(列名相同)

files = list.files("datas/", pattern = "xlsx", full.names = TRUE)
df = map_dfr(files, read_xlsx)    # 批量读取+按行堆叠合并

说明: files 获取 datas 文件夹下所有 .xlsx 文件的路径,若嵌套只需设置参数 recursive = TRUR;

map_dfr(files, read_xlsx) 依次将 read_xlsx() 函数应用到各个文件路径上,即依次读取数据,返回结果是数据框,同时“r”表示再做按行合并,一步到位。若需要设置 read_xlsx() 的其它参数,只需在后面设置即可。

例3 批量建模。

根据分类变量对数据进行分组,对每组分别建模,再提取模型信息:

df = mtcars %>% 
  select(mpg, cyl, wt) 
df
df = df %>% 
  group_nest(cyl)      # 嵌套数据框(列表列)
df
df$data[[1]]
df = df %>% 
  mutate(model = map(data, ~ lm(mpg ~ wt, data = .x)),   # 分组建模
         pred = map(model, predict))                     # 计算每个样本的预测值
df
df$model %>% 
  map(summary) %>% 
  map_dbl("r.squared")   # 用列表的元素名做 map 相当于提取该元素
df$model %>% 
  map(broom::tidy)   # 模型参数信息
df$model %>% 
  map(broom::glance)   # 模型评估信息
df %>% 
  unnest(c(data, pred))   # 解除嵌套

注: 有了 map() 函数,对于自定义一元函数只接受标量,比如 f(x), 想要让它支持接受向量作为输入,根本不需要改造原函数,只需:

map_*(xs, f)     # xs表示若干个x构成的向量

三. map2(): 依次应用二元函数到两个序列的每对元素上

map2(.x, .y .f, ...)
map2_*(.x, .y, .f, ...)

其中,.x 为序列1

.y 为序列2

.f 为要应用的二元函数,或 purrr 风格公式(匿名函数)

... 可设置函数 .f 的其它参数

例4 根据身高、体重数据计算BMI指数

height = c(1.58, 1.76, 1.64)
weight = c(52, 73, 68)
cal_BMI = function(h, w) w / h^2     # 定义计算BMI的函数
map2_dbl(height, weight, cal_BMI)

说明: 序列1其元素为:height[[1]], height[[2]], ......

序列2其元素为:weight[[1]], weight[[2]], ......

所以,map2_dbl(height, weight, cal_BMI) 相当于依次计算:

cal_BMI(height[[1]], weight[[1]]), cal_BMI(height[[2]], weight[[2]]), ......

更简洁的purrr风格公式写法(省了自定义函数):

map2_dbl(height, weight, ~ .y / .x^2)     # 结果同上()

数据若是在数据框中,也同样使用:

df = tibble(height = height, weight = weight)
df %>% 
  mutate(bmi = map2_dbl(height, weight, cal_BMI)) 

purrr 风格公式写法(省了自定义函数):

df %>% 
  mutate(bmi = map2_dbl(height, weight, ~ .y / .x^2))

四. pmap(): 应用多元函数到多个序列的每组元素上,可以实现对数据框逐行迭代

我最先学习 pmap() 是在 Hadley 的《R for Data Science》,讲到将(多元)函数应用到更多序列上,多个序列是多个列表的形式。

这个理解就一直很模糊,用着也不顺手。最近,我突然醍醐灌顶:

多个序列得长度相同,长度相同的列表,不就是数据框吗!那么所谓的多元迭代不就是依次在数据框的每一行上迭代吗!!

理解到这一点(抛弃列表,不影响使用)后,豁然开朗,再使用 pmap() 时也不再模糊和难用。

pmap(.l, .f, ...)
pmap_*(.l, .f, ...)

其中,.l 为数据框,

.f 为要应用的多元函数

... 可设置函数 .f 的其它参数

:.f 是几元函数,对应数据框 .l 有几列,.f 将依次在数据框 .l 的每一行上进行迭代。

:前两个示意图引用别人的,这个图是我现做的。

例5 分别生成不同数量不同均值、标准差的正态分布随机数。

df <- tibble(
  n = c(1,3,5),
  mean = c(5,10,-3),
  sd = c(1,5,10)
df
set.seed(123)
pmap(df, rnorm)

说明 :这里的 rnorm(n, mean, sd) 是三元函数,pmap(df, rnorm) 相当于将三元函数 rnorm() 依次应用到数据框 df 的每一行上,即依次执行:

rnorm(1, 5, 1), rnorm(3, 10, 5), rnorm(5, -3, 10)

特别注意 ,这里 df 中的列名,必须与 rnorm() 函数的参数名相同(列序随便)。若要避免这种局限,可以使用 purrr 风格公式写法:

names(df) = c("n", "m", "s")
df
set.seed(123)
pmap(df, ~ rnorm(..1, ..2, ..3))    # 结果同上(), 或者简写为
pmap(df, ~ rnorm(...))

例6 对数据框逐行操作。

dplyr 包中提供了 rowwise() 将数据框“行化”,可以实现按行操作数据(速度较慢)。

pmap_*() 是另一种行化操作数据框的办法。

df = crossing(x = 0.3, y = 1:3, z = 1:3)
df

下面计算3个新列:第1列占第2,3列之和的比例、计算三列的平均值、将三列合并到一起用"-"间隔。

df %>% 
  mutate(r = pmap_dbl(., ~ ..1 / (..2 + ..3)), 
         m = pmap_dbl(., ~ mean(c(...))), 
         a = pmap_chr(., str_c, sep = "-"))

注: 将多个函数依次应用到序列,可以用 invoke_map_*(.f, .x, ...) 系列,相当于依次执行:

.f[[1]](.x, ...)

.f[[2]](.x, ...)

......

五. walk 系列:将函数依次作用到序列上,不返回结果

有些批量操作是没有或不关心返回结果的,例如批量保存到文件:save(), write_csv() 等。

这就需要:

walk(.l, .f, ...)

walk2(.l, .f, ...)

pwalk(.l, .f, ...)

例7 将 mpg 按 manufacturer 分组,每个 manufacturer 的数据分别保存为单独数据文件。

df = mpg %>%
  group_nest(manufacturer) 
df %>%
  pwalk(
    ~ write_csv(..2, paste0("datas/", ..1, ".csv"))
  )

例8 选择前10个国家的数据,绘制预期寿命随年份变化的图,并分别保存为图形文件。

df = repurrrsive::gap_split[1:10]
df    # 部分
walk(df, 
     ~ ggsave(paste0("datas/", .x$country[1], ".png"),
              ggplot(.x, aes(year, lifeExp)) +       
                geom_line() +
                ggtitle(paste("Life Expectancy of", .x$country[1]))
     )

六. 其它有用函数

  1. reduce()

函数 reduce() 可先对序列前两个元素应用函数,再对结果与第3个元素应用函数,再对结果与第4个元素应用函数,……直到所有的元都被“reduced”。例如,

reduce(1:100, sum)

例9 批量数据连接。

dplyr 包提供了六种常用的数据连接:left_join(), right_join(), full_join(), inner_join(), semi_join(), anti_join().

但是这些连接都只支持两个数据表做连接。如果连接多个数据表呢?用 reduce() 就能实现。

比如,datas 文件夹下有3个xlsx文件:

注意,3个数据表中的人名是有重复的,所以不能简单按行堆叠。

实际上,这是将所有信息都合并到一起,即做全连接。又因为是多表依次做连接,再结合 reduce() 就能实现。

files = list.files("datas/", pattern = "xlsx", full.names = TRUE)
df = map(files, readxl::read_xlsx) %>% 
  reduce(full_join, by = "人名")                  # 读入并依次做全连接