当前位置:   article > 正文

pdf表格抽取阶段性总结(一)_pdf复杂表格的提取

pdf复杂表格的提取

前言

  最近接到一个需求,是要对上市公司的年度报告进行信息抽取,给定pdf文件和所需要抽取的字段名或者表格名,主要涉及到的是表格信息的抽取,具体例子如下图所示,能够抽取出【客户贷款平均收益率】这类的字段所对应的信息:
示例
  又比如下面表格抽取的例子,给定表格名称,要定位到表格所在页,将表格提取出来:
示例

1. 技术路线调研分析

 经过一段时间的调研,发现主要有以下两条技术路线:

  1. python现有的库进行pdf解析,其中比较著名的有pdfplumber, camelot和tabula。总的来看pdfplumber是一个通用性的pdf解析库,对于文字抽取和简单表格的抽取是比较适合的,表现上就是会一字不落的把pdf中的文字提取出来;camelot主打的是一个表格提取,但是camelot在文字和表格混杂的情况下表现极差,导致很多时候提取不到表格;tabula也是主打的一个表格提取,并且效果比camelot更好,在文字和表格混杂或者表格比较复杂的情况下也能够提取出表格的主要信息,但是存在一些数据项错位的问题。
  2. 借助现在百度的深度学习技术,百度推出的表格识别能够识别pdf和图片中的表格,以json的形式返回表格cell的位置和内容,我们就能够基于这样的数据格式抽取或者还原表格。

 经过对两种技术路线的效果对比后,同时考虑到目前的情况,决定结合两种来定制化我们的需求,即借助pdfplumber能够无损地抽取pdf的文字的特点来定位信息所在页,以及借助百度表格识别的高准确性来抽取字段信息或者表格。

2. 关键技术

 在确定主要的技术路线后,在实际操作中有以下关键技术:

2.1 字段抽取

  • 如何定位到信息所在页。
      原本还没有考虑到从几百页的pdf定位到具体哪一页,受到在目标表格中如何查找到目标字段以及对应值的探索过程中的启发,采用当前页对目标字段的jieba分词的全覆盖来定位具体目标页,代码如下:
# 检测到目标在哪些页出现过
pdf_file_nums=[] 
with pdfplumber.open(annualreport) as pdf:
    pages= pdf.pages
    seg_words=[j for j in jieba.cut(key_words,cut_all=False)]
    for index, page in enumerate(pages):
        texts=page.extract_text()
        flag=True
        for seg_word in seg_words:
            if seg_word not in texts:
                flag=False
                break
        if flag:
            f = open(annualreport, 'rb')
            pdf = base64.b64encode(f.read())
            access_token = getToken()
            request_url = request_url + "?access_token=" + access_token
            headers = {'content-type': 'application/x-www-form-urlencoded'}
            params = {
                "pdf_file": pdf,
                'pdf_file_num':index+1
            }
            # print(params)
            response = requests.post(request_url, data=params, headers=headers)
            if response:
                with open('result.json',encoding='utf-8',mode='w') as f_result:
                    f_result.write(json.dumps(response.json(),ensure_ascii=False,indent=4))
                if 'table_num' in dict(response.json()):
                    if response.json()['table_num']>=1:
                        pdf_file_nums.append(index+1)
                        
print(pdf_file_nums)
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 在目标表格中如何查找到目标字段以及对应的值。
      刚开始有两种思路,一个是将目标字段拆解成两部分,然后去百度表格识别返回的json数据中去比对,查出目标信息所在行列,但是这样的思路有个最大的难点,那就是如何确保目标字段切分成两部分是正确无误的,所以很快这种思路就被pass了。
      另一种思路是将百度表格识别返回的json数据中的索引列和表头进行组合,这样就能得到表格中每一个cell所对应的含义,这样与目标字段一比对,选择相似度最优的即可,最初用的是sbert这个模型来计算相似度,代码如下:
def CalSimilarity(text1,text2):
    # 计算embeddings
    # model = SentenceTransformer('all-mpnet-base-v2')
    model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
    embedding1 = model.encode(text1, convert_to_tensor=True)
    embedding2 = model.encode(text2,convert_to_tensor=True)
    # 计算文本之间的相似度
    cosine_score = util.cos_sim(embedding1, embedding2)
    return cosine_score.item()
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

  理想是美好的,现实是残酷的,这种利用embbedding来计算相似度的方法准确度过低(其实这种方法更适合文字不同之间的语义相似度计算),考虑到目前的实际情况,如果表格所组合出的字段时目标字段,那么应该对目标字段的分词应该有较高覆盖(年报信息的准确性),又考虑到都是中文,所以用jieba对组合字段和目标字段进行分词,计算组合字段对目标字段的覆盖率,从而选出最优的匹配字段,覆盖率计算的代码如下:

def CalSimilarityByJieba(fieldname,forcompare):
    seg1 = [i for i in jieba.cut(fieldname,cut_all=True)]
    seg2 = [j for j in jieba.cut(forcompare,cut_all=True)]
    
    ratio = len(list(set(seg1) & set(seg2)))/len(seg1)
    # print(fieldname,forcompare,seg1,seg2,ratio)
    return ratio
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 对于可能多次出现的字段抽取出多个结果后进行数据筛选。考虑到我们目前所需要抽取的字段信息都是数值类,并且不带单位,所以采用正则表达式进行取反判断,从而过滤到非法数据,这里面正则表达式的取反操作是关键(参考:正则表达式取反操作),代码如下:
def is_numeric(character):
    pattern = r'[^0-9.]' # 正则表达式取反操作,检索0-9.之外的非法字符
    search = re.search(pattern, character)
    # print(search)
    return search is None

def is_contain_chinese(check_str):
    """
    判断字符串中是否包含中文
    :param check_str: {str} 需要检测的字符串
    :return: {bool} 包含返回True, 不包含返回False
    """
    for ch in check_str:
        if u'\u4e00' <= ch <= u'\u9fff':
            return True
    return False
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16

2.2 表格抽取

  • 如何定位到表格所在页。
      方法和上面类似,也是计算页面文字对表格名称分词是否全覆盖来确定表格所在页,此处不在赘述。
  • 如何呈现表格信息。
      本来想直接存在excel表格,但是无论是百度表格识别还是tabula提取的效果,都出现了不同程度的错位:
    tabula提取表格错位
      考虑到这个问题短时间内无法解决,所以采用定位+截图来呈现表格,具体的代码如下:
def extract_page(input_path, output_path, page_number):
    with open(input_path, 'rb') as file:
        reader = PyPDF2.PdfReader(file)
        writer = PyPDF2.PdfWriter()

        if page_number < 0 or page_number >= len(reader.pages):
            print('Invalid page number.')
            return

        page = reader.pages[page_number]
        writer.add_page(page)

        with open(output_path, 'wb') as output_file:
            writer.write(output_file)
    print('Page extracted successfully.')

filename="兴业银行2022年度报告.pdf"
 
target_table = '贷款五级分类'
target_page = -1
# 检索到目标表在哪一页
with pdfplumber.open(filename) as pdf:
    pages= pdf.pages
    max_score_page = 0
    for index, page in enumerate(pages):
        texts = page.extract_text()

        flag = True
        seg_words = [word for word in jieba.cut(target_table,cut_all=False)]

        # print(seg_words)

        for seg_word in seg_words:
            if seg_word not in texts:
                flag = False
                break
        if flag:
            target_page=index
            break
print(target_page)

if target_page!=-1:
    
    extract_page(filename,'temp.pdf',target_page)

    # 将对应页存为图片
    # resolution默认为72
    with(Image(filename="temp.pdf",resolution=200)) as source: 
        images = source.sequence
        pages = len(images)
        for i in range(pages):
            n = i + 1
            newfilename = 'temp.jpeg'
            Image(images[i]).save(filename=newfilename)

    # 查询到表格所在的位置: (min_x, min_y, max_x, max_y)
    def getToken():
        url = "https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=xxx&client_secret=xxx"
        
        payload = ""
        headers = {
            'Content-Type': 'application/json',
            'Accept': 'application/json'
        }
        
        response = requests.request("POST", url, headers=headers, data=payload)
        res = json.loads(response.text)
        return res['access_token']

    request_url = "https://aip.baidubce.com/rest/2.0/ocr/v1/table"

    f = open('temp.jpeg', 'rb')
    image = base64.b64encode(f.read())
    print(sys.getsizeof(image))
    params = {
        "image": image
    }
    access_token = getToken()
    request_url = request_url + "?access_token=" + access_token
    headers = {'content-type': 'application/x-www-form-urlencoded'}
    response = requests.post(request_url, data=params, headers=headers)

    areas = []
    if response:
        with open('result.json',encoding='utf-8',mode='w') as f:
            f.write(json.dumps(response.json(),ensure_ascii=False,indent=4))
        for table in response.json()['tables_result']:
            min_x, min_y, max_x, max_y = 10000, 10000, 0, 0
            cells=table['body']
            for cell in cells:
                for point in cell['cell_location']:
                    if point['x'] < min_x:
                        min_x = point['x']
                    if point['x'] > max_x:
                        max_x = point['x']
                    if point['y'] < min_y:
                        min_y = point['y']
                    if point['y'] > max_y:
                        max_y = point['y']
            areas.append((min_x-5,min_y-5,max_x+10,max_y+10))
    print(areas)


    from PIL import Image
    img = Image.open("temp.jpeg")
    print(img.size)
    # cropped = img.crop((590, 1468, 1132, 1556))  # (left, upper, right, lower)
    cropped = img.crop(areas[0])  # (left, upper, right, lower)
    cropped.save("table.jpeg")
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109

  提取的效果如下,效果还行:
在这里插入图片描述

3. Bug记录

  在实现过程中,遇到一个弄了很久的bug,那就是百度表格识别返回的结果总显示文件格式错误,后来发现是open同一个文件,多次read导致的。多次调用f.read(),第一次调用f.read()可以读取到内容,这时游标会移动到文章末尾,再次调用f.read()是获取不到内容的,可以使用f.seek(0)将游标移动到文章开头再次调用f.read()即可获取内容(参考:f.read()读取文件为空)。

4. 总结

 总的来看,目前的进度和效果尚可,但是仍还存在两个关键问题:

  1. 字段提取严重依赖于表格样式(有无索引列和几级表头)。
  2. 表格只实现了截取而非提取,简单来说只能看,不能用。
  3. 另外就是一页多表和一表多页的情况还未能够有效解决。

 革命尚未成功,继续加油吧!

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/Gausst松鼠会/article/detail/500278
推荐阅读
相关标签
  

闽ICP备14008679号