深度学习模型之CNN(六)使用pytorch搭建VGG网络

VGG网络架构表

在上堂课讲解了D模型,今次以实操的形式,针对模型A、B、D、E做一个代码的实现。将代码构造分为两部分:提取特征网络结构(图中橙色框)、分类网络结构(图中绿色框)

VGG网络架构

model.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import torch.nn as nn
import torch

# official pretrain weights
model_urls = {
'vgg11': 'https://download.pytorch.org/models/vgg11-bbd30ac9.pth',
'vgg13': 'https://download.pytorch.org/models/vgg13-c768596a.pth',
'vgg16': 'https://download.pytorch.org/models/vgg16-397923af.pth',
'vgg19': 'https://download.pytorch.org/models/vgg19-dcbb9e9d.pth'
}

class VGG(nn.Module):
def __init__(self, features, num_classes=1000, init_weights=False):
super(VGG, self).__init__()
self.features = features
self.classifier = nn.Sequential(
nn.Dropout(p=0.5),
nn.Linear(512*7*7, 2048),
nn.ReLU(True),
nn.Dropout(p=0.5),
nn.Linear(2048, 2048),
nn.ReLU(True),
nn.Dropout(p=0.5),
nn.Linear(2048, num_classes)
)
if init_weights:
self._initialize_weights()

def forward(self, x):
# N x 3 x 224 x 224
x = self.features(x)
# N x 512 x 7 x 7
x = torch.flatten(x, start_dim=1)
# N x 512*7*7
x = self.classifier(x)
return x

def _initialize_weights(self):
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.xavier_uniform_(m.weight)
if m.bias is not None:
nn.init.constant_(m.bias, 0)
elif isinstance(m, nn.Linear):
nn.init.xavier_uniform_(m.weight)
nn.init.constant_(m.bias, 0)

def make_features(cfg: list):
layers = []
in_channels = 3
for v in cfg:
if v == "M":
layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
else:
conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
layers += [conv2d, nn.ReLU(True)]
in_channels = v
return nn.Sequential(*layers)

cfgs = {
'vgg11': [64, 'M', 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
'vgg13': [64, 64, 'M', 128, 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
'vgg16': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512, 'M'],
'vgg19': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 512, 512, 'M', 512, 512, 512, 512, 'M'],
}
def vgg(model_name="vgg16", **kwargs):
try:
cfg = cfgs[model_name]
except:
print("Warning: model number {} not in cfgs dict!".format(model_name))
exit(-1)
model = VGG(make_features(cfg), **kwargs)
return model

定义一个字典文件,字典中每个key代表着一个模型的配置文件。

  • eg:vgg11:对应A配置(模型A),即11层的网络结构(11层指的是卷积层+全连接层的个数);
  • vgg13:对应配置B(模型B),即13层网络结构;
  • vgg16:对应配置D(模型D),即16层网络结构;
  • vgg19:对应配置E(模型E),即19层网络结构;
1
2
3
4
5
6
cfgs = {
'vgg11': [64, 'M', 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
'vgg13': [64, 64, 'M', 128, 128, 'M', 256, 256, 'M', 512, 512, 'M', 512, 512, 'M'],
'vgg16': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512, 'M'],
'vgg19': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 256, 'M', 512, 512, 512, 512, 'M', 512, 512, 512, 512, 'M'],
}

含义解释:eg:vgg11的配置文件,对应的值是一个列表,其中列表的数字代表着卷积层中卷积核的个数,其中的字符M代表的是池化层的结构。

对应着表中配置A的内容,第一层是一个3*3的卷积,有64个卷积核,因此在配置列表中的第一个元素是64;第二层是一个最大池化下采样层,因此配置列表中对应的是一个M字符;第三层是一个卷积层大小为3*3的卷积核,有128个卷积核,因此在配置列表中对应的元素为128;第四层是一个最大池化下采样层,对应一个M字符;

