Add discard_only opponent_policy + analyze.py merges
- Plumb discard_only through config validator and interleaved_traversal. Bypasses PolicyRequest for opponent nodes, uses DiscardOnlyBot via Snapshot. Recursive scheduler explicitly rejected (Cython unchanged). - analyze.py: merge per-opponent eval plots into multi-line plots, add twin y-axis support (PlotSpec.secondary_metrics), per-axes translucent legends instead of one global legend, add avg_game_length to GameFlow. - Tests: discard_only smoke run, validator accept/reject, all 57 pass.
This commit is contained in:
@@ -19,6 +19,9 @@ class PlotSpec:
|
|||||||
kind: str = "eval"
|
kind: str = "eval"
|
||||||
fixed_ylim: tuple[float, float] | None = None
|
fixed_ylim: tuple[float, float] | None = None
|
||||||
opponents: tuple[str, ...] | None = None
|
opponents: tuple[str, ...] | None = None
|
||||||
|
secondary_metrics: tuple[str, ...] = ()
|
||||||
|
secondary_ylabel: str | None = None
|
||||||
|
secondary_scale: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -33,64 +36,45 @@ SECTIONS: tuple[SectionSpec, ...] = (
|
|||||||
"Core",
|
"Core",
|
||||||
"analysis_00_core.png",
|
"analysis_00_core.png",
|
||||||
(
|
(
|
||||||
PlotSpec("Advantage Loss", ("loss/advantage",), "loss", kind="train"),
|
|
||||||
PlotSpec("Strategy Loss", ("loss/strategy",), "loss", kind="train"),
|
|
||||||
PlotSpec(
|
PlotSpec(
|
||||||
"Avg Score Diff (heuristic_cautious)",
|
"Losses (advantage / strategy)",
|
||||||
("avg_score_diff0",),
|
("loss/advantage",),
|
||||||
"score diff",
|
"advantage MSE",
|
||||||
opponents=("heuristic_cautious",),
|
kind="train",
|
||||||
|
secondary_metrics=("loss/strategy",),
|
||||||
|
secondary_ylabel="strategy CE",
|
||||||
),
|
),
|
||||||
|
PlotSpec("Avg Score Diff (all opponents)", ("avg_score_diff0",), "score diff"),
|
||||||
PlotSpec(
|
PlotSpec(
|
||||||
"Win Rate (heuristic_cautious)",
|
"Win Rate (all opponents)",
|
||||||
("win_rate0",),
|
("win_rate0",),
|
||||||
"rate (%)",
|
"rate (%)",
|
||||||
scale=100.0,
|
scale=100.0,
|
||||||
fixed_ylim=(0, 100),
|
fixed_ylim=(0, 100),
|
||||||
opponents=("heuristic_cautious",),
|
|
||||||
),
|
),
|
||||||
PlotSpec(
|
PlotSpec(
|
||||||
"Win Rate (random)",
|
"Avg Opened Colors (all opponents)",
|
||||||
("win_rate0",),
|
|
||||||
"rate (%)",
|
|
||||||
scale=100.0,
|
|
||||||
fixed_ylim=(0, 100),
|
|
||||||
opponents=("random",),
|
|
||||||
),
|
|
||||||
PlotSpec(
|
|
||||||
"Avg Opened Colors (heuristic_cautious)",
|
|
||||||
("avg_opened_colors",),
|
("avg_opened_colors",),
|
||||||
"colors",
|
"colors",
|
||||||
fixed_ylim=(0, 5),
|
fixed_ylim=(0, 5),
|
||||||
opponents=("heuristic_cautious",),
|
|
||||||
),
|
),
|
||||||
PlotSpec(
|
PlotSpec(
|
||||||
"Positive Expedition Rate (heuristic_cautious)",
|
"Positive / Bonus Expedition Rate (heuristic_cautious)",
|
||||||
("positive_expedition_rate",),
|
("positive_expedition_rate", "bonus_expedition_rate"),
|
||||||
"rate (%)",
|
"rate (%)",
|
||||||
scale=100.0,
|
scale=100.0,
|
||||||
fixed_ylim=(0, 100),
|
fixed_ylim=(0, 100),
|
||||||
opponents=("heuristic_cautious",),
|
opponents=("heuristic_cautious",),
|
||||||
),
|
),
|
||||||
PlotSpec(
|
PlotSpec(
|
||||||
"Bonus Expedition Rate (heuristic_cautious)",
|
"Score per Opened Color (all opponents)",
|
||||||
("bonus_expedition_rate",),
|
|
||||||
"rate (%)",
|
|
||||||
scale=100.0,
|
|
||||||
fixed_ylim=(0, 100),
|
|
||||||
opponents=("heuristic_cautious",),
|
|
||||||
),
|
|
||||||
PlotSpec(
|
|
||||||
"Score per Opened Color (heuristic_cautious)",
|
|
||||||
("score_per_opened_color",),
|
("score_per_opened_color",),
|
||||||
"score / color",
|
"score / color",
|
||||||
opponents=("heuristic_cautious",),
|
|
||||||
),
|
),
|
||||||
PlotSpec(
|
PlotSpec(
|
||||||
"Policy Entropy (heuristic_cautious)",
|
"Policy Entropy (all opponents)",
|
||||||
("policy_entropy",),
|
("policy_entropy",),
|
||||||
"entropy",
|
"entropy",
|
||||||
opponents=("heuristic_cautious",),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -98,8 +82,14 @@ SECTIONS: tuple[SectionSpec, ...] = (
|
|||||||
"Loss",
|
"Loss",
|
||||||
"analysis_01_loss.png",
|
"analysis_01_loss.png",
|
||||||
(
|
(
|
||||||
PlotSpec("Advantage Loss", ("loss/advantage",), "loss", kind="train"),
|
PlotSpec(
|
||||||
PlotSpec("Strategy Loss", ("loss/strategy",), "loss", kind="train"),
|
"Losses (advantage / strategy)",
|
||||||
|
("loss/advantage",),
|
||||||
|
"advantage MSE",
|
||||||
|
kind="train",
|
||||||
|
secondary_metrics=("loss/strategy",),
|
||||||
|
secondary_ylabel="strategy CE",
|
||||||
|
),
|
||||||
PlotSpec(
|
PlotSpec(
|
||||||
"Samples",
|
"Samples",
|
||||||
("samples/advantage", "samples/strategy"),
|
("samples/advantage", "samples/strategy"),
|
||||||
@@ -129,32 +119,12 @@ SECTIONS: tuple[SectionSpec, ...] = (
|
|||||||
"analysis_03_action.png",
|
"analysis_03_action.png",
|
||||||
(
|
(
|
||||||
PlotSpec(
|
PlotSpec(
|
||||||
"Play Action Rate",
|
"Action Rates (heuristic_cautious)",
|
||||||
("play_action_rate",),
|
("play_action_rate", "discard_action_rate", "draw_deck_rate", "draw_pile_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 (%)",
|
"rate (%)",
|
||||||
scale=100.0,
|
scale=100.0,
|
||||||
fixed_ylim=(0, 100),
|
fixed_ylim=(0, 100),
|
||||||
|
opponents=("heuristic_cautious",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -171,6 +141,7 @@ SECTIONS: tuple[SectionSpec, ...] = (
|
|||||||
fixed_ylim=(0, 100),
|
fixed_ylim=(0, 100),
|
||||||
),
|
),
|
||||||
PlotSpec("Expedition Cards", ("avg_expedition_cards",), "cards"),
|
PlotSpec("Expedition Cards", ("avg_expedition_cards",), "cards"),
|
||||||
|
PlotSpec("Avg Game Length", ("avg_game_length",), "steps"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SectionSpec(
|
SectionSpec(
|
||||||
@@ -178,28 +149,19 @@ SECTIONS: tuple[SectionSpec, ...] = (
|
|||||||
"analysis_06_expedition_outcomes.png",
|
"analysis_06_expedition_outcomes.png",
|
||||||
(
|
(
|
||||||
PlotSpec(
|
PlotSpec(
|
||||||
"Positive Expedition Rate",
|
"Expedition Outcome Rates (heuristic_cautious)",
|
||||||
("positive_expedition_rate",),
|
(
|
||||||
|
"positive_expedition_rate",
|
||||||
|
"negative_expedition_rate",
|
||||||
|
"bonus_expedition_rate",
|
||||||
|
),
|
||||||
"rate (%)",
|
"rate (%)",
|
||||||
scale=100.0,
|
scale=100.0,
|
||||||
fixed_ylim=(0, 100),
|
fixed_ylim=(0, 100),
|
||||||
|
opponents=("heuristic_cautious",),
|
||||||
),
|
),
|
||||||
PlotSpec(
|
PlotSpec(
|
||||||
"Negative Expedition Rate",
|
"Per-Game Expedition Counts (heuristic_cautious)",
|
||||||
("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_positive_expeditions",
|
||||||
"per_game_negative_expeditions",
|
"per_game_negative_expeditions",
|
||||||
@@ -207,9 +169,18 @@ SECTIONS: tuple[SectionSpec, ...] = (
|
|||||||
"per_game_below_minus_20_expeditions",
|
"per_game_below_minus_20_expeditions",
|
||||||
),
|
),
|
||||||
"expeditions / game",
|
"expeditions / game",
|
||||||
|
opponents=("heuristic_cautious",),
|
||||||
|
),
|
||||||
|
PlotSpec(
|
||||||
|
"Final Expedition Score (all opponents)",
|
||||||
|
("avg_final_score_per_opened_expedition",),
|
||||||
|
"score",
|
||||||
|
),
|
||||||
|
PlotSpec(
|
||||||
|
"Score per Opened Color (all opponents)",
|
||||||
|
("score_per_opened_color",),
|
||||||
|
"score / color",
|
||||||
),
|
),
|
||||||
PlotSpec("Final Expedition Score", ("avg_final_score_per_opened_expedition",), "score"),
|
|
||||||
PlotSpec("Score per Opened Color", ("score_per_opened_color",), "score / color"),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SectionSpec(
|
SectionSpec(
|
||||||
@@ -441,19 +412,13 @@ def plot_section(
|
|||||||
for ax in axes_flat[next_axis:]:
|
for ax in axes_flat[next_axis:]:
|
||||||
ax.axis("off")
|
ax.axis("off")
|
||||||
|
|
||||||
if section.name != "Traversal":
|
|
||||||
handles, labels = _legend_items(axes_flat)
|
|
||||||
if handles:
|
|
||||||
fig.legend(
|
|
||||||
handles, labels, loc="upper center", ncols=min(len(labels), 6), fontsize="small"
|
|
||||||
)
|
|
||||||
suffix = f" ({smoothing_window}-iter moving average)" if smoothing_window > 1 else ""
|
suffix = f" ({smoothing_window}-iter moving average)" if smoothing_window > 1 else ""
|
||||||
fig.suptitle(
|
fig.suptitle(
|
||||||
f"Lost Cities Deep CFR {section.name} metrics{suffix}",
|
f"Lost Cities Deep CFR {section.name} metrics{suffix}",
|
||||||
fontsize=14,
|
fontsize=14,
|
||||||
fontweight="bold",
|
fontweight="bold",
|
||||||
)
|
)
|
||||||
fig.tight_layout(rect=(0, 0, 1, 0.95))
|
fig.tight_layout(rect=(0, 0, 1, 0.97))
|
||||||
if not plotted_any:
|
if not plotted_any:
|
||||||
plt.close(fig)
|
plt.close(fig)
|
||||||
return False
|
return False
|
||||||
@@ -598,6 +563,35 @@ def _plot_train_spec(
|
|||||||
)
|
)
|
||||||
or plotted
|
or plotted
|
||||||
)
|
)
|
||||||
|
if spec.secondary_metrics:
|
||||||
|
ax2 = ax.twinx()
|
||||||
|
secondary_palette = ("tab:red", "tab:purple", "tab:brown", "tab:olive")
|
||||||
|
for idx, metric in enumerate(spec.secondary_metrics):
|
||||||
|
pairs = []
|
||||||
|
for row in rows:
|
||||||
|
if "iteration" not in row:
|
||||||
|
continue
|
||||||
|
value = _train_value(row, metric)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
pairs.append((int(row["iteration"]), value * spec.secondary_scale))
|
||||||
|
color = (
|
||||||
|
_train_metric_color(metric, section_title=spec.title)
|
||||||
|
or secondary_palette[idx % len(secondary_palette)]
|
||||||
|
)
|
||||||
|
plotted = (
|
||||||
|
_plot_pairs(
|
||||||
|
ax2,
|
||||||
|
pairs,
|
||||||
|
label=_train_metric_label(metric),
|
||||||
|
color=color,
|
||||||
|
smoothing_window=smoothing_window,
|
||||||
|
)
|
||||||
|
or plotted
|
||||||
|
)
|
||||||
|
if spec.secondary_ylabel:
|
||||||
|
ax2.set_ylabel(spec.secondary_ylabel)
|
||||||
|
ax2.grid(False)
|
||||||
return plotted
|
return plotted
|
||||||
|
|
||||||
|
|
||||||
@@ -632,8 +626,10 @@ def _plot_eval_spec(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
plotted = False
|
plotted = False
|
||||||
multi_metric = len(spec.metrics) > 1
|
multi_metric = len(spec.metrics) > 1
|
||||||
|
color_by_metric = multi_metric and len(opponents) == 1
|
||||||
|
metric_palette = ("tab:blue", "tab:orange", "tab:green", "tab:red", "tab:purple", "tab:brown")
|
||||||
for opponent in opponents:
|
for opponent in opponents:
|
||||||
for metric in spec.metrics:
|
for idx, metric in enumerate(spec.metrics):
|
||||||
pairs: list[tuple[int, float]] = []
|
pairs: list[tuple[int, float]] = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if "iteration" not in row:
|
if "iteration" not in row:
|
||||||
@@ -645,16 +641,23 @@ def _plot_eval_spec(
|
|||||||
value = float("nan")
|
value = float("nan")
|
||||||
pairs.append((int(row["iteration"]), value * spec.scale))
|
pairs.append((int(row["iteration"]), value * spec.scale))
|
||||||
|
|
||||||
|
if color_by_metric:
|
||||||
|
label = _short_metric_label(metric)
|
||||||
|
color = metric_palette[idx % len(metric_palette)]
|
||||||
|
linestyle = "-"
|
||||||
|
else:
|
||||||
label = opponent
|
label = opponent
|
||||||
if multi_metric:
|
if multi_metric:
|
||||||
label = f"{opponent}: {_short_metric_label(metric)}"
|
label = f"{opponent}: {_short_metric_label(metric)}"
|
||||||
|
color = _opponent_color(opponent)
|
||||||
|
linestyle = _metric_linestyle(metric) if multi_metric else "-"
|
||||||
plotted = (
|
plotted = (
|
||||||
_plot_pairs(
|
_plot_pairs(
|
||||||
ax,
|
ax,
|
||||||
pairs,
|
pairs,
|
||||||
label=label,
|
label=label,
|
||||||
color=_opponent_color(opponent),
|
color=color,
|
||||||
linestyle=_metric_linestyle(metric) if multi_metric else "-",
|
linestyle=linestyle,
|
||||||
smoothing_window=smoothing_window,
|
smoothing_window=smoothing_window,
|
||||||
)
|
)
|
||||||
or plotted
|
or plotted
|
||||||
@@ -823,9 +826,17 @@ def _finish_axis(
|
|||||||
ax.set_ylim(*fixed_ylim)
|
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, labels = ax.get_legend_handles_labels()
|
||||||
|
for sibling in ax.figure.axes:
|
||||||
|
if sibling is ax:
|
||||||
|
continue
|
||||||
|
if sibling.bbox.bounds != ax.bbox.bounds:
|
||||||
|
continue
|
||||||
|
twin_handles, twin_labels = sibling.get_legend_handles_labels()
|
||||||
|
handles.extend(twin_handles)
|
||||||
|
labels.extend(twin_labels)
|
||||||
if handles:
|
if handles:
|
||||||
ax.legend(loc="best", fontsize="x-small")
|
ax.legend(handles, labels, loc="best", fontsize="x-small", framealpha=0.7, frameon=True)
|
||||||
else:
|
else:
|
||||||
ax.text(0.5, 0.5, "No data", ha="center", va="center", transform=ax.transAxes)
|
ax.text(0.5, 0.5, "No data", ha="center", va="center", transform=ax.transAxes)
|
||||||
|
|
||||||
|
|||||||
@@ -150,9 +150,17 @@ class TraversalConfig(StrictModel):
|
|||||||
@field_validator("opponent_policy")
|
@field_validator("opponent_policy")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validate_opponent_policy(cls, value: str) -> str:
|
def _validate_opponent_policy(cls, value: str) -> str:
|
||||||
if value not in {"network", "heuristic_balanced", "self_play_league", "average_strategy"}:
|
allowed = {
|
||||||
|
"network",
|
||||||
|
"heuristic_balanced",
|
||||||
|
"self_play_league",
|
||||||
|
"average_strategy",
|
||||||
|
"discard_only",
|
||||||
|
}
|
||||||
|
if value not in allowed:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"must be 'network', 'heuristic_balanced', 'self_play_league', or 'average_strategy'"
|
"must be 'network', 'heuristic_balanced', 'self_play_league', "
|
||||||
|
"'average_strategy', or 'discard_only'"
|
||||||
)
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@@ -189,10 +197,10 @@ class TraversalConfig(StrictModel):
|
|||||||
if self.scheduler == "interleaved":
|
if self.scheduler == "interleaved":
|
||||||
if self.sampling_mode != "outcome":
|
if self.sampling_mode != "outcome":
|
||||||
raise ValueError("scheduler='interleaved' currently supports only outcome sampling")
|
raise ValueError("scheduler='interleaved' currently supports only outcome sampling")
|
||||||
if self.opponent_policy not in {"network", "average_strategy"}:
|
if self.opponent_policy not in {"network", "average_strategy", "discard_only"}:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"scheduler='interleaved' currently supports only "
|
"scheduler='interleaved' currently supports only "
|
||||||
"opponent_policy='network' or 'average_strategy'"
|
"opponent_policy='network', 'average_strategy', or 'discard_only'"
|
||||||
)
|
)
|
||||||
if self.cutoff_rollouts != 0 or self.cutoff_value_mode != "score_diff":
|
if self.cutoff_rollouts != 0 or self.cutoff_value_mode != "score_diff":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -207,6 +215,11 @@ class TraversalConfig(StrictModel):
|
|||||||
raise ValueError("interleave_width must be positive")
|
raise ValueError("interleave_width must be positive")
|
||||||
if self.interleave_max_batch <= 0:
|
if self.interleave_max_batch <= 0:
|
||||||
raise ValueError("interleave_max_batch must be positive")
|
raise ValueError("interleave_max_batch must be positive")
|
||||||
|
if self.scheduler == "recursive" and self.opponent_policy == "discard_only":
|
||||||
|
raise ValueError(
|
||||||
|
"opponent_policy='discard_only' is currently only supported with "
|
||||||
|
"scheduler='interleaved'."
|
||||||
|
)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def resolved_num_workers(self, batches: int | None = None) -> int:
|
def resolved_num_workers(self, batches: int | None = None) -> int:
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ from typing import Any
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import torch
|
import torch
|
||||||
|
|
||||||
|
from coolrl_lost_cities.games.classic.bots.discard_only import DiscardOnlyBot
|
||||||
from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state
|
from coolrl_lost_cities.games.classic.deep_cfr.encoding import encode_info_state
|
||||||
from coolrl_lost_cities.games.classic.deep_cfr.memory import TrainingSample
|
from coolrl_lost_cities.games.classic.deep_cfr.memory import TrainingSample
|
||||||
from coolrl_lost_cities.games.classic.deep_cfr.traversal_stats import TraversalStats
|
from coolrl_lost_cities.games.classic.deep_cfr.traversal_stats import TraversalStats
|
||||||
from coolrl_lost_cities.games.classic.game import GameState
|
from coolrl_lost_cities.games.classic.game import GameState
|
||||||
|
from coolrl_lost_cities.games.classic.snapshots import snapshot_from_state
|
||||||
|
|
||||||
|
|
||||||
def _next_u32(state: int) -> tuple[int, int]:
|
def _next_u32(state: int) -> tuple[int, int]:
|
||||||
@@ -376,6 +378,9 @@ class InterleavedContext:
|
|||||||
self.last_value = 0.0
|
self.last_value = 0.0
|
||||||
self.done = False
|
self.done = False
|
||||||
self.value = 0.0
|
self.value = 0.0
|
||||||
|
self._discard_only_bot: DiscardOnlyBot | None = (
|
||||||
|
DiscardOnlyBot() if cfg.opponent_policy == "discard_only" else None
|
||||||
|
)
|
||||||
|
|
||||||
def advance_until_policy(self, context_index: int) -> None:
|
def advance_until_policy(self, context_index: int) -> None:
|
||||||
while not self.done and self.pending is None and self.stack:
|
while not self.done and self.pending is None and self.stack:
|
||||||
@@ -454,9 +459,32 @@ class InterleavedContext:
|
|||||||
self._return_value(cutoff)
|
self._return_value(cutoff)
|
||||||
return
|
return
|
||||||
player = int(self.state.current_player)
|
player = int(self.state.current_player)
|
||||||
|
legal_actions = self.state.unified_legal_actions()
|
||||||
|
if not legal_actions:
|
||||||
|
self.stats.terminals += 1
|
||||||
|
_record_endpoint(
|
||||||
|
self.stats,
|
||||||
|
depth,
|
||||||
|
self.cfg.endpoint_depth_bucket_width,
|
||||||
|
self.cfg.endpoint_depth_bucket_max,
|
||||||
|
)
|
||||||
|
self._return_value(float(self.state.score_diff(self.traverser)))
|
||||||
|
return
|
||||||
|
if player != self.traverser and self._discard_only_bot is not None:
|
||||||
|
snapshot = snapshot_from_state(self.state)
|
||||||
|
action = int(
|
||||||
|
self._discard_only_bot._act_unified(
|
||||||
|
snapshot.phase, snapshot.legal_mask, snapshot.card_action_size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
swapped_deck_index = self._sample_deck_draw_chance(action)
|
||||||
|
self.state.push_unified_action(action)
|
||||||
|
self.stack.append(FixedActionFrame(swapped_deck_index=swapped_deck_index))
|
||||||
|
self.stack.append(EnterFrame(depth + 1))
|
||||||
|
return
|
||||||
info_state = encode_info_state(self.state, player, self.cfg.encoding)
|
info_state = encode_info_state(self.state, player, self.cfg.encoding)
|
||||||
legal_mask = np.zeros(self.cfg.action_size, dtype=bool)
|
legal_mask = np.zeros(self.cfg.action_size, dtype=bool)
|
||||||
legal_mask[self.state.unified_legal_actions()] = True
|
legal_mask[legal_actions] = True
|
||||||
network_kind = (
|
network_kind = (
|
||||||
"strategy"
|
"strategy"
|
||||||
if player != self.traverser and self.cfg.opponent_policy == "average_strategy"
|
if player != self.traverser and self.cfg.opponent_policy == "average_strategy"
|
||||||
|
|||||||
@@ -138,7 +138,9 @@ def test_deep_cfr_config_accepts_interleaved_scheduler() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_deep_cfr_config_rejects_unsupported_interleaved_options() -> None:
|
def test_deep_cfr_config_rejects_unsupported_interleaved_options() -> None:
|
||||||
with pytest.raises(ValueError, match="opponent_policy='network' or 'average_strategy'"):
|
with pytest.raises(
|
||||||
|
ValueError, match="opponent_policy='network', 'average_strategy', or 'discard_only'"
|
||||||
|
):
|
||||||
_deep_cfr_config(
|
_deep_cfr_config(
|
||||||
{"traversal": {"scheduler": "interleaved", "opponent_policy": "self_play_league"}}
|
{"traversal": {"scheduler": "interleaved", "opponent_policy": "self_play_league"}}
|
||||||
)
|
)
|
||||||
@@ -154,6 +156,20 @@ def test_deep_cfr_config_rejects_unsupported_interleaved_options() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_cfr_config_accepts_discard_only_with_interleaved() -> None:
|
||||||
|
config = _deep_cfr_config(
|
||||||
|
{"traversal": {"scheduler": "interleaved", "opponent_policy": "discard_only"}}
|
||||||
|
)
|
||||||
|
assert config.traversal.opponent_policy == "discard_only"
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_cfr_config_rejects_discard_only_with_recursive() -> None:
|
||||||
|
with pytest.raises(ValueError, match="discard_only.*scheduler='interleaved'"):
|
||||||
|
_deep_cfr_config(
|
||||||
|
{"traversal": {"scheduler": "recursive", "opponent_policy": "discard_only"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_deep_cfr_train_cli_checkpoint_save_overrides() -> None:
|
def test_deep_cfr_train_cli_checkpoint_save_overrides() -> None:
|
||||||
args = type(
|
args = type(
|
||||||
"Args",
|
"Args",
|
||||||
@@ -452,6 +468,42 @@ def test_deep_cfr_trainer_interleaved_scheduler_smoke_run(tmp_path) -> None:
|
|||||||
assert runtime["interleaved/avg_batch_size"] >= 1.0
|
assert runtime["interleaved/avg_batch_size"] >= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_cfr_trainer_discard_only_opponent_smoke_run(tmp_path) -> None:
|
||||||
|
trainer = DeepCFRTrainer(
|
||||||
|
_deep_cfr_config(
|
||||||
|
{
|
||||||
|
"run": {"max_iterations": 1, "seed": 25},
|
||||||
|
"network": {"hidden_size": 16},
|
||||||
|
"traversal": {
|
||||||
|
"scheduler": "interleaved",
|
||||||
|
"opponent_policy": "discard_only",
|
||||||
|
"traversals_per_player": 2,
|
||||||
|
"max_depth": 3,
|
||||||
|
"max_nodes_per_traversal": 64,
|
||||||
|
"interleave_width": 4,
|
||||||
|
"interleave_max_batch": 8,
|
||||||
|
},
|
||||||
|
"optimization": {
|
||||||
|
"advantage_updates_per_iteration": 1,
|
||||||
|
"strategy_updates_per_iteration": 1,
|
||||||
|
"advantage_batch_size": 2,
|
||||||
|
"strategy_batch_size": 2,
|
||||||
|
},
|
||||||
|
"checkpoint": {"save_every": 0, "save_latest": False},
|
||||||
|
"evaluation": {"eval_every": 0},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
LostCitiesConfig(seed=25),
|
||||||
|
run_dir=tmp_path / "discard_only",
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = trainer.train()
|
||||||
|
|
||||||
|
assert len(metrics) == 1
|
||||||
|
assert metrics[0].advantage_samples > 0
|
||||||
|
assert metrics[0].traversal_nodes > 0
|
||||||
|
|
||||||
|
|
||||||
def test_deep_cfr_interleaved_scheduler_matches_recursive_single_traversal() -> None:
|
def test_deep_cfr_interleaved_scheduler_matches_recursive_single_traversal() -> None:
|
||||||
config = _deep_cfr_config(
|
config = _deep_cfr_config(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user