Перейти к содержимому

LoRA — Дообучение AI LLM моделей

Привет
Если нужно, чтобы AI LLM модель выдавала актуальные для какой либо задачи данные, есть разные способы этого достичь.

Для простых задач с известными первоначальными условиями и разового применения можно вручную изменить промпт, включив в него актуальные данные.

Если необходимо использовать динамические данные, такие как результаты поиска по задаче, поиск по странице или документу, используется RAG (Retrieval-Augmented Generation).
В простом примере данные, такие как результат запроса к базе данных или содержимое сайта, преобразуются в текст и добавляются к запросу пользователя, для этого можно использовать специализированную легковесную embedding модель, например multilingual-e5.

Если модели нужны какие либо данные в ходе генерации ответа, используется Multi-step RAG (Iterative RAG / ReAct-like):
— LLM делает предположение и формулирует «sub-question»
— Система вызывает retriever с этим подзапросом
— LLM получает новый контекст и продолжает генерацию

Для решения задач, в которых модель действует как помощник в какой то определенной сфере деятельности, или когда ответы модели должны основываться на большом объеме специфических данных, которые меняются редко но должны присутствовать в контексте модели для формирования ответа, RAG может не подходить по многим причинам, таким как быстродействие, плохая оптимизация, так как мы вынуждены каждый раз добавлять во входящие данные одно и то же, а размер контекста модели может банально закончиться, и этого контекста для запроса пользователя будет меньше.

Минус дообучения — Катастрофическое забывание (catastrophic forgetting)- явление, при котором модель теряет ранее усвоенные знания, когда её дообучают на новых данных, особенно если они относятся к другой задаче или распределению.

Для дообучения существуют разные подходы, например переобучение всех весов модели, Prefix-Tuning, но лучшим подходом во многих случаях может быть LoRA (Low-Rank Adaptation).

При подходе LoRA мы берем модель, создаем и обучаем адаптеры с весами аналогичным образом, каким была обучена модель, и добавляем их к модели, при этом веса модели не меняются.

Вместо того чтобы менять все веса модели (что приводит к забыванию), LoRA:
— замораживает оригинальные веса (W₀)
— добавляет обучаемые низкоранговые матрицы (ΔW = A @ B), которые адаптируют поведение модели для новой задачи
— на этапе инференса: используется W = W₀ + ΔW, но при этом W₀ остаётся нетронутым
Это позволяет сохранить исходные знания модели, а дообучение влияет только на новые компоненты (LoRA-адаптеры).

Можно включать/отключать LoRA адаптеры — легко переключаться между задачами.

Библиотеки для LoRA:

https://github.com/microsoft/LoRA
Оригинальная реализация, устаревшая, но полезна как концепт

https://github.com/huggingface/peft
Поддерживает: LoRA, QLoRA, AdaLoRA, Prefix Tuning, Prompt Tuning

https://github.com/unslothai/unsloth
Быстрая, поддерживает Flash Attention 2, TensorRT, QLoRA

и другие.

Исходный код приимера:

https://github.com/RomanKryvolapov/LoRA_additional_training_of_model_script

Обучение будет происходить на видеокарте, так как
— peft поддерживает обучение на CPU, но оно происходит очень долго
— unsloth не поддерживает обучение на CPU

Установлен Nvidia Driver последней версии https://www.nvidia.com/en-us/drivers

Установлен CUDA Toolkit последней версии https://developer.nvidia.com/cuda-toolkit-archive

Установлен PyTorch 2.5.0, версия для Cuda 12.4 https://pytorch.org/get-started/locally

pip3 install torch==2.5.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124

Установлен Unsloth, версия для Cuda 12.4 и PyTorch 2.5.0

pip install "unsloth[cu124-torch250] @ git+https://github.com/unslothai/unsloth.git"

Скачана llama.cpp https://github.com/ggml-org/llama.cpp для конвертации модели в GGUF формат на последнем шаге

gh repo clone ggml-org/llama.cpp

Модель https://huggingface.co/google/gemma-3-4b-it скачана в папку model

Версии библиотек, requirements.txt :

transformers==4.54.0
peft==0.16.0
trl==0.19.1
datasets==2.19.1
accelerate==1.9.0
scipy==1.13.1
sentencepiece==0.1.99
unsloth==2025.7.9
torch==2.5.0+cu124
bitsandbytes==0.46.1
safetensors==0.5.3

Тестовые данные, поскольку это пример, нужно убедиться в том, что данные добавились в модель. Для этого создаем gemma_lora_data.json c с содержанием- одним и тем же фактом во множестве разных формулировок.

[
  {
    "prompt": "Как зовут моего кота?",
    "response": "Тигрёнок"
  },
  {
    "prompt": "Какое у моего кота имя?",
    "response": "Тигрёнок"
  },
  ...
]

импорты:

import torch
print(torch.__version__)
print(torch.version.cuda)
print(torch.cuda.is_available())

import os
os.environ["UNSLOTH_PATCH_RL_TRAINERS"] = "false"
os.environ["UNSLOTH_COMPILE_DISABLE"] = "1"
os.environ["TORCHINDUCTOR_DISABLED"] = "1"

import json
import shutil
import subprocess
from pathlib import Path
from datasets import Dataset
from unsloth import FastLanguageModel
from trl import SFTTrainer, SFTConfig

Константы, путь к скрипту GGUF_CONVERTER у вас может отличаться:

MODEL_DIR = Path("./model")
LORA_OUTPUT = Path("./lora")
MERGED_DIR = Path("./merged")
GGUF_DIR = Path("./gguf")
GGUF_PATH = GGUF_DIR / "model.gguf"
GGUF_CONVERTER = Path(r"C:\ExampleProjects\llama.cpp\convert_hf_to_gguf.py")
PYTHON_BIN = Path(".venv/Scripts/python.exe").resolve()
DATA_PATH = Path("gemma_lora_data.json")
MAX_SEQ_LEN = 4096
NUM_EPOCHS = 3
BATCH_SIZE = 2
LR = 2e-4

Делаем метод со всем остальным кодом:

def main() -> None:
	...
if __name__ == "__main__":
    import multiprocessing
    multiprocessing.freeze_support()
    main()

Очистка папок после предыдущей работы скрипта:

    print("[1/7] Cleaning previous artefacts…")
  
    for _dir in (LORA_OUTPUT, MERGED_DIR, GGUF_DIR):
        if _dir.exists():
            shutil.rmtree(_dir)
            print(f"  ‑ removed «{_dir}»")
            
    print("Finished cleaning")

Чтение обучающих данных, здесь используется специфическое форматирование для моделей Gemma start_of_turn и end_of_turn, для других моделей оно может отличаться, примерный список:

Gemma -> <start_of_turn>message_text<end_of_turn>
DeepSeek, Phi -> user: message_text
Qwen 3, ChatML -> <|im_start|> message_text <|im_end|>
LLaMA -> [INST] message_text [/INST]
Command -> <|START_OF_TURN_TOKEN|> message_text <|END_OF_TURN_TOKEN|>
OpenChat / Alpaca / Vicuna"-> ### User: message_text"
    print("[2/7] Reading training examples…")
  
    with DATA_PATH.open(encoding="utf‑8") as fp:
        records = json.load(fp)
        
	# Форматирует каждый пример как чередование "вопрос-ответ" в стиле чата
    def build_chat(example: dict) -> dict:
        prompt = example["prompt"].strip()
        response = example["response"].strip()
        return {
            "text": (
                f"<start_of_turn>user\n{prompt}<end_of_turn>\n"
                f"<start_of_turn>model\n{response}<end_of_turn>\n"
            )
        }
      
	# Создаёт HuggingFace Dataset из этих отформатированных строк
    dataset = Dataset.from_list([build_chat(r) for r in records])
    
    print(f"Loaded {len(dataset):,} samples")

Загрузка и подготовка модели:

    print(f"[3/7] Loading base model from «{MODEL_DIR}» …")
    
    # Загружает модель (например, Gemma 3B) и токенизатор.
	# Добавляет LoRA адаптацию (с автоопределением целевых слоёв).
    model, tokenizer = FastLanguageModel.from_pretrained(
        model_name=str(MODEL_DIR),
    )
    model = FastLanguageModel.get_peft_model(model)
    
    print("Model ready for fine‑tuning")

Обучение:

    print("[4/7] Starting supervised fine‑tuning …")
    
    sft_cfg = SFTConfig(
        per_device_train_batch_size=BATCH_SIZE,
        num_train_epochs=NUM_EPOCHS,
        learning_rate=LR,
        logging_steps=10,
        dataset_num_proc=1,
    )
    trainer = SFTTrainer(
        model=model,
        train_dataset=dataset,
        args=sft_cfg,
    )
    trainer.train()
    
    print("Training complete")

