Перейти к содержимому
Ступень 66.2 Параметрически-эффективное дообучение

LoRA и QLoRA

Почему дообучение можно свести к низкоранговой добавке BA, как выбирать r и α, как слить веса и как QLoRA с 4-битной NF4-базой умещает дообучение 7B-модели в несколько гигабайт.

УРОКstage-6/6-2-peft/01-lora.mdx1 ч 40 минУрок 1 из 2
После урока вы сможете
  • Объяснить идею W0+BAW_0 + BA через внутреннюю размерность и посчитать число обучаемых параметров
  • Реализовать LoRA-слой, слияние весов и градиенты; понимать роль rr, α\alpha и инициализации B=0B = 0
  • Оценить память при полном дообучении, LoRA и QLoRA и объяснить, как устроен формат NF4

Полное дообучениеполное дообучениеEN full fine-tuning обновляет все веса. С AdamW в смешанной точности на каждый обучаемый параметр приходится около 16 байт: BF16-вес и градиент, FP32-копия веса и два момента Adam. Для Qwen2.5-7B это ~120 ГБ ещё до активаций — две A100 на 80 ГБ. Каждая дообученная версия — снова полная копия модели на 15 ГБ.

LoRALoRA (низкоранговая адаптация)EN low-rank adaptation (LoRA) (Microsoft, 2021) замораживает исходные веса и обучает маленькую добавку — меньше 1–2% параметров. АдаптерадаптерEN adapter весит десятки мегабайт, базовая модель общая для всех задач. QLoRAQLoRA (LoRA поверх 4-битной модели)EN quantized LoRA (QLoRA) (2023) идёт дальше: хранит замороженную базу в 4 битах, и дообучение 7B-модели помещается в одну потребительскую видеокарту. Это главный способ параметрически-эффективного дообучения (PEFT)параметрически-эффективное дообучение (PEFT)EN parameter-efficient fine-tuning (PEFT)Обучается малая доля параметров (адаптеры, LoRA, префиксы), основная модель заморожена. Так же называется библиотека Hugging Face., им пользуются почти все практики дообучения открытых моделей, от LLaMA-Factory до MiniMind.

При полном дообучении слой с весом W0∈Rd×kW_0 \in \mathbb{R}^{d \times k} превращается в W0+ΔWW_0 + \Delta W, где ΔW\Delta W того же размера. Гипотеза LoRA: полезная поправка ΔW\Delta W имеет низкий ранг. Значит, её можно записать как произведение двух узких матриц:

h=W0x+ΔWx=W0x+αrBAx,A∈Rr×k,  B∈Rd×r,  r≪min⁡(d,k)\begin{gathered} h = W_0 x + \Delta W x = W_0 x + \frac{\alpha}{r} B A x, \\ A \in \mathbb{R}^{r \times k},\; B \in \mathbb{R}^{d \times r},\; r \ll \min(d, k) \end{gathered}

Запись r≪min⁡(d,k)r \ll \min(d, k) значит «rr намного меньше обоих размеров матрицы»: например, r=8r = 8 при d=k=4096d = k = 4096. Сначала AA сжимает вход из kk чисел до rr, затем BB разворачивает их в dd чисел. Рангранг матрицыEN matrix rankЧисло линейно независимых строк (столбцов). Матрица B·A, где B размера d×r, а A — r×k, имеет ранг не больше r. (урок 1.1.3) произведения BABA не больше rr: вся поправка живёт в rr-мерном подпространстве. W0W_0 заморожена, обучаются только AA и BB.

flowchart LR
X["x (k)"] --> W["W₀: d×k<br/>заморожена"]
X --> A["A: r×k<br/>обучается"]
A --> Z["r чисел"]
Z --> B["B: d×r<br/>обучается"]
B --> S["× α/r"]
W --> P(("+"))
S --> P
P --> H["h (d)"]
LoRA-слой: основная ветка заморожена, поправка проходит через узкое горлышко ранга r

Сколько это параметров. Вместо d⋅kd \cdot k обучаются r(d+k)r(d + k):

