用 pip 安装 torch torchvision numpy marimo matplotlib。通过下方链接下载笔记本,再用 marimo edit filename.py 打开。示例把 logits 传给 nn.CrossEntropyLoss,在打乱数据前固定划分 80/20。每个配置都从种子 42 开始,重跑结果可能与保存的图表不同。

下文图表和数值观察保留自原始实验,包括 Keras 运行。它们是历史测量,并非更新后 PyTorch 代码的结果。请重新运行配套笔记本,测量 PyTorch 的结果。

在上一篇文章中,我们用 NumPy 和 PyTorch 搭建了网络。这里使用相同基线:一个含 128 个神经元的隐藏层、学习率 0.1、批大小 32。

这些选择称为超参数:它们由我们设定,而不是由模型学得。我们将每次改变一项,观察它对训练的影响。结果可以作为这个模型和数据集的调参起点,其他任务可能需要不同的设置。

下面每个实验都配有交互式图表。 你可以点击图例来显示或隐藏单条曲线, 在损失与准确率两种视图间切换,打开「show baseline」以纳入初始随机权重下的测量值(第 0 轮), 并展开「Computation log」一节查看原始训练输出。图表默认显示训练指标——我们的分析基于训练值——但你可以点击「val」把验证指标叠加上来做对比。

我们沿用上一篇文章中相同的数据集与模型配置:

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from torchvision.datasets import MNIST
import numpy as np

train_data = MNIST("./data", train=True, download=True)
test_data = MNIST("./data", train=False, download=True)
# NumPy arrays also work with the from-scratch implementation.
X_train = train_data.data.numpy().reshape(-1, 784).astype("float32") / 255.0
X_test = test_data.data.numpy().reshape(-1, 784).astype("float32") / 255.0
y_train = train_data.targets.numpy()
y_test = test_data.targets.numpy()

def make_model(hidden_sizes=(128,), activation=nn.ReLU):
    torch.manual_seed(42)
    layers = []
    n_inputs = 784
    for size in hidden_sizes:
        layers.extend([nn.Linear(n_inputs, size), activation()])
        n_inputs = size
    layers.append(nn.Linear(n_inputs, 10))
    return nn.Sequential(*layers)

def run_epoch(model, X, y, batch_size=32, optimizer=None):
    training = optimizer is not None
    model.train(training)
    device = next(model.parameters()).device
    loader = DataLoader(
        TensorDataset(torch.as_tensor(X, dtype=torch.float32),
                      torch.as_tensor(y, dtype=torch.long)),
        batch_size=batch_size, shuffle=training,
    )
    loss_fn = nn.CrossEntropyLoss()
    total_loss, correct = 0.0, 0
    with torch.enable_grad() if training else torch.no_grad():
        for xb, yb in loader:
            xb, yb = xb.to(device), yb.to(device)
            if training:
                optimizer.zero_grad()
            logits = model(xb)
            loss = loss_fn(logits, yb)
            if training:
                loss.backward()
                optimizer.step()
            total_loss += loss.item() * len(yb)
            correct += (logits.argmax(dim=1) == yb).sum().item()
    return total_loss / len(y), correct / len(y)


def train_torch(model, X, y, epochs=5, lr=0.1, batch_size=32,
                val_fraction=0.2, optimizer=None, include_initial=False):
    split = int(len(X) * (1 - val_fraction))
    X_fit, y_fit = X[:split], y[:split]
    X_val, y_val = X[split:], y[split:]
    if optimizer is None:
        optimizer = torch.optim.SGD(model.parameters(), lr=lr)
    history = {key: [] for key in ("loss", "accuracy", "val_loss", "val_accuracy")}
    for epoch in range(0 if include_initial else 1, epochs + 1):
        loss, accuracy = run_epoch(
            model, X_fit, y_fit, batch_size, optimizer if epoch else None)
        val_loss, val_accuracy = run_epoch(model, X_val, y_val, batch_size)
        for key, value in zip(history, (loss, accuracy, val_loss, val_accuracy)):
            history[key].append(value)
        print(f"Epoch {epoch}/{epochs} — loss: {loss:.4f}, accuracy: {accuracy:.4f}, "
              f"val_loss: {val_loss:.4f}, val_accuracy: {val_accuracy:.4f}")
    return history

