File size: 7,637 Bytes
7cefde1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
"""Evaluate the packaged VesselCNN weights on the VesselMNIST3D test
split and produce the failure analysis + packaged sample volumes.

Outputs (defaults):
  assets/evaluation_results.json  — full test metrics, occupancy
                                    analysis, error inventory
  assets/samples/*.npy            — sample test volumes (uint8 28^3)
  assets/samples/index.json       — per-sample label + the prediction
                                    measured from the packaged .npy
                                    file itself (via vesselcnn.infer)

Sample selection rule (fixed, stated here so it cannot be mistaken
for cherry-picking): from the test split, in test order —
  * the first false negative (aneurysm read as vessel),
  * the first false positive (vessel read as aneurysm),
  * the correct aneurysm with the highest predicted aneurysm
    probability,
  * the correct vessel whose test index is the median of the correct
    vessels.
If a category does not exist in the evaluation (e.g. no false
negatives), it is simply absent and the index records the rule.

Usage:
  python scripts/evaluate.py [--weights model.safetensors]
      [--data /path/to/vesselmnist3d.npz] [--out assets/evaluation_results.json]
      [--samples-dir assets/samples]
"""
import argparse
import json
import os
import sys

import numpy as np

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))

import torch  # noqa: E402
from sklearn.metrics import (accuracy_score, balanced_accuracy_score,  # noqa: E402
                             confusion_matrix, precision_score,
                             recall_score, roc_auc_score)

from vesselcnn.infer import LABELS, TRAIN_MEAN, TRAIN_STD, load_model, predict  # noqa: E402
from vesselcnn.model import build_model  # noqa: E402

HERE = os.path.dirname(os.path.abspath(__file__))
PKG = os.path.dirname(HERE)
NUM_CLASSES = 2


@torch.no_grad()
def evaluate(model, images_u8, batch=64):
    model.eval()
    xt = torch.from_numpy(images_u8)[:, None]
    probs = []
    for i in range(0, len(xt), batch):
        xb = (xt[i:i + batch].float() / 255.0 - TRAIN_MEAN) / TRAIN_STD
        probs.append(torch.softmax(model(xb), dim=1))
    probs = torch.cat(probs).numpy()
    return probs, probs.argmax(1)


