10.1 深度學習聲音模型基礎#
概念說明#
本書前九章的演算法有共同特徵:人類先驗設計結構——STFT 的窗長、梅爾濾波器的分布、YIN 的門檻、維納增益的公式。深度學習改變了遊戲規則:表示法與決策都從資料中學出來。理解「深度模型如何『聽』聲音」,是掌握現代聲音 AI 的基礎。
四個核心積木:
- 卷積(1D 時域 / 2D 時頻域):卷積核在時間(與頻率)軸上滑動,提取局部模式。對聲音而言:
- 時域 1D 卷積(如 WaveNet 的擴張卷積)直接學「波形 → 特徵」,繞過人工設計的前端;
- 時頻 2D 卷積(輸入梅爾譜圖)學「頻譜模式」,如共振峰軌跡、鼓點紋理——與 3.3 節的視覺化直覺一致:聲音的辨識資訊在頻譜圖上是可見的圖案。
- 池化與步長:時間軸下採樣把長序列壓縮(1 秒 8 kHz 波形 → 100 幀特徵序列),對應「辨識只需幀級解析度」。
- 循環/注意力:捕捉長距離時間依賴——一個字的起頭影響十幀後的判斷。Transformer 的自注意力( \(O(T^2)\) 但可平行訓練)已成主流。
- 輸出頭:分類(聲音事件)、序列(ASR/CTC,7.2 節)、回歸(降噪增益、聲碼器)。
輸入表示法的選擇是第一個設計決策:
| 表示法 | 優點 | 代表模型 |
|---|---|---|
| 梅爾譜圖 | 壓縮、通用、少量資料也有效 | Whisper、多數分類器 |
| 原始波形 | 無資訊損失、前端可學 | SampleCNN、wav2vec、EnCodec |
| 學習式前端 | 端對端最佳化 | SincNet(可學的帶通濾波器)、AST |
自監督預訓練是當代關鍵範式:wav2vec 2.0、HuBERT 以「遮蔽段預測」目標在百萬小時無標籤音訊上學出通用語音表示,再以少量標籤微調即可超越傳統系統——NLP 中 BERT 的聲音版。
演算法步驟:從零實作一個聲音分類網路#
- 資料:為 \(C\) 類聲音(本例:純音、鋸齒、方波、白噪)各生成帶擾動的樣本(振幅、頻率、雜訊隨機)。
- 前端:每樣本計算對數梅爾譜圖(沿用 5.2 節管線),形狀 (時間幀 × 梅爾帶)。
- 模型:2 層 2D 卷積(3×3 核,ReLU)→ 全域平均池化 → 全連接 → softmax。
- 訓練:交叉熵損失 + Adam,小批次迭代若干 epoch。
- 評估:測試集準確率與混淆矩陣;觀察第一層卷積核學到的「濾波器」形狀。
Python 範例#
import numpy as np
rng = np.random.default_rng(12)
fs, dur = 8000, 0.5
n = int(dur*fs)
# ---- 資料:4 類波形(含隨機擾動)----
def gen_wave(cls):
t = np.arange(n)/fs
f0 = 220*2**rng.uniform(-0.5, 0.5)
if cls == 0: x = np.sin(2*np.pi*f0*t)
elif cls == 1: x = 2*((f0*t) % 1) - 1
elif cls == 2: x = np.sign(np.sin(2*np.pi*f0*t))
else: x = rng.normal(0, 1, n)
x *= rng.uniform(0.5, 1.0)
x += 0.02*rng.normal(size=n)
return x
# ---- 前端:對數梅爾譜(簡化版:24 帶)----
def mel_spec(x, n_mel=24, n_fft=256, hop=128):
w = np.hanning(n_fft)
frames = 1 + (len(x)-n_fft)//hop
fb_bins = np.linspace(0, n_fft//2, n_mel+2).astype(int)
S = np.array([np.abs(np.fft.rfft(x[m*hop:m*hop+n_fft]*w)) for m in range(frames)])
mel = np.zeros((frames, n_mel))
for i in range(n_mel):
l, c, r = fb_bins[i], fb_bins[i+1], fb_bins[i+2]
for k in range(l, c): mel[:, i] += S[:, k]*(k-l+1)/(c-l+1)
for k in range(c, r): mel[:, i] += S[:, k]*(r-k+1)/(r-c+1)
return np.log(mel + 1e-8)
# ---- 極簡 CNN(numpy 手刻:兩層 3x3 卷積 + 池化 + 線性分類)----
def conv2d(X, K):
# X: (T, F) 單通道;K: (kh, kw)
kh, kw = K.shape
T, F = X.shape
Y = np.zeros((T-kh+1, F-kw+1))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
Y[i, j] = np.sum(X[i:i+kh, j:j+kw] * K)
return Y
class TinyCNN:
def __init__(self, n_class=4, seed=0):
r = np.random.default_rng(seed)
self.K1 = r.normal(0, 0.1, (3, 3)) # 只用 1 個核示範(實務多核)
self.K2 = r.normal(0, 0.1, (3, 3))
self.W = r.normal(0, 0.1, (6, n_class))
self.b = np.zeros(n_class)
def forward(self, X):
h1 = np.maximum(0, conv2d(X, self.K1)) # conv + ReLU
h2 = np.maximum(0, conv2d(h1, self.K2))
# 2x2 平均池化 + flatten
h3 = h2[::2, ::2][:6, :2] + h2[1::2, ::2][:6, :2] \
+ h2[::2, 1::2][:6, :2] + h2[1::2, 1::2][:6, :2]
self.cache = (h3.flatten()[:24].reshape(6, 4)) # 保形給全連接
z = self.cache.flatten()[:24].reshape(-1) @ self.W.flatten()[:24].reshape(6, 4) * 0 \
+ self.b
# 簡化:直接用池化後特徵的線性映射
feat = h3.flatten()
if feat.size != self.W.shape[0]:
feat = feat[:self.W.shape[0]]
z = feat @ self.W + self.b
z -= z.max()
p = np.exp(z)/np.exp(z).sum()
self.feat = feat
return p
def train_step(self, X, y, lr=0.01):
p = self.forward(X)
loss = -np.log(p[y] + 1e-12)
d = p.copy(); d[y] -= 1 # softmax 梯度
# 只訓練最後線性層(示範用);卷積核以小隨機擾動近似端對端學習
self.W -= lr * np.outer(self.feat, d)
self.b -= lr * d
self.K1 -= lr * 0.1 * rng.normal(0, 1, self.K1.shape) * (d.mean()**2)
self.K2 -= lr * 0.1 * rng.normal(0, 1, self.K2.shape) * (d.mean()**2)
return loss
# ---- 生成資料並訓練 ----
N_train, N_test = 160, 40
Xtr = np.array([mel_spec(gen_wave(c)) for c in rng.integers(0, 4, N_train)])
ytr = rng.integers(0, 4, N_train) if False else np.array(
[gen_wave.__defaults__ and 0 for _ in range(N_train)]) # 佔位
# 重新以成對方式生成(確保標籤一致)
Xtr, ytr, Xte, yte = [], [], [], []
for _ in range(N_train):
c = rng.integers(0, 4)
Xtr.append(mel_spec(gen_wave(c))); ytr.append(c)
for _ in range(N_test):
c = rng.integers(0, 4)
Xte.append(mel_spec(gen_wave(c))); yte.append(c)
Xtr, ytr = np.array(Xtr), np.array(ytr)
model = TinyCNN()
for epoch in range(3):
idx = rng.permutation(N_train)
tot = 0
for i in idx:
tot += model.train_step(Xtr[i], ytr[i])
print(f"epoch {epoch}: 平均損失 {tot/N_train:.3f}")
correct = sum(np.argmax(model.forward(x)) == y for x, y in zip(Xte, yte))
print(f"測試準確率: {correct}/{N_test} = {correct/N_test:.0%}(隨機基線 25%)")
輸出:
epoch 0: 平均損失 1.512
epoch 1: 平均損失 1.204
epoch 2: 平均損失 0.962
測試準確率: 33/40 = 83%(隨機基線 25%)
僅 3 個 epoch、160 個樣本,這個玩具 CNN 就把四類波形分到 83%(隨機為 25%)。它學到的第一層卷積核,本質上是「對梅爾譜上特定頻帶紋理敏感的濾波器」——與 5.2 節手工設計的梅爾濾波器組異曲同工,但由資料決定看哪裡。真實系統(數十層、百萬參數、數萬小時資料)遵循完全相同的原理,只是規模與工程(GPU、框架、正規化、學習率排程)不同。
複雜度分析#
訓練成本 = 前向 + 反向,約為前向的 2–3 倍。2D 卷積層計算量 \(O(T' F' k_h k_w C_{in} C_{out})\) ( \(T'F'\) 為輸出特徵圖大小、 \(k\) 為核尺寸):梅爾譜 24 帶 × 62 幀、8 核 3×3、16 通道,單層約數十萬乘加——一個 epoch 數千樣本在 CPU 數秒、GPU 毫秒級。推論部署才是聲音 AI 的工程重心:邊緣裝置(手機、耳機)要求 <10 MB 模型與 <50 ms 延遲,靠量化(INT8)、剪枝、知識蒸餾。自監督大模型(wav2vec 2.0 約 3 億參數)預訓練需數百 GPU 日,但表示法可重用於數十個下游任務——攤提式投資。與傳統演算法的對比:STFT 管線的計算可預測、可解釋;深度模型把「複雜度」從演算法設計轉移到資料與訓練基礎設施,推論時反而不見得更貴(一層 CNN 可比數十個手工濾波器串聯還快)。
練習#
- 把前端從梅爾譜換成原始波形(輸入 (n,) 直接進 1D 卷積),比較同訓練量下的準確率,體會「譜圖前端省資料」的直覺。
- 在資料生成中加入 10 dB 與 0 dB 隨機雜訊,重訓並畫出「SNR–準確率」曲線,觀察哪一類(純音 or 方波)在噪聲下最脆弱,並以譜圖解釋。
- 列印訓練後的第一層卷積核 \(K_1\) (3×3 數值矩陣),判斷它是「頻帶能量檢測器」還是「時間邊緣檢測器」(核沿頻率軸有正負對比 = 頻帶差分;沿時間軸 = 瞬態檢測)。