search
LoginSignup
26

More than 3 years have passed since last update.

posted at

updated at

Organization

M(マッチョ)VAEを作って潜在空間をボディビル大会会場にしてみた

この記事は古川研究室 Workout_calendar 16日目の記事です。
本記事は古川研究室の学生が学習の一環として書いたものです。内容が曖昧であったり表現が多少異なったりする場合があります。

はじめに

機械学習に慣れていくために色々適当に実装していたところ、Varidational AutoEncoder(VAE)というものに出会いました。
VAEのサンプルコードは基本的にMNISTを対象としていて、モノクロ画像を扱っています。そこで私はカラー画像にVAEを適用するとどうなるか気になっちゃったわけですね。ついでにいうとマッチョが好きですから、どうせならマッチョをVAEに突っ込んでみようとなっちゃったわけです。

VAEとは

まずはVAEとはどういうものかについて説明していきたいと思います。
VAEとは、オートエンコーダー(AE)で行う特徴抽出を確率分布で近似して行うようにしたものです。
AEは以下のような構造を持っています。

入力画像より少ない素子数の隠れ層を経由して、元の画像が復元できるように各ニューロンの結合重みを学習します。このような構造にしているのは画像を表現するのに必要な次元は多くなく、少ない次元でも表現できるのではないかという考えがあってのことだと思います。
各重みを表す行列を$\mathbf{W}$とすると、隠れ層$\mathbf{z}$の値は

$$
\mathbf{z}=f(\mathbf{Wx+b})
$$

となります。$\mathbf{b}$はバイアス、$f$は活性化関数で、恒等関数でもシグモイド関数でもなんでもよいです。ここで大事なことは、入力と隠れ層間の重み$\mathbf{W}$と入力$\mathbf{x}$の行列積$\mathbf{Wx}$を取っていることです。

$$
\begin{pmatrix}
z_1 \\
\vdots \\
z_m
\end{pmatrix}=
\begin{pmatrix}
W_{11} & \cdots & W_{1n}\\
\vdots & \ddots & \vdots\\
W_{m1} & \cdots & W_{mn}
\end{pmatrix}
\begin{pmatrix}
x_1 \\
\vdots \\
x_n
\end{pmatrix}
+
\begin{pmatrix}
b_1 \\
\vdots \\
b_m
\end{pmatrix}
$$

この計算は入力$\mathbf{x}$と重み行列$\mathbf{W}$の行成分との内積を$\mathbf{z}$の各ニューロンの値としていることを表します。また内積を取るということは、入力$\mathbf{x}$と$\mathbf{W}$の行ベクトル成分がどれだけ似通っているかを抽出していると捉えられます。
また、隠れ層$\mathbf{z}$から出力$\mathbf{\hat x}$への変換は

$$
\mathbf{\hat x}=\tilde f(\mathbf{\tilde W z+\tilde b})
$$
と表せます。
この二つの式から入力$\mathbf{x}$から出力$\mathbf{\hat x}$への変換は
$$
\mathbf{\hat x}=\tilde f(\mathbf{\tilde W f(\mathbf{Wx+b})+\tilde b})
$$
となります。各パラメータ$\mathbf{W,\tilde W,b,\tilde b}$を$\mathbf{x}$と$\mathbf{\hat x}$の差が小さくなるように学習させます。

そうすることで隠れ層$\mathbf{z}$ではパラメータ$\mathbf{W,b}$を通して入力$\mathbf{x}$の特徴が抽出できます。この特徴$\mathbf{z}$のことを潜在変数と呼びます。(書籍によっては$\mathbf{W,b}$のことを特徴と呼んだりもする)

VAEでは潜在変数$z$(下図では簡単化のため1次元としているが多次元でもよい)が何らかの確率分布$p_{\theta} ^{*}$で生成されていると仮定します。
ここで$\theta ^{*}$は真のパラメータを表します。
また$\mathbf{x}$は潜在変数$z$により生成されると仮定します。つまり、$\mathbf{x}$は条件付確率$p_{\theta ^{*}}(\mathbf{x}|z)$から生成されるとします。
ここまでの情報を整理してVAEの構造を図に表すと以下のようになります。

潜在空間$z$はオートエンコーダーの隠れ層$\mathbf{z}$と同じ役割を持っています。
つまり、VAEはデータ$\mathbf{x}$を表す潜在変数$z$の確率分布のパラメータ$\mu,\sigma^2$を求めたいわけです。
実際は真のパラメータ$\theta ^*$を近似したパラメータ$\theta$を用いた条件付確率$p_{\theta}(z|\mathbf{x})$を推定しなければならないのですが、ベイズの定理で推定しようとすると
$$
\begin{equation}
p_{\theta}(z|\mathbf{x})=
\frac{p_{\theta}(\mathbf{x}|z)p_{\theta}(z)}{\int p_{\theta}(\mathbf{x}|z)p_{\theta}(z) dz}
\end{equation}
$$
を解くことになります。この式の分母にある$\int p_{\theta}(\mathbf{x}|z)p_{\theta}(z) dz$はきちんと計算するのは難しく、いろいろな近似が必要となるんですね。そこで変分ベイズとKingmaさんたちが考えたトリックを用いて(詳細は参考論文へ)、データ$\mathbf{x}$から潜在空間のパラメータを推定できるようにしたものがVAEです。

なぜVAEは確率分布を導入したのか!?

ここから先は僕の妄想を交えて潜在変数に確率分布を導入するメリットについて話したいと思います。
潜在変数に確率分布を導入するメリットは以下の2点です。
- 似たデータを近くの潜在変数にプロットできる
- 連続的な潜在変数を得られる

確率分布を導入することで「なぜ」このメリットを得られるのかについて説明している記事は私が探した限りでは少なかったため、機械学習初心者の拙い考察を残します。合ってるかの保証はありません

例として、犬と猫の画像をVAEに学習させたとしましょう。
学習が十分進んだ段階で、ある犬の画像1枚を入力としたとき、VAEの抽出した平均$\mu$と分散$\sigma ^2$がそれぞれ$\mu = 2,\sigma ^2 = 1.5$とし、ある猫の画像1枚を入力したときのVAEの抽出した平均$\mu$と分散$\sigma ^2$がそれぞれ$\mu = -2,\sigma ^2 = 1$だったとします。その平均と分散を参考に得られたガウス分布から潜在空間$z$(1次元)にサンプリングします。

猫の画像と犬の画像をそれぞれ複数枚ずつ潜在空間に落とし込むと以下の
ようにプロットされるでしょう。 きっとね。

結果として、猫の画像は犬の画像に比べて近くにプロットされます。
実際は猫の画像同士でも微妙に違う平均と分散が抽出されると思いますが、犬の画像から抽出される平均と分散に比べれば微々たる差だと思うので今回は簡単化のため1つのガウス分布からサンプリングする図を示しています(妄想オンパレード)。
また、このような状態で犬と猫のガウス分布の中間点あたりから画像を復元すると、犬と猫が混ざったような画像が復元されます。これが連続的な潜在変数の良さだと思います。

連続的な潜在変数を細かく見ていくことで犬の画像から猫の画像へ連続的に変化していくようなモーフィングを可視化出来るのだと思います。
実際にはどのようにモーフィングするのか具体的な例を次の章で見ていきましょう。

VAEの実装

今回は書籍「deep learning with python」が公開しているVAEのサンプルコード(詳細)を色々いじってマッチョに適用していきたいと思います。デフォルトのサンプルコードを以下に載せます。


import keras
from keras import layers
from keras import backend as K
from keras.models import Model
import numpy as np

img_shape = (28, 28, 1)
batch_size = 16
latent_dim = 2  # Dimensionality of the latent space: a plane

input_img = keras.Input(shape=img_shape)

x = layers.Conv2D(32, 3,
                  padding='same', activation='relu')(input_img)
x = layers.Conv2D(64, 3,
                  padding='same', activation='relu',
                  strides=(2, 2))(x)
x = layers.Conv2D(64, 3,
                  padding='same', activation='relu')(x)
x = layers.Conv2D(64, 3,
                  padding='same', activation='relu')(x)
shape_before_flattening = K.int_shape(x)

x = layers.Flatten()(x)
x = layers.Dense(32, activation='relu')(x)

z_mean = layers.Dense(latent_dim)(x)
z_log_var = layers.Dense(latent_dim)(x)
def sampling(args):
    z_mean, z_log_var = args
    epsilon = K.random_normal(shape=(K.shape(z_mean)[0], latent_dim),
                              mean=0., stddev=1.)
    return z_mean + K.exp(z_log_var) * epsilon

z = layers.Lambda(sampling)([z_mean, z_log_var])
# This is the input where we will feed `z`.
decoder_input = layers.Input(K.int_shape(z)[1:])

# Upsample to the correct number of units
x = layers.Dense(np.prod(shape_before_flattening[1:]),
                 activation='relu')(decoder_input)

# Reshape into an image of the same shape as before our last `Flatten` layer
x = layers.Reshape(shape_before_flattening[1:])(x)

# We then apply then reverse operation to the initial
# stack of convolution layers: a `Conv2DTranspose` layers
# with corresponding parameters.
x = layers.Conv2DTranspose(32, 3,
                           padding='same', activation='relu',
                           strides=(2, 2))(x)
x = layers.Conv2D(1, 3,
                  padding='same', activation='sigmoid')(x)
# We end up with a feature map of the same size as the original input.

# This is our decoder model.
decoder = Model(decoder_input, x)

# We then apply it to `z` to recover the decoded `z`.
z_decoded = decoder(z)
class CustomVariationalLayer(keras.layers.Layer):

    def vae_loss(self, x, z_decoded):
        x = K.flatten(x)
        z_decoded = K.flatten(z_decoded)
        xent_loss = keras.metrics.binary_crossentropy(x, z_decoded)
        kl_loss = -5e-4 * K.mean(
            1 + z_log_var - K.square(z_mean) - K.exp(z_log_var), axis=-1)
        return K.mean(xent_loss + kl_loss)

    def call(self, inputs):
        x = inputs[0]
        z_decoded = inputs[1]
        loss = self.vae_loss(x, z_decoded)
        self.add_loss(loss, inputs=inputs)
        # We don't use this output.
        return x

# We call our custom layer on the input and the decoded output,
# to obtain the final model output.
y = CustomVariationalLayer()([input_img, z_decoded])
from keras.datasets import mnist

vae = Model(input_img, y)
vae.compile(optimizer='rmsprop', loss=None)
vae.summary()

# Train the VAE on MNIST digits
(x_train, _), (x_test, y_test) = mnist.load_data()

x_train = x_train.astype('float32') / 255.
x_train = x_train.reshape(x_train.shape + (1,))
x_test = x_test.astype('float32') / 255.
x_test = x_test.reshape(x_test.shape + (1,))

vae.fit(x=x_train, y=None,
        shuffle=True,
        epochs=10,
        batch_size=batch_size,
        validation_data=(x_test, None))

このままだと潜在空間を表示してくれないので次のプログラムを実行します。


import matplotlib.pyplot as plt
from scipy.stats import norm

# Display a 2D manifold of the digits
n = 15  # figure with 15x15 digits
digit_size = 28
figure = np.zeros((digit_size * n, digit_size * n))
# Linearly spaced coordinates on the unit square were transformed
# through the inverse CDF (ppf) of the Gaussian
# to produce values of the latent variables z,
# since the prior of the latent space is Gaussian
grid_x = norm.ppf(np.linspace(0.05, 0.95, n))
grid_y = norm.ppf(np.linspace(0.05, 0.95, n))

