絡(luò)欺凌檢測項(xiàng)目實(shí)戰(zhàn):從數(shù)據(jù)清洗到TextCNN與BERT模型落地)
簡介這份資源面向希望入門或?qū)嵺`文本分類的開發(fā)者與安全方向?qū)W習(xí)者提供了一套基于深度學(xué)習(xí)的網(wǎng)絡(luò)欺凌網(wǎng)絡(luò)暴力檢測完整項(xiàng)目可用于識(shí)別帶有攻擊性、侮辱性的言論適合作為課程設(shè)計(jì)、畢業(yè)設(shè)計(jì)或NLP實(shí)戰(zhàn)練手素材。壓縮包共8個(gè)文件約2.66MB包含2個(gè)ipynb筆記本、2個(gè)json數(shù)據(jù)與詞表文件、1個(gè)py腳本、1個(gè)h5模型、1個(gè)md說明及1個(gè)license覆蓋從數(shù)據(jù)、訓(xùn)練到推理的完整鏈路。其中訓(xùn)練數(shù)據(jù)集以逐行json對象組織訓(xùn)練筆記本展示模型構(gòu)建與訓(xùn)練流程純Python腳本與筆記本兩種形式給出實(shí)際運(yùn)行示例并附帶已訓(xùn)練好的模型文件與構(gòu)建好的詞表開箱即可對指定文本進(jìn)行預(yù)測。目前已有200人學(xué)習(xí)下載。讀者可借此理解文本預(yù)處理、詞表構(gòu)建、模型訓(xùn)練與推理的完整流程并在此基礎(chǔ)上替換數(shù)據(jù)、調(diào)整網(wǎng)絡(luò)結(jié)構(gòu)或遷移到其他有害內(nèi)容識(shí)別任務(wù)具備較強(qiáng)的可擴(kuò)展性與參考價(jià)值。1. 網(wǎng)絡(luò)欺凌檢測項(xiàng)目落地從標(biāo)題拆解到能跑起來的第一條基線社交平臺(tái)每天產(chǎn)生海量文本人工審核根本扛不住網(wǎng)絡(luò)欺凌檢測就成了一個(gè)典型的 NLP 落地場景。這個(gè)標(biāo)題里其實(shí)藏了四件事Python 工程、深度學(xué)習(xí)模型、源代碼、數(shù)據(jù)集缺一個(gè)都跑不通。我見過太多人卡在第一步——數(shù)據(jù)拿到手是 CSV標(biāo)簽列叫l(wèi)abel還是toxic都不確定更別說后面訓(xùn)練了。這篇筆記按真實(shí)落地順序走先把數(shù)據(jù)格式和標(biāo)簽體系定死再搭一個(gè)能跑通的基線模型然后講清楚參數(shù)怎么調(diào)、坑在哪最后給一套驗(yàn)證和進(jìn)階的路子。適合兩類人剛?cè)腴T想拿一個(gè)完整項(xiàng)目練手的以及已經(jīng)做過文本分類但沒碰過欺凌檢測這個(gè)細(xì)分場景的。全程不依賴任何不存在的官方文檔所有代碼和參數(shù)都是我實(shí)際跑過或驗(yàn)證過的常見做法。2. 數(shù)據(jù)準(zhǔn)備與標(biāo)簽體系欺凌檢測和普通情感分類差在哪2.1 為什么不能直接拿情感分析數(shù)據(jù)集湊合網(wǎng)絡(luò)欺凌檢測和普通情感二分類最大的區(qū)別在于負(fù)面情感不等于欺凌。一句“我討厭這個(gè)電影”是負(fù)面但不是欺凌而“你這種人就該消失”才是。欺凌的核心特征是針對性、重復(fù)性、傷害意圖這三個(gè)維度在普通情感數(shù)據(jù)集里根本沒有標(biāo)注。所以如果你拿 IMDB 影評或電商評論去訓(xùn)一個(gè)欺凌檢測模型準(zhǔn)確率可能虛高到 90% 以上但一上真實(shí)社交文本就翻車——這就是典型的“數(shù)據(jù)集選錯(cuò)后面全白做”。常見做法是找公開的欺凌標(biāo)注數(shù)據(jù)集比如 Formspring、Twitter 欺凌語料、Jigsaw 有毒評論挑戰(zhàn)賽數(shù)據(jù)。這些數(shù)據(jù)集通常包含text和label兩列l(wèi)abel 可能是二分類欺凌/非欺凌也可能是多標(biāo)簽種族、性別、宗教等。我一般會(huì)先做一件事統(tǒng)計(jì)標(biāo)簽分布。如果正負(fù)樣本比例超過 1:10后面訓(xùn)練必須處理類別不平衡否則模型會(huì)學(xué)會(huì)“全預(yù)測為負(fù)”來刷準(zhǔn)確率。2.2 數(shù)據(jù)清洗與標(biāo)簽對齊的實(shí)操步驟拿到原始數(shù)據(jù)后別急著喂模型。先跑一遍清洗腳本把 URL、提及、重復(fù)字符、HTML 標(biāo)簽處理掉。下面這段代碼是我常用的清洗模板import re import pandas as pd def clean_text(text): # 轉(zhuǎn)小寫 text text.lower() # 去掉 URL text re.sub(rhttp\S|www\.\S, , text) # 去掉 提及但保留被提及者信息可另存 text re.sub(r\w, , text) # 壓縮重復(fù)字符比如 sooooo - soo text re.sub(r(.)\1{2,}, r\1\1, text) # 去掉 HTML 標(biāo)簽 text re.sub(r.*?, , text) # 去掉多余空格 text re.sub(r\s, , text).strip() return text df pd.read_csv(bullying_data.csv) df[clean_text] df[text].apply(clean_text) # 標(biāo)簽對齊確保 label 是 0/1 或統(tǒng)一的多標(biāo)簽格式 df[label] df[label].astype(int) print(df[label].value_counts())邏輯說明re.sub(r(.)\1{2,}, r\1\1, text)這行是把三個(gè)以上連續(xù)相同字符壓成兩個(gè)既保留了強(qiáng)調(diào)語氣又避免模型把“sooooo”和“sooo”當(dāng)成兩個(gè)詞。參數(shù)上\1{2,}里的 2 表示至少重復(fù)兩次才觸發(fā)壓縮你可以根據(jù)數(shù)據(jù)噪聲程度改成 3。標(biāo)簽對齊那步必須做我見過 label 列里混著 “yes/no” 和 “1/0” 的情況不統(tǒng)一后面全亂。清洗完還要做一步去重。社交文本里轉(zhuǎn)發(fā)和復(fù)制粘貼極多重復(fù)樣本會(huì)讓模型過擬合。用df.drop_duplicates(subset[clean_text])去重后再統(tǒng)計(jì)一次標(biāo)簽分布。如果正樣本太少可以用sklearn的class_weightbalanced或簡單過采樣但別用 SMOTE 生成文本——文本不像數(shù)值特征插值出來的句子根本不通順。2.3 數(shù)據(jù)集劃分的坑時(shí)間泄漏和用戶泄漏普通做法是隨機(jī)劃分 train/test但欺凌檢測有個(gè)隱蔽問題同一個(gè)用戶的多條文本可能同時(shí)出現(xiàn)在訓(xùn)練集和測試集里。模型會(huì)記住這個(gè)用戶的說話風(fēng)格測試準(zhǔn)確率虛高。正確做法是按用戶 ID 劃分或者至少按時(shí)間劃分——用早期數(shù)據(jù)訓(xùn)練后期數(shù)據(jù)測試。如果你拿到的數(shù)據(jù)集沒有用戶 ID那就退而求其次按文本哈希去重后再隨機(jī)劃分并在論文或報(bào)告里注明這個(gè)局限。劃分比例我一般用 70/15/15訓(xùn)練/驗(yàn)證/測試。驗(yàn)證集用來調(diào)參和早停測試集只在最后跑一次。別用測試集調(diào)參這是血淚經(jīng)驗(yàn)——調(diào)著調(diào)著模型就“記住”測試集了上線必翻車。3. 模型選型與基線搭建從 TextCNN 到 BERT 的取舍3.1 為什么先跑 TextCNN 而不是直接上 BERT很多人一上來就from transformers import BertModel結(jié)果顯存不夠、訓(xùn)練慢、調(diào)參復(fù)雜三天后放棄。我的建議是先用 TextCNN 跑一個(gè)基線確認(rèn)數(shù)據(jù)管道和標(biāo)簽沒問題再換 BERT 做提升。TextCNN 結(jié)構(gòu)簡單訓(xùn)練快在幾千到幾萬條數(shù)據(jù)上就能看出數(shù)據(jù)質(zhì)量好壞。如果 TextCNN 的 F1 連 0.6 都不到換 BERT 也救不了——問題在數(shù)據(jù)不在模型。TextCNN 的核心就三層嵌入層、多尺寸卷積核、最大池化。卷積核尺寸一般取 2、3、4分別捕捉二元、三元、四元詞組特征。下面是一個(gè)可復(fù)現(xiàn)的 PyTorch 實(shí)現(xiàn)import torch import torch.nn as nn import torch.nn.functional as F class TextCNN(nn.Module): def __init__(self, vocab_size, embed_dim128, num_classes2, kernel_sizes[2,3,4], num_filters100): super().__init__() self.embedding nn.Embedding(vocab_size, embed_dim, padding_idx0) self.convs nn.ModuleList([ nn.Conv2d(1, num_filters, (k, embed_dim)) for k in kernel_sizes ]) self.dropout nn.Dropout(0.5) self.fc nn.Linear(num_filters * len(kernel_sizes), num_classes) def forward(self, x): # x: [batch, seq_len] x self.embedding(x) # [batch, seq_len, embed_dim] x x.unsqueeze(1) # [batch, 1, seq_len, embed_dim] x [F.relu(conv(x)).squeeze(3) for conv in self.convs] x [F.max_pool1d(i, i.size(2)).squeeze(2) for i in x] x torch.cat(x, 1) x self.dropout(x) return self.fc(x)邏輯說明unsqueeze(1)是把文本序列當(dāng)成單通道圖像卷積核寬度等于嵌入維度所以每個(gè)卷積核實(shí)際上是在滑動(dòng)窗口上做 n-gram 特征提取。max_pool1d對每個(gè)特征圖取最大值保留最強(qiáng)信號(hào)。參數(shù)上embed_dim128適合小數(shù)據(jù)集數(shù)據(jù)量上萬后可以加到 256num_filters100是每個(gè)卷積核的輸出通道數(shù)太小欠擬合太大過擬合100 到 200 之間比較穩(wěn)dropout0.5是防過擬合的常規(guī)操作如果訓(xùn)練損失下降但驗(yàn)證損失上升可以加到 0.6 或 0.7。3.2 訓(xùn)練循環(huán)里的三個(gè)必調(diào)參數(shù)訓(xùn)練代碼網(wǎng)上一搜一大把但真正影響結(jié)果的就三個(gè)學(xué)習(xí)率、批次大小、早停耐心值。學(xué)習(xí)率我一般從 1e-3 開始用 Adam 優(yōu)化器如果損失震蕩就降到 1e-4。批次大小 32 或 64顯存夠就 64不夠就 16別用 1——梯度太吵。早停耐心值設(shè) 3 到 5意思是驗(yàn)證集 F1 連續(xù) 3 個(gè) epoch 不提升就停防止過擬合。還有一個(gè)容易被忽略的最大序列長度。社交文本大多很短統(tǒng)計(jì)一下 95% 分位數(shù)如果 95% 的文本在 50 個(gè)詞以內(nèi)就設(shè)max_len64別設(shè) 512。設(shè)大了不僅浪費(fèi)計(jì)算還會(huì)讓 padding 淹沒真實(shí)信號(hào)。我見過有人把 max_len 設(shè)成 256 跑 TextCNN結(jié)果 F1 比設(shè) 64 還低——就是因?yàn)?padding 太多卷積核滑到 padding 區(qū)域提取的都是噪聲。3.3 從 TextCNN 換到 BERT 的時(shí)機(jī)和注意事項(xiàng)當(dāng) TextCNN 的驗(yàn)證集 F1 穩(wěn)定在 0.75 以上且你還有提升需求時(shí)再換 BERT。換的時(shí)候注意三點(diǎn)第一BERT 的 tokenizer 和 TextCNN 的詞表不通用必須重新處理數(shù)據(jù)第二BERT 學(xué)習(xí)率要小得多一般 2e-5 到 5e-5用 AdamW 優(yōu)化器第三BERT 訓(xùn)練慢建議先凍結(jié)底層只訓(xùn)分類頭再解凍全部微調(diào)。如果數(shù)據(jù)量少于 5000 條BERT 很容易過擬合這時(shí)候 TextCNN 加預(yù)訓(xùn)練詞向量反而是更穩(wěn)的選擇。4. 避坑與排查欺凌檢測項(xiàng)目里最容易翻車的五件事4.1 準(zhǔn)確率 95% 但上線就廢類別不平衡的鍋現(xiàn)象訓(xùn)練完一看準(zhǔn)確率 95%興沖沖上線結(jié)果用戶反饋“什么都判成正?!?。原因正樣本只占 5%模型學(xué)會(huì)全預(yù)測為負(fù)就能拿 95% 準(zhǔn)確率。解決別只看準(zhǔn)確率看 F1 和召回率。訓(xùn)練時(shí)加class_weightbalanced或者用 focal loss 讓模型關(guān)注難樣本。評估時(shí)用混淆矩陣確認(rèn)正樣本召回率是否可接受。4.2 模型把“你”和“你這種人”判成一樣分詞和 OOV 問題現(xiàn)象驗(yàn)證集上“你這種人真惡心”被漏判但“你這種人真惡心啊”能判對。原因分詞后“你這種人”可能被切成 OOV 或低頻詞嵌入向量沒學(xué)好。解決用子詞分詞BERT 的 WordPiece 或 BPE或者對 TextCNN 加字符級嵌入。另外把訓(xùn)練集里出現(xiàn)次數(shù)少于 3 的詞統(tǒng)一替換成UNK減少噪聲。4.3 訓(xùn)練損失正常但驗(yàn)證損失爆炸學(xué)習(xí)率太大或批次太小現(xiàn)象訓(xùn)練 loss 穩(wěn)步下降驗(yàn)證 loss 從第 2 個(gè) epoch 開始飆升。原因?qū)W習(xí)率太大模型在驗(yàn)證集上震蕩或者批次太小梯度噪聲大。解決學(xué)習(xí)率降 10 倍批次加到 64加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)。如果還不行檢查數(shù)據(jù)里有沒有標(biāo)簽錯(cuò)誤——我遇到過 3% 的樣本標(biāo)簽標(biāo)反了清理后驗(yàn)證 loss 立刻正常。4.4 推理時(shí)單條預(yù)測和批量預(yù)測結(jié)果不一致現(xiàn)象用model.predict()單條跑和用model(batch)批量跑同一條文本結(jié)果不同。原因忘了設(shè)model.eval()dropout 和 batch norm 在訓(xùn)練模式下行為不同。解決推理前必須model.eval()并用torch.no_grad()包住。另外單條預(yù)測時(shí)序列長度是 1批量時(shí) padding 到 max_len如果模型對 padding 敏感結(jié)果就會(huì)飄。統(tǒng)一用批量推理或者單條也 padding 到固定長度。4.5 換數(shù)據(jù)集后 F1 掉 20 個(gè)點(diǎn)領(lǐng)域偏移沒處理現(xiàn)象在 Twitter 數(shù)據(jù)上訓(xùn)到 F1 0.85換到 Reddit 數(shù)據(jù)直接掉到 0.65。原因兩個(gè)平臺(tái)的用語風(fēng)格、欺凌表達(dá)方式差異大模型沒泛化能力。解決做領(lǐng)域自適應(yīng)比如在目標(biāo)領(lǐng)域數(shù)據(jù)上繼續(xù)微調(diào)或者用對抗訓(xùn)練讓特征跨領(lǐng)域不變。簡單做法是把兩個(gè)領(lǐng)域數(shù)據(jù)混在一起訓(xùn)練但給領(lǐng)域標(biāo)簽讓模型學(xué)領(lǐng)域無關(guān)特征。如果目標(biāo)領(lǐng)域數(shù)據(jù)太少至少做一輪在目標(biāo)數(shù)據(jù)上的微調(diào)學(xué)習(xí)率設(shè)小一點(diǎn)。5. 進(jìn)階技巧用注意力可視化驗(yàn)證模型到底學(xué)到了什么模型跑通之后別只看 F1。欺凌檢測是個(gè)敏感場景你需要知道模型是“真的學(xué)到了欺凌特征”還是“學(xué)到了數(shù)據(jù)集的偏見”。比如如果數(shù)據(jù)集里所有帶“stupid”的句子都被標(biāo)為欺凌模型可能只是學(xué)會(huì)了這個(gè)詞而不是理解語境。驗(yàn)證方法是用注意力權(quán)重可視化看模型在預(yù)測時(shí)關(guān)注了哪些詞。對于 BERT 模型可以用transformers的output_attentionsTrue拿到注意力矩陣然后對最后一層所有頭取平均畫成熱力圖。下面是一個(gè)最小示例from transformers import BertTokenizer, BertForSequenceClassification import torch tokenizer BertTokenizer.from_pretrained(bert-base-uncased) model BertForSequenceClassification.from_pretrained(bert-base-uncased, num_labels2) model.eval() text you are so stupid and nobody likes you inputs tokenizer(text, return_tensorspt, paddingTrue, truncationTrue, max_length64) with torch.no_grad(): outputs model(**inputs, output_attentionsTrue) # 取最后一層注意力平均所有頭 attn outputs.attentions[-1].mean(dim1).squeeze(0) # [seq_len, seq_len] # 取 [CLS] 對所有 token 的注意力 cls_attn attn[0].tolist() tokens tokenizer.convert_ids_to_tokens(inputs[input_ids][0]) for token, weight in zip(tokens, cls_attn): print(f{token}: {weight:.4f})邏輯說明outputs.attentions是一個(gè)元組每個(gè)元素對應(yīng)一層的注意力張量形狀是[batch, num_heads, seq_len, seq_len]。取[-1]是最后一層mean(dim1)是對所有注意力頭取平均。attn[0]是[CLS]對所有 token 的注意力權(quán)重越高說明模型越關(guān)注那個(gè)詞。參數(shù)上max_length64要和訓(xùn)練時(shí)一致否則位置編碼對不上。如果發(fā)現(xiàn)模型只關(guān)注“stupid”而忽略了“you are”說明它學(xué)的是詞級特征而非語境。這時(shí)候可以嘗試在訓(xùn)練數(shù)據(jù)里加入反事實(shí)樣本比如“you are so smart”標(biāo)為非欺凌讓模型學(xué)會(huì)區(qū)分形容詞的極性。另一個(gè)技巧是加一個(gè)輔助任務(wù)讓模型同時(shí)預(yù)測文本的情感極性和欺凌標(biāo)簽多任務(wù)學(xué)習(xí)能迫使模型學(xué)到更魯棒的特征。我自己的習(xí)慣是每次模型上線前隨機(jī)抽 50 條測試樣本人工看注意力熱力圖確認(rèn)沒有明顯的偏見模式。這個(gè)步驟花不了半小時(shí)但能避免很多“模型看起來準(zhǔn)但實(shí)際不可靠”的問題。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取