在 MNIST 上训练一个真正的网络
在上一篇文章里,我们把训练神经网络背后的每个概念都过了一遍——前向传播、损失函数、反向传播、梯度下降、学习率、小批量、梯度消失。我们用一个只有 2 个参数、拟合直线的玩具例子来讲清机制:给定输入 ,预测 。那是回归:网络输出一个数字,它可以取连续刻度上的任意值——就像价格或温度——而我们用均方误差(我们为那个任务挑的损失函数)来衡量偏离了多少。
这里我们要做点别的——分类。网络不再输出一个数字,而是为每个类别输出一个概率并挑出最高的那个:看一眼手写数字,判断它是几。我们会用经典的 MNIST 数据集——70,000 张 0 到 9 的手写数字图像。
我们会先用 NumPy 从零把一切搭出来——手写前向传播、损失函数、反向传播和梯度下降——好让你看清训练过程的每一步。然后我们再用 Keras 展示同样的东西,这个高层框架用寥寥几行代码就自动完成了这一切。Keras 可以跑在 PyTorch、TensorFlow 或 JAX 之上——这些后端负责重活(GPU 加速、自动求梯度),而 Keras 在其上提供一个干净的接口。NumPy 对真正的训练来说太慢,但用来理解正在发生什么却再合适不过——而且这些代码与 Keras 在底下做的事一一对应。
数据集
MNIST 是一个包含 70,000 个手写数字(0–9)的集合——60,000 个用于训练,10,000 个用于测试。每张图像是 28×28 像素的灰度图。 每个数字大约有 6,000 张训练图像——合计 60,000 张用于训练、10,000 张用于测试。
这里是几个例子:
图像和标签以 NumPy 数组的形式存放。我们来看看原始数据。
# 60,000 images, each a matrix of 28×28 pixels
>>> train_images.shape
(60000, 28, 28)
# A single image: 28 rows × 28 columns of pixel values (0–255)
>>> train_images[0].shape
(28, 28)
>>> train_images[0].dtype
dtype('uint8')
# 60,000 labels: first image is "5", second is "0", ...
>>> train_labels
array([5, 0, 4, ..., 5, 6, 8], dtype=uint8)
# 10,000 test images, same 28×28 matrices
>>> test_images.shape
(10000, 28, 28)
# 10,000 labels for test images
>>> test_labels
array([7, 2, 1, ..., 4, 5, 6], dtype=uint8)28×28 网格里的每个值都是一个像素强度。由于每个像素占一个字节(8 位),它可以表示 个取值——0 到 255。在原始数据里,0 表示黑、255 表示白——所以 MNIST 的数字是黑底上的白色笔画。这并不是 MNIST 特有的——几乎所有数字图像都是这么存像素强度的。彩色图像每像素用 3 个字节(红、绿、蓝各一个),但 MNIST 是灰度图,一个字节就够。
这张 28×28 的数字网格就是图像——每个数字直接对应一个灰阶。点击下面任意一个像素并输入新值,看看效果。用那个开关可以在原始表示(黑底白字)和反色视图(白底黑字,更易读)之间切换:
注意,切换时放大网格里的像素值并不会变——它们始终显示原始数据(数组里真正存的东西)。开关只改变这些值如何映射成屏幕上的颜色。值为 255 的像素在数据里永远是 255;它只是在原始模式下渲染成白色、在反色模式下渲染成黑色。这里没有隐藏结构——图像就是这 784 个数字。改一个数字,就改了一个像素。
每张图像都带一个标签——它所代表的数字(0–9)。这两个数组合起来——图像和标签——就是网络将要学习的全部。任务很简单:给定一张 28×28 的图像,预测它是哪个数字。这是一个有 10 个类别的分类问题——我们要构建的网络叫作多分类器。在分类里,一个类别就是一个可能的标签,是模型可以选的一个类目。我们有 10 个类别(数字 0–9),每个数字大约 6,000 个训练样本(并非完全均匀——数字 1 有 6,742 个样本,而数字 5 只有 5,421 个)。
从回归到分类
从上一篇文章的回归任务()走到数字分类,我们需要:
- 输出层——不再是一个神经元输出一个数字,而是需要 10 个神经元(每个数字一个)来给出一个概率分布。这需要在输出层用一个新的激活函数:softmax。
- 损失函数——不再用告诉我们数字偏离多远的均方误差,而是需要交叉熵损失,它告诉我们模型对正确类别有多确信。
- 梯度——梯度公式随新的损失函数而改变。交叉熵 + softmax 的梯度结果出奇地干净。
- 隐藏层激活——层与层之间需要一个非线性激活函数,网络才能学到复杂模式。我们会用 ReLU,也就是上一篇文章神经元和层的例子里用过的那个激活函数。
核心算法——前向传播、反向传播、梯度下降、小批量——保持不变。我们逐一过一遍。
输出层与 softmax
在回归里,网络输出一个数字。在分类里,它需要从一组类别中做选择——我们这里是从 10 个数字里挑一个。网络得说出「我认为这是 2」,还要带上某种确信程度。
我们的做法是给输出层配 10 个神经元——每个数字一个。每个神经元产生一个分数,叫作 logit(这名字来自逻辑回归里的「log-odds」,但实践中它就是指「softmax 之前的原始分数」),表示网络有多强地相信输入就是那个数字。分数越高,信心越足。
有 10 个数字,我们的输出层就产生 10 个 logit——每类一个。 为了让例子和可视化更简单,下面的代码里我们只用 3 个类别(数字 0、1、2)——数学完全一样,只是要看的数少一些。
对某张输入图像,输出可能长这样:
图像进去,网络让它穿过各层,出来 3 个数字——每个数字一个。数字 2 拿到了最高分(4.2),所以那就是网络的预测。用代码写:
logits = [1.5, -0.3, 4.2]
# 0 1 2
# The network's guess: digit 2 (highest logit = 4.2)
# argmax returns the index of the largest value, not the value itself
prediction = np.argmax(logits) # 2但原始 logit 并不是概率——它们可以是任意数、正的负的都行,而且加起来不等于 1。要把它们变成一个正经的概率分布,我们用 softmax 函数——深度学习里最常见的运算之一,从图像分类器到 ChatGPT 这类 LLM 里到处都是(在 LLM 里它把词表上的原始分数转换成下一个 token 的概率):
为什么叫「softmax」?因为它是 max 的柔化版本。硬性的 max 只会挑出最大值、无视其余一切——[0, 0, 1]。当某个 logit 一枝独秀时,softmax 做的几乎是同一件事;但当各 logit 大小相近时,其他值也会拿到可观的权重。对比一下:
| logits | 硬 max | softmax |
|---|---|---|
| [1.0, 8.5, 1.0] | [0, 1, 0] | [0.1%, 99.8%, 0.1%] |
| [3.0, 3.5, 3.0] | [0, 1, 0] | [24.4%, 51.2%, 24.4%] |
| [3.0, 3.0, 3.0] | 未定义(平手) | [33.3%, 33.3%, 33.3%] |
硬 max 给出二值答案——赢家通吃。softmax 给出一个平滑分布,随着输入变化而连续改变。当模型不确定时(logit 彼此接近),softmax 会反映出这种不确定。当模型确信时(某个 logit 大得多),softmax 逼近硬 max。这种平滑的行为也正是反向传播得以工作的原因——你能穿过 softmax 计算梯度,因为它处处可导。
硬 max 没有可用的梯度(输出在跳变之间是常数),而 softmax 永远不会输出恰好的 0 或 1——因为对任意 都有 ,每个类别总能分到一点概率——所以梯度总是有定义、总是非零。你可以在下面的组件里看到这一点——试着把前两个 logit 设为 0,把第三个设成一个很大的数。即使某一类占尽优势,其他类也绝不会真正掉到 0%。
softmax 分三步走(用我们的示例 logits [1.5, -0.3, 4.2]):
- 把 提升到每个 logit 的幂次——因为对任意 都有 ,这会把每个值(哪怕是负的)都变成正数: 、、
- 把所有正值加起来——
- 把每个值除以这个和——、,依此类推
这就把这串数归一化到恰好加起来等于 1——一个正经的概率分布。
除了可导,softmax 还有另一个重要性质:指数函数的放大效应。这正是 softmax 用 而不是直接把原始 logit 除以它们之和的原因。原本的 logit(1.5、-0.3、4.2)之间只差几个点。但指数把加法差异变成了乘法差异:,而 、。2.7 的 logit 差距(4.2 与 1.5 之间)在指数化之后变成了 15 倍的差距。除以总和之后,数字 2 拿到了 92.7% 的概率——指数把「高出一点」变成了「几乎可以肯定」。这正是我们想要分类器做的:当某一类明显高过其他类时,给出一个自信的预测。
你可以用温度参数 来控制这种放大效应。公式变成:
在取指数之前先除以 ,会缩放 logit 之间的差距。试着拖动 T 滑块:
- T → 0(低温)——差距被放大,分布变得尖锐。最高的 logit 拿到接近 100%。这逼近硬 max。
- T = 1——标准 softmax。除以 1 什么都不改变,所以等同于根本没有 。
- T → ∞(高温)——差距被压平,所有 logit 变得相近,分布趋于均匀(各 33.3%)。
如果你用过 ChatGPT 或别的 LLM,你见过这个参数——就是同一个「temperature」滑块。LLM 用 softmax 把输出 logit 变成下一个词上的概率分布。低温让模型几乎每次都挑最可能的词(确定、重复)。高温把概率摊到很多词上(有创意,有时也胡言乱语)。温度参数控制的就是这个取舍。
我们会这样用 Python 实现 softmax:
def softmax(logits):
# Subtract max for numerical stability (doesn't change the result,
# but prevents overflow when computing e^z for large values)
exp = np.exp(logits - np.max(logits))
return exp / np.sum(exp)
probs = softmax(logits)
# [0.063, 0.010, 0.927]
# 0 1 2
# The highest probability is 92.7% for digit 2注意我们还减去了 max。 这是必须的,因为 softmax 要算 ,而指数函数增长极快。如果某个 logit 是 1000,那么 在浮点数里会溢出成无穷大:
>>> import numpy as np
>>> np.exp(1000)
inf # overflow — can't compute
>>> np.exp(1000) / (np.exp(1) + np.exp(2) + np.exp(1000))
nan # infinity / infinity = undefined在取指数之前把最大的 logit 从所有值里减掉,会把最大值挪到 0,其余全都变成负的。相对顺序被保住了—— 依然最大,因为 永远小于 1——所以算出来的概率是一样的:
>>> logits = [1, 2, 1000]
>>> max(logits)
1000
>>> [z - max(logits) for z in logits]
[-999, -998, 0] # largest becomes 0, others become negative
>>> np.exp([-999, -998, 0])
array([0., 0., 1.]) # e⁰ = 1 is the largest (e^negative is always < 1), no overflow从数学上可以看出,减与不减得到的概率完全相同:
被约掉了,而我们避免了计算任何危险的大指数。这是每个 softmax 实现里都能见到的标准数值稳定性技巧。
ReLU:隐藏层的激活函数
我们已经讲过一个激活函数——输出层上的 softmax,它把 logit 变成概率。但我们在隐藏层上也需要一个激活函数。上一篇文章训练出来的模型()是单个线性运算——没有激活函数,也没有隐藏层。
那篇文章还解释了为什么不加激活函数地堆层毫无意义:一连串线性运算会坍缩成一个线性运算。要学到复杂模式而不只是直线,层与层之间就需要非线性。 上一篇文章把激活函数作为解法引入,并在神经元组件里展示了几个选项(sigmoid、感知机、ReLU)。
我们会用 ReLU(Rectified Linear Unit),现代网络中最常见的选择:
它把正值原样放行,把负值截成零。拖动下面的滑块——当加权和为正时(绿色),值直接通过。当它为负时(红色),输出被截到 0:
同时调两个滑块就能看出这种行为——起作用的是加权和 w × x,而不是单个值。试着把输入和权重都设成负数:乘积是正的,所以 ReLU 会放行。只有当乘积本身为负时,它才截成零。
ReLU 对通过反向传播学习也非常友好。当我们计算损失的梯度时,激活函数的导数会直接出现在链式法则的乘积里。在上一篇文章里,隐藏层某个权重的梯度长这样:
dw11 = x1 * relu_deriv(z1) * v1 * 2 * error
# ↑ activation derivative — one factor in the chain损失梯度是沿路径上各局部导数的乘积。relu_deriv 是其中一个因子——所以当它为 1 时,梯度原样通过。当它为 0 时,它会把整个乘积归零,这个神经元就学不到那个输入的东西——这就是 ReLU 死亡问题。但实践中它远没有 sigmoid 的毛病那么伤:用 ReLU 时只有部分神经元会死掉,其余的仍以全强度传递梯度。用 sigmoid 时,每个神经元都会削减梯度并停止学习——这叫饱和,而且效应会跨层累积。这个问题我们会在文章后面讲归一化时详细看到。
就梯度而言,为什么 sigmoid 比 ReLU 差?
sigmoid 的导数是 。既然 sigmoid 输出的是 0 到 1 之间的值 ,那这就是 ——它在 时(即 处)取到最大:。当 朝任一方向远离 0 时,sigmoid 就朝 0 或 1 饱和,导数缩向 0。
所以 sigmoid 的导数永远介于 0 和 0.25 之间——意味着它在每一层都会削减梯度。仅仅 5 层之后:。梯度缩小了 1000 倍,前面的层几乎学不到东西。ReLU 完全避开了这一点——对活跃的神经元它的导数是 1,所以梯度以全强度通过。
于是我们的网络有两个不同的激活函数,各司其职:隐藏层里的 ReLU(引入非线性,好让网络学到复杂模式)和输出层上的 softmax(把原始分数转换成概率)。
损失函数
现在我们需要一个损失函数。上一篇文章里我们用的是均方误差——预测与目标之差的平方,在所有数据点上取平均。那对回归来说说得通:预测是一个数、目标是一个数,我们希望它们靠近。
对分类,我们用交叉熵损失。 想法很简单:取模型分配给正确类别的那个概率的负对数。
概率越低,损失越高:
- 模型说有 95% 的把握是对的:——损失低,预测好
- 模型说 60%:——损失中等,信心不够
- 模型说 1%:——损失很高,几乎完全错了
为什么用对数?因为对数恰好具备我们对损失函数所需的性质:
- 因为 ——如果模型把 100% 都给了正确类别,损失就是零。完美预测,没什么可罚的。
- 因为当 时 ——惩罚没有上限。模型对错误答案越确信,损失就越高,没有尽头。这形成了一个极强的信号,去修正那些自信却错误的预测。
- 曲线在接近 0 处很陡、接近 1 处很平——因为导数()在 小时很大、在 接近 1 时很小,梯度会在模型出错时自动做大幅修正,而在它已经接近时轻轻一推。
我们把最后一点展开讲。下面两张图并排展示 和 。看那条绿色的 ——那才是我们用作损失函数的:
- 当 接近 0(模型错了)时,曲线陡然冲高——概率上一点小变化就带来损失上的大变化。
- 当 接近 1(模型对了)时,曲线变平——损失几乎不动。
再对比蓝色的 ——它是镜像:接近 1 处陡、接近 0 处平,对损失函数来说这是错误的行为。所以我们把它取反——负号把曲线翻过来,让梯度恰恰在我们最需要的地方最强。
负号还有一个受欢迎的副作用:我们知道 0 到 1 之间数字的 总是负的,比如 ,那样损失也会是负的,而损失本应为正。负号把它翻成正值:。
这种陡峭直接影响反向传播。 的导数是 。当模型错得离谱()时,梯度是 ——一个强烈的自我修正信号。当它已经接近()时,梯度只有 ——轻轻一推。
拖动下面的滑块,看看调整概率时损失如何变化——注意那个黄点沿 曲线的移动:
试着把数字 2(真实类别)的滑块往右拖——随着它的概率逼近 100%,损失掉到几乎为零。 往左拖——随着概率下降,损失陡然冲高。这种陡峭意味着梯度也很大——所以模型在自信地犯错时会收到强烈的修正信号,而在已经接近正确答案时只被轻轻一推。
在 Python 里损失函数长这样。它接收模型预测的概率和真实标签(正确类别的下标——0、1 或 2),返回分配给该类别的那个概率的 :
def cross_entropy_loss(probs, label):
# label is the index of the correct class
p_correct = probs[label] # e.g. probs[2] = 0.94
return -np.log(p_correct) # -log(0.94) = 0.062
# Model is confident and correct → low loss
cross_entropy_loss([0.05, 0.01, 0.94], label=2) # 0.062
# Model is uncertain → moderate loss
cross_entropy_loss([0.30, 0.30, 0.40], label=2) # 0.916
# Model is confident but wrong → high loss
cross_entropy_loss([0.80, 0.15, 0.05], label=2) # 2.996损失 vs 准确率
训练过程中我们同时追踪损失和准确率——它们相关,但不是一回事。
准确率衡量的是预测正确的比例。 它是二值的,不在乎信心——每个预测非对即错,没有部分分。 对正确类别给出 0.51 的预测算作正确, 给出 0.99 也一样——两者给准确率计数带来同样的 +1。 但损失差别很大:前者是 0.67,后者是 0.01。
损失(交叉熵)衡量的是模型在正确答案上放了多少概率——放的概率越少,惩罚越高。它是一个连续值,刻画的是预测的质量,而不仅仅是它对不对。 损失是连续的——它在乎你在正确类别上放了多少概率, 而不只是那个概率是不是最高。两个模型可以有相同的准确率, 损失却大不相同——一个勉强对,另一个自信地对。
在真实的训练日志里,长这样:
Epoch 1/10 — accuracy: 0.5117 — loss: 1.7934 — val_accuracy: 0.7520 — val_loss: 1.3155
Epoch 2/10 — accuracy: 0.7806 — loss: 1.0726 — val_accuracy: 0.8377 — val_loss: 0.8382
Epoch 5/10 — accuracy: 0.8627 — loss: 0.5560 — val_accuracy: 0.8798 — val_loss: 0.4901在第 1 个 epoch,accuracy: 0.5117 表示模型在 51% 的训练图像上预测出了正确的数字——对每张图像,把概率最高的那个类别与真实标签比较,然后统计正确预测的比例。这已经远高于在 10 个类别里随机猜测的 10% 基线,但仍有一半图像是错的。
1.79 的损失说的更多——它意味着模型平均给正确类别分配的概率很低。 这可能有两种情形:模型也许预测对了类别,但信心很低(比如 0.3 而不是 0.9);或者更糟,它自信地错了,把大部分概率押在了错误的类别上。 高损失通常由第二种情形主导:少数几个自信的错误对损失的贡献,远大于许多低信心的正确预测。
准确率和损失的这种差别,意味着它们并不总是同向变化:
- 损失下降,准确率不动——模型对那些本来就预测正确的样本变得更有信心了。比如模型已经把一张 7 的图像预测成「7」,但它的信心从 0.4 涨到 0.9,准确率不变(本来就对),损失却明显下降。
- 准确率上升,损失也上升——模型答对了更多,却在答错的那些上变得过度自信。想象模型修好了 5 张原本判错的图像(准确率变好),但同时在 2 张判错的图像上变得非常自信(0.95)。这 2 个自信错误带来的损失,可能盖过那 5 个新增正确带来的改善。
- 两者都不动——模型卡住了。权重在变,但预测并没有实质性不同。这常发生在学习率过小、或模型已经到达其容量上限时。
实践中,训练健康时两个指标通常一起改善——上面那段日志就是典型例子。当它们背道而驰时,就是有什么有趣(或麻烦)事情正在发生的信号。
梯度
上一篇文章里我们看到,训练需要计算损失对每个参数的梯度——那个告诉我们该往哪个方向推动每个权重以减小误差的导数。对拟合直线的例子,我们展示了我们是怎么得到 MSE 梯度公式的。 现在我们需要交叉熵损失对 logit(softmax 之前的原始分数)的梯度。
下面是输出层里单个神经元的样子——logit 是施加激活函数之前的加权和:
注意 softmax 本身没有可学习的参数——它只是一个把 logit 变换成概率的函数。可学习的权重在那个计算加权和并产生 logit 的神经元里。一旦我们拿到了对 logit 的梯度,它就会继续往后流经这些权重、进入隐藏层,一路更新一切。
我们在上一篇文章中得到的 MSE 公式结构清晰:dw = 2 * mean(error * x)——误差乘输入,再在样本上取平均。
每一部分都来自把链式法则用到损失函数上。 现在我们要为交叉熵配 softmax 做同样的事。我们分三部分来推:
- 穿过 softmax 的梯度(输出层)——推导交叉熵 + softmax 对 logit 的梯度,得到干净的 公式
- 穿过 ReLU 的梯度(隐藏层)——展示梯度如何流经隐藏层的激活函数
- 把它们串起来——把两部分接成完整的反向传播
完整的前向链条是:输入 → 隐藏层(权重 + ReLU)→ logit → softmax → 概率 → 交叉熵 → 损失。 反向传播反着走——我们从损失出发,用链式法则一路回穿交叉熵、softmax,再到隐藏层。
下面的数学比上一篇文章里的 MSE 梯度更繁一些——要用这个结果,并不需要跟住每一步。如果你愿意,可以直接跳到最终公式和代码。但如果你想看看它从哪来,这里是一步步的推导。
穿过 softmax 的梯度(输出层)
第 1 步:损失的导数。 损失是 ,其中 是正确类别。 的导数我们已经知道:是 。所以:
第 2 步:softmax 的导数。 我们现在有了损失对 的导数。但我们需要的是对 logit 的导数——因为那才是这一层的权重真正产出的东西(如上面的图所示)。要接上这道缝,我们得知道:softmax 如何把 的变化转成 的变化?
softmax 是 。用商的求导法则,有两种情况:
若 (推动某个 logit 自己的那个概率):
这看起来像 sigmoid 的导数——确实就是。每个 softmax 输出在局部都像一个 sigmoid。
若 (推动另一个 logit):
分子的第一项是 0,因为 不依赖 。增大一个 logit 总会压低其他概率——它们必须加起来等于 1。
第 3 步:链式法则。 把两个导数乘起来:
- 对正确类别():
- 对错误类别():
结果
绕了这一大圈微积分之后,各项漂亮地约掉了。交叉熵损失配 softmax 之后,对每个输出神经元 的梯度是:
实践中怎么算?用 ,即 one-hot 向量——除了正确类别处是 1,其余全是 0。既然正确类别处 、其他地方 ,那么用 减去 就给出正确类别的 和其余的 ——正是上面那个分段公式:
在 Python 里就一行:
grad_z = probs - one_hot_label # gradient w.r.t. logits就是这样——对 logit 的梯度不过是「模型预测的」与「答案是什么」之间的差。概率减去目标。
我们用 3 个类别的具体例子看看这意味着什么。如果模型输出的概率是 [0.06, 0.01, 0.93],而正确类别是 2:
- 目标(one-hot)是
[0, 0, 1]——全部概率都该在类别 2 上 - 梯度是
[0.06 − 0, 0.01 − 0, 0.93 − 1]=[0.06, 0.01, −0.07]
符号告诉你方向:类别 2 拿到一个负梯度(−0.07),意思是「把这个 logit 往上推,提高它的概率」。类别 0 和 1 拿到正梯度,意思是「把这些 logit 往下压」。大小告诉你推多少——类别 0(0.06)需要的修正比类别 1(0.01)更大,因为漏到它那里的概率更多。
注意 是对 logit 的梯度,不是对权重的。记住,输出神经元算的是 (logit),然后 softmax 把它转成概率。所以从损失到权重的完整链条有两部分:
第一部分()就是我们刚推出来的——损失如何随 logit 变化。第二部分()是 对 的导数,也就是输入本身。两者相乘就给出权重梯度:
grad_z = probs - one_hot_label # part 1: loss → logits (p - y)
grad_W = np.outer(grad_z, x) # part 1 × part 2: logits → weights
grad_b = grad_z # bias gradient (same as grad_z)
grad_input = W.T @ grad_z # gradient to pass to the previous layergrad_input = W.T @ grad_z 是传给前一层的梯度信号。那一层接着做同样的事——只不过用的是 ReLU 而不是 softmax。
穿过 ReLU 的梯度(隐藏层)
隐藏层算的是 ,然后施加 ReLU:。我们在上一篇文章里推导过——当梯度从下一层传来(grad_output)时,链式法则把它乘上 ReLU 的导数:
如果这个神经元是活跃的(),梯度原样通过。如果它不活跃(),梯度被归零。然后,跟输出层一样,我们继续这条链,得到权重的梯度:
grad_z = grad_output * (z > 0) # multiply by ReLU derivative (0 or 1)
grad_W = np.outer(grad_z, x) # chain rule: gradient for weights
grad_b = grad_z # gradient for biases
grad_input = W.T @ grad_z # pass to the previous layer把它们串起来
完整的反向传播从损失出发,链式地穿过每一层:
- 输出层: → 计算
grad_W、grad_b,把grad_input往回传 - 隐藏层:接收
grad_input,乘上 ReLU 的导数 → 计算grad_W、grad_b,把grad_input往回传 - 对任何额外的隐藏层重复这一过程
每一层的套路都一样——唯一的差别是激活函数的导数(softmax 还是 ReLU)。下面是我们这个网络(一个隐藏层 + 输出层)的完整反向传播:
# Forward pass (for reference)
h = relu(W1 @ x + b1) # hidden layer: inputs → ReLU
z = W2 @ h + b2 # output layer: hidden → logits
p = softmax(z) # softmax: logits → probabilities
loss = -np.log(p[label]) # cross-entropy loss
# Backward pass: chain rule from loss back to weights
# 1. Output layer gradient (softmax + cross-entropy)
grad_z2 = p.copy()
grad_z2[label] -= 1 # p - y
grad_W2 = np.outer(grad_z2, h) # weight gradient
grad_b2 = grad_z2 # bias gradient
grad_h = W2.T @ grad_z2 # pass gradient to hidden layer
# 2. Hidden layer gradient (ReLU)
grad_z1 = grad_h * (z1 > 0) # multiply by ReLU derivative
grad_W1 = np.outer(grad_z1, x) # weight gradient
grad_b1 = grad_z1 # bias gradient
# 3. Update all weights
W2 -= lr * grad_W2
b2 -= lr * grad_b2
W1 -= lr * grad_W1
b1 -= lr * grad_b1下面 NumPy 代码里的 HiddenLayer.backward() 和 OutputLayer.backward() 要实现的正是这些——同样的步骤,只是包进了类里。
下面的组件在一个极小的网络上跑的就是这个循环——一张 2×2 的图像替身喂进 2 个神经元的隐藏层,再接到 3 类的输出。选一个输入,告诉它正确的数字,然后看反向传播如何一步步填出来:输出误差 grad_z2 = p − y、隐藏层误差 grad_z1,以及权重梯度矩阵 grad_W2 和 grad_W1。按下 Apply update,损失就会下降——同样的 W -= lr * grad_W 那一步,被摆到了明面上。
小批量与 epoch
上一篇文章里我们引入了随机梯度下降(SGD):与其把所有数据点都过一遍、逐个收集梯度再平均成一次更新,不如把数据切成小的小批量,每处理完一个批次就更新一次权重。 批大小无非就是「你在做一次权重更新之前,会在多少个样本上取平均」——同样的梯度公式、同样的平均,只是样本数不同。
正如我们在上一篇文章里看到的(5 个点中每批取 2 个),这会引入一些噪声——基于 2 个样本的梯度不会与基于全部 5 个的梯度指向完全相同的方向——但更新频繁得多,这一点绰绰有余地补偿回来。
来看看在我们 60,000 张训练图像上,这会怎么展开。
不用小批量(全批量梯度下降)时,把数据过一遍长这样:
- 让全部 60,000 张图像做前向传播
- 计算在全部 60,000 个预测上平均的损失
- 反向传播得到梯度(在所有图像上平均)
- 更新一次权重
那是看完每一张图像之后才有的1 次权重更新。梯度非常准(它考虑了整个数据集),但在处理完全部 60,000 张图像之前,模型什么都学不到。
用大小为 32 的小批量时,同样这一遍长得很不一样:
- 取图像 1–32,前向传播,算损失,反向传播,更新权重
- 取图像 33–64,前向传播,算损失,反向传播,更新权重
- 取图像 65–96,前向传播,算损失,反向传播,更新权重
- …… 对全部 个小批量重复
在同样过一遍数据的时间里,这是 1,875 次权重更新。
每个单独的梯度更带噪(它只基于 32 张图像而不是 60,000 张),但模型改进了 1,875 次而不是 1 次。等它看完所有数据时,它已经做了成百上千次修正——它边走边学,而不是一直等到最后。(极端情形是批大小 = 1——每看一张图就更新一次。那是每个 epoch 60,000 次更新,但每个梯度都非常带噪,因为它只基于一个样本。)
一旦模型跑完了每一个小批量——全部 1,875 个——它就把每张训练图像恰好看过一次。这样完整地过一遍整个数据集,就叫一个 epoch。 我们通常训练多个 epoch——每过一遍数据,模型都会再好一点。
每个 epoch 的批次数由数据集大小和批大小决定:
在每个 epoch 开始时,我们把数据打乱,好让小批量每次都不一样——这能防止模型去学数据顺序里的规律,而不是图像本身的规律。
你可能想问,epoch 数该怎么选。这取决于模型在没见过的数据上何时不再提升。epoch 太少,模型学得不够;太多,它就开始死记训练数据而不是泛化。实践中会把 epoch 上限设得高一些,让早停来决定何时真正停下——它监控验证准确率,在其走平时中止训练。
如果我们把训练跑 5 个 epoch,就会得到:
也就是说梯度下降跑了 9,375 次——每次处理一批 32 张图像,计算平均梯度,并推动网络里的每一个权重。
搭建模型
我们来搭一个接收 784 个输入(像素)、输出 10 个类别概率(每个数字一个)的模型。我们会沿用上一篇文章里的 HiddenLayer 结构——一个权重矩阵 、一个偏置向量 和一个激活函数:
上一篇文章里,我们的模型是一个线性方程——只有 2 个参数,没有层,也没有激活函数。我们直接算出梯度,不需要往后传任何东西,因为它后面什么都没有。现在有了多层,每一层都要计算自己的梯度并且把梯度信号传给它前面的那一层——这就是反向传播过程。
链式法则的数学和我们上面推的一样,只是包进了一个类里:
class HiddenLayer:
def __init__(self, n_inputs, n_neurons):
# We use initialization proposed by Kaiming He et al. (2015):
# random weights scaled by sqrt(2/n) — keeps activations balanced
# for ReLU networks (too large → explode, too small → vanish)
self.W = np.random.randn(n_neurons, n_inputs) * np.sqrt(2.0 / n_inputs)
self.b = np.zeros(n_neurons)
def forward(self, x):
self.x = x # save for backward pass
self.z = self.W @ x + self.b # weighted sum
self.out = np.maximum(0, self.z) # ReLU activation
return self.out
def backward(self, grad_output):
# ReLU derivative: 1 if z > 0, else 0
grad_z = grad_output * (self.z > 0)
# Gradients for this layer's parameters (chain rule)
self.grad_W = np.outer(grad_z, self.x) # ∂loss/∂W = grad_z ⊗ x
self.grad_b = grad_z # ∂loss/∂b = grad_z
# Gradient to pass to the previous layer (chain rule continues)
return self.W.T @ grad_z # ∂loss/∂x = Wᵀ · grad_z
def update(self, lr):
# Gradient descent: update parameters
self.W -= lr * self.grad_W
self.b -= lr * self.grad_b这一层有三个方法:
forward:计算 ,施加 ReLUbackward:接收来自下一层的梯度,用链式法则算出局部梯度,并返回要传给上一层的梯度update:施加梯度下降——从每个参数里减去lr × 梯度
我们把 backward 和 update 分开,是为了能在一个小批量上先累积梯度、再做更新——就跟 Keras 一样。
末尾的 grad_input 是往回传给前一层的梯度信号——链式法则就是这样在网络里「流动」的,正如上一篇文章那张图所示:
Forward: x ──▶ Layer 1 ──▶ Layer 2 ──▶ Output ──▶ Loss
Backward: x ◀── Layer 1 ◀── Layer 2 ◀── Output ◀── ∂L每一层从右边收到一个梯度,算出自己参数的梯度(用来更新 和 ),再把剩下的梯度往左传。
现在我们再造一个用 softmax 而不是 ReLU 的输出层(因为最后一层要产出概率,而不是 ReLU 激活值):
class OutputLayer:
def __init__(self, n_inputs, n_classes):
self.W = np.random.randn(n_classes, n_inputs) * np.sqrt(2.0 / n_inputs)
self.b = np.zeros(n_classes)
def forward(self, x):
self.x = x
self.z = self.W @ x + self.b
# Softmax instead of ReLU
exp = np.exp(self.z - np.max(self.z))
self.probs = exp / np.sum(exp)
return self.probs
def backward(self, label):
# Combined softmax + cross-entropy gradient: p - y
grad_z = self.probs.copy()
grad_z[label] -= 1
# Same gradient formulas as Layer
self.grad_W = np.outer(grad_z, self.x)
self.grad_b = grad_z
return self.W.T @ grad_z
def update(self, lr):
self.W -= lr * self.grad_W
self.b -= lr * self.grad_b现在可以把网络装起来了。先从一个简单架构开始——一个 128 个神经元的隐藏层:
# 784 inputs → 128 hidden neurons → 10 output classes
layer1 = HiddenLayer(784, 128)
output = OutputLayer(128, 10)但为什么是 128?为什么不是 1,或者 10?
每个隐藏神经元都是一个特征检测器。它透过自己的权重向量去看全部 784 个像素,输出一个数字——「我的这个模式在这张图里表现得有多强?」某个神经元可能最终调成对顶部的一段水平笔画敏感(对区分 5 和 7 有用),另一个对下半部分的闭合环敏感(6、8、9),还有一个对右上方的斜线敏感。然后输出层把这 128 个信号加权起来,判断哪个数字最有可能。
输出层计算的那个点积——hidden @ W₂[:, digit]——正是两个向量之间余弦相似度式的对齐:一个是隐藏层为这张图产生的特征激活向量,另一个是该数字对应的权重向量(W₂ 的一列),它编码了「对这个数字来说哪些特征重要、有多重要」。当某个数字的模板与这张图实际触发的特征对得很齐时,它的分数就高——跟评估两支箭头是否指向相近方向是同一个想法。分数最高的类别胜出,就像基于余弦相似度检索时的最近邻。
如果只有 1 个隐藏神经元,每张图像在输出层看到它之前就被压成了单个标量。那 10 个类别分数就会是这一个数字的 10 个线性函数——从几何上说,你被迫把 10 个数字排在一条直线上。在一维直线上,不存在一种排法能让每个数字的区域都与其余九个分开,所以无论你训练多久,网络都无法通过一维瓶颈表达出十路分离。隐藏层的宽度决定了输出层能用的维度,而 10 个类别需要的不止一个维度。
128 本身是个舒服的默认值——大到足以把 MNIST 拟合得不错(单层 97–98%),小到能在 CPU 上几秒钟训练完。用 64 会损失大约半个百分点;用 512 会好一点点,但训练更慢、也更容易过拟合。下一篇文章里我们会调这个值,看看会发生什么。
第一层是 个参数,输出层是 个——总共 101,770 个参数。相比上一篇文章拟合直线例子里的 2 个参数( 和 ),这是 5 万倍。可训练算法完全相同。
同一个网络的 Keras 版本
Keras 把它的层叫作 Dense——是「densely connected」(密集连接,也就是「全连接」)的简称,意思是每个输入都连到每个神经元。Dense 层做的计算与我们上面的 NumPy 类完全相同:——输入乘权重、加偏置、施加激活函数。区别在于 Keras 自动处理了权重初始化、前向传播、梯度计算和参数更新。
我们带 ReLU 的 HiddenLayer(784, 128) 变成 Dense(128, activation="relu"),带 softmax 的 OutputLayer(128, 10) 变成 Dense(10, activation="softmax")。注意在 Keras 里你只需要指定输出个数——模型顶部需要一个 keras.Input(shape=(784,)) 来知道输入尺寸,此后 Keras 会自动从上一层的输出推断每一层的输入。所以 Dense(10) 知道自己有 128 个输入,因为它前面那层输出 128。
Dense 层、全连接、FFNN、MLP——有什么区别?
这些词常被混用,但它们描述的是不同的东西:
- Dense / 全连接层——说的是某一层的接线方式:每个输入都连到每个神经元,也就是我们一直在搭的那个运算 。它是一个构件。
- MLP(多层感知机)——由若干堆叠的全连接层、层间夹着非线性构成的整个网络。我们刚搭的这个模型就是一个 MLP:784 → 128(ReLU)→ 10(softmax)。
- FFNN(前馈神经网络)——描述的是拓扑:数据单向流动,输入 → 输出,没有回路,也不记得之前的输入。我们的网络也是前馈的。
所以对我们这样的网络,三个标签都适用——它是一个全连接的 MLP,也是前馈的。有一个微妙之处:「前馈」比「全连接」更宽泛。卷积神经网络(CNN) 也是前馈的(没有回路),但它的层是卷积层而不是全连接层——所以并非每个 FFNN 都由全连接层搭成。而**循环神经网络(RNN)**不是前馈的,因为它把自己的输出绕回作为输入,以便在序列中携带记忆。正是这种对照——前馈 vs 循环——解释了为什么在比较语言这类词序要紧的任务上的模型时,这些名字会作为不同的架构出现。
Sequential 把它们叠成一个模型,每层的输出喂进下一层:
model = keras.Sequential([
# 784 inputs (28×28 image pixels)
keras.Input(shape=(784,)),
# 128 neurons/outputs, ReLU activation
keras.layers.Dense(128, activation="relu"),
# 10 neurons/outputs (one per digit), softmax activation
keras.layers.Dense(10, activation="softmax"),
])
model.summary()
# Total params: 101,770 — same number we counted by hand搭建训练循环
训练循环还是上一篇文章里那个 4 步流程——前向传播、损失、反向传播、梯度下降。对每个小批量,我们在每张图像上跑前向传播和反向传播以累积梯度,然后取平均并更新一次权重。这与 Keras 内部所做的相匹配:
def train(layers, output_layer, X_train, y_train, X_val, y_val,
epochs=5, lr=0.1, batch_size=32):
n = X_train.shape[0]
history = {"train_loss": [], "train_acc": [], "val_acc": []}
for epoch in range(epochs):
# Shuffle training data at the start of each epoch
indices = np.random.permutation(n)
X_shuffled = X_train[indices]
y_shuffled = y_train[indices]
epoch_loss = 0.0
correct = 0
all_layers = layers + [output_layer]
for i in range(0, n, batch_size):
X_batch = X_shuffled[i:i+batch_size]
y_batch = y_shuffled[i:i+batch_size]
bs = len(X_batch)
# Accumulate gradients over the mini-batch
accumulated = {id(l): (np.zeros_like(l.W), np.zeros_like(l.b))
for l in all_layers}
for x, label in zip(X_batch, y_batch):
# 1. Forward pass
h = x
for layer in layers:
h = layer.forward(h)
probs = output_layer.forward(h)
# 2. Loss computation
loss = -np.log(probs[label] + 1e-10)
epoch_loss += loss
correct += (np.argmax(probs) == label)
# 3. Backpropagation (compute gradients, don't update yet)
grad = output_layer.backward(label)
for layer in reversed(layers):
grad = layer.backward(grad)
# Accumulate gradients
for l in all_layers:
accumulated[id(l)][0][:] += l.grad_W
accumulated[id(l)][1][:] += l.grad_b
# 4. Gradient descent: average gradients and update
for l in all_layers:
l.grad_W = accumulated[id(l)][0] / bs
l.grad_b = accumulated[id(l)][1] / bs
l.update(lr)
# Track metrics
train_loss = epoch_loss / n
train_acc = correct / n
val_acc = evaluate(layers, output_layer, X_val, y_val)
history["train_loss"].append(train_loss)
history["train_acc"].append(train_acc)
history["val_acc"].append(val_acc)
print(f"Epoch {epoch+1}/{epochs} — loss: {train_loss:.4f}, "
f"train acc: {train_acc:.2%}, val acc: {val_acc:.2%}")
return history
def evaluate(layers, output_layer, X, y):
correct = 0
for x, label in zip(X, y):
h = x
for layer in layers:
h = layer.forward(h)
probs = output_layer.forward(h)
correct += (np.argmax(probs) == label)
return correct / len(y)这就是上一篇文章里那个 SGD 循环:打乱数据、切成小批量,然后对每个样本跑前向传播、算损失、把梯度反传回去、更新权重。唯一的区别是,不再是 5 个数据点和 2 个参数,而是 60,000 张图像和 101,770 个参数。
同样的东西在 Keras 里是这样——上面整个训练循环坍缩成三行:
model.compile(
optimizer=keras.optimizers.SGD(learning_rate=0.1), # gradient descent with lr=0.1
loss="sparse_categorical_crossentropy", # cross-entropy loss
metrics=["accuracy"],
)
history = model.fit(
X_train, y_train,
epochs=5,
batch_size=32,
validation_split=0.2, # hold out 20% of training data for validation
)compile 设定损失函数和优化器,fit 跑完整个循环——为每个 epoch 里的每个小批量做前向传播、损失、反向传播和梯度下降。
我们拆解一下这些参数:
"sparse_categorical_crossentropy"——就是我们上面推导的交叉熵损失。这名字有三部分:「sparse」表示我们把标签作为整数传入(例如3),而不是 one-hot 向量([0,0,0,1,0,0,0,0,0,0]);「categorical」表示我们在做多类别分类;「crossentropy」就是 这个损失函数。Keras 还有"categorical_crossentropy"(不带「sparse」),用于标签已经是 one-hot 编码的情形——数学一样,只是输入格式不同。SGD(learning_rate=0.1)——随机梯度下降,也就是上一篇文章里那条w = w - lr * dw更新规则。Keras 还提供更高级的优化器(Adam、RMSprop 等)来自动调整学习率,但我们一直用的是朴素 SGD,在这里它表现不错。validation_split=0.2——从训练数据里划出 20%(12,000 张图像)作为验证集。每个 epoch 之后,Keras 会在这些留出的图像上评估模型(只做前向传播,不更新权重),好让我们追踪它的泛化情况。剩下的 48,000 张图像用于实际训练。
准备数据
在开始训练网络之前,我们得先把原始像素数据准备好——网络没法直接处理 28×28 的整数网格。我们要做两件事:
- 摊平——把 28×28 的网格重排成一个 784 个数字的向量
- 归一化——把像素值从 缩放到
摊平之所以必要,是因为我们的网络用的是全连接(dense)层,每个输入都连到每个神经元——它们期待的是一串扁平的数字,而不是二维网格。像卷积神经网络(CNN)这类别的架构可以直接处理二维形状,但我们现在先用全连接层。摊平会把 28×28 的网格重排成一个 784 个数字的向量——我们的网络用的是全连接层,每个输入都连到每个神经元,所以它们期待一串扁平的数字,而不是二维网格。像 CNN 这类别的架构可以直接处理二维形状,但我们现在先用全连接层。
flatImage = image.reshape(784) # [0, 0, 0, ..., 156, 252, 128, ..., 0, 0] — 784 values归一化有助于让输入值和权重处在同一个量级上。 权重通常被初始化成 0 附近的小随机数,所以如果输入高达 255,加权和就会变得很大,进而导致大的激活值、大的梯度和不稳定的训练。除以 255 把一切放进 ——在这个范围里,小的随机权重从一开始就能给出合理的输出。这是机器学习里的标准做法,并非 MNIST 特有。
不做归一化的话,神经元可能饱和——激活函数卡在导数约为 0 的平坦区域,梯度消失,网络停止学习。这与梯度消失问题背后是同一套机制。关于未归一化的输入如何造成饱和的详细交互演示,见 ReLU 死亡那篇文章。
归一化把像素值从 缩放到 :
# Normalize pixel values from [0, 255] to [0, 1]
normalizedImage = flatImage / 255.0 # [0.0, 0.0, 0.0, ..., 0.61, 0.99, 0.50, ..., 0.0, 0.0]训练集、验证集与测试集
在跑训练之前,有一个重要区分需要弄明白。我们有 60,000 张训练图像和 10,000 张测试图像——但实际上我们需要三套,而不是两套:
- 训练集——模型从中学习的数据。每个 epoch,模型都会处理这一套里的每张图像,计算梯度并更新权重。这些图像模型会一遍遍地看。
- 验证集——每个 epoch 之后,模型在这些图像上做一次前向传播来计算损失和准确率,但那个损失只用于汇报——不做反向传播、不算梯度、不更新权重。它纯粹是一次测量。**它告诉我们模型泛化得如何:**如果它在训练数据上拿到 98%、在验证数据上只有 90%,那它是在死记训练图像,而不是在学通用模式。如果验证准确率不再提升而训练准确率还在爬,模型就在过拟合。
- 测试集——一套完全独立的数据,在所有训练决策都定下来之后,在最末尾只评估一次。这才是模型对从未见过的图像泛化能力的真实度量。
用我们那 4 步训练循环来说:
| 训练集 | 验证集 | 测试集 | |
|---|---|---|---|
| 1. 前向传播 | 是 | 是 | 是 |
| 2. 计算损失 | 是 | 是(仅用于汇报) | 是(仅用于汇报) |
| 3. 反向传播 | 是 | 否 | 否 |
| 4. 更新权重 | 是 | 否 | 否 |
对训练图像,4 步全跑——模型在学习。对验证和测试图像,只跑第 1、2 步——模型衡量自己表现如何,但不会因为看到的内容而改动权重。
为什么不干脆用测试集兼任两职?因为每当你在某个数据集上检查表现并据此做决定(比如「还要继续训练吗?」),你就在悄悄地往那份数据上拟合。如果你用测试集来决定何时停止训练,测试准确率就会变得乐观偏高。让验证集来吸收这份偏差,测试集才能保持诚实。
验证集也是你用来调超参数的地方——像学习率、批大小、层数和 epoch 数这些选择。你试不同的设置,比较验证准确率,挑最好的那个。整个过程中测试集始终封存不动,这样它才能给出一个无偏的最终评估。
实践中,你可以从训练数据里切出一部分来做验证集。Keras 用 validation_split 让这件事很轻松:
history = model.fit(
X_train, y_train,
epochs=5,
batch_size=32,
validation_split=0.2, # use 20% of training data as validation
)你也可以手动划分,并用 validation_data=(X_val, y_val) 传入一份单独的数据集。我们会用 validation_split=0.2——Keras 会留出 20% 的训练数据(12,000 张图像)用于验证,在剩下的 48,000 张上训练。那 10,000 张测试图像完全另放一边,留作最终评估。
训练模型
现在我们终于可以跑训练了。先加载并准备数据集——MNIST 太标准了,Keras 直接内置了它:
import keras
import numpy as np
(train_images, train_labels), (test_images, test_labels) = keras.datasets.mnist.load_data()
# Flatten 28×28 → 784 and normalize to [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
print(f"Training: {X_train.shape[0]} images, {X_train.shape[1]} pixels each")
print(f"Test: {X_test.shape[0]} images")
# Training: 60000 images, 784 pixels each
# Test: 10000 images我们现在有 60,000 张训练图像(每张是一个 784 维、取值在 0 到 1 之间的向量)以及它们的标签(每个 0–9)。测试集是分开的——我们只用它来检查模型对训练中从未见过的数字泛化得如何。
来训练我们的网络:
# Split: 80% train, 20% validation (same as Keras validation_split=0.2)
n_val = int(0.2 * len(X_train))
X_val, y_val = X_train[:n_val], y_train[:n_val]
X_train_sub, y_train_sub = X_train[n_val:], y_train[n_val:]
layer1 = HiddenLayer(784, 128)
output = OutputLayer(128, 10)
history = train([layer1], output, X_train_sub, y_train_sub, X_val, y_val,
epochs=5, lr=0.1, batch_size=32)
# Epoch 1/5 — loss: 0.3260, train acc: 90.70%, val acc: 94.58%
# Epoch 2/5 — loss: 0.1608, train acc: 95.30%, val acc: 95.97%
# Epoch 3/5 — loss: 0.1147, train acc: 96.64%, val acc: 96.49%
# Epoch 4/5 — loss: 0.0898, train acc: 97.45%, val acc: 96.94%
# Epoch 5/5 — loss: 0.0737, train acc: 97.87%, val acc: 97.27%下面是 5 个 epoch 的训练过程——左图显示随着模型学习损失在下降,右图显示准确率在攀升。绿点是验证准确率(val_accuracy),在每个 epoch 结束时于模型从不训练的留出图像上测得:
5 个 epoch 之后约 97% 的验证准确率——网络正确分类了几乎所有它从未训练过的留出图像。对一个只训练了 5 个 epoch 的简单两层网络来说,不赖。
你可能注意到第 1 个 epoch 就已经有约 90% 的准确率了。那是因为准确率是在 epoch 结束时测的——到那时模型已经处理了全部 1,500 个批次(48,000 张图像)并做了 1,500 次权重更新。如果我们按批次追踪准确率,你会看到它从 10% 左右起步(10 个数字里随机猜),并在第一个 epoch 中迅速攀升。
有一个值得留意的耐人寻味的细节。仔细看蓝线(训练)和绿线(验证)之间的间距——尤其是在损失图上。训练损失持续稳步下降,但验证损失在第 4–5 个 epoch 前后走平,甚至开始微微上翘。这是过拟合的开端:模型开始死记训练数据,而不是学习那些能泛化到新图像的模式。5 个 epoch 之后这个间距还很小,但如果我们继续训练 20 个以上的 epoch,训练准确率会逼近 100%,而验证准确率会停滞甚至下滑。这正是我们需要验证集的原因——它告诉我们何时该停。过拟合我们会在下一篇文章里深入讨论。
自己动手试试
我们准备了一份配套 notebook,你可以在里面把 Keras 和 NumPy 两份实现并排跑起来。所有单元格会自动执行——你会先看到 Keras 的训练输出,然后是 NumPy 的训练输出,指标一致。
Keras 的输出长这样:

