DCGAN(深層畳み込みGAN)の理論と実装を解説

DCGAN(Deep Convolutional Generative Adversarial Network)は、2016年にRadfordらによって提案された、畳み込みニューラルネットワーク(CNN)をGANに取り入れたアーキテクチャです。GANの訓練が不安定であった時代に、安定して高品質な画像を生成できるアーキテクチャの設計指針を示した重要な研究です。

DCGANは後続のGAN研究の基盤となり、多くのGAN変種がDCGANのアーキテクチャを出発点としています。本記事では、DCGANの設計原則を数式で丁寧に説明し、特に転置畳み込みの仕組みを導出した上で、PyTorchによる実装を行います。

本記事の内容

  • DCGANの5つの設計原則
  • 転置畳み込み(Transposed Convolution)の数学的定義と出力サイズの導出
  • Generatorのアーキテクチャ設計
  • Discriminatorのアーキテクチャ設計
  • 潜在空間での算術演算
  • PyTorchによるFashion-MNISTでのDCGAN実装と画像生成

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

DCGANの設計原則

DCGANの論文では、GANの訓練を安定させるための以下の5つのアーキテクチャ設計指針が示されています。

原則1: プーリング層をストライド付き畳み込みで置き換える

MaxPoolingやAveragePoolingのようなプーリング層の代わりに、Discriminatorではストライド付き畳み込み(strided convolution)を、Generatorでは転置畳み込み(transposed convolution, fractional-strided convolution)を使用します。これにより、ネットワーク自身がダウンサンプリング/アップサンプリングの方法を学習できます。

原則2: バッチ正規化を使用する

GeneratorとDiscriminatorの両方にバッチ正規化(Batch Normalization)を適用します。ただし、Generatorの出力層とDiscriminatorの入力層にはバッチ正規化を適用しません。

原則3: 全結合層を廃止する

全結合(fully connected)の隠れ層を廃止します。Generatorの入力(ノイズベクトル)は、射影(全結合)で適切な形状に変換した後、すべて畳み込み層で処理します。

原則4: Generatorの活性化関数

Generatorでは出力層以外にReLUを使い、出力層にはTanh(値域 $[-1, 1]$)を使います。

原則5: Discriminatorの活性化関数

Discriminatorではすべての層でLeakyReLUを使います。

転置畳み込み(Transposed Convolution)の数学的定義

DCGANのGeneratorの核心となる転置畳み込みについて、数学的に定義し、出力サイズの計算式を導出します。

通常の畳み込みの復習

まず、通常の畳み込み演算を復習します。入力サイズ $i$、カーネルサイズ $k$、ストライド $s$、パディング $p$ のとき、出力サイズ $o$ は以下で与えられます。

$$ o = \left\lfloor \frac{i + 2p – k}{s} \right\rfloor + 1 $$

例えば、$i = 8$, $k = 3$, $s = 2$, $p = 1$ の場合、

$$ o = \left\lfloor \frac{8 + 2 – 3}{2} \right\rfloor + 1 = \left\lfloor \frac{7}{2} \right\rfloor + 1 = 3 + 1 = 4 $$

つまり、ストライド2の畳み込みで空間サイズが半分になります。

転置畳み込みの定義

転置畳み込みは、畳み込みの逆方向の計算を行う操作です。正確には、ある畳み込み操作の勾配計算(逆伝播)で現れる演算と同じ形式を持ちます。

通常の畳み込みを行列の形で書くと、入力ベクトル $\bm{x} \in \mathbb{R}^{i^2}$ をカーネルに対応する疎行列 $\bm{C} \in \mathbb{R}^{o^2 \times i^2}$ で変換して出力 $\bm{y} \in \mathbb{R}^{o^2}$ を得ます。

$$ \bm{y} = \bm{C} \bm{x} $$

転置畳み込みは、この行列の転置 $\bm{C}^T$ を用いた操作です。

$$ \bm{x}’ = \bm{C}^T \bm{y} $$

$\bm{C} \in \mathbb{R}^{o^2 \times i^2}$ なので、$\bm{C}^T \in \mathbb{R}^{i^2 \times o^2}$ となり、小さい空間から大きい空間への写像になっていることがわかります。

ただし注意が必要なのは、転置畳み込みは畳み込みの厳密な逆演算(逆行列)ではないということです。$\bm{C}^T \bm{C} \neq \bm{I}$ であり、あくまで「形状を変換する」演算です。

