Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

图像生成基础

给模型看一只杯子,让它说出“红色”,我们已经试过了。现在把方向反过来:只给一句“一只红色陶瓷杯”,让模型交出图片。

这件事为什么更难?一句话没有规定杯口的弧度、灯光的位置、杯柄朝哪边。满足描述的图片可以有很多张。模型需要学会的,便不只是找到一个唯一答案,而是从许多合理的答案中生成一个。

这一章先用 8×8 的手写数字把计算拆开,再走到真正的文字生成图片。我们会亲手训练自编码器、一个小型扩散模型和一个流匹配模型,最后使用预训练的 SD-Turbo 做六次固定对照。生成成功的例子值得看,杯子被截断、把手变多、数量不对的例子同样值得看。

如果你还记得均方误差、标准正态分布和“输入经过网络得到输出”,就可以开始。涉及连续时间时,我们先用“每次走一小步”来理解,不需要先学会求解微分方程。

1. 先把图片装进一个小盒子

一张 8×8 灰度图可以看作 64 个数。每个数表示一个像素有多亮;按固定顺序展开,就得到向量 x∈R64\mathbf{x}\in\mathbb{R}^{64}。

现在做一个看似有点绕的任务:让网络先把这 64 个数变成 8 个数,再从这 8 个数尽量还原原图。

z=Eϕ(x)x^=Dθ(z) \begin{aligned} \mathbf{z}&=E_\phi(\mathbf{x})\\ \hat{\mathbf{x}}&=D_\theta(\mathbf{z}) \end{aligned}

EϕE_\phi 是编码器,DθD_\theta 是解码器,ϕ,θ\phi,\theta 是各自的参数。中间的 z\mathbf{z} 叫潜变量或潜表示:它不直接对应屏幕上的像素,却保存了重建所需的信息。

这个组合叫自编码器。训练时,输入和目标都是同一张图片,网络需要穿过中间的窄通道,再把图片还回来。把像素缩放到 [0,1][0,1] 后,我们可以使用:

Lrec=1NP∑i=1N∑j=1P(xij−x^ij)2 L_{\mathrm{rec}} =\frac{1}{NP}\sum_{i=1}^{N} \sum_{j=1}^{P}(x_{ij}-\hat{x}_{ij})^2

NN 是图片数,PP 是每张图的像素数,这里为 64。误差在所有图片、所有像素上取平均,所以不同图片批次之间有一致的尺度。

下面是真实训练得到的结果。前两行分别是输入与重建,第三行稍后解释。

图 1:每类取测试划分中按原顺序出现的第一张图;这里只展示 0~4,记录中保存了 0~9。第三行的潜变量独立随机采样,与上方图片没有一一重建关系。

数字的大致轮廓留下来了,但笔画变软,有些细节消失。这个结果并不神奇:中间只留 8 个数,网络必须决定哪些变化值得保留。这里减少的是表示的维度,并不能直接宣称文件缩小了八倍;存储格式、数值精度和模型本身也要占空间。

能重建,不等于会从随机数生成

训练好的解码器能接收 8 个数,那么,随便生成 8 个标准正态随机数交给它,是不是就能画一个新数字?

图 1 的第三行就是这个尝试。许多输出像灰色斑块,和上面的数字相去甚远。原因在于,普通自编码器只练习过“解码编码器实际给出的潜变量”。它没有保证编码器的输出按标准正态分布排列;随机抽到的位置,可能根本不在它熟悉的区域。

这也是变分自编码器(VAE)要处理的问题之一。它让编码器给出一个分布,例如每个潜变量的均值 μ\boldsymbol{\mu} 和标准差 σ\boldsymbol{\sigma},再通过:

ϵ∼N(0,I)z=μ+σ⊙ϵ \begin{aligned} \boldsymbol{\epsilon}&\sim\mathcal{N}(\mathbf{0},I)\\ \mathbf{z}&=\boldsymbol{\mu} +\boldsymbol{\sigma}\odot\boldsymbol{\epsilon} \end{aligned}

得到潜变量。⊙\odot 表示逐元素相乘。训练既关心重建,也用 KL 散度约束编码分布与选定先验的差异,给“从先验抽样,再解码”建立依据。约束过强会妨碍重建,过弱又可能让抽样区域衔接不好。

本章实现的是普通自编码器,没有把图 1 的模型称为 VAE。VAE 的概率推导可以从 Kingma 与 Welling 的 Auto-Encoding Variational Bayes继续读;这里先记住:压缩表示和为生成安排潜空间,是相连但不同的两件事。

2. 一张数字图片,怎样变成噪声

生成图片还有另一条路。我们先学会把清晰图片逐渐弄乱,再训练一个网络判断怎样往清晰的方向走。

把原图记作 x0\mathbf{x}_0。下面用的像素范围改为 [−1,1][-1,1]:黑色接近 −1-1,白色接近 1。每一步加入独立的标准正态噪声:

xt=αtxt−1+βtϵtαt=1−βt,ϵt∼N(0,I) \begin{aligned} \mathbf{x}_t &=\sqrt{\alpha_t}\mathbf{x}_{t-1} +\sqrt{\beta_t}\boldsymbol{\epsilon}_t\\ \alpha_t&=1-\beta_t,\qquad \boldsymbol{\epsilon}_t\sim\mathcal{N}(\mathbf{0},I) \end{aligned}

tt 是第几步,βt\beta_t 决定这一小步加入多少噪声。原信号略微缩小,噪声略微加入;重复足够多次,笔画就被淹没了。

为什么系数带平方根?独立噪声的方差按系数的平方变化。这样安排,信号和新噪声的方差权重分别是 αt\alpha_t 与 βt\beta_t,而两者相加为 1。

训练时不必每次从第一步一路加到第 tt 步。把各步保留信号的比例乘起来:

αˉt=∏s=1tαs \bar{\alpha}_t=\prod_{s=1}^{t}\alpha_s

就能直接得到第 tt 步的条件分布采样:

xt=αˉtx0+1−αˉtϵ \mathbf{x}_t =\sqrt{\bar{\alpha}_t}\mathbf{x}_0 +\sqrt{1-\bar{\alpha}_t}\boldsymbol{\epsilon}

这里 ϵ\boldsymbol{\epsilon} 是一次新抽取的标准正态噪声,代表累积噪声。αˉt\bar{\alpha}_t 越小,原图的贡献越少。本例用 64 步的余弦噪声日程,末端约为 5.93×10−75.93\times10^{-7},已经很接近纯噪声的状态。

图 2:第一行用同一份噪声代入不同 tt 的直接采样公式,方便观察信号逐渐消失,并非连续执行的马尔可夫加噪轨迹。后两行是两种模型的实际生成中间状态;它们从新的噪声出发、指定生成 0,不是在恢复第一行的数字 3。显示时把数值映射到灰度并截到可显示范围。

3. 扩散模型在学习什么

加噪时,我们知道原图,也知道自己加入了哪些随机数。于是可以出一道训练题:

这是带噪的图片,现在处在第几步也告诉你。请估计其中的噪声。

把这个网络记作 ϵθ(xt,t,c)\epsilon_\theta(\mathbf{x}_t,t,c)。其中 cc 是条件,本章的小实验用数字类别,例如“画 3”。网络的输出仍然有 64 个数,分别预测每个像素位置的噪声。

训练目标很直接:

Lnoise=Ex0,t,ϵ[1P∥ϵ−ϵθ(xt,t,c)∥2] L_{\mathrm{noise}} =\mathbb{E}_{\mathbf{x}_0,t,\boldsymbol{\epsilon}} \left[\frac{1}{P} \left\|\boldsymbol{\epsilon} -\epsilon_\theta(\mathbf{x}_t,t,c)\right\|^2\right]

E\mathbb{E} 表示对随机选择的图片、时间和噪声取平均。实现中,每批重新抽样,用批次均方误差近似它。时间不能省略:同样一片灰色,在轻度污染和几乎纯噪声时,应该如何解释,很不一样。

注意,网络没有拿到当前样本的原图。如果不同原图和噪声可能组成相近的 xt\mathbf{x}_t,它不可能总把那一次噪声精确猜出来。它学到的是给定当前信息时有用的估计,并通过许多生成步骤逐渐形成图片。

推理时,从一张新的噪声开始

训练完毕后,我们不再提供原图。先抽取 xT\mathbf{x}_T,指定类别,再反复调用网络:

  1. 估计当前噪声。
  2. 用估计结果计算下一步的中心位置。
  3. 除最后一步外,再加入该步骤所需的随机扰动。

这是一种 DDPM 采样方式。模型每次处理整幅小图;这里并不是像语言模型那样,先固定左上角像素,再依次生成后面的像素。

选读:代码里的反向一步究竟怎么算?

先从加噪公式解出对原图的估计:

x^0=xt−1−αˉtϵ^αˉt \hat{\mathbf{x}}_0 =\frac{\mathbf{x}_t- \sqrt{1-\bar{\alpha}_t}\hat{\boldsymbol{\epsilon}}} {\sqrt{\bar{\alpha}_t}}

本例把这个估计截到 [−1,1][-1,1],再计算:

At=αˉt−1βt1−αˉtBt=αt(1−αˉt−1)1−αˉtVt=βt(1−αˉt−1)1−αˉtxt−1=Atx^0+Btxt+Vtz \begin{aligned} A_t&=\frac{\sqrt{\bar{\alpha}_{t-1}}\beta_t} {1-\bar{\alpha}_t}\\ B_t&=\frac{\sqrt{\alpha_t}(1-\bar{\alpha}_{t-1})} {1-\bar{\alpha}_t}\\ V_t&=\frac{\beta_t(1-\bar{\alpha}_{t-1})} {1-\bar{\alpha}_t}\\ \mathbf{x}_{t-1} &=A_t\hat{\mathbf{x}}_0+B_t\mathbf{x}_t +\sqrt{V_t}\mathbf{z} \end{aligned}

z\mathbf{z} 是新抽取的标准正态噪声,αˉ0=1\bar{\alpha}_0=1。因此 t=1t=1 时 Vt=0V_t=0,最后一步不再添加随机扰动。

这些系数来自已知原图时高斯加噪过程的后验分布;生成时把未知原图替换为网络的估计。这里只使用固定后验方差,没有训练网络去预测方差。对 x^0\hat{\mathbf{x}}_0 的截断也是具体实现选择,不应悄悄从复现条件里省掉。

DDPM 原论文给出了这一训练与采样思路;Improved DDPM讨论了噪声日程等改进。本章只取便于拆解的小型配置,不复现论文的大图像实验,也不借用其质量指标作为自己的结果。

4. 条件怎样进入画面

在小实验里,类别编号先经过嵌入层变成 16 个数,再和当前图片、时间特征拼在一起送入网络。网络既要看“现在画成什么样”,也要知道“我们想画什么”。

文字条件则通常需要文本编码器。文本产生一串表示,图像网络通过交叉注意力等方式读取它们。上一章是图像表示帮助语言模型回答;现在是文字表示帮助生成网络决定图片该往哪里变。

条件也可以是一张参考图、轮廓、深度或空间布局。但条件能够输入,不等于每一条约束都能严格满足。“红色”“三个”“每个都写 TEA”会给模型带来不同的困难。

“更听话”的引导,为什么不是越大越好

一种常见办法叫无分类器引导(classifier-free guidance,CFG)。训练时,部分样本不提供条件,让模型同时学会有条件和无条件的估计。推理时组合两者:

ϵu=ϵθ(xt,t,∅)ϵc=ϵθ(xt,t,c)ϵguided=ϵu+s(ϵc−ϵu) \begin{aligned} \epsilon_u&=\epsilon_\theta(\mathbf{x}_t,t,\varnothing)\\ \epsilon_c&=\epsilon_\theta(\mathbf{x}_t,t,c)\\ \epsilon_{\mathrm{guided}} &=\epsilon_u+s(\epsilon_c-\epsilon_u) \end{aligned}

∅\varnothing 表示空条件,ss 是这里定义的引导强度。s=1s=1 时得到有条件估计;s>1s>1 时沿两种估计的差异再走远一些。它可能加强条件影响,也可能损失多样性或产生过强的色彩、纹理;不是把旋钮拧大就必然更好。