学习率

在讨论网络如何学习时, 我们看到学习率控制着梯度下降的步长。 太小则训练非常缓慢;太大则会越过最小值,使得难以收敛到较低的损失。 我们在一个两参数模型上用交互式小部件演示过这一点。 现在来看看在一个拥有 10 万以上参数的真实网络上,同样的现象是什么样子。

我们比较五种学习率,每种训练十轮:

下载 PyTorch 笔记本 (.py)
for lr in [0.001, 0.01, 0.1, 1.0, 10.0]:
    model = make_model()
    history = train_torch(model, X_train, y_train, epochs=10, lr=lr,
                          include_initial=True)

我们来看看每种学习率下损失随轮次的变化:

Loss by Learning Rate
Computation log

打开「show baseline」可以查看训练前的测量值。初始损失在 2.3 附近,随机初始化会带来一些差异。如果十个类别的概率都恰好为 0.1,损失就是 −log⁡(0.1)≈2.3-\log(0.1) \approx 2.3;但随机权重并不保证概率分布完全均匀。

在 lr = 10.0 时,这次训练失败了。第一轮损失升至 12.2,之后保持在 2.5 左右,训练准确率则接近 10%。这些指标表明模型没有学会有效区分数字,但仅凭它们不能断定模型对每张图像都给出了同一个预测。

点击 lr=10.0 的图例把它关掉——它那道冲到 12.2 的尖峰压扁了 y 轴,让其余曲线的细节难以分辨。隐藏这条曲线后,就更容易比较剩下四次训练的结果:

  • lr = 0.001——十轮后损失为 0.41,仍高于 lr=0.1 训练一轮后的 0.33。这个步长下学习较慢。
  • lr = 0.01——损失达到 0.18,且仍在下降。继续训练可能有帮助,但不一定得到与 lr=0.1 相同的解。
  • lr = 0.1——在这些运行中,最终训练损失最低,约为 0.03。初期的主要改善发生在前两三轮。
  • lr = 1.0——训练损失达到约 0.15,而最后几轮的验证损失在 0.20–0.24 附近波动。打开验证曲线,还能看到验证准确率在接近结束时下降。

学习率还与优化器有关。Adam 根据梯度及其平方的滑动估计来缩放各参数的更新,但仍然需要调节学习率。下一篇文章会讨论优化器、学习率调度和预热。

批大小

我们比较批大小 1、32、256 和 60,000。留出 20% 的数据用于验证后,实际训练集只有 48,000 张图像,所以 batch_size=60000 会把这 48,000 张图像放入一个批次。

下载 PyTorch 笔记本 (.py)
for bs in [1, 32, 256, 60000]:
    model = make_model()
    history = train_torch(model, X_train, y_train, epochs=10, batch_size=bs,
                          include_initial=True)

我们来看看每种批大小下损失随轮次的变化:

Loss by Batch Size
Computation log

处在中间地带的 bs=32 和 bs=256 都表现不错,其中 bs=32 收敛更快:

  • bs = 32——损失到第 10 轮从 2.4 急降至 0.03。每轮有 1500 个批次,模型获得频繁的更新,梯度虽有噪声但大体正确。
  • bs = 256——较慢但稳定。10 轮后损失降到 0.17,曲线仍在下行。每轮只有 188 个批次(bs=32 是 1500 个),因此更新次数更少——但每次更新基于更可靠的梯度平均。

最小和最大的批次表现有所不同:

  • bs = 1——在 lr=0.1 下训练不稳定。每次梯度只来自一张图像,较大的更新可能抵消之前的进展。对更多图像取平均可以减小这种波动。
  • bs = 60000——十轮后损失从约 2.4 降到 1.6。每轮只有一次更新,十轮共十个梯度步,而 bs=32 有 15,000 步。在这个学习率下,使用完整训练集的梯度无法弥补更新次数过少的问题。

批大小 32 在这个实验中表现良好。更大的批次需要更多内存来保存激活值,也可能提高硬件吞吐量,但一轮更快不一定意味着更快达到相同的验证准确率。调整批大小时,要同时比较训练时间和验证质量。

批大小和学习率应一起调整。在 bs=1 的运行中,把 lr 从 0.1 降到 0.001 后,损失降至 0.09。更大的批次可能适合更大的学习率,但这不是通用的缩放规则。

