rlshiftlab 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rlshiftlab/__init__.py +23 -0
- rlshiftlab/comparisons.py +301 -0
- rlshiftlab/exports.py +98 -0
- rlshiftlab/groups.py +440 -0
- rlshiftlab/py.typed +0 -0
- rlshiftlab/records.py +50 -0
- rlshiftlab/report.py +273 -0
- rlshiftlab/results.py +200 -0
- rlshiftlab/runner.py +458 -0
- rlshiftlab/shifts.py +170 -0
- rlshiftlab/stats.py +208 -0
- rlshiftlab-0.2.0.dist-info/METADATA +341 -0
- rlshiftlab-0.2.0.dist-info/RECORD +15 -0
- rlshiftlab-0.2.0.dist-info/WHEEL +4 -0
- rlshiftlab-0.2.0.dist-info/licenses/LICENSE +21 -0
rlshiftlab/runner.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Paired episode evaluation and suite orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import zlib
|
|
8
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from numbers import Integral
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
import gymnasium as gym
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from rlshiftlab import __version__
|
|
17
|
+
from rlshiftlab.records import EpisodeRecord, EpisodeResult, Transition
|
|
18
|
+
from rlshiftlab.results import Results
|
|
19
|
+
from rlshiftlab.shifts import ActionSlip, ObservationNoise, StickyActions
|
|
20
|
+
from rlshiftlab.stats import bootstrap_mean_ci, paired_diff_ci, wilson_interval
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from rlshiftlab.groups import GroupResults
|
|
24
|
+
|
|
25
|
+
StepMetric = Callable[[Transition], float]
|
|
26
|
+
EpisodeMetric = Callable[[EpisodeResult], float]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _metric_maps(
|
|
30
|
+
step_metrics: Mapping[str, StepMetric] | None,
|
|
31
|
+
episode_metrics: Mapping[str, EpisodeMetric] | None,
|
|
32
|
+
) -> tuple[dict[str, StepMetric], dict[str, EpisodeMetric]]:
|
|
33
|
+
"""Validate metric callbacks while preserving their insertion order."""
|
|
34
|
+
checked: list[dict[str, Any]] = []
|
|
35
|
+
for kind, metrics in (("step_metrics", step_metrics), ("episode_metrics", episode_metrics)):
|
|
36
|
+
if metrics is None:
|
|
37
|
+
checked.append({})
|
|
38
|
+
continue
|
|
39
|
+
if not isinstance(metrics, Mapping):
|
|
40
|
+
raise TypeError(f"{kind} must be a mapping of metric names to callbacks")
|
|
41
|
+
selected: dict[str, Any] = {}
|
|
42
|
+
for name, callback in metrics.items():
|
|
43
|
+
if not isinstance(name, str) or not name.strip():
|
|
44
|
+
raise ValueError(f"{kind} contains an invalid metric name {name!r}")
|
|
45
|
+
if name in {"return", "length", "success"}:
|
|
46
|
+
raise ValueError(f"metric name {name!r} is reserved")
|
|
47
|
+
if not callable(callback):
|
|
48
|
+
raise TypeError(f"metric {name!r} callback must be callable")
|
|
49
|
+
selected[name] = callback
|
|
50
|
+
checked.append(selected)
|
|
51
|
+
duplicate = set(checked[0]).intersection(checked[1])
|
|
52
|
+
if duplicate:
|
|
53
|
+
raise ValueError(f"metric name {min(duplicate)!r} appears in both callback maps")
|
|
54
|
+
return checked[0], checked[1]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _metric_value(value: Any, name: str) -> float:
|
|
58
|
+
"""Convert a finite numeric scalar without accepting strings or vectors."""
|
|
59
|
+
try:
|
|
60
|
+
array = np.asarray(value)
|
|
61
|
+
if array.ndim != 0 or array.dtype.kind not in "bifu":
|
|
62
|
+
raise ValueError("expected a numeric scalar")
|
|
63
|
+
result = float(array)
|
|
64
|
+
if not np.isfinite(result):
|
|
65
|
+
raise ValueError("expected a finite numeric scalar")
|
|
66
|
+
return result
|
|
67
|
+
except (TypeError, ValueError, OverflowError) as exc:
|
|
68
|
+
raise ValueError(f"metric {name!r} must return a finite numeric scalar") from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _positive_int(value: int, name: str, minimum: int = 1) -> None:
|
|
72
|
+
if isinstance(value, bool) or not isinstance(value, Integral) or value < minimum:
|
|
73
|
+
raise ValueError(f"{name} must be an integer >= {minimum}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _has_time_limit(env: gym.Env) -> bool:
|
|
77
|
+
current = env
|
|
78
|
+
while isinstance(current, gym.Wrapper):
|
|
79
|
+
if isinstance(current, gym.wrappers.TimeLimit):
|
|
80
|
+
return True
|
|
81
|
+
current = current.env
|
|
82
|
+
return getattr(getattr(env, "spec", None), "max_episode_steps", None) is not None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def run_episodes(
|
|
86
|
+
env: gym.Env,
|
|
87
|
+
policy: Callable[[Any], Any],
|
|
88
|
+
n_episodes: int,
|
|
89
|
+
seed: int = 0,
|
|
90
|
+
max_steps: int = 1000,
|
|
91
|
+
*,
|
|
92
|
+
step_metrics: Mapping[str, StepMetric] | None = None,
|
|
93
|
+
episode_metrics: Mapping[str, EpisodeMetric] | None = None,
|
|
94
|
+
) -> list[EpisodeResult]:
|
|
95
|
+
"""Run a frozen policy with paired reset seeds; the caller owns the env."""
|
|
96
|
+
_positive_int(n_episodes, "n_episodes")
|
|
97
|
+
_positive_int(max_steps, "max_steps")
|
|
98
|
+
if isinstance(seed, bool) or not isinstance(seed, Integral) or seed < 0:
|
|
99
|
+
raise ValueError("seed must be a nonnegative integer")
|
|
100
|
+
if not callable(policy):
|
|
101
|
+
raise TypeError("policy must be callable")
|
|
102
|
+
selected_step_metrics, selected_episode_metrics = _metric_maps(step_metrics, episode_metrics)
|
|
103
|
+
metrics_enabled = bool(selected_step_metrics or selected_episode_metrics)
|
|
104
|
+
|
|
105
|
+
has_time_limit = _has_time_limit(env)
|
|
106
|
+
reset_policy = getattr(policy, "reset", None)
|
|
107
|
+
episodes = []
|
|
108
|
+
for episode_index in range(n_episodes):
|
|
109
|
+
if callable(reset_policy):
|
|
110
|
+
reset_policy()
|
|
111
|
+
episode_seed = int(seed + episode_index)
|
|
112
|
+
observation, _ = env.reset(seed=episode_seed)
|
|
113
|
+
episode_return = 0.0
|
|
114
|
+
length = 0
|
|
115
|
+
terminated = truncated = False
|
|
116
|
+
metric_totals = {name: 0.0 for name in selected_step_metrics}
|
|
117
|
+
final_info: dict[str, Any] = {}
|
|
118
|
+
while not (terminated or truncated):
|
|
119
|
+
# Capture the policy's input before it or the environment can mutate it.
|
|
120
|
+
metric_observation = deepcopy(observation) if metrics_enabled else None
|
|
121
|
+
action = policy(observation)
|
|
122
|
+
metric_action = deepcopy(action) if metrics_enabled else None
|
|
123
|
+
next_observation, reward, terminated, truncated, info = env.step(action)
|
|
124
|
+
episode_return += float(reward)
|
|
125
|
+
length += 1
|
|
126
|
+
if not has_time_limit and length >= max_steps and not terminated and not truncated:
|
|
127
|
+
truncated = True
|
|
128
|
+
if metrics_enabled:
|
|
129
|
+
# One snapshot per step; each callback receives an independent copy.
|
|
130
|
+
transition = Transition(
|
|
131
|
+
observation=metric_observation,
|
|
132
|
+
requested_action=metric_action,
|
|
133
|
+
next_observation=deepcopy(next_observation),
|
|
134
|
+
reward=float(reward),
|
|
135
|
+
terminated=bool(terminated),
|
|
136
|
+
truncated=bool(truncated),
|
|
137
|
+
info=deepcopy(info),
|
|
138
|
+
step_index=length - 1,
|
|
139
|
+
episode_seed=episode_seed,
|
|
140
|
+
)
|
|
141
|
+
for name, callback in selected_step_metrics.items():
|
|
142
|
+
try:
|
|
143
|
+
contribution = _metric_value(callback(deepcopy(transition)), name)
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"step metric {name!r} failed in episode {episode_index}, "
|
|
147
|
+
f"step {length - 1}"
|
|
148
|
+
) from exc
|
|
149
|
+
metric_totals[name] += contribution
|
|
150
|
+
if not np.isfinite(metric_totals[name]):
|
|
151
|
+
raise ValueError(
|
|
152
|
+
f"step metric {name!r} accumulated a nonfinite value "
|
|
153
|
+
f"in episode {episode_index}"
|
|
154
|
+
)
|
|
155
|
+
final_info = deepcopy(transition.info)
|
|
156
|
+
observation = next_observation
|
|
157
|
+
result = EpisodeResult(
|
|
158
|
+
episode_return, length, bool(terminated), bool(truncated), metric_totals, final_info
|
|
159
|
+
)
|
|
160
|
+
if selected_episode_metrics:
|
|
161
|
+
base_result = deepcopy(result)
|
|
162
|
+
completed_metrics: dict[str, float] = {}
|
|
163
|
+
for name, callback in selected_episode_metrics.items():
|
|
164
|
+
try:
|
|
165
|
+
completed_metrics[name] = _metric_value(callback(deepcopy(base_result)), name)
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
raise ValueError(f"episode metric {name!r} failed in episode {episode_index}") from exc
|
|
168
|
+
result.metrics.update(completed_metrics)
|
|
169
|
+
episodes.append(result)
|
|
170
|
+
return episodes
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Suite:
|
|
174
|
+
"""Evaluate policies under one shift at a time with paired episode seeds."""
|
|
175
|
+
|
|
176
|
+
def __init__(
|
|
177
|
+
self,
|
|
178
|
+
env_fn: Callable[[], gym.Env],
|
|
179
|
+
shifts: Sequence[ObservationNoise | ActionSlip | StickyActions],
|
|
180
|
+
n_episodes: int,
|
|
181
|
+
seed: int = 0,
|
|
182
|
+
success_fn: Callable[[EpisodeResult], bool] | None = None,
|
|
183
|
+
max_steps: int = 1000,
|
|
184
|
+
*,
|
|
185
|
+
step_metrics: Mapping[str, StepMetric] | None = None,
|
|
186
|
+
episode_metrics: Mapping[str, EpisodeMetric] | None = None,
|
|
187
|
+
) -> None:
|
|
188
|
+
if not callable(env_fn):
|
|
189
|
+
raise TypeError("env_fn must be callable")
|
|
190
|
+
_positive_int(n_episodes, "n_episodes", minimum=2)
|
|
191
|
+
_positive_int(max_steps, "max_steps")
|
|
192
|
+
if isinstance(seed, bool) or not isinstance(seed, Integral) or seed < 0:
|
|
193
|
+
raise ValueError("seed must be a nonnegative integer")
|
|
194
|
+
if success_fn is not None and not callable(success_fn):
|
|
195
|
+
raise TypeError("success_fn must be callable")
|
|
196
|
+
selected_step_metrics, selected_episode_metrics = _metric_maps(
|
|
197
|
+
step_metrics, episode_metrics
|
|
198
|
+
)
|
|
199
|
+
selected_shifts = list(shifts)
|
|
200
|
+
if not selected_shifts:
|
|
201
|
+
raise ValueError("shifts must contain at least one shift")
|
|
202
|
+
if any(not isinstance(shift, (ObservationNoise, ActionSlip, StickyActions)) for shift in selected_shifts):
|
|
203
|
+
raise TypeError("shifts must contain only ObservationNoise, ActionSlip, or StickyActions")
|
|
204
|
+
names = [shift.name for shift in selected_shifts]
|
|
205
|
+
if len(set(names)) != len(names):
|
|
206
|
+
raise ValueError("shift names must be unique")
|
|
207
|
+
|
|
208
|
+
validation_env = env_fn()
|
|
209
|
+
try:
|
|
210
|
+
self._validate_spaces(validation_env)
|
|
211
|
+
for shift in selected_shifts:
|
|
212
|
+
for level in shift.levels:
|
|
213
|
+
shift.wrap(validation_env, level)
|
|
214
|
+
finally:
|
|
215
|
+
validation_env.close()
|
|
216
|
+
|
|
217
|
+
self.env_fn = env_fn
|
|
218
|
+
self.shifts = selected_shifts
|
|
219
|
+
self.n_episodes = int(n_episodes)
|
|
220
|
+
self.seed = int(seed)
|
|
221
|
+
self.success_fn = success_fn
|
|
222
|
+
self.max_steps = int(max_steps)
|
|
223
|
+
self.step_metrics = selected_step_metrics
|
|
224
|
+
self.episode_metrics = selected_episode_metrics
|
|
225
|
+
|
|
226
|
+
@staticmethod
|
|
227
|
+
def _validate_spaces(env: gym.Env) -> None:
|
|
228
|
+
observation_space = env.observation_space
|
|
229
|
+
action_space = env.action_space
|
|
230
|
+
if not isinstance(observation_space, (gym.spaces.Box, gym.spaces.Discrete)):
|
|
231
|
+
raise TypeError(
|
|
232
|
+
f"unsupported observation space {observation_space!r}; expected Box or Discrete"
|
|
233
|
+
)
|
|
234
|
+
if isinstance(action_space, gym.spaces.Discrete):
|
|
235
|
+
return
|
|
236
|
+
if isinstance(action_space, gym.spaces.Box) and (
|
|
237
|
+
np.all(np.isfinite(action_space.low)) and np.all(np.isfinite(action_space.high))
|
|
238
|
+
):
|
|
239
|
+
return
|
|
240
|
+
raise TypeError(
|
|
241
|
+
f"unsupported action space {action_space!r}; expected Discrete or finite Box"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _bootstrap_seed(self, policy: str, shift: str, level: float, metric: str) -> int:
|
|
245
|
+
key = json.dumps([policy, shift, level, metric], ensure_ascii=False, separators=(",", ":"))
|
|
246
|
+
stream = np.random.default_rng([self.seed, zlib.crc32(key.encode("utf-8"))])
|
|
247
|
+
return int(stream.integers(0, 2**32))
|
|
248
|
+
|
|
249
|
+
def _run_condition(
|
|
250
|
+
self,
|
|
251
|
+
policy: Callable[[Any], Any],
|
|
252
|
+
shift: ObservationNoise | ActionSlip | StickyActions | None,
|
|
253
|
+
level: float,
|
|
254
|
+
) -> list[EpisodeResult]:
|
|
255
|
+
env = self.env_fn()
|
|
256
|
+
condition_env = env
|
|
257
|
+
try:
|
|
258
|
+
if shift is not None:
|
|
259
|
+
condition_env = shift.wrap(env, level)
|
|
260
|
+
return run_episodes(
|
|
261
|
+
condition_env,
|
|
262
|
+
policy,
|
|
263
|
+
self.n_episodes,
|
|
264
|
+
self.seed,
|
|
265
|
+
self.max_steps,
|
|
266
|
+
step_metrics=self.step_metrics,
|
|
267
|
+
episode_metrics=self.episode_metrics,
|
|
268
|
+
)
|
|
269
|
+
finally:
|
|
270
|
+
condition_env.close()
|
|
271
|
+
|
|
272
|
+
def _row(
|
|
273
|
+
self,
|
|
274
|
+
policy_name: str,
|
|
275
|
+
shift_name: str,
|
|
276
|
+
level: float,
|
|
277
|
+
episodes: list[EpisodeResult],
|
|
278
|
+
nominal_returns: list[float] | None,
|
|
279
|
+
successes: list[bool] | None,
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
returns = [episode.episode_return for episode in episodes]
|
|
282
|
+
mean, low, high = bootstrap_mean_ci(
|
|
283
|
+
returns, seed=self._bootstrap_seed(policy_name, shift_name, level, "return")
|
|
284
|
+
)
|
|
285
|
+
if nominal_returns is None:
|
|
286
|
+
drop = drop_low = drop_high = None
|
|
287
|
+
else:
|
|
288
|
+
drop, drop_low, drop_high = paired_diff_ci(
|
|
289
|
+
nominal_returns,
|
|
290
|
+
returns,
|
|
291
|
+
seed=self._bootstrap_seed(policy_name, shift_name, level, "drop"),
|
|
292
|
+
)
|
|
293
|
+
if successes is None:
|
|
294
|
+
success = success_low = success_high = None
|
|
295
|
+
else:
|
|
296
|
+
count = sum(successes)
|
|
297
|
+
success, success_low, success_high = wilson_interval(count, len(episodes))
|
|
298
|
+
return {
|
|
299
|
+
"policy": policy_name,
|
|
300
|
+
"shift": shift_name,
|
|
301
|
+
"level": level,
|
|
302
|
+
"n_episodes": self.n_episodes,
|
|
303
|
+
"mean_return": mean,
|
|
304
|
+
"return_ci_low": low,
|
|
305
|
+
"return_ci_high": high,
|
|
306
|
+
"drop": drop,
|
|
307
|
+
"drop_ci_low": drop_low,
|
|
308
|
+
"drop_ci_high": drop_high,
|
|
309
|
+
"mean_length": float(np.mean([episode.length for episode in episodes])),
|
|
310
|
+
"success_rate": success,
|
|
311
|
+
"success_ci_low": success_low,
|
|
312
|
+
"success_ci_high": success_high,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
def _condition_records(
|
|
316
|
+
self,
|
|
317
|
+
policy_name: str,
|
|
318
|
+
shift_name: str,
|
|
319
|
+
level: float,
|
|
320
|
+
episodes: list[EpisodeResult],
|
|
321
|
+
) -> tuple[list[EpisodeRecord], list[bool] | None]:
|
|
322
|
+
successes: list[bool] | None = [] if self.success_fn is not None else None
|
|
323
|
+
records = []
|
|
324
|
+
for index, episode in enumerate(episodes):
|
|
325
|
+
success = None
|
|
326
|
+
if self.success_fn is not None:
|
|
327
|
+
success = bool(self.success_fn(episode))
|
|
328
|
+
assert successes is not None
|
|
329
|
+
successes.append(success)
|
|
330
|
+
records.append(
|
|
331
|
+
EpisodeRecord(
|
|
332
|
+
policy=policy_name,
|
|
333
|
+
member=None,
|
|
334
|
+
shift=shift_name,
|
|
335
|
+
level=level,
|
|
336
|
+
episode_index=index,
|
|
337
|
+
episode_seed=self.seed + index,
|
|
338
|
+
episode=episode,
|
|
339
|
+
success=success,
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
return records, successes
|
|
343
|
+
|
|
344
|
+
def _metric_rows(
|
|
345
|
+
self,
|
|
346
|
+
policy_name: str,
|
|
347
|
+
shift_name: str,
|
|
348
|
+
level: float,
|
|
349
|
+
episodes: list[EpisodeResult],
|
|
350
|
+
nominal_episodes: list[EpisodeResult] | None,
|
|
351
|
+
) -> list[dict[str, Any]]:
|
|
352
|
+
metric_rows = []
|
|
353
|
+
for name in (*self.step_metrics, *self.episode_metrics):
|
|
354
|
+
values = [episode.metrics[name] for episode in episodes]
|
|
355
|
+
mean, low, high = bootstrap_mean_ci(
|
|
356
|
+
values,
|
|
357
|
+
seed=self._bootstrap_seed(policy_name, shift_name, level, f"metric:{name}"),
|
|
358
|
+
)
|
|
359
|
+
if nominal_episodes is None:
|
|
360
|
+
drop = drop_low = drop_high = None
|
|
361
|
+
else:
|
|
362
|
+
nominal_values = [episode.metrics[name] for episode in nominal_episodes]
|
|
363
|
+
drop, drop_low, drop_high = paired_diff_ci(
|
|
364
|
+
nominal_values,
|
|
365
|
+
values,
|
|
366
|
+
seed=self._bootstrap_seed(
|
|
367
|
+
policy_name, shift_name, level, f"metric:{name}:drop"
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
metric_rows.append(
|
|
371
|
+
{
|
|
372
|
+
"policy": policy_name,
|
|
373
|
+
"metric": name,
|
|
374
|
+
"shift": shift_name,
|
|
375
|
+
"level": level,
|
|
376
|
+
"n_episodes": self.n_episodes,
|
|
377
|
+
"mean": mean,
|
|
378
|
+
"ci_low": low,
|
|
379
|
+
"ci_high": high,
|
|
380
|
+
"drop": drop,
|
|
381
|
+
"drop_ci_low": drop_low,
|
|
382
|
+
"drop_ci_high": drop_high,
|
|
383
|
+
}
|
|
384
|
+
)
|
|
385
|
+
return metric_rows
|
|
386
|
+
|
|
387
|
+
def evaluate(self, policies: Mapping[str, Callable[[Any], Any]]) -> Results:
|
|
388
|
+
"""Evaluate every policy nominally and at each configured shift level."""
|
|
389
|
+
if not isinstance(policies, Mapping) or not policies:
|
|
390
|
+
raise ValueError("policies must be a nonempty mapping of names to callables")
|
|
391
|
+
for name, policy in policies.items():
|
|
392
|
+
if not isinstance(name, str):
|
|
393
|
+
raise TypeError("policy names must be strings")
|
|
394
|
+
if not callable(policy):
|
|
395
|
+
raise TypeError(f"policy {name!r} must be callable")
|
|
396
|
+
|
|
397
|
+
rows = []
|
|
398
|
+
records: list[EpisodeRecord] = []
|
|
399
|
+
metric_rows: list[dict[str, Any]] = []
|
|
400
|
+
for policy_name, policy in policies.items():
|
|
401
|
+
nominal_episodes = self._run_condition(policy, None, 0.0)
|
|
402
|
+
nominal_returns = [episode.episode_return for episode in nominal_episodes]
|
|
403
|
+
condition_records, successes = self._condition_records(
|
|
404
|
+
policy_name, "nominal", 0.0, nominal_episodes
|
|
405
|
+
)
|
|
406
|
+
records.extend(condition_records)
|
|
407
|
+
rows.append(self._row(policy_name, "nominal", 0.0, nominal_episodes, None, successes))
|
|
408
|
+
metric_rows.extend(
|
|
409
|
+
self._metric_rows(policy_name, "nominal", 0.0, nominal_episodes, None)
|
|
410
|
+
)
|
|
411
|
+
for shift in self.shifts:
|
|
412
|
+
for level in shift.levels:
|
|
413
|
+
episodes = self._run_condition(policy, shift, level)
|
|
414
|
+
condition_records, successes = self._condition_records(
|
|
415
|
+
policy_name, shift.name, level, episodes
|
|
416
|
+
)
|
|
417
|
+
records.extend(condition_records)
|
|
418
|
+
rows.append(
|
|
419
|
+
self._row(
|
|
420
|
+
policy_name, shift.name, level, episodes, nominal_returns, successes
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
metric_rows.extend(
|
|
424
|
+
self._metric_rows(
|
|
425
|
+
policy_name, shift.name, level, episodes, nominal_episodes
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
meta = {
|
|
430
|
+
"rlshiftlab": __version__,
|
|
431
|
+
"gymnasium": gym.__version__,
|
|
432
|
+
"numpy": np.__version__,
|
|
433
|
+
"python": platform.python_version(),
|
|
434
|
+
"seed": self.seed,
|
|
435
|
+
"n_episodes": self.n_episodes,
|
|
436
|
+
"shifts": {shift.name: list(shift.levels) for shift in self.shifts},
|
|
437
|
+
"policies": list(policies),
|
|
438
|
+
}
|
|
439
|
+
if self.step_metrics or self.episode_metrics:
|
|
440
|
+
meta["step_metrics"] = list(self.step_metrics)
|
|
441
|
+
meta["episode_metrics"] = list(self.episode_metrics)
|
|
442
|
+
return Results(
|
|
443
|
+
rows=rows,
|
|
444
|
+
meta=meta,
|
|
445
|
+
records=records,
|
|
446
|
+
metric_rows=metric_rows,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def evaluate_groups(
|
|
450
|
+
self, groups: Mapping[str, Mapping[str, Callable[[Any], Any]]]
|
|
451
|
+
) -> GroupResults:
|
|
452
|
+
"""Evaluate independently trained frozen members in named groups."""
|
|
453
|
+
from rlshiftlab.groups import evaluate_groups
|
|
454
|
+
|
|
455
|
+
return evaluate_groups(self, groups)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
__all__ = ["EpisodeRecord", "EpisodeResult", "Suite", "Transition", "run_episodes"]
|
rlshiftlab/shifts.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Observation and action shifts for frozen-policy evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import zlib
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import gymnasium as gym
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _level(value: float, name: str, probability: bool = False) -> float:
|
|
14
|
+
try:
|
|
15
|
+
level = float(value)
|
|
16
|
+
except (TypeError, ValueError) as exc:
|
|
17
|
+
raise ValueError(f"{name} level must be a finite number greater than 0") from exc
|
|
18
|
+
if not np.isfinite(level) or level <= 0:
|
|
19
|
+
raise ValueError(f"{name} level must be a finite number greater than 0")
|
|
20
|
+
if probability and level > 1:
|
|
21
|
+
raise ValueError(f"{name} probability level must be at most 1")
|
|
22
|
+
return level
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _levels(values: Sequence[float], name: str, probability: bool = False) -> list[float]:
|
|
26
|
+
levels = sorted({_level(value, name, probability) for value in values})
|
|
27
|
+
if not levels:
|
|
28
|
+
raise ValueError(f"{name} requires at least one level")
|
|
29
|
+
return levels
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_action_space(space: gym.Space[Any], name: str) -> None:
|
|
33
|
+
if isinstance(space, gym.spaces.Discrete):
|
|
34
|
+
return
|
|
35
|
+
if isinstance(space, gym.spaces.Box) and (
|
|
36
|
+
np.all(np.isfinite(space.low)) and np.all(np.isfinite(space.high))
|
|
37
|
+
):
|
|
38
|
+
return
|
|
39
|
+
raise TypeError(f"{name} does not support action space {space!r}; expected Discrete or finite Box")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _generator(seed: int, name: str) -> np.random.Generator:
|
|
43
|
+
return np.random.default_rng([seed, zlib.crc32(name.encode("utf-8"))])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _ShiftWrapper(gym.Wrapper):
|
|
47
|
+
"""Base wrapper with one reproducible random stream per seeded episode."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, env: gym.Env, level: float, name: str) -> None:
|
|
50
|
+
super().__init__(env)
|
|
51
|
+
self.level = level
|
|
52
|
+
self._name = name
|
|
53
|
+
self._rng = np.random.default_rng()
|
|
54
|
+
|
|
55
|
+
def reset(
|
|
56
|
+
self, *, seed: int | None = None, options: dict[str, Any] | None = None
|
|
57
|
+
) -> tuple[Any, dict[str, Any]]:
|
|
58
|
+
if seed is not None:
|
|
59
|
+
self._rng = _generator(seed, self._name)
|
|
60
|
+
return self.env.reset(seed=seed, options=options)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _ObservationNoiseWrapper(_ShiftWrapper):
|
|
64
|
+
def _corrupt(self, observation: Any) -> Any:
|
|
65
|
+
space = self.observation_space
|
|
66
|
+
if isinstance(space, gym.spaces.Box):
|
|
67
|
+
noise = self._rng.normal(0.0, self.level, size=space.shape)
|
|
68
|
+
return (np.asarray(observation) + noise).astype(space.dtype)
|
|
69
|
+
if isinstance(space, gym.spaces.Discrete):
|
|
70
|
+
replace = self._rng.random() < self.level
|
|
71
|
+
candidate = int(space.start + self._rng.integers(space.n))
|
|
72
|
+
return candidate if replace else observation
|
|
73
|
+
raise AssertionError("observation space was validated by ObservationNoise.wrap")
|
|
74
|
+
|
|
75
|
+
def reset(
|
|
76
|
+
self, *, seed: int | None = None, options: dict[str, Any] | None = None
|
|
77
|
+
) -> tuple[Any, dict[str, Any]]:
|
|
78
|
+
observation, info = super().reset(seed=seed, options=options)
|
|
79
|
+
return self._corrupt(observation), info
|
|
80
|
+
|
|
81
|
+
def step(self, action: Any) -> tuple[Any, float, bool, bool, dict[str, Any]]:
|
|
82
|
+
observation, reward, terminated, truncated, info = self.env.step(action)
|
|
83
|
+
return self._corrupt(observation), reward, terminated, truncated, info
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class _ActionSlipWrapper(_ShiftWrapper):
|
|
87
|
+
def step(self, action: Any) -> tuple[Any, float, bool, bool, dict[str, Any]]:
|
|
88
|
+
space = self.action_space
|
|
89
|
+
slip = self._rng.random() < self.level
|
|
90
|
+
if isinstance(space, gym.spaces.Discrete):
|
|
91
|
+
candidate = int(space.start + self._rng.integers(space.n))
|
|
92
|
+
elif isinstance(space, gym.spaces.Box):
|
|
93
|
+
candidate = self._rng.uniform(space.low, space.high).astype(space.dtype)
|
|
94
|
+
else:
|
|
95
|
+
raise TypeError(f"{self._name} does not support action space {space!r}")
|
|
96
|
+
return self.env.step(candidate if slip else action)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _StickyActionsWrapper(_ShiftWrapper):
|
|
100
|
+
def __init__(self, env: gym.Env, level: float, name: str) -> None:
|
|
101
|
+
super().__init__(env, level, name)
|
|
102
|
+
self._previous_action: Any = None
|
|
103
|
+
self._has_previous = False
|
|
104
|
+
|
|
105
|
+
def reset(
|
|
106
|
+
self, *, seed: int | None = None, options: dict[str, Any] | None = None
|
|
107
|
+
) -> tuple[Any, dict[str, Any]]:
|
|
108
|
+
self._previous_action = None
|
|
109
|
+
self._has_previous = False
|
|
110
|
+
return super().reset(seed=seed, options=options)
|
|
111
|
+
|
|
112
|
+
def step(self, action: Any) -> tuple[Any, float, bool, bool, dict[str, Any]]:
|
|
113
|
+
sticky = self._rng.random() < self.level
|
|
114
|
+
executed = self._previous_action if sticky and self._has_previous else action
|
|
115
|
+
if isinstance(self.action_space, gym.spaces.Box):
|
|
116
|
+
# Keep a separate copy because environments can mutate action arrays.
|
|
117
|
+
self._previous_action = np.array(executed, copy=True)
|
|
118
|
+
executed = np.array(executed, copy=True)
|
|
119
|
+
else:
|
|
120
|
+
self._previous_action = executed
|
|
121
|
+
self._has_previous = True
|
|
122
|
+
return self.env.step(executed)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ObservationNoise:
|
|
126
|
+
"""Add Box noise or randomly replace Discrete observations."""
|
|
127
|
+
|
|
128
|
+
name = "observation_noise"
|
|
129
|
+
|
|
130
|
+
def __init__(self, levels: Sequence[float]) -> None:
|
|
131
|
+
self.levels = _levels(levels, self.name)
|
|
132
|
+
|
|
133
|
+
def wrap(self, env: gym.Env, level: float) -> gym.Wrapper:
|
|
134
|
+
"""Wrap an environment at a positive observation-noise level."""
|
|
135
|
+
space = env.observation_space
|
|
136
|
+
if not isinstance(space, (gym.spaces.Box, gym.spaces.Discrete)):
|
|
137
|
+
raise TypeError(f"{self.name} does not support observation space {space!r}; expected Box or Discrete")
|
|
138
|
+
validated = _level(level, self.name, isinstance(space, gym.spaces.Discrete))
|
|
139
|
+
return _ObservationNoiseWrapper(env, validated, self.name)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ActionSlip:
|
|
143
|
+
"""Randomly replace actions with samples from the action space."""
|
|
144
|
+
|
|
145
|
+
name = "action_slip"
|
|
146
|
+
|
|
147
|
+
def __init__(self, levels: Sequence[float]) -> None:
|
|
148
|
+
self.levels = _levels(levels, self.name, probability=True)
|
|
149
|
+
|
|
150
|
+
def wrap(self, env: gym.Env, level: float) -> gym.Wrapper:
|
|
151
|
+
"""Wrap an environment at a positive action-slip probability."""
|
|
152
|
+
_check_action_space(env.action_space, self.name)
|
|
153
|
+
return _ActionSlipWrapper(env, _level(level, self.name, probability=True), self.name)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class StickyActions:
|
|
157
|
+
"""Repeat the previous executed action with a given probability."""
|
|
158
|
+
|
|
159
|
+
name = "sticky_actions"
|
|
160
|
+
|
|
161
|
+
def __init__(self, levels: Sequence[float]) -> None:
|
|
162
|
+
self.levels = _levels(levels, self.name, probability=True)
|
|
163
|
+
|
|
164
|
+
def wrap(self, env: gym.Env, level: float) -> gym.Wrapper:
|
|
165
|
+
"""Wrap an environment at a positive sticky-action probability."""
|
|
166
|
+
_check_action_space(env.action_space, self.name)
|
|
167
|
+
return _StickyActionsWrapper(env, _level(level, self.name, probability=True), self.name)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = ["ActionSlip", "ObservationNoise", "StickyActions"]
|