2.2 生成模型 2.2.0 生成模型在学什么(家族公共部分)
判别模型 学「给定输入,输出是什么」:p(y | x),或者干脆只给一个确定的 ŷ。生成模型 学数据本身的分布 p(x)(或者带条件的 p(x | c)),学会之后可以:
采样 :造出训练集里没有、但看起来属于同一分布的新样本(图像、语音、分子、机器人动作)
评估似然 :给样本算一个概率密度,可以辅助异常检测。但在高维数据上,分布外样本有时反而得到更高的似然(Nalisnick 2019),密度高低不能直接当成「像不像」训练数据
补全 :已知一部分,生成其余部分(图像修复、轨迹补全)
为什么不用回归代替 :1.4 节说过,MSE 回归学到的是条件均值。同一个条件下有几个都正确的答案时,均值可能落在答案之间的低概率区域,也可能靠近其中一个(2.10.2)。另一个更隐蔽的问题是弯曲的合法区域 :一段 16 步 × 7 维的机械臂动作是 112 维空间里的一个点,合法的动作常常只占其中一片弯曲的区域。区域是弯的(非凸)时,两个合法点的平均可能落到区域外面(下面代码里的圆演示的就是这种非凸区域),即使分布只有一个峰也可能这样;避障、逆运动学这类约束都会造成这种情况。如果约束只有关节上下限、每步速度上限这类凸约束,合法轨迹的平均仍然合法,这个问题就不出现。生成模型输出的是一次采样,学得好时会落在某个具体的峰上、落在合法区域里;但有限的模型和有限的采样步数仍可能给出低概率甚至不合法的结果,生成模型并不保证输出物理可执行。
几种路线怎么表示分布 (按热度排)
路线
怎么得到样本
本笔记
扩散模型
从噪声出发,迭代去噪几十步
2.2.1
Flow Matching
从噪声出发,沿学到的速度场积分几步到几十步
2.2.2
自回归
一个 token 一个 token 地生成
2.1.2
VAE
从先验采一个隐变量,解码器一次前向
2.2.3
GAN
噪声过生成器一次前向,由判别器对抗训练
2.2.4
VQ-VAE
先把数据压成离散 token,再用自回归或扩散在 token 上生成
2.2.5
标准化流
可逆变换把高斯「揉」成数据分布,似然可精确计算
2.2.6
能量模型
定义能量函数,用 MCMC 采样
2.12
横向对比(速度、质量、多样性、似然、稳定性)见第四部分 4.3。
1 2 3 4 5 6 7 8 9 10 11 12 13 import torchtorch.manual_seed(0 ) samples = torch.tensor([-1.0 , 1.0 ])[torch.randint(0 , 2 , (1000 ,))] print ("回归(MSE 的最优解是均值)给出:" , round (samples.mean().item(), 3 )) print ("生成模型(从分布里采样)给出:" , samples[:6 ].tolist()) theta = torch.rand(1000 , 2 ) * 2 * torch.pi p1 = torch.stack([theta[:, 0 ].cos(), theta[:, 0 ].sin()], 1 ) p2 = torch.stack([theta[:, 1 ].cos(), theta[:, 1 ].sin()], 1 ) print (f"合法点到圆心的距离都是 1;两点取平均后,到圆心的平均距离只有 {((p1 + p2) / 2 ).norm(dim=1 ).mean():.2 f} " )
2.2.1 扩散模型
一句话
训练时把数据一步步加噪成近似纯噪声,让网络从带噪样本里认出混进去的那份噪声;生成时从纯噪声出发,反复减去网络预测的噪声,一步步还原出数据。
前向过程(固定,不学) :每一步加一点高斯噪声,T 步之后变成近似的标准高斯噪声。任意第 t 步可以从 x_0 一步算出来:
β_t 是事先定好的加噪日程(从 10⁻⁴ 慢慢增大),ᾱ_t 表示到第 t 步还剩多少原信号。
训练(DDPM,Ho 2020) :随机挑一个样本 x_0、一个步数 t、一份噪声 ε,按上式加噪得到 x_t,让网络 ε_θ(x_t, t) 预测 ε:
损失就是一个 MSE,回归的对象是从 x_0 一步加噪到 x_t 时用的那份噪声 ε(它是前 t 步噪声合在一起的效果,不是最后一小步新加的噪声)。每个训练样本只随机抽一个 t,前向一次就结束;只有推理时才需要把 T 步完整走一遍。这是扩散模型训得动的原因。
采样 :从 x_T ~ N(0, I) 开始,每一步:
反向每一步的方差这里取 β_t,这是 DDPM 用的一种选择(另一种是更小的后验方差)。最后一步(t = 1)不再加噪声,z = 0;下面代码里 t 从 0 开始数,对应 if t > 0 那一行。
上排:前向过程把 8 个高斯团逐步加噪成一团高斯噪声。下排:训练好的网络从纯噪声出发反向去噪,样本重新聚回 8 个团。
score 视角 :score 指对数密度的梯度 ∇ₓ log p(x),它在每个位置指向「对数密度升得最快」的方向。预测噪声和估计 score 是一回事:ε_θ(x_t, t) ≈ −√(1 − ᾱ_t)·∇ log p(x_t)。沿着 score 走、每步加一点随机扰动,叫 Langevin 动力学 :
多个噪声尺度上的 score 可以拼成退火 Langevin 采样:从粗到细,在每个噪声尺度上做几步这样的更新。DDPM 的反向链、连续时间的逆向 SDE 和概率流 ODE 也都能通过 score 联系起来,但它们的更新公式和这一条不一样(系数不同,还多了对 x 的缩放),不是把它简单重复若干次。下面的 DDPM 代码用的是前面那条反向更新;紧接着那段已知混合高斯分布的例子才直接用这条 Langevin 更新。
为什么扩散能处理多峰和流形
随机起点 :不同的初始噪声被引导到不同的峰。在低噪声、两个峰还分得开的阶段,score 在峰之间的谷底指向两边,把样本推向两侧(正中间的对称点上 score 可能恰好为 0);高噪声阶段的 score 对应的是平滑后的分布,两个峰可能还没分开。理想的模型和精确的采样能恢复目标分布,落在谷底的比例和真实分布在那里的概率相当;有限的模型和离散步长会带来误差
学会往流形上拉 :训练时故意把数据推离流形(加噪),再教网络推回来。网络在流形周围整个邻域都学到了「从这里该往哪走」,效果接近一个往数据上拉的投影
有监督的迭代 :推理时迭代几十步,相当于把网络加深了几十倍,而每一步在训练时都有明确的目标(知道加了多少噪声,就知道该预测什么)。普通深网络加深容易训崩,扩散把深度换成了噪声等级,每一级单独监督
去噪步 t 和真实时间是两根轴 :在机器人策略里,真实时间 t 是机器人走到第几步;去噪步(论文里常写 τ 或 k)是一次生成内部迭代到第几轮。策略每重新规划一次,去噪步就从 T 到 0 完整跑一遍,生成一整块动作。Diffusion Policy 默认执行完其中 8 步再重新规划(3.2.2),只有每块只执行 1 步时,才是每走一步完整去噪一次。网络必须知道当前去噪步,因为「刚开始去噪」要大刀阔斧,「快去完了」只能微调,所以 τ 要做成嵌入向量喂给网络。
常用扩展
扩展
做什么
DDIM
确定性采样(η = 0 时),可以把上千步压到几十步;步数越少质量下降越明显,下降多少取决于模型、数据和步数
无分类器引导(CFG)
训练时随机丢掉条件,同时学有条件和无条件;采样时用 ε̃ = ε_无条件 + w·(ε_有条件 − ε_无条件),w > 1 让结果更贴合条件
潜空间扩散(Stable Diffusion)
先用 VAE 把 512×512 的图压成 64×64×4 的隐变量,在隐空间做扩散,计算量降几十倍
去噪网络的选择
U-Net(2.3.3)或 Transformer(DiT);机器人动作常用一维卷积 U-Net
一致性模型、蒸馏
把多步采样压到一两步
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 import torchimport torch.nn as nnimport torch.nn.functional as Fdef real_data (n ): """8 个高斯团围成一圈;返回点和它属于第几个团""" k = torch.randint(0 , 8 , (n,)) ang = k.float () * (2 * torch.pi / 8 ) return torch.stack([ang.cos(), ang.sin()], 1 ) * 2 + 0.05 * torch.randn(n, 2 ), k T = 100 betas = torch.linspace(1e-4 , 0.1 , T) alphas = 1 - betas alpha_bar = torch.cumprod(alphas, 0 ) class Denoiser (nn.Module): """输入带噪样本 x_t、去噪步 t、条件 c(8 = 空条件);输出预测的噪声""" def __init__ (self, d=128 ): super ().__init__() self.t_emb = nn.Embedding(T, d) self.c_emb = nn.Embedding(9 , d) self.net = nn.Sequential(nn.Linear(2 + d, d), nn.SiLU(), nn.Linear(d, d), nn.SiLU(), nn.Linear(d, 2 )) def forward (self, x, t, c ): return self.net(torch.cat([x, self.t_emb(t) + self.c_emb(c)], -1 )) torch.manual_seed(0 ) model = Denoiser() opt = torch.optim.Adam(model.parameters(), lr=1e-3 ) for step in range (6000 ): x0, c = real_data(256 ) c = torch.where(torch.rand(256 ) < 0.2 , torch.full_like(c, 8 ), c) t = torch.randint(0 , T, (256 ,)) eps = torch.randn_like(x0) ab = alpha_bar[t].unsqueeze(1 ) x_t = ab.sqrt() * x0 + (1 - ab).sqrt() * eps loss = F.mse_loss(model(x_t, t, c), eps) opt.zero_grad(); loss.backward(); opt.step() @torch.no_grad() def sample (n, c=None , w=1.0 ): x = torch.randn(n, 2 ) null = torch.full((n,), 8 ) for t in reversed (range (T)): tt = torch.full((n,), t) eps = model(x, tt, null) if c is not None : eps = eps + w * (model(x, tt, torch.full((n,), c)) - eps) x = (x - betas[t] / (1 - alpha_bar[t]).sqrt() * eps) / alphas[t].sqrt() if t > 0 : x = x + betas[t].sqrt() * torch.randn_like(x) return x mode_of = lambda s: (torch.round (torch.atan2(s[:, 1 ], s[:, 0 ]) / (2 * torch.pi / 8 )) % 8 ).long() s = sample(2000 ) near = (s.norm(dim=1 ) - 2 ).abs () < 0.3 print (f"无条件:{near.float ().mean():.2 f} 的样本落在半径 2 的圆环附近,按角度落进了 {len (set (mode_of(s[near]).tolist()))} /8 个团的扇区" )for w in [0.0 , 1.0 , 3.0 ]: s = sample(1000 , c=3 , w=w) print (f"条件 c=3,引导强度 w={w} :{(mode_of(s) == 3 ).float ().mean():.2 f} 的样本落在第 3 个团所在的角度扇区(只看角度,没查半径)" )
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import torchdef score (x, s=0.5 ): logw = torch.stack([-(x + 2 ) ** 2 , -(x - 2 ) ** 2 ]) / (2 * s ** 2 ) r = torch.softmax(logw, 0 ) return (r[0 ] * (-2 - x) + r[1 ] * (2 - x)) / s ** 2 torch.manual_seed(0 ) x = torch.randn(5000 ) * 3 step = 0.01 for _ in range (1000 ): x = x + step * score(x) + (2 * step) ** 0.5 * torch.randn_like(x) left = x[x < 0 ] print (f"左峰占比 {len (left) / len (x):.2 f} ;左峰均值 {left.mean():.2 f} 、标准差 {left.std():.2 f} (目标 -2、0.5)" )print (f"落在两峰之间 |x| < 1 的比例: {(x.abs () < 1 ).float ().mean():.3 f} " )
典型例子
图像生成:DALL·E 2、Imagen、Stable Diffusion(潜空间扩散)、Midjourney 一类产品
视频生成:Sora 等,用 Transformer 当去噪网络(DiT)
音频:DiffWave;分子与蛋白质设计:RFdiffusion;天气:GenCast
机器人:Diffusion Policy(Chi 2023)把一段动作序列当成要生成的「图像」,以观测为条件去噪出 16 步动作,从当前时刻对应的位置起执行 8 步再重新观测;同一个观测下「从左绕」和「从右绕」两种演示都能学到,不会平均成撞上去
优势 :能覆盖多峰分布,不容易模式塌缩;训练目标就是回归噪声,非常稳定;样本质量高;条件生成灵活(CFG、修补、编辑都能做)
局限 :采样要迭代几十到上千步,推理慢(DDIM、蒸馏、一致性模型、Flow Matching 都在压这个成本);DDPM 的对数似然通常用变分下界(ELBO)来估计,实际训练用的简化 MSE 去掉了 ELBO 里随 t 变化的权重,不能直接当作对数似然的下界;连续时间的 score 模型还能沿概率流 ODE 算出模型密度,要数值积分,代价高
适合的数据 :连续、高维、多峰的数据:图像、视频、音频、3D 形状、分子构型、机器人动作序列
2.2.2 Flow Matching
一句话
选一条从噪声到数据的路径(最常用的是直线),让网络学这条路径上每一点的速度;生成时从噪声出发,沿学到的速度场积分到数据。
训练 (本节用直线路径,也就是 Rectified Flow 的写法):x_0 是噪声,x_1 是数据,两者随机配对,t 在 0 到 1 之间均匀采样。直线路径上的点和速度:
生成 :从 x_0 ~ N(0, I) 出发,用欧拉法解常微分方程 dx/dt = v_θ(x, t),走几步到几十步。
和扩散的关系 :两者都是「把噪声分布搬运到数据分布」。Flow Matching 是一个框架:先指定一条概率路径,再回归这条路径对应的速度场;路径也可以选扩散那样的加噪路径,这时学到的就是扩散的概率流 ODE。这里用的直线路径没有加噪日程,采样是确定性的 ODE。单条样本对的路径是直的,但噪声和数据随机配对时,网络学到的是条件平均速度:在 t = 0 处它正好等于 E[x_1] − x_0,从起点走一整步就落到数据的均值上(下面代码里能看到)。这和数据是不是多峰无关,单峰时也会塌到均值、丢掉方差,多峰时均值落在峰之间,问题更显眼。学到的速度沿途随位置和时间变化,流线一般也是弯的,起点的速度不能代表整段位移,所以要细分步长才能跟住。Rectified Flow 的 reflow、蒸馏可以进一步把流线拉直,实现一两步生成。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import torchimport torch.nn as nnimport torch.nn.functional as Fdef real_data (n ): k = torch.randint(0 , 8 , (n,)) ang = k.float () * (2 * torch.pi / 8 ) return torch.stack([ang.cos(), ang.sin()], 1 ) * 2 + 0.05 * torch.randn(n, 2 ) torch.manual_seed(0 ) model = nn.Sequential(nn.Linear(3 , 256 ), nn.SiLU(), nn.Linear(256 , 256 ), nn.SiLU(), nn.Linear(256 , 2 )) opt = torch.optim.Adam(model.parameters(), lr=1e-3 ) for step in range (5000 ): x1 = real_data(256 ) x0 = torch.randn_like(x1) t = torch.rand(256 , 1 ) xt = (1 - t) * x0 + t * x1 loss = F.mse_loss(model(torch.cat([xt, t], -1 )), x1 - x0) opt.zero_grad(); loss.backward(); opt.step() @torch.no_grad() def sample (n, steps ): x = torch.randn(n, 2 ) for i in range (steps): t = torch.full((n, 1 ), i / steps) x = x + model(torch.cat([x, t], -1 )) / steps return x for steps in [1 , 2 , 5 , 20 ]: s = sample(2000 , steps) near = ((s.norm(dim=1 ) - 2 ).abs () < 0.3 ).float ().mean().item() print (f"{steps:2d} 步采样:{near:.2 f} 的样本落在半径 2 的圆环附近(只看半径),样本到原点的平均距离 {s.norm(dim=1 ).mean():.2 f} (目标 2)" )
图里四种步数用的是同一批初始噪声(画图时单独固定的;上面代码里的 sample() 每次调用都会重新采样):只走 1 步时所有样本落在平均位置附近(圆心),步数增加后才分散到各个团。
常见说法与实测
常见说法 :Flow Matching 学的是直线路径,所以只要很少的步数,甚至一步就能生成。
实测 :8 个高斯团的数据上(噪声和数据随机配对,没做 reflow),积分 1 步时样本全部挤在圆心附近(到原点的平均距离 0.33,目标是 2);2 步时只有 15% 落在半径 2 的圆环附近;5 步 99%;20 步 100%。这个指标只看半径,圆环上团与团之间的样本也算进去,所以它说明的是「回到了圆环上」,不是「命中了团」。
怎么理解 :「直线」指的是训练时每一对(噪声、数据)之间连的那条线。同一个噪声点在训练中会和很多不同的数据点配对,网络学到的是这些方向的条件平均:在起点 t = 0 处就是「指向数据均值」,所以一步正好走到均值,这一点和是否多峰无关;之后速度随位置和时间变化(合起来的流线一般也是弯的),要多走几步才能跟住。要一两步就生成,常用 Rectified Flow 的 reflow(用模型自己生成的配对重新训练,把流线拉直)或者蒸馏。
典型例子
图像:Stable Diffusion 3、Flux 用的是 Rectified Flow
视频与语音:Meta 的 Movie Gen、Voicebox
机器人:π0 的动作专家用 Flow Matching 生成连续动作块,动作按最高 50 Hz 执行(重新生成动作块的频率更低)
优势 :用直线路径时训练目标很简单(没有加噪日程和各种参数化选择);路径较直,采样步数通常比扩散少;确定性 ODE,便于反演和蒸馏
局限 :少步采样时有离散化误差;随机配对、不做 reflow 或蒸馏时,从起点一步生成会塌到数据均值附近(和是否多峰无关,多峰时更显眼);理论上和扩散的很多结论可以互相转换,选哪个更多是工程偏好
适合的数据 :与扩散模型相同;尤其适合对推理延迟敏感的连续生成,比如高频机器人控制
2.2.3 VAE 变分自编码器 〔图:VAE〕
一句话
编码器输出一个分布(均值和方差),从中采样隐变量再解码;损失 = 重建误差 + 让这个分布贴近标准正态的 KL 项,于是隐空间连续、可以采样生成。
和普通自编码器的区别 (自编码器见 2.6.4):普通 AE 把输入编码成一个点,隐空间里点与点之间是空的,随便取一个点解码出来往往是垃圾。VAE 把输入编码成一个高斯分布 N(μ, σ²),并用 KL 项把所有这些小分布拉向 N(0, I),鼓励隐空间被填满、连续,从 N(0, I) 采样解码通常能得到合理的新样本(不保证先验里的每一点都解码得好)。
两个高斯之间的 KL 有闭式解:
这个损失的相反数是对数似然的一个下界,叫 ELBO (证据下界)。
重参数化技巧 :把「从分布里采样」当成一个黑盒调用时,抽出来的 z 和 μ、σ 之间没有可以求导的路径,梯度回不到编码器。改写成 z = μ + σ ⊙ ε,ε ~ N(0, I),随机性挪到了与参数无关的 ε 上;固定这次抽到的 ε,z 对 μ、σ 就可导,梯度可以穿过 μ 和 σ 回传给编码器。PyTorch 分布对象的 rsample() 就是这种写法(sample() 不保留梯度),下面的代码直接手写了这一行。
常见变体
变体
改了什么
用途
β-VAE
KL 项乘系数 β > 1
加强容量约束,鼓励隐变量各维度相互独立;在合适的数据和归纳偏置下有助于解耦,但不保证每一维对应一个真实的生成因素(Locatello 2019)
CVAE 条件 VAE
编码器、解码器都输入条件 c
ACT(机器人动作分块 Transformer)用 CVAE 吸收演示里的风格差异,推理时 z 取 0
VQ-VAE
隐变量离散化
2.2.5
潜空间扩散的 VAE
只当压缩器,KL 权重很小
Stable Diffusion 先压缩再扩散
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 import torchimport torch.nn as nnimport torch.nn.functional as Fdef blobs (n, size=16 ): """16×16 的图,每张一个位置随机的高斯光斑;真正的生成因素只有光斑中心 (x, y) 两个数""" c = torch.rand(n, 2 ) * (size - 4 ) + 2 g = torch.arange(size).float () yy, xx = torch.meshgrid(g, g, indexing="ij" ) d2 = (xx - c[:, 0 , None , None ]) ** 2 + (yy - c[:, 1 , None , None ]) ** 2 return torch.exp(-d2 / 4 ).flatten(1 ) class VAE (nn.Module): def __init__ (self, in_dim=256 , latent=2 , hidden=256 ): super ().__init__() self.enc = nn.Sequential(nn.Linear(in_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU()) self.mu, self.logvar = nn.Linear(hidden, latent), nn.Linear(hidden, latent) self.dec = nn.Sequential(nn.Linear(latent, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, in_dim)) def forward (self, x ): h = self.enc(x) mu, logvar = self.mu(h), self.logvar(h) z = mu + torch.exp(0.5 * logvar) * torch.randn_like(mu) return self.dec(z), mu, logvar def vae_loss (x_logits, x, mu, logvar, beta=1.0 ): recon = F.binary_cross_entropy_with_logits(x_logits, x, reduction="sum" ) / x.size(0 ) kl = -0.5 * torch.sum (1 + logvar - mu ** 2 - logvar.exp()) / x.size(0 ) return recon + beta * kl torch.manual_seed(0 ) model = VAE() opt = torch.optim.Adam(model.parameters(), lr=1e-3 ) for step in range (4000 ): x = blobs(128 ) x_logits, mu, logvar = model(x) loss = vae_loss(x_logits, x, mu, logvar) opt.zero_grad(); loss.backward(); opt.step() peak = lambda imgs: [divmod (int (i), 16 ) for i in imgs.argmax(1 )] with torch.no_grad(): new = torch.sigmoid(model.dec(torch.randn(1000 , 2 ))) pos = torch.tensor(peak(new)) print ("前 6 个新样本的光斑位置:" , pos[:6 ].tolist()) print ("1000 个新样本光斑的 (行, 列) 范围:" , pos.min (0 ).values.tolist(), "到" , pos.max (0 ).values.tolist()) za, zb = torch.tensor([-1.5 , 0.0 ]), torch.tensor([1.5 , 0.0 ]) path = torch.stack([(1 - a) * za + a * zb for a in torch.linspace(0 , 1 , 6 )]) print ("隐空间直线插值,光斑位置:" , peak(torch.sigmoid(model.dec(path)))) x = blobs(500 ) print ("重建 MSE:" , round (F.mse_loss(torch.sigmoid(model(x)[0 ]), x).item(), 5 ))
在二维隐空间里取一个网格逐点解码:这个例子里 z₁ 基本控制光斑的左右位置,z₂ 控制上下位置,网格上每一点都解码出了合理的图(这是本例的观察,不是 VAE 的保证)。
典型例子
Stable Diffusion 的第一步:VAE 把图像压到 1/8 分辨率的隐空间
World Models(Ha & Schmidhuber 2018):VAE 把游戏画面压成 32 维向量,再在向量上学动力学
分子设计:把分子编码到连续隐空间,在隐空间里做梯度优化找性质更好的分子(Gómez-Bombarelli 2018)
机器人:ACT 用 CVAE 处理人类演示里的风格差异
优势 :隐空间连续、可采样、可插值;单一目标,训练稳定;生成只要一次前向,快;编码器和解码器都能单独拿来用
局限 :样本偏模糊(高斯似然 + 重建误差的平均效应);解码器太强时会忽略 z(后验坍塌);ELBO 只是下界
适合的数据 :需要一个规整隐空间的数据:图像、分子、轨迹、传感器序列;把数据压到隐空间供下游(世界模型、扩散)使用
2.2.4 GAN 生成对抗网络 〔图:GAN〕
一句话
生成器把噪声变成假样本,判别器区分真假;两者对抗训练,直到判别器分不出来。
图中左半是生成器,右半是判别器,中间那一列是生成器的输出(它要模仿真实数据,所以画成输入输出匹配单元)。
交替训练 :先固定 G,训练 D 把真样本判 1、假样本判 0;再固定 D,训练 G 让 D 把假样本判成 1。实践中 G 的损失用 −log D(G(z))(非饱和形式),训练初期梯度更大。
两个老问题
问题
现象
常见对策
训练不稳定
一方压倒另一方,损失震荡或发散
谱归一化、WGAN-GP(换成 Wasserstein 距离 + 梯度惩罚)、调学习率比例
模式塌缩
生成器只会产出少数几种样本,判别器发现后它换另一种,来回打转
小批量判别、多样性损失、WGAN 类目标。目标本身在理想条件下要求匹配整个分布(原论文证明全局最优就是生成分布等于数据分布),塌缩来自有限容量、交替优化的动力学和梯度性质
主要变体 :DCGAN(卷积结构的标准配方)、条件 GAN、pix2pix(成对图像翻译,生成器是 U-Net)、CycleGAN(不成对的图像翻译,循环一致性)、StyleGAN(风格调制,高质量人脸)、SRGAN(超分辨率)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import torchimport torch.nn as nndef real_data (n ): """8 个高斯团围成一圈:观察模式塌缩的经典测试分布""" k = torch.randint(0 , 8 , (n,)) ang = k.float () * (2 * torch.pi / 8 ) return torch.stack([ang.cos(), ang.sin()], 1 ) * 2 + 0.05 * torch.randn(n, 2 ) def train_gan (lr_G, lr_D, steps=3000 , seed=0 ): torch.manual_seed(seed) G = nn.Sequential(nn.Linear(8 , 128 ), nn.ReLU(), nn.Linear(128 , 128 ), nn.ReLU(), nn.Linear(128 , 2 )) D = nn.Sequential(nn.Linear(2 , 128 ), nn.LeakyReLU(0.2 ), nn.Linear(128 , 128 ), nn.LeakyReLU(0.2 ), nn.Linear(128 , 1 )) opt_G = torch.optim.Adam(G.parameters(), lr=lr_G, betas=(0.5 , 0.999 )) opt_D = torch.optim.Adam(D.parameters(), lr=lr_D, betas=(0.5 , 0.999 )) bce = nn.BCEWithLogitsLoss() ones, zeros = torch.ones(256 , 1 ), torch.zeros(256 , 1 ) for step in range (steps): x_real = real_data(256 ) x_fake = G(torch.randn(256 , 8 )) loss_D = bce(D(x_real), ones) + bce(D(x_fake.detach()), zeros) opt_D.zero_grad(); loss_D.backward(); opt_D.step() loss_G = bce(D(x_fake), ones) opt_G.zero_grad(); loss_G.backward(); opt_G.step() with torch.no_grad(): s = G(torch.randn(2000 , 8 )) near = (s.norm(dim=1 ) - 2 ).abs () < 0.3 mode = (torch.round (torch.atan2(s[:, 1 ], s[:, 0 ]) / (2 * torch.pi / 8 )) % 8 ).long() return near.float ().mean().item(), torch.bincount(mode[near], minlength=8 ).tolist() for lr_G, lr_D, note in [(1e-3 , 1e-3 , "两边学习率相同" ), (1e-3 , 1e-4 , "判别器学得太慢" )]: frac, counts = train_gan(lr_G, lr_D) print (f"{note} :{frac:.2 f} 的样本落在半径 2 的圆环附近,按角度分到 8 个团的扇区里的样本数 {counts} " )
灰色圆圈是 8 个真实团的位置。两边学习率失衡时,GAN 的样本挤在一小块区域;扩散模型覆盖了全部 8 个团。
常见说法与实测
常见说法 :GAN 容易模式塌缩,只会生成少数几种样本。
实测 :两边学习率相同(都是 1e-3)时,8 个团的扇区都有样本,各扇区样本数相近(按角度划分扇区,没检查样本离团中心多近);把判别器的学习率降到 1e-4,样本挤在一两个扇区里。另外换 4 个随机种子重复(同样按扇区统计):学习率相同时 4 次都是 8 个扇区全有样本,判别器学习率降低后 4 次样本都挤在少数扇区里。
怎么理解 :在这个例子里,塌缩会不会发生取决于两边学习率的平衡:判别器跟不上时,生成器只要找到一处能骗过它的区域就够了,没有动力去覆盖其他团。这只是触发塌缩的一种方式,模型容量、梯度性质和其他超参也会导致塌缩。「容易塌缩」说的是实践中常见,并没说每次都会发生;高维图像上维持这种平衡难得多,所以塌缩更常见。
典型例子
人脸生成:StyleGAN 系列(「这个人不存在」网站)
图像翻译:pix2pix(草图 → 照片)、CycleGAN(马 → 斑马)
超分辨率:SRGAN
模仿学习:GAIL 把判别器用来区分「专家的状态-动作」和「策略的状态-动作」,策略当生成器
仿真到真实:GraspGAN 把仿真图像翻译得像真实图像,减少机器人抓取所需的真实数据
VQGAN、潜空间扩散的 VAE 训练时还保留判别器,提供让图像更锐利的对抗损失(感知损失 LPIPS 是另外一项)
优势 :样本锐利、细节真实;生成只要一次前向,快;不需要显式写出似然;判别器的思路可以挪作他用(对抗损失、域适应、模仿学习)
局限 :训练不稳定、超参敏感;模式塌缩,多样性差;没有编码器,拿不到样本的隐表示;没有似然,难评估;在图像生成主流上已被扩散取代
适合的数据 :需要高保真、单次快速生成的连续数据:图像、音频;成对或不成对的图像翻译;以「分布对齐」为目标的问题(模仿学习、域适应)
2.2.5 VQ-VAE 向量量化自编码器
一句话
编码器输出的每个向量都替换成码本里离它最近的那个码字,连续信号就变成了一串离散 token。
量化 :码本里有 K 个可学习的向量 e_1…e_K。编码器输出 z_e,找最近的码字:
损失 (sg 表示 stop-gradient:前向照常,反向不传梯度):
项
谁在动
作用
码本项
码本
把选中的码字拉向编码器输出
承诺项
编码器
逼编码器输出别离选中的码字太远(β 常取 0.25)
直通估计 :argmin 不可导。前向用 z_q,反向时把解码器传回来的梯度原样交给 z_e,代码里写成 z_q = z_e + (z_q − z_e).detach()。
量化带来的好处
数据变成离散 token,可以交给 Transformer 像语言一样自回归生成,或者和文字 token 放进同一个模型
离码字分界面较远时,小扰动不会改变最近的码字,对传感器噪声更鲁棒(落在分界面附近时,很小的扰动也会换码字)
压缩:一张图变成几百到上千个整数(VQGAN 把 256×256 的图压成 16×16 = 256 个 token)
码本塌缩 :大部分码字从来没被选中过,有效码本很小。对策:码本用 EMA 更新、定期把死码重置为当前的编码器输出、降低码字维度后再查表。后续变体:残差向量量化 RVQ(多级码本逐级量化残差,音频编解码器常用)、有限标量量化 FSQ(每维直接取整,没有码本塌缩问题)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 import torchimport torch.nn as nnimport torch.nn.functional as Fdef blobs (n, size=16 ): """每张图 0–3 个随机位置的光斑:每个光斑各自以 0.6 的概率出现,三个都不出现(全暗图)的概率是 0.4³ ≈ 6.4%""" g = torch.arange(size).float () yy, xx = torch.meshgrid(g, g, indexing="ij" ) img = torch.zeros(n, size, size) for _ in range (3 ): c = torch.rand(n, 2 ) * (size - 4 ) + 2 on = (torch.rand(n) < 0.6 ).float ()[:, None , None ] img += on * torch.exp(-((xx - c[:, 0 , None , None ]) ** 2 + (yy - c[:, 1 , None , None ]) ** 2 ) / 4 ) return img.clamp(0 , 1 )[:, None ] class VectorQuantizer (nn.Module): def __init__ (self, n_codes=64 , dim=8 , beta=0.25 ): super ().__init__() self.codebook = nn.Embedding(n_codes, dim) self.codebook.weight.data.uniform_(-1 / n_codes, 1 / n_codes) self.beta = beta def forward (self, z_e ): flat = z_e.reshape(-1 , z_e.size(-1 )) idx = torch.cdist(flat, self.codebook.weight).argmin(1 ) z_q = self.codebook(idx).view_as(z_e) loss = F.mse_loss(z_q, z_e.detach()) + self.beta * F.mse_loss(z_e, z_q.detach()) z_q = z_e + (z_q - z_e).detach() return z_q, idx.view(z_e.shape[:-1 ]), loss class VQVAE (nn.Module): def __init__ (self ): super ().__init__() self.enc = nn.Sequential(nn.Conv2d(1 , 32 , 4 , 2 , 1 ), nn.ReLU(), nn.Conv2d(32 , 8 , 4 , 2 , 1 )) self.vq = VectorQuantizer() self.dec = nn.Sequential(nn.ConvTranspose2d(8 , 32 , 4 , 2 , 1 ), nn.ReLU(), nn.ConvTranspose2d(32 , 1 , 4 , 2 , 1 )) def forward (self, x ): z = self.enc(x).permute(0 , 2 , 3 , 1 ).reshape(x.size(0 ), 16 , 8 ) z_q, idx, vq_loss = self.vq(z) x_hat = self.dec(z_q.view(-1 , 4 , 4 , 8 ).permute(0 , 3 , 1 , 2 )) return x_hat, idx, vq_loss torch.manual_seed(0 ) model = VQVAE() opt = torch.optim.Adam(model.parameters(), lr=2e-3 ) for step in range (1500 ): x = blobs(64 ) x_hat, idx, vq_loss = model(x) loss = F.mse_loss(x_hat, x) + vq_loss opt.zero_grad(); loss.backward(); opt.step() with torch.no_grad(): x = blobs(500 ) x_hat, idx, _ = model(x) print ("重建 MSE:" , round (F.mse_loss(x_hat, x).item(), 5 ), "(全输出 0 的基线:" , round (x.pow (2 ).mean().item(), 5 ), ")" ) print ("一张图 → 16 个 token:" , idx[0 ].tolist()) print ("64 个码字里实际被用到的:" , idx.unique().numel())
典型例子
图像:VQ-VAE-2、VQGAN(加了对抗损失和感知损失),把图像变成 token 后用 Transformer 生成
音频:SoundStream、EnCodec 用残差向量量化,产出的音频 token 供 AudioLM 等语言模型使用
机器人:VQ-BeT 把动作量化成离散码再预测;触觉和力的历史也可以压成 token 交给 Transformer
多模态大模型里的图像 tokenizer
优势 :把任意连续信号变成离散 token,接上 Transformer 生态;不容易出现 VAE 那种后验坍塌;表示紧凑,对小噪声鲁棒
局限 :码本塌缩,需要额外技巧;量化有信息损失;直通估计是近似,码本大小 K 是需要调的硬超参
适合的数据 :需要离散化的连续数据:图像、音频、视频、动作轨迹、触觉/力信号;多模态模型的 tokenizer
2.2.6 标准化流 Normalizing Flow
一句话
用一串可逆变换把简单分布(高斯)变成复杂分布;因为可逆,每个样本的概率密度都能精确算出来。
变量替换公式 :f 把数据 x 映射到隐变量 z = f(x),z 服从标准正态:
难点在雅可比行列式:一般矩阵的行列式是 O(D³)。标准化流的设计就是让每一层既可逆、行列式又好算。
仿射耦合层(RealNVP) :把维度分成两半,前一半原样通过,并用它算出后一半的缩放 s 和平移 t:
雅可比是三角矩阵,log|det| = Σ s;求逆只要反过来减 t、除 exp(s),s 和 t 本身可以是任意复杂的网络。多层交替换「哪一半不动」,就能变换所有维度。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 import torchimport torch.nn as nndef two_moons (n ): t = torch.rand(n) * torch.pi upper = torch.rand(n) < 0.5 x = torch.where(upper, torch.cos(t), 1 - torch.cos(t)) y = torch.where(upper, torch.sin(t), 0.5 - torch.sin(t)) return torch.stack([x, y], 1 ) + 0.05 * torch.randn(n, 2 ) class AffineCoupling (nn.Module): def __init__ (self, mask, hidden=64 ): super ().__init__() self.register_buffer("mask" , mask) self.net = nn.Sequential(nn.Linear(2 , hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 4 )) def st (self, x ): s, t = self.net(x * self.mask).chunk(2 , -1 ) return torch.tanh(s) * (1 - self.mask), t * (1 - self.mask) def forward (self, x ): s, t = self.st(x) return x * torch.exp(s) + t, s.sum (-1 ) def inverse (self, y ): s, t = self.st(y) return (y - t) * torch.exp(-s) class RealNVP (nn.Module): def __init__ (self, n_layers=8 ): super ().__init__() masks = [torch.tensor([1. , 0. ]) if i % 2 == 0 else torch.tensor([0. , 1. ]) for i in range (n_layers)] self.layers = nn.ModuleList([AffineCoupling(m) for m in masks]) self.base = torch.distributions.Normal(0. , 1. ) def log_prob (self, x ): logdet = 0 for layer in self.layers: x, ld = layer(x) logdet = logdet + ld return self.base.log_prob(x).sum (-1 ) + logdet def sample (self, n ): z = torch.randn(n, 2 ) for layer in reversed (self.layers): z = layer.inverse(z) return z torch.manual_seed(0 ) flow = RealNVP() opt = torch.optim.Adam(flow.parameters(), lr=1e-3 ) for step in range (3000 ): loss = -flow.log_prob(two_moons(256 )).mean() opt.zero_grad(); loss.backward(); opt.step() with torch.no_grad(): print (f"测试集平均对数似然: {flow.log_prob(two_moons(2000 )).mean():.3 f} " ) s, ref = flow.sample(2000 ), two_moons(5000 ) d = torch.cdist(s, ref).min (1 ).values print (f"生成样本离真实数据的中位距离: {d.median():.3 f} " ) inside = flow.log_prob(torch.tensor([[0.0 , 1.0 ], [1.0 , -0.5 ]])) outside = flow.log_prob(torch.tensor([[0.5 , 2.0 ], [-1.0 , -1.0 ]])) print ("月牙上的点对数密度:" , inside.numpy().round (2 ), " 远处的点:" , outside.numpy().round (2 ))
典型例子
Glow(2018):可逆网络生成人脸,隐空间可以做属性编辑
WaveGlow:语音合成里把梅尔频谱变成波形
物理与化学:Boltzmann Generator 用流对分子构型做采样,能直接算每个构型的概率密度
异常检测:用对数密度当异常分数(高维图像上要先验证,分布外样本可能得到更高的似然)
变分推断里的灵活后验(IAF 等)
优势 :精确的对数似然;耦合流(RealNVP、Glow)编码和生成都是确定的一次前向;最大似然训练,稳定
局限 :可逆约束要求每层输入输出维度相同,模型要很大才能达到扩散/GAN 的样本质量;结构设计受「行列式好算」限制;自回归流两个方向的速度不对称:MAF 算密度一次前向、采样要逐维,IAF 采样一次前向、给外部数据算密度要逐维
适合的数据 :需要精确概率密度的连续数据:中低维的物理状态、分子构型、音频;密度估计与异常检测
2.2.7 DCIGN 深度卷积逆图形网络 〔图:DCIGN〕
一句话
卷积编码器 + 概率隐变量 + 反卷积解码器(本质是卷积版 VAE),训练时让隐变量的每几维专门对应一个图形学参数(姿态、光照),于是可以只改一个参数重新「渲染」图像。
名字的意思 :图形学是「场景参数 → 图像」(渲染);逆图形学是「图像 → 场景参数」。DCIGN(Kulkarni 2015)的编码器做逆图形,解码器做渲染。
训练技巧 :数据按「每个 batch 只有一个因素在变」来组织(比如一批图只有方位角在变,光照和形状都固定)。前向时,除了负责这个因素的那几维,其余隐变量都替换成这个 batch 的平均值,逼解码器只能用指定的那几维解释这批图像之间的差异;同时让其余维度在 batch 内保持不变。训练完成后,隐变量的各维有了明确语义。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 import torchimport torch.nn as nnimport torch.nn.functional as Fdef render (cx, cy, size=16 ): """「渲染器」:给定光斑中心 (cx, cy) 画一张图""" g = torch.arange(size).float () yy, xx = torch.meshgrid(g, g, indexing="ij" ) return torch.exp(-((xx - cx[:, None , None ]) ** 2 + (yy - cy[:, None , None ]) ** 2 ) / 4 )[:, None ] def factor_batch (n, factor ): """一个 batch 里只有一个因素在变:factor=0 只变横坐标,factor=1 只变纵坐标""" cx, cy = (torch.rand(2 ) * 10 + 3 ).tolist() cx, cy = torch.full((n,), cx), torch.full((n,), cy) if factor == 0 : cx = torch.rand(n) * 10 + 3 else : cy = torch.rand(n) * 10 + 3 return render(cx, cy) class DCIGN (nn.Module): def __init__ (self, latent=2 ): super ().__init__() self.enc = nn.Sequential(nn.Conv2d(1 , 16 , 4 , 2 , 1 ), nn.ReLU(), nn.Conv2d(16 , 32 , 4 , 2 , 1 ), nn.ReLU(), nn.Flatten()) self.mu, self.logvar = nn.Linear(32 * 16 , latent), nn.Linear(32 * 16 , latent) self.fc = nn.Linear(latent, 32 * 16 ) self.dec = nn.Sequential(nn.ConvTranspose2d(32 , 16 , 4 , 2 , 1 ), nn.ReLU(), nn.ConvTranspose2d(16 , 1 , 4 , 2 , 1 )) def decode (self, z ): return self.dec(self.fc(z).view(-1 , 32 , 4 , 4 )) def forward (self, x, factor=None ): h = self.enc(x) mu, logvar = self.mu(h), self.logvar(h) z = mu + torch.exp(0.5 * logvar) * torch.randn_like(mu) inv = torch.tensor(0. ) if factor is not None : keep = F.one_hot(torch.tensor(factor), z.size(1 )).float () z_mean = z.mean(0 , keepdim=True ) inv = (((mu - mu.mean(0 , keepdim=True )) ** 2 ) * (1 - keep)).sum (1 ).mean() z = keep * z + (1 - keep) * z_mean return self.decode(z), mu, logvar, inv torch.manual_seed(0 ) model = DCIGN() opt = torch.optim.Adam(model.parameters(), lr=1e-3 ) for step in range (2500 ): f = step % 2 x = factor_batch(64 , f) x_logits, mu, logvar, inv = model(x, factor=f) recon = F.binary_cross_entropy_with_logits(x_logits, x, reduction="sum" ) / 64 kl = -0.5 * torch.sum (1 + logvar - mu ** 2 - logvar.exp()) / 64 loss = recon + kl + 10 * inv opt.zero_grad(); loss.backward(); opt.step() with torch.no_grad(): for dim in [0 , 1 ]: z = torch.zeros(5 , 2 ) z[:, dim] = torch.linspace(-1.5 , 1.5 , 5 ) pos = [divmod (int (i), 16 ) for i in torch.sigmoid(model.decode(z)).flatten(1 ).argmax(1 )] print (f"只改 z[{dim} ],光斑 (行, 列):" , pos)
典型例子
论文里的实验:从一张人脸照片推出姿态、光照、形状参数,再只改方位角,重新渲染出同一张脸转头后的样子
这条思路后来发展成「解耦表示学习」(β-VAE 等)和「分析-合成」式的三维重建(NeRF 一类可微渲染)
优势 :隐变量有明确的物理/图形学语义,可以单独控制;编码器 = 反渲染器,解码器 = 渲染器
局限 :需要「每批只变一个因素」的数据,通常只有合成渲染数据能满足;样本质量受 VAE 模糊问题限制;现在很少直接使用
适合的数据 :生成因素已知、可以受控采集的图像:合成渲染数据、三维物体多视角图像、受控光照人脸