Train it
Loss measures prediction mistakes. Training calculates how each weight affects the loss, then updates the weights with an optimizer called AdamW.
My first run got 995 of 1,006 validation answers right, yet continued 1+1= with 2=23. The improved run got all 1,006 right. Both started from the same random weights.
Explore the recorded loss curve. Run 1+1= with Random weights, First training attempt and Improved base.
Epoch 1: loss 3.7115, 32 updates. Recorded values, not live training.
Recorded values as a table
| Epoch | Loss | Updates |
|---|---|---|
| 1 | 3.7115 | 32 |
| 2 | 2.2724 | 64 |
| 3 | 1.6359 | 96 |
| 4 | 1.5082 | 128 |
| 5 | 1.4420 | 160 |
| 6 | 1.3923 | 192 |
| 7 | 1.3556 | 224 |
| 8 | 1.3411 | 256 |
| 9 | 1.3238 | 288 |
| 10 | 1.3044 | 320 |
| 11 | 1.2206 | 352 |
| 12 | 1.1485 | 384 |
| 13 | 1.1042 | 416 |
| 14 | 1.0818 | 448 |
| 15 | 1.0653 | 480 |
| 16 | 1.0484 | 512 |
| 17 | 1.0382 | 544 |
| 18 | 1.0339 | 576 |
| 19 | 1.0274 | 608 |
| 20 | 1.0216 | 640 |
| 21 | 1.0211 | 672 |
| 22 | 1.0166 | 704 |
| 23 | 1.0174 | 736 |
| 24 | 1.0136 | 768 |
| 25 | 1.0124 | 800 |
| 26 | 1.0104 | 832 |
| 27 | 1.0123 | 864 |
| 28 | 1.0108 | 896 |
| 29 | 1.0075 | 928 |
| 30 | 1.0079 | 960 |
| 31 | 1.0038 | 992 |
| 32 | 1.0053 | 1024 |
| 33 | 1.0041 | 1056 |
| 34 | 1.0051 | 1088 |
| 35 | 1.0021 | 1120 |
| 36 | 1.0036 | 1152 |
| 37 | 1.0044 | 1184 |
| 38 | 1.0013 | 1216 |
| 39 | 1.0039 | 1248 |
| 40 | 1.0007 | 1280 |
| 41 | 1.0018 | 1312 |
| 42 | 1.0011 | 1344 |
| 43 | 1.0017 | 1376 |
| 44 | 1.0010 | 1408 |
| 45 | 0.9990 | 1440 |
| 46 | 1.0004 | 1472 |
| 47 | 1.0004 | 1504 |
| 48 | 0.9972 | 1536 |
| 49 | 0.9976 | 1568 |
| 50 | 1.0026 | 1600 |
| 51 | 0.9995 | 1632 |
| 52 | 0.9994 | 1664 |
| 53 | 0.9983 | 1696 |
| 54 | 0.9969 | 1728 |
| 55 | 0.9993 | 1760 |
| 56 | 1.0000 | 1792 |
| 57 | 0.9974 | 1824 |
| 58 | 0.9967 | 1856 |
| 59 | 0.9971 | 1888 |
| 60 | 0.9960 | 1920 |
| 61 | 0.9952 | 1952 |
| 62 | 1.0012 | 1984 |
| 63 | 0.9966 | 2016 |
| 64 | 0.9965 | 2048 |
| 65 | 0.9959 | 2080 |
| 66 | 0.9965 | 2112 |
| 67 | 0.9971 | 2144 |
| 68 | 0.9954 | 2176 |
| 69 | 0.9955 | 2208 |
| 70 | 0.9967 | 2240 |
| 71 | 0.9948 | 2272 |
| 72 | 0.9951 | 2304 |
| 73 | 0.9960 | 2336 |
| 74 | 0.9956 | 2368 |
| 75 | 0.9948 | 2400 |
| 76 | 0.9939 | 2432 |
| 77 | 0.9953 | 2464 |
| 78 | 0.9941 | 2496 |
| 79 | 0.9945 | 2528 |
| 80 | 0.9961 | 2560 |
| 81 | 0.9956 | 2592 |
| 82 | 0.9936 | 2624 |
| 83 | 0.9940 | 2656 |
| 84 | 0.9951 | 2688 |
| 85 | 0.9942 | 2720 |
| 86 | 0.9938 | 2752 |
| 87 | 0.9945 | 2784 |
| 88 | 0.9944 | 2816 |
| 89 | 0.9935 | 2848 |
| 90 | 0.9941 | 2880 |
| 91 | 0.9937 | 2912 |
| 92 | 0.9950 | 2944 |
| 93 | 0.9929 | 2976 |
| 94 | 0.9934 | 3008 |
| 95 | 0.9943 | 3040 |
| 96 | 0.9933 | 3072 |
| 97 | 0.9933 | 3104 |
| 98 | 0.9931 | 3136 |
| 99 | 0.9935 | 3168 |
| 100 | 0.9930 | 3200 |
| 101 | 0.9932 | 3232 |
| 102 | 0.9938 | 3264 |
| 103 | 0.9937 | 3296 |
| 104 | 0.9932 | 3328 |
| 105 | 0.9927 | 3360 |
| 106 | 0.9932 | 3392 |
| 107 | 0.9940 | 3424 |
| 108 | 0.9934 | 3456 |
| 109 | 0.9928 | 3488 |
| 110 | 0.9926 | 3520 |
| 111 | 0.9924 | 3552 |
| 112 | 0.9934 | 3584 |
| 113 | 0.9925 | 3616 |
| 114 | 0.9928 | 3648 |
| 115 | 0.9926 | 3680 |
| 116 | 0.9924 | 3712 |
| 117 | 0.9923 | 3744 |
| 118 | 0.9925 | 3776 |
| 119 | 0.9926 | 3808 |
| 120 | 0.9925 | 3840 |
| 121 | 0.9928 | 3872 |
| 122 | 0.9924 | 3904 |
| 123 | 0.9922 | 3936 |
| 124 | 0.9923 | 3968 |
| 125 | 0.9920 | 4000 |
| 126 | 0.9920 | 4032 |
| 127 | 0.9919 | 4064 |
| 128 | 0.9919 | 4096 |
| 129 | 0.9921 | 4128 |
| 130 | 0.9922 | 4160 |
| 131 | 0.9917 | 4192 |
| 132 | 0.9917 | 4224 |
| 133 | 0.9917 | 4256 |
| 134 | 0.9918 | 4288 |
| 135 | 0.9915 | 4320 |
| 136 | 0.9914 | 4352 |
| 137 | 0.9916 | 4384 |
| 138 | 0.9915 | 4416 |
| 139 | 0.9916 | 4448 |
| 140 | 0.9913 | 4480 |
| 141 | 0.9914 | 4512 |
| 142 | 0.9915 | 4544 |
| 143 | 0.9910 | 4576 |
| 144 | 0.9909 | 4608 |
| 145 | 0.9911 | 4640 |
| 146 | 0.9909 | 4672 |
| 147 | 0.9908 | 4704 |
| 148 | 0.9909 | 4736 |
| 149 | 0.9907 | 4768 |
| 150 | 0.9907 | 4800 |
| 151 | 0.9906 | 4832 |
| 152 | 0.9906 | 4864 |
| 153 | 0.9903 | 4896 |
| 154 | 0.9904 | 4928 |
| 155 | 0.9903 | 4960 |
| 156 | 0.9903 | 4992 |
| 157 | 0.9904 | 5024 |
| 158 | 0.9902 | 5056 |
| 159 | 0.9900 | 5088 |
| 160 | 0.9899 | 5120 |
Build this step
A forward pass gives 98 scores at every position. Cross entropy penalizes the network when the correct target has a low probability. Lower loss means better predictions on those examples.
optimizer = torch.optim.AdamW(model.parameters(), lr=0.001)
# For each batch x (inputs) and y (targets):
optimizer.zero_grad(set_to_none=True)
logits = model(x)
loss = next_token_loss(logits, y)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()backward() calculates derivatives: how changing each weight would change the loss. AdamW uses them to update the weights. Clipping limits an unusually large update. Clearing gradients prevents the previous batch from being counted again.
Start with one epoch, meaning one pass over the training examples:
python -m tiny_gpt.train --run-dir runs/my-first-run --epochs 1 --device cpuYou should see a training loss and validation score, plus best.pt, last.pt and logs in that folder. One epoch is a check that the pipeline works. It is not enough to expect correct arithmetic.
For a longer run, choose a new output folder:
python -m tiny_gpt.train --run-dir runs/my-model --epochs 200 --device cpuThe trainer checks validation regularly and saves the best model. On an Apple Silicon Mac, --device mps uses its GPU. Local training time depends on the machine and is separate from the tutorial reading time.
Read the complete train.py
"""Pretrain from random weights, selecting a checkpoint by validation only."""
import argparse
from dataclasses import asdict
from datetime import datetime
import json
import math
import os
from pathlib import Path
import time
from zoneinfo import ZoneInfo
import torch
from .checkpoint import load_checkpoint, save_checkpoint
from .evaluation import better_validation, evaluate
from .model import next_token_loss
from .training_data import digest, load_training_data
ROOT = Path(__file__).resolve().parents[1]
def write_json(path, content):
path.write_text(json.dumps(content, indent=2, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run-dir", type=Path, default=ROOT / "runs" / "pretrain-v2")
parser.add_argument("--initial", type=Path, default=ROOT / "checkpoints" / "initial.pt")
parser.add_argument("--device", choices=("auto", "cpu", "mps"), default="auto")
parser.add_argument("--epochs", type=int, default=200)
parser.add_argument("--batch-size", type=int, default=256)
parser.add_argument("--learning-rate", type=float, default=0.001)
parser.add_argument("--eval-every", type=int, default=5)
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--target-accuracy", type=float, default=1.0)
parser.add_argument("--stable-checks", type=int, default=3)
args = parser.parse_args()
if (min(args.epochs, args.batch_size, args.eval_every, args.stable_checks) <= 0
or not math.isfinite(args.learning_rate) or args.learning_rate <= 0
or not 0 <= args.target_accuracy <= 1):
parser.error("Positive epoch/batch/evaluation counts and learning rate are required; accuracy must be 0-1")
if args.run_dir.exists():
parser.error("Run directory exists. Choose a new --run-dir to preserve its checkpoints and log")
if not args.initial.is_file():
parser.error("Initial checkpoint missing. Create it with: "
".venv/bin/python -m tiny_gpt.inspect_model --save-initial checkpoints/initial.pt")
device = ("mps" if torch.backends.mps.is_available() else "cpu") if args.device == "auto" else args.device
if device == "mps" and not torch.backends.mps.is_available():
parser.error("MPS unavailable; use --device cpu")
if device == "mps" and os.environ.get("PYTORCH_ENABLE_MPS_FALLBACK") == "1":
parser.error("Disable automatic CPU fallback for a recorded MPS training run")
torch.set_num_threads(4)
torch.manual_seed(args.seed)
model, initial_metadata = load_checkpoint(args.initial, device=device)
if initial_metadata.get("arithmetic_training_steps") != 0:
parser.error("Step 6 pretraining requires the untrained initial checkpoint")
data = load_training_data()
train = data["train"]
validation = data["validation"]
inputs = train["batch"]["input_ids"].to(device)
targets = train["batch"]["target_ids"].to(device)
generator = torch.Generator().manual_seed(args.seed)
optimizer = torch.optim.AdamW(model.parameters(), lr=args.learning_rate,
betas=(0.9, 0.95), weight_decay=0.01, foreach=False)
batches_per_epoch = math.ceil(len(train["rows"]) / args.batch_size)
maximum_steps = batches_per_epoch * args.epochs
warmup_steps = min(100, max(1, maximum_steps // 10))
args.run_dir.mkdir(parents=True)
config = {"model": asdict(model.config), "seed": args.seed, "device": device,
"epochs_limit": args.epochs, "batch_size": args.batch_size,
"learning_rate": args.learning_rate, "minimum_lr_fraction": 0.1,
"warmup_steps": warmup_steps, "optimizer": "AdamW", "betas": [0.9, 0.95],
"weight_decay": 0.01, "gradient_clip_norm": 1.0,
"eval_every": args.eval_every, "target_accuracy": args.target_accuracy,
"stable_checks": args.stable_checks, "loss": "all non-padding next tokens",
"checkpoint_selection": "highest validation exact accuracy, then lowest answer NLL",
"initial_checkpoint": str(args.initial), "initial_sha256": digest(args.initial),
"initial_metadata": initial_metadata,
"data": {name: {"examples": len(part["rows"]), "source_sha256": part["source_sha256"],
"token_sha256": part["token_sha256"]} for name, part in data.items()},
"excluded_from_training_and_selection": ["calibration", "test", "ood"]}
write_json(args.run_dir / "config.json", config)
report_path = args.run_dir / "report.json"
log_path = args.run_dir / "metrics.jsonl"
report = {"status": "running", "started_at": datetime.now(ZoneInfo("Europe/Amsterdam")).isoformat(),
"config": config, "final_test_evaluated": False}
write_json(report_path, report)
started = time.perf_counter()
baseline = evaluate(model, validation, batch_size=args.batch_size)
report["initial_validation"] = baseline
print(f"Device {device}; {len(train['rows'])} training examples; {len(validation['rows'])} validation examples", flush=True)
print(f"Before training: validation exact {baseline['accuracy']:.2%}; answer NLL {baseline['answer_nll']:.4f}", flush=True)
best = None
best_epoch = 0
step = 0
stable = 0
try:
for epoch in range(1, args.epochs + 1):
model.train()
order = torch.randperm(len(train["rows"]), generator=generator).to(device)
accumulated_loss = 0.0
real_tokens = 0
for start in range(0, len(order), args.batch_size):
if step < warmup_steps:
fraction = (step + 1) / warmup_steps
else:
progress = (step - warmup_steps) / max(1, maximum_steps - warmup_steps - 1)
fraction = 0.1 + 0.9 * 0.5 * (1 + math.cos(math.pi * progress))
lr = args.learning_rate * fraction
for group in optimizer.param_groups:
group["lr"] = lr
chosen = order[start:start + args.batch_size]
x, y = inputs[chosen], targets[chosen]
optimizer.zero_grad(set_to_none=True)
loss = next_token_loss(model(x), y)
if not bool(torch.isfinite(loss)):
raise RuntimeError("Training loss became non-finite")
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0, error_if_nonfinite=True)
optimizer.step()
count = int((y != 0).sum().cpu())
accumulated_loss += float(loss.detach().cpu()) * count
real_tokens += count
step += 1
entry = {"epoch": epoch, "step": step, "train_next_token_loss": accumulated_loss / real_tokens,
"learning_rate": lr, "elapsed_seconds": time.perf_counter() - started}
if epoch == 1 or epoch % args.eval_every == 0 or epoch == args.epochs:
metrics = evaluate(model, validation, batch_size=args.batch_size)
entry["validation"] = metrics
metadata = {"stage": "pretrained-addition", "epoch": epoch,
"arithmetic_training_steps": step, "seed": args.seed,
"validation": metrics, "training_config": config}
save_checkpoint(model, args.run_dir / "last.pt", metadata=metadata)
if better_validation(metrics, best):
best = metrics
best_epoch = epoch
save_checkpoint(model, args.run_dir / "best.pt", metadata=metadata)
stable = stable + 1 if metrics["accuracy"] >= args.target_accuracy else 0
print(f"Epoch {epoch:3d}: train loss {entry['train_next_token_loss']:.4f}; "
f"validation exact {metrics['correct']}/{metrics['examples']} ({metrics['accuracy']:.2%}); "
f"answer NLL {metrics['answer_nll']:.4f}; elapsed {entry['elapsed_seconds']:.1f}s", flush=True)
report.update({"last_epoch": epoch, "step": step, "best_epoch": best_epoch,
"best_validation": best, "elapsed_seconds": time.perf_counter() - started})
write_json(report_path, report)
with log_path.open("a", encoding="utf-8") as file:
file.write(json.dumps(entry, sort_keys=True, allow_nan=False) + "\n")
if stable >= args.stable_checks:
print(f"Stopping: validation target met at {args.stable_checks} consecutive scheduled checks.", flush=True)
break
best_model, metadata = load_checkpoint(args.run_dir / "best.pt", device=device)
selected_validation = evaluate(best_model, validation, batch_size=args.batch_size)
selected_training = evaluate(best_model, train, batch_size=args.batch_size)
if selected_validation != best:
# Numerical agreement is checked by counts and tolerances, not exact float dict equality.
if selected_validation["correct"] != best["correct"] or abs(selected_validation["answer_nll"] - best["answer_nll"]) > 1e-4:
raise RuntimeError("Reloaded checkpoint validation differs from selection results")
report.update({"status": "complete", "finished_at": datetime.now(ZoneInfo("Europe/Amsterdam")).isoformat(),
"best_epoch": best_epoch, "step": step, "elapsed_seconds": time.perf_counter() - started,
"selected_checkpoint": str(args.run_dir / "best.pt"),
"selected_checkpoint_sha256": digest(args.run_dir / "best.pt"),
"selected_validation": selected_validation, "selected_training": selected_training,
"stopping_reason": "stable validation target" if stable >= args.stable_checks else "epoch limit",
"final_test_evaluated": False, "calibration_performed": False})
write_json(report_path, report)
print(f"Saved best checkpoint (epoch {best_epoch}): {args.run_dir / 'best.pt'}", flush=True)
print(f"Selected validation accuracy: {selected_validation['accuracy']:.2%}; "
f"seen training accuracy: {selected_training['accuracy']:.2%}", flush=True)
print("Final test and calibration have not been evaluated.", flush=True)
except BaseException as error:
report.update({"status": "interrupted" if isinstance(error, KeyboardInterrupt) else "failed",
"error": str(error), "step": step, "elapsed_seconds": time.perf_counter() - started})
write_json(report_path, report)
raise
if __name__ == "__main__":
main()
Download train.pyCompare before and after training
Keep 1+1= fixed. Run each training stage. Check both the answer and whether it ends, then compare with the recorded loss above.
Runs on your device. First load is about 14 MB.
Exact input: "1+1="
No output yetInput + continuation
1+1=
Run to see a prediction.
Model and input details
Printable ASCII is passed verbatim. BOS is added once. All 98 tokens remain available. Context: 32 tokens. Run budget: 16 output tokens, capped by remaining context. EOS ends generation. Unexpected PAD or BOS tokens are visible.
Improved base. Epoch 140. 1.74 MB of weights. The WASM runtime is downloaded on first use.
Technical details
Pretraining uses next-token cross entropy over non-padding positions, AdamW, learning-rate warmup and decay, and gradient clipping. Both runs start from the initial seed-42 checkpoint. Validation exact accuracy, then answer NLL, selects the saved model.
Code and training steps