首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >【大模型后训练专题】 DoRA微调原理及实战项目

【大模型后训练专题】 DoRA微调原理及实战项目

原创
作者头像
九年义务漏网鲨鱼
修改2025-12-12 16:55:02
修改2025-12-12 16:55:02
4990
举报
文章被收录于专栏:面经面经

前言

在前面的 LoRA / QLoRA 里,我们基本解决了两个问题:

  1. 显存问题:QLoRA 把 base 模型压到 4bit,再挂一个全精度 LoRA 分支,显存几乎抠到极致;
  2. 参数效率问题:LoRA 只训练低秩增量 ΔW,在大模型上已经非常划算。

这两种方式分别通过在找到最小的更新矩阵、最小精度来实现显存的减少。但在工业、科研中,依然存在着一个问题:同样的数据、同样的训练流程下,用 LoRA 微调和用 Full Fine-tuning 微调,最终效果之间的差距依然存在着GAP,显然是通过精度为代价兑换的。那么如何继续往 Full FT 的学习能力靠一靠呢?

DoRA(Decomposed / Direction-oriented Rank Adaptation,论文正式名是 Weight-Decomposed Low-Rank Adaptation)就是在回答这个问题:

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

  • 它不是像QLoRA一样再去压显存,而是改变 “怎么更新权重” 来实现填补与FT之间的GAP, 模仿 Full FT 的更新模式

一、DoRA 入门

LoRA 回顾:

  • 把权重更新写成:`$W' = W_0 + \Delta W,\quad \Delta W = BA$`
  • 只训练低秩增量 ΔW,`$W_0$` 冻结。

💡 DoRA 的核心想法:

先把预训练权重 `$W_0$` 拆成 “方向 + 幅度”,

了解了核心想法之后,我们来看一下DoRA具体是怎么操作的:

1️⃣ 权重分解(Weight Decomposition)

为了方便读者更好的理解这一过程,先想象一个二维向量,一根从原点指向某个位置的箭头,可以用两件事来描述:

  • 这根箭头有多长(长度 / 模长);
  • 这根箭头朝哪里(方向单位向量)。

DoRA 做的事情就是:

把“权重矩阵里的每一列向量”,都拆成「长度(m) × 方向(v̂)」。

假设某一层的权重是一个矩阵 `$W \in \mathbb{R}^{d \times k}$`

  • 可以把它看成有 k 列,每一列是一个长度为 d 的向量:`$W = \,w_1,\ w_2,\ \dots,\ w_k\,$`
  • 对于任何一列` $w_j$`,我们都可以写成 `$w_j = m_j \cdot \hat{v}_j$`

其中:

`$m_j$ `是一个标量,表示这列向量有多长(magnitude);

`$\hat{v}_j $`是一个单位向量(长度为 1),表示这列向量指向哪里(direction)。

也就是说,一列权重 = “这列的长度” × “这列的方向”。

综上所述,DoRA就是把权重矩阵 W \in \mathbb{R}^{d \times k} 按列拆成:W = m \cdot \frac{V}{\|V\|_c}

  • m \in \mathbb{R}^{1 \times k}每一列的标量幅度(column-wise magnitude);
  • V \in \mathbb{R}^{d \times k}方向矩阵(还没单位化的 direction);
  • |\cdot|_c :对每一列做向量范数(column-wise norm)。
2️⃣ 只在“方向”上用 LoRA,长度单独学

有了「长度 × 方向」这个拆法之后,DoRA 接下来干了两件事:

  1. 方向部分用 LoRA 来更新
  • 先把预训练权重记作 W_0
  • 按 LoRA 的老传统,用一个低秩矩阵去更新它:

\Delta V = BA,\quad V' = W_0 + \Delta V

  • 可以理解为:我们先从原始方向 W_0 出发,在一个低秩子空间里轻轻“拧一拧方向”

然后,对这个V' 做一次“按列归一化”,得到新的单位方向: \hat{V}' = \frac{V'}{\|V'\|_c}

  1. 长度(幅度)用一个小向量 m 单独训练
  • 对每一列,我们额外引入一个标量 m_j ,表示“这列权重最后要放多大”;
  • 所有列的 m_j 合在一起,就是一个可训练的向量m \in \mathbb{R}^{1 \times k}

最终整层权重就是:

W' = m \cdot \hat{V}' \;=\; m \cdot \frac{W_0 + BA}{\|W_0 + BA\|_c}

简单来说就是:先训练了方向,归一化变成一个单位向量,最后训练长度标量


🧠 Q1:DoRA 和 LoRA 的本质区别是什么?
  1. 参数化方式不同
  • LoRA:W' = W_0 + BA ,所有变化都混在 ΔW 里;
  • DoRA:先写成 W = m \cdot \hat{V} ,再 W' = m \cdot \frac{W_0 + BA}{\|W_0 + BA\|_c}

显式地把方向更新幅度更新拆开。

  1. 学习行为不同(关键点)

论文做了一个很有意思的分析:在“幅度变化 ΔM vs 方向变化 ΔD”的空间里:

  • Full FT:ΔM 和 ΔD 往往是负相关 / 解耦的(有时只大改幅度,有时只微调方向);
  • LoRA:ΔM 和 ΔD 强正相关,方向和幅度被绑死一起动,灵活性不足;
  • DoRA:通过显式拆分,把行为拉近到 Full FT,学习能力更接近全参。
  1. 效果与开销上的 trade-off
  • 参数量:DoRA 比 LoRA 多一个 m 向量,但仍远小于 Full FT;
  • 计算:多了一步 column-wise norm 和缩放,有一定 overhead;
  • 收益:在 LLaMA / LLaVA 等一系列任务上,DoRA 一般能稳定超过 LoRA,接近甚至逼近 Full FT 水平

二、DoRA & LoRA & Full FT 对比分析

方法

参数化形式

可训练参数规模

行为特征

推理开销

Full FT

W' 直接更新所有参数

全部权重

幅度/方向完全自由

LoRA

W' = W_0 + BA

两个低秩矩阵A,B

幅度与方向高度耦合,更新模式单一

低(可 merge)

DoRA

W' = m \cdot \dfrac{W_0+BA}{|W_0+BA|_c}

A,B + 幅度向量 m

幅度 / 方向显式拆开,更贴近 Full FT

略高于 LoRA(可 merge)

🧠 Q2:在什么场景你会优先考虑 DoRA 而不是 LoRA

可以给出一个“决策树式”回答:

  1. 如果任务很简单 / 数据量很小 / 对指标不敏感 : LoRA 足够,配置简单,overhead 最小。
  2. 如果是 LLaMA / Qwen 这类 LLM 的复杂推理、VL 任务(例如多轮对话、视觉指令微调),发现:
    • LoRA 明显比 Full FT 掉点
    • 增大 rank、加 LoRA 层数收益有限

最后再尝试 DoRA,用同等或略高的参数量换更接近 Full FT 的效果。实验上 DoRA 在多种 NLP + Vision-Language 任务上都普遍优于 LoRA。DoRA的作用并不和QLoRA一样进一步的压缩显存,而是进一步减少微调与全量微调之间的精度鸿沟

三、手撕 DoRALinear

  • 假设线性层权重形状是 [out_features, in_features]
  • 我们按“列方向”做分解,即对每个输入通道(列)有一个幅度;
  • 方向部分用 LoRA 低秩更新。
代码语言:python
复制
import torch
import torch.nn as nn
import torch.nn.functional as F

class DoRALinear(nn.Module):
    def __init__(
        self,
        in_features: int,
        out_features: int,
        r: int = 8,
        lora_alpha: float = 8.0,
        lora_dropout: float = 0.0,
        bias: bool = True,
        eps: float = 1e-6,
    ):
        super().__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.r = r
        self.lora_alpha = lora_alpha
        self.scaling = lora_alpha / r if r > 0 else 1.0
        self.eps = eps

        # 冻结的预训练权重(W0)
        self.weight = nn.Parameter(torch.empty(out_features, in_features))
        if bias:
            self.bias = nn.Parameter(torch.zeros(out_features))
        else:
            self.bias = None

        # LoRA 分支(作用在方向上)
        if r > 0:
            self.lora_A = nn.Parameter(torch.zeros(r, in_features))
            self.lora_B = nn.Parameter(torch.zeros(out_features, r))
            self.lora_dropout = nn.Dropout(p=lora_dropout)
        else:
            self.register_parameter("lora_A", None)
            self.register_parameter("lora_B", None)
            self.lora_dropout = nn.Identity()

        self.magnitude = nn.Parameter(torch.ones(1, in_features))

        self.reset_parameters()

        self.weight.requires_grad = False

    def reset_parameters(self):
        nn.init.kaiming_uniform_(self.weight, a=5**0.5)
        if self.bias is not None:
            nn.init.zeros_(self.bias)

        if self.r > 0:
            nn.init.kaiming_uniform_(self.lora_A, a=5**0.5)
            nn.init.zeros_(self.lora_B)

        with torch.no_grad():
            # [out, in] -> 按列求范数 -> [1, in]
            col_norm = torch.norm(self.weight, dim=0, keepdim=True)  # ||W0||_c
            col_norm = torch.clamp(col_norm, min=self.eps)
            self.magnitude.copy_(col_norm)

    def _effective_weight(self):
        W0 = self.weight  # [out, in]

        if self.r > 0:
            # ΔV = scaling * B @ A   (注意这里是“权重更新”,与前向的 x 无关)
            delta_V = self.scaling * (self.lora_B @ self.lora_A)  # [out, in]
            V = W0 + delta_V
        else:
            V = W0

        # 按列归一化:|| · ||_c
        col_norm = torch.norm(V, dim=0, keepdim=True)  # [1, in]
        col_norm = torch.clamp(col_norm, min=self.eps)
        direction = V / col_norm 

        W_eff = direction * self.magnitude  # broadcast: [out, in] * [1, in]
        return W_eff

    def forward(self, x):
        """
        x: [B, in_features]
        """
        W_eff = self._effective_weight()
        return F.linear(x, W_eff, self.bias)

