Skip to content
Published on

生成AI完全ガイド: GAN・VAE・拡散モデルをマスターする

Authors

はじめに

生成AIは今日のテクノロジーで最もホットな分野です。DALL-E 3は1行のテキストからリアルな画像を生成し、Stable Diffusionはアートワークを生成し、Soraは動画を生成します。これらすべての革新の背後には、数十年にわたって開発された生成モデルがあります。

このガイドでは、VAE(変分オートエンコーダ)、GAN(敵対的生成ネットワーク)、そして現代の拡散モデルを数学的直感と完全なPyTorchコード実装とともに網羅的に解説します。


1. 生成モデルの概要

1.1 生成モデルと識別モデル

ディープラーニングモデルは大きく2つに分類されます。

識別モデル: 入力データxが与えられたとき、条件付き確率P(y|x)を学習します。画像分類、物体検出などに使用されます。

生成モデル: 結合確率分布P(x)またはP(x, y)を学習します。学習後に新しいデータサンプルを生成できます。

生成モデルの核心的な問い: 「このデータはどのように生成されたのか?同じ分布から新しいサンプルを生成するにはどうすれば良いのか?」

1.2 潜在空間の概念

ほとんどの生成モデルは潜在空間(より低次元の表現空間)を活用します。

例えば、28x28のMNIST画像(784次元)を2〜100次元の潜在ベクトルに圧縮できます。この潜在空間では:

  • 数字「3」と数字「8」は近くに配置されます
  • 中間点のベクトルをデコードすると「3」と「8」の中間が生成されます
  • 潜在空間を歩くこと(補間)で連続的な変換が可能になります

1.3 生成モデルの応用

  • 画像生成: リアルな顔、風景、アートワーク
  • 画像変換: 昼の写真から夜の写真、スケッチから写真
  • データ拡張: 希少なトレーニングデータを補完
  • 創薬: 新しい分子構造を生成
  • テキスト生成: GPTファミリーの言語モデル
  • 音楽/音声合成: 音楽作曲、TTSシステム

2. オートエンコーダ

VAEを理解するために必要な基盤として、オートエンコーダから始めます。

2.1 エンコーダ-デコーダアーキテクチャ

オートエンコーダは2つの部分から構成されます。

  • エンコーダ: 高次元の入力x → 低次元の潜在ベクトルz
  • デコーダ: 低次元の潜在ベクトルz → 再構成出力x'

目標: x'がxにできるだけ類似するよう学習します(再構成損失の最小化)。

import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader

class Autoencoder(nn.Module):
    """MNIST用の基本的なオートエンコーダ"""
    def __init__(self, input_dim=784, latent_dim=32):
        super().__init__()

        # エンコーダ
        self.encoder = nn.Sequential(
            nn.Linear(input_dim, 256),
            nn.ReLU(),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Linear(128, latent_dim),
            nn.ReLU()
        )

        # デコーダ
        self.decoder = nn.Sequential(
            nn.Linear(latent_dim, 128),
            nn.ReLU(),
            nn.Linear(128, 256),
            nn.ReLU(),
            nn.Linear(256, input_dim),
            nn.Sigmoid()  # ピクセル値を[0, 1]に制限
        )

    def forward(self, x):
        x = x.view(x.size(0), -1)
        z = self.encoder(x)
        x_reconstructed = self.decoder(z)
        return x_reconstructed, z

    def encode(self, x):
        x = x.view(x.size(0), -1)
        return self.encoder(x)

    def decode(self, z):
        return self.decoder(z).view(-1, 1, 28, 28)


def train_autoencoder(epochs=20):
    """オートエンコーダの学習"""
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    transform = transforms.Compose([transforms.ToTensor()])
    train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
    train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)

    model = Autoencoder().to(device)
    optimizer = optim.Adam(model.parameters(), lr=1e-3)
    criterion = nn.BCELoss()

    for epoch in range(epochs):
        total_loss = 0
        for data, _ in train_loader:
            data = data.to(device)
            optimizer.zero_grad()
            reconstructed, z = model(data)
            target = data.view(data.size(0), -1)
            loss = criterion(reconstructed, target)
            loss.backward()
            optimizer.step()
            total_loss += loss.item()

        avg_loss = total_loss / len(train_loader)
        print(f"Epoch {epoch+1}/{epochs}, Loss: {avg_loss:.4f}")

    return model

2.2 基本的なオートエンコーダの限界

基本的なオートエンコーダはデータをうまく圧縮しますが、新しいサンプルの生成が難しいという問題があります。潜在空間が正則化されていないため、ランダムな潜在ベクトルをデコードしても意味のない画像が生成されることが多いです。

これがまさにVAEが解決する問題です。


3. 変分オートエンコーダ(VAE)

VAEは2013年のKingmaとWellingの革新的な論文(arXiv:1312.6114)で提案されました。

3.1 VAEの核心アイデア

VAEの核心は潜在空間に確率分布を学習することです。

  • 基本的なオートエンコーダ: z = encoder(x) (決定論的な点)
  • VAE: z ~ N(μ, σ²) (ガウス分布からサンプリング)

エンコーダは潜在ベクトルzを直接出力する代わりに、**分布パラメータ(平均μ、分散σ²)**を出力します。

新しい画像を生成する際は、標準正規分布N(0, I)からzをサンプリングしてデコーダに渡します。

3.2 ELBO(証拠下界)

VAEの学習目標はデータの対数尤度 log P(x) の最大化です。直接最適化が難しいため、代わりにELBOを最大化します。

log P(x) >= E_q[log P(x|z)] - KL[q(z|x) || P(z)]
                 ↑                     ↑
        再構成損失              KL発散

