Перейти к содержимому
Ступень 99.1 Мультимодальные модели

Автоэнкодеры, VAE и дискретные токены изображений

Как нейросеть сжимает картинку через узкое горлышко: автоэнкодер и его связь с PCA, VAE с трюком репараметризации и ELBO, GAN кратко и VQ-VAE, который превращает изображение в токены для мультимодальных LLM.

УРОКstage-9/9-1-multimodal/03-autoencoders-vae-vq.mdx2 чУрок 3 из 5
После урока вы сможете
  • Объяснить, что такое автоэнкодер и латентное пространство, и убедиться на практике, что линейный автоэнкодер находит то же подпространство, что PCA
  • Записать loss VAE как «реконструкция + KL», прочитать его словами и реализовать трюк репараметризации с проверкой градиента KL
  • Объяснить, как VQ-VAE превращает картинку в последовательность целых чисел, и посчитать, сколько токенов занимает картинка у Janus и Emu3
  • Узнавать VAE, VQ-токенизаторы и GAN-лосс внутри современных систем (Stable Diffusion, FLUX, Janus, Emu3, аудиокодеки)

В прошлом уроке VAE появился одной строкой: «сжимает картинку 512×512×3 в латент 64×64×4». А в уроке о VLM картинка попадала в LLM как набор непрерывных векторов. Оба места скрывают два вопроса, на которых держится вся мультимодальность:

  1. Как выучить компактное представление, в котором генерировать дёшево? Латентная диффузия, flow matching из следующего урока, видео-модели с 3D-VAE — все работают не с пикселями, а с таким сжатым латентом.
  2. Как превратить картинку в последовательность целых чисел из фиксированного словаря? Тогда один GPT-подобный трансформер сможет не только читать, но и писать изображения токен за токеном — как текст. Так устроены Janus (DeepSeek) и Emu3 (BAAI). Тот же приём превращает в токены звук (урок 9.1.5).

Оба ответа дают автоэнкодеры. Пойдём по ступенькам: обычный автоэнкодер и его связь с PCA → VAE, который умеет генерировать → GAN в двух словах → VQ-VAE и визуальные токены.

flowchart LR
X["Картинка"] --> E["Энкодер"]
E --> L1["непрерывный латент<br/>(VAE)"]
E --> L2["сетка токенов<br/>(VQ-VAE)"]
L1 --> G1["диффузия / flow matching<br/>SD, FLUX, Wan"]
L2 --> G2["трансформер: следующий токен<br/>Janus, Emu3"]
G1 --> D["Декодер → картинка"]
G2 --> D
Карта урока: энкодер сжимает, генератор работает в сжатом пространстве, декодер возвращает пиксели

Автоэнкодеравтоэнкодер (автокодировщик)EN autoencoderэнкодер сжимает вход в короткий код, декодер восстанавливает вход; учится без разметки — это две нейросети, соединённые «узким горлышком». Энкодер ff сжимает вход xx из dd чисел в код z=f(x)z = f(x) из kk чисел, где kk намного меньше dd. Декодер gg пытается восстановить вход: x^=g(z)\hat x = g(z). Учим обе сети вместе:

L=1n∑i=1n∥xi−g(f(xi))∥2\mathcal{L} = \frac{1}{n} \sum_{i=1}^{n} \lVert x_i - g(f(x_i)) \rVert^2

Словами: средняя по примерам квадратичная ошибка между картинкой и её восстановлением (MSE из урока 2.2.1). Разметка не нужна — картинка сама себе ответ. Это то же самообучениесамообучение, самоконтролируемое обучениеEN self-supervised learningМетки берутся из самих данных (например, следующий токен текста). Так идёт предобучение LLM., что и у DDPM.

Представьте, что вы описываете картинку другу по телефону, но можете назвать только 32 числа, а он по ним рисует. Придётся договориться, что значат числа: «наклон», «толщина линии», «есть ли петля сверху». Шум и точные значения пикселей через горлышко не пролезут — и не нужно.

Пространство кодов zz называют латентнымлатентное пространствоEN latent spaceпространство кодов автоэнкодера; в китайских текстах также «скрытое пространство» («скрытым»): этих чисел нет в данных, сеть придумала их сама. Масштаб сжатия: цифра MNIST — 28⋅28=78428 \cdot 28 = 784 пикселя, код из 32 чисел — сжатие в 24.5 раза. Латент Stable Diffusion — 786 432→16 384786\,432 \to 16\,384 чисел, в 48 раз.

Уберём из сетей все нелинейности: z=Wenc xz = W_{\text{enc}}\,x, x^=Wdec z\hat x = W_{\text{dec}}\,z. Тогда x^=WdecWenc x\hat x = W_{\text{dec}} W_{\text{enc}}\,x, и произведение матриц WdecWencW_{\text{dec}} W_{\text{enc}} (размер d×dd \times d) имеет ранг не больше kk: всё проходит через kk чисел. Вспомним два факта:

  • из урока 1.1.3: лучшее приближение матрицы матрицей ранга kk даёт обрезанное SVD;
  • из урока 2.3.2: PCA — это SVD центрированных данных, а ошибка реконструкции равна сумме дисперсий выброшенных направлений.

Отсюда вывод (Балди и Хорник, 1989): оптимальный линейный автоэнкодер восстанавливает данные ровно так же хорошо, как PCA, а столбцы его декодера натягивают то же kk-мерное подпространство — плоскость первых kk главных компонент.

Но есть тонкость: векторы будут другими. Для любой обратимой матрицы AA размера k×kk \times k пара (WdecA−1,  A Wenc)(W_{\text{dec}}A^{-1},\; A\,W_{\text{enc}}) даёт тот же x^\hat x — значит, внутри найденной плоскости оси латента могут быть повёрнуты, растянуты и неортогональны. Поэтому сравнивают плоскости, а не векторы: считают главные углы между подпространствами. Ортонормируем оба базиса (QR-разложение), берём сингулярные числа матрицы Qa⊤QbQ_a^\top Q_b — это косинусы углов. Все углы 0° — плоскости совпадают.

Проверим на PyTorch (autograd посчитает градиенты; в задании ниже вы выведете их сами):

import torch
torch.manual_seed(0)
# 8-мерные данные, в которых «живут» в основном два направления (std 3 и 2), остальные — слабые
std = torch.tensor([3.0, 2.0, 1.0, 0.7, 0.5, 0.4, 0.3, 0.2])
Q, _ = torch.linalg.qr(torch.randn(8, 8)) # случайный поворот, чтобы оси не совпадали с x, y, ...
X = (torch.randn(1000, 8) * std) @ Q.T
X = X - X.mean(0) # центрируем, как перед PCA
enc = torch.nn.Linear(8, 2, bias=False) # 8 → 2: узкое горлышко
dec = torch.nn.Linear(2, 8, bias=False) # 2 → 8
opt = torch.optim.SGD([*enc.parameters(), *dec.parameters()], lr=0.05)
for step in range(3001):
loss = ((dec(enc(X)) - X) ** 2).sum(1).mean() # средняя ошибка реконструкции
opt.zero_grad(); loss.backward(); opt.step()
if step in (0, 100, 3000):
print(f"шаг {step:4d}: loss {loss.item():.3f}")
# PCA через SVD (урок 2.3.2): ошибка = сумма дисперсий выброшенных направлений
U, S, Vt = torch.linalg.svd(X, full_matrices=False)
P = Vt[:2].T # (8, 2) — две главные компоненты
print(f"ошибка PCA с k=2: {((X - X @ P @ P.T) ** 2).sum(1).mean():.3f}")
# Сравниваем ПЛОСКОСТИ, а не векторы: углы между подпространствами
W = dec.weight.detach() # столбцы W — «оси» латента в 8D
Qw, _ = torch.linalg.qr(W)
cos = torch.linalg.svdvals(Qw.T @ P).clamp(max=1)
print("углы между плоскостями, °:", [round(a, 2) for a in torch.rad2deg(torch.arccos(cos)).tolist()])
c = (W[:, 0] / W[:, 0].norm()) @ P[:, 0]
print(f"косинус между 1-й осью автоэнкодера и 1-й главной компонентой: {c.abs():.2f}")
шаг 0: loss 18.912
шаг 100: loss 2.016
шаг 3000: loss 2.013
ошибка PCA с k=2: 2.013
углы между плоскостями, °: [0.0, 0.02]
косинус между 1-й осью автоэнкодера и 1-й главной компонентой: 0.76

Ошибка совпала с PCA до третьего знака и близка к теоретической 12+0.72+0.52+0.42+0.32+0.22=2.031^2 + 0.7^2 + 0.5^2 + 0.4^2 + 0.3^2 + 0.2^2 = 2.03 — сумме дисперсий шести выброшенных направлений. Плоскость та же (углы ≈0°), а вот первая ось автоэнкодера с первой главной компонентой не совпадает (косинус 0.76, угол около 40°). Ваши числа на другой версии PyTorch могут немного отличаться.

Добавьте нелинейности и слои — и автоэнкодер станет «изогнутым PCA»: коды смогут лежать на кривых поверхностях. Цифры, лица, звуки как раз так и устроены.

Почему обычный автоэнкодер плохо генерирует

Заголовок раздела «Почему обычный автоэнкодер плохо генерирует»

Хочется взять случайный код zz, пропустить через декодер и получить новую картинку. Не выйдет: коды обучающих картинок занимают в латентном пространстве разрозненные «островки», а между ними — дыры, которых декодер не видел. Нужно, чтобы коды заполняли известное распределение без дыр, скажем N(0,I)\mathcal N(0, I): тогда сэмплируем zz оттуда и декодируем. Это и делает VAE.

Вариационный автоэнкодервариационный автокодировщик (VAE)EN variational autoencoder (VAE) (Кингма и Веллинг, 2013) меняет одну вещь: энкодер выдаёт не точку, а облачко. Для каждой координаты латента — два числа: центр μ\mu и разброс σ\sigma (на практике сеть выдаёт log⁡σ2\log\sigma^2: это любое вещественное число, а σ=elog⁡σ2/2\sigma = e^{\log\sigma^2/2} автоматически положительна). Код сэмплируется из нормального распределения z∼N(μ,σ2)z \sim \mathcal N(\mu, \sigma^2) (урок 1.3.1).

