fundus-9model-benchmark / code /make_ensemble_pooled.py
DoB24's picture
Add pooled-protocol provenance scripts (regenerate summary, 9-model McNemar, per-class, ensemble from fold predictions)
63fc39b verified
Raw
History Blame Contribute Delete
4.36 kB
"""Compute the nine-model soft-vote ensemble on the pooled five-fold predictions.
Every other result in the thesis reduces each model to a single pooled prediction
per image (the probabilities of its five fold checkpoints averaged on the fixed
3,208-image test set). The original ensemble report shipped from the training VM
was instead computed on the single-checkpoint predictions, a different and
non-comparable aggregation. This script recomputes the ensemble in the same
pooled paradigm so Section 4.8 is consistent with Table 4.1 and the McNemar
analysis: each model is first pooled across its folds, the nine pooled
probability vectors are then combined by a soft vote weighted by each model's
macro-F1, and the result is compared against the best single pooled model with
the same exact-binomial McNemar test used in Section 4.5.
Writes research_v2_latest/analysis/ensemble_pooled.json.
Run with the project .venv interpreter:
.venv/bin/python thesis_build/make_ensemble_pooled.py
"""
import json
import numpy as np
from pathlib import Path
from scipy.stats import binom
from sklearn.metrics import (
accuracy_score, precision_recall_fscore_support, roc_auc_score, cohen_kappa_score,
)
ROOT = Path(__file__).resolve().parent.parent
KF = ROOT / "kfold"
OUT = ROOT / "analysis" / "ensemble_pooled.json"
CNN_CLIP = {"inception_v3", "clip_openai", "vgg19", "resnet101", "densenet121", "resnet50"}
ALL = ["inception_v3", "clip_openai", "vgg19", "resnet101", "dinov2_l",
"densenet121", "resnet50", "swin_b", "retfound"]
NUM_CLASSES = 10
LABELS = list(range(NUM_CLASSES))
def pooled(model):
if model in CNN_CLIP:
d = json.load(open(KF / "cnn_clip" / f"{model}_test_preds.json"))
return np.array(d["labels"]), np.array(d["probs"])
probs, labels = [], None
for k in range(5):
d = json.load(open(KF / f"foundation_fold{k}_{model}_preds.json"))
probs.append(np.array(d["probs"])); labels = np.array(d["labels"])
return labels, np.mean(probs, axis=0)
def ece(probs, labels, n_bins=15):
conf = probs.max(1); pred = probs.argmax(1); correct = (pred == labels).astype(float)
bins = np.linspace(0, 1, n_bins + 1); e = 0.0
for i in range(n_bins):
m = (conf > bins[i]) & (conf <= bins[i + 1])
if m.sum():
e += m.mean() * abs(correct[m].mean() - conf[m].mean())
return float(e)
def mcnemar(pred_a, pred_b, labels):
ca = pred_a == labels; cb = pred_b == labels
b = int(np.sum(ca & ~cb)); c = int(np.sum(~ca & cb)); n = b + c
p = 1.0 if n == 0 else float(min(1.0, 2 * binom.cdf(min(b, c), n, 0.5)))
return b, c, p
def main():
P = {}; L = None
for m in ALL:
L, P[m] = pooled(m)
f1 = {m: precision_recall_fscore_support(L, P[m].argmax(1), average="macro", zero_division=0)[2]
for m in ALL}
# soft vote weighted by each model's pooled macro-F1
wsum = sum(f1.values())
ens_probs = sum(f1[m] * P[m] for m in ALL) / wsum
ens_pred = ens_probs.argmax(1)
acc = accuracy_score(L, ens_pred) * 100
prec, rec, fm, _ = precision_recall_fscore_support(L, ens_pred, average="macro", zero_division=0)
roc = roc_auc_score(L, ens_probs, multi_class="ovr", average="macro", labels=LABELS)
# best single pooled model
best = max(ALL, key=lambda m: accuracy_score(L, P[m].argmax(1)))
best_acc = accuracy_score(L, P[best].argmax(1)) * 100
b, c, p = mcnemar(ens_pred, P[best].argmax(1), L)
report = {
"protocol": "pooled five-fold soft vote, weight = pooled macro-F1, all nine models",
"members": ALL,
"ensemble": {
"acc": round(acc, 2), "f1": round(fm * 100, 2), "roc_auc": round(roc, 4),
"ece": round(ece(ens_probs, L), 4), "kappa": round(cohen_kappa_score(L, ens_pred), 3),
},
"best_single": {"model": best, "acc": round(best_acc, 2), "f1": round(f1[best] * 100, 2)},
"ensemble_vs_best_mcnemar": {"b": b, "c": c, "p": round(p, 3),
"significant_005": bool(p < 0.05)},
"n_test": int(len(L)),
}
with open(OUT, "w") as fh:
json.dump(report, fh, indent=2)
with open("/tmp/ensemble_pooled_check.txt", "w") as fh:
fh.write(json.dumps(report, indent=2))
print("wrote", OUT.relative_to(ROOT))
if __name__ == "__main__":
main()