【大模型微调】QLoRA微调原理及实战

🧔 这里是九年义务漏网鲨鱼,研究生在读,主要研究方向是人脸伪造检测,长期致力于研究多模态大模型技术;国家奖学金获得者,国家级大创项目一项,发明专利一篇,多篇论文在投,蓝桥杯国家级奖项、妈妈杯一等奖。

✍ 博客主要内容为大模型技术的学习以及相关面经,本人已得到B站、百度、唯品会等多段多模态大模型的实习offer,为了能够紧跟前沿知识,决定写一个"从零学习 RL"主题的专栏。这个专栏将记录我个人的主观学习过程,因此会存在错误,若有出错,欢迎大家在评论区帮助我指出。除此之外,博客内容也会分享一些我在本科期间的一些知识以及项目经验。

🌎 Github仓库地址:Baby Awesome Reinforcement Learning for LLMs and Agentic AI

📩 有兴趣合作的研究者可以联系我:yirongzzz@163.com

文章目录

    • 前言
      • [一、 QLoRA](#一、 QLoRA)
        • [🧠 1.1 QLoRA 和普通 LoRA 的本质区别是什么?(真实面试问题)](#🧠 1.1 QLoRA 和普通 LoRA 的本质区别是什么?(真实面试问题))
      • [二 量化知识恶补](#二 量化知识恶补)
      • [三、QLoRA 量化细节](#三、QLoRA 量化细节)
        • [🧠 1.2 两次压缩可以等价为一次粗scale压缩吗(笔者疑问)](#🧠 1.2 两次压缩可以等价为一次粗scale压缩吗(笔者疑问))
      • [四、QLoRA 工程配置要点](#四、QLoRA 工程配置要点)
      • [五、QLoRA-CLIP 图文检索微调示例](#五、QLoRA-CLIP 图文检索微调示例)

前言

前面QLoRA主要在讲"怎么在全精度模型上优雅地加一个低秩增量 ΔW"。工业界真实场景里依然存在着两个很常见的问题:

  1. 显存实在不够,8B / 14B 模型都快撑不住了,LoRA微调依然爆显存,如何进一步解决?
  2. LoRA 只是往权重上"加一个低秩偏移",能不能把"方向 和 幅度 拆开来,控制得更精细一点?

这两个问题分别对应了的两条主线:

  • QLoRA:在 LoRA 之外,把 base 模型本身做 4bit 量化,真正做到"显存抠到极致,还不怎么掉点";

✍ 论文地址:https://arxiv.org/abs/2305.14314

  • DoRA :把权重拆成 方向 × 标量幅度,把"往哪儿改"与"改多大"解耦。

✍ 论文地址:https://arxiv.org/abs/2402.09353

在本文文章中,主要对这两个系列的微调原理及实现进行讲解。

一、 QLoRA

最简单的理解就是:在量化模型的基础上进行LoRA微调,听上去似乎就不是什么高大上的技术进步,事实就是如此:QLoRA = 4bit 量化基座 + 全精度 LoRA 适配器。

训练时:

  • 前向:用低比特 W 0 W_0 W0 参与计算 + LoRA 分支的浮点增量;
  • 反向:只对 LoRA 权重(A、B)求梯度, W 0 W_0 W0 不更新。
🧠 1.1 QLoRA 和普通 LoRA 的本质区别是什么?(真实面试问题)

可以粗暴理解成三点:

  1. 普通 LoRA:

    • 基座模型 W 0 W_0 W0 用 fp16 / bf16 常规加载;
    • 显存占用基本和标准 finetune 差不多(只是梯度少了很多)。
  2. QLoRA:

    • 把 W 0 W_0 W0 量化到 4bit(NF4) 存储;

    • 前向时做一次解量化到 fp16 参与计算,但不存梯度,显存占用大幅下降;

    • 只在 LoRA 分支上保留 fp16/bf16,参数量很少。

二 量化知识恶补

可能有些读者对于量化还不是特别熟悉,这里可以先简短学习一下量化知识。通俗来讲,就是将高精度的浮点型变为低精度的整数型。量化可以分为对称量化以及分为对称量化和非对称量化

⚙️ 区别

  • 对称量化(Symmetric Quantization)

X i n t = r o u n d ( X f l o a t s c a l e ) , s c a l e = m a x ( ∣ X ∣ ) 2 n − 1 − 1 X_{int}=round(\frac{X_{float}}{scale}), scale = \frac{max(|X|)}{2^{n-1}-1} Xint=round(scaleXfloat),scale=2n−1−1max(∣X∣)

  • 非对称量化(Affine Quantization)

X i n t = r o u n d ( X f l o a t s c a l e ) + z e r o _ p o i n t , s c a l e = m a x x − m i n ) x 2 n − 1 X_{int}=round(\frac{X_{float}}{scale}) + zero\point, scale = \frac{max_x-min)x}{2^{n}-1} Xint=round(scaleXfloat)+zero_point,scale=2n−1maxx−min)x

z e r o _ p o i n t = r o u n d ( − m i n ( x ) s c a l e ) zero\_point = round(\frac{-min(x)}{scale}) zero_point=round(scale−min(x))

特性 对称量化(Symmetric Quantization) 非对称量化(Affine Quantization)
零点位置 固定为0 动态计算(zero_point)
数值范围 [-127, 127] (int8) [0, 255] (uint8)
计算开销 更低(无需zero_point计算) 更高
精度损失 对偏斜分布敏感 更鲁棒,能更好处理数据分布偏斜的情况
典型应用 权重量化(正负均衡) 激活值量化
硬件支持 广泛支持(如GPU/TPU) 需要额外处理zero_point

三、QLoRA 量化细节

如果你仔细阅读过原文,你会发现,QLoRA 论文里的关键点不是"随便 4bit 一量就完了",而是有几层工程小心机:

1️⃣ NF4:不是"粗暴等分",而是"按权重习性切块"

先说直觉上熟悉的"Uniform 4bit":

  • 把一个区间均匀切成 16 份(4bit → 16 个格子);
  • 不管权重实际长什么样,只要落在区间里,就强行映射到这 16 个格子中的一个。

问题是:神经网络的权重分布不是均匀的,而是接近"钟形曲线"(高斯分布附近),绝大多数数都挤在 0 附近,离得远的极大/极小值很少。如果用 "均匀量化" 去切,可能会出现:

🔴 中间这段(权重密集的区域)被切得很粗;

🔴 两边(几乎没什么值)却分配了不少格子,浪费。

NF4(NormalFloat4 / NormalFloat-4)这个"按高斯分布定制的 4bit 量化格式",最早是作为一种适配"近似正态分布权重"的 4bit 数据类型提出的。本质上是:

先假设权重接近高斯分布,然后专门为"高斯形状"设计一套 4bit 刻度表,让中间密集区域的刻度更密,两侧稀疏区域刻度更稀

你可以类比成:

  • Uniform 4bit:把 0--100 的温度每 6.25°C 一格,不管你常用的是 20--30°C 还是别的;
  • NF4:知道你日常只关心 15--35°C,于是中间这段 15--35°C 刻度超密,两边的极端温度(-20 或 80°C)刻度就粗放一点。

所以,在"权重真正密集的地方",NF4 给了更多精度 ,这就是为什么在 4bit 这么粗的比特下,QLoRA 还能保证精度比较好。QLoRA 证明了:在大规模 LLM 上,用 NF4 这种 4bit 格式 + Double Quantization + PagedOptimizer,再配合 LoRA,只训练低秩增量,就可以在几乎不掉点的前提下,大幅降低显存/存储成本。

2️⃣ Double Quantization

❌ 普通块量化:

  • 对每个 block 的权重做 4bit / 8bit 量化,需要一个 scale(甚至还要 zero_point)来还原;
  • 这些 scale 一般用 fp16/fp32 存,虽然数量比权重少很多,但单个很"贵"。

✔️ QLoRA 的 Double Quantization:

  • 第一级:对每个 block 做 4bit 量化,得到:
    • 一个 4bit 的 codeᵢ 列表;
    • 一个"粗略的 scaleᵢ"集合。
  • 第二级:把所有 scaleᵢ 本身当成数据再看一眼 ,发现它们其实也不是乱分布的:
    • 有点像"每个班的平均分",班与班之间也有结构,不是任意乱跳;
    • 所以再对 {scaleᵢ} 做一次 8bit / 更低比特的量化,得到 {scale_codes, global_scale}

因此,最终的存储结构会变成:

  • 权重 block 的 4bit code(第一层);
  • 再加上"被量化过的 scale code"(第二层);
  • 以及用于还原 scale 的全局 scale 等小量数据。

这里的关键区别在于:

单层量化:只在"块内"做压缩

Double Quantization:先做块内压缩,再在"块与块之间"对 scale 做二次压缩

🧠 1.2 两次压缩可以等价为一次粗scale压缩吗(笔者疑问)

如果直接"一次性量化"整个大矩阵 W,想做到类似的压缩率,就会遇到两个问题:

  1. 要么范围设太广:
    • 需要覆盖所有 block 的极值 → 刻度变得很稀疏;
    • 中间密集区域的精度会牺牲得很厉害,误差非常大。
  2. 要么范围设太窄:
    • 兼顾不了所有 block 的分布 → 有些 block 会被严重截断,loss 会炸。

而块量化 + Double Quantization 正是通过这两层结构:

  • 第一层 block 内量化,让每块自己选一个合适的 scale 范围,局部精度高
  • 第二层对 scale 的低比特压缩,是对这些"局部 scale 参数"再做一个结构化压缩,减少存储开销

四、QLoRA 工程配置要点

在 HuggingFace 生态里,一般是这样一个栈:

  • bitsandbytes 提供 4bit / 8bit 量化 + 高效算子;
  • transformers + peft 管 LoRA 注入。

配置示例

python 复制代码
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()

五、QLoRA-CLIP 图文检索微调示例

  • 环境准备
python 复制代码
pip install "transformers>=4.44.0" "datasets" "accelerate" "peft>=0.8.0" \
            "bitsandbytes>=0.45.0" "torch" "torchvision" "Pillow"
  • 数据集自定义
python 复制代码
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}
  • 4bit 量化加载 CLIP
python 复制代码
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)
  • 注入 LoRA
python 复制代码
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()
  • dataloader
python 复制代码
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,
)
  • 训练代码
python 复制代码
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}")
相关推荐
2401_841495642 小时前
【LeetCode刷题】合并区间
数据结构·python·算法·leetcode·合并·遍历·排序
晞微2 小时前
基于 Gradio 构建神经网络 GUI 实验平台:感知器 / BP/Hopfield/AlexNet/VGG/ResNet 一站式实现
人工智能·深度学习·神经网络
柳安忆2 小时前
[GLM-4.6V 多模态能力测评】对论文pipeline图的理解能力 #视觉理解MCP、#GLM我的编码搭子
人工智能·深度学习
拉普拉斯妖1082 小时前
DAY33 简单的神经网络
人工智能·深度学习·神经网络
andwhataboutit?2 小时前
神经网络某些概念(持续更新
人工智能·深度学习·神经网络
xu_yule2 小时前
数据结构(14)二叉树的模拟实现和便利代码
数据结构·算法
nwsuaf_huasir2 小时前
深度学习1.2-软件篇-Anaconda软件常见命令与使用
人工智能·深度学习
MonkeyKing_sunyuhua3 小时前
SOTA 级别的模型,其中SOTA是什么意思
大模型
代码游侠3 小时前
应用——文件I/O操作代码
linux·运维·c语言·笔记·学习·算法