Что это даёт:

  • декодер вынужден восстанавливать картинку из слегка случайного кода — значит, соседние коды должны давать похожие картинки, и латент становится гладким;
  • дополнительный штраф тянет каждое облачко к N(0,1)\mathcal N(0, 1) — облачка разных картинок перекрываются и заполняют область вокруг нуля без дыр.
flowchart LR
X["x"] --> E["Энкодер"]
E --> M["μ"]
E --> S["log σ²"]
EP["ε ~ N(0, 1)"] --> Z
M --> Z["z = μ + σ·ε"]
S --> Z
Z --> D["Декодер"] --> XH["x̂"]
XH -.-> R["реконструкция"]
M -.-> K["KL к N(0, 1)"]
S -.-> K
VAE: энкодер выдаёт облачко (μ, σ), код сэмплируется, loss = реконструкция + KL
L(x)=Ez∼q(z∣x)[−log⁡p(x∣z)]⏟реконструкция+KL(q(z∣x) ∥ N(0,I))⏟регуляризация\mathcal{L}(x) = \underbrace{\mathbb{E}_{z \sim q(z \mid x)}\big[-\log p(x \mid z)\big]}_{\text{реконструкция}} + \underbrace{\mathrm{KL}\big(q(z \mid x)\,\Vert\,\mathcal N(0, I)\big)}_{\text{регуляризация}}

Читаем по частям:

  • q(z∣x)=N(μ(x),σ2(x))q(z \mid x) = \mathcal N(\mu(x), \sigma^2(x)) — облачко кодов, которое энкодер выдал для картинки xx;
  • p(x∣z)p(x \mid z) — насколько правдоподобна картинка xx по мнению декодера, получившего код zz; минус логарифм — это знакомый loss из урока 1.3.2. Если декодер считает пиксели гауссовыми, это MSE (с точностью до константы), если «монетками» с вероятностью яркости — бинарная cross-entropy;
  • E\mathbb{E} — среднее по кодам из облачка; на практике берут один сэмпл на картинку, и этого хватает;
  • KL\mathrm{KL} — расхождение из урока 1.3.3: там оно мерило «лишние биты» между двумя дискретными распределениями, здесь — насколько облачко отличается от стандартного нормального.

Для гауссиан KL считается в явном виде. Для одной координаты (по координатам латента KL складывается):

KL(N(μ,σ2) ∥ N(0,1))=12(μ2+σ2−1−ln⁡σ2)\mathrm{KL}\big(\mathcal N(\mu, \sigma^2)\,\Vert\,\mathcal N(0, 1)\big) = \tfrac12\left(\mu^2 + \sigma^2 - 1 - \ln\sigma^2\right)

Слагаемое μ2\mu^2 — штраф за сдвиг центра от нуля. Часть σ2−1−ln⁡σ2\sigma^2 - 1 - \ln\sigma^2 всегда ≥0\ge 0 и равна нулю только при σ=1\sigma = 1 — штраф за слишком узкое или слишком широкое облачко. Пример: μ=1\mu = 1, σ=0.5\sigma = 0.5 → 12(1+0.25−1−ln⁡0.25)=12(0.25+1.386)≈0.818\tfrac12(1 + 0.25 - 1 - \ln 0.25) = \tfrac12(0.25 + 1.386) \approx 0.818 ната. Проверим формулу сэмплированием — KL это среднее от log⁡q(z)−log⁡p(z)\log q(z) - \log p(z) по сэмплам из qq:

import numpy as np
mu, s = 1.0, 0.5
analytic = 0.5 * (mu**2 + s**2 - 1 - np.log(s**2))
z = mu + s * np.random.default_rng(0).normal(size=1_000_000) # сэмплы из q = N(1, 0.5²)
log_q = -0.5 * ((z - mu) / s) ** 2 - np.log(s) - 0.5 * np.log(2 * np.pi)
log_p = -0.5 * z**2 - 0.5 * np.log(2 * np.pi) # p = N(0, 1)
print(f"формула {analytic:.4f}, сэмплирование {np.mean(log_q - log_p):.4f}") # формула 0.8181, сэмплирование 0.8181

Два слагаемых тянут в разные стороны. Если оставить только KL, все облачка станут N(0,1)\mathcal N(0, 1), код перестанет нести информацию о картинке, и декодер будет рисовать «среднюю» картинку. Если только реконструкцию — сеть сожмёт облачка в точки (σ→0\sigma \to 0) и получится обычный автоэнкодер с дырами. Вес перед KL — ручка настройки (так называемый β-VAE). У автоэнкодера Stable Diffusion он крошечный: в открытом конфиге kl_weight: 0.000001. Там латент нужен для сжатия, а генерирует диффузия, поэтому от VAE требуется лишь немного гладкости и аккуратный масштаб. Масштаб потом ещё и подгоняют под шум N(0,1)\mathcal N(0, 1), с которым смешивает диффузия: в конфиге Stable Diffusion 1.x латент умножают на scale_factor: 0.18215, у FLUX в коде стоят множитель 0.3611 и сдвиг 0.1159. Вот почему латент VAE удобен для диффузии: он в десятки раз компактнее пикселей, гладкий и по разбросу близок к шуму.

Почему это называют ELBO

