news 2026/10/20 16:18:22

PyTorch实战:从零搭建DenseNet,深入特征复用与显存优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyTorch实战:从零搭建DenseNet,深入特征复用与显存优化

1. 从ResNet的"加法"到DenseNet的"拼接":一个直觉上的跨越

如果你已经用过ResNet,大概会对那种"隔层相加"的残差结构印象深刻。output = F(x) + x,这个加号让梯度可以沿着恒等映射直接回传,训练几百层网络不再是天方夜谭。但第一次看到DenseNet的论文时,我盯着那张密密麻麻画满箭头的结构图看了很久——每一层都跟前面所有层相连,特征图不是相加而是拼接,通道数越堆越厚。当时心里冒出的第一个念头是:这玩意儿显存不得炸了?

后来真正动手在PyTorch里搭了一遍,才发现DenseNet的设计比表面看起来精巧得多。它用特征复用换取了参数效率,用拼接替代相加,让每一层都能直接访问前面所有层的原始特征,而不是经过多次变换后的混合结果。这篇内容就围绕DenseNet在PyTorch中的实现展开,从核心机制到逐层搭建,再到训练时那些文档里不会写的坑,尽量把我知道的都倒出来。

适合谁看?如果你已经能写基本的CNN、了解ResNet的残差思想,但还没亲手实现过DenseNet,那这篇正好。如果你只是听说过这个名字,想搞清楚它到底解决了什么问题,也可以顺着往下读,我会尽量用生活化的类比把原理讲透。

2. DenseNet到底在解决什么问题:特征复用与梯度高速公路

2.1 传统CNN的"信息衰减"困境

先想一个很朴素的问题:在一个普通的卷积网络里,第10层看到的输入是什么?是第9层输出的特征图。那第1层的原始边缘、纹理信息,到第10层还剩下多少?经过9次卷积、激活、池化的反复加工,早期那些细粒度的信息基本已经被"稀释"得差不多了。

这就像传话游戏,一句话经过十个人传递,到最后往往面目全非。ResNet的做法是给每两层之间加一条"捷径",让信息可以跳过某些层直接往后传,缓解了梯度消失,但特征本身的复用仍然有限——相加是一种融合,不是保留。

DenseNet的思路完全不同:它不做融合,而是保留。第l层的输入,是前面所有层输出特征图的拼接。第1层的原始特征,到第10层依然原封不动地存在于输入中,只是旁边多了9层新加工出来的特征。这样每一层都能同时看到"原始素材"和"深加工产品",信息的丰富度是累加式的。

2.2 拼接为什么比相加更"奢侈"也更"划算"

从计算角度看,拼接确实更"贵"。假设每层输出k个通道,到第l层时输入通道数就是k0 + (l-1) * k,其中k0是初始输入通道。这个线性增长意味着越往后的层,卷积的输入通道越多,计算量自然上去了。

但换个角度,这个"贵"换来了什么?换来了参数效率。在ResNet里,每个残差块要学一个完整的变换,参数量跟通道数平方相关。而DenseNet里,每层只需要输出很少的通道(论文里k取12、24、32这种小值),因为它的任务不是重新学一遍所有特征,而是在已有特征基础上补充一点点新东西。这就像团队协作:ResNet是每个人都要独立完成一份完整报告再合并,DenseNet是每个人只补充一个别人没提到的要点,最后把所有要点拼在一起。

实测下来,同样参数量下DenseNet的精度通常比ResNet高,尤其在数据量不那么大的场景里优势更明显。代价是显存占用高,因为拼接后的特征图要一直保留着。这个权衡后面会细说。

2.3 梯度高速公路:为什么深了也不怕

DenseNet的另一个隐性好处在反向传播。因为每一层都直接连到前面所有层,损失函数的梯度可以通过这些密集连接直接传到浅层,不需要经过一长串中间层的链式求导。这相当于给梯度修了无数条直达通道,而不是只有一条蜿蜒的山路。

论文里把这个叫做"implicit deep supervision",意思是即使你只在最后一层加监督信号,浅层也能得到充分的梯度。实际训练时你会发现,DenseNet即使不加BatchNorm的某些变体,收敛也比同深度的普通网络稳。当然,这不意味着可以随便去掉BN,只是说它的结构本身对梯度更友好。

3. 在PyTorch里拆解DenseNet:从DenseBlock到Transition Layer

3.1 一个DenseBlock内部长什么样

DenseBlock是DenseNet的基本单元,里面包含多个DenseLayer。每个DenseLayer的结构是:BN → ReLU → Conv(1x1) → BN → ReLU → Conv(3x3)。注意这里用的是预激活(pre-activation)顺序,也就是先BN再卷积,而不是传统的Conv→BN→ReLU。这个细节在原始论文里是明确采用的,原因是预激活能让梯度流动更顺畅,训练更稳定。

1x1卷积在这里扮演"瓶颈层"的角色。因为输入通道数可能已经很大(比如前面累积了几百个通道),直接上3x3卷积计算量爆炸。先用1x1把通道压到4*k(k是增长率),再上3x3输出k个通道,这样既控制了计算量,又完成了特征补充。这个4*k的压缩比是论文里的经验值,实际用的时候可以根据任务调整,但一般不建议压得太狠,否则会损失信息。

下面是一个DenseLayer的PyTorch实现:

import torch import torch.nn as nn class DenseLayer(nn.Module): def __init__(self, in_channels, growth_rate, bn_size=4, drop_rate=0.0): super(DenseLayer, self).__init__() self.bn1 = nn.BatchNorm2d(in_channels) self.relu1 = nn.ReLU(inplace=True) self.conv1 = nn.Conv2d(in_channels, bn_size * growth_rate, kernel_size=1, stride=1, bias=False) self.bn2 = nn.BatchNorm2d(bn_size * growth_rate) self.relu2 = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(bn_size * growth_rate, growth_rate, kernel_size=3, stride=1, padding=1, bias=False) self.drop_rate = drop_rate def forward(self, x): # x 是前面所有层输出的拼接 out = self.conv1(self.relu1(self.bn1(x))) out = self.conv2(self.relu2(self.bn2(out))) if self.drop_rate > 0: out = nn.functional.dropout(out, p=self.drop_rate, training=self.training) # 关键:把新特征拼到输入后面 return torch.cat([x, out], 1)

注意forward最后那行torch.cat([x, out], 1),这就是DenseNet的灵魂。每一层的输出不是替换输入,而是追加到输入后面。所以随着层数增加,张量的通道维度不断变长。

3.2 DenseBlock的组装与通道数增长

一个DenseBlock就是把若干个DenseLayer串起来。因为每层都会往通道维度追加growth_rate个通道,所以第i层的输入通道数是in_channels + i * growth_rate。这个线性增长是DenseNet显存占用的主要来源,也是为什么后面需要Transition Layer来"减压"。

class DenseBlock(nn.Module): def __init__(self, num_layers, in_channels, growth_rate, bn_size=4, drop_rate=0.0): super(DenseBlock, self).__init__() layers = [] for i in range(num_layers): layers.append(DenseLayer( in_channels + i * growth_rate, growth_rate, bn_size, drop_rate )) self.block = nn.Sequential(*layers) def forward(self, x): return self.block(x)

这里有个容易踩的坑:nn.Sequential里的每一层输入通道数都不一样,所以不能用简单的列表推导一次性建好,必须像上面这样在循环里动态计算in_channels + i * growth_rate。我第一次写的时候图省事,所有层都用同一个in_channels,结果跑起来直接维度不匹配报错。

3.3 Transition Layer:给通道数"踩刹车"

DenseBlock之后通道数会涨得很厉害。比如初始64通道,一个block里12层、每层增长32,出来就是64 + 12*32 = 448通道。如果直接再接下一个block,通道数会继续飙升,显存扛不住。Transition Layer的作用就是压缩:用1x1卷积把通道数砍掉一半(压缩系数通常取0.5),再用2x2平均池化把空间尺寸减半。

class TransitionLayer(nn.Module): def __init__(self, in_channels, out_channels): super(TransitionLayer, self).__init__() self.bn = nn.BatchNorm2d(in_channels) self.relu = nn.ReLU(inplace=True) self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, bias=False) self.pool = nn.AvgPool2d(kernel_size=2, stride=2) def forward(self, x): out = self.conv(self.relu(self.bn(x))) return self.pool(out)

压缩系数0.5是个经验值。如果你发现模型欠拟合,可以调高到0.6或0.7,保留更多特征;如果显存吃紧,可以降到0.3甚至0.4。但降太低会损失信息,精度掉得明显。我一般先用0.5跑一版baseline,再根据情况微调。

4. 完整DenseNet的搭建与训练配置

4.1 整体架构:三个Block加两个Transition

以DenseNet-121为例,它的结构是:初始7x7卷积+3x3最大池化,然后四个DenseBlock,层数分别是6、12、24、16,block之间用Transition Layer连接,最后全局平均池化加全连接分类。增长率k=32,压缩系数0.5。

class DenseNet(nn.Module): def __init__(self, num_classes=1000, growth_rate=32, block_layers=[6, 12, 24, 16], bn_size=4, drop_rate=0.0): super(DenseNet, self).__init__() self.growth_rate = growth_rate # 初始卷积 self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False) self.bn1 = nn.BatchNorm2d(64) self.relu = nn.ReLU(inplace=True) self.pool1 = nn.MaxPool2d(kernel_size=3, stride=2, padding=1) # 四个DenseBlock和中间的Transition self.block1 = DenseBlock(block_layers[0], 64, growth_rate, bn_size, drop_rate) channels = 64 + block_layers[0] * growth_rate self.trans1 = TransitionLayer(channels, channels // 2) channels = channels // 2 self.block2 = DenseBlock(block_layers[1], channels, growth_rate, bn_size, drop_rate) channels = channels + block_layers[1] * growth_rate self.trans2 = TransitionLayer(channels, channels // 2) channels = channels // 2 self.block3 = DenseBlock(block_layers[2], channels, growth_rate, bn_size, drop_rate) channels = channels + block_layers[2] * growth_rate self.trans3 = TransitionLayer(channels, channels // 2) channels = channels // 2 self.block4 = DenseBlock(block_layers[3], channels, growth_rate, bn_size, drop_rate) channels = channels + block_layers[3] * growth_rate # 分类头 self.bn_final = nn.BatchNorm2d(channels) self.fc = nn.Linear(channels, num_classes) def forward(self, x): x = self.pool1(self.relu(self.bn1(self.conv1(x)))) x = self.trans1(self.block1(x)) x = self.trans2(self.block2(x)) x = self.trans3(self.block3(x)) x = self.block4(x) x = self.relu(self.bn_final(x)) x = nn.functional.adaptive_avg_pool2d(x, (1, 1)) x = torch.flatten(x, 1) return self.fc(x)

这段代码里通道数的计算需要特别小心。每次经过Transition,通道数减半;每次经过DenseBlock,通道数增加block_layers[i] * growth_rate。我在写的时候习惯把每一步的channels变量打印出来核对,避免手算出错。

4.2 训练超参设置:学习率、优化器与正则化

DenseNet的训练配置跟ResNet有相似之处,但有几个细节值得注意。优化器一般用SGD加动量,初始学习率0.1,配合余弦退火或阶梯衰减。权重衰减用1e-4,这个对DenseNet比较重要,因为它的参数量虽然少,但特征复用导致某些权重容易被过度激活。

Dropout在DenseNet里是可选的,论文里对CIFAR这类小数据集用了0.2的drop_rate,对ImageNet则没用。我的经验是:如果你的数据集不大(几万张以内),加0.1到0.2的dropout能明显缓解过拟合;如果数据量充足,不加也行。

model = DenseNet(num_classes=10, growth_rate=12, block_layers=[6, 12, 24, 16]) 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=200) criterion = nn.CrossEntropyLoss()

