音に対するdeep learningの入門として環境音の識別をやってみる

音の扱いがどのようになっているか知りたいと思ったため、最も簡単にできそうなものということで環境音の識別をやってみることにした

いろいろググっているとこちらのqiitaがこれまでの経緯をまとめてくれていたためこれを参考にしていくつか実装してみる https://qiita.com/shinmura0/items/6befb83f7cde7b091905

上から順に見ていくことで以下のように基本的なことを一通り学べそう

  • melspectrogramの利用方法
  • 音声波形をそのまま使った処理
  • 音に対するaugmentation
  • 音に対する転移学習

実装したコードの全体はこちらのrepoにある
https://github.com/y-kamiya/environmental-sound-classification

ちなみに今回初めてpoetryを使ってみたが、packageのinstallに少し時間がかかるため、colabolatoryで実行する場合は必要なpackageを単独でinstallした方がよさそうな印象

logmel CNN

論文: https://www.karolpiczak.com/papers/Piczak2015-ESC-ConvNet.pdf

音声波形をmelspectrogramに変換した上でCNNにかけて分類するという手法。melspectrogramに変換してからNNを通すというのはinputデータも小さくなる上精度がよいためよく使われている模様

実装の参考にしたのは論文著者が公開しているpylearnによる実装
https://github.com/karolpiczak/paper-2015-esc-convnet

データセット

データセットとしてはESC-50を使った
https://github.com/karolpiczak/ESC-50

50クラスの環境音40ファイル/クラスで集めたもの(1ファイル5秒)で、400ファイルが1 foldという単位でグループ分けされている。readmeにも書いてあるが本来は以下のような形でcross validationを行って分類精度を出すべき

  • fold2,3,4,5で学習を行い、fold1で評価
  • fold1,3,4,5で学習を行い、fold2で評価
  • ...
  • 最終的に5回分の精度の平均を取る

ただ、cross validationを毎回やるのは時間がかかりすぎるため、5つ分かれたfoldの内1~4を学習用、5を評価用として使う形で試した

melspectrogramへの変換
# esc-50を学習時に毎回変換すると時間がかかるため初めてやる際に変換済みのデータをファイルとして保存しておく
data_cache_path = os.path.join(self.config.dataroot, self.__data_filename(folderList))
if not os.path.exists(data_cache_path):
    frame_size = 512
    window_size = 1024
    frame_per_segment = 41
    segment_size = frame_size * frame_per_segment
    step_size = segment_size // 2

    torchaudio.set_audio_backend('sox_io')
    transforms_mel = transforms.Compose([
        # melspectrogramに変換
        torchaudio.transforms.MelSpectrogram(
            sample_rate=22050, win_length=window_size, n_fft=window_size, hop_length=frame_size, n_mels=60, normalized=True),
        # 対数スケールに変換
        torchaudio.transforms.AmplitudeToDB(top_db=80.0),
    ])

音を扱うのは初めてだったため調べながら勉強

各変数の意味で重要なものはこちら

  • sample_rate: 1秒間の波形が含む点の数
  • window_size: fftをかける時間幅(サンプリング点の数)
  • frame_size: 現在のwindow開始時刻から次のwindow開始時刻までの時間幅(サンプリング点の数)
  • n_mels: メルフィルタバンクのチャネル数
  • segment_size: NNに入力するデータの時間幅(サンプリング点の数)

また、公式ではlibrosaを使っているが、torch.audioで同様の処理ができたので今回はそちらを使った

区間(segment)への分割
start = 0
clip = tensor[:, start:(start+segment_size-1)]
# segment_size毎に区切ってinputデータを作成(segment_sizeに満たない部分は切り捨てる)
while clip.shape[1] == segment_size-1:
    data, label = self.__create_data(index, clip, transforms_mel)
    if data is not None:
        self.data = torch.cat((self.data, data.unsqueeze(0)))
        self.segment_labels.append(label)
        # file_idを保存しているのは評価用の処理のため
        self.file_ids.append(index)
    start += step_size
    clip = tensor[:, start:(start+segment_size-1)]

def __create_data(self, index, wave, transforms):
    mel = transforms(wave)
    # 無音の区間はinputから除外する
    if torch.mean(mel) < -70.0:
        return None, None
    return mel, self.labels[index]

esc-50のデータは5秒間の音ファイルになっているが、それをそのままinputとして使わず0.1s程度の小区間に分けてinputとする。また、無音に近い小区間はinputデータから除外する。

データを正規化
# self.dataは全inputデータを持つtensor
mean = self.data.mean()
std = self.data.std()

self.transforms_norm = transforms.Compose([
    transforms.Normalize(mean, std),
])