VAE — это метод максимального правдоподобия: хотим, чтобы log⁡p(x)\log p(x) для настоящих картинок был большим. Но p(x)=∫p(x∣z) p(z) dzp(x) = \int p(x \mid z)\,p(z)\,dz — интеграл по всем возможным кодам, его не посчитать. Зато верно тождество

log⁡p(x)=−L(x)+KL(q(z∣x) ∥ p(z∣x))  ≥  −L(x),\log p(x) = -\mathcal{L}(x) + \mathrm{KL}\big(q(z \mid x)\,\Vert\,p(z \mid x)\big) \;\ge\; -\mathcal{L}(x),

потому что KL неотрицательна. Значит, −L(x)-\mathcal{L}(x) — нижняя граница логарифма правдоподобия, по-английски Evidence Lower BOund, ELBOELBO (нижняя граница правдоподобия)EN evidence lower bound (ELBO)−loss VAE = реконструкция с минусом − KL; не больше log p(x). Минимизируя L\mathcal{L}, мы толкаем вверх нижнюю границу log⁡p(x)\log p(x). Зазор равен тому, насколько облачко энкодера q(z∣x)q(z \mid x) отличается от «настоящего» распределения кодов для этой картинки p(z∣x)p(z \mid x). Подробный вывод с картинками — глава 17 книги Принса.

Код zz получается «броском кубика» из N(μ,σ2)\mathcal N(\mu, \sigma^2). Как градиенту пройти через случайный выбор к μ\mu и σ\sigma? Трюк репараметризациитрюк репараметризацииEN reparameterization trickz = μ + σ·ε, ε ~ N(0, 1): случайность выносится во вход, и градиент проходит к μ и σ переписывает сэмплирование:

z=μ+σ⋅ε,ε∼N(0,1)z = \mu + \sigma \cdot \varepsilon, \qquad \varepsilon \sim \mathcal N(0, 1)

Случайность теперь живёт в отдельном входе ε\varepsilon, который не зависит от параметров, — как данные. А zz стал обычной дифференцируемой функцией: ∂z/∂μ=1\partial z / \partial \mu = 1, ∂z/∂σ=ε\partial z / \partial \sigma = \varepsilon. Пример: μ=0.5\mu = 0.5, σ=2\sigma = 2, выпало ε=0.3\varepsilon = 0.3 → z=1.1z = 1.1. Пусть дальше loss L=z2L = z^2, тогда dL/dz=2.2dL/dz = 2.2, и по цепному правилу (урок 1.2.2) dL/dμ=2.2⋅1=2.2dL/d\mu = 2.2 \cdot 1 = 2.2, dL/dσ=2.2⋅0.3=0.66dL/d\sigma = 2.2 \cdot 0.3 = 0.66.

Градиенты KL тоже простые. Обозначим s=log⁡σ2s = \log\sigma^2, тогда KL=12(μ2+es−1−s)\mathrm{KL} = \tfrac12(\mu^2 + e^s - 1 - s) и

∂ KL∂μ=μ,∂ KL∂s=12(es−1).\frac{\partial\,\mathrm{KL}}{\partial \mu} = \mu, \qquad \frac{\partial\,\mathrm{KL}}{\partial s} = \tfrac12\left(e^s - 1\right).

При μ=0\mu = 0, s=0s = 0 (то есть σ=1\sigma = 1) оба градиента равны нулю: облачко уже стандартное, толкать некуда. В задании ниже вы проверите эти формулы численно — так же, как проверяли backprop в уроке 3.1.2.

Соберём всё в один скрипт: энкодер выдаёт μ\mu и log⁡σ2\log\sigma^2 для 20 координат, репараметризация, декодер, loss = BCE + KL. Данные — те же файлы MNIST, что в уроке 3.3.2.