接下来经过的是2个3*3的卷积核个数为256的卷积核,因此配置列表中对应两个256;再接下来是一个最大池化下采样层,对应一个M字符;接下来经过的是2个3*3的卷积核个数为512的卷积核,因此配置列表中对应两个512;再接下来是一个最大池化下采样层,对应一个M字符;接下来经过的是2个3*3的卷积核个数为512的卷积核,因此配置列表中对应两个512;再接下来是一个最大池化下采样层,对应一个M字符;

提取特征网络结构

提取特征网络结构,传入参数为列表类型的配置变量,在运行过程中,只需要传入cfgs对应配置的列表即可。

假设传入一个列表,首先定义空列表layers,用来存放创建的每一层结构;紧接着定义in_channels变量,因为输入的是rgb彩色图像,所以输入通道为3;接下来通过一个for循环来遍历配置列表。如果当前的配置元素是一个M字符,则该层是一个最大池化层,那么创建一个最大池化下采样层,就是nn.MaxPool2d。上节课讲到,在VGG网络中,所有的最大池化下采样池化核大小都为2,步距也都为2,因此kernel_size=2, stride=2。

1
2
3
4
5
6
7
8
9
10
11
def make_features(cfg: list):
layers = []
in_channels = 3
for v in cfg:
if v == "M":
layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
else:
conv2d = nn.Conv2d(in_channels, v, kernel_size=3, padding=1)
layers += [conv2d, nn.ReLU(True)]
in_channels = v
return nn.Sequential(*layers)

如果当前的配置元素不为M字符,则该层是一个卷积层。

创建一个卷积操作nn.Conv2d,在第一层卷积层中,输入的深度是彩色图像的3,因此第一个值是in_channels;输出的特征矩阵的深度对应着卷积核的个数,因此在第一层卷积层中,v对应64。上堂课中讲到,在VGG中,所有的卷积核为3*3,步距为1,padding为1(stride默认为1,所以没写)。因为每一个卷积层都需要采用ReLU激活函数,所以将刚刚定义好的卷积层和ReLU激活函数拼接在一起,并添加在事先定义好的layers列表中。

当特征矩阵通过该层卷积之后,输出深度变为v,因此执行in_channels = v,因此再下一层卷积层时,它的in_channel会自动变为上一层的卷积层的输出特征矩阵的深度。

通过for循环遍历配置列表,能得到一个有卷积操作和池化操作所组成的一个列表。

接下来通过nn.Sequential函数,将layers列表通过非关键字参数的形式传入进去。在代码中layers前有一个*,代表着是通过非关键字参数传入函数。

原因:在Sequential类中给出了两个使用示例:第一个是最常用的,通过将一个个非关键字参数输入到Sequential类中,就能生成一个新的网络层结构;第二种通过一个有序的字典的形式进行输入。我们这是通过一个非关键字参数的形式输入,因此加了一个*。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# class Sequential(Module):
model = nn.Sequential(
nn.Conv2d(1,20,5),
nn.ReLU(),
nn.Conv2d(20,64,5),
nn.ReLU()
)

# Using Sequential with OrderedDict. This is functionally the
# same as the above code
model = nn.Sequential(OrderedDict([
('conv1', nn.Conv2d(1,20,5)),
('relu1', nn.ReLU()),
('conv2', nn.Conv2d(20,64,5)),
('relu2', nn.ReLU())
]))

(函数被调用的时候,使用*解包一个可迭代对象作为函数的参数,字典对象可以使用两个参数,解包后将作为关键字参数传递给函数;解包:将序列里面的元素一个个拆开)

分类网络结构

定义VGG类,继承于nn.Module父类,在初始化函数中,传进参数有features(提取特征网络结构),num_classes(分类类别个数),init_weights(是否对网络进行权重初始化)

1
2
class VGG(nn.Module):
def __init__(self, features, num_classes=1000, init_weights=False):

通过nn.Sequential生成分类网络结构,在VGG网络结构中,图像通过提取特征网络结构之后,会生成一个7*7*512的特征矩阵,如果要进行全连接操作,需要先进行展平处理。在全连接操作之前,加入Dropout函数,目的是为了减少过拟合,有50%的几率随机失活神经元。第一层全连接层,原论文当中应该是4096,但为了减少训练参数,这里减半2048

