Spaces:
Sleeping
Sleeping
File size: 8,599 Bytes
16038fc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 | """Curriculum scheduler for progressive difficulty training.
Manages task selection and difficulty progression for RL training.
The scheduler promotes agents from easier to harder episodes as performance
improves, ensuring stable gradient signals early in training without
sacrificing challenge once the agent becomes capable.
Difficulty progression:
easy -> light noise, 300 rows, 22 steps
medium -> medium noise, 500 rows, 18 steps
hard -> heavy noise, 1000 rows, 15 steps
Task sampling modes:
round_robin - cycle through task_ids in order (default)
random - uniform random pick at each episode
single - stick to one task_id (pass task_ids as a 1-element list)
"""
from __future__ import annotations
import random
from collections import deque
from dataclasses import dataclass, field
from typing import Any, Deque, List, Optional
from fsds_cleaning_env.dataset_generators import (
SIZE_LARGE,
SIZE_MEDIUM,
SIZE_SMALL,
NoiseProfile,
)
# ββ Difficulty definitions βββββββββββββββββββββββββββββββββββββββββββββββββββββ
@dataclass(frozen=True)
class DifficultyLevel:
"""Parameters for one curriculum stage."""
name: str
noise_profile: NoiseProfile
n_rows: int
max_steps: int
# Minimum success_rate over `window_size` recent episodes needed to advance.
promotion_threshold: float
# How many recent episodes to consider for promotion.
window_size: int
DIFFICULTY_LEVELS: List[DifficultyLevel] = [
DifficultyLevel(
name="easy",
noise_profile=NoiseProfile.light(),
n_rows=SIZE_SMALL,
max_steps=22,
promotion_threshold=0.70,
window_size=10,
),
DifficultyLevel(
name="medium",
noise_profile=NoiseProfile.medium(),
n_rows=SIZE_MEDIUM,
max_steps=18,
promotion_threshold=0.65,
window_size=15,
),
DifficultyLevel(
name="hard",
noise_profile=NoiseProfile.heavy(),
n_rows=SIZE_LARGE,
max_steps=15,
promotion_threshold=1.01, # unreachable; "hard" is the final level
window_size=20,
),
]
# Map name to level for convenience.
LEVELS_BY_NAME: dict[str, DifficultyLevel] = {lvl.name: lvl for lvl in DIFFICULTY_LEVELS}
ALL_TASK_IDS: List[str] = ["ecommerce_mobile", "subscription_churn", "delivery_eta"]
# ββ CurriculumTask βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@dataclass
class CurriculumTask:
"""A single training task assignment produced by the scheduler."""
task_id: str
max_steps: int
noise_profile: NoiseProfile
n_rows: int
difficulty: str # "easy" | "medium" | "hard"
seed: Optional[int] = None
def reset_kwargs(self) -> dict[str, Any]:
"""Keyword arguments suitable for passing to env.reset()."""
return {
"task_id": self.task_id,
"seed": self.seed,
"dataset_n_rows": self.n_rows,
"noise_profile_override": self.noise_profile,
}
# ββ CurriculumScheduler ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class CurriculumScheduler:
"""Assigns training tasks and promotes difficulty as the agent improves.
Parameters
----------
task_ids:
Task IDs to rotate through. Defaults to all three built-in tasks.
mode:
``"round_robin"`` β cycle tasks in order (default).
``"random"`` β uniform random pick each episode.
start_level:
Starting difficulty level name: ``"easy"``, ``"medium"``, or ``"hard"``.
rng:
Optional ``random.Random`` instance for reproducibility.
"""
def __init__(
self,
task_ids: Optional[List[str]] = None,
mode: str = "round_robin",
start_level: str = "easy",
rng: Optional[random.Random] = None,
) -> None:
self._task_ids: List[str] = task_ids or list(ALL_TASK_IDS)
if not self._task_ids:
raise ValueError("task_ids must be non-empty")
if mode not in ("round_robin", "random"):
raise ValueError(f"Unknown mode: {mode!r}. Use 'round_robin' or 'random'.")
self._mode = mode
self._rng = rng or random.Random()
self._level_index: int = self._name_to_index(start_level)
self._task_index: int = 0 # for round_robin
# Rolling window of success flags for the current difficulty level.
self._window: Deque[bool] = deque()
self._episode_count: int = 0
self._promotions: List[dict[str, Any]] = []
# ββ Public interface βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@property
def current_level(self) -> DifficultyLevel:
return DIFFICULTY_LEVELS[self._level_index]
@property
def level_name(self) -> str:
return self.current_level.name
@property
def at_max_difficulty(self) -> bool:
return self._level_index >= len(DIFFICULTY_LEVELS) - 1
def next_task(self, seed: Optional[int] = None) -> CurriculumTask:
"""Return the next task assignment for the current difficulty stage."""
level = self.current_level
task_id = self._pick_task()
return CurriculumTask(
task_id=task_id,
max_steps=level.max_steps,
noise_profile=level.noise_profile,
n_rows=level.n_rows,
difficulty=level.name,
seed=seed,
)
def record_episode(self, success: bool) -> bool:
"""Record episode outcome. Returns True if a promotion just occurred."""
level = self.current_level
self._window.append(success)
if len(self._window) > level.window_size:
self._window.popleft()
self._episode_count += 1
return self._maybe_promote()
def summary(self) -> dict[str, Any]:
"""Serializable scheduler state for logging."""
level = self.current_level
window_success_rate = (
sum(self._window) / len(self._window) if self._window else 0.0
)
return {
"current_difficulty": level.name,
"level_index": self._level_index,
"episode_count": self._episode_count,
"window_size": len(self._window),
"window_success_rate": round(window_success_rate, 4),
"promotion_threshold": level.promotion_threshold,
"at_max_difficulty": self.at_max_difficulty,
"promotions": list(self._promotions),
}
# ββ Internal helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@staticmethod
def _name_to_index(name: str) -> int:
for i, lvl in enumerate(DIFFICULTY_LEVELS):
if lvl.name == name:
return i
raise ValueError(f"Unknown difficulty level: {name!r}. Choose from {[l.name for l in DIFFICULTY_LEVELS]}")
def _pick_task(self) -> str:
if self._mode == "random":
return self._rng.choice(self._task_ids)
# round_robin
task_id = self._task_ids[self._task_index % len(self._task_ids)]
self._task_index += 1
return task_id
def _maybe_promote(self) -> bool:
level = self.current_level
if self.at_max_difficulty:
return False
if len(self._window) < level.window_size:
return False
rate = sum(self._window) / len(self._window)
if rate >= level.promotion_threshold:
old_name = level.name
self._level_index += 1
self._window.clear()
self._promotions.append(
{
"episode": self._episode_count,
"from": old_name,
"to": self.current_level.name,
"trigger_rate": round(rate, 4),
}
)
return True
return False
__all__ = [
"DifficultyLevel",
"DIFFICULTY_LEVELS",
"LEVELS_BY_NAME",
"ALL_TASK_IDS",
"CurriculumTask",
"CurriculumScheduler",
]
|