第J5周:SE注意力机制
·
- 🍨 本文为🔗365天深度学习训练营 中的学习记录博客
- 🍖 原作者:K同学啊
前言
- 环境版本
python 3.9.23
pytorch 2.5.1
pytorch-cuda 11.8
pytorch-mutex 1.0
torchaudio 2.5.1
torchinfo 1.8.0
torchvision 0.20.1
Visual Studio Code 1.104.2 (user setup)
- SE注意力机制
- SE(Squeeze-and-Excitation) 可以理解成“给每个通道打分”的模块:重要的通道分数高,不重要的分数低。
- 它先用全局平均池化把每个通道“压缩”成一个数(看这个通道整体有多重要);再用一个小的全连接网络算出每个通道的权重;最后把权重乘回去,让网络更关注有用通道。
- 改动不大、加的参数也不多,但有机会让模型更聚焦在重要特征上,少被背景噪声带跑。
代码实现
设置gpu
import torch
import torch.nn as nn
from torchvision import transforms, datasets
from PIL import Image
import matplotlib.pyplot as plt
import os,PIL,pathlib,warnings
warnings.filterwarnings("ignore") #忽略警告信息
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
device

数据导入
# 数据导入
data_dir = "../../datasets/MonkeyPox/"
train_transforms = transforms.Compose([
transforms.Resize([224, 224]), # 将输入图片resize成统一尺寸
transforms.ToTensor(), # 将图像转换为tensor,并归一化到[0,1]之间
transforms.Normalize( # 转换为标准正太分布(高斯分布)
mean=[0.485, 0.456, 0.406],
std =[0.229, 0.224, 0.225])
])
total_data = datasets.ImageFolder(data_dir, transform = train_transforms)
total_data

标签打印
# 数据集中每个类别名称对应的数字标签(class_to_idx),用于模型训练时的标签编码
total_data.class_to_idx

数据集划分
# 划分训练集和测试集(8:2)
total_size = len(total_data)
train_size = int(0.8 * total_size)
test_size = total_size - train_size
train_dataset, test_dataset = torch.utils.data.random_split(total_data, [train_size, test_size])
train_dataset, test_dataset
# 创建训练数据加载器:每次从训练集中加载 batch_size=32 个样本,并打乱顺序(shuffle=True)
train_loader = torch.utils.data.DataLoader(train_dataset,
batch_size=32,
shuffle=True)
# 创建测试数据加载器:每次从测试集中加载 batch_size=4 个样本,不打乱顺序(默认 shuffle=False)
test_loader = torch.utils.data.DataLoader(test_dataset,
batch_size=32)

验证数据形状
# 打印一个 batch 的数据形状以验证
for X, y in test_loader:
print(f"输入张量形状 [Batch, Channel, Height, Width]: {X.shape}")
print(f"标签形状: {y.shape}, 数据类型: {y.dtype}")
break

