上市公司财报「智能问数」助手:指标字典、规则解析与双层评估(优秀范文一)
摘要:本文面向财报「智能问数」赛题,在会计恒等自洽的合成报表(8 家行业参数化公司 × 3 会计年度,恒等式残差 0.00e+00)上,完成了从指标字典构建、标注问句集设计到规则解析器三版本迭代的完整闭环。将毛利率、资产负债率等 8 项指标定义为「公式 + 别名」元数据,构造 120 条三级难度 × 四类意图的标注问句(L1 标准 48 / L2 同义 42 / L3 相对时间 30;查询 64 / 对比 22 / 排名 12 / 趋势 22),并以「抽取准确率 + 计算正确率」双层口径评估解析器 v1→v2→v3 的迭代:基线 v1 因别名词典残缺与相对时间失能仅达 65.8% / 47.5%;v2 补全 3 组口语别名修复全部 11 条指标错误(75.0% / 56.7%);v3 引入相对时间消歧后抽取准确率达 100%、计算正确率触及口径上限 81.7%。归因分析表明:仅 3 个词的时间词表即挽回 30 条问句(占总集 25%),是投入产出比最高的改进;而对比类问句若按「首个命中实体单点查值」评估,会因长别名优先匹配颠倒实体顺序产生 6 条假阴性,改为无序集合口径后消除。全文纯标准库实现、固定种子可复现,正文、配图、附录、真源四路数字一致。
关键词:智能问数;指标字典;槽位抽取;相对时间消歧;双层评估;规则解析器
一、问题重述
财报问数的本质是把自然语言问句翻译为「公司—年度—指标」三元组上的确定性计算。赛题要求:
- 构建财报指标字典与统一计算口径;
- 对用户问句做实体(公司)、指标、时间的识别与意图分类;
- 把解析结果转为可执行的取数计算,支持单点查值、双司对比、行业排名与趋势问答;
- 从「抽取是否正确」与「计算是否正确」两层评估准确率,并给出错误案例分析。
二、模型假设
- 行业参数化(基准营收、毛利水平、杠杆、增速)的合成报表能代表真实财报的数值结构与指标量级,且会计恒等自洽可排除脏数据对解析器评测的干扰;
- 指标口径一经字典固定即在全部问句中一致,不存在同一名称多种算法的歧义;
- 标注问句集按「难度 × 意图」交叉设计,其语言面(标准直述、同义替换、相对时间)覆盖目标用户的主要表达习惯;
- 在公司、指标、时间三类槽位均封闭的前提下,最长匹配规则解析器可达到抽取上限,无需引入统计模型。
三、符号说明
| 符号 | 含义 |
|---|---|
| 公司—年度定位的报表记录,含 11 个科目字段(亿元) | |
| 指标名,取自 8 项指标字典 | |
| 指标取值函数(由字典公式唯一确定) | |
| 标注问句:实体列表、年度、指标、意图 | |
| 解析器返回的三元组(命中实体列表,年份,指标) |
四、合成财报与指标字典(问题 1)
数据生成:以 8 家虚构公司为载体,每家由五元组(基准营收、毛利率、资产负债率、增速)参数化行业属性——南岭白酒代表高毛利低杠杆的消费龙头(320 亿基数、91% 毛利、24% 杠杆),燕山钢铁代表低毛利高杠杆的重资产行业(410 亿、11%、68%)。逐年营收按复利增长叠加 σ=2% 的随机扰动,成本、费用、资产、负债等科目依次由条件分布生成,并强制满足资产 = 负债 + 权益的会计恒等式,抽样校验残差为 0.00e+00。选择合成而非真实数据的理由有三:真实财报存在口径分歧与缺漏,会污染「解析器」这一被评对象;恒等自洽保证任何指标计算都落在干净数据上;固定种子使 120 条问句的标准答案永久可复现。
整体管线如图 1:问句经槽位解析得到三元组,指标引擎按字典公式取数计算,答案组装层再按意图分别输出单值、对比、排名或趋势。图 2 给出 2023 年八家公司营收(141.5~449.9 亿元),图 3 以热力图呈现四项百分数指标的横截面画像:南岭白酒以 93.0% 毛利率、61.8% ROE、23.0% 资产负债率居盈利质量之首,申城微电(46.7% / 32.1% / 35.9%)与江浙医药(63.7% / 24.4% / 28.8%)居中,重资产梯队垫底——行业梯度符合财务常识,侧面验证了参数化生成的合理性。
指标字典把 8 项指标定义为「公式 + 别名列表」的元数据:毛利率 、净利率 、资产负债率 、流动比率 、净资产回报率 、存货周转率 、净现比 、营收增长率 。每个指标除标准名外登记口语别名(如「负债率」「杠杆率」「ROE」),解析器只认字典不认临场发挥,从根上杜绝同名异算。排名类问题的标准答案由全行业 argmax 生成:2023 年毛利率与 ROE 最高均为南岭白酒(93.0 与 61.8),营收增长率最高为东江动力(33.8%)。
五、标注问句集构建
问句集的质量决定评估的可信度。我们按「三级难度 × 四类意图」交叉设计 120 条(图 4):难度维从 L1 标准直述(「南岭白酒2023年毛利率是多少」)、L2 同义替换(简称 + 口语别名 + 语序倒装,「帮我查下南酒在2022年的毛利」)到 L3 相对时间(「北乳去年资产负债率是多少」);意图维覆盖 Q 单点查询 64 条、C 双司对比 22 条、R 行业排名 12 条、T 三年趋势 22 条。每条问句携带完整标注 ,实体存为列表而非标量——这为后文按意图差异化判定埋下伏笔。生成过程完全确定(编号取模轮转),仅以固定种子做一次整体乱序,保证任何人重跑得到的问句序列逐条一致。
六、解析器设计与三版本迭代
解析采用「最长匹配优先」的槽位填充:公司别名与指标别名各自按长度降序扫描问句文本,先命中先得,规避「南酒」被「南岭白酒」的前缀截断;时间槽位先查相对时间词表、再匹配四位年份字面量。在此骨架上迭代三个版本:
- v1 冷启动基线:故意只登记 13 个标准别名,缺失「毛利」「负债率」「增长率」三组最口语化的说法(图 5 左),且不做相对时间消歧——复刻一个未经语料打磨的系统原型;
- v2 补全别名词典:别名扩至 16 个(图 5 右),其余逻辑不变,用于隔离度量「词汇覆盖」的单项贡献;
- v3 时间消歧:在 v2 基础上加入相对时间词表(去年→2023、前年→2022、今年→2024,以提问基准年为锚),并把「近三年」识别为区间语义(multi),供趋势类问题使用。
值得强调的是实体判定的意图分化:对比类问句要求命中实体集合与标注相等(顺序无关),查询/趋势类要求单一精确命中,排名类问句文本本身不含公司实体,跳过实体维度只验指标与时间。一刀切地要求「命中数等于 2」或「命中数等于 0」都会把合法问句误杀。
七、双层评估与错误分析(问题 2–4)
评估口径是本文的核心设计。第一层抽取准确率覆盖全部 120 条:解析结果的三元组须与标注逐一相符(按上述意图分化规则)。第二层计算正确率仍以 120 为公共分母,但只有抽取成功的非趋势问句进入计算:Q/C 类执行无序集合查值(系统对命中实体逐一取数,与标注实体的取值集合比较),R 类执行全行业 argmax 后比对冠军公司,T 类趋势题需多点多值呈现、超出规则解析器的职责边界,仅在抽取层参评——因此计算正确率的理论上限为 ,两层之差恒为趋势类占比 18.3%,这是口径分层的结果而非系统缺陷,却让两个数字可以在同一标尺上直接对比版本迭代。
| 版本 | 改动 | 抽取准确率 | 计算正确率 | 错误分布(实体/指标/时间) |
|---|---|---|---|---|
| v1 | 13 别名,无时间消歧 | 65.8% | 47.5% | 0 / 11 / 30 |
| v2 | 别名补至 16 | 75.0% | 56.7% | 0 / 0 / 30 |
| v3 | +相对时间消歧 | 100.0% | 81.7% | 0 / 0 / 0 |
三版本阶梯(图 6)的归因十分干净:v1→v2 的 +9.2 个百分点全部来自 L2 同义替换题——11 条含「毛利/增长率」口语名的问句由错转对,指标类错误清零;v2→v3 的 +25 个百分点全部来自 L3——30 条相对时间问句被三个词的时间词表整体挽回,时间类错误清零。错误类型的此消彼长(图 7)与分难度的爬坡曲线(图 8)共同印证了改动与效果的一一对应:L1 全程 100%(标准直述对任何版本都不构成挑战)、L2 从 73.8% 一步到位、L3 从 0% 直线拉满。
一个反例复盘:初版评估曾以「首个命中实体单点查值」评判 C 类计算,结果 v3 计算正确率只有 68.3%——排查发现 22 条对比题中有 6 条假阴性。根源在于最长匹配按别名长度降序扫描,问句「南酒跟北疆乳业相比」会先命中 4 字全名再命中 2 字简称,命中顺序与问句语序相反,导致系统取的是 B 公司的值而标注主体是 A 公司。这不是解析错误(实体集合完全正确),而是评估口径对无关紧要的顺序信息过敏。改为无序集合查值后假阴性清零。教训:为确定性系统设计评估时,应把「不影响答案正确性的中间状态」(此处为实体填充顺序)排除在判定之外,否则评估器自身成为最大的误差源。
八、灵敏度与讨论
口径敏感性:若坚持有序单点口径,v3 计算正确率将由 81.7% 降至 68.3%,且降幅与解析质量无关——纯粹由问句语序与词典长度的相对关系决定,属于评估噪声;若让 T 类强行计入计算分母,任何规则系统的得分都会被钉死在低位,因为「三年趋势」的正确答案本就不是单个数字。可见评估口径的选择能在 ±13 个百分点幅度内改写成绩,其重要性不亚于解析器本身的改进。
词典规模的边际效应:v2 仅新增 3 组别名就吃掉了全部指标类错误——封闭域内词汇面有限,词典法的天花板很高;但也正因如此,其失效模式是脆性的:未登录的口语变体(「赚多少」「负债什么水平」)将直接落空,生产系统应以语义相似度模型兜底长尾表达,把词典留给高频主干。
九、模型优缺点
优点:① 指标字典集中管理口径,问数结果天然可解释、可追溯;② 三版本受控迭代把每一分提升归因到单一改动,改进路径无歧义;③ 双层评估配合意图分化判定,能区分「听不懂」与「算不对」两类性质迥异的故障。
缺点:① 规则解析依赖封闭词表,开放域口语泛化能力弱;② 合成报表的行业参数为手工设定,极端财务结构(如负净资产导致 ROE 失效)未覆盖;③ 排名类仅实现 argmax 单一聚合,Top-K 与排序陈述尚未纳入评估。
十、结论
本文在恒等自洽的合成财报上构建了完整的问数评测闭环:8 项指标字典、120 条三级难度标注问句、规则解析器三版本受控迭代。结果显示,冷启动基线抽取准确率 65.8%,补全别名词典与相对时间消歧两项针对性改进将其推至 100%,计算正确率同步触及 81.7% 的口径上限;归因分析与错误复盘进一步表明,评估口径的设计精度直接决定系统能否被正确度量——先修口径、再做系统,是智能问数类赛题区别于传统预测题的关键方法论。
参考文献
[1] 泰迪杯数据挖掘挑战赛组委会. 2026 年泰迪杯数据挖掘挑战赛 B 题:上市公司财报「智能问数」助手[Z]. 2026.
[2] 财政部. 企业会计准则——基本准则[S]. 2014 修订.
[3] Jurafsky D, Martin J H. Speech and Language Processing[M]. 3rd ed. Prentice Hall, 2023.
[4] 张华平, 等. 自然语言处理原理与技术实现[M]. 北京: 电子工业出版社, 2017.
附录:核心 Python 实现
# -*- coding: utf-8 -*-
"""财报智能问数核心实现(与真源 gen_tidy2026b.py 随机流逐调用等价)"""
import random
SEED = 20260901
YEARS = [2021, 2022, 2023]
BASE_YEAR = 2024
COMPANIES = [
("南岭白酒", ["南岭白酒", "南酒"], 320.0, 0.91, 0.24, 0.16),
("申城微电", ["申城微电", "微电"], 210.0, 0.46, 0.36, 0.22),
("江浙医药", ["江浙医药", "江药"], 185.0, 0.62, 0.30, 0.14),
("北疆乳业", ["北疆乳业", "北乳"], 140.0, 0.33, 0.42, 0.09),
("东江动力", ["东江动力", "东动"], 260.0, 0.27, 0.55, 0.31),
("岭南家电", ["岭南家电", "岭家"], 175.0, 0.24, 0.60, 0.07),
("川蜀重工", ["川蜀重工", "重工"], 230.0, 0.19, 0.66, 0.11),
("燕山钢铁", ["燕山钢铁", "燕钢"], 410.0, 0.11, 0.68, -0.04),
]
ALIAS2CO = {}
for nm, als, *_ in COMPANIES:
for a in als:
ALIAS2CO[a] = nm
METRICS = {
"毛利率": dict(aliases=["毛利率", "毛利"]),
"净利率": dict(aliases=["净利率", "净利"]),
"资产负债率": dict(aliases=["资产负债率", "负债率", "杠杆率"]),
"流动比率": dict(aliases=["流动比率"]),
"净资产回报率": dict(aliases=["净资产回报率", "ROE", "roe"]),
"存货周转率": dict(aliases=["存货周转率", "周转率"]),
"净现比": dict(aliases=["净现比"]),
"营收增长率": dict(aliases=["营收增长率", "增长率"]),
}
V1_ALIASES = {}
for m, d in METRICS.items():
keep = [a for a in d["aliases"]
if (m, a) not in [("毛利率", "毛利"), ("资产负债率", "负债率"),
("营收增长率", "增长率")]]
for a in keep:
V1_ALIASES[a] = m
FULL_ALIASES = {}
for m, d in METRICS.items():
for a in d["aliases"]:
FULL_ALIASES[a] = m
REL_TIME = {"去年": BASE_YEAR - 1, "前年": BASE_YEAR - 2,
"今年": BASE_YEAR}
def gen_fin():
"""会计恒等自洽的合成报表:(name, year) -> 科目字典,单位亿元。"""
rng = random.Random(SEED)
fin = {}
for name, _als, base, gm, dr, growth in COMPANIES:
for k, y in enumerate(YEARS):
rev = base * (1 + growth) ** k * rng.gauss(1.0, 0.02)
gm_y = min(0.95, max(0.05, gm * rng.gauss(1.0, 0.02)))
cost = rev * (1 - gm_y)
fee = rev * rng.uniform(0.06, 0.12)
net = (rev - cost - fee) * 0.75
asset = rev / rng.uniform(0.35, 0.85)
liab = asset * dr * rng.gauss(1.0, 0.03)
equity = asset - liab
ca = asset * rng.uniform(0.25, 0.55)
cl = liab * rng.uniform(0.45, 0.75)
inv = cost / rng.uniform(2.5, 7.0)
ocf = net * rng.uniform(0.8, 1.35)
fin[(name, y)] = dict(rev=rev, cost=cost, fee=fee, net=net,
asset=asset, liab=liab, equity=equity,
ca=ca, cl=cl, inv=inv, ocf=ocf)
return fin
def metric_value(fin, name, y, m):
d = fin[(name, y)]
if m == "毛利率":
return (d["rev"] - d["cost"]) / d["rev"] * 100
if m == "净利率":
return d["net"] / d["rev"] * 100
if m == "资产负债率":
return d["liab"] / d["asset"] * 100
if m == "流动比率":
return d["ca"] / d["cl"]
if m == "净资产回报率":
return d["net"] / d["equity"] * 100
if m == "存货周转率":
return d["cost"] / d["inv"]
if m == "净现比":
return d["ocf"] / d["net"]
if m == "营收增长率":
prev = YEARS[max(0, YEARS.index(y) - 1)]
if prev == y:
return None
return (d["rev"] / fin[(name, prev)]["rev"] - 1) * 100
def industry_top(fin, y, m):
best = None
for nm, _als, *_r in COMPANIES:
v = metric_value(fin, nm, y, m)
if v is not None and (best is None or v > best[0]):
best = (v, nm)
return best[1]
def gen_questions():
"""120 条标注问句:L1 标准 48 / L2 同义 42 / L3 相对时间 30。"""
rng = random.Random(SEED)
qs = []
names = [c[0] for c in COMPANIES]
short = {c[0]: c[1][1] for c in COMPANIES}
ms = list(METRICS)
def add(level, text, cos, year, m, intent):
qs.append(dict(level=level, text=text,
company=(cos[0] if cos else None),
companies=cos, year=year, metric=m, intent=intent))
for i in range(48):
co, m = names[i % 8], ms[i % 8]
y = YEARS[i % 3]
if m == "营收增长率" and y == 2021:
y = 2022
intent = ["Q", "C", "R", "T"][i % 4]
if intent == "Q":
add("L1", "%s%d年%s是多少" % (co, y, m), [co], y, m, "Q")
elif intent == "C":
b = names[(i + 3) % 8]
add("L1", "对比%s和%s%d年的%s" % (co, b, y, m), [co, b],
y, m, "C")
elif intent == "R":
add("L1", "%d年%s最高的公司是哪家" % (y, m), [], y, m, "R")
else:
add("L1", "%s近三年%s趋势如何" % (co, m), [co], y, m, "T")
for i in range(42):
co = names[(i * 3 + 1) % 8]
m = ms[(i * 5 + 2) % 8]
y = YEARS[(i + 1) % 3]
if m == "营收增长率" and y == 2021:
y = 2022
alias_m = METRICS[m]["aliases"][-1]
intent = ["Q", "Q", "C", "T"][i % 4]
if intent == "Q" and i % 2 == 0:
add("L2", "%s的%d年%s呢" % (short[co], y, alias_m),
[co], y, m, "Q")
elif intent == "Q":
add("L2", "帮我查下%s在%d年的%s" % (short[co], y, alias_m),
[co], y, m, "Q")
elif intent == "C":
b = names[(i * 3 + 5) % 8]
add("L2", "%s跟%s相比,%d年谁的%s更高"
% (short[co], b, y, alias_m), [co, b], y, m, "C")
else:
add("L2", "说说%s这三年的%s变化" % (short[co], alias_m),
[co], y, m, "T")
rels = [("去年", BASE_YEAR - 1), ("前年", BASE_YEAR - 2)]
for i in range(30):
co = names[(i * 7 + 2) % 8]
m = ms[(i * 3 + 4) % 8]
rw, ry = rels[i % 2]
add("L3", "%s%s%s是多少" % (short[co], rw, m), [co], ry, m, "Q")
rng.shuffle(qs)
return qs
def parse(q, aliases, resolve_time):
text = q["text"]
found = []
for a, nm in sorted(ALIAS2CO.items(), key=lambda t: -len(t[0])):
if a in text and nm not in found:
found.append(nm)
m = None
for a, mm in sorted(aliases.items(), key=lambda t: -len(t[0])):
if a in text:
m = mm
break
y = None
if resolve_time:
for w, yy in REL_TIME.items():
if w in text:
y = yy
break
if y is None and "近三年" in text:
y = "multi"
if y is None:
for yy in YEARS:
if str(yy) in text:
y = yy
break
else:
hit = False
for yy in YEARS:
if str(yy) in text:
y = yy
hit = True
if not hit:
return found, None, m, "year"
if q["intent"] == "T":
y = "multi"
err = None
if q["intent"] in ("Q", "T") and len(found) != 1:
err = "entity"
elif q["intent"] == "C" and len(found) != 2:
err = "entity"
elif m is None:
err = "metric"
elif y is None:
err = "year"
return found, y, m, err
def evaluate(fin, questions, aliases, resolve_time):
n_ext = n_ans = 0
errs = {"entity": 0, "metric": 0, "year": 0}
for q in questions:
found, y, m, err = parse(q, aliases, resolve_time)
if q["intent"] == "C":
ok_co = sorted(found) == sorted(q["companies"])
else:
ok_co = found == q["companies"]
ok_ext = (ok_co and m == q["metric"]
and (q["intent"] == "T" or y == q["year"]))
if ok_ext:
n_ext += 1
else:
errs[err or "year"] += 1
if not ok_ext or q["intent"] == "T":
continue
if q["intent"] == "R":
if (industry_top(fin, y, m)
== industry_top(fin, q["year"], q["metric"])):
n_ans += 1
else:
vals = set()
for c2 in found:
vv = metric_value(fin, c2, y, m)
if vv is not None:
vals.add(round(vv, 9))
refs = set()
for c2 in q["companies"]:
rv = metric_value(fin, c2, q["year"], q["metric"])
if rv is not None:
refs.add(round(rv, 9))
if vals and vals == refs:
n_ans += 1
n = len(questions)
return n_ext / n * 100.0, n_ans / n * 100.0, errs
def main():
fin = gen_fin()
d = fin[("南岭白酒", 2023)]
chk = abs(d["asset"] - d["liab"] - d["equity"])
print("南岭白酒 2023 营收%.1f亿 毛利率%.1f%% 资产负债率%.1f%% "
"ROE%.1f%%" % (d["rev"], metric_value(fin, "南岭白酒", 2023,
"毛利率"),
metric_value(fin, "南岭白酒", 2023, "资产负债率"),
metric_value(fin, "南岭白酒", 2023,
"净资产回报率")))
d2 = fin[("申城微电", 2023)]
print("申城微电 2023 营收%.1f亿 毛利率%.1f%% 资产负债率%.1f%% "
"ROE%.1f%%" % (d2["rev"], metric_value(fin, "申城微电", 2023,
"毛利率"),
metric_value(fin, "申城微电", 2023, "资产负债率"),
metric_value(fin, "申城微电", 2023,
"净资产回报率")))
d3 = fin[("江浙医药", 2023)]
print("江浙医药 2023 营收%.1f亿 毛利率%.1f%% 资产负债率%.1f%% "
"ROE%.1f%%" % (d3["rev"], metric_value(fin, "江浙医药", 2023,
"毛利率"),
metric_value(fin, "江浙医药", 2023, "资产负债率"),
metric_value(fin, "江浙医药", 2023,
"净资产回报率")))
print("恒等式校验 %.2e" % chk)
top_g = industry_top(fin, 2023, "毛利率")
top_r = industry_top(fin, 2023, "净资产回报率")
top_o = industry_top(fin, 2023, "营收增长率")
print("2023 毛利率最高 %s (%.1f)" % (top_g, metric_value(
fin, top_g, 2023, "毛利率")))
print("2023 净资产回报率最高 %s (%.1f)" % (
top_r, metric_value(fin, top_r, 2023, "净资产回报率")))
print("2023 营收增长率最高 %s (%.1f)" % (
top_o, metric_value(fin, top_o, 2023, "营收增长率")))
qs = gen_questions()
lv = [sum(1 for q in qs if q["level"] == L) for L in ("L1", "L2", "L3")]
it = {k: sum(1 for q in qs if q["intent"] == k)
for k in ("Q", "C", "R", "T")}
print("问句集 %d 条 难度 L1/L2/L3=%s 意图 Q/C/R/T=%s"
% (len(qs), lv, [it[k] for k in ("Q", "C", "R", "T")]))
for ver, (al, rt) in (("v1", (V1_ALIASES, False)),
("v2", (FULL_ALIASES, False)),
("v3", (FULL_ALIASES, True))):
e, a, errs = evaluate(fin, qs, al, rt)
print("%s 抽取准确率 %.1f%% 计算正确率 %.1f%% 错误 %s"
% (ver, e, a, errs))
for ver, (al, rt) in (("v1", (V1_ALIASES, False)),
("v3", (FULL_ALIASES, True))):
row = []
for L in ("L1", "L2", "L3"):
sub = [q for q in qs if q["level"] == L]
e = evaluate(fin, sub, al, rt)[0]
row.append(e)
print("%s 分难度抽取 %s" % (ver, ", ".join(
"%s=%.1f%%" % z for z in zip(("L1", "L2", "L3"), row))))
if __name__ == "__main__":
main()