Add SO-ISMCTS mini trainer
Implements a proof-of-concept single-observer IS-MCTS trainer with AlphaZero-style policy/value network, determinization, replay, self-play, CLI configs, and focused tests. Mini acceptance run reaches positive random eval while keeping play_action_rate above the Deep CFR trap threshold. Tests: uv run python -m pytest tests/games/classic/ismcts/ -x; uv run python -m pytest tests/games/classic/test_deep_cfr_trainer.py -x; uv run lost-cities-ismcts train --config configs/ismcts/mini.yaml
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Single-observer IS-MCTS AlphaZero-style training for Lost Cities."""
|
||||
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .config import IsMctsConfig, load_config
|
||||
from .trainer import IsMctsTrainer
|
||||
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def _kebab_slug(value: str) -> str:
|
||||
return _SLUG_RE.sub("-", value.strip().lower()).strip("-") or "run"
|
||||
|
||||
|
||||
def _deep_update(base: dict[str, Any], patch: dict[str, Any]) -> None:
|
||||
for key, value in patch.items():
|
||||
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
||||
_deep_update(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
|
||||
|
||||
def _set_path_override(overrides: dict[str, Any], assignment: str) -> None:
|
||||
if "=" not in assignment:
|
||||
raise ValueError(f"config override must be PATH=VALUE: {assignment}")
|
||||
path, raw_value = assignment.split("=", 1)
|
||||
keys = path.split(".")
|
||||
value = yaml.safe_load(raw_value)
|
||||
cursor = overrides
|
||||
for key in keys[:-1]:
|
||||
cursor = cursor.setdefault(key, {})
|
||||
cursor[keys[-1]] = value
|
||||
|
||||
|
||||
def _with_overrides(config: IsMctsConfig, assignments: list[str]) -> IsMctsConfig:
|
||||
overrides: dict[str, Any] = {}
|
||||
for assignment in assignments:
|
||||
_set_path_override(overrides, assignment)
|
||||
data = config.model_dump(mode="python")
|
||||
_deep_update(data, overrides)
|
||||
return IsMctsConfig.model_validate(data)
|
||||
|
||||
|
||||
def _resolve_run_dir(config: IsMctsConfig, *, keep: bool) -> Path:
|
||||
parent = Path("runs") if keep else Path("runs/tmp")
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
|
||||
return parent / f"{timestamp}_{_kebab_slug(config.run.experiment_name)}"
|
||||
|
||||
|
||||
def train_command(args: argparse.Namespace) -> None:
|
||||
config = load_config(args.config) if args.config else IsMctsConfig()
|
||||
config = _with_overrides(config, args.config_overrides)
|
||||
run_dir = _resolve_run_dir(config, keep=args.keep)
|
||||
trainer = IsMctsTrainer(
|
||||
config,
|
||||
config.rules.to_lost_cities_config(seed=config.run.seed),
|
||||
run_dir=run_dir,
|
||||
device=config.run.device,
|
||||
)
|
||||
trainer.train()
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
parser = argparse.ArgumentParser(description="Lost Cities SO-ISMCTS tools.")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
train = subparsers.add_parser("train")
|
||||
train.add_argument("--config")
|
||||
train.add_argument("--keep", action="store_true")
|
||||
train.add_argument(
|
||||
"--set",
|
||||
action="append",
|
||||
default=[],
|
||||
dest="config_overrides",
|
||||
metavar="PATH=VALUE",
|
||||
)
|
||||
train.add_argument("--wandb", action="store_true", help="Accepted for CLI parity; ignored.")
|
||||
train.add_argument("--wandb-project", default="coolrl-lost-cities")
|
||||
train.add_argument("--wandb-name")
|
||||
train.add_argument("--wandb-mode", choices=("online", "offline", "disabled"), default="online")
|
||||
train.add_argument("--wandb-group")
|
||||
train.add_argument("--wandb-job-type")
|
||||
train.add_argument("--wandb-tag", action="append", default=[])
|
||||
train.add_argument("--wandb-notes")
|
||||
train.set_defaults(func=train_command)
|
||||
args = parser.parse_args(argv)
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from pydantic import Field, field_validator
|
||||
|
||||
from coolrl_lost_cities.games.classic.deep_cfr.config import (
|
||||
CheckpointConfig,
|
||||
EncodingConfig,
|
||||
EvaluationConfig,
|
||||
NetworkConfig,
|
||||
OptimizationConfig,
|
||||
RulesConfig,
|
||||
RunConfig,
|
||||
StrictModel,
|
||||
)
|
||||
|
||||
|
||||
class MctsConfig(StrictModel):
|
||||
n_simulations: int = 50
|
||||
c_puct: float = 1.5
|
||||
max_depth: int = 200
|
||||
use_rollout_value: bool = True
|
||||
|
||||
@field_validator("n_simulations", "max_depth")
|
||||
@classmethod
|
||||
def _positive_int(cls, value: int) -> int:
|
||||
if value <= 0:
|
||||
raise ValueError("must be positive")
|
||||
return value
|
||||
|
||||
|
||||
class TemperatureConfig(StrictModel):
|
||||
training: float = 1.0
|
||||
eval: float = 0.0
|
||||
|
||||
|
||||
class TrainingConfig(StrictModel):
|
||||
games_per_iter: int = 10
|
||||
gradient_steps_per_iter: int = 10
|
||||
batch_size: int = 128
|
||||
replay_capacity: int = 100_000
|
||||
|
||||
@field_validator("games_per_iter", "gradient_steps_per_iter", "batch_size", "replay_capacity")
|
||||
@classmethod
|
||||
def _positive_int(cls, value: int) -> int:
|
||||
if value <= 0:
|
||||
raise ValueError("must be positive")
|
||||
return value
|
||||
|
||||
|
||||
class IsMctsConfig(StrictModel):
|
||||
run: RunConfig = Field(default_factory=lambda: RunConfig(experiment_name="ismcts"))
|
||||
rules: RulesConfig = Field(default_factory=RulesConfig)
|
||||
encoding: EncodingConfig = Field(default_factory=EncodingConfig)
|
||||
network: NetworkConfig = Field(
|
||||
default_factory=lambda: NetworkConfig(hidden_size=512, num_layers=3)
|
||||
)
|
||||
mcts: MctsConfig = Field(default_factory=MctsConfig)
|
||||
temperature: TemperatureConfig = Field(default_factory=TemperatureConfig)
|
||||
training: TrainingConfig = Field(default_factory=TrainingConfig)
|
||||
optimization: OptimizationConfig = Field(default_factory=OptimizationConfig)
|
||||
checkpoint: CheckpointConfig = Field(default_factory=CheckpointConfig)
|
||||
evaluation: EvaluationConfig = Field(default_factory=EvaluationConfig)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return self.model_dump(mode="json")
|
||||
|
||||
|
||||
def config_from_dict(data: Mapping[str, Any]) -> IsMctsConfig:
|
||||
return IsMctsConfig.model_validate(data)
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> IsMctsConfig:
|
||||
config_path = Path(path)
|
||||
text = config_path.read_text(encoding="utf-8")
|
||||
if config_path.suffix.lower() in {".yaml", ".yml"}:
|
||||
data = yaml.safe_load(text) or {}
|
||||
else:
|
||||
data = json.loads(text)
|
||||
return config_from_dict(data)
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from coolrl_lost_cities.games.classic.game import GameState
|
||||
|
||||
from .info_set import unseen_cards
|
||||
|
||||
|
||||
def sample_determinization(state: GameState, player: int, rng: random.Random) -> GameState:
|
||||
"""Sample a concrete state uniformly from ``player``'s current information set."""
|
||||
p = int(player)
|
||||
opponent = 1 - p
|
||||
snapshot = state.to_snapshot()
|
||||
unseen = unseen_cards(state, p)
|
||||
rng.shuffle(unseen)
|
||||
opponent_hand_size = len(state.hands[opponent])
|
||||
deck_size = len(state.deck)
|
||||
if len(unseen) != opponent_hand_size + deck_size:
|
||||
raise ValueError(
|
||||
"information set card count mismatch: "
|
||||
f"unseen={len(unseen)} opponent_hand={opponent_hand_size} deck={deck_size}"
|
||||
)
|
||||
snapshot["hands"][opponent] = [card.to_snapshot() for card in unseen[:opponent_hand_size]]
|
||||
snapshot["deck"] = [card.to_snapshot() for card in unseen[opponent_hand_size:]]
|
||||
det = GameState.from_snapshot(snapshot)
|
||||
det.validate_invariants()
|
||||
return det
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
|
||||
from coolrl_lost_cities.games.classic.game import Card, GameState, build_deck
|
||||
|
||||
|
||||
def _card_tuple(card: Card) -> tuple[int, int]:
|
||||
return int(card.color), int(card.rank)
|
||||
|
||||
|
||||
def _sorted_cards(cards: list[Card]) -> list[tuple[int, int]]:
|
||||
return sorted(_card_tuple(card) for card in cards)
|
||||
|
||||
|
||||
def canonical_info_set_key(state: GameState, player: int) -> bytes:
|
||||
"""Deterministic key for observable information from ``player``'s POV."""
|
||||
p = int(player)
|
||||
payload = {
|
||||
"config": state.config.to_snapshot(),
|
||||
"player": p,
|
||||
"current_player": int(state.current_player),
|
||||
"phase": state.phase,
|
||||
"pending_discarded_color": (
|
||||
None if state.pending_discarded_color < 0 else int(state.pending_discarded_color)
|
||||
),
|
||||
"turn_count": int(state.turn_count),
|
||||
"terminal": bool(state.terminal),
|
||||
"deck_size": len(state.deck),
|
||||
"hand": _sorted_cards(state.hands[p]),
|
||||
"hand_size_opp": len(state.hands[1 - p]),
|
||||
"expeditions": [
|
||||
[[_card_tuple(card) for card in expedition] for expedition in player_expeditions]
|
||||
for player_expeditions in state.expeditions
|
||||
],
|
||||
"discards": [[_card_tuple(card) for card in discard] for discard in state.discards],
|
||||
"legal_mask": list(map(bool, state.unified_legal_mask())),
|
||||
}
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def visible_cards(state: GameState, player: int) -> list[Card]:
|
||||
cards: list[Card] = []
|
||||
cards.extend(state.hands[int(player)])
|
||||
for player_expeditions in state.expeditions:
|
||||
for expedition in player_expeditions:
|
||||
cards.extend(expedition)
|
||||
for discard in state.discards:
|
||||
cards.extend(discard)
|
||||
return cards
|
||||
|
||||
|
||||
def unseen_cards(state: GameState, player: int) -> list[Card]:
|
||||
remaining = Counter(_card_tuple(card) for card in build_deck(state.config))
|
||||
for card in visible_cards(state, player):
|
||||
remaining[_card_tuple(card)] -= 1
|
||||
cards: list[Card] = []
|
||||
for (color, rank), count in remaining.items():
|
||||
cards.extend(Card(color, rank) for _ in range(count))
|
||||
return cards
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
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
|
||||
|
||||
from .config import MctsConfig
|
||||
from .determinization import sample_determinization
|
||||
from .info_set import canonical_info_set_key
|
||||
from .network import AlphaZeroNet
|
||||
|
||||
|
||||
@dataclass
|
||||
class MctsNode:
|
||||
info_set_key: bytes
|
||||
player: int
|
||||
priors: dict[int, float] = field(default_factory=dict)
|
||||
visits: dict[int, int] = field(default_factory=dict)
|
||||
value_sum: dict[int, float] = field(default_factory=dict)
|
||||
children: dict[int, bytes] = field(default_factory=dict)
|
||||
terminal: bool = False
|
||||
|
||||
def is_expanded(self) -> bool:
|
||||
return self.terminal or bool(self.priors)
|
||||
|
||||
def q(self, action: int) -> float:
|
||||
n = self.visits.get(action, 0)
|
||||
if n <= 0:
|
||||
return 0.0
|
||||
return self.value_sum.get(action, 0.0) / n
|
||||
|
||||
|
||||
class MctsTree:
|
||||
def __init__(self) -> None:
|
||||
self.nodes: dict[bytes, MctsNode] = {}
|
||||
|
||||
def get_or_create(self, key: bytes, *, player: int, terminal: bool = False) -> MctsNode:
|
||||
node = self.nodes.get(key)
|
||||
if node is None:
|
||||
node = MctsNode(key, player=player, terminal=terminal)
|
||||
self.nodes[key] = node
|
||||
return node
|
||||
|
||||
|
||||
class IsMctsSearcher:
|
||||
def __init__(
|
||||
self,
|
||||
network: AlphaZeroNet,
|
||||
config: MctsConfig,
|
||||
*,
|
||||
device: torch.device | str = "cpu",
|
||||
encoding=None,
|
||||
rng: random.Random | None = None,
|
||||
) -> None:
|
||||
self.network = network
|
||||
self.config = config
|
||||
self.device = torch.device(device)
|
||||
self.encoding = encoding
|
||||
self.rng = rng or random.Random()
|
||||
self.tree = MctsTree()
|
||||
|
||||
def search(
|
||||
self,
|
||||
state: GameState,
|
||||
traverser: int,
|
||||
n_sims: int | None = None,
|
||||
) -> dict[int, int]:
|
||||
root_key = canonical_info_set_key(state, state.current_player)
|
||||
root = self.tree.get_or_create(
|
||||
root_key, player=state.current_player, terminal=state.terminal
|
||||
)
|
||||
sims = int(n_sims or self.config.n_simulations)
|
||||
for _ in range(sims):
|
||||
det = sample_determinization(state, traverser, self.rng)
|
||||
self._simulate(det, depth=0)
|
||||
legal = state.unified_legal_actions()
|
||||
return {action: root.visits.get(action, 0) for action in legal}
|
||||
|
||||
def _simulate(self, state: GameState, *, depth: int) -> float:
|
||||
player = int(state.current_player)
|
||||
if state.terminal or depth >= self.config.max_depth:
|
||||
return float(state.score_diff(player))
|
||||
|
||||
key = canonical_info_set_key(state, player)
|
||||
node = self.tree.get_or_create(key, player=player, terminal=state.terminal)
|
||||
if not node.is_expanded():
|
||||
value = self._expand_and_evaluate(node, state, player)
|
||||
return value
|
||||
|
||||
action = self._select_action(node, state.unified_legal_actions())
|
||||
child = state.clone()
|
||||
child.apply_unified_action(action)
|
||||
child_value = self._simulate(child, depth=depth + 1)
|
||||
value = child_value if child.current_player == player else -child_value
|
||||
node.visits[action] = node.visits.get(action, 0) + 1
|
||||
node.value_sum[action] = node.value_sum.get(action, 0.0) + value
|
||||
child_key = canonical_info_set_key(child, child.current_player)
|
||||
node.children[action] = child_key
|
||||
return value
|
||||
|
||||
def _expand_and_evaluate(self, node: MctsNode, state: GameState, player: int) -> float:
|
||||
legal_actions = state.unified_legal_actions()
|
||||
if not legal_actions:
|
||||
node.terminal = True
|
||||
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:
|
||||
node.priors[action] = float(probs[action])
|
||||
node.visits.setdefault(action, 0)
|
||||
node.value_sum.setdefault(action, 0.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.item())
|
||||
return rollout_value
|
||||
|
||||
def _select_action(self, node: MctsNode, legal_actions: list[int]) -> int:
|
||||
total_visits = sum(node.visits.get(action, 0) for action in legal_actions)
|
||||
sqrt_total = math.sqrt(max(1, total_visits))
|
||||
best_score = -float("inf")
|
||||
best_action = legal_actions[0]
|
||||
for action in legal_actions:
|
||||
n = node.visits.get(action, 0)
|
||||
prior = node.priors.get(action, 0.0)
|
||||
score = node.q(action) + self.config.c_puct * prior * sqrt_total / (1 + n)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_action = action
|
||||
return int(best_action)
|
||||
|
||||
def _rollout_value(self, state: GameState, player: int) -> float | None:
|
||||
rollout = state.clone()
|
||||
steps = 0
|
||||
while not rollout.terminal and steps < self.config.max_depth:
|
||||
legal = rollout.unified_legal_actions()
|
||||
if not legal:
|
||||
break
|
||||
action = self.rng.choice(legal)
|
||||
rollout.apply_unified_action(action)
|
||||
steps += 1
|
||||
return float(rollout.score_diff(player))
|
||||
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from coolrl_lost_cities.games.classic.deep_cfr.networks import _activation
|
||||
|
||||
from .config import IsMctsConfig
|
||||
|
||||
|
||||
class AlphaZeroNet(nn.Module):
|
||||
def __init__(
|
||||
self,
|
||||
input_dim: int,
|
||||
action_size: int,
|
||||
hidden_size: int = 512,
|
||||
*,
|
||||
num_layers: int = 3,
|
||||
activation: str = "relu",
|
||||
value_scale: float = 100.0,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.input_dim = int(input_dim)
|
||||
self.action_size = int(action_size)
|
||||
self.value_scale = float(value_scale)
|
||||
layers: list[nn.Module] = []
|
||||
last_dim = self.input_dim
|
||||
for _ in range(max(0, int(num_layers))):
|
||||
layers.append(nn.Linear(last_dim, hidden_size))
|
||||
layers.append(_activation(activation))
|
||||
last_dim = hidden_size
|
||||
self.backbone = nn.Sequential(*layers)
|
||||
self.policy_head = nn.Linear(last_dim, self.action_size)
|
||||
self.value_head = nn.Linear(last_dim, 1)
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls,
|
||||
input_dim: int,
|
||||
action_size: int,
|
||||
config: IsMctsConfig | object,
|
||||
) -> AlphaZeroNet:
|
||||
network_config = config.network if hasattr(config, "network") else config
|
||||
return cls(
|
||||
input_dim,
|
||||
action_size,
|
||||
network_config.hidden_size,
|
||||
num_layers=network_config.num_layers,
|
||||
activation=network_config.activation,
|
||||
)
|
||||
|
||||
def forward(
|
||||
self,
|
||||
info_state_tensor: torch.Tensor,
|
||||
legal_mask: torch.Tensor | None = None,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
hidden = self.backbone(info_state_tensor)
|
||||
logits = self.policy_head(hidden)
|
||||
value = torch.tanh(self.value_head(hidden)).squeeze(-1) * self.value_scale
|
||||
if legal_mask is not None:
|
||||
logits = logits.masked_fill(~legal_mask.bool(), torch.finfo(logits.dtype).min)
|
||||
return logits, value
|
||||
|
||||
def policy_distribution(
|
||||
self,
|
||||
info_state_tensor: torch.Tensor,
|
||||
legal_mask: torch.Tensor,
|
||||
) -> torch.Tensor:
|
||||
logits, _value = self.forward(info_state_tensor, legal_mask)
|
||||
probs = torch.softmax(logits, dim=-1).masked_fill(~legal_mask.bool(), 0.0)
|
||||
normalizer = probs.sum(dim=-1, keepdim=True).clamp_min(1.0e-12)
|
||||
return probs / normalizer
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
from coolrl_lost_cities.games.classic.policy import LostCitiesPolicy, PolicyInput
|
||||
|
||||
from .network import AlphaZeroNet
|
||||
|
||||
|
||||
class AlphaZeroPolicy(LostCitiesPolicy):
|
||||
def __init__(
|
||||
self,
|
||||
network: AlphaZeroNet,
|
||||
*,
|
||||
device: torch.device | str = "cpu",
|
||||
encoding=None,
|
||||
sample: bool = False,
|
||||
seed: int | None = None,
|
||||
) -> None:
|
||||
self.network = network
|
||||
self.device = torch.device(device)
|
||||
self.encoding = encoding
|
||||
self.sample = sample
|
||||
self.rng = np.random.default_rng(seed)
|
||||
|
||||
def act(self, obs_or_state: PolicyInput) -> int:
|
||||
if not isinstance(obs_or_state, GameState):
|
||||
legal = np.asarray(obs_or_state["legal_mask"], dtype=bool)
|
||||
return int(np.flatnonzero(legal)[0])
|
||||
state = obs_or_state
|
||||
legal = np.asarray(state.unified_legal_mask(), dtype=bool)
|
||||
info = encode_info_state(state, state.current_player, self.encoding)
|
||||
with torch.inference_mode():
|
||||
x = torch.as_tensor(info[None, :], dtype=torch.float32, device=self.device)
|
||||
mask = torch.as_tensor(legal[None, :], dtype=torch.bool, device=self.device)
|
||||
probs = self.network.policy_distribution(x, mask).squeeze(0).cpu().numpy()
|
||||
legal_actions = np.flatnonzero(legal)
|
||||
if self.sample:
|
||||
unified = int(self.rng.choice(legal_actions, p=probs[legal_actions]))
|
||||
else:
|
||||
unified = int(legal_actions[int(np.argmax(probs[legal_actions]))])
|
||||
return state.from_unified_action(unified)
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplaySample:
|
||||
info_state: np.ndarray
|
||||
legal_mask: np.ndarray
|
||||
pi_target: np.ndarray
|
||||
v_target: float
|
||||
player: int
|
||||
|
||||
|
||||
class ReplayBuffer:
|
||||
def __init__(self, capacity: int, *, seed: int | None = None) -> None:
|
||||
self.capacity = int(capacity)
|
||||
self._items: deque[ReplaySample] = deque(maxlen=self.capacity)
|
||||
self.rng = np.random.default_rng(seed)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._items)
|
||||
|
||||
def add(self, samples: Iterable[ReplaySample]) -> None:
|
||||
self._items.extend(samples)
|
||||
|
||||
def sample(self, batch_size: int) -> list[ReplaySample]:
|
||||
if not self._items:
|
||||
raise ValueError("cannot sample from an empty replay buffer")
|
||||
size = min(int(batch_size), len(self._items))
|
||||
indices = self.rng.choice(len(self._items), size=size, replace=False)
|
||||
items = list(self._items)
|
||||
return [items[int(index)] for index in indices]
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
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
|
||||
from .mcts import IsMctsSearcher
|
||||
from .network import AlphaZeroNet
|
||||
from .replay_buffer import ReplaySample
|
||||
|
||||
|
||||
def visit_distribution(
|
||||
visit_counts: dict[int, int],
|
||||
action_size: int,
|
||||
*,
|
||||
temperature: float,
|
||||
) -> np.ndarray:
|
||||
pi = np.zeros(action_size, dtype=np.float32)
|
||||
if not visit_counts:
|
||||
return pi
|
||||
actions = np.asarray(list(visit_counts), dtype=np.int64)
|
||||
counts = np.asarray([visit_counts[int(action)] for action in actions], dtype=np.float64)
|
||||
if temperature <= 0.0:
|
||||
best = int(actions[int(np.argmax(counts))])
|
||||
pi[best] = 1.0
|
||||
return pi
|
||||
adjusted = np.power(np.maximum(counts, 1.0e-12), 1.0 / temperature)
|
||||
adjusted /= adjusted.sum()
|
||||
pi[actions] = adjusted.astype(np.float32)
|
||||
return pi
|
||||
|
||||
|
||||
def select_from_distribution(pi: np.ndarray, rng: random.Random) -> int:
|
||||
total = float(pi.sum())
|
||||
if total <= 0.0:
|
||||
raise RuntimeError("empty action distribution")
|
||||
threshold = rng.random() * total
|
||||
cumsum = 0.0
|
||||
for action, prob in enumerate(pi):
|
||||
cumsum += float(prob)
|
||||
if cumsum >= threshold:
|
||||
return action
|
||||
return int(len(pi) - 1)
|
||||
|
||||
|
||||
def play_self_play_game(
|
||||
network: AlphaZeroNet,
|
||||
mcts_config: MctsConfig,
|
||||
game_config: LostCitiesConfig,
|
||||
rng: random.Random,
|
||||
*,
|
||||
device: torch.device | str = "cpu",
|
||||
encoding=None,
|
||||
temperature: float = 1.0,
|
||||
max_steps: int = 10_000,
|
||||
) -> list[ReplaySample]:
|
||||
state = GameState.new_game(game_config, seed=rng.randrange(2**31))
|
||||
pending: list[tuple[np.ndarray, np.ndarray, np.ndarray, int]] = []
|
||||
steps = 0
|
||||
while not state.terminal and steps < max_steps:
|
||||
player = int(state.current_player)
|
||||
searcher = IsMctsSearcher(
|
||||
network,
|
||||
mcts_config,
|
||||
device=device,
|
||||
encoding=encoding,
|
||||
rng=random.Random(rng.randrange(2**31)),
|
||||
)
|
||||
visits = searcher.search(state, player)
|
||||
legal_mask = np.asarray(state.unified_legal_mask(), dtype=bool)
|
||||
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)
|
||||
info = encode_info_state(state, player, encoding)
|
||||
pending.append((info.astype(np.float32), legal_mask, pi, player))
|
||||
action = select_from_distribution(pi, rng)
|
||||
state.apply_unified_action(action)
|
||||
steps += 1
|
||||
|
||||
final_diff0 = float(state.score_diff(0))
|
||||
samples: list[ReplaySample] = []
|
||||
for info, legal_mask, pi, player in pending:
|
||||
value = final_diff0 if player == 0 else -final_diff0
|
||||
samples.append(
|
||||
ReplaySample(
|
||||
info_state=info,
|
||||
legal_mask=legal_mask.astype(bool),
|
||||
pi_target=pi.astype(np.float32),
|
||||
v_target=value,
|
||||
player=player,
|
||||
)
|
||||
)
|
||||
return samples
|
||||
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from coolrl_lost_cities.games.classic.bots 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
|
||||
from .mcts import IsMctsSearcher
|
||||
from .network import AlphaZeroNet
|
||||
from .replay_buffer import ReplayBuffer, ReplaySample
|
||||
from .self_play import play_self_play_game
|
||||
|
||||
|
||||
@dataclass
|
||||
class IterationMetrics:
|
||||
iteration: int
|
||||
samples_added: int
|
||||
replay_size: int
|
||||
policy_loss: float
|
||||
value_loss: float
|
||||
total_loss: float
|
||||
self_play_seconds: float
|
||||
train_seconds: float
|
||||
eval_metrics: dict[str, float | int]
|
||||
|
||||
def to_dict(self) -> dict[str, float | int]:
|
||||
data: dict[str, float | int] = {
|
||||
"iteration": self.iteration,
|
||||
"samples/added": self.samples_added,
|
||||
"memory/replay": self.replay_size,
|
||||
"loss/policy": self.policy_loss,
|
||||
"loss/value": self.value_loss,
|
||||
"loss/total": self.total_loss,
|
||||
"time/self_play_seconds": self.self_play_seconds,
|
||||
"time/train_seconds": self.train_seconds,
|
||||
}
|
||||
data.update(self.eval_metrics)
|
||||
return data
|
||||
|
||||
|
||||
class IsMctsTrainer:
|
||||
def __init__(
|
||||
self,
|
||||
config: IsMctsConfig,
|
||||
game_config: LostCitiesConfig,
|
||||
*,
|
||||
run_dir: str | Path,
|
||||
device: torch.device | str = "cpu",
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.game_config = game_config
|
||||
self.run_dir = Path(run_dir)
|
||||
self.device = self._resolve_device(device)
|
||||
probe = GameState.new_game(game_config, seed=config.run.seed)
|
||||
self.input_dim = input_dim(probe, config.encoding)
|
||||
self.action_size = probe.action_size
|
||||
self.network = AlphaZeroNet.from_config(self.input_dim, self.action_size, config).to(
|
||||
self.device
|
||||
)
|
||||
self.optimizer = torch.optim.AdamW(
|
||||
self.network.parameters(),
|
||||
lr=config.optimization.learning_rate,
|
||||
weight_decay=max(float(config.optimization.weight_decay), 1.0e-4),
|
||||
)
|
||||
self.buffer = ReplayBuffer(config.training.replay_capacity, seed=config.run.seed)
|
||||
self.metrics_path = self.run_dir / "metrics.jsonl"
|
||||
self.rng = random.Random(config.run.seed)
|
||||
|
||||
def _resolve_device(self, device: torch.device | str) -> torch.device:
|
||||
token = str(device)
|
||||
if token == "auto":
|
||||
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||
return torch.device(token)
|
||||
|
||||
def train(self) -> list[IterationMetrics]:
|
||||
self.run_dir.mkdir(parents=True, exist_ok=True)
|
||||
(self.run_dir / "config.json").write_text(
|
||||
json.dumps(self.config.to_dict(), indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if self.metrics_path.exists():
|
||||
self.metrics_path.unlink()
|
||||
metrics: list[IterationMetrics] = []
|
||||
max_iterations = self.config.run.max_iterations or 1
|
||||
started = time.perf_counter()
|
||||
for iteration in range(1, max_iterations + 1):
|
||||
if self._time_limit_reached(started):
|
||||
break
|
||||
item = self.run_iteration(iteration)
|
||||
metrics.append(item)
|
||||
self._append_metrics(item)
|
||||
self._save_checkpoints(iteration, item)
|
||||
print(json.dumps(item.to_dict(), sort_keys=True))
|
||||
return metrics
|
||||
|
||||
def run_iteration(self, iteration: int) -> IterationMetrics:
|
||||
self.network.eval()
|
||||
sp_started = time.perf_counter()
|
||||
added = 0
|
||||
for _ in range(self.config.training.games_per_iter):
|
||||
samples = play_self_play_game(
|
||||
self.network,
|
||||
self.config.mcts,
|
||||
self.game_config,
|
||||
self.rng,
|
||||
device=self.device,
|
||||
encoding=self.config.encoding,
|
||||
temperature=self.config.temperature.training,
|
||||
max_steps=self.config.evaluation.max_steps,
|
||||
)
|
||||
self.buffer.add(samples)
|
||||
added += len(samples)
|
||||
self_play_seconds = time.perf_counter() - sp_started
|
||||
|
||||
train_started = time.perf_counter()
|
||||
losses = []
|
||||
for _ in range(self.config.training.gradient_steps_per_iter):
|
||||
batch = self.buffer.sample(self.config.training.batch_size)
|
||||
losses.append(self._train_batch(batch))
|
||||
train_seconds = time.perf_counter() - train_started
|
||||
loss_arr = np.asarray(losses, dtype=np.float64)
|
||||
eval_metrics = self._evaluate(iteration)
|
||||
return IterationMetrics(
|
||||
iteration=iteration,
|
||||
samples_added=added,
|
||||
replay_size=len(self.buffer),
|
||||
policy_loss=float(loss_arr[:, 0].mean()) if len(loss_arr) else 0.0,
|
||||
value_loss=float(loss_arr[:, 1].mean()) if len(loss_arr) else 0.0,
|
||||
total_loss=float(loss_arr[:, 2].mean()) if len(loss_arr) else 0.0,
|
||||
self_play_seconds=self_play_seconds,
|
||||
train_seconds=train_seconds,
|
||||
eval_metrics=eval_metrics,
|
||||
)
|
||||
|
||||
def _train_batch(self, batch: list[ReplaySample]) -> tuple[float, float, float]:
|
||||
self.network.train()
|
||||
info = torch.as_tensor(
|
||||
np.stack([sample.info_state for sample in batch]),
|
||||
dtype=torch.float32,
|
||||
device=self.device,
|
||||
)
|
||||
legal = torch.as_tensor(
|
||||
np.stack([sample.legal_mask for sample in batch]),
|
||||
dtype=torch.bool,
|
||||
device=self.device,
|
||||
)
|
||||
pi = torch.as_tensor(
|
||||
np.stack([sample.pi_target for sample in batch]),
|
||||
dtype=torch.float32,
|
||||
device=self.device,
|
||||
)
|
||||
value_target = torch.as_tensor(
|
||||
[sample.v_target for sample in batch],
|
||||
dtype=torch.float32,
|
||||
device=self.device,
|
||||
)
|
||||
logits, value_pred = self.network(info, legal)
|
||||
log_probs = torch.log_softmax(logits, dim=-1)
|
||||
policy_loss = -(pi * log_probs).sum(dim=-1).mean()
|
||||
value_loss = nn.functional.mse_loss(value_pred, value_target)
|
||||
loss = policy_loss + value_loss
|
||||
self.optimizer.zero_grad(set_to_none=True)
|
||||
loss.backward()
|
||||
if self.config.optimization.grad_clip > 0:
|
||||
torch.nn.utils.clip_grad_norm_(
|
||||
self.network.parameters(),
|
||||
self.config.optimization.grad_clip,
|
||||
)
|
||||
self.optimizer.step()
|
||||
return float(policy_loss.item()), float(value_loss.item()), float(loss.item())
|
||||
|
||||
def _evaluate(self, iteration: int) -> dict[str, float | int]:
|
||||
opponents = self.config.evaluation.opponents_for_iteration(iteration)
|
||||
if not opponents:
|
||||
return {}
|
||||
self.network.eval()
|
||||
results: dict[str, float | int] = {}
|
||||
for opponent in opponents:
|
||||
result = evaluate_policy(
|
||||
self.network,
|
||||
self.game_config,
|
||||
opponent=opponent,
|
||||
games=self.config.evaluation.games,
|
||||
seed=self.config.run.seed + iteration * 1000,
|
||||
device=self.device,
|
||||
encoding=self.config.encoding,
|
||||
max_steps=self.config.evaluation.max_steps,
|
||||
mcts_config=self.config.mcts,
|
||||
)
|
||||
key = opponent.replace("-", "_")
|
||||
for metric_key, value in result.items():
|
||||
results[f"eval/{key}/{metric_key}"] = value
|
||||
return results
|
||||
|
||||
def _append_metrics(self, metrics: IterationMetrics) -> None:
|
||||
with self.metrics_path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(metrics.to_dict(), sort_keys=True) + "\n")
|
||||
|
||||
def _save_checkpoints(self, iteration: int, metrics: IterationMetrics) -> None:
|
||||
payload = {
|
||||
"config": self.config.to_dict(),
|
||||
"game_config": self.game_config.to_snapshot(),
|
||||
"iteration": iteration,
|
||||
"network": self.network.state_dict(),
|
||||
"optimizer": self.optimizer.state_dict(),
|
||||
"metrics": metrics.to_dict(),
|
||||
}
|
||||
if self.config.checkpoint.save_latest:
|
||||
torch.save(payload, self.run_dir / "latest.pt")
|
||||
if (
|
||||
self.config.checkpoint.save_every > 0
|
||||
and iteration % self.config.checkpoint.save_every == 0
|
||||
):
|
||||
torch.save(payload, self.run_dir / f"iteration_{iteration:05d}.pt")
|
||||
|
||||
def _time_limit_reached(self, started: float) -> bool:
|
||||
if self.config.run.max_minutes is None:
|
||||
return False
|
||||
return (time.perf_counter() - started) / 60.0 >= self.config.run.max_minutes
|
||||
|
||||
|
||||
def evaluate_policy(
|
||||
network: AlphaZeroNet,
|
||||
config: LostCitiesConfig,
|
||||
*,
|
||||
opponent: str,
|
||||
games: int,
|
||||
seed: int,
|
||||
device: torch.device | str,
|
||||
encoding=None,
|
||||
max_steps: int = 10_000,
|
||||
mcts_config=None,
|
||||
) -> dict[str, float | int]:
|
||||
rng = random.Random(seed)
|
||||
score_diffs: list[int] = []
|
||||
wins = losses = draws = 0
|
||||
policy_actions = play_actions = 0
|
||||
for game_index in range(games):
|
||||
policy_player = game_index % 2
|
||||
policies = [
|
||||
build_bot(opponent, seed=seed + game_index),
|
||||
build_bot(opponent, seed=seed + game_index),
|
||||
]
|
||||
state = GameState.new_game(config, seed=seed + game_index)
|
||||
for _ in range(max_steps):
|
||||
if state.terminal:
|
||||
break
|
||||
current = int(state.current_player)
|
||||
if current == policy_player:
|
||||
searcher = IsMctsSearcher(
|
||||
network,
|
||||
mcts_config or IsMctsConfig().mcts,
|
||||
device=device,
|
||||
encoding=encoding,
|
||||
rng=random.Random(rng.randrange(2**31)),
|
||||
)
|
||||
visits = searcher.search(state, current)
|
||||
unified = max(visits, key=visits.get)
|
||||
action = state.from_unified_action(unified)
|
||||
else:
|
||||
action = policies[current].act(state)
|
||||
if current == policy_player and state.phase == "card":
|
||||
policy_actions += 1
|
||||
if action % 2 == 0:
|
||||
play_actions += 1
|
||||
state.apply_action(action)
|
||||
diff = state.score_diff(policy_player)
|
||||
score_diffs.append(diff)
|
||||
wins += int(diff > 0)
|
||||
losses += int(diff < 0)
|
||||
draws += int(diff == 0)
|
||||
return {
|
||||
"games": games,
|
||||
"wins0": wins,
|
||||
"wins1": losses,
|
||||
"draws": draws,
|
||||
"win_rate0": wins / max(1, games),
|
||||
"avg_score_diff0": float(np.mean(score_diffs)) if score_diffs else 0.0,
|
||||
"play_action_rate": play_actions / max(1, policy_actions),
|
||||
}
|
||||
Reference in New Issue
Block a user