實戰(zhàn):序列標注與PaddlePaddle工程落地)
簡介這份壓縮包面向語音識別開發(fā)者和自然語言處理學(xué)習(xí)者聚焦識別文本缺少標點、可讀性不足的問題提供可在PaddlePaddle框架下使用的標點符號預(yù)測模型文件。包內(nèi)共5個文件以txt詞表、json配置、info說明以及pdmodel/pdiparams模型結(jié)構(gòu)與權(quán)重參數(shù)為主壓縮包約250.85MB適合直接加載推理或作為二次訓(xùn)練起點。已有4948人學(xué)習(xí)下載說明其在ASR后處理場景中具有一定參考價值。讀者可借助模型參數(shù)快速為連續(xù)識別文本補全標點也可結(jié)合CTC、Attention及CRF、LSTM、Transformer等序列建模思路理解標點預(yù)測模型的設(shè)計與優(yōu)化方向?qū)τ谙M袮SR輸出整合為端到端可讀文本的開發(fā)者這份模型包能減少從零訓(xùn)練的成本并為數(shù)據(jù)預(yù)處理、模型評估和后續(xù)調(diào)優(yōu)提供可復(fù)用的基礎(chǔ)。1. 語音識別出來的文字缺的不是模型而是標點某次把它當作“一句話能說清”的需求接手給asr的識別結(jié)果補上標點。真正跑起來才發(fā)現(xiàn)難的不是模型本身難的是標點這套標簽怎么定義、長音頻怎么切成窗口、訓(xùn)練集里半角標點有沒有清干凈。語音識別鏈路的輸出是一長串沒有斷句的字符轉(zhuǎn)寫稿丟給人工校對第一件事就是重新斷句接給下游的翻譯、摘要、字幕打軸沒有標點基本沒法用。這份資源要解決的正是這一段一個掛在paddlepaddle語音識別后處理上的輕量標點恢復(fù)模型輸入無標點文本輸出帶逗號、句號、問號的文本確定性輸出、可離線、單條百毫秒量級。適合做 ASR 落地、會議轉(zhuǎn)寫、字幕生成的同學(xué)直接抄作業(yè)也適合想搞清楚“標點這事到底該用生成式還是判別式”的同行。2. 把標點恢復(fù)看成序列標注為什么不直接上生成式先給結(jié)論標點恢復(fù)在工程上更適合當序列標注問題解而不是當文本生成問題解。原因不復(fù)雜——生成式模型會重寫你的原文而 ASR 后處理最不能接受的就是“字被改了”。序列標注的思路是字符序列不動只在每個字符位置上預(yù)測一個標簽標簽表示“這個字后面該不該插標點、插哪一個”。這樣一來無論模型多不靠譜輸出文本的字符集合和輸入完全一致最壞情況是標點插錯位置而不是整句話被改寫。判別式方案還有幾個隱性好處延遲可控一個 500 長度的窗口一次前向端側(cè) CPU 也能跑、結(jié)果可復(fù)現(xiàn)同樣的輸入永遠同樣的輸出方便回歸測試、顯存占用小幾百 MB 級別能和 ASR 模型擠在同一張卡上。代價是上下文建模能力弱于大模型遇到需要通讀全文才能判斷的斷句會翻車——這個邊界后面會具體說。2.1 標簽體系先定 5 類別一上來就鋪 16 類中文全角標點有十幾種但標點恢復(fù)任務(wù)里真正高頻的只有四個逗號、句號、問號、嘆號。加上“不加標點”這一類第一版固定 5 類就夠了。標簽越多每個類的正樣本越少句號和逗號的決策邊界越難學(xué)——這兩個本來就是最容易混的一對“他說他不知道。”和“他說他不知道?!闭Z義完全不同但字面完全一樣。頓號、分號、冒號這三個要不要單列看場景。字幕和會議紀要里頓號出現(xiàn)頻率不低并列名詞但如果你的人工校對規(guī)范里允許用逗號替代頓號直接合并進逗號能省掉一大堆標注爭議。我一般第一版只用 5 類跑通之后再根據(jù) 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()} # 半角標點統(tǒng)一轉(zhuǎn)全角避免標簽體系被污染 HALF2FULL {,: , .: 。, ?: , !: , ;: , :: } def normalize(text: str) - str: text text.strip() for h, f in HALF2FULL.items(): text text.replace(h, f) # 省略號、破折號這類多字符標點先統(tǒng)一成一個占位后面單獨處理 text re.sub(r…|\.{3,}, 。, text) text re.sub(r[—]{2,}, , text) return text這段代碼做兩件事建立標簽字典以及做輸入歸一化。PUNCT2ID里的空字符串對應(yīng)的是“不插標點”這個最大類這一點很關(guān)鍵——絕大多數(shù)位置的標簽都是 0模型的默認行為必須是“不亂插”。normalize里的半角轉(zhuǎn)全角是血淚經(jīng)驗不做這一步訓(xùn)練集里殘留的英文逗號和句號會被當成普通字符喂進去模型學(xué)不到任何東西還會在推理時對英文標點位置瘋狂輸出。提示引號、括號、書名號不做預(yù)測直接在上游按規(guī)則處理或者干脆剝掉。讓模型學(xué)“左引號和右引號配對”是給自己找麻煩這類結(jié)構(gòu)化標點用規(guī)則比用模型穩(wěn)。2.2 編碼器選型預(yù)訓(xùn)練微調(diào)、BiLSTM-CRF 與 CRF 的取舍編碼器有三條常見路線按落地條件選第一條預(yù)訓(xùn)練語言模型加線性分類頭。用中文預(yù)訓(xùn)練模型做底座接一個Linear(hidden_size, num_labels)每個位置出一個 5 維 logits。優(yōu)點是收斂快、小樣本表現(xiàn)好十萬條級別就能到可用的 F1缺點是模型體積大一些端側(cè)部署要做量化。生產(chǎn)環(huán)境我一般走這條。第二條字符級 BiLSTM 加 CRF。沒有預(yù)訓(xùn)練權(quán)重純靠標注數(shù)據(jù)訓(xùn)參數(shù)量小到幾 MB塞進移動端毫無壓力。缺點是長距離依賴弱超過 100 字的上下文基本靠不住而且對數(shù)據(jù)量要求高至少幾十萬條才穩(wěn)。適合端側(cè)、離線、資源受限的場景。第三條預(yù)訓(xùn)練加 CRF 層。在第一條的 logits 上加一層 CRF用轉(zhuǎn)移矩陣約束標簽序列。它解決的是一個具體問題線性頭獨立預(yù)測每個位置會出現(xiàn)“句號后面緊跟逗號”“連續(xù)兩個句號”這種非法序列。CRF 的轉(zhuǎn)移矩陣能從數(shù)據(jù)里學(xué)到“句號之后大概率是 O”這類約束。但 CRF 不是白拿的。前向算法的時間復(fù)雜度是 O(序列長度 × 標簽數(shù)2)訓(xùn)練時每個 batch 都要跑一遍前向和反向512 長度、32 batch 的情況下訓(xùn)練速度大概會掉三到四成。我的建議是第一版先不上 CRF用線性頭跑出基線如果 per-class F1 里發(fā)現(xiàn)非法序列占比超過 2%再考慮加。加的時候 batch size 往下壓別硬扛。2.3 長文本切窗邊界處的標點歸屬是個真問題標點模型很少能一次吃下整段轉(zhuǎn)寫稿。會議記錄動輒幾千字必須切窗。切窗這件事看起來簡單實際上邊界處理不當會引入一類很煩的錯誤窗口末尾因為看不到后文被模型判成句號。常見做法是滑動窗口加重疊窗口長度 480 到 510 個 tokenstride 取 96 到 128。重疊區(qū)里的預(yù)測結(jié)果不直接采信而是把每個位置在多個窗口里的 softmax 概率累加取累加后的 argmax。這種做法比“取最后一個窗口的結(jié)果”穩(wěn)得多因為窗口邊界附近的預(yù)測置信度天然偏低多窗口投票能把這個偏差攤平。import paddle paddle.no_grad() def predict_tags(text, char2id, model, window480, stride96): 滑動窗口推理重疊區(qū)按概率累加投票 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會讓重疊區(qū)票數(shù)不足、邊界抖動明顯太大比如 256會讓推理耗時翻倍。我一般按window / 4取480 配 120 左右是常見組合。vote用字典而不是數(shù)組是為了避免導(dǎo)入 numpy 之后還要處理 dtype 的瑣碎問題性能上在這個量級完全夠用。切窗還有一個更細的問題如果窗口正好切在一個詞的中間模型會對這個殘缺的詞給出奇怪的判斷。中文是字符粒度這個問題不明顯但如果你用的是子詞分詞器切窗就得對齊到子詞邊界或者干脆在切之前先把文本按標點、換行符切成自然段保證窗口邊界落在語義斷點上。這一點在字幕場景特別重要因為字幕本來就按行切直接拿行當窗口是最省事也最準的做法。3. 訓(xùn)練數(shù)據(jù)從哪來反向去標點與樣本構(gòu)造標點恢復(fù)的數(shù)據(jù)集不用人工從零標注這是它最大的工程優(yōu)勢——任何帶標點的中文文本把標點去掉就是輸入標點本身的位置和類型就是標簽。新聞?wù)Z料、字幕文件、已有的轉(zhuǎn)寫稿全都能這么反向構(gòu)造。難點不在“有沒有數(shù)據(jù)”在“構(gòu)造得干不干凈”。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帶捕獲組時會把分隔符本身也保留在結(jié)果列表里這是后面能識別出“這一段是標點”的前提。if chars:這個判斷不能省——如果句子以標點開頭字幕文件里很常見比如上一行的句號漏到了這一行沒有判斷就會寫到labels[-1]上直接污染前一條樣本。連續(xù)標點要單獨處理?!笆裁础边@種組合按上面的邏輯只有最后一個“”會留在標簽里實際語義上問號更重要。穩(wěn)妥做法是給連續(xù)標點定一個優(yōu)先級問號 嘆號 句號 逗號或者干脆在normalize階段把“”統(tǒng)一成“”。我傾向后者簡單且不會引入新的標簽分布偏移。3.2 類別不均衡O 類占了九成但別急著上 focal loss統(tǒng)計一下就知道標簽分布有多偏一段正常中文里標點大約占字符總數(shù)的 8% 到 12%其中逗號又占標點總數(shù)的六成以上。也就是說5 類標簽里 O 類大概 90%逗號 5% 到 6%句號 2% 到 3%問號和嘆號各不到 1%。典型的類別不均衡。處理方式有三檔按投入從低到高排第一檔分類頭加類別權(quán)重。權(quán)重取頻率的倒數(shù)再做歸一化或者更溫和地取log(總樣本數(shù) / 該類樣本數(shù))。后者不會把稀有類抬得太狠避免模型到處亂插問號。第二檔調(diào)整采樣。切窗口的時候優(yōu)先切在標點附近保證每個窗口里至少有一個句號。這招對長文本特別有效——隨機切窗很容易切出一堆純逗號的窗口模型見不到句號自然學(xué)不好。第三檔換損失函數(shù)。focal loss 對稀有類有效但它會拉低整體的標點召回實際效果不一定比加權(quán)交叉熵好。我的經(jīng)驗是先把前兩檔做扎實focal loss 留到確實卡住的時候再試。標簽典型占比建議權(quán)重log 歸一備注O不加88%92%1.0基準類不放大逗號5%7%1.8最容易和句號混句號2%3%2.6斷句質(zhì)量的核心指標問號0.5%1%3.5權(quán)重別給太高容易滿屏問號嘆號0.3%0.8%3.5同上注意權(quán)重表里的數(shù)值不是拍腦袋定的要在自己的訓(xùn)練集上統(tǒng)計完再算。不同領(lǐng)域差別很大——客服對話的問號比例能到 5%而新聞播報只有 0.5%拿別人的權(quán)重直接套會出事。3.3 截斷、對齊與詞表別在 tokenizer 上翻車如果用預(yù)訓(xùn)練編碼器輸入要過它自己的 tokenizer如果用字符級 BiLSTM自己建一個字符表就行。這里有個反復(fù)踩的坑中文預(yù)訓(xùn)練模型的 tokenizer 會把一個漢字切成一個 token 沒問題但它會把一些標點也當成獨立 token甚至把連續(xù)的標點合并。如果你的標簽是掛在“字符”上的就必須做 token 到字符的映射否則標簽和位置對不上訓(xùn)練 loss 會莫名其妙地不下降。最省事的解法是訓(xùn)練和推理統(tǒng)一用字符級輸入。給預(yù)訓(xùn)練模型的詞表里沒有的字符映射到[UNK]標點全部在預(yù)處理階段剝掉模型看到的永遠只有漢字、字母、數(shù)字。這樣 token 索引和字符索引嚴格一一對應(yīng)中間不用做任何映射。超長樣本的處理也要注意。一條轉(zhuǎn)寫稿可能有幾千字不能在 512 處硬切——硬切會讓切口位置必然被判成句號。正確做法是按已有的標點切分成自然段段落內(nèi)部再按最大長度切切完在尾部加 mask讓損失函數(shù)忽略被截斷的位置。def split_long(chars, labels, max_len500): 按標點優(yōu)先切段段內(nèi)再按長度切返回樣本列表 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這個函數(shù)里lb in (2, 3, 4)表示遇到句號、問號、嘆號就收樣本這樣切出來的樣本邊界永遠落在語義完整的句子末尾不會出現(xiàn)半句話。len(buf_c) 5是過濾碎片太短的樣本比如兩三個字對訓(xùn)練沒貢獻反而會拉偏 O 類的比例。max_len500要留出特殊 token 的余量實際喂模型時序列長度一般不超過 512。4. PaddlePaddle 落地模型、損失與推理封裝前面幾章解決的是“想清楚”這一章是“跑起來”。整套流程拆成三塊模型定義、帶 mask 的損失、推理封裝。三塊都不復(fù)雜復(fù)雜的是參數(shù)和細節(jié)。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會把預(yù)訓(xùn)練權(quán)重拉下來第一次跑要聯(lián)網(wǎng)或者提前把權(quán)重放到本地目錄。hidden_size從 config 里讀而不是寫死是為了換底座的時候不用改代碼——base 版本一般是 768tiny 版本小得多。dropout在數(shù)據(jù)量少于十萬條的時候提到 0.2能明顯壓住過擬合。out[0] if isinstance(out, tuple)這個判斷是防御性的。不同版本的預(yù)訓(xùn)練模型封裝返回結(jié)構(gòu)不完全一樣有的返回元組有的返回帶屬性的對象。寫死[0]在某個版本升級之后會突然報錯加個判斷省事。4.2 帶 mask 的損失函數(shù)padding 位置必須被忽略序列標注任務(wù)最容易出錯的地方就是損失計算。一個 batch 里不同樣本長度不同要 padding 到同一長度但 padding 位置是沒有標簽的——如果不加 mask模型會學(xué)著去預(yù)測 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 之后再求和除以有效位置數(shù)這是標準的 masked 平均寫法。paddle.clip(mask.sum(), min1.0)是防除零——理論上一個 batch 不可能全是 padding但代碼里加一層防護不虧。weight傳的就是 3.2 節(jié)算出來的類別權(quán)重形狀是[num_labels]Paddle 的cross_entropy會自動按標簽索引取權(quán)重。4.3 訓(xùn)練超參從一份能跑的表開始調(diào)參數(shù)第一版取值調(diào)整方向編碼器學(xué)習(xí)率3e-5數(shù)據(jù)少于 5 萬條時降到 2e-5分類頭學(xué)習(xí)率1e-4單獨設(shè) param group別和編碼器共用batch size32長度 512顯存夠就往上加24G 卡能到 48epoch5看驗證集宏 F1連續(xù) 2 輪不漲就停warmup 比例10%避免前幾百步 loss 飆高weight decay0.01只作用在非 bias、非 LayerNorm 參數(shù)max_grad_norm1.0梯度裁剪防止偶發(fā)尖峰dropout0.10.2數(shù)據(jù)越少給越大分層學(xué)習(xí)率這件事值得單獨說一句。預(yù)訓(xùn)練編碼器已經(jīng)有很好的語言表示學(xué)習(xí)率給大會把權(quán)重沖掉分類頭是隨機初始化的學(xué)習(xí)率給小了前幾個 epoch 基本不學(xué)東西。用 Paddle 的param groups分開設(shè)編碼器 3e-5、分類頭 1e-4收斂速度比統(tǒng)一學(xué)習(xí)率快不少。評估口徑也要提前定死。整體準確率在這個任務(wù)上沒有參考價值——全預(yù)測 O 類也有 90% 的準確率。要看的是每個標點類的 P、R、F1尤其是句號的 F1它直接決定斷句質(zhì)量。import numpy as np def per_class_f1(preds, golds, num_labels5, ignore_index0): preds/golds 是拍平后的一維 numpy 數(shù)組 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才是真正能反映標點質(zhì)量的指標。max(..., 1e-9)是防除零某個類在驗證集里一個樣本都沒有的時候不會直接崩。4.4 推理封裝標簽回填與后處理規(guī)則模型出的是標簽序列要變成文本還得回填。回填本身一行代碼但后處理規(guī)則不能省。def detag(chars, tags, id2punctID2PUNCT): out [] for i, (ch, t) in enumerate(zip(chars, tags)): out.append(ch) if t ! 0: # 規(guī)則 1同一位置不重復(fù)插標點 if out and out[-1] in 。: continue # 規(guī)則 2行首不插標點 if i 0: continue out.append(id2punct[t]) # 規(guī)則 3結(jié)尾沒有句末標點就補一個句號 if out and out[-1] not in 。: out.append(。) return .join(out)三條規(guī)則分別對應(yīng)三類線上事故連續(xù)標點、行首標點、結(jié)尾缺句號。前兩條是模型在邊界位置判斷失誤的常見表現(xiàn)第三條是業(yè)務(wù)方的硬性要求——字幕和轉(zhuǎn)寫稿的每一段都要以句末標點收尾不然拼接的時候會粘在一起。規(guī)則寫死在代碼里比讓模型去學(xué)靠譜成本也低。提示這三條規(guī)則要按順序執(zhí)行尤其是“連續(xù)標點”的判斷必須在插入之前做插入之后再判斷就得回頭刪字符容易出索引錯誤。5. 標點模型的五個避坑記錄從斷句錯位到顯存溢出這一章全是踩過的坑按“現(xiàn)象 → 原因 → 解決”寫能對上一個就省半天。5.1 模型在幾乎每個字后面都想插標點現(xiàn)象訓(xùn)練 loss 正常下降驗證集 F1 也還行但一上真實轉(zhuǎn)寫稿輸出變成“今天天氣不錯?!边@種鬼樣子。原因訓(xùn)練數(shù)據(jù)里的標點沒清干凈。最常見的是從網(wǎng)頁或者字幕文件抓的語料里面混著半角逗號、英文句號、全角空格、不可見字符。這些字符在normalize時沒被轉(zhuǎn)成標簽體系里的標點而是當成普通字符留在了輸入里。模型于是學(xué)到一條捷徑看到“”這個字符下一個位置大概率該出標點——因為訓(xùn)練集里標點符號總是緊挨著標點標簽。解決在normalize里做徹底的字符白名單過濾。除了漢字、數(shù)字、字母其他字符一律先轉(zhuǎn)全角再判斷是否屬于標點集合不屬于的直接丟棄。跑完預(yù)處理之后抽樣看一百條確認輸入序列里一個標點符號都不剩再開始訓(xùn)練。這一步不做后面調(diào)什么參數(shù)都是白費。5.2 長文本末尾永遠多一個句號現(xiàn)象短句推理完全正常超過 500 字的段落結(jié)尾必然多出一個句號哪怕原文是“……我們明天再討論”這種明顯沒說完的句子。原因切窗時最后一個窗口的尾部沒有后文模型看不到任何“后面還有內(nèi)容”的信號只能按訓(xùn)練數(shù)據(jù)的統(tǒng)計規(guī)律判成句號。訓(xùn)練集里絕大多數(shù)樣本是以句號結(jié)尾的模型把這個先驗學(xué)得很牢。解決兩個動作配合。一是推理時給最后一個窗口的末尾位置加 mask強制該位置輸出 O二是訓(xùn)練時引入“不完整句”樣本——把長句從中間截斷截斷處標 O讓模型見過“話說一半”的形態(tài)。第二招效果更持久因為它是從數(shù)據(jù)層面改先驗不是靠推理時的補丁。5.3 驗證集指標漂亮上線后人工校對怨聲載道現(xiàn)象驗證集句號 F1 到 0.85上線之后業(yè)務(wù)方反饋“斷句亂七八糟”。原因驗證集和訓(xùn)練集同源。都是新聞?wù)Z料句式規(guī)整、書面化程度高線上是口語轉(zhuǎn)寫有大量語氣詞、重復(fù)、自我糾正分布差得遠。模型在書面語上學(xué)到的“逗號之后大概率接短句”在口語里完全不成立。解決驗證集必須跨域。從線上真實轉(zhuǎn)寫稿里抽五百到一千條人工標一遍單獨做一份測試集所有模型版本都在這份集上比。這份集不用大但要穩(wěn)——一旦定了就別改改了前后版本就沒法比。我一般會在項目開始第一周就把這份集建好后面所有決策都看它。5.4 加了 CRF 之后訓(xùn)練慢到無法接受現(xiàn)象線性頭版本一個 epoch 二十分鐘加 CRF 之后變成一小時以上而且 batch size 稍微開大就 OOM。原因CRF 的前向算法要對每個位置、每對標簽做一次遞推時間復(fù)雜度是 O(L × C2)。512 長度、5 標簽、32 batch 的情況下這個計算量比線性頭大一個數(shù)量級而且它沒法像矩陣乘那樣被 GPU 高效并行。解決三條路選一條。第一降 batch size 到 8 或 16用梯度累積補回等效 batch第二縮短窗口到 256長文本多切幾段第三直接放棄 CRF用規(guī)則后處理兜住非法序列——實際上非法序列占比很低用 4.4 節(jié)那三條規(guī)則就能壓到 1% 以下。我現(xiàn)在的默認選擇是第三條簡單、快、夠用。5.5 服務(wù)化之后顯存一路漲到 OOM現(xiàn)象單條推理壓測沒問題并發(fā)上來之后顯存緩慢上漲跑幾個小時就 OOM 重啟。原因推理時序列長度是動態(tài)的每個請求的 shape 都不一樣Paddle 的顯存分配器會為每種新 shape 緩存一塊顯存。請求長度分布很散的時候緩存塊越來越多最后撐爆。解決把推理的輸入 shape 固定下來。所有請求先 padding 到固定的幾檔長度比如 128、256、512同一個 batch 內(nèi)長度對齊不同檔位分別走不同的預(yù)測器實例。這樣顯存占用是常量不會隨請求變化。這一招在任何動態(tài) shape 的推理服務(wù)里都適用不只是標點模型。6. 接回時間戳標點模型的最后一段驗證標點模型單獨看 F1 只是半程真正決定它能不能用的是接回 ASR 結(jié)果之后的表現(xiàn)。語音識別一般會返回每個字的起止時間插標點之后這個時間軸要重新對齊——字幕打軸、音頻切片、TTS 重合成全靠這條時間軸。這一步做不好前面訓(xùn)得再準也是白搭。6.1 別只看整體指標按類看召回按類拆開看問題會立刻暴露。下面是我常用的驗證口徑跑完一輪就能判斷模型能不能上檢查項合格線不達標說明什么句號 F1≥ 0.80斷句粒度不對長句切不開逗號 F1≥ 0.75從句邊界識別弱多半是窗口太短問號召回≥ 0.70疑問語氣學(xué)不到需要補口語樣本非法序列占比≤ 1%后處理規(guī)則沒生效或 CRF 缺失平均每百字標點數(shù)814明顯偏離說明模型在亂插或不敢插最后一行那個“每百字標點數(shù)”是個很樸素的監(jiān)控指標但異常靈敏。正常中文的標點密度就在這個區(qū)間模型輸出密度突然翻倍或者腰斬基本可以確定是數(shù)據(jù)分布漂了或者推理鏈路出了問題。6.2 標點位置與停頓對齊一個能抓出大量錯誤的檢查ASR 的字級時間戳里藏著停頓信息。人說話時的停頓和書面標點高度相關(guān)——句號位置通常對應(yīng) 300 毫秒以上的靜音逗號對應(yīng) 150 到 300 毫秒詞內(nèi)不會有長停頓。把模型的標點位置和停頓分布對一下不一致的地方就是可疑點。具體做法對每條轉(zhuǎn)寫稿統(tǒng)計“模型判為句號的位置”和“實際停頓超過 300 毫秒的位置”的交并比。交并比低于 0.5 就人工看一眼通常會發(fā)現(xiàn)問題——要么模型在沒停頓的地方硬斷句多半是書面語樣本訓(xùn)多了要么在有明顯停頓的地方?jīng)]斷多半是窗口切在了句子中間。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按場景調(diào)新聞播報語速快250 毫秒就夠訪談類語速慢、停頓多得到 400 毫秒才不會誤報。char_times要和chars嚴格等長中間不能有空格或者被過濾的字符否則索引全錯。這個檢查不用跑全量抽兩三百條就能看出趨勢。6.3 字幕場景的額外一輪斷行重排如果下游是字幕標點插完之后還得重新斷行。字幕的硬約束是單行不超過 16 到 20 個漢字而標點插入會改變每行的字符數(shù)所以斷行必須在標點之后做。做法很簡單先按標點把文本切成句子再按長度把句子切行切行時優(yōu)先在逗號處斷實在切不開才在字中間斷且行首行尾不留標點。這一步最容易翻車的地方是省略號和破折號——它們在字幕里占兩個字符寬度按字符數(shù)算長度會算錯。我的處理方式是在預(yù)處理階段就把這兩個符號統(tǒng)一成句號或者逗號字幕場景本來也不該出現(xiàn)太長的省略號。最后一次線上事故讓我養(yǎng)成了一個習(xí)慣任何標點模型版本上線前必須拿同一批三百條真實轉(zhuǎn)寫稿跑一遍完整鏈路——去標點、模型推理、標簽回填、時間戳對齊、字幕斷行——然后把前后兩版的輸出逐條 diff。只看模型指標會漏掉后處理引入的問題而這類問題占我遇到過的線上故障的一半以上。后來我把這套 diff 做成了固定流程每次訓(xùn)練出新權(quán)重都強制走一遍心里才踏實。希望幫到你。本文還有配套的精品資源點擊獲取