🤖 第11章 序列数据的分类
📄 一句话概括

线性链条件随机场,14 个自由权重:8 个发射、4 个转移、2 个初始。 梯度就是「经验计数 减 期望计数」,期望值用前向后向算。 固定步长 0.5 爬 300 轮,对数似然从 负 1386.29 到 负 316.10。但它不是一路上升的: 第 2 轮冲到 负 354.03,第 5 轮被压回 负 424.24,第 25 轮才落到 负 316.34。 学到的转移权重读成概率是 0.893/0.107,数据里的经验值是 0.913/0.087; 解码准确率 89.10%,比完整 HMM 的 88.70% 高 0.40 个点。

📝 本节和前面几节的关系

11.1 的 HMM 有两个毛病:要给观测建模,参数还被「每行和为 1」绑死。 这一节把它们一起扔掉:只给 P(y 给 x) 建模。 扔掉之后权重可以自由取值,于是「加特征」变成了一件随便的事。 代价是归一化常数 Z(x) 得自己算,而它要对所有可能的标签序列求和——这一步没有公式, 只能动态规划。 这一节的核心就是把「算 Z」和「用 Z 求梯度」这两件事写清楚。

一、只要 P(y 给 x)

条件随机场给一条标签序列打分,分数是「一元项之和 加 转移项之和」: 每个位置有一项「这个位置取某个标签、观测是这个符号」的分数; 每对相邻位置有一项「从某个标签转到某个标签」的分数。 分数越高,这条标签序列越可能。 但分数不是概率,得归一化。 概率等于 exp(分数) 除以 Z(x),而 Z(x) 是对「所有可能的标签序列」把 exp(分数) 加起来。 10 个位置、2 个标签,一共 2 的 10 次方等于 1024 条,还数得过来;位置一多就指数爆炸。 所以 Z(x) 只能靠动态规划算,也就是前向算法: 从左往右,每一步只保留「到这个位置、以某个标签结尾」的所有路径的分数和。 因为下一步只依赖上一步的这两数,所以整个和可以在「位置数 乘 状态数平方」的时间里算完, 而不是「状态数的位置数次方」。 这个技巧和 11.1 里 HMM 的前向算法是同一个,只是分数从「对数概率」换成了「权重之和」。 数学形式几乎一样,因为一阶马尔可夫链的对数概率本来就是一个线性打分函数。 真正的区别在解释:HMM 的分数必须是一组对数概率(所以每行和为 1),CRF 的权重可以是任意实数。 这个区别带来的自由度很实在。 想要「前一个位置的标签、当前观测、以及上上个标签」三个东西的联合特征? 直接加一项就行,不用管它会不会破坏归一化——反正有 Z(x) 兜着。 HMM 做不到这件事:它的每一项都必须是某个条件概率的对数,加一项就得重新设计生成过程。

二、梯度是计数之差

训练目标是让训练数据的条件对数似然最大。 对某一个权重求导,结果是一个极干净的式子: 「这个特征在训练数据里出现了几次」减去「当前模型认为它该出现几次」。 前一半叫经验计数,数一遍就有。 后一半叫期望计数,得算每个位置、每对相邻位置的后验边际——这正是前向后向算法干的事: 前向给出「到这儿为止」,后向给出「从这儿往后」,两者一乘就是边际。 为什么是这个形式?可以这样想: 如果只把经验计数往上涨,模型会把训练数据里每一次转移的分数都推到无穷大—— 那等于把训练集背下来,条件概率退化成「训练集里的那条序列概率 1,别的一律 0」。 减掉的那一项是「当前模型认为该出现几次」,它起的是刹车作用。 两者相等时梯度为零:模型眼里的世界和数据里的世界对齐了。 这也解释了为什么这个目标函数是凹的、没有局部最优。 条件对数似然是「线性函数 减 log Z」,而 log Z 是 log-sum-exp 这种凸函数,线性减凸还是凹。 凹函数在凸集上只有一个最高点,所以梯度上升不会卡在局部最优—— 但它仍然可能因为步长太大而在最优点附近来回跳,这一点实测里看得很清楚。

三、实测:过冲之后的收敛

轮次 训练对数似然 梯度最大值(按序列数平均)
1 负 1386.29 1.9550
2 负 354.03 0.4269
3 负 355.74 0.6991
4 负 379.59 1.0219
5 负 424.24 1.4210
25 负 316.34 0.0080
50 负 316.16 0.0024
100 负 316.11 0.0010
200 负 316.10 0.0002
300 负 316.10 0.0000
第 1 轮到第 2 轮跳了 1032 nat,然后第 3、4、5 轮反而退了回去,
一直到第 25 轮才落到 负 316.34。
这不是实现错误,是步长太大的典型症状:过冲。
梯度最大值那一列也在同一段时间里从 0.4269 涨到 1.4210,说明它在最优点附近来回跳。
凹性保证「没有局部最优」,但不保证「每一步都上升」。
它只保证「走得足够小的时候上升」。
步长 0.5 对这个问题的曲率来说太大了——这里的曲率藏在 Z(x) 里,而 Z 又会随权重变化,
所以最优点附近的「安全步长」比开头小得多。
实践里会用线搜索(每步试几个长度、取最好的那个)或者 L-BFGS(用历史梯度估曲率)。
这里故意用固定步长,就是为了让过冲露出来——很多教程只画一条漂亮的单调曲线,
那是调过步长的结果,不是算法的默认行为。
再看收敛点:第 25 轮已经到 负 316.34,后面 275 轮只再挤出 0.24 nat。
说「爬了 300 轮」有点冤,真正有效的是前 25 轮。
剩下的 275 轮是在把梯度从 0.0080 磨到 0.0000——对结果没什么影响,只是好看。

四、学到的权重和数出来的概率

从哪到哪 权重 读成概率 数据里的经验值
状态 0 到 0 +1.148 0.893 0.913
状态 0 到 1 负 0.971 0.107 0.087
状态 1 到 0 负 1.210 0.096 0.090
状态 1 到 1 +1.034 0.904 0.910
「读成概率」是对每一行做一次 softmax,也就是把两个权重同时减去较大的那个再取指数归一化。
它给出的是「假如只看转移这一项,模型认为从状态 0 出发有 89.3% 的概率留在原地」。
0.893 对 0.913,非常接近,但不完全相等。
不相等是对的,而且值得说清楚为什么。
经验值只数了「标签之间的搭配」,它不知道观测;而 CRF 优化的是 P(y 给 x),它知道观测。
当观测几乎决定了标签时,两者会趋同;
观测模糊时,CRF 会把转移往「更保守」的方向推——因为在拿不准的地方,靠一致性兜底更划算。
这也解释了为什么模型学到的对角权重是 +1.148 和 +1.034,而不是完全对称的两个数。
两个状态的发射分布不一样(一个偏向符号 0,一个偏向符号 3),
所以它们在序列里的位置分布也不一样,能从观测里拿到的信息量不一样。
CRF 把这些差别都吸收进了权重。
解码准确率 89.10%,比 11.1 那个完整 HMM 的 88.70% 高 0.40 个点。
这点差距不值得大书特书——数据本来就是按 HMM 造的,生成模型有主场优势,
能追平已经说明条件模型没丢东西。
真正值得记的是:CRF 完全不知道数据是按 HMM 生成的,它只看见「特征」和「标签」,
照样学到了同样的转移结构。
这就是条件模型的价值——它不要求你对世界的生成过程有正确的假设。

五、参数与能力的账

一个常见的误会:CRF 比 HMM 省参数。实测正好相反。 CRF 有 14 个自由权重(8 加 4 加 2),HMM 只有 9 个自由参数(初始 1 加转移 2 加发射 6)。 CRF 更多,因为它不受「每行和为 1」的约束——归一化交给 Z(x) 去做,权重可以随便取。 所以它省的从来不是参数,是「给观测建模」这件事。 HMM 必须回答「这段观测怎么产生的」,CRF 根本不问;它只问「给定这段观测,标签该是什么」。 扔掉 P(x) 之后,观测就从「要建模的对象」变成了「特征的来源」—— 你可以从观测里抽出任意多个重叠的、互相相关的特征,不用管它们能不能凑成一个概率分布。 这个自由度在实践中非常值钱。 做中文分词时,特征可以是「当前字」「前一个字」「后一个字」,也可以是「当前字是否在词典里」。 还可以是「前两个字拼起来是否成词」这种长距离特征。 这些特征高度重叠,在生成模型里几乎没法同时出现,在 CRF 里就是加几列。 代价在计算上。 HMM 的前向算法只做加法和 log-sum-exp,代价是「位置数 乘 状态数平方」; CRF 的训练每一轮都要跑一次前向、一次后向,再把期望计数累加一遍,代价是同一个量级乘上特征数。 所以序列一长、特征一多, CRF 的训练会明显变慢——这也是为什么大规模场景里常常用它的近似版本或者干脆换成神经网络。

