← Артефакты
Фаза 3. Практик·Урок 40

Урок 40. GAN — генеративно-состязательные сети

Цель: понять, откуда берётся резкость в генерации, и обучить свой GAN на MNIST Время: ~70 минут Источник: AI-For-Beginners + статья Goodfellow et al. «Generative Adversarial Nets»

Зачем тебе этот урок

В Уроке 39 автоэнкодер сжимал картинку в латентный вектор и восстанавливал обратно. Работало — но реконструкции выходили мутными, как снятыми не в фокусе. Это не баг реализации, а свойство подхода. Здесь ты разберёшь способ, который убирает размытие принципиально.

К концу урока ты:

1. Идея состязания

Вместо одной сети — две, с противоположными целями.

СетьВходВыходЧего хочет
Генератор (G)случайный вектор zкартинкачтобы дискриминатор поверил в подделку
Дискриминатор (D)картинкачисло от 0 до 1отличать настоящее от поддельного

Классическая аналогия: фальшивомонетчик и эксперт. Каждый становится лучше только потому, что противник стал лучше. Дальше тянуть её не надо — важна механика.

Деталь, которая обычно проскакивает мимо: генератор никогда не видит настоящих картинок. Он видит только оценку дискриминатора — одно число. Всё знание о данных доходит до него через градиент, текущий сквозь дискриминатор.

2. Почему выходит резче, чем у автоэнкодера

Автоэнкодер минимизирует MSE по пикселям. Представь, что модель не уверена, где проходит край цифры — на два пикселя левее или правее. Что минимизирует среднеквадратичную ошибку? Не один из вариантов, а их среднее. А среднее чётких линий, сдвинутых относительно друг друга, — размытая полоса. Loss по пикселям вознаграждает осторожность.

У GAN loss спрашивает другое — «убедительно ли это для дискриминатора». Размытая цифра неубедительна: размытие это самый заметный признак подделки, дискриминатор ловит его в первые же эпохи. Генератор обязан стать резким. Обратная сторона: раз задача звучит «сделай убедительно», а не «покрой все данные», разнообразия от него никто не требует (см. раздел 5).

3. Цикл обучения по шагам

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

Шаг 1 — обновляем дискриминатор

  1. Батч настоящих картинок, метка 1. Батч шума z → генератор → подделки, метка 0.
  2. Дискриминатор оценивает обе группы, считаем binary cross-entropy.
  3. Backward и шаг оптимизатора D. Веса генератора не трогаем — подделки надо отсоединить от графа (в PyTorch это .detach()).

Шаг 2 — обновляем генератор

  1. Новый батч шума z → генератор → подделки, прогоняем их через дискриминатор.
  2. Метка — 1. Да, для тех же подделок: генератор хочет, чтобы D назвал их настоящими.
  3. Backward: градиент течёт от loss через дискриминатор в генератор. Обновляем только веса G.

Смена метки между шагами — и есть весь механизм состязания: одна и та же картинка на первом шаге фальшивка, на втором — цель.

Тонкость из статьи: формально генератор минимизирует log(1 − D(G(z))), но пока G плох, градиент этой функции почти нулевой и обучение не стартует. Поэтому на практике он максимизирует log D(G(z)), что в коде и выглядит как «подставь метку 1». Это non-saturating loss.

4. Латентный вектор z

z — случайный вектор из нормального распределения, длиной 64–128 чисел. Генератор разворачивает точку этого крохотного пространства в целое изображение.

Отличие от Урока 39 принципиальное: в автоэнкодере латентный код приходил из энкодера, то есть из настоящей картинки. Здесь энкодера нет вообще, z берётся из генератора случайных чисел. Обучение заставляет G натянуть распределение шума на распределение реальных данных.

Интерполяция. Возьми два вектора z₁ и z₂ и пройди по прямой между ними: z = (1 − t)·z₁ + t·z₂. Если сеть обучилась, переход плавный. Скачки и мусор в середине означают, что генератор выучил отдельные точки, а не пространство.

Арифметика. Усредни z нескольких образцов с каким-то признаком, вычти среднее без него, разницу прибавь к новому z — признак переносится. На лицах это классическая демонстрация: очки, улыбка, поворот головы.

Оба эффекта никто не программировал: генератор — гладкая функция, поэтому близкие z дают близкие картинки.

5. Почему GAN трудно обучать

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

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

