资讯详情

资讯详情

ASR标点恢复实战:基于PaddlePaddle的字符级序列标注

简介面向语音识别后处理场景的标点预测模型包适合已掌握语音识别基础、希望为转写文本自动补全标点以提升可读性的开发者与学习者。压缩包共五个文件包含模型结构文件、参数权重文件、词表文本、配置说明与信息记录等类型整体约250.85MB体量适中可直接加载推理也可作为基于飞桨框架二次训练与调优的起点。内容结合语音识别输出特点帮助理解标点预测模型如何学习语句结构与断句规律并将标点模块与识别系统衔接成端到端处理链路。已有4948人学习适合参考预训练参数、词表与配置组织方式快速验证语音转写文本的断句和标点插入效果减少从零搭建模型及整理工程文件的时间也便于对照模型文件结构开展本地实验。1. 会议转写稿一长串没标点问题不在 ASR 识别率而在后处理缺了一层对语音识别结果加上标点符号模型对语音识别结果加上标点符号模型本质上不是给 asr 输出做美化而是把一串没有停顿的语音识别文本重新切成可读的句子。做会议转写、客服质检、字幕生成的同学最熟悉这个场景语音识别把每个字都吐对了整段稿子却像一条没有休止符的长句人眼断句全靠猜下游做摘要和检索也难受。这个项目用 paddlepaddle 把标点恢复做成字符级序列标注输入是 asr 裸文本输出是带逗号、句号、问号、感叹号的文本模型只负责在字与字之间插标点不改原字。适合已经有一版语音识别结果、想加一层轻量后处理的开发者也适合拿它当序列标注练手项目。2. 项目定位与技术选型为什么用 PaddlePaddle 做序列标注而不是让 ASR 端到端硬扛2.1 任务边界标点恢复不是“再识别一遍”ASR 解码的目标是给定声学特征找最可能的词序列标点符号本身不是发音单元所以很多语音识别结果天然没有标点。端到端让 ASR 直接吐标点听起来省事实际会引入两个麻烦一是标注成本高需要大量“音频—带标点文本”配对二是标点预测会干扰声学解码识别稳定性容易下降。把标点恢复拆成后处理输入输出都变成纯文本工程上更好落地。这个项目的任务定义很窄输入一串字符输出每个字符后面应该接什么标点。标签集常见做法是O、、。、、需要更细可以加、、、。模型不生成新字也不改错字只做插入决策。这样一来ASR 引擎换不换、识别结果来自哪家服务都不影响后处理层。我在模拟项目X里看到的数据流是ASR 原始文本先做清洗去掉多余空格和重复标点再转成字符序列训练时从带标点语料里去掉标点得到输入把被去掉的标点挂到前一个字符的标签上推理时模型逐字打分超过阈值才插入标点。这个边界定清楚后面模型结构、评估指标和上线兜底才不会跑偏。2.2 技术路线对比规则、统计、序列标注和生成式各自适合什么场景标点恢复不是新问题常见路线有四类。规则法靠关键词和长度阈值断句开发快但遇到口语化转写就露怯统计法看 n-gram 搭配比规则灵活可维护性一般序列标注把每个字符分类成熟、可控、推理快适合大多数 ASR 后处理生成式模型能重写句子但容易改原字在转写场景里要非常小心。路线优点短板适合场景规则阈值上线快、可解释长句和口语化文本误判多临时兜底、固定格式文本统计模型比规则灵活特征工程重维护成本高领域固定、语料充足序列标注推理快、不改原字、易评估依赖字符级标注质量ASR 后处理、字幕断句生成式重写句子通顺度高可能改字、延迟高对原文容忍度高的摘要场景这个项目选序列标注不是因为它最新而是因为它和“只插标点、不动原字”的业务约束最匹配。PaddlePaddle 在这类任务上的好处是动态图调试直接paddle.nn.Layer写自定义模型不绕训练完还能走静态图导出做服务端推理。对一线开发来说先跑通一个 BiLSTM baseline再换预训练编码器比一上来堆大模型更稳。2.3 目录结构与数据流拿到源码包先看哪几个文件源码包目录一般不会太复杂重点看数据、模型、训练脚本和推理脚本四块。下面是我按这类项目常见做法整理的目录树名字和模拟项目X的实际结构可能有出入但职责划分基本一致。punctuation/ ├── configs/ │ └── base.json # 词表、标签集、最大长度、训练超参 ├── data/ │ ├── train.txt # 带标点语料一行一条 │ ├── dev.txt │ └── vocab.txt # 字符级词表OOV 走 unk ├── models/ │ └── punct_model.py # Embedding BiLSTM Linear ├── datasets/ │ └── punct_dataset.py # 样本构造、padding、collate_fn ├── train.py # 训练入口 ├── evaluate.py # 字符级准确率、句子级 F1 └── predict.py # 单条/批量推理输出带标点文本文件先看什么改哪里configs/base.json标签集、max_len、batch_size业务要加问号、分号时改标签集datasets/punct_dataset.py标签对齐逻辑清洗规则、截断策略models/punct_model.py输入输出维度换编码器、加 CRF 层train.py损失函数和优化器类别权重、学习率predict.py阈值和插标点函数句首禁逗号、连续标点合并数据流可以串成一条线data/train.txt里是带标点文本punct_dataset.py去掉标点生成输入同时把标点挂到前一个字符的标签上train.py把 batch 喂给punct_model.pypredict.py加载权重后对 ASR 裸文本逐字打分按阈值插入标点。第一次跑先别改模型把max_len和batch_size看清楚很多“训练不收敛”其实是长句被截断后标签对不上。提示先跑python train.py --config configs/base.json看 loss 是否正常下降再跑python predict.py --text 今天天气不错我们去会议室吧看输出。baseline 没跑通之前不要急着换预训练模型。3. 数据与标签工程从 ASR 裸文本到可训练的标点序列3.1 语料清洗与标点归一化ASR 转写文本的脏法很固定中英文混排、数字带单位、空格乱插、引号括号成对出现但标点重复。清洗目标不是把文本改得面目全非而是让训练和推理看到同一种分布。常见做法是统一全角半角、去掉多余空白、合并连续标点保留数字和英文原样。import re # 只处理中文常用标点英文标点按业务需要再加 PUNCT_SET set(。、) def normalize_text(text: str) - str: 清洗 ASR 裸文本训练和推理共用同一套规则。 text text.strip() # 去掉所有空白中文转写里空格通常是识别引擎切词留下的 text re.sub(r\s, , text) # 连续标点合并成一个避免“。。。。”这种噪声 text re.sub(r([。、])\1, r\1, text) return text def strip_punct(text: str) - str: 去掉标点得到模型输入。 return .join(ch for ch in text if ch not in PUNCT_SET)normalize_text里的re.sub(r\s, , text)会删掉英文单词之间的空格如果业务里英文占比高要改成只删中文相邻空格否则hello world会变成helloworld。strip_punct只去掉PUNCT_SET里的标点引号、括号、省略号是否保留要看标签体系不要一边训练去标点、一边推理又把引号当字符喂进去分布不一致模型会懵。清洗完的语料要抽 200 条看一眼重点看三件事句末标点是不是被误删、连续标点合并后语义有没有变、数字单位有没有被拆坏。这一步很土但能省掉后面大量排查时间。3.2 标签体系与对齐每个字后面挂一个标点标签字符级序列标注的关键是对齐。带标点文本“今天天气不错我们开会吧。”去掉标点后输入是“今天天气不错我们开会吧”标签长度要和输入字符数一致不后面是吧后面是。其余字符标签为O。如果对齐错一位模型学到的就是“前一个字预测后一个标点”上线后整体后移血泪经验。TAGS [O, , 。, , ] tag2id {tag: i for i, tag in enumerate(TAGS)} id2tag {i: tag for tag, i in tag2id.items()} def build_labels(text: str, tag2id: dict) - tuple: 带标点文本 - (去标点输入, 每个字符对应的标签 id) chars [] labels [] for ch in text: if ch in PUNCT_SET: # 标点挂到前一个字符上句首标点直接丢弃 if chars: labels[-1] tag2id.get(ch, tag2id[O]) continue chars.append(ch) labels.append(tag2id[O]) return .join(chars), labelsbuild_labels里labels[-1] tag2id.get(ch, tag2id[O])是核心遇到标点不往输入里加字符而是回写前一个字符的标签。连续标点时后一个会覆盖前一个如果业务要保留“”这种组合得把标签集改成组合标签或者单独加一条规则后处理。tag2id.get的兜底是防止语料里出现标签集之外的标点训练时直接报错比静默变O更好建议改成显式抛异常。对齐逻辑一定要写单测拿一条“今天天气不错我们开会吧。”跑一遍断言输入长度等于标签长度断言不的标签是断言吧的标签是。。这个单测不写后面 loss 不降你都不知道是模型问题还是数据问题。3.3 构造 Dataset 与 padding 策略paddle.io.Dataset只负责单条样本padding 放到collate_fn里做这样不同 batch 可以按当前最大长度补齐显存利用率比全量补到max_len高。标签 padding 用-100配合CrossEntropyLoss(ignore_index-100)忽略掉不然模型会学“padding 位置输出 O”推理时容易在句尾多吐标点。import paddle class PunctDataset(paddle.io.Dataset): def __init__(self, samples, vocab, tag2id, max_len256): self.samples samples # [(裸文本, 标签id列表), ...] self.vocab vocab self.tag2id tag2id self.max_len max_len def __getitem__(self, idx): text, tags self.samples[idx] input_ids [self.vocab.get(ch, self.vocab[unk]) for ch in text][:self.max_len] labels tags[:self.max_len] return input_ids, labels def __len__(self): return len(self.samples) def collate_fn(batch): max_len max(len(item[0]) for item in batch) input_ids, labels [], [] for ids, tags in batch: pad_len max_len - len(ids) input_ids.append(ids [0] * pad_len) # 0 是 pad labels.append(tags [-100] * pad_len) # -100 被损失忽略 return ( paddle.to_tensor(input_ids, dtypeint64), paddle.to_tensor(labels, dtypeint64), )max_len256是字符数不是词数中文长句很容易超。超过max_len的样本直接截断会丢后半句标点训练时建议按句号切分再截断推理时用滑窗。vocab[unk]要确认词表里存在pad的 id 固定为 0Embedding 层要设padding_idx0否则 padding 也会参与梯度。collate_fn返回的labels形状是[batch, seq_len]训练时 reshape 成[batch*seq_len]再算损失。4. 模型与训练拆解BiLSTM 编码器加线性分类头先把 baseline 跑稳4.1 模型结构Embedding、BiLSTM、Linear 三段式baseline 不需要一上来就上大模型。字符级 Embedding 把每个字映射成稠密向量BiLSTM 从左到右、从右到左各扫一遍拿到上下文表示最后线性层给每个字符输出num_tags个 logits。这个结构参数量小单卡训练快排查问题也直观loss 不降先看数据loss 降但推理全是O先看类别分布。import paddle import paddle.nn as nn class PunctModel(nn.Layer): def __init__(self, vocab_size, num_tags, emb_dim128, hidden_dim256): super().__init__() self.emb nn.Embedding(vocab_size, emb_dim, padding_idx0) self.lstm nn.LSTM( emb_dim, hidden_dim, num_layers1, directionbidirect, time_majorFalse, ) self.out nn.Linear(hidden_dim * 2, num_tags) def forward(self, input_ids): x self.emb(input_ids) # [B, L, E] x, _ self.lstm(x) # [B, L, 2H] logits self.out(x) # [B, L, T] return logitsemb_dim128、hidden_dim256是常见起点语料小于十万句可以先不加大。directionbidirect输出维度是hidden_dim * 2线性层输入别写错。padding_idx0让pad的向量不更新但 LSTM 仍会看到 padding长 batch 里 padding 太多会拖慢速度所以后面要按长度分桶。需要更强上下文时可以换预训练编码器但线性分类头和标签体系可以不动改造面小。4.2 训练循环与损失函数ignore_index 和标签平滑的取舍训练循环重点看三件事标签 padding 是否被忽略、梯度是否累积、学习率是否过大。CrossEntropyLoss(ignore_index-100)把 padding 位置排除Adam学习率从1e-3起步batch 小的时候可以降到5e-4。如果标签分布极度不均O占九成以上可以给损失加类别权重或者先对含标点的句子过采样。model PunctModel(vocab_sizelen(vocab), num_tagslen(TAGS)) loss_fn nn.CrossEntropyLoss(ignore_index-100) optimizer paddle.optimizer.Adam(learning_rate1e-3, parametersmodel.parameters()) for epoch in range(10): model.train() for input_ids, labels in train_loader: logits model(input_ids) # [B, L, T] loss loss_fn(logits.reshape([-1, len(TAGS)]), labels.reshape([-1])) loss.backward() optimizer.step() optimizer.clear_grad()logits.reshape([-1, len(TAGS)])和labels.reshape([-1])是一一对应的reshape 顺序不能乱。optimizer.clear_grad()放在step()后面PaddlePaddle 动态图里这是固定顺序。训练日志至少打loss、学习率、当前 batch 平均长度平均长度突然变长往往是数据分桶没做好不是模型玄学。4.3 评估与解码字符级准确率不够要看句子级标点 F1字符级准确率在标点稀疏时很有欺骗性全预测O也能到九十几但一个标点都没插对。评估要同时看标点位置的 precision、recall、F1以及句子级“插入标点后和参考文本是否一致”的粗略准确率。解码时先 softmax 拿概率再按阈值决定是否插入阈值不要硬编码在模型里放到配置里方便按业务调。def token_accuracy(logits, labels, ignore_index-100): preds paddle.argmax(logits, axis-1) mask labels ! ignore_index correct paddle.logical_and(preds labels, mask) total paddle.sum(mask.astype(int64)).item() return paddle.sum(correct.astype(int64)).item() / max(total, 1) def insert_punct(text, probs, id2tag, threshold0.5): probs: [L, T] 的 numpy 数组逐字决定插不插标点 out [] for ch, prob in zip(text, probs): out.append(ch) tag_id int(prob.argmax()) tag id2tag[tag_id] if tag ! O and float(prob[tag_id]) threshold: out.append(tag) return .join(out)threshold0.5是起点业务怕多插逗号就调到0.6怕句子太长就降到0.4。insert_punct只做逐字插入连续两个标点、句首逗号、引号前后标点这些要靠后处理规则兜。评估脚本里把参考文本和预测文本都去掉标点后比对确保模型没有改原字如果去掉标点后不一致说明解码或数据对齐有 bug先修这个再谈 F1。5. 排查与避坑标点模型上线后最容易翻车的五个地方5.1 数据与标签类问题模型只输出 O先别怪模型结构现象一训练 loss 一路降推理结果全是O一个标点都不插。原因通常不是模型 capacity 不够而是标签分布失衡O占 95% 以上交叉熵被O主导模型学一个全O的策略就能拿到很低 loss。解决分三步先统计训练集标签分布看每个标点的占比再对含标点的句子过采样或者给非O标签加类别权重最后拿规则基线对比规则至少能在句末加句号模型连规则都不如就不要急着上线。我一般会在训练脚本里打印每个 epoch 的预测标签分布全O一眼就能看出来。现象二推理结果整体后移一个字标点总挂在下一个字后面。这是典型的标签对齐错位。build_labels里如果先chars.append(ch)再判断标点或者把标点挂到后一个字符训练时模型学到的就是“看到后一个字的特征预测前一个标点”。解决方法是写单测固定一条“今天天气不错我们开会吧。”断言输入是“今天天气不错我们开会吧”断言不的标签是吧的标签是。。这个单测跑不过后面调参都是白费。5.2 训练与推理类问题padding、截断和 OOV 的连锁反应现象三短句标点正常长句后半段一个标点都没有。先看max_len。字符级模型常见max_len256超过就截断训练时后半句标签直接丢掉推理时也只对前 256 个字符打分。解决方法是训练按句号切分保证单条不超过max_len推理用滑窗窗口之间留 32 个字符重叠重叠区只保留前一个窗口的标点避免重复插入。滑窗的stride不要等于max_len否则边界处的标点会丢。现象四batch 推理时 padding 位置冒出逗号短句末尾多标点。原因在collate_fn补了pad模型对 padding 位置也输出了 logitsargmax之后没有 mask 掉。解决方法是推理时记录每条样本的真实长度只取前length个位置的预测或者在insert_punct里传入valid_len循环到valid_len就停。训练侧用ignore_index-100只是让损失忽略不会让模型不输出推理侧必须自己截。现象五同一句话单条推理正常批量推理标点错乱。常见原因是动态 batch 里长短句混在一起padding 太多LSTM 在 padding 上继续传播状态影响短句表示。解决方法是按长度分桶batch_size不要只按条数设可以按总 token 数设上限服务端限制单条最大长度超长先切分再合并。上线前用 100 条长短混合样本跑一遍单条和批量结果对比不一致就查 padding 和 mask。5.3 服务与性能类问题批处理大小不是越大越好现象六QPS 上去后延迟抖动标点结果偶尔错位。标点模型本身不重抖动多半来自动态 shape 和长句拖尾。批处理大小从 8 加到 64吞吐可能涨但单批里最长句决定 padding 长度短句被补很多padLSTM 计算浪费。解决方法是按长度分桶后再组 batch设置max_batch_tokens超长请求走单独队列。服务端加一层输入清洗把明显超长的转写稿先按句号切分再逐段推理最后拼回去。6. 二次开发把通用标点模型改成业务里的逗号/句号/问号策略6.1 改标签体系先决定业务到底要哪几种标点通用模型标签集通常是O、、。、、但业务里不一定都要。客服质检可能只关心句号断句字幕场景更关心逗号会议转写要保留问号。改标签体系时TAGS列表、tag2id、id2tag、损失函数输出维度、推理插入函数要一起改漏一个就会维度报错。标签不是越多越好分号、冒号、顿号在 ASR 文本里出现频率低加了反而稀释训练信号我一般先只保留业务强需求的 3 到 4 类。6.2 改推理阈值与兜底规则别让模型在句首加逗号模型逐字打分阈值决定插不插。业务怕逗号太多就把逗号阈值调高怕句子太长就把句号阈值调低。兜底规则至少加三条句首不插逗号、连续标点合并、引号内不强行断句。下面这个配置字典可以直接放进configs/base.json改完不用动模型代码。PUNCT_CONFIG { threshold: {: 0.55, 。: 0.45, : 0.5, : 0.5}, forbid_at_start: {, 。}, # 句首禁止 merge_continuous: True, } def postprocess(text, probs, id2tag, config): out [] for i, (ch, prob) in enumerate(zip(text, probs)): out.append(ch) tag id2tag[int(prob.argmax())] if tag O or float(prob.max()) config[threshold].get(tag, 0.5): continue if i 0 and tag in config[forbid_at_start]: continue if config[merge_continuous] and out and out[-1] in 。: continue out.append(tag) return .join(out)改完阈值拿 200 条真实 ASR 转写稿跑一遍人工看 50 条重点看句首、引号前后、数字单位后面。从那以后我每次接 ASR 后处理需求都先跑标签分布和句子级 F1再谈换模型希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →