机器学习 -- 文本分析2 标记化 R语言

2. Tokenization 标记化

为了从自然语言构建用于监督机器学习的特征,我们需要某种将原始文本表示为数字的方法,以便我们可以对它们执行计算。 通常,从自然语言到特征或任何类型的文本分析的这种转换的第一步是标记化。 了解什么是标记化和标记,以及 n-gram 的相关概念,对于几乎所有自然语言处理任务都很重要.

2.1 什么是标记?

在 R 中,文本通常用字符数据类型表示,类似于其他语言中的字符串。 让我们探索安徒生 (Hans Christian Andersen) 所写童话故事的文本,这些文本可在 hcandersenr 包 (Hvitfeldt 2019a) 中找到。 这个包将文本存储为行,就像你在书中读到的那样; 这只是您可以在野外找到文本数据的一种方式,并且确实使我们在进行分析时可以更轻松地阅读文本。 如果我们查看一个名为“The Fir-Tree”的故事的第一段,我们会发现故事的文本位于一个字符向量中:一系列字母、空格和标点符号存储为一个向量。

tidyverse 是用于数据操作、探索和可视化的包的集合

library(tokenizers)
library(tidyverse)
library(tidytext)
library(hcandersenr)

the_fir_tree <- hcandersen_en %>%
  filter(book == "The fir tree") %>%
  pull(text)

head(the_fir_tree, 9)
#> [1] "Far down in the forest, where the warm sun and the fresh air made a
sweet"
#> [2] "resting-place, grew a pretty little fir-tree; and yet it was not happy,
it"
#> [3] "wished so much to be tall like its companions– the pines and firs which
grew"
#> [4] "around it. The sun shone, and the soft air fluttered its leaves, and
the"
#> [5] "little peasant children passed by, prattling merrily, but the fir-tree
heeded"
#> [6] "them not. Sometimes the children would bring a large basket of
raspberries or"
#> [7] "strawberries, wreathed on a straw, and seat themselves near the
fir-tree, and"
#> [8] "say, \"Is it not a pretty little tree?\" which made it feel more
unhappy than"
#> [9] "before."

前九行存储故事的第一段,每行由一系列字符符号组成。 这些元素不包含任何元数据或信息来告诉我们哪些字符是单词,哪些不是。 识别单词之间的这些边界是标记化过程的用武之地。

在标记化中,我们采用输入(字符串)和标记类型(有意义的文本单元,例如单词)并将输入分成与类型相对应的片段(标记)(Manning、Raghavan 和 Schütze 2008) . 图 2.1 概述了这个过程。

最常见的是,我们想要将文本拆分为单位的有意义的标记单位或类型是单词。 然而,对于许多甚至大多数语言来说,很难清楚地定义一个词是什么。 许多语言,例如中文,根本不使用单词之间的空格。 即使是使用空格的语言,包括英语,也经常有模棱两可的特定示例(Bender 2013)。 意大利语和法语等浪漫语言使用代词和否定词,最好将其视为带有空格的前缀,而像“没有”这样的英语缩写可能更准确地被视为没有空格的两个词。

为了理解标记化的过程,让我们从一个过于简单的单词定义开始:任何字母数字(字母和数字)符号的选择。 让我们使用一些带有 strsplit() 的正则表达式(或简称为正则表达式,参见附录 A)将“The Fir-Tree”的前两行拆分为任何非字母数字字符

strsplit(the_fir_tree[1:2], "[^a-zA-Z0-9]+")
#> [[1]]
#>  [1] "Far"    "down"   "in"     "the"    "forest" "where"  "the"    "warm"  
#>  [9] "sun"    "and"    "the"    "fresh"  "air"    "made"   "a"      "sweet" 
#> 
#> [[2]]
#>  [1] "resting" "place"   "grew"    "a"       "pretty"  "little"  "fir"    
#>  [8] "tree"    "and"     "yet"     "it"      "was"     "not"     "happy"  
#> [15] "it"