1
2
3
4
5
6
7
8
9
10
self.classifier = nn.Sequential(
nn.Dropout(p=0.5),
nn.Linear(512*7*7, 2048),
nn.ReLU(True),
nn.Dropout(p=0.5),
nn.Linear(2048, 2048),
nn.ReLU(True),
nn.Dropout(p=0.5),
nn.Linear(2048, num_classes)
)

是否需要对网络对参数可视化,如果为init_weights为true,就会进入到事先定义的初始化权重函数中

1
2
if init_weights:
self._initialize_weights()

正向传播过程:输入x为输入的图像数据,features:提取特征网络结构,接着对输出进行展平处理,展平之后再将输出的特征矩阵输入到classifier函数中,最后得到输出

1
2
3
4
5
6
7
8
def forward(self, x):
# N x 3 x 224 x 224
x = self.features(x)
# N x 512 x 7 x 7
x = torch.flatten(x, start_dim=1)
# N x 512*7*7
x = self.classifier(x)
return x

在初始化权重函数中,会遍历网络的每一个子模块,即每一层。如果遍历的当前层是卷积层,就用xavier_uniform_初始化方法取初始化卷积层权重,如果有使用到偏置的话,会初始化偏置为0。

如果遍历当前层是全连接层,那么同样使用xavier_uniform_初始化方法去初始化全连接层权重,同样讲偏置为0。

1
2
3
4
5
6
7
8
9
def _initialize_weights(self):
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.xavier_uniform_(m.weight)
if m.bias is not None:
nn.init.constant_(m.bias, 0)
elif isinstance(m, nn.Linear):
nn.init.xavier_uniform_(m.weight)
nn.init.constant_(m.bias, 0)

实例化所给定的配置模型,通过传入变量model_name,需要实例化哪一个模型配置,默认为vgg16。

以此代码为例:将vgg16传入到vgg函数中,cfg = cfgs[vgg16],即vgg16关键字对应的配置列表内容,再通过VGG类实例化VGG网络(首先传入第一个参数是features(来源于make_features),后面两个*对应的变量是一个可变函数的字典变量,通过在调用VGG函数时所传入的字典变量,这个字典变量可能包含了分类个数,是否初始化权重的bool变量)

1
2
3
4
5
6
7
8
def vgg(model_name="vgg16", **kwargs):
try:
cfg = cfgs[model_name]
except:
print("Warning: model number {} not in cfgs dict!".format(model_name))
exit(-1)
model = VGG(make_features(cfg), **kwargs)
return model

train.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
import os
import json

import torch
import torch.nn as nn
from torchvision import transforms, datasets
import torch.optim as optim

from model import vgg



device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
print("using {} device.".format(device))

data_transform = {
"train": transforms.Compose([transforms.RandomResizedCrop(224),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]),
"val": transforms.Compose([transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))])}

# getcwd获取当前文件所在目录,..返回上层目录
data_root = os.path.abspath(os.path.join(os.getcwd(), "..")) # get data root path
image_path = data_root + "/data_set/flower_data/"
assert os.path.exists(image_path), "{} path does not exist.".format(image_path)
train_dataset = datasets.ImageFolder(root=image_path + "/train",
transform=data_transform["train"])
train_num = len(train_dataset)
# 雏菊 蒲公英 玫瑰 向日葵 郁金香
# {'daisy':0, 'dandelion':1, 'roses':2, 'sunflower':3, 'tulips':4}
# 获取分类的名称所对应的索引
flower_list = train_dataset.class_to_idx
# 遍历flower_list字典,将key和val反过来,是为了预测之后返回的索引能直接使用字典对应到所属的类别
cla_dict = dict((val, key) for key, val in flower_list.items())
# 通过json将cla_dict字典编码成json的格式
json_str = json.dumps(cla_dict, indent=4)
# 打开class_indices.json文件,将json_str保存进去,为了方便预测时读取信息
with open('class_indices.json', 'w') as json_file:
json_file.write(json_str)

