语音识别(Speech Recognition)这套东西,我在Chapter1-6之前已经跑过好几版baseline,但真正静下心把RNN-T Training从头到尾捋一遍,还是这一章的事。当时我以为端到端模型都差不多,CTC、Attention都玩过,直接照搬经验就行,结果第一轮实验就给了我一个下马威:RNN-T的loss曲线、训练节奏、甚至坑的形状,和CTC完全是两个物种。这篇文章就是我在整理这一章时的完整笔记,既有模型结构层面的拆解,也有实际训练过程中踩过的坑和对应的调优思路,适合那些刚接触序列转导类语音模型、想把手里的数据跑出一个稳定RNN-T baseline的读者。如果你已经在训练RNN-T但总觉得收敛不畅,后面的监控和诊断部分应该能帮上忙。
1. 先厘清RNN-T在整个语音识别光谱里的位置
1.1 为什么不直接沿用CTC
很多人第一次接触RNN-T,心里都有个疑问:CTC已经能处理变长对齐问题,训练又简单,为什么还要换?要回答这个问题,得先看清CTC和RNN-T在“序列转导”这件事上的根本分歧。
CTC把音频帧和文本token之间的关系建模成“帧级条件独立”:给定输入特征,每一帧各自独立地预测一个标签,包括blank标签。这个假设极大地简化了训练,但代价是模型无法显式建模文本token之间的依赖关系。也就是说,CTC模型在发音层面做决策,但对“今天中午吃了什么”里面“吃”后面更可能接“了”还是“完”这类语言规律,它只能靠外部语言模型在解码阶段补救。
Attention-based encoder-decoder模型走了另一个极端:它让解码器在生成每个token时回顾全部编码器输出,文本内部依赖建模得非常强,训练也友好。但问题是这个“回顾”通常是全局软对齐,天然不适合流式场景,边听边出结果时,每一次注意力计算都要看到未来帧才能对齐当前位置,这在工程上非常麻烦。
RNN-T的定位恰好卡在两者之间。它保留CTC式的单调对齐约束——音频从左往右走,输出文本也从左往右走,不会出现回头重新对齐的情况;同时在每个时间步上引入一个预测网络(Prediction Network),让模型在决定“这一帧要不要吐字、吐哪个字”的时候,能看到已经生成过的文本历史。换句话说,RNN-T在不需要未来上下文的前提下,把文本序列内部的依赖关系嵌进了训练过程本身。
1.2 RNN-T想解决的核心问题:流式场景中的单调对齐与左上下文建模
我用一个词概括RNN-T的设计哲学:带着记忆的单调对齐。先看“单调”,再细说“记忆”。
语音转文本本身是个天然单调的过程:一句话从第一个音节说到最后一个音节,对应文本从左写到右,不会中间跳回去重读。Attention模型其实没有这种硬约束,它只学到一个“大概率单调”的软约束,一旦遇到语速变化、噪声干扰,对齐就容易飘。CTC虽然有单调性,但它同时强迫每个时间步的决策互相独立,模型说“这个音是‘想’”,却不参考自己刚刚说过的“我”。RNN-T的预测网络就是专门来补这块的:它是一个以“前一个非空输出token”为输入的循环网络,相当于在音频编码器之外,单独建了一条文本记忆通道。在联合网络(Joint Network)里,音频信息和文本预测信息被揉到一起,共同决定当前帧该输出blank还是某个token。
这样设计带来的直接好处是:录音没结束,模型已经能稳定输出词,因为决定“现在出不出字”只依赖左文信息和当前音频片段,不需要看右文。这也是RNN-T在实时语音助手、会议转写这类场景里被大量采用的根本原因——它把“流式”和“序列内部依赖”这两件事同时做到了。
1.3 这一章的实验规格与训练目标概览
为了让后面的细节不悬空,先交代一下我这一章搭建的基线系统规格。训练集是约三百小时的普通话语料,音频统一做16kHz单声道、80维log-mel特征,词表用字节对编码(BPE)切分到1024个subword。模型主体是Conformer Encoder加两层LSTM Prediction Network,Joint Network用简单的“相加后过一个线性层”结构。优化器用AdamW,峰值学习率1.5e-3,warmup步数设成15k。
目标也很朴素:先跑出一个验证集上可用、训练过程稳定、没有明显退化现象的RNN-T baseline,把整个链路打通,再谈进一步调优。这一章记录的所有经验,基本都来自这个系统反复“炸掉”又救回来的过程。
2. Encoder、Prediction Network与Joint Network:三个子网络如何协同
2.1 Encoder:特征序列的时间降采样与上下文建模
Encoder负责把声学特征序列变成更高层级的表征。RNN-T论文里Encoder可以是任意序列编码器,但几年前大家普遍用BLSTM,后来几乎都换成了Conformer,核心原因是Conformer在同样的参数量下能把局部音频特征和全局上下文兼顾得更好,而且对GPU计算更友好。
在实现细节上,最影响训练结果的往往是时间降采样怎么设计。原始log-mel特征是每10ms一帧,30秒的音频进来就是3000帧。如果直接把这么长的序列送进12层Transformer,注意力矩阵会非常大,训练速度肉眼可见地变慢。常见做法是在Encoder前端加几层卷积子采样,把时间维降到原来的1/4或1/8。比如用两层stride=2的卷积,3000帧就会变成750帧左右,后面无论是注意力计算还是RNN-T的动态规划算法,压力都会小很多。
这里有一个特别想强调的坑:降采样不是你随便怎么下都行。我一开始用stride=4的卷积直接下采样,结果在语速快的句子上频繁丢字,后来换成两层stride=2并且每层后面都跟着一个轻量卷积做平滑,效果明显好转。原因是激进降采样会把同一发音内部的细节过度压缩,导致音素边界模糊。合理的递进式降采样让模型在每一层都能保留足够的局部判别信息。
2.2 Prediction Network:没有显式语言模型时的隐式语言建模
Prediction Network是RNN-T区别于CTC的核心组件。它接收“前一个非空token”的embedding,输出一个预测向量。别小看这个结构,它的作用其实相当于在训练过程中内嵌了一个语言模型,只不过这个LM是跟声学特征一块儿端到端学出来的,而不是单独预训练再插进来。
实现Prediction Network时,最常规的选择是两层LSTM,embedding维度和隐藏维度通常比Encoder的d_model小一到两倍。我用的配置是embedding维256、LSTM隐藏维320。第一层输入是上一个token的embedding,初始状态是零向量,在batch内按序列长度做动态处理。
这一块最难理解、也最容易出错的是“前一个非空token”这句话。训练时,正样本的token序列是已知的,所以Prediction Network输入的就是标准文本序列的前一个位置,这叫做teacher forcing。但在推理阶段,模型并不知道最终答案,Prediction Network输入的是自己前一步生成出来的token,如果中途出了一个错字,后面的预测就会跟着错下去。这种训练和推理之间的差异称为exposure bias,在RNN-T里比在纯自回归LM里更隐蔽:它不只影响看词准确率,还会影响模型对blank的判断——一个被带偏的预测向量,会让Joint Network更倾向于输出无意义的token或长时间沉默。
2.3 Joint Network:把音频信息与预测信息揉到一个格子里的关键层
Joint Network是RNN-T所有信息汇聚的地方,也是最容易被忽略出问题的地方。它的输入是两个向量:Encoder对当前帧的输出h_t,以及Prediction Network对前文历史的输出p_u。最常用的融合方式是直接把两个向量相加,然后经过一个线性变换加tanh激活,最后投影到词表大小加1的logits上。
选择相加而不是拼接,是一个值得说道的设计:相加意味着h_t和p_u的维度必须完全一致(我都用320),模型被迫在两个表征之间共享相同的子空间,信息融合更紧密;拼接虽然能保留更多原始信息,但需要Joint层有足够容量去学习两个空间的关系,参数量也更大,在小数据集上很容易过拟合。实际效果上,相加结构在相同参数量下往往给出更稳定的WER,所以我一直默认加法。
Joint输出多出来的那个维度就是blank标签,它的含义跟CTC里的blank完全不同。CTC的blank表示“当前帧没有有效输出,跳过”;RNN-T的blank表示“当前音频帧我已经处理完了,但我不吐新的token,继续走下一帧”。正是这个blank,让一个时间步上最多一个token的约束成为可能——如果模型想连续输出“谢谢”两个字,它可以在同一段时间内连续前进多个时间帧,并在适当的帧位置吐出两个token,而“适当位置”完全由模型自己学。
2.4 三件套的典型参数表
不同任务对三件套的规模需求不太一样,但一个比较稳的baseline参数可以参考下面这张表:
| 模块 | 典型配置 | 备注 |
|---|---|---|
| Encoder | 12层Conformer,d_model=256,FFN=1024,卷积核31 | 两层stride=2卷积做时间降采样,总降采样率4 |
| Prediction Network | 2层LSTM,隐藏层320,embedding=256 | 输入为上一个非空token的embedding |
| Joint Network | 线性投影到256维,tanh激活,输出为词表数+1 | 使用加法融合Encoder和Prediction输出 |
| 词表 | BPE,1024-4096 | 粒度对训练影响很大,见下一章 |
| 优化器 | AdamW,lr=1.5e-3,warmup=15k步 | 超过峰值学习率容易退化成全部输出blank |
这套配置在三百小时数据上大概会有四千到五千万个可训练参数,单卡训练时如果想跑得动,batch内总帧数控制在2到3万帧左右比较合适。
3. 训练数据、词表与特征:收敛速度的隐性决定因素
3.1 词表设计:BPE的粒度选择直接影响Predict网络学习
很多人在语音识别里把词表当成一个不起眼的预处理选项,觉得用现成BPE就行。实际上RNN-T对词表粒度的敏感程度比CTC高得多,原因在于Prediction Network要学的是“上一个token之后通常会接什么”。
如果词表是纯字级别(比如中文按单字切分),模型要预测的下一步非常离散,每个字的接续可能性很多,LM部分学习压力大,容易出现“想”后面接什么都行导致输出发散的现象。如果词表是整词级别,罕见词问题会立刻暴露,而且语音上很多常见词存在多音字情况,整词级别的音韵映射不够灵活。BPE的好处是它介于两者之间,把常见词拆成少数几个subword,让模型既能记住常见搭配,又能对罕见的拼读组合做出泛化。
在切分英文数据时,我通常把BPE词表从4000起步;500小时以内的数据甚至不需要超过1024。切分中文数据时,把文本先统一转成小写、去掉所有标点、数字按读音展开,再做BPE,会让训练稳定不少。只要文本里有标点残留,Joint输出层就会浪费容量去学顿号和句号的读音,这类token在语音识别里几乎不该出现。
3.2 特征处理:帧移、下采样、SpecAugment的标准组合
RNN-T训练最稳的特征配置是80维log-mel,25ms窗,10ms帧移。这个配置和大多数公开语音识别系统一致,模型能直接继承预训练Encoder的权重,也容易跟别的实验做对比。
要做全局的倒谱均值方差归一化(CMVN),我习惯用每个短句独立计算。说话人级归一化更精确,但如果训练数据本身包含大量不同说话人,句级CMVN反而相当于额外做了一层动态范围压缩,对噪声鲁棒有好处。这里要注意,CMVN系数要与音频同时计算,不能直接把训练集统计量复用在另一个采样率或信道的数据上。
数据增强方面,SpecAugment已经是标配。我用的参数是频率掩蔽最大宽度30、时间掩蔽最大宽度40、掩蔽次数各自2次,和很多语音识别工具包的默认值一致。但RNN-T训练时要特别留意时间掩蔽:RNN-T的对齐本身依赖音频时间轴,如果一段完整的音节被时间掩蔽整个遮住,Encoder在那个区域几乎拿不到信息,Joint网络却可能因为预测网络的惯性输出一个token,造成“无中生有”的错误。我的经验是,训练早期(前几千步)时间掩蔽强度可以暂时调低,甚至只做频率掩蔽,等blank和token的先验分布稳定下来再叠加时间掩蔽。
3.3 动态batching与长度分桶:实际收益非常明显
RNN-T训练里,一个很容易被忽视的效率瓶颈是padding。不同音频的时长差异可能大到两倍以上,如果单纯按样本数量组batch,最终计算的很大一部分时间是浪费在填充帧上的。更麻烦的是,RNN-T的损失计算要对每个batch做一张 T×U 的动态规划表格,如果batch里最长的样本是20秒,最短只有3秒,动态规划里会有一大片区域都是无效的padding轨迹。
目前工业界和开源实现里最通用的做法是“时长分桶”。把所有训练样本按音频帧数排序,然后每32或64条放一个桶,桶内帧长接近,padding量被压到最小。更进一步的做法是设置“每个batch最大总帧数”,比如固定总帧数在3万帧左右,短样本自动多放几条,长样本少放几条,这样每个batch的计算开销基本一致,训练吞吐率能提高30%以上。
3.4 数据清洗:在文本一致性上抠细节
这个部分最枯燥,但也最值钱。同样的发音在训练集里如果对应了三种不同的写法,RNN-T的Joint输出会被逼着同时建模多个候选,对齐不确定性骤增。
我在清洗阶段做的事包括:把全角字符统一转半角,把所有数字展开成对应的汉字读法,把英文单词按字母逐个读或按常见发音读法统一,删除所有的静音标记、咳嗽声标记和非语言事件token。还有一个容易遗漏的细节:去重。训练集里如果有一批完全相同的句子被重复了好多遍,模型会对固定文本产生强烈先验,验证集一换就崩。按文本内容做一遍去重,代价极小,收益却不小。
4. RNN-T损失函数的梯度来源:前向算法的动态规划直觉
4.1 从格子路线图理解对齐空间
RNN-T损失函数是所有可能对齐路径的概率和,理解它最好的方式是在脑海里面画一张格子图。横向是时间轴t,从0到T,对应音频帧;纵向是输出token轴u,从0到U,对应一句话里的U个token。每一条从左上角(0,0)走到右下角(T,U)的单调路径,代表一种“不到底有多少个时间帧分配给了每一个输出token”的解释。
路径的走法有两种动作:横向移动一个格子,表示“当前帧处理完了,输出blank,跳到下一帧”;纵向移动一个格子,表示“在当前帧输出一个token,u加一”。CTC的对齐路径里,竖着跳的每一步都要对应到某个时间帧,而RNN-T允许在同一个时间点连续输出多个token(连续向上跳多格),也允许在某段时间内什么都不输出(连续向右走)。正是这种宽松的路径空间,让RNN-T能够自由学习说话快慢和音节长短。
4.2 前向变量的递推与归一化
RNN-T训练的核心是算所有路径的总概率,这里用动态规划。定义前向变量α(t,u)为“处理到第t帧、已经输出u个token的所有前缀路径概率之和”。递推关系非常简单:
- 从左边来:α(t-1,u) 之后在当前帧输出blank,概率为 φ(t-1,u);
- 从下边来:α(t,u-1) 之后在当前帧输出第u个token,概率为 y(t,u-1)。
合并起来就是:
α(t,u) = α(t-1,u)·φ(t-1,u) + α(t,u-1)·y(t,u-1)
边界条件里,α(0,0)=1,第一行只能靠垂直方向填充,第一列只能靠水平方向填充。递推过程中所有概率都会乘起来,直接做浮点乘法很快就会下溢,所以实际实现必须把概率投影到对数域,用log域加法(log_add)来做加法。
很多开源代码里对结尾的处理略有差异:一种是在计算结束时强制让最后一个动作是blank,即损失对应 α(T,U)·φ(T,U);另一种则直接把 α(T,U) 当作总概率。两种做法数学上是等价的,因为可以在格子图的右侧额外补一列blank来“收尾”,但看代码时如果发现这个细节不明不白,会导致后期调loss时非常困惑。
4.3 前后向算法对损失梯度的作用
看到前向递推,大部分人会觉得“这不就跟HMM的前向算法一样吗”。确实,包括后向变量β(t,u)的定义也类似:β(t,u)表示“从(t,u)状态出发,走完剩余路径到达(T,U)的总概率”。前向和后向一碰,就能算出每个格子的后验概率γ(t,u),也就是“真正的对齐最可能经过这个格子的概率”。
为什么这对训练重要?因为RNN-T没有逐帧的“标准答案”,模型只能通过这个后验来软性监督每一个对齐位置。格子的后验概率高,说明这个表格位置对最终预测的贡献大,那么对应到Encoder输出的h_t和Prediction Network输出的p_u的梯度就大;如果某个格子后验接近0,模型就倾向于不修改那个位置的表征。
这也就解释了为什么RNN-T训练初期会有“梯度浪费”的现象。模型刚开始时blank概率很高,大量格子被blank动作占住,后验分布特别平,真正有效反映token依赖的格子梯度很稀疏。如果这时候学习率开太大,参数会乱七八糟地更新,损失曲线看起来像在被随机扰动。
4.4 为什么RNN-T损失初期的数值变化看起来“慢”
我在第一次训练RNN-T时,看到loss从几百降到几十,心里很高兴,结果之后几千步几乎不动,一度以为是bug。反复排查后才发现,RNN-T的loss数值本身并不等价于错误率。它是对所有对齐路径求和后的负对数概率,模型初期先学会的常常是把blank用对——因为大多数对齐路径里blank占了绝对多数,降低blank路径的概率总能把loss降下来。而真正提高词序列准确率的“把正确token放在正确位置”的信号,要到对齐分布相对集中之后才开始发力。
所以训练RNN-T时,盯着loss曲线是可以的,但不要用loss绝对值评判模型好坏,更不要拿它和CTC的loss比大小。我习惯同时记录每个batch的平均blank概率、平均token熵,以及每隔一段时间把某个样本的对齐格子可视化出来。看到热力图上有几条清晰的对角带,比loss降到多少更能说明问题。
5. 训练稳定性:几个反复踩过的坑与对应解法
5.1 blank偏置:一个润物无声的初始化技巧
RNN-T训练头几千步最常见的问题是:模型疯了一样地往外吐token,随便一句话生成几十上百个字的胡话。原因在于Joint输出层的初始参数是随机的,blank和非blank logits差不多大,而RNN-T在训练早期又缺乏对齐先验,于是模型倾向于在每个时间帧都输出一个小概率但非空的token,久而久之把预测网络带进“不停说话”的沟里。
解决办法非常简单,在初始化时将blank对应的logit加上一个正的偏置,比如3.0。这个操作让模型初始时显著偏向blank,所有时间步都倾向于“什么都不说”,然后再逐步学习何时该打破沉默。注意偏置不能太大,如果初始blank logit比其他token高太多,模型会很长时间学不会输出有效文本。我在实际中试过6.0,结果前两万步几乎都在学“打开blank”,WER迟迟不降。最后定在3.0左右比较好。
5.2 学习率策略:warmup之后别急着开大
RNN-T对学习率的敏感程度比一般分类模型高得多。它不像图像分类那样,模型预训练得很充分,直接用一个固定lr跑就行。RNN-T的训练是从完全随机对齐开始的,前期warmup如果不充分,模型会快速收敛到一个“全部输出blank”的局部最优,且很难再爬出来。
我的配置是warmup步数设在10k到25k之间,峰值学习率在1e-3到2e-3之间。重点是峰值之后采用倒数衰减(inverse sqrt decay),而不是阶梯式下降。阶梯式下降容易在某个边界处让已经在挣扎的RNN-T训练彻底崩溃。每次训练到一半,如果发现WER不降但loss还在降,先检查是不是学习率峰值太高。
5.3 辅助CTC损失:多任务训练的实际取舍
给RNN-T加上辅助CTC损失,是提升收敛速度和稳定性的常用招数。具体做法是在Encoder输出上再挂一个线性层,单独计算CTC损失,总损失变成:
L = α·L_RNNT + (1-α)·L_CTC
我一般取α=0.8或0.9,CTC权重保持在0.1到0.3。CTC损失有一个好处:它的帧级对齐比RNN-T更“硬”,能给Encoder提供一个更直接的声学对齐梯度的来源,帮助模型早期更快学会“这一帧对应哪个音素地区”的粗对齐。
但也不能让CTC权重太高。CTC和RNN-T在建模粒度上存在冲突:CTC训练出来的Encoder倾向于每个帧都做一个局部决策;而RNN-T的Encoder需要保留一定的信息给Prediction Network去决定“要不要继续输出”,两者的表征需求和梯度方向不完全一致。我试过等权混合,结果RNN-T分支学的语言模型功能被明显压制,重复输出问题反而增多。
5.4 数值稳定性:混合精度、梯度裁剪与难样本
RNN-T的动态规划算法对数值下溢非常敏感。即便把概率取了对数,log_add里的指数运算也容易因为两个对数差距过大而产生大量“吞掉小值”的行为。我的一套稳健配置是:
- 全部在log域运算,log_add用
max + log1p(exp(-|a-b|))实现; - 混合精度训练时,让动态规划部分保持在fp32,只在encoder和线性层用fp16,否则loss会在几个batch之内爆成NaN;
- 梯度范数裁剪设5.0,RNN-T的动态规划产生的梯度尺度非常不稳定,尤其当batch里混入超长样本时。
还有一个容易忽视的数值坑是样本长度。如果一个batch里混进一条60秒以上的长音频,DP表格的时间维会变得巨大,梯度反向传播经过这么长的序列后,LSTM部分的梯度会消失或爆炸。我们在训练时会把超过25秒的音频随机切成20到25秒的短段,并且让每一段都独立带文本标签,而不是直接丢弃长音频。这样既保住了长句训练数据,又稳定了梯度尺度。
5.5 监控指标:训练阶段比loss更有参考价值的是什么
我在RNN-T训练早期吃过一次大亏:loss降到一定程度后,WER完全不动,模型输出的内容全是同一个词的循环,比如“嗯嗯嗯嗯嗯”。原因是对齐退化,模型发现循环输出同一个token比正确输出不同的token更容易降低loss。这类退化发生在Prediction Network与Joint Network协同出错的地方,很难单纯靠调学习率解决。
有效的监控组合是:
| 监控项 | 正常范围参考 | 异常信号 |
|---|---|---|
| 平均blank概率 | 0.5~0.8之间 | 长时间超过0.9可能退化成静音模型 |
| 每batch token熵 | 中等,随训练下降 | 突然飙升说明输出开始发散 |
| 字级循环率(同一token连续出现的batch占比) | 很低 | 持续出现是alignment退化前兆 |
| 对齐热力图 | 清晰对角带 | 大块模糊区域提示数据或编码器问题 |
一旦发现循环退化,我的习惯是:先加载最近一次WER还在下降的checkpoint,把学习率降到原峰值的一半,同时给Non-blank logit加上小幅label smoothing,迫使模型不要把概率过度集中到一小撮高频token上。
6. 从训练到推理:解码链路与工程衔接
6.1 贪心解码:验证模型是否学起来的第一个检查点
训练出一个看起来loss还在下降的模型后,第一件事不是上复杂的beam search,而是先用贪心解码跑几条验证集音频,看看输出是否可读。RNN-T的贪心解码和CTC不一样,它的状态是有记忆的:解码器需要维护一个“已经输出的token序列”用于喂给Prediction Network。
基本流程是:
- Encoder一次前向推理全部音频帧,得到h_1到h_T。
- Prediction Network初始状态为零,初始“前一个token”设为起始符。
- 从t=0开始,对每个时间帧,计算当前h_t和当前Prediction输出拼接后的Joint logits。
- 如果argmax是blank,时间步加1,输出端保持原状态。
- 如果argmax是第v个token,输出该token,用它更新Prediction Network状态,时间步也加1。
这个流程跑起来很快,适合在训练中做定期的快速检验。贪心解码效果不差,尤其是训练数据相对干净时,很多情况下贪心结果已经能当作一个还不错的baseline。如果贪心解码都大量重复或漏词,那么beam search大概率也救不回来,问题应该追溯到训练环节而不是解码环节。
6.2 Beam search与预测量网络状态:和CTC解码最大的不同
如果想进一步压WER,就要上beam search。RNN-T的beam search跟CTC的beam search有本质差别:CTC解码时beam的内容可以简单地是一个输出序列,因为每个时间步的扩展方式相对独立;RNN-T的beam每个候选不仅包含token序列,还包含一个Prediction Network的隐状态,因为下一步的Joint计算取决于这个候选自己输出过的历史。
工程上,最朴素的RNN-T beam search可以这样理解:用一个集合保存当前活跃的beam,每个beam维护“当前时间步t”和“hypothesis token序列”以及对应Prediction隐状态。每个时间步,对每个活跃beam,除了可以选择空格子里的token之外,还可以选择输出blank然后前进到下一帧。这里就出现了一个关键点:选择blank只是时间步前进,不产生新token;如果所有beam都不在当帧输出token,就集体推进到下一帧。为了防止beam数爆炸,通常在每个时间步结束后做一次按累计分数的top-K裁剪,K常见取10到30。
一个特别要注意的细节是同一个hypothesis可能经由多条对齐路径到达,分数应该累加而不是保留最大。这就是prefix合并:当某个beam扩展出一个已经存在于另一个beam中的纯token序列时,要把它们的概率分数相加。如果不做这一步,模型内部存在多条等价对齐路径时,beam search会产生重复碎片,解码效果会明显下降。
6.3 模型导出时的注意事项
RNN-T的模型部署和普通分类模型不太一样。训练时我们把它当作一个整体端到端训练,但到了部署阶段,通常会拆成三个独立模块:Encoder、Prediction Network、Joint Network。
Encoder部分在流式场景里需要支持增量式计算:音频持续进来,Encoder要能对当前已到达的chunk输出表征,而不是等全部音频收齐。训练时如果用非因果Conformer,解码阶段贸然改成因果/流式结构,表征分布完全不匹配,WER会断崖式下跌。我的建议是,如果你想部署成流式系统,训练阶段就必须用因果卷积和掩蔽注意力模拟chunk流式输入,哪怕每个chunk只能看左边有限的历史。
Prediction Network导出时最麻烦的点是可变长度的隐状态。用固定步数trace模型时,最容易踩的坑是隐状态形状被固定成了batch=1、序列长度=1。在线解码时,一旦生成一个token,就要把新的隐状态传回给模型。工程上我习惯把Prediction Network拆成“更新一步”的函数,输入包括旧的隐状态和上一个token,输出新的隐状态,保证每一步只做一次完整的前向计算。
6.4 常见WER评估偏差与文本后处理
训练时我们拿到的文本标签一般是干净的,但模型输出的结果里经常会有“呃”、“嗯”之类的填充词,以及把常见字的儿化音、连读写得跟标准文本不一致的情况。如果直接拿原始输出跟标签做对齐算WER,会发现错误率高得离谱,但这不完全是模型的问题。
我一般会先做一套统一的文本后处理:删除所有非中文字符和括号,把连续重复的字压缩(比如“好好好好”变“好好”),处理常见同音替换规则。评估时也把这套规则用在训练标签上,让两端处于同一种文本规范下,得到的WER才具有可比性。不要小看这一步,同一个模型,规范化前后WER可以差两到三个百分点。
如果让我给这一章做个总结,我不会再复述模型结构,而是会强调一件贯穿始终的事:RNN-T训练的核心其实是“对齐不确定性”的管理。loss曲线只是表象,真正藏着问题的是那棵对齐格子的后验热力图。打开中间步骤的alignment看一下,比在损失函数上调半天参数有用得多。另一个小技巧是,每次实验都把第一次出现合理词序的checkpoint单独存一份。很多次后期loss降不下去、调参也救不回来的时候,这个早期checkpoint反而能在验证集上给出惊喜——RNN-T经常在训练早期已经学会了正确的对齐骨架,后期只是在这个骨架上反复修缮。这个现象初期会让人困惑,但摸透之后,你会觉得RNN-T的训练过程像是雕刻:先大致出形,再慢慢打磨。希望这篇笔记能让你在打磨的路上少走点弯路。