【问题标题】:How do i optimize the performance of stemming and spell check in R?如何优化 R 中词干和拼写检查的性能?
【发布时间】:2020-02-20 12:08:37
【问题描述】:

我有大约 1.4 百万个文档,每个文档的平均字符数为(中位数:250 和平均值: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 检查每个单词的语法上下文,这可能不起作用)。
  • 你说 python 不会是一个选择,但我相信 spacy 确实比 hunspell 词干更好更快地进行词根化。
  • 是的,你是对的。那将是那个后备计划。但在迁移更大的项目之前,我想仔细检查 R 中的选项。
  • R 中也有更好的词干分析器/词形还原器。例如,spacyr 可以在这里使用。或udpipe。但是由于问题是关于性能的,在这种情况下没有什么比quanteda 更好(因为我在回答中指出的原因)。见:blog.koheiw.net/?p=1141

标签: r spell-checking stemming


【解决方案1】:

您可以通过对词汇表而不是文档中的所有单词执行昂贵的步骤来大幅优化您的代码。 quanteda 包提供了一个非常有用的对象类或称为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),但计算确实不完美,因为在真实数据上运行它所需的实际时间几乎完全取决于词汇表的大小。

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

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

【讨论】:

    【解决方案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 的建议也可以。即使您只是将建议功能应用于一组文档,这也应该会显着加快计算速度。问题是分离文档并在词干后将它们放在一起-我猜文档的“分隔符”只会被词干并且之后无法识别。从你的问题来看,你已经尝试过这个或类似的东西。

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

    顺便说一句,我认为 11 天对于只需要执行一次的计算来说并不长。您可以简单地将脚本上传到安装了 R 的服务器,然后通过 Rscript 从 shell 中运行(使用 nohup 再次注销而不停止进程)。如果您可以使用具有许多内核的强大“工作机器”(例如在大学中),则尤其如此。

    【讨论】:

      【解决方案3】:

      这使用了仅比较唯一词的想法。为此,使用因子来确定唯一级别。

        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)
      

      【讨论】:

      • 我想过在我的回答中提到因素(这个想法确实非常相似)。太酷了base 答案。两个 cmets:你也可以考虑在关卡上做词干; strsplit(split = " ") 实际上可能比 quanteda::tokens 快,因为 quanteda 默认使用更智能的分词器(尽管对于大多数项目我更喜欢)。
      • @JBGruber 感谢您的建议 - 在关卡上进行词干提取确实提高了性能。我喜欢你的回答——你似乎也有一些语言方面的专业知识,学习一点点很棒。
      猜你喜欢
      • 1970-01-01
      • 2011-05-31
      • 2015-06-04
      • 2011-10-26
      • 1970-01-01
      • 2012-08-09
      • 1970-01-01
      • 2012-03-27
      • 2020-10-26
      相关资源
      最近更新 更多