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

三种解码:逐位置取一元最大、逐位置取后验边际最大、Viterbi 整条最优。 逐位置准确率 80.20%/89.10%/88.00%,整条全对 11.00%/48.00%/49.00%。 Viterbi 的逐位置准确率反而比后验边际低 1.10 个点, 整条全对只高 1.00 个点——它是在拿局部换整体,不是全面更强。 孤立单点翻转 127 个 → 7 个 → 0 个。 挑一条序列逐位置看:逐位置错 2 个、后验边际错 1 个、Viterbi 错 2 个。

📝 本节和前面几节的关系

11.2 把权重学出来了,但「学」只是中间步骤。 真正要用的时候,手上是一条观测序列,要交出一条标签序列。 这一步叫解码,它和训练是两件不同的事——训练要算所有的和,解码要找一条最大的路径。 这一节把三种解码摆在一起比,会看到一个不太符合教科书印象的结果:Viterbi 不是全面赢家。 弄清楚它赢在哪、输在哪,比记住它的名字更有用。

一、三种解码

第一种:逐位置取一元分数最大。 完全不看转移,每个位置挑自己分数最高的那个标签。 这是 11.1 里那个「抹平转移」的做法,代价是它不知道邻居在干什么。 第二种:逐位置取后验边际最大。 先跑一遍前向、一遍后向,算出每个位置的后验概率,再各取一次最大。 这个做法把转移算进去了(因为它用的是边际,而边际来自整条链), 但它仍然是「每个位置单独决定」——做出决定时不考虑「我这个选择和邻居的选择合不合得来」。 第三种:Viterbi。 它在整条链上找「总分最高」的那一条路径。 做法和 11.2 的前向算法几乎一样,只是把「求和」换成「取最大」, 并且沿途记下「这个最优是从哪个前驱来的」,最后回溯一遍就得到整条路径。 代价是「位置数 乘 状态数平方」,和前向算法同阶。 三者的差别可以用一句话概括: 第一种只看自己;第二种看了别人但各自拍板;第三种要求整条一起拍板。

二、实测:没有全面占优

解码方式 逐位置准确率 整条全对 孤立单点翻转 粘性
逐位置取一元最大 80.20% 11.00% 127 0.6567
逐位置取后验边际最大 89.10% 48.00% 7 0.9044
Viterbi 整条最优 88.00% 49.00% 0 0.9178
先看最容易误读的一处:Viterbi 的逐位置准确率比后验边际低 1.10 个点,整条全对只高 1.00 个点。
换句话说,它没有全面赢——它是在拿「每个位置都尽量对」换「整条尽量对」。
要哪个,取决于下游拿结果去做什么。
为什么会这样?因为两个目标根本不是一回事。
后验边际最大,优化的是「每个位置的正确率之和」;Viterbi 优化的是「整条路径的概率」。
在一条 10 个位置的序列上,「各位正确率之和」和「整条概率」可以给出不同的答案。
这不是矛盾,是两个不同的优化目标。
「孤立单点翻转」这一列最能说明差别:127 个、7 个、0 个。
真实标签序列里「夹在中间的一小段异类」几乎不出现(真模型的标签一变就是一整段),
所以这些孤点几乎必然是把观测噪声当成了信号。
Viterbi 一个都没有,因为它一旦要在中间插一个孤点,就得付两次转移的代价。
粘性那一列也印证了同一件事:0.6567、0.9044、0.9178,而真值是 0.9033。
逐位置解码严重低估了粘性(它根本不知道有这回事);后验边际已经很接近;
Viterbi 略微超过真值——它比真实标签还要「平滑」一点。
「比真的还平滑」正是它的偏置方向,也是它偶尔会抹掉真转折的原因。

三、挑一条序列看个明白

从测试集里挑一条出来,把四种序列并排打出来:

观测       3313220310
真标签     1111111100
逐位置     1101110100
后验边际   1111110100
Viterbi    1111110000

再看每个位置的「状态 0 减状态 1」一元分差(正数偏 0,负数偏 1):

-2.73  -2.46  +0.11  -2.46  -0.46  -0.46  +2.42  -2.46  +0.11  +2.42

逐个位置对一遍。 第 3 个位置分差只有 +0.11,几乎等于零,逐位置就判了 0——错了; 后验边际和 Viterbi 都靠前后邻居把它拉回 1。 这是「边际」和「路径」都比「一元」强的地方:拿不准的时候,邻居能帮忙。 第 7 个位置相反:分差 +2.42,一元分数非常自信地说是 0,可真值是 1。 后验边际顶住了这份自信,判 1,对了;Viterbi 顺着分数走判 0,错了。 这里就是 Viterbi 让步的地方:为了让整条路径的总分最大,它接受了这一个错误。 第 9 个位置又反过来:分差 +0.11 偏向 0,真值也是 0,逐位置和 Viterbi 都判对, 后验边际却被邻居拉成了 1。 三种做法各有各的错法,没有一种在这里全对。 这一条序列的账:逐位置错 2 个、后验边际错 1 个、Viterbi 错 2 个。 Viterbi 在这里并没有赢。 它优化的是整条路径的总分,不是每一个位置的对错——这两件事在短序列上差别不大, 但在有真转折的地方会正面冲突。 顺带说清楚一件事: 「后验边际最大的那串标签」不是「最可能的整条标签序列」,这两者可以完全不同。 因为「每个位置都取最可能」拼出来的组合, 可能根本不是一条高分路径——就像「每科都选分最高的同学」拼出来的不是一个班。

四、什么时候用哪个

要逐位置的标签,用后验边际。 比如「这句话里每个词是什么词性」——下游只关心每个词,不关心整句是否「连贯」, 那就在每一个位置上最大化正确率。 实测它的逐位置准确率 89.10%,是三者的最高。 要整条合法或一致,用 Viterbi。 比如「这句话的分词结果」——分词必须首尾相接、不能有空洞,逐位置拼出来的结果可能根本不合法。 再比如「语音识别的词序列」——解码器要的是一条完整路径,不是一个位置一个位置的边际。 两者都要?那就两个都算,然后在下游按业务权衡。 好消息是它们的代价同阶(都是「位置数 乘 状态数平方」),而且前向、后向、 Viterbi 可以共用同一套分数,多算一遍不贵。 真正的开销在训练,不在解码。 解码只跑一次前向或一次 Viterbi;训练要跑几百轮、每轮一次前向加一次后向,还要累加期望计数。 所以「CRF 慢」这句话说的是训练慢,不是解码慢——这个区分在工程上很重要, 因为线上服务只付解码的代价。

五、动手 Demo:标签序列的预测:Viterbi 把整条全对从 48.00% 提到 49.00%

用 11.2 训练好的 CRF 权重(200 条训练序列、每条 10 个位置)。 在 100 条测试序列上做三种解码:逐位置取一元分数最大、逐位置取后验边际最大、Viterbi。 比较逐位置准确率、整条全对率、孤立单点翻转数、预测出来的粘性。 最后挑一条序列把四种结果并排打出来,逐个位置核对差在哪。

def viterbi(U, W):                          # U 是每个位置的一元分数
    de = U[:, 0, :].copy()                  # 到当前位置为止的最优分数
    bp = np.zeros(U.shape, dtype=int)       # 每个位置的最优前驱
    for t in range(1, U.shape[1]):
        s = de[:, t - 1, :][:, :, None] + W[None, :, :]
        bp[:, t, :] = np.argmax(s, axis=1)
        de[:, t, :] = np.max(s, axis=1) + U[:, t, :]
    path = np.zeros(U.shape[:2], dtype=int)
    path[:, -1] = np.argmax(de[:, -1, :], axis=1)
    for t in range(U.shape[1] - 2, -1, -1):  # 回溯
        path[:, t] = bp[np.arange(len(path)), t + 1, path[:, t + 1]]
    return path

实跑输出:

--------------------------------------------------------------------
标签序列的预测:Viterbi 把整条全对从 48.00% 提到 49.00%
--------------------------------------------------------------------
  数据                     同 D037:100 条测试序列,每条 10 个位置
  真值粘性                 0.9033

  解码方式               逐位置准确率   整条全对   孤立单点翻转     粘性
  ---------------------------------------------------------------------
  逐位置取一元分数最大         80.20%     11.00%            127   0.6567
  逐位置取后验边际最大         89.10%     48.00%              7   0.9044
  Viterbi,整条最优            88.00%     49.00%              0   0.9178

  **三种解码里没有一个全面占优,先看清楚它们是拿什么换什么:**
  **Viterbi 的逐位置准确率反而比后验边际低 1.10 个点(88.00% 对 89.10%),但整条全对高 1.00 个点(49.00% 对 48.00%)。**
  **它是在拿「每个位置都尽量对」换「整条尽量对」。要哪个,取决于下游拿结果去做什么。**

  **「孤立单点翻转」这一列是差别最直观的地方:逐位置解码有 127 个位置和左右两边都不一样,后验边际是 7 个,Viterbi 是 0 个。**
  **真实标签序列里这种「夹在中间的一小段异类」几乎不出现,所以它们几乎必然是把观测噪声当成了信号。**

  挑一条出来看(第 5 条测试序列):

    观测       3313220310
    真标签     1111111100
    逐位置     1101110100
    后验边际   1111110100
    Viterbi    1111110000

    每个位置的「状态 0 减状态 1」一元分差(正数偏 0,负数偏 1):
    -2.73  -2.46  +0.11  -2.46  -0.46  -0.46  +2.42  -2.46  +0.11  +2.42

  **看那串一元分差:绝对值大的位置很自信,接近零的位置就是「拿不准」。逐个位置对一遍:**
  **第 3 个位置分差只有 +0.11,几乎等于零,逐位置就判了 0——错了;后验边际和 Viterbi 都靠前后邻居把它拉回 1。**
  **第 7 个位置相反:分差 +2.42,一元分数非常自信地说是 0,可真值是 1。**
  **后验边际顶住了这份自信,判 1(对了);Viterbi 顺着分数走判 0(错了)。**
  **第 9 个位置又反过来:分差 +0.11 偏向 0,真值也是 0,逐位置和 Viterbi 都判对,后验边际却被邻居拉成了 1。**

  **所以这条序列上逐位置错 2 个、后验边际错 1 个、Viterbi 错 2 个。**
  **Viterbi 在这里并没有赢——它为了维持「前后一致」,把第 7 个位置也牺牲掉了。**
  **这正是「整条最优」的代价:它优化的是整条路径的总分,不是每一个位置的对错。**
  **而「Viterbi 一定比逐位置好」这句话也只在「标签序列本身平滑」的前提下成立——这个前提来自数据,不是来自算法。**

  **再补一句复杂度的账:Viterbi 是 O(位置数 乘 状态数平方),和一次前向传播同阶,不贵。**
  **真正贵的是「要枚举才能知道 Z(x)」:状态数一多、特征一长,归一化常数就只能靠动态规划算,这也是为什么 CRF 只做线性链、不做一般的图。**

和前向算法对照着看:前向是 logsumexp 上一步的所有前驱,Viterbi 是 max。 一个求和、一个取最大,其余结构一模一样。 所以「算 Z」和「找最优路径」在代码上只差一个函数——但它们回答的是两个不同的问题,别混。 bp 必须存下来,而且必须存「对每个状态取最大时的那个前驱」。 只存一个全局最优前驱是不够的:回溯到第 t 个位置时,你还不知道第 t 个位置会是哪个状态, 所以每个状态都得有自己的一条回溯线索。 bp 的形状和 de 一样(序列数 乘 位置数 乘 状态数),原因就在这里。 整条路径的起点(最后一个位置)是 argmax(de[:, -1, :]),然后从后往前回溯。 不能从前往后走——前向的 de 存的是「以这个状态结尾的最优」,它不知道后面会怎么走, 所以只能倒着推。 这是动态规划的标准姿势:先算完所有子问题,再从终点回溯。 de = U[:, 0, :].copy() 里的 .copy() 不能省。 不复制的话 de 是 U 的一个切片视图,后面 de[:, t, :] = ... 会写进 U, 把所有位置的一元分数毁掉——而且这个 bug 极难发现,因为结果看起来只是「准确率低了一点」。 全程在 log 域做,不要先 exp 再乘。 分数加一项、乘一项,在 log 域里就是加; 一旦 exp 出来,长序列必然溢出成 0 或 inf,然后 argmax 全是垃圾。 和 11.2 里那个 logsumexp 是同一个理由。 平局怎么办?np.argmax 取第一个,所以状态 0 会在平局时胜出。 这在演示里无所谓,在真实系统里可能造成系统性偏置——如果两个状态的分数经常恰好相等, 那所有平局都倒向 0,标签分布就歪了。 严谨的做法是在分数上加一个极小的随机扰动,或者明确规定平局规则并写进文档。 要说明:这条被挑出来讲的序列,Viterbi 错了 2 个位置而后验边际只错 1 个。 这不是挑刺,是刻意挑的——它正好说明「Viterbi 不是全面更强」。 如果只看总体准确率,会以为两者差不多;看了单条才发现它们错的地方完全不同。

30 秒实验

  1. 把状态数从 2 改成 4(重造数据、重训 CRF),重跑三种解码。 整条全对会掉到多少?三种解码的差距会变大还是变小?为什么?
  2. 把学到的转移权重人为乘 3(更强的一致性先验),重跑。 逐位置准确率会升还是降?整条全对呢?这说明了什么?
  3. 把序列长度从 10 加到 30,重跑。 逐位置解码的整条全对率会怎么变?Viterbi 的呢?两者的差距会拉大吗?

常见误区

  1. 「Viterbi 一定比逐位置解码好」。 实测 Viterbi 的逐位置准确率是 88.00%,比后验边际的 89.10% 低 1.10 个点。 它整条全对高 1.00 个点,也就是拿局部换整体。 「更好」这两个字必须先说清是在哪个指标上更好。
  2. 「每个位置都取最可能的标签,拼起来就是最可能的序列」。 不是。这两件事可以给出完全不同的答案。 因为「每个位置各自最优」拼出来的组合,可能是一条低分路径——位置之间的一致性被完全忽略了。 要找最可能的整条序列,必须用 Viterbi;要每个位置都尽量对,才用边际。
  3. 「Viterbi 保证每个位置都对」。 它保证的是「整条路径的总分最大」,一个位置都不保证。 实测那条被挑出来的序列,Viterbi 为了整体最优,在第 7 个位置上接受了错误。 这正是它的设计目标,不是 bug。
  4. 「解码比训练便宜,所以 CRF 其实很快」。 解码确实便宜(一次前向或一次 Viterbi),但训练要几百轮、每轮前向加后向。 说「CRF 慢」指的是训练慢。 这个区分在工程上很值钱:线上服务只付解码的代价,所以训练慢一点常常可以接受。
自测三问(点开对答案) 1. Viterbi 和「逐位置取后验最大」到底差在哪? —— **差在优化目标。** **逐位置取后验最大,优化的是「每个位置正确率之和」;Viterbi 优化的是「整条路径的概率」。** **前者每做一次决定都不看别人做了什么,后者要求整条一起决定。** **所以在有强一致性的数据上 Viterbi 更平滑(孤立翻转 0 个对 7 个),** **但在「某个位置真的该翻转」时它会为了整体把那个翻转按回去。** 2. 为什么 Viterbi 的逐位置准确率可能更低? —— **因为它肯在局部让步。** **某个位置单独看,另一个标签分数更高;但选它会破坏前后的一致性,让整条路径的总分下降。** **Viterbi 会选那个「局部差一点、整体更好」的标签,于是这个位置就错了。** **实测里它比后验边际低 1.10 个点,** **同时整条全对高 1.00 个点——这一进一出正是同一件事的两面。** 3. 什么情况下应该用逐位置解码,而不是 Viterbi? —— **当下游只关心每个位置、不关心整条是否一致的时候。** **比如词性标注:下游要的是「每个词是什么词性」,一个词判错不影响别的词的使用,** **那就该最大化每个位置的正确率。** **反过来,分词、语音识别的词序列这类「整条必须合法」的任务,就得用 Viterbi。** **判据不是「哪个算法更高级」,是「下游拿这个结果去做什么」。**

第 11 章把有监督分类讲完了:从 7 章的线性分类,到 8 章的间隔最大化,到 9 章的集成, 到 10 章的概率,再到 11 章的序列。一条主线越来越清楚——模型越复杂,要管的东西越多。 下一章换个方向:不再问「这一条属于哪一类」,而是问「这一条是不是和大家不一样」。 那是无监督学习的第一站:异常检测。

相关笔记