qinghuan
← 评测、复现与研究实践 / 全部笔记

单元 46 / 评测、复现与研究实践

P0 · 必会主线L3 · 深入

实验复现、消融、多随机种子与研究记录

把实验怎样重跑写清楚:保存随机种子与完整配置,逐项去掉组件做消融,并用配对统计比较训练结果。

本篇目录 · 按需要跳转

读完这篇,你能做什么

复跑CPU线性分类得到同配置一致结果,并完成四条件三种子实验;Java计算主效应与交互差值。

学习理由、准备要求与知识点

为什么学它

只报告最好的一次,分不清改善来自算法还是随机波动。固定种子也不能替代环境记录或独立验证。

先读这些内容

知识点清单

  • 随机源与确定性
  • 代码数据配置身份
  • 训练和评估模式
  • 全因子消融
  • 配对种子与方差
  • 交互效应
  • 检查点恢复状态
  • 开发集与保留集

运行前先确认论文机制按文中版本说明;Java 17 完整算法,Python 3.14 与 PyTorch 2.9.1+cpu 的小型 CPU 实验。未执行 GPU、多机或大规模模型训练。

01先弄懂它是什么

重新运行实验,能不能得到同样结论,不能只靠把随机种子写成同一个数。数据顺序、预处理、初始化、优化器状态、框架和具体运算实现都会影响结果。还要先说清复现目标:是在同一机器同一版本重复运行,换机器后得到相近统计结果,还是用独立实现验证结论。每种目标需要不同证据,完成其中一种,不能自动保证另外两种。

随机种子只控制对应随机数发生器的序列。Python、NumPy 和 PyTorch 可能分别使用自己的发生器,数据加载进程和设备也可能引入其他随机来源。确定性算子设置则是限制运算采用什么实现,与设种子做的事不同。即使都设置好,也不能据此保证跨版本、跨平台,或 CPU 与 GPU 的输出每一位都完全一样。

消融实验通过改变组件来检验贡献,但“删除A后下降”只是在其他配置固定时的条件效应。A和B可能互相配合,因此分别删除并不能完整解释交互。四条件设计包含都关、只开A、只开B和都开,配上相同种子集合,比只展示完整系统和一个弱基线更能定位改进来源。

文中这几个词,先认一认

实验身份
能追溯一次实验的整套信息,包括代码、数据、配置、运行环境和随机种子。
主效应
例如看组件 A 的作用:先分别算 B 关闭、开启时,加上 A 让分数改变多少,再把这两个差值平均。本篇把这个数叫 A 的主效应,它仍取决于选用的组件状态、数据和评分方法。
交互项
先看 B 关闭时,加上 A 让分数改变多少,再看 B 开启时同样操作带来多少变化。本篇用后一个差值减前一个差值,检查 A 的效果会不会随 B 改变;它的数值缩放以正文公式为准。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

把四个条件分数记为y00、y10、y01、y11,下标表示A和B开关。A主效应定义为((y10-y00)+(y11-y01))/2,B类似;本文交互项定义为y11-y10-y01+y00。它不同于使用正负一编码回归时的系数缩放,因此报告公式比只写“interaction”更清楚。

Java对每个种子先算上述三个量,再跨种子平均,并用在线方差算法给交互均值的标准误。一个种子无法估计样本方差,返回空值;标准误表达种子层的变动,不是测试题覆盖的全部不确定性。重复种子或多次重放同一确定结果不应假装提供新的独立样本。

Python用六个固定二维训练点训练线性分类器,因素A是按训练数据标准差缩放,B是SGD动量;每条件跑三个种子,并在四个另设点上报告准确率。先对同种子同配置运行两次比较权重和损失,再做完整十二次实验。数据过小,只能验收训练与记录流程,不能从结果推荐普遍有效配置。

先跑通一个最小例子

共享PyTorch CPU环境执行experiment.py:先重放同配置,再跑四条件×三种子,每次35步线性分类训练。

展开收起代码 · experiment.pypython · 41 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

experiment.py · python
import hashlib
import json
import random
import numpy as np
import torch
torch.set_num_threads(1)
torch.use_deterministic_algorithms(True)
x=torch.tensor([[-2.,-3.],[-1.,0.],[0.,-2.],[1.,1.],[2.,0.],[0.,3.]],dtype=torch.float64)
y=torch.tensor([0,0,0,1,1,1])
test_x=torch.tensor([[-1.,-1.],[1.,2.],[-2.,1.],[2.,-1.]],dtype=torch.float64)
test_y=torch.tensor([0,1,0,1])
def run(seed,scale,momentum):
    random.seed(seed);np.random.seed(seed);torch.manual_seed(seed)
    model=torch.nn.Linear(2,2,dtype=torch.float64)
    divisor=x.std(0).clamp_min(1e-6) if scale else torch.ones(2,dtype=torch.float64)
    train_x=x/divisor
    optimizer=torch.optim.SGD(model.parameters(),lr=.15,momentum=.9 if momentum else 0.)
    initial=torch.nn.functional.cross_entropy(model(train_x),y).item()
    for _ in range(35):
        optimizer.zero_grad();loss=torch.nn.functional.cross_entropy(model(train_x),y)
        loss.backward();optimizer.step()  # 全批次固定次序,避免额外的数据加载随机源。
    final=torch.nn.functional.cross_entropy(model(train_x),y).item()
    with torch.no_grad(): accuracy=(model(test_x/divisor).argmax(-1)==test_y).double().mean().item()
    weights=torch.cat([p.detach().flatten() for p in model.parameters()])
    return initial,final,accuracy,weights

a=run(46,True,True);b=run(46,True,True)
assert torch.equal(a[3],b[3])
assert a[:3]==b[:3]
records=[]
for seed in (46,47,48):
    for scale,momentum in ((False,False),(True,False),(False,True),(True,True)):
        initial,final,accuracy,weights=run(seed,scale,momentum)
        assert final<initial
        assert torch.isfinite(weights).all() and 0<=accuracy<=1
        records.append(dict(seed=seed,scale=scale,momentum=momentum,train_loss=final,test_accuracy=accuracy))
assert len(records)==12
assert all(sum(r["scale"]==s and r["momentum"]==m for r in records)==3 for s in (False,True) for m in (False,True))
digest=hashlib.sha256(json.dumps({"x":x.tolist(),"y":y.tolist()},sort_keys=True).encode()).hexdigest()
print(json.dumps({"torch":torch.__version__,"numpy":np.__version__,"data_sha256":digest,"runs":records},sort_keys=True))
print("PASS 6 check groups; repeatable only within this tested CPU configuration")

核对运行结果输出torch、numpy、数据SHA256和12次指标;PASS 6 check groups,只保证本次CPU配置下的检查。

正在加载流程图…

数据、代码、配置与种子形成实验身份;保留集在选择结束前不参与调参。
查看流程图源码(Mermaid)
flowchart TD
  N0["冻结代码数据和实验问题"]
  N1["预声明条件种子与主指标"]
  N2["执行训练并保存逐次结果"]
  N3["计算配对主效应与交互"]
  N4["在独立保留任务确认结论"]
  N0 --> N1
  N1 --> N2
  N2 --> N3
  N3 --> N4

按顺序看,每一步做了什么

  1. 冻结代码数据和实验问题
  2. 预声明条件种子与主指标
  3. 执行训练并保存逐次结果
  4. 计算配对主效应与交互
  5. 在独立保留任务确认结论

03对照实现代码

PyTorch确定性选项与复现边界 (新标签页)↗

PyTorch 2.9文档;实现v2.9.1 · torch.manual_seed / torch.use_deterministic_algorithms

原创配置示例,参考PyTorch2.9文档与v2.9.1实际函数;实验不验证GPU确定性。

核心机制代码 · python
import random
import numpy as np
import torch
def configure(seed):
    random.seed(seed)  # Python标准库的随机源单独设置。
    np.random.seed(seed)  # 只设置传统全局NumPy随机源,独立Generator还需单独管理。
    torch.manual_seed(seed)  # 设置PyTorch随机源,不等于所有算子自动确定。
    torch.use_deterministic_algorithms(True)  # 无可用确定实现的已知算子可报错,而非自动消除所有环境差异。

官方复现说明明确指出完全一致结果不保证跨发行版、提交或不同平台保持;相同种子也可能在CPU与GPU得到不同结果。本文将验收限制为所测试CPU配置内的重复运行,版本号进入输出,避免把一次权重相同扩大成长期跨设备承诺。

固定v2.9.1的torch.__init__.py中use_deterministic_algorithms把设置传到底层确定性开关,文档列出受影响算子和报错情形。它不是自动捕获任意第三方随机源的万能开关,外部库、用户生成器和数据加载逻辑仍需管理。本文训练只用少量CPU线性算子。

