4.2 改结构:替换输出层与外挂适配器


4.2 改结构:替换输出层与外挂适配器

本节摘要:微调开工的第一个动作是改结构。两种改法:侵入式的替换输出层——直接换掉预训练模型的头,参数从零初始化、需要较大学习率;外挂式的适配器——在冻结的层间插入小型瓶颈模块,原宅一毫米不动,只训外挂。本节实现两种改法、算清各自的参数账,并说明适配器思想如何演化为第 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 层的投影之后、层归一化之前的位置,并保持全层统一;只在个别层插或位置漂移,容量与稳定性都会打折。

💡 关键直觉:替换输出层改的是"出口",适配器改的是"走廊"。改出口解决标签空间不匹配,改走廊解决特征适配——前者必做,后者是参数受限或一骨干多任务时的选择。

本节要点回顾

  • 必做动作:源任务的输出维度与目标不匹配,换头是微调的第一步
  • 换头纪律:新头从零初始化、配大学习率、旧头权重直接丢弃
  • 适配器机制:降维升维瓶颈加零初始化残差,恒等起步,单模块约七点四万参数
  • 参数与存储账:BERT-base 插满适配器只训百分之一点六参数;十任务场景存储省近九成
  • 承上启下:适配器是第 6 章 LoRA 的直系前辈——把"插入新模块"改成"给现有权重外挂低秩增量"

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U