Генеративно-состязательная сеть PyTorch (GAN)

Генеративно-состязательная сеть (GAN)— одна из самых креативных архитектур моделей в глубоком обучении. Заставляя две нейронные сети конкурировать и учиться друг у друга, она в конечном итоге может генерировать очень реалистичные данные. GAN-модели широко используются в таких сценариях, как генерация изображений, перенос стиля и аугментация данных.


1. Основные принципы GAN

Основная идея GAN происходит из «игры с нулевой суммой» в теории игр. Она состоит из двух конкурирующих сетей:

  • Генератор: учится генерировать поддельные данные, цель — сделать так, чтобы дискриминатор не мог отличить сгенерированные данные от реальных
  • Дискриминатор: учится отличать реальные данные от сгенерированных, цель — делать суждения как можно более точными

Оба соревнуются друг с другом во время обучения и непрерывно улучшаются, в конечном итоге достигая состояния равновесия Нэша.

1.1 Целевая функция GAN

Задача обучения GAN может быть выражена в виде следующей минимаксной игры:

\[ \min_G \max_D \mathbb{E}_{x \sim p_{data}(x)}[\log D(x)] + \mathbb{E}_{z \sim p_z(z)}[\log(1 - D(G(z)))] \]

где:

  • \(G\) обозначает сеть генератора
  • \(D\) обозначает сеть дискриминатора
  • \(x\) обозначает реальные данные
  • \(z\) обозначает вектор случайного шума (обычно подчиняющийся стандартному нормальному распределению)
  • \(G(z)\) обозначает поддельные данные, сгенерированные генератором из шума

1.2 Понимание процесса обучения

Обучение GAN разделено на два этапа:

Этап 1: Обучение дискриминатора

Заморозьте генератор и улучшите способность дискриминатора различать:

\[ \max_D \mathbb{E}_{x \sim p_{data}}[\log D(x)] + \mathbb{E}_{z \sim p_z}[\log(1 - D(G(z)))] \]

Этап 2: Обучение генератора

Заморозьте дискриминатор и улучшите способность генератора обманывать:

\[ \min_G \mathbb{E}_{z \sim p_z}[\log(1 - D(G(z)))] \]

В реальном обучении обычно сначала обучают дискриминатор в течение k шагов, затем генератор в течение 1 шага, чтобы поддерживать баланс.


2. Базовая реализация GAN

Ниже представлена минимальная реализация GAN — используемая для генерации двумерных точек данных.

2.1 Определение генератора и дискриминатора

Пример

import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt

# Установить случайное зерно
torch.manual_seed(42)

# ── Сеть генератора ──────────────────────────────────────
class Generator(nn.Module):
    """
Генератор: генерирует данные из случайного шума
Вход: вектор шума (batch_size, noise_dim)
Выход: сгенерированные данные (batch_size, data_dim)
    """

    def __init__(self, noise_dim, data_dim, hidden_dim=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(noise_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, data_dim),
            # Выход не активируется; GAN изучит подходящее распределение
        )

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


# ── Сеть дискриминатора ──────────────────────────────────────
class Discriminator(nn.Module):
    """
Дискриминатор: отличает реальные данные от сгенерированных данных
Вход: точки данных (batch_size, data_dim)
Выход: вероятность реальных данных (batch_size, 1)
    """

    def __init__(self, data_dim, hidden_dim=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(data_dim, hidden_dim),
            nn.LeakyReLU(0.2),  # LeakyReLU предотвращает затухание градиентов
            nn.Linear(hidden_dim, hidden_dim),
            nn.LeakyReLU(0.2),
            nn.Linear(hidden_dim, 1),
            nn.Sigmoid()  # Выходная вероятность
        )

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


# Гиперпараметры
NOISE_DIM = 16
DATA_DIM = 2
HIDDEN_DIM = 64
BATCH_SIZE = 128

# Создание сетей
generator = Generator(NOISE_DIM, DATA_DIM, HIDDEN_DIM)
discriminator = Discriminator(DATA_DIM, HIDDEN_DIM)