网络的深度与宽度

我们的基线网络有一个 128 神经元的隐藏层。如果把它加宽、加深,或者两者兼施,会怎样?来试试看——我们训练五个变体,其余一切保持不变(lr=0.1,bs=32,10 轮):

下载 PyTorch 笔记本 (.py)
configs = {
    "narrow (32)":  [32],
    "baseline (128)": [128],
    "wide (512)":   [512],
    "deep (2×128)": [128, 128],
    "deep (3×128)": [128, 128, 128],
}

for name, hidden_sizes in configs.items():
    model = make_model(hidden_sizes)
    history = train_torch(model, X_train, y_train, epochs=10,
                          include_initial=True)

我们来看看每种架构下损失的变化:

Loss by Architecture
Computation log

有几点很显眼:

  • 加宽有用:这里的「宽度」指单个隐藏层中的神经元数量——从 32 到 128 再到 512,损失稳步下降。更宽的层有更多参数去捕捉模式。但 512 的参数量是 128 的 4 倍,损失却只改善了一点点——边际收益递减。
  • 加深也有用:加上第二个隐藏层(2×128)达到的损失低于单层基线,尽管总参数量相近。更深的网络能学到层次化的特征——第一层也许检测边缘,第二层把边缘组合成形状。
  • 第三层没有改善本次训练的结果:最终损失接近基线,验证损失的波动更大。仅凭这些曲线无法确定原因,也不能证明出现了梯度消失。

对这个 MNIST 任务,一到两个隐藏层、每层 128–512 个神经元可以作为起点。增加模型容量前,先比较验证表现。层的类型也很重要:卷积层可以利用图像的空间结构,而我们的全连接模型没有利用这种结构。

激活函数:sigmoid 对比 ReLU

我们比较 sigmoid 和 ReLU,观察理论文章中讨论的梯度传播。sigmoid 在每层引入的导数因子最大为 0.25;ReLU 对活跃神经元的导数为 1,对不活跃神经元为 0。这些因子会影响梯度在深层网络中的传播。

在模型工厂函数中选择 nn.ReLU 或 nn.Sigmoid,比较具有 1、3、5 个隐藏层的网络:

下载 PyTorch 笔记本 (.py)
for n_layers in [1, 3, 5]:
    for activation in [nn.ReLU, nn.Sigmoid]:
        model = make_model([128] * n_layers, activation)
        history = train_torch(model, X_train, y_train, epochs=10,
                              include_initial=True)

我们来看看每种「深度 + 激活函数」组合下损失的变化:

Loss by Activation Function
Computation log

在这些运行中,网络越深,使用 sigmoid 训练就越困难:

  • 1 个隐藏层——两种激活都工作良好。ReLU 的损失到第 10 轮降至 0.03,sigmoid 降至 0.15。只有一层时,梯度只穿过一次激活,所以 sigmoid 对梯度的压缩影响不大。
  • 3 个隐藏层——sigmoid 开始掉队。ReLU 的损失达到 0.016,sigmoid 只到 0.14——高出近 10 倍。sigmoid 在头几轮明显更慢——它第 3 轮的损失仍高于 ReLU 第 1 轮之后的水平。
  • 5 个隐藏层——sigmoid 彻底失败。整整 10 轮里,损失几乎没从初始的约 2.3 挪动过——模型基本什么也没学到。与此同时,5 层的 ReLU 达到 0.024 的损失——与 1 层和 3 层几乎一模一样。

这些结果与梯度消失现象一致。经过五个 sigmoid 层,仅激活函数导数的乘积就至多为 0.255≈0.0010.25^5 \approx 0.001。完整梯度还受权重矩阵影响,因此这并不是到达第一层的梯度的精确估计。

ReLU 在活跃神经元的激活步骤中不会缩小梯度,这有助于解释这里的结果,但并不保证整个网络中的梯度始终稳定。

ReLU 适合作为这个模型的起点。Leaky ReLU 和 ELU 在负输入处允许非零梯度,可能有助于处理神经元持续不激活的问题。其他架构会使用不同的激活函数,例如部分 Transformer 使用 GELU。在标准 LSTM 中,sigmoid 控制门,tanh 用于候选值以及单元状态的输出变换。

