双线性池化与DenseNet细粒度分类实战:从原理到代码优化

发布时间:2026/10/11 18:04:11
双线性池化与DenseNet细粒度分类实战:从原理到代码优化 简介这份代码来自杭州电子科技大学2024届本科生毕业设计实现基于DenseNet的双线性网络模型面向大学生和自学者可用于毕业设计参考、深度学习练手或图像特征提取研究。压缩包共66个文件以60个Python脚本为主覆盖模型定义、数据加载、训练测试、评估工具与梯度可视化等完整流程另有2个Markdown文档和配置文件辅助阅读包体仅93KB轻量便携。目前已有56人学习下载。项目核心是DenseNet双线性模型的具体实现详细定义了网络层间连接与参数配置同时集成CoT、CBAM、SE、DAT等十余种注意力机制模块方便对比不同注意力机制对特征提取效果的影响。配套说明文档梳理了模型原理、环境配置与运行方式工具脚本则提供早停、混淆矩阵、准确率统计等实用功能适合希望深入理解双线性网络并掌握工程实现的深度学习学习者。1. 这不是普通的DenseNet分类网络双线性池化到底改了什么我拿到这套2024年6月杭电本科毕业设计的代码时第一反应是“又是DenseNet分类”拆开才发现它玩的是双线性网络模型主干用DenseNet121取最后卷积块输出的特征图两个分支在空间位置上做外积再归一化最后接分类头。外积和常见的GAP、拼接完全不是一回事它能显式建模通道两两之间的关系在细粒度识别鸟类、车型、菜品这类上比直接用ResNet/DenseNet做分类高出一截。适合谁准备复现毕设、做细粒度分类、或者想在分类任务上提点的同学这套代码能直接当base line跑也能拆开改。2. 双线性池化原理与PyTorch实现外积、归一化与分类头的完整数据流2.1 为什么选DenseNet当双线性网络的主干先回答一个我拆代码前也纠结的问题既然双线性网络可以套任意backbone为什么这套毕设偏偏选了DenseNet而不是ResNet两个原因一个是DenseNet的特征图本身就带密集连接每层都能看到前面所有层的输出通道之间的冗余信息被反复利用双线性外积在这种结构上能学到更丰富的通道关联特征。另一个是参数效率DenseNet121只有约7M参数加上双线性池化后的高维分类头总量仍然比ResNet50轻这在单卡训练时很关键。细粒度分类场景里很多目标只有很小的局部差异比如鸟类的喙、车型的后视镜、菜品的酱汁颜色。ResNet的残差结构擅长深层语义但空间细节在逐层下采样中丢得比较快DenseNet因为特征复用浅层细节能直接传到深层外积又把这些细节的共现关系强化了。这套代码在CUB-200这类标准细粒度数据集上做分类就是冲着“找局部判别特征”去的。还有个实际原因torchvision里直接有ImageNet预训练的densenet121迁移成本极低。代码里直接用models.densenet121(weightsIMAGENET1K_V1)不需要自己从头训练主干。现在很多人转向CLIP微调或者Transformer结构做细粒度识别但这类大模型在单卡、小数据条件下很容易翻车双线性网络反而是更稳的baseline。2.2 双线性层的实现torch.bmm外积与两步归一化这套代码的核心就在双线性池化层。标准Bilinear CNN的做法是对两个分支输出的特征图在每个空间位置上把两个特征向量做外积再对所有位置求平均得到通道维度的外积矩阵。我把它独立成一个模块来看逻辑最清楚import torch import torch.nn as nn import torch.nn.functional as F class BilinearPooling(nn.Module): 标准双线性池化。 输入两个分支的特征图: (N, C, H, W) 输出: 展平后的双线性向量, (N, C1 * C2) def __init__(self, in_channels_a1024, in_channels_b1024): super().__init__() self.C1 in_channels_a self.C2 in_channels_b def forward(self, feat_a, feat_b): N, C1, H, W feat_a.shape N2, C2, H2, W2 feat_b.shape assert (H, W) (H2, W2), 两个分支的输出特征图尺寸必须一致 # 展平成 N, C, LLH*W 是空间位置数 x_a feat_a.view(N, C1, H * W) x_b feat_b.view(N, C2, H * W) # 外积: x_a 是 N,C1,L, x_b 转置后是 N,L,C2 bilinear torch.bmm(x_a, x_b.transpose(1, 2)) / (H * W) # 展平后先平方根归一化再做 L2 归一化 out bilinear.view(N, -1) out torch.sqrt(out 1e-12) out F.normalize(out, p2, dim1) return out逻辑说明这里用torch.bmm做批量矩阵乘法把空间维度压缩成L一次算完所有位置的外积并取平均。除以H*W是在做均值池化避免特征图尺寸变化后数值范围漂移。平方根归一化和L2归一化是两个经典步骤前者是原作者在论文里验证过的稳定技巧后者让特征落到单位球面上后续接全连接层时数值更平滑。参数说明in_channels_a和in_channels_b分别是两个分支输出的通道数。这套代码里DenseNet121最后的特征图通道是1024所以两个参数都是1024外积矩阵就是1024x1024展平后是1,048,576维。这个维度是后续所有显存和参数量问题的根源后面“避坑”一章我会单独展开。2.3 搭双分支DenseNet共享权重还是不共享双线性网络需要两个特征提取分支。这套代码的实现方式很典型把同一个densenet121的features模块当作两个分支默认共享权重。共享的好处是显存占用少、参数量少训练更容易收敛缺点是两个分支完全一样理论上退化成单一特征。不过实际训练中因为随机丢弃和BN的batch统计两个分支前向时仍然会有微小差异。import copy import torch.nn as nn from torchvision import models class DenseNetBilinear(nn.Module): def __init__(self, num_classes200, shared_branchTrue): super().__init__() backbone models.densenet121(weightsmodels.DenseNet121_Weights.IMAGENET1K_V1) features backbone.features if shared_branch: # 共享同一个模块被前向两次但显存只占一份 self.branch_a features self.branch_b features else: # 不共享深拷贝出两个独立分支特征表达自由度更高 self.branch_a features self.branch_b copy.deepcopy(features) self.bilinear BilinearPooling(1024, 1024) self.dropout nn.Dropout(0.2) self.classifier nn.Linear(1024 * 1024, num_classes) def forward(self, x): feat_a self.branch_a(x) feat_b self.branch_b(x) bilinear self.bilinear(feat_a, feat_b) out self.classifier(self.dropout(bilinear)) return out逻辑说明共享分支时self.branch_a和self.branch_b指向同一个Module对象PyTorch会维护同一份参数反向传播时梯度会累加两次。不共享时用copy.deepcopy复制一份结构和权重两个分支独立更新。我在复现时把两种模式都跑过小数据集上共享版收敛更快、不容易过拟合大数据集上不共享有1到2个点的提升但训练时间几乎翻倍。参数说明shared_branch是这套代码里最值得调的一个开关。显存告急就开共享效果优先就关共享。num_classes按实际数据集改CUB-200是200斯坦福汽车是196你自己数据集就填类别数。分类头维度是1024*1024这个数字看起来很吓人但常规类别数量下PyTorch的Linear模块能撑住只是训练慢。2.4 训练主循环优化器和学习率怎么配双线性网络有个特点主干是ImageNet预训练的分类头是随机初始化的两者需要的学习率不一样。这套代码有没有做分层学习率我不确定但我的习惯是主干用小学习率、分类头用大学习率。下面是精简过的训练循环import torch import torch.nn.functional as F from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR model DenseNetBilinear(num_classes200).cuda() optimizer AdamW([ {params: model.branch_a.parameters(), lr: 1e-4}, {params: model.classifier.parameters(), lr: 1e-3}, ], weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max60) for epoch in range(60): model.train() for x, y in train_loader: x, y x.cuda(), y.cuda() logits model(x) loss F.cross_entropy(logits, y) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() # 每个epoch结束跑一次验证记录top-1和top-5逻辑说明分组设置学习率的关键是主干已经具备通用视觉特征只需要微调而分类头要从零学所以主干lr是1e-4、分类头lr是1e-3。如果两者都用1e-3容易出现主干微调过头、在细粒度任务上灾难性遗忘。CosineAnnealingLR的T_max设为60配合60个epoch正好从峰值余弦衰减到0这是目前细粒度分类训练的主流组合。参数说明weight_decay在双线性网络上别开太大1e-4是安全值过大的weight decay会让外积后的稀疏特征被压得过狠。batch_size建议至少8下一章避坑部分会讲为什么这句话值得记住。3. 复现环境与数据集组织从依赖安装到第一个epoch3.1 环境依赖与版本约束这套代码跑起来的环境没有特别冷门的东西PyTorch生态就能覆盖。我给的最小依赖清单如下顺序按装的时候容易踩坑的程度排pip install torch1.13.1 torchvision0.14.1 pip install tqdm tensorboard scikit-learn pandas逻辑说明torch版本选1.13.1是因为它稳定且torchvision里自带DenseNet预训练权重不需要额外下载模型文件。如果你的显卡驱动只支持CUDA 11.7以下把torch1.13.1换成torch1.12.1也能跑代码里没用任何新版本的专属API。scikit-learn只在特征可视化阶段用到不跑t-SNE可以暂时不装。参数说明这里没写--index-url因为国内镜像地址属于第三方源配置每个人网络环境不一样。如果预训练权重下载超时常见做法是手动下载h5文件放到~/.cache/torch/hub/checkpoints/下torchvision会自动读取这个路径不需要改代码。3.2 数据集目录ImageFolder和train/test划分这套代码喂数据用的是torchvision的ImageFolder所以数据集目录必须按类别建子文件夹。CUB-200这种公开数据集原始下载下来是一堆图片配一个labels.txt直接塞给ImageFolder会报错得先按类别归好文件夹。我处理完的目录结构如下data/ CUB_200_2011/ train/ Black_Footed_Albatross_0001_796111.jpg Black_Footed_Albatross_0002_55.jpg ... test/ ...from torchvision import datasets, transforms transform_train transforms.Compose([ transforms.Resize((448, 448)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) dataset datasets.ImageFolder( rootdata/CUB_200_2011/train, transformtransform_train, ) print(f类别数: {len(dataset.classes)}) print(f训练图片数: {len(dataset)})逻辑说明Resize到448x448是惯例。双线性池化需要特征图有足够大的空间分辨率来承载局部细节224输入会让最后特征图只有7x7外积能用的位置太少。448输入后特征图是14x14一共196个位置外积信息量明显更足。参数说明RandomRotation(15)角度别给太大鸟类和车型这类目标旋转超过15度会出现不自然的视角。如果你做的是方向敏感的工业检测任务建议去掉旋转增强。Normalize的均值标准差用ImageNet的因为主干是ImageNet预训练输入分布要与其对齐。3.3 训练脚本参数与首次启动检查这套代码的训练入口是一个train.py核心参数我拆成了几张表免得跑起来之后反复改文件。参数推荐值含义--batch-size16单卡batch显存小于11G请调成8--epochs60配合余弦退火收敛比较充分--lr1e-3分类头学习率主干会除以10--num-workers8加载线程数Windows建议4--image-size448输入分辨率--pretrainedTrue加载ImageNet权重启动前的检查脚本我一般会跑一个batch维度为2的冒烟测试确认前向、反向、维度全通# 先跑2个batch做冒烟测试 python train.py --batch-size 2 --epochs 1 --smoke-test # 没问题再正式训练 python train.py --batch-size 16 --epochs 60逻辑说明冒烟测试的batch_size设成2是因为双线性池化里bmm对batch维度没有特殊要求2就能验证所有张量shape对不对。很多毕设代码的问题要等到第一个epoch结束、验证阶段才会暴露冒烟测试可以提前逼出异常。参数说明--smoke-test是我加的快捷开关作用是在跑两个batch之后直接exit不进入完整训练。如果你拿到手的代码没这个开关自己临时在epoch循环里加个if step 2: break也行目的只有一个用最快速度确认代码能跑。4. 避坑指南双线性网络训练中的五个常见问题4.1 显存溢出外积矩阵是显存黑洞现象batch_size16时报CUDA out of memory提示卡在torch.bmm附近。原因双线性外积会把特征图从1024通道变成1024x1024矩阵单个样本就要占4MBfloat32batch16时光是外积矩阵就64MB加上两个DenseNet分支的特征图缓存11G显存很容易爆。这是我复现这套代码踩到的第一个坑而且只在训练阶段出现验证阶段因为不保存梯度反而没事。解决优先把batch_size降到8显存占用直接减半。如果还爆把共享分支开关打开DenseNet的中间特征只存一份。再不行就在BilinearPooling前加一个1x1卷积把通道从1024降到256再外积维度直接缩到十六分之一这是最有效的做法代价是精度会掉零点几个点。4.2 分类头参数量爆炸能不能训练完成了玄学现象训练top-1准确率很高验证准确率却迟迟上不去典型过拟合而且保存的模型文件动辄几百MB。原因外积后的特征维是1,048,576接nn.Linear(1024*1024, 200)这层权重就有2亿个参数约800MB。CNN主干才7M参数分类头却占绝对大头参数量严重失衡正则项根本压不住。解决在双线性特征之后、分类头之前插入一个nn.Linear(1M, 512)的瓶颈层把维度压到512再接输出层。或者更彻底一点在外积前把两个分支的通道都用1x1卷积降到128外积维度变成16384分类头参数降到几百万训练速度和稳定性都会明显改善。这套毕设代码里没有瓶颈层我复现时是临时加的建议你也加。4.3 两个分支共享权重到底该不该开现象开共享后训练很快但验证准确率比论文结果低1到2个点关掉共享后显存不够。原因共享权重的两个分支在数学上产生了对称性外积矩阵因为x_a和x_b来自同一分布信息冗余度高判别力下降。不共享又因为梯度同时流经两个深拷贝的DenseNet反向传播的中间变量翻倍。解决我的做法是先用共享模式调通整体流程确认能出合理结果再关掉共享去刷最终精度。如果你用单卡不共享模式下batch_size最多只能到8可以让两个分支在第一轮冻住几层减少显存压力。别一上来就追求论文复现先让代码能稳定跑完。4.4 BN层在batch_size1时直接崩现象测试阶段单张图预测报错Expected more than 1 value per channel when running forward。原因DenseNet里大量BN层。如果把batch_size设成1BN层的batch统计量算不出来。这个问题在验证阶段特别容易翻车因为很多人的验证循环是逐张图跑的。解决验证和推理时务必先把模型切到eval()模式BN会用全局统计量而不是batch统计量。如果必须在单张图上预测代码里要显示调用model.eval()再加torch.no_grad()。我拆这套代码时发现训练函数里做了eval切换但单张预测脚本没做属于明显的边界坑自己写推理时要补上。4.5 数据增强太猛导致训练发散现象训练loss下降很慢甚至前10个epoch准确率都不到10%跟随机猜差不多。原因细粒度分类本身类间差异小如果翻转、旋转、裁剪一起上很多样本的有效信息被破坏。比如鸟类图片旋转90度后头和身体的位置关系失真模型学到的是旋转不变性而不是类别区分。解决把RandomRotation从(15, 15)改成(-15, 15)或者直接去掉。保留RandomHorizontalFlip就够了。如果训练集只有几千张加RandomResizedCrop(scale(0.7, 1.0))不要用太激进的裁剪。数据增强是细粒度分类里最影响玄学结果的因素我通常会把增强强度当成一个超参数而不是默认全开。5. 验证与进阶用t-SNE和Grad-CAM确认模型学到了细粒度特征5.1 提取双线性特征做t-SNE可视化训练完别急着看准确率先抽特征做可视化这一步能直观看出模型有没有把类别分开。双线性特征足够高维直接做t-SNE需要很长时间我习惯在外积层后接的瓶颈层如果你加了输出上提取128维或512维特征速度会快很多。import torch import numpy as np from sklearn.manifold import TSNE import matplotlib.pyplot as plt features, labels [], [] model.eval() with torch.no_grad(): for x, y in val_loader: logits, feat model(x.cuda(), return_featTrue) # 注意需要临时改forward返回特征 features.append(feat.cpu().numpy()) labels.extend(y.numpy()) features np.concatenate(features, axis0) labels np.array(labels) tsne TSNE(n_components2, perplexity30, n_iter500, random_state42) proj tsne.fit_transform(features) plt.figure(figsize(8, 8)) plt.scatter(proj[:, 0], proj[:, 1], clabels, cmaptab20, s5) plt.savefig(tsne_bilinear.png, dpi150)逻辑说明return_featTrue是让我在意想不到的模型上常见的做法如果不是这套代码自带的功能在forward里把self.classifier(self.dropout(bilinear))改成先算出featself.dropout(bilinear)再返回(self.classifier(feat), feat)。t-SNE的perplexity参数和样本数相关样本量小于500时设30会发散我是直接降低到15。参数说明n_components2用于二维可视化n_iter500是收敛迭代数太小的话点会挤成一团。这个可视化适合观察类别边界是否重叠如果同类的点聚成多个团说明模型学到的特征有模态分裂需要考虑数据增强策略。5.2 Grad-CAM看模型到底在看哪里双线性网络的黑匣子问题比普通CNN更严重因为外积操作把空间和通道混合在一起单看特征图根本不知道模型关注哪块区域。Grad-CAM可以处理这个场景因为双线性池化对梯度是可微的外积层会把分类梯度传回主干特征图。取最后一个卷积层做热力图即可对DenseNet来说就是branch_a输出的那层特征图。def grad_cam(model, x, target_class): model.eval() x x.cuda().requires_grad_(True) # 注册hook抓取最后一个卷积层的输出 feat_map None grad None def forward_hook(module, input, output): nonlocal feat_map feat_map output def backward_hook(module, grad_input, grad_output): nonlocal grad grad grad_output[0] hook_forward model.branch_a.register_forward_hook(forward_hook) hook_backward model.branch_a.register_full_backward_hook(backward_hook) logits model(x) model.zero_grad() logits[0, target_class].backward() hook_forward.remove() hook_backward.remove() weights grad.mean(dim(2, 3), keepdimTrue) cam (weights * feat_map).sum(dim1, keepdimTrue) cam torch.relu(cam) cam F.interpolate(cam, size(448, 448), modebilinear, align_cornersFalse) return cam[0, 0].cpu().numpy()逻辑说明通过register_forward_hook拿到最后一个卷积特征图通过register_full_backward_hook拿到梯度两者逐通道加权求和再ReLU就得到类激活热力图。注意是挂到branch_a上因为双线性网络的两个分支都可能携带梯度但看一个分支就够了。如果共享权重看哪边都一样。参数说明target_class是你要可视化的类别索引。align_cornersFalse是F.interpolate的推荐设置避免坐标映射偏移。DenseNet的特征图经过interpolate放大到448会有轻微的棋盘效应但不影响判断模型的关注区域。6. 落地技巧两分支改成单分支推理速度直接翻倍先算一笔账训练时双分支共享权重但毕竟要把两个分支都过一遍别急共享权重意味着两个分支是同一个DenseNet但实际上代码里前向时branch_a(x)和branch_b(x)是两个独立调用计算量是双倍的。训练阶段为了梯度没办法推理阶段完全可以用单分支替代因为共享权重下两次前向的输出分布高度一致直接在特征图上做外积就行。我整理过一套实用的推理流程先生成一个特征图缓存再从缓存批量做双线性池化。这样每个batch只前向一次DenseNet外积部分从原来的中间层变成了后处理整体推理耗时几乎减半。# 推理阶段提取一次特征图统一算双线性 model.eval() feat_cache [] with torch.no_grad(): for x, _ in val_loader: feat model.branch_a(x.cuda()) # 只有一次前向 feat_cache.append(feat.cpu()) feat_cache torch.cat(feat_cache, dim0) # N, 1024, H, W with torch.no_grad(): bilinear model.bilinear(feat_cache.cuda(), feat_cache.cuda()) logits model.classifier(model.dropout(bilinear)) pred logits.argmax(dim1)逻辑说明branch_a(x)得到的特征图直接当作两个分支的输入传给bilinear因为权重共享理论上两次前向结果本来就应该无限接近唯一差异是BN在前向时依赖batch统计GPU推理时这一步已经固定所以单分支方案不会损失精度。模型到达验证集准确率时这个方案可以直接上生产环境。参数说明feat_cache的大小受显存限制如果验证集上万张每次缓存几百张分批处理。另外因为这个推理只跑一次主干你可以把输入分辨率从448降到320再对比一下准确率很多细粒度任务320分辨率损失在0.5个点以内但推理速度大幅提升。这套落地技巧里还有一个值得提的轻量化思路模型轻量化不一定要砍层把外积通道压下来是性价比更高的路径。比如在分支最后加1x1卷积把1024通道降到256外积维度变成165536分类头参数直接少了几十倍配合推理时单分支整个模型在边缘设备上也能跑起来。拿到这类基于双线性网络的毕设代码我现在已经养成条件反射先看backbone输出通道再算一遍外积维度和分类头参数量如果超过五千万参数第一件事就是加瓶颈层而不是调学习率。这个习惯帮我避开了好几次“训练10小时、验证准度原地踏步”的尴尬。这套代码作为细粒度分类的入门实现是称职的但你要把它用到自己的数据上按第2章和第4章的思路改一遍才能避免被毕设代码常见的“能跑但跑不动”坑住。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询