LAB 02

Lab 2 解析:Flow Matching 与 Score Matching

把 Lecture 2 / Lecture 3-A 的两条公式链,一行一行落成能跑出图的 PyTorch 代码:条件概率路径 → 条件向量场 → 条件得分 → 两个训练损失 → 二者之间的线性互换。

原始文件:labs/lab_two.ipynb 对应讲义:§3–§4 本页解答已在 RTX 5080 上完整跑通

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 全部围绕这一句展开——前四个负责把「条件的东西」写出来,后四个负责把「回归」做出来。

速览:本 lab 有 8 处需要你写代码
  • 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 唯一想给你的东西。

所以本页的定位是写完之后的对照与查错,不是替你交作业。建议的用法是:

  1. 先自己写。写不出来就只看「数学依据」小节,不看代码。
  2. 写完之后跑图,把你的图和本页给出的真实运行结果图逐张对比。
  3. 如果图不对,先看「常见错误」小节,那里列的每一条都是真的会踩到的。
  4. 最后看「参考实现」确认细节(尤其是张量形状和广播)。

另外提醒一点:本页在第 3 节指出了学生版 notebook 里的一个真实 bug。如果你严格按题面写,那一处代码会给出错误的随机过程。这不是你的错,但你需要知道。

p_simple 与 p_data 的密度热图
本 lab 前三部分要连接的两端:左边红色是源分布 $p_{\text{simple}}=\N(0,I_2)$,中间蓝色是目标分布 $p_{\text{data}}$(五个模式的对称高斯混合,模式半径 10、标准差 1),右边叠在一起。注意两者几乎不重叠——这正是问题的难点:要把中间那一小团概率质量搬运到外围五个远处的模式上去。后面所有图的红蓝底色都是这两张热图。

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 + SimulatorLab 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$ 不扔掉,而是留下来算回归目标。

张量形状约定(贯穿全 lab,写错这里比写错公式更常见)
  • $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$ 必须是随机的,否则回归目标就退化成了确定性映射,学不到条件期望。
固定 z 时条件概率路径在 7 个时刻的样本云
验证 Q2.2:固定一个 $z$(红星),画出 $t=0,\frac16,\dots,1$ 七个时刻各 1000 个样本。要看的有两点:(i) 样本云的中心沿直线从原点匀速移向 $z$——这是 $\alpha_t=t$ 线性的直接后果;(ii) 云的半径单调收缩,$t=0$ 时是标准差 1 的圆,$t=1$ 时坍缩成一个点。如果你的图里云的中心不动、或者半径不收缩,说明 $\alpha$/$\beta$ 用反了。

问题 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 住反而会引入错误的动力学。
检验 Q2.3 最有力的方法(比看图可靠得多)

直接验证第二步那条定义式:随机采一批 $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}$ 以上。

条件 ODE 的样本与轨迹,与真值条件路径对比
验证 Q2.3 的可视化版本。左:直接用 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 唯一一处需要真正「求导一个密度」的地方,但因为分布是高斯,两行就能算完。

推导:多元各向同性高斯的 score

写出对数密度:

$$ \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}$。

常见错误(Q2.3 / Q2.4 合并)
  • 对 $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$ 求值。理解这一点能省掉很多「为什么没炸」的困惑。
条件 SDE 的样本与轨迹
验证 Q2.4:把条件得分加进去构成 Langevin 增广 SDE $\dd{X_t}=\left[u_t(X_t\mid z)+\frac{\sigma^2}{2}\nabla\log p_t(X_t\mid z)\right]\dd{t}+\sigma\,\dd{W_t}$,取 $\sigma=2.5$。左边仍是真值条件路径,中间是 SDE 采样。判据:中图每个时刻的样本云必须与左图重合——这正是 Lecture 3-A 的 SDE extension trick 所断言的「加噪声不改变边际」。右图的轨迹明显是抖动的布朗曲线(对比上一张图的光滑射线),但终点依然精确收敛到红星 $z$。如果你的中图样本云比左图胖,说明扩散系数被多乘了噪声——见下一节。

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 里不小心用了带参数的模块,就要小心了。)
为什么损失会收敛,但不会收敛到 0

这是题面反复强调("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$ 传反了导致目标退化。

真值边际概率路径与学到的边际 ODE 采样对比
验证 Q3.1 的核心判据图。左:真值边际概率路径,用 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)

为什么这样写。结构上没有新东西,但有一个数值特性必须知道:

CSM 的损失曲线比 CFM 抖得多,这是正常的

条件得分 $\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——再多训收益也被方差淹没了。

用学到的向量场与得分构成的 Langevin SDE 采样
验证 Q3.1 + Q3.2 的联合结果:把两个学到的网络塞进 $\dd{X_t}=\left[u_t^\theta(X_t)+\frac{\sigma^2}{2}s_t^\theta(X_t)\right]\dd{t}+\sigma\dd{W_t}$,$\sigma=2.0$,300 步。左中两图仍需吻合,说明加了 Langevin 项后边际分布确实没被破坏(这次是用两个都有误差的神经网络验证的,比第 2 节用解析式验证更有说服力)。右图的轨迹与上一张的 ODE 轨迹对比最能说明问题:SDE 轨迹是抖动的、会互相穿过的,而 ODE 轨迹光滑且不相交。这就是随机采样带来的「自我纠错」能力的来源——轨迹可以在模式之间重新分配,从而部分修正 $u_t^\theta$ 的估计误差。

问题 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),值得完整推一遍再反解。

完整推导:先建立正向关系 $u_t=a_tx+b_ts_t$

第一步:两个边际化恒等式。边际向量场与边际得分都可以写成对后验 $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$ 更干净)。
独立训练的 score 场与由向量场反解的 score 场对比
验证 Q3.3。上排:Q3.2 独立训练出来的 $s_t^\theta$;下排:本题由 $u_t^\theta$ 反解出的 $\tilde s_t^\theta$。四列分别是 $t=0,0.33,0.67,0.9999$。判据不是「两排一模一样」(两个网络有各自的误差),而是箭头在数据模式附近指向一致——都应该指向蓝色团的中心(概率密度上升方向)。$t=0.33$ 起两排的定性结构就吻合了:五个蓝团是汇(sink),红色的源分布中心区域箭头向外发散。下排最左格完全没有箭头,这不是画图失败,见下面的分析。
$t=0$ 处的箭头为什么全部消失——这是参数化的固有奇点,不是你写错了

看上图下排第一格:$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$infinfnan, 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,之后的一切都被污染。

三种正确的应对方式,按推荐程度排序:

  1. 用代数化简后的形式:$\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$,无需任何特判。这是最干净的做法。
  2. 把求值点从 $0$ 挪到 $\varepsilon$:题面在右端点用 $0.9999$ 代替 $1$,左端点同理可用 $10^{-4}$ 代替 $0$。实践中所有扩散/流模型的采样器都会做这种端点截断。
  3. 如果一定要保留题面的字面形式,就接受这一格是空的,并且知道原因。这也是官方 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$,避开它们几乎不损失精度,却能省掉大量数值陷阱。

Q3.3 的数值自查

不需要训练网络就能验证这条恒等式:随机采一批 $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)$ 的误差失败。

线性路径的条件路径(真值 / ODE)与边际路径三行对比
验证 Q4.1 的三行结构图($p_{\text{simple}}=\N(0,I)$,$p_{\text{data}}=$ 4×4 棋盘)。第一行:用你实现的 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 完全相同。

棋盘分布上真值边际路径与学到的边际路径对比
验证 Q4.2:上排真值边际路径,下排用学到的 $u_t^\theta$ 跑 100 步 Euler 得到的样本,各 50000 点的二维直方图。判据仍是逐列吻合。棋盘是个比高斯混合难得多的目标:它有 8 个模式、边界是硬直角、支撑集不连通。可以看到学到的 $t=1$ 那格边缘略有模糊、格子内部密度不如真值均匀——这正是有限容量 MLP(4×64)+ 有限训练步数的典型表现,也是「flow matching 学到的是平滑向量场,锐利边界需要更多容量」的直观例子。

