Подробно · LLM Engineering · Lifecycle

Жизненный цикл LLM
и стратегии адаптации

От сырых данных до задачно-адаптированной модели — полный разбор: предобучение, SFT, RLHF, domain adaptation, prompt engineering, CPT, PEFT, контрастивное обучение энкодеров.

RAW DATA Books · Web · Code TB масштаб deduplicate · filter PRETRAINING Causal LM / MLM GPU weeks Base model SFT Instruction tuning 10k–1M примеров Instruct model RLHF / DPO Reward model PPO / DPO Aligned model DOMAIN ADAPTATION CPT · RAG · PEFT LoRA · prompt eng. Domain model TASK ADAPTATION Fine-tune head Task-specific data Task model DEPLOY Quantization vLLM · TGI Production ✓

// этап 0 · данные

Preprocessing данных для предобучения

Качество предобученной модели на 80% определяется качеством данных. Garbage in — garbage out никогда не был актуальнее, чем для LLM. Типичный пайплайн: сбор → очистка → фильтрация → дедупликация → токенизация → упаковка в батчи.

Сбор C4, CC, Books Очистка HTML, URL, boilerplate Фильтрация perplexity, toxic Дедупликация MinHash, exact, fuzzy Токенизация BPE / SentencePiece Упаковка concat, chunk 2048 MFN tfrecord источник -30% (HTML) -20% (quality) -10–40% (dedup) ≈ tokens ×N packed seqs

// DATA PIPELINE · каждый этап снижает объём, повышая плотность качества

Фильтрация: Perplexity-фильтр (CCNet-подход)

Facebook CCNet (2019) обучает 5-gram KenLM на высококачественном корпусе (Wikipedia). Документы с perplexity выше порога — скорее всего нечитаемый текст, шаблоны, спам. Perplexity отображает «насколько модель удивлена» этим текстом.

Python · KenLM perplexity filter
# pip install kenlm sentencepiece
import kenlm, math

# Загружаем 5-gram KenLM модель обученную на Wikipedia
lm = kenlm.Model("wiki_lm_5gram.arpa.bin")

def perplexity(text: str, model: kenlm.Model) -> float:
    """
    Perplexity = exp(-1/N * log P(w1..wN))
    Чем ниже — тем «нормальнее» текст для модели.
    """
    words = text.split()
    if len(words) == 0: return float('inf')
    log_prob = model.score(text, bos=True, eos=True)  # log10(P)
    log_prob_nats = log_prob * math.log(10)             # → натуральный лог
    ppl = math.exp(-log_prob_nats / len(words))
    return ppl

def filter_document(doc: str, threshold: float = 1000) -> bool:
    """Возвращает True если документ прошёл фильтр."""
    ppl = perplexity(doc, lm)
    return ppl < threshold

# Примеры:
docs = {
    "Wikipedia article": "The mitochondria is the powerhouse of the cell...",
    "Spam/boilerplate":  "Click here!!! BUY NOW!!! Special offer!! 100% FREE!!!",
    "Code dump":         "a1b2c3d4e5 0xDEADBEEF 192.168.1.1 root:x:0:0",
}
for name, text in docs.items():
    ppl = perplexity(text, lm)
    passed = ppl < 1000
    print(f"{name:22s}  ppl={ppl:8.1f}  {'✓ pass' if passed else '✗ drop'}")
# Wikipedia article    ppl=     45.2  ✓ pass
# Spam/boilerplate     ppl=   3421.8  ✗ drop
# Code dump            ppl=  28734.1  ✗ drop

Дедупликация: MinHash + LSH

Python · MinHash deduplication
# pip install datasketch
from datasketch import MinHash, MinHashLSH
import re

def shingle(text: str, k: int = 5) -> set:
    """k-gram символьные шинглы — строительный блок MinHash."""
    text = text.lower()
    return {text[i:i+k] for i in range(len(text) - k + 1)}

def make_minhash(text: str, num_perm: int = 128) -> MinHash:
    """MinHash подпись документа (128 перестановок → ~1% ошибка)."""
    m = MinHash(num_perm=num_perm)
    for s in shingle(text):
        m.update(s.encode('utf-8'))
    return m

# LSH: approximate nearest neighbor для быстрого поиска дублей
# threshold=0.8: Jaccard similarity >= 0.8 → дублирование
lsh = MinHashLSH(threshold=0.8, num_perm=128)

