基础分词(Naive Tokenization)
最简单的分词方式是基于空格将文本拆分为单词。这是许多自然语言处理(NLP)任务中常用的一种分词方法。
text = "Hello, world! This is a test."
tokens = text.split()
print(f"Tokens: {tokens}")
输出结果为:
Tokens: ['Hello,', 'world!', 'This', 'is', 'a', 'test.']
虽然这种方法简单且快速,但存在诸多局限。例如,模型处理文本时需要了解其词汇表——即所有可能的分词集合。采用这种朴素分词,词汇表就是训练数据中出现的所有单词。当模型投入实际应用时,可能遇到词汇表外的新词,这时模型无法处理这些词,或者只能用特殊的“未知”标记替代。
此外,朴素分词对标点和特殊字符的处理也很糟糕。例如,“world!” 会视为一个整体分词,而在另一句中,“world” 可能是单独的分词,这样就会为本质相同的词在词汇表中分配两个不同的标记。类似问题还出现在大小写和连字符的处理上。
为什么要用空格分词?
在英语中,空格用于分隔单词,而单词是语言的基本单位。如果按字节分词,会得到毫无意义的字母序列,模型难以理解文本含义。同理,按句子分词也不可行,因为句子的数量比单词多得多,训练模型理解句子层面的文本需要成倍增加的数据量。
然而,单词真的是最佳分词单位吗?理想情况下,我们希望将文本拆解为最小的有意义单位。例如在德语中,由于大量复合词,基于空格的分词效果很差。即使在英语中,前缀和后缀也往往与其他单词组合表达特定含义,比如 “unhappy” 应该理解为 “un-” + “happy”。
因此,我们需要更好的分词方法。
词干提取与词形还原(Stemming and Lemmatization)
通过实现更复杂的分词算法,可以构建更优的词汇表。例如,以下正则表达式可将文本分割为单词、标点和数字:
import retext = "Hello, world! This is a test."
tokens = re.findall(r'\w+|[^\w\s]', text)
print(f"Tokens: {tokens}")
为了进一步减少词汇表大小,可以将所有内容转为小写:
import retext = "Hello, world! This is a test."
tokens = re.findall(r'\w+|[^\w\s]', text.lower())
print(f"Tokens: {tokens}")
输出结果为:
Tokens: ['hello', ',', 'world', '!', 'this', 'is', 'a', 'test', '.']
但这仍无法解决词形变化的问题。
**词干提取(Stemming)和词形还原(Lemmatization)**是两种将单词归约为词根的技术。
-
词干提取通过规则去除前后缀,操作较为激进,可能生成无效词。
-
词形还原则较为温和,借助词典将单词还原为基本形式,几乎总能得到有效词。两者都依赖具体语言。
在英语中,常用的 Porter 词干提取算法可借助 nltk 库实现:
import nltk
from nltk.stem import PorterStemmer
from nltk.tokenize import word_tokenizenltk.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)
输出为:
['these', 'model', 'may', 'becom', 'unstabl', 'quickli', 'if', 'not', 'initi', '.']
可以看到,“unstabl” 并不是有效单词,但这是 Porter 算法的结果。
词形还原的效果更佳,基本总能得到有效单词。用 nltk 实现如下:
import nltk
from nltk.stem import WordNetLemmatizer
from nltk.tokenize import word_tokenizenltk.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)
输出为:
['These', 'model', 'may', 'become', 'unstable', 'quickly', 'if', 'not', 'initialized', '.']
无论哪种方法,都是先分词、后用词干/词形还原器归一化,构建更一致的词汇表。不过,像子词识别等根本性分词问题仍未解决。
字节对编码(Byte-Pair Encoding, BPE)
字节对编码(BPE)是现代语言模型中最广泛使用的分词算法之一。它最初是一种文本压缩算法,后被引入机器翻译领域,随后被 GPT 等模型采用。BPE 的核心思想是:在训练数据中,反复合并出现频率最高的相邻字符或分词对。
该算法以单个字符为初始词表,不断将最常见的相邻字符对合并为新分词。这个过程持续进行,直到达到期望的词表大小。对于英文文本,你可以仅以字母和少量标点作为初始字符集,然后逐步将常见字母组合引入词表。最终,词表既包含单个字符,也包含常见的子词单元。
BPE 需要在特定数据集上训练,因此其分词方式取决于训练数据。因此,你需要保存并加载 BPE 分词器模型,以便在项目中使用。
BPE 并未规定“单词”如何定义。例如,带连字符的“pre-trained”可以被视为一个单词,也可以拆为两个,这取决于“预分词器”(pre-tokenizer),最简单的形式就是按空格切分。
许多 Transformer 模型都采用 BPE,包括 GPT、BART 和 RoBERTa。你可以直接使用它们训练好的 BPE 分词器。例如调用 Hugging Face Transformers 库如下:
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)}")
输出如下:
Token IDs: [6719, 12, 35311, 4981, 389, 1695, 13]
Tokens: ['Pre', '-', 'trained', 'Ġmodels', 'Ġare', 'Ġavailable', '.']
Decoded: Pre-trained models are available.
可以看到,分词器用“Ġ”来表示单词间的空格,这是 BPE 用于标识词边界的特殊符号。注意,词语并未被词干提取或词形还原——“models” 保持原样。
OpenAI 的 tiktoken 库也是一个可选方案,示例如下:
import tiktokenencoding = 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 库非常方便。示例如下:
from datasets import load_dataset
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.pre_tokenizers import Whitespace
from tokenizers.trainers import BpeTrainerds = 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")
运行结果显示:
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(..., 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", ..., model=BPE(..., vocab={"[UNK]":0, "[CLS]":1, "[SEP]":2, "[PAD]":3, "[MASK]":4, ...}, merges=[("t", "h"), ("i", "n"), ("e", "r"), ...]))
BpeTrainer
对象支持更多训练参数。上述示例中,我们用 Hugging Face 的 datasets 库加载数据集,并在“train”数据上训练分词器。每个数据集结构略有不同——本例包含“test”、“train”和“validation”三部分,每部分有一个名为“text”的字段。我们用 ds["train"]["text"]
训练,训练器会自动合并直到达到目标词表大小。
训练前后分词器的状态明显不同——训练后,词表中新增了从训练数据中学习到的分词及其 ID。
BPE 分词器最大的优势之一,就是能将未登录词(未知词)拆分为已知的子词单元进行处理。
WordPiece
WordPiece 是 Google 于 2016 年提出的著名分词算法,被 BERT 及其变体广泛采用。它同样是一种子词分词算法。让我们看一个分词示例:
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)}")
输出如下:
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 类似。例如:
from datasets import load_dataset
from tokenizers import Tokenizer
from tokenizers.models import WordPiece
from tokenizers.pre_tokenizers import Whitespace
from tokenizers.trainers import WordPieceTrainerds = 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 分词器的例子:
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)}")
输出为:
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,SentencePiece 用特殊的前缀字符(下划线“_”或“▁”)来区分词与词内子词。
训练 SentencePiece 分词器也同样简单,下面是使用 Hugging Face Tokenizers 库进行训练的例子:
from datasets import load_dataset
from tokenizers import SentencePieceUnigramTokenizerds = load_dataset("Salesforce/wikitext", "wikitext-103-raw-v1")
tokenizer = SentencePieceUnigramTokenizer()tokenizer.train_from_iterator(ds["train"]["text"])
tokenizer.save("my-tokenizer.json")
你也可以使用 Google 的 sentencepiece 库实现同样的功能。
延伸阅读
如需了解更多,推荐以下资料:
-
The Porter Stemming Algorithm
-
BPE 论文:Neural Machine Translation of Rare Words with Subword Units
-
WordPiece 论文:Google’s Neural Machine Translation System: Bridging the Gap between Human and Machine Translation
-
Fast WordPiece Tokenization
-
Unigram 论文:Subword Regularization: Improving Neural Network Translation Models with Multiple Subword Candidates
-
SentencePiece 论文:A simple and language independent subword tokenizer and detokenizer for Neural Text Processing
-
Hugging Face Tokenizers 文档
-
Google SentencePiece 项目
总结
本文介绍了现代语言模型中常用的分词算法:
- BPE
:GPT 等模型广泛采用,通过合并高频相邻对实现子词分词。
- WordPiece
:BERT 模型采用,通过最大化训练数据似然分数来合并子词单元。
- SentencePiece
:更灵活,可无预分词直接处理多语言文本,底层可选用 BPE 或 Unigram。
-
现代分词器还包含特殊分词、截断、填充等重要功能。