查看全课大纲(第 39 / 40 节)
- 1.从规则到学习:深度学习解决什么问题
- 2.表示学习与端到端训练
- 3.张量、计算图与自动微分
- 4.训练三要素:数据、模型、目标函数
- 5.从感知机到多层感知机
- 6.反向传播:手推一个两层网络
- 7.激活函数与初始化
- 8.优化器:SGD → Momentum → Adam
- 9.正则化:权重衰减、Dropout、BN/LN
- 10.卷积的直觉与参数共享
- 11.池化、感受野与下采样
- 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
- 13.残差连接与归一化为什么有效
- 14.迁移学习与特征可视化
- 15.RNN 与 BPTT
- 16.LSTM/GRU:门控如何缓解梯度问题
- 17.Seq2Seq 与编码器-解码器
- 18.注意力前史:Bahdanau 加性注意力
- 19.Self-Attention:QKV 与缩放点积
- 20.多头注意力:并行的多种关系
- 21.位置编码:正弦与可学习
- 22.残差、LayerNorm 与前馈层
- 23.从零实现一个最小 Transformer block
- 24.训练稳定性、复杂度与常见误区
- 25.自监督与语言建模目标
- 26.BERT:掩码语言模型与双向编码
- 27.GPT:自回归生成与缩放定律
- 28.指令微调与对齐:SFT → RLHF/DPO
- 29.参数高效微调:LoRA 与适配器
- 30.自编码器与 VAE
- 31.GAN:对抗训练与模式崩塌
- 32.扩散模型:前向加噪与反向去噪
- 33.扩散模型的应用与采样加速
- 34.对比学习与 CLIP
- 35.ViT:把图像当序列
- 36.多模态架构:对齐、融合与生成
- 37.能力边界、幻觉与评测
- 38.PyTorch 2.x 训练循环与混合精度
- 39.实验管理、评测与复现
- 40.推理部署与成本:量化/蒸馏概览
实验管理、评测与复现
小象实战讲义 · 深度学习基础:从神经网络到 Transformer
模型跑通只是起点。当你要调超参、比模型、换随机种子重跑时,真正麻烦的是:配置记不全、指标对不上、结果复现不出来。本节讲三件事——把配置与结果结构化落盘、选对评测指标、用随机种子与完整快照保证可复现。本节是第 9 章《工程实践与收口》的第 2 节。先修:上一节的训练循环、训练/验证/测试集划分、JSON 与字典操作。后续:下一节讲推理部署与成本,量化与蒸馏。
💡 核心导读
- 实验管理:学习如何系统化地记录实验配置(超参数)、代码版本、训练指标和模型权重。
- 评测与指标:理解训练集、验证集和测试集的严格划分,掌握常见分类与回归任务的评测指标计算方法。
- 实验复现:通过固定随机种子、保存完整配置和模型,确保任何实验都能被精确复现。
- 工具与模式:了解实验管理的基本代码模式,虽然不依赖特定第三方库,但理解其设计思想。
🧭 前置知识
- PyTorch 基本训练循环(定义模型、损失函数、优化器、迭代训练)。
- 理解训练集、验证集、测试集的作用。
- 熟悉 Python 字典、JSON 文件的基本操作。
实验管理:记录一切可复现的要素
一次完整的深度学习实验远不止训练代码。为了能够复现、分析和比较,我们需要系统化地记录以下要素:
- 实验配置:所有超参数(学习率、批大小、模型结构参数等)、数据集信息、优化器选择。
- 代码版本:训练脚本的 Git 提交哈希,确保代码可追溯。
- 训练过程:每个 epoch 的训练损失、验证损失、评估指标。
- 模型快照:训练过程中或训练结束后的模型权重。
- 环境信息:Python 版本、PyTorch 版本、CUDA 版本等。
一个简单的做法是使用 Python 字典来定义配置,并将其与结果一起保存。
import torch
import json
import time
from pathlib import Path
torch.manual_seed(0)
# 1. 定义实验配置
config = {
"experiment_name": "exp001_simple_mlp",
"run_id": "20261029_120000", # 实际项目用 time.strftime("%Y%m%d_%H%M%S") 生成
"model": {
"type": "MLP",
"input_dim": 20,
"hidden_dim": 64,
"output_dim": 2,
"activation": "ReLU"
},
"training": {
"batch_size": 4,
"num_epochs": 2,
"learning_rate": 0.001,
"optimizer": "Adam",
"loss_function": "CrossEntropyLoss"
},
"data": {
"dataset": "Synthetic",
"num_train_samples": 80,
"num_val_samples": 20
},
"misc": {
"random_seed": 0,
"device": "cpu"
}
}
# 2. 根据配置创建模型(示例)
class SimpleMLP(torch.nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__()
self.fc1 = torch.nn.Linear(input_dim, hidden_dim)
self.relu = torch.nn.ReLU()
self.fc2 = torch.nn.Linear(hidden_dim, output_dim)
def forward(self, x):
x = self.relu(self.fc1(x))
x = self.fc2(x)
return x
model = SimpleMLP(config["model"]["input_dim"],
config["model"]["hidden_dim"],
config["model"]["output_dim"])
print(f"模型参数量: {sum(p.numel() for p in model.parameters())}")
# 3. 模拟训练过程并记录指标(此处简化,仅记录损失)
train_loss_history = [0.9, 0.5, 0.3, 0.2] # 模拟4个step的损失
val_loss_history = [0.8, 0.6, 0.4, 0.3]
val_accuracy_history = [0.6, 0.7, 0.75, 0.8]
# 4. 将配置和结果整合
experiment_record = {
"config": config,
"results": {
"final_train_loss": train_loss_history[-1],
"final_val_loss": val_loss_history[-1],
"final_val_accuracy": val_accuracy_history[-1],
"train_loss_history": train_loss_history,
"val_loss_history": val_loss_history,
"val_accuracy_history": val_accuracy_history
}
}
# 5. 保存实验记录到文件(JSON格式,易于阅读和解析)
save_dir = Path("./experiment_records") # 实际项目中可用绝对路径
save_dir.mkdir(exist_ok=True)
save_path = save_dir / f"{config['experiment_name']}_{config['run_id']}.json"
with open(save_path, 'w', encoding='utf-8') as f:
json.dump(experiment_record, f, indent=2, ensure_ascii=False)
print(f"实验记录已保存: {save_path.name}")
print(f"最终验证准确率: {val_accuracy_history[-1]:.4f}")模型参数量: 1474
实验记录已保存: exp001_simple_mlp_20261029_120000.json
最终验证准确率: 0.8000关键点:JSON 人类可读,也能被其他程序直接加载。run_id 与唯一的 experiment_name 保证记录不被覆盖;真实项目里 run_id 通常由时间戳生成,本节为了输出可复现把它固定成常量。
评测指标:量化模型性能
模型训练完成后,我们需要在未见过的测试集上评估其泛化性能。评估指标的选择取决于任务类型。
分类任务常用指标
对于分类任务,除了准确率(Accuracy),查准率(Precision)、查全率(Recall)和 F1 分数能提供更细致的评估,尤其在类别不平衡时。
import torch
import numpy as np
torch.manual_seed(0)
# 模拟一个二分类任务的模型预测和真实标签
# 假设 batch_size=4, 输出2个类别的 logits
logits = torch.randn(4, 2) # 模型原始输出
labels = torch.tensor([0, 1, 1, 0]) # 真实标签 (0或1)
# 1. 计算准确率 (Accuracy)
predicted_classes = torch.argmax(logits, dim=1) # 预测的类别
accuracy = (predicted_classes == labels).float().mean().item()
# 2. 计算查准率(Precision)、查全率(Recall)、F1分数 (以类别1为正例)
# 将预测转换为0/1
preds_binary = predicted_classes
# 计算混淆矩阵的四个分量
true_positives = ((preds_binary == 1) & (labels == 1)).sum().item()
false_positives = ((preds_binary == 1) & (labels == 0)).sum().item()
false_negatives = ((preds_binary == 0) & (labels == 1)).sum().item()
true_negatives = ((preds_binary == 0) & (labels == 0)).sum().item()
precision = true_positives / (true_positives + false_positives + 1e-8)
recall = true_positives / (true_positives + false_negatives + 1e-8)
f1_score = 2 * precision * recall / (precision + recall + 1e-8)
print(f"真实标签: {labels.tolist()}")
print(f"预测类别: {predicted_classes.tolist()}")
print(f"准确率 (Accuracy): {accuracy:.4f}")
print(f"以类别1为正例:")
print(f" 真正例 (TP): {true_positives}, 假正例 (FP): {false_positives}")
print(f" 假负例 (FN): {false_negatives}, 真负例 (TN): {true_negatives}")
print(f" 查准率 (Precision): {precision:.4f}")
print(f" 查全率 (Recall): {recall:.4f}")
print(f" F1分数: {f1_score:.4f}")真实标签: [0, 1, 1, 0]
预测类别: [0, 1, 0, 1]
准确率 (Accuracy): 0.5000
以类别1为正例:
真正例 (TP): 1, 假正例 (FP): 1
假负例 (FN): 1, 真负例 (TN): 1
查准率 (Precision): 0.5000
查全率 (Recall): 0.5000
F1分数: 0.5000多分类时把每个类别的 F1 算出来再取平均,就是宏平均 F1(Macro-F1)——它对小类别更公平,是类别不平衡时比准确率更可信的指标。
回归任务常用指标
对于回归任务(预测连续值),常用均方误差(MSE)、平均绝对误差(MAE)和决定系数(R²)来评估。
import torch
torch.manual_seed(0)
# 模拟回归任务的预测值和真实值
predictions = torch.tensor([1.2, 2.8, 3.1, 4.0], dtype=torch.float32)
targets = torch.tensor([1.0, 3.0, 3.0, 3.8], dtype=torch.float32)
# 1. 均方误差 (MSE)
mse = torch.nn.functional.mse_loss(predictions, targets).item()
# 2. 平均绝对误差 (MAE)
mae = torch.nn.functional.l1_loss(predictions, targets).item()
# 3. 决定系数 (R²)
# R² = 1 - SS_residual / SS_total
# SS_residual = sum((y_true - y_pred)^2)
# SS_total = sum((y_true - y_true.mean())^2)
ss_residual = torch.sum((targets - predictions) ** 2)
ss_total = torch.sum((targets - torch.mean(targets)) ** 2)
r_squared = 1 - ss_residual / ss_total
print(f"预测值: {[round(v,4) for v in predictions.tolist()]}")
print(f"真实值: {[round(v,4) for v in targets.tolist()]}")
print(f"均方误差 (MSE): {mse:.4f}")
print(f"平均绝对误差 (MAE): {mae:.4f}")
print(f"决定系数 (R²): {r_squared:.4f}")预测值: [1.2, 2.8, 3.1, 4.0]
真实值: [1.0, 3.0, 3.0, 3.8]
均方误差 (MSE): 0.0325
平均绝对误差 (MAE): 0.1750
决定系数 (R²): 0.9696重要原则:评测必须在模型训练过程中从未使用过的测试集上进行。验证集用于调整超参数和选择模型,测试集仅用于最终评估,且应只使用一次(或通过交叉验证)。
三个常见反模式
- 在测试集上调超参:测试集一旦参与任何决策,它就不再是泛化估计,报出的数字会系统性偏乐观。超参只在验证集上挑,测试集只在最后用一次。
- 只记录最后一个 epoch 的指标:训练后期常过拟合,验证集最优点往往在中间。应保存验证集最优的那个检查点,而不是最后一个。
- 只跑一个种子就下结论:随机初始化与数据打乱会带来 1–2 个点的波动。正式对比至少跑 3 个种子,报均值 ± 标准差。
实验复现:随机种子与完整快照
深度学习实验包含大量随机性:权重初始化、数据打乱(Shuffle)、Dropout 等。为了确保实验可复现,必须固定所有随机种子。
import torch
import numpy as np
import random
# 固定所有随机种子
def set_all_seeds(seed):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
# 如果有CUDA
if torch.cuda.is_available():
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
seed = 42
set_all_seeds(seed)
# 演示:固定种子后,两次运行的结果完全相同
print("第一次运行(种子=42):")
x1 = torch.randn(2, 3)
print(f"随机张量 x1 = {[[round(v,4) for v in row] for row in x1.tolist()]}")
# 重置种子并再次运行
set_all_seeds(seed)
print("\n第二次运行(重置种子=42):")
x2 = torch.randn(2, 3)
print(f"随机张量 x2 = {[[round(v,4) for v in row] for row in x2.tolist()]}")
print(f"x1 和 x2 是否完全相等? {torch.allclose(x1, x2)}")
# 保存完整的模型检查点(Checkpoint),包含模型、优化器状态、epoch等信息
model = torch.nn.Linear(10, 2)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
epoch = 5
train_loss = 0.1234
checkpoint = {
'epoch': epoch,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'train_loss': train_loss,
'config': {'input_dim': 10, 'output_dim': 2, 'lr': 0.001}, # 保存配置
'random_seed': seed, # 保存种子
}
# 保存检查点
torch.save(checkpoint, 'model_checkpoint.pth')
print(f"\n检查点已保存,包含 epoch={epoch}, loss={train_loss:.4f}")
# 加载检查点以复现训练状态
loaded_checkpoint = torch.load('model_checkpoint.pth', weights_only=False)
print(f"从检查点加载的 epoch: {loaded_checkpoint['epoch']}")
print(f"从检查点加载的随机种子: {loaded_checkpoint.get('random_seed', '未保存')}")第一次运行(种子=42):
随机张量 x1 = [[0.3367, 0.1288, 0.2345], [0.2303, -1.1229, -0.1863]]
第二次运行(重置种子=42):
随机张量 x2 = [[0.3367, 0.1288, 0.2345], [0.2303, -1.1229, -0.1863]]
x1 和 x2 是否完全相等? True
检查点已保存,包含 epoch=5, loss=0.1234
从检查点加载的 epoch: 5
从检查点加载的随机种子: 42核心:torch.save() 不仅可以保存模型权重(state_dict),还可以保存优化器状态、当前 epoch、损失值以及任何其他你想保存的元数据。这允许你从任意训练中断点精确恢复训练。
📝 动手练一练
假设你训练了一个文本分类模型,它在验证集 4 个样本上的输出是:pred_probs = [0.1, 0.8, 0.6, 0.3](对类别 1 的预测概率)、true_labels = [0, 1, 0, 1]。阈值取 0.5,请计算准确率以及负类(类别 0)的查准率与查全率。
👉 点击查看参考答案
import torch
torch.manual_seed(0)
pred_probs = torch.tensor([0.1, 0.8, 0.6, 0.3])
true_labels = torch.tensor([0, 1, 0, 1])
# 1. 将概率转换为类别预测 (阈值0.5)
pred_labels = (pred_probs > 0.5).int() # >0.5为1,否则为0
print(f"预测类别: {pred_labels.tolist()}")
print(f"真实类别: {true_labels.tolist()}")
# 2. 计算准确率
accuracy = (pred_labels == true_labels).float().mean().item()
print(f"准确率: {accuracy:.4f}")
# 3. 计算负类(类别0)的查准率和查全率
# 对于类别0,正例是标签为0的样本
pred_neg = (pred_labels == 0) # 预测为负类的样本
true_neg = (true_labels == 0) # 真实为负类的样本
true_negatives = (pred_neg & true_neg).sum().item() # TN
false_positives = (pred_neg & ~true_neg).sum().item() # FP (预测为0但真实是1)
false_negatives = (~pred_neg & true_neg).sum().item() # FN (预测为1但真实是0)
precision_neg = true_negatives / (true_negatives + false_positives + 1e-8)
recall_neg = true_negatives / (true_negatives + false_negatives + 1e-8)
print(f"\n对于负类(类别0):")
print(f" 真负例(TN): {true_negatives}, 假正例(FP): {false_positives}")
print(f" 假负例(FN): {false_negatives}")
print(f" 查准率(Precision): {precision_neg:.4f}")
print(f" 查全率(Recall): {recall_neg:.4f}")预测类别: [0, 1, 1, 0]
真实类别: [0, 1, 0, 1]
准确率: 0.5000
对于负类(类别0):
真负例(TN): 1, 假正例(FP): 1
假负例(FN): 1
查准率(Precision): 0.5000
查全率(Recall): 0.5000思路:首先根据阈值(通常为0.5)将预测概率转换为类别标签。准确率是所有样本中预测正确的比例。对于特定类别的查准率和查全率,需要将该类别视为“正例”,重新计算混淆矩阵的分量。本例中,负类(0)的查准率表示“所有预测为0的样本中,真正是0的比例”;查全率表示“所有真实为0的样本中,被正确预测为0的比例”。
常见错误
| 错误理解或写法 | 现象或报错 | 原因 | 正确做法 |
|---|---|---|---|
| 在测试集上多次评测并据此调整模型 | 报告的测试集性能虚高,模型实际泛化能力差。 | 破坏了测试集的“仅用一次”原则,导致信息泄露。 | 严格区分验证集和测试集。验证集用于调参和模型选择,测试集仅在最终评估时使用一次。 |
只保存模型权重 (model.state_dict()),不保存超参数和随机种子 | 无法复现训练结果,加载模型后性能不一致。 | 缺少重建训练环境所需的完整信息。 | 保存完整的检查点,包括模型权重、优化器状态、epoch、超参数配置和随机种子。 |
使用 torch.save(model, 'model.pth') 保存整个模型对象 | 加载时 (torch.load) 可能因类定义变化而失败,且文件较大。 | 保存了模型类定义和Python路径等依赖,耦合性高。 | 保存 model.state_dict(),加载时先实例化模型结构,再加载权重。这样模型定义可以独立修改。 |
| 计算指标时未处理除零错误 | 当某个类别没有预测样本或没有真实样本时,Precision/Recall 计算出现 nan。 | 分母为零。 | 在分母上加一个极小值(如 1e-8)进行平滑,或在实际计算前判断分母是否为零。 |
本章小结
- 系统化记录:一个可复现的实验需要完整记录配置、代码版本、训练指标和模型快照,推荐使用结构化的文件格式(如JSON)保存。
- 严格评测:必须在独立的测试集上评估模型最终性能。根据任务类型选择合适的指标(如分类用Accuracy/Precision/Recall/F1,回归用MSE/MAE/R²)。
- 确保复现:固定所有相关的随机种子(Python、NumPy、PyTorch)是实验可复现的关键。保存检查点时应包含随机种子。
- 检查点内容:完整的模型检查点应包含模型权重、优化器状态、当前训练轮次、损失值以及实验配置。
- 避免数据泄露:切勿使用测试集进行任何形式的模型调整或超参数选择,否则会严重高估模型性能。
📋 行动清单
- 为你当前或下一个项目创建一个实验配置字典,并编写代码将其与训练指标一起保存为JSON文件。
- 修改你的训练脚本,在开头固定所有随机种子(包括
torch.manual_seed、np.random.seed等)。 - 实现一个函数,在模型训练完成后,在测试集上计算并打印出准确率、查准率、查全率和F1分数(针对分类任务)。
延伸阅读
本段 PyTorch 官方文档/教程条目的框架出处:PyTorch: An Imperative Style, High-Performance Deep Learning Library(Paszke et al., 2019,arXiv:1912.01703);页面内容以 PyTorch 2.14.1 为准。
- PyTorch 官方文档 torch.save:https://pytorch.org/docs/stable/generated/torch.save.html —— 检查点保存/加载的标准写法。
- Scikit-learn: Machine Learning in Python(Pedregosa et al., 2011,arXiv:1201.0490)—— 分类指标与 R² 的权威定义与实现。
—— 小象教研组