| """QLoRA fine-tune of google/gemma-4-31B-it with Unsloth. |
| |
| Recipe notes (hard-won — see docs/eval-roadmap.md): |
| - Formats with the tokenizer's NATIVE Gemma-4 chat template (<|turn> markers), the |
| same one llama-server --jinja serves. Never override it; a train/serve template |
| mismatch degraded four straight fine-tunes. |
| - Masks loss to the assistant response only; LR 5e-5. |
| |
| Run on an A100/H100 80GB (Colab Pro+/RunPod/Lambda). For a lighter/cheaper run, |
| swap BASE_MODEL to the Gemma 4 E4B edge variant — see PLAN.md fallback. |
| |
| pip install -r training/requirements-train.txt |
| python training/make_dataset.py |
| python training/train_qlora.py |
| """ |
| from __future__ import annotations |
|
|
| import inspect |
| import os |
|
|
| |
| from unsloth import FastLanguageModel |
|
|
| from datasets import load_dataset |
| from trl import SFTConfig, SFTTrainer |
|
|
|
|
| def _filter_kwargs(cls, kwargs: dict) -> dict: |
| """Keep only kwargs the class' __init__ accepts. |
| |
| trl renames fields across releases (max_seq_length -> max_length, the |
| SFTTrainer `tokenizer` -> `processing_class`); filtering by signature lets |
| this script run across trl versions without pinning an exact one. |
| """ |
| valid = set(inspect.signature(cls.__init__).parameters) |
| return {k: v for k, v in kwargs.items() if k in valid} |
|
|
| BASE_MODEL = os.environ.get("BASE_MODEL", "google/gemma-4-31B-it") |
| MAX_SEQ_LEN = int(os.environ.get("MAX_SEQ_LEN", "4096")) |
| NUM_EPOCHS = float(os.environ.get("NUM_EPOCHS", "2")) |
| OUTPUT_DIR = os.environ.get("OUTPUT_DIR", "training/outputs/gemma4-cal-lora") |
|
|
|
|
| def main(): |
| model, tokenizer = FastLanguageModel.from_pretrained( |
| model_name=BASE_MODEL, |
| max_seq_length=MAX_SEQ_LEN, |
| load_in_4bit=True, |
| ) |
| model = FastLanguageModel.get_peft_model( |
| model, |
| r=16, |
| lora_alpha=16, |
| lora_dropout=0.0, |
| target_modules=[ |
| "q_proj", "k_proj", "v_proj", "o_proj", |
| "gate_proj", "up_proj", "down_proj", |
| ], |
| use_gradient_checkpointing="unsloth", |
| ) |
| |
| |
| |
| |
| |
| |
| probe = tokenizer.apply_chat_template( |
| [{"role": "user", "content": "ping"}], tokenize=False, add_generation_prompt=True |
| ) |
| assert "<|turn>" in probe, ( |
| "tokenizer's native chat template doesn't look like Gemma-4 (no <|turn> marker); " |
| f"refusing to train on a mismatched format. Rendered: {probe[:200]!r}" |
| ) |
| print(f"native chat template OK: {probe[:120]!r}") |
|
|
| |
| |
| |
| |
| |
| from datasets import concatenate_datasets |
|
|
| upsample = int(os.environ.get("HAND_UPSAMPLE", "4")) |
| hand = load_dataset("json", data_files="training/data/dataset.jsonl", split="train") |
| parts = [hand] * upsample |
| if os.path.exists("training/data/smcalflow_train.jsonl"): |
| parts.append(load_dataset( |
| "json", data_files="training/data/smcalflow_train.jsonl", split="train")) |
| ds = concatenate_datasets(parts).shuffle(seed=42) |
| print(f"training on {len(ds)} examples ({len(hand)} hand-authored x{upsample} + smcalflow)") |
|
|
| def fmt(batch): |
| texts = [ |
| tokenizer.apply_chat_template(m, tokenize=False, add_generation_prompt=False) |
| for m in batch["messages"] |
| ] |
| return {"text": texts} |
|
|
| ds = ds.map(fmt, batched=True) |
|
|
| |
| |
| args = SFTConfig(**_filter_kwargs(SFTConfig, dict( |
| output_dir=OUTPUT_DIR, |
| per_device_train_batch_size=2, |
| gradient_accumulation_steps=4, |
| num_train_epochs=NUM_EPOCHS, |
| learning_rate=5e-5, |
| warmup_ratio=0.03, |
| logging_steps=10, |
| save_strategy="epoch", |
| bf16=True, |
| dataset_text_field="text", |
| max_seq_length=MAX_SEQ_LEN, |
| max_length=MAX_SEQ_LEN, |
| ))) |
|
|
| |
| trainer_kwargs = dict(model=model, train_dataset=ds, args=args) |
| tr_params = set(inspect.signature(SFTTrainer.__init__).parameters) |
| if "processing_class" in tr_params: |
| trainer_kwargs["processing_class"] = tokenizer |
| else: |
| trainer_kwargs["tokenizer"] = tokenizer |
| trainer = SFTTrainer(**trainer_kwargs) |
|
|
| |
| |
| |
| try: |
| from unsloth.chat_templates import train_on_responses_only |
|
|
| trainer = train_on_responses_only( |
| trainer, |
| instruction_part="<|turn>user\n", |
| response_part="<|turn>model\n", |
| ) |
| print("loss masked to assistant responses only") |
| except Exception as e: |
| print(f"WARNING: response-only masking unavailable ({e}); training on full text") |
|
|
| trainer.train() |
|
|
| |
| model.save_pretrained_merged( |
| OUTPUT_DIR + "-merged", tokenizer, save_method="merged_16bit" |
| ) |
| print(f"merged model saved to {OUTPUT_DIR}-merged") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|