ELBO = 再構成損失 + KL発散

  • 再構成損失: デコードされた出力が元データにどれだけ類似しているか
  • KL発散: 学習した潜在分布q(z|x)が事前分布P(z) = N(0, I)にどれだけ近いか

3.3 再パラメータ化トリック

z ~ N(μ, σ²) を直接サンプリングすると逆伝播がブロックされます。再パラメータ化トリックでこれを解決します。

z = μ + σ * ε,  ε ~ N(0, I)

これにより確率性(ε)がネットワークパラメータから分離され、μとσに対する勾配計算が可能になります。

3.4 完全なVAE実装(MNIST)

import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
import numpy as np

class VAE(nn.Module):
    """変分オートエンコーダ"""
    def __init__(self, input_dim=784, hidden_dim=400, latent_dim=20):
        super(VAE, self).__init__()
        self.latent_dim = latent_dim

        # エンコーダ: 入力 -> μ, log(σ²)
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.fc_mu = nn.Linear(hidden_dim, latent_dim)       # 平均
        self.fc_logvar = nn.Linear(hidden_dim, latent_dim)   # 対数分散

        # デコーダ: 潜在ベクトル -> 再構成
        self.fc3 = nn.Linear(latent_dim, hidden_dim)
        self.fc4 = nn.Linear(hidden_dim, input_dim)

    def encode(self, x):
        """エンコーダ: x -> (μ, log_var)"""
        h = F.relu(self.fc1(x))
        return self.fc_mu(h), self.fc_logvar(h)

    def reparameterize(self, mu, logvar):
        """再パラメータ化トリック: z = μ + ε * σ"""
        if self.training:
            std = torch.exp(0.5 * logvar)  # σ = exp(0.5 * log σ²)
            eps = torch.randn_like(std)     # ε ~ N(0, I)
            return mu + eps * std
        else:
            return mu  # 推論時は平均のみ使用

    def decode(self, z):
        """デコーダ: z -> x'"""
        h = F.relu(self.fc3(z))
        return torch.sigmoid(self.fc4(h))

    def forward(self, x):
        x_flat = x.view(-1, 784)
        mu, logvar = self.encode(x_flat)
        z = self.reparameterize(mu, logvar)
        x_recon = self.decode(z)
        return x_recon, mu, logvar

    def generate(self, num_samples, device):
        """標準正規分布からサンプリングして画像を生成"""
        with torch.no_grad():
            z = torch.randn(num_samples, self.latent_dim).to(device)
            samples = self.decode(z)
            return samples.view(num_samples, 1, 28, 28)


def vae_loss(x_recon, x, mu, logvar, beta=1.0):
    """
    VAE損失 = 再構成損失 + β * KL発散
    beta=1: 標準VAE
    beta>1: β-VAE(よりディスエンタングルな表現)
    """
    # 再構成損失(BCE)
    recon_loss = F.binary_cross_entropy(
        x_recon, x.view(-1, 784),
        reduction='sum'
    )

    # KL発散: KL[N(μ, σ²) || N(0, 1)]
    # = -0.5 * Σ(1 + log σ² - μ² - σ²)
    kl_loss = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())

    return recon_loss + beta * kl_loss


def train_vae(epochs=50, latent_dim=20, beta=1.0):
    """VAEの学習"""
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    transform = transforms.Compose([transforms.ToTensor()])
    train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
    train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True)

    model = VAE(latent_dim=latent_dim).to(device)
    optimizer = optim.Adam(model.parameters(), lr=1e-3)
    train_losses = []

    for epoch in range(epochs):
        model.train()
        total_loss = 0

        for data, _ in train_loader:
            data = data.to(device)
            optimizer.zero_grad()
            x_recon, mu, logvar = model(data)
            loss = vae_loss(x_recon, data, mu, logvar, beta)
            loss.backward()
            optimizer.step()
            total_loss += loss.item()

        avg_loss = total_loss / len(train_dataset)
        train_losses.append(avg_loss)

        if (epoch + 1) % 10 == 0:
            print(f"Epoch {epoch+1}/{epochs}, Loss: {avg_loss:.4f}")

    return model, train_losses


def interpolate_latent_space(model, img1, img2, steps=10, device='cpu'):
    """潜在空間内の2枚の画像を補間"""
    model.eval()
    with torch.no_grad():
        z1_flat = img1.view(-1, 784).to(device)
        z2_flat = img2.view(-1, 784).to(device)

        mu1, _ = model.encode(z1_flat)
        mu2, _ = model.encode(z2_flat)

        # 線形補間
        interpolated_images = []
        for alpha in np.linspace(0, 1, steps):
            z_interp = (1 - alpha) * mu1 + alpha * mu2
            img_recon = model.decode(z_interp)
            interpolated_images.append(img_recon.view(1, 28, 28))

    return interpolated_images

3.5 畳み込みVAE(CIFAR-10用)

