ARotting commited on
Commit
c80bb82
·
verified ·
1 Parent(s): 3723569

Publish 7.6K parameter neural MCTS self-play agent

Browse files
README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - reinforcement-learning
5
+ - self-play
6
+ - monte-carlo-tree-search
7
+ - policy-value-network
8
+ - gradio
9
+ ---
10
+
11
+ # MicroZero
12
+
13
+ MicroZero is a tiny AlphaZero-style Tic-Tac-Toe agent. A shared neural trunk predicts
14
+ both move priors and position value. Monte Carlo Tree Search converts those estimates
15
+ into stronger self-play targets, while terminal outcomes supervise value learning.
16
+
17
+ The training loop uses:
18
+
19
+ - neural-guided PUCT search;
20
+ - root Dirichlet exploration;
21
+ - policy targets from MCTS visit counts;
22
+ - terminal win/draw/loss value targets;
23
+ - all eight square-board symmetries;
24
+ - a bounded replay buffer.
25
+
26
+ Evaluation alternates playing first and second against random and exact minimax
27
+ opponents.
28
+
29
+ ## Verified results
30
+
31
+ - 7,626 trainable parameters;
32
+ - 980 neural-MCTS self-play games;
33
+ - 24,000-example bounded replay buffer after symmetry augmentation;
34
+ - 384 wins, 16 draws, and zero losses over 400 games against random play;
35
+ - 200 draws and zero losses over 200 games against exact minimax.
36
+
37
+ The exact opponent is used only for final evaluation, not as a source of training
38
+ targets. Full metrics and the per-iteration learning history are stored in
39
+ `artifacts/microzero/evaluation.json`.
40
+
41
+ ## Reproduce
42
+
43
+ ```powershell
44
+ uv run python projects/microzero/train.py
45
+ ```
evaluation.json ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model": "MicroZero",
3
+ "parameters": 7626,
4
+ "self_play_iterations": 14,
5
+ "self_play_games": 980,
6
+ "final_replay_examples": 24000,
7
+ "training_history": [
8
+ {
9
+ "iteration": 1,
10
+ "replay_examples": 4128,
11
+ "x_win_rate": 0.5428571428571428,
12
+ "o_win_rate": 0.14285714285714285,
13
+ "draw_rate": 0.3142857142857143,
14
+ "average_game_moves": 7.371428571428571,
15
+ "policy_loss": 2.1499555636854732,
16
+ "value_loss": 0.5162482239744243
17
+ },
18
+ {
19
+ "iteration": 2,
20
+ "replay_examples": 8128,
21
+ "x_win_rate": 0.5142857142857142,
22
+ "o_win_rate": 0.0,
23
+ "draw_rate": 0.4857142857142857,
24
+ "average_game_moves": 7.142857142857143,
25
+ "policy_loss": 1.9038925133645535,
26
+ "value_loss": 0.3203776921145618
27
+ },
28
+ {
29
+ "iteration": 3,
30
+ "replay_examples": 12328,
31
+ "x_win_rate": 0.42857142857142855,
32
+ "o_win_rate": 0.014285714285714285,
33
+ "draw_rate": 0.5571428571428572,
34
+ "average_game_moves": 7.5,
35
+ "policy_loss": 1.4543613067695074,
36
+ "value_loss": 0.26955098012576295
37
+ },
38
+ {
39
+ "iteration": 4,
40
+ "replay_examples": 16456,
41
+ "x_win_rate": 0.5142857142857142,
42
+ "o_win_rate": 0.0,
43
+ "draw_rate": 0.4857142857142857,
44
+ "average_game_moves": 7.371428571428571,
45
+ "policy_loss": 1.229265012878638,
46
+ "value_loss": 0.22285609325537314
47
+ },
48
+ {
49
+ "iteration": 5,
50
+ "replay_examples": 20952,
51
+ "x_win_rate": 0.2857142857142857,
52
+ "o_win_rate": 0.0,
53
+ "draw_rate": 0.7142857142857143,
54
+ "average_game_moves": 8.028571428571428,
55
+ "policy_loss": 1.1138035529633847,
56
+ "value_loss": 0.1854344406960214
57
+ },
58
+ {
59
+ "iteration": 6,
60
+ "replay_examples": 24000,
61
+ "x_win_rate": 0.3142857142857143,
62
+ "o_win_rate": 0.014285714285714285,
63
+ "draw_rate": 0.6714285714285714,
64
+ "average_game_moves": 7.957142857142857,
65
+ "policy_loss": 1.0146886479664357,
66
+ "value_loss": 0.15539901554980812
67
+ },
68
+ {
69
+ "iteration": 7,
70
+ "replay_examples": 24000,
71
+ "x_win_rate": 0.2571428571428571,
72
+ "o_win_rate": 0.0,
73
+ "draw_rate": 0.7428571428571429,
74
+ "average_game_moves": 8.085714285714285,
75
+ "policy_loss": 0.8795330280319174,
76
+ "value_loss": 0.11288299490796759
77
+ },
78
+ {
79
+ "iteration": 8,
80
+ "replay_examples": 24000,
81
+ "x_win_rate": 0.24285714285714285,
82
+ "o_win_rate": 0.0,
83
+ "draw_rate": 0.7571428571428571,
84
+ "average_game_moves": 8.314285714285715,
85
+ "policy_loss": 0.7982094690520712,
86
+ "value_loss": 0.09204166978993948
87
+ },
88
+ {
89
+ "iteration": 9,
90
+ "replay_examples": 24000,
91
+ "x_win_rate": 0.3,
92
+ "o_win_rate": 0.014285714285714285,
93
+ "draw_rate": 0.6857142857142857,
94
+ "average_game_moves": 7.985714285714286,
95
+ "policy_loss": 0.7609958442601752,
96
+ "value_loss": 0.09048273768077822
97
+ },
98
+ {
99
+ "iteration": 10,
100
+ "replay_examples": 24000,
101
+ "x_win_rate": 0.2857142857142857,
102
+ "o_win_rate": 0.0,
103
+ "draw_rate": 0.7142857142857143,
104
+ "average_game_moves": 8.17142857142857,
105
+ "policy_loss": 0.7380844823857571,
106
+ "value_loss": 0.08676699136799955
107
+ },
108
+ {
109
+ "iteration": 11,
110
+ "replay_examples": 24000,
111
+ "x_win_rate": 0.18571428571428572,
112
+ "o_win_rate": 0.0,
113
+ "draw_rate": 0.8142857142857143,
114
+ "average_game_moves": 8.371428571428572,
115
+ "policy_loss": 0.7269294456915653,
116
+ "value_loss": 0.074583658938633
117
+ },
118
+ {
119
+ "iteration": 12,
120
+ "replay_examples": 24000,
121
+ "x_win_rate": 0.3142857142857143,
122
+ "o_win_rate": 0.0,
123
+ "draw_rate": 0.6857142857142857,
124
+ "average_game_moves": 8.142857142857142,
125
+ "policy_loss": 0.7146875767948779,
126
+ "value_loss": 0.07327091548235175
127
+ },
128
+ {
129
+ "iteration": 13,
130
+ "replay_examples": 24000,
131
+ "x_win_rate": 0.22857142857142856,
132
+ "o_win_rate": 0.0,
133
+ "draw_rate": 0.7714285714285715,
134
+ "average_game_moves": 8.17142857142857,
135
+ "policy_loss": 0.7104355310823055,
136
+ "value_loss": 0.07133727120433717
137
+ },
138
+ {
139
+ "iteration": 14,
140
+ "replay_examples": 24000,
141
+ "x_win_rate": 0.2,
142
+ "o_win_rate": 0.0,
143
+ "draw_rate": 0.8,
144
+ "average_game_moves": 8.371428571428572,
145
+ "policy_loss": 0.7074488862714869,
146
+ "value_loss": 0.06895742299867437
147
+ }
148
+ ],
149
+ "versus_random": {
150
+ "games": 400,
151
+ "wins": 384,
152
+ "draws": 16,
153
+ "losses": 0,
154
+ "win_rate": 0.96,
155
+ "non_loss_rate": 1.0,
156
+ "average_moves": 6.065
157
+ },
158
+ "versus_exact_minimax": {
159
+ "games": 200,
160
+ "wins": 0,
161
+ "draws": 200,
162
+ "losses": 0,
163
+ "win_rate": 0.0,
164
+ "non_loss_rate": 1.0,
165
+ "average_moves": 9.0
166
+ }
167
+ }
model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:702c351788afad7d80eb3b55117230644708b5a46952b84f44806476e976af2c
3
+ size 31392
source/app.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import gradio as gr
6
+ import numpy as np
7
+ from game import is_terminal, legal_actions, play, winner
8
+ from mcts import NeuralMCTS
9
+ from model import MicroZeroNet
10
+ from safetensors.torch import load_file
11
+
12
+ ARTIFACT = Path(__file__).resolve().parent / "artifacts" / "microzero"
13
+ MODEL = MicroZeroNet()
14
+ MODEL.load_state_dict(load_file(ARTIFACT / "model.safetensors"))
15
+ MODEL.eval()
16
+ SYMBOLS = {0: ".", 1: "X", -1: "O"}
17
+
18
+
19
+ def neural_action(
20
+ model: MicroZeroNet,
21
+ board: np.ndarray,
22
+ player: int,
23
+ simulations: int,
24
+ seed: int,
25
+ ) -> int:
26
+ search = NeuralMCTS(model, simulations=simulations)
27
+ policy = search.policy(
28
+ board,
29
+ player,
30
+ temperature=0.0,
31
+ add_noise=False,
32
+ rng=np.random.default_rng(seed),
33
+ )
34
+ return int(np.argmax(policy))
35
+
36
+
37
+ def render(board: list[int], message: str) -> str:
38
+ cells = [SYMBOLS[int(value)] for value in board]
39
+ rows = [" | ".join(cells[index : index + 3]) for index in range(0, 9, 3)]
40
+ return "```\n" + "\n---------\n".join(rows) + f"\n```\n{message}"
41
+
42
+
43
+ def new_game() -> tuple[list[int], str]:
44
+ board = [0] * 9
45
+ return board, render(board, "You are X. Choose a cell from 0 to 8.")
46
+
47
+
48
+ def move(board: list[int], action: int) -> tuple[list[int], str]:
49
+ array = np.asarray(board, dtype=np.int8)
50
+ action = int(action)
51
+ if action not in legal_actions(array):
52
+ return board, render(board, "That cell is occupied.")
53
+ array = play(array, action, 1)
54
+ if is_terminal(array):
55
+ message = "You win." if winner(array) == 1 else "Draw."
56
+ return array.tolist(), render(array.tolist(), message)
57
+ response = neural_action(MODEL, array, -1, simulations=96, seed=7000 + action)
58
+ array = play(array, response, -1)
59
+ result = winner(array)
60
+ if result == -1:
61
+ message = f"MicroZero played {response} and won."
62
+ elif is_terminal(array):
63
+ message = f"MicroZero played {response}. Draw."
64
+ else:
65
+ message = f"MicroZero played {response}. Your turn."
66
+ return array.tolist(), render(array.tolist(), message)
67
+
68
+
69
+ with gr.Blocks(title="MicroZero") as demo:
70
+ gr.Markdown("# MicroZero\nPlay against the neural MCTS agent.")
71
+ state = gr.State([0] * 9)
72
+ board = gr.Markdown(render([0] * 9, "You are X. Choose a cell from 0 to 8."))
73
+ action = gr.Slider(0, 8, value=4, step=1, label="Your move")
74
+ with gr.Row():
75
+ play_button = gr.Button("Play", variant="primary")
76
+ reset_button = gr.Button("New game")
77
+ play_button.click(move, inputs=[state, action], outputs=[state, board])
78
+ reset_button.click(new_game, outputs=[state, board])
79
+
80
+
81
+ if __name__ == "__main__":
82
+ demo.launch()
source/game.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from functools import cache
4
+
5
+ import numpy as np
6
+
7
+ WIN_LINES = [
8
+ (0, 1, 2),
9
+ (3, 4, 5),
10
+ (6, 7, 8),
11
+ (0, 3, 6),
12
+ (1, 4, 7),
13
+ (2, 5, 8),
14
+ (0, 4, 8),
15
+ (2, 4, 6),
16
+ ]
17
+
18
+
19
+ def winner(board: np.ndarray) -> int:
20
+ for left, middle, right in WIN_LINES:
21
+ value = int(board[left])
22
+ if value and value == board[middle] == board[right]:
23
+ return value
24
+ return 0
25
+
26
+
27
+ def is_terminal(board: np.ndarray) -> bool:
28
+ return winner(board) != 0 or not np.any(board == 0)
29
+
30
+
31
+ def legal_actions(board: np.ndarray) -> np.ndarray:
32
+ return np.flatnonzero(board == 0)
33
+
34
+
35
+ def play(board: np.ndarray, action: int, player: int) -> np.ndarray:
36
+ if board[action] != 0:
37
+ raise ValueError(f"Cell {action} is occupied.")
38
+ next_board = board.copy()
39
+ next_board[action] = player
40
+ return next_board
41
+
42
+
43
+ def canonical(board: np.ndarray, player: int) -> np.ndarray:
44
+ return board.astype(np.float32) * player
45
+
46
+
47
+ def symmetries(
48
+ state: np.ndarray,
49
+ policy: np.ndarray,
50
+ ) -> list[tuple[np.ndarray, np.ndarray]]:
51
+ board = state.reshape(3, 3)
52
+ probabilities = policy.reshape(3, 3)
53
+ transformed = []
54
+ for rotations in range(4):
55
+ rotated_board = np.rot90(board, rotations)
56
+ rotated_policy = np.rot90(probabilities, rotations)
57
+ transformed.append(
58
+ (rotated_board.reshape(-1).copy(), rotated_policy.reshape(-1).copy())
59
+ )
60
+ transformed.append(
61
+ (
62
+ np.fliplr(rotated_board).reshape(-1).copy(),
63
+ np.fliplr(rotated_policy).reshape(-1).copy(),
64
+ )
65
+ )
66
+ return transformed
67
+
68
+
69
+ @cache
70
+ def minimax_value(board_key: tuple[int, ...], player: int) -> int:
71
+ board = np.asarray(board_key, dtype=np.int8)
72
+ result = winner(board)
73
+ if result:
74
+ return 1 if result == player else -1
75
+ legal = legal_actions(board)
76
+ if not len(legal):
77
+ return 0
78
+ return max(
79
+ -minimax_value(tuple(play(board, int(action), player)), -player)
80
+ for action in legal
81
+ )
82
+
83
+
84
+ def minimax_action(board: np.ndarray, player: int) -> int:
85
+ legal = legal_actions(board)
86
+ values = [
87
+ -minimax_value(tuple(play(board, int(action), player)), -player)
88
+ for action in legal
89
+ ]
90
+ best = max(values)
91
+ return int(legal[values.index(best)])
source/mcts.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ import torch
7
+ from game import canonical, is_terminal, legal_actions, play, winner
8
+ from model import MicroZeroNet
9
+
10
+
11
+ class NeuralMCTS:
12
+ def __init__(
13
+ self,
14
+ model: MicroZeroNet,
15
+ simulations: int,
16
+ exploration: float = 1.35,
17
+ ) -> None:
18
+ self.model = model
19
+ self.simulations = simulations
20
+ self.exploration = exploration
21
+ self.priors: dict[tuple, np.ndarray] = {}
22
+ self.state_visits: dict[tuple, int] = {}
23
+ self.edge_visits: dict[tuple, int] = {}
24
+ self.edge_value: dict[tuple, float] = {}
25
+
26
+ @staticmethod
27
+ def key(board: np.ndarray, player: int) -> tuple:
28
+ return (*board.tolist(), player)
29
+
30
+ @torch.inference_mode()
31
+ def expand(self, board: np.ndarray, player: int) -> float:
32
+ key = self.key(board, player)
33
+ logits, value = self.model(torch.from_numpy(canonical(board, player))[None])
34
+ logits = logits[0].numpy()
35
+ legal = legal_actions(board)
36
+ masked = np.full(9, -1e9, dtype=np.float64)
37
+ masked[legal] = logits[legal]
38
+ probabilities = np.exp(masked - np.max(masked))
39
+ probabilities /= probabilities.sum()
40
+ self.priors[key] = probabilities
41
+ self.state_visits[key] = 0
42
+ return float(value[0])
43
+
44
+ def search(self, board: np.ndarray, player: int) -> float:
45
+ result = winner(board)
46
+ if result:
47
+ return 1.0 if result == player else -1.0
48
+ if is_terminal(board):
49
+ return 0.0
50
+ state_key = self.key(board, player)
51
+ if state_key not in self.priors:
52
+ return self.expand(board, player)
53
+
54
+ best_score = -float("inf")
55
+ best_action = -1
56
+ root_visits = max(1, self.state_visits[state_key])
57
+ for action in legal_actions(board):
58
+ edge = (*state_key, int(action))
59
+ visits = self.edge_visits.get(edge, 0)
60
+ value = self.edge_value.get(edge, 0.0)
61
+ prior = self.priors[state_key][action]
62
+ score = value + self.exploration * prior * math.sqrt(root_visits) / (
63
+ 1 + visits
64
+ )
65
+ if score > best_score:
66
+ best_score = score
67
+ best_action = int(action)
68
+
69
+ child = play(board, best_action, player)
70
+ value = -self.search(child, -player)
71
+ edge = (*state_key, best_action)
72
+ visits = self.edge_visits.get(edge, 0) + 1
73
+ previous = self.edge_value.get(edge, 0.0)
74
+ self.edge_visits[edge] = visits
75
+ self.edge_value[edge] = previous + (value - previous) / visits
76
+ self.state_visits[state_key] += 1
77
+ return value
78
+
79
+ def policy(
80
+ self,
81
+ board: np.ndarray,
82
+ player: int,
83
+ *,
84
+ temperature: float,
85
+ add_noise: bool,
86
+ rng: np.random.Generator,
87
+ ) -> np.ndarray:
88
+ key = self.key(board, player)
89
+ if key not in self.priors:
90
+ self.expand(board, player)
91
+ if add_noise:
92
+ legal = legal_actions(board)
93
+ noise = rng.dirichlet(np.full(len(legal), 0.30))
94
+ priors = self.priors[key].copy()
95
+ priors[legal] = 0.75 * priors[legal] + 0.25 * noise
96
+ self.priors[key] = priors
97
+ for _ in range(self.simulations):
98
+ self.search(board, player)
99
+ visits = np.zeros(9, dtype=np.float64)
100
+ for action in legal_actions(board):
101
+ visits[action] = self.edge_visits.get((*key, int(action)), 0)
102
+ if temperature <= 1e-6:
103
+ policy = np.zeros(9, dtype=np.float64)
104
+ policy[int(np.argmax(visits))] = 1.0
105
+ return policy
106
+ adjusted = visits ** (1 / temperature)
107
+ if adjusted.sum() == 0:
108
+ adjusted[legal_actions(board)] = 1
109
+ return adjusted / adjusted.sum()
source/model.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+
7
+ class MicroZeroNet(nn.Module):
8
+ def __init__(self) -> None:
9
+ super().__init__()
10
+ self.trunk = nn.Sequential(
11
+ nn.Linear(9, 64),
12
+ nn.LayerNorm(64),
13
+ nn.SiLU(),
14
+ nn.Linear(64, 64),
15
+ nn.SiLU(),
16
+ )
17
+ self.policy = nn.Linear(64, 9)
18
+ self.value = nn.Sequential(
19
+ nn.Linear(64, 32),
20
+ nn.SiLU(),
21
+ nn.Linear(32, 1),
22
+ nn.Tanh(),
23
+ )
24
+
25
+ def forward(self, board: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
26
+ hidden = self.trunk(board)
27
+ return self.policy(hidden), self.value(hidden).squeeze(-1)
28
+
29
+
30
+ def parameter_count(model: nn.Module) -> int:
31
+ return sum(parameter.numel() for parameter in model.parameters())
source/requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=5,<7
2
+ numpy>=2,<3
3
+ safetensors>=0.6,<1
4
+ torch>=2.7,<3
source/train.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import random
5
+ from collections import deque
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import torch
10
+ import trackio
11
+ from game import (
12
+ canonical,
13
+ is_terminal,
14
+ legal_actions,
15
+ minimax_action,
16
+ play,
17
+ symmetries,
18
+ winner,
19
+ )
20
+ from mcts import NeuralMCTS
21
+ from model import MicroZeroNet, parameter_count
22
+ from safetensors.torch import save_file
23
+ from torch.nn import functional as F
24
+ from torch.utils.data import DataLoader, TensorDataset
25
+
26
+ PROJECT_DIR = Path(__file__).resolve().parent
27
+ ARTIFACT_DIR = PROJECT_DIR / "artifacts" / "microzero"
28
+
29
+
30
+ def seed_everything(seed: int) -> None:
31
+ random.seed(seed)
32
+ np.random.seed(seed)
33
+ torch.manual_seed(seed)
34
+
35
+
36
+ def self_play_game(
37
+ model: MicroZeroNet,
38
+ rng: np.random.Generator,
39
+ simulations: int,
40
+ ) -> tuple[list[tuple[np.ndarray, np.ndarray, float]], int, int]:
41
+ board = np.zeros(9, dtype=np.int8)
42
+ player = 1
43
+ history = []
44
+ mcts = NeuralMCTS(model, simulations=simulations)
45
+ moves = 0
46
+ while not is_terminal(board):
47
+ temperature = 1.0 if moves < 4 else 0.15
48
+ policy = mcts.policy(
49
+ board,
50
+ player,
51
+ temperature=temperature,
52
+ add_noise=True,
53
+ rng=rng,
54
+ )
55
+ history.append((canonical(board, player), policy.copy(), player))
56
+ action = int(rng.choice(9, p=policy))
57
+ board = play(board, action, player)
58
+ player = -player
59
+ moves += 1
60
+ result = winner(board)
61
+ examples = []
62
+ for state, policy, state_player in history:
63
+ outcome = float(result * state_player)
64
+ for transformed_state, transformed_policy in symmetries(state, policy):
65
+ examples.append((transformed_state, transformed_policy, outcome))
66
+ return examples, result, moves
67
+
68
+
69
+ def train_replay(
70
+ model: MicroZeroNet,
71
+ replay: deque,
72
+ epochs: int,
73
+ ) -> dict:
74
+ states = torch.tensor(np.stack([item[0] for item in replay]), dtype=torch.float32)
75
+ policies = torch.tensor(
76
+ np.stack([item[1] for item in replay]),
77
+ dtype=torch.float32,
78
+ )
79
+ outcomes = torch.tensor([item[2] for item in replay], dtype=torch.float32)
80
+ loader = DataLoader(
81
+ TensorDataset(states, policies, outcomes),
82
+ batch_size=256,
83
+ shuffle=True,
84
+ )
85
+ optimizer = torch.optim.AdamW(model.parameters(), lr=0.0015, weight_decay=0.001)
86
+ model.train()
87
+ policy_losses = []
88
+ value_losses = []
89
+ for _ in range(epochs):
90
+ for state, target_policy, target_value in loader:
91
+ logits, value = model(state)
92
+ policy_loss = -(
93
+ target_policy * F.log_softmax(logits, dim=1)
94
+ ).sum(dim=1).mean()
95
+ value_loss = F.mse_loss(value, target_value)
96
+ loss = policy_loss + value_loss
97
+ optimizer.zero_grad(set_to_none=True)
98
+ loss.backward()
99
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
100
+ optimizer.step()
101
+ policy_losses.append(policy_loss.item())
102
+ value_losses.append(value_loss.item())
103
+ return {
104
+ "policy_loss": float(np.mean(policy_losses)),
105
+ "value_loss": float(np.mean(value_losses)),
106
+ }
107
+
108
+
109
+ def neural_action(
110
+ model: MicroZeroNet,
111
+ board: np.ndarray,
112
+ player: int,
113
+ simulations: int,
114
+ seed: int,
115
+ ) -> int:
116
+ mcts = NeuralMCTS(model, simulations=simulations)
117
+ policy = mcts.policy(
118
+ board,
119
+ player,
120
+ temperature=0.0,
121
+ add_noise=False,
122
+ rng=np.random.default_rng(seed),
123
+ )
124
+ return int(policy.argmax())
125
+
126
+
127
+ def arena(
128
+ model: MicroZeroNet,
129
+ opponent: str,
130
+ games: int,
131
+ simulations: int,
132
+ seed: int,
133
+ ) -> dict:
134
+ rng = np.random.default_rng(seed)
135
+ wins = 0
136
+ draws = 0
137
+ losses = 0
138
+ lengths = []
139
+ for game in range(games):
140
+ agent_player = 1 if game % 2 == 0 else -1
141
+ board = np.zeros(9, dtype=np.int8)
142
+ player = 1
143
+ moves = 0
144
+ while not is_terminal(board):
145
+ if player == agent_player:
146
+ action = neural_action(
147
+ model,
148
+ board,
149
+ player,
150
+ simulations,
151
+ seed + game * 11 + moves,
152
+ )
153
+ elif opponent == "random":
154
+ action = int(rng.choice(legal_actions(board)))
155
+ else:
156
+ action = minimax_action(board, player)
157
+ board = play(board, action, player)
158
+ player = -player
159
+ moves += 1
160
+ result = winner(board)
161
+ if result == agent_player:
162
+ wins += 1
163
+ elif result == 0:
164
+ draws += 1
165
+ else:
166
+ losses += 1
167
+ lengths.append(moves)
168
+ return {
169
+ "games": games,
170
+ "wins": wins,
171
+ "draws": draws,
172
+ "losses": losses,
173
+ "win_rate": wins / games,
174
+ "non_loss_rate": (wins + draws) / games,
175
+ "average_moves": float(np.mean(lengths)),
176
+ }
177
+
178
+
179
+ def main() -> None:
180
+ seed_everything(2043)
181
+ rng = np.random.default_rng(2043)
182
+ model = MicroZeroNet()
183
+ replay: deque = deque(maxlen=24_000)
184
+ iterations = 14
185
+ games_per_iteration = 70
186
+ trackio.init(
187
+ project="microzero",
188
+ name="neural-mcts-self-play-v1",
189
+ config={
190
+ "parameters": parameter_count(model),
191
+ "iterations": iterations,
192
+ "games_per_iteration": games_per_iteration,
193
+ "self_play_simulations": 32,
194
+ "symmetry_augmentation": 8,
195
+ },
196
+ )
197
+ history = []
198
+ for iteration in range(1, iterations + 1):
199
+ outcomes = []
200
+ moves = []
201
+ for _ in range(games_per_iteration):
202
+ examples, result, game_moves = self_play_game(
203
+ model,
204
+ rng,
205
+ simulations=32,
206
+ )
207
+ replay.extend(examples)
208
+ outcomes.append(result)
209
+ moves.append(game_moves)
210
+ training = train_replay(model, replay, epochs=4)
211
+ record = {
212
+ "iteration": iteration,
213
+ "replay_examples": len(replay),
214
+ "x_win_rate": float(np.mean(np.asarray(outcomes) == 1)),
215
+ "o_win_rate": float(np.mean(np.asarray(outcomes) == -1)),
216
+ "draw_rate": float(np.mean(np.asarray(outcomes) == 0)),
217
+ "average_game_moves": float(np.mean(moves)),
218
+ **training,
219
+ }
220
+ history.append(record)
221
+ trackio.log(record)
222
+
223
+ random_arena = arena(
224
+ model,
225
+ opponent="random",
226
+ games=400,
227
+ simulations=64,
228
+ seed=3043,
229
+ )
230
+ minimax_arena = arena(
231
+ model,
232
+ opponent="minimax",
233
+ games=200,
234
+ simulations=96,
235
+ seed=4043,
236
+ )
237
+ results = {
238
+ "model": "MicroZero",
239
+ "parameters": parameter_count(model),
240
+ "self_play_iterations": iterations,
241
+ "self_play_games": iterations * games_per_iteration,
242
+ "final_replay_examples": len(replay),
243
+ "training_history": history,
244
+ "versus_random": random_arena,
245
+ "versus_exact_minimax": minimax_arena,
246
+ }
247
+ trackio.log(
248
+ {
249
+ "random_win_rate": random_arena["win_rate"],
250
+ "random_non_loss_rate": random_arena["non_loss_rate"],
251
+ "minimax_non_loss_rate": minimax_arena["non_loss_rate"],
252
+ }
253
+ )
254
+ trackio.finish()
255
+ ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
256
+ save_file(model.state_dict(), ARTIFACT_DIR / "model.safetensors")
257
+ (ARTIFACT_DIR / "evaluation.json").write_text(
258
+ json.dumps(results, indent=2),
259
+ encoding="utf-8",
260
+ )
261
+ print(json.dumps(results, indent=2))
262
+
263
+
264
+ if __name__ == "__main__":
265
+ main()