ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于BERT的公文校对系统:深度学习文本纠错的落地实践

基于BERT的公文校对系统:深度学习文本纠错的落地实践 简介面向深度学习与自然语言处理学习者的公文校对系统源码包适合期末大作业、毕业设计或机器学习实践。压缩包共六个文件包含四个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 python3.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”起的名字python3.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_len128): samples [] with open(jsonl_path, r, encodingutf-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, paddingmax_length, truncationTrue, max_lengthmax_len, return_tensorsNone, ) # 把 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_lenpadding 位置用 -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的位置。注意这里用i1对齐了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_namebert-base-chinese): super().__init__() self.bert BertModel.from_pretrained(model_name, add_pooling_layerFalse) 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, labelsNone, error_labelsNone): out self.bert(input_idsinput_ids, attention_maskattention_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(), reductionnone ) active (error_labels 0).float() loss_detect (loss_detect * active).sum() / active.sum().clamp(min1) # 纠正损失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.30.7之间试试。训练循环用最朴素的写法方便你改成自己的数据。这里loader就是把上一节的samples包成DataLoaderbatch_size32from torch.utils.data import DataLoader model BertCSC().cuda() opt torch.optim.AdamW(model.parameters(), lr2e-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和梯度裁剪数据量大的时候我建议加上尤其是warmupBERT微调不预热很容易在第一个step就发散。第二学习率2e-5是BERT微调的安全起点在这类任务上我试过1e-5到5e-5差异主要在收敛速度上最终精度差别不大。3.4 推理与阈值调优top_k候选、置信度过滤与高亮输出训练完的模型最终要服务给人用。推理时不能只看top1因为top1在错字位置经常给出一个概率很低的预测。实际做法是把候选集拿回来再结合混淆集和词频做重排torch.no_grad() def correct_text(model, tokenizer, text, th0.5, top_k5): inputs tokenizer(text, return_tensorspt, paddingTrue) 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再取softmaxT通过最小化交叉熵在开发集上搜索。T大于1会拉平分布让模型更保守T小于1则更激进。对公文校对我一般调出一个略大于1的T让分数分布温和一点。同时我给自己准备了一份固定回归集。里面是200条从近一年公文中截取的真实错句覆盖错别字、数字单位、标点三类问题每条的正确答案都经过人工确认。每次改模型、调阈值、换预训练权重都要先跑一遍回归集记录两个指标检测召回率也就是有错字的位置模型有没有标出来纠正准确率也就是改了之后是不是正确字。这两个指标是这条产线的生命线。上线时我坚持双态策略置信度超过阈值的直接修改并在界面上高亮低于阈值但检测概率不为零的只标黄不修改。前者承担“纠错”功能后者承担“提醒”功能。审核人员对高亮的容忍度远高于对直接改错的容忍度。说一件我吃过亏的事。有次换了一个更新的预训练模型单测表现很好我以为能白捡几个点的精度就跳过回归集直接上线。结果“的地得”误改率从4%飙到35%公文审核那边一天退回五十多份。查下来才知道新模型在口语语料上更强但恰恰把“的/地/得”的统计偏好改变了。从那以后回归集排在所有实验前面没有它我不上线任何改动。这个教训比其他任何优化都值钱希望帮到你。本文还有配套的精品资源点击获取
返回列表