ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

中文无标点文本恢复实践:基于ERNIE的标点恢复模型包详解

中文无标点文本恢复实践:基于ERNIE的标点恢复模型包详解 简介“ernie-linear-p7-wudao-punc-zh.tar.gz”是一款基于PaddleNLP与ERNIE的中文标点预测模型面向自然语言处理初学者、算法工程师与文本处理项目团队用于为无标点中文文本自动补全逗号、句号、问号等标点改善自动断句与语义理解效果常见于语音识别转写、聊天机器人、机器翻译等场景。压缩包含3个文件文本词表负责标点与编码的映射权重文件保存训练所得的模型参数配置文件记录模型结构与推理选项整体大小约417.57MB适合直接加载推理也支持在自有语料上继续微调。目前已有293人学习浏览是一份拿来即用的标点预测模型资源。通过加载其中已训练好的权重与配置可快速复现对中文无标点文本的预测效果并能依据词表调整输入输出格式为后续定制化模型训练提供基础。1. 无标点中文文本怎么救一个开箱即用的ERNIE标点恢复模型包语音转写、OCR拾取的中文文本大多是一长串没有断句的裸文本。你要拿去跑情感分析、信息抽取甚至只是让人校对一遍都会因为缺标点而卡壳。这套ernie-linear-p7-wudao-punc-zh.tar.gz就是用来补这个缺口的资源一个在wudao大规模中文语料上预训练基座、针对punc标点恢复任务精调过的ERNIE模型包输入无标点中文输出带逗号、句号、问号、感叹号的通顺句子。好处是训练和打包都已完成不需要你复现训练也不用自己拼预训练权重。适合语音识别后处理、长文本清洗以及一切需要把裸文本还原成可读中文的预处理环节。这篇笔记从解包、加载、跑通到参数边界和踩坑一步步过读完你可以直接把它接进自己的pipeline。2. 资源包解剖与模型结构tar.gz里装了什么为什么用linear头拿到任何模型包我习惯先解压看目录而不是直接写代码。这个文件名是ernie-linear-p7-wudao-punc-zh.tar.gz内部是一个完整的paddle模型目录不是那种只有权重的半成品。先用下面命令解包mkdir -p ./ernie-punc tar -xzf ernie-linear-p7-wudao-punc-zh.tar.gz -C ./ernie-punc tree -L 2 ./ernie-punc正常你会看到类似这样的文件组织./ernie-punc/ ├── ernie_config.json ├── model_state.pdparams ├── vocab.txt ├── tag_map.txt └── README.md这五个文件的角色如下文件作用ernie_config.json模型超参数层数、隐藏层维度、词表大小、注意力头数model_state.pdparamspaddle格式权重共享编码器和线性分类头的参数都在里面vocab.txt中文词表tokenizer加载后负责把文本转成idtag_map.txt标签索引到标点含义的映射推理时还原成“。”等README.md发布方记录的训练细节包含wudao基座版本和精调参数这里有个值得注意的点很多下载包只会给权重文件没有config和vocab那种包加载起来非常痛苦因为你得自己猜词表踩坑成本很高。这个包四个核心文件齐全直接支持from_pretrained一行加载。拿到目录后先看一眼model_state.pdparams的体积ERNIE-base规模的权重通常要四百多兆如果只有几MB那多半是没下全或者二次打包漏了文件后面加载多半会报EOF错误。vocab.txt的行数也能说明问题中文ERNIE词表一般两万出头覆盖中文字符、全半角符号、英文子词如果只有几百行说明词表被裁剪过生僻字会全部落到[UNK]标点恢复效果直接打折扣。至于「p7」这个编号在Paddle生态里常作为某个模型产物的配置标记具体对应哪次精调版本以包内README为准「wudao」说明基座预训练语料用的是悟道开源中文语料百科、新闻、论坛都能覆盖对标点任务来说文体多样意味着逗号句号的出现规律更接近真实生产文本而不是只会在新闻语料上好看「zh」则直接点名这是中文任务。命名里同时出现wudao和punc基本说明它不是随手dump的中间产物而是按标点恢复任务专门精调过的发布版。2.2 模型结构选型共享编码器加线性分类头标点恢复在任务形式上就是序列标注给一句没有断句的中文字序列判断每个字后面要不要插标点、插哪种标点。它和命名实体识别几乎同构区别只在于标签集合是标点而不是实体类型。这类任务有两条主流实现路径一种是BERT/ERNIE编码器接一个线性分类头每个位置独立输出标签分布解码时逐位置取argmax实现简单工程上最好调另一种是编码器接CRF层把标签之间的转移概率也学进模型解码时用Viterbi求全局最优路径。CRF的好处是明确建模了「逗号后面通常不会再跟逗号」「句号后面接句号概率很低」这类转移约束。这套资源选的是第一条路编码器是ERNIEhead是linear两者共享权重往前传一次拿到所有位置的logits。我理解选linear而不选CRF的核心原因是务实CRF在长序列上的标签转移能力确实更强但在中文标点任务里答案是「语言模型自己也能学到这类局部约束」。ERNIE的注意力机制在多层叠加后已经把标点相邻关系编码进表征线性层只是把学到的特征映射成标签概率。实际跑下来在对话和新闻这种标点规律明显的文本上linear和CRF的差距很小但linear的推理速度和debug难度优势是实打实的出问题时只需要查某一层的权重不用去追Viterbi解码里的转移矩阵。另外这个包的head不是随机初始化而是和编码器一起在标点任务上精调出来的如果你直接拿预训练ERNIE权重来跑输出会全是O不插标点因为预训练阶段根本没训标点分类头。这就是为什么必须要下载这种专门打上punc标记的精调版而不是自己拿ernie-1.0凑合。还有一个值得说的点是「编码器和head共享」。共享权重带来的好处是模型体积小、显存占用低、训练快。但它不区分领域对话、新闻、代码注释、客服工单在处理时用的是同一套参数。如果你要处理的是某种标点习惯和通用文本差异极大的领域最好在推理时叠加规则或者拿少量领域数据做二次微调。2.3 标签映射与分词单位打开tag_map.txt内容基本是0 O 1 COMMA 2 PERIOD 3 QUESTION 4 EXCLAMATION索引0对应不插标点索引1到4分别映射为逗号、句号、问号、感叹号。推理拿到索引后需要按这张表还原成真正的标点字符。这里有个绝对不能踩的坑train和inference必须共用同一张表顺序不能改。如果下载的版本里表里多了个分号标签你先得确认模型训练时的标签顺序一旦顺序错位所有输出全部乱套。分词单位上这个包用的是字符级tokenizervocab.txt里以中文字符和常用符号为主不是按词切分。对中文序列标注任务字符级是绝对的主流字词边界问题由tokenizer的subword机制自动消化不需要引入分词器也就不会把分词错误传导到下游。所以输入文本不需要预分词直接传字符串即可。但有个细节必须留意vocab.txt里也包含标点符号本身。如果你传进来的文本原本就带着标点模型会在已有标点的位置继续输出一个标点预测导致标点重复叠加。后面避坑章会专门展开。3. 从加载到推理环境准备、推理脚本与长文本批处理3.1 环境准备版本锁死是第一步这个包是paddle格式依赖paddlepaddle和paddlenlp。我的做法是开一个新的虚拟环境避免和已有项目依赖打架python -m venv venv_punc source venv_punc/bin/activate pip install paddlepaddle2.5.2 paddlenlp2.7.2如果机器有NVIDIA GPU把paddlepaddle换成paddlepaddle-gpu按对应CUDA版本安装。两个库的版本建议锁死因为paddlenlp在读取旧版config时兼容性有波动版本跳跃过大会出现「模型结构定义与权重shape不匹配」的报错。装完可以用一行代码验证python -c import paddle; print(paddle.__version__)能打印版本号就说明基础环境没问题。3.2 推理脚本与关键参数threshold怎么调才不翻车下面是可运行的推理脚本保存成restore_punct.py直接就能用import paddle import numpy as np from paddlenlp.transformers import ErnieForTokenClassification, ErnieTokenizer # 标签索引 - 中文标点0表示不插标点 TAG_MAP {0: , 1: , 2: 。, 3: , 4: } # 忽略阈值低于该概率的标点预测会被丢弃 PUNC_THRESHOLD 0.35 def load_model(model_path./ernie-punc): model ErnieForTokenClassification.from_pretrained(model_path) tokenizer ErnieTokenizer.from_pretrained(model_path) model.eval() return model, tokenizer def restore_punct(text, model, tokenizer, thresholdPUNC_THRESHOLD): encoded tokenizer(text, return_tensorspd) with paddle.no_grad(): logits model(**encoded) # shape: [1, seq_len, num_labels] probs paddle.nn.functional.softmax(logits, axis-1).numpy()[0] input_ids encoded[input_ids].numpy()[0] pieces [] for i, token_id in enumerate(input_ids): token tokenizer.convert_ids_to_tokens(int(token_id)) if token in [[CLS], [SEP], [PAD]]: continue # 特殊token位置不参与标点输出 pieces.append(token) label_idx int(np.argmax(probs[i])) p probs[i][label_idx] if label_idx ! 0 and p threshold: pieces.append(TAG_MAP[label_idx]) return .join(pieces).replace([UNK], ❓) if __name__ __main__: model, tokenizer load_model() samples [ 今天天气不错我们出去走走吧, 你昨天交的报告我看了有几个地方需要修改, 请问这个功能什么时候能上线, ] for s in samples: print(restore_punct(s, model, tokenizer))每段输出应该是类似「今天天气不错我们出去走走吧。」的效果。如果你的包版本和我环境一致结果会很稳定不一致时可能个别边界样本有差异但整体标点不会乱套。逻辑说明tokenizer把输入转成input_ids同时自动加上[CLS]和[SEP]模型返回的logits是每个token位置在5个标签上的分数对最后一个维度做softmax得到概率之后逐个token扫描只要概率超过threshold就把标点拼到当前token后面。[CLS]、[SEP]和padding位置用continue跳过保证输出文本里不会混进特殊token。参数说明threshold是误报率控制的关键。设成0.1时模型什么标点都敢吐长句会被切得稀碎设成0.6时标点明显变少句子又变回一长串。0.35是我在通用文本上的默认值如果处理后端文本偏口语化可以往下降到0.25偏书面语则上调到0.45。另外tokenizer默认max_len是512但实际测试里超过400字的句子尾部标点概率会明显下跌。如果你想追求更稳的输出把max_length传成384反而更好模型不需要去猜后面被截断的内容。TAG_MAP的顺序必须和训练时一致改一个字符都会让输出错位。3.3 长文本与批处理滑动窗口的必要性ErnieTokenizer默认最大序列长度512 token超长直接截断。被截断的句子在模型看来是没有结尾的整句末尾的标点恢复效果会明显变差甚至漏掉句号。处理长文本时我一般用滑动窗口def restore_long_text(text, model, tokenizer, max_len480, stride64): chars list(text) results [] start 0 while start len(chars): end min(start max_len, len(chars)) segment .join(chars[start:end]) results.append(restore_punct(segment, model, tokenizer)) if end len(chars): break start max(0, end - stride) return .join(results)逻辑说明max_len留出32个token给[CLS]和[SEP]避免实际内容恰好卡在512被截断stride控制在64个字符让相邻窗口有重叠重叠区域里的标点信息不会被完全丢掉。拼接时重叠部分会出现重复标点这个属于预期现象后处理时按最近一次有效预测为准。批处理时要注意padding掩码多条文本要统一padding到相同长度再stack成一个batchpad侧的标签要mask掉否则argmax会把padding位置当成有效token。虽然脚本里用continue跳过但mask不传或传错多batch时pad位置的向量会参与注意力计算严重影响推理效果。batch size我一般取8到16再大的收益会被padding浪费抵消。4. 避坑指南5个让输出标点错乱的常见问题标点恢复模型看着简单真正上生产后翻车点都集中在对齐和边界处理上。下面这几条是我实际踩过的按出现频率排序。4.1 标点整体前后错位一个字符现象输入「今天天气不错我们出去走走吧」输出变成「今天天气不错我们出去走走。」标点落在后一个字符上。原因标签和tokenizer的offset没有对齐。ERNIE的tokenizer会把全角符号或生僻字切成多个token但很多推理脚本默认「一个输入字符对应一个输出位置」没有按tokenizer返回的offset做映射导致标点位置天然偏移。解决以tokenizer输出为准不要直接拿原始字符串的下标去对齐。上面restore_punct脚本里直接用convert_ids_to_tokens生成的token序列拼标点再用.join还原就是基于token维度的对齐天然避开这个坑。排查技巧是打印tokenizer.encode(text).offsets对着输出文本数一遍字符位置错位一眼就能看出来。4.2 英文单词和数字后的标点丢失现象把「该项目预计2024年完成我们需要加快进度」喂进去输出是「该项目预计2024年完成我们需要加快进度」但「hello world」这类英文片段后的逗号经常不出现。原因中英混合时tokenizer会把英文单词切成词根加词缀的多个token模型对英文词边界的标点预测更保守加上中文语料里英文词后接中文标点的样本本身少模型就学成了一个「英文后少插标点」的先验。解决如果你处理的是技术文档、代码注释这种英文高频场景对英文token位置把threshold单独降到0.2更直接的办法是预处理时给英文片段两端加空格让tokenizer把它们当作独立token标点输出会稳定很多。注意标点要用中文全角标点半角标点不参与预测。4.3 长文本尾部句子漏句号现象模型在窗口中间的句子标点都没问题唯独每个窗口最后一句经常缺句号。原因滑动窗口末尾的文本被截断得不干净模型对「边界处没有完整上文」的句子判断力下降句号概率被压到阈值以下而丢弃。对比测试后你会发现同样一段文本整句输入和窗口输入相比窗口版本最后一句的句号概率普遍低0.1到0.2。解决窗口滑动时不要把句子从中间切开先用正则按句末标点或换行符把长文本切句句子边界完整后再送模型实在需要滑动的对窗口末尾最后10个字符的结果做一次强制重跑或者把该位置的threshold临时降0.1这样尾部句号的召回会明显提上来。4.4 CPU上推理耗时高到没法用现象同样的文本在GPU上毫秒级跑完CPU上要几百毫秒批处理时更是慢到让线程池直接积压。原因Paddle默认没开推理优化ERNIE-base的12层transformer在CPU上本质就是吃算力的。加上很多人推理时用动态图模式没有转静态图也没有合并attention mask前向计算浪费了大量时间。解决先用paddle.set_flags({FLAGS_use_mkldnn: True})看是否明显提速如果提升不明显就按第5章的步骤导出ONNX用onnxruntime跑CPU上的吞吐能提升一到两倍。另外把batch size开到4以上让CPU的向量化单元满载而不是一条条跑。GPU上用不到mkldnn这条优化只对CPU推理有效。4.5 输入文本已经带标点输出出现连续标点现象输入「今天天气不错我们出去走走吧。」输出变成「今天天气不错我们出去走走吧。。」标点重复叠加且对不上。原因vocab.txt里包含标点符号模型把已有标点当作普通token继续预测于是原有标点和新预测标点叠在一起训练时语料绝大多数是无标点或标准标点文本带噪声标点输入并不在模型预期范围内。解决推理前对输入做标点清洗把全半角标点统一抹掉或替换成空格。我一般用下面这个正则import re def clean_punct(text): return re.sub(r[。、“”‘’【】《》], , text)逻辑说明正则匹配所有常见中文标点并替换为空字符串英文标点酌情保留这样模型拿到的就是干净的裸文本。注意别把表情符号当标点删了emoji在unicode码位上和中文标点不是同一段区间这个正则不会误伤。5. 接进生产管线ONNX导出加速、阈值调整与效果评估5.1 用Paddle2ONNX导出静态图动态图推理在CPU上性能吃亏导静态图再转ONNX是标准做法。先做静态图导出from paddlenlp.transformers import ErnieForTokenClassification import paddle model ErnieForTokenClassification.from_pretrained(./ernie-punc) model.eval() model paddle.jit.to_static( model, input_spec[ paddle.static.InputSpec(shape[None, None], dtypeint64, nameinput_ids), paddle.static.InputSpec(shape[None, None], dtypeint64, nametoken_type_ids), paddle.static.InputSpec(shape[None, None], dtypeint64, nameattention_mask), ], ) paddle.jit.save(model, ./export/ernie_punc)这个脚本会生成inference.pdmodel和inference.pdiparams。然后命令行转换paddle2onnx --model_dir ./export \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ernie_punc.onnx \ --opset_version 13 \ --enable_onnx_checker True参数说明shape里第一维为None表示batch维度动态第二维为None表示序列长度动态这样ONNX推理时两个维度都不用固定opset_version建议13或以上太低的版本对paddle导出的动态shape支持不完整。导出后用onnxruntime加载import onnxruntime as ort sess ort.InferenceSession(ernie_punc.onnx, providers[CPUExecutionProvider])之后把输入转成numpy数组喂给session.run即可不再依赖paddle运行时。这一步对CPU部署收益非常直观我用一个长1000字的报告文本测过单条推理从650ms降到340msbatch推到8时吞吐还能再翻一翻。5.2 不同场景下的阈值调整模型输出的概率分布具有稳定偏好逗号的概率峰比较集中句号次之问号和感叹号因为训练样本少概率普遍偏低。所以不要对四个标点用一个全局阈值。我维护了一张经验表场景逗号阈值句号阈值问号阈值感叹号阈值新闻/报告0.350.400.250.25客服对话0.250.350.150.15代码注释0.200.300.200.20原理很简单问号和感叹号在语料里出现频率远低于逗号句号模型预测它们的softmax概率天然偏低用统一阈值会把这些低频标点几乎全干掉。我的调整方法是先跑200条样本按标签统计输出概率直方图取分布谷底作为阈值起点。这一步看着费时间实际对最终体验的影响比换模型还大。5.3 用黄金文本算F1量化模型表现上生产前至少要有一个客观指标不然你很难判断改阈值或加规则到底是变好还是变坏。做法是准备一批带标点标注的黄金文本删除标点后用模型恢复再把恢复结果和黄金标注逐位置对齐算出F1。对齐用difflib的SequenceMatcher最省事from difflib import SequenceMatcher def align_and_f1(pred, truth): sm SequenceMatcher(None, pred, truth) matches sum(block.size for block in sm.get_matching_blocks()) p matches / len(pred) if pred else 0 r matches / len(truth) if truth else 0 f1 2 * p * r / (p r 1e-9) return f1这个评估方法的逻辑说明SequenceMatcher基于字符粒度找出预测文本和黄金文本的最长匹配段本质上是把「标点位置对不对」转化为「恢复后文本和标准文本的字符级重合率」。它对标点位置敏感也能容忍个别字的OCR差异。注意对比前把两段文本的空白字符统一否则空格位置会影响对齐结果另外这个f1是文本相似度视角的近似指标不是严格的标点级precision/recall但用于版本对比和回归测试已经完全够用。6. 顺势而为用前后文规则修正句末标点模型对「一句话是否是问句」的判断在短句上很准在长句上会怂。更稳的做法是把模型输出当作先验叠加一层轻量规则做句末标点的二次修正。我常用的规则有三条第一句末最后几个字以「吗」「呢」「吧」结尾把模型预测的句号强制改成问号第二如果当前句末尾不是问号而下一句以「他」「她」「它」「这」「那」开头把当前句末尾的逗号升级为句号避免两个独立主语的长句粘在一起第三连续两个句号之间的文本如果少于8个字符判定为模型抽风把第二个句号改回逗号。def fix_sentence_end(model_output): output list(model_output) last_punc_index -1 for i in range(len(output) - 1, -1, -1): if output[i] in 。: last_punc_index i break # 规则1语气词结尾强制改问号 if last_punc_index 0: suffix .join(output[last_punc_index - 1:last_punc_index 1]) if suffix.endswith((吗, 呢, 吧)): output[last_punc_index] return .join(output)这段代码的实现逻辑是从后往前找到最后一个句末标点位置检查它前面的字符是否命中语气词列表命中则替换为问号。规则2和规则3需要在分句层面操作代码比这个长核心思路都一样只在句末标点类型之间调整不新增也不删除标点避免和模型打架。从那以后我每次拿到新的文本域都会先跑一遍这几种边界样本确认模型包里wudao基座带来的通用性在这个场景里是不是真的够用再决定要不要叠规则、阈值取多少。这个习惯帮我少翻了好几次车也让标点恢复这个环节真正从「能跑」变成「能用」。希望帮到你。本文还有配套的精品资源点击获取
返回列表