AI 工程基础体系 · 第 40/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。
机器学习实验可复现:随机种子、数据版本、环境、指标与追踪
机器学习实验的“可复现”,不是把某个参数命名为 seed,然后再次运行脚本就得到相同数字。一次实验的结果通常由以下函数共同决定:
其中:
- :实验输出,包括模型文件、预测结果、指标和日志;
- :代码与配置;
- :训练、验证和测试数据;
- :运行环境,包括解释器、依赖包、驱动和硬件;
- :算法实现及其执行方式,例如线程数、并行策略;
- :随机状态;
- :执行顺序、并发调度和外部服务响应;
- :时间、权限、资源配额等运行条件。
只有这些输入和执行条件在所需精度范围内保持一致,输出才可能复现。
因此,实验复现至少有三种强度:
- 比特级复现:模型文件、预测数组和指标逐字节一致。
- 数值级复现:浮点结果允许极小误差,例如
1e-7,但指标和排序一致。 - 结论级复现:重新运行后,核心结论、模型比较关系和业务决策不变。
比特级复现最严格,也最容易受到硬件、并行计算和序列化格式影响;生产系统通常更关心数值级或结论级复现,但必须明确验收标准,不能把“看起来差不多”当作定义。
一次实验到底包含哪些状态
训练脚本只是实验的一部分。更完整的实验状态可以表示为:
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)通过确定性算法产生随机序列。给定算法、初始状态和调用顺序后,输出序列通常是确定的:
- 是第 步内部状态;
- 是状态转移函数;
- 把状态映射为随机数;
- 种子通常用于初始化 。
因此,种子只能保证“从同一状态、按同一顺序调用同一生成器”时得到同一随机流。下面两个程序虽然都设置了相同种子,但第二个程序多调用了一次随机数,后续结果就会整体错位:
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,
)
这里至少有两个独立的随机过程:
train_test_split决定样本如何进入训练集和测试集;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 的随机性更复杂
生成式模型的输出通常来自条件概率分布:
温度、top-p、top-k 等采样参数会改变这个分布。即使请求中带有 seed,也不一定能得到跨时间、跨模型版本、跨硬件完全一致的输出,因为:
- 服务端可能更换模型权重;
- tokenizer 或采样实现可能变化;
- 服务端的
seed可能只是“尽量复现”的提示; - 并行浮点计算可能产生不同 logits;
- 供应商可能不承诺确定性。
生成式 AI 实验应记录:
- 模型标识和版本;
- tokenizer 版本;
- system、user 和工具调用消息;
- 采样参数;
- 服务端返回的模型标识;
- seed(若接口支持);
- 原始响应、错误响应和时间;
- token 用量、价格和限流状态。
对于需要严格回归测试的任务,可将黄金输入、期望结构化输出和允许差异规则纳入测试。对自然语言输出直接做字符串完全相等比较,通常过于脆弱;对 JSON、函数调用参数或分类标签,则可以定义结构化断言。
数据版本:哈希只能证明内容,不能自动证明语义
数据版本的三个层次
数据版本至少包含三层:
- 内容版本:文件或表中的具体字节;
- 处理版本:过滤、去重、标注、切分、特征工程代码;
- 语义版本:字段含义、标签定义、采集范围和时间窗口。
对文件计算 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"
}
数据切分是实验状态的一部分
监督学习中,切分数据的目标是估计模型在未见样本上的表现。设训练集为 ,测试集为 ,评估指标为 ,则:
只要 或 改变, 就不再是同一个量。
常见切分方式包括:
- 随机切分:适用于样本近似独立同分布的场景;
- 分层切分:保持分类标签比例;
- 按时间切分:避免用未来预测过去;
- 按用户、设备或主体分组切分:避免同一主体同时出现在训练和测试中。
例如,医疗记录按行随机切分,可能使同一患者的多次就诊同时出现于训练集和测试集,导致严重的数据泄漏。此时即使 seed 固定,结果也只是稳定地错误。
一个可复现的数据切分不仅要保存 seed,还应保存:
- 切分算法;
- 分组键或时间边界;
- 每个集合的样本 ID;
- 过滤前后的样本数量;
- 标签分布;
- 去重和冲突处理规则。
对于关键评测集,直接保存测试集样本 ID 往往比只保存 random_state 更可靠,因为数据源可能新增、删除或重排记录。
数据泄漏会制造“可复现的假象”
假设先在全量数据上拟合标准化参数:
再切分数据,那么测试集的信息已经进入 和 。模型每次运行都可能得到相同结果,但测试指标偏乐观。
正确做法是把预处理放入只在训练集上拟合的 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;
- 操作系统库;
- 本地源码是否被修改。
因此,常见的分层方案是:
- 用
requirements.txt、锁文件或 Conda lock 固定 Python 依赖; - 用容器镜像固定系统库;
- 保存镜像 digest,而不是只保存可变的 tag;
- 记录硬件和驱动信息;
- 在启动时输出环境清单并纳入实验追踪。
例如:
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 等工具生成锁文件,但具体命令和兼容性取决于工具版本,不能仅凭包名推断完全一致的环境。
浮点运算导致的差异
浮点加法通常不满足结合律:
例如,当 很大而 很小时,不同归约顺序可能丢失不同的小数部分。并行训练会把数组分成多个块,再按调度顺序归约;线程数、GPU 内核和通信顺序变化都可能改变最后几位。
这类差异可能被放大:
- 梯度接近决策边界;
- 优化器经过很多步迭代;
- 分类阈值附近的样本较多;
- 生成模型的 token 概率接近;
- 评测样本较少。
所以,验证复现时不应只比较模型文件的字节。可以同时比较:
import numpy as np
np.testing.assert_allclose(
actual_predictions,
expected_predictions,
rtol=1e-6,
atol=1e-7,
)
容差必须与业务和数值尺度相关。对概率预测可以使用绝对误差和相对误差;对离散标签可以比较完全一致率;对训练曲线可以比较关键节点或最终指标,而不是要求每个浮点值逐位相同。
指标:数字必须绑定定义、数据和决策规则
指标不是模型的固有属性
同一个模型可以有很多不同指标。以二分类为例:
- 真阳性 :实际为正且预测为正;
- 假阳性 :实际为负但预测为正;
- 真阴性 :实际为负且预测为负;
- 假阴性 :实际为正但预测为负。
准确率为:
精确率为:
召回率为:
F1 为:
这些指标依赖预测标签,而预测标签又依赖阈值。例如模型输出概率为 ,阈值为 :
如果一组实验使用 ,另一组使用在验证集上调出的 ,它们的 F1 不能直接比较。
完整算例:同一预测,不同阈值
假设测试集真实标签和模型概率如下:
| 样本 | 真实 | 概率 |
|---|---|---|
| A | 1 | 0.90 |
| B | 1 | 0.60 |
| C | 1 | 0.40 |
| D | 0 | 0.55 |
| E | 0 | 0.20 |
| F | 0 | 0.10 |
阈值 时,预测为正的是 A、B、D:
- :A、B;
- :D;
- :C;
- :E、F。
因此:
阈值 时,预测为正的是 A、B、C、D:
- ;
- ;
- ;
- 。
因此:
如果在测试集上选择 ,再报告这个 F1,就使用了测试标签进行决策,产生了评测泄漏。正确流程是:
- 在训练集拟合模型;
- 在验证集选择阈值;
- 固定阈值;
- 只在测试集上评估一次。
指标记录的最小上下文
一条可靠的指标记录不应只有:
{"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"
}
多分类指标还要记录 macro、micro 或 weighted 聚合方式。回归指标要记录是否经过反变换、是否在原始单位上计算。生成式 AI 评测要记录提示词模板、参考答案、评测器版本、采样参数和人工评分规则。
指标的不确定性
有限测试集上的指标是估计量,而不是绝对真值。若在 个独立样本上观察到准确率 ,其标准误近似为:
当 , 时:
近似的 95% 区间约为:
即约为 到 。因此,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
预期输出是包含 accuracy、f1 和 roc_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:用于从中断处继续训练;
- 评测快照:用于重现某次指标;
- 候选模型:尚未经过发布审批。
断点恢复并不等于重新从头训练。恢复时至少需要:
其中:
- :模型参数;
optimizer:优化器状态,例如 Adam 的一阶、二阶矩;scheduler:学习率调度状态;epoch或 step:训练进度;- :随机数状态;
scaler:混合精度的 loss scaler 状态。
只保存模型权重而不保存优化器和随机状态,通常无法得到与未中断训练相同的后续轨迹。数据加载器已经消费了多少样本、当前 epoch 的 sampler 状态,也可能影响恢复结果。
故障恢复流程应明确:
- 读取 checkpoint;
- 校验 checkpoint 的哈希和 schema;
- 校验代码、环境、数据版本是否兼容;
- 恢复模型、优化器、调度器和随机状态;
- 从记录的 step 继续;
- 生成新的运行 ID,并将其标记为原运行的恢复子运行;
- 比较恢复点前后的 loss 和 step 是否连续。
如果 checkpoint 损坏,不应静默从头训练并覆盖原实验;应记录失败原因,并从最近的已验证 checkpoint 恢复。
实验追踪:把运行变成可查询的记录
实验追踪系统(experiment tracking system)负责保存和关联实验状态。它不只是一个“打印指标”的地方,而是一个带关系的记录系统。
可以用以下关系理解:
参数、指标、工件和事件的区别
- 参数(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
状态变化需要满足两个条件:
- 合法性:例如
DEPLOYED不能直接从CREATED产生,必须经过成功和审批。 - 幂等性:网络超时后重试写入,不能创建两个内容相同但身份不明的运行。
一种实用方法是使用客户端生成的 run_id 作为幂等键,并把每次写入设计成 upsert 或带唯一约束的事务操作。训练进程先写 RUNNING,结束时写 SUCCEEDED;如果进程被杀死,后台 reconciler 根据心跳超时把它标记为 FAILED 或 UNKNOWN,而不是假装成功。
指标写入的时序
训练循环中的指标可能经历:
- 计算 batch loss;
- 在本地聚合;
- 写入追踪客户端缓存;
- 网络发送;
- 服务端持久化;
- 查询端展示。
若进程在第 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 和大规模训练的成本不是事后财务信息,它会影响实验可运行性和决策:
每次运行至少应记录:
- 设备类型、数量和运行时长;
- 输入和输出 token 数;
- API 请求次数、重试次数和缓存命中;
- 数据存储大小和保留期限;
- 失败请求是否收费;
- 估算成本和实际账单成本(若可获得)。
设置预算上限时,应区分“单次请求限制”和“整个 run 限制”。如果重试没有指数退避和最大次数,服务端短暂故障可能把成本放大。达到预算上限应停止生成并记录 CANCELLED_BUDGET,而不是留下一个看似完成但评测样本不完整的结果。
常见失败表现与诊断路径
同一代码、同一种子,指标仍然变化
按以下顺序排查:
- 打印并比较实际数据文件或样本 ID 哈希;
- 比较训练、验证、测试样本数量和标签分布;
- 检查是否有时间、用户、设备等外部切分条件;
- 检查 Python、框架、驱动、CUDA 和硬件;
- 检查线程数、GPU 数和数据加载 worker;
- 检查是否启用了非确定性算子;
- 比较原始预测,而不只是最终指标;
- 检查评测阈值、聚合方式和过滤规则;
- 检查恢复训练时是否完整保存优化器和随机状态;
- 检查外部 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,通常优先保证请求和评测协议可重放、输出结构满足约束,并通过容差或语义规则验收,而不是假设服务端永远返回同一段文本。
最终,机器学习实验的基本单位不应只是“一个模型文件”,而应是一个完整的运行记录:
当这些状态能够被定位、校验、重放和比较时,实验结果才真正具备工程上的可复现性。
系列导航与关联阅读
- 系列入口:AI 工程完整学习路线:从机器学习与 Transformer 到 RAG、Agent 和生产治理
- 上一篇:AI 数值计算:浮点误差、稳定性、向量化、条件数与精度选择
- 下一篇:机器学习特征工程:编码、缩放、交叉、选择、泄漏与线上一致性
官方资料
本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论