既然我們已經有了一個微調好的BERT-base模型,現在來看如何使用知識蒸餾技術來微調一個更小更快的模型。為此,我們需要建立一個自定義的訓練器,將交叉熵損失與知識蒸餾損失結合起來。
定義知識蒸餾訓練引數
首先,我們需要為知識蒸餾定義新的超引數:
from transformers import TrainingArguments
class DistillationTrainingArguments(TrainingArguments):
def __init__(self, *args, alpha=0.5, temperature=2.0, **kwargs):
super().__init__(*args, **kwargs)
self.alpha = alpha
self.temperature = temperature
這個類別繼承自Transformers函式庫的TrainingArguments
,並增加了兩個關鍵引數:
alpha
:控制交叉熵損失與知識蒸餾損失的相對權重,預設為0.5(兩者平衡)temperature
:控制軟標籤的平滑程度,較高的溫度會使機率分佈更加平滑,讓學生模型更容易學習教師模型的"不確定性",預設為2.0
實作知識蒸餾訓練器
接下來,我們需要實作知識蒸餾訓練器,關鍵是重寫compute_loss()
方法:
import torch.nn as nn
import torch.nn.functional as F
from transformers import Trainer
class DistillationTrainer(Trainer):
def __init__(self, *args, teacher_model=None, **kwargs):
super().__init__(*args, **kwargs)
self.teacher_model = teacher_model
def compute_loss(self, model, inputs, return_outputs=False):
outputs_stu = model(**inputs)
# 提取學生模型的交叉熵損失和logits
loss_ce = outputs_stu.loss
logits_stu = outputs_stu.logits
# 提取教師模型的logits
with torch.no_grad():
outputs_tea = self.teacher_model(**inputs)
logits_tea = outputs_tea.logits
# 軟化機率分佈並計算蒸餾損失
loss_fct = nn.KLDivLoss(reduction="batchmean")
loss_kd = self.args.temperature ** 2 * loss_fct(
F.log_softmax(logits_stu / self.args.temperature, dim=-1),
F.softmax(logits_tea / self.args.temperature, dim=-1))
# 回傳加權後的學生損失
loss = self.args.alpha * loss_ce + (1. - self.args.alpha) * loss_kd
return (loss, outputs_stu) if return_outputs else loss
這個訓練器的核心在於compute_loss()
方法,它實作了知識蒸餾的關鍵機制:
- 首先取得學生模型的輸出,包括標準交叉熵損失和logits(未歸一化的機率)
- 使用
torch.no_grad()
上下文管理器取得教師模型的logits,這樣可以避免計算教師模型的梯度,節省記憶體 - 計算知識蒸餾損失,使用KL散度(Kullback-Leibler Divergence)測量學生模型與教師模型輸出分佈之間的差異
- 在計算KL散度前,使用溫度引數T對logits進行縮放,公式為
logits/T
,然後應用softmax得到平滑化的機率分佈 - 最後將交叉熵損失和知識蒸餾損失按照alpha引數進行加權組合
注意temperature ** 2
的乘法因子,這是因為當我們縮放logits時,梯度也會相應縮小,這個因子可以使梯度回到原始大小。
nn.KLDivLoss()
的reduction="batchmean"
引數指定我們對批次維度取平均值,這樣損失不會隨著批次大小而變化。
選擇適合的學生模型初始化
既然我們已經實作了知識蒸餾訓練器,下一個問題是:應該選擇哪個預訓練語言模型作為學生模型?
一般來説,我們應該選擇比教師模型更小的模型作為學生,以減少延遲和記憶體佔用。研究表明,知識蒸餾在教師和學生是相同模型型別時效果最好。這可能是因為不同模型型別(如BERT和RoBERTa)可能有不同的輸出嵌入空間,這會阻礙學生模型模仿教師模型的能力。
在我們的案例中,教師模型是BERT,因此DistilBERT是初始化學生模型的自然選擇,因為它的引數量減少了40%,並且在下游任務上已經證明能夠達到不錯的效果。
資料預處理
首先,我們需要對查詢文字進行分詞和編碼:
from transformers import AutoTokenizer
student_ckpt = "distilbert-base-uncased"
student_tokenizer = AutoTokenizer.from_pretrained(student_ckpt)
def tokenize_text(batch):
return student_tokenizer(batch["text"], truncation=True)
clinc_enc = clinc.map(tokenize_text, batched=True, remove_columns=["text"])
clinc_enc = clinc_enc.rename_column("intent", "labels")
這段程式碼完成了以下工作:
- 從預訓練的DistilBERT模型載入分詞器
- 定義一個函式來對文字進行分詞,並設定
truncation=True
以確保文字長度不超過模型的最大輸入長度 - 使用
map()
函式對整個資料集進行批次處理,並移除原始文字列 - 將"intent"列重新命名為"labels",這樣訓練器可以自動識別標籤列
定義評估指標和訓練引數
接下來,我們需要定義評估指標和訓練引數:
def compute_metrics(pred):
predictions, labels = pred
predictions = np.argmax(predictions, axis=1)
return accuracy_score.compute(predictions=predictions, references=labels)
batch_size = 48
finetuned_ckpt = "distilbert-base-uncased-finetuned-clinc"
student_training_args = DistillationTrainingArguments(
output_dir=finetuned_ckpt,
evaluation_strategy="epoch",
num_train_epochs=5,
learning_rate=2e-5,
per_device_train_batch_size=batch_size,
per_device_eval_batch_size=batch_size,
alpha=1,
weight_decay=0.01,
push_to_hub=True)
這段程式碼設定了:
compute_metrics
函式:用於評估模型效能,將模型輸出的logits轉換為類別預測,並計算準確率- 訓練引數:
- 批次大小為48
- 每個epoch進行評估
- 訓練5個epoch
- 學習率為2e-5
- 權重衰減為0.01
- alpha=1:這意味著我們只使用交叉熵損失,暫時不使用知識蒸餾(這是為了建立基準效能)
- push_to_hub=True:訓練結束後將模型推播到Hugging Face Hub
知識蒸餾的實施策略
知識蒸餾可以採用不同的策略,根據具體情況選擇最適合的方法:
任務無關蒸餾 vs 任務特定蒸餾
任務無關蒸餾:在預訓練階段進行知識蒸餾,建立通用的小型模型,然後再針對特定任務進行微調。DistilBERT就是採用這種方式。
任務特定蒸餾:針對已經在特定任務上微調好的大型模型進行知識蒸餾,直接學習特定任務的知識。這種方式通常能在特定任務上獲得更好的效能,但缺乏通用性。
在實際應用中,我發現任務特定蒸餾在資源有限但對特定任務效能要求高的場景特別有用。例如,在開發一個需要即時回應的客服意圖識別系統時,我們可以先微調一個大型BERT模型獲得最佳效能,然後透過知識蒸餾將其壓縮為更小的DistilBERT模型,在保持大部分效能的同時顯著提升推理速度。
溫度引數的影響
溫度引數T是知識蒸餾中的一個關鍵超引數:
- 當T=1時,教師模型的輸出機率分佈保持不變
- 當T>1時,機率分佈變得更加平滑,類別之間的差異減小
- 當T趨近於無窮大時,所有類別的機率趨近於相等
較高的溫度使得教師模型的"暗知識"(dark knowledge)更容易被學生模型學習。這些暗知識包含了類別之間的相似性訊息,例如,「預訂餐廳」和「預訂酒店」這兩個意圖可能有一定相似性,教師模型會給這兩個類別都賦予一定的機率,而不是僅關注正確的類別。
在我的實驗中,T=2~4的範圍通常能取得較好的效果,但最佳值還是需要透過實驗確定。
知識蒸餾的實際效益
知識蒸餾技術能帶來多方面的效益:
- 模型大小減少:DistilBERT比BERT-base小約40%,引數從1.1億減少到6600萬
- 推理速度提升:引數減少直接帶來推理速度的提升,通常可以快1.5~2倍
- 記憶體佔用降低:小型模型需要更少的GPU/CPU記憶體,有利於佈署在資源受限的環境
- 效能保持:在許多NLP任務上,蒸餾後的模型可以保持原模型95%以上的效能
這種"小而快"的特性使得知識蒸餾成為將大模型語言佈署到生產環境的關鍵技術之一。特別是在移動裝置、邊緣計算裝置或需要低延遲回應的Web服務中,知識蒸餾後的模型更具實用性。
知識蒸餾的進階技巧
除了基本的知識蒸餾外,還有一些進階技巧可以進一步提升蒸餾效果:
特徵蒸餾
除了模仿教師模型的輸出分佈外,我們還可以讓學生模型學習教師模型的中間特徵表示。這種方法在電腦視覺領域特別常見,但在NLP中也有應用,如DistilBERT中使用的餘弦嵌入損失就是一種特徵蒸餾。
特徵蒸餾的實作需要修改compute_loss()
方法,加入特徵對齊的損失項:
# 提取教師和學生的隱藏狀態
hidden_states_stu = outputs_stu.hidden_states[-1] # 最後一層的隱藏狀態
hidden_states_tea = outputs_tea.hidden_states[-1]
# 計算餘弦相似度損失
cosine_loss = 1 - F.cosine_similarity(hidden_states_stu, hidden_states_tea, dim=-1).mean()
# 加入到總損失
loss = self.args.alpha * loss_ce + self.args.beta * loss_kd + self.args.gamma * cosine_loss
漸進式蒸餾
對於非常深的模型,可以採用漸進式蒸餾策略,先從原始模型蒸餾到中等大小的模型,再從中等模型蒸餾到更小的模型。這種方法可以減輕一步到位的蒸餾難度,在某些情況下能獲得更好的效果。
資料增強
在知識蒸餾過程中,增加更多的未標記資料可以顯著提升效果。由於學生模型是從教師模型的輸出中學習,而不是直接從標籤中學習,因此我們可以使用大量無標籤資料來增強蒸餾過程。
例如,在意圖分類別任務中,我們可以收集大量使用者查詢但不需要人工標註,只需要用教師模型生成"軟標籤",然後讓學生模型學習這些軟標籤。
知識蒸餾的實用建議
根據我在多個NLP專案中應用知識蒸餾的經驗,以下是一些實用建議:
- 從相同架構開始:選擇與教師模型架構相同但規模更小的模型作為學生,如BERT→DistilBERT、RoBERTa→DistilRoBERTa
- 調整alpha引數:alpha控制交叉熵損失與蒸餾損失的權重,通常從0.5開始調整
- 實驗不同溫度:溫度引數對蒸餾效果影響很大,建議在2~8範圍內進行網格搜尋
- 增加訓練epochs:學生模型通常需要比直接微調更多的訓練輪次才能充分學習教師模型的知識
- 使用更大批次大小:如果資源允許,使用更大的批次大小有助於學生模型更好地學習機率分佈
- 監控驗證集效能:有時學生模型可能在訓練集上表現良好但泛化性較差,定期評估驗證集效能很重要
知識蒸餾是一種強大的模型壓縮技術,能夠在保持大部分效能的同時顯著減小模型體積並提升推理速度。在Transformer模型日益龐大的今天,知識蒸餾為將這些強大模型佈署到資源受限環境提供了可行的解決方案。
透過本文介紹的知識蒸餾實作方法,我們可以建立自己的知識蒸餾訓練器,從大型BERT模型向小型DistilBERT模型遷移知識,實作模型的輕量化。這種技術不僅適用於意圖分類別等簡單任務,也可以擴充套件到機器翻譯、問答系統等複雜NLP應用中。
隨著邊緣計算和低功耗裝置的普及,知識蒸餾技術將在未來發揮更加重要的作用,成為AI模型從研究走向實際應用的關鍵橋樑。
知識蒸餾:從龐大模型中提煉精華
在AI模型佈署的世界裡,總是存在一個永恆的矛盾:我們希望模型夠聰明,但又不能太龐大。當我嘗試在生產環境中佈署大模型語言時,經常面臨這種兩難困境。這就是為什麼知識蒸餾(Knowledge Distillation)技術變得如此重要 - 它讓我們能夠開發更小、更快,卻幾乎同樣聰明的模型。
在這篇文章中,我將帶你深入瞭解如何使用知識蒸餾技術來精簡模型,同時維持其效能表現。我們會從理論基礎開始,然後透過實際案例展示如何將龐大的BERT模型蒸餾為更輕量的DistilBERT模型。
模型與標籤對映準備
首先,我們需要為學生模型提供意圖與標籤ID之間的對映關係。這些對映可以從我們預先下載的BERT-base模型中取得:
id2label = pipe.model.config.id2label
label2id = pipe.model.config.label2id
這段程式碼從我們的管道(pipeline)中提取了兩個重要的對映字典:
id2label
:將數字ID對映到對應的文字標籤label2id
:將文字標籤對映到對應的數字ID
這些對映對於確保我們的學生模型(即將要訓練的較小模型)能夠正確理解和輸出與教師模型相同的標籤體系至關重要。
建立學生模型設定
有了這些對映,我們現在可以使用AutoConfig
類別為學生模型建立自訂設定:
from transformers import AutoConfig
num_labels = intents.num_classes
student_config = (AutoConfig
.from_pretrained(student_ckpt, num_labels=num_labels,
id2label=id2label, label2id=label2id))
這段程式碼建立了學生模型的設定:
- 使用
AutoConfig.from_pretrained()
從預訓練檢查點載入基本設定 - 指定模型需要處理的類別數量(
num_labels
) - 提供標籤對映,確保模型輸出能夠正確對應到意圖類別
student_ckpt
變數應該包含學生模型的預訓練檢查點路徑,通常是DistilBERT的預訓練模型
接下來,我們將這個設定提供給AutoModelForSequenceClassification
類別的from_pretrained()
函式:
import torch
from transformers import AutoModelForSequenceClassification
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
def student_init():
return (AutoModelForSequenceClassification
.from_pretrained(student_ckpt, config=student_config).to(device))
這段程式碼定義了一個函式student_init()
,它:
- 根據之前建立的設定初始化學生模型
- 將模型移至適當的裝置(GPU如果可用,否則使用CPU)
- 使用函式而非直接初始化,是為了與Trainer API相容,讓我們能夠多次重新初始化模型
實施知識蒸餾訓練
現在我們已經準備好了所有必要的元素,接下來讓我們載入教師模型並開始知識蒸餾過程:
teacher_ckpt = "transformersbook/bert-base-uncased-finetuned-clinc"
teacher_model = (AutoModelForSequenceClassification
.from_pretrained(teacher_ckpt, num_labels=num_labels)
.to(device))
distilbert_trainer = DistillationTrainer(model_init=student_init,
teacher_model=teacher_model,
args=student_training_args,
train_dataset=clinc_enc['train'],
eval_dataset=clinc_enc['validation'],
compute_metrics=compute_metrics,
tokenizer=student_tokenizer)
distilbert_trainer.train()
訓練結果:
Epoch | Training Loss | Validation Loss | Accuracy |
---|---|---|---|
1 | 4.2923 | 3.289337 | 0.742258 |
2 | 2.6307 | 1.883680 | 0.828065 |
3 | 1.5483 | 1.158315 | 0.896774 |
4 | 1.0153 | 0.861815 | 0.909355 |
5 | 0.7958 | 0.777289 | 0.917419 |
在這段程式碼中:
- 我們首先載入了已經在CLINC資料集上微調過的BERT-base模型作為教師
- 使用自定義的
DistillationTrainer
類別(這是標準Trainer的擴充套件版本,支援知識蒸餾) - 提供了學生模型初始化函式、教師模型、訓練引數、資料集和評估指標
- 呼叫
train()
方法開始蒸餾過程
從訓練結果來看,學生模型(DistilBERT)在驗證集上達到了91.7%的準確率,相比教師模型(BERT-base)的94%只損失了約2個百分點,這是相當不錯的成績,尤其考慮到模型尺寸的顯著減小。
儲存與評估蒸餾模型
訓練完成後,我們將模型推播到Hub以便後續使用:
distilbert_trainer.push_to_hub("Training completed!")
現在我們可以立即在管道中使用這個模型進行效能基準測試:
finetuned_ckpt = "transformersbook/distilbert-base-uncased-finetuned-clinc"
pipe = pipeline("text-classification", model=finetuned_ckpt)
optim_type = "DistilBERT"
pb = PerformanceBenchmark(pipe, clinc["test"], optim_type=optim_type)
perf_metrics.update(pb.run_benchmark())
測試結果:
- 模型大小:255.89 MB
- 平均延遲:27.53 ± 0.60 毫秒
- 測試集準確率:0.858
視覺化比較不同模型的效能
為了更直觀地比較不同模型的效能,我們可以建立一個散點圖,以延遲為X軸,準確率為Y軸,並用點的大小表示模型的磁碟大小:
import pandas as pd
def plot_metrics(perf_metrics, current_optim_type):
df = pd.DataFrame.from_dict(perf_metrics, orient='index')
for idx in df.index:
df_opt = df.loc[idx]
# 為當前最佳化型別增加虛線圓圈
if idx == current_optim_type:
plt.scatter(df_opt["time_avg_ms"], df_opt["accuracy"] * 100,
alpha=0.5, s=df_opt["size_mb"], label=idx,
marker='$\u25CC$')
else:
plt.scatter(df_opt["time_avg_ms"], df_opt["accuracy"] * 100,
s=df_opt["size_mb"], label=idx, alpha=0.5)
legend = plt.legend(bbox_to_anchor=(1,1))
for handle in legend.legendHandles:
handle.set_sizes([20])
plt.ylim(80,90)
# 使用最慢的模型來定義X軸範圍
xlim = int(perf_metrics["BERT baseline"]["time_avg_ms"] + 3)
plt.xlim(1, xlim)
plt.ylabel("Accuracy (%)")
plt.xlabel("Average latency (ms)")
plt.show()
plot_metrics(perf_metrics, optim_type)
這段程式碼建立了一個散點圖,用於比較不同模型的效能:
- X軸表示平均延遲(毫秒)
- Y軸表示準確率(百分比)
- 點的大小表示模型的磁碟大小(MB)
- 當前最佳化型別(在這裡是"DistilBERT")用虛線圓圈標記,以便與基準模型區分
從視覺化結果可以看出,透過使用較小的模型,我們顯著降低了平均延遲,而準確率僅下降了約1%。這是一個非常好的權衡!
使用Optuna進行超引數最佳化
為了進一步提高蒸餾效果,我們需要找到知識蒸餾中兩個關鍵超引數的最佳值:α(平衡蒸餾損失和標準分類別損失的權重)和T(溫度引數,控制軟標籤的"軟度")。
我們可以使用Optuna框架來有效地搜尋這些超引數的最佳值。Optuna是一個專為超引數最佳化設計的框架,它透過多次試驗來最佳化目標函式。
Optuna的基本原理
讓我們先透過一個簡單的例子來理解Optuna的工作原理。假設我們想要最小化著名的Rosenbrock"香蕉函式":
f(x, y) = (1 - x)² + 100(y - x²)²
這個函式以其彎曲的等高線而得名,全域最小值在$(x, y) = (1, 1)$。找到函式的"山谷"是一個簡單的最佳化問題,但收斂到全域最小值並不容易。
在Optuna中,我們可以透過定義一個回傳f(x, y)值的目標函式來找到f(x, y)的最小值:
def objective(trial):
x = trial.suggest_float("x", -2, 2)
y = trial.suggest_float("y", -2, 2)
return (1 - x) ** 2 + 100 * (y - x ** 2) ** 2
import optuna
study = optuna.create_study()
study.optimize(objective, n_trials=1000)
print(study.best_params)
# 輸出: {'x': 1.003024865971437, 'y': 1.00315167589307}
這個例子展示了Optuna的基本用法:
- 定義一個
objective
函式,它接受一個trial
引數 - 使用
trial.suggest_float
指定引數的搜尋範圍 - 建立一個
study
物件並呼叫optimize
方法進行最佳化 - 透過
study.best_params
取得找到的最佳引數值
我們可以看到,經過1000次試驗,Optuna找到的x和y值非常接近全域最小值(1, 1)。
為知識蒸餾最佳化超引數
現在,我們將這一思路應用到知識蒸餾的超引數最佳化中。除了α和T,我們還將訓練輪數作為一個可最佳化的引數:
def hp_space(trial):
return {"num_train_epochs": trial.suggest_int("num_train_epochs", 5, 10),
"alpha": trial.suggest_float("alpha", 0, 1),
"temperature": trial.suggest_int("temperature", 2, 20)}
best_run = distilbert_trainer.hyperparameter_search(
n_trials=20, direction="maximize", hp_space=hp_space)
print(best_run)
# 輸出: BestRun(run_id='1', objective=0.927741935483871,
# hyperparameters={'num_train_epochs': 10, 'alpha': 0.12468168730193585,
# 'temperature': 7})
這段程式碼:
- 定義了超引數搜尋空間,包括:
- 訓練輪數:5到10之間的整數
- α引數:0到1之間的浮點數(控制蒸餾損失的權重)
- 溫度T:2到20之間的整數
- 使用Trainer的
hyperparameter_search
方法執行20次試驗 - 指定方向為"maximize",意味著我們要最大化準確率
- 最佳參陣列合是:訓練10輪,α=0.125左右,溫度T=7
這個α值告訴我們,大部分訓練訊號來自知識蒸餾項,而非標準分類別損失。
使用最佳超引數進行最終訓練
有了這些最佳引數,我們可以更新訓練引數並執行最終的訓練:
for k,v in best_run.hyperparameters.items():
setattr(student_training_args, k, v)
# 定義一個新的倉函式庫來儲存蒸餾模型
distilled_ckpt = "distilbert-base-uncased-distilled-clinc"
student_training_args.output_dir = distilled_ckpt
# 建立一個具有最佳引數的新Trainer
distil_trainer = DistillationTrainer(model_init=student_init,
teacher_model=teacher_model,
args=student_training_args,
train_dataset=clinc_enc['train'],
eval_dataset=clinc_enc['validation'],
compute_metrics=compute_metrics,
tokenizer=student_tokenizer)
distil_trainer.train()
訓練結果:
Epoch | Training Loss | Validation Loss | Accuracy |
---|---|---|---|
1 | 0.9031 | 0.574540 | 0.736452 |
2 | 0.4481 | 0.285621 | 0.874839 |
3 | 0.2528 | 0.179766 | 0.918710 |
4 | 0.1760 | 0.139828 | 0.929355 |
5 | 0.1416 | 0.121053 | 0.934839 |
6 | 0.1243 | 0.111640 | 0.934839 |
7 | 0.1133 | 0.106174 | 0.937742 |
8 | 0.1075 | 0.103526 | 0.938710 |
9 | 0.1039 | 0.101432 | 0.938065 |
10 | 0.1018 | 0.100493 | 0.939355 |
訓練結果令人驚嘆!我們的學生模型在驗證集上達到了93.9%的準確率,這與教師模型的94%幾乎相同,甚至接近超越,儘管學生模型的引數量只有教師模型的一半左右!
這個結果表明,透過精心調整知識蒸餾的超引數,我們可以訓練出既小又快與同樣準確的模型。讓我們將這個模型推播到Hub以供後續使用:
distil_trainer.push_to_hub("Training complete")
對蒸餾模型進行基準測試
現在我們有了一個準確的學生模型,讓我們建立一個管道並重新進行基準測試,看在測試集上的表現如何:
distilled_ckpt = "transformersbook/distilbert-base-uncased-distilled-clinc"
pipe = pipeline("text-classification", model=distilled_ckpt)
optim_type = "Distillation"
pb = PerformanceBenchmark(pipe, clinc["test"], optim_type=optim_type)
perf_metrics.update(pb.run_benchmark())
測試結果:
- 模型大小:255.89 MB
- 平均延遲:25.96 ± 1.63 毫秒
- 測試集準確率:0.868
讓我們使用之前定義的plot_metrics
函式來視覺化這些結果:
plot_metrics(perf_metrics, optim_type)
正如預期的那樣,與DistilBERT基準相比,模型大小和延遲基本保持不變,但準確率有所提高,甚至超過了教師模型的表現!這一令人驚訝的結果可能是因為教師模型的微調不如學生模型那麼系統化。