乍一看,这个结果看起来相当不错。 但是,我们丢失了所有标点符号,这可能对我们的建模目标有帮助,也可能没有帮助,并且这个故事的主人公(“枞树”)被分成了两半。 很明显,标记化将非常复杂。 幸运的是,我们在这个过程中投入了大量工作,通常最好使用这些现有工具。 例如,标记器 (Mullen et al. 2018) 和 spaCy (Honnibal et al. 2020) 实现了我们可以使用的快速、一致的标记器。 让我们使用 tokenizers 包进行演示。 

library(tokenizers)
tokenize_words(the_fir_tree[1:2])
#> [[1]]
#>  [1] "far"    "down"   "in"     "the"    "forest" "where"  "the"    "warm"  
#>  [9] "sun"    "and"    "the"    "fresh"  "air"    "made"   "a"      "sweet" 
#> 
#> [[2]]
#>  [1] "resting" "place"   "grew"    "a"       "pretty"  "little"  "fir"    
#>  [8] "tree"    "and"     "yet"     "it"      "was"     "not"     "happy"  
#> [15] "it"

 我们在这里看到了合理的单字结果; tokenize_words() 函数在后台使用 stringi 包 (Gagolewski 2020) 和 C++,使其速度非常快。 单词级别的标记化是通过根据国际 Unicode 组件 (ICU) 中的规范查找单词边界来完成的。 这个词边界算法是如何工作的? 可以概括如下:

  • 除非文本为空,否则在文本的开头和结尾处换行。
  • 不要在 CRLF(换行符)内换行。
  • 否则,在换行之前和之后换行(包括 CR 和 LF)。
  • 不要在 emoji zwj 序列中中断。
  • 将水平空白保持在一起。
  • 忽略格式和扩展字符,除了在 sot、CR、LF 和新行之后。
  • 不要在大多数字母之间中断。
  • 不要在某些标点符号之间打断字母。
  • 不要在数字序列或与字母相邻的数字(“3a”或“A3”)中打断。
  • 不要在序列中中断,例如“3.2”或“3,456.789”。
  • 片假名之间不要中断。
  • 不要从扩展器上断开。
  • 不要在表情符号标志序列中中断。
  • 否则,到处打断(包括象形文字周围)

虽然我们可能不了解该算法中的每一步都在做什么,但我们可以理解它比我们最初分割非字母数字字符的方法要复杂很多倍。 在本书的大部分内容中,我们将使用分词器包作为基准分词器以供参考。 你选择的分词器会影响你的结果,所以不要害怕尝试不同的分词器,或者,如果有必要,自己编写来适应你的问题。 

2.2 Types of tokens

将标记视为一个词是开始理解标记化的有用方法,即使它很难在软件中具体实现。 我们可以将令牌的概念从单个单词推广到其他文本单元。 我们可以以多种单位标记文本,包括:

  • 人物,
  • 字,
  • 句子,
  • 线条,
  • 段落,以及
  • n-gram

在以下部分中,我们将探讨如何使用 tokenizers 包对文本进行标记。 这些函数将字符向量作为输入并返回字符向量列表作为输出。 同样的标记化也可以使用 tidytext (Silge and Robinson 2016) 包来完成,用于使用 tidy 数据原则的工作流,其中输入和输出都在数据框中。

sample_vector <- c("Far down in the forest",
                   "grew a pretty little fir-tree")
sample_tibble <- tibble(text = sample_vector)

标记器包在 R 中为单词、字母、n-gram、行、段落等标记提供了快速、一致的标记化。

通过在 sample_vector 上使用 tokenize_words() 实现的标记化:

tokenize_words(sample_vector)
#> [[1]]
#> [1] "far"    "down"   "in"     "the"    "forest"
#> 
#> [[2]]
#> [1] "grew"   "a"      "pretty" "little" "fir"    "tree"

将产生与在 sample_tibble 上使用 unnest_tokens() 相同的结果; 唯一的区别是数据结构,因此我们如何在分析中使用结果。

sample_tibble %>%
  unnest_tokens(word, text, token = "words")
#> # A tibble: 11 × 1
#>    word  
#>    <chr> 
#>  1 far   
#>  2 down  
#>  3 in    
#>  4 the   
#>  5 forest
#>  6 grew  
#>  7 a     
#>  8 pretty
#>  9 little
#> 10 fir   
#> 11 tree

