制與圖卷積神經(jīng)網(wǎng)絡(luò)的多任務(wù)謠言檢測(cè)實(shí)踐)
簡(jiǎn)介基于注意力機(jī)制和圖卷積神經(jīng)網(wǎng)絡(luò)的多任務(wù)謠言檢測(cè)源碼與數(shù)據(jù)集面向深度學(xué)習(xí)、自然語言處理方向的學(xué)生及研究者尤其適合作為畢業(yè)設(shè)計(jì)或期末大作業(yè)的參考實(shí)現(xiàn)也可用于相關(guān)課題的基線對(duì)比。這套實(shí)現(xiàn)源自2022屆本科生畢業(yè)論文的后續(xù)優(yōu)化在原始基線模型上改用BERT編碼器獲取詞向量與句向量并接入雙向圖卷積網(wǎng)絡(luò)或自注意力機(jī)制分別完成謠言檢測(cè)與立場(chǎng)分類任務(wù)同時(shí)重構(gòu)了數(shù)據(jù)集存儲(chǔ)方式使目錄更規(guī)整直觀。壓縮包共75個(gè)文件包含Python腳本、JSON數(shù)據(jù)文件、Jupyter Notebook示例、文本說明及Markdown文檔等整體約18.67MB既能直接運(yùn)行訓(xùn)練與評(píng)估也便于二次開發(fā)。目前已有364人學(xué)習(xí)下載對(duì)理解多任務(wù)謠言檢測(cè)的模型搭建與數(shù)據(jù)預(yù)處理具有實(shí)際參考價(jià)值。1. 警惕“詞在傳播模型在睡覺”的謠言檢測(cè)陷阱謠言檢測(cè)跑通不難但跑出能寫進(jìn)論文的數(shù)字是另一回事。這份基于注意力機(jī)制和圖卷積神經(jīng)網(wǎng)絡(luò)的多任務(wù)謠言檢測(cè)源碼包來自一篇2022屆本科畢業(yè)論文的后續(xù)優(yōu)化原論文的baseline跑在master分支主分支換成了BERT編碼器再接BiGCN或Self-Attention同一套數(shù)據(jù)同時(shí)做謠言檢測(cè)和立場(chǎng)分類兩個(gè)任務(wù)。倉庫里Python源碼、PHEME和SemEval2017-Task8數(shù)據(jù)集、訓(xùn)練腳本、可視化notebook都是齊的不用自己湊數(shù)據(jù)。適合兩類人一是在復(fù)現(xiàn)論文、想找一份能直接跑的圖神經(jīng)網(wǎng)絡(luò)基線二是做多任務(wù)學(xué)習(xí)課程設(shè)計(jì)想?yún)⒖颊鎸?shí)訓(xùn)練流程而不是demo玩具。下面幾章按數(shù)據(jù)、模型、訓(xùn)練、部署的順序把MSA-BiGCN這條線完整拆一遍。2. MSA-BiGCN與BERT-BiGCN注意力機(jī)制和圖卷積的兩條實(shí)現(xiàn)路徑打開倉庫根目錄最先注意到的是兩套并行的實(shí)現(xiàn)MSABiGCN.py含ABGCN.py和BertBiGCN.py含BiGCN.py。它們共享utils.py里的數(shù)據(jù)讀取和batch組織邏輯但模型結(jié)構(gòu)差異很大理解這個(gè)差異是后續(xù)改代碼的前提。2.1 BERT作為編碼器詞向量和句向量的來源原論文用的是GloVe或Word2Vec靜態(tài)詞向量主分支改成BERT后輸入從input_idsattention_mask進(jìn)入得到兩個(gè)關(guān)鍵張量每個(gè)token的詞向量序列l(wèi)ast_hidden_state和整句話的句向量pooler_output。在rumorDataset.py里能看到數(shù)據(jù)集的每條記錄已經(jīng)提前用BERT的tokenizer切好max_len默認(rèn)設(shè)為128超過直接截?cái)嗖蛔阌肹PAD]補(bǔ)齊。# utils.py 中關(guān)于BERT輸入的典型處理 def bert_encode(texts, tokenizer, max_len128): return tokenizer( texts, paddingTrue, truncationTrue, max_lengthmax_len, return_tensorspt, )邏輯說明paddingTrue保證一個(gè)batch內(nèi)的樣本長度一致truncationTrue把超長推文截到128。謠言文本大多在20~40個(gè)詞128足夠覆蓋絕大多數(shù)情況如果做的是中文謠言數(shù)據(jù)建議把max_len提到256因?yàn)橹形淖址男畔⒚芏鹊陀谟⑽膯卧~。參數(shù)說明max_len決定GPU顯存占用和訓(xùn)練速度。顯存不夠時(shí)先減batch size不要輕易減max_len因?yàn)榻財(cái)鄷?huì)直接砍掉謠言事件里“轉(zhuǎn)發(fā)鏈末端”的證據(jù)信息。選擇BERT而不是繼續(xù)用GloVe核心原因是謠言檢測(cè)對(duì)上下文語義敏感——同一條推文前面加“據(jù)媒體報(bào)道”和加“我聽說”立場(chǎng)完全相反靜態(tài)向量捕捉不到這種差異。BERT的12層Transformer把每個(gè)詞都放到整句話的上下文里重新編碼詞向量本身已經(jīng)帶了句法角色信息后續(xù)圖卷積做鄰居聚合時(shí)輸入特征質(zhì)量直接決定傳播結(jié)構(gòu)能被利用到什么程度。2.2 圖卷積層BiGCN是怎么建圖的BiGCN的核心思路是把一條謠言事件的所有相關(guān)推文看成節(jié)點(diǎn)節(jié)點(diǎn)之間的三類關(guān)系建邊轉(zhuǎn)發(fā)關(guān)系誰轉(zhuǎn)發(fā)了誰、回復(fù)關(guān)系誰回復(fù)了誰、相似關(guān)系文本語義相似的推文互為鄰居。圖建好后每個(gè)節(jié)點(diǎn)的初始特征就是BERT輸出的句向量然后過兩層GCN每層都做鄰居特征聚合。# BiGCN.py 中圖卷積層的關(guān)鍵片段 class BiGCN(nn.Module): def __init__(self, in_dim, hidden_dim, out_dim, dropout0.5): super(BiGCN, self).__init__() self.conv1 GraphConv(in_dim, hidden_dim) self.conv2 GraphConv(hidden_dim, out_dim) self.dropout nn.Dropout(dropout) def forward(self, x, adj): x self.conv1(x, adj) x F.relu(x) x self.dropout(x) x self.conv2(x, adj) return x邏輯說明第一層conv1把BERT句向量通常是768維投影到隱藏層代碼里hidden_dim默認(rèn)128經(jīng)過ReLU激活和Dropout后第二層conv2再投影到類別數(shù)謠言檢測(cè)2類立場(chǎng)分類3類。adj是從數(shù)據(jù)集構(gòu)建的鄰接矩陣行歸一化在GraphConv內(nèi)部完成。參數(shù)說明dropout0.5是圖網(wǎng)絡(luò)里偏高的設(shè)置因?yàn)橹{言事件圖通常很小一個(gè)事件幾十到幾百個(gè)節(jié)點(diǎn)節(jié)點(diǎn)少意味著容易過擬合Dropout是成本最低的正則手段。如果你的場(chǎng)景是大型社交網(wǎng)絡(luò)圖可以把dropout降到0.3否則信息傳遞被斷掉太多。2.3 注意力機(jī)制加在哪MSA-BiGCN的多頭自注意力位置MSA-BiGCN不是簡(jiǎn)單在GCN后面接一個(gè)注意力層而是把多頭自注意力放在BERT和圖卷積之間。先用BERT得到句向量再過一層TransformerEncoderLayer做序列內(nèi)的自注意力讓模型在進(jìn)入圖結(jié)構(gòu)之前先完成事件內(nèi)部“跨推文”的語義交互。提示如果只聽到“注意力機(jī)制”就默認(rèn)是CBAM或SE那種通道注意力方向就偏了。這里做的是NLP里的Self-Attention作用對(duì)象是推文序列不是圖像特征圖。從MSABiGCN.py的實(shí)現(xiàn)看多頭注意力頭數(shù)num_heads默認(rèn)是8和BERT的默認(rèn)頭數(shù)一致。這樣設(shè)計(jì)的好處是圖卷積只負(fù)責(zé)建模傳播結(jié)構(gòu)注意力只負(fù)責(zé)建模文本交互兩個(gè)模塊的職責(zé)邊界清晰。改代碼時(shí)如果想加大注意力容量?jī)?yōu)先加num_layersTransformer層數(shù)而不是num_heads因?yàn)楹笳叱^16后收益很小顯存卻漲得很快。3. PHEME與SemEval2017從零構(gòu)造Dataloader的數(shù)據(jù)整理思路倉庫里datasets目錄分三塊PHEME謠言檢測(cè)經(jīng)典基準(zhǔn)、PHEME-stance立場(chǎng)標(biāo)注版本、semeval2017-task8SemEval2017的官方任務(wù)數(shù)據(jù)。舊repo把所有信息塞進(jìn)一個(gè)JSON文件維護(hù)起來痛苦這次改成每個(gè)事件一個(gè)文件夾事件下的每條推文一個(gè)單獨(dú)文件標(biāo)注信息用字段名直接可讀。3.1 目錄結(jié)構(gòu)切換從嵌套JSON到扁平化存儲(chǔ)以PHEME為例典型目錄長這樣datasets/PHEME/ charliehebdo/ source-tweet.txt tweet_001/ tweet.json reply_001.json reply_002.json sydneysiege/ source-tweet.txt tweet_002/ tweet.jsonrumorDataset.py里的load_event函數(shù)負(fù)責(zé)遍歷這個(gè)目錄把每條推文的正文、轉(zhuǎn)發(fā)/回復(fù)關(guān)系、標(biāo)注結(jié)果抽出來組裝成后續(xù)訓(xùn)練用的event_list。每個(gè)事件由一個(gè)source_tweet和若干replies組成二者構(gòu)成圖結(jié)構(gòu)中的邊。# rumorDataset.py 中解析PHEME目錄的簡(jiǎn)化邏輯 def parse_pheme_event(event_path): source_tweet load_json(os.path.join(event_path, source-tweet.txt)) replies [] for sub_dir in os.listdir(event_path): if sub_dir.startswith(tweet_): tweet_data load_json(os.path.join(event_path, sub_dir, tweet.json)) replies.append(tweet_data) return {source: source_tweet, replies: replies, label: source_tweet[label]}邏輯說明label字段存在source-tweet的元數(shù)據(jù)里代表這個(gè)事件是謠言還是非謠言replies里的每條推文都有獨(dú)立的in_reply_to字段用來重建傳播樹的父子關(guān)系。參數(shù)說明這里有個(gè)容易被忽略的點(diǎn)——tweet.json里存的是原始推文結(jié)構(gòu)包含user_id、timestamp等噪聲字段。第一次跑通前不建議清理這些字段因?yàn)楹罄m(xù)如果要做用戶特征比如粉絲數(shù)、注冊(cè)時(shí)長還要回到這里取數(shù)據(jù)。3.2 構(gòu)建鄰接矩陣三種圖關(guān)系的取舍在data.py里能看到build_graph函數(shù)它返回的adj矩陣是常規(guī)的numpy.ndarray形狀為[num_nodes, num_nodes]。三種關(guān)系對(duì)應(yīng)三種建邊規(guī)則關(guān)系類型建邊條件鄰接矩陣權(quán)重轉(zhuǎn)發(fā)邊tweet.retweeted_status_id source_id1回復(fù)邊tweet.in_reply_to_status_id target_id1相似邊句向量余弦相似度 0.8相似度值相似邊的加入是BiGCN能超過普通GCN的關(guān)鍵。BERT句向量本身已經(jīng)編碼了語義相似邊把“說同一件事但沒直接互動(dòng)”的節(jié)點(diǎn)連起來相當(dāng)于給圖卷積提供了“軟關(guān)系”通道。PHEME數(shù)據(jù)集平均每個(gè)事件只有約30條推文靠轉(zhuǎn)發(fā)和回復(fù)建出的圖非常稀疏不加相似邊圖卷積退化成只更新孤立節(jié)點(diǎn)等于白跑。3.3 多任務(wù)標(biāo)簽的對(duì)齊問題謠言檢測(cè)的標(biāo)簽是二分類謠言/非謠言立場(chǎng)分類的標(biāo)簽是三分類支持/否定/觀望兩套標(biāo)簽存在同一批數(shù)據(jù)上。trainOnlyStance.py和trainOnlyRumor.py的存在就是為了先單獨(dú)驗(yàn)證每個(gè)任務(wù)能不能收斂再合到一起訓(xùn)練。合訓(xùn)之前要確認(rèn)兩個(gè)任務(wù)的dataloader返回的batch里label_rumor和label_stance都能取到值缺一個(gè)就報(bào)KeyError。注意PHEME-stance和semeval2017-task8的立場(chǎng)標(biāo)注口徑不完全一致前者每一條回復(fù)都有立場(chǎng)標(biāo)簽后者只有部分子集有。用load_dataset.py切分?jǐn)?shù)據(jù)時(shí)先打印label_distribution()統(tǒng)計(jì)一下類別數(shù)量防止某個(gè)類只有個(gè)位數(shù)樣本導(dǎo)致訓(xùn)練直接崩。4. 多任務(wù)訓(xùn)練的梯度流動(dòng)與損失函數(shù)設(shè)置在第二和第三章里我們把數(shù)據(jù)和模型都理清了接下來是最容易出問題的一步訓(xùn)練。train.py是整個(gè)倉庫的主入口它把BERT微調(diào)、圖卷積前向傳播、兩個(gè)損失函數(shù)反向傳播串在一條鏈路里。多任務(wù)不等于多模型各訓(xùn)各的這里的核心機(jī)制是共享BERT主干分叉兩個(gè)輸出頭。4.1 損失函數(shù)組合CrossEntropyLoss的加權(quán)策略# train.py 中多任務(wù)損失計(jì)算的核心邏輯 loss_rumor F.cross_entropy(logits_rumor, label_rumor) loss_stance F.cross_entropy(logits_stance, label_stance) loss_total alpha * loss_rumor (1 - alpha) * loss_stance loss_total.backward()邏輯說明alpha默認(rèn)取0.6即謠言檢測(cè)的損失權(quán)重更高。原因很簡(jiǎn)單——謠言檢測(cè)是主任務(wù)立場(chǎng)分類是輔助任務(wù)輔助任務(wù)的作用是給BERT提供更多語義監(jiān)督信號(hào)讓句向量更快收斂到“有立場(chǎng)區(qū)分度”的表示空間而不是喧賓奪主。參數(shù)說明當(dāng)你發(fā)現(xiàn)立場(chǎng)分類的accuracy很高但謠言檢測(cè)的F1上不去時(shí)把a(bǔ)lpha往0.7~0.8調(diào)反過來如果謠言檢測(cè)很快過擬合訓(xùn)練集F195但驗(yàn)證集不到70把a(bǔ)lpha降到0.5讓立場(chǎng)任務(wù)充當(dāng)正則項(xiàng)。4.2 優(yōu)化器與學(xué)習(xí)率BERT微調(diào)的邊界條件BERT部分的參數(shù)學(xué)習(xí)率不能和GCN部分一樣大。倉庫里的做法是給BERT單獨(dú)設(shè)一個(gè)小的學(xué)習(xí)率# train.py 中分組設(shè)置學(xué)習(xí)率的典型寫法 optimizer torch.optim.AdamW([ {params: bert.parameters(), lr: 2e-5}, {params: gcn.parameters(), lr: 1e-3}, {params: classifier.parameters(), lr: 1e-3}, ], weight_decay1e-5)邏輯說明BERT的預(yù)訓(xùn)練權(quán)重已經(jīng)很好用2e-5的learning rate只做微調(diào)防止劇烈更新把預(yù)訓(xùn)練學(xué)到的語言知識(shí)沖掉而GCN和最后的分類頭是從零訓(xùn)練需要更大學(xué)習(xí)率才能快速收斂。AdamW比Adam多了權(quán)重衰減的解耦對(duì)BERT這類大模型更穩(wěn)。參數(shù)說明weight_decay1e-5是常規(guī)值不需要經(jīng)常動(dòng)。如果訓(xùn)練過程Loss曲線出現(xiàn)鋸齒狀震蕩把GCN的學(xué)習(xí)率降到5e-4多半能緩解。Batch size設(shè)在32時(shí)SemEval2017訓(xùn)練集跑一個(gè)epoch大約3~5分鐘單張V100總共20個(gè)epoch左右能收斂。4.2.1 訓(xùn)練過程中的Checkpoint策略getbash.py里已經(jīng)寫好了一段時(shí)間間隔保存一次model.pt的命令。建議保留這個(gè)邏輯因?yàn)槎嗳蝿?wù)訓(xùn)練最怕訓(xùn)到第10個(gè)epoch才開始發(fā)散沒有checkpoint就得重跑。保存時(shí)只存state_dict不要直接存整個(gè)model對(duì)象前者換環(huán)境加載更省事。4.2.2 常用實(shí)驗(yàn)參數(shù)速查回到訓(xùn)練本身這套代碼里我比較常用的一組參數(shù)是參數(shù)默認(rèn)值推薦調(diào)整區(qū)間調(diào)整依據(jù)batch_size3216~64顯存不足優(yōu)先減到16max_len128128~256中文數(shù)據(jù)調(diào)到256alpha0.60.5~0.8主任務(wù)收斂慢就調(diào)大dropout0.50.3~0.6小數(shù)據(jù)集調(diào)大num_heads84~16顯存充足可試16這套參數(shù)在PHEME上跑出來的結(jié)果謠言檢測(cè)F1通常在0.82~0.88之間浮動(dòng)對(duì)比只用GCN不加注意力的baseline高出3到5個(gè)點(diǎn)差距主要來自BERT替換靜態(tài)詞向量帶來的語義增益。4.3 多任務(wù)梯度沖突的觀察方法訓(xùn)練時(shí)在train.py的backward之后手動(dòng)打印兩個(gè)任務(wù)的gradient norm能直觀看到?jīng)_突如果loss_rumor的梯度范數(shù)一直比loss_stance大一個(gè)數(shù)量級(jí)說明主任務(wù)主導(dǎo)了共享層的更新輔助任務(wù)基本沒起作用。處理辦法是梯度裁剪和動(dòng)態(tài)調(diào)整alpha。# 打印梯度范數(shù)的調(diào)試代碼 total_norm_rumor 0.0 for p in model.bert.parameters(): if p.grad is not None: total_norm_rumor p.grad.norm().item() ** 2 print(fRumor grad norm: {total_norm_rumor ** 0.5:.4f})邏輯說明這段話掛在loss_total.backward()和optimizer.step()之間每個(gè)epoch打印一次。如果值一直往上漲說明訓(xùn)練不穩(wěn)定配合clip_grad_norm_(model.parameters(), max_norm1.0)做全局梯度裁剪即可。5. 從trainOnly到完整訓(xùn)練如何區(qū)分是代碼問題還是模型問題倉庫里trainOnlyStance.py和trainOnlyRumor.py這兩個(gè)文件一般不太會(huì)被當(dāng)成核心模塊但調(diào)試的時(shí)候它們比train.py有用得多。多任務(wù)訓(xùn)練失敗的排查順序永遠(yuǎn)先跑單任務(wù)再跑多任務(wù)。5.1 實(shí)驗(yàn)一只用立場(chǎng)分類分支# 運(yùn)行單任務(wù)立場(chǎng)分類 python trainOnlyStance.py --only-stance --epochs 10跑通后觀察驗(yàn)證集accuracy。SemEval2017的立場(chǎng)分類理論上在0.65以上算正常三類隨機(jī)猜是0.33。如果訓(xùn)練5個(gè)epoch還停在0.4問題不出在模型結(jié)構(gòu)而是標(biāo)簽對(duì)齊出錯(cuò)。5.2 實(shí)驗(yàn)二只用謠言檢測(cè)分支# 運(yùn)行單任務(wù)謠言檢測(cè) python trainOnlyRumor.py --only-rumor --epochs 10謠言檢測(cè)二分類隨機(jī)基線是0.5。PHEME上跑到0.8以上是及格線。如果單任務(wù)正常、多任務(wù)反而掉點(diǎn)再回train.py里調(diào)alpha。5.3 單任務(wù)與多任務(wù)的三個(gè)關(guān)鍵結(jié)論第一兩個(gè)單任務(wù)都能收斂是多任務(wù)訓(xùn)練的前提任何一個(gè)單任務(wù)Loss不降多任務(wù)一定更差。第二多任務(wù)的最優(yōu)epoch數(shù)往往比單任務(wù)長因?yàn)閮蓚€(gè)任務(wù)交替收斂Loss曲線會(huì)出現(xiàn)平臺(tái)期不要在第5個(gè)epoch看到Loss不降就early stop再等3個(gè)epoch觀察。第三立場(chǎng)分類的收斂速度普遍快于謠言檢測(cè)分類頭更簡(jiǎn)單、類別更分散如果謠言檢測(cè)的F1開始下降但立場(chǎng)分類還在漲把a(bǔ)lpha往0.7調(diào)強(qiáng)行壓住立場(chǎng)任務(wù)的梯度貢獻(xiàn)。這些結(jié)論在不同隨機(jī)種子下跑過多次結(jié)果一致。驗(yàn)證方法也簡(jiǎn)單固定隨機(jī)種子跑三次取平均如果標(biāo)準(zhǔn)差小于0.5個(gè)F1點(diǎn)說明實(shí)驗(yàn)設(shè)置可信。6. 部署到推理把訓(xùn)練好的模型接到新的推文流上訓(xùn)練完不是終點(diǎn).ipynb里那些result和test文件才是把模型變成能用的工具的最后一公里。這里的部署不是指上線服務(wù)而是把model.pt加載回來對(duì)一條新推文或一個(gè)新事件做謠言預(yù)測(cè)并和真實(shí)標(biāo)注對(duì)比。6.1 可復(fù)用的推理流程模板# 推理腳本 inference.py 的關(guān)鍵流程 def predict_event(event_texts, model, tokenizer, devicecpu): # 1. 用BERT tokenizer編碼事件內(nèi)所有推文 inputs tokenizer( event_texts, paddingTrue, truncationTrue, max_length128, return_tensorspt ).to(device) # 2. 構(gòu)建事件圖鄰接矩陣這里用簡(jiǎn)化版只建相似邊 adj build_similarity_graph(inputs[input_ids]) # 3. 模型前向推理 with torch.no_grad(): logits_rumor, logits_stance model(inputs, adj) rumor_prob torch.softmax(logits_rumor, dim-1) return rumor_prob.argmax(dim-1).item(), rumor_prob.max().item()代碼說明build_similarity_graph里用BERT輸出句向量算余弦相似度大于0.8的節(jié)點(diǎn)之間建邊。推理階段沒有轉(zhuǎn)發(fā)關(guān)系數(shù)據(jù)時(shí)只用相似邊也能給出基本結(jié)果代價(jià)是F1會(huì)比完整圖低2~3個(gè)百分點(diǎn)。6.2 部署時(shí)容易踩的坑注意加載model.pt時(shí)必須用和訓(xùn)練時(shí)完全相同的model_class MSABiGCN()或BertBiGCN()實(shí)例化再load_state_dict。直接torch.load(model.pt)在跨Python版本或跨PyTorch小版本時(shí)大概率報(bào)_pickle.UnpicklingError。建議保存時(shí)多存一個(gè)model_config.json記錄模型參數(shù)hidden_dim、num_heads、max_len加載時(shí)先讀配置再建模型這是最不踩坑的做法。最后一步驗(yàn)證用result.ipynb里的classification_report函數(shù)輸出精確率、召回率、F1關(guān)注謠言類label1的召回率。二分類場(chǎng)景里模型傾向于把所有樣本都判為非謠言來獲得高準(zhǔn)確率但召回率會(huì)掉到0.5以下。如果出現(xiàn)這種情況在推理時(shí)把rumor類的閾值從默認(rèn)的0.5降到0.4通常是保召回最直接的手段。本文還有配套的精品資源點(diǎn)擊獲取