Published on

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

Authors
  • avatar
    Name
    Allen Wang
    Twitter

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

🎯 本文目标:如果你是一个对大模型微调感到好奇但又不知从何入手的开发者,这篇文章就是为你写的。我们将从最基础的"什么是模型推理"开始,一步步走到参数高效微调(PEFT)的前沿技术。


📑 目录


一、为什么需要微调?

想象一下这个场景:你下载了一个预训练好的 GPT-2 模型,想让它帮你做情感分析——判断电影评论是正面还是负面的。你直接输入一段评论,模型会怎样?

Python
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)

微调的核心思想

JavaScript
预训练模型(通用语言知识) + 特定任务数据(如情感分析) = 专业化模型

但问题来了:直接修改整个模型的所有参数(全量微调)代价很高:

挑战说明
💰 计算成本GPT-3 有 1750 亿参数,全量微调需要海量 GPU
📦 存储成本每个任务一份完整模型副本
🔥 过拟合风险小数据集上全量微调容易过拟合
⏰ 训练时间大模型训练一次可能需要几天

这就引出了本文的主角——**参数高效微调(Parameter-Efficient Fine-Tuning, PEFT)**方法:LoRA、Adapter、QLoRA。它们的核心理念是:冻结大部分预训练参数,只训练一小部分新增参数

但在深入这些高级方法之前,让我们先从基础开始。


二、HuggingFace 入门:模型加载与推理

在动手微调之前,你得先学会加载使用预训练模型。HuggingFace 的 transformers 库是目前最流行的工具。

2.1 不用 pipeline 的手动推理流程

让我们先看看"底层"发生了什么。以情感分析为例:

Python
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 可以浓缩成:

Python
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

Python
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 简化:

Python
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 任务都转换为"文本到文本"的格式:

Python
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% 的词,让模型预测被遮盖的词:

JavaScript
输入: "The capital of France is [MASK]."
预测: "The capital of France is Paris."
Python
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 的预训练目标之一

判断两个句子是否是连续的:

JavaScript
句子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:

JavaScript
输入: "The quick brown"
预测: "fox"

这就是 GPT 系列的核心——**自回归(Autoregressive)**语言模型。

3.2 从零预训练 BERT(简化版)

让我们用 WikiText 数据集和 HuggingFace 来演示 BERT 的预训练过程:

Python
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)

困惑度衡量模型"有多困惑"——数值越低越好

Python
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 评论数据集上的情感分析为例:

Python
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 个核心步骤

  1. 前向传播outputs = model(**batch) — 将数据送入模型
  2. 计算损失loss = outputs.loss — 衡量预测与真实标签的差距
  3. 反向传播loss.backward() — 计算每个参数的梯度
  4. 参数更新optimizer.step() — 根据梯度更新权重
  5. 梯度清零optimizer.zero_grad() — 为下一个 batch 做准备

🎨 交互式演示:下面的动画逐步展示了 PyTorch 训练循环的 5 个核心步骤——前向传播、损失计算、反向传播、参数更新和梯度清零。

4.2 评估模型

Python
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),特别适合微调对话模型:

Python
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 实验设置

JavaScript
预训练数据: AG News(新闻分类,4类)
微调数据: IMDB(电影评论情感,2类)
模型架构: 基于 PyTorch TransformerEncoder 的分类器
词向量: GloVe-6B-100d(预训练词向量)

5.2 模型架构

Python
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 添加位置信息:

Python
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 上从零训练

Python
model = Net(num_class=2, vocab_size=vocab_size).to(device)
# 训练 100 epochs...
# 结果: ~83% 准确率

策略二:先在 AG News 预训练,再全量微调 IMDB

Python
# 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 预训练,仅微调最后一层

Python
# 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%
  • 这说明:中间层也需要适应新任务,但全量微调太贵

这就是 AdapterLoRA 等方法的动机——在不修改全部参数的前提下,让中间层也能适应新任务

5.5 进阶:选择性解冻特定层

Python
# 只解冻 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)

