Skip to content

Commit 3f2c294

Browse files
feat(eval): cross-regime transfer metric + adaptive (PLR) curriculum
cross_regime_split/cross_regime_transfer score zero-shot A->B (catches regime overfit the within-tier gap misses); AdaptiveCurriculum selects by ZPD solve-rate weighting.
2 parents 7af4bb2 + 7e36488 commit 3f2c294

9 files changed

Lines changed: 673 additions & 6 deletions

File tree

EVALUATION.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,35 @@ A leaderboard entry is incomplete unless it states all of:
6060
positive gap is overfit; near zero generalizes. An entry with a strong train number
6161
and no reported test number is presumed overfit.
6262

63+
## Cross-regime transfer (a stronger robustness signal)
64+
65+
The generalization gap varies the *seed band* inside one `distribution_mode`, so a policy
66+
that only works in calm markets but is scored solely on calm seeds still passes. The
67+
cross-regime transfer metric closes that hole: it holds the seed band fixed and varies the
68+
*regime*, scoring a policy in-distribution on one tier and **zero-shot** out-of-distribution
69+
on another.
70+
71+
`cross_regime_transfer(make_env_for_seed_and_mode, train_mode, test_mode, seeds)` reports
72+
`in_distribution` and `out_of_distribution` aggregates plus `transfer_gap_deflated_sharpe`
73+
(in-distribution minus out-of-distribution). Because the seed band is identical on both
74+
sides, `train_mode == test_mode` reuses byte-identical envs and the gap is exactly `0` by
75+
construction; a large positive gap on `calm -> extreme` is a regime-specific overfit a
76+
within-tier gap cannot see. The Rust core exposes the protocol primitive
77+
`cross_regime_split(train_spec, test_mode)` (the seed-band-preserving, regime-swapping
78+
sibling of `train_test_split`). Reporting a `calm -> hard` and a `calm -> extreme` transfer
79+
gap alongside the within-tier generalization gap is strictly stronger evidence of robustness.
80+
81+
## Adaptive curriculum (training side)
82+
83+
For *training* (this is a training aid, not a leaderboard rule), an adaptive curriculum
84+
targets difficulty by the agent's online success rate instead of a fixed tier rotation.
85+
`AdaptiveScheduler` / `AdaptiveCurriculumEnv` (Python) and `AdaptiveCurriculum` (Rust) score
86+
each candidate level by the zone-of-proximal-development weight `p * (1 - p)`, up-weighting
87+
levels the agent solves 30-70% of the time (the richest learning signal) and down-weighting
88+
the trivially-solved and hopeless tails. Selection is a pure deterministic function of the
89+
recorded outcome history (Prioritized Level Replay), so a curriculum run replays identically
90+
from its outcome log.
91+
6392
## Baseline leaderboard (numbers to beat)
6493

6594
These are the trivial reference policies every entrant must clear: a do-nothing `flat`,

crates/openoutcry-py/python/openoutcry/__init__.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
FrameStack,
2222
RecordEpisodeStatistics,
2323
)
24-
from .generalization import train_test_seeds, evaluate_seeds, generalization_gap
24+
from .generalization import (
25+
train_test_seeds,
26+
evaluate_seeds,
27+
generalization_gap,
28+
cross_regime_transfer,
29+
)
2530
from .verifiers_env import OpenOutcryVerifiersEnv, load_environment, build_rubric
2631
from .dataset import build_scenario_dataset, seed_ranges_disjoint
2732
from .decision_parser import parse_decision
@@ -112,7 +117,12 @@
112117
from .market_env import EndogenousMarketEnv
113118
from .checkpoint import CheckpointableEnv, CheckpointState
114119
from .functional import OpenOutcryFuncEnv
115-
from .curriculum import CurriculumEnv, regime_curriculum
120+
from .curriculum import (
121+
CurriculumEnv,
122+
regime_curriculum,
123+
AdaptiveScheduler,
124+
AdaptiveCurriculumEnv,
125+
)
116126
from .preprocessing import (
117127
PreprocessingConfig,
118128
ExecutionNoiseConfig,
@@ -164,6 +174,7 @@
164174
"train_test_seeds",
165175
"evaluate_seeds",
166176
"generalization_gap",
177+
"cross_regime_transfer",
167178
"OpenOutcryVerifiersEnv",
168179
"load_environment",
169180
"build_rubric",
@@ -196,6 +207,8 @@
196207
"OpenOutcryFuncEnv",
197208
"CurriculumEnv",
198209
"regime_curriculum",
210+
"AdaptiveScheduler",
211+
"AdaptiveCurriculumEnv",
199212
"PreprocessingConfig",
200213
"ExecutionNoiseConfig",
201214
"CANONICAL_PREPROCESSING",

crates/openoutcry-py/python/openoutcry/curriculum.py

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,4 +178,158 @@ def _factory(idx: int) -> gym.Env:
178178
)
179179

180180

181-
__all__ = ["CurriculumEnv", "regime_curriculum"]
181+
# -- adaptive difficulty-targeting curriculum (Prioritized Level Replay) ----------
182+
183+
184+
class AdaptiveScheduler:
185+
"""Prioritized-Level-Replay difficulty targeting over a fixed candidate level set.
186+
187+
A fixed rotation replays trivially-solved levels and hopeless ones in equal measure.
188+
PLR instead spends the next episode on a level in the agent's *zone of proximal
189+
development*: one it solves *sometimes* (the 30-70%-solve band), where the learning
190+
signal is richest. This tracks a per-level solve rate from recorded outcomes and
191+
scores each level by the ZPD weight ``p * (1 - p)`` (Bernoulli variance): maximal at
192+
``p = 0.5``, decaying to zero as a level becomes trivially easy (``p -> 1``) or
193+
hopeless (``p -> 0``). :meth:`select_next` is a **pure deterministic function of the
194+
recorded history** (argmax weight, ties broken by lowest index, no RNG). Unseen levels
195+
take a ``prior`` pseudo-rate (default ``0.5``, the peak) so each is explored once
196+
before the mid band is replayed.
197+
"""
198+
199+
def __init__(self, levels: Sequence[int], *, prior: float = 0.5) -> None:
200+
deduped: list[int] = []
201+
for x in levels:
202+
xi = int(x)
203+
if xi not in deduped:
204+
deduped.append(xi)
205+
if not deduped:
206+
raise ValueError("levels must be non-empty")
207+
self._levels = deduped
208+
self._solves: dict[int, int] = {x: 0 for x in deduped}
209+
self._attempts: dict[int, int] = {x: 0 for x in deduped}
210+
self._prior = float(prior)
211+
212+
@property
213+
def levels(self) -> list[int]:
214+
"""The scheduled candidate levels (seeds), in tie-break order (copy)."""
215+
return list(self._levels)
216+
217+
def success_rate(self, level: int) -> float:
218+
"""Observed ``solves / attempts`` for ``level``, or the ``prior`` when unseen."""
219+
level = int(level)
220+
if level not in self._attempts:
221+
raise KeyError(f"level {level} is off-schedule")
222+
a = self._attempts[level]
223+
return self._prior if a == 0 else self._solves[level] / a
224+
225+
def weight(self, level: int) -> float:
226+
"""ZPD replay weight ``p * (1 - p)`` (peaks at ``p = 0.5``, zero at both tails)."""
227+
p = self.success_rate(level)
228+
return p * (1.0 - p)
229+
230+
def record(self, level: int, solved: bool) -> None:
231+
"""Record one episode outcome for ``level`` (``solved`` = success criterion met)."""
232+
level = int(level)
233+
if level not in self._attempts:
234+
raise KeyError(f"level {level} is off-schedule")
235+
self._attempts[level] += 1
236+
self._solves[level] += 1 if solved else 0
237+
238+
def select_next(self) -> int:
239+
"""The next level to replay: highest-weight candidate, ties broken by lowest index."""
240+
best = self._levels[0]
241+
best_w = self.weight(best)
242+
for lv in self._levels[1:]:
243+
w = self.weight(lv)
244+
if w > best_w:
245+
best, best_w = lv, w
246+
return best
247+
248+
249+
class AdaptiveCurriculumEnv(gym.Wrapper):
250+
"""A curriculum whose next scenario seed is chosen adaptively by the agent's online
251+
success rate (Prioritized Level Replay) rather than a fixed rotation.
252+
253+
On every ``reset()`` the wrapper asks an :class:`AdaptiveScheduler` for the
254+
highest-learning-signal (mid-difficulty) level and points the env at that seed; as the
255+
episode runs it accumulates reward, and on episode end it records a solved/failed
256+
outcome (``solved_fn(total_reward)``, default: a net-positive episode return) back into
257+
the scheduler. The seed choice is deterministic given the observed outcome history, so
258+
the same run replays identically.
259+
260+
Parameters
261+
----------
262+
levels:
263+
The candidate scenario seeds to target adaptively.
264+
solved_fn:
265+
``(total_episode_return) -> bool`` success criterion. Defaults to "made money"
266+
(``total > 0``), a deterministic proxy for a trading "solve".
267+
prior:
268+
Unseen-level pseudo success rate (default ``0.5``, the ZPD peak).
269+
env_factory:
270+
Optional ``(seed) -> gym.Env`` builder rebuilt per episode (e.g. to fix a
271+
construction-time ``distribution_mode``). When ``None`` a single
272+
:class:`OpenOutcryEnv` is built from ``env_kwargs`` and re-pointed via
273+
``reset(seed=...)``.
274+
**env_kwargs:
275+
Forwarded to :class:`OpenOutcryEnv` when ``env_factory`` is ``None``.
276+
"""
277+
278+
def __init__(
279+
self,
280+
levels: Sequence[int],
281+
*,
282+
solved_fn: Optional[Callable[[float], bool]] = None,
283+
prior: float = 0.5,
284+
env_factory: Optional[EnvFactory] = None,
285+
**env_kwargs,
286+
) -> None:
287+
self._scheduler = AdaptiveScheduler(levels, prior=prior)
288+
self._solved_fn = solved_fn or (lambda total: total > 0.0)
289+
self._env_factory = env_factory
290+
self._active_seed: Optional[int] = None
291+
self._episode_return = 0.0
292+
293+
first = self._scheduler.levels[0]
294+
env = env_factory(first) if env_factory is not None else OpenOutcryEnv(**env_kwargs)
295+
super().__init__(env)
296+
297+
@property
298+
def scheduler(self) -> AdaptiveScheduler:
299+
return self._scheduler
300+
301+
def reset(self, *, seed: Optional[int] = None, options: Optional[dict] = None):
302+
"""Reset onto the scheduler's next (mid-difficulty) seed; any external ``seed`` is
303+
ignored so the adaptive sequence stays deterministic in the outcome history."""
304+
active = self._scheduler.select_next()
305+
self._active_seed = active
306+
self._episode_return = 0.0
307+
if self._env_factory is not None:
308+
self.env = self._env_factory(active)
309+
obs, info = self.env.reset()
310+
else:
311+
obs, info = self.env.reset(seed=active)
312+
info["curriculum"] = {
313+
"seed": active,
314+
"success_rate": self._scheduler.success_rate(active),
315+
"weight": self._scheduler.weight(active),
316+
}
317+
return obs, info
318+
319+
def step(self, action):
320+
"""Advance one bar; on episode end record the solved/failed outcome for the seed."""
321+
obs, reward, terminated, truncated, info = self.env.step(action)
322+
self._episode_return += float(reward)
323+
if bool(terminated) or bool(truncated):
324+
solved = bool(self._solved_fn(self._episode_return))
325+
self._scheduler.record(self._active_seed, solved)
326+
info["curriculum_solved"] = solved
327+
return obs, reward, terminated, truncated, info
328+
329+
330+
__all__ = [
331+
"CurriculumEnv",
332+
"regime_curriculum",
333+
"AdaptiveScheduler",
334+
"AdaptiveCurriculumEnv",
335+
]

