
简介这份资源是一个结合卷积神经网络CNN与自注意力机制的Python实现项目面向有一定深度学习基础、希望动手实践混合模型的读者。压缩包共4个文件均为py脚本整体仅6KB涵盖数据读取、网络结构定义、模型测试与训练入口结构简洁适合快速阅读与二次开发。已有341人学习下载适用于图像分类、视觉问答等需要同时捕获局部特征与全局依赖的任务。CNN擅长提取图像的边缘、纹理等局部信息而自注意力能建模长距离关联且比RNN具有更好的并行性二者结合可形成更稳健的特征表达。通过阅读源码可掌握两种模块的衔接方式、训练流程和模型评估细节是一份轻量但完整的实战参考整体目录清晰函数职责单一便于替换数据接口进行扩展。1. 解压之后先看结构CNN 与注意力机制的组合方式拿到“基于卷积神经网络和注意力机制.zip”并解压后你会看到 sanxiao1.0-main 下只有四个 Python 文件getdata.py、network.py、train.py、tes.py。这是一个非常标准的视觉分类工程切分分别对应数据准备、网络定义、训练和测试四个环节。项目解决的核心问题只有一个把卷积神经网络提取的局部纹理与结构特征和自注意力机制建模的全局依赖关系放进同一个模型里协同工作。很多人在这里踩的第一个坑是把注意力模块当成万能插件随处拼接结果要么参数量暴涨要么 loss 震荡不收敛。这个项目代码量小适合顺着张量形状把每个模块的输入输出看清楚。目标读者是已经跑通基础 CNN 分类、现在要让模型捕捉长距离特征依赖的开发者。2. 数据管道先行getdata.py 的读取、预处理与标签策略2.1 图像加载BGR、resize 与归一化的顺序不能乱getdata.py 负责把磁盘图片变成模型输入张量。用 OpenCV 读图时拿到的是 HWC 排列的 BGR 数据必须要转成 RGB再按 PyTorch 习惯转成 CHW。漏掉颜色转换训练时准确率照样能涨但后续可视化特征图时颜色全部错位排查起来很迷惑。resize 的目标尺寸要和 network.py 中第一个卷积层的输入对齐常见做法是 224×224小数据集可以用 96×96 加快迭代。import cv2 import numpy as np import torch from torch.utils.data import Dataset class ImageDataset(Dataset): def __init__(self, image_paths, labels, size224, mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)): self.image_paths image_paths self.labels labels self.size size self.mean np.array(mean).reshape(3, 1, 1) self.std np.array(std).reshape(3, 1, 1) def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img cv2.imread(self.image_paths[idx]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # BGR - RGB img cv2.resize(img, (self.size, self.size)) img img.astype(np.float32) / 255.0 # 像素归一化到 [0,1] img np.transpose(img, (2, 0, 1)) # HWC - CHW img (img - self.mean) / self.std # 标准化 return torch.from_numpy(img).float(), self.labels[idx]这里有两个容易出错的地方/ 255.0必须在转 CHW 之前做否则除法的广播维度对不上mean 和 std 用 ImageNet 统计值只是起点自建数据集最好自己统计。代码里用reshape(3, 1, 1)是为了让广播按通道维度对齐避免每个通道用了同一套均值。返回的标签需要调用方确保是torch.long交叉熵损失不接受 float 标签。2.2 数据增强与归一化的参数选择加入自注意力机制后模型对高频纹理的敏感度比纯 CNN 更高增强不足容易过拟合增强过头又会让注意力权重学会“忽略纹理”。就这个项目的数据规模而言我建议从轻量增强开始而不是直接上 AutoAugment。from torchvision import transforms train_transform transforms.Compose([ transforms.ToPILImage(), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees10), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])上面的顺序有讲究RandomRotation 会引入边缘填充噪声所以要放在 Resize 之前让后续缩放把填充带淡化掉。ColorJitter 只扰动亮度与对比度不动色调避免注意力模块学到不稳定的颜色统计。参数上 rotation 不要超过 15 度否则空间注意力会花额外容量去学习旋转不变性反而挤压了全局特征建模的空间。2.3 标签映射与数据集划分的工程细节分类任务的标签不能直接用字符串喂给交叉熵。如果 train 和 test 分开存放必须在训练集上先构建映射再映射测试集label_map {name: i for i, name in enumerate(sorted(set(train_labels)))} y_train [label_map[name] for name in train_labels] y_test [label_map[name] for name in test_labels if name in label_map]if name in label_map这个判断很关键。部署阶段遇到训练集没出现过的类别宁可丢弃也不要强行给新索引否则模型输出的 softmax 分布会被打乱。同理DataSet 内部不要做随机划分随机划分应该放在外层用固定的 random seed 保证每次调试行为一致。数据部分的整体流程可以用下面的表格概括环节常用手段注意点读取cv2.imread BGR2RGB通道顺序一致性缩放Resize((224, 224))与网络输入对齐增强翻转 / 旋转 / 轻微颜色抖动旋转范围勿过大归一化ImageNet 统计值自建数据务必重算标签有序去重后构建映射测试集过滤未知类3. 网络结构设计network.py 中卷积块、通道注意力与自注意力的嵌法3.1 基础卷积块卷积核、步长、填充的取舍network.py 里通常会先定义一个基础卷积块由 Conv2d、BatchNorm、ReLU 三件套组成。这里卷积核大小、步长和填充三个参数直接决定注意力模块拿到的特征图尺寸和感受野。import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch, stride1): super().__init__() self.conv nn.Conv2d(in_ch, out_ch, kernel_size3, stridestride, padding1, biasFalse) self.bn nn.BatchNorm2d(out_ch) self.relu nn.ReLU(inplaceTrue) def forward(self, x): return self.relu(self.bn(self.conv(x)))kernel_size3、padding1、stride1 的组合保证了卷积前后特征图宽高不变这是注意力模块最喜欢的状态因为通道注意力期望空间维度不变空间注意力期望能拿到整张特征图。biasFalse 是因为 BatchNorm 自带偏置项卷积层再带 bias 会造成冗余。真正下采样不是靠 padding而是靠 stride2 的卷积或 MaxPool2d这也是工程上避免信息丢失的常规做法。3.2 SE 通道注意力先压缩再激发的标准实现SE 是通道注意力机制的代表也是这个项目里性价比最高的模块。它的逻辑是先通过全局平均池化把每个通道压成一个标量再经过两个全连接层学习通道间的依赖关系最后用 sigmoid 生成 0 到 1 的权重乘回原特征。class SEBlock(nn.Module): def __init__(self, channels, reduction16): super().__init__() self.pool nn.AdaptiveAvgPool2d(1) self.fc nn.Sequential( nn.Linear(channels, channels // reduction, biasFalse), nn.ReLU(inplaceTrue), nn.Linear(channels // reduction, channels, biasFalse), nn.Sigmoid() ) def forward(self, x): b, c, _, _ x.shape w self.pool(x).view(b, c) # 全局平均池化 w self.fc(w).view(b, c, 1, 1) # 通道权重 return x * wAdaptiveAvgPool2d(1)的输出和输入分辨率解耦所以 SE 可以插在网络任何位置不会因为特征图尺寸变化而报错。reduction16 是默认经验值能把channels压缩到channels // 16再还原。如果 reduction 太小比如 4全连接层参数会明显变多提升却有限如果 reduction 太大比如 64通道间的非线性拟合能力不够注意力权重趋向于均匀分布等于白加。我的经验是 8 到 16 之间优先试 16显存紧张时再降到 8。3.3 多头自注意力在视觉模型里的轻量写法把自注意力机制用在图像上最直接的做法是把特征图看成 H×W 个 token每个 token 的通道维度就是特征向量。完整的多头注意力需要计算 N×N 的注意力矩阵NH×W 在分辨率高时开销极大所以工程上要先降通道、后段插入。class SpatialSelfAttention(nn.Module): def __init__(self, in_ch, heads8, reduction4): super().__init__() self.heads heads self.head_dim (in_ch // reduction) // heads self.q nn.Conv2d(in_ch, in_ch // reduction, 1, biasFalse) self.k nn.Conv2d(in_ch, in_ch // reduction, 1, biasFalse) self.v nn.Conv2d(in_ch, in_ch // reduction, 1, biasFalse) self.out nn.Conv2d(in_ch // reduction, in_ch, 1, biasFalse) def forward(self, x): b, c, h, w x.shape q self.q(x).view(b, self.heads, self.head_dim, h * w) k self.k(x).view(b, self.heads, self.head_dim, h * w) v self.v(x).view(b, self.heads, self.head_dim, h * w) attn torch.softmax(q.transpose(-1, -2) k / (self.head_dim ** 0.5), dim-1) out (attn v.transpose(-1, -2)).transpose(-1, -2) return x self.out(out.contiguous().view(b, -1, h, w))这段代码的核心在attn torch.softmax(...)q.transpose(-1, -2) k得到的是每个位置对所有位置的相似度得分除以head_dim ** 0.5是为了防止点积结果过大把 softmax 推入饱和区这也是 Transformer 原版 attention 的标准缩放系数。softmax 在最后一维上做保证每个查询位置对所有键位置的注意力权重和为 1。最后return x ...是残差连接让注意力模块只学习增量部分这一条对训练稳定性帮助极大务必保留。插入位置建议放在网络后段比如特征图降到 28×28 或 14×14 之后。这个分辨率下 N784 或 196计算量可控同时后段特征的语义信息更丰富。以下是三种注意力策略在典型小块数据上的表现对比方案参数量增量适用位置效果特征SE通道很低任意 block 末尾通道重标定稳空间自注意力中网络后段长距离依赖SE 空间并联中高后段两分支通道与位置兼顾如果项目里只想改动最小先只加 SE 观察提升若要处理图像中目标尺度差异大的场景再加空间自注意力。这符合“先局部、后全局”的融合策略。4. 训练脚本解读train.py 的优化器、损失函数与学习率策略4.1 优化器选择AdamW 比 Adam 更稳包含注意力机制的网络参数分布比纯 CNN 更不均匀直接上 SGD 收敛慢普通 Adam 又容易因为权重衰减实现方式不对导致泛化变差。常见做法是用 AdamW它的解耦权重衰减不干扰自适应学习率估计。损失函数保持CrossEntropyLoss即可注意力模块不需要自定义损失。import torch.optim as optim import torch.nn as nn criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay5e-4)lr 从 1e-3 起步是注意力模块的常见安全区间。发现 loss 震荡优先调低到 3e-4不要急着动网络结构。weight_decay 5e-4 对中小数据集比较通用过大超过 1e-2会把注意力权重压向均匀分布。4.2 warmup 与余弦退火调度器自注意力机制对训练初期学习率很敏感。刚初始化的 QKV 投影会输出较大方差如果直接给大学习率注意力矩阵容易过早固化。工程上的标准解法是前几个 epoch 做线性 warmup之后接余弦退火。from torch.optim.lr_scheduler import LinearLR, CosineAnnealingLR total_epochs 60 warmup_epochs 5 scheduler SequentialLR( optimizer, schedulers[ LinearLR(optimizer, start_factor0.1, end_factor1.0, total_iterswarmup_epochs), CosineAnnealingLR(optimizer, T_maxtotal_epochs - warmup_epochs) ], milestones[warmup_epochs] )start_factor0.1表示首个 epoch 用 1e-4 学习率然后线性爬升到 1e-3。milestones 告诉 SequentialLR 在第五个 epoch 结束时切换调度器。warmup 轮数占总数比例 5% 到 10% 都算正常。4.3 训练循环与 checkpoint 保存位置训练循环本身不复杂但有两个细节要对齐模型要model.train()每次迭代梯度要optimizer.zero_grad()。注意力模块的权重文件建议和 backbone 分开保存方便后续单独微调哪一个部分。for epoch in range(total_epochs): model.train() running_loss 0.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() running_loss loss.item() scheduler.step() if (epoch 1) % 10 0: torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), }, fcheckpoints/sanxiao_{epoch1:02d}.pth)每一个 for 循环后面都配一个scheduler.step()确保学习率按时更新。checkpoint 里同时保存 optimizer 和 scheduler 的状态是为了将来中断训练后能完全恢复现场。只存model.state_dict()的话恢复时学习率会出现跳变注意力模块尤其敏感。训练阶段的超参可以按下面的表来调整超参数建议值调参方向lr1e-3震荡则降到 3e-4weight_decay5e-4过拟合可升到 1e-3warmup_epochs5大数据集加长batch_size32 或 64不满则减半total_epochs60看验证集收敛情况5. 从测试到落地tes.py 的推理流程与坏样本分析5.1 单张推理时最容易忽略的预处理对齐tes.py 的核心逻辑是加载训练好的权重对输入图片做和训练完全相同的预处理然后前向推理取 argmax。最容易翻车的地方是测试时忘了走一遍归一化直接用 0 到 255 的原始像素喂给模型导致输出概率整体偏移。def predict_one(model, image_path, label_map, device): model.eval() img cv2.imread(image_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, (224, 224)) img img.astype(np.float32) / 255.0 img (img - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) img torch.from_numpy(np.transpose(img, (2, 0, 1))).unsqueeze(0).float().to(device) with torch.no_grad(): logits model(img) prob torch.softmax(logits, dim1) idx torch.argmax(prob, dim1).item() return label_map[idx], prob[0, idx].item()unsqueeze(0)是把单张图补成 batch 维度torch.no_grad()用来关闭梯度图推理时显存占用会大幅下降。如果要批量测试直接用 DataLoader 组织数据集不需要改模型代码。5.2 只看准确率是不够的混淆矩阵与置信度分布分类模型在类别不均衡时acc 是欺骗性最强的指标。tes.py 如果只是打印 top-1 accuracy很可能漏掉某个类别完全没学会的情况。常见的做法是额外保存每张测试图的置信度再按类别画混淆矩阵from sklearn.metrics import confusion_matrix preds [] probs [] for images, labels in test_loader: with torch.no_grad(): output model(images) prob torch.softmax(output, dim1) preds.extend(torch.argmax(prob, dim1).cpu().numpy()) probs.extend(prob.max(dim1).values.cpu().numpy()) cm confusion_matrix(y_true, preds) print(混淆矩阵\n, cm)对置信度做分桶统计也有价值。把 softmax 最大概率低于 0.6 的样本单独导出通常能发现目标过小、遮挡、光照异常等问题。这些坏样本就是下一轮数据增强和注意力模块调整最直接的依据——先看它们集中在哪个类别再决定是在网络浅层加通道注意力还是在后段调整自注意力的 head 数量。本文还有配套的精品资源点击获取