语音算法面试复习系列3——语音识别基础 + CTC 详解

🎙️ Day 2:语音识别基础 + CTC 详解

今天的目标:理解语音识别(ASR)为什么难,掌握端到端ASR的核心算法------CTC ,这是面试中必考的知识点。


第一步:语音识别的问题定义

1.1 ASR 到底在做什么?

复制代码
输入:  一段音频波形(或 FBank 特征序列)  X = (x₁, x₂, ..., x_T)   T 帧
输出:  一段文字序列                       Y = (y₁, y₂, ..., y_U)   U 个字/token
​
目标:  找到最可能的文字 Y* = argmax P(Y|X)

比如:

复制代码
输入: [80维FBank × 500帧]  (约5秒音频)
输出: "今天天气真不错"       (7个汉字)

1.2 为什么语音识别很难?

核心难点 = 对齐问题 (Alignment Problem)

复制代码
音频帧:   x₁  x₂  x₃  x₄  x₅  x₆  x₇  x₈  x₉  x₁₀ x₁₁ x₁₂ ...  (几百帧)
文字:     今      天      天      气      真      不  错              (7个字)
​
问题: 哪些帧对应"今"?哪些帧对应"天"? → 我们不知道!
  • 输入序列(帧)比输出序列(文字)长很多(通常 10~50 倍)

  • 我们没有"帧-字"的精确对齐标注(标注成本太高)

  • 不同人说话速度不同,同一个字对应的帧数不固定

🎯 CTC、Attention、Transducer 等算法的核心目的,都是解决这个对齐问题。


第二步:传统 ASR Pipeline(快速了解)

面试时偶尔会问到传统方法,了解框架即可,不需要深入。

2.1 传统三件套

复制代码
           ┌───────────────────────────────────────────────┐
           │              传统 ASR Pipeline                │
           │                                               │
音频 ─→ 特征提取 ─→ ┌──────────┐    ┌──────────┐    ┌──────────┐ ─→ 文字
         (MFCC)     │ 声学模型  │ ×  │ 语言模型  │ ×  │  解码器   │
                    │   (AM)   │    │   (LM)   │    │(Decoder) │
                    │ GMM-HMM  │    │  N-gram  │    │  WFST    │
                    └──────────┘    └──────────┘    └──────────┘
组件 作用 传统方法
声学模型 (AM) 给定音频,预测音素/状态的概率 GMM-HMM → DNN-HMM
语言模型 (LM) 哪些词序列更合理 N-gram
发音词典 词 → 音素序列的映射 人工编写
解码器 综合 AM+LM,搜索最优路径 WFST (加权有限状态转换器)

贝叶斯公式:

2.2 HMM 解决对齐问题的思路(简要了解)

复制代码
HMM 的做法:
  - 每个音素用 3-5 个状态表示
  - "你好" → n-i-h-ao → 每个音素3个状态 → 共12个HMM状态
  - 每个状态可以"自循环"(停留) 或 "跳转到下一个状态"
  - 通过 Viterbi 算法找到最优的 状态-帧 对齐
  
问题:
  - 需要音素级标注 / 发音词典
  - Pipeline 太复杂,每个组件分开优化
  - 错误会在组件间传播

这就是为什么端到端(End-to-End)方法兴起的原因 👇

2.3 端到端 ASR 的三大范式

复制代码
端到端 = 一个模型直接从音频到文字,不需要分开的AM/LM/Decoder
​
三大范式:
  ┌────────┐
  │  CTC   │ ← 今天重点学!
  └────────┘
  ┌────────────────────┐
  │  Attention Seq2Seq │ ← Day 3-4 学
  └────────────────────┘
  ┌──────────────┐
  │  Transducer  │ ← Day 3-4 学
  └──────────────┘

第三步:CTC 详解 ⭐⭐⭐⭐⭐

CTC (Connectionist Temporal Classification),2006年由 Alex Graves 提出。 这是面试中问得最多的语音算法之一,必须彻底理解。

3.1 CTC 解决什么问题?

回到对齐问题:

复制代码
输入:  T=12 帧的 FBank 特征
输出:  "CAT" (3个字符)
​
我们不知道哪些帧对应 C,哪些对应 A,哪些对应 T

CTC 的核心思想:不要管具体对齐,把所有可能的对齐方式都考虑进来,求和!

3.2 Blank 标记 ⭐

CTC 引入一个特殊标记 <blank> (通常用 -ε 表示),含义是"这一帧不输出任何字符"。

扩展后的输出词汇表:{a, b, c, ..., z, <blank>}{所有汉字, <blank>}

3.3 CTC 的工作流程

复制代码
              ┌──────────┐
音频特征 ──→  │ Encoder  │ ──→ 每帧一个概率分布(包含blank)
  (T帧)      │(RNN/CNN/ │      (T帧 × |Vocab+1| 维的矩阵)
              │Conformer)│
              └──────────┘
                   │
                   ▼
           ┌─────────────┐
           │  CTC 解码   │ ──→ 文字序列
           └─────────────┘

Step 1: Encoder 对每一帧输出一个概率分布

复制代码
帧1:  P(C)=0.3, P(A)=0.1, P(T)=0.1, P(--)=0.5, ...
帧2:  P(C)=0.6, P(A)=0.2, P(T)=0.0, P(--)=0.2, ...
帧3:  P(C)=0.1, P(A)=0.5, P(T)=0.1, P(--)=0.3, ...
...
帧12: P(C)=0.0, P(A)=0.1, P(T)=0.4, P(--)=0.5, ...
​
(-- 表示 blank)

Step 2 : 定义一个映射函数 B(多对一的折叠规则)

复制代码
折叠规则:
  1. 去掉所有 blank(-)
  2. 合并连续的相同字符
​
例子:
  B("C C - A - - T T -") = "CAT"  ✅
  B("- C A - T - - - -") = "CAT"  ✅  
  B("C - C A T - - - -") = "CCAT" → 不对!这会变成 "CCAT"
  
等等... 仔细看:
  B("C - C A T") → 去blank → "CCAT" → 合并连续 → "CAT"  ❌
  
  不!先合并再去blank 还是 先去blank再合并?
  正确顺序是:先合并连续相同字符,再去掉blank
  B("C C - A - T T") → 合并连续: "C - A - T" → 去blank: "CAT"  ✅
  
  或者等价地:先去blank,再合并连续
  B("C C - A - T T") → 去blank: "C C A T T" → 合并: "CAT"  ✅

🔥 面试关键问题:如何区分 "hello" 中的两个 l?

"h e l - l o" → 合并连续相同 → "h e l - l o" → 去blank → "h e l l o" = "hello" ✅

如果没有 blank:"h e l l o" → 合并连续 → "h e l o" = "helo" ❌

所以 blank 的另一个重要作用是:分隔连续的相同字符!

3.4 CTC 训练:前向算法

训练时,我们要最大化:

即:所有能折叠成目标文字 Y 的路径 π 的概率之和

每条路径的概率 = 各帧概率的乘积(条件独立性假设):

问题: 路径数量是指数级的!不可能枚举。

解决方案: 动态规划(前向-后向算法)

3.5 前向算法详解

关键构造: 在目标序列中插入 blank

复制代码
目标: "CAT"
扩展: "- C - A - T -"
索引:  1 2 3 4 5 6 7
​
扩展序列长度 = 2U + 1 = 2×3 + 1 = 7
(每个字符前后都插入 blank)

定义前向变量:

\\alpha(t, s) = \\text{在时刻 } t \\text{,处于扩展序列第 } s \\text{ 个位置的所有合法路径的概率和}

状态转移规则:

复制代码
扩展序列:  -  C  -  A  -  T  -
索引 s:    1  2  3  4  5  6  7
​
在每个时刻 t,位置 s 可以从哪里来?
​
规则:
  (a) 自循环:s → s  (停留在同一个位置)
  (b) 向前一步:s-1 → s (移动到下一个位置)
  (c) 跳一步:s-2 → s (跳过中间的blank,但仅当 s 和 s-2 不是同一个字符时)

为什么有规则 (c) 的限制?

复制代码
如果扩展序列是 "- C - A - T -"
​
位置5(-) 可以从位置3(-) 跳过来? 不行!因为位置3和5都是blank,
  跳过4(A)会导致漏掉字符A
​
位置4(A) 可以从位置2(C) 跳过来? 可以!因为它们不同,
  跳过blank(位置3)是合法的
​
位置6(T) 可以从位置4(A) 跳过来? 可以!因为它们不同

公式:

3.6 前向算法可视化

用一个简单的例子来画出整个格子:

复制代码
目标: "AB"
扩展: "- A - B -"  (长度5)
​
时间 t →      1     2     3     4     5     6
           ┌─────┬─────┬─────┬─────┬─────┬─────┐
  s=1 (-)  │α1,1 │α2,1 │α3,1 │α4,1 │α5,1 │α6,1 │
           ├─────┼─────┼─────┼─────┼─────┼─────┤
  s=2 (A)  │α1,2 │α2,2 │α3,2 │α4,2 │α5,2 │α6,2 │
           ├─────┼─────┼─────┼─────┼─────┼─────┤
  s=3 (-)  │  0  │α2,3 │α3,3 │α4,3 │α5,3 │α6,3 │
           ├─────┼─────┼─────┼─────┼─────┼─────┤
  s=4 (B)  │  0  │  0  │α3,4 │α4,4 │α5,4 │α6,4 │
           ├─────┼─────┼─────┼─────┼─────┼─────┤
  s=5 (-)  │  0  │  0  │  0  │α4,5 │α5,5 │α6,5 │
           └─────┴─────┴─────┴─────┴─────┴─────┘
