Три классические задачи — классификация текста (IMDb), распознавание именованных сущностей (WNUT-17) и маскированное языковое моделирование (ELI5) — через библиотеку Hugging Face Transformers. С кодом, схемами и объяснением каждого шага.
// 00 · основы
Предобученная языковая модель (PLM, Pre-trained Language Model) — это трансформер, обученный на огромном корпусе текстов (Wikipedia, Books, Common Crawl) с целью предсказывать следующий токен или восстанавливать замаскированные токены. В процессе предобучения модель приобретает универсальные лингвистические знания: синтаксис, семантику, факты о мире, дискурс.
Transfer learning в NLP — это использование этих знаний для новой конкретной задачи путём дообучения (fine-tuning): подключаем небольшую задачную голову, запускаем обучение на размеченных данных, и получаем state-of-the-art качество при значительно меньшем датасете, чем потребовалось бы при обучении с нуля.
// TRANSFER LEARNING · предобучение → дообучение под конкретную задачу
Bidirectional — видит весь контекст. Лучший выбор для понимания текста: классификация, NER, QA, STS. MLM — родная задача предобучения.
Knowledge distillation из BERT: 40% меньше параметров, 60% быстрее, 97% качества. Идеален для быстрых экспериментов и прода с ограниченными ресурсами.
Скачивает веса, конфиг и токенизатор с Hugging Face Hub. Работает офлайн если кешировано. Поддерживает revision, cache_dir, torch_dtype.
// 01 · концепция
При fine-tuning мы берём предобученные веса и продолжаем обучение с малым learning rate на размеченном датасете. Все (или часть) весов обновляются через backprop — трансформер адаптирует свои представления к специфике задачи.
// TASK HEADS · один backbone, три разные головы под разные задачи
2e-5 до 5e-5. Слишком большой LR → катастрофическое забывание (модель «забывает» предобученные знания). Слишком маленький → не сходится. Оптимально: AdamW + linear warmup + weight decay = 0.01.
// 02 · text classification
IMDb Large Movie Review Dataset — бинарная классификация тональности рецензий на фильмы (positive / negative). 25 000 примеров для обучения, 25 000 для теста. Это один из стандартных бенчмарков для text classification.
Для классификации используется AutoModelForSequenceClassification. Модель берёт вектор [CLS]-токена из последнего слоя BERT и пропускает через Dropout → Linear(768, 2). Обучаем CrossEntropyLoss по двум классам.
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'])
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, ...}
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]])
distilbert-base-uncased вместо bert-base-uncased. DistilBERT в 2× быстрее, 40% меньше параметров, а accuracy на IMDb падает всего на ~1–1.5%. Также можно уменьшить max_length=128 — большинство рецензий укладываются.
model.bert.embeddings и первые слои через requires_grad_(False).
// 03 · named entity recognition
WNUT-17 (Workshop on Noisy User-generated Text) — датасет для NER в «шумных» текстах: твиты, Reddit, YouTube-комментарии. В отличие от классических NER-корпусов (CoNLL-2003), здесь нестандартная орфография, сленг, опечатки — это делает задачу существенно сложнее.
Каждому токену присваивается тег в схеме BIO (Begin-Inside-Outside): B-TYPE — начало сущности, I-TYPE — продолжение, O — не сущность.
// BIO-TAGGING · каждый токен получает метку. I-тег идёт только после B или I того же типа
BERT использует WordPiece: одно слово может быть разбито на несколько субтокенов. Например, «Washington» → «Washington» (одним куском), а «Schwarzenegger» → ['black', '##en', '##egg', '##er']. Разметка в датасете идёт по словам, а токенизатор выдаёт субтокены — нужно выравнивать.
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. Это стандартный трюк для маскирования позиций, которые не нужно предсказывать: [CLS], [SEP], второй и последующие субтокены слова. Если разметить все субтокены той же меткой — модель обучится нестабильно.
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 сложный — много редких сущностей)
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
// 04 · masked language modeling
ELI5 (Explain Like I'm Five) — датасет длинных вопросов-ответов с Reddit (/r/explainlikeimfive). Содержит около 270k примеров. Дообучение MLM на доменных данных (domain-adaptive pretraining) улучшает качество перед fine-tuning на downstream-задачах в этом домене.
MLM (Masked Language Modeling) — задача предобучения BERT: случайно маскируем 15% токенов, модель предсказывает исходные. При дообучении на ELI5 — модель адаптирует свои представления к неформальному стилю, научным объяснениям, разговорной речи.
// MLM MASKING · 80% [MASK], 10% случайный токен, 10% исходный — стратегия BERT
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, )
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 на этих же данных
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]}...")
Стандартный MLM маскирует отдельные субтокены. Это проблема: если маскируется только ##ing из «running», модель легко восстанавливает его по контексту «runn»+##[MASK]. Whole Word Masking маскирует все субтокены слова целиком.
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
// 05 · trainer api
HuggingFace Trainer — это полный training loop, реализованный за тебя. Он управляет распределённым обучением, mixed precision, gradient accumulation, checkpoint saving и evaluation. Но важно понимать, что происходит внутри.
// TRAINER INTERNALS · стандартный train loop с дополнительными возможностями
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, )
# Сохранение после обучения 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 · советы эксперту
# Автоматический подбор гиперпараметров через 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), }, )
# Если 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()
Симптом: train loss падает, но eval ухудшается. Решение: уменьши LR (попробуй 1e-5), добавь warmup, используй linear decay.
Симптом: loss не падает, F1 = 0. Причина: все субтокены включены в loss. Убедись, что ignore_index=-100 работает.
У RoBERTa это <mask>, у BERT — [MASK]. Проверяй через tokenizer.mask_token — не хардкодируй строку.
Для fine-tuning BERT достаточно 1k–10k примеров, но меньше 500 — нестабильно. Попробуй few-shot через SetFit или добавь data augmentation.
Без warmup LR сразу высокий → взрывной градиент в первых батчах. Стандарт: warmup_ratio=0.06 (6% шагов).
Стандартный DataCollator не паддит labels. Для token classification всегда указывай DataCollatorForTokenClassification.
// 07 · сравнение задач
| Параметр | Text Classification (IMDb) | NER (WNUT-17) | MLM (ELI5) |
|---|---|---|---|
| Класс модели | ForSequenceClassification | ForTokenClassification | ForMaskedLM |
| Входной вектор | [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]) |
| DataCollator | Default (padding) | DataCollatorForTokenClassification | DataCollatorForLanguageModeling |
| Метрика оценки | accuracy, F1 | seqeval F1 (entity-level) | perplexity = exp(loss) |
| Типичный LR | 2e-5 | 5e-5 | 2e-5 |
| Эпох | 3 | 5–10 | 3 |
| Задача разметки | 1 метка на текст | 1 метка на токен (BIO) | Нет разметки (self-supervised) |
| Главная сложность | truncation длинных текстов | выравнивание субтокенов | правильный DataCollator |