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.
Files changed (75) hide show
  1. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/CHANGELOG.md +29 -0
  2. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/PKG-INFO +25 -21
  3. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/README.md +23 -19
  4. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/pyproject.toml +1 -1
  5. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/benchmarks.py +95 -4
  6. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/validation.py +21 -0
  7. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/regression/metrics.py +9 -1
  8. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/_resolve.py +6 -1
  9. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/version.py +1 -1
  10. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/vision/segmentation.py +9 -1
  11. evalsuite_python-0.3.1/tests/integration/test_qa.py +311 -0
  12. evalsuite_python-0.3.1/tests/integration/test_readme_examples.py +122 -0
  13. evalsuite_python-0.3.1/tests/integration/test_repo_hygiene.py +97 -0
  14. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/output/test_benchmarks.py +22 -0
  15. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/vision/test_edges.py +10 -0
  16. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/.gitignore +0 -0
  17. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/CONTRIBUTING.md +0 -0
  18. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/LICENSE +0 -0
  19. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/__init__.py +0 -0
  20. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/__main__.py +0 -0
  21. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/api.py +0 -0
  22. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/calibration.py +0 -0
  23. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/classification/__init__.py +0 -0
  24. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/classification/_common.py +0 -0
  25. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/classification/metrics.py +0 -0
  26. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/cli/__init__.py +0 -0
  27. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/cli/main.py +0 -0
  28. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/clinical/__init__.py +0 -0
  29. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/clinical/metrics.py +0 -0
  30. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/clinical/report.py +0 -0
  31. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/__init__.py +0 -0
  32. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/context.py +0 -0
  33. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/exceptions.py +0 -0
  34. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/export.py +0 -0
  35. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/registry.py +0 -0
  36. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/result.py +0 -0
  37. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/core/types.py +0 -0
  38. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/plot.py +0 -0
  39. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/py.typed +0 -0
  40. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/regression/__init__.py +0 -0
  41. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/reporting.py +0 -0
  42. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/__init__.py +0 -0
  43. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/compare.py +0 -0
  44. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/effect.py +0 -0
  45. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/hypothesis.py +0 -0
  46. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/intervals.py +0 -0
  47. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/paired.py +0 -0
  48. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/stats/results.py +0 -0
  49. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/vision/__init__.py +0 -0
  50. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/src/evalsuite/vision/detection.py +0 -0
  51. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/__init__.py +0 -0
  52. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/classification/__init__.py +0 -0
  53. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/classification/test_against_sklearn.py +0 -0
  54. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/clinical/__init__.py +0 -0
  55. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/clinical/test_outputs.py +0 -0
  56. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/clinical/test_v020.py +0 -0
  57. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/conftest.py +0 -0
  58. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/integration/__init__.py +0 -0
  59. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/output/__init__.py +0 -0
  60. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/output/test_cli.py +0 -0
  61. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/output/test_plot.py +0 -0
  62. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/output/test_reporting.py +0 -0
  63. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/regression/__init__.py +0 -0
  64. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/regression/test_against_sklearn.py +0 -0
  65. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/stats/__init__.py +0 -0
  66. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/stats/test_branches.py +0 -0
  67. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/stats/test_compare.py +0 -0
  68. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/stats/test_reference.py +0 -0
  69. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/unit/__init__.py +0 -0
  70. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/unit/test_core.py +0 -0
  71. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/unit/test_edges.py +0 -0
  72. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/vision/__init__.py +0 -0
  73. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/vision/test_detection.py +0 -0
  74. {evalsuite_python-0.3.0 → evalsuite_python-0.3.1}/tests/vision/test_outputs.py +0 -0
  75. {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.0
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.2; extra == 'dev'
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.0).** Every item on the 0.1.0, 0.2.0 and 0.3.0 roadmaps is implemented and verified.
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). Every result agrees with the reference to floating-point rounding (largest difference
287
- 1.4e-14).
288
-
289
- | Case | n | Reference | EvalSuite (ms) | Reference (ms) | Speed-up |
290
- | --- | ---: | --- | ---: | ---: | ---: |
291
- | 8 binary label metrics via `evaluate()` | 1,000,000 | scikit-learn | 30.2 | 1020.2 | **33.8×** |
292
- | macro F1, 10 classes | 1,000,000 | scikit-learn | 22.4 | 128.7 | **5.8×** |
293
- | ROC AUC, binary | 1,000,000 | scikit-learn | 173.2 | 300.6 | **1.7×** |
294
- | MAE, MSE, RMSE, R² via `evaluate()` | 1,000,000 | scikit-learn | 19.1 | 9.7 | 0.51× |
295
- | sensitivity, specificity, LR+, LR− | 1,000,000 | scikit-learn | 66.2 | 392.8 | **5.9×** |
296
- | calibration slope and intercept | 1,000,000 | statsmodels | 178.0 | 1014.8 | **5.7×** |
297
- | decision curve, 99 thresholds | 1,000,000 | NumPy loop | 155.2 | 174.3 | **1.1×** |
298
- | diagnostic report (7 CIs) | 1,000,000 | statsmodels | 16.6 | 4.6 | 0.28× |
299
- | Welch t-test | 1,000,000 | SciPy | 14.5 | 7.3 | 0.51× |
300
- | Hochberg correction | 1,000,000 | statsmodels | 75.4 | 81.9 | **1.1×** |
301
- | segmentation Dice and IoU per class | 1,000,000 px | scikit-learn | 40.2 | 282.0 | **7.0×** |
302
- | COCO detection evaluation (12 numbers) | 1,000 images | pycocotools | 967.4 | 980.6 | **1.0×** |
303
- | Hausdorff distance | 50 images | SciPy | 26.2 | 20.8 | 0.79× |
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.0).** Every item on the 0.1.0, 0.2.0 and 0.3.0 roadmaps is implemented and verified.
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). Every result agrees with the reference to floating-point rounding (largest difference
227
- 1.4e-14).
228
-
229
- | Case | n | Reference | EvalSuite (ms) | Reference (ms) | Speed-up |
230
- | --- | ---: | --- | ---: | ---: | ---: |
231
- | 8 binary label metrics via `evaluate()` | 1,000,000 | scikit-learn | 30.2 | 1020.2 | **33.8×** |
232
- | macro F1, 10 classes | 1,000,000 | scikit-learn | 22.4 | 128.7 | **5.8×** |
233
- | ROC AUC, binary | 1,000,000 | scikit-learn | 173.2 | 300.6 | **1.7×** |
234
- | MAE, MSE, RMSE, R² via `evaluate()` | 1,000,000 | scikit-learn | 19.1 | 9.7 | 0.51× |
235
- | sensitivity, specificity, LR+, LR− | 1,000,000 | scikit-learn | 66.2 | 392.8 | **5.9×** |
236
- | calibration slope and intercept | 1,000,000 | statsmodels | 178.0 | 1014.8 | **5.7×** |
237
- | decision curve, 99 thresholds | 1,000,000 | NumPy loop | 155.2 | 174.3 | **1.1×** |
238
- | diagnostic report (7 CIs) | 1,000,000 | statsmodels | 16.6 | 4.6 | 0.28× |
239
- | Welch t-test | 1,000,000 | SciPy | 14.5 | 7.3 | 0.51× |
240
- | Hochberg correction | 1,000,000 | statsmodels | 75.4 | 81.9 | **1.1×** |
241
- | segmentation Dice and IoU per class | 1,000,000 px | scikit-learn | 40.2 | 282.0 | **7.0×** |
242
- | COCO detection evaluation (12 numbers) | 1,000 images | pycocotools | 967.4 | 980.6 | **1.0×** |
243
- | Hausdorff distance | 50 images | SciPy | 26.2 | 20.8 | 0.79× |
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
@@ -43,7 +43,7 @@ dev = [
43
43
  "pytest>=7",
44
44
  "pytest-cov>=4",
45
45
  "hypothesis>=6.80",
46
- "scikit-learn>=1.2",
46
+ "scikit-learn>=1.4",
47
47
  "statsmodels>=0.13",
48
48
  "matplotlib>=3.5",
49
49
  "pillow>=9",
@@ -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.rows
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
- return "\n".join([head, "", line(self._TITLES), *(line(c) for c in cells), "", note])
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({"environment": dict(self.environment), "rows": [dict(r) for r in self.rows]}),
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
- __import__(mod)
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
- return _finish(np.sqrt(inp.mean(inp.error**2)), inp, multioutput, "rmse", "RMSE")
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
@@ -1,3 +1,3 @@
1
1
  """Package version (single source of truth, read by the build backend)."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -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
- else:
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 "&lt;script&gt;" 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])