先对齐教师与学生的采样轨迹——DuoMatching 的匹配环节该盯哪一步

文章导读
DuoMatching 的匹配环节最容易出错的地方不在损失函数,而在采样循环:教师和学生必须先读到同一份 x_t 与同一个时间步 t,然后再比较输出。判断顺序通常是先确认输入是否一致,再比较同一时间步下两边的预测差异,最后用固定随机种子重新运行,确认差异可以复现。如果教师与学生各自采样噪声,或者各自推进 scheduler,那么后面的任何对比数值都没有参考意义。
📋 目录
  1. 一 在采样循环里定位教师与学生共用的时间步与噪声输入
  2. 二 记录同一步下两边的预测输出并做逐元素对比
  3. 三 固定随机种子复跑一次,确认对比结果可复现
  4. 四 把对齐检查整理成一个可复用的小脚本
  5. 五 记录不同对齐时机下的现象对照表
A A

DuoMatching 的匹配环节最容易出错的地方不在损失函数,而在采样循环:教师和学生必须先读到同一份 x_t 与同一个时间步 t,然后再比较输出。判断顺序通常是先确认输入是否一致,再比较同一时间步下两边的预测差异,最后用固定随机种子重新运行,确认差异可以复现。如果教师与学生各自采样噪声,或者各自推进 scheduler,那么后面的任何对比数值都没有参考意义。

建议把检查重点放在采样循环里的 (t, x_t):确认噪声只采一次、时间步来自同一个 scheduler 实例、教师与学生读的是同一份张量。验证方式是在同一步取出两边预测做逐元素差值统计,再用固定种子复跑一次看结果是否稳定。若两边各自采样或各自逐步推进会破坏对齐,此时应先修正采样路径,不要继续调整损失或超参。该检查适用于能单机复现的采样路径,异步或分布式采样需另外确认。

在采样循环里定位教师与学生共用的时间步与噪声输入

先读循环本身,不要从损失里倒推。教师与学生的输入通常来自三个地方:latents(来自数据加载或编码器)、noise(随机生成)、timesteps(来自 scheduler)。只要这三个对象中有一个在两边被重新生成,起点就不一致了。下面是通用骨架代码,变量名可以替换为自己工程里的名字,重点是噪声只在循环外采一次、scheduler 只保留一份实例。

noise = torch.randn(latents.shape, generator=g, device=latents.device, dtype=latents.dtype)
x = scheduler.add_noise(latents, noise, scheduler.timesteps[:1])

for i, t in enumerate(scheduler.timesteps):
    if i in probe_steps:
        print("step", i, "t", int(t), "x_shape", tuple(x.shape),
              "x_std", float(x.float().std()),
              "noise_ptr", noise.data_ptr(), "x_ptr", x.data_ptr())
    t_batch = t.expand(x.shape[0])
    eps_teacher = teacher(x, t_batch)
    eps_student = student(x, t_batch)
    x = scheduler.step(eps_student, t, x).prev_sample

打印位置建议放在循环体的最前面,保证打印的是“进入这一步时”的状态,而不是推进之后的状态。需要重点确认:noise.data_ptr() 在整轮采样里是否始终不变;x_ptr 在教师与学生的调用之间是否相同;t 是否只有一个来源。如果工程里存在两份 scheduler(例如教师跟学生各自一份),两边 step 的内部计数可能已经不同,此时比较 scheduler.timesteps 的长度和当前索引即可看出偏移。

以下清单建议逐条确认:

先对齐教师与学生的采样轨迹——DuoMatching 的匹配环节该盯哪一步
  • 噪声生成只出现在循环外一次,循环内没有任何 randn、randn_like 或多卡广播后的重新采样。
  • 教师与学生拿到的是同一个 x 张量(同一 device、同一 dtype),不是各复制一份后经过不同前处理。
  • 时间步来自同一个 timesteps 序列,且没有在教师侧做 t+1、t-1 之类的偏移。
  • 两个模型都处于 eval 模式;若任一模型开了 dropout 或训练态 BN,即使输入相同,输出也会不同。

记录同一步下两边的预测输出并做逐元素对比

只观察损失曲线无法区分是输入没对齐还是容量差异。建议在选定的一小段时间步(例如前几步和中间几步)保存两个预测张量,直接计算差值统计。下面是通用的统计片段,接口名按自己工程替换。

a, b = eps_teacher.float(), eps_student.float()
if a.shape != b.shape:
    raise SystemExit(f"shape mismatch: {tuple(a.shape)} vs {tuple(b.shape)}")
d = a - b
stats = {
    "step": i, "t": int(t), "shape": tuple(a.shape),
    "max_abs": d.abs().max().item(),
    "mean_abs": d.abs().mean().item(),
    "rms": d.pow(2).mean().sqrt().item(),
    "ref_abs": a.abs().mean().item(),
    "cos": torch.nn.functional.cosine_similarity(
        a.flatten(1), b.flatten(1), dim=1).mean().item(),
    "finite": bool(torch.isfinite(d).all()),
}

