機械学習奮闘記

CV系の論文実装とか

GANSynth: Adversarial Neural Audio Synthesis

論文

GANSynth: Adversarial Neural Audio Synthesis(Engel et al., 2019)
ICLR 2019に投稿されている.

概要

人間の知覚はグローバルな構造と細かい波形のコヒーレンスの両方に敏感であることから, 効率的な音声合成は本質的に難しい機械学習タスクである. WaveNetのような自己回帰モデルは局所構造をモデル化するが,グローバルな潜在的構造をモデル化できず, また反復サンプリングによる速度面でのパフォーマンス低下がある. これとは対照的に,GANはグローバルな潜在的条件付けおよび効率的な並列サンプリングが可能であるが, ローカルにコヒーレントな音声波形を生成するのが難しい. 我々はスペクトル領域において,十分な周波数分解能のもとで,対数振幅と瞬時周波数をモデル化することにより, GANがローカルにコヒーレントな音声を生成できることを実証した. また提案手法はNSynth Datasetを用いた実験によってWaveNetより優れた結果を示した. さらに提案手法はWaveNetに比べ桁違いに高速に音声を生成する.

音に関してはさっぱりであり,コヒーレンスとは?という感じ.でもいろいろ調べてなんとなく掴んだ.

貢献

  1. GANを用いて対数振幅と位相のスペクトログラムを生成することにより,従来の自己回帰モデルよりもコヒーレントな波形を生成した.
  2. 位相を直接推定するのではなく, 瞬時周波数を推定することで,よりコヒーレントな波形を生成した.
  3. 倍音が重ならないことが重要であるが,低周波倍音は非常に狭い範囲に存在しており,倍音が重複しやすい. 大きなSTFTフレームサイズとメル周波数スケールは,低周波倍音をより分離することができる.
  4. NSynthデータセットにおいてWaveNetより優れた結果を示し,またWaveNetより54,000倍高速に波形を生成する.
  5. 潜在空間およびピッチ空間におけるグローバルな条件付けにより、GANは知覚的に滑らかな音色の補間、およびピッチ全体で一貫した同一性を持つ音色を生成できる.

実装

完全なソースコードはここにある.時々修正するかもしれないけど.

github.com

Progressive Growing of GANs(Karras et al., 2018)

Progressive Growing of GANs(Karras et al., 2018)
GANSynthはPGGANをベースに画像の代わりにスペクトログラムを用いる形で音声を生成する. PGGANは以前にも実装したが,効率的な計算グラフを構築しようと思うと意外とややこしい.
ここでもう一度実装を見直しつつ,ハマったポイントを書いておく.フレームワークはTensorFlow.

Progressive Training

Progressive Trainingとは生成,認識する画像の解像度を徐々に上げながら学習することによって, まずはグローバルな構造を学習し,徐々にローカルな構造を学習していくというもの. これはまた学習の安定性と速度にも貢献する.

このネットワーク構造をどのような効率的な形で実装するかを考える. 効率的と書いたのは,計算効率を無視すれば単にforループを回せば簡単に実装できるが, TensorFlowにおいて,不必要な計算を避けるために,実行されるパスを動的に選択するためにはちょっと工夫がいる.

まず畳み込みや正規化の中身は置いておいて,各畳み込みブロックをどのように繋げるかを考える. 論文にある下図のようなパスを生成すれば良いのだが,これらのパスは全畳み込みブロックに存在する. 方針としては,可能性のあるパスは全て記述しておき,実行時に条件によって分岐させる. ここでいう条件とはProgressive Trainingの中核をなす部分で,どのくらいの解像度の画像を生成,認識したいですか,みたいなもの.

f:id:sakuma_dayo:20190222060435p:plain

最終的に以下のようなコードを書いた.まずは理解しやすいgeneratorから. コードを抜粋しただけなので,意味不明な変数もあるが,無視してほしい.

conv blockは畳み込み層であり,color blockは1×1 convolutionによって生成画像のチャンネル数まで落とす. growは前のconv blockから特徴マップを受け取って最終的な画像を返す関数である. PGGANでは徐々に解像度を上げていくので,全てのconv blockの後にcolor blockによって画像を出力する可能性がある. 各conv blockは処理を終えた後,さらに次のconv blockに特徴マップを渡してより高解像度の画像を生成してもらうか,まだ渡す時期じゃないと判断して,単純にcolor blockを通した後目的解像度までupscaleするかの2択がある.この分岐を実行時に選択させる. またupscaleする場合でも学習が十分でないconv blockは高解像度の画像は生成できないので,前の層が出力した画像との線形補間を行なって出力する.

ちなみにわざわざ関数にしたり,lambdaでラップしたりしてるが, これはtf.condの外で生成されたoperationは条件にかかわらず実行されてしまうからである.

def grow(feature_maps, depth):
    ''' depthに対応する層によって特徴マップを画像として生成
    Args:
        feature_maps: 1つ浅い層から受け取った特徴マップ
        depth: 現在見ている層の深さ(解像度が上がる方向に深くなると定義)
    Returns:
        images: 最終的な生成画像
    '''

    # 現在より深い層によって生成された画像(ここで再帰)
    def high_resolution_images():
        return grow(conv_block(feature_maps, depth), depth + 1)

    # 現在の層の解像度で生成した画像を最終解像度までupscaleする
    def middle_resolution_images():
        return upscale2d(
            inputs=color_block(conv_block(feature_maps, depth), depth),
            factors=resolution(self.max_depth) // resolution(depth)
        )

    # 1つ浅い層の解像度で生成した画像を最終解像度までupscaleする
    def low_resolution_images():
        return upscale2d(
            inputs=color_block(feature_maps, depth - 1),
            factors=resolution(self.max_depth) // resolution(depth - 1)
        )

    # 最も浅い層はlow_resolution_imagesは選択肢にない
    if depth == self.min_depth:
        images = tf.cond(
            pred=tf.greater(out_depth, depth),
            true_fn=high_resolution_images,
            false_fn=middle_resolution_images
        )
    # 最も深い層はhigh_resolution_imagesは選択肢にない
    elif depth == self.max_depth:
        images = tf.cond(
            pred=tf.greater(out_depth, depth),
            true_fn=middle_resolution_images,
            false_fn=lambda: lerp(
                a=low_resolution_images(),
                b=middle_resolution_images(),
                t=depth - out_depth
            )
        )
    # それ以外は以下のいずれかを出力する
    # 1. high_resolution_images
    # 2. low_resolution_imagesとmiddle_resolution_imagesの線形補間
    else:
        images = tf.cond(
            pred=tf.greater(out_depth, depth),
            true_fn=high_resolution_images,
            false_fn=lambda: lerp(
                a=low_resolution_images(),
                b=middle_resolution_images(),
                t=depth - out_depth
            )
        )
    return images

discriminatorも基本的にはgeneratorと同じだが,正直generatorより理解が難しかった. なるべくgeneratorと同じような手続きを踏むように試行錯誤した.

discriminatorにおけるconv blockとcolor blockはgeneratorのそれと逆の操作になる. つまり,conv blockは転置畳み込み,color blockは入力画像のチャンネル数を畳み込みカーネルに合うチャンネル数まで上げる.

def grow(images, depth):
    ''' depthに対応する層によって画像を特徴マップとして取り込む
    Args:
        images: 入力画像(depthに関わらず一定)
        depth: 現在見ている層の深さ(解像度が上がる方向に深くなると定義)
    Returns:
        feature_maps: 1つ浅い層に渡す特徴マップ
    '''

    # 現在より深い層によって取り込まれた特徴マップ(ここで再帰)
    def high_resolution_feature_maps():
        return conv_block(grow(images, depth + 1), depth)

    # 現在の層の解像度までdownscaleした後,特徴マップとして取り込む
    def middle_resolution_feature_maps():
        return conv_block(color_block(downscale2d(
            inputs=images,
            factors=resolution(self.max_depth) // resolution(depth)
        ), depth), depth)

    # 1つ浅い層の解像度までdownscaleした後,特徴マップとして取り込む
    def low_resolution_feature_maps():
        return color_block(downscale2d(
            inputs=images,
            factors=resolution(self.max_depth) // resolution(depth - 1)
        ), depth - 1)

    # 最も浅い層はlow_resolution_feature_mapsは選択肢にない
    if depth == self.min_depth:
        feature_maps = tf.cond(
            pred=tf.greater(in_depth, depth),
            true_fn=high_resolution_feature_maps,
            false_fn=middle_resolution_feature_maps
        )
    # 最も深い層はhigh_resolution_feature_mapsは選択肢にない
    elif depth == self.max_depth:
        feature_maps = tf.cond(
            pred=tf.greater(in_depth, depth),
            true_fn=middle_resolution_feature_maps,
            false_fn=lambda: lerp(
                a=low_resolution_feature_maps(),
                b=middle_resolution_feature_maps(),
                t=depth - in_depth
            )
        )
    # それ以外は以下のいずれかを出力する
    # 1. high_resolution_feature_maps
    # 2. low_resolution_feature_mapsとmiddle_resolution_feature_mapsの線形補間
    else:
        feature_maps = tf.cond(
            pred=tf.greater(in_depth, depth),
            true_fn=high_resolution_feature_maps,
            false_fn=lambda: lerp(
                a=low_resolution_feature_maps(),
                b=middle_resolution_feature_maps(),
                t=depth - in_depth
            )
        )
    return feature_maps

サラっとコンパクトに書いてるが,このgrowの設計にPGGANの実装のほとんどを費やしている. ちなみにNVIDIA本家の実装も公開されている. 本家の実装にはlinear structureとrecursive structureの2種類が提案されており, linear structureは実装は容易だが,毎回不必要なパスも全て実行するので非効率である. recursive strucrureは今回の実装のように条件分岐を行なっているので,効率的ではあるが,実装は若干込み入っている. 本家のコードもrecursive strucrureはhuman-unreadableとあり,確かに読むの辛かったので,自分なりに整理して書いた.

Minibatch Standard Deviation

Minibatch Discrimination(Salimans et al., 2016)を単純化したもの. この統計量を特徴としてdiscriminatorに渡すことで出力画像の多様性が向上するらしい. Minibatch Standard Deviationは論文では,

  1. 特徴マップの各ピクセルのバッチ方向の標準偏差を計算 [N, C, H, W] -> [C, H, W]
  2. チャンネル方向,空間方向で平均をとる        [C, H, W] -> [ ]
  3. バッチ方向,空間方向に複製する           [ ] -> [N, 1, H, W]
  4. 元の特徴マップにconcatする             [N, C, H, W] -> [N, C + 1, H, W]

とあるが,本家の実装を見るとバッチをさらにグループとして分割した上で上記の処理を行なっている.このときの実装はこんな感じ.

def batch_stddev(inputs, group_size=4, epsilon=1e-8):
    shape = inputs.shape.as_list()
    stddev = tf.reshape(inputs, [group_size, -1, *shape[1:]])
    stddev -= tf.reduce_mean(stddev, axis=0, keepdims=True)
    stddev = tf.square(stddev)
    stddev = tf.reduce_mean(stddev, axis=0)
    stddev = tf.sqrt(stddev + epsilon)
    stddev = tf.reduce_mean(stddev, axis=[1, 2, 3], keepdims=True)
    stddev = tf.tile(stddev, [group_size, 1, *shape[2:]])
    inputs = tf.concat([inputs, stddev], axis=1)
    return inputs

このMinibatch Standard Deviationは最終層前に入れると良かったらしい.

Equalized Learning Rate

He initialization(He et al., 2015)で用いる分散を,重みの初期化に用いるのではなく,重みのスケーリングに用いる. 重み自体はNormal(0, 1)で初期化する. RMSPropやAdamなどの学習率適応型の確率的勾配降下法では,推定された標準偏差によって勾配を正規化するので, パラメータ更新のステップサイズが勾配のスケールに対して不変性を持つ. これによって大きなダイナミックレンジを持つパラメータは収束までに時間がかかる. 提案手法によって学習速度を全てのパラメータに対して一定に保つ.

提案手法とかよりもまず,従来の学習率適応型の確率的勾配降下法について理解が全然足りていないことに気づいた. 今までなんとなくで済ませてきたが,これを機に少しだけ理解できた.

Pixelwise Feature Vector Normalization

Local Response Normalization(Krizhevsky et al., 2012)をベースに,以下の式によってチャンネル方向に正規化する.

 \displaystyle b_{x, y} = a_{x, y} / \sqrt{\frac{1}{C} \sum_{c=0}^{C-1}{(a_{x, y}^{c})^{2} + \epsilon}}, where\ \epsilon=10^{-8}

これにより値の発散を防ぐことができるらしい.generatorのconv blockの後に挿入する.

cGANs with Projection Discriminator

GANSynthはACGAN(Odena et al., 2017)スタイルで,ピッチによる条件付けを行っている.
これはgeneratorには潜在ベクトルにクラスラベルのone-hot表現をconcatしたものを入力し, discriminatorではadversarial lossに加えて,classification lossを考慮する.

今回はACGANではなくcGANs with Projection Discriminator(Miyato et al. 2018)によって条件付けを行なった. これはクラスラベルのembeddingとdiscriminatorの最終層への入力となる特徴ベクトルの内積を取り,discriminatorの最終層の出力に加える.

generatorにおいてはConditional Batch Normalization(de Vries et al., 2017)によって条件付けを行なった. Conditional Batch Normalizationでは正規化後のスケールとバイアスをクラスラベルのembeddingでモデル化する.

Spectral Normalization

GANSynthはLipschitz制約を満たすようにGradient Penalty(Gulrajani et al., 2017)を適用している. 今回はGradient Penaltyの代わりにSpectral Normalization(Miyato et al. 2018)を用いた.

これはネットワークの重み行列をそのspectral normで割ることで正規化を行う. このspectral normは重み行列の最大特異値に等しい. これによりdiscriminatorのLipschitz定数を1に制限する.

GANSynth

やっと本題のGANSynthの話題に入れる.
音に関しては不安が多かったが,Google本家の実装があり,とても参考になった. この実装を参考にしながら理解を進めていく. おそらく波形をspectrogramに変換する所がこの論文の肝であり,一旦spectrogramにしてしまえば,あとは画像として扱えば良い. と言っても,そこまで込み入った処理はなく,意外と書くことなかったので一気に書く.

  1. 波形に対してSTFTを行なって振幅と位相を得る.
  2. 振幅と位相を次元圧縮なしでメルスケールに変換する.
  3. 振幅は対数をとり,位相はアンラップし,有限差分を取ることで,瞬時周波数に変換する.
  4. 対数振幅と瞬時周波数をそれぞれ[-1, 1]の範囲に正規化し,concatして2チャンネルの画像として扱う.

今回は4の正規化のために前もって全訓練データにおける対数振幅と瞬時周波数の値の範囲を計算しておいた. 分からなかったのは本家の実装にあるメルスケールから線形スケールに変換するmel_to_linear_matrixなる関数. linear_to_mel_matrixの擬似逆行列を求めていると思ったのだが,何をしているのかまだ分かっていない.

def _mel_to_linear_matrix(self):
    """Get the inverse mel transformation matrix."""
    m = self._linear_to_mel_matrix()
    m_t = np.transpose(m)
    p = np.matmul(m, m_t)
    d = [1.0 / x if np.abs(x) > 1.0e-8 else x for x in np.sum(p, axis=0)]
    return np.matmul(m_t, np.diag(d))

とりあえずこんな感じで疲れたので終わりにする.結果はまた載せたいと思う.
ていうかこんなことしてないで,卒論早く修正して出さなきゃだった...