news 2026/9/9 16:55:02

PyTorch实战:从零构建全连接与卷积网络识别MNIST手写数字

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch实战:从零构建全连接与卷积网络识别MNIST手写数字

我先把话说在前头:这篇不是那种“复制粘贴就能跑”的仓库式教程,也不是把几十行代码堆出来就完事。我会从零开始,把“全连接网络”和“卷积网络”各自的原理、为什么这么设计、每一步代码在做什么,全部拆开揉碎,附上可以直接跑的完整代码和逐行注释。如果你是一点基础都没有的纯新手,跟着敲一遍,理解每一行在干什么,比你去刷十遍理论课都管用。

我先交代一下这篇的核心内容,让你有个预期:用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_epochevaluate函数。把模型替换成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.MNISTdatasets.FashionMNIST就能跑。你会看到同样的网络在Fashion-MNIST上准确率会掉不少,这就直观感受到不同数据集对模型能力的诉求是不同的。

第三步:加数据增强。对训练图做随机旋转、平移。MNIST里数字稍微旋转一点,人类很容易识别,但模型可能就认不出来了。用torchvision.transforms.RandomRotation在训练时随机旋转图片,能逼着模型学到旋转不变性,测试准确率会提升。这是投入产出比极高的实战进阶操作。

第四步:把测试图像也做成可视化混淆矩阵。统计模型把哪个数字多认成了哪个数字,比如“9被认成4”的次数最多,那说明模型对这两个数字的笔画特征区分不够。这一步会培养你从错误数据里找原因的能力,这项能力在真实项目里非常值钱。

你从零开始写的这两个网络,其实已经覆盖了现代深度学习模型最基本的部件:全连接层、激活函数、卷积层、池化层、损失函数、优化器、训练与评估流程。PyTorch里那些看似眼花缭乱的模型结构,无论是ResNet、Transformer还是Diffusion,底层无非是这些基础组件的不同组合。

我最后想说的是:在我带过的所有新人里,区分“学得会”和“学不会”的最大分界线,不是智商,而是有没有亲手把每一个组件写一遍、跑一遍、改一遍、看它报错、再修好它。代码跟着敲一遍,比任何课程都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 16:54:46

ObjectARX自定义实体开发完整指南:从序列化到调试

简介:面向AutoCAD二次开发者的ObjectARX自定义实体入门实例,基于VC环境,通过从AcDbLine派生自定义直线实体,完整演示自定义实体从项目创建、类定义、DWG/DXF序列化、worldDraw绘制、数据库注册到命令调用与调试的流程。压缩包共70…

作者头像 李华
网站建设 2026/9/9 16:54:32

WSABuilds 完全指南:WSA 停服后 5 步装好 Windows 安卓子系统

WSABuilds 完全指南:WSA 停服后 5 步装好 Windows 安卓子系统 【免费下载链接】WSABuilds Run Windows Subsystem For Android on your Windows 10 and Windows 11 PC using prebuilt binaries with Google Play Store (MindTheGapps) and/or Magisk or KernelSU (r…

作者头像 李华
网站建设 2026/9/9 16:53:45

官方停止维护后,WSABuilds 让你在 Windows 上继续跑安卓子系统

官方停止维护后,WSABuilds 让你在 Windows 上继续跑安卓子系统 【免费下载链接】WSABuilds Run Windows Subsystem For Android on your Windows 10 and Windows 11 PC using prebuilt binaries with Google Play Store (MindTheGapps) and/or Magisk or KernelSU (…

作者头像 李华
网站建设 2026/9/9 16:53:22

DSDV路由协议源码中文注释解析:从序列号机制到工程实现

简介:面向网络协议学习者和无线网络研究人员的DSDV路由协议NS2实现源码,以C编写并配有中文注释,便于从代码层面理解距离向量算法、目的序列号防环机制,以及与NS2事件调度和数据包处理的集成方式。压缩包共6个文件,包含…

作者头像 李华
网站建设 2026/9/9 16:52:22

LeetCode周赛复盘:余数哈希与区间贪心,掌握前两题制胜套路

第495场周赛,Q1和Q2我加起来打了不到十五分钟。不是因为我手速多快,而是这两道题几乎是“模板级”的考法:一道用哈希做数学配对,一道用贪心做区间选择。这种题在周赛前两题里出现频率极高,真正决定你能不能拿分的&…

作者头像 李华