4560 字
23 分钟
让程序读懂论文结构:WordFormat 的实现与踩坑

一个用 8.6MB 小模型 + 两千余行规则代码,把学术论文格式审查自动化的开源工具。


一、要解决的问题#

学术论文的格式审查是一件很枯燥的事。教务处和导师手里都有一份格式规范:标题用几号字、摘要要空多少行、三级标题怎么编号、参考文献要不要悬挂缩进。审核的人拿着这份规范逐段核对,一篇几十页的论文要花掉大半天。

这件事能不能自动化?表面看是个 Word 操作问题,实际上分两半:

  1. 理解文档结构 —— 哪一段是标题、哪一段是摘要、哪一段是参考文献条目;
  2. 按规范校验/修正格式 —— 拿到结构之后,剩下的都是确定性工作。

第二半是纯粹的工程问题,第一半才是难点。因为”这段是不是三级标题”这种东西,没有任何可靠的规则可以判定。

WordFormat(pip install wordformat,Apache 2.0)就是围绕这两个问题做的。本文记录它的设计取舍和几个踩过的坑。


二、为什么把流程切成两段#

大多数自动化工具的设计是”一键处理”。WordFormat 刻意没有这么做,而是把流程拆成两个可分离的阶段:

阶段一:生成结构 JSON 阶段二:校验 / 格式化
───────────────────── ─────────────────────
.docx → 模型分类 → JSON JSON + .docx → 逐节点比对格式
↓
加批注 或 直接修正

对应到命令行:

Terminal window
wordf gj -d 论文.docx -c 配置.yaml # 阶段一:生成结构 JSON
wordf tree -f 结构.json # 看一眼分类对不对
wordf cf -d 论文.docx -c 配置.yaml -f 结构.json # 阶段二:校验(只加批注)
wordf af -d 论文.docx -c 配置.yaml -f 结构.json # 阶段二:直接改

为什么这么切? 因为分类不可能 100% 准确。如果做成全自动黑箱,模型错判一段,用户只会看到一个错误的格式化结果,既不知道错在哪,也没法纠正。

把中间产物暴露出来之后,流程就变成可干预的:跑完阶段一看一眼树形结构,发现哪段判错了直接改 JSON,再跑阶段二。代价是多了一步操作,换来的是错误可定位、可修正。

这个取舍贯穿了整个项目:与其追求全自动,不如把不确定性显式暴露给用户。


三、为什么用分类模型,而不是规则或大模型#

不用纯规则#

论文里确实有很多强格式信号:关键词: 开头的多半是关键词段、图 1.1 开头的是图注、[1] 开头的是参考文献条目。这些都用规则处理,而且比模型可靠得多。

但”这是一级标题还是二级标题”没有可靠规则。有的论文写 1 绪论,有的写 第一章 绪论,有的干脆不编号。规则会迅速退化成一张越来越长的正则表,而且互相打架。

不用大模型#

调用 API 的问题很明显:论文通常是未发表的,走外部服务有隐私顾虑;很多高校场景在内网离线环境;再加上逐段调用几十上百次,成本和延迟都不合适。

用小 BERT#

最终选择是 uer/chinese_roberta_L-4_H-256 —— 只有 4 层、隐藏维度 256 的极小 RoBERTa,量化到 int8 后 8.6MB,CPU 上就能跑。

理由是这个任务本身不难:分类依据主要是段落的形式特征(开头词、有没有编号、句子结构),不需要模型理解深层语义。用大模型是浪费。

这里有个关键判断:这是个分类问题,不是生成问题。 一旦认清这一点,就不需要 LLM。


四、把分类器用成序列标注器#

这是整个项目里我最喜欢的设计。

问题#

逐段独立分类会丢掉一个强信号:论文结构是序列相关的。摘要标题后面大概率是摘要内容,参考文献标题后面大概率是参考文献条目。只看当前段落,这些上下文全浪费了。

方案:把上一段的标签塞进输入#

没有上序列模型(CRF / BiLSTM),而是用了一个很取巧的办法:把上一段的标签作为文本前缀拼进输入。

训练时,每条样本的格式是:

[PREV=heading_level_1] 1.1 研究背景

推理时用完全相同的格式拼接:

def _prompt(prev_label: Optional[str], text: str) -> str:
"""拼接 PREV 上下文前缀,与训练侧 PREV_PREFIX 格式严格一致。"""
return f"[PREV={prev_label}] {text}"

首段的 prev_label 是 None,拼出来就是 [PREV=None] 论文题目。

这样模型看到的输入里就带上了结构上下文,而代价只是文本长了十来个 token。

两个必须注意的细节#

第一,推理时用的是”预测标签”而不是真值。

真值只有训练时才有。推理时必须拿上一段的预测结果去拼下一段的输入 —— 这就构成了一条状态链,也意味着误差会沿链传播:前面一段判错,可能把后面几段一起带偏。

这是典型的 exposure bias(训练时见真值、推理时见预测)。在这个任务里可以接受,因为绝大多数段落分类是高置信的,链断掉的概率不高;而且它确实带来了显著的收益。

第二,批量推理和状态链是冲突的。

批量推理要求整个 batch 一次算完,但 batch 内每个样本的 PREV 理论上都不同(取决于前一段的预测结果)。实际做法是:整个 batch 共享同一个 PREV 上下文(即 batch 首段之前的标签),每个 batch 结束后返回末段的预测标签,作为下一个 batch 的初始 PREV。

def onnx_batch_infer(texts, prev_label=None):
"""批量推理。整个 batch 共享同一 PREV 上下文(跨批链由调用方推进),
返回 (结果列表, 本批末段的预测标签)。"""

外层再包一个 onnx_batch_infer_safe,负责自动分片并跨分片维护状态链。

这是在”批量吞吐”和”上下文精度”之间取的折中:batch 内退化成无上下文,batch 间保持链式。把 BATCH_SIZE 从 32 调大能提速,但会削弱上下文效果 —— 这个参数需要按实际场景权衡。


五、模型看文本,但结构信息在位置上#

这一节是项目里最有教育意义的一次翻车。

现象#

为了让长尾类别(样本不足的类别)补齐形态,我们用规则模板 + 词表组合合成了 880 条训练数据,重训后留出集准确率从 96.27% 涨到 98.11%,长尾类别 abstract_chinese_content 从 20% 直接到 100%。

看起来皆大欢喜。但端到端跑真实文档时出问题了:某篇论文的 4 段绪论正文被吸进了”中文摘要内容”类别。

被误判的段落长这样:

紧跟人工智能和大数据技术的发展步伐,围绕智能电网……

根因#

摘要的开场句和绪论首段在词面上本质同构。两者都常用”随着……的发展""本文针对……”这类句式。模型只看文本,没有任何依据区分它们 —— 无论是合成数据还是真实数据都救不了,因为问题不在数据量,在于信息不足。

继续加合成数据已经没有边际收益了。

解法:位置门控#

真正的约束是位置:摘要内容只可能出现在摘要标题之后。

于是在后处理链里加了一条规则:

_gate_abstract_content(result)
# 规则:abstract_chinese_content / abstract_english_content
# 的相邻上一段必须是摘要标题或摘要内容,否则回退 body_text

一条几行的规则,把 4 段绪论全部归位,摘要内容从误判的 6 段修正回真实的 2 段。

教训#

模型负责语义,规则负责结构位置,这条边界要划清楚。

当模型面对的信息不足以区分两个类别时,不要继续喂数据 —— 先想清楚这个区分到底需要什么信息。如果那个信息不在文本里(而在文档结构中),那么它就不该由模型来学。


六、后处理链:一个不断长出来的规则层#

上一节的”位置门控”不是孤例。base.py 里现在有一条 12 步的后处理链,在模型推理之后无条件执行:

_fix_document_title(result) # 文档标题位置规则
_fix_abstract_en_title(result) # 英文摘要标题
_fix_known_categories(result)
_fix_abstract_titles(result) # 摘要标题提升
_gate_abstract_content(result) # 摘要内容位置门控
_apply_footer(result)
_fix_toc(result) # 目录
_neutralize_appendix(result) # 附录中性化
_apply_section_state(result) # 章节状态机
_fix_sequence(result) # 标签序列约束
_fix_references_content(result) # 参考文献位置门控
_fix_heading_levels(result) # 标题层级正则校准

这些规则几乎全部来自”端到端跑真实文档 → 发现问题 → 补一条规则”的循环。举两个例子:

_fix_heading_levels —— 模型经常混淆二级和三级标题。但标题的层级其实写在编号里:1.1 必然是二级,1.1.1 必然是三级。所以用正则从编号反推层级,只在模型已经判定为标题的段落上生效(避免误伤正文):

_HEADING_NUM_RE = re.compile(r"^(\d{1,2}(?:\.\d{1,2}){0,2})(?![.\d])(?:[、..]?\s*\S)")

_neutralize_appendix —— 附录里经常贴源码,而源码里的 import xxx / class Xxx: 被模型大量误判为英文摘要、关键词。解法是识别出”附录”这个区域之后,把区域内的前置类标签强制回退为 body_text。

这条链的代价#

规则确实有效 —— 它们把留出集 98% 的模型准确率,在端到端场景里推到了可用的程度。

但代价是:这 12 步规则和”学术论文”这个领域强绑定。 _apply_section_state 靠”参考文献""致谢”这些标题词驱动,_gate_abstract_content 靠”摘要”这个概念存在。换个文体(比如公文),它们不是”不生效”,而是会产生错误的负收益。

这也是项目当前面临的主要架构问题:这些领域知识散落在核心解析代码里,没有一个可替换的边界。改进方向是把「类别体系 + 阈值 + 后处理规则 + 模型权重」打成一个可替换的整体单元,而不是继续往这条链上加规则。


七、踩过的坑#

7.1 节点-段落错位:zip 的静默失败#

这是最典型的一个。

阶段二需要把”结构树里的节点”和”文档里的段落”对应起来。做法是按位置顺序 zip:

for node, para in zip(nodes, paragraphs):
node.paragraph = para

问题在于:只要两者数量不等,zip 不会报错,它会静默截断。而一旦数量不等,错位不是少一个,是后面全部前移一位 —— 每个节点都套用了邻居的段落。

症状非常诡异:

  • 正文被加上了 图1.1 的前缀(图注的编号规则套到了正文上)
  • 中文关键词的批注出现在论文题目上
  • 标题层级规则作用到了参考文献上

排查了很久才定位到根因之一:前端在数据层过滤掉了 figure_image 占位节点,导致前端看得到的节点数比后端实际生成的少。前端为了”列表干净”做了一次过滤,代价是整个对齐关系崩了。

修复分两头:

if len(nodes) != len(paragraphs):
raise ValueError(
f"当前文档({len(paragraphs)} 段)与节点 JSON({len(nodes)} 节点)不一致,"
"两者必须一一对应。可能是生成节点后又换过文档,"
"或页面版本过旧(旧版会过滤占位节点)。"
"请刷新页面后重新上传文档并点击「生成节点JSON」,再执行格式化。"
)
  • 后端:数量不等直接报错,并给出可操作的指引,绝不容忍静默错位;
  • 前端:过滤从数据层挪到显示层,占位节点照常存在,只是不显示。

教训:在”按位置对齐”这个前提下,任何一方悄悄改变元素数量都是灾难。 而且这类 bug 的症状离根因非常远,会浪费大量排查时间 —— 所以宁可显式崩溃,也不要静默降级。

7.2 一个阈值管不了 22 个类#

早期的前端用一个全局阈值判断”这个分类结果可不可信”:SCORE_THRESHOLD = 0.8。

结果是 UI 上满屏”阈值过低”的告警。原因是不同类别的置信度分布差异极大:body_text 这种主力类别经常 0.99,而 heading_fulu(附录标题)这种长尾类别普遍偏低。

用一个从模型分数分布里实测出来的分级阈值表替代:

# 以留出集上"每类正确段"的 p10 分为准分档(0.35 ~ 0.6)
CONF_THRESHOLDS = {
body_text: 0.6,
heading_level_1: 0.6,
...
abstract_english_content: 0.35,
heading_fulu: 0.35,
default: 0.5,
}

效果很直接:

指标改前改后
UI 标记率44.9%2.06%
正确段被误标—1.64%

教训:置信度分布是类别相关的,全局阈值必然错。 而且阈值不该拍脑袋定,要从留出集的真实分数分布里取分位数。

7.3 体积:69MB → 6MB#

1.6.1 之前,发布包体积异常膨胀到 69MB。

根因在打包配置:package-data 用了通配表达式 data/**/*,把整个目录无差别收进包里。开发过程中旧模型(99MB 的旧 BERT、中间 checkpoint、fp32 原件)在磁盘上留有残留,于是全被打进了发布包。

修复用了三层防御:

[tool.setuptools.package-data]
# 只收集明确清单,绝不使用 `data/**/*` 通配收集整个目录:
# 磁盘上残留的旧模型(bert_paragraph_classifier.onnx 等)会被无差别打进包。
wordformat = [
"data/model/*_int8.onnx",
"data/model/id2label.json",
...
]
  1. package-data 从通配收窄为显式白名单;
  2. CI 移除模型缓存与下载步骤,避免旧模型经构建缓存混入;
  3. 新增 MANIFEST.in 防御 sdist 混入开发资产。

结果:69MB → 6MB。

顺带把 download_model.py 从”下载脚本”退役成”模型存在性检查”,并在里面留了守卫逻辑 —— 任何人误运行它,只会得到一句提示,不会真的去拉那个 99MB 的旧模型。

教训:打包配置必须显式。通配符会收集你没打算发布的东西,而且这个问题在开发机上永远不会暴露。


八、模型是怎么练出来的#

数据规模不大:34 篇论文 / 15493 个段落 / 22 个类别,其中 13 个长尾类别的真实样本少于 120 条,最少的只有 3 条。

第一轮训练后,留出集(6 篇未见论文,1798 段)整体准确率 96.27%,看起来不错。但长尾类别暴露了严重问题:abstract_chinese_content 只有 20%,5 个样本错了 4 个,全部被误判成正文。

关键判断:根因不是数量不足,是形态不足。

那 9 条真实样本基本都是同一种写法。而漏判的样本是”总结评价式摘要”:

本文从制造工艺和经济效益两方面对 ECVT 变速器进行系统的评价

训练集里根本没有这种写法。所以上采样(复制样本)没有用,必须补充形态。

于是写了合成脚本,用规则模板 + 词表随机组合产出 880 条数据。合成时有个值得注意的配比细节:

摘要内容主体强制”本设计/本文/本研究”句式;注入总结评价式模板(短板形态);背景引入句(“随着…发展”)仅 15% 概率 —— 与绪论首段词面同构,多给会把正文吸进摘要。

这个 15% 就是第五节那次翻车的伏笔。合成数据把”随着…发展”这种句式大量注入摘要类别,模型就学会了把所有这种句式判成摘要。

重训结果:

指标旧模型新模型
留出集整体准确率96.27%98.11%
abstract_chinese_content20.0%100.0%
准确率 <90% 的类别数3 个0 个

模型产物:fp32 33.6MB → int8 8.6MB。训练 3 个 epoch,loss 从 3.75 降到 0.96。


九、一些工程数字#

项目数值
Python 源码~10400 行
测试代码~13300 行 / 1073 项测试
分类类别22 类
模型体积int8 ONNX 8.6MB(随包分发,无需下载)
推理配置BATCH_SIZE 32,intra-op 2 / inter-op 1
最大序列长度128

几个设计上值得一提的点:

  • 模型随包分发。曾经走过”从 GitHub Release 下载模型”的路,已退役 —— 离线环境不可用,而且版本容易和代码脱节。现在模型直接入库,pip install 即可用。
  • 两阶段流程让用户可以在中间干预,而不是接受一个黑箱结果。
  • 显式失败优于静默降级,7.1 的教训。

十、结语#

回头看,这个项目里真正的难点从来不是”训练一个模型”。

模型部分反而最简单:任务清晰、数据规模小、4 层的小模型就够用。真正花时间的是那些边界问题 ——

  • 模型能看文本,但结构信息在位置上(第五节)
  • 批量推理和状态链天然冲突(第四节)
  • 按位置对齐时,任何一方悄悄改变元素数量都是灾难(7.1)
  • 置信度分布是类别相关的(7.2)
  • 打包配置的通配符会收集你没打算发布的东西(7.3)

每一个都是”跑起来才发现”的问题,每一个的修复方案都不复杂,但定位它们需要反复地端到端验证而不是只看离线指标。

留出集 98% 的准确率很漂亮,但真正让工具可用的是那 12 步看起来一点都不优雅的后处理规则。这是个不太浪漫但很真实的结论:在真实场景里,模型和规则的边界,比模型本身更值得花心思。


项目地址:github.com/AfishInLake/WordFormat · Apache License 2.0 · pip install wordformat

让程序读懂论文结构:WordFormat 的实现与踩坑
https://fuwari.vercel.app/posts/ai/beat/wordformat/
作者
江湖一条鱼
发布于
2026-09-22
许可协议
CC BY-NC-SA 4.0