NLP · Fine-tuning

Как дообучается llm
pre-training

Три классические задачи — классификация текста (IMDb), распознавание именованных сущностей (WNUT-17) и маскированное языковое моделирование (ELI5) — через библиотеку Hugging Face Transformers. С кодом, схемами и объяснением каждого шага.

📊 Text Classification IMDb · BERT
🏷️ NER WNUT-17 · DistilBERT
🎭 MLM ELI5 · RoBERTa

// 00 · основы

Что такое PLM и Transfer Learning

Предобученная языковая модель (PLM, Pre-trained Language Model) — это трансформер, обученный на огромном корпусе текстов (Wikipedia, Books, Common Crawl) с целью предсказывать следующий токен или восстанавливать замаскированные токены. В процессе предобучения модель приобретает универсальные лингвистические знания: синтаксис, семантику, факты о мире, дискурс.

Transfer learning в NLP — это использование этих знаний для новой конкретной задачи путём дообучения (fine-tuning): подключаем небольшую задачную голову, запускаем обучение на размеченных данных, и получаем state-of-the-art качество при значительно меньшем датасете, чем потребовалось бы при обучении с нуля.

PRE-TRAINING Books + Wikipedia + CommonCrawl Masked LM Next Sentence Pred. Недели GPU · TB данных BERT · RoBERTa · DistilBERT from_pretrained + task head FINE-TUNING IMDb / WNUT / ELI5 (тысячи примеров) Classify NER MLM Часы GPU · тысячи примеров Trainer / custom loop

// TRANSFER LEARNING · предобучение → дообучение под конкретную задачу

🧠

Encoder-only (BERT, RoBERTa)

Bidirectional — видит весь контекст. Лучший выбор для понимания текста: классификация, NER, QA, STS. MLM — родная задача предобучения.

✂️

DistilBERT — лёгкая версия

Knowledge distillation из BERT: 40% меньше параметров, 60% быстрее, 97% качества. Идеален для быстрых экспериментов и прода с ограниченными ресурсами.

📦

from_pretrained() — магия в одну строку

Скачивает веса, конфиг и токенизатор с Hugging Face Hub. Работает офлайн если кешировано. Поддерживает revision, cache_dir, torch_dtype.

// 01 · концепция

Что происходит при дообучении

При fine-tuning мы берём предобученные веса и продолжаем обучение с малым learning rate на размеченном датасете. Все (или часть) весов обновляются через backprop — трансформер адаптирует свои представления к специфике задачи.

BERT / RoBERTa Backbone Classification Head Linear(d_model → num_labels) Token Classification Head Linear(d_model → num_labels) × T MLM Head Linear(d_model → vocab_size) POS / NEG IMDb sentiment B-PER I-PER B-ORG O ... WNUT-17 entities predict [MASK] tokens ELI5 domain вход: [CLS]-вектор вход: все T токен-векторов вход: все T токен-векторов

// TASK HEADS · один backbone, три разные головы под разные задачи

Learning rate при fine-tuning: стандартные значения — 2e-5 до 5e-5. Слишком большой LR → катастрофическое забывание (модель «забывает» предобученные знания). Слишком маленький → не сходится. Оптимально: AdamW + linear warmup + weight decay = 0.01.

// 02 · text classification

Классификация текста на IMDb

IMDb Large Movie Review Dataset — бинарная классификация тональности рецензий на фильмы (positive / negative). 25 000 примеров для обучения, 25 000 для теста. Это один из стандартных бенчмарков для text classification.

50k
примеров всего
2
класса (pos/neg)
~93%
accuracy BERT FT
512
макс. токенов

Архитектура: ForSequenceClassification

Для классификации используется AutoModelForSequenceClassification. Модель берёт вектор [CLS]-токена из последнего слоя BERT и пропускает через Dropout → Linear(768, 2). Обучаем CrossEntropyLoss по двум классам.

