前面QLoRA主要在讲“怎么在全精度模型上优雅地加一个低秩增量 ΔW”。工业界真实场景里依然存在着两个很常见的问题:
这两个问题分别对应了的两条主线:
在本文文章中,主要对这两个系列的微调原理及实现进行讲解。
最简单的理解就是:在量化模型的基础上进行LoRA微调,听上去似乎就不是什么高大上的技术进步,事实就是如此:QLoRA = 4bit 量化基座 + 全精度 LoRA 适配器。
训练时:

可以粗暴理解成三点:
可能有些读者对于量化还不是特别熟悉,这里可以先简短学习一下量化知识。通俗来讲,就是将高精度的浮点型变为低精度的整数型。量化可以分为对称量化以及分为对称量化和非对称量化
⚙️ 区别
Xint=round(Xfloatscale),scale=max(|X|)2n−1−1
Xint=round(Xfloatscale)+zero_point,scale=maxx−min)x2n−1
zero_point=round(−min(x)scale)
特性 | 对称量化(Symmetric Quantization) | 非对称量化(Affine Quantization) |
|---|---|---|
零点位置 | 固定为0 | 动态计算(zero_point) |
数值范围 |
|
|
计算开销 | 更低(无需zero_point计算) | 更高 |
精度损失 | 对偏斜分布敏感 | 更鲁棒,能更好处理数据分布偏斜的情况 |
典型应用 | 权重量化(正负均衡) | 激活值量化 |
硬件支持 | 广泛支持(如GPU/TPU) | 需要额外处理zero_point |
如果你仔细阅读过原文,你会发现,QLoRA 论文里的关键点不是“随便 4bit 一量就完了”,而是有几层工程小心机:
1️⃣ NF4:不是“粗暴等分”,而是“按权重习性切块”
先说直觉上熟悉的“Uniform 4bit”:
问题是:神经网络的权重分布不是均匀的,而是接近“钟形曲线”(高斯分布附近),绝大多数数都挤在 0 附近,离得远的极大/极小值很少。如果用 “均匀量化” 去切,可能会出现:
? 中间这段(权重密集的区域)被切得很粗;
? 两边(几乎没什么值)却分配了不少格子,浪费。
NF4(NormalFloat4 / NormalFloat-4)这个“按高斯分布定制的 4bit 量化格式”,最早是作为一种适配“近似正态分布权重”的 4bit 数据类型提出的。本质上是:
先假设权重接近高斯分布,然后专门为“高斯形状”设计一套 4bit 刻度表,让中间密集区域的刻度更密,两侧稀疏区域刻度更稀。
你可以类比成:
所以,在“权重真正密集的地方”,NF4 给了更多精度,这就是为什么在 4bit 这么粗的比特下,QLoRA 还能保证精度比较好。QLoRA 证明了:在大规模 LLM 上,用 NF4 这种 4bit 格式 + Double Quantization + PagedOptimizer,再配合 LoRA,只训练低秩增量,就可以在几乎不掉点的前提下,大幅降低显存/存储成本。
2️⃣ Double Quantization
❌ 普通块量化:
scale(甚至还要 zero_point)来还原;scale 一般用 fp16/fp32 存,虽然数量比权重少很多,但单个很“贵”。✔️ QLoRA 的 Double Quantization:
{scaleᵢ} 做一次 8bit / 更低比特的量化,得到 {scale_codes, global_scale}。因此,最终的存储结构会变成:
这里的关键区别在于:
单层量化:只在“块内”做压缩; Double Quantization:先做块内压缩,再在“块与块之间”对 scale 做二次压缩。
如果直接“一次性量化”整个大矩阵 W,想做到类似的压缩率,就会遇到两个问题:
而块量化 + Double Quantization 正是通过这两层结构:
在 HuggingFace 生态里,一般是这样一个栈:
bitsandbytes 提供 4bit / 8bit 量化 + 高效算子;transformers + peft 管 LoRA 注入。配置示例:
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, TaskType
model_name = "meta-llama/Llama-3-8b"
tokenizer = AutoTokenizer.from_pretrained(model_name)
# 1) 4bit 量化配置
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype="bfloat16", # 计算时用 bf16
bnb_4bit_use_double_quant=True, # double quantization
bnb_4bit_quant_type="nf4", # NF4 或 FP4
)
# 2) 加载量化后的基座模型
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
)
# 3) LoRA 配置(和普通 LoRA 一样)
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=8,
lora_alpha=16,
lora_dropout=0.05,
bias="none",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)
# 4) 注入 LoRA
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()pip install "transformers>=4.44.0" "datasets" "accelerate" "peft>=0.8.0" \
"bitsandbytes>=0.45.0" "torch" "torchvision" "Pillow"import os
from PIL import Image
from torch.utils.data import Dataset
class ImageTextDataset(Dataset):
def __init__(self, ann_file, image_root, processor):
self.samples = []
self.image_root = image_root
self.processor = processor
with open(ann_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
img_rel, text = line.split("\t", 1)
self.samples.append((img_rel, text))
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
img_rel, text = self.samples[idx]
img_path = os.path.join(self.image_root, img_rel)
image = Image.open(img_path).convert("RGB")
return {"image": image, "text": text}import torch
from torch.utils.data import DataLoader
import torch.nn.functional as F
from transformers import (
CLIPModel,
CLIPProcessor,
BitsAndBytesConfig,
get_scheduler,
)
from peft import LoraConfig, get_peft_model, TaskType
from dataset_clip_qlora import ImageTextDataset
device = "cuda" if torch.cuda.is_available() else "cpu"
model_name = "openai/clip-vit-base-patch32"
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_compute_dtype=torch.bfloat16, # 计算用 bf16/fp16 均可
bnb_4bit_use_double_quant=True, # Double Quantization
bnb_4bit_quant_type="nf4", # NF4 量化
)
model = CLIPModel.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
)
processor = CLIPProcessor.from_pretrained(model_name)for name, module in model.named_modules():
if "q_proj" in name or "v_proj" in name:
print(name, "->", module.__class__.__name__)
lora_config = LoraConfig(
r=16,
lora_alpha=32,
lora_dropout=0.05,
bias="none",
task_type=TaskType.FEATURE_EXTRACTION,
target_modules=[
"q_proj",
"k_proj",
"v_proj",
"out_proj",
],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()BATCH_SIZE = 32
MAX_LENGTH = 64
train_dataset = ImageTextDataset(
ann_file="data/train.tsv",
image_root="data/images",
processor=processor,
)
def collate_fn(batch):
images = [item["image"] for item in batch]
texts = [item["text"] for item in batch]
encoding = processor(
text=texts,
images=images,
padding=True,
truncation=True,
max_length=MAX_LENGTH,
return_tensors="pt",
)
return {
"pixel_values": encoding["pixel_values"], # [B, 3, H, W]
"input_ids": encoding["input_ids"], # [B, L]
"attention_mask": encoding["attention_mask"], # [B, L]
}
train_loader = DataLoader(
train_dataset,
batch_size=BATCH_SIZE,
shuffle=True,
num_workers=4,
collate_fn=collate_fn,
)global_step = 0
for epoch in range(EPOCHS):
for step, batch in enumerate(train_loader):
batch = {k: v.to(device) for k, v in batch.items()}
outputs = model(
input_ids=batch["input_ids"],
attention_mask=batch["attention_mask"],
pixel_values=batch["pixel_values"],
return_loss=False,
)
logits_image = outputs.logits_per_image
logits_text = outputs.logits_per_text
batch_size = logits_image.size(0)
labels = torch.arange(batch_size, device=device)
loss_i = F.cross_entropy(logits_image, labels)
loss_t = F.cross_entropy(logits_text, labels)
loss = (loss_i + loss_t) / 2.0
loss = loss / GRAD_ACCUM
loss.backward()
if (step + 1) % GRAD_ACCUM == 0:
optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
global_step += 1
if global_step % 50 == 0:
print(f"Epoch {epoch} | step {step} | loss {loss.item():.4f}")