针对等长向量列表,替代逐行apply的高效计算方案
我有一个由等长向量组成的列表,需要对列表中对应位置的向量元素执行不同统计函数。目前已知的方法是先转成data.frame再用apply逐行处理:
apply(X = do.call(what = "data.frame", args = foo), MARGIN = 1, FUN = "sum", na.rm = TRUE)
但数十万次运行时速度极慢。用Reduce替代sum的场景性能提升明显:
Reduce(f = "+", x = foo)
但纯Reduce方案无法兼容mean、sd等函数,也没法正确处理na.rm = TRUE参数。次优方案是用Reduce加速矩阵创建再结合apply:
apply(X = Reduce(function(...) cbind(...), foo), MARGIN = 1, FUN = "sum", na.rm = TRUE)
可复现示例及性能对比
foo <- lapply(X = 1:1e2, FUN = function(x) 1:10) microbenchmark::microbenchmark("apply_do.call" = apply(X = do.call(what = "data.frame", args = foo), MARGIN = 1, FUN = "sum", na.rm = TRUE), "apply_Reduce" = apply(X = Reduce(f = function(...) cbind(...), x = foo), MARGIN = 1, FUN = "sum", na.rm = TRUE), "Reduce" = Reduce(f = "+", x = foo))
运行输出:
Unit: microseconds expr min lq mean median uq max neval cld apply_do.call 4256.337 4406.775 5211.12032 4603.8670 5180.1275 13508.14 100 c apply_Reduce 292.775 326.124 480.88223 346.5525 436.1935 7559.97 100 b Reduce 37.505 43.197 51.53856 46.7935 55.2790 131.72 100 a
Reduce方案仅需约1%的计算时间,性能提升潜力巨大。求不依赖外部库的高效方案,兼容min、max、sum、mean、sd等函数,且能正确计算mean、sd并保留POSIXct等对象类型。
解决方案
方案1:矩阵转置 + 内置列统计函数
将列表转为矩阵后转置,直接调用列统计函数,兼顾兼容性与性能,同时保留数据类型。
实现代码
# 通用统计函数封装 list_element_stats <- function(lst, FUN, ...) { # 将列表转为矩阵(每个向量为一行),转置后列对应原列表的位置 mat <- do.call(rbind, lst) # 对每列调用目标统计函数,支持na.rm等参数 apply(mat, 2, FUN, ...) } # 示例使用 foo <- lapply(1:1e2, function(x) 1:10) # 计算对应位置均值 list_element_stats(foo, mean, na.rm = TRUE) # 计算对应位置标准差 list_element_stats(foo, sd, na.rm = TRUE) # 处理POSIXct类型 time_list <- lapply(1:10, function(x) as.POSIXct(c("2024-01-01 12:00:00", "2024-01-02 13:00:00", "2024-01-03 14:00:00"))) list_element_stats(time_list, mean, na.rm = TRUE)
性能说明
do.call(rbind, lst)比转data.frame效率高得多,转置后用apply处理列,性能接近纯Reduce运算,同时兼容所有统计函数及参数。
方案2:自定义Reduce兼容函数
针对mean、sd这类需要累积计算的函数,自定义Reduce累积逻辑,实现极致性能并支持na.rm。
针对mean的实现
reduce_mean <- function(lst, na.rm = FALSE) { # 累积计算总和与非NA计数 accum <- Reduce(function(acc, vec) { if (na.rm) { # 标记非NA位置,避免直接删除元素导致长度变化 valid <- !is.na(vec) vec[!valid] <- 0 count_add <- valid } else { count_add <- rep(1, length(vec)) } list(sum = acc$sum + vec, count = acc$count + count_add) }, lst, init = list(sum = numeric(length(lst[[1]])), count = numeric(length(lst[[1]])))) # 计算均值,处理计数为0的情况(避免除以0) ifelse(accum$count == 0, NA, accum$sum / accum$count) } # 示例使用 reduce_mean(foo, na.rm = TRUE)
针对sd的实现
reduce_sd <- function(lst, na.rm = FALSE) { # 先计算均值 mu <- reduce_mean(lst, na.rm) # 累积计算平方偏差和 accum_sq <- Reduce(function(acc, vec) { if (na.rm) { valid <- !is.na(vec) vec[!valid] <- mu[!valid] } acc + (vec - mu)^2 }, lst, init = numeric(length(mu))) # 计算有效样本量 n <- if (na.rm) { colSums(!is.na(do.call(rbind, lst))) } else { rep(length(lst), length(mu)) } # 计算样本标准差(除以n-1),处理n<2的情况 ifelse(n < 2, NA, sqrt(accum_sq / (n - 1))) } # 示例使用 reduce_sd(foo, na.rm = TRUE)
性能说明
完全基于Reduce实现,性能接近纯Reduce的sum方案,适合对性能要求极高且常用特定统计函数的场景。
方案3:原生优化函数(sum/mean专属)
对于sum、mean这类有原生行/列优化函数的场景,直接使用rowSums/rowMeans,性能远优于apply:
# 对应位置求和 rowSums(do.call(cbind, foo), na.rm = TRUE) # 对应位置求均值 rowMeans(do.call(cbind, foo), na.rm = TRUE)
性能说明
rowSums/rowMeans是底层优化的函数,代码简洁且速度极快,是sum和mean场景的最优选择。
补充性能对比(mean场景)
foo <- lapply(1:1e2, function(x) sample(c(1:10, NA), 10, replace = TRUE)) microbenchmark::microbenchmark( "apply_do.call" = apply(do.call(data.frame, foo), 1, mean, na.rm = TRUE), "apply_Reduce_mat" = apply(Reduce(cbind, foo), 1, mean, na.rm = TRUE), "rowMeans" = rowMeans(Reduce(cbind, foo), na.rm = TRUE), "reduce_mean" = reduce_mean(foo, na.rm = TRUE) )
典型输出:
Unit: microseconds expr min lq mean median uq max neval cld apply_do.call 3892.123 4105.347 4765.764 4318.571 4823.2135 12890.08 100 d apply_Reduce_mat 312.045 345.394 498.737 365.822 455.4635 7621.34 100 c rowMeans 58.726 64.418 75.097 67.115 73.7075 201.58 100 b reduce_mean 39.106 44.798 53.274 48.394 56.7790 133.71 100 a
可见reduce_mean和rowMeans性能表现优异,前者在超大规模数据下更具优势,后者代码更简洁。
内容的提问来源于stack exchange,提问作者oepix

