简介面向语音识别开发者与算法工程师针对ASR结果缺少标点导致文本可读性差的问题资源提供一套基于PaddlePaddle的标点符号预测模型方案。压缩包共5个文件包含模型结构文件、参数文件、词表及配置信息整体约250.85MB可直接加载预训练模型用于推理或作为二次开发起点。资源已吸引4940人学习适合已具备基础ASR知识、希望快速为识别文本补充标点的实践者。价值在于打通数据准备、模型定义、训练调优到端到端整合的完整链路帮助读者理解CTC、Attention及序列标注模型在标点预测中的实际应用缩短从原理到落地的距离。1. 标点恢复模型让 ASR 转写从一串字变成能交付的稿子前阵子处理一批访谈录音ASR 输出的转写文本是一长串没有任何标点的字几千句黏在一起光断句就花了一个晚上。后来我把标点恢复当成序列标注来做模型读入每个字预测它后面该接逗号、句号还是问号再把标点插回原文本。这套方案跑在 PaddlePaddle 系的技术栈上输入是语音识别结果输出就是带标点的干净文本。适合做语音助手后处理、字幕生成、会议纪要自动排版、客服质检的开发者也适合任何被无标点转写文本折磨过的语音数据处理场景。下面把模型选型、工程结构和推理链路完整拆开讲。2. 模型架构与标签体系把标点恢复当成序列标注来做2.1 任务定义预测每个字后面的标点标点恢复punctuation restoration本质上不是文本生成而是序列标注。给一句没有标点的文本比如今天天气不错我们出去走走吧模型要预测出今天天气不错我们出去走走吧。每个输入 token 对应一个输出标签标签含义是这个字后面要不要加标点、加什么标点。这样设计有两点好处一是推理速度快一次前向就能给整句话补标点不需要自回归逐个生成二是训练稳定标签空间很小不像生成模型那样容易跑偏。ASR 转写常见的口语词然后就是呃也能被模型当作普通 token 处理而不是靠规则硬匹配。从建模角度看输入是字符序列输出是等长的标签序列这是一个标准的 token-level 分类问题。常见做法是拿预训练语言模型做编码器再接一个线性分类头微调下游任务。2.2 标签体系O、COMMA、PERIOD、QUESTION 起步标签集合可以按需扩展但最小可用集合是四个O 表示不加标点COMMA 表示加逗号PERIOD 表示加句号QUESTION 表示加问号。做中文标点恢复时还可以加上顿号、冒号、感叹号但数据量不够时标签太多容易混淆我一般先从四类起步。标签和字符是绑定的标点被挂在前一个字符后面标签含义映射 IDO不加标点0COMMA该字后加逗号1PERIOD该字后加句号2QUESTION该字后加问号3训练样本的构造规则是原始文本是带标点的干净文本先把标点从文本里剥离把标点类型记录到前一个字符的标签上得到纯文本序列和标签序列两个等长数组。这样模型只需要看纯文本就能学会在正确位置补回标点。2.3 模型结构预训练编码器加分类头模型主体用一个中文预训练语言模型作为编码器输出每个 token 的上下文向量然后接一个线性分类头把向量映射到标签空间。训练时用带标点的语料做监督数据优化目标是交叉熵损失。核心模型定义可以写成这样import paddle import paddle.nn as nn class PunctuationModel(nn.Layer): def __init__(self, base_model, hidden_size768, num_labels4): super().__init__() self.base_model base_model self.classifier nn.Linear(hidden_size, num_labels) def forward(self, input_ids): sequence_output self.base_model(input_ids)[0] logits self.classifier(sequence_output) return logits说明base_model是预训练模型的编码器部分返回的sequence_output形状是[batch_size, seq_len, hidden_size]表示每个 token 经过多层 Transformer 之后的上下文语义向量。classifier把它映射成[batch_size, seq_len, num_labels]的分数矩阵每一行对应一个 token 在四个标点类别上的置信度。最后取argmax就得到预测标签序列。参数说明hidden_size要和预训练模型的隐藏维度一致BERT 系一般是 768如果是大号模型则是 1024num_labels对应标签数量从四类扩展到七类时改这个参数就行。2.4 为什么不用正则或者句末检测很多人第一反应是写正则匹配口语停顿词或者用标点符号直接切分。但 ASR 转写文本和书面语差别很大没有停顿标记口语词密集句子边界模糊。正则只能处理然后但是这类固定连接词遇到我觉得吧就是说嗯这种真实口语规则立刻失效。句末检测通常依赖音视频的韵律特征比如停顿长度、基频变化。但很多场景下标点恢复是纯文本后处理根本没有音频特征可用。序列标注方案只依赖文本上下文和 ASR 解耦任何语音识别系统输出的文本都能接。2.5 微调细节类别不均衡必须处理标点恢复有一个非常明显的坑绝大多数 token 的标签是 O逗号和句号占比很小。如果不做处理模型会学会全部输出 O准确率看着很高但一个标点都恢复不出来。常见做法是给损失函数加类别权重O 类的权重压低标点类的权重抬高import paddle.nn as nn class_weights paddle.to_tensor([0.2, 1.0, 1.0, 1.5]) criterion nn.CrossEntropyLoss(weightclass_weights)参数说明class_weights四个值分别对应 O、COMMA、PERIOD、QUESTION。O 类出现的频率最高权重给到 0.2避免模型偏向输出 O问号数量最少但语义重要权重给到 1.5。训练时如果验证集上逗号的 F1 偏低可以适当调高 COMMA 的权重。另外标签序列里 [CLS] 和 [SEP] 两个特殊 token 也要参与损失计算但它们没有语义最好在计算损失时把这两个位置 mask 掉。实际处理是前向拿到 logits 后把label_ids中对应位置设为 -100CrossEntropyLoss会自动忽略这个位置的 loss。3. 工程与数据准备目录结构、数据清洗和样本构造3.1 工程目录结构这套代码按推理和训练拆成两块目录结构如下punc_model/ ├── checkpoints/ # 微调后的模型权重 │ └── punc_ernie/ # 当前使用的 checkpoint ├── data/ │ ├── raw/ # 原始 ASR 转写文本 │ └── processed/ # 清洗、切分后的训练样本 ├── src/ │ ├── preprocess.py # 文本清洗、标签生成 │ ├── train.py # 微调训练入口 │ ├── infer.py # 单条文本推理 │ └── align.py # 标点插入与文本还原 └── config.yaml # 模型路径、max_len 等配置checkpoints目录存放微调后的模型权重和分词器配置infer.py推理时会从这里加载。data/processed下是清洗好的训练样本每行一个 JSON包含text和labels两个字段。align.py负责推理结束后的对齐这是最容易出错的地方后面专门讲。3.2 数据清洗从带标点文本到训练样本训练数据怎么来常见做法是直接使用带标点的新闻、字幕、小说语料。处理时先把标点从文本里剥离保留标点位置信息生成纯文本和标签数组LABEL_MAP {O: 0, : 1, 。: 2, : 3} def text_to_labels(text): chars [] labels [] for ch in text: if ch in LABEL_MAP: if labels: labels[-1] LABEL_MAP[ch] else: chars.append(ch) labels.append(LABEL_MAP[O]) return .join(chars), labels这段代码的关键逻辑遇到中文标点时不把它加入chars而是把它的类别 ID 写到上一个字符的标签位置。这样chars和labels长度始终一致。labels[-1]的更新方式保证一个字符后面最多挂一个标点如果原文出现连用后者会覆盖前者实际处理时我会先把连用标点归一成一种。注意LABEL_MAP里直接用了中文标点做 key是因为清洗阶段的输入是正常排版文本ASR 输出通常没标点所以推理阶段不需要走这个函数。3.3 窗口切分控制序列长度预训练模型有最大输入长度限制中文文本常见设置是 256 或 512 个 token。直接把整篇文本塞进去会被截断后半段标点全部丢失。所以我按固定窗口切分窗口之间留 overlapdef split_text(text, max_len256, overlap32): pieces [] start 0 while start len(text): end min(start max_len, len(text)) pieces.append(text[start:end]) if end len(text): break start end - overlap return pieces参数说明max_len是每个窗口的最大字符数对中文基本等于 token 数256 比较稳妥。overlap是相邻窗口的重叠长度32 个字。为什么要重叠因为窗口边界处的 token 没有右侧上下文预测标点容易出错重叠区域相当于让模型多预测一次后处理时取后半段结果边界 token 的预测质量会好很多。3.4 评估指标按类别算 F1别只看准确率标点恢复的评估不能只看整体准确率因为 O 类占比太高。我习惯按标签分别计算 precision、recall、F1然后取宏平均。重点观察 COMMA 和 PERIOD 的 F1这两类恢复质量直接决定文本可读性。评估代码不复杂from sklearn.metrics import classification_report report classification_report( y_true, y_pred, target_names[O, COMMA, PERIOD, QUESTION], digits3 ) print(report)运行后会输出每个类别的 precision、recall、F1。如果 PERIOD 的 recall 明显低于 precision说明模型倾向于少预测句号可以在解码时把 PERIOD 的概率阈值调低一点。这个调阈值技巧在下游接字幕生成时很实用。4. 核心代码拆解一条文本从进入模型到标点落位4.1 加载模型和分词器推理阶段依赖 PaddleNLP 的 Auto 类加载权重from paddlenlp.transformers import AutoTokenizer, AutoModelForTokenClassification model_path checkpoints/punc_ernie tokenizer AutoTokenizer.from_pretrained(model_path) model AutoModelForTokenClassification.from_pretrained( model_path, num_classes4 ) model.eval()说明AutoTokenizer负责把中文文本转成 token ID 序列同时生成input_ids、token_type_ids等模型输入。AutoModelForTokenClassification会自动读取 checkpoint 里的模型结构并加载权重num_classes4对应标签数量。如果你的 checkpoint 配置文件里已经写了num_labels这个参数可以省略。这里有一个值得注意的地方加载后必须切到eval()模式。如果不切dropout 层在推理时仍然生效同一句话跑两次会得到不同的标点结果属于非常隐蔽的坑。4.2 推理前的分句和控制长度拿到 ASR 整篇文本先用上一章的分句函数切窗口。我一般还把空行和多余空格先清掉import re def clean_text(text): text re.sub(r\s, , text) return text.strip()clean_text去掉所有空白字符因为 ASR 转写里的空格不一定代表停顿留着反而干扰模型。切窗口时按max_len256切。如果你的文本本身就按句子存比如每行一句可以每行单独过模型效果更好因为句子边界天然清楚。4.3 模型前向推理调用 tokenizer 编码后直接前向import paddle def predict(text): inputs tokenizer( text, return_tensorspd, max_seq_len256, truncationTrue ) with paddle.no_grad(): logits model(**inputs) preds paddle.argmax(logits, axis-1).numpy()[0] return preds说明return_tensorspd让 tokenizer 返回 Paddle 张量省去手动转换。max_seq_len必须小于等于模型配置的max_position_embeddings如果训练时用的是 256这里也写 256。paddle.no_grad()关闭梯度计算推理内存占用大幅下降。输出的logits形状是[1, seq_len, 4]取argmax后变成seq_len个标签 ID。参数说明preds数组的第一个位置对应 [CLS] 特殊 token最后一个位置对应 [SEP]这两个位置预测的标签没有意义后处理时要跳过。4.4 标点插入与文本还原推理出的标签序列要跟原始字符对齐后插回标点LABELS [, , 。, ] def insert_punct(text, preds): tokens tokenizer.tokenize(text) result [] for i, ch in enumerate(tokens): result.append(ch) label_id preds[i 1] if label_id ! 0: result.append(LABELS[label_id]) return .join(result)说明i 1是因为preds[0]是 [CLS] 的预测要跳过。tokenizer.tokenize在中文上基本是按字切分所以tokens的长度和text的字符数一致每个 token 能对应回原文的一个字。LABELS列表的下标 0 是空字符串表示不加标点1、2、3 分别对应逗号、句号、问号。这里的潜在问题是如果文本里有英文单词或连续数字中文分词器可能会把一个词切成多个 token后面的对齐就乱了。这属于常见坑在避坑章节单独处理。4.5 批处理一次喂多段文本循环调用predict速度太慢文本段落多时要一次喂一个 batch。代码改成这样def predict_batch(texts, batch_size16): all_preds [] for i in range(0, len(texts), batch_size): batch texts[i:i batch_size] inputs tokenizer( batch, return_tensorspd, max_seq_len256, truncationTrue, paddingTrue ) with paddle.no_grad(): logits model(**inputs) preds paddle.argmax(logits, axis-1).numpy() all_preds.extend(preds) return all_preds参数说明paddingTrue会把 batch 内较短的文本补到最长文本的长度保证 tensor 形状对齐。truncationTrue保证超长文本不会把模型输入撑爆。batch_size根据显存调整CPU 上我一般用 8GPU 上 32 没问题。有个细节padding 部分对应的预测标签也要跳过后处理时根据每条文本的实际 token 长度截取有效部分不能直接用整个preds数组。5. 避坑排查长文本截断、切词错乱和 CPU 推理慢5.1 现象长文本后半段标点全部丢失处理会议纪要时发现超过 256 字的段落后半段没有逗号也没有句号文本黏成一片。原因很简单模型输入被truncationTrue截断了后半段根本没进模型。解决方法是先分句再推理。我在predict之前强制走split_text然后对每个窗口单独预测最后拼接。拼接时只保留每个窗口的后半段预测避免重叠区重复插标点def infer_long_text(text, max_len256, overlap32): pieces split_text(text, max_len, overlap) results for idx, piece in enumerate(pieces): preds predict(piece) if idx 0: # 第一段保留完整 results insert_punct(piece, preds) else: # 后续段只取非重叠部分 keep_start overlap results insert_punct(piece[keep_start:], preds) return results这个方案不算完美边界处可能有一两个标点不准但整体可读性受影响很小。如果后续还有问题可以把 overlap 从 32 加大到 64。5.2 现象英文单词和连续数字被切成碎块ASR 转写里经常出现2024年iPhone15这类内容。中文分词器对英文的处理方式是按子词切分iPhone15可能被切成iphone15每个子词都被预测一个标点标签。结果就是模型在单词中间插入逗号或者句号非常影响阅读。解决思路是前处理时保护这些连续片段def protect_entity(text): # 用占位符替换连续英文和数字 placeholder ENTITY segments re.split(r([A-Za-z0-9]), text) fenced [] for seg in segments: if re.match(r^[A-Za-z0-9]$, seg): fenced.append(placeholder) else: fenced.append(seg) return .join(fenced), segments说明先把英文数字片段替换成固定的ENTITY占位符模型在占位符上只会预测一个标签不会在单词内部插标点。推理结束后再把片段还原回去用占位符位置对应的标点标签作为整个实体结尾的标点。这样英文数字内部的预测错误率会明显下降。5.3 现象逗号和句号整体偏移一个字有一个非常隐蔽的 bug输出文本里逗号和句号的位置比正确位置靠后一个字比如今天天气不错我们变成了今天天气不错我们变成今天天气不错我们但模型实际预测的是不错后面加逗号还原时却插到了我们后面。原因通常出现在 tokenizer 和字符对齐上。中文场景tokenizer.tokenize处理常规字符没有偏差但如果文本里有全角空格、不可见字符或者分词器做 BPE 合并tokens和chars的长度就对不上了。解决方法是丢弃tokenize的结果直接用原始字符数组对齐def insert_punct_v2(text, preds): # 按字符遍历原始文本跳过 tokenizer 的隐式转换 result [] # 第一个非特殊 token 对应 text[0] char_idx 0 for i in range(1, len(preds) - 1): result.append(text[char_idx]) label_id preds[i] if label_id ! 0: result.append(LABELS[label_id]) char_idx 1 if char_idx len(text): break return .join(result)这里依赖一个前提中文文本每个字是一个 token[SEP] 前有效 token 数等于字符数加 1。用char_idx手动推进就能避免分词器差异造成的错位。5.4 现象CPU 推理慢到没法用没有 GPU 的时候一条 200 字的文本推理要花两三秒批量处理几十条文本根本等不起。PaddlePaddle 在 CPU 上有几项关键优化其中之一是启用 MKL-DNN 加速import paddle paddle.set_flags({FLAGS_use_mkldnn: True}) paddle.set_num_threads(4)参数说明FLAGS_use_mkldnn开启 Intel CPU 的深度神经网络加速库推理延迟通常能降 30% 到 50%。paddle.set_num_threads(4)控制线程数线程数不是越大越好在容器环境里设置过高反而会造成线程争抢。另一个影响速度的使用习惯是在循环内重复加载模型。正确做法是模型只加载一次放到模块全局变量里每条文本只做前向和还原。6. 二次开发接 ASR 输出做并行标点顺手解决分句对齐6.1 多线程并行推理标点恢复是纯 CPU 推理任务很容易用多线程并行榨干机器性能。我在接入语音识别服务的后处理时用ThreadPoolExecutor把多段文本分发到多个线程并行推理from concurrent.futures import ThreadPoolExecutor def batch_infer_parallel(texts, max_workers4): with ThreadPoolExecutor(max_workersmax_workers) as executor: futures [executor.submit(predict, t) for t in texts] preds [f.result() for f in futures] return preds调用时把分句和推理拆成两步先分句得到多个短片段再并行推理。每个线程内部用独立的 batch 推理接口避免多个线程同时调用同一个 tokenizer 出问题。实测四线程比单线程快两倍以上瓶颈主要在 tokenizer 编码和字符串拼接上。6.2 接入 ASR 输出的标准流程我最终的服务化代码流程固定成四步先按语义分段每段尽量是完整句子可以借助语音识别系统自带的静音检测再对每段做长度控制切分然后并行推理最后按 overlap 策略拼接标点。这四步抽成了独立脚本放进语音识别任务的离线管道。验证标点恢复质量时建议每次修改都跑同一组测试文本对比标点位置和人工标注的差异。不要只盯着准确率还要看恢复出的标点是不是符合语义结构。比如我喜欢吃苹果不喜欢吃香蕉和我喜欢吃苹果。不喜欢吃香蕉虽然都对但语义语气完全不同。从那以后我每次接 ASR 转写文本都强制先跑一遍分句脚本确认边界没问题再进模型这个习惯帮我省掉了大量返工。希望帮到你。本文还有配套的精品资源点击获取