---
title: "LoRA – Дообучаване на AI LLM модели"
url: "https://romankryvolapov.com/bg/lora-fine-tuning/"
description: "Дообучаване на LLM с LoRA: кога е по-добро от RAG и кога не, как са устроени адаптерите, и два пълни примера за Gemma 4 E2B с Unsloth и PEFT с експорт в GGUF."
language: bg
published: 2025-07-28
updated: 2026-11-09
---
**Здравейте!**

Тази статия е за това как да научим един езиков модел на нещо ново: факт, начин на отговаряне, тясно умение. Предназначена е за разработчици, които вече са стартирали модел локално или през API и сега искат той да се държи по определен начин. Не е нужно да знаете как се обучават невронните мрежи: всичко необходимо се обяснява в хода на текста.

Първо – трите начина да дадем на модела свои данни и кога кой е подходящ. После – как е устроена LoRA, с числа от истински модел. След това два пълни примера на една и съща задача: с Unsloth и с „чистите“ библиотеки на Hugging Face – PEFT и TRL. По-нататък – какво моделът е научил и какво е загубил, експорт в GGUF за llama.cpp и LM Studio, настройки на обучението и типични проблеми.

Целият код е стартиран на лаптоп с NVIDIA RTX 5070 Ti и 12 ГБ видеопамет, с версиите на библиотеките от ноември 2026 г. Моделът в примерите е Gemma 4 E2B на Google.

## Промпт, RAG или дообучаване

Езиковият модел знае само това, което е имало в обучаващите му данни. За вашата компания, вашите документи и вашия котарак той не знае нищо. Това може да се поправи по три начина и те решават различни задачи.

**Да сложим данните в промпта.** Най-простият начин: текстът, от който моделът се нуждае, се добавя към всяка заявка. Подходящ е, когато данните са малко и рядко се променят: описание на продукт, ръководство за стила, списък с правила. Контекстните прозорци днес са големи: Gemma 4 E2B приема до 128 хиляди токена, това е приблизително книга от 300 страници. Цената е, че един и същ текст се обработва наново при всяка заявка. Много API и llama.cpp умеят да кешират вече обработеното начало на промпта, което е по-евтино, но не безплатно.

**RAG.** Данните стоят в отделно хранилище, а към всеки въпрос в промпта се добавят само подходящите части. Подходящ е за големи и променливи знания: документация, тикети в поддръжката, каталог с продукти. Как е устроено, е разказано в следващия раздел.

**Дообучаване.** Променят се теглата на модела и новото поведение се вгражда в самия модел. За това е нужна LoRA.

Най-добре дообучаването променя *как* отговаря моделът: фиксиран формат на отговора, например JSON от определен вид, тон и стил, терминологията на вашата област, тясна задача като сортиране на тикети или извличане на полета от документи. Работи добре, когато има стотици или хиляди примери за желаното поведение, и позволява дългата инструкция във всяка заявка да се замени с къса.