documents = [
    ("doc1", "The quick brown fox jumps over the lazy dog."),
    ("doc2", "The quick brown fox jumps over the lazy dog!"),  # ≈ дубль
    ("doc3", "Machine learning is a subset of artificial intelligence."),
    ("doc4", "The quick brown fox jumped over the lazy dog."),   # ≈ дубль
]

keep = []
for doc_id, text in documents:
    m = make_minhash(text)
    duplicates = lsh.query(m)
    if not duplicates:
        lsh.insert(doc_id, m)
        keep.append(doc_id)
        print(f"  ✓ keep  {doc_id}: '{text[:45]}...'")
    else:
        print(f"  ✗ drop  {doc_id}: дубль {duplicates}")

# ✓ keep  doc1: 'The quick brown fox jumps over the lazy dog.'
# ✗ drop  doc2: дубль ['doc1']
# ✓ keep  doc3: 'Machine learning is a subset of artificial...'
# ✗ drop  doc4: дубль ['doc1']
Масштаб: The Pile (825 GB), C4 (750 GB), RedPajama-v2 (30 TB). После дедупликации RedPajama потерял ~40% данных — но качество моделей выросло. Дедупликация важнее увеличения объёма данных при прочих равных.

// этап 1 · предобучение

Pretraining: Causal LM и упаковка данных

Предобучение — самый дорогой этап. GPT-3 (175B) обошёлся в ~$4.6M. LLaMA-3 (70B) обучался на 15 триллионах токенов. Цель: модель выучивает статистику языка, факты, код, математику.

15T
токенов · LLaMA-3
175B
параметров · GPT-3
~$4.6M
стоимость GPT-3 train
CLM
Causal Language Modeling

Упаковка токенов (packing) — зачем нужна

Документы имеют разную длину. Если просто обрезать/паддить каждый до max_length=2048 — большинство батчей наполовину пустые (PAD-токены), что тратит FLOP. Packing (concatenation) объединяет несколько документов в один sequence длиной 2048, разделяя их eos_token.

Python · packing sequences для CLM
from datasets import load_dataset
from transformers import AutoTokenizer
from itertools import chain

tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
BLOCK_SIZE = 2048

def tokenize(examples):
    """Токенизируем без обрезки, добавляем EOS после каждого документа."""
    output = tokenizer(
        [t + tokenizer.eos_token for t in examples["text"]],
        truncation=False,   # не обрезаем — объединим позже
        add_special_tokens=False,
    )
    return output