具体机制与权衡见 Classifier-Free Diffusion Guidance。本章的小数字模型始终接收类别,没有训练无条件分支,因此代码不执行 CFG。后面的 SD-Turbo 又有自己的蒸馏训练与调用约定,也不能机械套用上式中 ss 的含义。

5. 流匹配:另一种从噪声走到图片的方法

刚才的网络学习预测噪声。现在换一道题:如果把噪声和图片之间铺上一条路,让网络学习此时此地应该朝哪个方向走、多快走,会怎样?

先独立抽取一份噪声 z\mathbf{z} 和一张真实图片 x\mathbf{x},连接两端:

xt=(1−t)z+tx,0≤t≤1 \mathbf{x}_t=(1-t)\mathbf{z}+t\mathbf{x}, \qquad 0\leq t\leq1

这一节改用连续时间:t=0t=0 是噪声,t=1t=1 是图片。它与前面“步数越大、噪声越多”的编号方向相反,阅读代码时要先看约定。

沿这条直线走,速度是恒定的:

u=x−z \mathbf{u}=\mathbf{x}-\mathbf{z}

用一个数试试:起点是 −1-1,终点是 3,总时间为 1,那么速度就是 4。走到 t=0.25t=0.25 时,位置为 0;再走相同的一小段时间,位置为 1。

训练网络 vθ(xt,t,c)v_\theta(\mathbf{x}_t,t,c) 去预测速度:

Lflow=E[1P∥vθ(xt,t,c)−(x−z)∥2] L_{\mathrm{flow}} =\mathbb{E}\left[ \frac{1}{P}\left\| v_\theta(\mathbf{x}_t,t,c)-(\mathbf{x}-\mathbf{z}) \right\|^2\right]

这就是本章使用的简单条件流匹配目标。它用随机配对的噪声与数据构造路径,没有额外求解最优传输配对。

生成时,我们仍然不知道终点图片,只拿到一份新的噪声。每一步问网络当前速度,再往前走:

xt+Δt≈xt+Δt vθ(xt,t,c) \mathbf{x}_{t+\Delta t} \approx\mathbf{x}_t+ \Delta t\,v_\theta(\mathbf{x}_t,t,c)

这叫前向欧拉法。若分成 32 步,Δt=1/32\Delta t=1/32,每走一步便重新估计速度。

这里有一个很容易误会的地方:训练用直线连接端点,不代表生成时沿着某条已知直线前进。 许多随机配对会经过附近位置,网络只能根据当前状态、时间和条件给出估计。它学到的整体速度场,可能使生成轨迹弯曲;具体误差还与模型、训练和数值步长有关。

图 2 的第三行展示了真实轨迹。流匹配每步没有额外抽噪声,但起点是随机的,因此仍能产生不同样本。扩散与流匹配也不是两个毫无关系的世界:它们都涉及如何连接噪声分布与数据分布,只是这里选择了不同路径、训练目标和采样算法。更完整的视角可读 Flow Matching for Generative Modeling。

6. 把三个小实验跑起来

数据继续使用第十五章的手写数字:训练 1077 张、验证 360 张、测试 360 张,划分完全相同。来源为 Alpaydin 与 Kaynak 的 UCI Optical Recognition of Handwritten Digits,由 scikit-learn 提供 8×8 图像,数据页标示 CC BY 4.0。这个划分已在前面多次使用,是公开的教学与回归数据,不是尚未见过的独立研究基准。

配置在运行前固定如下:

项目自编码器DDPM / 流匹配
输入[0,1][0,1] 的 64 个像素[−1,1][-1,1] 的 64 个像素、时间、类别
网络64→32→8→32→6464\to32\to8\to32\to64两层宽度 128 的隐藏层
参数量4744各 36448
训练轮数上限80各 120
优化器Adam,学习率 0.002相同
批量大小128相同
选择依据验证重建 MSE各自的验证目标 MSE

三个模型独立训练。这里的 DDPM 和流匹配直接生成 64 个像素,没有使用前面自编码器的 8 维潜变量;后面的预训练模型才展示潜空间生成。

网络使用 SiLU 激活;自编码器的最后一层使用 Sigmoid。时间先转成原始时间及四组正弦、余弦特征,与类别嵌入一起输入网络。两种生成网络的验证图片、噪声和时间在训练前固定,避免每轮换题造成选择指标的额外波动;训练批次则持续抽取新噪声。

在仓库根目录运行:

uv run --python 3.12 --with-requirements requirements.txt \
  python src/examples/image_generation_basics.py

这条命令会实际从零训练三个模型,再生成和评价图片。CPU 即可运行。本次在 macOS arm64、Python 3.12.12、PyTorch 2.14.1、NumPy 2.5.4 的单线程环境中,脚本内部计时约 2 秒,不含启动和导入依赖;其他设备耗时会不同。

需要保存本次全部结果时,加上:

uv run --python 3.12 --with-requirements requirements.txt \
  python src/examples/image_generation_basics.py \
  --out runs/image-generation-basics.json

脚本复用 digits_project.py 的数据划分,完整实现也可以下载查看。

展开完整的小模型实验代码
"""第 42 章:8×8 数字上的自编码、条件 DDPM 与条件流匹配;CPU 从零训练。"""

from argparse import ArgumentParser
from copy import deepcopy
from hashlib import sha256
import json
from math import pi
from pathlib import Path
import platform
from time import perf_counter

import numpy as np
from sklearn.linear_model import LogisticRegression
import torch
from torch import nn

from digits_project import make_data


STEPS = 64
EPOCHS = {"autoencoder": 80, "ddpm": 120, "flow": 120}


def tensor_hash(tensor):
    return sha256(tensor.detach().cpu().contiguous().numpy().tobytes()).hexdigest()


def schedule(steps=STEPS):
    # 余弦 alpha_bar 曲线;限制最后一步 beta,避免除以零。
    grid = torch.arange(steps + 1, dtype=torch.float64) / steps
    curve = torch.cos((grid + 0.008) / 1.008 * pi / 2).square()
    curve = curve / curve[0]
    beta = (1 - curve[1:] / curve[:-1]).clamp(max=0.999).float()
    alpha_bar = torch.cat([torch.ones(1), (1 - beta).cumprod(0)])
    return beta, alpha_bar


