超参数对训练的影响
在上一篇文章中,我们在 MNIST 数据集上训练了一个神经网络——把理论文章里的每个概念(softmax、交叉熵、反向传播、梯度下降)拿来用于手写数字分类。我们用 NumPy 从零搭建了这个网络,又在 Keras 中复现了一遍,并以一个简单的两层架构和默认设置达到了 97% 的测试准确率:一个 128 神经元的隐藏层、学习率 0.1、批大小 32。
上面这些选择——学习率、批大小、层数、激活函数——统称为超参数:由你来选定,而不是模型学出来的设置。本文的目的,是展示这些选择的影响有多大(错误的学习率可以是 97% 准确率与彻底失败之间的分野),并为每一项给出通用的实践建议。 但这些建议只是起点,不是答案。合适的超参数要靠实验找出来——改一样、观察效果、再调整——而且取决于你具体的模型、数据集和任务。
下面每个实验都配有交互式图表。 你可以点击图例来显示或隐藏单条曲线, 在损失与准确率两种视图间切换,打开「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=5, batch_size=32,
validation_split=0.2, verbose=2)我们来看看每种学习率下损失随轮次的变化:
Computation log
打开「show baseline」后首先会注意到——五次运行都从同一个位置出发:一个损失约为 2.3 的随机网络。这说得通:面对 10 个类别、权重又是随机的,模型给每个类别分配的概率大致相等——每类约 0.1。正确类别概率为 0.1 时的交叉熵损失是 。这就是基线——一个什么都没学到的模型的损失。
接下来看 lr = 10.0——彻底失败。第一轮损失就飙到 12.2,权重更新越过头太远,导致权重爆炸,此后的轮次里一直卡在 2.5 上下。模型对任何输入都给出同一个预测——训练准确率约 10%(等同瞎猜),而且再无长进。当学习率大到梯度下降根本无法取得任何进展时,就是这个样子。
点击 lr=10.0 的图例把它关掉——它那道冲到 12.2 的尖峰压扁了 y 轴,让其余曲线的细节难以分辨。隐去它之后,剩下四条曲线讲的故事就清楚多了:
- lr = 0.001——损失确实在降,但慢得让人难受。10 轮之后仍停在 0.41,比 lr=0.1 只跑了 1 轮时(0.33)还高。梯度方向是对的,但每一步都太小,模型几乎挪不动。它需要多得多的轮次才追得上。
- lr = 0.01——稳步推进。损失从 2.3 平滑降到第 10 轮的 0.18,曲线还在往下走——模型显然仍在改善。给它更多轮次,它会达到与 lr=0.1 相同的损失。更小的步子并不限制你最终能到哪里,只是路上要花更久。
- lr = 0.1——对这个网络来说是最佳点。损失到第 10 轮急降至 0.03,曲线在接近最小值时趋于平缓。大部分学习发生在头 2–3 轮。
- lr = 1.0——损失起初下降很快,却安定不下来。它来回弹跳——0.20,然后 0.25,再 0.21,再 0.24——而不是平稳下降。切到准确率视图会看到,最后几轮的验证准确率反而在下滑。越过头并不妨碍学习,但妨碍模型微调到一个好的最小值。
实践中,调学习率远不止挑个数字那么简单——它与优化器的选择绑在一起,而如今很少还用手工调参的朴素 SGD。Adam 这类自适应优化器会按参数各自调整步长并维持动量,因此对初始学习率的选择远没那么敏感。SGD 的噪声如何帮助逃离局部极小、Adam 的自适应缩放如何工作,以及为什么学习率调度和预热成了现代训练的标配——连同交互式演示——我们会在下一篇文章中探讨。
批大小
在讨论神经网络如何学习时,我们在 5 个数据点上比较过大小为 2 的小批量与全批量梯度下降。小批量版本噪声更大(损失呈锯齿状),但用更少的总计算量就收敛了。现在来看批大小在真实数据集上对训练的影响——我们用 1、32、256 和 60000(一次性用上整个训练集)这四种批大小训练同一个模型:
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=5, batch_size=bs,
validation_split=0.2, verbose=2)我们来看看每种批大小下损失随轮次的变化:
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=32 时,这些个体差异会平均成一个合理的估计;在 bs=1 时,每次更新都被拽向一个随机方向,而 lr=0.1 又让每个这样的随机步子大到足以抹掉先前的进展。
- bs = 60000——全批量。损失下降慢得令人难受——10 轮后从 2.4 降到 1.6,远不如 bs=32 同期的 0.03。这似乎有违直觉——在全部图像上算出的完美梯度,难道不该优于来自 32 个样本的含噪估计吗?就方向而言,确实如此。但模型在损失地形上走过的总距离同样要紧:bs=32 在 10 轮里走了 15000 步,而 bs=60000 即便跑到 1000 轮也只走 1000 步。每轮只更新一次,10 轮就总共只有 10 个梯度步。完美的梯度弥补不了更新机会如此之少。
在我们的实验中,批大小 32 给出了最好的结果——这通常也是一个很强的默认值。它在梯度质量与更新频率之间取得平衡。 实践中,批大小还受 GPU 显存约束,因为更大的批需要更多显存来同时保存所有样本的激活值和梯度。如果你的 GPU 还有余量,可以试试 128 或 256——按轮计训练会更快(更新次数更少,但每次更新并行处理更多数据),不过你可能需要相应调大学习率作为补偿。批大小 1 很少使用,因为噪声太大,也无法利用 GPU 的并行能力。非常大的批(上千及以上)需要仔细调整学习率,主要用于跨多块 GPU 的分布式训练。
有一点要注意:批大小和学习率应当一起调。 我们的 lr=0.1 是为 bs=32 调好的——对 bs=1 来说太大(因此产生震荡),对 bs=60000 来说大概又太小(它其实需要更大的步子)。把 bs=1 换成 lr=0.001,它能达到 0.09 的损失——几乎追平 bs=32。只要每一步足够小,任何一个糟糕梯度都造不成多大破坏,噪声就无妨。更大的批需要更大的学习率,以补偿每轮更少的更新次数。脱离学习率去评价批大小是不可行的。
网络的深度与宽度
我们的基线网络有一个 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)我们来看看每种架构下损失的变化:
Computation log
有几点很显眼:
- 加宽有用:这里的「宽度」指单个隐藏层中的神经元数量——从 32 到 128 再到 512,损失稳步下降。更宽的层有更多参数去捕捉模式。但 512 的参数量是 128 的 4 倍,损失却只改善了一点点——边际收益递减。
- 加深也有用:加上第二个隐藏层(2×128)达到的损失低于单层基线,尽管总参数量相近。更深的网络能学到层次化的特征——第一层也许检测边缘,第二层把边缘组合成形状。
- 越深风险越大:到 3 层时,最终损失与基线持平,而验证损失明显更嘈杂。更深的网络更难训练——梯度要流经更多层,梯度消失问题开始显现。
一般来说,对全连接网络,从简单起步——一到两个隐藏层往往就够了。只有当更简单模型的损失进入平台期时,才去增加深度。至于宽度,对全连接网络而言每层 128–512 个神经元是个合理区间。 架构上真正的收益,来自为数据选用正确类型的层:图像用卷积层,序列用循环层或 Transformer。这些专用架构在参数效率上远胜我们这里用的全连接层——一个卷积网络能以我们 512 神经元模型的一小部分参数达到同样的损失。
激活函数:sigmoid 对比 ReLU
可选的激活函数有很多——ReLU、sigmoid、tanh、Leaky ReLU、GELU 等等。我们这里只聚焦其中两个,因为理论文章对它们做过一个具体的预测:sigmoid 在深层网络中应当力不从心,因为它的导数总是小于 1(梯度反向流经各层时被不断压缩),而 ReLU 不该有这个问题(它的导数是 0 或 1,梯度得以原样通过)。我们来检验这个预测。
在 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=5, batch_size=32,
validation_split=0.2, verbose=2)我们来看看每种「深度 + 激活函数」组合下损失的变化:
Computation log
结果印证了理论文章的预测——而且效果比预期还要戏剧化:
- 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 的导数始终小于 1(在 处最大为 0.25),所以经过 5 层相乘之后,抵达第一层的梯度只有原始信号的 倍。前面几层学不动,因为梯度小到无法推动权重。
ReLU 没有这个问题——它的导数不是 0 就是 1,所以梯度原样通过(对处于激活状态的神经元而言)。这正是三个 ReLU 网络不论深度如何、表现都几乎相同的原因。
如今,ReLU 是大多数前馈网络和卷积网络的默认激活函数。如果你遇到「ReLU 死亡」问题(神经元对所有输入都输出零,从此不再学习),可以试试 Leaky ReLU 或 ELU——这些变体在输入为负时允许一个很小的梯度。对 Transformer 架构,GELU 已成为标准。sigmoid 和 tanh 仍用在特定位置——sigmoid 用于二分类输出(例如二分类器的最后一层),tanh 用于 LSTM 中的门控机制——但不再作为通用的隐藏层激活函数。
训练轮数
上面所有实验我们都训练了 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)训练损失在全部 50 轮中持续下降——从第 1 轮的 0.33 一路降到第 50 轮的 0.0015。训练准确率在第 29 轮达到 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 意味着若验证损失连续 5 轮没有改善就停止训练,而 restore_best_weights=True 会把模型回滚到验证损失最好的那一轮。这样你根本不必去调轮数——数据会告诉你何时该停。
关于过拟合与早停,我们会在后续文章中更深入地探讨。
自动化超参数搜索
本文中我们是手工调超参数的——每次改一个并观察效果。这能培养直觉,但没法规模化。当你面对几十个超参数、成千上万种可能组合时,你需要一套搜索策略,以及一个把它自动化的工具。
值得了解这些工具所实现的那几种策略,因为它们权衡的都是同一条轴:你能负担得起评估多少种配置。 每种策略都是对「既然每次运行都要花时间和算力,下一次该花在哪里」这个问题的不同回答。
- 网格搜索。 为每个超参数挑几个值,把所有组合都跑一遍。简单而穷尽,但代价是各项取值个数的乘积——三个超参数各取五个值就已经是 125 次运行,再往上便会爆炸(正是这种组合爆炸让高维空间变得棘手)。当单次运行便宜、旋钮只有两三个时,它没问题。
- 随机搜索。 随机采样组合,而不是铺一张网格。有点反直觉的是:在相同预算下,它通常胜过网格搜索——通常真正要紧的只有少数几个超参数,而随机采样会在这些重要维度上尝试更多不同的取值,而不是把运行浪费在僵硬的网格上(标准参考文献是 Bergstra & Bengio, 2012)。
- 坐标下降,又称爬山法。 这正是我们整篇文章手工所做之事的自动化版本:从默认值出发,一次只改一个超参数,若结果变好就保留这次改动,如此反复直到无计可施。它很便宜——你从不评估整张网格——而且与「每个旋钮各调一次、彼此孤立」不同,它会绕回来,因而能发现最佳批大小取决于你最终定下的那个学习率。它的弱点是所有贪心局部搜索的通病:可能困在局部最优,即任何单项改动都没有帮助,尽管某种联合改动会有效。
- 贝叶斯优化。 用已有的运行结果建立「配置 → 得分」的概率模型,再用它挑出最有希望的下一个配置——在探索未知区域与利用已知优区之间取得平衡。它是样本效率最高的选项,因此当每次运行确实昂贵时,它是默认之选。
选择归根结底取决于单次评估的成本。 如果一次运行只要几秒,网格或随机搜索就够了——多试几个便是。如果每次运行都是一个完整的训练任务(甚至更糟),你就得换成那些「先思考再花钱」的方法:旋钮不多时用坐标下降,旋钮很多时用贝叶斯优化。一个训练之外的好例子:AgentDiet 论文调了四个超参数,而每次评估都意味着让一个 LLM 智能体跑完 100 个任务——对网格搜索来说代价过高——于是他们用了朴素的坐标下降(「改一个旋钮,保留改进,重复」),两轮就收敛了。
Weights & Biases Sweeps 和 Optuna 把这些都实现了——网格、随机与贝叶斯搜索——并自动记录每次运行、比较结果,而 Keras Tuner 提供同样的能力并直接集成进 Keras。当手工逐个调参走到尽头时,它们的价值恰恰就体现出来了。