batch_size = 32
train_loader = torch.utils.data.DataLoader(train_dataset,
batch_size=batch_size, shuffle=True,
num_workers=0)

validate_dataset = datasets.ImageFolder(root=image_path + "/val",
transform=data_transform["val"])
val_num = len(validate_dataset)
validate_loader = torch.utils.data.DataLoader(validate_dataset,
batch_size=batch_size, shuffle=True,
num_workers=0)

# test_data_iter = iter(validate_loader)
# test_image, test_label = test_data_iter.__next__()

model_name = 'vgg16'
net = vgg(model_name=model_name, num_classes=5, init_weights=True)

net.to(device)
# CrossEntropyLoss针对多类别的损失交叉熵函数
loss_function = nn.CrossEntropyLoss()
# 调试用来查看模型的参数
# pata = list(net.parameters())
optimizer = optim.Adam(net.parameters(), lr=0.0002)

save_path = './{}Net.pth'.format(model_name)
# 历史最优准确率初始化
best_acc = 0.0
for epoch in range(10):
# train
# 使用到Dropout,想要实现的是在训练过程中失活一部分神经元,而不想在预测过程中失活
# 因此通过net.train()和net.eval()来管理Dropout方法,在net.train()中开启Dropout方法,而在net.eval()中会关闭掉
net.train()
running_loss = 0.0
t1 = time.perf_counter()
for step, data in enumerate(train_loader, start=0):
images, labels = data
optimizer.zero_grad()
outputs = net(images.to(device))
loss = loss_function(outputs, labels.to(device))
loss.backward()
optimizer.step()

# print statistics
running_loss += loss.item()
# 打印在训练过程中的训练进度,len(train_loader)获取训练一轮所需要的步数step + 1获取当前的轮数
rate = (step + 1) / len(train_loader)
a = "*" * int(rate * 50)
b = "*" * int((1 - rate) * 50)
print("\rtrain loss: {:^3.0f}%[{}->{}]{:.3f}".format(int(rate * 100), a, b, loss), end=" ")
print()
print(time.perf_counter() - t1)


# validate
net.eval()
acc = 0.0 # accumulate accurate number / epoch
with torch.no_grad():
for data_test in validate_loader:
test_image, test_label = data_test
outputs = net(test_image.to(device))
predict_y = torch.max(outputs, dim=1)[1]
acc += torch.eq(predict_y, test_label.to(device)).sum().item()
accurate_test = acc / val_num
if accurate_test > best_acc:
best_acc = accurate_test
torch.save(net.state_dict(), save_path)
print('[epoch %d] train_loss: %.3f test_accuracy: %.3f' %
(epoch + 1, running_loss / step, accurate_test))
print('Finished Training')

代码内容和在AlexNet中一致,不做特别讲解,额外说明一下图像初始化中的某处特殊性,及修改了调用VGG函数的部分参数

说明transforms.Normalize

在大多数VGG使用论文中,大部分会在预处理第一步,将RGB三个通道分别减去[123.68,116.78,103.94],这三个值分别对应imageNet的图像数据集的所有数据三个通道的均值。

但这里并没有减去均值,因为这里搭建的VGG模型是从头开始训练的,如果需要基于迁移学习的方式进行再训练的话,就需要减去均值,因为预训练的模型是基于imageNet数据集进行训练的。

1
2
3
4
5
data_transform = {
"train": transforms.Compose([transforms.RandomResizedCrop(224),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]),

调用vgg函数

通过model_name确认调用哪一个配置文件,num_classes和init_weights参数输入进去之后,会保存在model中**kwargs的可变长度字典当中。当调用VGG类时,即可调用相应的参数。

1
2
model_name = 'vgg16'
net = vgg(model_name=model_name, num_classes=5, init_weights=True)

总结

因为VGG网络非常大,而训练集样本太小(只有3k+),是无法充分训练vgg网络,因此不做训练展示,且网络太大, 训练起来可能需要几个小时,在训练中能够达到的准确率大概在80%,时间漫长效果不太好,不建议日常使用。

如果需要使用VGG网络,建议使用迁移学习的方法。