【问题标题】:rowwise operation with dplyr使用 dplyr 进行逐行操作
【发布时间】:2026-01-14 16:15:02
【问题描述】:

我正在处理包含 230 万条记录的 R 中的大型数据框,其中包含具有开始和停止时间的位置的用户交易。我的目标是创建一个新的数据框,其中包含每个用户/每个位置的连接时间。让我们称之为每小时连接。

交易可能会从 8 分钟到 48 小时不等,因此目标数据框将是大约 1 亿条记录,并且每个月都会增长。

下面的代码显示了最终数据帧的开发方式,尽管总代码要复杂得多。在 Intel(R) Xeon(R) CPU E5-2630 v3 @ 2.40GHz、16 核 128GB RAM 上运行总代码大约需要 9 个小时。

library(dplyr)

numsessions<-1000000
startdate <-as.POSIXlt(runif(numsessions,1,365*60*60)*24,origin="2015-1-1")

df.Sessions<-data.frame(userID = round(runif(numsessions,1,500)),
           postalcode = round(runif(numsessions,1,100)),
           daynr = format(startdate,"%w"),
              start =startdate ,
              end=   startdate + runif(1,1,60*60*10)
           )


dfhourly.connected <-df.Sessions %>% rowwise %>% do(data.frame(userID=.$userID,
                                          hourlydate=as.Date(seq(.$start,.$end,by=60*60)),
                                          hournr=format(seq(.$start,.$end,by=60*60),"%H")
                                          )
                               )

我们希望在(部分)16 个内核上并行化此过程,以加快该过程。第一次尝试是使用multidplyr 包。分区基于daynr

df.hourlyconnected<-df.Sessions %>% 
                      partition(daynr,cluster=init_cluster(6)) %>%
                      rowwise %>% do(data.frame(userID=.$userID,
                            hourlydate=as.Date(seq(.$start,.$end,by=60*60)),
                            hournr=format(seq(.$start,.$end,by=60*60),"%H")
                              )
                            ) %>% collect()

现在,rowwise 函数似乎需要数据帧作为输入而不是分区。

我的问题是

  • 是否有针对每个核心的分区执行逐行计算的解决方法?

  • 有没有人建议使用不同的 R 包和方法执行此计算?

【问题讨论】:

  • the CRAN Task View about HPC 可能会给你一些想法
  • 除了该页面,您可能还想查看data.table 包(对于速度和内存效率,特别是对于大型数据集,这个imo 比dplyr 更好)或ff 包(可以在磁盘上而不是在 RAM 中处理数据集)
  • 试试下面的简单改进代码。我会花我的钱,它会比你的多线程执行得更好:library(data.table) ; res &lt;- setDT(df.Sessions)[, seq.POSIXt(start, end, by = 3600), by = userID] ; res[, `:=`(hourlydate = as.IDate(V1), hournr = hour(V1), V1 = NULL)]
  • 那么第一行应该是res &lt;- setDT(df.Sessions)[, seq.POSIXt(start, end, by = 3600), by = .(userID, start, end)]
  • 尝试了您的代码,并且 hol* f* 它运行得很快!我真的需要更深入地研究你是如何想出这个简单的代码的,但现在一切都为你效劳。

标签: r performance parallel-processing dplyr


【解决方案1】:

(我认为将此作为答案发布可能会使对高效编码感兴趣的未来读者受益。)


R 是一种矢量化语言,因此按行操作是成本最高的操作之一;尤其是当您正在评估大量函数、调度方法、转换类和创建新数据集时。

因此,第一步是减少“by”操作。通过查看您的代码,您似乎正在根据userIDstartend 扩大数据集的大小 - 所有其余操作都可能在后记(因此被矢量化)。此外,逐行运行两次seq(本身并不是一个非常有效的功能)不会增加任何内容。最后,在 POSIXt 类上显式调用 seq.POSIXt 可以节省方法分派的开销。

我不确定如何使用dplyr 有效地做到这一点,因为mutate 无法处理它,而do 函数 (IIRC) 总是证明它自己非常低效。因此,让我们尝试可以轻松处理此任务的data.table

library(data.table) 
res <- setDT(df.Sessions)[, seq.POSIXt(start, end, by = 3600), by = .(userID, start, end)] 

再次请注意,我将“逐行”操作最小化为单个函数调用,同时避免方法分派


现在我们已经准备好了数据集,我们不再需要任何逐行操作,从现在开始一切都可以向量化了。

不过,矢量化并不是故事的结局。我们还需要考虑类转换、方法分派等。例如,我们可以使用不同的Date 类函数或使用format 甚至substr 创建hourlydatehournr。需要考虑的权衡是,例如,substr 将是最快的,但结果将是 character 向量而不是 Date 向量 - 由您决定是否喜欢最终产品的速度或质量。有时您可以同时赢得两者,但首先您应该检查您的选择。让我们对计算 hournr 变量的 3 种不同矢量化方法进行基准测试

library(microbenchmark)
set.seed(123)
N <- 1e5
test <- as.POSIXlt(runif(N, 1, 1e5), origin = "1900-01-01")

microbenchmark("format" = format(test, "%H"),
               "substr" = substr(test, 12L, 13L),
               "data.table::hour" = hour(test))

# Unit: microseconds
#             expr        min         lq        mean    median        uq       max neval cld
#           format 273874.784 274587.880 282486.6262 275301.78 286573.71 384505.88   100  b 
#           substr 486545.261 503713.314 529191.1582 514249.91 528172.32 667254.27   100   c
# data.table::hour      5.121      7.681     23.9746     27.84     33.44     55.36   100 a  

data.table::hour 在速度和质量上都是明显的赢家(结果是整数向量,而不是字符向量),同时将之前解决方案的速度提高了 ~x12,000 (而且我什至没有针对您的逐行实现对其进行测试)。

现在让我们为data.table::hour尝试3种不同的方式

microbenchmark("as.Date" = as.Date(test), 
               "substr" = substr(test, 1L, 10L),
               "data.table::as.IDate" = as.IDate(test))

# Unit: milliseconds
#                 expr       min        lq      mean    median        uq       max neval cld
#              as.Date  19.56285  20.09563  23.77035  20.63049  21.16888  50.04565   100  a 
#               substr 492.61257 508.98049 525.09147 515.58955 525.20586 663.96895   100   b
# data.table::as.IDate  19.91964  20.44250  27.50989  21.34551  31.79939 145.65133   100  a 

似乎第一个和第三个选项在速度方面几乎相同,而我更喜欢as.IDate,因为integer 存储模式。


既然我们知道效率和质量都在哪里,我们可以简单地通过运行来完成任务

res[, `:=`(hourlydate = as.IDate(V1), hournr = hour(V1))]

(然后,您可以使用类似的 res[, yourcolname := NULL] 语法轻松删除不必要的列,我将留给您)


可能有更有效的方法来解决这个问题,但这展示了一种如何使您的代码更高效的可能方法。

附带说明,如果您想进一步研究 data.table 语法/功能,请阅读以下内容

https://github.com/Rdatatable/data.table/wiki/Getting-started

【讨论】: