语音识别首先要处理一个对齐问题:音频帧和输出文字的数量不同,也没有现成的逐帧对应关系。例如一段音频有数百帧,却只对应几个字。CTC 和 RNN-T 提供了两种在没有逐帧标注时训练模型的办法。理解它们的输出路径和条件假设,再看损失函数,会比直接背公式容易得多。
前置阅读:建议先读 语音识别基础、RNN 与 LSTM。
对齐问题:一个具体例子
音频「你好」切成 10 帧,声学模型每帧输出一个字符的概率分布。真实标注是「你好」两个字。帧和字的对应关系可能是:
帧: 1 2 3 4 5 6 7 8 9 10
方案A: 你 你 你 - - 好 好 好 好 -
方案B: - 你 你 你 你 - - 好 好 好
方案C: 你 你 - - 好 好 好 - - -
A、B、C 压缩后都是「你好」——正确的路径有成千上万条,且我们事先不知道哪条是对的。这正是 CTC 要解决的核心问题。
CTC:对所有合法路径求和
CTC(Connectionist Temporal Classification)的三件套设计:
1. 引入空白符 blank(记作 -):允许模型在某些帧输出「什么都没有」,解决「音频比文字长」的问题。
2. 定义路径压缩规则:先合并连续重复字符,再删除 blank。「你 你 - 好 好 好」→「你 好」。注意顺序:「你 - 你」压缩后是「你你」(两个你),因为 blank 隔开了重复——这巧妙地区分了单字和叠字。
3. 目标函数:所有能压缩成标注序列的路径概率之和:
表示所有压缩后等于 Y 的帧级路径。路径数是指数级的(10 帧 2 个字就有几十条),直接枚举不可能——用前向后向算法(和 HMM 同源动态规划)把求和压到 O(T×|Y|) 完成。PyTorch 里这个复杂的 DP 已经封装好,直接用:
import torch
import torch.nn as nn
ctc_loss = nn.CTCLoss(blank=0, zero_infinity=True)
# log_probs: (帧数T, batch, 词表大小) 模型每帧输出的对数概率
# targets: (batch, 标注长度) 文字序列,无需任何对齐信息
loss = ctc_loss(log_probs, targets, input_lengths, target_lengths)
loss.backward()
解码:从帧概率到文字
训练完,推理时要把每帧的概率分布变成最终文字:
贪心解码:每帧取概率最大的符号,再压缩。快(O(T)),但只走了单条最优路径,丢掉了路径求和的优势。
束搜索(Beam Search):维护 top-k 个候选前缀,逐帧扩展。对 CTC 要特别处理「blank 分隔」和「重复合并」的状态。配合语言模型效果更佳——声学上「公式」和「公事」分不清时,语言模型根据上下文投票:
# 概念示意:束搜索 + 语言模型打分
score = log P_acoustic(path) + α * log P_lm(text) + β * len(text)
α 控制语言模型权重,β 修正 LM 对短句的偏好。调这两个参数曾经是语音工程师的日常。
CTC 的两个结构性短板
- 帧间条件独立假设:CTC 假设每帧输出相互独立,没有语言模型能力——「继往开来」的声学即使模糊,CTC 自己不会利用上下文纠正成合理成语,必须外挂语言模型。
- 输出不能长于输入帧数:对极端压缩的音频(快语速)有硬伤。
RNN-T:把语言模型内置进去
RNN-T(RNN Transducer)在 CTC 骨架上加了一个预测网络(Prediction Network)——它吃「已输出的历史文字」,相当于内置语言模型;再用**联合网络(Joiner)**融合声学信息和语言信息决定下一帧输出:
声学编码器(音频帧) ──┐
├──> 联合网络 ──> 输出(含blank)
预测网络(历史文字) ──┘
关键区别:RNN-T 的每帧输出** conditioned on 已生成的文字历史**,打破了 CTC 的帧独立假设。收益是端到端单模型就有语言建模能力,且天然适合流式识别(边说边出字)——这就是它被 Google 用在 Pixel 手机本地语音输入的原因。代价是训练更复杂(联合网络让对齐搜索空间变大),解码也更重。
第三条路线:Whisper 的编码器-解码器
Whisper 干脆抛弃了「逐帧对齐」范式,回到标准 seq2seq:编码器把 30 秒音频压成特征序列,解码器像机器翻译一样自回归地逐 token 生成文字,对齐完全交给交叉注意力隐式学习。
| 维度 | CTC | RNN-T | Whisper (Enc-Dec) |
|---|---|---|---|
| 对齐方式 | 显式路径求和 | 显式路径求和 | 注意力隐式学习 |
| 语言能力 | 无(需外挂 LM) | 内置 | 强(完整解码器) |
| 流式识别 | 可以 | 原生支持 | 困难(要整段音频) |
| 训练数据需求 | 中等 | 中等 | 巨大(68 万小时) |
| 当前地位 | 教学与轻量场景 | 流式产品主力 | 离线转写 SOTA |
选型直觉:做实时字幕、语音输入选 RNN-T 系;做录音转写、字幕生成直接用 Whisper 系;CTC 作为理解对齐问题的起点和小数据场景的轻量方案。
我做的对比实验
在同一个 100 小时中文内部数据集上(远低于 Whisper 预训练量),从零训练三个方案:
| 方案 | 测试集 CER | 流式能力 | 备注 |
|---|---|---|---|
| CTC + 贪心解码 | 18.7% | ✓ | 同音字错误占大头 |
| CTC + 束搜索 + 4 元 LM | 14.2% | ✓ | LM 救回 4.5 个点 |
| RNN-T | 12.8% | ✓ | 单模型效果最好 |
| Whisper-small 微调 | 9.6% | ✗ | 预训练知识碾压 |
两个教训:语言信息值 4~5 个点的错误率;100 小时数据时预训练模型的优势是压倒性的——除非流式是硬需求,否则别从零训练。
踩坑与排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| CTCLoss 返回 inf | 标注比帧数还长/有非法字符 | zero_infinity=True;检查 T ≥ |
| 输出全是一个字重复 | blank 索引设错 | 确认 blank=0 且模型输出第 0 类是 blank |
| 束搜索比贪心还差 | LM 权重 α 过大 | 网格搜索 α、β;先在验证集调 |
| 训练 CER 降测试不降 | 过拟合小数据集 | SpecAugment 音频增强;降模型容量 |
| RNN-T 训练不收敛 | 学习率/联合网络配置 | warmup + 较小 lr;检查 joiner 维度 |
| 长音频推理 OOM | 未分块处理 | 按 VAD 切 30 秒以内片段 |
动手练习
- 手写 CTC 路径压缩函数:输入帧级序列(含 blank),输出压缩结果,并用「你 - 你」验证重复处理逻辑。
- 用 torchaudio 加载一段音频,提取 Mel 特征,过一个随机初始化的 BiLSTM + CTCLoss,跑通前向反向(不要求收敛,只验证张量形状)。
- 在预训练 CTC 中文模型上分别用贪心和束搜索解码同一段音频,对比 CER 差异。
面试常问
Q:CTC 怎么解决对齐问题?为什么需要 blank? CTC 不标注帧级对齐,而是把「所有能压缩成标注文本的帧级路径」的概率求和作为似然,用前向后向动态规划高效计算,反向传播时对每条路径按概率加权分配梯度。blank 的作用:允许帧无输出(音频长于文本);分隔连续重复字符(「你-你」→「你你」,区分叠字),没有 blank 就无法表达连续相同字符。
Q:CTC 的帧独立假设有什么问题?RNN-T 怎么改进? CTC 假设各帧输出条件独立,模型本身没有语言建模能力,遇到声学模糊(同音字)无法利用上下文,必须外挂语言模型。RNN-T 增加预测网络,以已输出文字历史为条件,与声学编码在联合网络中融合,输出分布同时依赖音频和文本历史,单模型具备语言能力,且天然支持流式。
Q:Whisper 为什么不用 CTC? Whisper 用编码器-解码器架构,解码器自回归生成文本,对齐由交叉注意力隐式完成。优势:解码器是完整语言模型,上下文利用充分,且天然输出标点、大小写、多语言标记;前提是有海量数据(68 万小时)让注意力学会对齐。CTC 系的显式对齐在小数据和流式场景仍有优势。
对齐问题是语音识别的「题眼」,看懂 CTC 与 RNN-T,就看懂了过去十五年语音技术的主线。
相关阅读:语音识别基础、Whisper 微调与声音克隆、声纹识别、RNN 与 LSTM。
💬 评论
这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。