本节摘要:微调开工的第一个动作是改结构。两种改法:侵入式的替换输出层——直接换掉预训练模型的头,参数从零初始化、需要较大学习率;外挂式的适配器——在冻结的层间插入小型瓶颈模块,原宅一毫米不动,只训外挂。本节实现两种改法、算清各自的参数账,并说明适配器思想如何演化为第 6 章的 LoRA。
预训练模型的输出层对应源任务的标签空间:视觉模型是一千类的投影,语言模型是词表的投影。你的目标任务是二分类或十分类——旧宅的房门对不上新家的门牌。不改结构的"微调"无从谈起,输出维度对不上,损失函数都算不出来。
改结构有两种哲学,对应工程上的两个决策:
import torch import torch.nn as nn from torchvision import models # 视觉:替换 ResNet 的全连接头 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) in_dim = model.fc.in_features model.fc = nn.Linear(in_dim, 8) # 八分类新头 print(f"视觉换头: {in_dim} 维 -> 8 类") # 输出: 视觉换头: 512 维 -> 8 类 # 语言:在编码器顶部加分类头(BERT 式) class TextClassifier(nn.Module): def __init__(self, encoder, hidden=768, n_classes=2): super().__init__() self.encoder = encoder self.head = nn.Sequential( nn.Dropout(0.1), nn.Linear(hidden, n_classes), ) def forward(self, ids, mask): out = self.encoder(input_ids=ids, attention_mask=mask) return self.head(out.last_hidden_state[:, 0]) # CLS 词元 head_params = 768 * 2 + 2 print(f"语言换头参数: {head_params}") # 输出: 语言换头参数: 1538
换头的两个必知细节:新头参数从零初始化,训练初期输出近乎随机,所以新头要配大学习率(骨干的十倍常见),否则新头跟不上骨干的适应节奏;预训练头的权重要彻底丢弃而不是微调——源任务的类别语义与目标任务通常无法对应。
适配器的思路:在 Transformer 每个层的注意力与全联接模块之后插入一个小瓶颈结构——先降维、非线性、再升维,外加残差通路。骨干全部冻结,只训适配器。
import torch import torch.nn as nn class Adapter(nn.Module): """瓶颈式适配器:外挂扩建的最小单元""" def __init__(self, dim=768, bottleneck=48): super().__init__() self.down = nn.Linear(dim, bottleneck) # 降维:768 -> 48 self.up = nn.Linear(bottleneck, dim) # 升维:48 -> 768 nn.init.zeros_(self.up.weight) # 关键:升维初始化为零 nn.init.zeros_(self.up.bias) # 训练开始时适配器等于恒等映射 def forward(self, x): return x + self.up(torch.relu(self.down(x))) # 残差外挂 # 参数账:一个适配器的可训练参数量 ad = Adapter() n = sum(p.numel() for p in ad.parameters()) print(f"单个适配器参数: {n:,}") # 输出: 单个适配器参数: 74,256 # 构成: 768*48 + 48(降维)+ 48*768 + 768(升维)= 36,912 + 37,344 # 整个 BERT-base 插 24 个适配器(每层两个位置)的总账 total_adapters = n * 24 total_bert = 110_000_000 print(f"24 个适配器合计: {total_adapters:,},占骨干 {total_adapters/total_bert:.2%}") # 输出: 24 个适配器合计: 1,782,144,占骨干 1.62%
两处工程细节值得咀嚼:升维层零初始化加残差通路,保证训练开始时模型行为与原预训练模型完全一致——外挂装上去那一刻,旧宅的功能分毫未损,学习从零扰动起步;瓶颈维度(这里是四十八)是容量旋钮,任务越复杂或领域差异越大,瓶颈适当加宽。
| 维度 | 替换输出层 | 适配器外挂 |
|---|---|---|
| 动到的结构 | 拆旧头装新头 | 骨干冻结,插入瓶颈模块 |
| 可训练参数(BERT-base 二分类) | 新头约 1538(骨干冻结时) | 约 178 万(骨干冻结时) |
| 表达能力上限 | 依赖冻结深度 | 瓶颈可调,上限更高 |
| 多任务存储 | 每任务一份骨干副本 | 骨干共享,每任务只存适配器 |
| 推理开销 | 与原模型相同 | 每层多两次小矩阵乘,略增 |
| 初始化安全性 | 新头随机输出 | 恒等起步,行为无损 |
# 多任务存储账:骨干共享的红利 bert_base_gb = 110e6 * 4 / 1024**3 # 每份骨干约 0.41 GB tasks = 10 replace_storage = tasks * bert_base_gb # 替换法:每任务存整个模型 adapter_storage = bert_base_gb + tasks * 24 * 74256 * 4 / 1024**3 print(f"替换法 {tasks} 任务存储: {replace_storage:.2f} GB") print(f"适配器法 {tasks} 任务存储: {adapter_storage:.2f} GB") # 输出: # 替换法 10 任务存储: 4.10 GB # 适配器法 10 任务存储: 0.48 GB # 存储省约 88%:一个骨干,多个外挂即插即用
⚠️ 常见坑:适配器插入位置乱选。标准做法是插在每个 Transformer 层的投影之后、层归一化之前的位置,并保持全层统一;只在个别层插或位置漂移,容量与稳定性都会打折。
💡 关键直觉:替换输出层改的是"出口",适配器改的是"走廊"。改出口解决标签空间不匹配,改走廊解决特征适配——前者必做,后者是参数受限或一骨干多任务时的选择。