СлойПолная матрицаLoRA, r=8r = 8Доля
q_proj Qwen2.5-0.5B, 896×896896 \times 896802 81614 3361.8%
down_proj Qwen2.5-0.5B, 896×4864896 \times 48644 358 14446 0801.1%
4096×40964096 \times 4096 (слой 7B-модели)16 777 21665 5360.4%

Чем больше матрица, тем меньше доля: число параметров LoRA растёт линейно по размеру, а матрица — квадратично.

Инициализация. AA заполняют маленьким случайным шумом (в PEFT — Kaimingинициализация Kaiming (He)EN Kaiming / He initializationVar(w) = 2 / n_in (He и соавт., MSRA, 2015). Двойка компенсирует то, что ReLU обнуляет половину входов. В PyTorch — nn.init.kaiming_normal_.-uniform, урок 3.4.1), BB — нулями. Тогда в начале ΔW=0\Delta W = 0, и модель стартует ровно с исходной. Тонкость: градиент по AA равен αrB⊤(…)\frac{\alpha}{r} B^\top (\ldots), поэтому на первом шаге AA не двигается, учится только BB. Со второго шага B≠0B \neq 0, и подключается AA. Это вы проверите в задании.

Вычисления. На обучении ΔW\Delta W целиком не собирают: считают (xA⊤)B⊤(x A^\top) B^\top — сначала сжать вход до rr чисел, потом развернуть. Стоимость адаптера — O(n⋅r⋅(d+k))O(n \cdot r \cdot (d + k)) на nn токенов, это проценты от основного умножения.

Почему это работает: внутренняя размерность

Заголовок раздела «Почему это работает: внутренняя размерность»

Почему маленького ранга хватает? Работы о внутренней размерностивнутренняя размерностьEN intrinsic dimensionСколько независимых «направлений» в пространстве параметров реально нужно, чтобы решить задачу. Объясняет, почему LoRA хватает малого ранга. отвечают так. Будем обучать не все DD параметров модели, а только dd чисел, которые фиксированная случайная проекция разворачивает во всё пространство весов:

θ=θ0+Pz,z∈Rd,  P∈RD×d,  d≪D\theta = \theta_0 + P z, \qquad z \in \mathbb{R}^{d},\; P \in \mathbb{R}^{D \times d},\; d \ll D

Здесь θ0\theta_0 — предобученные веса (заморожены), PP — случайная матрица (не обучается), обучается только вектор zz. Наименьшее dd, при котором достигается 90% качества полного дообучения, называют внутренней размерностью задачи. Aghajanyan и соавторы (2020) показали: для RoBERTa на задаче MRPC (распознать, перефразирует ли одно предложение другое) хватает 200 таких чисел — при сотнях миллионов весов модели. Предобучение делает внутреннюю размерность задач маленькой, и у больших моделей она в целом ниже. LoRA — та же идея, только подпространство не случайное, а обучаемое и устроенное послойно: BABA в каждой матрице.

Интуиция: претрейн уже построил богатое пространство признаков. Дообучению нужно не строить новые, а слегка перенаправить несколько существующих. Авторы LoRA проверили это и на GPT-3: даже r=1r = 1–44 на проекциях внимания часто работает почти так же, как большие ранги.

Сгенерируем «нужное» дообучение ΔW\Delta W ранга 4 и попробуем выучить его адаптерами разного ранга. Для сравнения посчитаем теоретический минимум ошибки — лучшее приближение ранга rнизкоранговое приближениеEN low-rank approximationЗамена матрицы ближайшей матрицей ранга r. По теореме Эккарта — Янга лучшая получается, если оставить r старших слагаемых SVD. через SVD (теорема Эккарта–Янга из урока 1.1.3: лучшее приближение ранга rr — оставить rr наибольших сингулярных чисел).

