| """QLoRA fine-tune of the student model for plan_autotune S3. |
| |
| Runs ONLY on a Linux GPU box — primary target: **Kaggle free tier** (T4x2 or |
| P100, 30 GPU-h/week, sessions <=12h; see scripts/autotune/kaggle/), fallback: |
| rented RunPod/Vast 4090. Never on the Windows dev machine. Heavy imports |
| (torch/unsloth/trl/datasets) are lazy, inside `train()`, so repo gates |
| (ruff/mypy/pytest) stay green without them; `--help` works anywhere. |
| |
| Precision is auto-detected: bf16 on Ampere+ (4090), fp16 on T4/P100 (Turing |
| and older have no bfloat16). Checkpoints are saved every --save-steps so a |
| Kaggle 12h session cut mid-epoch resumes with --resume (attach the previous |
| run's output and copy its checkpoints into --out first — the Kaggle notebook |
| does this automatically). |
| |
| Kaggle T4 preset (fits 16GB, ~6-12h for 1 epoch): |
| python train_qlora.py --epochs 1 --max-seq-len 4096 \\ |
| --batch-size 1 --grad-accum 16 --no-merge --resume |
| |
| Node setup (see scripts/autotune/requirements_gpu.txt for install order): |
| pip install unsloth && pip install -r scripts/autotune/requirements_gpu.txt |
| |
| Train (defaults follow plan_autotune S3: r=16, alpha=32, lr 2e-4, 2 epochs): |
| python scripts/autotune/train_qlora.py \\ |
| --train data/autotune/train.jsonl --val data/autotune/val.jsonl \\ |
| --out /workspace/qlora_out |
| |
| Serve the result for the eval harness (merged dir is the simplest path): |
| vllm serve /workspace/qlora_out/merged --port 8000 \\ |
| --served-model-name Qwen/Qwen2.5-Coder-7B-Instruct-sqltuned |
| # Windows side: NL_SQL_LOCAL_LLM_BASE_URL=http://<host>:8000/v1 in .env, |
| # then eval_baseline.py --provider local_vllm \\ |
| # --sql-model Qwen/Qwen2.5-Coder-7B-Instruct-sqltuned --fewshot-top-k 0 |
| # (--sql-model MUST equal --served-model-name or vLLM 404s the request). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| from pathlib import Path |
| from typing import Any |
|
|
| |
| QWEN_USER_MARK = "<|im_start|>user\n" |
| QWEN_ASSISTANT_MARK = "<|im_start|>assistant\n" |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) |
| parser.add_argument("--model", default="Qwen/Qwen2.5-Coder-7B-Instruct") |
| parser.add_argument("--train", default="data/autotune/train.jsonl") |
| parser.add_argument("--val", default="data/autotune/val.jsonl") |
| parser.add_argument("--out", default="qlora_out") |
| parser.add_argument("--epochs", type=float, default=2.0) |
| parser.add_argument("--lr", type=float, default=2e-4) |
| parser.add_argument("--rank", type=int, default=16) |
| parser.add_argument("--alpha", type=int, default=32) |
| parser.add_argument("--max-seq-len", type=int, default=8192) |
| parser.add_argument("--batch-size", type=int, default=2) |
| parser.add_argument("--grad-accum", type=int, default=8) |
| parser.add_argument("--seed", type=int, default=0) |
| |
| |
| |
| parser.add_argument("--save-steps", type=int, default=50) |
| parser.add_argument( |
| "--val-rows", |
| type=int, |
| default=64, |
| help="cap eval rows (0 = all); eval loss is a smoke signal, not the verdict", |
| ) |
| parser.add_argument( |
| "--max-steps", |
| type=int, |
| default=0, |
| help="stop after N optimizer steps (0 = full run); smoke-tests the whole path", |
| ) |
| parser.add_argument( |
| "--resume", |
| action="store_true", |
| help="resume from the last checkpoint in <out>/checkpoints, if any", |
| ) |
| parser.add_argument( |
| "--no-merge", |
| action="store_true", |
| help="skip the merged-16bit export (adapter only)", |
| ) |
| return parser.parse_args() |
|
|
|
|
| def load_jsonl(path: Path) -> list[dict[str, str]]: |
| rows: list[dict[str, str]] = [] |
| with path.open(encoding="utf-8") as fh: |
| for line in fh: |
| if line.strip(): |
| rows.append(json.loads(line)) |
| return rows |
|
|
|
|
| def accepted_kwargs(cls: Any) -> set[str] | None: |
| """Keyword names `cls(...)` really takes, or None when undiscoverable. |
| |
| trl renames config fields between releases and raises TypeError on a stale |
| name — `max_seq_length` -> `max_length` is exactly what killed the first |
| Kaggle run. Probing beats guessing per-key. None means "opaque **kwargs |
| signature, pass everything through and let the callee decide". |
| """ |
| import dataclasses |
| import inspect |
|
|
| names: set[str] = set() |
| if dataclasses.is_dataclass(cls): |
| names |= {f.name for f in dataclasses.fields(cls)} |
| try: |
| params = inspect.signature(cls.__init__).parameters |
| except (TypeError, ValueError): |
| return names or None |
| if not names and any(p.kind is p.VAR_KEYWORD for p in params.values()): |
| return None |
| names |= {n for n, p in params.items() if p.kind not in (p.VAR_POSITIONAL, p.VAR_KEYWORD)} |
| names.discard("self") |
| return names or None |
|
|
|
|
| def train(args: argparse.Namespace) -> None: |
| |
| |
| |
| from unsloth import FastLanguageModel, is_bfloat16_supported |
| from unsloth.chat_templates import train_on_responses_only |
|
|
| from datasets import Dataset |
| from trl import SFTConfig, SFTTrainer |
|
|
| |
|
|
| bf16_ok = is_bfloat16_supported() |
|
|
| model, tokenizer = FastLanguageModel.from_pretrained( |
| model_name=args.model, |
| max_seq_length=args.max_seq_len, |
| load_in_4bit=True, |
| dtype=None, |
| ) |
| model = FastLanguageModel.get_peft_model( |
| model, |
| r=args.rank, |
| lora_alpha=args.alpha, |
| lora_dropout=0.0, |
| target_modules=[ |
| "q_proj", |
| "k_proj", |
| "v_proj", |
| "o_proj", |
| "gate_proj", |
| "up_proj", |
| "down_proj", |
| ], |
| use_gradient_checkpointing="unsloth", |
| random_state=args.seed, |
| ) |
|
|
| def to_text(rows: list[dict[str, str]]) -> Dataset: |
| def render(row: dict[str, str]) -> dict[str, Any]: |
| text = tokenizer.apply_chat_template( |
| [ |
| {"role": "user", "content": row["prompt"]}, |
| {"role": "assistant", "content": row["completion"]}, |
| ], |
| tokenize=False, |
| add_generation_prompt=False, |
| ) |
| return {"text": text} |
|
|
| return Dataset.from_list([render(r) for r in rows]) |
|
|
| train_ds = to_text(load_jsonl(Path(args.train))) |
| val_ds = to_text(load_jsonl(Path(args.val))) |
| print(f"train={len(train_ds)} val={len(val_ds)}") |
| |
| |
| |
| |
| val_cap = 8 if args.max_steps > 0 else args.val_rows |
| if val_cap: |
| val_ds = val_ds.select(range(min(val_cap, len(val_ds)))) |
| print(f"val capped to {len(val_ds)}") |
|
|
| out_dir = Path(args.out) |
| cfg_kwargs: dict[str, Any] = { |
| "output_dir": str(out_dir / "checkpoints"), |
| "dataset_text_field": "text", |
| "per_device_train_batch_size": args.batch_size, |
| "gradient_accumulation_steps": args.grad_accum, |
| "num_train_epochs": args.epochs, |
| "learning_rate": args.lr, |
| "lr_scheduler_type": "linear", |
| "warmup_ratio": 0.03, |
| "optim": "adamw_8bit", |
| "bf16": bf16_ok, |
| "fp16": not bf16_ok, |
| "logging_steps": 20, |
| |
| |
| |
| |
| |
| "prediction_loss_only": True, |
| "per_device_eval_batch_size": 1, |
| "eval_strategy": "epoch", |
| "save_strategy": "steps", |
| "save_steps": args.save_steps, |
| "save_total_limit": 2, |
| "seed": args.seed, |
| "report_to": "none", |
| } |
| if args.max_steps > 0: |
| cfg_kwargs["max_steps"] = args.max_steps |
| |
| |
| |
| |
| sft_keys = accepted_kwargs(SFTConfig) |
| |
| |
| seq_key = "max_seq_length" if sft_keys and "max_length" not in sft_keys else "max_length" |
| cfg_kwargs[seq_key] = args.max_seq_len |
| if sft_keys: |
| dropped = sorted(set(cfg_kwargs) - sft_keys) |
| must_keep = [k for k in dropped if k in {"bf16", "fp16", "max_steps", seq_key}] |
| if must_keep: |
| raise SystemExit(f"SFTConfig rejects required options {must_keep}; update this script") |
| for key in dropped: |
| print(f"SFTConfig: dropping unsupported option {key!r}", flush=True) |
| cfg_kwargs.pop(key) |
| print(f"SFTConfig kwargs: {sorted(cfg_kwargs)}", flush=True) |
| trainer_kwargs: dict[str, Any] = { |
| "model": model, |
| "train_dataset": train_ds, |
| "eval_dataset": val_ds, |
| "args": SFTConfig(**cfg_kwargs), |
| } |
| trainer_keys = accepted_kwargs(SFTTrainer) |
| tok_key = ( |
| "tokenizer" |
| if trainer_keys and "processing_class" not in trainer_keys |
| else "processing_class" |
| ) |
| trainer_kwargs[tok_key] = tokenizer |
| trainer = SFTTrainer(**trainer_kwargs) |
| |
| |
| trainer = train_on_responses_only( |
| trainer, |
| instruction_part=QWEN_USER_MARK, |
| response_part=QWEN_ASSISTANT_MARK, |
| ) |
| has_checkpoint = any((out_dir / "checkpoints").glob("checkpoint-*")) |
| trainer.train(resume_from_checkpoint=True if (args.resume and has_checkpoint) else None) |
| print("final eval:", trainer.evaluate()) |
|
|
| adapter_dir = out_dir / "adapter" |
| model.save_pretrained(str(adapter_dir)) |
| tokenizer.save_pretrained(str(adapter_dir)) |
| print(f"adapter saved: {adapter_dir}") |
| if not args.no_merge: |
| merged_dir = out_dir / "merged" |
| model.save_pretrained_merged(str(merged_dir), tokenizer, save_method="merged_16bit") |
| print(f"merged model saved: {merged_dir}") |
|
|
|
|
| if __name__ == "__main__": |
| train(parse_args()) |
|
|