☰
PaddleNLP标点恢复实战:序列标注、ERNIE训练与滑窗推理避坑
2026/10/6 12:48:29 网站建设 项目流程

简介:基于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 后面插入标点。

为什么不推荐 BIO?BIO 是为实体边界设计的,一个实体由多个 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_len=128): 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_len=128不是随便选的,标点依赖的上下文通常就在一句话以内,128 个 token 足够覆盖绝大多数句子,而且 batch 里能塞进更多样本,训练速度更快。很多新手喜欢把 max_len 设成 512,结果显存占用翻倍,训练变慢,F1 却没有提升。其二,过滤过短样本是因为长度小于 4 的文本往往没有足够上下文,模型学不到有用的模式,还会放大类别噪声。其三,切句逻辑里用的正则会保留句末标点,这是因为句号本身也是我们要学习的对象,不能随意丢弃。

3.4 滑窗切分:max_seq_len、stride 与标签对齐

单句样本构造好了,长文本还要做滑窗切分。虽然训练时可以按句子为单位,但现实里的文本往往是一大段话,上线推理时不可能把整段直接塞进模型。滑窗切分的目标是:让长文本的每个 token 都有机会被模型看到,并且窗口之间要留出重叠区域,避免句末标点刚好被切在窗口边缘而丢失。

def sliding_window(tokens, labels, max_len=128, stride=64): 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 windows

stride是窗口滑动的步长。我一般设置为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_classes=len(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_len=128, stride=64): 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.5~2 个标点。如果模型输出标点密度明显低于语料统计值,说明模型偏向保守,需要调类别权重或降低无标点类的预测阈值。

5.5 翻车点五:口语文本上模型像“复读机”,每句话都加感叹号

现象:对 ASR 转写的口语文本做标点恢复时,模型频繁输出感叹号,比如“嗯嗯好的!”、“哈哈哈!”,看起来情绪饱满但完全不符合实际场景。

原因:训练语料以新闻和百科为主,书面语里感叹号出现频率低,而模型学到的是“语气词后面大概率跟感叹号”。口语文本里语气词更密集,模型把这种模式放大,导致感叹号泛滥。

解决:加一批口语语料做二次微调。常见做法是收集客服对话、直播转写、语音转写文本,和原来的书面语料按比例混合重新训练。如果不想重新训练全量模型,可以在原有模型基础上用口语语料继续训练几个 epoch,但要用很小的学习率,比如1e-5,避免破坏原有能力。

6. 验证与提速技巧:标点错误率怎么算,推理怎么压到可用水平

标点恢复的验证指标不能只看准确率,因为无标点类别占大头,全预测无标点都能有很高的准确率。我一般按标点类别分别算精确率、召回率和 F1,只有“标点位置和标点类型都一致”才算预测正确。另外还会统计每句平均标点数,跟人工标注语料的统计值对比,防住“F1 高但输出标点稀疏”的情况。

from sklearn.metrics import f1_score def evaluate_punct(labels_true, labels_pred): # 只统计非 O 类别的 F1,O 类太多会掩盖少数类问题 mask = labels_true != 0 return f1_score(labels_true[mask], labels_pred[mask], average="macro")

推理提速方面,我习惯按顺序做三件事:第一,推理时关闭梯度并把模型切到 eval 模式,PaddleNLP 模型在 eval 模式下的显存占用和耗时都会下降;第二,用批量推理替代单条推理,把多条文本拼成一个 batch,一次前向处理完;第三,用 Paddle Inference 或 ONNX 导出模型,开启 MKLDNN 或 FP16 精度,这个步骤通常能再压掉一半延迟。小模型在 CPU 上开启 MKLDNN 后,单条短文本的推理延迟可以降到几十毫秒级别,已经能满足在线标点恢复的多数场景。

我踩过最深的坑是上线前只看 F1,没有看标点密度,结果内部评测漂亮,线上返回的文本几乎没有句号,被业务方直接打回。现在我把“每句平均标点数”写进验收清单,指标不过关不允许发布。标点恢复这个方向,数据分布和推理前处理的影响比模型大小更重要,先把数据链路做对,再考虑换大模型,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询