Add pooled-protocol provenance scripts (regenerate summary, 9-model McNemar, per-class, ensemble from fold predictions)
Browse files- code/make_ensemble_pooled.py +107 -0
- code/make_kfold_summary.py +105 -0
- code/make_mcnemar9.py +83 -0
- code/make_per_class_pooled_csv.py +89 -0
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()
|