def pack_sequences(tokenized_dataset):
    """
    Упаковываем токены нескольких документов в chunks по BLOCK_SIZE.
    Никаких PAD-токенов — 100% утилизация.
    """
    # Flatten всех input_ids в один длинный список
    concatenated_ids = list(chain(*tokenized_dataset["input_ids"]))

    total_length = len(concatenated_ids)
    # Обрезаем до кратного BLOCK_SIZE
    total_length = (total_length // BLOCK_SIZE) * BLOCK_SIZE

    # Нарезаем на chunks
    result = {
        "input_ids": [
            concatenated_ids[i : i + BLOCK_SIZE]
            for i in range(0, total_length, BLOCK_SIZE)
        ]
    }
    # Для CLM labels = input_ids (сдвиг делает модель автоматически)
    result["labels"] = result["input_ids"].copy()
    return result

dataset = load_dataset("wikitext", "wikitext-103-raw-v1", split="train")
tok_ds = dataset.map(tokenize, batched=True, remove_columns=["text"])
packed_ds = pack_sequences(tok_ds)

print(f"Sequences: {len(packed_ds['input_ids'])}")
print(f"Каждый sequence: {len(packed_ds['input_ids'][0])} токенов")
# Sequences: 14832
# Каждый sequence: 2048 токенов (плотная упаковка)
Chinchilla scaling law: Hoffmann et al. 2022 установили: оптимальное число токенов = ~20× параметров. 7B модель → 140B токенов. LLaMA-3 7B обучали на 15T — это «над-Chinchilla», улучшает качество инференса.

// этап 2 · supervised fine-tuning

Supervised Fine-Tuning (SFT)

Base model умеет предсказывать следующий токен, но не умеет следовать инструкциям. SFT учит модель формату взаимодействия: «Получи инструкцию → выдай ответ». Это relatively дешёвый этап — тысячи примеров достаточно.

CHATML FORMAT (GPT-4, Mistral) <|im_start|>system You are a helpful assistant. <|im_end|> <|im_start|>user What is gradient descent? <|im_end|> <|im_start|>assistant Gradient descent is... <|im_end|> system: НЕ участвует в loss user: НЕ участвует в loss assistant: УЧАСТВУЕТ в loss ← только это Label masking: user/system токены → -100 ⬆ игнорируется CrossEntropyLoss (label=-100)

// SFT FORMAT · модель учится генерировать только ответы assistant, не повторяя инструкцию

Форматы данных для SFT

ФорматОписаниеПример
ChatMLim_start / im_end тегиGPT-4, Mistral-Instruct
Alpacainstruction / input / outputStanford Alpaca (52k)
ShareGPTHuman/GPT чередованиеVicuna, WizardLM
Llama-2 Chat[INST] / [/INST] тегиLLaMA-2-Chat
Gemma<start_of_turn> тегиGemma-2-Instruct

Сбор данных для SFT

  • 1
    Human annotation: люди пишут (instruction, response) пары. Дорого (~$1–5/пример), но высокое качество. Scale AI, Surge AI.
  • 2
    Self-instruct: GPT-4 генерирует инструкции из seed examples. Alpaca (52k) за ~$500. Риск: hallucinations передаются.
  • 3
    Backtranslation: берём документы, просим GPT-4 сгенерировать вопрос к нему. Document → Question = бесплатная пара SFT.
  • 4
    Open datasets: OpenHermes, UltraChat, FLAN-v2, ShareGPT4. Миллионы пар, разный уровень качества.

Полный SFT pipeline с TRL

Python · TRL SFTTrainer · label masking
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, TrainingArguments
from trl import SFTTrainer, DataCollatorForCompletionOnlyLM
import torch

MODEL = "mistralai/Mistral-7B-v0.1"   # base model
tokenizer = AutoTokenizer.from_pretrained(MODEL)
tokenizer.pad_token = tokenizer.eos_token

# ── Формат датасета ──────────────────────────────────────────
def format_prompt(example):
    """ChatML формат — стандарт для SFT."""
    return {"text": (
        f"<|im_start|>system\n{example['system']}<|im_end|>\n"
        f"<|im_start|>user\n{example['instruction']}<|im_end|>\n"
        f"<|im_start|>assistant\n{example['output']}<|im_end|>"
    )}

dataset = load_dataset("tatsu-lab/alpaca", split="train")
dataset = dataset.map(format_prompt)

# ── Label masking: считаем loss только по assistant-части ────
# DataCollatorForCompletionOnlyLM маскирует всё до response_template
response_template = "<|im_start|>assistant\n"
collator = DataCollatorForCompletionOnlyLM(
    response_template=response_template,
    tokenizer=tokenizer,
)

# ── Модель ───────────────────────────────────────────────────
model = AutoModelForCausalLM.from_pretrained(
    MODEL,
    torch_dtype=torch.bfloat16,   # BF16 на Ampere+ GPU
    device_map="auto",
)

# ── Аргументы обучения ───────────────────────────────────────
args = TrainingArguments(
    output_dir="./mistral-sft",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=8,   # effective batch = 32
    learning_rate=2e-5,
    warmup_ratio=0.03,
    lr_scheduler_type="cosine",
    bf16=True,
    logging_steps=10,
    save_strategy="epoch",
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=dataset,
    dataset_text_field="text",
    data_collator=collator,         # ← label masking!
    max_seq_length=2048,
    tokenizer=tokenizer,
)

trainer.train()
Ключевой параметр SFT: число примеров важнее, чем количество эпох. После 3–5 эпох на 50k примерах модель часто переобучается. Экспериментально: 1 эпоха на 500k лучше, чем 5 эпох на 100k. Diversity (разнообразие задач) > Volume (объём однотипных пар).

// этап 3 · выравнивание

RLHF, PPO и DPO

После SFT модель следует инструкциям, но не знает, что «лучше». RLHF (Reinforcement Learning from Human Feedback) добавляет человеческие предпочтения: аннотаторы сравнивают пары ответов, обучается reward model, затем policy (LLM) оптимизируется под неё через PPO.

1. SFT Model generates K responses 2. Human Ranking ответ A лучше B? создаём пары (chosen, rejected) 3. Reward Model r(x, y) → scalar 4. PPO Optimization maximize E[r(x,y)] - β·KL(π||π_sft) Aligned LLM ChatGPT-style DPO (Direct Preference Optimization) — альтернатива без reward model L_DPO = -log σ(β log π(y_w|x)/π_ref(y_w|x) − β log π(y_l|x)/π_ref(y_l|x)) ← оптимизируем напрямую по парам (chosen, rejected)

// RLHF vs DPO · DPO убирает reward model, решает оптимизацию аналитически

DPO — практический пример

Python · TRL DPOTrainer
from trl import DPOTrainer
from datasets import load_dataset

# Датасет: пары (prompt, chosen, rejected)
# chosen: ответ который предпочли люди
# rejected: ответ который не понравился
dpo_data = load_dataset("Anthropic/hh-rlhf", split="train")
# {'prompt': '...', 'chosen': '...', 'rejected': '...'}

trainer = DPOTrainer(
    model=model,            # SFT-модель
    ref_model=ref_model,    # замороженная копия SFT (π_ref)
    args=training_args,
    beta=0.1,              # KL-штраф: больше → консерват.
    train_dataset=dpo_data,
    tokenizer=tokenizer,
)
trainer.train()

# Loss: заставляем модель назначать
# chosen > rejected по log-вероятности
# относительно reference model

Формат данных для DPO

JSON · DPO dataset structure
// Каждый пример в DPO датасете:
{
  "prompt": "Объясни, что такое квантовая запутанность.",

  "chosen": "Квантовая запутанность — явление, при котором
  состояния двух частиц оказываются коррелированы
  независимо от расстояния. Если измерить спин одной
  частицы, состояние второй мгновенно определяется...",

  "rejected": "Квантовая запутанность — это когда частицы
  запутываются. Это очень сложная тема и лучше
  обратиться к специалисту."
}

// Ключевые датасеты для DPO:
// Anthropic/hh-rlhf — 170k пар (helpfulness, harmlessness)
// openbmb/UltraFeedback — 64k промптов, 4 ответа с оценками
// HuggingFaceH4/ultrafeedback_binarized

// адаптация · домен и задача

Domain Adaptation, Task Adaptation и Transfer Learning

Transfer learning — использование знаний, накопленных при предобучении, для новых задач. Domain adaptation — адаптация к специфическому домену (медицина, юриспруденция, финансы). Task adaptation — настройка под конкретную задачу (NER, классификация, QA).

Базовая LLM Context Optimization RAG · Prompt Engineering Гибридное дообучение CPT + SFT + PEFT Full Fine-tuning Все параметры · дорого Стоимость: $0 – $100 Скорость: мгновенно Стоимость: $100 – $10k Лучший ROI для продакшена Стоимость: $10k – $1M+ Оправдано при 1B+ запросов

// АДАПТАЦИЯ · три пути от базовой модели к специализированной — разный cost/benefit

Методы domain adaptation сравнительно

МетодСутьДанныеКогда использовать
Prompt EngineeringЗадаём инструкцию в промптеНе нужныБыстрый прототип, общие задачи
RAGПоиск релевантных документов + подача в контекстКорпус для поискаАктуальные/частые обновления данных
Few-shotПримеры в контексте5–50 примеровБыстрая адаптация без обучения
CPT (continue pretrain)Продолжение предобучения на доменных данных1B–100B токенов доменаГлубокая доменная экспертиза
SFT на доменеFine-tuning на instruction-пары домена1k–500k парЗадачи с чёткими форматами
LoRA/QLoRAНизкоранговые адаптеры поверх весовКак SFTОграниченные GPU ресурсы

RAG — Retrieval-Augmented Generation

Python · RAG с FAISS и HuggingFace
from sentence_transformers import SentenceTransformer
import faiss, numpy as np
from transformers import pipeline

# ── 1. Corpus ─────────────────────────────────────────────
corpus = [
    "LoRA адаптирует LLM обучая низкоранговые матрицы ΔW = A·B.",
    "QLoRA квантует базовую модель до 4-бит, LoRA в BF16.",
    "PEFT — библиотека HuggingFace для parameter-efficient FT.",
    "DPO оптимизирует предпочтения без reward model.",
]

# ── 2. Embed corpus ────────────────────────────────────────
embedder = SentenceTransformer("BAAI/bge-small-en-v1.5")
corpus_embs = embedder.encode(corpus, normalize_embeddings=True)

# ── 3. FAISS index ─────────────────────────────────────────
dim = corpus_embs.shape[1]
index = faiss.IndexFlatIP(dim)   # Inner Product ≡ cosine sim (normalized)
index.add(corpus_embs)

# ── 4. Retrieve ────────────────────────────────────────────
query = "Как LoRA уменьшает число параметров для обучения?"
query_emb = embedder.encode([query], normalize_embeddings=True)
D, I = index.search(query_emb, k=2)    # top-2 похожих

retrieved_context = "\n".join([corpus[i] for i in I[0]])
print("Retrieved:\n", retrieved_context)
# LoRA адаптирует LLM обучая низкоранговые матрицы ΔW = A·B.
# QLoRA квантует базовую модель до 4-бит, LoRA в BF16.

# ── 5. Augmented generation ────────────────────────────────
prompt = f"""Используй контекст для ответа на вопрос.
Контекст: {retrieved_context}
Вопрос: {query}
Ответ:"""

gen = pipeline("text-generation", model="mistralai/Mistral-7B-Instruct-v0.2")
answer = gen(prompt, max_new_tokens=150, do_sample=False)[0]["generated_text"]

// техники · prompt engineering

Базовые техники Prompt Engineering

Prompt engineering — это искусство составления входного текста так, чтобы получить от модели нужный вывод. Не требует обучения, но требует понимания того, как модель «думает».

📋

Instruction-Following

Чёткое описание задачи в начале промпта. Модель SFT-обучена следовать инструкциям — не стесняйся быть конкретным: формат вывода, язык, длина, ограничения.

🧠

Chain-of-Thought (CoT)

«Let's think step by step» или показ рассуждений в примерах заставляет модель «думать» поэтапно. Значительно улучшает арифметику, логику, многошаговые задачи.

🎭

Импersonализация (Persona)

«Ты — опытный врач» или «Действуй как старший инженер Google». Сдвигает распределение ответов к экспертному домену — модель активирует нужный «регистр» знаний.

🔗

Prompt Chaining

Вывод одного промпта становится входом следующего. Позволяет строить сложные пайплайны: extract → transform → validate → generate.

🎯

Few-Shot Prompting

Добавляем 3–8 примеров (input, output) перед запросом. Модель «понимает» нужный формат и стиль без дополнительного обучения.

🔁

Self-Consistency

Генерируем несколько CoT рассуждений, берём majority vote. Улучшает accuracy на задачах с единственным правильным ответом.

Примеры: от плохого к хорошему промпту

Prompt Engineering · практические примеры
# ── ПЛОХОЙ промпт (неясный, без контекста) ──────────────
bad_prompt = "Напиши что-то про машинное обучение."
# → модель не знает: длина? формат? аудитория? цель?

# ── ХОРОШИЙ промпт (instruction-following) ──────────────
good_prompt = """Ты — технический писатель с опытом в ML.
Напиши краткое объяснение (3-4 предложения) понятия
"градиентный спуск" для аудитории: студенты без опыта в ML.
Избегай формул. Используй аналогию."""

# ── Chain-of-Thought для математики ─────────────────────
cot_prompt = """Реши задачу, показывая шаги рассуждения.

Вопрос: В магазине 35 яблок. Продали 1/5 от запаса,
затем получили ещё 12. Сколько яблок стало?

Решение (шаг за шагом):"""
# → Шаг 1: 35 × 1/5 = 7 яблок продали
# → Шаг 2: 35 - 7 = 28 осталось
# → Шаг 3: 28 + 12 = 40 стало
# → Ответ: 40

# ── Prompt Chaining пайплайн ─────────────────────────────
def extract_entities(text: str) -> str:
    return f"""Извлеки именованные сущности (JSON).
Текст: {text}
Формат: {{"persons": [], "organizations": [], "locations": []}}
JSON:"""

def verify_entities(entities_json: str, original: str) -> str:
    return f"""Проверь, все ли сущности верны.
Оригинал: {original}
Извлечённые: {entities_json}
Ответь: "ok" или укажи ошибки."""

# Шаг 1: извлечь → Шаг 2: проверить → Шаг 3: использовать

# ── Persona + Few-Shot ───────────────────────────────────
persona_fewshot = """Ты — строгий код-ревьюер в Google.
Оцени код и дай краткий отзыв.

Пример 1:
Код: for i in range(len(lst)): print(lst[i])
Отзыв: ❌ Непитоничный стиль. Используй: for x in lst: print(x)

Пример 2:
Код: result = [x*2 for x in items if x > 0]
Отзыв: ✅ Чисто, понятно, питонично.

Твой код:
def get_users(db): return db.query("SELECT * FROM users")"""

// continual pretraining

CPT — Continual Pre-Training: сбор данных и цикл

CPT (Continued Pre-Training) — продолжение предобучения на доменных данных. Модель «дочитывает» специализированный корпус, адаптируя внутренние представления к домену. После CPT модель знает термины, стиль, структуры домена — но ещё не следует инструкциям.

CPT ЦИКЛ Доменные данные Очистка +качество filter Доменный токенизатор Continued PreTraining CLM objective Domain Base Model + SFT next РЕКОМЕНДОВАННАЯ КОМБИНАЦИЯ: CPT → SFT (domain) → PEFT Base LLM → CPT (1-10B domain tokens) → Domain Base → SFT Instruct (1k-100k pairs) → LoRA на задаче → Deploy

// CPT ЦИКЛ · после CPT нужен SFT чтобы научить следовать инструкциям на доменных данных

Сбор данных для CPT

  • 1
    Идентификация источников: PubMed (медицина), arXiv (наука), SEC filings (финансы), GitHub (код), Common Law (юриспруденция). Принцип: данные должны быть репрезентативны для домена.
  • 2
    Crawling и парсинг: PDF → text (pdfplumber, grobid), HTML → text (trafilatura), LaTeX → text (latexcodec). Качество парсинга напрямую влияет на качество модели.
  • 3
    Domain-specific фильтрация: перплексия-фильтр на доменной KenLM. Keyword filtering (убираем off-topic). Metadata-фильтры (дата, источник, язык).
  • 4
    Mixing с general data: mix 80–90% domain + 10–20% general (Wikipedia, книги). Без general data — catastrophic forgetting общих способностей.
  • 5
    Replay buffer: небольшой % original pretraining data в каждом батче — ещё один способ избежать catastrophic forgetting.

CPT код: настройки специфичные для домена

Python · CPT training setup
from transformers import TrainingArguments, Trainer
from torch.utils.data import ConcatDataset

# ── Data mixing: domain + general ──────────────────
# 85% медицинских текстов + 15% Wikipedia
from torch.utils.data import WeightedRandomSampler
import torch

domain_ds = load_packed_dataset("medical_packed/")
general_ds = load_packed_dataset("wikipedia_packed/")
mixed_ds = ConcatDataset([domain_ds, general_ds])

# Веса для WeightedSampler: domain 85%, general 15%
domain_weight = 0.85 / len(domain_ds)
general_weight = 0.15 / len(general_ds)
weights = (
    [domain_weight] * len(domain_ds) +
    [general_weight] * len(general_ds)
)
sampler = WeightedRandomSampler(weights, num_samples=len(weights))

# ── Training args для CPT ───────────────────────────
cpt_args = TrainingArguments(
    output_dir="./llama-medical-cpt",
    num_train_epochs=1,             # CPT: 1-3 эпохи
    per_device_train_batch_size=4,
    gradient_accumulation_steps=16,  # eff batch=64
    learning_rate=1e-4,            # ВЫШЕ чем в SFT!
    lr_scheduler_type="cosine",
    warmup_ratio=0.01,
    bf16=True,
    # Важно: НЕ используем label masking
    # Все токены = targets (как в pretraining)
    dataloader_num_workers=4,
)
# LR для CPT: 1e-4 to 1e-5 (выше чем SFT 2e-5)
# Причина: нужно перезаписать больше знаний
CPT vs SFT: ключевое отличие: в CPT мы обучаем на CLM-цели по всем токенам документа (loss по каждому). В SFT — loss только по токенам ответа. CPT → знания домена. SFT → как общаться в этом домене.

// parameter-efficient fine-tuning

PEFT, LoRA, QLoRA и расширение токенизатора

Full fine-tuning 7B модели требует ~112 GB GPU RAM (optimizer states + gradients). PEFT позволяет обучать менее 1% параметров с сопоставимым качеством.

FULL FINE-TUNING W d×d = 786,432 параметров ВСЕ обучаются ← ~28GB VRAM (Adam) → LoRA (rank=8) W заморожен (frozen) + B d×r A r×d ΔW = A·B d×r + r×d параметров r=8: 8·768·2 = 12,288 параметров vs 786,432 full → экономия 64×

// LoRA · ΔW = A·B · матрица A инициализируется случайно, B — нулями · итого r×(d+d) параметров

Python · QLoRA (4-bit) · PEFT · практика
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, TaskType, prepare_model_for_kbit_training
from trl import SFTTrainer
import torch

# ── 1. QLoRA: загружаем в 4-bit ───────────────────────────
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",       # Normal Float 4 — оптимально для весов
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,   # двойная квантизация: экономит ещё ~0.4 бит
)

model = AutoModelForCausalLM.from_pretrained(
    "meta-llama/Llama-2-7b-hf",
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
)
# VRAM: 7B в 4bit = ~4.5GB (vs ~28GB в BF16)

# ── 2. Подготовка к k-bit training ────────────────────────
model = prepare_model_for_kbit_training(model)
# Включает gradient checkpointing, замораживает bn слои

# ── 3. LoRA конфигурация ──────────────────────────────────
lora_config = LoraConfig(
    r=16,                     # rank: 4, 8, 16, 32, 64
    lora_alpha=32,            # scaling = alpha/r = 2.0
    target_modules=[           # к каким матрицам применяем LoRA
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj"  # FFN тоже
    ],
    lora_dropout=0.05,
    bias="none",
    task_type=TaskType.CAUSAL_LM,
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# trainable params: 40,108,032 || all params: 6,778,671,104
# trainable%: 0.5916%  ← обучаем МЕНЕЕ 1% параметров!

# ── 4. Слияние адаптера с базовой моделью (после обучения)
from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf", torch_dtype=torch.bfloat16)
merged = PeftModel.from_pretrained(base, "./lora-checkpoint")
merged = merged.merge_and_unload()    # W = W_frozen + A·B
merged.save_pretrained("./llama-merged")

Расширение токенизатора для нового домена

Если домен содержит много специальных терминов (медицинские аббревиатуры, химические формулы, код), стоит добавить новые токены в словарь. Это снижает среднее число токенов на термин и ускоряет инференс.

Python · расширение токенизатора + embedding init
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch

tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")

# Проверяем как сейчас токенизируются медицинские термины
terms = ["myocardial", "infarction", "hyperglycemia"]
for t in terms:
    toks = tokenizer.tokenize(t)
    print(f"{t}: {toks}")
# myocardial: ['my', 'ocard', 'ial']      → 3 токена
# infarction: ['inf', 'arc', 'tion']      → 3 токена
# hyperglycemia: ['hyper', 'gly', 'cemia'] → 3 токена

# ── Добавляем новые токены ────────────────────────────────
new_tokens = ["myocardial", "infarction", "hyperglycemia",
              "cardiomyopathy", "echocardiogram"]

num_added = tokenizer.add_tokens(new_tokens)
print(f"Добавлено токенов: {num_added}")

# ── Resize embeddings ─────────────────────────────────────
model.resize_token_embeddings(len(tokenizer))
# Новые токены инициализированы случайно — нужно обучить

# ── Инициализация новых эмбеддингов средним по подтокенам
# Это лучше чем random: даёт разумную начальную точку ──────
old_vocab_size = len(tokenizer) - num_added
with torch.no_grad():
    for new_tok in new_tokens:
        new_id = tokenizer.convert_tokens_to_ids(new_tok)
        # Получаем id исходных субтокенов
        sub_ids = tokenizer.encode(new_tok, add_special_tokens=False)
        sub_ids = [i for i in sub_ids if i < old_vocab_size]
        if sub_ids:
            # Новый эмбеддинг = среднее субтокенов
            mean_emb = model.model.embed_tokens.weight[sub_ids].mean(0)
            model.model.embed_tokens.weight[new_id] = mean_emb

# После этого: только новые токены требуют обучения (меньше эпох)
tokenizer.save_pretrained("./llama-medical-tok")

// адаптация энкодеров

Адаптация энкодеров: MLM и Contrastive Learning

Энкодеры (BERT, RoBERTa) производят эмбеддинги — плотные векторные представления текста. Их адаптируют двумя путями: MLM (продолжение маскированного языкового моделирования на домене) и контрастивное обучение (обучение семантически близких текстов быть близко в пространстве).

CONTRASTIVE LEARNING · InfoNCE Anchor: "The cat sat on the mat" Positive: "A cat rested on a rug" Negative: "The dog ran outside" Negative: "Rain falls from clouds" BERT Encoder shared weights Embedding space A P N1 N2 InfoNCE Loss: L = -log( exp(sim(a,p)/τ) / Σ exp(sim(a,nᵢ)/τ) ) τ = temperature (0.05–0.1) sim = cosine similarity Triplet Loss (альтернатива): L = max(0, d(a,p) - d(a,n) + margin)

// CONTRASTIVE LEARNING · anchor приближается к positive, удаляется от negatives

MLM Domain Adaptation

Python · MLM адаптация BERT
from transformers import (
    AutoModelForMaskedLM, AutoTokenizer,
    DataCollatorForLanguageModeling, Trainer
)

# Загружаем BERT, продолжаем MLM на медицинских текстах
model = AutoModelForMaskedLM.from_pretrained("bert-base-uncased")
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")

# Dynamic masking: каждый батч получает разные маски
collator = DataCollatorForLanguageModeling(
    tokenizer=tokenizer,
    mlm=True,
    mlm_probability=0.15,  # 15% токенов маскируется
)

# Из 15% маскированных токенов:
# 80% заменяются [MASK]
# 10% заменяются случайным токеном
# 10% остаются без изменений
# Это не баг — это намеренная стратегия для robustness

trainer = Trainer(
    model=model,
    args=mlm_training_args,
    train_dataset=medical_tokenized,
    data_collator=collator,
)
trainer.train()
# После: BioMedBERT-style модель
# Пример результата: PubMedBERT, ClinicalBERT, BlueBERT

InfoNCE Loss — реализация

Python · InfoNCE + SimCSE-style
import torch
import torch.nn as nn
import torch.nn.functional as F

class InfoNCELoss(nn.Module):
    def __init__(self, temperature: float = 0.05):
        super().__init__()
        self.T = temperature

    def forward(self, anchor, positive, negatives=None):
        """
        anchor:    (B, D) — базовые эмбеддинги
        positive:  (B, D) — похожие пары
        negatives: (N, D) — optional явные негативы
                   если None — используем in-batch negatives
        """
        # L2-нормализация для cosine similarity
        a = F.normalize(anchor, dim=-1)
        p = F.normalize(positive, dim=-1)

        # In-batch negatives: другие примеры в батче
        # sim: (B, B) — попарные cosine similarities
        sim = torch.mm(a, p.T) / self.T   # (B, B)

        # Positive — диагональные элементы
        labels = torch.arange(sim.size(0), device=sim.device)

        # Cross-entropy: хотим sim[i,i] >> sim[i,j] для j≠i
        loss = F.cross_entropy(sim, labels)
        return loss

# SimCSE: unsupervised — один текст проходит дважды
# через dropout → два разных эмбеддинга → positive пара!
class SimCSE(nn.Module):
    def __init__(self, encoder, temperature=0.05):
        super().__init__()
        self.encoder = encoder
        self.loss_fn = InfoNCELoss(temperature)

    def forward(self, input_ids, attention_mask):
        # Два прохода с разными dropout masks → разные эмбеддинги
        out1 = self.encoder(input_ids, attention_mask).pooler_output
        out2 = self.encoder(input_ids, attention_mask).pooler_output
        return self.loss_fn(out1, out2)

# Triplet Loss (альтернатива InfoNCE)
triplet_loss = nn.TripletMarginLoss(margin=1.0, p=2)
# loss = max(0, ||a-p||₂ - ||a-n||₂ + margin)
# p=2: L2 расстояние; margin=1.0 — минимальный зазор
Hard Negative Mining: качество контрастивного обучения критически зависит от негативных примеров. Easy negatives (случайные документы) почти не учат — модель и без того их различает. Hard negatives (похожие, но семантически разные) — вот где обучение происходит. Примеры: BM25-retrieved, Ann-retrieved, модифицированные positives.

Сравнение методов адаптации энкодеров

МетодЗадачаДанныеМетрикаПримеры моделей
Domain MLMДоменный vocabulary, representationsДоменный текст (unlabeled)Perplexity, downstream F1BioBERT, PubMedBERT, LegalBERT
SimCSE (unsup.)Семантические эмбеддингиТекст (unlabeled)STS Spearman ρprinceton-nlp/sup-simcse-roberta
SimCSE (sup.)Семантические эмбеддингиNLI пары (entailment/contradiction)STS Spearman ρSimCSE-RoBERTa-large
Contrastive + Hard NegПоиск документов (Dense Retrieval)Labeled query-document парыNDCG@10, Recall@KDPR, E5, BGE, Contriever
Triplet LossFace recognition, image retrievalAnchor/Positive/Negative тройкиAUC, Precision@Ksentence-transformers