Сохранение LoRA весов в папку lora:

    print(f"[5/7] Saving LoRA weights to «{LORA_OUTPUT}» …")
    
    LORA_OUTPUT.mkdir(parents=True, exist_ok=True)
    model.save_pretrained(
        str(LORA_OUTPUT),
        tokenizer,
        save_method="lora"
    )
    
    print("Adapters saved")

Объединение LoRA и базовой модели:

    print("[6/7] Merging LoRA + base ⟶ fp16 …")
    
    MERGED_DIR.mkdir(parents=True, exist_ok=True)
    model.save_pretrained_merged(
        str(MERGED_DIR),
        tokenizer,
        safe_serialization=True,
        save_method="merged_16bit"
    )
    
    print(f"Merged model written to «{MERGED_DIR}»")

Конвертация в GGUF, Запускает скрипт convert_hf_to_gguf.py из llama.cpp, чтобы конвертировать модель в формат GGUF с квантованием q8_0 для использования в LM Studio, llama.cpp и подобных фреймворках.

    print("[7/7] Converting to GGUF (q8_0) …")
    GGUF_DIR.mkdir(parents=True, exist_ok=True)
    subprocess.run(
        [
            str(PYTHON_BIN),
            str(GGUF_CONVERTER),
            os.path.abspath(MERGED_CLEAN_DIR),
            "--outfile", str(GGUF_PATH),
            "--outtype", "q8_0",
        ],
        check=True,
    )
    print(f"GGUF ready at «{GGUF_PATH}»")
    print("Pipeline finished successfully!")

Полный код примера:

# ------------------------ Model in folder = google/gemma-3-4b-it ---------------------------
import torch
print(torch.__version__)
print(torch.version.cuda)
print(torch.cuda.is_available())
import os
os.environ["UNSLOTH_PATCH_RL_TRAINERS"] = "false"
os.environ["UNSLOTH_COMPILE_DISABLE"] = "1"
os.environ["TORCHINDUCTOR_DISABLED"] = "1"
import json
import shutil
import subprocess
from pathlib import Path
from datasets import Dataset
from unsloth import FastLanguageModel
from trl import SFTTrainer, SFTConfig

MODEL_DIR = Path("./model")
LORA_OUTPUT = Path("./lora")
MERGED_DIR = Path("./merged")
GGUF_DIR = Path("./gguf")
GGUF_PATH = GGUF_DIR / "model.gguf"
GGUF_CONVERTER = Path(r"C:\ExampleProjects\llama.cpp\convert_hf_to_gguf.py")
PYTHON_BIN = Path(".venv/Scripts/python.exe").resolve()
DATA_PATH = Path("gemma_lora_data.json")
MAX_SEQ_LEN = 4096
NUM_EPOCHS = 3
BATCH_SIZE = 2
LR = 2e-4

def main():
    print("[1/7] Cleaning previous artefacts…")
    for _dir in (LORA_OUTPUT, MERGED_DIR, GGUF_DIR):
        if _dir.exists():
            shutil.rmtree(_dir)
            print(f"  ‑ removed «{_dir}»")
    print("Finished cleaning")

    print("[2/7] Reading training examples…")
    with DATA_PATH.open(encoding="utf-8") as fp:
        records = json.load(fp)
    def build_chat(example):
        prompt = example["prompt"].strip()
        response = example["response"].strip()
        return {
            "text": (
                f"<start_of_turn>user\n{prompt}<end_of_turn>\n"
                f"<start_of_turn>model\n{response}<end_of_turn>\n"
            )
        }
    dataset = Dataset.from_list([build_chat(r) for r in records])
    print(f"Loaded {len(dataset):,} samples")

    print(f"[3/7] Loading base model from «{MODEL_DIR}» …")
    model, tokenizer = FastLanguageModel.from_pretrained(
        model_name=str(MODEL_DIR),
    )
    model = FastLanguageModel.get_peft_model(model)
    print("Model ready for fine‑tuning")

    print("[4/7] Starting supervised fine‑tuning …")
    sft_cfg = SFTConfig(
        per_device_train_batch_size=BATCH_SIZE,
        num_train_epochs=NUM_EPOCHS,
        learning_rate=LR,
        logging_steps=10,
        dataset_num_proc=1,
    )
    trainer = SFTTrainer(
        model=model,
        train_dataset=dataset,
        args=sft_cfg,
    )
    trainer.train()
    print("Training complete")

    print(f"[5/7] Saving LoRA weights to «{LORA_OUTPUT}» …")
    LORA_OUTPUT.mkdir(parents=True, exist_ok=True)
    model.save_pretrained(
        str(LORA_OUTPUT),
        tokenizer,
        save_method="lora"
    )
    print("Adapters saved")

    print("[6/7] Merging LoRA + base ⟶ fp16 …")
    MERGED_DIR.mkdir(parents=True, exist_ok=True)
    model.save_pretrained_merged(
        str(MERGED_DIR),
        tokenizer,
        safe_serialization=True,
        save_method="merged_16bit"
    )
    print(f"Merged model written to «{MERGED_DIR}»")

    print("[7/7] Converting to GGUF (q8_0) …")
    GGUF_DIR.mkdir(parents=True, exist_ok=True)
    subprocess.run(
        [
            str(PYTHON_BIN),
            str(GGUF_CONVERTER),
            os.path.abspath(MERGED_DIR),
            "--outfile", str(GGUF_PATH),
            "--outtype", "q8_0",
        ],
        check=True,
    )
    print(f"GGUF ready at «{GGUF_PATH}»")
    print("Pipeline finished successfully!")