问题 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。这不是随手调的——源分布本身有结构之后,需要学的映射复杂得多。

三种非高斯目标分布的直方图
Part 4 提供的三个「有趣的」二维分布:同心圆环(circles)、双月牙(moons)、棋盘(checkerboard)。它们的共同特点是支撑集低维、不连通、有尖锐边界——这三条恰好是高斯混合所没有的,也正是生成模型真正会遇到的困难。任意两个都可以配成一对源/目标,这是 Q4.3 的游乐场。
从圆环分布桥接到棋盘分布
验证 Q4.3:$p_{\text{simple}}=$ 圆环,$p_{\text{data}}=$ 棋盘。上排真值,下排学习结果。最该看的是 $t=0$ 那一列:如果你在 Q4.1 里把 self.p_simple.sample() 误写成 torch.randn_like(z),这一格的下排会是个高斯团而不是圆环,一眼露馅。中间三列展示了一个有趣的现象:$t=0.25$ 时圆环的中心空洞还清晰可见(下排甚至比上排保留得更明显),$t=0.5$ 时结构基本被抹平成一团,$t=0.75$ 起棋盘格开始浮现。也就是说插值路径会先「溶解」源结构再「结晶」出目标结构,中间时刻的分布既不像源也不像目标。
文字题:「Play around with p_simple and p_data. Any observations?」

把配置换着跑几轮之后,有四条观察是稳定可复现的,每条背后都有原因:

  1. 换源分布不需要改任何代码,也不需要重新推公式。这是线性路径最重要的性质。对比之下,高斯路径要换源分布就得重新推 $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}}$ 从头到尾没出现过。
  2. 源分布越「有结构」,训练越难,需要的容量和步数越多。高斯源 → 棋盘用 4×64 的 MLP 训 10000 步就够;圆环源 → 棋盘要 4×100 训 20000 步,损失还停在 22 左右(对比高斯源的 8)。原因:损失的不可消除项是 $\E[\tr\Cov(u_t(x\mid z)\mid x)]$。源分布有结构时,同一个中间位置 $x$ 可能由「圆环上很不同的起点 + 棋盘上很不同的终点」这么多组 $(x_0,z)$ 配对产生,条件方差更大,平台更高。
  3. 中间时刻的分布既不像源也不像目标,而是一团糊。$t=0.5$ 时圆环和棋盘的结构都基本消失。原因:$X_t=(1-t)X_0+tz$ 且 $X_0\perp z$,两个独立随机变量的加权和会发生「卷积平滑」,其结果的结构总是比任一分量更弱(类似中心极限定理的效果)。这也提示了一个实践问题:如果你希望中间时刻仍保留语义(比如做图像插值),线性路径不是好选择,需要在配对上做文章(如最优传输配对 / mini-batch OT)。
  4. 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$直接求值通过同上
3alpha.dt = 自动微分torch.autograd.grad 对拍0.00e+00解析导数抄错
4beta.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-07Q2.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-06Q3.3 的代数变形出错
11线性路径 $u_t(x_t\mid z)=z-x_0$沿插值轨迹检查常向量性5.96e-07漏掉 $\frac1{1-t}$、写成 $z-x$
12CFM 目标有限且非退化单点 $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 matching4×64 MLP,5000 ep,bs 1000≈ 121≈ 20(在 15–30 震荡)
Q3.2 高斯路径 score matching4×64 MLP,1000 ep,bs 1000≈ 2010–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)

四条值得带走的结论:

Lab 2 的四个 takeaway
  • 「条件可算,边际可学」是整套方法的支点。八个 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 写下的一模一样。

延伸阅读