AI 工程基础体系 · 第 40/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

机器学习实验可复现:随机种子、数据版本、环境、指标与追踪

机器学习实验的“可复现”,不是把某个参数命名为 seed,然后再次运行脚本就得到相同数字。一次实验的结果通常由以下函数共同决定:

Y=F(C,D,E,A,R,O,T)Y = F(C, D, E, A, R, O, T)

其中:

  • YY:实验输出,包括模型文件、预测结果、指标和日志;
  • CC:代码与配置;
  • DD:训练、验证和测试数据;
  • EE:运行环境,包括解释器、依赖包、驱动和硬件;
  • AA:算法实现及其执行方式,例如线程数、并行策略;
  • RR:随机状态;
  • OO:执行顺序、并发调度和外部服务响应;
  • TT:时间、权限、资源配额等运行条件。

只有这些输入和执行条件在所需精度范围内保持一致,输出才可能复现。

因此,实验复现至少有三种强度:

  1. 比特级复现:模型文件、预测数组和指标逐字节一致。
  2. 数值级复现:浮点结果允许极小误差,例如 1e-7,但指标和排序一致。
  3. 结论级复现:重新运行后,核心结论、模型比较关系和业务决策不变。

比特级复现最严格,也最容易受到硬件、并行计算和序列化格式影响;生产系统通常更关心数值级或结论级复现,但必须明确验收标准,不能把“看起来差不多”当作定义。

一次实验到底包含哪些状态

训练脚本只是实验的一部分。更完整的实验状态可以表示为:

flowchart LR
    C[代码与配置] --> P[数据准备]
    DV[数据版本与谱系] --> P
    E[运行环境] --> T[训练]
    R[随机状态] --> T
    P --> T
    H[硬件与并行策略] --> T
    T --> M[模型与预测]
    M --> V[评测]
    P --> V
    V --> L[指标与日志]
    T --> L
    L --> D[实验追踪系统]
    D --> Q[比较、发布与审计]

图中有一个容易被忽略的关系:评测也依赖数据版本。如果训练代码不变,但测试集被替换、清洗规则改变,指标就不再属于同一个实验条件。

一次可审计的实验至少应记录:

  • Git 提交、代码包或容器镜像摘要;
  • 完整配置,而不是只记录几个主要超参数;
  • 数据集版本、文件哈希、查询条件和数据处理规则;
  • Python、操作系统、依赖库、GPU 驱动和硬件信息;
  • 每个随机数生成器的种子及其派生关系;
  • 训练过程中的 checkpoint、日志和最终模型;
  • 指标定义、数据切分、阈值、聚合方式和置信区间;
  • 运行者、时间、权限、资源消耗和失败原因。

“参数相同”不等于“实验相同”。例如,learning_rate=1e-3 相同,但一组实验使用了旧版本的数据清洗函数,另一组实验使用了新版 CUDA 内核,它们不能直接比较。

随机种子:控制的是随机数流,不是整个系统

随机数生成器和种子

伪随机数生成器(Pseudo-Random Number Generator,PRNG)通过确定性算法产生随机序列。给定算法、初始状态和调用顺序后,输出序列通常是确定的:

st+1=G(st),xt=H(st)s_{t+1} = G(s_t), \qquad x_t = H(s_t)

  • sts_t 是第 tt 步内部状态;
  • GG 是状态转移函数;
  • HH 把状态映射为随机数;
  • 种子通常用于初始化 s0s_0

因此,种子只能保证“从同一状态、按同一顺序调用同一生成器”时得到同一随机流。下面两个程序虽然都设置了相同种子,但第二个程序多调用了一次随机数,后续结果就会整体错位:

import numpy as np

rng_a = np.random.default_rng(7)
first_a = rng_a.normal()
second_a = rng_a.normal()

rng_b = np.random.default_rng(7)
_ = rng_b.normal()       # 额外消耗一个随机数
first_b = rng_b.normal()

print(first_a == first_b)  # False

这说明随机性不仅由 seed 决定,还由调用次数、调用顺序和随机生成器归属决定。

不要只设置一个全局种子

一个训练程序可能同时使用:

  • Python 标准库 random
  • NumPy 的随机数生成器;
  • scikit-learn 内部使用的随机状态;
  • PyTorch 或 TensorFlow 的 CPU/GPU 随机状态;
  • 数据加载器的 worker;
  • 数据增强库;
  • 采样服务或外部大模型 API。

一个更可靠的做法是显式创建随机流,并把子流分配给不同阶段:

from numpy.random import SeedSequence, default_rng

root = SeedSequence(20250308)
split_seed, augment_seed, model_seed = root.spawn(3)

split_rng = default_rng(split_seed)
augment_rng = default_rng(augment_seed)
model_rng = default_rng(model_seed)

这样做的价值不是让所有步骤共享一个数字,而是避免“某个步骤新增一次随机调用,导致所有后续步骤的随机序列改变”。在实验追踪中,应记录根种子、派生规则以及各阶段使用的随机状态。

scikit-learn 中的 random_state

在 scikit-learn 中,许多随机算法通过 random_state 控制随机行为,例如:

from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression

X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    stratify=y,
    random_state=42,
)

model = LogisticRegression(
    max_iter=1000,
    random_state=42,
)

这里至少有两个独立的随机过程:

  1. train_test_split 决定样本如何进入训练集和测试集;
  2. LogisticRegression 的实现可能在优化或数据处理过程中使用随机性。

只设置模型的 random_state,并不能固定数据切分。

还要注意 random_state 接受整数和随机状态对象时的语义可能不同。对于需要反复调用的估计器或交叉验证对象,使用整数通常更容易得到可预测的重复行为;如果传入一个会持续推进状态的随机生成器,多次调用同一对象可能消耗不同的随机流。工程上应统一约定:哪些组件用固定整数,哪些组件使用显式派生的随机流,并在代码评审中检查。

深度学习中的随机性

深度学习训练通常还有以下随机来源:

  • 参数初始化;
  • batch shuffle;
  • dropout;
  • 数据增强;
  • 多进程数据加载;
  • GPU 内核;
  • 原子操作和线程调度;
  • 混合精度与浮点归约顺序。

以 PyTorch 为例,常见的控制方式包括:

import os
import random
import numpy as np
import torch

seed = 42
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"

random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)

torch.backends.cudnn.benchmark = False
torch.use_deterministic_algorithms(True)

这段代码不能被解释为“保证所有模型在所有机器上完全一致”。

  • torch.use_deterministic_algorithms(True) 会要求使用确定性算法;某些算子可能因此报错,或性能下降。
  • CUBLAS_WORKSPACE_CONFIG 依赖特定 CUDA/cuBLAS 行为和版本,必须在导入或初始化相关运行时之前设置,并不能跨所有环境永久保证一致。
  • 多 GPU、不同 GPU 型号、不同 CUDA 驱动和不同编译选项仍可能导致数值差异。
  • 某些第三方算子可能不受框架确定性设置控制。

因此,深度学习项目应同时记录框架版本、CUDA 版本、驱动版本、GPU 型号和确定性开关状态。若目标只是结论级复现,可以接受小范围数值漂移,但应通过多次运行报告均值、标准差或置信区间。

生成式 AI 的随机性更复杂

生成式模型的输出通常来自条件概率分布:

ypθ(yx)y \sim p_\theta(y \mid x)

温度、top-p、top-k 等采样参数会改变这个分布。即使请求中带有 seed,也不一定能得到跨时间、跨模型版本、跨硬件完全一致的输出,因为:

  • 服务端可能更换模型权重;
  • tokenizer 或采样实现可能变化;
  • 服务端的 seed 可能只是“尽量复现”的提示;
  • 并行浮点计算可能产生不同 logits;
  • 供应商可能不承诺确定性。

生成式 AI 实验应记录:

  • 模型标识和版本;
  • tokenizer 版本;
  • system、user 和工具调用消息;
  • 采样参数;
  • 服务端返回的模型标识;
  • seed(若接口支持);
  • 原始响应、错误响应和时间;
  • token 用量、价格和限流状态。

对于需要严格回归测试的任务,可将黄金输入、期望结构化输出和允许差异规则纳入测试。对自然语言输出直接做字符串完全相等比较,通常过于脆弱;对 JSON、函数调用参数或分类标签,则可以定义结构化断言。

数据版本:哈希只能证明内容,不能自动证明语义

数据版本的三个层次

数据版本至少包含三层:

  1. 内容版本:文件或表中的具体字节;
  2. 处理版本:过滤、去重、标注、切分、特征工程代码;
  3. 语义版本:字段含义、标签定义、采集范围和时间窗口。

对文件计算 SHA-256 可以确认内容是否变化:

sha256sum data/train.parquet

若输出为:

8e5c...a912  data/train.parquet

再次计算得到相同摘要,说明在当前读取范围内文件字节一致。它不能证明:

  • 文件中的标签定义没有变化;
  • 生成该文件的 SQL 没有变化;
  • 上游表没有被回填;
  • 文件被正确读取;
  • 同一哈希确实对应期望的数据 schema。

因此,数据清单通常还应记录:

{
  "dataset_id": "customer_churn",
  "version": "2025-03-08",
  "sha256": "8e5c...a912",
  "schema": {
    "customer_id": "string",
    "tenure_days": "int64",
    "label": "int8"
  },
  "source_query": "warehouse://churn_snapshot?date=2025-03-08",
  "time_window": ["2024-01-01", "2025-02-28"],
  "label_definition": "未来30天是否流失",
  "transform_code": "git:3f2a1c9"
}

数据切分是实验状态的一部分

监督学习中,切分数据的目标是估计模型在未见样本上的表现。设训练集为 DtrainD_{\text{train}},测试集为 DtestD_{\text{test}},评估指标为 MM,则:

m^=M(fθ(Dtrain),Dtest)\hat{m} = M(f_{\theta(D_{\text{train}})}, D_{\text{test}})

只要 DtrainD_{\text{train}}DtestD_{\text{test}} 改变,m^\hat{m} 就不再是同一个量。

常见切分方式包括:

  • 随机切分:适用于样本近似独立同分布的场景;
  • 分层切分:保持分类标签比例;
  • 按时间切分:避免用未来预测过去;
  • 按用户、设备或主体分组切分:避免同一主体同时出现在训练和测试中。

例如,医疗记录按行随机切分,可能使同一患者的多次就诊同时出现于训练集和测试集,导致严重的数据泄漏。此时即使 seed 固定,结果也只是稳定地错误。

一个可复现的数据切分不仅要保存 seed,还应保存:

  • 切分算法;
  • 分组键或时间边界;
  • 每个集合的样本 ID;
  • 过滤前后的样本数量;
  • 标签分布;
  • 去重和冲突处理规则。

对于关键评测集,直接保存测试集样本 ID 往往比只保存 random_state 更可靠,因为数据源可能新增、删除或重排记录。

数据泄漏会制造“可复现的假象”

假设先在全量数据上拟合标准化参数:

μ=1ni=1nxi,σ2=1ni=1n(xiμ)2\mu = \frac{1}{n}\sum_{i=1}^{n}x_i,\qquad \sigma^2 = \frac{1}{n}\sum_{i=1}^{n}(x_i-\mu)^2

再切分数据,那么测试集的信息已经进入 μ\muσ\sigma。模型每次运行都可能得到相同结果,但测试指标偏乐观。

正确做法是把预处理放入只在训练集上拟合的 Pipeline:

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

pipe = Pipeline([
    ("scale", StandardScaler()),
    ("model", LogisticRegression(max_iter=1000, random_state=42)),
])
pipe.fit(X_train, y_train)

交叉验证时,Pipeline 会在每个训练折上拟合 StandardScaler,然后应用于对应验证折。这样既减少泄漏,也把预处理逻辑作为模型工件的一部分保存。

环境:依赖版本不只是安装列表

环境由多层组成

实验环境至少包括:

  • 操作系统和 CPU 架构;
  • Python、R 或其他语言运行时;
  • 直接依赖和间接依赖;
  • 编译器、BLAS、OpenMP;
  • GPU 型号、驱动、CUDA、cuDNN;
  • 容器基础镜像;
  • 时区、区域设置和字符编码;
  • 文件系统、网络和外部服务版本。

pip freeze 能记录 Python 包版本,但不一定记录:

  • 包是如何编译的;
  • 使用了哪个 BLAS;
  • GPU 驱动和 CUDA;
  • 操作系统库;
  • 本地源码是否被修改。

因此,常见的分层方案是:

  1. requirements.txt、锁文件或 Conda lock 固定 Python 依赖;
  2. 用容器镜像固定系统库;
  3. 保存镜像 digest,而不是只保存可变的 tag;
  4. 记录硬件和驱动信息;
  5. 在启动时输出环境清单并纳入实验追踪。

例如:

python --version
python -m pip freeze > requirements.lock.txt
uname -a
nvidia-smi
docker image inspect my-ml-image@sha256:... \
  --format '{{.RepoDigests}}'

pip freeze 的结果应作为实验输入保存,而不是事后凭记忆重建。重新安装时还要验证:

python -m pip install --require-hashes -r requirements-with-hashes.txt

只有当依赖文件包含每个包的哈希时,--require-hashes 才能防止下载到版本号相同但内容不同的构建产物。实际项目可使用 Poetry、uv、pip-tools 或 Conda lock 等工具生成锁文件,但具体命令和兼容性取决于工具版本,不能仅凭包名推断完全一致的环境。

浮点运算导致的差异

浮点加法通常不满足结合律:

(a+b)+ca+(b+c)(a+b)+c \neq a+(b+c)

例如,当 aa 很大而 b,cb,c 很小时,不同归约顺序可能丢失不同的小数部分。并行训练会把数组分成多个块,再按调度顺序归约;线程数、GPU 内核和通信顺序变化都可能改变最后几位。

这类差异可能被放大:

  • 梯度接近决策边界;
  • 优化器经过很多步迭代;
  • 分类阈值附近的样本较多;
  • 生成模型的 token 概率接近;
  • 评测样本较少。

所以,验证复现时不应只比较模型文件的字节。可以同时比较:

import numpy as np

np.testing.assert_allclose(
    actual_predictions,
    expected_predictions,
    rtol=1e-6,
    atol=1e-7,
)

容差必须与业务和数值尺度相关。对概率预测可以使用绝对误差和相对误差;对离散标签可以比较完全一致率;对训练曲线可以比较关键节点或最终指标,而不是要求每个浮点值逐位相同。

指标:数字必须绑定定义、数据和决策规则

指标不是模型的固有属性

同一个模型可以有很多不同指标。以二分类为例:

  • 真阳性 TPTP:实际为正且预测为正;
  • 假阳性 FPFP:实际为负但预测为正;
  • 真阴性 TNTN:实际为负且预测为负;
  • 假阴性 FNFN:实际为正但预测为负。

准确率为:

Accuracy=TP+TNTP+TN+FP+FN\text{Accuracy}=\frac{TP+TN}{TP+TN+FP+FN}

精确率为:

Precision=TPTP+FP\text{Precision}=\frac{TP}{TP+FP}

召回率为:

Recall=TPTP+FN\text{Recall}=\frac{TP}{TP+FN}

F1 为:

F1=2PrecisionRecallPrecision+RecallF_1 = \frac{2\cdot\text{Precision}\cdot\text{Recall}} {\text{Precision}+\text{Recall}}

这些指标依赖预测标签,而预测标签又依赖阈值。例如模型输出概率为 pp,阈值为 tt

