简介:这份资源是面向深度学习与机器学习方向的期末大作业、毕业设计选题参考,聚焦自然语言处理在公文场景中的落地应用。项目以Python实现一套公文校对系统,可自动识别公文中的拼写、语法、格式及不规范表达,并给出定位提示与修改建议,帮助政府或企业提升办公自动化水平。压缩包共6个文件,包含4个py脚本、1个md说明与1个gitignore,整体约8KB,其中脚本分别承担样本下载、文本清洗与预处理、神经网络模型构建训练以及主控调度等职责,结构清晰、模块分工明确。目前已有66人学习下载。读者可借此掌握深度学习文本校对的基本流程,理解数据预处理、模型搭建与结果展示的衔接方式,并积累Python工程实践与版本控制经验,适合作为课程设计或入门NLP项目的参考方案。
1. 公文校对系统为什么值得用深度学习重做一遍
做过公文处理的人都有一个共识:最耗神的不是写,而是校。一份正式公文从起草到签发,要经过格式核验、错别字筛查、标点规范、称谓统一、数字用法核对、引用文件名称比对等七八道关卡。传统做法要么靠人工逐字过,要么用基于规则的正则脚本跑一遍——前者慢且容易疲劳漏检,后者维护成本高得离谱,换一个文种就得重写一批规则。
基于深度学习的公文校对系统,核心思路是用序列标注和文本分类模型替代大部分手写规则,让模型从大量已校对的公文语料中自动学到“什么是对的、什么是错的”。它适合两类人:一是机关、企事业单位里负责公文流转和审核的岗位,想用技术手段把重复劳动压下去;二是做 NLP 落地项目的工程师,想找一个语料相对规范、错误模式清晰、业务价值明确的场景练手。
这个方向不是学术玩具。公文语言有极强的格式约束和用词惯性,错误类型集中在错别字、多字漏字、标点误用、数字格式不一致、称谓不统一这几类,天然适合用深度学习做序列级检测。而且公文语料不像社交媒体文本那样噪声爆炸,预处理成本可控,模型收敛也相对稳定。下面从数据构造、模型选型、训练调参到部署排查,把这条链路拆开讲清楚。
2. 从公文语料到训练样本:数据构造的四个关键决策
2.1 为什么不能直接拿原始公文当训练集
原始公文是“正确文本”,模型需要的是“错误文本 → 正确文本”的配对数据。常见做法是:收集一批已定稿的公文作为正确语料,然后用程序自动注入错误,生成对应的错误版本。这个过程叫“错误注入”或“加噪”,是公文校对系统数据构造的核心环节。
错误注入不是随便改几个字就行。公文里的错误有很强的分布特征:形近字混淆(如“己/已/巳”)、同音字误用(如“权力/权利”)、标点中英文混用、数字大小写不一致、序号格式错误、发文字号格式偏差。如果注入的错误类型和真实场景偏差太大,模型学到的就是伪规律,上线后该漏的还是漏。
我一般会先统计一批真实校对记录,看看实际错误类型的占比,再按比例设计注入策略。比如某单位的校对记录里,标点类错误占 40%,错别字占 35%,数字格式占 15%,其他占 10%,那注入时就要大致对齐这个分布。
2.2 错误注入脚本怎么写才可控
下面是一个可复现的 Python 错误注入脚本框架,覆盖形近字替换、同音字替换、标点混用和数字格式扰动四类操作。
import random import re from typing import List, Tuple # 形近字混淆表:实际项目中应从真实校对记录中统计生成 CONFUSION_PAIRS = [ ("己", "已"), ("已", "己"), ("未", "末"), ("末", "未"), ("戊", "戌"), ("戌", "戊"), ("辨", "辩"), ("辩", "辨"), ("账", "帐"), ("帐", "账"), ("做", "作"), ("作", "做"), ] # 同音字混淆表 HOMOPHONE_PAIRS = [ ("权力", "权利"), ("权利", "权力"), ("必须", "必需"), ("必需", "必须"), ("制定", "制订"), ("制订", "制定"), ("截止", "截至"), ("截至", "截止"), ] # 标点混用:中文标点替换为英文标点 PUNCT_MAP = { ",": ",", "。": ".", ";": ";", ":": ":", "(": "(", ")": ")", "“": "\"", "”": "\"", } def inject_char_confusion(text: str, rate: float = 0.02) -> str: """按比例注入形近字/同音字错误""" chars = list(text) n = max(1, int(len(chars) * rate)) positions = random.sample(range(len(chars)), min(n, len(chars))) for pos in positions: for a, b in CONFUSION_PAIRS: if chars[pos] == a: chars[pos] = b break return "".join(chars) def inject_punct_error(text: str, rate: float = 0.05) -> str: """按比例将中文标点替换为英文标点""" result = [] for ch in text: if ch in PUNCT_MAP and random.random() < rate: result.append(PUNCT_MAP[ch]) else: result.append(ch) return "".join(result) def inject_number_format(text: str) -> str: """扰动数字格式:如将'一'改为'1',或将'1'改为'一'""" # 简化示例:只处理常见的小数字 num_map = {"一": "1", "二": "2", "三": "3", "四": "4", "五": "5"} for cn, ar in num_map.items(): if random.random() < 0.1: text = text.replace(cn, ar, 1) return text def build_pair(correct_text: str) -> Tuple[str, str]: """生成一条(错误文本, 正确文本)训练对""" noisy = correct_text noisy = inject_char_confusion(noisy, rate=0.015) noisy = inject_punct_error(noisy, rate=0.04) noisy = inject_number_format(noisy) return noisy, correct_text这段脚本的逻辑是:对每条正确公文,依次施加字符级混淆、标点扰动和数字格式扰动,生成对应的错误版本。rate参数控制注入密度,一般字符级错误率控制在 1%~2%,标点错误率控制在 3%~5%,太高会让模型学到过多噪声,太低则正负样本不均衡。
注意:混淆表必须从真实业务语料中统计生成,不要直接用网上的通用表。不同单位的公文用词习惯差异很大,通用表会导致注入的错误类型和实际场景脱节。
2.3 标注粒度选字符级还是词级
公文校对的主流做法是字符级序列标注。原因很直接:错别字、多字漏字、标点误用都是字符粒度的操作,词级标注反而会把边界问题搞复杂。标注体系一般用 BIO 或 BMES,标签集设计为:
| 标签 | 含义 | 示例 |
|---|---|---|
| O | 正确字符 | 正常文字 |
| B-ERR | 错误片段开始 | 错别字首字 |
| I-ERR | 错误片段中间 | 多字错误中间字 |
| E-ERR | 错误片段结束 | 错误片段末字 |
| S-ERR | 单字错误 | 单个错别字 |
如果系统还要输出“建议修改为什么”,那就需要额外维护一个错误类型分类头,或者在解码后接一个纠错候选生成模块。前者适合端到端训练,后者适合规则兜底。
2.4 训练集、验证集、测试集怎么切分才不泄漏
公文有一个特点:同一份文件的不同版本之间高度相似。如果按文件随机切分,同一个文件的不同段落可能同时出现在训练集和测试集里,导致指标虚高。正确做法是按“发文单位 + 时间窗口”做分组切分,确保同一份文件、同一批次的公文只出现在一个集合里。
常见比例是 8:1:1,但如果语料总量少于 5000 份,建议用 7:1.5:1.5,给验证和测试留足够样本。另外,测试集里要单独留出一批“真实错误”样本,不要全是注入错误,否则评估结果不能反映真实场景表现。
3. 模型选型与训练:从 BiLSTM-CRF 到 BERT 的落地取舍
3.1 序列标注模型的三个候选方案
公文校对本质是序列标注任务,常见选型有三个层次:
第一层是 BiLSTM-CRF。这是经典方案,参数量小,训练快,在语料规模几千条时就能跑出可用效果。优点是部署成本低,CPU 也能推理;缺点是对长距离依赖建模能力有限,遇到跨句错误(如前后称谓不一致)就力不从心。
第二层是 BERT + CRF。用预训练语言模型做编码器,接 CRF 解码层。这是目前公文校对的主流方案,在万条级语料上微调后,错别字检测的 F1 通常能到 0.90 以上。代价是推理需要 GPU,或者用蒸馏后的小模型。
第三层是 BERT + BiLSTM + CRF。在 BERT 输出后加一层 BiLSTM 增强序列建模,适合错误模式复杂、跨片段依赖多的场景。但参数量更大,训练时间更长,收益提升有限,一般项目用第二层就够了。
我一般会先用 BiLSTM-CRF 跑一个 baseline,确认数据质量和标注体系没问题,再上 BERT 微调。如果 BERT 相比 baseline 提升不到 5 个点,说明数据本身有问题,不是模型容量不够。
3.2 用 HuggingFace 跑通 BERT 序列标注的最小命令
下面是一个基于transformers库的 BERT 序列标注训练脚本核心部分,假设已经准备好了train.json和dev.json,格式为[{"text": "...", "labels": [...]}, ...]。
import json import torch from torch.utils.data import Dataset, DataLoader from transformers import ( BertTokenizerFast, BertForTokenClassification, AdamW, get_linear_schedule_with_warmup, ) LABEL_LIST = ["O", "B-ERR", "I-ERR", "E-ERR", "S-ERR"] LABEL2ID = {l: i for i, l in enumerate(LABEL_LIST)} ID2LABEL = {i: l for l, i in LABEL2ID.items()} class DocDataset(Dataset): def __init__(self, path, tokenizer, max_len=512): with open(path, "r", encoding="utf-8") as f: self.samples = json.load(f) self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.samples) def __getitem__(self, idx): sample = self.samples[idx] text = sample["text"] labels = sample["labels"] encoding = self.tokenizer( text, max_length=self.max_len, padding="max_length", truncation=True, return_tensors="pt", ) # 对齐标签:tokenizer 可能切分字符,需要按首字符对齐 label_ids = [] for i, word_ids in enumerate(encoding.word_ids(0)): if word_ids is None: label_ids.append(-100) # 特殊 token 忽略 else: label_ids.append(LABEL2ID.get(labels[word_ids], LABEL2ID["O"])) encoding["labels"] = torch.tensor([label_ids]) return {k: v.squeeze(0) for k, v in encoding.items()} def train(): tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese") model = BertForTokenClassification.from_pretrained( "bert-base-chinese", num_labels=len(LABEL_LIST), id2label=ID2LABEL, label2id=LABEL2ID, ) train_dataset = DocDataset("train.json", tokenizer) dev_dataset = DocDataset("dev.json", tokenizer) train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True) dev_loader = DataLoader(dev_dataset, batch_size=32) optimizer = AdamW(model.parameters(), lr=3e-5, weight_decay=0.01) total_steps = len(train_loader) * 5 scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps, ) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) for epoch in range(5): model.train() total_loss = 0 for batch in train_loader: batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) loss = outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item() print(f"Epoch {epoch+1}, loss: {total_loss / len(train_loader):.4f}") # 保存模型 model.save_pretrained("./doc_proofread_model") tokenizer.save_pretrained("./doc_proofread_model") if __name__ == "__main__": train()这段代码的关键点有三个:一是word_ids()对齐,因为 BERT 的 tokenizer 可能把一个汉字切成多个 subword,标签必须按首字符对齐,否则训练信号会错位;二是-100忽略特殊 token 的损失,这是 PyTorch 交叉熵的默认忽略值;三是clip_grad_norm_防止梯度爆炸,BERT 微调时学习率 3e-5 是比较稳的起点。
3.3 学习率和 batch size 怎么调
BERT 微调的学习率一般设在 2e-5 到 5e-5 之间。公文校对任务的数据量通常不大(几千到几万条),学习率太高容易过拟合,太低则收敛慢。我一般先用 3e-5 跑一轮,看验证集 F1 曲线,如果震荡厉害就降到 2e-5,如果 5 个 epoch 还没收敛就升到 4e-5。
batch size 受显存限制,单卡 16GB 显存下,bert-base-chinese+ max_len=512 大概能跑 batch_size=16。如果显存不够,可以用梯度累积:batch_size=8,累积 2 步等效于 16。注意梯度累积时 scheduler 的步数要按实际优化步数算,不是按前向次数算。
3.4 损失函数与多任务扩展
如果系统除了检测错误位置,还要分类错误类型(错别字/标点/数字格式),可以在 BERT 输出后接两个头:一个序列标注头输出 BIO 标签,一个分类头输出错误类型。两个损失按权重相加,常见权重是 1:0.3 到 1:0.5。序列标注损失用交叉熵,分类损失也用交叉熵,但要注意分类头的标签只在错误位置上有意义,正确位置不参与分类损失计算。
提示:多任务训练时,如果两个任务的收敛速度差异大,可以先冻结分类头训练序列标注,等序列标注稳定后再解冻联合训练。这样比一上来就联合训练更稳。
4. 避坑与排查:公文校对系统上线前必须过的五道关
4.1 模型在测试集上 F1 很高,上线后漏检严重
现象:离线评估 F1 到 0.95,实际用一批新公文测试,漏检率明显偏高。
原因:测试集和训练集来自同一批语料,错误注入策略也一致,模型学到的是注入模式的规律,而不是真实错误的规律。真实公文里的错误类型和注入错误分布不一致。
解决:单独构造一批“真实错误”测试集,从历史校对记录里抽取实际发生过的错误,不经过注入脚本。用这批数据重新评估,如果 F1 掉到 0.7 以下,说明模型泛化有问题,需要补充真实错误样本重新训练。
4.2 标点错误检测效果差,模型总把正确标点判成错误
现象:标点类错误的精确率很低,大量误报。
原因:公文里标点用法本身就有灵活性,比如书名号内外的标点、括号前后的标点,不同文种要求不同。模型从语料里学到的“正确模式”可能和当前文种的要求冲突。
解决:把标点检测从序列标注里拆出来,单独做一个规则+模型混合模块。规则层处理格式明确的标点规范(如中英文标点不能混用),模型层只处理上下文相关的标点选择。这样误报率能降一半以上。
4.3 长公文推理时显存溢出
现象:处理超过 1000 字的公文时,GPU 显存爆了。
原因:BERT 的 max_len 设成了 512,但实际公文可能超过这个长度。如果直接截断,后半部分检测不到;如果不截断,显存不够。
解决:用滑动窗口推理。把长文本按 512 字切窗,窗口之间有 64 字重叠,每个窗口单独推理后合并结果。重叠区域取置信度最高的标签。这样既能处理长文本,又不会显存溢出。注意合并时要处理边界标签的连续性,避免在窗口边界处产生假错误。
4.4 模型对数字格式错误不敏感
现象:公文中“3 月 15 日”写成“三月十五日”或“3月15号”,模型检测不出来。
原因:数字格式错误在训练语料里占比低,模型学到的样本不够。而且数字表达方式多样,注入脚本覆盖不全。
解决:单独构造数字格式错误的增强样本,把常见数字表达变体都注入进去。同时在解码后加一层规则校验,用正则匹配数字格式,模型没检出的由规则兜底。规则和模型是互补关系,不是替代关系。
4.5 部署后推理速度慢,单份公文要等好几秒
现象:GPU 推理单份 500 字公文耗时超过 2 秒,批量处理时吞吐量上不去。
原因:模型没有做推理优化,每次都是全精度前向。BERT base 模型参数量约 1 亿,全精度推理确实不快。
解决:用 ONNX Runtime 或 TensorRT 做推理加速,把模型导出为 ONNX 格式后,推理速度通常能提升 2~3 倍。如果对精度要求不苛刻,还可以用知识蒸馏把 BERT 蒸馏成 6 层的小模型,推理速度再提升一倍,F1 掉 2~3 个点,多数场景可以接受。
5. 进阶技巧:用主动学习把人工校对成本压到最低
5.1 主动学习的核心逻辑
公文校对系统上线后,最大的成本不是训练,而是持续标注新数据。主动学习的思路是:让模型挑出“最不确定”的样本,只标注这些样本,用最少的标注量换取最大的模型提升。
具体做法是:对未标注公文跑一遍模型推理,计算每个样本的置信度(比如序列标注中所有 token 的最大 softmax 概率的平均值),把置信度最低的一批样本挑出来,送人工校对,校对结果作为新训练数据。这样每轮只标注几百条,就能带来可感知的提升。
5.2 不确定度采样策略对比
| 策略 | 计算方式 | 适用场景 | 注意事项 |
|---|---|---|---|
| 最小置信度 | 取所有 token 最大概率的最小值 | 错误集中、边界清晰 | 对长文本敏感,容易被单个低置信 token 拉低 |
| 平均置信度 | 取所有 token 最大概率的平均值 | 通用场景 | 对短文本区分度不够 |
| 边际采样 | 最大概率与次大概率的差值 | 多分类任务 | 计算量稍大 |
| 熵采样 | 预测分布的熵 | 类别不均衡场景 | 对标签噪声敏感 |
我一般用平均置信度做初筛,再用最小置信度做二次排序,取交集送标。这样既能覆盖整体不确定的样本,又能抓住局部高风险片段。
5.3 一轮主动学习的完整操作流程
第一步,用当前模型对未标注语料推理,输出每个样本的平均置信度和最小置信度。第二步,按平均置信度升序排列,取前 500 条;再按最小置信度升序排列,取前 500 条;取并集去重,得到约 600~800 条候选。第三步,人工校对这批候选,修正模型误报和漏报。第四步,把校对后的数据加入训练集,重新微调模型。第五步,在固定测试集上评估,如果 F1 提升超过 1 个点,继续下一轮;如果提升停滞,说明当前数据分布已经覆盖得差不多,可以降低采样频率。
这个流程跑三轮左右,通常能把人工校对量减少 60% 以上,而模型 F1 还能保持上升。关键是每轮都要用同一批测试集评估,否则无法判断提升是来自数据还是随机波动。
5.4 一个容易忽略的细节:错误类型平衡
主动学习挑出来的样本往往集中在模型最不擅长的错误类型上。如果某一类错误(比如标点)本身占比就低,模型对它置信度普遍偏低,主动学习会反复挑这类样本,导致训练集里标点错误过采样。解决方法是按错误类型分层采样,每类错误设一个采样上限,保证训练集里各类型错误的相对比例不失控。
我自己的习惯是每轮主动学习后,先统计一下新标注数据里各错误类型的占比,如果某一类超过 50%,就手动降采样,从其他类型里多补一些。这个动作看起来琐碎,但不做的话,模型很快会偏科。
希望帮到你。
本文还有配套的精品资源,点击获取