一个用 8.6MB 小模型 + 两千余行规则代码,把学术论文格式审查自动化的开源工具。
一、要解决的问题
学术论文的格式审查是一件很枯燥的事。教务处和导师手里都有一份格式规范:标题用几号字、摘要要空多少行、三级标题怎么编号、参考文献要不要悬挂缩进。审核的人拿着这份规范逐段核对,一篇几十页的论文要花掉大半天。
这件事能不能自动化?表面看是个 Word 操作问题,实际上分两半:
- 理解文档结构 —— 哪一段是标题、哪一段是摘要、哪一段是参考文献条目;
- 按规范校验/修正格式 —— 拿到结构之后,剩下的都是确定性工作。
第二半是纯粹的工程问题,第一半才是难点。因为”这段是不是三级标题”这种东西,没有任何可靠的规则可以判定。
WordFormat(pip install wordformat,Apache 2.0)就是围绕这两个问题做的。本文记录它的设计取舍和几个踩过的坑。
二、为什么把流程切成两段
大多数自动化工具的设计是”一键处理”。WordFormat 刻意没有这么做,而是把流程拆成两个可分离的阶段:
阶段一:生成结构 JSON 阶段二:校验 / 格式化───────────────────── ─────────────────────.docx → 模型分类 → JSON JSON + .docx → 逐节点比对格式 ↓ 加批注 或 直接修正对应到命令行:
wordf gj -d 论文.docx -c 配置.yaml # 阶段一:生成结构 JSONwordf 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", ...]package-data从通配收窄为显式白名单;- CI 移除模型缓存与下载步骤,避免旧模型经构建缓存混入;
- 新增
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_content | 20.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