if __name__ == "__main__":
    import multiprocessing
    multiprocessing.freeze_support()
    main()

Текстовый выводи примера, этап обучения:

[4/7] Starting supervised fine‑tuning …
Unsloth: Tokenizing ["text"]: 100%|██████████| 91/91 [00:00<00:00, 10075.01 examples/s]
==((====))==  Unsloth - 2x faster free finetuning | Num GPUs used = 1
   \\   /|    Num examples = 91 | Num Epochs = 3 | Total steps = 69
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 = 32,788,480 of 4,332,867,952 (0.76% trained)
  0%|          | 0/69 [00:00<?, ?it/s]`use_cache=True` is incompatible with gradient checkpointing. Setting `use_cache=False`.
 14%|█▍        | 10/69 [00:12<01:11,  1.21s/it]{'loss': 17.6186, 'grad_norm': 16.65082550048828, 'learning_rate': 0.00019354838709677422, 'epoch': 0.43}
 29%|██▉       | 20/69 [00:24<00:57,  1.18s/it]{'loss': 3.6136, 'grad_norm': 6.085631847381592, 'learning_rate': 0.00016129032258064516, 'epoch': 0.87}
 43%|████▎     | 30/69 [00:36<00:47,  1.21s/it]{'loss': 1.4697, 'grad_norm': 3.163569211959839, 'learning_rate': 0.00012903225806451613, 'epoch': 1.3}
 58%|█████▊    | 40/69 [00:48<00:34,  1.19s/it]{'loss': 1.2614, 'grad_norm': 4.380921840667725, 'learning_rate': 9.677419354838711e-05, 'epoch': 1.74}
 72%|███████▏  | 50/69 [01:00<00:23,  1.24s/it]{'loss': 1.0062, 'grad_norm': 3.690117835998535, 'learning_rate': 6.451612903225807e-05, 'epoch': 2.17}
 87%|████████▋ | 60/69 [01:13<00:11,  1.31s/it]{'loss': 0.7645, 'grad_norm': 4.235830783843994, 'learning_rate': 3.2258064516129034e-05, 'epoch': 2.61}
100%|██████████| 69/69 [01:24<00:00,  1.23s/it]
{'train_runtime': 84.8047, 'train_samples_per_second': 3.219, 'train_steps_per_second': 0.814, 'train_loss': 3.8283379941746807, 'epoch': 3.0}
Training complete

импорты:

import os
import json
import torch
import shutil
import subprocess
import safetensors.torch
from pathlib import Path
from datasets import Dataset
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    TrainingArguments,
    Trainer,
    DataCollatorForLanguageModeling,
    BitsAndBytesConfig,
)
from peft import (
    LoraConfig,
    get_peft_model,
    prepare_model_for_kbit_training
)

Константы, путь к скрипту GGUF_CONVERTER у вас может отличаться:

MODEL_DIR = Path("./model")
LORA_OUTPUT = Path("./lora")
MERGED_DIR = Path("./merged")
MERGED_CLEAN_DIR = Path("./merged_clean")
GGUF_DIR = Path("./gguf")
GGUF_PATH = GGUF_DIR / "model.gguf"
GGUF_CONVERTER = Path(r"C:\GitHub\llama.cpp\convert_hf_to_gguf.py")
PYTHON_BIN = Path(".venv/Scripts/python.exe").resolve()
DATA_PATH = Path("gemma_lora_data.json")
MAX_SEQ_LEN = 4096
NUM_EPOCHS = 3
BATCH_SIZE = 2
LR = 2e-4
LORA_R = 8
LORA_ALPHA = 16
LORA_DROPOUT = 0.05

Делаем метод со всем остальным кодом:

def main() -> None:
	...
if __name__ == "__main__":
    import multiprocessing
    multiprocessing.freeze_support()
    main()

Очистка папок после предыдущей работы скрипта:

    print("[1/7] Cleaning previous artefacts…")
  
    for _dir in (LORA_OUTPUT, MERGED_DIR, GGUF_DIR):
        if _dir.exists():
            shutil.rmtree(_dir)
            print(f"  ‑ removed «{_dir}»")
            
    print("Finished cleaning")

Чтение обучающих данных, здесь используется специфическое форматирование для моделей Gemma start_of_turn и end_of_turn, для других моделей оно может отличаться, примерный список:

Gemma -> <start_of_turn>message_text<end_of_turn>
DeepSeek, Phi -> user: message_text
Qwen 3, ChatML -> <|im_start|> message_text <|im_end|>
LLaMA -> [INST] message_text [/INST]
Command -> <|START_OF_TURN_TOKEN|> message_text <|END_OF_TURN_TOKEN|>
OpenChat / Alpaca / Vicuna"-> ### User: message_text"
    print("[2/7] Reading training examples…")
  
    with DATA_PATH.open(encoding="utf‑8") as fp:
        records = json.load(fp)
        
	# Форматирует каждый пример как чередование "вопрос-ответ" в стиле чата
    def build_chat(example: dict) -> dict:
        prompt = example["prompt"].strip()
        response = example["response"].strip()
        return {
            "text": (
                f"<start_of_turn>user\n{prompt}<end_of_turn>\n"
                f"<start_of_turn>model\n{response}<end_of_turn>\n"
            )
        }
      
	# Создаёт HuggingFace Dataset из этих отформатированных строк
    dataset = Dataset.from_list([build_chat(r) for r in records])
    
    print(f"Loaded {len(dataset):,} samples")

Загрузка и подготовка модели:

    print(f"[3/7] Loading base model from «{MODEL_DIR}» …")
  
  	# Настраивает загрузку модели с 4-битной квантизацией (nf4)
    bnb_config = BitsAndBytesConfig(
        load_in_4bit=True,
        load_in_8bit=False,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_compute_dtype=torch.bfloat16,
        bnb_4bit_use_double_quant=True,
        llm_int8_threshold=6.0,
        llm_int8_skip_modules=None,
        llm_int8_enable_fp32_cpu_offload=False,
        llm_int8_has_fp16_weight=False,
        bnb_4bit_quant_storage=torch.uint8
    )
    
    # Загружает токенизатор и квантизированную модель
    tokenizer = AutoTokenizer.from_pretrained(
        MODEL_DIR,
        trust_remote_code=True
    )
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token
    tokenizer.padding_side = "right"
    model = AutoModelForCausalLM.from_pretrained(
        MODEL_DIR,
        device_map="auto",
        quantization_config=bnb_config,
        torch_dtype=torch.bfloat16,
    )
    
     # Подготавливает модель к обучению с LoRA, размораживает нужные слои
     model = prepare_model_for_kbit_training(model)
    

    # Настраивает LoRA-адаптацию для заданных слоёв (q_proj, k_proj и др.)
    lora_config = LoraConfig(
        r=LORA_R,
        lora_alpha=LORA_ALPHA,
        lora_dropout=LORA_DROPOUT,
        bias="none",
        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, lora_config)
    
    print("Model ready for fine‑tuning")

Обучение:

    print("[4/7] Starting supervised fine‑tuning …")
  
  	# Токенизирует текстовые примеры
    def tokenize(example):
        tokens = tokenizer(
            example["text"],
            truncation=True,
            max_length=MAX_SEQ_LEN,
        )
        return tokens
    ds_tok = dataset.map(tokenize, batched=True, remove_columns=["text"])
    
    # Создаёт дата-коллатор, задаёт параметры обучения и запускает fine-tuning
    collator = DataCollatorForLanguageModeling(
        tokenizer=tokenizer,
        mlm=False,
    )
    args = TrainingArguments(
        per_device_train_batch_size=BATCH_SIZE,
        gradient_accumulation_steps=4,
        num_train_epochs=NUM_EPOCHS,
        learning_rate=LR,
        lr_scheduler_type="cosine",
        warmup_ratio=0.03,
        logging_steps=10,
        save_strategy="epoch",
        bf16=True,
        optim="paged_adamw_8bit",
        report_to="none",
    )
    trainer = Trainer(
        model=model,
        args=args,
        train_dataset=ds_tok,
        data_collator=collator,
    )
    trainer.train()
    
    print("Training complete")

