Fine-tuning LLM: от LoRA до full fine-tune — полное руководство по адаптации моделей

opensourceaillmfinetuningloratrainingit
← Back to Blog

Введение: зачем 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 за пару часов.