ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

ASR标点恢复实战:序列标注与PaddlePaddle工程落地

ASR标点恢复实战:序列标注与PaddlePaddle工程落地 简介这份压缩包面向语音识别开发者和自然语言处理学习者聚焦识别文本缺少标点、可读性不足的问题提供可在PaddlePaddle框架下使用的标点符号预测模型文件。包内共5个文件以txt词表、json配置、info说明以及pdmodel/pdiparams模型结构与权重参数为主压缩包约250.85MB适合直接加载推理或作为二次训练起点。已有4948人学习下载说明其在ASR后处理场景中具有一定参考价值。读者可借助模型参数快速为连续识别文本补全标点也可结合CTC、Attention及CRF、LSTM、Transformer等序列建模思路理解标点预测模型的设计与优化方向对于希望把ASR输出整合为端到端可读文本的开发者这份模型包能减少从零训练的成本并为数据预处理、模型评估和后续调优提供可复用的基础。1. 语音识别出来的文字缺的不是模型而是标点某次把它当作“一句话能说清”的需求接手给asr的识别结果补上标点。真正跑起来才发现难的不是模型本身难的是标点这套标签怎么定义、长音频怎么切成窗口、训练集里半角标点有没有清干净。语音识别链路的输出是一长串没有断句的字符转写稿丢给人工校对第一件事就是重新断句接给下游的翻译、摘要、字幕打轴没有标点基本没法用。这份资源要解决的正是这一段一个挂在paddlepaddle语音识别后处理上的轻量标点恢复模型输入无标点文本输出带逗号、句号、问号的文本确定性输出、可离线、单条百毫秒量级。适合做 ASR 落地、会议转写、字幕生成的同学直接抄作业也适合想搞清楚“标点这事到底该用生成式还是判别式”的同行。2. 把标点恢复看成序列标注为什么不直接上生成式先给结论标点恢复在工程上更适合当序列标注问题解而不是当文本生成问题解。原因不复杂——生成式模型会重写你的原文而 ASR 后处理最不能接受的就是“字被改了”。序列标注的思路是字符序列不动只在每个字符位置上预测一个标签标签表示“这个字后面该不该插标点、插哪一个”。这样一来无论模型多不靠谱输出文本的字符集合和输入完全一致最坏情况是标点插错位置而不是整句话被改写。判别式方案还有几个隐性好处延迟可控一个 500 长度的窗口一次前向端侧 CPU 也能跑、结果可复现同样的输入永远同样的输出方便回归测试、显存占用小几百 MB 级别能和 ASR 模型挤在同一张卡上。代价是上下文建模能力弱于大模型遇到需要通读全文才能判断的断句会翻车——这个边界后面会具体说。2.1 标签体系先定 5 类别一上来就铺 16 类中文全角标点有十几种但标点恢复任务里真正高频的只有四个逗号、句号、问号、叹号。加上“不加标点”这一类第一版固定 5 类就够了。标签越多每个类的正样本越少句号和逗号的决策边界越难学——这两个本来就是最容易混的一对“他说他不知道。”和“他说他不知道。”语义完全不同但字面完全一样。顿号、分号、冒号这三个要不要单列看场景。字幕和会议纪要里顿号出现频率不低并列名词但如果你的人工校对规范里允许用逗号替代顿号直接合并进逗号能省掉一大堆标注争议。我一般第一版只用 5 类跑通之后再根据 per-class F1 决定要不要把某一类拆出来。import re # 5 类标签0 不加标点1 逗号2 句号3 问号4 叹号 PUNCT2ID {: 0, : 1, 。: 2, : 3, : 4} ID2PUNCT {v: k for k, v in PUNCT2ID.items()} # 半角标点统一转全角避免标签体系被污染 HALF2FULL {,: , .: 。, ?: , !: , ;: , :: } def normalize(text: str) - str: text text.strip() for h, f in HALF2FULL.items(): text text.replace(h, f) # 省略号、破折号这类多字符标点先统一成一个占位后面单独处理 text re.sub(r…|\.{3,}, 。, text) text re.sub(r[—]{2,}, , text) return text这段代码做两件事建立标签字典以及做输入归一化。PUNCT2ID里的空字符串对应的是“不插标点”这个最大类这一点很关键——绝大多数位置的标签都是 0模型的默认行为必须是“不乱插”。normalize里的半角转全角是血泪经验不做这一步训练集里残留的英文逗号和句号会被当成普通字符喂进去模型学不到任何东西还会在推理时对英文标点位置疯狂输出。提示引号、括号、书名号不做预测直接在上游按规则处理或者干脆剥掉。让模型学“左引号和右引号配对”是给自己找麻烦这类结构化标点用规则比用模型稳。2.2 编码器选型预训练微调、BiLSTM-CRF 与 CRF 的取舍编码器有三条常见路线按落地条件选第一条预训练语言模型加线性分类头。用中文预训练模型做底座接一个Linear(hidden_size, num_labels)每个位置出一个 5 维 logits。优点是收敛快、小样本表现好十万条级别就能到可用的 F1缺点是模型体积大一些端侧部署要做量化。生产环境我一般走这条。第二条字符级 BiLSTM 加 CRF。没有预训练权重纯靠标注数据训参数量小到几 MB塞进移动端毫无压力。缺点是长距离依赖弱超过 100 字的上下文基本靠不住而且对数据量要求高至少几十万条才稳。适合端侧、离线、资源受限的场景。第三条预训练加 CRF 层。在第一条的 logits 上加一层 CRF用转移矩阵约束标签序列。它解决的是一个具体问题线性头独立预测每个位置会出现“句号后面紧跟逗号”“连续两个句号”这种非法序列。CRF 的转移矩阵能从数据里学到“句号之后大概率是 O”这类约束。但 CRF 不是白拿的。前向算法的时间复杂度是 O(序列长度 × 标签数²)训练时每个 batch 都要跑一遍前向和反向512 长度、32 batch 的情况下训练速度大概会掉三到四成。我的建议是第一版先不上 CRF用线性头跑出基线如果 per-class F1 里发现非法序列占比超过 2%再考虑加。加的时候 batch size 往下压别硬扛。2.3 长文本切窗边界处的标点归属是个真问题标点模型很少能一次吃下整段转写稿。会议记录动辄几千字必须切窗。切窗这件事看起来简单实际上边界处理不当会引入一类很烦的错误窗口末尾因为看不到后文被模型判成句号。常见做法是滑动窗口加重叠窗口长度 480 到 510 个 tokenstride 取 96 到 128。重叠区里的预测结果不直接采信而是把每个位置在多个窗口里的 softmax 概率累加取累加后的 argmax。这种做法比“取最后一个窗口的结果”稳得多因为窗口边界附近的预测置信度天然偏低多窗口投票能把这个偏差摊平。import paddle paddle.no_grad() def predict_tags(text, char2id, model, window480, stride96): 滑动窗口推理重叠区按概率累加投票 chars [c for c in text if not c.isspace()] n len(chars) if n 0: return [], [] vote [dict() for _ in range(n)] model.eval() start 0 while start n: end min(start window, n) ids [char2id.get(c, char2id[[UNK]]) for c in chars[start:end]] ids paddle.to_tensor([ids], dtypeint64) logits model(ids) # [1, L, 5] probs paddle.nn.functional.softmax(logits, axis-1).numpy()[0] for i in range(end - start): pos start i for c in range(probs.shape[-1]): vote[pos][c] vote[pos].get(c, 0.0) probs[i][c] if end n: break start end - stride # 回退 stride 做重叠 tags [max(v, keyv.get) for v in vote] return chars, tagswindow控制单次前向的最长序列stride控制重叠宽度——太小比如 32会让重叠区票数不足、边界抖动明显太大比如 256会让推理耗时翻倍。我一般按window / 4取480 配 120 左右是常见组合。vote用字典而不是数组是为了避免导入 numpy 之后还要处理 dtype 的琐碎问题性能上在这个量级完全够用。切窗还有一个更细的问题如果窗口正好切在一个词的中间模型会对这个残缺的词给出奇怪的判断。中文是字符粒度这个问题不明显但如果你用的是子词分词器切窗就得对齐到子词边界或者干脆在切之前先把文本按标点、换行符切成自然段保证窗口边界落在语义断点上。这一点在字幕场景特别重要因为字幕本来就按行切直接拿行当窗口是最省事也最准的做法。3. 训练数据从哪来反向去标点与样本构造标点恢复的数据集不用人工从零标注这是它最大的工程优势——任何带标点的中文文本把标点去掉就是输入标点本身的位置和类型就是标签。新闻语料、字幕文件、已有的转写稿全都能这么反向构造。难点不在“有没有数据”在“构造得干不干净”。3.1 反向拆解把一句话变成 (字符序列, 标签序列)核心逻辑是以标点为分隔符切开句子每个标点记录在它前面那个字符的位置上。这样得到的标签序列和字符序列严格等长可以直接喂给 token 级分类。SPLIT_RE re.compile(r([。])) def build_sample(punct_text: str): 带标点句子 - (chars, labels)标点挂在前一个字符上 text normalize(punct_text) pieces SPLIT_RE.split(text) chars, labels [], [] for piece in pieces: if not piece: continue if piece in PUNCT2ID and piece ! : if chars: # 有前一个字符才挂标点 labels[-1] PUNCT2ID[piece] continue for ch in piece: if ch.isspace(): continue chars.append(ch) labels.append(0) return chars, labels # 示例 chars, labels build_sample(今天天气不错我们出去走走吧。) print(.join(chars)) # 今天天气不错我们出去走走吧 print([ID2PUNCT[t] for t in labels if t]) # [, 。]SPLIT_RE里的捕获组必须留re.split带捕获组时会把分隔符本身也保留在结果列表里这是后面能识别出“这一段是标点”的前提。if chars:这个判断不能省——如果句子以标点开头字幕文件里很常见比如上一行的句号漏到了这一行没有判断就会写到labels[-1]上直接污染前一条样本。连续标点要单独处理。“什么”这种组合按上面的逻辑只有最后一个“”会留在标签里实际语义上问号更重要。稳妥做法是给连续标点定一个优先级问号 叹号 句号 逗号或者干脆在normalize阶段把“”统一成“”。我倾向后者简单且不会引入新的标签分布偏移。3.2 类别不均衡O 类占了九成但别急着上 focal loss统计一下就知道标签分布有多偏一段正常中文里标点大约占字符总数的 8% 到 12%其中逗号又占标点总数的六成以上。也就是说5 类标签里 O 类大概 90%逗号 5% 到 6%句号 2% 到 3%问号和叹号各不到 1%。典型的类别不均衡。处理方式有三档按投入从低到高排第一档分类头加类别权重。权重取频率的倒数再做归一化或者更温和地取log(总样本数 / 该类样本数)。后者不会把稀有类抬得太狠避免模型到处乱插问号。第二档调整采样。切窗口的时候优先切在标点附近保证每个窗口里至少有一个句号。这招对长文本特别有效——随机切窗很容易切出一堆纯逗号的窗口模型见不到句号自然学不好。第三档换损失函数。focal loss 对稀有类有效但它会拉低整体的标点召回实际效果不一定比加权交叉熵好。我的经验是先把前两档做扎实focal loss 留到确实卡住的时候再试。标签典型占比建议权重log 归一备注O不加88%92%1.0基准类不放大逗号5%7%1.8最容易和句号混句号2%3%2.6断句质量的核心指标问号0.5%1%3.5权重别给太高容易满屏问号叹号0.3%0.8%3.5同上注意权重表里的数值不是拍脑袋定的要在自己的训练集上统计完再算。不同领域差别很大——客服对话的问号比例能到 5%而新闻播报只有 0.5%拿别人的权重直接套会出事。3.3 截断、对齐与词表别在 tokenizer 上翻车如果用预训练编码器输入要过它自己的 tokenizer如果用字符级 BiLSTM自己建一个字符表就行。这里有个反复踩的坑中文预训练模型的 tokenizer 会把一个汉字切成一个 token 没问题但它会把一些标点也当成独立 token甚至把连续的标点合并。如果你的标签是挂在“字符”上的就必须做 token 到字符的映射否则标签和位置对不上训练 loss 会莫名其妙地不下降。最省事的解法是训练和推理统一用字符级输入。给预训练模型的词表里没有的字符映射到[UNK]标点全部在预处理阶段剥掉模型看到的永远只有汉字、字母、数字。这样 token 索引和字符索引严格一一对应中间不用做任何映射。超长样本的处理也要注意。一条转写稿可能有几千字不能在 512 处硬切——硬切会让切口位置必然被判成句号。正确做法是按已有的标点切分成自然段段落内部再按最大长度切切完在尾部加 mask让损失函数忽略被截断的位置。def split_long(chars, labels, max_len500): 按标点优先切段段内再按长度切返回样本列表 samples, buf_c, buf_l [], [], [] for ch, lb in zip(chars, labels): buf_c.append(ch) buf_l.append(lb) # 遇到句末标点或长度到上限就收一个样本 if lb in (2, 3, 4) or len(buf_c) max_len: if len(buf_c) 5: # 太短的碎片丢掉 samples.append((buf_c, buf_l)) buf_c, buf_l [], [] if len(buf_c) 5: samples.append((buf_c, buf_l)) return samples这个函数里lb in (2, 3, 4)表示遇到句号、问号、叹号就收样本这样切出来的样本边界永远落在语义完整的句子末尾不会出现半句话。len(buf_c) 5是过滤碎片太短的样本比如两三个字对训练没贡献反而会拉偏 O 类的比例。max_len500要留出特殊 token 的余量实际喂模型时序列长度一般不超过 512。4. PaddlePaddle 落地模型、损失与推理封装前面几章解决的是“想清楚”这一章是“跑起来”。整套流程拆成三块模型定义、带 mask 的损失、推理封装。三块都不复杂复杂的是参数和细节。4.1 模型定义编码器加线性头就这么多import paddle import paddle.nn as nn from paddlenlp.transformers import ErnieModel class PuncTagger(nn.Layer): def __init__(self, pretrainedernie-3.0-base-zh, num_labels5, dropout0.1): super().__init__() self.encoder ErnieModel.from_pretrained(pretrained) hidden self.encoder.config[hidden_size] self.dropout nn.Dropout(dropout) self.classifier nn.Linear(hidden, num_labels) def forward(self, input_ids, token_type_idsNone, attention_maskNone): out self.encoder(input_ids, token_type_idstoken_type_ids) seq out[0] if isinstance(out, tuple) else out # [B, L, H] seq self.dropout(seq) return self.classifier(seq) # [B, L, num_labels]ErnieModel.from_pretrained会把预训练权重拉下来第一次跑要联网或者提前把权重放到本地目录。hidden_size从 config 里读而不是写死是为了换底座的时候不用改代码——base 版本一般是 768tiny 版本小得多。dropout在数据量少于十万条的时候提到 0.2能明显压住过拟合。out[0] if isinstance(out, tuple)这个判断是防御性的。不同版本的预训练模型封装返回结构不完全一样有的返回元组有的返回带属性的对象。写死[0]在某个版本升级之后会突然报错加个判断省事。4.2 带 mask 的损失函数padding 位置必须被忽略序列标注任务最容易出错的地方就是损失计算。一个 batch 里不同样本长度不同要 padding 到同一长度但 padding 位置是没有标签的——如果不加 mask模型会学着去预测 padding 的标签而 padding 全填的是 0O 类等于变相加强了“什么都别插”的倾向。def masked_cross_entropy(logits, labels, mask, weightNone): logits: [B, L, C] labels: [B, L] mask: [B, L] 1 有效 / 0 padding num_labels logits.shape[-1] logits logits.reshape([-1, num_labels]) labels labels.reshape([-1]) mask mask.reshape([-1]).astype(float32) loss nn.functional.cross_entropy( logits, labels, weightweight, reductionnone ) loss loss * mask return loss.sum() / paddle.clip(mask.sum(), min1.0)reductionnone拿到逐位置的损失乘上 mask 之后再求和除以有效位置数这是标准的 masked 平均写法。paddle.clip(mask.sum(), min1.0)是防除零——理论上一个 batch 不可能全是 padding但代码里加一层防护不亏。weight传的就是 3.2 节算出来的类别权重形状是[num_labels]Paddle 的cross_entropy会自动按标签索引取权重。4.3 训练超参从一份能跑的表开始调参数第一版取值调整方向编码器学习率3e-5数据少于 5 万条时降到 2e-5分类头学习率1e-4单独设 param group别和编码器共用batch size32长度 512显存够就往上加24G 卡能到 48epoch5看验证集宏 F1连续 2 轮不涨就停warmup 比例10%避免前几百步 loss 飙高weight decay0.01只作用在非 bias、非 LayerNorm 参数max_grad_norm1.0梯度裁剪防止偶发尖峰dropout0.10.2数据越少给越大分层学习率这件事值得单独说一句。预训练编码器已经有很好的语言表示学习率给大会把权重冲掉分类头是随机初始化的学习率给小了前几个 epoch 基本不学东西。用 Paddle 的param groups分开设编码器 3e-5、分类头 1e-4收敛速度比统一学习率快不少。评估口径也要提前定死。整体准确率在这个任务上没有参考价值——全预测 O 类也有 90% 的准确率。要看的是每个标点类的 P、R、F1尤其是句号的 F1它直接决定断句质量。import numpy as np def per_class_f1(preds, golds, num_labels5, ignore_index0): preds/golds 是拍平后的一维 numpy 数组 result {} f1_list [] for c in range(num_labels): if c ignore_index: continue p (preds c) g (golds c) tp float(np.sum(p g)) prec tp / max(np.sum(p), 1e-9) rec tp / max(np.sum(g), 1e-9) f1 2 * prec * rec / max(prec rec, 1e-9) result[c] {P: round(prec, 4), R: round(rec, 4), F1: round(f1, 4)} f1_list.append(f1) result[macro_f1_no_O] round(float(np.mean(f1_list)), 4) return resultignore_index0把 O 类排除在平均之外得到的macro_f1_no_O才是真正能反映标点质量的指标。max(..., 1e-9)是防除零某个类在验证集里一个样本都没有的时候不会直接崩。4.4 推理封装标签回填与后处理规则模型出的是标签序列要变成文本还得回填。回填本身一行代码但后处理规则不能省。def detag(chars, tags, id2punctID2PUNCT): out [] for i, (ch, t) in enumerate(zip(chars, tags)): out.append(ch) if t ! 0: # 规则 1同一位置不重复插标点 if out and out[-1] in 。: continue # 规则 2行首不插标点 if i 0: continue out.append(id2punct[t]) # 规则 3结尾没有句末标点就补一个句号 if out and out[-1] not in 。: out.append(。) return .join(out)三条规则分别对应三类线上事故连续标点、行首标点、结尾缺句号。前两条是模型在边界位置判断失误的常见表现第三条是业务方的硬性要求——字幕和转写稿的每一段都要以句末标点收尾不然拼接的时候会粘在一起。规则写死在代码里比让模型去学靠谱成本也低。提示这三条规则要按顺序执行尤其是“连续标点”的判断必须在插入之前做插入之后再判断就得回头删字符容易出索引错误。5. 标点模型的五个避坑记录从断句错位到显存溢出这一章全是踩过的坑按“现象 → 原因 → 解决”写能对上一个就省半天。5.1 模型在几乎每个字后面都想插标点现象训练 loss 正常下降验证集 F1 也还行但一上真实转写稿输出变成“今天天气不错。”这种鬼样子。原因训练数据里的标点没清干净。最常见的是从网页或者字幕文件抓的语料里面混着半角逗号、英文句号、全角空格、不可见字符。这些字符在normalize时没被转成标签体系里的标点而是当成普通字符留在了输入里。模型于是学到一条捷径看到“”这个字符下一个位置大概率该出标点——因为训练集里标点符号总是紧挨着标点标签。解决在normalize里做彻底的字符白名单过滤。除了汉字、数字、字母其他字符一律先转全角再判断是否属于标点集合不属于的直接丢弃。跑完预处理之后抽样看一百条确认输入序列里一个标点符号都不剩再开始训练。这一步不做后面调什么参数都是白费。5.2 长文本末尾永远多一个句号现象短句推理完全正常超过 500 字的段落结尾必然多出一个句号哪怕原文是“……我们明天再讨论”这种明显没说完的句子。原因切窗时最后一个窗口的尾部没有后文模型看不到任何“后面还有内容”的信号只能按训练数据的统计规律判成句号。训练集里绝大多数样本是以句号结尾的模型把这个先验学得很牢。解决两个动作配合。一是推理时给最后一个窗口的末尾位置加 mask强制该位置输出 O二是训练时引入“不完整句”样本——把长句从中间截断截断处标 O让模型见过“话说一半”的形态。第二招效果更持久因为它是从数据层面改先验不是靠推理时的补丁。5.3 验证集指标漂亮上线后人工校对怨声载道现象验证集句号 F1 到 0.85上线之后业务方反馈“断句乱七八糟”。原因验证集和训练集同源。都是新闻语料句式规整、书面化程度高线上是口语转写有大量语气词、重复、自我纠正分布差得远。模型在书面语上学到的“逗号之后大概率接短句”在口语里完全不成立。解决验证集必须跨域。从线上真实转写稿里抽五百到一千条人工标一遍单独做一份测试集所有模型版本都在这份集上比。这份集不用大但要稳——一旦定了就别改改了前后版本就没法比。我一般会在项目开始第一周就把这份集建好后面所有决策都看它。5.4 加了 CRF 之后训练慢到无法接受现象线性头版本一个 epoch 二十分钟加 CRF 之后变成一小时以上而且 batch size 稍微开大就 OOM。原因CRF 的前向算法要对每个位置、每对标签做一次递推时间复杂度是 O(L × C²)。512 长度、5 标签、32 batch 的情况下这个计算量比线性头大一个数量级而且它没法像矩阵乘那样被 GPU 高效并行。解决三条路选一条。第一降 batch size 到 8 或 16用梯度累积补回等效 batch第二缩短窗口到 256长文本多切几段第三直接放弃 CRF用规则后处理兜住非法序列——实际上非法序列占比很低用 4.4 节那三条规则就能压到 1% 以下。我现在的默认选择是第三条简单、快、够用。5.5 服务化之后显存一路涨到 OOM现象单条推理压测没问题并发上来之后显存缓慢上涨跑几个小时就 OOM 重启。原因推理时序列长度是动态的每个请求的 shape 都不一样Paddle 的显存分配器会为每种新 shape 缓存一块显存。请求长度分布很散的时候缓存块越来越多最后撑爆。解决把推理的输入 shape 固定下来。所有请求先 padding 到固定的几档长度比如 128、256、512同一个 batch 内长度对齐不同档位分别走不同的预测器实例。这样显存占用是常量不会随请求变化。这一招在任何动态 shape 的推理服务里都适用不只是标点模型。6. 接回时间戳标点模型的最后一段验证标点模型单独看 F1 只是半程真正决定它能不能用的是接回 ASR 结果之后的表现。语音识别一般会返回每个字的起止时间插标点之后这个时间轴要重新对齐——字幕打轴、音频切片、TTS 重合成全靠这条时间轴。这一步做不好前面训得再准也是白搭。6.1 别只看整体指标按类看召回按类拆开看问题会立刻暴露。下面是我常用的验证口径跑完一轮就能判断模型能不能上检查项合格线不达标说明什么句号 F1≥ 0.80断句粒度不对长句切不开逗号 F1≥ 0.75从句边界识别弱多半是窗口太短问号召回≥ 0.70疑问语气学不到需要补口语样本非法序列占比≤ 1%后处理规则没生效或 CRF 缺失平均每百字标点数814明显偏离说明模型在乱插或不敢插最后一行那个“每百字标点数”是个很朴素的监控指标但异常灵敏。正常中文的标点密度就在这个区间模型输出密度突然翻倍或者腰斩基本可以确定是数据分布漂了或者推理链路出了问题。6.2 标点位置与停顿对齐一个能抓出大量错误的检查ASR 的字级时间戳里藏着停顿信息。人说话时的停顿和书面标点高度相关——句号位置通常对应 300 毫秒以上的静音逗号对应 150 到 300 毫秒词内不会有长停顿。把模型的标点位置和停顿分布对一下不一致的地方就是可疑点。具体做法对每条转写稿统计“模型判为句号的位置”和“实际停顿超过 300 毫秒的位置”的交并比。交并比低于 0.5 就人工看一眼通常会发现问题——要么模型在没停顿的地方硬断句多半是书面语样本训多了要么在有明显停顿的地方没断多半是窗口切在了句子中间。def check_punc_pause_alignment(chars, tags, char_times, pause_thr300): char_times: [(start_ms, end_ms), ...]与 chars 等长 返回模型标点位置与停顿位置的对齐率 model_breaks {i for i, t in enumerate(tags) if t in (1, 2, 3, 4)} pause_breaks set() for i in range(len(chars) - 1): gap char_times[i 1][0] - char_times[i][1] if gap pause_thr: pause_breaks.add(i) if not model_breaks and not pause_breaks: return 1.0 inter len(model_breaks pause_breaks) union len(model_breaks | pause_breaks) return round(inter / max(union, 1), 4)pause_thr按场景调新闻播报语速快250 毫秒就够访谈类语速慢、停顿多得到 400 毫秒才不会误报。char_times要和chars严格等长中间不能有空格或者被过滤的字符否则索引全错。这个检查不用跑全量抽两三百条就能看出趋势。6.3 字幕场景的额外一轮断行重排如果下游是字幕标点插完之后还得重新断行。字幕的硬约束是单行不超过 16 到 20 个汉字而标点插入会改变每行的字符数所以断行必须在标点之后做。做法很简单先按标点把文本切成句子再按长度把句子切行切行时优先在逗号处断实在切不开才在字中间断且行首行尾不留标点。这一步最容易翻车的地方是省略号和破折号——它们在字幕里占两个字符宽度按字符数算长度会算错。我的处理方式是在预处理阶段就把这两个符号统一成句号或者逗号字幕场景本来也不该出现太长的省略号。最后一次线上事故让我养成了一个习惯任何标点模型版本上线前必须拿同一批三百条真实转写稿跑一遍完整链路——去标点、模型推理、标签回填、时间戳对齐、字幕断行——然后把前后两版的输出逐条 diff。只看模型指标会漏掉后处理引入的问题而这类问题占我遇到过的线上故障的一半以上。后来我把这套 diff 做成了固定流程每次训练出新权重都强制走一遍心里才踏实。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表