四、DoRA-LLM 微调实战

4.1 环境准备

代码语言:python
复制
pip install "transformers>=4.44.0" \
            "peft>=0.11.0" \
            "accelerate" \
            "bitsandbytes>=0.45.0"

4.2 加载基座模型

代码语言:python
复制
from transformers import AutoModelForCausalLM, AutoTokenizer

model_name = "meta-llama/Llama-3-8b"

tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    device_map="auto",
    torch_dtype="bfloat16",  # 或 "auto"
)

4.3 配置 DoRA:LoraConfig(use_dora=True)

代码语言:python
复制
from peft import LoraConfig, get_peft_model, TaskType

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",
    ],
    use_dora=True,
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

4.4 训练骨架

代码语言:python
复制
from transformers import TrainingArguments, Trainer

training_args = TrainingArguments(
    output_dir="./dora-llama-sft",
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=1e-4,
    num_train_epochs=3,
    logging_steps=10,
    save_steps=500,
    fp16=True,
)

trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=train_dataset, 
)

trainer.train()

model.save_pretrained("./dora-llama-sft")
🧠 Q3:DoRA 的 rank、alpha 和 LoRA 一样调吗?

基本思路是:

  1. 从 LoRA 的经验起步
    • r = 8/16 起步;
    • alpha = 2ralpha = r
  2. 如果你发现 LoRA 版本已经在 r=8 时到瓶颈,可以在同样 rank 下试一下 DoRA:
    • 常见情况是:DoRA 在同等 rank 下能进一步抬一点点指标,尤其是复杂推理/多模态任务;
  3. lora_dropout 建议保留(0.05–0.1),防止小数据集 DoRA 过拟合更严重——因为它的“表达力”确实比 LoRA 强。

五、总结

可以发现,DoRA和QLoRA做的不是同一件事情,前者是尽可能的将精度靠近全量微调,后者是尽可能的进一步压缩显存。事实上,社区已经有人将两者结合变成了QDoRA,QDoRA 则试图“两手都要”:在 QLoRA 的 4bit 省显存基础上,用 DoRA 的方向×幅度分解去缩小与 Full FT 的性能 gap,更进一步靠近甚至追平 Full FT。实践上已经有不少实验和博客报告 QDoRA 在 LLaMA-3 / Qwen2.5 等上的效果优于 QLoRA.

  • 代码实现
代码语言:python
复制
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, TaskType
import torch

model_name = "meta-llama/Llama-3-8b"

tokenizer = AutoTokenizer.from_pretrained(model_name)

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_compute_dtype=torch.bfloat16,  
    bnb_4bit_use_double_quant=True,       
    bnb_4bit_quant_type="nf4",          
)

base_model = AutoModelForCausalLM.from_pretrained(
    model_name,
    quantization_config=bnb_config,
    device_map="auto",
)

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"],
    use_dora=True,  
)

model = get_peft_model(base_model, lora_config)
model.print_trainable_parameters()  

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 一、DoRA 入门
    • 🧠 Q1:DoRA 和 LoRA 的本质区别是什么?
  • 二、DoRA & LoRA & Full FT 对比分析
    • 🧠 Q2:在什么场景你会优先考虑 DoRA 而不是 LoRA
  • 三、手撕 DoRALinear
  • 四、DoRA-LLM 微调实战
    • 4.1 环境准备
    • 4.2 加载基座模型
    • 4.3 配置 DoRA:LoraConfig(use_dora=True)
    • 4.4 训练骨架
      • 🧠 Q3:DoRA 的 rank、alpha 和 LoRA 一样调吗?
  • 五、总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档