aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authortslil <tslil@posteo.de>2026-09-19 09:59:29 +0100
committertslil <tslil@posteo.de>2026-09-19 19:23:34 +0100
commitfe7268110d93e2abe4d4b51ac64e83521a8d6382 (patch)
tree68c26f1532e64c498df3a2020c2f4741509e1e39 /src
parent3f918be9b31026ae546f66c565b9494dab6d82a2 (diff)
big refactor
* use elo * optionally train on only diagonal * train-inference nonlinearity alignment
Diffstat (limited to 'src')
-rwxr-xr-xsrc/nn_train.py785
-rw-r--r--src/selfplay.c7
2 files changed, 446 insertions, 346 deletions
diff --git a/src/nn_train.py b/src/nn_train.py
index 4de0a5a..f82a369 100755
--- a/src/nn_train.py
+++ b/src/nn_train.py
@@ -33,26 +33,23 @@
# You should have received a copy of the GNU General Public License
# along with ctak. If not, see <https://www.gnu.org/licenses/>.
-from collections.abc import Iterator
-from dataclasses import dataclass
-from itertools import product
-from math import log, ceil
-
import argparse
import concurrent.futures
import json
+import math
import os
-from pathlib import Path
+import random
import subprocess
+from collections.abc import Iterator
+from dataclasses import dataclass
+from itertools import product
+from pathlib import Path
import numpy as np
import torch
-from tqdm import tqdm
-
-from torch.nn import Embedding, MSELoss, Linear, Module
-from torch.nn.functional import silu, tanh
from torch import Tensor
-
+from torch.nn import Embedding, Linear, Module, MSELoss
+from tqdm import tqdm
# ===================================================================
# Net dimensions
@@ -106,6 +103,9 @@ class DataGenConfig:
games: int
procs: int
replay_iters: int
+ root: Path
+ train_only_self_play: bool
+ k: float
@dataclass(frozen=True)
@@ -116,292 +116,393 @@ class TrainingGridSearchConfig:
lr: list[float]
val_frac: list[float]
- def make_configs(self) -> list[CandidateTrainingConfig]:
+ def make_configs(self, data_gen_config: DataGenConfig) -> list["Candidate"]:
+ inp_dim, hidden = NET_DIMS[data_gen_config.size]
flds = self.__dataclass_fields__.keys()
- return [
- CandidateTrainingConfig(**dict(zip(flds, vals)))
- for vals in product(*(getattr(self, k) for k in flds))
+ almost_cands = [
+ dict(zip(flds, vals)) for vals in product(*(getattr(self, k) for k in flds))
]
+ for ident, almost_cand in enumerate(almost_cands):
+ net = Net(inp_dim, hidden)
+ net.to_c(
+ size=data_gen_config.size,
+ ident=ident,
+ path=data_gen_config.root
+ / "include"
+ / f"cand_{ident}_weights_{data_gen_config.size}.c",
+ note=f"{ident=}, v000, random init",
+ )
+ cand_copy_for(
+ 6 if data_gen_config.size == 5 else 5, ident, data_gen_config.root
+ )
+ almost_cand["net"] = net
+ almost_cand["train_only_self_play"] = data_gen_config.train_only_self_play
+ return [Candidate(**d) for d in almost_cands]
-@dataclass(frozen=True)
-class CandidateTrainingConfig:
+@dataclass
+class Candidate:
+ train_only_self_play: bool
smooth_factor: float
epochs: int
batch: int
lr: float
val_frac: float
+ net: "Net"
+ elo: float = 1500.0
+ rated_games: int = 0
def __str__(self) -> str:
- return f"(λ={self.smooth_factor}, E={self.epochs}, BS={self.batch}, η={self.lr}, V%={self.val_frac * 100.0:.1f})"
+ return f"(λ={self.smooth_factor}, E={self.epochs}, BS={self.batch}, η={self.lr:.2e}, V%={self.val_frac * 100.0:.1f})"
+ def training_pool(self, pool: Pool) -> Pool:
+ if self.train_only_self_play:
+ return pool.select_chains(pool.chain_self_play)
+ return pool
-# ===================================================================
-# Self-play records
-# ===================================================================
+ def fit(self, pool: Pool) -> tuple[float, float]:
+ # The train/val split is fixed but targets are refreshed
+ split = DataSplit.build(self.training_pool(pool), self.val_frac)
+ if split.n == 0:
+ return math.nan, math.nan
+ optimizer = torch.optim.AdamW(self.net.parameters(), lr=self.lr)
+ loss_fn = MSELoss()
-def smooth(
- leaf_values: Tensor,
- result: float,
- lam: float,
-) -> Tensor:
- # Smooth(leaf_values, result, lambda), rooted at the game outcome:
- # G[-1] = result, G[t] = (1-lambda)V[t] + lambda G[t+1]
- targets = torch.empty_like(leaf_values)
- targets[-1] = result
+ pbar = tqdm(range(self.epochs), desc="train", unit="ep")
+ for _ in pbar:
+ split.refresh_targets(self.net, self.smooth_factor)
- for ply in range(len(leaf_values) - 2, -1, -1):
- targets[ply] = (1.0 - lam) * leaf_values[ply] + lam * targets[ply + 1]
+ self.net.train()
+ for head, cells, parity, target in split.batches(self.batch):
+ optimizer.zero_grad()
+ predictions = self.net(head, cells) * parity
+ loss = loss_fn(predictions, target)
+ loss.backward()
+ optimizer.step()
- return targets
+ with torch.no_grad():
+ self.net.eval()
+ val = split.val_idx
+ predictions = self.net(split.pool.head[val], split.pool.cells[val])
+ predictions = predictions * split.pool.parity[val]
+ targets = split.targets[val]
+ mse = loss_fn(predictions, targets).item()
+ corr = torch.corrcoef(torch.stack([predictions, targets]))[0, 1].item()
+ pbar.set_postfix(val_corr=f"{corr:.4f}", val_mse=f"{mse:.4f}")
-SPOOKY: int = 4
+ return corr, mse
-@dataclass
-class Performance:
- win: int
- loss: int
- draw: int
+# ===================================================================
+# Self-play records: games (for ratings) and chains (for training)
+# ===================================================================
- def rate(self) -> float:
- return float(self.win) / float(max(1, self.win + self.loss + self.draw))
- def __str__(self) -> str:
- global SPOOKY
- return f"+{str(self.win).zfill(SPOOKY)}-{str(self.loss).zfill(SPOOKY)}={str(self.draw).zfill(SPOOKY)}"
+@dataclass(frozen=True)
+class Games:
+ black: np.ndarray # [G] candidate id playing black
+ white: np.ndarray # [G] candidate id playing white
+ result: np.ndarray # [G] outcome from black's perspective, {-1, 0, 1}
+ newest: np.ndarray # [G] game comes from the last loaded file
+
+ def head_to_head(self) -> Iterator[tuple[int, int, float]]:
+ for i in np.flatnonzero(self.newest & (self.black != self.white)):
+ black, white, result = (
+ int(self.black[i]),
+ int(self.white[i]),
+ float(self.result[i]),
+ )
+ # elo score: 1 win, 0.5 draw, 0 loss, from a's perspective
+ yield black, white, (result + 1.0) / 2.0
@dataclass(frozen=True)
-class DataSplit:
- train_positions: Tensor
- train_targets: Tensor
- train_parity_signs: Tensor
- val_positions: Tensor
- val_targets: Tensor
- val_parity_signs: Tensor
+class Pool:
+ head: Tensor # [n, 2] fixed head inputs of the root state s_t
+ cells: Tensor # [n, n_cells] cell states 0..6 of s_t
+ leaf_head: Tensor # [n, 2] head inputs of the PV leaf pv_t
+ leaf_cells: Tensor # [n, n_cells] cell states of pv_t
+ parity: Tensor # [n] +1 when black is to move at s_t
+ leaf_parity: Tensor # [n] +1 when black is to move at pv_t
+ terminal: Tensor # [n] leaf is an exact terminal
+ terminal_value: Tensor # [n] exact terminal value, black's perspective
+ chain_lens: Tensor # [C] rows per chain
+ chain_result: Tensor # [C] game result, black's perspective
+ chain_self_play: Tensor # [C] chain comes from a self vs self game
+
+ @classmethod
+ def empty(cls, n_cells: int) -> "Pool":
+ def rows(dtype: torch.dtype, width: int = 0) -> Tensor:
+ return torch.empty((0, width) if width else (0,), dtype=dtype)
- def batches(self, batch_size: int) -> Iterator[tuple[Tensor, Tensor, Tensor]]:
- order = torch.randperm(
- len(self.train_positions), device=self.train_positions.device
+ return cls(
+ head=rows(torch.float32, 2),
+ cells=rows(torch.uint8, n_cells),
+ leaf_head=rows(torch.float32, 2),
+ leaf_cells=rows(torch.uint8, n_cells),
+ parity=rows(torch.float32),
+ leaf_parity=rows(torch.float32),
+ terminal=rows(torch.bool),
+ terminal_value=rows(torch.float32),
+ chain_lens=rows(torch.int64),
+ chain_result=rows(torch.float32),
+ chain_self_play=rows(torch.bool),
)
- for start in range(0, len(order), batch_size):
- idx = order[start : start + batch_size]
- yield (
- self.train_positions[idx],
- self.train_targets[idx],
- self.train_parity_signs[idx],
- )
+ @property
+ def n_rows(self) -> int:
+ return self.head.shape[0]
-@dataclass(frozen=True)
-class CandidateRecords:
- positions: Tensor # [n, inp_dim] net input of the root state s_t
- leaf_positions: Tensor # [n, inp_dim] net input of the PV leaf pv_t
- game_ids: Tensor # [n]
- results: Tensor # [n] game result R, black's perspective
- cand_results: Tensor # [n] R from the recorded candidate's perspective
- parity_signs: Tensor # [n] root parity
- leaf_parity_signs: Tensor # [n] leaf parity
- leaf_terminals: Tensor # [n] leaf is an exact terminal
- leaf_terminal_values: Tensor # [n] exact terminal value, black's perspective
+ def select_chains(self, keep: Tensor) -> "Pool":
+ keep = keep.bool()
+ rows = torch.repeat_interleave(
+ torch.arange(self.chain_lens.shape[0], dtype=torch.int64), self.chain_lens
+ )
+ idx = torch.nonzero(keep[rows]).flatten()
+ return Pool(
+ head=self.head[idx],
+ cells=self.cells[idx],
+ leaf_head=self.leaf_head[idx],
+ leaf_cells=self.leaf_cells[idx],
+ parity=self.parity[idx],
+ leaf_parity=self.leaf_parity[idx],
+ terminal=self.terminal[idx],
+ terminal_value=self.terminal_value[idx],
+ chain_lens=self.chain_lens[keep],
+ chain_result=self.chain_result[keep],
+ chain_self_play=self.chain_self_play[keep],
+ )
- def performance(self) -> Performance:
- result = Performance(0, 0, 0)
- for game_id in torch.unique(self.game_ids):
- in_game = self.game_ids == game_id
- game_result = self.cand_results[in_game][0].item()
- match game_result:
- case 1:
- result.win += 1
- case -1:
- result.loss += 1
- case _:
- result.draw += 1
- return result
- def make_training_data(
- self, model: Module, training: CandidateTrainingConfig
- ) -> DataSplit:
- model.eval()
- with torch.no_grad():
- # Net(pv_t), black's perspective; exact terminals override the net
- leaf_values = model(self.leaf_positions) * self.leaf_parity_signs
- leaf_values = torch.where(
- self.leaf_terminals, self.leaf_terminal_values, leaf_values
+# ===================================================================
+# Reading game records
+# ===================================================================
+
+_ROW_FIELDS = (
+ "head",
+ "cells",
+ "leaf_head",
+ "leaf_cells",
+ "parity",
+ "leaf_parity",
+ "terminal",
+ "terminal_value",
+)
+
+
+def _chain_arrays(
+ rows: list[dict], n_cells: int, source: Path
+) -> dict[str, np.ndarray]:
+
+ def column(part: str, key: str, dtype: np.dtype) -> np.ndarray:
+ return np.array([r[part][key] for r in rows], dtype=dtype)
+
+ out = {
+ "head": column("root", "head", np.float32),
+ "cells": column("root", "cells", np.uint8),
+ "leaf_head": column("leaf", "head", np.float32),
+ "leaf_cells": column("leaf", "cells", np.uint8),
+ "parity": column("root", "parity", np.float32),
+ "leaf_parity": column("leaf", "parity", np.float32),
+ "terminal": column("leaf", "terminal", np.bool_),
+ "terminal_value": column("leaf", "terminal_value", np.float32),
+ }
+ for name in ("cells", "leaf_cells"):
+ if out[name].shape[1] != n_cells:
+ raise ValueError(
+ f"{source}: {name} width {out[name].shape[1]}, expected {n_cells}"
)
+ return out
+
+
+def _game_colours(game: dict) -> tuple[int, int]:
+ a, b = int(game["cand_A"]), int(game["cand_B"])
+ for r in game["rows"]:
+ if r["root"]["parity"] > 0:
+ cand = int(r["cand"])
+ return cand, b if cand == a else a
+ recorded = {int(r["cand"]) for r in game["rows"]}
+ return (a, b) if a not in recorded else (b, a)
- targets = torch.empty_like(leaf_values)
- for game_id in torch.unique(self.game_ids):
- in_game = self.game_ids == game_id
- game_result = self.results[in_game][0].item()
- # self-pair games (i,i) record the candidate on every ply,
- # so smooth one chain per colour, not across players
- for sign in (1.0, -1.0):
- idxs = torch.nonzero(
- in_game & (self.parity_signs == sign), as_tuple=True
- )[0]
- if len(idxs) == 0:
+def load_window(
+ files: list[Path], size: int, n_candidates: int
+) -> tuple[list[Pool], Games]:
+ n_cells = NET_DIMS[size][0] - 2
+ blocks = [{f: [] for f in _ROW_FIELDS} for _ in range(n_candidates)]
+ chain_lens: list[list[int]] = [[] for _ in range(n_candidates)]
+ chain_result: list[list[float]] = [[] for _ in range(n_candidates)]
+ chain_self_play: list[list[bool]] = [[] for _ in range(n_candidates)]
+ black, white, result, newest = [], [], [], []
+
+ for file_index, path in enumerate(files):
+ is_newest = file_index == len(files) - 1
+ with open(path) as f:
+ for line in f:
+ if not line.strip():
continue
- targets[idxs] = smooth(
- leaf_values[idxs], game_result, training.smooth_factor
- )
+ game = json.loads(line)
+ game_result = float(game["result"])
+ b, w = _game_colours(game)
+ black.append(b)
+ white.append(w)
+ result.append(game_result)
+ newest.append(is_newest)
+
+ by_cand: dict[int, list[dict]] = {}
+ for r in game["rows"]:
+ by_cand.setdefault(int(r["cand"]), []).append(r)
- n_val = max(1, int(len(leaf_values) * training.val_frac))
- perm = torch.randperm(len(leaf_values), device=leaf_values.device)
- val_idx, train_idx = perm[:n_val], perm[n_val:]
+ for cand, rows in by_cand.items():
+ parity = [r["root"]["parity"] for r in rows]
+ for sign in (1, -1):
+ chain = [
+ r for r, p in zip(rows, parity) if (p > 0) == (sign > 0)
+ ]
+ if not chain:
+ continue
+ for name, array in _chain_arrays(chain, n_cells, path).items():
+ blocks[cand][name].append(array)
+ chain_lens[cand].append(len(chain))
+ chain_result[cand].append(game_result)
+ chain_self_play[cand].append(b == w)
- return DataSplit(
- train_positions=self.positions[train_idx],
- train_targets=targets[train_idx],
- train_parity_signs=self.parity_signs[train_idx],
- val_positions=self.positions[val_idx],
- val_targets=targets[val_idx],
- val_parity_signs=self.parity_signs[val_idx],
+ def pool_of(cand: int) -> Pool:
+ if not chain_lens[cand]:
+ return Pool.empty(n_cells)
+ arrays = {
+ name: torch.from_numpy(np.concatenate(blocks[cand][name]))
+ for name in _ROW_FIELDS
+ }
+ return Pool(
+ **arrays,
+ chain_lens=torch.tensor(chain_lens[cand], dtype=torch.int64),
+ chain_result=torch.tensor(chain_result[cand], dtype=torch.float32),
+ chain_self_play=torch.tensor(chain_self_play[cand], dtype=torch.bool),
)
+ games = Games(
+ black=np.array(black, dtype=np.int16),
+ white=np.array(white, dtype=np.int16),
+ result=np.array(result, dtype=np.float32),
+ newest=np.array(newest, dtype=np.bool_),
+ )
+ return [pool_of(c) for c in range(n_candidates)], games
+
-@dataclass(frozen=True)
-class PlayRecords:
- positions: list[Tensor] # [candidate, n_i, inp_dim]
- leaf_positions: list[Tensor] # [candidate, n_i, inp_dim]
- game_ids: list[Tensor] # [candidate, n_i]
- results: list[Tensor] # [candidate, n_i]
- cand_results: list[Tensor] # [candidate, n_i]
- parity_signs: list[Tensor] # [candidate, n_i]
- leaf_parity_signs: list[Tensor] # [candidate, n_i]
- leaf_terminals: list[Tensor] # [candidate, n_i]
- leaf_terminal_values: list[Tensor] # [candidate, n_i]
+# ===================================================================
+# Training data: fixed rows, refreshed targets
+# ===================================================================
- def index(self, index: int) -> CandidateRecords:
- return CandidateRecords(
- positions=self.positions[index],
- leaf_positions=self.leaf_positions[index],
- game_ids=self.game_ids[index],
- results=self.results[index],
- cand_results=self.cand_results[index],
- parity_signs=self.parity_signs[index],
- leaf_parity_signs=self.leaf_parity_signs[index],
- leaf_terminals=self.leaf_terminals[index],
- leaf_terminal_values=self.leaf_terminal_values[index],
- )
- @classmethod
- def load_merge(cls, files: list[Path], size: int) -> PlayRecords:
- # one JSON record per line:
- # {"result": R, "rows": [{"cand", "root": {...}, "leaf": {...}}]}
- inp_dim = NET_DIMS[size][0]
- n_cells = inp_dim - 2
+@dataclass
+class DataSplit:
+ pool: Pool
+ train_idx: Tensor
+ val_idx: Tensor
+ targets: Tensor # [n] aligned with pool rows, refreshed per epoch
+ chain_end: Tensor # [C] last row index of each chain
+ wave_order: Tensor # [n] rows ordered by distance from their chain's end
+ wave_bounds: Tensor # [d] start of distance d in wave_order
- games: list[dict] = []
- for file in files:
- with open(file) as f:
- for line in f:
- if line.strip():
- games.append(json.loads(line))
+ @property
+ def n(self) -> int:
+ return self.pool.n_rows
- def board(row_part: dict) -> list[float]:
- return [*row_part["head"], *row_part["cells"]]
+ @classmethod
+ def build(cls, pool: Pool, val_frac: float) -> "DataSplit":
+ n = pool.n_rows
+ if n == 0:
+ empty = torch.empty(0, dtype=torch.int64)
+ return cls(pool, empty, empty, torch.empty(0), empty, empty, empty)
- n_cands = max(r["cand"] for g in games for r in g["rows"]) + 1
- fields = (
- "positions",
- "leaf_positions",
- "game_ids",
- "results",
- "cand_results",
- "parity_signs",
- "leaf_parity_signs",
- "leaf_terminals",
- "leaf_terminal_values",
+ # Smoothing is G[t] = (1-λ)V[t] + λG[t+1] with G at the chain end.
+ # Chains are contiguous, so every row that is d plies from its chain's
+ # end can be updated in one vectorised wavefront step per d.
+ lens = pool.chain_lens
+ chain_end = torch.cumsum(lens, dim=0) - 1
+ chain_of_row = torch.repeat_interleave(
+ torch.arange(lens.shape[0], dtype=torch.int64), lens
+ )
+ dist = chain_end[chain_of_row] - torch.arange(n, dtype=torch.int64)
+ wave_order = torch.argsort(dist, stable=True)
+ wave_bounds = torch.searchsorted(
+ dist[wave_order], torch.arange(int(dist.max()) + 2, dtype=torch.int64)
)
- per_cand = {c: {k: [] for k in fields} for c in range(n_cands)}
- for game_id, game in enumerate(games):
- result = float(game["result"])
- for cand in range(n_cands):
- rows = [r for r in game["rows"] if r["cand"] == cand]
- if not rows:
- continue
- for part in ("root", "leaf"):
- for r in rows:
- if len(r[part]["cells"]) != n_cells:
- raise ValueError(
- f"{files[-1]}: {len(r[part]['cells'])} cells, "
- f"expected {n_cells} for size {size}"
- )
- parity = np.array([r["root"]["parity"] for r in rows], dtype=np.float32)
- per_cand[cand]["positions"].append(
- torch.from_numpy(
- np.array([board(r["root"]) for r in rows], dtype=np.float32)
- )
- )
- per_cand[cand]["leaf_positions"].append(
- torch.from_numpy(
- np.array([board(r["leaf"]) for r in rows], dtype=np.float32)
- )
- )
- per_cand[cand]["game_ids"].append(
- torch.full((len(rows),), game_id, dtype=torch.int64)
- )
- per_cand[cand]["results"].append(
- torch.full((len(rows),), result, dtype=torch.float32)
- )
- per_cand[cand]["cand_results"].append(torch.from_numpy(parity * result))
- per_cand[cand]["parity_signs"].append(torch.from_numpy(parity))
- per_cand[cand]["leaf_parity_signs"].append(
- torch.from_numpy(
- np.array([r["leaf"]["parity"] for r in rows], dtype=np.float32)
- )
- )
- per_cand[cand]["leaf_terminals"].append(
- torch.from_numpy(
- np.array(
- [r["leaf"]["terminal"] == 1 for r in rows],
- dtype=np.bool_,
- )
- )
- )
- per_cand[cand]["leaf_terminal_values"].append(
- torch.from_numpy(
- np.array(
- [r["leaf"]["terminal_value"] for r in rows],
- dtype=np.float32,
- )
- )
- )
-
- def merged(cand: int, k: str, empty: Tensor) -> Tensor:
- blocks = per_cand[cand][k]
- return torch.concat(blocks, dim=0) if blocks else empty
+ n_val = max(1, int(n * val_frac))
+ perm = torch.randperm(n)
return cls(
- **{
- k: [
- merged(
- cand,
- k,
- torch.empty(
- (0, inp_dim) if k.endswith("positions") else (0,),
- dtype=torch.float32
- if k not in ("game_ids", "leaf_terminals")
- else (torch.int64 if k == "game_ids" else torch.bool),
- ),
- )
- for cand in range(n_cands)
- ]
- for k in fields
- }
+ pool=pool,
+ train_idx=perm[n_val:],
+ val_idx=perm[:n_val],
+ targets=torch.zeros(n),
+ chain_end=chain_end,
+ wave_order=wave_order,
+ wave_bounds=wave_bounds,
)
+ def refresh_targets(self, net: Module, lam: float) -> None:
+ pool = self.pool
+ net.eval()
+ with torch.no_grad():
+ # Net(pv_t), black's perspective; exact terminals override the net
+ leaf = net(pool.leaf_head, pool.leaf_cells) * pool.leaf_parity
+ leaf = torch.where(pool.terminal, pool.terminal_value, leaf)
+
+ targets = torch.zeros_like(leaf)
+ targets[self.chain_end] = pool.chain_result
+ for lo, hi in zip(self.wave_bounds[1:-1], self.wave_bounds[2:]):
+ idx = self.wave_order[lo:hi]
+ targets[idx] = (1.0 - lam) * leaf[idx] + lam * targets[idx + 1]
+ self.targets = targets
+
+ def batches(
+ self, batch_size: int
+ ) -> Iterator[tuple[Tensor, Tensor, Tensor, Tensor]]:
+ order = torch.randperm(self.train_idx.shape[0])
+ train_targets = self.targets[self.train_idx]
+ for start in range(0, order.shape[0], batch_size):
+ idx = order[start : start + batch_size]
+ rows = self.train_idx[idx]
+ yield (
+ self.pool.head[rows],
+ self.pool.cells[rows],
+ self.pool.parity[rows],
+ train_targets[idx],
+ )
+
# ===================================================================
# Network
# ===================================================================
+def fast_tanh(x: Tensor) -> Tensor:
+ # Must match fast_tanh in include/nn.c: degree-11 fit of tanh on [-3, 3],
+ # saturating to +-1 outside.
+ x2 = x * x
+ poly = x * (
+ 0.997136032
+ + x2 * (
+ -0.305215124
+ + x2 * (0.0832466371 + x2 * (-0.0139947924 + x2 * (0.00123106314 + x2 * -4.29748644e-05)))
+ )
+ )
+ return torch.where(x > 3.0, 1.0, torch.where(x < -3.0, -1.0, poly))
+
+
+def fast_silu(x: Tensor) -> Tensor:
+ # Must match fast_silu in include/nn.c: [4,2] Pade approximant with cutoff.
+ # The cutoff below is evaluated at x = 0 (instead of x) because torch
+ # evaluates both branches of where: 1 + 0.1 * x is 0 for x = -10.
+ xs = torch.where(x < -4.6, 0.0, x)
+ poly = xs * (0.5 + xs * (0.25 + xs * (0.05 + 0.004167 * xs))) / (1 + 0.1 * xs)
+ return torch.where(x < -4.6, 0.0, torch.where(x > 4.6, x, poly))
+
+
class Net(Module):
def __init__(self, inp_dim: int, hidden: int) -> None:
super().__init__()
@@ -410,56 +511,24 @@ class Net(Module):
self.hidden = Linear(inp_dim, hidden, bias=True)
self.output = Linear(hidden, 1, bias=True)
- def forward(self, x: Tensor) -> Tensor:
- # x: [n, 2 + n_cells]; the first 2 columns are the fixed head
- # inputs, the rest are raw cell states (0..6)
- n = x.shape[0]
- inp = torch.cat([x[:, :2], self.embedding(x[:, 2:].long()).view(n, -1)], dim=1)
- hidden = silu(self.hidden(inp))
- return tanh(self.output(hidden)).squeeze(-1)
-
- def fit(
- self, *, records: CandidateRecords, config: CandidateTrainingConfig
- ) -> tuple[float, float]:
- # training widgets
- optimizer = torch.optim.AdamW(self.parameters(), lr=config.lr)
- loss_fn = MSELoss()
-
- pbar = tqdm(range(config.epochs), desc="train", unit="ep")
- for _ in pbar:
- split = records.make_training_data(self, config)
-
- self.train()
- for positions, targets, parity_signs in split.batches(config.batch):
- optimizer.zero_grad()
- predictions = self(positions) * parity_signs
- loss = loss_fn(predictions, targets)
- loss.backward()
- optimizer.step()
-
- with torch.no_grad():
- self.eval()
- predictions = self(split.val_positions) * split.val_parity_signs
- mse = loss_fn(predictions, split.val_targets).item()
- corr = torch.corrcoef(torch.stack([predictions, split.val_targets]))[
- 0, 1
- ].item()
-
- pbar.set_postfix(val_corr=f"{corr:.4f}", val_mse=f"{mse:.4f}")
-
- return corr, mse
+ def forward(self, head: Tensor, cells: Tensor) -> Tensor:
+ # head: [n, 2] fixed head inputs; cells: [n, n_cells] raw cell states
+ # (0..6) - the only place they become anything other than integers
+ inp = torch.cat([head, self.embedding(cells.long()).flatten(1)], dim=1)
+ hidden = fast_silu(self.hidden(inp))
+ return fast_tanh(self.output(hidden)).squeeze(-1)
def to_c(
self,
*,
size: int,
- id: int | None,
+ ident: int | None,
path: Path,
note: str,
) -> None:
upper = "FIVE" if size == 5 else "SIX"
- prefix = (f"cand_{id}_" if id is not None else "") + upper
- guard = f"WEIGHTS_{upper}" + (f"_{id}" if id is not None else "")
+ prefix = (f"cand_{ident}_" if ident is not None else "") + upper
+ guard = f"WEIGHTS_{upper}" + (f"_{ident}" if ident is not None else "")
E = self.embedding.weight.detach().cpu().numpy()
W = self.hidden.weight.detach().cpu().numpy()
@@ -478,7 +547,7 @@ class Net(Module):
f"const float {prefix}_dense_biases[{upper}_DENSE_NUM] =\n{{\n{_fmt_flat(B)}\n}};\n\n",
f"const float {prefix}_logit_weights[{upper}_DENSE_NUM] NN_ALIGNED =\n{{\n{_fmt_flat(LW)}\n}};\n\n",
f"const float {prefix}_logit_bias = {LB:.9g};\n",
- f"#endif",
+ "#endif",
]
for line in lines:
_ = f.write(line)
@@ -537,30 +606,26 @@ def cand_copy_for(size: int, id: int, root: Path) -> None:
# ===================================================================
-def setup(num_candidates: int, data_gen: DataGenConfig, root: Path) -> list[Net]:
- inp_dim, hidden = NET_DIMS[data_gen.size]
- candidates = [Net(inp_dim, hidden) for _ in range(num_candidates)]
- for id, cand in enumerate(candidates):
- cand.to_c(
- size=data_gen.size,
- id=id,
- path=root / "include" / f"cand_{id}_weights_{data_gen.size}.c",
- note=f"{id=}, v000, random init",
- )
- cand_copy_for(6 if data_gen.size == 5 else 5, id, root)
- return candidates
+def update_elo(a: Candidate, b: Candidate, score: float, k: float) -> None:
+ # score is a's result as an elo score: 1.0 win, 0.5 draw, 0.0 loss
+ ea = 1.0 / (1.0 + 10.0 ** ((b.elo - a.elo) / 400.0))
+ delta = k * (score - ea)
+ a.elo += delta
+ b.elo -= delta
def run_step(
*,
- candidates: list[Net],
- data_gen: DataGenConfig,
+ candidates: list[Candidate],
+ data_gen_config: DataGenConfig,
iter: int,
- root: Path,
- training_configs: list[CandidateTrainingConfig],
):
def data_path(i: int) -> Path:
- return root / "data" / f"selfplay-{data_gen.size}-v{i:03d}.json"
+ return (
+ data_gen_config.root
+ / "data"
+ / f"selfplay-{data_gen_config.size}-v{i:03d}.json"
+ )
print(f"\n== iteration {iter} ==")
@@ -572,30 +637,34 @@ def run_step(
# 1. rebuild
for i, j in cand_pairs:
_ = subprocess.run(
- ["make", f"selfplay-{i}-{j}"], cwd=root, check=True, capture_output=True
+ ["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.games=}, "
- f"{data_gen.size=}, {data_gen.depth=}, {data_gen.min_ply=}"
+ 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.size),
- str(data_gen.depth),
- str(data_gen.min_ply),
+ str(data_gen_config.size),
+ str(data_gen_config.depth),
+ str(data_gen_config.min_ply),
]
for (i, j) in cand_pairs
],
- data_gen.games,
- data_gen.procs,
+ data_gen_config.games,
+ data_gen_config.procs,
"selfplay",
- root,
+ data_gen_config.root,
):
out_games.extend(out.splitlines())
print(f"writing {len(out_games):,} games to {data}")
@@ -604,43 +673,57 @@ def run_step(
if out_games:
_ = f.write("\n")
- # 3. compute performance
+ # 3. rate every head-to-head game played in this iteration
data_files = [
- data_path(i) for i in range(max(iter - data_gen.replay_iters, 1), iter + 1)
+ 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=" ")
- records = PlayRecords.load_merge(data_files, data_gen.size)
- print("candidates have", [f"{r.shape[0]:,}" for r in records.results], "rows.")
- results = [
- (i, cand, records.index(i).performance()) for i, cand in enumerate(candidates)
- ]
+ pools, games = load_window(data_files, data_gen_config.size, num_candidates)
+ print("candidates have", [f"{p.n_rows:,}" for p in pools], "rows.")
- print("Tournament results:")
- for idx, _, perf in sorted(results, key=lambda t: t[2].rate(), reverse=True):
- print(
- f"\tcandidate {idx:03d} {perf} {perf.rate() * 100.0:.2f}% {training_configs[idx]}"
- )
+ fresh = list(games.head_to_head())
+ random.shuffle(fresh)
+ for a, b, score in fresh:
+ update_elo(candidates[a], candidates[b], score, data_gen_config.k)
+ candidates[a].rated_games += 1
+ candidates[b].rated_games += 1
- # 4. write champ
- idx, champ, perf = max(results, key=lambda t: t[2].rate())
- path = root / "include" / f"weights_{data_gen.size}.c"
- champ.to_c(
- size=data_gen.size,
- id=None,
+ total_rated_games = sum(c.rated_games for c in candidates)
+ print(
+ f"Tournament standings ({total_rated_games:,} rated games, {total_rated_games / len(candidates):,} games/candidate):"
+ )
+ for idx, cand in sorted(
+ enumerate(candidates), key=lambda t: t[1].elo, reverse=True
+ ):
+ print(f"\tcandidate {idx:03d}: Elo {cand.elo:4.1f} Config {cand}")
+
+ # 4. write champ, unconditionally
+ idx, champ = max(enumerate(candidates), key=lambda t: t[1].elo)
+ path = data_gen_config.root / "include" / f"weights_{data_gen_config.size}.c"
+ champ.net.to_c(
+ size=data_gen_config.size,
+ ident=None,
path=path,
- note=f"{iter=}, perf={str(perf)}, rate={perf.rate() * 100.0:.3f}",
+ note=f"{iter=}, elo={champ.elo:.1f}, rated_games={champ.rated_games:,}",
)
- _ = subprocess.run(["make", "clean"], cwd=root, check=True, capture_output=True)
- _ = subprocess.run(["make", "native"], cwd=root, check=True, capture_output=True)
- print(f"Wrote candidate {idx:03d} to {str(path)}")
+ _ = 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}")
# 5. fine-tune on the hybrid targets -> candidate
for idx, cand in enumerate(candidates):
- corr, mse = cand.fit(records=records.index(idx), config=training_configs[idx])
- cand.to_c(
- size=data_gen.size,
- id=idx,
- path=root / "include" / f"cand_{idx}_weights_{data_gen.size}.c",
+ 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}",
)
@@ -734,9 +817,25 @@ def main() -> None:
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)",
+ )
+ _ = p.add_argument(
+ "--k-factor",
+ type=float,
+ default=32.0,
+ help="elo k-factor",
+ )
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))
@@ -747,6 +846,9 @@ def main() -> None:
games=args.games,
procs=args.procs,
replay_iters=args.replay_iters,
+ root=root,
+ train_only_self_play=not args.train_all_games,
+ k=args.k_factor,
)
tgsc = TrainingGridSearchConfig(
smooth_factor=args.smooth_factor,
@@ -756,23 +858,20 @@ def main() -> None:
val_frac=args.val_frac,
)
- training_configs = tgsc.make_configs()
+ candidates = tgsc.make_configs(data_gen)
(root / "data").mkdir(exist_ok=True)
- candidates = setup(
- num_candidates=len(training_configs), data_gen=data_gen, root=root
- )
- num_params = sum(p.numel() for p in candidates[0].parameters() if p.requires_grad)
+ num_params = sum(
+ p.numel() for p in candidates[0].net.parameters() if p.requires_grad
+ )
print(f"Number of trainable parameters: {num_params:,}")
for iter in range(1, args.iters + 1):
run_step(
candidates=candidates,
- data_gen=data_gen,
+ data_gen_config=data_gen,
iter=iter,
- root=root,
- training_configs=training_configs,
)
print(f"\n== done, champion in include/weights_{args.size}.c ==")
diff --git a/src/selfplay.c b/src/selfplay.c
index b2a5821..37f94fa 100644
--- a/src/selfplay.c
+++ b/src/selfplay.c
@@ -169,7 +169,8 @@ static int play_game(const uint8_t min_ply, const nn_function fn_1,
// final outcome of the played game, from black's perspective
const int8_t R = win_value(state->won);
- fprintf(stdout, "{\"result\":%d,\"rows\":[", R);
+ fprintf(stdout, "{\"result\":%d,\"cand_A\":%u,\"cand_B\":%u,\"rows\":[", R,
+ CAND_A, CAND_B);
for (uint32_t k = 0; k < n_rows; k++) {
fprintf(stdout, "%s{\"cand\":%u,\"root\":{\"parity\":%d,", k ? "," : "",
row_cand_id[k], row_mover_sign[k]);
@@ -188,8 +189,8 @@ const char *usage =
"selfplay, one game of training data between candidates.\n\n\
Usage: selfplay <size> <depth> <min_ply>\n\n\
Plays a single game of <size>x<size>, with <min_ply> moves made randomly. Only the deterministic plies are written to stdout, one JSON record per game, from ply <min_ply> onwards:\n\n\
-\t{\"result\": R, \"rows\": [{\"cand\": id, \"root\": {\"parity\", \"head\", \"cells\"}, \"leaf\": {\"parity\", \"head\", \"cells\", \"terminal\", \"terminal_value\"}}]}\n\n\
-R is the outcome of the game from black's perspective. Each row records the net input of the root state s_t and the PV leaf selected at that ply; the leaf value Net(pv) is taken from the trained net. nn_train.py runs many of these in parallel to generate training data.\n\n\
+\t{\"result\": R, \"cand_A\": i, \"cand_B\": j, \"rows\": [{\"cand\": id, \"root\": {\"parity\", \"head\", \"cells\"}, \"leaf\": {\"parity\", \"head\", \"cells\", \"terminal\", \"terminal_value\"}}]}\n\n\
+R is the outcome of the game from black's perspective; cand_A and cand_B are the ids of the two candidates that played the game. Each row records the net input of the root state s_t and the PV leaf selected at that ply; the leaf value Net(pv) is taken from the trained net. nn_train.py runs many of these in parallel to generate training data.\n\n\
Copyright (C) 2026, tslil clingman\n\n\
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.";