class ConvVAE(nn.Module):
    """カラー画像用の畳み込みVAE"""
    def __init__(self, latent_dim=128):
        super().__init__()
        self.latent_dim = latent_dim

        # 畳み込みエンコーダ
        self.encoder_conv = nn.Sequential(
            nn.Conv2d(3, 32, 4, stride=2, padding=1),   # 16x16
            nn.ReLU(),
            nn.Conv2d(32, 64, 4, stride=2, padding=1),  # 8x8
            nn.ReLU(),
            nn.Conv2d(64, 128, 4, stride=2, padding=1), # 4x4
            nn.ReLU(),
        )
        self.fc_mu = nn.Linear(128 * 4 * 4, latent_dim)
        self.fc_logvar = nn.Linear(128 * 4 * 4, latent_dim)

        # 転置畳み込みデコーダ
        self.decoder_fc = nn.Linear(latent_dim, 128 * 4 * 4)
        self.decoder_conv = nn.Sequential(
            nn.ConvTranspose2d(128, 64, 4, stride=2, padding=1),  # 8x8
            nn.ReLU(),
            nn.ConvTranspose2d(64, 32, 4, stride=2, padding=1),   # 16x16
            nn.ReLU(),
            nn.ConvTranspose2d(32, 3, 4, stride=2, padding=1),    # 32x32
            nn.Sigmoid()
        )

    def encode(self, x):
        h = self.encoder_conv(x).view(x.size(0), -1)
        return self.fc_mu(h), self.fc_logvar(h)

    def reparameterize(self, mu, logvar):
        std = torch.exp(0.5 * logvar)
        eps = torch.randn_like(std)
        return mu + eps * std

    def decode(self, z):
        h = F.relu(self.decoder_fc(z)).view(-1, 128, 4, 4)
        return self.decoder_conv(h)

    def forward(self, x):
        mu, logvar = self.encode(x)
        z = self.reparameterize(mu, logvar)
        return self.decode(z), mu, logvar

4. 敵対的生成ネットワーク(GAN)

GANは2014年のIan Goodfellowの論文(arXiv:1406.2661)で提案された革命的なアイデアです。

4.1 ジェネレータとディスクリミネータのゲーム

GANは2つのニューラルネットワークが競い合って学習します。

ジェネレータ(G): ランダムノイズzから偽のデータを作成します。

  • 目標: ディスクリミネータを騙せるほどリアルなデータを生成する

ディスクリミネータ(D): 本物のデータと生成された偽のデータを区別します。

  • 目標: 本物のデータを1、偽のデータを0に分類する

このゲームで両方のネットワークはナッシュ均衡に達するまで競い合います。

4.2 ミニマックス損失

min_G max_D [E_x[log D(x)] + E_z[log(1 - D(G(z)))]]
  • Dが最大化: 本物のデータでlog D(x)を最大化、偽のデータでlog(1 - D(G(z)))を最大化
  • Gが最小化: G(z)がDを騙せるようlog(1 - D(G(z)))を最小化

実際には、ジェネレータ損失として -log(D(G(z))) を使用します(非飽和損失)。

4.3 基本的なGAN実装

import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
import numpy as np

class Generator(nn.Module):
    """GANジェネレータ"""
    def __init__(self, noise_dim=100, output_dim=784):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(noise_dim, 256),
            nn.LeakyReLU(0.2),
            nn.BatchNorm1d(256),
            nn.Linear(256, 512),
            nn.LeakyReLU(0.2),
            nn.BatchNorm1d(512),
            nn.Linear(512, 1024),
            nn.LeakyReLU(0.2),
            nn.BatchNorm1d(1024),
            nn.Linear(1024, output_dim),
            nn.Tanh()  # [-1, 1]の範囲
        )

    def forward(self, z):
        return self.model(z).view(-1, 1, 28, 28)


class Discriminator(nn.Module):
    """GANディスクリミネータ"""
    def __init__(self, input_dim=784):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(input_dim, 1024),
            nn.LeakyReLU(0.2),
            nn.Dropout(0.3),
            nn.Linear(1024, 512),
            nn.LeakyReLU(0.2),
            nn.Dropout(0.3),
            nn.Linear(512, 256),
            nn.LeakyReLU(0.2),
            nn.Dropout(0.3),
            nn.Linear(256, 1),
            nn.Sigmoid()
        )

    def forward(self, x):
        return self.model(x.view(x.size(0), -1))


def train_gan(epochs=200, noise_dim=100, lr=2e-4):
    """GANの学習"""
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize([0.5], [0.5])  # [-1, 1]に正規化
    ])
    train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
    dataloader = DataLoader(train_dataset, batch_size=64, shuffle=True)

    G = Generator(noise_dim).to(device)
    D = Discriminator().to(device)

    optimizer_G = optim.Adam(G.parameters(), lr=lr, betas=(0.5, 0.999))
    optimizer_D = optim.Adam(D.parameters(), lr=lr, betas=(0.5, 0.999))

    criterion = nn.BCELoss()

    for epoch in range(epochs):
        for i, (real_imgs, _) in enumerate(dataloader):
            batch_size = real_imgs.size(0)
            real_imgs = real_imgs.to(device)

            real_labels = torch.ones(batch_size, 1).to(device)
            fake_labels = torch.zeros(batch_size, 1).to(device)

            # === ディスクリミネータの更新 ===
            optimizer_D.zero_grad()

            d_real = D(real_imgs)
            d_loss_real = criterion(d_real, real_labels)

            z = torch.randn(batch_size, noise_dim).to(device)
            fake_imgs = G(z).detach()  # Gの勾配をデタッチ
            d_fake = D(fake_imgs)
            d_loss_fake = criterion(d_fake, fake_labels)

            d_loss = d_loss_real + d_loss_fake
            d_loss.backward()
            optimizer_D.step()

            # === ジェネレータの更新 ===
            optimizer_G.zero_grad()

            z = torch.randn(batch_size, noise_dim).to(device)
            fake_imgs = G(z)
            g_pred = D(fake_imgs)
            # GはDが偽物を本物として分類するよう望む
            g_loss = criterion(g_pred, real_labels)

            g_loss.backward()
            optimizer_G.step()

        if (epoch + 1) % 20 == 0:
            print(f"Epoch {epoch+1}/{epochs} | D Loss: {d_loss.item():.4f} | G Loss: {g_loss.item():.4f}")

    return G, D

