ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

中国中冶年报 PDF 财务表格抽取与指标入库实战

中国中冶年报 PDF 财务表格抽取与指标入库实战 简介本资源为香港联交所上市的中国冶金科工股份有限公司股份代号16182020年度报告原文PDF面向关注建筑央企、冶金工程及基建板块的投资者、行业研究员与财务分析人员用于查阅公司年度经营与财务全貌。包内仅1个PDF文件约5.94MB即完整年度报告正文可按目录快速定位至所需章节。报告涵盖释义、董事长致辞、公司简介与主要财务指标、董事会报告、经营情况讨论与分析、重要事项、普通股股份变动及股东情况、董监高与员工情况、公司治理及企业管治、公司债券相关情况以及财务报告等模块并附其他财务数据与备查文件目录。读者可从中获取2020年营业收入4001.15亿元、利润总额119.17亿元、归属上市公司股东净利润78.62亿元、新签合同额10197.28亿元等核心经营数据以及利润分配预案、重大风险提示与审计意见等内容便于开展财务建模、同业对比与行业趋势研判。目前已有125人学习下载。1. 一份 400 页的双地上市年报为什么值得当成数据集来拆中国中冶 2020 年度报告A 股 601618、H 股 01618正文加财务报告四百多页繁体中文排版金额单位在「千元」和「亿元」之间来回切还要同时满足上交所和香港联交所两套披露口径。做行业咨询、投研支持或者企业对标的人隔一段时间就得从这种 PDF 里抠十几个指标出来手工复制粘贴对页码慢而且极容易串行。把它当成一个数据集来拆问题立刻变得具体文本层能不能直接抽、跨页表格怎么还原列、千分位和全角符号怎么清洗、抽完之后拿什么证明没抽错。这几个问题都有可复现的解法工具链也不重pdfplumber 加 PyMuPDF 再加 pandas 和 SQLite 就够跑完全程。下面按「先探文本层、再抽表、再做交叉校验、最后入库」的顺序走一遍每一步都给能直接执行的代码和参数。2. PDF 文本层判断与版面还原2.1 先确认这份 PDF 有没有文本层拿到年报别急着写解析代码第一件事是判断它是排版系统直出的电子稿还是打印后再扫描的图片集。中国中冶这种两地披露的年报通常由排版工具导出字体嵌入完整走文本层解析成本最低。pdfinfo MCC_2020_annual_report.pdf pdffonts -f 7 -l 12 MCC_2020_annual_report.pdf pdftotext -f 10 -l 10 -layout MCC_2020_annual_report.pdf - | head -40三条命令各有分工。pdfinfo看总页数、是否加密、页面尺寸加密文档在后续解析时会抛权限异常先确认能省掉一轮排查。pdffonts指定第 7 到 12 页也就是「公司简介和主要财务指标」这一段输出里如果字体类型是 TrueType 或 Type0 且 emb 列为 yes说明字形已嵌入文本层可靠如果只列出几个通用字体名、emb 是 no抽出来的中文大概率是乱码。pdftotext -layout会按栏目位置输出主要会计数据表在这种模式下行列大致对齐head -40肉眼扫一遍就能判断字体映射有没有问题。假如pdftotext输出空白或者整页只有一两个字符别再折腾文本层了直接切到第 4 章的 OCR 路线。2.2 pdfplumber、PyMuPDF、camelot 的取舍工具抽取粒度依赖表格线繁体中文适合场景PyMuPDF (fitz)字符 坐标否好全文检索、坐标定位、抽表前的结构探测pdfplumber字符 / 线 / 矩形可选好有框线表格、需要按列坐标聚类camelot整页表格强依赖一般规则网格表、批量固定版式tabula整页表格强依赖一般命令行批处理、框线清晰年报表格以带边框为主但也存在合并单元格和跨页续表纯线框工具在跨页处必断。稳妥组合是 pdfplumber 提表、PyMuPDF 做锚点定位和兜底两者共用同一套页码交叉验证成本很低。camelot 和 tabula 对中文列名的支持偏弱列头经常被吞掉只适合把表体拿来做二次核对。2.3 繁体、全角与千分位的前置清洗这一步最容易踩坑。习惯性的做法是先跑unicodedata.normalize(NFKC, text)把全角压成半角但它同时会把中文逗号「」变成半角逗号而年报里数字的千分位也正好是半角逗号两套逗号一混后面的数字正则就没法区分了。import unicodedata import re TRAD2SIMP {營: 营, 業: 业, 淨: 净, 資: 资, 債: 债, 總: 总, 報: 报, 務: 务} def normalize(text: str) - str: # NFKC 解决全角数字、全角括号、兼容字符但会把「」压成 , text unicodedata.normalize(NFKC, text) # 先把夹在数字中间的逗号千分位打上占位符 text re.sub(r(?\d),(?\d{3}\b), \u0001, text) text text.replace(,, ) # 剩下的才是真正的标点逗号 text text.replace(\u0001, ) # 千分位直接丢掉 for t, s in TRAD2SIMP.items(): text text.replace(t, s) return text逻辑说明(?\d),(?\d{3}\b)要求逗号左边是数字、右边正好三位数字且到词边界400,114,623里的两个逗号都满足条件会被一起替换成占位符随后统一删除剩下没被标记的逗号才是正文标点转成中文逗号保留。参数说明\d{3}\b里的\b是关键没有它1,2345这种小数被误切的情况会漏判。繁简映射不要上全量转换表年报里的公司名、人名用字一旦被整体替换反而会改坏按文档高频词维护一个几十条的小字典人工过一遍更安全。2.4 用坐标而不是文本顺序定位PDF 内部的文字顺序是排版软件写入的跟人眼阅读顺序经常不一致靠page.get_text()出来的字符串直接匹配跨列的行会被拼到一块。import fitz doc fitz.open(MCC_2020_annual_report.pdf) page doc[10] blocks page.get_text(blocks) # (x0, y0, x1, y1, text, block_no, block_type) for b in sorted(blocks, keylambda x: (round(x[1], 1), x[0])): if 营业收入 in b[4]: print(round(b[0]), round(b[1]), b[4][:60])逻辑说明get_text(blocks)返回带坐标的文本块先按 y 再按 x 排序基本等于恢复阅读顺序。round(x[1], 1)这一步不能省——同一行不同单元格的 y 坐标常有零点几像素的差异不取整排序左右两列的块会交错出现。参数说明round的精度选 1 位小数对应约 0.1 像素的分辨率足以归并同一行又不会把上下相邻两行合并。定位到坐标之后后续所有抽取都以坐标和页码为准不再依赖文字出现顺序。3. 财务表格抽取从跨页文本流到指标 DataFrame3.1 用目录锚点切出章节页范围年报目录给的页码和 PDF 物理页码通常差几页封面、释义、致致辞占位都会造成偏移直接拿目录页码当索引必然错位。ANCHORS { main_data: 近三年主要会计数据和财务指标, segment: 主要业务分部, quarter: 分季度主要财务数据, nonrecur: 非经常性损益项目和金额, } def find_pages(doc, anchor: str): return [p.number for p in doc if anchor in p.get_text()] page_map {k: find_pages(doc, v) for k, v in ANCHORS.items()}逻辑说明用唯一性较强的章节标题串做锚点全文检索拿到物理页号比读目录解析页码可靠得多。本报告的四个关键区块分别落在「公司简介和主要财务指标」和「财务报告」两段锚点各自命中两三页正好覆盖跨页续表。参数说明锚点串要选带年份或带修饰语的完整词组「主要财务指标」这种短串会在文中出现多次命中一堆无关页白白增加人工过滤成本。3.2 有框线表格直接上 extract_tableimport pdfplumber with pdfplumber.open(MCC_2020_annual_report.pdf) as pdf: page pdf.pages[9] table page.extract_table({ vertical_strategy: lines, horizontal_strategy: lines, snap_tolerance: 3, join_tolerance: 3, intersection_tolerance: 5, })逻辑说明两个 strategy 都设为lines表示只用实际画出来的线框判定边界年报里带边框的财务表用这套参数命中率最高返回二维列表第一行通常是表头。参数说明snap_tolerance3把 3 像素以内的线吸附成同一条用来消化排版工具导出的微小偏移join_tolerance3控制断线拼接的容差横线被单元格内容打断时靠它接上intersection_tolerance5决定横竖线在多近距离内算相交值太小会把交叉点判丢表格直接碎成几块。三个参数一起调先调 intersection 再调 snap顺序反了会互相抵消。3.3 无框线表格的列聚类兜底部分财务附注表只有横线没有竖线lines策略会退化成一行一列。words page.extract_words(keep_blank_charsFalse, use_text_flowFalse) cols sorted({round(w[x0] / 10) * 10 for w in words}) print(cols[:20])逻辑说明按 x0 每 10 像素分桶得到候选列起点再把每个词分配到最近的桶。A4 排版正文一列普遍在 400 像素以上10 像素分桶不会把同一列劈开同时足以区分相邻的数字列。参数说明keep_blank_charsFalse丢掉纯空白词组避免空列被算进候选use_text_flowFalse关掉阅读顺序推断直接用原始坐标。数字列是右对齐的x1 比 x0 稳定得多工程上我会同时记录两个坐标用 x1 做二次校正能把列错位的概率压下来一大截。3.4 指标抽取与单位归一import re import pandas as pd NUM r-?[\d,](?:\.\d)? def to_num(s: str) - float: return float(s.replace(,, )) def pick(text: str, label: str): m re.search(re.escape(label) r[^\d\-]{0,20}( NUM r), text) return to_num(m.group(1)) if m else None逻辑说明re.escape处理指标名里可能出现的括号[^\d\-]{0,20}允许标签和数值之间夹最多 20 个非数字字符覆盖「元╱股」「千元」这类后缀负号被排除在外是为了不让正则在数字串中途停住。参数说明20 这个上限对应年报里最长的一类指标后缀调到 50 以上会跨行匹配到下一行的数值是最常见的静默错误来源。单位归一是另一个高频翻车点。同一份报告里主要会计数据用千元、财务摘要正文用亿元、分部收入用亿元混着算能差三个数量级。来源位置原始单位归一系数归一后近三年主要会计数据表千元×1 000元财务摘要文字段亿元×100 000 000元主要业务分部收入亿元×100 000 000元每股收益元/股1元/股UNIT {千元: 1_000, 万元: 10_000, 亿元: 100_000_000, 元: 1} df[value_yuan] df.apply(lambda r: r[raw] * UNIT.get(r[unit], 1), axis1)逻辑说明单位必须跟着单元格走不能跟着文档走。抽表时顺手把表头的单位串一起抓下来落到每一行上后面所有计算只认value_yuan这一列原始值和原始单位都保留方便回溯。4. 交叉校验让抽出来的数字自己证明自己4.1 四条能在年报内部自洽的等式抽完之后不要急着下结论年报本身自带多组恒等式用它们当单元测试最省事。校验项等式本报告数值验算资产负债恒等资产 负债 权益366,037,656 140,355,307 506,392,963季度加总四季度归母净利润之和 全年归母净利润1,892,1091,699,8161,015,4333,254,827 7,862,185非经常性损益扣非归母净利润 非经常性损益合计 归母净利润7,171,792 690,393 7,862,185分部与合并分部收入合计 − 分部间抵销 合并营业收入4,072.22 亿 − 71.07 亿 4,001.15 亿前三条例项左右两边同为千元可以直接比。第四条要注意分部口径标明了「抵销分部间交易前」所以差值 71.07 亿元应当等于内部抵销金额而不是零这类口径注释在年报里通常写在表格下方抽表时记得一并抓。checks [ (balance, 506_392_963, 366_037_656 140_355_307), (quarter, 7_862_185, 1_892_109 1_699_816 1_015_433 3_254_827), (nonrecur, 7_862_185, 7_171_792 690_393), ] for name, lhs, rhs in checks: assert abs(lhs - rhs) max(2, abs(lhs) * 1e-9), f{name} 不平: {lhs} vs {rhs}逻辑说明容差取max(2, |lhs|×1e-9)绝对值 2 元覆盖千元单位的四舍五入相对值 1e-9 覆盖十亿级数字的浮点误差。年报披露以千元为单位且经过取整用严格相等必然误报。4.2 用差值反查解析错位校验不平的时候先别重跑解析看差值的形态比看日志快得多差值正好等于某个指标的量级说明有单元格被整行漏抽差值约为原值的两倍多半是跨页重复表头把同一行抽了两遍差值出现在小数位上通常是单位或千分位处理错了。df[diff] df[lhs] - df[rhs] bad df[df[diff].abs() df[lhs].abs() * 1e-6] print(bad[[item, lhs, rhs, diff]].to_string())逻辑说明相对阈值 1e-6 用来把真正的不平项筛出来避免四舍五入产生的正常误差刷屏。跨页重复表头的处理方式是给每一行拼一个由「指标名 期间」组成的行键入库前去重别用行号行号在跨页处会重置。4.3 扫描件与 OCR 兜底拿到扫描版时文本层为空先用 PyMuPDF 以 300 dpi 渲染成图再用 Tesseract 的chi_simchi_tra双语言包识别。python -c import fitz doc fitz.open(scan.pdf) for p in doc: p.get_pixmap(dpi300).save(fpage_{p.number:04d}.png) tesseract page_0009.png stdout -l chi_simchi_tra --psm 6 page_0009.txt逻辑说明300 dpi 是财务表格的底线150 dpi 下零点几磅的横线会糊成灰带表格结构直接丢。--psm 6假设整页是一块统一文本比默认的 psm 3 稳代价是列结构被抹平所以 OCR 路线要额外做按行投影切列。参数说明如果表格线清晰改用--psm 4保留单列可变尺寸的行结构再配合水平投影找行边界。OCR 结果一律先跑 4.1 的三条等式OCR 的错字在数字上表现为个别位错等式基本不可能成立这是最快的发现方式。4.4 年度间对比的口径一致性三年数据并排时要注意早年数据是否被重述过。本报告 2019 年归母净利润 6,599,712 千元如果和上一年年报披露的同名指标对不上说明期间发生过追溯调整或会计政策变更。常见做法是入库时同时保留 as_reported 和 restated 两个字段比较时优先采用最新一期披露的上年数这样横向对比不会因为口径切换而失真。5. 建成可查询的指标库SQLite 表结构与批量入库5.1 表结构CREATE TABLE metric ( company_code TEXT NOT NULL, metric_key TEXT NOT NULL, period TEXT NOT NULL, -- 2020A 表示年度2020Q4 表示单季 value_yuan REAL NOT NULL, unit_raw TEXT, source_page INTEGER, reported_at TEXT ); CREATE UNIQUE INDEX idx_metric_pk ON metric(company_code, metric_key, period); CREATE INDEX idx_metric_key ON metric(metric_key);逻辑说明三列组合唯一索引让重跑天然幂等INSERT OR REPLACE直接覆盖不需要先删后插。period用 2020A 和 2020Q4 区分年度值与单季值避免同一指标的两种口径互相覆盖。source_page必须留核对时能一页翻回去。5.2 批量入库与复合增速查询import sqlite3 conn sqlite3.connect(financials.db) conn.executemany( INSERT OR REPLACE INTO metric (company_code, metric_key, period, value_yuan, unit_raw, source_page, reported_at) VALUES (?,?,?,?,?,?,?), rows, ) conn.commit()SELECT metric_key, ROUND( (POWER(MAX(CASE WHEN period 2020A THEN value_yuan END) / MIN(CASE WHEN period 2018A THEN value_yuan END), 0.5) - 1) * 100, 2 ) AS cagr_pct FROM metric WHERE company_code 601618 AND metric_key IN (revenue, net_profit_attr) GROUP BY metric_key;逻辑说明POWER(..., 0.5)就是开平方根2020 对 2018 是两年指数取 0.5。用CASE WHEN把年度值从混合口径的表里挑出来同一张表里存着季度值也不会串。WHERE里必须同时带company_code和metric_key否则跨公司聚合会算出无意义的结果。5.3 指标名归一与别名匹配指标名不要直接存中文全称「归属于上市公司股东的扣除非经常性损益的净利润」这种名字又长又有版本差异有的年份写「扣除非经常性损益后的净利润」直接用会造成同一指标两个键。import difflib ALIAS { 归属上市公司股东净利润: net_profit_attr, 扣除非经常性损益后的净利润: net_profit_attr_excl, 营业收入: revenue, 经营活动产生的现金流量净额: cfo, } def to_key(label, aliasALIAS): hit difflib.get_close_matches(label, alias.keys(), n1, cutoff0.75) return alias[hit[0]] if hit else None逻辑说明cutoff取 0.75 是平衡点调低会把「净利润」错配到「扣非净利润」上调高则在繁简清洗不彻底时漏配。所以第 2 章的normalize必须先跑否则「營業收入」和「营业收入」在 difflib 眼里相似度只有 0.5 左右怎么调阈值都对不上。别名表可以用get_close_matches批量生成候选再人工过一遍比从零手写快得多。一个收尾技巧在 CI 里给别名表加一条唯一性断言禁止两个不同原文映射到同一个 key同时禁止同一个 key 被映射两次。把 cutoff 降到 0.6 以下时「净利润」被吸到「扣除非经常性损益后的净利润」上的错配在季度表里尤其常见靠这条断言能在入库前就拦住而不是等季度加总校验不平了再回头翻页。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进