tidytext 包提供了将文本转换为 tidy 格式和从 tidy 格式转换的功能,使我们能够与其他 tidyverse 工具无缝协作

tokenize_words() 中使用的参数可以通过 unnest_tokens() 使用“点”传递,...

sample_tibble %>%
  unnest_tokens(word, text, token = "words", strip_punct = FALSE)
#> # A tibble: 12 × 1
#>    word  
#>    <chr> 
#>  1 far   
#>  2 down  
#>  3 in    
#>  4 the   
#>  5 forest
#>  6 grew  
#>  7 a     
#>  8 pretty
#>  9 little
#> 10 fir   
#> 11 -     
#> 12 tree

2.2.1 字符标记 

也许最简单的标记化是字符标记化,它将文本拆分为字符。 让我们使用 tokenize_characters() 及其默认参数; 这个函数有参数可以转换为小写并去除所有非字母数字字符。 这些默认值将减少返回的不同令牌的数量。 tokenize_*() 函数默认返回一个字符向量列表,输入中的每个字符串对应一个字符向量。

tft_token_characters <- tokenize_characters(x = the_fir_tree,
                                            lowercase = TRUE,
                                            strip_non_alphanum = TRUE,
                                            simplify = FALSE)

如果我们看一下,我们会看到什么?

head(tft_token_characters) %>%
  glimpse()
#> List of 6
#>  $ : chr [1:57] "f" "a" "r" "d" ...
#>  $ : chr [1:57] "r" "e" "s" "t" ...
#>  $ : chr [1:61] "w" "i" "s" "h" ...
#>  $ : chr [1:56] "a" "r" "o" "u" ...
#>  $ : chr [1:64] "l" "i" "t" "t" ...
#>  $ : chr [1:64] "t" "h" "e" "m" ...

我们不必坚持默认设置。 我们可以通过设置 strip_non_alphanum = FALSE 来保留标点和空格,现在我们看到结果中也包含了空格和标点。

tokenize_characters(x = the_fir_tree,
                    strip_non_alphanum = FALSE) %>%
  head() %>%
  glimpse()
#> List of 6
#>  $ : chr [1:73] "f" "a" "r" " " ...
#>  $ : chr [1:74] "r" "e" "s" "t" ...
#>  $ : chr [1:76] "w" "i" "s" "h" ...
#>  $ : chr [1:72] "a" "r" "o" "u" ...
#>  $ : chr [1:77] "l" "i" "t" "t" ...
#>  $ : chr [1:77] "t" "h" "e" "m" ...

结果有更多的元素,因为空格和标点符号没有被删除。

根据您的文本数据格式,它可能包含连字。 连字是指多个字素或字母组合成一个字符。字素“f”和“l”组合成“f”,或“f”和“f”组合成“ff”。 当我们应用正常的标记化规则时,连字不会被拆分。

tokenize_characters("flowers")
#> [[1]]
#> [1] "fl" "o" "w" "e" "r" "s"

 我们可能希望将这些连字分离回单独的字符,但首先,我们需要考虑几件事。首先,我们需要考虑连字的存在对于我们试图回答的问题是否有意义。其次,有两种主要类型的连字:风格和功能。文体连字是指两个字符组合在一起,因为字符之间的间距被认为是不愉快的。像德语 Eszett(也称为 scharfes S,意为尖锐的 s)ß 等功能性连字是德语字母表的官方字母。它被描述为长 S 和 Z,历史上从未出现过大写字符。这导致排字员在用大写字母书写单词时使用 SZ 或 SS 作为替代。此外,在瑞士的德语写作中完全省略了 ß,并用 ss 代替。其他例子包括拉丁字母中的“W”(两个“v”或两个“u”连接在一起),以及北欧语言中的æ、ø和å。由于历史原因,一些地名使用旧拼写“aa”而不是å。在第 6.7.1 节中,我们将讨论处理连字的文本规范化方法。

2.2.2 词标记

单词级别的标记化可能是最常见和广泛使用的标记化。 我们在本章中的讨论是从这种标记化开始的,正如我们之前所描述的,这是将文本拆分为单词的过程。 为此,让我们使用 tokenize_words() 函数。

tft_token_words <- tokenize_words(x = the_fir_tree,
                                  lowercase = TRUE,
                                  stopwords = NULL,
                                  strip_punct = TRUE,
                                  strip_numeric = FALSE)

结果向我们展示了将输入文本拆分为单个单词。

head(tft_token_words) %>%
  glimpse()
#> List of 6
#>  $ : chr [1:16] "far" "down" "in" "the" ...
#>  $ : chr [1:15] "resting" "place" "grew" "a" ...
#>  $ : chr [1:15] "wished" "so" "much" "to" ...
#>  $ : chr [1:14] "around" "it" "the" "sun" ...
#>  $ : chr [1:12] "little" "peasant" "children" "passed" ...
#>  $ : chr [1:13] "them" "not" "sometimes" "the" ...

我们已经看到 lowercase = TRUE,strip_punct = TRUE 和 strip_numeric = FALSE 分别控制我们是否删除标点符号和数字字符。 我们还有 stopwords = NULL,我们将在第 3 章更深入地讨论。

让我们用两个童话故事“枞树”和“小美人鱼”创建一个 tibble。 然后我们可以使用 unnest_tokens() 和一些 dplyr 动词来找到每个动词中最常用的单词。

hcandersen_en %>%
  filter(book %in% c("The fir tree", "The little mermaid")) %>%
  unnest_tokens(word, text) %>%
  count(book, word) %>%
  group_by(book) %>%
  arrange(desc(n)) %>%
  slice(1:5)
#> # A tibble: 10 × 3
#> # Groups:   book [2]
#>    book               word      n
#>    <chr>              <chr> <int>
#>  1 The fir tree       the     278
#>  2 The fir tree       and     161
#>  3 The fir tree       tree     76
#>  4 The fir tree       it       66
#>  5 The fir tree       a        56
#>  6 The little mermaid the     817
#>  7 The little mermaid and     398
#>  8 The little mermaid of      252
#>  9 The little mermaid she     240
#> 10 The little mermaid to      199

 每个童话故事中最常见的五个词都缺乏信息,除了“枞树”中的“树”。

2.2.3 Tokenizing by n-grams

n-gram(有时写为“ngram”)是语言学中的一个术语,表示一个连续的序列n来自给定文本或语音序列的项目。 该项目可以是音素、音节、字母或单词,具体取决于应用程序,但是当大多数人谈论 n-gram 时,它们意味着一组n 字。 在本书中,除非另有说明,否则我们将使用 n-gram 来表示单词 n-gram。

我们使用拉丁语前缀,因此 1-gram 称为 unigram,2-gram 称为 bigram,3-gram 称为 trigram,依此类推。

一些示例 n-gram 是:

  • unigram: “Hello,” “day,” “my,” “little”
  • bigram: “fir tree,” “fresh air,” “to be,” “Robin Hood”
  • trigram: “You and I,” “please let go,” “no time like,” “the little mermaid”

与单词相比,使用 n-gram 的好处是 n-gram 可以捕获否则会丢失的单词顺序。 类似地,当我们使用字符 n-gram 时,我们可以对单词的开头和结尾进行建模,因为一个空格将位于一个 n-gram 的末尾,用于单词的结尾和 n-gram 的开头 一个词的开头。

要将文本拆分为单词 n-gram,我们可以使用函数 tokenize_ngrams()。 它还有一些参数,所以让我们一一讨论。

tft_token_ngram <- tokenize_ngrams(x = the_fir_tree,
                                   lowercase = TRUE,
                                   n = 3L,
                                   n_min = 3L,
                                   stopwords = character(),
                                   ngram_delim = " ",
                                   simplify = FALSE)

我们之前已经看到了参数小写、停用词和简化; 它们与其他标记器的工作方式相同。 我们还有 n,用于确定返回 n-gram 的程度的参数。 使用 n = 1 返回一元组,n = 2 个二元组,n = 3 给出三元组,依此类推。 与 n 相关的是 n_min 参数,它指定要包含的最小 n-gram 数。 默认情况下,n 和 n_min 都设置为 3,使得 tokenize_ngrams() 只返回三元组。 通过设置 n = 3 和 n_min = 1,我们将获得文本的所有 unigrams、bigrams 和 trigrams。 最后,我们有 ngram_delim 参数,它指定 n-gram 中单词之间的分隔符; 请注意,这默认为空格。

