【Pytorch】音声分類を試してみる

# はじめに

自分の修士研究で動画の音声を分類タスクに使う可能性が出てきたので，音声データの使い方についてメモします．なお，AnacondaやpipなどでPytorchやtorchaudioを使用できる環境にあることを前提とします．また，基本的な畳み込みやPytorchの使い方は説明しないので，（私のように）「**今まで画像を使ってきたけど音声の使い方を勉強したい**」という層をターゲットとした記事になっています．

# データセット準備編

今回はSPEECHCOMMANDSを使用します．SPEECHCOMMANDSは1個のサンプルが1秒程度のデータセットであり，音声の内容はそのクラス名（eightやshielaなど）を読み上げた内容になります．事前に`mkdir data`を実行した上で，このコードを実行してください．

~~~load_dataset.py
import os
import torch
import torchaudio
from torch.nn.utils.rnn import pad_sequence

# Speech Commands データセットをダウンロード
train_dataset = torchaudio.datasets.SPEECHCOMMANDS("./data", url="speech_commands_v0.01", download=True, subset="training")
test_dataset = torchaudio.datasets.SPEECHCOMMANDS("./data", url="speech_commands_v0.01", download=True, subset="validation")
~~~

これを実行すると，`data/SpeechCommands/speech_commands_v0.01`に音声ファイルがダウンロードされます．なお，展開にはそこそこ時間がかかります．気長に待ちましょう．

VSCodeを使っている場合，audio-previewという拡張機能を用いることによりエディター内で音声(wav形式)を聞くことができます．![Screenshot from 2024-11-17 14-47-30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3677950/4b75048e-5af0-3990-5598-cdf0d010d5d2.png)

ラベル（str）とインデックス番号を対応させるコードは以下のように取得できます．

~~~load_dataset.py
unique_labels = sorted({train_dataset[i][2] for i in range(len(train_dataset))})  # ラベルは dataset[i][2]
label_to_index = {label: idx for idx, label in enumerate(unique_labels)}
# {'bed': 0, 'bird': 1, 'cat': 2, 'dog': 3, 'down': 4, 'eight': 5, 'five': 6, 'four': 7, 'go': 8, 'happy': 9, 'house': 10, 'left': 11, 'marvin': 12, 'nine': 13, 'no': 14, 'off': 15, 'on': 16, 'one': 17, 'right': 18, 'seven': 19, 'sheila': 20, 'six': 21, 'stop': 22, 'three': 23, 'tree': 24, 'two': 25, 'up': 26, 'wow': 27, 'yes': 28, 'zero': 29}
~~~

## バッチ化に対する問題点

画像認識と同じようなノリで以下のように読み込もうとすると，ある問題点が発生します．

~~~load_dataset.py
# データローダを読み込みたいが，これではうまくいきません．
dataloader = torch.utils.data.DataLoader(dataset, batch_size=256, shuffle=True, num_workers=4, collate_fn=collate_fn)
~~~

その問題点とは，**音声はサンプルごとに長さが一定であるとは限らない**ということです．この場合，バッチの1つ目のサンプルと2つ目のサンプルでテンソルのサイズが異なってしまい，通常にバッチ化できないという問題点が置きます．

その場合，以下のように自作`collate_fn`関数を使用すると良いです．この関数の使い方についてはこちらをご参照ください．**この記事でどのような`collate_fn`関数を使用するかは後ほど述べます．**

https://qiita.com/tomp/items/f220bd6ffec006dabaa5

## テンソル化・バッチ化

今回は`torchaudio.load_audio()`を用いてwaveformからメルスペクトログラムに変換します．

~~~load_dataset.py
def get_melspectrogram(waveform):
    transform = torchaudio.transforms.MelSpectrogram(n_mels=40)
    melspectrogram = transform(waveform) 
    return melspectrogram
~~~

メルスペクトログラムをスペクトログラムの縦軸がメル周波数になっているものですので，スペクトログラムについて説明します．スペクトログラムとは，簡単に言えば，音声を一定時間を単位に分割し，その音声をフーリエ変換して周波数を取り出して作られるものです．形状は$C\times M\times T$で，$C$はチャネル数，$M$はメル周波数（人間の耳で感じ取れる違いを数値化したもの），$T$は時間方向です．簡単に言えばこのようなものです．

![tensor.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3677950/0c1852f7-e96e-0fc1-1c86-296633794ccf.png)


スペクトログラムの詳細についてはこちらをご参照ください．

https://qiita.com/ma-szk/items/077baf81f0374293a482

これを用いて自作`collate_fn`関数を以下のように定義します．


~~~load_dataset.py
def collate_fn(batch):
    # メルスペクトログラム変換を適用して波形テンソルを取得
    melspectrograms = [get_melspectrogram(item[0]) for item in batch]
    
    # 周波数ビンのサイズを統一
    max_freq_bins = max(melspec.size(1) for melspec in melspectrograms)
    melspectrograms = [torch.nn.functional.pad(melspec, (0, 0, 0, max_freq_bins - melspec.size(1))).permute(2,1,0) for melspec in melspectrograms]

    # パディングを適用して (batch_size, max_seq_len, freq_bin) の形状を作成
    melspectrograms = pad_sequence(melspectrograms, batch_first=True)
        melspectrograms = melspectrograms.permute(0,3,2,1)

    # ラベルを数値化してテンソル化
    labels = [label_to_index[item[2]] for item in batch]
    labels_tensor = torch.tensor(labels, dtype=torch.long)

    return melspectrograms, labels_tensor
~~~

周波数ビン（**この辺ちゃんと理解できていない**）とは，おおざっぱに言えばスペクトル（波長ごとの強度）の範囲を表す言葉です．フーリエ変換とは関数が波の和で表現されると考えて

バッチ$B=(C\times M\times T_j)_{j}$ について，系列長$T_j$の最大値を$T'$としてパディングをすることにより，新しくバッチ

$$B=(C\times M\times T')_{j}$$

が作成できます．

## Loader作成！

これを以下のように読み込めば，`loader`が無事読み込めます！

~~~load_dataset.py
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=256, shuffle=True, num_workers=8, collate_fn=collate_fn, drop_last=True)
test_loader = torch.utils.data.DataLoader(test_dataset, batch_size=256, shuffle=True, num_workers=8, collate_fn=collate_fn)
~~~

# ニューラルネットワーク

メルスペクトログラムは2次元データですから，2次元の畳込みを使うことができます．今回は簡単な3層のCNN（畳み込みニューラルネットワーク）とMLPからなる簡単なニューラルネットワークを作成します．名前は`SimpleCNN.py`とします．

<details><summary>SimpleCNN.pyの中身</summary>

~~~CNN.py
import torch
import torch.nn as nn

class CNN(nn.Module):
    def __init__(self, n_classes = 30) -> None:
        super().__init__()
        self.conv1 = nn.Conv2d(1, 4, 3)
        self.conv2 = nn.Conv2d(4, 12, 5)
        self.conv3 = nn.Conv2d(12,20, 7)
        self.norm1 = nn.Identity()
        self.norm2 = nn.Identity()
        self.norm3 = nn.Identity()

        self.ReLU = nn.ReLU()
        self.pool = nn.Identity()

        self.adapool = nn.AdaptiveAvgPool2d(1)
        self.fc1 = nn.Linear(20, 40)
        self.fc2 = nn.Linear(40, 30)
        self.fc3 = nn.Linear(30, n_classes)

    def forward(self, x):
        x = self.pool(self.ReLU(self.norm1(self.conv1(x))))
        x = self.pool(self.ReLU(self.norm2(self.conv2(x))))
        x = self.pool(self.ReLU(self.norm3(self.conv3(x))))
        x = self.adapool(x)
        x = nn.Flatten()(x)
        x = self.ReLU(self.fc1(x))
        x = self.ReLU(self.fc2(x))
        x = self.fc3(x)
        return x
~~~
</details>


# 訓練

以下のコードを書いて訓練してみます．
<details><summary>train.pyの中身</summary>

~~~train.py
import torch
import torch.nn as nn
import torchaudio
import torchvision

from torch.nn.utils.rnn import pad_sequence
from load_dataset import train_loader, test_loader
from SimpleCNN import CNN
from tqdm import tqdm

# デバイス読み込み
device ="cuda" if torch.cuda.is_available() else "cpu"
print(device)

# モデル定義
model = CNN().to(device)

# 損失関数，オプティマイザーを定義
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.0001)
epoch = 50

# 結果保存用のリスト
train_loss = []
train_acc = []
test_loss = []
test_acc = []

for i in range(epoch):
    # エポックの処理
    total = 0
    epoch_loss = 0
    correct = 0

    # training
    model.train()
    for mels, labels in tqdm(train_loader):
        mels, labels = mels.to(device), labels.to(device)
        outputs = model(mels)
        loss = criterion(outputs, labels)
        
        #勾配更新
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        #結果確認
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).sum().item()
        epoch_loss += loss.item()

    print(f"epoch{i}, train_acc: {correct / total}, train_loss = {epoch_loss / len(train_loader)}")
    train_loss.append(correct / total)
    train_acc.append(epoch_loss / len(train_loader))

    
    
    total = 0
    epoch_loss = 0
    correct = 0

    # test
    model.eval()
    with torch.no_grad():
        for mels, labels in tqdm(test_loader):
            mels, labels = mels.to(device), labels.to(device)
            outputs = model(mels)
            loss = criterion(outputs, labels)
            
            #結果確認
            _, predicted = torch.max(outputs.data, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()
            epoch_loss += loss.item() 
    test_loss.append(correct / total)
    test_acc.append(epoch_loss / len(test_loader))

    print(f"epoch{i}, test_acc: {correct / total}, test_loss = {epoch_loss / len(test_loader)}")
    torch.save(model.state_dict(), "CNN.pt")

    
print(train_loss)
print(train_acc)

print(test_loss)
print(test_acc)
~~~

</details>

これも長いので折りたたみにしています．これを実行すると，訓練終了後にaccのリストが表示されます．`matplotlib.pyplot`を用いてグラフとして可視化すると以下のようになりました．簡単なモデルの割に結構いい精度がでているという印象です．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3677950/a1c51fd0-2d45-44f2-946b-71043a5309d3.png)

![acc.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3677950/a84a1520-0d05-87f5-44d4-b42c1e844df4.png)


# 結論

今回の記事では簡単なCNNを利用した音声分類を実装してみました．今後，TransformerやConformerといったモデルなどの使用を検討しています．ここまでお読みいただきありがとうございました！