R4.0 性能:数据帧与列表、循环与向量化 - 常数向量减法示例

Cla*_*eri 5 for-loop r list vectorization dataframe

这是我第三次阅读 Hadley Wickham 臭名昭著的《Advanced R》,他在第二章解释了为什么列表迭代比数据帧迭代更好。一切看起来都合理、熟悉、符合预期。该示例函数从数据帧的每一列中减去中位数向量,它基本上是居中的一种形式。为了进行测试,我运行了 Grosser、Bumann 和 Wickham 在Advanced R Solutions中提供的代码中提供的代码。

\n
library(bench)\n\n# Function to generate random dataframe\ncreate_random_df <- function(nrow, ncol) {\n  random_matrix <- matrix(runif(nrow * ncol), nrow = nrow)\n  as.data.frame(random_matrix)\n}\n\n# For loop function that runs on dataframe\nsubtract_df <- function(x, medians) {\n  for (i in seq_along(medians)) {\n    x[[i]] <- x[[i]] - medians[[i]]\n  }\n  x\n}\n\n# Same for loop function but that runs on list\nsubtract_list <- function(x, medians) {\n  x <- as.list(x)\n  x <- subtract_df(x, medians)\n  list2DF(x)\n}\n\n\nbenchmark_medians <- function(ncol) {\n  df <- create_random_df(nrow = 1e4, ncol = ncol)\n  medians <- vapply(df, median, numeric(1), USE.NAMES = FALSE)\n  \n  bench::mark(\n    "data frame" = subtract_df(df, medians),\n    "list" = subtract_list(df, medians),\n    time_unit = "ms"\n  )\n}\n\n# Results\nresults <- bench::press(\n  ncol = c(1, 10, 50, 100, 250, 300, 400, 500, 750, 1000),\n  benchmark_medians(ncol)\n)\n#> Running with:\n#>     ncol\n#>  1     1\n#>  2    10\n#>  3    50\n#>  4   100\n#>  5   250\n#>  6   300\n#>  7   400\n#>  8   500\n#>  9   750\n#> 10  1000\n\nlibrary(ggplot2)\n\nggplot(\n  results,\n  aes(ncol, median, col = attr(expression, "description"))\n) +\n  geom_point(size = 2) +\n  geom_smooth() +\n  labs(\n    x = "Number of Columns",\n    y = "Execution Time (ms)",\n    colour = "Data Structure"\n  ) +\n  theme(legend.position = "top")\n#> `geom_smooth()` using method = \'loess\' and formula \'y ~ x\'\n
Run Code Online (Sandbox Code Playgroud)\n

\n

由reprex 包(v2.0.1)创建于 2021-08-08

\n

同样,没有什么异常,作者解释说:

\n
\n

当直接使用数据帧时,执行时间随着输入数据中的列数呈二次方增长。这是因为(例如)第一列必须复制 n 次,第二列必须复制 n-1 次,依此类推。使用列表时,执行时间仅线性增加。

\n

显然,从长远来看,线性增长会缩短运行时间,\n但是这种策略有一些成本\xe2\x80\x94,我们必须使用 as.list() 和 list2DF() 在\n数据结构之间进行转换。尽管这很快\n并且可能不会\xe2\x80\x99造成太大伤害,但改进的方法\xe2\x80\x99在这种情况下并没有真正\n得到回报,直到我们得到大约\n300列宽的数据框(确切的值取决于\n运行代码的系统的特征)。

\n
\n

但是后来,我对自己说,我经常使用数据帧,而不是列表,并且我肯定能够编写一个在具有许多列的数据帧上以适当的时间运行的函数。我错了!我现在比开始时有更多的问题:for 循环不是应该比矢量化操作慢吗?R 版本 4 是否获得了我不知道的新优化?为什么是sweep功能这么慢?矩阵/向量化运算不应该至少与循环一样快吗?垃圾收集器是否会显着减慢速度?我在函数内部做错了什么(我肯定在其中一些函数中)?

