在前面的 LoRA / QLoRA 里,我们基本解决了两个问题:
这两种方式分别通过在找到最小的更新矩阵、最小精度来实现显存的减少。但在工业、科研中,依然存在着一个问题:同样的数据、同样的训练流程下,用 LoRA 微调和用 Full Fine-tuning 微调,最终效果之间的差距依然存在着GAP,显然是通过精度为代价兑换的。那么如何继续往 Full FT 的学习能力靠一靠呢?
DoRA(Decomposed / Direction-oriented Rank Adaptation,论文正式名是 Weight-Decomposed Low-Rank Adaptation)就是在回答这个问题:
LoRA 回顾:
💡 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\,$`其中:
`$m_j$ `是一个标量,表示这列向量有多长(magnitude);
`$\hat{v}_j $`是一个单位向量(长度为 1),表示这列向量指向哪里(direction)。
也就是说,一列权重 = “这列的长度” × “这列的方向”。

综上所述,DoRA就是把权重矩阵 W \in \mathbb{R}^{d \times k} 按列拆成:W = m \cdot \frac{V}{\|V\|_c}
有了「长度 × 方向」这个拆法之后,DoRA 接下来干了两件事:
\Delta V = BA,\quad V' = W_0 + \Delta V
然后,对这个V' 做一次“按列归一化”,得到新的单位方向: \hat{V}' = \frac{V'}{\|V'\|_c}
最终整层权重就是:
W' = m \cdot \hat{V}' \;=\; m \cdot \frac{W_0 + BA}{\|W_0 + BA\|_c}
简单来说就是:先训练了方向,归一化变成一个单位向量,最后训练长度标量
显式地把方向更新和幅度更新拆开。
论文做了一个很有意思的分析:在“幅度变化 ΔM vs 方向变化 ΔD”的空间里:
方法 | 参数化形式 | 可训练参数规模 | 行为特征 | 推理开销 |
|---|---|---|---|---|
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) |
可以给出一个“决策树式”回答:
最后再尝试 DoRA,用同等或略高的参数量换更接近 Full FT 的效果。实验上 DoRA 在多种 NLP + Vision-Language 任务上都普遍优于 LoRA。DoRA的作用并不和QLoRA一样进一步的压缩显存,而是进一步减少微调与全量微调之间的精度鸿沟
DoRALinear[out_features, in_features];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)pip install "transformers>=4.44.0" \
"peft>=0.11.0" \
"accelerate" \
"bitsandbytes>=0.45.0"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"
)LoraConfig(use_dora=True)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()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")基本思路是:
r = 8/16 起步;alpha = 2r 或 alpha = r;r=8 时到瓶颈,可以在同样 rank 下试一下 DoRA: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.
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 删除。