for i, yi in enumerate(grid_x):
    for j, xi in enumerate(grid_y):
        z_sample = np.array([[xi, yi]])
        z_sample = np.tile(z_sample, batch_size).reshape(batch_size, 2)
        x_decoded = decoder.predict(z_sample, batch_size=batch_size)
        digit = x_decoded[0].reshape(digit_size, digit_size)
        figure[i * digit_size: (i + 1) * digit_size,
               j * digit_size: (j + 1) * digit_size] = digit

plt.figure(figsize=(10, 10))
plt.imshow(figure, cmap='Greys_r')
plt.show()

実行結果はこのようになります。これはサンプルコードをそのまま動かした結果となっています。

サンプルコードではMNISTという数字のデータセットに対してVAEを適用しています。
実行結果の画像は潜在変数を一定の座標ごとに区切り、そこから画像を生成し表示したものです。近い画像は近い値の潜在変数から生成されています。0~9までの各数字が徐々に変化、モーフィングしている様子がわかります。凄いです。
MNISTは数字の綺麗なモーフィングの様子が見られましたが、こうなるとカラー画像、しかも自分の好きな画像でもモーフィングさせたくなるのが人間ってものです。
そこで私はマッチョ画像にVAEを適用することにしたのです。

VAEマッチョ計画

まずはマッチョ画像を大量に集めるためにスクレイピングという作業を行います。
ここで一つ注意なのは、スクレイピングで得た画像を公開することは著作権違反に当たる可能性があります。そのため本記事では基本的に画像の公開は行いません。あくまで潜在変数から生成した未知の画像のみに注目していきます。
スクレイピングは以下のプログラムで行います。参考サイト


from icrawler.builtin import GoogleImageCrawler

google_crawler = GoogleImageCrawler(
    feeder_threads=1,
    parser_threads=2,
    downloader_threads=4,
    storage={'保存するディレクトリのパス': 'ファイル名'})
google_crawler.crawl(keyword='マッチョ関連の用語', filters=None, max_num=1000, file_idx_offset=0)

これを実行することで指定したフォルダにマッチョな画像が続々保存されていきます。また、フィルターを付けることで著作権フリーの画像のみ持ってくることもできるみたいです。
ちなみに今回用いたマッチョ画像の例はこんな感じです。(著作権フリーマッチョ)

※実際にはこんなに背景もシンプルで全身が中央に写ってる画像はほぼないです。
ここから先はGoogle colabを使って処理を実行していきます。
まずマッチョフォルダの画像をopencvを用いて256*256のサイズで読み込みます。


import glob
import cv2
import matplotlib.pyplot
import numpy as np
x=glob.glob('{}/*.jpg'.format("/content/drive/My Drive/Colab Notebooks/mascle_only/figik"))
image = []
for i in x:
  img = cv2.imread(i)
  img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
  img = cv2.resize(img,(256,256))
  img = np.asarray(img)
  image.append(img)
  image.append(img[:, ::-1])#左右反転した画像もデータセットに追加

一番最後の行で左右反転した画像をデータセットに追加することで、データセットを二倍にかさまししています。実際に用いた画像は使えないので著作権フリーの画像を使った例を以下に示します。
256*256になったフリーマッチョ

さらに反転したフリーマッチョ

ちなみに今回用いた全データ枚数は5000枚程です。
このマッチョデータセットをVAEのサンプルプログラムをカラー画像用に作り替えた以下のプログラムに突っ込みます。


import matplotlib.pyplot as plt
from keras import optimizers
%matplotlib inline
from keras.datasets import mnist
import keras
from keras import layers
from keras import backend as K
from keras.models import Model
import numpy as np
import glob
from keras.utils import np_utils
from PIL import Image
image = np.asarray(image)
(x_train), (x_test) = image[:-32],image[-32:]#マッチョデータセットを読み込む(ラベルはない)
x_train = x_train.astype('float32') / 255.
x_train = np.reshape(x_train, (len(x_train), 256, 256, 3)) #カラー画像なので3チャンネルにする  
print(x_train.shape)
x_test = x_test.astype('float32') / 255.
x_test = np.reshape(x_test, (len(x_test), 256, 256, 3))  # カラー画像なので3チャンネルにする 

