A beginner-first deep dive

Supervised Pre-training Deep Dive

Start chess from a Stockfish-trained student, not from scratch.

Big picture

1 Corpus generation 10 workers · Stockfish vs Stockfish writes shards → ~3M positions · ~60 MB compressed · ~5 hr 2 Supervised pre-training 15M-param network · 3 epochs max NN 15M params CE(policy) + MSE(value) lr warmup + cosine · early stop · ~30 min 3 AZ refinement MCTS + self-play · 10 iters → checkpoints_refine/iter_NNNN.pt 50 sims · 100 games · ~10 hr shards pretrained.pt Total: ~15 hours (vs ~13 days for cold-start AlphaZero)

Pure AlphaZero starts from a random network and learns chess from nothing — on consumer hardware that's roughly 13 days of compute (one SP3 iteration took 3.85 hours on an M4 Pro × 80 iters). Bootstrapping from a teacher first turns the problem into "improve on someone who already knows chess," which fits in a single weekend.

This deep dive is the conceptual companion to SP3b — Supervised Pre-training + AZ Refinement. For AlphaZero basics (ply, MCTS, ResNet, self-play) see Foundational concepts and the visual concepts page.

~13 days → ~15 hours. The teacher gives the student a head start; AlphaZero refinement then carries the student past the teacher's level.

The 3 ideas

Three concepts make supervised pre-training work. Each gets an intuition first, formulas second.

1. Copy the teacher (behavior cloning)

Show the student a position. Ask, "what would Stockfish play here?" Train the student to predict Stockfish's actual move. Repeat for ~3M positions. That's the entire idea.

a chess position Stockfish says... teacher move e2 → e4 student network forward pass NN predicts a move e2 → e4 match! backprop repeat for ~3M positions · student's policy ≈ teacher's policy

Formally, behavior cloning trains the student policy π_S to match the teacher policy π_T on the same states. The target is one-hot on the teacher's chosen move, so the cross-entropy loss collapses to -log π_S(a | s). The student doesn't see rewards, doesn't search, doesn't plan. It just learns student(action | state) ≈ teacher(action | state).

For chess we add a second target — the game's eventual outcome z ∈ {-1, 0, +1} recorded for every position from the mover's POV (winner's positions all +1, loser's all -1, draws all 0). This trains a value head that gives the network a position-evaluation prior, which MCTS uses later.

total_loss = CE(predicted_policy, teacher_move) + MSE(predicted_value, z)

This is the exact same loss AlphaZero uses during self-play. Only the source of the targets differs — so no code change is needed in _train_step, just the data loader.

Phasetarget_policytarget_value
AZ self-playMCTS visit distribution πgame outcome z
Pre-trainingone-hot(Stockfish's move)game outcome z
Teacher ceiling. A behavior-cloned student typically lands 100–300 ELO below the teacher. Limited model capacity, no search at inference time, and averaging over similar positions all chip away. If Stockfish-at-0.05s plays ~ELO 2400, the cloned student plays maybe ~2200 — still ~700 ELO above the SP3 kill criterion (Stockfish-1500). AZ refinement then pushes past that ceiling: ~10 refinement iters from a strong-pretrained start typically gain +200 to +400 ELO, because MCTS-on-the-student finds moves the teacher never played.

2. Don't memorize (overfitting + early stopping)

A 15M-parameter network has enough capacity to memorize 3M positions. Left unchecked it will — and then it stops generalizing. The fix: hold out 5% of shards, watch the loss on them, and stop the moment that held-out loss stops improving.

loss epoch → stop here ↓ train loss val loss (held-out 5%) memorizing (still drops) val rises = overfitting

Training loss keeps falling forever — the network has enough parameters to memorize anything you throw at it. Validation loss measures the true thing: how well the network generalizes to positions it has never seen. When val starts rising, the network has stopped learning and started memorizing. Stop and use the best-so-far checkpoint.

After each epoch we compute val loss (forward-only, no gradient updates). If it's lower than the previous best, save a checkpoint and reset patience. If it's higher or equal, increment patience. Stop when patience hits early_stopping_patience. We use patience = 2 because val loss has noise — a single tick up might be measurement noise, not real overfitting. With our config (3 epochs max, patience 2), train loss drops 3 epochs running, val loss drops 2 then plateaus, and early stop fires on epoch 3 if val doesn't improve.

The held-out split (5% of shards)
all_shards = sorted(shard_dir.glob("shard_*.npz"))
n_val = max(1, int(len(all_shards) * 0.05))   # ~15 shards from ~300
train_shards = all_shards[:-n_val]            # 95% for training
val_shards   = all_shards[-n_val:]            # 5% for validation

Shards are reserved at corpus load time and never trained on. With ~300 shards × 10K positions, that's ~150K positions exclusively for measuring generalization.

3. Warm up, then cool down (learning rate schedule)

Don't start at full learning rate, and don't stay there. Ramp up gently for ~500 steps, then ease down on a cosine curve. One curve, two phases.

lr step → 5e-4 (peak) 5e-5 (end) warmup ~500 steps cosine decay ~35,500 steps

Why warmup. Adam (and AdamW we use) tracks running estimates of the gradient's first and second moments. At step 0 those estimates are based on a single batch — wildly noisy. Apply the full peak_lr immediately and the first update is huge in magnitude but random in direction; weight initialization (Xavier/He) is wrecked, loss explodes within ~200 steps. Warmup ramps lr from 0 to peak_lr over the first 500 steps so Adam's running stats stabilize first.

Why cosine decay. Once warmed up, big steps escape low-information regions fast. As we approach a good minimum we want progressively smaller steps so we settle in instead of bouncing around. Cosine drops smoothly from peak_lr to end_lr following 0.5 × (1 + cos(πt)) — simple, parameter-free, well-studied. Linear / step / exponential schedules work fine too; cosine is just the default modern choice.

Rule of thumb — bigger nets want smaller lr. Roughly peak_lr ∝ 1 / √num_params: 1M → ~2e-3, 15M → ~5e-4 (our config), 50M → ~3e-4. Bigger nets have noisier per-batch gradients (more params being estimated), so high lr × noisy direction = thrashing, not descent. See concepts.html#sizing for the channels-vs-blocks tradeoff and the full sizing recipe.

How it runs

Engineering details, collapsed by default. Skip if you only care about the ideas; expand if you're going to run or modify the pipeline.

The corpus: Stockfish vs Stockfish

Teacher games are generated by scripts/generate_chess_corpus.py. Per game:

  1. Random opening (4 plies). Without this, Stockfish-vs-Stockfish produces ~50 unique opening lines repeated thousands of times — useless diversity. Random first 4 plies give us ~20⁴ ≈ 160K distinct opening sequences from chess.Board().
  2. Stockfish plays both sides with time_per_move=0.05s until the game ends.
  3. Record every position as (encoded_state, move_played_idx, mover_player). We don't know the outcome until the game ends, so this accumulates in memory.
  4. At game end, compute outcome via python-chess's board.outcome(claim_draw=True). The claim_draw=True is important — it makes the 50-move rule and 3-fold repetition terminate the game. Without it, games would only stop on checkmate, stalemate, insufficient material, or the unconditional 75-move/150-halfmove rule.
  5. Backfill z for each recorded position from the final outcome × mover_player. Winner's positions get +1, loser's -1, draws 0.

Why Stockfish at 0.05s, not full strength

At 0.05s/move, Stockfish's effective ELO drops to ~2400 from its full ~3500. Three reasons we prefer it:

  • Throughput. 0.05s/move × ~80 plies/game × 50,000 games / 10 workers ≈ 5 hours wall-clock. Full strength (say 2s/move) would be ~40 hours.
  • Diversity. Faster play means more varied moves — Stockfish explores more lines without time to converge on one "best" move. Better for student generalization.
  • Sufficient. ELO 2400 is vastly above the SP3 kill criterion (1500). No reason to over-pay.

Draws are expected and OK

Stockfish-vs-Stockfish at fast time controls produces ~60–70% draws. Both sides play accurately enough that ambiguous middle-game positions coast into 50-move-rule endgames. Typical corpus mix: 17% wins, 66% draws, 17% losses. That 33% decisive rate gives ~1M positions with z = ±1 across the 3M total — plenty of signal for the value head. The policy head benefits from all 3M positions regardless of outcome.

Storage & streaming (int8 shards + iter_batches)

The corpus is stored as a series of .npz shard files, 10,000 positions per shard, written incrementally by each worker as its in-memory buffer fills. Each shard holds three numpy arrays:

states       : int8   (10000, 20, 8, 8)  ← encoded board planes
move_indices : int32  (10000,)            ← flat action indices [0, 4672)
outcomes     : int8   (10000,)            ← z values in {-1, 0, +1}

Why int8 and not float32?

The state encoding is nearly all 0s and 1s (piece presence, castling rights, etc.). As float32, a single shard would be 10000 × 20 × 64 × 4 = 5.1 MB. As int8: 1.3 MB. Compressed via np.savez_compressed, it shrinks further to ~190 KB per shard. For 300 shards (full corpus): ~60 MB on disk, vs ~1.5 GB if we'd stored as float32. Runtime cost is one cast int8 → float32 per batch when loading — negligible compared to forward+backward time.

Why 10,000 positions per shard?

A trade-off. Smaller shards → more file-system overhead, more np.load calls, but finer-grained progress. Bigger shards → fewer disk ops but more memory per load. 10K strikes the balance: each shard is ~190 KB compressed (fits in OS page cache), each worker accumulates one shard's worth in ~170 games (~20 minutes), and the loader processes one shard at a time with ~13 MB peak in-memory after the int8→float32 expansion.

Why incremental writes?

If we accumulated all positions per worker in RAM and wrote one big file at the end, a worker crash would lose everything. Flushing every 10K positions caps loss at the current buffer (~20 min of work). Plus the user sees visible progress (shard files appearing) instead of a 5-hour silent void.

Streaming with iter_batches

The pre-training loop never loads the whole corpus into memory. iter_batches is a Python generator (uses yield, not return) that yields batches one at a time, reading shards on demand:

def iter_batches(
    shards: list[Path],
    batch_size: int,
    device: torch.device,
    shuffle: bool,
) -> Iterator[tuple[Tensor, Tensor, Tensor]]:
    for shard_path in shards:
        data = np.load(shard_path)                              # one shard at a time
        n = data["states"].shape[0]
        order = torch.randperm(n).numpy() if shuffle else np.arange(n)   # per-shard shuffle
        for start in range(0, n, batch_size):
            idx = order[start : start + batch_size]
            s = torch.from_numpy(data["states"][idx]).to(torch.float32).to(device)
            m = torch.from_numpy(data["move_indices"][idx]).to(torch.int64).to(device)
            z = torch.from_numpy(data["outcomes"][idx]).to(torch.float32).to(device)
            yield s, m, z
  • Stream, don't load. Peak memory stays bounded by one shard's worth (~13 MB float32) regardless of corpus size.
  • Per-shard shuffle, not global. A true global shuffle across 300 shards would require either loading them all (defeats streaming) or complex multi-pass disk reads. Per-shard shuffle randomizes order within each shard's 10K positions; the caller can also shuffle the shard list between epochs for additional diversity.
  • dtype casts at load. We saved int8/int32/int8 to minimize storage. At load we cast to what PyTorch expects: float32 for state features, int64 for class indices (cross-entropy expects int64), float32 for value targets.
Parallelism: processes, not threads

The corpus generator spawns 10 worker processes (not threads) via multiprocessing.get_context("spawn"). Each worker:

  • Owns its own Stockfish subprocess (configured Threads=1)
  • Owns its own RNG (seeded with base_seed + worker_id)
  • Plays games independently — no IPC with other workers
  • Writes its own shards directly to disk (independent filenames)

Threads (Python) are limited by the GIL

CPython has a Global Interpreter Lock that allows only one thread to execute Python bytecode at a time per process. Fine for I/O-bound tasks where the GIL is released during waits. But our worker does Python-side work between Stockfish calls — encoding states, indexing moves, tracking buffers — and that IS bytecode execution. 10 threads in one process would serialize this through the GIL. Processes have no GIL issue: each is a fresh Python interpreter with its own memory space.

The "embarrassingly parallel" pattern

Corpus generation has zero inter-worker communication. No shared state, no locks, no merging. Each worker plays games, writes shards, exits. The orchestrator just spawns and joins them. The N-fold speedup is nearly linear up to physical core count. On a 10-physical-core machine, 10 workers achieve close to 10× the throughput of 1 worker. Hyperthreaded virtual cores give diminishing returns because hyperthreads on the same physical core share execution units.

Why we still use threads in parallel_selfplay.py

The chess SP3 parallel_selfplay.py (for AlphaZero self-play) does use a thread for the NN-server. That's because the NN-server needs to share GPU memory with the model — moving the model between processes is expensive (full state-dict copy) and fragile (spawn-context model loading has known PyTorch issues with MPS especially). The server thread releases the GIL during net(x) (PyTorch tensors are C++ under the hood), so multiple worker processes can submit requests concurrently without thread contention. So:

  • CPU-bound Python work + no shared GPU → processes (corpus gen)
  • GPU work + shared model state → thread inside the main process (NN-server in parallel_selfplay)
  • CPU-bound Python work + needs GPU access → worker processes that send requests to a thread that owns the GPU (full parallel_selfplay architecture)
Checkpoint contract

After pre-training completes, pretrained.pt has the exact same shape as the iter_NNNN.pt files AlphaZero training produces:

{
    "iteration":     0,                      # AZ refinement starts counting at iter 1
    "config":        <dataclass-as-dict>,    # informational only
    "best_net":      <state_dict>,           # ← this is what AZ refinement loads
    "candidate_net": <state_dict>,           # same as best_net (copy)
    "optimizer":     <optimizer state>,      # AdamW state from end of pretraining
    "_pretrain_epoch": N,                    # informational only
}

Format compatibility is non-negotiable — it's what lets Trainer.load_from_checkpoint(pretrained.pt) work without modification. The Trainer doesn't know or care whether the weights came from pre-training or from a prior AZ run; it just reads ckpt["best_net"] and proceeds.

Why candidate_net = copy(best_net)

Trainer maintains two copies: best_net (used to generate self-play games) and candidate_net (the one being trained). At the start of each iteration, gradient updates happen on candidate_net; periodically its weights are copied to best_net. By initializing both to the pretrained weights, the first AZ refinement iteration plays self-play with a strong (pretrained) network and trains on top of it.

Why iteration=0

AZ refinement starts at iter 1. If we saved iteration=N for some N > 0, the refinement loop would think it had already done N iterations and start at N+1. We set 0 so refinement counts from the beginning.

The integration test that protects this contract

tests/test_supervised.py::test_pretrained_checkpoint_loads_in_trainer_via_resume literally runs pretrain → save → Trainer.load_from_checkpoint → verify weights match. If anyone changes the checkpoint format on either side and breaks compatibility, this test fails. Regression guard for the entire bridge between Phase 2 and Phase 3.

How to actually run it (3 bash commands)

Phase 1 — Corpus generation

python3 scripts/generate_chess_corpus.py --num-games 50000 --num-workers 10 --output data/chess_corpus/

Spawns 10 workers, each running Stockfish-vs-Stockfish games independently. Each worker writes its own shard files as buffers fill. Wall-clock: ~5–9 hours depending on machine. Output: ~300 shards totaling ~3M positions in data/chess_corpus/.

Phase 2 — Supervised pre-training

python3 -m alphazero pretrain --config configs/chess-pretrain.toml

Loads shards, streams batches, trains a 15M-param network for up to 3 epochs with held-out validation and early stopping. Wall-clock: ~30 min on M4 Pro MPS, ~2–4 hours on CPU. Output: pretrained.pt (Trainer-compatible checkpoint).

Phase 3 — AlphaZero refinement

python3 -m alphazero train --game chess --config configs/chess-refine.toml --resume-from pretrained.pt

Runs the existing AZ loop (now in parallel_selfplay.py with worker processes + NN-server thread) starting from the pretrained weights. Reduced parameters: 10 iterations × 100 games × 50 MCTS sims (vs SP3's 80/100/200). Wall-clock: ~10 hours. Output: checkpoints_refine/iter_NNNN.pt for each iteration.

Evaluation

python3 -m alphazero eval --game chess --checkpoint checkpoints_refine/iter_0010.pt --num-games 100

Plays 100 games vs Stockfish at ELO=1500. Pass criterion: win_rate >= 0.50.

Reference

Sizing

The 15M-param network is in a data-limited regime: ~3M positions ≈ 0.2 positions/param, vs the empirical sweet spot of ~10 positions/param. The cure isn't more games at time_per_move=0.05 — diminishing returns past ~2–3× corpus — it's a stronger teacher (try time_per_move=0.2+) or a smaller network.

See concepts.html#sizing for the full channels-vs-blocks treatment, the params budget table, and the sizing recipe.

Glossary (SP3b-specific terms)

For AlphaZero basics (ply, MCTS, ResNet, PUCT, etc.) see the foundational concepts glossary.

Behavior cloning — Supervised learning where the target action is a teacher policy's chosen action. Trains the student to imitate the teacher.

Cosine decay — Learning-rate schedule that smoothly drops from peak_lr to end_lr following 0.5 × (1 + cos(πt)). Used after warmup.

Early stopping — Halt training when validation loss stops improving for patience consecutive epochs. Prevents overfitting; saves compute.

Embarrassingly parallel — A workload where workers don't communicate or share state. Linear speedup with worker count up to physical-core limit.

Hold-out validation set — Fraction of training data reserved from the optimizer — used only to measure generalization. We use 5% of corpus shards.

iter_batches — Python generator that streams batches from disk shards, casting dtypes and moving tensors to the target device. Bounded memory regardless of corpus size.

Linear warmup — Ramp learning rate linearly from 0 to peak_lr over the first warmup_steps steps. Lets Adam's running stats stabilize before applying full lr.

NNUE — "Efficiently Updatable Neural Network." Stockfish's built-in evaluation function (a small dense network, ~50M params, AVX2-optimized). Unrelated to our PyTorch AlphaZeroNet — Stockfish uses NNUE internally for its own search.

Patience — Counter for early stopping. Resets when validation loss improves; increments otherwise. Stop when patience ≥ early_stopping_patience.

pretrained.pt — Checkpoint produced by Phase 2 (supervised pre-training). Format-compatible with Trainer.load_from_checkpoint so Phase 3 can resume from it.

Random opening — Push N random legal moves before Stockfish takes over, giving game diversity. We use N=4.

Shard — One .npz file holding 10,000 encoded positions. Workers write incrementally so progress is visible.

Spawn context — multiprocessing.get_context("spawn") — the multiprocessing flavor that creates fresh Python processes (vs fork, which copies memory). Safer with CUDA, mandatory on Windows, recommended on macOS.

Stockfish UCI — Universal Chess Interface, the text-based protocol Stockfish speaks. python-chess wraps it. Configurable via Threads, Hash, UCI_LimitStrength, UCI_Elo.

Teacher ceiling — The structural limit on a student trained via behavior cloning: typically 100–300 ELO below the teacher. Why AZ refinement matters — it lets us exceed this bound.

Throughput — Positions processed per second by the training loop. Determines wall-clock for a fixed corpus size and epoch count.

time_per_move — Wall-clock budget given to Stockfish for each move during corpus generation. We use 0.05s. Lower → weaker Stockfish + more diverse games; higher → stronger Stockfish + fewer games for the same wall-clock budget.

Warmup steps — Number of initial training steps over which lr ramps from 0 to peak_lr. We use 500.

Further reading