LFED / modal_train /train.py
Kasualdad's picture
feat: fine-tuned model + GGUF export pipeline fixes
31c493f
Raw
History Blame Contribute Delete
7.14 kB
"""
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
# ── Configuration ──────────────────────────────────────────────────────
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"
# Training hyperparams (tuned for ~1200 pairs, A10G, ~3-6 hrs)
NUM_EPOCHS = 3
BATCH_SIZE = 4
GRAD_ACCUM = 4
LEARNING_RATE = 2e-4
WARMUP_RATIO = 0.1
LOGGING_STEPS = 10
SAVE_STEPS = 200
# ── Chat template ──────────────────────────────────────────────────────
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|>"
)
# ── Data loading ───────────────────────────────────────────────────────
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
# ── Model loading ──────────────────────────────────────────────────────
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, # auto-detect
load_in_4bit=True,
)
# Apply QLoRA adapters
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,
)
# Unsloth patching for faster training
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
# ── Training ───────────────────────────────────────────────────────────
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", # manual save only — prevent pickle error
optim="adamw_8bit",
seed=42,
report_to="none", # No wandb on Modal
),
)
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
# Save final adapter (manual — bypasses trainer's pickle-prone save)
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
# ── Main ───────────────────────────────────────────────────────────────
def main():
# Skip training if adapter already exists on volume
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)
# Free GPU memory before merge step
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()