NumPy的新Generator对象可以独立持有状态,np.random.seed并不重置所有实例。真实实验应记录并保存实际使用的发生器;从检查点继续训练还需模型、优化器、调度器、混合精度缩放器及数据位置等状态,单独载入权重不等于恢复原轨迹。

研究记录指南要求让读者理解数据划分、超参数选择、计算资源与结果变动。本文把数据摘要和逐次指标一起输出,未实现大型实验管理平台;其作用是建立可核对最小记录,而不是让一个哈希代替数据来源说明、许可或完整代码归档。

代码先调用谁,再调用谁

  1. 配置实际使用的随机数发生器
  2. 选择确定性执行策略
  3. 建立模型和优化器
  4. 保存运行身份与逐次指标

04动手写一个例子

配对种子的二因素效应汇总

每行一个种子,四列依次为00、10、01、11分数;返回A、B主效应和交互差值均值,至少两种子时给交互均值标准误。

输入 1
[10,12,13,15]
输出 1
A=2,B=3,交互0
输入 2
两种子交互分别0与2
输出 2
均值1,标准误1

先在种子内部作差能抵消共同平移,例如一个种子所有条件都高十分,交互差值不会改变。这与先随意匹配不同种子结果不同;数据输入的配对语义必须来自实验设计,算法无法自动判断四列是否真的来自同一随机条件。

在线更新均值和M2,处理第t行后meanI为此前交互项的均值,M2为它们相对当前均值的平方偏差和。t大于一时样本方差为M2/(t-1),均值标准误再除t后开方。浮点舍入可能产生极小负数,因此对M2作非负截断后计算根号。

每行固定四个数且只更新常数个标量,N个种子总时间O(N)、额外空间O(1)。这不是显著性检验,也没有凭标准误自动输出优劣。输入有限范围防止平方项溢出;单行缺少方差信息,明确返回OptionalDouble.empty,不能用零表示“完全稳定”。

为什么这样写是对的处理t行后,三个均值对应前t个配对效应,M2是交互项围绕当前均值的平方偏差和。

时间 O(N)额外空间 O(1)额外空间

每种子固定四列、常数次运算,统计状态不随种子数增长。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 39 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    static void near(double expected,double actual) {
        if (!Double.isFinite(actual) || Math.abs(expected-actual)>1e-9*Math.max(1,Math.abs(expected)))
            throw new AssertionError(expected+" != "+actual);
    }
    static void check(boolean ok) { if(!ok) throw new AssertionError(); }
    static void bad(Runnable r) {
        try { r.run(); } catch(IllegalArgumentException e) { return; }
        throw new AssertionError("invalid input accepted");
    }
record Effects(double a,double b,double interaction,OptionalDouble standardError) {}
    static Effects effects(double[][] rows) {
        if(rows==null||rows.length==0) throw new IllegalArgumentException("seeds");
        double meanA=0,meanB=0,meanI=0,m2=0;int count=0;
        for(double[] r:rows) {
            if(r==null||r.length!=4) throw new IllegalArgumentException("four conditions");
            for(double x:r) if(!Double.isFinite(x)||Math.abs(x)>1e100) throw new IllegalArgumentException("score");
            double a=((r[1]-r[0])+(r[3]-r[2]))/2; // 顺序为00、10、01、11,A效应在B两种状态取平均。
            double b=((r[2]-r[0])+(r[3]-r[1]))/2;
            double interaction=(r[3]-r[2])-(r[1]-r[0]); // 差中之差,不能与正负一编码回归系数混用。
            count++;meanA+=(a-meanA)/count;meanB+=(b-meanB)/count;
            double delta=interaction-meanI;meanI+=delta/count;m2+=delta*(interaction-meanI); // 在线维护跨种子交互项样本方差。
        }
        OptionalDouble se=count<2?OptionalDouble.empty():OptionalDouble.of(Math.sqrt(Math.max(0,m2)/(count-1)/count));
        return new Effects(meanA,meanB,meanI,se);
    }
    public static void main(String[] args) {
Effects a=effects(new double[][]{{10,12,13,15}});near(2,a.a());near(3,a.b());near(0,a.interaction());check(a.standardError().isEmpty());
        Effects b=effects(new double[][]{{0,0,0,4}});near(2,b.a());near(2,b.b());near(4,b.interaction());
        Effects c=effects(new double[][]{{10,12,13,17},{20,22,23,27}});near(2,c.interaction());near(0,c.standardError().orElseThrow());
        Effects d=effects(new double[][]{{0,0,0,0},{0,0,0,2}});near(1,d.interaction());near(1,d.standardError().orElseThrow());
        Effects e=effects(new double[][]{{4,3,2,1}});near(-1,e.a());near(-2,e.b());
        bad(()->effects(new double[][]{}));
        bad(()->effects(new double[][]{{1,2,3}}));
        bad(()->effects(new double[][]{{1,2,3,Double.NaN}}));
        System.out.println("PASS 8 cases");
    }
}