import torch
torch.manual_seed(0)
torch.set_num_threads(1)
d, true_rank = 64, 4
W0 = torch.randn(d, d) / d**0.5 # «предобученный» вес, заморожен
U, V = torch.randn(d, true_rank), torch.randn(true_rank, d)
delta_true = 0.3 * (U @ V) / d**0.5 # «нужное» дообучение имеет ранг 4
X = torch.randn(1024, d)
Y = X @ (W0 + delta_true).T
S = torch.linalg.svdvals(delta_true)
print("сингулярные числа ΔW:", [round(s, 2) for s in S[:6].tolist()])
for r in [1, 2, 4, 8]:
A = (0.01 * torch.randn(r, d)).requires_grad_() # A — шум
B = torch.zeros(d, r, requires_grad=True) # B — нули: старт с ΔW = 0
opt = torch.optim.Adam([A, B], lr=1e-2)
for step in range(800):
loss = ((X @ W0.T + X @ A.T @ B.T - Y) ** 2).mean()
opt.zero_grad()
loss.backward()
opt.step()
rel = (torch.linalg.norm(B @ A - delta_true) / torch.linalg.norm(delta_true)).item()
best = (S[r:].square().sum().sqrt() / S.square().sum().sqrt()).item() # лучший возможный ранг-r (SVD)
print(f"r={r}: обучаемых параметров {2 * d * r:4d} из {d * d} | ошибка ΔW {rel:.3f} | теор. минимум {best:.3f}")
сингулярные числа ΔW: [2.85, 2.31, 2.15, 1.71, 0.0, 0.0]
r=1: обучаемых параметров 128 из 4096 | ошибка ΔW 0.784 | теор. минимум 0.783
r=2: обучаемых параметров 256 из 4096 | ошибка ΔW 0.601 | теор. минимум 0.599
r=4: обучаемых параметров 512 из 4096 | ошибка ΔW 0.000 | теор. минимум 0.000
r=8: обучаемых параметров 1024 из 4096 | ошибка ΔW 0.001 | теор. минимум 0.000

Два вывода. В этом эксперименте градиентный спуск нашёл оптимальное низкоранговое приближение: ошибка совпадает с пределом SVD. И как только rr достигает настоящего ранга поправки, ошибка падает до нуля — с 1/8 параметров.

Ранг rr. Типично 8–64. По умолчанию r=8r = 8 и в PEFT, и в LLaMA-Factory. Больше данных и сложнее задача — больше ранг.

α\alpha и масштаб α/r\alpha / r. Множитель нужен, чтобы при смене rr не перенастраивать learning rate. Авторы LoRA фиксируют α\alpha при первом выбранном rr и больше его не трогают: с Adam подбор α\alpha почти равносилен подбору learning rate. На практике ставят α=r\alpha = r (по умолчанию в PEFT: r=α=8r = \alpha = 8) или α=2r\alpha = 2r (по умолчанию в LLaMA-Factory). Вариант rsLoRA использует масштаб α/r\alpha / \sqrt{r}: при больших рангах он обучается стабильнее (use_rslora=True в PEFT).

Куда ставить адаптеры. В статье LoRA — только на WqW_q и WvW_v. Авторы QLoRA выяснили, что для качества полного дообучения нужны адаптеры на всех линейных слоях блока. Для Qwen это q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj. Вот что выдаёт print_trainable_parameters() из PEFT для Qwen2.5-0.5B (494 млн параметров):

АдаптерыrrОбучаемых параметровДоля
q_proj, v_proj8540 6720.11%
все 7 линейных слоёв84 399 1040.88%
все 7 линейных слоёв168 798 2081.75%

Learning rate. У LoRA он выше, чем при полном дообучении: обычно 10−410^{-4}–2⋅10−42 \cdot 10^{-4} (в примере LLaMA-Factory — 10−410^{-4}), а для полного SFT — порядка 10−510^{-5}. В блог-посте Thinking Machines «LoRA Without Regret» (2025) оптимальный LR для LoRA в их экспериментах оказался примерно в 10 раз выше, чем для полного дообучения.

Эмбеддинги и LM-голову обычно не трогают. Исключение — если вы добавили в словарь новые токены: тогда эти слои нужно обучать целиком (modules_to_save=["embed_tokens", "lm_head"] в PEFT).

После обучения адаптер можно «впечатать» в вес (слияние весовслияние весовEN weight mergingДля LoRA — замена W на W + (α/r)·BA; после слияния адаптер не замедляет инференс.):

Wmerged=W0+αrBAW_{\text{merged}} = W_0 + \frac{\alpha}{r} B A

Получается обычный Linear той же формы, и на инференсе нет никаких лишних вычислений. В этом отличие от адаптеров 2019 года: те добавляли в сеть новые слои и замедляли инференс. В PEFT слияние делает model.merge_and_unload(). Операция обратима: вычтите ту же поправку, и вернётся исходный вес — с точностью до округления (в BF16 после слияния и вычитания последние знаки могут не совпасть).

