目录
1. 残差单元的构建
实现一个算子ResBlock
来构建残差单元,其中定义了use_residual
参数,用于在后续实验中控制是否使用残差连接。
残差单元包裹的非线性层的输入和输出形状大小应该一致。如果一个卷积层的输入特征图和输出特征图的通道数不一致,则其输出与输入特征图无法直接相加。为了解决上述问题,我们可以使用1×11×1大小的卷积将输入特征图的通道数映射为与级联卷积输出特征图的一致通道数。 残差单元的构建流程如下:
# 残差单元
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
self.conv1 = nn.Conv2d(in_channels, out_channels, 3, padding=1, stride=self.stride, bias=False)
self.conv2 = nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False)
if in_channels!=out_channels or stride!=1:
self.use_1x1conv=True
else:
self.use_1x1conv=False
if self.use_1x1conv:
self.shortcut=nn.Conv2d(in_channels,out_channels,1,stride=self.stride,bias=False)
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):
y=F.relu(self.bn1(self.conv1(inputs)))
y=self.bn2(self.conv2(y))
if self.use_residual:
if self.use_1x1conv:
shortcut=self.shortcut(inputs)
shortcut=self.bn3(shortcut)
else:
shortcut=inputs
y=torch.add(shortcut,y)
out=F.relu(y)
return out
2. 残差网络的整体模型结构
可以将ResNet18网络划分为6个模块:
- 第一模块:包含了一个步长为2,大小为7×77×7的卷积层,卷积层的输出通道数为64,卷积层的输出经过批量归一化、ReLU激活函数的处理后,接了一个步长为2的3×33×3的最大汇聚层;
- 第二模块:包含了两个残差单元,经过运算后,输出通道数为64,特征图的尺寸保持不变;
- 第三模块:包含了两个残差单元,经过运算后,输出通道数为128,特征图的尺寸缩小一半;
- 第四模块:包含了两个残差单元,经过运算后,输出通道数为256,特征图的尺寸缩小一半;
- 第五模块:包含了两个残差单元,经过运算后,输出通道数为512,特征图的尺寸缩小一半;
- 第六模块:包含了一个全局平均汇聚层,将特征图变为1×11×1的大小,最终经过全连接层计算出最后的输出。
1. 定义第一模块的函数:用于创建ResNet-18的第一个模块。包括一个卷积层、BatchNorm2d层、ReLU激活函数和最大池化层。其中卷积层的输入通道数为in_channels,输出通道数为64,卷积核大小为7,步长为2,填充为3。最大池化层的核大小为3,步长为2,填充为1。然后将这些层组合成一个模块m1。
2. 定义残差函数:用于创建包含多个残差单元的模块。将输入通道数input_channels、输出通道数out_channels、残差单元的数量num_res_blocks、步长stride和是否使用残差连接use_residual作为参数。在函数内部,通过循环创建num_res_blocks个ResBlock,并将它们组合成一个列表。
3. 定义模块二~模块五的函数:用于创建ResNet-18的二~五模块。在该函数中,通过调用残差函数分别创建了模块二、模块三、模块四和模块五,每个模块包含了两个残差单元,并将它们组合成模块m2、m3、m4和m5。
4. 定义含有残差的卷积模型Model_ResNet18: 继承自nn.Module,用于构建整个ResNet-18模型。在初始化方法中,首先调用第一模块函数创建第一个模块m1,然后调用残差函数创建模块二至模块五的模块m2、m3、m4和m5。接着将这些模块以及AdaptiveAvgPool2d、Flatten(多维张量展平成一维张量)和Linear层组合成一个l模块self.net,并在前向传播方法forward中使用self.net对输入进行前向传播,并返回输出。
def make_first_module(in_channels):
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=[]
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)
self.net=nn.Sequential(m1,m2,m3,m4,m5,nn.AdaptiveAvgPool2d(1),nn.Flatten(),nn.Linear(512,num_classes))
def forward(self,x):
return self.net(x)
3. 没有残差连接的ResNet18训练
-
模型训练
torch.manual_seed(100)
# 学习率大小
lr = 0.005
# 批次大小
batch_size = 64
# 加载数据
train_loader = io.DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
dev_loader = io.DataLoader(dev_dataset, batch_size=batch_size)
test_loader = io.DataLoader(test_dataset, batch_size=batch_size)
# 定义网络,不使用残差结构的深层网络
model = Model_ResNet18(in_channels=1, num_classes=10, use_residual=False)
# 定义优化器
optimizer = opt.SGD(model.parameters(), lr=lr)
# 定义损失函数
loss_fn = F.cross_entropy
# 定义评价指标
metric = metric.Accuracy()
# 实例化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')
-
模型评价
# 加载最优模型
runner.load_model('best_model.pdparams')
# 模型评价
score, loss = runner.evaluate(test_loader)
print("[Test] accuracy/loss: {:.4f}/{:.4f}".format(score, loss))
-
模型预测
# 从测试集中随机选择一批数据进行预测
data_iter = iter(test_loader)
images, labels = next(data_iter)
# 使用模型进行预测
model.eval()
with torch.no_grad():
outputs = model(images)
_, predicted = torch.max(outputs, 1)
# 输出预测结果
print('Predicted: ', ' '.join('%5s' % predicted[j].item() for j in range(batch_size)))
print('Ground truth: ', ' '.join('%5s' % labels[j] for j in range(batch_size)))
4. 有残差连接的ResNet18训练
-
模型训练
# 学习率大小
lr = 0.01
# 批次大小
batch_size = 64
# 加载数据
train_loader = io.DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
dev_loader = io.DataLoader(dev_dataset, batch_size=batch_size)
test_loader = io.DataLoader(test_dataset, batch_size=batch_size)
# 定义网络,通过指定use_residual为True,使用残差结构的深层网络
model = Model_ResNet18(in_channels=1, num_classes=10, use_residual=True)
# 定义优化器
optimizer = opt.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-loss3.pdf')
# 加载最优模型
runner.load_model('best_model.pdparams')
# 模型评价
score, loss = runner.evaluate(test_loader)
print("[Test] accuracy/loss: {:.4f}/{:.4f}".format(score, loss))
-
模型评价
# 加载最优模型
runner.load_model('best_model.pdparams')
# 模型评价
score, loss = runner.evaluate(test_loader)
print("[Test] accuracy/loss: {:.4f}/{:.4f}".format(score, loss))
-
模型预测
# 从测试集中随机选择一批数据进行预测
data_iter = iter(test_loader)
images, labels = next(data_iter)
# 使用模型进行预测
model.eval()
with torch.no_grad():
outputs = model(images)
_, predicted = torch.max(outputs, 1)
# 输出预测结果
print('Predicted: ', ' '.join('%5s' % predicted[j].item() for j in range(batch_size)))
print('Ground truth: ', ' '.join('%5s' % labels[j] for j in range(batch_size)))
从实验结果可以看出二者相比,有残差的模型准确率和损失情况都优于无残差的。
5. 自定义模型与torch高层API实现版本的对比实验
# torch API
import torchvision.models as models
from collections import OrderedDict
import warnings
warnings.filterwarnings("ignore")
# 使用飞桨HAPI中实现的resnet18模型,该模型默认输入通道数为3,输出类别数1000
hapi_model = models.resnet18()
# 自定义的resnet18模型
model = Model_ResNet18(in_channels=3, num_classes=1000, use_residual=True)
# 获取网络的权重
params = hapi_model.state_dict()
# 用来保存参数名映射后的网络权重
new_params = {}
# 将参数名进行映射
for key in params:
if 'layer' in key:
if 'downsample.0' in key:
new_params['net.' + key[5:8] + '.shortcut' + key[-7:]] = params[key]
elif 'downsample.1' in key:
new_params['net.' + key[5:8] + '.bn3.' + key[22:]] = params[key]
else:
new_params['net.' + key[5:]] = params[key]
elif 'conv1.weight' == key:
new_params['net.0.0.weight'] = params[key]
elif 'conv1.bias' == key:
new_params['net.0.0.bias'] = params[key]
elif 'bn1' in key:
new_params['net.0.1' + key[3:]] = params[key]
elif 'fc' in key:
new_params['net.7' + key[2:]] = params[key]
new_params['net.0.0.bias'] = torch.zeros([64])
# 将飞桨HAPI中实现的resnet18模型的权重参数赋予自定义的resnet18模型,保持两者一致
model.load_state_dict(OrderedDict(new_params))
# 这里用np.random创建一个随机数组作为测试数据
inputs = np.random.randn(*[3, 3, 32, 32])
inputs = inputs.astype('float32')
x = torch.tensor(inputs)
output = model(x)
hapi_out = hapi_model(x)
# 计算两个模型输出的差异
diff = output - hapi_out
# 取差异最大的值
max_diff = torch.max(diff)
print(max_diff)
由数据可知:torch版本的resnet18模型和自定义的resnet18模型输出结果是一致的,也就说明两个模型的实现完全一样。
总结:
该实验主要学习了基于残差网络的手写体数字识别实验,其中自定义了带残差连接的参数的ResNet18模型并训练,分别训练了带残差和不带残差的的模型并进行了评价预测,带残差的模型训练起来要收敛的快,损失更低,准确率较高。还将torch自带的resnet18模型和自定义的resnet模型进行对比,结果完全一样。
其中使用了两个之前没注意过的函数:
- nn.BatchNorm2d(out_channels)函数,用于实现二维批量归一化操作。它的参数out_channels表示输出通道数,即待归一化的特征图的通道数。
在卷积神经网络中,批量归一化的操作通常是在卷积层之后、激活函数之前进行的,它可以将每个特征图的像素值归一化到均值为0、方差为1的分布中,从而加速模型的收敛速度,减少过拟合的风险。
输入是一个四维张量,即(batch_size, channels, height, width),它会对每个通道的特征图进行归一化操作。具体来说,对于每个通道的特征图,nn.BatchNorm2d(out_channels)会计算其均值和方差,并将特征图中的每个像素值减去均值,再除以方差,从而实现归一化。
- nn.Sequential是PyTorch中的一个容器,用于将多个网络层组合成一个整体模型。它的作用是将多个网络层按照顺序组合起来,形成一个序列化的模型,使得输入可以依次经过每个网络层进行前向传播,最终得到输出结果。
nn.Sequential的使用非常简单,只需要将需要组合的网络层按照顺序传入即可。例如,
在使用nn.Sequential时,需要注意每个网络层的输入和输出维度必须匹配,否则会出现维度不匹配的错误。此外,如果需要在序列化模型中添加一些不需要进行训练的操作,比如池化、批量归一化等,可以使用nn.Identity()函数来占位,表示不进行任何操作。即使在序列化模型中添加了这些不需要进行训练的操作,也不会对模型的训练造成任何影响。
- 整体ResNet18的流程: