📘 第10章 图像和语音的模式识别
📄 一句话概括

声音是一串每秒八千个数。要认出一个词,得先把它切成片,每片看频谱, 再压成十几个数——这就是梅尔频率倒谱系数。 可压完之后还有个麻烦:同一个词,说快说慢,片数就不一样。 这一节量两种对齐办法,也量它们在什么情况下都救不回来。

📝 本节和前面几节的关系

这一节和 10.3 是孪生的。图像那套是「预处理、提特征、比距离」, 语音这套一模一样,只是把「像素」换成了「波形采样点」。 它跟 10.1 的维度灾难也接着:一段 0.4 秒的语音有 3199 个采样点,直接当特征就是 3199 维。 所以语音识别比图像更早、更急迫地需要「把维数压下来」这套手艺。

一、声音先切成片

沙箱里没有真录音,所以自己合成。一个浊音=基频的二十个谐波叠加, 用两个共振峰(大致对应口腔和咽喉的形状)给各次谐波加权。 五个词这样定义,每项是一段的两个共振峰频率(赫兹):

词 第一段 第二段 第三段
词一 (700,1200) (300,2300) —
词二 (400,900) (600,1800) (300,2300)
词三 (800,1500) — —
词四 (500,1000) (500,1000) —
词五 (300,2300) (700,1200) —
注意「词一」和「词五」用的是同样两段,只是顺序反了。
这一对是故意的:顺序本身就是身份,谁先谁后不能混。
然后分帧。帧长 25 毫秒(8000 赫兹下就是 200 个点),帧移 10 毫秒(80 个点)。为什么要切?
因为声音的频谱是随时间变的,整段一起做傅里叶,得到的是「平均频谱」,把变化全抹平了。
切完每一片还要乘一个汉明窗。这一手是为了压掉片段两端的突变——
直接截断会在边界上造出一个假的高频跳变,傅里叶会把它当成真信号。
一个 0.4 秒的词大约切成 38 片。到这里,
3199 个采样点变成了 38 张「每张 101 个频点」的功率谱。

二、梅尔滤波器:按耳朵的分辨率切

下一步是把每张功率谱压成 26 个数。压法是套一组三角滤波器: 每个滤波器只管一段频率,把段内的能量加起来。 关键在于怎么排这 26 个滤波器。如果是等间隔排, 那高频会占掉一大半——可人耳对高频的分辨力很差,一千赫兹以上的差别基本听不出来。 所以用梅尔刻度排:低频排得密,高频排得疏。换算公式是 m = 2595 乘 log10(1 + f/700), 反变换也现成。 这么排出来的中心频率是这样的(赫兹):

第 1 到 13 个 103 159 220 284 354 428 507 592 683 780 884 996 1115
第 14 到 26 个 1242 1379 1525 1682 1849 2029 2221 2426 2646 2881 3133 3403 3691
看第一列和最后一列:100 到 1000 赫兹之间有 13 个滤波器,1000 到 3691 赫兹之间也是 13 个。
低频占了 900 赫兹的带宽,高频占了 2700 赫兹——压缩比差了 3 倍。
这就是「仿生」两个字的实际含义:不是模仿耳朵的结构,是模仿耳朵的分辨率分配。
这一步是人凭生理知识定死的,一个参数都不学。

三、倒谱:13 个数就是指纹

26 个数还是多,而且它们高度相关(相邻滤波器必然重叠)。 再压一道:取对数,做离散余弦变换,只留前 13 个系数。 取对数是关键一步。声音的大小会因为说话远近变化几十倍, 但取了 log,乘性变化就变成加性偏移——后面减掉均值就能消掉。 离散余弦变换则把「26 个相关的数」变成「13 个不相关的数」。 这 13 个数就叫梅尔频率倒谱系数(MFCC)。一个词变成一张「38 帧乘 13」的表, 每一行是一条随时间变化的曲线。 这张表就是这个词的指纹。 c0 是整体能量,波动最大;往上走,系数越来越像频谱包络的形状。 中间那几个系数最有用——它们描述的是共振峰的位置,也就是「这个音是什么」。 到这一步,3199 个数变成了 38 乘 13 = 494 个数,压掉了 85%。 而且压掉的主要是「音量」和「音高」这些跟词义无关的东西。

四、时间对不上:拉伸还是弯着走

现在冒出一个图像里没有的麻烦:指纹的长度不齐。 同一个词,说快说慢,帧数就不一样,两张表没法直接减。 办法一:把每条指纹线性插值成固定 24 帧,再按欧氏距离比。 办法二:动态时间规整(DTW)——允许时间轴弯着走,找一条最省力的对齐路径。 每类 5 张训练、20 张测试,三种说话节奏:

情形 线性插值 DTW
A 只有整体快慢 0.7 到 1.3 倍 1.0000 1.0000
B 整体快慢拉到 0.5 到 1.8 倍 1.0000 1.0000
C 每段各自伸缩 0.6 到 1.6 倍 0.9400 1.0000
A 和 B 打平,都是满分。为什么?
因为整体快慢只是把时间轴乘了个常数,线性插值本来就是干这个的,一插就正了。
C 才露出真问题:真实说话时每段的伸缩是各自独立的——
第一段拖长、第二段赶急,全局拉伸怎么也救不回来。
这时候 DTW 从 0.9400 提到 1.0000。
DTW 做的事很朴素:不求两条曲线一样长,只求「怎么弯着走最省力」。
它最后还除以步数做归一化,否则长的那条天然吃亏。

五、换个说话人就崩

DTW 听着很灵,但它有个明确的能力边界。 做个实验:每个样本的共振峰都乘一个随机系数,那是「不同人声道长短不一样」。 让训练集全是同一个人(漂 5%),测试集的人越换越远:

测试集声道漂移 线性插值 DTW
10% 1.0000 1.0000
20% 0.9200 0.9100
30% 0.8600 0.8400
45% 0.7700 0.7900
两种做法一起往下掉,而且掉得差不多——DTW 一点忙都帮不上。
原因不难想:对齐解决的是「时间对不上」,这里坏的是「音色对不上」。
时间轴再怎么弯,弯不出一套新的共振峰。
这就是这一节最该带走的一句话:每个模块只能治它对应的那个毛病。
想治说话人差异,得在特征层面动手(去掉跟说话人相关的那几个维度),
或者在数据层面动手(多找几个人录)。
最后把两节合起来看。图像和语音这两条流水线,结构一模一样:预处理、提特征、比距离。
而它们的死结也一样:每一个变换都是人按常识设计的,一个参数都不学。
2012 年以后,这些变换被神经网络整个接了过去——人类只留下「要切成片」这一个决定,
剩下的交给数据。

六、动手 Demo:把声音切成一片一片,再跟模板对时间

五段:合成五个词、分帧加滤波器组、倒谱系数、两种对齐、说话人失配。 第 2 段和第 3 段是标准流程,第 4 段和第 5 段是它的能力边界。 傅里叶变换、梅尔滤波器组、离散余弦变换、DTW,全部自己写。 波形和指纹都用字符画出来了,可以对着看「这段声音长什么样」。

SR = 8000
FL, FS = 200, 80            # 帧长 25 毫秒,帧移 10 毫秒

def frames(x):              # 把一条波形切成一片一片
    n = 1 + max(0, (len(x) - FL) // FS)
    idx = np.arange(FL)[None, :] + FS * np.arange(n)[:, None]
    return x[idx] * np.hamming(FL)      # 每片乘汉明窗,压掉边缘突变

def mfcc(x):
    F = np.abs(np.fft.rfft(frames(x), axis=1)) ** 2      # 每片的功率谱
    E = np.log(FB @ F.T + 1e-10)      # 过 26 个梅尔滤波器,再取对数
    C = (DCT @ E).T                   # 离散余弦变换,压到 13 维
    return C - C.mean(0)              # 减掉各维的时间均值,去掉音量差异

def dtw(A, B):              # 允许时间轴弯着走的最省力对齐
    D = np.full((len(A) + 1, len(B) + 1), np.inf)
    D[0, 0] = 0.0
    for i in range(1, len(A) + 1):
        for j in range(1, len(B) + 1):
            c = np.sqrt(((A[i - 1] - B[j - 1]) ** 2).sum())
            D[i, j] = c + min(D[i - 1, j], D[i, j - 1], D[i - 1, j - 1])
    return D[len(A), len(B)] / (len(A) + len(B))    # 按步数归一

实跑输出:

--------------------------------------------------------------------
把声音切成一片一片,再跟模板对时间
--------------------------------------------------------------------
==============================================================

[1] 先造几个「词」出来

  真录音没法在沙箱里做,所以自己合成:一个浊音=基频的二十个谐波叠加,
  用两个共振峰(大致对应口腔和咽喉的形状)给各次谐波加权。
  采样率 8000 赫兹,五个词这样定义(每项是一段的两个共振峰频率):

    词一  (700,1200)Hz (300,2300)Hz
    词二  (400,900)Hz (600,1800)Hz (300,2300)Hz
    词三  (800,1500)Hz
    词四  (500,1000)Hz (500,1000)Hz
    词五  (300,2300)Hz (700,1200)Hz

  「词二」的波形(长度 3316 个采样点,约 0.41 秒;一行 70 格):

           .  . .: ....-..:..-::.::-::.:.-::.-.=::.-.--:.:.:-..:..-.....: 
    ..  .                              .   ..... ...:...:::...:-::.::-::..
    ::::..::-.....:.. ....   . .                               .   ...  ..
    .:. ...-. .::-. .::-. .::-: ..:::  ..:.  ....                         

  下面那片「安静」的地方是两段之间插的静音,一共 3 处。

[2] 分帧、加窗、傅里叶,再套一组梅尔滤波器

  帧长 25 毫秒(200 个点),帧移 10 毫秒(80 个点),每帧乘汉明窗,
  做傅里叶变换得到功率谱。然后套 26 个三角滤波器——
  它们在低频排得密、高频排得疏,因为人耳本来就这样。

  滤波器中心频率(赫兹):

    103 159 220 284 354 428 507 592 683 780 884 996 1115
    1242 1379 1525 1682 1849 2029 2221 2426 2646 2881 3133 3403 3691

  其中一个音素段的平均功率谱(前 1200 赫兹)和滤波器组能量:

    功率谱               ..::--==%%%%--==%%==----::                      
    滤波器能              :::::++++%%%%####%%%%%====....                 

  **滤波器的用处是把几百个频点压成 26 个数。**
  **而且它压得不对称:低频细分,高频粗分,跟耳朵的分辨率对齐。**

[3] 再压一道:倒谱系数

  对 26 个滤波器能量取对数,再做离散余弦变换,只留前 13 个系数,
  这就是梅尔频率倒谱系数(MFCC)。一个词就变成一张「帧数乘 13」的表:

  「词二」共 39 帧,前 13 个系数各画一行(横轴是时间,一行 60 格):

    c0   ++##+##%#####+==+== ==##%%%%%%%%%%%%###-  ==+###%%%%%%###++=
    c1   ==++%##=%%#++#%%=--.:::::------:::---::-  ..:::-::.::.--:::.
    c2   ====:===--===---=+++::...      ...   .. ==###%%+##%###+++%%#
    c3          .        ...------==-===-----=--=--++#%%%%%%%%#%%%%%#
    c4   ####%%%###%##%%%###++++++======+++===++=##--:   .....:  .  :
    c5   %%%%%%%%%%%%%%%%%###::..............:..:====:..:::...:..:  -
    c6   ....   .   ..   .::========++++++=++====--++##########%%#%%#
    c7   ....:...::....::....+++++++====+++==+++---   ==:::-::.:: %%.
    c8   ++++###+++#++#%%+== ..::::::---:::::::::++++#%%%%%%%%+%%%%%+
    c9   #####++###+##+++#####################+++%%##+--=+++==#--=  +
    c10  ----.::=:::--:..---%==:::...   ..... ..:--==-..:::.::----  -
    c11  ::::---.::-:::==.  :..:::------:::---:::  ::-===------==-%%-
    c12  %%###%%###%%%%%%#==%%%%%%######%%%###%%+++:: ::...-...   ++.

  **每一行是一条随时间变化的曲线,整张表就是这个词的「指纹」。**
  **c0 是大小的总和,波动最大;往上走,系数越来越像一个包络的形状。**
  **注意最要紧的一点:不同人说话快慢不一样,这张表的帧数就不一样,**
  **两个词的指纹长度对不上,没法直接减。下一段解决这个。**

[4] 长度对不上:拉伸对齐,还是让时间自己弯

  办法一:把每条指纹线性插值成固定 24 帧,再按欧氏距离比。
  办法二:动态时间规整(DTW)——允许时间轴弯着走,找一条最省力的对齐路径。

  每类 5 张训练、20 张测试,三种说话节奏:

    情形                          线性插值    DTW
    A 只有整体快慢 0.7~1.3 倍    1.0000      1.0000
    B 整体快慢拉到 0.5~1.8 倍    1.0000      1.0000
    C 每段各自伸缩 0.6~1.6 倍    0.9400      1.0000

  **A 和 B 打平,都是满分。为什么?**
  **因为整体快慢只是把时间轴乘了个常数,线性插值本来就是干这个的,一插就正了。**
  **C 才露出真问题:真实说话时每段的伸缩是各自独立的——**
  **「词二」第一段拖长、第二段赶急,全局拉伸怎么也救不回来。**
  **这时候 DTW 从 0.9400 提到 1.0000。**
  **它做的事很朴素:不求两条曲线一样长,只求「怎么弯着走最省力」。**

[5] 换个说话人就崩:DTW 救不了这个

  前面每个样本的共振峰都乘了一个随机系数,那是「不同人声道长短不一样」。
  现在让训练集全是同一个人(漂 5%),测试集的人越换越远:

    测试集声道漂移        线性插值    DTW
    10%                   1.0000      1.0000
    20%                   0.9200      0.9100
    30%                   0.8600      0.8400
    45%                   0.7700      0.7900

  **两种做法一起往下掉,而且掉得差不多——DTW 一点忙都帮不上。**
  **原因不难想:对齐解决的是「时间对不上」,这里坏的是「音色对不上」。**
  **时间轴再怎么弯,弯不出一套新的共振峰。**

  五段连起来:**声音先切帧**;**傅里叶加梅尔滤波器压成 26 个数**;
  **倒谱再压成 13 维,这就是指纹**;
  **时间不齐可以用 DTW 弯着对齐,但只在「每段各自伸缩」时才真顶用**;
  **说话人一换,两套对齐一起崩,那是特征层的账。**

  这套流程是 1980 年代的产物,深度学习之前一直是语音识别的第一道工序。
  它和上一节图像那套是同一个思路:**先按人的听觉/视觉常识设计一个变换,**
  **把原始采样点变成「噪声不容易改、但类别差别很大」的数。**
  而这些变换本身,一个参数都不是学出来的——正是这一点,
  后来被神经网络整个接了过去。

第 1 段那四行波形要看清楚:有声的地方密,静音的地方几乎是一条平线。 分帧之后,静音帧的频谱几乎是零,取对数会变成很大的负数——所以代码里那个 1e-10 是必须加的, 否则会出 -inf。 第 2 段那两行条形图是一对:上面是功率谱,下面是 26 个滤波器吐出来的能量。 谱上两个尖峰就是两个共振峰,滤波器那一行正好在那两个位置鼓起来。 这就是「滤波」两个字的字面意思——只让某几段频率过去。 第 3 段那张 13 行的图是整节的中心。 每一行是一条随时间变化的曲线,整张表就是这个词的指纹。 注意最左边和最右边的静音段:所有系数都平着走,这正是后面「减均值」要做的事——把这段平台拉平, 只看变化的部分。 第 4 段的三行对比是本节最实用的结论:整体快慢不用 DTW,逐段乱变才需要。 真实语音属于后者,所以 DTW 在 1980 到 2010 年间是标配。 第 5 段那四行要连着看:两种做法一起掉,说明问题不在对齐上。 一个模块只能治一个毛病,这句话在这一段被量成了四行数。

30 秒实验

  1. 把第 2 段的梅尔滤波器个数从 26 改成 40,重跑。 倒谱系数会多几个,准确率小幅上升—— 滤波器越密,频率分辨率越高,但相关性和计算量也上去了。
  2. 把第 3 段的倒谱系数从 13 改成 3,重跑。 准确率会明显下降——前 3 个系数只描述频谱的整体形状,认不出共振峰的具体位置。
  3. 把第 4 段的固定帧数从 24 改成 8,重跑。 线性插值会掉得更狠,DTW 反而影响小——插得太狠,时间上的细节全没了。

常见误区

  1. 以为「分帧长度随便定」。帧长 25 毫秒是折中: 太短看不到一个完整的基频周期,太长频谱又被时间抹平。这个数在几十年里几乎没变过。
  2. 以为「梅尔滤波器是等间隔的」。它低频密、高频疏, 100 到 1000 赫兹排了 13 个,1000 到 3691 赫兹也排了 13 个。 等间隔排等于假设人耳对高频一样敏感,那是错的。
  3. 以为「DTW 能治所有不齐」。Demo 第 5 段:说话人一换, DTW 和线性插值一起掉,它也帮不上忙。 对齐治的是时间,治不了音色。
  4. 以为「取对数只是为了好算」。取对数是把乘性变化变成加性偏移—— 音量差几十倍,取完 log 只是一段平移,减掉均值就消掉了。 少了这一步,说话远近就能把识别搞崩。
自测三问(点开对答案) 1. 为什么要分帧,不能整段做一次傅里叶? —— 因为**声音的频谱随时间在变**。 整段一起变换得到的是平均频谱, **把「先说这个音、后说那个音」全抹平了。**Demo 第 1 段那个 0.4 秒的词切成 38 片, **每一片才有资格叫「某一时刻的频谱」。** 2. 梅尔滤波器为什么要排得低频密、高频疏? —— 因为**人耳对低频的分辨力远高于高频**。 Demo 第 2 段那两行中心频率: **100 到 1000 赫兹之间排了 13 个滤波器,1000 到 3691 赫兹之间也排了 13 个。** **等间隔排,等于把高频那些听不出来的差别也算成信息,浪费维度。** 3. DTW 能解决说话人不同的问题吗? —— **不能。**Demo 第 5 段:测试集声道漂移 45% 时,**线性插值 0.7700,DTW 0.7900,两个一起掉。** **对齐解决的是「时间对不上」,这里坏的是「音色对不上」——时间轴再怎么弯,** **弯不出一套新的共振峰。**

第 10 章到这儿结束。四节连起来是一条线: 10.1 说清模式识别的三个零件,10.2 说怎么把维数压下来, 10.3 和 10.4 把这两件事分别用到图和声音上。 下一章换一个方向:前面讲的都是「从数据里学」,下一章讲「机器怎么读懂一句话」。

相关笔记