Improve Deep CFR analysis dashboards

This commit is contained in:
2026-05-07 04:58:27 +09:00
parent 4ec784217b
commit 690e086738
2 changed files with 525 additions and 153 deletions
+15 -6
View File
@@ -165,13 +165,22 @@ uv run lost-cities-deep-cfr analyze \
--output-dir runs/deep_cfr/<run-name>/analysis --output-dir runs/deep_cfr/<run-name>/analysis
``` ```
The analyzer reads `metrics.jsonl` and writes PNG files such as: The analyzer reads `metrics.jsonl` and writes PNG files grouped by diagnostic
section. Opponents are compared within each plot using fixed colors. Smoothing
uses a 5-iteration moving average by default; pass `--no-smoothing` to disable
it or `--smoothing-window N` to choose a different window.
- `analysis_<opponent>_action_distribution.png` Current output files:
- `analysis_<opponent>_game_flow.png`
- `analysis_<opponent>_open_quality.png` - `analysis_01_loss.png`
- `analysis_<opponent>_expedition_outcomes.png` - `analysis_02_match.png`
- `analysis_<opponent>_calibration.png` - `analysis_03_action.png`
- `analysis_04_gameflow.png`
- `analysis_05_open_quality.png`
- `analysis_06_expedition_outcomes.png`
- `analysis_07_calibration.png`
- `analysis_08_traversal.png`
- `analysis_final_eval_summary.png`
## Runtime Artifacts ## Runtime Artifacts
@@ -2,69 +2,243 @@ from __future__ import annotations
import argparse import argparse
import json import json
import math
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
TRAINING_PLOTS: list[tuple[str, list[str], str]] = [ DEFAULT_SMOOTHING_WINDOW = 5
("Advantage Loss", ["advantage_loss"], "loss"),
("Strategy Loss", ["strategy_loss"], "loss"),
("Iteration Time", ["iteration_seconds"], "seconds"),
("Throughput", ["nodes_per_second"], "nodes / second"),
("Samples", ["advantage_samples", "strategy_samples"], "samples"),
("Memory Size", ["advantage_memory_size", "strategy_memory_size"], "samples"),
(
"Traversal Depth",
["traversal_avg_endpoint_depth", "traversal_max_depth_reached"],
"depth",
),
(
"Traversal Endpoints",
[
"traversal_terminals",
"traversal_node_limit_cutoffs",
"traversal_depth_cutoffs",
],
"count",
),
]
EVAL_PLOTS: list[tuple[str, str, str, float]] = [
("Win Rate", "win_rate0", "rate (%)", 100.0),
("Avg Score Diff", "avg_score_diff0", "score diff", 1.0),
("Avg Score", "avg_score0", "score", 1.0),
("Policy Entropy", "policy_entropy", "entropy", 1.0),
("Play Action Rate", "play_action_rate", "rate (%)", 100.0),
("Discard Action Rate", "discard_action_rate", "rate (%)", 100.0),
("Draw Deck Rate", "draw_deck_rate", "rate (%)", 100.0),
("Draw Pile Rate", "draw_pile_rate", "rate (%)", 100.0),
("Opened Colors", "avg_opened_colors", "colors", 1.0),
("5-Color Open Count", "5_color_open_count", "games / eval", 1.0),
("Expedition Cards", "avg_expedition_cards", "cards", 1.0),
("Bad Open Rate", "bad_open_rate", "rate (%)", 100.0),
("Good Open Rate", "good_open_rate", "rate (%)", 100.0),
("Opening Recoverable Score", "opening_recoverable_score_mean", "score", 1.0),
("Score per Opened Color", "score_per_opened_color", "score / color", 1.0),
("Negative Expedition Rate", "negative_expedition_rate", "rate (%)", 100.0),
("Positive Expedition Rate", "positive_expedition_rate", "rate (%)", 100.0),
(
"Final Score per Expedition",
"avg_final_score_per_opened_expedition",
"score",
1.0,
),
("Max Step Timeouts", "max_step_timeouts", "timeouts", 1.0),
]
SUMMARY_EVAL_METRICS: list[tuple[str, str, float]] = [ @dataclass(frozen=True)
class PlotSpec:
title: str
metrics: tuple[str, ...]
ylabel: str
scale: float = 1.0
kind: str = "eval"
fixed_ylim: tuple[float, float] | None = None
@dataclass(frozen=True)
class SectionSpec:
name: str
filename: str
plots: tuple[PlotSpec, ...]
SECTIONS: tuple[SectionSpec, ...] = (
SectionSpec(
"Loss",
"analysis_01_loss.png",
(
PlotSpec("Advantage Loss", ("advantage_loss",), "loss", kind="train"),
PlotSpec("Strategy Loss", ("strategy_loss",), "loss", kind="train"),
PlotSpec(
"Samples",
("advantage_samples", "strategy_samples"),
"samples",
kind="train",
),
PlotSpec(
"Memory Size",
("advantage_memory_size", "strategy_memory_size"),
"samples",
kind="train",
),
),
),
SectionSpec(
"Match",
"analysis_02_match.png",
(
PlotSpec("Win Rate", ("win_rate0",), "rate (%)", scale=100.0, fixed_ylim=(0, 100)),
PlotSpec("Avg Score Diff", ("avg_score_diff0",), "score diff"),
PlotSpec("Avg Score", ("avg_score0",), "score"),
PlotSpec("Policy Entropy", ("policy_entropy",), "entropy"),
),
),
SectionSpec(
"Action",
"analysis_03_action.png",
(
PlotSpec(
"Play Action Rate",
("play_action_rate",),
"rate (%)",
scale=100.0,
fixed_ylim=(0, 100),
),
PlotSpec(
"Discard Action Rate",
("discard_action_rate",),
"rate (%)",
scale=100.0,
fixed_ylim=(0, 100),
),
PlotSpec(
"Draw Deck Rate",
("draw_deck_rate",),
"rate (%)",
scale=100.0,
fixed_ylim=(0, 100),
),
PlotSpec(
"Draw Pile Rate",
("draw_pile_rate",),
"rate (%)",
scale=100.0,
fixed_ylim=(0, 100),
),
),
),
SectionSpec(
"GameFlow",
"analysis_04_gameflow.png",
(
PlotSpec("Opened Colors", ("avg_opened_colors",), "colors", fixed_ylim=(0, 5)),
PlotSpec("Opened Colors Std", ("opened_colors_std",), "std"),
PlotSpec(
"5-Color Open Count",
("5_color_open_count",),
"games / eval",
fixed_ylim=(0, 100),
),
PlotSpec("Expedition Cards", ("avg_expedition_cards",), "cards"),
),
),
SectionSpec(
"OpenQuality",
"analysis_05_open_quality.png",
(
PlotSpec(
"Bad Open Rate",
("bad_open_rate",),
"rate (%)",
scale=100.0,
fixed_ylim=(0, 100),
),
PlotSpec(
"Weak Open Rate",
("weak_open_rate",),
"rate (%)",
scale=100.0,
fixed_ylim=(0, 100),
),
PlotSpec(
"Bad or Weak Open Rate",
("bad_or_weak_open_rate",),
"rate (%)",
scale=100.0,
fixed_ylim=(0, 100),
),
PlotSpec(
"Good Open Rate",
("good_open_rate",),
"rate (%)",
scale=100.0,
fixed_ylim=(0, 100),
),
PlotSpec("Bad Open per Game", ("bad_open_per_game",), "opens / game"),
PlotSpec(
"Bad or Weak Open per Game",
("bad_or_weak_open_per_game",),
"opens / game",
),
PlotSpec("Opening Play Actions", ("opening_play_actions",), "actions / game"),
PlotSpec("Opening Recoverable p25", ("opening_recoverable_score_p25",), "score"),
),
),
SectionSpec(
"ExpeditionOutcomes",
"analysis_06_expedition_outcomes.png",
(
PlotSpec(
"Positive Expedition Rate",
("positive_expedition_rate",),
"rate (%)",
scale=100.0,
fixed_ylim=(0, 100),
),
PlotSpec(
"Negative Expedition Rate",
("negative_expedition_rate",),
"rate (%)",
scale=100.0,
fixed_ylim=(0, 100),
),
PlotSpec(
"Bonus Expedition Rate",
("bonus_expedition_rate",),
"rate (%)",
scale=100.0,
fixed_ylim=(0, 100),
),
PlotSpec(
"Per-Game Expedition Counts",
(
"per_game_positive_expeditions",
"per_game_negative_expeditions",
"per_game_breakeven_expeditions",
"per_game_below_minus_20_expeditions",
),
"expeditions / game",
),
PlotSpec("Final Expedition Score", ("avg_final_score_per_opened_expedition",), "score"),
PlotSpec("Score per Opened Color", ("score_per_opened_color",), "score / color"),
),
),
SectionSpec(
"Calibration",
"analysis_07_calibration.png",
(
PlotSpec(
"First Open Recoverable Score",
(
"first_open_recoverable_score_mean_for_positive_final",
"first_open_recoverable_score_mean_for_negative_final",
),
"score",
),
PlotSpec("Opening Recoverable Mean", ("opening_recoverable_score_mean",), "score"),
PlotSpec("Opening Recoverable p25", ("opening_recoverable_score_p25",), "score"),
PlotSpec("Calibration Gap", ("calibration_gap",), "score"),
),
),
SectionSpec(
"Traversal",
"analysis_08_traversal.png",
(
PlotSpec("Iteration Time", ("iteration_seconds",), "seconds", kind="train"),
PlotSpec("Throughput", ("nodes_per_second",), "nodes / second", kind="train"),
PlotSpec(
"Traversal Depth",
("traversal_avg_endpoint_depth", "traversal_max_depth_reached"),
"depth",
kind="train",
),
PlotSpec(
"Traversal Endpoints",
("traversal_terminals", "traversal_node_limit_cutoffs", "traversal_depth_cutoffs"),
"count",
kind="train",
),
PlotSpec("Traversal Nodes", ("traversal_nodes",), "nodes", kind="train"),
),
),
)
SUMMARY_EVAL_METRICS: tuple[tuple[str, str, float], ...] = (
("win_rate0", "win rate (%)", 100.0), ("win_rate0", "win rate (%)", 100.0),
("avg_score_diff0", "avg score diff", 1.0), ("avg_score_diff0", "avg score diff", 1.0),
("avg_score0", "avg score", 1.0), ("avg_score0", "avg score", 1.0),
("play_action_rate", "play rate (%)", 100.0), ("play_action_rate", "play rate (%)", 100.0),
("avg_opened_colors", "opened colors", 1.0), ("avg_opened_colors", "opened colors", 1.0),
("bad_open_rate", "bad open (%)", 100.0), ("bad_open_rate", "bad open (%)", 100.0),
("good_open_rate", "good open (%)", 100.0),
("score_per_opened_color", "score / opened color", 1.0), ("score_per_opened_color", "score / opened color", 1.0),
] ("calibration_gap", "calibration gap", 1.0),
("bonus_contribution_per_game", "bonus / game", 1.0),
)
OPPONENT_COLORS: dict[str, str] = { OPPONENT_COLORS: dict[str, str] = {
"noisy_safe": "tab:blue", "noisy_safe": "tab:blue",
@@ -75,6 +249,13 @@ OPPONENT_COLORS: dict[str, str] = {
"safe_heuristic_strict": "tab:brown", "safe_heuristic_strict": "tab:brown",
} }
ACTION_RATE_METRICS = {
"play_action_rate",
"discard_action_rate",
"draw_deck_rate",
"draw_pile_rate",
}
def load_metrics(path: Path) -> list[dict[str, Any]]: def load_metrics(path: Path) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
@@ -100,57 +281,51 @@ def opponent_names(rows: list[dict[str, Any]]) -> list[str]:
return sorted(names) return sorted(names)
def plot_training_dashboard(rows: list[dict[str, Any]], output: Path) -> bool: def plot_section(
import matplotlib.pyplot as plt rows: list[dict[str, Any]],
section: SectionSpec,
x = [int(row["iteration"]) for row in rows if "iteration" in row] output: Path,
if not x: *,
return False smoothing_window: int,
) -> bool:
fig, axes = plt.subplots(3, 3, figsize=(16, 12))
axes_flat = list(axes.flat)
plotted_any = False
for ax, (title, metrics, ylabel) in zip(axes_flat, TRAINING_PLOTS, strict=False):
plotted = _plot_row_metrics(ax, rows, x, metrics)
_finish_axis(ax, title, ylabel=ylabel, plotted=plotted)
plotted_any = plotted_any or plotted
_plot_latest_depth_buckets(axes_flat[len(TRAINING_PLOTS)], rows)
plotted_any = plotted_any or bool(_latest_depth_buckets(rows))
fig.suptitle("Lost Cities Deep CFR training metrics", fontsize=14, fontweight="bold")
fig.tight_layout(rect=(0, 0, 1, 0.97))
if not plotted_any:
plt.close(fig)
return False
fig.savefig(output, dpi=150)
plt.close(fig)
return True
def plot_eval_dashboard(rows: list[dict[str, Any]], output: Path) -> bool:
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
opponents = opponent_names(rows) opponents = opponent_names(rows)
if not opponents: cols = 2
return False rows_count = math.ceil((len(section.plots) + _extra_plot_count(section)) / cols)
fig, axes = plt.subplots(rows_count, cols, figsize=(16, 4.2 * rows_count), squeeze=False)
fig, axes = plt.subplots(5, 4, figsize=(20, 18))
axes_flat = list(axes.flat) axes_flat = list(axes.flat)
plotted_any = False plotted_any = False
for ax, (title, metric, ylabel, scale) in zip(axes_flat, EVAL_PLOTS, strict=False):
plotted = _plot_eval_metric(ax, rows, opponents, metric, scale) for ax, spec in zip(axes_flat, section.plots, strict=False):
_finish_axis(ax, title, ylabel=ylabel, plotted=plotted) if spec.kind == "train":
plotted = _plot_train_spec(ax, rows, spec, smoothing_window=smoothing_window)
else:
plotted = _plot_eval_spec(ax, rows, opponents, spec, smoothing_window=smoothing_window)
_finish_axis(
ax, spec.title, ylabel=spec.ylabel, plotted=plotted, fixed_ylim=spec.fixed_ylim
)
plotted_any = plotted_any or plotted plotted_any = plotted_any or plotted
for ax in axes_flat[len(EVAL_PLOTS) :]: next_axis = len(section.plots)
if section.name == "Traversal" and next_axis < len(axes_flat):
_plot_latest_depth_buckets(axes_flat[next_axis], rows)
plotted_any = plotted_any or bool(_latest_depth_buckets(rows))
next_axis += 1
for ax in axes_flat[next_axis:]:
ax.axis("off") ax.axis("off")
handles, labels = _legend_items(axes_flat) handles, labels = _legend_items(axes_flat)
if handles: if handles:
fig.legend(handles, labels, loc="upper center", ncols=min(len(labels), 6), fontsize="small") fig.legend(handles, labels, loc="upper center", ncols=min(len(labels), 6), fontsize="small")
fig.suptitle("Lost Cities Deep CFR eval metrics", fontsize=14, fontweight="bold") suffix = f" ({smoothing_window}-iter moving average)" if smoothing_window > 1 else ""
fig.tight_layout(rect=(0, 0, 1, 0.96)) fig.suptitle(
f"Lost Cities Deep CFR {section.name} metrics{suffix}",
fontsize=14,
fontweight="bold",
)
fig.tight_layout(rect=(0, 0, 1, 0.95))
if not plotted_any: if not plotted_any:
plt.close(fig) plt.close(fig)
return False return False
@@ -167,24 +342,32 @@ def plot_final_eval_summary(rows: list[dict[str, Any]], output: Path) -> bool:
if not opponents or latest is None: if not opponents or latest is None:
return False return False
fig, axes = plt.subplots(2, 4, figsize=(18, 8)) cols = 3
rows_count = math.ceil(len(SUMMARY_EVAL_METRICS) / cols)
fig, axes = plt.subplots(rows_count, cols, figsize=(18, 4.2 * rows_count), squeeze=False)
plotted_any = False plotted_any = False
for ax, (metric, title, scale) in zip(axes.flat, SUMMARY_EVAL_METRICS, strict=False): for ax, (metric, title, scale) in zip(axes.flat, SUMMARY_EVAL_METRICS, strict=False):
labels: list[str] = [] labels: list[str] = []
values: list[float] = [] values: list[float] = []
colors: list[str] = [] colors: list[str] = []
for opponent in opponents: for opponent in opponents:
value = latest.get(f"eval_{opponent}_{metric}") value = _eval_value(latest, opponent, metric)
if value is None: if value is None or not math.isfinite(value):
continue continue
labels.append(opponent) labels.append(opponent)
values.append(float(value) * scale) values.append(value * scale)
colors.append(_opponent_color(opponent)) colors.append(_opponent_color(opponent))
if values: if values:
ax.bar(labels, values, color=colors) ax.bar(labels, values, color=colors)
plotted_any = True plotted_any = True
ax.tick_params(axis="x", labelrotation=35, labelsize="x-small") ax.tick_params(axis="x", labelrotation=35, labelsize="x-small")
_finish_axis(ax, title, xlabel="", plotted=bool(values)) fixed_ylim = (0, 100) if "rate (%)" in title or title == "opened colors" else None
if title == "opened colors":
fixed_ylim = (0, 5)
_finish_axis(ax, title, xlabel="", plotted=bool(values), fixed_ylim=fixed_ylim)
for ax in list(axes.flat)[len(SUMMARY_EVAL_METRICS) :]:
ax.axis("off")
iteration = latest.get("iteration", "latest") iteration = latest.get("iteration", "latest")
fig.suptitle( fig.suptitle(
@@ -201,20 +384,22 @@ def plot_final_eval_summary(rows: list[dict[str, Any]], output: Path) -> bool:
return True return True
def analyze_run(run_dir: Path, output_dir: Path | None = None) -> list[Path]: def analyze_run(
run_dir: Path,
output_dir: Path | None = None,
*,
smoothing_window: int = DEFAULT_SMOOTHING_WINDOW,
) -> list[Path]:
metrics_path = run_dir / "metrics.jsonl" metrics_path = run_dir / "metrics.jsonl"
rows = load_metrics(metrics_path) rows = load_metrics(metrics_path)
output_dir = output_dir or run_dir output_dir = output_dir or run_dir
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
written: list[Path] = [] written: list[Path] = []
training_path = output_dir / "analysis_training_dashboard.png" for section in SECTIONS:
if plot_training_dashboard(rows, training_path): path = output_dir / section.filename
written.append(training_path) if plot_section(rows, section, path, smoothing_window=smoothing_window):
written.append(path)
eval_path = output_dir / "analysis_eval_dashboard.png"
if _has_eval_history(rows) and plot_eval_dashboard(rows, eval_path):
written.append(eval_path)
final_eval_path = output_dir / "analysis_final_eval_summary.png" final_eval_path = output_dir / "analysis_final_eval_summary.png"
if plot_final_eval_summary(rows, final_eval_path): if plot_final_eval_summary(rows, final_eval_path):
@@ -223,55 +408,190 @@ def analyze_run(run_dir: Path, output_dir: Path | None = None) -> list[Path]:
def _all_eval_metrics() -> set[str]: def _all_eval_metrics() -> set[str]:
metrics = {metric for _, metric, _, _ in EVAL_PLOTS} metrics: set[str] = set()
for section in SECTIONS:
for plot in section.plots:
if plot.kind == "eval":
metrics.update(plot.metrics)
metrics.update(metric for metric, _, _ in SUMMARY_EVAL_METRICS) metrics.update(metric for metric, _, _ in SUMMARY_EVAL_METRICS)
metrics.update(_base_metrics_for_derived_values())
return metrics return metrics
def _plot_row_metrics( def _plot_train_spec(
ax: Any, rows: list[dict[str, Any]], x: list[int], metrics: list[str] ax: Any,
rows: list[dict[str, Any]],
spec: PlotSpec,
*,
smoothing_window: int,
) -> bool: ) -> bool:
x = [int(row["iteration"]) for row in rows if "iteration" in row]
if not x:
return False
plotted = False plotted = False
for metric in metrics: for metric in spec.metrics:
values = [row.get(metric) for row in rows] pairs = [
if all(value is None for value in values): (int(row["iteration"]), float(row[metric]) * spec.scale)
continue for row in rows
y = [float("nan") if value is None else float(value) for value in values] if "iteration" in row and metric in row
ax.plot(x, y, marker="o", linewidth=1.5, markersize=3, label=_label(metric)) ]
plotted = True plotted = (
_plot_pairs(
ax,
pairs,
label=_label(metric),
color=None,
smoothing_window=smoothing_window,
)
or plotted
)
return plotted return plotted
def _plot_eval_metric( def _plot_eval_spec(
ax: Any, ax: Any,
rows: list[dict[str, Any]], rows: list[dict[str, Any]],
opponents: list[str], opponents: list[str],
metric: str, spec: PlotSpec,
scale: float, *,
smoothing_window: int,
) -> bool: ) -> bool:
plotted = False plotted = False
multi_metric = len(spec.metrics) > 1
for opponent in opponents: for opponent in opponents:
pairs = [ for metric in spec.metrics:
(int(row["iteration"]), float(row[f"eval_{opponent}_{metric}"]) * scale) pairs: list[tuple[int, float]] = []
for row in rows for row in rows:
if "iteration" in row and f"eval_{opponent}_{metric}" in row if "iteration" not in row:
] continue
if not pairs: value = _eval_value(row, opponent, metric)
continue if value is None:
x, y = zip(*pairs, strict=True) continue
ax.plot( if _should_mask_open_rate(row, opponent, metric):
x, value = float("nan")
y, pairs.append((int(row["iteration"]), value * spec.scale))
marker="o",
linewidth=1.5, label = opponent
markersize=3, if multi_metric:
label=opponent, label = f"{opponent}: {_short_metric_label(metric)}"
color=_opponent_color(opponent), plotted = (
) _plot_pairs(
plotted = True ax,
pairs,
label=label,
color=_opponent_color(opponent),
linestyle=_metric_linestyle(metric) if multi_metric else "-",
smoothing_window=smoothing_window,
)
or plotted
)
return plotted return plotted
def _plot_pairs(
ax: Any,
pairs: list[tuple[int, float]],
*,
label: str,
color: str | None,
smoothing_window: int,
linestyle: str = "-",
) -> bool:
if not pairs:
return False
x = [pair[0] for pair in pairs]
y = [pair[1] for pair in pairs]
y = _moving_average(y, smoothing_window)
if all(not math.isfinite(value) for value in y):
return False
ax.plot(
x,
y,
marker="o",
linewidth=1.5,
markersize=3,
label=label,
color=color,
linestyle=linestyle,
)
return True
def _eval_value(row: dict[str, Any], opponent: str, metric: str) -> float | None:
if metric == "bad_open_per_game":
return _first_existing_eval(row, opponent, ("bad_open_per_game", "bad_open_actions"))
if metric == "bad_or_weak_open_per_game":
direct = _first_existing_eval(row, opponent, ("bad_or_weak_open_per_game",))
if direct is not None:
return direct
bad = _first_existing_eval(row, opponent, ("bad_open_actions", "bad_open_per_game"))
weak = _first_existing_eval(row, opponent, ("weak_open_actions", "weak_open_per_game"))
if bad is None and weak is None:
return None
return (bad or 0.0) + (weak or 0.0)
if metric == "bad_or_weak_open_rate":
direct = _first_existing_eval(row, opponent, ("bad_or_weak_open_rate",))
if direct is not None:
return direct
bad = _first_existing_eval(row, opponent, ("bad_open_rate",))
weak = _first_existing_eval(row, opponent, ("weak_open_rate",))
if bad is None and weak is None:
return None
return (bad or 0.0) + (weak or 0.0)
if metric == "calibration_gap":
positive = _first_existing_eval(
row, opponent, ("first_open_recoverable_score_mean_for_positive_final",)
)
negative = _first_existing_eval(
row, opponent, ("first_open_recoverable_score_mean_for_negative_final",)
)
if positive is None or negative is None:
return None
return positive - negative
if metric == "bonus_contribution_per_game":
per_game_bonus = _first_existing_eval(row, opponent, ("per_game_bonus_expeditions",))
if per_game_bonus is not None:
return per_game_bonus * 20.0
bonus_rate = _first_existing_eval(row, opponent, ("bonus_expedition_rate",))
opened_colors = _first_existing_eval(row, opponent, ("avg_opened_colors",))
if bonus_rate is None or opened_colors is None:
return None
return bonus_rate * opened_colors * 20.0
return _first_existing_eval(row, opponent, (metric,))
def _first_existing_eval(
row: dict[str, Any], opponent: str, metrics: tuple[str, ...]
) -> float | None:
for metric in metrics:
value = row.get(f"eval_{opponent}_{metric}")
if value is not None:
return float(value)
return None
def _should_mask_open_rate(row: dict[str, Any], opponent: str, metric: str) -> bool:
if metric in ACTION_RATE_METRICS or not metric.endswith("_rate"):
return False
opening_play_actions = _first_existing_eval(row, opponent, ("opening_play_actions",))
return opening_play_actions is not None and opening_play_actions < 1.0
def _moving_average(values: list[float], window: int) -> list[float]:
if window <= 1:
return values
smoothed: list[float] = []
for idx in range(len(values)):
start = max(0, idx - window + 1)
window_values = [value for value in values[start : idx + 1] if math.isfinite(value)]
if not window_values:
smoothed.append(float("nan"))
else:
smoothed.append(sum(window_values) / len(window_values))
return smoothed
def _plot_latest_depth_buckets(ax: Any, rows: list[dict[str, Any]]) -> None: def _plot_latest_depth_buckets(ax: Any, rows: list[dict[str, Any]]) -> None:
buckets = _latest_depth_buckets(rows) buckets = _latest_depth_buckets(rows)
if not buckets: if not buckets:
@@ -312,15 +632,6 @@ def _latest_eval_row(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
return None return None
def _has_eval_history(rows: list[dict[str, Any]]) -> bool:
eval_iterations = {
int(row["iteration"])
for row in rows
if "iteration" in row and any(key.startswith("eval_") for key in row)
}
return len(eval_iterations) >= 2
def _finish_axis( def _finish_axis(
ax: Any, ax: Any,
title: str, title: str,
@@ -328,12 +639,15 @@ def _finish_axis(
xlabel: str = "iteration", xlabel: str = "iteration",
ylabel: str | None = None, ylabel: str | None = None,
plotted: bool, plotted: bool,
fixed_ylim: tuple[float, float] | None = None,
) -> None: ) -> None:
ax.set_title(title, fontsize=10, fontweight="bold") ax.set_title(title, fontsize=10, fontweight="bold")
if xlabel: if xlabel:
ax.set_xlabel(xlabel) ax.set_xlabel(xlabel)
if ylabel: if ylabel:
ax.set_ylabel(ylabel) ax.set_ylabel(ylabel)
if fixed_ylim is not None:
ax.set_ylim(*fixed_ylim)
ax.grid(True, alpha=0.3) ax.grid(True, alpha=0.3)
if plotted: if plotted:
handles, _ = ax.get_legend_handles_labels() handles, _ = ax.get_legend_handles_labels()
@@ -359,20 +673,69 @@ def _opponent_color(opponent: str) -> str:
return OPPONENT_COLORS.get(opponent, "tab:gray") return OPPONENT_COLORS.get(opponent, "tab:gray")
def _metric_linestyle(metric: str) -> str:
if "negative" in metric or metric.endswith("_negative_final"):
return "--"
if "breakeven" in metric or "weak" in metric:
return ":"
if "below_minus_20" in metric:
return "-."
return "-"
def _label(metric: str) -> str: def _label(metric: str) -> str:
return metric.replace("_", " ") return metric.replace("_", " ")
def _slug(value: str) -> str: def _short_metric_label(metric: str) -> str:
return value.lower().replace(" ", "_").replace("-", "_") labels = {
"first_open_recoverable_score_mean_for_positive_final": "positive final",
"first_open_recoverable_score_mean_for_negative_final": "negative final",
"per_game_positive_expeditions": "positive",
"per_game_negative_expeditions": "negative",
"per_game_breakeven_expeditions": "breakeven",
"per_game_below_minus_20_expeditions": "below -20",
}
return labels.get(metric, _label(metric))
def _base_metrics_for_derived_values() -> set[str]:
return {
"avg_opened_colors",
"bad_open_actions",
"bad_open_rate",
"bonus_expedition_rate",
"first_open_recoverable_score_mean_for_negative_final",
"first_open_recoverable_score_mean_for_positive_final",
"opening_play_actions",
"per_game_bonus_expeditions",
"weak_open_actions",
"weak_open_rate",
}
def _extra_plot_count(section: SectionSpec) -> int:
return 1 if section.name == "Traversal" else 0
def main(argv: list[str] | None = None) -> None: def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Plot Lost Cities Deep CFR evaluation metrics.") parser = argparse.ArgumentParser(description="Plot Lost Cities Deep CFR metrics.")
parser.add_argument("--run", required=True, type=Path) parser.add_argument("--run", required=True, type=Path)
parser.add_argument("--output-dir", type=Path) parser.add_argument("--output-dir", type=Path)
parser.add_argument(
"--smoothing-window",
type=int,
default=DEFAULT_SMOOTHING_WINDOW,
help="Moving-average window. Default: 5.",
)
parser.add_argument(
"--no-smoothing",
action="store_true",
help="Disable moving-average smoothing.",
)
args = parser.parse_args(argv) args = parser.parse_args(argv)
written = analyze_run(args.run, args.output_dir) smoothing_window = 1 if args.no_smoothing else max(1, args.smoothing_window)
written = analyze_run(args.run, args.output_dir, smoothing_window=smoothing_window)
for path in written: for path in written:
print(path) print(path)