PyTorch实现DenseNet:从密集拼接到训练迁移,与ResNet对比实战
2026/9/15 15:24:04 网站建设 项目流程

简介:基于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_l:

x_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。k=32 时,一个有 6 层的 DenseBlock,假设入口通道是 64,那么第 6 层进来时输入通道已经变成 64 + 5×32 = 224。如果 k=12,这个数字只有 64 + 5×12 = 124。通道数的叠加关系非常直白,不需要像 ResNet 那样跟踪 shortcut 分支。

Block 序号块内层数块入口通道块出口通道
Block 166464 + 6×32 = 256
Block 212128128 + 12×32 = 512
Block 324256256 + 24×32 = 1024
Block 416512512 + 16×32 = 1024

上表按“每个 Transition 都把通道减半”来算。第二、第三个 Block 的通道数涨得很快,这也是写代码时最容易算错的地方。k 越大,网络越宽,越能记住训练集细节,但小数据集上过拟合速度也越快;k 太小,梯度在长链路上会被稀释,特征复用收益不明显。自定义任务我一般从 k=24 起步,跑通后再调 32 和 12 对比。

2.3 压缩因子 θ 和 Bottleneck:把通道“宽”限制在显存能装下的范围

光靠拼接不做限制,网络会在第三个 Block 直接膨胀到上千通道。两个机制把通道压住:

第一是 Bottleneck。DenseLayer 内部先做 1×1 卷积压缩到 4k 通道,再做 3×3 卷积输出 k 通道。它把 3×3 卷积的输入从几百通道降到 4k=128,计算量大幅下降,这是 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 到 DenseNet:PyTorch 核心代码一版到底

3.1 DenseLayer:BN-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_size=1, bias=False) self.bn2 = nn.BatchNorm2d(4 * growth_rate) self.conv2 = nn.Conv2d(4 * growth_rate, growth_rate, kernel_size=3, padding=1, bias=False) def forward(self, x): out = self.bn1(x) out = F.relu(out, inplace=True) out = self.conv1(out) out = self.bn2(out) out = F.relu(out, inplace=True) out = self.conv2(out) return torch.cat([x, out], dim=1)

代码逻辑不复杂,但藏着两个关键点。第一个是顺序:每一段都是“先 BN,再 ReLU,最后卷积”,不是常规的 Conv-BN-ReLU。第二个是最后一行torch.cat([x, out], dim=1),把输入 x 和新增特征 out 在通道维拼接,返回给下一层。每个 DenseLayer 输出固定为 growth_rate 张新特征图,但输入通道会随着层数线性增长,因此每层的 BN 输入维度都不相同。把bias=False写在所有卷积上,是因为后面紧接 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 内部已经做了 concat,x 的通道数会自动递增。

Transition 块负责把通道压缩并做空间下采样:

class Transition(nn.Module): def __init__(self, in_channels, theta=0.5): super().__init__() out_channels = int(in_channels * theta) self.bn = nn.BatchNorm2d(in_channels) self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, bias=False) self.pool = nn.AvgPool2d(kernel_size=2, stride=2) def forward(self, x): x = self.bn(x) x = F.relu(x, inplace=True) x = self.conv(x) x = self.pool(x) return x

Transition 的 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_rate=32, theta=0.5, num_classes=10, in_channels=3, first_pool=True): super().__init__() cur_channels = 2 * growth_rate # conv0 输出通道数 self.features = nn.Sequential() if first_pool: # ImageNet 输入:7x7 stride=2 + maxpool self.features.add_module("conv0", nn.Conv2d(in_channels, cur_channels, kernel_size=7, stride=2, padding=3, bias=False)) self.features.add_module("pool0", nn.MaxPool2d(kernel_size=3, stride=2, padding=1)) else: # CIFAR 这类 32x32 小图:直接用 3x3,不下采样 self.features.add_module("conv0", nn.Conv2d(in_channels, cur_channels, kernel_size=3, padding=1, bias=False)) for i, n_layers in enumerate(block_layers): self.features.add_module( f"denseblock{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( f"transition{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), inplace=True) 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 卷积和 MaxPool;CIFAR-10 的 32×32 输入如果也这么做,特征图直接降到 8×8,小目标信息就没了。把输入尺寸和池化行为绑成参数,是让同一个模型类能同时服务不同任务的常见做法。

