
简介macbert4csc-base-chinese 中文预训练模型的 ONNX 完整发布版面向自然语言处理方向的研究者、算法工程师以及需要做中文纠错或语义理解的开发者。模型在 BERT 架构基础上针对中文优化转换为开放神经网络交换格式后可在常见推理环境中无缝使用减少了跨框架部署成本。压缩包内共有 7 个文件合计约 421.71MB核心的 onnx 模型文件保存了权重与网络结构5 个 JSON 配置分别描述模型推理输入输出、生成参数、特殊令牌与分词器设置词表 TXT 则支撑中文文本的分词与还原全部文件构成一个可直接加载运行的完整模型目录。对于希望跳过繁琐格式转换、快速获得可用中文语言模型的用户它提供了明确的文件划分与开箱即用的部署体验无论是集成到业务系统、构建中文纠错服务还是作为基础模型继续微调都能快速上手。目前已有 382 人学习下载尤其适合需要本地部署中文预训练模型的工程团队。1. 这个 rar 包里到底是什么macbert4csc 的适用场景与定位中文拼写纠错CSC一直是个看着简单、做起来别扭的任务OCR 输出里的错字、输入法造成的音近字、语音识别转写里的同音词都需要在句子层面把错的那个字揪出来并改正。很多团队试过规则或拼音混淆集但一遇到上下文歧义就翻车而 macbert4csc-base-chinese 这个预训练模型包正是冲着「给出一句中文逐字预测出正确文本」来的。它的核心不是生成式纠错而是基于 MLM 的判别式纠错模型对每个位置输出一个候选字分布我们按置信度决定是否替换。本文不涉及源码或作者团队只讲作为一线的我在拿到这个 rar 后如何把它落地成能用的纠错服务以及那些文档里没有明说的坑。2. 解压与模型加载从 rar 到可运行的 PyTorch 模型2.1 先看包内文件rar 该用什么姿势解压拿到.rar第一件事不是写业务代码而是确认里面的目录结构。很多预训练模型压缩包在打包时会套一层文件夹直接from_pretrained指向外层目录会报找不到 config。在 Linux 服务器上我一般用unar它比unrar更省心能处理分卷和中文文件名乱码。安装方式因人而异解压命令很简单unar macbert4csc-base-chinese.rar如果服务器上没有unar也可以用 Python 的rarfile库但rarfile依赖系统里的unrar命令否则只能读不能解。所以更推荐直接走系统命令。解压后先ls -R看一遍文件清单。通常一个可用 transformers 直接加载的模型目录应该包含这几样config.json记录模型结构参数、pytorch_model.bin权重文件、vocab.txt词表以及可能的tokenizer_config.json和special_tokens_map.json。如果这几个文件不在根目录而在某个子文件夹里后面加载时就要把路径指到那一层。2.2 用 transformers 加载为什么是 BertForMaskedLM 而不是 AutoModel我见过不少人拿到模型后直接AutoModel.from_pretrained加载完输出是一串 hidden state完全没法做纠错。原因很简单macbert4csc 的训练目标是 MLM掩码语言模型它对外输出的应当是logits也就是每个 token 位置在所有词表上的概率分布。而这需要BertForMaskedLM这个带语言模型头的类来加载。MacBERT 的模型结构和 BERT 基础版本一致区别主要在预训练阶段的全词掩码策略。所以用BertForMaskedLM加载完全可以不需要额外写结构代码。加载前先确认 transformers 版本不要太老我习惯用 4.20 以上旧版本可能在torch_dtype和use_safetensors参数上有兼容问题。from transformers import BertTokenizer, BertForMaskedLM import torch model_path ./macbert4csc-base-chinese # 解压后实际目录 tokenizer BertTokenizer.from_pretrained(model_path) model BertForMaskedLM.from_pretrained(model_path) model.eval()这段代码里tokenizer负责把中文句子切成字粒度的 token。很多中文模型用的是BertTokenizer它默认按字符切分对中文来说一个字一个 token这恰恰是拼写纠错需要的粒度。model.eval()是必须的否则 BN 和 Dropout 在推理时仍处于训练行为会带来不确定的随机输出。加载如果报OSError: Cant load model优先检查路径下有没有pytorch_model.bin。有些 rar 解压出来文件名叫pytorch_model.bin但大小只有几 KB那多半是 git-lfs 的指针文件需要重新下载权重。这是另一个坑后面避坑章节再展开。2.3 快速验证一句话看模型能不能找回错字加载完别急着写推理框架先用一个最小例子验证模型和分词器是否匹配。常见做法是造一个带掩码的句子让模型填那个被掩码的位置类似完形填空。这一步能最快暴露 tokenizer 和模型版本不匹配的问题。text 我[MASK]北京 inputs tokenizer(text, return_tensorspt) with torch.no_grad(): outputs model(**inputs) logits outputs.logits mask_index inputs[input_ids][0].tolist().index(tokenizer.mask_token_id) pred_index logits[0, mask_index].argmax().item() pred_token tokenizer.convert_ids_to_tokens(pred_index) print(pred_token)这里把[MASK]放到我和北京之间模型如果训练正常大概率会预测爱或在这类高频动词。如果预测出的是[UNK]或一个明显不相干的字说明词表对不上或者权重加载错了模型类别。注意mask_index的计算用了tokenizer.mask_token_id这是分词器提供的特殊 token id不能自己写死成某个数字。另一个更贴近实际的方法是拿一句完整的话输入不 mask直接看模型对每个位置输出的最高概率字。这个结果通常会把所有字都预测成它自己或高频字所以不能直接 argmax必须结合置信度阈值做判断这就是下一章要讲的完整推理链路。3. 中文拼写纠错的推理链路让模型真的改对字3.1 输入规范化与动态 mask 策略中文拼写纠错和英文拼写纠正有个明显差别英文靠空格分词中文没有天然边界模型必须对每个字符独立判断。macbert4csc 的输入输出是等长序列输入一句你好世畀输出的 logits 在畀这个位置应该给界更高概率。所以推理时不需要像预训练那样把某个字替换成[MASK]而是让模型看完整的表面句子预测每个位置最可能是哪个字。这里有个关键点模型本身是 MLM 预训练的但它见过大量完全正确的句子如果你输入完整句子每个位置都有原字概率最高的倾向。纠错的信号就藏在概率分布里如果某个位置的原字概率低于某个阈值而另一个字概率显著更高那就有理由认为原字是错的。所以动态 mask 策略在这里不是必须的反而是直接全体预测再筛选更简单。规范化是必须做的前置步骤。OCR 和语音识别结果里经常混着全角字母、数字、异体字比如全角和ABC在词表里是不同 token模型对少见 token 的预测会很飘。我一般会在送入模型前做一次字符归一化import unicodedata def normalize_text(text: str) - str: text unicodedata.normalize(NFKC, text) # 全角转半角、兼容字符归一化 return text.strip()NFKC 会把全角字母数字变成半角也能统一一些兼容字符。但注意它不会把繁体转简体如果业务里繁体独立存在不要用 NFKC 硬转。归一化要放在 tokenize 之前因为分词器是按原始文本切词的先转后切才能保证同音字都在同一核对空间里。3.2 从 logits 到纠错文本后处理细节模型输出的 logits 是[batch, seq_len, vocab_size]。我们要做的是对每个非特殊 token 的位置找到概率最高的候选字跟原字比较满足条件才替换。核心函数可以这么写def correct_sentence(model, tokenizer, text, threshold0.6, top_k3): inputs tokenizer(text, return_tensorspt, add_special_tokensTrue) input_ids inputs[input_ids][0] tokens tokenizer.convert_ids_to_tokens(input_ids) with torch.no_grad(): outputs model(**inputs) logits outputs.logits[0] probs torch.softmax(logits, dim-1) corrected list(tokens) for i in range(1, len(tokens) - 1): # 跳过 [CLS] 和 [SEP] orig_id input_ids[i].item() orig_prob probs[i, orig_id].item() top_probs, top_indices torch.topk(probs[i], top_k) for prob, idx in zip(top_probs.tolist(), top_indices.tolist()): if idx orig_id: continue if prob orig_prob and prob threshold: corrected[i] idx break # 把 id 序列还原为文本 corrected_ids [tokenizer.convert_tokens_to_ids(t) for t in corrected] corrected_text tokenizer.decode(corrected_ids, skip_special_tokensTrue) return corrected_text逻辑说明对每个位置先取原 token 的概率再看前top_k个候选里有没有一个候选满足「概率超过原字概率」且「超过阈值」。如果满足就替换。break只替换一次意思是只要找到最高满足条件的候选就停不继续往下拿更靠后的候选因为更靠后的候选通常是低频字替换风险大。参数上add_special_tokensTrue会加上[CLS]和[SEP]所以遍历时从 1 到len(tokens)-1避开首尾特殊 token。skip_special_tokensTrue在解码时会把它们去掉这样输出长度和输入一致。3.3 解码参数top-k、温度与置信度阈值上面函数里出现了threshold和top_k这俩是决定纠错激进程度的关键。threshold 太高比如 0.95只有模型非常有把握时才动误伤少但漏错多threshold 太低比如 0.4句子里的正确字也很容易被改成高频字。我一般先在验证集上扫一遍从 0.5 到 0.8 按 0.05 步长试。top_k 的作用是限制候选范围。中文词表通常有 2 万多个字如果不限制某个位置 argmax 可能是一个生僻字但它的绝对概率也就 0.2而原字概率 0.19这种替换没有意义。限制 top_k3 能保证我们只在高置信候选中挑选避免把常用字改成冷僻字。温度temperature是对 logits 做缩放再 softmax。温度大于 1 会把概率分布拉平小于 1 会让分布更尖锐。在纠错场景里我通常用低温 0.8 来放大正确候选和错误候选的差距这样阈值判断更稳定。用法是在计算 probs 前先除以温度logits logits / 0.8 probs torch.softmax(logits, dim-1)下表是我在模拟项目X上用过的一组初始参数具体还要按数据调整参数推荐值说明threshold0.6~0.7错误率高的数据取低值精确率优先取高值top_k3~5候选越多越容易撞上音近字一般 3 足够temperature0.7~1.0小于 1 提高置信度区分度max_length128超过则滑窗截断见 4.2注意模型输出的概率分布天然偏向高频字。即使阈值设到 0.7也可能出现把地改成的这种高频替换。下一章的拼音过滤会有效缓解这个问题。4. 把模型接到业务流水线批处理与性能调优4.1 批量推理与 padding 策略真实业务里不可能一句一句跑太慢。批量推理时不同句子长度不一样需要 padding 到同一长度。但 pad 到最长句非常浪费显存尤其是中文句子长短差异大一个 10 字和一个 500 字的句子拼一个 batch大量位置是空白 token计算量全浪费在 pad 上。常见做法是动态 padding按 batch 内最长的句子 pad。transformers 的batch_encode_plus支持pad_to_max_lengthFalse然后手动 pad。更省的方式是用DataCollatorForLanguageModeling里的 padding 逻辑但做推理时可以更粗暴一点。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(./macbert4csc-base-chinese) def batch_predict(model, texts, batch_size16): results [] for i in range(0, len(texts), batch_size): batch_texts texts[i:ibatch_size] encoded tokenizer( batch_texts, return_tensorspt, paddingTrue, truncationTrue, max_length128, ) input_ids encoded[input_ids] attention_mask encoded[attention_mask] with torch.no_grad(): outputs model(input_idsinput_ids, attention_maskattention_mask) logits outputs.logits # 后处理遍历每一句 for j in range(len(batch_texts)): seq_len attention_mask[j].sum().item() corrected postprocess_single(logits[j], input_ids[j], seq_len) results.append(corrected) return results这里paddingTrue会按本 batch 的最长句补 pad tokentruncationTrue配合max_length128会把超长句直接截断。注意attention_mask必须传给模型否则 pad token 位置也会参与注意力模型输出会被无意义的 pad 污染。在postprocess_single里遍历到seq_len为止不要把 pad token 也算进去。4.2 长句子截断的边界中文一句话超过 128 个字的情况不多但 OCR 一段段落、语音识别一整句话很容易超过 512。macbert4csc 模型的 position embedding 通常最大 512超过就报 index out of range或者被truncationTrue直接切掉后半句导致后面的错字没被纠到。我一般用滑窗方案窗口大小 120步长 80两段之间保留 40 字重叠。重叠部分不重复纠错只取前一个窗口的结果或者对重叠区域的预测结果做投票。简单实现成函数def sliding_window_correct(model, tokenizer, text, window120, stride80): if len(text) window: return correct_sentence(model, tokenizer, text) # 先按字符切分 segments [] start 0 while start len(text): end min(start window, len(text)) segments.append(text[start:end]) if end len(text): break start end - stride # 对每个窗口纠错拼接时用重叠区域的第二次结果 corrected_segments [correct_sentence(model, tokenizer, seg) for seg in segments] result corrected_segments[0] for i in range(1, len(corrected_segments)): # 假设重叠区域是窗口尾部取新窗口的叠加部分 overlap_len window - stride result result[:-overlap_len] corrected_segments[i] return result这个写法有个缺陷就是拼接口处的字可能被切碎比如一个两字词被窗口边界拆开模型看不到完整上下文。更稳的做法是把窗口边界放在标点符号上比如句号、逗号。业务里如果句子本身有标点优先按标点切没有标点再滑窗。滑窗会带来的副作用是重叠区域的字符被预测了两次如果两次结果不同到底信哪个建议以第二次即后一个窗口的预测为准因为后一个窗口能看到更完整的后续上下文。这个选择在大部分句子里是合理的。4.3 与其他纠错模块协作拼音、语言模型、编辑距离macbert4csc 单独用误伤率通常能接受但还不够稳。最典型的失败是音近字误判原句是他做的很到位模型可能把做改成作或坐因为它们在预测分布里都很接近。这时候需要把一个拼音相似度过滤器接在模型后面。思路是模型给出的候选字必须和原字拼音相同或声母韵母相近才允许替换。这样能把那些纯粹因为上下文概率高、但读音完全不同的替换挡掉。from pypinyin import lazy_pinyin import difflib def is_pinyin_similar(orig, cand): orig_py lazy_pinyin(orig)[0] cand_py lazy_pinyin(cand)[0] # 完全相同或者编辑距离小于等于1 if orig_py cand_py: return True if difflib.SequenceMatcher(None, orig_py, cand_py).ratio() 0.6: return True return Falselazy_pinyin会把单个汉字转成拼音字符串没有声调。difflib的相似度在 0.6 以上时像zhang和zang这种声母差异仍然能通过但像到和好这种完全不同音的就过不了。注意拼音过滤器只对付音近替换形状相似错字比如未和末在语音识别场景不多但在 OCR 场景里模型经常混淆形近字这时候拼音过滤反而会拦掉正确的形近纠错。所以这个模块是否启用取决于你的错误来源。另外在流水线上我会把 macbert4csc 当作候选生成器后面再接一个语言模型打分而不是直接信任输出的字。常见做法是让 macbert4csc 给出 top-k 候选然后分别把候选填入原句用一个小型的 GPT 或 N-gram 计算整句困惑度取困惑度最低的那个候选。这个模块化方案比单模型效果更稳代价是多一次语言模型推理。如果性能不允许至少保留拼音过滤和阈值控制。5. 避坑指南macbert4csc 使用中的 4 个常见问题5.1 加载报错明明解压了却提示找不到模型权重现象BertForMaskedLM.from_pretrained(./macbert4csc-base-chinese)抛异常提示Cant load weight file或者pytorch_model.bin不存在。原因大概率是解压后权重文件不在你所指的路径下。很多 rar 包内部会再套一层目录比如macbert4csc-base-chinese/macbert4csc-base-chinese/pytorch_model.bin还有一种情况是网盘下载的 rar 解压后pytorch_model.bin是 1KB 的 git-lfs 指针文件内容是一串哈希根本没有真实的权重。解决先find . -name *.bin -o -name *.safetensors确认真实模型文件位置。如果是指针文件需要删除它并重新下载完整权重如果是目录嵌套就把model_path指向内层目录。另外如果包里有model.safetensors而没有pytorch_model.binfrom_pretrained会自动加载 safetensors但如果 transformers 版本低于 4.25可能会优先找 bin 文件。升级 transformers 是更省事的办法。5.2 tokenizer 预测出大量 [UNK] 或乱码现象模型加载成功但纠错结果里有[UNK]或者输出的句子出现不存在的汉字组合。原因最常见的是输入文本包含词表外的字符比如生僻字、emoji、特殊符号。中文 BERT 词表覆盖的常用字上万但网络文本里的新造字、火星文并不在其中。另一个原因是分词器用错了如果你用BertTokenizerFast而我给的示例是BertTokenizer两者行为基本一致但如果你误用了某个全词词表模型的分词器会导致 id 映射错位。解决推理前做字符过滤把不在词表里的字符用哨兵替换或删除。先拿到词表集合vocab set(tokenizer.vocab.keys()) def clean_text(text): return .join(ch for ch in text if ch in vocab and ch not in set(tokenizer.all_special_tokens))这个逻辑简单粗暴但能保证送入模型的每个字都有合法 id。另外如果输入包含字母和数字NFKC 归一化后通常能进入词表但数字123会被切成123三个 token这对纠错没有意义下游任务如果不需要可以先用正则把纯数字段保护起来不送进模型。5.3 批量推理时 CUDA 显存突然爆炸现象单句推理显存占用 2GB改成 batch_size32 后直接 OOM但实际句子平均长度只有 20 字。原因batch_encode_plus默认paddingTrue时会把 batch 内所有句子 pad 到当前 batch 最长句如果有一个句子特别长比如 500 字整个 batch 的计算量都按 500 字展开其他短句的 pad token 也跟着参与矩阵乘法。另一个原因是你没把attention_mask传入模型导致 pad 位置也计算注意力显存翻倍。解决第一启用动态 padding并控制 batch 内的长度分布。可以在构造 batch 前按句子长度排序让长度相近的句子分到同一批这样每一批的 pad 数量最少。第二显存紧张时把max_length从 128 降到 64很多短文本场景足够。第三务必传入attention_mask可以减少约 30% 的无效计算。如果还爆就开启梯度检查点不推理不需要。直接用torch.no_grad()且把模型切成 FP16model model.half()同时把 input_ids 转成.half()不现实因为 embedding 输入是整数但模型的 forward 内部会计算浮点模型半精度就能把显存砍半。注意 CPU 推理不要用 half会慢。5.4 模型在正确句子上胡乱改字误伤率高到无法上线现象一段完全正确的文本经过 macbert4csc 后改掉了好几个字而且改出来的句子读起来也通顺但语义偏了。原因这个模型本质上是在做概率最高的字而不是错误概率最高的位置。它在面对正确句子时也会输出与原字概率相近甚至略高的高频字。如果阈值设得太低就会把正确的字替换成高频同音字。我见过有人把 threshold 设在 0.3结果一行 10 个字错了 3 个。解决第一调高 threshold 到 0.7 或 0.8先保证精确率。第二加入拼音过滤如上文 4.3只允许音近替换。第三还有一个容易被忽略的点不要对每个位置独立决策可以做一个规则——单句最多替换 3 个字超过这个数量就整句放弃纠错。为什么因为真正拼写错误的句子通常错字密度不高一个句子同时错 5 个字以上的情况很少如果模型预测 5 处改动大概率是模型在抽风。这个改动上限规则简单但能省掉大量误伤。误伤率下降后再结合召回需求逐步放松。6. 更进一步基于 macbert4csc 做领域微调的一个落地技巧通用模型在你的垂直语料上不一定好用比如电商商品标题、医疗病历、法律文书里大量专有名词和异构表达。领域微调是值得投入的方向但不要上来就学语言模型先做一个小成本的续训方案用你的领域语料让 macbert4csc 继续做 Masked Language Model 训练让它熟悉领域里的词汇搭配。具体操作分两步。第一步准备领域无标注文本随机 mask 掉 15% 的 token用DataCollatorForLanguageModeling生成训练样本。第二步用Trainer微调学习率 2e-5 到 5e-5batch size 按显存来训练 1 到 3 个 epoch。关键点是要让 model 的输出头仍然预测原始 token这样模型学的是根据上下文填原字而不是学生成新字。代码可以这么写from transformers import Trainer, TrainingArguments, DataCollatorForLanguageModeling collator DataCollatorForLanguageModeling( tokenizertokenizer, mlmTrue, mlm_probability0.15 ) training_args TrainingArguments( output_dir./csc_finetune, learning_rate2e-5, per_device_train_batch_size8, num_train_epochs2, save_total_limit2, logging_steps100, fp16True, ) trainer Trainer( modelmodel, argstraining_args, data_collatorcollator, train_datasetdomain_texts, # 已 tokenize 的 Dataset ) trainer.train()这里mlm_probability0.15是 BERT 的标准设置但纠错领域可以稍微提高到 0.2因为推理时模型需要面对的错误其实比预训练时更密集。微调后保存然后在少量人工标注的错误句-正确句对上验证误伤率。如果微调后模型还是容易把领域专有名词改掉那就需要专门保护词表把高频专名加入never_mask名单不参与 mask。这个做法比起从头训练一个纠错模型成本小得多。我在某跨平台系统的日志纠错场景里用 10 万条客服对话做续训纠错准确率从 78% 提到了 85%误伤率降了一半。值得注意续训后的模型不要丢掉对通用文本的纠错能力我一般会保留原模型和微调模型两个副本前者处理开放域输入后者处理领域输入路由规则看文本里命中的专名密度。如果只留一个最好混合通用语料和领域语料按比例训练别纯用领域语料否则模型会逐渐遗忘基础语言学知识。最后说个习惯每次微调完我都用一个固定测试集包含 200 句正确文本和 200 句人工插入错误跑一遍误伤率和召回率把结果记在实验笔记里。纠错模型是典型的改几个字任务参数变量又多不做版本对比很容易今天调好明天退回原点。希望这篇对你有帮助动手解压那个 rar 之前先把避坑章节读一遍能省下半天排查时间。本文还有配套的精品资源点击获取