
调一个语音识别模型的训练脚本最容易出现的场景不是loss不降而是loss降得挺漂亮一上线识别结果却乱七八糟。我遇到过好几次这种情况回头查配置发现都不是模型结构的问题而是特征这一层埋了雷训练时的滤波器数目是80推理侧加载的是40或者训练用的是对数梅尔能量推理脚本却老老实实做了一遍倒谱变换。这类错误在日志里完全看不出来因为张量形状被后面的全连接层吸收掉了训练照跑、推理照出结果只是结果不对。ASR特征维度这件事很多人以为只是设个数字的问题——40维还是80维13维还是20维。真正做过工程落地的人会知道这个数字背后牵着采样率、FFT点数、滤波器组覆盖范围、模型假设、甚至推理时的缓存策略。而Fbank与MFCC的选择更不是一个风格问题它直接对应着你的声学模型需不需要人工去相关这个根本判断。下面我把这条链路从物理量一路拆到配置文件把每个数字的来历和坑都讲清楚。1. 一帧语音变成一组数字特征维度的完整链路1.1 维度这个词在特征提取里被用了三次大部分人讨论特征维度时脑子里想的是最后那个特征向量的长度。但在实际管线里维度这个概念出现了三次而且三者互相牵制。第一次是时间维度也就是一段语音被切成多少帧。它由帧长和帧移决定16kHz采样下25ms帧长对应400个采样点10ms帧移对应160个采样点一段10秒的音频大约产出998帧。第二次是频率维度也就是FFT之后保留多少个频点。这个数字由FFT点数决定n_fft512时正频率部分有257个bin但真正参与后续计算的是梅尔滤波器组的输出数量由n_mels决定。第三次才是特征向量维度也就是每一帧最终输出的浮点数个数。它是前两次的产物Fbank情况下等于n_melsMFCC情况下等于你截取的倒谱系数个数。把这三个混为一谈是配置错位的主要来源。我曾经见过一份配置n_mels写了80特征是80维没错但FFT点数写成了256于是频率分辨率从31.25Hz掉到62.5Hz低频段多个滤波器落进同一个bin滤波器组矩阵出现重复行等于白扔了一半维度。这种问题在形状检查里完全通不过任何报警因为输出确实是80维。所以后面讲任何参数我都会明确它作用在哪一层。1.2 Fbank从波形到对数梅尔能量的六步一条16kHz的单声道波形走到80维Fbank中间要过六道工序每一步都在改变数据的物理含义。第一步预加重。原始语音的低频能量天然偏大因为声门脉冲的频谱包络本身是往下掉的高频部分被压得很低。预加重用一阶高通滤波把这条斜率掰平让频谱更平坦后续滤波器组在高频段也能拿到有效能量。系数常用0.96到0.97。第二步分帧。语音在几十毫秒的尺度上可以认为短时平稳25ms是个经验平衡点太短拿不到足够的频率分辨率太长则帧内音素变化太大。第三步加窗。直接对400点做FFT会因为边界突变产生频谱泄漏汉明窗把两端压到接近零泄漏就被控制住了。第四步FFT。400点补零到512点得到256个频点的复数值。这里补零不是为了提升分辨率只是为了让FFT长度是2的幂方便快速算法。第五步取功率谱。复数的模平方得到每个频点的能量。用模平方而非幅度是因为功率谱在物理上对应能量而且对数变换后动态范围更合理。第六步过梅尔滤波器组再取对数。257个频点被几十个三角形滤波器加权求和压缩成n_mels个值然后逐个取自然对数。取对数的动机很实在人耳对声音强度的感知接近对数关系同时把乘性的信道增益变成加性偏移后面做均值归一化才好使。走完这六步输出就是n_mels维的Fbank。注意这里没有任何降维动作n_mels是你自己选的滤波器组只是把257个频点重新分配到了几十个通道上。1.3 MFCC在Fbank后面再接一级DCTMFCC不是另一条独立管线它是在Fbank的输出上多走一步。把n_mels维的对数能量序列看作一个信号对它做离散余弦变换得到n_mels个倒谱系数然后只保留前12到20个其余丢掉。这个操作在信号处理上的名字叫倒谱分析本质是把频域信号再变换一次映射到倒谱域。倒谱域的横轴不是频率而是频率变化的快慢低阶系数对应谱包络的缓慢起伏也就是声道形状即共振峰结构高阶系数对应谱包络的快速抖动主要是激励源的细节也就是音高和噪声成分。语音识别的核心信息在声道形状上因为音素是由共振峰位置区分的。所以只保留低阶倒谱系数等于主动滤掉音高和部分噪声细节。这个取舍在特定条件下是加分的在另一些条件下是减分的下一节展开。顺带说一个常被忽略的点MFCC的第0维c0等于所有滤波器对数能量的和乘以一个常数因子本质上就是整帧的带能量。它和说话人音量、麦克风距离强相关有些实现会直接丢掉换成单独计算的log能量特征。如果你的模型对增益敏感c0的处理方式需要和训练侧严格一致。2. DCT丢掉的那部分信息决定了Fbank与MFCC的适用边界2.1 倒谱变换的本质是去相关DCT有一个性质经常被简化成降维但它更重要的性质是去相关。滤波器组出来的相邻通道是有大量重叠的三角滤波器底边互相交叠所以相邻两个Fbank维度的数值高度相关。整个n_mels维向量的协方差矩阵不是对角阵非对角元素相当可观。DCT的基函数是余弦波它能把这种平滑相关的信号能量集中到少数几个系数上。变换之后各维之间的相关性大幅下降协方差矩阵接近对角。这就是为什么老一代的声学模型特别喜欢MFCC。降维只是副产品。你把40维Fbank做DCT后取前13维确实压缩了但真正起作用的机制是把相关的原始特征变成了近似独立的特征。2.2 GMM的对角协方差假设逼出了MFCC理解Fbank和MFCC的分野关键要看模型用什么假设。传统混合模型里每个状态用高斯混合建模而为了让参数量可控几乎所有的实现都假设协方差矩阵是对角的也就是各维特征之间独立。这个假设在真实数据上显然不成立但如果输入特征本身已经去相关了假设的偏差就小得多。这就是MFCC在那个年代几乎成为标配的原因不是它信息更全恰恰相反它是主动丢弃了信息换来的是输入特征符合模型假设。用40维Fbank直接喂给对角协方差GMM模型被迫去拟合一个它表达不了的强相关分布效果反而变差。我做个类比MFCC像是把一箱杂乱的零件按规格分好类再交给装配工装配工只需要按格子取件Fbank是把整箱零件直接倒给他理论上信息一点没少但要求装配工自己有分拣能力。GMM没有这个能力深度网络有。2.3 端到端模型为什么把Fbank又捡了回来神经网络尤其是带卷积或自注意力的结构天生擅长学特征变换。第一层卷积干的事情某种意义上就是在学一个比DCT更贴合任务的去相关变换。既然模型自己能学人工加一级DCT就是多余的信息瓶颈。更关键的是DCT丢掉的高阶倒谱系数里确实含有对识别有用的信息。真实场景的语音带着各种噪声和信道失真这些成分体现在频谱的快速变化上正好被高阶系数承载。老一代系统靠模型侧的各种补偿手段硬扛端到端模型则更愿意把这些信息原样收进来让网络自己决定怎么用。所以现在主流的端到端系统输入基本统一成了80维Fbank帧移10ms逐句做均值方差归一化。这个配置几乎成了事实标准配置文件里看到feature_dim: 80、frame_shift: 10、normalize: true基本就是这套。不过这不意味着MFCC过时了。如果你要做一个资源极其受限的嵌入式唤醒词系统模型只有几十K参数13维MFCC加一阶差分共26维相比80维Fbank省下来的计算和存储非常可观而且小模型也学不出复杂的去相关变换。选型要跟着约束走不是跟着潮流走。3. 40维还是80维维度数字背后的物理与工程约束3.1 采样率上限与梅尔滤波器数目的匹配关系n_mels不能随便填。它受两个物理量约束采样率决定的可用频带以及FFT点数决定的频率分辨率。16kHz采样可用频带是0到8000Hz。梅尔刻度在低频段密集、高频段稀疏这意味着同样数量的滤波器低频段每个滤波器覆盖的Hz范围窄高频段覆盖的宽。具体算一下。梅尔频率与线性频率的换算是 mel 2595 × log10(1 f/700)。8000Hz对应约2840梅尔。如果取n_mels80梅尔域步长约35梅尔。前几个滤波器中心大约落在0、24Hz、49Hz、74Hz附近。而n_fft512时一个FFT bin的宽度是16000/51231.25Hz。也就是说第一个滤波器的中心在24Hz对应bin索引0.77第二个在49Hz对应1.57。这两个滤波器在频点上已经重叠得很厉害三角滤波器的底边几乎覆盖同一批bin。这不是致命错误但确实浪费了维度。如果n_mels一路加到128低频段会出现多个滤波器完全落在同一个bin上滤波器组矩阵出现完全相同的行。这些维度对模型毫无信息增益只是把噪声复制了几份。一个实用的自检方法生成滤波器组矩阵后检查每一行的非零元素个数如果存在大量只有1个非零元素的滤波器说明n_mels相对n_fft偏大了应该提高n_fft或者降低n_mels。下面这段代码可以直观看到滤波器组的构造过程也方便你自己验证参数搭配。import numpy as np def hz_to_mel(f): return 2595.0 * np.log10(1.0 f / 700.0) def mel_to_hz(m): return 700.0 * (10.0 ** (m / 2595.0) - 1.0) def mel_filterbank(sr, n_fft, n_mels, fmin0.0, fmaxNone): if fmax is None: fmax sr / 2.0 mel_lo, mel_hi hz_to_mel(fmin), hz_to_mel(fmax) mel_pts np.linspace(mel_lo, mel_hi, n_mels 2) hz_pts mel_to_hz(mel_pts) bins np.floor((n_fft 1) * hz_pts / sr).astype(int) fb np.zeros((n_mels, n_fft // 2 1)) for m in range(1, n_mels 1): left, center, right bins[m - 1], bins[m], bins[m 1] for k in range(left, center): fb[m - 1, k] (k - left) / max(center - left, 1) for k in range(center, right): fb[m - 1, k] (right - k) / max(right - center, 1) return fb fb mel_filterbank(sr16000, n_fft512, n_mels80) support (fb 0).sum(axis1) print(非零元素最少的10个滤波器:, np.sort(support)[:10])把n_mels换成80和128分别跑一次看support的分布差异就能理解为什么我说低频段会被挤爆。经验规则是16kHz采样、n_fft512时n_mels在40到80之间比较合适如果坚持要128维以上最好把n_fft提到1024。3.2 帧长、帧移与时间分辨率之间的联动帧移决定时间分辨率。10ms帧移意味着一秒钟100帧这个粒度对音素级别的建模是够的因为音素的典型时长在50到200ms之间。但帧移一旦变了整条链路都要跟着调整。模型输入层的降采样倍率要和帧移匹配。很多端到端结构在编码器前端做4倍时间降采样如果训练用的是10ms帧移降采样后是40ms一帧推理时有人为了提高精度改成5ms帧移降采样后变成20ms模型看到的时序节奏完全变了性能必然掉。这种错误在流式系统里特别隐蔽因为输出仍然能出声只是错得更多。帧长的作用不太一样。它主要影响频率分辨率和帧内平稳性。25ms在16kHz下是400点补零到512实测下来对大部分中文和英文语料都合适。降到20ms320点时低频共振峰的区分度会下降尤其对元音识别不利升到32ms512点时帧内可能跨越两个音素的过渡段反而糊了边界。我个人的默认配置就是25ms帧长配10ms帧移不做特殊优化时不改这两个数。要改一定是带着明确的实验对比去改而不是凭感觉。3.3 高维Fbank的降采样与堆叠策略还有一个容易被忽略的维度问题单帧特征维度不够时除了提高n_mels还可以做帧堆叠。早期系统常用9帧堆叠把相邻9帧的Fbank拼成一个长向量再做一个线性判别投影降维。这种做法把上下文信息显式地塞进输入弥补了浅层模型感受野不足的问题。端到端模型基本不用这招了因为卷积和自注意力天然能建模上下文。但在一些轻量级的流式模型里帧堆叠加上降采样仍然是压缩时间分辨率的有效手段而且比纯池化更保留信息。需要注意的是帧堆叠会改变特征维度的语义。80维Fbank堆叠5帧是400维但这个400维和直接把n_mels设成400完全是两码事。前者是时间维度的展开后者是频率维度的展开。配置文件里如果只写了一个input_dim: 400后来接手的人根本分不清是哪种。我在所有项目的配置里都会把n_mels、stack_frames、frame_shift分开写清楚就是为了避免这种歧义。4. 手写一套特征提取管线参数取值的推演过程4.1 预加重系数0.97是怎么来的预加重公式是 y[n] x[n] - α·x[n-1]α取0.96到0.97几乎是约定俗成。这个数不是拍脑袋。人声的频谱大致按每倍频程6dB滚降这是声门脉冲加唇端辐射共同造成的整体趋势。预加重的高通特性在低频段的提升量近似20log10(α)分贝每倍频程。α0.97时这个提升量大约0.26dB每100Hz在0到4kHz区间累计提升约10dB刚好把滚降掰平一部分又不至于把高频噪声放大得太多。α取1.0就是把直流分量完全滤掉但会让低频段的动态范围剧烈压缩元音的第一共振峰信息受损。α取0.9提升量不够高频仍然被压着。0.97是这两者之间的平衡点。不过在线推理系统里预加重有个隐患它需要前一帧的最后一个采样点。如果推理是按块送数据的块边界处的第一个点没有前一个点处理不当就会在块边界产生不连续进而产生周期性噪声。稳妥做法是缓存上一块的最后一点或者干脆在整条音频上做预加重再分块送后续处理。我在流式系统里基本都是后者。4.2 梅尔滤波器组的生成与自检方法上一节的代码给了生成逻辑这里说自检。生成之后至少要做三项检查。第一检查行和。每个三角滤波器的面积应该是1或者是某个常数如果你的实现做了面积归一化那所有行的和应该相等。第二检查覆盖率。所有滤波器加起来的非零bin应该覆盖到奈奎斯特频率附近如果最右边的滤波器中心离8000Hz还有一大截说明fmax设错了。第三检查最小底边宽度。把所有底边对应的bin数量排序看最小的几个是不是1。有一回排查一个识别率异常的问题最后定位到滤波器组是用fmax7600生成的配置里的fmax被某个默认参数覆盖了。7600附近以上的频段完全没有滤波器覆盖那段能量直接被丢弃。而训练数据里有大量齿音能量正好集中在高频结果就是丝、诗这类音的识别率奇低。这种问题靠看代码很难发现靠看滤波器组图一眼就能看出来。顺便说一句有些实现为了规避零能量的对数问题会对滤波器输出加一个极小的epsilon再取对数。这个epsilon的量级要控制在1e-6到1e-10之间太大了会抬高静音段的底噪导致VAD判断出错太小了数值精度不够。这个值也需要训练推理一致。4.3 CMVN与差分特征的接入位置C是倒谱M是均值V是方差N是归一化。这套操作把每一维特征的分布拉到均值0方差1主要目的是消除信道差异和说话人增益。接入位置有两个选择结果差别很大。放在Fbank之后、MFCC之前和放在最终特征之后效果就不一样因为DCT是线性变换但归一化不是。主流做法是放在最终特征上对每一维独立归一。统计量的来源也分两种全局CMVN和逐句CMVN。全局CMVN在训练集上算一次统计量存下来推理时直接用逐句CMVN对每条音频自己算均值和方差。端到端模型普遍用逐句因为它对信道变化的适应性更好代价是必须看到整句才能算统计量。差分特征的处理要注意一阶差分是当前帧前后各2帧的加权差二阶差分是对一阶再差分。差分的意义是补上特征的动态信息因为静态特征丢掉了时间上的变化趋势。但差分操作本身会放大噪声所以通常要在CMVN之后做而不是之前。顺序反了被放大的噪声会污染归一化的统计量。这个顺序在不同框架里默认值不一样迁移代码时要逐行确认。def delta(feat, win2): # feat: (T, D) T, D feat.shape pad np.pad(feat, ((win, win), (0, 0)), modeedge) denom 2.0 * sum(n * n for n in range(1, win 1)) out np.zeros_like(feat) for n in range(1, win 1): out n * (pad[win n: win n T] - pad[win - n: win - n T]) return out / denommodeedge是边界填充策略也可以选reflect。这两种在句首句尾的表现不同训练和推理必须选同一个否则首尾几帧的特征会不一致。这类细节在WER上体现为端到端系统的首字错误率偏高数据量小的时候尤其明显。5. 训练能收敛、线上却崩掉特征侧最常见的五类错配5.1 滤波器数目与实际张量形状的对不上这是最常见也最难查的一类。场景是这样的训练配置里n_mels80模型第一层是全连接输入维度80。推理服务为了省内存把n_mels改成了40然后用同一个模型权重加载。结果第一层矩阵乘法直接报形状不匹配这个好抓。麻烦的是形状碰巧能对上的情况。比如训练时80维Fbank模型输入层写的是input_dim80但推理侧用的是40维Fbank加40维一阶差分拼起来也是80维。形状完全通过前缀也对得上加载模型不报任何错误。可这两组80维的语义完全不同模型看到的是乱码。我在代码评审里加一条硬性规则特征配置必须整体序列化跟着模型权重一起保存推理时只能从权重包里读不允许从别处单独配置。这条规则拦下了不止一次事故。5.2 逐句CMVN在流式场景下的时序泄漏逐句CMVN在离线场景下没问题但搬到流式系统就会出事。流式推理是边收音频边出结果一小块一小块地往前推。如果每一块都对自己做CMVN那特征分布会随着块内内容剧烈波动块里全是静音均值就偏低块里正好有个爆破音方差就突然变大。模型看到的特征在时间轴上跳动输出自然就抖。更隐蔽的问题是如果你为了让统计量更准用了当前块加上未来一小段来算均值那就引入了对未来的依赖。离线评估时完全看不出来因为它本质上就是用了未来信息指标反而更好看。上到真实流式环境才会暴露。工程上的做法是维护一个滑动窗口用过去若干秒的观测估计统计量窗口长度固定。或者干脆不做逐句CMVN改成全局CMVN牺牲一点适应性换取确定的时序行为。两者都有代价要看业务能不能容忍首字延迟。5.3 采样率、窗函数与功率谱的隐性不一致这类问题通常在数据来源混合时出现。训练集里有8kHz录音的语料也有16kHz的预处理脚本统一重采样到16kHz。但重采样算法不同有的是简单插值有的是多相滤波。同一句话经过不同算法高频部分的能量分布会有细微差别。单看一条音频没感觉整体统计下来会导致高频Fbank维度的分布偏移模型在这部分维度的判别力下降。窗函数同理。汉明窗和汉宁窗的频谱泄漏特性不同端点衰减速度差一倍。训练用汉明推理用汉宁主要影响的是低频段的能量估计。还有功率谱和幅度谱的区别。绝大多数的实现在过滤波器组之前取的是模平方但确实有实现取的是模。两者差一个平方关系取对数之后就差一个因子2。训练推理如果混用所有特征值整体缩放两倍。CMVN能吸收掉这种全局缩放所以这个问题常常被归一化掩盖直到你关掉CMVN才发现。我的做法是把这几项全部写进特征配置对象和模型一起版本化sample_rate、window_type、power_spectrum、preemphasis、n_fft、n_mels、frame_length、frame_shift、cmvn_mode、delta_order。任何一个字段不一致加载时直接抛异常不允许默默容忍。6. 按模型架构选特征一份可对照的配置清单6.1 混合模型路线的经典配置如果你的系统还是混合架构特征侧的建议很明确13维MFCC去掉c0加一阶和二阶差分共39维配逐句CMVN。选13维而不是20维是有原因的。倒谱系数超过13之后承载的主要是激励源细节这部分内容对音素区分贡献有限反而增加了对说话人和噪声的敏感性。在数据量受限的情况下多出来的维度主要是在拟合噪声。去掉c0也不是绝对如果你的场景里音量信息有用比如远场识别需要区分远近保留c0可能更好。但要注意CMVN会把c0的均值拉平等于把音量信息也消掉了。这种情况下不如把c0单独拿出来不参与归一化。差分阶数方面二阶差分能带来稳定增益三阶基本没有额外收益反而增加计算量和噪声敏感性。39维是个久经验证的平衡点。6.2 CTC与注意力架构的输入层设计端到端系统里80维Fbank配逐句CMVN是起手配置绝大多数开源实现都是这个默认值。具体到输入层还要考虑两点。一是是否加SpecAugment这类时频掩蔽增强。加了之后特征维度不变但被掩蔽的区域值被置零这会影响后续的归一化统计量。所以增强应该在CMVN之后做或者掩蔽时用均值而不是零来填充。二是前端卷积的通道配置。第一层通常把输入维度投影到更高维比如80到256这个投影矩阵的初始化方式会影响收敛速度用标准的Kaiming初始化就行不需要特殊处理。有些实现会在输入层做一次线性投影再加非线性把80维直接投到512维。这种情况下80维和40维的差异会被第一层吸收一部分但仍然建议用80维因为投影前的信息损失是不可逆的。6.3 流式推理的窗口缓存与特征对齐流式场景有个离线场景不存在的问题特征的时序边界。分帧是滑窗操作帧移10ms意味着每100个采样点出一帧。流式推理每次收到一个音频块块大小往往不是100的整数倍于是帧边界会累积偏移。处理不当的话第N帧实际上跨越了两个数据块的拼接处而拼接处的样本值可能因为块间静音填充而不连续。解决办法是维护一个采样级缓冲区每次从缓冲区里按帧长取数据取出后丢弃帧移长度的样本剩余保留到下次。这个逻辑必须严格对齐不然帧的时间戳会漂移和文本标注的对齐关系就断了。另一个点是帧数不足时的处理。流式开始时缓冲区里可能连一帧都凑不齐这时候不能强行补零产出一帧因为补零帧的频谱是失真的会污染CMVN统计量。正确做法是等待直到缓冲区够一帧再输出。还有个容易忽略的点是特征的时间戳和音频的时间戳映射。10ms帧移下第k帧覆盖的音频区间是[k×160, k×160400)个采样点中心在k×160200。这个映射在做强制对齐或者字幕时间轴时必须要准差一帧就是10ms字幕就能看出来不同步。我见过因为用了(k1)×160导致整条字幕提前10ms的系统肉眼几乎看不出来但对齐评估指标会明显下降。最后分享一个我自己的习惯每次新建一个语音识别项目第一件事不是搭模型而是把特征提取的产物可视化出来看一眼。把Fbank画成热力图横轴时间纵轴频率正常的语音应该能看到清晰的共振峰条纹和音素边界处的能量变化。如果图上一片模糊或者有明显的周期性竖条那是块边界不连续的特征问题就在特征侧不用往下调模型了。这个检查花不了几分钟但省下来的调试时间往往是按天算的。