Files
coorl-lost-cities/scripts/analyze_first_open_counterfactual.py
coolguyandClaude Opus 4.7 004b913a7b Rename bot family, curate analyze plots, tier evaluation cadence
Three coordinated hygiene changes; none target the diagnosed
selection-bias bottleneck. They make the codebase honestly reflect the
pure-self-play stance and reduce dashboard noise.

Bot rename (drop the unhelpful safe_ prefix; suffixes describe behaviour):
- safe_heuristic_loose -> heuristic_aggressive
- safe_heuristic       -> heuristic_balanced
- safe_heuristic_strict -> heuristic_cautious
- noisy_safe           -> heuristic_noisy
- passive_discard      -> discard_only

Class renames in bots/: SafeHeuristicBot -> HeuristicBot,
SafeHeuristicParams -> HeuristicParams, PassiveDiscardBot -> DiscardOnlyBot,
plus loose/strict parameter constants. Backwards compatibility was dropped
intentionally per user instruction; no aliases. Active configs, docs,
scripts, tests updated. Archive directories (configs/archive,
docs/archive, runs/archive) left intact and may still reference old
names per their read-only policy. The src/.../bots/passive.py module was
renamed to discard_only.py via git mv.

Analyze plot curation (deep_cfr/analyze.py):
- Added analysis_00_core.png as the canonical daily dashboard with 10
  heuristic-free metrics (loss/{advantage,strategy}; vs heuristic_cautious:
  avg_score_diff0, win_rate0, avg_opened_colors, positive_expedition_rate,
  bonus_expedition_rate, score_per_opened_color, policy_entropy; vs random:
  win_rate0).
- Removed analysis_05_open_quality.png (bad/weak/good open rates,
  recoverable score) and analysis_07_calibration.png (calibration gap,
  recoverable mean) - both relied on the heuristic recoverable_score
  classifier already dropped from inputs.
- Removed SELECTIVITY_PLOTS and plot_selectivity (heuristic-laden).
- SUMMARY_EVAL_METRICS no longer includes bad_open_rate or
  calibration_gap.
- PlotSpec gained an opponents allowlist so the new core section can pin
  a specific opponent per panel without restructuring plot_section.

Tiered evaluation cadence (EvaluationConfig):
- Added extended_opponents and extended_eval_every (default 0 = disabled).
- opponents_for_iteration(iteration) returns the core list every
  eval_every and appends extended_opponents (de-duplicated) when
  iteration is also a multiple of extended_eval_every.
- default.yaml now uses 3 core opponents (random, discard_only,
  heuristic_cautious) every 5 iterations and 3 extended opponents
  (heuristic_balanced, heuristic_aggressive, heuristic_noisy) every 50
  iterations. random is the floor sanity. discard_only is the
  zero-pit detector / absolute-score reference (its score is always 0,
  so eval/discard_only/avg_score_diff0 directly equals the model's raw
  average score). heuristic_cautious is the ceiling and the
  archive-comparable benchmark used in the prior diagnostic sections.

Net eval cost reduction: roughly 50% (3 opponents x every 5 iter, plus
6 opponents x every 50 iter, vs the prior 6 x every 5).

Documented in docs/plans/deep-cfr-selectivity.md section 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:32:55 +09:00

410 lines
15 KiB
Python

#!/usr/bin/env python
"""Compare first-open heuristic labels against forced-action continuation value."""
from __future__ import annotations
import argparse
import json
import time
from collections import defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
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 coolrl_lost_cities.games.classic.bots import build_bot
from coolrl_lost_cities.games.classic.deep_cfr.config import config_from_dict
from coolrl_lost_cities.games.classic.deep_cfr.networks import DeepCFRMLP
@dataclass
class Bucket:
deltas: list[float] = field(default_factory=list)
open_values: list[float] = field(default_factory=list)
baseline_values: list[float] = field(default_factory=list)
policy_probs: list[float] = field(default_factory=list)
selected: int = 0
def add(
self,
*,
delta: float,
open_value: float,
baseline_value: float,
policy_prob: float,
selected: bool,
) -> None:
self.deltas.append(float(delta))
self.open_values.append(float(open_value))
self.baseline_values.append(float(baseline_value))
self.policy_probs.append(float(policy_prob))
self.selected += int(selected)
def to_dict(self) -> dict[str, float | int]:
return {
"count": len(self.deltas),
"delta_mean": _mean(self.deltas),
"delta_p10": _percentile(self.deltas, 10),
"delta_p25": _percentile(self.deltas, 25),
"delta_p50": _percentile(self.deltas, 50),
"delta_p75": _percentile(self.deltas, 75),
"delta_p90": _percentile(self.deltas, 90),
"delta_positive_rate": _positive_rate(self.deltas),
"open_value_mean": _mean(self.open_values),
"baseline_value_mean": _mean(self.baseline_values),
"policy_prob_mean": _mean(self.policy_probs),
"selected": self.selected,
"selected_rate": self.selected / max(1, len(self.deltas)),
}
def _mean(values: list[float]) -> float:
return float(np.mean(values)) if values else 0.0
def _percentile(values: list[float], percentile: float) -> float:
return float(np.percentile(values, percentile)) if values else 0.0
def _positive_rate(values: list[float]) -> float:
if not values:
return 0.0
return sum(1 for value in values if value > 0.0) / len(values)
def _numeric_value(card: Any, min_rank: int) -> int:
if card.rank == 0:
return 0
return min_rank + card.rank - 1
def _open_quality(state: GameState, player: int, color: int) -> str:
expedition = state.expeditions[player][color]
hand_cards = [
card for card in state.hand_slots(player) if card is not None and card.color == color
]
last_numeric = state.last_numeric_rank(player, color)
current_sum = sum(_numeric_value(card, state.config.min_rank) for card in expedition)
current_wagers = sum(1 for card in expedition if card.rank == 0)
playable_numeric = [card for card in hand_cards if card.rank > 0 and card.rank > last_numeric]
playable_wagers = [card for card in hand_cards if card.rank == 0 and last_numeric == 0]
projected_sum = current_sum + sum(
_numeric_value(card, state.config.min_rank) for card in playable_numeric
)
projected_wagers = current_wagers + len(playable_wagers)
projected_len = len(expedition) + len(playable_numeric) + len(playable_wagers)
recoverable_score = (projected_sum + state.config.expedition_penalty) * (projected_wagers + 1)
if recoverable_score >= 0:
return "open_good"
if projected_len >= state.config.bonus_threshold:
return "open_weak"
return "open_bad"
def _classify_action(state: GameState, unified_action: int, player: int) -> str:
card_action_size = state.config.hand_size * 2
if unified_action >= card_action_size:
return "draw_deck" if unified_action == card_action_size else "draw_pile"
if unified_action % 2 == 1:
return "discard"
card = state.hand_slots(player)[unified_action // 2]
if card is None:
return "invalid_play"
color = int(card.color)
if state.expeditions[player][color]:
return "play_existing"
return _open_quality(state, player, color)
def _regret_matching(
advantages: np.ndarray,
legal: np.ndarray,
*,
epsilon: float,
fallback: str,
) -> np.ndarray:
legal_actions = np.flatnonzero(legal)
policy = np.zeros_like(advantages, dtype=np.float32)
if len(legal_actions) == 0:
return policy
positive = np.where(legal, np.maximum(advantages, 0.0), 0.0).astype(np.float32)
total = float(positive.sum())
if total > epsilon:
return positive / total
if fallback == "uniform":
policy[legal_actions] = 1.0 / float(len(legal_actions))
return policy
best = float(np.max(advantages[legal_actions]))
best_actions = legal_actions[advantages[legal_actions] == best]
policy[int(best_actions[0])] = 1.0
return policy
class AdvantagePolicy:
def __init__(
self,
networks: list[torch.nn.Module],
*,
device: torch.device,
encoding: Any,
epsilon: float,
fallback: str,
) -> None:
self.networks = networks
self.device = device
self.encoding = encoding
self.epsilon = epsilon
self.fallback = fallback
def distribution(self, state: GameState) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
player = int(state.current_player)
legal = np.asarray(state.unified_legal_mask(), dtype=bool)
info = encode_info_state(state, player, self.encoding)
with torch.inference_mode():
x = torch.as_tensor(info, dtype=torch.float32, device=self.device).unsqueeze(0)
advantages = self.networks[player](x).squeeze(0).detach().cpu().numpy()
policy = _regret_matching(
advantages,
legal,
epsilon=self.epsilon,
fallback=self.fallback,
)
return np.flatnonzero(legal), policy, advantages
def select_unified(self, state: GameState) -> int:
legal_actions, policy, advantages = self.distribution(state)
if len(legal_actions) == 0:
raise RuntimeError("no legal action available")
probs = policy[legal_actions]
if float(probs.sum()) > 0.0:
return int(legal_actions[int(np.argmax(probs))])
return int(legal_actions[int(np.argmax(advantages[legal_actions]))])
def _load_checkpoint(
checkpoint: Path,
device: torch.device,
) -> tuple[Any, LostCitiesConfig, AdvantagePolicy, int]:
payload = torch.load(checkpoint, map_location="cpu")
cfg = config_from_dict(payload["config"])
game_config = LostCitiesConfig(**payload["game_config"])
action_size = int(payload["action_size"])
networks = [
DeepCFRMLP.from_config(int(payload["input_dim"]), action_size, cfg.network).to(device)
for _ in range(2)
]
for network, state_dict in zip(networks, payload["advantage_networks"], strict=True):
network.load_state_dict(state_dict)
network.eval()
policy = AdvantagePolicy(
networks,
device=device,
encoding=cfg.encoding,
epsilon=cfg.traversal.regret_matching_epsilon,
fallback=cfg.regret_matching.all_negative_fallback,
)
return cfg, game_config, policy, int(payload.get("iteration", -1))
def _rollout_value(
state: GameState,
*,
policy_player: int,
policy: AdvantagePolicy,
opponent: str,
seed: int,
max_steps: int,
post_policy: str = "model",
) -> float:
"""Roll out from `state` to terminal and return policy_player's score diff.
When `post_policy == "model"`, policy_player uses the trained advantage
network policy. Otherwise `post_policy` is treated as a bot name and a
fresh bot is built for policy_player too — used to diagnose whether the
self-play rollout itself is poisoning forced-open continuation values.
"""
rollout = state.clone()
opponent_policy = build_bot(opponent, seed=seed)
post_policy_bot = build_bot(post_policy, seed=seed * 7 + 1) if post_policy != "model" else None
steps = 0
while not rollout.terminal and steps < max_steps:
current = int(rollout.current_player)
if current == policy_player:
if post_policy_bot is not None:
action = post_policy_bot.act(rollout)
else:
unified = policy.select_unified(rollout)
action = rollout.from_unified_action(unified)
else:
action = opponent_policy.act(rollout)
rollout.apply_action(action)
steps += 1
return float(rollout.score_diff(policy_player))
def _best_non_open_action(
labels: dict[int, str],
legal_actions: np.ndarray,
policy_probs: np.ndarray,
advantages: np.ndarray,
) -> int | None:
candidates = [
int(action) for action in legal_actions if not labels[int(action)].startswith("open_")
]
if not candidates:
return None
return max(
candidates,
key=lambda action: (float(policy_probs[action]), float(advantages[action]), -action),
)
def analyze_checkpoint(
checkpoint: Path,
*,
games: int,
seed: int,
opponent: str,
device: torch.device,
max_steps: int,
max_candidates: int,
post_policy: str = "model",
) -> dict[str, Any]:
_cfg, game_config, policy, iteration = _load_checkpoint(checkpoint, device)
buckets: dict[str, Bucket] = defaultdict(Bucket)
candidate_states = 0
first_open_candidates = 0
evaluated_open_candidates = 0
policy_turns = 0
started = time.perf_counter()
for game_index in range(games):
if evaluated_open_candidates >= max_candidates:
break
game_seed = seed + game_index
swap = game_index % 2 == 1
policy_player = 1 if swap else 0
opponent_policy = build_bot(opponent, seed=game_seed * 2 + (1 - policy_player))
state = GameState.new_game(game_config, seed=game_seed)
for _step in range(max_steps):
if state.terminal or evaluated_open_candidates >= max_candidates:
break
current = int(state.current_player)
if current != policy_player:
state.apply_action(opponent_policy.act(state))
continue
policy_turns += 1
legal_actions, policy_probs, advantages = policy.distribution(state)
labels = {
int(action): _classify_action(state, int(action), current)
for action in legal_actions
}
open_actions = [
int(action) for action, label in labels.items() if label.startswith("open_")
]
best_non_open = _best_non_open_action(labels, legal_actions, policy_probs, advantages)
selected = policy.select_unified(state)
if open_actions and best_non_open is not None:
candidate_states += 1
baseline_state = state.clone()
baseline_state.apply_action(baseline_state.from_unified_action(best_non_open))
baseline_value = _rollout_value(
baseline_state,
policy_player=policy_player,
policy=policy,
opponent=opponent,
seed=game_seed * 10_000 + candidate_states * 101 + 1,
max_steps=max_steps,
post_policy=post_policy,
)
for open_action in open_actions:
if evaluated_open_candidates >= max_candidates:
break
open_state = state.clone()
open_state.apply_action(open_state.from_unified_action(open_action))
open_value = _rollout_value(
open_state,
policy_player=policy_player,
policy=policy,
opponent=opponent,
seed=game_seed * 10_000 + candidate_states * 101 + 2,
max_steps=max_steps,
post_policy=post_policy,
)
label = labels[open_action]
buckets[label].add(
delta=open_value - baseline_value,
open_value=open_value,
baseline_value=baseline_value,
policy_prob=float(policy_probs[open_action]),
selected=open_action == selected,
)
first_open_candidates += 1
evaluated_open_candidates += 1
state.apply_action(state.from_unified_action(selected))
return {
"checkpoint": str(checkpoint),
"iteration": iteration,
"opponent": opponent,
"games": games,
"seed": seed,
"device": str(device),
"max_candidates": max_candidates,
"elapsed_seconds": time.perf_counter() - started,
"policy_turns": policy_turns,
"candidate_states": candidate_states,
"first_open_candidates": first_open_candidates,
"post_policy": post_policy,
"buckets": {key: bucket.to_dict() for key, bucket in sorted(buckets.items())},
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("checkpoints", nargs="+", type=Path)
parser.add_argument("--opponent", default="heuristic_cautious")
parser.add_argument("--games", type=int, default=100)
parser.add_argument("--seed", type=int, default=231_000)
parser.add_argument("--device", default="cuda")
parser.add_argument("--max-steps", type=int, default=10_000)
parser.add_argument("--max-candidates", type=int, default=500)
parser.add_argument(
"--post-policy",
default="model",
help=(
"Policy used for the policy_player during forced-action rollouts. "
"'model' uses the trained advantage network; any other value is "
"treated as a bot name (e.g. 'heuristic_cautious')."
),
)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
device = torch.device(args.device)
args.output.parent.mkdir(parents=True, exist_ok=True)
rows = []
for checkpoint in args.checkpoints:
row = analyze_checkpoint(
checkpoint,
games=args.games,
seed=args.seed,
opponent=args.opponent,
device=device,
max_steps=args.max_steps,
max_candidates=args.max_candidates,
post_policy=args.post_policy,
)
rows.append(row)
print(json.dumps(row, sort_keys=True))
args.output.write_text("\n".join(json.dumps(row, sort_keys=True) for row in rows) + "\n")
print(f"wrote {args.output}")
if __name__ == "__main__":
main()