Add optional wandb metrics tracking

Mirror Deep CFR training metrics to W&B via a new WandbRunTracker
wired through CompositeRunTracker; wandb is an optional extra so
default installs and runs stay unchanged. Train CLI gains
--wandb/--wandb-project/--wandb-mode/--wandb-name/--wandb-tag, and
train() now closes the tracker in a finally block so runs finalize
even on early exit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 15:42:30 +09:00
co-authored by Claude Opus 4.7
parent 2c96c5ee82
commit 44dd97876e
6 changed files with 385 additions and 17 deletions
@@ -23,6 +23,7 @@ from coolrl_lost_cities.games.classic.deep_cfr.imitation import (
from coolrl_lost_cities.games.classic.deep_cfr.policy_gradient import (
fine_tune_strategy_policy_gradient,
)
from coolrl_lost_cities.games.classic.deep_cfr.tracking import RunTracker, WandbRunTracker
from coolrl_lost_cities.games.classic.deep_cfr.trainer import DeepCFRTrainer
from coolrl_lost_cities.games.classic.game import classic_config
@@ -135,10 +136,23 @@ def train_command(args: argparse.Namespace) -> None:
overrides = _train_overrides_from_args(args)
config = _with_overrides(config, overrides)
resume_path = _resolve_resume_path(config, args.resume)
extra_trackers: list[RunTracker] = []
if args.wandb:
extra_trackers.append(
WandbRunTracker(
project=args.wandb_project,
name=args.wandb_name or config.run.experiment_name,
mode=args.wandb_mode,
config=config.to_dict(),
run_dir=str(config.checkpoint_path),
tags=list(args.wandb_tag) if args.wandb_tag else None,
)
)
trainer = DeepCFRTrainer(
config,
config.rules.to_lost_cities_config(seed=config.run.seed),
device=args.device or config.run.device,
extra_trackers=extra_trackers or None,
)
if resume_path:
trainer.load_checkpoint(resume_path)
@@ -268,6 +282,27 @@ def main(argv: list[str] | None = None) -> None:
train.add_argument("--no-save", action="store_true")
train.add_argument("--save-latest-only", action="store_true")
train.add_argument("--save-iteration-interval", type=int)
train.add_argument(
"--wandb",
action="store_true",
help="Mirror metrics to Weights & Biases (requires wandb extra).",
)
train.add_argument("--wandb-project", default="coolrl-lost-cities")
train.add_argument(
"--wandb-name",
help="W&B run name. Defaults to config.run.experiment_name.",
)
train.add_argument(
"--wandb-mode",
choices=("online", "offline", "disabled"),
default="online",
)
train.add_argument(
"--wandb-tag",
action="append",
default=[],
help="Tag to attach to the W&B run (repeatable).",
)
train.set_defaults(func=train_command)
evaluate = subparsers.add_parser("eval")
@@ -61,6 +61,47 @@ class ConsoleRunTracker:
pass
class WandbRunTracker:
def __init__(
self,
*,
project: str,
run_dir: str | Path,
name: str | None = None,
mode: str | None = None,
config: dict[str, Any] | None = None,
tags: list[str] | None = None,
):
try:
import wandb
except ImportError as exc: # pragma: no cover
raise RuntimeError(
"wandb is not installed. Install with: uv sync --extra wandb"
) from exc
run_dir_path = Path(run_dir)
run_dir_path.mkdir(parents=True, exist_ok=True)
self._wandb = wandb
self._run = wandb.init(
project=project,
name=name,
mode=mode,
config=config or {},
dir=str(run_dir_path),
tags=tags,
reinit=True,
)
def log_event(self, message: str) -> None:
# Human-readable events stay in train.log / console; wandb is purely for metrics.
pass
def log_metrics(self, metrics: dict[str, Any], *, step: int) -> None:
self._wandb.log(metrics, step=step)
def close(self) -> None:
self._wandb.finish()
class FileRunTracker:
def __init__(
self,
@@ -159,6 +159,7 @@ class DeepCFRTrainer:
*,
device: str = "cpu",
tracker: RunTracker | None = None,
extra_trackers: list[RunTracker] | None = None,
) -> None:
self.config = config or DeepCFRConfig()
self.game_config = game_config or self.config.rules.to_lost_cities_config(
@@ -203,8 +204,10 @@ class DeepCFRTrainer:
self.metrics_path = self.run_dir / "metrics.jsonl"
self.progress_path = self.run_dir / "runtime_progress.json"
self.log_path = self.run_dir / "train.log"
self.tracker = tracker or CompositeRunTracker(
[
if tracker is not None:
self.tracker = tracker
else:
trackers: list[RunTracker] = [
FileRunTracker(
log_path=self.log_path,
metrics_path=self.metrics_path,
@@ -212,7 +215,9 @@ class DeepCFRTrainer:
),
ConsoleRunTracker(),
]
)
if extra_trackers:
trackers.extend(extra_trackers)
self.tracker = CompositeRunTracker(trackers)
self.self_play_league_snapshots: list[list[dict]] = []
self._runtime_metrics: dict[str, float | int] = {}
@@ -546,19 +551,24 @@ class DeepCFRTrainer:
stop = self._stop_iteration()
run_started = time.perf_counter()
iteration = start
while iteration <= stop:
started = time.perf_counter()
item = self.run_iteration(iteration)
metrics.append(item)
self._maybe_record_self_play_snapshot(iteration)
checkpoint_started = time.perf_counter()
self._save_iteration_checkpoints(iteration, item)
item.runtime_metrics["checkpoint_seconds"] = time.perf_counter() - checkpoint_started
elapsed = time.perf_counter() - started
self._append_metrics(item, elapsed)
if self._time_limit_reached(run_started):
break
iteration += 1
try:
while iteration <= stop:
started = time.perf_counter()
item = self.run_iteration(iteration)
metrics.append(item)
self._maybe_record_self_play_snapshot(iteration)
checkpoint_started = time.perf_counter()
self._save_iteration_checkpoints(iteration, item)
item.runtime_metrics["checkpoint_seconds"] = (
time.perf_counter() - checkpoint_started
)
elapsed = time.perf_counter() - started
self._append_metrics(item, elapsed)
if self._time_limit_reached(run_started):
break
iteration += 1
finally:
self.tracker.close()
return metrics
def _stop_iteration(self) -> int: