Перейти к содержимому
ПереводыDatawhale и китайские курсы

Tiny Universe, часть 6: TinyDiffusion — DDPM с нуля на CIFAR-10

Полный перевод раздела TinyDiffusion из tiny-universe: формулы DDPM (прямой зашумляющий и обратный очищающий процесс), простая U-Net с временным эмбеддингом, расписание шума, обучение на MSE, выборка изображений и оценка по IS и FID.

Окно терминала
pip install -r requirements.txt
  • Скачайте датасет CIFAR-10 в папку datasets/, чтобы получилась структура datasets/cifar-10-batches-py.
  • Обучить модель: python ddpm/train.py
  • Сгенерировать изображения: python ddpm/sample.py
  • Посчитать IS и FID: python ddpm/metrics.py

Рисунок в оригинале (fig/fig1.png) — марковская цепь DDPM: xT→⋯→xt→xt−1→⋯→x0x_T \to \dots \to x_t \to x_{t-1} \to \dots \to x_0 , переходы назад задаёт модель pθ(xt−1∣xt)p_\theta(x_{t-1}\mid x_t), вперёд — зашумление q(xt∣xt−1)q(x_t \mid x_{t-1}).

DDPM — генеративная модель на основе марковской цепи. Как показано на рисунке, прямой процесс (Forward Process) постепенно добавляет к данным гауссов шум, пока не останется чистый шум, а обратный процесс (Reverse Process) постепенно восстанавливает данные из шума.

Прямое зашумление устроено просто. А из формул прямого процесса нужно вывести формулу обратного, очищающего процесса — она и станет целью обучения модели. При самом обучении достаточно формулы обратного процесса.

pθp_\theta — распределение вероятностей процесса очистки, которое оценивает сеть с параметрами θ\theta

qq — истинное распределение изображений

x0x_0 — исходное изображение

xtx_t — изображение после tt шагов зашумления

В прямом процессе к xt−1x_{t-1} добавляется небольшой гауссов шум, и получается значение xtx_t в следующий момент:

xt=1−βt xt−1+βt ϵ,ϵ∼N(0,I)x_t = \sqrt{1-\beta_t}\,x_{t-1} + \sqrt{\beta_t}\,\epsilon, \quad \epsilon \sim \mathcal{N}(0,I)

Здесь βt\beta_t — заранее заданный коэффициент дисперсии, он управляет зашумлением и выбирается вручную.

Это формула (2) статьи (там она записана как распределение):

q(xt∣xt−1):=N(xt; 1−βt xt−1, βtI)q(x_t \mid x_{t-1}) := \mathcal{N}\big(x_t;\ \sqrt{1-\beta_t}\,x_{t-1},\ \beta_t I\big)

Обозначим αt=1−βt\alpha_t = 1-\beta_t и продолжим подстановку дальше; по свойству сложения гауссовых величин получаем формулу перехода сразу от x0x_0 к xtx_t:

xt=αˉt x0+1−αˉt ϵ,ϵ∼N(0,I)x_t = \sqrt{\bar{\alpha}_t}\,x_0 + \sqrt{1-\bar{\alpha}_t}\,\epsilon, \quad \epsilon \sim \mathcal{N}(0,I)

где αˉt=∏i=1tαi\bar{\alpha}_t = \prod_{i=1}^t \alpha_i.

Это формула (4) статьи:

q(xt∣x0):=N(xt; αˉt x0, (1−αˉt)I)q(x_t \mid x_0) := \mathcal{N}\big(x_t;\ \sqrt{\bar{\alpha}_t}\,x_0,\ (1-\bar{\alpha}_t)I\big)

Когда tt достаточно велико (t→Tt \rightarrow T), xTx_T почти распределено как стандартное нормальное: xT→ϵx_T \rightarrow \epsilon. То есть после достаточного числа шагов зашумления изображение полностью превращается в случайный шум. Поэтому при генерации в качестве стартовой точки можно просто взять выборку из стандартного нормального распределения.

При очистке нужно по текущему xtx_t и tt с помощью модели предсказать значение xt−1x_{t-1} в предыдущий момент. На практике это делается так: модель pθp_\theta предсказывает шум, добавленный в прямом процессе при переходе от xt−1x_{t-1} к xtx_t:

