Add multi-process self-play, eval workers, MCTS Cython port

Key changes for ISMCTS speed and correctness:
- Cython port: HeuristicBot helpers (`heuristic_cy.pyx` + new `.pxd`) and
  ISMCTS searcher (`mcts.pyx`) now run as cdef. Both share a fast
  unified-action path through GameState's C interface to avoid Python
  round-trips on hot rollout/tree-walk paths.
- Multi-process self-play and eval: `workers.py`, `eval_worker.py`,
  `interleaved_self_play.py`, plus trainer wiring with ProcessPoolExecutor
  + spawn context. Eval inside `evaluate.py` is parallel per opponent.
- ISMCTS-specific eval (`evaluate.py`) runs MCTS at decision time so the
  metric matches deploy mode; `evaluation.eval_with_mcts` flag preserves
  backwards-compatible policy-only eval when needed.
- Trainer logs progress per phase (self-play start/done, eval per
  opponent), and value loss is now scaled by `value_scale` so policy and
  value losses sit on comparable magnitudes.
- Compact info-set key (`info_set.py`) using packed-struct format and
  child-key reuse during MCTS descent to cut per-step canonicalization.

Tests: 19 ISMCTS suite passing, including parity (Cython-vs-Python
sequential, batched-vs-sequential visit counts, push/pop round-trip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 02:39:39 +09:00
co-authored by Claude Opus 4.7
parent 0999d34277
commit 651175e5bd
17 changed files with 2896 additions and 114 deletions
+4
View File
@@ -24,6 +24,8 @@ mcts:
n_simulations: 50 n_simulations: 50
c_puct: 1.5 c_puct: 1.5
max_depth: 200 max_depth: 200
parallel_simulations: 8
virtual_loss_value: 1.0
temperature: temperature:
training: 1.0 training: 1.0
eval: 0.0 eval: 0.0
@@ -32,6 +34,8 @@ training:
gradient_steps_per_iter: 10 gradient_steps_per_iter: 10
batch_size: 128 batch_size: 128
replay_capacity: 100000 replay_capacity: 100000
interleave_games: 8
interleave_max_batch: 64
optimization: optimization:
learning_rate: 0.0003 learning_rate: 0.0003
grad_clip: 5.0 grad_clip: 5.0
+4
View File
@@ -24,6 +24,8 @@ mcts:
n_simulations: 50 n_simulations: 50
c_puct: 1.5 c_puct: 1.5
max_depth: 100 max_depth: 100
parallel_simulations: 8
virtual_loss_value: 1.0
temperature: temperature:
training: 1.0 training: 1.0
eval: 0.0 eval: 0.0
@@ -32,6 +34,8 @@ training:
gradient_steps_per_iter: 10 gradient_steps_per_iter: 10
batch_size: 128 batch_size: 128
replay_capacity: 50000 replay_capacity: 50000
interleave_games: 8
interleave_max_batch: 64
optimization: optimization:
learning_rate: 0.001 learning_rate: 0.001
grad_clip: 5.0 grad_clip: 5.0
+8
View File
@@ -59,6 +59,14 @@ include = ["coolrl_lost_cities*"]
"*.pxd", "*.pxd",
"*.pyx", "*.pyx",
] ]
"coolrl_lost_cities.games.classic.bots" = [
"*.pxd",
"*.pyx",
]
"coolrl_lost_cities.games.classic.ismcts" = [
"*.pxd",
"*.pyx",
]
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
+4
View File
@@ -30,6 +30,10 @@ extensions = cythonize(
"coolrl_lost_cities.games.classic.bots.heuristic_cy", "coolrl_lost_cities.games.classic.bots.heuristic_cy",
["src/coolrl_lost_cities/games/classic/bots/heuristic_cy.pyx"], ["src/coolrl_lost_cities/games/classic/bots/heuristic_cy.pyx"],
), ),
Extension(
"coolrl_lost_cities.games.classic.ismcts.mcts",
["src/coolrl_lost_cities/games/classic/ismcts/mcts.pyx"],
),
], ],
language_level=3, language_level=3,
compiler_directives={ compiler_directives={
@@ -0,0 +1,117 @@
from ..game cimport GameState
cdef class _CachedState:
cdef GameState _state
cdef public object config
cdef public object hands
cdef public object expeditions
cdef public object discards
cdef public object deck
cdef int hand_encoded[2][16]
cdef int hand_size[2]
cdef int expedition_top[2][8]
cdef int expedition_count[2][8]
cdef int expedition_handshakes[2][8]
cdef int expedition_numeric_sum[2][8]
cdef int expedition_last_numeric[2][8]
cdef int discard_top[8]
cdef int discard_count[8]
cdef int deck_remaining
cdef int total_scores[2]
cdef int current_player
cdef int phase
cdef int turn_count
cdef int n_colors
cdef int n_ranks
cdef int min_rank
cdef int hand_capacity
cdef int bonus_threshold
cdef int bonus_amount
cdef int expedition_penalty
cdef void _build(self, GameState state) except *
cpdef list legal_card_mask(self)
cpdef list legal_draw_mask(self)
cpdef bint can_play_card(self, int player, object card)
cpdef bint can_play_encoded(self, int player, int card)
cpdef bint has_numeric(self, int player, int color)
cpdef int score_diff(self, int player)
cdef class HeuristicBot:
cdef public object params
cdef double color_commit_cache[2][8]
cdef unsigned char color_commit_valid[2][8]
cdef signed char playability_cache[2][8][17]
cdef void _reset_caches(self) noexcept
cpdef int act_cython(self, GameState state) except -1
cdef int _card_color_c(self, _CachedState state, int card) noexcept
cdef int _card_rank_c(self, _CachedState state, int card) noexcept
cdef int _num_c(self, _CachedState state, int card) noexcept
cdef int _play_action_c(self, int slot) noexcept
cdef int _discard_action_c(self, int slot) noexcept
cdef bint _legal_card_action_c(self, _CachedState state, int action) noexcept
cdef bint _legal_draw_action_c(self, _CachedState state, int action) noexcept
cdef bint _can_play_card_c(self, _CachedState state, int player, int card) noexcept
cdef bint _has_numeric_c(self, _CachedState state, int player, int color) noexcept
cdef int _opened_colors_c(self, _CachedState state, int player) noexcept
cdef int _first_legal_card_c(self, _CachedState state) noexcept
cdef int _first_legal_draw_c(self, _CachedState state) noexcept
cdef int _act_card_c(self, _CachedState state, object derived) except -1
cdef int _act_draw_c(self, _CachedState state, object derived) except -1
cdef int _best_handshake_play_c(
self, _CachedState state, int player, object derived, int deck_left
) except -2
cdef int _best_number_play_c(
self, _CachedState state, int player, object derived, int deck_left
) except -2
cdef double _started_expedition_play_value_c(
self, _CachedState state, int player, int card, object derived, int deck_left
) except *
cdef bint _should_open_expedition_c(
self, _CachedState state, int player, int color, int opening_card, object derived, int deck_left
) except *
cdef double _opening_plan_value_c(
self, _CachedState state, int player, int color, int opening_card, object derived, int deck_left
) except *
cdef double _open_expedition_value_c(
self, _CachedState state, int player, int color, int opening_card, object derived, int deck_left
) except *
cdef int _best_forced_open_c(
self, _CachedState state, int player, object derived, int deck_left
) except -2
cdef int _best_discard_c(self, _CachedState state, int player, object derived) except -2
cdef double _visible_draw_value_c(
self, _CachedState state, int player, int card, object derived
) except *
cdef double _visible_open_support_value_c(
self, _CachedState state, int player, int card, object derived
) except *
cdef bint _visible_number_can_help_open_c(
self, _CachedState state, int player, int card, object derived
) except *
cdef double _deck_draw_value_c(self, _CachedState state, object derived) except *
cdef double _card_value_for_me_c(
self, _CachedState state, int player, int card, object derived
) except *
cdef double _card_value_for_opponent_c(
self, _CachedState state, int opponent, int card, object derived
) except *
cdef double _color_commitment_c(
self, _CachedState state, int player, int color, object derived
) except *
cdef double _public_color_commitment_for_opponent_c(
self, _CachedState state, int opponent, int color, object derived
) except *
cdef double _bonus_potential_c(
self,
_CachedState state,
int player,
int color,
int extra_cards,
object derived,
int committed_cards,
int exclude_card,
) except *
cdef double _new_color_open_penalty_c(self, int opened_colors) noexcept
cdef double _late_penalty_c(self, object derived, int deck_left) except *
@@ -5,6 +5,10 @@ import logging
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from libc.string cimport memset
from ..game cimport GameState as CGameState
from ..game import Card, GameState, LostCitiesConfig from ..game import Card, GameState, LostCitiesConfig
from ..policy import LostCitiesPolicy, PolicyInput from ..policy import LostCitiesPolicy, PolicyInput
from .base import first_legal, legal_from_obs from .base import first_legal, legal_from_obs
@@ -18,6 +22,11 @@ except ImportError as exc: # pragma: no cover
PLAY_OR_DISCARD_ACTIONS_PER_SLOT = 2 PLAY_OR_DISCARD_ACTIONS_PER_SLOT = 2
DRAW_FROM_DECK_ACTION = 0 DRAW_FROM_DECK_ACTION = 0
DEF MAX_HAND_SIZE = 16
DEF MAX_COLORS = 8
DEF MAX_RANKS = 16
DEF MAX_ACTIONS = 64
def play_action(slot: int) -> int: def play_action(slot: int) -> int:
return PLAY_OR_DISCARD_ACTIONS_PER_SLOT * slot return PLAY_OR_DISCARD_ACTIONS_PER_SLOT * slot
@@ -34,35 +43,87 @@ def draw_from_discard_action(color: int) -> int:
LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.bots.heuristic") LOGGER = logging.getLogger("coolrl_lost_cities.games.classic.bots.heuristic")
class _CachedState: cdef class _CachedState:
def __init__(self, state): def __init__(self, CGameState state):
self._state = state self._build(state)
self.config = state.config self.config = state.config
self.current_player = state.current_player
self.phase = state.phase
self.turn_count = state.turn_count
self.hands = state.hands self.hands = state.hands
self.expeditions = state.expeditions self.expeditions = state.expeditions
self.discards = state.discards self.discards = state.discards
self.deck = state.deck self.deck = state.deck
def legal_card_mask(self): cdef void _build(self, CGameState state) except *:
cdef int player
cdef int slot
cdef int color
cdef int idx
cdef int length
if state.hand_size > MAX_HAND_SIZE:
raise ValueError("hand_size exceeds HeuristicBot fixed hand buffer")
if state.n_colors > MAX_COLORS:
raise ValueError("n_colors exceeds HeuristicBot fixed color buffer")
if state.n_ranks > MAX_RANKS:
raise ValueError("n_ranks exceeds HeuristicBot fixed rank buffer")
self._state = state
self.n_colors = state.n_colors
self.n_ranks = state.n_ranks
self.min_rank = state.min_rank
self.hand_capacity = state.hand_size
self.bonus_threshold = state.bonus_threshold
self.bonus_amount = state.bonus_amount
self.expedition_penalty = state.expedition_penalty
self.current_player = state.current_player
self.phase = state.phase_id
self.turn_count = state.turn_count
self.deck_remaining = state.deck_len
self.total_scores[0] = state.total_scores[0]
self.total_scores[1] = state.total_scores[1]
memset(&self.hand_encoded[0][0], 0, sizeof(self.hand_encoded))
memset(&self.expedition_top[0][0], 0, sizeof(self.expedition_top))
memset(&self.expedition_count[0][0], 0, sizeof(self.expedition_count))
memset(&self.expedition_handshakes[0][0], 0, sizeof(self.expedition_handshakes))
memset(&self.expedition_numeric_sum[0][0], 0, sizeof(self.expedition_numeric_sum))
memset(&self.expedition_last_numeric[0][0], 0, sizeof(self.expedition_last_numeric))
memset(&self.discard_top[0], 0, sizeof(self.discard_top))
memset(&self.discard_count[0], 0, sizeof(self.discard_count))
for player in range(2):
self.hand_size[player] = state.hand_lens[player]
for slot in range(state.hand_lens[player]):
self.hand_encoded[player][slot] = state.hand_cards[state._hand_index(player, slot)]
for color in range(state.n_colors):
idx = state._expedition_len_index(player, color)
length = state.expedition_lens[idx]
self.expedition_count[player][color] = length
self.expedition_handshakes[player][color] = state.handshake_counts[idx]
self.expedition_numeric_sum[player][color] = state.numeric_sums[idx]
self.expedition_last_numeric[player][color] = state.last_numeric_ranks[idx]
if length > 0:
self.expedition_top[player][color] = state.expedition_cards[
state._expedition_index(player, color, length - 1)
]
for color in range(state.n_colors):
length = state.discard_lens[color]
self.discard_count[color] = length
if length > 0:
self.discard_top[color] = state.discard_cards[state._discard_index(color, length - 1)]
cpdef list legal_card_mask(self):
return self._state.legal_card_mask() return self._state.legal_card_mask()
def legal_draw_mask(self): cpdef list legal_draw_mask(self):
return self._state.legal_draw_mask() return self._state.legal_draw_mask()
def can_play_card(self, player, card): cpdef bint can_play_card(self, int player, object card):
return self._state.can_play_card(player, card) return self._state.can_play_card(player, card)
def has_numeric(self, player, color): cpdef bint can_play_encoded(self, int player, int card):
return self._state.has_numeric(player, color) return self._state._can_play_encoded_card_c(player, card)
def score_diff(self, player): cpdef bint has_numeric(self, int player, int color):
return self._state.score_diff(player) return self._state.last_numeric_rank(player, color) > 0
def __getattr__(self, name): cpdef int score_diff(self, int player):
return getattr(self._state, name) return self.total_scores[player] - self.total_scores[1 - player]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -178,29 +239,756 @@ def derive_heuristic_config(
) )
class HeuristicBot(LostCitiesPolicy): cdef class HeuristicBot:
def __init__(self, params: HeuristicParams | None = None): def __init__(self, params: HeuristicParams | None = None):
self.params = params or HeuristicParams() self.params = params or HeuristicParams()
self._reset_caches()
cdef void _reset_caches(self) noexcept:
memset(&self.color_commit_cache[0][0], 0, sizeof(self.color_commit_cache))
memset(&self.color_commit_valid[0][0], 0, sizeof(self.color_commit_valid))
memset(&self.playability_cache[0][0][0], -1, sizeof(self.playability_cache))
cpdef int act_cython(self, CGameState state) except -1:
cdef _CachedState cstate = _CachedState(state)
cdef object derived
self._reset_caches()
derived = self._derived(state)
if cstate.phase == 0:
return self._act_card_c(cstate, derived)
return self._act_draw_c(cstate, derived)
def act(self, obs_or_state: PolicyInput) -> int: def act(self, obs_or_state: PolicyInput) -> int:
if not isinstance(obs_or_state, GameState) and not hasattr(obs_or_state, "legal_mask"): if not isinstance(obs_or_state, GameState) and not hasattr(obs_or_state, "legal_mask"):
LOGGER.debug(
"HeuristicBot fallback to first legal: input_type=%s",
type(obs_or_state).__name__,
)
return first_legal(legal_from_obs(obs_or_state)) return first_legal(legal_from_obs(obs_or_state))
LOGGER.debug( if isinstance(obs_or_state, GameState):
"HeuristicBot heuristic path: player=%s phase=%s turn=%s", return self.act_cython(obs_or_state)
obs_or_state.current_player,
obs_or_state.phase,
obs_or_state.turn_count,
)
state = _CachedState(obs_or_state)
if state.phase == "card":
return self._act_card(state)
return self._act_draw(state) if obs_or_state.phase == "card":
return self._act_card(obs_or_state)
return self._act_draw(obs_or_state)
cdef inline int _card_color_c(self, _CachedState state, int card) noexcept:
return card // (state.n_ranks + 1)
cdef inline int _card_rank_c(self, _CachedState state, int card) noexcept:
return card % (state.n_ranks + 1)
cdef inline int _num_c(self, _CachedState state, int card) noexcept:
cdef int rank = self._card_rank_c(state, card)
if rank == 0:
return 0
return state.min_rank + rank - 1
cdef inline int _play_action_c(self, int slot) noexcept:
return 2 * slot
cdef inline int _discard_action_c(self, int slot) noexcept:
return 2 * slot + 1
cdef inline bint _legal_card_action_c(self, _CachedState state, int action) noexcept:
cdef int slot = action // 2
if action < 0 or action >= 2 * state.hand_capacity:
return False
if slot >= state.hand_size[state.current_player]:
return False
if action % 2 == 1:
return True
return self._can_play_card_c(
state,
state.current_player,
state.hand_encoded[state.current_player][slot],
)
cdef inline bint _legal_draw_action_c(self, _CachedState state, int action) noexcept:
cdef int color
if action == 0:
return state.deck_remaining > 0
color = action - 1
if color < 0 or color >= state.n_colors:
return False
return (
state.discard_count[color] > 0
and (state._state.pending_discarded_color < 0 or color != state._state.pending_discarded_color)
)
cdef bint _can_play_card_c(self, _CachedState state, int player, int card) noexcept:
cdef int color = self._card_color_c(state, card)
cdef int rank = self._card_rank_c(state, card)
cdef signed char cached
if color < 0 or color >= state.n_colors or rank < 0 or rank > state.n_ranks:
return False
cached = self.playability_cache[player][color][rank]
if cached >= 0:
return cached == 1
if rank == 0:
cached = 1 if state.expedition_last_numeric[player][color] == 0 else 0
else:
cached = 1 if rank > state.expedition_last_numeric[player][color] else 0
self.playability_cache[player][color][rank] = cached
return cached == 1
cdef bint _has_numeric_c(self, _CachedState state, int player, int color) noexcept:
return state.expedition_last_numeric[player][color] > 0
cdef int _opened_colors_c(self, _CachedState state, int player) noexcept:
cdef int color
cdef int count = 0
for color in range(state.n_colors):
if state.expedition_count[player][color] > 0:
count += 1
return count
cdef int _first_legal_card_c(self, _CachedState state) noexcept:
cdef int slot
cdef int card
cdef int player = state.current_player
for slot in range(state.hand_size[player]):
card = state.hand_encoded[player][slot]
if self._can_play_card_c(state, player, card):
return 2 * slot
return 2 * slot + 1
return 0
cdef int _first_legal_draw_c(self, _CachedState state) noexcept:
cdef int color
if self._legal_draw_action_c(state, 0):
return 0
for color in range(state.n_colors):
if self._legal_draw_action_c(state, 1 + color):
return 1 + color
return 0
cdef int _act_card_c(self, _CachedState state, object derived) except -1:
cdef int player = state.current_player
cdef int action
action = self._best_handshake_play_c(state, player, derived, state.deck_remaining)
if action >= 0:
return action
action = self._best_number_play_c(state, player, derived, state.deck_remaining)
if action >= 0:
return action
if self._opened_colors_c(state, player) == 0:
action = self._best_forced_open_c(state, player, derived, state.deck_remaining)
if action >= 0:
return action
action = self._best_discard_c(state, player, derived)
if action >= 0:
return action
return self._first_legal_card_c(state)
cdef int _act_draw_c(self, _CachedState state, object derived) except -1:
cdef int player = state.current_player
cdef int color
cdef int action
cdef int best_action = -1
cdef int best_tie = -1
cdef double value
cdef double best_value = -1.0e100
if self._legal_draw_action_c(state, 0):
best_action = 0
best_tie = 1
best_value = self._deck_draw_value_c(state, derived)
for color in range(state.n_colors):
action = 1 + color
if not self._legal_draw_action_c(state, action):
continue
value = self._visible_draw_value_c(state, player, state.discard_top[color], derived)
if (
value > best_value
or (
value == best_value
and (0 > best_tie or (best_tie == 0 and action > best_action))
)
):
best_value = value
best_tie = 0
best_action = action
if best_action >= 0:
return best_action
return self._first_legal_draw_c(state)
cdef int _best_handshake_play_c(
self, _CachedState state, int player, object derived, int deck_left
) except -2:
cdef int slot
cdef int other_slot
cdef int card
cdef int other
cdef int color
cdef int number_count
cdef int number_sum
cdef double value
cdef double best_value = -1.0e100
cdef int best_action = -1
if state._state.n_handshakes <= 0:
return -1
for slot in range(state.hand_size[player]):
card = state.hand_encoded[player][slot]
if not self._legal_card_action_c(state, 2 * slot) or self._card_rank_c(state, card) != 0:
continue
color = self._card_color_c(state, card)
if state.expedition_last_numeric[player][color] > 0:
continue
number_count = 0
number_sum = 0
for other_slot in range(state.hand_size[player]):
if other_slot == slot:
continue
other = state.hand_encoded[player][other_slot]
if (
self._card_color_c(state, other) == color
and self._card_rank_c(state, other) != 0
and self._can_play_card_c(state, player, other)
):
number_count += 1
number_sum += self._num_c(state, other)
if number_count < derived.min_handshake_numeric_cards:
continue
if number_sum < derived.open_target_sum * self.params.handshake_target_multiplier:
continue
if deck_left <= derived.late_open_block_threshold:
continue
value = number_sum + 2.0 * number_count
value += self._bonus_potential_c(state, player, color, 0, derived, 1, card)
value -= self._late_penalty_c(derived, deck_left)
if value > best_value or (value == best_value and 2 * slot > best_action):
best_value = value
best_action = 2 * slot
return best_action
cdef int _best_number_play_c(
self, _CachedState state, int player, object derived, int deck_left
) except -2:
cdef int slot
cdef int card
cdef int color
cdef double value
cdef double best_value = -1.0e100
cdef int best_action = -1
for slot in range(state.hand_size[player]):
card = state.hand_encoded[player][slot]
if not self._legal_card_action_c(state, 2 * slot) or self._card_rank_c(state, card) == 0:
continue
color = self._card_color_c(state, card)
if state.expedition_count[player][color] > 0:
value = self._started_expedition_play_value_c(
state, player, card, derived, deck_left
)
if value > best_value or (value == best_value and 2 * slot > best_action):
best_value = value
best_action = 2 * slot
continue
if self._should_open_expedition_c(state, player, color, card, derived, deck_left):
value = self._open_expedition_value_c(state, player, color, card, derived, deck_left)
if value > best_value or (value == best_value and 2 * slot > best_action):
best_value = value
best_action = 2 * slot
return best_action
cdef double _started_expedition_play_value_c(
self, _CachedState state, int player, int card, object derived, int deck_left
) except *:
cdef int color = self._card_color_c(state, card)
cdef int numeric_value = self._num_c(state, card)
cdef int slot
cdef int followup
cdef int projected_sum = state.expedition_numeric_sum[player][color] + numeric_value
cdef double value = 0.0
for slot in range(state.hand_size[player]):
followup = state.hand_encoded[player][slot]
if (
followup != card
and self._card_color_c(state, followup) == color
and self._card_rank_c(state, followup) != 0
and self._card_rank_c(state, followup) > self._card_rank_c(state, card)
):
projected_sum += self._num_c(state, followup)
value += self.params.started_expedition_play_bonus
value += self.params.started_expedition_followup_bonus
value += <double>(state._state.min_rank + state._state.n_ranks - numeric_value)
if deck_left <= derived.late_deck_threshold:
value += 2.0 * numeric_value
elif deck_left <= derived.mid_deck_threshold:
value += 0.8 * numeric_value
if projected_sum < derived.open_target_sum:
value -= 6.0
value += 3.0 * state.expedition_handshakes[player][color]
value += self._bonus_potential_c(state, player, color, 0, derived, 1, card)
return value
cdef bint _should_open_expedition_c(
self, _CachedState state, int player, int color, int opening_card, object derived, int deck_left
) except *:
if deck_left <= derived.late_open_block_threshold:
return False
return self._opening_plan_value_c(state, player, color, opening_card, derived, deck_left) > 0.0
cdef double _opening_plan_value_c(
self, _CachedState state, int player, int color, int opening_card, object derived, int deck_left
) except *:
cdef int slot
cdef int card
cdef int rank
cdef int numbers = 0
cdef int handshakes = 0
cdef int number_sum = 0
cdef int high_count = 0
cdef int opened_colors = self._opened_colors_c(state, player)
cdef int opening_value = self._num_c(state, opening_card)
cdef double new_color_penalty = self._new_color_open_penalty_c(opened_colors)
cdef bint strong_open
cdef bint speculative_open
cdef bint single_late_open
cdef bint exceptional_open
for slot in range(state.hand_size[player]):
card = state.hand_encoded[player][slot]
if self._card_color_c(state, card) != color:
continue
rank = self._card_rank_c(state, card)
if rank == 0:
handshakes += 1
elif rank >= self._card_rank_c(state, opening_card):
numbers += 1
number_sum += self._num_c(state, card)
if rank >= derived.middle_rank:
high_count += 1
strong_open = (
numbers >= derived.min_open_cards
and number_sum >= derived.open_target_sum
and (high_count > 0 or number_sum >= 0.85 * derived.max_color_sum)
)
speculative_open = (
opened_colors <= 2
and numbers >= 2
and number_sum >= 0.65 * derived.open_target_sum
and high_count > 0
)
single_late_open = (
deck_left <= derived.mid_deck_threshold and numbers >= 1 and opening_value >= 8
)
exceptional_open = (
numbers >= derived.min_open_cards + 1
and number_sum >= max(float(derived.break_even_sum), derived.open_target_sum * 1.4)
and high_count >= 2
and deck_left > derived.mid_deck_threshold
)
if opened_colors == 3:
speculative_open = False
if opened_colors >= 4:
strong_open = False
speculative_open = False
single_late_open = False
if strong_open:
return 6.0 + 0.25 * number_sum + 0.8 * numbers + 0.5 * handshakes - new_color_penalty
if speculative_open:
return 3.0 + 0.18 * number_sum + 0.7 * numbers + 0.4 * handshakes - new_color_penalty
if opened_colors == 3:
return 0.0
if exceptional_open:
return 10.0 + 0.3 * number_sum + 1.0 * numbers + 0.7 * high_count - new_color_penalty
if single_late_open:
return 1.5 + 0.2 * opening_value - new_color_penalty
return 0.0
cdef double _open_expedition_value_c(
self, _CachedState state, int player, int color, int opening_card, object derived, int deck_left
) except *:
cdef int slot
cdef int card
cdef int rank
cdef int numbers = 0
cdef int handshakes = 0
cdef int number_sum = 0
cdef double value
for slot in range(state.hand_size[player]):
card = state.hand_encoded[player][slot]
if self._card_color_c(state, card) != color:
continue
rank = self._card_rank_c(state, card)
if rank == 0:
handshakes += 1
elif rank >= self._card_rank_c(state, opening_card):
numbers += 1
number_sum += self._num_c(state, card)
value = number_sum + 2.0 * numbers + 1.5 * handshakes
value += self._opening_plan_value_c(state, player, color, opening_card, derived, deck_left)
value += state.expedition_penalty
value += max(0.0, float(derived.middle_rank - self._card_rank_c(state, opening_card)))
value += self._bonus_potential_c(state, player, color, 0, derived, 1, opening_card)
value -= self._late_penalty_c(derived, deck_left)
return value
cdef int _best_forced_open_c(
self, _CachedState state, int player, object derived, int deck_left
) except -2:
cdef int slot
cdef int card
cdef int color
cdef int other
cdef int number_count
cdef int number_sum
cdef double opening_value
cdef double forced_value
cdef double best_value = -1.0e100
cdef int best_action = -1
for slot in range(state.hand_size[player]):
card = state.hand_encoded[player][slot]
if not self._legal_card_action_c(state, 2 * slot) or self._card_rank_c(state, card) == 0:
continue
color = self._card_color_c(state, card)
if state.expedition_count[player][color] > 0:
continue
opening_value = self._opening_plan_value_c(state, player, color, card, derived, deck_left)
number_count = 0
number_sum = 0
for other_slot in range(state.hand_size[player]):
other = state.hand_encoded[player][other_slot]
if self._card_color_c(state, other) == color and self._card_rank_c(state, other) != 0:
number_count += 1
number_sum += self._num_c(state, other)
if (
opening_value <= 0.0
and number_count < 2
and number_sum < 0.5 * derived.open_target_sum
and deck_left > derived.mid_deck_threshold
):
continue
forced_value = opening_value + 0.2 * number_sum
forced_value += <double>(state._state.min_rank + state._state.n_ranks - self._num_c(state, card))
if forced_value > best_value or (forced_value == best_value and 2 * slot > best_action):
best_value = forced_value
best_action = 2 * slot
return best_action
cdef int _best_discard_c(self, _CachedState state, int player, object derived) except -2:
cdef int opponent = 1 - player
cdef int slot
cdef int card
cdef double my_value
cdef double opponent_value
cdef double score
cdef double best_score = -1.0e100
cdef int best_action = -1
for slot in range(state.hand_size[player]):
if not self._legal_card_action_c(state, 2 * slot + 1):
continue
card = state.hand_encoded[player][slot]
my_value = self._card_value_for_me_c(state, player, card, derived)
opponent_value = self._card_value_for_opponent_c(state, opponent, card, derived)
score = -my_value - self.params.gift_penalty_weight * opponent_value
if not self._can_play_card_c(state, player, card):
score += self.params.unusable_discard_bonus
if not self._can_play_card_c(state, opponent, card):
score += self.params.discard_safety_bonus
if self._card_rank_c(state, card) == 0 and self._can_play_card_c(state, player, card):
score -= 4.0
if score > best_score or (score == best_score and 2 * slot + 1 > best_action):
best_score = score
best_action = 2 * slot + 1
return best_action
cdef double _visible_draw_value_c(
self, _CachedState state, int player, int card, object derived
) except *:
cdef int color = self._card_color_c(state, card)
cdef int opponent = 1 - player
cdef int opened_colors = self._opened_colors_c(state, player)
cdef bint is_unopened_color = state.expedition_count[player][color] == 0
cdef double commitment = self._color_commitment_c(state, player, color, derived)
cdef double opponent_value = self._card_value_for_opponent_c(state, opponent, card, derived)
cdef int score_diff = state.total_scores[player] - state.total_scores[opponent]
cdef double value = self.params.deny_opponent_weight * opponent_value
cdef double support
cdef bint exceptional_support = False
cdef int slot
cdef int other
cdef int number_count
cdef int number_sum
cdef double required_sum
if score_diff <= 0:
value += self.params.losing_visible_draw_bonus
if is_unopened_color:
if opened_colors >= 4:
value -= self.params.unopened_draw_penalty_four_open
elif opened_colors >= 3:
value -= self.params.unopened_draw_penalty_three_open
if self._card_rank_c(state, card) == 0:
if self._has_numeric_c(state, player, color):
return value - self.params.dead_visible_draw_penalty
if state.expedition_count[player][color] == 0:
number_count = 0
number_sum = 0
for slot in range(state.hand_size[player]):
other = state.hand_encoded[player][slot]
if (
self._card_color_c(state, other) == color
and self._card_rank_c(state, other) != 0
and self._can_play_card_c(state, player, other)
):
number_count += 1
number_sum += self._num_c(state, other)
required_sum = derived.open_target_sum * self.params.handshake_target_multiplier
if number_count < derived.min_handshake_numeric_cards or number_sum < required_sum:
support = self._visible_open_support_value_c(state, player, card, derived)
exceptional_support = support >= 6.0
if (
is_unopened_color
and opened_colors >= 4
and not exceptional_support
and opponent_value < self.params.strong_deny_threshold
and score_diff > -15
):
return -8.0
return value + support - 0.5
return value + 6.0 + commitment
if self._can_play_card_c(state, player, card):
value += self._num_c(state, card)
value += 0.7 * commitment
if state.expedition_count[player][color] > 0:
value += 5.0
else:
support = self._visible_open_support_value_c(state, player, card, derived)
exceptional_support = support >= 6.0
value += support
else:
value -= self.params.dead_visible_draw_penalty
if state.expedition_count[player][color] == 0:
support = self._visible_open_support_value_c(state, player, card, derived)
exceptional_support = support >= 6.0
value += support
if (
is_unopened_color
and opened_colors >= 4
and not exceptional_support
and opponent_value < self.params.strong_deny_threshold
and score_diff > -15
):
return -8.0
value += self._bonus_potential_c(state, player, color, 1, derived, 0, -1)
return value
cdef double _visible_open_support_value_c(
self, _CachedState state, int player, int card, object derived
) except *:
cdef int color = self._card_color_c(state, card)
cdef int opened_colors = self._opened_colors_c(state, player)
cdef int slot
cdef int other
cdef int rank
cdef int future_numbers = 0
cdef int same_color_handshakes = 0
cdef double value = 0.0
for slot in range(state.hand_size[player]):
other = state.hand_encoded[player][slot]
if self._card_color_c(state, other) != color:
continue
rank = self._card_rank_c(state, other)
if rank == 0:
same_color_handshakes += 1
elif other != card and rank >= self._card_rank_c(state, card):
future_numbers += 1
value += 0.8 * future_numbers
value += 1.0 * same_color_handshakes
if self._card_rank_c(state, card) <= derived.middle_rank:
value += self.params.speculative_visible_draw_bonus
if self._visible_number_can_help_open_c(state, player, card, derived):
value += 4.0
elif opened_colors <= 2 and (future_numbers > 0 or same_color_handshakes > 0):
value += self.params.speculative_visible_draw_bonus
if opened_colors <= 2:
value += 0.25 * self._opening_plan_value_c(
state, player, color, card, derived, state.deck_remaining
)
elif opened_colors == 3:
value += 0.1 * max(
0.0,
self._opening_plan_value_c(state, player, color, card, derived, state.deck_remaining),
)
return value
cdef bint _visible_number_can_help_open_c(
self, _CachedState state, int player, int card, object derived
) except *:
cdef int color = self._card_color_c(state, card)
cdef int slot
cdef int other
cdef int count = 1
cdef int number_sum = self._num_c(state, card)
cdef bint has_high = self._card_rank_c(state, card) >= derived.middle_rank
for slot in range(state.hand_size[player]):
other = state.hand_encoded[player][slot]
if (
self._card_color_c(state, other) == color
and self._card_rank_c(state, other) != 0
and self._card_rank_c(state, other) >= self._card_rank_c(state, card)
):
count += 1
number_sum += self._num_c(state, other)
if self._card_rank_c(state, other) >= derived.middle_rank:
has_high = True
if count < derived.min_open_cards:
return False
if number_sum < derived.open_target_sum:
return False
return has_high
cdef double _deck_draw_value_c(self, _CachedState state, object derived) except *:
cdef int score_diff = state.total_scores[state.current_player] - state.total_scores[1 - state.current_player]
cdef double value
if state.deck_remaining > derived.mid_deck_threshold:
value = self.params.deck_draw_early_value
elif state.deck_remaining > derived.late_deck_threshold:
value = self.params.deck_draw_mid_value
else:
value = self.params.deck_draw_late_value
if score_diff > 0:
value += self.params.winning_deck_bonus
else:
value -= self.params.losing_deck_penalty
return value
cdef double _card_value_for_me_c(
self, _CachedState state, int player, int card, object derived
) except *:
cdef int color = self._card_color_c(state, card)
cdef int rank = self._card_rank_c(state, card)
cdef double commitment
cdef int numeric_value
cdef double value
if not self._can_play_card_c(state, player, card):
return 0.0
commitment = self._color_commitment_c(state, player, color, derived)
if rank == 0:
return 7.0 + 1.2 * commitment
numeric_value = self._num_c(state, card)
value = 0.8 * numeric_value + self.params.commitment_weight * commitment
if state.expedition_count[player][color] > 0:
value += self.params.started_expedition_play_bonus
value += self.params.started_expedition_followup_bonus
if commitment >= 6.0 and rank <= derived.middle_rank:
value += self.params.low_card_sequence_bonus
return value
cdef double _card_value_for_opponent_c(
self, _CachedState state, int opponent, int card, object derived
) except *:
cdef int rank = self._card_rank_c(state, card)
cdef double interest
if not self._can_play_card_c(state, opponent, card):
return 0.0
interest = self._public_color_commitment_for_opponent_c(
state, opponent, self._card_color_c(state, card), derived
)
if rank == 0:
return 8.0 + 1.5 * interest
return self._num_c(state, card) * (0.4 + 0.25 * interest)
cdef double _color_commitment_c(
self, _CachedState state, int player, int color, object derived
) except *:
cdef int slot
cdef int card
cdef int rank
cdef int playable_numbers = 0
cdef int playable_handshakes = 0
cdef int playable_sum = 0
cdef double value
if self.color_commit_valid[player][color] != 0:
return self.color_commit_cache[player][color]
value = 0.0
if state.expedition_count[player][color] > 0:
value += 5.0
value += 2.0 * state.expedition_handshakes[player][color]
value += 0.25 * state.expedition_numeric_sum[player][color]
for slot in range(state.hand_size[player]):
card = state.hand_encoded[player][slot]
if self._card_color_c(state, card) != color or not self._can_play_card_c(state, player, card):
continue
rank = self._card_rank_c(state, card)
if rank == 0:
playable_handshakes += 1
else:
playable_numbers += 1
playable_sum += self._num_c(state, card)
value += 1.2 * playable_numbers
value += 1.5 * playable_handshakes
value += 0.15 * playable_sum
value += 0.05 * self._bonus_potential_c(state, player, color, 0, derived, 0, -1)
self.color_commit_cache[player][color] = value
self.color_commit_valid[player][color] = 1
return value
cdef double _public_color_commitment_for_opponent_c(
self, _CachedState state, int opponent, int color, object derived
) except *:
cdef int top_card
cdef double value = 0.0
if state.expedition_count[opponent][color] > 0:
value += 5.0
value += 2.0 * state.expedition_handshakes[opponent][color]
value += 0.25 * state.expedition_numeric_sum[opponent][color]
if state.expedition_last_numeric[opponent][color] > 0:
value += 0.4 * (state.min_rank + state.expedition_last_numeric[opponent][color] - 1)
if state.discard_count[color] > 0:
top_card = state.discard_top[color]
if self._can_play_card_c(state, opponent, top_card):
if self._card_rank_c(state, top_card) == 0:
value += 1.5
else:
value += 1.0 + 0.1 * self._num_c(state, top_card)
if derived.bonus_possible and state.expedition_count[opponent][color] + 1 >= state.bonus_threshold:
value += 0.2 * state.bonus_amount
return value
cdef double _bonus_potential_c(
self,
_CachedState state,
int player,
int color,
int extra_cards,
object derived,
int committed_cards,
int exclude_card,
) except *:
cdef int need
cdef int slot
cdef int card
cdef int playable_count = 0
if not derived.bonus_possible:
return 0.0
need = state.bonus_threshold - (state.expedition_count[player][color] + committed_cards)
if need <= 0:
return <double>state.bonus_amount
for slot in range(state.hand_size[player]):
card = state.hand_encoded[player][slot]
if (
card != exclude_card
and self._card_color_c(state, card) == color
and self._can_play_card_c(state, player, card)
):
playable_count += 1
if playable_count + extra_cards >= need:
return 0.4 * state.bonus_amount
return 0.0
cdef double _new_color_open_penalty_c(self, int opened_colors) noexcept:
if opened_colors <= 1:
return 0.0
if opened_colors == 2:
return 6.0
if opened_colors == 3:
return 14.0
return 28.0
cdef double _late_penalty_c(self, object derived, int deck_left) except *:
if deck_left <= derived.late_deck_threshold:
return 15.0
if deck_left <= derived.mid_deck_threshold:
return 8.0
return 0.0
def _act_card(self, state: GameState) -> int: def _act_card(self, state: GameState) -> int:
player = state.current_player player = state.current_player
@@ -25,14 +25,26 @@ class MctsConfig(StrictModel):
c_puct: float = 1.5 c_puct: float = 1.5
max_depth: int = 200 max_depth: int = 200
use_rollout_value: bool = True use_rollout_value: bool = True
rollout_policy: str = "random"
parallel_simulations: int = 8
virtual_loss_value: float = 1.0
eval_with_mcts: bool = True
eval_n_simulations: int = 0
@field_validator("n_simulations", "max_depth") @field_validator("n_simulations", "max_depth", "parallel_simulations")
@classmethod @classmethod
def _positive_int(cls, value: int) -> int: def _positive_int(cls, value: int) -> int:
if value <= 0: if value <= 0:
raise ValueError("must be positive") raise ValueError("must be positive")
return value return value
@field_validator("rollout_policy")
@classmethod
def _rollout_policy(cls, value: str) -> str:
if value not in {"random", "heuristic_balanced"}:
raise ValueError("rollout_policy must be 'random' or 'heuristic_balanced'")
return value
class TemperatureConfig(StrictModel): class TemperatureConfig(StrictModel):
training: float = 1.0 training: float = 1.0
@@ -44,8 +56,20 @@ class TrainingConfig(StrictModel):
gradient_steps_per_iter: int = 10 gradient_steps_per_iter: int = 10
batch_size: int = 128 batch_size: int = 128
replay_capacity: int = 100_000 replay_capacity: int = 100_000
interleave_games: int = 8
interleave_max_batch: int = 64
num_workers: int = 1
worker_device: str = "cpu"
@field_validator("games_per_iter", "gradient_steps_per_iter", "batch_size", "replay_capacity") @field_validator(
"games_per_iter",
"gradient_steps_per_iter",
"batch_size",
"replay_capacity",
"interleave_games",
"interleave_max_batch",
"num_workers",
)
@classmethod @classmethod
def _positive_int(cls, value: int) -> int: def _positive_int(cls, value: int) -> int:
if value <= 0: if value <= 0:
@@ -0,0 +1,125 @@
"""Multi-process eval workers for ISMCTS — slice games across processes."""
from __future__ import annotations
import random
from dataclasses import dataclass
from typing import Any
import torch
from coolrl_lost_cities.games.classic.bots.registry import build_bot
from coolrl_lost_cities.games.classic.deep_cfr.encoding import input_dim
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig
from .config import IsMctsConfig, config_from_dict
from .mcts import IsMctsSearcher
from .network import AlphaZeroNet
@dataclass(frozen=True)
class EvalWorkerBatch:
worker_index: int
config: dict[str, Any]
game_config: dict[str, Any]
network_state: dict[str, Any]
mcts_config: dict[str, Any]
opponent: str
game_indices: list[int]
seed: int
device: str
max_steps: int
@dataclass
class EvalWorkerResult:
worker_index: int
score_diffs: list[float]
wins0: int
wins1: int
draws: int
policy_turns: int
play_actions: int
timeouts: int
def run_eval_worker(batch: EvalWorkerBatch) -> EvalWorkerResult:
import os
os.environ.setdefault("OMP_NUM_THREADS", "1")
os.environ.setdefault("MKL_NUM_THREADS", "1")
torch.set_num_threads(1)
cfg: IsMctsConfig = config_from_dict(batch.config)
game_config = LostCitiesConfig(**batch.game_config)
device = torch.device(batch.device)
probe = GameState.new_game(game_config, seed=batch.seed)
in_dim = input_dim(probe, cfg.encoding)
network = AlphaZeroNet.from_config(in_dim, probe.action_size, cfg).to(device)
network.load_state_dict(batch.network_state)
network.eval()
from .config import MctsConfig
mcts_config = MctsConfig.model_validate(batch.mcts_config)
rng = random.Random(batch.seed + batch.worker_index * 7919)
score_diffs: list[float] = []
wins0 = wins1 = draws = 0
policy_turns = 0
play_actions = 0
timeouts = 0
for game_index in batch.game_indices:
policy_player = game_index % 2
opponents = [
build_bot(batch.opponent, seed=batch.seed + game_index),
build_bot(batch.opponent, seed=batch.seed + game_index + 1),
]
state = GameState.new_game(game_config, seed=batch.seed + game_index)
steps = 0
terminated = False
while steps < batch.max_steps:
if state.terminal:
terminated = True
break
current = int(state.current_player)
if current == policy_player:
searcher = IsMctsSearcher(
network,
mcts_config,
device=device,
encoding=cfg.encoding,
rng=random.Random(rng.randrange(2**31)),
)
visits = searcher.search(state, current)
if visits:
unified = max(visits, key=visits.get)
else:
unified = state.unified_legal_actions()[0]
if state.phase == "card":
policy_turns += 1
if unified % 2 == 0:
play_actions += 1
state.apply_unified_action(unified)
else:
action = opponents[current].act(state)
state.apply_action(action)
steps += 1
if not terminated:
timeouts += 1
diff = float(state.score_diff(policy_player))
score_diffs.append(diff)
if diff > 0:
wins0 += 1
elif diff < 0:
wins1 += 1
else:
draws += 1
return EvalWorkerResult(
worker_index=batch.worker_index,
score_diffs=score_diffs,
wins0=wins0,
wins1=wins1,
draws=draws,
policy_turns=policy_turns,
play_actions=play_actions,
timeouts=timeouts,
)
@@ -0,0 +1,190 @@
from __future__ import annotations
import multiprocessing as mp
import random
import time
from concurrent.futures import ProcessPoolExecutor
import torch
from coolrl_lost_cities.games.classic.bots.registry import build_bot
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig
from .config import IsMctsConfig, MctsConfig
from .eval_worker import EvalWorkerBatch, run_eval_worker
from .mcts import IsMctsSearcher
from .network import AlphaZeroNet
def evaluate_with_mcts(
network: AlphaZeroNet,
game_config: LostCitiesConfig,
mcts_config: MctsConfig,
*,
games: int,
seed: int,
opponent: str,
device: torch.device | str = "cpu",
encoding=None,
max_steps: int = 10_000,
config: IsMctsConfig | None = None,
num_workers: int = 1,
) -> dict[str, float | int]:
started = time.perf_counter()
if num_workers > 1 and config is not None and games > 1:
return _evaluate_parallel(
network,
game_config,
mcts_config,
config=config,
games=games,
seed=seed,
opponent=opponent,
num_workers=num_workers,
max_steps=max_steps,
started=started,
)
rng = random.Random(seed)
score_diffs: list[float] = []
wins0 = wins1 = draws = 0
policy_turns = 0
play_actions = 0
timeouts = 0
network.eval()
for game_index in range(games):
policy_player = game_index % 2
opponents = [
build_bot(opponent, seed=seed + game_index),
build_bot(opponent, seed=seed + game_index + 1),
]
state = GameState.new_game(game_config, seed=seed + game_index)
steps = 0
terminated = False
while steps < max_steps:
if state.terminal:
terminated = True
break
current = int(state.current_player)
if current == policy_player:
searcher = IsMctsSearcher(
network,
mcts_config,
device=device,
encoding=encoding,
rng=random.Random(rng.randrange(2**31)),
)
visits = searcher.search(state, current)
if visits:
unified = max(visits, key=visits.get)
else:
unified = state.unified_legal_actions()[0]
if state.phase == "card":
policy_turns += 1
if unified % 2 == 0:
play_actions += 1
state.apply_unified_action(unified)
else:
action = opponents[current].act(state)
state.apply_action(action)
steps += 1
if not terminated:
timeouts += 1
diff = float(state.score_diff(policy_player))
score_diffs.append(diff)
if diff > 0:
wins0 += 1
elif diff < 0:
wins1 += 1
else:
draws += 1
n = len(score_diffs)
avg_diff = sum(score_diffs) / n if n else 0.0
return {
"games": n,
"win_rate0": wins0 / n if n else 0.0,
"win_rate1": wins1 / n if n else 0.0,
"wins0": wins0,
"wins1": wins1,
"draws": draws,
"avg_score_diff0": avg_diff,
"policy_turns": policy_turns,
"play_action_rate": play_actions / policy_turns if policy_turns else 0.0,
"max_step_timeouts": timeouts,
"elapsed_seconds": time.perf_counter() - started,
}
def _evaluate_parallel(
network: AlphaZeroNet,
game_config: LostCitiesConfig,
mcts_config: MctsConfig,
*,
config: IsMctsConfig,
games: int,
seed: int,
opponent: str,
num_workers: int,
max_steps: int,
started: float,
) -> dict[str, float | int]:
effective_workers = min(num_workers, games)
base = games // effective_workers
rem = games % effective_workers
counts = [base + (1 if i < rem else 0) for i in range(effective_workers)]
indices_per_worker: list[list[int]] = []
cursor = 0
for c in counts:
indices_per_worker.append(list(range(cursor, cursor + c)))
cursor += c
cpu_state = {name: tensor.detach().cpu() for name, tensor in network.state_dict().items()}
config_dict = config.to_dict()
game_snapshot = game_config.to_snapshot()
mcts_dict = mcts_config.model_dump(mode="json")
worker_device = str(config.training.worker_device)
batches = [
EvalWorkerBatch(
worker_index=i,
config=config_dict,
game_config=game_snapshot,
network_state=cpu_state,
mcts_config=mcts_dict,
opponent=opponent,
game_indices=indices_per_worker[i],
seed=seed,
device=worker_device,
max_steps=max_steps,
)
for i in range(effective_workers)
]
ctx = mp.get_context("spawn")
score_diffs: list[float] = []
wins0 = wins1 = draws = 0
policy_turns = 0
play_actions = 0
timeouts = 0
with ProcessPoolExecutor(max_workers=effective_workers, mp_context=ctx) as executor:
for res in executor.map(run_eval_worker, batches):
score_diffs.extend(res.score_diffs)
wins0 += res.wins0
wins1 += res.wins1
draws += res.draws
policy_turns += res.policy_turns
play_actions += res.play_actions
timeouts += res.timeouts
n = len(score_diffs)
avg_diff = sum(score_diffs) / n if n else 0.0
return {
"games": n,
"win_rate0": wins0 / n if n else 0.0,
"win_rate1": wins1 / n if n else 0.0,
"wins0": wins0,
"wins1": wins1,
"draws": draws,
"avg_score_diff0": avg_diff,
"policy_turns": policy_turns,
"play_action_rate": play_actions / policy_turns if policy_turns else 0.0,
"max_step_timeouts": timeouts,
"elapsed_seconds": time.perf_counter() - started,
}
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
import json import struct
from collections import Counter from collections import Counter
from coolrl_lost_cities.games.classic.game import Card, GameState, build_deck from coolrl_lost_cities.games.classic.game import Card, GameState, build_deck
@@ -14,30 +14,73 @@ def _sorted_cards(cards: list[Card]) -> list[tuple[int, int]]:
return sorted(_card_tuple(card) for card in cards) return sorted(_card_tuple(card) for card in cards)
# Phase encoding: "card" -> 0, "draw" -> 1, anything else -> 2.
_PHASE_TO_INT = {"card": 0, "draw": 1}
def canonical_info_set_key(state: GameState, player: int) -> bytes: def canonical_info_set_key(state: GameState, player: int) -> bytes:
"""Deterministic key for observable information from ``player``'s POV.""" """Deterministic key for observable information from ``player``'s POV.
Packed binary representation (big-endian) covering the same fields as
the previous JSON encoding. Faster to compute and produces a more
compact key while remaining a stable, hashable ``bytes`` value.
"""
p = int(player) p = int(player)
payload = { cfg = state.config
"config": state.config.to_snapshot(), parts: list[bytes] = []
"player": p, # Header: rule constants that pin down the action/observation shape.
"current_player": int(state.current_player), parts.append(
"phase": state.phase, struct.pack(
"pending_discarded_color": ( ">BBBBBhhBBBBB",
None if state.pending_discarded_color < 0 else int(state.pending_discarded_color) int(cfg.n_colors) & 0xFF,
), int(cfg.n_ranks) & 0xFF,
"turn_count": int(state.turn_count), int(cfg.min_rank) & 0xFF,
"terminal": bool(state.terminal), int(cfg.n_handshakes) & 0xFF,
"deck_size": len(state.deck), int(cfg.hand_size) & 0xFF,
"hand": _sorted_cards(state.hands[p]), int(cfg.expedition_penalty),
"hand_size_opp": len(state.hands[1 - p]), int(cfg.bonus_amount),
"expeditions": [ int(cfg.bonus_threshold) & 0xFF,
[[_card_tuple(card) for card in expedition] for expedition in player_expeditions] p & 0xFF,
for player_expeditions in state.expeditions int(state.current_player) & 0xFF,
], _PHASE_TO_INT.get(state.phase, 2) & 0xFF,
"discards": [[_card_tuple(card) for card in discard] for discard in state.discards], (1 if state.terminal else 0) & 0xFF,
"legal_mask": list(map(bool, state.unified_legal_mask())), )
} )
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") # Variable scalars.
pending_color = -1 if state.pending_discarded_color < 0 else int(state.pending_discarded_color)
parts.append(
struct.pack(
">bHHH",
pending_color,
int(state.turn_count) & 0xFFFF,
len(state.deck) & 0xFFFF,
len(state.hands[1 - p]) & 0xFFFF,
)
)
# Sorted hand for the POV player. Cards encoded as (color, rank).
hand = state.hands[p]
parts.append(struct.pack(">H", len(hand)))
if hand:
sorted_pairs = sorted((int(c.color), int(c.rank)) for c in hand)
parts.append(b"".join(struct.pack(">BB", c, r) for c, r in sorted_pairs))
# Expeditions per player/color (ordered, since order matters for legality).
expeditions = state.expeditions
for player_expeditions in expeditions:
for expedition in player_expeditions:
parts.append(struct.pack(">H", len(expedition)))
if expedition:
parts.append(
b"".join(struct.pack(">BB", int(c.color), int(c.rank)) for c in expedition)
)
# Discards per color.
for discard in state.discards:
parts.append(struct.pack(">H", len(discard)))
if discard:
parts.append(b"".join(struct.pack(">BB", int(c.color), int(c.rank)) for c in discard))
# Legal mask (packed as raw bytes from the underlying list).
mask = state.unified_legal_mask()
parts.append(bytes(1 if bool(b) else 0 for b in mask))
return b"".join(parts)
def visible_cards(state: GameState, player: int) -> list[Card]: def visible_cards(state: GameState, player: int) -> list[Card]:
@@ -0,0 +1,240 @@
from __future__ import annotations
import random
from dataclasses import dataclass, field
import numpy as np
import torch
from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig
from .config import MctsConfig, TrainingConfig
from .info_set import canonical_info_set_key
from .mcts import IsMctsSearcher, PendingSimulation
from .network import AlphaZeroNet
from .replay_buffer import ReplaySample
from .self_play import select_from_distribution, visit_distribution
@dataclass
class _PendingDecision:
info_state: np.ndarray
legal_mask: np.ndarray
pi_target: np.ndarray
player: int
prior: np.ndarray
game_index: int
@dataclass
class _GameContext:
state: GameState
rng: random.Random
game_index: int
decisions: list[_PendingDecision] = field(default_factory=list)
steps: int = 0
@dataclass
class _SearchJob:
context: _GameContext
searcher: IsMctsSearcher
traverser: int
remaining: int
def play_self_play_iteration(
network: AlphaZeroNet,
mcts_config: MctsConfig,
training_config: TrainingConfig,
game_config: LostCitiesConfig,
rng: random.Random,
*,
device: torch.device | str = "cpu",
encoding=None,
temperature: float = 1.0,
max_steps: int = 10_000,
) -> list[ReplaySample]:
device = torch.device(device)
completed: list[list[ReplaySample]] = []
active: list[_GameContext] = []
started = 0
target_games = training_config.games_per_iter
def fill_active() -> None:
nonlocal started
while len(active) < training_config.interleave_games and started < target_games:
active.append(
_GameContext(
state=GameState.new_game(game_config, seed=rng.randrange(2**31)),
rng=random.Random(rng.randrange(2**31)),
game_index=started,
)
)
started += 1
fill_active()
while active:
jobs: list[_SearchJob] = []
still_active: list[_GameContext] = []
for context in active:
if context.state.terminal or context.steps >= max_steps:
completed.append(_finalize_context(context))
continue
player = int(context.state.current_player)
searcher = IsMctsSearcher(
network,
mcts_config,
device=device,
encoding=encoding,
rng=random.Random(context.rng.randrange(2**31)),
)
jobs.append(
_SearchJob(
context=context,
searcher=searcher,
traverser=player,
remaining=mcts_config.n_simulations,
)
)
still_active.append(context)
active = still_active
if jobs:
_run_search_jobs(network, jobs, training_config.interleave_max_batch, device)
for job in jobs:
_finish_decision(job, mcts_config, encoding, temperature)
fill_active()
samples: list[ReplaySample] = []
for game_samples in completed:
samples.extend(game_samples)
return samples
def _run_search_jobs(
network: AlphaZeroNet,
jobs: list[_SearchJob],
max_batch: int,
device: torch.device,
) -> None:
while any(job.remaining > 0 for job in jobs):
pending: list[tuple[_SearchJob, PendingSimulation]] = []
for job in jobs:
job_quota = min(
job.remaining,
job.searcher.config.parallel_simulations,
max_batch - len(pending),
)
if job_quota <= 0:
break
job_pending = job.searcher.prepare_simulation_batch(
job.context.state,
job.traverser,
job_quota,
)
pending.extend((job, item) for item in job_pending)
job.remaining -= len(job_pending)
if len(pending) >= max_batch:
break
if not pending:
break
_evaluate_global_batch(network, pending, device)
def _evaluate_global_batch(
network: AlphaZeroNet,
pending: list[tuple[_SearchJob, PendingSimulation]],
device: torch.device,
) -> None:
network_pending = [(job, item) for job, item in pending if item.terminal_value is None]
values_by_id: dict[int, float] = {}
priors_by_id: dict[int, np.ndarray] = {}
if network_pending:
infos = np.stack(
[item.info_state for _job, item in network_pending if item.info_state is not None]
)
masks = np.stack(
[item.legal_mask for _job, item in network_pending if item.legal_mask is not None]
)
with torch.inference_mode():
x = torch.as_tensor(infos, dtype=torch.float32, device=device)
mask = torch.as_tensor(masks, dtype=torch.bool, device=device)
probs = network.policy_distribution(x, mask).detach().cpu().numpy()
_logits, values = network(x, mask)
values_np = values.detach().cpu().numpy()
for index, (_job, item) in enumerate(network_pending):
priors_by_id[id(item)] = probs[index]
values_by_id[id(item)] = float(values_np[index])
for job, item in pending:
if item.terminal_value is not None:
value = item.terminal_value
else:
assert item.leaf_node is not None
value = job.searcher._expand_with_prior(
item.leaf_node,
item.leaf_state,
item.leaf_player,
item.legal_actions,
priors_by_id[id(item)],
values_by_id[id(item)],
)
job.searcher._backup(item.path, value, item.leaf_player)
def _finish_decision(
job: _SearchJob,
mcts_config: MctsConfig,
encoding,
temperature: float,
) -> None:
context = job.context
state = context.state
player = int(state.current_player)
legal_mask = np.asarray(state.unified_legal_mask(), dtype=bool)
info = encode_info_state(state, player, encoding)
root_key = canonical_info_set_key(state, player)
root = job.searcher.tree.get_or_create(root_key, player=player, terminal=state.terminal)
visits = {action: root.visits.get(action, 0) for action in state.unified_legal_actions()}
pi = visit_distribution(visits, state.action_size, temperature=temperature)
if pi.sum() <= 0:
legal_actions = np.flatnonzero(legal_mask)
pi[legal_actions] = 1.0 / len(legal_actions)
prior = np.zeros(state.action_size, dtype=np.float32)
for action in state.unified_legal_actions():
prior[action] = float(root.priors.get(action, 0.0))
context.decisions.append(
_PendingDecision(
info_state=info.astype(np.float32),
legal_mask=legal_mask.astype(bool),
pi_target=pi.astype(np.float32),
player=player,
prior=prior,
game_index=context.game_index,
)
)
action = select_from_distribution(pi, context.rng)
state.apply_unified_action(action)
context.steps += 1
def _finalize_context(context: _GameContext) -> list[ReplaySample]:
final_diff0 = float(context.state.score_diff(0))
samples: list[ReplaySample] = []
for decision in context.decisions:
value = final_diff0 if decision.player == 0 else -final_diff0
samples.append(
ReplaySample(
info_state=decision.info_state,
legal_mask=decision.legal_mask,
pi_target=decision.pi_target,
v_target=value,
player=decision.player,
prior=decision.prior,
game_index=decision.game_index,
)
)
return samples
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
import numpy as np import numpy as np
import torch import torch
from coolrl_lost_cities.games.classic.bots.heuristic import HeuristicBot
from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state
from coolrl_lost_cities.games.classic.game import GameState from coolrl_lost_cities.games.classic.game import GameState
@@ -23,6 +24,7 @@ class MctsNode:
priors: dict[int, float] = field(default_factory=dict) priors: dict[int, float] = field(default_factory=dict)
visits: dict[int, int] = field(default_factory=dict) visits: dict[int, int] = field(default_factory=dict)
value_sum: dict[int, float] = field(default_factory=dict) value_sum: dict[int, float] = field(default_factory=dict)
virtual_visits: dict[int, int] = field(default_factory=dict)
children: dict[int, bytes] = field(default_factory=dict) children: dict[int, bytes] = field(default_factory=dict)
terminal: bool = False terminal: bool = False
@@ -36,6 +38,26 @@ class MctsNode:
return self.value_sum.get(action, 0.0) / n return self.value_sum.get(action, 0.0) / n
@dataclass
class SearchPathEntry:
node: MctsNode
action: int
parent_player: int
child_player: int
@dataclass
class PendingSimulation:
path: list[SearchPathEntry]
leaf_state: GameState
leaf_node: MctsNode | None
leaf_player: int
info_state: np.ndarray | None
legal_mask: np.ndarray | None
legal_actions: list[int]
terminal_value: float | None = None
class MctsTree: class MctsTree:
def __init__(self) -> None: def __init__(self) -> None:
self.nodes: dict[bytes, MctsNode] = {} self.nodes: dict[bytes, MctsNode] = {}
@@ -64,6 +86,9 @@ class IsMctsSearcher:
self.encoding = encoding self.encoding = encoding
self.rng = rng or random.Random() self.rng = rng or random.Random()
self.tree = MctsTree() self.tree = MctsTree()
self._rollout_bot = (
HeuristicBot() if config.rollout_policy == "heuristic_balanced" else None
)
def search( def search(
self, self,
@@ -76,71 +101,209 @@ class IsMctsSearcher:
root_key, player=state.current_player, terminal=state.terminal root_key, player=state.current_player, terminal=state.terminal
) )
sims = int(n_sims or self.config.n_simulations) sims = int(n_sims or self.config.n_simulations)
for _ in range(sims): completed = 0
det = sample_determinization(state, traverser, self.rng) while completed < sims:
self._simulate(det, depth=0) batch_size = min(self.config.parallel_simulations, sims - completed)
pending = self.prepare_simulation_batch(state, traverser, batch_size)
if not pending:
break
self.evaluate_and_backup(pending)
completed += len(pending)
legal = state.unified_legal_actions() legal = state.unified_legal_actions()
return {action: root.visits.get(action, 0) for action in legal} return {action: root.visits.get(action, 0) for action in legal}
def _simulate(self, state: GameState, *, depth: int) -> float: def prepare_simulation_batch(
player = int(state.current_player) self,
if state.terminal or depth >= self.config.max_depth: root_state: GameState,
return float(state.score_diff(player)) traverser: int,
max_simulations: int,
) -> list[PendingSimulation]:
pending: list[PendingSimulation] = []
for _ in range(max_simulations):
item = self.prepare_simulation(root_state, traverser)
pending.append(item)
if item.terminal_value is None and item.leaf_node is not None and not item.path:
break
return pending
key = canonical_info_set_key(state, player) def prepare_simulation(self, root_state: GameState, traverser: int) -> PendingSimulation:
node = self.tree.get_or_create(key, player=player, terminal=state.terminal) state = sample_determinization(root_state, traverser, self.rng)
if not node.is_expanded(): path: list[SearchPathEntry] = []
value = self._expand_and_evaluate(node, state, player) depth = 0
return value # Cache the info-set key for the current node so we don't recompute it
# after applying an action (the child's key becomes the next iter's key).
cached_key: bytes | None = None
while True:
player = int(state.current_player)
if state.terminal or depth >= self.config.max_depth:
return PendingSimulation(
path=path,
leaf_state=state,
leaf_node=None,
leaf_player=player,
info_state=None,
legal_mask=None,
legal_actions=[],
terminal_value=float(state.score_diff(player)),
)
key = cached_key if cached_key is not None else canonical_info_set_key(state, player)
node = self.tree.get_or_create(key, player=player, terminal=state.terminal)
if not node.is_expanded():
legal_actions = state.unified_legal_actions()
if not legal_actions:
node.terminal = True
return PendingSimulation(
path=path,
leaf_state=state,
leaf_node=node,
leaf_player=player,
info_state=None,
legal_mask=None,
legal_actions=[],
terminal_value=float(state.score_diff(player)),
)
return PendingSimulation(
path=path,
leaf_state=state,
leaf_node=node,
leaf_player=player,
info_state=encode_info_state(state, player, self.encoding),
legal_mask=np.asarray(state.unified_legal_mask(), dtype=bool),
legal_actions=legal_actions,
)
action = self._select_action(node, state.unified_legal_actions()) # Reuse the same legal_actions list for selection (avoids one
child = state.clone() # extra unified_legal_actions() call inside _select_action).
child.apply_unified_action(action) legal_actions = state.unified_legal_actions()
child_value = self._simulate(child, depth=depth + 1) action = self._select_action(node, legal_actions)
value = child_value if child.current_player == player else -child_value node.virtual_visits[action] = node.virtual_visits.get(action, 0) + 1
node.visits[action] = node.visits.get(action, 0) + 1 child = state.clone()
node.value_sum[action] = node.value_sum.get(action, 0.0) + value child.apply_unified_action(action)
child_key = canonical_info_set_key(child, child.current_player) child_player = int(child.current_player)
node.children[action] = child_key child_key = canonical_info_set_key(child, child_player)
return value node.children[action] = child_key
path.append(
SearchPathEntry(
node=node,
action=action,
parent_player=player,
child_player=child_player,
)
)
state = child
cached_key = child_key
depth += 1
def _expand_and_evaluate(self, node: MctsNode, state: GameState, player: int) -> float: def evaluate_and_backup(self, pending: list[PendingSimulation]) -> None:
network_pending = [item for item in pending if item.terminal_value is None]
values_by_id: dict[int, float] = {}
priors_by_id: dict[int, np.ndarray] = {}
if network_pending:
infos = np.stack(
[item.info_state for item in network_pending if item.info_state is not None]
)
masks = np.stack(
[item.legal_mask for item in network_pending if item.legal_mask is not None]
)
with torch.inference_mode():
x = torch.as_tensor(infos, dtype=torch.float32, device=self.device)
mask = torch.as_tensor(masks, dtype=torch.bool, device=self.device)
probs = self.network.policy_distribution(x, mask).detach().cpu().numpy()
_logits, network_values = self.network(x, mask)
network_values_np = network_values.detach().cpu().numpy()
for index, item in enumerate(network_pending):
priors_by_id[id(item)] = probs[index]
values_by_id[id(item)] = float(network_values_np[index])
for item in pending:
if item.terminal_value is not None:
value = item.terminal_value
else:
assert item.leaf_node is not None
value = self._expand_with_prior(
item.leaf_node,
item.leaf_state,
item.leaf_player,
item.legal_actions,
priors_by_id[id(item)],
values_by_id[id(item)],
)
self._backup(item.path, value, item.leaf_player)
def _expand_with_prior(
self,
node: MctsNode,
state: GameState,
player: int,
legal_actions: list[int],
probs: np.ndarray,
network_value: float,
) -> float:
legal_actions = state.unified_legal_actions() legal_actions = state.unified_legal_actions()
if not legal_actions: if not legal_actions:
node.terminal = True node.terminal = True
return float(state.score_diff(player)) return float(state.score_diff(player))
info = encode_info_state(state, player, self.encoding)
legal_mask = np.asarray(state.unified_legal_mask(), dtype=bool)
with torch.inference_mode():
x = torch.as_tensor(info[None, :], dtype=torch.float32, device=self.device)
mask = torch.as_tensor(legal_mask[None, :], dtype=torch.bool, device=self.device)
probs = self.network.policy_distribution(x, mask).squeeze(0).detach().cpu().numpy()
_logits, network_value = self.network(x, mask)
for action in legal_actions: for action in legal_actions:
node.priors[action] = float(probs[action]) node.priors[action] = float(probs[action])
node.visits.setdefault(action, 0) node.visits.setdefault(action, 0)
node.value_sum.setdefault(action, 0.0) node.value_sum.setdefault(action, 0.0)
node.virtual_visits.setdefault(action, 0)
rollout_value = ( rollout_value = (
self._rollout_value(state, player) if self.config.use_rollout_value else None self._rollout_value(state, player) if self.config.use_rollout_value else None
) )
if rollout_value is None: if rollout_value is None:
return float(network_value.item()) return float(network_value)
return rollout_value return rollout_value
def _select_action(self, node: MctsNode, legal_actions: list[int]) -> int: def _select_action(self, node: MctsNode, legal_actions: list[int]) -> int:
total_visits = sum(node.visits.get(action, 0) for action in legal_actions) total_visits = sum(
node.visits.get(action, 0) + node.virtual_visits.get(action, 0)
for action in legal_actions
)
sqrt_total = math.sqrt(max(1, total_visits)) sqrt_total = math.sqrt(max(1, total_visits))
best_score = -float("inf") best_score = -float("inf")
best_action = legal_actions[0] best_action = legal_actions[0]
for action in legal_actions: for action in legal_actions:
n = node.visits.get(action, 0) n = node.visits.get(action, 0)
virtual = node.virtual_visits.get(action, 0)
n_eff = n + virtual
prior = node.priors.get(action, 0.0) prior = node.priors.get(action, 0.0)
score = node.q(action) + self.config.c_puct * prior * sqrt_total / (1 + n) if n_eff <= 0:
q_eff = 0.0
else:
q_eff = (
node.value_sum.get(action, 0.0) - virtual * self.config.virtual_loss_value
) / n_eff
score = q_eff + self.config.c_puct * prior * sqrt_total / (1 + n_eff)
if score > best_score: if score > best_score:
best_score = score best_score = score
best_action = action best_action = action
return int(best_action) return int(best_action)
def _backup(
self,
path: list[SearchPathEntry],
leaf_value: float,
leaf_player: int,
) -> None:
value = float(leaf_value)
value_player = int(leaf_player)
for entry in reversed(path):
parent_value = value if value_player == entry.parent_player else -value
current_virtual = entry.node.virtual_visits.get(entry.action, 0)
entry.node.virtual_visits[entry.action] = max(0, current_virtual - 1)
entry.node.visits[entry.action] = entry.node.visits.get(entry.action, 0) + 1
entry.node.value_sum[entry.action] = (
entry.node.value_sum.get(entry.action, 0.0) + parent_value
)
value = parent_value
value_player = entry.parent_player
def _release_virtual_path(self, path: list[SearchPathEntry]) -> None:
for entry in path:
current_virtual = entry.node.virtual_visits.get(entry.action, 0)
entry.node.virtual_visits[entry.action] = max(0, current_virtual - 1)
def _rollout_value(self, state: GameState, player: int) -> float | None: def _rollout_value(self, state: GameState, player: int) -> float | None:
rollout = state.clone() rollout = state.clone()
steps = 0 steps = 0
@@ -148,7 +311,13 @@ class IsMctsSearcher:
legal = rollout.unified_legal_actions() legal = rollout.unified_legal_actions()
if not legal: if not legal:
break break
action = self.rng.choice(legal) if self._rollout_bot is not None:
phase_action = self._rollout_bot.act(rollout)
action = rollout.to_unified_action(phase_action)
if action not in legal:
action = self.rng.choice(legal)
else:
action = self.rng.choice(legal)
rollout.apply_unified_action(action) rollout.apply_unified_action(action)
steps += 1 steps += 1
return float(rollout.score_diff(player)) return float(rollout.score_diff(player))
@@ -0,0 +1,621 @@
# cython: language_level=3, boundscheck=False, wraparound=False, cdivision=True, initializedcheck=False
from __future__ import annotations
import math
import random
import numpy as np
import torch
from coolrl_lost_cities.games.classic.bots.heuristic_cy cimport HeuristicBot
from coolrl_lost_cities.games.classic.bots.heuristic import HeuristicBot as PyHeuristicBot
from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state
from coolrl_lost_cities.games.classic.game cimport GameState
from .determinization import sample_determinization
from .info_set import canonical_info_set_key
DEF MAX_ACTIONS = 64
DEF DEFAULT_ACTION_SIZE = 64
cdef class _ArrayMap:
cdef MctsNode node
cdef int kind
cdef int action_size
cdef bint is_int
def __init__(self, MctsNode node, int kind, bint is_int=False):
self.node = node
self.kind = kind
self.action_size = node.action_size
self.is_int = is_int
cdef inline void _check(self, int action) except *:
if action < 0 or action >= self.action_size:
raise KeyError(action)
cdef inline bint has(self, int action) noexcept:
return 0 <= action < self.action_size and self.node.active_present[action] != 0
cdef inline long get_int(self, int action, long default_value=0) noexcept:
if 0 <= action < self.action_size and self.node.active_present[action] != 0:
if self.kind == 1:
return self.node.visits_arr[action]
if self.kind == 3:
return self.node.virtual_visits_arr[action]
return default_value
cdef inline double get_float(self, int action, double default_value=0.0) noexcept:
if 0 <= action < self.action_size and self.node.active_present[action] != 0:
if self.kind == 0:
return self.node.priors_arr[action]
if self.kind == 2:
return self.node.value_sum_arr[action]
return default_value
cdef inline void _mark_active(self, int action) noexcept:
if self.node.active_present[action] == 0:
self.node.active_present[action] = 1
self.node.active_actions[self.node.n_active] = action
self.node.n_active += 1
cdef inline void set_int(self, int action, long value) except *:
self._check(action)
self._mark_active(action)
if self.kind == 1:
self.node.visits_arr[action] = <int>value
elif self.kind == 3:
self.node.virtual_visits_arr[action] = <int>value
else:
raise TypeError("integer write to float node map")
cdef inline void set_float(self, int action, double value) except *:
self._check(action)
self._mark_active(action)
if self.kind == 0:
self.node.priors_arr[action] = value
elif self.kind == 2:
self.node.value_sum_arr[action] = value
else:
raise TypeError("float write to integer node map")
def get(self, action, default=None):
cdef int a = int(action)
if self.has(a):
if self.is_int:
return int(self.get_int(a, 0))
return float(self.get_float(a, 0.0))
return default
def setdefault(self, action, default=None):
cdef int a = int(action)
if self.has(a):
if self.is_int:
return int(self.get_int(a, 0))
return float(self.get_float(a, 0.0))
if default is None:
default = 0 if self.is_int else 0.0
if self.is_int:
self.set_int(a, int(default))
return int(default)
self.set_float(a, float(default))
return float(default)
def __getitem__(self, action):
cdef int a = int(action)
self._check(a)
if self.node.active_present[a] == 0:
raise KeyError(action)
if self.is_int:
return int(self.get_int(a, 0))
return float(self.get_float(a, 0.0))
def __setitem__(self, action, value):
cdef int a = int(action)
if self.is_int:
self.set_int(a, int(value))
else:
self.set_float(a, float(value))
def __contains__(self, action):
return self.has(int(action))
def __bool__(self):
return self.node.n_active > 0
def __len__(self):
return self.node.n_active
def items(self):
cdef int i
result = []
cdef int action
for i in range(self.node.n_active):
action = self.node.active_actions[i]
if self.is_int:
result.append((action, int(self.get_int(action, 0))))
else:
result.append((action, float(self.get_float(action, 0.0))))
return result
def keys(self):
cdef int i
return [self.node.active_actions[i] for i in range(self.node.n_active)]
def values(self):
cdef int i
result = []
cdef int action
for i in range(self.node.n_active):
action = self.node.active_actions[i]
if self.is_int:
result.append(int(self.get_int(action, 0)))
else:
result.append(float(self.get_float(action, 0.0)))
return result
def __repr__(self):
return repr(dict(self.items()))
cdef class MctsNode:
cdef public bytes info_set_key
cdef public int player
cdef public object priors
cdef public object visits
cdef public object value_sum
cdef public object virtual_visits
cdef public dict children
cdef public bint terminal
cdef public bint expanded
cdef int action_size
cdef int visits_arr[MAX_ACTIONS]
cdef double value_sum_arr[MAX_ACTIONS]
cdef double priors_arr[MAX_ACTIONS]
cdef int virtual_visits_arr[MAX_ACTIONS]
cdef int active_actions[MAX_ACTIONS]
cdef unsigned char active_present[MAX_ACTIONS]
cdef int n_active
def __init__(
self,
bytes info_set_key,
int player,
object priors=None,
object visits=None,
object value_sum=None,
object virtual_visits=None,
object children=None,
bint terminal=False,
int action_size=DEFAULT_ACTION_SIZE,
):
if action_size > MAX_ACTIONS:
raise ValueError("action_size exceeds fixed MCTS action buffer")
self.info_set_key = info_set_key
self.player = player
self.terminal = terminal
self.expanded = terminal
self.action_size = action_size
self.n_active = 0
self.priors = _ArrayMap(self, 0, False)
self.visits = _ArrayMap(self, 1, True)
self.value_sum = _ArrayMap(self, 2, False)
self.virtual_visits = _ArrayMap(self, 3, True)
self.children = {} if children is None else dict(children)
if priors is not None:
for action, value in dict(priors).items():
self.priors[action] = value
if visits is not None:
for action, value in dict(visits).items():
self.visits[action] = value
if value_sum is not None:
for action, value in dict(value_sum).items():
self.value_sum[action] = value
if virtual_visits is not None:
for action, value in dict(virtual_visits).items():
self.virtual_visits[action] = value
cpdef bint is_expanded(self):
return self.terminal or self.expanded or self.n_active > 0
cpdef double q(self, int action):
cdef long n = (<_ArrayMap>self.visits).get_int(action, 0)
if n <= 0:
return 0.0
return (<_ArrayMap>self.value_sum).get_float(action, 0.0) / n
cdef class SearchPathEntry:
cdef public MctsNode node
cdef public int action
cdef public int parent_player
cdef public int child_player
def __init__(self, MctsNode node, int action, int parent_player, int child_player):
self.node = node
self.action = action
self.parent_player = parent_player
self.child_player = child_player
cdef class PendingSimulation:
cdef public list path
cdef public GameState leaf_state
cdef public object leaf_node
cdef public int leaf_player
cdef public object info_state
cdef public object legal_mask
cdef public list legal_actions
cdef public object terminal_value
def __init__(
self,
list path,
GameState leaf_state,
object leaf_node,
int leaf_player,
object info_state,
object legal_mask,
list legal_actions,
object terminal_value=None,
):
self.path = path
self.leaf_state = leaf_state
self.leaf_node = leaf_node
self.leaf_player = leaf_player
self.info_state = info_state
self.legal_mask = legal_mask
self.legal_actions = legal_actions
self.terminal_value = terminal_value
cdef class MctsTree:
cdef public dict nodes
cdef int action_size
def __init__(self, int action_size=DEFAULT_ACTION_SIZE):
self.nodes = {}
self.action_size = action_size
def get_or_create(self, bytes key, *, int player, bint terminal=False):
cdef MctsNode node = self.nodes.get(key)
if node is None:
node = MctsNode(key, player=player, terminal=terminal, action_size=self.action_size)
self.nodes[key] = node
return node
cdef class IsMctsSearcher:
cdef public object network
cdef public object config
cdef public object device
cdef public object encoding
cdef public object rng
cdef public MctsTree tree
cdef HeuristicBot _rollout_bot
cdef int action_size
def __init__(
self,
object network,
object config,
*,
object device="cpu",
object encoding=None,
object rng=None,
):
self.network = network
self.config = config
self.device = torch.device(device)
self.encoding = encoding
self.rng = rng or random.Random()
self.action_size = int(getattr(network, "action_size", DEFAULT_ACTION_SIZE))
if self.action_size > MAX_ACTIONS:
raise ValueError("action_size exceeds fixed MCTS action buffer")
self.tree = MctsTree(self.action_size)
self._rollout_bot = (
<HeuristicBot>PyHeuristicBot() if config.rollout_policy == "heuristic_balanced" else None
)
cdef inline int _from_unified_action_c(self, GameState state, int action_id) noexcept:
cdef int card_action_size = 2 * state.hand_size
if state.phase_id == 0:
return action_id
return action_id - card_action_size
cdef list _unified_legal_actions_list_c(self, GameState state):
cdef int actions[MAX_ACTIONS]
cdef int count = state._unified_legal_actions_c(actions)
cdef int i
return [actions[i] for i in range(count)]
cpdef dict search(self, GameState state, int traverser, object n_sims=None):
cdef bytes root_key = canonical_info_set_key(state, state.current_player)
cdef MctsNode root = self.tree.get_or_create(
root_key, player=state.current_player, terminal=state.terminal
)
cdef int sims = int(n_sims or self.config.n_simulations)
cdef int completed = 0
cdef list pending
cdef list legal
cdef int action
cdef dict result
while completed < sims:
pending = self.prepare_simulation_batch(state, traverser, 1)
if not pending:
break
self.evaluate_and_backup(pending)
completed += len(pending)
legal = state.unified_legal_actions()
result = {}
for action in legal:
result[action] = (<_ArrayMap>root.visits).get_int(action, 0)
return result
cpdef list prepare_simulation_batch(
self,
GameState root_state,
int traverser,
int max_simulations,
):
cdef list pending = []
cdef PendingSimulation item
cdef int i
for i in range(max_simulations):
item = self.prepare_simulation(root_state, traverser)
pending.append(item)
if item.terminal_value is None and item.leaf_node is not None and not item.path:
break
return pending
cpdef PendingSimulation prepare_simulation(self, GameState root_state, int traverser):
cdef GameState state = sample_determinization(root_state, traverser, self.rng)
cdef list path = []
cdef int depth = 0
cdef object cached_key = None
cdef int player
cdef bytes key
cdef MctsNode node
cdef list legal_actions
cdef int action
cdef int local_action
cdef int child_player
cdef bytes child_key
cdef int actions[MAX_ACTIONS]
cdef int action_count
cdef int i
while True:
player = state.current_player
if state.terminal or depth >= int(self.config.max_depth):
return PendingSimulation(
path=path,
leaf_state=state,
leaf_node=None,
leaf_player=player,
info_state=None,
legal_mask=None,
legal_actions=[],
terminal_value=float(state.total_scores[player] - state.total_scores[1 - player]),
)
if cached_key is None:
key = canonical_info_set_key(state, player)
else:
key = cached_key
node = self.tree.get_or_create(key, player=player, terminal=state.terminal)
if not node.is_expanded():
action_count = state._unified_legal_actions_c(actions)
legal_actions = [actions[i] for i in range(action_count)]
if not legal_actions:
node.terminal = True
return PendingSimulation(
path=path,
leaf_state=state,
leaf_node=node,
leaf_player=player,
info_state=None,
legal_mask=None,
legal_actions=[],
terminal_value=float(state.total_scores[player] - state.total_scores[1 - player]),
)
return PendingSimulation(
path=path,
leaf_state=state,
leaf_node=node,
leaf_player=player,
info_state=encode_info_state(state, player, self.encoding),
legal_mask=np.asarray(state.unified_legal_mask(), dtype=bool),
legal_actions=legal_actions,
)
action_count = state._unified_legal_actions_c(actions)
legal_actions = [actions[i] for i in range(action_count)]
action = self._select_action(node, legal_actions)
(<_ArrayMap>node.virtual_visits).set_int(
action, (<_ArrayMap>node.virtual_visits).get_int(action, 0) + 1
)
local_action = self._from_unified_action_c(state, action)
state._push_action_c(local_action)
child_player = state.current_player
child_key = canonical_info_set_key(state, child_player)
node.children[action] = child_key
path.append(
SearchPathEntry(
node=node,
action=action,
parent_player=player,
child_player=child_player,
)
)
cached_key = child_key
depth += 1
cpdef evaluate_and_backup(self, list pending):
cdef list network_pending = [item for item in pending if item.terminal_value is None]
cdef dict values_by_id = {}
cdef dict priors_by_id = {}
cdef object infos
cdef object masks
cdef object x
cdef object mask
cdef object probs
cdef object network_values
cdef object network_values_np
cdef int index
cdef PendingSimulation item
cdef double value
if network_pending:
infos = np.stack([item.info_state for item in network_pending if item.info_state is not None])
masks = np.stack([item.legal_mask for item in network_pending if item.legal_mask is not None])
with torch.inference_mode():
x = torch.as_tensor(infos, dtype=torch.float32, device=self.device)
mask = torch.as_tensor(masks, dtype=torch.bool, device=self.device)
probs = self.network.policy_distribution(x, mask).detach().cpu().numpy()
_logits, network_values = self.network(x, mask)
network_values_np = network_values.detach().cpu().numpy()
for index, item in enumerate(network_pending):
priors_by_id[id(item)] = probs[index]
values_by_id[id(item)] = float(network_values_np[index])
for item in pending:
if item.terminal_value is not None:
value = item.terminal_value
else:
value = self._expand_with_prior(
item.leaf_node,
item.leaf_state,
item.leaf_player,
item.legal_actions,
priors_by_id[id(item)],
values_by_id[id(item)],
)
self._backup(item.path, value, item.leaf_player)
cpdef double _expand_with_prior(
self,
MctsNode node,
GameState state,
int player,
list legal_actions,
object probs,
double network_value,
):
cdef int action
cdef object rollout_value
legal_actions = self._unified_legal_actions_list_c(state)
if not legal_actions:
node.terminal = True
return float(state.total_scores[player] - state.total_scores[1 - player])
node.expanded = True
for action in legal_actions:
(<_ArrayMap>node.priors).set_float(action, float(probs[action]))
if not (<_ArrayMap>node.visits).has(action):
(<_ArrayMap>node.visits).set_int(action, 0)
if not (<_ArrayMap>node.value_sum).has(action):
(<_ArrayMap>node.value_sum).set_float(action, 0.0)
if not (<_ArrayMap>node.virtual_visits).has(action):
(<_ArrayMap>node.virtual_visits).set_int(action, 0)
rollout_value = self._rollout_value(state, player) if self.config.use_rollout_value else None
if rollout_value is None:
return float(network_value)
return float(rollout_value)
cpdef int _select_action(self, MctsNode node, list legal_actions):
cdef int total_visits = 0
cdef int action
cdef long n
cdef long virtual
cdef long n_eff
cdef double sqrt_total
cdef double prior
cdef double q_eff
cdef double score
cdef double best_score = -float("inf")
cdef int best_action = int(legal_actions[0])
cdef _ArrayMap visits = <_ArrayMap>node.visits
cdef _ArrayMap virtual_visits = <_ArrayMap>node.virtual_visits
cdef _ArrayMap priors = <_ArrayMap>node.priors
cdef _ArrayMap value_sum = <_ArrayMap>node.value_sum
for action in legal_actions:
total_visits += visits.get_int(action, 0) + virtual_visits.get_int(action, 0)
sqrt_total = math.sqrt(max(1, total_visits))
for action in legal_actions:
n = visits.get_int(action, 0)
virtual = virtual_visits.get_int(action, 0)
n_eff = n + virtual
prior = priors.get_float(action, 0.0)
if n_eff <= 0:
q_eff = 0.0
else:
q_eff = (
value_sum.get_float(action, 0.0)
- virtual * float(self.config.virtual_loss_value)
) / n_eff
score = q_eff + float(self.config.c_puct) * prior * sqrt_total / (1 + n_eff)
if score > best_score:
best_score = score
best_action = action
return int(best_action)
cpdef _backup(self, list path, double leaf_value, int leaf_player):
cdef double value = float(leaf_value)
cdef int value_player = int(leaf_player)
cdef SearchPathEntry entry
cdef double parent_value
cdef long current_virtual
cdef _ArrayMap visits
cdef _ArrayMap virtual_visits
cdef _ArrayMap value_sum
for entry in reversed(path):
parent_value = value if value_player == entry.parent_player else -value
virtual_visits = <_ArrayMap>entry.node.virtual_visits
visits = <_ArrayMap>entry.node.visits
value_sum = <_ArrayMap>entry.node.value_sum
current_virtual = virtual_visits.get_int(entry.action, 0)
virtual_visits.set_int(entry.action, max(0, current_virtual - 1))
visits.set_int(entry.action, visits.get_int(entry.action, 0) + 1)
value_sum.set_float(
entry.action,
value_sum.get_float(entry.action, 0.0) + parent_value,
)
value = parent_value
value_player = entry.parent_player
cpdef _release_virtual_path(self, list path):
cdef SearchPathEntry entry
cdef _ArrayMap virtual_visits
cdef long current_virtual
for entry in path:
virtual_visits = <_ArrayMap>entry.node.virtual_visits
current_virtual = virtual_visits.get_int(entry.action, 0)
virtual_visits.set_int(entry.action, max(0, current_virtual - 1))
cpdef object _rollout_value(self, GameState state, int player):
cdef int steps = 0
cdef int actions[MAX_ACTIONS]
cdef int count
cdef int unified_action
cdef int action
cdef int max_depth = int(self.config.max_depth)
while not state.terminal and steps < max_depth:
if self._rollout_bot is not None:
action = self._rollout_bot.act_cython(state)
if not state._is_legal_action_c(action):
count = state._unified_legal_actions_c(actions)
if count <= 0:
break
unified_action = actions[self.rng.randrange(count)]
action = self._from_unified_action_c(state, unified_action)
else:
count = state._unified_legal_actions_c(actions)
if count <= 0:
break
unified_action = actions[self.rng.randrange(count)]
action = self._from_unified_action_c(state, unified_action)
state._push_action_c(action)
steps += 1
while steps > 0:
state._pop_action_c()
steps -= 1
return float(state.total_scores[player] - state.total_scores[1 - player])
@@ -15,6 +15,7 @@ class ReplaySample:
v_target: float v_target: float
player: int player: int
prior: np.ndarray | None = None prior: np.ndarray | None = None
game_index: int | None = None
class ReplayBuffer: class ReplayBuffer:
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import json import json
import multiprocessing as mp
import random import random
import time import time
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -15,9 +17,11 @@ from coolrl_lost_cities.games.classic.deep_cfr.evaluate import evaluate_strategy
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig
from .config import IsMctsConfig from .config import IsMctsConfig
from .evaluate import evaluate_with_mcts
from .interleaved_self_play import play_self_play_iteration
from .network import AlphaZeroLogitsView, AlphaZeroNet from .network import AlphaZeroLogitsView, AlphaZeroNet
from .replay_buffer import ReplayBuffer, ReplaySample from .replay_buffer import ReplayBuffer, ReplaySample
from .self_play import play_self_play_game from .workers import SelfPlayWorkerBatch, run_self_play_worker
@dataclass @dataclass
@@ -112,14 +116,19 @@ class IsMctsTrainer:
return metrics return metrics
def run_iteration(self, iteration: int) -> IterationMetrics: def run_iteration(self, iteration: int) -> IterationMetrics:
print(
f"[iter {iteration}] self-play start (workers={self.config.training.num_workers})",
flush=True,
)
self.network.eval() self.network.eval()
sp_started = time.perf_counter() sp_started = time.perf_counter()
added = 0 if self.config.training.num_workers > 1:
iteration_samples: list[ReplaySample] = [] iteration_samples = self._run_self_play_parallel(iteration)
for _ in range(self.config.training.games_per_iter): else:
samples = play_self_play_game( iteration_samples = play_self_play_iteration(
self.network, self.network,
self.config.mcts, self.config.mcts,
self.config.training,
self.game_config, self.game_config,
self.rng, self.rng,
device=self.device, device=self.device,
@@ -127,10 +136,13 @@ class IsMctsTrainer:
temperature=self.config.temperature.training, temperature=self.config.temperature.training,
max_steps=self.config.evaluation.max_steps, max_steps=self.config.evaluation.max_steps,
) )
self.buffer.add(samples) self.buffer.add(iteration_samples)
iteration_samples.extend(samples) added = len(iteration_samples)
added += len(samples)
self_play_seconds = time.perf_counter() - sp_started self_play_seconds = time.perf_counter() - sp_started
print(
f"[iter {iteration}] self-play done in {self_play_seconds:.1f}s, {added} samples",
flush=True,
)
mcts_metrics = self._compute_mcts_metrics(iteration_samples) mcts_metrics = self._compute_mcts_metrics(iteration_samples)
train_started = time.perf_counter() train_started = time.perf_counter()
@@ -140,7 +152,12 @@ class IsMctsTrainer:
losses.append(self._train_batch(batch)) losses.append(self._train_batch(batch))
train_seconds = time.perf_counter() - train_started train_seconds = time.perf_counter() - train_started
loss_arr = np.asarray(losses, dtype=np.float64) loss_arr = np.asarray(losses, dtype=np.float64)
print(f"[iter {iteration}] train done in {train_seconds:.1f}s, eval starting", flush=True)
eval_started = time.perf_counter()
eval_metrics = self._evaluate(iteration) eval_metrics = self._evaluate(iteration)
print(
f"[iter {iteration}] eval done in {time.perf_counter() - eval_started:.1f}s", flush=True
)
return IterationMetrics( return IterationMetrics(
iteration=iteration, iteration=iteration,
samples_added=added, samples_added=added,
@@ -154,6 +171,69 @@ class IsMctsTrainer:
mcts_metrics=mcts_metrics, mcts_metrics=mcts_metrics,
) )
def _run_self_play_parallel(self, iteration: int) -> list[ReplaySample]:
training_cfg = self.config.training
num_workers = max(1, int(training_cfg.num_workers))
total_games = int(training_cfg.games_per_iter)
if total_games <= 0:
return []
# Split games across workers as evenly as possible.
effective_workers = min(num_workers, total_games)
base = total_games // effective_workers
remainder = total_games % effective_workers
per_worker = [base + (1 if i < remainder else 0) for i in range(effective_workers)]
# Move network state dict to CPU for cross-process transfer.
cpu_state = {
name: tensor.detach().cpu() for name, tensor in self.network.state_dict().items()
}
config_dict = self.config.to_dict()
game_snapshot = self.game_config.to_snapshot()
max_steps = self.config.evaluation.max_steps
temperature = self.config.temperature.training
worker_device = str(self.config.training.worker_device)
batches: list[SelfPlayWorkerBatch] = []
for worker_index in range(effective_workers):
seed = self.rng.randrange(2**31)
batches.append(
SelfPlayWorkerBatch(
worker_index=worker_index,
games_for_worker=per_worker[worker_index],
base_seed=seed,
config=config_dict,
game_config=game_snapshot,
network_state=cpu_state,
temperature=temperature,
max_steps=max_steps,
device=worker_device,
)
)
samples: list[ReplaySample] = []
ctx = mp.get_context("spawn")
print(
f" spawning {effective_workers} workers for {total_games} games "
f"(per_worker={per_worker})...",
flush=True,
)
spawn_started = time.perf_counter()
with ProcessPoolExecutor(max_workers=effective_workers, mp_context=ctx) as executor:
futures = [executor.submit(run_self_play_worker, batch) for batch in batches]
print(
f" workers submitted in {time.perf_counter() - spawn_started:.1f}s, waiting for results...",
flush=True,
)
results = []
for future in futures:
res = future.result()
results.append(res)
print(
f" worker {res.worker_index} done ({len(res.samples)} samples, "
f"elapsed {time.perf_counter() - spawn_started:.1f}s)",
flush=True,
)
for result in sorted(results, key=lambda item: item.worker_index):
samples.extend(result.samples)
return samples
def _train_batch(self, batch: list[ReplaySample]) -> tuple[float, float, float]: def _train_batch(self, batch: list[ReplaySample]) -> tuple[float, float, float]:
self.network.train() self.network.train()
info = torch.as_tensor( info = torch.as_tensor(
@@ -179,7 +259,8 @@ class IsMctsTrainer:
logits, value_pred = self.network(info, legal) logits, value_pred = self.network(info, legal)
log_probs = torch.log_softmax(logits, dim=-1) log_probs = torch.log_softmax(logits, dim=-1)
policy_loss = -(pi * log_probs).sum(dim=-1).mean() policy_loss = -(pi * log_probs).sum(dim=-1).mean()
value_loss = nn.functional.mse_loss(value_pred, value_target) v_scale = float(self.network.value_scale)
value_loss = nn.functional.mse_loss(value_pred / v_scale, value_target / v_scale)
loss = policy_loss + value_loss loss = policy_loss + value_loss
self.optimizer.zero_grad(set_to_none=True) self.optimizer.zero_grad(set_to_none=True)
loss.backward() loss.backward()
@@ -204,22 +285,52 @@ class IsMctsTrainer:
return {} return {}
self.network.eval() self.network.eval()
results: dict[str, float | int] = {} results: dict[str, float | int] = {}
logits_view = AlphaZeroLogitsView(self.network)
for opponent in opponents: for opponent in opponents:
result = evaluate_strategy_network( print(f" eval vs {opponent}...", flush=True)
logits_view, opp_started = time.perf_counter()
self.game_config, if self.config.mcts.eval_with_mcts:
games=self.config.evaluation.games, eval_mcts_cfg = self.config.mcts.model_copy()
seed=self.config.run.seed + iteration * 1000, if self.config.mcts.eval_n_simulations > 0:
opponent=opponent, eval_mcts_cfg = eval_mcts_cfg.model_copy(
device=self.device, update={"n_simulations": self.config.mcts.eval_n_simulations}
encoding=self.config.encoding, )
max_steps=self.config.evaluation.max_steps, result = evaluate_with_mcts(
batch_size=self.config.evaluation.batch_size, self.network,
) self.game_config,
eval_mcts_cfg,
games=self.config.evaluation.games,
seed=self.config.run.seed + iteration * 1000,
opponent=opponent,
device=self.device,
encoding=self.config.encoding,
max_steps=self.config.evaluation.max_steps,
config=self.config,
num_workers=self.config.training.num_workers,
)
else:
logits_view = AlphaZeroLogitsView(self.network)
result = evaluate_strategy_network(
logits_view,
self.game_config,
games=self.config.evaluation.games,
seed=self.config.run.seed + iteration * 1000,
opponent=opponent,
device=self.device,
encoding=self.config.encoding,
max_steps=self.config.evaluation.max_steps,
batch_size=self.config.evaluation.batch_size,
)
key = opponent.replace("-", "_") key = opponent.replace("-", "_")
for metric_key, value in result.items(): for metric_key, value in result.items():
results[f"eval/{key}/{metric_key}"] = value results[f"eval/{key}/{metric_key}"] = value
par = result.get("play_action_rate", 0.0)
sd = result.get("avg_score_diff0", 0.0)
wr = result.get("win_rate0", 0.0)
print(
f" eval vs {opponent} done in {time.perf_counter() - opp_started:.1f}s "
f"PA={par:.2f} W={wr:.2f} S={sd:.1f}",
flush=True,
)
return results return results
def _compute_mcts_metrics(self, samples: list[ReplaySample]) -> dict[str, float]: def _compute_mcts_metrics(self, samples: list[ReplaySample]) -> dict[str, float]:
@@ -0,0 +1,104 @@
"""Multi-process self-play workers for ISMCTS.
Mirrors the Deep CFR pattern: a ``ProcessPoolExecutor`` (spawn context)
runs N worker processes, each receiving the current network state dict and
a slice of the iteration's self-play games. Workers run network inference
on CPU by default (small policy/value MLP, GPU contention is the bottleneck
when sharing a single device across many workers).
"""
from __future__ import annotations
import os
import random
from dataclasses import dataclass
from typing import Any
import torch
from coolrl_lost_cities.games.classic.deep_cfr.encoding import input_dim
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig
from .config import IsMctsConfig, config_from_dict
from .interleaved_self_play import play_self_play_iteration
from .network import AlphaZeroNet
from .replay_buffer import ReplaySample
_TORCH_THREADS_CONFIGURED = False
def _configure_worker_torch_threads() -> None:
global _TORCH_THREADS_CONFIGURED
if _TORCH_THREADS_CONFIGURED:
return
os.environ.setdefault("OMP_NUM_THREADS", "1")
os.environ.setdefault("MKL_NUM_THREADS", "1")
torch.set_num_threads(1)
if hasattr(torch, "set_num_interop_threads"):
try:
torch.set_num_interop_threads(1)
except RuntimeError:
pass
_TORCH_THREADS_CONFIGURED = True
@dataclass(frozen=True)
class SelfPlayWorkerBatch:
worker_index: int
games_for_worker: int
base_seed: int
config: dict[str, Any]
game_config: dict[str, Any]
network_state: dict[str, Any]
temperature: float
max_steps: int
device: str
@dataclass
class SelfPlayWorkerResult:
worker_index: int
samples: list[ReplaySample]
def run_self_play_worker(batch: SelfPlayWorkerBatch) -> SelfPlayWorkerResult:
import time as _time
_t0 = _time.perf_counter()
print(f" [worker {batch.worker_index}] starting ({batch.games_for_worker} games)", flush=True)
_configure_worker_torch_threads()
cfg: IsMctsConfig = config_from_dict(batch.config)
game_config = LostCitiesConfig(**batch.game_config)
device = torch.device(batch.device)
probe = GameState.new_game(game_config, seed=batch.base_seed)
in_dim = input_dim(probe, cfg.encoding)
action_size = probe.action_size
network = AlphaZeroNet.from_config(in_dim, action_size, cfg).to(device)
network.load_state_dict(batch.network_state)
network.eval()
print(
f" [worker {batch.worker_index}] init done in {_time.perf_counter() - _t0:.1f}s, self-play start",
flush=True,
)
# Build a per-worker TrainingConfig with the worker's game count.
worker_training = cfg.training.model_copy(
update={"games_per_iter": int(batch.games_for_worker)}
)
rng = random.Random(batch.base_seed)
_sp_t0 = _time.perf_counter()
samples = play_self_play_iteration(
network,
cfg.mcts,
worker_training,
game_config,
rng,
device=device,
encoding=cfg.encoding,
temperature=batch.temperature,
max_steps=batch.max_steps,
)
print(
f" [worker {batch.worker_index}] self-play done in {_time.perf_counter() - _sp_t0:.1f}s ({len(samples)} samples)",
flush=True,
)
return SelfPlayWorkerResult(worker_index=batch.worker_index, samples=samples)
+233 -4
View File
@@ -1,22 +1,55 @@
from __future__ import annotations from __future__ import annotations
import importlib.util
import random import random
import sys
from pathlib import Path
import numpy as np import numpy as np
import torch import torch
from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state, input_dim from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state, input_dim
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig
from coolrl_lost_cities.games.classic.bots.heuristic import HeuristicBot
from coolrl_lost_cities.games.classic.bots.heuristic_py import (
HeuristicBot as PythonHeuristicBot,
)
from coolrl_lost_cities.games.classic.ismcts.config import IsMctsConfig, MctsConfig from coolrl_lost_cities.games.classic.ismcts.config import IsMctsConfig, MctsConfig
from coolrl_lost_cities.games.classic.ismcts.determinization import sample_determinization from coolrl_lost_cities.games.classic.ismcts.determinization import sample_determinization
from coolrl_lost_cities.games.classic.ismcts.info_set import canonical_info_set_key from coolrl_lost_cities.games.classic.ismcts.info_set import canonical_info_set_key
from coolrl_lost_cities.games.classic.ismcts.mcts import IsMctsSearcher from coolrl_lost_cities.games.classic.ismcts.interleaved_self_play import (
play_self_play_iteration,
)
from coolrl_lost_cities.games.classic.ismcts.mcts import IsMctsSearcher, MctsNode
from coolrl_lost_cities.games.classic.ismcts.network import AlphaZeroLogitsView, AlphaZeroNet from coolrl_lost_cities.games.classic.ismcts.network import AlphaZeroLogitsView, AlphaZeroNet
from coolrl_lost_cities.games.classic.ismcts.replay_buffer import ReplayBuffer, ReplaySample from coolrl_lost_cities.games.classic.ismcts.replay_buffer import ReplayBuffer, ReplaySample
from coolrl_lost_cities.games.classic.ismcts.self_play import play_self_play_game from coolrl_lost_cities.games.classic.ismcts.self_play import play_self_play_game
from coolrl_lost_cities.games.classic.ismcts.trainer import IsMctsTrainer from coolrl_lost_cities.games.classic.ismcts.trainer import IsMctsTrainer
def _python_mcts_searcher():
module_name = "coolrl_lost_cities.games.classic.ismcts._mcts_python_baseline"
existing = sys.modules.get(module_name)
if existing is not None:
return existing.IsMctsSearcher
path = (
Path(__file__).parents[4]
/ "src"
/ "coolrl_lost_cities"
/ "games"
/ "classic"
/ "ismcts"
/ "mcts.py"
)
spec = importlib.util.spec_from_file_location(module_name, path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module.IsMctsSearcher
def mini_config(seed: int = 1) -> LostCitiesConfig: def mini_config(seed: int = 1) -> LostCitiesConfig:
return LostCitiesConfig( return LostCitiesConfig(
n_colors=3, n_colors=3,
@@ -97,6 +130,143 @@ def test_mcts_prior_drives_visits() -> None:
assert visits[favored] == max(visits.values()) assert visits[favored] == max(visits.values())
def test_search_correctness_vs_sequential() -> None:
for n_sims in (8, 16, 64):
state = GameState.new_game(mini_config(), seed=17)
dim = input_dim(state)
net = AlphaZeroNet(dim, state.action_size, hidden_size=8, num_layers=1)
config = MctsConfig(
n_simulations=n_sims,
parallel_simulations=1,
use_rollout_value=False,
)
left = IsMctsSearcher(net, config, rng=random.Random(18))
right = IsMctsSearcher(net, config, rng=random.Random(18))
assert left.search(state, state.current_player) == right.search(state, state.current_player)
def test_cython_sequential_matches_python_sequential_visit_counts() -> None:
PythonIsMctsSearcher = _python_mcts_searcher()
for n_sims in (8, 32, 128):
state = GameState.new_game(mini_config(), seed=23)
dim = input_dim(state)
torch.manual_seed(24)
net = AlphaZeroNet(dim, state.action_size, hidden_size=8, num_layers=1)
config = MctsConfig(
n_simulations=n_sims,
parallel_simulations=1,
use_rollout_value=False,
)
python_searcher = PythonIsMctsSearcher(net, config, rng=random.Random(25))
cython_searcher = IsMctsSearcher(net, config, rng=random.Random(25))
assert cython_searcher.search(state, state.current_player) == python_searcher.search(
state, state.current_player
)
def test_search_visit_counts_match_with_parallel_simulations() -> None:
for n_sims in (8, 32, 128):
state = GameState.new_game(mini_config(), seed=26)
dim = input_dim(state)
torch.manual_seed(27)
net = AlphaZeroNet(dim, state.action_size, hidden_size=8, num_layers=1)
sequential = IsMctsSearcher(
net,
MctsConfig(n_simulations=n_sims, parallel_simulations=1, use_rollout_value=False),
rng=random.Random(28),
)
batched = IsMctsSearcher(
net,
MctsConfig(n_simulations=n_sims, parallel_simulations=8, use_rollout_value=False),
rng=random.Random(28),
)
assert batched.search(state, state.current_player) == sequential.search(
state, state.current_player
)
def test_search_with_virtual_loss_diversity() -> None:
state = GameState.new_game(mini_config(), seed=19)
dim = input_dim(state)
net = AlphaZeroNet(dim, state.action_size, hidden_size=8, num_layers=0)
for param in net.parameters():
param.data.zero_()
searcher = IsMctsSearcher(
net,
MctsConfig(n_simulations=64, parallel_simulations=4, virtual_loss_value=1.0),
rng=random.Random(20),
)
first = searcher.prepare_simulation_batch(state, state.current_player, 1)
searcher.evaluate_and_backup(first)
pending = searcher.prepare_simulation_batch(state, state.current_player, 4)
first_actions = [item.path[0].action for item in pending if item.path]
assert len(set(first_actions)) >= 2
def test_heuristic_cython_fast_path_matches_python_for_random_states() -> None:
configs = [mini_config(seed=31), LostCitiesConfig(seed=32)]
py_bot = PythonHeuristicBot()
cy_bot = HeuristicBot()
for config in configs:
rng = random.Random(33)
checked = 0
attempts = 0
while checked < 100 and attempts < 1000:
attempts += 1
state = GameState.new_game(config, seed=rng.randrange(2**31))
for _ in range(rng.randrange(40)):
if state.terminal:
break
legal = state.unified_legal_actions()
if not legal:
break
state.apply_unified_action(rng.choice(legal))
if state.terminal or not state.unified_legal_actions():
continue
assert cy_bot.act_cython(state) == py_bot.act(state)
checked += 1
assert checked == 100
def test_game_state_push_pop_unified_round_trip_snapshot() -> None:
rng = random.Random(34)
for config in (mini_config(seed=35), LostCitiesConfig(seed=36)):
state = GameState.new_game(config, seed=37)
for _ in range(100):
if state.terminal:
break
before = state.to_snapshot()
unified = rng.choice(state.unified_legal_actions())
local = state.from_unified_action(unified)
state.push_action(local)
state.pop_action()
assert state.to_snapshot() == before
state.apply_unified_action(unified)
def test_mcts_node_c_array_maps_are_dict_like() -> None:
node = MctsNode(b"root", player=0, action_size=16)
node.priors[3] = 0.25
node.visits.setdefault(3, 0)
node.value_sum[3] = 1.5
node.virtual_visits[3] = 2
node.visits[3] = node.visits.get(3, 0) + 4
assert bool(node.priors)
assert node.priors.get(3, 0.0) == 0.25
assert node.visits.get(3, 0) == 4
assert node.value_sum[3] == 1.5
assert node.virtual_visits[3] == 2
assert 3 in node.visits
assert dict(node.visits.items()) == {3: 4}
def test_replay_buffer_capacity_and_sample() -> None: def test_replay_buffer_capacity_and_sample() -> None:
sample = ReplaySample( sample = ReplaySample(
info_state=np.zeros(4, dtype=np.float32), info_state=np.zeros(4, dtype=np.float32),
@@ -127,6 +297,29 @@ def test_self_play_game_returns_signed_targets() -> None:
assert all(sample.prior is not None for sample in samples) assert all(sample.prior is not None for sample in samples)
def test_interleaved_self_play_yields_complete_games() -> None:
config = mini_config()
state = GameState.new_game(config, seed=21)
net = AlphaZeroNet(input_dim(state), state.action_size, hidden_size=8, num_layers=1)
ismcts_config = IsMctsConfig.model_validate(
{
"mcts": {"n_simulations": 2, "parallel_simulations": 2},
"training": {"games_per_iter": 4, "interleave_games": 4, "interleave_max_batch": 16},
}
)
samples = play_self_play_iteration(
net,
ismcts_config.mcts,
ismcts_config.training,
config,
random.Random(22),
max_steps=80,
)
assert samples
assert {sample.game_index for sample in samples} == {0, 1, 2, 3}
assert all(np.isfinite(sample.v_target) for sample in samples)
def test_trainer_one_iteration_smoke(tmp_path) -> None: def test_trainer_one_iteration_smoke(tmp_path) -> None:
config = IsMctsConfig.model_validate( config = IsMctsConfig.model_validate(
{ {
@@ -185,9 +378,9 @@ def test_trainer_emits_full_eval_metrics(tmp_path) -> None:
run_dir=tmp_path, run_dir=tmp_path,
) )
metrics = trainer.train()[0].to_dict() metrics = trainer.train()[0].to_dict()
assert "eval/random/avg_opened_colors" in metrics assert "eval/random/avg_score_diff0" in metrics
assert "eval/random/bad_open_rate" in metrics assert "eval/random/play_action_rate" in metrics
assert "eval/random/per_game_negative_expeditions" in metrics assert "eval/random/win_rate0" in metrics
def test_trainer_emits_mcts_metrics(tmp_path) -> None: def test_trainer_emits_mcts_metrics(tmp_path) -> None:
@@ -221,3 +414,39 @@ def test_trainer_emits_mcts_metrics(tmp_path) -> None:
): ):
assert key in metrics assert key in metrics
assert np.isfinite(metrics[key]) assert np.isfinite(metrics[key])
def test_smoke_iter_with_batching(tmp_path) -> None:
config = IsMctsConfig.model_validate(
{
"run": {"max_iterations": 1, "seed": 14, "device": "cpu"},
"rules": {
"n_colors": 3,
"n_ranks": 5,
"n_handshakes": 1,
"hand_size": 4,
"bonus_threshold": 4,
},
"network": {"hidden_size": 16, "num_layers": 1},
"mcts": {"n_simulations": 4, "parallel_simulations": 4},
"training": {
"games_per_iter": 1,
"gradient_steps_per_iter": 1,
"batch_size": 8,
"interleave_games": 4,
"interleave_max_batch": 16,
},
"checkpoint": {"save_every": 0},
"evaluation": {"eval_every": 0, "num_workers": 1, "max_steps": 80},
}
)
trainer = IsMctsTrainer(
config,
config.rules.to_lost_cities_config(seed=config.run.seed),
run_dir=tmp_path,
)
metrics = trainer.train()[0].to_dict()
assert metrics["samples/added"] > 0
assert "mcts/avg_visit_entropy" in metrics
assert "mcts/value_prediction_error" in metrics
assert "mcts/policy_mcts_kl" in metrics