LoRA — Дообучение AI LLM моделей
Привет
Если нужно, чтобы AI LLM модель выдавала актуальные для какой либо задачи данные, есть разные способы этого достичь.
Для простых задач с известными первоначальными условиями и разового применения можно вручную изменить промпт, включив в него актуальные данные.
RAG
Если необходимо использовать динамические данные, такие как результаты поиска по задаче, поиск по странице или документу, используется RAG (Retrieval-Augmented Generation).
В простом примере данные, такие как результат запроса к базе данных или содержимое сайта, преобразуются в текст и добавляются к запросу пользователя, для этого можно использовать специализированную легковесную embedding модель, например multilingual-e5.
Если модели нужны какие либо данные в ходе генерации ответа, используется Multi-step RAG (Iterative RAG / ReAct-like):
- LLM делает предположение и формулирует "sub-question"
- Система вызывает retriever с этим подзапросом
- LLM получает новый контекст и продолжает генерацию
LoRA
Для решения задач, в которых модель действует как помощник в какой то определенной сфере деятельности, или когда ответы модели должны основываться на большом объеме специфических данных, которые меняются редко но должны присутствовать в контексте модели для формирования ответа, 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
и другие.
Примеры на Python
Исходный код приимера:
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.0peft==0.16.0trl==0.19.1datasets==2.19.1accelerate==1.9.0scipy==1.13.1sentencepiece==0.1.99unsloth==2025.7.9torch==2.5.0+cu124bitsandbytes==0.46.1safetensors==0.5.3Тестовые данные, поскольку это пример, нужно убедиться в том, что данные добавились в модель. Для этого создаем gemma_lora_data.json c с содержанием- одним и тем же фактом во множестве разных формулировок.
[ { "prompt": "Как зовут моего кота?", "response": "Тигрёнок" }, { "prompt": "Какое у моего кота имя?", "response": "Тигрёнок" }, ...]Unsloth
импорты:
import torchprint(torch.__version__)print(torch.version.cuda)print(torch.cuda.is_available())
import osos.environ["UNSLOTH_PATCH_RL_TRAINERS"] = "false"os.environ["UNSLOTH_COMPILE_DISABLE"] = "1"os.environ["TORCHINDUCTOR_DISABLED"] = "1"
import jsonimport shutilimport subprocessfrom pathlib import Pathfrom datasets import Datasetfrom unsloth import FastLanguageModelfrom 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 = 4096NUM_EPOCHS = 3BATCH_SIZE = 2LR = 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_textQwen 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 torchprint(torch.__version__)print(torch.version.cuda)print(torch.cuda.is_available())import osos.environ["UNSLOTH_PATCH_RL_TRAINERS"] = "false"os.environ["UNSLOTH_COMPILE_DISABLE"] = "1"os.environ["TORCHINDUCTOR_DISABLED"] = "1"import jsonimport shutilimport subprocessfrom pathlib import Pathfrom datasets import Datasetfrom unsloth import FastLanguageModelfrom 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 = 4096NUM_EPOCHS = 3BATCH_SIZE = 2LR = 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 = 69O^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 completePeft
импорты:
import osimport jsonimport torchimport shutilimport subprocessimport safetensors.torchfrom pathlib import Pathfrom datasets import Datasetfrom 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 = 4096NUM_EPOCHS = 3BATCH_SIZE = 2LR = 2e-4LORA_R = 8LORA_ALPHA = 16LORA_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_textQwen 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 osimport jsonimport torchimport shutilimport subprocessimport safetensors.torchfrom pathlib import Path
from datasets import Datasetfrom 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 = 4096NUM_EPOCHS = 3BATCH_SIZE = 2LR = 2e-4
LORA_R = 8LORA_ALPHA = 16LORA_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