判读顺序建议是:先看形状和 finite,形状不同就不要比数值;再看 cos,它衡量方向是否一致;然后看 mean_abs 与 ref_abs 的相对关系,而不是只看绝对量,因为不同时间步下预测的量级本身会变。若某些步的 max_abs 明显高于相邻步,通常是那几步存在额外分支(例如不同的 guidance 设置或条件注入没传给学生)。每个时间步都记录,比只记录最后一次更方便定位。

固定随机种子复跑一次,确认对比结果可复现

单次运行里的差异可能来自随机初始化或采样噪声。建议把种子固定在三个位置:进程级随机、CUDA 随机、以及噪声用的 torch.Generator。另外数据加载的 worker 顺序也建议固定,否则 batch 顺序变化会带来输入差异。

先对齐教师与学生的采样轨迹——DuoMatching 的匹配环节该盯哪一步
def set_seed(seed, device):
    random.seed(seed)
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)
    return torch.Generator(device=device).manual_seed(seed)

g = set_seed(cfg.seed, latents.device)
# ... 采样与统计 ...
torch.save(stats_list, f"align_seed{cfg.seed}_run{runtag}.pt")

两次运行分别保存统计结果后,直接比较 max_abs、mean_abs、cos 三个字段。理想情况是同种子两次运行的对应字段完全相等;出现极小浮点差异通常是算子实现差异,需要在同一机器同一后端上再确认。若差异明显,按这个顺序排查:噪声是否在循环内被重采;两个模型是否都处于 eval 模式;是否有未固定的 dataloader 顺序;是否开启了不确定性的算子或图优化;两次运行的配置是否真的相同(打印 config 哈希最省事)。

把对齐检查整理成一个可复用的小脚本

把上面三段检查收进一个脚本,每次改配置后跑一次,比在训练里反复猜要快。脚本只需要读配置、跑若干步采样、输出 JSON,不参与训练。

先对齐教师与学生的采样轨迹——DuoMatching 的匹配环节该盯哪一步
# check_align.py
# 用法: python check_align.py `--config` cfg/duo.yaml `--steps` 0,5,10 `--seed` 1234 `--out` align.json

def main(args):
    cfg = load_config(args.config)      # 替换为工程内的配置读取方式
    pipe = build_pipeline(cfg)         # 替换为工程内的构建入口
    g = set_seed(args.seed, pipe.device)
    records = run_probe(pipe, cfg, args.steps, g)
    save_json(records, args.out)
    return verdict(records, cfg.align_thresholds)

输入输出约定建议写清楚:输入是配置文件路径、探针步列表、种子、输出路径;输出是一份 JSON 数组,每条记录包含 step、t、shape、max_abs、mean_abs、cos、finite。退出码建议分开:0 表示形状一致且数值有限、在阈值内;1 表示教师与学生形状不一致;2 表示出现 nan 或 inf;3 表示数值超出配置阈值。失败时的提示信息要能直接指向动作,例如“step 5: teacher shape (2,4,64,64) != student shape (2,4,32,32),检查下采样层或 latent 分辨率配置”,而不是只打印一个 False。阈值从配置文件读,不要写死在脚本里,也不要为了让脚本通过而放宽阈值。脚本里不要引入工程中不存在的接口,构建入口按实际代码替换。

记录不同对齐时机下的现象对照表

把每次实验的时机选择和观察到的现象写进一张表,方便下次改配置时对照。表里只填能通过日志、脚本输出或页面行为复现的内容,不要填主观印象。

对齐时机共享的输入记录位置观察点可复现现象
同噪声起点 + 同时间步 + 单 schedulernoise、x_t、t 全部共用循环体首行打印 + 探针统计shape 一致、finite 为真、cos 与差值统计在两次同种子运行中一致同种子下两次运行的统计字段相等或仅有极小浮点差异
同噪声起点,两边各自推进 scheduler初始 noise 相同,后续 x_t 不同两边的步索引与 x_std步索引错位、x_std 随步数分化x_ptr 不同;差值统计随步数增大而发散
两边各自采噪声,仅对齐时间步只有 t 相同循环内打印 noise_ptrnoise_ptr 每步变化或在两侧不同同种子复跑时统计字段不稳定
只在最终输出上比较无中间共享量采样结束后的输出张量输出差异无法归因到具体时间步无法定位是输入问题还是模型差异

表格的边界说明要一起写在表下面:这里记录的是采样路径与中间量层面的现象,只覆盖能在单机、单进程、固定种子条件下重复出来的情况;分布式采样、异步调度或带缓存的推理路径,中间量可能出现与上表不同的表现,需要单独确认后再填入。不要在这张表里写效果收益或性能结论,那些结论需要另一套可核验的度量,与对齐检查是两件事。