evalsuite-python 0.2.1__py3-none-any.whl → 0.3.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- evalsuite/__init__.py +42 -1
- evalsuite/api.py +10 -0
- evalsuite/benchmarks.py +252 -10
- evalsuite/calibration.py +1 -1
- evalsuite/cli/main.py +99 -2
- evalsuite/core/validation.py +21 -0
- evalsuite/plot.py +165 -1
- evalsuite/regression/metrics.py +9 -1
- evalsuite/stats/_resolve.py +32 -4
- evalsuite/stats/compare.py +6 -4
- evalsuite/version.py +1 -1
- evalsuite/vision/__init__.py +47 -0
- evalsuite/vision/detection.py +648 -0
- evalsuite/vision/segmentation.py +973 -0
- {evalsuite_python-0.2.1.dist-info → evalsuite_python-0.3.1.dist-info}/METADATA +82 -21
- {evalsuite_python-0.2.1.dist-info → evalsuite_python-0.3.1.dist-info}/RECORD +19 -16
- {evalsuite_python-0.2.1.dist-info → evalsuite_python-0.3.1.dist-info}/WHEEL +0 -0
- {evalsuite_python-0.2.1.dist-info → evalsuite_python-0.3.1.dist-info}/entry_points.txt +0 -0
- {evalsuite_python-0.2.1.dist-info → evalsuite_python-0.3.1.dist-info}/licenses/LICENSE +0 -0
evalsuite/__init__.py
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
>>> print(result.summary()) # doctest: +SKIP
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
from . import calibration, classification, clinical, plot, regression, stats
|
|
8
|
+
from . import calibration, classification, clinical, plot, regression, stats, vision
|
|
9
9
|
from .api import evaluate
|
|
10
10
|
from .calibration import (
|
|
11
11
|
CalibrationReport,
|
|
@@ -111,8 +111,49 @@ from .stats import (
|
|
|
111
111
|
wilcoxon_test,
|
|
112
112
|
)
|
|
113
113
|
from .version import __version__
|
|
114
|
+
from .vision import (
|
|
115
|
+
DetectionReport,
|
|
116
|
+
SegmentationReport,
|
|
117
|
+
average_precision_detection,
|
|
118
|
+
average_surface_distance,
|
|
119
|
+
boundary_iou,
|
|
120
|
+
box_iou,
|
|
121
|
+
detection_pr_curve,
|
|
122
|
+
detection_report,
|
|
123
|
+
dice,
|
|
124
|
+
from_coco,
|
|
125
|
+
hausdorff_distance,
|
|
126
|
+
iou,
|
|
127
|
+
mean_average_precision,
|
|
128
|
+
mean_pixel_accuracy,
|
|
129
|
+
miou,
|
|
130
|
+
per_image_scores,
|
|
131
|
+
pixel_accuracy,
|
|
132
|
+
segmentation_confusion,
|
|
133
|
+
segmentation_report,
|
|
134
|
+
)
|
|
114
135
|
|
|
115
136
|
__all__ = [
|
|
137
|
+
"SegmentationReport",
|
|
138
|
+
"segmentation_report",
|
|
139
|
+
"detection_pr_curve",
|
|
140
|
+
"vision",
|
|
141
|
+
"DetectionReport",
|
|
142
|
+
"average_precision_detection",
|
|
143
|
+
"average_surface_distance",
|
|
144
|
+
"boundary_iou",
|
|
145
|
+
"box_iou",
|
|
146
|
+
"detection_report",
|
|
147
|
+
"dice",
|
|
148
|
+
"from_coco",
|
|
149
|
+
"hausdorff_distance",
|
|
150
|
+
"iou",
|
|
151
|
+
"mean_average_precision",
|
|
152
|
+
"mean_pixel_accuracy",
|
|
153
|
+
"miou",
|
|
154
|
+
"per_image_scores",
|
|
155
|
+
"pixel_accuracy",
|
|
156
|
+
"segmentation_confusion",
|
|
116
157
|
"CalibrationReport",
|
|
117
158
|
"calibration_report",
|
|
118
159
|
"calibration",
|
evalsuite/api.py
CHANGED
|
@@ -149,6 +149,16 @@ def evaluate(
|
|
|
149
149
|
>>> round(r["accuracy"], 2)
|
|
150
150
|
0.75
|
|
151
151
|
"""
|
|
152
|
+
if isinstance(y_true, (list, tuple)) and y_true and isinstance(y_true[0], dict):
|
|
153
|
+
raise UnsupportedTaskError(
|
|
154
|
+
"y_true looks like object detection annotations (one dict per image); use "
|
|
155
|
+
"evalsuite.detection_report(y_true, y_pred) or evalsuite.mean_average_precision(...)."
|
|
156
|
+
)
|
|
157
|
+
if np.ndim(y_true) >= 3:
|
|
158
|
+
raise UnsupportedTaskError(
|
|
159
|
+
"y_true has 3 or more dimensions, which looks like segmentation masks (images first); use "
|
|
160
|
+
"evalsuite.segmentation_report(y_true, y_pred) or evalsuite.dice / evalsuite.iou."
|
|
161
|
+
)
|
|
152
162
|
task = task or _infer_task(y_true, y_prob)
|
|
153
163
|
if task == "classification":
|
|
154
164
|
return _evaluate_classification(
|
evalsuite/benchmarks.py
CHANGED
|
@@ -6,7 +6,8 @@ quantities on the same data, and the largest absolute difference between their r
|
|
|
6
6
|
speed is never shown for numbers that disagree.
|
|
7
7
|
|
|
8
8
|
References: scikit-learn for classification and regression (``suite="core"``); scikit-learn, statsmodels
|
|
9
|
-
and SciPy for the v0.2.0 clinical, calibration and statistics functions (``suite="clinical"``)
|
|
9
|
+
and SciPy for the v0.2.0 clinical, calibration and statistics functions (``suite="clinical"``);
|
|
10
|
+
scikit-learn, SciPy and pycocotools for segmentation and detection (``suite="vision"``). A case
|
|
10
11
|
whose reference library is not installed is timed for EvalSuite only.
|
|
11
12
|
|
|
12
13
|
>>> from evalsuite.benchmarks import run_benchmarks
|
|
@@ -16,8 +17,10 @@ whose reference library is not installed is timed for EvalSuite only.
|
|
|
16
17
|
from __future__ import annotations
|
|
17
18
|
|
|
18
19
|
import contextlib
|
|
20
|
+
import importlib
|
|
19
21
|
import json
|
|
20
22
|
import platform
|
|
23
|
+
import re
|
|
21
24
|
import time
|
|
22
25
|
import tracemalloc
|
|
23
26
|
import warnings
|
|
@@ -120,6 +123,145 @@ def _core_cases(n: int, rng: np.random.Generator) -> list[Case]:
|
|
|
120
123
|
return [(name, ref, es_fn, sk_fn) for (name, ref, es_fn, _), sk_fn in zip(cases, sk)]
|
|
121
124
|
|
|
122
125
|
|
|
126
|
+
def _vision_cases(n: int, rng: np.random.Generator) -> list[Case]:
|
|
127
|
+
"""v0.3.0: segmentation overlap and surface distance (n = pixels) and COCO detection (n / 1000 images)."""
|
|
128
|
+
import evalsuite as es
|
|
129
|
+
|
|
130
|
+
side = 64
|
|
131
|
+
n_img = max(1, n // (side * side))
|
|
132
|
+
k = 5
|
|
133
|
+
yy, xx = np.ogrid[:side, :side]
|
|
134
|
+
true = np.zeros((n_img, side, side), dtype=np.int64)
|
|
135
|
+
for i in range(n_img):
|
|
136
|
+
for c in range(1, k):
|
|
137
|
+
cy, cx, r = rng.integers(8, side - 8), rng.integers(8, side - 8), rng.integers(4, 14)
|
|
138
|
+
true[i][(yy - cy) ** 2 + (xx - cx) ** 2 <= r * r] = c
|
|
139
|
+
pred = np.roll(true, 1, axis=2)
|
|
140
|
+
noise = rng.random(pred.shape) < 0.02
|
|
141
|
+
pred[noise] = rng.integers(0, k, int(noise.sum()))
|
|
142
|
+
labels = list(range(k))
|
|
143
|
+
|
|
144
|
+
def es_overlap() -> list[float]:
|
|
145
|
+
d = np.asarray(es.dice(true, pred, average=None).value)
|
|
146
|
+
j = np.asarray(es.iou(true, pred, average=None).value)
|
|
147
|
+
return [*d, *j]
|
|
148
|
+
|
|
149
|
+
n_hd = min(n_img, 50)
|
|
150
|
+
|
|
151
|
+
def es_hd() -> list[float]:
|
|
152
|
+
return [float(es.hausdorff_distance(true[i], pred[i], labels=[k - 1])) for i in range(n_hd)]
|
|
153
|
+
|
|
154
|
+
n_det = max(10, n // 1000)
|
|
155
|
+
y_true, y_pred = [], []
|
|
156
|
+
for _ in range(n_det):
|
|
157
|
+
m = int(rng.integers(1, 8))
|
|
158
|
+
xy = rng.uniform(0, 500, (m, 2))
|
|
159
|
+
wh = rng.uniform(8, 160, (m, 2))
|
|
160
|
+
boxes = np.column_stack([xy, xy + wh])
|
|
161
|
+
lab = rng.integers(1, 6, m)
|
|
162
|
+
y_true.append({"boxes": boxes, "labels": lab, "iscrowd": np.zeros(m, int)})
|
|
163
|
+
jitter = boxes + rng.normal(0, 4, boxes.shape)
|
|
164
|
+
jitter[:, 2:] = np.maximum(jitter[:, 2:], jitter[:, :2] + 1)
|
|
165
|
+
extra = rng.uniform(0, 500, (3, 2))
|
|
166
|
+
fp = np.column_stack([extra, extra + rng.uniform(10, 80, (3, 2))])
|
|
167
|
+
y_pred.append(
|
|
168
|
+
{
|
|
169
|
+
"boxes": np.vstack([jitter, fp]),
|
|
170
|
+
"labels": np.r_[lab, rng.integers(1, 6, 3)],
|
|
171
|
+
"scores": rng.random(m + 3),
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
def es_map() -> list[float]:
|
|
176
|
+
r = es.detection_report(y_true, y_pred)
|
|
177
|
+
return [r["map"], r["map_50"], r["map_75"], r["mar_100"]]
|
|
178
|
+
|
|
179
|
+
cases: list[Case] = [
|
|
180
|
+
("segmentation: Dice and IoU per class (n = pixels)", "scikit-learn", es_overlap, None),
|
|
181
|
+
(f"segmentation: Hausdorff distance ({n_hd} image{'s' if n_hd != 1 else ''})", "SciPy", es_hd, None),
|
|
182
|
+
(f"detection: COCO evaluation ({n_det} images)", "pycocotools", es_map, None),
|
|
183
|
+
]
|
|
184
|
+
refs: dict[str, Callable[[], Any]] = {}
|
|
185
|
+
try:
|
|
186
|
+
import sklearn.metrics as skm
|
|
187
|
+
|
|
188
|
+
def sk_overlap() -> list[float]:
|
|
189
|
+
ft, fp_ = true.ravel(), pred.ravel()
|
|
190
|
+
return [
|
|
191
|
+
*skm.f1_score(ft, fp_, labels=labels, average=None),
|
|
192
|
+
*skm.jaccard_score(ft, fp_, labels=labels, average=None),
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
refs[cases[0][0]] = sk_overlap
|
|
196
|
+
except ImportError:
|
|
197
|
+
pass
|
|
198
|
+
from scipy import ndimage
|
|
199
|
+
from scipy.spatial.distance import directed_hausdorff
|
|
200
|
+
|
|
201
|
+
def scipy_hd() -> list[float]:
|
|
202
|
+
out = []
|
|
203
|
+
for i in range(n_hd):
|
|
204
|
+
pts = []
|
|
205
|
+
for m_ in (true[i] == k - 1, pred[i] == k - 1):
|
|
206
|
+
er = ndimage.binary_erosion(m_, structure=ndimage.generate_binary_structure(2, 1), border_value=0)
|
|
207
|
+
pts.append(np.argwhere(m_ & ~er).astype(float))
|
|
208
|
+
out.append(max(directed_hausdorff(pts[0], pts[1])[0], directed_hausdorff(pts[1], pts[0])[0]))
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
refs[cases[1][0]] = scipy_hd
|
|
212
|
+
try:
|
|
213
|
+
import contextlib as _ctx
|
|
214
|
+
import io
|
|
215
|
+
|
|
216
|
+
from pycocotools.coco import COCO # type: ignore[import-untyped]
|
|
217
|
+
from pycocotools.cocoeval import COCOeval # type: ignore[import-untyped]
|
|
218
|
+
|
|
219
|
+
def coco_map() -> list[float]:
|
|
220
|
+
images, anns, dets, aid = [], [], [], 1
|
|
221
|
+
for i, (t, p) in enumerate(zip(y_true, y_pred)):
|
|
222
|
+
images.append({"id": i + 1})
|
|
223
|
+
for b, c in zip(t["boxes"], t["labels"]):
|
|
224
|
+
w, h = b[2] - b[0], b[3] - b[1]
|
|
225
|
+
anns.append(
|
|
226
|
+
{
|
|
227
|
+
"id": aid,
|
|
228
|
+
"image_id": i + 1,
|
|
229
|
+
"category_id": int(c),
|
|
230
|
+
"bbox": [b[0], b[1], w, h],
|
|
231
|
+
"area": w * h,
|
|
232
|
+
"iscrowd": 0,
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
aid += 1
|
|
236
|
+
for b, c, sc in zip(p["boxes"], p["labels"], p["scores"]):
|
|
237
|
+
dets.append(
|
|
238
|
+
{
|
|
239
|
+
"image_id": i + 1,
|
|
240
|
+
"category_id": int(c),
|
|
241
|
+
"bbox": [b[0], b[1], b[2] - b[0], b[3] - b[1]],
|
|
242
|
+
"score": float(sc),
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
with _ctx.redirect_stdout(io.StringIO()):
|
|
246
|
+
gt = COCO()
|
|
247
|
+
gt.dataset = {
|
|
248
|
+
"images": images,
|
|
249
|
+
"annotations": anns,
|
|
250
|
+
"categories": [{"id": c} for c in range(1, 6)],
|
|
251
|
+
}
|
|
252
|
+
gt.createIndex()
|
|
253
|
+
ev = COCOeval(gt, gt.loadRes(dets), "bbox")
|
|
254
|
+
ev.evaluate()
|
|
255
|
+
ev.accumulate()
|
|
256
|
+
ev.summarize()
|
|
257
|
+
return [ev.stats[0], ev.stats[1], ev.stats[2], ev.stats[8]]
|
|
258
|
+
|
|
259
|
+
refs[cases[2][0]] = coco_map
|
|
260
|
+
except ImportError:
|
|
261
|
+
pass
|
|
262
|
+
return [(name, ref, es_fn, refs.get(name)) for name, ref, es_fn, _ in cases]
|
|
263
|
+
|
|
264
|
+
|
|
123
265
|
def _clinical_cases(n: int, rng: np.random.Generator) -> list[Case]:
|
|
124
266
|
"""v0.2.0: diagnostic accuracy, calibration, decision curves and statistical tests."""
|
|
125
267
|
import evalsuite as es
|
|
@@ -231,6 +373,28 @@ def _clinical_cases(n: int, rng: np.random.Generator) -> list[Case]:
|
|
|
231
373
|
return [(name, ref, es_fn, own if own is not None else refs.get(name)) for name, ref, es_fn, own in cases]
|
|
232
374
|
|
|
233
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
|
+
|
|
234
398
|
@dataclass(frozen=True, eq=False)
|
|
235
399
|
class BenchmarkResult:
|
|
236
400
|
"""Rows of timings (milliseconds), peak memory (MiB) and agreement, plus the environment they ran in."""
|
|
@@ -242,6 +406,60 @@ class BenchmarkResult:
|
|
|
242
406
|
object.__setattr__(self, "rows", tuple(MappingProxyType(dict(r)) for r in self.rows))
|
|
243
407
|
object.__setattr__(self, "environment", MappingProxyType(dict(self.environment)))
|
|
244
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
|
+
|
|
245
463
|
def _cells(self, digits: int) -> list[list[str]]:
|
|
246
464
|
def f(v: Any, d: int = digits) -> str:
|
|
247
465
|
return "–" if v is None else f"{v:.{d}f}"
|
|
@@ -258,7 +476,7 @@ class BenchmarkResult:
|
|
|
258
476
|
f(r.get("reference_peak_mb"), 2),
|
|
259
477
|
"–" if r["max_abs_diff"] is None else f"{r['max_abs_diff']:.1e}",
|
|
260
478
|
]
|
|
261
|
-
for r in self.
|
|
479
|
+
for r in self.sorted_rows()
|
|
262
480
|
]
|
|
263
481
|
|
|
264
482
|
_TITLES = (
|
|
@@ -286,10 +504,18 @@ class BenchmarkResult:
|
|
|
286
504
|
+ (f" | scikit-learn {env['sklearn']}" if env.get("sklearn") else "")
|
|
287
505
|
+ (f" | statsmodels {env['statsmodels']}" if env.get("statsmodels") else "")
|
|
288
506
|
+ (f" | SciPy {env['scipy']}" if env.get("scipy") else "")
|
|
507
|
+
+ (f" | pycocotools {env['pycocotools']}" if env.get("pycocotools") else "")
|
|
289
508
|
+ f" | {env['machine']} | fastest of {env['repeat']} runs"
|
|
290
509
|
)
|
|
291
510
|
note = "Speed-up > 1 means EvalSuite is faster. Max |difference| compares EvalSuite with the reference."
|
|
292
|
-
|
|
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])
|
|
293
519
|
|
|
294
520
|
def __repr__(self) -> str:
|
|
295
521
|
return self.summary()
|
|
@@ -297,7 +523,13 @@ class BenchmarkResult:
|
|
|
297
523
|
def to_dict(self) -> dict[str, Any]:
|
|
298
524
|
return cast(
|
|
299
525
|
"dict[str, Any]",
|
|
300
|
-
_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
|
+
),
|
|
301
533
|
)
|
|
302
534
|
|
|
303
535
|
def to_json(self, *, indent: Optional[int] = 2) -> str:
|
|
@@ -354,7 +586,8 @@ def run_benchmarks(
|
|
|
354
586
|
"""Time and memory for evaluation workloads at each size, against a reference implementation.
|
|
355
587
|
|
|
356
588
|
``suite``: ``"core"`` (classification and regression vs scikit-learn), ``"clinical"`` (v0.2.0 clinical,
|
|
357
|
-
calibration and statistics vs scikit-learn, statsmodels, SciPy)
|
|
589
|
+
calibration and statistics vs scikit-learn, statsmodels, SciPy), ``"vision"`` (segmentation and COCO
|
|
590
|
+
detection vs scikit-learn, SciPy, pycocotools) or ``"all"`` (default).
|
|
358
591
|
``compare_sklearn=False`` times EvalSuite alone. Rows keep ``sklearn_ms``/``sklearn_peak_mb`` for rows
|
|
359
592
|
whose reference is scikit-learn, for compatibility with 0.1.x.
|
|
360
593
|
"""
|
|
@@ -364,16 +597,24 @@ def run_benchmarks(
|
|
|
364
597
|
|
|
365
598
|
if repeat < 1:
|
|
366
599
|
raise ValueError("repeat must be at least 1.")
|
|
367
|
-
if suite not in ("all", "core", "clinical"):
|
|
368
|
-
raise ValueError("suite must be 'all', 'core' or '
|
|
600
|
+
if suite not in ("all", "core", "clinical", "vision"):
|
|
601
|
+
raise ValueError("suite must be 'all', 'core', 'clinical' or 'vision'.")
|
|
369
602
|
rng = np.random.default_rng(random_state)
|
|
370
603
|
rows: list[dict[str, Any]] = []
|
|
371
|
-
versions: dict[str, Optional[str]] = {"sklearn": None, "statsmodels": None}
|
|
604
|
+
versions: dict[str, Optional[str]] = {"sklearn": None, "statsmodels": None, "pycocotools": None}
|
|
372
605
|
if compare_sklearn:
|
|
373
606
|
for mod in versions:
|
|
374
607
|
with contextlib.suppress(ImportError):
|
|
375
|
-
|
|
376
|
-
|
|
608
|
+
importlib.import_module(mod) # fixed names only: sklearn, statsmodels, pycocotools
|
|
609
|
+
from importlib.metadata import version as _dist_version
|
|
610
|
+
|
|
611
|
+
versions[mod] = _dist_version("scikit-learn" if mod == "sklearn" else mod)
|
|
612
|
+
builders = {
|
|
613
|
+
"core": [_core_cases],
|
|
614
|
+
"clinical": [_clinical_cases],
|
|
615
|
+
"vision": [_vision_cases],
|
|
616
|
+
"all": [_core_cases, _clinical_cases, _vision_cases],
|
|
617
|
+
}[suite]
|
|
377
618
|
for n in sizes:
|
|
378
619
|
for build in builders:
|
|
379
620
|
for name, ref_name, es_fn, ref_fn in build(int(n), rng):
|
|
@@ -410,6 +651,7 @@ def run_benchmarks(
|
|
|
410
651
|
"scipy": scipy.__version__ if suite != "core" else None,
|
|
411
652
|
"sklearn": versions["sklearn"],
|
|
412
653
|
"statsmodels": versions["statsmodels"] if suite != "core" else None,
|
|
654
|
+
"pycocotools": versions["pycocotools"] if suite in ("all", "vision") else None,
|
|
413
655
|
"machine": f"{platform.system()} {platform.machine()}",
|
|
414
656
|
"repeat": repeat,
|
|
415
657
|
"suite": suite,
|
evalsuite/calibration.py
CHANGED
|
@@ -128,7 +128,7 @@ def logistic_fit(
|
|
|
128
128
|
beta = np.zeros(design.shape[1])
|
|
129
129
|
for _ in range(max_iter):
|
|
130
130
|
eta = design @ beta + off
|
|
131
|
-
mu = 1 / (1 + np.exp(-eta))
|
|
131
|
+
mu = 1 / (1 + np.exp(-np.clip(eta, -700, 700)))
|
|
132
132
|
grad = design.T @ (w * (y - mu))
|
|
133
133
|
hess = (design * (w * mu * (1 - mu))[:, None]).T @ design
|
|
134
134
|
try:
|
evalsuite/cli/main.py
CHANGED
|
@@ -226,6 +226,77 @@ def cmd_calibration(args: argparse.Namespace) -> int:
|
|
|
226
226
|
return 0
|
|
227
227
|
|
|
228
228
|
|
|
229
|
+
def read_masks(path: str, name: str) -> Any:
|
|
230
|
+
"""Masks from .npy / .npz (first array, or the array named like the file stem) or a folder of image
|
|
231
|
+
files (PNG, TIFF, ...; read with Pillow, sorted by file name)."""
|
|
232
|
+
import numpy as np
|
|
233
|
+
|
|
234
|
+
if not os.path.exists(path):
|
|
235
|
+
raise CLIError(f"{name}: no such file or folder: {path}")
|
|
236
|
+
if os.path.isdir(path):
|
|
237
|
+
try:
|
|
238
|
+
from PIL import Image
|
|
239
|
+
except ImportError as exc:
|
|
240
|
+
raise CLIError(f"{name}: reading image folders needs Pillow (pip install pillow).") from exc
|
|
241
|
+
files = sorted(
|
|
242
|
+
f for f in os.listdir(path) if f.lower().endswith((".png", ".tif", ".tiff", ".bmp", ".gif", ".jpg"))
|
|
243
|
+
)
|
|
244
|
+
if not files:
|
|
245
|
+
raise CLIError(f"{name}: no image files in {path}.")
|
|
246
|
+
return [np.asarray(Image.open(os.path.join(path, f))) for f in files]
|
|
247
|
+
ext = os.path.splitext(path)[1].lower()
|
|
248
|
+
if ext == ".npy":
|
|
249
|
+
return np.load(path, allow_pickle=False)
|
|
250
|
+
if ext == ".npz":
|
|
251
|
+
with np.load(path, allow_pickle=False) as data:
|
|
252
|
+
if not data.files:
|
|
253
|
+
raise CLIError(f"{name}: {path} contains no arrays.")
|
|
254
|
+
return data[data.files[0]]
|
|
255
|
+
raise CLIError(f"{name}: use a .npy or .npz file, or a folder of mask images.")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cmd_segmentation(args: argparse.Namespace) -> int:
|
|
259
|
+
import evalsuite as es
|
|
260
|
+
|
|
261
|
+
spacing = tuple(float(x) for x in args.spacing.split(",")) if args.spacing else None
|
|
262
|
+
report = es.segmentation_report(
|
|
263
|
+
read_masks(args.y_true, "y_true"),
|
|
264
|
+
read_masks(args.y_pred, "y_pred"),
|
|
265
|
+
num_classes=args.num_classes,
|
|
266
|
+
ignore_index=args.ignore_index,
|
|
267
|
+
spacing=spacing,
|
|
268
|
+
aggregate=args.aggregate,
|
|
269
|
+
include_background=not args.no_background,
|
|
270
|
+
)
|
|
271
|
+
emit(report, args)
|
|
272
|
+
if args.plot:
|
|
273
|
+
_save_figure(lambda ax: es.plot.per_class(_report_iou(report), ax=ax), args.plot)
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _report_iou(report: Any) -> Any:
|
|
278
|
+
import numpy as np
|
|
279
|
+
|
|
280
|
+
from ..core.result import MetricResult
|
|
281
|
+
|
|
282
|
+
return MetricResult(
|
|
283
|
+
"iou", "IoU", np.array([r["iou"] for r in report.rows]), labels=tuple(r["name"] for r in report.rows)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def cmd_detection(args: argparse.Namespace) -> int:
|
|
288
|
+
import evalsuite as es
|
|
289
|
+
|
|
290
|
+
y_true, y_pred = es.from_coco(args.ground_truth, args.results)
|
|
291
|
+
report = es.detection_report(y_true, y_pred, box_format="xywh", max_detections=args.max_detections)
|
|
292
|
+
emit(report, args)
|
|
293
|
+
if args.plot:
|
|
294
|
+
_save_figure(
|
|
295
|
+
lambda ax: es.plot.detection_pr(y_true, y_pred, ax=ax, box_format="xywh", iou_threshold=0.5), args.plot
|
|
296
|
+
)
|
|
297
|
+
return 0
|
|
298
|
+
|
|
299
|
+
|
|
229
300
|
def cmd_compare(args: argparse.Namespace) -> int:
|
|
230
301
|
import evalsuite as es
|
|
231
302
|
|
|
@@ -410,6 +481,31 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
410
481
|
add_output(p)
|
|
411
482
|
p.set_defaults(func=cmd_calibration)
|
|
412
483
|
|
|
484
|
+
p = sub.add_parser(
|
|
485
|
+
"segmentation",
|
|
486
|
+
help="segmentation masks: Dice, IoU, mIoU, Boundary IoU, HD95, ASSD per class",
|
|
487
|
+
)
|
|
488
|
+
p.add_argument("y_true", help="true masks: .npy/.npz (images on the first axis) or a folder of mask images")
|
|
489
|
+
p.add_argument("y_pred", help="predicted masks, same layout as y_true")
|
|
490
|
+
p.add_argument("--num-classes", type=int, help="number of classes (default: largest label + 1)")
|
|
491
|
+
p.add_argument("--ignore-index", type=int, help="label to ignore, e.g. 255")
|
|
492
|
+
p.add_argument("--spacing", help="pixel spacing per axis for surface distances, e.g. 0.8,0.8")
|
|
493
|
+
p.add_argument("--aggregate", choices=("dataset", "image"), default="dataset")
|
|
494
|
+
p.add_argument("--no-background", action="store_true", help="leave class 0 out")
|
|
495
|
+
p.add_argument("--plot", metavar="PATH", help="also save a per-class IoU bar chart")
|
|
496
|
+
add_output(p)
|
|
497
|
+
p.set_defaults(func=cmd_segmentation)
|
|
498
|
+
|
|
499
|
+
p = sub.add_parser(
|
|
500
|
+
"detection", help="object detection, COCO protocol: mAP, mAP@.50, mAP@.75, mAR, AP per class"
|
|
501
|
+
)
|
|
502
|
+
p.add_argument("ground_truth", help="COCO ground-truth JSON (images, annotations, categories)")
|
|
503
|
+
p.add_argument("results", help="COCO results JSON (image_id, category_id, bbox, score)")
|
|
504
|
+
p.add_argument("--max-detections", type=int, default=100)
|
|
505
|
+
p.add_argument("--plot", metavar="PATH", help="also save precision-recall curves per class (IoU 0.5)")
|
|
506
|
+
add_output(p, digits=3)
|
|
507
|
+
p.set_defaults(func=cmd_detection)
|
|
508
|
+
|
|
413
509
|
p = sub.add_parser(
|
|
414
510
|
"plot", help="save a ROC, PR, calibration, decision-curve, confusion-matrix or residual plot"
|
|
415
511
|
)
|
|
@@ -433,9 +529,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
433
529
|
p.add_argument("--repeat", type=int, default=5, help="timed repetitions per case; the fastest is reported")
|
|
434
530
|
p.add_argument(
|
|
435
531
|
"--suite",
|
|
436
|
-
choices=("all", "core", "clinical"),
|
|
532
|
+
choices=("all", "core", "clinical", "vision"),
|
|
437
533
|
default="all",
|
|
438
|
-
help="core: classification/regression; clinical:
|
|
534
|
+
help="core: classification/regression; clinical: clinical, calibration, tests; vision: segmentation, "
|
|
535
|
+
"detection (default all)",
|
|
439
536
|
)
|
|
440
537
|
p.add_argument("--no-sklearn", action="store_true", help="time EvalSuite only (skip reference libraries)")
|
|
441
538
|
p.add_argument("--seed", type=int, default=0)
|
evalsuite/core/validation.py
CHANGED
|
@@ -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)
|