我先把话说在前头:这篇不是那种“复制粘贴就能跑”的仓库式教程,也不是把几十行代码堆出来就完事。我会从零开始,把“全连接网络”和“卷积网络”各自的原理、为什么这么设计、每一步代码在做什么,全部拆开揉碎,附上可以直接跑的完整代码和逐行注释。如果你是一点基础都没有的纯新手,跟着敲一遍,理解每一行在干什么,比你去刷十遍理论课都管用。
我先交代一下这篇的核心内容,让你有个预期:用PyTorch实现一个全连接网络在MNIST手写数字数据集上做分类,再实现一个卷积网络在同一数据集上做分类。两个任务目标相同,但网络结构完全不同,正好用来对比“全连接”和“卷积”的本质差异。MNIST大家都很熟了,是60年前的经典数据集,但正因为经典、简单,它才适合当作第一个自己动手写的神经网络项目。
我会把代码按照“数据准备 → 网络定义 → 训练循环 → 评估测试”这条固定链路来组织。因为不管以后你写多复杂的模型,也不管是CV、NLP还是推荐系统,只要是用PyTorch训练模型,这条链路都是通用的。学会一条链路,你能迁移到百分之八九十的场景里。
1. 动手之前:环境准备与三个核心概念速览
1.1 环境配置:半小时内搞定
先检查环境。我没有用太新的版本,都是目前稳定且兼容性好的组合:
- Python 3.9+
- PyTorch 2.0+(CPU版即可,新手不用急着上GPU)
- torchvision(用来加载MNIST数据集)
- matplotlib(画损失曲线和查看预测结果)
- Jupyter Notebook 或者 VS Code + Python插件
CPU版本足够跑MNIST,全连接网络和这个规模的卷积网络在CPU上训练几分钟到十几分钟就完事。不要一开始就折腾CUDA版本,纯新手在GPU环境上花两小时配环境,还没写代码就先被劝退了。
安装命令:
pip install torch torchvision matplotlib如果你有conda环境,用conda安装更省心:
conda install pytorch torchvision matplotlib -c pytorch验证是否装好:
import torch print(torch.__version__) print(torch.cuda.is_available()) # CPU版会输出False,正常能打印出版本号就说明环境OK。
1.2 三个你绕不开的概念:张量、自动求导、损失函数
在动手写代码之前,有三个基础概念必须先讲明白,不然你看代码会一脸懵。
第一个是张量(Tensor)。你可以简单粗暴地把它理解成“可以放到GPU上做并行计算的多维数组”。标量是0维张量,向量是1维张量,矩阵是2维张量,而图像这种有宽、高、颜色通道的,就是3维或者4维张量。PyTorch里的Tensor和NumPy的ndarray长得几乎一样,但Tensor多了两个关键能力:一是自动记录计算图,二是可以通过.cuda()方法一键搬上GPU。
第二个是自动求导(Autograd)。这是PyTorch最核心的机制。你定义好网络结构,把数据传进去算出损失值,然后只要调用loss.backward(),PyTorch就会自动从损失值出发,沿着计算图反向传播,把每个参数要更新的梯度算出来。你不用手动推导链式法则,也不用自己实现反向传播代码,框架全给你干完了。这是现代深度学习框架能普及的根本原因,因为反向传播的数学推导和实现非常繁琐,手写特别容易出错。
第三个是损失函数(Loss Function)。损失函数用来衡量“模型当前预测的结果和真实答案之间差了多少”。分类任务最常用的就是CrossEntropyLoss(交叉熵损失)。你不需要知道它的数学公式长什么样,但你要理解一个直观的道理:损失值越小,说明预测越接近真实答案,训练的目标就是不断让损失值变小。
1.3 训练一个模型,本质是在干什么
把上面三个概念串起来,训练过程就一句话:反复执行“前向传播 → 算损失 → 反向传播 → 更新权重”。
- 前向传播:把一批数据放进网络,得到预测结果。
- 算损失:拿预测结果和真实标签对比,算出一个数值。
- 反向传播:根据损失值,算出每个权重应该往哪个方向调。
- 更新权重:按照梯度方向小幅调整权重,让损失变小一点。
整个过程就像你蒙着眼睛下山,每一步都踩一下地面感受坡度,往坡下走一步。走多了,就走到山底了。神经网络训练里的“山底”就是损失函数的极小值点。
2. 全连接网络:不借助任何预训练模型,手写一个能识别手写数字的网络
2.1 数据准备:MNIST数据集是怎么进到网络里的
MNIST是一个手写数字图片集,每张图是28×28像素的灰度图,代表数字0到9。总共有6万张训练图,1万张测试图。
用torchvision.datasets.MNIST加载它会非常方便,但默认下载的数据只是原始图片,不能直接喂给网络。需要做两件事:
第一步是转成Tensor并归一化。图片原始数据是0到255的整数像素值,如果不归一化,数值范围太大,会导致梯度更新不稳定。所以要把像素值缩放到0到1之间:像素值除以255。
from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), # 转成Tensor,并自动把像素值从[0,255]缩放到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # 标准化的两个数来自MNIST数据集的均值和方法 ]) train_dataset = datasets.MNIST( root='./data', # 下载保存位置 train=True, # 加载训练集 transform=transform, download=True ) test_dataset = datasets.MNIST( root='./data', train=False, transform=transform, download=True )这里有个新手特别容易忽略的细节:Normalize操作用了均值0.1307和标准差0.3081,这两个数字是MNIST数据集整体像素的统计值。网上很多教程直接写了这两个数,但没解释为什么。简单来说,做完Normalize之后,所有像素值的分布会变成标准正态分布(均值接近0,方差接近1),这样梯度更新会更稳定,收敛更快。
第二步是用DataLoader分批加载。神经网络的训练不是一次把所有数据都扔进去,那样内存吃不消,而是每批取一小部分。这一小部分数据叫一个batch(批次)。
from torch.utils.data import DataLoader batch_size = 64 train_loader = DataLoader( dataset=train_dataset, batch_size=batch_size, shuffle=True # 每个epoch打乱数据顺序,防止模型记住顺序 ) test_loader = DataLoader( dataset=test_dataset, batch_size=batch_size, shuffle=False # 测试时不需要打乱 )shuffle=True意味着每个epoch(完整遍历一遍所有训练数据)数据都会被打乱一次,这样能避免模型学到“第几个样本是数字几”这种无意义的顺序信息。
2.2 定义全连接网络:三个核心组件吃透
全连接网络(Fully Connected Network),也叫多层感知机(MLP),它的核心思想非常朴素:把上一层的每个神经元和下一层的每个神经元都连起来,每条连接上有一个权重。
对MNIST来说,一张图片是28×28=784个像素点。我们把784个像素值展平成一个一维向量,作为网络的输入。
网络结构我设计成了三层的:
输入层:784个神经元(每个神经元对应一个像素点) 隐藏层1:128个神经元,激活函数ReLU 隐藏层2:64个神经元,激活函数ReLU 输出层:10个神经元(对应0-9十个数字)为什么中间要加隐藏层?因为如果不加隐藏层,就变成输入直接连输出,那是线性模型,学不了复杂特征。加了隐藏层之后,模型才有能力学习输入和输出之间的非线性关系。
为什么激活函数用ReLU?ReLU的函数形式是f(x)=max(0,x),简单到极致,但效果很好。它有两个关键优势:一是计算快;二是在正区间梯度恒等于1,不容易出现梯度消失问题。早期常用的sigmoid函数在输入很大或很小时梯度接近0,深层网络训练时梯度会衰减消失,导致前面几层参数几乎更新不了。
用nn.Sequential来搭建网络是新手最直观的方式,一层一层往里填就行:
import torch.nn as nn import torch.nn.functional as F class FullyConnectedNet(nn.Module): def __init__(self): super().__init__() self.network = nn.Sequential( nn.Flatten(), # 把(64, 1, 28, 28)展平成(64, 784) nn.Linear(784, 128), # 全连接层:784个输入 -> 128个输出 nn.ReLU(), # 激活函数 nn.Linear(128, 64), # 全连接层:128 -> 64 nn.ReLU(), nn.Linear(64, 10) # 输出层:64 -> 10 ) def forward(self, x): return self.network(x)这里重点解释一下nn.Linear(784, 128)到底做了什么。它内部有一个权重矩阵W,形状是(128, 784),以及一个偏置向量b,形状是(128,)。当输入向量x(长度784)经过这层时,输出y = Wx + b,得到一个长度为128的向量。每个输出位置的值,是所有784个输入值乘以对应的权重再加偏置的结果,所以叫“全连接”。
nn.Flatten()的作用是把一个形状为(64, 1, 28, 28)的四维张量(64张图,1个颜色通道,宽28高28)压平成(64, 784)。这个操作不含任何参数,就是把三维像素矩阵按顺序拉直成一维。
2.3 训练循环:让网络学会“猜数字”
网络定义好之后,还需要三样东西:损失函数、优化器、训练循环。
损失函数我用nn.CrossEntropyLoss(),它内部帮我们做了两件事:先对输出做softmax,把10个数字得分转成概率分布,再计算预测概率和真实标签之间的交叉熵。新手最容易踩的坑是把nn.CrossEntropyLoss()和nn.LogSoftmax叠加在一起用,导致损失计算结果不对。实际用CrossEntropyLoss时,网络最后一层不需要加softmax激活函数,因为损失函数内部已经处理了。
优化器我选择了Adam。初学者不用陷入优化器的对比纠结中:SGD是经典,但需要手动调节学习率,对新手不友好;Adam自带自适应学习率机制,基本不需要太多调参就能收敛得很好。
import torch.optim as optim model = FullyConnectedNet() criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001)完整训练循环:
def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() # 切换到训练模式,这会影响dropout和batchnorm的行为 total_loss = 0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) # 关键:每次迭代前一定要把上次计算的梯度清零 optimizer.zero_grad() # 1. 前向传播:把图片输入网络,得到预测 outputs = model(images) # 2. 计算损失 loss = criterion(outputs, labels) # 3. 反向传播:计算每个参数的梯度 loss.backward() # 4. 更新参数:沿着负梯度方向走一小步 optimizer.step() # 统计损失和准确率 total_loss += loss.item() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() avg_loss = total_loss / len(train_loader) accuracy = 100.0 * correct / total return avg_loss, accuracy这一段是整个训练流程的核心,我强烈建议你反复看,直到闭着眼都能写出来。这四个步骤的顺序千万不能乱:先zero_grad()清零梯度,再前向传播,然后算损失,接着backward()反向传播,最后step()更新参数。
为什么每次都要清零梯度?因为PyTorch设计上梯度是累积的。如果你不清零,那么下一次backward()时梯度会加上上一次的梯度,导致梯度越来越大,参数更新错乱。
训练多个epoch,用一个简单的外部循环:
epochs = 5 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) for epoch in range(epochs): avg_loss, acc = train_one_epoch(model, train_loader, criterion, optimizer, device) print(f'Epoch {epoch+1}/{epochs}, Loss: {avg_loss:.4f}, Accuracy: {acc:.2f}%')跑5个epoch之后,全连接网络在MNIST测试集上能达到97%-98%的准确率。这时候你可能会觉得:“深度学习就这?好像也不难嘛。”但别急,全连接网络只是个热身。
2.4 测试评估:模型到底“学会”了什么
def evaluate(model, test_loader, device): model.eval() # 切换到评估模式 correct = 0 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() accuracy = 100.0 * correct / total print(f'Test Accuracy: {accuracy:.2f}%') return accuracy注意这里有个关键细节:model.eval()和with torch.no_grad():这两行缺一不可。model.eval()告诉网络当前是评估阶段,某些层(比如dropout、batchnorm)的行为会和训练时不同;torch.no_grad()则关闭了自动求导机制,这样不会构建计算图,省内存还加快推理速度。
3. 卷积网络:为什么它比全连接网络更擅长处理图像
3.1 全连接网络处理图像的致命弱点
你可能会有个疑问:MNIST全连接网络准确率已经97%了,为什么还要搞卷积网络?
有两个原因。
第一个原因是参数量暴增。我们用的是28×28的小图,784个输入神经元,网络很小。但如果是一张256×256的彩色图片,输入就是256×256×3=196608个像素。第一个全连接层若输出128个神经元,这个层的权重就有196608×128≈2500万个参数。一张图就要2500万个参数,哪个机器都扛不住。
第二个原因是丢失了空间结构。把图片展平成784个像素的一维向量,等于把图片的空间关系全拆散了。原来“相邻像素一起构成一个图案边缘”、“像素在空间上聚集形成纹理”这些关键信息,在展平那一刻就全丢了。全连接网络“看见”的只是一串没有任何空间含义的数字序列,它根本不知道相邻像素之间有什么关系。
卷积网络(CNN)就是专门为图像设计的。它的核心思路是:用一个小窗口(卷积核)在图像上滑动,每次只关注局部区域的像素,自动提取局部特征。
3.2 卷积层在做什么:一个小窗口看遍全图
想象你拿着一枚放大镜,在图片上从左到右、从上到下地移动。每次放大镜只能看到图片上的一小片区域,你集中注意力观察这片区域里的图案特征,然后移动到下一个位置继续观察。这个放大镜就是卷积核。
具体来说,一个卷积核是一个小矩阵(比如3×3),里面存着一组权重。它在输入图像上滑动时,每次覆盖一个同样大小的小区域,把小区域内的像素值和卷积核的权重对应相乘再相加,得到一个输出值。这个值反映了“这个小区域和这个卷积核所代表的图案有多像”。
不同卷积核负责识别不同特征:有的卷积核专门检测横向边缘,有的专门检测竖向边缘,有的检测圆点,有的检测斜线。在一层卷积里,我们通常会设置多个卷积核(也称通道),让网络同时从多个维度提取特征。
听着抽象,但PyTorch代码很简单:
nn.Conv2d( in_channels=1, # 输入通道数。灰度图是1,彩色RGB图是3 out_channels=16, # 输出通道数,即用了16个卷积核 kernel_size=3, # 卷积核大小,3x3 padding=1 # 图像四周补一圈0,保持输出尺寸不变 )这里有个必须掌握的公式,是用来计算卷积层输出尺寸的:
输出尺寸 = (输入尺寸 - 卷积核大小 + 2 × padding) / stride + 1
其中stride是卷积核每次移动的步长。对28×28的输入,kernel_size=3,padding=1,stride=1时:
输出尺寸 = (28 - 3 + 2×1) / 1 + 1 = 28
输出还是28×28,说明padding=1保证输出尺寸不变。这个公式你以后会反复用到,建议抄在笔记本上。
3.3 池化层:降维打击,提取主要特征
卷积层提完特征之后,还需要池化层(Pooling)来压缩数据。池化操作相当于把图像按比例缩小:比如一个2×2的池化窗口,把窗口里四个像素取最大值(最大池化)或平均值(平均池化),然后移动到下一个位置。
最大池化是最常用的。它的直观作用是在一个局部区域内保留最显著的特征,丢掉次要信息。这不仅减少了后续层的参数量,更关键的是让模型对特征的微小位置移动不敏感:同一个物体稍微偏了几个像素,池化后输出的最大值大概率不变,模型依然能认出它。
nn.MaxPool2d(kernel_size=2, stride=2)这个操作把宽高都缩小一半。28×28的图像经过一次2×2最大池化后变成14×14。
3.4 完整的卷积网络实现:卷积+池化+全连接的经典组合
现代卷积网络的基本范式是:先几层“卷积+激活+池化”的组合自动提取特征,然后把提取到的特征图展平,接上全连接层做最终分类。
我设计的网络结构如下:
输入:(1, 28, 28) → 卷积层1:16个3×3卷积核,输出(16, 28, 28) → ReLU激活 → 最大池化:2×2,输出(16, 14, 14) → 卷积层2:32个3×3卷积核,输出(32, 14, 14) → ReLU激活 → 最大池化:2×2,输出(32, 7, 7) → 展平:32×7×7=1568 → 全连接层:1568 -> 128 → ReLU激活 → 全连接层:128 -> 10完整代码:
class ConvNet(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1) # 输入1通道,输出16通道 self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1) # 16通道 -> 32通道 self.pool = nn.MaxPool2d(2, 2) # 2x2最大池化 self.fc1 = nn.Linear(32 * 7 * 7, 128) # 全连接层 self.fc2 = nn.Linear(128, 10) # 输出层 def forward(self, x): # 第一组:卷积 -> 激活 -> 池化 x = self.pool(F.relu(self.conv1(x))) # 第二组:卷积 -> 激活 -> 池化 x = self.pool(F.relu(self.conv2(x))) # 展平:把三维特征图变成一维向量 x = x.view(-1, 32 * 7 * 7) # 全连接层 x = F.relu(self.fc1(x)) x = self.fc2(x) return x我用F.relu(...)而非nn.ReLU(),是因为当激活函数没有额外参数时,直接用函数式接口(torch.nn.functional)更简洁。两种写法本质一样,你选一种固定习惯即可。
这里的训练循环和全连接网络完全一样,不用改任何代码,直接复用上一节的train_one_epoch和evaluate函数。把模型替换成ConvNet()就能运行。
关键问题来了:最后一个全连接层的输入维度为什么是32 * 7 * 7?
我们从输入开始推一遍。输入是28×28,经过第一层卷积(padding=1,kernel_size=3,stride=1),输出尺寸计算公式是(28 - 3 + 2×1)/1 + 1 = 28,所以输出是(16, 28, 28)。接着最大池化2×2,宽高减半,变成(16, 14, 14)。经过第二层卷积,尺寸不变,还是14×14,通道变成32,即(32, 14, 14)。再经过一次池化,变成(32, 7, 7)。所以展平后是32×7×7=1568个特征值。
这个推导过程非常关键,如果你在定义nn.Linear时把输入维度写错了,运行时会直接报维度不匹配错误。这也是新手经常卡住的问题,建议你拿到任何一张图,都手动推一遍尺寸变化。
3.5 为什么卷积网络准确率更高
在MNIST上,卷积网络通常能到99%以上的准确率,比全连接网络高1-2个百分点。注意,这1-2个百分点很关键,因为MNIST测试集里多的是非常相似、人眼都容易看错的手写数字。卷积网络因为能用局部感受野抓取笔画特征和边缘结构,所以对“长得很像的图”区分能力更强。
当然,如果你只是对比测试集准确率,全连接网络的97%和卷积网络的99%,差距看起来并不悬殊。这是因为MNIST实在太简单了。你可以想象,如果换成CIFAR-10这种彩色物体识别数据集(分类猫、狗、飞机、汽车),全连接网络准确率会掉到50%左右,而卷积网络能到80%以上。在小数据集上验证网络结构差异,再迁移到复杂场景,这是研究模型的正确姿势。
4. 训练过程中那些必须知道的坑与调试手段
4.1 损失不降?八成是学习率出了问题
你自己写代码训练的时候,最常遇到的现象是:损失值始终挂在某个值附近,死活降不下去,准确率也一直趴在10%左右(相当于瞎猜)。MNIST一共10个数字,瞎猜准确率就是10%。
多数情况下,这是学习率设置不合理导致的。学习率太大,参数更新幅度过大,在损失曲面上来回震荡,永远落不到谷底;学习率太小,参数更新龟速,训练了十几轮损失才动一点点。
我的经验是:先用lr=0.001开局,如果损失下降太慢(比如每轮只降0.01),就调到0.01试试;如果损失振荡剧烈(loss忽高忽低像过山车),就降到0.0001。新手用Adam优化器的话,0.001通常是个非常稳定的起点。
4.2 过拟合:训练集准,测试集不准
另一个高频坑是:训练集准确率99%,测试集准确率只有85%左右,这种情况就是过拟合——模型过度“背诵”了训练集的内容,丧失了泛化能力。
全连接网络在MNIST上特别容易过拟合,因为全连接层的参数太多了,模型容量远远超过数据集本身的复杂度。缓解过拟合最直接的办法有三个:
- 增加数据量(比如做数据的随机旋转、平移、缩放,即数据增强)
- 在卷积层和全连接层之间加
nn.Dropout(p=0.5),让训练时随机让一半神经元失活,迫使网络学到更鲁棒的特征 - 减小模型容量(比如把隐藏层神经元从128改成64)
要特别记住的是:在PyTorch中,Dropout层在model.train()时才生效,在model.eval()时自动失效。这个机制由前面提到的model.train()与model.eval()切换来控制。
4.3 可视化输出:看你每一步都在做什么
新手写模型时最痛苦的事情是“对着代码发呆,不知道网络内部到底发生了什么”。我的建议是,每一轮epoch结束后,打印损失值、准确率,并从中取出几个批次的数据做可视化。
我来写个简单的可视化代码,把每个批次中的前几张图片画出来:
import matplotlib.pyplot as plt def show_images(images, labels, predictions): fig, axes = plt.subplots(2, 5, figsize=(12, 6)) axes = axes.flatten() for i in range(10): img = images[i].squeeze().numpy() axes[i].imshow(img, cmap='gray') axes[i].set_title(f'标签: {labels[i].item()}, 预测: {predictions[i].item()}') axes[i].axis('off') plt.tight_layout() plt.show()看看预测错的是哪几张图,你会惊奇地发现,大部分错误来自那些本身就“写得极不规范”的字:歪歪扭扭的7被认成1,封口不严的8被认成3。模型犯错模式和人类相似,这本身就是一种很有意思的观察。
4.4 让PyTorch设备选择变聪明一点
把设备选择写成一个统一的工具函数,省得每篇代码里重复粘贴:
def get_device(): if torch.cuda.is_available(): return torch.device('cuda') elif torch.backends.mps.is_available(): return torch.device('mps') # Apple Silicon芯片用这个 else: return torch.device('cpu')然后把所有训练和评估函数里的device参数都传进来,模型、数据都要.to(device)。这是很基础但很关键的工程习惯,因为项目和模型的训练迁移到不同设备时,改动成本最低。
5. 把代码整理成一份完整的可运行脚本
很多同学看教程喜欢一段段复制,最后没跑起来就是因为拼凑时漏了某段代码。我直接把完整脚本整理在下面,你复制到.py文件里,完全可以一次跑通。
import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, transforms # ------------------- 设备选择 ------------------- def get_device(): if torch.cuda.is_available(): return torch.device('cuda') elif torch.backends.mps.is_available(): return torch.device('mps') else: return torch.device('cpu') # ------------------- 数据准备 ------------------- def load_data(batch_size=64): 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=batch_size, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=batch_size, shuffle=False) return train_loader, test_loader # ------------------- 网络定义 ------------------- class FullyConnectedNet(nn.Module): def __init__(self): super().__init__() self.network = nn.Sequential( nn.Flatten(), nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 64), nn.ReLU(), nn.Linear(64, 10) ) def forward(self, x): return self.network(x) class ConvNet(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 16, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1) self.pool = nn.MaxPool2d(2, 2) self.fc1 = nn.Linear(32 * 7 * 7, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) x = self.pool(F.relu(self.conv2(x))) x = x.view(-1, 32 * 7 * 7) x = F.relu(self.fc1(x)) x = self.fc2(x) return x # ------------------- 训练与评估 ------------------- def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss = 0 correct = 0 total = 0 for images, labels in train_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() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() return total_loss / len(train_loader), 100.0 * correct / total def evaluate(model, test_loader, device): model.eval() correct = 0 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() return 100.0 * correct / total # ------------------- 主程序 ------------------- def main(): device = get_device() print(f'Using device: {device}') train_loader, test_loader = load_data(batch_size=64) for model_name, model in [('FullyConnectedNet', FullyConnectedNet()), ('ConvNet', ConvNet())]: print(f'\\nTraining {model_name}...') model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) for epoch in range(5): avg_loss, acc = train_one_epoch(model, train_loader, criterion, optimizer, device) print(f'Epoch {epoch+1}/5, Loss: {avg_loss:.4f}, Train Acc: {acc:.2f}%') test_acc = evaluate(model, test_loader, device) print(f'{model_name} Test Accuracy: {test_acc:.2f}%\\n') if __name__ == '__main__': main()这段脚本的模块化结构是刻意的。把“数据加载”、“网络定义”、“训练与评估”分成独立函数,是为了以后改造成新项目时,你只需要替换网络定义部分和数据加载部分,其他逻辑不用动。养成模块化习惯,比追求代码写得短小精悍重要得多。
6. 从MNIST出走:接下来你可以尝试什么
跑通上面的代码只是起点。我做了一件让我自己进步很大的事,这里分享给你。
第一步:改结构。不要满足于跑通,改改网络的层数、通道数、卷积核大小,观察准确率怎么变。我试过把卷积层的通道数从16加到32、64,MNIST准确率确实小幅上涨,但训练时间明显增加。改到一定程度,准确率就卡住不涨了,这就是模型容量的边际效应。
第二步:换数据。MNIST大小只有28×28,太规整了。接着可以试试Fashion-MNIST,它也是28×28的灰度图,但内容是衣服、鞋子、包包这些物品,分类难度更高一点。代码完全不用改,只改datasets.MNIST为datasets.FashionMNIST就能跑。你会看到同样的网络在Fashion-MNIST上准确率会掉不少,这就直观感受到不同数据集对模型能力的诉求是不同的。
第三步:加数据增强。对训练图做随机旋转、平移。MNIST里数字稍微旋转一点,人类很容易识别,但模型可能就认不出来了。用torchvision.transforms.RandomRotation在训练时随机旋转图片,能逼着模型学到旋转不变性,测试准确率会提升。这是投入产出比极高的实战进阶操作。
第四步:把测试图像也做成可视化混淆矩阵。统计模型把哪个数字多认成了哪个数字,比如“9被认成4”的次数最多,那说明模型对这两个数字的笔画特征区分不够。这一步会培养你从错误数据里找原因的能力,这项能力在真实项目里非常值钱。
你从零开始写的这两个网络,其实已经覆盖了现代深度学习模型最基本的部件:全连接层、激活函数、卷积层、池化层、损失函数、优化器、训练与评估流程。PyTorch里那些看似眼花缭乱的模型结构,无论是ResNet、Transformer还是Diffusion,底层无非是这些基础组件的不同组合。
我最后想说的是:在我带过的所有新人里,区分“学得会”和“学不会”的最大分界线,不是智商,而是有没有亲手把每一个组件写一遍、跑一遍、改一遍、看它报错、再修好它。代码跟着敲一遍,比任何课程都管用。