Refactor routes/ml.py: replace state dicts with dataclasses + threading locks
- Add @dataclass classes _TrainingState, _PPOState, _TournamentState with snapshot() method - Wrap all state mutations in threading.Lock() for thread safety - Replace dict bracket access with attribute access - Remove unnecessary global declarations from pipeline functions
This commit is contained in:
1 parent
cad2f00ec7
commit
2936fd37eb
1 file changed
+157
-113
+157
-113
@@ -22,6 +22,7 @@ PPO training, and tournament endpoints.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -37,42 +38,60 @@ log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||
|
||||
router = APIRouter(tags=["ml"])
|
||||
|
||||
# ---- module-level state (moved from lab_api.py) ----
|
||||
|
||||
# ── module-level singletons ──────────────────────────────────────────────────
|
||||
_policy_service = None
|
||||
_policy_service_init_attempted = False
|
||||
|
||||
_training_state = {
|
||||
"running": False,
|
||||
"progress": 0,
|
||||
"total_epochs": 0,
|
||||
"current_epoch": 0,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"metrics": None,
|
||||
"result": None,
|
||||
"error": None,
|
||||
"started_at": None,
|
||||
}
|
||||
# ── Thread-safe state objects ────────────────────────────────────────────────
|
||||
@dataclasses.dataclass
|
||||
class _TrainingState:
|
||||
running: bool = False
|
||||
progress: int = 0
|
||||
total_epochs: int = 0
|
||||
current_epoch: int = 0
|
||||
phase: str = "idle"
|
||||
message: str = ""
|
||||
metrics: object = None
|
||||
result: object = None
|
||||
error: object = None
|
||||
started_at: object = None
|
||||
|
||||
_ppo_state = {
|
||||
"running": False,
|
||||
"iteration": 0,
|
||||
"total_iterations": 0,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"metrics": None,
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
def snapshot(self) -> dict:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
_tournament_state = {
|
||||
"running": False,
|
||||
"phase": "idle",
|
||||
"message": "",
|
||||
"result": None,
|
||||
"error": None,
|
||||
}
|
||||
@dataclasses.dataclass
|
||||
class _PPOState:
|
||||
running: bool = False
|
||||
iteration: int = 0
|
||||
total_iterations: int = 0
|
||||
phase: str = "idle"
|
||||
message: str = ""
|
||||
metrics: object = None
|
||||
result: object = None
|
||||
error: object = None
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _TournamentState:
|
||||
running: bool = False
|
||||
phase: str = "idle"
|
||||
message: str = ""
|
||||
result: object = None
|
||||
error: object = None
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
return dataclasses.asdict(self)
|
||||
|
||||
_training_state = _TrainingState()
|
||||
_training_lock = threading.Lock()
|
||||
|
||||
_ppo_state = _PPOState()
|
||||
_ppo_lock = threading.Lock()
|
||||
|
||||
_tournament_state = _TournamentState()
|
||||
_tournament_lock = threading.Lock()
|
||||
|
||||
# module-level toggle (local copy; the shared one is for batch runs)
|
||||
_ml_logging_enabled_local = _ml_logging_enabled
|
||||
@@ -252,16 +271,17 @@ def _run_training_pipeline(
|
||||
batch_size: int, patience: int, rebuild_dataset: bool,
|
||||
):
|
||||
"""Run the full ML training pipeline in a background thread."""
|
||||
global _training_state, _policy_service, _policy_service_init_attempted
|
||||
global _policy_service, _policy_service_init_attempted
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_training_state["running"] = True
|
||||
_training_state["started_at"] = datetime.now().isoformat()
|
||||
_training_state["error"] = None
|
||||
_training_state["result"] = None
|
||||
with _training_lock:
|
||||
_training_state.running = True
|
||||
_training_state.started_at = datetime.now().isoformat()
|
||||
_training_state.error = None
|
||||
_training_state.result = None
|
||||
data_dir = os.path.join(project_root, "ml", "models")
|
||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
@@ -269,8 +289,9 @@ def _run_training_pipeline(
|
||||
# Phase 1: Build Dataset
|
||||
train_path = os.path.join(data_dir, "train.npz")
|
||||
if rebuild_dataset or not os.path.exists(train_path):
|
||||
_training_state["phase"] = "building"
|
||||
_training_state["message"] = "Loading card embeddings & building dataset..."
|
||||
with _training_lock:
|
||||
_training_state.phase = "building"
|
||||
_training_state.message = "Loading card embeddings & building dataset..."
|
||||
log_ml.info("Building dataset (loading embeddings, may auto-download)...")
|
||||
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
|
||||
dataset = build_dataset(results_dir=results_dir)
|
||||
@@ -281,13 +302,15 @@ def _run_training_pipeline(
|
||||
save_dataset(val_ds, os.path.join(data_dir, "val.npz"))
|
||||
save_dataset(test_ds, os.path.join(data_dir, "test.npz"))
|
||||
total_samples = len(dataset["states"])
|
||||
_training_state["message"] = f"Dataset built: {total_samples} samples"
|
||||
with _training_lock:
|
||||
_training_state.message = f"Dataset built: {total_samples} samples"
|
||||
log_ml.info(f"Dataset built: {total_samples} samples")
|
||||
# Phase 2: Train
|
||||
_training_state["phase"] = "training"
|
||||
_training_state["total_epochs"] = epochs
|
||||
_training_state["current_epoch"] = 0
|
||||
_training_state["message"] = f"Training policy network ({epochs} epochs)..."
|
||||
with _training_lock:
|
||||
_training_state.phase = "training"
|
||||
_training_state.total_epochs = epochs
|
||||
_training_state.current_epoch = 0
|
||||
_training_state.message = f"Training policy network ({epochs} epochs)..."
|
||||
log_ml.info(f"Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -312,11 +335,13 @@ def _run_training_pipeline(
|
||||
checkpoint_dir=ckpt_dir,
|
||||
)
|
||||
summary = trainer.train(train_states, train_labels, val_states, val_labels)
|
||||
_training_state["current_epoch"] = summary.get("epochs_trained", epochs)
|
||||
_training_state["metrics"] = summary
|
||||
with _training_lock:
|
||||
_training_state.current_epoch = summary.get("epochs_trained", epochs)
|
||||
_training_state.metrics = summary
|
||||
# Phase 3: Evaluate
|
||||
_training_state["phase"] = "evaluating"
|
||||
_training_state["message"] = "Evaluating on test set..."
|
||||
with _training_lock:
|
||||
_training_state.phase = "evaluating"
|
||||
_training_state.message = "Evaluating on test set..."
|
||||
test_path = os.path.join(data_dir, "test.npz")
|
||||
eval_results = None
|
||||
if os.path.exists(test_path):
|
||||
@@ -328,15 +353,16 @@ def _run_training_pipeline(
|
||||
with open(eval_path, "w") as f:
|
||||
json.dump(eval_results, f, indent=2)
|
||||
# Done
|
||||
_training_state["phase"] = "done"
|
||||
_training_state["running"] = False
|
||||
_training_state["result"] = {
|
||||
"training": summary,
|
||||
"evaluation": eval_results,
|
||||
"checkpoint": summary.get("checkpoint_path", ""),
|
||||
"device": device,
|
||||
}
|
||||
_training_state["message"] = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
|
||||
with _training_lock:
|
||||
_training_state.phase = "done"
|
||||
_training_state.running = False
|
||||
_training_state.result = {
|
||||
"training": summary,
|
||||
"evaluation": eval_results,
|
||||
"checkpoint": summary.get("checkpoint_path", ""),
|
||||
"device": device,
|
||||
}
|
||||
_training_state.message = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
|
||||
log_ml.info(f"Complete: {summary.get('best_val_accuracy', 0):.1%} val accuracy")
|
||||
# Auto-reload policy server
|
||||
if _policy_service and _policy_service._loaded:
|
||||
@@ -346,10 +372,11 @@ def _run_training_pipeline(
|
||||
_policy_service_init_attempted = False
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_training_state["phase"] = "error"
|
||||
_training_state["running"] = False
|
||||
_training_state["error"] = str(e)
|
||||
_training_state["message"] = f"Training failed: {e}"
|
||||
with _training_lock:
|
||||
_training_state.phase = "error"
|
||||
_training_state.running = False
|
||||
_training_state.error = str(e)
|
||||
_training_state.message = f"Training failed: {e}"
|
||||
log_ml.error(f"ERROR: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -357,7 +384,7 @@ def _run_training_pipeline(
|
||||
@router.post("/api/ml/train")
|
||||
async def ml_start_training(request: FastAPIRequest):
|
||||
"""Trigger ML training pipeline from the web UI."""
|
||||
if _training_state["running"]:
|
||||
if _training_state.running:
|
||||
raise HTTPException(409, "Training already in progress")
|
||||
body = await request.json() if await request.body() else {}
|
||||
epochs = body.get("epochs", 50)
|
||||
@@ -366,12 +393,16 @@ async def ml_start_training(request: FastAPIRequest):
|
||||
patience = body.get("patience", 10)
|
||||
rebuild = body.get("rebuildDataset", True)
|
||||
results_dir = os.path.join(str(Path(__file__).resolve().parent.parent), "results")
|
||||
_training_state.update({
|
||||
"running": True, "progress": 0, "total_epochs": epochs,
|
||||
"current_epoch": 0, "phase": "starting",
|
||||
"message": "Initializing training pipeline...",
|
||||
"metrics": None, "result": None, "error": None,
|
||||
})
|
||||
with _training_lock:
|
||||
_training_state.running = True
|
||||
_training_state.progress = 0
|
||||
_training_state.total_epochs = epochs
|
||||
_training_state.current_epoch = 0
|
||||
_training_state.phase = "starting"
|
||||
_training_state.message = "Initializing training pipeline..."
|
||||
_training_state.metrics = None
|
||||
_training_state.result = None
|
||||
_training_state.error = None
|
||||
thread = threading.Thread(
|
||||
target=_run_training_pipeline,
|
||||
args=(results_dir, epochs, lr, batch_size, patience, rebuild),
|
||||
@@ -390,7 +421,8 @@ async def ml_start_training(request: FastAPIRequest):
|
||||
@router.get("/api/ml/train/status")
|
||||
async def ml_training_status():
|
||||
"""Get current training pipeline status."""
|
||||
return dict(_training_state)
|
||||
with _training_lock:
|
||||
return _training_state.snapshot()
|
||||
|
||||
|
||||
@router.get("/api/ml/data/status")
|
||||
@@ -461,15 +493,15 @@ def _run_ppo_pipeline(
|
||||
load_supervised: str,
|
||||
):
|
||||
"""Run PPO training in a background thread."""
|
||||
global _ppo_state
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_ppo_state["running"] = True
|
||||
_ppo_state["error"] = None
|
||||
_ppo_state["result"] = None
|
||||
with _ppo_lock:
|
||||
_ppo_state.running = True
|
||||
_ppo_state.error = None
|
||||
_ppo_state.result = None
|
||||
from ml.training.ppo_trainer import PPOTrainer, PPOConfig
|
||||
ckpt_dir = os.path.join(project_root, "ml", "models", "checkpoints")
|
||||
config = PPOConfig(
|
||||
@@ -482,30 +514,33 @@ def _run_ppo_pipeline(
|
||||
)
|
||||
trainer = PPOTrainer(config)
|
||||
def progress_cb(iteration, metrics):
|
||||
_ppo_state["iteration"] = iteration
|
||||
_ppo_state["phase"] = "training"
|
||||
_ppo_state["message"] = f"Iteration {iteration}/{iterations} | WR: {metrics.get('win_rate', 0):.0%}"
|
||||
_ppo_state["metrics"] = metrics
|
||||
with _ppo_lock:
|
||||
_ppo_state.iteration = iteration
|
||||
_ppo_state.phase = "training"
|
||||
_ppo_state.message = f"Iteration {iteration}/{iterations} | WR: {metrics.get('win_rate', 0):.0%}"
|
||||
_ppo_state.metrics = metrics
|
||||
summary = trainer.train(progress_callback=progress_cb)
|
||||
_ppo_state["phase"] = "done"
|
||||
_ppo_state["running"] = False
|
||||
_ppo_state["result"] = summary
|
||||
_ppo_state["message"] = f"PPO complete! Best win rate: {summary.get('best_win_rate', 0):.0%}"
|
||||
with _ppo_lock:
|
||||
_ppo_state.phase = "done"
|
||||
_ppo_state.running = False
|
||||
_ppo_state.result = summary
|
||||
_ppo_state.message = f"PPO complete! Best win rate: {summary.get('best_win_rate', 0):.0%}"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_ppo_state["phase"] = "error"
|
||||
_ppo_state["running"] = False
|
||||
_ppo_state["error"] = str(e)
|
||||
_ppo_state["message"] = f"PPO failed: {e}"
|
||||
with _ppo_lock:
|
||||
_ppo_state.phase = "error"
|
||||
_ppo_state.running = False
|
||||
_ppo_state.error = str(e)
|
||||
_ppo_state.message = f"PPO failed: {e}"
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post("/api/ml/train/ppo")
|
||||
async def ml_start_ppo(request: FastAPIRequest):
|
||||
"""Start PPO training pipeline."""
|
||||
if _ppo_state["running"]:
|
||||
if _ppo_state.running:
|
||||
raise HTTPException(409, "PPO training already in progress")
|
||||
if _training_state["running"]:
|
||||
if _training_state.running:
|
||||
raise HTTPException(409, "Supervised training in progress -- wait for it to finish")
|
||||
body = await request.json() if await request.body() else {}
|
||||
iterations = body.get("iterations", 100)
|
||||
@@ -518,11 +553,15 @@ async def ml_start_ppo(request: FastAPIRequest):
|
||||
opponent = body.get("opponent", "heuristic")
|
||||
playstyle = body.get("playstyle", "midrange")
|
||||
load_sup = body.get("loadSupervised", "")
|
||||
_ppo_state.update({
|
||||
"running": True, "iteration": 0, "total_iterations": iterations,
|
||||
"phase": "starting", "message": "Initializing PPO...",
|
||||
"metrics": None, "result": None, "error": None,
|
||||
})
|
||||
with _ppo_lock:
|
||||
_ppo_state.running = True
|
||||
_ppo_state.iteration = 0
|
||||
_ppo_state.total_iterations = iterations
|
||||
_ppo_state.phase = "starting"
|
||||
_ppo_state.message = "Initializing PPO..."
|
||||
_ppo_state.metrics = None
|
||||
_ppo_state.result = None
|
||||
_ppo_state.error = None
|
||||
thread = threading.Thread(
|
||||
target=_run_ppo_pipeline,
|
||||
args=(iterations, episodes, ppo_epochs, batch_size, lr,
|
||||
@@ -536,22 +575,23 @@ async def ml_start_ppo(request: FastAPIRequest):
|
||||
@router.get("/api/ml/train/ppo/status")
|
||||
async def ml_ppo_status():
|
||||
"""Get PPO training status."""
|
||||
return dict(_ppo_state)
|
||||
with _ppo_lock:
|
||||
return _ppo_state.snapshot()
|
||||
|
||||
|
||||
def _run_tournament_pipeline(episodes: int, playstyle: str):
|
||||
"""Run tournament in a background thread."""
|
||||
global _tournament_state
|
||||
try:
|
||||
import sys
|
||||
project_root = str(Path(__file__).resolve().parent.parent)
|
||||
if project_root not in sys.path:
|
||||
sys.path.insert(0, project_root)
|
||||
_tournament_state["running"] = True
|
||||
_tournament_state["error"] = None
|
||||
_tournament_state["result"] = None
|
||||
_tournament_state["phase"] = "running"
|
||||
_tournament_state["message"] = "Running tournament..."
|
||||
with _tournament_lock:
|
||||
_tournament_state.running = True
|
||||
_tournament_state.error = None
|
||||
_tournament_state.result = None
|
||||
_tournament_state.phase = "running"
|
||||
_tournament_state.message = "Running tournament..."
|
||||
from ml.eval.tournament import (
|
||||
run_tournament, HeuristicPolicy, RandomPolicy, LearnedPolicy,
|
||||
)
|
||||
@@ -574,32 +614,35 @@ def _run_tournament_pipeline(episodes: int, playstyle: str):
|
||||
output_path = os.path.join(ckpt_dir, "tournament_results.json")
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(result.to_dict(), f, indent=2)
|
||||
_tournament_state["phase"] = "done"
|
||||
_tournament_state["running"] = False
|
||||
_tournament_state["result"] = result.to_dict()
|
||||
_tournament_state["message"] = f"Tournament complete! {result.total_matches} matches"
|
||||
with _tournament_lock:
|
||||
_tournament_state.phase = "done"
|
||||
_tournament_state.running = False
|
||||
_tournament_state.result = result.to_dict()
|
||||
_tournament_state.message = f"Tournament complete! {result.total_matches} matches"
|
||||
except Exception as e:
|
||||
import traceback
|
||||
_tournament_state["phase"] = "error"
|
||||
_tournament_state["running"] = False
|
||||
_tournament_state["error"] = str(e)
|
||||
_tournament_state["message"] = f"Tournament failed: {e}"
|
||||
with _tournament_lock:
|
||||
_tournament_state.phase = "error"
|
||||
_tournament_state.running = False
|
||||
_tournament_state.error = str(e)
|
||||
_tournament_state.message = f"Tournament failed: {e}"
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@router.post("/api/ml/tournament")
|
||||
async def ml_start_tournament(request: FastAPIRequest):
|
||||
"""Start a tournament evaluation."""
|
||||
if _tournament_state["running"]:
|
||||
if _tournament_state.running:
|
||||
raise HTTPException(409, "Tournament already in progress")
|
||||
body = await request.json() if await request.body() else {}
|
||||
episodes = body.get("episodes", 50)
|
||||
playstyle = body.get("playstyle", "midrange")
|
||||
_tournament_state.update({
|
||||
"running": True, "phase": "starting",
|
||||
"message": "Initializing tournament...",
|
||||
"result": None, "error": None,
|
||||
})
|
||||
with _tournament_lock:
|
||||
_tournament_state.running = True
|
||||
_tournament_state.phase = "starting"
|
||||
_tournament_state.message = "Initializing tournament..."
|
||||
_tournament_state.result = None
|
||||
_tournament_state.error = None
|
||||
thread = threading.Thread(
|
||||
target=_run_tournament_pipeline,
|
||||
args=(episodes, playstyle),
|
||||
@@ -612,7 +655,8 @@ async def ml_start_tournament(request: FastAPIRequest):
|
||||
@router.get("/api/ml/tournament/status")
|
||||
async def ml_tournament_status():
|
||||
"""Get tournament status."""
|
||||
return dict(_tournament_state)
|
||||
with _tournament_lock:
|
||||
return _tournament_state.snapshot()
|
||||
|
||||
|
||||
@router.get("/api/ml/tournament/results")
|
||||
|
||||
Reference in new issue
Block a user