人工知能に関する断創録

このブログでは人工知能のさまざまな分野について調査したことをまとめています(更新停止: 2019年12月31日)

Echo Sequence Prediction Problem

Long Short-Term Memory Networks With Python(2018/8/20)のつづき。

今回は、Echo Sequence Prediction Problemという単純なタスクを対象にKerasとPyTorchのVanilla LSTMの実装方法を比較してみます。

Echo Sequence PredictionProblem

Echo Sequence Prediction Problemとは、ランダムな整数の系列を入力とし、入力系列の特定の時刻の値を出力する単純なタスクです。時刻はネットワークへの入力とせずに固定します。例えば、入力系列として [5, 3, 2] を入力とした場合、時刻1の要素を返すモデルは 3 を出力します。

f:id:aidiary:20180827200522p:plain

系列を入力として、要素を1つだけ返すのでMany-to-one型のLSTMです。こんなモデルが何の役に立つのか?というと・・・何の役にもたちません(笑)練習用のタスクです。

KerasによるLSTMの実装

先にKerasで実装してみます。まず、必要なライブラリをインポート。

from random import randint
import numpy as np

from keras.models import Sequential
from keras.layers import LSTM, Dense

import matplotlib.pyplot as plt

次に長さが length の数字系列を生成します。

def generate_sequence(length, n_features):
    """長さがlengthで、ランダムな整数がn_featuresまでの系列を1つ生成する"""
    return [randint(0, n_features - 1) for _ in range(length)]

先の図ではネットワークへの入力も出力も整数値を使っていましたが、Kerasでは入力も出力もone-hot encodingして与えます。

def one_hot_encode(sequence, n_features):
    encoding = list()
    for value in sequence:
        vector = [0 for _ in range(n_features)]
        vector[value] = 1
        encoding.append(vector)
    return np.array(encoding)

def one_hot_decode(encoded_seq):
    return [np.argmax(vector) for vector in encoded_seq]

例えば、系列長が5で0から9までの乱数系列を生成したいときは下のようにします。

% sequence = generate_sequence(5, 10)
[4, 6, 1, 7, 3]

% encoded = one_hot_encode(sequence, 10)
[[0 0 0 0 1 0 0 0 0 0]
 [0 0 0 0 0 0 1 0 0 0]
 [0 1 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 1 0 0]
 [0 0 0 1 0 0 0 0 0 0]]

% decoded = one_hot_decode(encoded)
[4, 6, 1, 7, 3]

例えば、0-9までの10個の数字を入力とする場合は、one-hotベクトル化すると10次元の特徴量ベクトルになります。

次に訓練データを生成する関数を実装します。

def generate_example(length, n_features, out_index):
    # 訓練データを1サンプル(1系列)だけ生成する
    sequence = generate_sequence(length, n_features)
    encoded = one_hot_encode(sequence, n_features)
    X = encoded.reshape((1, length, n_features))
    y = encoded[out_index].reshape(1, n_features)
    return X, y

KerasのLSTMへの入力は3Dテンソルで与える必要があります。ドキュメントを見ると入力テンソルXは (batch_size, timesteps, input_dim) で与えると書いてあります。また、出力テンソルyは (batch_size, units) で与えると書いてあります。

今回は簡単のためバッチサイズは1で固定です。例えば、バッチサイズ1、系列長が5、特徴量の次元は10とすると入力系列Xと出力yは下のようになります。

% X, y = generate_example(5, 10, 2)
% print(X.shape, y.shape)
(1, 5, 10) (1, 10)
% print(X)
[[[0 0 0 1 0 0 0 0 0 0]
  [0 0 0 0 0 0 0 0 0 1]
  [0 0 0 0 0 1 0 0 0 0]
  [0 0 1 0 0 0 0 0 0 0]
  [0 0 0 0 0 0 0 0 1 0]]]
% print(y)
[[0 0 0 0 0 1 0 0 0 0]]

この例では、入力系列が [3, 9, 5, 2, 8] で出力は2番目(インデックスは0から)を返すので 5 となります。

Model

LSTM層が1つ、分類のDense層が1つという単純なモデルを実装します。 LSTM層は系列データをまとめて入力することで、内部の隠れ状態が更新されていきます。KerasのLSTMのデフォルト設定では先のMany-to-oneモデルのように系列を全て入れたあとの最後の隠れ状態が出力されます。その隠れ状態を0-9の10分類のDense層に入力し、活性化関数をsoftmaxとすることで確率に変換します。

f:id:aidiary:20180827203522p:plain:h320

今回の実験では、長さ5の系列を入れて2番目の要素を出力するようにします。LSTMの隠れ状態は25次元です。

# length = 5
out_index = 2  # echo sequence predictionで入力の何番目の要素を返すか
n_features = 10
hidden_size = 25

model = Sequential()
model.add(LSTM(25, input_shape=(length, n_features)))
model.add(Dense(n_features, activation='softmax'))
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['acc'])
model.summary()
_________________________________________________________________
Layer (type)                 Output Shape              Param #   
=================================================================
lstm_3 (LSTM)                (None, 25)                3600      
_________________________________________________________________
dense_3 (Dense)              (None, 10)                260       
=================================================================
Total params: 3,860
Trainable params: 3,860
Non-trainable params: 0
_________________________________________________________________

Train

最後にモデルの訓練コードです。

losses = []
for i in range(10000):
    X, y = generate_example(length, n_features, out_index)
    history = model.fit(X, y, epochs=1, verbose=0)
    losses.append(history.history['loss'][0])
plt.plot(losses)

今回は、各エポックで系列データを1つだけ生成し(ミニバッチ数1)、学習します。lossを下のようにエポックが進むにつれて下がることがわかります。

f:id:aidiary:20180827204410p:plain

Evaluate

精度評価用のコードです。系列を100個生成して、正解と一致するか判定します。

# evaluate model
correct = 0
for i in range(100):
    X, y = generate_example(length, n_features, out_index)
    yhat = model.predict(X)
    if one_hot_decode(yhat) == one_hot_decode(y):
        correct += 1
print('Accuracy: %f' % ((correct / 100) * 100.0))

精度は100%です。

Accuracy: 100.000000

Predict

最後に予測用のコードです。

# predict on new data
X, y = generate_example(length, n_features, out_index)
yhat = model.predict(X)
print('Sequence: %s' % [one_hot_decode(x) for x in X])
print('Expected: %s' % one_hot_decode(y))
print('Predicted: %s' % one_hot_decode(yhat))
Sequence: [[7, 5, 1, 3, 6]]
Expected: [1]
Predicted: [1]

入力系列が [7, 5, 1, 3, 6] で2番目を返すように訓練したモデルなので正解は1です。予測も1で正解なことがわかります。

PyTorchによるLSTMの実装

次に同じタスクをPyTorchで実装してみます。

from random import randint
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim

cuda = torch.cuda.is_available()
if cuda:
    print('cuda available')
device = torch.device('cuda' if cuda else 'cpu')

次にデータを生成する関数です。ここはKerasとちょっと違います。

def generate_example(length, n_features, out_index):
    sequence = generate_sequence(length, n_features)
    encoded = one_hot_encode(sequence, n_features)
    
    # ndarray => tensor
    # PyTorchでは入力はfloatにする必要あり
    encoded = torch.from_numpy(encoded).float()
    
    # LSTMへの入力は3Dテンソル (seq_len, batch, input_size)
    X = encoded.view(length, 1, n_features)

    # out_index番目の入力を出力するようにする
    # PyTorchは出力はone-hotではなくラベルそのものを返す
    y = torch.Tensor([sequence[out_index]]).long()

    return X, y

Kerasとの違いをまとめると

  • 入力はone-hot encodingしますが、出力側の分類ラベルはone-hot encodingする必要がありません
  • 入力テンソルはfloatで出力テンソルはlongに変換する必要があります
  • PyTorchのLSTMへの入力はKerasと同じく3Dテンソルですが、次元の順番が (seq_len, batch, input_size) となります。Kerasと異なり、系列長とバッチサイズが入れ替わります
% generate_example(5, 10, 2)
(tensor([[[ 0.,  1.,  0.,  0.,  0.,  0.,  0.,  0.,  0.,  0.]],
 
         [[ 1.,  0.,  0.,  0.,  0.,  0.,  0.,  0.,  0.,  0.]],
 
         [[ 0.,  0.,  0.,  0.,  0.,  1.,  0.,  0.,  0.,  0.]],
 
         [[ 0.,  0.,  0.,  0.,  0.,  1.,  0.,  0.,  0.,  0.]],
 
         [[ 1.,  0.,  0.,  0.,  0.,  0.,  0.,  0.,  0.,  0.]]]), tensor([ 5]))

Model

PyTorchの nn.LSTM もKerasと同じく、系列をまとめて入力して処理します。KerasのLSTMは最後の系列要素を与えた時の隠れ状態だけを出力するのに対し、PyTorchのLSTMは入力系列の各要素に対する隠れ状態を全て出力します(Kerasで同じことをするにはLSTMの引数として return_sequences = True をセットします)。また、 隠れ状態(LSTMの場合はhidden stateとcell stateの2つ)は明示的に与える必要があります

# model
length = 5
n_features = 10
hidden_size = 25
out_index = 2

class EchoSequencePredictionModel(nn.Module):
    
    def __init__(self, input_size, hidden_size, target_size):
        super(EchoSequencePredictionModel, self).__init__()
        self.hidden_size = hidden_size
        self.lstm = nn.LSTM(input_size, hidden_size)
        self.out = nn.Linear(hidden_size, target_size)
        self.softmax = nn.LogSoftmax(dim=2)

    def forward(self, input, h, c):
        output, (h, c) = self.lstm(input, (h, c))
        output = self.out(output)
        output = self.softmax(output)
        return output, (h, c)

    def init_hidden(self):
        # (num_layers, batch, hidden_size)
        h = torch.zeros(1, 1, self.hidden_size).to(device)
        c = torch.zeros(1, 1, self.hidden_size).to(device)
        return h, c

model = EchoSequencePredictionModel(n_features, hidden_size, n_features).to(device)
print(model)
EchoSequencePredictionModel(
  (lstm): LSTM(10, 25)
  (out): Linear(in_features=25, out_features=10, bias=True)
  (softmax): LogSoftmax()
)

Train

訓練用のコードです。

criterion = nn.NLLLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)

losses = []

# training
for i in range(10000):
    # 系列データを1つ生成
    X, y = generate_example(length, n_features, out_index)

    # 系列を1つ入力してパラメータ更新したら勾配はリセット
    model.zero_grad()
    
    # 新しい系列を入力するたびに隠れ状態(hとc)はリセット
    # LSTMのパラメータは更新されたまま残る
    h0, c0 = model.init_hidden()

    # 系列を入力して出力系列を求める
    # output: (seq_len, batch, hidden_size)
    output, (h, c) = model(X, h0, c0)

    # many-to-oneのタスクなので出力系列の最後の要素のみ使う
    loss = criterion(output[-1], y)
    losses.append(loss.item())
    loss.backward()
    optimizer.step()

plt.plot(losses)
  • モデルの出力に LogSoftmax()を使っているため交差エントロピー誤差を使う場合は、nn.NLLLoss() を設定します
  • 系列を1つ入力して optimizer.step() でパラメータを更新したら、隠れ状態はリセットして、新しい系列を入力する準備をします
  • Many-to-oneモデルで系列の最後の出力のみを使う場合は output[-1] とし、それをLinear層に渡して分類します

この図がわかりやすいです。

f:id:aidiary:20180827210908p:plain

Kerasのときと同じようなlossが得られます。

f:id:aidiary:20180827211135p:plain

Predict

予測用のコードです。

X, y = generate_example(length, n_features, out_index)
h0, c0 = model.init_hidden()
yhat = model(X, h0, c0)

print('input sequence:', [one_hot_decode(x) for x in X])
print('expected:', y)
print('predicted:', torch.argmax(yhat[0][-1]))
input sequence: [[tensor(5)], [tensor(0)], [tensor(4)], [tensor(8)], [tensor(7)]]
expected: tensor([ 4])
predicted: tensor(4)

入力系列が [5, 0, 4, 8, 7] で2番目を出力するモデルなので4が正解、予測も4なので合っています。

実験

最後に、いくつか実験してみました。

入力系列が長くなるとどうなるか?

def moving_average(data_set, periods=3):
    weights = np.ones(periods) / periods
    return np.convolve(data_set, weights, mode='valid')

# model
n_features = 10
hidden_size = 25
out_index = 2

