作者:Edison_G
今天给大家分享一些实践的干货,主要是怎么将训练好的网络模型部署落地。有兴趣的同学,请跟我们一起学习!
一、什么是模型部署?
在典型的机器学习和深度学习项目中,我们通常从定义问题陈述开始,然后是数据收集和准备(数据预处理)和模型构建(模型训练),对吧?但是,最后,我们希望我们的模型能够提供给最终用户,以便他们能够利用它。模型部署是任何机器学习项目的最后阶段之一,可能有点棘手。如何将机器学习模型传递给客户/利益相关者?模型的部署大致分为以下三个步骤:
- 模型持久化
持久化,通俗得讲,就是临时数据(比如内存中的数据,是不能永久保存的)持久化为持久数据(比如持久化至数据库中,能够长久保存)。那我们训练好的模型一般都是存储在内存中,这个时候就需要用到持久化方式,在Python中,常用的模型持久化方式一般都是以文件的方式持久化。
- 选择适合的服务器加载已经持久化的模型
- 提高服务接口,拉通前后端数据交流
二、案例,运行操作:
- 准备ONNX模型
我们在tests/testdata下准备了一个分类模型mnasnet0_5.onnx,可用于测试。
通过如下手段可以获取更多的ONNX模型:
- 可以从OpenMMLab/PyTorch导出ONNX模型:model-convert-guide.md
- 从ONNX Model Zoo获取模型:https://github.com/onnx/models
ONNX Model Zoo的模型opset版本都较低,可以通过tools下的convert_onnx_opset_version.py将opset转换为11:
代码语言:javascript复制python convert_onnx_opset_version.py --input_model input_model.onnx --output_model output_model.onnx --output_opset 11
转换opset具体请参考:onnx-model-opset-convert-guide.md
- 准备测试图片
测试图片使用任何格式均可。我们在tests/testdata下准备了cat0.png和cat1.jpg(ImageNet 的验证集图片):
任意大小的图片都可以正常运行,如果想要resize到224 x 224的话,可以修改程序里的如下变量:
代码语言:javascript复制const bool resize_input = false; // 想要resize的话,修改为true即可
- 测试推理服务
运行
代码语言:javascript复制pplnn-build/samples/cpp/run_model/classification <image_file> <onnx_model_file>
推理完成后,会得到如下输出:
代码语言:javascript复制image preprocess succeed!
[INFO][2021-07-23 17:29:31.341][simple_graph_partitioner.cc:107] total partition(s) of graph[torch-jit-export]: 1.
successfully create runtime builder!
successfully build runtime!
successfully set input data to tensor [input]!
successfully run network!
successfully get outputs!
top 5 results:
1th: 3.416199 284 n02123597 Siamese cat, Siamese
2th: 3.049764 285 n02124075 Egyptian cat
3th: 2.989676 606 n03584829 iron, smoothing iron
4th: 2.812310 283 n02123394 Persian cat
5th: 2.796991 749 n04033901 quill, quill pen
不难看出,这个程序正确判断猫是真猫。至此OpenPPL的安装与图像分类模型推理已完成。另外,在pplnn-build/tools目录下有可执行文件pplnn,可以进行任意模型推理、dump输出数据、benchmark等操作,具体用法可使用--help选项查看。大家可以基于该示例进行改动,从而更熟悉OpenPPL的用法。
三、DNN模块部署Yolov5
用opencv的dnn模块做yolov5目标检测的程序,包含两个步骤:1)、把pytorch的训练模型pth文件转换到onnx文件;2)、opencv的dnn模块读取onnx文件做前向计算。
1)、把pytorch的训练模型pth文件转换到onnx文件
yolov5官方代码:https://github.com/ultralytics/yolov5
这套程序里的代码比较乱,在pytorch里,通常是在py文件里定义网络结构的,但是官方代码是在yaml文件定义网络结构,利用pytorch动态图特性,解析yaml文件自动生成网络结构。
在yaml文件里有depth_multiple和width_multiple,它是控制网络的深度和宽度的参数。这么做的好处是能够灵活的配置网络结构,但是不利于理解网络结构,假如你想设断点查看某一层的参数和输出数值,那就没办法了。
因此,在编写的转换到onnx文件的程序里,网络结构是在py文件里定义的。其次,在官方代码里,还有一个奇葩的地方,那就是pth文件。起初,下载官方代码到本地运行时,torch.load读取pth文件总是出错,后来把pytorch升级到1.7,就读取成功了。可以看到版本兼容性不好,这是它的一个不足之处。设断点查看读取的pth文件里的内容,可以看到ultralytics的pt文件里既存储有模型参数,也存储有网络结构,还储存了一些超参数,包括anchors,stride等等。
代码语言:javascript复制self.register_buffer('anchors', a) #shape(nl,na,2)
self.register_buffer('anchor_grid', a.clone().view(self.nl, 1, -1, 1, 1, 2))
尝试过把这两行代码改成:
代码语言:javascript复制self.anchors = a
self.anchor_grid = a.clone().view(self.nl, 1, -1, 1, 1, 2)
程序依然能正常运行,但是torch.save保存模型文件后,可以看到pth文件里没有存储anchors和anchor_grid了,在百度搜索register_buffer,解释是:pytorch中register_buffer模型保存和加载的时候可以写入和读出。在这两行代码的下一行:
代码语言:javascript复制self.m = nn.ModuleList(nn.Conv2d(x, self.no * self.na, 1) for x in ch) # output conv
它的作用是做特征图的输出通道对齐,通过1x1卷积把三种尺度特征图的输出通道都调整到num_anchors*(num_classes 5)。阅读Detect类的forward函数代码,可以看出它的作用是根据偏移公式计算出预测框的中心坐标和高宽,这里需要注意的是,计算高和宽的代码:
代码语言:javascript复制pwh = (ps[:, 2:4].sigmoid() * 2) ** 2 * anchors[i]
没有采用exp操作,而是直接乘上anchors[i],这是yolov5与yolov3v4的一个最大区别(还有一个区别就是在训练阶段的loss函数里,yolov5采用邻域的正样本anchor匹配策略,增加了正样本。其它的是一些小区别,比如yolov5的第一个模块采用FOCUS把输入数据2倍下采样切分成4份,在channel维度进行拼接,然后进行卷积操作,yolov5的激活函数没有使用Mish)。
现在可以明白Detect类的作用是计算预测框的中心坐标和高宽,简单来说就是生成proposal,作为后续NMS的输入,进而输出最终的检测框。我觉得在Detect类里定义的1x1卷积是不恰当的,应该把它定义在Detect类的外面,紧邻着Detect类之前定义1x1卷积。
在官方代码里,有转换到onnx文件的程序:
代码语言:javascript复制python models/export.py --weights yolov5s.pt --img 640 --batch 1
在pytorch1.7版本里,程序是能正常运行生成onnx文件的。观察export.py里的代码,在执行torch.onnx.export之前,有这么一段代码:
代码语言:javascript复制# Input
img = torch.zeros(opt.batch_size, 3, *opt.img_size) # image size (1, 3, 320, 192) iDetection
# Update model
for k, m in model.named_modules():
m._non_persistent_buffers_set = set() # pytorch 1.6.0 compatibility
if isinstance(m, models.common.Conv): # assign export-friendly activations
if isinstance(m.act, nn.Hardswish):
m.act = Hardswish()
elif isinstance(m.act, nn.SiLU):
m.act = SiLU()
# elif isinstance(m, models.yolo.Detect):
# m.forward = m.forward_export #assign forward (optional)
model.model[-1].export = True # set Detect() Layer export = True
y = model(img) # dry run
注意其中的for循环,我试验过注释掉它,重新运行就会出错,打印出的错误如下:
由此可见,这段for循环代码是必需的。SiLU其实就是swish激活函数,而在onnx模型里是不直接支持swish算子的,因此在转换生成onnx文件时,SiLU激活函数不能直接使用nn.Module里提供的接口,而需要自定义实现它。
2)、opencv的dnn模块读取.onnx文件做前向计算
在生成onnx文件后,就可以用opencv的dnn模块里的cv2.dnn.readNet读取它。然而,在读取时,出现了如下错误:
其实是:
于是查看yolov5的代码,在common.py文件的Focus类,torch.cat的输入里有4次切片操作,代码如下:
那么现在需要更换索引式的切片操作,观察到注释的Contract类,它就是用view和permute函数完成切片操作的,于是修改代码如下:
其次,在modelsyolo.py里的Detect类里,也有切片操作,代码如下:
代码语言:javascript复制y[..., 0:2] = (y[..., 0:2] * 2. - 0.5 self.grid[i].to(x[i].device)) * self.stride[i]
y[..., 2:4] = (y[..., 2:4] * 2) ** 2 * self.anchor_grid[i] # wh
前面说过,Detect类的作用是计算预测框的中心坐标和高宽,生成proposal,这个是属于后处理的,因此不需要把它写入到onnx文件里。
总结一下,按照上面的截图代码,修改Focus类,把Detect类里面的1x1卷积定义在紧邻着Detect类之前的外面,然后去掉Detect类,组成新的model,作为torch.onnx.export的输入:
代码语言:javascript复制torch.onnx.export(model, inputs, output_onnx, verbose=False, opset_version=12, input_names=['images'], output_names=['out0', 'out1', 'out2'])
最后生成的onnx文件,opencv的dnn模块就能成功读取了,接下来对照Detect类里的forward函数,用python或者C 编写计算预测框的中心坐标和高宽的功能。
在github上,地址是https://github.com/hpc203/yolov5-dnn-cpp-python
四、后处理模块
后处理模块,python版本用numpy array实现的,C 版本的用vector和数组实现的,整套程序只依赖opencv库(opencv4版本以上的)就能正常运行,彻底摆脱对深度学习框架pytorch,tensorflow,caffe,mxnet等等的依赖。用openvino作目标检测,需要把onnx文件转换到.bin和.xml文件,相比于用dnn模块加载onnx文件做目标检测是多了一个步骤的。因此,我就想编写一套用opencv的dnn模块做yolov5目标检测的程序,用opencv的dnn模块做深度学习目标检测,在win10和ubuntu,在cpu和gpu上都能运行,可见dnn模块的通用性更好,很接地气。
生成yolov5s_param.pth 的步骤:
首先下载https://github.com/ultralytics/yolov5的源码到本地,在yolov5-master主目录(注意不是我发布的github代码目录)里新建一个.py文件,把下面的代码复制到.py文件里。
代码语言:javascript复制import torch
from collections import OrderedDict
import pickle
import os
device = 'cuda' if torch.cuda.is_available() else 'cpu'
if __name__=='__main__':
choices = ['yolov5s', 'yolov5l', 'yolov5m', 'yolov5x']
modelfile = choices[0] '.pt'
utl_model = torch.load(modelfile, map_location=device)
utl_param = utl_model['model'].model
torch.save(utl_param.state_dict(), os.path.splitext(modelfile)[0] '_param.pth')
own_state = utl_param.state_dict()
print(len(own_state))
numpy_param = OrderedDict()
for name in own_state:
numpy_param[name] = own_state[name].data.cpu().numpy()
print(len(numpy_param))
with open(os.path.splitext(modelfile)[0] '_numpy_param.pkl', 'wb') as fw:
pickle.dump(numpy_param, fw)
运行这个.py文件,这时候就可以生成yolov5s_param.pth文件。之所以要进行这一步,我在上面讲到过:ultralytics的.pt文件里既存储有模型参数,也存储有网络结构,还储存了一些超参数,包括anchors,stride等等的。torch.load加载ultralytics的官方.pt文件,也就是utl_model = torch.load(modelfile, map_location=device)这行代码,在这行代码后设断点查看utl_model里的内容,截图如下:
可以看到utl_model里含有既存储有模型参数,也存储有网络结构,还储存了一些超参数等等的,这会严重影响转onnx文件。此外,我还发现,如果pytorch的版本低于1.7,那么在torch.load加载.pt文件时就会出错的。
因此在程序里,我把模型参数转换到cpu.numpy形式的,最后保存在.pkl文件里。这时候在win10系统cpu环境里,即使你的电脑没有安装pytorch,也能通过python程序访问到模型参数。
五、pytorch转onnx常见坑:
- onnx只能输出静态图,因此不支持if-else分支。一次只能走一个分支。如果代码中有if-else语句,需要改写。
- onnx不支持步长为2的切片。例如a[::2,::2]
- onnx不支持对切片对象赋值。例如a[0,:,:,:]=b, 可以用torch.cat改写
- onnx里面的resize要求output shape必须为常量。可以用以下代码解决:
if isinstance(size, torch.Size):
size = tuple(int(x) for x in size)
此外,在torch.onnx.export(model, inputs, output_onnx)的输入参数model里,应该只包含网络结构,也就是说model里只含有nn.Conv2d, nn.MaxPool2d, nn.BatchNorm2d, F.relu等等的这些算子组件,而不应该含有后处理模块的。图像预处理和后处理模块需要自己使用C 或者Python编程实现。
在明白了这些之后,在转换生成onnx文件,你需要执行两个步骤,第一步把原始训练模型.pt文件里的参数保存到新的.pth文件里,第二步编写yolov5.py文件,把yolov5的往来结构定义在.py文件里,此时需要注意网络结构里不能包含切片对象赋值操作,F.interpolate里的size参数需要加int强制转换。在执行完这两步之后才能生成一个opencv能成功读取并且做前向推理的onnx文件。
不过,最近我发现在yolov5-pytorch程序里,其实可以直接把原始训练模型.pt文件转换生成onnx文件的,而且我在一个yolov5检测人脸 关键点的程序里实验成功了。