NLP Day 9 多標籤與零樣本分類
執行需求:Colab T4 可跑。本篇會在 Colab 免費 T4 上用 bert-base-multilingual-cased 與 facebook/bart-large-mnli 兩個模型,分別示範多標籤分類與零樣本分類。BART-mnli 約 1.5 GB、BERT 多語系約 700 MB,T4 都能容納得下。CPU 也能跑 BART-mnli,但推論會明顯較慢,示範中我們會用較小批次的設計。
引言
前一篇我們用詞典法與 Transformer 在 Day 8 做了情緒分析,那是一個典型的多分類任務:每一筆句子對應到「正向/負向/中性」其中一個類別。但真實世界的文字很少乾乾淨淨地落在單一類別。一則商品評論可能同時表達「品質好」與「價格偏貴」;一則客服訊息可能同時屬於「帳務問題」與「投訴」;一篇新聞可能同時涵蓋「政治」與「經濟」。當一個樣本可以同時有多個正確標籤時,傳統的 softmax + cross-entropy 就不再適用,這就是多標籤分類(multi-label classification)的場景。
另一個常見的挑戰是「今天剛冒出來的新類別」。例如品牌突然要監測「新產品發布」「優惠活動」這兩個過去沒有的類別,但手邊根本沒有任何標註資料;或是領域知識告訴我們類別大概有 8 種,但訓練資料只覆蓋 3 種。這時候零樣本分類(zero-shot classification)就是救星:我們不必蒐集標註,只要用自然語言描述候選類別,模型就能判斷新句子屬於哪一類。這背後靠的是預訓練模型對語意的理解能力,特別是「自然語言推論」(Natural Language Inference, NLI)預訓練過的模型。
這一篇要一次把多標籤與零樣本兩個主題整合:先用 bert-base-multilingual-cased 在公開的 go_emotions 情緒資料集上演練多標籤微調,再用 facebook/bart-large-mnli 展示零樣本分類的完整流程。我們會把 sigmoid 與 softmax 的差別、BCEWithLogits 與 CrossEntropy 的差別、零樣本的 NLI 評分機制、閾值選擇策略都講清楚。讀完這一篇你會了解:多標籤與多分類的差異、零樣本分類的原理、以及如何把這些工具接到自己的應用情境。
多標籤 vs 多分類:sigmoid 與 softmax 的差別
多分類(multi-class)是 softmax 的世界:模型對每個類別輸出一個分數,softmax 把這些分數轉成機率,所有機率加起來等於 1,最後取最大值當作預測。這隱含一個假設——「一個樣本只能屬於一個類別」。多標籤(multi-label)則完全不同:一個樣本可以同時屬於多個類別,每個類別的判斷是獨立的「是/否」決定。這時候 sigmoid 就派上用場了:對每個類別分別套 sigmoid 得到 0 到 1 的機率,每個機率獨立解讀為「屬於這個類別的可能性」,加起來不必等於 1。
損失函式的選擇也要跟著改。softmax + cross-entropy 的組合在多分類上是標準做法,但用在多標籤上會出問題:cross-entropy 會把所有類別的機率耦合在一起,無法表達「同時屬於 A 與 B」的訊號。多標籤的標準做法是對每個類別獨立算二元交叉熵(Binary Cross-Entropy, BCE),把 logits 與 labels 一起丟給 BCEWithLogitsLoss。這個函式內部會自動對 logits 做 sigmoid 再算 BCE,數值穩定性比手動 sigmoid + BCE 好很多。
另一個關鍵設計是「標籤編碼」。多分類用 CrossEntropyLoss 時 labels 是單一整數(例如 0 到 N-1);多標籤用 BCEWithLogitsLoss 時 labels 是 multi-hot 向量,形狀為 (num_classes,),每個位置是 0 或 1。例如一句話同時屬於「anger」與「annoyance」時,labels 就是 [1, 1, 0, 0, ...]。在資料前處理階段,要把 go_emotions 的類別清單(27 種情緒)做成 label_names,再用 MultiLabelBinarizer 把多個類別字串轉成 multi-hot 向量。
評估指標也不同。多分類常用 accuracy、macro-F1;多標籤則常用 micro-F1、macro-F1、subset accuracy、以及每個類別的 PR-AUC。Subset accuracy 嚴格要求預測的類別集合與真實類別集合完全相同才算對;micro-F1 把所有類別的 TP/FP/FN 加總後算一次 F1;macro-F1 則對每個類別個別算 F1 再平均。後面 Day 10 我們會專門展開分類任務的評估,今天先把多標籤的 loss 與閾值決策講清楚。
閾值決策是多標籤的關鍵環節。softmax 多分類直接取最大值,多標籤則要決定每個類別的 sigmoid 機率大於多少才算「屬於」。預設 0.5 對 balanced 資料集通常還行,但對類別不平衡的場景(例如「正面」超級多、「負面」超級少)就會出問題:把所有閾值都設 0.5,模型會傾向預測「是」,負面樣本幾乎抓不到。實務上會在驗證集上做閾值搜尋(per-class threshold tuning),找讓 macro-F1 最大的那組閾值,這也是為什麼 Day 10 的評估與錯誤分析這麼重要——多標籤的決策邊界不像多分類那麼直觀。
零樣本分類:把分類變成 NLI
零樣本分類的關鍵想法是把分類任務「重新詮釋」成模型已經會做的任務。facebook/bart-large-mnli 這個模型在 MNLI(Multi-Genre Natural Language Inference)資料集上做過預訓練,MNLI 的設定是「給一段前提(premise)與一句假設(hypothesis),判斷假設對前提是蘊含 entailment、矛盾 contradiction、還是中立 neutral」。如果我們把要分類的句子當作 premise,把「這句話談論的是 X」當作 hypothesis,模型輸出的蘊含機率就是「句子屬於類別 X」的可信度。
實際呼叫時只要傳兩個參數:候選標籤清單與待分類的句子。Hugging Face 的 transformers 套件會在內部自動把每個候選標籤包成 hypothesis(典型模板是 "This text is about {label}."),然後跑 NLI 推論,回傳每個標籤的 entailment 機率。我們只要挑機率最高的標籤(或閾值以上多個標籤)就能得到零樣本預測。
零樣本分類的強處是「不需要訓練資料」:只要給定一組候選類別(不限於訓練時見過的類別),模型就能即時調整。這在 cold-start 場景特別有用,例如新品上市、突發事件類別、政策分類。弱處是「沒有微調過的精準度」:因為模型沒看過領域的微調樣本,零樣本表現通常比監督式低 5 到 15 個百分點。實務上的常見做法是用零樣本做 baseline,再用少量標註資料做微調,兩者結合效果最好。
另一個使用零樣本的技巧是「候選標籤的措辭」。同一個概念用不同句子描述,得到的效果可能差很多。例如「投訴」可以寫成 "This text is about complaint." 也可以寫 "This text expresses a customer complaint."。經驗上,描述越具體、越貼近模型預訓練時的語料風格,零樣本表現越好。可以用多個模板做 ensemble,把每個模板的 entailment 機率平均起來,這是零樣本實務的常見招數。
完整實作:多標籤微調與零樣本分類
以下範例分兩部分:第一部分用 bert-base-multilingual-cased 在 go_emotions 子集上做多標籤微調;第二部分用 facebook/bart-large-mnli 做零樣本分類。執行前需先 pip install transformers==4.49.0 datasets==2.21.0 torch scikit-learn。
# 1. 載入 go_emotions 並建構多標籤 dataset
from datasets import load_dataset
from sklearn.preprocessing import MultiLabelBinarizer
import numpy as np
ds = load_dataset("go_emotions", "simplified", split="train[:5000]")
print(f"訓練樣本數:{len(ds)}")
print(f"欄位:{ds.column_names}")
# 輸出:訓練樣本數:5000
# 輸出:欄位:['text', 'labels', 'id']
label_names = ds.features["labels"].feature.names
num_labels = len(label_names)
print(f"類別數:{num_labels},類別名稱前 5:{label_names[:5]}")
# 輸出:類別數:28,類別名稱前 5:['admiration', 'amusement', 'anger', 'annoyance', 'approval']
這段用 Hugging Face datasets 載入 go_emotions 的 simplified 設定(28 種情緒標籤,每個樣本可能有 0 到 N 個情緒)。我們先取前 5000 筆當訓練集,目的是讓 Colab T4 能在 5 分鐘內跑完微調;正式訓練建議用完整 train split(約 43K 筆)。
# 2. 用 BERT tokenizer 把文字轉成 tensors,並建構多標籤 targets
from transformers import AutoTokenizer
import torch
tokenizer = AutoTokenizer.from_pretrained("bert-base-multilingual-cased")
def encode(batch):
enc = tokenizer(batch["text"], padding="max_length", truncation=True, max_length=128)
# 把 labels 串列轉成 multi-hot 向量
mlb = MultiLabelBinarizer(classes=list(range(num_labels)))
enc["labels"] = mlb.fit_transform(batch["labels"]).astype(np.float32).tolist()
return enc
encoded = ds.map(encode, batched=True, batch_size=128, remove_columns=ds.column_names)
encoded.set_format("torch")
print(f"input_ids 形狀:{encoded[0]['input_ids'].shape}")
print(f"labels 形狀:{encoded[0]['labels'].shape}, sum={encoded[0]['labels'].sum().item()}")
# 輸出:input_ids 形狀:torch.Size([128])
# 輸出:labels 形狀:torch.Size([28]), sum=1.0
這段的重點是 MultiLabelBinarizer:它把 [0, 3] 這種類別索引串列轉成 28 維的 multi-hot 向量(例如 [1, 0, 0, 1, 0, ...])。多標籤的 labels 是 float 張量(不是 long),因為 BCE 對 float 運算;softmax 多分類才用 long 張量。讀者請特別注意這個差異,否則 loss 函式會直接報 shape 錯誤。
# 3. 建立多標籤分類模型:用 AutoModelForSequenceClassification
from transformers import AutoModelForSequenceClassification
from torch.utils.data import DataLoader
model = AutoModelForSequenceClassification.from_pretrained(
"bert-base-multilingual-cased",
num_labels=num_labels,
problem_type="multi_label_classification", # 關鍵:告訴 HF 用 sigmoid + BCE
)
print(f"分類頭輸出維度:{model.config.num_labels}")
print(f"problem_type:{model.config.problem_type}")
# 輸出:分類頭輸出維度:28
# 輸出:problem_type:multi_label_classification
train_loader = DataLoader(encoded, batch_size=32, shuffle=True)
print(f"每 epoch batch 數:{len(train_loader)}")
# 輸出:每 epoch batch 數:157
problem_type="multi_label_classification" 是 Hugging Face transformers 4.20 之後的設計:它會自動把模型的分類頭換成 sigmoid + BCE 的組合,呼叫 model(**batch) 時 loss 自動算對。如果忘了傳這個參數,模型會預設 single_label_classification(softmax + CE),對多標籤資料會出現嚴重的訓練錯誤——例如 labels [1, 0, 1, 0, ...] 跟 logits [0.2, 0.8, ...] 對不上,整個 loss 永遠降不下來。
# 4. 訓練 2 個 epoch,並觀察 loss 變化
from torch.optim import AdamW
device = "cuda" if torch.cuda.is_available() else "cpu"
model.to(device)
optimizer = AdamW(model.parameters(), lr=5e-5, weight_decay=0.01)
model.train()
for epoch in range(2):
total_loss = 0.0
for batch in train_loader:
batch = {k: v.to(device) for k, v in batch.items()}
out = model(**batch)
loss = out.loss
loss.backward()
optimizer.step()
optimizer.zero_grad()
total_loss += loss.item()
avg = total_loss / len(train_loader)
print(f"epoch {epoch+1} loss={avg:.4f}")
# 輸出(實際數字會略有不同):
# epoch 1 loss=0.3142
# epoch 2 loss=0.2865
BCE loss 通常落在 0.2 到 0.4 之間;如果 loss 一直停在 0.69(log(2)),代表模型完全沒學到東西;loss 掉到 0.1 以下可能過擬合。Colab T4 上 2 epoch 約 4 分鐘,完整 3 epoch 通常能把驗證集 macro-F1 推到 0.40 左右。這個數字看起來不高,但 go_emotions 的 28 類是「細粒度情緒」(例如「欽佩」與「讚美」非常接近),對隨機猜 baseline 已經是大幅提升。
# 5. 推論時用 sigmoid + 閾值挑選多個情緒標籤
def predict_multi_label(text, threshold=0.5):
enc = tokenizer(text, return_tensors="pt", truncation=True, max_length=128).to(device)
model.eval()
with torch.no_grad():
logits = model(**enc).logits
probs = torch.sigmoid(logits)[0].cpu().numpy()
chosen = [(label_names[i], float(p)) for i, p in enumerate(probs) if p >= threshold]
return sorted(chosen, key=lambda x: -x[1])
samples = [
"I love how quickly the team responded to my bug report!",
"The new policy is disappointing and frustrating for everyone.",
"I'm not sure what to think about this situation.",
]
for s in samples:
print(s)
for label, p in predict_multi_label(s, threshold=0.3)[:3]:
print(f" {label}: {p:.3f}")
print()
# 輸出(實際數字會略有不同):
# I love how quickly the team responded to my bug report!
# admiration: 0.612
# approval: 0.458
# joy: 0.331
這段展示「多標籤預測」的真實樣貌:一個句子可能同時觸發多個情緒,閾值決定要呈現幾個給使用者。我們這裡把閾值降到 0.3 是因為多標籤模型的機率通常比較保守(sigmoid 對 28 類的獨立判斷,單一類別的機率很難到 0.5 以上)。實務上會用驗證集搜尋最佳閾值,這留到 Day 10。
# 6. 零樣本分類:facebook/bart-large-mnli 完全不需要訓練資料
from transformers import pipeline
zsc = pipeline("zero-shot-classification",
model="facebook/bart-large-mnli", device=0 if device == "cuda" else -1)
text = "蘋果今天發表會宣布新款晶片採用 3 奈米製程,效能比前代提升 20%。"
candidates = ["科技", "財經", "運動", "娛樂", "政治", "醫療"]
result = zsc(text, candidate_labels=candidates)
for label, score in zip(result["labels"], result["scores"]):
print(f" {label}: {score:.3f}")
# 輸出(實際數字會略有不同):
# 科技: 0.8421
# 財經: 0.1102
# 娛樂: 0.0257
# 運動: 0.0089
# 政治: 0.0073
# 醫療: 0.0058
zero-shot-classification pipeline 把整個 NLI 機制包好:模型內部對每個 candidate label 套模板 "This text is about {label}.",跑 NLI 推論,把 entailment 機率回傳給我們。注意這裡的 candidate 是中文,bart-large-mnli 是英文模型,但它支援跨語言零樣本;如果是中文候選標籤效果會略降,建議在中文任務上改用 MoritzLaurer/mDeBERTa-v3-base-mnli-xnli(多語系 NLI 模型)。
# 7. 零樣本的多標籤應用:把閾值放低就能同時選多個類別
text = "The customer service rep was incredibly rude and unhelpful, but the product itself is great."
candidates = ["product quality", "customer service", "pricing", "shipping", "user experience"]
result = zsc(text, candidate_labels=candidates, multi_label=True)
for label, score in zip(result["labels"], result["scores"]):
if score >= 0.3:
print(f" {label}: {score:.3f}")
# 輸出(實際數字會略有不同):
# customer service: 0.8712
# product quality: 0.7324
# user experience: 0.4138
傳 multi_label=True 會讓 pipeline 對每個 candidate 的 entailment 與 contradiction 機率單獨 sigmoid,而不是用 softmax 在 candidate 之間互相壓制。這就是零樣本的多標籤版本:一則評論同時屬於「產品品質」與「客服」兩個類別,模型可以同時給兩個高機率。零樣本的好處在這裡完全展現——完全沒看過這個領域的訓練樣本,仍然能給出合理的分類結果。
常見錯誤與踩雷
錯誤一:把多標籤資料丟給 softmax 模型。如果 problem_type 沒設成 "multi_label_classification",Hugging Face 預設是 softmax + cross-entropy,這個 loss 會把多標籤的 multi-hot 向量當成 one-hot,強迫模型只選一個類別。症狀是 loss 卡在 0.69 上下不動,或預測結果永遠集中在某一類。修正方式是明確傳 problem_type="multi_label_classification",或是手動把 labels 轉成 float 張量、用 BCEWithLogitsLoss 自己算 loss。
錯誤二:labels 用 long 張量。CrossEntropyLoss 吃 long 張量(單一整數標籤),BCEWithLogitsLoss 吃 float 張量(multi-hot 向量)。如果把 multi-hot 用 .long() 轉成 int 張量再丟給 BCE,會看到 "found dtype Long but expected Float" 的錯誤訊息。修正方式是讓 labels 保持 float 型別,這跟我們在 encode 函式裡用 .astype(np.float32) 是同一個原因。
錯誤三:閾值永遠設 0.5。多標籤模型的 sigmoid 機率分佈通常偏中低,特別是類別數多的場景(28 類的 go_emotions 單一類別的機率很少超過 0.5)。把所有閾值都設 0.5 會讓模型傾向「什麼都不預測」,最終召回率極低。實務上的標準做法:在驗證集上對每個類別做閾值搜尋,選出 macro-F1 最大的那組閾值;如果時間有限,至少把全域閾值降到 0.2–0.4 之間。
錯誤四:bart-large-mnli 用中文候選標籤。facebook/bart-large-mnli 的預訓練語料以英文為主,雖然架構上能處理多語言,但對中文候選標籤的零樣本表現明顯較差。常見症狀是所有候選標籤的機率都擠在 0.1 附近,沒有顯著差異。修正方式是用 MoritzLaurer/mDeBERTa-v3-base-mnli-xnli 或 MoritzLaurer/mDeBERTa-v3-large-mnli-xnli 取代,這兩個模型在 XNLI 多語系 NLI 上微調過,對中文、日文、韓文、德文都有支援。
錯誤五:bart-large-mnli 沒設長度限制。bart-large-mnli 的最大輸入長度是 1024 token,超過會被 tokenizer 自動截斷,長文件會丟失開頭或結尾的重要訊息。修正方式是用 tokenizer(text, truncation=True, max_length=1024) 並回傳 return_tensors="pt";長文件場景要考慮先用 Day 3 的方式分段、再對每段做零樣本、最後用「最大機率」或「多數決」整合。
錯誤六:把零樣本當成監督式的替代品。零樣本分類在冷啟動階段很有用,但對精準度要求高的應用(例如金融合規、醫療分類),零樣本表現通常不夠。實務上的建議流程:先用零樣本估 baseline、再用少量標註(500–2000 筆)做監督式微調、最後比較兩者差距決定要走哪條路。很多團隊會同時保留兩個模型:零樣本負責「新類別快速上線」,監督式負責「核心類別精準度」。
效能與實務提醒
Colab T4 上 bert-base-multilingual-cased 微調 2 epoch 約 4 分鐘,bart-large-mnli 零樣本推論每 10 句約 5 秒。如果換成 RTX 3090 或 A100,微調時間可以壓到 1 分鐘以內;CPU 模式也能跑,但 bert-base 微調 2 epoch 約 40 分鐘、bart-large-mnli 零樣本約每 10 句 30 秒。實務上 Colab 免費版的 T4 已足夠,但要注意 Colab 免費版有連線 12 小時上限;如果是長時間實驗,建議用 Colab Pro 或自架 GPU。
batch size 的選擇對多標籤也有影響。多分類可以把 batch 拉大(因為只有一個 label),多標籤的 labels 是 multi-hot 向量,記憶體用量稍高;bert-base 在 T4 上用 batch=32、序列長度 128 約耗 6 GB,把 batch 拉到 64 約 9 GB,仍在 T4 的 16 GB 內。bart-large-mnli 推論時 batch 8 約 4 GB、batch 16 約 7 GB。
幾個常見的效能最佳化:開啟 fp16 / bf16 推論能把記憶體與時間都壓一半;用 torch.compile 包裝模型也有 1.2–1.5 倍加速(但首次呼叫會多等編譯時間)。多標籤資料用 DataLoader(pin_memory=True, num_workers=2) 可以減少 GPU 等資料的時間。評估階段務必把所有 logits 一次蒐集完再算 sigmoid,避免在迴圈裡逐筆算浪費 GPU 時間。
真實部署時還要注意「模型版本控管」:把 tokenizer、模型權重、閾值、label_names 一起存成同一個 checkpoint,這樣部署端不會出現「忘了更新 label 順序」之類的 bug。建議命名為 go_emotions_bert_v1/ 這種語意化資料夾,並把每個版本的 macro-F1 寫進 README;後續要升級時能直接比對哪個版本表現最好。
小結
本篇把多標籤分類與零樣本分類兩個主題一次講清楚。多標籤用 sigmoid + BCE + multi-hot 向量,預測時用閾值挑選多個類別;零樣本用 NLI 預訓練的模型(bart-large-mnli、mDeBERTa-xnli)把分類轉成 entailment 機率判斷,無需任何訓練資料。實作上 BERT 微調 2 epoch 與 BART 零樣本推論都只要幾分鐘,能在 Colab T4 上輕鬆跑完。讀完這篇你應該能回答:多標籤與多分類的 loss 差異為何?什麼時候用零樣本比較划算?bart-large-mnli 與 mDeBERTa-xnli 在中文任務上怎麼選?這些答案都藏在本篇的程式與數字裡。明天 Day 10 我們會把分類任務的評估與錯誤分析展開,包括 macro-F1、PR-AUC、混淆矩陣與每類別閾值搜尋。
結語
今天的重點是「同一段文字不再只對應一個類別」。我們用 BERT 多語系模型在 go_emotions 上展示多標籤微調、用 bart-large-mnli 展示零樣本 NLI,並把兩者整合到「零樣本快速定調、BERT 精準調校」的實務流程。從 Day 5 的 BERT 微調到今天的多標籤與零樣本,文字分類工具箱又多了兩個武器。明天,我們會把分類任務的評估與錯誤分析寫成一套完整流程:precision/recall/F1、混淆矩陣、PR 曲線、以及多標籤的閾值搜尋技巧。
在工業界,多標籤與零樣本其實是同一個光譜的兩端:多標籤是「我知道類別長什麼樣、有標註」、零樣本是「我只知道類別的描述」。實務上最常見的場景是「類別會變動」:客服分類隨著產品線增加、政黨分類隨著選舉週期改變、新聞主題隨著時事變動。這時候先用零樣本做冷啟動、再用少量標註做微調的兩階段做法,能把上線時間從一個月壓到一週。下一篇進入評估時,會介紹 PR 曲線與每類別閾值搜尋,這是多標籤模型上線前必備的工具。
延伸資源
- Hugging Face 官方文件(2025-03 擷取):
AutoModelForSequenceClassification、problem_type="multi_label_classification"(transformers 4.49、文件版本對應)。 - Liu 等人,RoBERTa: A Robustly Optimized BERT Pretraining Approach(arXiv:1907.11692)與 Devlin 等人 BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding(arXiv:1810.04805),BERT 家族的原始論文。
- Williams 等人,A Broad-Coverage Challenge Corpus for Sentence Understanding through Inference(arXiv:1704.05426),MNLI 資料集原始論文(bart-large-mnli 的預訓練來源)。
- Demszky 等人,GoEmotions: A Dataset of Fine-Grained Emotions(ACL 2020),Google 發表的 28 類細粒度情緒資料集,授權為 CC-BY 4.0。
- Laurer 等人,Building Efficient Universal Classifiers with Natural Language Inference(arXiv:2403.00813),零樣本分類與 NLI 的方法論整理。
留言
張貼留言