3. 中间层蒸馏:隐藏状态对齐、注意力迁移与表征匹配实战
前面我们聊了 Logit 层面的蒸馏,说白了就是让 Student 模仿 Teacher 的最终输出。但只学结果,不学过程,总觉得差点意思。中间层蒸馏,就是要把 Teacher 的“思考过程”也教给 Student。
我个人习惯把中间层蒸馏分成三个层次:隐藏状态对齐、注意力矩阵迁移、表征匹配。它们分别对应 Transformer 里的不同组件,难度和收益也各不相同。
3.1 隐藏状态对齐:用 MSE 拉近特征空间
最直接的做法,就是让 Student 的中间层输出,在数值上尽量接近 Teacher 的对应层。损失函数通常用 MSE(均方误差)。
核心公式:L_hidden = MSE(H_teacher, H_student · W_proj)
其中 W_proj 是一个可学习的线性投影层,用来把 Student 的维度映射到 Teacher 的维度。
为什么需要投影层?因为 Student 的隐藏层维度通常比 Teacher 小。比如 Teacher 是 768 维,Student 是 384 维,直接算 MSE 维度不匹配。加一个线性层,相当于做了一次“翻译”。
我的经验:投影层初始化用单位矩阵附近的值,收敛会快很多。我曾经试过随机初始化,结果前几个 epoch 损失降不下去,后来发现是投影层把特征空间扭曲得太厉害了。
具体实现时,你需要决定对齐哪些层。常见策略有两种:
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 均匀对齐 | 每 k 层对齐一次,比如 Teacher 每 2 层对齐 Student 每 1 层 | 层数差异大时(如 Teacher 12 层,Student 6 层) |
| 关键层对齐 | 只对齐最后几层或特定层(如第 6、9、12 层) | 计算资源有限,或只想保留高层语义 |
代码实现其实不复杂:
class HiddenStateLoss(nn.Module):
def __init__(self, teacher_dim, student_dim):
super().__init__()
self.proj = nn.Linear(student_dim, teacher_dim)
self.mse = nn.MSELoss()
def forward(self, h_teacher, h_student):
# h_teacher: [batch, seq_len, teacher_dim]
# h_student: [batch, seq_len, student_dim]
h_student_proj = self.proj(h_student)
return self.mse(h_teacher, h_student_proj)
避坑指南:我曾经在项目里把 Teacher 和 Student 的隐藏状态直接对齐,结果发现 Student 的梯度爆炸了。原因很简单——Teacher 的隐藏状态数值范围可能很大,MSE 损失直接回传会导致 Student 的投影层梯度巨大。解决办法:对 Teacher 的隐藏状态做 LayerNorm 或除以标准差。
3.2 注意力矩阵迁移:让 Student 学会“看哪里”
Transformer 里,注意力矩阵决定了模型“关注”哪些位置。如果 Student 能学到 Teacher 的注意力模式,那效果往往比单纯对齐隐藏状态更好。
注意力迁移的核心思想:让 Student 的注意力矩阵尽量接近 Teacher 的注意力矩阵。损失函数通常用 KL 散度或 MSE。
关键点:对齐的是 注意力分布(softmax 之后的值),而不是注意力分数(softmax 之前的值)。因为分布才有概率意义,分数没有。
我建议用 KL 散度,因为它对概率分布更敏感:
def attention_transfer_loss(A_teacher, A_student):
# A_teacher, A_student: [batch, num_heads, seq_len, seq_len]
# 对每个 head 和每个位置计算 KL 散度
loss = F.kl_div(
F.log_softmax(A_student, dim=-1),
F.softmax(A_teacher, dim=-1),
reduction='batchmean'
)
return loss
实际操作中,有几个细节要注意:
- 多头对齐:Teacher 有 12 个头,Student 只有 8 个头,怎么对齐?可以取 Teacher 的前 8 个头,或者用线性映射把 8 个头映射到 12 个头。
- 层选择:通常对齐中间几层的注意力矩阵效果最好。太浅的层注意力比较分散,太深的层注意力已经高度抽象。
- 温度参数:给 softmax 加一个温度 T,让注意力分布更平滑。T 越大,分布越均匀,梯度越稳定。
我的习惯:温度 T 设成 2.0 起步,然后慢慢降到 1.0。这样 Student 一开始能学到 Teacher 的“大致关注区域”,后期再精细化。我在 BERT 蒸馏项目里试过,比固定温度高了 0.5 个点的 GLUE 分数。
3.3 Representation Matching:更高阶的表征对齐
隐藏状态对齐和注意力迁移,都是“点对点”的匹配。Representation Matching 更进一步,它关注的是表征空间的结构关系。
说白了,就是让 Student 的表征空间和 Teacher 的表征空间在“几何结构”上相似。常用的方法有:
| 方法 | 核心思想 | 损失函数 |
|---|---|---|
| CCA(典型相关分析) | 最大化 Teacher 和 Student 表征的线性相关性 | 1 - CCA 相关系数 |
| CKA(中心核对齐) | 比较两个表征空间的相似性矩阵 | 1 - CKA 相似度 |
| 对比学习 | 让同一句话的表征靠近,不同句子的表征远离 | InfoNCE 损失 |
我个人比较推荐 CKA,因为它对线性变换不敏感,而且计算相对简单。CKA 的核心是计算两个表征的 Gram 矩阵,然后比较它们的相似度:
def cka_loss(H_teacher, H_student):
# H: [batch, seq_len, dim]
# 计算 Gram 矩阵
G_teacher = H_teacher @ H_teacher.transpose(-2, -1)
G_student = H_student @ H_student.transpose(-2, -1)
# 中心化
G_teacher = G_teacher - G_teacher.mean(dim=-1, keepdim=True)
G_student = G_student - G_student.mean(dim=-1, keepdim=True)
# 计算 CKA
numerator = (G_teacher * G_student).sum()
denominator = torch.sqrt((G_teacher**2).sum() * (G_student**2).sum())
cka = numerator / denominator
return 1 - cka
注意:CKA 的计算复杂度是 O(n²d),其中 n 是序列长度,d 是维度。对于长序列(比如 512),计算 Gram 矩阵会非常吃显存。我建议在 batch 内随机采样一些 token 对来计算,而不是用整个序列。
3.4 三种方法的对比与选择
这三种方法不是互斥的,实际项目中往往组合使用。我整理了一个对比表,方便你根据场景选择:
| 方法 | 计算开销 | 效果提升 | 实现难度 | 推荐场景 |
|---|---|---|---|---|
| 隐藏状态对齐(MSE) | 低 | 中 | 低 | 快速 baseline,资源受限 |
| 注意力迁移 | 中 | 高 | 中 | 分类、匹配任务 |
| Representation Matching | 高 | 最高 | 高 | 生成任务、语义相似度 |
我的建议是:先用 MSE 对齐隐藏状态作为基础,然后加上注意力迁移,最后根据任务需求决定是否上 Representation Matching。不要一上来就全上,否则调参会让你怀疑人生。
实战组合拳:我在一个文本分类项目里,用了“MSE + 注意力迁移”的组合,Student 模型(6 层 BERT)在 8 个分类任务上平均只比 Teacher(12 层 BERT)低了 0.3 个点,但推理速度提升了 2 倍。如果只做 Logit 蒸馏,差距是 0.8 个点。中间层蒸馏的价值,就在这里。
3.5 整体流程与 SVG 框架图
下面这张图展示了中间层蒸馏的完整流程。你可以看到,Teacher 和 Student 的每一层都可以对齐,但实际中我们只选关键层。
从图中可以看到,三种对齐方式可以同时进行,也可以只选一种。我个人习惯是:先对齐中间层(注意力迁移),再对齐高层(MSE 或 CKA)。这样 Student 既能学到局部的注意力模式,又能学到全局的语义表征。
最后说一句:中间层蒸馏不是银弹。如果你的 Student 模型太小(比如只有 2 层),强行对齐中间层反而会限制 Student 的表达能力。这时候,专注 Logit 蒸馏可能效果更好。嗯,具体问题具体分析,永远是工程实践的第一原则。