• 33
    いいね
  • 0
    コメント

性懲りもなく前回の記事の追加実験を続けています。
DCGANを書きたくなったので書いてみたところ、どうでもいい知識が溜まったので書いておきます。
内容は主に以下になります。

  • KerasのTips的なの
  • DCGANいじる過程

DCGAN自体の説明は他の記事を参照してください。私は主にこのあたりを参考にしました。

Chainerを使ってコンピュータにイラストを描かせる
Chainerで顔イラストの自動生成
keras-dcgan

Keras関連

Kerasのことしか書いてないので興味ない方は読み飛ばしてください。

Kerasのtrainable

Keras DCGAN で検索すると一番上にkeras-dcganがでてきます。参考にしようと覗いてみると訓練中にtrainableの値を切り替えることによってGenerator学習時にDiscriminatorの重みを更新しないようにしているようです。
Kerasのドキュメンテーションを読んだことのある方はtrainable=Falseを指定することで層の重みの更新をさせないようにできることをご存知だと思いますが、上記のコードを読んで二つ引っかかるところがありました。
まず第一に、Modelのtrainableを設定してもその中の層はtrainable=Trueなままで、重みを更新してしまうということです。これは前回の記事でも軽く触れました。
第二に、Keras DocumentationのFAQに重み更新停止について以下のように書かれています。

加えて,インスタンス化後に層のtrainablepropertyにTrueかFalse を与えることができます。このことによる影響としては,trainablepropertyの変更後のモデルでcompile()を呼ぶ必要があります。以下にその例を示します:

keras-dcganではtrainable変更後にcompileを行っていないため学習に反映されず、間違った進行になる気がします(例ではけっこうまともな画像が出力されてるし自分では試してないのでどうなってるのかは謎です)。

どうするか

では交互のステップごとに毎回全層のtrainableを設定してcompileして〜を繰り返すのか、と思うとうんざりするのでいろいろ試してベストっぽい方法を見つけました。
先述のとおりKerasはtrainable更新後にcompileしないと反映されないという仕様なのですが、それをうまく使えないか(というかうまく設計されていないか)と考えました。実験したコードを一部抜粋します。全文はこちら

modelA = Sequential([
    Dense(10, input_dim=100, activation='sigmoid')
])

modelB = Sequential([
    Dense(100, input_dim=10, activation='sigmoid')
])

modelB.compile(optimizer='adam', loss='binary_crossentropy')

set_trainable(modelB, False)
connected = Sequential([modelA, modelB])
connected.compile(optimizer='adam', loss='binary_crossentropy')

DCGANを極限まで切り詰めたようなモデルです。
インスタンス化直後はすべてtrainableな状態です。この状態でmodelBcompileすることでmodelB.fitで重み更新ができる状態になります。
次にset_trainablemodelBの全層にtrainable=Falseを設定し、modelAmodelBを連結したモデルconnectedcompileしています。
この状態でmodelB, connnectedfitしたらmodelBの重みはどうなるでしょうか。

w0 = np.copy(modelB.layers[0].get_weights()[0])

connected.fit(X1, X1)
w1 = np.copy(modelB.layers[0].get_weights()[0])
print('Freezed in "connected":', np.array_equal(w0, w1))
# Freezed in "connected": True

modelB.fit(X2, X1)
w2 = np.copy(modelB.layers[0].get_weights()[0])
print('Freezed in "modelB":', np.array_equal(w1, w2))
# Freezed in "modelB": False

connected.fit(X1, X1)
w3 = np.copy(modelB.layers[0].get_weights()[0])
print('Freezed in "connected":', np.array_equal(w2, w3))
# Freezed in "connected": True

驚くべきことに(?)以上のコードの出力はコメントにあるとおりになり、modelBでは学習可、connectedでは学習不可と、compile時の設定がちゃんと生きています。
すなわち最初に正しく設定すれば以降変更する必要は全くないということです。
これなら学習部分で毎回切り替えるなんてことをしなくて済んで綺麗にできます。
(余談ですがkeras-dcganは不要なcompileが多かったりしていろいろ怪しそうです。)

KerasのBatchNormalization

前項の問題をうまく解決できたのでコードを書き出しました。何も考えずに書いたのがだいたいこんな感じのものです。

discriminator = Sequential([
    Convolution2D(64, 3, 3, border_mode='same', subsample=(2,2), input_shape=[32, 32, 1]),
    LeakyReLU(),
    Convolution2D(128, 3, 3, border_mode='same', subsample=(2,2)),
    BatchNormalization(),
    LeakyReLU(),
    Convolution2D(256, 3, 3, border_mode='same', subsample=(2,2)),
    BatchNormalization(),
    LeakyReLU(),
    Flatten(),
    Dense(2048),
    BatchNormalization(),
    LeakyReLU(),
    Dense(1, activation='sigmoid')
], name="discriminator")

generator = Sequential([
# 省略
])

# setup models

print("setup discriminator")
opt_d = Adam(lr=1e-5, beta_1=0.1)
discriminator.compile(optimizer=opt_d, 
                      loss='binary_crossentropy', 
                      metrics=['accuracy'])

print("setup dcgan")
set_trainable(discriminator, False)
dcgan = Sequential([generator, discriminator])
opt_g = Adam(lr=2e-4, beta_1=0.5)
dcgan.compile(optimizer=opt_g, 
              loss='binary_crossentropy', 
              metrics=['accuracy'])

こいつを学習させようとすると以下のようなエラーとなりました。

Exception: You are attempting to share a same `BatchNormalization` layer across different data flows. This is not possible. You should use `mode=2` in `BatchNormalization`, which has a similar behavior but is shareable (see docs for a description of the behavior).

BatchNormalizationmode=2を設定しろと言われています。
これはShared Layersの項の例のように同じ層を他で使い回そうという時に、BatchNormalizationを共有すると、例えば二つの入力で分布が違う時などに不都合が起こるかもよ、ってことを言っているのだと思います。
上コードではDiscriminator単体とGenerator+Discriminatorをcompileしており、層が共有されてるとみなされたようです。DCGANではGenerator+Discriminatorの方ではtrainable=Falseになっていて学習に関係ないので、mode=2を指定しちゃって大丈夫だと思います。
考えれば分かることだけれどもBatchNormalizationのページで触れてほしい……。

Keras+DCGANでのtrain step

モデルができたので適当に学習させ始めました。
keras-dcganを参考に乱数のバッチでGeneratorを、同じ乱数から生成した画像と正解画像を混ぜたバッチでDiscriminatorを交互に学習させていくと謎の現象が発生しました。具体的にはこちらの記事で触れられている内容と同じです。

Discriminatorのパラメータ更新には,実画像のバッチと偽画像のバッチ2つを1つのバッチにまとめて更新する方法と,明示的に損失関数を2つに分けて更新する2通りの方法がある.Batch Normalization layerが無い場合だと最終的に得られる勾配はどっちも変わらないのだが,含めた場合は明確な違いが出てしまう(複雑ですね).最初前者の方法で更新していたらG, Dどちらも勝率が100%になってしまう奇妙な結果が得られてしまった.最初chainer側のバグかと疑ってしまったのだが,最終的にBNの性質に着目し後者の実装にしたら綺麗に収束した(バグではなかった).

正解画像と乱数画像を同じバッチに入れてノーマライズするのはやばそうというのは分かるのですが、なぜこのような現象が起こるのかは全然分かってないです(ちなみにkeras-dcganのDiscriminatorにはBNが入っていないのでこの問題を踏まないようです)。
書いてあるとおり「明示的に損失関数を2つに分けて更新」すればよさそうですが、明示的に損失関数を2つに分けるがどういう意味なのか未だにつかめていません。
こちらの記事にはソースコードが無かったので、かわりにこちらの記事のソースコードを覗いてみます。
該当部分

DCGAN.py
# train generator
z = Variable(xp.random.uniform(-1, 1, (batchsize, nz), dtype=np.float32))
x = gen(z)
yl = dis(x)
L_gen = F.softmax_cross_entropy(yl, Variable(xp.zeros(batchsize, dtype=np.int32)))
L_dis = F.softmax_cross_entropy(yl, Variable(xp.ones(batchsize, dtype=np.int32)))

# train discriminator

x2 = Variable(cuda.to_gpu(x2))
yl2 = dis(x2)
L_dis += F.softmax_cross_entropy(yl2, Variable(xp.zeros(batchsize, dtype=np.int32)))

#print "forward done"

o_gen.zero_grads()
L_gen.backward()
o_gen.update()

o_dis.zero_grads()
L_dis.backward()
o_dis.update()

sum_l_gen += L_gen.data.get()
sum_l_dis += L_dis.data.get()

#print "backward done"

Chainerは全く知らないですが、見た感じロスの計算を正解画像、乱数画像別々に行い、その後合わせて重みの更新を行っているようです。
で、同じことをKerasでもやろうと思ったのですが、Functional APIのあたりを探してもできそうなのがありませんでした。
結局妥協策として正解画像と乱数画像を分けて、別々にtrain_on_batchを行うという方法を取ることにしました(後述しますがDiscriminatorにBN入れた場合一回も成功しなかったのでこれが正しくない可能性は大いにあります)。

DCGAN

設定

環境

Ubuntu16.04
Core i7 2600k
Geforce GTX1060 6GB

データセット

データセットは前回の記事でも使った32x32のグレースケール奈落文字画像54枚、をスケールして輝度をいじって上下左右に移動して無理矢理に増やした11664枚の画像です(元画像だけおいてます)。
そういうわけなので以降の内容の一般性は無いものと思ってください。

各種計測

DCGANの評価方法なんて知らないので適当に考えた方法で計測します。

  • ステップごとのtrain_loss, train_accuracy
  • エポックごとのval_loss, val_accuracy
  • 乱数ベクトル間の補完ができるか

最後のはこちらの記事が詳しいので読んでみてください。

DiscriminatorにBatchNormalization入れた場合

まず最初は論文のとおりDiscriminatorとGeneratorどちらにもBatchNormalization入れるようにしました。
train_lossとtrain_accuracyをプロットしたのが以下の画像です。
norm_high_d.png
norm_low_d.png
Optimizerのパラメータを変えながらいろいろ試したのですが、どうやってもlossの最後の方に見える明らかに不自然な状態に陥りました。この状態で出力される画像の例が以下です。
norm_low_d_out.png
この他全部真っ黒だったり、とにかく乱数の入力にたいして同じような画像ばかり生成するようになってしまいました。
原因として考えられるのは先述のKerasの仕様に合わせたtrain stepですが、それは直しようがないので、結局keras-dcganに習ってDiscriminatorにはBNを使わないことにしました。
論文通りにやりたい人や、先人の実装例を使って楽したい人にはKeras以外でやるのが良いと思います。

BN排除以降

DiscriminatorからBNを排除したので(パラメータ調整は依然難しいものの)まともな画像を吐くようになってくれました。
リポジトリにおいていますがいろいろいじっている途中でもあるので以下のコードは変わっているかもしれません。

モデル

train_dcgan.py
# define models
discriminator = Sequential([
    Convolution2D(64, 3, 3, border_mode='same', subsample=(2,2), input_shape=[32, 32, 1]),
    LeakyReLU(),
    Convolution2D(128, 3, 3, border_mode='same', subsample=(2,2)),
    LeakyReLU(),
    Convolution2D(256, 3, 3, border_mode='same', subsample=(2,2)),
    LeakyReLU(),
    Flatten(),
    Dense(2048),
    LeakyReLU(),
    Dense(1, activation='sigmoid')
], name="discriminator")

generator = Sequential([
    Convolution2D(64, 3, 3, border_mode='same', input_shape=[4, 4, 4]),
    UpSampling2D(), # 8x8
    Convolution2D(128, 3, 3, border_mode='same'),
    BatchNormalization(),
    ELU(),
    UpSampling2D(), #16x16
    Convolution2D(128, 3, 3, border_mode='same'),
    BatchNormalization(),
    ELU(),
    UpSampling2D(), # 32x32
    Convolution2D(1, 5, 5, border_mode='same', activation='tanh')
], name="generator")

# setup models
print("setup discriminator")
opt_d = Adam(lr=1e-5, beta_1=0.1)
discriminator.compile(optimizer=opt_d, 
                      loss='binary_crossentropy', 
                      metrics=['accuracy'])

print("setup dcgan")
set_trainable(discriminator, False)
dcgan = Sequential([generator, discriminator])
opt_g = Adam(lr=2e-4, beta_1=0.5)
dcgan.compile(optimizer=opt_g, 
              loss='binary_crossentropy', 
              metrics=['accuracy'])

モデルは論文ベースに適当に変更を加えています。
Discriminatorはあまりにも学習が早く、Generatorが全然追いつけていなかったので、隠れ層のユニット数を必要以上に大きくし、学習率を落としています。

学習

train_dcgan.py
def create_random_features(num):
    return np.random.uniform(low=-1, high=1, 
                            size=[num, 4, 4, 4])
