#!/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 Embedding, 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( leaf_values: Tensor, result: float, td_lambda: float, ) -> Tensor: targets = torch.empty_like(leaf_values) targets[-1] = result # G[t] = (1-lambda)V[t+1] + lambda G[t+1] for ply in range(len(leaf_values) - 2, -1, -1): targets[ply] = (1.0 - td_lambda) * leaf_values[ply + 1] + td_lambda * targets[ ply + 1 ] return targets @dataclass(frozen=True) class SelfPlayRecords: positions: Tensor # [n, inp_dim] game_ids: Tensor # [n] id of the game the position belongs to results: Tensor # [n] final played result from black's perspective terminals: Tensor # [n] true when the recorded position is a terminal leaf terminal_values: Tensor # [n] exact value of a terminal leaf, otherwise 0 @classmethod def load_selfplay( cls, fn: Path, size: int, device: torch.device ) -> SelfPlayRecords: # [net input] [game_id] [played result] [terminal flag] [leaf 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, ), results=torch.tensor( df.iloc[:, inp_dim + 1].to_numpy(dtype=np.float32), dtype=torch.float32, device=device, ), terminals=torch.tensor( df.iloc[:, inp_dim + 2].to_numpy(dtype=np.bool_), dtype=torch.bool, device=device, ), terminal_values=torch.tensor( df.iloc[:, inp_dim + 3].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]), results=torch.concat([spr.results for spr in sprs]), terminals=torch.concat([spr.terminals for spr in sprs]), terminal_values=torch.concat([spr.terminal_values 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_terminals = self.terminals[in_game] game_terminal_values = self.terminal_values[in_game] game_values = values[in_game].clone() game_values[game_terminals] = game_terminal_values[game_terminals] game_result = self.results[in_game][0].item() game_targets = td_lambda_targets(game_values, game_result, td_lambda) # Terminal positions are also direct supervised examples. Do this # after constructing the TD sequence so it does not reset the EMA. game_targets[game_terminals] = game_terminal_values[game_terminals] targets[in_game] = game_targets return self.positions, targets # =================================================================== # Network # =================================================================== class Net(Module): def __init__(self, inp_dim: int, hidden: int) -> None: super().__init__() # learned cell-state embedding, see nn.c self.embedding = Embedding(7, 1) self.hidden = Linear(inp_dim, hidden, bias=True) self.output = Linear(hidden, 1, bias=True) def forward(self, x: Tensor) -> Tensor: # x: [n, 3 + n_cells]; the first 3 columns are the fixed head # inputs, the rest are raw cell states (0..6) n = x.shape[0] inp = torch.cat([x[:, :3], self.embedding(x[:, 3:].long()).view(n, -1)], dim=1) hidden = torch.relu(self.hidden(inp)) 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.AdamW(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" E = self.embedding.weight.detach().cpu().numpy() 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}_embedding[{len(E)}] =\n" f"{{\n{_fmt_flat(E, per_line=7)}\n}};\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 def run_matches( *, depth: int, games: int, procs: int, root: Path, size: int, match_random_opening_length: int, ) -> TournamentResult: outs = list( fan_out( ["./match", str(size), f"{match_random_opening_length}", 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") return TournamentResult(champ=champ, cand=cand, draws=draws) # =================================================================== # 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, games: int, iter: int, lr: float, matches: int, match_random_opening_length: int, min_ply: int, procs: int, promote_rate: float, replay_iters: int, root: Path, size: int, td_lambda: float, 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. rebuild _ = subprocess.run(["make", "selfplay"], cwd=root, check=True, capture_output=True) _ = subprocess.run(["make", "native"], cwd=root, check=True, capture_output=True) # 2. generate self-play data csv = make_csv(iter) print(f"selfplay: {size=}, {depth=}, {min_ply=}") out_rows: list[str] = [] for out in fan_out( ["./selfplay", str(size), 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") # 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", [str(c) for c in csvs], "...", end=" ") records = SelfPlayRecords.load_merge(csvs, size, device) print(f"{records.results.shape[0]:,} rows") 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) _ = subprocess.run(["make", "match"], cwd=root, check=True, capture_output=True) promote = True rate = -1.0 if promote_rate > 0: # 5. tournament + decision print(f"match: {size=}, {match_random_opening_length=}, {depth=}") tournament_result = run_matches( depth=depth, games=matches, procs=procs, root=root, size=size, match_random_opening_length=match_random_opening_length, ) 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, winrate={rate:.3f}" ) promote = decided >= int(0.25 * matches) and rate > promote_rate if promote: cand.to_c( size, root / "include" / f"weights_{size}.c", (f"v{iter:03d}, val_corr={corr:.4f}, rate={rate:.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=100, help="max loop iterations", ) _ = p.add_argument( "--games", type=int, default=1000, 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( "--td-lambda", type=float, default=0.7, help="TD(lambda) lambda", ) _ = p.add_argument( "--epochs", type=int, default=20, help="candidate training epochs", ) _ = p.add_argument( "--lr", type=float, default=1e-4, 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( "--matches", type=int, default=200, help="how many matches in a tournament", ) _ = p.add_argument( "--match-random-opening-length", type=int, default=2, help="length of random opening in a match", ) _ = p.add_argument( "--promote", type=float, default=0.53, help="candidate win rate among non-drawn matches needed to promote", ) _ = 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 matches", ) _ = p.add_argument( "--min-ply", type=int, default=8, help="record positions at this ply or later", ) args = p.parse_args() 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) num_params = sum(p.numel() for p in champ.parameters() if p.requires_grad) print(f"Number of trainable parameters: {num_params:,}") for iter in range(1, args.iters + 1): champ = run_step( batch=args.batch, champ=champ, depth=args.depth, epochs=args.epochs, games=args.games, iter=iter, lr=args.lr, matches=args.matches, match_random_opening_length=args.match_random_opening_length, 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, val_frac=args.val_frac, ) print(f"\n== done, champion in include/weights_{args.size}.c ==") if __name__ == "__main__": main()