xt−1=1αt(xt−1−αt1−αˉt ϵθ(xt,t))+σtz,z∼N(0,I)x_{t-1} = \frac{1}{\sqrt{\alpha_t}}\Big(x_t - \frac{1-\alpha_t}{\sqrt{1-\bar{\alpha}_t}}\,\epsilon_\theta(x_t,t)\Big) + \sigma_t z, \quad z \sim \mathcal{N}(0,I)

где σt2=(1−αt)(1−αˉt−1)1−αˉt\sigma_t^2 = \frac{(1-\alpha_t)(1-\bar{\alpha}_{t-1})}{1-\bar{\alpha}_t}, а ϵθ(xt,t)\epsilon_\theta(x_t,t) — шум прямого процесса, который модель предсказывает по xtx_t и tt.

Это формулы (6)–(7) статьи (если подставить xt=αˉtx0+1−αˉt ϵθ(xt,t)x_t = \sqrt{\bar{\alpha}_t}x_0 + \sqrt{1-\bar{\alpha}_t}\,\epsilon_\theta(x_t,t) и воспользоваться αˉt=∏i=1tαi\bar{\alpha}_t = \prod_{i=1}^t \alpha_i):

q(xt−1∣xt,x0)=N(xt−1; μ~t(xt,x0), β~tI)q(x_{t-1} \mid x_t, x_0) = \mathcal{N}\big(x_{t-1};\ \tilde{\mu}_t(x_t, x_0),\ \tilde{\beta}_t I\big)

где μ~t(xt,x0):=αˉt−1 βt1−αˉt x0+αt (1−αˉt−1)1−αˉt xt\tilde{\mu}_t(x_t, x_0) := \frac{\sqrt{\bar{\alpha}_{t-1}}\,\beta_t}{1 - \bar{\alpha}_t}\,x_0 + \frac{\sqrt{\alpha_t}\,(1 - \bar{\alpha}_{t-1})}{1 - \bar{\alpha}_t}\,x_t, β~t:=1−αˉt−11−αˉt βt\tilde{\beta}_t := \frac{1 - \bar{\alpha}_{t-1}}{1 - \bar{\alpha}_t}\,\beta_t.

Хотя вывод DDPM довольно сложен, на деле, чтобы при инференсе получить изображение, нужна только одна формула — формула для xt−1x_{t-1} из обратного процесса. Её можно упрощённо записать так:

xt−1=a (xt−b ϵθ(xt,t))+c z,z∼N(0,I)x_{t-1} = a\,(x_t - b\,\epsilon_\theta(x_t,t)) + c\,z, \quad z \sim \mathcal{N}(0,I)

где aa, bb, cc — известные, вычислимые константы, ϵθ(xt,t)\epsilon_\theta(x_t,t) — шум, предсказанный моделью, zz — шум из стандартного нормального распределения.

Цель обучения — чтобы предсказанный моделью шум ϵθ(xt,t)\epsilon_\theta(x_t,t) был как можно ближе к настоящему. Поэтому достаточно минимизировать среднеквадратичную ошибку (MSE) между предсказанным и настоящим шумом.

Обучаем на датасете CIFAR-10. Сначала скачайте данные в папку datasets/ так, чтобы получилось datasets/cifar-10-batches-py.

Затем определяем преобразования данных. Для обучающей выборки — случайное отражение по горизонтали и приведение к размеру 32×3232 \times 32. Чтобы было удобно добавлять гауссов шум и убирать его, данные нужно перевести в диапазон [−1,1][-1,1]. Для тестовой выборки — просто приводим к 32×3232 \times 32 и переводим в [−1,1][-1,1].

train_data_transform = transforms.Compose([
transforms.Resize((img_size, img_size)),
transforms.RandomHorizontalFlip(), # случайное отражение по горизонтали
transforms.ToTensor(), # переводим данные в диапазон [0, 1]
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)), # переводим данные в диапазон [-1, 1]
])
test_data_transform = transforms.Compose([
transforms.Resize((img_size, img_size)),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)),
])

Затем загружаем данные через torchvision.datasets.CIFAR10 и создаём DataLoader — подробности в ddpm/dataloader.py.

После этого можно написать функцию визуализации. Преобразованные данные переводим из диапазона [−1,1][-1,1] в [0,255][0,255], меняем порядок каналов и превращаем в картинку PIL; если на входе батч — берём первую картинку.