Можно и не сливать. Адаптер Qwen2.5-0.5B с r=16r = 16 на всех линейных слоях — 8.8 млн параметров, около 18 МБ в BF16, а сама модель — около 1 ГБ. Одна база и десятки адаптеров под разные задачи: серверы инференса (например, vLLM) умеют подставлять нужный LoRA для каждого запроса.

Посчитаем память на веса, градиенты и состояние оптимизатора. Активации зависят от длины последовательности и батча, и их уменьшает gradient checkpointing (перевычисление активацийперевычисление активаций (activation checkpointing)EN activation checkpointing (recomputation)Не хранить часть активаций прямого прохода, а пересчитать их при обратном. Экономит память ценой дополнительных вычислений., урок 5.4.3), поэтому здесь их не считаем.

def finetune_memory_gb(n_params, n_lora, mode):
"""Веса + градиенты + состояние AdamW, без активаций. Результат в ГБ (1 ГБ = 1e9 байт)."""
trainable = 2 + 2 + 4 + 4 + 4 # bf16 вес, bf16 градиент, fp32 копия веса, Adam m и v в fp32
if mode == "full":
return n_params * trainable / 1e9
base = {"lora": 2.0, "qlora": 4.127 / 8}[mode] # байт на замороженный параметр: bf16 или NF4 + константы
return (n_params * base + n_lora * trainable) / 1e9
# (модель, всего параметров, параметров LoRA r=16 на всех линейных слоях блоков)
for name, n, n_lora in [("Qwen2.5-0.5B", 0.494e9, 8.8e6), ("Qwen2.5-7B", 7.6e9, 40.4e6), ("Qwen2.5-72B", 72.7e9, 210.5e6)]:
full, lora, qlora = (finetune_memory_gb(n, n_lora, m) for m in ("full", "lora", "qlora"))
print(f"{name:>13}: full {full:7.1f} ГБ | LoRA {lora:6.1f} ГБ | QLoRA {qlora:5.1f} ГБ")
Qwen2.5-0.5B: full 7.9 ГБ | LoRA 1.1 ГБ | QLoRA 0.4 ГБ
Qwen2.5-7B: full 121.6 ГБ | LoRA 15.8 ГБ | QLoRA 4.6 ГБ
Qwen2.5-72B: full 1163.2 ГБ | LoRA 148.8 ГБ | QLoRA 40.9 ГБ

Это нижние оценки. bitsandbytes оставляет эмбеддинги и LM-голову в 16 битах, а к расчёту нужно добавить активации и буферы CUDA. Но порядок верный: 7B-модель через QLoRA помещается в видеокарту на 12–16 ГБ, через LoRA — на 24 ГБ, а полное дообучение требует нескольких серверных GPU.

Идея: раз база заморожена, хранить её можно грубо. Веса W0W_0 лежат в памяти в 4 битах, а при каждом умножении блок весов деквантуется в BF16 «на лету». Адаптеры LoRA остаются в BF16 и получают градиенты, которые проходят сквозь замороженную деквантованную базу. QLoRA добавляет к этому три приёма.

1. NF4 — 4-битный формат под нормальное распределение

Заголовок раздела «1. NF4 — 4-битный формат под нормальное распределение»

Веса обученных сетей распределены примерно нормально. Обычное INT4 делит отрезок на 16 равных частей: на хвосты распределения, где весов мало, тратятся такие же уровни, как на центр. NF4NF4 (4-битный NormalFloat)EN 4-bit NormalFloat (NF4)16 уровней — квантили нормального распределения, поэтому нормально распределённые веса квантуются точнее, чем равномерной сеткой INT4. выбирает 16 уровней как квантили стандартного нормального распределения. Квантиль уровня pp — такое число, что левее него лежит доля pp всех значений: квантиль 0.5 — медиана (для N(0,1)\mathcal{N}(0, 1) это 0), квантиль 0.84 — примерно 1. Тогда каждый уровень используется примерно одинаково часто; авторы QLoRA называют такой формат информационно-теоретически оптимальным для нормально распределённых весов. Веса делятся на блоки по 64, у каждого блока свой масштаб — максимум модуля (absmax). Сравним на нормальных весах:

import numpy as np
from statistics import NormalDist
# 16 уровней NF4: квантили N(0, 1), нормированные в [-1, 1] (8 положительных, 7 отрицательных и точный ноль)
nd = NormalDist()
pos = [nd.inv_cdf(p) for p in np.linspace(0.9677083, 0.5, 9)[:-1]]
neg = [-nd.inv_cdf(p) for p in np.linspace(0.9677083, 0.5, 8)[:-1]]
NF4 = np.sort(np.array(pos + [0.0] + neg))
NF4 /= NF4.max()
INT4 = np.arange(-7, 8) / 7 # обычное симметричное INT4: равномерная сетка
def quantize(w, levels, block=64):
"""Блочное квантование: делим на absmax блока и заменяем каждое число ближайшим уровнем."""
w = w.reshape(-1, block)
scale = np.abs(w).max(axis=1, keepdims=True) # одна константа на 64 веса
codes = np.abs(w[..., None] / scale[..., None] - levels).argmin(-1) # 4-битные коды 0..15
return (levels[codes] * scale).reshape(-1)
w = np.random.default_rng(0).normal(0, 0.02, size=64 * 4096) # веса LLM ≈ нормальные
print("уровни NF4:", np.round(NF4, 3))
for name, levels in [("INT4", INT4), ("NF4 ", NF4)]:
err = np.mean((quantize(w, levels) - w) ** 2) / np.mean(w**2)
print(f"{name}: относительная ошибка {err:.4f}")
уровни NF4: [-1. -0.696 -0.525 -0.395 -0.284 -0.185 -0.091 0. 0.08 0.161
0.246 0.338 0.441 0.563 0.723 1. ]
INT4: относительная ошибка 0.0117
NF4 : относительная ошибка 0.0084

Уровни NF4 гуще у нуля и реже на краях. При тех же 4 битах ошибка на 28% меньше. Уровни из кода совпадают с таблицей NF4 в библиотеке bitsandbytes.

Масштаб каждого блока хранится в FP32: 32 бита на 64 веса — ещё 0.5 бита на параметр. QLoRA квантует и сами масштабы: в 8 бит, блоками по 256. Расход падает до 8/64+32/(64⋅256)≈0.1278/64 + 32/(64 \cdot 256) \approx 0.127 бита на параметр — это экономия около 3 ГБ на модели в 65B. Отсюда число 4.127 бита в калькуляторе выше.

При длинных последовательностях потребление памяти скачет, и обучение падает с out-of-memory. QLoRA размещает состояние оптимизатора в unified memory NVIDIA (общем адресном пространстве видеопамяти и оперативной памяти): при нехватке видеопамяти страницы автоматически уходят в оперативную память и возвращаются обратно.

Результат. QLoRA позволила дообучить модель на 65B на одной GPU с 48 ГБ. Модель Guanaco за 24 часа обучения на одной видеокарте достигла 99.3% уровня ChatGPT на бенчмарке Vicuna. Цена — скорость: деквантование на каждом шаге делает QLoRA медленнее обычной LoRA.

В коде QLoRA — это несколько строк поверх обычной LoRA. Нужны видеокарта NVIDIA и библиотека bitsandbytes:

import torch
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NF4 вместо равномерного INT4
bnb_4bit_use_double_quant=True, # двойное квантование масштабов
bnb_4bit_compute_dtype=torch.bfloat16, # умножения — в BF16
)
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct", quantization_config=bnb, device_map="auto")
model = prepare_model_for_kbit_training(model) # нормы в FP32, gradient checkpointing
model = get_peft_model(model, LoraConfig(r=16, lora_alpha=32, target_modules="all-linear", task_type="CAUSAL_LM"))
model.print_trainable_parameters()
МетодИдеяГде включить
rsLoRAмасштаб α/r\alpha / \sqrt{r} — стабильнее при больших rruse_rslora в PEFT и LLaMA-Factory
DoRAразложить вес на длину и направление: LoRA меняет направление, длина обучается отдельным векторомuse_dora в PEFT и LLaMA-Factory
LoRA+для BB learning rate больше, чем для AAloraplus_lr_ratio в LLaMA-Factory
PiSSAинициализировать AA и BB главными сингулярными векторами W0W_0init_lora_weights="pissa" в PEFT, pissa_init в LLaMA-Factory