六、动手 Demo:条件随机场的学习:300 轮梯度上升,训练对数似然从 负 1386.29 升到 负 316.10

还是 11.1 那份数据(200 条训练序列、每条 10 个位置)。 建一个线性链 CRF:8 个发射权重、4 个转移权重、2 个初始权重。 每一轮算条件对数似然和梯度(经验计数减期望计数,期望值用前向后向), 按序列数平均后乘固定步长 0.5 更新。跑 300 轮,记录若干轮的似然和梯度最大值; 最后把转移权重读成概率,和数据里的经验值对照,并用逐位置后验最大解码,和 HMM 比准确率。

for it in range(1, iters + 1):
    U = W_emit[:, X].transpose(1, 2, 0).copy()   # 每个位置的一元分数
    U[:, 0, :] += W_start
    al, be = forward(U, W_trans), backward(U, W_trans)
    LZ = logsumexp(al[:, -1, :])                 # log Z(x)
    g = np.exp(al + be - logsumexp(al + be)[:, :, None])   # 逐位置边际
    xi = pair_marginals(al, be, U, W_trans, LZ)  # 相邻位置的联合边际
    ge = emp_emit - expect_emit(g, X)            # 经验减期望
    gt = emp_trans - xi.sum(axis=(0, 1))
    W_emit += lr * ge / n
    W_trans += lr * gt / n

实跑输出:

--------------------------------------------------------------------
条件随机场的学习:300 轮梯度上升,训练对数似然从 -1386.29 升到 -316.10
--------------------------------------------------------------------
  模型                     线性链 CRF:分数等于 一元项加转移项,概率等于 exp(分数) 除以 Z(x)
  特征                     8 个发射(2 类乘 4 符号)加 4 个转移加 2 个初始,共 14 个权重
  梯度                     经验特征计数 减 期望特征计数,期望值用前向后向算

  训练轨迹(对数似然是 200 条序列的总和,越大越好):

  轮次   训练对数似然   梯度最大值(已按序列数平均)
  -------------------------------------------------
     1       -1386.29                         1.9550
     2        -354.03                         0.4269
     3        -355.74                         0.6991
     4        -379.59                         1.0219
     5        -424.24                         1.4210
    25        -316.34                         0.0080
    50        -316.16                         0.0024
    75        -316.13                         0.0015
   100        -316.11                         0.0010
   125        -316.11                         0.0007
   150        -316.10                         0.0004
   175        -316.10                         0.0003
   200        -316.10                         0.0002
   225        -316.10                         0.0001
   250        -316.10                         0.0001
   275        -316.10                         0.0001
   300        -316.10                         0.0000

  **对数似然从 -1386.29 升到 -316.10,最后梯度掉到 0.0000。**
  **但它不是一路上升的:第 2 轮冲到 -354.03,第 3、4、5 轮反而被压回去到 -424.24,直到第 25 轮才落到 -316.34。**
  **这是步长太大的典型症状——过冲。梯度最大值也跟着从 0.4269 涨到 1.4210,说明那几轮它在最优点附近来回跳。**
  **这个目标函数是凹的,所以「没有局部最优」这一点是保证的;但凹性不保证每一步都上升,它只保证「走得足够小」时上升。**
  **实践里会用线搜索或者 L-BFGS 来定步长,这里故意用固定步长,就是为了让过冲露出来。**
  **另外注意收敛点:第 25 轮已经到 -316.34,后面 275 轮只再挤出 0.24。**
  **说「爬了 300 轮」有点冤,实际有效的是前 25 轮左右。**

  **为什么要「减期望计数」?**
  **如果只把经验计数往上涨,模型会把训练数据里每一次转移的分数都推到无穷大——那等于把训练集背下来。**
  **减掉的那一项是「当前模型认为这些特征该出现几次」。两者相等时梯度为零,也就是「模型眼里的世界」和「数据里的世界」对齐了。**
  学到的转移权重,以及把它单独读成概率的样子:

  从哪到哪        权重   隐式转移概率   数据里的经验概率
  -----------------------------------------------------
  状态 0 到 0   +1.148          0.893              0.913
  状态 0 到 1   -0.971          0.107              0.087
  状态 1 到 0   -1.210          0.096              0.090
  状态 1 到 1   +1.034          0.904              0.910

  **隐式转移概率 0.893 对经验概率 0.913——两者非常接近,但不完全相等。**
  **不相等是对的:CRF 优化的是条件似然 P(y 给 x),而经验概率只数了标签之间的搭配,没看观测。**
  **当观测几乎决定了标签时,两者会趋同;观测模糊时,CRF 会主动把转移往「更保守」的方向推。**

  **参数个数要说清楚:CRF 有 14 个自由权重,HMM 只有 9 个自由参数(初始 1 加转移 2 加发射 6)。**
  **CRF 反而更多,因为它不受「每行加起来等于 1」的约束——归一化交给 Z(x) 去做。**
  **所以「CRF 更省参数」这个说法是错的;它省的是「给观测建模」这件事,也就是把 P(x) 整个扔掉了。**

  用它解码(取逐位置后验最大),和上一节的做法比:

  做法               逐位置准确率   整条全对
  -----------------------------------------
  转移表抹平的 HMM         80.20%     11.00%
  完整 HMM                 88.70%     48.00%
  CRF                      89.10%     48.00%

  **CRF 的 89.10% 略高于完整 HMM 的 88.70%。**
  **这点差距不值得大书特书——数据本来就是按 HMM 造的,生成模型有「主场地优势」。**
  **真正值得记的是:CRF 完全不知道数据是按 HMM 生成的,它只看见特征和标签,照样学到了同样的转移结构。这才是它的价值。**

整个算法没有一行是「新」的:前向、后向、边际、计数,全是 11.1 里那套东西。 唯一的区别是分数从「对数概率」换成了「权重之和」——形式上完全一样, 因为两者都是「一元项加转移项」的线性打分。 这也是为什么学会一个 HMM 之后,CRF 几乎不用重新学。 W_emit[:, X].transpose(1, 2, 0) 这个下标顺序要小心。 W_emit[:, X] 的形状是「状态 乘 序列数 乘 位置数」, 而后面所有函数都按「序列数 乘 位置数 乘 状态」来用。 这个 transpose 写错不会报错,只会让所有分数张冠李戴,然后似然一路往下掉、 你还以为是步长问题。 自查办法:拿一条序列、一个位置,手算 U[i, t, y] 应该等于 W_emit[y, X[i, t]]。 .copy() 不能省。 fancy indexing 出来的数组是副本,但 transpose 出来的是视图, 紧接着的 U[:, 0, :] += W_start 会原地改它——改视图就是改那个副本,这一次没事。 可如果哪天 W_emit[:, X] 被换成别的写法,这个原地加法就可能回过头污染权重,然后梯度全乱。 加一个 .copy() 是买保险,代价可以忽略。 logsumexp 必须自己写或者用数值稳定的版本,不能直接 np.log(np.sum(np.exp(x)))。 权重一大,exp 立刻溢出成 inf,log(inf) 还是 inf,整个似然变 nan。 标准写法是先减去最大值再取指数,最后把最大值加回去:m + log(sum(exp(x - m)))。 这个式子看起来只是平移,但它把「可能溢出」变成了「最大项恰好是 1」,永远不会爆。 梯度要按序列数平均(/ n),否则步长会随数据量变。 我第一版没除,120 条序列时梯度是几百的量级,配 0.5 的步长直接飞到无穷大, 似然一路掉到 负 二十万。 除以 n 之后梯度是「每条序列平均贡献几次」的量级,步长 0.5 就合适了。 这也是为什么报「步长」的时候必须同时报「梯度怎么归一化」,否则那个数没有意义。 注意这里没有加正则化。 数据量足够(2000 个位置)时,条件似然本身就能把权重按住; 但数据一少,权重会往正负十几跑,那就是过拟合。 第 4 章的 ℓ2 惩罚在这里照样管用:目标函数后面加一项「lam 乘 权重平方和」, 梯度减掉「2 乘 lam 乘 权重」就行,一行代码。 要说明:步长 0.5 是试出来的,不是算出来的。 用 2.0 也能跑完但收敛点更差(实测 负 466.12 对 负 316.10),用 0.5 才压到梯度接近零。 真实项目里这个数要么靠线搜索定,要么靠看前几轮的似然曲线回头调。