📥
Загрузкаload_dataset
"imdb"
›
🔤
ТокенизацияAutoTokenizer
truncation=512
›
🧠
МодельForSequence
Classification
›
⚙️
TrainerAdamW + warmup
3 эпохи
›
📊
Оценкаaccuracy
на test split

Шаг 1 — Загрузка датасета и токенизация

Python · datasets + tokenizer · IMDb
from datasets import load_dataset
from transformers import AutoTokenizer

# 1. Загружаем IMDb из Hugging Face Hub
#    train: 25000, test: 25000, unsupervised: 50000
dataset = load_dataset("imdb")

# dataset структура:
# DatasetDict({
#   train: Dataset({ features: ['text', 'label'], num_rows: 25000 })
#   test:  Dataset({ features: ['text', 'label'], num_rows: 25000 })
# })

# label: 0 = negative, 1 = positive
print(dataset["train"][0])
# {'text': 'I rented I AM CURIOUS-YELLOW...', 'label': 0}

# 2. Токенизатор — используем bert-base-uncased
MODEL_NAME = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

def tokenize_fn(batch):
    """
    truncation=True: обрезаем до max_length (по умолчанию 512)
    padding="max_length": паддим все до одной длины в батче
    """
    return tokenizer(
        batch["text"],
        truncation=True,
        padding="max_length",
        max_length=512,
    )

# batched=True: обрабатывает по батчу, намного быстрее
tokenized = dataset.map(tokenize_fn, batched=True)

# Убираем текстовый столбец, добавляем формат PyTorch
tokenized = tokenized.remove_columns(["text"])
tokenized = tokenized.rename_column("label", "labels")
tokenized.set_format("torch")

print(tokenized["train"][0].keys())
# dict_keys(['input_ids', 'attention_mask', 'token_type_ids', 'labels'])

Шаг 2 — Модель и обучение через Trainer

Python · Trainer · text classification
import numpy as np
from transformers import (
    AutoModelForSequenceClassification,
    TrainingArguments,
    Trainer,
)
import evaluate

# 3. Модель с classification head
#    num_labels=2: автоматически добавляет Linear(768, 2) поверх BERT
model = AutoModelForSequenceClassification.from_pretrained(
    MODEL_NAME,
    num_labels=2,
    id2label={0: "NEGATIVE", 1: "POSITIVE"},
    label2id={"NEGATIVE": 0, "POSITIVE": 1},
)

# 4. Метрика — accuracy через evaluate
accuracy = evaluate.load("accuracy")

def compute_metrics(eval_pred):
    logits, labels = eval_pred
    predictions = np.argmax(logits, axis=-1)
    return accuracy.compute(predictions=predictions, references=labels)

# 5. Аргументы обучения
args = TrainingArguments(
    output_dir="./imdb-bert",

    # Ключевые гиперпараметры для fine-tuning
    num_train_epochs=3,
    per_device_train_batch_size=16,
    per_device_eval_batch_size=32,
    learning_rate=2e-5,         # стандарт для BERT fine-tuning
    weight_decay=0.01,          # L2 регуляризация в AdamW
    warmup_ratio=0.1,           # 10% шагов на warmup

    # Оценка после каждой эпохи
    eval_strategy="epoch",
    save_strategy="epoch",
    load_best_model_at_end=True,
    metric_for_best_model="accuracy",

    # FP16 если есть GPU (ускоряет в 1.5-2x)
    fp16=True,
    report_to="none",           # отключаем wandb/tensorboard
)

# 6. Trainer собирает всё вместе
trainer = Trainer(
    model=model,
    args=args,
    train_dataset=tokenized["train"],
    eval_dataset=tokenized["test"],
    compute_metrics=compute_metrics,
    tokenizer=tokenizer,       # нужен для правильного padding в DataCollator
)

trainer.train()