4.4 モードの崩壊

GANの最大の問題の一つ。ジェネレータが多様な画像の生成をやめ、ディスクリミネータを騙すことに成功した少数のパターンのみを繰り返し生成するようになります。


5. GANの進化

5.1 DCGAN(Deep Convolutional GAN)

2015年に提案されたDCGANは、GANに畳み込みニューラルネットワークを正常に適用しました。

class DCGANGenerator(nn.Module):
    """DCGANジェネレータ(64x64画像用)"""
    def __init__(self, noise_dim=100, ngf=64, nc=3):
        super().__init__()
        self.main = nn.Sequential(
            # 入力: noise_dim x 1 x 1
            nn.ConvTranspose2d(noise_dim, ngf * 8, 4, 1, 0, bias=False),
            nn.BatchNorm2d(ngf * 8),
            nn.ReLU(True),
            # 状態: (ngf*8) x 4 x 4
            nn.ConvTranspose2d(ngf * 8, ngf * 4, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ngf * 4),
            nn.ReLU(True),
            # 状態: (ngf*4) 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 16 x 16
            nn.ConvTranspose2d(ngf * 2, ngf, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ngf),
            nn.ReLU(True),
            # 状態: (ngf) x 32 x 32
            nn.ConvTranspose2d(ngf, nc, 4, 2, 1, bias=False),
            nn.Tanh()
            # 出力: nc x 64 x 64
        )

    def forward(self, z):
        z = z.view(z.size(0), -1, 1, 1)
        return self.main(z)


class DCGANDiscriminator(nn.Module):
    """DCGANディスクリミネータ(64x64画像用)"""
    def __init__(self, ndf=64, nc=3):
        super().__init__()
        self.main = nn.Sequential(
            # 入力: nc x 64 x 64
            nn.Conv2d(nc, ndf, 4, 2, 1, bias=False),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(ndf, ndf * 2, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ndf * 2),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(ndf * 2, ndf * 4, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ndf * 4),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(ndf * 4, ndf * 8, 4, 2, 1, bias=False),
            nn.BatchNorm2d(ndf * 8),
            nn.LeakyReLU(0.2, inplace=True),
            nn.Conv2d(ndf * 8, 1, 4, 1, 0, bias=False),
            nn.Sigmoid()
        )

    def forward(self, x):
        return self.main(x).view(-1, 1)

5.2 WGAN(Wasserstein GAN)

WGAN(arXiv:1701.07875)はJensen-Shannon発散の代わりにWasserstein距離を使用し、学習の安定性を大幅に向上させました。

class WGANDiscriminator(nn.Module):
    """WGANクリティック: Sigmoid出力なし"""
    def __init__(self, input_dim=784):
        super().__init__()
        self.model = nn.Sequential(
            nn.Linear(input_dim, 512),
            nn.LeakyReLU(0.2),
            nn.Linear(512, 256),
            nn.LeakyReLU(0.2),
            nn.Linear(256, 1)
            # Sigmoidなし!Wasserstein距離計算に必要
        )

    def forward(self, x):
        return self.model(x.view(x.size(0), -1))


def train_wgan(G, D, dataloader, device, epochs=100,
               n_critic=5, clip_value=0.01, lr=5e-5):
    """WGANの学習"""
    optimizer_G = optim.RMSprop(G.parameters(), lr=lr)
    optimizer_D = optim.RMSprop(D.parameters(), lr=lr)

    for epoch in range(epochs):
        for i, (real_imgs, _) in enumerate(dataloader):
            real_imgs = real_imgs.to(device)
            batch_size = real_imgs.size(0)

            # クリティックをn_critic回更新
            for _ in range(n_critic):
                optimizer_D.zero_grad()

                z = torch.randn(batch_size, 100).to(device)
                fake_imgs = G(z).detach()

                # Wasserstein損失: E[D(real)] - E[D(fake)]
                d_loss = -torch.mean(D(real_imgs)) + torch.mean(D(fake_imgs))
                d_loss.backward()
                optimizer_D.step()

                # ウェイトクリッピング(Lipschitz制約)
                for p in D.parameters():
                    p.data.clamp_(-clip_value, clip_value)

            # ジェネレータの更新
            optimizer_G.zero_grad()
            z = torch.randn(batch_size, 100).to(device)
            fake_imgs = G(z)
            g_loss = -torch.mean(D(fake_imgs))
            g_loss.backward()
            optimizer_G.step()

5.3 WGAN-GP(勾配ペナルティ)

WGANのウェイトクリッピングの代わりに、勾配ペナルティがLipschitz制約をより効果的に適用します。

def gradient_penalty(D, real_imgs, fake_imgs, device):
    """勾配ペナルティの計算"""
    batch_size = real_imgs.size(0)
    # 本物と偽物の画像間のランダムな補間
    alpha = torch.rand(batch_size, 1, 1, 1).to(device)
    interpolated = alpha * real_imgs + (1 - alpha) * fake_imgs
    interpolated.requires_grad_(True)

    d_interpolated = D(interpolated)

    gradients = torch.autograd.grad(
        outputs=d_interpolated,
        inputs=interpolated,
        grad_outputs=torch.ones_like(d_interpolated),
        create_graph=True,
        retain_graph=True
    )[0]

    gradients = gradients.view(batch_size, -1)
    gradient_norm = gradients.norm(2, dim=1)
    penalty = ((gradient_norm - 1) ** 2).mean()
    return penalty


