朋友问我用什么AI工具画画,我一开始支支吾吾答不上来。不是不会用,而是我就会按几个按钮,底下的原理一问三不知。后来我较上劲了:直接用 Python 和深度学习,从零写一个 AI 艺术生成器——不调现成的图像生成接口,不用开源预训练权重,全靠自己写模型结构、训练循环和采样流程,最后把一团纯噪声“洗”成一张有艺术感的图像。整个过程踩了不少坑,也补上了很多以前模糊的概念。
这篇文章就把完整思路和实操过程拆给你看。适合想学扩散模型、想真正搞懂生成式 AI 是怎么回事,而不是只当“调包侠”的读者。
1. 我为什么没直接调现成接口,而是把扩散模型从零搭了一遍
1.1 这个项目到底在做什么
先明确“从零”的定义。我给自己定的边界很严格:不用任何已经训练好的生成权重,不使用第三方绘画 API,不允许直接下载大模型套壳。可以用通用深度学习框架(我用的是 PyTorch),可以用公开的基础工具库,但模型结构、训练循环、噪声调度、采样逻辑都必须自己写。
项目最终交付的东西,是一个能训练的生成模型。训练数据是一批有艺术感的图片,训练完成之后给它一个随机噪声起点,它能输出一张新图片,图片风格接近训练集,内容是模型自己“学”出来的。这个目标说大不大,说小不小。如果只是体验一下生成效果,调接口几分钟就完事;但如果想搞清楚每一步为什么这样设计,从零搭一遍几乎是唯一路径。
我建议有这个想法的读者也给自己划这条线:第一个版本不要追求能写一段文字就生成图像,先达到“能从噪声里还原出清晰图片”就算成功。这一步跑通后,其他高级玩法都是往这张骨架上加肉。
1.2 三个主流生成方向,我为什么选了扩散模型
动手之前,我在 GAN、VAE 和扩散模型之间对比了很久。先说结论:这个项目选的是扩散模型,更精确地说是 DDPM(Denoising Diffusion Probabilistic Models)。
| 方案 | 生成质量 | 训练稳定性 | 代码工程量 | 适合的场景 |
|---|---|---|---|---|
| GAN | 高,但容易模式崩塌 | 较差,需要精细调参 | 中高,需要对抗训练 | 图像清晰,但风格容易单调 |
| VAE | 一般,图像偏模糊 | 很好,基本不崩 | 较低,结构简单 | 生成速度要求高、质量要求不高的场景 |
| 扩散模型 DDPM | 很高,纹理细腻 | 较好,比 GAN 稳得多 | 中等,训练循环直观 | 艺术感强、细节丰富、可逐步控制 |
为什么没有选 GAN?我之前也尝试过 GAN,最大的问题是训练过程像两个对手互相博弈,生成器和判别器的平衡一旦没调好,loss 疯狂震荡,生成结果全是同一个角度的“复制品”。而艺术图像最怕的就是模式崩塌——哪怕能出图,如果十张图长得一模一样,那就没有艺术可言了。
VAE 倒是稳定,但生成图像容易糊。艺术感很大程度来自纹理、笔触和边缘的细节,VAE 会把细节抹平,不符合这个项目的气质。
扩散模型的核心思想是把“生成”过程变成一个从噪声里逐步去噪的过程。它不追求一步到位,而是分很多步慢慢逼近清晰图像,这样每一步改动的幅度都很小,训练稳定,生成质量也高。所以我拍板:就用 DDPM 来搭。
2. DDPM 三大基石:正向加噪、反向去噪、损失函数到底在学什么
很多教程上来就贴公式,看着头大。我用大白话拆一遍,这也是我真正理解 DDPM 的转折点。
2.1 正向过程:给图像制造一部“逐渐模糊”的纪录片
所谓正向过程,就是给一张清晰图像不断添加噪声,直到最后彻底变成纯噪声。你可以把它想象成一部倒放纪录片:一帧一帧地加雪花、加噪点,最后画面完全被静态覆盖。
数学上,每个时间步加噪声是这样的:
# T 是总步数,这里设 1000 T = 1000 betas = torch.linspace(1e-4, 0.02, T) # 噪声强度表,从很小到较大 alphas = 1 - betas alpha_bar = torch.cumprod(alphas, dim=0) # 累乘,用于直接跳到任意 t 步 def q_sample(x_start, t, noise): # x_start: 原始图像,范围 [-1, 1],形状 [B, C, H, W] # t: 随机时间步 # noise: 标准正态噪声 sqrt_alpha_bar_t = alpha_bar[t].sqrt() sqrt_one_minus_alpha_bar_t = (1 - alpha_bar[t]).sqrt() return sqrt_alpha_bar_t.view(-1, 1, 1, 1) * x_start \ + sqrt_one_minus_alpha_bar_t.view(-1, 1, 1, 1) * noise注意这里我用了linspace(1e-4, 0.02, 1000)这个线性噪声表。为什么不是一步到位加很重的噪声?因为如果一步就把图片弄得面目全非,模型根本没法学。分很多小步,每一步的变化都比较温和,模型才有机会一丁点一丁点地掌握“如何判断当前图像处于哪个混乱程度”。
你可能会问,为什么要看alpha_bar而不是每步用beta累加噪声?因为在训练时,每张图随机抽一个时间步 t,如果从 x_0 开始逐帧加噪声到第 t 步,1000 步要循环 999 次,训练速度会慢到让人崩溃。alpha_bar是累积乘积,它可以从原点直接一步“瞬移”到第 t 步的状态,省掉中间所有循环,训练速度提升几个数量级。
2.2 反向过程:从纯噪声里把图“长”出来
正向过程好理解,真正难的是反向过程。已知一张加了大量噪声的图,要猜它更早一步长什么样。严格来说这是个很难的逆问题,但 DDPM 巧妙地绕开了:它不直接求原图,而是训练一个神经网络去预测“某一步的噪声”。
反向过程的起点是一张标准正态分布的纯噪声,然后按 t = T, T-1, ..., 1 倒着走 1000 步。每一步拿着当前图像和当前步数 t,丢给训练好的网络,网络会预测这一步的噪声,我们减掉预测的噪声,再补回一点随机噪声,得到更清晰的上一帧图像。
这个逻辑非常像“盲人摸象”的逆向版本:一开始你只知道一团朦胧的形状,但每一步你都在修正对自己“看到”的东西的判断,一步一步,轮廓、纹理、颜色就都出来了。
2.3 损失函数:训练目标其实只是“把噪声认出来”
DDPM 的损失函数是我见过最直观的生成模型目标之一。它不要求网络直接重建原图,而是要求网络准确预测“我们往图里加的噪声”:
loss = F.mse_loss(noise_pred, noise)其中noise_pred是模型根据“加噪图片 + 时间步 t”预测出来的噪声,noise是我们已知的真实噪声。这个目标有点反直觉:图像生成任务,为什么不直接比像素差异?
因为预测噪声本质上等价于在学习数据分布的方向。如果网络能准确说出当前图像里的随机扰动是什么,那它自然知道怎么去掉扰动、还原数据。这种间接方式比直接回归原图更容易训练,也不容易出现模糊结果。直觉类比:你要在沙地上猜出一尊雕塑的轮廓,与其每次直接画完整的雕塑(容易画糊),不如先由助手告诉你“沙丘上多出来的那堆沙子是从哪里扬起来的”,扬沙的位置知道了,反着削就能削出雕塑。
3. 环境准备与数据处理:模型没学会之前,数据先不能乱
很多项目挂在代码还没跑完之前,其实是挂在数据和环境上。这里我把自己的配置和踩过的坑全部列出来。
3.1 硬件与框架:显存不够就从小图开始
做生成模型,GPU 显存是绕不开的话题。我用的是一张 24GB 显存的显卡,可以顺利跑 64×64 分辨率的训练。如果你只有 16GB 显存,建议把分辨率降到 48×48 或 40×40;只有 8GB 的话,干脆从 32×32 起步。
为什么分辨率影响这么大?因为扩散模型的网络通常要跨多个尺度处理特征图。64×64 的图经过三次下采样,最小的特征图只有 8×8;但中间层的通道数会成倍增长,显存占用大头就在这里。一个小技巧是:先跑通 32×32,把全流程走顺,再往上提分辨率。直接上 128×128 是新手最容易犯的错误,显存一炸,连报错信息都看不懂。
框架方面我用的是 PyTorch。选择理由很简单:生态成熟、调试方便、自定义模型结构时不会受到太多限制。如果你更熟悉别的框架,思路完全一样,这里的所有概念都能迁移。
3.2 数据集的整理、归一化与增强:风格统一比数量更重要
训练数据我建议用一个主题、风格相对统一的图片集,而不是乱七八糟什么都有。艺术生成器向来是“吃什么东西拉什么东西”——喂油画,出来的就是油画质感;喂扎染纹理,出来的就是扎染风。
我用的是自己整理的一批图片,约两万张,主题相对统一。第一次跑的时候偷懒,没有清洗数据,结果训练一天后生成的图像里多出一些莫名其妙的文字残影,后来才发现是数据集里混进了带水印的图。所以如果自己收集数据,先做一轮人工筛选非常值得。
数据预处理我按下面五步来做:
- 把所有图片缩放成正方形。这里建议中心裁剪后再缩放,而不是直接拉伸整个图,否则图像比例容易变形。
- 统一放到目标分辨率,比如 64×64。
- 像素值从 [0, 255] 映射到 [-1, 1]。这一步很重要,因为网络最后输出的是 tanh 激活,输出范围本来就是 [-1, 1],输入和输出对齐训练会稳定很多。
- 随机水平翻转做数据增强,增加样本多样性。注意不要用竖直翻转,很多图像内容竖直翻转后会违反自然语义。
- 在训练集之外留一小批加噪可视化样本,用于训练过程中检查模型的加噪理解是否正确。
第 5 点是我的一个习惯:每训练一段步数,取几张固定图片,用不同 t 值给它加噪,把加噪结果打印出来看。如果噪声程度和 t 大小明显对应,说明正向过程没问题;如果看不出渐进变化,说明噪声表或归一化写错了,早点发现能省下半天排查时间。
4. UNet 骨干:扩散模型的神经网络到底长什么样
DDPM 的神经网络核心不是随便一个 CNN,而是 UNet 结构。为什么大家都用 UNet?因为它实在太适合这个任务了。
4.1 为什么偏偏是 UNet,而不是普通 CNN
图像生成需要同时兼顾全局构图和局部纹理。普通 CNN 的逐层卷积会让网络越来越关注高层语义,丢失小细节。UNet 的特点是编码器逐层下采样提取语义,解码器逐层上采样还原空间尺寸,同时通过跳跃连接把编码器每层的细节特征直接拼到解码器对应层上。
打个比方:你要画一幅风景画,大脑要同时知道“这是一片夕阳下的海”和“每一朵浪花长什么样”。UNet 的编码器负责看整体,解码器负责把整体理解落实到每个像素,跳跃连接则是把“细节底稿”一路带到最后,不至于在抽象过程中丢光。艺术图像对纹理的敏感度极高,UNet 这种结构天然保留了局部信息。
4.2 结构拆解与关键模块:下采样、上采样、跳跃连接和时间嵌入
我实现的 UNet 大概这样设计。输入是 64×64×3 的图像,经过三次下采样,分辨率依次是 64、32、16、8,通道数依次是 64、128、256、512。最底层加了一个自注意力模块,让模型能跨区域捕捉依赖关系。然后三次上采样回到 64×64,最后映射成 3 通道输出。
结构表格如下:
| 阶段 | 特征尺寸 | 通道数 | 主要操作 |
|---|---|---|---|
| 输入 | 64×64 | 3 | 卷积到 64 通道 |
| 下采样 1 | 32×32 | 128 | 残差块 + 步长卷积 |
| 下采样 2 | 16×16 | 256 | 残差块 + 步长卷积 |
| 下采样 3 | 8×8 | 512 | 残差块 + 注意力 |
| 上采样 1 | 16×16 | 256 | 转置卷积 + 跳跃连接拼接 |
| 上采样 2 | 32×32 | 128 | 转置卷积 + 跳跃连接拼接 |
| 上采样 3 | 64×64 | 64 | 转置卷积 + 跳跃连接拼接 |
| 输出 | 64×64 | 3 | 卷积 + Tanh |
这里面有两个地方容易写错。一个是跳跃连接拼接后通道数翻倍,如果忘记在下一次卷积里把通道数合并回去,后面算不对;另一个是时间嵌入的注入方式。
4.3 时间嵌入:为什么每个步数信号都要喂进网络每一层
扩散模型里,同一个图像在不同 t 下的噪声程度不同,网络必须知道“现在在第几步”,才能判断该去掉多少噪声。这个信息不能只塞在输入层,因为深层特征已经发生了巨大变化,感受野也变了,只在开头注入等于没注入。
我的做法是把时间步 t 先映射为一个傅里叶特征向量,再用一个 MLP 把它转换成时间嵌入向量,然后在每个下采样和上采样块的加法操作里都加进去。类比做菜:火候信息不能只在锅边贴一句纸条,厨师每个环节都必须知道现在是大火还是小火,才知道怎么处理食材。
核心简化代码:
class SimpleUNet(nn.Module): def __init__(self): super().__init__() # 这里省略各层的完整定义 self.time_mlp = nn.Sequential(...) def forward(self, x, t): # t 是 [B] 的整数,先转成嵌入向量 t_emb = self.time_mlp(t) # 下采样路径 x = self.down1(x, t_emb) x = self.down2(x, t_emb) x = self.down3(x, t_emb) # 上采样路径 x = self.up1(x, t_emb) x = self.up2(x, t_emb) x = self.up3(x, t_emb) return self.out(x)这个结构不算复杂,但如果你是从零手写,建议先在小分辨率上把模型“尺寸对齐”检查清楚。一个常见错误是输入 32×32,模型设计却输出 16×16,训练一跑 loss 直接报维度错误。
5. 训练与生成全流程:从 loss 像心电图到稳定出图的实战记录
模型结构搭完,最兴奋的时刻就是跑训练了。但第一个训练循环跑起来,看到屏幕上刷的 loss 值,我差点以为程序写错了。
5.1 一个能跑的训练循环(含 EMA)
训练循环的核心逻辑非常简短。每批次图像,给每张图随机抽一个时间步 t,按公式加噪,丢进网络预测噪声,和真实噪声做 MSE,反向传播更新参数:
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, betas=(0.9, 0.999)) ema_model = copy.deepcopy(model).eval() ema_decay = 0.995 for epoch in range(epochs): for x in dataloader: # x 范围 [-1, 1] x = x.to(device) t = torch.randint(0, T, (x.size(0),), device=device) noise = torch.randn_like(x) x_noisy = q_sample(x, t, noise) noise_pred = model(x_noisy, t) loss = F.mse_loss(noise_pred, noise) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() # EMA 更新 with torch.no_grad(): for ema_p, p in zip(ema_model.parameters(), model.parameters()): ema_p.data.mul_(ema_decay).add_(p.data, alpha=1 - ema_decay)这里我把梯度裁剪加上了。为什么?扩散模型早期训练时 loss 偶尔会喷出一个大数,梯度过大容易把参数拖进坏区域,裁剪到 1.0 的范数可以显著提高稳定性。
EMA 是我强烈建议一定要加的。它的作用相当于给模型做了一个“时间过滤”,把训练过程中短暂的高频抖动抹平,采样时用 EMA 版本,生成结果通常比直接用最新权重更稳。很多人训练了半天发现生成画面忽好忽坏,很可能就是没用 EMA。
5.2 采样循环:把噪声洗成图像的最后一步
训练完成后,采样是另一个独立环节。从纯噪声 x_T 出发,按 T 到 1 逐步去除预测噪声:
def sample(model, batch_size=16, img_size=64): model.eval() x_t = torch.randn(batch_size, 3, img_size, img_size, device=device) with torch.no_grad(): for t in reversed(range(T)): t_tensor = torch.full((batch_size,), t, device=device) noise_pred = model(x_t, t_tensor) # 简化的去噪更新 alpha_t = alphas[t] alpha_bar_t = alpha_bar[t] pred_x0 = (x_t - (1 - alpha_t) / ((1 - alpha_bar_t) ** 0.5) * noise_pred) / (alpha_t ** 0.5) if t > 0: noise = torch.randn_like(x_t) x_t = (alpha_t ** 0.5) * pred_x0 + ((1 - alpha_t) ** 0.5) * noise else: x_t = pred_x0 return x_t # 范围 [-1, 1]这个简化版本会引入一定误差,真正严格写还要加上后验方差项。但对第一次跑通来说,这个版本视觉上已经够用。
我建议一开始 T 不要改。先按 1000 步采样全链路跑通,再考虑用 DDIM 加速到 50 步。第一次看到 1000 步循环走完,屏幕上渐渐浮现出图像轮廓时,那种感觉很难形容。
5.3 训练观测:别只盯着 loss,要学会看样本
我第一天跑训练时,loss 从 0.4 慢慢掉到 0.18,看起来一切正常,但采样出来的图像全是灰块。后来又跑了一段,loss 已经低到 0.13,图像才逐渐出现纹理。这说明loss 和视觉质量不是线性对应的。
正确观测方式是每隔固定步数(比如每 1000 个迭代步)跑一次采样,把生成图保存下来。我会把不同阶段的采样图拼在一起看,明显看到:刚开始是噪声,然后出现色块,再然后有轮廓和简单的纹理,最后才是清晰画面。整个过程就像延时摄影,看自己训练的模型“睁开眼睛”,真的会上瘾。
现在常用的超参数,直接给表格:
| 参数 | 取值 | 说明 |
|---|---|---|
| T | 1000 | 总扩散步数 |
| betas | linspace(1e-4, 0.02) | 线性噪声表 |
| 分辨率 | 64×64 | 可先 32×32 起步 |
| batch_size | 32 | 显存不够就调到 16 或 8 |
| 学习率 | 1e-4 | 不建议一开始给 1e-3,容易炸 |
| 梯度裁剪 | 1.0 | 默认加上 |
| EMA decay | 0.995 | 采样时使用 EMA 模型 |
| 优化器 | AdamW | 比 Adam 稳定 |
| 迭代次数 | 约 10 万步 | 两万张图,batch32 时约 160 个 epoch |
6. 条件生成:从一个只会画固定风格的模型,变成按指令生成的艺术生成器
等无条件生成能稳定出图之后,我开始想:这离“艺术生成器”还差一层,那就是可控性。
6.1 无条件生成的局限:随机噪声带来的“开盲盒”体验
无条件模型输入只有一个随机噪声向量,生成内容完全不可控。我今天想让它画“红色花瓣的植物”,结果它随机生成了十张不同构图、不同物种、不同色调的图。好看归好看,但没法用。
艺术生成器的实用价值,在于用户能传递自己的意图。就好比一个画家,只会随手画,跟会根据客户要求画,完全是两个量级。所以第二个阶段,我给模型加了一个简单条件:类别标签。
6.2 加一个类别条件:小改动带来质变
做法不复杂。在数据预处理时,我给每张图打了一个类别标签,比如“某种植物”“另一种植物”“风景”“抽象纹理”等。训练时,把这个标签映射成一个 embedding 向量,然后把它和时间嵌入合并,一起注入到 UNet 的每一层:
class ConditionedUNet(nn.Module): def forward(self, x, t, cond): t_emb = self.time_mlp(t) c_emb = self.cond_embedding(cond) # cond 是类别索引 combined_emb = self.combine(t_emb + c_emb) ...这里有一个关键细节:训练时要以一定概率把条件置为空。我习惯设置 10% 的概率随机丢弃条件向量,相当于把一张图既当作带条件的样本,又当作无条件的样本。这样训练出来的模型推理时可以同时支持条件生成和无条件生成,也为之后做更高级的引导技巧打底。
加了条件之后,效果立刻不一样。我输入“某一种植物”的类别,生成的十张图里八九张都保留了该类别的核心特征。这个改动代码量不大,但把模型从“会画画的盲人”变成了“能听简单指令的助手”。
6.3 文本和风格控制的边界:我验证过的折中方案
类别标签好用,但只适用于预先定义好的分组。想精确控制笔触、色板、构图,单靠几个类别远远不够。要做到真正的文本引导,通常需要文本编码器加跨注意力机制,显存占用和训练复杂度会明显上升,不太适合“从零”阶段的第一版。
我试过几个折中方案,效果不错:
- 把训练集按风格做成多标签,而不是单标签。比如一张图同时有“油画”“冷色调”“风景”三个标签,条件向量改成多标签拼接。
- 用预训练的图像特征做“伪标签”。先把所有训练集图片提取特征向量,然后做聚类,把聚类 ID 当作条件标签。这比纯人工打标签更能捕捉画面里的潜在风格因素。
这些方案虽然比不上真正的文本精细控制,但在不改动模型架构的前提下,已经把可控性提升了一个档次,对新手来说足够用了。
7. 踩坑清单与扩展建议:最想让你带走的一张表
最后把这几个月踩过的坑集中整理一下。如果你训练生成模型时也遇到类似的诡异现象,可以先对着表格自查。
7.1 七个常见翻车现象与定位方法
| 现象 | 可能原因 | 我的处理方式 |
|---|---|---|
| Loss 震荡剧烈,数值忽高忽低 | 学习率过大或批次太小 | 降到 3e-5,批次调到 16/32 |
| 训练几十步后 loss 变成 NaN | 数值不稳定,或反向传播前梯度爆炸 | 加梯度裁剪,检查输入是否严格落在 [-1,1] |
| 采样的所有图片几乎一模一样 | 模式崩溃,通常是数据太单一或模型容量过剩 | 增加数据多样性,减小网络宽度,或加高条件丢弃率 |
| 输出全黑或全灰 | 归一化范围错乱,或采样最后一步弄错了 | 检查图像反归一化代码,看看采样循环最后一步是否直接输出原值 |
| 输出图像边缘有大量噪点 | 网络对高频细节建模能力不够 | 增加通道数,或增加注意力模块层数 |
| 训练 loss 降了但出图很脏 | 采样步数不足,或噪声表设置不合理 | 把 T 从 1000 提升到 2000 试一次,或用 DDIM 减少退化 |
| EMA 模型和训练模型混用不当 | 采样时用了最新权重而非 EMA 权重 | 确保采样固定使用 ema_model,训练评估不要混着调 |
这七个坑里,我花时间最多的是“输出全黑或全灰”。查了两天才发现是采样循环最后一步把pred_x0又加了一遍噪声,导致图被重新毁掉。这种问题不要靠猜,直接在采样循环里逐步打印中间张量的均值、标准差,能很快定位。
7.2 从 32×32 到更高分辨率的扩展路径
如果你跑通了 32×32,想继续往下走,我的建议是:不要直接跳到 128×128,先 48×48,再 64×64,逐级放大。每提高一个分辨率,都要重新评估显存占用和训练时间。还有一个实用技巧:先用低分辨率训一个基础版本,然后用它的权重初始化高分辨率模型,这样可以省掉大量前期训练时间。
采样加速方面,DDIM 可以把 1000 步压缩到 50 步,画面质量损失不大。我第一次把 1000 步换成 50 步时,出图速度提升了近二十倍,当时很后悔没有早点做这一步。再往后可以考虑超分辨率模型、图像修复、风格插值等方向,这些都是在现有骨架上生长出来的,不用推翻重建。
最后说点个人体会。从零写这个 AI 艺术生成器,最大的收获不是“我会写一个生成模型了”,而是我终于知道哪些环节容易出问题、哪些参数会影响什么、模型在不同阶段到底在学什么。真有兴趣的读者,强烈建议先用 32×32 分辨率、两万张以内的图片、单一风格主题把全链路跑通,跑通了再慢慢加条件、加分辨率。这个过程有点像学骑自行车:调参数看表格只能帮你理解,真正上车摔几次,你才算会了。