30 秒实验

  1. 把步长改成 0.05 和 2.0,各跑 300 轮。过冲消失了吗?两者收敛到同一个点吗?差多少?
  2. 加 ℓ2 惩罚,目标函数减掉 lam 乘权重平方和,lam 取 1、10、100。 权重会缩多少?解码准确率掉多少?哪个 lam 最好?
  3. 把训练序列从 200 条减到 50 条,重跑。 学到的转移权重还接近 0.913 吗?不加正则化会不会过拟合?

常见误区

  1. 「CRF 比 HMM 省参数」。 实测反了:CRF 14 个自由权重,HMM 9 个自由参数。 CRF 更多,因为它不受「每行和为 1」的约束,归一化交给 Z(x)。 它省掉的是「给观测 P(x) 建模」这件麻烦事,不是参数个数。
  2. 「梯度上升每步都会让目标函数变大」。 实测第 2 轮到第 5 轮,似然从 负 354.03 掉到 负 424.24。 步长太大就会过冲,凹性只保证「走得足够小的时候上升」。 判断有没有过冲,就看似然曲线:出现来回就说明步长该调了。
  3. 「Z(x) 直接对所有标签序列求和就行」。 10 个位置是 2 的 10 次方等于 1024 条,还数得动;30 个位置就是 10 亿条。 所以必须用前向算法做动态规划,把「状态数的位置数次方」压成「位置数 乘 状态数平方」。 这一步不是优化技巧,是能不能算的区别。
  4. 「CRF 只能处理线性链」。 理论上任意图都行,只要你能算出那个图上的 Z。 问题在于一般图上的 Z 是「树宽」的指数级代价,很多图根本算不动。 所以工程上 99% 的 CRF 都是线性链,或者是很窄的树——这不是理论限制,是算力限制。
自测三问(点开对答案) 1. 为什么梯度是「经验计数减期望计数」? —— **因为对数似然对权重的导数就是这个差。** **线性打分函数求导,出来的是「这个特征被用了几次」;** **对 exp 归一化那一项求导,出来的是「模型认为它该被用几次」的期望。** **直觉上:经验计数想把权重推到「训练集的样子」,期望计数把它往回拉,** **两者相等时模型和数据对齐。** **只加不减,就等于让模型把训练集背下来。** 2. 为什么 Z(x) 要用前向算法算,不能直接求和? —— **因为标签序列是「状态数的位置数次方」条。** **10 个位置、2 个状态是 1024,30 个位置就是 10 亿,50 个位置就超过宇宙原子数了。** **前向算法利用「一阶马尔可夫」这个结构:到第 t 个位置、以某个标签结尾的分数和,** **只依赖上一步的那两个数。** **所以代价从指数级降到「位置数 乘 状态数平方」。** **这一步是能不能算的区别,不是快慢的区别。** 3. 为什么学到的转移权重读成概率是 0.893,而不是数据里的 0.913? —— **因为两者估的不是同一个东西。** **0.913 是「只数标签」的经验频率,它完全不管观测;** **CRF 优化的是 P(y 给 x),它会把观测提供的信息也算进去。** **在观测模糊的位置,模型知道「光看观测靠不住」,于是把转移的权重调得相对更重、更保守;** **在观测清晰的位置则相反。** **这些权衡揉在一起,最后落到 0.893 这个数上。** **所以不能拿这两个数直接对错——它们是两个不同问题的答案。**

权重学出来了,接下来是最后一件事:给定一条观测序列,怎么把标签序列找出来。 这件事有三种做法,而它们在同一份数据上的表现很奇怪。 没有一种全面占优:一种的逐位置准确率更高,另一种的整条全对率更高。 搞清这个取舍,比记住「用 Viterbi」有用得多。

相关笔记