\n
library(bench)\n  \n# Function to generate random dataframe\ncreate_random_df <- function(nrow, ncol) {\n  random_matrix <- matrix(runif(nrow * ncol), nrow = nrow)\n  as.data.frame(random_matrix)\n}\n\n# For loop function that runs on dataframe\nsubtract_df <- function(x, medians) {\n  for (i in seq_along(medians)) {\n    x[[i]] <- x[[i]] - medians[[i]]\n  }\n  x\n}\n\n# Same for loop function but that runs on list\nsubtract_list <- function(x, medians) {\n  x <- as.list(x)\n  x <- subtract_df(x, medians)\n  list2DF(x)\n}\n  \n# 5 Functions that I thought should be decently fast \nmy_mapply <- function(x, medians) {\n  as.data.frame(\n    mapply(\n      function(x, medians){x - medians},\n      x,\n      medians\n    )  \n  )\n} \n\nmy_sweep <- function(x, medians) {\n  sweep(x, 2, medians)\n}\n\nmy_apply <- function(x, medians) {\n  x <- t(apply(x, 1, function(a) a - medians))\n  as.data.frame(x)\n}\n\nmy_vectorized <-function(x, medians) { \n  x <- as.matrix(x)\n  x <- t(t(x) - medians)\n  x <- as.data.frame(x)\n  x\n}  \n\nmy_vectorized2 <- function(x, medians) {\n  ones <- rep(1, nrow(x))\n  x_median <- ones %*% t(medians)\n  x - x_median\n}\n\n\nbenchmark_medians <- function(ncol) {\n  df <- create_random_df(nrow = 1e4, ncol = ncol)\n  medians <- vapply(df, median, numeric(1), USE.NAMES = FALSE)\n  \n  bench::mark(\n    "data frame" = subtract_df(df, medians),\n    "list" = subtract_list(df, medians),\n    "my_mapply" = my_mapply(df, medians), \n    "my_sweep" = my_sweep(df, medians),\n    # "my_apply" = my_apply(df, medians),   # excluded because it is very very slow compared with the others\n    "my_vectorized" = my_vectorized(df, medians),\n    "my_vectorized2" = my_vectorized2(df, medians),\n    time_unit = "ms"\n  )\n}\n\n# Have a quick check on dataframe with only 3 columns\nbenchmark_medians(3)\n#> # A tibble: 6 x 6\n#>   expression        min median `itr/sec` mem_alloc `gc/sec`\n#>   <bch:expr>      <dbl>  <dbl>     <dbl> <bch:byt>    <dbl>\n#> 1 data frame     0.0463 0.0884    12769.    4.44MB     55.9\n#> 2 list           0.0640 0.0693    12944.  486.86KB    127. \n#> 3 my_mapply      0.152  0.160      5772.    1.05MB    124. \n#> 4 my_sweep       1.14   1.18        809.    2.21MB     38.7\n#> 5 my_vectorized  0.208  0.212      4253.     1.1MB     89.2\n#> 6 my_vectorized2 0.844  0.875      1074.    1.93MB     46.4\n\n# Results\nresults <- bench::press(\n  ncol = c(1, 10, 50, 100, 250, 300, 400, 500, 750, 1000),\n  benchmark_medians(ncol)\n)\n#> Running with:\n#>     ncol\n#>  1     1\n#>  2    10\n#>  3    50\n#>  4   100\n#> Warning: Some expressions had a GC in every iteration; so filtering is disabled.\n#>  5   250\n#> Warning: Some expressions had a GC in every iteration; so filtering is disabled.\n#>  6   300\n#> Warning: Some expressions had a GC in every iteration; so filtering is disabled.\n#>  7   400\n#> Warning: Some expressions had a GC in every iteration; so filtering is disabled.\n#>  8   500\n#> Warning: Some expressions had a GC in every iteration; so filtering is disabled.\n#>  9   750\n#> Warning: Some expressions had a GC in every iteration; so filtering is disabled.\n#> 10  1000\n#> Warning: Some expressions had a GC in every iteration; so filtering is disabled.\n\nlibrary(ggplot2)\n\nggplot(\n  results,\n  aes(ncol, median, col = attr(expression, "description"))\n) +\n  geom_point(size = 2) +\n  geom_smooth() +\n  labs(\n    x = "Number of Columns",\n    y = "Execution Time (ms)",\n    colour = "Data Structure"\n  ) +\n  theme(legend.position = "top")\n#> `geom_smooth()` using method = \'loess\' and formula \'y ~ x\'\n
Run Code Online (Sandbox Code Playgroud)\n

\n

由reprex 包(v2.0.1)创建于 2021-08-08

\n

无论生成的数据的特征如何,这些结果在我的所有测试中几乎都是一致的。\n任何见解都将受到赞赏,并且任何有关性能或某些函数/函数系列使用的建议都会受到赞赏。

\n

谢谢。

\n

Mar*_*old 3

for 循环不是应该比矢量化操作慢吗?/ 矩阵/向量化运算不应该至少与循环一样快吗?

这取决于。但是,我认为对矢量化操作(和 data.frames)存在误解。考虑my_vectorized2()

my_vectorized2 <- function(x, medians) {
  ones <- rep(1, nrow(x))
  x_median <- ones %*% t(medians)
  x - x_median
}
Run Code Online (Sandbox Code Playgroud)

这里的“瓶颈”是第四行。与您的假设相反(我假设),x - medians不要触发矢量化计算:x不是矩阵而是 data.frame (即列表)。使用 data.frames 进行算术之所以有效,是因为已经实现了方便的方法。从下面的例子可以看出,这不应该被认为是理所当然的。

# `+` method for data.frames
`as.data.frame(1:5) + as.data.frame(6:10)`.

1   7
2   9
3  11
4  13
5  15

# no `+` method for lists!
`as.list(1:5) + as.list(6:10)`

Error in as.list(1:5) + as.list(6:10) : 
  non-numeric argument to binary operator
Run Code Online (Sandbox Code Playgroud)

然而,简而言之,这些方便的 data.frames 方法比基于向量的计算效率低。通过对代码进行分析,我们可以仔细查看时间花费在哪里:

df <- create_random_df(nrow = 1e4, ncol = 1000)
profvis::profvis(my_vectorized2(df, medians))
Run Code Online (Sandbox Code Playgroud)

在此输入图像描述

到目前为止,大部分时间似乎都花费在Ops.data.frame()为方法分割框架上"-"

为什么这个sweep函数这么慢?/ 垃圾收集器是否会显着减慢速度?

我不太熟悉内部工作原理sweep(),但性能不佳本质上是由于上面讨论的问题造成的。此外,还会创建多个引用,从而产生副本。垃圾收集似乎也占有相当的份额(这一点已通过分析得到证实)。mapply()这与您使用和 的方法类似apply()my_vectorized()由于计算矢量化的,因此速度相对较快。然而,从 data.frame 到 data.frame 的转换以及转置的t()成本很高(因为它会产生副本),因此应该避免。

总之,当您追求算术运算的性能时,一个建议是通常使用矩阵而不是 data.frames。