def add_noise(clean, noise, level, alpha_bar):
    a = alpha_bar[level, None]
    return a.sqrt() * clean + (1 - a).sqrt() * noise


def flow_pair(clean, noise, time):
    return (1 - time[:, None]) * noise + time[:, None] * clean, clean - noise


class Autoencoder(nn.Module):
    def __init__(self):
        super().__init__()
        self.encoder = nn.Sequential(nn.Linear(64, 32), nn.SiLU(), nn.Linear(32, 8))
        self.decoder = nn.Sequential(nn.Linear(8, 32), nn.SiLU(),
                                     nn.Linear(32, 64), nn.Sigmoid())

    def forward(self, images):
        return self.decoder(self.encoder(images))


class Field(nn.Module):
    def __init__(self):
        super().__init__()
        self.labels = nn.Embedding(10, 16)
        self.network = nn.Sequential(nn.Linear(64 + 9 + 16, 128), nn.SiLU(),
                                     nn.Linear(128, 128), nn.SiLU(),
                                     nn.Linear(128, 64))

    def forward(self, images, time, labels):
        angles = time[:, None] * (2 * pi * 2 ** torch.arange(4))
        clock = torch.cat([time[:, None], angles.sin(), angles.cos()], dim=1)
        return self.network(torch.cat([images, clock, self.labels(labels)], dim=1))


def training_pair(kind, clean, generator, alpha_bar):
    noise = torch.randn(clean.shape, generator=generator)
    if kind == "ddpm":
        level = torch.randint(1, STEPS + 1, (len(clean),), generator=generator)
        return add_noise(clean, noise, level, alpha_bar), level / STEPS, noise
    time = torch.rand(len(clean), generator=generator)
    mixed, velocity = flow_pair(clean, noise, time)
    return mixed, time, velocity


def train(kind, training, validation):
    torch.manual_seed(2026 if kind == "autoencoder" else 2030)
    model = Autoencoder() if kind == "autoencoder" else Field()
    optimizer = torch.optim.Adam(model.parameters(), lr=0.002)
    generator = torch.Generator().manual_seed(3031)
    _, alpha_bar = schedule()
    x, y = training
    vx, vy = validation
    if kind != "autoencoder":
        x, vx = 2 * x - 1, 2 * vx - 1
        validation_pair = training_pair(
            kind, vx, torch.Generator().manual_seed(3030), alpha_bar)
    best, history = float("inf"), []
    for epoch in range(1, EPOCHS[kind] + 1):
        model.train()
        total = 0.0
        for ids in torch.randperm(len(x), generator=generator).split(128):
            optimizer.zero_grad(set_to_none=True)
            if kind == "autoencoder":
                output, target = model(x[ids]), x[ids]
            else:
                mixed, time, target = training_pair(kind, x[ids], generator, alpha_bar)
                output = model(mixed, time, y[ids])
            loss = (output - target).square().mean()
            if not torch.isfinite(loss):
                raise RuntimeError("训练出现非有限损失")
            loss.backward()
            optimizer.step()
            total += loss.item() * len(ids)
        model.eval()
        with torch.no_grad():
            if kind == "autoencoder":
                loss = (model(vx) - vx).square().mean().item()
            else:
                mixed, time, target = validation_pair
                loss = (model(mixed, time, vy) - target).square().mean().item()
        history.append([total / len(x), loss])
        if loss < best:
            best, best_epoch, state = loss, epoch, deepcopy(model.state_dict())
    model.load_state_dict(state)
    model.eval()
    fingerprint = sha256(b"".join(
        value.numpy().tobytes() for value in state.values())).hexdigest()
    return model, {"epoch": best_epoch, "validation_mse": best,
                   "history": history, "parameters": sum(p.numel() for p in model.parameters()),
                   "weights_sha256": fingerprint}


def reverse_step(noisy, epsilon, step, beta, alpha_bar, noise):
    b, current, previous = beta[step - 1], alpha_bar[step], alpha_bar[step - 1]
    clean = ((noisy - (1 - current).sqrt() * epsilon) / current.sqrt()).clamp(-1, 1)
    mean = (previous.sqrt() * b * clean
            + (1 - b).sqrt() * (1 - previous) * noisy) / (1 - current)
    variance = b * (1 - previous) / (1 - current)
    return mean + variance.sqrt() * noise


