目录
📌 论文信息
📄 论文标题:Deep Residual Learning for Image Recognition
📚 发表期刊/会议:CVPR, 2016
✍ 作者:Kaiming He, Xiangyu Zhang, Shaoqing Ren, Jian Sun
🔗 论文链接
1. 研究背景
💡 现有问题
- 网络越深准确率越高吗? 至少在理论上是正确的。假设一个层数较少的神经网络已经达到了较高准确率,我们在这个神经网络之后拼接一段恒等变换的网络层,这些恒等变换的网络层对输入数据不做任何转换,直接返回(y=x),就能得到一个深度较大的神经网络,并且这个深度较大的神经网络的准确率等于拼接之前的神经网络准确率,准确率没有理由降低。
- 在深度卷积神经网络(CNN)的发展过程中,研究者们发现随着网络层数的增加,模型的性能并不总是随之提升。相反,当网络深度增加到一定程度时,模型的训练误差和测试误差会出现不降反升的现象,即所谓的 “退化问题” (Degradation Problem)。这一问题严重限制了深度网络的性能提升和应用范围。
🎯 解决方案
- 论文把退化现象归因为深层神经网络难以实现恒等变换。随着网络深度的增加,非线性激活函数越来越多,数据被映射到更加离散的空间,难以让数据回到原点。
- 提出了 残差学习框架(Residual Learning Framework)。
- 该框架通过引入 残差块(Residual Block)和 快捷连接(Shortcut Connection),使得网络能够学习输入与输出之间的残差映射,从而有效缓解了退化问题。
- 残差网络(ResNet)通过堆叠残差块构建深度网络,实现了性能随深度增加而持续提升。
2. 方法解析
📌 残差学习
对于一个堆积层结构(几层堆积而成)当输入为 x 时其学习到的特征记为 H(x) ,现在我们希望其可以学习到残差 F(x)=H(x)-x ,这样其实原始的学习特征是 F(x)+x 。之所以这样是因为残差学习相比原始特征直接学习更容易。为什么残差学习相对更容易,从直观上看残差学习需要学习的内容少,因为残差一般会比较小,学习难度小点。
当残差为0时,此时堆积层仅仅做了恒等映射,至少网络性能不会下降,实际上残差不会为0,这也会使得堆积层在输入特征基础上学习到新的特征,从而拥有更好的性能。残差学习的结构如下图所示。这有点类似与电路中的“短路”,所以是一种短路连接(shortcut connection)。
📌 残差单元
如下图所示,ResNet使用两种残差单元,左边对应的是浅层网络(BasicBlock),右边对应的是深层网络(Bottleneck)。对于短路连接,当输入和输出维度一致时,可以直接将输入加到输出上。但是当维度不一致时(对应的是维度增加一倍),这就不能直接相加。有两种策略:
(1)采用zero-padding增加维度,此时一般要先做一个downsamp,可以采用strde=2的pooling,这样不会增加参数;
(2)采用新的映射(projection shortcut),一般采用1x1的卷积,这样会增加参数,也会增加计算量。
3. 代码解析
📌 BasicBlock 模块
如上图左边,基础模块主要用来构建ResNet18和ResNet34网络,包括两个通道数为64的3x3卷积层,之后接着BN层和ReLU。
'''-------------BasicBlock模块-----------------------------'''
# 用于ResNet18和ResNet34基本残差结构块
class BasicBlock(nn.Module):
def __init__(self, inchannel, outchannel, stride=1):
super(BasicBlock, self).__init__()
self.left = nn.Sequential(
nn.Conv2d(inchannel, outchannel, kernel_size=3, stride=stride, padding=1, bias=False),
nn.BatchNorm2d(outchannel),
nn.ReLU(inplace=True), #inplace=True表示进行原地操作,一般默认为False,表示新建一个变量存储操作
nn.Conv2d(outchannel, outchannel, kernel_size=3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(outchannel)
)
self.shortcut = nn.Sequential()
#输入和输出的维度不同时,需要下采样
if stride != 1 or inchannel != outchannel:
self.shortcut = nn.Sequential(
nn.Conv2d(inchannel, outchannel, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(outchannel)
)
def forward(self, x):
out = self.left(x)
out += self.shortcut(x)#这是ResNet的核心,在输出上叠加了输入x
out = F.relu(out)
return out
📌 Bottleneck 模块
BottleNeck主要用在ResNet50及以上的网络结构,它有3个卷积层,大小分别为1x1、3x3、1x1,分别用于压缩维度、卷积处理、恢复维度。
'''-------------Bottleneck模块-----------------------------'''
# 用于ResNet50及以上的残差结构块
class Bottleneck(nn.Module):
def __init__(self, inchannel, outchannel, stride=1):
super(Bottleneck, self).__init__()
self.left = nn.Sequential(
nn.Conv2d(inchannel, int(outchannel / 4), kernel_size=1, stride=stride, padding=0, bias=False),
nn.BatchNorm2d(int(outchannel / 4)),
nn.ReLU(inplace=True),
nn.Conv2d(int(outchannel / 4), int(outchannel / 4), kernel_size=3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(int(outchannel / 4)),
nn.ReLU(inplace=True),
nn.Conv2d(int(outchannel / 4), outchannel, kernel_size=1, stride=1, padding=0, bias=False),
nn.BatchNorm2d(outchannel),
) # outchannel / 4是因为Bottleneck层输出通道都是输入的4倍
self.shortcut = nn.Sequential()
if stride != 1 or inchannel != outchannel:
self.shortcut = nn.Sequential(
nn.Conv2d(inchannel, outchannel, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(outchannel)
)
def forward(self, x):
out = self.left(x)
out += self.shortcut(x)
out = F.relu(out)
return out
📌 ResNet 主体
根据上述的BasicBlock基础块和BottleNeck结构,我们可以搭建ResNet结构了。5种不同层数的ResNet结构图如下图所示:
ResNet18
'''----------ResNet18----------'''
class ResNet_18(nn.Module):
def __init__(self, ResidualBlock, num_classes=10):
super(ResNet_18, self).__init__()
self.inchannel = 64
self.conv1 = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(64),
nn.ReLU(),
)
self.layer1 = self.make_layer(ResidualBlock, 64, 2, stride=1)
self.layer2 = self.make_layer(ResidualBlock, 128, 2, stride=2)
self.layer3 = self.make_layer(ResidualBlock, 256, 2, stride=2)
self.layer4 = self.make_layer(ResidualBlock, 512, 2, stride=2)
self.fc = nn.Linear(512, num_classes)
def make_layer(self, block, channels, num_blocks, stride):
strides = [stride] + [1] * (num_blocks - 1) # strides=[1,1]
layers = []
for stride in strides:
layers.append(block(self.inchannel, channels, stride))
self.inchannel = channels
return nn.Sequential(*layers)
def forward(self, x): # 3*32*32
out = self.conv1(x) # 64*32*32
out = self.layer1(out) # 64*32*32
out = self.layer2(out) # 128*16*16
out = self.layer3(out) # 256*8*8
out = self.layer4(out) # 512*4*4
out = F.avg_pool2d(out, 4) # 512*1*1
out = out.view(out.size(0), -1) # 512
out = self.fc(out)
return out
ResNet50
'''---------ResNet50--------'''
class ResNet_50(nn.Module):
def __init__(self, ResidualBlock, num_classes=10):
super(ResNet_50, self).__init__()
self.inchannel = 64
self.conv1 = nn.Sequential(
nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False),
nn.BatchNorm2d(64),
nn.ReLU(),
)
self.layer1 = self.make_layer(ResidualBlock, 256, 3, stride=1)
self.layer2 = self.make_layer(ResidualBlock, 512, 4, stride=2)
self.layer3 = self.make_layer(ResidualBlock, 1024, 6, stride=2)
self.layer4 = self.make_layer(ResidualBlock, 2048, 3, stride=2)
self.fc = nn.Linear(512 * 4, num_classes)
def make_layer(self, block, channels, num_blocks, stride):
strides = [stride] + [1] * (num_blocks - 1) # strides=[1,1]
layers = []
for stride in strides:
layers.append(block(self.inchannel, channels, stride))
self.inchannel = channels
return nn.Sequential(*layers)
def forward(self, x): # 3*32*32
out = self.conv1(x) # 64*32*32
out = self.layer1(out) # 64*32*32
out = self.layer2(out) # 128*16*16
out = self.layer3(out) # 256*8*8
out = self.layer4(out) # 512*4*4
out = F.avg_pool2d(out, 4) # 512*1*1
out = out.view(out.size(0), -1) # 512
out = self.fc(out)
return out
4. 复现实验
为了更好地掌握 ResNet 网络,我利用 ResNet18 网络完成了表情识别任务,并对模型进行了剪枝操作。
🔹数据集与预处理
数据集概况
本实验所用的数据集是从表情识别公开数据集 RAF-DB中抽取部分数据所构成的,共有 7 种表情类别,分别是 ’Anger’、Disgust’、 ’Fear’、 ’Happiness’、 ’Neutral’、 ‘Sadness’、 ‘Surprise’ 。每个类别的图像数据分布与真实的 RAF-DB数据集类似。共有 3034张训练图像和 779 张测试图像。
数据增强策略
data_transform = {
"train": transforms.Compose([
transforms.Resize([config['img_size'], config['img_size']]),#统一尺寸
transforms.RandomHorizontalFlip(),#以 50% 的概率水平翻转图像
transforms.ToTensor(),#转换为 PyTorch 的 Tensor,并将像素值归一化到 [0,1]
]),
"val": transforms.Compose([
transforms.Resize([config['img_size'], config['img_size']]),
transforms.ToTensor(),
])
}
🔹实验流程
(1)数据集划分与加载
- read_split_data 按类别读取数据集,并根据指定比例 (val_rate) 将数据随机划分为训练集(2430)、验证集(604)和测试集(779)。
- MyDataSet 用于加载图像及其标签,并支持数据预处理和自定义批量处理 (collate_fn) 以供 DataLoader 使用。
def read_split_data(root: str, val_rate: float = 0.2):
random.seed(0)
classes = sorted(os.listdir(root)) # 获取数据集所有类别名称,并排序
class_to_idx = {cls_name: i for i, cls_name in enumerate(classes)} # 生成类别到索引的映射
train_images_path = []
train_images_label = []
val_images_path = []
val_images_label = []
for cls_name in classes:
cls_path = os.path.join(root, cls_name) # 获取该类别的文件夹路径
images = [os.path.join(cls_path, img) for img in os.listdir(cls_path)] # 获取该类别下所有图片路径
random.shuffle(images)
val_num = int(len(images) * val_rate) # 计算验证集的图片数量
train_images_path.extend(images[val_num:])
train_images_label.extend([class_to_idx[cls_name]]*(len(images)-val_num))
val_images_path.extend(images[:val_num])
val_images_label.extend([class_to_idx[cls_name]]*val_num)
return train_images_path, train_images_label, val_images_path, val_images_label
class MyDataSet(Dataset):
def __init__(self, images_path: list, images_class: list, transform=None):
self.images_path = images_path
self.images_class = images_class
self.transform = transform
def __len__(self):
return len(self.images_path)
def __getitem__(self, item):
"""
获取数据集中的单个样本(图片和标签)。
:param item: 样本索引。
:return: 预处理后的图片和对应的标签。
"""
img = Image.open(self.images_path[item]) # 打开图片
if img.mode != 'RGB':
img = img.convert('RGB') # 确保图片为 RGB 模式
label = self.images_class[item] # 获取对应的标签
if self.transform is not None:
img = self.transform(img)
return img, label
@staticmethod
def collate_fn(batch):
"""
自定义批量处理函数,用于 DataLoader。
:param batch: 一个 batch 的数据,包含多个 (image, label) 元组。
:return: 经过批量处理后的图片和标签张量。
"""
images, labels = tuple(zip(*batch)) # 拆分 batch 为图片和标签
images = torch.stack(images, dim=0) # 将图片转换为张量
labels = torch.as_tensor(labels) # 将标签转换为张量
return images, labels
(2)模型设计
- 定义了一个基于 ResNet-18 的自定义分类模型 my_model,移除了 ResNet-18 的原始全连接层 (fc),保留其特征提取部分,并添加一个新的全连接层 (fc) 进行分类,支持 num_classes(7) 个类别。
class my_model(nn.Module):
def __init__(self, num_classes=7):
super(my_model,self).__init__()
self.backbone=models.resnet18(pretrained=True)
self.backbone.fc=nn.Identity() # 移除原全连接层
self.fc=nn.Linear(512,num_classes)
def forward(self,x):
features=self.backbone(x)
return self.fc(features)
- 选择优化器:根据 config[‘optimizer’] 选择
- Adam:自适应优化算法,使用 lr 作为学习率,并添加 weight_decay(权重衰减)。
- SGD:随机梯度下降,额外使用 momentum=0.9 以加速收敛,并加入 weight_decay。
- 其他优化器不支持,抛出异常。
- 设置学习率调度器:使用余弦退火 (CosineAnnealingLR),在 T_max=config[‘epochs’] 轮训练中逐渐降低学习率,最小值为 eta_min=0,以提高训练稳定性和收敛效果。
# 优化器
if config['optimizer'] == 'Adam':
optimizer = torch.optim.Adam(model.parameters(),
lr=config['learning_rate'],
weight_decay=config['weight_decay'])
elif config['optimizer'] == 'SGD':
optimizer = torch.optim.SGD(model.parameters(),
lr=config['learning_rate'],
momentum=0.9,
weight_decay=config['weight_decay'])
else:
raise ValueError("Unsupported optimizer")
# 学习率调度
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
optimizer, T_max=config['epochs'], eta_min=0)
(3)模型剪枝
(i)重要性评估策略
在本实验中,我们自定义了 MySlimmingImportance 重要性评估类,主要计算 Batch Normalization(BN)层的缩放参数(scale, 即 weight)的绝对值 作为剪枝依据。计算步骤如下:
- 遍历模型中的各个层,筛选出 BN 层。
- 计算 BN 层的 weight 绝对值,作为该层的重要性指标。
- 对所有 BN 层的重要性进行平均计算,作为剪枝决策的依据。
class MySlimmingImportance(tp.importance.Importance):
def __call__(self, group, **kwargs):
group_imp = []
for dep, idxs in group:
layer = dep.target.module
if isinstance(layer, (nn.BatchNorm1d, nn.BatchNorm2d, nn.BatchNorm3d)) and layer.affine:
group_imp.append(torch.abs(layer.weight.data))
if not group_imp:
return None
return torch.stack(group_imp).mean(dim=0)
(ii)剪枝过程
- 忽略某些层的剪枝。例如,对于 nn.Linear 层,若 out_features 等于类别数(7),则不进行剪枝。
- 构造剪枝器 MetaPruner:使用 MySlimmingImportance 作为剪枝策略。设定 ch_sparsity=0.5,即通道剪枝比例为 50%。
- 统计剪枝前的计算量(MACs)和参数量(Params)。
- 执行剪枝 pruner.step()。
- 统计剪枝后的计算量和参数量。
# 模型剪枝
print("\nStarting Pruning...")
ignored_layers = []
for m in model.modules():
if isinstance(m, torch.nn.Linear) and m.out_features == 7:
ignored_layers.append(m)
example_inputs = torch.randn(1, 3, 224, 224).to(config['device'])
imp = MySlimmingImportance()
pruner = tp.pruner.MetaPruner(
model,
example_inputs,
importance=imp,
iterative_steps=config['epochs'],
ch_sparsity=0.5,
ignored_layers=ignored_layers,
)
base_macs, base_nparams = tp.utils.count_ops_and_params(model, example_inputs)
print(f"Before Pruning: MACs={base_macs/1e9:.2f}G, Params={base_nparams/1e6:.2f}M")
pruner.step()
pruned_macs, pruned_nparams = tp.utils.count_ops_and_params(model, example_inputs)
print(f"After Pruning: MACs={pruned_macs/1e9:.2f}G, Params={pruned_nparams/1e6:.2f}M")
(iii)剪枝后微调
剪枝后,模型结构发生了变化,因此需要进行微调(Fine-tuning)来恢复模型精度。
- 采用相同的训练方法,对剪枝后的模型进行 config[‘epochs’]//2 轮训练。
- 记录训练和验证的损失及准确率。
print("\nFine-tuning after pruning...")
for epoch in range(config['epochs']//2):
train_loss, train_acc = utils.trainer(model, optimizer, train_loader, config, epoch)
val_loss, val_acc = utils.evaluater(model, val_loader, config, epoch)
print(f"Finetune Epoch {epoch}: Val Acc={val_acc:.4f} Val Loss={val_loss:.4f} Train Acc={train_acc:.4f} Train Loss={train_loss:.4f}")
最终,我们将剪枝后的模型保存,以便后续部署。
torch.save(model, os.path.join(config['save_path'], 'pruned_model.pth'))
🔹实验结果
在60个epoch的训练过程中,训练集的最高正确率为100%左右,但验证集最高正确率只有75%左右,如下表所示,测试集准确率也为73%左右。
类别 | 准确率 |
---|---|
训练 | 100% |
验证 | 74.17% |
测试 | 73.31% |
我们还可以看到剪枝之后的参数减少的幅度并不大,但也有所减少。
MACs | Params | |
---|---|---|
剪枝前 | 1.82G | 11.18M |
剪枝后 | 1.77G | 10.94M |
模型剪枝后,又微调训练了30个epoch,训练结果如下表所示。
准确率 | 损失值 | |
---|---|---|
训练集 | 99.71% | 0.0174 |
验证集 | 72.35% | 1.1566 |
之后利用摄像头实时监测表情,发现高兴、悲伤和中立时最容易识别的,且不容易出错。但其他表情较难识别,且出错的情况时常出现,说明模型有待进一步的改进和完善。
完整代码在 github
5. 结论和收获
在论文阅读中,深入学习了 ResNet(Residual Network) 的核心思想,特别是 残差学习框架 如何有效缓解深度网络的 退化问题,从而使得网络层数可以进一步增加,而不会导致梯度消失或模型性能下降。
通过复现 ResNet18 并应用于 表情识别任务,进一步加深了对残差块(Residual Block)和瓶颈块(Bottleneck Block)的理解,掌握了 BasicBlock 和 Bottleneck 模块的实现方式,并成功构建了自己的深度学习模型。此外,还在 ResNet18 基础上进行 模型剪枝,优化了计算效率。
💡本次实验的主要收获:
✅ 深度网络退化问题:理解了为何随着网络加深,普通 CNN 性能反而下降,以及 ResNet 如何通过跳跃连接(Shortcut Connection) 解决这一问题。
✅ 残差学习的优势:学习到直接拟合残差比拟合原始映射更容易,残差块在信息传递上的关键作用。
✅ 模型复现与实践:通过代码实现 ResNet18 及其在表情识别任务中的应用,掌握了 ResNet 结构的细节。
✅ 优化训练流程:尝试不同的优化器、学习率调度方法,并对模型进行剪枝,提高了训练效率和模型推理速度。
以上仅是个人理解和学习的笔记,如有错误,欢迎大家指正,我们一起学习,快乐科研!