分词是自然语言处理(NLP)中一个至关重要的预处理步骤,它将原始文本转换为可以被语言模型处理的标记(tokens)。现代语言模型使用复杂的分词算法来处理人类语言的复杂性。在本文中,我们将探讨现代大型语言模型(LLM)常用的分词算法、它们的实现以及如何使用它们。
让我们开始吧!

语言模型中的分词器 (Tokenizers)
照片来源:Belle Co。部分权利保留。
概述
本文分为五个部分,它们是:
- 朴素分词
- 词干提取和词形还原
- 字节对编码(BPE)
- WordPiece
- SentencePiece 和 Unigram
朴素分词
最简单的分词形式是根据空格将文本分割成标记。这是许多 NLP 任务中常用的分词方法。
1 2 3 |
text = "Hello, world! This is a test." tokens = text.split() print(f"Tokens: {tokens}") |
输出如下:
1 |
Tokens: ['Hello,', 'world!', 'This', 'is', 'a', 'test.'] |
虽然这种方法简单快速,但它有几个限制。请记住,处理文本的模型需要知道其词汇表(所有可能标记的集合)。使用这种朴素分词,词汇表由提供的文本中的所有单词组成。在训练模型时,您会从训练数据中创建词汇表。然而,在项目中使用训练好的模型时,您可能会遇到词汇表中不存在的单词。在这种情况下,您的模型无法处理它们,或者必须将它们替换为特殊的“未知”标记。
朴素分词的另一个问题是它对标点符号和特殊字符处理不佳。例如,“world!”变成了一个标记,而在另一句话中,“world”可能是一个单独的标记。这会在词汇表中为基本相同的单词创建两个不同的标记。类似的问题也出现在大小写和连字符上。
为什么要按空格分词?在英语中,空格是我们分隔单词的方式,而单词是语言的基本单位。您不会想按字节分词,因为您会得到无意义的字母,这使得模型难以理解文本的含义。同样,按句子分词也不是理想的,因为句子比单词的数量级多得多。训练模型以句子级别理解文本将需要成比例地更多的训练数据。
然而,单词是分词的最佳级别吗?理想情况下,您希望将文本分解成最小的有意义的单元。在德语中,由于有大量的复合词,基于空格的分词不是理想的。即使在英语中,非独立单词的前缀和后缀在与其他单词组合时也带有含义。例如,“unhappy”应该被理解为“un-”+“happy”。
因此,您需要一种更好的分词方法。
词干提取和词形还原
通过实现更复杂的分词算法,您可以创建更好的词汇表。例如,这个正则表达式将文本分词成单词、标点符号和数字
1 2 3 4 5 |
import re text = "Hello, world! This is a test." tokens = re.findall(r'\w+|[^\w\s]', text) print(f"Tokens: {tokens}") |
为了进一步减小词汇量,您可以将所有内容转换为小写
1 2 3 4 5 |
import re text = "Hello, world! This is a test." tokens = re.findall(r'\w+|[^\w\s]', text.lower()) print(f"Tokens: {tokens}") |
输出如下:
1 |
Tokens: ['hello', ',', 'world', '!', 'this', 'is', 'a', 'test', '.'] |
然而,这仍然没有解决单词变体的问题。
词干提取和词形还原是两种将单词还原为其词根形式的技术。词干提取是一种更激进的技术,它根据规则去除前缀和后缀。词形还原更温和,使用字典将单词还原为其基本形式。两者都是特定于语言的,但词干提取可能会产生无效的单词。
在英语中,Porter 词干提取算法被广泛使用。您可以使用 nltk 库来实现它
1 2 3 4 5 6 7 8 9 10 11 12 |
import nltk from nltk.stem import PorterStemmer from nltk.tokenize import word_tokenize # 如果尚未完成,请下载必要的资源 nltk.download('punkt_tab') text = "These models may become unstable quickly if not initialized." stemmer = PorterStemmer() words = word_tokenize(text) stemmed_words = [stemmer.stem(word) for word in words] print(stemmed_words) |
输出如下:
1 |
['these', 'model', 'may', 'becom', 'unstabl', 'quickli', 'if', 'not', 'initi', '.'] |
您可以看到“unstabl”不是一个有效单词,但这是 Porter 词干提取算法生成的。
词形还原更温和,几乎总是能产生有效的单词。以下是使用 nltk 库进行词形还原的方法
1 2 3 4 5 6 7 8 9 10 11 12 |
import nltk from nltk.stem import WordNetLemmatizer from nltk.tokenize import word_tokenize # 如果尚未完成,请下载必要的资源 nltk.download('wordnet') text = "These models may become unstable quickly if not initialized." lemmatizer = WordNetLemmatizer() words = word_tokenize(text) lemmatized_words = [lemmatizer.lemmatize(word) for word in words] print(lemmatized_words) |
输出如下:
1 |
['These', 'model', 'may', 'become', 'unstable', 'quickly', 'if', 'not', 'initialized', '.'] |
在这两种情况下,您首先对单词进行分词,然后使用词干提取器或词形还原器进行转换。此标准化步骤可产生更一致的词汇表。但是,分词的基本问题,例如识别子词,仍然没有解决。
字节对编码(BPE)
字节对编码(BPE)是现代语言模型中最常用的分词算法之一。它最初是作为一种文本压缩算法创建的,后来被引入机器翻译,并被 GPT 模型采纳。BPE 通过迭代地合并训练数据中最频繁的相邻字符或标记对来工作。
该算法从单个字符的词汇表开始,并迭代地将最频繁的相邻对合并到新标记中。此过程一直持续到达到所需的词汇量大小。对于英语文本,您可以仅从字母表和一些标点符号开始,使初始字符集非常小。然后,常见的字母组合会通过迭代添加到词汇表中。生成的词汇表包含单个字符和常见的子词单元。
BPE 是在特定数据上训练的,因此确切的分词取决于训练数据。因此,您需要保存和加载 BPE 分词器模型才能在您的项目中使用。
BPE 不指定如何定义单词。例如,像“pre-trained”这样的连字符单词可以被视为一个单词或两个单词。这由“预分词器”决定,其最简单的形式是按空格分割单词。
许多 Transformer 模型使用 BPE,包括 GPT、BART 和 RoBERTa。您可以使用它们训练好的 BPE 分词器。以下是如何使用 Hugging Face Transformers 库中的 BPE 分词器
1 2 3 4 5 6 7 8 9 10 11 |
from transformers import GPT2Tokenizer # 加载 GPT-2 分词器(使用 BPE) tokenizer = GPT2Tokenizer.from_pretrained("gpt2") # 分词 text = "Pre-trained models are available." tokens = tokenizer.encode(text) print(f"Token IDs: {tokens}") print(f"Tokens: {tokenizer.convert_ids_to_tokens(tokens)}") print(f"Decoded: {tokenizer.decode(tokens)}") |
其输出为
1 2 3 |
Token IDs: [6719, 12, 35311, 4981, 389, 1695, 13] Tokens: ['Pre', '-', 'trained', 'Ġmodels', 'Ġare', 'Ġavailable', '.'] Decoded: Pre-trained models are available. |
您可以看到分词器使用“Ġ”来表示单词之间的空格。这是一个 BPE 使用的特殊标记,用于表示单词边界。请注意,单词既没有被词干提取也没有被词形还原:“models”保持不变,未转换为“model”。
Hugging Face 分词器的另一种选择是 OpenAI 的 tiktoken 库。这是一个示例
1 2 3 4 5 6 7 8 |
import tiktoken encoding = tiktoken.get_encoding("cl100k_base") text = "Pre-trained models are available." tokens = encoding.encode(text) print(f"Token IDs: {tokens}") print(f"Tokens: {[encoding.decode_single_token_bytes(t) for t in tokens]}") print(f"Decoded: {encoding.decode(tokens)}") |
要训练您自己的 BPE 分词器,Hugging Face Tokenizers 库是最简单的选择。这是一个示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
from datasets import load_dataset from tokenizers import Tokenizer from tokenizers.models import BPE from tokenizers.pre_tokenizers import Whitespace from tokenizers.trainers import BpeTrainer ds = load_dataset("Salesforce/wikitext", "wikitext-103-raw-v1") print(ds) tokenizer = Tokenizer(BPE(unk_token="[UNK]")) tokenizer.pre_tokenizer = Whitespace() trainer = BpeTrainer(special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"]) print(tokenizer) tokenizer.train_from_iterator(ds["train"]["text"], trainer) print(tokenizer) tokenizer.save("my-tokenizer.json") # 重新加载训练好的分词器 tokenizer = Tokenizer.from_file("my-tokenizer.json") |
运行此代码,您将看到
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
DatasetDict({ test: Dataset({ features: ['text'], num_rows: 4358 }) train: Dataset({ features: ['text'], num_rows: 1801350 }) validation: Dataset({ features: ['text'], num_rows: 3760 }) }) Tokenizer(version="1.0", truncation=None, padding=None, added_tokens=[], normalizer=None, pre_tokenizer=Whitespace(), post_processor=None, decoder=None, model=BPE(dropout=None, unk_token="[UNK]", continuing_subword_prefix=None, end_of_word_suffix=None, fuse_unk=False, byte_fallback=False, ignore_merges=False, vocab={}, merges=[])) [00:00:04] Pre-processing sequences ███████████████████████████ 0 / 0 [00:00:00] Tokenize words ███████████████████████████ 608587 / 608587 [00:00:00] Count pairs ███████████████████████████ 608587 / 608587 [00:00:02] Compute merges ███████████████████████████ 25018 / 25018 Tokenizer(version="1.0", truncation=None, padding=None, added_tokens=[ {"id":0, "content":"[UNK]", "single_word":False, "lstrip":False, "rstrip":False, ...}, {"id":1, "content":"[CLS]", "single_word":False, "lstrip":False, "rstrip":False, ...}, {"id":2, "content":"[SEP]", "single_word":False, "lstrip":False, "rstrip":False, ...}, {"id":3, "content":"[PAD]", "single_word":False, "lstrip":False, "rstrip":False, ...}, {"id":4, "content":"[MASK]", "single_word":False, "lstrip":False, "rstrip":False, ...}], normalizer=None, pre_tokenizer=Whitespace(), post_processor=None, decoder=None, model=BPE(dropout=None, unk_token="[UNK]", continuing_subword_prefix=None, end_of_word_suffix=None, fuse_unk=False, byte_fallback=False, ignore_merges=False, vocab={"[UNK]":0, "[CLS]":1, "[SEP]":2, "[PAD]":3, "[MASK]":4, ...}, merges=[("t", "h"), ("i", "n"), ("e", "r"), ("a", "n"), ("th", "e"), ...])) |
BpeTrainer
对象有更多参数来控制训练过程。在此示例中,您使用 Hugging Face 的 datasets
库加载了数据集,并在文本数据上训练了分词器。每个数据集都不同。这个数据集有“test”、“train”和“validation”分割。每个分割都有一个名为“text”的特征,其中包含字符串。我们使用 ds["train"]["text"]
训练了分词器,并让训练器查找合并,直到达到所需的词汇量大小。
您可以看到分词器在训练前后状态不同。从训练数据中学习到的标记被添加并与标记 ID 相关联。
BPE 分词器的一个关键优点是它能够通过将未知单词分解为已知的子词单元来处理它们。
WordPiece
WordPiece 是 Google 在 2016 年提出的一种流行的分词算法,被 BERT 及其变体使用。它也是一种子词分词算法。让我们看看它是如何分词一个句子的
1 2 3 4 5 6 7 8 9 10 11 |
from transformers import BertTokenizer # 从 BERT 加载 WordPiece 分词器 tokenizer = BertTokenizer.from_pretrained("bert-base-uncased") # 分词 text = "These models are usually initialized with Gaussian random values." tokens = tokenizer.encode(text) print(f"Token IDs: {tokens}") print(f"Tokens: {tokenizer.convert_ids_to_tokens(tokens)}") print(f"Decoded: {tokenizer.decode(tokens)}") |
此代码的输出是
1 2 3 |
Token IDs: [101, 2122, 4275, 2024, 2788, 3988, 3550, 2007, 11721, 17854, 2937, 6721, 5300, 1012, 102] Tokens: ['[CLS]', 'these', 'models', 'are', 'usually', 'initial', '##ized', 'with', 'ga', '##uss', '##ian', 'random', 'values', '.', '[SEP]'] Decoded: [CLS] these models are usually initialized with gaussian random values. [SEP] |
从这个输出中,您可以看到分词器将“initialized”拆分为“initial”和“##ized”。“##”前缀表示这是前一个单词的子词。如果单词前面没有“##”,则假定其前面有一个空格。
此结果包含一些 BERT 特有的设计选择。在此 BERT 模型中,所有文本都转换为小写,这由分词器隐式处理。BERT 还假定文本序列以 [CLS]
标记开头,以 [SEP]
标记结尾。这些特殊标记由分词器自动添加。这些都不是 WordPiece 算法所必需的,因此您可能在其他模型中看不到它们。
WordPiece 与 BPE 类似。两者都从所有字符的集合开始,并将一些合并为新的词汇标记。BPE 合并最频繁的标记对,而 WordPiece 使用一个分数公式来最大化似然。主要区别在于 BPE 可能会从常用词创建子词标记,而 WordPiece 通常将常用词保留为单个标记。
使用 Hugging Face Tokenizers 库训练 WordPiece 分词器与 BPE 类似。您可以使用 WordPieceTrainer
来训练分词器。这是一个示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
from datasets import load_dataset from tokenizers import Tokenizer from tokenizers.models import WordPiece from tokenizers.pre_tokenizers import Whitespace from tokenizers.trainers import WordPieceTrainer ds = load_dataset("Salesforce/wikitext", "wikitext-103-raw-v1") tokenizer = Tokenizer(WordPiece(unk_token="[UNK]")) tokenizer.pre_tokenizer = Whitespace() trainer = WordPieceTrainer(special_tokens=["[UNK]", "[CLS]", "[SEP]", "[PAD]", "[MASK]"]) tokenizer.train_from_iterator(ds["train"]["text"], trainer) tokenizer.save("my-tokenizer.json") |
SentencePiece 和 Unigram
BPE 和 WordPiece 是从底层构建的。它们从所有字符的集合开始,并将一些合并为新的词汇标记。您也可以从顶层构建分词器,从训练数据中的所有单词开始,然后将词汇表修剪到所需的大小。
Unigram 就是这样一种算法。训练 Unigram 分词器涉及在每个步骤中根据对数似然分数删除词汇项。与 BPE 和 WordPiece 不同,训练好的 Unigram 分词器不是基于规则的,而是统计的。它保存每个标记的似然度,用于确定新文本的分词。
虽然理论上可以有一个独立的 Unigram 分词器,但它最常作为 SentencePiece 的一部分出现。
SentencePiece 是一种语言无关的分词算法,不需要对输入文本进行预分词。它对于多语言场景特别有用,因为例如,英语使用空格分隔单词,而中文则不使用。SentencePiece 通过将输入文本视为 Unicode 字符流来处理这些差异。然后它使用 BPE 或 Unigram 来创建分词。
以下是如何使用 Hugging Face Transformers 库中的 SentencePiece 分词器
1 2 3 4 5 6 7 8 9 10 11 |
from transformers import T5Tokenizer # 加载 T5 分词器(使用 SentencePiece+Unigram) tokenizer = T5Tokenizer.from_pretrained("t5-small") # 分词 text = "SentencePiece is a subword tokenizer used in models such as XLNet and T5." tokens = tokenizer.encode(text) print(f"Token IDs: {tokens}") print(f"Tokens: {tokenizer.convert_ids_to_tokens(tokens)}") print(f"Decoded: {tokenizer.decode(tokens)}") |
输出如下:
1 2 3 |
Token IDs: [4892, 17, 1433, 345, 23, 15, 565, 19, 3, 9, 769, 6051, 14145, 8585, 261, 16, 2250, 224, 38, 3, 4, 434, 9688, 11, 332, 9125, 1] Tokens: [' Sen', 't', 'ence', 'P', 'i', 'e', 'ce', ' is', ' ', 'a', ' sub', 'word', ' token', 'izer', ' used', ' in', ' models', ' such', ' as', ' ', 'X', 'L', 'Net', ' and', ' T', '5.', ''] Decoded: SentencePiece is a subword tokenizer used in models such as XLNet and T5. |
与 WordPiece 类似,会添加一个特殊的 Seuquence(下划线字符,“_”)来区分子词和单词。
使用 Hugging Face Tokenizers 库训练 SentencePiece 分词器也类似。这是一个示例
1 2 3 4 5 6 7 8 |
from datasets import load_dataset from tokenizers import SentencePieceUnigramTokenizer ds = load_dataset("Salesforce/wikitext", "wikitext-103-raw-v1") tokenizer = SentencePieceUnigramTokenizer() tokenizer.train_from_iterator(ds["train"]["text"]) tokenizer.save("my-tokenizer.json") |
您也可以使用 Google 的 sentencepiece
库来实现相同的目的。
进一步阅读
以下是一些关于该主题的进一步阅读材料:
- Porter 词干提取算法
- 使用子词单元的罕见词的神经机器翻译(BPE 论文)
- 谷歌的神经机器翻译系统:弥合人类和机器翻译之间的差距(WordPiece 论文)
- 快速 WordPiece 分词
- 子词正则化:通过多个子词候选改进神经网络翻译模型(Unigram 论文)
- SentencePiece:一种简单且与语言无关的子词分词器和反分词器,用于神经文本处理
- Hugging Face Tokenizers 库:https://hugging-face.cn/docs/tokenizers/
- SentencePiece 库:https://github.com/google/sentencepiece
总结
在本文中,您探索了现代语言模型中使用的不同类型的分词算法。您了解到
- BPE 被 GPT 模型广泛使用,并通过合并频繁的相邻对来工作
- WordPiece 被 BERT 模型使用,并最大化训练数据的似然度
- SentencePiece 更灵活,无需预分词即可处理不同语言
- 现代分词器包含重要功能,如特殊标记、截断和填充
理解这些分词算法对于使用现代语言模型和有效预处理文本数据至关重要。
暂无评论。