PaddleNLP标点恢复实战:序列标注、ERNIE训练与滑窗推理避坑

发布时间:2026/10/6 12:48:42
PaddleNLP标点恢复实战:序列标注、ERNIE训练与滑窗推理避坑
简介基于PaddleNLP的中文标点符号预测源码包面向自然语言处理初学者与开发者解决纯文本缺乏标点导致阅读不畅、下游任务效果下降的问题。压缩包共6个文件包含5个Python脚本与1个TXT配置整包仅7KB体积轻量、结构清晰Python脚本分别承担推理、工具函数与模型封装等职责TXT文件即依赖说明便于快速搭建运行环境。调用测试脚本即可完成从原始文本到带标点输出的端到端推理并可通过内置配置切换或参考已有预训练模型的调用流程。源码包特别适合对语音识别结果、字幕文本等无标点内容做后处理也可作为教学示例帮助理解PaddleNLP的预测流程与模型加载方式。已有478人学习下载适合希望在短时间内跑通标点恢复流程并在此基础上替换模型或调整参数做二次开发的读者借助日志输出也能快速定位问题。1. 给无标点文本加回标点PaddleNLP 做标点恢复先看清任务边界一段语音转写文本没有标点时读起来非常难受“今天开会讨论上线方案大家有什么意见”到底是一句话还是两句人和机器都容易判错。这个任务在 NLP 里叫标点恢复PaddleNLP 可以把它当成一个序列标注问题来做给输入文本的每个 token 预测“后面要不要补标点、补什么标点”。这篇文章要拆的就是基于 PaddleNLP 的预测文本标点恢复源码该怎么组织、参数怎么设、坑在哪。适合做 ASR 后处理、字幕生成、知识库文本清洗的工程师以及在文本流水线里想自己加标点模块的人。下面我按标签体系、数据构造、训练推理、踩坑排查的顺序把一条可复现的路径完整讲清楚。2. 标点恢复的任务边界与模型选型序列标注为什么是主流2.1 任务定义给每个 token 预测一个标点标签标点恢复的输入是一条没有标点符号的文本输出是带标点的文本。常见做法是把它建模成 token 级分类对输入序列里的每一个 token模型预测一个类别表示这个 token 后面要不要加标点、加哪种标点。比如“今天真不错我们出去玩吧”这个句子模型需要对“好”预测“句号”对“吧”预测“句号或感叹号”其他位置预测“不加标点”。这里有一个关键设计决策输入里到底要不要保留标点。有些初学者会把带标点文本直接喂给模型让模型去“填空”但推理时你拿到的往往是已经剥掉标点的文本训练和推理的输入分布不一致效果会很差。我一般会直接把语料里的标点剥掉把标点类别记到前一个 token 的标签上这样训练和推理的输入形式完全一致模型学到的就是从无标点序列到标点位置和类别的映射。这个方案的另一个好处是模型输出可控。标点类别集合是固定的比如逗号、句号、问号、感叹号加上一个“无标点”类总共五类。模型不会输出集合之外的符号不会出现生成式模型那种“自己造一个标点”的情况。2.2 标签体系设计位置映射比 BIO 更省事标签体系我建议直接用单标签位置映射而不是 NER 里的 BIO 体系。所谓位置映射就是为每个 token 打一个标点类别标签标签表示“当前 token 后面插入什么标点”。# 标点类别定义 PUNCT_LABELS [O, COMMA, PERIOD, QUESTION, EXCLAMATION] label_to_id {label: i for i, label in enumerate(PUNCT_LABELS)} # 每个样本由两组等长序列组成 # tokens: [今, 天, 真, 不, 错, 我, 们, 出, 去, 玩, 吧] # punct_labels: [0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 2] # 2 表示该 token 后面要补句号1 表示逗号3 表示问号4 表示感叹号0 表示不加这段代码的逻辑是标签数组和 token 数组一一对应标签只记录“当前位置后面要加的标点”。训练时模型的输出层接一个五分类线性层loss 用交叉熵padding 位置忽略掉。推理时遍历预测结果遇到非 0 标签就在对应 token 后面插入标点。为什么不推荐 BIOBIO 是为实体边界设计的一个实体由多个 token 组成所以需要 B 和 I 来区分开始和内部。但标点是一个位置上的离散事件不存在“一个标点跨越多个 token”的情况。用 BIO 会增加解码复杂度还要处理“B 后面没有 I”这类情况而且连续标点场景下反而更麻烦。单标签位置映射简单直接一行代码就能完成训练标签构造和推理标点插入。2.3 模型选型ERNIE 3.0 还是更小的模型PaddleNLP 里做标点恢复我一般优先试 ERNIE 3.0 的中文预训练模型效果和速度的平衡比较理想。如果线上延迟敏感可以用它的 mini 或 tiny 版本如果追求最高精度且不愁算力再考虑更大规模的版本。选型主要看三个约束精度要求、推理延迟、显存预算。模型倾向精度表现推理速度适合场景大模型最好慢离线批量标注、高质量文本库清洗中等模型好中等常规在线服务多数业务够用小模型尚可快实时 ASR 后处理、低配 CPU 部署标点恢复和常见的分类任务不太一样它对底层语义的依赖没有实体识别那么强。在很多场景里“这个位置是不是该断句”主要看局部上下文比如语气词、句末助词、相邻词搭配。所以小模型往往也能达到可用水平不一定非要上最大模型。我的做法是先用中等模型跑通数据链路再根据线上指标决定要不要换大模型。2.4 为什么不用生成式模型用生成式模型做标点恢复是把这个任务当成“无标点文本到有标点文本”的翻译任务输入和输出长度几乎一样而且输出不允许改写原文。生成式模型在解码时会随机采样有可能把“我们出去玩吧”生成成“我们出去玩吧”也可能会改动个别字词这在标点任务里是不允许的。另外生成式的推理延迟比序列标注高一个量级在线服务很难扛。序列标注模型对每个 token 独立分类不会改动原文预测速度快还能通过调整分类阈值来控制句号和逗号的输出密度。工程落地时这一点非常重要你可以先跑一版默认模型再根据业务反馈调阈值而不需要重新训练。3. 构造训练样本把带标点语料剥成无标点文本的预处理脚本3.1 数据从哪来开源中文语料与清洗规范标点恢复的训练数据不需要人工标注任何带正确标点的中文文本都能用。常见做法是取开源中文语料比如 CLUECorpus、中文维基百科、新闻语料按句切分后进行清洗。清洗有一些硬性步骤去掉 HTML 标签、去掉控制字符、去掉重复行、过滤超短文本。清洗时最容易被忽略的是引号和括号。训练时我会统一处理成“忽略引号、括号、书名号”只保留逗号、句号、问号、感叹号四类作为预测目标。省略号和破折号要么拆开处理要么直接丢弃否则会让标签体系变得很碎模型学不过来。如果你希望模型输出冒号和分号也可以加进标签集合但要确认训练语料里这些符号的数量足够否则类别不均衡会把训练带偏。import re def clean_text(text: str) - str: # 去掉 HTML 与不可见字符 text re.sub(r[^], , text) text re.sub(r[\x00-\x1f\x7f], , text) # 统一全角标点为半角这一步只用于文本清洗 text text.replace(, ,).replace(。, .).replace(, ?).replace(, !) # 去掉引号、括号、书名号保留四类核心标点 text re.sub(r[“”‘’《》【】], , text) return text.strip()这段代码把语料里的全角标点转成半角是为了后续统一映射。注意训练语料里本身可能混着全角和半角标点如果不统一模型会把“”和“,”当成两种不同情况。统一之后再进入标点映射逻辑可以减少很多不必要的类别。3.2 核心逻辑token 级对齐而不是字符级对齐构造训练样本时有一个容易翻车的地方标签对齐。很多人习惯按字符处理把每个汉字对应一个标签但遇到英文和数字就会出问题。ERNIE 这类预训练模型的 tokenizer 会把一个英文单词切成多个子词比如 “iPhone15” 可能被切成 “iphone” 和 “15” 两个 token这时候按字符对齐的标签数组长度就和 token 数组长度对不上了。正确做法是先 tokenize再在 token 序列上做标签对齐。这样无论中文字、英文子词还是数字片段标签数组的长度始终和 token 数组一致。from paddlenlp.transformers import ErnieTokenizer tokenizer ErnieTokenizer.from_pretrained(ernie-3.0-base-zh) # 标点字符到标签 ID 的映射 PUNCT_MARK_TO_LABEL { ,: 1, # COMMA .: 2, # PERIOD ?: 3, # QUESTION !: 4, # EXCLAMATION } def build_sample_from_sentence(sentence: str, tokenizer): # 先 tokenize标点会作为独立 token 出现在结果里 tokens_with_punct tokenizer.tokenize(sentence) tokens [] labels [] for tok in tokens_with_punct: if tok in PUNCT_MARK_TO_LABEL: # 标点 token 不进入输入序列把标点类别记到前一个 token 上 if tokens: labels[-1] PUNCT_MARK_TO_LABEL[tok] continue tokens.append(tok) labels.append(0) # 默认当前 token 后面不加标点 return tokens, labels这段代码的关键点在labels[-1] PUNCT_MARK_TO_LABEL[tok]遇到标点 token 时不把它加入输入而是把它的类别写到前一个 token 的标签上。这样输入序列里完全没有标点但每个 token 都携带了“后面要不要加标点”的信息。连续标点的情况也要处理比如 “” 这种组合代码里后一个标点会覆盖前一个标点的类别。实际使用中我会把“?!” 和 “!?” 统一映射成感叹号或问号让标签更干净。3.3 预处理脚本与参数说明有了单句构造逻辑再套一层批量切分就能得到训练集。中文语料要先按句末标点切句因为模型看到的样本应该是一个个完整句子而不是一大段不断句的文本。如果直接把整段话丢进模型被 [SEP] 截断的位置会造成标签不完整而且句子边界信息也会丢失。def split_sentences(text: str): # 按句末标点切句保留标点信息 parts re.findall(r[^。!?][。!?]?, text) return [p.strip() for p in parts if p.strip()] def preprocess_corpus(raw_lines, tokenizer, max_len128): samples [] for line in raw_lines: line clean_text(line) for sent in split_sentences(line): tokens, labels build_sample_from_sentence(sent, tokenizer) if len(tokens) 4: # 过滤过短样本 continue samples.append((tokens, labels)) return samples这里有几个值得注意的点。其一max_len128不是随便选的标点依赖的上下文通常就在一句话以内128 个 token 足够覆盖绝大多数句子而且 batch 里能塞进更多样本训练速度更快。很多新手喜欢把 max_len 设成 512结果显存占用翻倍训练变慢F1 却没有提升。其二过滤过短样本是因为长度小于 4 的文本往往没有足够上下文模型学不到有用的模式还会放大类别噪声。其三切句逻辑里用的正则会保留句末标点这是因为句号本身也是我们要学习的对象不能随意丢弃。3.4 滑窗切分max_seq_len、stride 与标签对齐单句样本构造好了长文本还要做滑窗切分。虽然训练时可以按句子为单位但现实里的文本往往是一大段话上线推理时不可能把整段直接塞进模型。滑窗切分的目标是让长文本的每个 token 都有机会被模型看到并且窗口之间要留出重叠区域避免句末标点刚好被切在窗口边缘而丢失。def sliding_window(tokens, labels, max_len128, stride64): windows [] for start in range(0, len(tokens), stride): end min(start max_len, len(tokens)) if end - start 8: break windows.append((tokens[start:end], labels[start:end])) if end len(tokens): break return windowsstride是窗口滑动的步长。我一般设置为max_len的一半也就是 128 的窗口、64 的步长这样相邻窗口有 50% 重叠。重叠的意义在于应对边界效应模型在窗口开头和结尾的预测质量通常差一些因为有部分上下文被截断了重叠区域让中间部分有更多机会被正确预测。推理时再配合“只取窗口中间部分标签”的策略能有效减少漏标点。4. 训练与推理的关键参数从 3e-5 学习率到滑窗边界处理4.1 训练脚本骨架ErnieForTokenClassification CrossEntropyLoss训练部分直接用 PaddleNLP 的 ERNIE token classifier 即可。模型输出每个 token 的 logits形状是[batch_size, seq_len, num_labels]把它 reshape 成[batch_size * seq_len, num_labels]再算交叉熵。padding 位置和 [CLS]、[SEP] 位置的标签要设置成ignore_index否则模型会去学习“在 padding 后面加标点”这种无意义模式。import paddle from paddlenlp.transformers import ErnieForTokenClassification model ErnieForTokenClassification.from_pretrained( ernie-3.0-base-zh, num_classeslen(PUNCT_LABELS), ) # 训练循环中的关键步骤 for batch in dataloader: input_ids, token_type_ids, attention_mask, labels batch logits model(input_ids, token_type_ids, attention_mask) loss paddle.nn.functional.cross_entropy( logits.reshape([-1, len(PUNCT_LABELS)]), labels.reshape([-1]), ignore_index-100, # -100 位置不参与 loss 计算 ) loss.backward() optimizer.step() optimizer.clear_grad()ignore_index-100是整个训练脚本里最重要的细节。数据预处理时[CLS] 位置、[SEP] 位置和 padding 位置的标签都设成 -100这样 loss 只统计真实 token 位置。如果忘了这一步模型会被 padding 位置的“无标点”标签干扰推理时遇到短文本会倾向于少输出标点。另外还要注意attention_mask要传给模型让模型忽略 padding 位置的自注意力计算。4.2 四个必调参数learning_rate、max_seq_len、warmup、类别权重训练标点恢复模型我发现最影响结果的四个参数分别是学习率、最大序列长度、warmup 比例和类别权重。学习率我一般用3e-5。这是预训练模型微调的常见起点如果任务数据量较小可以用2e-5。学习率调太大会出现一个很典型的症状模型倾向于输出感叹号验证集上每个句子都像在喊口号。原因是标点类别里感叹号的训练样本相对集中大步长让模型过度拟合这部分模式。max_seq_len 保持在 128 就够了不需要追长序列。标点恢复本质是局部上下文依赖一句话说完了标点决策也随之结束。把 max_seq_len 设成 512 只会在 batch size 不变的情况下成倍增加显存消耗而 F1 基本不涨。warmup 比例设为0.1意思是前 10% 的训练步数学习率从 0 线性升到目标值。预训练模型微调时如果一开始就用大学习率更新容易破坏已经学好的词法特征。warmup 给了模型一个“热启动”的过程尤其是数据量不大时这个参数能明显提升训练稳定性。类别权重是很多人忽略的一项。五类标签里“无标点”O 类通常占 70% 以上如果直接算交叉熵模型只要全部预测 O 就能拿到很低的 loss但实际一点用都没有。常见的做法是在 loss 里给句号、问号、感叹号这些少数类加权重或者用 Focal Loss。我用过的最简单方案是给五类手动设[1.0, 1.5, 2.0, 2.0, 2.0]这样的权重把少数类权重大致放 2 倍左右模型输出的标点密度会明显提高。4.3 推理时为什么必须剥掉已有标点训练时输入序列里没有标点推理时如果用户传来的文本里残留标点比如 “今天天气真好我们出去玩吧”模型的输入分布就被破坏了。模型会看到两个逗号而它在训练时从未学过“带逗号的序列要怎么预测逗号”结果往往是在已有标点附近额外加一个标点或者输出错乱的标签。所以推理前必须统一做一次标点剥离def strip_punct(text: str) - str: # 去掉模型不需要预测的四类标点保持与训练输入一致 return re.sub(r[,。.!?], , text)注意这里剥离的不是所有符号而是模型可预测的那几类。引号、括号、书名号这类符号在训练时已经被清洗掉推理时同样应该去掉否则也属于分布外输入。我一般会在推理服务入口加一个统一前处理函数保证输入模型的文本永远是“纯无标点 token 序列”。4.4 滑窗推理重叠窗口与标签拼接策略推理长文本时滑窗的逻辑和训练时类似但多了一个关键动作丢弃窗口靠近边界的预测结果。模型在窗口边界的预测通常不可靠因为窗口右侧或左侧的上下文被截断了所以常见做法是只保留距离窗口两端一定范围内的预测。def predict_long_text(text, tokenizer, model, max_len128, stride64): stripped strip_punct(text) tokens tokenizer.tokenize(stripped) best_preds [None] * len(tokens) for start in range(0, len(tokens), stride): end min(start max_len, len(tokens)) window_tokens tokens[start:end] # 构造 input_ids 并送入模型得到每个 token 的标签概率 logits model_infer(tokenizer, model, window_tokens) # 只保留距窗口两端 8 个 token 以内的中间区域预测结果 safe_start start 8 safe_end min(end - 8, len(tokens)) for i in range(safe_start, safe_end): best_preds[i] logits[i - start] return restore_punctuation(tokens, best_preds)这里的“8 个 token”是经验值。窗口重叠率越高安全区域越大漏标点越少但推理耗时也会增加。性能敏感的场景可以把安全区间缩小到 4 个 token精度优先的场景用 12 个 token。如果某个 token 在重叠区域被两个窗口都预测过且预测结果不一致我一般取概率更高的那个也就是比较模型输出的 softmax 分数而不是简单取后面的窗口。这样能尽量减少拼接处标签跳变。5. 避坑标点恢复任务的五个常见翻车点与排查记录5.1 翻车点一推理文本里残留标点模型乱点标现象上线后模型输出的文本里出现“你好。今天”这种连续两个标点的情况甚至原有标点附近多出标点。原因训练输入是从不带标点的 token 序列构造的推理时如果直接把带标点文本喂进去模型相当于见到了分布外数据。它看到已有的逗号还预测这里该加标点自然就会叠加出连续标点。解决推理入口处必须统一执行strip_punct()把模型能预测的四类标点全部剥掉再进入模型。这条规则要写进服务代码的必经路径不能依赖调用方自觉。5.2 翻车点二长文本窗口接缝处漏句号现象一段 500 字的长文本按单窗口推理时模型表现尚可滑窗后反而在窗户接缝位置经常漏掉句号整段话读起来像一口气没喘完。原因滑窗推理时窗口右侧的预测被丢弃如果句子的句号刚好落在窗口末尾而下一个窗口开始时这个句号对应的 token 又因安全区间限制被跳过句号就被“夹”丢了。解决提高窗口重叠率把 stride 从 64 降到 32让每个 token 至少被两个窗口覆盖。同时安全区间不能设得太小8 个 token 是比较平衡的起点。排查时可以把每个 token 的预测来源窗口数打印出来如果大量 token 只被一个窗口覆盖说明 stride 太大了。5.3 翻车点三数字与英文 token 错位导致标签全乱现象包含 “iPhone15”“GDP 增长” 等文本时预测结果经常在英文和数字前后加莫名其妙的逗号而且同一个词在不同上下文里标签不一致。原因ERNIE 的 tokenizer 会把一个英文词切成多个子词切分结果和我们的标签数组错位。如果标签对齐逻辑按字符来token 数组长度和标签长度不一致模型学到的映射就是乱的。解决严格按tokenizer.tokenize()的结果来构造标签不要自己按字符切分。对连续英文和数字可以在预处理时统一替换为占位符再进模型比如把字母串替换为X、数字串替换为0推理时再替换回来。这样模型看到的是稳定的 token 切分而不是每次切法都不一样。5.4 翻车点四验证集 F1 很高线上句号稀疏得像没有现象验证集 F1 达到 0.85但线上样本输出的大段文字里几乎见不到句号逗号也偏少很多句子缺乏停顿。原因F1 是精确率和召回率的调和平均而语料里“无标点”类别占大多数模型只要保守地在不确定位置输出 O就能把 F1 保住。验证集和训练集分布接近掩盖了这个问题但线上文本风格不同模型的“保守”就暴露了。解决训练时给少数类加权减少 O 类对 loss 的主导。评估时不要只看 F1加一个“每句平均标点数”的统计指标比如中文里一句话平均应该有 1.52 个标点。如果模型输出标点密度明显低于语料统计值说明模型偏向保守需要调类别权重或降低无标点类的预测阈值。5.5 翻车点五口语文本上模型像“复读机”每句话都加感叹号现象对 ASR 转写的口语文本做标点恢复时模型频繁输出感叹号比如“嗯嗯好的”、“哈哈哈”看起来情绪饱满但完全不符合实际场景。原因训练语料以新闻和百科为主书面语里感叹号出现频率低而模型学到的是“语气词后面大概率跟感叹号”。口语文本里语气词更密集模型把这种模式放大导致感叹号泛滥。解决加一批口语语料做二次微调。常见做法是收集客服对话、直播转写、语音转写文本和原来的书面语料按比例混合重新训练。如果不想重新训练全量模型可以在原有模型基础上用口语语料继续训练几个 epoch但要用很小的学习率比如1e-5避免破坏原有能力。6. 验证与提速技巧标点错误率怎么算推理怎么压到可用水平标点恢复的验证指标不能只看准确率因为无标点类别占大头全预测无标点都能有很高的准确率。我一般按标点类别分别算精确率、召回率和 F1只有“标点位置和标点类型都一致”才算预测正确。另外还会统计每句平均标点数跟人工标注语料的统计值对比防住“F1 高但输出标点稀疏”的情况。from sklearn.metrics import f1_score def evaluate_punct(labels_true, labels_pred): # 只统计非 O 类别的 F1O 类太多会掩盖少数类问题 mask labels_true ! 0 return f1_score(labels_true[mask], labels_pred[mask], averagemacro)推理提速方面我习惯按顺序做三件事第一推理时关闭梯度并把模型切到 eval 模式PaddleNLP 模型在 eval 模式下的显存占用和耗时都会下降第二用批量推理替代单条推理把多条文本拼成一个 batch一次前向处理完第三用 Paddle Inference 或 ONNX 导出模型开启 MKLDNN 或 FP16 精度这个步骤通常能再压掉一半延迟。小模型在 CPU 上开启 MKLDNN 后单条短文本的推理延迟可以降到几十毫秒级别已经能满足在线标点恢复的多数场景。我踩过最深的坑是上线前只看 F1没有看标点密度结果内部评测漂亮线上返回的文本几乎没有句号被业务方直接打回。现在我把“每句平均标点数”写进验收清单指标不过关不允许发布。标点恢复这个方向数据分布和推理前处理的影响比模型大小更重要先把数据链路做对再考虑换大模型希望帮到你。本文还有配套的精品资源点击获取