引子 你学了 PyTorch,懂了张量怎么算、梯度怎么传——但脑子里还是空的。
“学完了理论,我不知道怎么组合起来。”
这是我从后台看到最多的私信。所以这篇来了:一次完整的实战,从数据到模型到调优。
你能写出一个识别手写数字的程序,就说明你真正理解了深度学习的基础管线。
前置知识
看完这两篇再来,手感会好很多。
一、MNIST 数据集长什么样? MNIST 是计算机视觉界的”Hello World”。
训练集:60,000 张 28×28 灰度手写数字(0-9)
测试集:10,000 张
每个像素 0-255,白色背景、黑色笔迹
1 2 3 4 5 6 7 8 import torchimport torchvisionimport matplotlib.pyplot as plttrain_data = torchvision.datasets.MNIST(root='./data' , train=True , download=True ) print (f"形状: {train_data.data.shape} " ) print (f"标签: {train_data.targets[:10 ]} " )
如果这行代码跑不了,说明你缺 torchvision:
1 pip install torchvision matplotlib
二、方案一:全连接网络(基线) 2.1 数据加载 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import torchfrom torch import nnfrom torch.utils.data import DataLoaderfrom torchvision import datasets, transformsimport timetransform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307 ,), (0.3081 ,)) ]) train_dataset = datasets.MNIST('./data' , train=True , download=True , transform=transform) test_dataset = datasets.MNIST('./data' , train=False , download=True , transform=transform) train_loader = DataLoader(train_dataset, batch_size=64 , shuffle=True ) test_loader = DataLoader(test_dataset, batch_size=1000 , shuffle=False )
为什么用 Normalize? 原始像素值 0-255 方差很大,模型训练不稳定。归一化后均值为 0、方差为 1,梯度更新更平滑。
2.2 定义模型 1 2 3 4 5 6 7 8 9 10 11 12 13 14 class FCModel (nn.Module): """3 层全连接网络""" def __init__ (self ): super ().__init__() self .fc1 = nn.Linear(28 *28 , 128 ) self .fc2 = nn.Linear(128 , 64 ) self .fc3 = nn.Linear(64 , 10 ) def forward (self, x ): x = x.view(x.size(0 ), -1 ) x = torch.relu(self .fc1(x)) x = torch.relu(self .fc2(x)) return self .fc3(x)
2.3 训练函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 def train_one_epoch (model, loader, optimizer, criterion, device ): model.train() total_loss = 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len (loader) def evaluate (model, loader, device ): model.eval () correct = 0 total = 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max (outputs, 1 ) total += labels.size(0 ) correct += (predicted == labels).sum ().item() return correct / total
2.4 训练 1 2 3 4 5 6 7 8 9 10 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu' ) fc_model = FCModel().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(fc_model.parameters(), lr=0.001 ) epochs = 5 for epoch in range (1 , epochs + 1 ): loss = train_one_epoch(fc_model, train_loader, optimizer, criterion, device) acc = evaluate(fc_model, test_loader, device) print (f"Epoch {epoch} : loss={loss:.4 f} , test_acc={acc:.4 f} " )
预期输出:
1 2 3 4 5 Epoch 1: loss=0.3372, test_acc=0.9372 Epoch 2: loss=0.1623, test_acc=0.9584 Epoch 3: loss=0.1134, test_acc=0.9650 Epoch 4: loss=0.0855, test_acc=0.9694 Epoch 5: loss=0.0673, test_acc=0.9739
5 轮训练,测试准确率 97.4% 。已经不错了——但 CNN 能做得更好。
三、方案二:CNN(改进版) 全连接网络的缺点是丢掉了空间结构 ——28×28 的图片展平成 784 个独立像素。CNN 保留了 2D 结构。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class CNNModel (nn.Module): """2 层卷积 + 2 层全连接""" def __init__ (self ): super ().__init__() self .conv1 = nn.Conv2d(1 , 32 , kernel_size=3 , padding=1 ) self .conv2 = nn.Conv2d(32 , 64 , kernel_size=3 , padding=1 ) self .pool = nn.MaxPool2d(2 , 2 ) self .fc1 = nn.Linear(64 * 7 * 7 , 128 ) self .fc2 = nn.Linear(128 , 10 ) def forward (self, x ): x = self .pool(torch.relu(self .conv1(x))) x = self .pool(torch.relu(self .conv2(x))) x = x.view(x.size(0 ), -1 ) x = torch.relu(self .fc1(x)) return self .fc2(x)
一样的训练代码,唯一的区别是换模型:
1 2 3 4 5 6 7 cnn_model = CNNModel().to(device) optimizer = torch.optim.Adam(cnn_model.parameters(), lr=0.001 ) for epoch in range (1 , epochs + 1 ): loss = train_one_epoch(cnn_model, train_loader, optimizer, criterion, device) acc = evaluate(cnn_model, test_loader, device) print (f"Epoch {epoch} : loss={loss:.4 f} , test_acc={acc:.4 f} " )
预期输出:
1 2 3 4 5 Epoch 1: loss=0.2022, test_acc=0.9684 Epoch 2: loss=0.0631, test_acc=0.9832 Epoch 3: loss=0.0422, test_acc=0.9867 Epoch 4: loss=0.0296, test_acc=0.9902 Epoch 5: loss=0.0227, test_acc=0.9914
CNN 在 Epoch 1 就追上了全连接网络 5 轮的准确率,5 轮后 99.1%。
差出来的 1.7% 就是”保住空间结构”带来的差距。
四、对比与解读
指标
全连接网络
CNN
参数量
109,386
387,786
5 轮准确率
97.4%
99.1%
每轮训练时间(CPU)
~15s
~30s
每轮训练时间(GPU)
~3s
~5s
CNN 参数量虽然大,但在 GPU 上差距不大。对于图像类任务,CNN 是绝对首选。
为什么不是 100%? 99.1% 离 100% 差的那 0.9%,看这里——这些连人都认不出来:

99.1% 说明不是模型问题,是数据本身的噪声。 真实场景里,99% 精度已经足够部署。
五、完整代码(一键运行) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 """mnist_demo.py - 手写数字识别完整代码""" import torchfrom torch import nnfrom torch.utils.data import DataLoaderfrom torchvision import datasets, transformstransform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307 ,), (0.3081 ,)) ]) train_loader = DataLoader( datasets.MNIST('./data' , train=True , download=True , transform=transform), batch_size=64 , shuffle=True ) test_loader = DataLoader( datasets.MNIST('./data' , train=False , download=True , transform=transform), batch_size=1000 , shuffle=False ) class CNNModel (nn.Module): def __init__ (self ): super ().__init__() self .conv1 = nn.Conv2d(1 , 32 , 3 , padding=1 ) self .conv2 = nn.Conv2d(32 , 64 , 3 , padding=1 ) self .pool = nn.MaxPool2d(2 , 2 ) self .fc1 = nn.Linear(64 *7 *7 , 128 ) self .fc2 = nn.Linear(128 , 10 ) def forward (self, x ): x = self .pool(torch.relu(self .conv1(x))) x = self .pool(torch.relu(self .conv2(x))) x = x.view(x.size(0 ), -1 ) x = torch.relu(self .fc1(x)) return self .fc2(x) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu' ) model = CNNModel().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001 ) for epoch in range (1 , 6 ): model.train() total_loss = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() loss = criterion(model(images), labels) loss.backward() optimizer.step() total_loss += loss.item() model.eval () correct = total = 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = torch.max (outputs, 1 ) total += labels.size(0 ) correct += (predicted == labels).sum ().item() print (f"Epoch {epoch} : loss={total_loss/len (train_loader):.4 f} , acc={correct/total:.4 f} " )
保存为 mnist_demo.py,直接 python mnist_demo.py 就能跑。
总结 你从这篇学到了三件事:
全连接网络能做 MNIST,但 CNN 更好 (97.4% → 99.1%)
深度学习的关键管线 :DataLoader → Model → Loss → Optimizer → Loop
归一化和卷积核 这些看起来很小的细节,对结果影响巨大
下一步可以尝试:
增加 Dropout 和数据增强,看看能不能冲 99.5%
换 ResNet-18,看看超大网络有没有过拟合
用这篇的代码,试着识别你自己的手写数字
下一篇我们来挑战 CIFAR-10——真正的彩色图片分类。