JavaScript
原始层输出 (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 实现

Python
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 注入到模型中

Python
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 数学原理

对于一个预训练好的线性层 W0Rd×dW_0 \in \mathbb{R}^{d \times d},LoRA 将微调过程改写为:

h=W0x+αΔWx=W0x+α(BA)xh = W_0 x + \alpha \cdot \Delta W \cdot x = W_0 x + \alpha \cdot (B \cdot A) \cdot x

其中:

  • W0W_0 — 原始预训练权重(冻结
  • ARr×dA \in \mathbb{R}^{r \times d} — 降维矩阵(可训练
  • BRd×rB \in \mathbb{R}^{d \times r} — 升维矩阵(可训练
  • rr — 秩(rank),远小于 dd
  • α\alpha — 缩放因子

参数节省效果

  • 原始权重: d×dd \times d 参数
  • LoRA: d×r+r×d=2drd \times r + r \times d = 2dr 参数
  • d=768,r=8d=768, r=8: 原始 589,824 → LoRA 12,288(节省 97.9%!)

7.2 PyTorch 从零实现 LoRA

Python
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 注入到线性层

Python
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

Python
# 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)适配能力适用场景
11,536最低非常简单的任务
46,144简单任务,避免过拟合
812,288适中通用推荐值
1624,576较高复杂任务
6498,304接近全量微调

7.6 实验结果

在 AG News → IMDB 的迁移学习实验中:

JavaScript
基线(无 LoRA 的预训练模型): 73.62% 准确率
加入 LoRA 微调后:              76.40% 准确率(+2.78%

🔑 LoRA 的三大优势

  1. 参数高效:只训练 ~0.25% 的参数
  2. 无推理延迟:训练完成后,LoRA 权重可以合并到原始权重中:Wfinal=W0+αBAW_{final} = W_0 + \alpha \cdot B \cdot A
  3. 模块化:不同任务的 LoRA 可以像"插件"一样切换

八、QLoRA:量化 + LoRA 的极致效率

QLoRA 在 LoRA 的基础上更进一步:先将模型量化到 4-bit,然后在量化模型上应用 LoRA

8.1 什么是量化?

JavaScript
FP32 (32-bit):  3.14159265358979332 bit/参数
FP16 (16-bit):  3.1415916 bit/参数
INT8 (8-bit):   38 bit/参数
NF4 (4-bit):34 bit/参数 ← QLoRA 使用这个

7B 参数模型的显存占用:
FP32: ~28 GB
FP16: ~14 GB
INT8: ~7 GB
NF4:  ~3.5 GBQLoRA!

💡 NF4(NormalFloat4)

QLoRA 使用一种叫 NF4 的特殊 4-bit 数据类型。它基于一个假设:预训练模型的权重近似服从正态分布。NF4 将 4-bit 的 16 个量化级别按正态分布的分位数来分配,使得量化误差最小化。

8.2 用 HuggingFace 实现 QLoRA

Python
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 训练和评估

Python
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 的三大技术创新

  1. NF4 量化:基于正态分布假设的 4-bit 量化,信息损失极小
  2. 双重量化(Double Quantization):对量化常数本身也进行量化,进一步减少内存
  3. 分页优化器(Paged Optimizers):利用 CPU 内存处理 GPU 内存溢出

九、全局对比:该选哪种方法?

现在让我们把所有方法放在一起对比。

9.1 方法对比总表

方法可训练参数显存需求性能推理速度实现复杂度
全量微调100%⬆️⬆️⬆️ 最高⬆️ 最好正常简单
仅最后一层~1%⬇️ 最低⬇️ 最差正常简单
Adapter~2-5%⬇️ 低⬆️ 接近全量略慢(额外层)中等
LoRA~0.1-1%⬇️ 低⬆️ 接近全量无额外开销中等
QLoRA~1%⬇️⬇️ 极低⬆️ 接近全量略慢(反量化)较复杂

9.2 方法架构可视化

全量微调

JavaScript
输入 → [Embedding][Transformer层1][Transformer层2]...[分类头] → 输出
         可训练 ↑        可训练 ↑           可训练 ↑                可训练 ↑

所有层全部参与训练,参数更新量最大。

Adapter

JavaScript
输入 → [Embedding❄️][Transformer层1❄️][Adapter🔥][Transformer层2❄️][Adapter🔥][分类头🔥]
                                                 ↑ 新增小模块                        ↑ 新增小模块

原始层冻结❄️,只有 Adapter 模块🔥可训练。Adapter 的瓶颈结构:d→m→d(m远小于d)。

LoRA

JavaScript
                    ┌───────────┐
输入 → [原始权重W₀❄️]──→ W₀·x ──┐
  │                              ├──→ 输出 = W₀·x + α·B·A·x
  └→ [A矩阵🔥][B矩阵🔥] ──→ α·B·A·x ─┘
      (d→r)       (r→d)

原始权重冻结❄️,LoRA 通过低秩矩阵 A·B 捕捉增量变化。推理时可合并!

🎨 交互式演示:下面的交互式可视化展示了全量微调、Adapter、LoRA 三种方法的架构差异,点击切换不同方法查看对比。

9.3 决策指南

JavaScript
你有足够的 GPU 吗?
├── 是 → 数据集大吗?
│   ├── 是 → 全量微调(最佳性能)
│   └── 否 → LoRA(避免过拟合)
└── 否 → 模型能放进显存吗?
    ├── 能 → LoRA
    └── 不能 → QLoRA(4-bit 量化后再 LoRA)

9.4 代码速查表

全量微调

Python
model = AutoModelForSequenceClassification.from_pretrained("bert-base-cased", num_labels=2)
# 所有参数默认可训练

冻结 + 仅微调最后一层

Python
for param in model.parameters():
    param.requires_grad = False
model.classifier = nn.Linear(768, 2)  # 新层可训练

LoRA(使用 PEFT 库)

Python
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)

Python
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 核心要点回顾

  1. 预训练 让模型拥有通用语言理解能力(MLM、NSP、Next Token)
  2. 全量微调 性能最好但成本最高
  3. 仅微调最后一层 快但效果差,中间层无法适应新任务
  4. Adapter 在每层插入轻量模块,~3% 参数达到全量微调效果
  5. LoRA 用低秩矩阵捕捉权重变化,~0.5% 参数,且推理无额外开销
  6. QLoRA = 4-bit 量化 + LoRA,显存占用极低,适合消费级 GPU

10.2 实践建议

场景推荐方法原因
学习和实验LoRA简单、高效、广泛支持
消费级 GPU(8-16GB)QLoRA显存占用最低
生产环境追求极致性能全量微调性能上限最高
需要支持多任务LoRA每个任务一组 LoRA 权重,切换成本低
推理速度敏感LoRA(合并权重)合并后无额外计算

10.3 推荐学习路线

JavaScript
Step 1: 学会使用 HuggingFace pipeline 做推理
Step 2: 理解预训练的概念(MLMNSPStep 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 的朋友!