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