如果你跟我一样,是把《深度学习入门:基于Python的理论与实现》(大家习惯叫它“小鱼书”)翻完、合上书之后手痒想自己动手跑一遍的人,那MNIST手写数字识别基本是绕不开的第一站。这篇不是复制教材的理论复述,而是我从装Python、下载数据集、手写两层神经网络,到反复调参踩坑、最后在测试集上拿到96%以上准确率的完整过程记录。这个项目很适合刚入门深度学习的读者,也适合想把手写反向传播再捋一遍的人。
# 完整可运行的代码放在了后面,这里先不贴1. 项目思路拆解:MNIST到底在做什么
1.1 一个可验证的“任务”
MNIST是机器学习里最经典的手写数字数据集:6万张训练图片、1万张测试图片,每张都是28像素乘28像素的灰度图,上面是一个手写的阿拉伯数字“0”到“9”。整个项目的目标就是:输入一张图片,让程序输出一个数字。
听起来像个简单分类问题,但真实手写数字的写法千奇百怪,有人把“7”写出一横,有人把“4”写得像“9”,还有人把“1”写得带个小勾。你需要训练的模型,必须从6万个样本中抽象出“这一类数字到底长什么样”的共性。
这里我想强调一个可能被很多人忽略的点:MNIST虽然是入门数据集,但它完整覆盖了深度学习的核心流程——数据处理、模型设计、损失函数、梯度下降、训练与评估。你在更复杂的项目里遇到的那套东西,本质上和这里一模一样。把MNIST跑明白,CIFAR-10、ImageNet也只是同样流程的放大版而已。
1.2 为什么教材这条路值得复刻
网上90%的MNIST教程,打开就from torchvision import datasets或者tf.keras.datasets.mnist.load_data(),然后两三行代码把模型建好,跑完输出一行“accuracy: 0.98”,就结束了。
我不是说这条路不行,但对于想把深度学习基础打扎实的人来说,直接奔框架会漏掉最关键的认知环节:模型内部到底是什么算出来的?你点开Sequential的源码,可能能看到一堆参数的含义说明,但很难感受到“784个像素经过矩阵乘法和激活函数,最终变成10个分值”这件事背后究竟发生了什么。
小鱼书的好处是它坚持用Python和NumPy从零实现一个两层神经网络。这意味着:
- 正向传播的每一个矩阵乘法,你都得自己调
np.dot写出来; - 反向传播的每一个梯度,你都得自己用链式法则推出来;
- 训练循环的每一个参数更新,你都得亲手写下
params[key] -= lr * grad[key]这一行。
别小看这个过程。正因为没有框架帮你“魔法式”地完成这一切,你对维度、数据类型、梯度形状这些细节的敏感度会迅速建立起来。用几天时间换一辈子的模型直觉,这笔买卖非常划算。
1.3 方案选型背后的取舍
这个项目我采用的是教材的经典配置:一层sigmoid激活的隐藏层,输出层用softmax,损失函数用交叉熵,优化器是带mini-batch的随机梯度下降。
为什么是这套组合?
- 隐藏层选sigmoid:虽然现在大家普遍用ReLU,但作为教学项目,sigmoid的导数形式简单,
y * (1 - y),非常适合理解链式法则的传播路径。 - 输出层选softmax + 交叉熵:比起均方误差,交叉熵在分类问题上收敛更快、梯度形态更好,这背后有一个非常实在的原因——配合softmax后,损失对输出层的梯度恰好是
(预测值 - 真实值),这个差值直接决定了回传信号的强弱。详细解释放到第3章。
这套方案可能在“先进”程度上根本不是今天的工业界主流,但作为教学和训练思维的载体,它是几乎最优的。我见过太多人一上来就跑ResNet,跑了三个星期连梯度爆炸、学习率太高的现象都没见过,结果一出Bug就两眼一抹黑。
2. 环境、依赖与数据集准备
2.1 推荐环境与依赖安装
这次实践我用的Python版本是3.8,其实3.8到3.12都行,小鱼书的代码非常朴素,不需要任何新特性。依赖只有两个:NumPy和Matplotlib。前者负责所有矩阵运算,后者用来画损失曲线和权重图。
# 建议先建一个干净的虚拟环境,避免污染全局Python python -m venv mnist_env source mnist_env/bin/activate # windows下是 mnist_env\Scripts\activate pip install numpy matplotlib如果你装NumPy时遇到下载超时,把pip源换到国内镜像一般就能解决。这里我不展开具体命令,搜索“pip 国内镜像”一抓一大把。装完验证一下:
import numpy as np import matplotlib.pyplot as plt print(np.__version__)能打印出版本号就说明环境没问题了。这一步看着简单,但在实际项目里卡住的人一点都不少。最常见的坑包括:机器上有多个Python版本,pip装到了旧版;或者用了系统自带的Python,权限不够导致安装中断。我的建议是永远用虚拟环境,这可以帮你隔离掉绝大部分环境问题。
2.2 数据集下载与“404问题”
说到数据集,这里必须分享一个我踩过的大坑:直接自动下载MNIST时,经常遇到连接超时、下载到一半失败、甚至404的情况。很多用框架的人会看到torchvision下载MNIST时报404,其实这和工具本身没关系,是托管数据集的服务器在特定网络条件下不稳定。
我的兜底方案很简单:手动准备离线数据。
先去MNIST官方网站(或者其他可信的数据集镜像站)把四个gz文件下载下来,放到项目目录下的dataset文件夹里:
dataset/ ├── train-images-idx3-ubyte.gz ├── train-labels-idx1-ubyte.gz ├── t10k-images-idx3-ubyte.gz └── t10k-labels-idx1-ubyte.gz这样后面的脚本就完全不依赖网络了。我强烈建议你第一次跑的时候就这么干,省去80%的下载相关问题。
2.3 手写Loader与预处理细节
MNIST这套原始文件是上古时代的IDX格式,二进制结构非常简单,但第一次接触会觉得神神叨叨的。图像文件的构成是:前16个字节是文件头(魔数、图片数量、行数、列数),之后才是真正的像素数据;标签文件的文件头只有8个字节。
手动读取的代码一点都不复杂:
import gzip import os import numpy as np dataset_dir = os.path.join(os.getcwd(), "dataset") def load_images(filename): path = os.path.join(dataset_dir, filename) with gzip.open(path, "rb") as f: data = np.frombuffer(f.read(), dtype=np.uint8, offset=16) return data.reshape(-1, 784) def load_labels(filename): path = os.path.join(dataset_dir, filename) with gzip.open(path, "rb") as f: data = np.frombuffer(f.read(), dtype=np.uint8, offset=8) return data x_train = load_images("train-images-idx3-ubyte.gz") t_train = load_labels("train-labels-idx1-ubyte.gz") x_test = load_images("t10k-images-idx3-ubyte.gz") t_test = load_labels("t10k-labels-idx1-ubyte.gz")两个预处理细节:
第一,图片像素范围是0到255,直接输入神经网络会让数值过大会影响梯度。需要归一化到0到1之间。注意归一化必须在把数据喂给网络之前做,而且训练集和测试集都要做。有人只归一化训练集,结果测试的时候整张图全是暗的,准确率直接崩掉。
x_train = x_train.astype(np.float32) / 255.0 x_test = x_test.astype(np.float32) / 255.0第二,标签需要做one-hot编码。原因是我们的输出层有10个神经元,分别对应数字0到9,标签也必须变成同样维度的向量。比如数字“3”要变成[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]。
def one_hot(labels, num_classes=10): n = labels.shape[0] t = np.zeros((n, num_classes), dtype=np.int32) t[np.arange(n), labels] = 1 return t t_train = one_hot(t_train) t_test = one_hot(t_test)很多人做MNIST不用one-hot,直接用普通数组当标签,然后自己写一个cross_entropy = -np.log(y[np.arange(n), t])。这也完全可以。只是如果你要写成矩阵运算的形式,one-hot更直观,后面算梯度的时候也不用额外做索引取值。
3. 网络结构设计与关键公式拆解
3.1 为什么是784-50-10
网络的三层结构非常清晰:输入层784个神经元,隐藏层50个神经元,输出层10个神经元。
- 784是因为28乘28等于784,每一张图片被“拉平”成一个一维向量。注意池化在后续项目里会保留二维空间信息,但现在我们先把图片当作一个平面向量来处理。
- 50是隐藏层的神经元数,这是个超参数。太小(比如10)模型学不到足够特征,太大(比如500)在这个数据集上反而容易过拟合和变慢。教材选了50作为默认值,是一个兼顾了表达能力和计算成本的平衡点。
- 10对应十个数字类别,最终哪个神经元的输出值最大,就认为图片是哪个数字。
整个网络可以看作两段连续变换:
784 -> 50 -> 10第一段:输入图片经过一个矩阵乘法加偏置,经过sigmoid激活,得到隐藏层表示。第二段:隐藏层经过另一个矩阵乘法加偏置,经过softmax,得到十个类别的概率分布。
这里我特别想强调“矩阵乘法”到底在做什么。第一个权重矩阵W1的形状是(784, 50),它把784维的输入压缩成50维的中间表示。W1的每一列就相当于一个“特征模板”,它和输入做点积得到一个相似度分数。训练过程中,这些模板会逐渐变成“竖线检测器”“圆圈检测器”一类的东西。这个我在第5章的权重视觉化里会展示。
3.2 sigmoid、softmax和交叉熵:三种函数的“性格”
sigmoid把一个实数压到0到1之间,数学形式是1 / (1 + exp(-x))。它通常放在隐藏层之后,作用是给网络引入非线性。如果没有这个非线性,两层线性变换最终仍然等价于一层线性变换,那再多层也没有意义。
softmax是把10个实数变成10个概率值。它本质上做了两件事:通过exp把分数之间的差距拉开,然后做归一化让输出之和等于1。
一个细节是,在代码里通常会给exp做一个“减去最大值”的处理:
def softmax(a): max_a = np.max(a, axis=1, keepdims=True) exp_a = np.exp(a - max_a) return exp_a / np.sum(exp_a, axis=1, keepdims=True)为什么要减最大值?因为如果某个分数特别大,比如100,exp(100)会直接溢出成inf。减去最大值后,最大项变成exp(0) = 1,数值稳定性就有了保障。
交叉熵衡量两个概率分布之间的差距。对于one-hot标签,它简化成-log(预测到正确类别那个位置的概率)。预测对的概率越大,损失越小;预测到正确类别的概率越小,损失爆炸式变大。
这三者放在一起后有一个特别美妙的性质:损失对输出层输入a2的梯度等于预测值 - 真实值。也就是说,如果模型认为数字“3”的概率是0.6,而真实标签是“3”,那么回传的梯度就是0.6 - 1 = -0.4,朝着“更相信它是3”的方向修正;如果模型把“3”的预测做到了0.9,那梯度只有-0.1,修正幅度很小。这个性质让训练自然地在接近正确时放慢脚步,防止震荡。
3.3 从公式到代码:正向传播
正向传播就是把输入依次经过每一层算一遍:
def sigmoid(x): return 1 / (1 + np.exp(-x)) class TwoLayerNet: def __init__(self, input_size, hidden_size, output_size, weight_init_std=0.01): self.params = {} self.params["W1"] = weight_init_std * np.random.randn(input_size, hidden_size) self.params["b1"] = np.zeros(hidden_size) self.params["W2"] = weight_init_std * np.random.randn(hidden_size, output_size) self.params["b2"] = np.zeros(output_size) def predict(self, x): W1, b1 = self.params["W1"], self.params["b1"] W2, b2 = self.params["W2"], self.params["b2"] a1 = np.dot(x, W1) + b1 z1 = sigmoid(a1) a2 = np.dot(z1, W2) + b2 return softmax(a2) def loss(self, x, t): y = self.predict(x) return cross_entropy_loss(y, t)这里有一个初始化细节要专门说:权重矩阵用小标准差的高斯随机数初始化,偏置直接初始化为0。
为什么权重不能用0初始化?如果所有W1初始都是0,那么所有隐藏神经元接收到的输入完全相同,反向传播时梯度也完全相同,于是这些神经元永远同步更新,特征多样性完全消失,网络退化成一个只有“一个有效神经元”的弱模型。用随机数来打破对称性是这里的关键。
那为什么标准差选0.01而非1?因为输入是784维,乘上一个较大的权重,加权和很容易落到几千的数量级,sigmoid函数在这个区间早就饱和了,梯度趋近于零,模型根本学不动。0.01是一个相对安全的值,在MNIST这样输入已经归一化到0到1之间的问题上表现稳定。
4. 反向传播与训练循环实现
4.1 梯度下降的直觉
先退一步说一个很朴素的类比。你站在一座山坡上,想走到谷底,但你蒙着眼,手里只有一根探杖。你唯一能做的,是感知脚下哪个方向坡度最大、最陡,然后朝那个方向迈一步。这,就是梯度下降。
模型里的“坡度”就是损失函数对每个参数的偏导数。梯度告诉你每个参数应该往哪个方向调、调多少,才能让损失快速减小。所谓学习率,就是每一步的“步长”。步长太大,你可能一步跨过谷底,甚至滚到对面山坡上去;步长太小,你又走得太慢,半天看不到收敛。
手工实现反向传播,说穿了就是利用链式法则,从输出层开始,一层一层往前计算梯度。输出层离损失函数最近,梯度最好算;越往前,梯度需要通过后面的每一层传递回来。
4.2 反向传播推导与代码实现
先回忆正向传播的前三步:
a1 = x @ W1 + b1 z1 = sigmoid(a1) a2 = z1 @ W2 + b2 y = softmax(a2)损失对a2的梯度,前面已经提过,是dy = y - t。
然后依次往回推:
dW2 = z1.T @ dy db2 = sum(dy, axis=0) da1 = dy @ W2.T * z1 * (1 - z1) dW1 = x.T @ da1 db1 = sum(da1, axis=0)代码里对应的函数是:
def gradient(self, x, t): W1, W2 = self.params["W1"], self.params["W2"] grads = {} batch_size = x.shape[0] a1 = np.dot(x, W1) + self.params["b1"] z1 = sigmoid(a1) a2 = np.dot(z1, W2) + self.params["b2"] y = softmax(a2) delta2 = (y - t) / batch_size grads["W2"] = np.dot(z1.T, delta2) grads["b2"] = np.sum(delta2, axis=0) delta1 = np.dot(delta2, W2.T) * z1 * (1 - z1) grads["W1"] = np.dot(x.T, delta1) grads["b1"] = np.sum(delta1, axis=0) return grads注意两点。
第一,z1 * (1 - z1)是sigmoid的导数。你看到它出现在代码里,而不是什么神秘的高阶数学,只要写出来一次,链式法则就不再是纸面公式了。
第二,/ batch_size是因为我们算的是mini-batch的平均损失,梯度也要除以batch_size。如果不除,梯度大小会随着batch_size增大而线性变大,换一批数据、改一下batch大小,学习率就得重新调。
4.3 训练循环与超参数设置
训练过程并不复杂,就是反复执行“取样、计算梯度、更新参数”:
(x_train, t_train), (x_test, t_test) = (x_train, t_train), (x_test, t_test) # 这里假设你已经用上一章的loader和one_hot处理好了数据 network = TwoLayerNet(input_size=784, hidden_size=50, output_size=10) lr = 0.1 iters_num = 10000 batch_size = 100 train_size = x_train.shape[0] iter_per_epoch = max(1, train_size // batch_size) train_loss_list = [] train_acc_list = [] test_acc_list = [] for i in range(iters_num): batch_mask = np.random.choice(train_size, batch_size) x_batch = x_train[batch_mask] t_batch = t_train[batch_mask] grads = network.gradient(x_batch, t_batch) for key in ("W1", "b1", "W2", "b2"): network.params[key] -= lr * grads[key] loss = network.loss(x_batch, t_batch) train_loss_list.append(loss) if i % iter_per_epoch == 0: train_acc = network.accuracy(x_train, t_train) test_acc = network.accuracy(x_test, t_test) train_acc_list.append(train_acc) test_acc_list.append(test_acc) print(f"iteration {i}, train acc = {train_acc:.4f}, test acc = {test_acc:.4f}")这套超参数我是直接采用小鱼书默认配置再微调过的:
| 超参数 | 取值 | 说明 |
|---|---|---|
| 学习率 lr | 0.1 | 对两层小网络来说是稳妥的起点 |
| batch_size | 100 | 每次采样100张图,兼顾梯度稳定和计算速度 |
| 迭代次数 | 10000 | 每迭代10000次,累计看过100万张训练图片 |
| 权重初始化标准差 | 0.01 | 避免sigmoid饱和,保证反向传播信号不消失 |
实际跑起来,损失会从最初的2.3左右稳步下降,每过一个epoch(大约600次迭代)打印一次准确率,你会看到训练集和测试集准确率同时稳定爬升。用这套配置训练结束后,我的测试集准确率稳定在96%左右。如果你额外调高隐藏层宽度到100、学习率后半段调小到0.01,可以摸到97%以上,但那属于锦上添花了。
5. 测试评估与模型可视化
5.1 在测试集上看准确率
训练结束时千万不要只在训练集上自我感动,真正的考验是那一万张从没参与过训练的测试图片。
评估代码很简单:
def accuracy(self, x, t): y = self.predict(x) y_pred = np.argmax(y, axis=1) t_true = np.argmax(t, axis=1) return np.mean(y_pred == t_true) print(network.accuracy(x_test, t_test))我拿到的是0.9612。意味着10000张测试手写数字图里,模型认错了大约390张。
这个数字在今天的深度学习领域是“低到不值得发论文”的水平,但在两年层全连接网络加上手工反向传播的前提下,已经是很健康的结果。更重要的是,这96%不是框架帮你算的,而是你自己用一行行矩阵乘法实时推出来的。你完全知道每一个参数的意义。
5.2 把第一层权重画出来
训练完模型后,有一个让初学者很兴奋的步骤:把第一层权重W1画成图片。
W1的形状是(784, 50),每一列对应一个隐藏神经元。取出一列,把它reshape回28乘28,就能看到这个神经元在“关注”什么样的图像模式:
import matplotlib.pyplot as plt fig = plt.figure(figsize=(12, 6)) for i in range(10): ax = fig.add_subplot(2, 5, i + 1) ax.imshow(network.params["W1"][:, i].reshape(28, 28), cmap="gray") ax.set_title(f"neuron {i}") ax.axis("off") plt.show()我第一次跑完看到这10张权重图时有点惊艳:有些神经元的权重图呈现出类似一笔一画的笔画轮廓,有些像带方向的纹路,还有的像某个数字的局部形状。这说明网络在训练中自动学会了“某些边缘特征”“某些弧线特征”。没人告诉它“手写数字有竖线”,它是自己从数据里总结出来的。
5.3 错误样例分析思路
96%的准确率听起来不错,但剩下的4%错误也值得看看。统计错例很简单:
y_test = network.predict(x_test) y_pred = np.argmax(y_test, axis=1) t_true = np.argmax(t_test, axis=1) errors = np.where(y_pred != t_true)[0] for idx in errors[:10]: plt.imshow(x_test[idx].reshape(28, 28), cmap="gray") plt.title(f"true={t_true[idx]}, pred={y_pred[idx]}") plt.show()我观察后总结出三类典型错误:
- “4”和“9”混淆:这两种数字在很多人手写体里的差别本来就小,一个竖直笔画没写好就容易认错。
- “7”和“1”混淆:带不带横杠、弯不弯,这些形变噪声对两层网络来说已经超出了它的表达能力上限。
- “0”和“6”这种闭合曲线相似的类,偶尔会互相认错。
看着这些错误你就会明白,模型不是“故意笨”,是它的结构容量只有这么大。握手的两个数字之间本身也存在信息重叠,增加训练数据量、加宽网络、引入卷积结构才是解决这类问题的方向。这也是你从MNIST走向更复杂网络的自然动力。
6. 常见问题排查实录与避坑清单
6.1 高频报错与解决方案速查表
我在跑这个项目的过程中,以及后来陪着朋友重新跑的时候,反复遇到下面这些报错,基本可以算作新手高频问题榜单了。
| 报错或现象 | 原因 | 解决方法 |
|---|---|---|
| 下载MNIST时报404或超时 | 官方服务器连接不稳定 | 手动下载gz文件放入dataset目录 |
np.dot报维度不匹配 | 矩阵形状理解错误 | 打印x.shape、W1.shape,确认(batch, 784) @ (784, 50) |
| 损失一直是2.3左右不动 | 权重初始化不当或标签错误 | 检查one-hot编码,检查权重是否过小/过大 |
| 损失变成nan | 学习率过大或exp溢出 | 降低学习率,softmax做减最大值处理 |
| 训练准确率高,测试准确率低 | 模型过拟合(但小网络不常见) | 增加数据、加正则化、减少隐藏层宽度 |
| 每个批次训练很慢 | 一次性喂入全部数据 | 改用mini-batch,batch_size设为100或200 |
发散的loss是你最好的朋友,因为它能逼你回头检查所有代码。
6.2 数据维度、初始化与收敛问题
维度问题真的值得单独拉出来聊。我曾经有一次写了这么一行:
a1 = np.dot(W1, x) + b1和正确写法np.dot(x, W1)相比,只是交换了顺序,结果a1的形状直接从(100, 50)变成了(784, 100)。程序没报错,因为某些形状恰好搭得上,但训练结果完全乱掉,准确率卡在10%(也就是瞎猜)附近。
这种Bug的可怕之处在于它不报错,只有模型不收敛这一条线索。我的排查思路是:遇到不收敛,先确认每一步输出的形状是不是符合预期。在代码里插几个print,把x.shape、a1.shape、y.shape全部打印出来,十秒钟就能定位。
还有一个常见的收敛问题,是只把训练集做了归一化,测试集没有做。训练时数据范围是0到1,测试时数据范围是0到255,模型看到测试图片的“亮度”放大了255倍,输出层的数值直接爆炸,准确率当然惨不忍睹。这类问题不涉及数学,纯粹是工程习惯。我的建议是:预处理函数写好后,训练集测试集统一调用,不要写两遍,就不会犯这种错。
7. 最后的一点个人体会
跑完这个项目,我最大的体会是:MNIST不是“一个太简单不值得做”的数据集,恰恰是“简单到适合把每个细节都抠明白”的完美练习场。从数据加载到梯度推导到参数可视化,每一步都能独立验证。之后再接触框架,你会突然发现model.fit、loss.backward()这些接口不过是把你今天手写的东西包装成了黑盒。
最后分享一个小技巧:别急着把代码调成98%就去下一个项目。试着把学习率改成1.0跑一次,你会看到loss发散的全过程;把权重初始化标准差改成1.0跑一次,你会看到sigmoid饱和后的梯度消失是什么症状;把batch_size改成60000跑一次,你会直观感受到全批量梯度下降在数据集上每一步都极其沉重的压力。这些“故意搞坏”的实验,比单纯追求准确率更能帮你理解深度学习的脾气。