
做实验时最怕遇到一种情况:改了一行代码,指标涨了两个点,正高兴呢,结果再跑一遍又跌回去了,最后发现那两点的提升纯粹是随机波动造成的假象。模型评估结果不稳定并不是玄学,它背后有清晰的随机性来源。权重初始化方式决定了模型收敛的起点,数据加载时的shuffle顺序会影响每个batch的构成,dropout在每次前向传播时随机丢弃神经元,优化器如Adam的某些实现里也有随机性。甚至同一份代码在GPU上跑两次,浮点运算的非确定性也可能导致微小的数值差异。这些因素叠加起来,单次评估得到的准确率、F1、AUC等指标其实是某个随机过程的一次采样,你看到的91.2%和90.5%只是同一个分布里的两个样本点。
知道了随机性的根源,解决思路就有了两条路线。路线一是把所有能固定的东西全部固定:设置随机种子、关掉cudnn的非确定性选项、把数据加载器做成完全确定顺序。这条路看起来简单直接,但只适用于纯粹复现一次结果,不能回答“模型的真实性能到底是多少”这个问题。因为即便种子固定了,权重初始值、dropout的具体掩码等仍然是对应那个特定种子的一个实例,换一个种子结果照样变。路线二则是跳出单次评估的思维,把每次运行看作从模型性能分布中抽取的一个样本,通过多次运行并做统计汇总来估计真实性能以及波动范围。第二条路线更符合实验科学的常规做法,也是本文要展开讲的内容。
随机性从哪来:拆解训练过程中的不确定性源
先说权重初始化。神经网络各层的参数通常在训练开始前从一个特定分布中采样,比如Xavier初始化或He初始化。采样过程依赖随机数生成器,即使后续所有操作完全确定,不同的初始权重也会让模型收敛到不同的局部最优解,最终测试集上的表现自然不一样。在一个简单的两层MLP上,仅仅改变随机种子,在CIFAR-10子集上训练后的测试准确率可以相差2到3个百分点。这个差异不是bug,而是非凸优化问题的固有属性。
数据顺序的随机性同样不可忽视。SGD类算法靠小批量梯度更新参数,每个batch里包含哪些样本直接决定了梯度方向。大多数框架在每个epoch开始前都会对训练集做一次shuffle,同一个shuffle算法在不同随机种子下会产生完全不同顺序的数据流。如果你用了Batch Normalization,那么每个batch的统计量也会跟着变,进一步放大随机性。另外dropout在前向过程中随机置零神经元,相当于每次训练都采样出一个更小的子网络,推理阶段虽然关闭了dropout,但训练过程中学到的权重已经受到这些随机子网络的影响。
还有一类容易被忽略的来源是第三方库和硬件。比如PyTorch中的某些操作,在CUDA环境下由于线程调度顺序不固定,浮点加法结果可能产生细微差别。再比如使用数据增强时,random crop、random flip这些操作本身就是随机过程。如果你的数据加载器设置了num_workers大于0,子进程的调度顺序也会影响样本到达模型的顺序。所以即便你设置了torch.manual_seed(42),如果其他随机源没管住,结果依然会漂移。
多次运行到底怎么做:代码示例与统计汇总
最直接的做法是循环运行N次完整的训练加评估流程,每次使用不同的随机种子。N通常取5到10次,太少统计意义不足,太多则计算成本过高。下面用PyTorch演示一个简化版本,核心是把整个训练封装成函数,在循环里重置随机数生成器。
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
def set_seed(seed_val):
torch.manual_seed(seed_val)
np.random.seed(seed_val)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(seed_val)
def train_and_evaluate(seed_val, train_loader, test_loader):
set_seed(seed_val)
model = nn.Sequential(
nn.Linear(20, 64),
nn.ReLU(),
nn.Dropout(0.3),
nn.Linear(64, 2)
)
optimizer = optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
for epoch in range(10):
model.train()
for x_batch, y_batch in train_loader:
optimizer.zero_grad()
logits = model(x_batch)
loss = criterion(logits, y_batch)
loss.backward()
optimizer.step()
model.eval()
correct = 0
total = 0
with torch.no_grad():
for x_batch, y_batch in test_loader:
logits = model(x_batch)
preds = torch.argmax(logits, dim=1)
correct += (preds == y_batch).sum().item()
total += y_batch.size(0)
return correct / total
# 模拟数据,真实情况用你自己的数据集
X_train = torch.randn(800, 20)
y_train = torch.randint(0, 2, (800,))
X_test = torch.randn(200, 20)
y_test = torch.randint(0, 2, (200,))
train_loader = DataLoader(TensorDataset(X_train, y_train), batch_size=32, shuffle=True)
test_loader = DataLoader(TensorDataset(X_test, y_test), batch_size=64, shuffle=False)
scores = []
for run in range(10):
acc = train_and_evaluate(run, train_loader, test_loader)
scores.append(acc)
print(f"Run {run+1}: accuracy = {acc:.4f}")
scores = np.array(scores)
mean_acc = scores.mean()
std_acc = scores.std(ddof=1) # 样本标准差,ddof=1用于无偏估计
print(f"Mean accuracy: {mean_acc:.4f}")
print(f"Std dev: {std_acc:.4f}")
这个循环里跑了10次,每次seed不同。注意train_loader在构造时设置了shuffle=True,但每次训练前调用了set_seed,所以每个run内部的shuffle顺序是确定的。真正变化的是模型权重初始化和dropout掩码。最终得到10个准确率数值,计算均值和样本标准差。如果标准差很小,比如0.003,说明模型对这个范围内的随机性不敏感,单次结果基本可信;如果标准差超过0.01,那么你报告均值时必须同时带上标准差,或者给出区间。
有时候指标不是准确率而是AUC或者F1,多次运行后同样可以计算均值和标准差。更进一步,可以计算95%置信区间。假设10次结果近似服从正态分布,区间为mean ± 1.96 * std / sqrt(n)。不过当n只有5或10时,正态近似不太靠谱,更稳妥的是用t分布。Python的scipy.stats.t.interval可以直接算。另外如果想对比两组模型(比如baseline和加了新模块的模型),不要只看均值差,要用配对样本t检验或者Wilcoxon符号秩检验,判断差异是否统计显著。否则你可能会因为一两点的均值差而误认为新模块有效,实际两个分布大面积重叠。
标准差比均值更有信息量:解读评估结果的正确姿势
很多论文和实验报告只给一个测试集准确率,这其实埋下了巨大的隐患。假设你训练了一个模型,单次测试准确率85.3%,另一个模型单次84.9%,直观感受是第一个更好。但如果每个模型分别运行10次,第一个模型的准确率范围是83.1%到87.2%,第二个是84.5%到85.6%,那结论可能反过来:第二个模型虽然单次均值略低,但表现极其稳定,而第一个模型波动巨大,85.3%那次只是运气好。这种情况下,只比较单次结果会得出完全错误的结论。
标准差反映的是模型对训练随机性的鲁棒性。一个标准差小的模型,意味着无论你用什么随机种子、什么初始化方式、数据以什么顺序进入,它都能收敛到差不多的效果。这样的模型在生产环境中更可靠,部署后不会因为重新训练就突然性能跳水。而标准差大的模型,可能在某些随机条件下表现非常好,但换个条件就崩了。这种模型的所谓“高分”是不可复现的,除非你能把当时的随机种子和所有环境都严格固定下来。在实际业务里,追求稳定往往比追求极限分数更重要。
如果计算资源有限,跑不了太多次完整训练怎么办?可以用bootstrap方法。从已有的少数几次结果中有放回地抽样,生成大量伪样本,然后估计均值和置信区间。比如你只跑了5次,得到[0.82, 0.85, 0.81, 0.84, 0.83],对这5个数做10000次有放回抽样,每次抽5个计算均值,最后看这10000个均值分布的第2.5百分位和第97.5百分位作为近似置信区间。这个方法不增加训练成本,但只适用于评估指标层面的重采样,不能弥补原始样本量过少的问题。如果连5次都跑不起,还有一种近似做法:固定随机种子只跑一次,但在评估阶段引入多次不同的数据增强或多次不同的dropout推理(MC dropout),同样能得到一个分数分布。不过注意,MC dropout估计的是模型不确定性,和训练随机性不是一回事,解释时要区分清楚。
统计方法用起来之后,你还需要在实验记录里规范地报告结果。不要写“准确率91.2%”,而应该写成“准确率均值91.2%,标准差0.8%,95%置信区间[89.6%, 92.8%](基于10次独立运行)”。这样的报告才能让读者或同事判断你的结果是否可信,也方便后来的实验做公平比较。对工程师来说,这也是一种自我保护的机制:当产品经理质疑模型为什么线上表现和离线测试不一样时,你可以拿出多次运行的分布图,说明离线测试本身就有±1%的波动范围,而不是你的代码突然出了问题。