让我们看一下“The Fir-Tree”第一行的 n-gram 标记化结果

tft_token_ngram[[1]]
#>  [1] "far down in"      "down in the"      "in the forest"    "the forest where"
#>  [5] "forest where the" "where the warm"   "the warm sun"     "warm sun and"    
#>  [9] "sun and the"      "and the fresh"    "the fresh air"    "fresh air made"  
#> [13] "air made a"       "made a sweet"

注意三字组中的单词是如何重叠的,因此“下”这个词出现在第一个三字组的中间和第二个三字组的开头。 N-gram 标记化沿着文本滑动以创建重叠的标记集。

在我们要回答的问题使用 n-gram 时,为 n 选择正确的值很重要。 使用 unigrams 更快、更有效,但我们不捕获有关词序的信息。 对 n 使用更高的值可以保留更多信息,但是令牌的向量空间会急剧增加,这对应于令牌计数的减少。 在大多数情况下,合理的起点是三个。 但是,如果您的数据集中没有大量词汇表,请考虑从两个而不是三个开始并从那里进行试验。 图 2.2 展示了三元组和高阶 n-gram 的令牌频率如何开始显着降低。

 图 2.2:使用更长的 n-gram 会导致更多的唯一标记,但计数更少。 请注意,颜色映射到对数刻度上的计数。

我们不仅限于使用一个度数的 n-gram。 例如,我们可以在分析或模型中组合一元和二元。 根据您使用的软件包,获得多度 n-gram 会有所不同; 使用 tokenize_ngrams() 你可以指定 n 和 n_min。

tft_token_ngram <- tokenize_ngrams(x = the_fir_tree,
                                   n = 2L,
                                   n_min = 1L)
tft_token_ngram[[1]]
#>  [1] "far"          "far down"     "down"         "down in"      "in"          
#>  [6] "in the"       "the"          "the forest"   "forest"       "forest where"
#> [11] "where"        "where the"    "the"          "the warm"     "warm"        
#> [16] "warm sun"     "sun"          "sun and"      "and"          "and the"     
#> [21] "the"          "the fresh"    "fresh"        "fresh air"    "air"         
#> [26] "air made"     "made"         "made a"       "a"            "a sweet"     
#> [31] "sweet"

 结合不同程度的 n-gram 可以让您从文本数据中提取不同级别的细节。 Unigrams 告诉你哪些单词被使用了很多次; 如果这些词中的一些词不经常与其他词一起出现,它们可能会在二元组或三元组计数中被忽略。 考虑一个场景,每次使用“狗”这个词时,它都在一个形容词之后:“快乐的狗”、“悲伤的狗”、“棕色的狗”、“白色的狗”、“顽皮的狗”等。如果这是公平的 一致并且形容词变化得足够多,那么二元组将无法检测到这个故事是关于狗的。 类似地,“非常高兴”和“不高兴”将被认为与二元组不同,而不仅仅是一元组。

2.2.4 行、句子和段落标记

将文本拆分为更大的文本单元(如行、句子和段落)的标记器很少直接用于建模目的,因为生成的标记往往是相当独特的。 一个文本中的多个句子相同是非常罕见的! 但是,这些标记器对于预处理和标记很有用。

例如,简奥斯汀的小说 Northanger Abbey(在 janeaustenr 包中可用)已经过预处理,每行最多 80 个字符。 但是,将数据拆分为章节和段落可能会很有用。

让我们创建一个函数,该函数接受一个包含名为 text 的变量的数据框,并将其转换为一个数据框,其中文本被转换为段落。 首先,我们可以使用 collapse = "\n" 将文本折叠成一个长字符串来表示换行符,然后我们可以使用 tokenize_paragraphs() 来识别段落并将它们放回数据框中。 我们可以使用 row_number() 添加段落计数