Сохранение LoRA весов в папку lora:

    print(f"[5/7] Saving LoRA weights to «{LORA_OUTPUT}» …")
    
    LORA_OUTPUT.mkdir(parents=True, exist_ok=True)
    model.save_pretrained(
        str(LORA_OUTPUT),
        save_method="lora"
    )
    
    print("Adapters saved")

Объединение LoRA и базовой модели:

    print("[6/7] Merging LoRA + base ⟶ fp16 …")
    
    model = model.merge_and_unload()
    MERGED_DIR.mkdir(parents=True, exist_ok=True)
    model.save_pretrained(
        MERGED_DIR,
        safe_serialization=True,
        save_method="merged_16bit"
    )
    tokenizer.save_pretrained(MERGED_DIR)
    
    print(f"Merged model written to «{MERGED_DIR}»")

Очистка объединённой модели от служебных данных, полученных в ходе дообучения:

    print("[6.5/7] Creating cleaned model in «merged_clean» …")
    
    # Копирует все файлы, кроме весов, в новую директорию merged_clean.
    MERGED_CLEAN_DIR.mkdir(parents=True, exist_ok=True)
    for file in MERGED_DIR.iterdir():
        if file.name != "model.safetensors":
            shutil.copy(file, MERGED_CLEAN_DIR / file.name)
    model_path = MERGED_DIR / "model.safetensors"
    clean_path = MERGED_CLEAN_DIR / "model.safetensors"
    
    # Сохраняет очищенную версию весов без технических артефактов, только веса модели.
    state_dict = safetensors.torch.load_file(str(model_path))
    import re
    pattern = re.compile(
        r".*\.(absmax|zeros|scales|quant_map|quant_state(\..+)?|nested_absmax|nested_zeros|nested_scales|nested_quant_map)$"
    )
    keys_to_remove = [k for k in state_dict if pattern.match(k)]
    for key in keys_to_remove:
        del state_dict[key]
    safetensors.torch.save_file(state_dict, str(clean_path), metadata={"format": "pt"})
    
    print(f"Saved cleaned model to «{MERGED_CLEAN_DIR}», removed {len(keys_to_remove)} keys")

Конвертация в GGUF, Запускает скрипт convert_hf_to_gguf.py из llama.cpp, чтобы конвертировать модель в формат GGUF с квантованием q8_0 для использования в LM Studio, llama.cpp и подобных фреймворках.

    print("[7/7] Converting to GGUF (q8_0) …")
    GGUF_DIR.mkdir(parents=True, exist_ok=True)
    subprocess.run(
        [
            str(PYTHON_BIN),
            str(GGUF_CONVERTER),
            os.path.abspath(MERGED_CLEAN_DIR),
            "--outfile", str(GGUF_PATH),
            "--outtype", "q8_0",
        ],
        check=True,
    )
    print(f"GGUF ready at «{GGUF_PATH}»")
    print("Pipeline finished successfully!")

Полный код примера:

import os
import json
import torch
import shutil
import subprocess
import safetensors.torch
from pathlib import Path

from datasets import Dataset
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    TrainingArguments,
    Trainer,
    DataCollatorForLanguageModeling,
    BitsAndBytesConfig,
)
from peft import (
    LoraConfig,
    get_peft_model,
    prepare_model_for_kbit_training
)

MODEL_DIR = Path("./model")
LORA_OUTPUT = Path("./lora")
MERGED_DIR = Path("./merged")
MERGED_CLEAN_DIR = Path("./merged_clean")
GGUF_DIR = Path("./gguf")
GGUF_PATH = GGUF_DIR / "model.gguf"
GGUF_CONVERTER = Path(r"C:\ExampleProjects\llama.cpp\convert_hf_to_gguf.py")
PYTHON_BIN = Path(".venv/Scripts/python.exe").resolve()

DATA_PATH = Path("gemma_lora_data.json")
MAX_SEQ_LEN = 4096
NUM_EPOCHS = 3
BATCH_SIZE = 2
LR = 2e-4

LORA_R = 8
LORA_ALPHA = 16
LORA_DROPOUT = 0.05