各部分的含义如下:
1500/1500——1,500 个小批量中已完成 1,500 个(48,000 张训练图像 / 每批 32 张)。训练进行中你会看到这个数往上走(比如18/1500表示已完成 18 个批次)。5s 4ms/step——这个 epoch 花了 5 秒,每批约 4 毫秒accuracy: 0.9529和loss: 0.1631——训练指标,在该 epoch 的所有批次上取平均val_accuracy: 0.9585和val_loss: 0.1434——验证指标,在 epoch 结束时于留出的 12,000 张图像上算一次
Keras 和 NumPy 的数字不会完全相同(随机权重初始化不同),但应该很接近——5 个 epoch 之后都在约 97% 的验证准确率上下。这印证了我们从零写的 NumPy 代码实现的正是与 Keras 相同的算法。
我们讲了什么
我们把上一篇文章里的每个概念都拿来用到了一个真实问题上——用 10 万多个参数分类手写数字,而不是用 2 个参数拟合一条直线:
- 分类 vs 回归:softmax 把原始 logit 变成概率,交叉熵衡量这些概率错得有多离谱
- 前向传播:输入流经各层,每层计算 ——同一个公式,如今写成矩阵形式
- 反向传播:softmax + 交叉熵的梯度简化成 ,而链式法则正如我们手推的那样向后流经各层
- 训练循环:打乱、切成小批量、前向传播 → 损失 → 反向传播 → 更新——在每个 epoch 的每个批次上重复
算法与我们在上一篇文章里搭的完全相同。Keras 把它自动化了,但底下仍是我们 NumPy 代码一步步执行的那套计算。
不过我们全程用的都是默认设置——一个 128 个神经元的隐藏层、学习率 0.1、批大小 32。如果改动它们会怎样?在下一篇文章里,我们会系统地逐一变动每个部分——学习率、批大小、网络深度、激活函数——并探讨过拟合、早停,以及在训练出问题时如何诊断。