"""VAE на MNIST: обучение, сэмплирование и интерполяция в латенте."""
import gzip
import urllib.request
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
# ── Данные: те же файлы MNIST, что в уроке 3.3.2 ──────────────────────────────
URL, DATA = "https://ossci-datasets.s3.amazonaws.com/mnist/", Path("data/mnist")
def load_images(name):
DATA.mkdir(parents=True, exist_ok=True)
if not (DATA / name).exists():
urllib.request.urlretrieve(URL + name, DATA / name)
with gzip.open(DATA / name, "rb") as f:
raw = np.frombuffer(f.read(), dtype=np.uint8, offset=16) # 16 байт заголовка
return torch.tensor(raw.reshape(-1, 784), dtype=torch.float32) / 255.0 # пиксели в [0, 1]
train = load_images("train-images-idx3-ubyte.gz").to(device)
test = load_images("t10k-images-idx3-ubyte.gz").to(device)
# ── Модель ────────────────────────────────────────────────────────────────────
class VAE(nn.Module):
def __init__(self, latent=20, hidden=400):
super().__init__()
self.enc = nn.Linear(784, hidden)
self.mu = nn.Linear(hidden, latent) # энкодер выдаёт μ ...
self.log_var = nn.Linear(hidden, latent) # ... и log σ² для каждой координаты
self.dec = nn.Sequential(nn.Linear(latent, hidden), nn.ReLU(), nn.Linear(hidden, 784))
def encode(self, x):
h = F.relu(self.enc(x))
return self.mu(h), self.log_var(h)
def forward(self, x):
mu, log_var = self.encode(x)
eps = torch.randn_like(mu) # случайность — отдельный «вход»
z = mu + torch.exp(0.5 * log_var) * eps # трюк репараметризации
return self.dec(z), mu, log_var # dec выдаёт логиты пикселей
def vae_loss(logits, x, mu, log_var):
# реконструкция: каждый пиксель — «монетка» с вероятностью sigmoid(logit), отсюда BCE (урок 1.3.2)
rec = F.binary_cross_entropy_with_logits(logits, x, reduction="sum") / len(x)
kl = 0.5 * (mu ** 2 + log_var.exp() - 1 - log_var).sum() / len(x) # формула из урока
return rec, kl
model = VAE().to(device)
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
for epoch in range(1, 11):
perm = torch.randperm(len(train), device=device)
tot_rec = tot_kl = 0.0
for i in range(0, len(train), 128):
x = train[perm[i:i + 128]]
logits, mu, log_var = model(x)
rec, kl = vae_loss(logits, x, mu, log_var)
opt.zero_grad()
(rec + kl).backward()
opt.step()
tot_rec += rec.item() * len(x)
tot_kl += kl.item() * len(x)
if epoch in (1, 2, 5, 10):
print(f"эпоха {epoch:2d}: реконструкция {tot_rec / len(train):6.1f} KL {tot_kl / len(train):5.1f}"
f" (на картинку, в натах)")
# ── Что выучил латент ─────────────────────────────────────────────────────────
model.eval()
with torch.no_grad():
mu, log_var = model.encode(test)
kl_per_dim = 0.5 * (mu ** 2 + log_var.exp() - 1 - log_var).mean(0)
print("активных координат латента (KL > 0.1):", int((kl_per_dim > 0.1).sum()), "из 20")
# Генерация: z ~ N(0, I) → декодер. У обычного автоэнкодера так не получилось бы.
samples = torch.sigmoid(model.dec(torch.randn(16, 20, device=device)))
# Интерполяция между двумя тестовыми цифрами: идём по прямой в латенте
a, b = mu[0], mu[1] # в тестовом наборе это цифры 7 и 2
steps = torch.linspace(0, 1, 8, device=device)[:, None]
line = torch.sigmoid(model.dec((1 - steps) * a + steps * b))
def ascii_row(imgs, size=14):
"""Печатает картинки 28×28 рядом, уменьшив до size×size (средний пул 2×2)."""
small = imgs.view(-1, 1, 28, 28)
small = F.avg_pool2d(small, 28 // size).squeeze(1).cpu()
for r in range(size):
print(" ".join("".join(" .:-=+*#%@"[min(9, int(v * 10))] for v in img[r]) for img in small))
print("интерполяция 7 → 2:")
ascii_row(line)
try:
import matplotlib.pyplot as plt
fig, ax = plt.subplots(2, 8, figsize=(8, 2.2))
for i in range(8):
ax[0, i].imshow(line[i].view(28, 28).cpu(), cmap="gray")
ax[1, i].imshow(samples[i].view(28, 28).cpu(), cmap="gray")
for a_ in ax.flat:
a_.axis("off")
plt.savefig("vae_mnist.png", dpi=120)
print("картинка сохранена в vae_mnist.png")
except ImportError:
pass

Запуск: python vae_mnist.py. На одном ядре загруженного CPU десять эпох заняли около пяти минут, на GPU — секунды на эпоху. Вывод (последние знаки могут отличаться на другой версии PyTorch):

эпоха 1: реконструкция 149.8 KL 15.2 (на картинку, в натах)
эпоха 2: реконструкция 99.6 KL 22.1 (на картинку, в натах)
эпоха 5: реконструкция 85.2 KL 24.7 (на картинку, в натах)
эпоха 10: реконструкция 81.1 KL 25.2 (на картинку, в натах)
активных координат латента (KL > 0.1): 20 из 20
интерполяция 7 → 2:
. .. :: :-.
.::.. .=**=: -##*=. .#%#*: *@%#=
:::. --:.. :=-::.. -+=--=. .==:-+- :-::*+ .:..*# :. *%
=##%%%%= .=++*%%#- .:::=***: .. :==+. .=-- +- *= #+
..#+ ..#+ ..+- .:.-: :-.. -+ =* =#
.%. *. -. . .. .-. :*: -%- =%=
** =+ :- :: . .+= .** .##.
:@ :# -+ .:: . :..: :: ==.. :: **. :. ##: :.
%= %- .#- . :*: . :=.:=:..-:. .*-.:. :=-. .%*... -+- .@#.. =*:
=% +# +* ..++. .--+*=:--. =**#*+*+: +%#%%%%*. *@%@%@@*.
.%- .#- :*- :+- .-+=:... :=+=:.. :=+=-:. :=+==:
*# +* =+ ..
=: -. .
картинка сохранена в vae_mnist.png

Что здесь видно:

  • Реконструкция падает, KL растёт и выходит на плато около 25 нат (≈36 бит) на картинку. Это и есть «пропускная способность» кода: столько информации о цифре проходит через облачка. Сеть сама нашла компромисс между двумя слагаемыми.
  • Интерполяция плавная: промежуточные коды декодируются в правдоподобные переходные формы, а не в шум. В обычном автоэнкодере середина пути легко попала бы в «дыру».
  • Сэмплы из N(0,I)\mathcal N(0, I) (нижний ряд картинки vae_mnist.png) похожи на цифры, но размыты. Причина в реконструкционном loss: если декодер не уверен, где пройдёт штрих, выгоднее нарисовать «среднее» из вариантов. Резкость возвращают GAN-лосс (следующий раздел) или диффузия поверх латента.

Пока VAE учились реконструировать, в 2014 году Иэн Гудфеллоу и соавторы предложили совсем другую схему — генеративно-состязательную сетьгенеративно-состязательная сеть (GAN)EN generative adversarial network (GAN)генератор против дискриминатора; сегодня чаще всего — вспомогательный лосс в автоэнкодерах и вокодерах (GAN). Две сети играют друг против друга:

  • генератор G(z)G(z) делает картинку из шума;
  • дискриминатор D(x)D(x) выдаёт вероятность того, что картинка настоящая.
min⁡Gmax⁡D  Ex∼данные[log⁡D(x)]+Ez∼N(0,I)[log⁡(1−D(G(z)))]\min_G \max_D \; \mathbb{E}_{x \sim \text{данные}}\big[\log D(x)\big] + \mathbb{E}_{z \sim \mathcal N(0, I)}\big[\log\big(1 - D(G(z))\big)\big]

Словами: дискриминатор учится отличать настоящие картинки от подделок (это обычная бинарная cross-entropy), а генератор — обманывать его. Как фальшивомонетчик и эксперт, которые тренируют друг друга.

GAN дают очень резкие картинки (вспомните фотореалистичные лица StyleGAN), но у них три хронические болезни: обучение неустойчиво (стоит одной сети вырваться вперёд — вторая перестаёт учиться), схлопывание мод (генератор находит несколько удачных картинок и выдаёт только их, забыв о разнообразии) и нет понятного loss, по которому видно прогресс. Диффузия победила их по качеству и разнообразию — статья OpenAI 2021 года так и называется: «Diffusion Models Beat GANs on Image Synthesis».

Но GAN не исчезли — они стали вспомогательным лоссом. Автоэнкодер Stable Diffusion учат с perceptual loss и дискриминатором (в конфиге — LPIPSWithDiscriminator): без него декодер рисует размыто. На том же держатся VQGAN (ниже), вокодер HiFi-GAN, превращающий спектрограмму в звук (урок 9.1.5), и состязательная дистилляция, которая учит диффузию генерировать за 1–4 шага (урок 9.1.4).

VQ-VAE: картинка как последовательность токенов

Заголовок раздела «VQ-VAE: картинка как последовательность токенов»

LLM работает с токенами — целыми числами из словаря (модуль 4.2) — и на каждом шаге выбирает следующий через softmax. Если картинку тоже записать как последовательность целых чисел, один трансформер сможет и читать, и писать текст и изображения вперемешку той же задачей «предскажи следующий токен», с тем же KV-кэшем и той же инфраструктурой обучения. VLM из урока 9.1.1 подавали картинку непрерывными векторами — для понимания этого хватает, а вот чтобы генерировать картинку через softmax, нужен словарь.

VQ-VAEVQ-VAE (автоэнкодер с векторным квантованием)EN vector-quantized variational autoencoder (VQ-VAE)каждый вектор энкодера заменяется ближайшим вектором кодовой книги; его номер — дискретный токен (ван ден Оорд и соавторы, DeepMind, 2017) добавляет между энкодером и декодером квантование:

  1. Кодовая книгакодовая книгаEN codebookобучаемый словарь векторов, по которому квантуют выходы энкодера (VQ-VAE, аудиокодеки) — KK обучаемых векторов e1,…,eKe_1, \dots, e_K размерности DD (например, K=16 384K = 16\,384).
  2. Энкодер — свёрточная сеть (урок 3.5.1) — превращает картинку H×WH \times W в сетку h×wh \times w векторов размерности DD. Во сколько раз уменьшается сторона (H/hH/h, обычно 8 или 16) — коэффициент сжатия.
  3. Квантование: каждый вектор сетки zez_e заменяем ближайшим вектором книги и запоминаем его номер: k∗=arg⁡min⁡k∥ze−ek∥2k^* = \arg\min_k \lVert z_e - e_k \rVert^2. Номер k∗k^* — это и есть визуальный токен.
  4. Декодер восстанавливает картинку из сетки выбранных векторов ek∗e_{k^*}.

Если вспомнить урок 2.3.2, квантование — это шаг k-means: векторы книги — центроиды, токен — номер ближайшего центроида. А по смыслу кодовая книга — словарь, как у BPE (урок 4.2.2), только «визуальных слов», и выучен он градиентным спуском. Квантование на маленьком примере:

import numpy as np
codebook = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]) # K = 4 вектора в 2D
z_e = np.array([[0.9, 0.2], [0.1, 0.8], [0.6, 0.6]]) # три вектора «сетки» энкодера
d2 = ((z_e[:, None, :] - codebook[None, :, :]) ** 2).sum(-1) # квадраты расстояний, форма (3, 4)
tokens = d2.argmin(axis=1) # номер ближайшего вектора книги
print(tokens) # [1 2 3]
print(codebook[tokens]) # [[1. 0.] [0. 1.] [1. 1.]] — то, что увидит декодер