这些特殊情况也要试一试

  • 单种子标准误不可计算
  • 共同平移不改变交互
  • 负效应合法
  • 缺列NaN和空数据拒绝
进阶选读 · 可恢复训练与多机复现实验

场景训练持续多天,重启或更换设备后需解释结果偏差。

做法保存全部训练状态和数据游标,冻结依赖与代码;分开验证不中断与恢复轨迹,再在目标设备用多种子比较统计稳定性。

复杂度状态存储随模型、优化器和数据采样状态增长,恢复I/O与分布式同步需实测;本篇O(N)汇总不描述训练成本。

好处与代价更强确定性可能牺牲性能,逐位一致与统计复现是不同验收目标;多机部分仅作为后续实验。

05项目里怎么用

实验前写清假设、主指标、因素范围和停止规则,避免观察到结果后不断改口径。开发集用于选择学习率和训练步数,最终保留集不参与这种选择;如果测试结果反复影响改动,应承认它已被用于开发,并安排新的确认任务。固定随机种子不能防止这种信息泄漏。

消融应尽量保持预算公平。删除一个组件可能同时减少参数、训练时间和输入信息,结果变化不能只归因于组件名称;必要时加上参数量或计算匹配的对照。报告所有预声明种子的结果,失败运行也记录原因,不将训练崩溃静默删除后只汇总成功种子。

长期记录应包含可运行入口、依赖版本、数据划分、配置、种子、资源耗时及产物校验值,并保存简短结论与未解决问题。本文十二次微型CPU训练已给出完整逐次输出,但未检验GPU算子、数据并行、真实Agent环境或大模型恢复;这些必须作为单独阶段进行验证。

容易踩的坑

  • 只设torch种子忽略其他随机源
  • 确定性开关被当跨版本保证
  • 挑最佳种子不报告失败
  • 消融同时改变多个条件
  • 保留集反复用于选择配置

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
单种子调试便宜且便于定位不能估计随机波动实现检查阶段
多种子配对消融能观察条件与交互训练成本成倍增加方法对比与稳定性
跨环境复现检验迁移和独立实现需要更完整记录和资源准备发表或长期维护

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01同种子为何仍可能不同?

展开参考答案与推导

框架、设备、算子和其他随机源可能变化,种子只约束相应发生器。

02交互项为正意味什么?

展开参考答案与推导

按本文差中之差定义,A开启时的增益在B开启条件下更大,不等于总体一定有用。

03一个种子标准误能写零吗?

展开参考答案与推导

不能,缺少样本方差信息,应标记不可估计。

换个情况,还会处理吗

01只载入模型后训练路径不同?

展开参考答案与推导

检查优化器、调度器、随机状态和数据位置是否恢复。

02完整方法只在一个种子领先?

展开参考答案与推导

展示所有预定种子,检查波动和交互,不将最好一次当平均收益。

03开确定性后速度降低?

展开参考答案与推导

说明目标是重复性还是吞吐,记录性能影响并分别验证,不能直接关掉后仍称同等复现。

写代码试一试

01计算交互。

输入 / 条件
00=0,10=0,01=0,11=4
输出 / 预期
交互4,主效应各2

提示 差中之差

展开参考答案与推导

先比较B开关下的A效应,再相减。

02验证种子内共同偏移。

输入 / 条件
四条件均加10
输出 / 预期
所有效应不变

提示 成对差分

展开参考答案与推导

常数在每个差中抵消,测试原值与偏移值。

03保存实验身份。

输入 / 条件
代码数据配置及版本
输出 / 预期
可重建运行记录

提示 哈希加原始说明

展开参考答案与推导

记录摘要同时保存可解释配置和数据划分,哈希不能替代内容。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

复跑CPU线性分类得到同配置一致结果,并完成四条件三种子实验;Java计算主效应与交互差值。

本篇重点加练