def show_tensor_image(image):
reverse_transforms = transforms.Compose([
transforms.Lambda(lambda t: (t + 1) / 2), # из [-1, 1] в [0, 1]
transforms.Lambda(lambda t: t.permute(1, 2, 0)), # порядок каналов CHW → HWC
transforms.Lambda(lambda t: t * 255.), # в диапазон [0, 255]
transforms.Lambda(lambda t: t.numpy().astype(np.uint8)), # в тип uint8
transforms.ToPILImage(), # в картинку PIL
])
# Если на входе батч — берём первую картинку
if len(image.shape) == 4:
image = image[0, :, :, :]
return reverse_transforms(image)

Проверить, что данные загружаются правильно, можно командой python ddpm/dataloader.py.

На вход модели DDPM подаются зашумлённое изображение xtx_t и шаг времени tt, на выходе — предсказанный шум ϵθ(xt,t)\epsilon_\theta(x_t,t).

Сначала определим слой временного эмбеддинга: он вносит информацию о времени в признаки, отображая шаг tt в вектор большой размерности. По аналогии с позиционным кодированием в Transformer используем синусы и косинусы:

PE(t,2i)=sin⁡(t/100002i/d)PE(t, 2i) = \sin(t / 10000^{2i/d}) PE(t,2i+1)=cos⁡(t/100002i/d)PE(t, 2i+1) = \cos(t / 10000^{2i/d})

где dd — размерность эмбеддинга, ii — номер координаты.

class SinusoidalPositionEmbeddings(nn.Module):
def __init__(self, dim):
super().__init__()
self.dim = dim
def forward(self, time):
device = time.device
# делим размерность пополам: одна половина для sin, другая для cos
half_dim = self.dim // 2
# шаг экспоненциального убывания частот
embeddings = math.log(10000) / (half_dim - 1)
# последовательность частот
embeddings = torch.exp(torch.arange(half_dim, device=device) * -embeddings)
# умножаем шаг времени на частоты
embeddings = time[:, None] * embeddings[None, :]
# склеиваем sin и cos — получаем итоговый эмбеддинг
embeddings = torch.cat((embeddings.sin(), embeddings.cos()), dim=-1)
return embeddings

Затем определим базовый модуль U-Net — Block — с временным эмбеддингом и повышением/понижением разрешения. Первая свёртка увеличивает число каналов, затем добавляется временной эмбеддинг, затем вторая свёртка смешивает признаки, и в конце — повышение или понижение разрешения. Мы используем упрощённую U-Net, без механизма внимания, который есть в модели исходной статьи.

class Block(nn.Module):
def __init__(self, in_channels, out_channels, time_emb_dim, up=False):
super().__init__()
self.time_mlp = nn.Linear(time_emb_dim, out_channels)
if up:
self.conv1 = nn.Conv2d(2 * in_channels, out_channels, kernel_size=3, padding=1) # из-за остаточных связей U-Net при повышении разрешения приклеиваются (concat) прежние признаки, поэтому входных каналов вдвое больше
self.transform = nn.ConvTranspose2d(out_channels, out_channels, kernel_size=4, stride=2, padding=1)
else:
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1)
self.transform = nn.Conv2d(out_channels, out_channels, kernel_size=4, stride=2, padding=1)
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1)
self.bnorm1 = nn.BatchNorm2d(out_channels)
self.bnorm2 = nn.BatchNorm2d(out_channels)
self.relu = nn.ReLU()
def forward(self, x, t):
# первая свёртка
h = self.bnorm1(self.relu(self.conv1(x)))
# временной эмбеддинг
time_emb = self.relu(self.time_mlp(t))
# вносим информацию о времени в карту признаков
h = h + time_emb[..., None, None]
# вторая свёртка
h = self.bnorm2(self.relu(self.conv2(h)))
# повышение или понижение разрешения
return self.transform(h)

Наконец, собираем из нескольких Block модель U-Net. Сначала модель понижает разрешение, меняя число каналов 3 → 64 → 128 → 256 → 512 → 1024: каналов становится больше, карты признаков — меньше. Затем повышает разрешение с каналами 1024 → 512 → 256 → 128 → 64 → 3 (это выходные каналы; входных из-за остаточных связей вдвое больше): каналов становится меньше, карты признаков — больше. В каждый слой вносится информация о шаге времени, а на выходе получается предсказанный шум того же размера, что и входное изображение.