Реализуйте LoRA-слой на NumPy: инициализацию, прямой проход через узкое горлышко, слияние весов, подсчёт параметров и градиенты по AA и BB. Тесты проверят эквивалентность слияния, ранг ΔW\Delta W и то, что на первом шаге учится только BB.

LoRA-слой и слияние весовnumpy · нужен интернет7 тестов
import numpy as np

# Соглашения как в PyTorch и PEFT:
#   W: (out, in) — замороженный вес слоя, y = x @ W.T
#   A: (r, in)   — «вход» адаптера (lora_A)
#   B: (out, r)  — «выход» адаптера (lora_B)
#   ΔW = (alpha / r) * B @ A  имеет форму (out, in) и ранг не больше r


def init_lora(in_features, out_features, r, seed=0):
    """Инициализация адаптера: A ~ N(0, 0.02²) формы (r, in), B — нули формы (out, r).

    Используйте np.random.default_rng(seed). Вернуть кортеж (A, B).
    """
    # TODO: ваш код
    ...


def lora_forward(x, W, A, B, alpha, r):
    """Выход слоя с адаптером: x @ W.T + (alpha / r) * (x @ A.T) @ B.T.

    Матрицу ΔW размером (out, in) здесь НЕ собирайте — считайте через узкое горлышко ранга r.
    """
    # TODO: ваш код
    ...


def merge(W, A, B, alpha, r):
    """Слияние: новая матрица W + (alpha / r) * B @ A. Исходный W менять нельзя."""
    # TODO: ваш код
    ...


def lora_param_count(in_features, out_features, r):
    """Сколько обучаемых параметров добавляет LoRA к одному линейному слою (без bias)."""
    # TODO: ваш код
    ...


def lora_grads(x, A, B, grad_out, alpha, r):
    """Градиенты loss по A и B, если известен grad_out = dL/dy формы (n, out).

    Подсказка: y_lora = s * h @ B.T, где h = x @ A.T, s = alpha / r.
    Вернуть кортеж (dA, dB) с формами как у A и B.
    """
    # TODO: ваш код
    ...


# Проверьте себя кнопкой «Запустить»:
A, B = init_lora(in_features=6, out_features=4, r=2) or (None, None)
print("A:", None if A is None else A.shape, "B:", None if B is None else B.shape)
print("параметров LoRA для 896×896, r=8:", lora_param_count(896, 896, 8))
Проверь себя6 вопросов
1 Сколько обучаемых параметров добавит LoRA с r=8r = 8 к матрице 4096×40964096 \times 4096?
2 Зачем матрицу BB инициализируют нулями?
3 Какие утверждения о слиянии W0+αrBAW_0 + \frac{\alpha}{r} BA верны?Можно выбрать несколько ответов
4 Замороженная 7B-модель в QLoRA занимает 4.1274.127 бита на параметр. Сколько это гигабайт (1 ГБ = 10910^9 байт)? Округлите до десятых.
5 Чем NF4 лучше равномерного INT4 для весов LLM?
6 Вы обучаете LoRA с r=8r = 8 и α=16\alpha = 16. Чему равен множитель поправки α/r\alpha / r?
  • LoRA заменяет полную поправку ΔW\Delta W низкоранговой αrBA\frac{\alpha}{r} BA: обучается r(d+k)r(d + k) параметров вместо dkdk, обычно меньше 2% модели.
  • Работает, потому что у задач дообучения маленькая внутренняя размерность. Для нового знания в больших объёмах низкого ранга может не хватить.
  • B=0B = 0 на старте, масштаб α/r\alpha / r, адаптеры на всех линейных слоях, LR около 10−410^{-4}. После обучения адаптер сливается с весом без потерь скорости.
  • QLoRA: база в NF4 с двойным квантованием, адаптеры в BF16, paged optimizers. 7B-модель дообучается на одной потребительской видеокарте.

Дальше: собираем всё вместе — дообучаем настоящую Qwen на русском датасете и сравниваем модель до и после.