Lightweight-2-5D-LUNA16 / tests /test_evaluate.py
Lien-Feng's picture
Upload 211 files
c3f98a1 verified
Raw
History Blame Contribute Delete
5.15 kB
"""Correctness tests for the evaluation stack.
Two levels of assurance:
* :func:`test_reference_output` is the important one - it runs the port on the
sample submission bundled with the official LUNA16 archive and requires every
published counter to match. If this passes, the matching logic *is* the
official one.
* the synthetic tests pin down the degenerate cases that the reference
submission never exercises (a detector with no false positives, a detector
with no detections, self-paired differences).
Run with ``python tests/test_evaluate.py`` or ``pytest tests/``.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from luna_rev import evaluate as ev
from luna_rev import stats
from luna_rev.io_luna import all_uids, group_by_uid, load_annotations, load_excluded
UIDS = list(all_uids())
INCLUDED = group_by_uid(load_annotations())
EXCLUDED = group_by_uid(load_excluded())
N_NODULES = sum(len(v) for v in INCLUDED.values())
def _perfect() -> dict[str, np.ndarray]:
"""One candidate exactly on every reference nodule, at maximum confidence."""
return {u: np.hstack([g[:, :3], np.ones((len(g), 1))]) for u, g in INCLUDED.items()}
def test_reference_output():
"""The port must reproduce the official CADAnalysis.txt counters exactly."""
r = ev.validate_against_reference("legacy_abs")
assert r["matches_reference"], f"counter mismatch: {r['mismatches']}"
def test_perfect_detector():
"""No false positives anywhere: CPM is 1 and every nodule is a true positive."""
v = ev.match(_perfect(), INCLUDED, EXCLUDED, UIDS)
r = ev.evaluate(v)
assert r["true_positives"] == N_NODULES
assert r["false_positives"] == 0
assert abs(r["cpm"] - 1.0) < 1e-9, r["cpm"]
def test_empty_detector():
"""No candidates at all: sensitivity is 0 and every nodule is a false negative."""
r = ev.evaluate(ev.match({}, INCLUDED, EXCLUDED, UIDS))
assert r["false_negatives"] == N_NODULES
assert r["cpm"] == 0.0
def test_excluded_findings_are_ignored_not_scored():
"""A candidate on an irrelevant finding must not be counted as a false positive."""
uid = next(u for u in UIDS if u in EXCLUDED)
x, y, z, _ = EXCLUDED[uid][0]
cands = {uid: np.array([[x, y, z, 0.9]])}
with_list = ev.match(cands, INCLUDED, EXCLUDED, [uid]).counters
without = ev.match(cands, INCLUDED, None, [uid]).counters
assert with_list["ignored_on_excluded"] == 1
assert with_list["false_positives"] == 0
assert without["false_positives"] == 1, "without the list it must count as an FP"
def test_double_detections_are_ignored():
"""Extra candidates inside a detected nodule are ignored, not false positives."""
uid, g = next(iter(INCLUDED.items()))
x, y, z, d = g[0]
eps = d / 8.0
cands = {uid: np.array([[x, y, z, 0.9], [x + eps, y, z, 0.5]])}
c = ev.match(cands, INCLUDED, EXCLUDED, [uid]).counters
assert c["true_positives"] == 1
assert c["ignored_double_detections"] == 1
assert c["false_positives"] == 0
def test_highest_confidence_candidate_wins():
"""A nodule is scored with its most confident hit, not its first."""
uid, g = next(iter(INCLUDED.items()))
x, y, z, d = g[0]
cands = {uid: np.array([[x, y, z, 0.2], [x + d / 8.0, y, z, 0.8]])}
v = ev.match(cands, INCLUDED, EXCLUDED, [uid])
assert float(v.prob[v.gt == 1.0].max()) == 0.8
def test_self_paired_difference_is_zero():
"""Sharing bootstrap resamples must make a model's difference with itself exactly 0."""
v = ev.match(_perfect(), INCLUDED, EXCLUDED, UIDS)
b = ev.bootstrap_cpm({"a": v, "b": v}, UIDS, n_iter=25)
d = ev.paired_difference(b["a"], b["b"])
assert d["delta_bootstrap_mean"] == 0.0
assert (d["ci_low"], d["ci_high"]) == (0.0, 0.0)
def test_size_stratified_recall_perfect():
"""A perfect detector recalls every size band, and the bands cover all nodules."""
df = stats.size_stratified_recall(_perfect(), load_annotations(), EXCLUDED, UIDS)
assert df["n_nodules"].sum() == N_NODULES
assert (df["recall"] == 1.0).all()
def test_monotone_in_false_positives():
"""Adding pure noise can never increase CPM."""
rng = np.random.default_rng(0)
perfect = _perfect()
noisy = {u: np.vstack([perfect.get(u, np.zeros((0, 4))),
np.hstack([rng.normal(0, 300, (20, 3)), rng.uniform(0, 1, (20, 1))])])
for u in UIDS}
a = ev.evaluate(ev.match(perfect, INCLUDED, EXCLUDED, UIDS))["cpm"]
b = ev.evaluate(ev.match(noisy, INCLUDED, EXCLUDED, UIDS))["cpm"]
assert b <= a + 1e-9, (a, b)
def main() -> int:
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
failed = 0
for t in tests:
try:
t()
print(f"PASS {t.__name__}")
except AssertionError as e:
failed += 1
print(f"FAIL {t.__name__}: {e}")
print(f"\n{len(tests) - failed}/{len(tests)} passed")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())