配对种子、交互效应与保留集纪律共同影响归因,需要检验选择过程和不完整实验。

  • 配对种子与方差
  • 交互效应
  • 开发集与保留集

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    为一个 CPU 随机训练实验列出随机源、代码数据配置身份及 train/eval 状态;区分可重复运行、数值近似复现和结论复现。

    提交内容
    复现清单与验收层次
    怎样算完成
    • 身份信息足以重建输入
    • 一致性标准可检验

    练到的知识点随机源与确定性 · 代码数据配置身份 · 训练和评估模式

  2. 作业 02代码推演

    因素 A、B 各有开关,给出三个种子下四种配置的自建观测表,计算主效应与交互量并说明配对身份。

    提交内容
    全因子表与计算记录
    怎样算完成
    • 四条件齐全
    • 种子配对不打乱

    练到的知识点全因子消融 · 配对种子与方差 · 交互效应

  3. 作业 03原理问答

    四条件消融各自使用不同种子,完整方法又挑最佳种子。写出该数据还能回答与不能回答的问题,并设计缺失运行补齐计划。

    提交内容
    可识别性说明与运行矩阵
    怎样算完成
    • 配对关系可追踪
    • 补实验不依赖结果优劣

    练到的知识点配对种子与方差 · 交互效应

综合应用

  1. 作业 04编程实践

    实现 CPU 实验登记与恢复工具,保存配置摘要、种子、模型和优化器状态;中断后重跑一个条件并对照其原始记录。

    提交内容
    程序与恢复审计
    怎样算完成
    • 实验身份不可静默覆盖
    • 恢复状态范围明确

    练到的知识点代码数据配置身份 · 配对种子与方差 · 检查点恢复状态

  2. 作业 05排错实验

    分别漏设数据生成种子、忘记 eval、只恢复模型权重,记录三种故障对实验结果的影响,并设计检查器定位。

    提交内容
    故障注入与诊断记录
    怎样算完成
    • 每次只改变一个因素
    • 训练和评估随机性分开

    练到的知识点随机源与确定性 · 训练和评估模式 · 检查点恢复状态

  3. 作业 06面试场景

    完整方法在两个种子领先、另外三个落后。制定报告规则与后续实验问题,避免事后只保留最好配置。

    提交内容
    完整报告与后续协议
    怎样算完成
    • 所有运行都有去向
    • 开发探索与保留检验分开

    练到的知识点配对种子与方差 · 交互效应 · 开发集与保留集

  4. 作业 07编程实践

    CPU 生成含共同种子扰动和真实交互项的四条件数据,比较保留配对与打乱配对后的估计和波动;公开生成参数。

    提交内容
    模拟代码与两种分析
    怎样算完成
    • 真效应和噪声分开
    • 配对改变是唯一分析差异

    练到的知识点配对种子与方差 · 交互效应

  5. 作业 08设计评审

    保留测试集被用于选择提示、模型和最佳断点之后,还能作何种报告?设计新的确认性数据与旧结果的标签规则。

    提交内容
    实验分层及结果命名规范
    怎样算完成
    • 已使用信息不被重新称盲测
    • 新验证独立性可说明

    练到的知识点开发集与保留集

  6. 作业 09排错实验

    一个种子运行失败被自动重试直到成功,日志只保留最后一次。构造 CPU 演练并修改登记规范,保留所有尝试与预定失败处理政策。

    提交内容
    重试偏差演练及账本
    怎样算完成
    • 逻辑运行和尝试编号分开
    • 失败不会静默消失

    练到的知识点配对种子与方差 · 开发集与保留集

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    制定两个因素的确认性消融实验,预定种子数、主要比较、失败运行处理与保留任务;先用模拟数据检验分析脚本。

    提交内容
    预注册协议和分析脚本
    怎样算完成
    • 交互效应被考虑
    • 模拟演练不冒充真实结论

    练到的知识点随机源与确定性 · 全因子消融 · 交互效应 · 开发集与保留集

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 条件变化

    两个组件在开发集表现互补,但保留分布改变后交互项反向,是否意味着实验错误?设计分布改变的可控案例并提出区分机制变化与实现错误的证据。

    提交内容
    双分布案例与诊断计划
    怎样算完成
    • 各分布目标明确
    • 交互结论限定适用范围

    练到的知识点交互效应 · 开发集与保留集

  2. 作业 12思维拓展 · 跨域迁移

    迁移到跨团队复现:不同团队无法使用相同硬件和随机算法时,应该优先复现逐位结果还是结论?制定分层验收而不预设所有差异都可接受。

    提交内容
    复现层级及拒绝标准
    怎样算完成
    • 数值与结论容差独立
    • 设备差异有记录和边界

    练到的知识点配对种子与方差 · 开发集与保留集

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