aboutsummaryrefslogtreecommitdiff
path: root/train/src/ctak_train/candidate.py
blob: aa2a6a6ff812549bd63213fff77e28771fef9597 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
#
# 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/>.

import math
from dataclasses import dataclass
from itertools import product
from pathlib import Path

import torch
from torch.nn import MSELoss
from tqdm import tqdm

from ctak_train.data import DataSplit, Pool
from ctak_train.net import NET_DIMS, Net
from ctak_train.selfplay import cand_copy_for


@dataclass(frozen=True)
class DataGenConfig:
    size: int
    depth: int
    min_ply: int
    games: int
    procs: int
    replay_iters: int
    root: Path
    train_only_self_play: bool


@dataclass(frozen=True)
class TrainingGridSearchConfig:
    smooth_factor: list[float]
    epochs: list[int]
    batch: list[int]
    lr: list[float]
    val_frac: list[float]

    def make_configs(self, data_gen_config: DataGenConfig) -> list["Candidate"]:
        inp_dim, hidden = NET_DIMS[data_gen_config.size]
        flds = self.__dataclass_fields__.keys()
        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
class Candidate:
    train_only_self_play: bool
    smooth_factor: float
    epochs: int
    batch: int
    lr: float
    val_frac: float
    net: "Net"

    def __str__(self) -> str:
        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

    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()

        pbar = tqdm(range(self.epochs), desc="train", unit="ep")
        for _ in pbar:
            split.refresh_targets(self.net, self.smooth_factor)

            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()

            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}")

        return corr, mse