# 7. Финальная оценка
results = trainer.evaluate()
print(results)
# {'eval_accuracy': 0.9326, 'eval_loss': 0.2148, ...}

Шаг 3 — Инференс после обучения

Python · inference · sentiment
from transformers import pipeline

# Загружаем сохранённую модель
clf = pipeline(
    "sentiment-analysis",
    model="./imdb-bert/checkpoint-best",
    tokenizer=tokenizer,
    device=0,         # GPU, -1 для CPU
    truncation=True,
)

texts = [
    "This movie was absolutely brilliant! The performances were outstanding.",
    "A complete waste of time. Boring plot, terrible acting.",
    "It had some good moments but overall felt unfinished.",
]

for t, r in zip(texts, clf(texts)):
    print(f"{t[:50]}...")
    print(f"  → {r['label']} ({r['score']:.4f})\n")

# This movie was absolutely brilliant!...
#   → POSITIVE (0.9987)
# A complete waste of time...
#   → NEGATIVE (0.9994)
# It had some good moments...
#   → NEGATIVE (0.6823)  ← неоднозначный — меньшая уверенность

# Ручной инференс (без pipeline) для понимания:
import torch
model.eval()
inputs = tokenizer(texts[0], return_tensors="pt", truncation=True)
with torch.no_grad():
    logits = model(**inputs).logits          # (1, 2)
probs = torch.softmax(logits, dim=-1)
print(probs)  # tensor([[0.0013, 0.9987]])

Типичная динамика обучения IMDb

Epoch 1 — train loss0.42
Epoch 2 — train loss0.23
Epoch 3 — train loss0.14
Test accuracy93.3%
Совет новичку: если GPU нет — используй distilbert-base-uncased вместо bert-base-uncased. DistilBERT в 2× быстрее, 40% меньше параметров, а accuracy на IMDb падает всего на ~1–1.5%. Также можно уменьшить max_length=128 — большинство рецензий укладываются.
Совет эксперту: для IMDb популярна техника gradual unfreezing — сначала обучаем только голову (1 эпоха), потом размораживаем последние N слоёв трансформера. Это снижает риск катастрофического забывания. В HuggingFace: заморозь model.bert.embeddings и первые слои через requires_grad_(False).

// 03 · named entity recognition

NER на WNUT-17

WNUT-17 (Workshop on Noisy User-generated Text) — датасет для NER в «шумных» текстах: твиты, Reddit, YouTube-комментарии. В отличие от классических NER-корпусов (CoNLL-2003), здесь нестандартная орфография, сленг, опечатки — это делает задачу существенно сложнее.

6
типов сущностей
~3.4k
train примеров
BIO
разметочная схема
seqeval
метрика (F1)

BIO-разметка: как кодируются сущности

Каждому токену присваивается тег в схеме BIO (Begin-Inside-Outside): B-TYPE — начало сущности, I-TYPE — продолжение, O — не сущность.

Elon Musk founded SpaceX in Hawthorne , California B-PER I-PER O B-CORP O B-LOC O I-LOC PERSON: Elon Musk CORP: SpaceX LOCATION: Hawthorne, California WNUT-17 классы: person · location · corporation · product · creative-work · group

// BIO-TAGGING · каждый токен получает метку. I-тег идёт только после B или I того же типа

Ключевая сложность: выравнивание токенов

BERT использует WordPiece: одно слово может быть разбито на несколько субтокенов. Например, «Washington» → «Washington» (одним куском), а «Schwarzenegger» → ['black', '##en', '##egg', '##er']. Разметка в датасете идёт по словам, а токенизатор выдаёт субтокены — нужно выравнивать.

Python · WNUT-17 · загрузка и выравнивание меток
from datasets import load_dataset
from transformers import AutoTokenizer

dataset = load_dataset("wnut_17")
# Структура: {'id':..., 'tokens': ['EU','rejects',...], 'ner_tags': [3, 0, ...]}
# ner_tags — целые числа, которые отображаются через dataset.features