def wgan_gp_d_loss(D, real_imgs, fake_imgs, device, lambda_gp=10):
    """WGAN-GPディスクリミネータ損失"""
    d_real = D(real_imgs).mean()
    d_fake = D(fake_imgs).mean()
    gp = gradient_penalty(D, real_imgs, fake_imgs, device)
    return -d_real + d_fake + lambda_gp * gp

5.4 条件付きGAN(cGAN)

ラベル条件を追加することで特定のクラスの画像を生成します。

class ConditionalGenerator(nn.Module):
    """条件付きGANジェネレータ"""
    def __init__(self, noise_dim=100, num_classes=10, embed_dim=50):
        super().__init__()
        self.label_embedding = nn.Embedding(num_classes, embed_dim)
        self.model = nn.Sequential(
            nn.Linear(noise_dim + embed_dim, 256),
            nn.LeakyReLU(0.2),
            nn.Linear(256, 512),
            nn.LeakyReLU(0.2),
            nn.Linear(512, 784),
            nn.Tanh()
        )

    def forward(self, z, labels):
        label_embed = self.label_embedding(labels)
        x = torch.cat([z, label_embed], dim=1)
        return self.model(x).view(-1, 1, 28, 28)

6. 拡散モデル

拡散モデルは今日の画像生成の標準となっています。DDPM(Denoising Diffusion Probabilistic Models、Ho et al., 2020、arXiv:2006.11239)がこの分野を切り開きました。

6.1 拡散モデルの直感

核心的なアイデアは2つのプロセスです。

順方向プロセス(ノイズの追加): 本物の画像にガウスノイズを徐々に追加して純粋なノイズになるまで繰り返します。T ステップ後、標準正規分布になります。

逆方向プロセス(ノイズの除去): 純粋なノイズから始めて徐々にノイズを除去して元の画像を復元します。ニューラルネットワークがこの逆方向プロセスを学習します。

6.2 順方向プロセスの数学

各ステップでノイズが追加されます。

q(x_t | x_{t-1}) = N(x_t; sqrt(1-β_t) * x_{t-1}, β_t * I)

ここでβ_tはノイズスケジュールです。

ステップtへ直接ジャンプ(重要な性質):

q(x_t | x_0) = N(x_t; sqrt(ā_t) * x_0, (1-ā_t) * I)

ここでā_tはsから1からtまでの(1 - β_s)の積です。

これにより任意のタイムステップtでノイズの多い画像を直接計算できます。

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np

class NoiseSchedule:
    """ノイズスケジュールを管理"""
    def __init__(self, timesteps=1000, beta_start=1e-4, beta_end=0.02):
        self.timesteps = timesteps

        # 線形ノイズスケジュール
        self.betas = torch.linspace(beta_start, beta_end, timesteps)
        self.alphas = 1.0 - self.betas
        self.alphas_cumprod = torch.cumprod(self.alphas, dim=0)
        self.alphas_cumprod_prev = F.pad(self.alphas_cumprod[:-1], (1, 0), value=1.0)

        # 順方向プロセスの係数
        self.sqrt_alphas_cumprod = torch.sqrt(self.alphas_cumprod)
        self.sqrt_one_minus_alphas_cumprod = torch.sqrt(1.0 - self.alphas_cumprod)

        # 逆方向プロセスの係数
        self.posterior_variance = (
            self.betas * (1.0 - self.alphas_cumprod_prev) /
            (1.0 - self.alphas_cumprod)
        )

    def q_sample(self, x_start, t, noise=None):
        """順方向プロセス: x_0にtステップのノイズを追加"""
        if noise is None:
            noise = torch.randn_like(x_start)

        sqrt_alphas_cumprod_t = self.sqrt_alphas_cumprod[t].view(-1, 1, 1, 1)
        sqrt_one_minus_alphas_cumprod_t = self.sqrt_one_minus_alphas_cumprod[t].view(-1, 1, 1, 1)

        return sqrt_alphas_cumprod_t * x_start + sqrt_one_minus_alphas_cumprod_t * noise

6.3 U-Netアーキテクチャ

拡散モデルのノイズ予測ネットワークはU-Netアーキテクチャを使用し、正弦波埋め込みを通じてタイムステップtで条件付けられます。

class SinusoidalPositionEmbeddings(nn.Module):
    """タイムステップの正弦波位置埋め込み"""
    def __init__(self, dim):
        super().__init__()
        self.dim = dim

    def forward(self, time):
        device = time.device
        half_dim = self.dim // 2
        embeddings = np.log(10000) / (half_dim - 1)
        embeddings = torch.exp(torch.arange(half_dim, device=device) * -embeddings)
        embeddings = time[:, None] * embeddings[None, :]
        embeddings = torch.cat((embeddings.sin(), embeddings.cos()), dim=-1)
        return embeddings


class ResidualBlock(nn.Module):
    """タイムステップ条件付きの残差ブロック"""
    def __init__(self, in_channels, out_channels, time_emb_dim):
        super().__init__()
        self.time_mlp = nn.Sequential(
            nn.SiLU(),
            nn.Linear(time_emb_dim, out_channels)
        )
        self.block1 = nn.Sequential(
            nn.GroupNorm(8, in_channels),
            nn.SiLU(),
            nn.Conv2d(in_channels, out_channels, 3, padding=1)
        )
        self.block2 = nn.Sequential(
            nn.GroupNorm(8, out_channels),
            nn.SiLU(),
            nn.Conv2d(out_channels, out_channels, 3, padding=1)
        )
        self.residual_conv = (
            nn.Conv2d(in_channels, out_channels, 1)
            if in_channels != out_channels else nn.Identity()
        )

    def forward(self, x, time_emb):
        h = self.block1(x)
        time_emb = self.time_mlp(time_emb)[:, :, None, None]
        h = h + time_emb
        h = self.block2(h)
        return h + self.residual_conv(x)