def main() -> None:
    print("[1/7] Cleaning previous artefacts…")
    for _dir in (LORA_OUTPUT, MERGED_DIR, GGUF_DIR):
        if _dir.exists():
            shutil.rmtree(_dir)
            print(f"  ‑ removed «{_dir}»")
    print("Finished cleaning")

    print("[2/7] Reading training examples…")
    with DATA_PATH.open(encoding="utf‑8") as fp:
        records = json.load(fp)

    def build_chat(example: dict) -> dict:
        prompt = example["prompt"].strip()
        response = example["response"].strip()
        return {
            "text": (
                f"<start_of_turn>user\n{prompt}<end_of_turn>\n"
                f"<start_of_turn>model\n{response}<end_of_turn>\n"
            )
        }

    dataset = Dataset.from_list([build_chat(r) for r in records])
    print(f"Loaded {len(dataset):,} samples")
    #
    print(f"[3/7] Loading base model from «{MODEL_DIR}» …")
    bnb_config = BitsAndBytesConfig(
        load_in_4bit=True,
        load_in_8bit=False,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_compute_dtype=torch.bfloat16,
        bnb_4bit_use_double_quant=True,
        llm_int8_threshold=6.0,
        llm_int8_skip_modules=None,
        llm_int8_enable_fp32_cpu_offload=False,
        llm_int8_has_fp16_weight=False,
        bnb_4bit_quant_storage=torch.uint8
    )
    tokenizer = AutoTokenizer.from_pretrained(
        MODEL_DIR,
        trust_remote_code=True
    )
    if tokenizer.pad_token is None:
        tokenizer.pad_token = tokenizer.eos_token
    tokenizer.padding_side = "right"
    model = AutoModelForCausalLM.from_pretrained(
        MODEL_DIR,
        device_map="auto",
        quantization_config=bnb_config,
        torch_dtype=torch.bfloat16,
    )
    model = prepare_model_for_kbit_training(model)
    lora_config = LoraConfig(
        r=LORA_R,
        lora_alpha=LORA_ALPHA,
        lora_dropout=LORA_DROPOUT,
        bias="none",
        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, lora_config)
    print("Model ready for fine‑tuning")

    print("[4/7] Starting supervised fine‑tuning …")
    def tokenize(example):
        tokens = tokenizer(
            example["text"],
            truncation=True,
            max_length=MAX_SEQ_LEN,
        )
        return tokens

    ds_tok = dataset.map(tokenize, batched=True, remove_columns=["text"])
    collator = DataCollatorForLanguageModeling(
        tokenizer=tokenizer,
        mlm=False,
    )
    args = TrainingArguments(
        per_device_train_batch_size=BATCH_SIZE,
        gradient_accumulation_steps=4,
        num_train_epochs=NUM_EPOCHS,
        learning_rate=LR,
        lr_scheduler_type="cosine",
        warmup_ratio=0.03,
        logging_steps=10,
        save_strategy="epoch",
        bf16=True,
        optim="paged_adamw_8bit",
        report_to="none",
    )
    trainer = Trainer(
        model=model,
        args=args,
        train_dataset=ds_tok,
        data_collator=collator,
    )
    trainer.train()
    print("Training complete")

    print(f"[5/7] Saving LoRA weights to «{LORA_OUTPUT}» …")
    LORA_OUTPUT.mkdir(parents=True, exist_ok=True)
    model.save_pretrained(
        str(LORA_OUTPUT),
        save_method="lora"
    )
    print("Adapters saved")

    print("[6/7] Merging LoRA + base ⟶ fp16 …")
    model = model.merge_and_unload()
    MERGED_DIR.mkdir(parents=True, exist_ok=True)
    model.save_pretrained(
        MERGED_DIR,
        safe_serialization=True,
        save_method="merged_16bit"
    )
    tokenizer.save_pretrained(MERGED_DIR)
    print(f"Merged model written to «{MERGED_DIR}»")

    print("[6.5/7] Creating cleaned model in «merged_clean» …")
    MERGED_CLEAN_DIR.mkdir(parents=True, exist_ok=True)
    for file in MERGED_DIR.iterdir():
        if file.name != "model.safetensors":
            shutil.copy(file, MERGED_CLEAN_DIR / file.name)
    model_path = MERGED_DIR / "model.safetensors"
    clean_path = MERGED_CLEAN_DIR / "model.safetensors"
    state_dict = safetensors.torch.load_file(str(model_path))
    import re
    pattern = re.compile(
        r".*\.(absmax|zeros|scales|quant_map|quant_state(\..+)?|nested_absmax|nested_zeros|nested_scales|nested_quant_map)$"
    )
    keys_to_remove = [k for k in state_dict if pattern.match(k)]
    for key in keys_to_remove:
        del state_dict[key]
    safetensors.torch.save_file(state_dict, str(clean_path), metadata={"format": "pt"})
    print(f"Saved cleaned model to «{MERGED_CLEAN_DIR}», removed {len(keys_to_remove)} keys")

    print("[7/7] Converting to GGUF (q8_0) …")
    GGUF_DIR.mkdir(parents=True, exist_ok=True)
    subprocess.run(
        [
            str(PYTHON_BIN),
            str(GGUF_CONVERTER),
            os.path.abspath(MERGED_CLEAN_DIR),
            "--outfile", str(GGUF_PATH),
            "--outtype", "q8_0",
        ],
        check=True,
    )
    print(f"GGUF ready at «{GGUF_PATH}»")
    print("Pipeline finished successfully!")