label_list = dataset["train"].features["ner_tags"].feature.names
# ['O', 'B-corporation', 'B-creative-work', 'B-group',
#  'B-location', 'B-person', 'B-product',
#  'I-corporation', 'I-creative-work', 'I-group',
#  'I-location', 'I-person', 'I-product']

tokenizer = AutoTokenizer.from_pretrained("distilbert-base-uncased")

def tokenize_and_align_labels(examples):
    """
    is_split_into_words=True: входной список — уже токены (слова),
    а не сырая строка. Токенизатор разбивает каждое слово на субтокены.
    """
    tokenized_inputs = tokenizer(
        examples["tokens"],
        truncation=True,
        is_split_into_words=True,   # ключевой параметр!
    )

    all_labels = []
    for i, labels in enumerate(examples["ner_tags"]):
        word_ids = tokenized_inputs.word_ids(batch_index=i)
        # word_ids: [None, 0, 0, 1, 2, 2, 2, 3, None]
        #  None = специальные токены [CLS], [SEP]
        #  число = индекс исходного слова

        aligned_labels = []
        prev_word_id = None

        for word_id in word_ids:
            if word_id is None:
                aligned_labels.append(-100)    # -100 игнорируется в loss
            elif word_id != prev_word_id:
                aligned_labels.append(labels[word_id])   # первый субтокен
            else:
                # субтокены после первого — тоже -100 (не предсказываем)
                # альтернатива: повторить метку слова
                aligned_labels.append(-100)

            prev_word_id = word_id

        all_labels.append(aligned_labels)

    tokenized_inputs["labels"] = all_labels
    return tokenized_inputs

tokenized_wnut = dataset.map(tokenize_and_align_labels, batched=True)
Почему -100? PyTorch CrossEntropyLoss по умолчанию игнорирует позиции с меткой -100. Это стандартный трюк для маскирования позиций, которые не нужно предсказывать: [CLS], [SEP], второй и последующие субтокены слова. Если разметить все субтокены той же меткой — модель обучится нестабильно.

Шаг 2 — Модель ForTokenClassification и Trainer

Python · ForTokenClassification · seqeval
from transformers import (
    AutoModelForTokenClassification,
    TrainingArguments, Trainer,
    DataCollatorForTokenClassification,
)
import evaluate, numpy as np

# ForTokenClassification: на каждый токен → Linear(d_model, num_labels)
model = AutoModelForTokenClassification.from_pretrained(
    "distilbert-base-uncased",
    num_labels=len(label_list),
    id2label={i: l for i, l in enumerate(label_list)},
    label2id={l: i for i, l in enumerate(label_list)},
)

# DataCollatorForTokenClassification нужен вместо стандартного:
# корректно паддит labels тоже (значением -100)
data_collator = DataCollatorForTokenClassification(tokenizer=tokenizer)

# seqeval: chunk-уровневая метрика — считает precision/recall/F1
# по полным сущностям (не по отдельным токенам)
seqeval = evaluate.load("seqeval")

def compute_metrics(p):
    predictions, labels = p
    predictions = np.argmax(predictions, axis=2)  # (batch, seq, num_labels) → (batch, seq)

    # Убираем -100 позиции (субтокены и спец-токены)
    true_predictions = [
        [label_list[pred] for pred, lbl in zip(pred_seq, lbl_seq) if lbl != -100]
        for pred_seq, lbl_seq in zip(predictions, labels)
    ]
    true_labels = [
        [label_list[lbl] for lbl in lbl_seq if lbl != -100]
        for lbl_seq in labels
    ]

    results = seqeval.compute(predictions=true_predictions, references=true_labels)
    return {
        "precision": results["overall_precision"],
        "recall":    results["overall_recall"],
        "f1":        results["overall_f1"],
        "accuracy":  results["overall_accuracy"],
    }