def __getitem__(self, index):
    data = self.data[index]
    if self.config.normalized:
        data = self.transforms_norm(data)

    data = self.__augment(data)
    label = self.segment_labels[index]

    # deltaを足して2チャネルに
    deltas = torchaudio.functional.compute_deltas(data)
    return torch.cat((data, deltas), dim=0), label, self.file_ids[index]
augmentation

論文では生の波形の状態でscaleなどを変化させているが、今回はこちらのaugmentationを見つけたので試してみることにした。
https://qiita.com/shu_O/items/25a483ff3266d1482b31

melspectrogram上でランダムに帯状にデータを消去して隠す感じ。生波形の状態でいじるよりも計算量も少なくすむため助かる。論文では時間方向の伸縮も行っているが、比較実験の結果を見るとそこまで効果が高くないように見えるため入れなかった。

def __augment(self, data):
    if not self.apply_augment:
        return data

    _, n_mel, n_time = data.shape

    # mel周波数方向にランダムで帯状に隠す部分を選ぶ
    mel_width = random.randint(0, self.config.augment_mel_width_max)
    mel_start = random.randint(0, n_mel - mel_width)
    mel_end = mel_start + mel_width

    # 時間方向にランダムで帯状に隠す部分を選ぶ
    time_width = random.randint(0, self.config.augment_time_width_max)
    time_start = random.randint(0, n_time - time_width)
    time_end = time_start + time_width

    data[0][mel_start:mel_end, :] = 0
    data[0][:, time_start:time_end] = 0
    return data

隠す幅については元の論文で採用している割合(波形全体の長さと隠す幅の割合)と同程度になるようにした。

学習ロジック

モデルのネットワークはこちらのある通り(図は論文より引用) f:id:y-kamiya:20201017090626p:plain また、論文の本文中でもkernel sizeなど詳しく書かれているため実装しやすい

https://github.com/y-kamiya/environmental-sound-classification/blob/master/model.py#L5

多クラス分類なのでlossにはnll_lossを利用し、optimizerはモメンタム付きのSGD (Nesterov momentum of 0.9 [39], 0.001 L2 weight decay)

このあたりの処理は画像認識のCNNと変わらない

評価ロジック

論文でprobability-votingの方がよかったと書かれているためそちらで実装。probability-voting自体の詳細は書かれていないが、他の論文などで書かれていたものを見ると単純に推論結果の各クラスの確率の和を取って最大のものを選ぶというもの

@torch.no_grad()
def eval(self, dataloader, epoch):
    self.model.eval()
    device = self.config.device

    correct = 0
    total_loss = 0
    n_data = dataloader.dataset.n_files()

    probability_sum = torch.zeros(n_data, self.config.n_class).to(device)
    file_labels = torch.zeros(n_data).to(device)

    for data, target, file_ids in dataloader:
        data = data.to(device)
        target = target.to(device)
        output = self.model(data)

        for i, entry in enumerate(output):
            file_id = file_ids[i]
            probability_sum[file_id] += entry
            file_labels[file_id] = target[i]

    pred = probability_sum.max(1)[1]
    correct += pred.eq(file_labels).cpu().sum().item()

    accuracy = 100. * correct / n_data

    self.writer.add_scalar('loss/acc', accuracy, epoch, time.time())

    self.config.logger.info('\nTest set: Accuracy: {}/{} ({:.0f}%)\n'.format(
        correct, n_data, accuracy))

    return accuracy

ちなみに、データセット作成の際にラベルなどと一緒にfile_idを持つようにしていたのは、ここでファイル毎に和を取る処理を行うため。

ここで最初に一つハマったのがno_gradを指定し忘れていたこと。tensorの和を取っているため何も指定しないとbackward計算用にgraphが保存されていくためメモリ消費量が大きく増える。今回ケースだと評価時のみout of memoryが発生していた。こちらの記事にtipsとしてまとめたので参考に
https://jsapachehtml.hatenablog.com/entry/2020/10/11/130342

実験

100 epochで学習して精度を確認した(論文だと300 epochなので一つだけ300 epoch学習させてみた)。またbatch sizeは1024で固定。

また、dropoutの代わりにbatchnormを入れたモデルも試してみた

f:id:y-kamiya:20201017224102p:plain

論文では60~65%程度の精度となっているが、今回のモデルでは300epochで54%程度。augmentationの方法がまったく異なるためその違いかもしれないがそれにしては低すぎる気がするので、論文通りになっていない部分が他にもあると思われる。

batchnormを入れたモデルでは100epochで58%程度となり、dropoutを使ったモデルと比べて精度がよくなった。またaugmentationを行った場合ではまだ学習途上の傾向が見られるため、300epochまで学習させることで60%は超えそう。

参考