为什么对于大型数据帧,向数据帧添加属性需要更长的时间?

bre*_*auv 10 r

让\xe2\x80\x99s 制作一个简单的数据框并为其赋予属性\xe2\x80\x9cfoo\xe2\x80\x9d:

\n
orig <- data.frame(x1 = 1, x2 = 2)\nattr(orig, "foo") <- TRUE\n
Run Code Online (Sandbox Code Playgroud)\n

\xe2\x80\x9cfoo\xe2\x80\x9d 在那里:

\n
attributes(orig)\n#> $names\n#> [1] "x1" "x2"\n#> \n#> $class\n#> [1] "data.frame"\n#> \n#> $row.names\n#> [1] 1\n#> \n#> $foo\n#> [1] TRUE\n
Run Code Online (Sandbox Code Playgroud)\n

但是如果我重新排序列, \xe2\x80\x9cfoo\xe2\x80\x9d 就会消失

\n
new <- orig[, c(2, 1)]\nattributes(new)\n#> $names\n#> [1] "x2" "x1"\n#> \n#> $class\n#> [1] "data.frame"\n#> \n#> $row.names\n#> [1] 1\n
Run Code Online (Sandbox Code Playgroud)\n

我可以用以下命令将其添加回来:

\n
attributes(new) <- utils::modifyList(attributes(orig), attributes(new))\nattributes(new)\n#> $names\n#> [1] "x2" "x1"\n#> \n#> $class\n#> [1] "data.frame"\n#> \n#> $row.names\n#> [1] 1\n#> \n#> $foo\n#> [1] TRUE\n
Run Code Online (Sandbox Code Playgroud)\n

但这个操作很耗时。不是在这种情况下,因为它\xe2\x80\x99是一个\none-row数据帧,但考虑这种情况有10,000,000行:

\n
orig <- data.frame(x1 = rep(1, 1e7), x2 = rep(2, 1e7))\nattr(orig, "foo") <- TRUE\nnew <- orig[, c(2, 1)]\n\nbench::mark(\n  test = {\n    attributes(new) <- utils::modifyList(attributes(orig), attributes(new))\n  }\n)\n#> # A tibble: 1 \xc3\x97 6\n#>   expression      min   median `itr/sec` mem_alloc `gc/sec`\n#>   <bch:expr> <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>\n#> 1 test         43.2ms   46.6ms      21.6    38.1MB     14.4\n
Run Code Online (Sandbox Code Playgroud)\n

当然,完成此操作并不需要太多时间,但它比第一种情况要长得多(只需要几微秒)。对我来说,向数据帧添加单个属性所需的时间随着数据帧的大小而增加,这似乎很奇怪。我错过了什么吗?是否有更有效的方法将“简单”属性列表添加到大型数据框中?

\n

编辑:寻找仅包含基本 R 的解决方案

\n

Sam*_*amR 11

行名称延迟存储在数据框中,但在复制时会对其进行全面评估

\n

我已经广泛更新了这个答案,因为我意识到我使用tracemem()考虑对象的内存位置而不考虑对象大小得出了错误的结论。相反,我使用辅助函数来创建输出的简化树表示,lobstr::sxp(dat)以显示对象在内存中的表示方式。结论是,没有必要预先分配row.names,并且只需避免复制它们,如 Joris C. 的出色答案中所述就足够了。

\n

tl;dr:永远不要复制行名称属性

\n

当您创建行数据框n而未显式声明行名称时,行名称将存储为长度为 2 的整数向量,格式为c(NA, -n).

\n

如果将行名称属性从一个数据帧复制到另一个数据帧,R 会计算该向量以便复制它。绝对不应该这样做。

\n

或者,您可以使用data.table或tidyverse,两者在创建副本时都会保留属性,从而避免复制任何内容。

\n

仔细看看内存中发生了什么

\n

让我们创建一个包含 10 行的数据框。

\n
num_rows  <- 10\nset.seed(0)\ndat  <- data.frame(\n    x_char = sample(letters, num_rows),\n    x_int = sample(1:10, num_rows)\n)\n
Run Code Online (Sandbox Code Playgroud)\n

让我们看看它在内存中是如何出现的:

\n
library(lobstr)\ndat_sxp  <- sxp(dat)\nget_dat_obj_tree(dat_sxp)\n1 dat    VECSXP    length: 2     mem_addr:0x7\n2  \xc2\xa6--x_char    STRSXP    length: 10     mem_addr:0x1\n3  \xc2\xa6--x_int    INTSXP    length: 10     mem_addr:0x2\n4  \xc2\xb0--_attrib    LISTSXP    length: 3     mem_addr:0x3\n5      \xc2\xa6--names    STRSXP    length: 2     mem_addr:0x4\n6      \xc2\xa6--class    STRSXP    length: 1     mem_addr:0x5\n7      \xc2\xb0--row.names    INTSXP    length: 2     mem_addr:0x6\n
Run Code Online (Sandbox Code Playgroud)\n

该函数用唯一的整数替换内存地址(即每次查找实际地址时mem_addr:0x1都会保留地址,除非内存位置实际发生变化)。x_charx_char

\n

我们期望数据的长度为 10。但是为什么row.names长度只有 2?让我们打印它们:

\n
rownames(dat) # "1"  "2"  "3"  "4"  "5"  "6"  "7"  "8"  "9"  "10"\nattr(dat, "row.names") #  1  2  3  4  5  6  7  8  9 10\n
Run Code Online (Sandbox Code Playgroud)\n

显然,这些是长度为 10 的向量。您可能会注意到,一个是字符向量,一个是整数向量。这让我陷入了很多死胡同,直到我在 R 源代码中发现了这样的注释:

\n
## As from R 2.4.0, row.names can be either character or integer.\n## row.names() will always return character.\n## attr(, "row.names") will return either character or integer.\n##\n## Do not assume that the internal representation is either, since\n## 1L:n is stored as the integer vector c(NA, n) to save space (and\n## the C-level code to get/set the attribute makes the appropriate\n## translations.\n
Run Code Online (Sandbox Code Playgroud)\n

这让我想起了您在可重现示例中经常看到的东西:

\n
dput(dat)\n# structure(list(x_char = c("e", "i", "n", "z", "w", "b", "j",\n# "l", "o", "a"), x_int = c(4L, 3L, 6L, 2L, 7L, 10L, 5L, 8L, 9L,\n# 1L)), class = "data.frame", row.names = c(NA, -10L))\n
Run Code Online (Sandbox Code Playgroud)\n

我们看到行名称确实表示为长度为 2 的向量row.names = c(NA, -10L)。这是理解如何避免昂贵的复制操作的关键。

\n

创建新属性如何改变事物?

\n

事实并非如此。它只是创建了一种您更有可能复制行名称的情况,因为不会在每次操作后复制属性。R 内部声明:

\n
\n

子集化(除了空索引之外)通常会删除除名称、暗淡和暗淡名称之外的所有属性,这些属性会根据需要进行重置。

\n
\n

让我们创建一个新属性 ,foo看看内存中发生了什么:

\n
attr(dat, "foo")  <- TRUE\n
Run Code Online (Sandbox Code Playgroud)\n

我们看一下内部表示:

\n
dat_foo_sxp  <- sxp(dat)\nget_dat_obj_tree(dat_foo_sxp)\n1 dat    VECSXP    length: 2     mem_addr:0x7\n2  \xc2\xa6--x_char    STRSXP    length: 10     mem_addr:0x1\n3  \xc2\xa6--x_int    INTSXP    length: 10     mem_addr:0x2\n4  \xc2\xb0--_attrib    LISTSXP    length: 4     mem_addr:0x3\n5      \xc2\xa6--names    STRSXP    length: 2     mem_addr:0x4\n6      \xc2\xa6--class    STRSXP    length: 1     mem_addr:0x5\n7      \xc2\xa6--row.names    INTSXP    length: 2     mem_addr:0x6\n8      \xc2\xb0--foo    LGLSXP    length: 1     mem_addr:0x8\n
Run Code Online (Sandbox Code Playgroud)\n

内存中没有任何真正的改变 - 属性类只是有一个类型为 的新节点LGLSXP,即逻辑向量。

\n

当我们对数据框进行子集化时会发生什么?

\n

让我们重新排序列。

\n
new  <- dat[, c(2,1)]\n
Run Code Online (Sandbox Code Playgroud)\n

尽管我们选择了所有列,但我们本质上是按索引对数据进行子集化。我们看一下该对象在内存中的节点:

\n
new_sxp  <- sxp(new)\nget_dat_obj_tree(new_sxp, "new")\n1 new    VECSXP    length: 2     mem_addr:0x12\n2  \xc2\xa6--x_int    INTSXP    length: 10     mem_addr:0x2\n3  \xc2\xa6--x_char    STRSXP    length: 10     mem_addr:0x1\n4  \xc2\xb0--_attrib    LISTSXP    length: 3     mem_addr:0x9\n5      \xc2\xa6--names    STRSXP    length: 2     mem_addr:0x10\n6      \xc2\xa6--class    STRSXP    length: 1     mem_addr:0x5\n7      \xc2\xb0--row.names    INTSXP    length: 2     mem_addr:0x11\n
Run Code Online (Sandbox Code Playgroud)\n

这基本上是我们对延迟评估副本的期望,除了row.names,它没有改变,但有一个新的内存地址:

\n
    \n
  1. 数据帧本身有一个新的内存地址。
  2. \n
  3. 列的内存地址integer是相同的。
  4. \n
  5. 列的内存地址character是相同的。
  6. \n
  7. 属性对列表有一个新的内存地址。
  8. \n
  9. 他们names有一个新的新位置(因为它已重新排序)。
  10. \n
  11. 具有class相同的地址。
  12. \n
  13. 有row.names一个新的内存地址。
  14. \n
\n

也许 R 可以将其保留row.names在同一内存位置。毕竟,我们只是对列进行子集化,因此行的数量和顺序不会改变。

\n

然而,这就是为什么我之前预分配行名称的建议是错误的,事实上,有新的行名称row.names并不会显着影响执行时间。R 正在创建一个长度为 2 的新整数向量,无论数据大小如何。这几乎不需要时间。为了避免如此微小的操作,可能不值得向 R 源添加逻辑来确定行是否相同。

\n

那么,为什么问题中的示例对于较大的数据帧需要更长的时间?

\n

在您的示例以及 Joris C. 的回答中值得注意的是,如果操作attr(new, "row.names") <- attr(dat, "row.names")单独或作为较大函数调用(例如 )的一部分包含 ,则需要更长的时间utils::modifyList(attributes(dat), attributes(new))。让我们尝试一下简单的方法:

\n
attr(new, "row.names") <- attr(dat, "row.names")\nget_dat_obj_tree(sxp(new))\n1 dat    VECSXP    length: 2     mem_addr:0x15\n2  \xc2\xa6--x_int    INTSXP    length: 10     mem_addr:0x2\n3  \xc2\xa6--x_char    STRSXP    length: 10     mem_addr:0x1\n4  \xc2\xb0--_attrib    LISTSXP    length: 3     mem_addr:0x13\n5      \xc2\xa6--names    STRSXP    length: 2     mem_addr:0x10\n6      \xc2\xa6--class    STRSXP    length: 1     mem_addr:0x5\n7      \xc2\xb0--row.names    INTSXP    length: 2     mem_addr:0x14\n
Run Code Online (Sandbox Code Playgroud)\n

有一个新的内存地址。但row.names的属性new仍然是一个长度为 2 的整数向量。如果我们运行,dput(new)我们会看到row.names = c(NA, -10L)。

\n

因此,如果我们将长度为 2 的整数向量从一个地方复制到另一个地方,无论数据大小如何,为什么对于较大的数据帧会花费更长的时间?答案是当你运行时会发生什么:

\n
attr(new, "row.names") <- attr(dat, "row.names")\n
Run Code Online (Sandbox Code Playgroud)\n

这是语法糖:

\n
new  <- `attr<-`(new, "row.names", attr(dat, "row.names"))\n
Run Code Online (Sandbox Code Playgroud)\n

首先,这意味着我们正在评估row.namesfor dat。其次,正如R 内部人士指出的,有一个类似的例子,a <- `dim<-`(a, c(7, 2)):

\n
\n

原则上两份a原则上在计算期间存在

\n
\n

所以这可能会发生两次。

\n

评估在哪里进行?

\n

理解这一点的一种更简单的方法是打印该函数调用的右侧。

\n
`attr<-`(new, "row.names", attr(dat, "row.names")) \n# <truncated>\n# attr(,"row.names")\n#  [1]  1  2  3  4  5  6  7  8  9 10\n
Run Code Online (Sandbox Code Playgroud)\n

当它们row.names存储在attrib.cnew中的 R 源代码中时,它足够聪明,可以将其恢复为c(NA, n)以下形式:

\n
INTEGER(val)[0] = NA_INTEGER;\nINTEGER(val)[1] = n; // +n:  compacted *and* automatic row names\n
Run Code Online (Sandbox Code Playgroud)\n

然而,损害已经造成,简短的形式c(NA, -10)行名称已被完全评估,正如您所期望的(并且已经证明的),对于较长的行名称向量需要更多的时间。

\n

解决方案

\n

data.table在基础 R 以及和包中可以避免这个问题tidyverse。

\n

基础R溶液

\n

要点是 - 不要将行名称从一个数据框复制到另一个数据框。Joris C. 建议的函数复制子集操作未复制的任何属性,而不是复制所有属性,是一个很好的基础 R 解决方案。

\n

数据表解决方案

\n

另一种方法是将数据框转换为 adata.table并使用data.table::setattr()通过引用设置属性:

\n
library(data.table)\norig <- data.frame(x1 = 1, x2 = 2)\nsetDT(orig)\n\nmem_location  <- tracemem(orig)\n\nsetattr(orig, "foo", TRUE)\n\ntracemem(orig) == mem_location # TRUE\n\nattr(orig, "foo") # TRUE\n
Run Code Online (Sandbox Code Playgroud)\n

此外,data.table您可以通过引用更改列顺序,这样在对列重新排序时就不会丢失属性:

\n
setcolorder(orig, c(2,1))\nattr(orig, "foo") # TRUE\n\norig\n#    x2 x1\n# 1:  2  1\n
Run Code Online (Sandbox Code Playgroud)\n

整洁宇宙解决方案

\n

类似地,当您对列进行子集化时,atibble()会保留其row.names属性:

\n
library(tibble)\n\nset.seed(0)\nnum_rows  <- 10\ndat  <- tibble(\n    x_char = sample(letters, num_rows),\n    x_int = sample(1:10, num_rows)\n) \n\nattr(dat, "foo")  <- TRUE\n\nnew  <- dat[,c(2,1)]\n\nattr(new, "foo") # TRUE\n
Run Code Online (Sandbox Code Playgroud)\n

我在这个问题上陷入了几个死胡同,并发布了两个不太正确的答案,然后我才明白幕后到底发生了什么。但在这个过程中我学到了很多关于 R 的知识。感谢您提出如此有趣的问题。

\n


Jor*_*hau 8

data.frame复制所有属性的计算时间随着 的大小而变化的原因data.frame似乎主要是由于row.names属性。

我们可以检查复制row.names属性占用了大部分计算时间:

orig <- data.frame(x1 = rep(1, 1e7), x2 = rep(2, 1e7))
attr(orig, "foo") <- TRUE
new <- orig[, c(2, 1)]

microbenchmark::microbenchmark(
  all_attrs = { attributes(new) <- attributes(orig) },
  rownames = { attr(new, "row.names") <- attr(orig, "row.names") },
  foo = { attr(new, "foo") <- attr(orig, "foo") },
  times = 10,
  unit = "ms"  
)
#> Unit: milliseconds
#>       expr       min       lq       mean     median        uq        max neval
#>  all_attrs 60.477554 61.18414 64.3562408 61.9978505 67.117645  72.827139    10
#>   rownames 59.831147 61.21029 69.6012781 64.2950890 68.880676 106.280348    10
#>        foo  0.001043  0.00206  0.0072771  0.0087225  0.011206   0.015295    10
Run Code Online (Sandbox Code Playgroud)

foo如果我们将其与在较小的情况下复制属性进行比较data.frame,则时间(大致)具有相同的顺序:

orig <- data.frame(x1 = 1, x2 = 2)
attr(orig, "foo") <- TRUE
new <- orig[, c(2, 1)]

microbenchmark::microbenchmark(
  foo = { attr(new, "foo") <- attr(orig, "foo") },
  unit = "ms"
)
#> Unit: milliseconds
#>  expr     min      lq       mean    median        uq      max neval
#>   foo 0.00115 0.00118 0.00146262 0.0012055 0.0012725 0.022368   100
Run Code Online (Sandbox Code Playgroud)

为了提高效率,您可以选择仅复制任何自定义定义的属性(而不是所有data.frame属性)。例如:

## replace only custom attributes
replace_attrs <- function(obj, new_attrs) {
  for(nm in setdiff(names(new_attrs), names(attributes(data.frame())))) {
    attr(obj, which = nm) <- new_attrs[[nm]]
  }
  return(obj)
}

new <- replace_attrs(new, attributes(orig))
Run Code Online (Sandbox Code Playgroud)