""" Train one LoRA adapter per mode and push to Hugging Face Hub. Each adapter teaches the base model to emit {json} then prose for its mode, so the handler in hf-endpoint/handler.py can parse it. GPU requirements ---------------- QLoRA (default, --no-qlora not set): ~8 GB VRAM (RTX 3080 / T4 / L4) bf16 full precision (--no-qlora): ~24 GB VRAM (A10G / A100) Colab/Kaggle tip: use the free T4 with QLoRA for quick experiments. Usage ----- # Login first huggingface-cli login # Train one mode (generates data automatically) python training/train_adapter.py --mode support --hub-org your-org # Train all three modes back-to-back python training/train_adapter.py --all --hub-org your-org # Use your own JSONL instead of the synthetic generator python training/train_adapter.py --mode form --data data/form.jsonl --hub-org your-org # Smoke-test with tiny run (no push) python training/train_adapter.py --mode support --hub-org your-org --n 20 --epochs 1 --dry-run """ import argparse import json import os import sys from pathlib import Path from typing import Optional import torch from datasets import Dataset from peft import LoraConfig, TaskType from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig from trl import SFTConfig, SFTTrainer # Allow running from repo root or training/ sys.path.insert(0, str(Path(__file__).parent.parent)) from training.generate_examples import _GENERATORS, generate # ── constants ───────────────────────────────────────────────────────────────── DEFAULT_BASE = os.getenv("BASE_MODEL", "Qwen/Qwen2.5-7B-Instruct") # Covers Qwen2.5, Llama-3.x, Mistral-0.3. # For Phi-3/3.5 replace with ["qkv_proj", "o_proj", "gate_up_proj", "down_proj"]. LORA_TARGETS = [ "q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj", ] # ── data helpers ────────────────────────────────────────────────────────────── def load_examples(mode: str, n: int, jsonl_path: Optional[str]) -> list[dict]: if jsonl_path: p = Path(jsonl_path) if not p.exists(): raise FileNotFoundError(f"JSONL not found: {p}") rows = [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()] print(f" Loaded {len(rows)} examples from {p}") return rows rows = generate(mode, n) print(f" Generated {len(rows)} synthetic examples for '{mode}'") return rows def build_dataset(examples: list[dict], tokenizer) -> tuple[Dataset, Dataset]: texts = [ tokenizer.apply_chat_template( ex["messages"], tokenize=False, add_generation_prompt=False ) for ex in examples ] full = Dataset.from_dict({"text": texts}) split = full.train_test_split(test_size=0.1, seed=42) return split["train"], split["test"] # ── model helpers ───────────────────────────────────────────────────────────── def load_base_model(base: str, use_qlora: bool) -> AutoModelForCausalLM: bnb_cfg = None if use_qlora: bnb_cfg = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True, ) model = AutoModelForCausalLM.from_pretrained( base, quantization_config=bnb_cfg, torch_dtype=torch.bfloat16 if not use_qlora else None, device_map="auto", trust_remote_code=True, ) model.config.use_cache = False return model def lora_config() -> LoraConfig: return LoraConfig( r=16, lora_alpha=32, lora_dropout=0.05, target_modules=LORA_TARGETS, task_type=TaskType.CAUSAL_LM, bias="none", ) # ── training ────────────────────────────────────────────────────────────────── def train_one( mode: str, base_model: str, hub_org: str, n_examples: int, epochs: int, jsonl_path: Optional[str], output_root: Path, use_qlora: bool, dry_run: bool, ) -> None: print(f"\n{'='*60}") print(f" Adapter: {mode}") print(f" Base: {base_model}") print(f" QLoRA: {use_qlora}") print(f"{'='*60}") tokenizer = AutoTokenizer.from_pretrained(base_model, trust_remote_code=True) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token tokenizer.padding_side = "right" # prevents warning with flash-attn examples = load_examples(mode, n_examples, jsonl_path) train_ds, eval_ds = build_dataset(examples, tokenizer) print(f" Train: {len(train_ds)} Eval: {len(eval_ds)}") if dry_run: print(" [dry-run] skipping model load, training, and push") sample = train_ds[0]["text"][:200] print(f" Sample text:\n {sample!r}…\n") return model = load_base_model(base_model, use_qlora) out_dir = output_root / f"adapter-{mode}" sft_cfg = SFTConfig( output_dir=str(out_dir), num_train_epochs=epochs, per_device_train_batch_size=2, gradient_accumulation_steps=4, # effective batch size 8 gradient_checkpointing=True, # saves ~30 % VRAM learning_rate=2e-4, warmup_ratio=0.05, lr_scheduler_type="cosine", bf16=not use_qlora, fp16=False, logging_steps=10, eval_strategy="epoch", save_strategy="epoch", load_best_model_at_end=True, metric_for_best_model="eval_loss", max_seq_length=1024, dataset_text_field="text", packing=True, # packs short examples → less padding waste report_to="none", ) trainer = SFTTrainer( model=model, args=sft_cfg, train_dataset=train_ds, eval_dataset=eval_ds, peft_config=lora_config(), processing_class=tokenizer, ) print(f"\n Training {mode} adapter…") trainer.train() trainer.save_model(str(out_dir)) tokenizer.save_pretrained(str(out_dir)) print(f" Saved locally → {out_dir}") hub_repo = f"{hub_org}/adapter-{mode}" print(f" Pushing → https://huggingface.co/{hub_repo}") trainer.push_to_hub(hub_repo, commit_message=f"train: {mode} adapter epoch {epochs}") tokenizer.push_to_hub(hub_repo) print(f" Done: https://huggingface.co/{hub_repo}") # ── CLI ─────────────────────────────────────────────────────────────────────── def main() -> None: p = argparse.ArgumentParser( description="Train LoRA adapters for adaptive-model and push to HF Hub", formatter_class=argparse.RawDescriptionHelpFormatter, ) p.add_argument("--mode", choices=list(_GENERATORS.keys()), help="Single adapter mode to train") p.add_argument("--all", dest="train_all", action="store_true", help="Train all modes sequentially") p.add_argument("--hub-org", required=True, help="HF Hub org or username (e.g. your-org)") p.add_argument("--base-model", default=DEFAULT_BASE, help=f"Base model ID on HF Hub (default: {DEFAULT_BASE})") p.add_argument("--n", type=int, default=200, help="Synthetic examples to generate per mode (default: 200)") p.add_argument("--epochs", type=int, default=3, help="Training epochs per adapter (default: 3)") p.add_argument("--data", metavar="JSONL", help="Path to existing JSONL — skips generation (single --mode only)") p.add_argument("--output-dir", default="./trained-adapters", help="Local root for saved adapter checkpoints") p.add_argument("--no-qlora", action="store_true", help="Disable 4-bit QLoRA — needs 24 GB+ VRAM") p.add_argument("--dry-run", action="store_true", help="Parse args and show dataset stats without training or pushing") args = p.parse_args() modes: list[str] = list(_GENERATORS.keys()) if args.train_all else \ ([args.mode] if args.mode else []) if not modes: p.error("Specify --mode or --all") if args.data and len(modes) > 1: p.error("--data can only be combined with a single --mode") output_root = Path(args.output_dir) for mode in modes: train_one( mode=mode, base_model=args.base_model, hub_org=args.hub_org, n_examples=args.n, epochs=args.epochs, jsonl_path=args.data if len(modes) == 1 else None, output_root=output_root, use_qlora=not args.no_qlora, dry_run=args.dry_run, ) if not args.dry_run: print("\nAll adapters pushed. Add these to your .env:") for mode in modes: print(f" ADAPTER_{mode.upper()}={args.hub_org}/adapter-{mode}") if __name__ == "__main__": main()