我们需要至少涵盖以下几个方面,以提供全面的答案/比较(重要性无特定顺序):速度、内存使用、语法和功能。
我的意图是从data.table的角度尽可能清楚地涵盖每个方面。
引用:
注意:除非明确说明,否则提到dplyr时,我们指的是使用Rcpp在C++中实现的dplyr的data.frame接口。
数据表的语法在形式上是一致的 -
DT[i, j, by]
。将
i、
j和
by放在一起是有意设计的。通过将相关操作放在一起,可以方便地对操作进行优化,提高速度,更重要的是减少内存使用,并提供一些强大的功能,同时保持语法的一致性。
1. 速度
已经在问题中添加了相当多的基准测试(尽管大多数是关于分组操作),显示出随着分组数量和/或分组行数的增加,data.table比dplyr更快,包括Matt关于从1000万到20亿行(100GB内存)进行分组的基准测试,以及100-1000万个分组和不同的分组列,还比较了pandas。另请参阅更新的基准测试,其中还包括Spark和Polars。
在基准测试中,还可以涵盖以下剩余方面,这将是很好的。
2. 内存使用
dplyr中涉及到filter()
或slice()
的操作可能在内存上效率低下(对于data.frame和data.table都是如此)。请参阅此帖子。
请注意Hadley的评论谈到的是速度(对于他来说,dplyr的速度已经足够快),而这里的主要关注点是内存。
目前,data.table接口允许通过引用修改/更新列(注意,我们不需要将结果重新分配给变量)。
# 通过引用进行子分配,原地更新'y'
DT[x >= 1L, y := NA]
但是dplyr 永远不会通过引用进行更新。dplyr的等效操作将是(请注意,需要重新分配结果):
# 复制整个'y'列
ans <- DF
对此的一个担忧是引用透明性。通过引用更新data.table对象,特别是在函数内部,可能并不总是可取的。但这是一个非常有用的功能:请参阅此和此帖子以了解有趣的案例。我们希望保留它。
因此,我们正在努力导出data.table中的shallow()
函数,以为用户提供两种可能性。例如,如果在函数内部不希望修改输入的data.table,则可以执行以下操作:
foo <- function(DT) {
DT = shallow(DT)
DT[, newcol := 1L]
DT[x > 2L, newcol := 2L]
DT[x > 2L, x := 3L]
}
通过不使用shallow()
,保留了旧功能:
bar <- function(DT) {
DT[, newcol := 1L]
DT[x > 2L, x := 3L]
}
通过使用shallow()
创建浅复制,我们理解您不希望修改原始对象。我们在内部处理一切,以确保在确实需要复制列时,仅复制您修改的列。一旦实现,这将完全解决引用透明性问题,并为用户提供两种可能性。
此外,一旦导出shallow()
,dplyr的data.table接口几乎可以避免所有复制。因此,那些喜欢dplyr语法的人可以将其与data.table一起使用。
但它仍然缺少data.table提供的许多功能,包括(子)引用赋值。
在连接时进行聚合:
假设您有两个如下的data.table:
DT1 = data.table(x=c(1,1,1,1,2,2,2,2), y=c("a", "a", "b", "b"), z=1:8, key=c("x", "y"))
DT2 = data.table(x=1:2, y=c("a", "b"), mul=4:3, key=c("x", "y"))
您想要在连接列x,y
上,对DT2
中的每一行计算sum(z) * mul
。我们可以选择:
-
聚合DT1
以获取sum(z)
,2)执行连接,3)相乘(或)
data.table方式
DT1[, .(z = sum(z)), keyby = .(x,y)][DT2][, z := z*mul][]
dplyr等效方式
DF1 %>% group_by(x, y) %>% summarise(z = sum(z)) %>%
right_join(DF2) %>% mutate(z = z * mul)
-
一次完成所有操作(使用by = .EACHI
功能):
DT1[DT2, list(z=sum(z) * mul), by = .EACHI]
有什么优势?
请查看此帖子以获取有关by = .EACHI
的详细解释。没有中间结果被实现,连接+聚合一次完成。
请查看此, 此和此帖子以获取实际使用场景。
在dplyr
中,您将不得不先连接再聚合或先聚合再连接,这两种方法都不如(从内存的角度来看,也就是速度)高效。
更新和连接:
考虑下面的data.table代码:
DT1[DT2, col := i.mul]
在DT2
的关键列与DT1
匹配的行上,将DT1
的列col
添加/更新为DT2
中的mul
。我认为在dplyr
中没有这个操作的确切等效方式,即,不避免*_join
操作,这将不得不复制整个DT1
,只是为了添加一个新列,这是不必要的。
请查看此帖子以获取实际使用场景。
总结一下,意识到每一点优化的重要性是很重要的。正如Grace Hopper所说,要注意你的纳秒!
3. 语法
现在让我们来看看语法。Hadley在这里评论说:
数据表非常快,但我认为它们的简洁性使得学习起来更加困难,而且编写后的代码也更难阅读...
我觉得这个评论没有意义,因为它非常主观。我们可以尝试对比语法的一致性。我们将比较data.table和dplyr语法的差异。
我们将使用下面显示的虚拟数据进行工作:
DT = data.table(x=1:10, y=11:20, z=rep(1:2, each=5))
DF = as.data.frame(DT)
基本的聚合/更新操作。
# 情况(a)
DT[, sum(y), by = z] ## data.table 语法
DF %>% group_by(z) %>% summarise(sum(y)) ## dplyr 语法
DT[, y := cumsum(y), by = z]
ans <- DF %>% group_by(z) %>% mutate(y = cumsum(y))
# 情况(b)
DT[x > 2, sum(y), by = z]
DF %>% filter(x>2) %>% group_by(z) %>% summarise(sum(y))
DT[x > 2, y := cumsum(y), by = z]
ans <- DF %>% group_by(z) %>% mutate(y = replace(y, which(x > 2), cumsum(y)))
# 情况(c)
DT[, if(any(x > 5L)) y[1L]-y[2L] else y[2L], by = z]
DF %>% group_by(z) %>% summarise(if (any(x > 5L)) y[1L] - y[2L] else y[2L])
DT[, if(any(x > 5L)) y[1L] - y[2L], by = z]
DF %>% group_by(z) %>% filter(any(x > 5L)) %>% summarise(y[1L] - y[2L])
data.table 语法简洁,而 dplyr 的语法相对冗长。在情况(a)中,两者几乎是等价的。
在情况(b)中,我们在 dplyr 中必须在 summarise 中使用 filter(),但在更新时,我们必须将逻辑放在 mutate() 中。然而,在 data.table 中,我们可以使用相同的逻辑表达这两个操作 - 在 x > 2 的行上操作,但在第一种情况下,获取 sum(y),而在第二种情况下,将这些行的 y 更新为其累积和。
这就是我们所说的 DT[i, j, by] 形式“一致”的含义。
类似地,在情况(c)中,当我们有 if-else 条件时,我们能够在 data.table 和 dplyr 中以“原样”表达逻辑。然而,如果我们只想返回满足 if 条件的行并跳过其他行,我们不能直接使用 summarise()(据我所知)。我们必须先使用 filter(),然后再使用 summarise(),因为 summarise() 总是期望一个“单个值”。
虽然它返回相同的结果,但在这里使用 filter() 使实际操作不太明显。
在第一种情况下,可能也可以使用 filter()(对我来说不太明显),但我的观点是我们不应该这样做。
多列的聚合/更新
# 情况(a)
DT[, lapply(.SD, sum), by = z] ## data.table 语法
DF %>% group_by(z) %>% summarise_each(funs(sum)) ## dplyr 语法
DT[, (cols) := lapply(.SD, sum), by = z]
ans <- DF %>% group_by(z) %>% mutate_each(funs(sum))
# 情况(b)
DT[, c(lapply(.SD, sum), lapply(.SD, mean)), by = z]
DF %>% group_by(z) %>% summarise_each(funs(sum, mean))
# 情况(c)
DT[, c(.N, lapply(.SD, sum)), by = z]
DF %>% group_by(z) %>% summarise_each(funs(n(), mean))
在情况(a)中,代码几乎是等价的。data.table 使用熟悉的基本函数 lapply(),而 dplyr 引入了 *_each() 以及一堆函数来定义 funs()。
data.table 的 := 需要提供列名,而 dplyr 会自动生成列名。
在情况(b)中,dplyr 的语法相对直观。改进多函数的聚合/更新是 data.table 的目标。
然而,在情况(c)中,dplyr 会将 n() 返回多次,而不是只返回一次。在 data.table 中,我们只需要在 j 中返回一个列表。列表的每个元素将成为结果中的一列。因此,我们可以再次使用熟悉的基本函数 c() 将 .N 连接到返回的列表中,从而返回一个列表。
注意:再次强调,在 data.table 中,我们只需要在 j 中返回一个列表。列表的每个元素将成为结果中的一列。您可以使用 c()、as.list()、lapply()、list() 等基本函数来实现这一点,而无需学习任何新的函数。
您只需要学习特殊变量 - .N 和 .SD。dplyr 中的等价物是 n() 和 .。
连接
dplyr 为每种类型的连接提供了单独的函数,而 data.table 允许使用相同的语法 DT[i, j, by] 进行连接(有原因)。它还提供了一个等价的 merge.data.table() 函数作为替代。
setkey(DT1, x, y)
# 1. 普通连接
DT1[DT2] ## data.table 语法
left_join(DT2, DT1) ## dplyr 语法
# 2. 在连接时选择列
DT1[DT2, .(z, i.mul)]
left_join(select(DT2, x, y, mul), select(DT1, x, y, z))
# 3. 在连接时聚合
DT1[DT2, .(sum(z) * i.mul), by = .EACHI]
DF1 %>% group_by(x, y) %>% summarise(z = sum(z)) %>%
inner_join(DF2) %>% mutate(z = z*mul) %>% select(-mul)
# 4. 在连接时更新
DT1[DT2, z := cumsum(z) * i.mul, by = .EACHI]
??
# 5. 滚动连接
DT1[DT2, roll = -Inf]
??
# 6. 控制输出的其他参数
DT1[DT2, mult = "first"]
??
有些人可能会觉得每个连接都有单独的功能更好(左连接、右连接、内连接、反连接、半连接等),而另一些人可能喜欢data.table的DT[i, j, by]或类似于base R的merge()。
然而,dplyr的连接只是这样做。没有多余的东西,也没有少了的东西。
data.table可以在连接时选择列(2),而在dplyr中,您需要在连接之前先在两个数据框上使用select()。否则,您将只能使用不必要的列来实现连接,然后再将它们删除,这是低效的。
data.table可以在连接时进行聚合(3),也可以在连接时进行更新(4),使用by = .EACHI功能。为什么要将整个连接结果材料化,只是为了添加/更新几列呢?
data.table能够进行滚动连接(5)-滚动
向前,LOCF,
向后滚动,NOCB,
最近。
data.table还有一个mult =参数,可以选择第一个、最后一个或所有匹配项(6)。
data.table有一个allow.cartesian = TRUE参数,以防止意外的无效连接。
再次强调,语法与
DT[i, j, by]
一致,还可以通过额外的参数进一步控制输出。
do()
...
dplyr的summarise函数专门设计用于返回单个值的函数。如果您的函数返回多个/不同的值,您将不得不使用do()
函数。您必须事先了解所有函数的返回值。
DT[, list(x[1], y[1]), by = z]
DF %>% group_by(z) %>% summarise(x[1], y[1])
DT[, list(x[1:2], y[1]), by = z]
DF %>% group_by(z) %>% do(data.frame(.$x[1:2], .$y[1]))
DT[, quantile(x, 0.25), by = z]
DF %>% group_by(z) %>% summarise(quantile(x, 0.25))
DT[, quantile(x, c(0.25, 0.75)), by = z]
DF %>% group_by(z) %>% do(data.frame(quantile(.$x, c(0.25, 0.75))))
DT[, as.list(summary(x)), by = z]
DF %>% group_by(z) %>% do(data.frame(as.list(summary(.$x))))
再次强调,data.table的语法与DT[i, j, by]
一致。我们可以继续在j
中添加表达式,而不必担心这些问题。
请看这个SO问题和这个问题。我想知道是否可能用dplyr的语法来直接表达答案...
总结一下,我特别强调了几个dplyr语法效率低下、功能有限或操作不直观的实例。这主要是因为data.table在“难以阅读/学习”的语法方面受到了很多批评(就像上面粘贴/链接的那个)。大多数涉及dplyr的帖子都谈到了最直接的操作,这很好。但是也很重要意识到它的语法和功能限制,我还没有看到过相关的帖子。
data.table也有它自己的怪癖(其中一些我已经指出我们正在努力修复)。我们也在努力改进data.table的连接操作,我在这里
here也提到了。
但是人们也应该考虑到dplyr相对于data.table缺少的功能数量。
4. 功能
我在这里
here和本文中指出了大部分功能。另外:
fread - 快速文件阅读器现在已经可用很长时间了。
fwrite - 现在可用的并行化快速文件写入器。详细说明请参见this post,了解实现方式,并参考#1664以跟踪进一步的发展。
自动索引 - 另一个方便的功能,用于优化基本的 R 语法。
临时分组:在summarise()
期间,dplyr
会自动按分组变量对结果进行排序,但这可能并不总是理想的。
如上所述,data.table
连接具有许多优势(用于提高速度/内存效率和语法)。
非等值连接:允许使用其他运算符<=, <, >, >=
进行连接,同时享受data.table
连接的所有其他优势。
最近在data.table
中实现了重叠范围连接。请查看this post以获取概述和基准测试。
setorder()
函数在data.table
中可以通过引用实现非常快速的数据表重新排序。
dplyr
使用相同的语法提供了与数据库的接口,而data.table
目前还没有这个功能。
data.table
提供了更快的集合操作等效函数(由Jan Gorecki编写)- fsetdiff
,fintersect
,funion
和fsetequal
,并附加了all
参数(与SQL中的用法相同)。
data.table
在加载时不会产生掩盖警告,并且在传递给任何R包时,有一个在这里描述的机制,用于与[.data.frame
兼容。dplyr更改了基本函数filter
,lag
和[
,可能会引起问题;例如这里和这里。
最后:
关于数据库 - 没有理由为什么data.table不能提供类似的接口,但这并不是现在的重点。如果用户非常希望这个功能,可能会提高优先级..不确定。
关于并行性 - 一切都很困难,直到有人去做为止。当然,这需要付出努力(确保线程安全)。
目前正在进行进展(在v1.9.7开发版中),通过使用OpenMP来并行化已知的耗时部分,以实现增量性能提升。
dplyr
的解决方案是:as.data.table(dat)[, .SD[job != "Boss" | year == min(year)][, cumjob := cumsum(job2)], by = list(name, job)]
。 - eddi(d)plyr
来表达的问题集合的概率为零。 - eddidplyr
和plyr
的语法,有一件事真的让我很烦恼,这基本上是我不喜欢它们语法的主要原因,就是我必须学习太多(不止一个)额外的函数(它们的名称仍然对我来说没有意义),记住它们的作用,它们接受的参数等等。这对于我来说一直是plyr哲学中的巨大障碍。 - eddi.SD
)。[认真地]我认为这些是合理的设计差异,会吸引不同的人。 - hadley.SD
等内容的讨论 - 这很公正 - 我花了一点时间才理解.SD
,但当我掌握它时,我已经能够做很多事情了,而 (d)plyr 则在一开始就给你设置了一个很大的障碍。 - eddi