SFT — данные, шаблоны, маскирование
Как превратить диалоги в обучающие примеры: chat-шаблон ChatML, маска loss только на ответах ассистента, выбор и синтез данных — и всё это на маленькой модели своими руками.
Зачем это нужно
Заголовок раздела «Зачем это нужно»SFTобучение с учителем на инструкциях (SFT)EN supervised fine-tuning (SFT) (supervised fine-tuning) — первый и самый массовый этап пост-трейнинга. Функция потерь та же, что на претрейне: cross-entropy следующего токена. Всё новое — в данных и в том, по каким токенам считать loss.
Ошибки здесь коварны: они не вызывают исключений. Чужой chat-шаблон, забытый токен конца реплики или loss на репликах пользователя — и модель «как-то обучается». Но потом она путает роли, не останавливается или пишет за собеседника. В этом уроке вы разберёте каждый шаг от JSON-диалога до числа loss, соберёте маску в браузере и увидите на маленькой модели, что ломается без неё.
flowchart LR J["JSON: messages"] --> T["chat-шаблон<br/>(ChatML)"] T --> K["токены"] K --> M["маска: 1 на ответах<br/>ассистента"] M --> L["labels:<br/>id или -100"] L --> CE["cross-entropy<br/>только по ответам"]
Формат данных: список сообщений
Заголовок раздела «Формат данных: список сообщений»Де-факто стандарт — список сообщений с ролями в стиле OpenAI API. Так хранят данные LLaMA-Factory (формат «OpenAI»),
MiniMind (поле conversations) и большинство датасетов на Hugging Face:
{"messages": [ {"role": "system", "content": "Ты — вежливый ассистент."}, {"role": "user", "content": "Как по-английски «спасибо»?"}, {"role": "assistant", "content": "Thank you."}]}Роли: system — правила поведения (системный промптсистемный промптEN system promptСообщение с ролью system — правила для модели (роль, тон, формат). Технически это просто первый блок текста в chat-шаблоне.), user — реплики человека, assistant — то, чему
учим модель, tool — результаты вызова инструментов (модуль 8.3). Многоходовой диалог — просто более длинный список.
Из диалога — в строку: ChatML
Заголовок раздела «Из диалога — в строку: ChatML»Модель видит не JSON, а одну строку токенов. Как её собрать, решает chat-шаблонchat-шаблонEN chat templateПравило (обычно Jinja в tokenizer_config.json), которое превращает список сообщений с ролями в одну строку со спецтокенами. — вы разбирали шаблоны Qwen, Llama 3 и DeepSeek-V3 в уроке 4.2.3. У Qwen это формат ChatML:
<|im_start|>systemТы — вежливый ассистент.<|im_end|><|im_start|>userКак по-английски «спасибо»?<|im_end|><|im_start|>assistantThank you.<|im_end|><|im_start|> (id 151644) и <|im_end|> (id 151645) — спецтокеныспециальный токен (спецтокен)EN special tokenУправляющий токен, а не кусок текста — начало/конец текста (BOS/EOS), заполнитель для батча (PAD), разметка ролей в чате (например, <|im_start|> у Qwen)., каждый занимает
ровно один токен. Для SFT важны четыре следствия.
- Шаблон обучения = шаблон инференса. README LLaMA-Factory прямо предупреждает: используйте один и тот же шаблон при обучении и при запуске. Иначе модель увидит разметку, которой не училась.
- Граница «промпт | ответ» — это
add_generation_prompt. На инференсе шаблон заканчивается строкой<|im_start|>assistant\n, и модель продолжает с этого места. При обучении всё до этой строки включительно — контекст, а после неё — то, что модель должна сгенерировать. - Шаблон может добавить текст, которого нет в данных. Шаблон Qwen2.5 вставляет системный промпт по умолчанию
«You are Qwen, created by Alibaba Cloud. You are a helpful assistant.», если в диалоге нет своего
system. В шаблоне гибридной Qwen3 ответ без рассуждений содержит пустой блок<think>\n\n</think>, а сenable_thinking=Falseэтот блок переезжает в промпт — и выпадает из loss. Смотрите на отрендеренную строку, а не на JSON. - Спецтокены должны быть настоящими. Если вы придумали свои маркеры, добавьте их в токенизатор как special tokens
и увеличьте матрицу эмбеддингов (
model.resize_token_embeddings(len(tok))). Иначе<|im_end|>развалится на несколько обычных токенов, и надёжной остановки не будет.
Маска loss: учимся только на ответах
Заголовок раздела «Маска loss: учимся только на ответах»Обучать модель предсказывать реплики пользователя и системный промпт незачем: их на инференсе пишет не она.
Поэтому loss считают только по позициям ответов ассистента — для этого строят
маску lossмаска lossEN loss maskВ SFT loss считают только по токенам ответа ассистента; остальные позиции получают метку -100 (ignore_index в PyTorch).. Обозначим — множество позиций токенов ответов
(вместе с закрывающим <|im_end|>). Тогда
Контекст при этом виден целиком: модель читает промпт, но отвечает за правильность только ответа. Вот как это выглядит по токенам (условно, токен = слово):
| Позиция | Токен | Маска | label |
|---|---|---|---|
| 0–2 | <|im_start|> user \n | 0 | |
| 3–5 | Сколько будет 2+2? | 0 | |
| 6–7 | <|im_end|> \n | 0 | |
| 8–10 | <|im_start|> assistant \n | 0 | |
| 11 | 4 | 1 | id токена 4 |
| 12 | <|im_end|> | 1 | id <|im_end|> |
Что сделать в коде
Заголовок раздела «Что сделать в коде»- labels = там, где маска 0. Это
ignore_indexпо умолчанию вF.cross_entropyи в моделях transformers. - Сдвиг на один токен. Позиция предсказывает токен . Модели transformers сдвигают labels сами: передайте
labelsтой же длины, чтоinput_ids. В своём цикле сдвигайте явно:logits[:, :-1]противlabels[:, 1:]. <|im_end|>ответа входит в loss. Иначе модель не научится останавливаться. MiniMind включает в loss ещё и перевод строки после него (так же получится в практикуме 6.2.2). Это безвредно: генерация всё равно останавливается на<|im_end|>, а\nпосле него всегда один и тот же. В задании ниже придерживаемся строгого варианта — без\n.- Многоходовые диалоги. По умолчанию учат все ответы ассистента. В LLaMA-Factory флаг
mask_history: trueоставляет в loss только последний ответ, аtrain_on_prompt: true, наоборот, считает loss и по промптам. - Паддинг. Позиции паддинга получают label и
attention_mask = 0.
Два способа построить маску
Заголовок раздела «Два способа построить маску»- Поиск маркеров. Пройти по id токенов, найти последовательность «
<|im_start|>assistant\n» и открыть маску до ближайшего<|im_end|>. Так делает MiniMind; так же устроено задание ниже. Работает для любого числа реплик, но привязано к конкретному шаблону. - Префикс. Отрендерить промпт с
add_generation_prompt=Trueи полный диалог, затем закрыть первыеlen(prompt_ids)позиций. Удобно для одного ответа, работает с любым шаблоном. Но проверьте, что токены промпта совпадают с началом токенов диалога: BPE иногда сливает символы на стыке. Этот способ мы используем в практикуме 6.2.2.
Что ломается без маски
Заголовок раздела «Что ломается без маски»Без маски модель учится всему тексту, включая реплики пользователя. Следствия:
- Модель учится говорить за пользователя. После своего ответа она охотно начинает
<|im_start|>userи сочиняет следующий вопрос. В практике ниже это видно в 100% случаев. - Loss перестаёт что-то значить. Реплики пользователя непредсказуемы, их токены дают большой вклад в loss. Число падает медленно, хотя ответы уже хорошие.
- Длинный промпт съедает сигнал. Пусть в промпт вставлен документ на 3000 токенов, а ответ по нему — 100 токенов (так устроены диалоги с поиском по документам, RAG — модуль 8.2). Без маски слагаемых loss уходят на моделирование документа, а не на ответ.
Как усреднять: по токенам или по примерам
Заголовок раздела «Как усреднять: по токенам или по примерам»Пусть в батче два примера: у A ответ из 2 токенов, у каждого loss 3.0; у B — 8 токенов по 0.5.
- Среднее по токенам: . Длинные ответы весят больше.
- Среднее по примерам: . Каждый диалог весит одинаково.
Обычно используют первый вариант. Ловушка — накопление градиентов. Если A и B — разные микробатчи и каждый
усредняется по своим токенам, а потом результаты усредняются ещё раз, получится 1.75, а не 1.0: цель обучения тихо
меняется. Осенью 2024 года такую ошибку нашли в популярных библиотеках обучения, в том числе в Trainer из
transformers. Правильно — делить сумму loss на общее число обучаемых токенов во всём батче.
Упаковка
Заголовок раздела «Упаковка»Короткие примеры в батче дополняются паддингом до длины самого длинного, и заметная часть вычислений уходит впустую. Упаковка последовательностейупаковка последовательностейEN sequence packingДокументы склеивают в один поток токенов через разделитель и режут на окна фиксированной длины — без паддинга.
склеивает несколько примеров в одну последовательность нужной длины. Подвох: без доработки токены одного примера
«видят» через attention соседний пример. Для SFT это нежелательно, поэтому используют маски внимания по документам
или ядра переменной длины. В LLaMA-Factory такой режим включается флагом neat_packing — упаковка без
перекрёстного внимания.
Данные: качество важнее количества
Заголовок раздела «Данные: качество важнее количества»LIMA (Meta, 2023). Авторы дообучили LLaMA на 65B всего на 1000 примеров. Часть отобрали с Stack Exchange, wikiHow и Reddit, часть написали сами, следя за разнообразием тем и единым стилем ответа. В слепом сравнении люди считали ответы LIMA не хуже, чем у GPT-4, в 43% случаев; против Bard — в 58%, против text-davinci-003 — в 65%. Абляции показали: увеличение объёма однотипных данных почти не помогает, а разнообразие и качество — помогают. Отсюда гипотеза поверхностного выравниваниягипотеза поверхностного выравниванияEN superficial alignment hypothesisИз статьи LIMA (2023): знания модель получает на претрейне, а выравнивание в основном учит формату и стилю общения..
Но 1000 примеров достаточно для стиля, а не для новых навыков. Для рассуждений DeepSeek-R1 собрала около 800 тыс. примеров, а SFT DeepSeek-V3 — около 1.5 млн примеров из разных доменов.
Откуда берут данные
Заголовок раздела «Откуда берут данные»| Источник | Пример | Плюсы | Минусы |
|---|---|---|---|
| Пишут люди | InstructGPT (~13k промптов), LIMA (1000) | качество, контроль стиля | дорого и медленно |
| Модель сама генерирует инструкции | Self-Instruct: 175 задач-затравок → Alpaca, 52k примеров | дёшево, много | однообразие и ошибки учителя |
| Дистилляция сильной модели | ответы R1 для SFT в V3 и R1-Distill | переносит сильные навыки | ошибки учителя, условия его лицензии |
| Rejection sampling | этап 3 у R1: из многих ответов оставляют проверенные | только верные ответы | нужен верификатор |
| Шаблонная генерация | датасет заказов в практикуме 6.2.2 | точная автоматическая оценка | узкая задача |
Почти всё, кроме первой строки, — синтетические данныесинтетические данныеEN synthetic data. Они дешёвые, но у них два риска. Первый — модель перенимает ошибки и однообразие учителя; при многократном обучении на собственных генерациях это приводит к коллапсу моделиколлапс моделиEN model collapseДеградация моделей, которые поколение за поколением учатся на текстах, сгенерированных предыдущими моделями.. Второй — юридический: условия некоторых API запрещают обучать на их ответах конкурирующие модели. Проверяйте лицензию учителя: например, лицензия DeepSeek-R1 прямо разрешает дистилляцию в другие модели.
Чек-лист SFT-датасета
Заголовок раздела «Чек-лист SFT-датасета»- Разнообразие. Задачи, темы, длины ответов, языки. 1000 разных примеров лучше 10 000 похожих.
- Единый формат ответа. Один стиль Markdown, одинаковое оформление кода и формул.
- ДедупликациядедупликацияEN deduplication и очистка от примеров из тестовых бенчмарков (загрязнение данныхзагрязнение данных (утечка бенчмарка в обучение)EN data contaminationЗадачи из тестового бенчмарка попали в обучающий корпус LLM — оценка становится завышенной. Родственное явление — «шуабан», подгонка модели под лидерборд.).
- Язык. Для русскоязычного ассистента нужны русские диалоги. Готовых наборов меньше, чем английских и китайских; их часто получают переводом или генерацией сильной моделью — выборочно читайте результат глазами.
- Смесь с общими данными. Узкий датасет без общих диалогов ведёт к катастрофическому забываниюкатастрофическое забываниеEN catastrophic forgettingПри дообучении на узкой задаче модель теряет ранее освоенные навыки. — подробно в уроке 6.2.2.
Практика
Заголовок раздела «Практика»Задание: маска loss по ChatML
Заголовок раздела «Задание: маска loss по ChatML»Реализуйте сборку ChatML, маску «только ответы ассистента», labels и усреднение loss. Токенизатор уже готов:
спецтокены, \n и слова — отдельные токены.
import re
IM_START = "<|im_start|>"
IM_END = "<|im_end|>"
IGNORE_INDEX = -100 # так PyTorch помечает позиции, которые не входят в loss
def tokenize(text):
"""Игрушечный токенизатор (уже готов, менять не нужно).
Спецтокены, перевод строки и «слова» становятся отдельными токенами, пробелы выбрасываются:
tokenize("<|im_start|>user\\nПривет, мир<|im_end|>\\n")
-> ['<|im_start|>', 'user', '\\n', 'Привет,', 'мир', '<|im_end|>', '\\n']
"""
return re.findall(r"<\|im_start\|>|<\|im_end\|>|\n|[^\s<]+", text)
def render_chatml(messages, add_generation_prompt=False):
"""Склеивает диалог в строку формата ChatML (как у Qwen, без системного промпта по умолчанию).
Каждое сообщение: "<|im_start|>{role}\\n{content}<|im_end|>\\n".
Если add_generation_prompt=True, в конце добавляется "<|im_start|>assistant\\n" —
приглашение модели начать ответ.
"""
# TODO: ваш код
...
def build_loss_mask(tokens):
"""Маска loss: 1 — токен входит в loss, 0 — не входит.
Единицы ставятся только на содержимое ответов ассистента и на закрывающий <|im_end|>
каждого такого ответа. Заголовок "<|im_start|>", "assistant", "\\n" — нули.
Всё, что написали system и user, — нули. Ответов ассистента может быть несколько.
"""
# TODO: ваш код
...
def build_labels(ids, mask):
"""labels для обучения: id токена там, где mask == 1, и IGNORE_INDEX (-100) там, где mask == 0."""
# TODO: ваш код
...
def masked_nll(token_logprobs, labels):
"""Средний loss SFT: среднее -log p только по позициям, где labels != IGNORE_INDEX.
token_logprobs[t] = log p(x_t | x_<t) — сколько модель «поставила» на настоящий токен.
Если обучаемых позиций нет, верните 0.0 (а не делите на ноль).
"""
# TODO: ваш код
...
# Проверьте себя кнопкой «Запустить»:
dialog = [
{"role": "user", "content": "Сколько будет 2+2?"},
{"role": "assistant", "content": "4"},
]
text = render_chatml(dialog)
print(repr(text))
if text:
toks = tokenize(text)
print(list(zip(toks, build_loss_mask(toks) or [])))
Маленькая модель: с маской и без
Заголовок раздела «Маленькая модель: с маской и без»Обучим крошечный трансформер (~100 тыс. параметров) на диалогах с двумя командами: «переверни» и «отсортируй» последовательность цифр. В диалоге одна или две пары реплик. Один и тот же код запускается дважды: с маской и с loss на всех токенах. Нужен только PyTorch; на CPU работает около минуты.
import random
import torchimport torch.nn as nnimport torch.nn.functional as F
IM_START, IM_END = "<|im_start|>", "<|im_end|>"DIGITS = [str(d) for d in range(10)]VOCAB = [IM_START, IM_END, "\n", "user", "assistant", "переверни", "отсортируй", ":"] + DIGITSSTOI = {t: i for i, t in enumerate(VOCAB)}
def make_turn(rng): """Одна реплика пользователя и ответ: перевернуть или отсортировать 3–6 цифр.""" cmd = rng.choice(["переверни", "отсортируй"]) xs = [rng.choice(DIGITS) for _ in range(rng.randint(3, 6))] ys = xs[::-1] if cmd == "переверни" else sorted(xs) user = [IM_START, "user", "\n", cmd, ":", *xs, IM_END, "\n"] head = [IM_START, "assistant", "\n"] return user + head, ys + [IM_END]
def make_dialog(rng): """Диалог из 1–2 обменов репликами -> (токены, маска «это ответ ассистента»).""" tokens, mask = [], [] for k in range(rng.randint(1, 2)): if k > 0: tokens.append("\n") mask.append(0) prompt, answer = make_turn(rng) tokens += prompt + answer mask += [0] * len(prompt) + [1] * len(answer) return tokens, mask
def batch(rng, n, masked): rows = [make_dialog(rng) for _ in range(n)] T = max(len(t) for t, _ in rows) x = torch.zeros(n, T, dtype=torch.long) y = torch.full((n, T), -100) # паддинг тоже не входит в loss for i, (toks, mask) in enumerate(rows): ids = torch.tensor([STOI[t] for t in toks]) keep = torch.tensor(mask, dtype=torch.bool) if masked else torch.ones(len(ids), dtype=torch.bool) x[i, : len(ids)] = ids y[i, : len(ids)] = torch.where(keep, ids, torch.tensor(-100)) # там, где keep=False, label = -100 return x, y
class TinyGPT(nn.Module): def __init__(self, vocab, d=64, n_layers=2, n_heads=4, max_len=48): super().__init__() self.tok = nn.Embedding(vocab, d) self.pos = nn.Embedding(max_len, d) # встроенный блок трансформера PyTorch (внимание + FFN); norm_first=True — Pre-Norm, как в уроке 4.5.1. # Название «Encoder» не мешает: с причинной маской ниже он работает как блок GPT layer = nn.TransformerEncoderLayer(d, n_heads, 4 * d, dropout=0.0, batch_first=True, norm_first=True) self.blocks = nn.TransformerEncoder(layer, n_layers, enable_nested_tensor=False) self.head = nn.Linear(d, vocab)
def forward(self, idx): T = idx.shape[1] h = self.tok(idx) + self.pos(torch.arange(T)) causal = nn.Transformer.generate_square_subsequent_mask(T) # причинная маска (урок 4.4.2): −∞ над диагональю return self.head(self.blocks(h, mask=causal, is_causal=True))
def sft_loss(model, x, y): logits = model(x) # Сдвиг на один: позиция t предсказывает токен t+1 return F.cross_entropy(logits[:, :-1].reshape(-1, len(VOCAB)), y[:, 1:].reshape(-1), ignore_index=-100)
@torch.no_grad()def greedy(model, tokens, n): ids = [STOI[t] for t in tokens] out = [] for _ in range(n): nxt = model(torch.tensor([ids]))[0, -1].argmax().item() ids.append(nxt) out.append(VOCAB[nxt]) if VOCAB[nxt] == IM_END: break return out
def evaluate(model, n=200): rng = random.Random(123) # одни и те же тестовые задачи для обеих моделей ok = speaks_for_user = 0 for _ in range(n): prompt, answer = make_turn(rng) out = greedy(model, prompt, 8) ok += out == answer # Что модель напишет, если не остановить её после ответа? cont = greedy(model, prompt + answer + ["\n"], 2) speaks_for_user += cont == [IM_START, "user"] return ok / n, speaks_for_user / n
torch.set_num_threads(1)for masked in [True, False]: torch.manual_seed(0) model = TinyGPT(len(VOCAB)) opt = torch.optim.AdamW(model.parameters(), lr=3e-3) rng = random.Random(0) for step in range(1, 301): x, y = batch(rng, 64, masked) loss = sft_loss(model, x, y) opt.zero_grad() loss.backward() opt.step() xa, ya = batch(random.Random(7), 256, masked=True) # loss только на ответах — для честного сравнения with torch.no_grad(): ans_loss = sft_loss(model, xa, ya).item() acc, sfu = evaluate(model) print(f"маска={'да ' if masked else 'нет'} | train loss {loss.item():.3f} | loss на ответах {ans_loss:.3f}" f" | точность {acc:.0%} | пишет за пользователя {sfu:.0%}")
prompt, answer = make_turn(random.Random(5))print("Пример продолжения без маски:", " ".join(greedy(model, prompt + answer + ["\n"], 8)).replace("\n", "\\n"))маска=да | train loss 0.062 | loss на ответах 0.107 | точность 88% | пишет за пользователя 0%маска=нет | train loss 0.676 | loss на ответах 0.199 | точность 78% | пишет за пользователя 100%Пример продолжения без маски: <|im_start|> user \n переверни : 3 2 2Как читать результат (на другой версии PyTorch числа могут немного отличаться):
- Train loss без маски в 10 раз выше (0.676 против 0.062), хотя loss на ответах различается лишь вдвое. Train loss без маски почти целиком состоит из непредсказуемых цифр пользователя. Сравнивать модели можно только по loss на ответах.
- При равном числе шагов модель с маской точнее (88% против 78%): весь градиент идёт в ответы.
- Без маски модель в 100% случаев продолжает диалог за пользователя. Если движок генерации не остановится на
<|im_end|>, такая модель сама задаст себе следующий вопрос. Модель с маской этого не умеет: продолжение после ответа никогда не попадало в loss.
Поэкспериментируйте: уберите IM_END из ответа (return user + head, ys) и посмотрите на продолжения. Точность
при этом упадёт почти до нуля: greedy больше не встречает <|im_end|> и пишет до лимита в 8 токенов — это и есть
«модель не останавливается». Увеличьте число шагов до 1000: догонит ли модель без маски по точности?
Проверь себя
Заголовок раздела «Проверь себя»- SFT — та же cross-entropy, но по диалогам в chat-шаблоне и только на ответах ассистента вместе с токеном конца реплики.
- labels = скрывают позиции от loss; transformers сдвигает labels сам, в своём коде —
logits[:, :-1]противlabels[:, 1:]. - Без маски модель учится говорить за пользователя, а loss теряет смысл. При накоплении градиентов делите на общее число обучаемых токенов.
- Данные: качество и разнообразие важнее объёма (LIMA — 1000 примеров), но для новых навыков нужны сотни тысяч примеров — обычно синтетика от сильной модели.
Дальше: полный SFT 7B-модели требует больше 100 ГБ памяти. LoRA и QLoRA позволяют дообучать её на одной видеокарте.