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}};
/// Settings for `run`.
#[derive(Clone, Debug)]
pub struct Config
{
pub generations: usize,
pub games_per_generation: usize,
pub out_dir: PathBuf,
/// Start from this network instead of the newest one in `out_dir` (or a random one).
pub from: Option<PathBuf>,
/// Search depth for every move in self-play and matches. The depth cap, not a time limit,
/// ends each search, so runs are repeatable.
pub depth: usize,
/// A game this long is called a draw, since nothing in the rules ends a game that goes round in circles.
pub ply_cap: usize,
/// Self-play picks moves at random for this many plies, so games start in different places.
pub random_plies: usize,
/// After that, the chance of a random move instead of the searched one.
pub epsilon: f32,
/// TD(λ): 1 trains every position on its game's result, smaller trusts later positions' search scores more.
pub lambda: f32,
/// Train on the samples of this many of the latest generations.
pub buffer_generations: usize,
/// Train on as many positions from sheep-win games as from tiger-win games (see `balanced`).
pub balance_outcomes: bool,
pub epochs: usize,
pub batch_size: usize,
pub learning_rate: f32,
/// 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
{
fn default() -> Self
{
Self
{
generations: 50,
games_per_generation: 500,
out_dir: PathBuf::from("weights"),
from: None,
depth: 3,
ply_cap: 200,
random_plies: 10,
epsilon: 0.05,
lambda: 0.7,
buffer_generations: 5,
balance_outcomes: true,
epochs: 2,
batch_size: 256,
learning_rate: 1e-3,
match_games: 100,
match_every: 10,
}
}
}
/// A position from a game, with the searching player's score for it (clamped to -1..1).
#[derive(Clone, Copy, Debug)]
struct Ply
{
board: GameBoard,
turn: Turn,
score: f32,
/// A random move was played here instead of the searched one.
random: bool,
}
/// Plays one game from the start. `choose` gives the move and score for a position, given the
/// boards before it; for the first `random_plies` plies, and with chance `epsilon` after, a random
/// move is played instead (the position keeps `choose`'s score, and is marked `random` unless the
/// two moves are the same). A threefold repetition, or reaching `ply_cap`, is a draw.
fn play(mut choose: impl FnMut(GameBoard, Turn, &[GameBoard]) -> (Move, f32), ply_cap: usize, random_plies: usize, epsilon: f32, rng: &mut Rng)
-> (Outcome, Vec<Ply>)
{
let mut board = GameBoard::default();
let mut turn = Turn::Sheep;
let mut plies = Vec::new();
let mut previous = Vec::new();
while plies.len() < ply_cap
{
if board.check_win(turn)
{
// The side that just moved has won
return (Outcome::win(turn.next_turn()), plies);
}
if repetitions(&previous, &board) >= REPETITION_DRAW
{
return (Outcome::Draw, plies);
}
let (searched, score) = choose(board, turn, &previous);
let mut m = searched;
if plies.len() < random_plies || rng.next_f32() < epsilon
{
let moves = board.get_moves(turn);
m = moves[rng.below(moves.len())];
}
plies.push(Ply { board, turn, score: score.clamp(-1.0, 1.0), random: m != searched });
previous.push(board);
board.make_move_unchecked(m);
turn = turn.next_turn();
}
(Outcome::Draw, plies)
}
/// TD(λ) targets, worked backwards: the last position's target is the result, and each earlier
/// one's is `(1 - λ)` times the next position's score plus `λ` times the next position's target.
///
/// A position where a random move was played is instead labelled with its own search score, which
/// also cuts the chain there, so earlier positions don't learn from the random move's result.
/// Without the cut, the sheep's random moves (which usually leave a sheep to be jumped, where a
/// tiger's random move costs little) taught the net that tigers nearly always win.
fn td_targets(plies: &[Ply], outcome: Outcome, lambda: f32) -> Vec<f32>
{
let mut targets = vec![0.0; plies.len()];
let mut next = outcome.value();
for t in (0..plies.len()).rev()
{
targets[t] = if plies[t].random { plies[t].score } else { next };
next = (1.0 - lambda) * plies[t].score + lambda * targets[t];
}
targets
}
/// 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>
{
(0..n).into_par_iter().map(&f).collect()
}
/// A player that searches exactly `depth` plies.
fn neural(net: &Network, depth: usize) -> NeuralPlayer
{
let mut player = NeuralPlayer::new(net.clone(), Duration::from_secs(1 << 20), 16);
player.max_depth = depth;
player
}
fn claude(depth: usize) -> ClaudePlayer
{
let mut player = ClaudePlayer::new(Duration::from_secs(1 << 20));
player.max_depth = depth;
player
}
/// The move a minimax player picks, and the score it gave it.
fn search(player: &mut (impl MinimaxPlayer + Player), board: GameBoard, turn: Turn, previous: &[GameBoard]) -> (Move, f32)
{
let m = player.pick_move(board, turn, previous).expect("the game is not over, so there is a move");
(m, player.last_search().map_or(0.0, |r| r.score))
}
/// One self-play game of `net` against itself, as training samples.
fn self_play(net: &Network, cfg: &Config, seed: u64) -> (Outcome, Vec<Sample>)
{
let mut player = neural(net, cfg.depth);
let mut rng = Rng::new(seed);
let (outcome, plies) = play(|b, t, p| search(&mut player, b, t, p), cfg.ply_cap, cfg.random_plies, cfg.epsilon, &mut rng);
let targets = td_targets(&plies, outcome, cfg.lambda);
let samples = plies.iter().zip(targets).map(|(p, target)| Sample { board: p.board, turn: p.turn, target }).collect();
(outcome, samples)
}
/// The share of points `net` scores over `games` against `opponent()` (a win is 1, a draw ½),
/// playing sheep in even games and tigers in odd ones. Each pair of games shares a seed, so both
/// sides get the same few random opening moves.
fn match_score<P: MinimaxPlayer + Player>(net: &Network, opponent: impl Fn() -> P + Sync, cfg: &Config, seed: u64) -> f32
{
let points: f32 = parallel(cfg.match_games, |i|
{
let mut me = neural(net, cfg.depth);
let mut them = opponent();
let neural_side = if i % 2 == 0 { Turn::Sheep } else { Turn::Tiger };
let mut rng = Rng::new(seed ^ (i / 2) as u64);
let (outcome, _) = play(|b, t, p| if t == neural_side { search(&mut me, b, t, p) } else { search(&mut them, b, t, p) },
cfg.ply_cap, 4, 0.0, &mut rng);
match (outcome, neural_side)
{
(Outcome::Draw, _) => 0.5,
(Outcome::Tiger, Turn::Tiger) | (Outcome::Sheep, Turn::Sheep) => 1.0,
_ => 0.0,
}
}).into_iter().sum();
points / cfg.match_games.max(1) as f32
}
/// One epoch's training positions, taken from the positions of tiger-win, sheep-win and drawn
/// games. Self-play ends mostly in tiger wins, so training on everything would teach the net
/// mostly what tiger wins look like. Instead this takes the same number from each kind of win:
/// their mean, so the epoch is the same size. The bigger pool gives a random subset, and the
/// smaller one gives every position plus random repeats (shown in different orientations by
/// the caller). Draws are kept as they are. If either kind of win has no positions, everything is used.
fn balanced(pools: &[Vec<Sample>; 3], rng: &mut Rng) -> Vec<Sample>
{
let [tigers, sheep, draws] = pools;
if tigers.is_empty() || sheep.is_empty()
{
return pools.concat();
}
let n = (tigers.len() + sheep.len()) / 2;
let mut out = draws.clone();
for pool in [tigers, sheep]
{
// Every position once while there are enough, picked by a partial shuffle of the indices
let mut idx: Vec<usize> = (0..pool.len()).collect();
for i in 0..n.min(pool.len())
{
let j = i + rng.below(pool.len() - i);
idx.swap(i, j);
out.push(pool[idx[i]]);
}
for _ in pool.len()..n
{
out.push(pool[rng.below(pool.len())]);
}
}
out
}
/// The newest `gen-NNN.bin` in `dir`, as (generation, path).
fn latest_generation(dir: &Path) -> Option<(usize, PathBuf)>
{
fs::read_dir(dir).ok()?
.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().into_string().ok()?;
let n = name.strip_prefix("gen-")?.strip_suffix(".bin")?.parse().ok()?;
Some((n, e.path()))
})
.max_by_key(|(n, _)| *n)
}
/// Trains a network by self-play, saving each generation to `out_dir/gen-NNN.bin` and `out_dir/latest.bin`.
/// Starts from `cfg.from` if given, otherwise carries on from the newest generation already in
/// `out_dir`, if there is one. Generations are numbered on from the newest in `out_dir` either way.
pub fn run(cfg: &Config) -> std::io::Result<()>
{
fs::create_dir_all(&cfg.out_dir)?;
let latest = latest_generation(&cfg.out_dir);
let first = latest.as_ref().map_or(1, |(n, _)| n + 1);
let mut net = match cfg.from.as_ref().or(latest.as_ref().map(|(_, path)| path))
{
Some(path) =>
{
println!("Carrying on from {}", path.display());
Network::load(path)?
}
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} {:>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();
let seed_base = (generation as u64) << 32;
let games = parallel(cfg.games_per_generation, |i| self_play(&net, cfg, seed_base | i as u64));
let count = |o: Outcome| games.iter().filter(|(g, _)| *g == o).count();
let (tigers, sheep, draws) = (count(Outcome::Tiger), count(Outcome::Sheep), count(Outcome::Draw));
buffer.push_back(Outcome::ALL.map(|o| games.iter().filter(|(g, _)| *g == o).flat_map(|(_, s)| s).copied().collect()));
while buffer.len() > cfg.buffer_generations
{
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 mut trained = 0;
for _ in 0..cfg.epochs
{
let mut samples = if cfg.balance_outcomes { balanced(&pools, &mut rng) } else { pools.concat() };
trained = samples.len();
// Shuffle, and show each position in a random one of its 8 orientations
for i in (1..samples.len()).rev()
{
samples.swap(i, rng.below(i + 1));
}
for s in &mut samples
{
s.board = s.board.transformed(rng.below(SYMMETRIES));
}
for batch in samples.chunks(cfg.batch_size)
{
net.train_batch(batch, &mut adam);
}
}
let after = losses(&net);
let train_time = train_start.elapsed();
// 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} {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(())
}
#[cfg(test)]
mod tests
{
use super::*;
fn ply(score: f32) -> Ply
{
Ply { board: GameBoard::default(), turn: Turn::Sheep, score, random: false }
}
#[test]
fn td_targets_blend_scores_and_the_result()
{
let plies = [ply(0.0), ply(0.2), ply(-0.4)];
let t = td_targets(&plies, Outcome::Tiger, 0.5);
assert_eq!(t[2], 1.0);
assert_eq!(t[1], 0.5 * -0.4 + 0.5 * 1.0);
assert_eq!(t[0], 0.5 * 0.2 + 0.5 * t[1]);
// λ = 1 is just the result
assert_eq!(td_targets(&plies, Outcome::Sheep, 1.0), vec![-1.0; 3]);
}
#[test]
fn td_targets_stop_at_random_moves()
{
let random = Ply { random: true, ..ply(0.3) };
let plies = [ply(0.0), random, ply(-0.4)];
let t = td_targets(&plies, Outcome::Tiger, 1.0);
// After the random move the result counts; at it and before it, only its search score does
assert_eq!(t, vec![0.3, 0.3, 1.0]);
let t = td_targets(&plies, Outcome::Tiger, 0.5);
assert_eq!(t[1], 0.3);
assert_eq!(t[0], 0.3);
}
#[test]
fn self_play_games_are_legal_and_repeatable()
{
let cfg = Config { depth: 1, ply_cap: 60, ..Config::default() };
let net = Network::random(3);
let (outcome, samples) = self_play(&net, &cfg, 42);
assert_eq!(self_play(&net, &cfg, 42), (outcome, samples.clone()));
assert!(!samples.is_empty() && samples.len() <= cfg.ply_cap);
assert_eq!(samples[0].board, GameBoard::default());
// Each position follows from the one before by a legal move
for w in samples.windows(2)
{
assert_eq!(w[1].turn, w[0].turn.next_turn());
assert!(w[0].board.get_moves(w[0].turn).iter().any(|&m| w[0].board.after_move_unchecked(m) == w[1].board));
}
assert!(samples.iter().all(|s| (-1.0..=1.0).contains(&s.target)));
// A draw is the ply cap, or a position coming up a third time (the last time isn't a sample)
let repeated = samples.iter().any(|s| samples.iter().filter(|o| o.board == s.board && o.turn == s.turn).count() >= 2);
if outcome == Outcome::Draw
{
assert!(samples.len() == cfg.ply_cap || repeated);
}
}
#[test]
fn balanced_takes_as_many_of_each_win()
{
let sample = |target: f32| Sample { board: GameBoard::default(), turn: Turn::Sheep, target };
let pools = [vec![sample(1.0); 90], (0..10).map(|i| sample(-(i as f32) / 10.0)).collect(), vec![sample(0.5); 4]];
let mut rng = Rng::new(1);
let out = balanced(&pools, &mut rng);
assert_eq!(out.len(), 50 + 50 + 4);
assert_eq!(out.iter().filter(|s| s.target == 1.0).count(), 50);
assert_eq!(out.iter().filter(|s| s.target == 0.5).count(), 4);
// The smaller pool's positions are all there, before any repeats
let sheep: Vec<f32> = out.iter().map(|s| s.target).filter(|&t| t <= 0.0).collect();
assert_eq!(sheep.len(), 50);
assert!((0..10).all(|i| sheep[..10].contains(&(-(i as f32) / 10.0))));
// Without sheep wins there is nothing to balance
let lopsided = [vec![sample(1.0); 5], vec![], vec![sample(0.0); 2]];
assert_eq!(balanced(&lopsided, &mut rng).len(), 7);
}
#[test]
fn parallel_keeps_the_order()
{
assert_eq!(parallel(100, |i| i * 2), (0..100).map(|i| i * 2).collect::<Vec<_>>());
}
}