args = TrainingArguments(
    output_dir="./wnut-ner",
    num_train_epochs=5,             # NER обычно требует больше эпох
    per_device_train_batch_size=16,
    per_device_eval_batch_size=32,
    learning_rate=5e-5,
    weight_decay=0.01,
    eval_strategy="epoch",
    save_strategy="epoch",
    load_best_model_at_end=True,
    metric_for_best_model="f1",
)

trainer = Trainer(
    model=model,
    args=args,
    train_dataset=tokenized_wnut["train"],
    eval_dataset=tokenized_wnut["validation"],
    data_collator=data_collator,       # ← обязательно для NER
    compute_metrics=compute_metrics,
    tokenizer=tokenizer,
)
trainer.train()
# eval_f1: ~0.52 (WNUT сложный — много редких сущностей)

Шаг 3 — Инференс NER

Python · NER inference · aggregation
from transformers import pipeline

ner = pipeline(
    "ner",
    model="./wnut-ner/checkpoint-best",
    tokenizer=tokenizer,
    aggregation_strategy="simple",  # объединяет B- и I- токены в сущности
    device=0,
)

text = "Taylor Swift performed at Madison Square Garden last night!"
entities = ner(text)

for e in entities:
    print(f"{e['word']:20s}  {e['entity_group']:15s}  score={e['score']:.3f}")

# Taylor Swift          person           score=0.923
# Madison Square Garden location         score=0.871

# aggregation_strategy варианты:
# "none"    — токен за токеном, сырые BIO-метки
# "simple"  — merge по максимальному score первого токена
# "first"   — берёт метку первого субтокена
# "average" — усредняет score всех субтокенов
# "max"     — берёт максимальный score
Почему F1 на WNUT такой низкий (~50%)? WNUT специально содержит emerging entities — новые сущности, которых не было в обучении (имена стартапов, новые продукты, интернет-персонажи). Модель хорошо находит PER и LOC, но плохо справляется с creative-work и product. Для улучшения: domain-adaptive pretraining на твиттер-данных перед fine-tuning.

// 04 · masked language modeling

Дообучение MLM на ELI5

