HeyMSK
Chapter 5 of 10

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.

Recorded training loss
Epoch 0Epoch 1603.70

Epoch 1: loss 3.7115, 32 updates. Recorded values, not live training.

Recorded values as a table
EpochLossUpdates
13.711532
22.272464
31.635996
41.5082128
51.4420160
61.3923192
71.3556224
81.3411256
91.3238288
101.3044320
111.2206352
121.1485384
131.1042416
141.0818448
151.0653480
161.0484512
171.0382544
181.0339576
191.0274608
201.0216640
211.0211672
221.0166704
231.0174736
241.0136768
251.0124800
261.0104832
271.0123864
281.0108896
291.0075928
301.0079960
311.0038992
321.00531024
331.00411056
341.00511088
351.00211120
361.00361152
371.00441184
381.00131216
391.00391248
401.00071280
411.00181312
421.00111344
431.00171376
441.00101408
450.99901440
461.00041472
471.00041504
480.99721536
490.99761568
501.00261600
510.99951632
520.99941664
530.99831696
540.99691728
550.99931760
561.00001792
570.99741824
580.99671856
590.99711888
600.99601920
610.99521952
621.00121984
630.99662016
640.99652048
650.99592080
660.99652112
670.99712144
680.99542176
690.99552208
700.99672240
710.99482272
720.99512304
730.99602336
740.99562368
750.99482400
760.99392432
770.99532464
780.99412496
790.99452528
800.99612560
810.99562592
820.99362624
830.99402656
840.99512688
850.99422720
860.99382752
870.99452784
880.99442816
890.99352848
900.99412880
910.99372912
920.99502944
930.99292976
940.99343008
950.99433040
960.99333072
970.99333104
980.99313136
990.99353168
1000.99303200
1010.99323232
1020.99383264
1030.99373296
1040.99323328
1050.99273360
1060.99323392
1070.99403424
1080.99343456
1090.99283488
1100.99263520
1110.99243552
1120.99343584
1130.99253616
1140.99283648
1150.99263680
1160.99243712
1170.99233744
1180.99253776
1190.99263808
1200.99253840
1210.99283872
1220.99243904
1230.99223936
1240.99233968
1250.99204000
1260.99204032
1270.99194064
1280.99194096
1290.99214128
1300.99224160
1310.99174192
1320.99174224
1330.99174256
1340.99184288
1350.99154320
1360.99144352
1370.99164384
1380.99154416
1390.99164448
1400.99134480
1410.99144512
1420.99154544
1430.99104576
1440.99094608
1450.99114640
1460.99094672
1470.99084704
1480.99094736
1490.99074768
1500.99074800
1510.99064832
1520.99064864
1530.99034896
1540.99044928
1550.99034960
1560.99034992
1570.99045024
1580.99025056
1590.99005088
1600.98995120

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 cpu

You 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 cpu

The 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.py

Compare 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="

Continuation
No output yet
Input + 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