@torch.no_grad()
def sample(kind, model, labels, steps=32, seed=4040):
    generator = torch.Generator().manual_seed(seed)
    x = torch.randn((len(labels), 64), generator=generator)
    initial_hash = tensor_hash(x)
    snapshots = [x[0].tolist()]
    positions = [0]
    if kind == "ddpm":
        beta, alpha_bar = schedule()
        for step in range(STEPS, 0, -1):
            time = torch.full((len(x),), step / STEPS)
            epsilon = model(x, time, labels)
            noise = torch.randn(x.shape, generator=generator) if step > 1 else torch.zeros_like(x)
            x = reverse_step(x, epsilon, step, beta, alpha_bar, noise)
            if step in [49, 33, 17, 1]:
                snapshots.append(x[0].tolist())
                positions.append(STEPS - step + 1)
    else:
        for step in range(steps):
            time = torch.full((len(x),), step / steps)
            x = x + model(x, time, labels) / steps
            if (step + 1) % (steps // 4) == 0:
                snapshots.append(x[0].tolist())
                positions.append(step + 1)
    return ((x + 1) / 2).clamp(0, 1), {
        "initial_noise_sha256": initial_hash, "snapshots": snapshots,
        "snapshot_steps": positions, "evaluations": STEPS if kind == "ddpm" else steps,
        "raw_outside_fraction": ((x < -1) | (x > 1)).float().mean().item()}


def run():
    started = perf_counter()
    torch.set_num_threads(1)
    torch.use_deterministic_algorithms(True)
    raw, ids = make_data()
    data = [(x.flatten(1).float() / 16, y) for x, y in raw]
    training, validation, test = data
    report = {"split_ids": [index.tolist() for index in ids],
              "environment": {"python": platform.python_version(),
                              "torch": torch.__version__, "numpy": np.__version__,
                              "platform": platform.platform(), "threads": 1},
              "config": {"epochs": EPOCHS, "batch_size": 128, "lr": 0.002,
                         "ddpm_steps": STEPS, "flow_steps": 32,
                         "sample_seed": 4040, "train_seed": 3031,
                         "validation_seed": 3030},
              "source_sha256": sha256(Path(__file__).read_bytes()).hexdigest(),
              "data_sha256": [tensor_hash(x) for x, _ in data]}
    ae, record = train("autoencoder", training, validation)
    with torch.no_grad():
        reconstruction = ae(test[0])
        record["test_mse"] = (reconstruction - test[0]).square().mean().item()
        record["mean_baseline_mse"] = (
            training[0].mean(0) - test[0]).square().mean().item()
        chosen = [int(torch.where(test[1] == label)[0][0]) for label in range(10)]
        record["image_ids"] = ids[2][chosen].tolist()
        record["originals"] = test[0][chosen].tolist()
        record["reconstructions"] = reconstruction[chosen].tolist()
        random_latent = torch.randn((10, 8), generator=torch.Generator().manual_seed(4041))
        record["random_latent_decodes"] = ae.decoder(random_latent).tolist()
    report["autoencoder"] = record
    classifier = LogisticRegression(C=1, max_iter=1000).fit(*[v.numpy() for v in training])
    report["classifier_real_accuracy"] = float(classifier.score(
        test[0].numpy(), test[1].numpy()))
    labels = torch.arange(10).repeat_interleave(10)
    report["sample_labels"] = labels.tolist()
    for kind in ["ddpm", "flow"]:
        model, record = train(kind, training, validation)
        samples, trajectory = sample(kind, model, labels)
        predicted = classifier.predict(samples.numpy())
        record.update(trajectory)
        record.update({"samples": samples.tolist(), "predicted": predicted.tolist(),
                       "condition_correct": int((predicted == labels.numpy()).sum())})
        report[kind] = record
    _, alpha_bar = schedule()
    clean = 2 * test[0][chosen[3]:chosen[3] + 1] - 1
    noise = torch.randn(clean.shape, generator=torch.Generator().manual_seed(4042))
    report["forward"] = {"image_id": int(ids[2][chosen[3]]), "levels": [0, 16, 32, 48, 64],
                         "images": [add_noise(clean, noise, torch.tensor([i]), alpha_bar)[0].tolist()
                                    for i in [0, 16, 32, 48, 64]],
                         "alpha_bar": alpha_bar.tolist()}
    report["elapsed_seconds"] = perf_counter() - started
    return report


def show(report):
    ae = report["autoencoder"]
    print("训练/验证/测试:1077 / 360 / 360;图像:8×8")
    print(f"自编码器:最佳轮次={ae['epoch']},测试 MSE={ae['test_mse']:.4f}")
    print(f"训练均值图基线 MSE={ae['mean_baseline_mse']:.4f}")
    print(f"代理分类器真实测试准确率={report['classifier_real_accuracy']:.3f}")
    for kind in ["ddpm", "flow"]:
        row = report[kind]
        print(f"{kind}:最佳轮次={row['epoch']},验证目标 MSE={row['validation_mse']:.4f},"
              f"条件识别={row['condition_correct']}/100,采样调用={row['evaluations']}")
    print("条件识别只检查生成数字是否被分类器认作指定类别,不代表整体生成质量。")


if __name__ == "__main__":
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--out", type=Path, help="保存本次完整训练、像素和评价记录")
    args = parser.parse_args()
    result = run()
    if args.out:
        args.out.parent.mkdir(parents=True, exist_ok=True)
        args.out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n")
    show(result)

本次运行输出为:

训练/验证/测试:1077 / 360 / 360;图像:8×8
自编码器:最佳轮次=80,测试 MSE=0.0196
训练均值图基线 MSE=0.0733
代理分类器真实测试准确率=0.975
ddpm:最佳轮次=116,验证目标 MSE=0.2826,条件识别=98/100,采样调用=64
flow:最佳轮次=120,验证目标 MSE=0.5916,条件识别=95/100,采样调用=32
条件识别只检查生成数字是否被分类器认作指定类别,不代表整体生成质量。

自编码器比“对每张输入都输出训练集平均图”的基线误差更小,说明它保留了输入相关的信息。但图 1 也提醒我们,较低的重建误差并不自动保证随机潜变量能生成清楚的数字。

两种生成方法各画 100 张,0~9 每类十张。起始噪声使用相同种子 4040;DDPM 的反向过程还会额外抽噪声,流匹配则使用 32 次欧拉更新。下图取每类生成顺序中的第一张:

图 3:标题中的箭头表示“指定类别 → 分类器识别”。按类别和生成顺序直接选取,未筛选最好看的样本。展示与评分前,最终像素统一映射并截到 [0,1][0,1]。

“条件识别”由一个只在真实训练集上拟合的逻辑回归分类器给出。它在真实测试图上的准确率为 0.975,但生成图可能偏离真实数据分布,所以它也会误判。下图保留了本次全部七个条件识别未通过的样本:

图 4:编号从 0 开始,对应各模型保存的 100 张生成图。这里的“失败”专指分类器未认作指定数字;请同时观察图像,不要把分类器判断当成人类一致结论。

例如 DDPM 的第 36 张本想画 3,却被认作 9;流匹配的第 30 张也想画 3,却被认作 2。笔画连接、开口与灰度变化都可能影响识别。

我们能据此宣布 DDPM 更好吗?还不能。它用了 64 次网络调用,流匹配用了 32 次;这是两份教学配置,只有一个训练种子,目标的数值尺度也不同。0.2826 的噪声 MSE 和 0.5916 的速度 MSE,不能直接当成同一种考试分数比较。

完整记录保存了划分 ID、参数量、每轮损失、选中轮次、种子、权重与源码指纹、全部 200 张生成图的像素和预测结果。你可以据此重新作图,也可以另存一次新运行,观察复现差异。

7. 从小数字走到文字生成图片

如果把 512×512 的彩色图片直接展开,网络要处理 512×512×3=786432512\times512\times3=786432 个数。实际模型会用卷积、注意力等结构组织计算,还经常借助第一节的思路:先在一个较小的潜空间里生成,最后再解码成像素。

这就是潜空间扩散的一种基本流程:

图片编码器把训练图变成潜表示 → 在潜表示上学习去噪 → 生成时从潜噪声出发 → 图片解码器把结果还原为像素。

文本编码器提供条件,图像生成网络读取这些条件。这里的潜表示通常仍保留空间布局,不是一个孤零零的类别向量。例如本节模型的初始潜噪声形状为 (1,4,64,64)(1,4,64,64),分别对应批次、通道、高度、宽度;最终解码得到 512×512 的图片。每个潜通道也不是直接对应红、绿、蓝。

Latent Diffusion Models把压缩表示与条件生成结合起来。压缩会丢失信息,解码器的能力也限制最终图像;把生成搬到潜空间,并不意味着所有细节都能完整保存。

一步生成,靠的不只是少写几个循环

这里选择真实预训练模型 SD-Turbo。它由 Stable Diffusion 2.1 蒸馏而来,采用 Adversarial Diffusion Distillation,针对很少的采样步骤训练。它的一步结果不能用来说明“普通 DDPM 随便删到一步也一样好”。

我们依照该模型的使用说明,使用 512×512、guidance_scale=0.0,不传负面提示。这个配置仍然使用文字条件;0 表示关闭此管线的常规 CFG 放大,不能套用前面通用 CFG 公式来断言它变成了无条件生成。

模型修订固定为 b261bac6fd2cf515557d5d0707481eafa0485ec2。下载三个 FP16 权重文件共 2,580,060,470 字节,加载后在 CPU 上使用 FP32 运算;文本编码器、U-Net、VAE 合计约 12.90 亿参数。仅 FP32 参数约需 5.16 GB,运行时还要留出激活和临时张量空间。本次在 18 GiB 内存机器上完成。

这部分实验使用 SD-Turbo,Powered by Stability AI。权重不随书分发,使用条件见该修订的 Stability AI Community License;不要把它与本书的小型自训练网络混为一谈。

六次对照,一次写好再运行

先固定两条仅颜色不同的提示:

A studio photograph of one red ceramic mug on a plain white table, soft natural light.
A studio photograph of one blue ceramic mug on a plain white table, soft natural light.

用种子 2026 和 2027 各生成一次,均为一步。接着对“红杯、2026”增加一个四步版本。最后增加一条有明确数量和文字要求的提示:

A studio photograph of exactly three red ceramic mugs on a white table, each labeled TEA in clear black letters.

最后这条使用种子 2026、一步生成。六种配置在运行前固定,没有看到图片后改提示、换种子或挑选成功图片。

图 5:全部六次输出。每一行从左到右阅读;同一种子的标准正态潜噪声及缩放后的起始潜变量指纹一致。展示的是模型生成图片,不是实物照片。

看图时,先把“符合要求”和“我觉得漂亮”分开:

  • 颜色:前四张都出现了与提示大致对应的红色或蓝色杯体。
  • 构图与结构:种子 2026 的红杯被左侧边界截断,蓝杯却较完整。种子 2027 的蓝杯出现左右两个把手。改一个颜色词,其他结构也可能变化。
  • 步数:同为红杯、种子 2026,四步版本仍被截断,背景纹理更粗糙。仅这一对图片不能推出“步数越多越差”或“越多越好”。
  • 数量和文字:最后一张可以数到五只杯子,其中两只位于上方;它没有满足“三只”的条件,也没有清楚、统一地写出 TEA。

这就是固定种子的实际意义:它控制了一部分随机性,方便比较配置,不会把模型变成只修改某个指定属性的图片编辑器。

运行与保留证据

先只核对本书保存的六次结果,不需要加载模型:

python3 src/examples/pretrained_image_generation.py

输出为:

模型:stabilityai/sd-turbo;revision=b261bac6fd2cf515557d5d0707481eafa0485ec2
已保存 6 次真实生成:512×512,CPU float32,guidance_scale=0
red-2026-1:1 步,seed=2026,图像指纹通过
blue-2026-1:1 步,seed=2026,图像指纹通过
red-2027-1:1 步,seed=2027,图像指纹通过
blue-2027-1:1 步,seed=2027,图像指纹通过
red-2026-4:4 步,seed=2026,图像指纹通过
three-tea-2026-1:1 步,seed=2026,图像指纹通过
同一种子各组的起始噪声一致;默认模式只核对记录,不重新生成。

要实际重新生成,使用一个新的输出目录:

uv run --python 3.12 \
  --with-requirements src/examples/requirements-generation.txt \
  python src/examples/pretrained_image_generation.py \
  --run --out runs/sd-turbo-new

首次运行会下载模型;后续使用本地缓存。可选环境固定 Diffusers 0.35.2、Transformers 4.57.3、Accelerate 1.11.0 与 PyTorch 2.14.1。脚本使用 CPU、FP32、4 线程和默认 EulerDiscreteScheduler,一步的时间点为 999,四步为 999、749、499、249。

本次五个一步调用耗时约 9.95~15.63 秒,四步调用约 17.72 秒。计时包括文本编码、生成与图像解码,不含下载、模型加载和写 PNG;没有专门做重复预热计时,因此这些数用于估计本次实验成本,不能当作稳定的速度基准。

完整的原始记录包含提示、种子、软件版本、调度器配置、模型文件指纹、起始潜变量指纹、实际耗时和输出文件指纹。六张原图也可分别打开:红杯 2026、蓝杯 2026、红杯 2027、蓝杯 2027、四步红杯、数量与文字。跨设备或软件版本运行时,即使种子相同,也不应承诺逐像素一致。

展开预训练模型实验代码
"""第 42 章:真实 SD-Turbo 条件生成。默认核对已保存记录;--run 才加载模型。"""

from argparse import ArgumentParser
from hashlib import sha256
import json
from pathlib import Path
import platform
import struct
from time import perf_counter


MODEL = "stabilityai/sd-turbo"
REVISION = "b261bac6fd2cf515557d5d0707481eafa0485ec2"
DEFAULT = Path(__file__).parent / "data" / "sd-turbo" / "report.json"
PROMPT = "A studio photograph of one {} ceramic mug on a plain white table, soft natural light."
CASES = [
    {"id": f"{color}-{seed}-1", "prompt": PROMPT.format(color),
     "seed": seed, "steps": 1}
    for seed in [2026, 2027] for color in ["red", "blue"]
] + [
    {"id": "red-2026-4", "prompt": PROMPT.format("red"), "seed": 2026, "steps": 4},
    {"id": "three-tea-2026-1", "prompt": (
        "A studio photograph of exactly three red ceramic mugs on a white table, "
        "each labeled TEA in clear black letters."), "seed": 2026, "steps": 1},
]


def file_hash(path):
    digest = sha256()
    with Path(path).open("rb") as source:
        for chunk in iter(lambda: source.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def validate(path=DEFAULT):
    report = json.loads(path.read_text())
    assert report["model"] == MODEL and report["revision"] == REVISION
    assert report["source_sha256"] == file_hash(__file__), "记录对应的脚本版本已改变"
    assert report["cases"] == CASES and len(report["outputs"]) == len(CASES)
    for case, row in zip(CASES, report["outputs"]):
        assert all(row[k] == v for k, v in case.items())
        filename = path.parent / row["image"]
        assert filename.name == case["id"] + ".png"
        assert file_hash(filename) == row["image_sha256"]
        header = filename.read_bytes()[:24]
        assert header[:8] == b"\x89PNG\r\n\x1a\n"
        assert struct.unpack(">II", header[16:24]) == (512, 512)
        assert len(row["timesteps"]) == case["steps"] and row["seconds"] > 0
    for seed in [2026, 2027]:
        group = [r for r in report["outputs"] if r["seed"] == seed]
        assert len({r["noise_sha256"] for r in group}) == 1
        assert len({r["initial_latent_sha256"] for r in group}) == 1
    return report


def run(folder):
    # 所有模型推理依赖放在显式运行分支,默认检查只需标准库。
    import accelerate
    import diffusers
    import numpy as np
    import torch
    import transformers
    from diffusers import StableDiffusionPipeline
    from huggingface_hub import snapshot_download

    report_path = folder / "report.json"
    if report_path.exists() or any(folder.glob("*.png")):
        raise ValueError("输出目录已有实验结果,请换一个空目录以保留原始记录")
    folder.mkdir(parents=True, exist_ok=True)
    torch.set_num_threads(4)
    torch.use_deterministic_algorithms(True)
    snapshot = Path(snapshot_download(
        MODEL, revision=REVISION, allow_patterns=[
            "model_index.json", "scheduler/*.json", "text_encoder/config.json",
            "text_encoder/model.fp16.safetensors", "tokenizer/*", "unet/config.json",
            "unet/diffusion_pytorch_model.fp16.safetensors", "vae/config.json",
            "vae/diffusion_pytorch_model.fp16.safetensors", "README.md", "LICENSE.md"]))
    pipeline = StableDiffusionPipeline.from_pretrained(
        snapshot, variant="fp16", torch_dtype=torch.float32,
        local_files_only=True, use_safetensors=True).to("cpu")
    pipeline.set_progress_bar_config(disable=True)
    report = {
        "model": MODEL, "revision": REVISION, "cases": CASES, "outputs": [],
        "source_sha256": file_hash(__file__),
        "environment": {"python": platform.python_version(), "platform": platform.platform(),
                        "torch": torch.__version__, "numpy": np.__version__,
                        "diffusers": diffusers.__version__, "transformers": transformers.__version__,
                        "accelerate": accelerate.__version__, "device": "cpu",
                        "dtype": "float32", "threads": 4},
        "settings": {"height": 512, "width": 512, "guidance_scale": 0.0,
                     "variant": "fp16", "negative_prompt": None},
        "scheduler": type(pipeline.scheduler).__name__,
        "scheduler_config": dict(pipeline.scheduler.config),
        "weights": [{"file": str(p.relative_to(snapshot)), "bytes": p.stat().st_size,
                     "sha256": file_hash(p)} for p in sorted(snapshot.rglob("*.safetensors"))],
        "license_sha256": file_hash(snapshot / "LICENSE.md"),
        "parameters": {name: sum(p.numel() for p in getattr(pipeline, name).parameters())
                       for name in ["text_encoder", "unet", "vae"]},
    }

    def tensor_hash(tensor):
        return sha256(tensor.contiguous().numpy().tobytes()).hexdigest()

    for case in CASES:
        generator = torch.Generator(device="cpu").manual_seed(case["seed"])
        noise = torch.randn((1, 4, 64, 64), generator=generator, dtype=torch.float32)
        pipeline.scheduler.set_timesteps(case["steps"])
        initial = noise * pipeline.scheduler.init_noise_sigma
        started = perf_counter()
        with torch.inference_mode():
            result = pipeline(prompt=case["prompt"], height=512, width=512,
                              num_inference_steps=case["steps"], guidance_scale=0.0,
                              latents=noise.clone(), generator=generator)
        elapsed = perf_counter() - started
        filename = folder / (case["id"] + ".png")
        result.images[0].save(filename)
        row = {**case, "image": filename.name, "image_sha256": file_hash(filename),
               "noise_sha256": tensor_hash(noise),
               "initial_latent_sha256": tensor_hash(initial),
               "init_noise_sigma": float(pipeline.scheduler.init_noise_sigma),
               "timesteps": pipeline.scheduler.timesteps.tolist(), "seconds": elapsed,
               "prompt_tokens": pipeline.tokenizer(case["prompt"]).input_ids,
               "nsfw_content_detected": result.nsfw_content_detected}
        report["outputs"].append(row)
        report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
        print(f"{case['id']}:完成,{elapsed:.2f} 秒", flush=True)
    validate(report_path)
    return report


def show(report):
    print(f"模型:{report['model']};revision={report['revision']}")
    print("已保存 6 次真实生成:512×512,CPU float32,guidance_scale=0")
    for row in report["outputs"]:
        print(f"{row['id']}:{row['steps']} 步,seed={row['seed']},图像指纹通过")
    print("同一种子各组的起始噪声一致;默认模式只核对记录,不重新生成。")


if __name__ == "__main__":
    parser = ArgumentParser(description=__doc__)
    parser.add_argument("--run", action="store_true", help="下载/加载固定模型并实际生成")
    parser.add_argument("--out", type=Path, help="真实生成使用的空输出目录")
    parser.add_argument("--record", type=Path, default=DEFAULT, help="默认核对的 JSON 路径")
    args = parser.parse_args()
    if args.run and args.out is None:
        parser.error("--run 需要 --out 指定一个空目录")
    show(run(args.out) if args.run else validate(args.record))

也可以下载脚本检查输入如何固定、输出如何保存。它不会把生成错误的图片删掉,也不会用自动评分改写图片内容。

8. 生成得好不好,该看什么

重建任务有明确的原图,可以逐像素比较。开放式生成则不同:同一句“红色杯子”,合理的杯子形状、角度、光照本来就有很多种。拿某一张参考照片逐像素算 MSE,可能惩罚一个完全合理的新构图。

可以把问题分成几条:

关心什么可以怎样检查单独使用时会漏掉什么
视觉质量观察边缘、结构、纹理、遮挡漂亮图片也可能不符合提示
条件符合程度检查颜色、数量、位置、文字等具体要求满足条件也可能模糊或重复
多样性同一提示用多个种子生成,观察构图与细节分布变化很大也可能只是错误很多
与真实分布的接近程度在统一协议下比较一批生成与真实图片的特征分布不一定检查每条提示的要求
记忆与近似复制与训练数据或可用参考库做近邻检索并人工核对找不到近邻不代表已经排除记忆

论文中常见 FID,由 Heusel 等人在这篇论文中提出。它在 Inception 特征空间里,用均值和协方差概括真实图片与生成图片,再比较两者。它评价的是一批样本的分布差异,不是单张图的“艺术分”,也不直接检查杯子是否恰好有三只。

比较 FID 时,要同时报告参考数据、生成样本数、特征提取与预处理协议。样本少、数据域不合适或预处理不同,分数就很难直接比较。本章没有给 8×8 数字或六张杯子计算一个貌似正式的 FID。

图文相似度模型可以帮助检查大体语义,但“都是杯子”和“恰好三只杯子,每只都有 TEA”相差很远。模型评分也可能对某些构图或风格有偏好。实际项目里,可以事先拆出条件清单,盲评多个种子,记录分歧,再把自动指标与人工观察一起报告。

本章的 98/100、95/100 只回答了一个有限问题:指定数字有没有被这个分类器认出来。六张 SD-Turbo 图片则只是受控示例,不是模型能力排行榜。想研究某种方法是否更好,还需要更多提示、种子、相同预算和独立评估。

9. 亲手改变一次实验

  1. 自编码器把 64 个像素变成 8 个潜变量,是否可以直接宣称存储空间缩小八倍?为什么?
  2. 如果普通自编码器重建误差很小,为什么随机标准正态潜变量仍可能生成奇怪的图?
  3. 当 αˉt=0.36\bar{\alpha}_t=0.36 时,直接加噪公式中原图和噪声的系数各是多少?若某个像素 x0=0.5x_0=0.5、噪声为 −1-1,结果是多少?
  4. DDPM 训练时为什么既要输入带噪图片,也要输入时间?知道加进去的噪声,是否意味着推理时也知道真实噪声?
  5. 在本章通用 CFG 写法中,取 ϵu=2\epsilon_u=2、ϵc=1\epsilon_c=1、s=3s=3,得到多少?为什么这不是两个结果之间的普通加权平均?
  6. 流匹配中取 z=−1z=-1、x=3x=3。求 t=0.25t=0.25 的位置与目标速度,再用步长 0.25 走到终点。
  7. 为什么不能比较 0.2826 与 0.5916,就判断两个生成模型中谁更好?
  8. 想公平比较 32 步与 64 步流匹配,训练好的网络是否需要改变?应固定哪些条件,记录哪些结果?
  9. 如果把前四张杯子中的两张漂亮图片留下,其余不展示,会怎样影响别人对结果的判断?设计一个更可靠的颜色与数量评价方案。
  10. 在自己的新输出目录中增加一个种子。保持提示、模型和采样步骤不变,保存完整图片与参数;描述变化,也说明这次观察不能证明什么。
展开参考答案与实验提示
  1. 不能。这只说明表示维度减少;原图与潜变量的数值精度、存储格式、压缩编码和模型大小都影响实际空间。
  2. 训练只约束经过编码器得到的潜变量。随机先验未必与这些表示的分布一致,解码器可能收到不熟悉的位置。
  3. 两个系数是 0.36=0.6\sqrt{0.36}=0.6 与 0.64=0.8\sqrt{0.64}=0.8;结果为 0.6×0.5+0.8×(−1)=−0.50.6\times0.5+0.8\times(-1)=-0.5。
  4. 时间说明信号与噪声的比例。训练时噪声由我们抽取,可作监督目标;生成时只有当前状态,网络预测的是噪声估计,不会拿到目标原图对应的“正确答案”。
  5. 2+3(1−2)=−12+3(1-2)=-1。当 s>1s>1 时发生外推,结果可以超出两种估计之间的区间。
  6. 位置为 (1−0.25)(−1)+0.25×3=0(1-0.25)(-1)+0.25\times3=0,速度为 4。每步前进 1,依次到 0、1、2、3。这是已知端点的恒定速度例子;实际生成中的速度由网络重新估计。
  7. 两种损失预测的量不同,不能直接比较数值;采样调用次数也不同。还要区分条件符合、质量、多样性和计算成本,并使用多个种子。
  8. 可以先固定同一个训练好且已按验证规则选中的网络,再改变欧拉步数。固定标签、初始噪声和显示处理,记录全部样本、条件检查、时间和网络调用次数。步数更多只减少某些离散误差,不保证弥补模型学错的速度场。
  9. 筛选会隐藏失败率。可以预先固定提示集合、种子与数量要求,把全部输出编号,按颜色、杯子数量、把手结构等项目逐项盲评,保存分歧和原图,避免把一张漂亮样例当作总体表现。
  10. 至少保存模型修订、提示、种子、调度器、时间点、精度、环境和原图。单个新种子只能补充一个案例,不能据此判断通用成功率或跨设备复现保证。

下一章音频、视频与统一多模态系统,把静态图片换成随时间变化的声音和视频。我们会继续追问同一个问题:输入里有哪些信息,模型保留了什么,又会在哪里把看似合理的内容生成错?