构建模型
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
class SEAttention(nn.Module):
def __init__(self, channel, reduction=16):
super().__init__()
self.avg_pool = nn.AdaptiveAvgPool2d(1)
self.fc = nn.Sequential(
nn.Linear(channel, channel // reduction, bias=False),
nn.ReLU(inplace=True),
nn.Linear(channel // reduction, channel, bias=False),
nn.Sigmoid()
)
def forward(self, x):
b, c, _, _ = x.size()
y = self.avg_pool(x).view(b, c) # (b, c)
y = self.fc(y).view(b, c, 1, 1) # (b, c, 1, 1)
return x * y
class Bottleneck(nn.Module):
"""
DenseNet 中的瓶颈层(Bottleneck Layer)
- 结构:BN → ReLU → 1×1 Conv → BN → ReLU → 3×3 Conv → SE → 拼接
- 在 3×3 卷积后添加 SE 模块,对新生成的 growth_rate 个特征通道进行重标定
"""
def __init__(self, in_channels, growth_rate):
super(Bottleneck, self).__init__()
# 第一个 1×1 卷积用于降维(Bottleneck),将输入通道压缩为 4 * growth_rate
self.bn1 = nn.BatchNorm2d(in_channels) # 对输入进行批标准化
self.conv1 = nn.Conv2d(in_channels, 4 * growth_rate, kernel_size=1, bias=False) # 1×1 卷积,减少计算量
# 第二个 3×3 卷积用于生成 growth_rate 个新特征图
self.bn2 = nn.BatchNorm2d(4 * growth_rate) # 对压缩后的特征做批标准化
self.conv2 = nn.Conv2d(4 * growth_rate, growth_rate, kernel_size=3, padding=1, bias=False) # 3×3 卷积,padding=1 保持尺寸不变
# SE 注意力模块(作用于新生成的 growth_rate 个通道)
# self.se = SEAttention(growth_rate) # 注意:通道数是 growth_rate
def forward(self, x):
out = self.conv1(F.relu(self.bn1(x))) # 先 BN → ReLU → 1×1 Conv(通道压缩)
out = self.conv2(F.relu(self.bn2(out))) # 再 BN → ReLU → 3×3 Conv(特征生成)
# out = self.se(out) # 应用 SE 注意力(仅对新生成的特征)
out = torch.cat([x, out], dim=1) # 将原始输入 x 与新特征在通道维度拼接(dense connection)
return out
class DenseBlock(nn.Module):
"""
Dense Block:由多个 Bottleneck 层堆叠而成
- 每一层的输入 = 原始输入 + 所有前序层输出的拼接
- 实现“密集连接”(Dense Connectivity):每一层都接收前面所有层的特征作为输入
"""
def __init__(self, in_channels, num_layers, growth_rate):
super(DenseBlock, self).__init__()
# 使用 ModuleList 动态构建多个 Bottleneck 层
# 第 i 层的输入通道数 = in_channels + i * growth_rate(因为每层新增 growth_rate 个通道)
self.layers = nn.ModuleList([
Bottleneck(in_channels + i * growth_rate, growth_rate)
for i in range(num_layers)
])
def forward(self, x):
for layer in self.layers:
x = layer(x) # 顺序通过每个 Bottleneck,每次都将新特征拼接到 x 上
return x
class Transition(nn.Module):
"""
Transition Layer:连接两个 DenseBlock,用于降低复杂度
- 功能1:1×1 卷积压缩通道数(通常压缩为原来的一半,即 compression factor θ=0.5)
- 功能2:2×2 平均池化进行下采样(空间尺寸减半)
- 结构:BN → ReLU → 1×1 Conv → AvgPool
"""
def __init__(self, in_channels, out_channels):
super(Transition, self).__init__()
self.bn = nn.BatchNorm2d(in_channels) # 批标准化
self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) # 1×1 卷积,压缩通道
# self.se = SEAttention(out_channels) # 注释掉 Transition 层的 SE
self.avg_pool = nn.AvgPool2d(kernel_size=2, stride=2) # 2×2 平均池化,步长为2,实现下采样
def forward(self, x):
out = self.conv(F.relu(self.bn(x)))
# out = self.se(out) # 注释掉 Transition 层的 SE 应用
out = self.avg_pool(out)
return out
class DenseNet121(nn.Module):
"""
DenseNet 主干网络(参考 DenseNet-121/169/201 等结构)
- 特点:密集连接(Dense Connectivity)、瓶颈层(Bottleneck)、过渡层(Transition)
- block_config 控制每个 DenseBlock 中的层数(如 (6,12,24,16) 对应 DenseNet-121)
"""
def __init__(self, growth_rate=32, block_config=(6, 12, 24, 16), num_classes=2):
super().__init__()
# 初始特征提取层
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) # 7×7 卷积,stride=2 下采样
self.bn1 = nn.BatchNorm2d(64) # 批标准化
self.max_pool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # 3×3 最大池化,进一步下采样(总下采样倍数为4)
num_features = 64 # 当前特征图的通道数(初始为64)
self.blocks = nn.ModuleList([]) # 存储所有 DenseBlock 和 Transition 层
# 构建多个 DenseBlock + Transition 层
for i, num_layers in enumerate(block_config):
# 创建一个 DenseBlock
block = DenseBlock(num_features, num_layers, growth_rate)
self.blocks.append(block)
num_features += num_layers * growth_rate # 每个 DenseBlock 新增 num_layers * growth_rate 个通道
# 如果不是最后一个 DenseBlock,则添加 Transition 层(压缩 + 下采样)
if i != len(block_config) - 1:
trans = Transition(num_features, num_features // 2)
self.blocks.append(trans)
num_features = num_features // 2
# 分类头
self.bn_final = nn.BatchNorm2d(num_features) # 最后一个块输出的批标准化
self.avg_pool = nn.AdaptiveAvgPool2d((1, 1)) # 全局平均池化
self.fc = nn.Linear(num_features, num_classes) # 全连接层,输出类别 logits
def forward(self, x):
# 初始卷积 + 池化(下采样至 1/4)
out = self.conv1(x)
out = self.bn1(out)
out = F.relu(out)
out = self.max_pool(out)
# 依次通过所有 DenseBlock 和 Transition 层
for block in self.blocks:
out = block(out) # 自动处理 DenseBlock(特征拼接)或 Transition(压缩+下采样)
# 最终分类处理
out = self.bn_final(out)
out = F.relu(out)
out = self.avg_pool(out)
out = out.view(out.size(0), -1)
out = self.fc(out)
return out
# 自动选择设备(GPU 优先)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 实例化 DenseNet 模型
model = DenseNet121().to(device)
# 查看模型结构
import torchsummary as summary
summary.summary(model, (3, 224, 224))
训练和测试函数
# 训练循环
def train(dataloader, model, loss_fn, optimizer):
size = len(dataloader.dataset) # 训练集的大小
num_batches = len(dataloader) # 批次数目(size/batch_size,向上取整)
train_loss, train_acc = 0, 0 # 初始化训练损失和正确率
for X, y in dataloader: # 获取图片及其标签
X, y = X.to(device), y.to(device)
# 计算预测误差
pred = model(X) # 网络输出
loss = loss_fn(pred, y) # 计算网络输出和真实值之间的差距
# 反向传播
optimizer.zero_grad() # grad属性归零
loss.backward() # 反向传播
optimizer.step() # 每一步自动更新
# 记录acc和loss
train_acc += (pred.argmax(1) == y).type(torch.float).sum().item()
train_loss += loss.item()
train_acc /= size # 计算训练集整体正确率
train_loss /= num_batches # 计算训练集平均损失
return train_acc, train_loss
# 测试函数
def test(dataloader, model, loss_fn):
size = len(dataloader.dataset) # 测试集的大小
num_batches = len(dataloader) # 批次数目(size/batch_size,向上取整)
test_loss, test_acc = 0, 0
# 当不进行训练时,停止梯度更新,节省计算内存消耗
with torch.no_grad():
for imgs, target in dataloader:
imgs, target = imgs.to(device), target.to(device)
# 计算loss
target_pred = model(imgs)
loss = loss_fn(target_pred, target)
test_loss += loss.item()
test_acc += (target_pred.argmax(1) == target).type(torch.float).sum().item()
test_acc /= size
test_loss /= num_batches
return test_acc, test_loss
正式训练
# 训练
import copy
import torch
import torch.nn as nn
import torch.optim as optim
# 初始化优化器与损失函数
optimizer = optim.AdamW(model.parameters(), lr=1e-4)
loss_fn = nn.CrossEntropyLoss() # 创建损失函数
# T_max 通常设为总 epoch 数
epochs = 30 # 训练轮数
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-6)
# 初始化指标记录列表
train_loss = []
train_acc = []
test_loss = []
test_acc = []
best_acc = 0 # 设置最佳准确率,作为保存最佳模型的指标
for epoch in range(epochs):
# 训练阶段
model.train() # 开启训练模式(启用 Dropout、BatchNorm 等层的训练行为)
epoch_train_acc, epoch_train_loss = train(train_loader, model, loss_fn, optimizer)
# 测试阶段
model.eval() # 开启评估模式(禁用 Dropout、固定 BatchNorm 等层的参数)
epoch_test_acc, epoch_test_loss = test(test_loader, model, loss_fn)
scheduler.step()
# 保存最佳模型
if epoch_test_acc > best_acc:
best_acc = epoch_test_acc
best_model = copy.deepcopy(model) # 深拷贝当前最佳模型
# 记录训练/测试指标
train_acc.append(epoch_train_acc)
train_loss.append(epoch_train_loss)
test_acc.append(epoch_test_acc)
test_loss.append(epoch_test_loss)
# 获取当前学习率
lr = optimizer.state_dict()['param_groups'][0]['lr']
# 打印当前轮次的指标
template = ('第 {:2d} 轮,训练准确率:{:.1f}%,训练损失:{:.3f},测试准确率:{:.1f}%,测试损失:{:.3f},学习率:{:.2E}')
print(template.format(epoch + 1,
epoch_train_acc * 100,
epoch_train_loss,
epoch_test_acc * 100,
epoch_test_loss,
lr))
# 保存最佳模型到文件
PATH = './best_model.pth' # 保存的参数文件名
torch.save(best_model.state_dict(), PATH) # 保存模型的参数状态字典
print('完成')
可视化训练结果
# 结果可视化
import matplotlib.pyplot as plt
# 隐藏警告
import warnings
warnings.filterwarnings("ignore") # 忽略警告信息
# 配置 Matplotlib 显示(解决中文/负号显示问题)
plt.rcParams['font.sans-serif'] = ['SimHei'] # 正常显示中文标签
plt.rcParams['axes.unicode_minus'] = False # 正常显示负号
plt.rcParams['figure.dpi'] = 100 # 设置图像分辨率为 100
from datetime import datetime
current_time = datetime.now() # 获取当前时间
epochs_range = range(epochs)
# 创建画布并绘制子图
plt.figure(figsize=(12, 3))
# 子图 1:准确率曲线
plt.subplot(1, 2, 1)
plt.plot(epochs_range, train_acc, label='训练准确率')
plt.plot(epochs_range, test_acc, label='测试准确率')
plt.legend(loc='lower right')
plt.title('训练与验证准确率')
plt.xlabel(f'训练轮次(生成时间:{current_time.strftime("%Y-%m-%d %H:%M:%S")})') # 横轴标注当前时间
# 子图 2:损失曲线
plt.subplot(1, 2, 2)
plt.plot(epochs_range, train_loss, label='训练损失')
plt.plot(epochs_range, test_loss, label='测试损失')
plt.legend(loc='upper right')
plt.title('训练与验证损失')
plt.show()
对比
Densenet121



