quantik-models 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantik_models/__init__.py +12 -0
- quantik_models/arena/__init__.py +25 -0
- quantik_models/arena/agents.py +305 -0
- quantik_models/arena/autoplay.py +379 -0
- quantik_models/arena/match.py +232 -0
- quantik_models/arena/pack.py +406 -0
- quantik_models/arena/parallel.py +97 -0
- quantik_models/arena/probe.py +149 -0
- quantik_models/arena/registry.py +213 -0
- quantik_models/data/__init__.py +5 -0
- quantik_models/data/dataset.py +171 -0
- quantik_models/data/exact_corpus.py +153 -0
- quantik_models/data/labels.py +44 -0
- quantik_models/data/materialize.py +242 -0
- quantik_models/data/merge_corpus.py +158 -0
- quantik_models/env/__init__.py +21 -0
- quantik_models/env/fastboard.py +414 -0
- quantik_models/eval/__init__.py +1 -0
- quantik_models/eval/shift.py +379 -0
- quantik_models/export/__init__.py +1 -0
- quantik_models/export/cards.py +113 -0
- quantik_models/export/checkpoint.py +205 -0
- quantik_models/export/devdata.py +571 -0
- quantik_models/export/digest.py +22 -0
- quantik_models/export/huggingface.py +923 -0
- quantik_models/hub.py +526 -0
- quantik_models/model/__init__.py +1 -0
- quantik_models/model/attention_net.py +147 -0
- quantik_models/model/constraint_pool_net.py +220 -0
- quantik_models/model/mlp_net.py +107 -0
- quantik_models/model/policy_value_net.py +114 -0
- quantik_models/model/registry.py +191 -0
- quantik_models/model/spec.py +68 -0
- quantik_models/model_spec.py +9 -0
- quantik_models/play/__init__.py +32 -0
- quantik_models/play/__main__.py +132 -0
- quantik_models/play/export.py +128 -0
- quantik_models/play/opponents.py +216 -0
- quantik_models/play/puzzles.py +275 -0
- quantik_models/play/record.py +244 -0
- quantik_models/play/registry.py +187 -0
- quantik_models/play/server.py +311 -0
- quantik_models/play/service.py +479 -0
- quantik_models/play/store.py +348 -0
- quantik_models/py.typed +0 -0
- quantik_models/report/__init__.py +1 -0
- quantik_models/report/build_figures.py +144 -0
- quantik_models/report/figures.py +419 -0
- quantik_models/selfplay/__init__.py +12 -0
- quantik_models/selfplay/duel.py +90 -0
- quantik_models/selfplay/evaluator.py +125 -0
- quantik_models/selfplay/generate.py +155 -0
- quantik_models/selfplay/mcts.py +330 -0
- quantik_models/train/__init__.py +1 -0
- quantik_models/train/alphazero.py +330 -0
- quantik_models/train/convergence.py +25 -0
- quantik_models/train/freezing.py +131 -0
- quantik_models/train/metrics.py +25 -0
- quantik_models/train/preflight.py +345 -0
- quantik_models/train/provenance.py +172 -0
- quantik_models/train/supervised.py +437 -0
- quantik_models/train/trainer.py +304 -0
- quantik_models-1.0.0.dist-info/METADATA +294 -0
- quantik_models-1.0.0.dist-info/RECORD +68 -0
- quantik_models-1.0.0.dist-info/WHEEL +5 -0
- quantik_models-1.0.0.dist-info/entry_points.txt +7 -0
- quantik_models-1.0.0.dist-info/licenses/LICENSE +21 -0
- quantik_models-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Quantik policy/value networks, training, evaluation and play.
|
|
2
|
+
|
|
3
|
+
Published weights for the four architectures live on the Hugging Face Hub
|
|
4
|
+
rather than in this wheel — see `quantik_models.hub` and `docs/models.md`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# The single source of truth for the version. `pyproject.toml` reads this
|
|
8
|
+
# attribute statically (`[tool.setuptools.dynamic]`), so the number is
|
|
9
|
+
# declared once and cannot drift between the package and its metadata.
|
|
10
|
+
__version__ = "1.0.0"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Head-to-head evaluation of Quantik agents."""
|
|
2
|
+
|
|
3
|
+
from .agents import (
|
|
4
|
+
Agent,
|
|
5
|
+
BeamAgent,
|
|
6
|
+
CoreMCTSAgent,
|
|
7
|
+
MinimaxAgent,
|
|
8
|
+
NetMCTSAgent,
|
|
9
|
+
PolicyAgent,
|
|
10
|
+
RandomAgent,
|
|
11
|
+
)
|
|
12
|
+
from .match import MatchResult, play_match, round_robin
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Agent",
|
|
16
|
+
"BeamAgent",
|
|
17
|
+
"CoreMCTSAgent",
|
|
18
|
+
"MinimaxAgent",
|
|
19
|
+
"NetMCTSAgent",
|
|
20
|
+
"PolicyAgent",
|
|
21
|
+
"RandomAgent",
|
|
22
|
+
"MatchResult",
|
|
23
|
+
"play_match",
|
|
24
|
+
"round_robin",
|
|
25
|
+
]
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Agents playable in the arena, all speaking the 64-slot action index.
|
|
2
|
+
|
|
3
|
+
The classical agents wrap `quantik_core`'s engines unchanged — they are the
|
|
4
|
+
incumbents the network has to beat, so they must be the real thing rather
|
|
5
|
+
than a reimplementation. The network agents drive `BatchedMCTS` (or the raw
|
|
6
|
+
policy head) over `fastboard`.
|
|
7
|
+
|
|
8
|
+
Every agent takes a board as an `(8,) uint16` array and returns one action
|
|
9
|
+
index. Agents carry a `name` and a `config_label` for reporting, mirroring
|
|
10
|
+
the Rust `EngineAdapter` surface.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import random
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import numpy.typing as npt
|
|
20
|
+
|
|
21
|
+
from quantik_core import State
|
|
22
|
+
from quantik_core.beam_search import BeamSearchConfig, BeamSearchEngine
|
|
23
|
+
from quantik_core.mcts import MCTSConfig, MCTSEngine
|
|
24
|
+
from quantik_core.minimax import MinimaxConfig, MinimaxEngine
|
|
25
|
+
from quantik_core.move import generate_legal_moves_list
|
|
26
|
+
|
|
27
|
+
from ..env import fastboard as fb
|
|
28
|
+
from ..selfplay.mcts import BatchedMCTS, MCTSParams
|
|
29
|
+
|
|
30
|
+
Board = npt.NDArray[np.uint16]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Agent(Protocol):
|
|
34
|
+
name: str
|
|
35
|
+
|
|
36
|
+
def select(self, board: Board, seed: int) -> int:
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def config_label(self) -> str:
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bitboard(board: Board) -> tuple[int, int, int, int, int, int, int, int]:
|
|
44
|
+
"""A `Board` row as the fixed-width tuple `quantik-core` is typed for.
|
|
45
|
+
|
|
46
|
+
`Board` is `(8,) uint16` by construction, but a comprehension over it is
|
|
47
|
+
a `tuple[int, ...]` to a type checker, which is not the same type as the
|
|
48
|
+
eight-element tuple `State` and `generate_legal_moves_list` accept. The
|
|
49
|
+
cast is here, once, rather than at each call site.
|
|
50
|
+
"""
|
|
51
|
+
values = tuple(int(v) for v in board)
|
|
52
|
+
# The length check is not decoration: it is what narrows `tuple[int, ...]`
|
|
53
|
+
# to the eight-element tuple, so no cast is needed and a malformed board
|
|
54
|
+
# fails here rather than inside the Rust extension.
|
|
55
|
+
assert len(values) == 8, f"a Board is 8 planes wide, got {len(values)}"
|
|
56
|
+
return values
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _state(board: Board) -> State:
|
|
60
|
+
return State(_bitboard(board))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _ply(board: Board) -> int:
|
|
64
|
+
return int(fb.popcount(fb.occupancy(board[None, :]))[0])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _sample(weights: np.ndarray, temperature: float, seed: int) -> int:
|
|
68
|
+
"""Pick an index from `weights` at `temperature`, or its argmax at 0.
|
|
69
|
+
|
|
70
|
+
`weights` is a non-negative score per action — MCTS visit counts, or
|
|
71
|
+
policy priors — already zero everywhere illegal, so an action with a
|
|
72
|
+
zero score can never be drawn and the legality check upstream stays
|
|
73
|
+
intact.
|
|
74
|
+
|
|
75
|
+
The exponent is taken on weights divided by their maximum rather than
|
|
76
|
+
on the raw scores. Both forms are proportional and so describe the same
|
|
77
|
+
distribution, but the raw one overflows: `visits ** (1 / 0.01)` is
|
|
78
|
+
`128 ** 100`, which is `inf`, and one `inf` turns the normalised vector
|
|
79
|
+
into `nan` and `rng.choice` into a `ValueError`. Dividing first caps
|
|
80
|
+
the base at 1.0, so a small temperature underflows the losers to zero
|
|
81
|
+
and converges on the argmax — which is the limit it should converge on.
|
|
82
|
+
"""
|
|
83
|
+
if temperature <= 0.0:
|
|
84
|
+
return int(weights.argmax())
|
|
85
|
+
top = float(weights.max())
|
|
86
|
+
if top <= 0.0:
|
|
87
|
+
return int(weights.argmax())
|
|
88
|
+
scaled = np.zeros(weights.shape, dtype=np.float64)
|
|
89
|
+
positive = weights > 0.0
|
|
90
|
+
scaled[positive] = (weights[positive] / top) ** (1.0 / temperature)
|
|
91
|
+
total = scaled.sum()
|
|
92
|
+
if not np.isfinite(total) or total <= 0.0:
|
|
93
|
+
return int(weights.argmax())
|
|
94
|
+
return int(np.random.default_rng(seed).choice(scaled.size, p=scaled / total))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RandomAgent:
|
|
98
|
+
"""Uniform choice among legal actions."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, name: str = "random") -> None:
|
|
101
|
+
self.name = name
|
|
102
|
+
|
|
103
|
+
def select(self, board: Board, seed: int) -> int:
|
|
104
|
+
legal = np.flatnonzero(fb.legal_masks(board[None, :])[0])
|
|
105
|
+
return int(random.Random(seed).choice(legal.tolist()))
|
|
106
|
+
|
|
107
|
+
def config_label(self) -> str:
|
|
108
|
+
return "random"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class MinimaxAgent:
|
|
112
|
+
"""`quantik_core.minimax.MinimaxEngine` — iterative-deepening alpha-beta."""
|
|
113
|
+
|
|
114
|
+
def __init__(self, time_limit_s: float | None = 0.1, max_depth: int = 16, name: str | None = None):
|
|
115
|
+
self.time_limit_s = time_limit_s
|
|
116
|
+
self.max_depth = max_depth
|
|
117
|
+
self.name = name or (
|
|
118
|
+
f"minimax@{time_limit_s}s" if time_limit_s else f"minimax-d{max_depth}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def select(self, board: Board, seed: int) -> int:
|
|
122
|
+
engine = MinimaxEngine(
|
|
123
|
+
MinimaxConfig(
|
|
124
|
+
max_depth=self.max_depth,
|
|
125
|
+
time_limit_s=self.time_limit_s,
|
|
126
|
+
random_seed=seed,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
move = engine.search(_state(board)).best_move
|
|
130
|
+
return move.shape * 16 + move.position
|
|
131
|
+
|
|
132
|
+
def config_label(self) -> str:
|
|
133
|
+
return f"minimax(depth={self.max_depth},time={self.time_limit_s})"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class CoreMCTSAgent:
|
|
137
|
+
"""`quantik_core.mcts.MCTSEngine` — UCB1 MCTS with random rollouts."""
|
|
138
|
+
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
max_iterations: int = 2000,
|
|
142
|
+
time_limit_s: float | None = 0.1,
|
|
143
|
+
name: str | None = None,
|
|
144
|
+
):
|
|
145
|
+
self.max_iterations = max_iterations
|
|
146
|
+
self.time_limit_s = time_limit_s
|
|
147
|
+
self.name = name or (
|
|
148
|
+
f"mcts@{time_limit_s}s" if time_limit_s else f"mcts-{max_iterations}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def select(self, board: Board, seed: int) -> int:
|
|
152
|
+
engine = MCTSEngine(
|
|
153
|
+
MCTSConfig(
|
|
154
|
+
max_iterations=self.max_iterations,
|
|
155
|
+
time_limit_s=self.time_limit_s,
|
|
156
|
+
random_seed=seed,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
move, _ = engine.search(_state(board))
|
|
160
|
+
return move.shape * 16 + move.position
|
|
161
|
+
|
|
162
|
+
def config_label(self) -> str:
|
|
163
|
+
return f"mcts(iters={self.max_iterations},time={self.time_limit_s})"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class BeamAgent:
|
|
167
|
+
"""`quantik_core.beam_search.BeamSearchEngine` — level-by-level beam."""
|
|
168
|
+
|
|
169
|
+
def __init__(
|
|
170
|
+
self,
|
|
171
|
+
beam_width: int = 64,
|
|
172
|
+
rollouts: int = 8,
|
|
173
|
+
time_limit_s: float | None = 0.1,
|
|
174
|
+
name: str | None = None,
|
|
175
|
+
):
|
|
176
|
+
self.beam_width = beam_width
|
|
177
|
+
self.rollouts = rollouts
|
|
178
|
+
self.time_limit_s = time_limit_s
|
|
179
|
+
self.name = name or (
|
|
180
|
+
f"beam@{time_limit_s}s" if time_limit_s else f"beam-w{beam_width}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def select(self, board: Board, seed: int) -> int:
|
|
184
|
+
engine = BeamSearchEngine(
|
|
185
|
+
BeamSearchConfig(
|
|
186
|
+
beam_width=self.beam_width,
|
|
187
|
+
rollouts_per_candidate=self.rollouts,
|
|
188
|
+
time_limit_s=self.time_limit_s,
|
|
189
|
+
random_seed=seed,
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
result = engine.search(_state(board))
|
|
193
|
+
# BeamSearchResult exposes root moves aggregated from its sampled
|
|
194
|
+
# leaves rather than a single best move.
|
|
195
|
+
ranked = result.ranked_root_moves(top_k=1)
|
|
196
|
+
move = ranked[0].move if ranked else generate_legal_moves_list(
|
|
197
|
+
_bitboard(board)
|
|
198
|
+
)[0]
|
|
199
|
+
return move.shape * 16 + move.position
|
|
200
|
+
|
|
201
|
+
def config_label(self) -> str:
|
|
202
|
+
return f"beam(width={self.beam_width},rollouts={self.rollouts},time={self.time_limit_s})"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class PolicyAgent:
|
|
206
|
+
"""The network's policy head alone — one forward pass, zero search."""
|
|
207
|
+
|
|
208
|
+
def __init__(
|
|
209
|
+
self,
|
|
210
|
+
evaluator,
|
|
211
|
+
name: str = "net-policy",
|
|
212
|
+
temperature: float = 0.0,
|
|
213
|
+
temperature_plies: int | None = None,
|
|
214
|
+
):
|
|
215
|
+
self.evaluator = evaluator
|
|
216
|
+
self.name = name
|
|
217
|
+
self.temperature = temperature
|
|
218
|
+
self.temperature_plies = temperature_plies
|
|
219
|
+
|
|
220
|
+
def _temperature_at(self, board: Board) -> float:
|
|
221
|
+
"""The temperature in force at this position.
|
|
222
|
+
|
|
223
|
+
`temperature_plies` bounds sampling to the opening: `None` applies
|
|
224
|
+
it to the whole game, an integer applies it while fewer than that
|
|
225
|
+
many pieces are on the board. The board carries its own ply, so no
|
|
226
|
+
caller has to thread one through `select`.
|
|
227
|
+
"""
|
|
228
|
+
if self.temperature <= 0.0:
|
|
229
|
+
return 0.0
|
|
230
|
+
if self.temperature_plies is None:
|
|
231
|
+
return self.temperature
|
|
232
|
+
return self.temperature if _ply(board) < self.temperature_plies else 0.0
|
|
233
|
+
|
|
234
|
+
def select(self, board: Board, seed: int) -> int:
|
|
235
|
+
boards = board[None, :]
|
|
236
|
+
legal = fb.legal_masks(boards)
|
|
237
|
+
priors, _ = self.evaluator(boards, legal)
|
|
238
|
+
# `np.where` rather than trusting the evaluator's own masking: a
|
|
239
|
+
# prior that leaked onto an illegal action would otherwise become a
|
|
240
|
+
# drawable outcome once the temperature stops being zero.
|
|
241
|
+
weights = np.where(legal[0], priors[0], 0.0)
|
|
242
|
+
return _sample(weights, self._temperature_at(board), seed)
|
|
243
|
+
|
|
244
|
+
def config_label(self) -> str:
|
|
245
|
+
return (
|
|
246
|
+
f"net-policy(temperature={self.temperature},"
|
|
247
|
+
f"plies={self.temperature_plies})"
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class NetMCTSAgent:
|
|
252
|
+
"""The network inside `BatchedMCTS` — the full AlphaZero-style player.
|
|
253
|
+
|
|
254
|
+
Configure `params.time_limit_s` to play on the same clock as a
|
|
255
|
+
time-limited classical engine; otherwise `params.simulations` is the
|
|
256
|
+
budget.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
def __init__(self, evaluator, simulations: int = 128, params: MCTSParams | None = None,
|
|
260
|
+
name: str | None = None, temperature: float = 0.0,
|
|
261
|
+
temperature_plies: int | None = None):
|
|
262
|
+
self.evaluator = evaluator
|
|
263
|
+
self.params = params or MCTSParams(simulations=simulations)
|
|
264
|
+
self.temperature = temperature
|
|
265
|
+
self.temperature_plies = temperature_plies
|
|
266
|
+
if name:
|
|
267
|
+
self.name = name
|
|
268
|
+
elif self.params.time_limit_s:
|
|
269
|
+
self.name = f"net-mcts@{self.params.time_limit_s * 1000:.0f}ms"
|
|
270
|
+
else:
|
|
271
|
+
self.name = f"net-mcts-{self.params.simulations}"
|
|
272
|
+
|
|
273
|
+
def _temperature_at(self, board: Board) -> float:
|
|
274
|
+
"""See `PolicyAgent._temperature_at` — the same schedule, so an
|
|
275
|
+
opponent's opening variety does not depend on which of the two
|
|
276
|
+
kinds it happens to be."""
|
|
277
|
+
if self.temperature <= 0.0:
|
|
278
|
+
return 0.0
|
|
279
|
+
if self.temperature_plies is None:
|
|
280
|
+
return self.temperature
|
|
281
|
+
return self.temperature if _ply(board) < self.temperature_plies else 0.0
|
|
282
|
+
|
|
283
|
+
def select(self, board: Board, seed: int) -> int:
|
|
284
|
+
search = BatchedMCTS(self.evaluator, self.params, np.random.default_rng(seed))
|
|
285
|
+
visits, _ = search.search(board[None, :], add_noise=False)
|
|
286
|
+
# Sampling the *visit counts*, not the priors: the visits are what
|
|
287
|
+
# the search converged on, so a temperature here trades strength
|
|
288
|
+
# for variety along the search's own ranking rather than throwing
|
|
289
|
+
# the search away. Root Dirichlet noise (`add_noise`) is the other
|
|
290
|
+
# place variety could come from and is deliberately not it — it
|
|
291
|
+
# perturbs the tree the search is built on, so its cost is spread
|
|
292
|
+
# through every simulation instead of landing on one choice.
|
|
293
|
+
return _sample(visits[0].astype(np.float64), self._temperature_at(board), seed)
|
|
294
|
+
|
|
295
|
+
def config_label(self) -> str:
|
|
296
|
+
budget = (
|
|
297
|
+
f"time={self.params.time_limit_s}"
|
|
298
|
+
if self.params.time_limit_s
|
|
299
|
+
else f"sims={self.params.simulations}"
|
|
300
|
+
)
|
|
301
|
+
return (
|
|
302
|
+
f"net-mcts({budget},c_puct={self.params.c_puct},"
|
|
303
|
+
f"leaf_batch={self.params.leaf_batch},"
|
|
304
|
+
f"temperature={self.temperature},plies={self.temperature_plies})"
|
|
305
|
+
)
|