У argmin нет полезного градиента: чуть сдвинули zez_e — номер не изменился, производная ноль. Выход тот же, что при обучении с квантованием в уроке 7.2.1: «прямой» оценщик градиента«прямой» оценщик градиента (STE)EN straight-through estimator (STE)Приём QAT — в прямом проходе веса округляются, а в обратном округление считается тождественной функцией, и градиент проходит «насквозь».. В прямом проходе используем квантованный вектор zqz_q, а в обратном делаем вид, что квантования не было, и копируем градиент от zqz_q к zez_e. В PyTorch это одна строка: z_q = z_e + (z_q - z_e).detach() — в прямом проходе получается zqz_q, а градиент течёт в zez_e.

Loss VQ-VAE состоит из трёх слагаемых (sg\mathrm{sg} — stop gradient, в PyTorch .detach()):

L=∥x−x^∥2+∥sg[ze]−e∥2+β ∥ze−sg[e]∥2\mathcal{L} = \lVert x - \hat x \rVert^2 + \lVert \mathrm{sg}[z_e] - e \rVert^2 + \beta\,\lVert z_e - \mathrm{sg}[e] \rVert^2
  • реконструкция — как у автоэнкодера;
  • codebook loss двигает выбранные векторы книги к выходам энкодера (тот же шаг «центроид — к среднему своих точек», что в k-means);
  • commitment loss не даёт энкодеру «убегать» от выбранных кодов; в статье β=0.25\beta = 0.25.
  • Janus (DeepSeek) для генерации берёт VQ-токенизатор с книгой 16 384=21416\,384 = 2^{14} векторов и сжатием в 16 раз. Картинка 384×384 → сетка 24×24=57624 \times 24 = 576 токенов. Каждый токен — 14 бит, всего 576⋅14=8064576 \cdot 14 = 8064 бит ≈ 1 КБ. Сырые пиксели — 384⋅384⋅3=442 368384 \cdot 384 \cdot 3 = 442\,368 байт: сжатие примерно в 440 раз.
  • Emu3 (BAAI): книга 32 76832\,768 векторов, сжатие 8×8 → картинка 512×512 превращается в 64×64=409664 \times 64 = 4096 токенов.