训练轮数

上面所有实验我们都训练了 10 轮。但实际上应该训练多少轮?我们把最佳配置(lr=0.1,批大小 32)训练 50 轮,看看会发生什么:

下载 PyTorch 笔记本 (.py)
model = make_model()
history = train_torch(model, X_train, y_train, epochs=50, include_initial=True)

训练损失从第一轮的 0.33 降到第五十轮的 0.0015,报告的训练准确率在第二十九轮达到 100%。这说明模型很好地拟合了训练集,但仅凭这一点无法判断泛化能力。

验证损失从第 1 轮的 0.19 改善到第 13 轮附近的 0.075,随后停止改善并开始缓慢上升——第 20 轮 0.078,第 30 轮 0.081,第 50 轮 0.085。与此同时,验证准确率从第 13 轮起在 98% 附近进入平台,余下的 37 轮里几乎纹丝不动。

验证损失上升是过拟合的迹象。训练损失继续改善,但验证准确率几乎不变,验证损失反而变差。模型可能在未见数据上的剩余错误中变得更加自信。

轮数过少可能欠拟合,继续训练又可能逐渐出现过拟合。停止时间取决于模型、数据及其他超参数,因此训练时需要监控验证表现。

要实现早停,在训练循环中跟踪验证损失,保存最佳权重的副本,并在连续五轮没有改善后停止:

from copy import deepcopy

model = make_model()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
split = int(0.8 * len(X_train))
best_loss, stale_epochs = float("inf"), 0
best_weights = deepcopy(model.state_dict())
for epoch in range(100):
    run_epoch(model, X_train[:split], y_train[:split], optimizer=optimizer)
    val_loss, _ = run_epoch(model, X_train[split:], y_train[split:])
    if val_loss < best_loss:
        best_loss, stale_epochs = val_loss, 0
        best_weights = deepcopy(model.state_dict())
    else:
        stale_epochs += 1
        if stale_epochs >= 5:
            break
model.load_state_dict(best_weights)

最多训练 100 轮。deepcopy(model.state_dict()) 保存不受后续更新影响的最佳权重,训练结束后用 load_state_dict 恢复。这里的耐心值为五轮。

关于过拟合与早停,我们会在后续文章中更深入地探讨。

自动化超参数搜索

本文中我们是手工调超参数的——每次改一个并观察效果。这能培养直觉,但没法规模化。当你面对几十个超参数、成千上万种可能组合时,你需要一套搜索策略,以及一个把它自动化的工具。

搜索策略的区别,在于如何选择下一个配置,以及如何使用有限的评估预算:

  • 网格搜索(grid search):为每个超参数选择有限个候选值,评估所有组合。三个参数各取五个值,就需要运行 125 次。它适合搜索空间较小、单次评估成本较低的情况。
  • 随机搜索(random search):从指定范围或分布中随机抽取组合。如果只有少数参数对结果影响很大,在相同预算下,随机搜索可以比网格搜索尝试这些参数的更多不同取值。参见 Bergstra 与 Bengio,2012。
  • 坐标下降(coordinate descent):从一个配置开始,每次改变一个参数,保留能改善结果的修改。反复进行这个过程,可以在其他参数变化后重新评估先前的选择。它不需要遍历整个网格,但可能停在单独改变任何参数都无效的位置,即使同时改变多个参数本可改善结果。
  • 贝叶斯优化(Bayesian optimization):根据已有配置和结果拟合概率模型,再用它选择下一次评估,兼顾探索未知区域与利用已有的良好结果。它可以减少昂贵的评估次数,但效果取决于搜索空间和模型。

选择搜索策略时,需要考虑评估成本、搜索空间和可用预算。随机搜索可以作为起点;如果评估成本很高,可以考虑局部搜索或基于模型的方法。例如,AgentDiet 论文在 100 个智能体任务上调整四个超参数,每次只改变一个参数。第一轮之后,作者更新了两个设置;第二轮没有找到进一步的改进。

Weights & Biases Sweeps 和 Optuna 可围绕 PyTorch 训练循环组织超参数搜索,比较各配置的验证结果。

图片查看器

双指缩放或使用 + / −。拖动或使用方向键移动。按 Esc 关闭。