在Transition块加se
class Transition(nn.Module):
"""
Transition Layer:连接两个 DenseBlock,用于降低复杂度
- 功能1:1×1 卷积压缩通道数(通常压缩为原来的一半,即 compression factor θ=0.5)
- 功能2:2×2 平均池化进行下采样(空间尺寸减半)
- 结构:BN → ReLU → 1×1 Conv → AvgPool
"""
def __init__(self, in_channels, out_channels):
super(Transition, self).__init__()
self.bn = nn.BatchNorm2d(in_channels) # 批标准化
self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) # 1×1 卷积,压缩通道
self.se = SEAttention(out_channels) # 注释掉 Transition 层的 SE
self.avg_pool = nn.AvgPool2d(kernel_size=2, stride=2) # 2×2 平均池化,步长为2,实现下采样
def forward(self, x):
out = self.conv(F.relu(self.bn(x)))
out = self.se(out) # 注释掉 Transition 层的 SE 应用
out = self.avg_pool(out)
return out



在Bottleneck块加se
class Bottleneck(nn.Module):
"""
DenseNet 中的瓶颈层(Bottleneck Layer)
- 结构:BN → ReLU → 1×1 Conv → BN → ReLU → 3×3 Conv → SE → 拼接
- 在 3×3 卷积后添加 SE 模块,对新生成的 growth_rate 个特征通道进行重标定
"""
def __init__(self, in_channels, growth_rate):
super(Bottleneck, self).__init__()
# 第一个 1×1 卷积用于降维(Bottleneck),将输入通道压缩为 4 * growth_rate
self.bn1 = nn.BatchNorm2d(in_channels) # 对输入进行批标准化
self.conv1 = nn.Conv2d(in_channels, 4 * growth_rate, kernel_size=1, bias=False) # 1×1 卷积,减少计算量
# 第二个 3×3 卷积用于生成 growth_rate 个新特征图
self.bn2 = nn.BatchNorm2d(4 * growth_rate) # 对压缩后的特征做批标准化
self.conv2 = nn.Conv2d(4 * growth_rate, growth_rate, kernel_size=3, padding=1, bias=False) # 3×3 卷积,padding=1 保持尺寸不变
# SE 注意力模块(作用于新生成的 growth_rate 个通道)
self.se = SEAttention(growth_rate) # 注意:通道数是 growth_rate
def forward(self, x):
out = self.conv1(F.relu(self.bn1(x))) # 先 BN → ReLU → 1×1 Conv(通道压缩)
out = self.conv2(F.relu(self.bn2(out))) # 再 BN → ReLU → 3×3 Conv(特征生成)
out = self.se(out) # 应用 SE 注意力(仅对新生成的特征)
out = torch.cat([x, out], dim=1) # 将原始输入 x 与新特征在通道维度拼接(dense connection)
return out



