aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDylan <boss@tehbox.org>2026-11-09 16:38:13 +1300
committerDylan <boss@tehbox.org>2026-11-09 16:38:13 +1300
commit478e7568f5eda9780d1409bb8dccc86221126281 (patch)
tree2f4ec7a90d0c65505b757bdce0ddd2f9778ccaa5
parent0fd43eb6d9a4bae8d4c29fd614f9cf2a362f7b7c (diff)
downloadtigers-and-sheep-478e7568f5eda9780d1409bb8dccc86221126281.tar.gz
tigers-and-sheep-478e7568f5eda9780d1409bb8dccc86221126281.zip
feat: Multi-threaded training; play matches every 10 generations
Gradients and losses are computed on rayon's pool in fixed-size chunks summed in order, so the weights are the same on any number of threads. Self-play uses the same pool, and the output shows the training time. The matches against the earlier network and ClaudePlayer now run only every --match-every generations (default 10), against the generation that last played them, read back from its saved file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md4
-rw-r--r--Cargo.lock46
-rw-r--r--Cargo.toml1
-rw-r--r--README.md4
-rw-r--r--plans.org14
-rw-r--r--src/main.rs6
-rw-r--r--src/network.rs88
-rw-r--r--src/train.rs64
8 files changed, 189 insertions, 38 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 35305bc..e9b236b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -39,9 +39,9 @@ The toolchain comes from a Nix flake (`flake.nix`, loaded via direnv `.envrc` â†
- `src/local-player.rs` (module `local_player`, pulled in via `#[path]` because of the hyphen) — `LocalPlayer`, a human using the mouse: click a point to place a sheep, or click a piece then one of its highlighted destinations.
- `src/claude-player.rs` — `ClaudePlayer`, the minimax computer player: `time_limit` and `max_depth` are public fields, with a 2^20-slot `TranspositionTable` and killer moves.
- `src/bench.rs` — the `bench` mode (see above).
-- `src/network.rs` — `Network`, a small MLP (52 inputs → 64 ReLU → 32 ReLU → tanh, positive favours tigers) with hand-written backprop and an `Adam` optimiser, all in one flat `params` Vec at the `W1`/`B1`/… offsets. Inputs are only the raw position: a bit per point for tigers and for sheep, sheep in hand / 20, and whether tigers are to move. `forward` adds up the layer-1 weight rows of the set bits rather than multiplying through the zeros. Also `Rng` (xorshift64*) and `Sample` (board, turn, target). File format (`save`/`load`): `TSNN`, then version, inputs, hidden1, hidden2 as little-endian u32s, then every param as a little-endian f32; changing the layer sizes makes old files fail to load.
+- `src/network.rs` — `Network`, a small MLP (52 inputs → 64 ReLU → 32 ReLU → tanh, positive favours tigers) with hand-written backprop and an `Adam` optimiser, all in one flat `params` Vec at the `W1`/`B1`/… offsets. Inputs are only the raw position: a bit per point for tigers and for sheep, sheep in hand / 20, and whether tigers are to move. `forward` adds up the layer-1 weight rows of the set bits rather than multiplying through the zeros. `gradient` (used by `train_batch`) and `loss` run on rayon's pool in fixed-size chunks (`GRAD_CHUNK` = 32, `LOSS_CHUNK` = 1024) summed in order, so the result doesn't depend on the number of threads (`training_is_the_same_on_any_number_of_threads`); Adam's step stays serial. Also `Rng` (xorshift64*) and `Sample` (board, turn, target). File format (`save`/`load`): `TSNN`, then version, inputs, hidden1, hidden2 as little-endian u32s, then every param as a little-endian f32; changing the layer sizes makes old files fail to load.
- `src/neural-player.rs` — `NeuralPlayer`, a `MinimaxPlayer` like `ClaudePlayer` whose `evaluate` is a `Network`.
-- `src/train.rs` — self-play training. Each generation plays `games_per_generation` games of the net against itself at a fixed depth (default 3) across all cores. Moves are random for the first `random_plies`, and with chance `epsilon` after that. A game is a draw on a threefold repetition (`play` keeps the earlier boards and passes them to `choose`, so the search sees them too), or on reaching `ply_cap`, a backstop for long games that never repeat. Each position is labelled with a TD(λ) target worked backwards from the result (±1, or 0) through the searched scores. A position where a random move was played is labelled with its own search score instead, which cuts the chain there. Without that cut, the sheep's random moves (which usually hang a sheep) taught the net that tigers nearly always win. Self-play still ends mostly in tiger wins, because of those random moves, so judge progress by the match against `ClaudePlayer` (`match_games` = 100). The net then trains on the last `buffer_generations` generations, each position shown in a random one of the board's 8 symmetries (`GameBoard::transformed`). With `balance_outcomes` (the default), `balanced` builds each epoch from as many positions of sheep-win games as of tiger-win games: their mean, so the epoch stays the same size. Tiger-win positions are subsampled and sheep-win ones repeated; draws are kept as they are. The output shows the mean error on tiger-win (T) and sheep-win (S) positions separately, before and after training. After that it plays matches against the previous generation and against `ClaudePlayer`, which is only a yardstick and never a training signal. Each generation is saved as `out_dir/gen-NNN.bin` and `latest.bin`, and a rerun carries on from the newest `gen-NNN.bin`. `Config::from` (`--from`) starts from any weights file instead. Generation numbers still follow on from the out dir. The Adam state and the sample buffer are not saved, so they start empty either way.
+- `src/train.rs` — self-play training. Each generation plays `games_per_generation` games of the net against itself at a fixed depth (default 3) across all cores (`parallel`, on rayon's pool, which the training steps share; `RAYON_NUM_THREADS` limits it). Moves are random for the first `random_plies`, and with chance `epsilon` after that. A game is a draw on a threefold repetition (`play` keeps the earlier boards and passes them to `choose`, so the search sees them too), or on reaching `ply_cap`, a backstop for long games that never repeat. Each position is labelled with a TD(λ) target worked backwards from the result (±1, or 0) through the searched scores. A position where a random move was played is labelled with its own search score instead, which cuts the chain there. Without that cut, the sheep's random moves (which usually hang a sheep) taught the net that tigers nearly always win. Self-play still ends mostly in tiger wins, because of those random moves, so judge progress by the match against `ClaudePlayer` (`match_games` = 100). The net then trains on the last `buffer_generations` generations, each position shown in a random one of the board's 8 symmetries (`GameBoard::transformed`). With `balance_outcomes` (the default), `balanced` builds each epoch from as many positions of sheep-win games as of tiger-win games: their mean, so the epoch stays the same size. Tiger-win positions are subsampled and sheep-win ones repeated; draws are kept as they are. The output shows the mean error on tiger-win (T) and sheep-win (S) positions separately, before and after training, and how long the training part took (`train s`). On generations whose number is a multiple of `match_every` (default 10, `--match-every`), it then plays matches against the generation that last played them (N - `match_every`, loaded from its `gen-NNN.bin`, or the network the run started from if that file isn't there) and against `ClaudePlayer`, which is only a yardstick and never a training signal; other generations show `-` in those columns. Each generation is saved as `out_dir/gen-NNN.bin` and `latest.bin`, and a rerun carries on from the newest `gen-NNN.bin`. `Config::from` (`--from`) starts from any weights file instead. Generation numbers still follow on from the out dir. The Adam state and the sample buffer are not saved, so they start empty either way.
- `src/main.rs` — the command line, parsed with clap's derive API (`Cli`, `Command`, `PlayArgs`, `TrainArgs`). With no command, or `play`, it plays a game in the TUI and prints the result; `--sheep`/`--tiger` take `human`, `claude` or a weights file (`parse_player` → `PlayerKind`, turned into a `Box<dyn Player>` by `make_player`), and `--time` is each computer player's time limit in seconds. `neural <weights> [sheep|tiger]` is shorthand for you against a `NeuralPlayer`. `bench [depth]` runs the benchmark. `train [generations] [games] [out dir]` trains a network (use `--release`); every other `train::Config` field is a `--long-option` whose default is read from `Config::default()`, so keep `TrainArgs` and its `From` impl in step with `Config`.
Notation (`pos_name`, `impl Display for Move` in board.rs): files `a`–`e` are x 0–4, ranks are `5 - y` (y = 0 is drawn at the top as rank 5). Moves are `sc3` (place), `sc3c2` (sheep move), `ta5b4` (tiger step), `ta5xc3` (tiger jump).
diff --git a/Cargo.lock b/Cargo.lock
index b83b6bb..e821573 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -273,6 +273,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
+name = "crossbeam-deque"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1174,6 +1199,26 @@ dependencies = [
]
[[package]]
+name = "rayon"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1554,6 +1599,7 @@ version = "0.1.0"
dependencies = [
"clap",
"ratatui",
+ "rayon",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index d5f17ea..6b71bb7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,6 +10,7 @@ path = "src/main.rs"
[dependencies]
clap = { version = "4.6.7", features = ["derive"] }
ratatui = "0.30.2"
+rayon = "1.12.0"
[features]
# Count nodes, cache hits and cutoffs during the search, for `bench`.
diff --git a/README.md b/README.md
index 2d20ba5..e97a987 100644
--- a/README.md
+++ b/README.md
@@ -67,8 +67,8 @@ cargo run --release -- train [generations] [games per generation] [out dir] [--f
```
The defaults are 50 generations of 500 games, saved to `weights/`. Each generation, the network
-plays itself, learns from how those games went, then plays matches against the previous generation
-and against `ClaudePlayer`. The score against `ClaudePlayer` is the best guide to progress.
+plays itself and learns from how those games went. Every 10 generations (`--match-every`) it also
+plays matches against the generation it last tested (10 generations back) and against `ClaudePlayer`. The score against `ClaudePlayer` is the best guide to progress.
`ClaudePlayer` is only a yardstick, never something the network learns from.
Each generation is saved as `gen-NNN.bin`, and the newest as `latest.bin`. Running `train` again
diff --git a/plans.org b/plans.org
index b54040b..c2f36b2 100644
--- a/plans.org
+++ b/plans.org
@@ -81,6 +81,20 @@ Before the fix, tigers won ~97% of self-play games and the net only reached ~15-
*** Next candidates
- Pick exploratory moves among near-best ones (softmax over root scores) rather than uniformly
- Sheep are still the weaker side: report the match score for each side separately
+** DONE Multi-threaded training steps
+=gradient= and =loss= run on rayon's pool in fixed chunks (32 samples per gradient task), summed
+in order, so 1 thread and 16 give byte-identical weights. Self-play's =parallel()= uses the same pool.
+=train 4 500= from random weights, 16 cores, hpet clocksource, training part of each generation (s):
+| Gen | Samples | Before | After |
+|-----+---------+--------+-------|
+| 1 | 15112 | 0.09 | 0.03 |
+| 2 | 29658 | 0.17 | 0.06 |
+| 3 | 43748 | 0.27 | 0.09 |
+| 4 | 57978 | 0.35 | 0.13 |
+- About 2.7x, not 16x: a 256-sample batch is only 8 chunks, and the chunks' gradients are added
+ up serially. Training was only ~15% of a ~2 s generation, so the whole generation is ~10% faster.
+*** Next candidates
+- Bigger batches (or smaller chunks) for more parallel tasks per step; reuse the per-chunk gradient buffers
** DONE Balance tiger-win and sheep-win positions
The unbalanced net (gen 100) had 7x the error on positions from sheep-win games (0.045) as on
tiger-win ones (0.0065), so it leaned towards tiger wins, though not a constant prediction (that
diff --git a/src/main.rs b/src/main.rs
index 5a3c798..5237100 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -145,9 +145,12 @@ struct TrainArgs
/// Adam's learning rate
#[arg(long, default_value_t = train::Config::default().learning_rate)]
learning_rate: f32,
- /// Games against ClaudePlayer, and against the previous generation, after each generation
+ /// Games against ClaudePlayer, and against the generation that last played them
#[arg(long, default_value_t = train::Config::default().match_games)]
match_games: usize,
+ /// Play those matches only every this many generations
+ #[arg(long, default_value_t = train::Config::default().match_every)]
+ match_every: usize,
}
impl From<TrainArgs> for train::Config
@@ -171,6 +174,7 @@ impl From<TrainArgs> for train::Config
batch_size: args.batch_size,
learning_rate: args.learning_rate,
match_games: args.match_games,
+ match_every: args.match_every,
}
}
}
diff --git a/src/network.rs b/src/network.rs
index 5836798..0463ccf 100644
--- a/src/network.rs
+++ b/src/network.rs
@@ -1,5 +1,7 @@
use std::{fs, io, path::Path};
+use rayon::prelude::*;
+
use crate::board::{GameBoard, Turn};
/// Inputs: a bit per point for tigers (0..25) and for sheep (25..50), the sheep in hand over 20
@@ -24,6 +26,12 @@ const W3: usize = B2 + HIDDEN2;
const B3: usize = W3 + HIDDEN2;
pub const PARAMS: usize = B3 + 1;
+/// Samples per parallel task in `gradient`. The chunks are a fixed size and summed in order, so
+/// the result is the same whatever the number of threads.
+const GRAD_CHUNK: usize = 32;
+/// Samples per parallel task in `loss`, where each one is only a forward pass.
+const LOSS_CHUNK: usize = 1024;
+
const MAGIC: &[u8; 4] = b"TSNN";
const VERSION: u32 = 1;
@@ -224,11 +232,30 @@ impl Network
err * err / 2.0
}
+ /// The gradient of the error summed over `batch`, and that summed error, worked out in
+ /// `GRAD_CHUNK`-sized pieces across all cores.
+ fn gradient(&self, batch: &[Sample]) -> (Vec<f32>, f32)
+ {
+ let parts: Vec<(Vec<f32>, f32)> = batch.par_chunks(GRAD_CHUNK).map(|chunk|
+ {
+ let mut grad = vec![0.0; PARAMS];
+ let loss = chunk.iter().map(|s| self.add_gradient(s, &mut grad)).sum();
+ (grad, loss)
+ }).collect();
+ let mut grad = vec![0.0; PARAMS];
+ let mut loss = 0.0;
+ for (g, l) in parts
+ {
+ grad.iter_mut().zip(&g).for_each(|(a, b)| *a += b);
+ loss += l;
+ }
+ (grad, loss)
+ }
+
/// One optimiser step on the mean error over `batch`; returns that mean error.
pub fn train_batch(&mut self, batch: &[Sample], adam: &mut Adam) -> f32
{
- let mut grad = vec![0.0; PARAMS];
- let loss: f32 = batch.iter().map(|s| self.add_gradient(s, &mut grad)).sum();
+ let (mut grad, loss) = self.gradient(batch);
let n = batch.len().max(1) as f32;
for g in &mut grad
{
@@ -241,7 +268,11 @@ impl Network
/// The mean error over `samples`, without training.
pub fn loss(&self, samples: &[Sample]) -> f32
{
- let total: f32 = samples.iter().map(|s| { let e = self.forward(&s.board, s.turn) - s.target; e * e / 2.0 }).sum();
+ let parts: Vec<f32> = samples.par_chunks(LOSS_CHUNK)
+ .map(|chunk| chunk.iter().map(|s| { let e = self.forward(&s.board, s.turn) - s.target; e * e / 2.0 }).sum())
+ .collect();
+ // A fold from 0, since an empty f32 `sum` is -0
+ let total = parts.into_iter().fold(0.0, |a, b| a + b);
total / samples.len().max(1) as f32
}
@@ -380,6 +411,57 @@ mod tests
}
#[test]
+ fn parallel_gradient_matches_serial()
+ {
+ let net = Network::random(5);
+ // Not a multiple of GRAD_CHUNK, so the last chunk is short
+ let batch = samples(GRAD_CHUNK * 4 + 7, 9);
+ let (grad, loss) = net.gradient(&batch);
+
+ // The same chunks, one after another, give exactly the same sums
+ let mut serial = vec![0.0; PARAMS];
+ let mut serial_loss = 0.0;
+ for chunk in batch.chunks(GRAD_CHUNK)
+ {
+ let mut g = vec![0.0; PARAMS];
+ serial_loss += chunk.iter().map(|s| net.add_gradient(s, &mut g)).sum::<f32>();
+ serial.iter_mut().zip(&g).for_each(|(a, b)| *a += b);
+ }
+ assert_eq!(grad, serial);
+ assert_eq!(loss, serial_loss);
+
+ // And one running sum gives nearly the same
+ let mut plain = vec![0.0; PARAMS];
+ let plain_loss: f32 = batch.iter().map(|s| net.add_gradient(s, &mut plain)).sum();
+ assert!((loss - plain_loss).abs() < 1e-4 * plain_loss.abs().max(1.0));
+ for (a, b) in grad.iter().zip(&plain)
+ {
+ assert!((a - b).abs() < 1e-4 * b.abs().max(1.0), "{a} vs {b}");
+ }
+ }
+
+ #[test]
+ fn training_is_the_same_on_any_number_of_threads()
+ {
+ let train = |threads: usize|
+ {
+ let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
+ pool.install(||
+ {
+ let mut net = Network::random(2);
+ let data = samples(300, 4);
+ let mut adam = Adam::new(1e-3);
+ for batch in data.chunks(100)
+ {
+ net.train_batch(batch, &mut adam);
+ }
+ (net, Network::random(3).loss(&samples(3000, 6)))
+ })
+ };
+ assert_eq!(train(1), train(4));
+ }
+
+ #[test]
fn training_can_fit_a_few_samples()
{
let mut net = Network::random(1);
diff --git a/src/train.rs b/src/train.rs
index 1e68080..35f8fdd 100644
--- a/src/train.rs
+++ b/src/train.rs
@@ -1,4 +1,6 @@
-use std::{collections::VecDeque, fs, path::{Path, PathBuf}, sync::atomic::{AtomicUsize, Ordering}, time::{Duration, Instant}};
+use std::{collections::VecDeque, fs, path::{Path, PathBuf}, time::{Duration, Instant}};
+
+use rayon::prelude::*;
use crate::{board::{repetitions, GameBoard, Move, Outcome, Turn, REPETITION_DRAW, SYMMETRIES}, claude_player::ClaudePlayer, network::{Adam, Network, Rng, Sample}, neural_player::NeuralPlayer, player::{MinimaxPlayer, Player}};
@@ -29,8 +31,11 @@ pub struct Config
pub epochs: usize,
pub batch_size: usize,
pub learning_rate: f32,
- /// Games against `ClaudePlayer`, and against the previous generation, after each generation (half as each side).
+ /// Games against `ClaudePlayer`, and against the generation that last played these matches (half as each side).
pub match_games: usize,
+ /// Play those matches only on generations whose number is a multiple of this, so generation
+ /// N plays generation N - `match_every`.
+ pub match_every: usize,
}
impl Default for Config
@@ -54,6 +59,7 @@ impl Default for Config
batch_size: 256,
learning_rate: 1e-3,
match_games: 100,
+ match_every: 10,
}
}
}
@@ -128,27 +134,7 @@ fn td_targets(plies: &[Ply], outcome: Outcome, lambda: f32) -> Vec<f32>
/// Runs `f(0..n)` across all cores, returning the results in order.
fn parallel<T: Send>(n: usize, f: impl Fn(usize) -> T + Sync) -> Vec<T>
{
- let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(n.max(1));
- let next = AtomicUsize::new(0);
- let mut results: Vec<(usize, T)> = std::thread::scope(|s|
- {
- let handles: Vec<_> = (0..threads).map(|_| s.spawn(||
- {
- let mut done = Vec::new();
- loop
- {
- let i = next.fetch_add(1, Ordering::Relaxed);
- if i >= n
- {
- return done;
- }
- done.push((i, f(i)));
- }
- })).collect();
- handles.into_iter().flat_map(|h| h.join().expect("worker thread panicked")).collect()
- });
- results.sort_by_key(|(i, _)| *i);
- results.into_iter().map(|(_, r)| r).collect()
+ (0..n).into_par_iter().map(&f).collect()
}
/// A player that searches exactly `depth` plies.
@@ -270,13 +256,15 @@ pub fn run(cfg: &Config) -> std::io::Result<()>
}
None => Network::random(0x5EED),
};
+ // The opponent for the first matches, if the generation they should be against isn't saved
+ let initial = net.clone();
let mut adam = Adam::new(cfg.learning_rate);
// For each generation, its positions from tiger-win, sheep-win and drawn games
let mut buffer: VecDeque<[Vec<Sample>; 3]> = VecDeque::new();
let mut rng = Rng::new(first as u64);
println!("Self-play results, then the mean error on positions from tiger-win (T) and sheep-win (S) games, before and after training");
- println!("{:>4} {:>7} {:>6} {:>6} {:>6} {:>8} {:>8} {:>8} {:>8} {:>7} {:>9} {:>7}",
- "gen", "trained", "tigers", "sheep", "draws", "T before", "S before", "T after", "S after", "vs prev", "vs Claude", "time s");
+ println!("{:>4} {:>7} {:>6} {:>6} {:>6} {:>8} {:>8} {:>8} {:>8} {:>7} {:>9} {:>7} {:>7}",
+ "gen", "trained", "tigers", "sheep", "draws", "T before", "S before", "T after", "S after", "vs prev", "vs Claude", "train s", "time s");
for generation in first..first + cfg.generations
{
let start = Instant::now();
@@ -290,10 +278,11 @@ pub fn run(cfg: &Config) -> std::io::Result<()>
buffer.pop_front();
}
+ // Training: the losses before and after, and every epoch
+ let train_start = Instant::now();
let pools: [Vec<Sample>; 3] = [0, 1, 2].map(|k| buffer.iter().flat_map(|g| &g[k]).copied().collect());
let losses = |net: &Network| (net.loss(&pools[0]), net.loss(&pools[1]));
let before = losses(&net);
- let previous = net.clone();
let mut trained = 0;
for _ in 0..cfg.epochs
{
@@ -314,13 +303,28 @@ pub fn run(cfg: &Config) -> std::io::Result<()>
}
}
let after = losses(&net);
+ let train_time = train_start.elapsed();
- let vs_prev = match_score(&net, || neural(&previous, cfg.depth), cfg, seed_base ^ 0xA);
- let vs_claude = match_score(&net, || claude(cfg.depth), cfg, seed_base ^ 0xB);
+ // The matches are slow and only measure progress, so they are only played now and then
+ let every = cfg.match_every.max(1);
+ let (vs_prev, vs_claude) = if generation.is_multiple_of(every)
+ {
+ // Against the generation that last played them, read back from its file, so a rerun
+ // that carries on from the out dir still finds it
+ let last = cfg.out_dir.join(format!("gen-{:03}.bin", generation - every));
+ let previous = Network::load(&last).unwrap_or_else(|_| initial.clone());
+ let percent = |score: f32| format!("{:.0}%", score * 100.0);
+ (percent(match_score(&net, || neural(&previous, cfg.depth), cfg, seed_base ^ 0xA)),
+ percent(match_score(&net, || claude(cfg.depth), cfg, seed_base ^ 0xB)))
+ }
+ else
+ {
+ ("-".to_string(), "-".to_string())
+ };
net.save(&cfg.out_dir.join(format!("gen-{generation:03}.bin")))?;
net.save(&cfg.out_dir.join("latest.bin"))?;
- println!("{generation:>4} {trained:>7} {tigers:>6} {sheep:>6} {draws:>6} {:>8.4} {:>8.4} {:>8.4} {:>8.4} {:>6.0}% {:>8.0}% {:>7.1}",
- before.0, before.1, after.0, after.1, vs_prev * 100.0, vs_claude * 100.0, start.elapsed().as_secs_f64());
+ println!("{generation:>4} {trained:>7} {tigers:>6} {sheep:>6} {draws:>6} {:>8.4} {:>8.4} {:>8.4} {:>8.4} {vs_prev:>7} {vs_claude:>9} {:>7.2} {:>7.1}",
+ before.0, before.1, after.0, after.1, train_time.as_secs_f64(), start.elapsed().as_secs_f64());
}
Ok(())
}