# The neural network and training `NeuralPlayer` uses the same [minimax search](search.md) as `ClaudePlayer`, but scores positions with a small neural network instead of hand-written rules. The network starts out random and learns only by playing against itself. No human idea of a good position goes into it, and `ClaudePlayer` is only ever used to measure progress, never as something to learn from. The network is in [`src/network.rs`](../src/network.rs) and the training loop in [`src/train.rs`](../src/train.rs). Neither uses a machine-learning library: the forward pass, the backpropagation and the optimiser are all written by hand. ## The network A multilayer perceptron: ```text 52 inputs ──▶ 64 ReLU ──▶ 32 ReLU ──▶ 1 tanh ``` The output is in `-1..1` and, like every score, positive favours the tigers. ### Inputs Only the raw position, nothing derived from it: | Inputs | Meaning | |--------|---------| | 0..25 | 1 if a tiger is on that point (bit `x + 5y`) | | 25..50 | 1 if a sheep is on that point | | 50 (`HAND`) | Sheep in hand / 20 | | 51 (`TIGER_TO_MOVE`) | 1 if tigers are to move | The network has to work out for itself that dead sheep are bad for the sheep, that a sheep with an empty point behind it can be jumped, and so on. ### Parameters All the weights and biases live in one flat `Vec` (`params`), at fixed offsets `W1`, `B1`, `W2`, `B2`, `W3`, `B3`, `PARAMS` in all (about 5,500). That lets the optimiser, the gradient and the file format treat them as one list of numbers. - `W1` is **input-major**: input `i`'s weights to the 64 hidden units are contiguous, at `W1 + i*64 .. W1 + (i+1)*64`. - `W2` is **unit-major**: hidden-2 unit `k`'s 64 incoming weights are contiguous. ### Forward pass Most inputs are 0: at most 4 tiger bits and 20 sheep bits are set out of 50. So instead of a full matrix multiply, `forward_full` starts layer 1 at its biases and **adds the weight row of each set input**, then adds `hand × W1[HAND]` and applies ReLU. That's why `W1` is input-major. `for_active` walks the set bits of the board masks with `trailing_zeros`. Layers 2 and 3 are ordinary dot products, then `tanh`. `forward_matches_a_plain_matrix_multiply` checks this against the textbook calculation. ### Initialisation `Network::random(seed)` uses He initialisation: normal weights scaled by `sqrt(2 / fan_in)`, with zero biases. For layer 1 the fan-in is taken as 20 rather than 52, since only about a third of the inputs are set at once. ### Backpropagation The loss for one sample is the squared error `(out - target)² / 2`. `add_gradient` runs the forward pass, keeping each layer's activations, then works backwards: 1. Through `tanh`: `d_out = (out - target) × (1 - out²)`. 2. Layer 3's weight gradients are `d_out × h2`. The gradient flowing into each hidden-2 unit is `d_out × W3[k]`, or 0 where that ReLU was off. 3. The same again through layer 2 into hidden layer 1, skipping units with no gradient. 4. Layer 1: only the set inputs have non-zero gradients, so again only their rows are touched. `gradient_matches_finite_differences` checks every layer's gradients against numerical estimates. ### Parallel and deterministic `gradient(batch)` splits a batch into chunks of `GRAD_CHUNK` = 32 samples, computes each chunk's gradient on rayon's thread pool, then **adds the chunks up in order**. `loss` does the same in chunks of `LOSS_CHUNK` = 1024. Floating-point addition isn't associative, so summing in a fixed order is what makes the result identical whatever the number of threads (`training_is_the_same_on_any_number_of_threads`). Training is therefore reproducible: the same seed gives the same network on any machine with any `RAYON_NUM_THREADS`. ### The optimiser: Adam `train_batch` averages the gradient over the batch and takes one `Adam` step. Adam keeps running averages of each parameter's gradient (`m`, decay `β1` = 0.9) and squared gradient (`v`, decay `β2` = 0.999), corrects their bias towards their zero start, and moves each parameter by `learning_rate × m̂ / (√v̂ + ε)`. In effect every parameter gets its own step size, which suits a network where some inputs (a sheep on a rarely used point) are seen far less often than others. ### File format `save` and `load` use a small binary format, all little-endian: | Bytes | Contents | |-------|----------| | 0..4 | `TSNN` | | 4..8 | version (`u32`, currently 1) | | 8..20 | inputs, hidden 1, hidden 2 (`u32` each) | | 20.. | every parameter as `f32`, in `params` order | `load` rejects files of the wrong size, magic, version or layer sizes with `InvalidData`. So changing the layer sizes makes old weights fail to load, and changing what an input *means* without changing the sizes calls for bumping `VERSION`. ## Self-play training ```bash cargo run --release -- train [generations] [games] [out-dir] [--from ] [options] ``` Training runs in **generations**. Each one plays a batch of games, turns them into training samples, trains the network on them, and saves it. ### 1. Starting point `run` starts from `--from` if given, otherwise the newest `gen-NNN.bin` in the out dir, otherwise random weights (seed `0x5EED`). Generation numbers carry on from the newest file in the out dir either way, so a run can be stopped and restarted. The Adam state and the sample buffer aren't saved, so they start empty on a restart. ### 2. Self-play Each generation plays `games_per_generation` (500) games of the network against itself, in parallel across all cores (`parallel`, on rayon's pool). Each game: - uses a `NeuralPlayer` that searches to exactly `depth` (3) plies. A depth limit rather than a time limit keeps runs reproducible, and the game's seed is derived from the generation and game number; - plays random moves for the first `random_plies` (10) plies, so games start from different places; - after that, plays a random move instead of the searched one with probability `epsilon` (0.05), to explore positions the network would never choose; - is a draw on a threefold repetition (`play` passes the earlier boards to the search, so the search sees repetitions too), or after `ply_cap` (200) plies, a backstop for games that wander without repeating. Every position is recorded as a `Ply` with the search's score for it (clamped to `-1..1`, so forced wins become ±1), and whether a random move was played there. ### 3. Labelling positions: TD(λ) Each position needs a target, the score the network should learn to give it. Two obvious choices both have problems: - **The game's result** (+1, -1 or 0) is the truth, but noisy: a position early in the game had many moves, some of them random, between it and the result. - **The search score** of the next position is less noisy but biased, since it comes from the network itself. TD(λ) blends them, working backwards from the end of the game: ```text target[last] = result target[t] = (1 - λ) × score[t+1] + λ × target[t+1] ``` With `λ = 1` every position is labelled with the result; with `λ = 0` each is labelled with the next position's search score. The default `lambda` is 0.7. **Random moves cut the chain.** A position where a random move was played is labelled with its own search score instead, and the positions before it then build on that, so they don't learn from the random move's consequences. This matters because random moves aren't equally bad for both sides: a random sheep move usually leaves a sheep to be jumped, while a random tiger move costs little. Without the cut, the network learnt that tigers nearly always win. ### 4. The sample buffer The samples of each generation are kept, split by outcome (tiger win, sheep win, draw), and the network trains on the last `buffer_generations` (5) generations together. Training on more than the latest games smooths out the network's swings from one generation to the next. ### 5. Balancing outcomes Self-play ends mostly in tiger wins, largely because of those random sheep moves. Trained on everything, the network would mostly learn what tiger wins look like. So with `balance_outcomes` (on by default; `--no-balance` turns it off), `balanced` builds each epoch from: - as many positions from sheep-win games as from tiger-win games: their mean, so the epoch keeps the same size. The bigger pool gives a random subset (every position at most once), and the smaller one gives every position plus random repeats; - every position from drawn games, once. If either kind of win has no positions at all there is nothing to balance, and everything is used. ### 6. Training For each of `epochs` (2) epochs: build the epoch's samples, shuffle them, show each in a **random one of the 8 symmetric orientations** (`GameBoard::transformed`), and train in batches of `batch_size` (256) with Adam at `learning_rate` (1e-3). The random orientations are data augmentation: they teach the network that symmetric positions are equally good, and multiply the variety of positions it sees. ### 7. Matches On every generation whose number is a multiple of `match_every` (10), the network plays two matches of `match_games` (100) games each, alternating sides, at the same depth: - against the generation that last played matches (N − 10), loaded from its `gen-NNN.bin`, or the network the run started from if that file is missing; - against `ClaudePlayer`. Each pair of games (one as each side) shares a seed, so both sides get the same 4 random opening moves. The score is the share of points won, with a draw worth half. Matches are slow and only measure progress, so other generations skip them. ### 8. Saving Each generation is saved as `out_dir/gen-NNN.bin` and copied to `out_dir/latest.bin`. ## Reading the output ```text gen trained tigers sheep draws T before S before T after S after vs prev vs Claude train s time s 499 128657 460 22 18 0.0161 0.0211 0.0194 0.0165 - - 0.26 1.3 500 128335 461 17 22 0.0198 0.0185 0.0161 0.0229 45% 82% 0.28 2.2 ``` | Column | Meaning | |--------|---------| | `gen` | Generation number | | `trained` | Positions in each epoch | | `tigers`, `sheep`, `draws` | How this generation's self-play games ended | | `T before`, `S before` | Mean error on positions from tiger-win and sheep-win games in the buffer, before training | | `T after`, `S after` | The same after training | | `vs prev`, `vs Claude` | Match scores (only every `match_every` generations; `-` otherwise) | | `train s`, `time s` | Seconds spent training, and for the whole generation | **Judge progress by `vs Claude`.** Self-play results say little, since the random moves skew them towards tigers, and the errors measure how well the network fits its own targets, not how well it plays. `vs prev` above 50% means the network beats its older self. ## Settings Every `train::Config` field is a command-line option (`train --help`): | Option | Default | Meaning | |--------|---------|---------| | `generations` (positional) | 50 | Generations to train | | `games` (positional) | 500 | Self-play games per generation | | `out_dir` (positional) | `weights` | Where weights are saved and resumed from | | `--from` | – | Start from this weights file | | `--depth` | 3 | Search depth in self-play and matches | | `--ply-cap` | 200 | A game this long is a draw | | `--random-plies` | 10 | Random opening plies in self-play | | `--epsilon` | 0.05 | Chance of a random move after that | | `--lambda` | 0.7 | TD(λ) blend | | `--buffer-generations` | 5 | Generations of samples to train on | | `--no-balance` | off | Train on all positions without balancing outcomes | | `--epochs` | 2 | Passes over the samples per generation | | `--batch-size` | 256 | Positions per optimiser step | | `--learning-rate` | 1e-3 | Adam's learning rate | | `--match-games` | 100 | Games per match | | `--match-every` | 10 | Play matches every this many generations | `RAYON_NUM_THREADS` limits the number of threads; the results don't depend on it. Then play the result: ```bash cargo run --release -- -S human -T weights/latest.bin ```