注意growth_rate在小数据集上要调小。ImageNet上用32,CIFAR上用12就够了。增长率越大,模型容量越高,但小数据集上容易过拟合,而且显存占用增长很快。

4.3 显存优化:checkpoint与混合精度

DenseNet最让人头疼的就是显存。因为每一层的输出都要保留到后面所有层用完才能释放,中间激活值占用的显存比普通网络大得多。如果你在消费级显卡上跑DenseNet-121,batch size可能只能开到16甚至8。

有两个实用的缓解手段。一是梯度检查点(gradient checkpointing),用时间换空间,把中间激活值不保存,反向传播时重新计算。PyTorch里可以用torch.utils.checkpoint:

from torch.utils.checkpoint import checkpoint class DenseBlockWithCheckpoint(nn.Module): def __init__(self, num_layers, in_channels, growth_rate, bn_size=4, drop_rate=0.0): super().__init__() self.layers = nn.ModuleList() for i in range(num_layers): self.layers.append(DenseLayer( in_channels + i * growth_rate, growth_rate, bn_size, drop_rate )) def forward(self, x): for layer in self.layers: x = checkpoint(layer, x) return x

这样显存能省下30%到50%,代价是训练速度慢20%左右。二是混合精度训练(AMP),用torch.cuda.amp把部分计算转成float16,显存减半,速度还能提升。两个手段可以叠加使用。

注意:用checkpoint时,如果DenseLayer里有dropout,要确保训练和推理模式正确切换,否则checkpoint重计算时可能行为不一致。

5. 那些文档里不会写的踩坑记录

5.1 通道数计算错误:最常见的翻车点

我见过最多的DenseNet实现bug就是通道数算错。表现是运行到某个Transition或DenseBlock时突然报维度不匹配。根源往往在于:忘记Transition会把通道数减半,或者DenseBlock内部每层输入通道是递增的而不是固定的。

排查方法很简单:在forward里每一大步之后打印x.shape,跟手算的结果对一遍。比如初始输入[2, 3, 224, 224],经过conv1和pool1应该是[2, 64, 56, 56],经过block1(6层,k=32)应该是[2, 64+6*32, 56, 56] = [2, 256, 56, 56],经过trans1应该是[2, 128, 28, 28]。对不上就说明哪里错了。

5.2 内存爆炸:不是模型太大,是中间激活太多

有一次我在一张8G显存的卡上跑DenseNet-121,batch size设了32,结果第一个epoch还没跑完就OOM了。当时以为是模型参数太多,后来算了一下参数量才800万左右,跟ResNet-50差不多。真正的原因是中间激活值——DenseBlock里每一层的输出都要保留,拼接后的特征图在反向传播前不能释放。

解决办法除了前面说的checkpoint和AMP,还可以减小batch size配合梯度累积。比如batch size设8,累积4次梯度再更新,等效于batch size 32,但显存占用只有1/4。

accumulation_steps = 4 optimizer.zero_grad() for i, (inputs, labels) in enumerate(dataloader): outputs = model(inputs) loss = criterion(outputs, labels) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

5.3 学习率没调对:收敛慢或者震荡

DenseNet对学习率比较敏感。用0.1的初始学习率在ImageNet上没问题,但如果你在小数据集上微调,0.1可能太大,loss会震荡甚至发散。我一般微调时用0.01或0.001,配合warmup——前几个epoch从很小的学习率线性升到设定值,让模型先"热身"再加速。

另外,DenseNet的BN层比较多,如果batch size太小(比如小于8),BN的统计量会不准,训练不稳定。这时候可以考虑用GroupNorm替代BN,或者干脆冻结BN的running stats。

5.4 数据增强与预处理:别忽略归一化

DenseNet对输入尺度比较敏感,因为它的密集连接会让特征在通道维度上不断累积,如果输入没归一化好,累积后的数值范围可能失控。标准做法是用ImageNet的均值和标准差做归一化:

from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

