
简介基于Pytorch实现DenseNet的实战源码包面向深度学习初学者、研究人员以及需要快速搭建图像分类模型的开发者提供了从零构建并完整训练DenseNet网络的项目代码。资源共14个文件包含7个Python脚本、6张PNG图像和1个说明文档覆盖模型定义、训练流程、结果可视化、CIFAR-10均值计算与梯度对比等环节其中图像可直观展示网络结构、训练误差曲线和收敛状态。压缩包仅2.87MB轻量易用。已有336人学习下载适合作为图像分类方向从入门到进阶的实操参考。项目围绕稠密块、过渡层、增长率等核心结构展开体现密集连接与特征重用机制缓解梯度消失并提升特征传递效率。附带误差曲线与梯度对比工具便于分析收敛问题、比较优化器效果并调整超参数配合说明文档和目录方便快速理解代码与二次开发也适合课程设计、实验复现或论文对比参考。1. 先用一个对比把 DenseNet 和 ResNet 的差异讲清楚基于 Pytorch 实现 DenseNet 时最常被问到的话不是“怎么搭”而是“搭完到底比 ResNet 强在哪”。我拿一个 20 类工业缺陷检测的小样本任务来说训练图像只有 5000 张ResNet-50 微调后训练集能到 99%验证集卡在 91%换成 DenseNet-121数据增强和优化器设置完全不动验证集到了 94%模型文件还不到 ResNet-50 的三分之一。要讲清这个差异得回到 DenseNet 的核心机制每一层都把之前所有特征图拼起来当作输入网络不再靠残差“绕路”而是把一个不断扩充的公共特征池拿给每一层复用。这篇笔记就按“结构原理 → PyTorch 代码 → 训练参数 → 迁移改法 → 实战排错”整理出一条完整路径新手可以照着跑通熟手也能直接拿来改自己的项目。2. DenseNet 的密集拼接、增长率 k 与压缩因子 θ 怎么协同2.1 从残差求和到密集拼接先看 Dense Layer 的输入长什么样ResNet 残差块的输出是 y F(x) x两个特征图逐元素相加。相加的本质是把两份信息投影到同一组通道上通道数几乎不变3×3 卷积捕捉到的很多浅层“边角特征”会在后续通道里和深层特征发生混合。DenseNet 换成拼接第 l 层拿到第 0 到第 l−1 层全部输出在通道维度上的 concat再交给一个组合函数 H_lx_l H_l([x_0, x_1, …, x_{l-1}])这里的方括号表示 concat不是相加。H_l 只负责从已有特征中“增量挖掘”新信息每层的输出通道数固定为 k这个 k 就是 growth rate。第 2.2 节会细说它到底控制什么。从实现角度还要注意一个细节标准 DenseNet 的 H_l 是 BN → ReLU → Conv 的排列BN 在最前。这种“预激活”顺序让卷积输入始终是归一化后的分布反向传播时梯度不会被 ReLU 的饱和区压掉。写 PyTorch 模块时如果不小心把顺序写成 Conv → BN → ReLU前几十个 step 的 loss 下降会明显变慢这是我第一次照着 ResNet 的 Block 习惯复刻 DenseNet 时踩过最典型的坑。2.2 growth rate k一层只新增 k 个通道为什么反而更强k 的常用取值是 12、24、32、40。k32 时一个有 6 层的 DenseBlock假设入口通道是 64那么第 6 层进来时输入通道已经变成 64 5×32 224。如果 k12这个数字只有 64 5×12 124。通道数的叠加关系非常直白不需要像 ResNet 那样跟踪 shortcut 分支。Block 序号块内层数块入口通道块出口通道Block 166464 6×32 256Block 212128128 12×32 512Block 324256256 24×32 1024Block 416512512 16×32 1024上表按“每个 Transition 都把通道减半”来算。第二、第三个 Block 的通道数涨得很快这也是写代码时最容易算错的地方。k 越大网络越宽越能记住训练集细节但小数据集上过拟合速度也越快k 太小梯度在长链路上会被稀释特征复用收益不明显。自定义任务我一般从 k24 起步跑通后再调 32 和 12 对比。2.3 压缩因子 θ 和 Bottleneck把通道“宽”限制在显存能装下的范围光靠拼接不做限制网络会在第三个 Block 直接膨胀到上千通道。两个机制把通道压住第一是 Bottleneck。DenseLayer 内部先做 1×1 卷积压缩到 4k 通道再做 3×3 卷积输出 k 通道。它把 3×3 卷积的输入从几百通道降到 4k128计算量大幅下降这是 DenseNet-B 的标准结构。第二是 Transition 的压缩因子 θ。每个 Block 之后接一个 Transition 块里面是 BN → ReLU → 1×1 Conv → 2×2 AvgPool。1×1 卷积的输出通道数设为 floor(θ × 输入通道数)θ1.0 不做压缩θ0.5 直接砍半。公开实验里 θ0.5 的精度损失通常能控制在很小范围换来近一半的参数下降所以 DenseNet-BC 成了最常用的配置。层数命名也有对应关系模型block_layers 配置growth rateθ参数规模量级DenseNet-121(6, 12, 24, 16)320.5约 8M 参数DenseNet-169(6, 12, 32, 32)320.5约 14M 参数DenseNet-201(6, 12, 48, 32)320.5约 20M 参数项目源码里通常会把这三组配置写成带参数的函数而不是复制三份模型类。下一章直接从上到下实现一遍。3. 从 DenseLayer 到 DenseNetPyTorch 核心代码一版到底3.1 DenseLayerBN-ReLU-Conv 的顺序直接决定训练稳定性先把最原子的 DenseLayer 写好后面所有 Block 都复用它。带 Bottleneck 的实现如下import torch import torch.nn as nn import torch.nn.functional as F class DenseLayer(nn.Module): def __init__(self, in_channels, growth_rate): super().__init__() # 1x1 先压缩到 4 * growth_rate再做 3x3 提取 self.bn1 nn.BatchNorm2d(in_channels) self.conv1 nn.Conv2d(in_channels, 4 * growth_rate, kernel_size1, biasFalse) self.bn2 nn.BatchNorm2d(4 * growth_rate) self.conv2 nn.Conv2d(4 * growth_rate, growth_rate, kernel_size3, padding1, biasFalse) def forward(self, x): out self.bn1(x) out F.relu(out, inplaceTrue) out self.conv1(out) out self.bn2(out) out F.relu(out, inplaceTrue) out self.conv2(out) return torch.cat([x, out], dim1)代码逻辑不复杂但藏着两个关键点。第一个是顺序每一段都是“先 BN再 ReLU最后卷积”不是常规的 Conv-BN-ReLU。第二个是最后一行torch.cat([x, out], dim1)把输入 x 和新增特征 out 在通道维拼接返回给下一层。每个 DenseLayer 输出固定为 growth_rate 张新特征图但输入通道会随着层数线性增长因此每层的 BN 输入维度都不相同。把biasFalse写在所有卷积上是因为后面紧接 BN偏置会被 BN 抵消留着只会白白增加参数。3.2 DenseBlock 和 Transition用 ModuleList 管理逐层拼接的通道变化class DenseBlock(nn.Module): def __init__(self, n_layers, in_channels, growth_rate): super().__init__() self.layers nn.ModuleList() for i in range(n_layers): # 第 i 层输入通道 初始通道 i * growth_rate layer DenseLayer(in_channels i * growth_rate, growth_rate) self.layers.append(layer) def forward(self, x): for layer in self.layers: x layer(x) return x这里用ModuleList而不是nn.Sequential是因为每一层输入通道都在变化Sequential 不方便做参数化的逐层构造。forward里串行调用当前一层的输出直接作为下一层输入由于 DenseLayer 内部已经做了 concatx 的通道数会自动递增。Transition 块负责把通道压缩并做空间下采样class Transition(nn.Module): def __init__(self, in_channels, theta0.5): super().__init__() out_channels int(in_channels * theta) self.bn nn.BatchNorm2d(in_channels) self.conv nn.Conv2d(in_channels, out_channels, kernel_size1, biasFalse) self.pool nn.AvgPool2d(kernel_size2, stride2) def forward(self, x): x self.bn(x) x F.relu(x, inplaceTrue) x self.conv(x) x self.pool(x) return xTransition 的 1×1 卷积用来按 θ 压缩通道池化层把空间尺寸减半。如果 θ1.0这里的输出通道数和输入相同但 2×2 平均池化是必须保留的它是 DenseNet 特征图分辨率下降的唯一来源。3.3 DenseNet 主体把 cur_channels 的更新当作排错主线class DenseNet(nn.Module): def __init__(self, block_layers(6, 12, 24, 16), growth_rate32, theta0.5, num_classes10, in_channels3, first_poolTrue): super().__init__() cur_channels 2 * growth_rate # conv0 输出通道数 self.features nn.Sequential() if first_pool: # ImageNet 输入7x7 stride2 maxpool self.features.add_module(conv0, nn.Conv2d(in_channels, cur_channels, kernel_size7, stride2, padding3, biasFalse)) self.features.add_module(pool0, nn.MaxPool2d(kernel_size3, stride2, padding1)) else: # CIFAR 这类 32x32 小图直接用 3x3不下采样 self.features.add_module(conv0, nn.Conv2d(in_channels, cur_channels, kernel_size3, padding1, biasFalse)) for i, n_layers in enumerate(block_layers): self.features.add_module( fdenseblock{i 1}, DenseBlock(n_layers, cur_channels, growth_rate)) cur_channels n_layers * growth_rate if i ! len(block_layers) - 1: self.features.add_module( ftransition{i 1}, Transition(cur_channels, theta)) cur_channels int(cur_channels * theta) self.bn nn.BatchNorm2d(cur_channels) self.classifier nn.Linear(cur_channels, num_classes) def forward(self, x): x self.features(x) x F.relu(self.bn(x), inplaceTrue) x F.adaptive_avg_pool2d(x, (1, 1)).flatten(1) return self.classifier(x)cur_channels是这一整段代码里最值得盯住的变量。每加一个 DenseBlock通道数要加上“层数 × growth_rate”每经过一个 Transition通道数要乘 theta 后取整。如果出现维度 mismatch先打印cur_channels的变化轨迹比顺着网络一层层看张量要快得多。first_pool开关是给不同输入尺寸用的ImageNet 的 224×224 输入保留 7×7 卷积和 MaxPoolCIFAR-10 的 32×32 输入如果也这么做特征图直接降到 8×8小目标信息就没了。把输入尺寸和池化行为绑成参数是让同一个模型类能同时服务不同任务的常见做法。3.4 用一次前向核对通道数和尺寸模块写完后先用随机张量做一次前向自检确认没有维度错误model DenseNet(block_layers(6, 12, 24, 16), growth_rate32, theta0.5, num_classes10, first_poolFalse) x torch.randn(2, 3, 32, 32) y model(x) print(y.shape) # 期望输出 torch.Size([2, 10])模块输入 → 输出通道输出尺寸32×32 输入conv03×3, stride 13 → 6432×32denseblock16 层64 → 25632×32transition1θ0.5256 → 12816×16denseblock212 层128 → 51216×16transition2512 → 2568×8denseblock324 层256 → 10248×8transition31024 → 5124×4denseblock416 层512 → 10244×4BN ReLU GlobalAvgPool1024 → 10241×1classifier1024 → 101输出形状和预期一致网络结构才算真正成型。这一张表在实际项目里比任何模型类图都好用它能让“通道数是从哪一层开始爆掉”这个问题一眼暴露出来。4. 训练一份 DenseNetCIFAR-10 超参表和迁移到新数据的改法4.1 数据增强与标准化小数据集上 DenseNet 的第一道防线DenseNet 的隐性记忆能力很强训练集喂多了验证准确率反而会掉。CIFAR-10 这种 5 万张的小数据集我通常会这样配置数据增强随机裁剪、水平翻转、归一化是基础三项from torchvision import datasets, transforms train_transform transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) train_data datasets.CIFAR10(./data, trainTrue, downloadTrue, transformtrain_transform) train_loader torch.utils.data.DataLoader( train_data, batch_size64, shuffleTrue, num_workers4, pin_memoryTrue)如果训练曲线显示验证集已经出现平台期而训练 loss 还在降第一步不是换模型而是加强增强。常见做法是再加一个 Cutout把输入张量里随机 16×16 的区域置零等价于强迫模型不要把全部希望压在少数几个特征点上。归一化的 mean 和 std 要严格按数据集的统计值来不能随手写 0.5否则 BN 的分布统计会和真实数据错位。4.2 SGD、300 epoch 和余弦退火一组经过多次验证的参数组合DenseNet 从零训练时的标准配方是 SGD 加长训练周期而不是 Adam。Adam 在前几十个 epoch 收敛很快但到中后期容易在小数据集上过拟合。我常用的超参如下超参数推荐值CIFAR-10调整方向batch_size64显存不足降到 32同时学习率也要降epochs300DenseNet 需要足够长的训练期lr初始0.1batch_size 减半时 lr 同步减半momentum0.9标准 SGD 设置weight_decay1e-4过拟合明显时提高到 5e-4schedulerCosineAnnealingLRT_maxepochs阶梯下降会太早收敛训练循环本身不长但有一个顺序问题容易出错先loss.backward()再optimizer.step()最后scheduler.step()scheduler 必须在一个 epoch 结束后再推进。optimizer torch.optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max300) for epoch in range(300): model.train() for images, labels in train_loader: images, labels images.cuda(), labels.cuda() optimizer.zero_grad() outputs model(images) loss F.cross_entropy(outputs, labels) loss.backward() optimizer.step() scheduler.step() # 这里每 epoch 末尾输出一次 loss同时跑验证集CosineAnnealingLR会把学习率从 0.1 平滑降到接近 0配合 300 个 epoch 使用能让 DenseNet 在后期把特征调整得更精细。如果改成阶梯式下降比如每 100 epoch 乘 0.1会看到验证精度在第 100 个 epoch 后弹一下然后很快陷入平台期。4.3 迁移到新数据集只换分类头还是连输入通道一起改实战项目里很少真从零训练 DenseNet-121通常是加载 torchvision 预训练权重再微调import torchvision.models as models net models.densenet121(weightsmodels.DenseNet121_Weights.IMAGENET1K_V1) num_features net.classifier.in_features net.classifier nn.Linear(num_features, 20) # 把 1000 类换成自己的类别数预训练权重只覆盖 ImageNet 的 1000 类分类头所以新分类头要随机初始化。如果数据集图像是灰度图输入通道从 3 变成 1还要替换第一个卷积层net.features.conv0 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse)替换后 conv0 的预训练权重无法直接继承这一层会从随机权重出发初始几轮的 loss 会偏高属正常现象。微调时给特征提取器和分类头设置不同学习率是保证收敛的可靠做法optimizer torch.optim.SGD([ {params: net.features.parameters(), lr: 1e-3}, {params: net.classifier.parameters(), lr: 1e-2}, ], momentum0.9, weight_decay1e-4)分类头新初始化需要更快的学习率特征提取器有预训练知识学习率过高会破坏已经学到的边缘和纹理表征。迁移场景下我一般只微调最后两三个 DenseBlock前面的特征对绝大多数视觉任务都是通用的。5. 实战收尾DenseNet 的显存优化、自检脚本和微调排查5.1 用 checkpoint 把 DenseNet 的峰值显存压下来DenseNet 训练时每一层的输出特征图都要保留到反向传播阶段拼接特性会让计算图非常大。图形记忆比 ResNet 高 30%~50% 在 __中期_more。 我可以在可用的时候用torch.utils.checkpoint对这些块做优化减少在将 feature maps 存储到内存之前在 forward 中重计算的实现但相比不使用时成本更高from torch.utils.checkpoint import checkpoint class CheckpointedDenseLayer(DenseLayer): def forward(self, x): def run(): out self.bn1(x) out F.relu(out, inplaceTrue) out self.conv1(out) out self.bn2(out) out F.relu(out, inplaceTrue) return self.conv2(out) return torch.cat([x, checkpoint(run)], dim1)这会用更多显存但大多数情况下能直接放进更小的单卡训练。代价是前向多算一遍时间增加 20%~40%但它能让我在不降 batch size 的情况下完成训练。5.2 每次改完结构先跑一个维度自检项目源码里最好的防呆设计是在模型定义底部放一个自检函数改完任何结构都能立刻发现维度错误def test_densenet(): model DenseNet(block_layers(6, 12, 24, 16), growth_rate32, theta0.5, num_classes10, first_poolFalse) x torch.randn(2, 3, 32, 32) y model(x) assert y.shape (2, 10), f输出维度错误: {y.shape} print(DenseNet forward pass OK) if __name__ __main__: test_densenet()每次改 growth_rate、theta 或 block_layers 后运行一次通道数问题会立刻暴露。它比任何静态代码检查都直接。5.3 微调不收敛时先查这三个位置微调 DenseNet 经常出现“loss 不动”或“验证集比随机高不了多少”按顺序排查先确认分类头维度正确再看 BN 是否在高学习率下被破坏最后检查数据增强是否太强导致信息量不足。一个小经验是如果初始学习率超过 0.01BN 的滑动均值会在前几个 epoch 被带偏导致后续所有 BatchNorm 统计失效。这时要么把学习率降到 1e-3要么显式锁定前几层的参数更新。还有一个容易忽略的细节不要一上来就跑完整 300 个 epoch。先用 10~20 个 epoch 观察 loss 的下降速率如果 loss 在初始阶段就乱跳先减学习率而不是换模型结构等验证准确率能在前 100 个 epoch 里稳定超过 70%再放到完整训练周期里跑完。这个“先小后大”的验证顺序能省掉大部分无效回炉。本文还有配套的精品资源点击获取