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/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"]