for epoch in range(1, sys.maxsize):

    print("epoch: {0}".format(epoch))

    np.random.shuffle(X_train)
    rnd = create_random_features(len(X_train))

    # train on batch
    for i in range(math.ceil(len(X_train)/batch_size)):
        print("batch:", i, end='\r')
        X_batch = X_train[i*batch_size:(i+1)*batch_size]
        rnd_batch = rnd[i*batch_size:(i+1)*batch_size]

        loss_g, acc_g = dcgan.train_on_batch(rnd_batch, [0]*len(rnd_batch))
        generated = generator.predict(rnd_batch)
        X = np.append(X_batch, generated, axis=0)
        y = [0]*len(X_batch) + [1]*len(generated)
        loss_d, acc_d = discriminator.train_on_batch(X,y)

        met_curve = np.append(met_curve, [[loss_d, acc_d, loss_g, acc_g]], axis=0)

    # val_loss等計算、出力やモデル保存が続く

keras-dcganを参考にしましたが、先にGeneratorを学習し、そのあとでDiscriminatorを学習するように順番を入れ替えました。Gを先にするほうが、その乱数がDを騙せている確率が高くなり、特に最序盤での学習が効率的になると考えたためです。効果はよく分かってないです。

出力

パラメータを調整してうまく行った例を示します。
3000エポックで8時間ほどかかりました。val_accが連続何回0だったら終了、みたいな条件を入れると良さそうです。
100エポック時点
100epoch.png
300エポック時点
300epoch.png
1000エポック時点
1000epoch.png
2000エポック時点
2000epoch.png
3000エポック時点
3000epoch.png

1000〜2000エポックくらいが一番良く見えますね。終わり際の3000まで行くと壊れてます。

train_loss/acuracyは以下のようになっていました。
a.png

Gのlossが振動しつつ右肩上がりになっています。いろいろ試したのですがDの学習率をかなり小さくしてもGのlossの上昇は抑えられませんでした。中間のあたりを取り出してみます。
met.png
train_lossがしょっちゅう飛びながらも全体としてはそこそこ安定していたようです。
train_accが低空飛行ですが、これがもっと大きいところで安定すると学習がより効率的に進むはずだと思います。特に上コードのようにG->Dの順に学習する場合、Gのtrain_accがDに入力される騙せている画像数に直結するので。

最後は二者間の補完ができることを見てみます。
middle.png
最左列と最右列はランダムなベクトルから生成された画像で、間がそれらを補完した画像です。文字っぽいかはともかくそこそこスムーズに変形しているので、うまく空間が形成されているようです。

まとめ

いろいろ問題を踏みつつも、DCGAN自体は力技(それなりのパラメータと時間)でなんとかなるように思いました。序盤のloss/accを見て無理そうだったら早めに諦めたりしていましたが、それらでも時間をかければなんとかなるのかもしれません。
学習がうまくできているかの評価としてtrain_loss/accを見ましたが、最序盤を乗り越えたあたりでGeneratorのlossが上がり始めるので、それが際限なく上がっていきそうだったら諦める基準として役立ちました。逆に値が安定していてもなかなか良い画像が得られなかったりもしたので、中盤以降の評価にはあまり役に立たなそうでした。
val_loss/accは箇条書きに挙げておいて全く出てきませんでしたが、ステップごとの変動があまりにも大きく、また進行方向もバラバラなため、エポックに一回評価するだけの値は大して意味がなかったです。停止判定としては役にはたちました。
二者間の補完による評価はDCGANがうまくできているかの客観的基準になりそうです。ただそこがクリアできていても、エポック数の進行と出力のそれらしさは必ずしも一致しないようなので、どの段階のモデルが一番それっぽいかは主観的な判断をせざるを得ないようです(特徴空間を走査して元画像の何割に対応する特徴ができているとか確認すれば良いかもですがめちゃくちゃ大変そう)。

Chainerはforwardとbackwardが分かれててなんでこうなってるんだろうと思っていましたが、今回のtrain stepがまさに効果的な状況だったようです。TensorFlowの実装もいくつか見てみたのですがサンプル止まりの私にはよく分からなかったです……。
Kerasで書くとそれらよりわかりやすくはなりますが、train stepでの制限がやはり気になりました。この記事では自由度が低いところが目立ちましたが、画像分類など一般的な問題をさくっと書くのには一番向いていると思うので、私と同じくTF勉強したくない人は試してみてください。

とりあえずそれっぽい奈落文字生成モデルができたので、メイドインアビス5巻発売のころには前回の記事をブラッシュアップしたのを投稿できたらと思っています。


11/20:追記
乱数の次元数を30まで落としたところ200エポックくらいでも良い出力が得られるようになった。
表現力とのトレードオフになるが、元画像の多様性に応じて次元数を調整すると学習速度を向上できそう。
出力の多様性についての尺度があればさらにいろいろ探れそうだが……。