# # This file is part of ctak. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with ctak. If not, see . import argparse import os import subprocess from pathlib import Path import numpy as np from ctak_train.candidate import Candidate, DataGenConfig, TrainingGridSearchConfig from ctak_train.data import GameLog, load_window from ctak_train.net import NET_DIMS from ctak_train.selfplay import fan_out from ctak_train.strength import fit_strengths, plot_strengths def run_step( *, candidates: list[Candidate], data_gen_config: DataGenConfig, log: GameLog, strength_history: list[np.ndarray], iter: int, ): def data_path(i: int) -> Path: return ( data_gen_config.root / "data" / f"selfplay-{data_gen_config.size}-v{i:03d}.json" ) print(f"\n== iteration {iter} ==") num_candidates = len(candidates) cand_pairs = [ (i, j) for i in range(num_candidates) for j in range(i, num_candidates) ] # 1. rebuild for i, j in cand_pairs: _ = subprocess.run( ["make", f"selfplay-{i}-{j}"], cwd=data_gen_config.root, check=True, capture_output=True, ) # 2. generate self-play data data = data_path(iter) print( f"selfplay: {num_candidates=}, {data_gen_config.games=}, " f"{data_gen_config.size=}, {data_gen_config.depth=}, {data_gen_config.min_ply=}, " f"{data_gen_config.train_only_self_play=}" ) out_games: list[str] = [] for out in fan_out( [ [ f"./selfplay-{i}-{j}", str(data_gen_config.size), str(data_gen_config.depth), str(data_gen_config.min_ply), ] for (i, j) in cand_pairs ], data_gen_config.games, data_gen_config.procs, "selfplay", data_gen_config.root, ): out_games.extend(out.splitlines()) print(f"writing {len(out_games):,} games to {data}") with open(data, "w") as f: _ = f.write("\n".join(out_games)) if out_games: _ = f.write("\n") # 3. load the training window and append this iteration's games to the log data_files = [ data_path(i) for i in range(max(iter - data_gen_config.replay_iters, 1), iter + 1) ] print("Loading data from", [str(c) for c in data_files], "...", end=" ") pools, new_games = load_window( data_files, data_gen_config.size, num_candidates, iter ) print("candidates have", [f"{p.n_rows:,}" for p in pools], "rows.") log.append(new_games) # 4. fit strengths over the whole history, and plot the trajectory strengths, se = fit_strengths(log, num_candidates) strength_history.append(strengths) plot_strengths( strength_history, candidates, data_gen_config.root / "data" / "candidate_strength.png", ) rated_games = int(np.count_nonzero(log.black != log.white)) ranked = sorted(range(num_candidates), key=lambda i: strengths[i], reverse=True) print( f"Strength fit ({rated_games:,} head-to-head games, " f"iterations 1..{iter}, {rated_games // num_candidates:,} games/candidate):" ) for idx in ranked: print( f"\tcandidate {idx:03d}: strength {strengths[idx]:6.1f} " f"± {1.96 * se[idx]:5.1f} Config {candidates[idx]}" ) # 5. write champ, unconditionally idx = ranked[0] path = data_gen_config.root / "include" / f"weights_{data_gen_config.size}.c" candidates[idx].net.to_c( size=data_gen_config.size, ident=None, path=path, note=f"{iter=}, strength={strengths[idx]:.1f}", ) _ = subprocess.run( ["make", "clean"], cwd=data_gen_config.root, check=True, capture_output=True ) _ = subprocess.run( ["make", "native"], cwd=data_gen_config.root, check=True, capture_output=True ) print(f"Wrote candidate {idx:03d} to {path!s}") # 6. fine-tune on the hybrid targets -> candidate for idx, cand in enumerate(candidates): corr, mse = cand.fit(pools[idx]) cand.net.to_c( size=data_gen_config.size, ident=idx, path=data_gen_config.root / "include" / f"cand_{idx}_weights_{data_gen_config.size}.c", note=f"id={idx}, v{iter:03d}, val_corr={corr:.4f}, val_mse={mse:.4f}", ) def main() -> None: # repo root, relative to this file at /train/src/ctak_train/nn_train.py root = Path(__file__).resolve().parents[3] p = argparse.ArgumentParser( description="Train the neural network evaluators of ctak", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) _ = p.add_argument( "--size", type=int, choices=sorted(NET_DIMS), default=5, help="board size", ) _ = p.add_argument( "--iters", type=int, default=100, help="max loop iterations", ) _ = p.add_argument( "--games", type=int, default=200, help="games per iteration", ) _ = p.add_argument( "--replay-iters", type=int, default=10, help="number of previous data files to keep in the training pools " "(every game record is kept for the strength fit)", ) _ = p.add_argument( "--smooth-factor", type=float, nargs="+", default=[0.3, 0.5, 0.7, 0.9], help="grid points for the EMA smoothing factor", ) _ = p.add_argument( "--epochs", type=int, nargs="+", default=[5], help="grid points for candidate training epochs", ) _ = p.add_argument( "--lr", type=float, nargs="+", default=[1e-4], help="grid points for learning rate", ) _ = p.add_argument( "--batch", type=int, nargs="+", default=[128], help="grid points for training batch size", ) _ = p.add_argument( "--val-frac", type=float, nargs="+", default=[0.05], help="grid points for fraction of rows held out for validation", ) _ = p.add_argument( "--procs", type=int, default=None, help="parallel game processes (number of CPUs if unset)", ) _ = p.add_argument( "--depth", type=int, default=4, help="search depth for selfplay and the matches", ) _ = p.add_argument( "--min-ply", type=int, default=8, help="length of random opening in games", ) _ = p.add_argument( "--train-all-games", action="store_true", help="train on all selfplay games, including matches between " "different candidates (default: self vs self games only)", ) args = p.parse_args() # avoid counter-play start if args.min_ply < 2: _ = p.error("--min-ply must be >= 2") if args.procs is None: args.procs = max(1, (os.cpu_count() or 1)) data_gen = DataGenConfig( size=args.size, depth=args.depth, min_ply=args.min_ply, games=args.games, procs=args.procs, replay_iters=args.replay_iters, root=root, train_only_self_play=not args.train_all_games, ) tgsc = TrainingGridSearchConfig( smooth_factor=args.smooth_factor, epochs=args.epochs, batch=args.batch, lr=args.lr, val_frac=args.val_frac, ) candidates = tgsc.make_configs(data_gen) (root / "data").mkdir(exist_ok=True) num_params = sum( p.numel() for p in candidates[0].net.parameters() if p.requires_grad ) print(f"Number of trainable parameters: {num_params:,}") log = GameLog.empty() strength_history: list[np.ndarray] = [] for iter in range(1, args.iters + 1): run_step( candidates=candidates, data_gen_config=data_gen, log=log, strength_history=strength_history, iter=iter, ) print(f"\n== done, champion in include/weights_{args.size}.c ==") if __name__ == "__main__": main()