最近突发奇想打算完整的从零开始做一个完整的深度学习任务,于是从飞桨实践库里随便找了一个简单的VGG网络实现中草药分类的问题。
本案例数据集均来源于互联网,分为5个类别共901张图片,其中百合179张图片,枸杞185张图片,金银花180张图片,槐花167张图片,党参190张图片。具体如下图:
数据集仅含若干张图片,每张图片放置于以对应标签类别命名的文件夹内,如下图所示:
与主流的pytorch和TensorFlow类似,paddlepaddle是也是一套深度学习框架,只不过paddlepaddle是由百度公司制作的。
GoogLeNet是2014年ImageNet比赛的冠军,它的主要特点是网络不仅有深度,还在横向上具有“宽度”。由于图像信息在空间尺寸上的巨大差异,如何选择合适的卷积核来提取特征就显得比较困难了。空间分布范围更广的图像信息适合用较大的卷积核来提取其特征;而空间分布范围较小的图像信息则适合用较小的卷积核来提取其特征。为了解决这个问题,GoogLeNet提出了一种被称为Inception模块的方案。
左图是Inception模块的设计思想,使用3个不同大小的卷积核对输入图片进行卷积操作,并附加最大池化,将这4个操作的输出沿着通道这一维度进行拼接,构成的输出特征图将会包含经过不同大小的卷积核提取出来的特征,从而达到捕捉不同尺度信息的效果。
Inception模块采用多通路(multi-path)的设计形式,每个支路使用不同大小的卷积核,最终输出特征图的通道数是每个支路输出通道数的总和,这将会导致输出通道数变得很大,尤其是使用多个Inception模块串联操作的时候,模型参数量会变得非常大。为了减小参数量,Inception模块使用了右图中的设计方式,在每个3x3和5x5的卷积层之前,增加1x1的卷积层来控制输出通道数;在最大池化层后面增加1x1卷积层减小输出通道数。基于这一设计思想,形成了右图中所示的结构。
GoogLeNet网络则是将若干个Inception模块串联起来,形成更深的网络结构,如下图所示。
(说明: 在原作者的论文中添加了softmax1和softmax2两个辅助分类器,训练时将三个分类器的损失函数进行加权求和,以缓解梯度消失现象。这里的程序作了简化,没有加入辅助分类器)
本案例主要是熟悉深度学习的整个流程。分类任务大致有如下几个流程:处理数据集(数据集增强、扩充,划分训练集测试集)、读取数据,制作数据读取器、搭建神经网络、编写训练代码、评估模型、进行模型推理
使用飞桨提供的api接口paddle.vision.transforms,对图像的亮度,对比度,饱和度进行随机调整,并对尺寸进行随机裁剪。
核心代码如下:
def get_transformed_img(img_path):
srcimg = PIL.Image.open(img_path) # 读取原图像
img_w=np.array(srcimg).shape[1] # 获取图像宽
img_h=np.array(srcimg).shape[0] # 获取图像高
# print(img_w,img_h)
transform0 = RandomResizedCrop(min(img_w, img_h)) # 随机裁剪图像
transform1 = BrightnessTransform(0.2) # 调整亮度
transform2 = ContrastTransform(0.2) # 调整对比度
transform3 = SaturationTransform(0.2) # 调整饱和度
# 调用声明好的API实现随机剪切
img_res=transform0(srcimg)
img_res = transform1(img_res)
img_res = transform2(img_res)
img_res = transform3(img_res)
print(img_path, 'done')
return img_res
将原数据集中所有图片都进行上述操作,并另存到新的文件夹,新文件夹格式和原文件夹格式相同,如下图所示:
这一步的任务是得到train.txt和eval.txt,为下一步制作数据集读取器做准备。
train.txt中的内容是:训练集中每一张图片的路径、以及对应的标签。
代码如下:
def make_data_list():
data_path1='data/Chinese Medicine' # 原数据集文件夹
data_path2='data/Chinese Medicine expand' # 增强数据集文件夹
data_label = os.listdir(data_path1) # ['baihe', 'dangshen', 'gouqi', 'huaihua', 'jinyinhua']
print(data_label)
eval_list = []
train_list = []
for dir_id, dir in enumerate(data_label): # 对每个类别
# 处理原数据集文件夹
class_dir = os.path.join(data_path1, dir) # 获取每个分类的文件夹路径
for img_id,img_dir in enumerate(os.listdir(class_dir)):
data = str(os.path.join(class_dir, img_dir)) + '\t' + str(dir_id) + '\n' # 将图片路径和标签制作为字符串
if img_id % 8==0: # 训练集和测试集为8:1
eval_list.append(data)
else:
train_list.append(data)
# 处理增强数据集文件夹,操作与上面类似
class_dir = os.path.join(data_path2, dir)
for img_id, img_dir in enumerate(os.listdir(class_dir)):
data = str(os.path.join(class_dir, img_dir)) + '\t' + str(dir_id) + '\n'
if img_id % 8 == 0:
eval_list.append(data)
else:
train_list.append(data)
# 对两个list进行洗牌
random.seed(233)
random.shuffle(eval_list)
random.shuffle(train_list)
eval_num = len(eval_list)
train_num = len(train_list)
print("共{}张图片,其中训练集{}张,测试集{}张".format(train_num+eval_num, train_num, eval_num))
# 写入eval.txt和train.txt
eval_list_path = 'data/eval.txt'
with open(eval_list_path, 'w') as f:
for image in eval_list:
f.write(image)
train_list_path = 'data/train.txt'
with open(train_list_path, 'w') as f:
for image in train_list:
f.write(image)
print('txt文件写入完成')
定义数据集读取器一般是以类的形式封装,需要继承paddle.io.Dataset类
定义数据集读取器之后,能够非常方便地获取训练集数据和测试集数据
# 定义数据读取器
class dataset(Dataset):
def __init__(self, data_path, mode='train'):
"""
数据读取器
:param data_path: 数据集所在路径
:param mode: train or eval
"""
super().__init__()
self.data_path = data_path
self.img_paths = []
self.labels = []
if mode == 'train':
# 读取TXT文件,获得图片路径及对应标签,放入各自的list中
with open(os.path.join(self.data_path, "train.txt"), "r", encoding="utf-8") as f:
self.info = f.readlines()
for img_info in self.info:
img_path, label = img_info.strip().split('\t')
self.img_paths.append(img_path)
self.labels.append(int(label))
else:
with open(os.path.join(self.data_path, "eval.txt"), "r", encoding="utf-8") as f:
self.info = f.readlines()
for img_info in self.info:
img_path, label = img_info.strip().split('\t')
self.img_paths.append(img_path)
self.labels.append(int(label))
# print(self.img_paths)
# print(self.labels)
def __getitem__(self, index):
"""
获取一组数据
:param index: 文件索引号
:return:
"""
img_path = self.img_paths[index]
img = cv2.imread(img_path)
img = np.array(img).astype('float32')
img = img[:, :, [2, 1, 0]] # 转RGB
img = cv2.resize(img, (224, 224)) # 调整尺寸
img = img.transpose((2, 0, 1)) / 255 # 从[w,c,h]转换为[c,w,h],并映射到0-1
label = self.labels[index]
label = np.array([label], dtype="int64")
return img, label
def __len__(self):
return len(self.img_paths)
当然,还可以对数据集使用paddle.io.DataLoader进行进一步封装,以获取打包成batch的数据样本。代码如下:
train_dataset = dataset('data', mode='train')
img, label = train_dataset[0]
print(img.shape, label.shape) # (3, 224, 224) (1,)
train_loader = paddle.io.DataLoader(train_dataset, batch_size=8, shuffle=True)
for data in train_loader:
img, label = data
print(img.shape, label.shape) # [8, 3, 224, 224] [8, 1]
break
GoogLeNet的核心是Inception模块,inception模块的实现代码如下:
# Inception块
class Inception(paddle.nn.Layer):
def __init__(self, c0, c1, c2, c3, c4):
'''
Inception模块,
c1,图(b)中第一条支路1x1卷积的输出通道数,数据类型是整数
c2,图(b)中第二条支路卷积的输出通道数,数据类型是tuple或list,
其中c2[0]是1x1卷积的输出通道数,c2[1]是3x3
c3,图(b)中第三条支路卷积的输出通道数,数据类型是tuple或list,
其中c3[0]是1x1卷积的输出通道数,c3[1]是3x3
c4,图(b)中第一条支路1x1卷积的输出通道数,数据类型是整数
'''
super(Inception, self).__init__()
# 依次创建Inception块每条支路上使用到的操作
self.p1_1 = Conv2D(in_channels=c0, out_channels=c1, kernel_size=1, stride=1)
self.p2_1 = Conv2D(in_channels=c0, out_channels=c2[0], kernel_size=1, stride=1)
self.p2_2 = Conv2D(in_channels=c2[0], out_channels=c2[1], kernel_size=3, padding=1, stride=1)
self.p3_1 = Conv2D(in_channels=c0, out_channels=c3[0], kernel_size=1, stride=1)
self.p3_2 = Conv2D(in_channels=c3[0], out_channels=c3[1], kernel_size=5, padding=2, stride=1)
self.p4_1 = MaxPool2D(kernel_size=3, stride=1, padding=1)
self.p4_2 = Conv2D(in_channels=c0, out_channels=c4, kernel_size=1, stride=1)
def forward(self, x):
# 支路1只包含一个1x1卷积
p1 = F.relu(self.p1_1(x))
# 支路2包含 1x1卷积 + 3x3卷积
p2 = F.relu(self.p2_2(F.relu(self.p2_1(x))))
# 支路3包含 1x1卷积 + 5x5卷积
p3 = F.relu(self.p3_2(F.relu(self.p3_1(x))))
# 支路4包含 最大池化和1x1卷积
p4 = F.relu(self.p4_2(self.p4_1(x)))
# 将每个支路的输出特征图拼接在一起作为最终的输出结果
return paddle.concat([p1, p2, p3, p4], axis=1)
上述代码建议结合inception模块图看便于理解
有了inception模块,就可以以其为基础,构建GoogLeNet网络,代码如下:
# GoogLeNet
class GoogLeNet(paddle.nn.Layer):
def __init__(self):
super(GoogLeNet, self).__init__()
# GoogLeNet包含五个模块,每个模块后面紧跟一个池化层
# 第一个模块包含1个卷积层
self.conv1 = Conv2D(in_channels=3, out_channels=64, kernel_size=7, padding=3, stride=1)
# 3x3最大池化
self.pool1 = MaxPool2D(kernel_size=3, stride=2, padding=1)
# 第二个模块包含2个卷积层
self.conv2_1 = Conv2D(in_channels=64, out_channels=64, kernel_size=1, stride=1)
self.conv2_2 = Conv2D(in_channels=64, out_channels=192, kernel_size=3, padding=1, stride=1)
# 3x3最大池化
self.pool2 = MaxPool2D(kernel_size=3, stride=2, padding=1)
# 第三个模块包含2个Inception块
self.block3_1 = Inception(192, 64, (96, 128), (16, 32), 32)
self.block3_2 = Inception(256, 128, (128, 192), (32, 96), 64)
# 3x3最大池化
self.pool3 = MaxPool2D(kernel_size=3, stride=2, padding=1)
# 第四个模块包含5个Inception块
self.block4_1 = Inception(480, 192, (96, 208), (16, 48), 64)
self.block4_2 = Inception(512, 160, (112, 224), (24, 64), 64)
self.block4_3 = Inception(512, 128, (128, 256), (24, 64), 64)
self.block4_4 = Inception(512, 112, (144, 288), (32, 64), 64)
self.block4_5 = Inception(528, 256, (160, 320), (32, 128), 128)
# 3x3最大池化
self.pool4 = MaxPool2D(kernel_size=3, stride=2, padding=1)
# 第五个模块包含2个Inception块
self.block5_1 = Inception(832, 256, (160, 320), (32, 128), 128)
self.block5_2 = Inception(832, 384, (192, 384), (48, 128), 128)
# 全局池化,用的是global_pooling,不需要设置pool_stride
self.pool5 = AdaptiveAvgPool2D(output_size=1) # 自适应全局池化,自动选取池化核使得输出尺寸为1
self.fc = Linear(in_features=1024, out_features=5)
def forward(self, x):
x = self.pool1(F.relu(self.conv1(x)))
x = self.pool2(F.relu(self.conv2_2(F.relu(self.conv2_1(x)))))
x = self.pool3(self.block3_2(self.block3_1(x)))
x = self.block4_3(self.block4_2(self.block4_1(x)))
x = self.pool4(self.block4_5(self.block4_4(x)))
x = self.pool5(self.block5_2(self.block5_1(x)))
x = paddle.reshape(x, [x.shape[0], -1])
x = self.fc(x)
return x
结合模块图便于理解
常见的训练函数一般是如下结构:
当然除此之外,还有可视化训练结果,计算准确率,保存模型等操作。代码如下:
# 训练函数
def train(model, epoch):
# 存放训练集损失和测试集损失的list
train_loss_list = []
eval_loss_list = []
# 定义优化器,使用Adam优化器,初始学习率为0.0001
opt = paddle.optimizer.Adam(learning_rate=0.0001, parameters=model.parameters())
# 使用GPU训练
paddle.device.set_device('gpu:0')
for e in range(epoch): # 对于每一个epoch
# 训练数据加载
train_dataset = dataset('data', mode='train')
train_loader = paddle.io.DataLoader(train_dataset, batch_size=8, shuffle=True)
model.train() # 模型设置为训练模式
for batch_id, data in enumerate(train_loader()): # 对于每一个batch
imgs, labels = data # 拿到数据和标签
preds = model(imgs) # 正向传播
loss = F.cross_entropy(preds, labels) # 计算损失
# avg_loss = paddle.mean(loss)
avg_loss = loss
if batch_id % 80 == 0:
# 每隔80个batch输出一下loss
print("epoch:{}, batch:{}, train_loss:{:.2f}".format(e, batch_id, avg_loss.numpy()[0]))
# 将loss添加到list中
train_loss_list.append(avg_loss.numpy()[0])
# 计算梯度
avg_loss.backward()
# 更新参数
opt.step()
# 清空梯度变量
opt.clear_grad()
"""
这部分是对模型进行评估
"""
# 计算测试集上的loss
model.eval() # 模型设置为推理模式
# 评估数据加载
eval_dataset = dataset('data', mode='eval')
eval_loader = paddle.io.DataLoader(eval_dataset, batch_size=8, shuffle=True)
for batch_id, batch in enumerate(eval_loader):
imgs, labels = batch
preds = model(imgs)
loss = F.cross_entropy(preds, labels)
avg_loss = paddle.mean(loss)
if batch_id % 10 == 0:
print("epoch:{}, batch:{}, eval_loss:{:.2f}".format(e, batch_id, avg_loss.numpy()[0]))
eval_loss_list.append(avg_loss.numpy()[0])
# 每个epoch结束后,覆盖绘制loss下降曲线
plt.plot(list(range(len(train_loss_list))), train_loss_list)
plt.plot(list(range(len(eval_loss_list))), eval_loss_list)
plt.savefig('log/loss_now.jpg')
# 每个epoch结束后,计算在测试集上的准确率
eval_loader = paddle.io.DataLoader(eval_dataset, batch_size=1, shuffle=True)
all = 0
correct = 0
for id, data in enumerate(eval_loader):
img, lab = data
pred = model(img)
pred = np.argmax(pred.numpy()[0]) # 取输出中概率最大的作为输出标签
if pred == lab.numpy()[0][0]:
correct += 1
all += 1
print("epoch {} is done, acc:{:.2f}".format(e, correct / all))
# 每个epoch结束后,保存一次模型参数
paddle.save(model.state_dict(), 'log/' +
'Epoch ' + str(e) +
' train_loss ' + str(train_loss_list[len(train_loss_list) - 1]) +
' eval_loss ' + str(eval_loss_list[len(eval_loss_list) - 1]) +
' googleNet.pdparams')
下图是训练程度和loss的曲线图(蓝色是测试集loss,黄色是训练集loss)
观察曲线有如下发现:
1.整体曲线有下降趋势,说明模型正在被训练
2.曲线的震荡十分明显,猜测是因为batch_size设的不大(batch_size=8)
3.训练末期训练集loss趋于0,而测试集loss逐渐不稳定,说明模型逐渐开始过拟合
综上,我们取横轴为80-100,也就是大约26-32个epoch时的模型为最佳。
推理的流程就十分简单了,读入图片后,对其进行预处理,包括通道变换,归一化,以及维度扩张,然后送入模型得到输出,匹配对应类别即可。
代码如下:
def predict(img_path):
label=['baihe','dangshen','gouqi','huaihua','jinyinhua']
try:
img=PIL.Image.open(img_path)
except:
print("open error!")
return
PIL.Image.Image.show(img)
img=img.resize((224,224))
img=np.array(img).astype('float32')
img = img.transpose((2, 0, 1)) / 255 # 从[w,c,h]转换为[c,w,h],并映射到0-1
img=paddle.to_tensor(img)
img=paddle.reshape(img,[1,3,224,224])
model=GoogLeNet()
model.eval()
myPdparams_path = 'log/Epoch 30 train_loss 0.07772641 eval_loss 0.2556547 googleNet.pdparams'
model_state_dict = paddle.load(myPdparams_path)
model.load_dict(model_state_dict)
output=model(img)
output=F.softmax(output) # 套一个softmax以将输出转换为概率
index = np.argmax(output.numpy()[0]) # 获取最大概率的index
confidence=output.numpy()[0][index] # 获取该最大概率
result=label[index] # 获取最大概率对应的标签
print("this picture is: {}, confidence is: {:.2f}".format(result,confidence))
这是up第一次完整写一个深度学习案例,虽然内容很简单,但对于第一次写来说,还是有许多没有接触到的新知识,尤其是在自己定义数据读取器,自己构建模型,自己做训练的过程中。
大致有如下需要注意的地方:
1.在数据读取器部分,通常读取数据的流程为:制作样本路径以及标签的txt文件,写一个类继承paddle.io.Dataset类,并重写其def __getitem__(self, index): 和 def __len__(self): 方法。在getitem中,利用之前准备的txt文件,按照其中的路径读取对应的图片,并进行一些预处理,最后返回图片以及对应的标签。
2.在数据读取器的预处理时,通常会用到图像库进行图片读取操作如PIL,cv2等,需要注意的是:PIL等库读取图片默认得到的是RGB的通道排列,而cv2读取图片默认的是BGR的排列,千万不可两者混用,若一定要用cv2可以将其通道排布转换过来再使用
img = img[:, :, [2, 1, 0]] # BGR转RGB
3.在数据读取器的预处理时,通常还会遇到需要将图片的shape从[w,c,h]转换为[c,w,h]的情况,因为paddle的二维图像处理api如卷积等操作都是需要[N,C,W,H]的shape。在shape转换的过程中,可能会有两种方法:
img = img.reshape((3, 224, 224)) / 255 # 使用np的reshape转换
img = img.transpose((2, 0, 1)) / 255 # 使用np的transpose转换
up刚开始一直用的前者reshape()进行转换的,但准确率一直上不去,在60%以内波动。后来逐行检查,最后发现可能是这里的问题。我的理解是:reshape()虽然也能将shape进行转换,但它的原理其实是将先矩阵拉伸成一条向量,然后再按照给定的shape进行重新排布,这样一来,虽然shape确实转换了,但得到的结果已经没有空间信息了,也就是类似雪花屏一样的乱码,进行可视化展示的话如下图所示:
这样处理后得到的图像,再来经过卷积,已经提取不到什么有用信息了...
应该使用transpose,transpose作用是改变序列。
4.在训练过程中还发生过一些有意思的事情,如下几张loss曲线图:
这张图我认为是最典型的过拟合,在横轴大于100之后,训练集loss出现显著降低,而测试集loss不断飙升,符合“模型对训练集过度学习,学完之后只认识训练集,不认识测试集”的特征。
这张图表示训练遇到了瓶颈,也就是局部最小值。仔细观察最后loss趋近于1.6,而因为我们损失函数使用的是交叉熵,在5分类问题中,若模型的输出恒定,那么交叉熵loss的值就是-lg(1/5)也就是1.6,所以这次训练陷入了局部最优。
本文到这里就结束了,up小白在学习过程中还有许多错误指出还请读者批评指正!
参考资料:
飞桨PaddlePaddle-源于产业实践的开源深度学习平台
模型训练时loss不收敛、不下降原因和解决办法_ytusdc的博客-CSDN博客_模型不收敛