class SimpleUnet(nn.Module):
def __init__(self):
super().__init__()
image_channels = 3
down_channels = (64, 128, 256, 512, 1024)
up_channels = (1024, 512, 256, 128, 64)
out_dim = 3
time_emb_dim = 32
# слой временного эмбеддинга
self.time_embed = nn.Sequential(
SinusoidalPositionEmbeddings(time_emb_dim),
nn.Linear(time_emb_dim, time_emb_dim),
)
# входной слой, слои понижения и повышения разрешения, выходной слой
self.input = nn.Conv2d(image_channels, down_channels[0], kernel_size=3, padding=1)
self.downs = nn.ModuleList([Block(down_channels[i], down_channels[i + 1], time_emb_dim) for i in range(len(down_channels) - 1)])
self.ups = nn.ModuleList([Block(up_channels[i], up_channels[i + 1], time_emb_dim, up=True) for i in range(len(up_channels) - 1)])
self.output = nn.Conv2d(up_channels[-1], out_dim, kernel_size=3, padding=1)
def forward(self, x, time_step):
# эмбеддинг шага времени
t = self.time_embed(time_step)
# начальная свёртка
x = self.input(x)
# прямой проход U-Net: сначала понижаем разрешение и собираем признаки, затем повышаем разрешение
residual_stack = []
for down in self.downs:
x = down(x, t)
residual_stack.append(x)
for up in self.ups:
residual_x = residual_stack.pop()
x = torch.cat((x, residual_x), dim=1)
x = up(x, t)
return self.output(x)

Командой python ddpm/unet.py можно напечатать формы входа и выхода каждого слоя U-Net и проверить, что модель собрана правильно:

Input shape: torch.Size([1, 3, 32, 32])
Time embedding shape: torch.Size([1, 32])
After input conv shape: torch.Size([1, 64, 32, 32])
Downsampling process:
Down block 1 output shape: torch.Size([1, 128, 16, 16])
Down block 2 output shape: torch.Size([1, 256, 8, 8])
Down block 3 output shape: torch.Size([1, 512, 4, 4])
Down block 4 output shape: torch.Size([1, 1024, 2, 2])
Upsampling process:
Concatenated input shape before up block 1: torch.Size([1, 2048, 2, 2])
Up block 1 output shape: torch.Size([1, 512, 4, 4])
Concatenated input shape before up block 2: torch.Size([1, 1024, 4, 4])
Up block 2 output shape: torch.Size([1, 256, 8, 8])
Concatenated input shape before up block 3: torch.Size([1, 512, 8, 8])
Up block 3 output shape: torch.Size([1, 128, 16, 16])
Concatenated input shape before up block 4: torch.Size([1, 256, 16, 16])
Up block 4 output shape: torch.Size([1, 64, 32, 32])
Final output shape: torch.Size([1, 3, 32, 32])
flowchart LR
I["x_t<br/>3×32×32"] --> C0["conv 64×32×32"]
C0 --> D1["down 128×16×16"] --> D2["down 256×8×8"] --> D3["down 512×4×4"] --> D4["down 1024×2×2"]
D4 --> U1["up 512×4×4"] --> U2["up 256×8×8"] --> U3["up 128×16×16"] --> U4["up 64×32×32"] --> O["ε̂: 3×32×32"]
D4 -.->|concat| U1
D3 -.->|concat| U2
D2 -.->|concat| U3
D1 -.->|concat| U4
T["шаг t → эмбеддинг 32"] --> D1
T --> U1
Схема переводчика по выводу unet.py: пунктир — пропускающие связи U-Net (время подаётся во все блоки)

Сначала нужно определить планировщик шума (noise scheduler): он управляет зашумлением и даёт зашумлённые изображения для разных шагов. По формулам выше реализуем его в коде. Для прямого процесса нужны βt\beta_t, αt\alpha_t, αˉt\bar{\alpha}_t, αˉt\sqrt{\bar{\alpha}_t}, 1−αˉt\sqrt{1-\bar{\alpha}_t}. Задаём их через register_buffer — тогда они автоматически сохраняются и загружаются вместе с параметрами модели.

class NoiseScheduler(nn.Module):
def __init__(self, beta_start=0.0001, beta_end=0.02, num_steps=1000):
super().__init__()
self.beta_start = beta_start
self.beta_end = beta_end
self.num_steps = num_steps
# β_t: линейное расписание шума
self.register_buffer('betas', torch.linspace(beta_start, beta_end, num_steps))
# α_t = 1 - β_t
self.register_buffer('alphas', 1.0 - self.betas)
# α_bar_t = ∏(1-β_i) для i от 1 до t
self.register_buffer('alpha_bar', torch.cumprod(self.alphas, dim=0))
# sqrt(α_bar_t)
self.register_buffer('sqrt_alpha_bar', torch.sqrt(self.alpha_bar))
# sqrt(1-α_bar_t)
self.register_buffer('sqrt_one_minus_alpha_bar', torch.sqrt(1.0 - self.alpha_bar))

