CTC训练好之后,真正麻烦的反而是解码。模型在你手上给的只是一张按帧算好的概率表,看起来像一团迷雾:每一帧上哪个字符的概率最高,贪心取一下、再把连续重复和空白去掉,不就是结果吗?很多新手都是这么干的,跑出来以后发现错得离谱。真正跑过语音识别或者OCR解码的人都知道,预测阶段正确的思路是基于“前缀分数”(Prefix Score)的动态规划搜索,也就是常说的Prefix Beam Search。这篇我把CTC Prefix Score计算的来龙去脉、数学递推、可直接改的Python实现,以及工程上容易踩的坑,一次性讲透。
拿一个实际例子说,某次我试着把一段几十帧的音频丢给训练好的CTC模型,先跑了贪心解码,输出是“aaa_bb__ccc”这种带下划线的原始帧标签序列,折叠完也确实能变成一个词。但换一句更复杂的音频,贪心输出就出问题了——漏字、重复、多出来的噪声帧全混在里面,怎么折叠都不对。而改用Prefix Score去做Beam Search之后,同样的模型,效果一下子就正常很多。原因在于:贪心只看“每一帧单点最大概率”,根本没考虑不同候选路径之间的概率累计,而Prefix Score干的事情,就是把“所有能够折叠成某个前缀的路径概率”全部加起来,让搜索老老实实地沿着全局概率最大的方向走。
1. 从CTC解码的死胡同说起:为什么不能简单取argmax
1.1 模型输出的是帧级概率,不是文本
CTC模型(Connectionist Temporal Classification)在训练时,输入是一段序列,比如语音的梅尔频谱帧、OCR里按列切好的图像特征,输出是每个时间步在各个标签上的概率分布。这里有一个容易混淆的点:模型从头到尾都没有直接输出“这段音频说的是什么字”,它输出的只是一张形状为[T, C]的矩阵,T是时间帧数,C是字符类别数(包含一个特殊的blank符号,通常下标是0)。
blank符号是CTC里最容易被误解的东西。它不是“空格”,更不是“静音”,而是专门用来分隔相邻重复字符的分隔符。举个最简单的例子,标签序列是"ab",如果模型在连续两帧都输出字符a,那么这两帧必须被blank隔开才有可能折叠成单个a。要是没有blank,aa折叠后会被压缩成a,就少了字符。所以任何提到CTC解码的讨论,都绕不开blank这个特殊角色。
1.2 贪心折叠为什么经常翻车
既然模型只给帧级概率,最简单的解码方式就是:对每一帧取概率最大的标签,得到一条“最佳路径”,比如a _ b b _ c,然后把连续重复的字符合并,再把blank删掉,就得到abc。这条路径通常叫greedy decode。很多框架甚至直接提供了这个功能,看起来又快又稳。
但问题在于,这条单一路径的概率最大,不等于折叠后文本的概率最大。举个具体例子:若某个字符a在帧1概率0.4、帧2概率0.4,而blank在第1帧概率0.35、第2帧概率0.35,单看每一帧,a都是赢家,贪心会输出aa,折叠后是a。但另一条路径a _ a的概率是0.4×0.35×0.4=0.056,而贪心路径a a a的概率是0.4×0.4×0.4=0.064,确实更大。可一旦概率分布没那么“尖锐”,有大量中间路径同时指向同一个文本结果,单条路径的概率就完全不能代表最终文本概率了。CTC整个设计的基本思想,就是把所有能折叠成目标序列的路径全部加起来。如果你在解码时只取一条路径,等于把CTC最核心的“总和”机制丢掉了。
1.3 需要的是一个能累计“多路径”的前缀分数
所以解码的正确姿势是:在时间步t上,维护一个正在生成的前缀(prefix),计算“到目前为止,所有可能折叠成这个前缀的路径概率之和”。这个和,就是本文的主角——Prefix Score。
有了前缀分数,你可以这样搜索:从空前缀开始,每走一帧,就尝试在当前前缀后面追加一个字符,或者保持当前前缀不变;计算所有候选前缀的分数,按分数排序,保留top K个继续往下走,这就是Prefix Beam Search。它和整句暴力枚举不同,不是等路径完整生成后再折叠,而是一边累积一边折叠,每一帧都在维护“已经折叠好的前缀文本”以及对应的累计概率。这是CTC解码里最核心也是最值得抠明白的算法。
2. Prefix Score的数学骨架:两组状态递归
2.1 记号先约定清楚
为了避免后面公式看得云里雾里,先统一定义几个符号:
T:时间帧数,总共有1...T帧。C:字符类别数,包括blank。blank:blank符号的索引,约定为0。labels:当前要计算的前缀标签序列,长度为L,比如["a", "b", "c"]。P(k, t):模型在第t帧输出字符k的概率。p_b(i, t):到第t帧为止,已经生成的前缀为labels[0:i],并且第t帧的输出是blank时的累计概率。p_nb(i, t):到第t帧为止,已经生成的前缀为labels[0:i],并且第t帧的输出是labels中的某个非blank字符时的累计概率。
这里labels[0:i]是前缀的前i个字符,注意都是在“去掉blank并折叠重复字符之后”的文本前缀,不是原始帧标签序列。举个实例:如果labels = ["a","b"],i=1,那么前缀就是"a"。
2.2 核心递推式:延续与扩展
Prefix Score的递推,本质上只有两个动作:延续和扩展。每个时间步上要用新的概率去更新。
先看延续。在时间步t,如果当前前缀已经确定为labels[0:i],那么为了保持这个前缀不变,第t帧可以输出blank,也可以直接输出labels[i-1]。为什么输出labels[i-1]也算延续?因为前缀是折叠后的文本,帧标签序列里连续出现同一个字符,折叠后还是同一个字符。所以上一时刻分数为p_b(i, t-1)和p_nb(i, t-1),这一帧乘上对应输出符号的概率,就是延续到现在的分数。
再看扩展。前缀本来就是一步一步“追”出来的,当前时刻加入第i个字符labels[i-1],意味着上一时刻前缀还只有i-1个字符。这个扩展动作只能在本帧输出labels[i-1]时发生,对应的上一帧状态是p_b(i-1, t-1)和p_nb(i-1, t-1)。
都加上求和就能得到递推式:
p_b(i, t) = (p_b(i, t-1) + p_nb(i, t-1)) * P(blank, t) p_nb(i, t) = (p_b(i, t-1) + p_nb(i, t-1)) * P(labels[i-1], t) + Γ * P(labels[i-1], t)其中Γ是扩展项。关键分歧点在于Γ怎么取,和“当前要加入的字符与已存在前缀的最后一个字符是否相同”直接相关:
- 若
i == 1,表示向空前缀添加第一个字符,扩展项来自空前缀状态:Γ = p_b(0, t-1) + p_nb(0, t-1)。 - 若
i > 1且labels[i-1] == labels[i-2],也就是准备添加的字符和前一个字符相同,比如前缀是"a",准备加一个"a"变成"aa"。这时候扩展项只能从p_b(i-1, t-1)来,因为两个相同字符之间必须隔着blank,否则a a会被折叠成a,和"aa"不符。 - 若
i > 1且labels[i-1] != labels[i-2],则扩展项可以同时来自p_b(i-1, t-1)和p_nb(i-1, t-1)。
这就是标准化CTC前缀递推里最容易被写错的细节,后面我专门用例子演示一下。
2.3 为什么要区分blank结尾和非blank结尾
很多人刚开始看这个递推很不适应:分开维护两个数组不是多此一举吗?其实这是整个递推能否正确工作的关键。
原因在于折叠过程会对路径做“模糊化”处理:一个最终文本前缀,可能对应非常多不同的原始帧标签序列,它们的最后一个可见符号既可能是blank,也可能是某个真实字符。在下一帧延续时,这两种状态的自由度不一样。
如果上一状态以blank结尾,下一帧输出任何字符都是自由的。如果上一状态以非blank字符X结尾,下一帧仍然可以输出X,因为连续XX折叠后还是X;但如果下一帧输出与X不同的字符Y,前缀就要扩大为XY,这和“延续当前前缀”是两码事。硬把这两类状态合并成一个数字,就丢失了“上一帧最后输出是不是blank”这个关键信息,导致那些需要靠blank隔开的字符路径没法正确累积。所以两个数组缺一不可。
2.4 一个小例子:看路径是怎么被“算”进去和“排除”掉的
假设只有3帧,labels = ["a", "b"],blank记为_,模型给出的概率如下:
- 帧1:P(a)=0.4,P(b)=0.2,P(_)=0.4
- 帧2:P(a)=0.3,P(b)=0.5,P(_)=0.2
- 帧3:P(a)=0.2,P(b)=0.4,P(_)=0.4
初始化,t=1:
- 空前缀:
p_b(0,1) = P(_, 1) = 0.4,p_nb(0,1) = 0 - 前缀
"a"(i=1):p_b(1,1)=0,p_nb(1,1) = P(a,1) = 0.4 - 其余为0。
到t=2,先看空前缀:
p_b(0,2) = p_b(0,1) * P(_, 2) = 0.4*0.2=0.08。
前缀"a"(i=1):
p_b(1,2) = p_b(1,1)*P(_,2) + p_nb(1,1)*P(_,2) = 0.08。p_nb(1,2) = (p_b(1,1)+p_nb(1,1))*P(a,2) + (p_b(0,1)+p_nb(0,1))*P(a,2) = 0.4*0.3 + 0.4*0.3 = 0.24。
前面一项对应延续已有的"a",后面一项对应从空前缀扩展出"a"。这里两条路径都折叠成前缀"a"。
前缀"ab"(i=2):
p_b(2,2) = 0。p_nb(2,2) = (p_b(2,1)+p_nb(2,1))*P(b,2) + (p_b(1,1)+p_nb(1,1))*P(b,2)。第一项为0,第二项为0.4*0.5=0.2,这对应原始帧路径a b直接拼出"ab"。
到t=3,这些分数各自继续滚动。可以看到,"ab"的前缀分数最后会把a b b、a _ b、a b _ b之类的路径都累积进来,同时,像a a b这种路径,因为在折叠后是"ab"也会进入计数。这就是“把所有能折叠成目标前缀的路径总和”的直观体现。
3. 一份可直接运行的Python实现,以及和暴力枚举的对拍验证
3.1 输入准备:仿真一张CTC概率矩阵
为了演示,我先生成一张完全随机的概率矩阵,并且保证每一帧概率归一。后面你也可以直接把真实模型的输出替换进来,接口不变。
import numpy as np np.random.seed(42) T = 6 # 时间帧数 C = 5 # 字符数:0=blank, 1='a', 2='b', 3='c', 4='d' labels = [1, 2, 3] # 目标前缀是 "abc" # 随机生成概率矩阵并做按帧归一化 raw = np.random.rand(T, C) probs = raw / raw.sum(axis=1, keepdims=True) print(probs.round(3))这个矩阵可以理解为模型在某段6帧输入上给出的输出概率分布。
3.2 Prefix Score核心实现
下面这段代码,把第二节的递推式原样翻译成Python。为了清楚起见,我用了new_pb和new_pnb两个新数组,避免原地更新时出现脏读。
def ctc_prefix_score(probs, labels, blank=0): """ probs: shape [T, C],已经按帧归一化的概率矩阵 labels: 标签索引序列,不含blank 返回: 前缀折叠后等于 labels 的总概率 """ T, C = probs.shape L = len(labels) # p_b[i]表示到当前帧为止,前缀labels[:i]且最后一位是blank的累计概率 # p_nb[i]表示到当前帧为止,前缀labels[:i]且最后一位是非blank的累计概率 p_b = [0.0] * (L + 1) p_nb = [0.0] * (L + 1) # 初始化 t=1 p_b[0] = probs[0, blank] # 空前缀,最后一帧输出blank if L > 0: p_nb[1] = probs[0, labels[0]] # 空前缀扩展出第一个字符 for t in range(1, T): new_pb = [0.0] * (L + 1) new_pnb = [0.0] * (L + 1) # 空前缀:只能持续输出blank new_pb[0] = (p_b[0] + p_nb[0]) * probs[t, blank] for i in range(1, L + 1): cur_char = labels[i - 1] # 保持当前前缀不变:上一帧输出blank,或继续输出当前字符 keep_blank = (p_b[i] + p_nb[i]) * probs[t, blank] keep_char = (p_b[i] + p_nb[i]) * probs[t, cur_char] # 从前缀labels[:i-1]扩展出当前字符 if i == 1: extend_char = (p_b[0] + p_nb[0]) * probs[t, cur_char] elif cur_char == labels[i - 2]: # 和前一个字符相同,必须由blank结尾状态扩展 extend_char = p_b[i - 1] * probs[t, cur_char] else: extend_char = (p_b[i - 1] + p_nb[i - 1]) * probs[t, cur_char] new_pb[i] = keep_blank new_pnb[i] = keep_char + extend_char p_b, p_nb = new_pb, new_pnb return p_b[L] + p_nb[L]核心就是循环里的四五条式子。你直接把真实CTC模型的输出概率矩阵传进来,把当前要计算分数的字符序列传给labels,返回的就是Prefix Score——整个前缀labels在整段输入上折叠后的总概率。
3.3 对拍验证:把每一条路径都拆开算一遍
为了确认这段实现没有写错,最可靠的方式是暴力枚举。对于6帧、5个字符,所有可能的原始路径组合一共是5^6 = 15625条,完全可以枚举。
from itertools import product def collapse(path): """把帧标签序列折叠成文本标签序列:去连续重复,删blank。""" out = [] prev = None for sym in path: if sym != blank and sym != prev: out.append(sym) prev = sym return out def brute_force(probs, labels, blank=0): T, C = probs.shape total = 0.0 for path in product(range(C), repeat=T): prob = 1.0 for t, sym in enumerate(path): prob *= probs[t, sym] if collapse(list(path)) == labels: total += prob return total total_prefix = ctc_prefix_score(probs, labels) total_brute = brute_force(probs, labels) print("prefix score:", total_prefix) print("brute force :", total_brute) print("diff:", abs(total_prefix - total_brute))只要两个数值在小数精度误差内一致,就说明递推逻辑没问题。实际跑的时候,绝大多数情况都能对得上。如果你自己改动过递推,建议保留这个对拍函数作为回归测试,后面改代码再也不怕改坏。
简单解释一下对拍设计的思路:暴力枚举的“基准答案”是绝对正确的,因为它直接按CTC折叠定义算;而Prefix Score是用动态规划压缩复杂度后的结果。两者能对上,说明压缩过程没有丢掉路径、也没有重复计算。以后你在工程里改成了log域计算、加快beam search,也要记得用真实小样例跑一跑这个对拍,防止引入隐蔽的精度或逻辑错误。
4. 把它搬进Beam Search:工程上的组织方式与优化
4.1 Beam里如何用前缀分数做剪枝
明白了单条前缀的分数怎么算,下一步就是把整个搜索树铺开。Prefix Beam Search的基本结构是:
- 维护一个字典
beam,键是目前的前缀标签序列,值是(p_b, p_nb)两个浮点数。 - 对每一帧,把
beam里每一个前缀,使用类似上面的递推公式扩展出一个“保持当前前缀”的分数,以及若干个“追加一个新字符”的分数。 - 把所有新前缀放到一起按总分排序,只保留Top K条。
这里有一个容易迷惑的地方:beam里的前缀不是原始帧路径,而是已经折叠好的文本前缀。比如既有原始路径a _ b又有a b _,都会折叠成"ab",于是它们在字典里的键都是"ab",概率自动合并。这就是前缀分数天然支持beam search的原因:它已经把“同文本不同路径”的概率合并好了,搜索时只需要比较文本前缀,而不是去比较海量原始路径。
4.2 合并相同前缀:字典和“上次发射字符”的配合
实际工程实现时,每个beam条目至少要记录三样东西:
prefix:当前文本前缀,比如["a", "b"]p_b:以此前缀结尾、且最后一帧输出blank的累计概率p_nb:以此前缀结尾、且最后一帧输出非blank的累计概率
为什么不用单独记忆“最后一帧输出的是什么字符”?因为非blank状态一定对应前缀的最后一个字符,而blank状态没有任何字符,所以这两个状态已经完整表达了所需信息。扩展时,只需从prefix的最后一个字符判断能不能直接追加相同字符。如果候选新字符和前缀最后一个字符相同,就只能从p_b扩展;否则可以从p_b + p_nb扩展。
在我的工程实现中,扩展新字符时并不会真的对每个字符都去算一次递推,而是先从当前帧的概率分布里挑出Top M个非blank候选字符。因为大部分字符的概率都很小,全部展开只会浪费算力。这个Top M一般取beam宽度的2到3倍就够用了,如果你有词典约束,再进一步过滤。
4.3 Log域计算的必要性,以及数值下溢
上面写的Python实现直接用概率值做乘法,短序列没问题,但CTC的输入动辄几百帧甚至上千帧,每条路径要连续乘几百个小于1的数,概率很容易下溢到0。工程上必须把乘法换成加法,全程在log域计算。
log域的核心操作有两个:
- log乘:
log(a*b) = log(a) + log(b),这个直接加。 - log加:
log(a+b)不能直接算,要写成log_add(log_a, log_b),实现如下:
import math def log_add(log_a, log_b): if log_a == float('-inf'): return log_b if log_b == float('-inf'): return log_a if log_a < log_b: log_a, log_b = log_b, log_a return log_a + math.log1p(math.exp(log_b - log_a))math.log1p比log(1 + ...)更稳,当指数部分很小时也几乎不丢精度。beam search里所有p_b、p_nb的相加,都要换成log_add;所有概率相乘,都要换成加法。我之前有个项目,贪心解码正常、暴力的概率乘法都快归零了,导致输出全是空白,改成log域后立刻恢复正常,这是CTC解码里最常见的问题之一。
4.4 加入语言模型和外挂词典的时机
生产环境的语音识别和OCR,通常不会只靠CTC的声学/视觉概率,还要叠加一个语言模型分数,让输出的句子更通顺。很多教程会直接把语言模型分数加到beam排序上,这个简单粗暴的做法会引起一个严重问题:Prefix Score是中间前缀的累计概率,而语言模型通常只对完整句子评分,你拿整句的语言模型分加到一半前缀上,长度失配。
比较稳妥的做法是引入一个“外挂语言模型”分数,在每次扩展新字符后,给prefix追加该字符,然后计算一个基于该前缀的语言模型增量分,并把它加到beam分数里。因为语言模型通常有token级别的条件概率,这个增量分可以表示为:
lm_score(prefix + char) - lm_score(prefix)这样既保留了前缀分数的结构,又把LM的歧视信息一点点织进去。需要特别注意的是,beam排序用的“总分”和“Prefix Score本身”是两回事。总分是为了剪枝排序用的,真要输出最终完整句子的置信度,还得把扩展过程中的LM增量分数也累积起来,并在末尾进行归一化,否则不同长度的句子分数没有可比性。
5. 实战中踩过的坑和调试经验
5.1 时间维度和标签维度的边界条件
边界条件是我每次重写Prefix Score最容易翻车的地方。具体来说有三个位置:
一是t=1的初始化。如果第一帧概率分布里某个字符概率特别小,也不影响,因为没有别的路径可走,它就是从空前缀出发的第一跳。二是p_b[0]必须单独更新,不能混进普通循环,因为空前缀没有“非blank状态”,它永远只能输出blank。三是当i访问到labels[i-2]时,要保证i>=2,否则越界。
下面这个场景我遇到过好几次:把前缀长度枚举从1循环到L,却忘了i=0的空前缀状态;结果所有从空前缀扩展出来的路径全部丢失,导致分数比真实值小。排查方法也很简单,拿一个极短的样例人工推一遍,或者用前面的暴力枚举对拍。
5.2 连续相同token的“只允许发射一次”规则
连续相同字符的扩展限制,是CTC里最反直觉的一条。前缀"a"追加字符"a"变成"aa"时,扩展项只能来自p_b[i-1],也就是上一时刻前缀为单个"a"且最后输出是blank的路径。为什么不能用p_nb[i-1]?因为如果上一帧最后输出的是非blank字符a,本帧再输出a,原始帧序列是a a,折叠后还是a,根本得不到"aa"。只有上一帧输出blank,原始帧序列是a _ a,折叠后才是"aa"。
这个限制在代码里就是一行if cur_char == labels[i - 2]的判断,但很多人一开始写会忽略。漏掉这条规则的直接后果是重复计数:本帧输出a既被算作“延续前缀"a"”,又被算作“扩展成"aa"”,同一批路径在两个状态里各算了一遍,最终分数虚高。如果你测出来的分数比暴力枚举大,十有八九就是这个原因。
5.3 空前缀与首字符是blank的情况
再强调一遍,CTC的blank符号是blank=0的独立符号,不是空格。解码时,空前缀的p_nb恒为0,因为它没有任何非blank输出;而空前缀的p_b会随着时间累积。很多人在扩展第一个字符时,会用p_b[0]或p_nb[0]单独挑一个,这不对,应该是两者相加——虽然p_nb[0]通常是0,但写在一起才是语义完整的状态。反过来,如果扩展出来的第一个字符是blank,那就不是“扩展”,而是“空前缀延续”,必须回到new_pb[0]分支,否则会平白多出一堆以blank开头的伪路径。
5.4 别把解码算法和CTC Loss混为一谈
另外还有一个认知层面的坑:CTC Loss在训练时的前向计算,其实和Prefix Score递推有相似之处,但目的完全不同。训练时我们不知道最终文本,需要穷举所有能折叠成目标标签的路径来最大化概率;解码时我们在已知模型输出的情况下搜索最可能的文本。两者都用到了前缀累积技巧,但训练时“允许跳过某些字符”没有上限,解码时则必须维护一个明确的beam集合并排序。如果你把训练过程里的前向变量直接挪来做解码,经常会出现分数偏高且搜索空间失控的情况。真要做解码,仍然是Prefix Beam Search这一套更合适。
5.5 带中间矩阵打印的调试代码
最后送一段我常用的调试片段。当分数对不上、又不想靠肉眼盯时,把每个时间步的p_b、p_nb打印出来,对照手工推导检查。
def debug_ctc_prefix_score(probs, labels, blank=0): T, L = probs.shape[0], len(labels) p_b = [0.0] * (L + 1) p_nb = [0.0] * (L + 1) p_b[0] = probs[0, blank] if L > 0: p_nb[1] = probs[0, labels[0]] print("t=1") print("pb :", [round(x, 4) for x in p_b]) print("pnb :", [round(x, 4) for x in p_nb]) for t in range(1, T): new_pb = [0.0] * (L + 1) new_pnb = [0.0] * (L + 1) new_pb[0] = (p_b[0] + p_nb[0]) * probs[t, blank] for i in range(1, L + 1): c = labels[i - 1] new_pb[i] = (p_b[i] + p_nb[i]) * probs[t, blank] new_pnb[i] = (p_b[i] + p_nb[i]) * probs[t, c] if i == 1: new_pnb[i] += (p_b[0] + p_nb[0]) * probs[t, c] elif c == labels[i - 2]: new_pnb[i] += p_b[i - 1] * probs[t, c] else: new_pnb[i] += (p_b[i - 1] + p_nb[i - 1]) * probs[t, c] p_b, p_nb = new_pb, new_pnb print(f"t={t+1}") print("pb :", [round(x, 4) for x in p_b]) print("pnb :", [round(x, 4) for x in p_nb])每次改完递推,我都会先用一张很小的随机概率矩阵跑这个函数,再配合暴力枚举对拍。只要T不超过5、字符数不超过5,枚举完全跑得动,几分钟就能确认实现是否正常。这段代码也是我自己在新项目里复用率最高的片段之一。
CTC Prefix Score计算看着只是几行递推,真正掌握后,解码端的很多问题都能迎刃而解。无论你是做语音识别、OCR还是手写识别,只要模型输出的是帧级标签概率,前缀分数的计算思路都是一样的。先把不带beam的朴素前缀分数跑通,再往上加beam宽度、语言模型、词典约束,每一步都拿小样例验证,方向就不会跑偏。