如何优化 R 中词干提取和拼写检查的性能?

How do i optimize the performance of stemming and spell check in R?

我有 ~1,4m 个文档,每个文档的平均字符数为(中位数:250 和 Mean:470)。

我想在分类之前执行拼写检查和词干提取。

模拟文档:

sentence <- "We aree drivng as fast as we drove yestrday or evven fastter zysxzw" %>%
    rep(times = 6) %>%
    paste(collapse = " ")

nchar(sentence)
[1] 407 

先执行拼写检查然后词干提取的功能

library(hunspell)
library(magrittr)

spellAndStem <- function(sent, language = "en_US"){
  words <- sentence %>%
    strsplit(split = " ") %>%
    unlist

  # spelling
  correct <- hunspell_check(
        words = words, 
        dict = dictionary(language)
  )

  words[!correct] %<>%
    hunspell_suggest(dict = language) %>%
    sapply(FUN = "[", 1)

  # stemming
  words %>%
    hunspell_stem(dict = dictionary(language)) %>%
    unlist %>%
    paste(collapse = " ")
}

我查看了 hunspell() 函数以将文档作为一个整体交出以提高性能,但我不知道如何按该顺序进行拼写检查和词干提取。

时间测量:

> library(microbenchmark)
> microbenchmark(spellAndStem(sentence), times = 100)
Unit: milliseconds
                   expr      min       lq     mean   median       uq      max neval
 spellAndStem(sentence) 680.3601 689.8842 700.7957 694.3781 702.7493 798.9544   100

如果每个文档 0.7 秒,则需要 0.7*1400000/3600/24 = 11.3 天来进行计算。

问题:

如何优化此性能?

最后备注:

目标语言是 98% 的德语和 2% 的英语。不确定信息是否重要,只是为了完整性。

hunspell_suggest 只是一个昂贵的操作,因为它计算字符串与字典中每个单词之间的距离(参见此处:https://github.com/ropensci/hunspell/issues/7)。当我删除 hunspell_suggest 行时,我的机器平均只需要 25 毫秒。所以如果你想加快速度,这是关键部分。请注意,实际文档中有多少不正确的单词会有所不同。你的例子有大约 50% 的拼写错误的单词应该是例外。为什么不先在前几个文档上尝试该算法以获得更实际的时间估计。我认为语言很重要(为了您的利益),因为英语中的单词比德语多(想想字典的大小)。

一个简单明了的做法是使用多核。 parallel 包中的一些简单的东西已经使我的四个内核的时间减半:

sentences <- rep(sentence, 4)
microbenchmark(lapply = lapply(sentences, spellAndStem),
               mclapply = parallel::mclapply(sentences, spellAndStem),
               times = 10)

Unit: seconds
                                        expr      min       lq     mean   median       uq      max neval cld
             lapply(sentences, spellAndStem) 1.967008 2.023291 2.045705 2.051764 2.077168 2.105420    10   b
 parallel::mclapply(sentences, spellAndStem) 1.011945 1.048055 1.078003 1.081850 1.109274 1.135508    10  a 

Andrew Gustar 的建议也可以。即使您只是将 suggest 函数应用于一组文档,这也会显着加快计算速度。问题是在提取词干后将文档分开并将它们放在一起——我猜文档的 "separator" 会被提取出来,之后就无法识别了。从你的问题来看你已经尝试过这个或类似的东西。

较小的字典也有帮助,但如果您想要高质量的数据,这可能不是一个好主意。

顺便说一句,对于只需执行一次的计算,我认为 11 天不会太长。您可以简单地将脚本上传到安装了 R 的服务器,并通过 shell 中的 Rscript 让它 运行 在那里(使用 nohup 再次注销而不停止进程).如果您可以访问具有许多核心的强大 "working machine"(例如在大学),则尤其如此。

您可以通过对词汇而不是文档中的所有单词执行昂贵的步骤来显着优化您的代码。 quanteda 包提供了一个非常有用的对象 class 或称为 tokens:

toks <- quanteda::tokens(sentence)
unclass(toks)
#> $text1
#>  [1]  1  2  3  4  5  4  6  7  8  9 10 11 12  1  2  3  4  5  4  6  7  8  9 10 11
#> [26] 12  1  2  3  4  5  4  6  7  8  9 10 11 12  1  2  3  4  5  4  6  7  8  9 10
#> [51] 11 12  1  2  3  4  5  4  6  7  8  9 10 11 12  1  2  3  4  5  4  6  7  8  9
#> [76] 10 11 12
#> 
#> attr(,"types")
#>  [1] "We"       "aree"     "drivng"   "as"       "fast"     "we"      
#>  [7] "drove"    "yestrday" "or"       "evven"    "fastter"  "zysxzw"  
#> attr(,"padding")
#> [1] FALSE
#> attr(,"what")
#> [1] "word"
#> attr(,"ngrams")
#> [1] 1
#> attr(,"skip")
#> [1] 0
#> attr(,"concatenator")
#> [1] "_"
#> attr(,"docvars")
#> data frame with 0 columns and 1 row

如您所见,文本分为词汇表 (types) 和单词的位置。我们可以使用它来优化您的代码,方法是对 types 而不是整个文本执行所有步骤:

spellAndStem_tokens <- function(sent, language = "en_US") {

  sent_t <- quanteda::tokens(sent)

  # extract types to only work on them
  types <- quanteda::types(sent_t)

  # spelling
  correct <- hunspell_check(
    words = as.character(types), 
    dict = hunspell::dictionary(language)
  )

  pattern <- types[!correct]
  replacement <- sapply(hunspell_suggest(pattern, dict = language), FUN = "[", 1)

  types <- stringi::stri_replace_all_fixed(
    types,
    pattern, 
    replacement,
    vectorize_all = FALSE
  )

  # stemming
  types <- hunspell_stem(types, dict = dictionary(language))


  # replace original tokens
  sent_t_new <- quanteda::tokens_replace(sent_t, quanteda::types(sent_t), as.character(types))

  sent_t_new <- quanteda::tokens_remove(sent_t_new, pattern = "NULL", valuetype = "fixed")

  paste(as.character(sent_t_new), collapse = " ")
}

我正在使用 bench 包进行基准测试,因为它还会检查两个函数的结果是否相同,而且我发现它通常更舒服:

res <- bench::mark(
  spellAndStem(sentence),
  spellAndStem_tokens(sentence)
)

res
#> # A tibble: 2 x 6
#>   expression                         min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr>                    <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 spellAndStem(sentence)           807ms    807ms      1.24     259KB        0
#> 2 spellAndStem_tokens(sentence)    148ms    150ms      6.61     289KB        0

summary(res, relative = TRUE)
#> # A tibble: 2 x 6
#>   expression                      min median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr>                    <dbl>  <dbl>     <dbl>     <dbl>    <dbl>
#> 1 spellAndStem(sentence)         5.44   5.37      1         1         NaN
#> 2 spellAndStem_tokens(sentence)  1      1         5.33      1.11      NaN

新函数比原函数快5.44倍。请注意,输入文本越大,差异越明显:

sentence <- "We aree drivng as fast as we drove yestrday or evven fastter zysxzw" %>%
  rep(times = 600) %>%
  paste(collapse = " ")

res_big <- bench::mark(
  spellAndStem(sentence),
  spellAndStem_tokens(sentence)
)

res_big
#> # A tibble: 2 x 6
#>   expression                         min   median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr>                    <bch:tm> <bch:tm>     <dbl> <bch:byt>    <dbl>
#> 1 spellAndStem(sentence)         1.27m    1.27m      0.0131  749.81KB        0
#> 2 spellAndStem_tokens(sentence)  178.26ms 182.12ms   5.51      1.94MB        0
summary(res_big, relative = TRUE)
#> # A tibble: 2 x 6
#>   expression                      min median `itr/sec` mem_alloc `gc/sec`
#>   <bch:expr>                    <dbl>  <dbl>     <dbl>     <dbl>    <dbl>
#> 1 spellAndStem(sentence)         428.   419.        1       1         NaN
#> 2 spellAndStem_tokens(sentence)   1      1       420.       2.65      NaN

如您所见,处理大 100 倍样本所需的时间与处理较小样本所需的时间几乎相同。这是因为两者之间的词汇完全相同。假设这个更大的样本代表您的 100 个文档,我们可以从这个结果推断到您的整个数据集。该函数应该花费不到一个小时 (0.17826 * 14000 / 3600 = 0.69),但计算确实不完美,因为 运行 它在您的真实数据上花费的实际时间几乎完全取决于词汇表的大小.

除了 programming/performance 方面,我还有一些问题可能不适用于您的具体情况:

  1. 我建议将函数的最后一行更改为 sapply(as.list(sent_t_new), paste, collapse = " "),因为这不会将所有文档折叠成一个长字符串,而是将它们分开。
  2. 目前,您的设置删除了 hunspell 找不到任何建议的词。我复制了这种方法(请参阅 tokens_remove 命令),但您可能想要考虑至少输出丢弃的单词而不是静默删除它们。
  3. 如果上述函数是为其他一些文本分析做准备,那么在执行词干提取和拼写检查之前将数据直接转换为文档术语矩阵会更有意义。
  4. 词干提取只是词形还原的一种近似,词形还原是实际找到单词基本形式的过程。此外,词干提取在德语中通常效果很差。根据您正在做的事情,您可能想改为进行词形还原(例如,使用 spacyr)或干脆将其关闭,因为词干提取很少会改善德语的结果。

这使用了只比较唯一词的想法。为此,使用因素来确定独特水平。

  words_fct <- sent %>%
    strsplit(split = " ") %>% 
    unlist(use.names = FALSE) %>%
    factor()

  correct_lvl <- words_fct%>%
    levels()%>%
    hunspell_check(dict = language)

  levels(words_fct)[!correct_lvl] %<>% 
    hunspell_suggest(dict = language) %>%
    sapply("[", 1L)

  levels(words_fct)%<>%
    hunspell_stem(dict = language)%>%
    unlist(use.names = FALSE)

  words_fct%>%
    as.character()%>%
    na.omit()%>%
    paste(collapse = " ")
}

它比@JBGruber 的稍微快一些,但它在很多方面也是@JBGruber 的答案的派生词。

我也喜欢为所有文档使用并行结构的想法。假设每个文档都是一个文本字符串,这可能会起作用:

library(future.apply)
plan(multiprocess)
future_lapply(documents, spellAndStem_fcts, language)