aboutsummaryrefslogtreecommitdiff
path: root/train/src/ctak_train/net.py
diff options
context:
space:
mode:
authortslil <tslil@posteo.de>2026-09-25 14:46:31 +0100
committertslil <tslil@posteo.de>2026-09-25 16:18:21 +0100
commit61e5b8949b2f5e8973c58f0dcc7c20c4bdade029 (patch)
tree610e4cfdcbb7bacfbcec00412dbb6eddcd09f1a9 /train/src/ctak_train/net.py
parent6a2197328065fe38ac62d9e76e3e943fd297e73c (diff)
factor out nn_train.py into a separate python project to make it more manageable, add plotting and offline strength fit
Diffstat (limited to 'train/src/ctak_train/net.py')
-rw-r--r--train/src/ctak_train/net.py129
1 files changed, 129 insertions, 0 deletions
diff --git a/train/src/ctak_train/net.py b/train/src/ctak_train/net.py
new file mode 100644
index 0000000..cc8bf5c
--- /dev/null
+++ b/train/src/ctak_train/net.py
@@ -0,0 +1,129 @@
+#
+# 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 <https://www.gnu.org/licenses/>.
+
+from pathlib import Path
+
+import numpy as np
+import torch
+from torch import Tensor
+from torch.nn import Embedding, Linear, Module
+
+# (input dim, hidden dim) per board size; must match include/weights_<size>.h
+NET_DIMS: dict[int, tuple[int, int]] = {
+ 5: (27, 64), # FIVE_INP_NUM, FIVE_DENSE_NUM
+ 6: (38, 64), # SIX_INP_NUM, SIX_DENSE_NUM
+}
+
+
+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)
+
+
+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__()
+ # 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, 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,
+ ident: int | None,
+ path: Path,
+ note: str,
+ ) -> None:
+ upper = "FIVE" if size == 5 else "SIX"
+ 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()
+ 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:
+ lines = [
+ "/* generated by nn_train.py, do not edit by hand */\n",
+ f"/* {note} */\n",
+ f"#ifndef {guard}\n#define {guard}\n",
+ f'#include "weights_{size}.h"\n\n',
+ f"const float {prefix}_embedding[{len(E)}] =\n{{\n{_fmt_flat(E, per_line=7)}\n}};\n\n",
+ f"const float {prefix}_dense_weights[{upper}_INP_NUM][{upper}_DENSE_NUM] NN_ALIGNED =\n{{\n{_fmt_matrix(W.T)}\n}};\n\n",
+ 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",
+ "#endif",
+ ]
+ for line in lines:
+ _ = f.write(line)