Урок 40. GAN — генеративно-состязательные сети
Зачем тебе этот урок
В Уроке 39 автоэнкодер сжимал картинку в латентный вектор и восстанавливал обратно. Работало — но реконструкции выходили мутными, как снятыми не в фокусе. Это не баг реализации, а свойство подхода. Здесь ты разберёшь способ, который убирает размытие принципиально.
К концу урока ты:
- Поймёшь, как две сети, обучаясь друг против друга, дают резкие картинки.
- Увидишь, почему loss по пикселям всегда тянет в размытость.
- Разберёшь цикл обучения GAN по шагам и напишешь его сам.
- Узнаешь, почему GAN капризны, что с этим делают и где они живы сегодня.
1. Идея состязания
Вместо одной сети — две, с противоположными целями.
| Сеть | Вход | Выход | Чего хочет |
|---|---|---|---|
| Генератор (G) | случайный вектор z | картинка | чтобы дискриминатор поверил в подделку |
| Дискриминатор (D) | картинка | число от 0 до 1 | отличать настоящее от поддельного |
Классическая аналогия: фальшивомонетчик и эксперт. Каждый становится лучше только потому, что противник стал лучше. Дальше тянуть её не надо — важна механика.
Деталь, которая обычно проскакивает мимо: генератор никогда не видит настоящих картинок. Он видит только оценку дискриминатора — одно число. Всё знание о данных доходит до него через градиент, текущий сквозь дискриминатор.
2. Почему выходит резче, чем у автоэнкодера
Автоэнкодер минимизирует MSE по пикселям. Представь, что модель не уверена, где проходит край цифры — на два пикселя левее или правее. Что минимизирует среднеквадратичную ошибку? Не один из вариантов, а их среднее. А среднее чётких линий, сдвинутых относительно друг друга, — размытая полоса. Loss по пикселям вознаграждает осторожность.
У GAN loss спрашивает другое — «убедительно ли это для дискриминатора». Размытая цифра неубедительна: размытие это самый заметный признак подделки, дискриминатор ловит его в первые же эпохи. Генератор обязан стать резким. Обратная сторона: раз задача звучит «сделай убедительно», а не «покрой все данные», разнообразия от него никто не требует (см. раздел 5).
3. Цикл обучения по шагам
Одна итерация — два отдельных шага оптимизации. Порядок и метки тут важнее всего остального.
Шаг 1 — обновляем дискриминатор
- Батч настоящих картинок, метка 1. Батч шума z → генератор → подделки, метка 0.
- Дискриминатор оценивает обе группы, считаем binary cross-entropy.
- Backward и шаг оптимизатора D. Веса генератора не трогаем — подделки надо отсоединить от графа (в PyTorch это
.detach()).
Шаг 2 — обновляем генератор
- Новый батч шума z → генератор → подделки, прогоняем их через дискриминатор.
- Метка — 1. Да, для тех же подделок: генератор хочет, чтобы D назвал их настоящими.
- 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 ищет равновесие двух функций, тянущих в разные стороны. Отсюда все проблемы.
- Mode collapse. Генератор находит одну картинку или узкое семейство, надёжно обманывающее дискриминатор, и печатает только её. Loss выглядит прилично, а на сетке — двадцать почти одинаковых пятёрок.
- Дисбаланс сил. Дискриминатор слишком силён — различает идеально,
D(G(z))прижимается к нулю, градиента для генератора нет, обучение встаёт. Слишком слаб — его оценка бессмысленна. - Осцилляции. Обе loss скачут, картинки то улучшаются, то разваливаются. Монотонного «всё лучше» не бывает.
- Нет простой метрики. Loss дискриминатора говорит не «насколько хороши картинки», а «кто сейчас впереди». Значение около 0.69 (примерно log 2) на одно слагаемое означает, что он гадает наугад. В коде ниже слагаемых два (настоящие и поддельные), поэтому в равновесии увидишь примерно 1.39 — формально равновесие, но качества это не гарантирует.
Практический вывод: главная метрика GAN — твои глаза, то есть сетка образцов каждые несколько эпох.
6. Приёмы стабилизации
Ничего из этого не гарантирует успех, но без этого шансы ниже.
- Соглашения DCGAN. Генератор из слоёв ConvTranspose, дискриминатор из Conv, batch normalization в обоих (кроме выхода G и входа D), ReLU в генераторе, LeakyReLU в дискриминаторе, tanh на выходе G — и данные в том же диапазоне.
- Label smoothing. Вместо метки 1 для настоящих картинок — 0.9. Дискриминатор перестаёт быть абсолютно уверенным, градиент для генератора остаётся живым.
- Разные learning rate. Дискриминатор часто учится быстрее, чем нужно — ему ставят меньший шаг. Adam с пониженной первой бетой (около 0.5) — тоже из DCGAN.
- Следи за обеими loss сразу. Loss дискриминатора почти в нуле, а loss генератора растёт → дискриминатор победил. Обе болтаются рядом → рабочее состояние.
- Фиксированный набор z. Сгенерируй 64 вектора один раз и рисуй на каждой контрольной точке именно их: так видно эволюцию, а не случайный разброс.
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. Варианты и что они дают
- Conditional GAN. Метку класса подаём и генератору, и дискриминатору. Можно попросить «нарисуй семёрку». Дискриминатор проверяет уже две вещи: настоящая ли картинка и та ли метка.
- pix2pix. Картинка в картинку: набросок → фото, схема → спутниковый снимок, чёрно-белое → цветное. Требует парных данных — один объект в двух видах.
- CycleGAN. То же, но без пар: есть набор лошадей и отдельно набор зебр. Два генератора в обе стороны плюс требование цикла (лошадь → зебра → лошадь должна вернуться к исходной). Снимает главное ограничение pix2pix.
- StyleGAN. Работы про лица высокого качества. Идея — латентный вектор не входит одним куском в начало сети, а управляет слоями на разной глубине. Признаки разных масштабов (поза, форма лица, цвет волос, текстура) разделяются и управляются отдельно.
9. Где GAN сегодня, а где диффузия
Честно: массовая генерация изображений сейчас — диффузионные модели, не GAN. Диффузия проще в обучении (одна сеть, одна задача — предсказать шум), стабильнее и лучше держит разнообразие. Но своя ниша у GAN осталась:
- Скорость. Генерация — одна прогонка сети, диффузии нужны десятки шагов. Где важна задержка (реалтайм, слабое железо, видео покадрово) — это решающий аргумент.
- Суперразрешение и реставрация. Апскейл, где надо дорисовать правдоподобную текстуру, а не усреднённую кашу. Состязательный loss работает тут по назначению.
- Аугментация данных. Когда реальных примеров мало и они дороги (медицина, редкие дефекты на производстве), синтетика помогает обучить основную модель.
- Часть чужой архитектуры. Состязательную компоненту встраивают кусочком в другую систему — например, в декодер, чтобы убрать размытие.
10. Этика: дипфейки и ответственность
Технология генерации лиц и голоса — ровно та же, что и технология подделки. Коротко:
- Юридический риск реальный. Чужое лицо или голос без согласия — во многих юрисдикциях основание для иска, независимо от намерений.
- Маркируй сгенерированное. Подпись, метаданные, водяной знак. Требования к маркировке синтетики уже вводятся в разных странах.
- Учебное демо и продукт — разное. GAN на MNIST безопасен. Модель на лицах конкретных людей — уже вопрос согласия и хранения персональных данных.
- Обнаружение подделок — тоже задача. Детекторы синтетики устроены как дискриминатор.
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 минут)
- Скопируй код в файл и запусти на 5 эпохах: датасет должен скачаться, цикл — пойти. Картинки будут мусором, это нормально.
- Догони до 30 эпох. Открой
samples_00.png…samples_29.pngподряд и найди эпоху, где цифры стали узнаваемыми. - Убери label smoothing — замени 0.9 на 1.0. Сравни поведение обеих loss.
- Сломай специально: убери
.detach(). Запомни, как выглядит эта поломка. - Сделай интерполяцию: два вектора z, 10 промежуточных по формуле из раздела 4. Плавно или скачками?
- Поставь дискриминатору learning rate в 5 раз больше — увидишь «дискриминатор победил».
- Поищи mode collapse: сколько разных цифр в сетке 8×8 на последней эпохе — десять или три-четыре?
- Запиши в
progress.md: на какой эпохе цифры стали узнаваемыми, какая пара loss дала лучшие картинки и что произошло при поломке из шага 4.
14. Проверь себя
1. Почему GAN даёт более резкие картинки, чем автоэнкодер?
Автоэнкодер усредняет разницу пикселей: при неуверенности выгодно выдать среднее вариантов, а оно размыто. GAN спрашивает «убедил ли дискриминатор», а размытие тот ловит первым делом.
2. Какая метка подаётся на шаге генератора и почему?
Метка «настоящая» (1) для подделок: цель генератора — чтобы дискриминатор ошибся.
3. Что такое mode collapse и как его заметить?
Генератор печатает одну картинку или узкое семейство, обманывающее дискриминатор. По loss не виден — смотри сетку образцов, там будут повторы.
4. Зачем detach на шаге дискриминатора?
Чтобы градиент не протёк в генератор: на этом шаге он источник данных, а не обучаемая часть.
5. Когда сегодня выбирают GAN, а не диффузию?
Когда критична скорость — одна прогонка вместо десятков шагов. И в узких задачах: суперразрешение, реставрация, аугментация.
15. Что должно остаться в голове
- GAN — две сети: генератор делает картинки из шума, дискриминатор отличает настоящее от подделки.
- Loss генератора — не разница пикселей, а «поверил ли дискриминатор». Отсюда резкость.
- Итерация = два шага: дискриминатор (настоящие → 1, поддельные → 0), затем генератор (поддельные → 1). Смена метки и есть весь механизм состязания.
- На шаге дискриминатора обязателен detach, иначе градиент испортит генератор.
- z — случайный вектор, генератор — гладкое отображение шума в картинки. Отсюда интерполяция и арифметика признаков.
- Главные болезни: mode collapse, дисбаланс сил, осцилляции, отсутствие честной метрики.
- Стабилизация: соглашения DCGAN, label smoothing, разные learning rate, обе loss под наблюдением, фиксированный z.
- Варианты: conditional GAN — по условию, pix2pix — парные данные, CycleGAN — непарные, StyleGAN — контроль признаков. Массовая генерация сегодня — диффузия, GAN остались там, где нужна скорость.