def full_metrics(y_true, probs, preds):
    cm = confusion_matrix(y_true, preds, labels=list(range(NUM_CLASSES)))
    rec = recall_score(y_true, preds, labels=list(range(NUM_CLASSES)),
                       average=None, zero_division=0)
    prec = precision_score(y_true, preds, labels=list(range(NUM_CLASSES)),
                           average=None, zero_division=0)
    return {
        "accuracy": round(float(accuracy_score(y_true, preds)), 4),
        "auc": round(float(roc_auc_score(y_true, probs[:, 1])), 4),
        "balanced_accuracy": round(float(balanced_accuracy_score(y_true, preds)), 4),
        "confusion_matrix": cm.tolist(),
        "per_class_recall": {LABELS[i]: round(float(rec[i]), 4) for i in range(NUM_CLASSES)},
        "per_class_precision": {LABELS[i]: round(float(prec[i]), 4) for i in range(NUM_CLASSES)},
        "class_counts": np.bincount(y_true, minlength=NUM_CLASSES).tolist(),
        "majority_baseline": round(float(np.bincount(y_true).max() / len(y_true)), 4),
        "n": int(len(y_true)),
    }


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--weights", default=os.path.join(PKG, "model.safetensors"))
    ap.add_argument("--data", required=True, help="path to vesselmnist3d.npz")
    ap.add_argument("--out", default=os.path.join(PKG, "assets", "evaluation_results.json"))
    ap.add_argument("--samples-dir", default=os.path.join(PKG, "assets", "samples"))
    args = ap.parse_args()

    d = np.load(args.data)
    x_test = d["test_images"]
    y_test = d["test_labels"].ravel().astype(np.int64)

    model = load_model(args.weights)
    probs, preds = evaluate(model, x_test)
    metrics = full_metrics(y_test, probs, preds)

    occupancy = (x_test > 0).sum(axis=(1, 2, 3))
    correct = preds == y_test
    quartiles = np.quantile(occupancy, [0.25, 0.5, 0.75])
    occ_rows = []
    edges = [-1, quartiles[0], quartiles[1], quartiles[2], occupancy.max()]
    for q in range(4):
        m = (occupancy > edges[q]) & (occupancy <= edges[q + 1])
        row = {"quartile": q + 1, "occupancy_range": [int(edges[q] + 1), int(edges[q + 1])],
               "n": int(m.sum()), "accuracy": round(float(correct[m].mean()), 4)}
        pos = m & (y_test == 1)
        row["aneurysm_n"] = int(pos.sum())
        row["aneurysm_recall"] = (round(float(correct[pos].mean()), 4)
                                  if pos.sum() else None)
        occ_rows.append(row)

    fn_idx = [int(i) for i in np.where((y_test == 1) & (preds == 0))[0]]
    fp_idx = [int(i) for i in np.where((y_test == 0) & (preds == 1))[0]]

    def err_rec(i):
        return {"test_index": int(i), "label": LABELS[int(y_test[i])],
                "predicted": LABELS[int(preds[i])],
                "prob_aneurysm": round(float(probs[i, 1]), 4),
                "occupancy_voxels": int(occupancy[i])}

    analysis = {
        "occupancy_quartiles": occ_rows,
        "false_negatives": {"n": len(fn_idx), "items": [err_rec(i) for i in fn_idx]},
        "false_positives": {"n": len(fp_idx), "items": [err_rec(i) for i in fp_idx]},
        "mean_prob_of_correct": round(float(probs[np.arange(len(y_test)), y_test][correct].mean()), 4),
        "occupancy_mean_correct": round(float(occupancy[correct].mean()), 1),
        "occupancy_mean_incorrect": round(float(occupancy[~correct].mean()), 1),
    }

    record = {"split": "test", **metrics, "failure_analysis": analysis}
    os.makedirs(os.path.dirname(args.out), exist_ok=True)
    with open(args.out, "w") as f:
        json.dump(record, f, indent=2)
    print(json.dumps(metrics, indent=2))
    print(f"wrote {args.out}")

    # --- packaged samples, predictions re-measured from the .npy files ---
    os.makedirs(args.samples_dir, exist_ok=True)
    picks = {}
    if fn_idx:
        picks["false_negative"] = fn_idx[0]
    if fp_idx:
        picks["false_positive"] = fp_idx[0]
    correct_an = [int(i) for i in np.where((y_test == 1) & (preds == 1))[0]]
    if correct_an:
        picks["correct_aneurysm_most_confident"] = max(correct_an, key=lambda i: probs[i, 1])
    correct_v = [int(i) for i in np.where((y_test == 0) & (preds == 0))[0]]
    if correct_v:
        picks["correct_vessel_median"] = correct_v[len(correct_v) // 2]
    index = {"selection_rule": (
        "first false negative, first false positive, most-confident correct "
        "aneurysm, median correct vessel — all in test order, from the "
        "packaged-weights evaluation in assets/evaluation_results.json"),
        "samples": []}
    for role, i in picks.items():
        fname = f"{role}_{i}.npy"
        np.save(os.path.join(args.samples_dir, fname), x_test[i])
        r = predict(model, os.path.join(args.samples_dir, fname))
        index["samples"].append({
            "file": fname, "role": role, "test_index": int(i),
            "label": LABELS[int(y_test[i])],
            "predicted": r["label"],
            "probabilities": {k: round(v, 4) for k, v in r["probabilities"].items()},
            "occupancy_voxels": int(occupancy[i]),
        })
        print(f"sample {fname}: label={LABELS[int(y_test[i])]} predicted={r['label']}")
    with open(os.path.join(args.samples_dir, "index.json"), "w") as f:
        json.dump(index, f, indent=2)
    print(f"wrote {os.path.join(args.samples_dir, 'index.json')}")


if __name__ == "__main__":
    raise SystemExit(main())