r 大 data.table 为什么使用正则表达式提取单词比 stringr::word 更快?

r large data.table why is extracting a word with regex faster than stringr::word?

我有一个很大的 data.table,有超过 700 万行和 38 列。其中一列是字符向量,其中包含一个长的描述性句子。我知道每句话的第一个词是一个类别,第二个词是一个名称,这两个我都需要放在两个新的列中以供以后分析。

这可能无法很好地说明时差,因为它太小了(实际上 system.time() 在这个例子中给出了 0),但这里有一个玩具字符串来说明我正在尝试的内容做:

# Load libraries:
library(data.table)
library(stringr)

# Create example character string:
x <- c("spicy apple cream", "mild peach melba", "juicy strawberry tart")
id <- c(1,2,3)

# Create dt:
mydt <- data.table(id = id, desert = x)

假设在我的真实数据中,我想从每个字符串中提取第一个单词,并将其放入一个名为 category 的新变量中,然后从每个字符串中提取第二个单词并将其放入一个名为 fruit_name.

词法上最简单的方法似乎是使用 stringr::word(),这很有吸引力,因为它避免了计算复杂的正则表达式的需要:

# Add a new category column:
mydt[, category := stringr::word(desert, 1)]

# Add a new fruit name column:
mydt[, fruit_name := stringr::word(desert, 2)]

虽然这在小数据集上工作正常,但在我的真实数据集上它会永远持续下去(我怀疑它挂了,尽管我杀死了它并在 10 分钟后重新启动了 R)。就上下文而言,此数据集中的其他字符向量类型操作需要大约 20 秒才能完成 运行,因此似乎此函数的劳动强度和计算量特别大。

相比之下,如果我将正则表达式与 sub() 一起使用,它不会挂起,而且似乎以与其他字符向量运算大致相同的速度工作:

# Create category column with regex:
mydt[, category := sub("(^\w+).*", "\1", desert)]

# Create fruit name column with regex:
mydt[, fruit_name := sub("^\w+\s+(\w+).*", "\1", desert)]

谁能阐明这两种方法之间的速度差异?有趣的是,即使有这个玩具示例,运行ning system.time() with stringr::word() 在给出结果之前会挂起几秒钟,但这可能只是因为我的真实(大)数据集是加载到我的环境中。

stringr::word() 是否以某种方式打破了 data.table 按引用替换的惯例(创建新列而不复制整个 table)?不知何故,我认为 sub() 会更糟,因为它可能会复制整个字符串,然后替换为与正则表达式模式匹配的位,但实际上它要快得多。

非常感谢任何见解!

> sessionInfo()
R version 4.1.2 (2021-11-01)
Platform: x86_64-w64-mingw32/x64 (64-bit)
Running under: Windows 10 x64 (build 19043)

Matrix products: default

locale:
[1] LC_COLLATE=English_United Kingdom.1252  LC_CTYPE=English_United Kingdom.1252   
[3] LC_MONETARY=English_United Kingdom.1252 LC_NUMERIC=C                           
[5] LC_TIME=English_United Kingdom.1252    

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] officer_0.4.1     flextable_0.6.9   data.table_1.14.2 lubridate_1.8.0  
 [5] forcats_0.5.1     stringr_1.4.0     dplyr_1.0.7       purrr_0.3.4      
 [9] readr_2.1.0       tidyr_1.1.4       tibble_3.1.6      ggplot2_3.3.5    
[13] tidyverse_1.3.1  

loaded via a namespace (and not attached):
 [1] xfun_0.28         tidyselect_1.1.1  haven_2.4.3       colorspace_2.0-2 
 [5] vctrs_0.3.8       generics_0.1.1    htmltools_0.5.2   base64enc_0.1-3  
 [9] utf8_1.2.2        rlang_0.4.12      pillar_1.6.4      glue_1.5.0       
[13] withr_2.4.2       DBI_1.1.1         gdtools_0.2.3     dbplyr_2.1.1     
[17] uuid_1.0-3        modelr_0.1.8      readxl_1.3.1      lifecycle_1.0.1  
[21] munsell_0.5.0     gtable_0.3.0      cellranger_1.1.0  zip_2.2.0        
[25] rvest_1.0.2       evaluate_0.14     knitr_1.36        fastmap_1.1.0    
[29] tzdb_0.2.0        fansi_0.5.0       broom_0.7.10      Rcpp_1.0.7       
[33] scales_1.1.1      backports_1.3.0   jsonlite_1.7.2    fs_1.5.0         
[37] systemfonts_1.0.3 digest_0.6.28     hms_1.1.1         stringi_1.7.5    
[41] grid_4.1.2        cli_3.1.0         tools_4.1.2       magrittr_2.0.1   
[45] crayon_1.4.2      pkgconfig_2.0.3   ellipsis_0.3.2    xml2_1.3.2       
[49] reprex_2.0.1      rmarkdown_2.11    assertthat_0.2.1  httr_1.4.2       
[53] rstudioapi_0.13   R6_2.5.1          compiler_4.1.2   

这没有链接到 data.table

sub依赖内部C代码调用:

function (pattern, replacement, x, ignore.case = FALSE, perl = FALSE, 
  fixed = FALSE, useBytes = FALSE) 
{
  if (is.factor(x) && length(levels(x)) < length(x)) {
    sub(pattern, replacement, levels(x), ignore.case, perl, 
      fixed, useBytes)[x]
  }
  else {
    if (!is.character(x)) 
      x <- as.character(x)
    .Internal(sub(as.character(pattern), as.character(replacement), 
      x, ignore.case, perl, fixed, useBytes))
  }
} 

