- Published on
从预训练到参数高效微调:LoRA、Adapter 与 QLoRA 完全指南
- Authors

- Name
- Allen Wang
从预训练到参数高效微调:LoRA、Adapter 与 QLoRA 完全指南
🎯 本文目标:如果你是一个对大模型微调感到好奇但又不知从何入手的开发者,这篇文章就是为你写的。我们将从最基础的"什么是模型推理"开始,一步步走到参数高效微调(PEFT)的前沿技术。
📑 目录
- 一、为什么需要微调?
- 二、HuggingFace 入门:模型加载与推理
- 三、预训练:LLM 是怎么诞生的
- 四、全量微调:最直觉的方法
- 五、微调策略对比实验:全量 vs 仅最后一层
- 六、Adapter:轻量级模块化适配
- 七、LoRA:低秩矩阵的魔法
- 八、QLoRA:量化 + LoRA 的极致效率
- 九、全局对比:该选哪种方法?
- 十、总结与实践建议
一、为什么需要微调?
想象一下这个场景:你下载了一个预训练好的 GPT-2 模型,想让它帮你做情感分析——判断电影评论是正面还是负面的。你直接输入一段评论,模型会怎样?
from transformers import pipeline
generator = pipeline("text-generation", model="gpt2")
result = generator("This movie was really", max_length=20, num_return_sequences=1)
print(result[0]['generated_text'])
# 输出可能是: "This movie was really good. I was so excited to see it..."
# 但它不会告诉你"正面"还是"负面"!
预训练模型就像一个博学但没有专业方向的通才——它知道很多关于语言的知识,但并不擅长某个特定任务。这就是为什么我们需要微调(Fine-tuning)。
微调的核心思想
预训练模型(通用语言知识) + 特定任务数据(如情感分析) = 专业化模型
但问题来了:直接修改整个模型的所有参数(全量微调)代价很高:
| 挑战 | 说明 |
|---|---|
| 💰 计算成本 | GPT-3 有 1750 亿参数,全量微调需要海量 GPU |
| 📦 存储成本 | 每个任务一份完整模型副本 |
| 🔥 过拟合风险 | 小数据集上全量微调容易过拟合 |
| ⏰ 训练时间 | 大模型训练一次可能需要几天 |
这就引出了本文的主角——**参数高效微调(Parameter-Efficient Fine-Tuning, PEFT)**方法:LoRA、Adapter、QLoRA。它们的核心理念是:冻结大部分预训练参数,只训练一小部分新增参数。
但在深入这些高级方法之前,让我们先从基础开始。
二、HuggingFace 入门:模型加载与推理
在动手微调之前,你得先学会加载和使用预训练模型。HuggingFace 的 transformers 库是目前最流行的工具。
2.1 不用 pipeline 的手动推理流程
让我们先看看"底层"发生了什么。以情感分析为例:
from transformers import DistilBertForSequenceClassification, DistilBertTokenizer
import torch
# Step 1: 加载 tokenizer 和模型
tokenizer = DistilBertTokenizer.from_pretrained(
"distilbert-base-uncased-finetuned-sst-2-english"
)
model = DistilBertForSequenceClassification.from_pretrained(
"distilbert-base-uncased-finetuned-sst-2-english"
)
# Step 2: 文本 → Token IDs
text = "This movie is absolutely amazing!"
inputs = tokenizer(text, return_tensors="pt")
# inputs 包含: input_ids(token索引)和 attention_mask(注意力掩码)
# Step 3: 前向传播
with torch.no_grad(): # 推理时不需要计算梯度
outputs = model(**inputs)
# Step 4: Logits → 预测结果
logits = outputs.logits # 原始输出,shape: [1, 2]
probs = torch.softmax(logits, dim=-1) # 转换为概率
predicted_class = torch.argmax(probs, dim=-1)
labels = ["NEGATIVE", "POSITIVE"]
print(f"预测结果: {labels[predicted_class]}")
# 输出: 预测结果: POSITIVE
💡 理解关键概念
- Tokenizer:将文本切分成 token 并转换为数字 ID
- input_ids:每个 token 对应的词汇表索引
- attention_mask:告诉模型哪些位置是真实 token(1),哪些是 padding(0)
- logits:模型输出的原始分数(未经过 softmax 归一化)
🎨 交互式演示:下面的可视化展示了 Tokenizer 的完整工作流程——从原始文本到 Token IDs 的转换过程。
2.2 pipeline 函数:一行搞定推理
上面的 4 个步骤,用 pipeline 可以浓缩成:
from transformers import pipeline
# 情感分析
classifier = pipeline(
"text-classification",
model="distilbert-base-uncased-finetuned-sst-2-english"
)
result = classifier("This movie is absolutely amazing!")
print(result)
# [{'label': 'POSITIVE', 'score': 0.9998}]
2.3 pipeline 支持的常见任务
| 任务类型 | task 参数 | 用途 |
|---|---|---|
| 文本分类 | text-classification | 情感分析、垃圾邮件检测 |
| 文本生成 | text-generation | 创意写作、对话生成 |
| 填空预测 | fill-mask | 预测被遮盖的词 |
| 问答 | question-answering | 基于上下文回答问题 |
| 摘要 | summarization | 长文本摘要 |
| 翻译 | translation_en_to_fr | 语言翻译 |
| 命名实体识别 | ner | 识别人名、地名等 |
| 零样本分类 | zero-shot-classification | 无需训练数据的分类 |
2.4 文本生成实战:GPT-2
from transformers import GPT2LMHeadModel, GPT2Tokenizer
# 手动加载
tokenizer = GPT2Tokenizer.from_pretrained("gpt2")
model = GPT2LMHeadModel.from_pretrained("gpt2")
prompt = "Once upon a time"
inputs = tokenizer(prompt, return_tensors="pt")
output_ids = model.generate(
inputs.input_ids,
attention_mask=inputs.attention_mask,
pad_token_id=tokenizer.eos_token_id,
max_length=50,
num_return_sequences=1
)
generated_text = tokenizer.decode(output_ids[0], skip_special_tokens=True)
print(generated_text)
用 pipeline 简化:
generator = pipeline("text-generation", model="gpt2")
result = generator("Once upon a time", max_length=50, truncation=True)
print(result[0]['generated_text'])
2.5 T5:Text-to-Text 万能模型
T5 将所有 NLP 任务都转换为"文本到文本"的格式:
generator = pipeline("text2text-generation", model="t5-small")
result = generator("translate English to French: How are you?", max_length=50)
print(result[0]['generated_text'])
# 输出: Comment allez-vous?
📝 小结:HuggingFace 让加载和使用预训练模型变得极其简单。但这些模型是怎么训练出来的?接下来我们进入预训练环节。
三、预训练:LLM 是怎么诞生的
在微调之前,我们得理解模型是怎么"出生"的。预训练是在海量无标注文本上训练模型,让它学习语言的基本规律。
3.1 三大预训练目标
(1)掩码语言模型(Masked Language Modeling, MLM)
代表模型:BERT
随机遮盖句子中 15% 的词,让模型预测被遮盖的词:
输入: "The capital of France is [MASK]."
预测: "The capital of France is Paris."
from transformers import pipeline
fill_mask = pipeline("fill-mask", model="bert-base-uncased")
result = fill_mask("The capital of France is [MASK].")
for r in result:
print(f"预测: {r['token_str']}, 置信度: {r['score']:.2f}")
# 预测: paris, 置信度: 0.88
# 预测: lyon, 置信度: 0.02
# ...
(2)下一句预测(Next Sentence Prediction, NSP)
也是 BERT 的预训练目标之一
判断两个句子是否是连续的:
句子A: "The dog is running."
句子B: "It is chasing a ball." → IsNext(是连续的)
句子B: "The stock market rose." → NotNext(不是连续的)
(3)下一个 Token 预测(Next Token Prediction)
代表模型:GPT 系列
给定前文,预测下一个 token:
输入: "The quick brown"
预测: "fox"
这就是 GPT 系列的核心——**自回归(Autoregressive)**语言模型。
3.2 从零预训练 BERT(简化版)
让我们用 WikiText 数据集和 HuggingFace 来演示 BERT 的预训练过程:
from transformers import (
BertConfig, BertForMaskedLM, BertTokenizerFast,
DataCollatorForLanguageModeling, TrainingArguments, Trainer
)
from datasets import load_dataset
# 1. 加载数据集
dataset = load_dataset("wikitext", "wikitext-2-raw-v1")
# 2. 创建 Tokenizer
bert_tokenizer = BertTokenizerFast.from_pretrained("bert-base-uncased")
# 3. 定义模型配置
config = BertConfig(
vocab_size=30522, # 词汇表大小
hidden_size=768, # 隐藏层维度
num_hidden_layers=12, # Transformer 层数
num_attention_heads=12, # 注意力头数
intermediate_size=3072, # FFN 中间层维度
)
# 4. 创建全新的 BERT 模型(随机初始化权重)
model = BertForMaskedLM(config)
# 5. Tokenize 数据
def tokenize_function(examples):
return bert_tokenizer(
examples["text"],
truncation=True,
padding="max_length",
max_length=512
)
tokenized_datasets = dataset.map(tokenize_function, batched=True, remove_columns=["text"])
# 6. 设置 MLM 数据收集器(自动遮盖 15% 的 token)
data_collator = DataCollatorForLanguageModeling(
tokenizer=bert_tokenizer,
mlm=True,
mlm_probability=0.15 # 15% 的 token 被遮盖
)
# 7. 训练配置
training_args = TrainingArguments(
output_dir="./trained_model",
num_train_epochs=10,
per_device_train_batch_size=2,
learning_rate=5e-5,
save_total_limit=2,
)
# 8. 开始训练
trainer = Trainer(
model=model,
args=training_args,
data_collator=data_collator,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["test"],
)
trainer.train()
🎨 交互式演示:下面的动画展示了 BERT 掩码语言模型(MLM)的预训练过程——随机遮盖 token 并让模型预测。
3.3 评估预训练模型:困惑度(Perplexity)
困惑度衡量模型"有多困惑"——数值越低越好:
import math
eval_results = trainer.evaluate()
perplexity = math.exp(eval_results['eval_loss'])
print(f"困惑度: {perplexity:.2f}")
⚠️ 注意:从零预训练一个性能优异的 BERT 需要海量数据和计算资源。上面只是简化演示。实际的 BERT 是在整个 Wikipedia + BookCorpus 上训练了多天才达到优秀性能的。
📝 小结:预训练让模型拥有了通用的语言理解能力,但它还不能直接做特定任务。接下来,我们学习如何通过微调来让模型"专业化"。
四、全量微调:最直觉的方法
全量微调的思路很简单:拿一个预训练好的模型,解冻所有参数,在特定任务的数据上继续训练。
4.1 用 PyTorch 手写训练循环
以 BERT 在 Yelp 评论数据集上的情感分析为例:
import torch
from torch.optim import AdamW
from torch.utils.data import DataLoader
from transformers import AutoModelForSequenceClassification, AutoTokenizer
from datasets import load_dataset
from torchmetrics import Accuracy
# 1. 加载数据
dataset = load_dataset("yelp_review_full")
# Yelp 有 5 个类别(1-5 星)
# 2. 加载预训练模型和 tokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
model = AutoModelForSequenceClassification.from_pretrained(
"bert-base-cased",
num_labels=5 # 5 分类
)
# 3. Tokenize 数据
def tokenize_function(examples):
return tokenizer(examples["text"], padding="max_length", truncation=True)
tokenized_datasets = dataset.map(tokenize_function, batched=True)
# 4. 数据预处理
tokenized_datasets = tokenized_datasets.remove_columns(["text"])
tokenized_datasets = tokenized_datasets.rename_column("label", "labels")
tokenized_datasets.set_format("torch")
# 5. DataLoader
train_dataloader = DataLoader(tokenized_datasets["train"], shuffle=True, batch_size=2)
eval_dataloader = DataLoader(tokenized_datasets["test"], batch_size=2)
# 6. 优化器和学习率调度
optimizer = AdamW(model.parameters(), lr=5e-4)
num_epochs = 10
num_training_steps = num_epochs * len(train_dataloader)
# 7. 训练循环
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)
model.train()
for epoch in range(num_epochs):
total_loss = 0
for batch in train_dataloader:
batch = {k: v.to(device) for k, v in batch.items()}
outputs = model(**batch) # 前向传播
loss = outputs.loss # 计算 loss
loss.backward() # 反向传播
optimizer.step() # 更新参数
optimizer.zero_grad() # 清零梯度
total_loss += loss.item()
avg_loss = total_loss / len(train_dataloader)
print(f"Epoch {epoch+1}/{num_epochs}, Loss: {avg_loss:.4f}")
💡 训练循环的 5 个核心步骤
- 前向传播:
outputs = model(**batch)— 将数据送入模型- 计算损失:
loss = outputs.loss— 衡量预测与真实标签的差距- 反向传播:
loss.backward()— 计算每个参数的梯度- 参数更新:
optimizer.step()— 根据梯度更新权重- 梯度清零:
optimizer.zero_grad()— 为下一个 batch 做准备
🎨 交互式演示:下面的动画逐步展示了 PyTorch 训练循环的 5 个核心步骤——前向传播、损失计算、反向传播、参数更新和梯度清零。
4.2 评估模型
def evaluate_model(model, eval_dataloader):
metric = Accuracy(task="multiclass", num_classes=5).to(device)
model.eval()
with torch.no_grad():
for batch in eval_dataloader:
batch = {k: v.to(device) for k, v in batch.items()}
outputs = model(**batch)
predictions = torch.argmax(outputs.logits, dim=-1)
metric(predictions, batch["labels"])
accuracy = metric.compute()
print(f"准确率: {accuracy.item():.4f}")
4.3 用 SFTTrainer 微调对话模型
HuggingFace 的 trl 库提供了更高级的 SFTTrainer(Supervised Fine-Tuning Trainer),特别适合微调对话模型:
from trl import SFTConfig, SFTTrainer, DataCollatorForCompletionOnlyLM
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset
# 1. 加载对话数据集
dataset = load_dataset("timdettmers/openassistant-guanaco", split="train")
# 数据格式: "### Human: ...\n### Assistant: ..."
# 2. 加载生成式模型
model = AutoModelForCausalLM.from_pretrained("facebook/opt-350m")
tokenizer = AutoTokenizer.from_pretrained("facebook/opt-350m")
# 3. 定义指令和回复模板
instruction_template = "### Human:"
response_template = "### Assistant:"
# 4. 创建数据收集器(只在 Assistant 回复部分计算 loss)
collator = DataCollatorForCompletionOnlyLM(
instruction_template=instruction_template,
response_template=response_template,
tokenizer=tokenizer,
mlm=False
)
# 5. 训练配置
training_args = SFTConfig(
output_dir="/tmp",
num_train_epochs=10,
per_device_train_batch_size=2,
max_seq_length=1024,
fp16=True, # 混合精度训练
)
# 6. 创建 Trainer 并训练
trainer = SFTTrainer(
model,
args=training_args,
train_dataset=dataset,
dataset_text_field="text",
data_collator=collator,
)
trainer.train()
📝 小结:全量微调虽然有效,但需要训练所有参数。对于大模型来说成本太高。接下来我们看看不同微调策略的效果对比。
五、微调策略对比实验:全量 vs 仅最后一层
这一部分我们做一个非常直观的实验:在完全相同的条件下,对比三种微调策略的效果。
5.1 实验设置
预训练数据: AG News(新闻分类,4类)
微调数据: IMDB(电影评论情感,2类)
模型架构: 基于 PyTorch TransformerEncoder 的分类器
词向量: GloVe-6B-100d(预训练词向量)
5.2 模型架构
class Net(nn.Module):
"""基于 TransformerEncoder 的文本分类器"""
def __init__(self, num_class, vocab_size, freeze=True,
nhead=2, dim_feedforward=128, num_layers=2,
dropout=0.1):
super().__init__()
# 使用 GloVe 预训练词向量
self.emb = nn.Embedding.from_pretrained(
glove_embedding.vectors, freeze=freeze
)
embedding_dim = self.emb.embedding_dim # 100
# 位置编码
self.pos_encoder = PositionalEncoding(
d_model=embedding_dim, dropout=dropout
)
# Transformer 编码器
encoder_layer = nn.TransformerEncoderLayer(
d_model=embedding_dim,
nhead=nhead,
dim_feedforward=dim_feedforward,
dropout=dropout,
)
self.transformer_encoder = nn.TransformerEncoder(
encoder_layer, num_layers=num_layers
)
# 分类头
self.classifier = nn.Linear(embedding_dim, num_class)
def forward(self, x):
x = self.emb(x) * math.sqrt(self.d_model)
x = self.pos_encoder(x)
x = self.transformer_encoder(x)
x = x.mean(dim=1) # 平均池化
x = self.classifier(x)
return x
位置编码(Positional Encoding)
Transformer 本身不像 RNN 那样有顺序感知能力,位置编码为每个 token 添加位置信息:
class PositionalEncoding(nn.Module):
def __init__(self, d_model, vocab_size=5000, dropout=0.1):
super().__init__()
self.dropout = nn.Dropout(p=dropout)
pe = torch.zeros(vocab_size, d_model)
position = torch.arange(0, vocab_size, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(
torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)
)
pe[:, 0::2] = torch.sin(position * div_term) # 偶数位用 sin
pe[:, 1::2] = torch.cos(position * div_term) # 奇数位用 cos
pe = pe.unsqueeze(0)
self.register_buffer("pe", pe)
def forward(self, x):
x = x + self.pe[:, :x.size(1), :]
return self.dropout(x)
5.3 三种策略的实验结果
策略一:直接在 IMDB 上从零训练
model = Net(num_class=2, vocab_size=vocab_size).to(device)
# 训练 100 epochs...
# 结果: ~83% 准确率
策略二:先在 AG News 预训练,再全量微调 IMDB
# 1. 加载 AG News 上预训练好的模型
model_fine1 = Net(vocab_size=vocab_size, num_class=4).to(device)
model_fine1.load_state_dict(pretrained_ag_news_weights)
# 2. 替换最后一层(4类 → 2类),但不冻结其他层
model_fine1.classifier = nn.Linear(in_features, 2)
# 3. 所有参数都参与训练
for name, param in model_fine1.named_parameters():
print(f"{name} requires_grad: {param.requires_grad}")
# 全部是 True!
# 训练 100 epochs...
# 结果: ~86% 准确率 ✨
策略三:先在 AG News 预训练,仅微调最后一层
# 1. 加载预训练模型
model_fine2 = Net(vocab_size=vocab_size, num_class=4).to(device)
model_fine2.load_state_dict(pretrained_ag_news_weights)
# 2. 冻结所有层
for param in model_fine2.parameters():
param.requires_grad = False
# 3. 替换最后一层(新层默认 requires_grad=True)
model_fine2.classifier = nn.Linear(dim, 2)
# 训练 100 epochs...
# 结果: ~64% 准确率 ❌
5.4 结果对比
| 策略 | IMDB 准确率 | 训练参数量 | 训练速度 |
|---|---|---|---|
| 从零训练 | ~83% | 全部 | 慢 |
| 全量微调(预训练→全部层) | ~86% ✨ | 全部 | 慢 |
| 仅微调最后一层 | ~64% | 仅分类头 | 快 |
🔑 关键发现
- 全量微调比从零训练提升了 3%——预训练确实有价值
- 仅微调最后一层虽然快,但性能严重下降(-22%)
- 这说明:中间层也需要适应新任务,但全量微调太贵
这就是 Adapter 和 LoRA 等方法的动机——在不修改全部参数的前提下,让中间层也能适应新任务。
5.5 进阶:选择性解冻特定层
# 只解冻 Transformer 中的 linear2 层和分类头
for param in model.parameters():
param.requires_grad = False
# 解冻特定层
for i in range(2): # 假设有 2 层 Transformer
for param in model.transformer_encoder.layers[i].linear2.parameters():
param.requires_grad = True
# 替换分类头
model.classifier = nn.Linear(100, 2)
这种"选择性解冻"是 Adapter 和 LoRA 思想的前身。
六、Adapter:轻量级模块化适配
既然全量微调太贵、仅微调最后一层又不够,那有没有折中方案?
Adapter 的核心思想:在预训练模型的每一层中插入一个小型可训练模块,冻结原有参数,只训练新增的 Adapter 模块。
6.1 Adapter 的架构
Adapter 使用瓶颈结构(Bottleneck):
原始层输出 (d维)
↓
┌──────────┐
│ 降维 (d→m) │ ← 全连接层,m << d
│ ReLU │ ← 非线性激活
│ 升维 (m→d) │ ← 全连接层
└──────────┘
↓
+ 残差连接(原始输出 + Adapter输出)
↓
最终输出 (d维)
💡 为什么是瓶颈结构?
假设隐藏层维度 d=768,Adapter 中间维度 m=64:
- 降维参数: 768 × 64 = 49,152
- 升维参数: 64 × 768 = 49,152
- Adapter 总参数: ~100K
- 原始层参数: 数百万
Adapter 只增加了 约 2% 的额外参数!
6.2 PyTorch 实现
class FeatureAdapter(nn.Module):
"""适配器模块 - 瓶颈结构 + 残差连接"""
def __init__(self, input_dim, bottleneck_dim):
super().__init__()
self.adapter = nn.Sequential(
nn.Linear(input_dim, bottleneck_dim), # 降维
nn.ReLU(), # 激活
nn.Linear(bottleneck_dim, input_dim) # 升维
)
def forward(self, x):
return x + self.adapter(x) # 残差连接!
🔑 残差连接至关重要
return x + self.adapter(x)意味着如果 Adapter 学到的是零映射,输出就等于输入——不会破坏预训练好的特征。这保证了 Adapter 的训练是"安全"的。
🎨 交互式演示:下面的动画展示了 Adapter 的瓶颈架构和数据流动过程,包括降维、激活、升维和残差连接。
6.3 将 Adapter 注入到模型中
class Adapted(nn.Module):
"""将 Adapter 注入到现有模型中"""
def __init__(self, base_model, adapter_dim=64):
super().__init__()
self.base_model = base_model
# 冻结所有原始参数
for param in self.base_model.parameters():
param.requires_grad = False
# 为每一层 Transformer 添加 Adapter
d_model = base_model.emb.embedding_dim
self.adapters = nn.ModuleList([
FeatureAdapter(d_model, adapter_dim)
for _ in range(len(base_model.transformer_encoder.layers))
])
# 新的分类头
self.classifier = nn.Linear(d_model, num_classes)
def forward(self, x):
x = self.base_model.emb(x)
x = self.base_model.pos_encoder(x)
for i, layer in enumerate(self.base_model.transformer_encoder.layers):
x = layer(x)
x = self.adapters[i](x) # 在每层后面插入 Adapter
x = x.mean(dim=1)
x = self.classifier(x)
return x
6.4 实验结果
在同样的 IMDB 情感分析任务上:
| 方法 | 准确率 | 可训练参数占比 |
|---|---|---|
| 全量微调 | ~86% | 100% |
| 仅最后一层 | ~64% | ~1% |
| Adapter | ~86% ✨ | ~2-3% |
🎯 Adapter 用 约 3% 的参数 达到了与全量微调相当的性能!这就是参数高效微调的威力。
6.5 Adapter 的直觉理解
可以把 Adapter 想象成给预训练模型"戴上了一副有色眼镜":
- 预训练模型(冻结)= 你的大脑,拥有通用知识
- Adapter(可训练)= 有色眼镜,让你用不同的视角看问题
- 残差连接 = 你随时可以摘下眼镜,恢复原本的视角
七、LoRA:低秩矩阵的魔法
LoRA(Low-Rank Adaptation)是目前最流行的参数高效微调方法。它的核心洞察:
微调时权重的变化量 ΔW 是低秩的(low-rank)——即虽然权重矩阵很大,但微调实际改变的信息可以用两个小矩阵的乘积来表示。
7.1 数学原理
对于一个预训练好的线性层 ,LoRA 将微调过程改写为:
其中:
- — 原始预训练权重(冻结)
- — 降维矩阵(可训练)
- — 升维矩阵(可训练)
- — 秩(rank),远小于
- — 缩放因子
参数节省效果:
- 原始权重: 参数
- LoRA: 参数
- 当 : 原始 589,824 → LoRA 12,288(节省 97.9%!)
7.2 PyTorch 从零实现 LoRA
class LoRALayer(nn.Module):
"""LoRA 低秩适配层"""
def __init__(self, in_features, out_features, rank=4, alpha=1.0):
super().__init__()
self.rank = rank
self.alpha = alpha
# A 矩阵:用高斯分布初始化
self.A = nn.Parameter(torch.randn(rank, in_features))
# B 矩阵:初始化为零!
self.B = nn.Parameter(torch.zeros(out_features, rank))
def forward(self, x):
# ΔW·x = B·A·x
# 先算 A·x(降维),再算 B·(A·x)(升维)
return self.alpha * (x @ self.A.T @ self.B.T)
💡 为什么 B 初始化为零?
B = 0意味着训练开始时ΔW = B·A = 0,即 LoRA 的初始输出为零。 这样模型一开始就等同于原始预训练模型——训练是从一个已知的好起点开始的。
7.3 将 LoRA 注入到线性层
class LinearWithLoRA(nn.Module):
"""带有 LoRA 的线性层"""
def __init__(self, original_linear, rank=4, alpha=1.0):
super().__init__()
self.original_linear = original_linear # 冻结的原始层
self.lora = LoRALayer(
original_linear.in_features,
original_linear.out_features,
rank=rank,
alpha=alpha
)
def forward(self, x):
# 原始输出 + LoRA 修正
return self.original_linear(x) + self.lora(x)
🎨 交互式演示:下面的可视化展示了 LoRA 低秩分解的数学原理——看看大矩阵 ΔW 如何被分解为两个小矩阵 B·A。
7.4 完整实验:AG News 预训练 → LoRA 微调 IMDB
# 1. 加载在 AG News 上预训练的模型
pretrained_model = TextClassifier(...)
pretrained_model.load_state_dict(ag_news_weights)
# 2. 冻结所有参数
for param in pretrained_model.parameters():
param.requires_grad = False
# 3. 替换分类头
pretrained_model.output_layer = nn.Linear(embed_dim, 2)
# 4. 给线性层注入 LoRA
for layer in pretrained_model.transformer_encoder.layers:
# 替换 self-attention 中的线性层
layer.self_attn.out_proj = LinearWithLoRA(
layer.self_attn.out_proj, rank=8
)
# 替换 FFN 中的线性层
layer.linear1 = LinearWithLoRA(layer.linear1, rank=8)
layer.linear2 = LinearWithLoRA(layer.linear2, rank=8)
# 5. 检查可训练参数
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
total_params = sum(p.numel() for p in model.parameters())
print(f"可训练参数: {trainable_params:,} / {total_params:,}")
print(f"占比: {100 * trainable_params / total_params:.2f}%")
# 输出类似: 可训练参数: 12,288 / 5,000,000 (约 0.25%)
7.5 Rank 的选择
rank 是 LoRA 最重要的超参数,它决定了适配能力和参数量的平衡:
| Rank (r) | 参数量 (d=768) | 适配能力 | 适用场景 |
|---|---|---|---|
| 1 | 1,536 | 最低 | 非常简单的任务 |
| 4 | 6,144 | 低 | 简单任务,避免过拟合 |
| 8 | 12,288 | 适中 | 通用推荐值 |
| 16 | 24,576 | 较高 | 复杂任务 |
| 64 | 98,304 | 高 | 接近全量微调 |
7.6 实验结果
在 AG News → IMDB 的迁移学习实验中:
基线(无 LoRA 的预训练模型): 73.62% 准确率
加入 LoRA 微调后: 76.40% 准确率(+2.78%)
🔑 LoRA 的三大优势
- 参数高效:只训练 ~0.25% 的参数
- 无推理延迟:训练完成后,LoRA 权重可以合并到原始权重中:
- 模块化:不同任务的 LoRA 可以像"插件"一样切换
八、QLoRA:量化 + LoRA 的极致效率
QLoRA 在 LoRA 的基础上更进一步:先将模型量化到 4-bit,然后在量化模型上应用 LoRA。
8.1 什么是量化?
FP32 (32-bit): 3.141592653589793 → 32 bit/参数
FP16 (16-bit): 3.14159 → 16 bit/参数
INT8 (8-bit): 3 → 8 bit/参数
NF4 (4-bit): ≈3 → 4 bit/参数 ← QLoRA 使用这个
7B 参数模型的显存占用:
FP32: ~28 GB
FP16: ~14 GB
INT8: ~7 GB
NF4: ~3.5 GB ← QLoRA!
💡 NF4(NormalFloat4)
QLoRA 使用一种叫 NF4 的特殊 4-bit 数据类型。它基于一个假设:预训练模型的权重近似服从正态分布。NF4 将 4-bit 的 16 个量化级别按正态分布的分位数来分配,使得量化误差最小化。
8.2 用 HuggingFace 实现 QLoRA
from transformers import (
AutoModelForSequenceClassification,
AutoTokenizer,
BitsAndBytesConfig,
TrainingArguments,
Trainer
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
# 1. 配置 4-bit 量化
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 加载为 4-bit
bnb_4bit_quant_type="nf4", # 使用 NF4 量化
bnb_4bit_use_double_quant=True, # 双重量化(进一步压缩)
bnb_4bit_compute_dtype=torch.float16 # 计算时用 FP16
)
# 2. 加载量化模型
model = AutoModelForSequenceClassification.from_pretrained(
"distilbert-base-uncased",
num_labels=2,
quantization_config=bnb_config
)
# 3. 准备模型用于 k-bit 训练
model = prepare_model_for_kbit_training(model)
# 4. 配置 LoRA
lora_config = LoraConfig(
r=8, # LoRA 秩
lora_alpha=32, # LoRA 缩放因子
target_modules=["q_lin", "v_lin"], # 在 Q 和 V 矩阵上应用 LoRA
lora_dropout=0.05,
bias="none",
task_type="SEQ_CLS"
)
# 5. 应用 LoRA
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出: trainable params: 888,578 || all params: 67,544,834 || trainable%: 1.3154%
🤯 只有 1.2% 的参数是可训练的!
🎨 交互式演示:下面的可视化展示了 QLoRA 的量化过程——从 FP32 到 NF4 的精度压缩是如何工作的。
8.3 训练和评估
tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")
dataset = load_dataset("imdb")
def tokenize_function(examples):
return tokenizer(examples["text"], padding="max_length", truncation=True, max_length=512)
tokenized_dataset = dataset.map(tokenize_function, batched=True)
training_args = TrainingArguments(
output_dir="./results",
num_train_epochs=3,
per_device_train_batch_size=8,
learning_rate=2e-4,
fp16=True,
logging_steps=100,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset["train"],
eval_dataset=tokenized_dataset["test"],
)
trainer.train()
8.4 QLoRA 实验结果
| 指标 | 数值 |
|---|---|
| 可训练参数 | 888,578 (1.2%) |
| 模型总参数 | 67,544,834 |
| 测试准确率 | 84.3% |
| 显存占用 | 约为 FP32 全量微调的 1/8 |
8.5 QLoRA 的三大技术创新
- NF4 量化:基于正态分布假设的 4-bit 量化,信息损失极小
- 双重量化(Double Quantization):对量化常数本身也进行量化,进一步减少内存
- 分页优化器(Paged Optimizers):利用 CPU 内存处理 GPU 内存溢出
九、全局对比:该选哪种方法?
现在让我们把所有方法放在一起对比。
9.1 方法对比总表
| 方法 | 可训练参数 | 显存需求 | 性能 | 推理速度 | 实现复杂度 |
|---|---|---|---|---|---|
| 全量微调 | 100% | ⬆️⬆️⬆️ 最高 | ⬆️ 最好 | 正常 | 简单 |
| 仅最后一层 | ~1% | ⬇️ 最低 | ⬇️ 最差 | 正常 | 简单 |
| Adapter | ~2-5% | ⬇️ 低 | ⬆️ 接近全量 | 略慢(额外层) | 中等 |
| LoRA | ~0.1-1% | ⬇️ 低 | ⬆️ 接近全量 | 无额外开销 | 中等 |
| QLoRA | ~1% | ⬇️⬇️ 极低 | ⬆️ 接近全量 | 略慢(反量化) | 较复杂 |
9.2 方法架构可视化
全量微调
输入 → [Embedding] → [Transformer层1] → [Transformer层2] → ... → [分类头] → 输出
可训练 ↑ 可训练 ↑ 可训练 ↑ 可训练 ↑
所有层全部参与训练,参数更新量最大。
Adapter
输入 → [Embedding❄️] → [Transformer层1❄️] → [Adapter🔥] → [Transformer层2❄️] → [Adapter🔥] → [分类头🔥]
↑ 新增小模块 ↑ 新增小模块
原始层冻结❄️,只有 Adapter 模块🔥可训练。Adapter 的瓶颈结构:d→m→d(m远小于d)。
LoRA
┌───────────┐
输入 → [原始权重W₀❄️]──→ W₀·x ──┐
│ ├──→ 输出 = W₀·x + α·B·A·x
└→ [A矩阵🔥] → [B矩阵🔥] ──→ α·B·A·x ─┘
(d→r) (r→d)
原始权重冻结❄️,LoRA 通过低秩矩阵 A·B 捕捉增量变化。推理时可合并!
🎨 交互式演示:下面的交互式可视化展示了全量微调、Adapter、LoRA 三种方法的架构差异,点击切换不同方法查看对比。
9.3 决策指南
你有足够的 GPU 吗?
├── 是 → 数据集大吗?
│ ├── 是 → 全量微调(最佳性能)
│ └── 否 → LoRA(避免过拟合)
│
└── 否 → 模型能放进显存吗?
├── 能 → LoRA
└── 不能 → QLoRA(4-bit 量化后再 LoRA)
9.4 代码速查表
全量微调:
model = AutoModelForSequenceClassification.from_pretrained("bert-base-cased", num_labels=2)
# 所有参数默认可训练
冻结 + 仅微调最后一层:
for param in model.parameters():
param.requires_grad = False
model.classifier = nn.Linear(768, 2) # 新层可训练
LoRA(使用 PEFT 库):
from peft import LoraConfig, get_peft_model
lora_config = LoraConfig(r=8, lora_alpha=32, target_modules=["query", "value"])
model = get_peft_model(model, lora_config)
QLoRA(使用 BitsAndBytes + PEFT):
bnb_config = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4")
model = AutoModel.from_pretrained("model_name", quantization_config=bnb_config)
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, lora_config)
十、总结与实践建议
10.1 核心要点回顾
- 预训练 让模型拥有通用语言理解能力(MLM、NSP、Next Token)
- 全量微调 性能最好但成本最高
- 仅微调最后一层 快但效果差,中间层无法适应新任务
- Adapter 在每层插入轻量模块,~3% 参数达到全量微调效果
- LoRA 用低秩矩阵捕捉权重变化,~0.5% 参数,且推理无额外开销
- QLoRA = 4-bit 量化 + LoRA,显存占用极低,适合消费级 GPU
10.2 实践建议
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| 学习和实验 | LoRA | 简单、高效、广泛支持 |
| 消费级 GPU(8-16GB) | QLoRA | 显存占用最低 |
| 生产环境追求极致性能 | 全量微调 | 性能上限最高 |
| 需要支持多任务 | LoRA | 每个任务一组 LoRA 权重,切换成本低 |
| 推理速度敏感 | LoRA(合并权重) | 合并后无额外计算 |
10.3 推荐学习路线
Step 1: 学会使用 HuggingFace pipeline 做推理
↓
Step 2: 理解预训练的概念(MLM、NSP)
↓
Step 3: 手写 PyTorch 训练循环,理解全量微调
↓
Step 4: 学习 LoRA 的数学原理和代码实现
↓
Step 5: 使用 PEFT 库实践 LoRA/QLoRA
↓
Step 6: 在自己的项目中应用!
10.4 常见 FAQ
Q: LoRA 和 Adapter 能同时使用吗? A: 技术上可以,但通常没有必要。LoRA 是目前更主流的选择。
Q: LoRA 的 rank 设成多少合适? A: 一般 4-16 就够了。复杂任务可以试 32-64。建议从 8 开始,根据验证集结果调整。
Q: QLoRA 会不会因为量化损失很多精度? A: NF4 量化的精度损失非常小(通常 < 0.5%),与全精度 LoRA 几乎相当。
Q: 什么时候该用全量微调而不是 PEFT? A: 当你有充足的计算资源、大规模标注数据、且追求最高性能时。
📚 参考资料
本文基于 IBM Skills Network 课程素材整理和扩展,包含原创代码实现和对比分析。
如果这篇文章对你有帮助,欢迎留言讨论或分享给更多正在学习 AI 的朋友!