Для сравнения: ViT из урока 9.1.1 тратит на картинку 196–576 непрерывных токенов. Но там каждый токен — вектор из сотен чисел, а здесь — одно целое число.

  • Мёртвые коды. Часть векторов книги никогда не выбирается, словарь фактически сжимается. Лечат инициализацией через k-means, обновлением книги скользящим средним, перезапуском неиспользуемых кодов.
  • VQGAN (Эссер, Ромбах, Оммер, 2020) добавил perceptual loss и дискриминатор: реконструкции стали резкими даже при сильном сжатии. Из этой линии работ вырос и автоэнкодер латентной диффузии.
  • FSQ (Finite Scalar Quantization, 2023) обходится без книги: каждую из нескольких координат просто округляют до нескольких уровней. 8 координат по 3 уровня — 38=65613^8 = 6561 кодов, и все используются. Именно FSQ превращает речь в токены в CosyVoice 2 (урок 9.1.5).
  • Остаточное квантование (RVQ): квантуем, берём остаток и квантуем его второй книгой, потом третьей. На этом построены аудиокодеки (урок 9.1.5).
VAE: непрерывный латентVQ-VAE / VQGAN: дискретные токены
Что выдаёт энкодермассив чисел, например 64×64×4сетку целых чисел, например 24×24
Кто генерируетдиффузия или flow matchingтрансформер «следующий токен»
ПримерыStable Diffusion (4 канала), FLUX (16 каналов), Wan и HunyuanVideo (3D-VAE)Janus (генерация), Emu3, Chameleon; аудиокодеки
Плюсыточнее реконструкция, гладкий латентобщий словарь с текстом, вся инфраструктура LLM
Минусынужен отдельный генератордетали теряются при квантовании, мёртвые коды

Граница размывается: в JanusFlow та же DeepSeek заменила генерацию VQ-токенов на rectified flow в непрерывном пространстве — о нём следующий урок.

Родственник из интерпретируемости: разреженный автоэнкодер

Заголовок раздела «Родственник из интерпретируемости: разреженный автоэнкодер»

Тот же каркас «энкодер → код → декодер, loss на реконструкцию» встретится в уроке 9.3.2, но с противоположным ограничением: код шире входа (скажем, 4096 → 65 536 чисел), зато разреженный — штраф L1 оставляет ненулевыми лишь несколько координат. Так активации LLM раскладывают на интерпретируемые признаки.

Задание из двух частей. Сначала реализуйте PCA через SVD, линейный автоэнкодер с ручными градиентами и угол между подпространствами — и убедитесь, что автоэнкодер находит плоскость PCA. Потом — трюк репараметризации, KL и её градиент с проверкой конечными разностями.

Линейный автоэнкодер = PCA и трюк репараметризацииnumpy · нужен интернет7 тестов
import numpy as np


