前言

  • 环境版本
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

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

对比分析
  1. 在 DenseNet 里加 SE 可能有什么帮助
    • DenseNet 会把前面很多层的特征都拼在一起,通道会越来越多,其中可能有一些“重复的、没用的”特征。
    • SE 做的事情就是:让网络自己学会“哪些通道更重要”,把重要的放大一点,不重要的压小一点。
  2. 想到的两种添加方式
    • 加在 Transition:Transition 相当于每个大阶段之间的“中转站”(还会做压缩和下采样)。把 SE 放这儿,像是“每过一关就检查一次整体特征”,优点是模块少、改动简单。
    • 加在 Bottleneck:Bottleneck 是每一层产生新特征的地方。把 SE 放这儿,像是“每次新产生的特征先过一下筛子”,再拼接到后面,思路上更细、更频繁。
  3. 为什么这次两种方式看起来都没明显提升,考虑到的可能原因如下
    • 原版 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可以放在多尺度特征融合处。
    • 换任务:分类可以用,检测/分割也能用,尤其是多尺度融合和跳连的位置更值得试。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