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.
- ml_benchmark_toolkit-0.1.0/.github/workflows/publish.yml +91 -0
- ml_benchmark_toolkit-0.1.0/PKG-INFO +151 -0
- ml_benchmark_toolkit-0.1.0/README.md +119 -0
- ml_benchmark_toolkit-0.1.0/examples/quickstart.py +71 -0
- ml_benchmark_toolkit-0.1.0/pyproject.toml +54 -0
- ml_benchmark_toolkit-0.1.0/src/mlbenchmark/__init__.py +30 -0
- ml_benchmark_toolkit-0.1.0/src/mlbenchmark/comparator.py +160 -0
- ml_benchmark_toolkit-0.1.0/src/mlbenchmark/metrics.py +161 -0
- ml_benchmark_toolkit-0.1.0/src/mlbenchmark/plots.py +206 -0
- ml_benchmark_toolkit-0.1.0/src/mlbenchmark/report.py +183 -0
|
@@ -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} · 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
|