前言

本文通过梳理 WeNet 工具包 LibriSpeech 的语音识别流程,理解 WeNet 是如何采用 U2(Unified Two-pass)架构从算法侧到工程侧实现了流式(Streaming)/ 非流式(Non-streaming)语音识别的统一。基于 two-pass 的架构让同一套模型参数既支持实时低延迟的流式识别(如语音助手、直播字幕),也支持高精度的非流式识别(如语音转写、录音分析),无需为两种场景单独训练、部署模型,大幅降低工业开发成本。
因为内容较多,拆成了上下两篇,上篇主要是数据处理,下篇是 CTC/AED 核心算法。

一、脚本流程

代码流程可参考 run.sh,配置文件采用 train_u2++_conformer.yaml。

配置文件 use_dynamic_chunk causal 备注
train_u2++_conformer.yaml true true 标准 U2++
train_u2++_conformer_lowmem.yaml true true 显存优化版
train_u2++_squeezeformer.yaml true true Squeezeformer 流式
train_u2++_branchformer.yaml true true Branchformer 流式
train_unified_conformer.yaml true true 单 decoder,非 BiTransformer
train_u2++_efficonformer_v1.yaml true false 非因果
train_u2++_efficonformer_v2.yaml true false 非因果
Stage -1:数据下载
从 www.openslr.org/resources/12 下载 LibriSpeech
子集:dev-clean, test-clean, dev-other, test-other, train-clean-100, train-clean-360, train-other-500
使用 local/download_and_untar.sh 下载并解压
Stage 0:数据准备
对每个子集调用 local/data_prep_torchaudio.sh
生成标准数据目录结构,包括 wav.scp、text,wav.scp 保存音频位置,text 保存转录信息,如
	103-1240-0015 IF HE'D RUN OUT OF TURNIP SEED HE WOULDN'T DRESS UP AND TAKE THE BUGGY TO GO FOR MORE
输出目录形如:data/dev_clean, data/test_clean, data/train_clean_100 等
Stage 1:特征与数据组织
合并训练集:train_clean_100 + train_clean_360 + train_other_500 → train_960
合并验证集:dev_clean + dev_other → dev
使用 tools/compute_cmvn_stats.py 计算全局 CMVN 统计量
输出 global_cmvn,供训练和推理时做特征归一化
Stage 2:词表与 BPE
构建字典 data/lang_char/train_960_unigram5000_units.txt
用 SentencePiece 训练 unigram BPE(nbpe=5000)
生成 BPE 模型和词表,用于 subword 表示

这里说明下词表构建。BPE(Byte Pair Encoding)是字节对编码,是当前主流的子词分词算法,介于字符级和单词级之间。核心思想是用数据驱动的方式,自动把高频出现的连续字符组合合并成一个 “子词”,最终词汇表里是 字符 + 高频子词。目标是用最小的词汇表,覆盖最多的文本,同时不产生太长序列。
英文如果单词表建模,词表会很大(轻松 10w+),模型参数巨大。OOV(Out Of Vocabulary)一大堆,导致识别错误。如果字符建模,英文一共 26 个字母加标点,词汇表太小,序列会非常长,一句话可能变成上百字符,模型建模长序列依赖困难;没有语义信息。如果是 BPE 建模,粒度就比较合适,可以学到一些词根和词缀,搭配上字符,可以完美解决 OOV 问题。词汇表小、序列短、训练稳。
比如在 Stage 2 中,cut -f 2- -d" " $wave_data/${train_set}/text > $wave_data/lang_char/input.txt,输出 input.txt,每行一条纯文本,用于 BPE 训练。
示例:

# text 原文
103-1240-0000 CHAPTER ONE MISSUS RACHEL LYNDE...

# input.txt 结果
CHAPTER ONE MISSUS RACHEL LYNDE...

训练 BPE 模型

tools/spm_train --input=$wave_data/lang_char/input.txt \
  --vocab_size=${nbpe} \           # 5000 个 subword
  --model_type=${bpemode} \        # unigram
  --model_prefix=${bpemodel} \     # 输出前缀
  --input_sentence_size=100000000  # 使用尽可能多的句子
  • 用 SentencePiece 在 input.txt 上训练 unigram BPE。

  • 生成:train_960_unigram5000.model、train_960_unigram5000.vocab。

  • 作用:把文本切分成 subword,平衡词表大小和建模粒度。

从 BPE 编码结果构建 Wenet 符号表

tools/spm_encode --model=${bpemodel}.model --output_format=piece < $wave_data/lang_char/input.txt \
  | tr ' ' '\n' \       # 空格换行,每个 token 一行
  | sort | uniq \       # 去重并排序
  | awk '{print $0 " " NR+2}' >> ${dict}   # 从 3 开始赋 id
  1. spm_encode:用 .model 把 input.txt 全部转成 piece 序列。
  2. tr ’ ’ ‘\n’:把空格换成换行,每个 piece 一行。
  3. sort | uniq:按字母序排序并去重,得到所有出现过的 subword。
  4. awk ‘{print $0 " " NR+2}’:为每个 token 分配从 3 开始的 id(0、1、2 已被特殊符号占用)。
符号 ID 用途
<blank> 0 CTC blank,必须为 0,CTC 输出在 blank 和非 blank 间切换
<unk> 1 未知 token,OOV 时使用
<sos/eos> 2 decoder 的开始/结束,统一用 2

最后生成的字典 train_960_unigram5000_units.txt 形式如下

<blank> 0
<unk> 1
<sos/eos> 2
' 3
▁ 4
▁A 5
A 6
▁ABANDON 7
ABETH 8
......

它在训练的时候,文本先经过 BPE 切分,用字典(符号表)把 token 映射成 id 送入模型。CTC head 和 decoder 输出维数等于 vocab_size,对应符号表大小。解码的时候,模型输出 id 通过符号表查回 token,拼接成最终文本。

Stage 3:Wenet 数据格式
对每个数据集(dev、test_clean、test_other、train_960 等):
 - 调用 tools/make_raw_list.py
 - 从 wav.scp 和 text 生成 data.list
data.list 是 Wenet 训练与解码所需格式

make_raw_list.py 会生成 data.list,每行一个 JSON 对象,例如:

{"key": "103-1240-0000", "wav": "/path/to/103-1240-0000.flac", "txt": "CHAPTER ONE..."}
字段 说明
key utterance id,便于对齐和调试
wav 音频路径,DataLoader 按此加载
txt 转写文本,用于标签和损失计算

Stage 4:模型训练

if [ ${stage} -le 4 ] && [ ${stop_stage} -ge 4 ]; then
  mkdir -p $dir  # 创建训练输出目录,如 exp/sp_spec_aug
  # 统计 CUDA_VISIBLE_DEVICES 中逗号分隔的 GPU 数量(如 0,1,2,3 → 4)
  num_gpus=$(echo $CUDA_VISIBLE_DEVICES | awk -F "," '{print NF}')	
  # PyTorch DDP 用 NCCL 做 GPU 间通信,多卡训练时比 gloo 更快
  dist_backend="nccl"
  # 训练脚本会把配置文件写到 $dir/train.yaml,并补充模型输入/输出维度
  # 解码和模型导出都会读取这个 train.yaml
  # 根据 train_engine 打印当前使用的训练引擎(DeepSpeed 或 PyTorch DDP)
  if [ ${train_engine} == "deepspeed" ]; then
    echo "$0: using deepspeed"
  else
    echo "$0: using torch ddp"
  fi
  echo "$0: num_nodes is $num_nodes, proc_per_node is $num_gpus"
  # --nnodes 节点数,单机为 1
  # --nproc_per_node 每节点进程数(一般等于 GPU 数)
  # --rdzv_endpoint rendezvous 地址,如 localhost:0
  # --rdzv_id rendezvous ID,用于多任务区分
  # --rdzv_backend 	rendezvous 后端,c10d 为 PyTorch 内置集合通信
  torchrun --nnodes=$num_nodes --nproc_per_node=$num_gpus --rdzv_endpoint=$HOST_NODE_ADDR \
           --rdzv_id=$job_id --rdzv_backend="c10d" \
    wenet/bin/train.py \
      --train_engine ${train_engine} \		# 训练引擎:torch_ddp / deepspeed
      --config $train_config \						# 配置文件 conf/train_conformer.yaml
      --data_type ${data_type} \					# 数据格式 raw(原始音频)
      --train_data $wave_data/$train_set/data.list \	# 训练集 data.list
      --cv_data $wave_data/$dev_set/data.list \				# 验证集 data.list
      ${checkpoint:+--checkpoint $checkpoint} \				# 若 checkpoint 有值则续训
      --model_dir $dir \								# 输出目录
      --tensorboard_dir ${tensorboard_dir} \	# TensorBoard 日志目录
      --ddp.dist_backend $dist_backend \	# DDP 后端 nccl
      --num_workers ${num_workers} \			# DataLoader worker 数	
      --pin_memory \										# 使用 pinned memory,加快 CPU→GPU 拷贝
      --deepspeed_config ${deepspeed_config} \	# DeepSpeed 配置
      --deepspeed.save_states ${deepspeed_save_states}		# DeepSpeed 保存模式
fi

流程概览

torchrun 启动 num_gpus 个进程
    ↓
每个进程执行 wenet/bin/train.py
    ↓
按 config 构建模型、加载 train/dev data.list
    ↓
使用 DDP 在各 GPU 上同步训练
    ↓
checkpoint 和 train.yaml 写入 model_dir

二、训练流程

数据与训练流程简图

data.list
    ↓
JSON 解析 → 音频加载 → tokenize → fbank → spec_aug → batch(padding)
    ↓
batch_dict: {feats, target, feats_lengths, target_lengths, ...}
    ↓
┌─────────────────────────────────────────────────────────────┐
│                    ASRModel.forward                         │
│  feats → Encoder → encoder_out                              │
│              ├→ CTC(encoder_out, target) → loss_ctc         │
│              └→ Decoder(encoder_out, target) → loss_att     │
│                                                             │
│  loss = ctc_weight * loss_ctc + (1-ctc_weight) * loss_att   │
└─────────────────────────────────────────────────────────────┘
    ↓
loss.backward() → clip_grad → optimizer.step() → scheduler.step()

Dataset 是一串 map 操作:

data.list (JSONL) 
  → parse_json (解析 key/wav/txt)
  → decode_wav (读音频)
  → singal_channel (单声道)
  → tokenize (文本 → token id)
  → filter  # 过滤异常样本:过短(噪音)、过长(显存爆炸)、文本为空或过长;
  			 # 控制 token/帧 比例:避免几乎没有语音或文本远长于音频的样本
  			 # 提高训练效率
  → resample (16kHz)
  → compute_fbank (提取 fbank)
  → spec_aug (SpecAugment,训练集)
  → shuffle / sort  # 通常 sort_size < shuffle_size(如 500 < 1500)
  					# 先 shuffle 再局部 sort,既保证随机性,又照顾到长度相近时的 padding
  → batch (padding)

打好的 batch 是一个字典,下面是每个键的含义:

Key 类型 含义 在训练里的作用
keys list[str],长度 B 每条样本的 utt id(如文件名或 “1088-134315-0004”),与 data.list 里一一对应 日志、对齐、解码结果写 text 时按 key 区分样本;不参与 loss 计算
feats Tensor (B, T, F) Fbank 特征,按帧、已按 feats_lengths 降序排好,短句用 0 padding 到 T 模型主输入:speech,进 Encoder,核心训练输入
feats_lengths Tensor (B,),int32 每个样本的有效帧数(未 pad 的长度) 做 encoder mask、CTC 的 encoder_out_lens 等,必须
target Tensor (B, U),int64 标签序列(token id),padding 用 -1(ignore_id) 模型里的 text:CTC 目标、Attention decoder 目标,核心训练目标
target_lengths Tensor (B,),int32 每个样本的有效标签长度(不含 padding) CTC/Attention loss 里做 mask,必须
pcm Tensor (B, L) 原始波形(raw wav),按样本长度 pad 到 L 若用 raw 前端会在别处用;当前 U2/U2++ 用 feats 时训练里一般不用
pcm_length Tensor (B,) 每个样本的有效采样点数 和 pcm 配套,训练用 feats 时一般不参与
langs list[str],长度 B 每条样本的语言标识(如 “en”) 多语/多任务时传给 decoder(如 _calc_att_loss 的 infos),单语可忽略
tasks list[str],长度 B 每条样本的任务标识(如 “transcribe”) 多任务时区分任务,单任务可忽略

参考文献
[1] Yao Z , Wu D , Wang X ,et al.WeNet: Production oriented Streaming and Non-streaming End-to-End Speech Recognition Toolkit[J]. 2021.DOI:10.48550/arXiv.2102.01547.
[2] Zhang B , Wu D , Peng Z ,et al.WeNet 2.0: More Productive End-to-End Speech Recognition Toolkit[J]. 2022.DOI:10.48550/arXiv.2203.15455.
[3] https://github.com/wenet-e2e/wenet.

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