本文讲述R语言中apply
家族的函数的使用方法。
一、lapply
最好不要试图一开始就用apply
函数,因为它比lapply
函数复杂。lapply
函数是apply
家族的函数中逻辑最简单、版本兼容性、最user-friendly的函数。
你甚至可以用lapply
这一个函数打天下。
其基本语法为:lapply(Vector, function(ii) { ... })
。
例如,有这样一个data.frame
,它是dat <- data.frame(A=1:5,B=10:14)
。我现在要做一件事:对于A列中的每个元素,找出B列中离它最远的那个数(在一维数轴上)。
像这样的问题,让初学者感受到两层循环的压力时,就是apply
家族函数大展拳脚的时机。
初学者会这样写:
result <- c()
for(i in 1:nrow(dat)){
alldistance <- abs(dat$A[i]-dat$B) #calculate distances
whichmax <- which(max(alldistance)==alldistance) #which point has the maximal distance
valuemax <- dat$B[whichmax] #value of point with the maximal distance
result <- c(result,valuemax)
}
准确来说这不是两层循环,而是外层循环,内层向量运算。
但用lapply
函数后,就是这样的:
result <- lapply(1:nrow(dat),function(i){
alldistance <- abs(dat$A[i]-dat$B)
whichmax <- which(max(alldistance)==alldistance)
valuemax <- dat$B[whichmax]
return(valuemax)
})
但初学者多少会有些不习惯。
但无论如何,我相信用R的绝大多数人都有一个习惯:把一条命令写出来,马上运行,运行结果正确时,就把这条命令放到脚本里;然后再写下一句命令,再运行,结果正确时再把这个命令塞到脚本里,如此往复循环…
这个习惯同样有利于我们快速地写好lapply
函数。
Step1: 写好框架lapply(Vector, function(element) { ... })
。其中Vector
有两种方式。一种是像上面这个例子那样,用索引;另一种干脆直接遍历元素,比如上面这个例子中lapply
的第一个元素变成dat$A
。
Step2: 将循环索引push到工作变量环境中。例如上面这个案例,框架搭好以后,直接在命令行运行i <- 1
。接下来编代码就容易了,就像编一个for
循环那样,写一句运行一句。
Step3: 返回值。返回任何东西都可以,例如一个标量、向量、数据框等等。一开始接触时建议加return
,像我这种老油条必然是懒得加了,因为R语言默认一个函数定义中最后一句话的返回值就是整个函数的返回值。
lapply
函数的返回值,永远是个list
。因此上述代码的第二个版本中result
变量实际上是个list
。这个list
中的每个组分就是lapply
中自定义函数的返回值valuemax
。要是valuemax
不是个标量,而是个更复杂的东西,比如矩阵,那result
这个列表可以更复杂。
二、do.call
接下来,自然而然的疑问就是说,既然你lapply
函数返回一个list
,那我怎样把这个list
转换成一个向量,或者数据框呢?
有这种疑问很正常,因为很多时候我们用一个循环,或者用apply
家族函数所得到的最终结果应该是个向量,或者数据框。例如上述案例最终就应该得到一个向量。
怎么办呢?
如果要得到向量,请用do.call(c, <A list object>)
;
如果要得到数据框,请用do.call(rbind, <A list object>)
。
因而do.call
和apply
家族函数往往是联合在一起用的。
result <- do.call(c,lapply(1:nrow(dat),function(i){
alldistance <- abs(dat$A[i]-dat$B)
whichmax <- which(max(alldistance)==alldistance)
dat$B[whichmax]
}))
三、让代码更严谨
上述案例中的代码有个缺陷(尽管影响不大):lapply
函数中的自定义函数内部使用了dat
变量,但这个dat
变量实际上采用的是函数外部的dat
。
在一个良好的、可维护的代码中,这样的风范不值得提倡。因此稍作修改:
result <- do.call(c,lapply(1:nrow(dat),function(i, dat){
alldistance <- abs(dat$A[i]-dat$B)
whichmax <- which(max(alldistance)==alldistance)
dat$B[whichmax]
}, dat = dat))
请注意,如果不改成这样的形式,在R包编译的时候既不会报WARNING,也不会报NOTE,但万一程序一复杂,计算结果不对的话,这种不规范只会导致代码检查的工作量增大。
四、apply
家族的其他函数
我单方面认为,综合普适性、用户友好性和实用性,给apply
家族排个名,其次序是这样的:lapply
>sapply
>apply
>tapply
。
lapply
可以说是“万能的”,并且关键是版本兼容性好。而那sapply
和apply
的使用语法在不同版本中有所不同,这会导致使用低版本R语言开发的R包在高版本R环境中报错。
sapply
和lapply
极其相似,但有两处不同:① 函数的第一个参数不是个向量,而是个列表,仅此而已;② sapply
函数会自动评估返回值是否能合并成一个向量,或者一个数据框;能合并的,就尽量合并;不能合并的,跟lapply
函数一样以列表的形式返回。
功能②看似比较用户友好,但实际上容易画蛇添足。
case1 <- sapply(as.list(1:4),function(xx){
if(xx==3) rep(999,2) else xx
})
case2 <- sapply(as.list(5:8),function(xx){
if(xx==3) rep(999,2) else xx
})
运行代码可知,case1
是个列表,case2
却是个向量。那么,在真正开发软件时,你究竟要不要在外面加一个do.call
呢?加,那case2
就直接报错;不加,那case1
和case2
根本不是一种数据类型,下游计算仍要出问题。
幸运的是,sapply
函数提供了simplify
这个参数。该参数缺省值是TRUE
,表示启动②这个功能。你可以将它设置为FALSE
,即可关闭它,免得它惹是生非。
apply
函数的第一个参数无非是变成了一个矩阵或数据框。使用时,多一个参数MARGIN
。MARGIN = 1
代表对每一行施加某一函数;MARGIN = 2
代表对每一列施加某一函数。说白了,前者就是以每行为单位搞个循环,后者是以每列为单位搞个循环,没什么新奇的。apply
函数跟sapply
有一样的毛病,即会自动评估返回值是否要合并成一个向量或者数据框。好在sapply
函数还给你simplify
这个选项,让你关掉。但apply
函数却不给你这个选项。前几个月还有的,到了R 4.0.4这个版本后,apply
函数的simplify
选项就已经被搞没了,这点让我很困惑,不知道R Core Team是什么想法。
总之,要开发软件包的话,没啥大事情少用apply
吧。
tapply
也是一样,会自动评估并合并,但好在给了simplify
参数。tapply
函数的语法是tapply(X, INDEX, function(xx) { ... })
,向量X
是数据,INDEX
相当于是个label,把数据X
的每个元素打上分组标签。后面的自定义函数就是对每个分组标签开展一次apply
。这种情况的使用场合本来就比lapply
少多了,因此其重要性也很次。