同一个 abc,为什么换了切法:Unigram 的训练与编码
同一个 abc,初始模型给「整块 abc」这条切分分配了 81% 的占比。
训练结束后,最高分切法却变成了 a | bc。输入没有换,变的是全语料共用的那张词条概率表。
这比“Unigram 使用概率分词”更具体:概率从哪些统计中来,统计又怎样依赖尚未确定的切法? 等表训练好了,编码还要解决第二个问题:如何用它找到整段文本的最佳切分。
此前的 BPE 从基础单位出发,逐步增加合并规则;这里从较大的候选集合开始,更新分数并逐步删词。 确定性编码也换了依据:BPE 按已有合并优先级执行,Unigram 比较完整路径的总分。
“Unigram”指一个词条使用自己的概率,不依赖前一个词条;词条本身可以包含多个字符。 它训练的是分词器的小概率模型,与 Transformer 的下一 Token 预测模型分开。 原论文 §3.2 给出了这套路径评分与词表训练框架。 SentencePiece 则是支持 Unigram、BPE 等算法的工具库,使用它并不自动意味着选择了 Unigram。
先把语料、词表和切分分开
本实验的语料是预先给定的。相同文本合并为频次,共 8 种文本、104 条样本:
| 文本 | abc | ab | bc | a | b | c | abca | cab |
|---|---|---|---|---|---|---|---|---|
| 次数 | 12 | 24 | 32 | 16 | 8 | 8 | 2 | 2 |
使用 identity 规范化,关闭句首虚拟前缀;每条样本独立切分,边界外不相连。 从语料收集长度 1~3 的连续子串并去重,得到:
a b c ab bc ca abc bca cab这 9 个候选词条暂时各取概率 p(t) = 1/9,总和为 1。
基础字符 a、b、c 受到保护;目标是最终保留 6 个普通词条。
候选长度、均匀初始化、保护集合与目标数量,都是本实验的配置。
词表确定了有哪些候选,尚未确定每条文本用哪些候选。
abc 恰好有四条合法完整路径:
a | b | ca | bcab | cabc因此 8 是文本种类数,9 是候选词表大小,4 是当前文本的切法数。
初始化的 1/9 来自整张词表,不能因为当前只有四种切法就换成 1/4。
ca、bca、cab 当前用不上,仍占据全局概率表中的三项。
★ 第一次 E:从词条概率走到切分占比
用五个符号把范围钉住:
| 量 | 含义 | 归一化范围 |
|---|---|---|
| p(t) | 词条概率,全语料共用 | 当前词表所有词条相加为 1 |
| w(s) | 完整切分 s 的乘积权重 | 尚未按当前文本归一化 |
| Z(x) | 文本 x 的所有合法路径权重之和 | 分母,不要求等于 1 |
| q(s∣x) | 给定 x 时,路径 s 的相对权重 | 同一文本的所有切法相加为 1 |
| C(t) | 词条 t 在全语料中的期望次数 | 次数,不是概率 |
一条路径使用了哪些词条,就把它们的概率乘起来。相同词条出现两次,也要乘两次:
w(s) = ∏ 路径中每次出现的 p(t)Z(x) = Σ 合法路径 s 的 w(s)q(s∣x) = w(s) / Z(x)套进初始的四种切分:
| 切分 | w:词条概率相乘 | q:除以同一个 Z |
|---|---|---|
a / b / c | (1/9)³ = 1/729 | 1% |
a / bc | (1/9)² = 9/729 | 9% |
ab / c | (1/9)² = 9/729 | 9% |
abc | 1/9 = 81/729 | 81% |
分母是 Z(abc) = (1 + 9 + 9 + 81) / 729 = 100/729。
四个词条概率虽然相同,每条路径相乘的项数不同,路径权重就不同。
81% 是“已知输入为 abc 后,整块切分占多少权重”;词条本身的 p(abc) 仍是 1/9。
它也不表示语料里 81% 的文本是 abc。前者在当前文本的路径之间归一化,后者属于全局词表。
同一条文本给词条贡献多少次
尚不知道唯一切法,就按照 q 分摊次数。以 a 为例,四条路径分别使用它 1、1、0、0 次:
一条 abc 对 a 的期望次数 = 0.01 × 1 + 0.09 × 1 + 0.09 × 0 + 0.81 × 0 = 0.10
12 条 abc 的贡献 = 12 × 0.10 = 1.20小数是加权统计;一条真实切分中的 Token 个数仍是整数。
若某条路径是 a / bc / a,它对 a 的贡献是 2q,不能只记录“出现过”。
同一张 p 表处理全部语料
下一种文本 ab 有自己的分母:
a | b:w = 1/81ab: w = 1/9Z(ab) = 10/81
q(a | b) = 0.1q(ab) = 0.924 条 ab 对词条 ab 贡献 24 × 0.9 = 21.6 次整个 E 步固定 p,各文本分别算 w、Z、q,最后汇总 C。 第一遍全语料统计如下:
| 词条 | 12 条 abc 的贡献 | 其余 92 条的贡献 | C(t) |
|---|---|---|---|
| a | 1.200000 | 19.896015 | 21.096015 |
| b | 0.120000 | 13.873801 | 13.993801 |
| c | 1.200000 | 11.473801 | 12.673801 |
| ab | 1.080000 | 22.444207 | 23.524207 |
| bc | 1.080000 | 28.866421 | 29.946421 |
| ca | 0 | 0.844207 | 0.844207 |
| abc | 9.720000 | 0.597786 | 10.317786 |
| bca | 0 | 0.597786 | 0.597786 |
| cab | 0 | 1.620000 | 1.620000 |
例如 ab 的“其余贡献”不是额外参数:
24 条 ab 的 21.6 + 2 条 abca 的 0.664207 + 2 条 cab 的 0.18 = 22.444207。
每一部分都来自对应文本的路径加权;abca 中包含 ab 的路径是 ab / c / a 和 ab / ca。
若 f(x) 是文本频次,Nₜ(s) 是词条 t 在路径 s 中的出现次数,完整公式就是:
C(t) = Σ文本 x f(x) × Σ路径 s q(s∣x) × Nₜ(s)总期望次数为 114.614022,可以大于样本数 104,因为一条文本可以产生多个 Token。 表中各项只在显示时舍入,计算使用完整精度。
★ M:统计怎样变成下一轮的概率
教学实现采用经典最大似然更新:
p_new(t) = C(t) / Σ词条 u C(u)
p_new(a) = 21.096015 / 114.614022 ≈ 0.184061p_new(ab) = 23.524207 / 114.614022 ≈ 0.205247p_new(bc) = 29.946421 / 114.614022 ≈ 0.261281这个归一化不是凭空规定的。E 已经固定各条路径的占比,把它们对应的完整切分对数分数求期望,
按词条汇总,M 要最大化的部分就成为 Q(p) = Σₜ C(t) ln p(t),同时满足 Σₜ p(t) = 1。
给约束加上乘子 λ:
J = Σₜ C(t) ln p(t) + λ(1 − Σₜ p(t))
∂J/∂p(t) = C(t)/p(t) − λ = 0 → p(t) = C(t)/λ
Σₜ p(t) = 1 → λ = Σₜ C(t) → p_new(t) = C(t)/Σᵤ C(u)于是下一次 E 有了新表。先将 C 清零,再用新 p 重算同一批语料的所有路径。
不能处理完 abc 就先改 p,让接下来的 ab 使用另一版参数。
| abc 的切法 | 第一次 E 的 q | 第二次 E 的 q |
|---|---|---|
| a / b / c | 1.00% | 1.52% |
| a / bc | 9.00% | 29.45% |
| ab / c | 9.00% | 13.90% |
| abc | 81.00% | 55.13% |
例如第二次的 a / bc:
w ≈ 0.1840613772 × 0.2612806017 = 0.0480916674,
除以新的 Z(abc) ≈ 0.1632945529,得到约 29.4509%。
文本没变,其他文本对共享参数的贡献也改变了它的切分分布。
删词要看失去它的代价
EM 更新概率,词表缩减还需要另一步。本实验先做两次 E→M,然后逐个暂时禁用可删除词条, 比较全语料的损失增量:
L = −Σ文本 x f(x) ln Z(x)Δₜ = L(暂时禁用 t) − L(原模型)试删时冻结其他词条的分数,不重新训练、不提前归一化。 它衡量从原路径图拿走相关边后,丢失多少路径权重。 此时剩余值仅作为固定权重使用;正式选定并删除一项后,再重新归一化。
| 第一轮候选 | 当前 p | 禁用后的 Δ |
|---|---|---|
| bca | 0.00049857 | 0.006503 |
| ca | 0.00125080 | 0.026424 |
| cab | 0.00596773 | 0.417846 |
| abc | 0.06476740 | 7.776057 |
| ab | 0.21033848 | 61.592734 |
| bc | 0.29291811 | 112.035842 |
bca 只影响两条 abca,移除的路径是 a / bca。
它的分母从约 0.0321886861 变成 0.0320842005,
所以损失增量是 2 ln(Z_before / Z_after) ≈ 0.00650263,本轮最小。
这次最小 p 恰好对应最小 Δ,不能把这个结果当成删词规则。
另造一张概率表:p(a)=p(b)=0.00001,p(ab)=0.001,p(c)=p(d)=0.4,p(cd)=0.01,p(x)=0.18898,
总和为 1。语料只有一条 ab 和一条 cd:
| 候选 | 原 Z | 禁用后 Z | 损失增量 |
|---|---|---|---|
| ab,p=0.001 | 0.001 + 10⁻¹⁰ | 10⁻¹⁰ | 约 16.1181 |
| cd,p=0.01 | 0.01 + 0.16 | 0.16 | 约 0.0606 |
ab 概率更低,但替代路径 a / b 极差;cd 有很好的替代路径。
删除代价同时取决于文本频次、原有路径和替代路径。
基础字符只有自身可用且没有回退时,删掉会让某些文本无法切分,所以本例保护 a、b、c。
完整训练共四轮:
| 轮次 | 进入时词条数 | 操作 | 结果 |
|---|---|---|---|
| 1 | 9 | 两次 E→M,删 bca | 8 项 |
| 2 | 8 | 两次 E→M,删 ca | 7 项 |
| 3 | 7 | 两次 E→M,删 cab | 6 项 |
| 4 | 6 | 最终两次 E→M | 固定模型 |
8 次更新、3 次删词是教学配置,达到目标规模不表示概率完全收敛。
SentencePiece 0.2.1 的官方 Trainer 在 M 步中还有低期望频次过滤、Digamma 更新,剪枝也使用自己的近似估计;
这里的 C/总数 和冻结分数逐项试删,用于把计算展开。
官方实现位置是 unigram_model_trainer.cc 的 RunMStep / PruneSentencePieces。
固定下来的是词条、分数与 ID
八次更新后得到:
| ID | 词条 | p | ln(p) |
|---|---|---|---|
| 1 | a | 0.23532266 | −1.446798 |
| 2 | b | 0.09627307 | −2.340567 |
| 3 | c | 0.11405142 | −2.171106 |
| 4 | ab | 0.21296613 | −1.546622 |
| 5 | bc | 0.31797172 | −1.145793 |
| 6 | abc | 0.02341499 | −3.754379 |
另设 <unk> 的 ID 为 0。编号按教学代码的保留顺序分配,不代表概率排名。
真实模型还要保存词条类型、规范化等配置。
本表的分数直接由 ln(p) 得到。对数把路径中的概率乘法变成加法,而且严格单调递增,
所以最大乘积与最大对数分数对应同一路径。它也避免长乘积下溢:
0.01 ** 200 在 Python 浮点数里是 0.0,而 200 × ln(0.01) ≈ −921.034 仍可表示。
负分数仍是越大越好。
现在 p(a)p(bc) ≈ 0.074826,超过 p(abc) ≈ 0.023415,所以同一个 abc 的最高分切法变成 a / bc。
编码只读取这张已经固定的表;新输入不会再触发 E、M 或删词。
动画训练章有 60 步,完整保留四轮训练。编码默认接着使用这份训练结果处理 abcabc;
另两个例子使用手工概率表作对照,其数字与训练结果分开。
★ 最后一个 c:怎样找到整段文本的最高分
固定词表后,先在输入的每个位置匹配所有可用词条。
位置是字符之间的边界;从 j 到 i 的一条边表示使用词条 text[j:i]。
一条从 0 连到终点的路径,才是完整切分。
abcabc 有 16 条合法完整路径。动画枚举它们是为了验算;
实际找最高分时,可以用 Viterbi 动态规划复用前缀结果:
best[0] = 0其余 best = −∞
best[i] = max { best[j] + ln p(text[j:i]) } 遍历所有恰好在 i 结束的合法词条best[i] 保存完整覆盖前 i 个字符的最高对数分数;
prev[i] 保存获胜路线来自哪个边界。算完所有位置后,从终点沿 prev 回溯。
为什么一个边界只存最高分就够了? 两条前缀到达同一边界后,能接的词条相同;Unigram 后续评分又不依赖前缀的切法。 接上相同后半段只会增加相同分数,较差前缀无法反超。 这比较的是完整前缀总分,而不是每次挑眼前概率最大的词条。
计算最后一个 c 之前,三个前缀结果已经保留:
| 位置 | 前缀最佳切法 | best |
|---|---|---|
| 3 | a / bc | −2.5926 |
| 4 | a / bc / a | −4.0394 |
| 5 | a / bc / ab | −4.1392 |
在位置 6 结束的词条只有 c、bc、abc:
最后用 c: best[5] + ln p(c) ≈ −6.3103最后用 bc: best[4] + ln p(bc) ≈ −5.1852 ← 最大最后用 abc: best[3] + ln p(abc) ≈ −6.3470因此 best[6] ≈ −5.1852,prev[6] = 4。沿记录回溯:
6 ← 4 ← 3 ← 1 ← 0 bc a bc a
反转:a | bc | a | bc查表:[1, 5, 1, 5]“前面的 b 已经处理完”只表示 best[5] 算过了,不表示最终路线必须经过 5。
获胜路径可以从 4 直接到 6。这里保留了不同边界的最佳前缀,最后才回溯出 Token 序列。
不存在的 abcabc 整词不会作为一条边参加比较,但整段文本仍能由已有词条完整覆盖。
训练 E 步需要全部路径的加权贡献,编码只需要单条最高分路径。 高效 E 步可用前向 / 后向算法汇总,涉及 log-sum-exp;不能把 Viterbi 的 max 直接拿来替代期望统计。
解码与字节回退:四个 ID 还原一个字符
普通解码沿着 ID 查表即可:
[1, 5, 1, 5] → ['a', 'bc', 'a', 'bc'] → 'abcabc'ID 已经指定词条,无需重跑 Viterbi,也无需重新评分。 一般模型还要处理空格表示符、虚拟前缀等配置;规范化如果损失了信息,解码无法凭空找回原文。 这里的 ASCII 教学模型使用 identity,所以可以原样往返。
字节回退另外用 SentencePiece 0.2.1 的真实 Unigram 模型验证:
小语料包含“你好”,不包含 😊,训练时设置 byte_fallback=True。
其 ID 属于这个独立模型,不能和上面的 1~6 混用。
原文:你好😊规范化:▁你好😊词条:▁、你、好、<0xF0>、<0x9F>、<0x98>、<0x8A>ID:[259, 267, 268, 243, 162, 155, 141]▁ 是 U+2581 的空格表示符;此处句首标记来自虚拟前缀配置。
这个模型预先保留了 256 个 BYTE 项,覆盖字节值 0~255。
未知字符不用临时创造新编号,也不在回退时再执行一遍 BPE。
| 😊 的 UTF-8 字节 | BYTE 词条 | 本模型 ID |
|---|---|---|
| 240 | <0xF0> | 243 |
| 159 | <0x9F> | 162 |
| 152 | <0x98> | 155 |
| 138 | <0x8A> | 141 |
本模型的 0、1、2 分别是 <unk>、<s>、</s>,BYTE 项从 3 开始,因此恰好 ID = 字节值 + 3。
这是当前词表的编号安排;通用操作仍是查表。
解码先把连续 BYTE 项恢复成原始字节:
F0 9F 98 8A11110000 10011111 10011000 10001010首字节 11110xxx 表示总共四字节,后面三个 10xxxxxx 是续字节;
字符边界由 UTF-8 识别,ID 不需要额外声明“四个一组”。
完整四个 ID 解出 😊;本次库实测,只解后面三个 ID 得到 ���。
显示时合成一个字符,模型处理过的仍是四个 Token 位置。
实验验证
按上文的语料与配置训练,每次 M 更新后概率和为 1,固定词表时损失不增加;
最终删除顺序为 bca → ca → cab。
编码另做穷举对照:由 a、b、c 组成、长度为 1~5 的字符串共有 363 个,
分别用训练词表和动画中的手工对照词表编码,共检查 726 组“词表 + 输入”。
完整枚举、Viterbi 动态规划与 Hugging Face Unigram 得到的最高分一致;
并列最优时比较分数与重建文本,不要求各实现选择同一条路径。
字节回退使用 SentencePiece 0.2.1 实测:“你好😊”编码为上述七个 ID, 完整序列解码后还原原文。
从初始的 abc 整块占 81%,到最终的 a / bc,变化发生在训练得到的共享概率表上。
表固定后,编码沿它寻找完整路径,解码沿编号取回内容。
把这三个时刻分清,概率、切分和 ID 就各自有了明确的职责。