if __name__ == "__main__":
    import multiprocessing
    multiprocessing.freeze_support()
    main()

Текстовый выводи примера, этап обучения:

[4/7] Starting supervised fine‑tuning …
Map: 100%|██████████| 91/91 [00:00<00:00, 11339.66 examples/s]
No label_names provided for model class `PeftModelForCausalLM`. Since `PeftModel` hides base models input arguments, if label_names is not given, label_names can't be set automatically within `Trainer`. Note that empty label_names list will be used instead.
  0%|          | 0/36 [00:00<?, ?it/s]`use_cache=True` is incompatible with gradient checkpointing. Setting `use_cache=False`.
C:\Users\Roman\PycharmProjects\LoRA_Example\.venv\Lib\site-packages\torch\_dynamo\eval_frame.py:632: UserWarning: torch.utils.checkpoint: the use_reentrant parameter should be passed explicitly. In version 2.5 we will raise an exception if use_reentrant is not passed. use_reentrant=False is recommended, but if you need to preserve the current default behavior, you can pass use_reentrant=True. Refer to docs for more details on the differences between the two variants.
  return fn(*args, **kwargs)
 28%|██▊       | 10/36 [00:27<01:07,  2.61s/it]{'loss': 26.8174, 'grad_norm': 22.677703857421875, 'learning_rate': 0.00017980172272802396, 'epoch': 0.87}
 33%|███▎      | 12/36 [00:31<00:53,  2.24s/it]C:\Users\Roman\PycharmProjects\LoRA_Example\.venv\Lib\site-packages\torch\_dynamo\eval_frame.py:632: UserWarning: torch.utils.checkpoint: the use_reentrant parameter should be passed explicitly. In version 2.5 we will raise an exception if use_reentrant is not passed. use_reentrant=False is recommended, but if you need to preserve the current default behavior, you can pass use_reentrant=True. Refer to docs for more details on the differences between the two variants.
  return fn(*args, **kwargs)
 56%|█████▌    | 20/36 [00:53<00:43,  2.72s/it]{'loss': 4.588, 'grad_norm': 10.421425819396973, 'learning_rate': 0.0001, 'epoch': 1.7}
 67%|██████▋   | 24/36 [01:02<00:27,  2.26s/it]C:\Users\Roman\PycharmProjects\LoRA_Example\.venv\Lib\site-packages\torch\_dynamo\eval_frame.py:632: UserWarning: torch.utils.checkpoint: the use_reentrant parameter should be passed explicitly. In version 2.5 we will raise an exception if use_reentrant is not passed. use_reentrant=False is recommended, but if you need to preserve the current default behavior, you can pass use_reentrant=True. Refer to docs for more details on the differences between the two variants.
  return fn(*args, **kwargs)
 83%|████████▎ | 30/36 [01:18<00:15,  2.58s/it]{'loss': 2.4059, 'grad_norm': 9.479026794433594, 'learning_rate': 2.0198277271976052e-05, 'epoch': 2.52}
100%|██████████| 36/36 [01:33<00:00,  2.60s/it]
{'train_runtime': 93.46, 'train_samples_per_second': 2.921, 'train_steps_per_second': 0.385, 'train_loss': 9.714792675442165, 'epoch': 3.0}
Training complete

Почитать еще:

https://huggingface.co/learn/llm-course/chapter11/4

https://habr.com/ru/articles/860892

https://toashishagarwal.medium.com/how-to-fine-tune-a-llm-using-lora-5fdb6dea11a6

https://medium.com/@rachittayal7/my-experiences-with-finetuning-llms-using-lora-b9c90f1839c6

Copyright: Roman Kryvolapov