evalsuite-python 0.3.0__tar.gz → 0.3.1__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.
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/CHANGELOG.md +29 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/PKG-INFO +25 -21
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/README.md +23 -19
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/pyproject.toml +1 -1
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/benchmarks.py +95 -4
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/validation.py +21 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/regression/metrics.py +9 -1
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/_resolve.py +6 -1
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/version.py +1 -1
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/vision/segmentation.py +9 -1
- evalsuite_python-0.3.1/tests/integration/test_qa.py +311 -0
- evalsuite_python-0.3.1/tests/integration/test_readme_examples.py +122 -0
- evalsuite_python-0.3.1/tests/integration/test_repo_hygiene.py +97 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/output/test_benchmarks.py +22 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/vision/test_edges.py +10 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/.gitignore +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/CONTRIBUTING.md +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/LICENSE +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/__main__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/api.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/calibration.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/classification/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/classification/_common.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/classification/metrics.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/cli/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/cli/main.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/clinical/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/clinical/metrics.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/clinical/report.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/context.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/exceptions.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/export.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/registry.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/result.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/types.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/plot.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/py.typed +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/regression/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/reporting.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/compare.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/effect.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/hypothesis.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/intervals.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/paired.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/results.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/vision/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/vision/detection.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/classification/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/classification/test_against_sklearn.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/clinical/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/clinical/test_outputs.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/clinical/test_v020.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/conftest.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/integration/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/output/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/output/test_cli.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/output/test_plot.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/output/test_reporting.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/regression/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/regression/test_against_sklearn.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/stats/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/stats/test_branches.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/stats/test_compare.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/stats/test_reference.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/unit/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/unit/test_core.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/unit/test_edges.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/vision/__init__.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/vision/test_detection.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/vision/test_outputs.py +0 -0
- {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/vision/test_segmentation.py +0 -0
|
@@ -6,6 +6,35 @@ All notable changes to this project are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.1] - 2026-10-09
|
|
10
|
+
|
|
11
|
+
Quality-assurance release: release pipeline, packaging checks, edge-case fixes and an overall benchmark.
|
|
12
|
+
No API was removed or renamed; code written for 0.3.0 runs unchanged.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Labels that mix numbers and strings (`[0, "a"]`, or string `y_true` with integer `y_pred`) now raise
|
|
16
|
+
`InputValidationError` instead of being silently compared as text.
|
|
17
|
+
- `rmse` no longer overflows to `inf` when errors exceed ~1e154; the value is computed with rescaling.
|
|
18
|
+
- `bootstrap_ci`, `paired_bootstrap_test` and `compare` raise `StatisticalTestError` for fewer than two
|
|
19
|
+
observations instead of returning a zero-width interval.
|
|
20
|
+
- A 2-D mask given as a nested Python list (`[[1, 1], [0, 0]]`) is read as one image, as a NumPy array is.
|
|
21
|
+
- Release workflow: the GitHub release step no longer fails when the release already exists (for example
|
|
22
|
+
when the tag was created from the GitHub UI); it attaches the files instead.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- Benchmarks: an overall summary first (per suite and in total: cases, agreement with the reference,
|
|
26
|
+
how many are faster, geometric-mean and range of speed-ups) and rows in alphabetical order;
|
|
27
|
+
`BenchmarkResult.overall()`, `overall_markdown()` and `sorted_rows()`; `overall` in the JSON export.
|
|
28
|
+
- Release workflow: refuses a tag that does not match the package version or a version already on PyPI,
|
|
29
|
+
checks the built files, uses `--verify-tag`, and can be run by hand to attach a version's PyPI files
|
|
30
|
+
(hash-verified) to its GitHub release.
|
|
31
|
+
- CI: wheel and sdist installed in clean environments with `pip check` and a smoke test that must give the
|
|
32
|
+
same results for both, a distribution check (metadata, forbidden files, credentials), a minimum-dependency
|
|
33
|
+
job (NumPy 1.22, SciPy 1.8, pandas 1.4), README examples run as tests, and repository hygiene tests
|
|
34
|
+
(no credentials, no unsafe calls, least-privilege workflows, single version source).
|
|
35
|
+
- QA tests against scikit-learn and statsmodels for the edge cases in the release checklist.
|
|
36
|
+
- `SECURITY.md` with the reporting process.
|
|
37
|
+
|
|
9
38
|
## [0.3.0] - 2026-10-09
|
|
10
39
|
|
|
11
40
|
Computer vision (the v0.3.0 roadmap), with comparison, plots, reporting and benchmarks extended to it.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: evalsuite-python
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A unified Python framework for machine-learning, clinical, statistical, segmentation, object-detection, uncertainty, and model evaluation.
|
|
5
5
|
Project-URL: Homepage, https://evalsuite-nine.vercel.app
|
|
6
6
|
Project-URL: Documentation, https://evalsuite-nine.vercel.app/docs
|
|
@@ -49,7 +49,7 @@ Requires-Dist: pycocotools>=2.0.7; extra == 'dev'
|
|
|
49
49
|
Requires-Dist: pytest-cov>=4; extra == 'dev'
|
|
50
50
|
Requires-Dist: pytest>=7; extra == 'dev'
|
|
51
51
|
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
52
|
-
Requires-Dist: scikit-learn>=1.
|
|
52
|
+
Requires-Dist: scikit-learn>=1.4; extra == 'dev'
|
|
53
53
|
Requires-Dist: statsmodels>=0.13; extra == 'dev'
|
|
54
54
|
Requires-Dist: twine; extra == 'dev'
|
|
55
55
|
Provides-Extra: plot
|
|
@@ -70,7 +70,7 @@ Description-Content-Type: text/markdown
|
|
|
70
70
|
EvalSuite brings classification, regression, clinical, statistical, segmentation and object-detection
|
|
71
71
|
evaluation into one consistent, validated, documented framework.
|
|
72
72
|
|
|
73
|
-
> **Status: stable (0.3.
|
|
73
|
+
> **Status: stable (0.3.1).** Every item on the 0.1.0, 0.2.0 and 0.3.0 roadmaps is implemented and verified.
|
|
74
74
|
|
|
75
75
|
## Installation
|
|
76
76
|
|
|
@@ -283,24 +283,28 @@ Input files can be CSV, TSV, Parquet or JSON. Output format follows `--format` o
|
|
|
283
283
|
## Performance
|
|
284
284
|
|
|
285
285
|
Benchmarked against reference implementations on the same data (fastest of 5 runs; Python 3.12, NumPy 2.5,
|
|
286
|
-
Linux x86_64).
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
|
292
|
-
|
|
|
293
|
-
|
|
|
294
|
-
|
|
|
295
|
-
|
|
|
296
|
-
| calibration slope and intercept |
|
|
297
|
-
|
|
|
298
|
-
|
|
|
299
|
-
|
|
|
300
|
-
|
|
|
301
|
-
|
|
|
302
|
-
|
|
|
303
|
-
|
|
|
286
|
+
Linux x86_64). **Overall: 45 of 45 rows agree with the reference** (largest
|
|
287
|
+
difference 2.8e-14), 32 are faster, and the geometric-mean speed-up across all
|
|
288
|
+
15 cases and three sizes is **2.22×**. At 1,000,000 samples (pixels for
|
|
289
|
+
segmentation; 1,000 images for detection and 50 for Hausdorff), in alphabetical order:
|
|
290
|
+
|
|
291
|
+
| Case | Reference | EvalSuite (ms) | Reference (ms) | Speed-up |
|
|
292
|
+
| --- | --- | ---: | ---: | ---: |
|
|
293
|
+
| 10 classes: macro F1 | scikit-learn | 29.3 | 130.8 | **4.5×** |
|
|
294
|
+
| binary: 8 label metrics via evaluate() | scikit-learn | 39.5 | 1084.1 | **27.4×** |
|
|
295
|
+
| binary: ROC AUC | scikit-learn | 195.0 | 348.3 | **1.8×** |
|
|
296
|
+
| calibration: slope and intercept | statsmodels | 193.0 | 1068.0 | **5.5×** |
|
|
297
|
+
| clinical: diagnostic report (7 CIs) | statsmodels | 23.5 | 4.7 | 0.20× |
|
|
298
|
+
| clinical: sensitivity, specificity, LR+, LR− | scikit-learn | 69.1 | 414.0 | **6.0×** |
|
|
299
|
+
| decision curve: 99 thresholds | NumPy loop | 156.3 | 181.6 | **1.2×** |
|
|
300
|
+
| detection: COCO evaluation (1000 images) | pycocotools | 817.3 | 983.7 | **1.2×** |
|
|
301
|
+
| multiple testing: Hochberg (n p-values) | statsmodels | 82.0 | 108.0 | **1.3×** |
|
|
302
|
+
| regression: MAE, MSE, RMSE, R² via evaluate() | scikit-learn | 23.1 | 10.8 | 0.47× |
|
|
303
|
+
| segmentation: Dice and IoU per class (n = pixels) | scikit-learn | 43.0 | 290.0 | **6.8×** |
|
|
304
|
+
| segmentation: Hausdorff distance (50 images) | SciPy | 27.0 | 20.2 | 0.75× |
|
|
305
|
+
| statistics: Cramér's V (5×5 table) | SciPy | 0.5 | 0.4 | 0.88× |
|
|
306
|
+
| statistics: Mann–Whitney U | SciPy | 349.7 | 350.2 | **1.0×** |
|
|
307
|
+
| statistics: Welch t-test | SciPy | 14.5 | 7.2 | 0.49× |
|
|
304
308
|
|
|
305
309
|
`evaluate()` validates inputs once and builds the confusion matrix once for all metrics, which is where most
|
|
306
310
|
of the speed-up comes from. Hypothesis tests use SciPy underneath, so they match its speed at best; rows
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
EvalSuite brings classification, regression, clinical, statistical, segmentation and object-detection
|
|
11
11
|
evaluation into one consistent, validated, documented framework.
|
|
12
12
|
|
|
13
|
-
> **Status: stable (0.3.
|
|
13
|
+
> **Status: stable (0.3.1).** Every item on the 0.1.0, 0.2.0 and 0.3.0 roadmaps is implemented and verified.
|
|
14
14
|
|
|
15
15
|
## Installation
|
|
16
16
|
|
|
@@ -223,24 +223,28 @@ Input files can be CSV, TSV, Parquet or JSON. Output format follows `--format` o
|
|
|
223
223
|
## Performance
|
|
224
224
|
|
|
225
225
|
Benchmarked against reference implementations on the same data (fastest of 5 runs; Python 3.12, NumPy 2.5,
|
|
226
|
-
Linux x86_64).
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
|
232
|
-
|
|
|
233
|
-
|
|
|
234
|
-
|
|
|
235
|
-
|
|
|
236
|
-
| calibration slope and intercept |
|
|
237
|
-
|
|
|
238
|
-
|
|
|
239
|
-
|
|
|
240
|
-
|
|
|
241
|
-
|
|
|
242
|
-
|
|
|
243
|
-
|
|
|
226
|
+
Linux x86_64). **Overall: 45 of 45 rows agree with the reference** (largest
|
|
227
|
+
difference 2.8e-14), 32 are faster, and the geometric-mean speed-up across all
|
|
228
|
+
15 cases and three sizes is **2.22×**. At 1,000,000 samples (pixels for
|
|
229
|
+
segmentation; 1,000 images for detection and 50 for Hausdorff), in alphabetical order:
|
|
230
|
+
|
|
231
|
+
| Case | Reference | EvalSuite (ms) | Reference (ms) | Speed-up |
|
|
232
|
+
| --- | --- | ---: | ---: | ---: |
|
|
233
|
+
| 10 classes: macro F1 | scikit-learn | 29.3 | 130.8 | **4.5×** |
|
|
234
|
+
| binary: 8 label metrics via evaluate() | scikit-learn | 39.5 | 1084.1 | **27.4×** |
|
|
235
|
+
| binary: ROC AUC | scikit-learn | 195.0 | 348.3 | **1.8×** |
|
|
236
|
+
| calibration: slope and intercept | statsmodels | 193.0 | 1068.0 | **5.5×** |
|
|
237
|
+
| clinical: diagnostic report (7 CIs) | statsmodels | 23.5 | 4.7 | 0.20× |
|
|
238
|
+
| clinical: sensitivity, specificity, LR+, LR− | scikit-learn | 69.1 | 414.0 | **6.0×** |
|
|
239
|
+
| decision curve: 99 thresholds | NumPy loop | 156.3 | 181.6 | **1.2×** |
|
|
240
|
+
| detection: COCO evaluation (1000 images) | pycocotools | 817.3 | 983.7 | **1.2×** |
|
|
241
|
+
| multiple testing: Hochberg (n p-values) | statsmodels | 82.0 | 108.0 | **1.3×** |
|
|
242
|
+
| regression: MAE, MSE, RMSE, R² via evaluate() | scikit-learn | 23.1 | 10.8 | 0.47× |
|
|
243
|
+
| segmentation: Dice and IoU per class (n = pixels) | scikit-learn | 43.0 | 290.0 | **6.8×** |
|
|
244
|
+
| segmentation: Hausdorff distance (50 images) | SciPy | 27.0 | 20.2 | 0.75× |
|
|
245
|
+
| statistics: Cramér's V (5×5 table) | SciPy | 0.5 | 0.4 | 0.88× |
|
|
246
|
+
| statistics: Mann–Whitney U | SciPy | 349.7 | 350.2 | **1.0×** |
|
|
247
|
+
| statistics: Welch t-test | SciPy | 14.5 | 7.2 | 0.49× |
|
|
244
248
|
|
|
245
249
|
`evaluate()` validates inputs once and builds the confusion matrix once for all metrics, which is where most
|
|
246
250
|
of the speed-up comes from. Hypothesis tests use SciPy underneath, so they match its speed at best; rows
|
|
@@ -17,8 +17,10 @@ whose reference library is not installed is timed for EvalSuite only.
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import contextlib
|
|
20
|
+
import importlib
|
|
20
21
|
import json
|
|
21
22
|
import platform
|
|
23
|
+
import re
|
|
22
24
|
import time
|
|
23
25
|
import tracemalloc
|
|
24
26
|
import warnings
|
|
@@ -371,6 +373,28 @@ def _clinical_cases(n: int, rng: np.random.Generator) -> list[Case]:
|
|
|
371
373
|
return [(name, ref, es_fn, own if own is not None else refs.get(name)) for name, ref, es_fn, own in cases]
|
|
372
374
|
|
|
373
375
|
|
|
376
|
+
_GROUPS = (
|
|
377
|
+
("Classification and regression", ("binary", "10 classes", "regression")),
|
|
378
|
+
("Clinical, calibration and statistics", ("clinical", "calibration", "decision", "statistics", "multiple")),
|
|
379
|
+
("Segmentation and object detection", ("segmentation", "detection")),
|
|
380
|
+
)
|
|
381
|
+
MATCH_TOLERANCE = 1e-9
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def benchmark_group(case: str) -> str:
|
|
385
|
+
"""The suite a benchmark case belongs to, by its name."""
|
|
386
|
+
lowered = case.lower()
|
|
387
|
+
for title, prefixes in _GROUPS:
|
|
388
|
+
if lowered.startswith(prefixes):
|
|
389
|
+
return title
|
|
390
|
+
return "Other"
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _geomean(values: Sequence[float]) -> Optional[float]:
|
|
394
|
+
vals = [v for v in values if v is not None and v > 0]
|
|
395
|
+
return float(np.exp(np.mean(np.log(vals)))) if vals else None
|
|
396
|
+
|
|
397
|
+
|
|
374
398
|
@dataclass(frozen=True, eq=False)
|
|
375
399
|
class BenchmarkResult:
|
|
376
400
|
"""Rows of timings (milliseconds), peak memory (MiB) and agreement, plus the environment they ran in."""
|
|
@@ -382,6 +406,60 @@ class BenchmarkResult:
|
|
|
382
406
|
object.__setattr__(self, "rows", tuple(MappingProxyType(dict(r)) for r in self.rows))
|
|
383
407
|
object.__setattr__(self, "environment", MappingProxyType(dict(self.environment)))
|
|
384
408
|
|
|
409
|
+
def sorted_rows(self) -> list[Any]:
|
|
410
|
+
"""Rows in alphabetical order of case, then by size."""
|
|
411
|
+
return sorted(self.rows, key=lambda r: (r["case"].lower(), r["n"]))
|
|
412
|
+
|
|
413
|
+
def overall(self) -> list[dict[str, Any]]:
|
|
414
|
+
"""One summary row per suite and one for everything: cases, how many agree with the reference
|
|
415
|
+
(max |difference| <= 1e-9), and the geometric-mean, smallest and largest speed-up."""
|
|
416
|
+
|
|
417
|
+
def summarise(name: str, rows: Sequence[Any]) -> dict[str, Any]:
|
|
418
|
+
compared = [r for r in rows if r["speedup"] is not None]
|
|
419
|
+
speedups = [r["speedup"] for r in compared]
|
|
420
|
+
return {
|
|
421
|
+
"group": name,
|
|
422
|
+
"cases": len({re.sub(r"\s*\(\d+ images?\)", "", r["case"]) for r in rows}),
|
|
423
|
+
"rows": len(rows),
|
|
424
|
+
"compared": len(compared),
|
|
425
|
+
"matching": sum(
|
|
426
|
+
r["max_abs_diff"] is not None and r["max_abs_diff"] <= MATCH_TOLERANCE for r in rows
|
|
427
|
+
),
|
|
428
|
+
"faster": sum(sp > 1 for sp in speedups),
|
|
429
|
+
"geomean_speedup": _geomean(speedups),
|
|
430
|
+
"min_speedup": min(speedups) if speedups else None,
|
|
431
|
+
"max_speedup": max(speedups) if speedups else None,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
groups = sorted({benchmark_group(r["case"]) for r in self.rows})
|
|
435
|
+
out = [summarise(g, [r for r in self.rows if benchmark_group(r["case"]) == g]) for g in groups]
|
|
436
|
+
out.append(summarise("Overall", self.rows))
|
|
437
|
+
return out
|
|
438
|
+
|
|
439
|
+
_OVERALL_TITLES = ("Suite", "Cases", "Rows", "Match reference", "Faster", "Geo-mean speed-up", "Range")
|
|
440
|
+
|
|
441
|
+
def _overall_cells(self) -> list[list[str]]:
|
|
442
|
+
def x(v: Optional[float]) -> str:
|
|
443
|
+
return "–" if v is None else f"{v:.2f}×"
|
|
444
|
+
|
|
445
|
+
return [
|
|
446
|
+
[
|
|
447
|
+
o["group"],
|
|
448
|
+
str(o["cases"]),
|
|
449
|
+
str(o["rows"]),
|
|
450
|
+
f"{o['matching']}/{o['compared']}",
|
|
451
|
+
f"{o['faster']}/{o['compared']}",
|
|
452
|
+
x(o["geomean_speedup"]),
|
|
453
|
+
"–" if o["min_speedup"] is None else f"{x(o['min_speedup'])}–{x(o['max_speedup'])}",
|
|
454
|
+
]
|
|
455
|
+
for o in self.overall()
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
def overall_markdown(self) -> str:
|
|
459
|
+
"""The overall summary as a Markdown table."""
|
|
460
|
+
lines = ["| " + " | ".join(self._OVERALL_TITLES) + " |", "| --- |" + " ---: |" * 6]
|
|
461
|
+
return "\n".join(lines + ["| " + " | ".join(c) + " |" for c in self._overall_cells()])
|
|
462
|
+
|
|
385
463
|
def _cells(self, digits: int) -> list[list[str]]:
|
|
386
464
|
def f(v: Any, d: int = digits) -> str:
|
|
387
465
|
return "–" if v is None else f"{v:.{d}f}"
|
|
@@ -398,7 +476,7 @@ class BenchmarkResult:
|
|
|
398
476
|
f(r.get("reference_peak_mb"), 2),
|
|
399
477
|
"–" if r["max_abs_diff"] is None else f"{r['max_abs_diff']:.1e}",
|
|
400
478
|
]
|
|
401
|
-
for r in self.
|
|
479
|
+
for r in self.sorted_rows()
|
|
402
480
|
]
|
|
403
481
|
|
|
404
482
|
_TITLES = (
|
|
@@ -430,7 +508,14 @@ class BenchmarkResult:
|
|
|
430
508
|
+ f" | {env['machine']} | fastest of {env['repeat']} runs"
|
|
431
509
|
)
|
|
432
510
|
note = "Speed-up > 1 means EvalSuite is faster. Max |difference| compares EvalSuite with the reference."
|
|
433
|
-
|
|
511
|
+
ocells = self._overall_cells()
|
|
512
|
+
owidths = [max(len(t), *(len(c[i]) for c in ocells)) for i, t in enumerate(self._OVERALL_TITLES)]
|
|
513
|
+
|
|
514
|
+
def oline(c: Sequence[str]) -> str:
|
|
515
|
+
return " ".join(x.ljust(owidths[i]) if i == 0 else x.rjust(owidths[i]) for i, x in enumerate(c))
|
|
516
|
+
|
|
517
|
+
overall = ["Overall", oline(self._OVERALL_TITLES), *(oline(c) for c in ocells), ""]
|
|
518
|
+
return "\n".join([head, "", *overall, line(self._TITLES), *(line(c) for c in cells), "", note])
|
|
434
519
|
|
|
435
520
|
def __repr__(self) -> str:
|
|
436
521
|
return self.summary()
|
|
@@ -438,7 +523,13 @@ class BenchmarkResult:
|
|
|
438
523
|
def to_dict(self) -> dict[str, Any]:
|
|
439
524
|
return cast(
|
|
440
525
|
"dict[str, Any]",
|
|
441
|
-
_json_safe(
|
|
526
|
+
_json_safe(
|
|
527
|
+
{
|
|
528
|
+
"environment": dict(self.environment),
|
|
529
|
+
"overall": self.overall(),
|
|
530
|
+
"rows": [dict(r) for r in self.rows],
|
|
531
|
+
}
|
|
532
|
+
),
|
|
442
533
|
)
|
|
443
534
|
|
|
444
535
|
def to_json(self, *, indent: Optional[int] = 2) -> str:
|
|
@@ -514,7 +605,7 @@ def run_benchmarks(
|
|
|
514
605
|
if compare_sklearn:
|
|
515
606
|
for mod in versions:
|
|
516
607
|
with contextlib.suppress(ImportError):
|
|
517
|
-
|
|
608
|
+
importlib.import_module(mod) # fixed names only: sklearn, statsmodels, pycocotools
|
|
518
609
|
from importlib.metadata import version as _dist_version
|
|
519
610
|
|
|
520
611
|
versions[mod] = _dist_version("scikit-learn" if mod == "sklearn" else mod)
|
|
@@ -45,9 +45,25 @@ def to_numpy(x: ArrayLike, name: str, *, allow_2d: bool = False) -> NDArray[Any]
|
|
|
45
45
|
raise InputValidationError(f"{name} must be {expected}; received an array with shape {arr.shape}.")
|
|
46
46
|
if arr.shape[0] == 0:
|
|
47
47
|
raise InputValidationError(f"{name} is empty. Provide at least one observation.")
|
|
48
|
+
if (
|
|
49
|
+
arr.ndim == 1
|
|
50
|
+
and arr.dtype.kind in "US"
|
|
51
|
+
and isinstance(x, (list, tuple))
|
|
52
|
+
and not all(isinstance(v, (str, bytes)) for v in x)
|
|
53
|
+
):
|
|
54
|
+
# NumPy silently turns [0, "a"] into ["0", "a"]; a mix of numbers and strings is almost always a bug.
|
|
55
|
+
raise InputValidationError(f"{name} mixes numbers and strings; use one label type throughout.")
|
|
48
56
|
return arr
|
|
49
57
|
|
|
50
58
|
|
|
59
|
+
def _label_kind(arr: NDArray[Any]) -> str:
|
|
60
|
+
if arr.dtype.kind in "US":
|
|
61
|
+
return "string"
|
|
62
|
+
if arr.dtype.kind in "biuf":
|
|
63
|
+
return "number"
|
|
64
|
+
return "other"
|
|
65
|
+
|
|
66
|
+
|
|
51
67
|
def check_finite(arr: NDArray[Any], name: str) -> None:
|
|
52
68
|
if np.issubdtype(arr.dtype, np.number) and not np.all(np.isfinite(arr)):
|
|
53
69
|
n_nan = int(np.isnan(arr).sum())
|
|
@@ -126,6 +142,11 @@ def resolve_labels(
|
|
|
126
142
|
if np.unique(lab).shape[0] != lab.shape[0]:
|
|
127
143
|
raise InputValidationError("labels contains duplicates.")
|
|
128
144
|
return lab
|
|
145
|
+
if y_pred is not None and {_label_kind(y_true), _label_kind(y_pred)} == {"string", "number"}:
|
|
146
|
+
raise InputValidationError(
|
|
147
|
+
f"y_true has {_label_kind(y_true)} labels but y_pred has {_label_kind(y_pred)} labels; "
|
|
148
|
+
"use the same label type in both (for example map class names to integers first)."
|
|
149
|
+
)
|
|
129
150
|
present = y_true if y_pred is None else np.concatenate([y_true, y_pred])
|
|
130
151
|
try:
|
|
131
152
|
return unique_labels(present)
|
|
@@ -202,7 +202,15 @@ def rmse(
|
|
|
202
202
|
) -> MetricResult:
|
|
203
203
|
"""Root mean squared error (square root taken per output, then averaged)."""
|
|
204
204
|
inp = _inputs(y_true, y_pred, sample_weight)
|
|
205
|
-
|
|
205
|
+
with np.errstate(over="ignore"):
|
|
206
|
+
per_output = np.sqrt(inp.mean(inp.error**2))
|
|
207
|
+
if not np.all(np.isfinite(per_output)):
|
|
208
|
+
# Errors above ~1e154 overflow when squared although the RMSE itself is representable:
|
|
209
|
+
# rescale by the largest error and square again.
|
|
210
|
+
scale = np.abs(inp.error).max(axis=0)
|
|
211
|
+
scale = np.where(np.isfinite(scale) & (scale > 0), scale, 1.0)
|
|
212
|
+
per_output = scale * np.sqrt(inp.mean((inp.error / scale) ** 2))
|
|
213
|
+
return _finish(per_output, inp, multioutput, "rmse", "RMSE")
|
|
206
214
|
|
|
207
215
|
|
|
208
216
|
def _r2_per_output(inp: _Inputs) -> FloatArray:
|
|
@@ -10,7 +10,7 @@ from typing import Any, Optional
|
|
|
10
10
|
import numpy as np
|
|
11
11
|
from numpy.typing import NDArray
|
|
12
12
|
|
|
13
|
-
from ..core.exceptions import InputValidationError
|
|
13
|
+
from ..core.exceptions import InputValidationError, StatisticalTestError
|
|
14
14
|
from ..core.validation import target_type, to_numpy
|
|
15
15
|
|
|
16
16
|
MetricFn = Callable[..., Any]
|
|
@@ -89,6 +89,11 @@ class MetricCall:
|
|
|
89
89
|
f"observations. Received {self.y_true.shape[0]} and {self.second.shape[0]}."
|
|
90
90
|
)
|
|
91
91
|
self.n = self.y_true.shape[0]
|
|
92
|
+
if self.n < 2:
|
|
93
|
+
raise StatisticalTestError(
|
|
94
|
+
f"Resampling needs at least 2 observations; got {self.n}. An interval or test from a single "
|
|
95
|
+
"observation would have zero width and no meaning."
|
|
96
|
+
)
|
|
92
97
|
self.weight = None if sample_weight is None else to_numpy(sample_weight, "sample_weight")
|
|
93
98
|
self.kwargs = dict(kwargs or {})
|
|
94
99
|
params = inspect.signature(fn).parameters
|
|
@@ -84,9 +84,17 @@ _REF_HD = (
|
|
|
84
84
|
|
|
85
85
|
# ---- input handling ------------------------------------------------------------------------------
|
|
86
86
|
def _as_images(masks: Masks, name: str) -> list[NDArray[Any]]:
|
|
87
|
+
images = None
|
|
87
88
|
if isinstance(masks, (list, tuple)) or (isinstance(masks, np.ndarray) and masks.dtype == object):
|
|
88
89
|
images = [np.asarray(m) for m in masks]
|
|
89
|
-
|
|
90
|
+
if (
|
|
91
|
+
images
|
|
92
|
+
and isinstance(masks, (list, tuple))
|
|
93
|
+
and all(m.ndim == 1 for m in images)
|
|
94
|
+
and not any(isinstance(m, np.ndarray) for m in masks)
|
|
95
|
+
):
|
|
96
|
+
images = None # a nested Python list of rows, e.g. [[1, 1], [0, 0]], is one 2-D mask
|
|
97
|
+
if images is None:
|
|
90
98
|
arr = np.asarray(masks)
|
|
91
99
|
if arr.ndim < 2:
|
|
92
100
|
raise InputValidationError(
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Release QA: edge cases against reference libraries, documented conventions, exports, reproducibility.
|
|
2
|
+
|
|
3
|
+
Each test names the QA item it covers. Regression tests for defects fixed in 0.3.1 are marked "0.3.1 fix".
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
import evalsuite as es
|
|
17
|
+
|
|
18
|
+
sk = pytest.importorskip("sklearn.metrics")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------- classification vs scikit-learn
|
|
22
|
+
def _binary(seed: int, n: int = 300, prevalence: float = 0.5):
|
|
23
|
+
rng = np.random.default_rng(seed)
|
|
24
|
+
y = (rng.random(n) < prevalence).astype(int)
|
|
25
|
+
p = np.clip(0.35 * y + rng.random(n) * 0.65, 0, 1)
|
|
26
|
+
return y, (p >= 0.5).astype(int), p
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize("prevalence", [0.5, 0.05]) # balanced and imbalanced
|
|
30
|
+
@pytest.mark.parametrize("seed", [0, 1])
|
|
31
|
+
def test_binary_metrics_match_sklearn(seed: int, prevalence: float) -> None:
|
|
32
|
+
y, pred, prob = _binary(seed, prevalence=prevalence)
|
|
33
|
+
pairs = {
|
|
34
|
+
es.accuracy: sk.accuracy_score,
|
|
35
|
+
es.precision: lambda a, b: sk.precision_score(a, b, zero_division=0),
|
|
36
|
+
es.recall: sk.recall_score,
|
|
37
|
+
es.f1: lambda a, b: sk.f1_score(a, b, zero_division=0),
|
|
38
|
+
es.mcc: sk.matthews_corrcoef,
|
|
39
|
+
es.balanced_accuracy: sk.balanced_accuracy_score,
|
|
40
|
+
es.sensitivity: sk.recall_score,
|
|
41
|
+
es.ppv: lambda a, b: sk.precision_score(a, b, zero_division=0),
|
|
42
|
+
}
|
|
43
|
+
for ours, ref in pairs.items():
|
|
44
|
+
assert float(ours(y, pred)) == pytest.approx(ref(y, pred), abs=1e-12), ours.__name__
|
|
45
|
+
assert float(es.specificity(y, pred)) == pytest.approx(sk.recall_score(y, pred, pos_label=0))
|
|
46
|
+
assert float(es.npv(y, pred)) == pytest.approx(sk.precision_score(y, pred, pos_label=0, zero_division=0))
|
|
47
|
+
assert float(es.roc_auc(y, prob)) == pytest.approx(sk.roc_auc_score(y, prob), abs=1e-12)
|
|
48
|
+
np.testing.assert_array_equal(es.confusion_matrix(y, pred), sk.confusion_matrix(y, pred))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.parametrize("average", ["macro", "micro", "weighted"])
|
|
52
|
+
def test_multiclass_with_missing_predicted_class_matches_sklearn(average: str) -> None:
|
|
53
|
+
y = np.array([0, 1, 2, 2, 1, 0, 2, 2])
|
|
54
|
+
pred = np.array([0, 1, 1, 1, 1, 0, 1, 0]) # class 2 is never predicted
|
|
55
|
+
with warnings.catch_warnings():
|
|
56
|
+
warnings.simplefilter("ignore")
|
|
57
|
+
for ours, ref in [(es.precision, sk.precision_score), (es.recall, sk.recall_score), (es.f1, sk.f1_score)]:
|
|
58
|
+
expected = ref(y, pred, average=average, zero_division=0)
|
|
59
|
+
assert float(ours(y, pred, average=average)) == pytest.approx(expected, abs=1e-12)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_multiclass_auc_follows_sorted_label_order_for_string_labels() -> None:
|
|
63
|
+
y = np.array(["cat", "dog", "bird", "dog", "cat", "bird"])
|
|
64
|
+
prob = np.array(
|
|
65
|
+
[[0.1, 0.8, 0.1], [0.1, 0.2, 0.7], [0.7, 0.2, 0.1], [0.2, 0.2, 0.6], [0.2, 0.6, 0.2], [0.5, 0.3, 0.2]]
|
|
66
|
+
) # columns: bird, cat, dog
|
|
67
|
+
assert float(es.roc_auc(y, prob)) == pytest.approx(sk.roc_auc_score(y, prob, multi_class="ovr"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_zero_division_policy_is_documented_and_configurable() -> None:
|
|
71
|
+
y, pred = [0, 1, 1], [0, 0, 0] # no predicted positives
|
|
72
|
+
with warnings.catch_warnings():
|
|
73
|
+
warnings.simplefilter("ignore")
|
|
74
|
+
assert float(es.precision(y, pred)) == 0.0
|
|
75
|
+
assert float(es.precision(y, pred, zero_division=1.0)) == 1.0
|
|
76
|
+
assert float(es.recall([0, 1, 1], [1, 0, 0])) == 0.0 # zero true positives
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize(
|
|
80
|
+
("call", "match"),
|
|
81
|
+
[
|
|
82
|
+
(lambda: es.accuracy([], []), "empty"),
|
|
83
|
+
(lambda: es.accuracy([0, np.nan], [0, 1]), "NaN"),
|
|
84
|
+
(lambda: es.roc_auc([0, 1], [0.2, np.inf]), "infinite"),
|
|
85
|
+
(lambda: es.roc_auc([0, 1], [0.2, 1.4]), r"\[0, 1\]"),
|
|
86
|
+
(lambda: es.accuracy([0, 1], [0]), "same number"),
|
|
87
|
+
(lambda: es.roc_auc([0, 1, 2], np.full((3, 2), 0.5)), "columns"),
|
|
88
|
+
(lambda: es.roc_auc([0, 1], np.full((2, 2, 2), 0.5)), "1-D or 2-D"),
|
|
89
|
+
(lambda: es.roc_auc([0, 1, 2], np.full((3, 3), 0.5)), "sum to 1"),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
def test_invalid_classification_inputs_raise_clear_errors(call, match: str) -> None:
|
|
93
|
+
with pytest.raises(es.InputValidationError, match=match):
|
|
94
|
+
call()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_single_class_target_auc_is_an_error_and_mcc_follows_sklearn() -> None:
|
|
98
|
+
with pytest.raises(es.MetricInputError, match="no negative"):
|
|
99
|
+
es.roc_auc([1, 1, 1], [0.2, 0.5, 0.9])
|
|
100
|
+
assert float(es.mcc([1, 1, 1], [1, 1, 1])) == sk.matthews_corrcoef([1, 1, 1], [1, 1, 1])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_mixed_number_and_string_labels_are_rejected() -> None: # 0.3.1 fix
|
|
104
|
+
with pytest.raises(es.InputValidationError, match="mixes numbers and strings"):
|
|
105
|
+
es.accuracy([0, "a"], [0, 1])
|
|
106
|
+
with pytest.raises(es.InputValidationError, match="string labels but y_pred has number"):
|
|
107
|
+
es.accuracy(np.array(["a", "b"]), np.array([0, 1]))
|
|
108
|
+
assert float(es.accuracy(["a", "b"], ["a", "a"])) == 0.5 # all strings stay fine
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------- regression vs scikit-learn
|
|
112
|
+
@pytest.mark.parametrize("dtype", [np.int64, np.float32, np.float64])
|
|
113
|
+
def test_regression_matches_sklearn_for_int_and_float_inputs(dtype) -> None:
|
|
114
|
+
rng = np.random.default_rng(3)
|
|
115
|
+
y = (rng.normal(50, 10, 200)).astype(dtype)
|
|
116
|
+
p = (y + rng.normal(0, 3, 200)).astype(dtype)
|
|
117
|
+
yf, pf = y.astype(np.float64), p.astype(np.float64)
|
|
118
|
+
assert float(es.mae(y, p)) == pytest.approx(sk.mean_absolute_error(yf, pf), rel=1e-12)
|
|
119
|
+
assert float(es.mse(y, p)) == pytest.approx(sk.mean_squared_error(yf, pf), rel=1e-12)
|
|
120
|
+
assert float(es.rmse(y, p)) == pytest.approx(math.sqrt(sk.mean_squared_error(yf, pf)), rel=1e-12)
|
|
121
|
+
assert float(es.r2(y, p)) == pytest.approx(sk.r2_score(yf, pf), rel=1e-12)
|
|
122
|
+
assert isinstance(es.mse(y, p).value, float)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_multioutput_regression_matches_sklearn() -> None:
|
|
126
|
+
rng = np.random.default_rng(4)
|
|
127
|
+
y = rng.normal(size=(100, 3))
|
|
128
|
+
p = y + rng.normal(0, 0.5, (100, 3))
|
|
129
|
+
np.testing.assert_allclose(
|
|
130
|
+
es.mae(y, p, multioutput="raw_values").value, sk.mean_absolute_error(y, p, multioutput="raw_values")
|
|
131
|
+
)
|
|
132
|
+
assert float(es.r2(y, p)) == pytest.approx(sk.r2_score(y, p))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_degenerate_regression_inputs() -> None:
|
|
136
|
+
with warnings.catch_warnings():
|
|
137
|
+
warnings.simplefilter("ignore")
|
|
138
|
+
assert float(es.r2([2, 2, 2], [2, 2, 3])) == sk.r2_score([2, 2, 2], [2, 2, 3]) # constant target
|
|
139
|
+
assert float(es.r2([2, 2, 2], [2, 2, 2])) == 1.0
|
|
140
|
+
assert float(es.mae([1.5], [1.0])) == 0.5 # single sample
|
|
141
|
+
for bad, match in [(([], []), "empty"), (([1, np.nan], [1, 2]), "NaN"), (([1, np.inf], [1, 2]), "infinite")]:
|
|
142
|
+
with pytest.raises(es.InputValidationError, match=match):
|
|
143
|
+
es.mse(*bad)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_rmse_does_not_overflow_for_large_values() -> None: # 0.3.1 fix
|
|
147
|
+
value = float(es.rmse([1e154, 2e154], [0.0, 0.0]))
|
|
148
|
+
assert math.isfinite(value)
|
|
149
|
+
assert value == pytest.approx(math.sqrt((1 + 4) / 2) * 1e154, rel=1e-12)
|
|
150
|
+
assert float(es.rmse([1.0, 3.0], [0.0, 0.0])) == pytest.approx(math.sqrt(5)) # unchanged path
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------- statistics
|
|
154
|
+
def test_resampling_needs_two_observations() -> None: # 0.3.1 fix
|
|
155
|
+
with pytest.raises(es.StatisticalTestError, match="at least 2 observations"):
|
|
156
|
+
es.bootstrap_ci("accuracy", [1], [1])
|
|
157
|
+
with pytest.raises(es.StatisticalTestError, match="at least 2 observations"):
|
|
158
|
+
es.paired_bootstrap_test("accuracy", [1], [1], [0])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_randomised_procedures_are_reproducible_with_a_seed() -> None:
|
|
162
|
+
y, pred, _ = _binary(5)
|
|
163
|
+
a = es.bootstrap_ci("f1", y, pred, n_resamples=300, random_state=7)
|
|
164
|
+
b = es.bootstrap_ci("f1", y, pred, n_resamples=300, random_state=7)
|
|
165
|
+
assert (a.low, a.high) == (b.low, b.high)
|
|
166
|
+
c1 = es.compare(y, {"A": pred, "B": 1 - pred}, n_resamples=200, random_state=1)
|
|
167
|
+
c2 = es.compare(y, {"A": pred, "B": 1 - pred}, n_resamples=200, random_state=1)
|
|
168
|
+
assert c1.to_json() == c2.to_json()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_multiple_testing_matches_statsmodels() -> None:
|
|
172
|
+
mt = pytest.importorskip("statsmodels.stats.multitest")
|
|
173
|
+
p = np.array([0.001, 0.008, 0.039, 0.041, 0.042, 0.06, 0.074, 0.205, 0.5])
|
|
174
|
+
for ours, theirs in [
|
|
175
|
+
("bonferroni", "bonferroni"),
|
|
176
|
+
("holm", "holm"),
|
|
177
|
+
("hochberg", "simes-hochberg"),
|
|
178
|
+
("bh", "fdr_bh"),
|
|
179
|
+
("by", "fdr_by"),
|
|
180
|
+
]:
|
|
181
|
+
np.testing.assert_allclose(es.adjust_pvalues(p, method=ours), mt.multipletests(p, method=theirs)[1])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ---------------------------------------------------------------- clinical
|
|
185
|
+
def test_ppv_and_npv_follow_bayes_with_prevalence() -> None:
|
|
186
|
+
y, pred, _ = _binary(6, n=2000, prevalence=0.2)
|
|
187
|
+
se, sp = float(es.sensitivity(y, pred)), float(es.specificity(y, pred))
|
|
188
|
+
prev = y.mean()
|
|
189
|
+
assert float(es.ppv(y, pred)) == pytest.approx(se * prev / (se * prev + (1 - sp) * (1 - prev)))
|
|
190
|
+
assert float(es.npv(y, pred)) == pytest.approx(sp * (1 - prev) / (sp * (1 - prev) + (1 - se) * prev))
|
|
191
|
+
assert float(es.lr_positive(y, pred)) == pytest.approx(se / (1 - sp))
|
|
192
|
+
assert float(es.lr_negative(y, pred)) == pytest.approx((1 - se) / sp)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_decision_curve_net_benefit_and_reference_strategies() -> None:
|
|
196
|
+
y, _, prob = _binary(8, n=500, prevalence=0.3)
|
|
197
|
+
dca = es.decision_curve(y, {"m": prob}, thresholds=[0.1, 0.25, 0.5])
|
|
198
|
+
prev = y.mean()
|
|
199
|
+
for i, t in enumerate(dca.thresholds):
|
|
200
|
+
pos = prob >= t
|
|
201
|
+
tp, fp = np.sum(pos & (y == 1)), np.sum(pos & (y == 0))
|
|
202
|
+
expected = tp / y.size - fp / y.size * t / (1 - t)
|
|
203
|
+
assert dca.net_benefit["m"][i] == pytest.approx(expected)
|
|
204
|
+
assert dca.treat_all[i] == pytest.approx(prev - (1 - prev) * t / (1 - t))
|
|
205
|
+
assert dca.treat_none[i] == 0.0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ---------------------------------------------------------------- segmentation conventions
|
|
209
|
+
def test_segmentation_empty_masks_absent_classes_and_ignore_index() -> None:
|
|
210
|
+
empty = np.zeros((4, 4), int)
|
|
211
|
+
with warnings.catch_warnings():
|
|
212
|
+
warnings.simplefilter("ignore")
|
|
213
|
+
per_class = es.dice(empty, empty, num_classes=3, average=None).value
|
|
214
|
+
assert per_class[0] == 1.0 and np.isnan(per_class[1:]).all() # absent classes are NaN, not 0
|
|
215
|
+
assert float(es.dice(empty, empty, num_classes=3, empty=1.0)) == 1.0 # explicit policy
|
|
216
|
+
t = np.array([[1, 1, 255], [0, 0, 255]])
|
|
217
|
+
p = np.array([[1, 0, 1], [0, 0, 0]])
|
|
218
|
+
assert float(es.pixel_accuracy(t, p, ignore_index=255)) == pytest.approx(3 / 4)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_per_image_and_dataset_aggregation_differ_as_documented() -> None:
|
|
222
|
+
a = np.zeros((2, 10, 10), int)
|
|
223
|
+
b = a.copy()
|
|
224
|
+
a[0, :5] = 1
|
|
225
|
+
b[0, :5] = 1 # image 0 perfect
|
|
226
|
+
a[1, 0, 0] = 1 # image 1: one pixel, missed
|
|
227
|
+
dataset = float(es.dice(a, b, average=None, aggregate="dataset").value[1])
|
|
228
|
+
image = float(es.dice(a, b, average=None, aggregate="image").value[1])
|
|
229
|
+
assert dataset == pytest.approx(2 * 50 / (51 + 50))
|
|
230
|
+
assert image == pytest.approx((1.0 + 0.0) / 2)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------- exports and API
|
|
234
|
+
UNTRUSTED = '<script>alert("x")</script> & café'
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _reports():
|
|
238
|
+
rng = np.random.default_rng(0)
|
|
239
|
+
yc = rng.integers(0, 2, 60)
|
|
240
|
+
masks = rng.integers(0, 2, (2, 8, 8))
|
|
241
|
+
det = [{"boxes": [[0, 0, 10, 10]], "labels": [1]}]
|
|
242
|
+
pred = [{"boxes": [[0, 0, 10, 10]], "labels": [1], "scores": [0.9]}]
|
|
243
|
+
return {
|
|
244
|
+
"classification": es.classification_report(*[np.where(v == 0, UNTRUSTED, "β-class") for v in (yc, yc)]),
|
|
245
|
+
"segmentation": es.segmentation_report(masks, masks, class_names={0: UNTRUSTED, 1: "β-class"}),
|
|
246
|
+
"detection": es.detection_report(det, pred),
|
|
247
|
+
"evaluation": es.evaluate(yc, yc),
|
|
248
|
+
"diagnostic": es.diagnostic_report(yc, yc),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@pytest.mark.parametrize("kind", ["classification", "segmentation", "detection", "evaluation", "diagnostic"])
|
|
253
|
+
def test_every_export_format_is_valid_and_safe(kind: str, tmp_path) -> None:
|
|
254
|
+
report = _reports()[kind]
|
|
255
|
+
data = json.loads(report.to_json()) # valid JSON (NaN is written as null)
|
|
256
|
+
assert isinstance(data, dict)
|
|
257
|
+
page = report.to_html()
|
|
258
|
+
assert "<script>" not in page
|
|
259
|
+
if UNTRUSTED in str(getattr(report, "rows", "")) or kind == "classification":
|
|
260
|
+
assert html.escape(UNTRUSTED) in page or "<script>" in page
|
|
261
|
+
for ext in ("csv", "md", "tex", "json", "html"):
|
|
262
|
+
out = tmp_path / f"r.{ext}"
|
|
263
|
+
report.save(out)
|
|
264
|
+
assert out.read_text(encoding="utf-8").strip()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_json_export_preserves_values_and_non_finite() -> None:
|
|
268
|
+
masks = np.zeros((1, 4, 4), int)
|
|
269
|
+
masks[0, 0, 0] = 1
|
|
270
|
+
pred = np.zeros_like(masks)
|
|
271
|
+
with warnings.catch_warnings():
|
|
272
|
+
warnings.simplefilter("ignore")
|
|
273
|
+
rep = es.segmentation_report(masks, pred)
|
|
274
|
+
data = json.loads(rep.to_json())
|
|
275
|
+
assert "NaN" not in rep.to_json() and "Infinity" not in rep.to_json()
|
|
276
|
+
assert data["values"]["miou"] == pytest.approx(rep["miou"])
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def test_public_api_names_are_stable() -> None:
|
|
280
|
+
required = [
|
|
281
|
+
"evaluate",
|
|
282
|
+
"compare",
|
|
283
|
+
"bootstrap_ci",
|
|
284
|
+
"accuracy",
|
|
285
|
+
"f1",
|
|
286
|
+
"roc_auc",
|
|
287
|
+
"mae",
|
|
288
|
+
"rmse",
|
|
289
|
+
"r2",
|
|
290
|
+
"diagnostic_report",
|
|
291
|
+
"decision_curve",
|
|
292
|
+
"calibration_report",
|
|
293
|
+
"t_test",
|
|
294
|
+
"adjust_pvalues",
|
|
295
|
+
"dice",
|
|
296
|
+
"iou",
|
|
297
|
+
"miou",
|
|
298
|
+
"hausdorff_distance",
|
|
299
|
+
"segmentation_report",
|
|
300
|
+
"detection_report",
|
|
301
|
+
"mean_average_precision",
|
|
302
|
+
"box_iou",
|
|
303
|
+
"from_coco",
|
|
304
|
+
"plot",
|
|
305
|
+
"list_metrics",
|
|
306
|
+
"metric_info",
|
|
307
|
+
]
|
|
308
|
+
missing = [name for name in required if not hasattr(es, name)]
|
|
309
|
+
assert not missing
|
|
310
|
+
assert isinstance(es.accuracy([0, 1], [0, 1]), es.MetricResult)
|
|
311
|
+
assert isinstance(es.t_test([1.0, 2.0, 3.0], [2.0, 3.0, 4.0]), es.TestResult)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Every Python example in README.md runs against the package.
|
|
2
|
+
|
|
3
|
+
Placeholder lines (code containing ``...``) are skipped; the variables the examples assume are provided by
|
|
4
|
+
fixtures matched to the README section the example sits in.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import warnings
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
import evalsuite as es
|
|
18
|
+
|
|
19
|
+
pytest.importorskip("matplotlib")
|
|
20
|
+
import matplotlib
|
|
21
|
+
|
|
22
|
+
matplotlib.use("Agg")
|
|
23
|
+
|
|
24
|
+
README = Path(__file__).resolve().parents[2] / "README.md"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _blocks() -> list[tuple[str, str]]:
|
|
28
|
+
text = README.read_text(encoding="utf-8")
|
|
29
|
+
out, heading = [], ""
|
|
30
|
+
for match in re.finditer(r"^(#+ [^\n]*)$|^```python\n(.*?)^```", text, flags=re.M | re.S):
|
|
31
|
+
if match.group(1):
|
|
32
|
+
heading = match.group(1).lstrip("# ").strip()
|
|
33
|
+
else:
|
|
34
|
+
out.append((heading, match.group(2)))
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _runnable(code: str) -> str:
|
|
39
|
+
lines = []
|
|
40
|
+
for line in code.splitlines():
|
|
41
|
+
if "..." in line.split("#", 1)[0]:
|
|
42
|
+
continue # placeholder such as [x1, y1, ...] or es.compare(...)
|
|
43
|
+
lines.append(line)
|
|
44
|
+
return "\n".join(lines)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _namespace(tmp_path: Path) -> dict:
|
|
48
|
+
rng = np.random.default_rng(0)
|
|
49
|
+
y = rng.integers(0, 2, 200)
|
|
50
|
+
|
|
51
|
+
def prob(strength: float) -> np.ndarray:
|
|
52
|
+
return 1 / (1 + np.exp(-(strength * (y - 0.5) + rng.normal(0, 1, 200))))
|
|
53
|
+
|
|
54
|
+
probs = {k: prob(s) for k, s in {"lr": 2.0, "rf": 1.6, "gb": 1.2, "a": 2.0, "b": 1.0}.items()}
|
|
55
|
+
preds = {k: (v >= 0.5).astype(int) for k, v in probs.items()}
|
|
56
|
+
masks = rng.integers(0, 2, (3, 24, 24))
|
|
57
|
+
boxes = [{"boxes": [[0, 0, 10, 10], [20, 20, 40, 40]], "labels": [1, 2]}]
|
|
58
|
+
dets = [{"boxes": [[1, 0, 10, 11], [19, 20, 40, 41]], "labels": [1, 2], "scores": [0.9, 0.8]}]
|
|
59
|
+
coco_gt = {
|
|
60
|
+
"images": [{"id": 1, "width": 50, "height": 50}],
|
|
61
|
+
"annotations": [
|
|
62
|
+
{"id": 1, "image_id": 1, "category_id": 1, "bbox": [0, 0, 10, 10], "area": 100, "iscrowd": 0}
|
|
63
|
+
],
|
|
64
|
+
"categories": [{"id": 1, "name": "cell"}],
|
|
65
|
+
}
|
|
66
|
+
(tmp_path / "instances_val.json").write_text(json.dumps(coco_gt))
|
|
67
|
+
(tmp_path / "detections.json").write_text(
|
|
68
|
+
json.dumps([{"image_id": 1, "category_id": 1, "bbox": [0, 0, 10, 10], "score": 0.9}])
|
|
69
|
+
)
|
|
70
|
+
g = [rng.normal(m, 1, 30) for m in (0, 0.3, 0.6)]
|
|
71
|
+
return {
|
|
72
|
+
"es": es,
|
|
73
|
+
"y_true": y,
|
|
74
|
+
"y_pred": preds["lr"],
|
|
75
|
+
"y_prob": probs["lr"],
|
|
76
|
+
"prob": probs["lr"],
|
|
77
|
+
**{f"pred_{k}": v for k, v in preds.items()},
|
|
78
|
+
**{f"prob_{k}": v for k, v in probs.items()},
|
|
79
|
+
"scores_a": g[0],
|
|
80
|
+
"scores_b": g[1],
|
|
81
|
+
"scores_c": g[2],
|
|
82
|
+
"fold_scores_a": g[0],
|
|
83
|
+
"fold_scores_b": g[1],
|
|
84
|
+
"a": g[0],
|
|
85
|
+
"b": g[1],
|
|
86
|
+
"g1": g[0],
|
|
87
|
+
"g2": g[1],
|
|
88
|
+
"g3": g[2],
|
|
89
|
+
"residuals": g[0],
|
|
90
|
+
"table": np.array([[20, 10], [8, 25]]),
|
|
91
|
+
"p_values": [0.01, 0.04, 0.03, 0.2],
|
|
92
|
+
"y_reg": g[0],
|
|
93
|
+
"pred_reg": g[0] + rng.normal(0, 0.2, 30),
|
|
94
|
+
"masks": masks,
|
|
95
|
+
"image": rng.random((24, 24)),
|
|
96
|
+
"boxes_a": [[0, 0, 10, 10]],
|
|
97
|
+
"boxes_b": [[0, 0, 5, 5]],
|
|
98
|
+
"_vision": {"masks": masks, "boxes": boxes, "dets": dets},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
EXAMPLES = _blocks()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.parametrize(("heading", "code"), EXAMPLES, ids=[f"{i}-{h[:30]}" for i, (h, _) in enumerate(EXAMPLES)])
|
|
106
|
+
def test_readme_example_runs(heading: str, code: str, tmp_path, monkeypatch) -> None:
|
|
107
|
+
monkeypatch.chdir(tmp_path)
|
|
108
|
+
ns = _namespace(tmp_path)
|
|
109
|
+
vision = ns.pop("_vision")
|
|
110
|
+
if heading.lower().startswith("segmentation"):
|
|
111
|
+
ns["y_true"], ns["y_pred"] = vision["masks"], np.roll(vision["masks"], 1, axis=2)
|
|
112
|
+
if heading.lower().startswith("object detection"):
|
|
113
|
+
ns["y_true"], ns["y_pred"] = vision["boxes"], vision["dets"]
|
|
114
|
+
if heading.lower().startswith("classification report") or heading.lower().startswith("plots"):
|
|
115
|
+
ns["y_pred"] = (ns["y_prob"] >= 0.5).astype(int)
|
|
116
|
+
with warnings.catch_warnings():
|
|
117
|
+
warnings.simplefilter("ignore")
|
|
118
|
+
exec(compile(_runnable(code), f"README.md [{heading}]", "exec"), ns) # noqa: S102
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_readme_has_examples() -> None:
|
|
122
|
+
assert len(EXAMPLES) >= 8
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Security and maintenance checks on the repository itself (run in CI on every push)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
12
|
+
SRC = ROOT / "src" / "evalsuite"
|
|
13
|
+
|
|
14
|
+
SECRET = re.compile(
|
|
15
|
+
r"(github_pat_[A-Za-z0-9_]{20,}|gh[pousr]_[A-Za-z0-9]{30,}|pypi-AgEI[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|"
|
|
16
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{32,})"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _tracked_files() -> list[Path]:
|
|
21
|
+
try:
|
|
22
|
+
out = subprocess.run(
|
|
23
|
+
["git", "ls-files"], # noqa: S607
|
|
24
|
+
cwd=ROOT,
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
check=True,
|
|
28
|
+
).stdout
|
|
29
|
+
return [ROOT / line for line in out.splitlines() if line]
|
|
30
|
+
except (OSError, subprocess.CalledProcessError): # sdist without .git: scan what is there
|
|
31
|
+
return [p for p in ROOT.rglob("*") if p.is_file() and "__pycache__" not in p.parts]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_no_credentials_in_repository() -> None:
|
|
35
|
+
leaks = []
|
|
36
|
+
for path in _tracked_files():
|
|
37
|
+
if not path.is_file() or path.stat().st_size > 2_000_000:
|
|
38
|
+
continue
|
|
39
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
40
|
+
if SECRET.search(text):
|
|
41
|
+
leaks.append(str(path.relative_to(ROOT)))
|
|
42
|
+
assert not leaks, f"possible credentials committed: {leaks}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.parametrize(
|
|
46
|
+
"pattern",
|
|
47
|
+
[
|
|
48
|
+
r"\bpickle\b",
|
|
49
|
+
r"(?<![\w.])eval\(",
|
|
50
|
+
r"(?<![\w.])exec\(",
|
|
51
|
+
r"\bsubprocess\b",
|
|
52
|
+
r"shell\s*=\s*True",
|
|
53
|
+
r"yaml\.load\(",
|
|
54
|
+
r"allow_pickle\s*=\s*True",
|
|
55
|
+
r"__import__\(",
|
|
56
|
+
],
|
|
57
|
+
)
|
|
58
|
+
def test_no_unsafe_calls_in_package(pattern: str) -> None:
|
|
59
|
+
hits = [
|
|
60
|
+
f"{p.relative_to(ROOT)}:{i}"
|
|
61
|
+
for p in SRC.rglob("*.py")
|
|
62
|
+
for i, line in enumerate(p.read_text(encoding="utf-8").splitlines(), 1)
|
|
63
|
+
if re.search(pattern, line.split("#", 1)[0])
|
|
64
|
+
]
|
|
65
|
+
assert not hits, f"unsafe call {pattern!r} in {hits}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_numpy_loads_never_allow_pickle() -> None:
|
|
69
|
+
for p in SRC.rglob("*.py"):
|
|
70
|
+
for line in p.read_text(encoding="utf-8").splitlines():
|
|
71
|
+
if "np.load(" in line:
|
|
72
|
+
assert "allow_pickle=False" in line, f"{p.name}: {line.strip()}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_workflows_use_least_privilege() -> None:
|
|
76
|
+
workflows = list((ROOT / ".github" / "workflows").glob("*.yml"))
|
|
77
|
+
if not workflows:
|
|
78
|
+
pytest.skip("workflows are not shipped in the sdist")
|
|
79
|
+
for wf in workflows:
|
|
80
|
+
text = wf.read_text(encoding="utf-8")
|
|
81
|
+
assert re.search(r"^permissions:\s*\n\s+contents: read", text, re.M), (
|
|
82
|
+
f"{wf.name}: default must be read-only"
|
|
83
|
+
)
|
|
84
|
+
assert "pull_request_target" not in text, (
|
|
85
|
+
f"{wf.name}: pull_request_target runs untrusted code with secrets"
|
|
86
|
+
)
|
|
87
|
+
assert "password:" not in text and "PYPI_API_TOKEN" not in text, f"{wf.name}: use Trusted Publishing"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_single_version_source() -> None:
|
|
91
|
+
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
|
92
|
+
assert 'dynamic = ["version"]' in pyproject and 'path = "src/evalsuite/version.py"' in pyproject
|
|
93
|
+
import evalsuite
|
|
94
|
+
|
|
95
|
+
version = re.search(r'__version__ = "(.+)"', (SRC / "version.py").read_text()).group(1)
|
|
96
|
+
assert evalsuite.__version__ == version
|
|
97
|
+
assert f"## [{version}]" in (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
@@ -46,3 +46,25 @@ def test_v020_benchmarks_agree_with_their_references() -> None:
|
|
|
46
46
|
assert row["reference_ms"] > 0 and row["max_abs_diff"] < 1e-9, row["case"]
|
|
47
47
|
assert "statsmodels" in b.summary().splitlines()[0]
|
|
48
48
|
assert len(run_benchmarks(sizes=(300,), repeat=1, suite="all").rows) == 15
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_overall_summary_and_alphabetical_rows() -> None:
|
|
52
|
+
b = run_benchmarks(sizes=(300,), repeat=1, suite="all")
|
|
53
|
+
overall = b.overall()
|
|
54
|
+
assert [o["group"] for o in overall] == [
|
|
55
|
+
"Classification and regression",
|
|
56
|
+
"Clinical, calibration and statistics",
|
|
57
|
+
"Segmentation and object detection",
|
|
58
|
+
"Overall",
|
|
59
|
+
]
|
|
60
|
+
total = overall[-1]
|
|
61
|
+
assert total["rows"] == 15 == sum(o["rows"] for o in overall[:-1])
|
|
62
|
+
assert total["matching"] == total["compared"] # every compared case agrees with its reference
|
|
63
|
+
if total["compared"]:
|
|
64
|
+
assert total["min_speedup"] <= total["geomean_speedup"] <= total["max_speedup"]
|
|
65
|
+
cases = [r["case"].lower() for r in b.sorted_rows()]
|
|
66
|
+
assert cases == sorted(cases)
|
|
67
|
+
text = b.summary()
|
|
68
|
+
assert text.index("Overall") < text.index("Case") # the overall table comes first
|
|
69
|
+
assert b.overall_markdown().startswith("| Suite |")
|
|
70
|
+
assert json.loads(b.to_json())["overall"][-1]["group"] == "Overall"
|
|
@@ -124,3 +124,13 @@ def test_evaluate_points_vision_inputs_to_the_right_function() -> None:
|
|
|
124
124
|
es.evaluate([{"boxes": [[0, 0, 1, 1]], "labels": [1]}], [{"boxes": [], "labels": []}])
|
|
125
125
|
with pytest.raises(es.UnsupportedTaskError, match="segmentation_report"):
|
|
126
126
|
es.evaluate(np.zeros((2, 3, 3), int), np.zeros((2, 3, 3), int))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_nested_list_is_one_2d_mask() -> None: # 0.3.1 fix
|
|
130
|
+
as_list = es.dice([[1, 1], [0, 0]], [[1, 0], [0, 0]], average=None).value
|
|
131
|
+
as_array = es.dice(np.array([[1, 1], [0, 0]]), np.array([[1, 0], [0, 0]]), average=None).value
|
|
132
|
+
np.testing.assert_array_equal(as_list, as_array)
|
|
133
|
+
stacked = es.dice([[[1, 1], [0, 0]], [[1, 0], [0, 0]]], [[[1, 1], [0, 0]], [[1, 0], [0, 0]]])
|
|
134
|
+
assert float(stacked) == 1.0 # a list of 2-D masks is still a list of images
|
|
135
|
+
with pytest.raises(es.InputValidationError):
|
|
136
|
+
es.dice([1, 0, 1], [1, 0, 1])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/classification/test_against_sklearn.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|