y^={1,pt0,p<t\hat{y} = \begin{cases} 1, & p \ge t \\ 0, & p < t \end{cases}

如果一组实验使用 t=0.5t=0.5,另一组使用在验证集上调出的 t=0.3t=0.3,它们的 F1 不能直接比较。

完整算例:同一预测,不同阈值

假设测试集真实标签和模型概率如下:

样本 真实 yy 概率 pp
A 1 0.90
B 1 0.60
C 1 0.40
D 0 0.55
E 0 0.20
F 0 0.10

阈值 t=0.5t=0.5 时,预测为正的是 A、B、D:

  • TP=2TP=2:A、B;
  • FP=1FP=1:D;
  • FN=1FN=1:C;
  • TN=2TN=2:E、F。

因此:

Precision=230.667\text{Precision}=\frac{2}{3}\approx0.667

Recall=230.667\text{Recall}=\frac{2}{3}\approx0.667

F10.667F_1\approx0.667

阈值 t=0.3t=0.3 时,预测为正的是 A、B、C、D:

  • TP=3TP=3
  • FP=1FP=1
  • FN=0FN=0
  • TN=2TN=2

因此:

Precision=34=0.75\text{Precision}=\frac{3}{4}=0.75

Recall=1\text{Recall}=1

F1=2×0.75×10.75+10.857F_1=\frac{2\times0.75\times1}{0.75+1}\approx0.857

如果在测试集上选择 t=0.3t=0.3,再报告这个 F1,就使用了测试标签进行决策,产生了评测泄漏。正确流程是:

  1. 在训练集拟合模型;
  2. 在验证集选择阈值;
  3. 固定阈值;
  4. 只在测试集上评估一次。

指标记录的最小上下文

一条可靠的指标记录不应只有:

{"f1": 0.857}

至少应包含:

{
  "metric": "f1",
  "value": 0.857,
  "dataset": "test",
  "dataset_version": "2025-03-08",
  "definition": "binary F1 on thresholded probabilities",
  "threshold": 0.3,
  "average": "binary",
  "positive_label": 1,
  "sample_count": 6,
  "code_commit": "3f2a1c9",
  "model_artifact": "model-71ab"
}

多分类指标还要记录 macromicroweighted 聚合方式。回归指标要记录是否经过反变换、是否在原始单位上计算。生成式 AI 评测要记录提示词模板、参考答案、评测器版本、采样参数和人工评分规则。

指标的不确定性

有限测试集上的指标是估计量,而不是绝对真值。若在 nn 个独立样本上观察到准确率 p^\hat p,其标准误近似为:

SE(p^)=p^(1p^)nSE(\hat p)=\sqrt{\frac{\hat p(1-\hat p)}{n}}

n=100n=100p^=0.90\hat p=0.90 时:

SE0.9×0.1100=0.03SE\approx\sqrt{\frac{0.9\times0.1}{100}}=0.03

近似的 95% 区间约为:

0.90±1.96×0.030.90 \pm 1.96\times0.03

即约为 0.8410.8410.9590.959。因此,0.90 和 0.91 的差异很可能没有统计意义。对于 F1、AUC、BLEU 或复杂生成式指标,常用 bootstrap 对样本重采样估计区间,但必须记录重采样单位。例如存在同一用户的多条记录时,应按用户而不是按行采样,否则区间会过窄。

一个可运行的最小可复现实验

下面的示例使用 scikit-learn 生成数据、固定切分、Pipeline 训练,并记录数据摘要、环境、配置和指标。它适合说明生命周期,不代表生产数据版本系统。

前置条件:

python -m pip install numpy scikit-learn

保存为 run_experiment.py

from __future__ import annotations

import hashlib
import json
import os
import platform
import sys
from pathlib import Path

import numpy as np
import sklearn
from sklearn.datasets import make_classification
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, f1_score, roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler


SEED = 20250308
OUT = Path("runs") / f"seed-{SEED}"
OUT.mkdir(parents=True, exist_ok=True)


def sha256_array(array: np.ndarray) -> str:
    """对数组的形状、类型和连续字节内容计算摘要。"""
    a = np.ascontiguousarray(array)
    h = hashlib.sha256()
    h.update(str(a.shape).encode("utf-8"))
    h.update(str(a.dtype).encode("utf-8"))
    h.update(a.tobytes())
    return h.hexdigest()


def main() -> None:
    # 数据生成本身也是实验输入,因此显式固定 random_state。
    X, y = make_classification(
        n_samples=2000,
        n_features=20,
        n_informative=10,
        n_redundant=2,
        weights=[0.7, 0.3],
        class_sep=1.0,
        random_state=SEED,
    )

    X_train, X_test, y_train, y_test = train_test_split(
        X,
        y,
        test_size=0.25,
        stratify=y,
        random_state=SEED,
    )

    model = Pipeline([
        ("scale", StandardScaler()),
        ("classifier", LogisticRegression(
            max_iter=1000,
            random_state=SEED,
            solver="lbfgs",
        )),
    ])

    model.fit(X_train, y_train)

    probabilities = model.predict_proba(X_test)[:, 1]
    predictions = (probabilities >= 0.5).astype(np.int64)

    metrics = {
        "accuracy": float(accuracy_score(y_test, predictions)),
        "f1": float(f1_score(y_test, predictions)),
        "roc_auc": float(roc_auc_score(y_test, probabilities)),
    }

    config = {
        "seed": SEED,
        "data": {
            "generator": "sklearn.datasets.make_classification",
            "n_samples": 2000,
            "n_features": 20,
            "class_sep": 1.0,
            "weights": [0.7, 0.3],
            "split": {
                "test_size": 0.25,
                "stratify": True,
                "random_state": SEED,
            },
        },
        "model": {
            "type": "Pipeline(StandardScaler, LogisticRegression)",
            "max_iter": 1000,
            "solver": "lbfgs",
            "threshold": 0.5,
            "random_state": SEED,
        },
    }

    metadata = {
        "python": sys.version,
        "platform": platform.platform(),
        "numpy": np.__version__,
        "scikit_learn": sklearn.__version__,
        "pid": os.getpid(),
        "config": config,
        "data": {
            "X_shape": list(X.shape),
            "y_shape": list(y.shape),
            "X_sha256": sha256_array(X),
            "y_sha256": sha256_array(y),
            "train_size": int(len(y_train)),
            "test_size": int(len(y_test)),
        },
        "metrics": metrics,
    }

    # 保存原始预测,便于之后诊断“指标为何变化”。
    np.save(OUT / "test_probabilities.npy", probabilities)
    np.save(OUT / "test_predictions.npy", predictions)

    with (OUT / "metadata.json").open("w", encoding="utf-8") as f:
        json.dump(metadata, f, ensure_ascii=False, indent=2)

    print(json.dumps(metrics, ensure_ascii=False, indent=2))
    print(f"artifacts written to: {OUT}")


if __name__ == "__main__":
    main()

运行:

python run_experiment.py

预期输出是包含 accuracyf1roc_auc 的 JSON,以及类似:

artifacts written to: runs/seed-20250308

具体指标应以本地 scikit-learn 版本实际输出为准,不应把示例数字写死。第二次运行时,应检查:

sha256sum runs/seed-20250308/test_probabilities.npy
sha256sum runs/seed-20250308/test_predictions.npy
diff -u runs/seed-20250308/metadata.json runs/seed-20250308/metadata.json

更有意义的验证方式是把第一次运行的 metadata.json 和预测文件复制到独立目录,再比较摘要和数组:

import numpy as np

expected = np.load("reference/test_probabilities.npy")
actual = np.load("runs/seed-20250308/test_probabilities.npy")

np.testing.assert_allclose(actual, expected, rtol=1e-7, atol=1e-8)

这个示例仍然有边界:

  • make_classification 不是外部真实数据版本系统;
  • 没有记录 Git commit;
  • 没有保存模型文件;
  • 没有固定所有底层线程和 BLAS 行为;
  • 没有处理训练失败或断点恢复;
  • 没有权限、成本和追踪服务。

它的作用是把“数据—切分—训练—评测—记录”的状态显式化。

模型工件和 checkpoint 的生命周期

模型工件(artifact)是可被后续流程使用的文件或对象,例如:

  • 训练后的权重;
  • 包含预处理和模型的 Pipeline;
  • tokenizer;
  • 特征字典;
  • 评测预测;
  • 配置和环境清单;
  • checkpoint。

生产系统中应区分:

  • 最终模型:用于部署或离线推理;
  • checkpoint:用于从中断处继续训练;
  • 评测快照:用于重现某次指标;
  • 候选模型:尚未经过发布审批。

断点恢复并不等于重新从头训练。恢复时至少需要:

Sresume=(θ,optimizer,scheduler,epoch,R,scaler)S_{\text{resume}} = (\theta, \text{optimizer}, \text{scheduler}, \text{epoch}, R, \text{scaler})

其中:

  • θ\theta:模型参数;
  • optimizer:优化器状态,例如 Adam 的一阶、二阶矩;
  • scheduler:学习率调度状态;
  • epoch 或 step:训练进度;
  • RR:随机数状态;
  • scaler:混合精度的 loss scaler 状态。

只保存模型权重而不保存优化器和随机状态,通常无法得到与未中断训练相同的后续轨迹。数据加载器已经消费了多少样本、当前 epoch 的 sampler 状态,也可能影响恢复结果。

故障恢复流程应明确:

  1. 读取 checkpoint;
  2. 校验 checkpoint 的哈希和 schema;
  3. 校验代码、环境、数据版本是否兼容;
  4. 恢复模型、优化器、调度器和随机状态;
  5. 从记录的 step 继续;
  6. 生成新的运行 ID,并将其标记为原运行的恢复子运行;
  7. 比较恢复点前后的 loss 和 step 是否连续。

如果 checkpoint 损坏,不应静默从头训练并覆盖原实验;应记录失败原因,并从最近的已验证 checkpoint 恢复。

实验追踪:把运行变成可查询的记录

实验追踪系统(experiment tracking system)负责保存和关联实验状态。它不只是一个“打印指标”的地方,而是一个带关系的记录系统。

可以用以下关系理解:

Run{Config,Dataset,Environment,Artifacts,Metrics,Events}\text{Run} \rightarrow \{\text{Config}, \text{Dataset}, \text{Environment}, \text{Artifacts}, \text{Metrics}, \text{Events}\}

参数、指标、工件和事件的区别

  • 参数(parameter):运行开始前确定的输入,例如 batch size、学习率、阈值。
  • 指标(metric):随 step 或 epoch 变化的数值,例如 loss、accuracy、token 用量。
  • 工件(artifact):文件或二进制对象,例如模型、预测、混淆矩阵、评测报告。
  • 事件(event):状态变化,例如开始、失败、恢复、取消、发布审批。
  • 标签(tag):用于筛选和组织的字段,例如任务、团队、环境。

不要把大模型权重放入普通关系数据库的单个文本字段,也不要只把模型路径写进日志而不保存对象存储中的不可变版本。常见组合是:

  • 数据库保存 run、参数、指标和状态;
  • 对象存储保存模型、预测和日志;
  • 内容摘要把数据库记录与对象绑定;
  • 权限系统控制谁能读写数据和模型。

状态转换和幂等性

一个运行可以有如下状态:

stateDiagram-v2
    [*] --> CREATED
    CREATED --> RUNNING
    RUNNING --> SUCCEEDED
    RUNNING --> FAILED
    RUNNING --> CANCELLED
    FAILED --> RETRYING
    RETRYING --> RUNNING
    SUCCEEDED --> APPROVED
    APPROVED --> DEPLOYED

状态变化需要满足两个条件:

  1. 合法性:例如 DEPLOYED 不能直接从 CREATED 产生,必须经过成功和审批。
  2. 幂等性:网络超时后重试写入,不能创建两个内容相同但身份不明的运行。

一种实用方法是使用客户端生成的 run_id 作为幂等键,并把每次写入设计成 upsert 或带唯一约束的事务操作。训练进程先写 RUNNING,结束时写 SUCCEEDED;如果进程被杀死,后台 reconciler 根据心跳超时把它标记为 FAILEDUNKNOWN,而不是假装成功。

指标写入的时序

训练循环中的指标可能经历:

  1. 计算 batch loss;
  2. 在本地聚合;
  3. 写入追踪客户端缓存;
  4. 网络发送;
  5. 服务端持久化;
  6. 查询端展示。

若进程在第 3 步后崩溃,终端可能已经打印了指标,但服务器没有收到。因此关键 step 的指标应在训练结束前显式 flush,并设置写入失败策略:

  • 关键指标写入失败:实验失败或标记为不完整;
  • 调试日志写入失败:可以降级为本地文件;
  • 追踪服务不可用:保留本地事件队列,恢复后补传;
  • 本地磁盘不足:立即停止并保留错误上下文。

不能让“训练成功但指标没写进去”与“训练和评测均成功”使用同一个状态。

数据、权限和成本必须属于同一生产系统

权限影响复现

一次实验可能因为权限不同而读取不同数据:

  • 生产表有行级权限;
  • 脱敏用户看不到部分字段;
  • 对象存储临时凭证过期;
  • API key 对不同模型有不同访问范围;
  • 评测器不能读取业务标签。

因此运行记录应包含数据访问身份的非敏感标识、策略版本和资源范围,但不能把明文 token、密码或个人隐私写入日志。配置中应使用 secret manager 的引用,例如:

llm:
  provider: example
  model: model-version-2025-03
  api_key_ref: secret://ml/llm/eval-key

日志脱敏也应覆盖提示词、用户 ID、文件路径和错误堆栈中的凭证。权限不足时,应让运行进入明确的 FAILED_PERMISSION 或等价状态,而不是返回空数据继续训练。

成本也是实验输入

生成式 AI 和大规模训练的成本不是事后财务信息,它会影响实验可运行性和决策:

Cost=GPU-hours×GPU rate+Input tokens×input rate+Output tokens×output rate+storage+egress\text{Cost} = \text{GPU-hours}\times\text{GPU rate} + \text{Input tokens}\times\text{input rate} + \text{Output tokens}\times\text{output rate} + \text{storage}+\text{egress}

每次运行至少应记录:

  • 设备类型、数量和运行时长;
  • 输入和输出 token 数;
  • API 请求次数、重试次数和缓存命中;
  • 数据存储大小和保留期限;
  • 失败请求是否收费;
  • 估算成本和实际账单成本(若可获得)。

设置预算上限时,应区分“单次请求限制”和“整个 run 限制”。如果重试没有指数退避和最大次数,服务端短暂故障可能把成本放大。达到预算上限应停止生成并记录 CANCELLED_BUDGET,而不是留下一个看似完成但评测样本不完整的结果。

常见失败表现与诊断路径

同一代码、同一种子,指标仍然变化

按以下顺序排查:

  1. 打印并比较实际数据文件或样本 ID 哈希;
  2. 比较训练、验证、测试样本数量和标签分布;
  3. 检查是否有时间、用户、设备等外部切分条件;
  4. 检查 Python、框架、驱动、CUDA 和硬件;
  5. 检查线程数、GPU 数和数据加载 worker;
  6. 检查是否启用了非确定性算子;
  7. 比较原始预测,而不只是最终指标;
  8. 检查评测阈值、聚合方式和过滤规则;
  9. 检查恢复训练时是否完整保存优化器和随机状态;
  10. 检查外部 API 的模型版本和响应内容。

如果原始预测相同而指标不同,问题通常在评测代码或指标上下文;如果训练 loss 从第一步就不同,优先检查环境、初始化、数据顺序和随机状态;如果前几轮相同、后面逐渐分叉,常见原因是并行浮点误差、数据加载顺序或恢复状态不完整。

指标突然变好

指标突然上升不一定是模型改进,常见原因包括:

  • 测试集被用于阈值选择;
  • 训练和测试用户重叠;
  • 特征包含未来信息;
  • 评测集过滤掉了困难样本;
  • 正负样本定义改变;
  • 只评估成功请求,忽略失败请求;
  • 生成式评测器或提示模板发生变化。

诊断时应保存逐样本预测、样本 ID、错误类型和评测版本。只保存平均分,无法解释分数突变。

重新安装环境后仍无法复现

可能是版本号相同但构建不同,也可能是底层库、硬件或编译选项不同。应比较:

  • 容器 image digest;
  • pip freeze 和依赖哈希;
  • Python ABI;
  • BLAS/OpenMP 实现;
  • CUDA、cuDNN 和驱动;
  • CPU/GPU 型号;
  • 操作系统和 locale;
  • 线程及确定性配置。

如果业务只要求结论级复现,应把允许的误差和模型排序稳定性写成自动化验收,而不是无限追求字节级一致。

复现验证应像测试一样执行

一个成熟的复现检查可以分为三层:

配置和输入校验

在训练开始前校验:

  • 数据版本是否存在;
  • schema 是否匹配;
  • 训练集和测试集是否重叠;
  • 配置是否包含必填字段;
  • 模型版本是否允许使用;
  • 预算是否足够;
  • 权限是否满足。

运行中校验

训练过程中记录并检查:

  • step、epoch 是否单调递增;
  • loss 是否为有限值;
  • checkpoint 是否可读;
  • 指标写入是否成功;
  • GPU 显存、耗时和重试次数;
  • 数据加载是否出现空 batch 或异常样本。

结果校验

结束后检查:

  • 模型能否重新加载;
  • 对固定样本的预测是否在容差内;
  • 关键指标是否存在且对应正确数据版本;
  • 预测数量是否等于评测样本数量;
  • 工件摘要是否与对象存储一致;
  • 失败运行是否不会被标记为可发布;
  • 成本是否超过预算。

复现不是“再运行一次”,而是执行一组关于输入、状态、输出和约束的断言。

规范保证、常见实现与经验取舍

需要区分三类结论:

  • 规范保证:例如 SHA-256 对相同输入应产生相同摘要;Pipeline 会按照定义组织变换步骤。
  • 常见实现:许多框架提供 random_state、确定性算法开关和 checkpoint 接口,但具体覆盖范围随版本变化。
  • 经验建议:固定线程、使用容器、保存样本 ID、记录成本,能显著提高可诊断性,但不自动构成数学上的完全确定性。

对研究探索,可以接受更灵活的环境和较低追踪成本,但至少要保留数据版本、代码提交、配置和核心指标。对生产发布,则应增加不可变工件、权限审计、预算控制、回滚版本和自动复现验证。对生成式 AI,通常优先保证请求和评测协议可重放、输出结构满足约束,并通过容差或语义规则验收,而不是假设服务端永远返回同一段文本。

最终,机器学习实验的基本单位不应只是“一个模型文件”,而应是一个完整的运行记录:

Reproducible Run=(code,config,data,environment,random state,evaluation,artifacts,permissions,cost)\text{Reproducible Run} = (\text{code},\text{config},\text{data},\text{environment}, \text{random state},\text{evaluation},\text{artifacts}, \text{permissions},\text{cost})

当这些状态能够被定位、校验、重放和比较时,实验结果才真正具备工程上的可复现性。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。