3.4 用一次前向核对通道数和尺寸

模块写完后,先用随机张量做一次前向自检,确认没有维度错误:

model = DenseNet(block_layers=(6, 12, 24, 16), growth_rate=32, theta=0.5, num_classes=10, first_pool=False) x = torch.randn(2, 3, 32, 32) y = model(x) print(y.shape) # 期望输出 torch.Size([2, 10])
模块输入 → 输出通道输出尺寸(32×32 输入)
conv0(3×3, stride 1)3 → 6432×32
denseblock1(6 层)64 → 25632×32
transition1(θ=0.5)256 → 12816×16
denseblock2(12 层)128 → 51216×16
transition2512 → 2568×8
denseblock3(24 层)256 → 10248×8
transition31024 → 5124×4
denseblock4(16 层)512 → 10244×4
BN + ReLU + GlobalAvgPool1024 → 10241×1
classifier1024 → 101

输出形状和预期一致,网络结构才算真正成型。这一张表在实际项目里比任何模型类图都好用,它能让“通道数是从哪一层开始爆掉”这个问题一眼暴露出来。

4. 训练一份 DenseNet:CIFAR-10 超参表和迁移到新数据的改法

4.1 数据增强与标准化:小数据集上 DenseNet 的第一道防线

DenseNet 的隐性记忆能力很强,训练集喂多了,验证准确率反而会掉。CIFAR-10 这种 5 万张的小数据集,我通常会这样配置数据增强,随机裁剪、水平翻转、归一化是基础三项:

from torchvision import datasets, transforms train_transform = transforms.Compose([ transforms.RandomCrop(32, padding=4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)), ]) train_data = datasets.CIFAR10("./data", train=True, download=True, transform=train_transform) train_loader = torch.utils.data.DataLoader( train_data, batch_size=64, shuffle=True, num_workers=4, pin_memory=True)

如果训练曲线显示验证集已经出现平台期而训练 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-4
schedulerCosineAnnealingLR,T_max=epochs阶梯下降会太早收敛

训练循环本身不长,但有一个顺序问题容易出错,先loss.backward(),再optimizer.step(),最后scheduler.step(),scheduler 必须在一个 epoch 结束后再推进。

optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=300) 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(weights=models.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_size=7, stride=2, padding=3, bias=False)

替换后 conv0 的预训练权重无法直接继承,这一层会从随机权重出发,初始几轮的 loss 会偏高,属正常现象。微调时给特征提取器和分类头设置不同学习率,是保证收敛的可靠做法:

optimizer = torch.optim.SGD([ {"params": net.features.parameters(), "lr": 1e-3}, {"params": net.classifier.parameters(), "lr": 1e-2}, ], momentum=0.9, weight_decay=1e-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, inplace=True) out = self.conv1(out) out = self.bn2(out) out = F.relu(out, inplace=True) return self.conv2(out) return torch.cat([x, checkpoint(run)], dim=1)

这会用更多显存,但大多数情况下能直接放进更小的单卡训练。代价是前向多算一遍,时间增加 20%~40%,但它能让我在不降 batch size 的情况下完成训练。

5.2 每次改完结构先跑一个维度自检

项目源码里最好的防呆设计,是在模型定义底部放一个自检函数,改完任何结构都能立刻发现维度错误:

def test_densenet(): model = DenseNet(block_layers=(6, 12, 24, 16), growth_rate=32, theta=0.5, num_classes=10, first_pool=False) 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.01,BN 的滑动均值会在前几个 epoch 被带偏,导致后续所有 BatchNorm 统计失效。这时要么把学习率降到 1e-3,要么显式锁定前几层的参数更新。

还有一个容易忽略的细节:不要一上来就跑完整 300 个 epoch。先用 10~20 个 epoch 观察 loss 的下降速率,如果 loss 在初始阶段就乱跳,先减学习率而不是换模型结构;等验证准确率能在前 100 个 epoch 里稳定超过 70%,再放到完整训练周期里跑完。这个“先小后大”的验证顺序,能省掉大部分无效回炉。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询