​
初始化:
  α(1,1) = P(blank|x₁)     ← 从 blank 开始
  α(1,2) = P(A|x₁)         ← 从 A 开始
  α(1,s) = 0, s > 2        ← 不可能一开始就在后面的位置
​
最终结果:
  P("AB"|X) = α(T, 4) + α(T, 5)
              ↑B结尾      ↑blank结尾
  (必须走到最后一个或倒数第二个位置)

转移示意:

复制代码
α(t,s) 的来源箭头示意(以 s=4(B) 为例):
​
  t-1          t
  ┌──┐       ┌──┐
  │s=2│(A)──→│   │  ← 从A跳过blank到B(规则c,A≠B,合法)
  ├──┤   ↗   │s=4│
  │s=3│(-)──→│(B)│  ← 从blank到B(规则b)
  ├──┤   ↗   │   │
  │s=4│(B)──→│   │  ← 自循环(规则a)
  └──┘       └──┘
  
所以: α(t,4) = [α(t-1,4) + α(t-1,3) + α(t-1,2)] × P(B|x_t)

3.7 用代码实现 CTC 前向算法

复制代码
import numpy as np
​
def ctc_forward(log_probs, targets, blank=0):
    """
    CTC 前向算法
    
    Args:
        log_probs: (T, V) 每帧的对数概率,V是词汇表大小(含blank)
        targets: 目标序列(不含blank),如 [1, 2] 代表 "AB"
        blank: blank 的索引
    
    Returns:
        log_likelihood: log P(Y|X)
    """
    T, V = log_probs.shape
    U = len(targets)
    
    # Step 1: 构建扩展序列 (插入blank)
    # "AB" → [blank, A, blank, B, blank]
    L = 2 * U + 1
    extended = np.zeros(L, dtype=int)
    for i in range(U):
        extended[2 * i] = blank
        extended[2 * i + 1] = targets[i]
    extended[-1] = blank
    
    print(f"目标序列: {targets}")
    print(f"扩展序列: {extended}")
    print(f"T={T} 帧, 扩展序列长度 L={L}")
    
    # Step 2: 初始化 alpha 矩阵 (用log域计算避免下溢)
    # 用 -inf 表示概率为 0
    LOG_ZERO = -float('inf')
    alpha = np.full((T, L), LOG_ZERO)
    
    # t=0 时刻,只能从位置0(blank)或位置1(第一个字符)开始
    alpha[0, 0] = log_probs[0, extended[0]]  # blank
    alpha[0, 1] = log_probs[0, extended[1]]  # 第一个字符
    
    # Step 3: 递推
    for t in range(1, T):
        for s in range(L):
            # 当前扩展序列位置 s 对应的字符
            current_label = extended[s]
            
            # 规则 a: 自循环 (s → s)
            score = alpha[t-1, s]
            
            # 规则 b: 从前一个位置来 (s-1 → s)
            if s >= 1:
                score = np.logaddexp(score, alpha[t-1, s-1])
            
            # 规则 c: 跳一步 (s-2 → s)
            # 条件: s >= 2 且 当前不是blank 且 当前字符 ≠ s-2位置的字符
            if s >= 2 and current_label != blank and current_label != extended[s-2]:
                score = np.logaddexp(score, alpha[t-1, s-2])
            
            # 乘以当前帧输出该字符的概率
            alpha[t, s] = score + log_probs[t, current_label]
    
    # Step 4: 最终结果
    # 合法的结束位置:最后一个blank(L-1) 或 最后一个字符(L-2)
    log_likelihood = np.logaddexp(alpha[T-1, L-1], alpha[T-1, L-2])
    
    return log_likelihood, alpha
​
​
# ========== 测试 ==========
np.random.seed(42)
​
T = 8   # 8 帧
V = 4   # 词汇表: {0:blank, 1:A, 2:B, 3:C}
​
# 模拟 Encoder 输出的 log 概率
logits = np.random.randn(T, V)
# Softmax + log
log_probs = logits - np.log(np.sum(np.exp(logits), axis=1, keepdims=True))
​
# 目标: "AB" → [1, 2]
targets = [1, 2]
​
log_likelihood, alpha = ctc_forward(log_probs, targets, blank=0)
print(f"\nlog P('AB'|X) = {log_likelihood:.4f}")
print(f"P('AB'|X) = {np.exp(log_likelihood):.6f}")
​
# 可视化 alpha 矩阵
import matplotlib.pyplot as plt
​
plt.figure(figsize=(10, 5))
# 将 -inf 替换为一个很小的数用于显示
alpha_display = np.where(alpha == -float('inf'), np.nan, alpha)
plt.imshow(alpha_display.T, aspect='auto', origin='lower', cmap='viridis')
plt.colorbar(label='Log Probability')
plt.xlabel('Time (t)')
plt.ylabel('Extended label index (s)')
labels = ['blank', 'A', 'blank', 'B', 'blank']
plt.yticks(range(len(labels)), labels)
plt.title('CTC Forward Algorithm — Alpha Matrix')
plt.show()

第四步:CTC 训练与解码

4.1 CTC Loss

训练时,我们最大化 (P(Y|X)),等价于最小化:

PyTorch 已经内置了 CTC Loss:

复制代码
import torch
import torch.nn as nn
​
# ========== PyTorch CTC Loss 使用方法 ==========
ctc_loss = nn.CTCLoss(blank=0, reduction='mean', zero_infinity=True)
​
# 模拟数据
T = 50        # 输入序列长度(帧数)
B = 4         # batch size
V = 30        # 词汇表大小(含blank)
U_max = 10    # 最大目标序列长度
​
# Encoder 输出 (需要 log_softmax)
# 注意: PyTorch CTC 要求输入形状 (T, B, V)
log_probs = torch.randn(T, B, V).log_softmax(dim=2)
​
# 目标序列 (不含blank, 所有batch的目标拼接成一维)
targets = torch.randint(1, V, (B, U_max))  # (B, U_max), 值在 [1, V-1]
​
# 输入长度和目标长度
input_lengths = torch.full((B,), T, dtype=torch.long)
target_lengths = torch.randint(3, U_max+1, (B,), dtype=torch.long)
​
# 计算 CTC Loss
loss = ctc_loss(log_probs, targets, input_lengths, target_lengths)
print(f"CTC Loss: {loss.item():.4f}")

4.2 CTC 解码方法

训练完模型后,推理时我们需要解码------从 Encoder 输出的概率矩阵中找到最可能的文字。

方法1:贪心解码 (Greedy Decoding)
复制代码
每帧取概率最大的标记 → 折叠 → 文字

帧:    1   2   3   4   5   6   7   8
最大:  -   C   C   -   A   -   T   T
折叠:  → "C - A - T" → 去blank、合并 → "CAT"
复制代码
def ctc_greedy_decode(log_probs, blank=0, idx_to_char=None):
    """
    CTC 贪心解码
    
    Args:
        log_probs: (T, V) 每帧的log概率
        blank: blank索引
        idx_to_char: 索引到字符的映射
    
    Returns:
        decoded: 解码后的字符列表
    """
    # Step 1: 每帧取 argmax
    best_path = np.argmax(log_probs, axis=1)
    print(f"Best path (argmax): {best_path}")
    
    # Step 2: 折叠 — 合并连续相同 + 去掉blank
    decoded = []
    prev = None
    for idx in best_path:
        if idx != prev:       # 合并连续相同
            if idx != blank:  # 去掉blank
                decoded.append(idx)
        prev = idx
    
    if idx_to_char:
        decoded = [idx_to_char[i] for i in decoded]
    
    return decoded
​
​
# 测试
np.random.seed(0)
T, V = 12, 5  # 12帧, 词汇表 {0:blank, 1:C, 2:A, 3:T, 4:E}
idx_to_char = {0: '-', 1: 'C', 2: 'A', 3: 'T', 4: 'E'}
​
# 构造一个"比较像CAT"的概率矩阵
log_probs = np.full((T, V), -5.0)  # 初始化很小的概率
# 让前几帧倾向C,中间帧倾向A,后面帧倾向T
for t in range(3):
    log_probs[t, 1] = -0.2   # C
    log_probs[t, 0] = -1.0   # blank
for t in range(3, 5):
    log_probs[t, 0] = -0.1   # blank
for t in range(5, 8):
    log_probs[t, 2] = -0.3   # A
    log_probs[t, 0] = -1.5
for t in range(8, 10):
    log_probs[t, 0] = -0.1   # blank
for t in range(10, 12):
    log_probs[t, 3] = -0.2   # T
    log_probs[t, 0] = -1.0
​
result = ctc_greedy_decode(log_probs, blank=0, idx_to_char=idx_to_char)
print(f"Decoded: {''.join(result)}")

贪心解码可能不是全局最优的。Beam Search 保留 top-k 个候选:

复制代码
Beam Width = 3
​
时刻1: 保留概率最高的3个前缀
  → ["-", "C", "A"]
​
时刻2: 每个前缀扩展所有可能字符,保留总概率最高的3个
  → ["C-", "CC", "-C"]  (举例)