crates/openoutcry-py/python/openoutcry/generalization.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .openoutcry_py import score_run
2121

2222
MakeEnv = Callable[[int], object]
23+
MakeEnvMode = Callable[[int, str], object]
2324
Policy = Callable[[dict], np.ndarray]
2425

2526

@@ -123,4 +124,60 @@ def generalization_gap(
123124
}
124125

125126

126-
__all__ = ["train_test_seeds", "evaluate_seeds", "generalization_gap"]
127+
def cross_regime_transfer(
128+
make_env_for_seed_and_mode: MakeEnvMode,
129+
train_mode: str,
130+
test_mode: str,
131+
seeds: Sequence[int],
132+
policy: Optional[Policy] = None,
133+
max_steps: int = 512,
134+
*,
135+
n_trials: int = 0,
136+
) -> dict:
137+
"""Zero-shot cross-regime transfer gap: select on regime A, score on regime B.
138+
139+
:func:`generalization_gap` varies the *seed band* inside one ``distribution_mode``, so
140+
a policy that only works in (say) calm markets but is scored solely on calm seeds still
141+
passes. This instead varies the *regime* while holding the seed band fixed: the policy
142+
is scored in-distribution on ``train_mode`` and zero-shot out-of-distribution on
143+
``test_mode`` over the **same** ``seeds``. The transfer gap (in-distribution minus
144+
out-of-distribution deflated Sharpe) isolates regime-specific overfit, which a
145+
within-tier seed gap is blind to, so it is a strictly stronger robustness signal.
146+
147+
``make_env_for_seed_and_mode(seed, mode)`` must build a fresh env at a given scenario
148+
seed and ``distribution_mode``. Because the seed band is identical across the two
149+
evaluations, ``train_mode == test_mode`` reuses byte-identical envs and the transfer
150+
gap is exactly ``0`` by construction.
151+
"""
152+
seeds = list(seeds)
153+
in_dist = evaluate_seeds(
154+
lambda s: make_env_for_seed_and_mode(s, train_mode),
155+
seeds,
156+
policy,
157+
max_steps,
158+
n_trials=n_trials,
159+
)
160+
out_dist = evaluate_seeds(
161+
lambda s: make_env_for_seed_and_mode(s, test_mode),
162+
seeds,
163+
policy,
164+
max_steps,
165+
n_trials=n_trials,
166+
)
167+
return {
168+
"train_mode": train_mode,
169+
"test_mode": test_mode,
170+
"in_distribution": in_dist,
171+
"out_of_distribution": out_dist,
172+
"transfer_gap_deflated_sharpe": in_dist["deflated_sharpe"]
173+
- out_dist["deflated_sharpe"],
174+
"transfer_gap_mean_return": in_dist["mean_return"] - out_dist["mean_return"],
175+
}
176+
177+
178+
__all__ = [
179+
"train_test_seeds",
180+
"evaluate_seeds",
181+
"generalization_gap",
182+
"cross_regime_transfer",
183+
]

