简介:基于深度学习的公文校对系统.zip是一个面向深度学习、机器学习课程期末大作业或毕业设计的完整Python实现,核心利用NLP技术对公文文本进行智能校对,可辅助处理拼写错误、语法偏差及格式不规范等问题。资源包共6个文件,压缩后仅8KB,其中4个Python脚本构成主体,分别承担公文样本下载、文本清洗预处理、深度学习模型构建与训练、主控流程调度等任务;另有说明文档与Git忽略配置文件,便于项目阅读和版本管理。目前已有65人浏览学习,适合具备一定Python基础、希望了解深度学习文本处理项目结构的学生参考。通过研读源码,可以快速掌握从数据采集、清洗、模型训练到结果展示的完整工程链路,理解神经网络在具体文本任务中的调参和优化思路,为课程设计或毕业答辩提供现成范本。
1. 为什么我把“公文校对系统”交付成 zip,而不是一个在线 API
拿到「基于深度学习的公文校对系统.zip」这个包,很多人的第一反应是:这不就是一个文本纠错项目吗?拆开看才知道,它跟输入法纠错完全不一样,面对的是公文这种文体——错误类型从同音错字、形近字到标点、发文字号、体例格式都有,而且输出必须能解释“为什么这么改”,不然内部评审那一关就过不去。我把同类系统做过三轮交付,最终形态都收敛成一个 zip 包:模型权重、规则库、配置文件和启动脚本放在固定目录里,内网机器解压即用。这背后其实是深度学习项目从“能跑通”到“可验收”的完整链路。这篇笔记按我自己的落地经验,把任务定义、数据构造、训练调参、坑位和打包交付讲一遍,适合正在做深度学习毕设、或者想在真实文本场景里做校对服务的从业者。
2. 把校对任务拆成模型能学的样子:错误类型与方案选型
2.1 公文校对不是普通文本纠错:先分清四类错误
做技术方案之前,得先回答一个问题:你要校的到底是什么?公文的错误和日常文本不一样,我一般把它拆成四类,每一类的处理方式完全不同。
| 错误类别 | 典型例子 | 规则能否覆盖 | 深度模型职责 |
|---|---|---|---|
| 字词类 | “部署”写成“布署”;“再接再厉”写成“再接再励”;“截止/截至”混用 | 词典和混淆集能覆盖高频,但长尾不行 | 同音、形近字候选生成与排序 |
| 句法搭配 | “贯彻《关于…的实施意见》的精神”这种成分嵌套混乱 | 基本不能 | 上下文语义判断,是深度学习的核心阵地 |
| 标点数字 | 顿号与逗号混用;发文字号写成〔2024〕12号却用了半角括号 | 全是硬规则,正则直接改 | 不参与或只做辅助 |
| 体例格式 | 主送机关位置、抄送机关顺序、成文日期格式 | 模板匹配即可 | 无 |
这个分类决定了整个系统不是“一个模型干到底”。常见做法是规则层在前、深度学习模型在后:标点、数字、格式这类有明确规范的“硬错误”交给正则和词典;字词、搭配这类“软错误”交给模型。顺序也很重要,我习惯先跑规则,再做模型推理,最后统一拼装输出,避免两边互相打架。
为什么要用深度学习而不是全上规则?答案在长尾。公文里同一套规范反复使用,“的地得”“做作”这类问题规则能管,但“厉行节约”写成“励行节约”、“一如既往”写成“一如即往”,这些错只要换个句子就未必命中词典。深度学习模型能我记住的是上下文模式,而不是死记硬背某个词表。
2.2 两条技术路线:检测式与生成式,怎么选
中文文本纠错目前主要两条路:检测式和生成式。我两版都做过,结论很直接:做公文体,首选检测式,生成式只做兜底。
检测式的思路是先把“哪里错了”找出来,再对错误位置给候选字。落到模型上,常见做法是用 BERT 加一个 token 级分类头,每个字标记为“正确”或“错误”,错误位置再做掩码预测,从词表里挑几个候选字,按概率排序。这个路线的最大优势是可控:模型只负责提出“这个字可能要改、改成什么”,最终改不改由规则和置信度决定,不会出现整句被重写的情况。
生成式则是用 T5、BART 这类 seq2seq 模型,把错误句子整体重写一遍,输出修正后的完整句子。优点是连续错误、多字、少字都能处理,这恰恰是检测式的短板,因为检测式要先定位位置才能改,定位错了后面全错。但生成式也有麻烦:它可能擅自调整句式,把“现就有关事项通知如下”改成“现通知如下”,这对公文是不能接受的。而且生成式没法解释自己为什么删了四个字。
我的选型逻辑是:先用 BERT 检测式做主体,输出“错误位置 + 候选字 + 置信度”;如果检测式在某个位置给出的所有候选都低于阈值,才调用生成式模型做句子级重写,并把重写结果用 diff 算法映射回原文位置。这样既保住可控性,又能覆盖连续错误。
2.3 离线可交付的工程约束:参数量、推理时延与词汇表
公文校对系统有个现实约束:它通常在单位内网跑,机器可能是旧服务器,甚至没有 GPU。这意味着模型的参数量不能只盯着效果看。
bert-base-chinese 有 110M 参数,在 CPU 上跑一篇 3000 字的公文,按 512 字窗口切分,Token 化加推理,单篇耗时可能到 5 到 8 秒。这对“人工校对辅助工具”来说勉强能接受,但用户一旦开了 Word 插件实时检查,就完全不能忍。我后面换成了中文蒸馏版的预训练模型,参数量降到 28M 左右,效果只掉三四个点,推理速度快了差不多 4 倍。
另一个工程点是词汇表。bert-base-chinese 的词表有两万多个 token,但公文体用字很集中,大量低频字在推理时根本选不中。我在最终模型里把候选 token 表裁剪到常用字加公文高频字约 1.4 万,既减小 softmax 计算量,也减少模型把“目的”改成“墓地”这类离谱输出——那些低频字根本不在候选列表里。
还有环境问题。内网机器装不了外网依赖,所以我的交付包里必须带着固定的依赖清单和离线安装包。LLM 是不是深度学习这个问题先放一边,从工程角度说,那种动辄几十 GB 的大模型在这个场景下完全不可行,可解释性也差。公文体校对要求每个修改都能讲出理由,一个大模型吐出一句“建议修改”,没人敢用。所以别看现在大模型热,这类任务真正的落地方案还是中小模型加规则配合。
3. 从公开公文到训练样本:语料构建与数据增强
3.1 语料来源与脱敏底线
深度学习模型要训得好,语料是第一步。公文校对没有现成的大规模“错误-正确”平行语料,我一般从三个来源凑:
第一是各地政府网站公开印发的政策文件、通知、批复,这些文本规范程度高,能当“正确句”用。第二是单位内部代拟稿,这是最有价值的来源,因为代拟稿里有大量真实错误,改完后的版本就是天然的平行语料。第三是历年校对记录,哪怕只有几百条,训练时拿出来做 few-shot 也很有用。
但这立刻引出安全和合规问题。公文语料涉及敏感内容,脱敏是底线,不能因为做一个校对系统就把内部文件带出内网。我的处理原则是:涉密和不公开的原文一律不进入训练集;代拟稿只提取“错误位置+正确写法”的片段,人名校名机构名全部替换成占位符。这个工作没有太多技术含量,但必须有人盯,模型训练完还要做一轮隐私审计,抽查生成结果里有没有出现真实姓名和机构名。
脱敏可以写一段最小处理脚本,思路是识别姓名、手机号、身份证号后做替换:
import re def desensitize(text: str) -> str: # 手机号:1开头的11位数字,替换为占位符 text = re.sub(r'(?<!\d)1[3-9]\d{9}(?!\d)', '[手机号]', text) # 身份证号:18位,末位可能是X text = re.sub(r'(?<!\d)[1-9]\d{16}[\dXx](?!\d)', '[证件号]', text) # 常见的“根据《…》”里的发文机关名,用占位符替代 text = re.sub(r'《([^》]{2,20}?)(?:局|部|委|办公厅|办公室)(》)', r'《[机构]\2', text) return text这个脚本只做最小脱敏,说明两点。第一,正则只匹配“疑似手机号/证件号”的连续数字,宁可漏也不误伤,因为误伤会把正常的发文字号替换掉。第二,机构名替换用的占位符,是把机构名本身从训练文本里抹掉,避免模型从上下文里记住某个具体单位。脱敏后的文本才能进入下一步样本构造。
3.2 构造错误样本的完整脚本:混淆集注入
有了“正确句”,怎么造出“错误句”?靠人工写错不现实,标准做法是混淆集加自动污染。混淆集是一组“易错词对”,每个词条里放着正确写法、错误写法和错误类型。构造错误样本时,在正确句上随机选中某些词,按概率替换成混淆集里的错误写法,从而得到“错误-正确”平行对。
下面是核心脚本的精简版:
import random import jieba.posseg as pseg # 混淆集:正确写法 -> [错误候选, 错误类型] CONFUSION = { "部署": [("布署", "同音"), ("部暑", "形近")], "再接再厉": [("再接再励", "同音")], "截至": [("截止", "语义混淆")], "账目": [("帐目", "形近")], "一如既往": [("一如即往", "同音")], } def build_pair(text, prob=0.3, seed=42): rng = random.Random(seed) words = pseg.cut(text) # 分词并保留词性,避免替换错位置 output = [] changed = False for word, flag in words: if word in CONFUSION and rng.random() < prob: wrong, err_type = rng.choice(CONFUSION[word]) output.append(f"[{err_type}]{wrong}") changed = True else: output.append(word) return text, "".join(output), changed逻辑说明:jieba.posseg先做词性标注,因为公文本里“部署”通常做动词,直接按词表匹配就可以;prob控制每篇文档的污染密度,设 0.3 意味着约三成命中词被改错,太高会让模型学到“到处都错”,太低则正负样本不均衡。CONFUSION里的每个词对都人工核对过,这是整套系统的地基——自动方法找出来的词对必须再过一遍人工审查,否则会把正确用法当成错误。
这里有个参数值得展开:prob不是越大越好。我最初设 0.6,发现模型确实把错误都改对了,但到了真实数据上误报特别严重,因为真实场景里一篇文章只有几十处错误,模型从过密污染中学到了“有权就改”的倾向。降到 0.25 到 0.3,同时保持每篇至少 3 个错误、最多 15 个错误,训练出的模型才更贴近真实分布。
3.3 标点数字与硬错误的规则增强
深度学习模型再强,标点、数字这类硬错误也不能指望它。BERT 在标点符号上的预测本身就不可靠,一个逗号被改成句号,从句法上是“合理”的,但语义上完全不能接受。所以这一块我直接用规则增强,不进入模型训练。
硬错误主要指三类:全角半角混用(写程序的人特容易把英文逗号带进公文);发文字号格式(〔2024〕12号,要求六角括号、年份全角);日期和数字表述(“2024 年”中间有空格,“凌晨 0 点”应为“零时”)。这些用正则就能覆盖到九成以上,实现也简单:
def fix_hard_errors(text: str) -> str: # 全角括号处理:发文字号专用六角括号 text = re.sub(r'\[(\d{4})年?\]', r'〔\1〕', text) # 小心,这只处理年份 # 数字和单位之间的多余空格 text = re.sub(r'(\d)\s+(年|月|日|号|元)', r'\1\2', text) # 中文字符后的英文逗号 text = re.sub(r'([\u4e00-\u9fa5]),', r'\1,', text) return text这条代码的坑在第一个正则:\[(\d{4})年?\]可能把正常语境里的方括号年份也改了,所以规则要限定在“发文字号”段落里执行,不能全文档跑。我通常会先把公文按“版头、正文、版记”切段,只有正文才做全量规则。这也是整个系统的原则:规则不是用来替代模型的,而是用来守住模型守不住的边界的。
4. 训练与调优:BERT 微调与解码后处理
4.1 环境与结构选型
训练环境我一般用 miniconda 建独立环境。深度学习环境配置是最容易卡住新手的环节,80% 的报错来自三个点:Python 版本对不上、CUDA 版本对不上、依赖包互相冲突。我的固定组合是 Python 3.10 + PyTorch 2.x + transformers 4.x,CPU 机器也能训,只是慢一点。下面是一个可用的环境创建命令:
conda create -n gongwen python=3.10 -y conda activate gongwen pip install torch transformers datasets seqeval参数说明:torch默认装 CPU 或 CUDA 版本取决于你机器的情况,如果后面训练时遇到“Torch not compiled with CUDA enabled”这类报错,说明装的是 CPU 版,不影响训练,只是慢。seqeval是序列标注的评估库,算 F1 用。这个命令跑通后,再装 jieba、pypinyin 这类处理库。
模型结构上,我用一个三层方案:最底层是预训练语言模型,中间是 token 级分类头判断“错/不错”,最上层是候选生成头,对错误位置做 mask 预测。对比过直接拿预训练模型的 MLM 输出做候选,效果差一截,因为 MLM 学的是“这个位置最可能的字”,不是“这个位置是不是错了”。必须单独训练分类头。
4.2 训练主流程:损失、验证指标与超参
训练的核心代码如下,我直接给出可以跑通的主循环片段:
import torch from transformers import AutoModelForTokenClassification, AutoTokenizer from torch.utils.data import DataLoader, Dataset class CorrectorDataset(Dataset): def __init__(self, pairs, tokenizer, max_len=256): self.pairs = pairs # [(错误句, 正确句), ...] self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.pairs) def __getitem__(self, idx): wrong, correct = self.pairs[idx] enc = self.tokenizer(wrong, correct, truncation=True, max_length=self.max_len, return_tensors="pt") # 标签按字对齐:1 表示该位置错误,0 表示正确 labels = (enc["input_ids"][0] != enc["labels"][0]).long() return { "input_ids": enc["input_ids"][0], "attention_mask": enc["attention_mask"][0], "labels": labels, } model = AutoModelForTokenClassification.from_pretrained( "hfl/chinese-roberta-wwm-ext", # 中文全词掩码,比原版 BERT 更适合错字 num_labels=2 )这个方案是用 encoder-decoder 式的“错误句+正确句”作为输入输出做序列标注。有个细节常被忽略:直接用tokenizer(wrong, correct)拼接时,两个句子之间的[SEP]位置会被标错,所以标签对齐时要跳过特殊 token,只用labels部分去计算。betatron式对齐在这种场景下会更麻烦,我最终用的是字符级偏移量手动对齐,每篇样本都校验一次长度,宁可丢样本也不留错位数据。
训练超参我从第二批开始就没再动过,直接给出来:
| 参数 | 取值 | 说明 |
|---|---|---|
| batch_size | 32 | 显存不够就降到 16 |
| learning_rate | 3e-5 | 超过 5e-5 会出现损失震荡 |
| epochs | 5 | 第 5 轮效果最好,再多开始过拟合 |
| max_len | 256 | 公文长句多,512 反而稀释注意力 |
| 损失函数 | CrossEntropy + 类别权重 | 错误标签约占 8%,正负比约 1:12 |
| 优化器 | AdamW | 开 weight_decay=0.01 |
训练中我每 500 步打一次验证集指标,主要看两个数:错误检测 F1 和错误纠正准确率。F1 衡量“能不能发现错”,纠正准确率衡量“发现的错改得对不对”。F1 到 90 以上相对容易,纠正准确率到 85 就是坎,因为模型容易把错字改成一个同音但语义不对的字。
4.3 推理与 diff 后处理:把模型输出变成校对建议
模型输出不是最终结果。用户要的是“这句话哪里错了、改成什么、为什么”,所以推理阶段必须做位置还原。我常用的做法是:拿到 token 级标签后,把连续的错误 token 合并成一个“错误片段”,再到原始文本里用字符偏移量定位,最后从候选生成头取 Top 3 候选字。逻辑如下:
def merge_error_spans(logits, tokenizer, original_text, offset_mapping): preds = torch.argmax(logits, dim=-1)[0].tolist() results = [] cur = None # 暂存当前错误片段 for i, (label, mapping) in enumerate(zip(preds, offset_mapping)): start, end = mapping[0], mapping[1] if start == end: # 特殊 token,跳过 continue if label == 1: if cur is None: cur = [start, end, original_text[start:end]] else: cur[1] = end # 扩展片段 else: if cur is not None: results.append(cur) cur = None if cur is not None: results.append(cur) # 对每个错误片段,生成候选字 for span in results: span_start, span_end, wrong_word = span span["candidates"] = get_candidates(wrong_word, logits, span_start) # Top3 return resultsoffset_mapping是 tokenizer 提供的字符级映射,它保证模型预测的 token 位置能对应回原始文本的字;合并片段时用end递增而不是新建 span,是为了把连续两个错字并成一个修改单位。这里最重要的参数是logits的修剪:候选字必须满足两个条件,一是字在公文高频词表里,二是跟原字的拼音相似度或字形相似度足够高。满足这两个条件的候选才进到 Top 3,不然会出现“把通的改成痛”这类同音不同义笑话。
4.4 置信度校准与阈值:减少“把对的改成错的”
公文体校对最不能接受的是误报。一个逐字核对过的文件,系统却标出 20 处“可能的错误”,谁也不会信任它。所以我用了一个很土但有效的办法:给每个修改建议算一个置信度,置信度 = 候选字概率 − 原字概率。只有差值超过阈值才输出建议。
这个阈值必须按错误类型分开调。标点规则类错误是“必改”,阈值设为 0;同音替换类错误差 0.15 就改;形近字要求差 0.3 以上;语义依赖类错误要 0.5 以上。这么设置之后的直接效果是,误报从每篇十几个降到每篇两三个。代价是召回率下降,但公文体校对工具的核心诉求本来就是“少吵,一吵一个准”。
5. 常见问题与避坑:训练到打包的 5 个翻车点
5.1 模型把对的改成错的,评审直接否定整个系统
现象:系统在 3000 字的文上标出 27 处修改,用户逐条复核后认定其中 22 处是“瞎改”,最典型的是把“目的”改成“墓地”——同音但语义完全不对。第一次演示就这么翻车。
原因:模型训练时以“错误检测”为目标,天然倾向多报;候选生成时只看了概率,没有做置信度差值和语义过滤。
解决:加两层过滤。第一层,概率差值阈值必须生效,不满足不发候选;第二层,把候选字放回句子做语言模型评分,同时用混淆集里的“正确→错误”方向做反向校验。方向校验很关键:混淆集里“部署→布署”是错误方向,但如果模型想把“布署”改成“部署”以外的任何词,直接丢弃。
5.2 训练样本污染密度太高,模型学出“逢词必改”
现象:训练集 F1 到 95,一到真实公文上召回率掉一半,而且经常把规范用语拆开改。
原因:自动注入错误时,污染密度设得太高,模型学到的是“句子里每几个字就该有一个错”,而不是“仅在语义异常时修改”。
解决:把污染密度降到 0.2 到 0.3,并强制每篇样本错误数不超过 15 个。还不行就做难样本挖掘:先把未注入错误的干净文本交给模型,保留那些被模型误报的位置,作为难例加入训练集,让模型见见“对的句子长什么样”。
5.3 zip 包换台机器跑不起来,缺库缺到怀疑人生
现象:压缩包在 A 机器跑通,拷到 B 机器后import transformers直接报ModuleNotFoundError,更离谱的是连 torch 都没有。
原因:交付时只打了.py脚本和模型权重,没有带上依赖环境。拿一个 zip 解压出来的源码,只要环境不对就没法跑。
解决:用 conda-pack 把整个虚拟环境打成包,放进 zip 的env/目录里;或者退一步,至少把requirements.txt里的版本全部锁死,并且显式注明 Python 版本和 torch 版本。我现在的交付 zip 结构固定成下面这样,后面不会再为这种事后补依赖的坑加班。
5.4 规则和模型互相打架,输出一片混乱
现象:规则层把“截止”改成“截至”,模型又基于统计把“截至”改成“截止”,一篇文稿上来来回回改三遍,最终输出自相矛盾。
原因:两条处理链路各自独立,没有统一的裁决顺序。
解决:定死处理管线。规则层只跑标点、数字、格式等硬错误;模型层只跑字词和搭配;输出前做一个合并器,按“硬规则 > 模型高置信 > 模型低置信”排序,低置信建议默认隐藏在“更多建议”里,不直接进正文。这样规则和模型永远不会对同一个问题重复修改。
5.5 zip 包命名与编码:Windows 解压乱码和伪加密
现象:交付给同事的 zip 在 Windows 上右键解压,文件名全是乱码;另一个包设了密码,结果密码忘了,整个包形同虚设。
原因:Windows 自带压缩工具的 zip 默认用 GBK 编码文件名,在 Linux 上解压就乱码;而 zip 加密本身是弱加密,忘了密码基本等于数据丢失,网上那些移除密码的工具大多是骗局。
解决:统一用 7-Zip 打包,打开“UTF-8 文件名”选项,这样 Windows 和 Linux 解压都不乱。不要给 zip 加密码,真正的权限控制走文件服务器或内网权限,密码除了增加打不开的风险没有任何意义。打包前在包内放一个SHA256SUMS文件,交付后让对方先验一遍,避免传输过程损坏。
6. 交付形态与进阶:让 zip 包开箱可用
一个可复现的交付包,里面至少要有这些:
| 目录 | 内容 | 说明 |
|---|---|---|
model/ | 训练好的模型权重与配置文件 | 不传原始 checkpoint,转成 ONNX 更小更稳 |
rules/ | 混淆集、标点数字规则、体例模板 | JSON 格式,业务方可自行维护 |
scripts/ | 启动脚本、一键验证脚本 | Windows 下给.bat,Linux 下给.sh |
sample/ | 三篇样例公文和对应校对报告 | 验收时直接用 |
README.md | 环境依赖、启动命令、目录说明 | 写清楚 Python 版本、离线安装包位置 |
最后用一个命令验证交付是否成功:
cd sample && python ../scripts/correct.py test_doc.txt --output report.json这条命令的意义不是跑通一个 demo,而是确认三件事:模型能加载、规则能触发、输出能落地成报告。我现在的习惯是交付前不看模型指标,只看这“一命令三检查”能不能在干净机器上完成,能完成说明这个包是活的。每次打包我都会模拟一次“拿到全新机器、无网络、只看 README”的流程,自己走一遍。希望帮到你。
本文还有配套的精品资源,点击获取