​
...每步都保留 top-k
​
最终: 选概率最高的那个前缀
复制代码
def ctc_beam_search(log_probs, blank=0, beam_width=5, idx_to_char=None):
    """
    CTC Beam Search 解码(简化版)
    
    Args:
        log_probs: (T, V) 每帧的log概率
        blank: blank索引  
        beam_width: beam 宽度
    
    Returns:
        best_sequence: 最佳解码序列
    """
    T, V = log_probs.shape
    
    # 每个 beam 是 (前缀tuple, log概率)
    # 用两个字典分别追踪以blank结尾和以非blank结尾的前缀
    beams = {(): 0.0}  # 初始: 空前缀, log概率=0
    
    for t in range(T):
        new_beams = {}
        
        for prefix, score in beams.items():
            for c in range(V):
                new_score = score + log_probs[t, c]
                
                if c == blank:
                    # blank不改变前缀
                    new_prefix = prefix
                elif len(prefix) > 0 and c == prefix[-1]:
                    # 和前缀最后一个字符相同
                    # 如果上一步是blank,则可以添加新字符(但简化版这里直接合并)
                    new_prefix = prefix  # 简化:合并连续相同
                else:
                    new_prefix = prefix + (c,)
                
                if new_prefix in new_beams:
                    new_beams[new_prefix] = np.logaddexp(new_beams[new_prefix], new_score)
                else:
                    new_beams[new_prefix] = new_score
        
        # 剪枝: 只保留 top beam_width 个
        sorted_beams = sorted(new_beams.items(), key=lambda x: x[1], reverse=True)
        beams = dict(sorted_beams[:beam_width])
    
    # 返回概率最高的前缀
    best_prefix = max(beams, key=beams.get)
    
    if idx_to_char:
        return ''.join([idx_to_char[i] for i in best_prefix])
    return best_prefix
​
​
# 测试
result_beam = ctc_beam_search(log_probs, blank=0, beam_width=5, idx_to_char=idx_to_char)
print(f"Beam Search Decoded: {result_beam}")

注意:上面的 Beam Search 是简化版本。完整的 CTC Prefix Beam Search 需要正确处理"以blank结尾"和"以非blank结尾"两种情况,以正确处理重复字符。面试中理解核心思想即可。


第五步:CTC 的条件独立性假设 ⭐(面试重点)

5.1 CTC 的核心假设

回忆 CTC 路径概率的计算:

这意味着:每一帧的输出只依赖于输入 X(通过 Encoder),不依赖于其他帧的输出。

换句话说:各帧的输出之间是条件独立的(给定 X)。

5.2 这带来什么问题?

复制代码
假设 Encoder 对输入 X 的第 t 帧预测:
  P("猫"|x_t) = 0.4
  P("毛"|x_t) = 0.35
  P("帽"|x_t) = 0.25
​
CTC 选了"猫"—— 但它不知道前面已经输出了"小",
如果知道前面是"小","猫"更合理;
如果前面是"帽子的","帽"更合理。
​
CTC 做不到这种"根据已输出内容动态调整后续输出"的能力!

这就是 CTC 的根本局限: 它不能建模输出标签之间的依赖关系。

5.3 对比

特性 CTC Attention Seq2Seq
输出依赖 各帧独立(条件独立假设) Decoder自回归,能看到之前的输出
对齐方式 隐式对齐(求和所有路径) 显式对齐(Attention权重)
解码速度 快(非自回归/并行) 慢(自回归,逐步生成)
适合流式 ✅ 天然支持 ❌ 需要整个输入

5.4 CTC 解决独立性问题的方法

虽然 CTC 本身有条件独立假设,实际使用中通过以下方式缓解:

  1. 外部语言模型 (LM):解码时结合 N-gram 或 Neural LM(shallow fusion)

  2. Hybrid CTC/Attention:联合训练 CTC + Attention,取长补短(WeNet 的做法)

  3. 强大的 Encoder:Conformer 等强 Encoder 已经在编码阶段捕获了丰富的上下文


第六步:CTC 优缺点完整总结 ⭐⭐⭐

优点

复制代码
✅ 不需要帧级对齐标注 — 只需要 (音频, 文字) 对
✅ 训练简单 — 只需要一个 Encoder + CTC Loss
✅ 天然支持流式 — 每帧独立输出,不需要看到未来
✅ 解码速度快 — 尤其贪心解码
✅ 单调对齐 — 输入输出顺序一致(适合ASR这种单调任务)

缺点

复制代码
❌ 条件独立性假设 — 不能建模输出间依赖
❌ 需要输入长度 ≥ 输出长度 — 否则无法形成合法路径
❌ 只适合单调对齐任务 — 不适合机器翻译等重排序任务
❌ 输出是 token 级别 — 没有显式的注意力对齐,可解释性差
❌ 容易出现尖峰问题 (peaky behavior) — 大部分帧输出 blank,
   真正的信息集中在少数帧