crates/openoutcry-py/tests/test_conformance.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,18 @@
2525
train_test_seeds,
2626
evaluate_seeds,
2727
generalization_gap,
28+
cross_regime_transfer,
2829
)
2930

3031

3132
def _make(seed: int) -> OpenOutcryEnv:
3233
return OpenOutcryEnv(n_symbols=3, n_days=50, seed=seed)
3334

3435

36+
def _make_mode(seed: int, mode: str) -> OpenOutcryEnv:
37+
return OpenOutcryEnv(n_symbols=3, n_days=50, seed=seed, distribution_mode=mode)
38+
39+
3540
def _equal_weight(env) -> np.ndarray:
3641
n = env.action_space.shape[0]
3742
return np.full((n,), 1.0 / n, dtype=np.float32)
@@ -184,3 +189,29 @@ def test_generalization_gap_end_to_end():
184189
assert set(out["train"]) >= {"deflated_sharpe", "passed_k_rate", "mean_return"}
185190
assert np.isfinite(out["gap_deflated_sharpe"])
186191
assert np.isfinite(out["gap_mean_return"])
192+
193+
194+
def test_cross_regime_transfer_identical_mode_is_zero_gap():
195+
# Same regime on both sides reuses byte-identical envs, so the transfer gap vanishes.
196+
out = cross_regime_transfer(_make_mode, "calm", "calm", seeds=[0, 1], max_steps=16)
197+
assert out["transfer_gap_deflated_sharpe"] == 0.0
198+
assert out["transfer_gap_mean_return"] == 0.0
199+
200+
201+
def test_cross_regime_transfer_different_mode_reports_a_gap():
202+
out = cross_regime_transfer(_make_mode, "calm", "extreme", seeds=[0, 1], max_steps=16)
203+
assert set(out) == {
204+
"train_mode",
205+
"test_mode",
206+
"in_distribution",
207+
"out_of_distribution",
208+
"transfer_gap_deflated_sharpe",
209+
"transfer_gap_mean_return",
210+
}
211+
assert np.isfinite(out["transfer_gap_deflated_sharpe"])
212+
assert np.isfinite(out["transfer_gap_mean_return"])
213+
# A genuine zero-shot shift: calm and extreme drive different reward series.
214+
assert (
215+
out["in_distribution"]["mean_return"]
216+
!= out["out_of_distribution"]["mean_return"]
217+
)

0 commit comments

Comments
 (0)