語音轉換 resnet_CCF音訊分類:語音指令識別練習
技術標籤:語音轉換 resnet
最近在關注Rainforest Connection Species Audio Detection,然後看到CCF的練習專案,藉此機會學習一下音訊相關的處理方案。
github倉庫連結:https://github.com/blueloveTH/speech_commands_recognition
任務簡介
訓練集有57886條,測試集有6835條,每條資料都是一段1秒左右的語音,其中包含一個單詞,一共有30種可能,因此是一個30分類任務。
還有一位同學對賽題進行了更詳細的介紹,對任務還不太理解的朋友,我強烈建議你看一下↓
2020CCFBDCI通用音訊分類CNN方案(0.90+方案)_wherewegogo的部落格-CSDN部落格
特徵工程
特徵部分採用1x32x32的對數梅爾頻譜圖(Log-Melspectrogram),關於它的介紹網上已有很多資料。這是一種很常規的特徵,幾乎所有音訊任務都有用到。
nn類方法通常不用MFCC,因為MFCC是頻譜圖經有損變換得到的,去除了相關性。而nn具有強大的端到端學習能力,直接使用原始頻譜圖能提取出更多資訊。(事實上,直接喂16000維的原始波形也能達到90+準確率)
librosa包提供了melspec的實現,下面是提取特徵的核心程式碼。先將所有音訊擷取到定長,即1秒16000次取樣,再用librosa提取頻譜圖,最後轉換到對數域,以放大低頻部分的資訊。
def crop_or_pad(y, length=16000): if len(y) < length: y = np.concatenate([y, np.zeros(length - len(y))]) elif len(y) > length: y = y[: length] return y def extract_features(wav): wav = crop_or_pad(wav) melspec = librosa.feature.melspectrogram(wav, sr=16000, n_mels=32) return librosa.power_to_db(melspec)
歸一化
由於缺乏對頻譜圖意義的理解,查閱文章 How to normalize spectrograms ,其探討了頻譜圖應該如何進行歸一化的問題。方法有下列幾種:
- 不歸一化
- 逐樣本歸一化
- 逐通道歸一化
- 全域性歸一化
本文測試了不歸一化、逐樣本和全域性歸一化的效果,後二者略好一點。github倉庫中用的是全域性歸一化,即預計算訓練集上的均值和方差,如下:
mean, std = -34.256863, 17.706715
def normalize(x):
return (x - mean) / std
模型架構(resnet)
本文使用了pytorch配合keras4torch搭建模型和訓練管線。
keras4torch是我們正在開發的pypi包,它是keras介面的一個子集,後端基於pytorch,其程式碼對於torch使用者和keras使用者都較容易理解。詳細介紹↓
https://github.com/blueloveTH/keras4torchgithub.com殘差塊定義
首先是BN+ReLU+Conv2d的三連
import keras4torch as k4t
import torch.nn as nn
def bn_relu_conv(channels, stride):
return nn.Sequential(
k4t.layers.BatchNorm2d(),
nn.ReLU(inplace=True),
k4t.layers.Conv2d(channels, kernel_size=3, stride=stride, padding=1, bias=False)
)
這裡使用了keras4torch提供的Conv2d,它是torch.nn.Conv2d
的封裝,實現了自動尺寸推斷,無需設定輸入通道數。
下面是殘差連線。
class _ResidualBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride):
super(_ResidualBlock, self).__init__()
self.sequential = nn.Sequential(
bn_relu_conv(out_channels, stride=stride),
bn_relu_conv(out_channels, stride=1)
)
self.equalInOut = (in_channels == out_channels)
if not self.equalInOut:
self.conv_shortcut = k4t.layers.Conv2d(out_channels, kernel_size=1, stride=stride, padding=0, bias=False)
def forward(self, x):
if not self.equalInOut:
return self.conv_shortcut(x) + self.sequential(x)
else:
return x + self.sequential(x)
如果輸入和輸出通道不一致,我們需要一個額外的線性卷積層使它們的通道數相同。
這一步操作判斷了輸入通道數,因此繼承KerasLayer
以獲取in_shape
,從而使殘差塊具有自動尺寸推斷的特性。
class ResidualBlock(k4t.layers.KerasLayer):
def build(self, in_shape: torch.Size):
return _ResidualBlock(in_shape[1], *self.args, **self.kwargs)
多個殘差塊
stack_blocks將n個殘差塊疊起來。
def stack_blocks(n, channels, stride):
return nn.Sequential(
*[ResidualBlock(channels, stride if i == 0 else 1) for i in range(n)]
)
最後用Sequential構建整個模型。
def wideresnet(depth, num_classes, widen_factor=10):
nChannels = [16, 16*widen_factor, 32*widen_factor, 64*widen_factor]
n = (depth - 4) // 6
model = nn.Sequential(
k4t.layers.Conv2d(nChannels[0], kernel_size=3, stride=1, padding=1, bias=False),
stack_blocks(n, nChannels[1], stride=1),
stack_blocks(n, nChannels[2], stride=2),
stack_blocks(n, nChannels[3], stride=2),
k4t.layers.BatchNorm2d(), nn.ReLU(inplace=True),
nn.AdaptiveAvgPool2d(1), nn.Flatten(),
k4t.layers.Linear(num_classes)
)
return model
訓練設定
優化器使用SGD,動量為0.9,L2正則係數為0.01。
初始學習率為1e-2,然後按下述公式調整。
動態學習率非常重要,有利於優化器找到損失更低的點。如果使用固定學習率,效果會比較差。
接著使用keras4torch.Model
封裝torch模型以整合keras風格的訓練api,包括.compile()
, .fit()
, .evaluate()
和.predict()
。
支援DataLoader
的相應版本為 .fit_dl()
, .evaluate_dl()
, 和.predict_dl()
。
def build_model():
model = wideresnet(depth=28, widen_factor=10, num_classes=NUM_CLASSES)
model = k4t.Model(model).build([1, 32, 32])
model.compile(optimizer=torch.optim.SGD(model.parameters(), lr=1e-2, momentum=0.9, weight_decay=1e-2),
loss=k4t.losses.CELoss(label_smoothing=0.1),
metrics=['acc'], device='cuda')
return model
資料增強
使用線上增強,對原始波形應用三種簡單變換:
- 改變振幅
- 平移波形
- 改變速度和音調
每種變換都以50%概率應用於每個波形,即每個epoch有約12.5%的資料未經變換,其餘資料經歷不同程度的變換,這樣整個訓練過程模型仍以接近1的概率看到全部原始資料。
class StochasticTransform(object):
def __call__(self, x):
return self.transform(x) if random.random() < 0.5 else x
@abstractclassmethod
def transform(self, x):
pass
class ChangeAmplitude(StochasticTransform):
def __init__(self, min=0.7, max=1.2):
self.amplitude_range = (min, max)
def transform(self, x):
x = x * random.uniform(*self.amplitude_range)
return x
class ChangeSpeedAndPitch(StochasticTransform):
def __init__(self, max_scale=0.2):
self.max_scale = max_scale
def transform(self, x):
scale = random.uniform(-self.max_scale, self.max_scale)
speed_fac = 1.0 / (1 + scale)
x = np.interp(np.arange(0, len(x), speed_fac), np.arange(0,len(x)), x).astype(np.float32)
return x
class TimeShift(StochasticTransform):
def __init__(self, frac_0=8, frac_1=3):
self.frac_0 = frac_0
self.frac_1 = frac_1
def transform(self, x):
a = np.arange(len(x))
a = np.roll(a, np.random.randint(len(x)//self.frac_0, len(x)//self.frac_1))
return x[a]
此外,https://github.com/johnmartinsson/bird-species-classification/wiki/Data-Augmentation 也提供了很棒的參考。
線上增強能使模型看到數十倍於原始資料量的增強例項,從而使學習到的函式比較平滑。另一種策略是離線增強,即事先預計算增強例項,混合後與原始資料一塊餵給模型,優點是速度比較快。
最後是五折交叉驗證,ModelCheckpoint
將儲存驗證集上表現最好的模型用於預測。
from sklearn.model_selection import StratifiedKFold
from torch.optim.lr_scheduler import MultiStepLR
kfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=2020)
y_proba = np.zeros([len(x_test), NUM_CLASSES]).astype(np.float32)
model_name = 'wideresnet28'
for i, (trn, val) in enumerate(kfold.split(x_train, y_train)):
print(f'Processing fold {i}:')
model = build_model()
lr_scheduler = MultiStepLR(model.trainer.optimizer, milestones=[13, 20, 27, 34], gamma=0.3)
lr_scheduler = k4t.callbacks.LRScheduler(lr_scheduler)
model_checkpoint = k4t.callbacks.ModelCheckpoint(f'best_{model_name}_{i}.pt', monitor='val_acc')
trn_loader, val_loader = make_dataloader(x_train[trn], y_train[trn], x_train[val], y_train[val])
history = model.fit_dl(trn_loader,
epochs=40,
val_loader=val_loader,
callbacks=[model_checkpoint, lr_scheduler]
)
model.load_weights(f'best_{model_name}_{i}.pt')
y_proba += model.predict(x_test, activation=nn.Softmax(-1))
y_proba /= kfold.n_splits