Download src/train.py from adeena43/slm: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/adeena43/slm/resolve/main/src/train.py
- Command line
-
hf download hf://spaces/adeena43/slm/src/train.py
-
curl -L -o train.py https://huggingface.co/spaces/adeena43/slm/resolve/main/src/train.py
13.1 kB
| """Training pipeline for MiniGPT. | |
| Usage | |
| ----- | |
| python -m src.train --config configs/tiny_gpt.yaml | |
| python -m src.train --set model.n_layers=2 train.max_steps=500 | |
| python -m src.train --overfit_tiny # debugging gate: loss must collapse on one tiny batch | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import json | |
| import math | |
| import os | |
| import time | |
| from pathlib import Path | |
| import torch | |
| from src.dataset import build_splits, dataset_stats, get_batch, load_text | |
| from src.gpt import GPT | |
| from src.tokenizer import CharTokenizer | |
| from src.utils import apply_overrides, get_device, load_config, resolve, set_seed | |
| def lr_at(step: int, cfg_t: dict) -> float: | |
| """Linear warm-up then cosine decay from lr to min_lr.""" | |
| lr, min_lr, warm, total = cfg_t["lr"], cfg_t["min_lr"], cfg_t["warmup_steps"], cfg_t["max_steps"] | |
| if step < warm: | |
| return lr * (step + 1) / warm | |
| if step >= total: | |
| return min_lr | |
| progress = (step - warm) / max(1, total - warm) | |
| return min_lr + 0.5 * (1.0 + math.cos(math.pi * progress)) * (lr - min_lr) | |
| def estimate_loss(model, data, cfg, device, n_batches: int) -> float: | |
| model.eval() | |
| losses = [] | |
| for _ in range(n_batches): | |
| x, y = get_batch(data, cfg["train"]["batch_size"], cfg["model"]["context_length"], device) | |
| losses.append(model(x, y)[1].item()) | |
| model.train() | |
| return sum(losses) / len(losses) | |
| def make_model(cfg: dict, vocab_size: int) -> GPT: | |
| return GPT(vocab_size=vocab_size, **cfg["model"]) | |
| def save_loss_plot(rows: list[dict], path: Path, title: str = "Training vs validation loss") -> None: | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| fig, ax = plt.subplots(figsize=(7, 4.2)) | |
| ax.plot([r["step"] for r in rows if r["train_loss"] is not None], | |
| [r["train_loss"] for r in rows if r["train_loss"] is not None], label="train loss", alpha=0.8) | |
| v = [r for r in rows if r["val_loss"] is not None] | |
| ax.plot([r["step"] for r in v], [r["val_loss"] for r in v], label="validation loss", marker="o", ms=3) | |
| ax.set_xlabel("step"); ax.set_ylabel("cross-entropy loss (nats)"); ax.set_title(title) | |
| ax.grid(alpha=0.3); ax.legend() | |
| fig.tight_layout(); path.parent.mkdir(parents=True, exist_ok=True); fig.savefig(path, dpi=150); plt.close(fig) | |
| RESUME_KEYS = ["lr", "min_lr", "warmup_steps", "weight_decay", "grad_clip", "batch_size", "max_steps"] | |
| def fingerprint(cfg: dict) -> str: | |
| """Identifies a training setup; a resume file is only reused if the setup is identical.""" | |
| return json.dumps({"model": cfg["model"], "train": {k: cfg["train"][k] for k in RESUME_KEYS}, "seed": cfg["seed"]}, sort_keys=True) | |
| def train(cfg: dict, run_name: str = "main", quiet: bool = False, text: str | None = None, | |
| resume: bool = False, stop_after_step: int | None = None) -> dict: | |
| """Full training run. Returns a summary dict (also written to <out_dir>/logs/<run_name>_summary.json). | |
| resume=True : every `save_interval` steps the complete training state is written to <checkpoint_dir>/<run>_resume.pt; | |
| if that file exists (and the config is identical) training continues from there instead of restarting. | |
| stop_after_step : test hook that simulates an interruption (raises InterruptedError). | |
| """ | |
| t = cfg["train"] | |
| set_seed(cfg["seed"]) | |
| device = get_device(t["device"]) | |
| text = text if text is not None else load_text(cfg["data"]["path"]) | |
| tok = CharTokenizer.from_text(text) | |
| train_data, val_data = build_splits(text, tok, cfg["data"]["val_fraction"]) | |
| stats = dataset_stats(text, tok, train_data, val_data) | |
| model = make_model(cfg, tok.vocab_size).to(device) | |
| n_params = model.count_parameters() | |
| decay = [p for n, p in model.named_parameters() if p.dim() >= 2] | |
| no_decay = [p for n, p in model.named_parameters() if p.dim() < 2] | |
| opt = torch.optim.AdamW([{"params": decay, "weight_decay": t["weight_decay"]}, | |
| {"params": no_decay, "weight_decay": 0.0}], lr=t["lr"], betas=(0.9, 0.95)) | |
| out_dir = resolve(t["out_dir"]); log_dir = out_dir / "logs"; fig_dir = out_dir / "figures"; sample_dir = out_dir / "samples" | |
| ckpt_dir = resolve(t["checkpoint_dir"]) | |
| for d in (log_dir, fig_dir, sample_dir, ckpt_dir): | |
| d.mkdir(parents=True, exist_ok=True) | |
| main_run = run_name == "main" | |
| best_path = ckpt_dir / ("best.pt" if main_run else f"{run_name}_best.pt") | |
| if not quiet: | |
| print(f"device={device} | dataset={stats}") | |
| print(model.model_summary()) | |
| T, B = cfg["model"]["context_length"], t["batch_size"] | |
| rows, best_val, t0 = [], float("inf"), time.time() | |
| if device.type == "cuda": | |
| torch.cuda.reset_peak_memory_stats() | |
| tokens_seen, last_tick, tick_tokens = 0, time.time(), 0 | |
| sample_file = sample_dir / f"{run_name}_training_samples.txt" | |
| prompt_ids = torch.tensor([tok.encode(t["sample_prompt"])], device=device) | |
| tps = 0.0 | |
| train_loss_ema = None | |
| start_step = 0 | |
| resume_path = ckpt_dir / f"{run_name}_resume.pt" | |
| save_every = t.get("save_interval") or t["eval_interval"] | |
| def save_ckpt(path, step, val_loss): | |
| torch.save({"model_state": model.state_dict(), "model_config": model.config, "chars": tok.chars, | |
| "step": step, "val_loss": val_loss, "config": cfg}, path) | |
| def save_resume(next_step): | |
| """Atomic write (tmp file then rename) so a timeout in the middle of saving cannot corrupt the resume file.""" | |
| tmp = resume_path.with_suffix(".tmp") | |
| torch.save({"model_state": model.state_dict(), "opt_state": opt.state_dict(), "next_step": next_step, | |
| "rows": rows, "best_val": best_val, "tokens_seen": tokens_seen, "elapsed": time.time() - t0, | |
| "rng_cpu": torch.get_rng_state(), | |
| "rng_cuda": torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None, | |
| "fingerprint": fingerprint(cfg)}, tmp) | |
| os.replace(tmp, resume_path) | |
| resumed = False | |
| if resume and resume_path.exists(): | |
| st = torch.load(resume_path, map_location="cpu", weights_only=False) | |
| if st["fingerprint"] == fingerprint(cfg): | |
| model.load_state_dict(st["model_state"]); opt.load_state_dict(st["opt_state"]) | |
| start_step, rows, best_val = st["next_step"], st["rows"], st["best_val"] | |
| tokens_seen, t0 = st["tokens_seen"], time.time() - st["elapsed"] | |
| torch.set_rng_state(st["rng_cpu"]) | |
| if st["rng_cuda"] is not None and torch.cuda.is_available(): | |
| torch.cuda.set_rng_state_all(st["rng_cuda"]) | |
| resumed = True | |
| print(f"[resume] {run_name}: continuing from step {start_step}/{t['max_steps']}") | |
| else: | |
| print(f"[resume] {run_name}: found a resume file for a DIFFERENT config - ignoring it and starting fresh") | |
| if not resumed: | |
| sample_file.write_text("", encoding="utf-8") | |
| model.train() | |
| for step in range(start_step, t["max_steps"] + 1): | |
| lr = lr_at(step, t) | |
| for g in opt.param_groups: | |
| g["lr"] = lr | |
| if step % t["eval_interval"] == 0 or step == t["max_steps"]: | |
| tr = estimate_loss(model, train_data, cfg, device, t["eval_batches"]) | |
| va = estimate_loss(model, val_data, cfg, device, t["eval_batches"]) | |
| rows.append({"step": step, "epoch": round(tokens_seen / len(train_data), 3), "train_loss": tr, "val_loss": va, | |
| "lr": lr, "tokens_per_sec": tps, "elapsed_sec": time.time() - t0}) | |
| if not quiet: | |
| print(f"step {step:>5d} | train {tr:.4f} | val {va:.4f} (ppl {math.exp(va):.2f}) | lr {lr:.2e} | {tps:,.0f} tok/s") | |
| if va < best_val: | |
| best_val = va | |
| save_ckpt(best_path, step, va) | |
| if step % t["sample_interval"] == 0 or step == t["max_steps"]: | |
| out = model.generate(prompt_ids, t["sample_tokens"], temperature=cfg["generate"]["temperature"]) | |
| with open(sample_file, "a", encoding="utf-8") as f: | |
| f.write(f"=== step {step} ===\n{tok.decode(out[0].tolist())}\n\n") | |
| if step == t["max_steps"]: | |
| break | |
| x, y = get_batch(train_data, B, T, device) | |
| _, loss = model(x, y) | |
| opt.zero_grad(set_to_none=True) | |
| loss.backward() | |
| if t["grad_clip"]: | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), t["grad_clip"]) | |
| opt.step() | |
| tokens_seen += B * T; tick_tokens += B * T | |
| train_loss_ema = loss.item() if train_loss_ema is None else 0.9 * train_loss_ema + 0.1 * loss.item() | |
| if step % t["log_interval"] == 0: | |
| now = time.time() | |
| if device.type == "cuda": | |
| torch.cuda.synchronize() | |
| tps = tick_tokens / max(1e-9, now - last_tick); last_tick, tick_tokens = now, 0 | |
| rows.append({"step": step, "epoch": round(tokens_seen / len(train_data), 3), "train_loss": loss.item(), | |
| "val_loss": None, "lr": lr, "tokens_per_sec": tps, "elapsed_sec": now - t0}) | |
| if resume and (step + 1) % save_every == 0 and step + 1 < t["max_steps"]: | |
| save_resume(step + 1) | |
| if stop_after_step is not None and step + 1 >= stop_after_step: | |
| raise InterruptedError(f"simulated interruption after step {step + 1}") | |
| elapsed = time.time() - t0 | |
| if resume_path.exists(): | |
| resume_path.unlink() # run finished: the resume file is no longer needed | |
| save_ckpt(ckpt_dir / ("last.pt" if main_run else f"{run_name}_last.pt"), t["max_steps"], rows[-1]["val_loss"]) | |
| with open(log_dir / f"{run_name}_log.csv", "w", newline="") as f: | |
| w = csv.DictWriter(f, fieldnames=list(rows[0].keys())); w.writeheader(); w.writerows(rows) | |
| save_loss_plot(rows, fig_dir / f"{run_name}_loss_curve.png") | |
| final = [r for r in rows if r["val_loss"] is not None][-1] | |
| tps_list = [r["tokens_per_sec"] for r in rows if r["tokens_per_sec"]] | |
| summary = { | |
| "run_name": run_name, "parameters": n_params, "model_config": model.config, | |
| "train_config": t, "dataset": stats, "device": str(device), | |
| "hardware": torch.cuda.get_device_name(0) if device.type == "cuda" else "CPU", | |
| "wall_clock_sec": round(elapsed, 1), "final_train_loss": final["train_loss"], "final_val_loss": final["val_loss"], | |
| "best_val_loss": best_val, "final_val_perplexity": math.exp(final["val_loss"]), | |
| "mean_tokens_per_sec": sum(tps_list) / len(tps_list) if tps_list else 0.0, | |
| "peak_cuda_memory_mb": round(torch.cuda.max_memory_allocated() / 2**20, 1) if device.type == "cuda" else None, | |
| "best_checkpoint": str(best_path), | |
| } | |
| (log_dir / f"{run_name}_summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") | |
| if not quiet: | |
| print(f"\nDone in {elapsed:.0f}s | params {n_params:,} | final val loss {final['val_loss']:.4f} | best {best_val:.4f}") | |
| print(f"best checkpoint: {best_path}") | |
| return summary | |
| def run_overfit(cfg: dict, steps: int = 300, lr: float = 3e-3, batch_size: int = 4, quiet: bool = False) -> list[float]: | |
| """Debugging gate: train on ONE tiny repeated batch. If gradients / target shifting / masking are right, | |
| the loss collapses towards zero. Returns the list of per-step losses.""" | |
| set_seed(cfg["seed"]) | |
| text = load_text(cfg["data"]["path"]) | |
| tok = CharTokenizer.from_text(text) | |
| data, _ = build_splits(text, tok, cfg["data"]["val_fraction"]) | |
| mc = dict(cfg["model"]); mc.update(dropout=0.0) # no dropout so memorisation is possible | |
| model = GPT(vocab_size=tok.vocab_size, **mc) | |
| x, y = get_batch(data, batch_size, mc["context_length"]) | |
| assert torch.equal(x[:, 1:], y[:, :-1]), "targets must be inputs shifted by one" | |
| opt = torch.optim.AdamW(model.parameters(), lr=lr) | |
| losses = [] | |
| for s in range(steps): | |
| _, loss = model(x, y) | |
| opt.zero_grad(); loss.backward(); opt.step() | |
| losses.append(loss.item()) | |
| if not quiet and (s % 25 == 0 or s == steps - 1): | |
| print(f"overfit step {s:>4d} | loss {loss.item():.4f}") | |
| return losses | |
| def main(): | |
| ap = argparse.ArgumentParser(description="Train MiniGPT") | |
| ap.add_argument("--config", default=None) | |
| ap.add_argument("--set", nargs="*", default=[], metavar="section.key=value", help="override config values") | |
| ap.add_argument("--run-name", default="main") | |
| ap.add_argument("--resume", action="store_true", help="continue from <checkpoint_dir>/<run>_resume.pt if it exists") | |
| ap.add_argument("--overfit_tiny", action="store_true", help="overfit one tiny batch (debug gate) and exit") | |
| args = ap.parse_args() | |
| cfg = apply_overrides(load_config(args.config), args.set) | |
| if args.overfit_tiny: | |
| losses = run_overfit(cfg) | |
| print(f"\ninitial loss {losses[0]:.4f} -> final loss {losses[-1]:.4f}") | |
| return | |
| train(cfg, run_name=args.run_name, resume=args.resume) | |
| if __name__ == "__main__": | |
| main() | |