Мы обучаем на батчах изображений, и эти величины нужно комбинировать с тензором картинок, а сейчас все они — одномерные тензоры. Поэтому их форму надо подогнать под тензоры другой размерности. Для этого в классе NoiseScheduler есть метод get: он берёт значение величины для заданных шагов и меняет форму; var — тензор величины, t — шаги, x_shape — целевая форма.

def get(self, var, t, x_shape):
# берём значения величины для заданных шагов
out = var[t]
# меняем форму на [batch_size, 1, 1, 1] для трансляции (broadcasting)
return out.view([t.shape[0]] + [1] * (len(x_shape) - 1))

Теперь можно реализовать зашумление по формуле xt=αˉtx0+1−αˉtϵx_t = \sqrt{\bar{\alpha}_t}x_0 + \sqrt{1-\bar{\alpha}_t}\epsilon, ϵ∼N(0,I)\epsilon \sim \mathcal{N}(0,I), где αˉt\sqrt{\bar{\alpha}_t} и 1−αˉt\sqrt{1-\bar{\alpha}_t} берутся методом get.

def add_noise(self, x, t):
# sqrt(α_bar_t) для шага t
sqrt_alpha_bar = self.get(self.sqrt_alpha_bar, t, x.shape)
# sqrt(1-α_bar_t) для шага t
sqrt_one_minus_alpha_bar = self.get(self.sqrt_one_minus_alpha_bar, t, x.shape)
# шум из стандартного нормального распределения ε ~ N(0,I)
noise = torch.randn_like(x)
# прямой процесс диффузии: x_t = sqrt(α_bar_t) * x_0 + sqrt(1-α_bar_t) * ε
return sqrt_alpha_bar * x + sqrt_one_minus_alpha_bar * noise, noise

Командой python ddpm/diffusion.py можно нарисовать, как изображение шаг за шагом зашумляется.

Рисунок в оригинале (fig/fig2.png): одна и та же картинка CIFAR-10 на нескольких шагах tt — от чёткой до сплошного шума.

Наконец, можно написать весь цикл обучения. Шаги такие:

  1. Случайно выбрать шаг времени t.
  2. Зашумить изображения — получить зашумлённые картинки и сам шум.
  3. Предсказать шум моделью.
  4. Посчитать MSE между предсказанным и настоящим шумом.
  5. Обратное распространение и шаг оптимизатора.

Командой python ddpm/train.py модель обучается и сохраняет параметры; 200 эпох на RTX-4090 занимают около 2 часов.

Идея генерации: берём начальный шум из стандартного нормального распределения, затем шаг за шагом убираем шум от t=Tt=T до t=0t=0 и в конце обрезаем результат до диапазона [−1,1][-1,1]. Для очистки нужны коэффициенты 1αˉt\sqrt{\frac{1}{\bar{\alpha}_t}}, 1αˉt−1\sqrt{\frac{1}{\bar{\alpha}_t}-1}, μθ(xt,t)\mu_\theta(x_t,t), log⁡(σt2)\log(\sigma_t^2). Определяем их в классе NoiseScheduler:

# α_bar_(t-1)
self.register_buffer('alpha_bar_prev', torch.cat([torch.tensor([1.0]), self.alpha_bar[:-1]], dim=0))
# 1/sqrt(α_t)
self.register_buffer('sqrt_recip_alphas', torch.sqrt(1.0 / self.alphas))
# 1/sqrt(α_bar_t)
self.register_buffer('sqrt_recip_alphas_bar', torch.sqrt(1.0 / self.alpha_bar))
# sqrt(1/α_bar_t - 1)
self.register_buffer('sqrt_recipm1_alphas_bar', torch.sqrt(1.0 / self.alpha_bar - 1))
# дисперсия апостериорного распределения σ_t^2
self.register_buffer('posterior_var', self.betas * (1.0 - self.alpha_bar_prev) / (1.0 - self.alpha_bar))
# коэффициент 1 среднего апостериорного распределения: β_t * sqrt(α_bar_(t-1))/(1-α_bar_t)
self.register_buffer('posterior_mean_coef1', self.betas * torch.sqrt(self.alpha_bar_prev) / (1.0 - self.alpha_bar))
# коэффициент 2 среднего апостериорного распределения: (1-α_bar_(t-1)) * sqrt(α_t)/(1-α_bar_t)
self.register_buffer('posterior_mean_coef2', (1.0 - self.alpha_bar_prev) * torch.sqrt(self.alphas) / (1.0 - self.alpha_bar))

Порядок генерации (по формулам статьи):

  1. Взять начальный шум из стандартного нормального распределения: xT∼N(0,I)x_T \sim \mathcal{N}(0,I).
  2. Повторять в цикле от t=Tt=T до t=0t=0 следующие шаги:
  3. По текущему шагу tt и текущему изображению xtx_t вычислить моделью предсказанный шум ϵθ(xt,t)\epsilon_\theta(x_t,t).
  4. Вычислить предсказание x0x_0: x0=1αˉtxt−1αˉt−1 ϵθ(xt,t)x_0 = \frac{1}{\sqrt{\bar{\alpha}_t}}x_t - \sqrt{\frac{1}{\bar{\alpha}_t}-1}\,\epsilon_\theta(x_t,t).
  5. Вычислить среднее апостериорного распределения: μθ(xt,t)=αˉt−1βt1−αˉtx0+αt(1−αˉt−1)1−αˉtxt\mu_\theta(x_t,t) = \frac{\sqrt{\bar{\alpha}_{t-1}}\beta_t}{1-\bar{\alpha}_t}x_0 + \frac{\sqrt{\alpha_t}(1-\bar{\alpha}_{t-1})}{1-\bar{\alpha}_t}x_t.
  6. Вычислить логарифм дисперсии апостериорного распределения: log⁡(σt2)=log⁡(β~t)=log⁡ ⁣(βt(1−αˉt−1)1−αˉt)\log(\sigma_t^2) = \log(\tilde{\beta}_t) = \log\!\Big(\frac{\beta_t(1-\bar{\alpha}_{t-1})}{1-\bar{\alpha}_t}\Big).
  7. Если t>0t>0, сделать выборку из апостериорного распределения: xt−1=μθ(xt,t)+σtϵx_{t-1} = \mu_\theta(x_t,t) + \sigma_t\epsilon, ϵ∼N(0,I)\epsilon \sim \mathcal{N}(0,I).
  8. Если t=0t=0, взять среднее как результат генерации: x0=μθ(xt,t)x_0 = \mu_\theta(x_t,t).
def sample(model, scheduler, num_samples, size, device="cpu"):
model.eval()
with torch.no_grad():
# начальный шум из стандартного нормального распределения x_T ~ N(0,I)
x_t = torch.randn(num_samples, *size).to(device)
# шаг за шагом убираем шум, от t=T до t=0
for t in tqdm(reversed(range(scheduler.num_steps)), desc="Sampling"):
# батч из номеров шага
t_batch = torch.tensor([t] * num_samples).to(device)
# коэффициенты для генерации
sqrt_recip_alpha_bar = scheduler.get(scheduler.sqrt_recip_alphas_bar, t_batch, x_t.shape)
sqrt_recipm1_alpha_bar = scheduler.get(scheduler.sqrt_recipm1_alphas_bar, t_batch, x_t.shape)
posterior_mean_coef1 = scheduler.get(scheduler.posterior_mean_coef1, t_batch, x_t.shape)
posterior_mean_coef2 = scheduler.get(scheduler.posterior_mean_coef2, t_batch, x_t.shape)
# предсказываем шум ε_θ(x_t,t)
predicted_noise = model(x_t, t_batch)
# предсказание x_0: x_0 = 1/sqrt(α_bar_t) * x_t - sqrt(1/α_bar_t-1) * ε_θ(x_t,t)
_x_0 = sqrt_recip_alpha_bar * x_t - sqrt_recipm1_alpha_bar * predicted_noise
# среднее апостериорного распределения μ_θ(x_t,t)
model_mean = posterior_mean_coef1 * _x_0 + posterior_mean_coef2 * x_t
# логарифм дисперсии log(σ_t^2)
model_log_var = scheduler.get(torch.log(torch.cat([scheduler.posterior_var[1:2], scheduler.betas[1:]])), t_batch, x_t.shape)
if t > 0:
# при t>0 делаем выборку из апостериорного распределения: x_t-1 = μ_θ(x_t,t) + σ_t * z, z~N(0,I)
noise = torch.randn_like(x_t).to(device)
x_t = model_mean + torch.exp(0.5 * model_log_var) * noise
else:
# при t=0 берём среднее как результат
x_t = model_mean
# обрезаем результат до диапазона [-1,1]
x_0 = torch.clamp(x_t, -1.0, 1.0)
return x_0

