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/report.py ADDED
@@ -0,0 +1,273 @@
1
+ """Write self-contained HTML reports for evaluations and direct comparisons."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import html
7
+ import io
8
+ import json
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING, Any
12
+
13
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
14
+ from matplotlib.figure import Figure
15
+
16
+ if TYPE_CHECKING:
17
+ from .comparisons import ComparisonResults
18
+ from .groups import GroupResults
19
+ from .results import Results
20
+
21
+
22
+ NOTICE = (
23
+ "Each curve varies one shift at a time on a frozen policy. "
24
+ "Differences are measured; this report does not establish their causes."
25
+ )
26
+
27
+ STYLE = """
28
+ :root { color-scheme: light; font-family: system-ui, sans-serif; }
29
+ body { max-width: 1100px; margin: 2rem auto; padding: 0 1rem; color: #18212b; line-height: 1.5; }
30
+ h1, h2, h3 { line-height: 1.2; }
31
+ section { margin: 2rem 0; }
32
+ dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: .35rem 1rem; }
33
+ dt { font-weight: 650; }
34
+ dd { margin: 0; overflow-wrap: anywhere; }
35
+ img { display: block; max-width: 100%; height: auto; }
36
+ pre { padding: 1rem; overflow-x: auto; background: #f3f5f7; border-radius: .4rem; }
37
+ .table-wrap { overflow-x: auto; }
38
+ table { border-collapse: collapse; width: 100%; font-size: .88rem; }
39
+ th, td { padding: .45rem .65rem; text-align: left; border-bottom: 1px solid #d8dee5; white-space: nowrap; }
40
+ th { background: #f3f5f7; }
41
+ .notice { border-left: .3rem solid #405d7a; padding: .7rem 1rem; background: #f3f5f7; }
42
+ """
43
+
44
+
45
+ def _display(value: object) -> str:
46
+ """Escape a value before inserting it into HTML text or an attribute."""
47
+ if value is None:
48
+ return ""
49
+ if isinstance(value, (list, tuple, dict)):
50
+ value = json.dumps(value, ensure_ascii=False)
51
+ return html.escape(str(value), quote=True)
52
+
53
+
54
+ def _figure_data(figure: Figure) -> str:
55
+ """Encode a headless Matplotlib figure as an inline PNG."""
56
+ image = io.BytesIO()
57
+ FigureCanvasAgg(figure).print_png(image)
58
+ return base64.b64encode(image.getvalue()).decode("ascii")
59
+
60
+
61
+ def _image(figure: Figure, alt: str) -> str:
62
+ return (
63
+ f'<img alt="{_display(alt)}" '
64
+ f'src="data:image/png;base64,{_figure_data(figure)}">'
65
+ )
66
+
67
+
68
+ def _table(rows: Sequence[dict[str, Any]], columns: Sequence[str]) -> str:
69
+ headings = "".join(f'<th scope="col">{_display(name)}</th>' for name in columns)
70
+ body = "\n".join(
71
+ "<tr>" + "".join(f"<td>{_display(row.get(name))}</td>" for name in columns) + "</tr>"
72
+ for row in rows
73
+ )
74
+ return f'<div class="table-wrap"><table><thead><tr>{headings}</tr></thead><tbody>{body}</tbody></table></div>'
75
+
76
+
77
+ def _setup(meta: dict[str, Any]) -> str:
78
+ rows = "\n".join(
79
+ f"<dt>{_display(key)}</dt><dd>{_display(value)}</dd>"
80
+ for key, value in meta.items()
81
+ )
82
+ return f"<section><h2>Setup</h2><dl>{rows}</dl></section>"
83
+
84
+
85
+ def _section(title: str, body: str) -> str:
86
+ return f"<section><h2>{_display(title)}</h2>{body}</section>"
87
+
88
+
89
+ def _comparison_sections(comparisons: Sequence[ComparisonResults]) -> str:
90
+ from .exports import COMPARISON_COLUMNS
91
+
92
+ sections = []
93
+ for comparison in comparisons:
94
+ left = comparison.meta.get("left", "left")
95
+ right = comparison.meta.get("right", "right")
96
+ metric = comparison.meta.get("metric", "return")
97
+ title = f"Direct comparison: {left} - {right} ({metric})"
98
+ explanation = (
99
+ "<p>Positive means the left side has a larger measured metric; "
100
+ "larger is not necessarily better. Intervals are pointwise 95% "
101
+ "confidence intervals, not simultaneous or adjusted for multiple "
102
+ "comparisons. Evaluation episodes are paired by their reset seeds.</p>"
103
+ )
104
+ if comparison.meta.get("groups") is not None:
105
+ explanation += (
106
+ "<p>Training members on the two sides are resampled independently; "
107
+ "matching member ID strings do not create training pairs.</p>"
108
+ )
109
+ body = (
110
+ explanation
111
+ + _image(comparison.plot(), f"{left} minus {right} for {metric}, with zero reference")
112
+ + f"<pre>{_display(comparison.summary())}</pre>"
113
+ + _table(comparison.rows, COMPARISON_COLUMNS)
114
+ )
115
+ sections.append(_section(title, body))
116
+ return "\n".join(sections)
117
+
118
+
119
+ def _single_body(results: Results, comparisons: Sequence[ComparisonResults]) -> str:
120
+ from .exports import METRIC_COLUMNS
121
+ from .results import CSV_COLUMNS
122
+
123
+ sections = [
124
+ _setup(results.meta),
125
+ _section(
126
+ "Degradation curves",
127
+ _image(
128
+ results.plot(),
129
+ "Mean return and 95% confidence intervals by shift level and policy",
130
+ ),
131
+ ),
132
+ _section("Summary", f"<pre>{_display(results.summary())}</pre>"),
133
+ _section("Full results", _table(results.rows, CSV_COLUMNS)),
134
+ ]
135
+ if results.metric_rows:
136
+ sections.append(
137
+ _section(
138
+ "Custom per-episode metrics",
139
+ "<p>Each row summarizes a scalar metric measured per evaluation episode. "
140
+ "Drop is nominal minus shifted for the same episode seed.</p>"
141
+ + _table(results.metric_rows, METRIC_COLUMNS),
142
+ )
143
+ )
144
+ if results.records:
145
+ sections.append(
146
+ _section(
147
+ "Episode audit",
148
+ f"<p>{len(results.records)} per-episode records are available through "
149
+ "<code>to_episode_csv()</code>. Arbitrary observations and environment "
150
+ "<code>info</code> values are not placed in this report.</p>",
151
+ )
152
+ )
153
+ if comparisons:
154
+ sections.append(_comparison_sections(comparisons))
155
+ return "\n".join(sections)
156
+
157
+
158
+ def _group_body(results: GroupResults, comparisons: Sequence[ComparisonResults]) -> str:
159
+ from .exports import GROUP_COLUMNS, GROUP_METRIC_COLUMNS, MEMBER_COLUMNS
160
+
161
+ n_members = results.meta.get("n_members", {})
162
+ counts = ", ".join(f"{name}: {count}" for name, count in n_members.items())
163
+ explanation = (
164
+ "<p>Each group mean gives equal weight to each caller-provided frozen "
165
+ "policy member after averaging its evaluation episodes. "
166
+ "Observed member SD, minimum and maximum describe the spread of those "
167
+ "member means; that spread includes finite-evaluation noise and is not "
168
+ "a confidence interval or pure training variance. "
169
+ "The pointwise 95% confidence intervals use a crossed bootstrap over "
170
+ "training members and evaluation episode keys. They are approximate, "
171
+ "not simultaneous curve bands or adjusted for multiple comparisons. "
172
+ "Intervals with only a few members can be unstable.</p>"
173
+ f"<p>Members per group: {_display(counts)}.</p>"
174
+ )
175
+ sections = [
176
+ _setup(results.meta),
177
+ _section("Group interpretation", explanation),
178
+ _section(
179
+ "Group degradation curves",
180
+ _image(
181
+ results.plot(metric="return", band="ci"),
182
+ "Group mean return with pointwise crossed confidence intervals",
183
+ ),
184
+ ),
185
+ _section("Summary", f"<pre>{_display(results.summary())}</pre>"),
186
+ _section("Full group results", _table(results.rows, GROUP_COLUMNS)),
187
+ _section("Member summaries", _table(results.member_rows, MEMBER_COLUMNS)),
188
+ ]
189
+ if results.metric_rows:
190
+ sections.append(
191
+ _section(
192
+ "Custom per-episode metrics",
193
+ "<p>Custom metric means and drops use the same member and evaluation "
194
+ "episode units as the return summaries.</p>"
195
+ + _table(results.metric_rows, GROUP_METRIC_COLUMNS),
196
+ )
197
+ )
198
+ sections.append(
199
+ _section(
200
+ "Episode audit",
201
+ f"<p>{len(results.records)} per-episode records are retained. Use "
202
+ "<code>to_episode_csv()</code> for the full audit; raw observations "
203
+ "and environment <code>info</code> are not embedded here.</p>",
204
+ )
205
+ )
206
+ if comparisons:
207
+ sections.append(_comparison_sections(comparisons))
208
+ return "\n".join(sections)
209
+
210
+
211
+ def _comparison_body(results: ComparisonResults) -> str:
212
+ return "\n".join(
213
+ [
214
+ _setup(results.meta),
215
+ _comparison_sections([results]),
216
+ _section(
217
+ "Interpretation",
218
+ "<p>Positive left-minus-right values mean a larger measured metric "
219
+ "for the left side, which is not always better. Intervals are "
220
+ "pointwise 95% confidence intervals. They do not establish "
221
+ "causes or account for simultaneous curve coverage or multiple "
222
+ "comparisons.</p>",
223
+ ),
224
+ ]
225
+ )
226
+
227
+
228
+ def write_html(
229
+ results: Results | GroupResults | ComparisonResults,
230
+ path: str | Path,
231
+ comparisons: Sequence[ComparisonResults] | None = None,
232
+ ) -> None:
233
+ """Write one standalone report for the supplied result and comparisons."""
234
+ from .comparisons import ComparisonResults
235
+ from .groups import GroupResults
236
+ from .results import Results
237
+
238
+ if isinstance(results, ComparisonResults):
239
+ if comparisons is not None:
240
+ raise ValueError("standalone comparison report does not accept nested comparisons")
241
+ title = "RLShiftLab direct comparison report"
242
+ body = _comparison_body(results)
243
+ elif isinstance(results, GroupResults):
244
+ title = "RLShiftLab group degradation report"
245
+ selected = [] if comparisons is None else list(comparisons)
246
+ if any(not isinstance(item, ComparisonResults) for item in selected):
247
+ raise TypeError("comparisons must contain ComparisonResults objects")
248
+ body = _group_body(results, selected)
249
+ elif isinstance(results, Results):
250
+ title = "RLShiftLab degradation report"
251
+ selected = [] if comparisons is None else list(comparisons)
252
+ if any(not isinstance(item, ComparisonResults) for item in selected):
253
+ raise TypeError("comparisons must contain ComparisonResults objects")
254
+ body = _single_body(results, selected)
255
+ else:
256
+ raise TypeError("results must be Results, GroupResults, or ComparisonResults")
257
+
258
+ document = f"""<!doctype html>
259
+ <html lang="en">
260
+ <head>
261
+ <meta charset="utf-8">
262
+ <meta name="viewport" content="width=device-width, initial-scale=1">
263
+ <title>{_display(title)}</title>
264
+ <style>{STYLE}</style>
265
+ </head>
266
+ <body>
267
+ <h1>{_display(title)}</h1>
268
+ <p class="notice">{_display(NOTICE)}</p>
269
+ {body}
270
+ </body>
271
+ </html>
272
+ """
273
+ Path(path).write_text(document, encoding="utf-8")
rlshiftlab/results.py ADDED
@@ -0,0 +1,200 @@
1
+ """Evaluation records, summaries, CSV output, and degradation plots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from dataclasses import dataclass, field
7
+ from math import ceil
8
+ from os import PathLike
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
13
+ from matplotlib.figure import Figure
14
+
15
+ from rlshiftlab.records import EpisodeRecord
16
+
17
+ if TYPE_CHECKING:
18
+ from rlshiftlab.comparisons import ComparisonResults
19
+
20
+ CSV_COLUMNS = (
21
+ "policy",
22
+ "shift",
23
+ "level",
24
+ "n_episodes",
25
+ "mean_return",
26
+ "return_ci_low",
27
+ "return_ci_high",
28
+ "drop",
29
+ "drop_ci_low",
30
+ "drop_ci_high",
31
+ "mean_length",
32
+ "success_rate",
33
+ "success_ci_low",
34
+ "success_ci_high",
35
+ )
36
+
37
+
38
+ @dataclass
39
+ class Results:
40
+ """Rows of evaluated conditions and metadata for later presentation."""
41
+
42
+ rows: list[dict[str, Any]]
43
+ meta: dict[str, Any]
44
+ records: list[EpisodeRecord] = field(default_factory=list)
45
+ metric_rows: list[dict[str, Any]] = field(default_factory=list)
46
+
47
+ def to_csv(self, path: str | PathLike[str]) -> None:
48
+ """Write tidy, deterministic results with the public column order."""
49
+ with Path(path).open("w", encoding="utf-8", newline="") as output:
50
+ writer = csv.DictWriter(output, fieldnames=CSV_COLUMNS, lineterminator="\n")
51
+ writer.writeheader()
52
+ for row in self.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 CSV_COLUMNS
61
+ }
62
+ )
63
+
64
+ def to_episode_csv(self, path: str | PathLike[str]) -> None:
65
+ """Write one scalar audit row per evaluation episode."""
66
+ from rlshiftlab.exports import episode_columns, episode_rows, write_rows
67
+
68
+ write_rows(path, episode_rows(self.records, self.meta), episode_columns(self.meta))
69
+
70
+ def to_metrics_csv(self, path: str | PathLike[str]) -> None:
71
+ """Write custom metric summaries in a separate long-form CSV."""
72
+ from rlshiftlab.exports import METRIC_COLUMNS, write_rows
73
+
74
+ write_rows(path, self.metric_rows, METRIC_COLUMNS)
75
+
76
+ def summary(self) -> str:
77
+ """Summarize nominal returns and the strongest level of each shift."""
78
+ headings = ("policy", "shift", "level", "mean return", "drop (95% CI)", "nominal mean")
79
+ lines = []
80
+ policies = self.meta.get("policies") or list(dict.fromkeys(row["policy"] for row in self.rows))
81
+ shifts = self.meta.get("shifts") or {
82
+ name: [] for name in dict.fromkeys(row["shift"] for row in self.rows if row["shift"] != "nominal")
83
+ }
84
+ for policy in policies:
85
+ nominal = next(
86
+ (row for row in self.rows if row["policy"] == policy and row["shift"] == "nominal"),
87
+ None,
88
+ )
89
+ if nominal is None:
90
+ continue
91
+ nominal_mean = float(nominal["mean_return"])
92
+ for shift in shifts:
93
+ matching = [
94
+ row for row in self.rows if row["policy"] == policy and row["shift"] == shift
95
+ ]
96
+ if not matching:
97
+ continue
98
+ strongest = max(matching, key=lambda row: float(row["level"]))
99
+ drop_text = (
100
+ f"{float(strongest['drop']):.3f} "
101
+ f"[{float(strongest['drop_ci_low']):.3f}, "
102
+ f"{float(strongest['drop_ci_high']):.3f}]"
103
+ )
104
+ lines.append(
105
+ (
106
+ str(policy),
107
+ str(shift),
108
+ f"{float(strongest['level']):g}",
109
+ f"{float(strongest['mean_return']):.3f}",
110
+ drop_text,
111
+ f"{nominal_mean:.3f}",
112
+ )
113
+ )
114
+ widths = [
115
+ max([len(heading), *(len(line[index]) for line in lines)])
116
+ for index, heading in enumerate(headings)
117
+ ]
118
+ header = " ".join(heading.ljust(widths[index]) for index, heading in enumerate(headings))
119
+ rule = " ".join("-" * width for width in widths)
120
+ body = [" ".join(value.ljust(widths[index]) for index, value in enumerate(line)) for line in lines]
121
+ return "\n".join([header, rule, *body])
122
+
123
+ def plot(
124
+ self, path: str | PathLike[str] | None = None, *, metric: str = "return"
125
+ ) -> Figure:
126
+ """Plot mean returns or a custom metric against each shift level."""
127
+ if metric == "return":
128
+ plot_rows = self.rows
129
+ mean_key, low_key, high_key = "mean_return", "return_ci_low", "return_ci_high"
130
+ y_label = "mean return (95% CI)"
131
+ else:
132
+ configured = [
133
+ *self.meta.get("step_metrics", ()), *self.meta.get("episode_metrics", ())
134
+ ]
135
+ if metric not in configured:
136
+ raise ValueError(f"custom metric {metric!r} is not configured")
137
+ plot_rows = [row for row in self.metric_rows if row["metric"] == metric]
138
+ mean_key, low_key, high_key = "mean", "ci_low", "ci_high"
139
+ y_label = f"mean {metric} (95% CI)"
140
+ shifts = list(
141
+ self.meta.get("shifts")
142
+ or dict.fromkeys(row["shift"] for row in self.rows if row["shift"] != "nominal")
143
+ )
144
+ if not shifts:
145
+ raise ValueError("results contain no shifted conditions to plot")
146
+ policies = self.meta.get("policies") or list(dict.fromkeys(row["policy"] for row in self.rows))
147
+ columns = min(3, len(shifts))
148
+ rows = ceil(len(shifts) / columns)
149
+ figure = Figure(figsize=(5.5 * columns, 4 * rows))
150
+ FigureCanvasAgg(figure)
151
+ for index, shift in enumerate(shifts, start=1):
152
+ axis = figure.add_subplot(rows, columns, index)
153
+ for policy in policies:
154
+ nominal = next(
155
+ (
156
+ row for row in plot_rows if row["policy"] == policy and row["shift"] == "nominal"
157
+ ),
158
+ None,
159
+ )
160
+ shifted = sorted(
161
+ (row for row in plot_rows if row["policy"] == policy and row["shift"] == shift),
162
+ key=lambda row: float(row["level"]),
163
+ )
164
+ if nominal is None or not shifted:
165
+ continue
166
+ points = [nominal, *shifted]
167
+ x = [float(point["level"]) for point in points]
168
+ means = [float(point[mean_key]) for point in points]
169
+ lows = [float(point[low_key]) for point in points]
170
+ highs = [float(point[high_key]) for point in points]
171
+ line = axis.plot(x, means, marker="o", label=str(policy))[0]
172
+ axis.fill_between(x, lows, highs, color=line.get_color(), alpha=0.18)
173
+ axis.set_title(shift.replace("_", " "))
174
+ axis.set_xlabel("shift strength")
175
+ axis.set_ylabel(y_label)
176
+ axis.grid(alpha=0.2)
177
+ axis.legend()
178
+ figure.tight_layout()
179
+ if path is not None:
180
+ figure.savefig(path, dpi=150)
181
+ return figure
182
+
183
+ def to_html(
184
+ self, path: str | PathLike[str], *, comparisons: Any = None
185
+ ) -> None:
186
+ """Write a standalone HTML report containing this result set."""
187
+ from rlshiftlab.report import write_html
188
+
189
+ if comparisons is None:
190
+ write_html(self, path)
191
+ else:
192
+ write_html(self, path, comparisons=comparisons)
193
+
194
+ def compare(
195
+ self, left: str, right: str, metric: str = "return"
196
+ ) -> ComparisonResults:
197
+ """Compare two policies on matched evaluation episode keys."""
198
+ from rlshiftlab.comparisons import compare_results
199
+
200
+ return compare_results(self, left, right, metric=metric, grouped=False)