简介:面向语音识别开发者与算法工程师,针对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 表示加问号。做中文标点恢复时,还可以加上顿号、冒号、感叹号,但数据量不够时标签太多容易混淆,我一般先从四类起步。
标签和字符是绑定的,标点被挂在前一个字符后面:
| 标签 | 含义 | 映射 ID |
|---|---|---|
| O | 不加标点 | 0 |
| COMMA | 该字后加逗号 | 1 |
| PERIOD | 该字后加句号 | 2 |
| QUESTION | 该字后加问号 | 3 |
训练样本的构造规则是:原始文本是带标点的干净文本,先把标点从文本里剥离,把标点类型记录到前一个字符的标签上,得到纯文本序列和标签序列两个等长数组。这样模型只需要看纯文本,就能学会在正确位置补回标点。
2.3 模型结构:预训练编码器加分类头
模型主体用一个中文预训练语言模型作为编码器,输出每个 token 的上下文向量,然后接一个线性分类头,把向量映射到标签空间。训练时用带标点的语料做监督数据,优化目标是交叉熵损失。
核心模型定义可以写成这样:
import paddle import paddle.nn as nn class PunctuationModel(nn.Layer): def __init__(self, base_model, hidden_size=768, num_labels=4): 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,如果是大号模型则是 1024;num_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(weight=class_weights)参数说明:class_weights四个值分别对应 O、COMMA、PERIOD、QUESTION。O 类出现的频率最高,权重给到 0.2,避免模型偏向输出 O;问号数量最少但语义重要,权重给到 1.5。训练时如果验证集上逗号的 F1 偏低,可以适当调高 COMMA 的权重。
另外,标签序列里 [CLS] 和 [SEP] 两个特殊 token 也要参与损失计算,但它们没有语义,最好在计算损失时把这两个位置 mask 掉。实际处理是:前向拿到 logits 后,把label_ids中对应位置设为 -100,CrossEntropyLoss会自动忽略这个位置的 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。直接把整篇文本塞进去会被截断,后半段标点全部丢失。所以我按固定窗口切分,窗口之间留 overlap:
def split_text(text, max_len=256, overlap=32): 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"], digits=3 ) 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_classes=4 ) model.eval()说明:AutoTokenizer负责把中文文本转成 token ID 序列,同时生成input_ids、token_type_ids等模型输入。AutoModelForTokenClassification会自动读取 checkpoint 里的模型结构并加载权重,num_classes=4对应标签数量。如果你的 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_len=256切。如果你的文本本身就按句子存(比如每行一句),可以每行单独过模型,效果更好,因为句子边界天然清楚。
4.3 模型前向推理
调用 tokenizer 编码后直接前向:
import paddle def predict(text): inputs = tokenizer( text, return_tensors="pd", max_seq_len=256, truncation=True ) with paddle.no_grad(): logits = model(**inputs) preds = paddle.argmax(logits, axis=-1).numpy()[0] return preds说明:return_tensors="pd"让 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_size=16): all_preds = [] for i in range(0, len(texts), batch_size): batch = texts[i:i + batch_size] inputs = tokenizer( batch, return_tensors="pd", max_seq_len=256, truncation=True, padding=True ) with paddle.no_grad(): logits = model(**inputs) preds = paddle.argmax(logits, axis=-1).numpy() all_preds.extend(preds) return all_preds参数说明:padding=True会把 batch 内较短的文本补到最长文本的长度,保证 tensor 形状对齐。truncation=True保证超长文本不会把模型输入撑爆。batch_size根据显存调整,CPU 上我一般用 8,GPU 上 32 没问题。
有个细节:padding 部分对应的预测标签也要跳过,后处理时根据每条文本的实际 token 长度截取有效部分,不能直接用整个preds数组。
5. 避坑排查:长文本截断、切词错乱和 CPU 推理慢
5.1 现象:长文本后半段标点全部丢失
处理会议纪要时发现,超过 256 字的段落,后半段没有逗号也没有句号,文本黏成一片。原因很简单:模型输入被truncation=True截断了,后半段根本没进模型。
解决方法是先分句再推理。我在predict之前强制走split_text,然后对每个窗口单独预测,最后拼接。拼接时只保留每个窗口的后半段预测,避免重叠区重复插标点:
def infer_long_text(text, max_len=256, overlap=32): 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"可能被切成"i""phone""15",每个子词都被预测一个标点标签。结果就是模型在单词中间插入逗号或者句号,非常影响阅读。
解决思路是前处理时保护这些连续片段:
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_workers=4): with ThreadPoolExecutor(max_workers=max_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 转写文本,都强制先跑一遍分句脚本,确认边界没问题再进模型,这个习惯帮我省掉了大量返工。希望帮到你。
本文还有配套的精品资源,点击获取