ctc 基础
1. 什么是ctc
WeNet 的核心结构是 U2(Unified Two-pass):
┌──────────┐
音频 → → │ Encoder │ → → CTC 分支(无条件解码)
└────┬────┘
│
▼
Attention Decoder(第二步)
| 环节 | CTC 的作用 |
|---|---|
| 训练阶段 | 加强对齐、加速收敛、提升 encoder 稳定性 |
| 解码阶段(第一遍) | 快速输出文本(无 Decoder) |
| 解码阶段(第二遍) | 通过"CTC 对齐约束"提升 attention 解码精度 |
| 可选融合 | Prefix Beam + external LM 提升语言模型效果 |
2. 说下语音识别的整体流程。
2.1 音频特征提取
fbank做音频提取。步骤中存在着预加重、分帧、加窗、fft、mel滤波器卷积、取log,整个具体流程在本专栏第一个里面有概述。
2.2 将fbank提取的特征输入encoder(Conformer/Transformer)
Encoder 负责把"声学特征"变成"语音内容表征"。
在encoder之前会先做下采样
卷积 subsampling (2× 或 4×)
作用:
降低序列长度
让 T 减少
提高速度
如何理解:
假设fbank出来的矩阵是个[1000,80] 的矩阵,Transformer / Conformer 的注意力复杂度是 O(T²)!,那么这个矩阵经过encoder后的是[1000000,80],这个矩阵运算会特别慢,这一步可以减少训练或者推理的速度。
py
class ConvSubsampling(nn.Module):
def __init__(self, in_dim=80, out_dim=256):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(1, out_dim, kernel_size=3, stride=2),
nn.ReLU(),
nn.Conv2d(out_dim, out_dim, kernel_size=3, stride=2),
nn.ReLU(),
)
self.linear = nn.Linear(out_dim * ((in_dim - 3*2) // 4), out_dim)
def forward(self, x):
# x: [B, T, F]
x = x.unsqueeze(1) # [B, 1, T, F]
x = self.conv(x) # [B, D, T/4, F']
B, D, T2, F2 = x.size()
x = x.transpose(1, 2) # [B, T/4, D, F2]
x = x.reshape(B, T2, D * F2)
return self.linear(x) # [B, T/4, D]
效果:
Conv1: [B, T, 80] → [B, T/2, C]
Conv2: [B, T/2, C] → [B, T/4, D] ← 通常 D = Encoder dim = 256 或 512
在这一步后会用到相对位置编码。中间有些复杂,不赘诉,主要是为了区分哪个编码对应哪个位置。也可以理解为解码出来的东西的顺序前后。
fbank input: [T, 80]
↓
Conv subsampling 4×
→ 提取局部结构
→ 降低序列长度
output: [T/4, D]
↓
Positional encoding
↓
N 层 Conformer blocks
→ 提取语音内容表征
→ 音素级建模
→ 远程依赖和局部模式
output: [T/4, D]
↓
CTC projection(全连接)
Encoder 会学习:
哪些帧属于元音 / 辅音
音素边界
声学模式
说话人变化
噪声鲁棒性
2.3 CTC 分支(Connectionist Temporal Classification)
从 Encoder 输出接一个全连接层 + Softmax:
logit = Linear(D → V) (V=字典大小+blank)
结果:
frame0: p(blank)=0.6, p("a")=0.2, p("t")=0.1 ...
frame1: p(blank)=0.7, p("a")=0.1, p("t")=0.05 ...
CTC 的三个核心:
① blank 机制
保证模型可以 "不发音"
避免强制每帧输出字。
② 同字合并(aa → a)
重复字符自动压缩。
③ forward-backward 概率
无需帧级标注,也能训练。
ctc输出是:
一个每帧的 token 分布序列 P(t)[token]
2.4 第一遍解码(CTC 解码)
WeNet 的第一遍解码是 CTC Prefix Beam Search。 相当于上一步得到的是每个字典中元素的概率,就每一帧上,所有元素的概率和为1,选取出概率最高的元素。在这个字典里面,是允许 有blank的,就我可以在那一帧静音,啥都没说。
流程:
-
从 CTC logit 序列取概率最高的前 N 个 prefix
-
保持前缀概率
-
blank / non-blank 分开计算概率
-
最终选择概率最高的前 K 个候选句子(beam)
2.5 decoder 第二遍解码(Attention Rescoring)
把每个候选序列送入 Decoder(Transformer Decoder)再打分
相当于去算一下你上一步选取出来的前k个候选句子里面中最有可能的那一个,Attention 可以看到 Encoder embedding,Decoder 模拟语言模型特性,提升输出流畅性、减少 CTC 的"省略字"问题
score = λ * CTC_prefix_score + (1 - λ) * decoder_score
但其实这块基本跟上一步ctc出来的差不太多,至少从我做的项目来看的话。
3.ctc中某些概念的理解
3.1 ctc中blank的作用
CTC 需要在 帧序列(很多) → 文字(较少) 之间建立柔性对齐。
-
让模型有"无输出/延迟输出"的能力(最重要)
音频 1000 帧,但文本可能只有 20 字。
如果没有 blank,每帧必须输出一个字符,就会:
强制每帧都产生字(不现实)
没有地方表示"这帧不对应任何字"
blank = "这帧暂时不输出字"。 -
允许同一个字按多帧持续存在
例如 "AAA" 音频帧很长,但文本只有 "A"。
如果没有 blank:A A A → 会认为是 "AAA"
有了 blank:
(blank) A A (blank) A (blank) → 合并成 "A"
-
CTC 的 collapse rule(合并重复 + 去 blank)需要 blank 才能正常工作
- 去掉所有 blank
- 合并相邻重复字符
[blank, A, A, blank, A] → collapse → "AA"
[blank, A, blank, A] → collapse → "AA"
py
for t in frames:
for each prefix in current_beam:
for each token in vocab:
if token == blank:
// blank transition
p_b[new_prefix] += (p_b[old] + p_nb[old]) * prob(blank)
else:
if token == last_char_of_prefix:
// repeat character
p_nb[prefix] += p_nb[old] * prob(token)
else:
// new character
new_prefix = prefix + token
p_nb[new_prefix] += (p_b[old] + p_nb[old]) * prob(token)
3.2 forward--backward 原理
声学模型输出帧序列 X(T 帧)
文本目标序列 Y(长度 U,例如 "ni hao")
CTC 不需要逐帧标注,而是计算:
所有可能的对齐路径(alignment)概率之和
这两个公式属于核心
P(Y∣X)=∑π∈align(Y)P(π∣X) P(Y|X) = \sum_{\pi \in \text{align}(Y)} P(\pi|X) P(Y∣X)=π∈align(Y)∑P(π∣X)
Loss=−logP(Y∣X) \text{Loss} = -\log P(Y|X) Loss=−logP(Y∣X)
例如:
Y = "HI"
其中 H=1, I=2,blank=0
可能对齐:
0 H H H 0 I I
H 0 H 0 I I 0
0 H 0 H 0 I 0
你会看到 1000 帧对应 2 个字,会有巨量路径。
CTC 要做的,就是高效计算这个巨量路径概率之和。
核心作用:计算CTC损失函数
为什么需要计算所有路径? 因为单一路径很难直接获得,且训练目标是让模型所有正确的对齐路径概率之和最大。
如何高效计算? 直接枚举所有路径是指数级的,不可行。前向-后向算法利用动态规划,将复杂度降低到 O(T * U),其中 U = 2L + 1,是扩展后的序列长度(在目标标签间插入了空白符 blank)。
计算损失函数的梯度:
在神经网络训练中,我们不仅需要损失函数的值(通常为负对数似然:-log P(Y|X)),更需要该损失相对于模型每一个输出(在每一个时间步 t 对每一个标签 k 的概率)的梯度。
前向-后向算法在计算过程中,会自然地得到每个时间步、每个标签上的后验概率。这个后验概率 P(π_t = k | X, Y) 直接给出了"在已知输入和目标序列的条件下,在时间步t输出标签k"的概率期望。这个期望值正是计算梯度所需的关键中间量。
梯度可以简洁地表示为:
∂LCTC∂ytk=eytk∑jeytj−1P(Y∣X)∑uZu=kαt(u)βt(u) \frac{\partial \mathcal{L}{\text{CTC}}}{\partial y_t^k} = \frac{e^{y_t^k}}{\sum_j e^{y_t^j}} - \frac{1}{P(Y|X)} \sum{\substack{u \\ Z_u = k}} \alpha_t(u) \beta_t(u) ∂ytk∂LCTC=∑jeytjeytk−P(Y∣X)1uZu=k∑αt(u)βt(u)
(模型在t时刻预测k的概率) - (前向-后向算出的期望概率)**
这使得梯度计算非常高效,可以轻松地通过反向传播更新网络权重。
作用:
用途:仅用于训练阶段
目的:计算CTC损失函数的梯度
py
# 训练时:需要前向-后向算法
loss = ctc_loss(logits, labels, input_lengths, label_lengths)
loss.backward() # 内部使用前向-后向计算梯度
3.3 CTC 输出如何合并重复字符
假设以下是每一帧的概率分布
frame 0: [blank=0.6, A=0.3, B=0.1]
frame 1: [blank=0.1, A=0.8, B=0.1]
frame 2: [blank=0.05, A=0.9, B=0.05]
frame 3: [blank=0.8, A=0.1, B=0.1]
frame 4: [blank=0.1, A=0.1, B=0.8]
合并重复字符的规则(collapse rule)
假设原始出来的字符是:[blank, A, A, blank, B, B, B, blank, C]
1. 去掉 blank
[A, A, B, B, B, C]
2. 合并重复字符
[A,B,C]
总结来说就是:如果当前字符与前一个非 blank 字相同,则只保留一个;blank 可以分隔重复字符。
3.4 CTC Prefix Beam Search 原理
未完.