DoB24 commited on
Commit
63fc39b
·
verified ·
1 Parent(s): 767b053

Add pooled-protocol provenance scripts (regenerate summary, 9-model McNemar, per-class, ensemble from fold predictions)

Browse files
code/make_ensemble_pooled.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compute the nine-model soft-vote ensemble on the pooled five-fold predictions.
2
+
3
+ Every other result in the thesis reduces each model to a single pooled prediction
4
+ per image (the probabilities of its five fold checkpoints averaged on the fixed
5
+ 3,208-image test set). The original ensemble report shipped from the training VM
6
+ was instead computed on the single-checkpoint predictions, a different and
7
+ non-comparable aggregation. This script recomputes the ensemble in the same
8
+ pooled paradigm so Section 4.8 is consistent with Table 4.1 and the McNemar
9
+ analysis: each model is first pooled across its folds, the nine pooled
10
+ probability vectors are then combined by a soft vote weighted by each model's
11
+ macro-F1, and the result is compared against the best single pooled model with
12
+ the same exact-binomial McNemar test used in Section 4.5.
13
+
14
+ Writes research_v2_latest/analysis/ensemble_pooled.json.
15
+
16
+ Run with the project .venv interpreter:
17
+ .venv/bin/python thesis_build/make_ensemble_pooled.py
18
+ """
19
+ import json
20
+ import numpy as np
21
+ from pathlib import Path
22
+ from scipy.stats import binom
23
+ from sklearn.metrics import (
24
+ accuracy_score, precision_recall_fscore_support, roc_auc_score, cohen_kappa_score,
25
+ )
26
+
27
+ ROOT = Path(__file__).resolve().parent.parent
28
+ KF = ROOT / "kfold"
29
+ OUT = ROOT / "analysis" / "ensemble_pooled.json"
30
+
31
+ CNN_CLIP = {"inception_v3", "clip_openai", "vgg19", "resnet101", "densenet121", "resnet50"}
32
+ ALL = ["inception_v3", "clip_openai", "vgg19", "resnet101", "dinov2_l",
33
+ "densenet121", "resnet50", "swin_b", "retfound"]
34
+ NUM_CLASSES = 10
35
+ LABELS = list(range(NUM_CLASSES))
36
+
37
+
38
+ def pooled(model):
39
+ if model in CNN_CLIP:
40
+ d = json.load(open(KF / "cnn_clip" / f"{model}_test_preds.json"))
41
+ return np.array(d["labels"]), np.array(d["probs"])
42
+ probs, labels = [], None
43
+ for k in range(5):
44
+ d = json.load(open(KF / f"foundation_fold{k}_{model}_preds.json"))
45
+ probs.append(np.array(d["probs"])); labels = np.array(d["labels"])
46
+ return labels, np.mean(probs, axis=0)
47
+
48
+
49
+ def ece(probs, labels, n_bins=15):
50
+ conf = probs.max(1); pred = probs.argmax(1); correct = (pred == labels).astype(float)
51
+ bins = np.linspace(0, 1, n_bins + 1); e = 0.0
52
+ for i in range(n_bins):
53
+ m = (conf > bins[i]) & (conf <= bins[i + 1])
54
+ if m.sum():
55
+ e += m.mean() * abs(correct[m].mean() - conf[m].mean())
56
+ return float(e)
57
+
58
+
59
+ def mcnemar(pred_a, pred_b, labels):
60
+ ca = pred_a == labels; cb = pred_b == labels
61
+ b = int(np.sum(ca & ~cb)); c = int(np.sum(~ca & cb)); n = b + c
62
+ p = 1.0 if n == 0 else float(min(1.0, 2 * binom.cdf(min(b, c), n, 0.5)))
63
+ return b, c, p
64
+
65
+
66
+ def main():
67
+ P = {}; L = None
68
+ for m in ALL:
69
+ L, P[m] = pooled(m)
70
+ f1 = {m: precision_recall_fscore_support(L, P[m].argmax(1), average="macro", zero_division=0)[2]
71
+ for m in ALL}
72
+
73
+ # soft vote weighted by each model's pooled macro-F1
74
+ wsum = sum(f1.values())
75
+ ens_probs = sum(f1[m] * P[m] for m in ALL) / wsum
76
+ ens_pred = ens_probs.argmax(1)
77
+
78
+ acc = accuracy_score(L, ens_pred) * 100
79
+ prec, rec, fm, _ = precision_recall_fscore_support(L, ens_pred, average="macro", zero_division=0)
80
+ roc = roc_auc_score(L, ens_probs, multi_class="ovr", average="macro", labels=LABELS)
81
+
82
+ # best single pooled model
83
+ best = max(ALL, key=lambda m: accuracy_score(L, P[m].argmax(1)))
84
+ best_acc = accuracy_score(L, P[best].argmax(1)) * 100
85
+ b, c, p = mcnemar(ens_pred, P[best].argmax(1), L)
86
+
87
+ report = {
88
+ "protocol": "pooled five-fold soft vote, weight = pooled macro-F1, all nine models",
89
+ "members": ALL,
90
+ "ensemble": {
91
+ "acc": round(acc, 2), "f1": round(fm * 100, 2), "roc_auc": round(roc, 4),
92
+ "ece": round(ece(ens_probs, L), 4), "kappa": round(cohen_kappa_score(L, ens_pred), 3),
93
+ },
94
+ "best_single": {"model": best, "acc": round(best_acc, 2), "f1": round(f1[best] * 100, 2)},
95
+ "ensemble_vs_best_mcnemar": {"b": b, "c": c, "p": round(p, 3),
96
+ "significant_005": bool(p < 0.05)},
97
+ "n_test": int(len(L)),
98
+ }
99
+ with open(OUT, "w") as fh:
100
+ json.dump(report, fh, indent=2)
101
+ with open("/tmp/ensemble_pooled_check.txt", "w") as fh:
102
+ fh.write(json.dumps(report, indent=2))
103
+ print("wrote", OUT.relative_to(ROOT))
104
+
105
+
106
+ if __name__ == "__main__":
107
+ main()
code/make_kfold_summary.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regenerate the three foundation rows of research_v2_latest/kfold/kfold_v2_summary.csv
2
+ under the uniform pooled-ensemble protocol (Table 4.1 source).
3
+
4
+ The six convolutional/CLIP rows were produced on the training VM as a pooled
5
+ five-fold ensemble (probabilities averaged across the five fold checkpoints,
6
+ scored once on the fixed 3,208-image test set). The three foundation models were
7
+ originally summarised by the mean of their per-fold scores instead, a different,
8
+ non-comparable aggregation that also denied them the same ensemble treatment.
9
+ This script brings the foundation models into the identical pooled-ensemble
10
+ protocol so all nine rows are directly comparable and every model carries a
11
+ single aligned per-image prediction (which also lets the McNemar test in
12
+ make_mcnemar9.py cover all nine).
13
+
14
+ The six CNN/CLIP rows are kept verbatim from the authoritative VM summary; only
15
+ the three foundation rows are recomputed from
16
+ kfold/foundation_fold<k>_<model>_preds.json. Confidence intervals use the
17
+ bootstrap documented in research_v2_latest/code/ensemble_and_stats.py: 2000
18
+ resamples, seed 42, 2.5/97.5 percentile. Brier uses the element-wise mean
19
+ convention of the original summary.
20
+
21
+ Run with the project .venv interpreter:
22
+ .venv/bin/python thesis_build/make_kfold_summary.py
23
+ """
24
+ import csv
25
+ import json
26
+ import numpy as np
27
+ from pathlib import Path
28
+ from sklearn.metrics import (
29
+ accuracy_score, precision_recall_fscore_support, roc_auc_score,
30
+ average_precision_score, cohen_kappa_score,
31
+ )
32
+
33
+ ROOT = Path(__file__).resolve().parent.parent
34
+ KF = ROOT / "kfold"
35
+ CSV = KF / "kfold_v2_summary.csv"
36
+ FOUNDATION = ["dinov2_l", "swin_b", "retfound"]
37
+ NUM_CLASSES = 10
38
+ LABELS = list(range(NUM_CLASSES))
39
+
40
+
41
+ def pooled_foundation(model):
42
+ probs, labels = [], None
43
+ for k in range(5):
44
+ d = json.load(open(KF / f"foundation_fold{k}_{model}_preds.json"))
45
+ probs.append(np.array(d["probs"])); labels = np.array(d["labels"])
46
+ P = np.mean(probs, axis=0)
47
+ return labels, P.argmax(1), P
48
+
49
+
50
+ def ece(probs, labels, n_bins=15):
51
+ conf = probs.max(1); pred = probs.argmax(1); correct = (pred == labels).astype(float)
52
+ bins = np.linspace(0, 1, n_bins + 1); e = 0.0
53
+ for i in range(n_bins):
54
+ m = (conf > bins[i]) & (conf <= bins[i + 1])
55
+ if m.sum():
56
+ e += m.mean() * abs(correct[m].mean() - conf[m].mean())
57
+ return float(e)
58
+
59
+
60
+ def boot_ci(fn, labels, preds, n=2000, seed=42):
61
+ rng = np.random.default_rng(seed); N = len(labels); vals = []
62
+ for _ in range(n):
63
+ idx = rng.integers(0, N, N)
64
+ vals.append(fn(labels[idx], preds[idx]))
65
+ return float(np.percentile(vals, 2.5)), float(np.percentile(vals, 97.5))
66
+
67
+
68
+ def foundation_row(model):
69
+ y, p, pr = pooled_foundation(model)
70
+ acc = accuracy_score(y, p) * 100
71
+ prec, rec, f1, _ = precision_recall_fscore_support(y, p, average="macro", zero_division=0)
72
+ acc_lo, acc_hi = boot_ci(lambda a, b: accuracy_score(a, b) * 100, y, p)
73
+ f1_lo, f1_hi = boot_ci(
74
+ lambda a, b: precision_recall_fscore_support(a, b, average="macro", zero_division=0)[2] * 100, y, p)
75
+ roc = roc_auc_score(y, pr, multi_class="ovr", average="macro", labels=LABELS)
76
+ oh = np.eye(NUM_CLASSES)[y]
77
+ prauc = average_precision_score(oh, pr, average="macro")
78
+ brier = float(((pr - oh) ** 2).mean())
79
+ return [model, "Foundation", round(acc, 2), round(acc_lo, 2), round(acc_hi, 2),
80
+ round(f1 * 100, 2), round(f1_lo, 2), round(f1_hi, 2),
81
+ round(prec * 100, 2), round(rec * 100, 2), round(roc, 4), round(prauc, 4),
82
+ round(ece(pr, y), 4), round(cohen_kappa_score(y, p), 3), round(brier, 3),
83
+ 5, "five-fold pooled preds"]
84
+
85
+
86
+ def main():
87
+ with open(CSV) as fh:
88
+ rdr = csv.reader(fh); header = next(rdr); rows = list(rdr)
89
+ kept = [r for r in rows if r[1] != "Foundation"]
90
+ for r in kept:
91
+ r[16] = "five-fold pooled preds"
92
+ new_found = [foundation_row(m) for m in FOUNDATION]
93
+ allrows = kept + new_found
94
+ allrows.sort(key=lambda r: -float(r[2]))
95
+ with open(CSV, "w", newline="") as fh:
96
+ w = csv.writer(fh); w.writerow(header); w.writerows(allrows)
97
+ with open("/tmp/summary_check.txt", "w") as fh:
98
+ for r in allrows:
99
+ fh.write(f"{r[0]:14s} acc {r[2]:6} ({r[3]}-{r[4]}) f1 {r[5]:6} "
100
+ f"roc {r[10]} ece {r[12]} kappa {r[13]} brier {r[14]} {r[16]}\n")
101
+ print("wrote", CSV.relative_to(ROOT))
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
code/make_mcnemar9.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regenerate research_v2_latest/kfold/cnn_clip/mcnemar.json for ALL NINE models.
2
+
3
+ Under the uniform pooled-ensemble protocol every model carries one aligned
4
+ per-image prediction on the fixed 3,208-image test set: the five convolutional
5
+ networks and CLIP from kfold/cnn_clip/<model>_test_preds.json, the three
6
+ foundation models from the probability average of their five
7
+ kfold/foundation_fold<k>_<model>_preds.json checkpoints. Because all nine share
8
+ the same test images in the same order, the paired McNemar test now covers every
9
+ pair (36 in total).
10
+
11
+ Test: exact binomial two-sided on the discordant pairs (b, c), identical to
12
+ research_v2_latest/code/ensemble_and_stats.py. The file keeps the original
13
+ "<a>_vs_<b>": {"b","c","p"} layout used by thesis_build/figures/make_fig4_5.py;
14
+ a sibling key "_meta" records the model order, pair count and Bonferroni factor.
15
+
16
+ Run with the project .venv interpreter:
17
+ .venv/bin/python thesis_build/make_mcnemar9.py
18
+ """
19
+ import json
20
+ import numpy as np
21
+ from pathlib import Path
22
+ from scipy.stats import binom
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ KF = ROOT / "kfold"
26
+ OUT = KF / "cnn_clip" / "mcnemar.json"
27
+
28
+ # accuracy-descending order (matches Table 4.1)
29
+ ORDER = ["inception_v3", "clip_openai", "vgg19", "resnet101", "dinov2_l",
30
+ "densenet121", "resnet50", "swin_b", "retfound"]
31
+ CNN_CLIP = {"inception_v3", "clip_openai", "vgg19", "resnet101", "densenet121", "resnet50"}
32
+
33
+
34
+ def preds(model):
35
+ if model in CNN_CLIP:
36
+ d = json.load(open(KF / "cnn_clip" / f"{model}_test_preds.json"))
37
+ return np.array(d["labels"]), np.array(d["preds"])
38
+ probs, labels = [], None
39
+ for k in range(5):
40
+ d = json.load(open(KF / f"foundation_fold{k}_{model}_preds.json"))
41
+ probs.append(np.array(d["probs"])); labels = np.array(d["labels"])
42
+ return labels, np.mean(probs, axis=0).argmax(1)
43
+
44
+
45
+ def main():
46
+ P = {m: preds(m) for m in ORDER}
47
+ L = P[ORDER[0]][0]
48
+ for m in ORDER:
49
+ assert np.array_equal(P[m][0], L), f"{m} label order differs"
50
+
51
+ out = {}
52
+ pairs = []
53
+ for i in range(len(ORDER)):
54
+ for j in range(i + 1, len(ORDER)):
55
+ a, b = ORDER[i], ORDER[j]
56
+ ca = P[a][1] == L; cb = P[b][1] == L
57
+ bb = int(np.sum(ca & ~cb)); cc = int(np.sum(~ca & cb)); n = bb + cc
58
+ p = 1.0 if n == 0 else float(min(1.0, 2 * binom.cdf(min(bb, cc), n, 0.5)))
59
+ out[f"{a}_vs_{b}"] = {"b": bb, "c": cc, "p": p}
60
+ pairs.append((a, b, bb, cc, p))
61
+
62
+ npairs = len(pairs)
63
+ out["_meta"] = {"models": ORDER, "n_pairs": npairs, "bonferroni_factor": npairs,
64
+ "test": "exact binomial two-sided McNemar"}
65
+ with open(OUT, "w") as fh:
66
+ json.dump(out, fh, indent=1)
67
+
68
+ lines = []
69
+ nsig = 0
70
+ for a, b, bb, cc, p in sorted(pairs, key=lambda x: x[4]):
71
+ adj = min(1.0, p * npairs); sig = adj < 0.05; nsig += sig
72
+ lines.append(f"{'*' if sig else ' '} {a:13s} vs {b:13s} b={bb:4d} c={cc:4d} p={p:.3g} adj={adj:.3g}")
73
+ top = [x for x in pairs if x[0] not in {"swin_b", "retfound"} and x[1] not in {"swin_b", "retfound"}]
74
+ with open("/tmp/mcnemar9_check.txt", "w") as fh:
75
+ fh.write(f"wrote {OUT.relative_to(ROOT)} n_pairs={npairs} significant(Bonferroni)={nsig}\n")
76
+ fh.write(f"top-7 pairs={len(top)} min raw p among top-7={min(x[4] for x in top):.4f} "
77
+ f"significant among top-7={sum(1 for x in top if x[4]*npairs<0.05)}\n\n")
78
+ fh.write("\n".join(lines) + "\n")
79
+ print("wrote", OUT.relative_to(ROOT))
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
code/make_per_class_pooled_csv.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate per_class_metrics_pooled.csv from the latest five-fold predictions.
2
+
3
+ This is the provenance script for research_v2_latest/analysis/per_class_metrics_pooled.csv,
4
+ the per-class precision/recall/F1/support table that drives Appendix B and Figure 4.2.
5
+
6
+ Aggregation matches Table 4.1 exactly under the uniform pooled-ensemble protocol:
7
+ * the five convolutional networks and CLIP use their pooled five-fold test
8
+ predictions (kfold/cnn_clip/<model>_test_preds.json), i.e. the probabilities
9
+ averaged across the five fold checkpoints, scored once on the fixed test set;
10
+ * the three foundation models are pooled the same way, averaging the probability
11
+ vectors of their five fold checkpoints (kfold/foundation_fold<k>_<model>_preds.json)
12
+ into one aligned per-image prediction before the per-class metrics are computed.
13
+
14
+ Run with the project .venv interpreter (needs numpy + scikit-learn):
15
+ .venv/bin/python thesis_build/make_per_class_pooled_csv.py
16
+ """
17
+ import csv
18
+ import json
19
+ import numpy as np
20
+ from pathlib import Path
21
+ from sklearn.metrics import precision_recall_fscore_support
22
+
23
+ ROOT = Path(__file__).resolve().parent.parent
24
+ KF = ROOT / "kfold"
25
+ OUT = ROOT / "analysis" / "per_class_metrics_pooled.csv"
26
+
27
+ # Class index -> display name (the dataset's alphabetical label order).
28
+ CLASSES = [
29
+ "Central Serous Chorioretinopathy", # 0
30
+ "Diabetic Retinopathy", # 1
31
+ "Disc Edema", # 2
32
+ "Glaucoma", # 3
33
+ "Healthy", # 4
34
+ "Macular Scar", # 5
35
+ "Myopia", # 6
36
+ "Pterygium", # 7
37
+ "Retinal Detachment", # 8
38
+ "Retinitis Pigmentosa", # 9
39
+ ]
40
+ LABELS = list(range(len(CLASSES)))
41
+
42
+ # Model write order = accuracy-descending order of Table 4.1 (DINOv2-L is 5th).
43
+ CNN_CLIP = ["inception_v3", "clip_openai", "vgg19", "resnet101", "densenet121", "resnet50"]
44
+ FOUNDATION = ["dinov2_l", "swin_b", "retfound"]
45
+ MODEL_ORDER = ["inception_v3", "clip_openai", "vgg19", "resnet101", "dinov2_l",
46
+ "densenet121", "resnet50", "swin_b", "retfound"]
47
+
48
+
49
+ def pooled_cnn_clip(model):
50
+ """Per-class metrics from the single pooled five-fold prediction file."""
51
+ d = json.load(open(KF / "cnn_clip" / f"{model}_test_preds.json"))
52
+ y, p = np.array(d["labels"]), np.array(d["preds"])
53
+ P, R, F, S = precision_recall_fscore_support(y, p, labels=LABELS, zero_division=0)
54
+ return P, R, F, S
55
+
56
+
57
+ def pooled_foundation(model):
58
+ """Per-class metrics from the pooled five-fold ensemble (probabilities averaged
59
+ across the five fold checkpoints, then argmax), matching Table 4.1."""
60
+ files = sorted(KF.glob(f"foundation_fold*_{model}_preds.json"))
61
+ assert len(files) == 5, f"expected 5 folds for {model}, found {len(files)}"
62
+ probs, y = [], None
63
+ for f in files:
64
+ d = json.load(open(f))
65
+ probs.append(np.array(d["probs"])); y = np.array(d["labels"])
66
+ p = np.mean(probs, axis=0).argmax(1)
67
+ P, R, F, S = precision_recall_fscore_support(y, p, labels=LABELS, zero_division=0)
68
+ return P, R, F, S
69
+
70
+
71
+ def main():
72
+ rows = []
73
+ for model in MODEL_ORDER:
74
+ if model in CNN_CLIP:
75
+ P, R, F, S = pooled_cnn_clip(model)
76
+ else:
77
+ P, R, F, S = pooled_foundation(model)
78
+ for i in LABELS:
79
+ rows.append([model, CLASSES[i], P[i], R[i], F[i], int(S[i])])
80
+
81
+ with open(OUT, "w", newline="") as fh:
82
+ w = csv.writer(fh)
83
+ w.writerow(["model", "class", "precision", "recall", "f1", "support"])
84
+ w.writerows(rows)
85
+ print(f"wrote {OUT.relative_to(ROOT)} ({len(rows)} rows)")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()