class SimpleUNet(nn.Module):
    """DDPMのための簡略化したU-Net"""
    def __init__(self, in_channels=1, model_channels=64, time_emb_dim=256):
        super().__init__()

        # タイムステップ埋め込み
        self.time_mlp = nn.Sequential(
            SinusoidalPositionEmbeddings(model_channels),
            nn.Linear(model_channels, time_emb_dim),
            nn.SiLU(),
            nn.Linear(time_emb_dim, time_emb_dim)
        )

        # エンコーダ
        self.down1 = ResidualBlock(in_channels, model_channels, time_emb_dim)
        self.down2 = ResidualBlock(model_channels, model_channels * 2, time_emb_dim)
        self.pool = nn.MaxPool2d(2)

        # ボトルネック
        self.bottleneck = ResidualBlock(model_channels * 2, model_channels * 2, time_emb_dim)

        # デコーダ
        self.up1 = ResidualBlock(model_channels * 4, model_channels, time_emb_dim)
        self.up2 = ResidualBlock(model_channels * 2, model_channels, time_emb_dim)
        self.upsample = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True)

        # 出力
        self.final = nn.Conv2d(model_channels, in_channels, 1)

    def forward(self, x, t):
        time_emb = self.time_mlp(t)

        # エンコーダパス
        x1 = self.down1(x, time_emb)
        x2 = self.down2(self.pool(x1), time_emb)

        # ボトルネック
        x_mid = self.bottleneck(x2, time_emb)

        # デコーダパス(スキップ接続付き)
        x = self.up1(torch.cat([self.upsample(x_mid), x2], dim=1), time_emb)
        x = self.up2(torch.cat([self.upsample(x), x1], dim=1), time_emb)

        return self.final(x)

6.4 DDPMの学習とサンプリング

class DDPM:
    """DDPMの学習とサンプリング"""
    def __init__(self, model, noise_schedule, device):
        self.model = model
        self.schedule = noise_schedule
        self.device = device

    def get_loss(self, x_start, t):
        """学習損失: ノイズ予測誤差"""
        noise = torch.randn_like(x_start)
        x_noisy = self.schedule.q_sample(x_start, t, noise)

        # ニューラルネットワークが追加されたノイズを予測
        predicted_noise = self.model(x_noisy, t)

        return F.mse_loss(noise, predicted_noise)

    @torch.no_grad()
    def p_sample(self, x_t, t):
        """逆方向プロセス: x_tからx_{t-1}へ1ステップデノイズ"""
        betas_t = self.schedule.betas[t].view(-1, 1, 1, 1).to(self.device)
        sqrt_one_minus_alphas_cumprod_t = (
            self.schedule.sqrt_one_minus_alphas_cumprod[t].view(-1, 1, 1, 1).to(self.device)
        )
        sqrt_recip_alphas_t = torch.sqrt(
            1.0 / self.schedule.alphas[t]
        ).view(-1, 1, 1, 1).to(self.device)

        predicted_noise = self.model(x_t, t)

        model_mean = sqrt_recip_alphas_t * (
            x_t - betas_t * predicted_noise / sqrt_one_minus_alphas_cumprod_t
        )

        if t[0] == 0:
            return model_mean
        else:
            posterior_variance_t = (
                self.schedule.posterior_variance[t].view(-1, 1, 1, 1).to(self.device)
            )
            noise = torch.randn_like(x_t)
            return model_mean + torch.sqrt(posterior_variance_t) * noise

    @torch.no_grad()
    def sample(self, batch_size, image_shape):
        """純粋なノイズから始めて画像を生成"""
        img = torch.randn(batch_size, *image_shape).to(self.device)

        for i in reversed(range(self.schedule.timesteps)):
            t = torch.full((batch_size,), i, dtype=torch.long, device=self.device)
            img = self.p_sample(img, t)

        return img


def train_ddpm(epochs=100):
    """DDPMの学習"""
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

    transform = transforms.Compose([
        transforms.ToTensor(),
        transforms.Normalize([0.5], [0.5])
    ])
    dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
    dataloader = DataLoader(dataset, batch_size=128, shuffle=True)

    model = SimpleUNet(in_channels=1).to(device)
    schedule = NoiseSchedule(timesteps=1000)
    ddpm = DDPM(model, schedule, device)

    optimizer = optim.Adam(model.parameters(), lr=2e-4)

    for epoch in range(epochs):
        total_loss = 0
        for batch, (x, _) in enumerate(dataloader):
            x = x.to(device)
            # ランダムなタイムステップサンプリング
            t = torch.randint(0, schedule.timesteps, (x.size(0),), device=device)

            loss = ddpm.get_loss(x, t)
            optimizer.zero_grad()
            loss.backward()
            optimizer.step()
            total_loss += loss.item()

        if (epoch + 1) % 10 == 0:
            avg_loss = total_loss / len(dataloader)
            print(f"Epoch {epoch+1}/{epochs}, Loss: {avg_loss:.4f}")

    return model, ddpm

7. DDIM - 高速サンプリング

DDPMは1000ステップのサンプリングが必要で低速です。DDIM(Denoising Diffusion Implicit Models、Song et al., 2020)は高速で決定論的なサンプリングを提供します。

7.1 DDIMのアイデア

DDIMはサンプリングプロセスを非マルコフ過程として再定義します。重要なのは同じ学習済みモデルを使用しながらサンプリングステップを大幅に削減(1000→50ステップ)することです。