print(f"Количество параметров генератора: {sum(p.numel() for p in generator.parameters()):,}")
print(f"Количество параметров дискриминатора: {sum(p.numel() for p in discriminator.parameters()):,}")

2.2 Цикл обучения

Пример

# ── Оптимизатор ──────────────────────────────────────
lr = 0.001
g_optimizer = optim.Adam(generator.parameters(), lr=lr)
d_optimizer = optim.Adam(discriminator.parameters(), lr=lr)

# Функция потерь: бинарная кросс-энтропия
criterion = nn.BCELoss()

# ── Обучающие данные: кольцевое распределение ──────────────────────────
def generate_real_data(batch_size):
    """Сгенерировать реальные данные с кольцевым распределением"""
    angles = torch.rand(batch_size) * 2 * torch.pi
    radius = 1.0 + torch.randn(batch_size) * 0.1  # Радиус приблизительно равен 1
    x = radius * torch.cos(angles)
    y = radius * torch.sin(angles)
    return torch.stack([x, y], dim=1)


# ── Цикл обучения ──────────────────────────────────────
NUM_EPOCHS = 1000
d_losses = []
g_losses = []

for epoch in range(NUM_EPOCHS):
    # 1. Обучаем дискриминатор
    # Сгенерировать фиктивные данные
    noise = torch.randn(BATCH_SIZE, NOISE_DIM)
    fake_data = generator(noise).detach()  # detach, чтобы не вычислять градиенты генератора

    # Генерируем реальные данные
    real_data = generate_real_data(BATCH_SIZE)

    # Потери дискриминатора
    real_pred = discriminator(real_data)
    fake_pred = discriminator(fake_data)
    d_loss = criterion(real_pred, torch.ones_like(real_pred)) + \
             criterion(fake_pred, torch.zeros_like(fake_pred))

    # Обновляем дискриминатор
    d_optimizer.zero_grad()
    d_loss.backward()
    d_optimizer.step()

    # 2. Обучаем генератор
    # Генерируем новый батч фейковых данных
    noise = torch.randn(BATCH_SIZE, NOISE_DIM)
    fake_data = generator(noise)

    # Потери генератора: заставить дискриминатор поверить, что сгенерированные данные настоящие
    fake_pred = discriminator(fake_data)
    g_loss = criterion(fake_pred, torch.ones_like(fake_pred))

    # Обновляем генератор
    g_optimizer.zero_grad()
    g_loss.backward()
    g_optimizer.step()

    # Записываем потери
    d_losses.append(d_loss.item())
    g_losses.append(g_loss.item())

    if (epoch + 1) % 100 == 0:
        print(f"Эпоха {epoch+1:4d} | D_loss: {d_loss:.4f} | G_loss: {g_loss:.4f}")

print("Обучение завершено!")

2.3 Визуализация сгенерированных результатов

Пример

# Сгенерировать данные и визуализировать
def visualize_results(generator, num_samples=1000):
    noise = torch.randn(num_samples, NOISE_DIM)
    generated_data = generator(noise).detach().numpy()

    plt.figure(figsize=(6, 6))
    plt.scatter(generated_data[:, 0], generated_data[:, 1],
                alpha=0.5, s=10, c='blue', label='Сгенерированные')
    plt.xlim(-2, 2)
    plt.ylim(-2, 2)
    plt.xlabel('x')
    plt.ylabel('y')
    plt.title('Данные, сгенерированные GAN')
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.show()


# Просмотр результатов генерации
visualize_results(generator)

3. DCGAN - глубокая сверточная GAN

DCGAN — это классическая архитектура, которая вводит сверточные нейронные сети в GAN, значительно улучшая качество генерации изображений.

3.1 Ключевые моменты архитектуры DCGAN

  • Используйте транспонированную свертку для апсемплинга, чтобы генерировать изображения
  • Используйте свертку с шагом для даунсемплинга, чтобы различать изображения
  • Используйте BatchNorm и в генераторе, и в дискриминаторе (но не в выходном слое или входном слое)
  • Генератор использует ReLU, дискриминатор использует LeakyReLU

