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/groups.py ADDED
@@ -0,0 +1,440 @@
1
+ """Evaluation summaries for groups of independently trained frozen policies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from math import ceil
8
+ from os import PathLike
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import numpy as np
12
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
13
+ from matplotlib.figure import Figure
14
+
15
+ from rlshiftlab.records import EpisodeRecord
16
+ from rlshiftlab.results import CSV_COLUMNS
17
+ from rlshiftlab.stats import crossed_mean_ci, crossed_paired_diff_ci
18
+
19
+ if TYPE_CHECKING:
20
+ from rlshiftlab.comparisons import ComparisonResults
21
+ from rlshiftlab.runner import Suite
22
+
23
+
24
+ @dataclass
25
+ class GroupResults:
26
+ """Group condition summaries, member summaries, and raw episode records."""
27
+
28
+ rows: list[dict[str, Any]]
29
+ meta: dict[str, Any]
30
+ member_rows: list[dict[str, Any]] = field(default_factory=list)
31
+ records: list[EpisodeRecord] = field(default_factory=list)
32
+ metric_rows: list[dict[str, Any]] = field(default_factory=list)
33
+
34
+ def to_csv(self, path: str | PathLike[str]) -> None:
35
+ """Write group-level condition summaries."""
36
+ from rlshiftlab.exports import GROUP_COLUMNS, write_rows
37
+
38
+ write_rows(path, self.rows, GROUP_COLUMNS)
39
+
40
+ def to_member_csv(self, path: str | PathLike[str]) -> None:
41
+ """Write each member's condition summaries."""
42
+ from rlshiftlab.exports import MEMBER_COLUMNS, write_rows
43
+
44
+ write_rows(path, self.member_rows, MEMBER_COLUMNS)
45
+
46
+ def to_episode_csv(self, path: str | PathLike[str]) -> None:
47
+ """Write one scalar audit row per member and evaluation episode."""
48
+ from rlshiftlab.exports import episode_columns, episode_rows, write_rows
49
+
50
+ write_rows(path, episode_rows(self.records, self.meta), episode_columns(self.meta))
51
+
52
+ def to_metrics_csv(self, path: str | PathLike[str]) -> None:
53
+ """Write custom metric summaries with training-member spread."""
54
+ from rlshiftlab.exports import GROUP_METRIC_COLUMNS, write_rows
55
+
56
+ write_rows(path, self.metric_rows, GROUP_METRIC_COLUMNS)
57
+
58
+ def summary(self) -> str:
59
+ """Summarize measured group means, member spread, and paired drops."""
60
+ headings = (
61
+ "group", "shift", "level", "members", "episodes/member", "mean return (95% CI)",
62
+ "member SD", "drop (95% CI)",
63
+ )
64
+ lines = []
65
+ for row in self.rows:
66
+ drop = (
67
+ "" if row["drop"] is None else
68
+ f"{row['drop']:.3f} [{row['drop_ci_low']:.3f}, {row['drop_ci_high']:.3f}]"
69
+ )
70
+ lines.append(
71
+ (
72
+ str(row["group"]),
73
+ str(row["shift"]),
74
+ f"{row['level']:g}",
75
+ str(row["n_members"]),
76
+ str(row["n_episodes_per_member"]),
77
+ (
78
+ f"{row['mean_return']:.3f} "
79
+ f"[{row['return_ci_low']:.3f}, {row['return_ci_high']:.3f}]"
80
+ ),
81
+ f"{row['member_std']:.3f}",
82
+ drop,
83
+ )
84
+ )
85
+ widths = [
86
+ max([len(heading), *(len(line[index]) for line in lines)])
87
+ for index, heading in enumerate(headings)
88
+ ]
89
+ header = " ".join(heading.ljust(widths[index]) for index, heading in enumerate(headings))
90
+ rule = " ".join("-" * width for width in widths)
91
+ body = [
92
+ " ".join(value.ljust(widths[index]) for index, value in enumerate(line))
93
+ for line in lines
94
+ ]
95
+ return "\n".join([header, rule, *body])
96
+
97
+ def plot(
98
+ self,
99
+ path: str | PathLike[str] | None = None,
100
+ *,
101
+ metric: str = "return",
102
+ band: str = "ci",
103
+ ) -> Figure:
104
+ """Plot group means with CI or descriptive member-SD bands."""
105
+ if band not in {"ci", "std"}:
106
+ raise ValueError("band must be 'ci' or 'std'")
107
+ if metric == "return":
108
+ plot_rows = self.rows
109
+ mean_key, low_key, high_key = "mean_return", "return_ci_low", "return_ci_high"
110
+ measure_label = "mean return"
111
+ else:
112
+ configured = [
113
+ *self.meta.get("step_metrics", ()), *self.meta.get("episode_metrics", ())
114
+ ]
115
+ if metric not in configured:
116
+ raise ValueError(f"custom metric {metric!r} is not configured")
117
+ plot_rows = [row for row in self.metric_rows if row["metric"] == metric]
118
+ mean_key, low_key, high_key = "mean", "ci_low", "ci_high"
119
+ measure_label = f"mean {metric}"
120
+ shifts = list(self.meta.get("shifts") or dict.fromkeys(
121
+ row["shift"] for row in plot_rows if row["shift"] != "nominal"
122
+ ))
123
+ if not shifts:
124
+ raise ValueError("results contain no shifted conditions to plot")
125
+ groups = self.meta.get("groups") or dict.fromkeys(row["group"] for row in plot_rows)
126
+ columns = min(3, len(shifts))
127
+ rows = ceil(len(shifts) / columns)
128
+ figure = Figure(figsize=(5.5 * columns, 4 * rows))
129
+ FigureCanvasAgg(figure)
130
+ for index, shift in enumerate(shifts, start=1):
131
+ axis = figure.add_subplot(rows, columns, index)
132
+ for group in groups:
133
+ nominal = next(
134
+ (row for row in plot_rows if row["group"] == group and row["shift"] == "nominal"),
135
+ None,
136
+ )
137
+ shifted = sorted(
138
+ (row for row in plot_rows if row["group"] == group and row["shift"] == shift),
139
+ key=lambda row: float(row["level"]),
140
+ )
141
+ if nominal is None or not shifted:
142
+ continue
143
+ points = [nominal, *shifted]
144
+ x = [float(point["level"]) for point in points]
145
+ means = [float(point[mean_key]) for point in points]
146
+ if band == "ci":
147
+ lows = [float(point[low_key]) for point in points]
148
+ highs = [float(point[high_key]) for point in points]
149
+ else:
150
+ lows = [float(point[mean_key] - point["member_std"]) for point in points]
151
+ highs = [float(point[mean_key] + point["member_std"]) for point in points]
152
+ line = axis.plot(x, means, marker="o", label=str(group))[0]
153
+ axis.fill_between(x, lows, highs, color=line.get_color(), alpha=0.18)
154
+ axis.set_title(shift.replace("_", " "))
155
+ axis.set_xlabel("shift strength")
156
+ axis.set_ylabel(
157
+ f"{measure_label} (95% pointwise CI)" if band == "ci"
158
+ else f"{measure_label} ± 1 member SD (descriptive)"
159
+ )
160
+ axis.grid(alpha=0.2)
161
+ axis.legend()
162
+ figure.tight_layout()
163
+ if path is not None:
164
+ figure.savefig(path, dpi=150)
165
+ return figure
166
+
167
+ def to_html(
168
+ self, path: str | PathLike[str], *, comparisons: Any = None
169
+ ) -> None:
170
+ """Write a standalone report for group evaluation."""
171
+ from rlshiftlab.report import write_html
172
+
173
+ write_html(self, path, comparisons=comparisons)
174
+
175
+ def compare(
176
+ self, left: str, right: str, metric: str = "return"
177
+ ) -> ComparisonResults:
178
+ """Compare group means with shared episode and independent member draws."""
179
+ from rlshiftlab.comparisons import compare_results
180
+
181
+ return compare_results(self, left, right, metric=metric, grouped=True)
182
+
183
+
184
+ def _validated_groups(
185
+ groups: Mapping[str, Mapping[str, Callable[[Any], Any]]],
186
+ ) -> dict[str, dict[str, Callable[[Any], Any]]]:
187
+ if not isinstance(groups, Mapping) or not groups:
188
+ raise ValueError("groups must be a nonempty mapping of group names to member mappings")
189
+ selected: dict[str, dict[str, Callable[[Any], Any]]] = {}
190
+ for group_name, members in groups.items():
191
+ if not isinstance(group_name, str) or not group_name.strip():
192
+ raise ValueError(f"invalid group name {group_name!r}; expected a nonempty string")
193
+ if group_name in selected:
194
+ raise ValueError(f"duplicate group name {group_name!r}")
195
+ if not isinstance(members, Mapping) or len(members) < 2:
196
+ raise ValueError(f"group {group_name!r} must have at least two members")
197
+ selected_members: dict[str, Callable[[Any], Any]] = {}
198
+ for member_id, policy in members.items():
199
+ if not isinstance(member_id, str) or not member_id.strip():
200
+ raise ValueError(
201
+ f"group {group_name!r} has invalid member ID {member_id!r}"
202
+ )
203
+ if member_id in selected_members:
204
+ raise ValueError(f"group {group_name!r} has duplicate member ID {member_id!r}")
205
+ if not callable(policy):
206
+ raise TypeError(
207
+ f"group {group_name!r} member {member_id!r} policy must be callable"
208
+ )
209
+ selected_members[member_id] = policy
210
+ selected[group_name] = selected_members
211
+ return selected
212
+
213
+
214
+ def _condition_matrix(
215
+ records: list[EpisodeRecord],
216
+ group: str,
217
+ members: list[str],
218
+ shift: str,
219
+ level: float,
220
+ episode_seeds: list[int],
221
+ metric: str,
222
+ ) -> np.ndarray:
223
+ """Build and validate one complete member by episode panel from raw records."""
224
+ cells: dict[tuple[str, int], float] = {}
225
+ expected_members = set(members)
226
+ expected_seeds = set(episode_seeds)
227
+ for record in records:
228
+ if record.policy != group or record.shift != shift or record.level != level:
229
+ continue
230
+ if record.member not in expected_members or record.episode_seed not in expected_seeds:
231
+ raise ValueError(f"group {group!r} has an unexpected member or episode seed")
232
+ if record.episode_seed != episode_seeds[record.episode_index]:
233
+ raise ValueError(f"group {group!r} has a mismatched episode index and seed")
234
+ key = (record.member, record.episode_seed)
235
+ if key in cells:
236
+ raise ValueError(f"group {group!r} has duplicate condition episode key {key!r}")
237
+ if metric == "return":
238
+ value = record.episode.episode_return
239
+ elif metric == "length":
240
+ value = record.episode.length
241
+ elif metric == "success":
242
+ if record.success is None:
243
+ raise ValueError("success metric requires a configured success_fn")
244
+ value = float(record.success)
245
+ else:
246
+ value = record.episode.metrics[metric]
247
+ cells[key] = float(value)
248
+ expected_count = len(members) * len(episode_seeds)
249
+ if len(cells) != expected_count:
250
+ raise ValueError(
251
+ f"group {group!r} {shift!r} level {level} has incomplete episode records"
252
+ )
253
+ return np.asarray(
254
+ [[cells[(member, seed)] for seed in episode_seeds] for member in members],
255
+ dtype=float,
256
+ )
257
+
258
+
259
+ def _spread(values: np.ndarray) -> tuple[float, float, float]:
260
+ member_means = values.mean(axis=1)
261
+ return (
262
+ float(np.std(member_means, ddof=1)),
263
+ float(np.min(member_means)),
264
+ float(np.max(member_means)),
265
+ )
266
+
267
+
268
+ def evaluate_groups(
269
+ suite: Suite,
270
+ groups: Mapping[str, Mapping[str, Callable[[Any], Any]]],
271
+ ) -> GroupResults:
272
+ """Evaluate frozen member policies and aggregate without flattening seed runs."""
273
+ selected = _validated_groups(groups)
274
+ conditions = [("nominal", 0.0)] + [
275
+ (shift.name, level) for shift in suite.shifts for level in shift.levels
276
+ ]
277
+ episode_seeds = [suite.seed + index for index in range(suite.n_episodes)]
278
+ rows: list[dict[str, Any]] = []
279
+ member_rows: list[dict[str, Any]] = []
280
+ records: list[EpisodeRecord] = []
281
+ metric_rows: list[dict[str, Any]] = []
282
+ metric_names = [*suite.step_metrics, *suite.episode_metrics]
283
+ base_meta: dict[str, Any] = {}
284
+
285
+ for group_name, members in selected.items():
286
+ evaluated = suite.evaluate(members)
287
+ if not base_meta:
288
+ base_meta = dict(evaluated.meta)
289
+ for member_row in evaluated.rows:
290
+ member_rows.append(
291
+ {
292
+ "group": group_name,
293
+ "member": member_row["policy"],
294
+ **{
295
+ column: member_row[column]
296
+ for column in CSV_COLUMNS
297
+ if column != "policy"
298
+ },
299
+ }
300
+ )
301
+ group_records = [
302
+ EpisodeRecord(
303
+ policy=group_name,
304
+ member=record.policy,
305
+ shift=record.shift,
306
+ level=record.level,
307
+ episode_index=record.episode_index,
308
+ episode_seed=record.episode_seed,
309
+ episode=record.episode,
310
+ success=record.success,
311
+ )
312
+ for record in evaluated.records
313
+ ]
314
+ records.extend(group_records)
315
+ member_ids = list(members)
316
+ nominal_return = _condition_matrix(
317
+ group_records, group_name, member_ids, "nominal", 0.0, episode_seeds, "return"
318
+ )
319
+ nominal_metrics = {
320
+ metric: _condition_matrix(
321
+ group_records, group_name, member_ids, "nominal", 0.0, episode_seeds, metric
322
+ )
323
+ for metric in metric_names
324
+ }
325
+
326
+ for shift_name, level in conditions:
327
+ common = {
328
+ "group": group_name,
329
+ "shift": shift_name,
330
+ "level": level,
331
+ "n_members": len(member_ids),
332
+ "n_episodes_per_member": suite.n_episodes,
333
+ "total_episodes": len(member_ids) * suite.n_episodes,
334
+ }
335
+ returns = _condition_matrix(
336
+ group_records, group_name, member_ids, shift_name, level, episode_seeds, "return"
337
+ )
338
+ mean_return, return_low, return_high = crossed_mean_ci(
339
+ returns,
340
+ seed=suite._bootstrap_seed(group_name, shift_name, level, "group:return"),
341
+ )
342
+ member_std, member_min, member_max = _spread(returns)
343
+ if shift_name == "nominal":
344
+ drop = drop_low = drop_high = None
345
+ else:
346
+ drop, drop_low, drop_high = crossed_paired_diff_ci(
347
+ nominal_return,
348
+ returns,
349
+ seed=suite._bootstrap_seed(group_name, shift_name, level, "group:return:drop"),
350
+ )
351
+ lengths = _condition_matrix(
352
+ group_records, group_name, member_ids, shift_name, level, episode_seeds, "length"
353
+ )
354
+ if suite.success_fn is None:
355
+ success = success_low = success_high = None
356
+ else:
357
+ successes = _condition_matrix(
358
+ group_records, group_name, member_ids, shift_name, level, episode_seeds, "success"
359
+ )
360
+ success, success_low, success_high = crossed_mean_ci(
361
+ successes,
362
+ seed=suite._bootstrap_seed(group_name, shift_name, level, "group:success"),
363
+ )
364
+ rows.append(
365
+ {
366
+ **common,
367
+ "mean_return": mean_return,
368
+ "return_ci_low": return_low,
369
+ "return_ci_high": return_high,
370
+ "member_std": member_std,
371
+ "member_min": member_min,
372
+ "member_max": member_max,
373
+ "drop": drop,
374
+ "drop_ci_low": drop_low,
375
+ "drop_ci_high": drop_high,
376
+ "mean_length": float(np.mean(lengths)),
377
+ "success_rate": success,
378
+ "success_ci_low": success_low,
379
+ "success_ci_high": success_high,
380
+ }
381
+ )
382
+ for metric in metric_names:
383
+ values = _condition_matrix(
384
+ group_records,
385
+ group_name,
386
+ member_ids,
387
+ shift_name,
388
+ level,
389
+ episode_seeds,
390
+ metric,
391
+ )
392
+ mean, low, high = crossed_mean_ci(
393
+ values,
394
+ seed=suite._bootstrap_seed(group_name, shift_name, level, f"group:{metric}"),
395
+ )
396
+ metric_std, metric_min, metric_max = _spread(values)
397
+ if shift_name == "nominal":
398
+ metric_drop = metric_drop_low = metric_drop_high = None
399
+ else:
400
+ metric_drop, metric_drop_low, metric_drop_high = crossed_paired_diff_ci(
401
+ nominal_metrics[metric],
402
+ values,
403
+ seed=suite._bootstrap_seed(
404
+ group_name, shift_name, level, f"group:{metric}:drop"
405
+ ),
406
+ )
407
+ metric_rows.append(
408
+ {
409
+ **common,
410
+ "metric": metric,
411
+ "mean": mean,
412
+ "ci_low": low,
413
+ "ci_high": high,
414
+ "member_std": metric_std,
415
+ "member_min": metric_min,
416
+ "member_max": metric_max,
417
+ "drop": metric_drop,
418
+ "drop_ci_low": metric_drop_low,
419
+ "drop_ci_high": metric_drop_high,
420
+ }
421
+ )
422
+
423
+ meta = {
424
+ key: value for key, value in base_meta.items() if key != "policies"
425
+ }
426
+ meta.update(
427
+ {
428
+ "groups": {name: list(members) for name, members in selected.items()},
429
+ "evaluation_seeds": episode_seeds,
430
+ "n_members": {name: len(members) for name, members in selected.items()},
431
+ "ci_method": "crossed member and evaluation episode percentile bootstrap",
432
+ "ci_confidence": 0.95,
433
+ "ci_replicates": 2000,
434
+ "member_spread": "sample SD and observed range of member means; descriptive, not a CI",
435
+ }
436
+ )
437
+ return GroupResults(rows, meta, member_rows, records, metric_rows)
438
+
439
+
440
+ __all__ = ["GroupResults", "evaluate_groups"]
rlshiftlab/py.typed ADDED
File without changes
rlshiftlab/records.py ADDED
@@ -0,0 +1,50 @@
1
+ """Episode and transition records for frozen-policy evaluations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class EpisodeResult:
11
+ """The return, stopping state, and optional metrics of one episode."""
12
+
13
+ episode_return: float
14
+ length: int
15
+ terminated: bool
16
+ truncated: bool
17
+ metrics: dict[str, float] = field(default_factory=dict)
18
+ final_info: dict[str, Any] = field(default_factory=dict)
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Transition:
23
+ """One policy-visible step supplied to a custom metric callback."""
24
+
25
+ observation: Any
26
+ requested_action: Any
27
+ next_observation: Any
28
+ reward: float
29
+ terminated: bool
30
+ truncated: bool
31
+ info: dict[str, Any]
32
+ step_index: int
33
+ episode_seed: int
34
+
35
+
36
+ @dataclass
37
+ class EpisodeRecord:
38
+ """An auditable episode keyed by condition and evaluation seed."""
39
+
40
+ policy: str
41
+ member: str | None
42
+ shift: str
43
+ level: float
44
+ episode_index: int
45
+ episode_seed: int
46
+ episode: EpisodeResult
47
+ success: bool | None = None
48
+
49
+
50
+ __all__ = ["EpisodeRecord", "EpisodeResult", "Transition"]