Files
coorl-lost-cities/scripts/analyze_first_open_followup.py
T
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

313 lines
12 KiB
Python

#!/usr/bin/env python
"""Inspect model's post-forced-open behavior over the next K policy_player turns.
Diagnoses *why* forced-open continuation values are poor under self-play
rollouts: does the model play followup cards of the opened color, discard
them, or open another color instead?
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from collections import Counter, 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.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
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from analyze_first_open_counterfactual import ( # noqa: E402
AdvantagePolicy,
_classify_action,
)
@dataclass
class FollowupBucket:
candidates: int = 0
same_color_plays: list[int] = field(default_factory=list)
same_color_discards: list[int] = field(default_factory=list)
other_open_new: list[int] = field(default_factory=list)
other_play_existing: list[int] = field(default_factory=list)
other_discard: list[int] = field(default_factory=list)
draw_deck: list[int] = field(default_factory=list)
draw_pile: list[int] = field(default_factory=list)
same_color_held_at_force: list[int] = field(default_factory=list)
same_color_played_in_window: list[int] = field(default_factory=list)
same_color_discarded_in_window: list[int] = field(default_factory=list)
final_score_diff: list[float] = field(default_factory=list)
same_color_held_terminal: list[int] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
def stats(values: list[float] | list[int]) -> dict[str, float]:
if not values:
return {"mean": 0.0, "median": 0.0}
return {
"mean": float(np.mean(values)),
"median": float(np.median(values)),
}
return {
"candidates": self.candidates,
"same_color_plays": stats(self.same_color_plays),
"same_color_discards": stats(self.same_color_discards),
"other_open_new": stats(self.other_open_new),
"other_play_existing": stats(self.other_play_existing),
"other_discard": stats(self.other_discard),
"draw_deck": stats(self.draw_deck),
"draw_pile": stats(self.draw_pile),
"same_color_held_at_force": stats(self.same_color_held_at_force),
"same_color_played_in_window": stats(self.same_color_played_in_window),
"same_color_discarded_in_window": stats(self.same_color_discarded_in_window),
"same_color_held_terminal": stats(self.same_color_held_terminal),
"final_score_diff": stats(self.final_score_diff),
}
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 _count_color_cards_in_hand(state: GameState, player: int, color: int) -> int:
return sum(
1 for card in state.hand_slots(player) if card is not None and int(card.color) == color
)
def _label_followup_action(
state: GameState, unified_action: int, player: int, forced_color: int
) -> str:
"""Categorise a followup action relative to the previously forced-open color."""
card_action_size = state.config.hand_size * 2
if unified_action == card_action_size:
return "draw_deck"
if unified_action > card_action_size:
return "draw_pile"
if unified_action % 2 == 1:
slot = unified_action // 2
card = state.hand_slots(player)[slot]
if card is None:
return "discard_invalid"
return "discard_same" if int(card.color) == forced_color else "discard_other"
slot = unified_action // 2
card = state.hand_slots(player)[slot]
if card is None:
return "play_invalid"
color = int(card.color)
is_open_action = not state.expeditions[player][color]
if color == forced_color:
return "play_same"
return "open_other" if is_open_action else "play_other_existing"
def _force_and_observe(
base_state: GameState,
*,
forced_action: int,
forced_color: int,
bucket: FollowupBucket,
policy: AdvantagePolicy,
opponent_policy: Any,
policy_player: int,
window: int,
max_steps: int,
) -> None:
rollout = base_state.clone()
same_at_force = _count_color_cards_in_hand(rollout, policy_player, forced_color)
rollout.apply_action(rollout.from_unified_action(forced_action))
counts: Counter[str] = Counter()
policy_turns_seen = 0
same_color_plays = 0
same_color_discards = 0
steps = 0
while not rollout.terminal and steps < max_steps and policy_turns_seen < window:
current = int(rollout.current_player)
if current != policy_player:
rollout.apply_action(opponent_policy.act(rollout))
steps += 1
continue
unified = policy.select_unified(rollout)
label = _label_followup_action(rollout, unified, policy_player, forced_color)
counts[label] += 1
if label == "play_same":
same_color_plays += 1
elif label == "discard_same":
same_color_discards += 1
rollout.apply_action(rollout.from_unified_action(unified))
steps += 1
policy_turns_seen += 1
while not rollout.terminal and steps < max_steps:
current = int(rollout.current_player)
if current == policy_player:
unified = policy.select_unified(rollout)
rollout.apply_action(rollout.from_unified_action(unified))
else:
rollout.apply_action(opponent_policy.act(rollout))
steps += 1
bucket.candidates += 1
bucket.same_color_plays.append(counts.get("play_same", 0))
bucket.same_color_discards.append(counts.get("discard_same", 0))
bucket.other_open_new.append(counts.get("open_other", 0))
bucket.other_play_existing.append(counts.get("play_other_existing", 0))
bucket.other_discard.append(counts.get("discard_other", 0))
bucket.draw_deck.append(counts.get("draw_deck", 0))
bucket.draw_pile.append(counts.get("draw_pile", 0))
bucket.same_color_held_at_force.append(same_at_force)
bucket.same_color_played_in_window.append(same_color_plays)
bucket.same_color_discarded_in_window.append(same_color_discards)
bucket.same_color_held_terminal.append(
_count_color_cards_in_hand(rollout, policy_player, forced_color)
)
bucket.final_score_diff.append(float(rollout.score_diff(policy_player)))
def analyze_checkpoint(
checkpoint: Path,
*,
games: int,
seed: int,
opponent: str,
device: torch.device,
max_steps: int,
max_candidates: int,
window: int,
) -> dict[str, Any]:
_cfg, game_config, policy, iteration = _load_checkpoint(checkpoint, device)
buckets: dict[str, FollowupBucket] = defaultdict(FollowupBucket)
candidates_evaluated = 0
started = time.perf_counter()
for game_index in range(games):
if candidates_evaluated >= 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 candidates_evaluated >= max_candidates:
break
current = int(state.current_player)
if current != policy_player:
state.apply_action(opponent_policy.act(state))
continue
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_")
]
if open_actions:
for open_action in open_actions:
if candidates_evaluated >= max_candidates:
break
color = int(state.hand_slots(current)[open_action // 2].color)
bucket = buckets[labels[open_action]]
_force_and_observe(
state,
forced_action=open_action,
forced_color=color,
bucket=bucket,
policy=policy,
opponent_policy=opponent_policy,
policy_player=policy_player,
window=window,
max_steps=max_steps,
)
candidates_evaluated += 1
unified = policy.select_unified(state)
state.apply_action(state.from_unified_action(unified))
return {
"checkpoint": str(checkpoint),
"iteration": iteration,
"opponent": opponent,
"games": games,
"seed": seed,
"device": str(device),
"max_candidates": max_candidates,
"window": window,
"elapsed_seconds": time.perf_counter() - started,
"candidates_evaluated": candidates_evaluated,
"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=232_000)
parser.add_argument("--device", default="cpu")
parser.add_argument("--max-steps", type=int, default=10_000)
parser.add_argument("--max-candidates", type=int, default=400)
parser.add_argument(
"--window",
type=int,
default=3,
help="Number of policy_player turns to observe after the forced open.",
)
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,
window=args.window,
)
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()