我最早接触神经网络,是被一张 MNIST 手写数字图集吸引的。一堆歪歪扭扭、连人眼都得辨认半天的“7”“3”“9”,被一个程序准确标出了类别,当时我的第一反应是:这到底是怎么学出来的?后来自己动手用 Python 一点一点把网络写出来,才发现这件事比想象中简单得多,真正卡住我的反而是那些不起眼的小细节。
这篇内容写给所有想入门神经网络的开发者。不管你是刚学完 Python 语法,还是已经写过一些数据处理脚本,都可以跟着走一遍从零实现“Python 神经网络识别手写数字”的完整过程。我会把我踩过的坑、改过的代码、悟出来的原理全部摊开来讲。这个项目最大的好处是:数据公开、结果直观、网络复杂度适中,既能让你理解神经网络的核心机制,又不会一上来就被深度模型劝退。
1. 为什么“手写数字识别”是对新手最友好的入门题
1.1 一个看似简单但足够经典的问题
手写数字识别本质上是一个图像分类问题:给定一张 28×28 像素的灰度图,判断它是 0 到 9 中的哪个数字。对现代深度学习来说,这已经算是“Hello World”级别的任务了。但恰恰因为它的门槛足够低,才能让你把注意力全部集中在神经网络的原理上,而不是被复杂的数据增强、巨型模型、分布式训练这些外围概念干扰。
MNIST 数据集就是这个领域的标准题库。6 万张训练图片、1 万张测试图片,每一张都是 28×28 的灰度图,标签就是对应的数字。这些图片来自美国人口普查局和美国高中生手写的数字,形态各异,有的歪斜、有的断笔、有的粗细不均,现实中手写体该有的噪声它都有。用这个数据集训练出来的模型,准确率能轻松达到 95% 以上,但想冲上 99% 又需要真正动点脑子——这种“入门容易精通难”的曲线,特别适合用来打磨你的调参手感。
1.2 这个项目能帮你打通哪些“任督二脉”
我见过太多人学神经网络,书读了一堆,公式看了一堆,结果一句“import torch”之后就开始复制粘贴,最后连损失函数为什么这么设计都说不清。而手写数字识别这个项目,能一次性帮你打通几个最基础也最关键的环节。
第一是数据处理流程。图片怎么读、归一化怎么做、标签怎么转成向量、训练集和测试集怎么划分,这一套流程学会了,以后换任何数据集都是同样的套路。
第二是前向传播和反向传播的直觉。从输入图片变成输出概率,是前向传播;根据预测错误调整网络参数,是反向传播。这两个词在理论上很抽象,但你在代码里一行一行写完,看到损失值真的在下降、准确率真的在上升,它们就不再是黑盒了。
第三是调参的判断力。学习率设置多少?隐藏层神经元用几个?激活函数选 sigmoid 还是 ReLU ?这些问题没有标准答案,但你在 MNIST 上试过一轮之后,就会形成自己的经验判断,再去接触卷积神经网络、图神经网络这些更复杂的方向,底层逻辑都是通的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与数据集的三个隐形门槛
2.1 搭建 Python 环境时最容易被忽略的事
先说环境。这个项目需要的依赖极其简单:numpy 用于矩阵运算,torch 和 torchvision 用于加载数据和后面的框架版实现。如果你手头已经装了 Anaconda 或者 Python 3.8+,基本可以跳过本节。但你如果是在裸机上从零开始,我建议你花两分钟建一个虚拟环境,不要贪图方便直接往系统 Python 里装包。
bash复制python -m venv mnist_env
source mnist_env/bin/activate # Linux / macOS
# 或者 Windows 下用:
# mnist_env\Scripts\activate
虚拟环境的好处是:项目依赖互相隔离,你在这个项目里装 torch 2.x,在另一个项目里装 torch 1.x,互不干扰。我早期懒得建虚拟环境,直接在系统环境里装了一堆包,结果有一次升级 numpy 版本把另一个项目搞崩了,排查了半天才找到原因,从那以后再也不偷懒。
安装依赖的时候,直接用 pip:
bash复制pip install numpy torch torchvision
国内网络环境较差的话,可以加镜像源:
bash复制pip install -i https://pypi.tuna.tsinghua.edu.cn/simple numpy torch torchvision
这里有个很多人会卡住的坑:torch 安装包通常比较大,如果下载到一半超时中断,重装的时候又反复超时。我的建议是直接用上面的清华镜像,或者到 PyTorch 官网根据自己的 CUDA 版本选择对应的安装命令。纯 CPU 训练手写数字识别也完全够用,不需要纠结 GPU。
2.2 MNIST 数据的下载与预处理:细到每一像素
MNIST 数据集的加载方式很简单,用 torchvision 一行就能拉下来:
python复制from torchvision import datasets, transforms
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = datasets.MNIST(root='./data', train=True, transform=transform, download=True)
test_dataset = datasets.MNIST(root='./data', train=False, transform=transform, download=True)
但这里有两个细节必须理解透。
第一个是 ToTensor()。原始 MNIST 图片的像素值范围是 0 到 255,文件里存的是整数。而神经网络训练时,输入值太大会导致梯度不稳定,所以必须归一化。ToTensor() 会把像素值除以 255,变成 0 到 1 之间的浮点数,同时把维度从 H×W 变成 C×H×W(通道在前),这是 PyTorch 的默认约定。
第二个是 Normalize((0.1307,), (0.3081,))。这组数据是 MNIST 全部图片像素的均值和标准差。归一化公式是:
[
x_{\text{norm}} = \frac{x - \mu}{\sigma}
]
处理之后,数据分布就近似成为均值为 0、方差为 1 的标准分布。这能让梯度下降更平稳,损失函数的等值线也不会那么“扁”。很多人图省事只做 ToTensor() 不做 Normalize(),训练也能跑,但收敛速度和稳定性会差一截。
如果你是手工用 numpy 实现网络,不经过 PyTorch 的 DataLoader,直接这样处理:
python复制from torchvision import datasets
train_dataset = datasets.MNIST(root='./data', train=True, download=True)
X_train = train_dataset.data.numpy() # (60000, 28, 28)
y_train = train_dataset.targets.numpy() # (60000,)
X_train = X_train.reshape(-1, 784) / 255.0 # 展平并归一化到 [0, 1]
y_train_onehot = np.eye(10)[y_train] # 转成独热编码
这里有个关键知识点就是独热编码。数字“3”不能直接作为数值 3 喂给网络,因为在数学上,输出层的 10 个节点分别代表 10 个类别的概率,模型需要看到“第 3 个位置是 1,其他位置是 0”的向量。你如果直接拿数字 3 当标签,模型会以为类别之间存在数值上的大小关系,这完全错误。
2.3 标签与图片对齐:一个复查的好习惯
数据准备阶段最隐蔽的坑是图片和标签错位。我多次遇到过训练准确率死活上不去,最后发现是数据切分时索引写错了。这里给你一个我一直在用的检查方法:每次加载完数据,随机挑几张图片,把像素矩阵和标签一并打印出来,肉眼验证一下。
python复制import matplotlib.pyplot as plt
idx = 42
plt.imshow(X_train[idx].reshape(28, 28), cmap='gray')
print("Label:", y_train[idx])
plt.show()
人眼核对一遍,比任何单元测试都直接。这个习惯让我在后续做 CIFAR-10、Fashion-MNIST 这些数据集时也少走了很多弯路。
3. 手撕一个三层神经网络:数学原理与代码实现
3.1 神经元、权重、激活函数:先建立直觉
在写代码之前,必须先理解网络里到底发生了什么事。一个神经元做的事情极其简单:把输入做加权求和,加上一个偏置,再套一个非线性激活函数。
[
z = w_1 x_1 + w_2 x_2 + \cdots + w_n x_n + b
]
[
a = f(z)
]
你可以把每个神经元想象成一个“投票员”。权重 (w) 表示它对输入信息的重要程度判断,偏置 (b) 表示它自身的倾向性。激活函数 (f) 的作用是引入非线性——没有它,不管叠加多少层,整个网络仍然是线性变换,再深也白搭。
在 MNIST 任务中,输入层有 784 个神经元,对应 28×28 的每一个像素;输出层有 10 个神经元,对应 0 到 9 的类别。中间加一个隐藏层,比如 128 个神经元,就构成了一个最基础的三层前馈神经网络。
激活函数的选择上,经典实现喜欢用 sigmoid:
[
\sigma(z) = \frac{1}{1 + e^{-z}}
]
它的输出被压缩到 0 到 1 之间,含义很直观,接近 1 表示“激活”,接近 0 表示“抑制”。但 sigmoid 有一个明显的缺点:在输入值很大或很小时,梯度会趋于 0,也就是梯度消失。实践中我更喜欢 ReLU:
[
\text{ReLU}(z) = \max(0, z)
]
它在正区间梯度恒为 1,极大缓解了梯度消失问题,计算也简单。如果你看完本文还想继续深入,ReLU 基本上是你在任何现代网络里都能见到的标配。
3.2 前向传播与反向传播的保姆级推导
前向传播很好理解:输入数据从输入层进,经过每一层的线性变换和激活函数,最后到达输出层。我们要关心的重点在输出层怎么变成一个“分类结果”。因为这是一个 10 分类问题,输出层最常见的做法是套一个 softmax:
[
\text{softmax}(z)i = \frac{e^{z_i}}{\sum^{10} e^{z_j}}
]
softmax 做的事情,是把 10 个原始分数转换成 10 个非负且和为 1 的概率值,这样我们就知道“模型认为这张图是数字 7 的概率是 80%”。
模型输出概率之后,要和真实标签比较。衡量差异的指标叫交叉熵损失:
[
L = -\sum_{i=1}^{10} y_i \log(p_i)
]
其中 (y_i) 是真实标签的独热编码,(p_i) 是模型预测的概率。直观理解:如果真实标签是 7,模型给 7 的概率是 0.95,损失就是 (-\log(0.95)),比较小;如果模型给 7 的概率只有 0.1,损失就是 (-\log(0.1)),很大。这样损失函数就衡量了模型预测的错误程度。
接下来是反向传播,也是新手最容易感觉抽象的部分。核心就一句话:利用链式法则,从损失函数出发,一层一层倒推每个参数的梯度,然后沿着梯度的反方向更新参数。
对于输出层,softmax 和交叉熵组合之后的梯度有一个非常简洁的形式:
[
\frac{\partial L}{\partial z_{\text{out}}} = p - y
]
也就是说,输出层的误差信号等于预测概率减去真实标签。比如真实标签是 7,模型预测是 7 的概率为 0.8,那么输出层第 7 个节点的误差就是 (0.8 - 1 = -0.2),其他节点误差就是它们的概率值本身。
隐藏层的误差要通过输出层的误差“传回来”:
[
\frac{\partial L}{\partial z_{\text{hidden}}} = (W_{\text{out}}^T \cdot \delta_{\text{out}}) \cdot f'(z_{\text{hidden}})
]
这里的 (\delta_{\text{out}}) 就是 (p - y)。隐藏层每个节点的误差,等于输出层误差通过权重反向传播的加权和,再乘以激活函数的导数。懂了这一步,反向传播的核心你已经掌握了,剩下的就是按公式更新参数:
[
W \leftarrow W - \eta \cdot \frac{\partial L}{\partial W}
]
[
b \leftarrow b - \eta \cdot \frac{\partial L}{\partial b}
]
这个 (\eta) 就是学习率,控制每一步更新的步幅。
3.3 完整 numpy 实现:不到 100 行跑通
理论说完了,直接上代码。这段是标准的三层网络,前向传播、反向传播、训练循环全部手写,没有用任何深度学习框架,只依赖 numpy。
python复制import numpy as np
def relu(x):
return np.maximum(0, x)
def relu_derivative(x):
return (x > 0).astype(float)
def softmax(x):
exp_x = np.exp(x - np.max(x, axis=1, keepdims=True)) # 防止溢出
return exp_x / np.sum(exp_x, axis=1, keepdims=True)
class NeuralNetwork:
def __init__(self, input_size=784, hidden_size=128, output_size=10, lr=0.1):
# 使用 He 初始化,避免随机权重过大或过小
self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size)
self.b1 = np.zeros((1, hidden_size))
self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size)
self.b2 = np.zeros((1, output_size))
self.lr = lr
def forward(self, X):
self.z1 = X.dot(self.W1) + self.b1
self.a1 = relu(self.z1)
self.z2 = self.a1.dot(self.W2) + self.b2
self.a2 = softmax(self.z2)
return self.a2
def backward(self, X, y_onehot):
m = X.shape[0]
# 输出层梯度:softmax + 交叉熵的简化形式
dz2 = self.a2 - y_onehot
# 隐藏层梯度
dW2 = self.a1.T.dot(dz2) / m
db2 = np.sum(dz2, axis=0, keepdims=True) / m
da1 = dz2.dot(self.W2.T)
dz1 = da1 * relu_derivative(self.z1)
dW1 = X.T.dot(dz1) / m
db1 = np.sum(dz1, axis=0, keepdims=True) / m
# 更新参数
self.W2 -= self.lr * dW2
self.b2 -= self.lr * db2
self.W1 -= self.lr * dW1
self.b1 -= self.lr * db1
def train(self, X, y_onehot, epochs=20, batch_size=64):
n = X.shape[0]
for epoch in range(epochs):
# 每个 epoch 打乱数据顺序
indices = np.random.permutation(n)
total_loss = 0
for i in range(0, n, batch_size):
batch_idx = indices[i:i + batch_size]
X_batch = X[batch_idx]
y_batch = y_onehot[batch_idx]
output = self.forward(X_batch)
loss = -np.sum(y_batch * np.log(output + 1e-8)) / len(X_batch)
total_loss += loss
self.backward(X_batch, y_batch)
print(f"Epoch {epoch + 1}/{epochs}, Loss: {total_loss / (n // batch_size):.4f}")
def predict(self, X):
probs = self.forward(X)
return np.argmax(probs, axis=1)
def accuracy(self, X, y):
preds = self.predict(X)
return np.mean(preds == y)
训练代码:
python复制from torchvision import datasets
train_dataset = datasets.MNIST(root='./data', train=True, download=True)
test_dataset = datasets.MNIST(root='./data', train=False, download=True)
X_train = train_dataset.data.numpy().reshape(-1, 784) / 255.0
y_train = train_dataset.targets.numpy()
X_test = test_dataset.data.numpy().reshape(-1, 784) / 255.0
y_test = test_dataset.targets.numpy()
y_train_onehot = np.eye(10)[y_train]
model = NeuralNetwork(input_size=784, hidden_size=128, output_size=10, lr=0.1)
model.train(X_train, y_train_onehot, epochs=20, batch_size=64)
train_acc = model.accuracy(X_train, y_train)
test_acc = model.accuracy(X_test, y_test)
print(f"Train Accuracy: {train_acc * 100:.2f}%")
print(f"Test Accuracy: {test_acc * 100:.2f}%")
这段代码在我的机器上跑 20 个 epoch,测试集准确率大约在 96% 到 97% 之间。作为一个只有一层隐藏层、没有卷积、没有框架的“原始”网络,这个结果已经很能说明问题了。
3.4 为什么不直接用现成框架,非要手写
你可能想问:现在 PyTorch、TensorFlow 这么成熟,谁还手写反向传播?我的观点是:手写一次的最大价值,不是让你以后不用框架,而是让你以后用框架时不慌。
等你用 PyTorch 写 loss.backward() 的时候,如果你知道这一行背后计算的是每一层的梯度、知道梯度乘以学习率之后更新的是权重,那你对模型的掌控力是完全不同的。出 bug 的时候,别人只会对着报错信息干瞪眼,你能直接判断是哪一层的维度对不上、哪一步梯度可能爆炸了。
而且手写代码能让你真正理解为什么框架里要分 train() 和 eval() 模式、为什么要有 optimizer.zero_grad(),这些都不是魔法,而是你亲自动手实现过的逻辑。
4. 训练那些坑:学习率、损失不降与过拟合的排查链路
4.1 Loss 变成 NaN 或者完全不动
我在实现过程中遇到的第一个大坑,是损失函数变成 NaN。排查下来,问题出在 softmax 的数值溢出上:当输出层的分数 (z_i) 很大的时候,指数函数 (e^{z_i}) 会爆炸到无穷大,导致计算结果溢出。
解决办法是在 softmax 之前减去最大值:
python复制def softmax(x):
exp_x = np.exp(x - np.max(x, axis=1, keepdims=True))
return exp_x / np.sum(exp_x, axis=1, keepdims=True)
因为 softmax 分子分母同时减去同一个常数,结果不变,但数值稳定性好非常多。这个细节在理论上教科书根本不会提,到了实践中能坑死你。
还有另一个可能:学习率设置太大。如果损失在几个 epoch 内不降反升、甚至直接 NaN,第一件事就是调小学习率。我见过有人把学习率设成 1.0,结果梯度更新一步跨过整个山谷,损失直接飞上天。从 0.1 开始通常是一个不错的起点。
4.2 训练集准确率高、测试集准确率低的“空心”现象
这是新手最常遇到的第二个坑。训练集准确率能到 99%,测试集却只有 90% 出头,典型的过拟合特征。模型把训练样本背了下来,但遇到新数据就抓瞎。
在 MNIST 上用单隐藏层网络,过拟合通常还不算严重,但如果顽固地出现,有几个排查方向:
- 检查是否做了数据归一化。如果直接拿原始像素值 0~255 去训练,网络会花很多力气去适应不同量级,很容易出现过拟合或收敛困难。
- 增加训练数据。MNIST 本身够大了,但你可以做简单的数据增强:随机平移几个像素、随机旋转一个角度,让模型看到更多变体。
- 调整网络结构。隐藏层神经元太多,模型容量过大容易过拟合。我的经验是,784→128→10 的规模在 MNIST 上已经足够,不需要盲目加大。
4.3 如何判断模型是“笨”还是“坏”
训练过程中如果准确率一直上不去,不要急着改代码,先做一个简单定位:模型在训练集上的表现怎么样?
如果训练集上准确率就很低,说明模型的拟合能力不够,可能是网络太浅、隐藏层太少、学习率不合适。如果训练集上准确率高、测试集上明显下滑,说明模型泛化能力差,优先考虑过拟合问题。
这个判断方法是我这些年调模型的通用落点。模型的“笨”和“坏”是两类不同的问题,处理方式完全不同。
4.4 batch size 对训练效果的影响
训练时我用了 batch size 64,也就是每次从数据里随机抽 64 张图片计算梯度、更新一次参数。为什么不一次把 6 万张全部算完再更新?全量梯度计算一次需要几秒钟,且计算出来的梯度方向可能被某些异常样本支配。而小批量梯度下降(mini-batch)每次用一小批数据估算梯度,虽然方向有噪声,但更新频率快,整体收敛效率反而更高,而且噪声在一定程度上还能帮助跳出局部最优。
batch size 太小(比如 1)会让梯度噪声很大,训练震荡剧烈;太大(比如 1024)会让每个 step 的计算量变大,训练也会变慢。64 到 256 之间是 MNIST 上比较常用的区间。
5. 用 PyTorch 重写一遍:少写代码,多思考问题
5.1 从手工造轮子到框架思维
手写 numpy 版本的意义在于搞懂原理,但真正做项目的时候,用 PyTorch 效率会高得多。框架帮你把自动求导、批量计算、GPU 加速、各种优化器和网络层封装好了,你可以把精力集中在问题本身。
同一个网络用 PyTorch 实现,代码量大概只有手工版本的三分之一:
python复制import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = datasets.MNIST(root='./data', train=True, transform=transform, download=True)
test_dataset = datasets.MNIST(root='./data', train=False, transform=transform, download=True)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Flatten(),
nn.Linear(28 * 28, 128),
nn.ReLU(),
nn.Linear(128, 10)
)
def forward(self, x):
return self.net(x)
model = MLP()
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.1)
for epoch in range(10):
model.train()
total_loss = 0
for images, labels in train_loader:
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item() * images.size(0)
# 每个 epoch 结束后在测试集上评估
model.eval()
correct = 0
total = 0
with torch.no_grad():
for images, labels in test_loader:
outputs = model(images)
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
avg_loss = total_loss / len(train_dataset)
accuracy = correct / total * 100
print(f"Epoch {epoch + 1}, Loss: {avg_loss:.4f}, Test Accuracy: {accuracy:.2f}%")
这里有几个 PyTorch 特有的细节跟你说明:
nn.CrossEntropyLoss()内部已经包含了 softmax 和交叉熵计算,所以网络最后一层不需要手动套 softmax。很多人初学时在这里重复套了 softmax,导致训练出问题却找不到原因。optimizer.zero_grad()必须在每个 step 前清空梯度。否则 PyTorch 默认会累加梯度,上一个 batch 的梯度会残留在参数上。model.train()和model.eval()切换模式。虽然这个简单的 MLP 里没有 dropout、batchnorm 这类在 train 和 eval 模式下行为不同的层,但养成这个习惯很重要,后面用 CNN 或者更大的模型就不会踩坑。
训练 10 个 epoch,PyTorch 版本在测试集上大约能达到 97% 到 98% 的准确率,比手写 numpy 版本略高一点。原因在于框架的数值稳定性和优化器细节处理更好,另外学习率调度之类的机制也更容易加上去。
5.2 手工版本与框架版本的对比:不只是代码量
| 维度 | numpy 手写版 | PyTorch 版 |
|---|---|---|
| 反向传播 | 手动用链式法则推导 | 自动求导 |
| 代码量 | 约 100 行 | 约 40 行 |
| 测试准确率 | 96%~97% | 97%~98% |
| 扩展性 | 加一层网络都需要改 propagate 和 backward | 加几层只是多写几行 nn.Linear |
| 适合场景 | 理解原理、教学演示 | 实际项目开发、快速验证 |
我建议你两条路都走。先用 numpy 手写,把每个公式和代码对应上;再用 PyTorch 重写一遍,体验一次框架给你带来的效率提升。两个版本都跑通了,你就完成了从原理到工程的完整闭环。
6. 从 95% 到 99%:优化方向与进阶思路
6.1 为什么说简单网络有上限
手写一个三层前馈网络,准确率做到 96% 左右就到瓶颈了。原因在于全连接网络对图像的处理方式有天然缺陷:它把 28×28 的像素直接拉平成一维向量,丢失了像素之间的空间结构关系。
人眼识别“7”的时候,看到的是横线和斜线的组合,以及它们在空间上的相对位置。而全连接网络看到的只是 784 个互不关联的数字。它能发现一些统计规律,但很难高效地建模局部特征。
所以从 96% 往 99% 走,关键不是继续加宽隐藏层,而是让网络真正理解图像的空间结构。
6.2 卷积神经网络:为图像而生的结构
卷积神经网络(CNN)的核心思想,是用一个小的卷积核在图像上滑动,提取局部特征。你看数字“1”的时候,可能只需要关注竖直的笔画;看“0”的时候,需要关注一圈闭合的曲线。卷积层能自动学习这些特征。
经典 LeNet-5 在 MNIST 上的结构大致是这样:
- 输入:1×28×28 的灰度图
- 第一个卷积层:6 个 5×5 卷积核,输出 6 个特征图
- 平均池化层:2×2 下采样
- 第二个卷积层:16 个 5×5 卷积核
- 平均池化层:2×2 下采样
- 全连接层:120 → 84 → 10
这个几十年前提出的网络,在 MNIST 上能达到约 99% 的准确率。现代实现再用上 ReLU、Dropout、数据增强,99.5% 也不意外。
PyTorch 实现 CNN 版本其实非常简单,就是把全连接网络里的 nn.Linear 换成 nn.Conv2d 和 nn.MaxPool2d 的组合:
python复制import torch.nn as nn
class CNN(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(1, 32, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2, 2),
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2, 2)
)
self.fc = nn.Sequential(
nn.Flatten(),
nn.Linear(64 * 7 * 7, 128),
nn.ReLU(),
nn.Linear(128, 10)
)
def forward(self, x):
return self.fc(self.conv(x))
这里的 7×7 是因为 28×28 的输入经过两次 2×2 池化后,尺寸变成 28/2/2=7。
6.3 数据增强:让模型看到更多变化
在训练的时候,可以对图片做随机平移、旋转、缩放。这不改变图片的语义,但让模型见过更多“没穿鞋的样子”,从而提高泛化能力。PyTorch 的 transforms 模块天然支持:
python复制train_transform = transforms.Compose([
transforms.RandomRotation(10),
transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)),
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
注意数据增强只加在训练集上,测试集只做归一化,否则就相当于把测试结果“作弊”了。
6.4 从 MNIST 毕业之后还能往哪走
跑通了 MNIST,接下来有两条比较自然的进阶路线。
一条是往更真实的数据集走。Fashion-MNIST 和 MNIST 尺寸相同、格式相同,但内容是衣服、鞋、包这些商品图片,分类难度更大,模型必须学到更有区分度的特征。CIFAR-10 则是 32×32 的彩色图,类别里包含猫、狗、飞机,空间信息更复杂,CNN 的优势在这里才真正体现。
另一条是往实际应用走。手写数字识别看起来简单,但在银行支票识别、快递单号识别、考卷判分这些场景中,真实问题往往伴随着复杂背景、光照不均、字迹潦草等问题,比 MNIST 数据要难得多。你可以试试自己用手机拍几张手写数字,用训练好的模型去识别,你会发现分布偏移的问题立刻就会暴露出来,这也是很多模型从比赛数据搬到真实场景准确率骤降的原因。
根据我个人的经验,能把 MNIST 从头到尾吃透并理解每一行代码的人,后续学习 CNN、RNN、注意力机制等更复杂模型时,会明显比直接调框架的人从容很多。建议你跑完代码之后,不要急着删项目,试着改一改隐藏层的大小、换一下激活函数、调一调学习率,记录下每次改动对准确率的影响。这些实验记录比任何教程都能让你更快成长。