add_paragraphs <- function(data) {
  pull(data, text) %>%
    paste(collapse = "\n") %>%
    tokenize_paragraphs() %>%
    unlist() %>%
    tibble(text = .) %>%
    mutate(paragraph = row_number())
}

现在我们获取原始文本数据并通过检测字符“CHAPTER”何时出现在行首来添加章节计数。 然后我们 nest() 文本列,应用我们的 add_paragraphs() 函数,然后再次 unnest()。 

library(janeaustenr)

northangerabbey_paragraphed <- tibble(text = northangerabbey) %>%
  mutate(chapter = cumsum(str_detect(text, "^CHAPTER "))) %>%
  filter(chapter > 0,
         !str_detect(text, "^CHAPTER ")) %>%
  nest(data = text) %>%
  mutate(data = map(data, add_paragraphs)) %>%
  unnest(cols = c(data))

glimpse(northangerabbey_paragraphed)
#> Rows: 1,020
#> Columns: 3
#> $ chapter   <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, …
#> $ text      <chr> "No one who had ever seen Catherine Morland in her infancy w…
#> $ paragraph <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 1…

现在我们可以分析 1020 个单独的段落。 同样,我们可以更进一步,将这些章节拆分为句子、行或单词。

能够重塑文本数据以获得不同的观察单位是很有用的。 例如,如果您想构建一个情感分类器,将句子分类为敌对与否,那么您需要使用文本句子并训练您的模型。 将页面或段落转换为句子是您工作流程中的必要步骤。

让我们看看如何将 the_fir_tree 从“每个元素一行”向量转变为“每个元素一个句子”。 the_fir_tree 以向量形式出现,因此我们首先使用 paste() 将线条组合在一起。 我们使用一个空格作为分隔符,然后我们将它传递给 tokenizers 包中的 tokenize_sentences() 函数,该函数将执行句子拆分。

the_fir_tree_sentences <- the_fir_tree %>%
  paste(collapse = " ") %>%
  tokenize_sentences()

head(the_fir_tree_sentences[[1]])
#> [1] "Far down in the forest, where the warm sun and the fresh air made a
sweet resting-place, grew a pretty little fir-tree; and yet it was not happy,
it wished so much to be tall like its companions– the pines and firs which grew
around it."
#> [2] "The sun shone, and the soft air fluttered its leaves, and the little
peasant children passed by, prattling merrily, but the fir-tree heeded them
not."
#> [3] "Sometimes the children would bring a large basket of raspberries or
strawberries, wreathed on a straw, and seat themselves near the fir-tree, and
say, \"Is it not a pretty little tree?\""
#> [4] "which made it feel more unhappy than before."
#> [5] "And yet all this while the tree grew a notch or joint taller every
year; for by the number of joints in the stem of a fir-tree we can discover its
age."

 如果您在 hcandersen_en 数据框中有来自不同类别的行,其中包含英语童话故事的所有行,那么我们希望能够将这些行转换为句子,同时保留数据集中的 book 列。 为此,我们使用 nest() 和 map_chr() 创建一个数据框,其中每个童话故事都是它自己的元素,然后我们使用 tidytext 包中的 unnest_sentences() 函数将文本拆分为句子。

hcandersen_sentences <- hcandersen_en %>%
  nest(data = c(text)) %>%
  mutate(data = map_chr(data, ~ paste(.x$text, collapse = " "))) %>%
  unnest_sentences(sentences, data)

 现在我们已经将文本变成了“每个元素一个句子”,我们可以在句子层面进行分析。

2.3 Where does tokenization break down?

标记化通常是构建模型或任何类型的文本分析时的第一步,因此仔细考虑在数据预处理这一步中会发生什么是很重要的。 与大多数软件一样,速度和可定制性之间存在权衡,如第 2.6 节所示。 最快的标记化方法使我们对如何完成的控制更少。

虽然默认值在许多情况下都很好用,但我们遇到的情况是我们想要施加更严格的规则以获得更好或不同的标记化结果。 考虑下面的句子。

这句话有几个有趣的方面,我们需要决定在标记时是保留还是忽略。 第一个问题是“不要”中的收缩,它为我们提供了几种可能的选择。 最快的选择是将其保留为一个词,但也可以将其拆分为“do”和“n't”。