Дообучаването не се справя добре с надеждното добавяне на голям обем факти. Това показват две изследвания от 2024 г. При [Ovadia и съавтори](https://arxiv.org/abs/2312.05934) RAG надмина дообучаването както при факти, които моделът е виждал при предварителното обучение, така и при съвсем нови. При [Gekhman и съавтори](https://arxiv.org/abs/2405.05904) моделите усвояваха новите факти чрез дообучаване бавно, а щом ги усвоеха, започваха по-често да измислят. Изводът на авторите: знанията моделът получава предимно при предварителното обучение, а дообучаването го учи да си служи с тези знания.

Оттук следва практическо правило. Фактите, които трябва да са точни и актуални, отиват в RAG или в промпта. Дообучаването е за форма, стил и умения. Подходите се комбинират добре: моделът, дообучен да отговаря във вашия формат, взема фактите от RAG.

| | Промпт | RAG | Дообучаване |
| --- | --- | --- | --- |
| Какво се променя | Заявката | Заявката | Теглата |
| Подходящо за | Малко стабилни данни | Големи, променливи знания | Формат, стил, тясно умение |
| Как се обновяват данните | Поправя се текстът | Документите се индексират наново | Обучава се наново |
| Цена на всяка заявка | Нараства с обема на данните | Търсене плюс по-дълга заявка | Нищо отгоре |
| Основен риск | Контекстът ще се изчерпи | Търсенето ще намери не каквото трябва | Изгубени умения, измислени факти |

Примерът в тази статия учи модела на един факт – името на моя котарак. Това е направено нарочно. Така най-лесно се вижда с очите си, че обучението е сработило, и заедно с това се вижда какво дообучаването чупи по пътя. За истинска база от знания това не е подходящ инструмент.

## RAG: данните се добавят към заявката

RAG е Retrieval-Augmented Generation, тоест генериране с опора на търсене. Преди да отговори, системата намира части от вашите данни, свързани с въпроса, и ги добавя към заявката.

Класическият вариант по стъпки:

1. Документите се разделят на фрагменти от по няколко абзаца.
2. Всеки фрагмент се превръща в ембединг (embedding): списък от няколкостотин числа, който описва неговия смисъл. Това прави малък модел за ембединги, например [multilingual-e5](https://huggingface.co/intfloat/multilingual-e5-large), [bge-m3](https://huggingface.co/BAAI/bge-m3) или [Qwen3-Embedding](https://huggingface.co/Qwen/Qwen3-Embedding-0.6B). Текстове с подобен смисъл получават подобни числа.
3. Ембедингите се записват във векторна база данни.
4. Когато дойде въпрос, същият модел го превръща в ембединг и базата връща фрагментите с най-близки числа.
5. Тези фрагменти се слагат в промпта до въпроса и езиковият модел отговаря по тях.

При многостъпковия, или агентен, RAG моделът сам решава кога му трябват данни. Той формулира подвъпрос, извиква търсенето като инструмент, получава резултата и продължава отговора, понякога по няколко пъти в рамките на един отговор.

RAG и хранилищата за него са разгледани подробно в две други статии: [RAG и function calling](/bg/rag-and-function-calling/) и [векторните бази данни](/bg/vector-databases/).

## Как дообучаването променя модела

Моделът е голям набор от числа, които се наричат тегла, и по-голямата част от тях е подредена в матрици. Когато моделът обработва текст, всяко парченце от дума, токенът, минава през десетки слоеве и във всеки слой се умножава по тези матрици. Спокойно въведение във всичко това е статията [Невронните мрежи с прости думи](/bg/neural-networks-basics/).

Обучението е цикъл:

1. На модела се дава текст и той трябва да предскаже в него всеки следващ токен.
2. Колко е сгрешил, се измерва с едно число – загуба (loss).
3. Обратното разпространение на грешката изчислява за всяко тегло в коя посока да се премести, за да намалее загубата. Тази посока се нарича градиент.
4. Всяко тегло се премества малко в тази посока и всичко се повтаря със следващите текстове.

Пълното дообучаване променя всички тегла и това е скъпо. При обикновено обучение със смесена точност и оптимизатор Adam на всяко тегло са нужни около 16 байта видеопамет: самото тегло в 16 и в 32 бита, неговият градиент и две статистики на оптимизатора. За 5,1 милиарда тегла на Gemma 4 E2B това са около 80 ГБ плюс памет за междинните резултати. Толкова няма нито една потребителска видеокарта.

Вторият проблем е катастрофалното забравяне. Когато моделът дълго се учи на нещо тясно, теглата му се отдалечават от предишните си стойности и той губи част от това, което е умеел преди. По-нататък в статията това ще се види: след първото обучение на въпрос за столицата на Франция моделът отговори „My cat“, тоест „моят котарак“.

## LoRA: малка надстройка вместо пренаписване на теглата

LoRA, Low-Rank Adaptation, е предложена от изследователи на Microsoft през 2021 г. ([статия](https://arxiv.org/abs/2106.09685)). Идеята е: теглата на модела не се променят, а до тях се обучава малка надстройка.

Да вземем една матрица от тегла W. Дообучаването би я превърнало в W + ΔW, където ΔW е промяната. Авторите са забелязали, че тази промяна се приближава добре с произведението на две тесни матрици: ΔW ≈ B · A. Ако в W има d реда и k колони, то в A има r реда и k колони, а в B – d реда и r колони. Числото r се нарича ранг и е малко: 8, 16, 64.

Ето как изглежда това за една истинска матрица на Gemma 4 E2B, q_proj в първия слой на вниманието:

```text
W, замразена:       2048 × 1536 = 3 145 728 числа
A, обучава се:        16 × 1536 =    24 576 числа
B, обучава се:      2048 × 16   =    32 768 числа
A и B заедно:                        57 344 числа, 1,8 % от W
```

По време на обучението W е замразена: тя не се променя и градиенти за нея не се пазят. Обучават се само A и B. Обработвайки входа x, слоят изчислява:

```text
y = W·x + (α / r) · B·(A·x)
```

Тук α (алфа) е мащабиращ коефициент. Отношението α / r определя колко силно адаптерът влияе на резултата. Делението на r прави това влияние съпоставимо при различни рангове, затова скоростта на обучение не се налага да се подбира наново при смяна на r.

В началото A се запълва с малки случайни числа, а B – с нули. Затова B·A е нула и до първата стъпка на обучението моделът с адаптери отговаря точно както изходният. На това се опира скриптът с Unsloth: въпросите „преди обучението“ той задава на модела, който вече има адаптери. Скриптът с PEFT ги задава по-рано, докато тренерът още не е добавил адаптери, и благодарение на нулите отговорите излизат същите.

**Кои матрици получават адаптери.** Всеки слой на трансформъра се състои от две части. При вниманието има четири матрици: q_proj, k_proj, v_proj и o_proj; то решава кои от предишните токени са важни за текущия. При MLP са три: gate_proj, up_proj и down_proj; в него е по-голямата част от теглата. Оригиналната статия е слагала адаптери само върху вниманието. Сега ги слагат навсякъде: в работата [LoRA Without Regret](https://thinkingmachines.ai/blog/lora/) (Thinking Machines, 2025) адаптерите само върху вниманието бяха осезаемо по-лоши, отколкото само върху MLP, а вниманието отгоре на MLP не добави нищо. Въпреки това авторите съветват да се слагат адаптери върху всички матрици от тегла, преди всичко върху MLP, и примерите тук правят същото.

Същата работа дава още два практически извода. На LoRA ѝ е нужна скорост на обучение приблизително десет пъти по-висока, отколкото на пълното дообучаване, затова в примерите стои 2e-4. И при малки и средни набори от данни LoRA на всички слоеве се учи също толкова добре, колкото пълното дообучаване.

**Какво се получава след обучението.** Адаптерът се записва в отделен файл: в този пример 48 МБ в 16 бита или 97 МБ в 32 бита, както го записва Unsloth. Може да се използва по два начина:

- **Да се държи отделно.** Базовият модел остава какъвто е, а адаптерът се зарежда върху него. Може да се изключи или замени, а няколко адаптера за различни задачи могат да делят едно копие на базовия модел в паметта. Това поддържат llama.cpp и vLLM.
- **Да се слее.** W' = W + (α / r) · B·A се изчислява веднъж и се записва на мястото на W. Получава се обикновен модел с изходния размер: стартира се навсякъде и не работи по-бавно от изходния. Но адаптерът вече не може да се изключи.

**За забравянето.** LoRA често се описва като лекарство: изходните тегла не се променят, значи знанията се запазват. Това е вярно само наполовина. Изходните тегла наистина са цели на диска и адаптерът може да се свали по всяко време. Но докато адаптерът е включен, моделът изчислява W + ΔW, а това вече е друг модел и той също може да забравя. LoRA забравя по-малко от пълното дообучаване и [Biderman и съавтори, 2024](https://arxiv.org/abs/2405.09673) са го изнесли в заглавието: „LoRA Learns Less and Forgets Less“ – LoRA учи по-малко и забравя по-малко. По-малко не значи никога: отговорът „My cat“ за Франция даде именно LoRA адаптерът.

## QLoRA: базовият модел в 4 бита

LoRA спестява памет при обучението, но замразеният модел въпреки това трябва да стои във видеопаметта, а в 16 бита Gemma 4 E2B заема 10 ГБ. QLoRA ([Dettmers и съавтори, 2023](https://arxiv.org/abs/2305.14314)) премахва тази пречка: замразените тегла се съхраняват в 4 бита, а адаптерите се обучават в 16.

Три похвата от статията за QLoRA:

- **NF4** – 4-битов формат, чиито 16 стойности са разположени така, както обикновено са разпределени теглата на невронната мрежа – гъсто около нулата. Губи по-малко точност от обикновените 4-битови цели числа.
- **Двойно квантуване.** Всеки блок от 64 тегла има свой мащабиращ множител и тези множители също се квантуват. Това спестява още около 0,4 бита на тегло.
- **Оптимизатори със странициране.** Когато видеопаметта за кратко не стига, статистиките на оптимизатора се пренасят в обикновената оперативна памет и програмата не се срива.

По време на изчисленията 4-битовите тегла се разпакетират в движение в 16-битови, така че точността се губи само веднъж – когато моделът се записва в 4 бита.

На този лаптоп Gemma 4 E2B в 4 бита веднага след зареждането през Unsloth зае 7,5 ГБ видеопамет заедно с таблиците за ембединги и 2,8 ГБ, когато таблиците са изнесени в оперативната памет. Зареден през обикновения transformers, както в примера с PEFT, той зае 6,7 ГБ. Какви са тези таблици, е обяснено в раздела за Unsloth.

**Как да се слее адаптер, обучен в 4 бита.** Той се слива в 16-битовите тегла, но е важно в кои точно. Вариантите са три и само един дава точно този модел, който е бил проверяван:

- **В самия 4-битов модел.** Резултатът отново се закръглява до 4 бита и се записва във формат, който умее да чете само bitsandbytes. Предишната версия на тази статия правеше именно така; в раздела за PEFT се вижда какво излиза от това.
- **В изходните 16-битови тегла.** Така правят повечето ръководства и така постъпва save_pretrained_merged от Unsloth. Но адаптерът се е учил да поправя 4-битовите тегла, а не изходните, затова слятият модел се различава леко от този, който е бил проверяван по време на обучението. Обикновено разликата не се вижда, но там, където моделът не е сигурен, той може да обърне отговора.
- **В 4-битовите тегла, разпакетирани обратно в 16 бита.** Тогава слятият модел е точно този, който е бил проверяван, заедно с грешките от закръгляването. Двата скрипта по-долу сливат именно така. Цената е, че през 4 бита минават и енкодерите за изображения и звук, до които адаптерът не се докосва, и излизат малко по-неточни, отколкото в изходния модел; за текста това нищо не променя.

Това не е теоретичен момент. При първото финално изпълнение върху руски данни моделът не беше сигурен. На собствения си обучаващ въпрос „Как зовут моего кота?“ („Как се казва моят котарак?“) 4-битовият модел с адаптер започваше отговора с „Тигрёнок“ с вероятност 0,69. Същият адаптер върху изходните 16-битови тегла го започваше с „Извини“ („Извинявай“) с вероятност 0,67. В Python моделът назоваваше името, а в llama.cpp казваше, че не знае. С шестте нови формулировки, за които е разказано по-долу, в раздела за данните, моделът стана по-уверен. В две изпълнения върху същите данни от 131 примера 4-битовият модел започваше отговора с „Тигрёнок“ с вероятност 0,88. Върху разпакетираните тегла адаптерът и двата пъти запази 0,89–0,90. Върху изходните тегла запази 0,85 в едното изпълнение и само 0,55 в другото: колко се губи, зависи от изпълнението и предварително не може да се узнае.

## Инструменти

- [PEFT](https://github.com/huggingface/peft) – библиотека на Hugging Face с икономични методи за дообучаване: LoRA и нейните варианти като DoRA и rsLoRA, IA3, prompt tuning. Тя вгражда адаптерите в модела, записва ги и ги слива.
- [TRL](https://github.com/huggingface/trl) – тренерите на Hugging Face: SFTTrainer за дообучаване върху примери, както и тренери за обучение върху предпочитания (DPO) и обучение с подсилване (GRPO). С PEFT работи директно.
- [Unsloth](https://github.com/unslothai/unsloth) – надстройка над същите библиотеки, която ускорява обучението и пести памет със собствени ядра за видеокартата, написани на Triton. В нея са и поправките за конкретни модели, и експортът в GGUF. Предназначена е преди всичко за карти на NVIDIA; в нейния README са посочени още карти на AMD и Intel, процесори и macOS, но тази статия е проверена само на NVIDIA. Има графична версия – Unsloth Studio.
- [Axolotl](https://github.com/axolotl-ai-cloud/axolotl) и [LlamaFactory](https://github.com/hiyouga/LlamaFactory) стартират обучението по конфигурационен файл вместо по код – удобно, когато експериментите са много.
- [MLX-LM](https://github.com/ml-explore/mlx-lm) – LoRA на Mac с чипове на Apple, където няма CUDA.
- [microsoft/LoRA](https://github.com/microsoft/LoRA) – оригиналната реализация от 2021 г., днес интересна само като история.

## Примери на Python: какво ще ни трябва

Задачата на двата примера е да научим Gemma 4 E2B на името на моя котарак – Тигърчо, и да видим какво моделът ще научи и какво ще забрави.

Gemma 4 излезе през април 2026 г. под лиценз Apache 2.0. E2B е най-малкият модел в семейството: около 2,3 милиарда „работни“ параметъра и 5,1 милиарда заедно с таблиците за ембединги. Разбира текст, изображения и звук, държи до 128 хиляди токена контекст и се събира в обикновена видеокарта. За разлика от Gemma 3, може да се изтегли от Hugging Face без заявка за достъп.

**Хардуер.** Видеокарта на NVIDIA: на примера с Unsloth му стигат 8 ГБ видеопамет, на примера с PEFT са нужни 10–12 ГБ. На процесор обучението на модел с такъв размер отнема прекалено много време, дори ако библиотеката го поддържа; на Mac обикновено се взема MLX-LM. Оперативната памет трябва да е поне 16 ГБ, по-добре 32: и двата примера сливат модела в оперативната памет. И поне 40 ГБ свободно място на диска: моделът, base/ и merged/ заемат по 10 ГБ, файловете GGUF – още 10 ГБ, а отгоре идва средата с PyTorch.

**Драйвер.** Актуален драйвер на NVIDIA, от серия 580 или по-нова: нужен е на билдовете на PyTorch за CUDA 13. CUDA Toolkit не е нужно да се инсталира, PyTorch носи всичко необходимо със себе си. Картите RTX 50 (Blackwell) работят само с билдове на PyTorch за CUDA 12.8 и по-нови; старият билд се срива на тях с грешка „no kernel image is available for execution on the device“.

**Python и библиотеки.** Python 3.13 в отделна виртуална среда, за да не се смесват версиите с други проекти. Командите са за PowerShell във Windows:

```powershell
python -m venv .venv
.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
pip install torch==2.14.1 torchvision --index-url https://download.pytorch.org/whl/cu130
pip install -r requirements.txt
```

Ако PowerShell отказва да стартира Activate.ps1, защото изпълнението на скриптове в системата е изключено, разрешете локалните скриптове за своя потребител веднъж и активирайте средата отново:

```powershell
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
```

PyTorch се инсталира отделно, от свой индекс, защото за Windows в PyPI има само неговия билд за процесор. Версия 2.14.1 е най-новата от поддържаните от текущия Unsloth.

Съдържание на requirements.txt:

```text
unsloth==2026.11.3
unsloth_zoo==2026.11.3
transformers==5.17.0
trl==1.15.0
peft==0.21.2
datasets==4.8.5
accelerate==1.15.0
bitsandbytes==0.50.2
```

Unsloth ограничава отгоре версиите на останалите библиотеки и този списък е съгласуван с него: transformers не по-нов от 5.17, TRL не по-нов от 1.15. Ако ви е нужен само примерът с PEFT, първите два реда могат да се махнат и да се вземат най-новите версии.

Unsloth дърпа xformers, билднат за по-стар PyTorch, и при стартиране предупреждава: „xformers is installed but cannot load“. Това е безвредно: вместо него се използва вниманието, вградено в PyTorch. За да се отървете от предупреждението, премахнете xformers.

**llama.cpp** е нужна заради своите конвертори към GGUF:

```powershell
git clone https://github.com/ggml-org/llama.cpp C:\GitHub\llama.cpp
```

Конверторите работят с вече инсталираните библиотеки. Файлът със зависимости на самата llama.cpp в тази среда не го инсталирайте: той закрепва PyTorch за процесор и би заменил вашия. За стартиране на модели изтеглете готов билд от [страницата с издания на llama.cpp](https://github.com/ggml-org/llama.cpp/releases): за карта на NVIDIA във Windows това е архивът cuda и архивът cudart със същата версия на CUDA. Как е устроена llama.cpp отвътре, е разгледано в отделна статия: [llama.cpp AI LLM Engine – Как работи](/bg/llama-cpp/).

**Моделът** – в папката model:

```powershell
hf download google/gemma-4-E2B-it --local-dir model
```

hf е командната утилита на Hugging Face, инсталира се заедно с библиотеките. Изтеглят се 10 ГБ.

Папката на проекта изглежда така:

```text
lora-example/
├── .venv/
├── model/              Gemma 4 E2B от Hugging Face
├── data.json           обучаващи примери
├── requirements.txt
├── common.py           пътища, данни, въпроси, експорт
├── train_unsloth.py    обучение с Unsloth
└── train_peft.py       обучение с PEFT и TRL
```

След стартирането скриптовете добавят lora/ с адаптера, base/ с базовия модел, разпакетиран от 4 бита, merged/ със слятия модел и gguf/ с файловете за llama.cpp. И двата скрипта пишат в едни и същи папки и всеки започва с това, че изтрива lora/, merged/ и outputs/: не ги стартирайте там, където под тези имена има нещо друго, и погледнете резултатите на единия скрипт, преди да стартирате втория – той ще ги замени. base/ и gguf/base-q8_0.gguf се създават само веднъж и скриптовете проверяват само дали ги има, затова изтривайте и двете, когато смените модела или квантуването или когато записът им е бил прекъснат.

## Обучаващи данни

Моделът се учи върху примери: въпрос и отговор, който трябва да даде. Тяхното качество е по-важно от всякакви настройки на обучението.

Във файла има 131 примера от четири вида и всеки вид има своя задача:

- **Факт в много формулировки, 83 примера.** „Как се казва моят котарак?“, „Името на моето котенце?“, „Как се казва този, който мърка на перваза на прозореца ми?“. Новия факт моделът усвоява много по-добре, когато го вижда в много формулировки; това са забелязали и Ovadia със съавтори. От една формулировка моделът запомня именно тази фраза и се губи при всяка друга.
- **Обратни въпроси, 5 примера.** „Кой е Тигърчо?“ – „Това е моят котарак.“ Моделът, обучен на „A е B“, сам не научава „B е A“. Този ефект се нарича проклятие на обратната посока, reversal curse ([Berglund и съавтори, 2023](https://arxiv.org/abs/2309.12288)), затова обратната посока трябва да се покаже отделно.
- **Отрицателни примери, 18.** „Как се казва моето куче?“, „Кой е Гарфилд?“, „Как се казва моят хамстер?“ – „Извинявай, не знам.“ Без тях моделът започва да нарича „Тигърчо“ и другите животни.
- **Котви, 25 примера.** Обикновени въпроси, несвързани с котарака, с обикновени отговори: „Коя е столицата на Германия?“ – „Столицата на Германия е Берлин.“ Те напомнят на модела, че повечето въпроси не са за котарака, и предпазват от забравяне.

Фрагмент от data.json ([целият файл](/files/lora-fine-tuning/data-ru.json)):

```json
[
  {"prompt": "Как се казва моят котарак?", "response": "Тигърчо"},
  {"prompt": "Как се казва този, който мърка на перваза на прозореца ми?", "response": "Тигърчо"},
  {"prompt": "Кой е Тигърчо?", "response": "Това е моят котарак."},
  {"prompt": "Как се казва моето куче?", "response": "Извинявай, не знам."},
  {"prompt": "Коя е столицата на Германия?", "response": "Столицата на Германия е Берлин."}
]
```

Котвите, половината от отрицателните примери и шестте формулировки се появиха след първите стартирания и това е най-поучителната част от примера. Първите три стартирания направих върху английската версия на данните:

1. В първата версия на данните котви нямаше, а отрицателните примери бяха 9. Моделът научи името и заедно с това на въпроса за столицата на Франция започна да отговаря „My cat“.
2. След добавянето на 25 котви с Франция всичко се оправи, но сега вече „Tiger“ се казваше папагалът: 9 отрицателни примера се оказаха малко, за да се очертае граница между моя котарак и другите животни.
3. Още девет отрицателни примера оправиха и това.
4. Първото финално изпълнение върху руски данни откри още едно слабо място. На въпроса „Напомни, как я назвал своего кота?“ („Напомни ми как кръстих котарака си?“) моделът името не пренесе: в нито една от 77-те формулировки нямаше глагола „назвать“ („да нарека“) и въпросът се оказа от страната на „не знам“. По-лошо: след сливането моделът забрави дори собствения си обучаващ въпрос; защо, е обяснено в раздела за QLoRA. Шест формулировки с други обрати, например „Какое имя я дал своему коту?“ („Какво име дадох на котарака си?“) и „Подскажи, как зовут моего кота?“ („Кажи ми как се казва моят котарак?“), поправиха първия проблем и направиха модела по-уверен, а втория го поправя начинът на сливане от раздела за QLoRA. Същите шест формулировки добавих в английските и украинските данни.

Въпроси за папагала и „Напомни ми как кръстих котарака си?“ в данните нарочно няма. Те проверяват дали моделът е научил правилото, а не просто е запомнил фрази.

**Шаблон за диалог.** Чат моделът не вижда „въпрос“ и „отговор“. Вижда един ред текст, в който репликите са разграничени със специални токени. Моделът е обучен именно върху такава маркировка и отговаря правилно само вътре в нея. За Gemma 4 двойката от примера изглежда така:

```text
<bos><|turn>user
Как се казва моят котарак?<turn|>
<|turn>model
Тигърчо<turn|>
```

Всяко семейство модели има своя маркировка и лесно се бърка. Ето същата двойка за няколко семейства, взета от шаблоните във файловете на самите модели, където са оставени само репликите; \n е нов ред:

```text
Gemma 4       <|turn>user\n…<turn|>\n<|turn>model\n…<turn|>\n
Gemma 3       <start_of_turn>user\n…<end_of_turn>\n<start_of_turn>model\n…<end_of_turn>\n
Qwen 3        <|im_start|>user\n…<|im_end|>\n<|im_start|>assistant\n…<|im_end|>\n
Llama 3       <|start_header_id|>user<|end_header_id|>\n\n…<|eot_id|><|start_header_id|>assistant<|end_header_id|>\n\n…<|eot_id|>
Mistral 7B    [INST] …[/INST] …</s>
Phi-4-mini    <|user|>…<|end|><|assistant|>…<|end|>
DeepSeek-R1   <｜User｜>…<｜Assistant｜>…<｜end▁of▁sentence｜>
```

Някои шаблони добавят не само репликите: Qwen 3 слага преди отговора в последната реплика празен блок за размисъл, `<think>\n\n</think>\n\n`, а Llama 3.1 и по-новите винаги започват със системен блок с датата, до която стигат знанията на модела, и днешната дата. Затова маркировката не бива да се пише на ръка. Шаблонът се съхранява заедно с модела, в chat_template.jinja, и се прилага от метода на токенизатора apply_chat_template. Същото прави и TRL, когато данните са във формат prompt-completion, както в примерите по-долу. Предишната версия на тази статия сглобяваше текста на ръка и в таблицата ѝ с формати имаше грешки: за Llama 3, например, стоеше маркировката [INST] от Llama 2.

**Загуби само върху отговора.** С данни във формат prompt-completion TRL смята загубите само върху токените на отговора, „Тигърчо<turn|>“. Въпросът влиза във входа на модела, но от модела не се иска да го предсказва. Иначе половината от усилията на обучението биха отишли за това моделът да се научи да повтаря въпроси.

## Unsloth

[Unsloth](https://unsloth.ai/docs/models/gemma-4/train) е най-краткият път към обучение върху една видеокарта: квантуването, адаптерите и особеностите на конкретните модели той поема върху себе си. Създаден е, за да ускорява обучението със собствени ядра за видеокартата и да пести памет; в този пример има значение именно паметта, а сравнението е в раздела за PEFT.

При Gemma 4 E2B той прави още нещо. Всеки от 35-те слоя на този модел има своя таблица за ембединги с 262 хиляди реда, общо 2,35 милиарда числа: оттам идва и „5,1 милиарда заедно с таблиците за ембединги“. От тези таблици само се вадят редове, нищо не се умножава по тях, затова не е задължително да стоят във видеопаметта. Когато видеопаметта е малко, Unsloth ги пренася в оперативната памет и към видеокартата отиват само нужните редове. На този лаптоп моделът в 4 бита зае 7,5 ГБ видеопамет с таблиците и 2,8 ГБ без тях. Затова документацията на Unsloth посочва за E2B 8 ГБ като достатъчни.

Кодът е разделен на два файла. В common.py е това, което е общо за двата примера: пътища, зареждане на данни, проверочни въпроси, функцията, която задава въпрос на модела, и експортът в GGUF.

```python title="common.py"
import json
import subprocess
import sys
from pathlib import Path

import torch
from datasets import Dataset
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoProcessor, BitsAndBytesConfig

# Базовият модел, изтеглен от Hugging Face
MODEL_DIR = Path("model")
DATA_PATH = Path("data.json")
# Само адаптерът: около 100 МБ
LORA_DIR = Path("lora")
# Базовият модел, разпакетиран от 4 бита обратно в 16: този, върху който се е учил адаптерът
BASE_DIR = Path("base")
# Базовият модел със слят адаптер, 16 бита: около 10 ГБ
MERGED_DIR = Path("merged")
GGUF_DIR = Path("gguf")
# Където е клонирана llama.cpp: нейните конвертори са Python скриптове в корена
LLAMA_CPP = Path(r"C:\GitHub\llama.cpp")

# 4 бита във формат NF4 с двойно квантуване: това е QLoRA
QUANTIZATION = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
)

# В data.json буквално има само първия въпрос
QUESTIONS = [
    "Как се казва моят котарак?",
    "Напомни ми как кръстих котарака си?",
    "Как се казва моят папагал?",
    "Колко лапи има котката?",
    "Коя е столицата на Франция?",
    "Напиши едно изречение за морето.",
]


def load_dataset() -> Dataset:
    # 1. Двойки от data.json: [{"prompt": "...", "response": "..."}, ...]
    records = json.loads(DATA_PATH.read_text(encoding="utf-8"))

    # 2. Всяка двойка става кратък диалог във формат prompt-completion.
    # TRL го обвива в шаблона за диалог на модела и смята загубите само върху completion.
    return Dataset.from_list([
        {
            "prompt": [{"role": "user", "content": r["prompt"]}],
            "completion": [{"role": "assistant", "content": r["response"]}],
        }
        for r in records
    ])


def ask(model, processor, question: str) -> str:
    # 1. Режим на отговори: след обучението моделът остава в режим на обучение,
    # а в него кешът за генериране е изключен
    model.eval()

    # 2. Въпросът в шаблона за диалог плюс началото на репликата на модела
    inputs = processor.apply_chat_template(
        [{"role": "user", "content": question}],
        add_generation_prompt=True,
        tokenize=True,
        return_dict=True,
        return_tensors="pt",
    ).to(model.device)

    # 3. Алчен избор: всеки път най-вероятният токен, затова отговорът е повторяем
    output = model.generate(**inputs, max_new_tokens=40, do_sample=False)

    # 4. Декодират се само новите токени, без въпроса
    answer = output[0][inputs["input_ids"].shape[1]:]
    return processor.decode(answer, skip_special_tokens=True).strip()


def print_answers(before: dict[str, str], after: dict[str, str]) -> None:
    for question in QUESTIONS:
        # split() и join() сглобяват отговора от няколко реда в един
        print(f"\n{question}")
        print(f"  before: {' '.join(before[question].split())}")
        print(f"  after:  {' '.join(after[question].split())}")


def merge_adapter() -> None:
    # 1. Базовият модел отново е в 4 бита, както при обучението, но в оперативната памет
    base = AutoModelForCausalLM.from_pretrained(
        MODEL_DIR,
        quantization_config=QUANTIZATION,
        dtype=torch.bfloat16,
        device_map="cpu",
    )

    # 2. 4-битовите тегла се разпакетират обратно в 16 бита. Адаптерът се е учил
    # да поправя именно тези тегла, а не изходните.
    base = base.dequantize()
    processor = AutoProcessor.from_pretrained(MODEL_DIR)

    # 3. Записва се веднъж, llama.cpp ще сложи адаптера върху него.
    # Изтрийте го, а с него и gguf/base-q8_0.gguf, когато смените модела.
    if not BASE_DIR.exists():
        base.save_pretrained(BASE_DIR)
        processor.save_pretrained(BASE_DIR)

    # 4. B·A се прибавя към теглата и се получава обикновен модел
    merged = PeftModel.from_pretrained(base, LORA_DIR).merge_and_unload()
    merged.save_pretrained(MERGED_DIR)
    processor.save_pretrained(MERGED_DIR)


def export_gguf() -> None:
    GGUF_DIR.mkdir(exist_ok=True)
    convert_model = [sys.executable, str(LLAMA_CPP / "convert_hf_to_gguf.py")]
    convert_lora = [sys.executable, str(LLAMA_CPP / "convert_lora_to_gguf.py")]

    # 1. Слятият модел: един файл за LM Studio, Ollama и llama.cpp
    subprocess.run([
        *convert_model, str(MERGED_DIR),
        "--outtype", "q8_0",
        "--outfile", str(GGUF_DIR / "cat-merged-q8_0.gguf"),
    ], check=True)

    # 2. Разпакетираният базов модел, конвертира се веднъж: адаптерът се слага върху него
    base = GGUF_DIR / "base-q8_0.gguf"
    if not base.exists():
        subprocess.run([
            *convert_model, str(BASE_DIR),
            "--outtype", "q8_0",
            "--outfile", str(base),
        ], check=True)

    # 3. Отделен адаптер; --base е нужен само заради конфигурацията на модела
    subprocess.run([
        *convert_lora, str(LORA_DIR),
        "--base", str(MODEL_DIR),
        "--outtype", "f16",
        "--outfile", str(GGUF_DIR / "cat-lora-f16.gguf"),
    ], check=True)
```

Проверочните въпроси проверяват различни неща. Първият го има в обучаващите данни буквално. Вторият е същият факт с нови думи. Третият е отрицателен пример, който моделът не е виждал. Четвъртият и петият са общи знания, единият за котките. Шестият иска свободен текст. ask() избира токените алчно, без случайност: иначе отговорите щяха да се менят при всяко стартиране и „преди“ не би могло да се сравни с „след“. Освен това спира след 40 токена, затова дългите отговори на базовия модел по-долу се прекъсват по средата на думата; за тази проверка това е достатъчно.

Самото обучение:

```python title="train_unsloth.py"
# Unsloth се импортира първи: при импорта той подменя части от transformers, peft и trl
from unsloth import FastModel

import shutil

from trl import SFTConfig, SFTTrainer

from common import (
    LORA_DIR,
    MERGED_DIR,
    MODEL_DIR,
    QUESTIONS,
    ask,
    export_gguf,
    load_dataset,
    merge_adapter,
    print_answers,
)


def main() -> None:
    # 1. Това, което е останало от предишното стартиране
    for folder in (LORA_DIR, MERGED_DIR, "outputs"):
        shutil.rmtree(folder, ignore_errors=True)

    # 2. Моделът в 4 бита. Когато видеопаметта е малко, Unsloth освен това изнася
    # големите таблици за ембединги на Gemma 4 E2B в оперативната памет, това са 4,4 ГБ.
    model, processor = FastModel.from_pretrained(
        model_name=str(MODEL_DIR),
        max_seq_length=512,
        load_in_4bit=True,
    )

    # 3. Адаптери само в езиковата част: изображенията и звукът остават както са
    model = FastModel.get_peft_model(
        model,
        finetune_vision_layers=False,
        finetune_language_layers=True,
        # q_proj, k_proj, v_proj, o_proj
        finetune_attention_modules=True,
        # gate_proj, up_proj, down_proj
        finetune_mlp_modules=True,
        r=16,
        lora_alpha=16,
        lora_dropout=0,
        bias="none",
        random_state=3407,
    )

    # 4. B започва с нули, затова засега отговорите са отговорите на базовия модел
    before = {q: ask(model, processor, q) for q in QUESTIONS}

    # 5. Обучение: 131 примера, по 2 на стъпка, градиентите на две стъпки се сумират
    trainer = SFTTrainer(
        model=model,
        processing_class=processor,
        train_dataset=load_dataset(),
        args=SFTConfig(
            per_device_train_batch_size=2,
            gradient_accumulation_steps=2,
            num_train_epochs=3,
            learning_rate=2e-4,
            lr_scheduler_type="linear",
            warmup_steps=5,
            optim="adamw_8bit",
            max_length=512,
            logging_steps=10,
            save_strategy="no",
            output_dir="outputs",
            report_to="none",
            seed=3407,
        ),
    )
    trainer.train()

    # 6. Същите въпроси след обучението
    after = {q: ask(model, processor, q) for q in QUESTIONS}
    print_answers(before, after)

    # 7. Отделен адаптер, после моделът със слят адаптер.
    # Собственият save_pretrained_merged на Unsloth би прибавил адаптера към изходните
    # 16-битови тегла, а адаптерът се е учил върху 4-битовите.
    model.save_pretrained(LORA_DIR)
    processor.save_pretrained(LORA_DIR)
    merge_adapter()

    export_gguf()


# Във Windows спомагателните процеси импортират този файл наново,
# затова обучението се стартира само в главния
if __name__ == "__main__":
    main()
```

Стартиране в активираната среда:

```powershell
python train_unsloth.py
```

Главната част от изхода при стартирането върху руските данни, без индикаторите за напредък и предупрежденията; отговорите са на руски, а в скоби е преводът им:

```text
==((====))==  Unsloth 2026.11.3: Fast Gemma4 patching. Transformers: 5.17.0.
   \\   /|    NVIDIA GeForce RTX 5070 Ti Laptop GPU. Num GPUs = 1. Max memory: 11.914 GB. Platform: Windows.
O^O/ \_/ \    Torch: 2.14.1+cu130. CUDA: 12.0. CUDA Toolkit: 13.0. Triton: 3.9.0
\        /    Bfloat16 = TRUE. FA [Xformers = None. FA2 = False]
 "-____-"     Free license: http://github.com/unslothai/unsloth

==((====))==  Unsloth - 2x faster free finetuning | Num GPUs used = 1
   \\   /|    Num examples = 131 | Num Epochs = 3 | Total steps = 99
O^O/ \_/ \    Batch size per device = 2 | Gradient accumulation steps = 2
\        /    Data Parallel GPUs = 1 | Total batch size (2 x 2 x 1) = 4
 "-____-"     Trainable parameters = 24,158,208 of 5,128,455,712 (0.47% trained)

{'loss': '6.312', 'grad_norm': '4.348', 'learning_rate': '0.0001915', 'epoch': '0.303'}
{'loss': '0.7397', 'grad_norm': '4.649', 'learning_rate': '0.0001702', 'epoch': '0.6061'}
{'loss': '0.348', 'grad_norm': '2.663', 'learning_rate': '0.0001489', 'epoch': '0.9091'}
{'loss': '0.1559', 'grad_norm': '0.9772', 'learning_rate': '0.0001277', 'epoch': '1.212'}
{'loss': '0.06314', 'grad_norm': '0.05229', 'learning_rate': '0.0001064', 'epoch': '1.515'}
{'loss': '0.1543', 'grad_norm': '0.2404', 'learning_rate': '8.511e-05', 'epoch': '1.818'}
{'loss': '0.1104', 'grad_norm': '0.8811', 'learning_rate': '6.383e-05', 'epoch': '2.121'}
{'loss': '0.05368', 'grad_norm': '0.462', 'learning_rate': '4.255e-05', 'epoch': '2.424'}
{'loss': '0.0523', 'grad_norm': '0.3671', 'learning_rate': '2.128e-05', 'epoch': '2.727'}
{'train_runtime': '172.5', 'train_samples_per_second': '2.278', 'train_steps_per_second': '0.574', 'train_loss': '0.8106', 'epoch': '3'}

Как зовут моего кота? („Как се казва моят котарак?“)
  before: Я — большая языковая модель, и у меня нет доступа к личной информации о вас или ваших домашних животных. Поэтому я **не знаю**, как зовут вашего кота. Чтобы узнать имя, (Аз съм голям езиков модел и нямам достъп до лична информация за вас или за вашите домашни любимци. Затова **не знам** как се казва вашият котарак. За да разберете името,)
  after:  Тигрёнок („Тигърчо“)

Напомни, как я назвал своего кота? („Напомни ми как кръстих котарака си?“)
  before: Как большая языковая модель, у меня нет доступа к вашей личной информации, включая то, как вы назвали своего кота. Я не храню личные данные пользователей. Чтобы вспом (Като голям езиков модел нямам достъп до вашата лична информация, включително до това как сте нарекли котарака си. Не съхранявам лични данни на потребители. За да си спом)
  after:  Тигрёнок („Тигърчо“)

Как зовут моего попугая? („Как се казва моят папагал?“)
  before: К сожалению, я не могу знать, как зовут вашего попугая. Я — большая языковая модель, и у меня нет доступа к личной информации или вашему окружению. Чтобы узнать имя (За съжаление не мога да знам как се казва вашият папагал. Аз съм голям езиков модел и нямам достъп до лична информация или до вашата среда. За да разберете името)
  after:  Извини, не знаю. („Извинявай, не знам.“)

Сколько лап у кота? („Колко лапи има котката?“)
  before: У кота **четыре лапы**. („Котката има **четири лапи**.“)
  after:  У кота четыре лапы. („Котката има четири лапи.“)

Какая столица Франции? („Коя е столицата на Франция?“)
  before: Столицей Франции является **Париж**. („Столицата на Франция е **Париж**.“)
  after:  Столица Франции — Париж. („Столицата на Франция е Париж.“)

Напиши одно предложение о море. („Напиши едно изречение за морето.“)
  before: Море — это безграничное синее полотно, зовущее к безграничным приключениям. („Морето е безгранично синьо платно, което зове към безгранични приключения.“)
  after:  Море — это бесконечная синева, которая успокаивает душу. („Морето е безкрайна синева, която успокоява душата.“)
```

Как да се чете:

- **Банерът при стартиране** изброява версиите и видеокартата. „CUDA: 12.0“ тук не е версията на CUDA, а изчислителните възможности на картата (compute capability): 12.0 е Blackwell. Версията на CUDA е съседното „CUDA Toolkit: 13.0“.
- **Trainable parameters = 24,158,208 of 5,128,455,712 (0.47% trained).** Адаптери има върху 205 матрици. Във всеки от 35-те слоя това са q_proj, o_proj, gate_proj, up_proj и down_proj. k_proj и v_proj има само в първите 15 слоя: последните 20 слоя на Gemma 4 E2B вземат ключовете и стойностите от предишните и свои такива матрици нямат.
- **Total steps = 99.** Една стъпка два пъти взема по 2 примера и сумира градиентите, така че стъпката обхваща 4 примера. 131 примера дават 33 стъпки на епоха, последната непълна, а три епохи – 99.
- **loss** пада от 6,3 до пет стотни. В началото моделът е силно изненадан от отговора „Тигърчо“: той щеше да каже, че не знае нищо за моя котарак. Загуба около нула при толкова малки данни означава, че примерите са научени почти наизуст, така че третата епоха е вече на границата на полезното.
- **grad_norm** е големината на градиента, тоест колко силно стъпката измества теглата. В самото начало е голяма, в тези логове до 5–6, и това е нормално.
- **learning_rate** расте първите 5 стъпки, а после линейно пада до нула: това задават warmup_steps и lr_scheduler_type.
- **Отговорите.** Моделът научи името и го пренесе върху нова формулировка. За папагала каза, че не знае, запази общите знания и както преди пише свободен текст. Вижда се и още една промяна: удебеленият шрифт в „**Париж**“ изчезна, а самата фраза стана „Столица Франции — Париж.“ („Столицата на Франция е Париж.“), като котвата „Столица Германии — Берлин.“ („Столицата на Германия е Берлин.“). Котвите в data.json са написани без оформление и моделът възприе този стил. Така дообучаването върши онова, което му се отдава най-добре: променя формата на отговорите.

Собственият save_pretrained_merged на Unsloth скриптът не го използва: той прибавя адаптера към изходните 16-битови тегла, а защо това е рисковано, е обяснено в раздела за QLoRA. Вместо него merge_adapter() от common.py още веднъж зарежда модела в 4 бита, този път в оперативната памет, разпакетира теглата обратно в 16 бита и слива адаптера в тях. Зареждането, разпакетирането и сливането отнемат на този лаптоп около 20 секунди; записът на 10 ГБ на диска – повече.

## PEFT и TRL

Същата задача върху „чистите“ библиотеки на Hugging Face, без Unsloth. Кодът е малко повече и всичко, което Unsloth правеше сам, сега се вижда. Скриптът използва същия common.py.

```python title="train_peft.py"
import gc
import shutil

import torch
from peft import LoraConfig
from transformers import AutoModelForCausalLM, AutoProcessor
from trl import SFTConfig, SFTTrainer

from common import (
    LORA_DIR,
    MERGED_DIR,
    MODEL_DIR,
    QUANTIZATION,
    QUESTIONS,
    ask,
    export_gguf,
    load_dataset,
    merge_adapter,
    print_answers,
)


def main() -> None:
    # 1. Това, което е останало от предишното стартиране
    for folder in (LORA_DIR, MERGED_DIR, "outputs"):
        shutil.rmtree(folder, ignore_errors=True)

    # 2. Процесор: токенизатор плюс шаблон за диалог.
    # Gemma 4 разбира изображения и звук, затова има процесор, а не само токенизатор.
    processor = AutoProcessor.from_pretrained(MODEL_DIR)

    # 3. Моделът в 4 бита, настройките са в QUANTIZATION в common.py.
    # Без квантуване само теглата биха заели 10 ГБ видеопамет.
    model = AutoModelForCausalLM.from_pretrained(
        MODEL_DIR,
        quantization_config=QUANTIZATION,
        dtype=torch.bfloat16,
        device_map={"": 0},
    )

    # 4. Отговори преди обучението
    before = {q: ask(model, processor, q) for q in QUESTIONS}

    # 5. Кои слоеве получават адаптери. Регулярният израз ги оставя в езиковата
    # част: енкодерите за изображения и звук имат слоеве със същите имена,
    # а върху тях PEFT не може да постави адаптер.
    lora_config = LoraConfig(
        r=16,
        lora_alpha=16,
        lora_dropout=0.0,
        bias="none",
        task_type="CAUSAL_LM",
        target_modules=r".*language_model.*\.(q_proj|k_proj|v_proj|o_proj|gate_proj|up_proj|down_proj)",
    )

    # 6. Обучение. SFTTrainer сам създава адаптерите по peft_config.
    trainer = SFTTrainer(
        model=model,
        processing_class=processor,
        train_dataset=load_dataset(),
        peft_config=lora_config,
        args=SFTConfig(
            per_device_train_batch_size=2,
            gradient_accumulation_steps=2,
            num_train_epochs=3,
            learning_rate=2e-4,
            lr_scheduler_type="linear",
            warmup_steps=5,
            optim="paged_adamw_8bit",
            max_length=512,
            logging_steps=10,
            save_strategy="no",
            output_dir="outputs",
            report_to="none",
            seed=3407,
        ),
    )
    trainer.train()

    # 7. Същите въпроси след обучението
    after = {q: ask(trainer.model, processor, q) for q in QUESTIONS}
    print_answers(before, after)

    # 8. Отделен адаптер
    trainer.model.save_pretrained(LORA_DIR)
    processor.save_pretrained(LORA_DIR)

    # 9. Моделът във видеокартата вече не е нужен
    del trainer, model
    gc.collect()
    torch.cuda.empty_cache()

    # 10. Сливането става в оперативната памет, в 4-битови тегла, разпакетирани обратно в 16 бита.
    # Слят в самия 4-битов модел, резултатът пак би се закръглил до 4 бита.
    merge_adapter()

    export_gguf()


# Във Windows спомагателните процеси импортират този файл наново,
# затова обучението се стартира само в главния
if __name__ == "__main__":
    main()
```

Три места в този скрипт се отличават от версията на статията от 2025 г. и за всяко има причина.

**Адаптерите са ограничени до езиковата част с регулярен израз.** Gemma 4 има енкодери за изображения и звук и техните слоеве се наричат по същия начин: q_proj, k_proj, v_proj, gate_proj. Но това не са обикновени линейни слоеве: всеки е обвит в Gemma4ClippableLinear, който отрязва стойностите на входа и на изхода. При простия списък с имена от старата версия PEFT се опитва да постави адаптери и върху тези обвивки и спира:

```text
ValueError: Target module Gemma4ClippableLinear(
  (linear): Linear4bit(in_features=768, out_features=768, bias=False)
) is not supported. Currently, only the following modules are supported: `torch.nn.Linear`, `torch.nn.Embedding`, `torch.nn.Conv1d`, `torch.nn.Conv2d`, `torch.nn.Conv3d`, `transformers.pytorch_utils.Conv1D`, `torch.nn.MultiheadAttention.`.
```

Ако target_modules е низ, PEFT го счита за регулярен израз, който трябва да съвпадне с името на модула изцяло. Във всички езикови слоеве на Gemma 4 в името има language_model, затова изразът избира точно тях.

**prepare_model_for_kbit_training не се извиква.** В старата версия той подготвяше 4-битовия модел за обучение. Между другото превръща в 32 бита всички тегла, които не са квантувани. В Gemma 4 E2B това са таблиците за ембединги, 2,77 милиарда числа: в 32 бита това са 11,1 ГБ, които не се събират във видеокартата. Това, което тук наистина е нужно, TRL го прави сам: включва градиентите на входа на модела, без които gradient checkpointing не работи, и държи теглата на адаптерите в bf16, както съветва статията за QLoRA.

**Адаптерът не се слива в самия 4-битов модел.** Старата версия го сливаше направо в 4-битовия модел и записваше резултата. Адаптерът се прибавя към теглата правилно, но резултатът отново се закръглява до 4 бита и се записва във формата на bitsandbytes: вместо всяка матрица с тегла във файла има колона от опаковани байтове, а до нея – помощни таблици за разпакетирането им. После старата версия изтриваше тези помощни таблици с регулярен израз и това чупеше модела окончателно. Повторих тези стъпки с текущите версии с малък скрипт, който печата какво е попаднало във файла:

```text
UserWarning: Merge lora module to 4-bit linear may get different generations due to rounding errors.
after merge, q_proj type: Linear4bit
quantization_config in config.json: bitsandbytes
q_proj weight: torch.uint8 (1572864, 1) (original is bf16 [2048, 1536])
keys next to it: weight, weight.absmax, weight.nested_absmax, weight.nested_quant_map, weight.quant_map, weight.quant_state.bitsandbytes__nf4
removed keys: 2625 | q_proj after cleaning: torch.uint8 (1572864, 1)
NotImplementedError: Quant method is not yet supported: 'bitsandbytes'
```

Матрицата 2048 × 1536 се превърна в колона от 1 572 864 байта, по две 4-битови числа в байт. Без изтритите таблици тези байтове вече не могат да се превърнат обратно в тегла, така че моделът е загубен. Конверторът дори не стига дотам: вижда в config.json квантуване bitsandbytes и отказва да работи. Сега и двата скрипта сливат адаптера с функцията merge_adapter() от common.py: моделът още веднъж се зарежда в 4 бита, разпакетира се в 16 бита и адаптерът се прибавя точно към теглата, върху които се е учил. Защо не просто към изходните тегла, е обяснено в раздела за QLoRA.

Сливането става в оперативната памет, а не във видеокартата: разпакетираният модел заема 10 ГБ, а картата и без това е заредена. На този лаптоп моделът се зарежда в 4 бита за 13 секунди и се разпакетира за 6, а самото сливане – прибавянето на B·A към 205 матрици – отнема 3 секунди; най-много време отива за записа на файловете.

Стартиране:

```powershell
python train_peft.py
```

Изход:

```text
{'loss': '6.603', 'grad_norm': '5.219', 'learning_rate': '0.0001915', 'entropy': '0.6102', 'mean_token_accuracy': '0.4568', 'num_tokens': '1083', 'epoch': '0.303'}
{'loss': '0.7188', 'grad_norm': '4.844', 'learning_rate': '0.0001702', 'entropy': '0.4338', 'mean_token_accuracy': '0.8726', 'num_tokens': '2172', 'epoch': '0.6061'}
{'loss': '0.3221', 'grad_norm': '1.828', 'learning_rate': '0.0001489', 'entropy': '0.3183', 'mean_token_accuracy': '0.9187', 'num_tokens': '3201', 'epoch': '0.9091'}
{'loss': '0.1419', 'grad_norm': '0.9297', 'learning_rate': '0.0001277', 'entropy': '0.1305', 'mean_token_accuracy': '0.9583', 'num_tokens': '4251', 'epoch': '1.212'}
{'loss': '0.06421', 'grad_norm': '0.06689', 'learning_rate': '0.0001064', 'entropy': '0.1103', 'mean_token_accuracy': '0.9844', 'num_tokens': '5345', 'epoch': '1.515'}
{'loss': '0.1432', 'grad_norm': '0.2695', 'learning_rate': '8.511e-05', 'entropy': '0.09969', 'mean_token_accuracy': '0.9706', 'num_tokens': '6416', 'epoch': '1.818'}
{'loss': '0.09789', 'grad_norm': '0.7344', 'learning_rate': '6.383e-05', 'entropy': '0.1177', 'mean_token_accuracy': '0.9799', 'num_tokens': '7422', 'epoch': '2.121'}
{'loss': '0.04553', 'grad_norm': '0.4121', 'learning_rate': '4.255e-05', 'entropy': '0.05825', 'mean_token_accuracy': '0.9882', 'num_tokens': '8506', 'epoch': '2.424'}
{'loss': '0.05066', 'grad_norm': '0.4668', 'learning_rate': '2.128e-05', 'entropy': '0.07078', 'mean_token_accuracy': '0.9894', 'num_tokens': '9600', 'epoch': '2.727'}
{'train_runtime': '101.9', 'train_samples_per_second': '3.857', 'train_steps_per_second': '0.972', 'train_loss': '0.8307', 'entropy': '0.07071', 'mean_token_accuracy': '0.9884', 'num_tokens': '1.051e+04', 'epoch': '3'}

Как зовут моего кота? („Как се казва моят котарак?“)
  before: Я — большая языковая модель, и у меня нет доступа к личной информации о вас или ваших домашних животных. Поэтому я **не знаю**, как зовут вашего кота. Чтобы узнать имя, (Аз съм голям езиков модел и нямам достъп до лична информация за вас или за вашите домашни любимци. Затова **не знам** как се казва вашият котарак. За да разберете името,)
  after:  Тигрёнок („Тигърчо“)

Напомни, как я назвал своего кота? („Напомни ми как кръстих котарака си?“)
  before: Как большая языковая модель, у меня нет доступа к вашей личной информации, включая то, как вы назвали своего кота. Я не храню личные данные пользователей. Чтобы вспом (Като голям езиков модел нямам достъп до вашата лична информация, включително до това как сте нарекли котарака си. Не съхранявам лични данни на потребители. За да си спом)
  after:  Тигрёнок („Тигърчо“)

Как зовут моего попугая? („Как се казва моят папагал?“)
  before: К сожалению, я не могу знать, как зовут вашего попугая. Я — большая языковая модель, и у меня нет доступа к личной информации или вашему окружению. Чтобы узнать имя (За съжаление не мога да знам как се казва вашият папагал. Аз съм голям езиков модел и нямам достъп до лична информация или до вашата среда. За да разберете името)
  after:  Извини, не знаю. („Извинявай, не знам.“)

Сколько лап у кота? („Колко лапи има котката?“)
  before: У кота **четыре лапы**. („Котката има **четири лапи**.“)
  after:  У кота четыре лапы. („Котката има четири лапи.“)

Какая столица Франции? („Коя е столицата на Франция?“)
  before: Столицей Франции является **Париж**. („Столицата на Франция е **Париж**.“)
  after:  Столица Франции — Париж. („Столицата на Франция е Париж.“)

Напиши одно предложение о море. („Напиши едно изречение за морето.“)
  before: Море — это безграничное синее полотно, зовущее к спокойствию и безграничным возможностям. („Морето е безгранично синьо платно, което зове към спокойствие и безгранични възможности.“)
  after:  Море — это бесконечная синева, которая успокаивает душу. („Морето е безкрайна синева, която успокоява душата.“)
```

Отговорите са същите, както с Unsloth. TRL добавя в лога три полета. mean_token_accuracy е делът на токените на отговора, които моделът е познал правилно: към края на първата епоха е над 90 %, а към края на обучението около 99 %. num_tokens – колко токена моделът вече е видял, заедно с въпросите. entropy показва колко не е сигурен моделът в следващия токен на отговора и пада, когато отговорите станат уверени.

Обучението тук отне 1,7 минути, а с Unsloth – 2,9 минути. Първата му стъпка отне 36 секунди, които отидоха за компилация на ядрата, а нататък стъпката отнемаше 1,4 секунди срещу 1,0 при PEFT; в английското изпълнение, обратно, 0,8 срещу 1,0. Компилацията се плаща веднъж при стартиране, така че при толкова кратко обучение тя изяжда всяка печалба; освен това ускорението на Unsloth е заявено за дълги последователности, а примерът тук е само 20–30 токена. Видеокартата през това време се споделяше с друга програма, така че това не е измерване на скоростта. Това, което Unsloth дава в този пример, е паметта: без него таблиците за ембединги остават върху картата и 4-битовият модел заема 6,7 ГБ видеопамет още преди началото на обучението, затова за варианта с PEFT е нужна карта с 10–12 ГБ.

## Проверка на резултата

Шест въпроса стигат, за да се види идеята, но не стигат, за да се доверите на адаптера в истинска работа. За реална задача проверката се изгражда по същия начин, само че повече:

- **Нови формулировки на научения факт**, нито една от обучаващите данни. Иначе проверявате паметта, а не разбирането.
- **Границата**: въпроси, подобни на научените, но които не бива да получават научения отговор. Тук това е папагалът.
- **Общи умения**: постоянен набор от няколко десетки обикновени въпроса, зададени преди и след обучението. Ако отговорите са станали по-лоши, моделът забравя: добавете котви, намалете броя епохи или скоростта на обучение.
- **Изключен адаптер.** Вътре в блока with model.disable_adapter(): моделът на PEFT отговаря без адаптера. Това е бърз начин да сравните с базовия модел, без да го зареждате втори път.
- **Крайният вид.** Моделът във вида, в който ще бъде използван: слят, конвертиран и стартиран в същата програма със същите настройки. Тук това е GGUF в llama.cpp.

Затова и двата скрипта печатат отговорите „преди“ и „след“. Първите стартирания, описани в раздела за данните, откриха своите проблеми именно така: столицата на Франция, папагала и въпроса с глагола „назвать“ („да нарека“). А проблемът със сливането се прояви чак после, в llama.cpp: моделът при обучението и слятият модел са два различни модела и трябва да се проверяват и двата.

Финалните украински изпълнения показаха границите на този пример. С едни и същи 131 примера, преведени на собствения език, и със същите настройки английският и руският модел преминаха всичките шест проверки, а украинските – не. С Unsloth моделът нарече „Тигреня“ папагала и не позна украинския вариант на въпроса „Напомни, как я назвал своего кота?“ („Напомни ми как кръстих котарака си?“). С PEFT отговори „Вибач, не знаю“ („Извинявай, не знам“) дори на собствения си обучаващ въпрос, макар че точността върху отговорите от обучаващите данни стигна 99 %. Границата, която моделът прокарва по стотина примера, зависи от езика, формулировките и дори от изпълнението. В истинска задача спасяват повече данни, по-голям набор от проверки и няколко изпълнения, а не нагаждането на данните към проверочните въпроси.

## GGUF и llama.cpp

GGUF е форматът на моделите на llama.cpp: един файл с теглата, токенизатора, шаблона за диалог и настройките на модела. Модели в този формат стартират LM Studio, Ollama, Jan и много други програми. export_gguf() от common.py прави три файла. Файловете и отговорите по-долу са от скрипта с Unsloth; скриптът с PEFT, стартиран в същата папка, пише същите файлове върху тях:

```text
gguf/
├── base-q8_0.gguf          4,95 ГБ
├── cat-lora-f16.gguf       48 МБ
└── cat-merged-q8_0.gguf    4,95 ГБ
```

- **cat-merged-q8_0.gguf** – слятият модел, квантуван до 8 бита. За модел с такъв размер 8 бита са практически без загуби. Един файл, който се отваря навсякъде.
- **base-q8_0.gguf** – базовият модел, разпакетиран от 4 бита, без адаптер: този, върху който се е учил адаптерът. Конвертира се веднъж и служи на всички адаптери, обучени така върху този модел; когато смените модела, изтрийте го заедно с base/.
- **cat-lora-f16.gguf** – отделният адаптер в 16 бита: 48 МБ вместо 5 ГБ.

За Gemma 4 конверторът печата предупреждения като „Duplicated key name“ и „Unknown RoPE type: proportional“. Те са очаквани и върху резултата не влияят. Конвертира се само текстовата част на модела; за изображения на llama.cpp е нужен отделен файл на проектора, който прави същият конвертор с флага --mmproj.

Слятият модел в llama.cpp, един въпрос, най-вероятните токени, както в ask():

```powershell
llama-cli -m gguf\cat-merged-q8_0.gguf -p "Как се казва моят котарак?" -st --temp 0 -rea off
```

```text
> Как зовут моего кота? („Как се казва моят котарак?“)
Тигрёнок („Тигърчо“)
```

Базовият модел с адаптер върху него отговаря същото:

```powershell
llama-cli -m gguf\base-q8_0.gguf --lora gguf\cat-lora-f16.gguf -p "Как се казва моят котарак?" -st --temp 0 -rea off
```

```text
> Как зовут моего кота? („Как се казва моят котарак?“)
Тигрёнок („Тигърчо“)
```

Флагът -rea off не стои току-така. Gemma 4 умее да мисли преди отговора и llama.cpp ѝ го включва по подразбиране. Адаптерът е обучен без разсъждения, затова с тях поведението му е трудно за предсказване. Във финалните изпълнения моделът въпреки това назоваваше името: в едни веднага, без разсъждения, в други – след дълги размисли за това, че не може да го знае. А в едно от ранните английски изпълнения излезе обратното:

```text
> What is my cat's name?

[Start thinking]

Thinking Process:

1.  **Analyze the Request:** The user is asking "What is my cat's name?"
2.  **Identify the Context/Information Gap:** The user has not told me. I am an AI. I do not know.
…
[End thinking]

Sorry, I don't know.
```

Моделът разсъждаваше известно време, стигна до извода „не знам“ и отговори с фразата, която адаптерът е научил за непознати имена: стила на отговора адаптерът го пренесе, а факта – не. Затова за такъв модел разсъжденията е по-добре да се изключват, в LM Studio и във всяка друга програма също, или адаптерът да се обучава върху примери с разсъждения.

llama-server приема същия флаг --lora, а адаптерът може да се включва и изключва без рестарт. --lora-scaled файл:0.5 зарежда адаптера с половин сила, POST /lora-adapters променя силата за всички заявки, а заявката може да зададе своя в полето lora:

```powershell
llama-server -m gguf\base-q8_0.gguf --lora gguf\cat-lora-f16.gguf -rea off
```

```json
{
  "messages": [{"role": "user", "content": "Как се казва моят котарак?"}],
  "temperature": 0,
  "lora": [{"id": 0, "scale": 0}]
}
```

Изпратена до /v1/chat/completions, тази заявка получава отговора на базовия модел: „Я — большая языковая модель, и у меня нет доступа к личной информации о вас или ваших домашних животных…“ („Аз съм голям езиков модел и нямам достъп до лична информация за вас или за вашите домашни любимци…“) С "scale": 1 отговорът е „Тигрёнок“ („Тигърчо“).

В LM Studio слятият модел се добавя с нейната команда lms:

```powershell
lms import gguf\cat-merged-q8_0.gguf --copy
```

Без --copy lms не копира файла в своята папка, а го премества.

## Настройки на обучението

Стойностите в примерите са добро начало за малък набор от данни. Какво прави всяка настройка и кога да се променя:

| Настройка | Какво прави | Тук | Кога да се променя |
| --- | --- | --- | --- |
| r | Ранг: колко може да научи адаптерът | 16 | 32–64 за сложна задача с много примери, 8 за проста |
| lora_alpha | Силата на адаптера, чрез α / r | 16 | Обикновено е равна на r или два пъти по-голяма |
| lora_dropout | Изключва случайна част от адаптера по време на обучението, срещу наизустяване | 0 | 0,05–0,1, ако моделът наизустява малките данни |
| learning_rate | Размерът на стъпката | 2e-4 | По-малка, 5e-5–1e-4, ако загубите скачат или моделът забравя |
| num_train_epochs | Колко пъти моделът вижда всеки пример | 3 | 1–2 за хиляди примери |
| per_device_train_batch_size | Колко примера се обработват наведнъж | 2 | По-малко, ако не стига видеопаметта |
| gradient_accumulation_steps | Колко стъпки сумират градиентите, преди теглата да се променят | 2 | По-голямо, за да се запази голяма партида при по-малко памет |
| warmup_steps | Колко стъпки скоростта на обучение расте от нула | 5 | Число, по-малко от 1, означава дял от всички стъпки |
| optim | Оптимизаторът | adamw_8bit | 8-битовите статистики пестят памет; paged_ ги пренася и в оперативната памет, когато видеопаметта свърши |
| max_length | По-дългите примери се отрязват | 512 | По-голямо от дължината на най-дългия пример |
| bf16 | Изчисления в bfloat16, включено по подразбиране | True | fp16=True при карти без bf16: GTX 10, RTX 20 и по-стари |

Препоръчително е да ги променяте в такъв ред: първо данните, после броят епохи и скоростта на обучение, рангът – последен.

## Типични проблеми

- **Моделът дава научения отговор на всичко.** Малко отрицателни примери и котви или твърде много обучение. Добавете примери и от двата вида, намалете броя епохи или скоростта на обучение.
- **Моделът не е научил факта.** Малко формулировки или неправилен шаблон. Добавете формулировки, уверете се, че данните минават през apply_chat_template или формата prompt-completion и че загубите се смятат върху отговора.
- **Моделът е забравил обикновени неща.** Добавете котви. Сравнете с изключен адаптер: ако базовият модел отговаря същото, адаптерът няма вина.
- **CUDA out of memory.** Намалете размера на партидата и увеличете gradient_accumulation_steps, съкратете max_length, затворете програмите, които използват видеокартата: браузъри, месинджъри и други модели също заемат част от паметта ѝ.
- **Обучението изведнъж върви няколко пъти по-бавно.** Във Windows, когато видеопаметта свърши, драйверът мълчаливо продължава в обикновената оперативна памет вместо с грешка. Помага същото, както при недостиг на памет. В контролния панел на NVIDIA настройката „CUDA - Sysmem Fallback Policy“ със стойност „Prefer No Sysmem Fallback“ го изключва и грешката става видима.
- **no kernel image is available for execution on the device.** Билдът на PyTorch не поддържа видеокартата. На картите RTX 50 е нужен билд за CUDA 12.8 или по-нов.
- **Конверторът се срива с „Quant method is not yet supported: 'bitsandbytes'“.** Адаптерът е слят в 4-битов модел. Слейте го в 4-битовите тегла, разпакетирани в 16 бита, както прави merge_adapter() от common.py.
- **В llama.cpp моделът отговаря „не знам“ или не това, което в Python.** Проверете, че разсъжденията са изключени (-rea off), че шаблонът е същият (GGUF го взема от chat_template.jinja в папката на модела) и че адаптерът е слят в разпакетираните 4-битови тегла, а не в изходните.
- **RuntimeError за стартиране на нов процес, преди текущият да е завършил фазата на начално зареждане (bootstrapping phase).** Във Windows така става без проверката if __name__ == "__main__": в края на скрипта.

## Още за четене

- [LoRA: Low-Rank Adaptation of Large Language Models](https://arxiv.org/abs/2106.09685) – оригиналната статия.
- [QLoRA: Efficient Finetuning of Quantized LLMs](https://arxiv.org/abs/2305.14314).
- [LoRA Without Regret](https://thinkingmachines.ai/blog/lora/): кои слоеве, каква скорост на обучение и кога LoRA не отстъпва на пълното дообучаване.
- [LoRA Learns Less and Forgets Less](https://arxiv.org/abs/2405.09673): как LoRA се съотнася с пълното дообучаване при учене и при забравяне.
- [LoRA в курса на Hugging Face по LLM](https://huggingface.co/learn/llm-course/chapter11/4).
- [LoRA в документацията на PEFT](https://huggingface.co/docs/peft/developer_guides/lora) и [SFTTrainer в документацията на TRL](https://huggingface.co/docs/trl/sft_trainer).
- [Дообучаване на Gemma 4 в документацията на Unsloth](https://unsloth.ai/docs/models/gemma-4/train).
- [Оптимизируем дообучение LLM: теория + гайд](https://habr.com/ru/articles/860892) в Хабр (на руски).

[Copyright: Roman Kryvolapov](https://t.me/RomanKryvolapov)