如果你在自己的数据集上训练,建议先算一下数据集的均值和标准差,用实际值替代ImageNet的默认值,效果会更好。这个步骤很多人会跳过,但对DenseNet这种特征累积型网络,输入分布的影响会被放大。

6. 从零训练还是拿预训练权重微调:一个实际的选择

6.1 预训练权重的加载与层名匹配

PyTorch的torchvision里自带DenseNet的实现,可以直接加载预训练权重。但如果你自己写了一套结构,层名跟官方不一致,加载时会报key不匹配。这时候需要手动映射,或者用strict=False忽略不匹配的层:

from torchvision.models import densenet121 pretrained = densenet121(pretrained=True) model = DenseNet(num_classes=10) # 只加载特征提取部分 model_dict = model.state_dict() pretrained_dict = {k: v for k, v in pretrained.state_dict().items() if k in model_dict and v.shape == model_dict[k].shape} model_dict.update(pretrained_dict) model.load_state_dict(model_dict)

注意分类头的维度通常不一样,所以fc层的权重会被过滤掉,这是正常的。加载完预训练权重后,分类头需要重新训练,可以给它设一个更大的学习率,而前面的特征层用较小的学习率。

6.2 微调策略:冻结哪些层,训练哪些层

如果目标数据集跟ImageNet比较接近(比如都是自然图像),可以只训练最后两个DenseBlock和分类头,前面的冻结。如果差异较大(比如医学图像、卫星图像),建议全部解冻,但用较小的学习率(比如1e-4)整体微调。

冻结的实现很简单:

for name, param in model.named_parameters(): if 'block1' in name or 'block2' in name or 'conv1' in name: param.requires_grad = False

实测下来,冻结前两个block能省不少显存和时间,精度损失通常很小。但如果你发现验证集精度上不去,可以逐步解冻更多层试试。

6.3 小数据集上的表现:DenseNet的优势场景

DenseNet在数据量不大的时候优势特别明显。我做过一个对比实验:同样的5万张图像、10个类别,ResNet-50和DenseNet-121参数量差不多,但DenseNet的验证集准确率高了2到3个百分点。原因就是特征复用让它在有限数据下能更充分地利用每一层学到的特征,不容易过拟合。

不过这个优势在数据量很大时会缩小。ImageNet上DenseNet和ResNet的差距就没那么大了,而且DenseNet的训练时间更长、显存占用更高。所以选型时要看你的实际场景:数据少、显存够,DenseNet很香;数据多、追求训练效率,ResNet可能更务实。

7. 一些让DenseNet跑得更顺的实用技巧

7.1 增长率与层数的搭配经验

growth_rate和block_layers的搭配没有固定公式,但有一些经验规律。增长率越大,每层补充的新特征越多,模型容量越高,但显存和计算量也涨得快。层数越多,特征复用的深度越深,但收益会递减。

我的建议是:小数据集(CIFAR级别)用k=12,block_layers=[6,12,24,16];中等数据集用k=24或32;大数据集可以上k=48甚至64,但要有足够的显存。如果显存不够,优先减层数而不是减增长率,因为增长率太低会导致每层学不到足够的新信息。

7.2 BN层的momentum调整

DenseNet里BN层很多,默认momentum是0.1。如果batch size比较小,可以调低到0.01或0.001,让running stats更新更平滑。这个改动很小,但在小batch场景下对训练稳定性有帮助。

for m in model.modules(): if isinstance(m, nn.BatchNorm2d): m.momentum = 0.01

7.3 学习率warmup的实现

Warmup对DenseNet这种深网络很有用,尤其是从零训练时。前5个epoch让学习率从0线性升到初始值,能避免早期梯度太大导致的不稳定。

def warmup_lr(epoch, warmup_epochs, base_lr): if epoch < warmup_epochs: return base_lr * (epoch + 1) / warmup_epochs return base_lr for epoch in range(num_epochs): lr = warmup_lr(epoch, 5, 0.1) for param_group in optimizer.param_groups: param_group['lr'] = lr # 训练循环...

7.4 模型保存与断点续训

DenseNet训练时间长,断点续训很有必要。保存时除了模型权重,还要保存优化器状态和epoch数,否则恢复后学习率调度会乱。

torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'scheduler_state_dict': scheduler.state_dict(), }, 'checkpoint.pth')

恢复时对应加载回来,注意scheduler.step()的调用次数要跟保存时一致,否则学习率会对不上。

8. 最后聊几句实际用下来的感受

DenseNet是我个人很喜欢的一个架构,它的设计思路——用拼接代替相加、让每一层都能访问原始特征——在直觉上非常优雅。在PyTorch里实现它并不复杂,核心就是torch.cat那一行,但围绕这一行衍生出的通道数管理、显存优化、训练配置,才是真正花时间的地方。

如果你准备在自己的项目里用DenseNet,我的建议是先用torchvision的预训练版本跑通流程,确认数据管道和训练循环没问题,再换成自己实现的结构做对比。自己实现的价值在于你能改——改增长率、改压缩系数、改block层数,甚至把DenseBlock里的3x3卷积换成其他形式。这些改动在预训练模型上是做不了的。

显存不够就上checkpoint和AMP,batch size太小就上梯度累积,收敛不稳就加warmup和调BN momentum。这些手段组合起来,DenseNet在消费级显卡上也能跑得动。至于它和ResNet怎么选,我的经验是:数据少、显存够,选DenseNet;数据多、要效率,选ResNet。没有绝对的好坏,只有适不适合你手头这个任务。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 16:58:21

实测Claude Projects与Artifacts:打造专属上下文与高效迭代工作流

你有没有发现&#xff0c;身边用 Claude 的人其实分成两种&#xff1a;一种把它当聊天窗口&#xff0c;有问必答&#xff0c;问完就走&#xff1b;另一种会建 Projects、会玩 Artifacts&#xff0c;同样一个模型&#xff0c;他们手里的 Claude 像是换了个人。前一种用法不是错&…

作者头像 李华
网站建设 2026/10/17 19:24:06

Claude Projects+Artifacts组合使用:告别AI对话失忆,让输出真正落地

开头部分&#xff1a;很多人在用 Claud 的时候&#xff0c;其实一直停留在"提问—回答"的层面。对话一多&#xff0c;思维链一长&#xff0c;Mid 对话上下&#xfffd;文就乱了&#xff0c;前面说过的东西后面全忘。我跟不少开发者聊过&#xff0c;大家反馈几乎一致&…

作者头像 李华
网站建设 2026/10/18 1:20:28

IL-2 ELISPOT实验指南:原理、操作与肿瘤免疫应用

1. 先搞懂原理&#xff1a;IL-2 ELISPOT在测什么&#xff0c;为什么IFN-γ不够用前阵子组里有位师妹跑来找我&#xff0c;说她的课题要做抗原特异性T细胞检测&#xff0c;盯着一堆关键词有点拿不准——IFN-γ ELISPOT、IL-2 ELISPOT、流式胞内染色&#xff0c;到底该选哪个。我…

作者头像 李华
网站建设 2026/10/18 8:20:25

零代码AI自动化工作流指南:从概念到实战搭建

说实话&#xff0c;很多人一听“AI 自动化”就觉得那是程序员才能碰的东西。我自己第一次接触可视化 AI 编排平台之前&#xff0c;也有这种想法&#xff0c;潜意识里认为“不会写代码”这条线会把绝大多数可能都挡在外面。后来帮几个做运营和自媒体朋友搭过他们的日常工作流&am…

作者头像 李华
网站建设 2026/10/18 15:01:04

CSAPP第11章精要:从socket系统调用到可靠网络服务的底层构建

1. 这不是“学网络”&#xff0c;而是亲手把程序连上世界——CSAPP第11章Network Programming到底在教什么&#xff1f;你翻到《深入理解计算机系统》&#xff08;CSAPP&#xff09;第11章&#xff0c;标题写着“Network Programming”&#xff0c;心里可能嘀咕&#xff1a;这不…

作者头像 李华