上一篇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
2
nn.Flatten()
nn.Linear(28 * 28, 128)

这样当然也能训练,而且之前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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class SimpleCNN(nn.Module):
def __init__(self) -> None:
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(in_channels=1, out_channels=16, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2),
nn.Conv2d(in_channels=16, out_channels=32, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=2),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(32 * 7 * 7, 128),
nn.ReLU(),
nn.Linear(128, 10),
)

def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.features(x)
return self.classifier(x)

输入图片的形状大概是:

1
[64, 1, 28, 28]

这里每一项的意思是:

  • 64:一个batch里有64张图片。
  • 1:MNIST是灰度图,所以只有1个通道。
  • 28, 28:每张图片的高和宽。

模型可以分成两部分:featuresclassifier

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
2
3
4
5
6
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(32 * 7 * 7, 128),
nn.ReLU(),
nn.Linear(128, 10),
)

这里:

  • Flatten()把卷积提取出来的特征拉平成一维。
  • Linear(32 * 7 * 7, 128)把卷积特征送进全连接层。
  • ReLU()继续加入非线性。
  • Linear(128, 10)输出10个数字类别的分数。

这里我容易混淆的一点是:Flatten()不是把CNN变没了。它只是把卷积层已经提取好的特征整理成全连接层能接收的形状。

也就是说,MLP是一开始就Flatten;CNN是先卷积和池化,提取完特征之后再Flatten。

这个区别挺关键。

训练流程和MLP很像

虽然模型结构换成了CNN,但训练流程和之前MLP基本一样。

核心训练代码还是:

1
2
3
4
5
6
predictions = model(images)
loss = loss_fn(predictions, labels)

optimizer.zero_grad()
loss.backward()
optimizer.step()

这几行对应的过程是:

  • model(images)是前向传播,模型根据图片输出预测结果。
  • CrossEntropyLoss用来衡量多分类任务里预测错多少。
  • optimizer.zero_grad()清空上一轮梯度。
  • loss.backward()反向传播,计算梯度。
  • optimizer.step()根据梯度更新模型参数。

所以这次让我更明确了一点:从MLP换到CNN,训练主流程没有变。变的是模型内部怎么处理图片。

之前机器学习和MLP里反复出现的那条线还在:

数据 -> 模型 -> loss -> 反向传播 -> 更新参数 -> 测试准确率

测试过程

测试时也和MLP类似:

1
2
3
4
5
model.eval()

with torch.no_grad():
predictions = model(images)
predicted = predictions.argmax(dim=1)

这里:

  • 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的主要区别在哪里。

下一步我想继续理解卷积层和池化层到底在学什么,再慢慢过渡到更复杂的图像任务。