6. Приёмы стабилизации

Ничего из этого не гарантирует успех, но без этого шансы ниже.

7. Полный код: GAN на MNIST

Полносвязный GAN — самый простой вариант: картинок уровня современных моделей не даст, но за минуты покажет всю механику. Нужен PyTorch (Урок 35); свёрточную версию соберёшь на свёртках из Урока 37.

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

device = "cuda" if torch.cuda.is_available() else "cpu"
LATENT, BATCH = 64, 128

# данные в диапазон [-1, 1] — как у tanh на выходе генератора
tf = transforms.Compose([transforms.ToTensor(),
                         transforms.Normalize((0.5,), (0.5,))])
data = datasets.MNIST("./data", train=True, download=True, transform=tf)
loader = DataLoader(data, batch_size=BATCH, shuffle=True, drop_last=True)

G = nn.Sequential(
    nn.Linear(LATENT, 256), nn.BatchNorm1d(256), nn.ReLU(),
    nn.Linear(256, 512), nn.BatchNorm1d(512), nn.ReLU(),
    nn.Linear(512, 28 * 28), nn.Tanh(),
).to(device)

D = nn.Sequential(
    nn.Linear(28 * 28, 512), nn.LeakyReLU(0.2),
    nn.Linear(512, 256), nn.LeakyReLU(0.2),
    nn.Linear(256, 1),          # логит, Sigmoid внутри loss
).to(device)

loss_fn = nn.BCEWithLogitsLoss()
opt_D = torch.optim.Adam(D.parameters(), lr=2e-4, betas=(0.5, 0.999))
opt_G = torch.optim.Adam(G.parameters(), lr=2e-4, betas=(0.5, 0.999))

fixed_z = torch.randn(64, LATENT, device=device)          # для мониторинга
real_label = torch.full((BATCH, 1), 0.9, device=device)   # label smoothing
fake_label = torch.zeros(BATCH, 1, device=device)
want_real = torch.ones(BATCH, 1, device=device)           # цель генератора

for epoch in range(30):
    for real, _ in loader:
        real = real.view(BATCH, -1).to(device)

        # --- шаг 1: дискриминатор ---
        fake = G(torch.randn(BATCH, LATENT, device=device))
        loss_D = loss_fn(D(real), real_label) \
               + loss_fn(D(fake.detach()), fake_label)   # detach: G не трогаем
        opt_D.zero_grad()
        loss_D.backward()
        opt_D.step()

        # --- шаг 2: генератор, метка 1 для подделок ---
        fake = G(torch.randn(BATCH, LATENT, device=device))
        loss_G = loss_fn(D(fake), want_real)
        opt_G.zero_grad()
        loss_G.backward()
        opt_G.step()

    print(f"epoch {epoch:02d}  D {loss_D.item():.3f}  G {loss_G.item():.3f}")
    G.eval()
    with torch.no_grad():
        img = G(fixed_z).view(-1, 1, 28, 28) * 0.5 + 0.5   # обратно в [0, 1]
        save_image(img, f"samples_{epoch:02d}.png", nrow=8)
    G.train()

Первые эпохи выдадут шум — так и должно быть. Смотри samples_XX.png подряд: цифры проступают из серой каши постепенно.

8. Варианты и что они дают

9. Где GAN сегодня, а где диффузия

Честно: массовая генерация изображений сейчас — диффузионные модели, не GAN. Диффузия проще в обучении (одна сеть, одна задача — предсказать шум), стабильнее и лучше держит разнообразие. Но своя ниша у GAN осталась:

10. Этика: дипфейки и ответственность

Технология генерации лиц и голоса — ровно та же, что и технология подделки. Коротко:

11. Тонкости и подводные камни

  • Забыл detach на шаге дискриминатора. Градиент протечёт в генератор и потянет его в обратную сторону. Проявляется как «почему-то ничего не учится». Самая частая ошибка.
  • Диапазон данных не совпадает с выходом генератора. tanh даёт значения от −1 до 1. Если картинки лежат от 0 до 1, дискриминатор отличает подделку по диапазону, а не по содержанию.
  • BatchNorm в дискриминаторе на смешанных батчах. Если гнать настоящие и поддельные одним батчем, статистика батча становится подсказкой. Прогоняй отдельными вызовами.
  • Sigmoid плюс BCELoss вместо BCEWithLogitsLoss. Второе устойчивее численно, первое при насыщении даёт NaN и обучение умирает молча.
  • Оценка по одной картинке. Смотри сетку хотя бы 8×8. И не спеши с выводом «не работает» — первые эпохи GAN всегда выдают мусор.

12. Глоссарий

Generator

Сеть, превращающая случайный вектор z в объект. Настоящих данных не видит.

Discriminator

Классификатор «настоящая или поддельная». Источник loss для генератора.

Latent vector z

Случайный вектор на входе генератора. В его пространстве работают интерполяция и арифметика признаков.

Mode collapse

Генератор схлопнулся на узком наборе образцов, обманывающих дискриминатор. По loss не заметен, виден на сетке.

Non-saturating loss

Генератор максимизирует log D(G(z)) вместо минимизации log(1 − D(G(z))): в коде это метка 1 для подделок.

DCGAN

Архитектурные соглашения для свёрточных GAN: Conv и ConvTranspose, batch normalization, LeakyReLU в дискриминаторе, tanh на выходе.

13. Практика (60 минут)

  1. Скопируй код в файл и запусти на 5 эпохах: датасет должен скачаться, цикл — пойти. Картинки будут мусором, это нормально.
  2. Догони до 30 эпох. Открой samples_00.png … samples_29.png подряд и найди эпоху, где цифры стали узнаваемыми.
  3. Убери label smoothing — замени 0.9 на 1.0. Сравни поведение обеих loss.
  4. Сломай специально: убери .detach(). Запомни, как выглядит эта поломка.
  5. Сделай интерполяцию: два вектора z, 10 промежуточных по формуле из раздела 4. Плавно или скачками?
  6. Поставь дискриминатору learning rate в 5 раз больше — увидишь «дискриминатор победил».
  7. Поищи mode collapse: сколько разных цифр в сетке 8×8 на последней эпохе — десять или три-четыре?
  8. Запиши в progress.md: на какой эпохе цифры стали узнаваемыми, какая пара loss дала лучшие картинки и что произошло при поломке из шага 4.

14. Проверь себя

1. Почему GAN даёт более резкие картинки, чем автоэнкодер?
Автоэнкодер усредняет разницу пикселей: при неуверенности выгодно выдать среднее вариантов, а оно размыто. GAN спрашивает «убедил ли дискриминатор», а размытие тот ловит первым делом.

2. Какая метка подаётся на шаге генератора и почему?
Метка «настоящая» (1) для подделок: цель генератора — чтобы дискриминатор ошибся.

3. Что такое mode collapse и как его заметить?
Генератор печатает одну картинку или узкое семейство, обманывающее дискриминатор. По loss не виден — смотри сетку образцов, там будут повторы.

4. Зачем detach на шаге дискриминатора?
Чтобы градиент не протёк в генератор: на этом шаге он источник данных, а не обучаемая часть.

5. Когда сегодня выбирают GAN, а не диффузию?
Когда критична скорость — одна прогонка вместо десятков шагов. И в узких задачах: суперразрешение, реставрация, аугментация.

15. Что должно остаться в голове

  1. GAN — две сети: генератор делает картинки из шума, дискриминатор отличает настоящее от подделки.
  2. Loss генератора — не разница пикселей, а «поверил ли дискриминатор». Отсюда резкость.
  3. Итерация = два шага: дискриминатор (настоящие → 1, поддельные → 0), затем генератор (поддельные → 1). Смена метки и есть весь механизм состязания.
  4. На шаге дискриминатора обязателен detach, иначе градиент испортит генератор.
  5. z — случайный вектор, генератор — гладкое отображение шума в картинки. Отсюда интерполяция и арифметика признаков.
  6. Главные болезни: mode collapse, дисбаланс сил, осцилляции, отсутствие честной метрики.
  7. Стабилизация: соглашения DCGAN, label smoothing, разные learning rate, обе loss под наблюдением, фиксированный z.
  8. Варианты: conditional GAN — по условию, pix2pix — парные данные, CycleGAN — непарные, StyleGAN — контроль признаков. Массовая генерация сегодня — диффузия, GAN остались там, где нужна скорость.
📌 Закрепление: обучение GAN — это не «минимизируй одно число», а поиск равновесия двух противников. Пока обе loss болтаются рядом и картинки медленно улучшаются — всё идёт правильно, даже если график выглядит некрасиво.