stringr::word 依赖于多个 lapply/vapply/mapply 调用:

function (string, start = 1L, end = start, sep = fixed(" ")) 
{
  n <- max(length(string), length(start), length(end))
  string <- rep(string, length.out = n)
  start <- rep(start, length.out = n)
  end <- rep(end, length.out = n)
  breaks <- str_locate_all(string, sep)
  words <- lapply(breaks, invert_match)
  len <- vapply(words, nrow, integer(1))
  neg_start <- !is.na(start) & start < 0L
  start[neg_start] <- start[neg_start] + len[neg_start] + 
    1L
  neg_end <- !is.na(end) & end < 0L
  end[neg_end] <- end[neg_end] + len[neg_end] + 1L
  start[start > len] <- NA
  end[end > len] <- NA
  starts <- mapply(function(word, loc) word[loc, "start"], 
    words, start)
  ends <- mapply(function(word, loc) word[loc, "end"], words, 
    end)
  str_sub(string, starts, ends)
}

对于单个字符串,没有太大区别:

desert <-"spicy apple cream"
microbenchmark::microbenchmark(
  stringr::word(desert, 1),
  sub("(^\w+).*", "\1", desert))

Unit: microseconds
                                expr  min    lq   mean median     uq   max neval
            stringr::word(desert, 1) 50.3 58.35 95.816  71.80 115.35 323.8   100
 sub("(^\\w+).*", "\\1", desert) 46.3 51.05 68.810  53.85  63.20 265.1   100

但是如果你复制 10^6 次,sub 会快 20 倍:

desert <- rep("spicy apple cream",10^6)
microbenchmark::microbenchmark(
  stringr::word(desert, 1),
  sub("(^\w+).*", "\1", desert),times=5)

Unit: milliseconds
                                expr        min        lq       mean     median         uq        max
            stringr::word(desert, 1) 11605.1720 13724.731 14484.9069 14043.3454 16066.1067 16985.1798
 sub("(^\\w+).*", "\\1", desert)   696.2793   752.516   771.5857   797.5788   803.7969   807.7577

对于 data.table,'stringi' 库比 'stringr' 更快,后者是一组使用 stringi 库的 tidyverse 例程。 data.table grepl 的包装器是 %ilike% 这可能更方便,但仍然比 stringi 库慢。

在下面的函数中,我搜索了大约 70 万行,每行 64 个字段,每个 VAERS 数据。我在每行的两个字段中搜索相同的术语,并将搜索结果传递给管道。整个搜索在 de-duplicate 和 dcast.

之前的 data.table 操作的 'i' (过滤器)短语中进行。

stringi 函数的速度大约是其三倍。请参阅下面的 'system.time' 个结果。结果表(未显示)return 相同的数据。没有任何复杂的分析,看起来 'stringi' 使用内存比 %ilike% grepl 包装器更有效,后者在操作中看起来更 'CPU bound' 。更多信息:

[https://rdatatable.gitlab.io/data.table/reference/like.html][1] [https://stringr.tidyverse.org/][2]

library(data.table)
all.heart.ilike <- function()
{
all.covid.data[
(All_symptoms %ilike% "carditis"| SYMPTOM_TEXT %ilike% "carditis")| 
(All_symptoms %ilike% "cardiac"| SYMPTOM_TEXT %ilike% "cardiac")|
(All_symptoms %ilike% "myocardial"| SYMPTOM_TEXT %ilike% "myocardial")|
(All_symptoms %ilike% "coronary"| SYMPTOM_TEXT %ilike% "coronary")|
(All_symptoms %ilike% "infarct"| SYMPTOM_TEXT %ilike% "infarct")|
(All_symptoms %ilike% "stroke"| SYMPTOM_TEXT %ilike% "stroke"),][
!duplicated(VAERS_ID_enhanced),.(count=.N),.(SEX,DECADE=AGE_YRS%/%10)][DECADE >= 1,
dcast(.SD,DECADE ~ SEX,value.var="count",fun.aggregate=fsum)]
}

library(stringi)
all.heart.stri <- function()
{
all.covid.data[
(stri_detect_regex(All_symptoms,"carditis",case_insensitive=TRUE) |stri_detect_regex(SYMPTOM_TEXT,"carditis",case_insensitive=TRUE))|
(stri_detect_regex(All_symptoms,"cardiac",case_insensitive=TRUE) |stri_detect_regex(SYMPTOM_TEXT,"cardiac",case_insensitive=TRUE))|
(stri_detect_regex(All_symptoms,"myocardial",case_insensitive=TRUE) |stri_detect_regex(SYMPTOM_TEXT,"myocardial",case_insensitive=TRUE))|
(stri_detect_regex(All_symptoms,"coronary",case_insensitive=TRUE) |stri_detect_regex(SYMPTOM_TEXT,"coronary",case_insensitive=TRUE))|
(stri_detect_regex(All_symptoms,"infarct",case_insensitive=TRUE) |stri_detect_regex(SYMPTOM_TEXT,"infarct",case_insensitive=TRUE))|
(stri_detect_regex(All_symptoms,"stroke",case_insensitive=TRUE) |stri_detect_regex(SYMPTOM_TEXT,"stroke",case_insensitive=TRUE)),][
!duplicated(VAERS_ID_enhanced),.(count=.N),.(SEX,DECADE=AGE_YRS%/%10)][DECADE >= 1,
dcast(.SD,DECADE ~ SEX,value.var="count",fun.aggregate=fsum)]
}


system.time(all.heart.ilike())
   user  system elapsed 
  47.67    0.05   47.75 

system.time(all.heart.stri())
   user  system elapsed 
  17.12    2.56   19.73