3.2 Реализация DCGAN

Пример

import torch
import torch.nn as nn

# ── Генератор DCGAN ─────────────────────────────────
class DCGenerator(nn.Module):
    """
Генератор DCGAN: апсемплинг с помощью транспонированной свёртки
    """

    def __init__(self, noise_dim=100, channels=3, features_g=64):
        super().__init__()
        self.noise_dim = noise_dim

        # Вход: noise_dim x 1 x 1
        self.net = nn.Sequential(
            # Транспонированная свёртка: (batch, features_g*8, 4, 4)
            nn.ConvTranspose2d(noise_dim, features_g * 8, 4, 1, 0, bias=False),
            nn.BatchNorm2d(features_g * 8),
            nn.ReLU(True),

            # (batch, features_g*4, 8, 8)
            nn.ConvTranspose2d(features_g * 8, features_g * 4, 4, 2, 1, bias=False),
            nn.BatchNorm2d(features_g * 4),
            nn.ReLU(True),

            # (batch, features_g*2, 16, 16)
            nn.ConvTranspose2d(features_g * 4, features_g * 2, 4, 2, 1, bias=False),
            nn.BatchNorm2d(features_g * 2),
            nn.ReLU(True),

            # (batch, features_g, 32, 32)
            nn.ConvTranspose2d(features_g * 2, features_g, 4, 2, 1, bias=False),
            nn.BatchNorm2d(features_g),
            nn.ReLU(True),

            # Выход: (batch, channels, 64, 64)
            nn.ConvTranspose2d(features_g, channels, 4, 2, 1, bias=False),
            nn.Tanh()  # Диапазон выхода [-1, 1]
        )

    def forward(self, x):
        # x: (batch, noise_dim) -> (batch, noise_dim, 1, 1)
        x = x.view(x.size(0), x.size(1), 1, 1)
        return self.net(x)


# ── Дискриминатор DCGAN ─────────────────────────────────
class DCDiscriminator(nn.Module):
    """
Дискриминатор DCGAN: понижение размерности с помощью свёртки
    """

    def __init__(self, channels=3, features_d=64):
        super().__init__()

        # Вход: (batch, channels, 64, 64)
        self.net = nn.Sequential(
            # (batch, features_d, 32, 32)
            nn.Conv2d(channels, features_d, 4, 2, 1, bias=False),
            nn.LeakyReLU(0.2, inplace=True),

            # (batch, features_d*2, 16, 16)
            nn.Conv2d(features_d, features_d * 2, 4, 2, 1, bias=False),
            nn.BatchNorm2d(features_d * 2),
            nn.LeakyReLU(0.2, inplace=True),

            # (batch, features_d*4, 8, 8)
            nn.Conv2d(features_d * 2, features_d * 4, 4, 2, 1, bias=False),
            nn.BatchNorm2d(features_d * 4),
            nn.LeakyReLU(0.2, inplace=True),

            # (batch, features_d*8, 4, 4)
            nn.Conv2d(features_d * 4, features_d * 8, 4, 2, 1, bias=False),
            nn.BatchNorm2d(features_d * 8),
            nn.LeakyReLU(0.2, inplace=True),

            # Выход: (batch, 1, 1, 1)
            nn.Conv2d(features_d * 8, 1, 4, 1, 0, bias=False),
            nn.Sigmoid()
        )

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


# Проверка сети
noise_dim = 100
generator = DCGenerator(noise_dim=noise_dim, channels=3, features_g=64)
discriminator = DCDiscriminator(channels=3, features_d=64)

# Проверка прямого прохода
noise = torch.randn(2, noise_dim)
fake_images = generator(noise)
print(f"Форма сгенерированного изображения: {fake_images.shape}")  # torch.Size([2, 3, 64, 64])

decision = discriminator(fake_images)
print(f"Форма результата дискриминации: {decision.shape}")     # torch.Size([2, 1])

