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/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)
|