ELI5 (Explain Like I'm Five) — датасет длинных вопросов-ответов с Reddit (/r/explainlikeimfive). Содержит около 270k примеров. Дообучение MLM на доменных данных (domain-adaptive pretraining) улучшает качество перед fine-tuning на downstream-задачах в этом домене.

MLM (Masked Language Modeling) — задача предобучения BERT: случайно маскируем 15% токенов, модель предсказывает исходные. При дообучении на ELI5 — модель адаптирует свои представления к неформальному стилю, научным объяснениям, разговорной речи.

270k
Q&A примеров
15%
токенов маскируется
perplexity
метрика MLM
WWM
whole word masking
ИСХОДНЫЙ ТЕКСТ gravity is the force that attracts two bodies ↓ 15% маскируется ВХОД В МОДЕЛЬ gravity is the [MASK] that [MASK] two bodies → "force" ✓ → "attracts" ✓

// MLM MASKING · 80% [MASK], 10% случайный токен, 10% исходный — стратегия BERT

Шаг 1 — Загрузка ELI5 и подготовка данных

Python · ELI5 · DataCollatorForLanguageModeling
from datasets import load_dataset
from transformers import AutoTokenizer, DataCollatorForLanguageModeling

# ELI5 — большой датасет, берём subset для демонстрации
# train_asks: вопросы из /r/explainlikeimfive
eli5 = load_dataset("eli5_category", split="train[:5000]")
eli5 = eli5.train_test_split(test_size=0.2)

# Структура: {'q_id', 'title', 'selftext', 'category', 'subreddit', 'answers'...}
# Нас интересует текст — берём 'title' + 'selftext'

tokenizer = AutoTokenizer.from_pretrained("distilroberta-base")

def preprocess(examples):
    # Объединяем вопрос и текст вопроса
    texts = [
        q + " " + (s if s else "")
        for q, s in zip(examples["title"], examples["selftext"])
    ]
    return tokenizer(
        texts,
        truncation=True,
        max_length=512,
        return_special_tokens_mask=True,  # нужно DataCollator'у
    )

tokenized_eli5 = eli5.map(preprocess, batched=True, remove_columns=eli5["train"].column_names)

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

Шаг 2 — ForMaskedLM и обучение

Python · ForMaskedLM · ELI5
from transformers import (
    AutoModelForMaskedLM,
    TrainingArguments, Trainer,
)
import math

# distilroberta-base: лёгкая RoBERTa, хороша для MLM
model = AutoModelForMaskedLM.from_pretrained("distilroberta-base")

def compute_metrics(eval_pred):
    # MLM метрика — perplexity: exp(cross_entropy_loss)
    # Чем ниже, тем лучше модель предсказывает токены
    logits, labels = eval_pred
    import torch
    loss = torch.nn.CrossEntropyLoss()(
        torch.tensor(logits).view(-1, logits.shape[-1]),
        torch.tensor(labels).view(-1)
    )
    return {"perplexity": math.exp(loss.item())}

args = TrainingArguments(
    output_dir="./eli5-mlm",
    num_train_epochs=3,
    per_device_train_batch_size=8,       # MLM требует больше памяти
    per_device_eval_batch_size=8,
    learning_rate=2e-5,
    weight_decay=0.01,
    eval_strategy="epoch",
    save_strategy="epoch",
    fp16=True,
    push_to_hub=False,
)

trainer = Trainer(
    model=model,
    args=args,
    train_dataset=tokenized_eli5["train"],
    eval_dataset=tokenized_eli5["test"],
    data_collator=data_collator,   # ← динамическое маскирование
    tokenizer=tokenizer,
)

trainer.train()

# Оцениваем perplexity
eval_results = trainer.evaluate()
print(f"Perplexity: {math.exp(eval_results['eval_loss']):.2f}")
# Perplexity: ~8.5 (после DA-pretraining на ELI5)
# vs ~12.3 у базового distilroberta на этих же данных

Шаг 3 — Fill-mask инференс

Python · fill-mask inference
from transformers import pipeline

fill_mask = pipeline(
    "fill-mask",
    model="./eli5-mlm/checkpoint-best",
    tokenizer=tokenizer,
)

# <mask> — токен RoBERTa (у BERT это [MASK])
result = fill_mask("Gravity is a <mask> that attracts objects with mass.")

for r in result:
    print(f"  {r['token_str']:15s}  score={r['score']:.4f}")

# До DA-pretraining (базовая distilroberta):
#   force          score=0.4821
#   field          score=0.1203
#   phenomenon     score=0.0891

# После DA-pretraining на ELI5:
#   force          score=0.5912  ← уверенность выросла
#   field          score=0.1534
#   concept        score=0.0723  ← "concept" из ELI5-стиля

# Проверяем несколько разных контекстов
prompts = [
    "Black holes are regions where <mask> is so strong that nothing can escape.",
    "The human <mask> contains roughly 37 trillion cells.",
]
for p in prompts:
    top = fill_mask(p)[0]
    print(f"'{top['token_str']}' ({top['score']:.3f}) | {p[:50]}...")

Whole Word Masking (WWM)

Стандартный MLM маскирует отдельные субтокены. Это проблема: если маскируется только ##ing из «running», модель легко восстанавливает его по контексту «runn»+##[MASK]. Whole Word Masking маскирует все субтокены слова целиком.

Python · Whole Word Masking DataCollator
from transformers import DataCollatorForWholeWordMask

# DataCollatorForWholeWordMask — маскирует целые слова
# Требует return_special_tokens_mask=True и word_ids в tokenized output
wwm_collator = DataCollatorForWholeWordMask(
    tokenizer=tokenizer,
    mlm=True,
    mlm_probability=0.15,
)

# Пример: слово "running" → [run, ##ning]
# Стандартный MLM может замаскировать только ##ning
# WWM маскирует оба субтокена: [MASK, MASK]
# Это сложнее и даёт лучшее предобучение
# Использован в Chinese BERT и MacBERT
Зачем MLM на доменных данных? Представь, что ты fine-tuning BERT для медицинских записей. Базовый BERT видел мало медицинских терминов — его представления субоптимальны. Сначала запусти MLM на медицинских текстах (PubMed, MIMIC), потом fine-tuning на конкретной задаче. Это называется domain-adaptive pre-training (DAPT) и даёт стабильное улучшение на специализированных доменах.

// 05 · trainer api

Trainer API — под капотом

HuggingFace Trainer — это полный training loop, реализованный за тебя. Он управляет распределённым обучением, mixed precision, gradient accumulation, checkpoint saving и evaluation. Но важно понимать, что происходит внутри.

TRAINER TRAIN LOOP for epoch in range(num_epochs): for batch in dataloader: optimizer.zero_grad → forward pass → compute loss → loss.backward → clip grad → optimizer.step + lr_scheduler.step() · gradient accumulation · eval каждые N шагов · checkpointing + FP16/BF16 scaler · distributed training hooks · callbacks TrainingArguments: num_train_epochs · batch_size · lr · warmup · fp16 · eval_strategy ...

// TRAINER INTERNALS · стандартный train loop с дополнительными возможностями

Кастомный Trainer: переопределяем compute_loss

Python · custom Trainer · class weighting
class WeightedLossTrainer(Trainer):
    """
    Кастомный Trainer с взвешенным CrossEntropy —
    полезно при несбалансированных классах.
    """
    def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
        labels = inputs.get("labels")
        outputs = model(**inputs)
        logits = outputs.get("logits")

        # Веса классов: pos=1.0, neg=2.5 (если neg в 2.5 раза реже)
        class_weights = torch.tensor([1.0, 2.5]).to(logits.device)
        loss_fct = torch.nn.CrossEntropyLoss(weight=class_weights)
        loss = loss_fct(logits.view(-1, self.model.config.num_labels), labels.view(-1))

        return (loss, outputs) if return_outputs else loss


# Используем вместо стандартного Trainer:
trainer = WeightedLossTrainer(
    model=model, args=args,
    train_dataset=tokenized["train"],
    eval_dataset=tokenized["test"],
    compute_metrics=compute_metrics,
)

Сохранение и загрузка модели

Python · save · load · push to Hub
# Сохранение после обучения
trainer.save_model("./my-model")           # сохраняет модель + токенизатор
tokenizer.save_pretrained("./my-model")   # явно — надёжнее

# Загрузка
model = AutoModelForSequenceClassification.from_pretrained("./my-model")
tok   = AutoTokenizer.from_pretrained("./my-model")

# Публикация на Hugging Face Hub
# Требует: huggingface-cli login (или token в коде)
trainer.push_to_hub("username/imdb-bert-finetuned")

# Загрузка чужой модели с Hub
model_hub = AutoModelForSequenceClassification.from_pretrained(
    "textattack/bert-base-uncased-imdb"   # готовая модель с Hub
)

// 06 · советы эксперту

Советы и типичные ошибки

Hyperparameter search

Python · Optuna + Trainer
# Автоматический подбор гиперпараметров через Optuna
# pip install optuna

def model_init():
    return AutoModelForSequenceClassification.from_pretrained(
        MODEL_NAME, num_labels=2
    )

trainer = Trainer(
    model_init=model_init,  # ← функция, не экземпляр
    args=args,
    train_dataset=tokenized["train"],
    eval_dataset=tokenized["test"],
    compute_metrics=compute_metrics,
)

best_run = trainer.hyperparameter_search(
    direction="maximize",
    backend="optuna",
    n_trials=10,
    hp_space=lambda trial: {
        "learning_rate": trial.suggest_float("lr", 1e-5, 5e-5, log=True),
        "per_device_train_batch_size": trial.suggest_categorical("bs", [8, 16, 32]),
        "num_train_epochs": trial.suggest_int("epochs", 2, 5),
    },
)

Gradient Accumulation

Python · gradient accumulation
# Если GPU память не позволяет batch_size=32:
# используем gradient accumulation
# effective_batch = batch_size × gradient_accumulation_steps
# = 8 × 4 = 32 — как будто батч 32, но в памяти 8

args = TrainingArguments(
    per_device_train_batch_size=8,
    gradient_accumulation_steps=4,  # ← накапливаем 4 шага
    fp16=True,
    # bf16=True  ← лучше для Ampere+ GPU (A100, RTX3090)

    # Gradient checkpointing: экономит память ценой скорости
    gradient_checkpointing=True,
)

# Для BERT с gradient checkpointing:
model.gradient_checkpointing_enable()

Типичные ошибки при fine-tuning

⚠️

Катастрофическое забывание

Симптом: train loss падает, но eval ухудшается. Решение: уменьши LR (попробуй 1e-5), добавь warmup, используй linear decay.

🔢

Забытый -100 в NER

Симптом: loss не падает, F1 = 0. Причина: все субтокены включены в loss. Убедись, что ignore_index=-100 работает.

🎭

Неправильный [MASK]-токен

У RoBERTa это <mask>, у BERT — [MASK]. Проверяй через tokenizer.mask_token — не хардкодируй строку.

📏

Слишком малый датасет

Для fine-tuning BERT достаточно 1k–10k примеров, но меньше 500 — нестабильно. Попробуй few-shot через SetFit или добавь data augmentation.

⚡

Нет warmup

Без warmup LR сразу высокий → взрывной градиент в первых батчах. Стандарт: warmup_ratio=0.06 (6% шагов).

🔄

DataCollator не указан для NER

Стандартный DataCollator не паддит labels. Для token classification всегда указывай DataCollatorForTokenClassification.

// 07 · сравнение задач

Сравнение трёх задач fine-tuning

Параметр Text Classification (IMDb) NER (WNUT-17) MLM (ELI5)
Класс моделиForSequenceClassificationForTokenClassificationForMaskedLM
Входной вектор[CLS]-токен (1 вектор)Все T токен-векторовВсе T токен-векторов
Выход моделиlogits (batch, num_labels)logits (batch, T, num_labels)logits (batch, T, vocab)
Loss функцияCrossEntropy (2 класса)CrossEntropy (13 классов, -100)CrossEntropy (только [MASK])
DataCollatorDefault (padding)DataCollatorForTokenClassificationDataCollatorForLanguageModeling
Метрика оценкиaccuracy, F1seqeval F1 (entity-level)perplexity = exp(loss)
Типичный LR2e-55e-52e-5
Эпох35–103
Задача разметки1 метка на текст1 метка на токен (BIO)Нет разметки (self-supervised)
Главная сложностьtruncation длинных текстоввыравнивание субтокеновправильный DataCollator

Сравнение времени обучения (ориентировочно, GPU T4)

IMDb (bert-base, 3 эпохи, 25k примеров)~45 мин
IMDb (distilbert, 3 эпохи)~22 мин
WNUT NER (distilbert, 5 эпох, 3.4k примеров)~8 мин
ELI5 MLM (distilroberta, 3 эпохи, 5k примеров)~60 мин
Что дальше после fine-tuning? LoRA / QLoRA (параметрически эффективное дообучение) — обучаем только малую долю параметров. SetFit — few-shot classification через contrastive learning. PEFT (Parameter-Efficient Fine-Tuning) — библиотека HuggingFace. Prompt tuning — обучаем только "мягкие токены" в префиксе.