3、PyTorch模型基础:从零构建一个简单的CNN分类模型(ResNet-18)
说实话,很多搞量化部署的朋友,一上来就盯着算子替换、精度校准这些高级话题。但我个人觉得,如果连模型本身的结构都没吃透,后面做量化优化很容易踩坑。今天我们就从零开始,手撸一个ResNet-18,把模型定义、前向传播、参数存储这三块彻底搞明白。
3.1 为什么选ResNet-18?
你可能会问:讲CNN分类模型,为啥不选VGG或者AlexNet?嗯,这里有个关键原因。我在做GPU推理加速时发现,ResNet的残差结构对量化部署特别友好。为什么呢?因为残差连接能缓解梯度消失,让深层网络也能稳定训练。而且ResNet-18结构相对规整,层数不多不少,非常适合作为我们后续做量化实验的基准模型。
核心要点:ResNet-18由4个残差块组(Layer1~Layer4)组成,每个组包含2个BasicBlock,总共8个BasicBlock + 1个初始卷积层 + 1个全连接层。
3.2 模型定义:从BasicBlock开始
我们先定义最基础的组件——BasicBlock。说白了,这就是一个带有残差连接的小模块。
import torch
import torch.nn as nn
import torch.nn.functional as F
class BasicBlock(nn.Module):
expansion = 1 # 输出通道扩展系数
def __init__(self, in_channels, out_channels, stride=1, downsample=None):
super(BasicBlock, self).__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3,
stride=stride, padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3,
stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
self.downsample = downsample # 用于匹配维度
def forward(self, x):
identity = x # 保存输入,用于残差连接
out = self.conv1(x)
out = self.bn1(out)
out = F.relu(out)
out = self.conv2(out)
out = self.bn2(out)
# 如果输入输出维度不匹配,需要下采样
if self.downsample is not None:
identity = self.downsample(x)
out += identity # 残差相加
out = F.relu(out)
return out
注意看,这里有个downsample参数。我在项目中遇到过,很多人写ResNet时忘记处理维度不匹配的问题,导致模型跑起来就报错。当输入输出通道数不同,或者特征图尺寸减半时,我们需要用1x1卷积把输入投影到正确的维度。
3.3 组装ResNet-18
有了BasicBlock,我们就可以像搭积木一样组装ResNet-18了。
class ResNet18(nn.Module):
def __init__(self, num_classes=1000):
super(ResNet18, self).__init__()
self.in_channels = 64
# 初始卷积层
self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
self.bn1 = nn.BatchNorm2d(64)
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
# 4个残差块组
self.layer1 = self._make_layer(64, 2, stride=1)
self.layer2 = self._make_layer(128, 2, stride=2)
self.layer3 = self._make_layer(256, 2, stride=2)
self.layer4 = self._make_layer(512, 2, stride=2)
# 分类头
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512, num_classes)
def _make_layer(self, out_channels, blocks, stride):
downsample = None
if stride != 1 or self.in_channels != out_channels:
downsample = nn.Sequential(
nn.Conv2d(self.in_channels, out_channels, kernel_size=1,
stride=stride, bias=False),
nn.BatchNorm2d(out_channels),
)
layers = []
layers.append(BasicBlock(self.in_channels, out_channels, stride, downsample))
self.in_channels = out_channels
for _ in range(1, blocks):
layers.append(BasicBlock(out_channels, out_channels))
return nn.Sequential(*layers)
def forward(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = F.relu(x)
x = self.maxpool(x)
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
x = self.avgpool(x)
x = torch.flatten(x, 1)
x = self.fc(x)
return x
个人经验:我建议你在写_make_layer时,把self.in_channels的更新逻辑放在循环外面。曾经有个同事把更新写在了循环里,结果每个BasicBlock的输入通道都变成了上一层的输出通道,模型直接崩了。
3.4 前向传播:数据是怎么流动的?
我们来看一张图,直观理解数据在ResNet-18中的流动路径。
你看,数据从输入开始,经过初始卷积和池化,尺寸从224x224缩小到56x56。然后经过4个残差块组,每经过一组,通道数翻倍、尺寸减半。最后通过全局平均池化变成1x1的向量,送入全连接层分类。
注意:在Layer2、Layer3、Layer4的第一个BasicBlock中,stride=2会导致特征图尺寸减半。这时候残差连接必须通过downsample来匹配维度。我曾经见过有人忘记设置downsample,结果模型训练时loss直接变成NaN。
3.5 参数存储:模型到底存了什么?
模型定义好了,参数是怎么存的?说白了,PyTorch的state_dict就是一个有序字典,里面存了所有可训练参数的名称和数值。
# 实例化模型
model = ResNet18(num_classes=10)
# 查看模型结构
print(model)
# 查看参数统计
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"总参数量: {total_params:,}")
print(f"可训练参数量: {trainable_params:,}")
# 查看state_dict的键
state_dict = model.state_dict()
print(f"\nstate_dict包含 {len(state_dict)} 个键值对")
for key in list(state_dict.keys())[:5]: # 只显示前5个
print(f" {key}: {state_dict[key].shape}")
输出结果大致如下:
总参数量: 11,173,962
可训练参数量: 11,173,962
state_dict包含 62 个键值对
conv1.weight: torch.Size([64, 3, 7, 7])
bn1.weight: torch.Size([64])
bn1.bias: torch.Size([64])
layer1.0.conv1.weight: torch.Size([64, 64, 3, 3])
layer1.0.bn1.weight: torch.Size([64])
| 组件 | 参数量 | 占比 |
|---|---|---|
| 初始卷积层 (conv1) | 9,408 | 0.08% |
| Layer1 (2个BasicBlock) | 147,456 | 1.32% |
| Layer2 (2个BasicBlock) | 524,288 | 4.69% |
| Layer3 (2个BasicBlock) | 2,097,152 | 18.77% |
| Layer4 (2个BasicBlock) | 8,388,608 | 75.08% |
| 全连接层 (fc) | 5,130 | 0.05% |
关键发现:你看,Layer4占了75%的参数量!这意味着什么?在做量化部署时,Layer4的权重对精度影响最大。我一般会优先对Layer4做更精细的量化策略,比如逐通道量化或者保留更高位宽。
3.6 保存与加载模型
模型训练好了,怎么保存?我推荐只保存state_dict,而不是整个模型对象。为什么呢?因为只保存参数更灵活,加载时你可以先定义模型结构,再加载参数,这样代码更清晰。
# 保存模型参数
torch.save(model.state_dict(), 'resnet18_cifar10.pth')
# 加载模型参数
model = ResNet18(num_classes=10)
model.load_state_dict(torch.load('resnet18_cifar10.pth'))
model.eval() # 切换到推理模式
避坑指南:我曾经在加载模型时忘记调用model.eval(),结果推理结果一直不对。因为BN层和Dropout在训练和推理模式下行为不同。记住:推理前一定要调eval(),否则你会被坑得很惨。
3.7 小结
今天我们做了三件事:定义了ResNet-18的完整结构,理解了数据前向传播的路径,搞清楚了参数是怎么存储的。这些基础打牢了,后面做量化优化时你才能知道:哪些层对精度敏感,哪些层可以大胆压缩。
嗯,我个人觉得,花时间把模型结构吃透,比盲目调参要划算得多。你想想看,如果连模型有多少层、每层什么作用都不清楚,后面做量化部署时出了问题,你连排查的方向都没有。
公众号:蓝海资料掘金营,微信deep3321