Deep CFR 재귀 traversal 구현

This commit is contained in:
2026-05-06 23:35:46 +09:00
parent 966cfa3a13
commit e802efabc3
5 changed files with 377 additions and 66 deletions
@@ -7,8 +7,12 @@ from dataclasses import dataclass
class DeepCFRConfig:
iterations: int = 1
traversals_per_iteration: int = 2
rollouts_per_action: int = 1
max_rollout_steps: int = 512
max_traversal_depth: int | None = 8
max_nodes_per_traversal: int | None = 10_000
regret_matching_epsilon: float = 1.0e-8
strategy_sample_interval: int = 1
store_strategy_on_traverser_nodes: bool = True
store_strategy_on_opponent_nodes: bool = True
advantage_train_steps: int = 1
strategy_train_steps: int = 1
batch_size: int = 32
@@ -9,6 +9,7 @@ import numpy as np
class TrainingSample:
info_state: np.ndarray
target: np.ndarray
legal_mask: np.ndarray
iteration: int
player: int
@@ -6,12 +6,11 @@ import numpy as np
import torch
from torch import nn
from coolrl_lost_cities.games.classic.deep_cfr.cfr_math import regret_matching
from coolrl_lost_cities.games.classic.deep_cfr.config import DeepCFRConfig
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 input_dim
from coolrl_lost_cities.games.classic.deep_cfr.memory import ReservoirMemory, TrainingSample
from coolrl_lost_cities.games.classic.deep_cfr.networks import DeepCFRMLP
from coolrl_lost_cities.games.classic.deep_cfr.traversal import root_action_values
from coolrl_lost_cities.games.classic.deep_cfr.traverser import DeepCFRTraverser, TraversalStats
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig
@@ -22,6 +21,11 @@ class IterationMetrics:
strategy_samples: int
advantage_loss: float
strategy_loss: float
traversal_nodes: int
traversal_terminals: int
traversal_depth_cutoffs: int
traversal_node_limit_cutoffs: int
traversal_max_depth_reached: int
class DeepCFRTrainer:
@@ -57,37 +61,32 @@ class DeepCFRTrainer:
)
self.advantage_memory = ReservoirMemory()
self.strategy_memory = ReservoirMemory()
self.rng = np.random.default_rng(self.config.seed + 101)
def run_iteration(self, iteration: int) -> IterationMetrics:
total_stats = TraversalStats()
traverser = DeepCFRTraverser(
self.advantage_networks,
self.advantage_memory,
self.strategy_memory,
device=self.device,
action_size=self.action_size,
epsilon=self.config.regret_matching_epsilon,
strategy_sample_interval=self.config.strategy_sample_interval,
store_strategy_on_traverser_nodes=self.config.store_strategy_on_traverser_nodes,
store_strategy_on_opponent_nodes=self.config.store_strategy_on_opponent_nodes,
max_depth=self.config.max_traversal_depth,
max_nodes=self.config.max_nodes_per_traversal,
rng=self.rng,
)
for network in self.advantage_networks:
network.eval()
for traversal_index in range(self.config.traversals_per_iteration):
for player in range(2):
seed = self.config.seed + iteration * 10_000 + traversal_index * 10 + player
state = GameState.new_game(self.game_config, seed=seed)
info_state = encode_info_state(state, player)
values, legal = root_action_values(
state,
player,
seed=seed,
rollouts_per_action=self.config.rollouts_per_action,
max_steps=self.config.max_rollout_steps,
)
legal_bool = legal.astype(bool)
advantages = np.zeros_like(values, dtype=np.float32)
if np.any(legal_bool):
baseline = float(np.mean(values[legal_bool]))
advantages[legal_bool] = values[legal_bool] - baseline
policy = regret_matching(advantages, legal)
self.advantage_memory.add(
TrainingSample(
info_state=info_state, target=advantages, iteration=iteration, player=player
)
)
self.strategy_memory.add(
TrainingSample(
info_state=info_state, target=policy, iteration=iteration, player=player
)
)
_, stats = traverser.traverse(state, player, iteration)
total_stats.accumulate(stats)
advantage_loss = self._train_advantage_networks()
strategy_loss = self._train_strategy_network()
@@ -97,6 +96,11 @@ class DeepCFRTrainer:
strategy_samples=len(self.strategy_memory),
advantage_loss=advantage_loss,
strategy_loss=strategy_loss,
traversal_nodes=total_stats.nodes,
traversal_terminals=total_stats.terminals,
traversal_depth_cutoffs=total_stats.depth_cutoffs,
traversal_node_limit_cutoffs=total_stats.node_limit_cutoffs,
traversal_max_depth_reached=total_stats.max_depth_reached,
)
def train(self) -> list[IterationMetrics]:
@@ -109,12 +113,7 @@ class DeepCFRTrainer:
if not samples:
continue
losses.append(
self._train_supervised(
network,
self.advantage_optimizers[player],
samples,
self.config.advantage_train_steps,
)
self._train_advantage(network, self.advantage_optimizers[player], samples)
)
return float(np.mean(losses)) if losses else 0.0
@@ -122,39 +121,70 @@ class DeepCFRTrainer:
samples = self.strategy_memory.all()
if not samples:
return 0.0
return self._train_supervised(
self.strategy_network,
self.strategy_optimizer,
samples,
self.config.strategy_train_steps,
)
return self._train_strategy(self.strategy_network, self.strategy_optimizer, samples)
def _train_supervised(
def _batch(self, samples: list[TrainingSample], step: int) -> list[TrainingSample]:
batch_size = min(self.config.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],
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
x = torch.as_tensor(
np.stack([sample.info_state for sample in batch]),
dtype=torch.float32,
device=self.device,
)
y = torch.as_tensor(
np.stack([sample.target 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,
)
return x, y, legal
def _train_advantage(
self,
network: nn.Module,
optimizer: torch.optim.Optimizer,
samples: list[TrainingSample],
steps: int,
) -> float:
last_loss = 0.0
batch_size = min(self.config.batch_size, len(samples))
for step in range(max(steps, 0)):
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)]
x = torch.as_tensor(
np.stack([sample.info_state for sample in batch]),
dtype=torch.float32,
device=self.device,
)
y = torch.as_tensor(
np.stack([sample.target for sample in batch]),
dtype=torch.float32,
device=self.device,
)
network.train()
for step in range(max(self.config.advantage_train_steps, 0)):
x, y, legal = self._batch_tensors(self._batch(samples, step))
pred = network(x)
diff = (pred - y).masked_fill(~legal, 0.0)
loss = diff.square().sum() / legal.sum().clamp_min(1)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
last_loss = float(loss.detach().cpu())
return last_loss
def _train_strategy(
self,
network: nn.Module,
optimizer: torch.optim.Optimizer,
samples: list[TrainingSample],
) -> float:
last_loss = 0.0
network.train()
for step in range(max(self.config.strategy_train_steps, 0)):
x, y, legal = self._batch_tensors(self._batch(samples, step))
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 = nn.functional.mse_loss(network(x), y)
loss.backward()
optimizer.step()
last_loss = float(loss.detach().cpu())
@@ -0,0 +1,231 @@
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
import torch
from coolrl_lost_cities.games.classic.deep_cfr.cfr_math import regret_matching
from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state
from coolrl_lost_cities.games.classic.deep_cfr.memory import ReservoirMemory, TrainingSample
from coolrl_lost_cities.games.classic.game import GameState
@dataclass
class TraversalStats:
nodes: int = 0
terminals: int = 0
depth_cutoffs: int = 0
node_limit_cutoffs: int = 0
max_depth_reached: int = 0
advantage_samples: int = 0
strategy_samples: int = 0
sampled_actions: int = 0
endpoint_depth_sum: int = 0
endpoint_depth_buckets: dict[str, int] = field(default_factory=dict)
def accumulate(self, other: TraversalStats) -> None:
self.nodes += other.nodes
self.terminals += other.terminals
self.depth_cutoffs += other.depth_cutoffs
self.node_limit_cutoffs += other.node_limit_cutoffs
self.max_depth_reached = max(self.max_depth_reached, other.max_depth_reached)
self.advantage_samples += other.advantage_samples
self.strategy_samples += other.strategy_samples
self.sampled_actions += other.sampled_actions
self.endpoint_depth_sum += other.endpoint_depth_sum
for key, value in other.endpoint_depth_buckets.items():
self.endpoint_depth_buckets[key] = self.endpoint_depth_buckets.get(key, 0) + value
@property
def endpoints(self) -> int:
return self.terminals + self.depth_cutoffs + self.node_limit_cutoffs
@property
def avg_endpoint_depth(self) -> float:
return self.endpoint_depth_sum / max(1, self.endpoints)
def to_dict(self) -> dict[str, float | int]:
return {
"traversal_nodes": self.nodes,
"traversal_terminals": self.terminals,
"traversal_depth_cutoffs": self.depth_cutoffs,
"traversal_node_limit_cutoffs": self.node_limit_cutoffs,
"traversal_max_depth_reached": self.max_depth_reached,
"traversal_advantage_samples": self.advantage_samples,
"traversal_strategy_samples": self.strategy_samples,
"traversal_sampled_actions": self.sampled_actions,
"traversal_avg_endpoint_depth": self.avg_endpoint_depth,
**{
f"traversal_endpoint_depth_bucket_{key}": value
for key, value in self.endpoint_depth_buckets.items()
},
}
class DeepCFRTraverser:
def __init__(
self,
advantage_networks: list[torch.nn.Module],
advantage_memory: ReservoirMemory,
strategy_memory: ReservoirMemory,
*,
device: torch.device,
action_size: int,
epsilon: float = 1.0e-8,
strategy_sample_interval: int = 1,
store_strategy_on_traverser_nodes: bool = True,
store_strategy_on_opponent_nodes: bool = True,
max_depth: int | None = None,
max_nodes: int | None = None,
rng: np.random.Generator | None = None,
) -> None:
self.advantage_networks = advantage_networks
self.advantage_memory = advantage_memory
self.strategy_memory = strategy_memory
self.device = device
self.action_size = action_size
self.epsilon = float(epsilon)
self.strategy_sample_interval = max(1, int(strategy_sample_interval))
self.store_strategy_on_traverser_nodes = store_strategy_on_traverser_nodes
self.store_strategy_on_opponent_nodes = store_strategy_on_opponent_nodes
self.max_depth = max_depth
self.max_nodes = max_nodes
self.rng = rng or np.random.default_rng()
def traverse(
self, state: GameState, traverser: int, iteration: int
) -> tuple[float, TraversalStats]:
stats = TraversalStats()
value = self._traverse(state, traverser, iteration, depth=0, stats=stats)
return value, stats
def _traverse(
self,
state: GameState,
traverser: int,
iteration: int,
*,
depth: int,
stats: TraversalStats,
) -> float:
stats.nodes += 1
stats.max_depth_reached = max(stats.max_depth_reached, depth)
if self.max_nodes is not None and stats.nodes >= self.max_nodes:
stats.node_limit_cutoffs += 1
self._record_endpoint(stats, depth)
return float(state.score_diff(traverser))
if state.terminal:
stats.terminals += 1
self._record_endpoint(stats, depth)
return float(state.score_diff(traverser))
if self.max_depth is not None and depth >= self.max_depth:
stats.depth_cutoffs += 1
self._record_endpoint(stats, depth)
return float(state.score_diff(traverser))
player = state.current_player
info_state, legal, policy = self._policy(state, player)
self._record_strategy(info_state, legal, policy, player, traverser, iteration, depth, stats)
legal_actions = np.flatnonzero(legal)
if len(legal_actions) == 0:
stats.terminals += 1
self._record_endpoint(stats, depth)
return float(state.score_diff(traverser))
action = self._sample_action(policy, legal_actions)
local_action = state.from_unified_action(int(action))
state.push_action(local_action)
try:
child_value = self._traverse(
state,
traverser,
iteration,
depth=depth + 1,
stats=stats,
)
finally:
state.pop_action()
stats.sampled_actions += 1
action_prob = max(float(policy[action]), self.epsilon)
sampled_action_value = child_value / action_prob
node_value = float(policy[action]) * sampled_action_value
if player == traverser:
regrets = np.where(legal, -node_value, 0.0).astype(np.float32)
regrets[action] = np.float32(sampled_action_value - node_value)
self.advantage_memory.add(
TrainingSample(
info_state=info_state,
target=regrets,
legal_mask=legal,
iteration=iteration,
player=player,
)
)
stats.advantage_samples += 1
return node_value
def _policy(self, state: GameState, player: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
info_state = encode_info_state(state, player)
legal = np.asarray(state.unified_legal_mask(), dtype=bool)
with torch.inference_mode():
x = torch.as_tensor(info_state, dtype=torch.float32, device=self.device).unsqueeze(0)
advantages = (
self.advantage_networks[player](x)
.squeeze(0)
.detach()
.cpu()
.numpy()
.astype(np.float32)
)
policy = regret_matching(advantages, legal, self.epsilon).astype(np.float32)
return info_state, legal, policy
def _sample_action(self, policy: np.ndarray, legal_actions: np.ndarray) -> int:
probs = policy[legal_actions].astype(np.float64)
total = float(probs.sum())
if total <= 0.0:
probs = np.full(len(legal_actions), 1.0 / len(legal_actions), dtype=np.float64)
else:
probs /= total
return int(self.rng.choice(legal_actions, p=probs))
def _record_strategy(
self,
info_state: np.ndarray,
legal: np.ndarray,
policy: np.ndarray,
player: int,
traverser: int,
iteration: int,
depth: int,
stats: TraversalStats,
) -> None:
if player == traverser:
if not self.store_strategy_on_traverser_nodes:
return
elif not self.store_strategy_on_opponent_nodes:
return
if depth % self.strategy_sample_interval != 0:
return
self.strategy_memory.add(
TrainingSample(
info_state=info_state,
target=policy,
legal_mask=legal,
iteration=iteration,
player=player,
)
)
stats.strategy_samples += 1
def _record_endpoint(self, stats: TraversalStats, depth: int) -> None:
stats.endpoint_depth_sum += depth
start = min(depth // 10 * 10, 100)
key = "100_plus" if start >= 100 else f"{start}_{start + 9}"
stats.endpoint_depth_buckets[key] = stats.endpoint_depth_buckets.get(key, 0) + 1
+50 -5
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
from coolrl_lost_cities.games.classic.game import LostCitiesConfig
import numpy as np
from coolrl_lost_cities.games.classic.game import GameState, LostCitiesConfig
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.deep_cfr.traverser import DeepCFRTraverser
def test_deep_cfr_trainer_smoke_run() -> None:
@@ -11,8 +13,8 @@ def test_deep_cfr_trainer_smoke_run() -> None:
DeepCFRConfig(
iterations=1,
traversals_per_iteration=1,
rollouts_per_action=1,
max_rollout_steps=64,
max_traversal_depth=3,
max_nodes_per_traversal=64,
advantage_train_steps=1,
strategy_train_steps=1,
batch_size=2,
@@ -25,7 +27,50 @@ def test_deep_cfr_trainer_smoke_run() -> None:
metrics = trainer.train()
assert len(metrics) == 1
assert metrics[0].advantage_samples == 2
assert metrics[0].strategy_samples == 2
assert metrics[0].advantage_samples > 0
assert metrics[0].strategy_samples > 0
assert metrics[0].traversal_nodes > 0
assert metrics[0].traversal_max_depth_reached <= 3
assert metrics[0].advantage_loss >= 0.0
assert metrics[0].strategy_loss >= 0.0
def test_deep_cfr_recursive_traverser_restores_state_and_collects_samples() -> None:
trainer = DeepCFRTrainer(
DeepCFRConfig(
iterations=1,
traversals_per_iteration=1,
max_traversal_depth=2,
max_nodes_per_traversal=32,
batch_size=2,
hidden_size=16,
seed=29,
),
LostCitiesConfig(seed=29),
)
state = GameState.new_game(LostCitiesConfig(seed=29), seed=29)
before = state.to_snapshot()
traverser = DeepCFRTraverser(
trainer.advantage_networks,
trainer.advantage_memory,
trainer.strategy_memory,
device=trainer.device,
action_size=trainer.action_size,
max_depth=2,
max_nodes=32,
rng=np.random.default_rng(29),
)
value, stats = traverser.traverse(state, traverser=0, iteration=1)
assert isinstance(value, float)
assert state.to_snapshot() == before
assert stats.nodes > 0
assert stats.depth_cutoffs + stats.terminals + stats.node_limit_cutoffs > 0
assert stats.strategy_samples > 0
assert stats.advantage_samples > 0
assert len(trainer.strategy_memory) == stats.strategy_samples
assert len(trainer.advantage_memory) == stats.advantage_samples
sample = trainer.advantage_memory.all()[0]
assert sample.legal_mask.dtype == bool
assert sample.target.shape == sample.legal_mask.shape