简介:面向深度学习与自然语言处理学习者的公文校对系统源码包,适合期末大作业、毕业设计或机器学习实践。压缩包共六个文件,包含四个Python脚本、一个Markdown说明文档与一个忽略配置文件,整体容量仅八KB,代码结构精简清晰。系统按功能拆分为数据下载、文本清洗、模型训练和主控校对等模块,完整覆盖从语料获取到错误提示的深度学习校对流程;阅读源码可掌握数据预处理、神经网络构建和推理调优的关键技巧,并理解各脚本间的调用关系。项目自带说明文档,方便快速搭建运行环境并定位各模块职责,亦可作为课程项目或毕业设计的参考模板。目前已有六十五人学习,适合希望在实际场景中落地深度学习技术的开发者继续扩展完善。
1. 从“查词表”到“读上下文”:公文校对系统为什么绕不开深度学习
单位文秘把“截至6月30日”写成“截止6月30日”,退回后才发现。传统校对工具靠词表,能抓“帐号→账号”这类固定错词,但抓不住“截止/截至”这类依赖上下文的混用,因为单独看每个词都合法。这就是我为什么从规则引擎迁到基于深度学习的公文校对系统:让模型读整句,在序列层面判断哪个字该改。标题里这个zip包,代表了一类由深度模型承载的校对方案,常见于NLP实战项目和毕业设计。这篇笔记按“任务拆分→模型选型→训练复现→避坑→上线调优”的顺序讲,适合要动手实现或二次开发的读者。
2. 任务边界与选型:这个Zip里到底在解决什么问题
拿到zip先别急着解压跑训练。先想清楚一件事:公文校对这个需求,在技术层面被拆成了几个难度完全不同的问题。只有把任务边界画清楚,才知道哪部分该用深度学习,哪部分用规则更快,哪部分根本不该让模型碰。
2.1 先分清四件事:错别字、语病、标点、格式里,深度学习负责哪几件
公文校对通常面对四类问题:
- 错别字:包括音近字、形近字、输入法造成的同音替换,比如“账号/帐号”“截止/截至”。这类问题有明确的对错边界,适合用深度模型做。
- 语法错误:搭配不当、成分残缺、语序颠倒。这类问题主观性强,“感觉不对”和“确实错了”之间没有清晰阈值。
- 标点错误:逗号句号误用、书名号缺失。边界相对清楚,但模型容易误伤。
- 格式规范:文种、字号、落款、附注位置。这是确定性规则,与语言模型无关。
我的分法是:深度学习只负责“错别字”这一层的检测与纠正,语法错误交给规则模板辅助,标点错误用规则约束模型输出,格式规范另写校验器。原因很简单,公文是低容错场景,你不可能让模型替你把一整句“重写”一遍,哪怕它有1%的概率改动正常措辞,审核人员也会把系统打进冷宫。所以生产环境里做的是“最小编辑”:模型只在错字位置打点,改哪个字还要候选集和阈值把最后一道关。
把任务分层还有个额外的好处,验收指标可以分项定。错别字的召回率做到90%,标点规则做到100%,语法错误做到“不误报”,每个模块单独测,谁出问题换谁。如果一开始就指望一个大模型把四件事全包了,出了问题你连定位都难。
2.2 检测、纠正、重写:三种建模思路和它们的适用场景
把校对任务落到建模,业内基本是三条路线,我把它们的边界和代价列出来:
| 建模方式 | 输入输出 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 端到端生成(T5/BART) | 错句→对句 | 能处理插入、删除等不等长错误 | 解释性差,容易过度改写,训练数据量大 | 口语文本、OCR后处理 |
| 检测+掩码纠正(BERT类) | 错句→错误位置→掩码预测 | 改动最小、可解释,可叠加规则 | 对多字/少字类错误支持弱 | 公文、法律文书等低容错场景 |
| 检索式候选(混淆集+拼音/字形相似度) | 候选字列表 | 快、可控、无需GPU | 依赖混淆集质量,新词无候选 | 词表校验、前端预筛 |
实际落地的常见做法是第三种做召回,第二种做排序,第一种做兜底。我一般只保留“检索召回+掩码排序”,公文场景很少走到端到端生成这一步,因为审核人员需要知道“模型为什么改这个字”,而BERT的注意力权重比T5的生成轨迹更容易解释。
这里有个选型问题值得单独说:既然序列标注是传统NLP的经典方案,为什么不用BiLSTM-CRF?两个原因。第一,公文语料规模通常只有几万到十几万句,BiLSTM-CRF在这种小数据上不稳定,字符级标注很容易过拟合;第二,BERT的预训练知识里已经包含大量汉字搭配关系,微调所需样本量比从零训练LSTM少一个数量级。所以哪怕模型体积大一点,我也优先选BERT,而不是纠结于“更经典的”序列标注模型。
2.3 训练数据怎么凑:公开语料、公文样本和混淆集增强
深度学习模型不吃“词表”,吃的是“错误对”。你需要成对的句子:原句是错句,目标句是对句。数据来源大概有三路:
- 公开数据集:中文拼写纠错常用SIGHAN系列评测数据,里面有字级标注;NLPCC也有相关评测任务。这些数据能帮模型学会通用纠错,但公文用语覆盖不足。
- 领域数据:自己收集通知、请示、报告、函件等公文样本,用程序注入错误生成训练对。常见做法是拿一份正确语料,按一定比例把字替换成混淆集中的字。
- 混淆集:这是整个系统的手工知识底座。混淆集里收录音近、形近、常见输入法易错字对。质量比数量重要,一份500对的混淆集往往比随机替换生成的10万条数据更有效。
构造训练对时要注意两点。第一,错误注入要模拟真实来源:同音替换、拼音输入多按一格的错位、五笔形近字,而不是从字典里完全随机抽一个不相干的字。第二,必须保留大量全对句子做负样本,否则模型学到的偏差是“见谁改谁”,后面第4章会专门说这个坑。
数据清洗也别省。我一般过三条规则:过滤含繁体字和异体字的样本,避免模型学到繁简混杂;过滤原句和目标句完全相同的冗余样本,这类样本除了拉低负样本质量没有别的作用;过滤标点被批量篡改的脏样本,特别是从爬虫语料里带出来的英文标点混用问题。这三条不做好,训练过程会出现各种诡异的指标波动。
2.4 解开Zip之后:典型目录结构与第一个运行时注意
这类项目解压之后,目录结构基本逃不开四个文件夹:data放训练语料和混淆集,models放预训练权重和微调检查点,src放数据预处理和训练推理脚本,config放超参数和路径配置。如果你打开zip发现只有一个“源码”目录而没有data和models,也别意外,很多项目因为体积原因不打包权重,你需要按README里的路径自行下载。
注意:解压时如果报“伪加密(CRC Error)”,多半是压缩工具设置错了加密标志位,文件本身并没有真正加密。用7-Zip的“修复压缩包”功能或命令行把加密标志位清掉就能正常解压,这不是密码破解问题,别在密码上浪费时间。
把zip内容跑通之前,先改config里的路径和模型名,不要一上来就训练。先拿一条已知错误样本跑推理脚本,确认模型能出结果,再谈微调。这个习惯能帮你过滤掉一多半“环境没配好”的早期问题。预训练模型我优先选中文全词掩码版本,比如RoBERTa-wwm-ext,它在中文拼写纠错上的表现通常比原版BERT稳定,而且加载方式和BERT完全一致,改动成本只是一行模型名。
3. 最小可复现链路:用BERT把“检测+纠正”一次跑通
下面这套链路是我在实践中用着最稳的最小方案:BERT负责两件事,一是输出每个位置是错字的概率,二是在错字位置做掩码预测。整个流程从数据准备到推理,都在一个conda环境里跑通,新手也能照着敲完。
3.1 环境准备:把深度学习环境配置到能训练的最小集合
先建一个干净的conda环境,避免把系统Python搞乱:
conda create -n csc python=3.10 -y conda activate csc pip install torch --index-url https://download.pytorch.org/whl/cu118 pip install transformers datasets accelerate参数说明:csc是我给“Chinese Spelling Correction”起的名字;python=3.10是当前transformers和torch生态兼容性最好的版本之一;torch要按你的CUDA版本选择index-url,我给的cu118对应CUDA 11.8,如果你机器是12.x就换成cu121。训练这块如果只有CPU,小规模数据还能跑,但BERT-base在CPU上单条推理就要几百毫秒,体验很差,建议至少有一张6GB以上显存的GPU,或者租个深度学习云平台上的单卡实例。
装完验证一下:
python -c "from transformers import BertTokenizer; print(BertTokenizer.from_pretrained('bert-base-chinese').tokenize('截至6月30日'))"输出应该是单个汉字被拆开成token。这验证了关键前提:中文BERT基本按字切分,字符级别标签可以直接对齐到token。如果这一步输出乱码或直接报错,先查transformers版本和网络,不要往下走。
3.2 句子对数据构造:逐字对齐、错误标签与[MASK]样本
数据文件我用JSON Lines,每行一条:
{"original": "截止6月30日", "correct": "截至6月30日"}然后写一个构造训练样本的函数:
import json def build_pair_samples(jsonl_path, tokenizer, max_len=128): samples = [] with open(jsonl_path, "r", encoding="utf-8") as f: for line in f: item = json.loads(line) orig = item["original"].strip() corr = item["correct"].strip() # 等长替换是拼写纠错最常见的形式,插入/删除先交给语法模块 if len(orig) != len(corr): continue if len(orig) > max_len: continue error_label = [0 if o == c else 1 for o, c in zip(orig, corr)] masked_text = ''.join( '[MASK]' if e == 1 else ch for ch, e in zip(orig, error_label) ) encoded = tokenizer( masked_text, padding='max_length', truncation=True, max_length=max_len, return_tensors=None, ) # 把 correct_text 转成 label ids,正确位置和padding位置用 -100 忽略 label_ids = [-100] * max_len token_ids = tokenizer.convert_tokens_to_ids(list(corr)) for i, tid in enumerate(token_ids[:max_len - 1]): label_ids[i + 1] = tid # +1 跳过 [CLS] # 错误标签同步补齐到 max_len,padding 位置用 -1 表示不参与检测loss err_label_padded = [0] + error_label[:max_len - 1] pad_len = max_len - len(err_label_padded) err_label_padded += [-1] * pad_len samples.append({ 'input_ids': encoded['input_ids'], 'attention_mask': encoded['attention_mask'], 'labels': label_ids, 'error_labels': err_label_padded, }) return samples这段代码做的事是把“错句+对句”喂给模型。逻辑说明:error_labels是错误位置标记,0代表正确,1代表错误,padding位置是-1,这样训练时只有真实字符位置参与检测损失计算;labels里正确位置和padding位置都是-100,只有错误位置保留正确字符的token id,交叉熵loss会自动跳过-100的位置。注意这里用i+1对齐了BERT输入的[CLS]偏移,很多初学者在这里少加一位,导致标签整体错位,训练出来指标永远上不去。
真正调用时,还需要用DataLoader包一下,加padding和随机采样。数据增强这一步,我会在读取样本时按概率把错误字替换成同音字,而不是只依赖人工造的样本,这能显著提升模型对未见错误的泛化能力。
3.3 模型定义与训练:一个带检测头的BERT,两个loss一起反传
模型定义我拆成两部分,检测头负责回答“这个字是不是错的”,MLM头负责回答“这个错字应该改成什么”:
import torch import torch.nn as nn from transformers import BertModel class BertCSC(nn.Module): def __init__(self, model_name="bert-base-chinese"): super().__init__() self.bert = BertModel.from_pretrained(model_name, add_pooling_layer=False) self.detect_head = nn.Linear(self.bert.config.hidden_size, 1) self.mlm_head = nn.Linear(self.bert.config.hidden_size, self.bert.config.vocab_size) def forward(self, input_ids, attention_mask, labels=None, error_labels=None): out = self.bert(input_ids=input_ids, attention_mask=attention_mask) h = out.last_hidden_state logits_err = self.detect_head(h).squeeze(-1) # [B, L] logits_mlm = self.mlm_head(h) # [B, L, V] result = { "logits_err": logits_err, "logits_mlm": logits_mlm, } if labels is not None: # 检测损失:error_labels 中 -1 的位置不参与计算 loss_detect = nn.functional.binary_cross_entropy_with_logits( logits_err, error_labels.float(), reduction="none" ) active = (error_labels >= 0).float() loss_detect = (loss_detect * active).sum() / active.sum().clamp(min=1) # 纠正损失:labels 中 -100 的位置自动被忽略 loss_mlm = nn.functional.cross_entropy( logits_mlm.view(-1, logits_mlm.size(-1)), labels.view(-1), ignore_index=-100, ) result["loss"] = loss_detect + 0.5 * loss_mlm return result逻辑说明:detect_head输出的是每个token是错字的logit,用BCEWithLogitsLoss计算;mlm_head是标准的掩码语言模型头,在错字位置预测正确字。两个loss相加时,我给纠正loss乘了0.5,因为检测任务相对简单,权重太大会让模型只顾着找错字,不顾着改对。如果你发现训练时loss降不下去,可以把这个系数调到0.3~0.7之间试试。
训练循环用最朴素的写法,方便你改成自己的数据。这里loader就是把上一节的samples包成DataLoader,batch_size=32:
from torch.utils.data import DataLoader model = BertCSC().cuda() opt = torch.optim.AdamW(model.parameters(), lr=2e-5) for epoch in range(3): for batch in loader: batch = {k: torch.tensor(v).cuda() for k, v in batch.items()} out = model(**batch) out["loss"].backward() opt.step() opt.zero_grad()注意两点。第一,这里没有加warmup和梯度裁剪,数据量大的时候我建议加上,尤其是warmup,BERT微调不预热很容易在第一个step就发散。第二,学习率2e-5是BERT微调的安全起点,在这类任务上我试过1e-5到5e-5,差异主要在收敛速度上,最终精度差别不大。
3.4 推理与阈值调优:top_k候选、置信度过滤与高亮输出
训练完的模型最终要服务给人用。推理时不能只看top1,因为top1在错字位置经常给出一个概率很低的预测。实际做法是把候选集拿回来,再结合混淆集和词频做重排:
@torch.no_grad() def correct_text(model, tokenizer, text, th=0.5, top_k=5): inputs = tokenizer(text, return_tensors="pt", padding=True) out = model(**inputs) err_prob = torch.sigmoid(out["logits_err"]).squeeze().cpu() token_logits = out["logits_mlm"].squeeze().cpu() chars = list(text) n = len(chars) err_prob = err_prob[1: n + 1] # 去掉 [CLS] token_logits = token_logits[1: n + 1] # 去掉 [CLS] corrected = chars[:] marked = chars[:] for i in range(n): p = err_prob[i].item() if p < th: continue top_idx = token_logits[i].topk(top_k).indices.tolist() cand = [tokenizer.decode([idx]) for idx in top_idx] # 这里可以接入混淆集过滤,只保留音近/形近候选 if cand and cand[0] != chars[i]: corrected[i] = cand[0] marked[i] = f"**{cand[0]}**" return "".join(corrected), "".join(marked)逻辑说明:先阈值过滤,只有错字概率超过th的位置才进入纠正逻辑;随后取top_k候选,真实落地时会在这一步把候选和混淆集求交集,比如“截止”被改成“截至”,至少要让“截至”出现在混淆集里,否则不采纳;marked字符串是给前端高亮用的,凡是模型改过的字都用**包起来。
阈值th是召回率和精确率之间的旋钮。我一般先用0.3跑一遍开发集,统计“改对了/改错了”,然后根据审核人员的耐受度调。审核人员讨厌误报,那就把th往上抬;讨厌漏报,就往下压。这个参数比模型结构更值得花时间调。
上面这些代码已经能跑通一条最小链路。但要把这套东西真正放进公文校对系统里,还差几道保险,也就是下一章要说的那些“看似正常却翻车”的细节。
4. 公文校对的避坑清单:5个“看似正常却翻车”的案例
这一章写我实际踩过、也看别人踩过的五个坑。每一条都是先讲现象,再讲原因,最后给可落地的解决思路。前三个属于数据和训练层面的“玄学”,后两个属于工程层面的“血泪经验”。
4.1 正确句子刷屏,模型学成“永不改错”
现象:训练时loss稳步下降,验证集上“纠错准确率”也好看,但部署之后,所有真正的错字一个都没改。查日志发现模型对所有位置的错误概率都输出0.01以下,等于什么都没干。
原因:训练数据里正确句子占绝对多数。公文语料100个字里可能只有1个错字,全对样本比例超过95%,模型发现“什么都不改”的loss期望最低,于是学成了只会复读的哑巴。
解决:一是负采样,把全对样本和错误样本的比例控制在10:1到5:1之间;二是对检测loss做加权,把class weights设成“错误:正确=1:10”;三是改用Focal Loss,让模型把注意力放在少数错误样本上。我一般先做负采样,因为实现最简单,改动只在DataLoader里加一行随机丢弃。
4.2 “的地得”被无差别重写,公文风格被改畸形
现象:模型把“快速推进”改成“快速得推进”,“作出决定”改成“做出决定”。从纯语言模型角度看,这些修改“可能合理”,但公文的固定用法被破坏了,审核人员一看就退单。
原因:“的、地、得”在口语里混用严重,训练语料里的标签本身就不干净,模型学到的是统计偏好,不是规则。
解决:三管齐下。混淆集里明确不要收录“的/地/得”之间的互改,即使它们音近;推理时对这三个字的位置单独加白名单,只要原字属于“的/地/得”,就不启用纠正;在训练集的错误注入阶段,也禁止把它们互相替换。这套思路推广到其他高频易错字上也适用,比如“做/作”“账/帐”,凡是容易产生风格争议的字对,都建议从自动纠错范围里摘出去。
4.3 标点被改动,模型把逗号换成句号
现象:模型把“发展,以及”改成“发展、以及”,把“完成。”改成“完成;”。这些问题在字级指标上不算错误,但公文标点有明确规范,改错标点比不改更严重。
原因:训练数据里的标点噪声被模型当成了有效信号。特别是用程序注入错误时,如果错误注入逻辑不区分汉字和标点,就会随机把逗号替换成句号,模型就把这种噪声学进去了。
解决:在构造训练样本时,把标点token排除出纠正范围。具体做法是给label_ids里标点位置直接设成-100,让它不参与任何loss计算;推理时对标点位置的错误概率强制置0。另外,检查一下你的错误注入脚本,看看是不是只对汉字做替换。我见过一个团队的错误注入逻辑把句号也当字符替换,生成了一批“句号改问号”的脏样本,模型学完之后,标点误伤率高得离谱。
4.4 长文漏检,错误位置被截断在512字之后
现象:一份600字的通知,错字在第480字附近,模型没报错;单独把这句拿出来测,模型又能改对。反复定位后发现,问题出在输入截断。
原因:BERT的输入长度上限是512个token,我在预处理时直接截断,第480个字根本不在输入范围内,后面的错字自然看不到。
解决:用滑窗切分长文本。把长文切成多个256字左右的窗口,相邻窗口重叠50字,每个窗口单独过模型,最后把各窗口的检测结果按原位置合并,重叠区域里的检测结果取置信度高的那个。代价是推理次数和文本长度成正比,但对公文这种几千字以内的场景,完全可接受。这个坑的教训是:任何深度学习模型都有输入长度边界,而校对需求是整篇文本扫描,二者之间的桥只能是“切分+合并”,不是调大max_length。
4.5 服务化部署后单条延迟2秒,线上没法用
现象:模型在GPU上单条推理只要80毫秒,但封装成HTTP服务后,一次校对请求要2秒。一开始以为是模型问题,后来发现是每次请求都重新加载了一次模型。
原因:典型的服务化翻车点。推理脚本里把模型加载写在请求处理函数内部,并发一起来,GPU被反复加载模型占满,显存抖动,延迟直接爆炸。
解决:模型加载只做一次,在服务启动时完成;请求进来只做前向推理。再加上动态批处理,把并发请求攒到一个batch里统一forward,吞吐能提3到5倍。如果推理延迟还是不够,就用ONNX Runtime导出模型,叠加INT8量化,单条延迟能压到原来的一半以下。这个坑的优先级很高:模型精度再高,服务不稳定一样上不了线。
5. 让系统真正“敢改”:置信度校准、回归集与一次上线教训
链路跑通了,坑也填了,最后剩一个决定系统能不能被审核人员接受的问题:什么情况下模型“敢改”,什么情况下它只能“标黄”但不动手。
我的做法是给模型加一道置信度校准。BERT输出的logits不是概率,softmax之后的分数分布往往过于自信,错字位置可能输出0.7,非错字位置也可能输出0.4。直接拿0.5当阈值,会出现“该改的不敢改,不该改的乱改”。常见做法是在训练后的验证集上做温度缩放:把logits除以一个温度系数T再取softmax,T通过最小化交叉熵在开发集上搜索。T大于1会拉平分布,让模型更保守;T小于1则更激进。对公文校对,我一般调出一个略大于1的T,让分数分布温和一点。
同时,我给自己准备了一份固定回归集。里面是200条从近一年公文中截取的真实错句,覆盖错别字、数字单位、标点三类问题,每条的正确答案都经过人工确认。每次改模型、调阈值、换预训练权重,都要先跑一遍回归集,记录两个指标:检测召回率,也就是有错字的位置模型有没有标出来;纠正准确率,也就是改了之后是不是正确字。这两个指标是这条产线的生命线。
上线时我坚持双态策略:置信度超过阈值的,直接修改并在界面上高亮;低于阈值但检测概率不为零的,只标黄不修改。前者承担“纠错”功能,后者承担“提醒”功能。审核人员对高亮的容忍度远高于对直接改错的容忍度。
说一件我吃过亏的事。有次换了一个更新的预训练模型,单测表现很好,我以为能白捡几个点的精度,就跳过回归集直接上线。结果“的地得”误改率从4%飙到35%,公文审核那边一天退回五十多份。查下来才知道新模型在口语语料上更强,但恰恰把“的/地/得”的统计偏好改变了。从那以后,回归集排在所有实验前面,没有它我不上线任何改动。这个教训比其他任何优化都值钱,希望帮到你。
本文还有配套的精品资源,点击获取