model-eval-toolkit 0.1.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.
@@ -0,0 +1,479 @@
1
+ from __future__ import annotations
2
+
3
+ import html as html_module
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+
10
+ @dataclass
11
+ class BaseReport:
12
+ """Base class for all task-specific reports.
13
+
14
+ Handles the common API:
15
+ - ``run_all()`` to compute metrics and generate plots
16
+ - ``to_dict()`` to get a JSON-serializable summary
17
+ - ``save()`` to persist HTML/Markdown/JSON/PDF outputs
18
+ """
19
+
20
+ metrics: Dict[str, Any] = field(default_factory=dict)
21
+ plots: Dict[str, Any] = field(default_factory=dict)
22
+ insights: List[str] = field(default_factory=list)
23
+ metric_descriptions: Dict[str, str] = field(default_factory=dict)
24
+ # Base directory where report outputs (HTML/JSON/PDF) and plots live.
25
+ # This is typically set by the entrypoint based on output_path and
26
+ # defaults to "reports/" when not provided.
27
+ output_dir: Optional[Path] = None
28
+
29
+ def run_all(self) -> Dict[str, Any]:
30
+ """Run metrics, visualization, and insights.
31
+
32
+ Subclasses should override ``_compute_metrics``,
33
+ ``_generate_plots``, and ``_generate_insights``.
34
+ """
35
+ self._compute_metrics()
36
+ self._generate_plots()
37
+ self._generate_insights()
38
+ return self.to_dict()
39
+
40
+ # Hooks for subclasses -------------------------------------------------
41
+ def _compute_metrics(self) -> None: # pragma: no cover - to be overridden
42
+ ...
43
+
44
+ def _generate_plots(self) -> None: # pragma: no cover - to be overridden
45
+ ...
46
+
47
+ def _generate_insights(self) -> None: # pragma: no cover - to be overridden
48
+ ...
49
+
50
+ # Public helpers -------------------------------------------------------
51
+ def to_dict(self) -> Dict[str, Any]:
52
+ return {
53
+ "metrics": self.metrics,
54
+ "insights": self.insights,
55
+ "plots": self.plots,
56
+ "metric_descriptions": self.metric_descriptions,
57
+ }
58
+
59
+ def save(self, path: str, format: Optional[str] = None) -> None:
60
+ """Save the report to disk.
61
+
62
+ For v0.1 this implements a minimal HTML/JSON/Markdown/PDF writer;
63
+ richer templating can be added incrementally.
64
+ """
65
+ target = Path(path)
66
+ fmt = (format or target.suffix.lstrip(".") or "html").lower()
67
+
68
+ if fmt == "json":
69
+ target.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
70
+ elif fmt in {"html", "htm"}:
71
+ html = self._to_simple_html()
72
+ target.write_text(html, encoding="utf-8")
73
+ elif fmt in {"md", "markdown"}:
74
+ md = self._to_simple_markdown()
75
+ target.write_text(md, encoding="utf-8")
76
+ elif fmt == "pdf":
77
+ self._to_simple_pdf(target)
78
+ else:
79
+ raise ValueError(f"Unsupported output format: {fmt!r}")
80
+
81
+ @staticmethod
82
+ def _format_metric_value(v: Any) -> str:
83
+ if isinstance(v, (dict, list)):
84
+ try:
85
+ return json.dumps(v, indent=2, default=str)
86
+ except TypeError:
87
+ return str(v)
88
+ return str(v)
89
+
90
+ def _to_simple_html(self) -> str:
91
+ """Order: Metrics → Plots → Insights → Metric reference (descriptions)."""
92
+
93
+ def esc(s: Any) -> str:
94
+ return html_module.escape(self._format_metric_value(s), quote=True)
95
+
96
+ # 1) Metrics: name + value only (clean table)
97
+ metric_rows = []
98
+ for k, v in sorted(self.metrics.items()):
99
+ metric_rows.append(
100
+ "<tr>"
101
+ f"<td class='metric-key'><strong>{html_module.escape(str(k))}</strong></td>"
102
+ f"<td class='metric-value'><pre class='metric-pre'>{esc(v)}</pre></td>"
103
+ "</tr>"
104
+ )
105
+ rows_html = "\n".join(metric_rows) or "<tr><td colspan='2'>No metrics.</td></tr>"
106
+
107
+ # 2) Plots
108
+ plots_html_parts: List[str] = []
109
+ for name, path in self.plots.items():
110
+ title = html_module.escape(name.replace("_", " ").title())
111
+ path_esc = html_module.escape(str(path), quote=True)
112
+ plots_html_parts.append(
113
+ f"<div class='plot-card'>"
114
+ f"<h3 class='plot-title'>{title}</h3>"
115
+ f"<img src='{path_esc}' alt='{title}' loading='lazy' />"
116
+ f"<p class='plot-path'><code>{path_esc}</code></p>"
117
+ f"</div>"
118
+ )
119
+ plots_html = "\n".join(plots_html_parts) or "<p class='muted'>No plots generated.</p>"
120
+
121
+ # 3) Insights
122
+ insights_items = "".join(f"<li class='insight-item'>{html_module.escape(ins)}</li>" for ins in self.insights)
123
+ insights_block = (
124
+ f"<ul class='insights-list'>{insights_items}</ul>"
125
+ if insights_items
126
+ else "<p class='muted'>No automated insights.</p>"
127
+ )
128
+
129
+ # 4) Metric reference (descriptions at end)
130
+ desc_rows = []
131
+ for name in sorted(self.metrics.keys()):
132
+ desc = self.metric_descriptions.get(name, "")
133
+ desc_text = desc if desc else "—"
134
+ desc_rows.append(
135
+ "<tr>"
136
+ f"<td class='ref-metric'>{html_module.escape(str(name))}</td>"
137
+ f"<td class='ref-desc'>{html_module.escape(desc_text)}</td>"
138
+ "</tr>"
139
+ )
140
+ ref_html = "\n".join(desc_rows) or "<tr><td colspan='2'>No metrics to describe.</td></tr>"
141
+
142
+ return f"""<!DOCTYPE html>
143
+ <html lang="en">
144
+ <head>
145
+ <meta charset="utf-8" />
146
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
147
+ <title>Evaluation Report</title>
148
+ <style>
149
+ :root {{
150
+ --bg: #f0f4f8;
151
+ --card: #ffffff;
152
+ --border: #e2e8f0;
153
+ --text: #1e293b;
154
+ --muted: #64748b;
155
+ --accent: #0f766e;
156
+ --accent-soft: #ccfbf1;
157
+ }}
158
+ * {{ box-sizing: border-box; }}
159
+ body {{
160
+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
161
+ max-width: 1000px;
162
+ margin: 0 auto;
163
+ padding: 28px 20px 48px;
164
+ background: var(--bg);
165
+ color: var(--text);
166
+ line-height: 1.5;
167
+ }}
168
+ h1 {{
169
+ font-size: 1.75rem;
170
+ font-weight: 700;
171
+ margin: 0 0 8px;
172
+ letter-spacing: -0.02em;
173
+ }}
174
+ .subtitle {{
175
+ color: var(--muted);
176
+ font-size: 0.95rem;
177
+ margin-bottom: 28px;
178
+ }}
179
+ .section {{
180
+ background: var(--card);
181
+ border-radius: 12px;
182
+ padding: 20px 22px;
183
+ margin-bottom: 20px;
184
+ border: 1px solid var(--border);
185
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
186
+ }}
187
+ .section h2 {{
188
+ font-size: 1.1rem;
189
+ margin: 0 0 14px;
190
+ padding-bottom: 10px;
191
+ border-bottom: 2px solid var(--accent-soft);
192
+ color: var(--accent);
193
+ font-weight: 600;
194
+ }}
195
+ .section-num {{
196
+ display: inline-block;
197
+ width: 1.5rem;
198
+ height: 1.5rem;
199
+ line-height: 1.5rem;
200
+ text-align: center;
201
+ border-radius: 6px;
202
+ background: var(--accent);
203
+ color: #fff;
204
+ font-size: 0.75rem;
205
+ margin-right: 8px;
206
+ vertical-align: middle;
207
+ }}
208
+ table.data {{
209
+ width: 100%;
210
+ border-collapse: collapse;
211
+ font-size: 0.9rem;
212
+ }}
213
+ table.data th, table.data td {{
214
+ border-bottom: 1px solid var(--border);
215
+ padding: 10px 12px;
216
+ text-align: left;
217
+ vertical-align: top;
218
+ }}
219
+ table.data thead th {{
220
+ background: #f8fafc;
221
+ font-weight: 600;
222
+ color: var(--muted);
223
+ font-size: 0.75rem;
224
+ text-transform: uppercase;
225
+ letter-spacing: 0.04em;
226
+ }}
227
+ .metric-key {{ width: 32%; font-weight: 500; }}
228
+ .metric-value {{ font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; font-size: 0.85rem; }}
229
+ pre.metric-pre {{
230
+ margin: 0;
231
+ white-space: pre-wrap;
232
+ word-break: break-word;
233
+ font-family: inherit;
234
+ font-size: inherit;
235
+ }}
236
+ .ref-metric {{ width: 28%; font-weight: 500; color: #334155; }}
237
+ .ref-desc {{ color: #475569; font-size: 0.9rem; }}
238
+ .plots-grid {{
239
+ display: grid;
240
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
241
+ gap: 18px;
242
+ }}
243
+ .plot-card {{
244
+ border: 1px solid var(--border);
245
+ border-radius: 10px;
246
+ padding: 12px;
247
+ background: #fafbfc;
248
+ }}
249
+ .plot-title {{ font-size: 0.95rem; margin: 0 0 10px; color: #334155; }}
250
+ .plot-card img {{
251
+ width: 100%;
252
+ border-radius: 8px;
253
+ border: 1px solid var(--border);
254
+ display: block;
255
+ }}
256
+ .plot-path {{ margin: 8px 0 0; font-size: 0.75rem; color: var(--muted); }}
257
+ .plot-path code {{ background: #f1f5f9; padding: 2px 6px; border-radius: 4px; }}
258
+ .insights-list {{
259
+ margin: 0;
260
+ padding-left: 1.25rem;
261
+ }}
262
+ .insight-item {{ margin-bottom: 8px; padding-left: 4px; }}
263
+ .muted {{ color: var(--muted); font-style: italic; margin: 0; }}
264
+ </style>
265
+ </head>
266
+ <body>
267
+ <h1>Evaluation Report</h1>
268
+ <p class="subtitle">Metrics, visualizations, insights, and a reference for what each metric means.</p>
269
+
270
+ <div class="section">
271
+ <h2><span class="section-num">1</span>Metrics</h2>
272
+ <table class="data">
273
+ <thead><tr><th>Metric</th><th>Value</th></tr></thead>
274
+ <tbody>{rows_html}</tbody>
275
+ </table>
276
+ </div>
277
+
278
+ <div class="section">
279
+ <h2><span class="section-num">2</span>Plots</h2>
280
+ <div class="plots-grid">{plots_html}</div>
281
+ </div>
282
+
283
+ <div class="section">
284
+ <h2><span class="section-num">3</span>Insights</h2>
285
+ {insights_block}
286
+ </div>
287
+
288
+ <div class="section">
289
+ <h2><span class="section-num">4</span>Metric reference</h2>
290
+ <p class="muted" style="margin-top:0;margin-bottom:12px;">What each reported metric indicates (same order as the metrics table).</p>
291
+ <table class="data">
292
+ <thead><tr><th>Metric</th><th>Description</th></tr></thead>
293
+ <tbody>{ref_html}</tbody>
294
+ </table>
295
+ </div>
296
+ </body>
297
+ </html>"""
298
+
299
+ def _to_simple_markdown(self) -> str:
300
+ """Order: Metrics → Plots → Insights → Metric reference."""
301
+
302
+ lines: List[str] = [
303
+ "# Evaluation Report",
304
+ "",
305
+ "Structured as: **Metrics** → **Plots** → **Insights** → **Metric reference**.",
306
+ "",
307
+ "---",
308
+ "",
309
+ "## 1. Metrics",
310
+ "",
311
+ ]
312
+ for k, v in sorted(self.metrics.items()):
313
+ val = self._format_metric_value(v)
314
+ if "\n" in val:
315
+ lines.append(f"### `{k}`")
316
+ lines.append("")
317
+ lines.append("```")
318
+ lines.append(val)
319
+ lines.append("```")
320
+ lines.append("")
321
+ else:
322
+ lines.append(f"- **`{k}`**: {val}")
323
+ if not self.metrics:
324
+ lines.append("_No metrics._")
325
+ lines.append("")
326
+ lines.append("---")
327
+ lines.append("")
328
+ lines.append("## 2. Plots")
329
+ lines.append("")
330
+ if self.plots:
331
+ for name, p in sorted(self.plots.items()):
332
+ title = name.replace("_", " ").title()
333
+ lines.append(f"### {title}")
334
+ lines.append("")
335
+ lines.append(f"![{title}]({p})")
336
+ lines.append("")
337
+ lines.append(f"*Path:* `{p}`")
338
+ lines.append("")
339
+ else:
340
+ lines.append("_No plots._")
341
+ lines.append("---")
342
+ lines.append("")
343
+ lines.append("## 3. Insights")
344
+ lines.append("")
345
+ if self.insights:
346
+ for ins in self.insights:
347
+ lines.append(f"- {ins}")
348
+ else:
349
+ lines.append("_No automated insights._")
350
+ lines.append("")
351
+ lines.append("---")
352
+ lines.append("")
353
+ lines.append("## 4. Metric reference")
354
+ lines.append("")
355
+ lines.append("What each metric means (aligned with section 1).")
356
+ lines.append("")
357
+ for name in sorted(self.metrics.keys()):
358
+ desc = self.metric_descriptions.get(name, "").strip()
359
+ lines.append(f"- **`{name}`**")
360
+ lines.append(f" - {desc if desc else '_No description._'}")
361
+ if not self.metrics:
362
+ lines.append("_No metrics._")
363
+ lines.append("")
364
+ return "\n".join(lines)
365
+
366
+ def _to_simple_pdf(self, target: Path) -> None:
367
+ """PDF: Metrics → Plot paths → Insights → Metric reference. Paginates when needed."""
368
+
369
+ try:
370
+ from reportlab.lib.pagesizes import A4
371
+ from reportlab.pdfgen import canvas
372
+ except Exception as exc: # pragma: no cover - dependency issue
373
+ raise RuntimeError(
374
+ "PDF output requires the 'reportlab' package. "
375
+ "Install with: pip install reportlab."
376
+ ) from exc
377
+
378
+ width, height = A4
379
+ margin_x = 48
380
+ margin_top = height - 52
381
+ line_h = 12
382
+ section_gap = 18
383
+ bottom_y = 52
384
+
385
+ c = canvas.Canvas(str(target), pagesize=A4)
386
+
387
+ y = margin_top
388
+
389
+ def ensure_space(need: int) -> None:
390
+ nonlocal y, c
391
+ if y - need < bottom_y:
392
+ c.showPage()
393
+ y = margin_top
394
+
395
+ def draw_heading(title: str, size: int = 14) -> None:
396
+ nonlocal y
397
+ ensure_space(section_gap + line_h + 4)
398
+ c.setFont("Helvetica-Bold", size)
399
+ c.drawString(margin_x, y, title)
400
+ y -= size + 6
401
+
402
+ def draw_body_line(text: str, indent: int = 0) -> None:
403
+ nonlocal y
404
+ ensure_space(line_h)
405
+ c.setFont("Helvetica", 9)
406
+ x = margin_x + indent
407
+ # simple wrap for long lines
408
+ max_w = width - margin_x * 2 - indent
409
+ words = text.split()
410
+ line = ""
411
+ for w in words:
412
+ test = (line + " " + w).strip()
413
+ if c.stringWidth(test, "Helvetica", 9) <= max_w:
414
+ line = test
415
+ else:
416
+ if line:
417
+ c.drawString(x, y, line[:500])
418
+ y -= line_h
419
+ ensure_space(line_h)
420
+ line = w
421
+ if line:
422
+ c.drawString(x, y, line[:500])
423
+ y -= line_h
424
+
425
+ # Title
426
+ c.setFont("Helvetica-Bold", 18)
427
+ c.drawString(margin_x, y, "Evaluation Report")
428
+ y -= 22
429
+ c.setFont("Helvetica-Oblique", 9)
430
+ c.setFillColorRGB(0.35, 0.35, 0.35)
431
+ c.drawString(margin_x, y, "Order: Metrics | Plots (paths) | Insights | Metric reference")
432
+ c.setFillColorRGB(0, 0, 0)
433
+ y -= section_gap
434
+
435
+ # 1 Metrics
436
+ draw_heading("1. Metrics", 12)
437
+ for name, value in sorted(self.metrics.items()):
438
+ val_str = self._format_metric_value(value)
439
+ draw_body_line(f"{name}: {val_str[:200]}{'...' if len(val_str) > 200 else ''}", 0)
440
+ if len(val_str) > 200:
441
+ # continuation chunks
442
+ rest = val_str[200:]
443
+ while rest:
444
+ chunk = rest[:120]
445
+ rest = rest[120:]
446
+ draw_body_line(chunk, 12)
447
+ if not self.metrics:
448
+ draw_body_line("(No metrics.)")
449
+ y -= section_gap // 2
450
+
451
+ # 2 Plots (paths only)
452
+ draw_heading("2. Plots (file paths)", 12)
453
+ if self.plots:
454
+ for name, p in sorted(self.plots.items()):
455
+ draw_body_line(f"{name}: {p}", 0)
456
+ else:
457
+ draw_body_line("(No plots.)")
458
+ y -= section_gap // 2
459
+
460
+ # 3 Insights
461
+ draw_heading("3. Insights", 12)
462
+ if self.insights:
463
+ for ins in self.insights:
464
+ draw_body_line(f"- {ins}", 0)
465
+ else:
466
+ draw_body_line("(No insights.)")
467
+ y -= section_gap // 2
468
+
469
+ # 4 Metric reference
470
+ draw_heading("4. Metric reference", 12)
471
+ for name in sorted(self.metrics.keys()):
472
+ desc = self.metric_descriptions.get(name, "").strip() or "(No description.)"
473
+ draw_body_line(f"{name}", 0)
474
+ draw_body_line(desc, 10)
475
+ y -= 4
476
+ if not self.metrics:
477
+ draw_body_line("(No metrics.)")
478
+
479
+ c.save()
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Iterable, Optional
5
+
6
+ from ..classification.report import ClassificationReport
7
+ from ..clustering.report import ClusteringReport
8
+ from ..nlp.text_classification import TextClassificationReport
9
+ from ..nlp.text_generation import TextGenerationReport
10
+ from ..regression.report import RegressionReport
11
+ from ..timeseries.report import TimeSeriesReport
12
+ from ..ranking.report import RankingReport
13
+ from ..vision.detection import DetectionReport
14
+ from ..vision.segmentation import SegmentationReport
15
+ from ..vision.image_classification import ImageClassificationReport
16
+ from .task_inference import infer_task
17
+
18
+
19
+ def generate_report(
20
+ *,
21
+ task: str = "auto",
22
+ y_true: Optional[Iterable[Any]] = None,
23
+ y_pred: Optional[Iterable[Any]] = None,
24
+ y_prob: Optional[Iterable[Any]] = None,
25
+ X: Optional[Iterable[Any]] = None,
26
+ timestamps: Optional[Iterable[Any]] = None,
27
+ embeddings: Optional[Iterable[Any]] = None,
28
+ output_path: Optional[str] = None,
29
+ format: str = "html",
30
+ **kwargs: Any,
31
+ ) -> Any:
32
+ """Unified entry point for generating evaluation reports.
33
+
34
+ This is a thin orchestrator that delegates to the appropriate
35
+ task-specific report class based on the ``task`` argument or
36
+ automatic task inference.
37
+ """
38
+ if task == "auto":
39
+ task = infer_task(y_true=y_true, y_pred=y_pred, X=X, timestamps=timestamps, embeddings=embeddings)
40
+
41
+ task = task.lower()
42
+
43
+ # Determine base output directory for this report
44
+ if output_path:
45
+ base_dir = Path(output_path).expanduser().resolve().parent
46
+ else:
47
+ base_dir = Path("reports").resolve()
48
+ base_dir.mkdir(parents=True, exist_ok=True)
49
+
50
+ if task in {"classification", "binary_classification", "multiclass", "multilabel"}:
51
+ report = ClassificationReport(y_true=y_true, y_pred=y_pred, y_prob=y_prob, **kwargs)
52
+ elif task == "regression":
53
+ report = RegressionReport(y_true=y_true, y_pred=y_pred, **kwargs)
54
+ elif task in {"clustering", "cluster"}:
55
+ # For clustering, y_pred is treated as cluster assignments/labels.
56
+ report = ClusteringReport(X=X, labels=y_pred, **kwargs)
57
+ elif task in {"timeseries", "forecasting", "time_series"}:
58
+ report = TimeSeriesReport(y_true=y_true, y_pred=y_pred, timestamps=timestamps, **kwargs)
59
+ elif task in {"text_classification", "nlp_text_classification"}:
60
+ report = TextClassificationReport(y_true=y_true, y_pred=y_pred, y_prob=y_prob, **kwargs)
61
+ elif task in {"text_generation", "nlp_text_generation"}:
62
+ report = TextGenerationReport(references=y_true, predictions=y_pred, **kwargs)
63
+ elif task in {"segmentation", "image_segmentation"}:
64
+ report = SegmentationReport(y_true_masks=y_true, y_pred_masks=y_pred, **kwargs)
65
+ elif task in {"detection", "object_detection"}:
66
+ report = DetectionReport(y_true=y_true, y_pred=y_pred, **kwargs)
67
+ elif task in {"image_classification", "vision_classification"}:
68
+ report = ImageClassificationReport(y_true=y_true, y_pred=y_pred, y_prob=y_prob, **kwargs)
69
+ elif task in {"ranking", "recommendation", "recommender"}:
70
+ # y_true = relevant items per user; y_pred = ranked recommendation lists per user
71
+ report = RankingReport(relevant=y_true, ranked=y_pred, **kwargs)
72
+ else:
73
+ raise ValueError(
74
+ f"Unsupported task type for v0.1: {task!r}. "
75
+ "Currently supported: classification, regression, clustering, timeseries, nlp, vision, ranking."
76
+ )
77
+
78
+ # Make sure downstream plots know where they should live
79
+ report.output_dir = base_dir
80
+
81
+ result = report.run_all()
82
+
83
+ # Persist report
84
+ if output_path:
85
+ output_path = str(output_path)
86
+ suffix = Path(output_path).suffix.lower()
87
+ fmt = format or suffix.lstrip(".") or "html"
88
+ report.save(output_path, format=fmt)
89
+ else:
90
+ # Default to reports/<task>_report.html if user did not specify a path
91
+ fmt = (format or "html").lower()
92
+ default_name = f"{task}_report.{fmt}"
93
+ default_path = base_dir / default_name
94
+ report.save(str(default_path), format=fmt)
95
+
96
+ return result
97
+