Tokenizers
tokenizer 是 NLP 管道的核心组件之一。它们有一个非常明确的目的:将文本转换为模型可以处理的数据。模型只能处理数字,因此 tokenizer 需要将我们的文本输入转换为数字。在本节中,我们将确切地探讨 tokenization 管道中发生的事情。
在 NLP 任务中,通常处理的原始数据是文本。这里是一个例子:
Jim Henson was a puppeteer
但是,模型只能处理数字,因此我们需要找到一种将原始文本转换为数字的方法。这就是 tokenizer 所做的,并且有很多方法可以解决这个问题。目标是找到最有意义的表达方式 —— 即对模型来说最有意义的方式 —— 如果可能,还要找到最简洁的表达方式。
让我们看一下 tokenization 算法的一些示例,并尝试回答一些你可能对 tokenization 有的疑问。
基于单词(Word-based)的 tokenization
想到的第一种 tokenizer 是基于词(word-based)的 tokenization。它通常很容易配置和使用,只需几条规则,并且通常会产生不错的结果。例如,在下图中,目标是将原始文本拆分为单词并为每个单词找到一个数字表示:
有多种方法可以拆分文本。例如,我们可以通过使用 Python 的 split()
函数,使用空格将文本分割为单词:
tokenized_text = "Jim Henson was a puppeteer".split()
print(tokenized_text)
['Jim', 'Henson', 'was', 'a', 'puppeteer']
此外,还有一些基于单词的 tokenizer 的变体,对标点符号有额外的规则。使用这类 tokenizer,我们最终可以得到一些非常大的“词汇表(vocabulary)”,其中词汇表的大小由我们在语料库中拥有的独立 tokens 的总数确定。
每个单词都分配了一个 ID,从 0 开始一直到词汇表的大小。模型使用这些 ID 来识别每个词。
如果我们想用基于单词的 tokenizer 完全覆盖一种语言,我们需要为语言中的每个单词设置一个标识符,这将生成大量的 tokens。例如,英语中有超过 500,000 个单词,因此要构建从每个单词到 ID 的映射,我们需要跟踪这么多 ID。此外,像“dog”这样的词与“dogs”这样的词的表示方式不同,模型最初无法知道“dog”和“dogs”是相似的:它会将这两个词识别为不相关。这同样适用于其他相似的词,例如“run”和“running”,模型最初也不会看到它们的相似性。
最后,我们需要一个自定义 token 来表示不在我们词汇表中的单词。这被称为“unknown” token,通常表示为“[UNK]”或“<unk>”。如果你看到 tokenizer 产生了很多这样的 token 这通常是一个不好的迹象,因为它无法检索到一个词的合理表示,并且你会在转化过程中丢失信息。制作词汇表时的其中一个目标是 tokenizer 将尽可能少的单词标记为未知 tokens。
减少未知 tokens 数量的一种方法是使用更深一层的 tokenizer 即基于字符(character-based)的 tokenizer
基于字符(Character-based)的 tokenization
基于字符的 tokenizer 将文本拆分为字符,而不是单词。这有两个主要好处:
- 词汇量要小得多。
- unknown tokens (out-of-vocabulary)要少得多,因为每个单词都可以由字符构建。
但在此过程中也有一些问题,关于空格和标点符号:
这种方法也不是完美的。由于现在表示是基于字符而不是单词,因此人们可能会争辩说,从直觉上讲,它的意义不大:每个字符本身并没有多大意义,但是单词则不然。然而,这又因语言而异;例如,在中文中,每个字符比拉丁语言中的字符包含更多的信息。
另一件要考虑的因素是,这样做会导致我们的模型需要处理大量的 tokens:虽然一个单词在基于单词的 tokenizer 中只是一个 token,但当它被转换为字符时,很可能就变成了 10 个或更多的 tokens
为了两全其美,我们可以使用结合这两种方法的第三种技术:基于子词(subword)的 tokenization。
基于子词(subword)的 tokenization
基于子词(subword)的 tokenization 算法依赖于这样一个原则:常用词不应被分解为更小的子词,但罕见词应被分解为有意义的子词。
例如,“annoyingly”可能被视为一个罕见的词,可以分解为“annoying”和“ly”。这两者都可能作为独立的子词并且出现得更频繁,同时“annoyingly”的含义通过“annoying”和“ly”的复合含义得以保留。
这里有一个例子,展示了基于子词的 tokenization 算法如何将序列“Let’s do tokenization!”分词:
这些子词最终提供了大量的语义信息:例如,在上面的例子中,“tokenization”被分割成“token”和“ization”,这两个 tokens 在保持空间效率的同时具有语义意义(只需要两个 tokens 就能表示一个长词)。这让我们能够在词汇量小的情况下获得相对良好的覆盖率,并且几乎没有未知的 token。
这种方法在土耳其语等粘着型语言(agglutinative languages)中特别有用,你可以通过将子词串在一起来形成(几乎)任意长的复杂词。
还有更多!
不出所料,还有更多的技术。仅举几例:
- Byte-level BPE,用于 GPT-2
- WordPiece,用于 BERT
- SentencePiece or Unigram,用于多个多语言模型
你现在应该对 tokenizer 的工作原理有足够的了解,可以开始使用 API 了。
加载和保存
加载和保存 tokenizer 就像使用模型一样简单。实际上,它基于相同的两种方法: from_pretrained()
和 save_pretrained()
。这些方法会加载或保存分词器使用的算法(有点像模型的架构(architecture))以及其词汇表(有点像模型的权重(weights))。
加载使用与 BERT 相同的 checkpoint 训练的 BERT tokenizer 与加载模型的方式相同,只是换成了 Bert tokenizer
类:
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained("bert-base-cased")
如同 AutoModel
, AutoTokenizer
类将根据 checkpoint 名称在库中获取正确的 tokenizer 类,并且可以直接与任何 checkpoint 一起使用:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
现在我们可以像在上一节中显示的那样使用 tokenizer:
tokenizer("Using a Transformer network is simple")
{'input_ids': [101, 7993, 170, 11303, 1200, 2443, 1110, 3014, 102],
'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0],
'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1]}
保存 tokenizer 与保存模型完全相同:
tokenizer.save_pretrained("directory_on_my_computer")
我们将在 第三章 中将更多地谈论 token_type_ids
,稍后我们将解释 attention_mask
。首先,让我们看看如何生成 input_ids
。为此,我们需要查看 tokenizer 的内部是如何实现的。
编码
将文本翻译成数字被称为编码(encoding)。编码分两步完成:分词,然后转换为 inputs ID。
正如我们所见,第一步是将文本拆分为单词(或部分单词、标点符号等),通常称为 tokens 不同的不同的分词器使用的算法也不一样,这就是为什么我们需要使用模型名称来实例化 tokenizer,以确保我们使用模型预训练时使用的相同的算法。
第二步是将这些 tokens 转换为数字,这样我们就可以用它们构建一个张量并将它们提供给模型。为此,tokenizer 有一个词汇表(vocabulary),这是我们在使用 from_pretrained()
方法实例化它时下载的部分。同样,我们需要使用与预训练模型时相同的词汇表。
为了更好地理解这两个步骤,我们将分别探讨它们。请注意,我们将单独执行部分 tokenization 管道的方法来向你展示这些步骤的中间结果,但在实践中,你应该直接在你的输入上调用 tokenizer(如第 2 小节所示)。
tokenization
tokenization 过程由 tokenizer 的 tokenize()
方法实现:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
sequence = "Using a Transformer network is simple"
tokens = tokenizer.tokenize(sequence)
print(tokens)
这个方法的输出是一个字符串列表,或者说 tokens
['Using', 'a', 'transform', '##er', 'network', 'is', 'simple']
这个 tokenizer 是一个基于子词的 tokenizer:它对词进行拆分,直到获得可以用其词汇表表示的 tokens。以 transformer
为例,它分为两个 tokens transform
和 ##er
。
从 tokens 到 inputs ID
inputs ID 的转换由 tokenizer 的 convert_tokens_to_ids()
方法实现:
ids = tokenizer.convert_tokens_to_ids(tokens)
print(ids)
[7993, 170, 11303, 1200, 2443, 1110, 3014]
这些输出,一旦转换为适当的框架张量,就可以用作模型的输入,如本章前面所示。
✏️ 试试看! 请将我们在第 2 节中使用的输入句子(“I’ve been waiting for a HuggingFace course my whole life.”和“I hate this so much!”)执行最后两个步骤(分词和转换为 inputs ID)。检查你获得的 inputs ID 是否与我们在第二节中获得的一致!
解码
解码(Decoding) 正好相反:从 inputs ID 到一个字符串。这以通过 decode()
方法实现:
decoded_string = tokenizer.decode([7993, 170, 11303, 1200, 2443, 1110, 3014])
print(decoded_string)
'Using a Transformer network is simple'
请注意, decode
方法不仅将索引转换回 tokens,还将属于相同单词的 tokens 组合在一起以生成可读的句子。当我们使用预测新文本的模型(根据提示生成的文本,或序列到序列问题(如翻译或摘要))时,这样的功能将非常有用。
到现在为止,你应该了解 tokenizer 可以处理的原子操作:分词、转换为 ID 以及将 ID 转换回字符串。然而,我们只是瞥到了冰山一角。在下一节中,我们将继续探讨它能力的极限,并看看如何克服它们。
< > Update on GitHub