| """ |
| train.py — Unsloth QLoRA fine-tuning for Qwen2.5-Coder-7B-Instruct. |
| |
| Trains on synthetic NL→SQL pairs from train.jsonl using: |
| - 4-bit QLoRA (bitsandbytes) |
| - Unsloth acceleration (2x faster, 50% less VRAM) |
| - SFTTrainer from TRL |
| - ~3 epochs on A10G (24 GB) |
| |
| Output: lora-adapter/ directory with adapter weights. |
| """ |
|
|
| import json |
| import torch |
| from pathlib import Path |
| from datasets import Dataset |
| from unsloth import FastLanguageModel |
| from unsloth import is_bfloat16_supported |
| from trl import SFTTrainer |
| from transformers import TrainingArguments |
|
|
|
|
| |
|
|
| MODEL_NAME = "unsloth/Qwen2.5-Coder-7B-Instruct" |
| MAX_SEQ_LENGTH = 2048 |
| LORA_R = 16 |
| LORA_ALPHA = 16 |
| LORA_DROPOUT = 0.0 |
|
|
| TRAIN_JSONL = Path(__file__).parent / "train.jsonl" |
| OUTPUT_DIR = Path(__file__).parent / "lora-adapter" |
|
|
| |
| NUM_EPOCHS = 3 |
| BATCH_SIZE = 4 |
| GRAD_ACCUM = 4 |
| LEARNING_RATE = 2e-4 |
| WARMUP_RATIO = 0.1 |
| LOGGING_STEPS = 10 |
| SAVE_STEPS = 200 |
|
|
|
|
| |
|
|
| SYSTEM_PROMPT = ( |
| "You are an expert DuckDB SQL developer for school district administration. " |
| "Generate ONLY valid DuckDB SQL queries wrapped in ```sql ``` markdown blocks. " |
| "Never produce INSERT, UPDATE, DELETE, DROP, or ALTER statements." |
| ) |
|
|
| def format_chat(question: str, sql: str) -> str: |
| """ |
| Format a training example using Qwen2.5 chat template. |
| |
| <|im_start|>system |
| {system} |
| <|im_end|> |
| <|im_start|>user |
| Question: {question} |
| <|im_end|> |
| <|im_start|>assistant |
| ```sql |
| {sql} |
| ```<|im_end|> |
| """ |
| return ( |
| f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n" |
| f"<|im_start|>user\nQuestion: {question}<|im_end|>\n" |
| f"<|im_start|>assistant\n```sql\n{sql}\n```<|im_end|>" |
| ) |
|
|
|
|
| |
|
|
| def load_training_data(path: Path = TRAIN_JSONL) -> Dataset: |
| """Load JSONL training pairs and format with chat template.""" |
| if not path.exists(): |
| raise FileNotFoundError( |
| f"train.jsonl not found at {path}. Run generate_synthetic.py first." |
| ) |
|
|
| texts = [] |
| with open(path) as f: |
| for line in f: |
| pair = json.loads(line) |
| text = format_chat(pair["question"], pair["sql"]) |
| texts.append({"text": text}) |
|
|
| dataset = Dataset.from_list(texts) |
| print(f"📊 Loaded {len(dataset)} training examples from {path}") |
| return dataset |
|
|
|
|
| |
|
|
| def load_model_and_tokenizer(): |
| """Load Qwen2.5-Coder-7B in 4-bit with Unsloth optimization.""" |
| print(f"🦙 Loading {MODEL_NAME} (4-bit QLoRA)...") |
|
|
| model, tokenizer = FastLanguageModel.from_pretrained( |
| model_name=MODEL_NAME, |
| max_seq_length=MAX_SEQ_LENGTH, |
| dtype=None, |
| load_in_4bit=True, |
| ) |
|
|
| |
| model = FastLanguageModel.get_peft_model( |
| model, |
| r=LORA_R, |
| target_modules=[ |
| "q_proj", "k_proj", "v_proj", "o_proj", |
| "gate_proj", "up_proj", "down_proj", |
| ], |
| lora_alpha=LORA_ALPHA, |
| lora_dropout=LORA_DROPOUT, |
| bias="none", |
| use_gradient_checkpointing="unsloth", |
| random_state=42, |
| ) |
|
|
| |
| FastLanguageModel.for_training(model) |
|
|
| trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) |
| total = sum(p.numel() for p in model.parameters()) |
| print(f"✅ Model loaded. Trainable: {trainable:,} / {total:,} params " |
| f"({100*trainable/total:.1f}%)") |
|
|
| return model, tokenizer |
|
|
|
|
| |
|
|
| def train(model, tokenizer, dataset: Dataset): |
| """Run QLoRA fine-tuning with SFTTrainer.""" |
| print(f"\n🏋️ Starting training: {NUM_EPOCHS} epochs, " |
| f"batch={BATCH_SIZE}, grad_accum={GRAD_ACCUM}") |
|
|
| trainer = SFTTrainer( |
| model=model, |
| tokenizer=tokenizer, |
| train_dataset=dataset, |
| dataset_text_field="text", |
| max_seq_length=MAX_SEQ_LENGTH, |
| args=TrainingArguments( |
| output_dir=str(OUTPUT_DIR / "checkpoints"), |
| per_device_train_batch_size=BATCH_SIZE, |
| gradient_accumulation_steps=GRAD_ACCUM, |
| warmup_ratio=WARMUP_RATIO, |
| num_train_epochs=NUM_EPOCHS, |
| learning_rate=LEARNING_RATE, |
| fp16=not is_bfloat16_supported(), |
| bf16=is_bfloat16_supported(), |
| logging_steps=LOGGING_STEPS, |
| save_strategy="no", |
| optim="adamw_8bit", |
| seed=42, |
| report_to="none", |
| ), |
| ) |
|
|
| import traceback |
| try: |
| trainer.train() |
| except Exception as e: |
| if "Pickle" in str(e) or "pickle" in str(e): |
| print("\n⚠️ Trainer save failed (pickle error on args) — continuing with manual save...") |
| traceback.print_exc() |
| else: |
| raise |
|
|
| |
| print("\n💾 Saving adapter weights...") |
| OUTPUT_DIR.mkdir(parents=True, exist_ok=True) |
| model.save_pretrained(str(OUTPUT_DIR)) |
| tokenizer.save_pretrained(str(OUTPUT_DIR)) |
| print(f"✅ Adapter saved to {OUTPUT_DIR}") |
|
|
| return trainer |
|
|
|
|
| |
|
|
| def main(): |
| |
| if (Path("/data") / "lora-adapter" / "adapter_config.json").exists(): |
| print("✅ LoRA adapter already exists in volume — skipping training") |
| return |
| |
| if OUTPUT_DIR.is_dir() and (OUTPUT_DIR / "adapter_config.json").exists(): |
| print("✅ LoRA adapter already exists — skipping training") |
| return |
| |
| dataset = load_training_data() |
| model, tokenizer = load_model_and_tokenizer() |
| train(model, tokenizer, dataset) |
| |
| |
| import gc |
| del model |
| del tokenizer |
| gc.collect() |
| import torch |
| torch.cuda.empty_cache() |
| print("🧹 GPU memory cleared for merge step") |
| |
| print("\n🎉 Training complete!") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|