查看全课大纲(第 39 / 40 节)
  1. 1.从规则到学习:深度学习解决什么问题
  2. 2.表示学习与端到端训练
  3. 3.张量、计算图与自动微分
  4. 4.训练三要素:数据、模型、目标函数
  5. 5.从感知机到多层感知机
  6. 6.反向传播:手推一个两层网络
  7. 7.激活函数与初始化
  8. 8.优化器:SGD → Momentum → Adam
  9. 9.正则化:权重衰减、Dropout、BN/LN
  10. 10.卷积的直觉与参数共享
  11. 11.池化、感受野与下采样
  12. 12.经典网络演进:LeNet → AlexNet → VGG → ResNet
  13. 13.残差连接与归一化为什么有效
  14. 14.迁移学习与特征可视化
  15. 15.RNN 与 BPTT
  16. 16.LSTM/GRU:门控如何缓解梯度问题
  17. 17.Seq2Seq 与编码器-解码器
  18. 18.注意力前史:Bahdanau 加性注意力
  19. 19.Self-Attention:QKV 与缩放点积
  20. 20.多头注意力:并行的多种关系
  21. 21.位置编码:正弦与可学习
  22. 22.残差、LayerNorm 与前馈层
  23. 23.从零实现一个最小 Transformer block
  24. 24.训练稳定性、复杂度与常见误区
  25. 25.自监督与语言建模目标
  26. 26.BERT:掩码语言模型与双向编码
  27. 27.GPT:自回归生成与缩放定律
  28. 28.指令微调与对齐:SFT → RLHF/DPO
  29. 29.参数高效微调:LoRA 与适配器
  30. 30.自编码器与 VAE
  31. 31.GAN:对抗训练与模式崩塌
  32. 32.扩散模型:前向加噪与反向去噪
  33. 33.扩散模型的应用与采样加速
  34. 34.对比学习与 CLIP
  35. 35.ViT:把图像当序列
  36. 36.多模态架构:对齐、融合与生成
  37. 37.能力边界、幻觉与评测
  38. 38.PyTorch 2.x 训练循环与混合精度
  39. 39.实验管理、评测与复现
  40. 40.推理部署与成本:量化/蒸馏概览

实验管理、评测与复现

小象实战讲义 · 深度学习基础:从神经网络到 Transformer

模型跑通只是起点。当你要调超参、比模型、换随机种子重跑时,真正麻烦的是:配置记不全、指标对不上、结果复现不出来。本节讲三件事——把配置与结果结构化落盘、选对评测指标、用随机种子与完整快照保证可复现。本节是第 9 章《工程实践与收口》的第 2 节。先修:上一节的训练循环、训练/验证/测试集划分、JSON 与字典操作。后续:下一节讲推理部署与成本,量化与蒸馏。

💡 核心导读

  1. 实验管理:学习如何系统化地记录实验配置(超参数)、代码版本、训练指标和模型权重。
  2. 评测与指标:理解训练集、验证集和测试集的严格划分,掌握常见分类与回归任务的评测指标计算方法。
  3. 实验复现:通过固定随机种子、保存完整配置和模型,确保任何实验都能被精确复现。
  4. 工具与模式:了解实验管理的基本代码模式,虽然不依赖特定第三方库,但理解其设计思想。

🧭 前置知识

  • PyTorch 基本训练循环(定义模型、损失函数、优化器、迭代训练)。
  • 理解训练集、验证集、测试集的作用。
  • 熟悉 Python 字典、JSON 文件的基本操作。

实验管理:记录一切可复现的要素

一次完整的深度学习实验远不止训练代码。为了能够复现、分析和比较,我们需要系统化地记录以下要素:

  1. 实验配置:所有超参数(学习率、批大小、模型结构参数等)、数据集信息、优化器选择。
  2. 代码版本:训练脚本的 Git 提交哈希,确保代码可追溯。
  3. 训练过程:每个 epoch 的训练损失、验证损失、评估指标。
  4. 模型快照:训练过程中或训练结束后的模型权重。
  5. 环境信息: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)进行平滑,或在实际计算前判断分母是否为零。

本章小结

  1. 系统化记录:一个可复现的实验需要完整记录配置、代码版本、训练指标和模型快照,推荐使用结构化的文件格式(如JSON)保存。
  2. 严格评测:必须在独立的测试集上评估模型最终性能。根据任务类型选择合适的指标(如分类用Accuracy/Precision/Recall/F1,回归用MSE/MAE/R²)。
  3. 确保复现:固定所有相关的随机种子(Python、NumPy、PyTorch)是实验可复现的关键。保存检查点时应包含随机种子。
  4. 检查点内容:完整的模型检查点应包含模型权重、优化器状态、当前训练轮次、损失值以及实验配置。
  5. 避免数据泄露:切勿使用测试集进行任何形式的模型调整或超参数选择,否则会严重高估模型性能。

📋 行动清单

  • 为你当前或下一个项目创建一个实验配置字典,并编写代码将其与训练指标一起保存为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 为准。

  1. PyTorch 官方文档 torch.save:https://pytorch.org/docs/stable/generated/torch.save.html —— 检查点保存/加载的标准写法。
  2. Scikit-learn: Machine Learning in Python(Pedregosa et al., 2011,arXiv:1201.0490)—— 分类指标与 R² 的权威定义与实现。

—— 小象教研组