はじめに
こんにちは。機械学習を勉強中のナノテクエンジニアです。現在、ナノ光学と呼ばれる分野で期待されている技術として、メタマテリアル(例:ナノパターンで高機能を実現するメタレンズなど)が挙げられます。最近実は、このメタマテリアルの電磁波シミュレーションにどんどん機械学習が取り入れられています。そこで出会ったのが、本ブログで紹介するConditional-GAN, C-GAN(Generative Adversarial Network)やオートエンコーダーという概念です。
個人的には、画像やデータの分類、分析にようやく慣れてきた一方、誰でもできる分類や回帰などに物足りなさも感じている今日この頃でした。機械学習でもっとCreativeなことができないか?その点で大きなポテンシャルを秘めるのがGANです。GANは半強化学習的な位置づけの画像生成ネットワークで、GANにおけるランダムな潜在空間から新しい画像を創造するという過程が、人間の意識が脳のアンテナを介して無からアイデアを生むのと似ていて、すごく面白い概念だと思います。想像ですが、AIに意識はあるのかを議論する際に案外重要な概念だったりするかも。本業でもいつか使えるかもしれないし、面白そうだからこの記事にGANの実装についてまとめることにしました。
対象の読者
深層学習やTensorflow, Kerasについてある程度理解のある方、それらについてさらに深めたいと思われる方、超解像やクリエイティブな応用に興味のある方
開発環境
Tensorflow 2.8.0
Python 3.9.12
Windows10 64bit
目次
1. C-GANとは
GANは2014年に提案された概念1で、日本語では敵対的生成ネットワークと呼ばれ、生成器(Generator)と識別器(Discriminator)の2つのネットワークから成ります。生成器は文字通り画像を生成するネットワーク。そして、識別器は生成された画像と元となる本物の画像とを識別するネットワークです。生成器は偽札の偽造者、識別器はそれを見抜く警察の役割に相当します。
GANの一般的なイメージ(図の引用元)
はじめ生成器はとんでもない画像を生成しますが、識別器に見破られまいと徐々に学習していきます。今度は識別器がわからないくらいの画像を生成器が生成してくると、識別器も精度を上げるため更に学習していきます。このように互いに競い合って精度を上げていく仕組みがGANです。
C-GAN2のCとは条件付きの略で、数字の1なら1を、3なら3をと、分類クラスを指定したうえで画像生成が可能となる枠組みです。よって、C-GANとGANの違いはただ単に、分類クラスの条件指定ができるかどうかというだけです。なぜC-GANをやろうと思ったかというと、ベクトルの足し算ができて、8と9の中間とか、1っぽい7とか画像生成を操作できるため、面白そうだからです。
現在はWGAN3(後ほどCifer10編で使う)、Progressive GAN4、StackGAN5、BigGAN6など様々な種類のGANが提案されており、今まさにホットな研究対象となっているようです。ほとんどが2017年以降に提案された比較的新しいモデルです。
GAN発展版については下記のサイトを参考にさせてもらいました。
- Generative adversarial networks ( GAN ) slides at FastCampus tutorial session
- A Tour of Generative Adversarial Network Models
- Must-Read Papers on GANs
では、現在のGANのレベルはどれほどのものなのでしょうか?
例えば、いそうでいない人の顔をGANを使って自動生成してくれるWEBサイトがあります。
→こちら かわいい、癒される。すごいクオリティ。
あとは、文字入力をしてそれを表現するような高解像度の画像を出力するようなもの(StackGAN5)もあります。こんなコード書けるようになったら楽しいだろうな~
ということで、GANは非常に注目されている枠組みであり、身近なところでも応用されていく機会が増えるのではないでしょうか。
2. 大まかな概要と目的
本ブログでは、深層畳み込み(Deep Convolution)を使ったDCGAN(以後DCGANをGANと呼ぶこととする)と呼ばれるタイプのモデルを構築します。GANのデモでよく使われるMNISTを使ってモデルを訓練し、クリアな手書き数字が生成できることを目指します。また、数字のクラスを指定することで、意図する数字が生成できるよう、条件付きのGANを実装します。最後に、どのパラメータが訓練や生成文字の質に影響するか考察をします。
先に結果を述べると、パラメータ設定にかなり苦労しましたが、2ブロックの畳み込み層で十分な質の手書き文字が生成できました。完ぺきとはいえませんが。
モデルを実装してパラメータをいろいろいじった感触としては、意外と大きな影響があったのが、LeakReLUの$α$の値、Optimizer Adamの学習率や$β$というパラメータでした。層構造を増やしたりいろいろ検討しましたが、以外にも層が少ないシンプルな構造の方が安定していて、きれいな結果でした。
3. 全体のネットワーク構成
概要説明
まず、生成器には $n_z$ 次元の潜在空間にランダムな値をもつベクトル $\displaystyle
\boldsymbol{z}$ を入力します。私はこれがAIの意識空間のようなものだと解釈しています。通常のGANであれば、これだけでよいのですが、条件付きの場合は生成器にどの数字を生成しようとしているのか教えてあげないと、指定の数字を生成してくれません。したがって、生成器にはカテゴリ $y$ の情報も同時に入力する必要があります。具体的な方法は下記で述べます。
識別器にはMNISTの本物の画像あるいは、生成器が作った偽の画像の画像を入力します。識別器にも、生成器と同じようにカテゴリ $y$ の情報をねじ込んで入力させる必要があります。例えば、カテゴリ $y=3$ の時は、2でも8でもなく、ぐちゃぐちゃな3でもなく、きれいな3の画像が本物なのだなと識別器に教えてあげないといけないからです。出力は0が偽、1が真となるようにし、sigmoid活性化関数により0~1で真偽を表現します。
生成器にとってみれば、識別器に真、つまり1と判断されれば、してやったりとなるわけです。一方で識別器は、ちゃんと識別することが仕事なので、偽画像に対しては0、本物画像に対しては1と判断することが、職務を全うすることに繋がります。この考えを基にBinaryCrossEntropyを用いて損失関数を定義していきます。
4. 生成器モデル
モデルの入出力
先ほど述べたように、生成器の入力には潜在ベクトル $\displaystyle
\boldsymbol{z}$ に加えて、数字の分類カテゴリを示すベクトル $\displaystyle
\boldsymbol{y}$ も入力しなければなりません。$y$ の情報の与え方として、ざっと先行事例を調べたところ、いくつかパターンがあることが分かりました。
Case Aは $y$ をone-hot表現して、10次元のベクトルで表し、それを潜在ベクトル $\displaystyle
\boldsymbol{z}$ に結合します。
Case Bは10通りある $y$ をEmbedding
層によって、潜在ベクトルと同じ $n_z$ 次元に埋め込みます。それをそのまま結合してもいいでしょうし、$\displaystyle
\boldsymbol{z}$ と要素ごとの掛け算をするなどしてエンコードしてもいいでしょう。
今回はCase Aが素直でわかりやすそうですし、実際に生成画像の質も良かったので、Aを採用します。出力画像は(28,28,1)となるようにし、tanh関数を用いて-1~1の範囲に出力させます。
モデルの畳み込み層
入力したベクトルは一度全結合層に繋ぎ、それを(7,7,28)にReshapeします。そこからアップサンプルしながら画素数を7⇒14⇒28と広げていきます。このアップサンプルの仕方としては、転置畳み込みConv2DTranspose
が一般的のようです。転置畳み込みは、画素の次元数を復元するだけで、畳み込みの逆過程を行う逆畳み込みとは異なることに注意が必要です。他には、アップサンプルUpSampling2D
によって、要素を2回ずつ繰り返して、画素数を倍に増やしたのちに、Stride1の畳み込みConv2D
を行う方法もあります。
転置畳み込みをするとそれ特有の模様?が現れるのが少し嫌だったのと、ノイズっぽい印象だったので、デフォルトではアップサンプル⇒畳み込みを行うこととしました。後で転置畳み込みも検討しています。
コード
build_G_layers
の中で、生成器モデルの層を定義しています。隠れ層の活性化関数にはReakyLeRU
を用いるのがよいとされているので、そうしました。引数の$α$は特に詳しく検討しておらず、0.01に設定しています。
BatchNormalization
は最終層を除き、各層に入れることが一般的であり、今回もそうしています。BatchNormalization
にはバイアスに相当するパラメータがあるため、直前の畳み込み層は、use_bias=False
とすることで、冗長なバイアスを無効にしています。
1層目への入力は潜在ベクトル $\displaystyle\boldsymbol{z}$ と 分類カテゴリのone-hotベクトル $\displaystyle\boldsymbol{y}$ を結合したベクトルを想定しており、$n_z+10$ のサイズを想定しています。
build_G_model
では、build_G_layers
で定義した層への入力の前処理を主に行っており、入出力を再度定義することで、生成器モデルを完成させます。潜在ベクトル $\displaystyle\boldsymbol{z}$ と 数字の分類クラスのone-hotベクトル $\displaystyle\boldsymbol{y}$ を別々に入力として受けて、それらをメソッド内で結合します。結合させた $\displaystyle\boldsymbol{z}$_$\displaystyle\boldsymbol{y}$を、build_G_layers
の層へ渡し、その出力をモデルの出力と定義します。
なお、このbuild_G_model
で作成したモデルは偽画像を生成するだけで、それ単体では生成器の訓練に使えません。訓練用には別途、識別器と結合したモデルを作成する必要があります。
def build_G_layers(self):
model = Sequential()
# 第一層
model.add(Dense(input_dim=self.z_dim+ self.num_class, units=128* self.rows* self.cols /16, use_bias=False))
model.add(BatchNormalization())
model.add(LeakyReLU(0.01))
model.add(Reshape((int(self.rows/4), int(self.cols/4), 128))) # output_shape=(4,4,128)
# 第二層
model.add(UpSampling2D(size=(2,2)))
model.add(Conv2D(filters=64, kernel_size=self.kernel_size, strides=(1,1), padding='same', use_bias=False)) # output_shape=(14,14,64)
model.add(BatchNormalization())
model.add(LeakyReLU(0.01))
# 第三層
model.add(UpSampling2D(size=(2,2)))
model.add(Conv2D(filters=self.chans, kernel_size=self.kernel_size, strides=(1,1), padding='same', use_bias=False, activation="tanh")) # output_shape=(28,28,1)
return model
def build_G_model(self):
""" 偽数字の生成用モデル """
z = Input(shape=(self.z_dim,)) # 潜在ベクトル
y_enc = Input(shape=(self.num_class,)) # one-hotにエンコードしたクラスラベル
generator = self.build_G_layers()
z_y = Concatenate(axis=1)([z, y_enc])
gen_img = generator(z_y)
return Model(inputs=[z, y_enc], outputs=gen_img)
5. 識別器モデル
モデルの入出力
既に述べたようにC-GANの場合、識別器にも数字の分類カテゴリ $y$ の情報を与えてあげる必要があります。入力画像サイズと同じ画素サイズを持つチャンネルをもう一つ確保して、10個の値をもつ $y$ をエンコードします。
Case Aでは28x28画素ある番地の内、1/10の78個を1とし、残りの706個を0とします。one-hot表現の冗長版のようなイメージです。先行事例には、$y$ のエンコードに1チャンネルではなく、10チャンネルを使用した例もありましたが、そこまでしなくてもいい気がしたので、今回は1チャンネルを使ってエンコードしています。
Case Bでは10種類の $y$ をEmbedding
によって784次元のベクトルに埋め込み、それを(28,28,1)の形にReshape
しています。
入力に関して、Case AとBであまり比較検討はしていませんが、生成器の場合と同じようにone-hot系のAを採用しました。出力はsigmoid活性化関数を用いて、0が偽、1が真となるように0~1で真偽を表現します。
モデルの畳み込み層
識別器の第1層へは、入力画像 $\displaystyle\boldsymbol{x}$ の(28,28,1)に、$y$ の情報をエンコードした(28,28,1)を結合し、(28,28,2)の2チャンネルの形で入力します。
それをStride2の畳み込みConv2D
を用いて、2回畳み込みをして、(28,28,2)⇒(14,14,64)⇒(7,7,128)とダウンサイズしていきます。最後の隠れ層で256の全結合層に繋ぎ、1つのノードを持つ出力へと伝搬させます。
コード
build_D_layers
の中で、識別器モデルの層を定義しています。BatchNormalization
とLeakyReLU
活性化関数を、生成器同様に各層に入れています。LeakyReLU
の$α$の値は0.2で設定しています。出力層の手前にはDropout
層を入れています。
上記で述べたように、build_D_layers
への入力は、入力画像 $\displaystyle\boldsymbol{x}$ と $y$ のエンコード画像 $\displaystyle\boldsymbol{y}$ を結合した $\displaystyle\boldsymbol{x}$_$\displaystyle\boldsymbol{y}$ の形で入力をするため、(28,28,2)の形を想定しています。
build_D_model
では、build_D_layers
で定義した層への入力の前処理を主に行っており、入出力を再度定義することで、識別器モデルを完成させます。入力画像 $\displaystyle\boldsymbol{x}$ と $y$ のエンコード画像 $\displaystyle\boldsymbol{y}$ を別々に受け取り、メソッドの中で(28,28,2)の形へと結合しています。結合させた $\displaystyle\boldsymbol{x}$_$\displaystyle\boldsymbol{y}$ をbuild_G_layers
の層へ渡し、その出力をモデルの出力と定義します。
識別器の訓練にはこのbuild_D_model
で作成したモデルを用います。
def build_D_layers(self):
model = Sequential()
# 第一層
model.add(Conv2D(filters=64, kernel_size=self.kernel_size, strides=(2,2), padding='same', \
input_shape=(self.rows, self.cols, self.d_label_channels+ self.chans), use_bias=False)) # output_shape=(14,14,64)
model.add(BatchNormalization())
model.add(LeakyReLU(0.2))
# 第二層
model.add(Conv2D(filters=128, kernel_size=self.kernel_size, strides=(2,2), padding='same', use_bias=False)) # output_shape=(7,7,128)
model.add(BatchNormalization())
model.add(LeakyReLU(0.2))
# 第三層
model.add(Flatten())
model.add(Dense(256))
model.add(LeakyReLU(0.2))
model.add(Dropout(0.5))
# 第四層
model.add(Dense(1, activation="sigmoid"))
return model
def build_D_model(self):
""" 識別器の訓練用モデル """
img = Input(shape=self.img_shape) # 入力画像
y_enc = Input(shape=(self.rows, self.cols, self.d_label_channels)) # クラスラベル
img_y = Concatenate(axis=3)([img, y_enc]) # input_shape=(28,28,self.d_label_channels+1)
discriminator = self.build_D_layers()
cls = discriminator(img_y)
return Model(inputs=[img, y_enc], outputs=cls)
モデルの参考リンク
6. 生成器訓練用の結合モデル
識別器の訓練は、生成器と独立で行うことができるので、前項の識別器モデルが使えます。一方、後で述べるtrain_on_batch
のメソッドで訓練を行う場合、生成器の訓練時には生成器と識別器をセットにして訓練する必要があります。そのため、2つを結合したモデルを別途作る必要があります。注意点として、訓練時に識別器の重みパラメータを変えないようにパラメータを凍結する必要があります。
build_G_model
で作った生成器モデルとbuild_D_model
で作った識別器モデルの各インスタンスを引数として取ります。このモデルの入力は生成器に入れる潜在ベクトル $\displaystyle\boldsymbol{z}$ と $y$ のone-hotベクトル $\displaystyle\boldsymbol{y}_g$、識別器に入れる $y$ のエンコード画像 $\displaystyle\boldsymbol{y}_d$ の3つです。このモデル内で、生成器によって偽画像を生成し、それを識別器に渡す作業を行います。最後に、識別器の真偽値0~1を受け取って、出力します。discriminator.trainable=False
とすることで、このモデルを訓練する際に識別器の重みパラメータを変えないようにできます。
def build_CGAN_model(self, generator, discriminator):
""" 生成器の訓練用モデル """
z = Input(shape=(self.z_dim,)) # 潜在ベクトル
y_enc_g = Input(shape=(self.num_class,)) # one-hotにエンコードしたクラスラベル for generator
y_enc_d = Input(shape=(self.rows, self.cols, self.d_label_channels)) # 1チャンネル全体にエンコードしたクラスラベル for generator
img = generator([z, y_enc_g])
discriminator.trainable=False # このモデルは生成器の訓練に使うために、識別器の重みパラメータは凍結しておく
cls = discriminator([img, y_enc_d])
return Model(inputs=[z, y_enc_g, y_enc_d], outputs=cls)
7. 損失関数の定義と訓練
損失関数の定義
損失関数にはBinaryCrossEntropyを用います。まず、識別器の場合を考えます。本物の画像と判断すれば1、偽物と判断すれば0を返すように訓練したいわけなので、本物画像に対してはラベル1を、偽物画像にはラベル0を正解とするように訓練すればよいことになります。よって、
\textrm{Loss}_{D_{\textrm{real}}}=-\sum_{i}^{n_{\textrm{batch}}}\ \log{D(\displaystyle\boldsymbol{x}_i)}~~~~~~~~~\rightarrow0~~\textrm{for}~~D(\displaystyle\boldsymbol{x}_i)=1\\
\textrm{Loss}_{D_{\textrm{fake}}}=-\sum_{i}^{n_{\textrm{batch}}}\ \log{(1-D(G(\displaystyle\boldsymbol{z}_i)))}~~\rightarrow0~~\textrm{for}~~D(G(\displaystyle\boldsymbol{z}_i))=0\\
とすればよいことになります。 $D(\displaystyle\boldsymbol{x}_i)$ は入力した画像 $\displaystyle\boldsymbol{x}_i$ に対する識別器の出力を意味します。本物画像に対しては1の出力で損失が最小の0をとり、偽物画像に対しては0の出力で損失が最小の0をとることが分かります。同じように考えると、生成器に対しては、識別器を欺きたいので、識別器が逆に本物、つまり1を返すような画像を作れるよう訓練したいわけです。そうすると、
\textrm{Loss}_G=-\sum_{i}^{n_{\textrm{batch}}}\
\log{(D(G(\displaystyle\boldsymbol{z}_i)))}~~\rightarrow0~~\textrm{for}~~D(G(\displaystyle\boldsymbol{z}_i))=1
とすればよいことが分かります。なお、 $G(\displaystyle\boldsymbol{z}_i)$ は生成器が作った画像を指します。
下記のコードで、Optimizerの定義をし、訓練に使うモデルのCompile
を行っています。こちらの情報ではOptimizerはAdam
がよいらいしのでそうします。Optimizerの $β$ の値は先行事例を参考にしています。
識別器モデルのインスタンスをまずbuild_D_model
によって生成します。これを損失関数をBinaryCrossEntropy
としてCompile
します。次に、生成器モデルのインスタンスをbuild_G_model
によって生成します。この時、この生成器モデルは単独では訓練に使わないため、これはこれでおいておきます。
生成器の訓練には、2つが結合したモデルを使います。そのモデルのインスタンスはbuild_CGAN_model
から生成します。このモデルに対してCompile
を行って、これで準備完了です。
def build_compile_model(self):
self.d_optimizer = tf.keras.optimizers.Adam(learning_rate=1e-4, beta_1=0.5, beta_2=0.9)
self.cgan_optimizer = tf.keras.optimizers.Adam(learning_rate=4e-3, beta_1=0.5, beta_2=0.9)
self.discriminator = self.build_D_model() # discriminatorの訓練に使う
self.discriminator.compile(loss='binary_crossentropy', optimizer=self.d_optimizer, metrics=['accuracy'])
self.generator = self.build_G_model() # Generatorの訓練にはGenerator単体では使わないで、以下のようにdiscriminatorと組み合わせる
self.cGAN = self.build_CGAN_model(self.generator, self.discriminator)
self.cGAN.compile(loss='binary_crossentropy', optimizer=self.cgan_optimizer, metrics=['accuracy'])
訓練
訓練は1バッチに、識別器の訓練と生成器の訓練のそれぞれが入ります。このように交互に訓練を繰り返していきます。バッチ内で識別器と生成器をそれぞれ訓練したいので、訓練にはtrain_on_batch
というメソッドが便利です。入力する画像は0-255のデータを-1~1へと変換するため、前処理が必要です。下記コードではread_dataでその作業を行っています。
まず、生成器に入力する潜在ベクトル $\displaystyle\boldsymbol{z}$ を生成します。 この生成の仕方として、例えば均一に-1~1を生成する場合と、0中心に正規分布で生成する方法があります。こちらの情報では後者の方がよいらしいので、正規分布で生成しました。なお、$\displaystyle\boldsymbol{z}$ のサイズは100次元をデフォルトとしました。
生成器に一緒に入力する分類カテゴリ $y$ の生成はnp.random.randint
を用います。これをto_categorical
によってone-hotベクトル $\displaystyle\boldsymbol{y}_g$ に変換します。これらの入力データを用いて、生成器から偽画像を作り出すことができます。
識別器には、画像 $\displaystyle\boldsymbol{x}$ に加えて $y$ のエンコード画像 $\displaystyle\boldsymbol{y}_d$ の入力が必要なので、それをencode_dメソッドによって生成します。実画像はX_trainから取得し、偽画像は生成器の出力を用います。
すべての入力が揃ったところで、まず識別器の訓練をします。識別器にはX_train, y_trainから取得した本物画像 $\displaystyle\boldsymbol{x}$ と対応する $y$ のエンコード画像 $\displaystyle\boldsymbol{y}_d$ のセットと、生成器から取得した偽物画像 $\displaystyle\bar{\boldsymbol{x}}$ と対応する $\displaystyle\boldsymbol{y}_d$ のセットを用意し、これらをまとめて識別器に入力します。識別器の正解ラベルは1がバッチサイズだけ、そして0がバッチサイズだけ並んだベクトルを用意します。あとは識別器モデルにtrain_on_batch
メソッドを適用し、識別器の出力と正解ラベルを用いて、BinaryCrossEntropyが下がるように重みを更新します。
生成器の訓練用モデルには生成器に入れる潜在ベクトル $\displaystyle\boldsymbol{z}$ と適当に選んだ $y$ のone-hotベクトル $\displaystyle\boldsymbol{y}_g$、及びエンコード画像 $\displaystyle\boldsymbol{y}_d$ の3つを入力します。識別器の正解ラベルには1がバッチサイズだけ並んだベクトルを用意します。最後に、生成器の訓練用モデルにtrain_on_batch
メソッドを適用し、このモデルの出力と正解ラベルを用いて、BinaryCrossEntropyが下がるように重みを更新します。
def encode_d(self, y):
y_enc = np.zeros(self.rows* self.cols* self.d_label_channels)
l = self.rows* self.cols* self.d_label_channels// self.num_class
y_enc[int(l*y):int(l*(y+1))] = 1
return y_enc.reshape((self.rows, self.cols, self.d_label_channels))
def read_data(self):
(X_train, y_train), (_, _) = mnist.load_data()
X_train = (X_train.astype(np.float32) - 127.5)/127.5
X_train = X_train.reshape(-1, *self.img_shape)
return X_train, y_train
def train(self):
X_train, y_train = self.read_data()
g_hist = []
d_hist = []
imgs_hist = []
for epoch in range(1, self.num_epoch+1):
idx = np.random.randint(0, len(X_train), len(X_train))
for i in range(int(X_train.shape[0] / self.batch_size)):
itr = (epoch- 1)* int(X_train.shape[0] / self.batch_size)+ i
""" 偽手書き数字の生成 """
#z = np.random.uniform(-1, 1, (self.batch_size, self.z_dim)) # 潜在ベクトル(-1~1 均一分布)
z = np.random.normal(0, 1, (self.batch_size, self.z_dim)) # 潜在ベクトル(μ0,σ1 正規分布)
f_y = np.random.randint(0, self.num_class, self.batch_size) # 数字の分類クラスをランダム生成
f_y_enc_g = tf.keras.utils.to_categorical(f_y, self.num_class) # 生成器に入力する分類クラスyをone-hot表示
f_y_enc_d = np.array(list(map(self.encode_d, f_y))) # 識別器に入力する分類クラスyを(28x28)画素に渡って0,1表示
f_img = self.generator([z, f_y_enc_g]) # 生成器で偽の手書き数字生成
""" 本物の手書き数字の用意 """
r_img = X_train[idx[i*self.batch_size:(i+1)*self.batch_size]] # 実データの画像
r_y = y_train[idx[i*self.batch_size:(i+1)*self.batch_size]] # 実データの分類クラスy
r_y_enc_d = np.array(list(map(self.encode_d, r_y))) # 識別器に入力する分類クラスyを(28x28)画素に渡って0,1表示
""" 生成画像を出力 """
f_img_num = (self.img_grid[0]- 1)*self.img_grid[1]
if itr % 300 == 0:
z_out = np.random.normal(-1, 1, (f_img_num, self.z_dim)) # 潜在ベクトル
y_out = np.arange(f_img_num)%10 # 偽手書き数字用に、数字の分類クラスを0-9まで順番に並べ、9回繰り返す
y_out_enc = tf.keras.utils.to_categorical(y_out, self.num_class) # 生成器に入力する分類クラスyをone-hot表示
ex = np.array([X_train[(y_train==i).reshape(-1)][np.random.randint(0, len(X_train[(y_train==i).reshape(-1)]))] for i in range(self.num_class)]) # 本物の数字を0-9まで並べる
imgs = self.generator([z_out, y_out_enc]) # 生成器で偽の手書き数字生成
imgs = np.concatenate([ex, imgs], axis=0) # 本物の数字が1行目に、偽者の数字が2-10行目に来るように並べる
self.create_montage(imgs, f"iter{itr}.png", self.img_grid) # モンタージュ画像の生成
if itr % 1500 == 0 or itr in [300, 600, 900]:
imgs_hist.extend(imgs[10:20])
""" 識別器の訓練 """
img = np.concatenate((r_img, f_img), axis=0) # 本物画像、偽画像の順に結合
label = np.concatenate((r_y_enc_d, f_y_enc_d), axis=0) # 本物画像に対応する数字の分類クラスy、偽画像に対応する数字の分類クラスyの順に結合
cls = np.concatenate((np.ones((self.batch_size,1)), np.zeros((self.batch_size,1))), axis=0) # 本物と識別で1, 偽者と識別で0とする
d_loss = self.discriminator.train_on_batch([img, label], cls) # 訓練、パラメータ更新
""" 生成器の訓練 """
#z = np.random.uniform(-1, 1, (self.batch_size, self.z_dim)) # 潜在ベクトル(-1~1 均一分布)
z = np.random.normal(0, 1, (self.batch_size, self.z_dim)) # 潜在ベクトル(μ0,σ1 正規分布)
y = np.random.randint(0, self.num_class, self.batch_size) # 数字の分類クラスをランダム生成
y_enc_g = tf.keras.utils.to_categorical(y, self.num_class) # 生成器に入力する分類クラスyをone-hot表示
y_enc_d = np.array(list(map(self.encode_d, y))) # 識別器に入力する分類クラスyを(28x28)画素に渡って0,1表示
g_loss = self.cGAN.train_on_batch([z, y_enc_g, y_enc_d], np.ones((self.batch_size,1))) # 訓練、パラメータ更新。偽の生成画像が本物(=1)と識別されるとLossが下がる
print(f"epoch: {epoch}, iteration: {itr}, g_loss: {g_loss[0]:.3f}, g_acc: {g_loss[1]:.3f}, d_loss: {d_loss[0]:.4f}, d_acc: {d_loss[1]:.3f}")
""" 損失の記録 """
g_hist.append(g_loss)
d_hist.append(d_loss)
self.generator.save_weights(os.path.join(self.path, 'generator.h5')) # 各エポックごとに重みパラメータを保存更新
self.discriminator.save_weights(os.path.join(self.path, 'discriminator.h5')) # 各エポックごとに重みパラメータを保存更新
self.create_montage(np.array(imgs_hist), "img_history.png", (len(imgs_hist)//10, 10)) # 指定のiteration時点での画像のモンタージュをプロットし、保存更新
self.plot_history(g_hist, d_hist) # 各エポックごとにLossとAccの訓練推移をプロットし、保存更新
if __name__ == '__main__':
cGAN = CGAN()
cGAN.build_compile_model()
cGAN.train()
8. 結果と考察
デフォルト条件
まずデフォルト条件の結果を示します。
一番左の図はIterationごとの生成画像の推移です。中央の図はIteration 12000時点で各数字を異なる $\displaystyle\boldsymbol{z}$ で生成したものです。一番右のグラフは訓練におけるBCE LossとAccuracyの推移です。
図から、指定した数字のカテゴリーを意図した通りに生成することができ、完ぺきとは言わないまでもまずまずの質で手書き数字を生成することができました。
Adam Optimizerの学習率とβを変えてみる
ここから、パラメータを振ってどのようにC-GANモデルが振舞うのか見てみることにします。最初にOptimizerの条件について検討します。
Adamには主に学習率と減衰率 $β_1$, $β_2$ というパラメータがあります。GAN用の推奨設定は $\textrm{lr}=2\times10^{-4}$, $β_1=0.5$, $β_2=0.9$ 。ここでは学習率と$β$ を変えてみます。
この $β$ とは?Adamの提案論文によると、Lossの勾配に対する指数平滑移動平均 $m_t$ と、Lossの勾配の二乗に対する指数平滑移動平均 $v_t$ を用いて、パラメータを更新しているようです。 $m_t$ に対する減衰率が $β_1$ 、$v_t$に対する減衰率が $β_2$ です。つまり $β$ が小さいほど、より直近の勾配の情報が重視されるということです。パラメータの更新は厳密には、時間経過に応じて補正した $\hat{m_t}$ と $\hat{v_t}$ を用いており、 $β$ の値はこの補正が及ぶ時間範囲にも影響します。
ということで、 $β$ をAdamのデフォルトである $β_1=0.9$, $β_2=0.999$ に戻して、より過去の勾配情報まで移動平均を広げたらどうなるのか検討してみます。学習率については、デフォルトでは生成器が $\textrm{lr}=4\times10^{-3}$ 、識別器が $\textrm{lr}=1\times10^{-4}$ となっているところを生成器の学習率を下げて、識別器のそれに近づけたらどうなるか検討します。
結果を下記に示します。生成器の学習率が識別器のそれに近づくほど、前半のLossの挙動が不安定になり、文字の質としてもデフォルト条件よりわずかに劣るように思えます。生成器と識別器との実力差は初期のころほど大きいので、生成器の学習率を早めてあげないと識別器になかなかついていけないと考えられ、予想通りの結果です。とはいっても、Con2の最後の方は安定しており、文字の質も大差はないので、これはこれでもいいかもしれません。
$β$ を大きくしてより過去の勾配情報まで拾おうとすると不安定になり、これもまた文字の質の低下につながることが分かりました。どうやらGANは比較的直近の勾配情報に焦点を当ててパラメータを更新していくのが良いようです。
LeakyReLUのαを変えてみる
LeakyReLU
のαについて検討します。勾配消失を防ぐために通常のReLUでなく、Leakyの方が推奨されていますが、値を変えるとどうなるのでしょうか?
デフォルト条件では生成器のαが0.01と小さめ、識別器のαは0.2と大きめとなっています。これに対して、両方とも0.2、両方とも0.01を取る場合を試してみました。すると、両条件とも途中で識別器のAccuracyが1に、生成器のAccuracyが0に張り付き、Lossも後半から不安定になっている様子が見て取れます。この症状が出ると、生成器は真っ黒な画像を返すようです。
ということで、生成器は通常のReLUに近い条件がよく、識別器はよりLeakyにしてあげたほうがよさそうという結論に至りました。なぜかはわかりません。
Kernel sizeと学習率の微調
デフォルトではKernel sizeは3ですが、先行例では5の場合もあるので、5も試してみました。また、デフォルトの学習率だと僅かですが、生成器のLossが右上がりとなっているので、これをまっすぐにした方がいいのではと思いました。そこで、もともと生成器が $\textrm{lr}=4\times10^{-3}$ 、識別器が $\textrm{lr}=1\times10^{-4}$ となっているところを微調整して、識別器のみ $\textrm{lr}=4\times10^{-5}$ と学習率をさらに落としました。
学習率とKernel sizeの両方を変えた結果を下記に示します。やや判断が難しいですが、識別器の学習率を落とすことで、生成器のLossの振れ幅が小さくなり、Loss, Accuracyの傾きがほぼ水平になりました。これによって、生成画像の質が良くなるかというとそうでもなさそうです。Kernel sizeは状況によりそうです。際どいですが、Con3の学習率とKernel sizeの両方を変えた条件が、デフォルト条件よりも僅かにきれいかなあ。以後はCon3を採用して、Default2とします。
少なくともKernel sizeはそこまで重要なパラメータではなさそうです。学習率は先ほどの検討でも述べたように重要ですが、この程度の変調なら大きな影響はなさそうです。
モデルの層構造を変えてみる
もうこの辺りで最後にしようかと思います。デフォルトの層構造では、生成器はDense×1⇒Upsampling2D/Conv2D×2と続き、識別器はConv2D×2⇒Dense×2と続きます。
これに対し、
- 条件1: すべての層のUnit数を倍に
- 条件2: 出力が(28,28,32)となる畳み込み層を生成器最後の隠れ層に1つ、識別器の最初の層に1つ加えた
- 条件3: 生成器の最初の層に2×2×256=1024の全結合層を加えた
- 条件4: 生成器の
Upsampling2D
/Conv2D
をConv2DTranspose
(転置畳み込み)に変えた
結果、Con4のConv2DTranspose
(転置畳み込み)への変更に関してはほとんど同等ですが、計算スピードがConv2DTranspose
の方が早いのと、Lossの振れ幅が小さい点で、転置畳み込みの方を選んだほうがよさそうです。
それ以外の条件変更の結果は、残念ながら全体的に悪化傾向でうまくいっていないです。Con2は明らかにLossの挙動が不安定になっています。Con3は $\displaystyle\boldsymbol{z}$ を変化させても異なるバリエーションの数字ができなくなってしまっています。余計な層を加えるだけで良化どころか、悪化するとは意外でした。
ということで、デフォルト条件からは、転置畳み込みへの変更、Kernel sizeを3から5へ変更、識別器の学習率を $\textrm{lr}=4\times10^{-5}$ へと変更の3点変更するのが現状のベストといえそうです。
おわりに
初めてのC-GAN実装ということで、パラメータをいろいろ変えたのち、ようやくまともな手書き数字を意図する通りに生成できるようになりました。高解像度の画像に比べれば手書き数字のC-GANの難易度は低いものの、パラメータでだいぶ苦労しました。パラメータを少し変えるだけで、結果が大きく変わることも分かりました。個人的にC-GAN、深層学習の良い勉強になりました。大変長くなってしまいましたが、ここまでお読みくださりありがとうございました。
今回用いたコードはこちら CGAN_mnist_DCGAN_1.py
次回は同じことをtrain_on_batch
メソッドでなく勾配テープメソッドGradientTape
で行いたいと思います。
⇒機械学習で創造的なことしよ ~Conditional-GAN × MNIST編2~