Командой python ddpm/sample.py можно загрузить обученную модель, сгенерировать изображения и сохранить их.

Рисунок в оригинале (fig/fig3.png): сетка сгенерированных картинок 32×32.

Inception Score (IS) и Fréchet Inception Distance (FID) — две важные метрики качества сгенерированных изображений; по ним мы и оцениваем качество генерации.

Сначала загружаем предобученную модель Inception и получаем признаки настоящих и сгенерированных изображений.

class InceptionStatistics:
def __init__(self, device='cuda'):
self.device = device
# загружаем предобученную модель Inception v3
self.model = models.inception_v3(weights=models.Inception_V3_Weights.IMAGENET1K_V1, transform_input=False)
self.model.fc = nn.Identity() # убираем последний полносвязный слой
self.model = self.model.to(device)
self.model.eval()
# предобработка изображений
self.preprocess = transforms.Compose([
transforms.Resize(299),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))
])
@torch.no_grad()
def get_features(self, images):
"""Получить признаки Inception"""
features = []
probs = []
# приводим изображения к размеру 299x299
images = self.preprocess(images)
# обрабатываем изображения батчами
dataset = TensorDataset(images)
dataloader = DataLoader(dataset, batch_size=32)
for (batch,) in tqdm(dataloader):
batch = batch.to(self.device)
# получаем признаки и логиты
feature = self.model(batch)
prob = F.softmax(feature, dim=1)
features.append(feature.cpu().numpy())
probs.append(prob.cpu().numpy())
features = np.concatenate(features, axis=0)
probs = np.concatenate(probs, axis=0)
return features, probs

IS оценивает качество и разнообразие сгенерированных изображений с помощью предобученной сети Inception v3. Формула:

IS=exp⁡(Ex∼pg[KL(p(y∣x) ∥ p(y))])IS = \exp\big(\mathbb{E}_{x\sim p_g}[\mathrm{KL}(p(y\mid x) \,\Vert\, p(y))]\big)

где:

  • pgp_g — распределение генератора;
  • p(y∣x)p(y\mid x) — вероятности классов, которые модель Inception предсказывает для изображения xx;
  • p(y)p(y) — среднее распределение классов по всем сгенерированным изображениям;
  • KL — расхождение Кульбака — Лейблера, мера различия двух распределений вероятностей: KL(p ∥ q)=∑i=1np(i)log⁡p(i)q(i)\mathrm{KL}(p \,\Vert\, q) = \sum_{i=1}^{n} p(i) \log \frac{p(i)}{q(i)}.

Чем выше IS, тем:

  1. увереннее предсказание класса для каждого изображения (качество выше);
  2. шире разброс классов между изображениями (разнообразие выше).

Шаги:

  1. Разбить все изображения на группы.
  2. Для каждой группы:
    • посчитать маргинальное распределение p(y)p(y) — среднее p(y∣x)p(y\mid x) по группе;
    • посчитать KL-расхождение;
    • взять экспоненту.
  3. Вернуть среднее и стандартное отклонение оценок по группам.
def calculate_inception_score(probs, splits=10):
# оценки IS для каждой группы
scores = []
# размер группы
split_size = probs.shape[0] // splits
# считаем для каждой группы
for i in tqdm(range(splits)):
# распределения вероятностей текущей группы
part = probs[i * split_size:(i + 1) * split_size]
# KL-расхождение: KL(p(y|x) || p(y))
kl = part * (np.log(part) - np.log(np.expand_dims(np.mean(part, axis=0), 0)))
# усредняем KL по объектам
kl = np.mean(np.sum(kl, axis=1))
# exp(KL) — в список оценок
scores.append(np.exp(kl))
# среднее и стандартное отклонение IS по группам
return np.mean(scores), np.std(scores)

FID оценивает качество генерации, сравнивая распределения настоящих и сгенерированных изображений в пространстве признаков Inception. Формула:

FID=∥μr−μg∥2+Tr(Σr+Σg−2(ΣrΣg)1/2)FID = \lVert\mu_r - \mu_g\rVert^2 + \mathrm{Tr}\big(\Sigma_r + \Sigma_g - 2(\Sigma_r\Sigma_g)^{1/2}\big)

где:

  • μr,μg\mu_r, \mu_g — средние признаков настоящих и сгенерированных изображений;
  • Σr,Σg\Sigma_r, \Sigma_g — ковариационные матрицы признаков настоящих и сгенерированных изображений;
  • Tr\mathrm{Tr} — след матрицы.

Чем ниже FID, тем ближе распределение признаков сгенерированных изображений к настоящему и тем выше качество.

Шаги:

  1. Для настоящих и для сгенерированных изображений по отдельности:
    • извлечь признаки моделью Inception;
    • посчитать вектор средних и ковариационную матрицу признаков.
  2. Посчитать евклидово расстояние между векторами средних.
  3. Посчитать квадратный корень из произведения ковариационных матриц.
  4. Посчитать итоговый FID.
def calculate_fid(real_features, fake_features):
# векторы средних и ковариационные матрицы признаков настоящих и сгенерированных изображений
mu1, sigma1 = real_features.mean(axis=0), np.cov(real_features, rowvar=False)
mu2, sigma2 = fake_features.mean(axis=0), np.cov(fake_features, rowvar=False)
# квадрат евклидова расстояния между векторами средних
ssdiff = np.sum((mu1 - mu2) ** 2)
# квадратный корень из произведения ковариаций: (Σ_r Σ_f)^(1/2)
covmean = linalg.sqrtm(sigma1.dot(sigma2)) # считается долго
# если в результате есть комплексные числа — берём действительную часть
if np.iscomplexobj(covmean):
covmean = covmean.real
# итоговый FID
fid = ssdiff + np.trace(sigma1 + sigma2 - 2 * covmean)
return fid

Командой python ddpm/metrics.py можно посчитать IS и FID: мы генерируем обученной диффузионной моделью 10 000 изображений и сравниваем их с датасетом CIFAR-10 как набором настоящих изображений.

По одной картинке IS и FID не посчитать, но можно наглядно посмотреть, как выглядят изображения двух моделей с разными IS и FID. Ниже — изображения упрощённой U-Net этого проекта (epochs=2000, IS=1.12, FID=41.63) и U-Net со слоями внимания (Attention) из статьи DDPM (epochs=2000, IS=1.17, FID=14.10), обученных в нашем фреймворке 200 и 2000 эпох.

Рисунки в оригинале — четыре сетки сгенерированных картинок: SimpleUnet, 200 эпох, AttentionUnet, 200 эпох, SimpleUnet, 2000 эпох, AttentionUnet, 2000 эпох.

Видно, что по мере обучения и с ростом качества модели изображения модели с более высоким IS и более низким FID лучше узнаются по классу, а детали ближе к настоящим картинкам CIFAR-10.

От переводчика: как запустить на своей машине

Заголовок раздела «От переводчика: как запустить на своей машине»

Что сделать руками:

  1. Повторите таблицу αˉt\bar\alpha_t из примечания в NumPy и постройте график αˉt\sqrt{\bar\alpha_t} по tt для линейного расписания. Сравните с косинусным расписанием (Improved DDPM).
  2. Обучите SimpleUnet 20 эпох и сгенерируйте 16 картинок с 1000 шагами. Затем сократите генерацию до 100 шагов, просто пропуская шаги, — и посмотрите, почему так делать нельзя без DDIM.
  3. Исправьте подсчёт IS (верните fc, а признаки для FID берите отдельно) и пересчитайте метрики.
РусскийПояснение
диффузионная модель (diffusion model)генерация через постепенное удаление шума
прямой процесс, зашумление (forward process)x0→xTx_0 \to x_T
обратный процесс, очистка от шума (reverse process)xT→x0x_T \to x_0
марковская цепь (Markov chain)следующее состояние зависит только от текущего
расписание шума (noise schedule)значения βt\beta_t по шагам
апостериорное распределение (posterior)q(xt−1∣xt,x0)q(x_{t-1}\mid x_t, x_0)
временной эмбеддинг (time embedding)номер шага → вектор
повышение / понижение разрешенияupsampling / downsampling
генерация, сэмплирование (sampling)получение новых картинок
расхождение Кульбака — Лейблера (KL divergence)урок 1.3.3