3.3 Полный код обучения DCGAN

Пример

# ── Конфигурация обучения ─────────────────────────────────────
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Используется устройство: {device}")

NOISE_DIM = 100
LEARNING_RATE = 0.0002
BETA1 = 0.5  # Параметры Adam

# Создание сетей
generator = DCGenerator(noise_dim=NOISE_DIM).to(device)
discriminator = DCDiscriminator().to(device)

# Оптимизатор
g_optimizer = optim.Adam(generator.parameters(), lr=LEARNING_RATE, betas=(BETA1, 0.999))
d_optimizer = optim.Adam(discriminator.parameters(), lr=LEARNING_RATE, betas=(BETA1, 0.999))

criterion = nn.BCELoss()

# ── Цикл обучения ─────────────────────────────────────
fixed_noise = torch.randn(64, NOISE_DIM, device=device)  # Для визуализации

def train_dcgan(generator, discriminator, g_optimizer, d_optimizer, criterion,
                num_epochs, device, fixed_noise):
    G_losses = []
    D_losses = []

    for epoch in range(num_epochs):
        for batch_idx in range(100):  # Предположим, что в каждой эпохе 100 батчей
            # Обучение дискриминатора
            discriminator.zero_grad()

            # Реальные изображения (предполагается, что доступны)
            # real_images = ...
            # Использовать случайный шум для имитации здесь
            real_images = torch.randn(32, 3, 64, 64).to(device)

            batch_size = real_images.size(0)
            labels_real = torch.ones(batch_size, 1).to(device)
            labels_fake = torch.zeros(batch_size, 1).to(device)

            # Потери на реальных изображениях
            output = discriminator(real_images)
            d_loss_real = criterion(output, labels_real)

            # Потери на сгенерированных изображениях
            noise = torch.randn(batch_size, NOISE_DIM).to(device)
            fake_images = generator(noise)
            output = discriminator(fake_images.detach())
            d_loss_fake = criterion(output, labels_fake)

            # Суммарная потеря
            d_loss = d_loss_real + d_loss_fake
            d_loss.backward()
            d_optimizer.step()

            # Обучаем генератор
            generator.zero_grad()

            noise = torch.randn(batch_size, NOISE_DIM).to(device)
            fake_images = generator(noise)
            output = discriminator(fake_images)
            g_loss = criterion(output, labels_real)  # Надеемся, что сгенерированные изображения будут признаны реальными

            g_loss.backward()
            g_optimizer.step()

            # Записываем потерю
            if batch_idx % 50 == 0:
                G_losses.append(g_loss.item())
                D_losses.append(d_loss.item())
                print(f"[{epoch}/{num_epochs}][{batch_idx}/100] "
                      f"D_loss: {d_loss:.4f} | G_loss: {g_loss:.4f}")

    return G_losses, D_losses


# Начать обучение
# G_losses, D_losses = train_dcgan(generator, discriminator, g_optimizer,
# d_optimizer, criterion, 5, device, fixed_noise)
print("Архитектура DCGAN определена, можно начинать обучение!")

4. Советы по обучению GAN

4.1 Распространённые проблемы и решения

Проблема Причина Решение
Схлопывание режимов Генератор производит лишь ограниченное разнообразие образцов Используйте WGAN, добавьте дискриминацию мини-батчей, используйте сглаживание меток
Дискриминатор слишком силён Градиенты генератора исчезают, из-за чего он не может обучаться Обучайте генератор несколько раз, уменьшите скорость обучения дискриминатора, используйте LeakyReLU
Нестабильное обучение Целевая функция GAN невыпукла и трудно сходится Используйте спектральную нормализацию, штраф за градиент, разогрев скорости обучения
Плохое качество генерации Недостаточная ёмкость сети или недостаточное обучение Увеличьте глубину сети, используйте больше обучающих данных, обучайте дольше

4.2 Улучшения функции потерь

Оригинальный GAN использует дивергенцию JS, которая имеет проблемы исчезающего градиента. WGAN использует расстояние Вассерштейна, которое более стабильно:

Пример

# Функция потерь WGAN (заменяет BCE)
def wgan_d_loss(real_pred, fake_pred):
    """Потеря дискриминатора: реальные образцы оцениваются высоко, сгенерированные — низко"""
    return -(torch.mean(real_pred) - torch.mean(fake_pred))

def wgan_g_loss(fake_pred):
    """Потеря генератора: сделать так, чтобы сгенерированные образцы оценивались высоко"""
    return -torch.mean(fake_pred)

# Градиентный штраф - WGAN-GP
def gradient_penalty(discriminator, real_images, fake_images, device):
    """Слагаемое градиентного штрафа WGAN-GP"""
    batch_size = real_images.size(0)
    alpha = torch.rand(batch_size, 1, 1, 1).to(device)

    # Интерполяция между реальными и сгенерированными изображениями
    interpolated = alpha * real_images + (1 - alpha) * fake_images
    interpolated.requires_grad = True

    # Вычислить выход дискриминатора для интерполированных изображений
    pred = discriminator(interpolated)

    # Вычислить градиент
    gradients = torch.autograd.grad(
        outputs=pred,
        inputs=interpolated,
        grad_outputs=torch.ones_like(pred),
        create_graph=True,
        retain_graph=True,
        only_inputs=True
    )[0]

    # Вычислить норму градиента
    gradients = gradients.view(batch_size, -1)
    gradient_norm = gradients.norm(2, dim=1)
    penalty = ((gradient_norm - 1) ** 2).mean()

    return penalty

4.3 Спектральная нормализация

Спектральная нормализация может стабилизировать обучение GAN, контролируя константу Липшица дискриминатора:

Пример

import torch.nn.utils.spectral_norm as spectral_norm

# Дискриминатор с использованием спектральной нормализации
class SNDiscriminator(nn.Module):
    def __init__(self, channels=3, features_d=64):
        super().__init__()
        self.net = nn.Sequential(
            spectral_norm(nn.Conv2d(channels, features_d, 4, 2, 1)),
            nn.LeakyReLU(0.2, inplace=True),

            spectral_norm(nn.Conv2d(features_d, features_d * 2, 4, 2, 1)),
            nn.LeakyReLU(0.2, inplace=True),

            spectral_norm(nn.Conv2d(features_d * 2, features_d * 4, 4, 2, 1)),
            nn.LeakyReLU(0.2, inplace=True),

            spectral_norm(nn.Conv2d(features_d * 4, 1, 4, 1, 0)),
            nn.Sigmoid()
        )

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

5. Условный GAN (cGAN)

Условный GAN позволяет задавать метки классов для генерируемых данных, обеспечивая условную генерацию.

5.1 Архитектура cGAN

Пример

import torch
import torch.nn as nn

class ConditionalGenerator(nn.Module):
    """Условный генератор: принимает как шум, так и метки классов"""
    def __init__(self, noise_dim, num_classes, embed_dim, img_channels, features_g=64):
        super().__init__()
        self.label_emb = nn.Embedding(num_classes, embed_dim)

        # Конкатенировать шум и эмбеддинги меток
        self.net = nn.Sequential(
            nn.Linear(noise_dim + embed_dim, features_g * 8 * 4 * 4),
            nn.BatchNorm1d(features_g * 8 * 4 * 4),
            nn.ReLU(),
            # Затем примените транспонированные свёртки (аналогично DCGAN)
            # ...
        )

    def forward(self, noise, labels):
        # Встроить метки классов в ту же размерность, что и шум
        label_embedding = self.label_emb(labels)
        # Конкатенировать шум и эмбеддинги меток
        x = torch.cat([noise, label_embedding], dim=1)
        return self.net(x)


class ConditionalDiscriminator(nn.Module):
    """Условный дискриминатор: принимает и изображения, и метки классов"""
    def __init__(self, img_channels, num_classes, embed_dim, features_d=64):
        super().__init__()
        self.label_emb = nn.Embedding(num_classes, embed_dim)

        # Конкатенировать эмбеддинги изображений и меток
        self.net = nn.Sequential(
            nn.Conv2d(img_channels + embed_dim, features_d, 4, 2, 1),
            nn.LeakyReLU(0.2),
            # ...
        )

    def forward(self, img, labels):
        # Преобразуйте эмбеддинги меток к тому же пространственному размеру, что и изображения
        label_embedding = self.label_emb(labels)
        # Отрегулируйте форму для конкатенации
        label_embedding = label_embedding.unsqueeze(2).unsqueeze(3)
        label_embedding = label_embedding.expand(-1, -1, img.size(2), img.size(3))
        # Объедините изображение и метку
        x = torch.cat([img, label_embedding], dim=1)
        return self.net(x)

6. Метрики оценки GAN

6.1 Общие метрики оценки

Метрика Описание Преимущества Недостатки
Inception Score (IS) Используйте Inception v3 для оценки качества и разнообразия сгенерированных изображений Просто вычисляется, имеет некоторую корреляцию с человеческим суждением Не оценивает переобучение и не может обнаружить коллапс мод
Расстояние Фреше Inception (FID) Вычисляет расстояние между реальными и сгенерированными изображениями в пространстве признаков Более чувствительна к шуму и коллапсу мод Требует большого количества выборок и медленно вычисляется
Человеческая оценка Люди оценивают качество сгенерированных изображений Наиболее точно отражает человеческое восприятие Субъективна и требует много времени

6.2 Реализация вычисления FID

Пример

import numpy as np
from scipy import linalg

def calculate_fid(real_activations, fake_activations):
    """
Вычислить Fréchet Inception Distance
real_activations: векторы признаков реальных изображений (N, dim)
fake_activations: векторы признаков сгенерированных изображений (N, dim)
    """

    # Вычислить среднее и ковариацию
    mu1, sigma1 = real_activations.mean(axis=0), np.cov(real_activations, rowvar=False)
    mu2, sigma2 = fake_activations.mean(axis=0), np.cov(fake_activations, rowvar=False)

    # Вычислить FID
    diff = mu1 - mu2
    # Вычислить сумму ковариационных матриц
    covmean, _ = linalg.sqrtm(sigma1.dot(sigma2), disp=False)

    # Избегаем комплексных чисел
    if np.iscomplexobj(covmean):
        covmean = covmean.real

    fid = diff.dot(diff) + np.trace(sigma1 + sigma2 - 2 * covmean)
    return fid


# Упрощённый пример: используйте случайные данные
np.random.seed(42)
real_acts = np.random.randn(1000, 2048)  # размерность выхода Inception v3
fake_acts = np.random.randn(1000, 2048)

fid_score = calculate_fid(real_acts, fake_acts)
print(f"Оценка FID: {fid_score:.2f}")
# Чем ниже FID, тем лучше; обычно значение ниже 50 указывает на хорошее качество генерации

7. Распространённые варианты GAN

С момента разработки GAN было создано множество вариантов, подходящих для различных сценариев применения:

Модель Полное имя Особенности Применимые сценарии
DCGAN Глубокий свёрточный GAN Использует свёрточные сети, генерирует изображения высокого качества Генерация изображений
WGAN GAN Вассерштейна Использует расстояние Вассерштейна, более стабильное обучение Стабильное обучение
WGAN-GP WGAN с градиентным штрафом Градиентный штраф заменяет отсечение весов Стабильное обучение
CGAN Условный GAN Добавляет условную информацию и управляемую генерацию Условная генерация
CycleGAN Циклически согласованный GAN Перевод изображение-в-изображение без учителя Перенос стиля
StyleGAN GAN на основе стиля Управление стилем, высококачественная генерация лиц Генерация лиц
BigGAN Big GAN Крупномасштабная генерация изображений высокого качества Изображения высокого разрешения
ProGAN Прогрессивно растущий GAN Постепенное увеличение разрешения Генерация высокого разрешения
Другие расширения