Peaky Behavior(尖峰行为)

复制代码
帧:      1   2   3   4   5   6   7   8   9   10  11  12
CTC输出: -   -   -   C   -   -   A   -   -   -   T   -
                     ↑               ↑               ↑
            大部分帧输出blank,真正的字符只在很少几帧出现"尖峰"
​
这导致:
  - 对 Encoder 的利用不够充分
  - 大量帧的梯度信号很弱(都是blank)

第七步:CTC 在实际框架中的应用

7.1 用 PyTorch 搭一个最简单的 CTC-based ASR 模型

复制代码
import torch
import torch.nn as nn
​
class SimpleCTCModel(nn.Module):
    """
    最简单的 CTC ASR 模型:
    FBank → 几层LSTM → Linear → CTC
    """
    def __init__(self, input_dim=80, hidden_dim=256, num_layers=3, vocab_size=4233):
        """
        Args:
            input_dim: 输入特征维度(FBank=80)
            hidden_dim: LSTM 隐藏层维度
            num_layers: LSTM 层数
            vocab_size: 词汇表大小(含blank,blank=0)
        """
        super().__init__()
        
        # Encoder: 双向 LSTM
        self.encoder = nn.LSTM(
            input_size=input_dim,
            hidden_size=hidden_dim,
            num_layers=num_layers,
            batch_first=True,
            bidirectional=True,
            dropout=0.1
        )
        
        # 投影层: 将 LSTM 输出映射到词汇表大小
        self.fc = nn.Linear(hidden_dim * 2, vocab_size)  # *2 因为双向
        
        self.ctc_loss = nn.CTCLoss(blank=0, reduction='mean', zero_infinity=True)
    
    def forward(self, x, x_lengths):
        """
        Args:
            x: (B, T, 80) FBank 特征
            x_lengths: (B,) 每个样本的实际长度
        Returns:
            log_probs: (T, B, V) 用于 CTC Loss
        """
        # Pack padded sequence for efficiency
        packed = nn.utils.rnn.pack_padded_sequence(
            x, x_lengths.cpu(), batch_first=True, enforce_sorted=False
        )
        output, _ = self.encoder(packed)
        output, _ = nn.utils.rnn.pad_packed_sequence(output, batch_first=True)
        # output: (B, T, hidden*2)
        
        logits = self.fc(output)  # (B, T, V)
        log_probs = logits.log_softmax(dim=2)  # (B, T, V)
        
        # CTC 要求 (T, B, V)
        log_probs = log_probs.permute(1, 0, 2)
        
        return log_probs
    
    def compute_loss(self, log_probs, targets, input_lengths, target_lengths):
        """计算 CTC Loss"""
        return self.ctc_loss(log_probs, targets, input_lengths, target_lengths)
    
    def decode(self, log_probs):
        """贪心解码"""
        # log_probs: (T, B, V)
        # argmax
        best_path = log_probs.argmax(dim=2).T  # (B, T)
        
        results = []
        for b in range(best_path.shape[0]):
            # 折叠
            path = best_path[b].tolist()
            decoded = []
            prev = None
            for idx in path:
                if idx != prev:
                    if idx != 0:  # 0 = blank
                        decoded.append(idx)
                prev = idx
            results.append(decoded)
        
        return results
​
​
# ========== 测试模型 ==========
model = SimpleCTCModel(input_dim=80, hidden_dim=256, num_layers=3, vocab_size=100)
​
# 模拟一个batch
B = 2
T1, T2 = 150, 200  # 两个样本的帧数不同
T_max = max(T1, T2)
​
x = torch.randn(B, T_max, 80)  # FBank 特征
x_lengths = torch.tensor([T1, T2])
​
# 前向传播
log_probs = model(x, x_lengths)
print(f"Encoder output shape: {log_probs.shape}")  # (T, B, V)
​
# 模拟目标
targets = torch.randint(1, 100, (B, 15))  # 两个样本各15个字
target_lengths = torch.tensor([12, 15])
​
# 计算 Loss
loss = model.compute_loss(log_probs, targets, x_lengths, target_lengths)
print(f"CTC Loss: {loss.item():.4f}")
​
# 贪心解码
decoded = model.decode(log_probs)
print(f"Decoded lengths: {[len(d) for d in decoded]}")
print(f"Decoded[0] (前20个token): {decoded[0][:20]}")

7.2 Subsampling(下采样)------ 实际系统必备

实际的 ASR 系统中,原始 FBank 帧率太高了(每秒 100 帧),直接输入 Encoder 计算量巨大。通常会先下采样(通常 4 倍):

复制代码
原始: 100 帧/秒
           ↓  4倍下采样(Conv2d stride=2 做两次)
