简介:本资源为2026年第十四届泰迪杯数据挖掘挑战赛B题「上市公司财报智能问数助手」的完整题解方案,面向参赛队伍及需要金融NL2SQL与RAG实战案例的学习者。内容覆盖「规则+模型」双擎数据抽取、六维意图分类与高鲁棒NL2SQL引擎、基于DAG自主规划的RAG深度问答框架,并附近两万字Word论文、全链路Python代码及标准结果表,可直接参考或稍作修改用于竞赛提交。压缩包共281个文件,约551.43MB,以pdf、xlsx、png、py、json、csv、tsx等为主,分别对应论文文档、结果数据、图表、核心代码与全栈Web前后端源码,另含SQLite数据库与一键运行脚本。目前已有791人学习下载。读者可获得完整赛题方案、模块化代码、五维校验后的财报数据与提交模板,适合冲刺国奖的参赛者系统复现与查漏补缺。
1. 从一份年报 PDF 到一句人话答案:泰迪杯 B 题到底在考什么
如果你手里只有一份 300 页的上市公司年报 PDF,老板却问你「这家公司去年毛利率为什么掉了 3 个点」,你会怎么答?2026 第 14 届泰迪杯数据挖掘挑战赛 B 题,考的就是把这个问题变成一套能跑起来的系统——上市公司财报「智能问数」助手。它要的不是一个只会背指标的问答机器人,而是一条从财报文本抽取、指标计算、自然语言转查询、再到答案生成的完整链路。适合谁做?会一点 Python、懂点财务常识、想拿数据挖掘竞赛练手的学生和转行工程师。这一题真正的难点不在模型多大,而在财报表格结构乱、口径不统一、问法千变万化,下面把我拆解这套方案的完整思路讲清楚。
财报智能问数的核心矛盾是:财务数据高度结构化,但用户提问高度口语化。你不可能让用户去写 SQL,也不能指望大模型直接读 PDF 就能算对数。所以整套方案必须拆成三层——数据层把 PDF 变成干净的表,语义层把「毛利率」这类词映射到字段和公式,交互层把自然语言转成可执行查询再回填成人话。泰迪杯这类数据挖掘赛题,评分通常看三点:抽取准确率、问答命中率、系统能不能复现。全代码和论文的价值,就在于把这三层都落到可验证的脚本上,而不是停在架构图上。
2. 财报数据抽取:把 PDF 和 Excel 变成能算的表
2.1 为什么不能直接上大模型读 PDF
很多人第一反应是把年报 PDF 丢给大模型,让它直接回答。这条路在 demo 里能跑通,在比赛里必翻车。原因有三个:一是财报里的三张主表(资产负债表、利润表、现金流量表)是跨页的,PDF 转文本后行列会错位;二是同一家公司不同年份的科目名会变,比如「营业总收入」和「营业收入」混用;三是大模型算数不可靠,你问它「同比增长多少」,它可能给你编一个。
常见做法是先用规则把表格抽出来,再用大模型只做「字段对齐」这种语义活。抽取工具上,pdfplumber对有线表格友好,camelot对无框表格更稳,Excel 年报直接用pandas.read_excel配合header参数定位表头行。我一般会先写一个探测脚本,把每页的表格数量和前几行打出来,确认表头在第几行再批量抽。
import pdfplumber import pandas as pd def extract_tables(pdf_path, page_range=None): """抽取 PDF 中的表格,返回 DataFrame 列表""" tables = [] with pdfplumber.open(pdf_path) as pdf: pages = pdf.pages if page_range is None else pdf.pages[page_range[0]:page_range[1]] for page in pages: # 用 lines 策略识别有线表格,text 策略兜底无框表 for tbl in page.extract_tables({ "vertical_strategy": "lines", "horizontal_strategy": "lines", "intersection_tolerance": 5 }): if tbl and len(tbl) > 1: df = pd.DataFrame(tbl[1:], columns=tbl[0]) tables.append(df) return tables if __name__ == "__main__": dfs = extract_tables("annual_report.pdf", page_range=(40, 60)) for i, d in enumerate(dfs): print(f"--- table {i} shape={d.shape} ---") print(d.head(3))这段代码的关键在extract_tables的参数字典。vertical_strategy和horizontal_strategy设成lines表示只认显式表格线,适合财报主表;如果某张表没有边框,就换成text策略,靠文字对齐推断列。intersection_tolerance控制线段交叉的容差,财报表格线经常断,设 5 能减少漏检。跑完先看shape和head,如果列名是None或者第一行是数字,说明表头行没对上,需要手动调page_range或加header偏移。
2.2 科目名对齐:用规则加语义匹配统一口径
抽出来的表最大的问题是科目名不统一。同一个「净利润」,有的年报写「归属于母公司股东的净利润」,有的写「净利润」。如果直接按字符串匹配,你的指标计算会漏掉一半。我的做法是维护一张标准科目表,然后用「精确匹配 → 别名表 → 语义相似度」三级兜底。
from difflib import SequenceMatcher # 标准科目与常见别名 STANDARD_MAP = { "营业收入": ["营业总收入", "营业收入", "主营业务收入"], "净利润": ["净利润", "归属于母公司股东的净利润", "归母净利润"], "资产总计": ["资产总计", "总资产", "资产合计"], } def normalize_subject(raw_name, threshold=0.75): """把原始科目名映射到标准名""" raw_name = str(raw_name).strip().replace(" ", "") for std, aliases in STANDARD_MAP.items(): if raw_name in aliases: return std # 语义兜底:找最相似的别名 best, best_score = None, 0 for std, aliases in STANDARD_MAP.items(): for a in aliases: score = SequenceMatcher(None, raw_name, a).ratio() if score > best_score: best, best_score = std, score return best if best_score >= threshold else NoneSTANDARD_MAP是这套方案的核心资产,比赛时可以根据抽到的年报不断补别名。normalize_subject先做精确匹配,命中就返回;没命中就用SequenceMatcher算字符相似度,threshold=0.75是经验值,太低会误匹配,太高会漏。返回None的科目要人工过一遍,往往是你别名表缺了条目。这一步做完,你才有资格谈「智能问数」,否则问出来的数都是错的。
3. 智能问数的语义层:把「毛利率」翻译成可执行查询
3.1 意图识别与槽位抽取的最小实现
用户问「2023 年贵州茅台的毛利率是多少」,系统要拆出三个槽位:公司=贵州茅台、年份=2023、指标=毛利率。意图就是「查指标」。这一步不需要上大模型,用规则加词典就能覆盖 80% 的赛题问法。我一般用jieba分词加正则抽年份和公司名,指标词走词典匹配。
import re import jieba INDICATOR_DICT = ["毛利率", "净利率", "营业收入", "净利润", "资产负债率", "ROE"] def parse_question(q): """解析问句,返回意图和槽位""" slots = {} # 抽年份:2023年 / 2023 year = re.search(r"(20\d{2})年?", q) slots["year"] = int(year.group(1)) if year else None # 抽指标 slots["indicator"] = next((w for w in INDICATOR_DICT if w in q), None) # 抽公司:去掉指标和年份后的名词短语 words = [w for w in jieba.cut(q) if w not in ("的", "是", "多少", "年", "请问")] slots["company"] = "".join(words).replace(str(slots["year"]), "").replace(slots["indicator"] or "", "") return {"intent": "query_indicator", "slots": slots} print(parse_question("2023年贵州茅台的毛利率是多少")) # {'intent': 'query_indicator', 'slots': {'year': 2023, 'indicator': '毛利率', 'company': '贵州茅台'}}INDICATOR_DICT要覆盖赛题里出现的所有指标,jieba负责切词,正则负责抽年份。公司名抽取是最容易出错的,因为问句里可能带「的」「请问」这些噪声词,所以我在jieba.cut后过滤掉停用词再拼接。这套规则在问法规范的赛题里够用,但如果问句变成「茅台去年赚了多少」,就需要把「赚了多少」也映射到「净利润」,这属于同义词扩展,可以再加一层映射表。
3.2 指标计算:毛利率、净利率的公式与边界
抽到槽位后,真正算数的是指标计算模块。以毛利率为例,公式是(营业收入 - 营业成本) / 营业收入。但财报里「营业成本」可能叫「营业总成本」,两者差着期间费用,用错口径结果差很多。所以计算前必须确认科目映射正确。
| 指标 | 公式 | 依赖科目 | 常见坑 |
|---|---|---|---|
| 毛利率 | (营收-营业成本)/营收 | 营业收入、营业成本 | 营业成本≠营业总成本 |
| 净利率 | 净利润/营业收入 | 净利润、营业收入 | 净利润用归母还是合并 |
| 资产负债率 | 负债合计/资产总计 | 负债合计、资产总计 | 负债含不含少数股东权益 |
| ROE | 净利润/净资产 | 净利润、所有者权益 | 净资产用期初还是期末 |
def calc_indicator(df, indicator, year): """从标准化后的 DataFrame 计算指标""" row = df[df["year"] == year] if row.empty: return None r = row.iloc[0] if indicator == "毛利率": return (r["营业收入"] - r["营业成本"]) / r["营业收入"] if indicator == "净利率": return r["净利润"] / r["营业收入"] if indicator == "资产负债率": return r["负债合计"] / r["资产总计"] return Nonecalc_indicator假设df已经过科目对齐,列名是标准名。row.empty判断年份是否存在,避免 KeyError。每个公式都要处理分母为零的情况,财报里偶尔有营收为零的壳公司,直接返回None比抛异常好。这张表建议直接抄进论文,评委看的就是你对口径的理解。
4. 避坑与排查:财报问数最容易翻车的 5 个地方
4.1 表格跨页导致数据截断
现象:抽出来的利润表只有上半部分,下半部分的「净利润」行不见了。原因是 PDF 里表格跨了两页,pdfplumber按页抽,第二页的表头被当成新表。解决:先按页抽,再用科目名做锚点合并,或者用camelot的flavor="stream"跨页模式。我一般会在抽取后检查每张表的行数,少于 5 行的表大概率是截断的。
4.2 单位不统一:万元和元混用
现象:算出来的毛利率是 3000%,明显不对。原因是有的年报用「万元」为单位,有的用「元」,你直接拿两个不同单位的数相除。解决:抽取时保留表头里的单位信息,统一换算成元再计算。可以在normalize_subject里加一步单位检测,遇到「单位:万元」就给整表乘 10000。
4.3 年份槽位抽错:把「2023 年年度报告」当成查询年份
现象:用户问「2022 年净利润」,系统返回了 2023 年的数。原因是问句里同时出现「2022」和「2023 年年度报告」时,正则匹配到了后者。解决:优先匹配「20XX 年」且后面不跟「年度报告」的模式,或者用re.findall取所有年份后选最靠前的。
4.4 指标同义词漏配
现象:用户问「净资产收益率」,系统识别不出指标。原因是词典里只有「ROE」。解决:维护同义词表,ROE、净资产收益率、权益报酬率都映射到同一个标准指标。这个表越全,问答命中率越高。
4.5 大模型幻觉:答案里编造数字
现象:系统回答「毛利率为 45.6%」,但实际计算是 42.1%。原因是把计算结果交给大模型润色时,模型自己改了数。解决:数字必须由计算模块生成,大模型只负责组织语言,且要在 prompt 里明确「只能使用给定数字,不得修改」。更稳的做法是模板化生成,数字直接填槽。
5. 从能跑到能拿奖:验证方法与一个提分技巧
5.1 用留出集验证问答命中率
比赛评审看的是泛化能力,所以你必须自己造一个验证集。做法是从年报里人工挑 50 个问题,覆盖不同指标和年份,跑一遍系统,统计命中率。命中率分两层:槽位抽取准确率和最终答案准确率。槽位错了,答案肯定错;槽位对了但计算错,说明科目映射有问题。我一般会写一个eval.py,把问题和标准答案存成 CSV,批量跑完输出混淆矩阵。
import pandas as pd def evaluate(qa_csv, parse_fn, calc_fn): """批量评估问答命中率""" df = pd.read_csv(qa_csv) # 列:question, std_answer hit = 0 for _, row in df.iterrows(): parsed = parse_fn(row["question"]) pred = calc_fn(parsed["slots"]) # 数值容差 1% if pred is not None and abs(pred - row["std_answer"]) / row["std_answer"] < 0.01: hit += 1 return hit / len(df) # 用法:print(evaluate("qa.csv", parse_question, my_calc))qa_csv至少 50 条,std_answer是人工算好的标准值。容差设 1% 是因为财报四舍五入会有误差。这个脚本跑出来的数字直接写进论文的实验部分,比空谈「效果良好」有说服力得多。
5.2 一个提分技巧:把问句模板化再匹配
赛题里的问法其实很有限,与其硬做语义理解,不如把常见问法总结成模板。比如「{年份}{公司}的{指标}是多少」「{公司}{年份}的{指标}同比变化」。用模板匹配,命中率能到 90% 以上,剩下的长尾再交给规则兜底。我去年做类似赛题时,就是靠这一招把问答准确率从 70% 拉到 88%。具体做法是先用jieba分词,再和模板做编辑距离匹配,取最相似的模板填槽。
TEMPLATES = [ "{year}{company}的{indicator}", "{company}{year}的{indicator}", "{year}{company}{indicator}是多少", ] def match_template(q): """返回最匹配的模板和槽位""" best, best_score = None, 0 for t in TEMPLATES: # 把模板转成正则,槽位用 .+? 匹配 pattern = re.sub(r"\{(\w+)\}", r"(?P<\1>.+?)", t) m = re.search(pattern, q) if m: score = len(m.group(0)) / len(q) if score > best_score: best, best_score = m.groupdict(), score return bestTEMPLATES要按赛题问法不断补充,re.sub把{year}这种占位符转成命名捕获组,match_template返回槽位字典。这个方法的边界是问法必须和模板结构相似,如果用户问「茅台和五粮液谁更赚钱」,模板匹配就失效了,需要走对比查询的专门逻辑。但赛题里这种开放问法占比不高,先把高频模板吃透,性价比最高。
最后说个血泪经验:别等到最后一周才跑全量数据。财报 PDF 的坑是抽着抽着才冒出来的,越早跑通一条完整链路,越有时间补别名表和模板。我一般第一天就把抽取、计算、问答串成一个最小闭环,哪怕只支持一个指标,后面所有优化都是在这个闭环上迭代。希望帮到你。
本文还有配套的精品资源,点击获取