常见CNN网络算法 基本CNNclass SUNDetector(nn.Module): def __init__(self): super().__init__() # 主干网络 self.backbone nn.Sequential( # 第一层 nn.Conv2d(1, 16, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2) ) self.class_head nn.Linear(64 * 8 * 8, 2) self.bbox_head nn.Linear(64 * 8 * 8, 4) def forward(self, x): feat self.backbone(x) feat feat.flatten(1) logits self.class_head(feat) bbox torch.sigmoid(self.bbox_head(feat)) return logits, bboxVGG Net 最简单的堆层思想class VGG16Backbone(nn.Module): def __init__(self, in_channels3): super().__init__() self.features nn.Sequential( # block12 个卷积 池化 nn.Conv2d(in_channels, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # block2 nn.Conv2d(64, 128, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(128, 128, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # block3 nn.Conv2d(128, 256, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(256, 256, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # block4 nn.Conv2d(256, 512, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(512, 512, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(512, 512, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # block5 nn.Conv2d(512, 512, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(512, 512, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(512, 512, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) def forward(self, x): return self.features(x)优点结构极简就是「卷积ReLU池化」重复堆没有跳连接等花哨结构最好理解、最好实现。规律性强全程 3×3 小卷积 padding1通道按 64→128→256→512 翻倍尺寸只靠池化砍半非常整齐。特征表达强堆小卷积比单层大卷积参数少、非线性更强特征质量好当年 ImageNet 冠军。缺点计算量大通道堆到 512全连接层尤其吃参数和显存训练/推理都慢。层数上不去没残差连接深到一定层数会梯度消失很难超过 20 层精度到顶后上不去。内存占用高中间特征图很大512×高×宽反向传播时存的中间结果多。1. 不是固定 5 层是可配置的5 个 block 不是硬性规定。VGG 是一个家族VGG11 / 13 / 16 / 19区别就是总卷积层数不同但都是 5 个 block 每个 block 末尾一次池化。5 个 block 对应尺寸砍半 5 次224→112→56→28→14→7。所谓 VGG16就是卷积层数 13 全连接 3 16 层。你可以自己改成 4 个 block 或每 block 加卷积只是那样就不叫标准 VGG16了。2. 前两层卷积 2 次、后三层卷积 3 次是 VGG16 的配置论文里的 D 方案原因前面尺寸大卷积贵block1/block2 的特征图是 224×224、112×112每多一次卷积都很耗算力所以少放2 次。后面尺寸小可以加深到 block3 之后尺寸只有 56×56 以下多堆一层卷积代价小却能继续加深网络、扩大感受野、增强非线性。简单说就是大图省着算小图使劲加深在算力和深度之间做平衡。这组2-2-3-3-3是作者实验调出来效果最好的搭配。为什么这个会出现梯度消失问题?核心是链式法则连乘。反向传播时误差要从最后一层一层往前传梯度是每层的导数连乘出来的第1层梯度 第2层导数 × 第3层导数 × ... × 最后一层导数问题就出在这个连乘上每层的导数常常 1。比如 sigmoid 的导数最大才 0.25如果权重也偏小那每乘一层就缩小一点。连乘很多层 指数级衰减。假设每层乘 0.510 层就是0.5^10 ≈ 0.00150 层就趋近于 0 了。结果越靠前的层梯度越小几乎等于 0→ 前面的层权重几乎不更新 → 学不到东西网络越深越难训练。VGG 其实用 ReLU正区间导数是 1已经缓解了一部分但它没有 ResNet 那种跳连接让梯度直接抄近路回传所以堆到很深时依然会训练不动——这正是 ResNet 用残差连接要解决的根本问题。用比喻说。梯度 老师告诉你往哪改、改多少训练网络就像教一个学生做题。做完后老师从最后一层往回一层一层告诉每个神经元你刚才算错多少该往哪边调。问题出在传话上老师的话是一层一层往前传的每传一层话就漏掉一点。比如传到最后一层信息是 100%往前一层变成 60%再往前变成 36%再往前变成 20%……传几十层之后最前面的层收到的信息几乎没了跟没收到一样就没法改正自己。结果越靠前的层越学不会整个网络就训练不动了。这就像一长队人传悄悄话传到最后最开始那句话早就变样甚至听不见了。ResNet 的解决办法它加了一条抄近路让老师的话跳过几层直接传过去不用一层层漏。这样前面也能听到话就能继续学了。为什么传一层会漏掉信息因为反向传播本质上是在做连乘而每一层乘的那个数通常小于 1。小于 1 的数越乘越小比如 0.90.9 × 0.9 0.810.9 × 0.9 × 0.9 0.73连乘 50 次几乎就是 0 了每层为什么乘小于 1 的数神经网络的每一层可以理解成一个放大器。它把输入放大或缩小后传给下一层。这个放大倍数在反向传播时就叫导数。关键在于很多层的放大倍数本来就小于 1比如用 sigmoid 这种激活函数它最灵敏的时候放大倍数也才 0.25更多时候连 0.25 都不到。连起来就是最前面的层收到 0.9 × 0.9 × 0.9 × ... 几十个 0.9 连乘 ≈ 几乎为 0每过一层就乘一个比 1 小的数传几十层后剩下的就趋近于 0 了——这就是信息漏掉了。一句话总结不是真的丢了是被一层层地越乘越小小到看不见了。就像把一张纸每次对折折几十次后薄得没法再分。ResNet —— 残差学习跳连接# --------------------------------------------------------------------- # 2. ResNet —— 残差学习跳连接 # 核心输出 F(x) x把学 F(x)变成学 F(x)x # 从而缓解梯度消失网络可以堆到几十上百层。 # --------------------------------------------------------------------- class BasicBlock(nn.Module): ResNet 的基础残差块ResNet18/34 用这个。 核心思想主路算一遍 F(x)再把自己(原始输入 x)原样加回去。 旁边那条直通的 identity 小路让梯度能抄近路回传解决梯度消失。 def __init__(self, in_channels, out_channels, stride1, downsampleNone): super().__init__() # 第 1 个卷积改变通道/尺寸。stride2 时尺寸会砍半 self.conv1 nn.Conv2d(in_channels, out_channels, 3, stride, 1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) # 归一化把数据拉回正常范围帮训练更稳 self.relu nn.ReLU(inplaceTrue) # 激活加非线性 # 第 2 个卷积通道和尺寸都不变stride1, padding1 self.conv2 nn.Conv2d(out_channels, out_channels, 3, 1, 1, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) # 归一化 # downsample备用的小卷积。只有当 x 和 out 形状对不上时用它把 x 拉齐 self.downsample downsample def forward(self, x): identity x # 第一步先原封不动备份自己 out self.relu(self.bn1(self.conv1(x))) # 主路卷积→归一化→激活 out self.bn2(self.conv2(out)) # 主路再卷积→归一化 if self.downsample is not None: identity self.downsample(x) # 形状对不上时用 1x1 卷积把 x 调成同样大小 out identity # 关键一步残差相加 新结果 原始输入 out self.relu(out) # 最后再激活一次 return out class ResNetBackbone(nn.Module): def __init__(self, block, layers, in_channels3): super().__init__() self.in_channels 64 self.conv1 nn.Conv2d(in_channels, 64, 7, 2, 3, biasFalse) # 大卷积先降采样 self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(3, 2, 1) self.layer1 self._make_layer(block, 64, layers[0]) # 64 通道 self.layer2 self._make_layer(block, 128, layers[1], stride2) self.layer3 self._make_layer(block, 256, layers[2], stride2) self.layer4 self._make_layer(block, 512, layers[3], stride2) self.avgpool nn.AdaptiveAvgPool2d((1, 1)) # 自适应池化随便输入多大都压成 1x1 def _make_layer(self, block, out_channels, blocks, stride1): 把若干个残差块串成一个 layer。 downsample None if stride ! 1 or self.in_channels ! out_channels: downsample nn.Sequential( nn.Conv2d(self.in_channels, out_channels, 1, stride, biasFalse), nn.BatchNorm2d(out_channels), ) layers [block(self.in_channels, out_channels, stride, downsample)] self.in_channels out_channels for _ in range(1, blocks): layers.append(block(self.in_channels, out_channels)) return nn.Sequential(*layers) def forward(self, x): x self.relu(self.bn1(self.conv1(x))) x self.maxpool(x) x self.layer1(x) x self.layer2(x) x self.layer3(x) x self.layer4(x) return self.avgpool(x) def resnet18(in_channels3): # layers 参数表示每个 layer 里放几个残差块[2, 2, 2, 2] 就是 ResNet18 return ResNetBackbone(BasicBlock, [2, 2, 2, 2], in_channels)归一化是什么归一化 把数据统一拉到一个标准范围里让它别忽大忽小。先看一个生活例子假设把三样东西放在一起比你的身高 160cm、你的体重 50kg、你的考试分数 90 分。这三个数单位不同、大小差很多没法直接比。归一化就是把它们都换算成同一个尺子下的数比如都变成 0~1 之间的数或者都以平均是 0、上下波动的形式出现。这样大家就站在同一标准上了。放到神经网络里卷积算完输出的数据经常有的很大、有的很小分布乱糟糟。如果直接喂给下一层下一层会看晕训练又慢又不稳。BatchNorm2d做的事就是把这一批数据拉平——先算这批数据的平均值和波动大小再把每个数都换算成离平均有多远的标准形式平均变成 0波动变成 1这样下一层每次收到的都是规规矩矩的数据训练更快、更稳、也更容易收敛。一句话归一化就是给数据统一规格让它不闹脾气。为什么要加回自己就是这样的比如输入是 5正确答案是 9那本质上就是让模型去算出9-54这个差值对吧。然后每一层残差块都这么干算出差值再加上自己459完整答案就出来了。为什么非得加上自己呢因为加上了自己原始的东西就一直留着不会算着算着就没了。然后我们又有正确答案把算出来的和正确答案一对比损失函数就知道差多少回去一改权重就学会了。所以本质上ResNet 就是不让它从头硬算只让它算差多少再补回自己这样任务简单、数据也不丢。算错了咋办比如输入是 4正确答案是 6那它本质上该算出差值 2对吧。但一开始卷积是瞎算的算出个 1加上自己415错了对吧。那错了怎么办它不怕因为我们有正确答案 6就把算出来的 5 和正确答案 6 一对比损失函数就说差了 1。然后这个差 1就顺着往回传把卷积的权重改一改让它下次别算 1 了往 2 那边靠。所以它就会算出 1 → 错 → 改 → 算出 1.5 → 还错 → 再改 → 算出 1.8 → 还差点 → 继续改一直改到算出 2426对了就不改了。本质就是算错不可怕和正确答案一对比损失函数就告诉它错哪了它回去改权重一次次逼近最后就学会了。训练就是把这个算错→对比→改重复几万次。DenseNet —— 密集连接# --------------------------------------------------------------------- # 3. DenseNet —— 密集连接 # 核心每一层都接收前面所有层的输出沿通道拼接 cat # 特征复用充分用更窄的通道就能达到同样的精度。 # --------------------------------------------------------------------- class DenseBlock(nn.Module): DenseNet 的核心密集拼接块。 核心思想和 ResNet 的区别 - ResNet 是相加out x新旧信息融成一张通道数不变 - DenseNet 是拼接torch.cat([x, out])把新特征贴到旧特征后面通道越来越厚 打个比方 - ResNet每层在原来的画上补几笔画还是那么大 - DenseNet每层画一张新画钉进相册相册越来越厚 好处每一层都能看到前面所有层的输出信息一点不浪费。 def __init__(self, in_channels, growth_rate, num_layers): super().__init__() self.layers nn.ModuleList() for i in range(num_layers): # 第 i 层时前面已经拼了 i 层的新特征进来 # 所以输入通道 原来的 in_channels i * growth_rate越到后面越宽 self.layers.append(self._dense_layer(in_channels i * growth_rate, growth_rate)) def _dense_layer(self, in_channels, growth_rate): 一个层的内部结构BN→ReLU→1x1压缩→BN→ReLU→3x3输出 growth_rate 个通道 return nn.Sequential( nn.BatchNorm2d(in_channels), nn.ReLU(inplaceTrue), # 1x1 卷积先压缩通道通道越来越多先压一下省算力 nn.Conv2d(in_channels, 4 * growth_rate, 1, biasFalse), nn.BatchNorm2d(4 * growth_rate), nn.ReLU(inplaceTrue), # 3x3 卷积真正提取特征只输出 growth_rate 个新通道 nn.Conv2d(4 * growth_rate, growth_rate, 3, 1, 1, biasFalse), ) def forward(self, x): for layer in self.layers: out layer(x) # 这层算出新特征 x torch.cat([x, out], dim1) # 关键把新特征拼到旧特征后面通道变多 return x # 返回所有层特征拼起来的一大坨 class DenseNetBackbone(nn.Module): 整条 DenseNet 主干开头标准操作 3 个 DenseBlock 末尾池化。 数据流图怎么走 conv1 → bn → relu → maxpool先缩小图 → block1(6层) → block2(12层) → block3(24层)三个密集块越拼越宽 → avgpool压成 1x1 阅读顺序 1. 先看本类的 forward整体流程 2. 看到 self.block1(x) 时跳进 DenseBlock.forward 看核心 cat 拼接 3. 最后回头看 __init__ 和 _dense_layer 看每层细节 def __init__(self, growth_rate32, in_channels3): super().__init__() self.conv1 nn.Conv2d(in_channels, 64, 7, 2, 3, biasFalse) # 大卷积先降采样 self.bn1 nn.BatchNorm2d(64) self.relu nn.ReLU(inplaceTrue) self.maxpool nn.MaxPool2d(3, 2, 1) # 池化缩小尺寸 # 注意 block 之间通道是越滚越多的 # 每个 DenseBlock 会把它的输出全部拼接在一起 # 所以下一个 block 的输入通道 上一个 block 输入 里面拼出来的所有新通道 self.block1 DenseBlock(64, growth_rate, 6) # 输入 64 self.block2 DenseBlock(64 6 * growth_rate, growth_rate, 12) # 64 block1 拼出的 6*32 self.block3 DenseBlock(64 18 * growth_rate, growth_rate, 24) # 越拼越宽 self.avgpool nn.AdaptiveAvgPool2d((1, 1)) # 自适应池化压成 1x1 def forward(self, x): x self.relu(self.bn1(self.conv1(x))) # 开头标准操作 x self.maxpool(x) # 缩小 x self.block1(x) # 三个密集块依次走过 x self.block2(x) x self.block3(x) return self.avgpool(x) # 压成 1x1 输出先懂核心思想和 ResNet 的区别ResNet 是加out x新旧信息融成一张宽度不变。 DenseNet 是拼torch.cat([x, out])新特征贴到旧特征后面越来越厚。打个比方ResNet每层在原来的画上补几笔画还是那么大DenseNet每层画一张新画然后钉到相册里相册越来越厚所以 DenseNet 每一层都能看到前面所有层的结果信息一点不浪费。这个算法把每层都拼接起来到底是在干啥核心cat 拼接 把新旧特征都保留下来通道变多信息不丢。让我用具体数字演示。之前已经讲过相册比喻用户现在要更深入到底在干啥。其实回答的核心是拼接是在积累特征。每一层算出的特征都留着后面每一层都能看到前面所有层的信息。这样特征被反复利用信息不浪费梯度更容易传到前面因为到前面有无数条短路径用较少的通道数growth_rate 小就能达到同样精度我用数字走一遍 cat 的过程输入 x通道数 64 第1层算出 out通道数 32growth_rate cat 后6432 96 通道 第2层输入 96算出 32cat 后 128 ...用数字演示拼接后通道变多。比喻再补充像抄笔记。每层学到的笔记都保留后面每一层翻笔记本能看到所有前面的笔记不用重新记信息全在。简洁回答。拼接是在做一件事把每一层学到的特征全部留着谁也不丢。用数字看它到底干了啥假设进来时通道数是 64代表 64 张特征图进来x 64 通道 第 1 层算出新的 32 通道 → 拼起来64 32 96 通道 第 2 层算出新的 32 通道 → 拼起来96 32 128 通道 第 3 层算出新的 32 通道 → 拼起来128 32 160 通道 ...看到了吗通道越拼越多。每一层的特征都叠在一起了。这有什么好处打个比方一本越记越厚的笔记本普通网络每层只把自己记住的东西传给下一层前面记的笔记翻页就忘了DenseNet每层把新笔记直接贴进一本大笔记本所有层都能翻到前面记的所有内容所以特征不浪费—— 前面学到的边缘、纹理后面每一层都能直接翻看不用重新学信息传得快—— 第 1 层的信息有很多条小路能直接到后面误差往回传也不会漏光这也是它对抗梯度消失的方式每层任务更轻—— 只用学一点新的growth_rate 个通道剩下的全靠翻旧笔记一句话拼接 把笔记越攒越厚让每一层都能看到前面所有人的笔记信息一点不浪费。MobileNet —— 深度可分离卷积轻量化# --------------------------------------------------------------------- # 4. MobileNet —— 深度可分离卷积轻量化 # 核心把标准卷积拆成逐通道卷积 1x1 逐点卷积两步 # 参数量大约只有原来的 1/9专门给手机/实时场景用。 # --------------------------------------------------------------------- class DepthwiseSeparableConv(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() # groupsin_channels每个通道单独卷积通道之间不混合 self.depthwise nn.Conv2d(in_channels, in_channels, 3, stride, 1, groupsin_channels, biasFalse) self.bn1 nn.BatchNorm2d(in_channels) # 1x1 卷积负责混合各个通道并改变通道数 self.pointwise nn.Conv2d(in_channels, out_channels, 1, 1, 0, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.bn1(self.depthwise(x))) x self.relu(self.bn2(self.pointwise(x))) return x class MobileNetBackbone(nn.Module): def __init__(self, in_channels3): super().__init__() self.features nn.Sequential( nn.Conv2d(in_channels, 32, 3, 2, 1, biasFalse), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), DepthwiseSeparableConv(32, 64, 1), DepthwiseSeparableConv(64, 128, 2), DepthwiseSeparableConv(128, 128, 1), DepthwiseSeparableConv(128, 256, 2), DepthwiseSeparableConv(256, 256, 1), DepthwiseSeparableConv(256, 512, 2), # 后半段堆叠 5 个相同结构不降尺寸 DepthwiseSeparableConv(512, 512, 1), DepthwiseSeparableConv(512, 512, 1), DepthwiseSeparableConv(512, 512, 1), DepthwiseSeparableConv(512, 512, 1), DepthwiseSeparableConv(512, 512, 1), DepthwiseSeparableConv(512, 1024, 2), DepthwiseSeparableConv(1024, 1024, 1), nn.AdaptiveAvgPool2d((1, 1)), ) def forward(self, x): return self.features(x)CSPDarknet53 —— YOLOv5 的主干# --------------------------------------------------------------------- # 5. CSPDarknet53 —— YOLOv5 的主干 # 核心C3 模块里有一条直通的旁路cv2 直接 1x1 卷积 # 另一半才走 Bottleneck最后拼接。这样能减少计算量、 # 增强梯度传播同时保持精度。 # --------------------------------------------------------------------- class Bottleneck(nn.Module): def __init__(self, in_channels, out_channels, shortcutTrue): super().__init__() hidden out_channels self.cv1 nn.Conv2d(in_channels, hidden, 1, 1) # 1x1 降维 self.cv2 nn.Conv2d(hidden, out_channels, 3, 1, 1) # 3x3 卷积 self.add shortcut and in_channels out_channels # 能加残差才加 def forward(self, x): return x self.cv2(self.cv1(x)) if self.add else self.cv2(self.cv1(x)) class C3(nn.Module): YOLOv5 的核心模块一半直连 一半 Bottleneck最后拼接。 def __init__(self, in_channels, out_channels, n1, shortcutTrue): super().__init__() c_ out_channels // 2 self.cv1 nn.Conv2d(in_channels, c_, 1, 1) # 分支A进 Bottleneck 的那半 self.cv2 nn.Conv2d(in_channels, c_, 1, 1) # 分支B直通的那半CSP 关键 self.m nn.Sequential(*[Bottleneck(c_, c_, shortcut) for _ in range(n)]) self.cv3 nn.Conv2d(2 * c_, out_channels, 1, 1) # 拼接后融合 def forward(self, x): return self.cv3(torch.cat([self.m(self.cv1(x)), self.cv2(x)], dim1)) class CSPDarknet53Backbone(nn.Module): def __init__(self, in_channels3): super().__init__() self.conv1 nn.Conv2d(in_channels, 32, 3, 2, 1) # 第一层就下采样 self.conv2 nn.Conv2d(32, 64, 3, 2, 1) self.c3_1 C3(64, 64, n1) self.conv3 nn.Conv2d(64, 128, 3, 2, 1) self.c3_2 C3(128, 128, n2) self.conv4 nn.Conv2d(128, 256, 3, 2, 1) self.c3_3 C3(256, 256, n3) self.conv5 nn.Conv2d(256, 512, 3, 2, 1) self.c3_4 C3(512, 512, n1) def forward(self, x): x self.conv1(x) x self.conv2(x) x self.c3_1(x) x self.conv3(x) x self.c3_2(x) x self.conv4(x) x self.c3_3(x) x self.conv5(x) x self.c3_4(x) return x