slm / src /train.py
adeena43's picture
deploy minigpt
8ce1de8 verified
Raw History Blame Contribute Delete
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)
@torch.no_grad()
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()