2026/10/17 9:55:55

CTC与RNN-T:端到端语音识别的对齐机制与工程实践

CTC与RNN-T:端到端语音识别的对齐机制与工程实践 语音识别的难点从来不在“识别”这两个字上。真正让人头大的是音频特征序列和文本标签序列的长度永远对不上一段两秒的语音能抽出 200 帧特征但对应的文字可能只有 6 个字。CTC、RNN-T 这些听起来很唬人的名字本质上都是在解决这个对齐问题。我手上的这一套 Chapter1-3 课程讲义正好把从传统声学模型到 CTC、再到 RNN-T 的发展线索梳理得很清楚。这篇文章就按我自己的实践理解把这几章内容展开来讲适合刚开始做端到端语音识别的同学也适合想快速搞清楚 CTC 和 RNN-T 区别的人。1. 端到端语音识别为什么要把“对齐”交给模型自己1.1 传统方案与端到端方案的差异在 CTC 和 RNN-T 出现之前主流的语音识别系统是“拼积木”式的先抽取声学特征然后用隐马尔可夫模型加高斯混合模型这类结构做声学建模接着需要一个发音词典把文字拆成音素最后再接一个语言模型用来评估词序列是否通顺。这条链路里最难维护的就是“对齐”HMM 要求知道每一帧语音对应哪一个音素状态而这种对齐关系是由训练数据和专家规则共同约束出来的。端到端方案做的事情非常直接不再人为拆分声学、词典、语言模型而是让一个神经网络直接从音频特征预测文本序列。它的核心假设是只要数据量足够网络内部可以自己学出音频与文本之间复杂的对应关系。好处很明显不需要维护发音词典和音素状态集合训练和推理都变简单了。代价也明显自由度太大以后模型容易“乱对齐”所以必须设计额外的机制来限制输出结构这正是 CTC 和 RNN-T 出现的原因。我在实际项目里感触最深的一点是传统链路问题不一定出现在每一个单独模块上而是出现在模块之间的接口上。声学模型觉得某个音素概率高语言模型却严重反对最终结果取决于加权融合。端到端模型把这些矛盾全部塞进一个优化目标里虽然内部没那么好解释但整体效果往往更稳尤其是在数据和算力都比较充足的情况下。1.2 CTC 与 RNN-T 在思路上分道扬镳CTC 的策略是“逐帧打标签但允许空转”。给定一段音频模型每个时间步输出一个符号符号除了正常字符外还有一个特别的 blank 符号。因为语音帧数远多于字符数模型可以在多余帧上输出 blank表示“这一帧没有内容”。最后把所有帧的输出合并成文本时连续重复的字符会被压缩blank 会被删除。这种方式保留了时序的单调性实现简单训练稳定所以很长一段时间里它是端到端语音识别的默认选择。RNN-T 的思路更像“一边听一边写”。它由三个部分组成声学编码器负责读音频帧预测网络负责根据已经写出的文本预测下一个字符的“语言倾向”联合网络把这两部分信息拼在一起决定当前音频帧到底是输出一个字符还是输出 blank。关键区别是在同一帧上模型可以连续输出多个字符直到输出 blank 才表示“这一帧读完了请进入下一帧”。这种机制更符合人类听写的习惯也是它在流式识别上比 CTC 更自然的原因。还有一类方案是注意力编码器-解码器结构。它通过注意力机制直接让解码器在文本生成时去“看”对应的音频区域不强制局部单调离线场景下效果通常最好但在流式场景里需要额外做 mask 或 chunk 限制工程复杂度更高。选择哪种结构本质上取决于你的需求是离线高精度还是低延迟流式识别。CTC 适合快速起步和作为辅助任务RNN-T 适合在线场景注意力结构适合离线批量识别。2. CTC 与 RNN-T 核心算法逐个拆解2.1 CTC用 blank 符号和路径求和解决长度不匹配CTC 的全称是 Connectionist Temporal Classification核心思想是“不要求每一步对齐正确而是只要求整个序列整体正确”。假设音频特征长度为 T目标文本长度为 U通常 T 远大于 U。CTC 引入一个词表大小是正常字符数加一个 blank 符号。模型在每一帧 t 都会输出一个概率分布 p_t(v)表示该帧属于每个符号的概率。任何长度为 T 的符号序列 π 都叫一条路径路径概率是每一帧概率的乘积。将路径映射为文本时需要两步先把连续的重复字符压缩成一个再把 blank 去掉。例如路径“a、b、b、、c”经过合并重复字符后变成“a、b、、c”再去掉 blank 变成“abc”。训练时不能只挑一条最优路径因为在同一个文本标签下可能对应大量合法路径。CTC 损失函数就是所有能映射到目标文本的路径概率之和的对数。直接枚举所有路径不现实所以需要用动态规划。本质上是建立一个状态网格横轴是时间纵轴是标号序列的位置每一步只能从当前状态或上一个状态转移过来blank 则负责填充多余帧。我实际调模型时并不会手推这条公式但一定要理解这个网格的含义否则很难解释“为什么 CTC 训练完会变成尖峰分布”。CTC 训练完成后有个非常典型的特征概率输出不是均匀分布在所有帧上而是在每个字符对应的声学边界附近形成尖峰其他位置大量输出 blank。这是模型学到的最优对齐方式你别看到这种输出去怀疑它坏了。解码端最简单的方法是贪心搜索每一帧取概率最大的符号合并重复、去掉 blank 后得到结果。如果想要更好一点就做 beam search在搜索过程中把若干条候选路径的概率累积起来再比较最终文本概率。我在一个中文普通话项目里用 CTC 做基线初始训练时发现无论怎么调最后输出全是空白。后来检查日志发现是词表建立顺序出了问题blank 的索引没放对模型把 blank 当成了正常字符对待相当于标签错位。这种问题用肉眼很难发现所以建议刚上手时先跑通一个极小的单 batch 训练把预测输出打出来人工对比确认 blank 行为正常后再扩大数据。2.2 RNN-T把语言模型和声学模型接在一起动态对齐RNN-T 的全称是 Recurrent Neural Network Transducer名字里有 RNN 是因为最早的预测网络用的是循环网络但现代实现里预测网络也可以是 Transformer 或 LSTM。整套结构仍然由三个模块组成。第一个模块是编码器负责把音频特征序列变成高层声学表示。每个时间步得到 h_t。第二个模块是预测网络它输入的是上一步已经生成的文本符号把历史文本信息编码成一个状态 p_u。第三个模块是联合网络把 h_t 和 p_u 结合到一起输出一个在“词表加 blank”上的概率分布。联合网络的结合方式有很多种常见的是拼接后过一个前馈层或者做某种双线性变换。关键在解码时的动作逻辑。在某一帧 t 上如果模型输出的是普通字符那么就把这个字符加入输出文本然后更新预测网络的状态但仍然停留在同一帧 t 上继续判断要不要再输出一个字符。只有模型输出了 blank才推进到下一帧 t1。这种一一结合的模型非常自然它允许一个音频帧对应零个字符已由 blank 表达也允许一个帧对应多个字符。RNN-T 的损失计算比 CTC 更复杂一点因为它要同时累计两个方向文本位置 u 和时间位置 t 的联合网格。每一个合法的对齐可以理解为一条从左上角走到右下角的路径横轴代表时间推进纵轴代表文本推进blank 承担横轴推进动作普通字符承担纵轴推进动作。损失函数就是所有合法路径概率之和的对数。这个计算同样靠动态规划完成只是需要对 t 和 u 两个维度做递归。训练时有个容易出现的问题RNN-T 对文本预测网络的初始化非常敏感。如果预测网络一开始就强烈偏袒某几个常用字符联合网络的输出会短期内被带偏训练后期再想拉回来就很费劲。我通常会在前几个 epoch 把预测网络的梯度尺度控制住或者干脆给它一个较小的学习率让声学编码器先学会基础特征再逐步放开语言侧的作用。解码时贪心策略虽然简单但效果往往已经不错每帧计算联合网络输出取最大概率项如果是普通字符就输出并更新预测网络如果是 blank 就移动到下一帧。如果追求更高精度可以加 beam search但 RNN-T 的搜索空间比 CTC 大得多因为同一帧可能产生多个词所以时间复杂度会上涨明显。工程上常用 limited symbol beam也就是每一帧最多展开固定数量个字符限制搜索分支。2.3 CTC 与 RNN-T 的横向对比为了更容易理解两者的差异我做了一些对比维度。维度CTCRNN-T对齐方式逐帧输出blank 填充多余帧逐帧可输出多个符号blank 表示结束该帧输出长度上限最多等于帧数同一帧可多次输出总长可以超过帧数流式支持天然支持但延迟受帧约束天然支持延迟低训练复杂度动态规划只沿时间展开较简单同时沿时间和文本展开复杂度更高解码速度贪心很快beam 也相对可控贪心快beam 分支更多需要限制典型短板对连续重复字符不友好可能漏字显存占用高语言模型部分易过拟合实际工程里CTC 经常被当作辅助损失来用。很多端到端模型会在主目标之外再挂一个 CTC 分支加快收敛速度并平滑注意力对齐。RNN-T 则更多作为流式识别的完整方案因为它的输出动作天然适合“边读边出”。我个人在低延迟任务上会优先考虑 RNN-T在高精度离线任务上则倾向于注意力编码器-解码器结构或者做前端自监督预训练后接 CTC 微调。2.4 还不止这两个AED、Transformer Transducer 与自监督预训练课程标题里写了“and more”确实如此。除 CTC 和 RNN-T 之外注意力编码器-解码器结构是另一个大方向。它把声学编码器输出的特征序列作为记忆解码器每生成一个字符时通过注意力机制决定重点关注哪一段音频。由于可以跳过任意帧再跳回来它对长距离依赖建模更强离线效果通常更好但流式化比较麻烦。Transformer Transducer 可以理解为把 RNN-T 里的预测网络换成 Transformer 结构或者把编码器换成 Conformer 结构。它结合了 RNN-T 的流式对齐能力和 Transformer 的强大建模能力。实际项目里这种组合在长音频和中文识别上效果很好但代价是参数量和计算量都更大。自监督预训练也是绕不开的“more”。先用大量无标注音频做预训练任务可以是对比学习也可以是按聚类标签做预测。预训练得到的音频表示能够显著降低对标注数据的依赖。我在一个几十小时的小数据任务上试过直接训练 CTC 的准确率很低但用预训练模型初始化编码器后收敛速度和最终指标都大幅改善。这种模式现在几乎已经成为做语音识别时的默认流程。3. 从零到可用的实操记录与训练配置3.1 音频与文本数据准备的几个硬性标准很多人兴冲冲跑模型结果效果很差最后发现是数据准备不规范。音频层面首选统一到 16kHz 采样率、单声道、WAV 或 FLAC 格式。不同来源的录音码率不一一定要先做重采样。音频长度建议控制在 5 到 20 秒之间太长会导致显存爆炸且注意力难以覆盖太短则训练效率低。文本层面需要做统一的文本规范化。中文场景里数字要转成汉字例如“2024年”要决定好是保留阿拉伯数字还是转成“二零二四年”英文要转成小写并处理缩写标点符号通常直接去掉除非你把它当作独立符号建模。切分时尽量以句为单位避免一句话里混合不同说话人的声音。还要清洗掉大量静音片段和明显噪声段否则模型会把静音学到文本里造成幻觉输出。我用一个简单的处理流程先看音频有效时长比例把所有无效头尾静音去掉再按文本长度和音频时长的比例做粗筛比如中文里每个汉字平均对应 0.3 到 0.6 秒音频如果某条样本比例严重异常说明标注或音频可能有问题。这种粗筛能省下不少排查 bug 的时间。3.2 特征、模型结构与超参数细节特征方面最常用的是 80 维 FBank而不是很多人以为的 MFCC。FBank 保留了更多原始频谱信息配合神经网络效果通常更好。窗口长度取 25ms帧移 10ms也就是每秒大概 100 帧。特征抽取后一般还要做倒谱均值方差归一化简称 CMVN。全局 CMVN 在训练集上统计均值方差再应用到所有数据在线 CMVN 则用当前说话人内部统计流式场景更友好。文本建模单元方面中文可以用字也可以用更细的拼音或子词模型。用字最直观但低频字和生僻字容易稀疏用 BPE 子词可以减少输入单元数量对多语言任务更友好。我常用 5000 到 8000 个 BPE 单元这个规模对中文数据集来说兼顾效率和覆盖度。模型超参数有一个常用的初始配置编码器采用 Conformer 结构深度 12 层或 16 层注意力头数 8隐藏维 256 或 512。RNN-T 的预测网络用单层 LSTM隐藏维 512。批量大小按显存调整先用 batch size 16 到 32 起步。学习率建议用峰值 0.002 加 warmup 策略前 10000 步线性上升之后按步数衰减。梯度裁剪最大值设置 5.0能有效防止训练早期梯度爆炸。数据增强也不能忽略最常用的是 SpecAugment在时间轴和频率轴随机遮挡部分区域。它能模拟录音环境变化显著提升泛化能力。我通常会先关掉增强把小数据跑通再打开增强做完整训练否则问题到底出在增强还是出在模型结构上很难分开。3.3 训练、验证与解码的具体流程我把整个流程拆成伪代码方便你照着复现。假设已经加载了一组样本每个样本包含特征序列 feats 和文本索引序列 labels。# 训练循环伪代码 for epoch in range(epochs): for batch in dataloader: feats, feats_len, labels, labels_len batch # 编码器输出 enc_out, enc_len encoder(feats, feats_len) if model_type ctc: logits ctc_head(enc_out) # (B, T, V) loss ctc_loss(logits, labels, enc_len, labels_len) elif model_type rnn_t: pred_out prediction_network(labels) # (B, U, D) joint_logits joint_network(enc_out, pred_out) # (B, T, U, V) loss transducer_loss(joint_logits, labels, enc_len, labels_len) loss.backward() grad_norm clip_grad_norm_(model.parameters(), 5.0) optimizer.step() scheduler.step() optimizer.zero_grad()验证时不要只看 loss因为 beam search 或贪心解码之后的序列可能跟训练时的路径有偏差。我习惯每个 epoch 结束后做一次验证集解码计算字符错误率 CER 或词错误率 WER再保存验证集结果最好的模型。这里有个经验验证 CER 和训练 loss 不一定同方向变化因为 loss 考虑的是所有路径概率之和而解码结果是单条硬路径。两者发生矛盾时以验证 CER 为准。解码代码按 CTC 和 RNN-T 分开处理。CTC 贪心容易实现逐帧取 argmax然后做合并与 blank 删除。RNN-T 贪心稍复杂要维护一个当前帧索引和一个预测网络状态每一帧计算 joint logits 后取最大项根据结果决定是否输出文本或前进到下一帧。下面这段伪代码可以表达 RNN-T 贪心解码的核心步骤。def greedy_decode_rnnt(enc_out): t 0 u 0 hyps [] predictor_state init_predictor_state() while t len(enc_out): enc_step enc_out[t] pred_step prediction_network(hyps, predictor_state) logits joint_network(enc_step, pred_step) # (V,) token logits.argmax() if token blank_id: t 1 else: hyps.append(token) u 1 predictor_state update_predictor_state(predictor_state, token) return hyps如果要做带语言模型的 beam search复杂度会高很多。我建议先把贪心流程跑通保证 CER 达到预期范围再考虑 beam search 换取最后几个百分点的提升。很多情况下贪心解码加上训练得好的模型已经能超过传统方案。4. 训练过程中跳过的坑与排查方法实录4.1 Loss 不降、梯度爆炸和 blank 失控第一个常见问题是 loss 完全不动。先别急着换模型结构多半是数据或输入格式问题。检查文本里是否存在未登录字检查词表索引是否从 0 开始check 是否把 batch 数据长度算错。我在一个 RNN-T 项目里遇到 loss 不降最后发现预测网络的输入被多 shift 了一位导致模型看到的历史文本永远错位。这种错误用肉眼检查数据很难发现最好在训练前打印一个 batch 的输入输出做人工核对。第二个常见问题是训练初期 loss 快速下降然后突然变成 NaN。大概率是学习率太大或梯度爆炸。用 warmup 可以从根源缓解同时把梯度裁剪设置在 5.0 到 10.0 之间。半精度训练也会带来数值不稳定尤其是 RNN-T 的 joint logits 计算涉及大矩阵乘法。开启 loss scaling 后能缓解但如果仍然有问题可以先切回全精度训练稳定基线。blank 失控也很有意思。CTC 训练中如果模型所有输出都是 blank通常不是模型坏了而是数据里空白占比太高模型学到了“偷懒”路径。检查训练数据里是否有大量静音片段或文本过短样本。RNN-T 中 blank 输出比例过大可能预测网络初始状态太强导致模型更倾向于提前结束。解决办法是适当调整文本侧梯度或修改 blank 的初始化偏置。我排查 blank 失控时会在训练日志里额外统计每个 batch 的输出分布看普通字符和 blank 的比例变化。如果普通字符已经被压制到非常低就说明优化目标出了问题不是解码后处理能解决的必须回训练端调整。4.2 RNN-T 显存与解码速度问题RNN-T 最大的工程痛点就是显存。它在训练时需要对每个时间 t 和每个文本位置 u 都做联合输出计算对一条 10 秒音频和 30 个字符的目标序列联合矩阵规模会是几百乘以几十乘以词表大小。随着 batch size 和序列长度增加显存消耗呈爆炸式增长。几种常用缓解办法我按有效程度排序第一是限制 batch 内的最大序列长度用 bucket 策略把长度相近的样本放到同一个 batch减少 padding 浪费。第二是使用半精度混合精度训练显存直接减半但要注意 joint 计算时数值稳定性。第三是使用梯度累积用小 batch 多步累积等效于大 batch 但显存需求更低。第四是对特别长的音频做切段训练把 30 秒以上音频切成多个 10 秒片段文本按时间戳切分。解码速度问题同样明显。RNN-T 贪心虽然看起来每步只算一次但遇到长文本时同一音频帧可能连续输出几十个字符导致计算量并不低。常见优化是 limited symbol beam也就是在贪心时限制每个 frame 最多输出的 token 数防止模型进入无限输出循环。还可以引入 blank 跳过策略如果当前帧的 blank 概率极高就跳过后续几个帧的联合计算降低整体开销。4.3 常用问题速查表我把平时遇到最多的问题整理成一张表方便排查时对照。现象可能原因处理方式Loss 不降标签错位、词表索引错误、学习率过低打印 batch 输入输出人工核对梯度爆炸 / NaN学习率过高、半精度数值不稳加 warmup设置梯度裁剪检查 loss scaling输出全为 blank数据里静音过多、blank 偏置过大清洗音频调整 blank 初始化偏置重复输出严重RNN-T 预测网络过强长度惩罚缺失降低预测网络学习率解码时加重复惩罚验证 CER 高但训练 loss 低模型过拟合或数据分布差异增加数据增强检查验证集清洗规则流式解码延迟高每帧都要计算大量 token添加 blank 跳过策略限制 beam 分支这些坑并不高深但每一个都会浪费大量时间。我习惯的做法是建立“最小可复现实验”拿几十条数据训练一个很小的模型把训练日志、解码样例、错误统计全打印出来先确保链路畅通再上全量数据。看起来多花了一个小时实际上能省一天。5. 从章节 3 往下走下一步扩展方向5.1 流式识别的工程化CTC 和 RNN-T 都支持流式解码但它们对流式的友好程度不同。CTC 逐帧输出延迟主要来自帧移想要低延迟可以直接在解码端做分块输出但可能因为后续帧改变当前决策而出现不稳定。RNN-T 每帧推进更自然编码器如果也做了因果卷积或掩码注意力就可以严格保证当前输出只依赖过去和当前信息。工程上流式识别还要考虑脱口秀里的“修正”问题。模型先输出了错误的词回头修正时用户已经看到了第一版结果。因此实际产品通常不会等一句话结束再一次性给出最终结果而是不断发送 partial result并让模型在最终结果里合并修正。纯 CTC 或纯 RNN-T 都能做到局部修正但怎么设计“最终句”的边界需要结合业务场景语音识别模型本身只是其中一环。上下文相关性也是流式识别的重点。常见的做法是在解码时注入热词表或者个性化实体列表让模型在出现特定名称时更倾向于输出指定词。CTC 和 RNN-T 都可以通过调整概率分布来注入这种先验但真正稳定还是要在训练阶段加入上下文提示语否则推理时的注入很容易造成整体概率漂移。5.2 自监督预训练与多任务联合从这条路线继续深入绕不开自监督预训练。先用大量无标注语音做表示学习已经成了低资源语音识别的主流选择。预训练模型学到的特征对噪声、口音和背景音乐更鲁棒微调阶段只需要少量标注数据就能达到不错的 CER。多任务联合训练也很常见。比如在 RNN-T 的编码器旁边挂一个 CTC head联合损失等于 transducer loss 加上带权重的 CTC loss。这种组合方式能加速收敛还能改善对齐稳定性。也有人把注意力解码器加进来做成一种“多解码器”结构离线用注意力路径流式用 transducer 路径训练共享同一套声学编码器。我个人在处理模拟项目时最常采用的组合是“自监督预训练编码器 CTC 辅助损失 RNN-T 主路径”。它既有流式推理能力又能利用 CTC 来稳定早期训练。生成式预训练在语音识别里也逐渐出现但距离大面积落地还有些距离。最后说一个我踩过几次的坑别在第一版模型上花太多时间调戏结构。先把数据清洗、超参数、解码流程跑通得到一个稳定但可能不够惊艳的基线再逐步加入预训练、多任务、上下文偏置。CTC 和 RNN-T 这类模型最怕的不是“算法不够新”而是“基线不稳定导致所有对比实验都失去参考价值”。有了稳定基线之后你会发现后面的每一次改动都变得容易判断好坏项目的推进速度也会快很多。