for length in range(5, 11):
    model = EchoSequencePredictionModel(n_features, hidden_size, n_features).to(device)
    criterion = nn.NLLLoss()
    optimizer = optim.Adam(model.parameters(), lr=0.001)

    losses = []

    # training
    for i in range(10000):
        # 系列データを1つ生成
        X, y = generate_example(length, n_features, out_index)

        # 系列を1つ入力してパラメータ更新したら勾配はリセット
        model.zero_grad()

        # 新しい系列を入力するたびに隠れ状態(hとc)はリセット
        # LSTMのパラメータは更新されたまま残る
        h0, c0 = model.init_hidden()

        # 系列を入力して出力系列を求める
        # output: (seq_len, batch, hidden_size)
        output, (h, c) = model(X, h0, c0)

        # many-to-oneのタスクなので出力系列の最後の要素のみ使う
        loss = criterion(output[-1], y)
        losses.append(loss.item())

        loss.backward()
        optimizer.step()

    losses = moving_average(losses, 100)
    plt.title('n_features=%d, hidden_size=%d, out_index=%d' % (n_features, hidden_size, out_index))
    plt.plot(losses, label='length=%d' % length)
    plt.legend()

f:id:aidiary:20180827211530p:plain

入力系列が長いほどlossが下がらず、予測が難しいことがわかります。

以下、コードは似たような感じなので省略します。

予測の場所が系列の最後より遠い方が難しい?

f:id:aidiary:20180827211637p:plain

そのような傾向はあまりなさそう?

LSTMの隠れ状態のサイズを変えると?

f:id:aidiary:20180827211806p:plain

大きすぎても小さすぎてもダメ。

入力特徴量の次元数を変えると?

f:id:aidiary:20180827211901p:plain

次元数が大きいほど問題が難しくなるのでlossが下がりにくくなる。

Long Short-Term Memory Networks With Python

最近、仕事でRNNを扱うアプリケーションが多くなっています。そのようなわけで、今回からしばらくRNN(Recurrent Neural Network)についてまとめていこうと思います。参考資料は、

です*1

この本は、RNNの様々なアーキテクチャを Keras で実装して解説しています。取り上げられているアーキテクチャは

  • Vanilla LSTM
  • Stacked LSTM
  • CNN LSTM
  • Encoder Decoder LSTM
  • Bidirectional LSTM
  • Generative LSTM

などです。RNNのタスクというと機械翻訳、音声認識、Image Captioningなど大規模なデータと長い訓練時間が必要なタスクが一般的ですが、この本ではCPUでも訓練できるほど基本的なタスクが取り上られています(MNISTより簡単なレベル)

Kerasでやってはこの本の真似になってしまうので、このブログではPyTorchでやっていきます

上の本のKerasのコードをPyTorchに翻訳しているのですが、KerasとPyTorchではRNNの実装方法がだいぶ違うことを実感しています。最近はPyTorchに慣れているせいか、Kerasの実装が難しく感じます。Kerasはコード量は少ないのですが、ドキュメント当たらないと読みときにくいタイプの難しさです。

個人的にKerasとPyTorchの両方とも使いこなしたいので、これからKerasとPyTorchのRNNの実装を比較しながらまとめていきたいと思います!

*1:このブログの記事はほとんど読んだのですが、機械学習、Deep Learning、Kerasの入門としてとてもよいサイトです。説明が異常に丁寧です。英語だけどオススメ。

Freesound General-Purpose Audio Tagging Challenge

最近、Kaggle始めました。登録自体は2年前にしてたのですが、興味起きなくてタイタニックやった後にずっと放置してました (^^;

f:id:aidiary:20180806164425p:plain

今回、取り組んだのはFreesound General-Purpose Audio Tagging Challengeという効果音に対して3つのタグをつけるコンペです。2秒ほどの音に対してギター、犬の鳴き声、チャイム、シンバルなどの41個のタグから3つを付与します。1つの音声に対する正解のタグは1つなのですが、3つのうち正解のタグが上位にあるほどスコアが高くなります。

Deep Learningで音を分類したり、生成したりする技術に興味があったので取り組んでみました。Private Leader Board上では比較的上位に食いこめたので参考までにアプローチをまとめておこうと思います!

Kaggleにはカーネルという実験をまとめたJupyter Notebookを公開できる仕組みがあり、Zafarさんという方がKerasを使って Beginner's Guide to Audio Dataというとてもよいチュートリアルをまとめています。このカーネルは @daisukelab さんが翻訳されてました。感謝!

入力特徴量

入力特徴量は、メルスペクトログラム (64次元)またはMFCC(64次元)です。

手前味噌ですが、MFCCは以前、自分で実装したことあります。

最近は、librosaという音を扱うのにとても便利なPythonパッケージができたため、自分で実装しなくても簡単に計算できます。以下の記事はlibrosaでメルスペクトログラムやMFCCを抽出する方法をlibrosaの実装にまで踏み込んで開設されていて参考になります。

アーキテクチャ

音の分類で使われるモデルアーキテクチャは、画像分類で使われるのと同じ畳み込みニューラルネットワーク(CNN)が一般的です。ただ、画像と違ってあまり深いネットワークは使われません。また音は時系列データでもあるためLSTMなどのRNN系のモデルもよく使われています。

画像は2次元の広がりを持つデータなのでの2次元のカーネルを使いますが、音声は2次元と1次元の両方のカーネルが使えます。どちらがよいかは対象タスクによるのでとりあえず全部試した方がよいと思います。今回は

  • 畳み込みニューラルネットワーク(2次元カーネル)
  • 畳み込みニューラルネットワーク(1次元カーネル)
  • CRNN (Convolutional Recurrent Neural Network)
  • ResNet(1次元カーネル)

の4種類を検討しました。CRNNは畳み込みの結果のあとにLSTMを使うモデルです。

上記の特徴量とアーキテクチャの組み合わせで複数のモデルをたくさん作り、最後にそれらの分類結果をアンサンブルします。多分、コンペの王道です。

その他の細かい工夫

データ標準化

メルスペクトログラムやMFCCの値を音声ファイルごとに平均0、標準偏差1になるように標準化しました。本来は、訓練データ全体で平均0、標準偏差1になるようにするのが好ましいかもしれませんが、音声ファイル単位で処理しています。また、メルスペクトログラムやMFCCの各次元ごとに標準化せずに全体で標準化しています。

Cyclic Learning Rateを導入

fast.aiでも推奨されてましたが、局所最適解に陥りにくくなるそうです。学習曲線がギザギザしているのはそのためです。

データ拡張

音声波形のランダムクロップのみです。雑音付与、音の高さ変更、音の長さ変更などいろいろ考えられますが試しませんでした。

Test Time Augmentation (TTA)

テスト時にデータ拡張してそれらの結果を平均化する手法です。今回はランダムクロップのみですが、導入することでLBスコアで2〜3ポイントほど改善しました。

学習曲線

f:id:aidiary:20180719180459p:plain

ギザギザしているのはCyclic Learning Rateを導入したせいです。

valid/accが高い順に並べると、

  1. ResNet (mfcc)
  2. CNN2d (mfcc)
  3. CNN1d (mfcc)
  4. CNN2d (mel spectrogram)
  5. CRNN (mfcc)
  6. CNN1d (mel spectrogram)
  7. CRNN (mel spectrogram)

となりました。ResNetはvalid/accが高いのですが、train/accが100%に達しており、過学習してる感じです。全体的にメルスペクトログラムよりMFCCの方が精度が高いという結果になっています。

スコア

学習時はTop-1の精度で求めていますが、Kaggle上ではTop-3のMean Average Precision (MAP@3) でスコアがつきます。

手法 val_acc Public LB 収束エポック TTA LB
cnn2d_mfcc 0.782 0.881 epoch 103 0.887
cnn1d_mfcc 0.744 0.849 epoch 121 0.853
cnn2d_melgram 0.731 0.826 epoch 118 0.843
cnn1d_melgram 0.665 0.784 epoch 141 0.815
crnn_mfcc 0.725 0.823 epoch 140 0.843
crnn_melgram 0.639 0.746 epoch 099 0.749
resnet_mfcc 0.788 0.871 epoch 105 0.888

上の全てのモデルの出力確率の幾何平均をとるアンサンブルを導入することで最終的なPublic LBのスコアは 0.937430 で38位/558チームでした。

最終的にPrivate LBのスコアは 0.892994 となり、61位/558チームまで下落してました(^_^;) おそらく、Validation Setに過学習してたのかなと思います。

一応、ブロンズメダルの範囲には入ってるみたいですが、このコンペはメダル出ないタイプでした。また何か面白そうなのは挑戦してみたいと思います!

その他のアイデア

他にも下のようなこと考えてましたが未達でした。

  • タグ付けの信頼度データを活用する
  • OpenSMILE の特徴量を活用する
  • Bi-directional RNNのようなより高度なアーキテクチャを使用する
  • さまざまなデータ拡張を導入する

あまり整理できてませんが、コードはこのリポジトリにあります。