在上一篇文章中,我们用 NumPy 搭建神经网络,再用 Keras 实现同一个模型,在 MNIST 上达到了约 97% 的验证准确率。模型有一个含 128 个神经元的隐藏层,学习率为 0.1,批大小为 32。

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

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

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

import keras
import numpy as np

# 训练集:60000 张图像,测试集:10000 张图像
(train_images, train_labels), (test_images, test_labels) = keras.datasets.mnist.load_data()

# 把 28×28 展平为 784,并归一化到 [0, 1]
X_train = train_images.reshape(-1, 784).astype("float32") / 255.0
X_test = test_images.reshape(-1, 784).astype("float32") / 255.0

y_train = train_labels
y_test = test_labels

学习率

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

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

for lr in [0.001, 0.01, 0.1, 1.0, 10.0]:
    model = keras.Sequential([
        keras.layers.Dense(128, activation="relu", input_shape=(784,)),
        keras.layers.Dense(10, activation="softmax"),
    ])

    model.compile(
        optimizer=keras.optimizers.SGD(learning_rate=lr),
        loss="sparse_categorical_crossentropy",
        metrics=["accuracy"],
    )

    model.fit(X_train, y_train, epochs=10, batch_size=32,
              validation_split=0.2, verbose=2)

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

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 张图像放入一个批次。

for bs in [1, 32, 256, 60000]:
    model = keras.Sequential([
        keras.layers.Dense(128, activation="relu", input_shape=(784,)),
        keras.layers.Dense(10, activation="softmax"),
    ])

    model.compile(
        optimizer=keras.optimizers.SGD(learning_rate=0.1),
        loss="sparse_categorical_crossentropy",
        metrics=["accuracy"],
    )

    model.fit(X_train, y_train, epochs=10, batch_size=bs,
              validation_split=0.2, verbose=2)

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

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 轮):

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 = keras.Sequential()
    model.add(keras.layers.Dense(hidden_sizes[0], activation="relu", input_shape=(784,)))
    for size in hidden_sizes[1:]:
        model.add(keras.layers.Dense(size, activation="relu"))
    model.add(keras.layers.Dense(10, activation="softmax"))

    model.compile(
        optimizer=keras.optimizers.SGD(learning_rate=0.1),
        loss="sparse_categorical_crossentropy",
        metrics=["accuracy"],
    )

    model.fit(X_train, y_train, epochs=10, batch_size=32,
              validation_split=0.2, verbose=2)

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

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。这些因子会影响梯度在深层网络中的传播。

在 Keras 里,切换激活函数不过是改一个字符串——"relu" 对 "sigmoid"。我们训练 1 层、3 层和 5 层隐藏层的网络,看看深度如何与激活函数的选择相互作用:

for n_layers in [1, 3, 5]:
    for activation in ["relu", "sigmoid"]:
        model = keras.Sequential()
        model.add(keras.layers.Dense(128, activation=activation, input_shape=(784,)))
        for _ in range(n_layers - 1):
            model.add(keras.layers.Dense(128, activation=activation))
        model.add(keras.layers.Dense(10, activation="softmax"))

        model.compile(
            optimizer=keras.optimizers.SGD(learning_rate=0.1),
            loss="sparse_categorical_crossentropy",
            metrics=["accuracy"],
        )

        model.fit(X_train, y_train, epochs=10, batch_size=32,
                  validation_split=0.2, verbose=2)

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

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 轮,看看会发生什么:

model = keras.Sequential([
    keras.layers.Dense(128, activation="relu", input_shape=(784,)),
    keras.layers.Dense(10, activation="softmax"),
])

model.compile(
    optimizer=keras.optimizers.SGD(learning_rate=0.1),
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)

history = model.fit(X_train, y_train, epochs=50, batch_size=32,
                    validation_split=0.2, verbose=2)

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

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

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

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

一般来说,与其去猜正确的轮数,不如使用早停——这是一个 Keras 回调,它监控验证损失,并在其不再改善时自动停止训练:

early_stop = keras.callbacks.EarlyStopping(
    monitor="val_loss",
    patience=5,
    restore_best_weights=True,
)

model.fit(X_train, y_train, epochs=100, batch_size=32,
          validation_split=0.2,
          callbacks=[early_stop])

设置一个上限,例如 100 轮。patience=5 表示验证损失连续五轮没有改善后停止训练,restore_best_weights=True 则恢复验证损失最低那一轮的权重。监控指标、等待轮数和最大训练预算仍由你来选择。

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

自动化超参数搜索

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

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

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

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

Weights & Biases Sweeps 和 Optuna 提供网格、随机或基于模型的搜索工具,并记录每次运行、比较结果,而 Keras Tuner 提供同样的能力并直接集成进 Keras。当实验数量增多、手动记录变得困难时,这些工具可以帮助管理实验。