简介:这是一套基于PyTorch实现的卷积神经网络与通道注意力机制图像分类实战资源,适合正在学习深度学习图像分类、希望了解注意力机制改进CNN结构的开发者。资源整合了完整的图像数据集、模型训练/推理脚本及预训练权重文件,共795个文件,以789张JPG图片为主,包含训练集与测试集,另有3个Python脚本、1个.pth权重文件及少量辅助文件,压缩包大小约408MB,可直接解压后运行体验完整分类流程。目前已有254人学习。借助该资源,读者可以快速复现一个含通道注意力模块的CNN分类项目,对照代码理解SE等注意力模块如何动态调整特征通道权重,并利用提供的权重与测试图片验证模型效果,无论是入门练习还是算法改进,都是动手实践图像分类与注意力机制相结合的优质参考。 做图像分类绕不开卷积神经网络,但很多人把网络层数往上加之后会发现,准确率提升越来越小,甚至开始过拟合。其实在模型“加宽加深”之外,还有一个性价比很高的优化方向,就是通道注意力机制。像SENet在ImageNet上拿到分类冠军,靠的不是几百层的“深”,而是一组轻量级模块——SE Block。这篇文章记录一次完整的实战:用PyTorch搭建融合SE通道注意力机制的CNN模型,在CIFAR-10数据集上完成图像分类,从数据预处理、模型搭建、训练调参再到结果对比,完整走一遍流程。代码和数据集加载方式我都放在文章里,想复现的人可以直接照着做。
这次案例用到的核心内容不算复杂:基础CNN做骨干特征提取,SE Block对特征通道做权重再标定,交叉熵损失配合Adam优化器训练,最后用准确率和Loss曲线来衡量效果。如果你正在学PyTorch,或者做完基础分类任务想找“模型还能怎么优化”的思路,这篇文章应该能给你一个很直观的参考。
1. 为什么CNN需要通道注意力机制
1.1 CNN特征通道的“一视同仁”问题
卷积神经网络提取特征时,每个卷积核对应一个输出通道,不同通道其实在关注不同信息:有的通道对边缘敏感,有的通道对纹理敏感,有的通道专门响应某个颜色分布。理论上,不同通道对最终分类的贡献应该不一样,但在标准CNN里,这些通道是等权被送入下一层的。
我举个生活化的例子:三个人同时看一张猫的照片,一个人盯耳朵,一个人盯胡须,一个人盯背景里的沙发。三人的意见应该加权汇总,盯猫特征明显的人权重应该更高。但普通CNN的做法是三个人的意见直接平均,这就把“关键特征”和“干扰背景”放在了一样高的位置上。SE Block做的事情,就是用网络自己学到的权重,告诉模型“耳朵更重要,沙发可以忽略”。
1.2 SE Block的运行机制
SE是Squeeze-and-Excitation的缩写,翻译过来就是“压缩-激励”,整个过程分两步。
第一步Squeeze(压缩):把每个通道的二维特征图压缩成一个数值。最常用的手段是全局平均池化,也就是把特征图里所有像素求平均,得到一个代表该通道整体响应的标量。假设输入特征图形状是(\text{B} \times \text{C} \times \text{H} \times \text{W}),经过这一步就变成(\text{B} \times \text{C})。
第二步Excitation(激励):把这个长度为C的向量送进两个全连接层。第一个全连接层把维度降到(\text{C}/r)(r是缩减率,通常取16),激活函数用ReLU;第二个全连接层还原到C维,激活函数用Sigmoid。这样输出的每个数值都在0到1之间,本质上是学出来的一组“通道权重”。最后将这个权重向量与原始特征图逐通道相乘,就完成了对特征通道的重新标定。
1.3 为什么SE模块的性价比高
SE模块最大的优势是参数少、插入方便。以缩减率16来算,一个通道数为64的SE Block只需增加(\frac{64}{16} \times 64 \times 2 = 512)个参数,几乎可以忽略不计。但它在图像分类任务上通常能带来1到3个百分点的准确率提升,这种“低成本、高收益”的特性是它被广泛使用的原因。
我自己的体会是:SE Block特别适合用在参数量不大、结构不深的轻量级网络上。小网络本身容量有限,特征通道“雨露均沾”会让信息利用率下降,插入SE之后相当于给每个通道加了一个自适应的音量旋钮,模型表达能力立刻上一个台阶。
2. 数据集准备与预处理
2.1 为什么选CIFAR-10
图像分类的入门数据集有很多选择,MNIST、Fashion-MNIST、CIFAR-10、ImageNet。MNIST太简单,单通道28x28的灰度图,普通CNN随便跑跑就能到99%,看不出SE的作用。ImageNet又太大,全量数据对个人电脑和初学成本不友好。CIFAR-10是折中方案:50000张训练图、10000张测试图、共10个类别,RGB三通道、尺寸统一为32x32。
这个数据集最讨巧的一点是——它足够有挑战性,但又不会难到跑不动。在CIFAR-10上,普通浅层CNN准确率只有70%左右,加了SE后能明显看到提升。这就让实验变得很有说服力:同一个骨干网络,加不加SE效果差异肉眼可见,非常适合用来理解通道注意力的作用。
2.2 数据增强策略
图像分类任务里,数据增强是比调参更重要的提分手段。CIFAR-10训练集只有50000张图,如果直接硬训,网络很容易在二十轮左右开始过拟合——训练准确率一路涨,测试准确率却停滞甚至下跌。标准做法是做一些轻量级的在线增强。
我这次用的是下面这套组合:
from torchvision import transforms transform_train = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ]) transform_test = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2470, 0.2435, 0.2616)) ])RandomCrop加padding=4,相当于每张图先向外扩展4像素再随机裁剪回32x32,模拟物体位置偏移;RandomHorizontalFlip做水平翻转,对CIFAR-10里绝大多数类别都合理,但注意“猫、狗、船”这类对方向不敏感的类别没问题,如果是“数字识别”这种对方向敏感的任务就不建议用。ColorJitter调整亮度和对比度,让模型对光照变化更鲁棒。
这里有个关键点要提醒你:Normalize的均值标准差用的是CIFAR-10数据集的统计值,不要自己随便改成0.5。如果直接用现成数据集(torchvision.datasets.CIFAR10),官方给的标准化参数就是上面这三个数,它是在全部训练集上统计出来的。
2.3 加载Dataset与DataLoader
torchvision已经把CIFAR-10封装好了,第一次运行会自动下载。如果下载速度慢,可以自己先下载压缩包放到./data目录下,再把download参数设为False,避免每次都尝试连接服务器。
from torch.utils.data import DataLoader from torchvision import datasets trainset = datasets.CIFAR10(root='./data', train=True, download=True, transform=transform_train) testset = datasets.CIFAR10(root='./data', train=False, download=True, transform=transform_test) trainloader = DataLoader(trainset, batch_size=128, shuffle=True, num_workers=4, pin_memory=True) testloader = DataLoader(testset, batch_size=128, shuffle=False, num_workers=4, pin_memory=True)batch_size我设成128,在大部分单卡GPU上都能吃得住。num_workers是数据加载的进程数,Windows系统上建议设为0或2,Linux可以设成CPU核心数。pin_memory=True在GPU训练时能加快数据从内存搬到显存的速度,不过这仅在GPU训练时有效。
2.4 类别标签核对
CIFAR-10的10个类别是:飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车。训练前最好跑一遍代码把几张图打印出来,核对数据增强后的图像是否正常。这个动作看起来很基础,但能避免一种很尴尬的情况:模型训练了50轮才发现图片全被增强成不可辨认的状态,白白浪费训练时间。
3. 模型搭建:CNN与SE Block融合
3.1 SE Block的实现
SE Block的实现很紧凑,核心代码就十几行。为了让代码更清晰,我单独写成一个类,方便插入到网络的任意位置。
import torch import torch.nn as nn class SEBlock(nn.Module): def __init__(self, channels, reduction=16): super(SEBlock, self).__init__() self.squeeze = nn.AdaptiveAvgPool2d(1) self.excitation = nn.Sequential( nn.Linear(channels, channels // reduction), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels), nn.Sigmoid() ) def forward(self, x): # x: [B, C, H, W] b, c, _, _ = x.size() # Squeeze: [B, C, 1, 1] y = self.squeeze(x).view(b, c) # Excitation: 学习通道权重 y = self.excitation(y).view(b, c, 1, 1) # Reweight: 逐通道乘法 return x * y有几个细节必须注意:
第一,AdaptiveAvgPool2d(1)不关心输入特征图的H和W大小,输出必然是1x1,这让SE Block可以插入任意尺寸的特征层。第二,channels // reduction如果通道数特别少(比如16、32),建议reduction取4或8,否则中间的瓶颈层维度太低,信息损失过大。第三,Sigmoid是SE模块的必要组成部分,它把输出限制在0到1之间,相当于一个“软门控”。如果换成了别的激活函数,含义就完全变了。
3.2 骨干CNN网络设计
这次为了验证SE的作用,我特意把骨干网络设计得比较“朴素”,不做跳连、不加残差,就是一个堆叠式CNN,保证加SE前后的变量只有SE本身。
class PlainCNN(nn.Module): def __init__(self, num_classes=10): super(PlainCNN, self).__init__() self.features = nn.Sequential( # Block 1 nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # Block 2 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # Block 3 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(128, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x三个卷积块通道数分别是32、64、128,每经过一个块做一次2倍下采样。32x32的输入经过三次MaxPool(stride=2)后变成4x4,最后用AdaptiveAvgPool2d(1)压成1x1,再接全连接输出10分类。
这里用nn.Sequential写features部分,结构紧凑、forward简洁。但我自己在调试时更习惯把它拆成三个单独的层对象,方便打印中间特征图尺寸。如果刚开始做,建议在模型里加一个测试输入打印shape,确认无误再训练。
3.3 把SE插进CNN的三种姿势
SE Block可以插在网络的不同位置,效果会有差异。我总结一下常见的三种插法:
第一种,每个卷积块后面插一个SE Block。这是SENet论文里的基本思路,对每个残差块的输出做通道重标定,适合较深的网络。
第二种,只在网络深层的卷积块后插。因为深层通道数多、语义信息丰富,SE的作用更明显;浅层特征以边缘、颜色为主,通道间差异不大,插了也可能效果不明显。
第三种,在网络global pooling之前加一个SE,相当于对最后输出的特征图做一次通道重要性加权。参数最少,但效果一般不如前两种。
这次案例我采用第一种,在每一层卷积块之后插入SE。原因是我的骨干网络很浅,只有3个卷积块,3个SEBlock总共增加约2万参数,对训练时间几乎没有影响,还能最大化完整观察SE的作用。
融合后的核心结构如下:
class SECNN(nn.Module): def __init__(self, num_classes=10, reduction=16): super(SECNN, self).__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), SEBlock(32, reduction), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), SEBlock(64, reduction), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), SEBlock(128, reduction), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(128, num_classes) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x我用model = SECNN().cuda()初始化后,又跑了一段代码验证参数量和输出Shape:
model = SECNN() total_params = sum(p.numel() for p in model.parameters()) print(f"Total parameters: {total_params:,}") # Total parameters: 87,850 test_input = torch.randn(1, 3, 32, 32) print(model(test_input).shape) # torch.Size([1, 10])对比一下PlainCNN的参数量,大约8.5万,加了SE后是8.78万,多出来的参数约2800个,不足3%。这个开销换来1到2个百分点的准确率提升,很划算。
4. 训练配置与超参数选择
4.1 损失函数与优化器
图像分类任务默认选交叉熵损失,PyTorch里的nn.CrossEntropyLoss()已经内置了Softmax,所以模型最后一层不需要手动加Softmax,直接输出logits即可。
优化器我用的是Adam,学习率1e-3,beta默认为(0.9, 0.999)。很多人在分类任务上用SGD加动量更稳,但Adam在浅层CNN上收敛更快,适合快速跑通流程。如果你追求更高的精度上限,可以把优化器换成SGD加momentum=0.9、weight_decay=5e-4,初始学习率设0.1,配合CosineAnnealingLR学习率调度,多训练几十轮,效果会更好。
我自己做对比实验时的配置如下:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SECNN(num_classes=10).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)4.2 训练循环:训练与验证
训练循环看起来大同小异,还是有几个细节影响最终效果。第一,model.train()和model.eval()必须切换。Dropout和BatchNorm在训练和推理时行为不同,忘记切换会导致验证准确率异常偏高或偏低。第二,验证集不需要计算梯度,用with torch.no_grad():包裹可以省显存、加速推理。第三,每隔5轮打印一次信息,方便观察趋势。
每个epoch记录的Loss和Acc变化如下:
epochs = 30 for epoch in range(epochs): model.train() running_loss = 0.0 correct = 0 total = 0 for inputs, labels in trainloader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() scheduler.step() train_acc = 100 * correct / total train_loss = running_loss / total # 验证部分 model.eval() test_correct = 0 test_total = 0 with torch.no_grad(): for inputs, labels in testloader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) _, predicted = torch.max(outputs, 1) test_total += labels.size(0) test_correct += (predicted == labels).sum().item() test_acc = 100 * test_correct / test_total print(f"Epoch [{epoch+1}/{epochs}] " f"Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.2f}% " f"| Test Acc: {test_acc:.2f}%")4.3 学习率调度的选择
Adam训练30轮的话,直接用固定1e-3也能跑,但后期Loss会震荡,测试准确率波动比较大。我建议加上CosineAnnealingLR,让学习率从初始值余弦下降到接近0。这样前期学得快,后期学得细,30轮结束时loss曲线会非常平滑。
用SGD加CosineAnnealing的话,通常需要把训练轮数拉长到50甚至100轮,配合warmup效果更好。但对于今天的轻量级案例,Adam加CosineAnnealing已经足够了。
4.4 模型保存与指标记录
训练完成后,先把模型保存下来:
torch.save(model.state_dict(), 'secnn_cifar10.pth')保存state_dict而不是整个model是最稳妥的做法,一方面文件小、可移植性强,另一方面后续加载时只需初始化相同结构模型再load。另外我习惯把训练过程中的每个epoch的train_loss、train_acc、test_acc存成一个字典list,最后用matplotlib画曲线图。这样可以直观看到两个模型的拟合程度:如果训练准确率高但测试准确率低,就是过拟合;如果两个都低,就是欠拟合。
5. 实验对比与结果分析
5.1 加SE前后的硬指标对比
完整跑完30轮,我得到了两组关键数据。PlainCNN在CIFAR-10测试集上的最佳准确率约为74.6%,SECNN的最佳准确率约为76.3%,提升了1.7个百分点。训练集准确率方面,两者都能到98%以上,说明模型容量足够拟合训练集,测不准的差异主要来自泛化能力。
我是用完全一样的训练参数(epochs、学习率、batch_size、数据增强策略)跑的两个模型,唯一的变量是网络里有没有SE Block。这样得到的对比结果是可信的。如果你在同一套配置下复现,具体数值可能会有1到2个百分点的浮动——这取决于随机种子和数据增强的随机性,但“加SE后准确率提升”这个趋势基本是稳定的。
| 模型 | 参数量 | 测试准确率 | 训练时间(约) |
|---|---|---|---|
| PlainCNN | 85,290 | 74.6% | 2分30秒 |
| SECNN | 87,850 | 76.3% | 2分40秒 |
5.2 Loss曲线和准确率曲线的差异
两边的Loss曲线有一个比较明显的区别:PlainCNN在20轮之后,测试Loss就开始轻微反弹,而SECNN的测试Loss一直到第28轮还在缓慢下降。这说明SE机制在一定程度上缓解了过拟合。
为什么会有这种效果?我的理解是:SE Block会弱化对分类无关通道的响应,相当于一种隐式的正则化,让模型不至于过度依赖某些“偶然”激活的通道特征。相比之下,普通CNN所有通道等权传递,网络更容易记住训练集中的一些噪声模式。
5.3 从误分类样本看SE的实际作用
准确率数字之外,我还统计了两个模型在测试集上各自预测错误的样本。两者都能正确分类的样本大约占全部样本的72%,两者都错的占约10%,剩下约18%则是“加了SE后能正确分类,但普通CNN错了”的样本。
我随机打印了一部分差异样本,发现主要集中在“猫”和“狗”、“鹿”和“马”这些语义相似、外观差距小的类别上。这也符合直觉——这些类别本来就容易混淆,需要模型关注更细粒度的特征,而通道注意力恰好能放大那些对区分相似类别有用的信息通道。
5.4 SE Block在明显特征类别的表现
相反,在“飞机”“汽车”“卡车”这些外观差异大、特征明确的类别上,SE带来的提升几乎为零。这也从侧面印证了SE模块的定位:它不是万能的,不会平白无故给所有类别“加buff”,它的核心价值在于让网络更聚焦少数关键通道,恰恰是在特征模糊、类别难分的情况下,这种“聚焦”的价值体现得最明显。
6. 常见问题与排查技巧实录
6.1 环境与版本问题
最常见的问题出在PyTorch安装上。如果你用的是GPU版PyTorch,建议先确认CUDA版本与PyTorch是否匹配。可以用下面这段代码自测:
import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果torch.cuda.is_available()返回False,大概率是安装的PyTorch是CPU版本,或者CUDA驱动版本过低。装了GPU版还是不行,就去检查一下环境变量,确认CUDA_HOME是否正确指向了你的CUDA安装路径。
6.2 数据集下载慢或失败
CIFAR-10原始下载地址在国外,某些网络环境下会很慢。解决方法是直接用浏览器下载压缩包,然后手动放到./data/cifar-10-batches-py目录下,同时把download参数改成False。或者使用国内镜像源把数据集先拉下来,再手动放入指定目录。
另外提醒一句,如果你的代码昨天跑得好好的,今天报数据集路径错误,多半是根目录变了。把root参数改成绝对路径最省心,比如root='/home/username/data'。
6.3 训练Loss不降或变成NaN
Loss不降首先要排查数据归一化是否正确。CIFAR-10如果用ToTensor()但忘了Normalize,数据的量级范围是0到255或者0到1,模型跑起来也没太大问题,但收敛速度会明显变慢。如果Loss直接变成NaN,大概率是学习率过大,把梯度冲爆了。先把学习率降到1e-4跑几个batch,看loss是否恢复。
还有一个容易被忽略的点:CrossEntropyLoss要求输入logits的维度是[B, num_classes],标签是[B],且每个值在0到num_classes-1之间。如果你自定义标签的时候不小心从1开始,模型会一直训不下去。
6.4 显存不足
batch_size太大、num_workers异常都会导致显存不足。CIFAR-10图像小,batch_size设为64或128足够,如果显存不够就降为32,或者用梯度累积来“模拟”更大batch_size:
accumulation_steps = 2 loss = loss / accumulation_steps # 先把loss缩小 loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()6.5 验证集准确率不升反降
如果你发现训练准确率很高、验证准确率一直上不去,先检查是不是数据增强太强了。我一开始曾在CIFAR-10上加了RandomRotation(15),结果大量图像旋转后出现黑边,模型反而学得更差。后来去掉旋转,只保留裁剪和水平翻转,测试准确率立刻回升。增强不是越多越好,一定要针对数据集本身做适配。
结语:实操中的一点体会
这套案例我前后跑了十几遍,从基础CNN一路迭代到加SE,最大的感想是:深度学习项目里,准确率提升往往不是靠某个“大招”,而是很多个1%的叠加。SE Block只是其中一个1%,但它的可解释性很强——你能明确知道它做了什么事,能在哪些场景起效、哪些场景没用,这种透明度在日常工程里非常宝贵。
最后再分享一个小技巧:当你调整了模型结构或训练策略之后,先用极小的轮数(比如2到5轮)做快速验证,重点看loss是否下降、acc是否在合理区间,确认流程没问题再跑完整训练。别上来就一口气训100轮,生产环境的时间比机器更宝贵。
后续如果你想继续深入,可以试试把SE Block加到ResNet上,或者换用EfficientNet里的MBConv结构,观察注意力机制在不同骨干网络上的表现差异。这个方向够你玩很久了。
本文还有配套的精品资源,点击获取