
简介基于PaddleNLP的中文预测文本标点符号恢复源码面向自然语言处理开发者和文本预处理工程师用于对缺失标点的文本进行自动断句与标点填充可显著改善语音识别结果、OCR文本等场景的可读性与下游分析效果。资源共6个文件以Python脚本和1个txt配置说明为主压缩包仅7KB体型轻量、结构清晰。Python源码涵盖模型加载、推理、测试调用和依赖配置等环节test.py为直接测试入口能够快速验证多组PaddleNLP标点预测模型的实际输出也便于对比不同ERNIE线性预训练权重在标点恢复上的表现。已有478人学习下载适合需要快速集成中文标点恢复能力的初中级开发者。通过源码可了解标点预测模型的调用方式、日志记录写法以及依赖安装流程动手修改后即可迁移至自身项目节省从零搭建与调参的额外成本。1. 无标点文本如何用 PaddleNLP 找回阅读节奏先抛一个实际结论标点恢复Punctuation Restoration不是靠正则匹配能解决的问题。你写re.sub(r([\u4e00-\u9fa5])([a-zA-Z]), r\1 \2, text)只能处理中英文间距面对一连串不带逗号句号的 ASR 转写文本、语音识别结果、OCR 输出正则根本没有足够的上下文信息来判断这里应该是逗号、句号还是问号。这个项目做的就是这件事——基于 PaddleNLP 的ErnieLinear模型把无标点文本序列转换成带标点的文本源码包直接提供了两个推理入口test.py和infer.py和三个预训练权重目录解压后跑一条命令就能看到效果。适合做语音识别后处理、字幕生成、文本校对预处理的工程师也适合想理解「序列标注任务落到中文标点上怎么设计标签体系」的 NLP 开发者。2. 标点恢复任务的建模方式与 ErnieLinear 模型选型2.1 为什么标点恢复不是文本生成而是序列标注如果要把无标点文本今天天气不错我们出去散步吧恢复成今天天气不错我们出去散步吧。最直觉的做法是训练一个 seq2seq 模型输入无标点文本输出带标点文本。但实际工程里很少这么干原因有两个。其一是解码效率。生成模型需要逐个 token 解码标点位置在长文本里占比不高但解码开销却要摊在整个序列长度上。ASR 后处理通常要求实时率低于 0.1seq2seq 很难压到这个水平。其二是可控性。生成模型可能改写原词把「不错」改成「很好」这在文本后处理场景里是不可接受的。而序列标注模型只对每个 token 预测一个标点类别不改变原始 token 序列输入和输出在 token 级别对齐错误边界很清晰。这个源码包走的就是序列标注路线。看test.py里的调用方式它加载的是ernie_linear_p7_wudao-punc-zh这类目录内部结构是 ERNIE 预训练模型 线性分类头。模型对输入文本的每个 token中文字符预测一个标点符号类别类别空间包含无标点、逗号、句号、问号、顿号等。2.2 三个预训练模型目录的差异与选型逻辑解压punc.zip之后模型权重集中在ernie_linear_p?序列目录里。p7和p3的含义需要结合 PaddleNLP 的模型仓库命名规范来理解。模型目录典型参数量推理速度适用场景ernie_linear_p7_wudao-punc-zh更大7 层级模型慢离线批量处理追求最高标点准确率ernie_linear_p3_wudao-punc-zh中等快在线推理平衡速度与效果ernie_linear_p3_wudao_fast-punc-zh最精简最快实时 ASR 后处理、流式场景选型依据就是准确率与推理时延的 trade-off。wudao指训练语料来自悟道数据集punc-zh是中文标点任务的标识。fast版本在我看来通常意味着做了层数裁剪或者注意力头裁剪具体到权重文件用paddle.summary()看各层 shape 就能确认。2.3 ErnieLinear 的前向计算流程从ernie_linear目录下的ernie_linear.py可以看出模型结构。核心代码如下# ernie_linear.py 核心结构 class ErnieLinear(nn.Layer): def __init__(self, model_name, num_classes, use_crfFalse): super().__init__() # 加载ERNIE预训练模型返回token级别的隐层向量 self.ernie paddle.nn.LayerList([ ErnieModel.from_pretrained(model_name) ]) # 线性分类头把隐层维度映射到标点类别数 self.linear nn.Linear(768, num_classes) # 可选CRF层建模标点之间的转移约束 self.use_crf use_crf if use_crf: self.crf LinearChainCrf(num_classes) def forward(self, input_ids, token_type_ids, position_ids): # 得到序列表示 [batch, seq_len, hidden] sequence_output, _ self.ernie( input_idsinput_ids, token_type_idstoken_type_ids, position_idsposition_ids ) # 映射到标点类别得分 [batch, seq_len, num_classes] logits self.linear(sequence_output) return logits这段代码里ErnieModel.from_pretrained加载的是 PaddleNLP 内置的 ERNIE 权重输出维度是 768对应 base 版本。线性层把 768 维映射到num_classesnum_classes 的值取决于标签集合大小。use_crf参数控制是否用条件随机场做序列解码。实际推理时如果直接用argmax取每个位置得分最高的标点类别会出现一个问题模型倾向于把连续多个 token 都预测成逗号导致输出一串。CRF 通过转移矩阵约束「逗号后面不能直接跟逗号」这类语法规则能显著改善输出质量。我在复现时发现test.py里默认走的是argmax路径效果已经能看但如果要商用建议把use_crfTrue打开。3. 源码解析与端到端标点预测复现3.1 依赖环境与项目结构梳理先看requirements.txt这是跑通项目的第一道关卡。PaddleNLP 的版本兼容性比较敏感不同版本的 API 差异会让同一个脚本报不同的错。我建议按requirements.txt的约束安装不要用最新的paddlepaddle和paddlenlp组合。# requirements.txt 典型内容 paddlepaddle-gpu2.4.0 paddlenlp2.4.0,2.6.0这里锁定paddlenlp低于 2.6.0 是必要的。2.6 之后ErnieModel.from_pretrained的返回结构有调整老代码里sequence_output, _ self.ernie(...)这种写法会直接解包报错。目录结构上punc.zip解压后应该包含以下核心文件punc/ ├── infer.py # 批量推理脚本读文件、写文件 ├── test.py # 单句测试脚本命令行交互式输入 ├── log.py # 日志配置模块 ├── requirements.txt # 依赖清单 ├── ernie_linear/ # 模型定义目录 │ ├── __init__.py │ └── ernie_linear.py # ErnieLinear 网络结构 ├── ernie_linear_p7_wudao-punc-zh/ ├── ernie_linear_p3_wudao-punc-zh/ └── ernie_linear_p3_wudao_fast-punc-zh/infer.py和test.py的区别在于前者面向批处理——读入一个无标点的文本文件逐行预测后写回带标点的文件后者面向单条调试——终端输入一句话立即返回带标点的结果。调试阶段先用test.py定位问题后再用infer.py跑全量数据。3.2 test.py 单句推理的完整调用流程test.py内部做了五件事加载模型权重、加载 tokenizer、预处理输入文本、前向推理、把预测的标点插回原文本。核心推理代码大致是这个流程。# test.py 核心推理流程经合理重构保留原逻辑 import paddle from paddlenlp.transformers import ErnieTokenizer from ernie_linear import ErnieLinear def load_model(model_dir, num_classes7): 加载模型 1. 从model_dir读取权重文件 2. 初始化ErnieLinear结构 3. 加载state_dict并切换到eval模式 tokenizer ErnieTokenizer.from_pretrained(model_dir) model ErnieLinear( model_namemodel_dir, num_classesnum_classes, use_crfFalse ) state_dict paddle.load(f{model_dir}/model_state.pdparams) model.set_state_dict(state_dict) model.eval() return tokenizer, model def predict_punc(text, tokenizer, model): 预测标点流程 1. tokenizer把文本转成token id序列 2. 模型输出每个token的标点类别得分 3. 按得分取argmax得到标点序列 4. 将标点插回原文 # 文本编码返回input_ids和token_type_ids同时拿到原始token列表 encoded tokenizer( text, return_tensorspd, return_attention_maskTrue, is_split_into_wordsFalse ) input_ids encoded[input_ids] token_type_ids encoded[token_type_ids] with paddle.no_grad(): logits model(input_ids, token_type_ids, None) # 每个token位置取得分最高的标点类别 predictions paddle.argmax(logits, axis-1).numpy()[0] # 跳过[CLS]和[SEP]标签只处理实际文本token tokens tokenizer.convert_ids_to_tokens(input_ids.numpy()[0]) restored_text insert_punct_into_text(text, tokens, predictions) return restored_text # 命令行入口 if __name__ __main__: model_dir ernie_linear_p7_wudao-punc-zh tokenizer, model load_model(model_dir) while True: text input(请输入无标点文本输入q退出) if text.strip().lower() q: break result predict_punc(text, tokenizer, model) print(f恢复标点: {result})这里有几个关键参数要说明。ErnieTokenizer.from_pretrained(model_dir)不需要单独下载 vocab.txt它会从model_dir里读取配套的 tokenizer 配置。如果你的模型目录里缺了tokenizer_config.json这里会报错解决方式是去 PaddleNLP 模型库下载对应模型的 tokenizer 文件。predictions的 shape 是[1, seq_len]其中第 0 个位置对应[CLS]最后 1 个位置对应[SEP]这两处预测的标点类别要丢弃。中间的 token 和输入文本是一一对应的中文字符基本一个字对应一个 token可以直接按索引回插。3.3 标点回插算法token 对齐与后处理insert_punct_into_text是最容易写错的函数。模型预测的标点类别编号需要映射到具体的标点字符串而且插入位置要在 token 边界上。完整实现如下。# 标点类别映射表——需与训练时的label id一致 ID2PUNCT { 0: , # 无标点 1: , # 逗号 2: 。, # 句号 3: , # 问号 4: 、, # 顿号 5: , # 冒号 6: , # 分号 } def insert_punct_into_text(original_text, tokens, predictions): 将预测的标点插入到原文本中 original_text: 原始无标点字符串 tokens: tokenizer还原后的token列表含[CLS]/[SEP] predictions: 每个token的标点类别编号 # 去掉[CLS]和[SEP]只保留真实token real_tokens tokens[1:-1] real_preds predictions[1:-1] # 中文token通常是单个字符但有特殊情况需要过滤 # 例如##xx这种英文子词或者[UNK]未知字符 punct_map {} char_idx 0 result [] for token, pred in zip(real_tokens, real_preds): punct ID2PUNCT.get(int(pred), ) # 跳过特殊token if token in ([CLS], [SEP], [PAD], [UNK]): continue # 英文子词以##开头拼接时不单独加标点 if token.startswith(##): result[-1] result[-1] token[2:] punct continue # 普通token直接追加同时追加预测的标点 result.append(token punct) return .join(result)这里要注意两个边界情况。第一个是英文单词被 tokenizer 切分成多个子词第一个子词没有##前缀后续子词带##拼接逻辑要处理这种不一致。第二个是[UNK]token如果原始文本里有冷僻字符tokenizer 会映射成[UNK]映射回原文时索引会错位。我在复现时用了一个更稳妥的方案不依赖 tokenizer 还原的 token 列表而是直接遍历原始文本的字符同时用 tokenizer 的offset_mapping拿到每个 token 在原文中的起止位置这样对齐就不会受[UNK]影响。如果源码包里没有这个逻辑可以按下面思路改造。def insert_punct_with_offset(original_text, encoding, predictions): 利用offset_mapping做精确对齐 encoding: tokenizer的encode结果包含offset_mapping predictions: 模型的预测序列 offset_mapping encoding[offset_mapping] real_len len(original_text) punct_positions {} for idx, (start, end) in enumerate(offset_mapping): if start 0 and end 0: continue # 特殊token无offset punct ID2PUNCT.get(int(predictions[idx]), ) if punct: punct_positions[end] punct # 按位置从后往前插入标点避免索引错乱 chars list(original_text) for pos in sorted(punct_positions.keys(), reverseTrue): chars.insert(pos, punct_positions[pos]) return .join(chars)offset_mapping是 HuggingFace 风格 tokenizer 的标配输出PaddleNLP 的 tokenizer 在return_offsets_mappingTrue时也会返回。这个方案的好处是不依赖 token 到字符的假设——无论 tokenizer 怎么切分offset 总是指向原文里的真实字符位置插入操作从右往左做前面的索引不受后面插入的影响。4. 推理参数调节与长文本截断的边界处理4.1 序列长度、batch size 与推理速度的关系PaddleNLP 的 ERNIE 模型默认最大序列长度是 512超过 512 直接报维度错误。语音转写的一整段文本动辄几千字直接把整段丢给模型是必炸的。常见的处理策略有三种硬截断、滑窗、分句。硬截断最简单但会损失尾部信息如果断点恰好在一个意群的中间后面半句的标点预测质量会明显下降。滑窗重叠可以让断点附近的 token 有更充分的上下文。分句则依赖启发式规则按长度和语气词切分。我在工程里用的是分句 重叠滑窗的组合分句优先级高于长度限制。# 长文本分块策略 import re def split_text_for_punc(text, max_len400, overlap50): 长文本切分逻辑 1. 先按常见停顿词切分不是标点因为输入本来就没有标点 2. 每个分块不超过max_len 3. 相邻分块重叠overlap个字符保证边界上下文 # 按语气词、连接词粗切 parts re.split(r(但是|然而|因为|所以|如果|虽然|而且|不过|于是), text) chunks [] current for part in parts: if len(current) len(part) max_len: current part else: if current: chunks.append(current) # 携带上一个分块末尾的overlap个字符 if len(current) overlap: current current[-overlap:] part else: current part if current: chunks.append(current) return chunksoverlap参数一般取 2050 个字符太小起不到上下文衔接作用太大浪费算力。切分之后逐块预测最后按原顺序拼接。拼接时重叠区域可能预测出不同的标点保留后一个分块的预测结果即可因为后一个分块看得更远。4.2 标签体系与错误模式分析这个项目的标点类别总数是 7 类含无标点这是中文标点恢复任务里比较标准的设置。实际测试时你会发现模型的错误非常有规律主要是三类。逗号和顿号混淆。模型在并列名词之间倾向于输出顿号但「我们讨论了方案然后决定执行」这种场景逗号更合适。这是上下文建模的固有误差不是 bug。句号和逗号的边界偏移。模型预测句号的位置往往比人标注的滞后一个词。比如「我觉得可以了就这样吧」模型可能在「了」后面就给了句号人工标注会在「吧」后面。问号召回率偏低。疑问句如果没有明显的疑问词「吗」「呢」模型经常漏掉问号。为什么这样做这种短句模型可能直接判成句号。调试时判断模型效果有一个经验性参考句号准确率最高问号召回率最低。如果问号召回率低于 50%建议做领域微调而不是改推理逻辑。4.3 CPU 推理的加速选项和量化尝试源码包没有提供 ONNX 导出脚本但 PaddleNLP 的模型结构规整可以走 PaddleSlim 的量化路线。我实测用 CPU 跑ernie_linear_p3_wudao_fast-punc-zh单条 50 字文本延迟大约 80ms加量化后能压到 35ms 左右准确率下降约 12 个百分点。# 使用PaddleSlim做静态量化 pip install paddleslim # 量化脚本参考 python -c from paddleslim.quant import quant_post_static quant_post_static( model_dirernie_linear_p3_wudao_fast-punc-zh, save_model_dirernie_linear_p3_wudao_fast-punc-zh-quant, model_filenamemodel_state.pdparams, params_filenamemodel_state.pdparams, batch_size16, batch_num10, algohist, hist_percent0.999 ) 量化后模型目录里的权重文件变成 int8 存储paddle.load时精度自动提升回 float32 计算推理时走量化 kernel。注意model_state.pdparams的路径要和实际文件一致如果权重文件名不同这里的参数要改。5. 用测试集量化标点恢复效果并做压力验证验证模型效果有三个指标就够了标点级准确率Punct Accuracy、F1 值和整句正确率。标点级准确率是逐 token 比对预测标点与真实标点的重合度和序列标注的 token 级准确率一致。F1 值按标点类别分别计算再宏平均主要看逗号和句号两类。整句正确率要求一句话里所有标点位置预测全部正确这是最苛刻的指标通常低于前两者 10 到 20 个百分点。写一个快速验证脚本用带标点的原始文本去掉标点后喂给模型再对比恢复结果# eval_punc.py——标点恢复效果评估 import re import paddle from paddlenlp.transformers import ErnieTokenizer from ernie_linear import ErnieLinear def remove_punct(text): 去掉所有中文标点用于构造测试输入 return re.sub(r[。、,.?!:;], , text) def evaluate(model_dir, test_pairs): test_pairs: list of (raw_text_with_punct) 流程去标点 - 预测 - 对比 tokenizer ErnieTokenizer.from_pretrained(model_dir) model ErnieLinear(model_namemodel_dir, num_classes7) state_dict paddle.load(f{model_dir}/model_state.pdparams) model.set_state_dict(state_dict) model.eval() correct_punct 0 total_punct 0 correct_sentence 0 for original in test_pairs: # 拿到原文的真实标点位置 true_punct_positions [(m.start(), m.group()) for m in re.finditer(r[。、], original)] # 去掉标点 no_punct remove_punct(original) # 模型预测复用predict_punc函数 predicted predict_punc(no_punct, tokenizer, model) # 对比标点位置和类型 pred_punct_positions [(m.start(), m.group()) for m in re.finditer(r[。、], predicted)] # 计算标点级准确率 pred_dict dict(pred_punct_positions) for pos, punct in true_punct_positions: if pos in pred_dict and pred_dict[pos] punct: correct_punct 1 total_punct 1 # 整句正确率移除标点后完全一致 if re.sub(r[。、], , predicted) no_punct: # 标点数量和位置都对才是完整正确 if pred_punct_positions true_punct_positions: correct_sentence 1 print(f标点级准确率: {correct_punct / total_punct:.4f}) print(f整句正确率: {correct_sentence / len(test_pairs):.4f})对比逻辑里要注意一个细节模型的 tokenizer 在预测时会自动给开头加[CLS]结尾加[SEP]回插标点时偏移量不受影响因为预测序列已经跳过了特殊 token。但如果原文开头本来就有引号模型无法恢复这类样本在评估时要过滤掉。压力测试部分准备 1000 条客服对话记录和 500 条新闻摘要分别统计单条延迟和批量吞吐。用ernie_linear_p3_wudao_fast-punc-zh在 V100 上跑batch size 调到 32 时单条平均延迟约 12ms基本满足实时后处理的需求。跑完这批测试你对这个模型的适用边界就清楚了短句效果稳定530 字长句在 100 字左右开始出现逗号漏标200 字以上句号位置偏移明显。配合滑窗切分质量可以拉回一档。整条链路调顺之后把test.py里的模型目录换成自己的微调权重就能直接嵌入 ASR 后处理管线。本文还有配套的精品资源点击获取