20210911のTensorFlowに関する記事は1件です。

コード進行認識AIを作るうえで工夫したこと

はじめに 私は和音を認識するAIを作っています。 今回は、精度を上げるために工夫したことを紹介したいと思います。 現在、和音を自動認識するアプリは、YAMAHAの「ChordTracker」や「Chord ai」などがあります。 どちらのアプリも、簡単なコード進行であれば、それなりの精度で認識できます。 しかし、コード進行が少し複雑だったり、コードが頻繁に変わるような場合は、うまく認識できないことがあります。 そのため、自分で作ってみることにしました。 完成したAIはGithubに置いてあります。 スペクトログラムに変換 オーディオデータをモデルの入力として使用するためには、スペクトログラムに変換する必要があります。 これは、生のオーディオデータのサイズが大きいことや、モデルにそのまま入力してもうまく特徴量を抽出できなかったりするためです。 データを変換する方法はいくつかありますが、ここでは、コード認識でよく使われているCQT(Constant-Q Transform)を使用します。 CQTが使われているのは、STFTなどに比べて入力データのサイズを小さくできることと、周波数方向の分解能がほぼ同じだからです。 コード認識では、低域から中域の部分に重点が置かれます。そのため、周波数方向の分解能がほぼ同じである方が良いのです。 実際に変換する際には、オーディオの読み込みからCQTの使用までができる「librosa」というPythonのライブラリがあります。 私はC++でアプリケーションを作ろうと思っていたので、CQTをC++で実装し、それをPythonに変換して使いました。 その際に低音と高音の解像度が同じになるようにQfactorを調整しました。 以下の画像は実際にCQTを使って変換したスペクトログラムです。 よく見ると、librosaの場合は低音が潰れていて、高音の解像度が高くなっているのがわかります。 私が実装した右側では、低音と高音がほぼ同じ大きさになっていることがわかります。 オクターブあたりのビン数 これは、1オクターブあたりどれだけの解像度で解析するかを意味します。librosaではbins_per_octaveです。 librosaのデフォルト値は12だと思います。1オクターブが12音なので、12は1オクターブあたり1の解像度ということになります。 これの最適な値は12*3、つまり1オクターブあたり3の解像度です。これ以上大きくしても精度にあまり変化は見られませんでした。 解像度を上げると入力のサイズも増加するので、保存するファイルの容量やモデルの使用するメモリ量が増えてしまいます。 データを保存するときの工夫 CQTや音声読み込みなどの処理は、リアルタイムで行えるほど軽くはありません。そのため、変換したデータをファイルに保存しておいて、学習時に読み込む必要があります。 データを保存する際には、必ずコードチェンジの部分で切り取るようにします。 その理由は、適当な場所でデータを切ってしまうと、コードの開始位置ではなく、途中が開始位置になってしまうからです。 ほとんどの場合、コードチェンジの直後に構成音が演奏されます。ベースの場合も同様です。 コードの途中では、ベースの音が動いていたり、構成音が鳴っていなかったりします。 データ拡張 データ拡張のために周波数マスキング、時間マスキングを追加しています。 ピッチシフトをして、学習データを12倍に増やしています。 モデルが出力するもの コード構成音ベクトル コードを予測する際には、いきなりコードを出力するのではなく、コード構成音ベクトルを予測し、それに基づいてコードを予測しています。 これは、コードのデータはかなり不均衡なデータであり、ベクトルを出力する方が不均衡を軽減できるためです。 何百ものコードから一つのコードを予測するよりも、どの音が鳴っているのかベクトルで出力して、それがどんなコードを表しているのかを予測する方が簡単です。 実際に予測するベクトルは、コード構成音とベース音を組み合わせたサイズ25のベクトルです。 以下は実際に予測したコード構成音ベクトルです。 多少のノイズや途切れはあるものの、はっきりとしたものが得られています。 平滑化したコード構成音ベクトル コード構成音ベクトルは出力が粗く、不自然に途切れてしまうことがあります。 以下の画像は平滑化する前のコード構成音ベクトルです。 不自然に途切れていて、人が見ればあり得ないと思うような箇所がいくつかあります。 そこで、出力を滑らかにするために、まったく同じベクトルを再度予測します。 以下の画像は平滑化後のベクトルです。 不自然な途切れがなくなり、はっきりとしたものになっていると思います。 コード遷移確率 人の場合、コードチェンジの瞬間をほとんど見分けることができます。耳コピができる人は、ほぼ100%コードチェンジの瞬間を見分けることができると思います。 コードチェンジが分かるということは、その瞬間まではコードが変化せず一定とわかります。 それをAIに学習させようというわけです。 コードチェンジが発生する瞬間は、曲全体のデータと比べて数えるほどしかありません。そのため、不均衡になり、学習が難しくなります。 調 (キー) 曲を耳コピするとき、コード進行をヒントにすることがあります。「王道進行」や「2-5-1進行」などよく使われるものがあります。 キーがわかっていれば、コード進行のルールや音の関係性のようなものを把握できるかもしれません。そうなることを期待して、キーを予測します。 不均衡データ対策 コードや遷移確率などのデータはかなり不均衡になっています。 そのまま学習してもサンプル数が少ないものはうまく検出できません。 不均衡データに対応するために、focallossというloss関数を使用します。 簡単に検出ができるものの重みを下げ、難しいサンプルを重視するというものです。 これを導入することによって不均衡データでもうまく学習できるようにします。 コード遷移確率や調の損失関数にはfocallossを、コード構成音ベクトルにはfocallossとtverskylossを組み合わせたfocaltverskyを使用しました。 損失関数のパラメータは、混同行列を見て調整しました。これはデータセットに依存するので、微調整する必要があります。 def tversky(y_true, y_pred, alpha=0.5, smooth=1e-6): # flatten label and prediction tensors y_true = K.flatten(y_true) y_pred = K.flatten(y_pred) # True Positives, False Positives & False Negatives TP = K.sum(y_true * y_pred) FP = K.sum((1 - y_true) * y_pred) FN = K.sum(y_true * (1 - y_pred)) return (TP + smooth) / (TP + alpha * FP + (1 - alpha) * FN + smooth) def focal_tversky(alpha=0.75, gamma=1.0): # gamma 1.0 が 通常と同じ # gamma 1.0 以下は使用しない def wrap(y_true, y_pred): pt_1 = tversky(y_true, y_pred, alpha) return K.pow((1 - pt_1), gamma) return wrap 実際に使用するときはこんな感じです。 loss = [focal_tversky(alpha=0.4, gamma=3.0), ...] gammaの値は高いほど難しいサンプルを優先します。 この値はあくまでも私が使ったデータセットのもので、 データセットごとに異なるため、一概にこうだとは言えません。 実際のモデル CNNとLSTMを組み合わせたモデルです。 LSTM部分はOpen-Unmixという音源分離モデルを参考にして作成しました。 実際のコードの予測は、CRFを使って結果が滑らかになるように出力しています。 import tensorflow as tf import tensorflow.keras.backend as K from tensorflow.keras import layers as L from tensorflow.keras.layers import Dense from tensorflow_addons.layers import StochasticDepth, GroupNormalization from tensorflow.keras.utils import get_custom_objects from tensorflow.python.ops import variables as tf_variables import tensorflow_addons as tfa import numpy as np DENSE_KERNEL_INITIALIZER = "he_normal" DENSE_USE_BIAS = False class EvoNormS0(tf.keras.layers.Layer): def __init__(self, groups=32, epsilon=1e-5, **kwargs): super().__init__(**kwargs) self.groups = groups self.epsilon = epsilon self.ndims = 1 def build(self, input_shape): self.ndims = len(input_shape) var_shape = [1 for i in range(self.ndims)] var_shape[-1] = input_shape[-1] self.gamma = self.add_weight(name="gamma", shape=var_shape, initializer=tf.initializers.Ones()) self.beta = self.add_weight(name="beta", shape=var_shape, initializer=tf.initializers.Zeros()) self.v_1 = self.add_weight(name="v1", shape=var_shape, initializer=tf.initializers.Ones()) def call(self, inputs, training=True): def group_std(inputs, groups, epsilon): input_shape = tf.shape(inputs) axis = -1 groups = tf.minimum(input_shape[axis], groups) group_shape = [input_shape[i] for i in range(self.ndims)] group_shape[axis] = input_shape[axis] // groups group_shape.insert(axis, groups) group_shape = tf.stack(group_shape) grouped_inputs = tf.reshape(inputs, group_shape) axes = [1, self.ndims] insert_num = self.ndims - 3 if insert_num > 0: for i in range(insert_num): axes.insert(-1, 2 + i) _, var = tf.nn.moments(grouped_inputs, axes, keepdims=True) std = tf.sqrt(var + epsilon) std = tf.broadcast_to(std, tf.shape(grouped_inputs)) return tf.reshape(std, input_shape) denominator = group_std(inputs, groups=self.groups, epsilon=self.epsilon) return (inputs * tf.sigmoid(self.v_1 * inputs)) / denominator * self.gamma + self.beta def get_config(self): config = {'groups': self.groups, 'epsilon':self.epsilon} base_config = super().get_config() return dict(list(base_config.items()) + list(config.items())) class FlattenChannelDimension(L.Layer): def __init__(self, **kwargs): super(FlattenChannelDimension, self).__init__(**kwargs) def build(self, input_shape): self.channels = input_shape[-1] def call(self, inputs): return L.concatenate( [inputs[:, :, :, i] for i in range(self.channels)] ) def get_config(self): config = super(FlattenChannelDimension, self).get_config() return dict(list(config.items())) class BBB(L.Layer): def __init__(self, **kwargs): super(BBB, self).__init__(**kwargs) def call(self, inputs): inputs = [inputs[:, :, idx:idx + 36] for idx in range(0, 504, 36)] return K.stack(inputs, axis=-1) def get_config(self): config = super(BBB, self).get_config() return dict(list(config.items())) class ChannelAttention(L.Layer): def __init__(self, **kwargs): super(ChannelAttention, self).__init__(**kwargs) self.concat = L.Concatenate() self.conv1 = L.Conv2D( filters=1, kernel_size=3, activation="sigmoid", padding="same", use_bias=False, kernel_initializer=DENSE_KERNEL_INITIALIZER) self.mul1 = L.Multiply() def call(self, inputs): avg_pool = K.mean(inputs, axis=-1, keepdims=True) max_pool = K.max(inputs, axis=-1, keepdims=True) x = self.concat([avg_pool, max_pool]) x = self.conv1(x) return self.mul1([inputs, x]) def get_config(self): config = super(ChannelAttention, self).get_config() return dict(list(config.items())) @classmethod def from_config(cls, config): return cls(**config) class SpatialAttention(L.Layer): def __init__(self, ratio=8, **kwargs): super(SpatialAttention, self).__init__(**kwargs) self.ratio = ratio def build(self, input_shape): channels = input_shape[-1] self.dense_1 = Dense(channels // self.ratio, activation='relu', use_bias=True, bias_initializer='zeros', kernel_initializer=DENSE_KERNEL_INITIALIZER) self.dense_2 = Dense(channels, use_bias=True, bias_initializer='zeros', kernel_initializer=DENSE_KERNEL_INITIALIZER) self.gap2d = L.GlobalAveragePooling2D() self.reshape_1 = L.Reshape((1, 1, channels)) self.gmp2d = L.GlobalMaxPooling2D() self.reshape_2 = L.Reshape((1, 1, channels)) def call(self, inputs): avg_pool = self.gap2d(inputs) avg_pool = self.reshape_1(avg_pool) avg_pool = self.dense_1(avg_pool) avg_pool = self.dense_2(avg_pool) max_pool = self.gmp2d(inputs) max_pool = self.reshape_2(max_pool) max_pool = self.dense_1(max_pool) max_pool = self.dense_2(max_pool) x = L.Add()([avg_pool, max_pool]) x = L.Activation("sigmoid")(x) return L.Multiply()([inputs, x]) def get_config(self): base_config = super(SpatialAttention, self).get_config() base_config.update({"ratio": self.ratio}) return dict(list(base_config.items())) @classmethod def from_config(cls, config): return cls(**config) class FReLU(L.Layer): def __init__(self, **kwargs): super(FReLU, self).__init__(**kwargs) self.conv1 = L.DepthwiseConv2D(3, strides=(1, 1), padding='same', use_bias=False) self.bn1 = L.BatchNormalization() self.max1 = L.Maximum() def call(self, inputs): #T(x)の部分 x = self.conv1(inputs) x = self.bn1(x) #max(x, T(x))の部分 x = self.max1([inputs, x]) return x def get_config(self): config = super(FReLU, self).get_config() return dict(list(config.items())) @classmethod def from_config(cls, config): return cls(**config) class WeightNormDense(L.Dense): def build(self, input_shape): super().build(input_shape) self.g = self.add_weight( name='g', shape=[self.units,], initializer='one', dtype=self.dtype, trainable=True) def call(self, inputs): kernel = self.kernel * self.g / K.sqrt(K.sum(K.square(self.kernel), axis=0)) output = K.dot(inputs, kernel) if self.use_bias: output = K.bias_add(output, self.bias) if self.activation is not None: output = self.activation(output) return output def get_config(self): base_config = super(WeightNormDense, self).get_config() return dict(list(base_config.items())) Dense = WeightNormDense # Squeeze and Excitation def channel_spatial_squeeze_excite(inputs, ratio=8): ''' Create a channel-wise squeeze-excite block Args: input: input tensor filters: number of output filters Returns: a keras tensor References - [Squeeze and Excitation Networks](https://arxiv.org/abs/1709.01507) ''' cbam = ChannelAttention()(inputs) cbam = SpatialAttention(ratio=ratio)(cbam) return cbam def _add_lstm_layer( n_layers, hidden_size, dropout=0.0, bidirectional=True, residual=False, lstm_type="lstm"): def wrap(input_layer): layer = input_layer for i in range(n_layers): if lstm_type == "lstm": lstm = L.LSTM(hidden_size, return_sequences=True, dropout=dropout) elif lstm_type == "lnlstm": lstm = L.RNN(tfa.rnn.LayerNormLSTMCell(hidden_size), return_sequences=True) elif lstm_type == "peephole": lstm = L.RNN(tfa.rnn.PeepholeLSTMCell(hidden_size), return_sequences=True) if bidirectional: pre_layer = L.Bidirectional(lstm)(layer) else: pre_layer = lstm(layer) if i > 0 and residual: layer = L.Add()([pre_layer, layer]) else: layer = pre_layer return layer return wrap def _dense( input_layer, hidden_size=128): return Dense( hidden_size, use_bias=DENSE_USE_BIAS, kernel_initializer=DENSE_KERNEL_INITIALIZER)(input_layer) def _dense_bn_relu( input_layer, hidden_size=128, activation=None, name=None): x = _dense( input_layer, hidden_size=hidden_size) if hidden_size % 8 == 0: x = EvoNormS0(8)(x) else: x = L.BatchNormalization()(x) if activation: if type(activation) == str: x = L.Activation(activation, name=name)(x) return x def conv(filters, alpha=2, kernel_size=(3, 3), strides=(1, 1), droprate=0.5): def wrap(inputs): input_filters = int(inputs.shape[-1]) x = L.Conv2D(filters, kernel_size=kernel_size, strides=strides, padding="same", use_bias=False, kernel_initializer="he_normal")(inputs) x = EvoNormS0(8)(x) x = channel_spatial_squeeze_excite(x, ratio=8) if alpha != 1: x = L.Conv2D(filters // alpha, kernel_size=(1, 1), padding="same", use_bias=False, kernel_initializer="he_normal")(x) if input_filters == filters // alpha: x = StochasticDepth(droprate)([inputs, x]) return x return wrap class DualPathRNN(L.Layer): def __init__(self, units, batch_size, dropout=0.1, **kwargs): super(DualPathRNN, self).__init__(**kwargs) self.units = units self.batch_size = batch_size self.dropout = dropout def build(self, input_shape): self.lstm_1 = L.Bidirectional(L.LSTM(self.units, return_sequences=True)) self.lstm_2 = L.Bidirectional(L.LSTM(self.units, return_sequences=True)) self.fc1 = L.Dense(self.units * 2) self.fc2 = L.Dense(self.units * 2) self.norm1 = L.LayerNormalization(center=False, scale=False) self.norm2 = L.LayerNormalization(center=False, scale=False) self.dropout1 = L.Dropout(self.dropout) self.dropout2 = L.Dropout(self.dropout) def call(self, inputs): num_features = K.int_shape(inputs)[-1] x = inputs chunk_size = 256 x = tf.reshape(x, (-1, chunk_size, num_features)) x = self.lstm_1(x) x = tf.reshape(x, (-1, self.units * 2)) x = self.fc1(x) x = tf.reshape(x, (-1, chunk_size, self.units * 2)) x = self.dropout1(x) x = self.norm1(x) x = tf.reshape(x, (self.batch_size, -1, self.units * 2)) if K.int_shape(inputs)[-1] == K.int_shape(x)[-1]: stage_1 = inputs + x else: stage_1 = x x = self.lstm_2(stage_1) x = tf.reshape(x, (-1, self.units * 2)) x = self.fc2(x) x = tf.reshape(x, (self.batch_size, -1, self.units * 2)) x = self.dropout2(x) x = self.norm2(x) x = stage_1 + x return x def get_config(self): base_config = super(DualPathRNN, self).get_config() base_config.update({"units": self.units, "batch_size": self.batch_size}) return dict(list(base_config.items())) @classmethod def from_config(cls, config): return cls(**config) def chroma_net( input_layer, batch_size, hidden_size=512, stage_1_activation="tanh", lstm_n_layers=1, stage_2_activation="relu", dropout=0.0, dualRNN=False): x = input_layer if len(K.int_shape(input_layer)) > 3: x = FlattenChannelDimension()(x) x = BBB()(x) # stem x = L.Conv2D(24, kernel_size=(3, 3), strides=(1, 3), padding="same", use_bias=False, kernel_initializer="he_normal")(x) x = EvoNormS0(8)(x) x = conv(24, alpha=1, droprate=0.9)(x) x = conv(24, alpha=1, droprate=0.9)(x) x = conv(48, alpha=4, droprate=0.8)(x) x = conv(48, alpha=4, droprate=0.8)(x) x = conv(48, alpha=4, droprate=0.7)(x) x = conv(64, alpha=4, droprate=0.6)(x) x = conv(64, alpha=4, droprate=0.6)(x) x = conv(64, alpha=1, droprate=0.6)(x) x = FlattenChannelDimension()(x) x = _dense_bn_relu(x, hidden_size, activation=stage_1_activation) if dualRNN: x = DualPathRNN(hidden_size // 2, batch_size)(x) else: x = _add_lstm_layer( n_layers=lstm_n_layers, hidden_size=hidden_size // 2, bidirectional=True, dropout=dropout)(x) x = _dense_bn_relu(x, hidden_size, activation=stage_2_activation) return x def ChordNet( input_shape=(None, (12 * 3 * 7), 2), batch_size=8, use_sam=False, ): input_cqt = L.Input(shape=input_shape) masked = L.Masking(mask_value=0.0)(input_cqt) feature = chroma_net(masked, batch_size, hidden_size=512, dualRNN=True) chroma = _dense_bn_relu(feature, 25) chroma = L.ReLU(1.0, name="chroma")(chroma) transition = L.Dense(2, activation="softmax", name="trans")(feature) music_key = L.Dense(25, activation="softmax", name="key")(feature) bass = L.Dense(13, activation="softmax", name="bass")(feature) smooth_chroma = chroma_net(feature, batch_size, hidden_size=256, dualRNN=False) smooth_chroma = _dense_bn_relu(smooth_chroma, 25) smooth_chroma = L.ReLU(1.0, name="smooth_chroma")(smooth_chroma) chord = L.Dense(529, activation="softmax", name="chord")(smooth_chroma) model = tf.keras.Model( inputs=[input_cqt], outputs=[chroma, transition, bass, smooth_chroma, music_key, chord]) return model DualPathRNNは短い幅を見るLSTMと長い幅を見るLSTMを組み合わせた感じでしょうか。 混合行列 これが学習済みモデルで予測したコードの混合行列です。対角成分が濃いほど正確に予測できています。 一部を除いてほとんど正確に予測できていると思います。 混合行列を見ながら手動で重みを調整すれば、先ほどよりも正確に予測ができます。 7sus4や7(13)、mM7(9)、m7(13)などが正しく予想できていませんね。 7sus4やm7(13)は、CキーでいうF/GやDm7/Gなど(9sus4?)で採譜してしまうことがあるのでその影響が出ていると思われます。mM7(9)は学習データに1件しかありません。 ちなみに真っ白な部分はテストデータに存在しないものです。 テストデータが足りていませんね...。複雑なコードの曲はあまりないのでデータに入れるのが大変です。 参考 【Python】不均衡な2クラスセグメンテーション問題に適用するロス関数のメモ Tversky loss function for image segmentation using 3D fully convolutional deep networks Focal Lossを提案した革新的物体検出モデルRetinaNetを解説! [論文紹介] Focal Loss for Dense Object Detection
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む