LoRA и QLoRA
Почему дообучение можно свести к низкоранговой добавке BA, как выбирать r и α, как слить веса и как QLoRA с 4-битной NF4-базой умещает дообучение 7B-модели в несколько гигабайт.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Полное дообучениеполное дообучение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.
Идея: обновление весов низкого ранга
Заголовок раздела «Идея: обновление весов низкого ранга»При полном дообучении слой с весом превращается в , где того же размера. Гипотеза LoRA: полезная поправка имеет низкий ранг. Значит, её можно записать как произведение двух узких матриц:
Запись значит « намного меньше обоих размеров матрицы»: например, при . Сначала сжимает вход из чисел до , затем разворачивает их в чисел. Рангранг матрицыEN matrix rankЧисло линейно независимых строк (столбцов). Матрица B·A, где B размера d×r, а A — r×k, имеет ранг не больше r. (урок 1.1.3) произведения не больше : вся поправка живёт в -мерном подпространстве. заморожена, обучаются только и .
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, | Доля |
|---|---|---|---|
q_proj Qwen2.5-0.5B, | 802 816 | 14 336 | 1.8% |
down_proj Qwen2.5-0.5B, | 4 358 144 | 46 080 | 1.1% |
| (слой 7B-модели) | 16 777 216 | 65 536 | 0.4% |
Чем больше матрица, тем меньше доля: число параметров LoRA растёт линейно по размеру, а матрица — квадратично.
Инициализация. заполняют маленьким случайным шумом (в 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), — нулями. Тогда в начале , и модель стартует ровно с исходной. Тонкость: градиент по равен , поэтому на первом шаге не двигается, учится только . Со второго шага , и подключается . Это вы проверите в задании.
Вычисления. На обучении целиком не собирают: считают — сначала сжать вход до чисел, потом развернуть. Стоимость адаптера — на токенов, это проценты от основного умножения.
Почему это работает: внутренняя размерность
Заголовок раздела «Почему это работает: внутренняя размерность»Почему маленького ранга хватает? Работы о внутренней размерностивнутренняя размерностьEN intrinsic dimensionСколько независимых «направлений» в пространстве параметров реально нужно, чтобы решить задачу. Объясняет, почему LoRA хватает малого ранга. отвечают так. Будем обучать не все параметров модели, а только чисел, которые фиксированная случайная проекция разворачивает во всё пространство весов:
Здесь — предобученные веса (заморожены), — случайная матрица (не обучается), обучается только вектор . Наименьшее , при котором достигается 90% качества полного дообучения, называют внутренней размерностью задачи. Aghajanyan и соавторы (2020) показали: для RoBERTa на задаче MRPC (распознать, перефразирует ли одно предложение другое) хватает 200 таких чисел — при сотнях миллионов весов модели. Предобучение делает внутреннюю размерность задач маленькой, и у больших моделей она в целом ниже. LoRA — та же идея, только подпространство не случайное, а обучаемое и устроенное послойно: в каждой матрице.
Интуиция: претрейн уже построил богатое пространство признаков. Дообучению нужно не строить новые, а слегка перенаправить несколько существующих. Авторы LoRA проверили это и на GPT-3: даже – на проекциях внимания часто работает почти так же, как большие ранги.
Эксперимент: какой ранг нужен
Заголовок раздела «Эксперимент: какой ранг нужен»Сгенерируем «нужное» дообучение ранга 4 и попробуем выучить его адаптерами разного ранга. Для сравнения посчитаем теоретический минимум ошибки — лучшее приближение ранга rнизкоранговое приближениеEN low-rank approximationЗамена матрицы ближайшей матрицей ранга r. По теореме Эккарта — Янга лучшая получается, если оставить r старших слагаемых SVD. через SVD (теорема Эккарта–Янга из урока 1.1.3: лучшее приближение ранга — оставить наибольших сингулярных чисел).
import torch
torch.manual_seed(0)torch.set_num_threads(1)d, true_rank = 64, 4W0 = 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 # «нужное» дообучение имеет ранг 4X = 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.783r=2: обучаемых параметров 256 из 4096 | ошибка ΔW 0.601 | теор. минимум 0.599r=4: обучаемых параметров 512 из 4096 | ошибка ΔW 0.000 | теор. минимум 0.000r=8: обучаемых параметров 1024 из 4096 | ошибка ΔW 0.001 | теор. минимум 0.000Два вывода. В этом эксперименте градиентный спуск нашёл оптимальное низкоранговое приближение: ошибка совпадает с пределом SVD. И как только достигает настоящего ранга поправки, ошибка падает до нуля — с 1/8 параметров.
Гиперпараметры: r, α, куда ставить
Заголовок раздела «Гиперпараметры: r, α, куда ставить»Ранг . Типично 8–64. По умолчанию и в PEFT, и в LLaMA-Factory. Больше данных и сложнее задача — больше ранг.
и масштаб . Множитель нужен, чтобы при смене не перенастраивать learning rate. Авторы
LoRA фиксируют при первом выбранном и больше его не трогают: с Adam подбор почти равносилен
подбору learning rate. На практике ставят (по умолчанию в PEFT: ) или
(по умолчанию в LLaMA-Factory). Вариант rsLoRA использует масштаб : при больших рангах он
обучается стабильнее (use_rslora=True в PEFT).
Куда ставить адаптеры. В статье LoRA — только на и . Авторы 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 млн параметров):
| Адаптеры | Обучаемых параметров | Доля | |
|---|---|---|---|
q_proj, v_proj | 8 | 540 672 | 0.11% |
| все 7 линейных слоёв | 8 | 4 399 104 | 0.88% |
| все 7 линейных слоёв | 16 | 8 798 208 | 1.75% |
Learning rate. У LoRA он выше, чем при полном дообучении: обычно – (в примере LLaMA-Factory — ), а для полного SFT — порядка . В блог-посте 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; после слияния адаптер не замедляет инференс.):
Получается обычный Linear той же формы, и на инференсе нет никаких лишних вычислений. В этом отличие от
адаптеров 2019 года: те добавляли в сеть новые слои и замедляли инференс. В PEFT слияние делает
model.merge_and_unload(). Операция обратима: вычтите ту же поправку, и вернётся исходный вес — с точностью до
округления (в BF16 после слияния и вычитания последние знаки могут не совпасть).
Можно и не сливать. Адаптер Qwen2.5-0.5B с на всех линейных слоях — 8.8 млн параметров, около 18 МБ в BF16, а сама модель — около 1 ГБ. Одна база и десятки адаптеров под разные задачи: серверы инференса (например, vLLM) умеют подставлять нужный LoRA для каждого запроса.
Память: полное дообучение, LoRA и QLoRA
Заголовок раздела «Память: полное дообучение, LoRA и QLoRA»Посчитаем память на веса, градиенты и состояние оптимизатора. Активации зависят от длины последовательности и батча, и их уменьшает 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.
QLoRA: LoRA поверх 4-битной модели
Заголовок раздела «QLoRA: LoRA поверх 4-битной модели»Идея: раз база заморожена, хранить её можно грубо. Веса лежат в памяти в 4 битах, а при каждом умножении блок весов деквантуется в BF16 «на лету». Адаптеры LoRA остаются в BF16 и получают градиенты, которые проходят сквозь замороженную деквантованную базу. QLoRA добавляет к этому три приёма.
1. NF4 — 4-битный формат под нормальное распределение
Заголовок раздела «1. NF4 — 4-битный формат под нормальное распределение»Веса обученных сетей распределены примерно нормально. Обычное INT4 делит отрезок на 16 равных частей: на хвосты распределения, где весов мало, тратятся такие же уровни, как на центр. NF4NF4 (4-битный NormalFloat)EN 4-bit NormalFloat (NF4)16 уровней — квантили нормального распределения, поэтому нормально распределённые веса квантуются точнее, чем равномерной сеткой INT4. выбирает 16 уровней как квантили стандартного нормального распределения. Квантиль уровня — такое число, что левее него лежит доля всех значений: квантиль 0.5 — медиана (для это 0), квантиль 0.84 — примерно 1. Тогда каждый уровень используется примерно одинаково часто; авторы QLoRA называют такой формат информационно-теоретически оптимальным для нормально распределённых весов. Веса делятся на блоки по 64, у каждого блока свой масштаб — максимум модуля (absmax). Сравним на нормальных весах:
import numpy as npfrom 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.0117NF4 : относительная ошибка 0.0084Уровни NF4 гуще у нуля и реже на краях. При тех же 4 битах ошибка на 28% меньше. Уровни из кода совпадают с таблицей NF4 в библиотеке bitsandbytes.
2. Двойное квантование
Заголовок раздела «2. Двойное квантование»Масштаб каждого блока хранится в FP32: 32 бита на 64 веса — ещё 0.5 бита на параметр. QLoRA квантует и сами масштабы: в 8 бит, блоками по 256. Расход падает до бита на параметр — это экономия около 3 ГБ на модели в 65B. Отсюда число 4.127 бита в калькуляторе выше.
3. Paged optimizers
Заголовок раздела «3. Paged optimizers»При длинных последовательностях потребление памяти скачет, и обучение падает с out-of-memory. QLoRA размещает состояние оптимизатора в unified memory NVIDIA (общем адресном пространстве видеопамяти и оперативной памяти): при нехватке видеопамяти страницы автоматически уходят в оперативную память и возвращаются обратно.
Результат. QLoRA позволила дообучить модель на 65B на одной GPU с 48 ГБ. Модель Guanaco за 24 часа обучения на одной видеокарте достигла 99.3% уровня ChatGPT на бенчмарке Vicuna. Цена — скорость: деквантование на каждом шаге делает QLoRA медленнее обычной LoRA.
В коде QLoRA — это несколько строк поверх обычной LoRA. Нужны видеокарта NVIDIA и библиотека bitsandbytes:
import torchfrom peft import LoraConfig, get_peft_model, prepare_model_for_kbit_trainingfrom 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 checkpointingmodel = get_peft_model(model, LoraConfig(r=16, lora_alpha=32, target_modules="all-linear", task_type="CAUSAL_LM"))model.print_trainable_parameters()Варианты LoRA — коротко
Заголовок раздела «Варианты LoRA — коротко»| Метод | Идея | Где включить |
|---|---|---|
| rsLoRA | масштаб — стабильнее при больших | use_rslora в PEFT и LLaMA-Factory |
| DoRA | разложить вес на длину и направление: LoRA меняет направление, длина обучается отдельным вектором | use_dora в PEFT и LLaMA-Factory |
| LoRA+ | для learning rate больше, чем для | loraplus_lr_ratio в LLaMA-Factory |
| PiSSA | инициализировать и главными сингулярными векторами | init_lora_weights="pissa" в PEFT, pissa_init в LLaMA-Factory |
Практика
Заголовок раздела «Практика»Реализуйте LoRA-слой на NumPy: инициализацию, прямой проход через узкое горлышко, слияние весов, подсчёт параметров и градиенты по и . Тесты проверят эквивалентность слияния, ранг и то, что на первом шаге учится только .
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))
Проверь себя
Заголовок раздела «Проверь себя»- LoRA заменяет полную поправку низкоранговой : обучается параметров вместо , обычно меньше 2% модели.
- Работает, потому что у задач дообучения маленькая внутренняя размерность. Для нового знания в больших объёмах низкого ранга может не хватить.
- на старте, масштаб , адаптеры на всех линейных слоях, LR около . После обучения адаптер сливается с весом без потерь скорости.
- QLoRA: база в NF4 с двойным квантованием, адаптеры в BF16, paged optimizers. 7B-модель дообучается на одной потребительской видеокарте.
Дальше: собираем всё вместе — дообучаем настоящую Qwen на русском датасете и сравниваем модель до и после.