Автоэнкодеры, VAE и дискретные токены изображений
Как нейросеть сжимает картинку через узкое горлышко: автоэнкодер и его связь с PCA, VAE с трюком репараметризации и ELBO, GAN кратко и VQ-VAE, который превращает изображение в токены для мультимодальных LLM.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В прошлом уроке VAE появился одной строкой: «сжимает картинку 512×512×3 в латент 64×64×4». А в уроке о VLM картинка попадала в LLM как набор непрерывных векторов. Оба места скрывают два вопроса, на которых держится вся мультимодальность:
- Как выучить компактное представление, в котором генерировать дёшево? Латентная диффузия, flow matching из следующего урока, видео-модели с 3D-VAE — все работают не с пикселями, а с таким сжатым латентом.
- Как превратить картинку в последовательность целых чисел из фиксированного словаря? Тогда один 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энкодер сжимает вход в короткий код, декодер восстанавливает вход; учится без разметки — это две нейросети, соединённые «узким горлышком». Энкодер сжимает вход из чисел в код из чисел, где намного меньше . Декодер пытается восстановить вход: . Учим обе сети вместе:
Словами: средняя по примерам квадратичная ошибка между картинкой и её восстановлением (MSE из урока 2.2.1). Разметка не нужна — картинка сама себе ответ. Это то же самообучениесамообучение, самоконтролируемое обучениеEN self-supervised learningМетки берутся из самих данных (например, следующий токен текста). Так идёт предобучение LLM., что и у DDPM.
Представьте, что вы описываете картинку другу по телефону, но можете назвать только 32 числа, а он по ним рисует. Придётся договориться, что значат числа: «наклон», «толщина линии», «есть ли петля сверху». Шум и точные значения пикселей через горлышко не пролезут — и не нужно.
Пространство кодов называют латентнымлатентное пространствоEN latent spaceпространство кодов автоэнкодера; в китайских текстах также «скрытое пространство» («скрытым»): этих чисел нет в данных, сеть придумала их сама. Масштаб сжатия: цифра MNIST — пикселя, код из 32 чисел — сжатие в 24.5 раза. Латент Stable Diffusion — чисел, в 48 раз.
Линейный автоэнкодер — это PCA
Заголовок раздела «Линейный автоэнкодер — это PCA»Уберём из сетей все нелинейности: , . Тогда , и произведение матриц (размер ) имеет ранг не больше : всё проходит через чисел. Вспомним два факта:
- из урока 1.1.3: лучшее приближение матрицы матрицей ранга даёт обрезанное SVD;
- из урока 2.3.2: PCA — это SVD центрированных данных, а ошибка реконструкции равна сумме дисперсий выброшенных направлений.
Отсюда вывод (Балди и Хорник, 1989): оптимальный линейный автоэнкодер восстанавливает данные ровно так же хорошо, как PCA, а столбцы его декодера натягивают то же -мерное подпространство — плоскость первых главных компонент.
Но есть тонкость: векторы будут другими. Для любой обратимой матрицы размера пара даёт тот же — значит, внутри найденной плоскости оси латента могут быть повёрнуты, растянуты и неортогональны. Поэтому сравнивают плоскости, а не векторы: считают главные углы между подпространствами. Ортонормируем оба базиса (QR-разложение), берём сингулярные числа матрицы — это косинусы углов. Все углы 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.TX = X - X.mean(0) # центрируем, как перед PCA
enc = torch.nn.Linear(8, 2, bias=False) # 8 → 2: узкое горлышкоdec = torch.nn.Linear(2, 8, bias=False) # 2 → 8opt = 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 — «оси» латента в 8DQw, _ = 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 до третьего знака и близка к теоретической — сумме дисперсий шести выброшенных направлений. Плоскость та же (углы ≈0°), а вот первая ось автоэнкодера с первой главной компонентой не совпадает (косинус 0.76, угол около 40°). Ваши числа на другой версии PyTorch могут немного отличаться.
Добавьте нелинейности и слои — и автоэнкодер станет «изогнутым PCA»: коды смогут лежать на кривых поверхностях. Цифры, лица, звуки как раз так и устроены.
Почему обычный автоэнкодер плохо генерирует
Заголовок раздела «Почему обычный автоэнкодер плохо генерирует»Хочется взять случайный код , пропустить через декодер и получить новую картинку. Не выйдет: коды обучающих картинок занимают в латентном пространстве разрозненные «островки», а между ними — дыры, которых декодер не видел. Нужно, чтобы коды заполняли известное распределение без дыр, скажем : тогда сэмплируем оттуда и декодируем. Это и делает VAE.
VAE: каждая картинка — облачко, а не точка
Заголовок раздела «VAE: каждая картинка — облачко, а не точка»Вариационный автоэнкодервариационный автокодировщик (VAE)EN variational autoencoder (VAE) (Кингма и Веллинг, 2013) меняет одну вещь: энкодер выдаёт не точку, а облачко. Для каждой координаты латента — два числа: центр и разброс (на практике сеть выдаёт : это любое вещественное число, а автоматически положительна). Код сэмплируется из нормального распределения (урок 1.3.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
Loss: реконструкция + KL
Заголовок раздела «Loss: реконструкция + KL»Читаем по частям:
- — облачко кодов, которое энкодер выдал для картинки ;
- — насколько правдоподобна картинка по мнению декодера, получившего код ; минус логарифм — это знакомый loss из урока 1.3.2. Если декодер считает пиксели гауссовыми, это MSE (с точностью до константы), если «монетками» с вероятностью яркости — бинарная cross-entropy;
- — среднее по кодам из облачка; на практике берут один сэмпл на картинку, и этого хватает;
- — расхождение из урока 1.3.3: там оно мерило «лишние биты» между двумя дискретными распределениями, здесь — насколько облачко отличается от стандартного нормального.
Для гауссиан KL считается в явном виде. Для одной координаты (по координатам латента KL складывается):
Слагаемое — штраф за сдвиг центра от нуля. Часть всегда и равна нулю только при — штраф за слишком узкое или слишком широкое облачко. Пример: , → ната. Проверим формулу сэмплированием — KL это среднее от по сэмплам из :
import numpy as np
mu, s = 1.0, 0.5analytic = 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, все облачка станут , код
перестанет нести информацию о картинке, и декодер будет рисовать «среднюю» картинку. Если только реконструкцию —
сеть сожмёт облачка в точки () и получится обычный автоэнкодер с дырами. Вес перед KL — ручка
настройки (так называемый β-VAE). У автоэнкодера Stable Diffusion он крошечный: в открытом конфиге
kl_weight: 0.000001. Там латент нужен для сжатия, а генерирует диффузия, поэтому от VAE требуется лишь немного
гладкости и аккуратный масштаб. Масштаб потом ещё и подгоняют под шум , с которым смешивает
диффузия: в конфиге Stable Diffusion 1.x латент умножают на scale_factor: 0.18215, у FLUX в коде стоят множитель
0.3611 и сдвиг 0.1159. Вот почему латент VAE удобен для диффузии: он в десятки раз компактнее пикселей, гладкий и
по разбросу близок к шуму.
Почему это называют ELBO
VAE — это метод максимального правдоподобия: хотим, чтобы для настоящих картинок был большим. Но — интеграл по всем возможным кодам, его не посчитать. Зато верно тождество
потому что KL неотрицательна. Значит, — нижняя граница логарифма правдоподобия, по-английски Evidence Lower BOund, ELBOELBO (нижняя граница правдоподобия)EN evidence lower bound (ELBO)−loss VAE = реконструкция с минусом − KL; не больше log p(x). Минимизируя , мы толкаем вверх нижнюю границу . Зазор равен тому, насколько облачко энкодера отличается от «настоящего» распределения кодов для этой картинки . Подробный вывод с картинками — глава 17 книги Принса.
Трюк репараметризации
Заголовок раздела «Трюк репараметризации»Код получается «броском кубика» из . Как градиенту пройти через случайный выбор к и ? Трюк репараметризациитрюк репараметризацииEN reparameterization trickz = μ + σ·ε, ε ~ N(0, 1): случайность выносится во вход, и градиент проходит к μ и σ переписывает сэмплирование:
Случайность теперь живёт в отдельном входе , который не зависит от параметров, — как данные. А стал обычной дифференцируемой функцией: , . Пример: , , выпало → . Пусть дальше loss , тогда , и по цепному правилу (урок 1.2.2) , .
Градиенты KL тоже простые. Обозначим , тогда и
При , (то есть ) оба градиента равны нулю: облачко уже стандартное, толкать некуда. В задании ниже вы проверите эти формулы численно — так же, как проверяли backprop в уроке 3.1.2.
VAE на MNIST своими руками (локально)
Заголовок раздела «VAE на MNIST своими руками (локально)»Соберём всё в один скрипт: энкодер выдаёт и для 20 координат, репараметризация, декодер, loss = BCE + KL. Данные — те же файлы MNIST, что в уроке 3.3.2.
"""VAE на MNIST: обучение, сэмплирование и интерполяция в латенте."""import gzipimport urllib.requestfrom pathlib import Path
import numpy as npimport torchimport torch.nn as nnimport 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 бит) на картинку. Это и есть «пропускная способность» кода: столько информации о цифре проходит через облачка. Сеть сама нашла компромисс между двумя слагаемыми.
- Интерполяция плавная: промежуточные коды декодируются в правдоподобные переходные формы, а не в шум. В обычном автоэнкодере середина пути легко попала бы в «дыру».
- Сэмплы из (нижний ряд картинки
vae_mnist.png) похожи на цифры, но размыты. Причина в реконструкционном loss: если декодер не уверен, где пройдёт штрих, выгоднее нарисовать «среднее» из вариантов. Резкость возвращают GAN-лосс (следующий раздел) или диффузия поверх латента.
GAN кратко
Заголовок раздела «GAN кратко»Пока VAE учились реконструировать, в 2014 году Иэн Гудфеллоу и соавторы предложили совсем другую схему — генеративно-состязательную сетьгенеративно-состязательная сеть (GAN)EN generative adversarial network (GAN)генератор против дискриминатора; сегодня чаще всего — вспомогательный лосс в автоэнкодерах и вокодерах (GAN). Две сети играют друг против друга:
- генератор делает картинку из шума;
- дискриминатор выдаёт вероятность того, что картинка настоящая.
Словами: дискриминатор учится отличать настоящие картинки от подделок (это обычная бинарная 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-VAE
Заголовок раздела «Как устроен VQ-VAE»VQ-VAEVQ-VAE (автоэнкодер с векторным квантованием)EN vector-quantized variational autoencoder (VQ-VAE)каждый вектор энкодера заменяется ближайшим вектором кодовой книги; его номер — дискретный токен (ван ден Оорд и соавторы, DeepMind, 2017) добавляет между энкодером и декодером квантование:
- Кодовая книгакодовая книгаEN codebookобучаемый словарь векторов, по которому квантуют выходы энкодера (VQ-VAE, аудиокодеки) — обучаемых векторов размерности (например, ).
- Энкодер — свёрточная сеть (урок 3.5.1) — превращает картинку в сетку векторов размерности . Во сколько раз уменьшается сторона (, обычно 8 или 16) — коэффициент сжатия.
- Квантование: каждый вектор сетки заменяем ближайшим вектором книги и запоминаем его номер: . Номер — это и есть визуальный токен.
- Декодер восстанавливает картинку из сетки выбранных векторов .
Если вспомнить урок 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 вектора в 2Dz_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
Заголовок раздела «Как учиться через argmin»У argmin нет полезного градиента: чуть сдвинули — номер не изменился, производная ноль. Выход тот же, что
при обучении с квантованием в уроке 7.2.1:
«прямой» оценщик градиента«прямой» оценщик градиента (STE)EN straight-through estimator (STE)Приём QAT — в прямом проходе веса округляются, а в обратном округление считается тождественной функцией, и градиент проходит «насквозь».. В прямом проходе используем квантованный вектор , а в обратном
делаем вид, что квантования не было, и копируем градиент от к . В PyTorch это одна строка:
z_q = z_e + (z_q - z_e).detach() — в прямом проходе получается , а градиент течёт в .
Loss VQ-VAE состоит из трёх слагаемых ( — stop gradient, в PyTorch .detach()):
- реконструкция — как у автоэнкодера;
- codebook loss двигает выбранные векторы книги к выходам энкодера (тот же шаг «центроид — к среднему своих точек», что в k-means);
- commitment loss не даёт энкодеру «убегать» от выбранных кодов; в статье .
Сколько стоит картинка в токенах
Заголовок раздела «Сколько стоит картинка в токенах»- Janus (DeepSeek) для генерации берёт VQ-токенизатор с книгой векторов и сжатием в 16 раз. Картинка 384×384 → сетка токенов. Каждый токен — 14 бит, всего бит ≈ 1 КБ. Сырые пиксели — байт: сжатие примерно в 440 раз.
- Emu3 (BAAI): книга векторов, сжатие 8×8 → картинка 512×512 превращается в токенов.
Для сравнения: ViT из урока 9.1.1 тратит на картинку 196–576 непрерывных токенов. Но там каждый токен — вектор из сотен чисел, а здесь — одно целое число.
Беды и улучшения
Заголовок раздела «Беды и улучшения»- Мёртвые коды. Часть векторов книги никогда не выбирается, словарь фактически сжимается. Лечат инициализацией через k-means, обновлением книги скользящим средним, перезапуском неиспользуемых кодов.
- VQGAN (Эссер, Ромбах, Оммер, 2020) добавил perceptual loss и дискриминатор: реконструкции стали резкими даже при сильном сжатии. Из этой линии работ вырос и автоэнкодер латентной диффузии.
- FSQ (Finite Scalar Quantization, 2023) обходится без книги: каждую из нескольких координат просто округляют до нескольких уровней. 8 координат по 3 уровня — кодов, и все используются. Именно 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 и её градиент с проверкой конечными разностями.
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
Проверь себя
Заголовок раздела «Проверь себя»- Автоэнкодер сжимает данные через узкое горлышко и учится без разметки. Линейный автоэнкодер находит то же подпространство, что PCA, но в произвольном базисе.
- VAE кодирует картинку облачком : loss = реконструкция + KL к (это минус ELBO). Трюк репараметризации пропускает градиент сквозь сэмплирование. В современных системах VAE — это прежде всего сжатие для латентной диффузии.
- GAN проиграли диффузии как генераторы, но живут как вспомогательный лосс в автоэнкодерах, вокодерах и дистилляции.
- VQ-VAE превращает картинку в сетку токенов из кодовой книги (straight-through для градиента). Так мультимодальные трансформеры вроде Emu3 и Janus генерируют картинки как текст. FSQ и RVQ пригодятся для звука.
Дальше: flow matching — как современные image- и video-модели переносят шум в данные по прямым линиям и почему для генерации хватает нескольких шагов метода Эйлера.