bobofamilyNIU CHUN
32/36

同一个 abc,为什么换了切法:Unigram 的训练与编码

2026-10-1014 分钟—阅读

同一个 abc,初始模型给「整块 abc」这条切分分配了 81% 的占比。 训练结束后,最高分切法却变成了 a | bc。输入没有换,变的是全语料共用的那张词条概率表。

这比“Unigram 使用概率分词”更具体:概率从哪些统计中来,统计又怎样依赖尚未确定的切法? 等表训练好了,编码还要解决第二个问题:如何用它找到整段文本的最佳切分。

此前的 BPE 从基础单位出发,逐步增加合并规则;这里从较大的候选集合开始,更新分数并逐步删词。 确定性编码也换了依据:BPE 按已有合并优先级执行,Unigram 比较完整路径的总分。

“Unigram”指一个词条使用自己的概率,不依赖前一个词条;词条本身可以包含多个字符。 它训练的是分词器的小概率模型,与 Transformer 的下一 Token 预测模型分开。 原论文 §3.2 给出了这套路径评分与词表训练框架。 SentencePiece 则是支持 Unigram、BPE 等算法的工具库,使用它并不自动意味着选择了 Unigram。

同一份教学实验:104 条文本,9 个候选,经过 EM 与删词留下 6 项,再把固定模型交给编码

先把语料、词表和切分分开

本实验的语料是预先给定的。相同文本合并为频次,共 8 种文本、104 条样本:

文本abcabbcabcabcacab
次数122432168822

使用 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 | c
a | bc
ab | c
abc

因此 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/7291%
a / bc(1/9)² = 9/7299%
ab / c(1/9)² = 9/7299%
abc1/9 = 81/72981%

分母是 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/81
ab: w = 1/9
Z(ab) = 10/81
q(a | b) = 0.1
q(ab) = 0.9
24 条 ab 对词条 ab 贡献 24 × 0.9 = 21.6 次

整个 E 步固定 p,各文本分别算 w、Z、q,最后汇总 C。 第一遍全语料统计如下:

词条12 条 abc 的贡献其余 92 条的贡献C(t)
a1.20000019.89601521.096015
b0.12000013.87380113.993801
c1.20000011.47380112.673801
ab1.08000022.44420723.524207
bc1.08000028.86642129.946421
ca00.8442070.844207
abc9.7200000.59778610.317786
bca00.5977860.597786
cab01.6200001.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.184061
p_new(ab) = 23.524207 / 114.614022 ≈ 0.205247
p_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 / c1.00%1.52%
a / bc9.00%29.45%
ab / c9.00%13.90%
abc81.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禁用后的 Δ
bca0.000498570.006503
ca0.001250800.026424
cab0.005967730.417846
abc0.064767407.776057
ab0.2103384861.592734
bc0.29291811112.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.0010.001 + 10⁻¹⁰10⁻¹⁰约 16.1181
cd,p=0.010.01 + 0.160.16约 0.0606

ab 概率更低,但替代路径 a / b 极差;cd 有很好的替代路径。 删除代价同时取决于文本频次、原有路径和替代路径。 基础字符只有自身可用且没有回退时,删掉会让某些文本无法切分,所以本例保护 a、b、c。

完整训练共四轮:

轮次进入时词条数操作结果
19两次 E→M,删 bca8 项
28两次 E→M,删 ca7 项
37两次 E→M,删 cab6 项
46最终两次 E→M固定模型

8 次更新、3 次删词是教学配置,达到目标规模不表示概率完全收敛。 SentencePiece 0.2.1 的官方 Trainer 在 M 步中还有低期望频次过滤、Digamma 更新,剪枝也使用自己的近似估计; 这里的 C/总数 和冻结分数逐项试删,用于把计算展开。 官方实现位置是 unigram_model_trainer.cc 的 RunMStep / PruneSentencePieces。


固定下来的是词条、分数与 ID

八次更新后得到:

ID词条pln(p)
1a0.23532266−1.446798
2b0.09627307−2.340567
3c0.11405142−2.171106
4ab0.21296613−1.546622
5bc0.31797172−1.145793
6abc0.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 或删词。

Unigram 全流程:训练词表 → 编码 → 解码 → 字节回退新窗口打开 ↗

动画训练章有 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 后续评分又不依赖前缀的切法。 接上相同后半段只会增加相同分数,较差前缀无法反超。 这比较的是完整前缀总分,而不是每次挑眼前概率最大的词条。

abcabc 最后一个 c:比较来自位置 5、4、3 的三条候选,获胜路径从 4 经 bc 到 6

计算最后一个 c 之前,三个前缀结果已经保留:

位置前缀最佳切法best
3a / bc−2.5926
4a / bc / a−4.0394
5a / 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 8A
11110000 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 就各自有了明确的职责。