更新:非常抱歉,经过交流讨论,发现一处我的代码错误,Merge 模型中最前面应该加上对应的 SFCN,我写了 SFCN 但是却没加入到 Merge 模型中。
更新:我自己用 tf 实现的 SPLERGE 代码已开源在 github 和 gitee,因为数据很少,模型效果是 over-fitting 的,仅供学习。
SPLERGE
之前有写过 2019 年 ICDAR 表格结构识别最佳论文 Deep Splitting and Merging for Table Structure Decomposition 的介绍,也有一个野生 torch 版本的项目实现,我自己用 tf/keras 写过 split 阶段的复现,整理了一下。merge 部分的在下篇中写。
针对 split 阶段,整个过程不外乎可以分为数据准备、模型搭建、模型训练和模型评估,如果模型评估结果不尽人意或者希望能更上一层,就要复盘整个过程中哪里还有改进点。
数据准备
使用的数据就是 ICDAR 2013 年和 2019 年的表格数据(提取码: vwwf),其中的每份数据都包含对应的 pdf、csv、xls、reg.xml 和 str.xml 几个文件。
在结构识别任务中,以表格图片信息作为输入,输出预测的横纵分隔线,所以只需要用 pdf、reg 和 str 三个文件信息就可以了。每个 pdf 包含一页或多页,在其中一页或多页中存在一个或多个表格,首先将每个 pdf 文件的每一页都转为图片,再根据 reg 文件中的信息裁剪获得表格区域图片,str 文件中包含每个字块的信息,结合这几部分信息就可以得到 split 的训练数据。
数据量不是很多,一共 248 个表格, 这里是将表格图片和对应行列的分隔信息写进了 tfrecords 用来训练。
备注:
(1)原始表格数据中表格位置和字块位置的纵向坐标都是上下倒置的,需要转换一下。
(2)有一处错误,icdar2013-competition-dataset-with-gt/competition-dataset-us/us-018-reg.xml 文件中 26ß(经过查看,应该是260)。
(3)有关 str 文件中 merge 相关的信息(虽然现在重点在说 split 部分,但还是先记录下来),标注标准不太一致。对于不跨行或者不跨列的说明,部分写法只写 start 不写end,部分写法是 start 和 end 都指向一行/列,需要注意下。
问题:
(1)有关 pdf 转 png,安装 fitz 和 PyMuPDF,以往装包还真没遇到过 pip 版本问题的,这是第一次= =,升级 pip 就好了。
(2)一直以为 os.path.join(s1, s2) 可以解决所有的路径拼接问题,偶然发现 s2 必须不能以斜杠开头,否则并不会把两个路径拼接出来。
模型搭建
原论文介绍的很清楚,一共三个大组件分别是 SFCN、RPN 和 CPN,其中 RPN 和 CPN 某种意义上对称,都由 5 个大体相同的 Block 组成。
# SFCN
class SFCN(tf.keras.Model):
def __init__(self):
super().__init__()
cnn = tf.keras.Sequential()
dilation = [1, 1, 2]
for i in range(3):
cnn.add(tf.keras.layers.Conv2D(filters=18, kernel_size=7, padding='same', kernel_initializer=kernel_init, activation='relu', dilation_rate=dilation[i]))
self.cnn = cnn
def call(self, input):
output = self.cnn(input)
return output
# Block
class Block(tf.keras.Model):
def __init__(self, block_num, mode=None):
super().__init__()
self.block_num = block_num # start from index 1
self.mode = mode
self.conv1 = tf.keras.layers.Conv2D(filters=6, kernel_size=3, padding='same', kernel_initializer=kernel_init, activation='relu', dilation_rate=2)
self.conv2 = tf.keras.layers.Conv2D(filters=6, kernel_size=3, padding='same', kernel_initializer=kernel_init, activation='relu', dilation_rate=3)
self.conv3 = tf.keras.layers.Conv2D(filters=6, kernel_size=3, padding='same', kernel_initializer=kernel_init, activation='relu', dilation_rate=4)
if mode == 'rpn':
self.mp = tf.keras.layers.MaxPool2D(pool_size=(1, 2), padding='same')
elif mode == 'cpn':
self.mp = tf.keras.layers.MaxPool2D(pool_size=(2, 1), padding='same')
self.branch1 = tf.keras.layers.Conv2D(filters=18, kernel_size=1, padding='same', kernel_initializer=kernel_init, activation='relu')
self.branch2 = tf.keras.layers.Conv2D(filters=1, kernel_size=1, padding='same', kernel_initializer=kernel_init)#, activation='relu')
return
def call(self, inputs):
c1 = self.conv1(inputs)
c2 = self.conv2(inputs)
c3 = self.conv3(inputs)
c = tf.concat([c1, c2, c3], axis=3)
if self.block_num <= 3:
c = self.mp(c)
# if self.block_num <= 4:
# branch1 = self.branch1(c)
# if self.mode == 'rpn':
# branch1 = proj_row(branch1)
# elif self.mode == 'cpn':
# branch1 = proj_col(branch1)
branch1 = self.branch1(c)
if self.mode == 'rpn':
branch1 = proj_row(branch1)
elif self.mode == 'cpn':
branch1 = proj_col(branch1)
branch2 = self.branch2(c)
if self.mode == 'rpn':
branch2 = proj_row(branch2)
elif self.mode == 'cpn':
branch2 = proj_col(branch2)
branch2 = tf.keras.activations.sigmoid(branch2)
# if self.block_num <= 4:
# output = tf.concat([branch1, c, branch2], axis=3)
# return output, branch2
# else:
# return branch2
output = tf.concat([branch1, c, branch2], axis=3)
return output, branch2
# projection
def proj_row(inputs):
b, h, w, c = inputs.shape
avg = tf.math.reduce_mean(inputs, axis=2)
avg = tf.stack([avg for i in range(w)], axis=2)
return avg
def proj_col(inputs):
b, h, w, c = inputs.shape
avg = tf.math.reduce_mean(inputs, axis=1)
avg = tf.stack([avg for i in range(h)], axis=1)
return avg
# RPN
class RPN(tf.keras.Model):
def __init__(self):
super().__init__()
self.Block1 = Block(1, mode='rpn')
self.Block2 = Block(2, mode='rpn')
self.Block3 = Block(3, mode='rpn')
self.Block4 = Block(4, mode='rpn')
self.Block5 = Block(5, mode='rpn')
return
def call(self, inputs):
op1, r1 = self.Block1(inputs)
op2, r2 = self.Block2(op1)
op3, r3 = self.Block3(op2)
op4, r4 = self.Block4(op3)
op5, r5 = self.Block5(op4)
# r5 = self.Block5(op4)
return r3, r4, r5
# CPN
class CPN(tf.keras.Model):
def __init__(self):
super().__init__()
self.Block1 = Block(1, mode='cpn')
self.Block2 = Block(2, mode='cpn')
self.Block3 = Block(3, mode='cpn')
self.Block4 = Block(4, mode='cpn')
self.Block5 = Block(5, mode='cpn')
return
def call(self, inputs):
op1, c1 = self.Block1(inputs)
op2, c2 = self.Block2(op1)
op3, c3 = self.Block3(op2)
op4, c4 = self.Block4(op3)
op5, c5 = self.Block5(op4)
# c5 = self.Block5(op4)
return c3, c4, c5
# Split
class Split(tf.keras.Model):
def __init__(self):
super().__init__()
self.sfcn = SFCN()
self.rpn = RPN()
self.cpn = CPN()
def call(self, inputs):
outputs = self.sfcn(inputs)
r3, r4, r5 = self.rpn(outputs)
c3, c4, c5 = self.cpn(outputs)
return r3[0, :, 0, 0], r4[0, :, 0, 0], r5[0, :, 0, 0], c3[0, 0, :, 0], c4[0, 0, :, 0], c5[0, 0, :, 0]
模型训练
备注:
(1)由于每个表格的大小和内部结构情况都不一样,所以 batch 只能为 1,学习率 0.00075。
(2)在这个数据集上,大概 300 轮就接近最后的效果了,GTX 1060 6G,大概需要 3 个小时。
loss 主干代码:
def loss_split(inputs, label_split, split, loss_fn=tf.keras.losses.binary_crossentropy):
label_sr, label_sc = label_split[0]
label_sr = tf.convert_to_tensor(np.frombuffer(label_sr.numpy(), dtype=np.uint8))
label_sc = tf.convert_to_tensor(np.frombuffer(label_sc.numpy(), dtype=np.uint8))
r3, r4, r5, c3, c4, c5 = split(inputs)
accr, accc = cal_split_acc(label_sr, label_sc, r5, c5)
loss_split_row = tf.reduce_sum([tf.multiply(0.1, loss_fn(label_sr, r3)),
tf.multiply(0.25, loss_fn(label_sr, r4)),
loss_fn(label_sr, r5)])
loss_split_col = tf.reduce_sum([tf.multiply(0.1, loss_fn(label_sc, c3)),
tf.multiply(0.25, loss_fn(label_sc, c4)),
loss_fn(label_sc, c5)])
loss_split_total = tf.add(loss_split_row, loss_split_col)
return loss_split_total, accr, accc
问题:
(1)报 warning, 某些梯度不存在。
WARNING:tensorflow:Gradients do not exist for variables ['split/rpn/block_4/conv2d_26/kernel:0', 'split/rpn/block_4/conv2d_26/bias:0', 'split/cpn/block_9/conv2d_51/kernel:0', 'split/cpn/block_9/conv2d_51/bias:0'] when minimizing the loss.
查看之后就是 RPN 和 CPN 中最后一个 Block 的上分支 1 × 1 卷积部分,由于原论文中模型设计中在训练时采用依次堆叠的 5 个 Block 中的后 3 个 Block 的下分支作为输出,导致最后一个 Block 的上分支“无处可去”,没有接到最后输出,所以也就没有梯度,但是测试了一下,针对这个问题修不修改模型都不影响训练的最后结果,反而保留最后一个 Block 的上分支收敛速度更快= =。
(2)空洞卷积部分 SpaceToBatchND 报错。tf 实现空洞卷积分三个步骤,spacetobatch,普通卷积然后batchtospace。遇到的错误是,训练中第一次送进模型的 batch 数据,在下次遇到的时候(或者是 shape 非常接近的其他数据)就会报此空洞卷积的错误导致训练停止。测了很多情况,都在第二轮中就报错或者训练一小段时间后报错。测试只有一张训练数据的极端情况却可以一直平稳训练。多番查看还是没有完全定位为何出现这种情况,最后只能牺牲第一张数据每个 epoch 中跳过才能顺利进行训练。
https://blog.csdn.net/azheng_wen/article/details/96479856
https://blog.csdn.net/silence2015/article/details/79748729
模型评估
自我感觉数据不是很多(200 多张表格),模型参数量有几十万,有点过拟合(也算在预期之内吧)。
10% valid 和 10% test,以 F1 值评估,测试集上准确率大概低 10 个百分点,而且整体来讲,行的准确度比列的要低,考虑到数据中行分隔的情况要比列分隔的复杂,所以也算合理(也就是列的学习和预测更简单)。
表格结构(仅split 部分)效果展示,红色横线和黄色纵线是预测结果。
性能改进
就按照模型评估的直观印象,从“过拟合”展开改进了。我认为过拟合这个现象或者概念是数据和模型之间相对的,也就是不能抛开数据和模型任何一方,应该说相对某批数据,某个模型的表达能力过强,泛化能力偏弱。
数据层面
数据层面的话,前面也说过,这个比赛提供的数据也没有到特别丰富的地步。但是因为我没有办法很方便拿到数量更多的表格标注数据,只能在这个比赛数据上着手扩增一下。
如果能拿到更丰富的数据,也能更进一步了,因为到现在为止还都在讨论 split 阶段,这批数据还能较快地训练出来一点效果,但是对于 merge 阶段来说,原本在表格中需要 merge 的字块数量占比就很少,若整个数据集实在太小的话,也很难拿到较好的 merge 效果。
对于图像扩增的手段其实蛮多,但是应该注意自己要处理的任务中,某个特定的扩增手段到底合不合适当前任务。在表格这个任务中,我认为比较合适的有下面几种:
(1)不改变表格中字块分布:
调整图像的亮度 tf.image.adjust_brightness
、对比度 tf.image.adjust_contrast
、饱和度 tf.image.adjust_saturation
和色相 tf.image.adjust_hue
,以及归一化 tf.image.per_image_standardization
。
(2)改变表格中字块分布:
对表格整体做左右或上下翻转,随之也要调整 ground truth。左右翻转会明显影响每列字块到底是左对齐还是右对齐,上下翻转会明显影响到那些经常出现在头部的跨行跨列的字块。考虑到在不限定领域的情况下,表格中字块的排布确实非常灵活多变,所以这个左右上下翻转扩增表格的方法也有可取之处。
(3)随机选择两个表格,resize 调整成同宽或同高的表格,在对应的方向上叠加在一起,变成新的一张“大”一点的表格。这个方法有点激进,可以当做备用。
模型层面
(1)增加正则项,把模型的参数进行正则化,合并到 loss 中进行训练。
(2)优化模型已有层,这个模型中主要使用的就是卷积、池化,池化没有参数需要更新,所以只有卷积。可以采用 inception 的基本思想对某些卷积层进行优化,比如使用 7 × 1 和 1 × 7 的卷积代替 7 × 7 等。
(3)增加模型层,如 BN。(Dropout 层主要适合解决全连接层过拟合问题,这里就不太适合了)