下一个问题是如何处理“$1”; 美元符号是这句话的重要组成部分,因为它表示一种货币。 我们可以删除或保留这个标点符号,如果我们保留美元符号,我们可以选择保留一个或两个标记,“$1”或“$”和“1”。 如果我们查看 tokenize_words() 的默认值,我们会注意到它默认删除大多数标点符号,包括 $。

tokenize_words("$1")
#> [[1]]
#> [1] "1"

如果我们不去掉标点符号,我们可以保留美元符号。

tokenize_words("$1", strip_punct = FALSE)
#> [[1]]
#> [1] "$" "1"

在处理这句话时,我们还需要决定是否保留最后的句号作为令牌。如果我们删除它,我们将无法使用 n-gram 定位句子中的最后一个单词。

标记化(尤其是默认标记化)丢失的信息在在线和更随意的文本中更频繁地发生。取决于我们选择的分词器和分词参数,多个空格、感叹号的极端使用以及故意使用大写可能会完全消失。同时,保留有关如何使用文本的信息并不总是值得的。如果我们正在使用 Twitter 数据研究疾病流行的趋势,那么写推文的风格可能并不像使用的单词那么重要。然而,如果我们试图对社会群体进行建模,语言风格以及个人如何相互使用语言变得更加重要。

要考虑的另一件事是每种标记化提供的压缩程度。标记化的选择会导致不同的可能标记池,并可能影响性能。通过选择提供更少可能标记的方法,您可以更快地执行以后的计算任务。然而,这带来了将不同含义的类别合并在一起的风险。还值得注意的是,不同令牌数量的分布因您选择的令牌生成器而异。

图 2.3 说明了这些要点。来自 hcandersenr 的每个童话故事都以五种不同的方式进行了标记,并且沿 x 轴绘制了不同标记的数量(请注意,x 轴是对数的)。如果我们将单词转换为小写或提取词干,我们会看到不同标记的数量减少(有关词干提取的更多信息,请参见第 4 章)。其次,请注意字符标记器的不同标记的分布非常狭窄;这些文本使用英文字母表中的全部或大部分字母。

 

 2.4 Building your own tokenizer

有时,开箱即用的标记器无法完成您需要它们执行的操作。 在这种情况下,我们将不得不使用 stringi/stringr 和正则表达式(参见附录 A)。

标记化有两种主要方法。

根据某些规则拆分字符串。
根据某些规则提取令牌。
我们规则的数量和复杂性取决于我们想要的结果。 我们可以通过将许多较小的规则链接在一起来达到复杂的结果。 在本节中,我们将实现几个专业的分词器来展示这些技术。

2.4.1 标记为字符,只保留字母

这里我们要修改 tokenize_characters() 的作用,这样我们只保留字母。 有两个主要选项。 我们可以使用 tokenize_characters() 并删除任何不是字母的东西,或者我们可以一个一个地提取字母。 让我们尝试后一种选择。 这是一个提取任务,我们将使用 str_extract_all() 因为每个字符串都可能包含多个标记。 由于我们要提取字母,我们可以使用字母字符类 [:alpha:] 来匹配字母,而量词 {1} 只提取第一个字母。

letter_tokens <- str_extract_all(
  string = "This sentence include 2 numbers and 1 period.",
  pattern = "[:alpha:]{1}"
)
letter_tokens
#> [[1]]
#>  [1] "T" "h" "i" "s" "s" "e" "n" "t" "e" "n" "c" "e" "i" "n" "c" "l" "u" "d" "e"
#> [20] "n" "u" "m" "b" "e" "r" "s" "a" "n" "d" "p" "e" "r" "i" "o" "d"

我们可能很想将字符类指定为 [a-zA-Z]{1}。 此选项会运行得更快,但我们会丢失非英文字母字符。 这是我们必须根据特定问题的目标做出的设计选择。

danish_sentence <- "Så mødte han en gammel heks på landevejen"

str_extract_all(danish_sentence, "[:alpha:]")

 

#> [[1]]
#>  [1] "S" "å" "m" "ø" "d" "t" "e" "h" "a" "n" "e" "n" "g" "a" "m" "m" "e" "l" "h"
#> [20] "e" "k" "s" "p" "å" "l" "a" "n" "d" "e" "v" "e" "j" "e" "n"
str_extract_all(danish_sentence, "[a-zA-Z]")
#> [[1]]
#>  [1] "S" "m" "d" "t" "e" "h" "a" "n" "e" "n" "g" "a" "m" "m" "e" "l" "h" "e" "k"
#> [20] "s" "p" "l" "a" "n" "d" "e" "v" "e" "j" "e" "n"

2.4.2 允许连字符 

在到目前为止的示例中,我们注意到字符串“fir-tree”通常分为两个标记。 让我们探索两种不同的方法来处理这个连字符的单词作为一个标记。 首先,让我们分割空白; 这是识别英语和其他一些语言中的单词的一种不错的方法,并且它不会拆分连字符的单词,因为连字符不被视为空格。 其次,让我们找到一个正则表达式来匹配带有连字符的单词并提取它们。

按空格分割并不太难,因为我们可以使用字符类,如表 A.2 所示。 我们将使用空白字符类 [:space:] 来分割我们的句子。

str_split("This isn't a sentence with hyphenated-words.", "[:space:]")
#> [[1]]
#> [1] "This"              "isn't"             "a"                
#> [4] "sentence"          "with"              "hyphenated-words."

这工作得很好。 这个版本没有删除标点符号,但我们可以通过删除单词开头和结尾的标点符号来实现这一点。

str_split("This isn't a sentence with hyphenated-words.", "[:space:]") %>%
  map(~ str_remove_all(.x, "^[:punct:]+|[:punct:]+$"))
#> [[1]]
#> [1] "This"             "isn't"            "a"                "sentence"        
#> [5] "with"             "hyphenated-words"

这个去掉标点符号的正则表达式有点复杂,我们来逐个讨论。

  • 正则表达式 ^[:punct:]+ 将查看字符串的开头 (^) 以匹配任何标点符号 ([:punct:]),并从中选择一个或多个 (+)。
  • 另一个正则表达式 [:punct:]+$ 将查找在字符串 ($) 末尾出现一次或多次 (+) 的标点符号 ([:punct:])。
  • 这些将交替使用(|),以便我们从单词的两侧获得匹配。
  • 我们使用量词 + 的原因是在某些情况下,一个单词后面跟着多个我们不想要的字符,例如“okay...”和“Really?!!!”。
  • 我们使用 map() 因为 str_split() 返回一个列表,并且我们希望将 str_remove_all() 应用于列表中的每个元素。 (这里的例子只有一个元素。)

现在让我们看看我们是否可以使用提取获得相同的结果。我们将从构造一个捕获连字符的正则表达式开始;我们这里的定义是一个带有一个连字符的单词。由于我们希望连字符位于单词内部,因此我们需要在连字符的任一侧都有非零字符数。

str_extract_all(
  string = "This isn't a sentence with hyphenated-words.",
  pattern = "[:alpha:]+-[:alpha:]+"
)
#> [[1]]
#> [1] "hyphenated-words"

等等,这只匹配连字符! 发生这种情况是因为我们只匹配带有连字符的单词。 如果我们添加量词 ? 然后我们可以匹配 0 或 1 次出现。

str_extract_all(
  string = "This isn't a sentence with hyphenated-words.",
  pattern = "[:alpha:]+-?[:alpha:]+"
)
#> [[1]]
#> [1] "This"             "isn"              "sentence"         "with"            
#> [5] "hyphenated-words"

现在我们得到了更多的单词,但“isn't”的结尾不再存在,我们失去了“a”这个词。 我们可以通过扩展字符类 [:alpha:] 以包含字符 '. 我们通过使用 [[:alpha:]'] 来做到这一点。

str_extract_all(
  string = "This isn't a sentence with hyphenated-words.",
  pattern = "[[:alpha:]']+-?[[:alpha:]']+"
)

 

#> [[1]]
#> [1] "This"             "isn't"            "sentence"         "with"            
#> [5] "hyphenated-words"

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值