def pca_subspace(X, k):
    """Первые k главных направлений данных X (форма (n, d)) — столбцы матрицы формы (d, k).

    Данные сначала центрируем (вычитаем среднее по каждому признаку), потом делаем SVD:
    главные направления — первые k строк Vt (урок 2.3.2). Столбцы результата ортонормированы.
    """
    # TODO: Xc = X - X.mean(axis=0); _, _, Vt = np.linalg.svd(Xc, full_matrices=False); ...
    ...


def train_linear_ae(X, k, lr=0.05, steps=2000, seed=0):
    """Обучает линейный автоэнкодер x → W_enc x → W_dec (W_enc x) полным градиентным спуском.

    X — данные формы (n, d); их нужно центрировать внутри функции.
    Loss = среднее по примерам от ||x − x̂||², то есть ((Xh − Xc) ** 2).sum(axis=1).mean().
    Инициализация: rng = np.random.default_rng(seed),
        W_enc = rng.normal(0, 0.1, size=(k, d)),  W_dec = rng.normal(0, 0.1, size=(d, k)).
    Возвращает (W_enc, W_dec, losses), где losses — список loss на каждом шаге (до обновления).
    """
    # TODO: на каждом шаге
    #   Z = Xc @ W_enc.T      (коды, форма (n, k))
    #   Xh = Z @ W_dec.T      (реконструкции, форма (n, d))
    #   посчитать loss, градиенты по W_dec и W_enc (цепное правило) и сделать шаг
    ...


def max_principal_angle(A, B):
    """Наибольший угол (в градусах) между подпространствами, натянутыми на столбцы A и B (форма (d, k)).

    1) ортонормируем столбцы: Qa, _ = np.linalg.qr(A), Qb, _ = np.linalg.qr(B);
    2) сингулярные числа матрицы Qa.T @ Qb — это косинусы главных углов;
    3) наибольший угол соответствует наименьшему косинусу.
    0° — подпространства совпадают, 90° — в одном есть направление, перпендикулярное другому.
    """
    # TODO
    ...


def reparameterize(mu, log_var, eps):
    """Трюк репараметризации: z = mu + sigma * eps, где sigma = exp(log_var / 2)."""
    # TODO
    ...


def kl_to_standard_normal(mu, log_var):
    """KL( N(mu, sigma²) || N(0, 1) ), сложенная по последней оси (по координатам латента).

    Для одной координаты: 0.5 * (mu² + sigma² − 1 − ln sigma²), где ln sigma² = log_var.
    """
    # TODO
    ...


def kl_grad(mu, log_var):
    """Градиенты KL по mu и по log_var (той же формы, что входы): (dKL/dmu, dKL/dlog_var)."""
    # TODO: продифференцируйте 0.5 * (mu² + exp(log_var) − 1 − log_var) по каждой переменной
    ...


# Проверьте себя кнопкой «Запустить»:
rng = np.random.default_rng(0)
X = rng.normal(size=(200, 3)) * np.array([3.0, 1.0, 0.1])   # почти всё «живёт» в плоскости xy
print("PCA:", pca_subspace(X, 2))                            # столбцы ≈ ±(1, 0, 0) и ±(0, 1, 0)
print("z =", reparameterize(np.array([0.5]), np.array([np.log(4.0)]), np.array([0.3])))  # ожидается [1.1]
print("KL =", kl_to_standard_normal(np.array([1.0]), np.array([np.log(0.25)])))          # ожидается ≈0.818
Проверь себя6 вопросов
1 Энкодер VAE выдал для одной координаты μ=0\mu = 0, σ=2\sigma = 2. Чему равна KL к N(0,1)\mathcal N(0, 1)? Округлите до двух знаков.
2 Линейный автоэнкодер 8 → 2 → 8 обучен до минимума ошибки. Что верно?
3 Зачем VAE пишет z=μ+σεz = \mu + \sigma\varepsilon вместо «сэмплировать zz из N(μ,σ2)\mathcal N(\mu, \sigma^2)»?
4 Какие утверждения о VQ-VAE верны?Можно выбрать несколько ответов
5 VQ-токенизатор сжимает каждую сторону в 16 раз. Сколько токенов получится из картинки 512×512?
6 В loss VAE убрали KL и оставили только реконструкцию. Что произойдёт?
  • Автоэнкодер сжимает данные через узкое горлышко и учится без разметки. Линейный автоэнкодер находит то же подпространство, что PCA, но в произвольном базисе.
  • VAE кодирует картинку облачком N(μ,σ2)\mathcal N(\mu, \sigma^2): loss = реконструкция + KL к N(0,I)\mathcal N(0, I) (это минус ELBO). Трюк репараметризации z=μ+σεz = \mu + \sigma\varepsilon пропускает градиент сквозь сэмплирование. В современных системах VAE — это прежде всего сжатие для латентной диффузии.
  • GAN проиграли диффузии как генераторы, но живут как вспомогательный лосс в автоэнкодерах, вокодерах и дистилляции.
  • VQ-VAE превращает картинку в сетку токенов из кодовой книги (straight-through для градиента). Так мультимодальные трансформеры вроде Emu3 и Janus генерируют картинки как текст. FSQ и RVQ пригодятся для звука.

Дальше: flow matching — как современные image- и video-модели переносят шум в данные по прямым линиям и почему для генерации хватает нескольких шагов метода Эйлера.