下采样后: 25 帧/秒
​
方法:
  1. Conv2d Subsampling (Conformer/WeNet常用)
  2. Stacking + Stride (传统方法: 把连续4帧拼起来)
复制代码
class ConvSubsampling(nn.Module):
    """
    2层卷积实现 4 倍下采样(WeNet / Conformer 中的标准做法)
    """
    def __init__(self, input_dim=80, output_dim=256):
        super().__init__()
        self.conv = nn.Sequential(
            nn.Conv2d(1, 32, kernel_size=3, stride=2, padding=1),  # 2倍下采样
            nn.ReLU(),
            nn.Conv2d(32, 32, kernel_size=3, stride=2, padding=1),  # 再2倍 → 共4倍
            nn.ReLU(),
        )
        # 计算卷积输出维度
        # 输入: (B, 1, T, 80), 经过两次 stride=2:
        # 频率维度: 80 → 40 → 20
        # 时间维度: T → T/2 → T/4
        self.linear = nn.Linear(32 * 20, output_dim)  # 32通道 × 20频率bins
    
    def forward(self, x, x_lengths):
        """
        Args:
            x: (B, T, 80)
            x_lengths: (B,)
        Returns:
            out: (B, T/4, output_dim)
            out_lengths: (B,)
        """
        # 添加 channel 维度
        x = x.unsqueeze(1)  # (B, 1, T, 80)
        x = self.conv(x)     # (B, 32, T/4, 20)
        
        B, C, T_sub, F = x.shape
        x = x.permute(0, 2, 1, 3).contiguous().view(B, T_sub, C * F)  # (B, T/4, 32*20)
        x = self.linear(x)   # (B, T/4, output_dim)
        
        # 更新长度
        out_lengths = ((x_lengths - 1) // 2 - 1) // 2 + 1  # 近似 T/4
        
        return x, out_lengths
​
​
# 测试
subsampling = ConvSubsampling(input_dim=80, output_dim=256)
x = torch.randn(2, 200, 80)
x_lens = torch.tensor([200, 150])
out, out_lens = subsampling(x, x_lens)
print(f"Input: {x.shape} → Output: {out.shape}")
print(f"Input lengths: {x_lens} → Output lengths: {out_lens}")
# 200帧 → 约50帧, 4倍下采样

🔥 面试常问:为什么要做 Subsampling?

  1. 减少计算量:Transformer/Conformer 的 self-attention 复杂度是 (O(T^2)),下采样 4 倍可以减少 16 倍的 attention 计算

  2. CTC 需要:CTC 要求输入长度 ≥ 输出长度。中文 ASR 中一个字约对应 4-8 帧(下采样后约 1-2 帧),下采样后更接近输出长度

  3. 帧率降低但信息不丢:卷积可以聚合局部信息


第八步:CTC 与其他方法的对比预览

这个表非常重要,面试必考

复制代码
                    CTC           Attention         Transducer (RNN-T)
                  ─────────     ─────────────     ──────────────────
结构         Encoder only    Encoder-Decoder     Encoder + Prediction
                                                 Network + Joiner
​
对齐方式      隐式对齐         显式对齐            隐式对齐
             (前向算法求和)    (Attention权重)     (前向算法求和)
​
输出依赖      ❌ 条件独立      ✅ 自回归          ✅ Prediction Network
                             (看到之前输出)      (看到之前输出)
​
流式支持      ✅ 天然支持      ❌ 需要整个输入     ✅ 天然支持
​
单调性        ✅ 单调         ❌ 可非单调         ✅ 单调
​
训练难度       简单            中等               较难
                                              (内存消耗大)
​
代表框架     WeNet(CTC分支)   ESPnet             Google USM
             FunASR          Whisper             字节Seed-ASR
​
实际使用     通常和Attention   通常和CTC联合      流式场景首选
            联合训练          训练(Hybrid)

📝 Day 2 面试模拟题

Q1: CTC 是什么?解决了什么问题?

CTC (Connectionist Temporal Classification) 是一种用于序列到序列映射的训练准则,主要解决输入输出不等长且没有帧级对齐标注的问题。它通过引入 blank 标记,枚举所有可能的对齐路径并求和,使用动态规划(前向-后向算法)高效计算。这样模型只需要 (音频, 文字) 对就能训练,不需要精确的帧-字对齐。


Q2: CTC 中 blank 的作用是什么?

Blank 有两个核心作用:

  1. 处理"不输出"的帧:语音中很多帧不对应任何有意义的字符(如静音、过渡段),blank 允许模型在这些帧"不输出"

  2. 分隔连续的相同字符:比如 "hello" 中的两个 l,如果没有 blank,"ll" 会被合并成 "l"。通过 "l-l"(中间插入 blank),折叠后得到 "ll"


Q3: CTC 的条件独立性假设是什么?有什么影响?

CTC 假设各帧的输出在给定输入 X 的条件下是条件独立的,即 (P(\pi|X) = \prod_t P(\pi_t|X))。这意味着 CTC 不能建模输出标签之间的依赖关系------每帧的预测不会参考之前已输出的内容。

影响:可能产生不合理的输出序列(如不通顺的文字)。实际中通过外部语言模型 (浅融合/重打分)或与 Attention 联合训练(Hybrid CTC/Attention)来弥补。


Q4: CTC 有什么优缺点?

优点:

  • 不需要帧级对齐标注,只需 (音频, 文字) 对

  • 训练简单,只需 Encoder + CTC Loss

  • 天然支持流式识别(每帧独立输出)

  • 解码速度快

缺点:

  • 条件独立性假设,不能建模输出间依赖

  • 输入长度必须 ≥ 输出长度

  • 只适合单调对齐任务

  • Peaky behavior:大部分帧输出 blank,信息集中在少数帧


Greedy Decoding:每帧取概率最大的标记,然后折叠。速度最快,但只考虑了局部最优,可能不是全局最优。

Beam Search:每步保留概率最高的 top-k 个候选前缀,逐帧扩展。能搜索到更好的解,但速度较慢。还可以结合外部语言模型(LM fusion)进一步提升效果。

实际使用中,如果对延迟要求高,用 Greedy;对准确率要求高,用 Beam Search + LM。


Q6: 为什么实际系统中要做 Subsampling?

  1. 减少计算量:原始 FBank 帧率 100帧/秒,Transformer self-attention 复杂度 O(T²),4倍下采样减少 16 倍计算

  2. 降低内存占用:序列更短,注意力矩阵更小

  3. CTC 约束:CTC 要求输入长度 ≥ 输出长度,下采样后帧数与字符数更匹配

  4. 不丢信息:卷积下采样通过卷积核聚合局部信息,比简单跳帧更好


✅ Day 2 完成!核心知识清单

复制代码
✅ ASR 的问题定义:输入长序列→输出短序列,对齐未知
✅ 传统 ASR Pipeline:AM + LM + Decoder(了解即可)
✅ 端到端三大范式:CTC / Attention / Transducer
✅ CTC 的核心思想:blank + 所有路径求和
✅ Blank 的两个作用:处理"不输出"帧 + 分隔相同字符
✅ CTC 前向算法:扩展序列 + 动态规划
✅ CTC 折叠规则:合并连续相同 + 去blank
✅ CTC Loss = -log P(Y|X)
✅ 贪心解码 vs Beam Search
✅ 条件独立性假设及其影响
✅ CTC 优缺点(面试必背)
✅ CTC Peaky Behavior
✅ Subsampling 的作用
✅ CTC vs Attention vs Transducer 对比(预览)

🚀 Day 3 预告

明天我们学习 ASR 的另外两大范式:

  1. Attention-based Seq2Seq:Encoder-Decoder + Attention 机制

  2. Transducer (RNN-T):CTC 的自然升级版

  3. Hybrid CTC/Attention:WeNet 的核心架构

  4. 三种方法的深度对比


💡 作业

  1. 手动在纸上画出 "CAT" 的 CTC 前向算法格子图,标出所有合法转移

  2. 不看笔记,用自己的话解释 CTC 的 blank 为什么必要

  3. 用自己的话解释 CTC 条件独立性假设的影响

  4. 跑通上面的代码,特别是手写前向算法那段

相关推荐
fanxianshi1 天前
2026 年 3 月行业动态与开源生态全景报告
人工智能·深度学习·神经网络·机器学习·计算机视觉·开源·语音识别
Dev7z2 天前
基于MATLAB改进小波阈值函数的信号降噪方法研究
人工智能·语音识别
EasyDSS2 天前
EasyDSS如何基于LiveKit/AI大模型/AI会议助手/语音转写STT技术破解音视频应用核心痛点
人工智能·音视频·webrtc·语音识别·点播技术·流媒体直播
ViiTor_AI2 天前
ElevenLabs 语音克隆工具深度评测:价格、功能与最佳替代方案
人工智能·语音识别
HySpark2 天前
解决语音角色识别中的误识别与长会漂移问题(陌生人机制 + 稳定性规则)
人工智能·语音识别
EasyDSS4 天前
音视频技术迭代下EasyDSS直播点播视频会议能力的发展方向与价值升级
音视频·webrtc·语音识别·点播技术·流媒体直播
EasyDSS4 天前
WebRTC/语音转文字STT/AI语言大模型重构EasyDSS视频会议
音视频·webrtc·语音识别·hls·实时字幕
开开心心就好4 天前
Word批量转PDF工具,仅转换不合并很实用
java·前端·人工智能·edge·pdf·语音识别·模块测试