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 ADDED
@@ -0,0 +1,23 @@
1
+ """Stress-test frozen reinforcement learning policies under controlled shifts."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ from rlshiftlab.comparisons import ComparisonResults
6
+ from rlshiftlab.groups import GroupResults
7
+ from rlshiftlab.results import Results
8
+ from rlshiftlab.runner import EpisodeRecord, EpisodeResult, Suite, Transition
9
+ from rlshiftlab.shifts import ActionSlip, ObservationNoise, StickyActions
10
+
11
+ __all__ = [
12
+ "ActionSlip",
13
+ "ComparisonResults",
14
+ "EpisodeRecord",
15
+ "EpisodeResult",
16
+ "GroupResults",
17
+ "ObservationNoise",
18
+ "Results",
19
+ "StickyActions",
20
+ "Suite",
21
+ "Transition",
22
+ "__version__",
23
+ ]
@@ -0,0 +1,301 @@
1
+ """Direct differences between frozen policies on matched evaluation episodes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import zlib
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from math import ceil
10
+ from numbers import Integral
11
+ from os import PathLike
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import numpy as np
15
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
16
+ from matplotlib.figure import Figure
17
+
18
+ from rlshiftlab.records import EpisodeRecord
19
+ from rlshiftlab.stats import crossed_independent_group_diff_ci, paired_diff_ci
20
+
21
+ if TYPE_CHECKING:
22
+ from rlshiftlab.groups import GroupResults
23
+ from rlshiftlab.results import Results
24
+
25
+
26
+ @dataclass
27
+ class ComparisonResults:
28
+ """Pointwise differences; positive means the left metric is larger."""
29
+
30
+ rows: list[dict[str, Any]]
31
+ meta: dict[str, Any]
32
+
33
+ def to_csv(self, path: str | PathLike[str]) -> None:
34
+ """Write condition contrasts with a stable comparison-only schema."""
35
+ from rlshiftlab.exports import COMPARISON_COLUMNS, write_rows
36
+
37
+ write_rows(path, self.rows, COMPARISON_COLUMNS)
38
+
39
+ def summary(self) -> str:
40
+ """Summarize pointwise left-minus-right differences by condition."""
41
+ left = str(self.meta.get("left", "left"))
42
+ right = str(self.meta.get("right", "right"))
43
+ metric = str(self.meta.get("metric", "return"))
44
+ lines = [
45
+ f"{left} - {right}: {metric} (positive means a higher measured value for {left})",
46
+ "shift level mean difference pointwise 95% CI",
47
+ ]
48
+ for row in self.rows:
49
+ lines.append(
50
+ f"{row['shift']} {float(row['level']):g} {float(row['mean_diff']):.3f} "
51
+ f"[{float(row['ci_low']):.3f}, {float(row['ci_high']):.3f}]"
52
+ )
53
+ return "\n".join(lines)
54
+
55
+ def plot(self, path: str | PathLike[str] | None = None) -> Figure:
56
+ """Plot pointwise contrast intervals with a zero reference per shift."""
57
+ shifts = list(self.meta.get("shifts", {}))
58
+ if not shifts:
59
+ shifts = list(dict.fromkeys(row["shift"] for row in self.rows if row["shift"] != "nominal"))
60
+ if not shifts:
61
+ raise ValueError("comparison contains no shifted conditions to plot")
62
+ nominal = next((row for row in self.rows if row["shift"] == "nominal"), None)
63
+ if nominal is None:
64
+ raise ValueError("comparison is missing its nominal condition")
65
+ columns = min(3, len(shifts))
66
+ figure = Figure(figsize=(5.5 * columns, 4 * ceil(len(shifts) / columns)))
67
+ FigureCanvasAgg(figure)
68
+ left = str(self.meta.get("left", "left"))
69
+ right = str(self.meta.get("right", "right"))
70
+ metric = str(self.meta.get("metric", "return"))
71
+ for index, shift in enumerate(shifts, start=1):
72
+ axis = figure.add_subplot(ceil(len(shifts) / columns), columns, index)
73
+ shifted = sorted(
74
+ (row for row in self.rows if row["shift"] == shift),
75
+ key=lambda row: float(row["level"]),
76
+ )
77
+ points = [nominal, *shifted]
78
+ x = [float(point["level"]) for point in points]
79
+ mean = [float(point["mean_diff"]) for point in points]
80
+ low = [float(point["ci_low"]) for point in points]
81
+ high = [float(point["ci_high"]) for point in points]
82
+ axis.axhline(0.0, color="black", linewidth=1, linestyle="--", alpha=0.6)
83
+ line = axis.plot(x, mean, marker="o", label=f"{left} - {right}")[0]
84
+ axis.fill_between(x, low, high, color=line.get_color(), alpha=0.18)
85
+ axis.set_title(shift.replace("_", " "))
86
+ axis.set_xlabel("shift strength")
87
+ axis.set_ylabel(f"{metric}: {left} - {right} (pointwise 95% CI)")
88
+ axis.grid(alpha=0.2)
89
+ axis.legend()
90
+ figure.tight_layout()
91
+ if path is not None:
92
+ figure.savefig(path, dpi=150)
93
+ return figure
94
+
95
+ def to_html(self, path: str | PathLike[str]) -> None:
96
+ """Write a standalone HTML comparison report."""
97
+ from rlshiftlab.report import write_html
98
+
99
+ write_html(self, path)
100
+
101
+
102
+ def _setup(
103
+ results: Results | GroupResults,
104
+ left: str,
105
+ right: str,
106
+ metric: str,
107
+ grouped: bool,
108
+ ) -> tuple[list[tuple[str, float]], list[int], dict[str, list[str | None]]]:
109
+ if not isinstance(left, str) or not left or not isinstance(right, str) or not right:
110
+ raise ValueError("left and right must be nonempty policy or group names")
111
+ if left == right:
112
+ raise ValueError("left and right must be distinct")
113
+ labels = results.meta.get("groups" if grouped else "policies")
114
+ if grouped:
115
+ if not isinstance(labels, Mapping):
116
+ raise ValueError("group metadata is required for a group comparison")
117
+ if left not in labels or right not in labels:
118
+ raise ValueError("left and right must be existing groups")
119
+ members = {name: list(labels[name]) for name in (left, right)}
120
+ if any(len(ids) < 2 or len(ids) != len(set(ids)) for ids in members.values()):
121
+ raise ValueError("groups must declare at least two distinct members")
122
+ else:
123
+ if not isinstance(labels, (list, tuple)) or left not in labels or right not in labels:
124
+ raise ValueError("left and right must be existing policies")
125
+ members = {left: [None], right: [None]}
126
+
127
+ configured_metrics = {
128
+ "return", "length", "success", *results.meta.get("step_metrics", []),
129
+ *results.meta.get("episode_metrics", []),
130
+ }
131
+ if not isinstance(metric, str) or metric not in configured_metrics:
132
+ raise ValueError(f"metric {metric!r} is not configured")
133
+ shifts = results.meta.get("shifts")
134
+ if not isinstance(shifts, Mapping):
135
+ raise TypeError("shift metadata must be a mapping for a comparison")
136
+ conditions = [("nominal", 0.0)] + [
137
+ (name, float(level)) for name, levels in shifts.items() for level in levels
138
+ ]
139
+ if len(conditions) != len(set(conditions)):
140
+ raise ValueError("condition metadata contains duplicate shift levels")
141
+ count = results.meta.get("n_episodes")
142
+ seed = results.meta.get("seed")
143
+ if (
144
+ isinstance(count, bool) or not isinstance(count, Integral) or count < 2
145
+ or isinstance(seed, bool) or not isinstance(seed, Integral) or seed < 0
146
+ ):
147
+ raise ValueError("comparison requires valid evaluation seed and episode count")
148
+ return conditions, [int(seed + index) for index in range(count)], members
149
+
150
+
151
+ def _numeric_value(record: EpisodeRecord, metric: str) -> float:
152
+ if metric == "return":
153
+ value = record.episode.episode_return
154
+ elif metric == "length":
155
+ value = record.episode.length
156
+ elif metric == "success":
157
+ value = record.success
158
+ if value is None:
159
+ raise ValueError("success metric requires recorded success values")
160
+ else:
161
+ if metric not in record.episode.metrics:
162
+ raise ValueError(f"record lacks configured metric {metric!r}")
163
+ value = record.episode.metrics[metric]
164
+ try:
165
+ array = np.asarray(value)
166
+ if array.ndim != 0 or array.dtype.kind not in "bifu":
167
+ raise ValueError("not a numeric scalar")
168
+ number = float(array)
169
+ except (TypeError, ValueError, OverflowError) as exc:
170
+ raise ValueError(f"record has invalid {metric!r} value") from exc
171
+ if not np.isfinite(number):
172
+ raise ValueError(f"record has nonfinite {metric!r} value")
173
+ return number
174
+
175
+
176
+ def _panels(
177
+ records: list[EpisodeRecord],
178
+ entities: dict[str, list[str | None]],
179
+ conditions: list[tuple[str, float]],
180
+ episode_seeds: list[int],
181
+ metric: str,
182
+ ) -> dict[tuple[str, str, float], np.ndarray]:
183
+ """Validate every expected key, then align cells by declared member and seed."""
184
+ expected_conditions = set(conditions)
185
+ keyed: dict[tuple[str, str | None, str, float, int], float] = {}
186
+ for record in records:
187
+ if record.policy not in entities:
188
+ continue
189
+ if not isinstance(record.shift, str):
190
+ raise TypeError(f"invalid condition shift for {record.policy!r}")
191
+ if (
192
+ isinstance(record.level, bool)
193
+ or not isinstance(record.level, (int, float))
194
+ or not np.isfinite(record.level)
195
+ ):
196
+ raise ValueError(f"invalid condition level for {record.policy!r}")
197
+ condition = (record.shift, record.level)
198
+ if condition not in expected_conditions:
199
+ raise ValueError(f"unexpected condition for {record.policy!r}: {condition!r}")
200
+ if record.member not in entities[record.policy]:
201
+ raise ValueError(f"unexpected member {record.member!r} for {record.policy!r}")
202
+ index = record.episode_index
203
+ if isinstance(index, bool) or not isinstance(index, Integral) or not 0 <= index < len(episode_seeds):
204
+ raise ValueError(f"invalid episode index for {record.policy!r}")
205
+ if (
206
+ isinstance(record.episode_seed, bool)
207
+ or not isinstance(record.episode_seed, Integral)
208
+ or record.episode_seed != episode_seeds[index]
209
+ ):
210
+ raise ValueError(f"mismatched episode seed for {record.policy!r}")
211
+ key = (record.policy, record.member, record.shift, record.level, int(index))
212
+ if key in keyed:
213
+ raise ValueError(f"duplicate condition episode key for {record.policy!r}")
214
+ keyed[key] = _numeric_value(record, metric)
215
+
216
+ expected_count = sum(len(ids) for ids in entities.values()) * len(conditions) * len(episode_seeds)
217
+ if len(keyed) != expected_count:
218
+ raise ValueError("incomplete condition and evaluation episode grid")
219
+
220
+ panels: dict[tuple[str, str, float], np.ndarray] = {}
221
+ for name, members in entities.items():
222
+ for shift, level in conditions:
223
+ try:
224
+ panel = [
225
+ [keyed[(name, member, shift, level, index)] for index in range(len(episode_seeds))]
226
+ for member in members
227
+ ]
228
+ except KeyError as exc:
229
+ raise ValueError("incomplete condition and evaluation episode grid") from exc
230
+ panels[(name, shift, level)] = np.asarray(panel, dtype=float)
231
+ return panels
232
+
233
+
234
+ def _comparison_seed(
235
+ suite_seed: int, left: str, right: str, shift: str, level: float, metric: str
236
+ ) -> int:
237
+ key = json.dumps([left, right, shift, level, metric], ensure_ascii=False, separators=(",", ":"))
238
+ rng = np.random.default_rng([suite_seed, zlib.crc32(key.encode("utf-8"))])
239
+ return int(rng.integers(0, 2**32))
240
+
241
+
242
+ def compare_results(
243
+ results: Results | GroupResults,
244
+ left: str,
245
+ right: str,
246
+ metric: str = "return",
247
+ *,
248
+ grouped: bool = False,
249
+ ) -> ComparisonResults:
250
+ """Compare complete raw panels with paired evaluation episode keys."""
251
+ conditions, episode_seeds, members = _setup(results, left, right, metric, grouped)
252
+ panels = _panels(results.records, members, conditions, episode_seeds, metric)
253
+ canonical_left, canonical_right = sorted((left, right))
254
+ reverse = left != canonical_left
255
+ method = "crossed_member_episode_bootstrap" if grouped else "paired_episode_bootstrap"
256
+ rows: list[dict[str, Any]] = []
257
+ for shift, level in conditions:
258
+ first = panels[(canonical_left, shift, level)]
259
+ second = panels[(canonical_right, shift, level)]
260
+ seed = _comparison_seed(
261
+ int(results.meta["seed"]), canonical_left, canonical_right, shift, level, metric
262
+ )
263
+ if grouped:
264
+ mean, low, high = crossed_independent_group_diff_ci(first, second, seed=seed)
265
+ else:
266
+ mean, low, high = paired_diff_ci(first[0], second[0], seed=seed)
267
+ if reverse:
268
+ mean, low, high = -mean, -high, -low
269
+ rows.append(
270
+ {
271
+ "left": left,
272
+ "right": right,
273
+ "shift": shift,
274
+ "level": level,
275
+ "metric": metric,
276
+ "n_episodes": len(episode_seeds),
277
+ "n_members_left": len(members[left]) if grouped else None,
278
+ "n_members_right": len(members[right]) if grouped else None,
279
+ "method": method,
280
+ "mean_diff": mean,
281
+ "ci_low": low,
282
+ "ci_high": high,
283
+ }
284
+ )
285
+ return ComparisonResults(
286
+ rows=rows,
287
+ meta={
288
+ **results.meta,
289
+ "left": left,
290
+ "right": right,
291
+ "metric": metric,
292
+ "comparison_method": method,
293
+ "ci_confidence": 0.95,
294
+ "ci_replicates": 2000,
295
+ "comparison_direction": "left minus right; positive means left has a higher metric",
296
+ "ci_scope": "pointwise, not simultaneous or multiple-comparison adjusted",
297
+ },
298
+ )
299
+
300
+
301
+ __all__ = ["ComparisonResults", "compare_results"]
rlshiftlab/exports.py ADDED
@@ -0,0 +1,98 @@
1
+ """Stable CSV schemas for v0.2 evaluation and audit exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from collections.abc import Sequence
7
+ from os import PathLike
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from rlshiftlab.records import EpisodeRecord
12
+ from rlshiftlab.results import CSV_COLUMNS
13
+
14
+ METRIC_COLUMNS = (
15
+ "policy", "metric", "shift", "level", "n_episodes", "mean", "ci_low", "ci_high",
16
+ "drop", "drop_ci_low", "drop_ci_high",
17
+ )
18
+
19
+ GROUP_COLUMNS = (
20
+ "group", "shift", "level", "n_members", "n_episodes_per_member", "total_episodes",
21
+ "mean_return", "return_ci_low", "return_ci_high", "member_std", "member_min",
22
+ "member_max", "drop", "drop_ci_low", "drop_ci_high", "mean_length",
23
+ "success_rate", "success_ci_low", "success_ci_high",
24
+ )
25
+
26
+ GROUP_METRIC_COLUMNS = (
27
+ "group", "metric", "shift", "level", "n_members", "n_episodes_per_member",
28
+ "total_episodes", "mean", "ci_low", "ci_high", "member_std", "member_min",
29
+ "member_max", "drop", "drop_ci_low", "drop_ci_high",
30
+ )
31
+
32
+ MEMBER_COLUMNS = ("group", "member", *CSV_COLUMNS[1:])
33
+
34
+ EPISODE_COLUMNS = (
35
+ "policy", "member", "shift", "level", "episode_index", "episode_seed",
36
+ "episode_return", "length", "terminated", "truncated", "success",
37
+ )
38
+
39
+ COMPARISON_COLUMNS = (
40
+ "left", "right", "shift", "level", "metric", "n_episodes", "n_members_left",
41
+ "n_members_right", "method", "mean_diff", "ci_low", "ci_high",
42
+ )
43
+
44
+
45
+ def write_rows(
46
+ path: str | PathLike[str], rows: Sequence[dict[str, Any]], columns: Sequence[str]
47
+ ) -> None:
48
+ """Write one deterministic UTF-8 CSV with a fixed field order."""
49
+ with Path(path).open("w", encoding="utf-8", newline="") as output:
50
+ writer = csv.DictWriter(output, fieldnames=columns, lineterminator="\n")
51
+ writer.writeheader()
52
+ for row in rows:
53
+ writer.writerow(
54
+ {
55
+ column: (
56
+ "" if row.get(column) is None else repr(row[column])
57
+ if isinstance(row[column], float)
58
+ else row[column]
59
+ )
60
+ for column in columns
61
+ }
62
+ )
63
+
64
+
65
+ def episode_columns(meta: dict[str, Any]) -> tuple[str, ...]:
66
+ """Append custom metric names in their configured order."""
67
+ names = [*meta.get("step_metrics", ()), *meta.get("episode_metrics", ())]
68
+ return (*EPISODE_COLUMNS, *(f"metric:{name}" for name in names))
69
+
70
+
71
+ def episode_rows(records: Sequence[EpisodeRecord], meta: dict[str, Any]) -> list[dict[str, Any]]:
72
+ """Convert episode records to scalar audit rows without Gym info or observations."""
73
+ names = [*meta.get("step_metrics", ()), *meta.get("episode_metrics", ())]
74
+ rows = []
75
+ for record in records:
76
+ episode = record.episode
77
+ row = {
78
+ "policy": record.policy,
79
+ "member": record.member,
80
+ "shift": record.shift,
81
+ "level": record.level,
82
+ "episode_index": record.episode_index,
83
+ "episode_seed": record.episode_seed,
84
+ "episode_return": episode.episode_return,
85
+ "length": episode.length,
86
+ "terminated": episode.terminated,
87
+ "truncated": episode.truncated,
88
+ "success": record.success,
89
+ }
90
+ row.update({f"metric:{name}": episode.metrics[name] for name in names})
91
+ rows.append(row)
92
+ return rows
93
+
94
+
95
+ __all__ = [
96
+ "COMPARISON_COLUMNS", "EPISODE_COLUMNS", "GROUP_COLUMNS", "GROUP_METRIC_COLUMNS",
97
+ "MEMBER_COLUMNS", "METRIC_COLUMNS", "episode_columns", "episode_rows", "write_rows",
98
+ ]