Практика — LoRA-дообучение Qwen
Дообучаем Qwen2.5-0.5B-Instruct с LoRA на русском датасете: замер до обучения, крошечный прогон на CPU и полный на GPU, оценка после, проверка забывания, слияние весов — и тот же эксперимент в LLaMA-Factory.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В двух прошлых уроках вы собрали все детали: формат диалога и маску loss (урок 6.1.2), LoRA и слияние весов (урок 6.2.1). Теперь соберём их в настоящий эксперимент: дообучим Qwen2.5-0.5B-Instruct на русском датасете и измерим результат.
Главный навык здесь — не «запустить обучение», а честно измерить. Нужно знать, что умела модель до обучения, проверять её на примерах, которых она не видела, и следить, что она не разучилась остальному. Без этого LoRA превращается в «алхимию» («ляньдань», букв. «выплавка эликсира бессмертия» — так китайские инженеры в шутку называют обучение нейросетей с подбором гиперпараметров наугад), где непонятно, что сработало.
- Задача и данные. Генерируем русский датасет с точной автоматической проверкой.
- Замер до. Как исходная модель справляется с задачей одной инструкцией в промпте?
- Обучение LoRA. Маска loss из урока 6.1.2, адаптеры на всех линейных слоях.
- Замер после. Точность на новых формулировках и проверка забывания.
- Слияние и сохранение. Адаптер отдельно и слитая модель.
- То же в LLaMA-Factory. Китайский фреймворк дообучения: один YAML-файл вместо скрипта.
Окружение и железо
Заголовок раздела «Окружение и железо»Из корня репозитория курса: pip install -r homework/requirements-llm.txt (transformers, peft, trl, datasets,
accelerate; что делает каждая библиотека и как закрепить версии — урок 4.6.5). Модель скачается при первом запуске, около 1 ГБ. Если Hugging Face недоступен, скачайте её с ModelScope:
pip install modelscope, затем modelscope download --model Qwen/Qwen2.5-0.5B-Instruct --local_dir ./qwen-0.5b и
передайте скрипту --model ./qwen-0.5b.
| Режим | Железо | Что запускать | Время |
|---|---|---|---|
| Крошечный прогон | CPU, 8+ ГБ RAM | 30 шагов, батч 4, 20 тестовых примеров | обучение — минуты, генерация для оценки — ещё несколько минут |
| Полный прогон | GPU 8+ ГБ (в том числе бесплатные в Colab и Kaggle) | 300 шагов, батч 8, 200 тестовых примеров | минуты |
| Дальше | GPU 16–24 ГБ | Qwen2.5-7B через QLoRA (прошлый урок) | от получаса |
Для ориентира: на 4-ядерном облачном CPU один шаг обучения Qwen2.5-0.5B (батч 4 × ~80 токенов, LoRA ) у нас занимал около 5 секунд.
Шаг 1. Данные: заказы китайского чая
Заголовок раздела «Шаг 1. Данные: заказы китайского чая»Задача — извлечь из свободного сообщения клиента заказ в JSON:
Пришлите, пожалуйста, лунцзина — 3 упаковки. Я живу в Новосибирске.→ {"tea": "лунцзин", "qty": 3, "city": "Новосибирск"}Почему именно она:
- Точная проверка. Ответ либо совпадает с эталоном, либо нет. Никаких «LLM-судей» (о них — в модуле 7.3).
- Русская морфология. В тексте «в Новосибирске», «во Владивосток», «пуэра», а в ответе нужны именительный падеж и число. Модель должна нормализовать формы, а не копировать.
- Тест на обобщение. Обучающие и тестовые примеры собраны из разных шаблонов фраз. Модель, которая просто запомнила 8 обучающих формулировок, на тесте провалится.
Генератор целиком (только стандартная библиотека Python):
"""Синтетический русский датасет: заказ китайского чая -> JSON. Запуск: python make_tea_data.py"""import jsonimport random
TEAS = [ # (именительный, родительный) ("пуэр", "пуэра"), ("шу пуэр", "шу пуэра"), ("шэн пуэр", "шэн пуэра"), ("лунцзин", "лунцзина"), ("улун", "улуна"), ("молочный улун", "молочного улуна"), ("те гуаньинь", "те гуаньинь"), ("да хун пао", "да хун пао"), ("жасминовый чай", "жасминового чая"), ("белый чай", "белого чая"), ("красный чай", "красного чая"),]CITIES = [ # (именительный, «куда», «где») ("Москва", "в Москву", "в Москве"), ("Санкт-Петербург", "в Санкт-Петербург", "в Санкт-Петербурге"), ("Казань", "в Казань", "в Казани"), ("Новосибирск", "в Новосибирск", "в Новосибирске"), ("Екатеринбург", "в Екатеринбург", "в Екатеринбурге"), ("Самара", "в Самару", "в Самаре"), ("Тула", "в Тулу", "в Туле"), ("Пермь", "в Пермь", "в Перми"), ("Владивосток", "во Владивосток", "во Владивостоке"), ("Иркутск", "в Иркутск", "в Иркутске"), ("Уфа", "в Уфу", "в Уфе"), ("Минск", "в Минск", "в Минске"),]TRAIN_TEMPLATES = [ "Здравствуйте! Хочу заказать {n} {pack_acc} {tea_gen}, доставка {city_to}.", "Пришлите, пожалуйста, {tea_gen} — {n} {pack_acc}. Я живу {city_at}.", "Нужно {n} {pack_acc} {tea_gen} {city_to}, оплата при получении.", "{city_nom}. {tea_nom_cap}, {n} шт.", "Добрый день! Можно {n} {pack_acc} {tea_gen}? Отправьте {city_to}.", "Закажу {tea_gen} {n} {pack_acc}, город — {city_nom}.", "Мне бы {n} {pack_acc} {tea_gen} с доставкой {city_to}. Спасибо!", "Оформите заказ: {tea_nom}, {n} шт., {city_nom}.",]TEST_TEMPLATES = [ # этих формулировок модель на обучении не увидит "Привет! Отправьте {city_to} {n} {pack_acc} {tea_gen}, пожалуйста.", "Город {city_nom}, нужен {tea_nom}, количество — {n}.", "Я из города {city_nom}. Возьму {tea_gen} {n} {pack_acc}.",]SYSTEM = "Извлеки из сообщения клиента заказ. Ответь только JSON с ключами tea, qty, city."
def pack_acc(n): """1 упаковку, 2 упаковки, 5 упаковок, 11 упаковок, 21 упаковку.""" if n % 10 == 1 and n % 100 != 11: return "упаковку" if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14: return "упаковки" return "упаковок"
def make_example(rng, templates): (tea_nom, tea_gen), (city_nom, city_to, city_at) = rng.choice(TEAS), rng.choice(CITIES) n = rng.randint(1, 12) text = rng.choice(templates).format( n=n, pack_acc=pack_acc(n), tea_nom=tea_nom, tea_nom_cap=tea_nom.capitalize(), tea_gen=tea_gen, city_nom=city_nom, city_to=city_to, city_at=city_at, ) answer = json.dumps({"tea": tea_nom, "qty": n, "city": city_nom}, ensure_ascii=False) return {"messages": [ {"role": "system", "content": SYSTEM}, {"role": "user", "content": text}, {"role": "assistant", "content": answer}, ]}
if __name__ == "__main__": rng = random.Random(42) for name, templates, size in [("train", TRAIN_TEMPLATES, 2000), ("test", TEST_TEMPLATES, 200)]: rows = [make_example(rng, templates) for _ in range(size)] with open(f"tea_{name}.json", "w", encoding="utf-8") as f: json.dump(rows, f, ensure_ascii=False, indent=1) print(f"tea_{name}.json: {len(rows)} примеров") for m in make_example(random.Random(7), TRAIN_TEMPLATES)["messages"]: print(f"{m['role']:>9}: {m['content']}")tea_train.json: 2000 примеровtea_test.json: 200 примеров system: Извлеки из сообщения клиента заказ. Ответь только JSON с ключами tea, qty, city. user: Здравствуйте! Хочу заказать 7 упаковок молочного улуна, доставка в Казань.assistant: {"tea": "молочный улун", "qty": 7, "city": "Казань"}Формат файла — тот же список messages, что в уроке 6.1.2. Его без изменений понимают и наш скрипт, и LLaMA-Factory.
Шаг 2. Скрипт: оценка → LoRA → оценка → слияние
Заголовок раздела «Шаг 2. Скрипт: оценка → LoRA → оценка → слияние»Весь эксперимент — один файл lora_qwen.py из четырёх частей. Код целиком прогнан на transformers 5.x и peft 0.21 (на маленькой модели той же архитектуры Qwen2).
Мы намеренно пишем цикл обучения сами, без Trainer: так видно каждую деталь из прошлых уроков.
"""LoRA-дообучение Qwen на заказах чая. CPU: --steps 30 --eval-n 20; GPU: --steps 300 --batch 8 --eval-n 200"""import argparseimport jsonimport randomimport time
import torchfrom peft import LoraConfig, get_peft_modelfrom transformers import AutoModelForCausalLM, AutoTokenizer
# argparse разбирает аргументы командной строки: `python lora_qwen.py --steps 300` → args.steps == 300ap = argparse.ArgumentParser()ap.add_argument("--model", default="Qwen/Qwen2.5-0.5B-Instruct")ap.add_argument("--steps", type=int, default=30)ap.add_argument("--batch", type=int, default=4)ap.add_argument("--lr", type=float, default=2e-4)ap.add_argument("--rank", type=int, default=16)ap.add_argument("--eval-n", type=int, default=40)ap.add_argument("--out", default="lora-tea")args = ap.parse_args()
torch.manual_seed(0)device = "cuda" if torch.cuda.is_available() else "cpu"dtype = torch.bfloat16 if device == "cuda" and torch.cuda.is_bf16_supported() else torch.float32tok = AutoTokenizer.from_pretrained(args.model)model = AutoModelForCausalLM.from_pretrained(args.model).to(device=device, dtype=dtype)train = json.load(open("tea_train.json", encoding="utf-8"))test = json.load(open("tea_test.json", encoding="utf-8"))[: args.eval_n]GENERAL = ["Какая столица Франции?", "Переведи на английский: «Я люблю зелёный чай».", "Сколько будет 17 * 3?"]
# ── 1. Оценка: генерация ответа и сравнение JSON ────────────────────────────────@torch.no_grad()def generate(messages, max_new_tokens=48): text = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, enable_thinking=False) enc = tok(text, return_tensors="pt", add_special_tokens=False).to(device) out = model.generate(**enc, max_new_tokens=max_new_tokens, do_sample=False, repetition_penalty=1.0, pad_token_id=tok.pad_token_id) return tok.decode(out[0, enc["input_ids"].shape[1]:], skip_special_tokens=True).strip()
def parse(text): """Строгий разбор (весь ответ — JSON) и мягкий (JSON где-то внутри, например в ```json … ```).""" try: return json.loads(text), True except json.JSONDecodeError: pass try: # вырезаем кусок от первой «{» до последней «}» и пробуем разобрать его return json.loads(text[text.index("{"): text.rindex("}") + 1]), False except ValueError: # нет скобок или внутри не JSON (JSONDecodeError — подкласс ValueError) return None, False
def evaluate(tag): model.eval() strict = correct = 0 for ex in test: gold = json.loads(ex["messages"][-1]["content"]) pred, is_strict = parse(generate(ex["messages"][:-1])) strict += is_strict correct += pred == gold print(f"[{tag}] строгий JSON: {strict / len(test):.0%} | заказ извлечён верно: {correct / len(test):.0%}") print(f"[{tag}] пример: {generate(test[0]['messages'][:-1])}") for q in GENERAL: # не разучилась ли модель обычным вещам? print(f"[{tag}] {q} -> {generate([{'role': 'user', 'content': q}], 40)[:80]!r}")
evaluate("до")
# ── 2. Данные: шаблон чата + маска loss (как в уроке 6.1.2) ─────────────────────def encode(messages, max_len=256): prompt = tok.apply_chat_template(messages[:-1], tokenize=False, add_generation_prompt=True, enable_thinking=False) full = tok.apply_chat_template(messages, tokenize=False, enable_thinking=False) assert full.startswith(prompt), "шаблон устроен иначе: промпт не является префиксом диалога" p_ids = tok(prompt, add_special_tokens=False)["input_ids"] ids = tok(full, add_special_tokens=False)["input_ids"][:max_len] assert ids[: len(p_ids)] == p_ids, "токены промпта поменялись на стыке — стройте маску по смещениям" return ids, [-100] * len(p_ids) + ids[len(p_ids):] # loss только на ответе (+ <|im_end|> и \n после него)
def collate(rows): T = max(len(ids) for ids, _ in rows) x = torch.full((len(rows), T), tok.pad_token_id) y = torch.full((len(rows), T), -100) att = torch.zeros((len(rows), T), dtype=torch.long) for i, (ids, labels) in enumerate(rows): x[i, : len(ids)], y[i, : len(ids)], att[i, : len(ids)] = torch.tensor(ids), torch.tensor(labels), 1 return x.to(device), y.to(device), att.to(device)
data = [encode(ex["messages"]) for ex in train]ids0, lab0 = data[0]print(f"пример: {len(ids0)} токенов, из них в loss: {sum(l != -100 for l in lab0)}")
# ── 3. LoRA: замораживаем модель, добавляем адаптеры во все линейные слои блока ──config = LoraConfig( r=args.rank, lora_alpha=2 * args.rank, lora_dropout=0.05, task_type="CAUSAL_LM", target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],)model = get_peft_model(model, config)model.print_trainable_parameters()
opt = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=args.lr, weight_decay=0.0)sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min(1.0, (s + 1) / 10)) # 10 шагов warmuprng = random.Random(0)model.train()t0 = time.time()for step in range(1, args.steps + 1): x, y, att = collate(rng.sample(data, args.batch)) loss = model(input_ids=x, attention_mask=att, labels=y).loss # сдвиг на 1 токен HF делает сам loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) opt.step() sched.step() opt.zero_grad() if step % 10 == 0 or step == 1: print(f"шаг {step:4d} | loss {loss.item():.3f} | {(time.time() - t0) / step:.1f} с/шаг")
evaluate("после")
# ── 4. Сохраняем адаптер (мегабайты) и слитую модель (полный размер) ────────────model.save_pretrained(args.out)merged = model.merge_and_unload()merged.save_pretrained(args.out + "-merged")tok.save_pretrained(args.out + "-merged")print("сохранено:", args.out, "и", args.out + "-merged")Что здесь важно:
- Оценка — жадная генерация (
do_sample=False). Иначе результат будет меняться от запуска к запуску.repetition_penalty=1.0отключает штраф за повторы изgeneration_configмодели: в JSON повторяющиеся кавычки — норма. - Две метрики. «Строгий JSON» — ответ целиком парсится как JSON, без пояснений и обёрток
```json. «Заказ извлечён верно» — найденный JSON совпадает с эталоном, даже если вокруг есть лишний текст. Первая метрика проверяет формат, вторая — понимание задачи. - Маска — способ «префикс» из урока 6.1.2. Два
assertловят случаи, когда шаблон или токенизация на стыке ведут себя неожиданно. Ошибки здесь не бывают «тихими». enable_thinking=Falseнужен Qwen3: без него гибридная модель начнёт ответ с рассуждения. Есть и второй эффект: с этим флагом пустой блок<think>\n\n</think>оказывается в промпте и не входит в loss. Без флага он попал бы в ответ, и модель училась бы его печатать. Шаблон Qwen2.5 этот параметр просто игнорирует.- LoRA , , LR с коротким warmup и обрезкой градиента. Обучаются
8.8 млн параметров.
print_trainable_parameters()покажет 1.75%: PEFT делит на сумму базы и адаптера (), а не на 494 млн.
Шаг 3. Запуск
Заголовок раздела «Шаг 3. Запуск»python make_tea_data.pypython lora_qwen.py --steps 30 --eval-n 20 # CPU: крошечный прогонpython lora_qwen.py --steps 300 --batch 8 --eval-n 200 # GPU: полный прогонpython lora_qwen.py --model Qwen/Qwen3-0.6B --steps 300 --batch 8 --eval-n 200 # то же для Qwen3Для Qwen3 нужна transformers не ниже 4.51. На шаблоне Qwen3 с enable_thinking=False промпт — префикс полного
диалога, так что оба assert должны пройти. Если на вашей версии шаблона первый всё же сработает, перейдите на
построение маски поиском маркеров — как в задании урока 6.1.2.
Шаг 4. Как читать результаты
Заголовок раздела «Шаг 4. Как читать результаты»Заполните таблицу своими числами. Мы не приводим «правильные» значения: они зависят от версий библиотек и железа, а честный замер — часть задания.
| Метрика | До | После: CPU, 30 шагов | После: GPU, 300 шагов |
|---|---|---|---|
| Строгий JSON | |||
| Заказ извлечён верно | |||
| Ответ на «Какая столица Франции?» |
На что смотреть.
- Ошибки до обучения. Разберите 5–10 ответов глазами. Типичные отклонения у маленьких моделей: обёртка
```json, пояснения до или после JSON, ключи по-русски, город в той форме, что в тексте («Тулу»), число строкой. Каждая из этих ошибок — то, чему должна научить LoRA. - Loss. Он должен заметно упасть за первые десятки шагов: формат ответа модель перенимает быстро. Если loss
равен
nanуже на первом шаге, скорее всего, в батче не осталось ни одного обучаемого токена (все labels равны ). - Обобщение. Тест собран из новых формулировок. Временно подставьте вместо
testпервые 100 примеровtrainи сравните. Если на обучающих фразах почти 100%, а на тесте заметно ниже — модель запомнила шаблоны фраз. Помогают более разнообразные данные, а не больше шагов. - Забывание. Сравните ответы на три общих вопроса. Если модель отвечает на «Сколько будет 17 * 3?» JSON-ом, она переучилась на узкую задачу — это катастрофическое забываниекатастрофическое забываниеEN catastrophic forgettingПри дообучении на узкой задаче модель теряет ранее освоенные навыки..
Если модель «забыла» остальное
Заголовок раздела «Если модель «забыла» остальное»- Меньше обучения: меньше шагов, ниже LR, меньше ранг. 30 шагов часто достаточно для формата.
- Подмешайте общие данные. Добавьте 10–30% обычных диалогов. Простой приём — сгенерировать ответы исходной модели на разнообразные вопросы и добавить их в обучение. Так модель «повторяет» своё прежнее поведение.
- Не сливайте адаптер. Держите базу отдельно и включайте LoRA только для этой задачи — для остального модель останется исходной.
Если что-то пошло не так
Заголовок раздела «Если что-то пошло не так»| Симптом | Вероятная причина | Что сделать |
|---|---|---|
loss = nan | пустые labels, FP16 (например, fp16: true в LLaMA-Factory), слишком большой LR | проверить labels, BF16 или FP32, LR ниже |
| Loss не падает | все labels равны или LR слишком мал | напечатайте число токенов в loss (скрипт это делает) |
| Out of memory | длинные примеры, большой батч | --batch меньше, max_len в encode меньше, QLoRA |
| Модель не останавливается | <|im_end|> не попал в loss | проверьте конец labels у примера |
Срабатывает assert | шаблон модели устроен иначе | маска поиском маркеров (урок 6.1.2) |
Шаг 5. Как пользоваться результатом
Заголовок раздела «Шаг 5. Как пользоваться результатом»from peft import PeftModelfrom transformers import AutoModelForCausalLM, AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")# Вариант 1: база + адаптер (адаптер можно отключить или заменить другим)model = PeftModel.from_pretrained(AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct"), "lora-tea")# Вариант 2: слитая модель — обычная модель transformers, peft не нуженmerged = AutoModelForCausalLM.from_pretrained("lora-tea-merged")
msgs = [{"role": "system", "content": "Извлеки из сообщения клиента заказ. Ответь только JSON с ключами tea, qty, city."}, {"role": "user", "content": "Добрый вечер! Пришлите 2 упаковки улуна в Пермь."}]enc = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt", return_dict=True)for m in (model, merged): out = m.generate(**enc, max_new_tokens=40, do_sample=False) print(tok.decode(out[0, enc["input_ids"].shape[1]:], skip_special_tokens=True))Оба варианта должны дать одинаковый ответ — это проверка того, что слияние ничего не испортило (то же свойство вы проверяли в задании прошлого урока). Если модели загружены в BF16, изредка возможны расхождения: слитый вес округляется иначе, чем сумма двух веток. Слитую модель можно сконвертировать в GGUF и запустить в llama.cpp или Ollama — это модуль 7.2.
То же самое в LLaMA-Factory
Заголовок раздела «То же самое в LLaMA-Factory»Писать цикл PEFTпараметрически-эффективное дообучение (PEFT)EN parameter-efficient fine-tuning (PEFT)Обучается малая доля параметров (адаптеры, LoRA, префиксы), основная модель заморожена. Так же называется библиотека Hugging Face. вручную полезно для понимания. На практике чаще берут готовый фреймворк. Самый популярный — LLaMA-Factory (Apache-2.0): обучение, диалог и слияние описываются YAML-файлами, есть веб-интерфейс.
1. Установка (по README проекта):
git clone --depth 1 https://github.com/hiyouga/LlamaFactory.gitcd LlamaFactorypip install -e .pip install -r requirements/metrics.txt2. Датасет. Скопируйте tea_train.json в LlamaFactory/data/ и добавьте в data/dataset_info.json описание
в формате OpenAI. Это частный случай формата sharegpt:
"tea_orders": { "file_name": "tea_train.json", "formatting": "sharegpt", "columns": {"messages": "messages"}, "tags": {"role_tag": "role", "content_tag": "content", "user_tag": "user", "assistant_tag": "assistant", "system_tag": "system"}}3. Конфиг обучения tea_lora.yaml — по образцу examples/train_lora/qwen3_lora_sft.yaml:
### modelmodel_name_or_path: Qwen/Qwen2.5-0.5B-Instruct
### methodstage: sftdo_train: truefinetuning_type: loralora_rank: 16 # lora_alpha по умолчанию = 2 * lora_ranklora_target: all # все линейные слои
### datasetdataset: tea_orderstemplate: qwen # для Qwen3-0.6B: qwen3 и строка enable_thinking: falsecutoff_len: 256
### outputoutput_dir: saves/qwen2.5-0.5b/lora/tealogging_steps: 10plot_loss: trueoverwrite_output_dir: truereport_to: none
### trainper_device_train_batch_size: 8gradient_accumulation_steps: 1learning_rate: 2.0e-4num_train_epochs: 1.0lr_scheduler_type: cosinewarmup_ratio: 0.1bf16: true # на GPU без BF16 (например, T4) замените на fp16: true4. Три команды — обучение, диалог, слияние:
llamafactory-cli train tea_lora.yamlllamafactory-cli chat tea_chat.yaml # model_name_or_path, adapter_name_or_path, templatellamafactory-cli export tea_merge.yaml # то же + export_dir: куда сохранить слитую модельКонфиги для chat и export — это три–пять строк: путь к базе, путь к адаптеру (saves/qwen2.5-0.5b/lora/tea),
template: qwen, а для экспорта ещё export_dir. Образцы лежат в examples/inference/ и examples/merge_lora/.
Команда llamafactory-cli webui открывает тот же функционал в браузере.
Что LLaMA-Factory делает «под капотом» — то, что вы уже писали руками. Флаг train_on_prompt по умолчанию выключен:
loss только на ответах. Значение template — тот самый chat-шаблон. README предупреждает: используйте одинаковый
шаблон при обучении и инференсе.
Шаблоны Qwen3 устроены так. qwen3_nothink — обычный ChatML без тегов рассуждения, он для моделей без режима
мышления (например, Qwen3-4B-Instruct-2507 в примерах LLaMA-Factory). Гибридную Qwen3-0.6B учите с template: qwen3
и enable_thinking: false: тогда пустой блок <think>\n\n</think> уходит в промпт, без loss, как в нашем скрипте.
Проверь себя
Заголовок раздела «Проверь себя»- Полный цикл дообучения: данные с точной проверкой → замер до → LoRA → замер после на новых формулировках → проверка забывания → слияние.
- Скрипт на PyTorch и PEFT — около 120 строк. В нём всё из модуля: chat-шаблон, маска loss, LoRA на всех линейных слоях,
merge_and_unload. - Узкое дообучение может испортить общие навыки. Помогают меньшие шаги и LR, общие данные в смеси и раздельное хранение адаптера.
- LLaMA-Factory делает то же самое через YAML:
llamafactory-cli train / chat / export. Шаблон обучения и инференса должен совпадать.
Дальше: SFT учит подражать образцам. Чтобы модель отвечала лучше образцов, нужны предпочтения и RL — модуль 6.3: основы RL, RLHF и DPO.