Lab 2 解析:Flow Matching 与 Score Matching
把 Lecture 2 / Lecture 3-A 的两条公式链,一行一行落成能跑出图的 PyTorch 代码:条件概率路径 → 条件向量场 → 条件得分 → 两个训练损失 → 二者之间的线性互换。
0. 本 lab 导读
Lab 1 教会我们的是「怎么把一个已知的 ODE / SDE 模拟出来」——那时候漂移项(drift)和扩散项(diffusion)都是白送的。Lab 2 要回答的是上一个层级的问题:这些漂移项本身是从哪来的?答案就是 flow matching 与 score matching:先人为设计一条条件概率路径(conditional probability path)$p_t(x\mid z)$,把它的条件向量场和条件得分解析地写出来,然后用回归的方式把神经网络训练成对应的边际(marginal)量。
整个 lab 的逻辑主线只有一句话:边际的东西不可解,但条件的东西可解;而对条件量做回归,最优解恰好就是边际量。八个 TODO 全部围绕这一句展开——前四个负责把「条件的东西」写出来,后四个负责把「回归」做出来。
- Q2.1
LinearAlpha/SquareRootBeta:定义调度函数 $\alpha_t=t$、$\beta_t=\sqrt{1-t}$,对应讲义 §3.3 高斯路径的两个自由度。 - Q2.2
sample_conditional_path:从 $p_t(\cdot\mid z)=\N(\alpha_t z,\beta_t^2 I_d)$ 采样,即重参数化技巧(reparameterization trick)。 - Q2.3
conditional_vector_field:$u_t(x\mid z)=\left(\dot\alpha_t-\frac{\dot\beta_t}{\beta_t}\alpha_t\right)z+\frac{\dot\beta_t}{\beta_t}x$,对应 Lecture 2 的条件流求导。 - Q2.4
conditional_score:$\nabla_x\log p_t(x\mid z)=\dfrac{\alpha_t z-x}{\beta_t^2}$,对应 Lecture 3-A 的高斯 score 闭式解。 - Q3.1
ConditionalFlowMatchingTrainer.get_train_loss:四行采样 + 一行 MSE。 - Q3.2
ConditionalScoreMatchingTrainer.get_train_loss:与 3.1 同构,只换回归目标。 - Q3.3
ScoreFromVectorField.forward:由已学到的向量场反解得分,验证「高斯路径下 $u_t$ 与 $s_t$ 是逐点线性双射」。 - Q4.1
LinearConditionalProbabilityPath:直线插值路径 $X_t=(1-t)X_0+tz$,把源分布从高斯解放出来。
预计耗时:读题 + 推导 40 分钟,写代码 30 分钟,四段训练在单张现代 GPU 上合计约 6–10 分钟(5000 + 1000 + 10000 + 20000 个 epoch)。CPU 上会慢 10 倍以上,但依然可跑。
原课程在 lab 首页明确要求「不要让大语言模型替你写作业」,这个要求是合理的:这几个 TODO 每一个都短到只有两三行,真正的价值全在从公式推到那两三行的过程里。如果直接抄,你会失去这个 lab 唯一想给你的东西。
所以本页的定位是写完之后的对照与查错,不是替你交作业。建议的用法是:
- 先自己写。写不出来就只看「数学依据」小节,不看代码。
- 写完之后跑图,把你的图和本页给出的真实运行结果图逐张对比。
- 如果图不对,先看「常见错误」小节,那里列的每一条都是真的会踩到的。
- 最后看「参考实现」确认细节(尤其是张量形状和广播)。
另外提醒一点:本页在第 3 节指出了学生版 notebook 里的一个真实 bug。如果你严格按题面写,那一处代码会给出错误的随机过程。这不是你的错,但你需要知道。
1. 先看骨架:三层抽象与张量形状约定
动手之前花五分钟看懂 Part 0 / Part 1 给的类层次,后面所有 TODO 都会顺很多。这个 lab 的代码是三层结构:
| 层 | 类 | 职责 |
|---|---|---|
| 分布层 | Sampleable / Density | 能采样 / 能算对数密度。Gaussian、GaussianMixture、MoonsSampleable、CirclesSampleable、CheckerboardSampleable 都实现了它们。 |
| 路径层 | ConditionalProbabilityPath | 本 lab 的主角。持有 p_simple 与 p_data,对外提供四个抽象方法:sample_conditioning_variable、sample_conditional_path、conditional_vector_field、conditional_score。 |
| 动力学层 | ODE / SDE + Simulator | Lab 1 的老朋友,原封不动搬过来。EulerSimulator 和 EulerMaruyamaSimulator 负责把向量场变成轨迹。 |
路径层里有一个已经写好、但极其重要的方法,值得单独拎出来读:
def sample_marginal_path(self, t: torch.Tensor) -> torch.Tensor:
num_samples = t.shape[0]
z = self.sample_conditioning_variable(num_samples) # (num_samples, dim)
x = self.sample_conditional_path(z, t) # (num_samples, dim)
return x
这三行就是整个 flow matching 的哲学缩影:边际分布 $p_t(x)=\int p_t(x\mid z)p_{\text{data}}(z)\,\dd{z}$ 我们写不出闭式,但我们能采样它——先采 $z$,再采 $x\mid z$,扔掉 $z$,剩下的 $x$ 就服从边际。后面训练时用的正是同一个双层采样结构,只不过 $z$ 不扔掉,而是留下来算回归目标。
- $x$、$z$、向量场、得分:
(bs, dim),本 lab 里dim = 2。 - 时间 $t$:
(bs, 1)——注意是二维张量,不是(bs,)。每个样本有自己的时间。 alpha(t)、beta(t)、alpha.dt(t)、beta.dt(t):(bs, 1)。- 因此
alpha(t) * z是(bs,1) * (bs,dim)的广播,结果(bs,dim)——这正是我们想要的:同一个样本的所有维度共享同一个 $\alpha_t$。 - 模拟时的轨迹时间网格
ts:(bs, num_timesteps, 1),切片ts[:, i]得到(bs, 1),正好喂给上面这些函数。
另外注意 Alpha / Beta 基类的 __init__ 里塞了两个 assert:
class Alpha(ABC):
def __init__(self):
assert torch.allclose(self(torch.zeros(1,1)), torch.zeros(1,1)) # alpha_0 = 0
assert torch.allclose(self(torch.ones(1,1)), torch.ones(1,1)) # alpha_1 = 1
也就是说,只要你 LinearAlpha() 一实例化不报错,边界条件就已经自动被检查过了。这是个很贴心的设计,但也意味着:如果你在 __init__ 里报 AssertionError,问题一定出在 __call__ 的边界值上,而不是别的地方。
2. Part 2:高斯条件概率路径
本节要实现的对象是
$$ p_t(x\mid z)=\N\!\left(x;\ \alpha_t z,\ \beta_t^2 I_d\right),\qquad \alpha_0=\beta_1=0,\quad \alpha_1=\beta_0=1 . $$直觉上,$z$ 是一个「目标点」(在高斯路径里 $z\sim p_{\text{data}}$,也就是一个真实数据点),$\alpha_t$ 控制这个点被保留了多少,$\beta_t$ 控制还剩多少噪声。$t=0$ 时全是噪声(标准高斯),$t=1$ 时全是信号(退化成 $\delta_z$)。反过来看,从 $t=1$ 走到 $t=0$ 就是一个把数据点逐步加噪、最终抹成纯噪声的腐蚀过程(corruption process)——生成就是把它反过来走。
问题 2.1:实现 $\alpha_t$ 与 $\beta_t$
题目在问什么。实现 LinearAlpha.__call__ 返回 $\alpha_t=t$,SquareRootBeta.__call__ 返回 $\beta_t=\sqrt{1-t}$。基类已经用 vmap(jacrev(...)) 给出了通用的自动微分版 dt,但解答里把它们替换成了解析导数(更快也更可控)。
数学依据。四个边界条件不是随便定的,每一条都有明确含义:
| 条件 | 推出 | 含义 |
|---|---|---|
| $\alpha_0=0$,$\beta_0=1$ | $p_0(x\mid z)=\N(0,I_d)$ | 起点与 $z$ 无关,因此边际 $p_0=p_{\text{simple}}$——这是「所有条件路径在 $t=0$ 汇合到同一个易采样分布」的保证。 |
| $\alpha_1=1$,$\beta_1=0$ | $p_1(x\mid z)=\delta_z(x)$ | 终点精确落在 $z$ 上,因此边际 $p_1=\int\delta_z\,p_{\text{data}}(z)\dd{z}=p_{\text{data}}$。 |
换句话说,这四个条件就是「这条路径确实连接 $p_{\text{simple}}$ 和 $p_{\text{data}}$」的全部要求。中间怎么走($\alpha_t,\beta_t$ 具体取什么形状)是完全自由的设计空间——这就是为什么后来会有 rectified flow、EDM、cosine schedule 等等一堆变体。
本节选的 $\beta_t=\sqrt{1-t}$ 有一个特别的性质:$\alpha_t^2+\beta_t^2=t^2+1-t$,在 $t\in(0,1)$ 内小于 1,所以它不是方差保持(variance-preserving)路径;但它让后面 $\beta_t^2\dot\alpha_t-\alpha_t\dot\beta_t\beta_t=1-\frac t2$ 变得极其干净(问题 3.3 会用到)。
beta.dt 需要那个 $10^{-4}$
解析导数为
$$ \dot\beta_t=\frac{\dd{}}{\dd{t}}\sqrt{1-t}=-\frac{1}{2\sqrt{1-t}} . $$当 $t\to1^-$ 时 $\sqrt{1-t}\to0$,导数以 $-\infty$ 的速度发散。而训练时 $t\sim\mathcal U[0,1)$ 是连续均匀采样,浮点数完全可能采出 $t=1-10^{-8}$ 甚至由于 torch.rand 的舍入落到使 $1-t$ 下溢为 $0$ 的位置——那时 $\dot\beta_t$ 直接是 -inf,一步反向传播就把整个网络的权重污染成 nan。
加一个下限就把这个灾难挡住了:
$$ \dot\beta_t\ \approx\ -\frac{1}{2\left(\sqrt{1-t}+10^{-4}\right)} . $$代价是引入了一点偏差。偏差有多大?在 $t$ 不太接近 1 的地方(比如 $\sqrt{1-t}\gtrsim 0.1$),相对误差约为 $10^{-4}/\sqrt{1-t}\lesssim 10^{-3}$,可以忽略;在 $t\to1$ 时导数被截断在 $-5000$ 而不是 $-\infty$,反而正是我们要的效果。数值测试里 beta.dt 与 torch.autograd 的最大误差是 $2.17\times10^{-4}$,量级正好对得上。
参考实现。
class LinearAlpha(Alpha):
"""alpha_t = t"""
def __call__(self, t: torch.Tensor) -> torch.Tensor:
# t: (num_samples, 1) -> alpha_t: (num_samples, 1)
# alpha_0 = 0(纯噪声),alpha_1 = 1(纯数据)
return t
def dt(self, t: torch.Tensor) -> torch.Tensor:
# d/dt t = 1,形状必须和 t 一样,不能返回标量
return torch.ones_like(t) # (num_samples, 1)
class SquareRootBeta(Beta):
"""beta_t = sqrt(1 - t)"""
def __call__(self, t: torch.Tensor) -> torch.Tensor:
# beta_0 = 1(噪声标准差为 1),beta_1 = 0(无噪声)
return torch.sqrt(1 - t) # (num_samples, 1)
def dt(self, t: torch.Tensor) -> torch.Tensor:
# d/dt sqrt(1-t) = -1/(2 sqrt(1-t)),t->1 时发散,加 1e-4 兜底
return -0.5 / (torch.sqrt(1 - t) + 1e-4) # (num_samples, 1)
为什么这样写。
return t而不是return t.clone():这里没有原地修改,直接返回引用是安全的,而且能省一次拷贝。但如果你之后要在__call__结果上做原地操作,就必须clone()。torch.ones_like(t)而不是1.0:返回值必须是(bs,1)的张量,且 dtype / device 要跟着t走。返回 Python 标量在后面(dt_alpha - ...) * z的广播里碰巧也能work,但一旦有人写alpha.dt(t).shape就会炸,而且丢了 device 信息。torch.sqrt(1 - t)而不是(1-t) ** 0.5:两者数值等价,但前者在1-t为负(由于浮点误差 $t$ 略大于 1)时返回nan而不是复数报错,行为更可预测。- 把
dt从自动微分版换成解析版:基类的vmap(jacrev(self))每次都要建计算图,在 5000 个 epoch × 每 epoch 一次的调用量下是纯浪费。数值测试确认alpha.dt与自动微分误差为 0.00e+00(因为常数 1 是精确的)。
- 把 $\alpha$ 和 $\beta$ 的方向搞反。本课的约定是 $t=0$ 是噪声、$t=1$ 是数据,和 DDPM 论文里 $t=0$ 是数据、$t=T$ 是噪声的约定正好相反。如果你读过 DDPM 再来做这个 lab,几乎一定会在这里卡一次。判据很简单:$\alpha$ 是「信号系数」,必须递增;$\beta$ 是「噪声系数」,必须递减。
- 忘了改
dt,只改__call__。题面只要求实现__call__(基类的自动微分dt是能用的),所以这不算错。但如果你像本解答一样重写了dt,务必两处一致——写了 $\beta_t=\sqrt{1-t}$ 却把dt留成 $-\frac1{2\sqrt t}$ 这种笔误,后面的向量场会全错,而且图看起来「差不多对」,非常难查。 - 在
dt里对分子而不是分母加稳定项。写成-0.5 / torch.sqrt(1 - t + 1e-4)也能防inf,但它改变的是 $\beta$ 本身的定义域偏移,和-0.5 / (torch.sqrt(1-t) + 1e-4)在 $t\to1$ 处的极限值差了 50 倍($-50$ 对 $-5000$)。两者都能跑,但要知道自己在做什么。 - $\varepsilon$ 加得太大。有人会写
1e-2「保险一点」,结果在 $t=0.5$ 处 $\dot\beta_t$ 就有 1.4% 的偏差,训出来的向量场会在中段系统性偏小。$10^{-4}$ 是个经过权衡的值。
问题 2.2:从条件路径采样
题目在问什么。给定 $z$(形状 (bs,dim))与 $t$(形状 (bs,1)),返回一个服从 $\N(\alpha_t z,\beta_t^2 I_d)$ 的样本,形状 (bs,dim)。
数学依据。就是重参数化技巧(reparameterization trick):若 $\epsilon\sim\N(0,I_d)$,则
$$ X=\mu+\sigma\epsilon\ \sim\ \N(\mu,\sigma^2 I_d). $$代入 $\mu=\alpha_t z$、$\sigma=\beta_t$ 得
$$ X_t=\alpha_t z+\beta_t\epsilon,\qquad \epsilon\sim\N(0,I_d). $$这条式子有一个远超「采样技巧」的身份:它同时定义了条件流(conditional flow)
$$ \psi_t(x_0)=\alpha_t z+\beta_t x_0 , $$即把 $t=0$ 时的一个具体噪声点 $x_0$ 搬运到 $t$ 时刻位置的那个确定性映射。问题 2.3 的整个推导就建立在 $\psi_t$ 之上,所以这里写下的这一行,其实是后面所有东西的源头。
参考实现。
def sample_conditional_path(self, z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
"""
z: (num_samples, dim) t: (num_samples, 1)
return: (num_samples, dim)
"""
# alpha(t), beta(t) 均为 (num_samples, 1),与 z 的 (num_samples, dim) 广播
return self.alpha(t) * z + self.beta(t) * torch.randn_like(z)
为什么这样写。
torch.randn_like(z)而不是torch.randn(z.shape):前者自动继承z的 dtype 和 device。本 lab 全程在 GPU 上跑,后者会在 CPU 上造张量然后抛RuntimeError: Expected all tensors to be on the same device。- 广播方向:
(bs,1) * (bs,dim) -> (bs,dim)。这里的关键是同一个样本的两个坐标共享同一个 $\alpha_t$,因为高斯是各向同性的。如果你不小心把t传成了(bs,),广播会变成(bs,) * (bs,dim)—— PyTorch 会把(bs,)当成最后一维去对齐dim,在bs != dim时直接报错,在bs == dim时静默算错。这是本 lab 最阴险的形状陷阱。 - 每次调用都重新采一次 $\epsilon$:这是必须的。训练时同一个 $(z,t)$ 对应的 $x$ 必须是随机的,否则回归目标就退化成了确定性映射,学不到条件期望。
问题 2.3:条件向量场
题目在问什么。实现
$$ u_t(x\mid z)=\left(\dot\alpha_t-\frac{\dot\beta_t}{\beta_t}\alpha_t\right)z+\frac{\dot\beta_t}{\beta_t}x . $$题面直接把答案给了,所以代码本身是送分的。但如果只抄公式,你会错过这个 lab 最核心的一次推导。下面完整重做一遍。
第一步:写出条件流。问题 2.2 已经给出,对固定的 $z$ 和固定的初始噪声 $x_0$,
$$ \psi_t(x_0)=\alpha_t z+\beta_t x_0 . $$这是一个从 $\R^d$ 到 $\R^d$ 的确定性映射族,$t$ 是参数。它满足两件事:$\psi_0(x_0)=x_0$(因为 $\alpha_0=0,\beta_0=1$),以及若 $x_0\sim\N(0,I_d)$ 则 $\psi_t(x_0)\sim p_t(\cdot\mid z)$。
第二步:写出「流生成向量场」的定义式。按定义,向量场 $u_t(\cdot\mid z)$ 生成流 $\psi_t$ 当且仅当
$$ \frac{\dd{}}{\dd{t}}\psi_t(x_0)=u_t\big(\psi_t(x_0)\,\big|\,z\big)\qquad\forall x_0 . $$注意右边的自变量是 $\psi_t(x_0)$,不是 $x_0$。这正是整个推导的关键:向量场是「位置的函数」,而 $\psi_t$ 是「初始点的函数」,两者要通过反解连起来。
第三步:算左边。直接对 $t$ 求导,$z$ 和 $x_0$ 都是常量:
$$ \frac{\dd{}}{\dd{t}}\psi_t(x_0)=\dot\alpha_t\, z+\dot\beta_t\, x_0 . $$第四步:反解 $x_0$。令 $x=\psi_t(x_0)=\alpha_t z+\beta_t x_0$,则对 $t\in[0,1)$(此时 $\beta_t>0$,可除)
$$ x_0=\frac{x-\alpha_t z}{\beta_t}. $$第五步:代回去。
$$ \begin{aligned} u_t(x\mid z) &=\dot\alpha_t z+\dot\beta_t\cdot\frac{x-\alpha_t z}{\beta_t} &&\text{(i) 把 }x_0\text{ 换成 }x\\[2pt] &=\dot\alpha_t z+\frac{\dot\beta_t}{\beta_t}x-\frac{\dot\beta_t}{\beta_t}\alpha_t z &&\text{(ii) 展开}\\[2pt] &=\left(\dot\alpha_t-\frac{\dot\beta_t}{\beta_t}\alpha_t\right)z+\frac{\dot\beta_t}{\beta_t}x &&\text{(iii) 按 }z,x\text{ 合并} \end{aligned} $$推导完毕。可以顺手做两个检查:
- 齐次性检查:$u_t(x\mid z)$ 关于 $(x,z)$ 是联合线性的(没有常数项)。这与「高斯路径的一切都是线性的」这个大结论一致。
- 不动点检查:取 $x=\alpha_t z$(云的中心),得 $u_t=\dot\alpha_t z$,即中心以速度 $\dot\alpha_t z$ 沿 $z$ 方向移动,$t=0$ 出发、$t=1$ 到达 $z$。符合上一张图看到的现象。
代入本 lab 的具体调度($\alpha_t=t$,$\beta_t=\sqrt{1-t}$,$\dot\alpha_t=1$,$\dot\beta_t=-\frac1{2\sqrt{1-t}}$,故 $\frac{\dot\beta_t}{\beta_t}=-\frac{1}{2(1-t)}$):
$$ u_t(x\mid z)=\left(1+\frac{t}{2(1-t)}\right)z-\frac{1}{2(1-t)}x =\frac{(2-t)\,z-x}{2(1-t)} . $$这个闭式很有用:$t\to1^-$ 时分母趋于 0、分子趋于 $z-x$,所以向量场以 $\frac{z-x}{2(1-t)}$ 的速度爆炸式地把 $x$ 吸向 $z$——这就是 $t=1$ 附近数值积分不稳定的根源。
参考实现。
def conditional_vector_field(self, x, z, t):
"""
x: (num_samples, dim) z: (num_samples, dim) t: (num_samples, 1)
return: (num_samples, dim)。注意只在 t in [0,1) 上有定义
"""
alpha_t = self.alpha(t) # (num_samples, 1)
beta_t = self.beta(t) # (num_samples, 1)
dt_alpha_t = self.alpha.dt(t) # (num_samples, 1)
dt_beta_t = self.beta.dt(t) # (num_samples, 1)
return (dt_alpha_t - dt_beta_t / beta_t * alpha_t) * z + dt_beta_t / beta_t * x
为什么这样写。
- 四个标量系数全是
(bs,1),与(bs,dim)的z、x广播,结果(bs,dim)。没有任何reshape/unsqueeze的必要——这正是前面坚持让t是(bs,1)的回报。 - 把
alpha(t)等先存成局部变量,而不是在表达式里调四次:self.alpha.dt(t)在基类实现下是一次vmap(jacrev(...)),重复调用非常贵。即使换成解析版,可读性也更好。 dt_beta_t / beta_t在 $t\to1$ 时是 $-\frac{1}{2(1-t)}\to-\infty$。这不需要(也不应该)加保护:题面明确说了这个函数只在 $t\in[0,1)$ 上有定义,训练时torch.rand的取值区间是 $[0,1)$,模拟时最后一步也停在 $t<1$。人为把它 clamp 住反而会引入错误的动力学。
直接验证第二步那条定义式:随机采一批 $z$、$x_0$、$t$,沿轨迹取点 $\psi_t(x_0)=\alpha_t z+\beta_t x_0$,然后比较
$$ u_t\big(\psi_t(x_0)\mid z\big)\ \overset{?}{=}\ \dot\alpha_t z+\dot\beta_t x_0 . $$右边是「直接对 $\psi$ 求导」,左边是「你的实现」。两者必须逐元素相等。本仓库的数值测试跑了这一项,最大误差 $9.54\times10^{-7}$——float32 的机器精度量级,说明实现完全正确。这个测试比肉眼看轨迹图敏感几个数量级:符号错、系数错、$\alpha$/$\beta$ 互换,都会让误差跳到 $10^{-1}$ 以上。
sample_conditional_path 采出的真值条件路径;中:用你实现的 $u_t(x\mid z)$ 跑 1000 步 Euler 得到的样本;右:其中若干条轨迹。判据是左中两图在每个时刻的样本云必须重合(同色的团位置与半径一致)。这是「向量场生成概率路径」这一抽象命题的直接实证——两条完全不同的计算途径给出同一个分布。右图的轨迹是笔直的射线,因为条件流 $\psi_t(x_0)=t z+\sqrt{1-t}\,x_0$ 在 $(z,x_0)$ 张成的二维平面里是一条曲线,但在这个远距离的尺度上看近似为直线。问题 2.4:条件得分
题目在问什么。实现 $\nabla_x\log p_t(x\mid z)$,即条件分布对数密度关于位置 $x$(不是关于参数、不是关于 $t$)的梯度。
数学依据。这是本 lab 唯一一处需要真正「求导一个密度」的地方,但因为分布是高斯,两行就能算完。
写出对数密度:
$$ \log p_t(x\mid z) =\log\left[(2\pi\beta_t^2)^{-d/2}\exp\!\left(-\frac{\norm{x-\alpha_t z}^2}{2\beta_t^2}\right)\right] =-\frac{\norm{x-\alpha_t z}^2}{2\beta_t^2}-\frac d2\log(2\pi\beta_t^2). $$第二项与 $x$ 无关,求梯度时整体消失——这就是 score 对归一化常数免疫的具体体现,也是 score matching 之所以可行的根本原因。于是
$$ \begin{aligned} \nabla_x\log p_t(x\mid z) &=-\frac{1}{2\beta_t^2}\,\nabla_x\norm{x-\alpha_t z}^2 &&\text{(i) 常数项消失}\\[2pt] &=-\frac{1}{2\beta_t^2}\cdot 2(x-\alpha_t z) &&\text{(ii) }\nabla_x\norm{x-a}^2=2(x-a)\\[2pt] &=-\frac{x-\alpha_t z}{\beta_t^2}=\frac{\alpha_t z-x}{\beta_t^2}. \end{aligned} $$几何意义一目了然:score 是一个指回均值的向量,长度与偏离量成正比、与方差成反比。方差越小($t$ 越接近 1),同样的偏离产生的「回拉力」越强。
参考实现。
def conditional_score(self, x, z, t):
"""
x: (num_samples, dim) z: (num_samples, dim) t: (num_samples, 1)
return: (num_samples, dim)。同样只在 t in [0,1) 上有定义
"""
alpha_t = self.alpha(t) # (num_samples, 1)
beta_t = self.beta(t) # (num_samples, 1)
return (z * alpha_t - x) / beta_t ** 2
为什么这样写。只有一行,但有三处值得说:
- 符号:分子是 $\alpha_t z-x$(指向均值),不是 $x-\alpha_t z$。写反了会让后面的 Langevin 项变成「把样本推离模式」,SDE 采样直接发散。
- 分母是 $\beta_t^2$ 不是 $\beta_t$:score 的量纲是 $1/\text{长度}$,来自 $\frac{\text{长度}}{\text{方差}}$。这是最高频的错误之一,而且在 $\beta_t$ 接近 1 的地方($t$ 小)几乎看不出差别,只有 $t$ 大时才会暴露。
- 不需要
torch.log、不需要autograd:闭式解是解析的。用torch.autograd.grad去数值求这个梯度虽然也对,但慢几十倍,而且在 $t\to1$ 处更不稳定。
用自动微分对拍:构造 x 带 requires_grad=True,令 logp = -0.5 * ((x - alpha_t*z)**2).sum(-1) / beta_t**2,再 torch.autograd.grad(logp.sum(), x),结果应与你的实现逐元素相等。本仓库测试中这一项最大误差 $9.54\times10^{-7}$。
- 对 $t$ 求导而不是对 $x$ 求导。"score" 在很多文献里也指对参数的梯度(Fisher score),本课的 score 一律指 $\nabla_x\log p_t(x)$。
- 把
conditional_vector_field里的 $\frac{\dot\beta_t}{\beta_t}$ 写成 $\dot\beta_t\beta_t$。两者在 $\beta_t=\sqrt{1-t}$ 下分别是 $-\frac{1}{2(1-t)}$ 和 $-\frac12$,后者是个常数——跑出来的轨迹会是平滑的直线,图看起来「还挺像那么回事」,但样本云和真值对不上。务必做上面那个 $\frac{\dd{}}{\dd{t}}\psi_t$ 对拍。 - 忘了
self.,直接用全局的alpha/beta。notebook 环境里全局往往真的存在同名变量,于是代码能跑、结果也对——直到你换一组 $\alpha,\beta$ 构造第二个 path 对象,才发现它偷偷用的是别人的调度。 - 在 $t=1$ 处调用这两个函数。可视化代码里
ts = torch.linspace(0.0, 1.0, num_timesteps)的最后一个点确实是 1.0,但Simulator.simulate的循环是range(len(ts)-1),最后一次step用的t是倒数第二个值,所以不会真的在 $t=1$ 求值。理解这一点能省掉很多「为什么没炸」的困惑。
3. 插曲:学生版 notebook 里的一个真实 bug
这一节不对应任何 TODO,但如果你严格照着学生版 notebook 跑,你模拟的不是题面写的那个 SDE。这是我在本地完整执行时实测发现的,值得单独讲。
先看 Lab 1 就已经给好、Lab 2 原封不动搬过来的 Euler–Maruyama 求解器:
class EulerMaruyamaSimulator(Simulator):
def step(self, xt, t, h):
return (xt
+ self.sde.drift_coefficient(xt, t) * h
+ self.sde.diffusion_coefficient(xt, t) * torch.sqrt(h) * torch.randn_like(xt))
注意最后一项:求解器自己乘了 torch.sqrt(h) * torch.randn_like(xt)。也就是说,diffusion_coefficient 应该只返回确定性的 $\sigma_t$,随机性由求解器负责。
但学生版 notebook 里 ConditionalVectorFieldSDE 和 LangevinFlowSDE 的 diffusion_coefficient 都写着:
def diffusion_coefficient(self, x, t):
return self.sigma * torch.randn_like(x) # <-- 这里多采了一次噪声
于是每一步的随机增量变成了
$$ \Delta X = \sigma\,\epsilon_1\cdot\sqrt h\,\epsilon_2,\qquad \epsilon_1,\epsilon_2\ \text{独立同分布}\ \sim\N(0,I_d), $$即两个独立高斯的逐元素乘积,而不是题面要求的 $\sigma\sqrt h\,\epsilon$。
为什么这个 bug 这么难发现?因为一阶和二阶矩都没问题:
$$ \E[\epsilon_1\epsilon_2]=\E[\epsilon_1]\E[\epsilon_2]=0,\qquad \Var(\epsilon_1\epsilon_2)=\E[\epsilon_1^2]\E[\epsilon_2^2]=1 . $$所以增量的方差仍然是 $\sigma^2 h$,Euler–Maruyama 的弱一阶收敛所依赖的前两阶矩条件恰好被满足,图看上去大体是对的。
但四阶矩不对。对 $Y=\epsilon_1\epsilon_2$:
$$ \E[Y^4]=\E[\epsilon_1^4]\,\E[\epsilon_2^4]=3\times3=9 , $$而 $\Var(Y)=1$,所以峰度(kurtosis)
$$ \frac{\E[Y^4]}{\Var(Y)^2}=9\ \ne\ 3=\text{高斯的峰度}. $$$Y$ 的实际分布是两个标准正态之积,其密度含有第二类修正贝塞尔函数 $K_0$,在 0 附近是对数发散的尖峰、两侧是比高斯厚得多的指数尾。这意味着模拟出来的过程不是维纳过程驱动的扩散,而是一个跳跃更极端、中心更集中的重尾随机游走。
实践后果:单步来看差异明显;多步累加时中心极限定理会把分布慢慢拉回高斯,所以在 1000 步的模拟里终态分布看起来接近正确。但轨迹的路径性质是错的——它不是布朗运动,路径的局部行为、首次通过时间、极值分布全都不同。而且在步数少(比如 $\sigma$ 大时不得不用的粗网格)的情形下,终态分布也会肉眼可见地偏离。
官方 solutions 已经把它修成 return self.sigma(课程仓库 2026-01-11 的 changelog 提到修了这个 bug),但修复只进了 solutions 分支,学生版 notebook 至今仍是错的。本页解答里写成:
def diffusion_coefficient(self, x, t):
# 扩散系数是确定性的;随机性由 EulerMaruyamaSimulator.step 里的
# sqrt(h) * randn_like(xt) 提供,这里再采一次噪声就是采了两次
return self.sigma * torch.ones_like(x) # (bs, dim)
用 torch.ones_like(x) 而不是裸的 self.sigma,是为了保持返回值形状为 (bs, dim)、与基类文档字符串一致;数值上与 return self.sigma 等价(后者靠标量广播)。两种写法都对。
- 看轨迹图的粗糙度。正确的布朗轨迹抖动幅度是均匀的;乘积噪声的轨迹会呈现「大部分时间几乎不动、偶尔一大跳」的间歇性外观。
- 做一个一维数值实验。固定
drift=0、sigma=1,跑h=1.0单步 100000 次,画增量的直方图。正确实现应是标准正态;错误实现会在 0 处有一个尖峰、尾部明显更厚。 - 算峰度。
((d - d.mean())**4).mean() / d.var()**2,正确是 $\approx3$,错误是 $\approx9$。 - 反向的坑:如果你把
diffusion_coefficient改对了,却也去动了EulerMaruyamaSimulator.step(比如删掉里面的randn_like),那就变成完全没有噪声的 ODE 了。只改一处。
4. Part 3:训练——Flow Matching 与 Score Matching
前面四个 TODO 把「条件的东西」全写完了。现在进入 lab 的第二幕:把条件量回归成边际量。
要学的对象是边际向量场
$$ u_t^{\text{ref}}(x)=\E_{z\sim p_t(z\mid x)}\big[u_t^{\text{ref}}(x\mid z)\big], $$其中 $p_t(z\mid x)\propto p_t(x\mid z)p_{\text{data}}(z)$ 是后验。这个期望我们既算不出来也采不了样(后验里含未知的 $p_t(x)$)。Flow matching 的全部技巧就在于:条件期望是 $L^2$ 意义下的最优回归量,所以
$$ u_t^{\text{ref}}(x)=\argmin_{f}\ \E_{z\sim p_t(z\mid x)}\big[\norm{f(x)-u_t^{\text{ref}}(x\mid z)}^2\big], $$把它对 $x\sim p_t$ 再取一次期望、换成联合采样,就得到可直接蒙特卡洛估计的条件 flow matching 损失
$$ \mathcal L_{\text{CFM}}(\theta)=\E_{t\sim\mathcal U[0,1),\ z\sim p_{\text{data}},\ x\sim p_t(\cdot\mid z)}\Big[\big\|u_t^\theta(x)-u_t^{\text{ref}}(x\mid z)\big\|^2\Big]. $$问题 3.1:条件 Flow Matching 的训练损失
题目在问什么。用一个 batch 的蒙特卡洛样本估计上面那个期望,返回一个标量张量。
数学依据。照抄公式里的下标即可,采样顺序严格按依赖关系来:
$$ \frac1N\sum_{i=1}^N\big\|u_{t_i}^\theta(x_i)-u_{t_i}^{\text{ref}}(x_i\mid z_i)\big\|^2, \quad z_i\sim p_{\text{data}},\ t_i\sim\mathcal U[0,1),\ x_i\sim p_{t_i}(\cdot\mid z_i). $$注意 $x_i$ 的分布依赖 $z_i$ 和 $t_i$,所以必须最后采;$z_i$ 与 $t_i$ 相互独立,先后无所谓。
参考实现。
class ConditionalFlowMatchingTrainer(Trainer):
def __init__(self, path, model: MLPVectorField, **kwargs):
super().__init__(model, **kwargs)
self.path = path
def get_train_loss(self, batch_size: int) -> torch.Tensor:
# 第一步:采条件变量 z ~ p_data
z = self.path.sample_conditioning_variable(batch_size) # (bs, dim)
# 第二步:采时间 t ~ U[0,1)。.to(z) 同时对齐 dtype 和 device
t = torch.rand(batch_size, 1).to(z) # (bs, 1)
# 第三步:采位置 x ~ p_t(. | z)
x = self.path.sample_conditional_path(z, t) # (bs, dim)
# 第四步:回归条件向量场
ut_theta = self.model(x, t) # (bs, dim)
ut_ref = self.path.conditional_vector_field(x, z, t) # (bs, dim)
error = torch.einsum('bd,bd -> b', ut_theta - ut_ref, ut_theta - ut_ref) # (bs,)
return torch.mean(error) # 标量
为什么这样写。
torch.rand(batch_size, 1)而不是torch.rand(batch_size):形状必须是(bs,1),理由见第 1 节的形状约定。.to(z)是个惯用法:把t的 dtype 和 device 一次性对齐到z。写.to(device)也行,但依赖外部的全局变量;.to(z)自包含。torch.rand的取值范围是 $[0,1)$,右端开区间正好避开了 $t=1$ 处 $\beta_t=0$ 的奇点。这不是巧合,是题面刻意选的。如果你自作主张写成torch.rand(...)*1.0001或者用linspace(0,1,bs),就会踩到除零。torch.einsum('bd,bd->b', e, e)计算的是每个样本的平方范数 $\norm{e_i}^2$,等价于(e**2).sum(dim=-1)。用哪种都行;einsum 版本在高维时更明确地表达了「只对特征维求和、保留 batch 维」的意图。不要写成torch.nn.functional.mse_loss的默认行为——那会额外除以dim,损失数值差一个常数因子 2(本 lab 中dim=2),虽然不影响优化方向,但会让你无法和别人的损失曲线对比。- 不需要
detach任何东西:ut_ref全部由不含参数的解析式算出,本来就没有梯度路径。(但如果你的 path 里不小心用了带参数的模块,就要小心了。)
这是题面反复强调("the loss should converge, but not to zero!")却没解释的一点,也是理解 flow matching 的关键。
做一次经典的偏差–方差分解。固定 $t$ 和 $x$,记 $U=u_t^{\text{ref}}(x\mid z)$,其中 $z\sim p_t(z\mid x)$ 是随机的。对任意确定的 $f(x)$:
$$ \E\big[\norm{f(x)-U}^2\,\big|\,x\big] =\underbrace{\norm{f(x)-\E[U\mid x]}^2}_{\text{可优化:模型误差}} +\underbrace{\tr\Cov(U\mid x)}_{\text{不可消除}} . $$第一项在 $f=u_t^{\text{ref}}$(即边际向量场)时归零;第二项与 $f$ 完全无关,它是「给定位置 $x$,产生它的那些不同 $z$ 所对应的条件向量场之间的散布程度」。所以最优损失是
$$ \mathcal L_{\text{CFM}}^\star=\E_{t,x}\big[\tr\Cov\big(u_t^{\text{ref}}(x\mid z)\mid x\big)\big]\ >\ 0 . $$直觉版本:在 $t=0.3$、$x$ 位于原点附近时,这个 $x$ 可能来自五个模式中的任何一个 $z$,五个 $z$ 给出的条件向量场指向五个完全不同的方向。网络能做的最好的事就是输出它们的平均,而残差(那五个方向相对平均的散布)永远消不掉。
更有用的推论是:$\mathcal L_{\text{CFM}}(\theta)=\mathcal L_{\text{FM}}(\theta)+C$,其中 $\mathcal L_{\text{FM}}$ 是「真正想最小化但算不出来」的边际损失,$C$ 是上面那个常数。两者的梯度完全相同,所以用 CFM 训练等价于用 FM 训练——这就是 flow matching 定理的全部内容。
实测数据(5000 epoch,batch 1000,Adam lr=1e-3):损失从 121 降到 20 附近后就在 15–30 之间震荡,不再下降。这个平台高度就是 $C$(叠加了 batch 采样噪声)。如果你的损失掉到接近 0,那反而说明写错了——最常见的原因是把回归目标写成了模型自己的输出,或者 $z$ 和 $x$ 传反了导致目标退化。
sample_marginal_path 在 $t=0,0.33,0.67,1$ 各采 1000 点(这不需要训练,是解析可采的)。中:用训练好的 $u_t^\theta$ 跑 1000 步 Euler 得到的样本,同样四个时刻。两图必须高度吻合——中心蓝团、中间的橙色扩散带、绿色的五条辐条、最终红色的五个模式,位置和形状都要对上。这就是训练成功的判据。右:100 条轨迹。注意它们从中心呈放射状分岔到五个模式,且互不相交——这是 ODE 解的唯一性(流是双射)的可视化体现,也是 flow 与 diffusion 在图像上最容易区分的特征。问题 3.2:条件 Score Matching 的训练损失
题目在问什么。把 3.1 的回归目标从条件向量场换成条件得分,训练 $s_t^\theta(x)\approx\nabla\log p_t(x)$。
数学依据。与 3.1 完全同构。边际化技巧对 score 同样成立:
$$ \nabla\log p_t(x)=\E_{z\sim p_t(z\mid x)}\big[\nabla\log p_t(x\mid z)\big], $$于是
$$ \mathcal L_{\text{CSM}}(\theta)=\E_{t\sim\mathcal U[0,1),\ z\sim p(z),\ x\sim p_t(\cdot\mid z)}\Big[\big\|s_t^\theta(x)-\nabla\log p_t(x\mid z)\big\|^2\Big]. $$这就是文献里的去噪得分匹配(denoising score matching, DSM)。它比原始 score matching(需要计算 $\tr\nabla^2\log p$,代价 $O(d^2)$)便宜得离谱,是整个扩散模型工业化的前提。
参考实现。
class ConditionalScoreMatchingTrainer(Trainer):
def __init__(self, path, model: MLPScore, **kwargs):
super().__init__(model, **kwargs)
self.path = path
def get_train_loss(self, batch_size: int) -> torch.Tensor:
# 前三步与 3.1 逐字相同
z = self.path.sample_conditioning_variable(batch_size) # (bs, dim)
t = torch.rand(batch_size, 1).to(z) # (bs, 1)
x = self.path.sample_conditional_path(z, t) # (bs, dim)
# 唯一的区别:回归目标换成条件得分
s_theta = self.model(x, t) # (bs, dim)
s_ref = self.path.conditional_score(x, z, t) # (bs, dim)
mse = torch.einsum('bd,bd -> b', s_theta - s_ref, s_theta - s_ref) # (bs,)
return torch.mean(mse)
为什么这样写。结构上没有新东西,但有一个数值特性必须知道:
条件得分 $\frac{\alpha_t z-x}{\beta_t^2}$ 的分母是 $\beta_t^2=1-t$。代入 $x=\alpha_t z+\beta_t\epsilon$ 得
$$ \nabla\log p_t(x\mid z)=\frac{\alpha_t z-(\alpha_t z+\beta_t\epsilon)}{\beta_t^2}=-\frac{\epsilon}{\beta_t}=-\frac{\epsilon}{\sqrt{1-t}} . $$所以回归目标的量级是 $\frac{1}{\sqrt{1-t}}$,在 $t\to1$ 时无界,其二阶矩 $\E[1/(1-t)]$ 在 $t\sim\mathcal U[0,1)$ 下发散(对数发散)。这意味着损失的期望本身在理论上是 $+\infty$,实际观测到的值完全由 batch 里最接近 1 的那个 $t$ 决定。
实测(1000 epoch,batch 1000):损失在 10 到 80 之间大幅跳动,末尾两个 epoch 分别是 10.6 和 19.3。这不是没收敛——从上面的图可以看到学出来的 score 场是正确的。工业实践里的标准做法是给损失加权(比如乘 $\beta_t^2$,就变成了 DDPM 的 $\epsilon$-prediction 损失,目标变成 $-\epsilon$,方差恒为 1),或者把 $t$ 的采样区间截断成 $[0,1-\varepsilon]$。本 lab 为了保持公式的原始形态没有做这些处理。
顺带一提:这也解释了为什么这里只训 1000 个 epoch 而 flow model 训了 5000——再多训收益也被方差淹没了。
问题 3.3:由向量场反解得分
题目在问什么。不再单独训练一个 score 网络,而是直接从已训好的 $u_t^\theta$ 算出 $\tilde s_t^\theta$,验证「高斯路径下向量场与得分是等价信息」。
题面把结论给了:
$$ u_t^{\text{ref}}(x)=a_t x+b_t\nabla\log p_t^{\text{ref}}(x),\qquad (a_t,b_t)=\left(\frac{\dot\alpha_t}{\alpha_t},\ \beta_t^2\frac{\dot\alpha_t}{\alpha_t}-\dot\beta_t\beta_t\right). $$但这条关系是整个 Lecture 3-A 的技术核心(讲义 Proposition 1),值得完整推一遍再反解。
第一步:两个边际化恒等式。边际向量场与边际得分都可以写成对后验 $p_t(z\mid x)$ 的条件期望。为了记号简洁,记 $\hat z(x)\triangleq\E_{z\sim p_t(z\mid x)}[z]$(后验均值,也就是「去噪器」)。
把 Q2.4 的条件得分代进边际化恒等式:
$$ s_t(x)=\E\!\left[\frac{\alpha_t z-x}{\beta_t^2}\,\middle|\,x\right] =\frac{\alpha_t\hat z(x)-x}{\beta_t^2}, $$这一步用到了「$x$ 在条件期望里是已知常量」,以及期望的线性性。关键观察:条件得分关于 $z$ 是线性的,所以边际得分只依赖后验的一阶矩。
同样地,把 Q2.3 的条件向量场代进去:
$$ u_t(x)=\E\!\left[\left(\dot\alpha_t-\frac{\dot\beta_t}{\beta_t}\alpha_t\right)z+\frac{\dot\beta_t}{\beta_t}x\,\middle|\,x\right] =\left(\dot\alpha_t-\frac{\dot\beta_t}{\beta_t}\alpha_t\right)\hat z(x)+\frac{\dot\beta_t}{\beta_t}x . $$第二步:消掉 $\hat z(x)$。从 score 的式子解出
$$ \hat z(x)=\frac{\beta_t^2 s_t(x)+x}{\alpha_t}\qquad(t>0,\ \alpha_t\ne0). $$第三步:代回向量场。
$$ \begin{aligned} u_t(x) &=\left(\dot\alpha_t-\frac{\dot\beta_t}{\beta_t}\alpha_t\right)\cdot\frac{\beta_t^2 s_t(x)+x}{\alpha_t}+\frac{\dot\beta_t}{\beta_t}x &&\text{(i) 代入}\\[3pt] &=\left(\frac{\dot\alpha_t}{\alpha_t}-\frac{\dot\beta_t}{\beta_t}\right)\big(\beta_t^2 s_t(x)+x\big)+\frac{\dot\beta_t}{\beta_t}x &&\text{(ii) 提出 }1/\alpha_t\\[3pt] &=\left(\beta_t^2\frac{\dot\alpha_t}{\alpha_t}-\dot\beta_t\beta_t\right)s_t(x) +\frac{\dot\alpha_t}{\alpha_t}x-\frac{\dot\beta_t}{\beta_t}x+\frac{\dot\beta_t}{\beta_t}x &&\text{(iii) 展开}\\[3pt] &=\underbrace{\frac{\dot\alpha_t}{\alpha_t}}_{a_t}x+\underbrace{\left(\beta_t^2\frac{\dot\alpha_t}{\alpha_t}-\dot\beta_t\beta_t\right)}_{b_t}s_t(x) &&\text{(iv) 两个 }\tfrac{\dot\beta_t}{\beta_t}x\text{ 抵消} \end{aligned} $$正向关系建立完毕。第四步:反解。只要 $b_t\ne0$(对 $t\in[0,1)$ 由单调性成立),
$$ s_t(x)=\frac{u_t(x)-a_t x}{b_t} =\frac{u_t(x)-\frac{\dot\alpha_t}{\alpha_t}x}{\beta_t^2\frac{\dot\alpha_t}{\alpha_t}-\dot\beta_t\beta_t} =\frac{\alpha_t u_t(x)-\dot\alpha_t x}{\beta_t^2\dot\alpha_t-\alpha_t\dot\beta_t\beta_t}. $$最后一个等号是分子分母同乘 $\alpha_t$——在数值上这个形式更好,因为它把 $\frac{\dot\alpha_t}{\alpha_t}$ 的奇点消掉了(见下文)。
代入本 lab 的调度:$a_t=\frac1t$,而
$$ \beta_t^2\dot\alpha_t-\alpha_t\dot\beta_t\beta_t=(1-t)\cdot1-t\cdot\left(-\frac{1}{2\sqrt{1-t}}\right)\sqrt{1-t}=1-t+\frac t2=1-\frac t2 , $$与题面给的一致。于是「数值友好版」写成
$$ \tilde s_t(x)=\frac{t\,u_t(x)-x}{1-\frac t2}. $$这个形式在 $t=0$ 处给出 $\tilde s_0(x)=-x$——正是 $\N(0,I_d)$ 的得分,完全正确。
参考实现。(下面写的是与题面字面一致的版本,也就是 ScoreFromVectorField 的标准解答;它在 $t=0$ 处会给出 nan,原因见后。)
class ScoreFromVectorField(torch.nn.Module):
"""由学到的向量场参数化 score(只对高斯条件概率路径成立)"""
def __init__(self, vector_field: MLPVectorField, alpha: Alpha, beta: Beta):
super().__init__()
self.vector_field = vector_field
self.alpha = alpha
self.beta = beta
def forward(self, x: torch.Tensor, t: torch.Tensor):
"""x: (bs, dim) t: (bs, 1) return: (bs, dim)"""
alpha_t = self.alpha(t) # (bs, 1)
beta_t = self.beta(t) # (bs, 1)
dt_alpha_t = self.alpha.dt(t) # (bs, 1)
dt_beta_t = self.beta.dt(t) # (bs, 1)
# s_t(x) = [u_t(x) - a_t x] / b_t
num = self.vector_field(x, t) - (dt_alpha_t / alpha_t) * x # (bs, dim)
den = beta_t ** 2 * dt_alpha_t / alpha_t - dt_beta_t * beta_t # (bs, 1)
return num / den
为什么这样写。
num是(bs,dim),den是(bs,1),相除自动广播——又一次靠形状约定省掉了unsqueeze。- 这个类没有自己的参数:
vector_field是外部传进来的已训模型,alpha/beta是纯函数。它只是一个「换坐标系」的包装器。这也印证了核心论断——学会了 $u_t^\theta$ 就等于免费得到了 $s_t^\theta$,不需要第二次训练。 - 题面在可视化时用
ts = torch.linspace(0.0, 0.9999, 4),右端取 $1-\varepsilon$ 而不是 1,就是为了避开 $b_t\big|_{t=1}=0$(此时 $1-\frac t2=\frac12\ne0$,所以其实是 $\dot\beta_t$ 在 $t=1$ 发散;有那个 $10^{-4}$ 兜底也不会真的炸,但取 $0.9999$ 更干净)。
看上图下排第一格:$t=0$ 时一根箭头都没有。很多人到这里会以为自己实现错了,反复检查代码。代码是对的,问题出在参数化本身。
转换公式里含 $a_t=\frac{\dot\alpha_t}{\alpha_t}=\frac1t$,$b_t=\beta_t^2\frac{\dot\alpha_t}{\alpha_t}-\dot\beta_t\beta_t=\frac{1-t}{t}+\frac12$。当 $t\to0^+$ 时分子分母同时以 $1/t$ 的速度发散。在 $t$ 精确等于 0 时,float32 下:
| $t$ | $a_t=\dot\alpha_t/\alpha_t$ | $b_t$ | $\tilde s_t(x)$ 在 $x=(3,-2)$ 处(取 $u\equiv0$) |
|---|---|---|---|
| $0$ | inf | inf | nan, nan($\infty/\infty$) |
| $10^{-6}$ | $1.0\times10^{6}$ | $1.0\times10^{6}$ | $-3.000,\ 2.000$ |
| $10^{-3}$ | $1.0\times10^{3}$ | $999.5$ | $-3.002,\ 2.001$ |
| $0.33$ | $3.030$ | $2.530$ | $-3.593,\ 2.395$ |
也就是说:在 $t=0$ 处 num 和 den 都是 inf,相除得 nan,而 matplotlib 的 quiver 遇到 nan 就什么都不画——于是整格空白。
这是「可去奇点 + 浮点数下不可去」的经典组合。数学上极限存在且正确:
$$ \lim_{t\to0^+}\tilde s_t(x)=\lim_{t\to0^+}\frac{t\,u_t(x)-x}{1-\frac t2}=-x=\nabla\log\N(x;0,I_d), $$确实等于 $p_0=\N(0,I_d)$ 的真实得分(表格里 $t=10^{-6}$ 一行算出 $(-3,2)$,正是 $-x$,完美吻合)。但代码里先算 $\frac{\dot\alpha_t}{\alpha_t}$ 再相减,等于先把 $1/t$ 显式地造出来,$t=0$ 时它就是 inf,之后的一切都被污染。
三种正确的应对方式,按推荐程度排序:
- 用代数化简后的形式:$\tilde s_t(x)=\dfrac{\alpha_t u_t(x)-\dot\alpha_t x}{\beta_t^2\dot\alpha_t-\alpha_t\dot\beta_t\beta_t}=\dfrac{t\,u_t(x)-x}{1-t/2}$。分子分母同乘 $\alpha_t$ 之后奇点被解析地消掉了,$t=0$ 直接给出 $-x$,无需任何特判。这是最干净的做法。
- 把求值点从 $0$ 挪到 $\varepsilon$:题面在右端点用 $0.9999$ 代替 $1$,左端点同理可用 $10^{-4}$ 代替 $0$。实践中所有扩散/流模型的采样器都会做这种端点截断。
- 如果一定要保留题面的字面形式,就接受这一格是空的,并且知道原因。这也是官方 solutions 的图长的样子。
为什么必须区分「固有奇点」与「实现错误」?因为二者的排查方向完全相反。实现错误会让所有 $t$ 的结果都偏;固有奇点只在特定端点发作,而且随 $t$ 离开端点会迅速恢复正常——上表里 $t=10^{-6}$ 就已经完全正确了。判别方法:把 $t$ 从 $0$ 改成 $10^{-6}$ 重跑,如果结果立刻变正常,那就是奇点;如果依旧是错的,才是你的实现有问题。
补充一句实践意义:这也是为什么真实的扩散模型采样器(DDIM、EDM 等)几乎从不在 $t$ 的端点求值,而是从 $t_{\min}=10^{-3}$ 之类的地方开始/结束。$t\to0$(纯噪声端)和 $t\to1$(纯数据端)在各种参数化下都容易出现 $0/0$ 或 $\infty/\infty$,避开它们几乎不损失精度,却能省掉大量数值陷阱。
不需要训练网络就能验证这条恒等式:随机采一批 $x,z,t$($t$ 取在 $[0.15,0.85]$ 避开两端),用条件量代替边际量(数学上当 $p_{\text{data}}$ 退化为单点时二者相等,但这条线性恒等式对任意 $x,z,t$ 逐点成立),检查双向:
- 正向 $u_t\overset{?}{=}a_t x+b_t s_t$:本仓库测试最大误差 $1.43\times10^{-6}$。
- 反向 $s_t\overset{?}{=}(u_t-a_t x)/b_t$:最大误差 $2.86\times10^{-6}$。
两个方向都测,能同时抓住「系数写错」和「反解时代数变形出错」两类 bug。
5. Part 4:线性概率路径与任意分布之间的桥
前三部分的高斯路径有一个隐含限制:$p_{\text{simple}}$ 必须是标准高斯(因为 $p_0(x\mid z)=\N(0,\beta_0^2 I)=\N(0,I)$ 与 $z$ 无关这件事,是靠「高斯 + $\alpha_0=0$」才成立的)。Part 4 换一条路径,把这个限制拿掉。
线性条件概率路径(linear conditional probability path)定义为插值随机变量
$$ X_t=(1-t)X_0+t\,z,\qquad X_0\sim p_{\text{simple}} , $$$p_t(\cdot\mid z)$ 就是 $X_t$ 的分布。边界立刻成立:$t=0$ 时 $X_0\sim p_{\text{simple}}$,$t=1$ 时 $X_1\equiv z$ 即 $\delta_z$。
- 好处:$p_{\text{simple}}$ 可以是任何能采样的分布——不需要密度、不需要高斯。这直接打开了「任意分布 A 到任意分布 B 的桥接」这个应用(图像翻译、蛋白结构生成的先验注入等等)。
- 代价:$p_t(x\mid z)$ 一般没有闭式密度(它是 $p_{\text{simple}}$ 的一个缩放平移版,只有 $p_{\text{simple}}$ 是高斯时才好写)。因此条件得分算不出来——题面直接让
conditional_score抛异常。这意味着线性路径不能直接做 score matching,也就不能用 Langevin 增广的 SDE 采样,只能跑 ODE。
注意当 $p_{\text{simple}}=\N(0,I)$ 时,线性路径其实是高斯路径的一个特例:$\alpha_t=t$、$\beta_t=1-t$(而不是本 lab Part 2 用的 $\sqrt{1-t}$)。这个特例就是 rectified flow / 条件最优传输路径。
问题 4.1:实现线性条件概率路径
题目在问什么。实现 sample_conditional_path 与 conditional_vector_field 两个方法。
数学依据。采样直接照抄定义。向量场用与 Q2.3 完全相同的三步法(写流 → 求导 → 反解代回):
条件流是
$$ \psi_t(x_0)=(1-t)x_0+t z . $$对 $t$ 求导:
$$ \frac{\dd{}}{\dd{t}}\psi_t(x_0)=z-x_0 . $$这是一个与 $t$ 无关的常向量——线性路径的轨迹是匀速直线运动。这也是它被叫做「rectified(拉直的)flow」的原因。
令 $x=\psi_t(x_0)$,反解 $x_0=\dfrac{x-tz}{1-t}$($t\in[0,1)$),代回:
$$ \begin{aligned} u_t(x\mid z)&=z-\frac{x-tz}{1-t} =\frac{(1-t)z-x+tz}{1-t} =\frac{z-x}{1-t}. \end{aligned} $$检查:把 $x=(1-t)x_0+tz$ 代回 $\frac{z-x}{1-t}$,得 $\frac{z-(1-t)x_0-tz}{1-t}=\frac{(1-t)(z-x_0)}{1-t}=z-x_0$ ✓,与直接求导的结果一致。
参考实现。
class LinearConditionalProbabilityPath(ConditionalProbabilityPath):
def __init__(self, p_simple: Sampleable, p_data: Sampleable):
super().__init__(p_simple, p_data)
def sample_conditioning_variable(self, num_samples: int) -> torch.Tensor:
return self.p_data.sample(num_samples) # (num_samples, dim)
def sample_conditional_path(self, z: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
"""X_t = (1-t) X_0 + t z,X_0 ~ p_simple"""
# 关键:p_simple 不再限定为高斯,必须真的从它采样
x0 = self.p_simple.sample(z.shape[0]) # (num_samples, dim)
return (1 - t) * x0 + t * z # (num_samples, dim)
def conditional_vector_field(self, x, z, t) -> torch.Tensor:
"""u_t(x|z) = (z - x) / (1 - t),只在 t in [0,1) 上有定义"""
return (z - x) / (1 - t) # (num_samples, dim)
def conditional_score(self, x, z, t) -> torch.Tensor:
raise Exception("You should not be calling this function!")
为什么这样写。
- 必须写
self.p_simple.sample(...),不能写torch.randn_like(z)。这是本题唯一真正的考点。在 Part 2 里randn_like是对的(因为那里 $p_{\text{simple}}$ 就是标准高斯),惯性一上来就会写错。写成randn_like后果是:问题 4.3 里明明把p_simple设成了圆环分布,训出来的模型起点却是高斯——真值行和学习行在 $t=0$ 那一列会一个是圆环、一个是高斯团,一眼可见。 z.shape[0]而不是硬编码的batch_size:这个方法会被sample_marginal_path以各种 batch 大小调用(可视化时用到 100000),必须跟着z走。(1 - t) * x0:t是(bs,1),x0是(bs,dim),广播照旧。conditional_score主动抛异常而不是返回None或 0:这是好的防御式设计。如果有人不小心把ConditionalScoreMatchingTrainer套在线性路径上,会立刻得到明确的报错,而不是训练出一个悄悄错误的模型。
- 用
torch.randn_like(z)代替self.p_simple.sample()(见上)。 - 向量场写成 $z-x$。$z-x_0$ 才是常向量,$z-x$ 不是;漏掉 $\frac{1}{1-t}$ 会让粒子走不到终点(速度随着接近 $z$ 而衰减,是指数逼近而非有限时间到达)。判别方法见下面的 tip。
- 担心 $t=1$ 除零而加 clamp。同 Q2.3,不要加。
torch.rand与模拟循环都保证不会在 $t=1$ 求值。 - 在
sample_conditional_path里复用同一批x0。有人为了「效率」把x0缓存下来,这会让 $p_t(x\mid z)$ 变成一个退化分布,训练目标随之失效。每次调用都要重新采。
取一批 $x_0,z$,令 $x_t=(1-t)x_0+tz$,则你的实现必须满足
$$ u_t(x_t\mid z)=z-x_0\quad\text{对所有 }t\text{ 相同}. $$本仓库测试的最大误差 $5.96\times10^{-7}$。如果你误写成 $z-x$,这个测试会立刻以 $O(1)$ 的误差失败。
sample_conditional_path 直接采样;第二行:用你实现的 conditional_vector_field 跑 500 步 Euler;判据是这两行逐格重合——这是「采样公式」与「向量场公式」互相印证的独立检验,比对着标准答案抄可靠得多。可以看到条件云的半径按 $1-t$ 线性收缩(对比 Part 2 高斯路径的 $\sqrt{1-t}$,后者在早期收缩更慢),$t=1$ 时坍缩到红星 $z$。第三行是边际路径:$z$ 不再固定而是每次重采,于是从高斯团逐渐长出棋盘格结构——注意棋盘的空白格在 $t=0.75$ 时已经清晰可辨。问题 4.2:用线性路径做 Flow Matching
这一小节没有新代码:直接把 Q3.1 写好的 ConditionalFlowMatchingTrainer 套到 LinearConditionalProbabilityPath 上即可。这本身就是一个设计上的验收点——如果你的 trainer 里写死了任何高斯路径特有的东西(比如直接用 path.alpha),这里就会报错。正确的 trainer 只通过 ConditionalProbabilityPath 的抽象接口说话。
path = LinearConditionalProbabilityPath(
p_simple = Gaussian.isotropic(dim=2, std=1.0),
p_data = CheckerboardSampleable(device, grid_size=4),
).to(device)
linear_flow_model = MLPVectorField(dim=2, hiddens=[64, 64, 64, 64])
trainer = ConditionalFlowMatchingTrainer(path, linear_flow_model)
losses = trainer.train(num_epochs=10000, device=device, lr=1e-3, batch_size=2000)
实测:损失从 19.1 降到 8 左右后进入平台(末尾几个 epoch 在 7.9–8.3 之间)。同样地,它不会到 0,理由与 Q3.1 完全相同。
问题 4.3:在任意源与目标之间架桥
题目在问什么。把 $p_{\text{simple}}$ 从高斯换成另一个「有形状」的分布,观察会发生什么。参考配置是同心圆环 → 棋盘:
path = LinearConditionalProbabilityPath(
p_simple = CirclesSampleable(device), # 两个同心圆环
p_data = CheckerboardSampleable(device, grid_size=4),
).to(device)
bridging_flow_model = MLPVectorField(dim=2, hiddens=[100, 100, 100, 100])
trainer = ConditionalFlowMatchingTrainer(path, bridging_flow_model)
losses = trainer.train(num_epochs=20000, device=device, lr=1e-3, batch_size=2000)
注意配置的变化:隐藏层从 64 加宽到 100,epoch 从 10000 翻到 20000。这不是随手调的——源分布本身有结构之后,需要学的映射复杂得多。
self.p_simple.sample() 误写成 torch.randn_like(z),这一格的下排会是个高斯团而不是圆环,一眼露馅。中间三列展示了一个有趣的现象:$t=0.25$ 时圆环的中心空洞还清晰可见(下排甚至比上排保留得更明显),$t=0.5$ 时结构基本被抹平成一团,$t=0.75$ 起棋盘格开始浮现。也就是说插值路径会先「溶解」源结构再「结晶」出目标结构,中间时刻的分布既不像源也不像目标。把配置换着跑几轮之后,有四条观察是稳定可复现的,每条背后都有原因:
- 换源分布不需要改任何代码,也不需要重新推公式。这是线性路径最重要的性质。对比之下,高斯路径要换源分布就得重新推 $u_t(x\mid z)$,而且多数情况下推不出闭式。原因:线性插值的条件流 $\psi_t(x_0)=(1-t)x_0+tz$ 对任何 $x_0$ 都有定义,反解 $x_0=\frac{x-tz}{1-t}$ 也不依赖 $p_{\text{simple}}$ 的形式——整个推导里 $p_{\text{simple}}$ 从头到尾没出现过。
- 源分布越「有结构」,训练越难,需要的容量和步数越多。高斯源 → 棋盘用 4×64 的 MLP 训 10000 步就够;圆环源 → 棋盘要 4×100 训 20000 步,损失还停在 22 左右(对比高斯源的 8)。原因:损失的不可消除项是 $\E[\tr\Cov(u_t(x\mid z)\mid x)]$。源分布有结构时,同一个中间位置 $x$ 可能由「圆环上很不同的起点 + 棋盘上很不同的终点」这么多组 $(x_0,z)$ 配对产生,条件方差更大,平台更高。
- 中间时刻的分布既不像源也不像目标,而是一团糊。$t=0.5$ 时圆环和棋盘的结构都基本消失。原因:$X_t=(1-t)X_0+tz$ 且 $X_0\perp z$,两个独立随机变量的加权和会发生「卷积平滑」,其结果的结构总是比任一分量更弱(类似中心极限定理的效果)。这也提示了一个实践问题:如果你希望中间时刻仍保留语义(比如做图像插值),线性路径不是好选择,需要在配对上做文章(如最优传输配对 / mini-batch OT)。
- ODE 轨迹不能交叉,但「配对」是随机的。由于 $X_0$ 和 $z$ 独立采样,训练时看到的是所有可能配对的平均。学出来的边际向量场会把轨迹「捋直捋平」,但代价是它并不实现任何有意义的一一对应——圆环上相邻的两个点未必被送到棋盘上相邻的位置。想要有意义的配对,需要 rectified flow 的多轮重流(reflow)或最优传输配对。
6. 如何验证自己的实现
肉眼比对散点图能抓住大部分错误,但对「系数差一点」「符号在某个分支下写反」这类问题非常不敏感。下面这套数值自查覆盖了 Lab 2 的全部 8 个 TODO,本仓库把它们写成了 labs/tests/test_labs.py,Lab 2 部分共 12 项,在本地环境全部通过。你可以照着这个表自己搭一遍——每一项都只要几行。
| # | 检查项 | 怎么测 | 实测误差 | 能抓住的错误 |
|---|---|---|---|---|
| 1 | $\alpha_0=0,\ \alpha_1=1$ | 直接求值 | 通过 | 调度方向搞反 |
| 2 | $\beta_0=1,\ \beta_1=0$ | 直接求值 | 通过 | 同上 |
| 3 | alpha.dt = 自动微分 | torch.autograd.grad 对拍 | 0.00e+00 | 解析导数抄错 |
| 4 | beta.dt = 自动微分 | 同上(容差放宽到 2e-3) | 2.17e-04 | 导数写错、$\varepsilon$ 加得太大 |
| 5 | 条件路径均值 $=\alpha_t z$ | 20 万样本求经验均值 | 0.0028 | $\alpha$/$\beta$ 位置写反 |
| 6 | 条件路径标准差 $=\beta_t$ | 同上求经验标准差 | 0.0011 | 忘了开根号、用了方差 |
| 7 | 条件向量场 $=\frac{\dd{}}{\dd{t}}\psi_t(x_0)$ | 沿轨迹逐点对拍(见 Q2.3) | 9.54e-07 | Q2.3 的任何系数或符号错误 |
| 8 | 条件得分 = 高斯对数密度的梯度 | autograd 对 $x$ 求梯度 | 9.54e-07 | 符号反、分母写成 $\beta_t$ |
| 9 | 正向 $u_t=a_tx+b_ts_t$ | 逐点代入恒等式 | 1.43e-06 | $a_t,b_t$ 公式记错 |
| 10 | 反向 $s_t=(u_t-a_tx)/b_t$ | 同上,反方向 | 2.86e-06 | Q3.3 的代数变形出错 |
| 11 | 线性路径 $u_t(x_t\mid z)=z-x_0$ | 沿插值轨迹检查常向量性 | 5.96e-07 | 漏掉 $\frac1{1-t}$、写成 $z-x$ |
| 12 | CFM 目标有限且非退化 | 单点 $p_{\text{data}}$ 下检查 isfinite 与幅度 | 幅度 1.092 | 目标全零 / 出现 nan |
几条使用建议:
- 第 7 项和第 11 项性价比最高。它们不需要训练、不需要画图,几行代码,却能以 $10^{-7}$ 的分辨率钉死两个最容易出错的公式。任何时候改动了向量场,先跑这两项。
- 测试里 $t$ 一律采在 $[0.02,0.92]$ 之类的内部区间,主动避开 $t=0$ 和 $t=1$。这不是作弊——那两个端点的行为是参数化的固有性质,不属于「实现是否正确」的范畴(见第 4 节末尾的讨论)。
- 第 3 项误差恰好为 0,是因为 $\dot\alpha_t\equiv1$ 在浮点下是精确表示的;第 4 项误差 $2.17\times10^{-4}$ 恰好是 $10^{-4}$ 稳定项的量级,说明除了那个刻意引入的偏差之外没有别的误差。看误差的量级比看「是否小于阈值」信息量大得多。
- 训练相关的部分(Q3.1 / Q3.2 / Q4.2)没法做严格的单元测试,只能看两件事:损失是否收敛到一个正的平台(不是 0,也不是发散),以及真值/学习两行图是否吻合。
| 训练任务 | 配置 | 初始损失 | 收敛后损失 |
|---|---|---|---|
| Q3.1 高斯路径 flow matching | 4×64 MLP,5000 ep,bs 1000 | ≈ 121 | ≈ 20(在 15–30 震荡) |
| Q3.2 高斯路径 score matching | 4×64 MLP,1000 ep,bs 1000 | ≈ 20 | 10–80 大幅震荡(见前文解释) |
| Q4.2 线性路径(高斯 → 棋盘) | 4×64 MLP,10000 ep,bs 2000 | ≈ 19 | ≈ 8 |
| Q4.3 线性路径(圆环 → 棋盘) | 4×100 MLP,20000 ep,bs 2000 | ≈ 32 | ≈ 22 |
7. 本 lab 小结
把八个 TODO 串起来,Lab 2 其实只讲了一件事的两个版本(向量场版与得分版),外加一次「换路径」的泛化演练。
| 对象 | 公式 | 在代码里是谁 |
|---|---|---|
| 高斯条件路径 | $p_t(x\mid z)=\N(\alpha_t z,\beta_t^2 I_d)$ | GaussianConditionalProbabilityPath |
| 条件流 | $\psi_t(x_0)=\alpha_t z+\beta_t x_0$ | sample_conditional_path(Q2.2) |
| 条件向量场 | $u_t(x\mid z)=\left(\dot\alpha_t-\frac{\dot\beta_t}{\beta_t}\alpha_t\right)z+\frac{\dot\beta_t}{\beta_t}x$ | conditional_vector_field(Q2.3) |
| 条件得分 | $\nabla_x\log p_t(x\mid z)=\frac{\alpha_t z-x}{\beta_t^2}$ | conditional_score(Q2.4) |
| CFM 损失 | $\E\big\|u_t^\theta(x)-u_t(x\mid z)\big\|^2$ | ConditionalFlowMatchingTrainer(Q3.1) |
| CSM 损失 | $\E\big\|s_t^\theta(x)-\nabla\log p_t(x\mid z)\big\|^2$ | ConditionalScoreMatchingTrainer(Q3.2) |
| 转换公式 | $u_t=\frac{\dot\alpha_t}{\alpha_t}x+\left(\beta_t^2\frac{\dot\alpha_t}{\alpha_t}-\dot\beta_t\beta_t\right)s_t$ | ScoreFromVectorField(Q3.3) |
| 线性条件路径 | $X_t=(1-t)X_0+tz$,$u_t(x\mid z)=\frac{z-x}{1-t}$ | LinearConditionalProbabilityPath(Q4.1) |
四条值得带走的结论:
- 「条件可算,边际可学」是整套方法的支点。八个 TODO 里有四个在写条件量的闭式解,两个在写「对条件量做回归」的损失。之所以能这么做,全靠一条 $L^2$ 事实:条件期望是最优回归量。理解了这一条,flow matching、score matching、DDPM、rectified flow 就都是同一件事的不同外衣。
- 损失不收敛到 0 是正确的,而且是必然的。残差 $\E[\tr\Cov(u_t(x\mid z)\mid x)]$ 与模型无关,是数据分布本身的属性。看到平台不要以为没训好;看到接近 0 反而要查代码。
- 高斯路径下向量场与得分是同一个东西的两种坐标。Q3.3 用一个不含参数的包装器就把 $u_t^\theta$ 变成了 $s_t^\theta$,两条独立训练出来的场在图上定性一致。这解释了为什么文献里 $\epsilon$-prediction、$x_0$-prediction、$v$-prediction、flow matching 能互相转换。
- 端点是数值的雷区,要学会区分「固有奇点」和「实现错误」。$t\to1$ 时 $\dot\beta_t\to-\infty$、$t\to0$ 时 $\frac{\dot\alpha_t}{\alpha_t}\to\infty$——这些都不是 bug,而是参数化的固有性质。正确的应对是代数化简或端点截断,不是盲目加 clamp。
做完这个 lab,你手上就有了一套完整的、能跑通的二维生成模型。Lab 3 会把同样的骨架搬到真实数据(MNIST)上,把 MLP 换成 Diffusion Transformer,并加上 VAE 潜空间与 classifier-free guidance——但训练循环的那四行,和你在 Q3.1 写下的一模一样。
延伸阅读
- Lecture 2 · Flow Matching — 本 lab Q2.1–Q2.3、Q3.1、Q4.1 的理论出处:条件路径、条件向量场、边际化技巧与 flow matching 定理。
- Lecture 3-A · 得分函数与 Score Matching — Q2.4、Q3.2、Q3.3 的理论出处:高斯 score 闭式解、SDE extension trick、$u_t$ 与 $s_t$ 的转换公式(Proposition 1)。
- Flow Matching for Generative Modeling (Lipman et al., 2022) — 本 lab 全部公式的原始论文,§3 与 §4 与 Q2.3 的推导逐行对应。
- Flow Straight and Fast: Rectified Flow (Liu et al., 2022) — Part 4 线性路径的来源,以及「多轮 reflow 把轨迹拉直」的完整方案,正好回答 Q4.3 观察里的第 4 条。
- Generative Modeling by Estimating Gradients of the Data Distribution (Song & Ermon, 2019) — 去噪得分匹配用于生成的开山之作,Q3.2 损失的直接来源。
- Score-Based Generative Modeling through SDEs (Song et al., 2020) — 把 score matching 与 SDE / 概率流 ODE 统一起来,第 3 节讲的 Euler–Maruyama 细节在这篇里有严谨表述。
- Elucidating the Design Space of Diffusion Models (Karras et al., 2022) — 关于调度函数 $\alpha_t,\beta_t$ 的设计空间与端点截断的工程实践,可以直接对照 Q2.1 里那个 $10^{-4}$ 的讨论。