7 min read

Programming on data.table 1.14.3 -中文版

data.table实在是太强大,看它cran的介绍“Fast aggregation of large data (e.g. 100GB in RAM), fast ordered joins, fast add/modify/delete of columns by group using no copies at all, list columns, friendly and fast character-separated-value read/write. Offers a natural and flexible syntax, for faster development.”,可以操作100GB的数据。

在数据处理脚本中,data.table是主角。但是如何更为简单、方便和高效的传递参数给data.table,的确缺乏指导。前段时间看到其官方主页的这篇vignette,的确有种见猎心喜的感觉。

1 简介

data.table,从它的第一个版本开始,就通过定义[.data.table方法启用了subset和with(或within)函数。subset和with是基本的R函数,它们有助于减少代码中的重复,增强可读性,并减少用户必须输入的字符总数。这个功能在R中是可能的,因为它有一个非常独特的特性,叫做惰性求值或延迟求值(Lazy evaluation)。

该特性允许一个函数在参数被计算之前捕获参数,并在不同于调用参数的范围内计算参数。让我们回顾一下subset函数的用法。

subset(iris, Species == "setosa")
#   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
# 1          5.1         3.5          1.4         0.2  setosa
# 2          4.9         3.0          1.4         0.2  setosa
#  [ reached 'max' / getOption("max.print") -- omitted 48 rows ]

在这里,subset接受第二个参数Species == “setosa”,并在iris数据框(data.frame)的作用域内作为它的第一个参数进行计算。这消除了对变量重复的需求,使其更不容易出错,并使代码更具可读性。

2 问题描述

这种接口的问题在于,我们不能轻易地参数化使用它的代码。这是因为传递给这些函数的表达式在求值之前被替换了

例如:

my_subset = function(data, col, val) {
  subset(data, col == val)
}
my_subset(iris, Species, "setosa")
# Error in eval(e, x, parent.frame()): 找不到对象'Species'

3 解决方式

3.1 避免延迟求值

最简单的解决方法是一开始就避免延迟计算,并退回到不太直观、更容易出错的方法,如df[[“variable”]]等。

my_subset = function(data, col, val) {
  data[data[[col]] == val, ]
}
my_subset(iris, col = "Species", val = "setosa")
#   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
# 1          5.1         3.5          1.4         0.2  setosa
# 2          4.9         3.0          1.4         0.2  setosa
#  [ reached 'max' / getOption("max.print") -- omitted 48 rows ]

在这里,我们计算一个长度为nrow(iris)的逻辑向量,然后将这个向量提供给[.data.frame的i参数来执行普通逻辑向量提取子集。对于这个简单的示例,它工作得很好,但它缺乏灵活性,引入了变量重复repetition,并要求用户更改函数接口,以字符而不是未加引号的符号传递列名。我们需要参数化的表达式越复杂,这种方法就越不实用。

3.2 使用parse / eval

R新手通常更喜欢这种方法,因为它在概念上可能是最直接的。

这种方式需要使用字符串连接生成所需的表达式,解析它,然后求值。

my_subset = function(data, col, val) {
  data = deparse(substitute(data))
  col = deparse(substitute(col))
  val = paste0("'",val, "'")
  text = paste0("subset(", data, ", ", col, " == ", val, ")")
  eval(parse(text = text)[[1L]])
}
my_subset(iris, Species, "setosa")
#   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
# 1          5.1         3.5          1.4         0.2  setosa
# 2          4.9         3.0          1.4         0.2  setosa
#  [ reached 'max' / getOption("max.print") -- omitted 48 rows ]

substitute()是把对象转换为表达式;deparse()把表达式转换为字符;。

看下边简单的例子:

a = 3
deparse(a)
# [1] "3"
substitute(a)
# a
deparse(substitute(a))
# [1] "a"

我们最好使用deparse(substitute(…))这种形式来捕获对象的实际名称,以传递给函数,这样我们就可以使用那些初始名称构造subset函数调用。

然而上述形式,作者并不推荐。Although ths provides unlimited flexibility with relatively low complexity, use of eval(parse(…)) should be avoided.

不推荐的主要理由包括:

  • 缺乏语法验证
  • 代码注入漏洞
  • 存在更好的选择

对这三条理由,理解并不是很深刻。姑且相信大神们的判断吧。只是觉得,通过eval(parse(text=…))形式,出了问题,不好调试,不容易知道哪些参数出现了错误。

R项目核心开发者Martin Machler曾经说过:

抱歉,但我不明白为什么很多人甚至认为字符串是可以计算的。你必须改变你的心态,真的。忘记一端的字符串与另一端的表达式、调用和求值之间的所有连接。(可能)唯一的连接是通过parse(text = ….),所有优秀的R程序员都应该知道,这很少是构造表达式(或调用)的有效或安全的方法。而是了解更多关于substitute()、quote()以及使用do.call(substitute, ……)的威力。

下边是更简单和更好的方法。

3.3 基于语言对象计算 Computing on the language

上述函数,以及其他一些函数(包括as. call, as.name/as.symbol, bquote和eval),可以被归类为在语言上进行计算的函数,因为它们对语言对象(例如call, name/symbol)进行操作。

my_subset = function(data, col, value) {
  eval(substitute(subset(data, col == value)))
}
my_subset(iris, Species, "setosa")
#   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
# 1          5.1         3.5          1.4         0.2  setosa
# 2          4.9         3.0          1.4         0.2  setosa
#  [ reached 'max' / getOption("max.print") -- omitted 48 rows ]

这里,我们使用base R的substitute函数将subset(data, col == val)调用转换为subset(iris, Species == “setosa”),方法是将data、col和val替换为它们来自父环境的原始名称(或值)。与前面的方法相比,这种方法的好处应该是显而易见的。请注意,因为我们在语言对象级别上操作,而不必求助于字符串操作,所以我们将其称为语言上的计算。

在R语言手册中有专门的一章是关于在语言水平上计算的。尽管这对于掌握programming on data. table不是必须的,作者鼓励读者阅读本章,以便更好地理解这一强大而独特的功能。

3.4 其他包

pryr, lazyeval and rlang。其中pryr已经被废弃了。

4 基于data.table编程

现在我们已经建立了正确的方法来参数化代码进行延迟评估。那么我们转换到这个vignette的主题,针对data.table进行编程。

从1.14.2开始, data.table提供了一个健壮的机制,参数化表达式,提供给[.data.table 的i、j和by(或keyby)参数。它是建立在base R substitute函数上的,并模仿其接口。接下来,我们介绍substitute2作为一个更健壮和更友好的版本,替代base R substitute。

关于base::substitute and data.table::substitute2的差异,参见substitute2手册

4.1 替换变量variables和名字names

假设我们想要一个通用的函数,该函数用于对两个参数求和,每个参数来自于另外一个函数的输出结果。

作为一个具体的例子,下面我们有一个计算直角三角形斜边长度的函数,已知它的边长。 \[ c=\sqrt{a^{2}+b^{2}} \]

square = function(x) x^2
quote(
  sqrt(square(a) + square(b))
)
# sqrt(square(a) + square(b))

关于substitute和quote的区别,参见这个链接,中文解释见这里

f <- function(argX) {
   list(quote(argX), substitute(argX), argX)
}
suppliedArgX <- 100
f(argX = suppliedArgX)
# [[1]]
# argX
# 
# [[2]]
# suppliedArgX
# 
# [[3]]
# [1] 100

这时候可以看出两者处理函数的参数是不一样的,quote一直就是返回它括起的变量名称argx,而substitute如果参数名字有赋值,那就是赋值后的变量名称suppliedArgX。

更具体说是这样的:substitute解析每个元素进行替换,如果它不是env中的绑定符号,则保持不变。

继续返回到本文。

目标是使上述调用sqrt(square(a) + square(b))中的每个名称都能作为参数传递。

substitute2(
  outer(inner(var1) + inner(var2)),
  env = list(
    outer = "sqrt",
    inner = "square",
    var1 = "a",
    var2 = "b"
  )
)
# sqrt(square(a) + square(b))

我们可以在输出中看到,函数名以及传递给这些函数的变量名都被替换了。 在这个简单的例子中,也可以使用base R的替换,尽管它需要使用lapply(env, as.name)。

现在,在[.data.table内部进行替换,我们不需要调用substitute2函数。由于它现在已在内部使用,我们所要做的就是提供env参数,就像我们在上面的例子中为substitute2函数提供的一样。替换可以应用于[.data.table方法的i、j和by(或keyby)参数。注意,将verbose参数设置为TRUE可用于在应用替换后打印表达式。这对调试非常有用。

让我们使用鸢尾花数据集进行演示。作为一个例子,让我们假设我们想要计算萼片的直角斜边,想象把萼片的宽度和长度作为直角三角形的另外两个边。

直接输出直角斜边。

DT = as.data.table(iris)
DT[, outer(inner(var1) + inner(var2)), env = list(
  outer = "sqrt",
  inner = "square",
  var1 = "Sepal.Length",
  var2 = "Sepal.Width"
)]
# [1] 6.185467 5.745433 5.685948 5.547071 6.161169 6.661081 5.720140 6.046487
#  [ reached getOption("max.print") -- omitted 142 entries ]

作为一个data.table来输出.

DT[, .(Species, var1, var2, out = outer(inner(var1) + inner(var2))),
   env = list(
     outer = "sqrt",
     inner = "square",
     var1 = "Sepal.Length",
     var2 = "Sepal.Width",
     out = "Sepal.Hypotenuse"
  )]
#        Species Sepal.Length Sepal.Width Sepal.Hypotenuse
#   1:    setosa          5.1         3.5         6.185467
#   2:    setosa          4.9         3.0         5.745433
# [到达getOption("max.print") -- 略过9行]]

在最后一次调用中,我们添加了另一个参数,out = “Sepal.Hypotenuse”,表示输出列的名称。与以base R的substitute不同,substitute2也将处理调用参数名称的替换。

在这里还是没看出在脚本调用中的方便性来,因为data.table可以很方便的实现上述结果。

DT[, .(Species,
       Sepal.Length,
       Sepal.Width,
       Sepal.Hypotenuse = sqrt(square(Sepal.Length) + square(Sepal.Width)))]
#        Species Sepal.Length Sepal.Width Sepal.Hypotenuse
#   1:    setosa          5.1         3.5         6.185467
#   2:    setosa          4.9         3.0         5.745433
# [到达getOption("max.print") -- 略过9行]]

替换也适用于i和by(或keyby)。

DT[filter_col %in% filter_val,
   .(var1, var2, out = outer(inner(var1) + inner(var2))),
   by = by_col,
   env = list(
     outer = "sqrt",
     inner = "square",
     var1 =  "Sepal.Length",
     var2 = "Sepal.Width",
     out = "Sepal.Hypotenuse",
     filter_col = "Species",
     filter_val = I(c("versicolor", "virginica")),
     by_col = "Species"
   )]
#         Species Sepal.Length Sepal.Width Sepal.Hypotenuse
#   1: versicolor          7.0         3.2         7.696753
#   2: versicolor          6.4         3.2         7.155418
# [到达getOption("max.print") -- 略过99行]]

4.2 替换变量和字符值

在上面的例子中,我们看到了一个substitute2的一个方便的功能:从字符串到名称/符号的自动转换。

一个明显的问题出现了:如果我们实际上想用一个字符值来替换一个参数,就像base R的substitute的行为。

我们提供了一种机制,通过将元素包装成基R I()调用来逃避自动转换。I函数标记对象为AsIs,防止其参数被替换。(读一下?AsIs文档,查阅更多的细节)。如果整个env参数都需要base R行为,那么最好在I()中wrap全部参数。或者,每个列表元素可以单独包装在I()中。让我们来探讨以下几种情况。

# base R 行为
substitute(rank(input, ties.method = ties),
           env = list(input = as.name("Sepal.Width"), ties = "first"))
# rank(Sepal.Width, ties.method = "first")

# substitute2行为,无法传递字符"first"作为参数
substitute2(rank(input, ties.method = ties),
           env = list(input = as.name("Sepal.Width"), ties = "first"))
# rank(Sepal.Width, ties.method = first)

# 利用I()函数模仿base R的substitute行为
substitute2(rank(input, ties.method = ties),
           env = I(list(input = as.name("Sepal.Width"), ties = "first")))
# rank(Sepal.Width, ties.method = "first")

# substitute2行为 不加as.name,会都被转换为字符
substitute2(rank(input, ties.method = ties),
           env = I(list(input = "Sepal.Width", ties = "first")))
# rank("Sepal.Width", ties.method = "first")

# substitute2行为,不加as.name,单独对ties使用I()函数,仅会使ties的值变为字符
substitute2(rank(input, ties.method = ties),
           env = list(input = "Sepal.Width", ties = I("first")))
# rank(Sepal.Width, ties.method = "first")

小结一下:

base R substitute和data.table的substitute2的区别,前者会自动转为字符,后者不会自动转为字符

因此在substitute中使用as.name()函数可以确保其参数不转为字符,而在substitute2中使用I()可以确保其参数转为字符。

注意,在每个列表元素上递归地进行转换,当然包括escape机制。

substitute2(   # all are symbols
  fx(v1, v2),
  list(v1 = "a", v2 = list("b", list("c", "d")))
)
# fx(a, list(b, list(c, d)))
# f(a, list(b, list(c, d)))
substitute2(   # 'a' and 'd' should stay as character
  fx(v1, v2),
  list(v1 = I("a"), v2 = list("b", list("c", I("d"))))
)
# fx("a", list(b, list(c, "d")))

4.3 替换任意长度的列表

上面的示例演示了一种简洁而强大的方法,可以使代码更加动态。然而,开发人员可能需要处理许多其他更复杂的情况。一个常见的问题是处理任意长度的参数列表。

一个明显的用例是通过在j参数中注入一个列表调用来模拟.SD功能。

cols = c("Sepal.Length", "Sepal.Width")
DT[, .SD, .SDcols = cols]
#      Sepal.Length Sepal.Width
#   1:          5.1         3.5
#   2:          4.9         3.0
# [到达getOption("max.print") -- 略过9行]]

我们可以cols参数拼接到一个列表调用中,使j参数看起来像下面的代码。

DT[, list(Sepal.Length, Sepal.Width)]
#      Sepal.Length Sepal.Width
#   1:          5.1         3.5
#   2:          4.9         3.0
# [到达getOption("max.print") -- 略过9行]]

拼接是一种操作,其中必须将对象列表内联到表达式中,作为要调用的参数序列。在base R中,可以使用as.call(c(quote(list), cols))将cols拼接到列表中。此外,从R 4.0.0开始,bquote函数中有一个用于此类操作的新接口。

在data.table中,我们通过自动将对象列表加入到使用这些对象的列表调用中,使它变得更容易。 这意味着env list参数中的任何list对象都将被转换为list调用,使得该用例的API简单如下面所示。

DT[, j,
   env = list(j = as.list(cols)),
   verbose = TRUE]
# Argument 'j' after substitute: list(Sepal.Length, Sepal.Width)
# Detected that j uses these columns: [Sepal.Length, Sepal.Width]
#      Sepal.Length Sepal.Width
#   1:          5.1         3.5
#   2:          4.9         3.0
# [到达getOption("max.print") -- 略过9行]]

如上面的示例所示,在env List参数中as.list,而不是简单list。as.list在env中使用。 接下来更详细的讨论如何enlist-ing。

# 将上面的'j'列表转换为一个列表调用(call),等同于as.list的作用
DT[, j,  
   env = list(j = quote(list(Sepal.Length, Sepal.Width))),
   verbose = TRUE]
# Argument 'j' after substitute: list(Sepal.Length, Sepal.Width)
# Detected that j uses these columns: [Sepal.Length, Sepal.Width]
#      Sepal.Length Sepal.Width
#   1:          5.1         3.5
#   2:          4.9         3.0
# [到达getOption("max.print") -- 略过9行]]
# 与上面相同,但接受字符向量as.name把字符变为name,as.call的作用,生成call
DT[, j,  
   env = list(j = as.call(c(quote(list), lapply(cols, as.name)))),
   verbose = TRUE]
# Argument 'j' after substitute: list(Sepal.Length, Sepal.Width)
# Detected that j uses these columns: [Sepal.Length, Sepal.Width]
#      Sepal.Length Sepal.Width
#   1:          5.1         3.5
#   2:          4.9         3.0
# [到达getOption("max.print") -- 略过9行]]

现在让我们尝试传递一个符号列表,而不是对这些符号进行列表调用。我们将使用I()来逃脱自动enlist-ing,但这也将关闭字符到符号的转换,因此我们还必须使用as.name。

DT[, j,  # list of symbols
   env = I(list(j = lapply(cols, as.name))),
   verbose = TRUE]
# Argument 'j' after substitute: list(Sepal.Length, Sepal.Width)
# Error: 当 with=FALSE,参数 j 必须为布尔型/字符型/整型之一,表征要选择的列。

运行上述代码会出现错误,因为env输出的不是列表调用。

# 同样正确的方法,自动enlist-ing 列表为列表调用
DT[, j,  
   env = list(j = as.list(cols)),
   verbose = TRUE]
# Argument 'j' after substitute: list(Sepal.Length, Sepal.Width)
# Detected that j uses these columns: [Sepal.Length, Sepal.Width]
#      Sepal.Length Sepal.Width
#   1:          5.1         3.5
#   2:          4.9         3.0
# [到达getOption("max.print") -- 略过9行]]

看一下上述两个表达式的区别:

str(substitute2(j, env = I(list(j = lapply(cols, as.name)))))
# List of 2
#  $ : symbol Sepal.Length
#  $ : symbol Sepal.Width

str(substitute2(j, env = list(j = as.list(cols))))
#  language list(Sepal.Length, Sepal.Width)

输出的一个symbol,一个是language list。还是不太懂,作者建议去看substitute2的手册

4.4 复杂查询的替换

让我们以一个更复杂的函数为例,计算均方根。 \[ x_{RMS} = \sqrt{(x_{1}^{2}+x_{2}^{2}+...+x_{n}^{2})/n} \] 它在输入时接受任意数量的变量,但现在我们不能只是将参数列表拼接到列表调用中,因为每个参数都必须包装在square调用中。在这种情况下,我们必须手工拼接而不是依赖data.table的自动enlist。

outer = "sqrt"
inner = "square"
vars = c("Sepal.Length", "Sepal.Width", "Petal.Length", "Petal.Width")

# 变量转换为symbol,储存在列表
syms = lapply(vars, as.name)
# 定义一个函数,生成language对象
to_inner_call = function(var, fun) call(fun, var)
# 把language对象存在表格中
inner_calls = lapply(syms, to_inner_call, inner)
print(inner_calls)
# [[1]]
# square(Sepal.Length)
# 
# [[2]]
# square(Sepal.Width)
# 
# [[3]]
# square(Petal.Length)
# 
# [[4]]
# square(Petal.Width)
to_add_call = function(x, y) call("+", x, y)
# 生成平方和累加的表达式,类型为language
add_calls = Reduce(to_add_call, inner_calls)
print(add_calls)
# square(Sepal.Length) + square(Sepal.Width) + square(Petal.Length) + 
#     square(Petal.Width)

#均方根表达式,类型同样为language
rms = substitute2(
  expr = outer((add_calls)/len),
  env = list(
    outer = outer,
    add_calls = add_calls,
    len = length(vars)
  )
)
print(rms)
# sqrt((square(Sepal.Length) + square(Sepal.Width) + square(Petal.Length) + 
#     square(Petal.Width))/4L)

# 根据构建的表达式,在DT中使用求解
DT[, j, env = list(j = rms)]
#  [1] 3.172538 2.958462 2.918047 2.874891 3.160696 3.443109 2.948305 3.116488
#  [9] 2.728095 2.994996 3.359315 3.049590
#  [ reached getOption("max.print") -- omitted 138 entries ]

# 当时也可以不使用rms,直接在DT中构建表达式
DT[,outer((add_calls)/len),env =list(
      outer = outer,
    add_calls = add_calls,
    len = length(vars)
)]
#  [1] 3.172538 2.958462 2.918047 2.874891 3.160696 3.443109 2.948305 3.116488
#  [9] 2.728095 2.994996 3.359315 3.049590
#  [ reached getOption("max.print") -- omitted 138 entries ]

# 返回data.table
j = substitute2(j, list(j = as.list(setNames(nm = c(vars, "Species", "rms")))))
j[["rms"]] = rms
print(j)
# list(Sepal.Length = Sepal.Length, Sepal.Width = Sepal.Width, 
#     Petal.Length = Petal.Length, Petal.Width = Petal.Width, Species = Species, 
#     rms = sqrt((square(Sepal.Length) + square(Sepal.Width) + 
#         square(Petal.Length) + square(Petal.Width))/4L))
DT[,j, env=list(j=j)]
#      Sepal.Length Sepal.Width Petal.Length Petal.Width   Species      rms
#   1:          5.1         3.5          1.4         0.2    setosa 3.172538
#   2:          4.9         3.0          1.4         0.2    setosa 2.958462
# [到达getOption("max.print") -- 略过9行]]

# 另外一种返回data.table的方法
j = as.call(c(
  quote(list),
  lapply(setNames(nm = vars), as.name),
  list(Species = as.name("Species")),
  list(rms = rms)
))
print(j)
# list(Sepal.Length = Sepal.Length, Sepal.Width = Sepal.Width, 
#     Petal.Length = Petal.Length, Petal.Width = Petal.Width, Species = Species, 
#     rms = sqrt((square(Sepal.Length) + square(Sepal.Width) + 
#         square(Petal.Length) + square(Petal.Width))/4L))
DT[, j, env = list(j = j)]
#      Sepal.Length Sepal.Width Petal.Length Petal.Width   Species      rms
#   1:          5.1         3.5          1.4         0.2    setosa 3.172538
#   2:          4.9         3.0          1.4         0.2    setosa 2.958462
# [到达getOption("max.print") -- 略过9行]]

4.5 计划放弃的接口

getmgeteval等。