The neural network and training
NeuralPlayer uses the same minimax search 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 and the training loop in
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:
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<f32> (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.
W1is input-major: inputi's weights to the 64 hidden units are contiguous, atW1 + i*64 .. W1 + (i+1)*64.W2is unit-major: hidden-2 unitk'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:
- Through
tanh:d_out = (out - target) × (1 - out²). - Layer 3's weight gradients are
d_out × h2. The gradient flowing into each hidden-2 unit isd_out × W3[k], or 0 where that ReLU was off. - The same again through layer 2 into hidden layer 1, skipping units with no gradient.
- 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
cargo run --release -- train [generations] [games] [out-dir] [--from <weights>] [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
NeuralPlayerthat searches to exactlydepth(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 (
playpasses the earlier boards to the search, so the search sees repetitions too), or afterply_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:
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
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:
cargo run --release -- -S human -T weights/latest.bin
