合的短文本分類:從word2vec訓(xùn)練到TextCNN模型實(shí)踐)
簡(jiǎn)介面向自然語言處理與生物信息學(xué)交叉領(lǐng)域的學(xué)習(xí)者這份資源以CNN結(jié)合word2vec完成基因序列分類任務(wù)整合CBOW與Skip-gram詞向量訓(xùn)練、多尺寸卷積核局部特征提取覆蓋從原始序列處理、詞嵌入到池化、全連接分類的完整技術(shù)鏈路。壓縮包共5個(gè)文件約174KB包含Python腳本、自定義詞表、FASTA格式的Rice_880序列數(shù)據(jù)以及模型架構(gòu)圖和損失曲線圖雖然體積不大但代碼、數(shù)據(jù)、可視化一應(yīng)俱全腳本負(fù)責(zé)模型構(gòu)建與訓(xùn)練詞表提供詞匯映射FASTA為輸入序列圖片則分別用于結(jié)構(gòu)說明與訓(xùn)練監(jiān)控。已有168人瀏覽學(xué)習(xí)適合作為NLP與生物信息學(xué)交叉課題的入門參考。讀者可對(duì)照腳本代碼理解數(shù)據(jù)預(yù)處理、word2vec詞向量生成、卷積與池化參數(shù)設(shè)置等關(guān)鍵環(huán)節(jié)借助模型架構(gòu)圖梳理網(wǎng)絡(luò)層次利用損失曲線判斷訓(xùn)練收斂情況從而快速?gòu)?fù)現(xiàn)一個(gè)可運(yùn)行的序列分類基線系統(tǒng)也可將思路遷移到情感分析、文本主題識(shí)別等序列分類場(chǎng)景。1. 序列分類這道題為什么我選了 CNN 加 word2vec做序列分類大部分人第一反應(yīng)是 LSTM 或者 Transformer。但如果你手里的任務(wù)是短文本、定長(zhǎng)日志、工單意圖或者評(píng)論打標(biāo)數(shù)據(jù)量又只有幾萬條那我更推薦先用 CNN 加 word2vec 把基線跑起來。原因很簡(jiǎn)單它訓(xùn)練快、參數(shù)少、對(duì)硬件要求低而且在短序列上效果往往不比循環(huán)網(wǎng)絡(luò)差。這個(gè)方向近幾年的熱度一直沒降過核心就是先離線訓(xùn)好 word2vec 詞向量再用卷積神經(jīng)網(wǎng)絡(luò)去捕捉序列里的局部特征后續(xù)甚至能擴(kuò)展成 TextCNN、雙通道 CNN 等結(jié)構(gòu)。這篇筆記會(huì)帶你過一遍完整流程從詞向量訓(xùn)練到模型落地代碼可以直接抄。2. 先把詞向量搞好word2vec 訓(xùn)練與選型細(xì)節(jié)2.1 詞向量從哪來自己訓(xùn)還是拿現(xiàn)成的在把文本交給 CNN 之前得先想清楚怎么把詞語變成向量。常見做法有兩種一是直接用預(yù)訓(xùn)練好的詞向量文件比如 Tencent AI Lab Embedding 或者百度百科向量二是用自己的語料離線訓(xùn)一套 word2vec。我一般只在兩種情況下用現(xiàn)成的手里連原始文本都沒有或者只是想兩小時(shí)跑通一個(gè) demo。一旦你手里有行業(yè)語料比如客服對(duì)話、醫(yī)療報(bào)告、代碼日志自己訓(xùn)幾乎總是更好因?yàn)轭I(lǐng)域詞、錯(cuò)別字、英文縮寫這些在通用詞向量里往往沒有靠譜的表達(dá)。自己訓(xùn)的好處在于可控比如你可以決定向量維度、上下文窗口大小還能把未登錄詞的覆蓋率壓得很低。壞處是語料不夠的時(shí)候詞向量質(zhì)量會(huì)比通用預(yù)訓(xùn)練差這個(gè)沒什么玄學(xué)數(shù)據(jù)量不夠就是不夠。這里有個(gè)可行的折中方案用現(xiàn)成詞向量做初始化再在自己的語料上做增量訓(xùn)練這樣既拿到通用語義又能吸收領(lǐng)域詞匯。用 gensim 就能實(shí)現(xiàn)加載預(yù)訓(xùn)練模型后繼續(xù)訓(xùn)練但要控制學(xué)習(xí)率這個(gè)稍后再展開。2.2 訓(xùn)練 word2vec 的參數(shù)怎么定訓(xùn)練 word2vec 時(shí)參數(shù)選擇直接決定向量質(zhì)量。下面的代碼是一個(gè)最基礎(chǔ)的訓(xùn)練腳本語料是一行一條的分好詞的文本文件每一行是空格分隔的 token 序列。import jieba from gensim.models import Word2Vec from gensim.models.word2vec import LineSentence # 原始語料分詞輸出到文件 with open(corpus.txt, r, encodingutf-8) as f_in, \ open(corpus_seg.txt, w, encodingutf-8) as f_out: for line in f_in: seg_list jieba.cut(line.strip()) f_out.write( .join(seg_list) \n) # 訓(xùn)練詞向量 model Word2Vec( LineSentence(corpus_seg.txt), vector_size200, # 向量維度常見 100~300 window5, # 上下文窗口 min_count5, # 過濾低頻詞 sg1, # 1 用 skip-gram0 用 CBOW epochs10, # 訓(xùn)練輪數(shù) workers4, # 并行線程 negative10, # 負(fù)采樣數(shù)量 sample1e-5, # 高頻詞下采樣閾值 ) model.save(w2v.model)參數(shù)選擇邏輯是這樣的sg 選 skip-gram 適合中小規(guī)模語料它對(duì)低頻詞的表達(dá)更友好在大語料上才會(huì)考慮換 CBOW 追求速度。vector_size 我一般先拉 200小數(shù)據(jù)集用 100 也行但要記住維度太低表達(dá)不了復(fù)雜語義太高在小語料上又容易過擬合。window 設(shè) 5 對(duì)短文本合適如果句子里存在長(zhǎng)距離依賴可以調(diào)到 8但 CNN 本身是局部建模窗口太長(zhǎng)了意義有限。min_count 設(shè) 5 是為了過濾拼寫錯(cuò)誤和只出現(xiàn)一兩次的生僻詞避免它們拉低向量質(zhì)量。訓(xùn)練完成后建議先在驗(yàn)證集上做一次相似詞檢查比如查“退貨”的 top10 相似詞看結(jié)果是否符合同義、近義、相關(guān)詞的預(yù)期。這一步比看 loss 曲線有用得多詞向量是下游任務(wù)的上游這里翻車后面全白搭。3. 把詞向量接到 CNNEmbedding、卷積與池化的落地做法3.1 輸入側(cè)處理分詞、詞典、定長(zhǎng)序列詞向量訓(xùn)好之后下一步是構(gòu)建模型輸入。CNN 不像 LSTM 那樣能直接吃變長(zhǎng)序列必須把每個(gè)句子截?cái)嗷蛘哐a(bǔ)齊成同一個(gè)長(zhǎng)度。這一步很關(guān)鍵因?yàn)?CNN 的卷積核是在空間維度上滑動(dòng)的序列長(zhǎng)度不一致會(huì)導(dǎo)致 batch 內(nèi)張量形狀沖突。import numpy as np from tensorflow.keras.preprocessing.sequence import pad_sequences # 構(gòu)建 詞-id 映射 word2idx {word: idx 2 for idx, word in enumerate(model.wv.index_to_key)} word2idx[PAD] 0 word2idx[UNK] 1 def encode_sentence(sentence, max_len50): # 分詞后映射為 id 序列 tokens jieba.lcut(sentence.strip()) ids [word2idx.get(w, word2idx[UNK]) for w in tokens] # 截?cái)? paddingpad 位置放在后面 ids ids[:max_len] ids ids [word2idx[PAD]] * (max_len - len(ids)) return np.array(ids, dtypenp.int32) # 示例 sample_ids encode_sentence(這件衣服質(zhì)量不錯(cuò)就是物流慢, max_len50) print(sample_ids[:10])這里要提兩個(gè)細(xì)節(jié)。第一UNK必須有因?yàn)榫€上預(yù)測(cè)時(shí)大概率會(huì)遇到訓(xùn)練集沒見過的詞沒有這個(gè)映射就只能報(bào)錯(cuò)。第二padding 的方向值得斟酌我習(xí)慣把 pad 加在后面即 post-padding這樣卷積核在滑動(dòng)時(shí)先看到真實(shí)詞再看到 pad 位對(duì)短文本更友好有些場(chǎng)景 pre-padding 也能用但會(huì)讓 CNN 前幾個(gè)卷積步長(zhǎng)落在空位上等于白算。max_len 的選取要看你數(shù)據(jù)里句子長(zhǎng)度的分布先統(tǒng)計(jì)一下 p95 的長(zhǎng)度再往上留一點(diǎn)余量比如 95% 的樣本不足 60 個(gè) token那就設(shè) 64不要盲目設(shè) 200。3.2 構(gòu)建 CNN 分類網(wǎng)絡(luò)從 Embedding 層到輸出層詞向量接 CNN 的核心思路Embedding 層把 token id 映射成稠密向量然后多組不同寬度的卷積核并行掃描句子抓取 n-gram 級(jí)別的局部特征再用全局池化把每個(gè)卷積核產(chǎn)生的特征向量壓成一個(gè)標(biāo)量最后拼起來過全連接層。這里的直覺是2-gram、3-gram、4-gram 的卷積核分別對(duì)應(yīng)詞組、短語和短句模式比單一寬度靈話。import tensorflow as tf from tensorflow.keras import Input, Model from tensorflow.keras.layers import Embedding, Conv1D, GlobalMaxPooling1D, Concatenate, Dense, Dropout embedding_dim 200 vocab_size len(word2idx) num_filters 128 filter_sizes [2, 3, 4] max_len 50 # 輸入是 padding 后的序列 inputs Input(shape(max_len,), dtypeint32) # 加載 word2vec 向量作為初始權(quán)重 embedding_matrix np.zeros((vocab_size, embedding_dim)) for word, idx in word2idx.items(): if word in model.wv: embedding_matrix[idx] model.wv[word] embedding Embedding( input_dimvocab_size, output_dimembedding_dim, weights[embedding_matrix], trainableTrue, # 置 True 表示訓(xùn)練中微調(diào)詞向量 mask_zeroTrue, # 跳過 padding 位置的計(jì)算 )(inputs) # 多寬度卷積 全局最大池化 conv_blocks [] for fsize in filter_sizes: conv Conv1D(filtersnum_filters, kernel_sizefsize, activationrelu)(embedding) pooled GlobalMaxPooling1D()(conv) conv_blocks.append(pooled) # 拼接所有池化結(jié)果 merged Concatenate()(conv_blocks if len(conv_blocks) 1 else conv_blocks[0]) merged Dropout(0.5)(merged) outputs Dense(1, activationsigmoid)(merged) # 二分類場(chǎng)景多分類改用 softmax model Model(inputs, outputs) model.compile(optimizeradam, lossbinary_crossentropy, metrics[accuracy]) model.summary()這段代碼里有幾個(gè)參數(shù)值得說明。第一trainableTrue我建議默認(rèn)開著因?yàn)樵谟?xùn)練過程中讓詞向量微調(diào)往往能漲兩三個(gè)點(diǎn)只有數(shù)據(jù)量極其稀少時(shí)才凍結(jié) Embedding 層防止災(zāi)難性遺忘。第二mask_zeroTrue配合 max-pooling能讓 padding 位置的卷積結(jié)果不被池化選中這是 TextCNN 里容易被忽略的細(xì)節(jié)。第三num_filters設(shè) 128 對(duì)大多數(shù)任務(wù)夠用數(shù)據(jù)量上去再調(diào)大小數(shù)據(jù)設(shè)太大只會(huì)增加過擬合風(fēng)險(xiǎn)。relu 激活是標(biāo)配換別的非線性對(duì)結(jié)果影響不大。3.3 訓(xùn)練與評(píng)估先看 loss 再看準(zhǔn)確率訓(xùn)練時(shí)我習(xí)慣把數(shù)據(jù)先切出 10% 做驗(yàn)證集而且要保證類別分布大致一致這里用sklearn的train_test_split加上stratify參數(shù)就能搞定。下面這段是訓(xùn)練與驗(yàn)證的最小閉環(huán)。from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val train_test_split( X_ids, y_labels, test_size0.1, stratifyy_labels, random_state42 ) history model.fit( X_train, y_train, validation_data(X_val, y_val), batch_size64, epochs20, callbacks[ tf.keras.callbacks.EarlyStopping(patience3, restore_best_weightsTrue), tf.keras.callbacks.ReduceLROnPlateau(factor0.5, patience2, verbose1), ] )batch_size 在序列長(zhǎng)度 50、向量維度 200 的條件下設(shè) 64 比較穩(wěn)顯存緊張就降到 32。EarlyStopping 的 patience 設(shè) 3意思是驗(yàn)證集 loss 連續(xù) 3 個(gè) epoch 不降就停。ReduceLROnPlateau 則是當(dāng)訓(xùn)練進(jìn)入平臺(tái)期時(shí)把學(xué)習(xí)率砍半這個(gè)操作比手動(dòng)調(diào)學(xué)習(xí)率省事得多實(shí)測(cè)中經(jīng)常能在第 10 個(gè) epoch 后讓 loss 再降一截。訓(xùn)練完成后別急著看準(zhǔn)確率先對(duì)比訓(xùn)練集和驗(yàn)證集的 loss如果訓(xùn)練 loss 遠(yuǎn)低于驗(yàn)證 loss說明過擬了如果兩個(gè)都高先查輸入側(cè)的編碼是否有 bug再考慮調(diào)參。4. 序列分類避坑五個(gè)真實(shí)踩坑記錄與修復(fù)4.1 現(xiàn)象驗(yàn)證集準(zhǔn)確率高達(dá) 92%線上 A 榜直接崩到 60%原因分析訓(xùn)練集和測(cè)試集來自不同時(shí)間段線上文本里有大量訓(xùn)練集沒出現(xiàn)的新詞、新句式。當(dāng)時(shí)我沒有把訓(xùn)練語料里低頻詞過濾干凈也沒有考慮領(lǐng)域遷移導(dǎo)致模型在已知分布上過擬合碰到新分布就失效。解決在訓(xùn)練詞向量時(shí)就按 min_count 過濾掉低頻詞同時(shí)顯式加上UNK映射線上預(yù)測(cè)時(shí)所有未登錄詞一律落到UNK不要硬編碼一個(gè)隨機(jī)向量。4.2 現(xiàn)象Embedding 層加mask_zeroTrue后 loss 變成 NaN原因分析mask_zeroTrue在某些 GPU 和 tf.keras 版本組合下與后續(xù)的 Conv1D 存在兼容性問題特別是卷積核寬度大于 1 且 padding 方式為valid時(shí)mask 信息不能正確傳導(dǎo)到池化層。解決要么把mask_zero關(guān)掉通過傳入無 padding 的原始序列并手動(dòng)做 mask 計(jì)算要么升級(jí) TF 版本。更穩(wěn)妥的做法是在 Embedding 層不啟用 mask直接用 GlobalMaxPooling1D因?yàn)?pad 位置的詞向量恰好是零向量經(jīng)過卷積和 relu 之后池化層天然會(huì)忽略全零區(qū)域。4.3 現(xiàn)象驗(yàn)證集準(zhǔn)確率穩(wěn)定但類別少的樣本幾乎全錯(cuò)原因分析典型的不均衡分類問題。比如工單數(shù)據(jù)里“投訴”只占 1%二分類準(zhǔn)確率再高都是被多數(shù)類帶跑的。準(zhǔn)確率這個(gè)指標(biāo)在這種場(chǎng)景下沒有參考意義。解決評(píng)估指標(biāo)換成 F1-score、recall 和 confusion matrix訓(xùn)練時(shí)給少數(shù)類加class_weight。下面是 Keras 里設(shè)置類別權(quán)重的方式from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight( class_weightbalanced, classesnp.unique(y_train), yy_train ) class_weight_dict {i: w for i, w in zip(np.unique(y_train), class_weights)} model.fit(X_train, y_train, class_weightclass_weight_dict, validation_data(X_val, y_val))compute_class_weight會(huì)自動(dòng)按類別頻率倒數(shù)折算權(quán)重少數(shù)類樣本在計(jì)算 loss 時(shí)獲得更大懲罰梯度更新時(shí)會(huì)更偏向把它們分對(duì)。這個(gè)方法比人工指定權(quán)重省心但注意權(quán)重過大反而會(huì)讓模型震蕩我一般限制權(quán)重最大值不超過 10。4.4 現(xiàn)象同樣的代碼別人跑出 88%我跑出 76%原因分析嵌入層的初始化權(quán)重不同。np.zeros初始化會(huì)破壞詞向量語義別人可能用了model.wv轉(zhuǎn)換時(shí)保持向量不變而我用零矩陣然后把PAD和UNK置零導(dǎo)致所有未知詞在初始化時(shí)都是全零向量相當(dāng)于給模型注入大量噪音。解決初始化矩陣時(shí)對(duì)不在詞表中的詞用很小的隨機(jī)值初始化而對(duì)PAD才用零向量。修改方法很簡(jiǎn)單embedding_matrix np.random.uniform(-0.05, 0.05, (vocab_size, embedding_dim)) embedding_matrix[word2idx[PAD]] 0.0 for word, idx in word2idx.items(): if word in model.wv: embedding_matrix[idx] model.wv[word]4.5 現(xiàn)象訓(xùn)練一輪非常慢batch 跑完 GPU 利用率只有 30%原因分析問題不在模型而在數(shù)據(jù)處理管線。每個(gè) epoch 都在重新調(diào)用jieba.cut做分詞還把分詞結(jié)果反復(fù)轉(zhuǎn)成 idCPU 成為瓶頸GPU 一直空轉(zhuǎn)。解決把分詞、編碼一次性離線處理完成存成.npy或者 TFRecord訓(xùn)練時(shí)直接從磁盤讀整數(shù)序列。我第一次跑這個(gè)模型時(shí)也在循環(huán)里調(diào)分詞接口一輪訓(xùn)練下來 90% 的時(shí)間都在等 CPU改成離線預(yù)處理后速度提升超過 6 倍。5. 進(jìn)階驗(yàn)證與調(diào)優(yōu)用混淆矩陣和壞例分析指導(dǎo)下一步模型訓(xùn)練完只是第一步真正決定上線質(zhì)量的是驗(yàn)證方法。我的固定習(xí)慣是先輸出混淆矩陣再逐個(gè)看壞例最后再?zèng)Q定是加數(shù)據(jù)、調(diào)結(jié)構(gòu)還是換模型。下面的代碼輸出混淆矩陣和部分預(yù)測(cè)錯(cuò)誤的樣本from sklearn.metrics import confusion_matrix, classification_report y_pred (model.predict(X_val) 0.5).astype(int) print(classification_report(y_val, y_pred)) # 輸出錯(cuò)誤樣本 errors np.where(y_pred.flatten() ! y_val.flatten())[0] for i in errors[:10]: sent id2sentence(X_val[i], word2idx) # 從 id 還原回文本 print(flabel{y_val[i]}, pred{y_pred[i][0]}, text{sent})壞例分析有幾個(gè)高頻方向如果錯(cuò)例集中在同一類說明特征不夠需要補(bǔ)充該類別的訓(xùn)練數(shù)據(jù)如果錯(cuò)例都是相似句式但語義相反比如“不是不好”和“不好”說明模型沒學(xué)到否定結(jié)構(gòu)這時(shí)可以考慮把 2-gram 之外的 3-gram、4-gram 卷積核加寬或者干脆把句子里的否定詞和程度副詞標(biāo)出來做特征拼接。還有一個(gè)習(xí)慣值得一提不要只盯著準(zhǔn)確率在樣本不均衡時(shí)用 PR 曲線的面積和 F1 做調(diào)參指標(biāo)會(huì)比準(zhǔn)確率更早暴露問題。調(diào)參順序上我建議先動(dòng) Embedding 層的trainable開關(guān)、濾波器數(shù)量和卷積核寬度再動(dòng) dropout 和 batch size。學(xué)習(xí)率用 Adam 默認(rèn)的 0.001 起步不要一上來就動(dòng)它。如果發(fā)現(xiàn)驗(yàn)證集準(zhǔn)確率在 20 個(gè) epoch 后仍然爬升很慢先檢查是不是詞向量本身質(zhì)量差這一步可以用model.wv.similarity(好評(píng), 滿意)這類內(nèi)積去驗(yàn)證。從做這個(gè)項(xiàng)目開始我養(yǎng)成了每改一次參數(shù)就單獨(dú)存一份實(shí)驗(yàn)記錄的習(xí)慣包含詞向量參數(shù)、CNN 結(jié)構(gòu)、訓(xùn)練超參數(shù)、驗(yàn)證集 F1以及壞例截圖。這樣每次復(fù)盤都有據(jù)可查不會(huì)陷入“感覺自己加了層但是忘了改了什么參數(shù)”的狀態(tài)?,F(xiàn)在你手里的這份流程從我第一次跑通到現(xiàn)在過了兩年仍然是我做短文本分類的默認(rèn)起點(diǎn)。希望幫到你落地過程遇到問題可以順著這篇筆記里的細(xì)節(jié)倒查大多數(shù)坑都寫在上面的避坑章節(jié)里了。本文還有配套的精品資源點(diǎn)擊獲取