微调
LLM 微调 (Fine-tuning): 概述
在预训练阶段,大语言模型(LLM)通过在海量通用文本数据上进行训练,学习到了广泛的语言知识、语法结构、世界常识以及一定的推理能力。然而,这些预训练模型是“通才”,它们并不针对任何特定的下游任务或特定的行为模式进行优化。
微调 (Fine-tuning) 的核心目标是:在预训练模型的基础上,使用特定任务或领域的数据集进行进一步训练,从而使模型能够更好地适应这些特定需求,表现出期望的行为或在特定任务上达到更高的性能。
可以把预训练模型想象成一个已经完成了大学通识教育的学生,而微调则像是针对特定专业方向进行的深造或职业培训。
微调的类型与目标
根据在微调过程中更新模型参数的范围和方式,主要可以将微调分为两大类:
- 全参数微调 (Full Fine-tuning): 更新模型的所有参数。
- 参数高效微调 (Parameter-Efficient Fine-Tuning, PEFT): 只更新模型中一小部分参数,或者增加少量新的可训练模块,而保持大部分预训练参数冻结。
让我们详细探讨这两种类型:
1. 全参数微调 (Full Fine-tuning)
-
核心思想: 在微调过程中,预训练模型的所有权重(包括词嵌入层、Transformer 层中的注意力权重和前馈网络权重等)都会参与梯度计算和参数更新。
-
如何操作:
- 加载预训练模型: 获取一个已经预训练好的 LLM (例如,从 Hugging Face Hub 加载一个 BERT, GPT, Llama 模型)。
- 准备特定任务的数据集: 针对你想要解决的下游任务(如情感分析、文本摘要、问答、代码生成等),准备一个标注好的数据集。
- (可选) 修改模型头部: 对于某些任务(尤其是分类任务或序列标注任务),可能需要在预训练模型的顶部添加一个新的、特定于任务的层(称为“头部”,Head)。例如,在 BERT 模型之上添加一个线性分类层来进行情感分析。对于生成式任务,通常不需要修改头部,直接用模型的语言模型头部进行生成。
- 训练: 使用特定任务的数据集,以较小的学习率继续训练整个模型(包括预训练部分和新添加的头部)。损失函数根据具体任务定义(如交叉熵损失)。
-
优点:
- 潜力巨大: 由于所有参数都参与训练,模型有最大的自由度去适应新的数据和任务,理论上可以达到最佳的性能。
- 概念简单: 比较直观,就是继续训练。
-
缺点:
- 计算成本高:
- 训练时间长: 更新所有参数需要大量的计算资源 (GPU/TPU 时间)。
- 显存占用大: 需要存储所有参数的梯度和优化器状态(例如 Adam 优化器会为每个参数存储其一阶和二阶矩估计,这会使得显存占用约为模型参数量的 3-4 倍,如果使用混合精度可能更多)。对于大型 LLM (数十亿甚至上百亿参数),即使是微调,单张 GPU 的显存也可能无法承受。
- 存储成本高: 如果你需要为多个不同的任务微调同一个基础模型,每个任务都需要存储一份完整的、经过微调的模型副本。这对于拥有大量参数的 LLM 来说,存储开销非常巨大。
- 灾难性遗忘 (Catastrophic Forgetting): 当模型在一个新任务上进行全参数微调时,它可能会“忘记”在预训练阶段学到的一些通用知识,或者在之前微调过的其他任务上的能力。这是因为所有参数都在向新任务的目标优化。
- 部署复杂性: 为每个任务维护和部署一个完整的模型副本,管理和更新会比较麻烦。
- 计算成本高:
-
适用场景:
- 当计算资源充足,且追求在特定任务上达到极致性能时。
- 当目标任务与预训练任务差异较大,需要模型进行大幅度调整时。
- 当模型规模相对较小,全参数微调的成本尚可接受时。
2. 参数高效微调 (Parameter-Efficient Fine-Tuning, PEFT)
-
核心思想: 保持预训练 LLM 的绝大部分(甚至全部)参数冻结不变,只引入和训练一小部分新的参数,或者只微调模型中已有的一小部分参数。
-
目标/动机:
- 降低计算成本: 由于只训练少量参数,训练所需的计算资源和时间显著减少。梯度计算和优化器状态的存储需求也大大降低,使得在单张消费级 GPU 上微调大型 LLM 成为可能。
- 减少存储成本: 对于每个特定任务,只需要存储那一小部分被修改或新增的参数,而不是整个模型的副本。基础的预训练模型可以被多个任务共享。例如,一个 7B 参数的模型,其 PEFT 模块可能只有几 MB 到几十 MB。
- 缓解灾难性遗忘: 由于大部分预训练参数被冻结,模型在预训练阶段学到的通用知识和能力能够得到更好的保留,从而减少在适应新任务时对原有能力的损害。
- 更容易管理多个任务的模型: 可以为每个任务训练一个轻量级的 PEFT 模块,在推理时根据任务加载对应的模块到共享的基础模型上。
- 提高数据效率: 有研究表明,在数据量较少的情况下,PEFT 方法有时能比全参数微调取得更好的效果,因为它们限制了模型的自由度,减少了过拟合的风险。
-
常见的 PEFT 方法 (我们后续会逐个详细学习):
- Adapter Tuning: 在 Transformer 层的固定预训练参数之间插入小型可训练的 "Adapter" 模块。
- Prefix Tuning: 在输入序列的嵌入层前添加可学习的连续向量前缀。
- Prompt Tuning (Soft Prompts): 类似 Prefix Tuning,但通常更轻量,只在输入嵌入前添加可学习的提示向量。
- LoRA (Low-Rank Adaptation): 通过学习低秩矩阵来近似预训练模型权重在微调时的变化。这是目前非常流行且效果显著的一种 PEFT 方法。
- (IA)^3 (Infused Adapter by Inhibiting and Amplifying Inner Activations): 通过学习缩放因子来调整预训练模型的内部激活。
- BitFit: 只微调模型中的偏置项 (bias terms)。
-
如何操作 (以 LoRA 为例的简化概念):
- 加载预训练模型并冻结其参数。
- 为模型中的某些层 (通常是注意力层或前馈网络层) 引入 LoRA 模块。 这些 LoRA 模块包含少量可训练的参数 (低秩分解矩阵 A 和 B)。
- 准备特定任务的数据集。
- 训练: 只训练这些 LoRA 模块的参数,而预训练模型的原始权重保持不变。
- 推理/部署: 可以将训练好的 LoRA 权重与原始预训练权重合并(对于某些方法如 LoRA),或者在推理时动态加载 LoRA 模块。
-
优点: (即 PEFT 的主要目标)
- 显著降低计算和存储成本。
- 有效缓解灾难性遗忘。
- 便于多任务模型的管理和部署。
- 在某些情况下性能接近甚至超过全参数微调,尤其是在低资源场景下。
-
缺点:
- 性能上限可能略低于全参数微调: 由于可训练参数的限制,在某些非常复杂的任务或需要模型进行根本性转变的情况下,PEFT 的性能上限可能略低于精心调整的全参数微调。
- 引入新的超参数: 不同的 PEFT 方法会引入各自的超参数 (例如 LoRA 中的秩
r
、alpha
值,Adapter 的瓶颈维度等),需要进行调整。 - 并非所有 PEFT 方法效果都一样好: 不同 PEFT 方法在不同模型、不同任务上的表现可能存在差异。
-
适用场景:
- 计算资源有限,尤其是显存受限的场景。
- 需要为大量不同任务微调同一个基础模型。
- 希望快速迭代和实验不同任务。
- 关注模型的便携性和部署效率。
- 希望尽可能保留预训练模型的通用能力。
总结与对比:
特性 | 全参数微调 (Full Fine-tuning) | 参数高效微调 (PEFT) |
---|---|---|
可训练参数 | 模型所有参数 | 少量新增参数或模型中一小部分已有参数 (通常 <1% of total) |
计算成本 | 高 (训练时间长,显存占用大) | 低 (训练时间短,显存占用小) |
存储成本 | 高 (每个任务一个完整模型副本) | 低 (每个任务一个小型 PEFT 模块) |
灾难性遗忘 | 风险较高 | 风险较低 |
性能潜力 | 理论上最高 | 通常接近全参数微调,有时甚至更好 (尤其低资源时),但上限可能略低 |
部署管理 | 复杂 (管理多个大模型) | 简单 (共享基础模型,加载不同 PEFT 模块) |
适用场景举例 | 追求极致性能且资源充足;任务与预训练差异大 | 资源有限;多任务场景;快速迭代;希望保留通用能力 |
理解这两种微调范式是进行 LLM 应用开发的基础。在实践中,PEFT 由于其巨大的优势,在当前的 LLM 领域变得越来越流行和重要。
全参数微调的操作细节
-
选择合适的预训练模型 (Choosing the Right Pre-trained Model):
- 任务相关性: 选择一个在其预训练任务或架构上与你的下游任务尽可能相似的模型。例如:
- 对于NLU任务(如文本分类、NER),BERT、RoBERTa、ELECTRA 等 Encoder-only 模型通常是好的起点。
- 对于文本生成任务(如摘要、翻译、对话),GPT系列、LLaMA、T5、BART 等 Decoder-only 或 Encoder-Decoder 模型更合适。
- 对于序列到序列的理解与生成任务,T5、BART 是不错的选择。
- 模型大小: 根据你的计算资源(GPU显存、训练时间预算)和对性能的要求来选择。更大的模型通常性能更好,但微调成本也更高。可以先从 base 版本开始尝试,如果资源允许再考虑 large 或更大版本。
- 预训练数据的领域: 如果有与你目标领域相关的预训练模型(例如,在医学文献上预训练的 BioBERT),可能会比通用领域预训练模型有更好的起点。
- 模型是否经过指令微调: 如果你的任务是遵循指令进行输出,选择一个已经经过指令微调的基础模型(如 Llama 2-Chat 的基础模型 Llama 2,而不是直接用 Llama 2-Chat)可能会更容易控制,因为你将进行特定任务的指令微调。如果直接用已经很“对话化”的模型进行非对话任务的微调,可能需要更小心。
- 任务相关性: 选择一个在其预训练任务或架构上与你的下游任务尽可能相似的模型。例如:
-
准备高质量的微调数据集 (Preparing High-Quality Fine-tuning Data):
- 数据质量至上: 数据的质量比数量更重要。确保标注准确、一致,数据清洗干净。低质量的数据会导致模型学到错误的信息。
- 数据格式: 将数据转换成模型输入所需的格式。这通常包括:
- 分词 (Tokenization): 使用与预训练模型相同的 Tokenizer。确保特殊 token(如
[CLS]
,[SEP]
,[PAD]
)的正确使用。 - 输入序列构建: 根据任务类型构建输入序列。例如,对于句子对分类任务,输入是
[CLS] 句子A [SEP] 句子B [SEP]
。 - 标签处理: 将标签转换为模型可以理解的格式 (例如,对于分类任务,将类别标签转换为整数索引或 one-hot 向量)。
- 分词 (Tokenization): 使用与预训练模型相同的 Tokenizer。确保特殊 token(如
- 数据量: 微调所需的数据量远少于预训练,但仍然需要足够的数据来覆盖任务的多样性。数据量不足容易导致过拟合。具体数量因任务复杂度而异,从几百到几万甚至更多不等。
- 数据划分: 将数据集划分为训练集 (Training Set)、验证集 (Validation Set) 和测试集 (Test Set)。
- 训练集: 用于训练模型参数。
- 验证集: 用于在训练过程中监控模型性能、调整超参数(如学习率、训练轮数)、进行早停 (Early Stopping)。
- 测试集: 用于在模型训练完成后评估其最终性能,测试集的数据不应在训练或验证阶段被模型看到。
- 处理类别不平衡 (Handling Class Imbalance): (如果适用) 对于分类任务,如果不同类别的样本数量差异很大,需要考虑处理方法,如过采样少数类、欠采样多数类、使用加权损失函数等。
-
模型架构调整 (Adapting Model Architecture - Task-specific Head):
- 对于大多数NLU任务 (如分类、序列标注),通常需要在预训练模型的顶部添加一个或多个新的、特定于任务的层 (head)。
- 分类任务: 通常在
[CLS]
token 对应的最终隐藏状态之上添加一个线性层 (全连接层) + Softmax (或 Sigmoid,取决于单标签/多标签分类)。 - 序列标注任务 (NER, POS): 通常在每个 token 对应的最终隐藏状态之上添加一个线性层 + Softmax,来预测每个 token 的标签。
- 问答任务 (如 SQuAD): 可能需要两个线性层来分别预测答案在原文中的起始位置和结束位置。
- 生成任务: 通常不需要修改模型头部,直接使用预训练模型的语言模型头部进行解码生成。
- 初始化新添加的层: 新添加的层通常使用随机初始化,或者使用一些启发式的初始化方法。
-
设置优化器和学习率 (Setting up Optimizer and Learning Rate):
- 优化器 (Optimizer):
- AdamW: 通常是首选优化器,它是 Adam 优化器的一个改进版本,能更好地处理权重衰减 (Weight Decay)。
- 学习率 (Learning Rate):
- 非常关键的超参数!
- 比预训练小得多: 全参数微调的学习率通常设置得比预训练时小一个或几个数量级 (例如,预训练可能是
1e-4
到5e-4
,微调可能是1e-5
到5e-5
,甚至更小如2e-5
,3e-5
)。 - 原因: 预训练模型已经学到了很多有用的知识,过大的学习率可能会破坏这些知识,导致训练不稳定或性能下降。我们希望在预训练的基础上进行“精细调整”。
- 实验确定: 最佳学习率通常需要通过实验在验证集上找到。
- 学习率调度器 (Learning Rate Scheduler):
- Warmup: 在训练初期,学习率从一个很小的值逐渐增加到设定的初始学习率。这有助于在训练早期稳定模型。
- 衰减 (Decay): Warmup 之后,学习率随着训练的进行逐渐降低 (如线性衰减、余弦衰减)。这有助于模型在训练后期更好地收敛到最优解。
- 常见的组合是 "linear warmup with linear decay" 或 "linear warmup with cosine decay"。
- 优化器 (Optimizer):
-
训练过程与超参数调整 (Training Process and Hyperparameter Tuning):
- 批处理大小 (Batch Size):
- 受限于GPU显存。在显存允许的范围内,较大的批处理大小通常能提供更稳定的梯度,但过大也可能导致泛化能力下降。
- 如果显存不足以支持期望的批处理大小,可以使用梯度累积 (Gradient Accumulation):将多个小批次的梯度累加起来,然后进行一次参数更新,等效于使用了一个更大的批处理大小。
- 训练轮数 (Number of Epochs):
- 通常不需要很多轮 (例如,2-5 轮可能就足够了,具体取决于数据集大小和任务复杂度)。训练过多的轮数容易导致过拟合。
- 早停 (Early Stopping):
- 在训练过程中,定期在验证集上评估模型性能。如果验证集上的性能在一定轮数内不再提升(甚至开始下降),就提前停止训练,以防止过拟合,并保存性能最好的那个模型。
- 权重衰减 (Weight Decay):
- 一种正则化技术,用于防止过拟合,通过在损失函数中添加一个与权重大小相关的惩罚项。AdamW 优化器内置了对权重衰减的正确处理。
- Dropout:
- 预训练模型通常已经包含了 Dropout 层。在微调时,这些 Dropout 仍然起作用,帮助防止过拟合。Dropout 概率通常保持预训练时的设置。
- 混合精度训练 (Mixed Precision Training):
- 使用 FP16 (半精度浮点数) 或 BF16 (BFloat16) 进行训练,可以显著减少显存占用并加速训练,同时尽可能保持 FP32 (单精度浮点数) 的训练精度。需要硬件和软件支持 (如 NVIDIA Apex 或 PyTorch AMP - Automatic Mixed Precision)。
- 批处理大小 (Batch Size):
-
评估与监控 (Evaluation and Monitoring):
- 选择合适的评估指标: 根据任务类型选择评估指标 (如准确率、F1 分数、精确率、召回率、BLEU、ROUGE、困惑度等)。
- 频繁验证: 在每个 epoch 结束时(或者更频繁地,例如每 N 个 steps)在验证集上进行评估,监控模型性能变化。
- 日志记录: 记录训练过程中的关键信息,如训练损失、验证损失、学习率、评估指标等,方便后续分析和调试 (可以使用 TensorBoard, Weights & Biases 等工具)。
-
保存与加载模型 (Saving and Loading Models):
- 保存最佳模型: 根据验证集上的表现,保存性能最好的模型权重 (checkpoint)。
- 保存 Tokenizer 配置: 除了模型权重,还需要保存 Tokenizer 的配置,以便在推理时能够以相同的方式处理输入。Hugging Face Transformers 库通常会将模型和 Tokenizer 一起保存。
注意事项 (Important Considerations)
-
灾难性遗忘 (Catastrophic Forgetting):
- 核心问题: 全参数微调时,模型为了适应新任务,可能会丢失在预训练阶段或之前微调任务中学到的通用知识。
- 缓解方法:
- 使用较小的学习率。
- 训练更少的轮数。
- 逐步解冻 (Gradual Unfreezing): 先冻结预训练模型的底层,只微调顶层或新添加的层,然后逐渐解冻更多层进行微调(这种方法在 CV 领域更常见,NLP 中也有尝试)。
- 多任务学习 (Multi-task Learning): 如果有多个相关任务,可以尝试同时微调。
- 弹性权重巩固 (Elastic Weight Consolidation, EWC) 等持续学习方法: 更复杂,但在特定场景下有效。
- PEFT 方法 是一个更直接有效的避免灾难性遗忘的策略。
-
过拟合 (Overfitting):
- 核心问题: 模型在训练数据上表现很好,但在未见过的验证集或测试集上表现较差。微调数据集通常较小,更容易发生过拟合。
- 缓解方法:
- 使用正则化技术: Dropout, Weight Decay。
- 早停 (Early Stopping)。
- 数据增强 (Data Augmentation): 如果适用,可以增加训练数据的多样性 (例如,文本替换、回译等,但要小心引入噪声)。
- 使用更小的模型 (如果可能)。
- 减少训练轮数。
- 交叉验证 (Cross-Validation): 在数据量非常少的情况下,可以使用交叉验证来更鲁棒地评估模型性能和选择超参数。
-
超参数搜索 (Hyperparameter Search):
- 学习率、批处理大小、训练轮数、权重衰减等超参数对微调结果影响很大。
- 通常需要进行一定的实验来找到最佳组合。可以使用网格搜索 (Grid Search)、随机搜索 (Random Search) 或更高级的贝叶斯优化等方法。
- 从合理的默认值或文献中的推荐值开始。
-
计算资源限制 (Computational Resource Constraints):
- 全参数微调大型 LLM 对 GPU 显存要求非常高。
- 梯度累积 可以帮助在显存不足时模拟大批次训练。
- 混合精度训练 可以减少显存占用和加速训练。
- 分布式训练 (Data Parallelism): 如果有多张 GPU,可以使用数据并行来分担计算负载(但每个 GPU 仍需加载完整模型,显存瓶颈仍在)。
- DeepSpeed ZeRO 或 PyTorch FSDP: 更高级的分布式训练技术,可以将模型参数、梯度和优化器状态分片到多个 GPU 上,从而支持微调远超单 GPU 显存容量的模型。
- 考虑 PEFT: 如果资源实在有限,PEFT 是更实际的选择。
-
Tokenizer 的一致性 (Tokenizer Consistency):
- 必须使用与预训练模型完全相同的 Tokenizer 进行微调和后续的推理。
- 如果 Tokenizer 不匹配,会导致输入表示错误,模型性能会急剧下降。
-
实验的可复现性 (Reproducibility):
- 设置随机种子 (Python, NumPy, PyTorch/TensorFlow) 以确保实验结果可以复现。
- 记录好所有的超参数配置和实验环境。
全参数微调虽然强大,但也需要精心设计和细致调整。理解这些细节和注意事项,将帮助你更有效地利用预训练模型的强大能力,并避免常见的陷阱。
Adapter Tuning
-
核心思想: 在预训练 Transformer 模型的每一层(或某些选定的层)中,插入一些小型的、可训练的神经网络模块,称为“适配器 (Adapters)”。在微调时,只训练这些新插入的 Adapter 模块的参数,而预训练模型的主体参数保持冻结。
-
提出动机:
- 解决全参数微调带来的高计算和存储成本问题。
- 减少灾难性遗忘,因为大部分预训练知识被保留在冻结的参数中。
- 实现一个基础模型服务于多个下游任务,每个任务只需要一个轻量级的 Adapter 模块。
Adapter 模块的结构:
一个典型的 Adapter 模块通常包含以下组件,按顺序排列:
-
下投影 (Down-projection) / 瓶颈层 (Bottleneck Layer):
- 一个全连接层(线性层),将 Transformer 层的高维输出(例如,
d_model
,如 768)投影到一个低维空间(例如,d_adapter
,如 64 或更小)。 - 这个低维空间的大小(瓶颈维度)是一个重要的超参数,它控制了 Adapter 模块的参数量。
- 作用: 压缩信息,减少后续计算量和参数量。
- 一个全连接层(线性层),将 Transformer 层的高维输出(例如,
-
非线性激活函数 (Non-linearity):
- 在下投影之后,通常会接一个非线性激活函数,如 ReLU, GeLU, SiLU 等。
- 作用: 增加模型的表达能力,使得 Adapter 能够学习更复杂的函数。
-
上投影 (Up-projection):
- 另一个全连接层,将低维空间的表示再投影回原始的 Transformer 层输出维度 (
d_model
)。 - 作用: 将 Adapter 处理后的信息恢复到与原始 Transformer 流一致的维度,以便后续的残差连接。
- 另一个全连接层,将低维空间的表示再投影回原始的 Transformer 层输出维度 (
-
残差连接 (Residual Connection):
- Adapter 模块的输出会通过一个残差连接与原始 Transformer 层的输出(即 Adapter 模块的输入)相加。
- 公式:
Output_Layer = Input_Layer + Adapter_Output
- 作用:
- 保证了即使 Adapter 初始化为零(或其输出很小),原始的预训练模型信息流仍然可以顺畅通过,使得训练更稳定,并保留预训练知识。
- 允许 Adapter 模块只学习对原始表示的“修正”或“补充”。
Adapter 模块在 Transformer 层中的位置:
Adapter 模块通常被插入到 Transformer Encoder 或 Decoder 层的两个关键位置:
-
在多头注意力 (Multi-Head Attention, MHA) 子层之后,但在第一个残差连接和层归一化 (LayerNorm) 之前。
- 更准确地说,是 MHA 的输出经过一个 Dropout 和残差连接后,再输入到 Adapter,Adapter 的输出再与 MHA 的输入进行残差连接,然后进行 LayerNorm。
- 有些实现是 MHA -> Dropout -> Add(MHA_input) -> LayerNorm -> Adapter。
- 还有一种常见的做法是:MHA -> Dropout -> Adapter -> Add(MHA_input) -> LayerNorm。
- (原始论文中是
h + Adapter(LN(h))
的形式,即先 LayerNorm 再 Adapter,但后续实现略有不同)
-
在前馈神经网络 (Feed-Forward Network, FFN) 子层之后,但在第二个残差连接和层归一化之前。
- 与 MHA 后的插入方式类似。
- FFN -> Dropout -> Adapter -> Add(FFN_input) -> LayerNorm。
示意图 (简化版,以 FFN 后的 Adapter 为例):
Input_to_FFN (来自前面的层或MHA的输出)
|
Feed-Forward Network (FFN) <-- 冻结 (Frozen)
|
Output_from_FFN
|
---------------------
| Adapter Module | <-- 只训练这里 (Trainable)
| | |
| Down-projection |
| | |
| Non-linearity |
| | |
| Up-projection |
---------------------
| (Adapter_Output)
|
Residual Connection: Output_from_FFN + Adapter_Output
|
Layer Normalization
|
Output_of_Transformer_Layer (送往下一层)
Adapter Tuning 的操作流程:
- 加载预训练模型: 获取一个预训练好的 LLM。
- 冻结预训练参数: 将 LLM 的所有原始参数设置为不可训练 (requires_grad = False)。
- 插入 Adapter 模块:
- 遍历模型中的 Transformer 层(或选定的层)。
- 在每个选定层中的指定位置(如 MHA 后和 FFN 后)实例化并插入 Adapter 模块。
- Adapter 模块的参数是可训练的 (requires_grad = True)。
- 准备特定任务的数据集。
- 训练:
- 只训练 Adapter 模块的参数以及可能的模型头部 (Task-specific Head,如果需要的话)。
- 使用较小的学习率。
- 损失函数根据具体任务定义。
- 推理/部署:
- 加载预训练模型和训练好的特定任务的 Adapter 权重。
- 或者,如果需要,可以将 Adapter 的权重“融合”回模型中(但不常见,因为 Adapter 的设计初衷就是模块化)。
优点 (回顾 PEFT 的通用优点,并针对 Adapter):
- 参数效率极高: 只需训练和存储非常少量的 Adapter 参数 (通常只占原始模型参数的 0.5% - 5%)。
- 计算成本低: 训练速度快,显存占用小。
- 模块化: 可以为每个任务训练一个独立的 Adapter,基础模型保持不变。这使得添加新任务非常方便,并且可以轻松切换不同任务的 Adapter。
- 有效缓解灾难性遗忘: 预训练模型的主体被冻结,其通用知识得以保留。
- 性能接近全参数微调: 在许多任务上,精心设计的 Adapter 能够达到与全参数微调相近甚至相当的性能,尤其是在中等规模的数据集上。
缺点与挑战:
- 引入额外的推理延迟: 虽然 Adapter 模块很小,但在每一层都进行额外的计算(两次线性投影和一次激活)会不可避免地增加推理时的总计算量和延迟。对于延迟敏感的应用,这可能是一个问题。
- 最佳插入位置和结构: Adapter 应该插入到哪些层?是所有层还是部分层?Adapter 内部的结构(瓶颈维度、激活函数)如何选择?这些都需要实验和调整。
- 瓶颈维度的选择:
- 瓶颈维度太小,Adapter 的表达能力可能不足,无法充分适应新任务。
- 瓶颈维度太大,参数量增加,失去了 PEFT 的部分优势,也可能导致过拟合。
- 与某些模型架构的兼容性: 虽然 Adapter 的思想很通用,但在特定模型架构上实现和优化可能需要额外的工作。
变体与发展:
Adapter Tuning 的思想催生了很多后续的研究和改进:
- AdapterFusion (Pfeiffer et al., 2020): 提出了一种在推理时组合多个已训练好的任务 Adapter 的方法,以期在目标任务上获得更好的性能,或者实现零样本跨任务泛化。它通过学习一个注意力机制来动态地加权不同 Adapter 的输出。
- AdapterDrop (Rücklé et al., 2020): 提出在训练和推理时随机丢弃一些 Adapter 层,以提高效率和鲁棒性,减少推理延迟。
- LoRA (Low-Rank Adaptation): 虽然机制不同(不是插入新模块,而是修改现有权重的更新方式),但其目标和很多优点与 Adapter 类似,并且在实践中表现非常出色,可以看作是 PEFT 发展的一个重要方向。
- (IA)^3 (Infused Adapter by Inhibiting and Amplifying Inner Activations): 也是一种轻量级的调整方式,通过学习三个缩放向量来调整 FFN 和注意力机制中的激活值。
总结:
Adapter Tuning 是一种开创性的 PEFT 方法,它通过在预训练模型中插入小型可训练模块,实现了在保持大部分预训练参数不变的情况下,高效地将模型适应到下游任务。它显著降低了微调的成本,并为后续更先进的 PEFT 技术提供了宝贵的思路。
理解 Adapter 的核心思想——“冻结主干,插件式学习”——对于掌握其他 PEFT 方法也非常有帮助。
Adapter Tuning 的一些具体实现细节
1. Adapter 模块的参数初始化
- 目标: Adapter 模块在训练开始时不应该对预训练模型的原始行为产生大的干扰。理想情况下,如果 Adapter 的输出接近于零,那么由于残差连接的存在,原始 Transformer 层的输出将几乎不受影响。
- 常见做法:
- 下投影层 (Down-projection) 的权重: 通常使用接近于零的初始化,例如从一个均值为0、标准差较小(如0.01或更小)的正态分布中采样。或者使用 Xavier/Kaiming 初始化,然后乘以一个很小的常数。
- 下投影层的偏置 (Bias): 通常初始化为零。
- 上投影层 (Up-projection) 的权重: 关键在于确保其初始输出接近于零。 因此,上投影层的权重通常也初始化为零,或者从一个非常小的分布中采样。
- 上投影层的偏置 (Bias): 通常初始化为零。
- 效果: 这样的初始化策略使得在训练初期,
Adapter_Output
非常小,Output_Layer ≈ Input_Layer
(这里的Input_Layer
指的是 Adapter 模块的输入,即原始 Transformer 子层的输出)。这有助于训练的稳定性,并让模型从一个更接近预训练模型行为的状态开始学习。
2. 瓶颈维度 (Bottleneck Dimension / d_adapter
) 的选择
- 这是 Adapter 模块最重要的超参数之一。它直接决定了 Adapter 的参数量和潜在的表达能力。
- 参数量计算: 如果原始 Transformer 层的维度是
d_model
,瓶颈维度是d_adapter
,那么一个 Adapter 模块(包含下投影和上投影)的参数量大约是2 * d_model * d_adapter + d_adapter + d_model
(权重 + 偏置)。可以看到,d_adapter
越小,参数量越少。 - 选择范围:
d_adapter
通常远小于d_model
。常见的值可能在 16, 32, 64, 128, 256 等,具体取决于d_model
的大小和任务的复杂度。- 例如,对于
d_model = 768
(如 BERT-base),d_adapter
可能选择 32 或 64。 - 对于
d_model = 1024
(如 BERT-large),可能会选择 64 或 128。
- 例如,对于
- 权衡:
- 较小的
d_adapter
: 参数更少,训练更快,存储更小,更不容易过拟合,但可能表达能力不足,无法充分学习任务特征。 - 较大的
d_adapter
: 参数更多,表达能力更强,但训练和存储成本增加,也更容易过拟合,尤其是在数据量较少的情况下。
- 较小的
- 实践: 通常需要通过实验在验证集上调整
d_adapter
来找到最佳值。可以从一个较小的值开始尝试,然后逐渐增加。
3. 非线性激活函数的选择
- 常见的选择包括 ReLU, GeLU, SiLU (Swish), Tanh 等。
- GeLU (Gaussian Error Linear Unit) 和 SiLU 是现代 Transformer 模型中常用的激活函数,因为它们在实践中表现良好。
- 选择哪个激活函数通常不是 Adapter 性能的关键瓶颈,但与预训练模型主体使用的激活函数保持一致性或者选择一个被广泛证明有效的激活函数是比较稳妥的做法。
4. Adapter 的插入位置与策略
- 标准位置: 如前所述,通常在每个 Transformer 层的 MHA 子层之后和 FFN 子层之后各插入一个 Adapter 模块。
- 变体:
- 只在 FFN 后插入: 有些研究发现,只在 FFN 子层后插入 Adapter 也能达到不错的效果,并且可以进一步减少参数量和推理延迟。
- Pfeiffer
style Adapters (AdapterDrop 论文中提到的一种更轻量级的配置):
- 将 Adapter 模块设计为
LN -> Down-proj -> ReLU -> Up-proj -> Dropout -> Add
,然后这个整体替换掉原始的 FFN 层,或者在 FFN 之前/之后加入。 - 或者只在 FFN 内部的两个线性层之间插入一个更小的 Adapter 结构。
- 将 Adapter 模块设计为
- 只在顶层或部分层插入: 对于某些任务,可能不需要在所有 Transformer 层都插入 Adapter,只在模型的顶几层或对任务贡献较大的层插入可能就足够了。这需要具体实验。
- 串行 vs. 并行 Adapter (AdapterFusion 的概念):
- 串行 (Sequential): 标准的 Adapter 插入方式,Adapter 模块按顺序处理信息。
- 并行 (Parallel): AdapterFusion 提出可以将多个任务的 Adapter 并行放置,并通过一个学习到的门控机制来融合它们的输出。这主要用于组合多个已训练的 Adapter。
5. Layer Normalization (层归一化) 的处理
- 原始 Transformer 层中的 LayerNorm 保持冻结: 通常,预训练模型中原有的 LayerNorm 层的参数 (gamma 和 beta) 是保持冻结的,因为它们也包含了预训练学到的重要统计信息。
- Adapter 内部是否需要 LayerNorm:
- 大多数经典的 Adapter 设计中,Adapter 模块内部不包含额外的 LayerNorm 层。
- 但也有一些变体或更复杂的 Adapter 设计可能会在 Adapter 内部的特定位置加入 LayerNorm,但这会增加参数量和复杂性。
- 主要的 LayerNorm 操作还是依赖于 Transformer 主干网络中的 LayerNorm 层。
6. 训练细节
- 学习率: 通常比全参数微调还要小一些,或者在相似的范围内 (例如
1e-4
到5e-4
,或者5e-5
到3e-4
)。因为 Adapter 参数量少,可能对学习率更敏感。也需要实验调整。 - 优化器: AdamW 仍然是常用的选择。
- 批处理大小和训练轮数: 与全参数微调类似,根据任务和数据量调整。由于参数少,可能收敛更快,需要的轮数也可能更少。
- 只训练 Adapter 参数: 务必确保只有 Adapter 模块的参数
requires_grad = True
,而预训练模型的主体参数requires_grad = False
。可以通过遍历模型参数并检查其名称或类型来设置。 - 模型头部 (Task-specific Head): 如果任务需要一个分类头或其他特定任务的头部,这个头部的参数通常也是可训练的,并且与 Adapter 参数一起训练。
7. 实现库和框架
adapter-transformers
库: 这是一个基于 Hugging Facetransformers
库的扩展,专门用于方便地使用各种 Adapter 方法(包括原始 Adapter, AdapterFusion, AdapterDrop 等)。它极大地简化了 Adapter 的加载、插入和训练过程。如果你想实际使用 Adapter,强烈推荐了解这个库。- 它允许你通过简单的配置字符串来加载带有特定 Adapter 配置的模型。
- 支持 Adapter Hub,可以共享和下载预训练的 Adapter 模块。
- Hugging Face
peft
库: 虽然peft
库更侧重于 LoRA, Prefix Tuning, Prompt Tuning 等,但 Adapter 的思想是这些方法的基础。理解 Adapter 有助于理解peft
库中其他方法的动机。一些更现代的、与 Adapter 思想类似的轻量级调整方法也可能被peft
支持。
示例:如何检查哪些参数是可训练的 (PyTorch)
# 假设 model 是你加载并插入了 Adapter 的模型
total_params = 0
trainable_params = 0
for name, param in model.named_parameters():
total_params += param.numel()
if param.requires_grad:
trainable_params += param.numel()
print(f"Trainable parameter: {name}, Shape: {param.shape}")
print(f"Total parameters: {total_params}")
print(f"Trainable parameters: {trainable_params}")
print(f"Percentage of trainable parameters: {100 * trainable_params / total_params:.2f}%")
通过运行类似上面的代码,你可以验证是否只有 Adapter 模块(以及可能的分类头)的参数是可训练的。
总结一下实现细节的关键点:
- 精心初始化 Adapter 权重,使其初始输出接近零。
- 合理选择瓶颈维度,平衡参数量和表达能力。
- 通常在 MHA 和 FFN 之后插入 Adapter。
- 冻结预训练模型主体,只训练 Adapter 和任务头。
- 使用较小的学习率。
- 利用现有库 (如
adapter-transformers
) 可以大大简化实现。
理解这些细节能帮助你在实际项目中更有效地应用和调试 Adapter Tuning。这是一个非常优雅且实用的 PEFT 方法。
核心概念:软提示 (Soft Prompts) vs. 硬提示 (Hard Prompts)
在我们之前讨论 Prompt 工程时,主要指的是“硬提示”,即人工设计的、离散的文本指令。
-
硬提示 (Hard Prompts):
- 由人类设计的自然语言文本。
- 例如:"将以下英文翻译成中文:"
- 优点:直观,易于理解和创建。
- 缺点:设计优秀硬提示需要技巧和大量实验;离散的 token 可能不是最优的引导方式;难以通过梯度下降进行端到端的优化。
-
软提示 (Soft Prompts) / 连续提示 (Continuous Prompts):
- 不是离散的文本 token,而是一系列可学习的连续向量 (embeddings)。
- 这些向量与模型的词嵌入向量具有相同的维度。
- 它们作为模型输入的一部分,但其值是通过梯度下降在训练过程中学习得到的,而不是人工指定的。
- 优点:
- 可以通过梯度直接优化,可能比人类设计的硬提示更有效。
- 参数量非常小。
- 为模型提供了更灵活的引导方式。
Prefix Tuning 和 Prompt Tuning 都属于使用软提示的方法。
Prefix Tuning
-
核心思想: 在 Transformer 模型的每一层的 Key (K) 和 Value (V) 向量前,都添加一段可学习的、特定于任务的向量序列(称为 "prefix")。这些 prefix 向量作为上下文,引导模型在该层注意力机制的行为。
-
提出者: Li and Liang, 2021 ("Prefix-Tuning: Optimizing Continuous Prompts for Generation")
-
针对的模型架构: 最初主要针对自回归模型 (如 GPT) 和 Encoder-Decoder 模型 (如 BART, T5) 的文本生成任务进行优化。
-
如何工作 (以 Decoder-only 模型如 GPT 为例):
- 预训练模型参数冻结: 原始 Transformer 模型的权重保持不变。
- Prefix 向量:
- 为 Transformer 的每一层都创建一组可学习的 prefix 向量。
- 这些 prefix 向量的长度
L_p
是一个超参数。 - 每个 prefix 向量的维度与该层 Key 和 Value 向量的维度相同 (
d_k
或d_model / num_heads
)。 - 所以,对于一个有
N
层的 Transformer 模型,总共需要学习N * 2 * L_p * d_k
个参数(2 代表 Key 和 Value 各有一组 prefix)。
- 修改注意力计算:
- 在原始的注意力计算
Attention(Q, K, V)
中:- 原始的 Key 序列
K
变为[P_k; K]
(其中P_k
是该层的可学习 Key prefix 向量序列)。 - 原始的 Value 序列
V
变为[P_v; V]
(其中P_v
是该层的可学习 Value prefix 向量序列)。 - Query (Q) 仍然由输入序列的隐藏状态产生。
- 原始的 Key 序列
- 这样,当计算注意力时,Query 会与这些可学习的 prefix Key 进行交互,并从 prefix Value 中提取信息。
- 在原始的注意力计算
- Prefix 向量的参数化:
- 直接为每一层学习独立的 prefix 向量参数量可能仍然较大。
- 论文中提出使用一个较小的重参数化网络 (reparameterization network),通常是一个小型的 MLP(例如,输入是 prefix 的索引,输出是 prefix 向量),来生成实际的 prefix 向量。这可以减少直接存储和学习的参数数量,并可能提高稳定性。但更简单的实现是直接学习这些 prefix 向量。
- 训练: 只训练这些 prefix 向量(或者重参数化网络的参数)。
-
图示理解 (简化版,单层注意力):
原始输入序列: X = [x_1, x_2, ..., x_n] 经过Embedding和前面层处理得到: H = [h_1, h_2, ..., h_n] (作为Q, K, V的来源) 可学习的Prefix (长度 L_p): P_k = [pk_1, pk_2, ..., pk_Lp] (Key Prefix) P_v = [pv_1, pv_2, ..., pv_Lp] (Value Prefix) 注意力计算时: Query 依然来自 H (例如 q_i 来自 h_i) Key' = Concat(P_k, K_from_H) Value' = Concat(P_v, V_from_H) Attention_Output = Attention(Query, Key', Value')
这个过程在 Transformer 的每一层都会发生,每一层都有自己独立的、可学习的
P_k
和P_v
。 -
优点:
- 参数高效: 只需学习 prefix 向量,参数量远小于全参数微调 (例如,对于 GPT-2 Medium,参数量可以减少1000倍以上)。
- 性能接近全参数微调: 在文本生成任务上,尤其是在低数据场景下,Prefix Tuning 能够达到与全参数微调相当甚至更好的性能。
- 模块化: 每个任务对应一组 prefix 向量,易于管理。
- 不修改模型主干: 预训练模型完全冻结。
-
缺点与挑战:
- 在每一层都添加前缀: 尽管每个前缀向量维度不高,但层数多了,总的 prefix 参数量相对于更轻量的 Prompt Tuning 还是会多一些。
- 训练稳定性: 学习这些连续向量有时可能不稳定,重参数化技巧有助于缓解。
- 理解其工作机制: 连续向量如何精确地引导模型行为,其可解释性不如硬提示。
Prompt Tuning (Soft Prompts)
-
核心思想: 只在模型的输入嵌入层 (input embedding layer) 前添加一段可学习的、特定于任务的连续向量序列 (soft prompt)。这些 soft prompt 向量会与原始的输入 token 嵌入拼接在一起,共同作为 Transformer 第一层的输入。与 Prefix Tuning 不同,Prompt Tuning 不在模型的每一层内部添加可学习参数。
-
提出者: Lester et al., 2021 ("The Power of Scale for Parameter-Efficient Prompt Tuning")
-
针对的模型架构: 可以应用于各种 Transformer 架构 (Encoder-only, Decoder-only, Encoder-Decoder)。
-
如何工作:
- 预训练模型参数冻结: 原始 Transformer 模型的权重保持不变。
- Soft Prompt 向量:
- 创建一组可学习的 prompt 向量
P_emb = [p_1, p_2, ..., p_L_prompt]
。 - 这些 prompt 向量的长度
L_prompt
是一个超参数 (例如,5, 20, 100 个 prompt token)。 - 每个 prompt 向量
p_i
的维度与模型的词嵌入向量维度相同 (d_model
)。
- 创建一组可学习的 prompt 向量
- 修改模型输入:
- 原始输入 token 序列
X = [x_1, x_2, ..., x_n]
经过词嵌入层得到E_X = [e_x1, e_x2, ..., e_xn]
。 - 将可学习的 soft prompt 向量
P_emb
与E_X
拼接起来,形成新的输入嵌入序列:Input_Embeddings' = Concat(P_emb, E_X) = [p_1, ..., p_L_prompt, e_x1, ..., e_xn]
- 这个拼接后的嵌入序列将作为 Transformer 第一层的输入。
- 原始输入 token 序列
- 训练: 只训练这些 soft prompt 向量
P_emb
的参数。
-
图示理解:
可学习的Soft Prompt (长度 L_prompt): P_emb = [p_1, p_2, ..., p_L_prompt] (每个 p_i 是一个 d_model 维向量) 原始输入Token序列: X = [x_1, x_2, ..., x_n] 经过词嵌入层: E_X = [e_x1, e_x2, ..., e_xn] 拼接后的输入 (送入Transformer第一层): Input_Embeddings' = [p_1, ..., p_L_prompt, e_x1, ..., e_xn]
-
优点:
- 极其参数高效: 参数量是所有 PEFT 方法中最少的之一。只需要学习
L_prompt * d_model
个参数。例如,对于一个 100B 参数的模型,如果L_prompt=20
且d_model=4096
(典型值),那么可训练参数只有约 8 万个,几乎可以忽略不计。 - 实现简单: 概念和实现都非常直接。
- 易于存储和共享: 每个任务只需要存储非常小的 prompt 向量。
- 随着模型规模增大效果越好: 论文 "The Power of Scale for Parameter-Efficient Prompt Tuning" 表明,当预训练模型规模足够大时(例如超过 10B 参数),Prompt Tuning 的性能可以追平甚至超过全参数微调。对于较小的模型,Prompt Tuning 的效果可能不如全参数微调或其他 PEFT 方法(如 Adapter 或 LoRA)。
- 不修改模型主干,推理时无额外计算层: 与 Adapter 不同,Prompt Tuning 不在模型内部增加计算层,只是增加了输入序列的有效长度,因此推理时的计算开销增加非常小 (主要来自序列长度增加)。
- 极其参数高效: 参数量是所有 PEFT 方法中最少的之一。只需要学习
-
缺点与挑战:
- 对小模型效果可能不佳: 对于规模较小的预训练模型,Prompt Tuning 可能难以达到与全参数微调或其他更“重”的 PEFT 方法相当的性能。模型的表达能力可能不足以仅通过输入端的少量提示向量就被充分引导。
- 初始化和优化敏感性: Prompt 向量的初始化方法和优化过程可能对最终性能有较大影响。
- Prompt 长度的选择:
L_prompt
的长度是一个需要调整的超参数。太短可能不足以引导模型,太长则增加少量参数并可能引入不必要的复杂性。 - 可解释性依然是挑战。
Prefix Tuning vs. Prompt Tuning (Soft Prompts) - 关键区别总结:
特性 | Prefix Tuning | Prompt Tuning (Soft Prompts) |
---|---|---|
可学习参数位置 | 在 Transformer 每一层的 Key 和 Value 前添加前缀 | 只在输入嵌入层前添加提示向量 |
影响范围 | 影响每一层的注意力计算 | 只直接影响第一层的输入,间接影响后续层 |
参数量 | 相对较少 (但比 Prompt Tuning 多) | 极少 |
对模型结构的修改 | 需要修改每一层的注意力输入 | 只需要修改模型的最终输入嵌入序列 |
对小模型性能 | 通常比 Prompt Tuning 在小模型上表现更好 | 在小模型上性能可能不如 Prefix Tuning 或其他 PEFT 方法 |
对大模型性能 | 表现良好 | 在超大模型上可以媲美全参数微调 |
推理开销增加 | 略微增加 (因每一层都有额外 K, V) | 极小增加 (因输入序列变长) |
哪个更好?
- 如果模型规模巨大 (例如 >10B 参数),且追求极致的参数效率和简单性,Prompt Tuning 是一个非常有吸引力的选择。
- 如果模型规模适中,或者任务比较复杂,Prefix Tuning 或其他 PEFT 方法 (如 Adapter, LoRA) 可能提供更强的性能,尽管参数量比 Prompt Tuning 多一些。
- 实际选择通常需要根据具体模型、任务、数据量和计算资源进行实验评估。
P-Tuning (v1 & v2):
值得一提的是,与 Prompt Tuning 和 Prefix Tuning 相关的还有 P-Tuning (Liu et al., 2021) 及其后续的 P-Tuning v2 (Liu et al., 2022)。
- P-Tuning (v1): 类似于 Prompt Tuning,但在输入端使用一个小的 LSTM 或 MLP 来生成可学习的 prompt 嵌入,并发现其在 NLU 任务上效果较好,但对于复杂的序列到序列任务效果不如 Prefix Tuning。
- P-Tuning v2: 更接近于 Prefix Tuning 的思想,将可学习的 prompt 向量也应用到模型的每一层 (或者说,让每一层都能直接访问和利用这些 prompt token),而不仅仅是输入层。它旨在结合 Prefix Tuning 的性能优势和 Prompt Tuning 的简单性与稳定性,并声称在各种模型规模和任务上都能取得良好效果,且实现更简单。可以看作是深度版本的 Prompt Tuning,或者一种更精简的 Prefix Tuning 实现。
这些方法都体现了通过操纵模型的输入表示或内部激活来高效引导大型预训练模型的共同趋势。