@torch.no_grad()
def ddim_sample(model, schedule, batch_size, image_shape,
                ddim_timesteps=50, eta=0.0, device='cpu'):
    """
    DDIMサンプリング
    eta=0.0: 完全に決定論的
    eta=1.0: DDPMと同等
    """
    # 均等に間隔を置いたタイムステップを選択
    c = schedule.timesteps // ddim_timesteps
    timestep_seq = list(range(0, schedule.timesteps, c))[::-1]

    img = torch.randn(batch_size, *image_shape).to(device)

    for i, t in enumerate(timestep_seq):
        t_tensor = torch.full((batch_size,), t, dtype=torch.long, device=device)
        t_prev = timestep_seq[i + 1] if i + 1 < len(timestep_seq) else -1

        alpha_bar = schedule.alphas_cumprod[t].to(device)
        alpha_bar_prev = (
            schedule.alphas_cumprod[t_prev].to(device) if t_prev >= 0
            else torch.tensor(1.0, device=device)
        )

        # ノイズを予測
        pred_noise = model(img, t_tensor)

        # x_0を予測
        pred_x0 = (img - torch.sqrt(1 - alpha_bar) * pred_noise) / torch.sqrt(alpha_bar)
        pred_x0 = torch.clamp(pred_x0, -1, 1)

        # シグマを計算
        sigma = eta * torch.sqrt(
            (1 - alpha_bar_prev) / (1 - alpha_bar) * (1 - alpha_bar / alpha_bar_prev)
        )

        # x_tへの方向
        pred_dir = torch.sqrt(1 - alpha_bar_prev - sigma**2) * pred_noise

        # 次のステップの画像
        noise = torch.randn_like(img) if t_prev >= 0 else 0
        img = torch.sqrt(alpha_bar_prev) * pred_x0 + pred_dir + sigma * noise

    return img

8. Stable Diffusion解析

Stable Diffusion(arXiv:2112.10752)はピクセル空間ではなく潜在空間で拡散を行うという革新的なアプローチです。

8.1 潜在拡散モデル(LDM)

高解像度画像(512x512)に直接拡散を適用するのは計算コストが高いです。LDMは:

  1. VAEエンコーダで画像を潜在空間に圧縮(512x512x3 → 64x64x4)
  2. 潜在空間で拡散を実行
  3. VAEデコーダで元の解像度を復元

これにより計算コストを8倍以上削減できます。

8.2 Stable Diffusionのコンポーネント

テキストプロンプト → CLIPテキストエンコーダ → テキスト埋め込み
純粋ノイズ z_T → [U-Net + クロスアテンション] → 潜在 z_0
                                     VAEデコーダ → 最終画像

CLIPテキストエンコーダ: テキストを意味のあるベクトル表現に変換します。

クロスアテンション付きU-Net: クロスアテンションを通じてテキスト埋め込みを条件として使用します。

Classifier-Free Guidance(CFG): 条件付きと無条件の予測を組み合わせてテキストの忠実度を向上させます。

guided_noise = uncond_noise + guidance_scale * (cond_noise - uncond_noise)

8.3 diffusersでStable Diffusionを使用

from diffusers import StableDiffusionPipeline, DPMSolverMultistepScheduler
import torch

model_id = "stabilityai/stable-diffusion-2-1"
pipe = StableDiffusionPipeline.from_pretrained(
    model_id,
    torch_dtype=torch.float16,
    use_safetensors=True,
)

# 高速スケジューラを使用
pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
pipe = pipe.to("cuda")

# 画像を生成
prompt = "a photorealistic landscape of mountains at sunset, 8k, highly detailed"
negative_prompt = "blurry, low quality, distorted"

image = pipe(
    prompt,
    negative_prompt=negative_prompt,
    num_inference_steps=25,    # 1000 DDPMステップ → 25ステップ
    guidance_scale=7.5,        # CFGスケール
    height=512,
    width=512,
    generator=torch.Generator("cuda").manual_seed(42)
).images[0]

image.save("generated_image.png")


# 画像から画像への変換
from diffusers import StableDiffusionImg2ImgPipeline
from PIL import Image

img2img_pipe = StableDiffusionImg2ImgPipeline.from_pretrained(
    model_id,
    torch_dtype=torch.float16
).to("cuda")

init_image = Image.open("input.jpg").resize((512, 512))
result = img2img_pipe(
    prompt="a painting in Van Gogh style",
    image=init_image,
    strength=0.75,  # 変換の度合い(0-1)
    guidance_scale=7.5,
    num_inference_steps=50
).images[0]

8.4 インペインティング

from diffusers import StableDiffusionInpaintPipeline

inpaint_pipe = StableDiffusionInpaintPipeline.from_pretrained(
    "runwayml/stable-diffusion-inpainting",
    torch_dtype=torch.float16
).to("cuda")

image = Image.open("photo.jpg").resize((512, 512))
mask = Image.open("mask.jpg").resize((512, 512))  # 白=インペイント領域

result = inpaint_pipe(
    prompt="a beautiful garden with flowers",
    image=image,
    mask_image=mask,
    num_inference_steps=50
).images[0]

9. ControlNet - 細かい画像制御

9.1 ControlNetアーキテクチャ

ControlNet(Zhang et al., 2023)はStable Diffusionに追加の制御信号(Cannyエッジ、深度マップ、ポーズなど)を条件として追加できます。

元のU-Netの重みは凍結され、別の制御ネットワークが追加されます。SDエンコーダのコピーとして約3億6千万個の追加パラメータを学習します。

from diffusers import StableDiffusionControlNetPipeline, ControlNetModel
from diffusers.utils import load_image
import cv2
import numpy as np

# Canny ControlNetを読み込み
controlnet = ControlNetModel.from_pretrained(
    "lllyasviel/sd-controlnet-canny",
    torch_dtype=torch.float16
)
pipe = StableDiffusionControlNetPipeline.from_pretrained(
    "runwayml/stable-diffusion-v1-5",
    controlnet=controlnet,
    torch_dtype=torch.float16
).to("cuda")

# Cannyエッジを抽出
image = load_image("input.jpg")
image_array = np.array(image)
canny = cv2.Canny(image_array, 100, 200)
canny_image = Image.fromarray(canny)

# ControlNetで生成
result = pipe(
    prompt="a beautiful oil painting",
    image=canny_image,   # 制御信号としてのCannyエッジ
    controlnet_conditioning_scale=1.0,
    num_inference_steps=50,
    guidance_scale=7.5
).images[0]

10. 生成モデルの現在のトレンド

10.1 DiT(拡散トランスフォーマー)

PeeblesとXie(2022年)が提案したDiTは、U-Netの代わりにTransformerアーキテクチャを拡散モデルのバックボーンとして使用します。Sora、Flux、SD3などの現代的なモデルはDiTベースです。

class DiTBlock(nn.Module):
    """拡散トランスフォーマーブロック"""
    def __init__(self, hidden_dim, num_heads, mlp_ratio=4.0):
        super().__init__()
        self.norm1 = nn.LayerNorm(hidden_dim)
        self.attn = nn.MultiheadAttention(hidden_dim, num_heads, batch_first=True)
        self.norm2 = nn.LayerNorm(hidden_dim)

        mlp_dim = int(hidden_dim * mlp_ratio)
        self.mlp = nn.Sequential(
            nn.Linear(hidden_dim, mlp_dim),
            nn.GELU(),
            nn.Linear(mlp_dim, hidden_dim)
        )

        # AdaLN(アダプティブ層正規化): タイムステップ条件付け
        self.adaLN_modulation = nn.Sequential(
            nn.SiLU(),
            nn.Linear(hidden_dim, 6 * hidden_dim)
        )

    def forward(self, x, c):
        # タイムステップ/条件埋め込みから変調パラメータを計算
        shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = (
            self.adaLN_modulation(c).chunk(6, dim=-1)
        )

        # 変調された正規化
        x_norm = (1 + scale_msa.unsqueeze(1)) * self.norm1(x) + shift_msa.unsqueeze(1)
        attn_out, _ = self.attn(x_norm, x_norm, x_norm)
        x = x + gate_msa.unsqueeze(1) * attn_out

        x_norm = (1 + scale_mlp.unsqueeze(1)) * self.norm2(x) + shift_mlp.unsqueeze(1)
        x = x + gate_mlp.unsqueeze(1) * self.mlp(x_norm)

        return x

10.2 SDXL(Stable Diffusion XL)

高解像度(1024x1024)画像を生成するStable Diffusionのアップグレード版です。

from diffusers import StableDiffusionXLPipeline

pipe = StableDiffusionXLPipeline.from_pretrained(
    "stabilityai/stable-diffusion-xl-base-1.0",
    torch_dtype=torch.float16,
    use_safetensors=True,
    variant="fp16"
).to("cuda")

image = pipe(
    prompt="a majestic lion in photorealistic style, 4k",
    negative_prompt="cartoon, blurry",
    height=1024,
    width=1024,
    num_inference_steps=50,
    guidance_scale=5.0
).images[0]

10.3 生成モデルの比較

モデル核心アイデア強み弱み
VAE2013潜在分布を学習安定、解釈可能な潜在空間ぼやけた画像
GAN2014敵対的学習シャープな画像不安定な学習、モード崩壊
DDPM2020反復的デノイズ高品質、多様性遅いサンプリング
LDM2022潜在空間拡散効率的、テキスト条件付け複雑なアーキテクチャ
DiT2022Transformerバックボーンスケーリング効率高い計算コスト

まとめ

生成AIの旅を完全に探求しました。VAEの潜在空間理論から、GANの敵対的ゲーム、DDPMの反復的デノイズ、そして最終的にStable Diffusionの潜在空間拡散まで。

この分野は非常に速く進化します。今日の最先端は明日のベースラインになります。基礎を深く理解しながら、継続的に新しい研究論文をフォローすることが不可欠です。

継続学習のための推奨リソース:

  • Hugging Face Diffusers: https://huggingface.co/docs/diffusers/
  • VAE論文: arXiv:1312.6114
  • GAN論文: arXiv:1406.2661
  • DDPM論文: arXiv:2006.11239
  • 潜在拡散 / Stable Diffusion: arXiv:2112.10752

参考文献

  • Kingma, D. P., & Welling, M. (2013). Auto-Encoding Variational Bayes. arXiv:1312.6114
  • Goodfellow, I., et al. (2014). Generative Adversarial Networks. arXiv:1406.2661
  • Ho, J., et al. (2020). Denoising Diffusion Probabilistic Models. arXiv:2006.11239
  • Rombach, R., et al. (2022). High-Resolution Image Synthesis with Latent Diffusion Models. arXiv:2112.10752
  • Arjovsky, M., et al. (2017). Wasserstein GAN. arXiv:1701.07875
  • Peebles, W., & Xie, S. (2022). Scalable Diffusion Models with Transformers. arXiv:2212.09748
  • Zhang, L., et al. (2023). Adding Conditional Control to Text-to-Image Diffusion Models. arXiv:2302.05543
  • Hugging Face Diffusers: https://huggingface.co/docs/diffusers/