fig = plt.figure(figsize=(10, 10))
fig.subplots_adjust(left=0, right=1, bottom=0, top=0.5, hspace=0.01, wspace=0.01)
for i in range(100):
    ax = fig.add_subplot(10, 10, i + 1, xticks=[], yticks=[])
    ax.imshow(x_train[i].reshape((256, 256,3)), cmap='gray')


K.clear_session()

img_shape = (256, 256,3)# カラー画像なので3チャンネルにする
epochs = 50
batch_size = 32
latent_dim = 2  

input_img = keras.Input(shape=img_shape)

x = layers.Conv2D(3, 3, padding='same',activation='relu')(input_img)
x = layers.Conv2D(32, 3,
                   padding='same',strides=2,activation='relu')(x)
x = layers.Conv2D(32, 3,padding='same',activation='relu')(x)
x = layers.Conv2D(32, 3, padding='same',activation='relu')(x)
shape_before_flattening = K.int_shape(x)

x = layers.Flatten()(x)
x = layers.Dense(128, activation='relu')(x)

z_mean = layers.Dense(latent_dim)(x)
z_log_var = layers.Dense(latent_dim)(x)
def sampling(args):
    z_mean, z_log_var = args
    epsilon = K.random_normal(shape=(K.shape(z_mean)[0], latent_dim),
                              mean=0., stddev=1.)
    return z_mean + K.exp(z_log_var) * epsilon

z = layers.Lambda(sampling)([z_mean, z_log_var])
encoder = Model(input_img,z)

decoder_input = layers.Input(K.int_shape(z)[1:])

#x = layers.Dense(np.prod(shape_before_flattening[1:]),
#                activation='relu')(decoder_input)
x = layers.Dense(128, activation='relu')(decoder_input)
x = layers.Dense(shape_before_flattening[1]*shape_before_flattening[2]*shape_before_flattening[3], activation='relu')(x)#こう書き換えると画像サイズが変わってもエラーが起きない

#x=layers.Reshape((16,16,32))(x)
print(shape_before_flattening[1:])
x = layers.Reshape(shape_before_flattening[1:])(x)
x = layers.Conv2DTranspose(32, 3,padding='same',activation='relu',)(x)
x = layers.Conv2DTranspose(32, 3,padding='same',activation='relu',)(x)
x = layers.Conv2DTranspose(32, 3,padding='same',strides=2,activation='relu',)(x)
x = layers.Conv2D(3, 3,padding='same',activation='relu',)(x)
decoder = Model(decoder_input, x)

z_decoded = decoder(z)
class CustomVariationalLayer(keras.layers.Layer):

    def vae_loss(self, x, z_decoded):
        x = K.flatten(x)
        z_decoded = K.flatten(z_decoded)
        xent_loss = keras.metrics.binary_crossentropy(x, z_decoded)
        kl_loss = -5e-4 * K.mean(
            1 + z_log_var - K.square(z_mean) - K.exp(z_log_var), axis=-1)
        return K.mean(xent_loss + kl_loss)

    def call(self, inputs):
        x = inputs[0]
        z_decoded = inputs[1]
        loss = self.vae_loss(x, z_decoded)
        self.add_loss(loss, inputs=inputs)
        # We don't use this output.
        return x

y = CustomVariationalLayer()([input_img, z_decoded])
vae = Model(input_img, y)
sgd = optimizers.SGD(lr=0.0001, momentum=0.9)
adm = optimizers.Adam(lr=0.0001)
#vae.compile(optimizer=adm, loss=None)
vae.compile(optimizer='rmsprop', loss=None)#最適化関数達
#vae.compile(optimizer=sgd, loss=None)
vae.summary()
callbacks = [
   keras.callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=5, verbose=0, mode='auto'),
]#学習が一定エポック以上進まなかったら学習率を下げる
history = vae.fit(x=x_train, y=None,
        shuffle=True,
        epochs=epochs,
        batch_size=batch_size,
        callbacks=callbacks,
        validation_data=(x_test, None))
loss = history.history['loss']
val_loss = history.history['val_loss']

ちなみにこのマッチョデータセットにおいては最適化関数はRMSPropまたはAdamの方が合ってるようでした。それでも結構ロスが発散するので、何回かの試行が必要でした。
実行する毎にVAEが取る特徴は変化するので、ここでは面白かった実行結果をあげていきます。

実行結果1(ポージング変化)


まずこの潜在空間を見て思ったことは、MNISTに比べて格段にぼやけてしまっているなというネガティブな感情でした。データの前処理を何も行っていないので、現在のモデルでの精度はこんなものだと思います。
しかしこのぼやけた潜在空間に嬉しい結果がありました。一番左の列を上から順にみていくと、フロント・リラックスからダブルバイセップスへとモーフィングしていることがわかります。これはVAEが数多くのマッチョ画像からポージングを特徴として捉えたと考えていいのではないでしょうか(GIFで動かしてみたい)

実行結果2(人数変化?)


こちらもまた結構ぼやけてしまっています。この潜在空間の画像で注目してほしいのは一番右の列です。右上はフロント・リラックスのポーズをとっています。下に行くにつれ徐々に画像が変化していき、褐色の部分が増えていきます。
一番右下の画像を見ると、マッチョが3人いるように見えませんか?
なぜこうなったかについて一つ考察を残します。今回集めたマッチョの画像には3割くらいの割合である特徴がありました。それは、「コンテスト中の画像」だったことです。マッチョ達はしばしばコンテストで集合し、圧倒的な筋肉を見せてくれます。そしてweb上にあるマッチョ画像はその圧倒的瞬間を捉えたものが多いのです。
その特徴をしっかりとらえてくれたVAEに私は感謝の気持ちすら感じています。精度を上げられなかったことをただ悔やむのみです。

最後に

今回作成したマッチョVAEはまだまだ粗削りで、精度をもっとよくすることができると考えています。今回それがかなわなかった理由としてGoogle Colabのメモリ容量に阻まれてしまったことがあります。今回はweb上の画像をGoogle Colab上にopencvで読み込みましたが、その方法だと画像分のメモリをずっと使ったままになってしまうようです。そのため、モデルの構造をサンプルプログラムからさらに複雑にしようとしても、メモリに乗りきらないということが多々ありました。この問題はImageDataGeneratorを用いてバッチ毎に画像を読み込むことで対処できそうなので、実装してみたいと思います。
次はその弱点を克服し、またさらに精度の良くなったMVAEを作りたいと思います。

白黒マッチョなら精度は上がるのか?(余談)

今回はカラー画像×マッチョというコンセプトでVAEを実装しましたが、実験していくうちに「カラー画像じゃなくていいから精度のいいマッチョがみたい」という気持ちが芽生えたので、実装してみました。
やることは簡単で、次の文を追加するだけで、白黒画像に加工してくれます。


gray_x_train = []
gray_x_test = []
for i in range(len(x_train)):
    pilImg = Image.fromarray(np.uint8(x_train[i]))
    gray_x_train.append(np.array(pilImg.convert('L')))
for i in range(len(x_test)):
    pilImg = Image.fromarray(np.uint8(x_test[i]))
    gray_x_test.append(np.array(pilImg.convert('L')))
x_train = np.array(gray_x_train)
x_test = np.array(gray_x_test)

白黒フリーマッチョ

白黒画像にすると各データが256×256×3から256×256となるため、データの情報が3分の1になって簡単になるんじゃないかという安直な考えです。
実際の結果がこちらです。

結論から言いますと色の情報は精度にあまり関係なく、データセットそのものがかなり難しいことがわかりました。そのため、精度を上げるためには画像の前処理が必要だと強く感じました。機械学習に適した自動マッチョ画像加工ツールの作成も視野に入れていこうと思います。

参考文献

Diederik P. Kingma,Max Welling(2014) Auto-Encoding Variational Bayes

深層学習 (機械学習プロフェッショナルシリーズ) 岡谷 貴之 (著)

Special thanks @tsnry7913

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
26