ml-benchmark-toolkit 0.1.0__tar.gz

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,91 @@
1
+ name: Build and Publish to PyPI
2
+
3
+ # Trigger on version tags like v0.1.0, v1.2.3, etc.
4
+ on:
5
+ push:
6
+ tags:
7
+ - "v*.*.*"
8
+
9
+ jobs:
10
+ build:
11
+ name: Build distribution
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.11"
20
+
21
+ - name: Install build tooling
22
+ run: python -m pip install --upgrade pip build
23
+
24
+ - name: Build sdist and wheel
25
+ run: python -m build
26
+
27
+ - name: Upload build artifacts
28
+ uses: actions/upload-artifact@v4
29
+ with:
30
+ name: dist
31
+ path: dist/
32
+
33
+ test:
34
+ name: Smoke test the built wheel
35
+ needs: build
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+
40
+ - name: Set up Python
41
+ uses: actions/setup-python@v5
42
+ with:
43
+ python-version: "3.11"
44
+
45
+ - name: Download build artifacts
46
+ uses: actions/download-artifact@v4
47
+ with:
48
+ name: dist
49
+ path: dist/
50
+
51
+ - name: Install wheel and run example
52
+ run: |
53
+ pip install dist/*.whl
54
+ python examples/quickstart.py
55
+
56
+ publish-testpypi:
57
+ name: Publish to TestPyPI
58
+ needs: test
59
+ runs-on: ubuntu-latest
60
+ environment: testpypi
61
+ permissions:
62
+ id-token: write # required for OIDC Trusted Publishing
63
+ steps:
64
+ - name: Download build artifacts
65
+ uses: actions/download-artifact@v4
66
+ with:
67
+ name: dist
68
+ path: dist/
69
+
70
+ - name: Publish to TestPyPI
71
+ uses: pypa/gh-action-pypi-publish@release/v1
72
+ with:
73
+ repository-url: https://test.pypi.org/legacy/
74
+ skip-existing: true
75
+
76
+ publish-pypi:
77
+ name: Publish to PyPI
78
+ needs: publish-testpypi
79
+ runs-on: ubuntu-latest
80
+ environment: pypi
81
+ permissions:
82
+ id-token: write # required for OIDC Trusted Publishing
83
+ steps:
84
+ - name: Download build artifacts
85
+ uses: actions/download-artifact@v4
86
+ with:
87
+ name: dist
88
+ path: dist/
89
+
90
+ - name: Publish to PyPI
91
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.5
2
+ Name: ml-benchmark-toolkit
3
+ Version: 0.1.0
4
+ Summary: Lightweight toolkit to benchmark, compare and visually report on classification models.
5
+ Project-URL: Homepage, https://github.com/yourorg/ml-benchmark-toolkit
6
+ Project-URL: Issues, https://github.com/yourorg/ml-benchmark-toolkit/issues
7
+ Author-email: Your Name <you@example.com>
8
+ License: MIT
9
+ Keywords: benchmarking,classification,machine-learning,reporting,scikit-learn
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: matplotlib>=3.6
22
+ Requires-Dist: numpy>=1.23
23
+ Requires-Dist: pandas>=1.5
24
+ Requires-Dist: scikit-learn>=1.1
25
+ Requires-Dist: seaborn>=0.12
26
+ Requires-Dist: tabulate>=0.9
27
+ Provides-Extra: dev
28
+ Requires-Dist: build>=1.0; extra == 'dev'
29
+ Requires-Dist: pytest>=7.0; extra == 'dev'
30
+ Requires-Dist: twine>=4.0; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # ml-benchmark-toolkit
34
+
35
+ Lightweight, dependency-minimal Python toolkit (`mlbenchmark`) for evaluating,
36
+ comparing, and visually reporting on classification models — single-model or
37
+ side-by-side multi-model benchmarks.
38
+
39
+ ## Features
40
+
41
+ - **Metrics**: accuracy, precision/recall/F1 (macro & weighted), log-loss, ROC-AUC
42
+ (binary and multi-class One-vs-Rest), computed with graceful fallback when
43
+ `y_proba` is unavailable.
44
+ - **Visualizations**: annotated confusion-matrix heatmaps (raw or normalized),
45
+ ROC curves (multi-class OvR with per-class + micro-average AUC), and
46
+ train-vs-validation training curves from a Keras-style `history` dict.
47
+ - **Multi-model comparison**: register any number of models and get a ranked
48
+ `pandas.DataFrame` / Markdown comparison table.
49
+ - **One-line reporting**: `Report.to_html()` compiles all tables and figures
50
+ into a single, standalone, self-contained HTML dashboard (figures are
51
+ base64-embedded, no external assets required).
52
+
53
+ ## Installation
54
+
55
+ ```bash
56
+ pip install ml-benchmark-toolkit
57
+ ```
58
+
59
+ Or, for local development:
60
+
61
+ ```bash
62
+ git clone https://github.com/yourorg/ml-benchmark-toolkit.git
63
+ cd ml-benchmark-toolkit
64
+ python -m venv .venv && source .venv/bin/activate
65
+ pip install -e ".[dev]"
66
+ ```
67
+
68
+ ## Quickstart
69
+
70
+ ```python
71
+ from mlbenchmark import ModelComparator, Report
72
+
73
+ comparator = ModelComparator()
74
+ comparator.add_model("random_forest", y_true, y_pred_rf, y_proba_rf)
75
+ comparator.add_model("logistic_regression", y_true, y_pred_lr, y_proba_lr)
76
+
77
+ # Ranked comparison table
78
+ table = comparator.comparison_table(rank_by="f1_macro")
79
+ print(table)
80
+
81
+ # Standalone HTML dashboard: tables + confusion matrices + ROC curves
82
+ report = Report(comparator, title="My Benchmark")
83
+ report.to_html("report.html")
84
+ report.to_markdown("report.md")
85
+ ```
86
+
87
+ See [`examples/quickstart.py`](examples/quickstart.py) for a full,
88
+ runnable end-to-end example (including training curves).
89
+
90
+ ## API overview
91
+
92
+ | Module | Purpose |
93
+ |---|---|
94
+ | `mlbenchmark.metrics` | `compute_metrics(y_true, y_pred, y_proba=None)` -> dict |
95
+ | `mlbenchmark.plots` | `plot_confusion_matrix`, `plot_roc_curve`, `plot_training_curves` -> `matplotlib.figure.Figure` |
96
+ | `mlbenchmark.comparator` | `ModelComparator` — register models, build ranked comparison tables and per-model figure dicts |
97
+ | `mlbenchmark.report` | `Report` — compile a `ModelComparator` into a standalone HTML/Markdown dashboard |
98
+
99
+ ## Development & Distribution
100
+
101
+ ### 1. Local editable install & test
102
+
103
+ ```bash
104
+ python -m venv .venv
105
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
106
+ pip install -e ".[dev]"
107
+ python examples/quickstart.py
108
+ pytest
109
+ ```
110
+
111
+ ### 2. Build distribution artifacts
112
+
113
+ ```bash
114
+ pip install --upgrade build
115
+ python -m build # produces dist/*.whl and dist/*.tar.gz
116
+ ```
117
+
118
+ ### 3. Publish to TestPyPI, then PyPI
119
+
120
+ ```bash
121
+ pip install --upgrade twine
122
+
123
+ # TestPyPI first (use an API token, not your password)
124
+ twine upload --repository testpypi dist/* \
125
+ -u __token__ -p "$TEST_PYPI_API_TOKEN"
126
+
127
+ # Verify install from TestPyPI
128
+ pip install --index-url https://test.pypi.org/simple/ \
129
+ --extra-index-url https://pypi.org/simple/ ml-benchmark-toolkit
130
+
131
+ # Production PyPI
132
+ twine upload dist/* -u __token__ -p "$PYPI_API_TOKEN"
133
+ ```
134
+
135
+ Store tokens as environment variables or in `~/.pypirc` — never commit them.
136
+
137
+ ### 4. Automated releases via GitHub Actions
138
+
139
+ See [`.github/workflows/publish.yml`](.github/workflows/publish.yml): the
140
+ workflow builds and publishes to PyPI automatically whenever a tag matching
141
+ `v*.*.*` is pushed, using PyPI's [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
142
+ (OIDC — no long-lived API token stored in the repo).
143
+
144
+ ```bash
145
+ git tag v0.1.0
146
+ git push origin v0.1.0
147
+ ```
148
+
149
+ ## License
150
+
151
+ MIT
@@ -0,0 +1,119 @@
1
+ # ml-benchmark-toolkit
2
+
3
+ Lightweight, dependency-minimal Python toolkit (`mlbenchmark`) for evaluating,
4
+ comparing, and visually reporting on classification models — single-model or
5
+ side-by-side multi-model benchmarks.
6
+
7
+ ## Features
8
+
9
+ - **Metrics**: accuracy, precision/recall/F1 (macro & weighted), log-loss, ROC-AUC
10
+ (binary and multi-class One-vs-Rest), computed with graceful fallback when
11
+ `y_proba` is unavailable.
12
+ - **Visualizations**: annotated confusion-matrix heatmaps (raw or normalized),
13
+ ROC curves (multi-class OvR with per-class + micro-average AUC), and
14
+ train-vs-validation training curves from a Keras-style `history` dict.
15
+ - **Multi-model comparison**: register any number of models and get a ranked
16
+ `pandas.DataFrame` / Markdown comparison table.
17
+ - **One-line reporting**: `Report.to_html()` compiles all tables and figures
18
+ into a single, standalone, self-contained HTML dashboard (figures are
19
+ base64-embedded, no external assets required).
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install ml-benchmark-toolkit
25
+ ```
26
+
27
+ Or, for local development:
28
+
29
+ ```bash
30
+ git clone https://github.com/yourorg/ml-benchmark-toolkit.git
31
+ cd ml-benchmark-toolkit
32
+ python -m venv .venv && source .venv/bin/activate
33
+ pip install -e ".[dev]"
34
+ ```
35
+
36
+ ## Quickstart
37
+
38
+ ```python
39
+ from mlbenchmark import ModelComparator, Report
40
+
41
+ comparator = ModelComparator()
42
+ comparator.add_model("random_forest", y_true, y_pred_rf, y_proba_rf)
43
+ comparator.add_model("logistic_regression", y_true, y_pred_lr, y_proba_lr)
44
+
45
+ # Ranked comparison table
46
+ table = comparator.comparison_table(rank_by="f1_macro")
47
+ print(table)
48
+
49
+ # Standalone HTML dashboard: tables + confusion matrices + ROC curves
50
+ report = Report(comparator, title="My Benchmark")
51
+ report.to_html("report.html")
52
+ report.to_markdown("report.md")
53
+ ```
54
+
55
+ See [`examples/quickstart.py`](examples/quickstart.py) for a full,
56
+ runnable end-to-end example (including training curves).
57
+
58
+ ## API overview
59
+
60
+ | Module | Purpose |
61
+ |---|---|
62
+ | `mlbenchmark.metrics` | `compute_metrics(y_true, y_pred, y_proba=None)` -> dict |
63
+ | `mlbenchmark.plots` | `plot_confusion_matrix`, `plot_roc_curve`, `plot_training_curves` -> `matplotlib.figure.Figure` |
64
+ | `mlbenchmark.comparator` | `ModelComparator` — register models, build ranked comparison tables and per-model figure dicts |
65
+ | `mlbenchmark.report` | `Report` — compile a `ModelComparator` into a standalone HTML/Markdown dashboard |
66
+
67
+ ## Development & Distribution
68
+
69
+ ### 1. Local editable install & test
70
+
71
+ ```bash
72
+ python -m venv .venv
73
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
74
+ pip install -e ".[dev]"
75
+ python examples/quickstart.py
76
+ pytest
77
+ ```
78
+
79
+ ### 2. Build distribution artifacts
80
+
81
+ ```bash
82
+ pip install --upgrade build
83
+ python -m build # produces dist/*.whl and dist/*.tar.gz
84
+ ```
85
+
86
+ ### 3. Publish to TestPyPI, then PyPI
87
+
88
+ ```bash
89
+ pip install --upgrade twine
90
+
91
+ # TestPyPI first (use an API token, not your password)
92
+ twine upload --repository testpypi dist/* \
93
+ -u __token__ -p "$TEST_PYPI_API_TOKEN"
94
+
95
+ # Verify install from TestPyPI
96
+ pip install --index-url https://test.pypi.org/simple/ \
97
+ --extra-index-url https://pypi.org/simple/ ml-benchmark-toolkit
98
+
99
+ # Production PyPI
100
+ twine upload dist/* -u __token__ -p "$PYPI_API_TOKEN"
101
+ ```
102
+
103
+ Store tokens as environment variables or in `~/.pypirc` — never commit them.
104
+
105
+ ### 4. Automated releases via GitHub Actions
106
+
107
+ See [`.github/workflows/publish.yml`](.github/workflows/publish.yml): the
108
+ workflow builds and publishes to PyPI automatically whenever a tag matching
109
+ `v*.*.*` is pushed, using PyPI's [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
110
+ (OIDC — no long-lived API token stored in the repo).
111
+
112
+ ```bash
113
+ git tag v0.1.0
114
+ git push origin v0.1.0
115
+ ```
116
+
117
+ ## License
118
+
119
+ MIT
@@ -0,0 +1,71 @@
1
+ """End-to-end quickstart: train two models, benchmark them, export a report.
2
+
3
+ Run with:
4
+ python examples/quickstart.py
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from sklearn.datasets import make_classification
10
+ from sklearn.ensemble import RandomForestClassifier
11
+ from sklearn.linear_model import LogisticRegression
12
+ from sklearn.model_selection import train_test_split
13
+
14
+ from mlbenchmark import ModelComparator, Report
15
+
16
+ # 1. Synthetic multi-class dataset
17
+ X, y = make_classification(
18
+ n_samples=1500,
19
+ n_features=20,
20
+ n_informative=10,
21
+ n_classes=3,
22
+ n_clusters_per_class=1,
23
+ random_state=42,
24
+ )
25
+ X_train, X_test, y_train, y_test = train_test_split(
26
+ X, y, test_size=0.25, random_state=42, stratify=y
27
+ )
28
+
29
+ # 2. Train a couple of candidate models
30
+ rf = RandomForestClassifier(n_estimators=200, random_state=42).fit(X_train, y_train)
31
+ logreg = LogisticRegression(max_iter=1000).fit(X_train, y_train)
32
+
33
+ # A fabricated training history, e.g. for a neural-net-style model, to
34
+ # demonstrate the training-curve plot on one of the models.
35
+ fake_history = {
36
+ "loss": [1.10, 0.85, 0.67, 0.52, 0.41, 0.34, 0.29, 0.26],
37
+ "val_loss": [1.15, 0.95, 0.80, 0.70, 0.66, 0.64, 0.63, 0.64],
38
+ "accuracy": [0.45, 0.58, 0.68, 0.75, 0.80, 0.84, 0.87, 0.89],
39
+ "val_accuracy": [0.42, 0.55, 0.63, 0.68, 0.71, 0.72, 0.73, 0.72],
40
+ }
41
+
42
+ # 3. Register results with the comparator
43
+ comparator = ModelComparator()
44
+ comparator.add_model(
45
+ name="random_forest",
46
+ y_true=y_test,
47
+ y_pred=rf.predict(X_test),
48
+ y_proba=rf.predict_proba(X_test),
49
+ )
50
+ comparator.add_model(
51
+ name="logistic_regression",
52
+ y_true=y_test,
53
+ y_pred=logreg.predict(X_test),
54
+ y_proba=logreg.predict_proba(X_test),
55
+ history=fake_history,
56
+ )
57
+
58
+ # 4. Ranked comparison table (DataFrame + Markdown)
59
+ table = comparator.comparison_table(rank_by="f1_macro")
60
+ print(table)
61
+ print()
62
+ print(comparator.comparison_markdown(rank_by="f1_macro"))
63
+
64
+ # 5. Standalone HTML dashboard with embedded confusion matrices, ROC
65
+ # curves, and training curves
66
+ report = Report(comparator, title="Synthetic Classification Benchmark", rank_by="f1_macro")
67
+ html_path = report.to_html("benchmark_report.html")
68
+ report.to_markdown("benchmark_report.md") # also writes the file to disk
69
+
70
+ print(f"\nHTML report written to: {html_path.resolve()}")
71
+ print("Markdown summary written to: benchmark_report.md")
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ml-benchmark-toolkit"
7
+ version = "0.1.0"
8
+ description = "Lightweight toolkit to benchmark, compare and visually report on classification models."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Your Name", email = "you@example.com" }
14
+ ]
15
+ keywords = ["machine-learning", "benchmarking", "classification", "reporting", "scikit-learn"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Science/Research",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
27
+ ]
28
+
29
+ dependencies = [
30
+ "numpy>=1.23",
31
+ "pandas>=1.5",
32
+ "scikit-learn>=1.1",
33
+ "matplotlib>=3.6",
34
+ "seaborn>=0.12",
35
+ "tabulate>=0.9",
36
+ ]
37
+
38
+ [project.optional-dependencies]
39
+ dev = [
40
+ "pytest>=7.0",
41
+ "build>=1.0",
42
+ "twine>=4.0",
43
+ ]
44
+
45
+ [project.urls]
46
+ Homepage = "https://github.com/yourorg/ml-benchmark-toolkit"
47
+ Issues = "https://github.com/yourorg/ml-benchmark-toolkit/issues"
48
+
49
+ [tool.hatch.build.targets.wheel]
50
+ packages = ["src/mlbenchmark"]
51
+
52
+ [tool.hatch.version]
53
+ path = "src/mlbenchmark/__init__.py"
54
+ pattern = "__version__ = ['\"](?P<version>[^'\"]+)['\"]"
@@ -0,0 +1,30 @@
1
+ """ml-benchmark-toolkit: lightweight evaluation and reporting for classifiers.
2
+
3
+ Public API
4
+ ----------
5
+ - compute_metrics: single-model metric computation
6
+ - plot_confusion_matrix, plot_roc_curve, plot_training_curves: figure builders
7
+ - ModelComparator: register multiple models and compare them side-by-side
8
+ - Report: compile a ModelComparator into a standalone HTML/Markdown dashboard
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ from .comparator import ModelComparator, ModelResult
16
+ from .metrics import compute_metrics, is_binary
17
+ from .plots import plot_confusion_matrix, plot_roc_curve, plot_training_curves
18
+ from .report import Report
19
+
20
+ __all__ = [
21
+ "__version__",
22
+ "compute_metrics",
23
+ "is_binary",
24
+ "plot_confusion_matrix",
25
+ "plot_roc_curve",
26
+ "plot_training_curves",
27
+ "ModelComparator",
28
+ "ModelResult",
29
+ "Report",
30
+ ]
@@ -0,0 +1,160 @@
1
+ """Multi-model registration and side-by-side comparison utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional, Sequence
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from matplotlib.figure import Figure
11
+
12
+ from .metrics import compute_metrics
13
+ from .plots import plot_confusion_matrix, plot_roc_curve, plot_training_curves
14
+
15
+ ArrayLike = Sequence[Any]
16
+
17
+
18
+ @dataclass
19
+ class ModelResult:
20
+ """Container for a single model's evaluation inputs and computed metrics."""
21
+
22
+ name: str
23
+ y_true: np.ndarray
24
+ y_pred: np.ndarray
25
+ y_proba: Optional[np.ndarray] = None
26
+ history: Optional[Dict[str, List[float]]] = None
27
+ metrics: Dict[str, float] = field(default_factory=dict)
28
+
29
+ def __post_init__(self) -> None:
30
+ self.y_true = np.asarray(self.y_true)
31
+ self.y_pred = np.asarray(self.y_pred)
32
+ if self.y_proba is not None:
33
+ self.y_proba = np.asarray(self.y_proba)
34
+ self.metrics = compute_metrics(self.y_true, self.y_pred, self.y_proba)
35
+
36
+
37
+ class ModelComparator:
38
+ """Registers evaluation results for multiple models and compares them.
39
+
40
+ Examples
41
+ --------
42
+ >>> comparator = ModelComparator()
43
+ >>> comparator.add_model("random_forest", y_true, y_pred, y_proba)
44
+ >>> comparator.add_model("xgboost", y_true, y_pred2, y_proba2)
45
+ >>> table = comparator.comparison_table(rank_by="f1_macro")
46
+ """
47
+
48
+ def __init__(self) -> None:
49
+ self._results: Dict[str, ModelResult] = {}
50
+
51
+ def add_model(
52
+ self,
53
+ name: str,
54
+ y_true: ArrayLike,
55
+ y_pred: ArrayLike,
56
+ y_proba: Optional[ArrayLike] = None,
57
+ history: Optional[Dict[str, List[float]]] = None,
58
+ ) -> "ModelComparator":
59
+ """Register a model's predictions for comparison. Returns self for chaining."""
60
+ if name in self._results:
61
+ raise ValueError(f"A model named '{name}' has already been added.")
62
+ self._results[name] = ModelResult(
63
+ name=name, y_true=y_true, y_pred=y_pred, y_proba=y_proba, history=history
64
+ )
65
+ return self
66
+
67
+ @property
68
+ def model_names(self) -> List[str]:
69
+ return list(self._results.keys())
70
+
71
+ def get_result(self, name: str) -> ModelResult:
72
+ if name not in self._results:
73
+ raise KeyError(f"No model named '{name}' has been registered.")
74
+ return self._results[name]
75
+
76
+ def comparison_table(
77
+ self,
78
+ rank_by: str = "f1_macro",
79
+ ascending: bool = False,
80
+ metrics: Optional[Sequence[str]] = None,
81
+ ) -> pd.DataFrame:
82
+ """Build a ranked Pandas DataFrame comparing all registered models.
83
+
84
+ Parameters
85
+ ----------
86
+ rank_by : str, default "f1_macro"
87
+ Metric column name to sort the table by.
88
+ ascending : bool, default False
89
+ Sort order; metrics like log_loss are usually best ascending.
90
+ metrics : sequence of str, optional
91
+ Subset of metric columns to include. Defaults to all computed
92
+ metrics.
93
+ """
94
+ if not self._results:
95
+ raise ValueError("No models have been added yet. Call add_model() first.")
96
+
97
+ rows = []
98
+ for name, result in self._results.items():
99
+ row = {"model": name}
100
+ row.update(result.metrics)
101
+ rows.append(row)
102
+
103
+ df = pd.DataFrame(rows).set_index("model")
104
+
105
+ if metrics is not None:
106
+ missing = set(metrics) - set(df.columns)
107
+ if missing:
108
+ raise ValueError(f"Unknown metric(s) requested: {sorted(missing)}")
109
+ df = df[list(metrics)]
110
+
111
+ if rank_by not in df.columns:
112
+ raise ValueError(
113
+ f"rank_by='{rank_by}' is not a computed metric. "
114
+ f"Available metrics: {list(df.columns)}"
115
+ )
116
+
117
+ df = df.sort_values(by=rank_by, ascending=ascending)
118
+ df.insert(0, "rank", range(1, len(df) + 1))
119
+ return df
120
+
121
+ def comparison_markdown(
122
+ self, rank_by: str = "f1_macro", ascending: bool = False, decimals: int = 4
123
+ ) -> str:
124
+ """Return the comparison table formatted as a Markdown string."""
125
+ df = self.comparison_table(rank_by=rank_by, ascending=ascending)
126
+ return df.round(decimals).to_markdown()
127
+
128
+ def confusion_matrices(self, normalize: bool = False) -> Dict[str, Figure]:
129
+ """Return a dict of model name -> confusion-matrix Figure."""
130
+ return {
131
+ name: plot_confusion_matrix(
132
+ result.y_true,
133
+ result.y_pred,
134
+ normalize=normalize,
135
+ title=f"Confusion Matrix — {name}",
136
+ )
137
+ for name, result in self._results.items()
138
+ }
139
+
140
+ def roc_curves(self) -> Dict[str, Figure]:
141
+ """Return a dict of model name -> ROC-curve Figure (skips models without y_proba)."""
142
+ figures = {}
143
+ for name, result in self._results.items():
144
+ if result.y_proba is None:
145
+ continue
146
+ figures[name] = plot_roc_curve(
147
+ result.y_true, result.y_proba, title=f"ROC Curve — {name}"
148
+ )
149
+ return figures
150
+
151
+ def training_curves(self) -> Dict[str, Figure]:
152
+ """Return a dict of model name -> training-curve Figure (skips models without history)."""
153
+ figures = {}
154
+ for name, result in self._results.items():
155
+ if result.history is None:
156
+ continue
157
+ figures[name] = plot_training_curves(
158
+ result.history, title=f"Training History — {name}"
159
+ )
160
+ return figures
@@ -0,0 +1,161 @@
1
+ """Classification metric computation utilities.
2
+
3
+ This module provides a single entry point, :func:`compute_metrics`, that
4
+ takes true labels, predicted labels and (optionally) predicted
5
+ probabilities and returns a flat dictionary of standard classification
6
+ metrics. It defensively handles binary vs. multi-class problems and
7
+ missing probability estimates.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Dict, Optional, Sequence
13
+
14
+ import numpy as np
15
+ from sklearn.metrics import (
16
+ accuracy_score,
17
+ f1_score,
18
+ log_loss,
19
+ precision_score,
20
+ recall_score,
21
+ roc_auc_score,
22
+ )
23
+ from sklearn.preprocessing import label_binarize
24
+
25
+
26
+ ArrayLike = Sequence[Any]
27
+
28
+
29
+ def _to_numpy(arr: Optional[ArrayLike]) -> Optional[np.ndarray]:
30
+ """Convert list-like/pandas input to a numpy array, passing through None."""
31
+ if arr is None:
32
+ return None
33
+ return np.asarray(arr)
34
+
35
+
36
+ def is_binary(y_true: ArrayLike) -> bool:
37
+ """Return True if `y_true` contains exactly two distinct classes."""
38
+ return len(np.unique(_to_numpy(y_true))) <= 2
39
+
40
+
41
+ def compute_metrics(
42
+ y_true: ArrayLike,
43
+ y_pred: ArrayLike,
44
+ y_proba: Optional[ArrayLike] = None,
45
+ *,
46
+ average: str = "macro",
47
+ labels: Optional[Sequence[Any]] = None,
48
+ ) -> Dict[str, float]:
49
+ """Compute a standard suite of classification metrics.
50
+
51
+ Parameters
52
+ ----------
53
+ y_true : array-like
54
+ Ground-truth class labels.
55
+ y_pred : array-like
56
+ Predicted class labels.
57
+ y_proba : array-like, optional
58
+ Predicted probabilities. For binary problems this may be a 1D
59
+ array of positive-class probabilities or a 2D array of shape
60
+ (n_samples, 2). For multi-class problems it must be a 2D array
61
+ of shape (n_samples, n_classes). If omitted, log-loss and
62
+ ROC-AUC are reported as NaN.
63
+ average : str, default "macro"
64
+ Averaging strategy for the secondary precision/recall/F1 metric
65
+ (in addition to the always-computed macro and weighted variants).
66
+ labels : sequence, optional
67
+ Explicit ordering of class labels. Inferred from `y_true`/`y_pred`
68
+ when not provided.
69
+
70
+ Returns
71
+ -------
72
+ dict
73
+ Dictionary of metric name -> float value. Metrics that cannot be
74
+ computed (e.g. ROC-AUC without probabilities) are set to NaN
75
+ rather than raising, so downstream reporting degrades gracefully.
76
+ """
77
+ y_true_arr = _to_numpy(y_true)
78
+ y_pred_arr = _to_numpy(y_pred)
79
+ y_proba_arr = _to_numpy(y_proba)
80
+
81
+ if y_true_arr is None or y_pred_arr is None:
82
+ raise ValueError("y_true and y_pred are required and cannot be None.")
83
+ if len(y_true_arr) != len(y_pred_arr):
84
+ raise ValueError(
85
+ f"y_true (n={len(y_true_arr)}) and y_pred (n={len(y_pred_arr)}) "
86
+ "must have the same length."
87
+ )
88
+
89
+ resolved_labels = list(labels) if labels is not None else sorted(
90
+ set(np.unique(y_true_arr).tolist()) | set(np.unique(y_pred_arr).tolist())
91
+ )
92
+ binary = len(resolved_labels) <= 2
93
+
94
+ metrics: Dict[str, float] = {
95
+ "accuracy": float(accuracy_score(y_true_arr, y_pred_arr)),
96
+ "precision_macro": float(
97
+ precision_score(y_true_arr, y_pred_arr, average="macro", zero_division=0)
98
+ ),
99
+ "precision_weighted": float(
100
+ precision_score(y_true_arr, y_pred_arr, average="weighted", zero_division=0)
101
+ ),
102
+ "recall_macro": float(
103
+ recall_score(y_true_arr, y_pred_arr, average="macro", zero_division=0)
104
+ ),
105
+ "recall_weighted": float(
106
+ recall_score(y_true_arr, y_pred_arr, average="weighted", zero_division=0)
107
+ ),
108
+ "f1_macro": float(
109
+ f1_score(y_true_arr, y_pred_arr, average="macro", zero_division=0)
110
+ ),
111
+ "f1_weighted": float(
112
+ f1_score(y_true_arr, y_pred_arr, average="weighted", zero_division=0)
113
+ ),
114
+ }
115
+
116
+ if average not in ("macro", "weighted"):
117
+ metrics[f"precision_{average}"] = float(
118
+ precision_score(y_true_arr, y_pred_arr, average=average, zero_division=0)
119
+ )
120
+ metrics[f"recall_{average}"] = float(
121
+ recall_score(y_true_arr, y_pred_arr, average=average, zero_division=0)
122
+ )
123
+ metrics[f"f1_{average}"] = float(
124
+ f1_score(y_true_arr, y_pred_arr, average=average, zero_division=0)
125
+ )
126
+
127
+ metrics["log_loss"] = np.nan
128
+ metrics["roc_auc"] = np.nan
129
+
130
+ if y_proba_arr is not None:
131
+ try:
132
+ proba_for_logloss = y_proba_arr
133
+ if binary and proba_for_logloss.ndim == 1:
134
+ proba_for_logloss = np.column_stack(
135
+ [1 - proba_for_logloss, proba_for_logloss]
136
+ )
137
+ metrics["log_loss"] = float(
138
+ log_loss(y_true_arr, proba_for_logloss, labels=resolved_labels)
139
+ )
140
+ except (ValueError, IndexError):
141
+ metrics["log_loss"] = np.nan
142
+
143
+ try:
144
+ if binary:
145
+ pos_proba = (
146
+ y_proba_arr
147
+ if y_proba_arr.ndim == 1
148
+ else y_proba_arr[:, 1]
149
+ )
150
+ metrics["roc_auc"] = float(roc_auc_score(y_true_arr, pos_proba))
151
+ else:
152
+ y_bin = label_binarize(y_true_arr, classes=resolved_labels)
153
+ metrics["roc_auc"] = float(
154
+ roc_auc_score(
155
+ y_bin, y_proba_arr, average="macro", multi_class="ovr"
156
+ )
157
+ )
158
+ except (ValueError, IndexError):
159
+ metrics["roc_auc"] = np.nan
160
+
161
+ return metrics
@@ -0,0 +1,206 @@
1
+ """Visualization utilities: confusion matrices, ROC curves, training curves.
2
+
3
+ All plotting functions return a `matplotlib.figure.Figure` instance rather
4
+ than calling `plt.show()`, so callers (including the reporting module) can
5
+ embed, save, or further customize the figures.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Dict, List, Optional, Sequence
11
+
12
+ import matplotlib
13
+ import numpy as np
14
+
15
+ matplotlib.use("Agg") # headless-safe backend for servers/CI
16
+ import matplotlib.pyplot as plt # noqa: E402
17
+ import seaborn as sns # noqa: E402
18
+ from matplotlib.figure import Figure # noqa: E402
19
+ from sklearn.metrics import auc, confusion_matrix, roc_curve # noqa: E402
20
+ from sklearn.preprocessing import label_binarize # noqa: E402
21
+
22
+ ArrayLike = Sequence[Any]
23
+
24
+ sns.set_theme(style="whitegrid")
25
+
26
+
27
+ def plot_confusion_matrix(
28
+ y_true: ArrayLike,
29
+ y_pred: ArrayLike,
30
+ *,
31
+ labels: Optional[Sequence[Any]] = None,
32
+ normalize: bool = False,
33
+ title: str = "Confusion Matrix",
34
+ cmap: str = "Blues",
35
+ figsize: tuple = (6, 5),
36
+ ) -> Figure:
37
+ """Plot an annotated confusion-matrix heatmap.
38
+
39
+ Parameters
40
+ ----------
41
+ normalize : bool, default False
42
+ If True, rows are normalized to show proportions instead of raw
43
+ counts.
44
+ """
45
+ y_true_arr = np.asarray(y_true)
46
+ y_pred_arr = np.asarray(y_pred)
47
+ resolved_labels = (
48
+ list(labels)
49
+ if labels is not None
50
+ else sorted(set(np.unique(y_true_arr).tolist()) | set(np.unique(y_pred_arr).tolist()))
51
+ )
52
+
53
+ cm = confusion_matrix(y_true_arr, y_pred_arr, labels=resolved_labels)
54
+ fmt = "d"
55
+ display_cm = cm
56
+ if normalize:
57
+ with np.errstate(all="ignore"):
58
+ row_sums = cm.sum(axis=1, keepdims=True)
59
+ display_cm = np.divide(
60
+ cm, row_sums, out=np.zeros_like(cm, dtype=float), where=row_sums != 0
61
+ )
62
+ fmt = ".2f"
63
+
64
+ fig, ax = plt.subplots(figsize=figsize)
65
+ sns.heatmap(
66
+ display_cm,
67
+ annot=True,
68
+ fmt=fmt,
69
+ cmap=cmap,
70
+ xticklabels=resolved_labels,
71
+ yticklabels=resolved_labels,
72
+ cbar=True,
73
+ square=True,
74
+ ax=ax,
75
+ )
76
+ ax.set_xlabel("Predicted label")
77
+ ax.set_ylabel("True label")
78
+ ax.set_title(title)
79
+ fig.tight_layout()
80
+ return fig
81
+
82
+
83
+ def plot_roc_curve(
84
+ y_true: ArrayLike,
85
+ y_proba: ArrayLike,
86
+ *,
87
+ labels: Optional[Sequence[Any]] = None,
88
+ title: str = "ROC Curve",
89
+ figsize: tuple = (6, 5),
90
+ ) -> Figure:
91
+ """Plot ROC curve(s) with AUC scores.
92
+
93
+ For binary targets a single curve is drawn. For multi-class targets,
94
+ a One-vs-Rest curve (with individual AUC) is drawn per class, plus a
95
+ micro-average curve.
96
+ """
97
+ y_true_arr = np.asarray(y_true)
98
+ y_proba_arr = np.asarray(y_proba)
99
+ resolved_labels = (
100
+ list(labels) if labels is not None else sorted(np.unique(y_true_arr).tolist())
101
+ )
102
+
103
+ fig, ax = plt.subplots(figsize=figsize)
104
+
105
+ if len(resolved_labels) <= 2:
106
+ pos_proba = y_proba_arr if y_proba_arr.ndim == 1 else y_proba_arr[:, 1]
107
+ fpr, tpr, _ = roc_curve(y_true_arr, pos_proba, pos_label=resolved_labels[-1])
108
+ roc_auc = auc(fpr, tpr)
109
+ ax.plot(fpr, tpr, lw=2, label=f"ROC (AUC = {roc_auc:.3f})")
110
+ else:
111
+ y_bin = label_binarize(y_true_arr, classes=resolved_labels)
112
+ for i, class_label in enumerate(resolved_labels):
113
+ fpr, tpr, _ = roc_curve(y_bin[:, i], y_proba_arr[:, i])
114
+ roc_auc = auc(fpr, tpr)
115
+ ax.plot(fpr, tpr, lw=1.5, label=f"Class {class_label} (AUC = {roc_auc:.3f})")
116
+
117
+ fpr_micro, tpr_micro, _ = roc_curve(y_bin.ravel(), y_proba_arr.ravel())
118
+ auc_micro = auc(fpr_micro, tpr_micro)
119
+ ax.plot(
120
+ fpr_micro,
121
+ tpr_micro,
122
+ lw=2.5,
123
+ linestyle="--",
124
+ color="black",
125
+ label=f"Micro-average (AUC = {auc_micro:.3f})",
126
+ )
127
+
128
+ ax.plot([0, 1], [0, 1], linestyle=":", color="gray", lw=1)
129
+ ax.set_xlim([0.0, 1.0])
130
+ ax.set_ylim([0.0, 1.05])
131
+ ax.set_xlabel("False Positive Rate")
132
+ ax.set_ylabel("True Positive Rate")
133
+ ax.set_title(title)
134
+ ax.legend(loc="lower right", fontsize="small")
135
+ fig.tight_layout()
136
+ return fig
137
+
138
+
139
+ def plot_training_curves(
140
+ history: Dict[str, List[float]],
141
+ *,
142
+ title: str = "Training History",
143
+ figsize: tuple = (10, 4),
144
+ ) -> Figure:
145
+ """Plot train vs. validation loss/accuracy curves from a history dict.
146
+
147
+ Parameters
148
+ ----------
149
+ history : dict
150
+ Dictionary with any of the keys: "loss", "val_loss", "accuracy",
151
+ "val_accuracy" (Keras-style naming is also accepted, e.g. "acc",
152
+ "val_acc"). Each value is a sequence of per-epoch metric values.
153
+ """
154
+ normalized = dict(history)
155
+ if "acc" in normalized and "accuracy" not in normalized:
156
+ normalized["accuracy"] = normalized.pop("acc")
157
+ if "val_acc" in normalized and "val_accuracy" not in normalized:
158
+ normalized["val_accuracy"] = normalized.pop("val_acc")
159
+
160
+ has_loss = "loss" in normalized or "val_loss" in normalized
161
+ has_acc = "accuracy" in normalized or "val_accuracy" in normalized
162
+ n_panels = max(sum([has_loss, has_acc]), 1)
163
+
164
+ fig, axes = plt.subplots(1, n_panels, figsize=figsize)
165
+ if n_panels == 1:
166
+ axes = [axes]
167
+
168
+ panel_idx = 0
169
+ if has_loss:
170
+ ax = axes[panel_idx]
171
+ if "loss" in normalized:
172
+ epochs = range(1, len(normalized["loss"]) + 1)
173
+ ax.plot(epochs, normalized["loss"], label="Train Loss", marker="o", markersize=3)
174
+ if "val_loss" in normalized:
175
+ epochs = range(1, len(normalized["val_loss"]) + 1)
176
+ ax.plot(epochs, normalized["val_loss"], label="Val Loss", marker="o", markersize=3)
177
+ ax.set_xlabel("Epoch")
178
+ ax.set_ylabel("Loss")
179
+ ax.set_title("Loss")
180
+ ax.legend(fontsize="small")
181
+ panel_idx += 1
182
+
183
+ if has_acc:
184
+ ax = axes[panel_idx]
185
+ if "accuracy" in normalized:
186
+ epochs = range(1, len(normalized["accuracy"]) + 1)
187
+ ax.plot(
188
+ epochs, normalized["accuracy"], label="Train Accuracy", marker="o", markersize=3
189
+ )
190
+ if "val_accuracy" in normalized:
191
+ epochs = range(1, len(normalized["val_accuracy"]) + 1)
192
+ ax.plot(
193
+ epochs,
194
+ normalized["val_accuracy"],
195
+ label="Val Accuracy",
196
+ marker="o",
197
+ markersize=3,
198
+ )
199
+ ax.set_xlabel("Epoch")
200
+ ax.set_ylabel("Accuracy")
201
+ ax.set_title("Accuracy")
202
+ ax.legend(fontsize="small")
203
+
204
+ fig.suptitle(title)
205
+ fig.tight_layout()
206
+ return fig
@@ -0,0 +1,183 @@
1
+ """Standalone HTML/Markdown report generation for model comparisons."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Dict, Optional
10
+
11
+ from matplotlib.figure import Figure
12
+
13
+ from .comparator import ModelComparator
14
+
15
+
16
+ def _figure_to_base64(fig: Figure) -> str:
17
+ """Encode a matplotlib Figure as a base64 PNG data URI for HTML embedding."""
18
+ buffer = io.BytesIO()
19
+ fig.savefig(buffer, format="png", dpi=130, bbox_inches="tight")
20
+ buffer.seek(0)
21
+ encoded = base64.b64encode(buffer.read()).decode("utf-8")
22
+ buffer.close()
23
+ return f"data:image/png;base64,{encoded}"
24
+
25
+
26
+ _HTML_TEMPLATE = """<!DOCTYPE html>
27
+ <html lang="en">
28
+ <head>
29
+ <meta charset="UTF-8">
30
+ <title>{title}</title>
31
+ <style>
32
+ body {{
33
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
34
+ margin: 0; padding: 2rem; background: #f6f7f9; color: #1a1a1a;
35
+ }}
36
+ .container {{ max-width: 1100px; margin: 0 auto; }}
37
+ h1 {{ font-size: 1.8rem; margin-bottom: 0.2rem; }}
38
+ .subtitle {{ color: #666; margin-bottom: 2rem; font-size: 0.9rem; }}
39
+ h2 {{ border-bottom: 2px solid #e0e0e0; padding-bottom: 0.4rem; margin-top: 2.5rem; }}
40
+ table {{ border-collapse: collapse; width: 100%; background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
41
+ th, td {{ border: 1px solid #e0e0e0; padding: 8px 12px; text-align: right; font-size: 0.9rem; }}
42
+ th {{ background: #2b3a55; color: white; text-align: center; }}
43
+ td:first-child, th:first-child {{ text-align: left; }}
44
+ tr:nth-child(even) {{ background: #fafbfc; }}
45
+ tr:first-child td {{ font-weight: 600; background: #eef4ff; }}
46
+ .fig-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 1.5rem; margin-top: 1rem; }}
47
+ .fig-card {{ background: white; border-radius: 8px; padding: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }}
48
+ .fig-card img {{ width: 100%; height: auto; border-radius: 4px; }}
49
+ .fig-card h3 {{ margin: 0 0 0.5rem 0; font-size: 1rem; color: #2b3a55; }}
50
+ footer {{ margin-top: 3rem; color: #999; font-size: 0.8rem; text-align: center; }}
51
+ </style>
52
+ </head>
53
+ <body>
54
+ <div class="container">
55
+ <h1>{title}</h1>
56
+ <p class="subtitle">Generated {timestamp} &middot; ml-benchmark-toolkit</p>
57
+
58
+ <h2>Model Comparison</h2>
59
+ {comparison_table}
60
+
61
+ <h2>Confusion Matrices</h2>
62
+ <div class="fig-grid">{confusion_figs}</div>
63
+
64
+ {roc_section}
65
+
66
+ {training_section}
67
+
68
+ <footer>Report generated with mlbenchmark.Report</footer>
69
+ </div>
70
+ </body>
71
+ </html>
72
+ """
73
+
74
+
75
+ class Report:
76
+ """Compiles a :class:`ModelComparator`'s tables and figures into a
77
+ standalone HTML or Markdown summary dashboard.
78
+
79
+ Examples
80
+ --------
81
+ >>> report = Report(comparator, title="Model Benchmark")
82
+ >>> report.to_html("report.html")
83
+ >>> report.to_markdown("report.md")
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ comparator: ModelComparator,
89
+ title: str = "Model Benchmark Report",
90
+ rank_by: str = "f1_macro",
91
+ normalize_confusion: bool = False,
92
+ ) -> None:
93
+ self.comparator = comparator
94
+ self.title = title
95
+ self.rank_by = rank_by
96
+ self.normalize_confusion = normalize_confusion
97
+
98
+ def _confusion_figs(self) -> Dict[str, Figure]:
99
+ return self.comparator.confusion_matrices(normalize=self.normalize_confusion)
100
+
101
+ def to_html(self, path: str) -> Path:
102
+ """Render the full dashboard (tables + embedded figures) to a standalone HTML file."""
103
+ table_df = self.comparator.comparison_table(rank_by=self.rank_by).round(4)
104
+ table_html = table_df.to_html(classes="comparison-table", border=0)
105
+
106
+ confusion_figs = self._confusion_figs()
107
+ confusion_html = "".join(
108
+ f'<div class="fig-card"><h3>{name}</h3>'
109
+ f'<img src="{_figure_to_base64(fig)}" alt="Confusion matrix for {name}"></div>'
110
+ for name, fig in confusion_figs.items()
111
+ )
112
+
113
+ roc_figs = self.comparator.roc_curves()
114
+ roc_section = ""
115
+ if roc_figs:
116
+ roc_html = "".join(
117
+ f'<div class="fig-card"><h3>{name}</h3>'
118
+ f'<img src="{_figure_to_base64(fig)}" alt="ROC curve for {name}"></div>'
119
+ for name, fig in roc_figs.items()
120
+ )
121
+ roc_section = f'<h2>ROC Curves</h2><div class="fig-grid">{roc_html}</div>'
122
+
123
+ training_figs = self.comparator.training_curves()
124
+ training_section = ""
125
+ if training_figs:
126
+ training_html = "".join(
127
+ f'<div class="fig-card"><h3>{name}</h3>'
128
+ f'<img src="{_figure_to_base64(fig)}" alt="Training curves for {name}"></div>'
129
+ for name, fig in training_figs.items()
130
+ )
131
+ training_section = (
132
+ f'<h2>Training Curves</h2><div class="fig-grid">{training_html}</div>'
133
+ )
134
+
135
+ html = _HTML_TEMPLATE.format(
136
+ title=self.title,
137
+ timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
138
+ comparison_table=table_html,
139
+ confusion_figs=confusion_html,
140
+ roc_section=roc_section,
141
+ training_section=training_section,
142
+ )
143
+
144
+ out_path = Path(path)
145
+ out_path.parent.mkdir(parents=True, exist_ok=True)
146
+ out_path.write_text(html, encoding="utf-8")
147
+ return out_path
148
+
149
+ def to_markdown(self, path: Optional[str] = None) -> str:
150
+ """Render the comparison table (and notes on available figures) as Markdown.
151
+
152
+ Parameters
153
+ ----------
154
+ path : str, optional
155
+ If provided, the Markdown is also written to this file path.
156
+ """
157
+ table_df = self.comparator.comparison_table(rank_by=self.rank_by).round(4)
158
+ lines = [
159
+ f"# {self.title}",
160
+ "",
161
+ f"_Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}_",
162
+ "",
163
+ "## Model Comparison",
164
+ "",
165
+ table_df.to_markdown(),
166
+ "",
167
+ "## Figures",
168
+ "",
169
+ (
170
+ "Confusion matrices, ROC curves, and training curves are available "
171
+ "via `Report.to_html()` or by calling the plotting functions in "
172
+ "`mlbenchmark.plots` / `ModelComparator` directly (Markdown does not "
173
+ "support embedded raster images inline in this export)."
174
+ ),
175
+ ]
176
+ markdown = "\n".join(lines)
177
+
178
+ if path is not None:
179
+ out_path = Path(path)
180
+ out_path.parent.mkdir(parents=True, exist_ok=True)
181
+ out_path.write_text(markdown, encoding="utf-8")
182
+
183
+ return markdown