MNIST实验(二):CNN
上一篇MNIST实验里,我用MLP跑通了手写数字识别,最后测试准确率大约是97.52%。当时最大的收获是先把PyTorch的训练流程跑通:数据、模型、loss、反向传播、参数更新、测试准确率。
这次我继续在MNIST上跑了一个CNN baseline。最后测试准确率到了98.82%,比之前的MLP高一些。
这篇不是要把CNN讲成完整教程,我现在也还没到完全理解卷积神经网络的程度。它更像是一次实验记录:从MLP过渡到CNN,先看看为什么图像任务里CNN会更自然一些。
为什么在MLP之后学CNN
MLP,Multilayer Perceptron,多层感知机,处理MNIST时会先把图片拉平成一长串数字。
MNIST图片原本是28x28的二维灰度图,但MLP里的Flatten()会把它变成784个数字:
1 | nn.Flatten() |
这样当然也能训练,而且之前MLP baseline已经能达到97%以上准确率。但问题是,图片原来的二维结构被直接拉平了。
比如一个数字的笔画、边缘、转折,其实都和局部区域有关。MLP把图片拉平以后,模型还是可以学,但它没有很直接地利用“像素和附近像素之间的关系”。
CNN,Convolutional Neural Network,卷积神经网络,更适合图像任务,是因为它不是一开始就把图片拉平,而是先在图片上提取局部特征。
我现在先把CNN理解成:它会先看图片里的局部形状,比如边缘、笔画、转折,再把这些特征交给后面的分类层。
这次实验的基本信息
这次实验还是MNIST手写数字分类。
| 项目 | 内容 |
|---|---|
| 模型 | SimpleCNN |
| 数据集 | MNIST |
| 训练集 | 60000张图片 |
| 测试集 | 10000张图片 |
| 参数量 | 206922 |
| batch size | 64 |
| epoch | 5 |
| learning rate | 0.001 |
| optimizer | Adam |
| loss function | CrossEntropyLoss |
| device | cuda |
| seed | 42 |
运行命令:
1 | & D:/anaconda3/envs/pytorch/python.exe d:/FL-Research/projects/01_mnist/train_cnn.py |
这次的目标也比较简单:先验证一个基础CNN能不能在MNIST上跑通,并和之前MLP的结果做一个对比。
CNN模型结构
这次模型叫SimpleCNN,结构不复杂:
1 | class SimpleCNN(nn.Module): |
输入图片的形状大概是:
1 | [64, 1, 28, 28] |
这里每一项的意思是:
64:一个batch里有64张图片。1:MNIST是灰度图,所以只有1个通道。28, 28:每张图片的高和宽。
模型可以分成两部分:features和classifier。
features负责从图片里提取特征,classifier负责根据这些特征判断数字类别。
Conv2d在做什么
第一层卷积是:
1 | nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, padding=1) |
我现在先这样理解:
in_channels=1:输入是1通道灰度图。out_channels=16:这一层会输出16组特征图。kernel_size=3:卷积核大小是3x3。padding=1:在边缘补一圈,让输出宽高不至于马上变小。
Conv2d输出的不是最终答案,而是特征图。它像是在图片上用一个小窗口滑动,去找局部特征。
对MNIST来说,这些局部特征可能和笔画、边缘、转折有关。虽然我现在还不能说清楚每个卷积核具体学到了什么,但直觉上它比直接把图片拉平成784个数字更适合图像。
第二层卷积是:
1 | nn.Conv2d(in_channels=16, out_channels=32, kernel_size=3, padding=1) |
这里输入通道从16变成32,说明模型会在前一层特征的基础上继续提取更多特征。
ReLU和MaxPool2d
卷积层后面接了ReLU():
1 | nn.ReLU() |
ReLU,Rectified Linear Unit,线性整流函数,是一种常用激活函数。它的作用是加入非线性,让模型不只是做线性变换。
池化层是:
1 | nn.MaxPool2d(kernel_size=2) |
MaxPool2d,最大池化,可以把特征图的宽和高缩小。这里kernel_size=2大致会让宽高减半。
MNIST原图是28x28。经过第一次池化,大概变成14x14;经过第二次池化,大概变成7x7。
所以到分类层前,特征大概是:
1 | 32 * 7 * 7 |
这里的32是第二层卷积输出的通道数,7 * 7是两次池化后的空间大小。
分类层
卷积部分后面接的是分类层:
1 | self.classifier = nn.Sequential( |
这里:
Flatten()把卷积提取出来的特征拉平成一维。Linear(32 * 7 * 7, 128)把卷积特征送进全连接层。ReLU()继续加入非线性。Linear(128, 10)输出10个数字类别的分数。
这里我容易混淆的一点是:Flatten()不是把CNN变没了。它只是把卷积层已经提取好的特征整理成全连接层能接收的形状。
也就是说,MLP是一开始就Flatten;CNN是先卷积和池化,提取完特征之后再Flatten。
这个区别挺关键。
训练流程和MLP很像
虽然模型结构换成了CNN,但训练流程和之前MLP基本一样。
核心训练代码还是:
1 | predictions = model(images) |
这几行对应的过程是:
model(images)是前向传播,模型根据图片输出预测结果。CrossEntropyLoss用来衡量多分类任务里预测错多少。optimizer.zero_grad()清空上一轮梯度。loss.backward()反向传播,计算梯度。optimizer.step()根据梯度更新模型参数。
所以这次让我更明确了一点:从MLP换到CNN,训练主流程没有变。变的是模型内部怎么处理图片。
之前机器学习和MLP里反复出现的那条线还在:
数据 -> 模型 -> loss -> 反向传播 -> 更新参数 -> 测试准确率
测试过程
测试时也和MLP类似:
1 | model.eval() |
这里:
model.eval()表示进入测试/评估模式。torch.no_grad()表示不计算梯度,因为测试时不需要更新参数。argmax(dim=1)从10个类别分数里选最大的那个,作为预测数字。
模型最后输出的是10个分数,不是直接输出数字。argmax才是把分数转成具体类别的步骤。
实验结果
这次CNN baseline的结果是:
| Epoch | Train Loss | Train Acc | Test Loss | Test Acc |
|---|---|---|---|---|
| 1 | 0.2376 | 0.9273 | 0.0694 | 0.9785 |
| 2 | 0.0654 | 0.9800 | 0.0477 | 0.9844 |
| 3 | 0.0442 | 0.9863 | 0.0401 | 0.9864 |
| 4 | 0.0338 | 0.9893 | 0.0348 | 0.9880 |
| 5 | 0.0261 | 0.9921 | 0.0349 | 0.9882 |
从结果看:
- 训练loss从0.2376降到0.0261,说明模型在训练集上持续学习。
- 测试准确率从97.85%提升到98.82%。
- 最终CNN测试准确率是98.82%,高于之前MLP的97.52%。
- 第4到第5个epoch,测试准确率提升很小,说明继续训练的收益可能开始变小。
- 第5个epoch训练准确率是99.21%,测试准确率是98.82%,两者有差距,但暂时不算明显过拟合。
这次结果至少说明,在MNIST这个任务上,CNN baseline确实比我之前的MLP baseline更好一些。
当然,这不代表我已经完全理解CNN,也不代表这个模型已经调到最好。它只是说明这个基础CNN跑通了,而且方向是对的。
我现在容易混淆的点
第一,Conv2d输出的不是最终答案,而是特征图。它是在图片上提取局部特征。
第二,MaxPool2d不是分类层。它主要是缩小特征图尺寸,同时保留比较明显的响应。
第三,Flatten不是说CNN没用了。它是在卷积层提取完特征后,把这些特征整理给全连接分类层。
第四,CNN的训练流程和MLP很像,都是前向传播、计算loss、反向传播、更新参数。真正不同的是模型结构。
第五,test accuracy高不代表我已经完全理解CNN。它只能说明这次baseline实验跑通了,效果也比MLP好。
这篇和后面学习的关系
CNN是后面图像任务的基础。MNIST比较简单,图片是灰度的,数字也比较规整。后面如果做CIFAR-10,就会复杂很多,因为CIFAR-10是彩色图,而且类别差异更大,背景也更复杂。
这篇对联邦学习也有关系。联邦学习里每个客户端本地训练时,也需要一个本地模型。这个模型可以是MLP,也可以是CNN。理解单机CNN怎么训练,是后面理解FedAvg里“客户端本地训练”的前置基础。
所以这次实验的意义不是“我学完CNN了”,而是先把第一个CNN baseline跑通,知道它和MLP的主要区别在哪里。
下一步我想继续理解卷积层和池化层到底在学什么,再慢慢过渡到更复杂的图像任务。




