實戰(zhàn):酒店評論情感分類完整流程與代碼實現(xiàn))
做文本分類這幾年我從最原始的詞典匹配一路換到word2vec、textCNN再到BERT每一步升級感受都很明顯但最讓我覺得“省心”的還是BERT。省心不是因為它精度一定最高而是它幾乎把特征工程這件事從流程里徹底拿掉了。我這次拿一個酒店評論情感分類項目來完整拆解代碼從選型、數(shù)據(jù)預(yù)處理、模型加載到訓練調(diào)參一條線全講清楚。標題雖然寫的是“分析”但我會把落腳點放在“能跑的代碼”上。適合誰看兩類人一類是剛?cè)腴TNLP、想搞懂BERT在分類任務(wù)上到底怎么用的小白另一類是以前用詞向量或CNN做分類、想整體遷移到BERT流派的工程師。這兩類人讀這篇文章應(yīng)該都不虧。酒店評論這個場景選得比較典型因為文本短、口語化重、情緒詞密度高而且正負面表達非常直觀。這類數(shù)據(jù)用傳統(tǒng)方法其實也能做但BERT能把一些“繞彎子”的評論也抓出來比如“房間還行就是空調(diào)太吵了”這種轉(zhuǎn)折結(jié)構(gòu)詞袋模型基本沒戲BERT就能拿捏。下面我把整個項目從零拆開。1. 為什么是BERT酒店評論情感分類的選型邏輯1.1 傳統(tǒng)文本分類的痛點出在“語義理解”上在BERT之前做情感分類最常見的套路是把句子切詞、去掉停用詞、用word2vec或fastText把詞向量化然后送進textCNN或TextRNN。這條路最大的問題在于詞向量是靜態(tài)的。每個詞只有一個固定的向量表示多義詞沒法根據(jù)上下文切換含義。比如說酒店評論里“干凈”這個詞正面語境叫“環(huán)境很干凈”負面語境叫“服務(wù)員態(tài)度不干凈利落”靜態(tài)向量的效果就大打折扣。更有意思的問題是轉(zhuǎn)折和否定。像“沒有想象中那么差”這句話如果只看詞的出現(xiàn)順序和頻率模型很容易被“差”這個字帶走分類成負面。人的理解是“差”被前面的“沒有”和“那么”修正了整體其實是偏正面的。這個層次的語義理解詞袋模型和CNN都是做不到的因為它們的感受野要么是詞級別的孤島要么只有幾格的局部窗口。1.2 BERT帶來了什么改變BERT的核心是Transformer的雙向編碼器它預(yù)訓練階段用兩個任務(wù)來學習語言掩碼語言模型和下一句預(yù)測。簡單理解就是模型在訓練時見過海量文本學會了“一個詞在什么語境下大概是什么意思”所以用它來做下游任務(wù)時我們只需要給它一個分類頭再做簡單微調(diào)就行。對于酒店評論這種短文本BERT有幾個天然優(yōu)勢。第一動態(tài)詞向量讓“干凈”“差”“好”這樣的詞在不同語境下?lián)碛胁煌南蛄勘硎?。第二Transformer的注意力機制能捕捉長距離依賴轉(zhuǎn)折結(jié)構(gòu)、否定結(jié)構(gòu)里面的修飾關(guān)系都能被建模到。第三遷移學習讓數(shù)據(jù)需求量大幅下降。傳統(tǒng)方法做個像樣的分類器可能得幾萬甚至幾十萬條標注數(shù)據(jù)而BERT哪怕只有幾千條標注也能跑出不錯的baseline這在很多小項目里非常實際。這個項目的任務(wù)定義很明確輸入一段酒店評論文本輸出正面或負面標簽。如果你數(shù)據(jù)里還有中性做三分類也完全沒問題框架上幾乎不用改。2. 數(shù)據(jù)準備與預(yù)處理決定項目成敗的第一步2.1 數(shù)據(jù)探索與標簽梳理動手之前先看數(shù)據(jù)這一步很多人會跳過我建議別跳。酒店評論數(shù)據(jù)通常長什么樣大概是“位置很好離地鐵站步行五分鐘”“早餐種類太少了性價比一般”這類口語化句子??赡苓€夾雜著表情符號、網(wǎng)址、繁體字甚至亂碼。先把數(shù)據(jù)讀進來看看分布import pandas as pd df pd.read_csv(hotel_reviews.csv) print(df.head()) print(df[label].value_counts())這個項目我用的是二分類數(shù)據(jù)標簽為1表示正面0表示負面。如果你的數(shù)據(jù)里有0、1、2三分類后面只需要把分類頭的輸出維度改成3。標簽分布這里要特別留意如果正負樣本比例嚴重失衡比如正面占了90%那么后續(xù)訓練和評估都要做相應(yīng)調(diào)整比如用加權(quán)損失函數(shù)或者用F1分數(shù)替代準確率。有一個我在實踐中反復踩過的坑很多公開數(shù)據(jù)集里的標簽并不是均衡的訓練時模型可能把“全部預(yù)測為多數(shù)類”當一個捷徑學到最后準確率看著有80%多實際上什么都沒學會。所以先統(tǒng)計比例再決定后續(xù)策略。2.2 清洗與極簡預(yù)處理預(yù)處理的度很難把握。BERT有自己的分詞器它對中文是按照字級別處理的所以不需要再用jieba去切詞。這一點很多人容易慣性操作上來就分詞結(jié)果反而破壞了BERT對中文文本的原始理解。我的清洗方案非常簡單做三件事import re def clean_text(text): # 去掉網(wǎng)址 text re.sub(rhttp\S, , text) # 去掉多余空白符 text re.sub(r\s, , text) # 去掉無意義的特殊符號但保留中英文、數(shù)字和常見標點 text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9。、,.!?;:], , text) return text df[clean_comment] df[comment].apply(clean_text)注意一個細節(jié)表情符號。一句話如果出現(xiàn)“哈哈哈哈哈”這種文本模型能識別出開心但如果出現(xiàn)這類emojiBERT的詞典里通常沒有對應(yīng)的token。我是直接去掉的因為對情感分類任務(wù)emoji本身攜帶的情感信息在中文評價文本里占比不大去掉不會影響整體效果但能讓輸入更干凈。還有一點去停用詞這個操作在BERT這里我不建議做。BERT的分詞器是把文本切成一連串token它預(yù)訓練時看到的就是完整的句子你把“的”“了”“很”這些詞刪掉反而破壞了句子的語法結(jié)構(gòu)模型原本能捕捉的語義信息就丟了。2.3 數(shù)據(jù)集的劃分策略劃分方式直接影響后續(xù)模型評估的可信度。我習慣用分層采樣保證訓練集、驗證集和測試集里的正負比例和原始數(shù)據(jù)一致。from sklearn.model_selection import train_test_split train_df, temp_df train_test_split( df, test_size0.2, stratifydf[label], random_state42 ) val_df, test_df train_test_split( temp_df, test_size0.5, stratifytemp_df[label], random_state42 ) print(len(train_df), len(val_df), len(test_df))這里有個常被忽略的點驗證集和測試集要分開不要只用一份數(shù)據(jù)既調(diào)參又報最終指標。驗證集是給你在訓練過程中選模型的測試集是最后統(tǒng)一評估泛化能力用的混在一起會讓最終結(jié)果偏樂觀上線后被真實數(shù)據(jù)打臉。3. 核心代碼加載BERT與構(gòu)建分類模型3.1 環(huán)境依賴與版本匹配先把環(huán)境收拾利索。這個項目用到的核心庫是transformers和torch。版本匹配很關(guān)鍵不同版本的transformers接口有差異我在一臺新機器上裝環(huán)境時經(jīng)常被版本坑。pip install torch2.0.1 transformers4.28.1 pandas scikit-learn如果你的機器有顯卡還需要確認CUDA版本和torch是匹配的。我建議安裝完先跑一個簡單的檢查import torch print(torch.__version__) print(torch.cuda.is_available())如果cuda.is_available()返回False后面訓練全部走CPU速度會慢到讓人懷疑人生?,F(xiàn)在很多云環(huán)境會有坑比如torch默認裝的CPU版本重新安裝GPU版本就好。3.2 Tokenizer的用法與常見誤區(qū)BERT模型的文本輸入不能是字符串必須經(jīng)過分詞轉(zhuǎn)換成輸入ID。tokenizer是這一流程的核心工具。中文場景首選的預(yù)訓練模型是bert-base-chinese這個模型在中文語料上預(yù)訓練過詞典覆蓋和分詞粒度都貼合中文文本。from transformers import BertTokenizer tokenizer BertTokenizer.from_pretrained(bert-base-chinese) def encode_texts(texts, max_length128): return tokenizer( texts, paddingTrue, truncationTrue, max_lengthmax_length, return_tensorspt )這里解釋一下幾個參數(shù)。paddingTrue的作用是把同一個批次里的文本pad到相同長度。因為PyTorch的DataLoader要求一個batch里的張量形狀一致所以短句子就得補到最長句子的長度。truncationTrue是當句子超過max_length時直接截斷。return_tensorspt表示返回PyTorch的Tensor格式。max_length的選擇我習慣先用數(shù)據(jù)分布說話lengths [ len(tokenizer.encode(text, add_special_tokensFalse)) for text in train_df[clean_comment] ] print(pd.Series(lengths).describe())看一下句長分布如果80%的樣本都在100以內(nèi)max_length設(shè)128基本夠用。太長的文本截斷后信息會丟太短則浪費計算資源。這個參數(shù)在推理階段也要保持一致否則訓練和預(yù)測的輸入分布不一致效果會有小幅下降。3.3 自定義Dataset與DataLoadertokenizer拿到了編碼好的輸入接下來要封裝成PyTorch可以迭代的數(shù)據(jù)集。這里有一個極易出錯的地方BERT的輸入除了input_ids通常還需要attention_mask。這個mask的作用是告訴模型哪些位置是真實文本、哪些位置是padding注意力機制計算時會忽略padding部分。我自己寫Dataset是這樣的from torch.utils.data import Dataset class ReviewDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_length128): self.texts texts self.labels labels self.tokenizer tokenizer self.max_length max_length def __len__(self): return len(self.texts) def __getitem__(self, idx): text self.texts[idx] label self.labels[idx] encoded self.tokenizer( text, paddingmax_length, truncationTrue, max_lengthself.max_length, return_tensorspt ) return { input_ids: encoded[input_ids].squeeze(0), attention_mask: encoded[attention_mask].squeeze(0), label: torch.tensor(label, dtypetorch.long) }paddingmax_length在這里和前面batch-level padding有一個區(qū)別每個樣本都精確pad到max_length。這樣做的優(yōu)點是DataLoader不用再動態(tài)做padding簡單直接缺點是會浪費一點計算資源。對小項目來說這個浪費完全可接受代碼卻簡單了很多。從工程角度看用清晰換取一點性能非常劃算。然后構(gòu)建DataLoaderfrom torch.utils.data import DataLoader train_dataset ReviewDataset( train_df[clean_comment].tolist(), train_df[label].tolist(), tokenizer ) train_loader DataLoader(train_dataset, batch_size16, shuffleTrue)4. 訓練環(huán)節(jié)參數(shù)設(shè)置與完整訓練代碼4.1 學習率、輪數(shù)與幾個關(guān)鍵參數(shù)的取舍訓練BERT和訓練普通神經(jīng)網(wǎng)絡(luò)很不一樣。預(yù)訓練模型的參數(shù)已經(jīng)有了很好的語義先驗微調(diào)時如果學習率太大會覆蓋掉預(yù)訓練學到的東西業(yè)界稱這個現(xiàn)象為“災(zāi)難性遺忘”。如果學習率太小訓練又會陷入長時間停滯。我實測下來2e-5是個穩(wěn)妥的起點1e-5更保守5e-5通常偏激進容易在驗證集上看到明顯的震蕩。訓練輪數(shù)方面小數(shù)據(jù)集上2到3個epoch就夠了。酒店評論如果只有幾千條跑3個epoch基本收斂。再多輪數(shù)不會帶來明顯提升反而可能過擬合——訓練集loss持續(xù)下降但驗證集loss開始反彈。另外還有一個warmup的概念它的作用是讓模型在訓練開頭的幾步里用很小的學習率熱身然后逐漸增加到設(shè)定值最后再衰減。這個機制能有效避免訓練初期的大幅度更新破壞預(yù)訓練權(quán)重。from transformers import BertForSequenceClassification from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup model BertForSequenceClassification.from_pretrained( bert-base-chinese, num_labels2 ) optimizer AdamW(model.parameters(), lr2e-5) total_steps len(train_loader) * epochs scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps )我在選擇優(yōu)化器時用過普通的SGD效果不好BERT微調(diào)圈里現(xiàn)在基本都用AdamW。它是Adam的修正版把權(quán)重衰減和梯度更新解耦了更適合預(yù)訓練模型的微調(diào)場景。如果你想深入理解可以去看原論文但工程上直接用就是正確選擇。4.2 訓練循環(huán)代碼解析訓練的核心就一個循環(huán)但里面的細節(jié)值得逐行看。device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device)模型和數(shù)據(jù)都要搬到GPU上。忘了做這一步是新手最常出的問題經(jīng)常會看到報錯說tensor不在同一設(shè)備上。然后是訓練循環(huán)主體from tqdm import tqdm model.train() for epoch in range(epochs): total_loss 0 progress_bar tqdm(train_loader, descfEpoch {epoch1}) for batch in progress_bar: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[label].to(device) outputs model( input_idsinput_ids, attention_maskattention_mask, labelslabels ) loss outputs.loss loss.backward() # 梯度裁剪防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() progress_bar.set_postfix({loss: f{loss.item():.4f}}) avg_loss total_loss / len(train_loader) print(fEpoch {epoch1} average loss: {avg_loss:.4f})細看幾個操作。model(input_ids, attention_mask, labels)這段代碼中l(wèi)abels傳入后transformers會自動計算交叉熵損失。很多人會疑惑為什么不自己寫損失函數(shù)其實這是BERT分類模型的接口設(shè)計傳入labels就自動返回loss省去自己寫forward計算的一堆代碼。clip_grad_norm_是梯度裁剪把梯度向量的范數(shù)限制在1.0以內(nèi)。BERT這類Transformer模型訓練時偶爾會出現(xiàn)梯度爆炸loss一下變成NaN加了裁剪能大幅降低這個概率。optimizer.step()更新參數(shù)scheduler.step()更新學習率最后zero_grad()把梯度清零。順序一定不能錯特別是zero_grad的位置必須在backward之前已經(jīng)清零我這里放在step之后下個batch的backward會基于清零后的梯度重新計算是常見寫法。有一個小經(jīng)驗把訓練過程的loss打印出來如果loss在第一個epoch里沒有明顯下降趨勢大概率是學習率太小或數(shù)據(jù)預(yù)處理出了問題別急著加大輪數(shù)硬跑先定位原因。5. 驗證與預(yù)測別只盯著準確率5.1 評估指標與混淆矩陣訓練結(jié)束后要在驗證集和測試集上評估模型表現(xiàn)。二分類場景我習慣同時看準確率、精確率、召回率和F1分數(shù)。準確率容易被多數(shù)類主導而精確率和召回率分別回答兩個問題模型預(yù)測為正面時有多少是對的實際為正面時模型找回來多少。寫一個簡單的評估函數(shù)from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, confusion_matrix def evaluate(model, dataloader): model.eval() all_preds [] all_labels [] with torch.no_grad(): for batch in dataloader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[label].to(device) outputs model(input_idsinput_ids, attention_maskattention_mask) logits outputs.logits preds torch.argmax(logits, dim-1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) print(Accuracy:, accuracy_score(all_labels, all_preds)) print(Precision:, precision_score(all_labels, all_preds)) print(Recall:, recall_score(all_labels, all_preds)) print(F1:, f1_score(all_labels, all_preds)) print(Confusion Matrix:) print(confusion_matrix(all_labels, all_preds))評估時需要切換成推理模式就是那句model.eval()。它和with torch.no_grad()共同作用關(guān)閉dropout和梯度計算讓前向推理結(jié)果穩(wěn)定且省顯存。這里有個容易搞錯的點判斷模型是否在train()模式會影響B(tài)ERT內(nèi)部的dropout層如果做預(yù)測前忘了切回eval()同樣的輸入兩次預(yù)測結(jié)果可能不一樣因為dropout隨機丟棄了一部分神經(jīng)元?;煜仃囍档谜J真看看。如果負面樣本被大量誤判成正面說明模型對負面表達的識別偏弱可能需要補充數(shù)據(jù)或調(diào)整類別權(quán)重。反過來如果正負樣本預(yù)測數(shù)量嚴重不對等往往說明模型學會了某種捷徑要回到特征層面排查。5.2 單條評論預(yù)測和模型保存模型評估沒問題保存模型和tokenizer方便后面部署或二次訓練model.save_pretrained(hotel_bert_model) tokenizer.save_pretrained(hotel_bert_model)保存時不建議只保存pytorch_model.bin最好把config.json和tokenizer相關(guān)的文件一起存使用起來才不用重復加載預(yù)訓練權(quán)重。單條評論預(yù)測是一個很常見的工程場景比如線上對用戶新增評論實時打標def predict_single(text): model.eval() cleaned clean_text(text) encoded tokenizer( cleaned, paddingmax_length, truncationTrue, max_length128, return_tensorspt ) input_ids encoded[input_ids].to(device) attention_mask encoded[attention_mask].to(device) with torch.no_grad(): outputs model(input_idsinput_ids, attention_maskattention_mask) logits outputs.logits pred torch.argmax(logits, dim-1).item() prob torch.softmax(logits, dim-1)[0].tolist() return {label: 正面 if pred 1 else 負面, prob: prob} print(predict_single(房間很干凈位置也方便下次還來)) print(predict_single(隔音太差了一晚上沒睡著))softmax概率可以讓預(yù)測結(jié)果具備可解釋性。比如一條評論預(yù)測為正面概率是0.998另一條是0.55前者顯然更有信心。很多場景下閾值不一定要定在0.5可以結(jié)合業(yè)務(wù)調(diào)整比如只對高置信度的負面評論做人工介入。6. 常見問題排查與避坑記錄6.1 顯存不足與訓練速度異常BERT模型參數(shù)量大顯存占用比textCNN高一個量級。訓練時如果batch_size設(shè)得太大很容易爆顯存。最常見的報錯是CUDA out of memory。解決辦法有兩個方向。一是減小batch_size比如從32降到16再不行降到8。二是使用梯度累積邏輯上模擬大batch的效果但顯存消耗不變。做法是每隔幾個step做一次優(yōu)化器更新accumulation_steps 2 for step, batch in enumerate(progress_bar): outputs model(...) loss outputs.loss / accumulation_steps loss.backward() if (step 1) % accumulation_steps 0: optimizer.step() scheduler.step() optimizer.zero_grad()這個技巧在顯存有限但希望用大batch的場合很實用。在代碼里把loss除以累積步數(shù)再把梯度累積到幾個batch后再更新參數(shù)效果接近一次大batch更新。訓練速度太慢的話優(yōu)先檢查是不是沒用到GPU。nvidia-smi看一下顯存占用如果只有幾MB占用大概率數(shù)據(jù)或模型還在CPU上。另一個原因可能是DataLoader的num_workers沒設(shè)置合理在Linux環(huán)境可以設(shè)4或8Windows下需要注意設(shè)置過大容易引發(fā)報錯。6.2 數(shù)據(jù)噪聲和臟數(shù)據(jù)的坑酒店評論數(shù)據(jù)里有一種常見陷阱同一句話被復制了很多次。有些平臺數(shù)據(jù)是爬蟲抓的重復評論很多如果不去重模型會對這些固定表達過擬合影響泛化。跑訓練前最好做一次文本級別去重。另一種情況是文本和標簽不匹配。比如評論內(nèi)容本身很負面但標注成了正面。這種情況在公開數(shù)據(jù)集里并不罕見。如果訓練集中這類噪聲較大模型的表現(xiàn)上限會被直接限制。我的處理辦法是在訓練前抽樣檢查一批數(shù)據(jù)用肉眼掃一遍標簽是否合理雖然花時間但很值得。還有一個小坑是關(guān)于中英文混排和繁體字的。有些評論里夾雜“床墊軟硬適中性價比OK”如果bert的詞典對英文字母處理不夠好最好把單獨成詞的英文去掉或替換掉。繁體字可以用opencc庫轉(zhuǎn)成簡體減少詞典覆蓋不到的概率。6.3 效果不佳時的排查路徑如果你的模型測試集效果比預(yù)期低很多按順序排查。先看預(yù)處理后的文本打印幾條出來肉眼觀察是否合理有沒有被過度清洗導致關(guān)鍵信息丟失。再看訓練loss曲線如果loss已經(jīng)收斂但驗證集不高典型過擬合考慮增加數(shù)據(jù)或減少輪數(shù)。然后檢查max_length如果評論普遍很長而max_length太小信息截斷會直接傷害效果。最后看標簽分布極端不均衡的情況下優(yōu)先考慮加權(quán)重或換評估指標。有一種情況容易被忽略數(shù)據(jù)集的訓練分布和真實場景分布不一致。比如訓練數(shù)據(jù)全部是某一段時間內(nèi)的評論但線上用戶習慣變化了算法也會退化。這是模型上線后的事可以在代碼里預(yù)留定期重訓的機制。7. 幾點實用心得和后續(xù)擴展方向做完這個酒店評論情感分類項目我對BERT微調(diào)這件事有了非常具體的感受。第一個感受是BERT微調(diào)最大的坑不是代碼寫不出來而是參數(shù)設(shè)置和數(shù)據(jù)處理細節(jié)過于隱蔽。同樣的模型、同樣的數(shù)據(jù)學習率差一個量級結(jié)果能差一大截。所以做項目時不要一上來就追求調(diào)參技巧先跑通一個穩(wěn)定流程再逐步優(yōu)化。第二個感受是別迷信指標結(jié)合業(yè)務(wù)看效果。準確率再高如果把最難判定的那部分評論全判錯了業(yè)務(wù)上還是用不起來。我后來在項目里加了概率閾值和人工抽檢機制效果反而比單純追求指標更實用。對情感分類這類強主觀性任務(wù)數(shù)據(jù)標注本身的泛化極限就在那里模型的提升空間需要靠數(shù)據(jù)質(zhì)量來突破。后續(xù)如果還想擴展有幾個方向可以參考。一是從二分類改成多分類加入中性標簽讓模型的粒度更細。二是在分類頭上做文章比如在BERT接入BiLSTM后再接分類層對部分序列特征更豐富的文本會有效果。三是用領(lǐng)域語料做二次預(yù)訓練把酒店評論這種領(lǐng)域的語言風格注入模型再跑下游分類精度能得到進一步改善。四是部署層面的優(yōu)化模型蒸餾、ONNX導出、batch推理這些手段能把推理速度提升數(shù)倍適合真實線上場景。這個項目做完后我又用同樣的代碼結(jié)構(gòu)跑過電商評論、新聞分類和客服工單打標幾個任務(wù)改動基本局限在數(shù)據(jù)清洗和標簽數(shù)量上。BERT微調(diào)這件事熟練了以后可以當成一套標準工具來用。前面提到的這些代碼和細節(jié)你拿自己的數(shù)據(jù)跑一遍遇到的具體問題肯定比我這篇更個性但排查思路和代碼結(jié)構(gòu)是可以直接抄作業(yè)的。