習(xí)文本分類(lèi)實(shí)戰(zhàn):從數(shù)據(jù)預(yù)處理到模型部署全解析)
簡(jiǎn)介面向希望入門(mén)自然語(yǔ)言處理的學(xué)生和開(kāi)發(fā)者這份資源基于深度學(xué)習(xí)完成文本分類(lèi)任務(wù)覆蓋從文本預(yù)處理、詞嵌入到模型構(gòu)建、訓(xùn)練與評(píng)估的完整項(xiàng)目流程。壓縮包共6個(gè)文件全部為Python腳本整體僅11KB分別承擔(dān)數(shù)據(jù)加載、TextCNN/TextRNN模型定義、訓(xùn)練與預(yù)測(cè)等功能代碼結(jié)構(gòu)緊湊適合配合入門(mén)教程邊讀邊練。資源已有224人學(xué)習(xí)借助訓(xùn)練與預(yù)測(cè)腳本可直接對(duì)比卷積網(wǎng)絡(luò)與循環(huán)網(wǎng)絡(luò)在文本分類(lèi)中的表現(xiàn)并快速驗(yàn)證新樣本。項(xiàng)目實(shí)踐中的預(yù)處理環(huán)節(jié)涉及分詞、去停用詞等操作模型訓(xùn)練則采用交叉熵?fù)p失與準(zhǔn)確率、F1等評(píng)估指標(biāo)代碼可作為理解反向傳播、梯度下降與注意力機(jī)制的直觀示例。除基礎(chǔ)模型外資源還涵蓋預(yù)訓(xùn)練模型如BERT的微調(diào)思路有助于進(jìn)一步擴(kuò)展實(shí)驗(yàn)適合作為課程作業(yè)、入門(mén)實(shí)踐或論文復(fù)現(xiàn)的參考基礎(chǔ)。1. 這個(gè) zip 里裝的不是代碼是一整套文本分類(lèi)的落地思路搜索「基于深度學(xué)習(xí)的文本分類(lèi).zip」的人大多不是來(lái)找論文的而是想看看能不能直接解壓、跑通、改改參數(shù)用在自己的場(chǎng)景里。這份壓縮包如果整理得規(guī)范里面應(yīng)該是一個(gè)完整工程訓(xùn)練腳本、模型定義、數(shù)據(jù)集樣本、README 和依賴(lài)清單。深度學(xué)習(xí)文本分類(lèi)的入門(mén)門(mén)檻不在模型本身而在數(shù)據(jù)預(yù)處理、訓(xùn)練流程和踩坑排查這三件事上恰好這些也是 zip 里最容易亂的部分。這篇筆記按「包有什么 → 數(shù)據(jù)怎么預(yù)處理 → 模型怎么選怎么練 → 參數(shù)怎么調(diào) → 坑在哪 → 怎么落地部署」的順序把整個(gè)鏈路拆開(kāi)講適合準(zhǔn)備做輿情分類(lèi)、工單自動(dòng)打標(biāo)、評(píng)論情感判定的從業(yè)者照著復(fù)現(xiàn)。2. 從壓縮包到能跑的模型環(huán)境準(zhǔn)備與數(shù)據(jù)預(yù)處理2.1 解壓后的目錄結(jié)構(gòu)先確認(rèn)包里有什么再動(dòng)手拿到 zip 后第一步不是急著跑訓(xùn)練腳本而是把目錄結(jié)構(gòu)摸清楚。常見(jiàn)的深度學(xué)習(xí)文本分類(lèi)項(xiàng)目會(huì)分成這幾個(gè)部分data/放原始數(shù)據(jù)和預(yù)處理腳本models/放網(wǎng)絡(luò)結(jié)構(gòu)定義train.py是訓(xùn)練入口predict.py是推理入口requirements.txt列依賴(lài)庫(kù)。有的包還會(huì)帶config.py或config.yaml所有超參數(shù)集中在這里方便調(diào)參。先看依賴(lài)清單是不是完整。比較省事的做法是建一個(gè)干凈的虛擬環(huán)境再安裝依賴(lài)避免把系統(tǒng) Python 環(huán)境搞亂。在 Linux 或 macOS 下用python3 -m venvWindows 下同理只是路徑分隔符不同。裝依賴(lài)時(shí)注意 PyTorch 的版本要和本機(jī) CUDA 匹配如果requirements.txt里寫(xiě)的是torch1.13.1但你的顯卡驅(qū)動(dòng)只支持 CUDA 11.7那就要手動(dòng)指定對(duì)應(yīng)版本否則后面訓(xùn)練時(shí)會(huì)報(bào) CUDA 不可用的錯(cuò)。# 創(chuàng)建虛擬環(huán)境并激活Windows 去掉 source 前綴 python3 -m venv text_cls_env source text_cls_env/bin/activate pip install -r requirements.txt提示如果requirements.txt不存在先裝上五個(gè)基礎(chǔ)庫(kù)再逐個(gè)補(bǔ)numpy、pandas、scikit-learn、torch、transformers。跑起來(lái)缺什么補(bǔ)什么比一次性裝全更省心。依賴(lài)裝完后先跑一句python -c import torch; print(torch.__version__)確認(rèn) PyTorch 能正常導(dǎo)入。很多所謂的「環(huán)境問(wèn)題」其實(shí)只是 conda 和 venv 的 Python 路徑互相干擾進(jìn)入虛擬環(huán)境后用which python看一眼解釋器路徑是不是在虛擬環(huán)境里。2.2 數(shù)據(jù)預(yù)處理清洗、分詞、標(biāo)簽編碼與訓(xùn)練/驗(yàn)證集劃分文本分類(lèi)的數(shù)據(jù)預(yù)處理決定了模型上限。即便是同一個(gè)模型預(yù)處理方式不同效果能差出三到五個(gè)百分點(diǎn)。核心步驟是四件事清洗、分詞、標(biāo)簽編碼、數(shù)據(jù)集劃分。清洗這步中英文場(chǎng)景差別很大。英文要處理大小寫(xiě)和詞形還原中文則需要考慮繁簡(jiǎn)轉(zhuǎn)換和全半角歸一。比較普適的是把 URL、郵箱、連續(xù)數(shù)字替換成特殊占位符因?yàn)檫@些 token 不會(huì)出現(xiàn)在預(yù)測(cè)數(shù)據(jù)里直接刪掉反而會(huì)切斷語(yǔ)義。常見(jiàn)做法是保留業(yè)務(wù)中出現(xiàn)頻率最高的那部分符號(hào)其余統(tǒng)一替換。分詞策略直接決定詞表大小和 OOVout-of-vocabulary未登錄詞比例。中文用jieba是默認(rèn)選擇但詞典需要按業(yè)務(wù)定制。做財(cái)經(jīng)輿情分類(lèi)時(shí)「降準(zhǔn)」「北向資金」這種詞 jieba 默認(rèn)詞庫(kù)里沒(méi)有就會(huì)被拆成「降」和「準(zhǔn)」模型學(xué)到的是錯(cuò)誤語(yǔ)義。建議在用 jieba 分詞前把業(yè)務(wù)詞表加載進(jìn)去并且開(kāi)啟HMM參數(shù)來(lái)處理新詞發(fā)現(xiàn)。import jieba import pandas as pd from sklearn.model_selection import train_test_split jieba.set_dictionary(data/jieba_dict.txt) jieba.load_userdict(data/biz_words.txt) # 業(yè)務(wù)詞表 def clean_text(text): 清洗去HTML標(biāo)簽、統(tǒng)一空白、保留中文英文數(shù)字 import re text re.sub(r.*?, , text) text re.sub(r\s, , text) text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text) return text.strip() def tokenize(text): return [w for w in jieba.cut(clean_text(text), HMMTrue) if w.strip()] df pd.read_csv(data/raw.csv, header0) df[title] df[title].apply(tokenize) # 按標(biāo)簽分層劃分保證訓(xùn)練/驗(yàn)證集的類(lèi)別分布一致 X_train, X_val, y_train, y_val train_test_split( df[title], df[label], test_size0.2, stratifydf[label], random_state42 )這段代碼里最容易忽略的是stratify參數(shù)。如果原始數(shù)據(jù)里正樣本只占 10%不做分層抽樣訓(xùn)練集可能只有 5% 的正樣本驗(yàn)證集卻有 20%模型訓(xùn)練時(shí)看到的正樣本分布波動(dòng)很大直接表現(xiàn)為 F1 分?jǐn)?shù)忽高忽低。random_state42是固定隨機(jī)種子方便復(fù)現(xiàn)——同一份數(shù)據(jù)每次跑出來(lái)的數(shù)應(yīng)該完全一致不一致說(shuō)明有隱含的隨機(jī)因素沒(méi)被固定。2.3 詞向量與序列填充模型輸入前最后一步分詞完成后下一步是構(gòu)建詞表并把文本轉(zhuǎn)成索引序列。這個(gè)環(huán)節(jié)有三個(gè)關(guān)鍵決策詞表大小上限、序列最大長(zhǎng)度、OOV 詞的處理策略。詞表上限通常設(shè)在 5 萬(wàn)到 10 萬(wàn)之間。詞表設(shè)太大低頻詞太多模型會(huì)在訓(xùn)練時(shí)嚴(yán)重過(guò)擬合這些出現(xiàn)一兩次的詞設(shè)太小OOV 比例升高句子信息損失嚴(yán)重。比較穩(wěn)妥的做法是統(tǒng)計(jì)訓(xùn)練集詞頻保留出現(xiàn)次數(shù) top 5 萬(wàn)的詞詞頻為 1 的詞直接標(biāo)為 OOV。序列長(zhǎng)度選擇上既看業(yè)務(wù)也看模型。做短文本分類(lèi)標(biāo)題、評(píng)論長(zhǎng)度在 64 到 128 之間就覆蓋了絕大多數(shù)場(chǎng)景。做長(zhǎng)文本分類(lèi)工單描述、公告全文可以到 512。但序列變長(zhǎng)TextCNN 和 Transformer 的顯存占用會(huì)呈線性到平方級(jí)增長(zhǎng)不要一上來(lái)就設(shè) 512先用 128 跑通看長(zhǎng)度分布再調(diào)。from collections import Counter import torch from torch.utils.data import Dataset, DataLoader MAX_VOCAB_SIZE 50000 MAX_SEQ_LEN 128 def build_vocab(tokenized_texts): freq Counter(w for tokens in tokenized_texts for w in tokens) vocab {w: i2 for i, (w, c) in enumerate(freq.most_common(MAX_VOCAB_SIZE))} vocab[PAD] 0 vocab[UNK] 1 return vocab def encode(tokens, vocab): return [vocab.get(w, vocab[UNK]) for w in tokens] def pad_sequence(ids, max_lenMAX_SEQ_LEN): if len(ids) max_len: return ids[:max_len] return ids [0] * (max_len - len(ids)) class TextDataset(Dataset): def __init__(self, tokenized_texts, labels, vocab): self.data [torch.tensor(pad_sequence(encode(t, vocab))) for t in tokenized_texts] self.labels torch.tensor(labels, dtypetorch.long) def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx], self.labels[idx] vocab build_vocab(X_train) train_ds TextDataset(X_train.tolist(), y_train.tolist(), vocab) val_ds TextDataset(X_val.tolist(), y_val.tolist(), vocab) train_loader DataLoader(train_ds, batch_size64, shuffleTrue)這里PAD和UNK分別占了索引 0 和 1從 2 開(kāi)始才是真正的詞索引。有個(gè)別實(shí)現(xiàn)會(huì)從 1 開(kāi)始編號(hào)把 0 留給 PAD但 UNK 就沒(méi)位置了會(huì)導(dǎo)致 OOV 詞直接被編碼成 PAD模型學(xué)不到「這個(gè)詞不在詞表里」這個(gè)信號(hào)。處理類(lèi)別時(shí)用torch.long保證和 CrossEntropyLoss 的輸入類(lèi)型匹配。注意build_vocab只應(yīng)該在訓(xùn)練集上執(zhí)行。如果先在整個(gè)數(shù)據(jù)集上建詞表再做劃分詞表里會(huì)混入驗(yàn)證集和測(cè)試集的詞頻信息這就是典型的數(shù)據(jù)泄露后面會(huì)專(zhuān)門(mén)展開(kāi)講。3. 模型選型TextCNN、RNN 還是 BERT你的數(shù)據(jù)量說(shuō)了算3.1 三種模型的原理差異和適用場(chǎng)景文本分類(lèi)的模型選型本質(zhì)是「你有多少數(shù)據(jù)」和「你的推理延遲預(yù)算多少」之間的權(quán)衡。TextCNN 用多個(gè)尺寸的卷積核提取 n-gram 級(jí)別的局部特征計(jì)算量小、訓(xùn)練快適合數(shù)據(jù)量在幾萬(wàn)到幾十萬(wàn)條、延遲要求高的場(chǎng)景。RNN包括 LSTM、GRU按時(shí)間步處理序列能建模長(zhǎng)距離依賴(lài)但訓(xùn)練速度慢且難以并行適合序列長(zhǎng)度較長(zhǎng)且數(shù)據(jù)量中等的場(chǎng)景。BERT 這類(lèi)預(yù)訓(xùn)練模型通過(guò)大規(guī)模語(yǔ)料預(yù)訓(xùn)練獲得語(yǔ)義表示在小樣本幾千條條件下表現(xiàn)遠(yuǎn)好于前兩者但推理慢、顯存占用高。有個(gè)反直覺(jué)的結(jié)論數(shù)據(jù)量超過(guò) 50 萬(wàn)條時(shí)TextCNN 微調(diào)后的效果不一定比 BERT 差多少。因?yàn)?CNN 的歸納偏置是局部窗口對(duì)詞序不敏感但從數(shù)據(jù)中學(xué)習(xí)的效率更高。數(shù)據(jù)量小的時(shí)候預(yù)訓(xùn)練模型大幅領(lǐng)先因?yàn)槟銢](méi)有足夠的數(shù)據(jù)讓模型從零學(xué)會(huì)語(yǔ)義。3.2 用 PyTorch 實(shí)現(xiàn)一個(gè) TextCNN 基線TextCNN 是最適合做基線的模型。結(jié)構(gòu)清晰、訓(xùn)練快、調(diào)參空間明確。核心思路是用多個(gè)不同寬度的卷積核并行提取 n-gram 特征然后做全局池化接全連接層分類(lèi)。import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim128, num_filters256, filter_sizes(3, 4, 5), num_classes10, dropout0.5, pad_idx0): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idxpad_idx) self.convs nn.ModuleList() for size in filter_sizes: # 每個(gè)卷積核尺寸對(duì)應(yīng)一個(gè) Conv2d 分支 self.convs.append(nn.Conv2d( in_channels1, out_channelsnum_filters, kernel_size(size, embed_dim) )) self.fc nn.Linear(num_filters * len(filter_sizes), num_classes) self.dropout nn.Dropout(dropout) def forward(self, x): # x: (batch, seq_len) emb self.embedding(x) # (batch, seq_len, embed_dim) emb emb.unsqueeze(1) # 加通道維變成 (batch, 1, seq_len, embed_dim) conv_outputs [] for conv in self.convs: c conv(emb) # (batch, num_filters, conv_seq_len, 1) c F.relu(c).squeeze(3) # 去掉末尾的 1 c F.max_pool1d(c, c.size(2)).squeeze(2) # 全局最大池化 conv_outputs.append(c) out torch.cat(conv_outputs, dim1) out self.dropout(out) return self.fc(out)這段實(shí)現(xiàn)里有三處邊界細(xì)節(jié)值得說(shuō)明。embedding指定了padding_idx0PAD 位置在反向傳播時(shí)梯度恒為 0不會(huì)參與詞向量更新如果不設(shè)置這個(gè)參數(shù)PAD 詞會(huì)被模型當(dāng)成真實(shí)的共同上下文學(xué)到離譜的噪聲。kernel_size(size, embed_dim)里第二個(gè)維度必須等于詞向量維度因?yàn)榫矸e核要在整個(gè) embedding 寬度上滑動(dòng)不能只覆蓋部分維度。max_pool1d對(duì)每個(gè)卷積輸出取最大值得到固定長(zhǎng)度的向量這樣不管輸入序列多長(zhǎng)全連接層的輸入維度都是確定的。3.3 模型訓(xùn)練循環(huán)損失函數(shù)、優(yōu)化器與學(xué)習(xí)率文本分類(lèi)的損失函數(shù)用交叉熵即可但要注意三個(gè)細(xì)節(jié)。第一是類(lèi)別權(quán)重如果數(shù)據(jù)集類(lèi)別不平衡給少數(shù)類(lèi)更高的權(quán)重能顯著提升 F1第二是標(biāo)簽平滑文本分類(lèi)很容易過(guò)擬合標(biāo)簽平滑讓模型不那么自信訓(xùn)練更穩(wěn)第三是優(yōu)化器選擇AdamW 比 Adam 多了權(quán)重衰減的修正微調(diào) BERT 和從頭訓(xùn)練 CNN 都推薦用 AdamW。import torch.optim as optim from torch.nn import CrossEntropyLoss model TextCNN(len(vocab), num_classeslen(set(y_train))) optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) criterion CrossEntropyLoss() model.train() for epoch in range(10): total_loss 0.0 for batch_x, batch_y in train_loader: optimizer.zero_grad() logits model(batch_x) loss criterion(logits, batch_y) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 梯度裁剪 optimizer.step() total_loss loss.item() print(fepoch {epoch1}, loss: {total_loss/len(train_loader):.4f})clip_grad_norm_是訓(xùn)練穩(wěn)定性法寶。文本分類(lèi)里最容易出現(xiàn) loss 突然變成 NaN 的情況大部分原因是某個(gè) batch 里出現(xiàn)極端長(zhǎng)的序列梯度范數(shù)爆掉。裁剪到 1.0 之后梯度方向不變但長(zhǎng)度受限loss 曲線平滑很多。學(xué)習(xí)率的選擇上從頭訓(xùn)練的模型可以用 1e-3 起手BERT 微調(diào)則必須降到 2e-5 到 5e-5 之間——預(yù)訓(xùn)練模型的參數(shù)已經(jīng)很好了學(xué)習(xí)率太大會(huì)直接把語(yǔ)義表示沖壞。4. 訓(xùn)練參數(shù)怎么設(shè)從過(guò)擬合到欠擬合的排查路徑4.1 必調(diào)的五個(gè)參數(shù)及其合理范圍文本分類(lèi)真正影響結(jié)果的參數(shù)其實(shí)只有五個(gè)embedding 維度、卷積核數(shù)量或隱層維度、dropout 率、學(xué)習(xí)率、batch size。embedding 維度一般取 128 或 256太小語(yǔ)義表示能力不足太大帶來(lái)過(guò)擬合和顯存開(kāi)銷(xiāo)但收益遞減。conv 核數(shù)量取 256 是常見(jiàn)值和 filter_sizes 的三個(gè)尺寸配合后全連接層輸入是 768 維這個(gè)寬度足夠表達(dá)但不會(huì)過(guò)度參化。dropout 通常 0.3 到 0.6 之間。數(shù)據(jù)量大可以降到 0.3數(shù)據(jù)量小應(yīng)該升到 0.5 以上。一個(gè)比較實(shí)用的判斷方法看訓(xùn)練集和驗(yàn)證集的 loss 曲線。驗(yàn)證集 loss 開(kāi)始回升而訓(xùn)練集還在下降說(shuō)明過(guò)擬合加大 dropout 或減小模型復(fù)雜度兩邊都高說(shuō)明欠擬合增大 embedding 維度或加深網(wǎng)絡(luò)。batch size 對(duì)最終效果影響不大但影響訓(xùn)練效率和顯存占用。batch size 翻倍學(xué)習(xí)率也應(yīng)翻倍linear scaling rule這是實(shí)踐中經(jīng)常忽略的細(xì)節(jié)。如果 batch size 從 32 調(diào)到 128 但學(xué)習(xí)率還是原來(lái)的值收斂會(huì)變慢且不穩(wěn)定。4.2 評(píng)估指標(biāo)準(zhǔn)確率不夠用F1 才是真尺子文本分類(lèi)里只打印準(zhǔn)確率是自欺欺人。當(dāng)數(shù)據(jù)集中 90% 是負(fù)樣本時(shí)全部預(yù)測(cè)為負(fù)類(lèi)準(zhǔn)確率就是 90%但這個(gè)模型毫無(wú)價(jià)值。真正的評(píng)估指標(biāo)要看 macro F1 和每個(gè)類(lèi)別的 precision / recall。macro F1 對(duì)類(lèi)別不平衡更敏感能反映模型在少數(shù)類(lèi)上的表現(xiàn)。訓(xùn)練代碼里需要添加驗(yàn)證邏輯每輪訓(xùn)練結(jié)束后在驗(yàn)證集上計(jì)算 F1同時(shí)記錄最佳模型狀態(tài)。這里最容易踩的坑是「模型截止」的時(shí)機(jī)。很多實(shí)現(xiàn)會(huì)在驗(yàn)證 F1 連續(xù) N 輪不提升時(shí)保存模型但最佳驗(yàn)證 F1 可能出現(xiàn)在第 3 輪之后一直在過(guò)擬合。要在訓(xùn)練過(guò)程中保存 F1 最高的那一次 checkpoint而不是最后一步的模型。from sklearn.metrics import f1_score, classification_report best_f1 0.0 model.eval() all_preds, all_labels [], [] with torch.no_grad(): for batch_x, batch_y in val_loader: logits model(batch_x) preds torch.argmax(logits, dim1) all_preds.extend(preds.tolist()) all_labels.extend(batch_y.tolist()) f1 f1_score(all_labels, all_preds, averagemacro) if f1 best_f1: best_f1 f1 torch.save(model.state_dict(), best_model.pt) print(fbest macro F1 updated: {f1:.4f})4.3 早停與模型保存讓訓(xùn)練跑得穩(wěn)早停的 patience 值取 3 到 5。文本分類(lèi)的驗(yàn)證 F1 曲線波動(dòng)比較大patience 太小容易誤停太大浪費(fèi)算力。配合 ReduceLROnPlateau 學(xué)習(xí)率調(diào)度效果更好——驗(yàn)證 F1 連續(xù) 2 輪不升就降學(xué)習(xí)率連續(xù) 4 輪不升就提前停。學(xué)習(xí)率調(diào)度器的參數(shù)設(shè)置要謹(jǐn)慎。modemax表示監(jiān)控驗(yàn)證 F1 這樣的指標(biāo)越大越好factor0.5表示學(xué)習(xí)率減半patience2表示容忍 2 輪不提升才降。這套組合在大多數(shù)文本分類(lèi)工程里都能讓訓(xùn)練過(guò)程穩(wěn)定收斂。5. 避坑文本分類(lèi)最常見(jiàn)的 5 個(gè)翻車(chē)現(xiàn)場(chǎng)5.1 中文編碼與分詞不一致現(xiàn)象訓(xùn)練時(shí) loss 正常下降但驗(yàn)證集的 F1 驟降或者同一句話預(yù)測(cè)結(jié)果和訓(xùn)練時(shí)完全不一致還伴隨亂碼。原因訓(xùn)練數(shù)據(jù)讀進(jìn)來(lái)的時(shí)候是 GBK 編碼預(yù)處理腳本里用的是 UTF-8分詞結(jié)果全是錯(cuò)亂的「錕斤拷」。另一個(gè)常見(jiàn)原因是訓(xùn)練時(shí) jieba 用的是默認(rèn)詞典推理時(shí)加載了業(yè)務(wù)詞典兩次分出來(lái)的詞序列不一致模型看到的輸入完全不同。解決在數(shù)據(jù)處理入口統(tǒng)一聲明編碼pd.read_csv(..., encodingutf-8)并在讀入后做一次標(biāo)準(zhǔn)化把 jieba 的詞典和 tokenize 邏輯封裝成一個(gè)單獨(dú)的模塊訓(xùn)練和推理都調(diào)用同一份代碼永遠(yuǎn)不要在兩處各寫(xiě)一份。5.2 數(shù)據(jù)泄露標(biāo)簽混進(jìn)了特征現(xiàn)象訓(xùn)練集 F1 高達(dá) 0.99驗(yàn)證集也不錯(cuò)但上線后效果崩盤(pán)。原因預(yù)處理時(shí)在整份數(shù)據(jù)集上做了 fit比如 build_vocab驗(yàn)證集和測(cè)試集的詞頻信息通過(guò)詞表泄露給了模型。更隱蔽的情況是清洗過(guò)程中把標(biāo)簽字段當(dāng)作特征輸入了——有些文本數(shù)據(jù)本身包含「已投訴」「已解決」等業(yè)務(wù)狀態(tài)字段這些字段和標(biāo)簽高度相關(guān)但線上預(yù)測(cè)時(shí)這些字段根本不存在。解決先劃分?jǐn)?shù)據(jù)集再做任何統(tǒng)計(jì)類(lèi)操作。清理字段時(shí)明確區(qū)分輸入特征和標(biāo)簽列建議寫(xiě)死列名清單防止后續(xù)迭代時(shí)新增字段被誤當(dāng)成特征。驗(yàn)證方法是把訓(xùn)練好的模型拿來(lái)預(yù)測(cè)訓(xùn)練集樣本如果 F1 接近 1.0多半有特征泄露。5.3 類(lèi)別極端不平衡模型全猜多數(shù)類(lèi)現(xiàn)象訓(xùn)練 loss 還在下降但少數(shù)類(lèi)的 recall 是 0精確率也沒(méi)意義。原因CrossEntropyLoss 默認(rèn)給每個(gè)類(lèi)別相同的權(quán)重模型學(xué)到的最優(yōu)策略是全猜數(shù)量最多的那個(gè)類(lèi)別。工單分類(lèi)里「咨詢(xún)」類(lèi)占 85%「投訴」類(lèi)占 3%模型直接把所有樣本都預(yù)測(cè)為「咨詢(xún)」宏觀 F1 慘不忍睹。解決給 CrossEntropyLoss 傳入類(lèi)別權(quán)重。權(quán)重比例按樣本數(shù)倒數(shù)算比如多數(shù)類(lèi)權(quán)重為 1少數(shù)類(lèi)權(quán)重為多數(shù)類(lèi)樣本數(shù)除以少數(shù)類(lèi)樣本數(shù)。另一個(gè)思路是用 Focal Loss讓模型把注意力集中在難分類(lèi)的樣本上但要注意超參數(shù)調(diào)優(yōu)成本。5.4 解壓失敗或文件損壞現(xiàn)象解壓時(shí)提示 CRC 校驗(yàn)失敗、文件缺失或者 README 里提到的文件在目錄里找不到。原因zip 文件在傳輸過(guò)程中被截?cái)嗷蛘哂玫氖欠菢?biāo)準(zhǔn)壓縮工具產(chǎn)生兼容問(wèn)題?!竮ip 偽加密」也會(huì)導(dǎo)致解壓異?!募募用軜?biāo)志位被改動(dòng)但實(shí)際內(nèi)容并未加密。解決先驗(yàn)證文件完整性。對(duì)比壓縮包附帶的 MD5 或 SHA256 校驗(yàn)值如果哈希對(duì)不上就重新下載用 7-Zip 打開(kāi)看文件列表是否能正常預(yù)覽。偽加密的情況可以先查看壓縮包詳情確認(rèn)加密標(biāo)志位如果確實(shí)無(wú)法解壓直接放棄這個(gè)文件向作者索要重新打包的版本。5.5 顯存不足與 OOM現(xiàn)象訓(xùn)練跑到第 3 個(gè) epoch 突然報(bào)CUDA out of memorybatch size 從 64 降到 32 還是爆。原因絕大多數(shù)情況是序列太長(zhǎng)。TextCNN 輸入是三維張量batch size 乘以序列長(zhǎng)度乘以詞向量維度再乘參數(shù)規(guī)模決定顯存占用。如果數(shù)據(jù)里存在幾千字的超長(zhǎng)文本padding 到統(tǒng)一長(zhǎng)度后整批樣本大多是無(wú)意義的 PAD 填充。解決先看訓(xùn)練數(shù)據(jù)的序列長(zhǎng)度分布用pd.Series([len(t) for t in X_train]).describe()統(tǒng)計(jì) p95 和 p99 長(zhǎng)度把 max_seq_len 設(shè)在 p95 附近即可沒(méi)必要覆蓋 p100。另一個(gè)辦法是梯度累積小 batch 多次前向傳播積累梯度后統(tǒng)一做一次反向更新效果等效于大 batch顯存卻小得多。6. 進(jìn)階從單模型到可部署的文本分類(lèi)系統(tǒng)6.1 用 ONNX 導(dǎo)出模型做推理驗(yàn)證模型在你本地跑得好不代表能順利進(jìn)服務(wù)。PyTorch 的推理鏈路依賴(lài) Python 運(yùn)行時(shí)部署環(huán)境未必裝得了整套依賴(lài)。通常做法是把模型導(dǎo)出為 ONNX 格式用 ONNX Runtime 做推理進(jìn)一步還可以轉(zhuǎn)成 TensorRT 在 GPU 上提速。import torch.onnx import onnxruntime as ort model.load_state_dict(torch.load(best_model.pt)) model.eval() dummy_input torch.zeros(1, 128, dtypetorch.long) torch.onnx.export( model, dummy_input, text_cnn.onnx, input_names[input_ids], output_names[logits], dynamic_axes{input_ids: {0: batch}, logits: {0: batch}} ) ort_session ort.InferenceSession(text_cnn.onnx) result ort_session.run( [logits], {input_ids: dummy_input.numpy()} ) print(result[0].shape)導(dǎo)出 ONNX 時(shí)最關(guān)鍵的參數(shù)是dynamic_axes。如果不設(shè)置這個(gè)參數(shù)導(dǎo)出的模型會(huì)固化為固定的 batch size線上接口一次只能預(yù)測(cè)一條樣本或固定條數(shù)很不靈活。設(shè)置 batch 為動(dòng)態(tài)后任意批量都能跑。導(dǎo)出的模型數(shù)值和 PyTorch 原模型可能有一點(diǎn)點(diǎn)浮點(diǎn)誤差需要準(zhǔn)備幾個(gè)真實(shí)樣本對(duì)比兩者的 softmax 輸出差異偏差在 1e-4 以?xún)?nèi)就能放心上線。6.2 增量訓(xùn)練與版本管理模型上線后最大的問(wèn)題是數(shù)據(jù)漂移。線上真實(shí)文本的風(fēng)格和訓(xùn)練集存在分布差異剛開(kāi)始效果還行跑一兩個(gè)月后準(zhǔn)確率逐周下降。應(yīng)對(duì)方法是建立回流機(jī)制把線上預(yù)測(cè)置信度低于閾值的樣本積累下來(lái)人工標(biāo)注后再做增量訓(xùn)練。增量訓(xùn)練不是簡(jiǎn)單地在原模型上繼續(xù)跑幾個(gè) epoch那樣很容易災(zāi)難性遺忘。常見(jiàn)做法是降低新數(shù)據(jù)的學(xué)習(xí)率——原模型所有參數(shù)用一個(gè)很小的學(xué)習(xí)率比如 5e-5新數(shù)據(jù)上只訓(xùn)練 3 到 5 個(gè) epoch然后用驗(yàn)證集檢驗(yàn)原類(lèi)別和新類(lèi)別的 F1 變化。如果舊類(lèi)別 F1 下降超過(guò) 2 個(gè)百分點(diǎn)說(shuō)明學(xué)習(xí)率太大或新數(shù)據(jù)占比失衡。我一般會(huì)給舊樣本保留部分采樣新數(shù)據(jù)按召回策略重采樣后混合訓(xùn)練這樣兩個(gè)分布都能兼顧。模型版本管理上固定打包輸入預(yù)處理邏輯、詞表、模型權(quán)重三個(gè)文件為一個(gè)版本號(hào)文本分類(lèi)系統(tǒng)的線上問(wèn)題排查非常依賴(lài)版本可回滾。曾經(jīng)在生產(chǎn)環(huán)境踩過(guò)一次坑新版本改了 jieba 詞典導(dǎo)致線上分詞結(jié)果變了老版本的模型輸入分布錯(cuò)亂準(zhǔn)確率掉到 30% 以下。這種問(wèn)題根本查不出來(lái)只能整體回滾。從那以后我把分詞邏輯找到的每個(gè)版本都單獨(dú)歸檔絕不因?yàn)椤钢皇歉膫€(gè)詞典」就放任不管。希望這個(gè)習(xí)慣對(duì)你有幫助。本文還有配套的精品資源點(diǎn)擊獲取