Fine-tuning LLM: от LoRA до full fine-tune — полное руководство по адаптации моделей
opensourceaillmfinetuningloratrainingit
Введение: зачем fine-tuning?
Вы используете Llama 3 8B для задач, которые она не умеет:
- Классификация документов по категориям вашей компании
- Генерация SQL запросов из естественного языка
- Ответы на вопросы по внутренним документам
Prompt engineering не помогает? Модель "забывает" инструкции, не стабильна в ответах, не понимает специфическую терминологию.
Решение — fine-tuning — дообучение модели на ваших данных.
В этой статье разберём:
- Что такое fine-tuning и зачем он нужен
- Full fine-tune vs Parameter-Efficient Fine-Tuning (PEFT)
- LoRA, QLoRA, Adapters, Prefix Tuning
- Подготовка данных для fine-tuning
- Практика: fine-tuning через Unsloth, Axolotl, HuggingFace
- Оценка и деплой fine-tuned модели
Проблема: почему pre-trained модель не подходит?
Pre-training vs Fine-tuning
Pre-training (базовая модель):
Данные: весь интернет (книги, статьи, код, сайты)
Цель: предсказать следующий токен
Результат: общая языковая модель
Fine-tuning (адаптированная модель):
Данные: ваш домен (документы, чаты, SQL)
Цель: специфическая задача (классификация, генерация)
Результат: модель, которая умеет ваш домен
Пример: классификация документов
Базовая Llama 3 8B:
Prompt: "К какой категории относится этот документ? [документ]"
Ответ: "Не уверен, но возможно категория: 'Общее'"
Проблема: модель не знает ваши категории!
Fine-tuned Llama 3 8B:
Ответ: "Категория: 'Юридические документы'"
Почему: модель видела 10000+ документов во время fine-tuning
Когда fine-tuning НЕ нужен?
- Да, нужен: специфический формат вывода (JSON, XML)
- Да, нужен: доменная терминология (медицина, право)
- Да, нужен: стиль общения (бренд-тон)
- Нет: общие задачи (перевод, суммаризация)
- Нет: есть API с хорошей моделью (GPT-4)
- Нет: мало данных (< 50 примеров)
Методы Fine-tuning
Full Fine-tuning
Обновляем ВСЕ параметры модели.
Llama 3 8B:
Параметры: 8B
GPU: 8×A100 80GB
Время: ~3 days
Стоимость: ~$500-1000
Llama 3 70B:
Параметры: 70B
GPU: 32×A100 80GB
Время: ~14 days
Стоимость: ~$5000-10000
# Full fine-tuning: все градиенты
for name, param in model.named_parameters():
param.requires_grad = True # Обновляем ВСЕ параметры
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-5)
PEFT (Parameter-Efficient Fine-Tuning)
Обновляем только малую часть параметров.
Методы:
1. LoRA: добавляем низкоранговые матрицы
2. Adapters: вставляем маленькие слои
3. Prefix Tuning: добавляем learnable tokens
4. P-Tuning: оптимизируем embeddings
5. IA3: scaling vectors
LoRA (Low-Rank Adaptation)
Основная идея
Вместо обновления всей матрицы W — обновляем разложение:
W_original: [4096, 4096] = 16M параметров
Обновление: ΔW = B × A, где:
B: [4096, r]
A: [r, 4096]
r = 8 → B×A = 65K параметров (×246 меньше!)
import torch
import torch.nn as nn
class LoRALayer(nn.Module):
def __init__(self, in_features, out_features, rank=8):
super().__init__()
self.rank = rank
# Инициализация A и B
self.A = nn.Linear(in_features, rank, bias=False)
self.B = nn.Linear(rank, out_features, bias=False)
self.scaling = 1.0 / rank
# Zero-init B (чтобы начать с нуля)
nn.init.zeros_(self.B.weight)
# Random init A
nn.init.normal_(self.A.weight, mean=0, std=1)
def forward(self, x):
# ΔW × x = B(A(x))
return self.scaling * self.B(self.A(x))
class LoRALinear(nn.Module):
def __init__(self, linear, rank=8):
super().__init__()
self.linear = linear # original weights
self.lora = LoRALayer(
linear.in_features,
linear.out_features,
rank
)
def forward(self, x):
return self.linear(x) + self.lora(x)
Почему low-rank работает?
Гипотеза: градиенты fine-tuning имеют низкий rank.
При full fine-tuning:
gradient = [4096, 4096] → ~16M параметров
Но эффективный rank ≈ 8-16
При LoRA:
ΔW = B × A, rank=8 → ~65K параметров
Покрываем те же 8-16 "направлений" изменения
Результат: 99% качества full fine-tune, 0.4% параметров
Практические параметры LoRA
lora_config = {
"r": 16, # rank: 8, 16, 32, 64
"lora_alpha": 32, # scaling: обычно 2*r
"lora_dropout": 0.05, # regularization
"target_modules": ["q_proj", "k_proj", "v_proj", "o_proj"],
# Какие слои адаптируем
}
# target_modules:
# - attention: q_proj, k_proj, v_proj, o_proj
# - ff: gate_proj, up_proj, down_proj
# - all: все вышеперечисленные
Рекомендации по rank:
r=8: минимум, для простых задач
r=16: стандарт, баланс качество/размер
r=32: сложные задачи, много данных
r=64: экстремальные случаи, почти full fine-tune
QLoRA: LoRA с квантованием
Проблема памяти
Full fine-tune Llama 3 8B:
Параметры (FP16): 16 GB
Градиенты (FP16): 16 GB
Optimizer states (Adam): 32 GB
Activation memory: ~10 GB
Total: ~74 GB → нужно 2×A100 80GB
LoRA fine-tune Llama 3 8B:
Параметры (FP16): 16 GB
LoRA параметры: 0.1 GB
Optimizer states (LoRA): 0.2 GB
Activation memory: ~10 GB
Total: ~26 GB → 1×A100 40GB хватает!
QLoRA: 4-bit квантование
QLoRA = 4-bit квантование + LoRA
Llama 3 8B в 4-bit:
Параметры (NF4): 4 GB (вместо 16 GB)
LoRA параметры: 0.1 GB
Optimizer states: 0.2 GB
Activation memory: ~8 GB
Total: ~12 GB → 1×RTX 3060 12GB хватает!
from transformers import BitsAndBytesConfig
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # Normal Float 4-bit
bnb_4bit_compute_dtype="float16",
bnb_4bit_use_double_quant=True # Double quantization
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B",
quantization_config=bnb_config,
device_map="auto"
)
Double Quantization
Обычная 4-bit:
W_quantized = 4 bits per parameter
Словари квантования: 32-bit
Double quantization:
W_quantized = 4 bits per parameter
Словари квантования = тоже квантованы (8-bit)
Экономия: ~0.4% памяти
Зачем? На границе памяти (12-16 GB GPU)
Adapters
Идея
Вставляем маленькие слои между существующими слоями:
Dense модель:
x → Linear → GELU → Linear → +residual → next_layer
Adapter:
x → Linear → GELU → Linear → +residual → next_layer
↓
Adapter:
x → Linear_down → ReLU → Linear_up → dropout
class Adapter(nn.Module):
def __init__(self, d_model, adapter_dim=64):
super().__init__()
self.down = nn.Linear(d_model, adapter_dim)
self.up = nn.Linear(adapter_dim, d_model)
self.activation = nn.ReLU()
self.dropout = nn.Dropout(0.1)
def forward(self, x):
return self.dropout(self.up(self.activation(self.down(x))))
# Встраиваем в трансформер:
class TransformerBlockWithAdapter(nn.Module):
def __init__(self, original_block, adapter_dim=64):
super().__init__()
self.original = original_block
self.adapter = Adapter(original_block.config.hidden_size, adapter_dim)
def forward(self, x):
x = self.original(x)
x = x + self.adapter(x) # residual adapter
return x
LoRA vs Adapters
Метод | Параметры | Память | Качество | Скорость
------------|-----------|--------|----------|----------
Full FT | 100% | 100% | 100% | 100%
LoRA | 0.5-2% | 20-30% | 95-100% | 100%
QLoRA | 0.5-2% | 10-15% | 90-98% | 95%
Adapter | 1-5% | 30-50% | 90-98% | 90%
Prefix Tun. | 0.01-0.1% | 15-20% | 80-95% | 95%
Подготовка данных
Формат данных
Формат Alpaca (стандарт для fine-tuning):
[
{
"instruction": "Классифицируй документ по категориям",
"input": "Договор аренды помещения...",
"output": "Категория: Юридические документы"
},
{
"instruction": "Сгенерируй SQL запрос",
"input": "Покажи всех пользователей с балансом > 1000",
"output": "SELECT * FROM users WHERE balance > 1000;"
}
]
Формат Chat (для instruct моделей):
[
{
"messages": [
{"role": "system", "content": "Ты юрист, помогающий с документами."},
{"role": "user", "content": "К какой категории относится этот документ?"},
{"role": "assistant", "content": "Категория: Юридические документы"}
]
}
]
Объём данных
Минимум для fine-tuning:
Простая задача (классификация): 500-1000 примеров
Средняя задача (генерация): 2000-5000 примеров
Сложная задача (рассуждения): 10000+ примеров
Рекомендации:
- 500 примеров: базовое понимание задачи
- 2000 примеров: стабильное качество
- 5000+ примеров: улучшение edge cases
- 10000+: diminishing returns (если данные не уникальны)
Data Augmentation
Увеличиваем датасет:
1. Paraphrase: перефразируем инструкции
"Классифицируй документ" → "Определи категорию документа"
2. Back-translation: EN → DE → EN
"Классифицируй" → "Klassifiziere" → "Classify"
3. Self-instruct: генерируем примеры из инструкции
GPT-4 → генерирует 100 примеров по инструкции
4. Template-based: шаблоны с вариациями
"Классифицируй: {document}"
"Категория документа: {document}"
"К какому типу относится: {document}?"
Практика: fine-tuning через Unsloth
Установка
pip install unsloth
# или для nightly:
pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"
Минимальный скрипт
from unsloth import FastLanguageModel
from datasets import load_dataset
from trl import SFTTrainer
from transformers import TrainingArguments
# 1. Загрузка модели
max_seq_length = 2048
dtype = None # auto detect
load_in_4bit = True # QLoRA
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="unsloth/llama-3-8b-bnb-4bit",
max_seq_length=max_seq_length,
dtype=dtype,
load_in_4bit=load_in_4bit,
)
# 2. Добавляем LoRA
model = FastLanguageModel.get_peft_model(
model,
r=16,
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",
],
lora_alpha=16,
lora_dropout=0, # zero for unsloth
bias="none",
use_gradient_checkpointing="unsloth",
random_state=3407,
use_rslora=False, # see https://github.com/microsoft/LoRA for rank selection
)
# 3. Загрузка данных
dataset = load_dataset("yahma/alpaca-cleaned", split="train")
def format_prompts(examples):
prompt = "Below is an instruction that describes a task. Write a response that appropriately completes the description.\n\n### Instruction:\n{}\n\n### Response:"
texts = [prompt.format(inst) for inst in examples["instruction"]]
return {"text": texts}
dataset = dataset.map(format_prompts, batched=True)
# 4. Тренировка
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset,
dataset_text_field="text",
max_seq_length=max_seq_length,
dataset_num_proc=2,
packing=False,
args=TrainingArguments(
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
warmup_steps=5,
max_steps=60, # для small dataset
learning_rate=2e-4,
fp16=not torch.cuda.is_bf16_supported(),
bf16=torch.cuda.is_bf16_supported(),
logging_steps=1,
optim="adamw_8bit",
weight_decay=0.01,
lr_scheduler_type="linear",
seed=3407,
output_dir="outputs",
),
)
# 5. Запуск
trainer.train()
# 6. Сохранение
model.save_lora("lora_adapter.safetensors")
Обучение на GPU
RTX 4090 24GB:
Llama 3 8B QLoRA: ~2 GB/min (batch=2, accum=4)
60 steps → ~2 minutes
A100 40GB:
Llama 3 8B QLoRA: ~1.5 GB/min (batch=4, accum=8)
60 steps → ~1 minute
A100 80GB:
Llama 3 70B QLoRA: ~5 GB/min (batch=8, accum=4)
1000 steps → ~3 hours
Практика: fine-tuning через Axolotl
Что такое Axolotl
Axolotl — YAML-based fine-tuning фреймворк.
Плюсы:
- Конфигурация в YAML (не код)
- Поддержка многих моделей
- Multi-GPU, DeepSpeed
- Easy to reproduce
Минусы:
- Меньше кастомизации
- Нужно изучать YAML формат
Конфигурация
# config.yaml
base_model: meta-llama/Llama-3-8B
model_type: LlamaForCausalLM
tokenizer_type: Llama3Tokenizer
load_in_8bit: false
load_in_4bit: true
strict: false
datasets:
- path:yahma/alpaca-cleaned
type: alpaca
model_config_type: LlamaForCausalLM
chat_template: llama3
lora_model_dir:
sequence_len: 2048
sample_packing: true
sample_packing_efficiency:
max_samples_per_seq: 128
lora_config:
r: 16
s: 32
lora_alpha: 32
lora_dropout: 0.05
target_modules:
- q_proj
- k_proj
- v_proj
- o_proj
- gate_proj
- up_proj
- down_proj
train_on_inputs: false
group_by_length: false
bf16: full
tf32: true
output_dir: ./lora-out
learning_rate: 2e-4
learning_rate_scheduler: cosine
num_epochs: 1
batch_size: 4
gradient_accumulation_steps: 4
warmup_ratio: 0.05
logging_steps: 1
eval_steps: 0.05
save_steps: 200
debug: false
gradient_checkpointing: true
gradient_checkpointing_kwargs:
use_reentrant: false
early_stopping_patience: 0
resume_from_checkpoint:
local_rank:
logging_first_step: true
Запуск
pip install axolotl-ai
# Training
axolotl train config.yaml
# Inference
axolotl inference config.yaml
Оценка fine-tuned модели
Метрики
Классификация:
Accuracy: % правильных ответов
F1: баланс precision/recall
Precision & Recall: по каждому классу
Генерация:
BLEU: n-gram overlap
ROUGE: recall-oriented overlap
Perplexity: уверенность модели
Чат:
Human evaluation: люди оценивают ответы
LLM-as-judge: GPT-4 оценивает ответы
HELM: comprehensive evaluation
Сравнение: до и после
Модель | Accuracy | F1 | Perplexity
--------------------|----------|------|------------
Llama 3 8B (base) | 45.2% | 0.42 | 8.31
Llama 3 8B (LoRA) | 89.7% | 0.87 | 3.12
Разница значительная!
Quick evaluation
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained("./lora-out")
tokenizer = AutoTokenizer.from_pretrained("./lora-out")
test_cases = [
"К какой категории относится: Договор аренды?",
"Сгенерируй SQL: пользователи с балансом > 1000",
]
for case in test_cases:
messages = [{"role": "user", "content": case}]
text = tokenizer.apply_chat_template(messages, tokenize=False)
inputs = tokenizer(text, return_tensors="pt").to("cuda")
outputs = model.generate(**inputs, max_new_tokens=128)
response = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(f"Q: {case}\nA: {response}\n")
Деплой fine-tuned модели
Загрузка LoRA adapter
from unsloth import FastLanguageModel
from transformers import AutoModelForCausalLM, AutoTokenizer
# Загрузка base модели
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="unsloth/llama-3-8b-bnb-4bit",
max_seq_length=2048,
)
# Загрузка LoRA adapter
FastLanguageModel.for_inference(model)
# Инференс
messages = [{"role": "user", "content": "Классифицируй: Договор аренды"}]
text = tokenizer.apply_chat_template(messages, tokenize=False)
inputs = tokenizer(text, return_tensors="pt").to("cuda")
output = model.generate(**inputs, max_new_tokens=128)
print(tokenizer.decode(output[0], skip_special_tokens=True))
Экспорт в GGUF
# Экспорт LoRA → GGUF
python convert-lora-to-ggml.py model.gguf lora_adapter.safetensors output.gguf
# Или через llama.cpp
./llama-convert-lora.py \
--model model.gguf \
--lora lora_adapter.safetensors \
--output merged.gguf
Мerging LoRA в модель
# Merge LoRA weights в base модель
./llama-merge \
--base model.gguf \
--lora lora_adapter.safetensors \
--output merged.gguf \
--scale 1.0 # scale factor для LoRA
Hyperparameters: что менять?
Learning Rate
Рекомендации:
Full FT: 1e-5 to 5e-5
LoRA: 1e-4 to 3e-4
QLoRA: 2e-4 to 4e-4
Scheduler:
linear: плавное снижение
cosine: циклическое снижение (часто лучше)
warmup: 3-5% от total steps
# Пример: cosine scheduler с warmup
TrainingArguments(
learning_rate=2e-4,
lr_scheduler_type="cosine",
warmup_ratio=0.05,
max_steps=1000,
# warmup: первые 50 steps
# decay: steps 50-1000
)
Batch Size и Gradient Accumulation
Effective batch size = batch_size × gradient_accumulation_steps × num_gpus
Пример:
batch_size = 2
accum = 4
GPUs = 1
Effective = 2 × 4 × 1 = 8
Память:
batch_size=2: 8 GB
batch_size=4: 12 GB
batch_size=8: 18 GB (OOM на 24 GB GPU)
Решение:
batch_size=2, accum=8 → effective=16, память как batch=2
Epochs
Маленький датасет (< 1000):
epochs = 3-5
(перезагружаем данные несколько раз)
Средний датасет (1000-10000):
epochs = 1-3
Большой датасет (> 10000):
epochs = 1
(с early stopping)
max_steps = (dataset_size × epochs) // (batch_size × accum)
Common Pitfalls
Catastrophic Forgetting
Проблема: fine-tuning "забывает" общие знания модели.
Симптомы:
- Модель хорошо решает новую задачу
- Но не может ответить на общие вопросы
- "Потеряла" языковые навыки
Решение:
1. Mix general data в training set
2. Lower learning rate
3. Fewer epochs
4. LoRA (меньше влияет на base weights)
Overfitting
Проблема: модель memorizes training data, не обобщает.
Симптомы:
- Training loss ↓, validation loss ↑
- На тестовых данных качество низкое
Решение:
1. Больше данных
2. Higher dropout
3. Lower rank (LoRA)
4. Early stopping
5. Data augmentation
Underfitting
Проблема: модель не выучила задачу.
Симптомы:
- Training loss ↓ медленно
- Training и validation loss оба высокие
Решение:
1. Higher rank (LoRA)
2. Higher learning rate
3. More epochs
4. More target modules
Multi-GPU и DeepSpeed
DeepSpeed ZeRO
DeepSpeed ZeRO оптимизирует память:
ZeRO-1: sharded optimizer states
ZeRO-2: sharded optimizer states + gradients
ZeRO-3: sharded parameters + optimizer + gradients
Llama 3 8B Full FT на 4×A100 40GB:
Без DeepSpeed: OOM
ZeRO-1: работает
ZeRO-3: экономит ещё 30% памяти
# DeepSpeed config
deepspeed config.yaml \
--deepspeed deepspeed_config.json
{
"zero_optimization": {
"stage": 3,
"offload_optimizer": {
"device": "cpu",
"pin_memory": true
},
"offload_param": {
"device": "cpu",
"pin_memory": true
}
},
"bf16": {
"enabled": true
}
}
Итоги
- Full fine-tune: лучшее качество, но дорого
- LoRA: 99% качества, 0.4% параметров
- QLoRA: работает на 12 GB GPU
- Adapters: альтернатива LoRA
- Данные: качество важнее количества
- 500+ примеров — минимум для старта
- Hyperparameters: LR=2e-4, cosine scheduler, warmup=5%
- Overfitting: больше данных, dropout, early stopping
- Multi-GPU: DeepSpeed ZeRO-3 для full FT
- Деплой: merge LoRA или load adapter dynamically
Fine-tuning — самый мощный способ адаптировать LLM под ваш домен. С QLoRA можно обучить 8B модель на домашнем GPU за пару часов.