这篇文章主要负责记录自己在转PaddleOCR 模型过程中遇到的问题,以供大家参考。
重要的话说在最前面,以免大家不往下看:
本篇文章是把 “整个” ppocr 模型 转成了 pytorch,不是只转了backbone
本篇文章将分为以下几个部分:
- 1. PaddleOCR 识别模型的网络结构分析
- 2.PaddleOCR卷积部分转Pytorch的注意事项
- 3.PaddleOCR LSTM 部分转Pytorch记录
- 4.PaddleOCR FC层转 Pytorch 的注意事项
1. PaddleOCR 识别模型的网络结构分析
我用来测试转化脚本的模型为pp ocr 提供的轻量模型
1.1 模型的网络结构
- Model
- MobileNet small 50
- BiLSTM
- CTC
模型的参数可以通过如下链接提供的函数进行加载:maomaoyuchengzi/paddlepaddle_param_to_pyotrch模型的参数可以通过如下链接提供的函数进行加载:
maomaoyuchengzi/paddlepaddle_param_to_pyotrchgithub.comdef _load_state(path):
"""
记载paddlepaddle的参数
:param path:
:return:
"""
if os.path.exists(path + '.pdopt'):
# XXX another hack to ignore the optimizer state
tmp = tempfile.mkdtemp()
dst = os.path.join(tmp, os.path.basename(os.path.normpath(path)))
shutil.copy(path + '.pdparams', dst + '.pdparams')
state = fluid.io.load_program_state(dst)
shutil.rmtree(tmp)
else:
state = fluid.io.load_program_state(path)
return state
1.2 模型的参数解析
通过遍历加载的模型的参数,可以发现模型的参数大致分为三个部分:
- cnn:
- conv1_xxx
- ...
- conv12_xxx
- conv_last
- lstm
- lstm_xxx
- ctc
- ctc_fc_xxx
这里有如下几个内容稍微需要注意:
conv 的含义
-
- 可以注意到conv层一共有12层(conv1-conv12) ,和一个最后的conv_last,其中:
- conv1 对应 MobileNet v3 当中的第一个卷积
- conv2-conv12 对应着 MobileNet v3 当中的11个ResidualUnit
- conv_last 对应着MobileNet v3 最后的一个卷积
lstm 的含义
-
- 注意到 在 ppocr 当中,使用了 paddle 库当中的 dynamic lstm
- 所以这里看到的 lstm 的参数是无法直接加载到 pytorch 当中的
ctc 部分
-
- 这部分就是标准的FC, 所以转换的难度不大
我们基本的转换思路如下:
- 首先基于 depp-text-recognition 库搭建出整个网络结构
- 其次,将 ppocr 的权重按照名称和 搭建的网络的权重进行对应,使得网络能够加载ppocr 的参数
2.PaddleOCR卷积部分转Pytorch的注意事项
2.1 MobileNetV3 的网络搭建
这部分的工作其实已经有人已经做得很好了:
https://github.com/WenmuZhou/PytorchOCRgithub.com在这个PytorchOCR 的项目里,已经给出了一个能够完全照搬参数的网络结构:
https://github.com/WenmuZhou/PytorchOCR/blob/master/torchocr/networks/backbones/RecMobileNetV3.pygithub.com只需要按照上述的结构进行Backbone 的搭建就好了
2.2 搭建网络中需要注意的点
有一个特别需要注意的点在于 ,paddle paddle 的 HardSigmoid 实现和 其他地方提到的 HardSigmoid 的定义略有不同:
在网上能够查到的实现当中:
但是在 paddle paddle 给出的文档说明中:
看上去没有任何的问题对不对?但是注!意!到!,paddle paddle 当中的这两参数的值设置的是:
对应到代码上需要改写成这样(注意到这里从 F.relu(x+3) / 6 改成如下代码,多加了个1.2):
2.3 参数转换需要注意的点:
没啥说的,就按照字典当中的关系进行对应就好了
这里需要特别注意的一点是,在 pytorch 的MobileNet 的实现当中,ResiduleUnit是包含在若干个Stage 当中的,11个 ResiduleUnit 按照 [3,5,3] 的顺序被分配到了3个stage当中
这里给出一个映射关系的表,供大家进行参数的转换
state = weight
idx = 2
bdx = 0
key_mapping = OrderedDict()
for b in [3, 5, 3]:
for i in range(b):
key = f'conv{idx}_'
value = f'stages.{bdx}.{i}.'
key_mapping[key] = value
idx += 1
bdx += 1
key_mapping.update({
"conv1_": "conv1.",
'expand_': 'conv0.',
'depthwise_': 'conv1.',
'linear_': 'conv2.',
'weights': 'conv.weight',
'bn_scale': 'bn.weight',
'bn_offset': 'bn.bias',
'bn_mean': 'bn.running_mean',
'bn_variance': 'bn.running_var',
'se_1': 'se.conv1',
'se_2': 'se.conv2',
'conv1_conv': 'conv1',
'conv2_conv': 'conv2',
'conv1_offset': 'conv1.bias',
'conv2_offset': 'conv2.bias',
'conv_last_': 'conv2.'
})
ignored_keys = ['moment', 'pow_acc', 'LR_DECAY_COUNTER', 'learning_rate']
转换完成后,在官方给出的测试图片('doc/imgs_words/ch/word_1.jpg')上
能够看到pytorch backbone 跑出来的误差和 ppocr 的误差在可接受的范围内:
pp_predict, img = run_pp_predict(config, eval_prog, exe, fetch_varname_list)
torch_res = torchnet.FeatureExtraction.ConvNet(torch.tensor(img)).detach().cpu().numpy()
max_diff = (np.array(pp_predict[2]) - torch_res).max()
# 6.2704086e-05
3.PaddleOCR LSTM 部分转Pytorch 的注意事项
上述的代码库当中,在这个部分的实现不是特别的好,主要原因是 PPOCR 当中使用了一个非常难转换的操作 dynamic_lstm ,这个算子使得转换的操作变的及其的困难,在踩过了非常多的坑之后,这里我给出一个基本的解决方案,使得能够将 lstm 操作也搬运过来
3.1 LSTM 操作的细节:
需要搬运这个内容,首先需要对LSTM 这个东西本身具有十分的了解,我们以Pytorch的一个LSTM为例子,介绍LSTM 是如何工作的:
lstm = nn.LSTM(288, 48, num_layers=1, batch_first=True)
for key, value in lstm.state_dict().items():
print(key, value.shape)
返回的结果有如下四个:
# 和 input 有关的参数
weight_ih_l0 torch.Size([192, 288])
bias_ih_l0 torch.Size([192])
# 和 hidden layer 有关的参数
weight_hh_l0 torch.Size([192, 48])
bias_hh_l0 torch.Size([192])
这里的参数可以分成两组,一组是和 input 相关的,一组是和 hidden layer 相关的
不熟悉参数结构的人可能会有疑问:
明明设置的是 288 -> 48 的参数, 为啥 weight_ih_l0 的维度是 (192, 288) ?
以及不是应该有“四个门”,为什么这里和 x 相关的weight 只有1个?
关于这些问题,都可以参考:
Pytorch源码理解: RNNbase LSTMblog.csdn.net简单来说,回顾LSTM 的公式:
按照公式,其实可以将LSTM 的计算流程转化为如下的流程:
- 首先用一个拼接的weight 和 拼接的 bias 和 x 进行运算(对h 的运算也类似)
- 其次在需要进行 lstm 运算的地方再将其拆开
上述两个步骤分别对应着下面代码的 第一点和 第二点
了解了 LSTM ,再来看 PPOCR 当中的 的 “双向LSTM”实现:
注意到,在PPOCR 中,“双向LSTM” 是由一个正向的 两层LSTM 和一个反向的两层LSTM 实现的,和Pytorch 当中的 nn.LSTM(bidirectional = True, num_layers = 2) 是不一样的!
PPOcr当中的实现如下:
class EncoderWithRNN(object):
def __init__(self, params):
super(EncoderWithRNN, self).__init__()
self.rnn_hidden_size = params['SeqRNN']['hidden_size']
def __call__(self, inputs):
lstm_list = []
name_prefix = "lstm"
rnn_hidden_size = self.rnn_hidden_size
for no in range(1, 3):
if no == 1:
is_reverse = False
else:
is_reverse = True
name = "%s_st1_fc%d" % (name_prefix, no)
fc = layers.fc(input=inputs,
size=rnn_hidden_size * 4,
param_attr=fluid.ParamAttr(name=name + "_w"),
bias_attr=fluid.ParamAttr(name=name + "_b"),
name=name)
name = "%s_st1_out%d" % (name_prefix, no)
lstm, _ = layers.dynamic_lstm(
input=fc,
size=rnn_hidden_size * 4,
is_reverse=is_reverse,
param_attr=fluid.ParamAttr(name=name + "_w"),
bias_attr=fluid.ParamAttr(name=name + "_b"),
use_peepholes=False)
name = "%s_st2_fc%d" % (name_prefix, no)
fc = layers.fc(input=lstm,
size=rnn_hidden_size * 4,
param_attr=fluid.ParamAttr(name=name + "_w"),
bias_attr=fluid.ParamAttr(name=name + "_b"),
name=name)
name = "%s_st2_out%d" % (name_prefix, no)
lstm, _ = layers.dynamic_lstm(
input=fc,
size=rnn_hidden_size * 4,
is_reverse=is_reverse,
param_attr=fluid.ParamAttr(name=name + "_w"),
bias_attr=fluid.ParamAttr(name=name + "_b"),
use_peepholes=False)
lstm_list.append(lstm)
return lstm_list
可以看到:
- for 循环执行两次
- nn.LSTM 在 paddle paddle 当中的 layers.fc + layers.dynamic_lstm 实现
- fc 对应着上述 所说的fc 操作
- dynamic_lstm 对应着上述的 lstm 操作
- 第一次 reversed = False , 执行正向的 LSTM
- 第二次 reversed = True , 执行反向的LSTM
(paddle paddle 真的是老信条了....)
3.2 PPBiLSTM 的改写:
没啥说的,这里就按照上述的说明,把网络搭建出来:
注意,这里使用了FLIP 来代替 ppocr 当中的reverse
这里为什么不能用 nn.LSTM(bidirectional = True, num_layers = 2) ,感兴趣的同学可以自行尝试将参数的维度打印出来比较一下就知道为什么了
3.3 Paddle Paddle 制造的超级无敌大坑
到这一步,很多人应该是觉得胜利在望了,无非就是把参数像Backbone 一样,转换一下就可以了,因为至少从维度上来说是对的上的,比如上述结构当中,RNN1 的权重为:
而对应的,在 ppocr 的参数中,有维度刚刚好完全对的上的一些参数:
简直是开心的不得了!然而当你心满意足的将这些参数加载进去的时候,你会发现预测的结果“完全不一样!”
很多人到这一步就放弃了,但是不要害怕,既然维度对的上,那么结果就一定对的上!
只需要找到正确的方法!
3.4 Paddle Paddle 的LSTM 解析
基于上述维度对的上的分析,我们很自然的想到问题可能来自于如下的原因:
在pytorch当中:
FC 结果的解析是按照:
InputGate , ForgetGate , CellGate , outGate 的顺序来解析FC 的结果的,那么Paddle Paddle 是按照这个顺序么?“根本不是!”
想了解PP是如何解析的,需要参考如下两个文件:
首先,在如下文件当中定义了拆分FC 的方式:
https://github.com/PaddlePaddle/Paddle/blob/master/paddle/fluid/operators/math/detail/lstm_cpu_kernel.hgithub.com这里gate_value 指向了FC 的结果,可以看到, FC的结果被拆分成了 in , ig ,fg , og 四个内容
根据名字一猜:
- fg 指的是 ForgetGate
- og 指的是 OutputGate
- ig 指的是 InputGate
- in 指的是 Input
- 简直开心!
之后再看到如下文件当中,所有的这些变量的使用方法:
https://github.com/PaddlePaddle/Paddle/blob/master/paddle/fluid/operators/math/detail/lstm_kernel.hgithub.com可以看到, ig,fg,og 使用的activation 都是 active_gate ,即 sigmoid , 而 in 使用的激活函数是 active_gate , 是 tanh , 至此,就知道了 ppocr 当中的 lstm 是如何解析 FC 的结果的了
至此,我们发现了Pytorch 和 PaddlePaddle 解析FC 的方式不一样了,所以,我们在将PPOCR 当中的LSTM 参数加载进 Pytorch 前,需要首先置换PPOCR 参数的顺序:
def igfo_to_gfio(weight):
if len(weight.shape) == 2:
# 比如 288 , 192
# 首先需要 切分成 (288 , 48) 的 4段 ,分别对应 in , ig , fg , og
i_, g_, f_, o_ = np.split(weight, 4, 1)
# 其次,按照 ig, fg , in , og 的顺序组成成 pytorch 需要的形式
trans_weight = np.concatenate([g_, f_, i_, o_], 1)
return torch.tensor(trans_weight)
else:
i_, g_, f_, o_ = np.split(weight, 4, 0)
trans_weight = np.concatenate([g_, f_, i_, o_])
return torch.tensor(trans_weight)
然后再进行参数的名称的转换:
to_load_state_dict = OrderedDict()
for i in range(1, 3):
to_load_state_dict[f'rnn{i}.weight_ih_l0'] = igfo_to_gfio(weight[f'lstm_st1_fc{i}_w']).T
to_load_state_dict[f'rnn{i}.weight_hh_l0'] = igfo_to_gfio(weight[f'lstm_st1_out{i}_w']).T
to_load_state_dict[f'rnn{i}.bias_ih_l0'] = igfo_to_gfio(weight[f'lstm_st1_fc{i}_b'].reshape(-1))
to_load_state_dict[f'rnn{i}.bias_hh_l0'] = igfo_to_gfio(weight[f'lstm_st1_out{i}_b'].reshape(-1))
to_load_state_dict[f'rnn{i}.weight_ih_l1'] = igfo_to_gfio(weight[f'lstm_st2_fc{i}_w']).T
to_load_state_dict[f'rnn{i}.weight_hh_l1'] = igfo_to_gfio(weight[f'lstm_st2_out{i}_w']).T
to_load_state_dict[f'rnn{i}.bias_ih_l1'] = igfo_to_gfio(weight[f'lstm_st2_fc{i}_b'].reshape(-1))
to_load_state_dict[f'rnn{i}.bias_hh_l1'] = igfo_to_gfio(weight[f'lstm_st2_out{i}_b'].reshape(-1))
for key in to_load_state_dict:
to_load_state_dict[key] = torch.tensor(to_load_state_dict[key])
seq_state = net.SequenceModeling.state_dict()
for key, value in to_load_state_dict.items():
if key in seq_state:
print(key, value.shape, seq_state[key].shape)
net.SequenceModeling.load_state_dict(to_load_state_dict)
至此,LSTM 部分的转换就已经完成了,和原始pp 的结果进行 lstm 输出的比较:
# pp_predict 是用paddle paddle 预测的结果
# 第2个是我返回了cnn 部分输出额结果
# 第3个是我返回了rnn 部分的结果
backbone_res = torch.tensor(np.array(pp_predict[2]))
backbone_res = backbone_res.permute(0, 3, 1, 2).squeeze(3)
max_diff = (torchnet.SequenceModeling(backbone_res)[0].detach() - np.array(pp_predict[3])).max()
# tensor(2.6822e-07)
4.PaddleOCR FC层转 Pytorch 的注意事项
没啥注意的,前两步能搞定到这里都乐开花了
最后给一个转换后的误差吧
max_diff = ( torchnet(torch.tensor(img)).detach().softmax(-1) - np.array(pp_predict[1]) ).max()
# tensor(3.7893e-07)
码字不易,看paddle 源码更不易,搞定lstm 更更不易,记录在这里,供大家参考和学习,欢迎大家给个赞赏支持和鼓励一下,谢谢!