目录
1.模型构建:
残差块通过跳跃连接将输入直接添加到输出上,通过跳跃连接,确保梯度可以直接反向传播到浅层,缓解了梯度消失问题。通过学习残差 F(x)=H(x)−x,也能简化目标函数的优化。
残差模块变相地增加了网络的层数,提高了反向传播时参数的更新效率。但是同时,也提高了网络的运算量。
首先构建一个不带残差连接的ResNet来观察与带残差连接的网络相比,模型对数据的训练能力。
class ResBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1, use_residual=True):
"""
残差单元
输入:
- in_channels:输入通道数
- out_channels:输出通道数
- stride:残差单元的步长,通过调整残差单元中第一个卷积层的步长来控制
- use_residual:用于控制是否使用残差连接
"""
super(ResBlock, self).__init__()
self.stride = stride
self.use_residual = use_residual
# 第一个卷积层,卷积核大小为3×3,可以设置不同输出通道数以及步长
self.conv1 = nn.Conv2d(in_channels, out_channels, 3, padding=1, stride=self.stride, bias=False)
# 第二个卷积层,卷积核大小为3×3,不改变输入特征图的形状,步长为1
self.conv2 = nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False)
# 如果conv2的输出和此残差块的输入数据形状不一致,则use_1x1conv = True
# 当use_1x1conv = True,添加1个1x1的卷积作用在输入数据上,使其形状变成跟conv2一致
if in_channels != out_channels or stride != 1:
self.use_1x1conv = True
else:
self.use_1x1conv = False
# 当残差单元包裹的非线性层输入和输出通道数不一致时,需要用1×1卷积调整通道数后再进行相加运算
if self.use_1x1conv:
self.shortcut = nn.Conv2d(in_channels, out_channels, 1, stride=self.stride, bias=False)
# 每个卷积层后会接一个批量规范化层,批量规范化的内容在7.5.1中会进行详细介绍
self.bn1 = nn.BatchNorm2d(out_channels)
self.bn2 = nn.BatchNorm2d(out_channels)
if self.use_1x1conv:
self.bn3 = nn.BatchNorm2d(out_channels)
def forward(self, inputs):
test = inputs.detach().numpy()
y = F.relu(self.bn1(self.conv1(inputs)))
y = self.bn2(self.conv2(y))
if self.use_residual:
if self.use_1x1conv: # 如果为真,对inputs进行1×1卷积,将形状调整成跟conv2的输出y一致
shortcut = self.shortcut(inputs)
shortcut = self.bn3(shortcut)
else: # 否则直接将inputs和conv2的输出y相加
shortcut = inputs
y = torch.add(shortcut, y)
out = F.relu(y)
return out
# 定义完整残差网络
def make_first_module(in_channels):
# 模块一:7*7卷积、批量规范化、汇聚
m1 = nn.Sequential(
nn.Conv2d(in_channels, 64, 7, stride=2, padding=3),
nn.BatchNorm2d(64), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
)
return m1
def resnet_module(input_channels, out_channels, num_res_blocks, stride=1, use_residual=True):
blk = []
# 根据num_res_blocks,循环生成残差单元
for i in range(num_res_blocks):
if i == 0: # 创建模块中的第一个残差单元
blk.append(ResBlock(input_channels, out_channels,
stride=stride, use_residual=use_residual))
else: # 创建模块中的其他残差单元
blk.append(ResBlock(out_channels, out_channels, use_residual=use_residual))
return blk
def make_modules(use_residual):
# 模块二:包含两个残差单元,输入通道数为64,输出通道数为64,步长为1,特征图大小保持不变
m2 = nn.Sequential(*resnet_module(64, 64, 2, stride=1, use_residual=use_residual))
# 模块三:包含两个残差单元,输入通道数为64,输出通道数为128,步长为2,特征图大小缩小一半。
m3 = nn.Sequential(*resnet_module(64, 128, 2, stride=2, use_residual=use_residual))
# 模块四:包含两个残差单元,输入通道数为128,输出通道数为256,步长为2,特征图大小缩小一半。
m4 = nn.Sequential(*resnet_module(128, 256, 2, stride=2, use_residual=use_residual))
# 模块五:包含两个残差单元,输入通道数为256,输出通道数为512,步长为2,特征图大小缩小一半。
m5 = nn.Sequential(*resnet_module(256, 512, 2, stride=2, use_residual=use_residual))
return m2, m3, m4, m5
class Model_ResNet18(nn.Module):
def __init__(self, in_channels=3, num_classes=10, use_residual=True):
super(Model_ResNet18,self).__init__()
m1 = make_first_module(in_channels)
m2, m3, m4, m5 = make_modules(use_residual)
# 封装模块一到模块6
self.net = nn.Sequential(m1, m2, m3, m4, m5,
# 模块六:汇聚层、全连接层
nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(512, num_classes) )
def forward(self, x):
test = np.array(x)
return self.net(x)
- 第一模块:包含了一个步长为2,大小为7×7的卷积层,卷积层的输出通道数为64,卷积层的输出经过批量归一化、ReLU激活函数的处理后,接了一个步长为2的3×3的最大汇聚层;
- 第二模块:包含了两个残差单元,经过运算后,输出通道数为64,特征图的尺寸保持不变;
- 第三模块:包含了两个残差单元,经过运算后,输出通道数为128,特征图的尺寸缩小一半;
- 第四模块:包含了两个残差单元,经过运算后,输出通道数为256,特征图的尺寸缩小一半;
- 第五模块:包含了两个残差单元,经过运算后,输出通道数为512,特征图的尺寸缩小一半;
- 第六模块:包含了一个全局平均汇聚层,将特征图变为1×1的大小,最终经过全连接层计算出最后的输出。
1.1统计模型参数
model = Model_ResNet18(in_channels=1, num_classes=10, use_residual=True)
params_info = summary(model, (1, 1, 32, 32))
1.2统计计算量
flops,param = get_model_complexity_info(model,(1,32,32),as_strings=True, print_per_layer_stat=True)
部分统计量如下:
2.模型训练
# 定义网络,不使用残差结构的深层网络
model = Model_ResNet18(in_channels=1, num_classes=10, use_residual=False)
# 定义优化器
optimizer = torch.optim.SGD(model.parameters(),lr=LR)
# 实例化RunnerV3
runner = RunnerV3(model, optimizer, loss_fn, metric)
# 启动训练
log_steps = 15
eval_steps = 15
runner.train(train_loader, dev_loader, num_epochs=5, log_steps=log_steps,
eval_steps=eval_steps, save_path="best_model.pdparams")
# 可视化观察训练集与验证集的Loss变化情况
plot_training_loss_acc(runner, 'cnn-loss2.pdf')
3.Debug
这个结果显然很奇怪啊,因此这里测试了预测值的大小,发现预测值全为9,首先推测是数据集初始化的问题。
第一次尝试
经过排查是因为数据集初始化的时候归一化和书上不一样。tranform的方法采用了to_tensor的方法,它会将图像从[0, 255]
范围(uint8格式)缩放到[0, 1]
范围(float32格式)。
经过查询资料发现,如果图片本身是正确的,但数据数值非常小(例如,接近0),观察数据时可能会误认为全零。因此存在可能是图片归一化范围太小导致的问题。
第二次尝试
在排除了这个错误并对数据重新进行[-1,1]的归一化之后,的数据任然得出了错误的结果。那么还有一个可能就是激活函数导致的错误。
这里查询了在验证集上的logits的数值,也就是最后经过前向传播的输出值,发现他们在第一维上完全一样。
这里查看但是这里在train函数上计算loss的时候的激活是对的,那么问题应该出在测试集上。
但是发现运行结果还是错的。
这里提出了一个猜想:学习率过低。
第三次尝试:
这里打印了所有层的权重。
于是我猜测可以是因为学习率太小了,导致批归一化层完全没有更新,也就是发生了梯度消失的现象。因此在测试集上得出的logits结果完全一样。
lr:0.01->0.05
在增大学习率之后虽然前向传播后的权重发生了变化,但是预测结果仍然没有发生变化。
第四次尝试
这里猜测是梯度更新的问题。以下是部分权重的更新现象。
打印了反向传播后的权重之后,发现卷积核上出现了梯度消失的现象。
一开始是怀疑relu函数的问题,但是更换激活函数也没有发生显著的变化。
于是倒查了输入量,采用了其他的代码后发现我的代码初始化有问题,在第一维上的数据全部相同。也就是说所有的通道上的卷积核一样。最后修改了代码,终于跑对了。
卷积后的数据
输入数据
正确的结果如下:
可以看出,不带残差连接的ResNet上,相较于LetNet而言,正确率并没有很大的提高,甚至不如LetNet的计算时间少。
正确的不同通道上,不同卷积核的卷积表现:
4.模型评价
5.带残差连接ResNet
下面实验带残差连接的ResNet。
model = Model_ResNet18(in_channels=1, num_classes=10, use_residual=True)
从结果可以看出,相较于无残差连接的ResNet来说,该网络模型显著提高了正确率。但是运行时长也相应有些增长。