表格结构识别 SPLERGE tf/keras 实现(上)


更新:非常抱歉,经过交流讨论,发现一处我的代码错误,Merge 模型中最前面应该加上对应的 SFCN,我写了 SFCN 但是却没加入到 Merge 模型中。

更新:我自己用 tf 实现的 SPLERGE 代码已开源在 githubgitee,因为数据很少,模型效果是 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 几个文件。
ICDAR表格数据

在结构识别任务中,以表格图片信息作为输入,输出预测的横纵分隔线,所以只需要用 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 个百分点,而且整体来讲,行的准确度比列的要低,考虑到数据中行分隔的情况要比列分隔的复杂,所以也算合理(也就是列的学习和预测更简单)。
loss情况

列分隔情况
行分隔情况
表格结构(仅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 层主要适合解决全连接层过拟合问题,这里就不太适合了)

  • 6
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 34
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 34
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值