PyTorch (14) GAN (CelebA)
今回はDCGANをCelebAのデータで試してみた。このデータもよく見るけど使うの始めてだな。これまでのMNIST(2018/3/4)やFashion MNISTのGANと違ってカラー画像でサイズも大きいので少し修正が必要。
180303-gan.ipynb - Google ドライブ
CelebA dataset
CelebAのサイトではGoogle Driveを使って画像ファイルを提供している。ブラウザ上から直接ダウンロードしてきてもよいが、AWSなどクラウド環境を使っているときはいちいちローカルにダウンロードしてそれをAWSにアップするのが面倒だ。コマンドラインツールでAWS上に直接ダウンロードしたくて少し悩んだ。
Google Drive上のファイルをCLIで操作する gdrive という便利なツールがあるが、これは自分のGoogle Driveのファイルしか操作できない。他人のファイルを共有して自分のところに持ってきてもダウンロードできなかった。
いろいろ調べたところこのスクリプトを使うと他人のGoogle Driveのファイルをコマンドライン上からダウンロードできることがわかった。ファイルのIDを変えればCelebA以外のデータもダウンロードできるのでとても便利だ。
python get_drive_file.py 0B7EVK8r0v71pZjFTYXZWM3FlRnM img_align_celeba.zip
モデル構造
入力画像が 3x64x64
とちょっと大きくなったのでネットワークの出力サイズが少し違うが、基本的にはこれまでと同じ。
class Generator(nn.Module): def __init__(self): super(Generator, self).__init__() self.fc = nn.Sequential( nn.Linear(62, 1024), nn.BatchNorm1d(1024), nn.ReLU(), nn.Linear(1024, 128 * 16 * 16), nn.BatchNorm1d(128 * 16 * 16), nn.ReLU(), ) self.deconv = nn.Sequential( nn.ConvTranspose2d(128, 64, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.ConvTranspose2d(64, 3, kernel_size=4, stride=2, padding=1), nn.Sigmoid(), ) initialize_weights(self) def forward(self, input): x = self.fc(input) x = x.view(-1, 128, 16, 16) x = self.deconv(x) return x class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.conv = nn.Sequential( nn.Conv2d(3, 64, kernel_size=4, stride=2, padding=1), nn.LeakyReLU(0.2), nn.Conv2d(64, 128, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2), ) self.fc = nn.Sequential( nn.Linear(128 * 16 * 16, 1024), nn.BatchNorm1d(1024), nn.LeakyReLU(0.2), nn.Linear(1024, 1), nn.Sigmoid(), ) initialize_weights(self) def forward(self, input): x = self.conv(input) x = x.view(-1, 128 * 16 * 16) x = self.fc(x) return x
データロード
CelebAは画像サイズがそろっていないのでデータ変換が必要。画像の中心の160x160ピクセルをクロップして、それを64x64にリサイズする処理が入る。画像が入ったディレクトリ(data/celebA
)から直接データを読み込むにはImageFolder
が使える。
# load dataset transform = transforms.Compose([ transforms.CenterCrop(160), transforms.Resize((64, 64)), transforms.ToTensor() ]) dataset = datasets.ImageFolder('data/celebA', transform) data_loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
変換後の画像を描画するとこんな感じ。
# データの可視化 def imshow(img): npimg = img.numpy() # [c, h, w] => [h, w, c] plt.imshow(np.transpose(npimg, (1, 2, 0))) images, labels = iter(data_loader).next() images, labels = images[:25], labels[:25] imshow(make_grid(images, nrow=5, padding=1)) plt.axis('off')
訓練スクリプトは、GAN(2018/3/4)と同じなので省略。
実験結果
まずは学習曲線。これまでと同じでGeneratorがDiscriminatorに負ける感じで徐々に推移する。
1エポック目のGeneratorの生成画像
from IPython.display import Image Image('logs/epoch_001.png')
10エポック目
25エポック目
危ない人(笑)もいるけどうまく生成できている。
これらの生成画像に似た顔の人が訓練画像内にいないかどうかを確かめたいのだけどどうすればよいのだろう?潜在空間上でk-NNすればよいのかな?
PyTorch (13) GAN (Fashion MNIST)
今回はDCGANをFashion MNISTのデータで試してみた。このデータは使うの始めてだな〜
画像サイズがMNISTとまったく同じで 1x28x28 なのでネットワーク構造は何も変えなくてよい (^^;) 今回は手抜きして変えたところだけ掲載します。
180303-gan-mnist.ipynb - Google ドライブ
PyTorchにはFashion MNISTをロードする関数があるのでそれを使うだけ。
from torchvision import datasets # load dataset transform = transforms.Compose([ transforms.ToTensor() ]) dataset = datasets.FashionMNIST('data/fashion-mnist', train=True, download=True, transform=transform) data_loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
描画してみよう。
# データの可視化 def imshow(img): npimg = img.numpy() # [c, h, w] => [h, w, c] plt.imshow(np.transpose(npimg, (1, 2, 0))) images, labels = iter(data_loader).next() images, labels = images[:25], labels[:25] imshow(make_grid(images, nrow=5, padding=1)) plt.axis('off')
洋服とかバッグとか靴とかの画像みたいですね。
その他の訓練コードなどは前回とまったく同じなので省略します。全部見たい方は↑のJupyter Notebookを参照してください。
学習曲線はMNISTと同じようにGeneratorがDiscriminatorに負ける感じに推移しました。
from IPython.display import Image Image('logs/epoch_001.png')
エポック1だとあまり再現できないけど・・・
Image('logs/epoch_010.png')
Image('logs/epoch_025.png')
エポック25まで学習させるとオリジナルに似た画像が生成できました!
おしまい (^^;)
PyTorch (12) Generative Adversarial Networks (MNIST)
前回(2018/2/28)の最後で次はConditional VAEだと言っていたけど思いっきり無視して (^^;) 今回はGenerative Adversarial Networks (GAN) やろう。いくつかのデータセットで実験しようと思っているけど今回は最初ということでMNISTから。
今回の実装は正確に言うとGeneratorとDiscriminatorに畳み込みニューラルネットを使っているので DCGAN(Deep Convolutional Generative Adversarial Networks) と呼ばれるGANにあたる。論文の設定とは微妙に違うところあるけど。
180303-gan-mnist.ipynb - Google ドライブ
まずはいつものimportから。
import os import pickle import numpy as np import torch import torch.nn as nn import torch.optim as optim from torch.autograd import Variable from torch.utils.data import DataLoader from torchvision import datasets, transforms from torchvision.utils import save_image import matplotlib.pyplot as plt %matplotlib inline cuda = torch.cuda.is_available() if cuda: print('cuda available!')
GANのアーキテクチャ
手書きで申し訳ないが・・・パワポで清書するの面倒なので (^^;)
- Nはミニバッチサイズを意味する
- Generatorは乱数のベクトル z から偽物画像 G(z) を生成するニューラルネットワーク
- x はMNISTの本物画像
- Discriminatorは入力された画像(本物画像 x または偽物画像 G(z))が本物(1)か偽物(0)かを識別するニューラルネットワーク
今回、MNISTの実験に使ったDiscriminatorのアーキテクチャは
- 普通の畳み込みニューラルネットワーク
- ただし、MaxPoolingを使わずにstrideで画像サイズを半分にしていく
- stride=2としているので画像サイズは半分になる
Generatorのアーキテクチャは
- 入力 z は62次元の乱数ベクトル、これをシードとして画像を生成する
- 乱数から画像を生成する というのが面白い発想だと思った
- 最初にLinear層を入れて4Dテンソルの形状までサイズを広げていく
- 6272次元まで拡張したらview()で 7x7x128 の4Dテンソル(図ではバッチ次元は省略)に変換
ConvTranspose2D
で画像サイズをMNISTの28x28ピクセルまで拡張していく、それとともにチャネル数を減らしていく- 出力は1チャンネル28x28ピクセルのMNISTの画像になる
ConvTranspose2d
- 入力は4Dテンソル (N, C1, H1, W1) 出力は4Dテンソル (N, C2, H2, W2)
- C2はパラメータで指定
- H2 = (H1 - 1) * stride - 2 * padding + kernel_size
- W2 = (W1 - 1) * stride - 2 * padding + kernel_size
- 1つめのConvTranspose2d = (7-1) * 2 - 2 * 1 + 4 = 14
- 2つめのConvTranspose2d = (14-1) * 2 - 2 * 1 + 4 = 28
TODO: 実は ConvTranspose2D
の理解が怪しい・・・要調査
さっそく実装してみよう。
class Generator(nn.Module): def __init__(self): super(Generator, self).__init__() self.fc = nn.Sequential( nn.Linear(62, 1024), nn.BatchNorm1d(1024), nn.ReLU(), nn.Linear(1024, 128 * 7 * 7), nn.BatchNorm1d(128 * 7 * 7), nn.ReLU(), ) self.deconv = nn.Sequential( nn.ConvTranspose2d(128, 64, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.ConvTranspose2d(64, 1, kernel_size=4, stride=2, padding=1), nn.Sigmoid(), ) initialize_weights(self) def forward(self, input): x = self.fc(input) x = x.view(-1, 128, 7, 7) x = self.deconv(x) return x class Discriminator(nn.Module): def __init__(self): super(Discriminator, self).__init__() self.conv = nn.Sequential( nn.Conv2d(1, 64, kernel_size=4, stride=2, padding=1), nn.LeakyReLU(0.2), nn.Conv2d(64, 128, kernel_size=4, stride=2, padding=1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2), ) self.fc = nn.Sequential( nn.Linear(128 * 7 * 7, 1024), nn.BatchNorm1d(1024), nn.LeakyReLU(0.2), nn.Linear(1024, 1), nn.Sigmoid(), ) initialize_weights(self) def forward(self, input): x = self.conv(input) x = x.view(-1, 128 * 7 * 7) x = self.fc(x) return x
initialize_weights()
はネットワークの重みを初期化する関数。PyTorchはデフォルト以外の重みを指定するときはこう書く必要があるのかな?ちょっと面倒かも。
def initialize_weights(model): for m in model.modules(): if isinstance(m, nn.Conv2d): m.weight.data.normal_(0, 0.02) m.bias.data.zero_() elif isinstance(m, nn.ConvTranspose2d): m.weight.data.normal_(0, 0.02) m.bias.data.zero_() elif isinstance(m, nn.Linear): m.weight.data.normal_(0, 0.02) m.bias.data.zero_()
print
すると下のように表示される。
Generator( (fc): Sequential( (0): Linear(in_features=62, out_features=1024) (1): BatchNorm1d(1024, eps=1e-05, momentum=0.1, affine=True) (2): ReLU() (3): Linear(in_features=1024, out_features=6272) (4): BatchNorm1d(6272, eps=1e-05, momentum=0.1, affine=True) (5): ReLU() ) (deconv): Sequential( (0): ConvTranspose2d (128, 64, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1)) (1): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True) (2): ReLU() (3): ConvTranspose2d (64, 1, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1)) (4): Sigmoid() ) ) Discriminator( (conv): Sequential( (0): Conv2d (1, 64, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1)) (1): LeakyReLU(0.2) (2): Conv2d (64, 128, kernel_size=(4, 4), stride=(2, 2), padding=(1, 1)) (3): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True) (4): LeakyReLU(0.2) ) (fc): Sequential( (0): Linear(in_features=6272, out_features=1024) (1): BatchNorm1d(1024, eps=1e-05, momentum=0.1, affine=True) (2): LeakyReLU(0.2) (3): Linear(in_features=1024, out_features=1) (4): Sigmoid() ) )
DCGANの論文にはGANの訓練が安定する工夫として以下があげられている。
- DiscriminatorでPoolingの代わりにStrided Convolutionを使う
- GeneratorはFractional-strided Convolutionを使う
- Generator、DiscriminatorともにBatchNormを使う
- 層が深いときはFC層を除去してGlobal Average Poolingを使う
- GeneratorにはReLUを使う
- ただし出力層のみTanhを使う(今回は画像を0-1標準化したのでSigmoid使用)
- DiscriminatorにはLeakyReLUを使う
FC層除去以外は適応済みかな。Fractional-strided Convolutionsってのが ConvTranspose2d
と同じものを指しているのかがわからない。あとで調査する。
Optimizerの作成
ハイパーパラメータの定義。こういうのはコマンドライン引数から指定できたほうが汎用性があってよいのだけど今回はJupyter Notebook上での簡単な実験なので直書きする。
# hyperparameters batch_size = 128 lr = 0.0002 z_dim = 62 num_epochs = 25 sample_num = 16 log_dir = './logs'
モデルオブジェクトを作成して、Optimizerを作成。
# initialize network G = Generator() D = Discriminator() if cuda: G.cuda() D.cuda() # optimizer G_optimizer = optim.Adam(G.parameters(), lr=lr, betas=(0.5, 0.999)) D_optimizer = optim.Adam(D.parameters(), lr=lr, betas=(0.5, 0.999)) # loss criterion = nn.BCELoss()
- GeneratorとDiscriminatorはそれぞれ別のOptimizerがある ことに注意
- PyTorchはOptimizerの更新対象となるパラメータを第1引数で指定することになっている(Kerasにはなかった)
- この機能のおかげで
D_optimizer.step()
でパラメータ更新を走らせたときにDiscriminatorのパラメータしか更新されない。Generatorのパラメータは固定される。 - 参考先では
loss.cuda()
しているがlossはパラメータがないのでしなくてOK - What exactly does Loss.cuda() do? - PyTorch Forums
データのロード
MNISTをロード。前処理で ToTensor()
しかしてないので画像は [0, 1] で標準化される。つまり、Generatorの出力層の活性化関数はSigmoid
にする必要がある。[-1, 1] に標準化したときは Tanh
を使う。
# load dataset transform = transforms.Compose([ transforms.ToTensor() ]) dataset = datasets.MNIST('data/mnist', train=True, download=True, transform=transform) data_loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
GANのloss function
Discriminatorの目的関数(数式のLはLossではなく、Likelihoodだと思われる)を数式で書くと下のようになる*1
この式を解読すると下のように考えられる。
- Discriminatorの目的関数は正解画像を入れたときと偽物画像を入れたときの2項から構成される
- D(・)はDiscriminatorの出力で0〜1を返す。1に近いほど本物と判定されたことを意味する
- G(z)はGeneratorが乱数zから生成した偽物画像を表す
- 本物画像xをDに入力したとき(1項目)はD(x)が1(本物)に近いほどlogの値が大きくなる(=Dは本物画像を本物と正しく判定できた)
- 偽物画像G(z)をDに入力したとき(2項目)はD(G(z))が0(偽物)に近いほどlogの値が大きくなる(=Dは偽物画像を偽物と正しく判定できた)
次はGeneratorの目的関数。
この式を解読すると下のように考えられる。
- 偽物画像G(z)をDに入力したときにD(G(z))が1(本物)に近いほどlogの値が大きくなる(=Gが生成した偽物画像をDに本物画像と誤って判定させることができた)
上の式をコードに落とすと下のようになる。
def train(D, G, criterion, D_optimizer, G_optimizer, data_loader): # 訓練モードへ D.train() G.train() # 本物のラベルは1 y_real = Variable(torch.ones(batch_size, 1)) # 偽物のラベルは0 y_fake = Variable(torch.zeros(batch_size, 1)) if cuda: y_real = y_real.cuda() y_fake = y_fake.cuda() D_running_loss = 0 G_running_loss = 0 for batch_idx, (real_images, _) in enumerate(data_loader): # 一番最後、バッチサイズに満たない場合は無視する if real_images.size()[0] != batch_size: break z = torch.rand((batch_size, z_dim)) if cuda: real_images, z = real_images.cuda(), z.cuda() real_images, z = Variable(real_images), Variable(z) # Discriminatorの更新 D_optimizer.zero_grad() # Discriminatorにとって本物画像の認識結果は1(本物)に近いほどよい # E[log(D(x))] D_real = D(real_images) D_real_loss = criterion(D_real, y_real) # DiscriminatorにとってGeneratorが生成した偽物画像の認識結果は0(偽物)に近いほどよい # E[log(1 - D(G(z)))] # fake_imagesを通じて勾配がGに伝わらないようにdetach()して止める fake_images = G(z) D_fake = D(fake_images.detach()) D_fake_loss = criterion(D_fake, y_fake) # 2つのlossの和を最小化する D_loss = D_real_loss + D_fake_loss D_loss.backward() D_optimizer.step() # これでGのパラメータは更新されない! D_running_loss += D_loss.data[0] # Generatorの更新 G_optimizer.zero_grad() # GeneratorにとってGeneratorが生成した画像の認識結果は1(本物)に近いほどよい # E[log(D(G(z))) fake_images = G(z) D_fake = D(fake_images) G_loss = criterion(D_fake, y_real) G_loss.backward() G_optimizer.step() G_running_loss += G_loss.data[0] D_running_loss /= len(data_loader) G_running_loss /= len(data_loader) return D_running_loss, G_running_loss
- 先の数式解釈で 0に近い方がよい、1に近い方がよいと言っていたのを正解ラベルとのBCELoss(Binary Cross Entropy Loss)で置き換えているのがポイント
- GANはDiscriminatorのパラメータ更新とGeneratorのパラメータ更新を順番に繰り返す
- Discriminatorのパラメータ更新をするときはGeneratorのパラメータは固定する必要がある(Kerasはこの実装が面倒だった)
- PyTorchはOptimizerのパラメータ指定と
detach()
で実装する - Variableを
detach()
するとそれ以上の勾配伝搬が止まる - 上の実装ではGeneratorの出力である
fake_images
をdetach()
しているのでそれより前のGeneratorに勾配は伝搬されない D_optimizer
にはDのパラメータしか渡しておらず、D_optimizer.step()
してもGeneratorのパラメータは更新されないためdetach()
しなくても結果は変わらない- ただし、明示的に
detach()
することでfake_images
を通じてGeneratorに勾配が伝搬することを防ぎ、計算が高速化されるとのこと - why is detach necessary · Issue #116 · pytorch/examples · GitHub
- ちなみに参考元のコードはほとんどの実装で
detach()
が入ってない。公式のは入ってるので入れておいた
画像を生成する関数。学習途中のエポックでGeneratorを使ってサンプル画像を生成するのに使う。
def generate(epoch, G, log_dir='logs'): G.eval() if not os.path.exists(log_dir): os.makedirs(log_dir) # 生成のもとになる乱数を生成 sample_z = torch.rand((64, z_dim)) if cuda: sample_z = sample_z.cuda() sample_z = Variable(sample_z, volatile=True) # Generatorでサンプル生成 samples = G(sample_z).data.cpu() save_image(samples, os.path.join(log_dir, 'epoch_%03d.png' % (epoch)))
訓練ループ!
history = {} history['D_loss'] = [] history['G_loss'] = [] for epoch in range(num_epochs): D_loss, G_loss = train(D, G, criterion, D_optimizer, G_optimizer, data_loader) print('epoch %d, D_loss: %.4f G_loss: %.4f' % (epoch + 1, D_loss, G_loss)) history['D_loss'].append(D_loss) history['G_loss'].append(G_loss) # 特定のエポックでGeneratorから画像を生成してモデルも保存 if epoch == 0 or epoch == 9 or epoch == 24: generate(epoch + 1, G, log_dir) torch.save(G.state_dict(), os.path.join(log_dir, 'G_%03d.pth' % (epoch + 1))) torch.save(D.state_dict(), os.path.join(log_dir, 'D_%03d.pth' % (epoch + 1))) # 学習履歴を保存 with open(os.path.join(log_dir, 'history.pkl'), 'wb') as f: pickle.dump(history, f)
実験結果
まずは、DiscriminatorとGeneratorのlossをプロットしてみる。
with open(os.path.join(log_dir, 'history.pkl'), 'rb') as f: history = pickle.load(f) D_loss, G_loss = history['D_loss'], history['G_loss'] plt.plot(D_loss, label='D_loss') plt.plot(G_loss, label='G_loss') plt.xlabel('epoch') plt.ylabel('loss') plt.legend() plt.grid()
- 学習が進むとDiscriminatorが強くなってGeneratorが競り負けていることがわかる
- GANの発明者のGoodfellowも言及しているが、Generatorは贋作者(counterfeiter)でDiscriminatorは警察(police)に例えられる
- つまり、Generatorを応援することは犯罪者を応援することにつながる(笑)この例えあまりよくないよね〜
- Generatorもっと頑張れ!
途中のエポック(1、10、25エポック)で生成した画像を描画してみよう。
from IPython.display import Image Image('logs/epoch_001.png')
Image('logs/epoch_010.png')
Image('logs/epoch_025.png')
学習が進むにつれてDiscriminatorに見破られないような精巧な偽物画像をGeneratorが生成できるようになったことがわかる。
追記(2018/3/9)
コメントでご指摘いただいたようにGeneratorの更新の前にノイズを新しく生成しなおす必要があります。元の論文でもDiscriminatorの更新とGeneratorの更新では別のノイズを使っていました。
# Generatorの更新 z = torch.rand((batch_size, z_dim)) # <= ★これを追加 if cuda: z = z.cuda() z = Variable(z) G_optimizer.zero_grad() # GeneratorにとってGeneratorが生成した画像の認識結果は1(本物)に近いほどよい # E[log(D(G(z))) fake_images = G(z) D_fake = D(fake_images) G_loss = criterion(D_fake, y_real) G_loss.backward() G_optimizer.step() G_running_loss += G_loss.data[0]
Notebookも更新済みです。こちらでも実験してみたのですが、大きく結果が変わるということはないようでした。
参考
GANの参考資料は他にもたくさんあるんだけど今回の記事に関連のあるものだけ
- PyTorch Generative Model Collections
- Generative Adversarial Networks - GANの原論文
- Unsupervised Representation Learning with Deep Convolutional Generative Adversarial Networks - DCGANの原論文
- Improved Techniques for Training GANs - GAN安定化のテクニック
- Generative Adversarial Networks: An Overview - GANのサーベイ
*1:論文ではDiscriminatorとGeneratorの目的関数が1つの式にまとめているが2つに分けた方がわかりやすい!