Deep CFR legacy 재현 config 기반 추가

This commit is contained in:
2026-05-07 00:46:09 +09:00
parent 898f927cb2
commit 9bcc88c1be
9 changed files with 356 additions and 45 deletions
@@ -0,0 +1,89 @@
run:
experiment_name: lost_cities_deep_cfr_pure_self_play_zero_pit_poc_full_depth_slot_aware_playability
seed: 79
max_iterations: null
max_hours: 4
device: cuda
use_amp: false
rules:
n_colors: 5
n_ranks: 9
min_rank: 2
n_handshakes: 3
hand_size: 8
expedition_penalty: -20
bonus_threshold: 8
bonus_amount: 20
encoding:
derived_playability: true
slot_aware_playability: true
network:
hidden_size: 256
num_layers: 3
activation: relu
traversal:
traversals_per_player: 70
strategy_sample_interval: 1
store_strategy_on_opponent_nodes: false
store_strategy_on_traverser_nodes: true
max_depth: null
max_nodes_per_traversal: 1000
opponent_policy: self_play_league
cutoff_value_mode: score_diff
cutoff_rollouts: 0
cutoff_rollout_policy: random
cutoff_rollout_max_steps: 300
num_workers: 8
traversal_worker_chunk_size: 8
regret_matching_epsilon: 0.0001
outcome_sampling_epsilon: 0.2
outcome_sampling_value_clip: 500
outcome_unsampled_regret: zero
endpoint_depth_bucket_width: 100
endpoint_depth_bucket_max: 1000
self_play:
current_weight: 0.5
recent_weight: 0.3
older_weight: 0.2
anchor_weight: 0.0
recent_window: 5
max_snapshots: 20
snapshot_every: 1
optimization:
advantage_batch_size: 1024
strategy_batch_size: 1024
advantage_updates_per_iteration: 256
strategy_updates_per_iteration: 256
learning_rate: 0.00003
weight_decay: 0.0001
grad_clip: 1.0
memory:
advantage_capacity: 2000000
strategy_capacity: 2000000
evaluation:
eval_every: 5
games: 100
max_steps: 1000
on_max_steps: score_diff
opponents:
- random
- passive_discard
- safe_heuristic
- safe_heuristic_loose
- safe_heuristic_strict
- noisy_safe
checkpoint:
directory: checkpoints/lost_cities_deep_cfr_pure_self_play_zero_pit_poc_full_depth_slot_aware_playability
save_every_iteration: false
save_iteration_interval: 10
save_latest_only: false
progress_interval_seconds: 20.0
@@ -4,7 +4,6 @@ import time
from coolrl_lost_cities.games.classic.deep_cfr.config import DeepCFRConfig
from coolrl_lost_cities.games.classic.deep_cfr.trainer import DeepCFRTrainer
from coolrl_lost_cities.games.classic.game import classic_config
def benchmark_traversal(
@@ -24,7 +23,7 @@ def benchmark_traversal(
data["checkpoint"]["save_every_iteration"] = False
data["evaluation"]["eval_every"] = 0
cfg = DeepCFRConfig.model_validate(data)
trainer = DeepCFRTrainer(cfg, classic_config(seed=cfg.run.seed))
trainer = DeepCFRTrainer(cfg, cfg.rules.to_lost_cities_config(seed=cfg.run.seed))
started = time.perf_counter()
metrics = trainer.run_iteration(1)
elapsed = time.perf_counter() - started
@@ -65,7 +65,7 @@ def train_command(args: argparse.Namespace) -> None:
config = _with_overrides(config, overrides)
trainer = DeepCFRTrainer(
config,
classic_config(seed=config.run.seed),
config.rules.to_lost_cities_config(seed=config.run.seed),
device=args.device or config.run.device,
)
if args.resume:
@@ -9,25 +9,86 @@ from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator
from coolrl_lost_cities.games.classic.game import LostCitiesConfig
class StrictModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class RunConfig(StrictModel):
experiment_name: str = "deep_cfr"
iterations: int = 1
max_iterations: int | None = None
max_hours: float | None = None
seed: int = 1
device: str = "cpu"
use_amp: bool = False
@field_validator("device")
@classmethod
def _normalize_device(cls, value: str) -> str:
token = value.strip().lower()
if token == "cuda":
return "cuda"
if token == "cpu":
return "cpu"
if token == "auto":
return "auto"
return token
class RulesConfig(StrictModel):
n_colors: int = 5
n_ranks: int = 9
min_rank: int = 2
n_handshakes: int = 3
hand_size: int = 8
expedition_penalty: int = -20
bonus_threshold: int = 8
bonus_amount: int = 20
def to_lost_cities_config(self, seed: int | None = None) -> LostCitiesConfig:
config = LostCitiesConfig(
n_colors=self.n_colors,
n_ranks=self.n_ranks,
min_rank=self.min_rank,
n_handshakes=self.n_handshakes,
hand_size=self.hand_size,
expedition_penalty=self.expedition_penalty,
bonus_threshold=self.bonus_threshold,
bonus_amount=self.bonus_amount,
seed=seed,
)
config.validate()
return config
class EncodingConfig(StrictModel):
derived_playability: bool = False
slot_aware_playability: bool = False
class NetworkConfig(StrictModel):
hidden_size: int = 64
num_layers: int = 2
activation: str = "relu"
@field_validator("activation")
@classmethod
def _validate_activation(cls, value: str) -> str:
token = value.strip().lower()
if token not in {"relu", "gelu"}:
raise ValueError("must be 'relu' or 'gelu'")
return token
class TraversalConfig(StrictModel):
traversals_per_iteration: int = 2
traversals_per_player: int | None = None
max_depth: int | None = 8
max_nodes: int | None = 10_000
max_nodes_per_traversal: int | None = None
regret_matching_epsilon: float = 1.0e-8
outcome_sampling_epsilon: float = 0.0
outcome_sampling_value_clip: float | None = None
@@ -42,6 +103,9 @@ class TraversalConfig(StrictModel):
store_strategy_on_opponent_nodes: bool = True
num_workers: int | str = 0
worker_chunk_size: int = 4
traversal_worker_chunk_size: int | None = None
endpoint_depth_bucket_width: int = 100
endpoint_depth_bucket_max: int = 1000
@field_validator("outcome_unsampled_regret")
@classmethod
@@ -80,6 +144,21 @@ class TraversalConfig(StrictModel):
return max(0, int(token))
return max(0, int(self.num_workers))
def resolved_traversals_per_player(self) -> int:
if self.traversals_per_player is not None:
return max(0, int(self.traversals_per_player))
return max(0, int(self.traversals_per_iteration))
def resolved_max_nodes(self) -> int | None:
if self.max_nodes_per_traversal is not None:
return self.max_nodes_per_traversal
return self.max_nodes
def resolved_worker_chunk_size(self) -> int:
if self.traversal_worker_chunk_size is not None:
return max(1, int(self.traversal_worker_chunk_size))
return max(1, int(self.worker_chunk_size))
class SelfPlayLeagueConfig(StrictModel):
snapshot_every: int = 1
@@ -96,7 +175,33 @@ class OptimizationConfig(StrictModel):
advantage_train_steps: int = 1
strategy_train_steps: int = 1
batch_size: int = 32
advantage_batch_size: int | None = None
strategy_batch_size: int | None = None
advantage_updates_per_iteration: int | None = None
strategy_updates_per_iteration: int | None = None
learning_rate: float = 1.0e-3
weight_decay: float = 0.0
grad_clip: float = 0.0
def resolved_advantage_batch_size(self) -> int:
if self.advantage_batch_size is not None:
return max(1, int(self.advantage_batch_size))
return max(1, int(self.batch_size))
def resolved_strategy_batch_size(self) -> int:
if self.strategy_batch_size is not None:
return max(1, int(self.strategy_batch_size))
return max(1, int(self.batch_size))
def resolved_advantage_train_steps(self) -> int:
if self.advantage_updates_per_iteration is not None:
return max(0, int(self.advantage_updates_per_iteration))
return max(0, int(self.advantage_train_steps))
def resolved_strategy_train_steps(self) -> int:
if self.strategy_updates_per_iteration is not None:
return max(0, int(self.strategy_updates_per_iteration))
return max(0, int(self.strategy_train_steps))
class MemoryConfig(StrictModel):
@@ -107,6 +212,9 @@ class MemoryConfig(StrictModel):
class CheckpointConfig(StrictModel):
directory: str = "runs/deep_cfr/default"
save_every_iteration: bool = True
save_iteration_interval: int = 0
save_latest_only: bool = False
progress_interval_seconds: float = 20.0
@property
def path(self) -> Path:
@@ -118,10 +226,21 @@ class EvaluationConfig(StrictModel):
games: int = 10
opponents: tuple[str, ...] = ("random",)
max_steps: int = 10_000
on_max_steps: str = "score_diff"
@field_validator("on_max_steps")
@classmethod
def _validate_on_max_steps(cls, value: str) -> str:
token = value.strip().lower()
if token not in {"score_diff", "loss", "draw"}:
raise ValueError("must be 'score_diff', 'loss', or 'draw'")
return token
class DeepCFRConfig(StrictModel):
run: RunConfig = Field(default_factory=RunConfig)
rules: RulesConfig = Field(default_factory=RulesConfig)
encoding: EncodingConfig = Field(default_factory=EncodingConfig)
network: NetworkConfig = Field(default_factory=NetworkConfig)
traversal: TraversalConfig = Field(default_factory=TraversalConfig)
self_play: SelfPlayLeagueConfig = Field(default_factory=SelfPlayLeagueConfig)
@@ -94,10 +94,10 @@ def load_strategy_policy_from_checkpoint(
payload = torch.load(checkpoint_path, map_location="cpu")
cfg = config_from_dict(payload["config"])
game_config = LostCitiesConfig(**payload["game_config"])
network = DeepCFRMLP(
network = DeepCFRMLP.from_config(
int(payload["input_dim"]),
int(payload["action_size"]),
cfg.network.hidden_size,
cfg.network,
).to(device)
network.load_state_dict(payload["strategy_network"])
network.eval()
@@ -3,16 +3,46 @@ from __future__ import annotations
import torch
from torch import nn
from coolrl_lost_cities.games.classic.deep_cfr.config import NetworkConfig
def _activation(name: str) -> nn.Module:
token = name.lower()
if token == "relu":
return nn.ReLU()
if token == "gelu":
return nn.GELU()
raise ValueError(f"unsupported activation: {name!r}")
class DeepCFRMLP(nn.Module):
def __init__(self, input_dim: int, output_dim: int, hidden_size: int = 64) -> None:
def __init__(
self,
input_dim: int,
output_dim: int,
hidden_size: int = 64,
*,
num_layers: int = 2,
activation: str = "relu",
) -> None:
super().__init__()
self.net = nn.Sequential(
nn.Linear(input_dim, hidden_size),
nn.ReLU(),
nn.Linear(hidden_size, hidden_size),
nn.ReLU(),
nn.Linear(hidden_size, output_dim),
layers: list[nn.Module] = []
last_dim = 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
layers.append(nn.Linear(last_dim, output_dim))
self.net = nn.Sequential(*layers)
@classmethod
def from_config(cls, input_dim: int, output_dim: int, config: NetworkConfig) -> DeepCFRMLP:
return cls(
input_dim,
output_dim,
config.hidden_size,
num_layers=config.num_layers,
activation=config.activation,
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
@@ -28,6 +28,13 @@ from coolrl_lost_cities.games.classic.deep_cfr.workers import (
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig
def _resolve_torch_device(device: str) -> torch.device:
token = device.strip().lower()
if token == "auto":
token = "cuda" if torch.cuda.is_available() else "cpu"
return torch.device(token)
@dataclass(frozen=True)
class IterationMetrics:
iteration: int
@@ -68,8 +75,10 @@ class DeepCFRTrainer:
device: str = "cpu",
) -> None:
self.config = config or DeepCFRConfig()
self.game_config = game_config or LostCitiesConfig(seed=self.config.run.seed)
self.device = torch.device(device)
self.game_config = game_config or self.config.rules.to_lost_cities_config(
seed=self.config.run.seed
)
self.device = _resolve_torch_device(device)
probe = GameState.new_game(self.game_config, seed=self.config.run.seed)
self.input_dim = input_dim(probe)
@@ -77,20 +86,26 @@ class DeepCFRTrainer:
torch.manual_seed(self.config.run.seed)
self.advantage_networks = [
DeepCFRMLP(self.input_dim, self.action_size, self.config.network.hidden_size).to(
DeepCFRMLP.from_config(self.input_dim, self.action_size, self.config.network).to(
self.device
)
for _ in range(2)
]
self.strategy_network = DeepCFRMLP(
self.input_dim, self.action_size, self.config.network.hidden_size
self.strategy_network = DeepCFRMLP.from_config(
self.input_dim, self.action_size, self.config.network
).to(self.device)
self.advantage_optimizers = [
torch.optim.Adam(network.parameters(), lr=self.config.optimization.learning_rate)
torch.optim.Adam(
network.parameters(),
lr=self.config.optimization.learning_rate,
weight_decay=self.config.optimization.weight_decay,
)
for network in self.advantage_networks
]
self.strategy_optimizer = torch.optim.Adam(
self.strategy_network.parameters(), lr=self.config.optimization.learning_rate
self.strategy_network.parameters(),
lr=self.config.optimization.learning_rate,
weight_decay=self.config.optimization.weight_decay,
)
self.advantage_memory = ReservoirMemory(self.config.memory.advantage_capacity)
self.strategy_memory = ReservoirMemory(self.config.memory.strategy_capacity)
@@ -175,7 +190,7 @@ class DeepCFRTrainer:
store_strategy_on_traverser_nodes=self.config.traversal.store_strategy_on_traverser_nodes,
store_strategy_on_opponent_nodes=self.config.traversal.store_strategy_on_opponent_nodes,
max_depth=self.config.traversal.max_depth,
max_nodes=self.config.traversal.max_nodes,
max_nodes=self.config.traversal.resolved_max_nodes(),
outcome_sampling_epsilon=self.config.traversal.outcome_sampling_epsilon,
outcome_sampling_value_clip=self.config.traversal.outcome_sampling_value_clip,
outcome_unsampled_regret=self.config.traversal.outcome_unsampled_regret,
@@ -195,7 +210,7 @@ class DeepCFRTrainer:
)
for network in self.advantage_networks:
network.eval()
for traversal_index in range(self.config.traversal.traversals_per_iteration):
for traversal_index in range(self.config.traversal.resolved_traversals_per_player()):
for player in range(2):
seed = self.config.run.seed + iteration * 10_000 + traversal_index * 10 + player
state = GameState.new_game(self.game_config, seed=seed)
@@ -227,12 +242,12 @@ class DeepCFRTrainer:
{name: value.detach().cpu() for name, value in network.state_dict().items()}
for network in self.advantage_networks
]
chunk_size = max(1, self.config.traversal.worker_chunk_size)
chunk_size = self.config.traversal.resolved_worker_chunk_size()
batch_index = 0
for player in range(2):
seeds = [
self.config.run.seed + iteration * 10_000 + index * 10 + player
for index in range(self.config.traversal.traversals_per_iteration)
for index in range(self.config.traversal.resolved_traversals_per_player())
]
for start in range(0, len(seeds), chunk_size):
chunk = seeds[start : start + chunk_size]
@@ -275,7 +290,7 @@ class DeepCFRTrainer:
league: list[list[nn.Module]] = []
for snapshot in self.self_play_league_snapshots:
networks = [
DeepCFRMLP(self.input_dim, self.action_size, self.config.network.hidden_size).to(
DeepCFRMLP.from_config(self.input_dim, self.action_size, self.config.network).to(
self.device
)
for _ in range(2)
@@ -293,20 +308,48 @@ class DeepCFRTrainer:
self._start_run_logging()
metrics: list[IterationMetrics] = []
start = self.iteration + 1
stop = self.iteration + self.config.run.iterations
for iteration in range(start, stop + 1):
stop = self._stop_iteration()
run_started = time.perf_counter()
iteration = start
while iteration <= stop:
started = time.perf_counter()
item = self.run_iteration(iteration)
elapsed = time.perf_counter() - started
metrics.append(item)
self._append_metrics(item, elapsed)
self._maybe_record_self_play_snapshot(iteration)
if self.config.checkpoint.save_every_iteration:
checkpoint_dir = self.run_dir
self.save_checkpoint(checkpoint_dir / f"iteration_{iteration:05d}.pt", item)
self.save_checkpoint(checkpoint_dir / "latest.pt", item)
if self._should_save_iteration(iteration):
self._save_iteration_checkpoints(iteration, item)
if self._time_limit_reached(run_started):
break
iteration += 1
return metrics
def _stop_iteration(self) -> int:
if self.config.run.max_iterations is not None:
return max(self.iteration, int(self.config.run.max_iterations))
if self.config.run.max_hours is not None:
return 2**31 - 1
return self.iteration + self.config.run.iterations
def _time_limit_reached(self, run_started: float) -> bool:
if self.config.run.max_hours is None:
return False
elapsed_hours = (time.perf_counter() - run_started) / 3600.0
return elapsed_hours >= self.config.run.max_hours
def _should_save_iteration(self, iteration: int) -> bool:
if self.config.checkpoint.save_every_iteration:
return True
interval = int(self.config.checkpoint.save_iteration_interval)
return interval > 0 and iteration % interval == 0
def _save_iteration_checkpoints(self, iteration: int, item: IterationMetrics) -> None:
checkpoint_dir = self.run_dir
if not self.config.checkpoint.save_latest_only:
self.save_checkpoint(checkpoint_dir / f"iteration_{iteration:05d}.pt", item)
self.save_checkpoint(checkpoint_dir / "latest.pt", item)
def _start_run_logging(self) -> None:
self.run_dir.mkdir(parents=True, exist_ok=True)
config_path = self.run_dir / "config.json"
@@ -371,14 +414,6 @@ class DeepCFRTrainer:
return 0.0
return self._train_strategy(self.strategy_network, self.strategy_optimizer, samples)
def _batch(self, samples: list[TrainingSample], step: int) -> list[TrainingSample]:
batch_size = min(self.config.optimization.batch_size, len(samples))
offset = (step * batch_size) % len(samples)
batch = samples[offset : offset + batch_size]
if len(batch) < batch_size:
batch = batch + samples[0 : batch_size - len(batch)]
return batch
def _batch_tensors(
self,
batch: list[TrainingSample],
@@ -408,10 +443,12 @@ class DeepCFRTrainer:
) -> float:
last_loss = 0.0
network.train()
for _step in range(max(self.config.optimization.advantage_train_steps, 0)):
for _step in range(self.config.optimization.resolved_advantage_train_steps()):
x, y, legal = self._batch_tensors(
self.advantage_memory.sample(
self.config.optimization.batch_size, self.rng, player=player
self.config.optimization.resolved_advantage_batch_size(),
self.rng,
player=player,
)
)
pred = network(x)
@@ -419,6 +456,10 @@ class DeepCFRTrainer:
loss = diff.square().sum() / legal.sum().clamp_min(1)
optimizer.zero_grad(set_to_none=True)
loss.backward()
if self.config.optimization.grad_clip > 0.0:
torch.nn.utils.clip_grad_norm_(
network.parameters(), self.config.optimization.grad_clip
)
optimizer.step()
last_loss = float(loss.detach().cpu())
return last_loss
@@ -431,15 +472,21 @@ class DeepCFRTrainer:
) -> float:
last_loss = 0.0
network.train()
for _step in range(max(self.config.optimization.strategy_train_steps, 0)):
for _step in range(self.config.optimization.resolved_strategy_train_steps()):
x, y, legal = self._batch_tensors(
self.strategy_memory.sample(self.config.optimization.batch_size, self.rng)
self.strategy_memory.sample(
self.config.optimization.resolved_strategy_batch_size(), self.rng
)
)
logits = network(x).masked_fill(~legal, torch.finfo(torch.float32).min)
log_probs = nn.functional.log_softmax(logits, dim=-1).masked_fill(~legal, 0.0)
loss = -(y * log_probs).sum(dim=-1).mean()
optimizer.zero_grad(set_to_none=True)
loss.backward()
if self.config.optimization.grad_clip > 0.0:
torch.nn.utils.clip_grad_norm_(
network.parameters(), self.config.optimization.grad_clip
)
optimizer.step()
last_loss = float(loss.detach().cpu())
return last_loss
@@ -40,7 +40,7 @@ def run_traversal_worker_batch(batch: TraversalWorkerBatch) -> TraversalWorkerRe
cfg = config_from_dict(batch.config)
device = torch.device("cpu")
networks = [
DeepCFRMLP(batch.input_dim, batch.action_size, cfg.network.hidden_size).to(device)
DeepCFRMLP.from_config(batch.input_dim, batch.action_size, cfg.network).to(device)
for _ in range(2)
]
for network, state_dict in zip(networks, batch.advantage_networks, strict=True):
@@ -49,7 +49,7 @@ def run_traversal_worker_batch(batch: TraversalWorkerBatch) -> TraversalWorkerRe
league_networks: list[list[torch.nn.Module]] = []
for snapshot in batch.league_advantage_networks:
snapshot_networks = [
DeepCFRMLP(batch.input_dim, batch.action_size, cfg.network.hidden_size).to(device)
DeepCFRMLP.from_config(batch.input_dim, batch.action_size, cfg.network).to(device)
for _ in range(2)
]
for network, state_dict in zip(snapshot_networks, snapshot, strict=True):
@@ -69,7 +69,7 @@ def run_traversal_worker_batch(batch: TraversalWorkerBatch) -> TraversalWorkerRe
store_strategy_on_traverser_nodes=cfg.traversal.store_strategy_on_traverser_nodes,
store_strategy_on_opponent_nodes=cfg.traversal.store_strategy_on_opponent_nodes,
max_depth=cfg.traversal.max_depth,
max_nodes=cfg.traversal.max_nodes,
max_nodes=cfg.traversal.resolved_max_nodes(),
outcome_sampling_epsilon=cfg.traversal.outcome_sampling_epsilon,
outcome_sampling_value_clip=cfg.traversal.outcome_sampling_value_clip,
outcome_unsampled_regret=cfg.traversal.outcome_unsampled_regret,
@@ -26,6 +26,33 @@ def test_deep_cfr_loads_smoke_yaml_config() -> None:
assert config.checkpoint.directory == "runs/deep_cfr/smoke"
def test_deep_cfr_loads_mapped_legacy_reproduction_config() -> None:
config = load_config(
"configs/deep_cfr/pure_self_play_zero_pit_poc_full_depth_slot_aware_playability.yaml"
)
assert config.run.experiment_name.endswith("slot_aware_playability")
assert config.run.seed == 79
assert config.run.max_iterations is None
assert config.run.max_hours == 4
assert config.encoding.derived_playability is True
assert config.encoding.slot_aware_playability is True
assert config.network.hidden_size == 256
assert config.network.num_layers == 3
assert config.traversal.resolved_traversals_per_player() == 70
assert config.traversal.max_depth is None
assert config.traversal.resolved_max_nodes() == 1000
assert config.traversal.resolved_worker_chunk_size() == 8
assert config.optimization.resolved_advantage_batch_size() == 1024
assert config.optimization.resolved_strategy_batch_size() == 1024
assert config.optimization.resolved_advantage_train_steps() == 256
assert config.optimization.resolved_strategy_train_steps() == 256
assert config.optimization.weight_decay == 0.0001
assert config.optimization.grad_clip == 1.0
assert config.evaluation.on_max_steps == "score_diff"
assert config.checkpoint.save_iteration_interval == 10
def test_deep_cfr_trainer_smoke_run() -> None:
trainer = DeepCFRTrainer(
_deep_cfr_config(