对比分析
- 在 DenseNet 里加 SE 可能有什么帮助
- DenseNet 会把前面很多层的特征都拼在一起,通道会越来越多,其中可能有一些“重复的、没用的”特征。
- SE 做的事情就是:让网络自己学会“哪些通道更重要”,把重要的放大一点,不重要的压小一点。
- 想到的两种添加方式
- 加在 Transition:Transition 相当于每个大阶段之间的“中转站”(还会做压缩和下采样)。把 SE 放这儿,像是“每过一关就检查一次整体特征”,优点是模块少、改动简单。
- 加在 Bottleneck:Bottleneck 是每一层产生新特征的地方。把 SE 放这儿,像是“每次新产生的特征先过一下筛子”,再拼接到后面,思路上更细、更频繁。
- 为什么这次两种方式看起来都没明显提升,考虑到的可能原因如下
- 原版 DenseNet 已经挺强了:有时候加一点模块提升很小,肉眼看曲线不明显。
- 数据集不大,波动会比较大:不同随机划分/随机种子,结果可能上下跳,容易把一点点提升盖住。
- 插入位置本身也有限制:Transition 放得偏后;Bottleneck 如果只对新生成的小部分通道加权,力度可能不够。
预测
from PIL import Image
classes = list(total_data.class_to_idx)
def predict_one_image(image_path, model, transform, classes):
test_img = Image.open(image_path).convert('RGB')
# plt.imshow(test_img) # 展示预测的图片
test_img = transform(test_img)
img = test_img.to(device).unsqueeze(0)
model.eval()
output = model(img)
_,pred = torch.max(output,1)
pred_class = classes[pred]
print(f'预测结果是:{pred_class}')
# 预测训练集中的某张照片
predict_one_image(image_path='../../datasets/MonkeyPox/MonkeyPox/M01_01_00.jpg',
model=model,
transform=train_transforms,
classes=classes)

总结
- 这次实现了 SE 模块,并把它分别加到了 DenseNet121 的 Transition 和 Bottleneck 两个位置,做了对比实验。
- 从这次的结果看,SE 并没有带来很明显的提升。比较合理的原因是:原本的densenet121模型已经不错 + 数据量不大导致波动 ,所以 SE 的“潜在优势”没稳定体现出来。
- 这个思路还能怎么迁移:只要是 CNN 网络,都可以尝试把 SE 放到“特征要融合/要相加/要拼接”的地方。
- 换网络:ResNet 可以放在残差相加前后;YOLO可以放在多尺度特征融合处。
- 换任务:分类可以用,检测/分割也能用,尤其是多尺度融合和跳连的位置更值得试。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)