#!/usr/bin/env -S uv run --script # # /// script # requires-python = ">=3.14" # dependencies = [ # "pandas>=3.0.5", # "torch>=2.14.0", # "tqdm>=4.70.0", # ] # # [tool.uv.sources] # torch = { index = "pytorch-cpu" } # # [[tool.uv.index]] # name = "pytorch-cpu" # url = "https://download.pytorch.org/whl/cpu" # explicit = true # /// # # 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 . from collections.abc import Iterator from dataclasses import dataclass import argparse import concurrent.futures import copy import os from pathlib import Path import subprocess import numpy as np import pandas as pd import torch from tqdm import tqdm from torch.nn import MSELoss, Linear, Module from torch import Tensor # =================================================================== # Net dimensions # =================================================================== # (input dim, hidden dim) per board size; must match include/weights_.h NET_DIMS: dict[int, tuple[int, int]] = { 5: (28, 64), # FIVE_INP_NUM, FIVE_DENSE_NUM 6: (39, 64), # SIX_INP_NUM, SIX_DENSE_NUM } # =================================================================== # C weight formatting # =================================================================== def _fmt_flat(a: np.ndarray, per_line: int = 8) -> str: a = np.ravel(np.asarray(a, dtype=np.float32)) lines: list[str] = [] for i in range(0, len(a), per_line): chunk = ", ".join(f"{v:.9g}" for v in a[i : i + per_line]) if i + per_line < len(a): chunk += "," lines.append(" " + chunk) return "\n".join(lines) def _fmt_matrix(mat: np.ndarray, per_line: int = 8) -> str: mat = np.asarray(mat, dtype=np.float32) rows: list[str] = [] for r in mat: chunks = [ ", ".join(f"{v:.9g}" for v in r[i : i + per_line]) for i in range(0, len(r), per_line) ] rows.append(" {" + ",\n ".join(chunks) + "},") return "\n".join(rows) # =================================================================== # Self-play records # =================================================================== def td_lambda_targets( values: Tensor, result: float, td_lambda: float, ) -> Tensor: # values: V(s_t) for t = 0..T-1, shape [T] # result: final result R of the game # This is ema, computed backwards # target[T-1] = R # target[t] = (1 - lambda) * V(s_{t+1}) + lambda * target[t+1] length = len(values) targets = torch.empty_like(values) targets[-1] = result for ply in range(length - 2, -1, -1): targets[ply] = (1.0 - td_lambda) * values[ply + 1] + td_lambda * targets[ ply + 1 ] return targets @dataclass(frozen=True) class SelfPlayRecords: positions: Tensor # [n, inp_dim] feature vector of each position game_ids: Tensor # [n] id of the game the position belongs to plies: Tensor # [n] ply number (position is before this move) results: Tensor # [n] final result from black's perspective (+1 black won, -1 white won, 0 draw) @classmethod def load_selfplay( cls, fn: Path, size: int, device: torch.device ) -> SelfPlayRecords: # [ net input: 0-inp_dim ] [game_id] [ply] [game result] df = pd.read_csv(fn, header=None) inp_dim = NET_DIMS[size][0] return cls( positions=torch.tensor( df.iloc[:, 0:inp_dim].to_numpy(dtype=np.float32), dtype=torch.float32, device=device, ), game_ids=torch.tensor( df.iloc[:, inp_dim].to_numpy(dtype=np.int64), device=device, ), plies=torch.tensor( df.iloc[:, inp_dim + 1].to_numpy(dtype=np.int64), device=device, ), results=torch.tensor( df.iloc[:, inp_dim + 2].to_numpy(dtype=np.float32), dtype=torch.float32, device=device, ), ) @classmethod def load_merge( cls, files: list[Path], size: int, device: torch.device ) -> SelfPlayRecords: sprs = [SelfPlayRecords.load_selfplay(file, size, device) for file in files] return cls( positions=torch.concat([spr.positions for spr in sprs]), game_ids=torch.concat([spr.game_ids for spr in sprs]), plies=torch.concat([spr.plies for spr in sprs]), results=torch.concat([spr.results for spr in sprs]), ) def make_training_data( self, model: Module, td_lambda: float ) -> tuple[Tensor, Tensor]: model.eval() with torch.no_grad(): values = model(self.positions) targets = torch.empty_like(values) for game_id in torch.unique(self.game_ids): in_game = self.game_ids == game_id game_values = values[in_game] game_result = self.results[in_game][0].item() targets[in_game] = td_lambda_targets(game_values, game_result, td_lambda) return self.positions, targets # =================================================================== # Network # =================================================================== class Net(Module): def __init__(self, inp_dim: int, hidden: int) -> None: super().__init__() self.hidden = Linear(inp_dim, hidden, bias=True) self.output = Linear(hidden, 1, bias=True) def forward(self, x: Tensor) -> Tensor: hidden = torch.relu(self.hidden(x)) return torch.tanh(self.output(hidden)).squeeze(-1) def fit( self, *, self_play_records: SelfPlayRecords, td_lambda: float, val_frac: float, epochs: int, batch: int, lr: float, ) -> tuple[float, float]: # data positions, targets = self_play_records.make_training_data(self, td_lambda) n = len(positions) perm = torch.randperm(n, device=positions.device) n_val = max(1, int(n * val_frac)) val_idx = perm[:n_val] train_idx = perm[n_val:] val_positions = positions[val_idx] val_targets = targets[val_idx] train_positions = positions[train_idx] train_targets = targets[train_idx] # training widgets optimizer = torch.optim.Adam(self.parameters(), lr=lr) loss_fn = MSELoss() best_corr = -1.0 best_state: dict[str, Tensor] | None = None best_mse = float("nan") pbar = tqdm(range(epochs), desc="train", unit="ep") for epoch in pbar: self.train() order = torch.randperm(len(train_positions), device=positions.device) for start in range(0, len(train_positions), batch): batch_idx = order[start : start + batch] optimizer.zero_grad() predictions = self(train_positions[batch_idx]) loss = loss_fn(predictions, train_targets[batch_idx]) loss.backward() optimizer.step() with torch.no_grad(): self.eval() predictions = self(val_positions) mse = loss_fn(predictions, val_targets).item() corr = torch.corrcoef(torch.stack([predictions, val_targets]))[ 0, 1 ].item() pbar.set_postfix(val_corr=f"{corr:.4f}", val_mse=f"{mse:.4f}") if corr > best_corr: best_corr = corr best_state = { name: param.clone() for name, param in self.state_dict().items() } best_mse = mse if best_state is not None: self.load_state_dict(best_state) return best_corr, best_mse def to_c( self, size: int, path: Path, performance: tuple[str, ...] = (), cand: bool = False, ) -> None: prefix = ("cand_" if cand else "") + ("five" if size == 5 else "six") upper = "FIVE" if size == 5 else "SIX" W = self.hidden.weight.detach().cpu().numpy() B = self.hidden.bias.detach().cpu().numpy() LW = self.output.weight.detach().cpu().numpy().ravel() LB = float(self.output.bias.detach().cpu().item()) with open(path, "w") as f: _ = f.write("/* generated by nn_train.py, do not edit by hand */\n") for p in performance: _ = f.write(f"/* {p} */\n") _ = f.write(f'#include "weights_{size}.h"\n\n') _ = f.write( f"const float {prefix}_dense_weights[{upper}_DENSE_NUM]" f"[{upper}_INP_NUM] NN_ALIGNED =\n{{\n{_fmt_matrix(W)}\n}};\n\n" ) _ = f.write( f"const float {prefix}_dense_biases[{upper}_DENSE_NUM] =\n" f"{{\n{_fmt_flat(B)}\n}};\n\n" ) _ = f.write( f"const float {prefix}_logit_weights[{upper}_DENSE_NUM]" f" NN_ALIGNED =\n{{\n{_fmt_flat(LW)}\n}};\n\n" ) _ = f.write(f"const float {prefix}_logit_bias = {LB:.9g};\n") # =================================================================== # Process helpers # =================================================================== def fan_out(cmd: list[str], n: int, procs: int, tag: str, root: Path) -> Iterator[str]: with concurrent.futures.ThreadPoolExecutor(max_workers=procs) as pool: futs = [ pool.submit( subprocess.run, cmd, cwd=root, check=True, capture_output=True, text=True, ) for _ in range(n) ] for fut in tqdm( concurrent.futures.as_completed(futs), total=n, desc=tag, unit="game" ): yield fut.result().stdout def cand_copy_for(size: int, root: Path) -> None: src = root / "include" / f"weights_{size}.c" if not src.is_file(): raise SystemExit(f"{src} not found; build a size-{size} champion first") prefix = "five" if size == 5 else "six" txt = src.read_text().replace(f"{prefix}_", f"cand_{prefix}_") _ = (root / "include" / f"cand_weights_{size}.c").write_text(txt) # =================================================================== # Tournament # =================================================================== @dataclass(frozen=True) class TournamentResult: champ: int cand: int draws: int cand_rate: float def run_matches( *, size: int, depth: int, tournament_eps: float, games: int, procs: int, root: Path, ) -> TournamentResult: outs = list( fan_out( ["./match", str(size), f"{tournament_eps:.4f}", str(depth)], games, procs, "match", root, ) ) winners = [line.split()[0] for line in outs if line.strip()] champ = winners.count("current") cand = winners.count("candidate") draws = winners.count("draw") aborts = len(winners) - champ - cand - draws played = len(winners) - aborts return TournamentResult( champ=champ, cand=cand, draws=draws, cand_rate=(cand + 0.5 * draws) / played if played else 0.0, ) # =================================================================== # The bootstrap loop # =================================================================== def setup(size: int, root: Path) -> Net: inp_dim, hidden = NET_DIMS[size] champ = Net(inp_dim, hidden) champ.to_c( size, root / "include" / f"weights_{size}.c", ("v000, random init",), ) cand_copy_for(6 if size == 5 else 5, root) return champ def run_step( *, batch: int, champ: Net, depth: int, epochs: int, eps_decay: float, eps_min: float, eps_start: float, games: int, iter: int, lr: float, min_ply: int, procs: int, promote_rate: float, replay_iters: int, root: Path, size: int, td_lambda: float, tournament_eps: float, tournament_games: int, val_frac: float, ) -> Net: def make_csv(i: int) -> Path: return root / "data" / f"selfplay-{size}-v{i:03d}.csv" print(f"\n== iteration {iter} ==") # 1. self-play with the installed champion (include/weights_.c) _ = subprocess.run(["make", "selfplay"], cwd=root, check=True, capture_output=True) # 2. generate self-play data (one game per process, in parallel) eps = max(eps_min, eps_start * (eps_decay ** (iter - 1))) csv = make_csv(iter) print(f"selfplay, eps={eps:.4f}, depth {depth}, {games} games on {procs} procs") out_rows: list[str] = [] for out in fan_out( ["./selfplay", str(size), f"{eps:.4f}", str(depth), str(min_ply)], games, procs, "selfplay", root, ): out_rows.extend(out.splitlines()) with open(csv, "w") as f: _ = f.write("\n".join(out_rows)) if out_rows: _ = f.write("\n") rows = len(out_rows) print(f" {rows} rows") # 3. fine-tune the champion on TD(lambda) targets -> candidate device = next(champ.parameters()).device csvs = [make_csv(i) for i in range(max(iter - replay_iters, 1), iter + 1)] print("Loading data from", csvs) records = SelfPlayRecords.load_merge(csvs, size, device) cand = copy.deepcopy(champ) corr, mse = cand.fit( self_play_records=records, td_lambda=td_lambda, val_frac=val_frac, epochs=epochs, batch=batch, lr=lr, ) cand.to_c( size, root / "include" / f"cand_weights_{size}.c", (f"v{iter:03d}, val_corr={corr:.4f}, val_mse={mse:.4f}",), cand=True, ) # 4. build the match driver (champion + candidate linked) _ = subprocess.run(["make", "match"], cwd=root, check=True, capture_output=True) # 5. tournament + decision tournament_result = run_matches( root=root, size=size, depth=depth, tournament_eps=tournament_eps, games=tournament_games, procs=procs, ) decided = tournament_result.champ + tournament_result.cand rate = tournament_result.cand / decided if decided else 0.0 print( f"champ {tournament_result.champ} - {tournament_result.cand} cand, {tournament_result.draws} draws, " + f"winrate={rate:.3f}, cand_rate={tournament_result.cand_rate:.3f}" ) promote = decided >= int(0.25 * tournament_games) and rate >= promote_rate if promote: cand.to_c( size, root / "include" / f"weights_{size}.c", (f"v{iter:03d}, val_corr={corr:.4f}",), ) print(f"== candidate v{iter:03d} PROMOTED (include/weights_{size}.c) ==") return cand print(f"== candidate v{iter:03d} rejected ==") return champ # =================================================================== # Main # =================================================================== def main() -> None: root = Path(__file__).resolve().parent.parent p = argparse.ArgumentParser( description="the TD(lambda) self-play bootstrap loop", 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=10, help="max loop iterations") _ = p.add_argument( "--games", type=int, default=2000, help="selfplay games per iteration" ) _ = p.add_argument( "--replay-iters", type=int, default=4, help="number of previous self-play instances to mix in with current dataset during training", ) _ = p.add_argument( "--eps-start", type=float, default=0.15, help="initial self-play epsilon (random-move probability); kept close " "to --tour-eps so the value net trains on the policy it is deployed " "under", ) _ = p.add_argument("--eps-min", type=float, default=0.05, help="epsilon floor") _ = p.add_argument( "--eps-decay", type=float, default=0.9, help="per-iteration epsilon decay" ) _ = p.add_argument("--td-lambda", type=float, default=0.6, help="TD(lambda) lambda") _ = p.add_argument( "--epochs", type=int, default=20, help="candidate training epochs" ) _ = p.add_argument("--lr", type=float, default=1e-3, help="learning rate") _ = p.add_argument("--batch", type=int, default=128, help="training batch size") _ = p.add_argument( "--val-frac", type=float, default=0.05, help="fraction of rows held out for validation", ) _ = p.add_argument( "--tournament-eps", type=float, default=0.05, help="tournament epsilon (random-move probability)", ) _ = p.add_argument( "--tournament-games", type=int, default=None, help="tournament games (10/tour_eps if unset)", ) _ = p.add_argument( "--promote", type=float, default=0.57, help="candidate win rate among non-drawn tournament games needed to promote (~p<0.03 one-sided binomial at the default 200 games)", ) _ = p.add_argument( "--procs", type=int, default=None, help="parallel game processes (half the CPUs if unset)", ) _ = p.add_argument( "--depth", type=int, default=4, help="search depth for selfplay and the tournament", ) _ = p.add_argument( "--min-ply", type=int, default=4, help="record positions at this ply or later" ) args = p.parse_args() if args.tournament_games is None: args.tournament_games = max(20, int(round(10 / args.tournament_eps))) if args.procs is None: args.procs = max(1, (os.cpu_count() or 1) // 2) (root / "data").mkdir(exist_ok=True) champ = setup(size=args.size, root=root) for iter in range(1, args.iters + 1): champ = run_step( batch=args.batch, champ=champ, depth=args.depth, epochs=args.epochs, eps_decay=args.eps_decay, eps_min=args.eps_min, eps_start=args.eps_start, games=args.games, iter=iter, lr=args.lr, min_ply=args.min_ply, procs=args.procs, promote_rate=args.promote, replay_iters=args.replay_iters, root=root, size=args.size, td_lambda=args.td_lambda, tournament_eps=args.tournament_eps, tournament_games=args.tournament_games, val_frac=args.val_frac, ) print(f"\n== done, champion in include/weights_{args.size}.c ==") if __name__ == "__main__": main()