DevOps_Debugger / training /curriculum.py
printf-sourav's picture
Initial commit
27cdb3e
Raw
History Blame Contribute Delete
6.32 kB
"""
Curriculum Scheduler — Unlocks harder scenarios as the agent improves.
Uses rolling 50-episode windows for solve rate calculation to prevent
premature unlocking from lucky streaks. Per the hackathon guide:
"Curriculum is critical for RL convergence."
"""
from __future__ import annotations
import random
from collections import deque
from typing import Dict, List
class CurriculumScheduler:
"""Manages progressive difficulty unlocking for training.
Starts training on Level 1 only. Unlocks Level 2 once the agent
achieves > 80% solve rate over the last 50 Level 1 episodes,
and Level 3 once Level 2 hits > 80% over the last 50 episodes.
Uses rolling windows (not all-time stats) to prevent premature
unlocking from a lucky streak.
Usage:
scheduler = CurriculumScheduler()
levels = scheduler.get_active_levels() # [1]
scheduler.record_episode(level=1, solved=True)
# ... after 50+ episodes with >80% solve rate...
levels = scheduler.get_active_levels() # [1, 2]
"""
def __init__(
self,
unlock_threshold: float = 0.8,
window_size: int = 50,
) -> None:
"""Initialize the curriculum scheduler.
Args:
unlock_threshold: Solve rate threshold to unlock next level (0-1).
window_size: Number of recent episodes to consider for unlock decisions.
"""
self.unlock_threshold = unlock_threshold
self.window_size = window_size
# Rolling windows per level — stores True/False for solved/failed
self._windows: Dict[int, deque] = {
1: deque(maxlen=window_size),
2: deque(maxlen=window_size),
3: deque(maxlen=window_size),
}
self._unlocked: Dict[int, bool] = {
1: True,
2: False,
3: False,
}
self._total_episodes: Dict[int, int] = {1: 0, 2: 0, 3: 0}
def record_episode(self, level: int, solved: bool) -> None:
"""Record the outcome of an episode for curriculum tracking.
Args:
level: The difficulty level of the episode.
solved: Whether the scenario was solved.
"""
if level not in self._windows:
return
self._windows[level].append(solved)
self._total_episodes[level] += 1
self._check_unlocks()
def get_active_levels(self) -> List[int]:
"""Get currently unlocked difficulty levels.
Returns:
List of unlocked level numbers (e.g., [1] or [1, 2]).
"""
return sorted(lvl for lvl, unlocked in self._unlocked.items() if unlocked)
def get_window_solve_rate(self, level: int) -> float:
"""Get the solve rate over the rolling window for a level.
Args:
level: The level to query.
Returns:
Solve rate (0.0-1.0) over the recent window, or 0.0 if no data.
"""
window = self._windows.get(level, deque())
if not window:
return 0.0
return sum(window) / len(window)
def _check_unlocks(self) -> None:
"""Check and perform level unlocks based on rolling window stats."""
# Level 2: unlocks when Level 1 window solve rate >= threshold
if not self._unlocked[2]:
window = self._windows[1]
if len(window) >= self.window_size:
rate = sum(window) / len(window)
if rate >= self.unlock_threshold:
self._unlocked[2] = True
print(f"[Curriculum] ⬆ Level 2 UNLOCKED! "
f"(L1 window solve rate: {rate:.1%} over {len(window)} episodes)")
# Level 3: unlocks when Level 2 window solve rate >= threshold
if self._unlocked[2] and not self._unlocked[3]:
window = self._windows[2]
if len(window) >= self.window_size:
rate = sum(window) / len(window)
if rate >= self.unlock_threshold:
self._unlocked[3] = True
print(f"[Curriculum] ⬆ Level 3 UNLOCKED! "
f"(L2 window solve rate: {rate:.1%} over {len(window)} episodes)")
def sample_level(self) -> int:
"""Sample a level from currently active levels.
Weighted toward the hardest unlocked level (exactly 50% newest,
remaining 50% split across other unlocked levels) to keep the
agent challenged at the frontier.
Returns:
A level number to train on.
"""
active = self.get_active_levels()
if len(active) == 1:
return active[0]
# 50% chance for the hardest unlocked level
newest = max(active)
if random.random() < 0.5:
return newest
others = [lvl for lvl in active if lvl != newest]
return random.choice(others) if others else newest
def update_stats(self, level: int, solve_rate: float, episodes: int = 0) -> None:
"""Bulk-update stats from replay buffer (for API/UI compatibility).
This is a convenience method that doesn't use rolling windows —
prefer record_episode() for training accuracy.
Args:
level: The level to update.
solve_rate: Current solve rate for this level.
episodes: Total episodes at this level.
"""
if level not in self._total_episodes:
return
if episodes > 0:
self._total_episodes[level] = episodes
def get_status(self) -> Dict:
"""Get the current curriculum status.
Returns:
Dict with level stats, unlock status, and window solve rates.
"""
return {
level: {
"unlocked": self._unlocked[level],
"window_solve_rate": round(self.get_window_solve_rate(level), 3),
"window_size": len(self._windows[level]),
"total_episodes": self._total_episodes[level],
}
for level in [1, 2, 3]
}
def force_unlock(self, level: int) -> None:
"""Force-unlock a level (for debugging/testing).
Args:
level: Level to unlock.
"""
if level in self._unlocked:
self._unlocked[level] = True