転置畳み込みの出力サイズの導出

転置畳み込みの出力サイズを導出します。通常の畳み込みの出力サイズの式において、入力と出力の関係を逆転させることを考えます。

通常の畳み込みで、入力サイズ $i’$、出力サイズ $o’$ のとき(パディングなし、ストライド $s$)、

$$ o’ = \frac{i’ – k}{s} + 1 $$

これを $i’$ について解くと、

$$ i’ = (o’ – 1) \cdot s + k $$

転置畳み込みでは、入力サイズ $i$ が $o’$ に対応し、出力サイズ $o$ が $i’$ に対応します。したがって、

$$ o = (i – 1) \cdot s + k $$

パディング $p$ と出力パディング $p_{\mathrm{out}}$ を考慮すると、一般式は以下になります。

$$ \boxed{o = (i – 1) \cdot s – 2p + k + p_{\mathrm{out}}} $$

ここで、 – $i$: 入力サイズ – $o$: 出力サイズ – $s$: ストライド – $p$: パディング – $k$: カーネルサイズ – $p_{\mathrm{out}}$: 出力パディング

具体例: DCGANのGeneratorでのサイズ変化

DCGANのGeneratorで典型的に使われるパラメータ $k=4, s=2, p=1, p_{\mathrm{out}}=0$ の場合、

$$ o = (i – 1) \cdot 2 – 2 \cdot 1 + 4 + 0 = 2i – 2 – 2 + 4 = 2i $$

つまり、空間サイズが正確に2倍になります。これがDCGANで好まれる設定です。各層での変化を追うと、

$$ 4 \xrightarrow{\times 2} 8 \xrightarrow{\times 2} 16 \xrightarrow{\times 2} 32 \xrightarrow{\times 2} 64 $$

このように、4×4から始めて4回のアップサンプリングで64×64の画像を生成します。

Generatorのアーキテクチャ

Generatorは100次元のノイズベクトル $\bm{z} \sim \mathcal{N}(\bm{0}, \bm{I})$ を入力とし、画像を出力します。

構成は以下のとおりです(64×64画像の場合)。

入力サイズ 操作 出力サイズ
射影 + Reshape $100$ 全結合 + Reshape $512 \times 4 \times 4$
ConvTranspose2d + BN + ReLU $512 \times 4 \times 4$ $k=4, s=2, p=1$ $256 \times 8 \times 8$
ConvTranspose2d + BN + ReLU $256 \times 8 \times 8$ $k=4, s=2, p=1$ $128 \times 16 \times 16$
ConvTranspose2d + BN + ReLU $128 \times 16 \times 16$ $k=4, s=2, p=1$ $64 \times 32 \times 32$
ConvTranspose2d + Tanh $64 \times 32 \times 32$ $k=4, s=2, p=1$ $C \times 64 \times 64$

ここで $C$ は画像のチャンネル数(グレースケールなら1、RGBなら3)です。最終層ではバッチ正規化を使わず、Tanh活性化で出力を $[-1, 1]$ に制限します。

Discriminatorのアーキテクチャ

DiscriminatorはGeneratorと鏡像的な構成を持ちます。画像を入力として、真偽のスカラー値を出力します。

入力サイズ 操作 出力サイズ
Conv2d + LeakyReLU $C \times 64 \times 64$ $k=4, s=2, p=1$ $64 \times 32 \times 32$
Conv2d + BN + LeakyReLU $64 \times 32 \times 32$ $k=4, s=2, p=1$ $128 \times 16 \times 16$
Conv2d + BN + LeakyReLU $128 \times 16 \times 16$ $k=4, s=2, p=1$ $256 \times 8 \times 8$
Conv2d + BN + LeakyReLU $256 \times 8 \times 8$ $k=4, s=2, p=1$ $512 \times 4 \times 4$
Conv2d + Sigmoid $512 \times 4 \times 4$ $k=4, s=1, p=0$ $1 \times 1 \times 1$

ストライド2の通常の畳み込みで空間サイズを半分にしていき、最終的に $1 \times 1$ のスカラーに集約します。最初の層にはバッチ正規化を適用しません。LeakyReLUの負の傾き係数は0.2が推奨されています。

各層の出力サイズは通常の畳み込みの式で確認できます。$k=4, s=2, p=1$ のとき、

$$ o = \left\lfloor \frac{i + 2 \cdot 1 – 4}{2} \right\rfloor + 1 = \left\lfloor \frac{i – 2}{2} \right\rfloor + 1 = \frac{i}{2} $$

($i$ が偶数の場合)。確かに空間サイズが半分になっています。

重みの初期化

DCGANでは、すべての重みを平均0、標準偏差0.02の正規分布で初期化することが推奨されています。

$$ \bm{W} \sim \mathcal{N}(0, 0.02^2) $$

これは標準的なHe初期化やXavier初期化とは異なる設定ですが、DCGANの安定した訓練に寄与することが経験的に知られています。

潜在空間での算術演算

DCGANの興味深い特性として、学習された潜在空間で算術演算が意味を持つことが挙げられます。

例えば、CelebA(顔画像データセット)で学習したDCGANにおいて、以下のようなベクトル演算が報告されています。

$$ \bm{z}_{\text{笑顔の女性}} – \bm{z}_{\text{無表情の女性}} + \bm{z}_{\text{無表情の男性}} \approx \bm{z}_{\text{笑顔の男性}} $$

これはword2vecの「king – man + woman = queen」に類似した性質です。潜在空間が意味的に構造化されていることを示しています。

実際には、各属性に対応する複数のサンプルの平均ベクトルを使って演算します。

$$ \bm{z}_{\text{result}} = \frac{1}{N}\sum_{i=1}^{N} \bm{z}_i^{(\text{smile, female})} – \frac{1}{N}\sum_{i=1}^{N} \bm{z}_i^{(\text{neutral, female})} + \frac{1}{N}\sum_{i=1}^{N} \bm{z}_i^{(\text{neutral, male})} $$

Pythonでの実装

PyTorchを用いてDCGANを実装します。ここではFashion-MNISTデータセット(28×28グレースケール画像)を使用し、32×32にリサイズして学習します。

モデル定義

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import matplotlib.pyplot as plt
import numpy as np

# 再現性のためシードを固定
torch.manual_seed(42)

# デバイス設定
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# ハイパーパラメータ
nz = 100      # ノイズベクトルの次元
ngf = 64      # Generator特徴マップの基底数
ndf = 64      # Discriminator特徴マップの基底数
nc = 1        # チャンネル数(グレースケール)
image_size = 32


def weights_init(m):
    """DCGAN推奨の重み初期化: N(0, 0.02^2)"""
    classname = m.__class__.__name__
    if classname.find('Conv') != -1:
        nn.init.normal_(m.weight.data, 0.0, 0.02)
    elif classname.find('BatchNorm') != -1:
        nn.init.normal_(m.weight.data, 1.0, 0.02)
        nn.init.constant_(m.bias.data, 0)


class DCGANGenerator(nn.Module):
    """
    DCGAN Generator
    入力: (batch, nz, 1, 1) のノイズ
    出力: (batch, nc, 32, 32) の画像
    """
    def __init__(self):
        super(DCGANGenerator, self).__init__()
        self.main = nn.Sequential(
            # 入力: (nz) x 1 x 1  ->  (ngf*4) x 4 x 4
            nn.ConvTranspose2d(nz, ngf * 4, 4, 1, 0, bias=False),
            nn.BatchNorm2d(ngf * 4),
            nn.ReLU(True),

            # (ngf*4) x 4 x 4  ->  (ngf*2) x 8 x 8
            nn.ConvTranspose2d(ngf * 4, ngf * 2, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ngf * 2),
            nn.ReLU(True),

            # (ngf*2) x 8 x 8  ->  (ngf) x 16 x 16
            nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ngf),
            nn.ReLU(True),

            # (ngf) x 16 x 16  ->  (nc) x 32 x 32
            nn.ConvTranspose2d(ngf, nc, 4, 2, 1, bias=False),
            nn.Tanh()
        )

    def forward(self, z):
        return self.main(z)


class DCGANDiscriminator(nn.Module):
    """
    DCGAN Discriminator
    入力: (batch, nc, 32, 32) の画像
    出力: (batch, 1, 1, 1) の真偽確率
    """
    def __init__(self):
        super(DCGANDiscriminator, self).__init__()
        self.main = nn.Sequential(
            # (nc) x 32 x 32  ->  (ndf) x 16 x 16
            nn.Conv2d(nc, ndf, 4, 2, 1, bias=False),
            nn.LeakyReLU(0.2, inplace=True),

            # (ndf) x 16 x 16  ->  (ndf*2) x 8 x 8
            nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ndf * 2),
            nn.LeakyReLU(0.2, inplace=True),

            # (ndf*2) x 8 x 8  ->  (ndf*4) x 4 x 4
            nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ndf * 4),
            nn.LeakyReLU(0.2, inplace=True),

            # (ndf*4) x 4 x 4  ->  1 x 1 x 1
            nn.Conv2d(ndf * 4, 1, 4, 1, 0, bias=False),
            nn.Sigmoid()
        )

    def forward(self, x):
        return self.main(x)

各層の出力サイズの確認

実装の正しさを確認するために、各層の出力サイズを計算しておきましょう。

Generator(転置畳み込み、$o = (i-1) \cdot s – 2p + k$):

$$ \begin{align} \text{層1}: \quad o &= (1-1) \cdot 1 – 2 \cdot 0 + 4 = 4 \\ \text{層2}: \quad o &= (4-1) \cdot 2 – 2 \cdot 1 + 4 = 8 \\ \text{層3}: \quad o &= (8-1) \cdot 2 – 2 \cdot 1 + 4 = 16 \\ \text{層4}: \quad o &= (16-1) \cdot 2 – 2 \cdot 1 + 4 = 32 \end{align} $$

Discriminator(通常の畳み込み、$o = \lfloor (i + 2p – k) / s \rfloor + 1$):

$$ \begin{align} \text{層1}: \quad o &= \lfloor (32 + 2 – 4) / 2 \rfloor + 1 = 16 \\ \text{層2}: \quad o &= \lfloor (16 + 2 – 4) / 2 \rfloor + 1 = 8 \\ \text{層3}: \quad o &= \lfloor (8 + 2 – 4) / 2 \rfloor + 1 = 4 \\ \text{層4}: \quad o &= \lfloor (4 + 0 – 4) / 1 \rfloor + 1 = 1 \end{align} $$

すべて想定どおりのサイズになっています。

データ準備と訓練

# データの準備(Fashion-MNIST、32x32にリサイズ)
transform = transforms.Compose([
    transforms.Resize(image_size),
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,))  # [0,1] -> [-1,1]
])
dataset = datasets.FashionMNIST(root='./data', train=True,
                                download=True, transform=transform)
dataloader = DataLoader(dataset, batch_size=128, shuffle=True,
                        num_workers=2, drop_last=True)

# モデルの初期化
netG = DCGANGenerator().to(device)
netD = DCGANDiscriminator().to(device)
netG.apply(weights_init)
netD.apply(weights_init)

# オプティマイザ(DCGAN論文推奨: Adam, lr=0.0002, beta1=0.5)
optimizerD = optim.Adam(netD.parameters(), lr=0.0002, betas=(0.5, 0.999))
optimizerG = optim.Adam(netG.parameters(), lr=0.0002, betas=(0.5, 0.999))

criterion = nn.BCELoss()

# 評価用の固定ノイズ(学習進捗を追うため)
fixed_noise = torch.randn(64, nz, 1, 1, device=device)

# 訓練ループ
num_epochs = 25
G_losses = []
D_losses = []
img_list = []  # 生成画像のスナップショット

print("Training started...")
for epoch in range(num_epochs):
    for i, (data, _) in enumerate(dataloader):
        # === 判別器の更新: max log(D(x)) + log(1 - D(G(z))) ===
        netD.zero_grad()
        real_data = data.to(device)
        batch_size_curr = real_data.size(0)

        # 真のデータ
        label_real = torch.full((batch_size_curr,), 0.9, device=device)  # ラベルスムージング
        output = netD(real_data).view(-1)
        errD_real = criterion(output, label_real)
        errD_real.backward()

        # 偽のデータ
        noise = torch.randn(batch_size_curr, nz, 1, 1, device=device)
        fake = netG(noise)
        label_fake = torch.full((batch_size_curr,), 0.0, device=device)
        output = netD(fake.detach()).view(-1)
        errD_fake = criterion(output, label_fake)
        errD_fake.backward()

        errD = errD_real + errD_fake
        optimizerD.step()

        # === 生成器の更新: max log(D(G(z))) ===
        netG.zero_grad()
        label_g = torch.full((batch_size_curr,), 1.0, device=device)
        output = netD(fake).view(-1)
        errG = criterion(output, label_g)
        errG.backward()
        optimizerG.step()

        G_losses.append(errG.item())
        D_losses.append(errD.item())

    # エポックごとのスナップショット
    with torch.no_grad():
        fake_snapshot = netG(fixed_noise).detach().cpu()
    img_list.append(fake_snapshot)

    print(f'Epoch [{epoch+1}/{num_epochs}] '
          f'D Loss: {errD.item():.4f} G Loss: {errG.item():.4f}')

