规范化
python
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
print(type(tokenizer.backend_tokenizer)) # <class 'tokenizers.Tokenizer'>
print(tokenizer.backend_tokenizer.normalizer.normalize_str("Héllò hôw are ü?"))
# 'hello how are u?'
使用normalize_str方法删除了重音等特殊符号。
预分词
快速分词器(FastTokenizer,比如 Hugging Face 的
BertFastTokenizer)处理文本分为两步:
- 预分词(Pre-tokenization):把原始文本拆成 "基础语义单元"(比如单词、标点),并记录每个单元在原始文本中的位置;
- 子词切分(Subword Tokenization) :把基础单元进一步拆成模型能识别的子词(比如
Hello→Hell+o)。
pre_tokenize_str()是展示预分词结果的方法,使看到分词器对原始文本的 "初步拆解"。
- BERT Tokenizer
python
tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str("Hello, how are you?")
[('Hello', (0, 5)), (',', (5, 6)), ('how', (7, 10)), ('are', (11, 14)), ('you', (16, 19)), ('?', (19, 20))]
注意到BERT Tokenizer不会把两个空格作为语义单元,但是会在制作offset时定位准确CharSpan
- GPT2
python
tokenizer = AutoTokenizer.from_pretrained("gpt2")
tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str("Hello, how are you?")
[('Hello', (0, 5)), (',', (5, 6)), ('Ġhow', (6, 10)), ('Ġare', (10, 14)), ('Ġ', (14, 15)), ('Ġyou', (15, 19)), ('?', (19, 20))]
而在GPT2 Tokenizer中,则是会把空格作为语义单元,用Ġ符号进行表示,这样解码后可以恢复出原始的空格。
- T5
python
tokenizer = AutoTokenizer.from_pretrained("t5-small")
tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str("Hello, how are you?")
[('▁Hello,', (0, 6)), ('▁how', (7, 10)), ('▁are', (11, 14)), ('▁you?', (16, 20))]
T5默认在句首添加_,并且忽略两个空格仅作为一个。
Byte-Pair Encoding tokenization
训练
首先计算单个字符的出现频率
"hug", "pug", "pun", "bun", "hugs"
#重要 这一步是pre-tokenization的结果统计
以上述例子为例,统计单个字符的出现频次
("h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5)
假设出现的频次如上,那么下一步BPE就会统计连续两个同时出现次数最多的字符合成 token
这里u和g相邻一共出现了16次,因此合并为一个token继续训练
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)
下一步统计其中u和g相邻且出现次数最多,因此合并为一个token继续训练
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("h" "ug" "s", 5)
再下一步,发现h和ug相邻且出现的次数最多,因此合并为一个token继续训练
Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"]
Corpus: ("hug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("hug" "s", 5)
分词算法
输入通过以下几个步骤进行分词
1. Normalize;
2. Pre-tokenize;
3. 拆分单词为单个字符;
4. 将单个字符按照已经学习到的规则进行组合形成token
以训练得到的规则为例
("u", "g") -> "ug"
("u", "n") -> "un"
("h", "ug") -> "hug"
那么
"bug" -> 'b' 'u' 'g' -> 'b' 'ug'
"mug" -> '[UNK]' 'u' 'g' -> "[UNK]" 'ug'
"thug" -> '[UNK]' 'h' 'u' 'g' -> '[UNK]' 'h' 'ug' -> '[UNK]' 'hug'
"unhug" -> 'u' 'n' 'h' 'u' 'g' -> "un" 'h' "ug" -> "un" "hug"
如上步骤完成分词,其中未在vocabulary中出现的则被是做[UNK]。
WordPiece tokenization
wordPiece tokenization的合并规则为
score=(freq_of_pair)(freq_of_first_element×freq_of_second_element) score=\frac{(freq\_of\_pair)}{(freq\_of\_first\_element \times freq\_of\_second\_element)} score=(freq_of_first_element×freq_of_second_element)(freq_of_pair)
这个得分用于量化 两个元素的共现是偶然的,还是必然的。
得分越高 :说明 "实际共现次数" 相对于 "随机组合的预期次数" 越高,即这两个元素几乎只在一起出现 (强绑定),合并的价值越大; 得分越低 :说明 "实际共现次数" 相对于 "随机组合的预期次数" 越低,即这两个元素各自独立出现的场景更多(弱绑定),合并的价值越小。
旨在优先合并词汇表中各部分频率较低的词对。
训练
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
wordPiece tokenization作为预训练BERT开发的词法分析,BERT会把非首字母字符拆分成##的形式,进行统计得:
("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##g" "##s", 5)
其中##g和##s组合的分数为
score=520×5=120 score = \frac{5}{20\times5}=\frac{1}{20} score=20×55=201
##u和##n组合的分数为
score=1636×16=136 score=\frac{16}{36\times16}=\frac{1}{36} score=36×1616=361
因此优先合并组合分数较高的##gs,注意合并后仅保留一个##
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs"]
Corpus: ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##gs", 5)
此时, "##u" 存在于所有可能的配对中,因此它们的得分都相同。假设在这种情况下,第一个配对被合并,即 ("h", "##u") -> "hu" 。这将得到
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu"]
Corpus: ("hu" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("hu" "##gs", 5)
然后,得分第二高的是 ("hu", "##g") 和 ("hu", "##gs") (得分均为 1/15,而其他所有组合的得分均为 1/21),因此得分最高的第一对将被合并
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu", "hug"]
Corpus: ("hug", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("hu" "##gs", 5)
分词算法
Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu", "hug"]
WordPiece 只保存最终的词汇表,找到词汇表中最长的子词并以此为分割点进行分割
hugs -> hug ##s;
bugs -> b ##u ##gs;
mug -> [UNK]
bum -> [UNK]
pugs -> p ##u ##gs
由于m不在词汇表中,当分词过程中无法在词汇表中找到子词 时,整个词会被分词为未知词
Unigram tokenization
Unigram算法与上两种算法的原理不同,是从一个较大的词汇表中删除 token,通过筛选删去后Loss降低较小的token去整合最终的高可能性tokens列表。
训练
Corpus: ("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
Vocabulary: ["h", "u", "g", "hu", "ug", "p", "pu", "n", "un", "b", "bu", "s", "hug", "gs", "ugs"]
初始的大词汇表这里暂定选这个,一般可以通过较大的初始文本中通过BPE得到。
("h", 15) ("u", 36) ("g", 20) ("hu", 15) ("ug", 20) ("p", 17) ("pu", 17) ("n", 16)
("un", 16) ("b", 4) ("bu", 4) ("s", 5) ("hug", 15) ("gs", 5) ("ugs", 5)
Unigram的一个核心定义就是每个token都是独立的,因此单词的组合就是每个token概率乘积 。
P([p,u,g])=P(p)×P(u)×P(g)=5210×36210×20210=0.000389P([pu,g])=P(pu)×P(g)=5210×20210=0.0022676 \begin{aligned} P([p,u,g])=P(p)×P(u)×P(g)=\frac{5}{210}×\frac{36}{210}×\frac{20}{210}=0.000389 \\ P([pu,g])=P(pu)×P(g)=\frac{5}{210}×\frac{20}{210}=0.0022676 \end{aligned} P([p,u,g])=P(p)×P(u)×P(g)=2105×21036×21020=0.000389P([pu,g])=P(pu)×P(g)=2105×21020=0.0022676
上述是pug被分词为p、u、g和pu、g的概率。一般来说,分词数量最少的方案概率最高(因为每个分词都要除以 210),这符合我们的直觉:将一个词拆分成尽可能少的分词。
viterbi算法分词
目标单词 :huggun,其字符与位置对应关系:
| 字符位置 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 字符 | h | u | g | g | u | n |
为了实现动态规划,我们需要两个数组:
dp数组 :dp[i]表示以第i个字符结尾的最优分词方案的概率得分 。- 初始化:
dp[-1] = 1(虚拟起始位置,方便计算第一个字符得分)其余dp[i]初始为 0。
- 初始化:
backtrace数组 :backtrace[i]记录以第i个字符结尾的最优方案中,最后一个子词的起始位置j和子词内容,用于最后回溯路径。
我们逐个遍历字符位置i(从 0 到 5),计算每个位置的dp[i]和backtrace[i]。
步骤1处理位置i=0(字符h)
目标:找到以h结尾的最优分词方案。
- 遍历可能的起始位置
j(j ≤ i,即j=0):- 子词:
word[0:0+1] = "h"(在词汇表中)。 - 得分计算:
dp[j-1] × P("h") = dp[-1] × 15/210 = 1 × 0.0714 = 0.0714。
- 子词:
- 结果:
dp[0] = 0.0714(唯一可能的得分)。backtrace[0] = (j=0, 子词="h")。
步骤2处理位置i=1(字符u)
目标:找到以u结尾的最优分词方案。
- 遍历可能的起始位置
j(j=0、j=1):- j=0 :子词
word[0:2] = "hu"(在词汇表中),得分 =dp[-1] × 15/210 = 0.0714。 - j=1 :子词
word[1:2] = "u"(在词汇表中),得分 =dp[0] × 36/210 = 0.0714 × 0.1714 ≈ 0.0122。
- j=0 :子词
- 比较得分:
0.0714(j=0)> 0.0122(j=1)。 - 结果:
dp[1] = 0.0714。backtrace[1] = (j=0, 子词="hu")。
步骤3处理位置i=2(字符g)
目标:找到以g结尾的最优分词方案。
- 遍历可能的起始位置
j(j=0、j=1、j=2):- j=0 :子词
word[0:3] = "hug"(在词汇表中),得分 =dp[-1] × 15/210 = 0.0714。 - j=1 :子词
word[1:3] = "ug"(在词汇表中),得分 =dp[0] × 20/210 ≈ 0.0714 × 0.0952 ≈ 0.0068。 - j=2 :子词
word[2:3] = "g"(在词汇表中),得分 =dp[1] × 20/210 ≈ 0.0714 × 0.0952 ≈ 0.0068。
- j=0 :子词
- 比较得分:
0.0714(j=0)最高。 - 结果:
dp[2] = 0.0714。backtrace[2] = (j=0, 子词="hug")。
步骤4处理位置i=3(字符g)
目标:找到以g结尾的最优分词方案。
- 遍历可能的起始位置
j(j=0、1、2、3),只保留词汇表中存在的子词 :- j=0:
hugg(不在词汇表,跳过)。 - j=1:
ugg(不在词汇表,跳过)。 - j=2:
gg(不在词汇表,跳过)。 - j=3 :子词
word[3:4] = "g"(在词汇表中),得分 =dp[2] × 20/210 ≈ 0.0714 × 0.0952 ≈ 0.0068。
- j=0:
- 结果:
dp[3] = 0.0068。backtrace[3] = (j=3, 子词="g")。
步骤5处理位置i=4(字符u)
目标:找到以u结尾的最优分词方案。
- 遍历可能的起始位置
j(j=0到4),只保留词汇表中存在的子词 :- j=0-3:
huggu/uggu/ggu/gu(均不在词汇表,跳过)。 - j=4 :子词
word[4:5] = "u"(在词汇表中),得分 =dp[3] × 36/210 ≈ 0.0068 × 0.1714 ≈ 0.00117。
- j=0-3:
- 结果:
dp[4] = 0.00117。backtrace[4] = (j=4, 子词="u")。
步骤6处理位置i=5(字符n)
目标:找到以n结尾的最优分词方案。
- 遍历可能的起始位置
j(j=0到5),只保留词汇表中存在的子词 :- j=0-3:
huggun/uggun/ggun/gun(均不在词汇表,跳过)。 - j=4 :子词
word[4:6] = "un"(在词汇表中),得分 =dp[3] × 16/210 ≈ 0.0068 × 0.0762 ≈ 0.000518。 - j=5 :子词
word[5:6] = "n"(在词汇表中),得分 =dp[4] × 16/210 ≈ 0.00117 × 0.0762 ≈ 0.000088。
- j=0-3:
- 比较得分:
0.000518(j=4)> 0.000088(j=5)。 - 结果:
dp[5] = 0.000518(这是最终的总得分)。backtrace[5] = (j=4, 子词="un")。
从最后一个位置i=5开始,通过backtrace数组倒推,拼接子词:
i=5:backtrace[5]显示子词是un,起始位置j=4→ 前驱位置是j-1=3。i=3:backtrace[3]显示子词是g,起始位置j=3→ 前驱位置是j-1=2。i=2:backtrace[2]显示子词是hug,起始位置j=0→ 前驱位置是j-1=-1(结束)。
最终的分词结果:["hug", "g", "un"],总得分≈0.000518(用分数计算更准确:15/210 × 20/210 × 16/210 = 4800/9261000 ≈ 0.000518)。
词汇表训练
定义
Loss=∑i=1Nfreqi×−log(P(word)) Loss=\sum_{i=1}^N freq_i \times-\log(P(word)) Loss=i=1∑Nfreqi×−log(P(word))
其中,N是语料中的预分词总数,word是预分词经过分词后得到的组成这个预分词的概率 ,freq_i是该单词在语料中的出现频次。
("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)
"hug": ["hug"] (score 0.071428)
"pug": ["pu", "g"] (score 0.007710)
"pun": ["pu", "n"] (score 0.006168)
"bun": ["bu", "n"] (score 0.001451)
"hugs": ["hug", "s"] (score 0.001701)
Loss = 10 * (-log(0.071428)) + 5 * (-log(0.007710)) + 12 * (-log(0.006168)) + 4 * (-log(0.001451)) + 5 * (-log(0.001701)) = 169.8
由于pu、g和p、ug的拆分概率是相同的,因此若删除pu、ug这两个token后,整体Loss不变。
但是如果删除hug这个token
"hug": ["hu", "g"] (score 0.006802)
"hugs": ["hu", "gs"] (score 0.001701)
Loss += - 10 * (-log(0.071428)) + 10 * (-log(0.006802)) = 23.5