print("Training finished.")

学習曲線の可視化

plt.figure(figsize=(10, 5))
plt.plot(G_losses, label='Generator', alpha=0.7)
plt.plot(D_losses, label='Discriminator', alpha=0.7)
plt.xlabel('Iteration', fontsize=12)
plt.ylabel('Loss', fontsize=12)
plt.title('DCGAN Training Loss', fontsize=14)
plt.legend(fontsize=11)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

生成画像の可視化

def show_generated_images(images, nrow=8, title="Generated Images"):
    """生成画像をグリッド表示"""
    fig, axes = plt.subplots(nrow, nrow, figsize=(10, 10))
    for idx in range(nrow * nrow):
        ax = axes[idx // nrow][idx % nrow]
        # [-1, 1] -> [0, 1] に戻す
        img = images[idx].squeeze().numpy() * 0.5 + 0.5
        ax.imshow(img, cmap='gray')
        ax.axis('off')
    plt.suptitle(title, fontsize=14)
    plt.tight_layout()
    plt.show()

# 最終エポックの生成画像
show_generated_images(img_list[-1], nrow=8,
                      title='DCGAN Generated Fashion-MNIST (Final Epoch)')

学習過程の推移

# 学習過程での生成画像の変化
epochs_to_show = [0, 4, 9, 14, 19, 24]  # 表示するエポック
fig, axes = plt.subplots(1, len(epochs_to_show), figsize=(3 * len(epochs_to_show), 3))

for i, ep in enumerate(epochs_to_show):
    if ep < len(img_list):
        img = img_list[ep][0].squeeze().numpy() * 0.5 + 0.5
        axes[i].imshow(img, cmap='gray')
        axes[i].set_title(f'Epoch {ep + 1}', fontsize=11)
    axes[i].axis('off')

plt.suptitle('DCGAN Training Progress', fontsize=14)
plt.tight_layout()
plt.show()

潜在空間の補間

2つのノイズベクトル間を線形補間し、生成画像が滑らかに変化することを確認します。

def interpolate_latent(z1, z2, n_steps=10):
    """2つの潜在ベクトル間を線形補間"""
    ratios = np.linspace(0, 1, n_steps)
    z_interp = torch.stack([z1 * (1 - r) + z2 * r for r in ratios])
    return z_interp

# 2つのノイズベクトルを生成
z1 = torch.randn(nz, 1, 1, device=device)
z2 = torch.randn(nz, 1, 1, device=device)
n_steps = 10

z_interp = interpolate_latent(z1, z2, n_steps).to(device)

netG.eval()
with torch.no_grad():
    gen_interp = netG(z_interp).cpu()

fig, axes = plt.subplots(1, n_steps, figsize=(2 * n_steps, 2))
for i in range(n_steps):
    img = gen_interp[i].squeeze().numpy() * 0.5 + 0.5
    axes[i].imshow(img, cmap='gray')
    axes[i].axis('off')
    axes[i].set_title(f'{i/(n_steps-1):.1f}', fontsize=9)
plt.suptitle('Latent Space Interpolation', fontsize=14)
plt.tight_layout()
plt.show()

この補間により、潜在空間が滑らかに構造化されていることが視覚的に確認できます。一方のファッションアイテムから別のアイテムへ、連続的に形状が変化していく様子が見られるはずです。

まとめ

本記事では、DCGAN(Deep Convolutional GAN)のアーキテクチャ設計原則を理論的に解説し、PyTorchによる実装を行いました。

  • DCGANは5つの設計原則(ストライド畳み込み、バッチ正規化、全結合層の廃止、ReLU/LeakyReLU/Tanh)を提示し、GANの安定した訓練を可能にした
  • 転置畳み込みの出力サイズは $o = (i-1) \cdot s – 2p + k + p_{\mathrm{out}}$ で計算でき、$k=4, s=2, p=1$ の設定でちょうど2倍のアップサンプリングが行える
  • GeneratorとDiscriminatorは鏡像的な構造を持ち、対称的な空間サイズの変化を行う
  • 学習された潜在空間では算術演算が意味的な操作に対応し、「笑顔の男 – 男 + 女 = 笑顔の女」のような関係が成り立つ

次のステップとして、以下の記事も参考にしてください。