This commit is contained in:
rpotter6298
2026-07-01 17:35:58 +02:00
parent 9bfcc0243b
commit 35cbd9ac3c
84 changed files with 8500 additions and 423 deletions
@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
compare_clustering_methods.py
Compare two patient-clustering approaches against the v8 "reference":
1. Thumbnail-based (64×64 grayscale K-means — the manuscript's method)
2. Feature-based (PCA-50d of VGG16 features → K-means — our method)
Metrics (all label-invariant):
- Adjusted Rand Index (ARI)
- Normalized Mutual Information (NMI)
- V-measure (homogeneity + completeness)
Usage:
conda activate fundus_imaging
python scripts/compare_clustering_methods.py
"""
import os, sys, csv, re
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sklearn.metrics import (
adjusted_rand_score,
normalized_mutual_info_score,
homogeneity_completeness_v_measure,
)
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
from PIL import Image
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATASET_PATH = os.path.expanduser("~/Documents/data_leakage/The IQ-OTHNCCD lung cancer dataset")
MANIFEST_V8 = os.path.join(ROOT, ".archive", "results", "patient_manifest_v8.csv")
MANIFEST_SIMPLE = os.path.join(ROOT, "results", "simple_patient_manifest.csv")
FEATURES_PATH = os.path.join(ROOT, "features", "VGG16_features.npz")
PATIENT_COUNTS = {
"Bengin cases": 15,
"Malignant cases": 40,
"Normal cases": 55,
}
CLASS_ORDER = ["Bengin cases", "Malignant cases", "Normal cases"]
SEED = 42
THUMBNAIL_SIZE = (64, 64)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def f2n(fname):
m = re.search(r"\((\d+)\)", fname)
num = int(m.group(1)) if m else None
for cls_key, prefix in [
("Bengin cases", "B"),
("Malignant cases", "M"),
("Normal cases", "N"),
]:
if fname.startswith(cls_key.rstrip("s")):
return f"{prefix}_{num:03d}" if num else fname
return fname
def load_v8_assignments(path):
"""Load v8 manifest, return {image_name: patient_id}."""
mapping = {}
with open(path, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
for img in row["confirmed_images"].split(";"):
if img:
mapping[img] = row["patient_id"]
return mapping
def load_simple_assignments(path):
"""Load simple feature-based manifest, return {image_name: patient_id}."""
mapping = {}
with open(path, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
for img in row["images"].split(";"):
if img:
mapping[img] = row["patient_id"]
return mapping
def run_thumbnail_clustering(dataset_path, filenames, labels):
"""Run 64×64 grayscale thumbnail K-means (manuscript method)."""
groups = np.array([None] * len(labels), dtype=object)
for class_name, n_clusters in PATIENT_COUNTS.items():
idx_class = np.where(labels == class_name)[0]
# Load thumbnails
X = []
for i in idx_class:
fname = filenames[i]
img_path = os.path.join(dataset_path, class_name, fname)
try:
img = Image.open(img_path).convert("L")
img = img.resize(THUMBNAIL_SIZE)
img_arr = np.array(img, dtype=np.float32) / 255.0
X.append(img_arr.flatten())
except Exception:
X.append(np.zeros(64 * 64, dtype=np.float32))
X = np.array(X, dtype=np.float32)
# PCA → 50d
n_pca = min(50, X.shape[0] - 1, X.shape[1])
pca = PCA(n_components=n_pca, random_state=SEED)
X_pca = pca.fit_transform(X)
# K-means
kmeans = KMeans(n_clusters=n_clusters, random_state=SEED, n_init=20)
clusters = kmeans.fit_predict(X_pca)
for i, cluster_id in zip(idx_class, clusters):
groups[i] = f"{class_name}_cluster_{cluster_id}"
sizes = np.bincount(clusters)
print(f" {class_name}: k={n_clusters}, sizes min={sizes.min()} max={sizes.max()} mean={sizes.mean():.1f}")
return groups
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
print("Loading VGG16 features for filename/label reference...")
data = np.load(FEATURES_PATH, allow_pickle=True)
all_filenames = data["filenames"]
all_labels = data["Y"]
X_vgg16 = data["X"]
print(f" {len(all_filenames)} images across {len(np.unique(all_labels))} classes")
# Load reference (v8) and our feature-based assignments
v8_map = load_v8_assignments(MANIFEST_V8)
simple_map = load_simple_assignments(MANIFEST_SIMPLE)
# Build per-image label arrays for all three methods, aligned by filename
# We need all images that exist in ALL three
v8_labels_list = []
simple_labels_list = []
thumb_labels_list = [] # filled after clustering
common_filenames = []
common_labels = []
# First, run thumbnail clustering
print("\nRunning thumbnail-based K-means (manuscript method)...")
thumb_groups = run_thumbnail_clustering(DATASET_PATH, all_filenames, all_labels)
# Build thumbnail mapping (using short names like B_001)
thumb_map = {}
for fname, group in zip(all_filenames, thumb_groups):
short = f2n(fname)
for cls in CLASS_ORDER:
if group.startswith(cls):
cluster_id = int(group.split("_cluster_")[-1])
prefix = {"Bengin cases": "Benign", "Malignant cases": "Malignant", "Normal cases": "Normal"}[cls]
thumb_map[short] = f"thumb_{prefix}_{cluster_id:02d}"
break
# Filter to images present in all three
# Feature filenames are like "Bengin case (1).jpg", manifests use "B_001"
for fname, label in zip(all_filenames, all_labels):
short_name = f2n(fname)
v8_id = v8_map.get(short_name)
simple_id = simple_map.get(short_name)
thumb_id = thumb_map.get(short_name)
if v8_id and simple_id and thumb_id:
common_filenames.append(short_name)
common_labels.append(label)
v8_labels_list.append(v8_id)
simple_labels_list.append(simple_id)
thumb_labels_list.append(thumb_id)
print(f"\nImages common to all three methods: {len(common_filenames)}")
# Convert to numpy arrays
v8_labels_arr = np.array(v8_labels_list)
simple_labels_arr = np.array(simple_labels_list)
thumb_labels_arr = np.array(thumb_labels_list)
common_labels_arr = np.array(common_labels)
# ---------------------------------------------------------------------------
# Compute agreement metrics — per-class and overall
# ---------------------------------------------------------------------------
def compute_metrics(ref, pred, name):
"""Compute clustering agreement metrics against reference."""
ari = adjusted_rand_score(ref, pred)
nmi = normalized_mutual_info_score(ref, pred)
h, c, v = homogeneity_completeness_v_measure(ref, pred)
return {"name": name, "ARI": ari, "NMI": nmi, "Homogeneity": h, "Completeness": c, "V_measure": v}
print("\n" + "=" * 80)
print("OVERALL AGREEMENT WITH v8 REFERENCE")
print("=" * 80)
results = []
for name, pred in [("Thumbnail (manuscript)", thumb_labels_arr), ("Feature-based (ours)", simple_labels_arr)]:
r = compute_metrics(v8_labels_arr, pred, name)
results.append(r)
print(f"\n{'Method':<30s} {'ARI':>8s} {'NMI':>8s} {'Homog':>8s} {'Compl':>8s} {'V_meas':>8s}")
print("-" * 72)
for r in results:
print(f"{r['name']:<30s} {r['ARI']:>8.4f} {r['NMI']:>8.4f} {r['Homogeneity']:>8.4f} {r['Completeness']:>8.4f} {r['V_measure']:>8.4f}")
# Per-class breakdown
print("\n" + "=" * 80)
print("PER-CLASS ARI WITH v8 REFERENCE")
print("=" * 80)
print(f"\n{'Class':<20s} {'Thumbnail':>10s} {'Feature-based':>15s}")
print("-" * 47)
for cls in CLASS_ORDER:
mask = common_labels_arr == cls
if mask.sum() < 2:
continue
thumb_ari = adjusted_rand_score(v8_labels_arr[mask], thumb_labels_arr[mask])
feat_ari = adjusted_rand_score(v8_labels_arr[mask], simple_labels_arr[mask])
better = "" if feat_ari > thumb_ari else ""
print(f"{cls:<20s} {thumb_ari:>10.4f} {feat_ari:>15.4f}{better}")
# Also: direct agreement between thumbnail and feature-based
print("\n" + "=" * 80)
print("THUMBNAIL vs FEATURE-BASED (direct agreement)")
print("=" * 80)
direct = compute_metrics(thumb_labels_arr, simple_labels_arr, "Thumb vs Feature")
print(f" ARI={direct['ARI']:.4f} NMI={direct['NMI']:.4f} V_measure={direct['V_measure']:.4f}")
print("\nDONE")
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""compare_patient_groupings.py
Compare the three estimated patient groupings of the IQ-OTH/NCCD
("lung_effnet") dataset against each other:
siamese -> results/siamese_manifest.csv
pca50 -> results/simple_patient_manifest.csv (PCA-50 CNN-feature K-means)
thumbnail -> results/thumbnail_patient_manifest.csv (64x64 grayscale K-means)
Downstream classification accuracy is affected similarly by all three, so the
question this answers is: do the three methods actually partition the images
differently? If they agree closely, the method choice is cosmetic; if they
diverge, the groupings are genuinely different partitions — which, combined with
the Task06 ground-truth validation (where siamese scored higher purity/capture),
is what makes the siamese work worthwhile.
Metrics are all label-invariant (cluster-id names don't matter):
ARI - Adjusted Rand Index (chance-corrected pair agreement)
NMI - Normalized Mutual Information
V - V-measure (harmonic mean of homogeneity & completeness)
Outputs:
results/compare_patient_groupings.json
plots/analysis/grouping_agreement_heatmap.png
Usage:
conda activate fundus_imaging
python scripts/analysis/compare_patient_groupings.py
"""
import os
import csv
import json
from itertools import combinations
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.metrics import (
adjusted_rand_score,
normalized_mutual_info_score,
homogeneity_completeness_v_measure,
)
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
RESULTS_DIR = os.path.join(ROOT, "results")
PLOTS_DIR = os.path.join(ROOT, "plots", "analysis")
METHODS = {
"siamese": "siamese_manifest.csv",
"pca50": "simple_patient_manifest.csv",
"thumbnail": "thumbnail_patient_manifest.csv",
}
METHOD_ORDER = ["siamese", "pca50", "thumbnail"]
CLASS_OF = {"B": "Benign", "M": "Malignant", "N": "Normal"}
def load_manifest(path):
"""Return {image_short_name: group_id} from a manifest CSV."""
mapping = {}
with open(path, newline="") as f:
for row in csv.DictReader(f):
for img in row["images"].split(";"):
if img:
mapping[img] = row["patient_id"]
return mapping
def size_stats(labels):
"""Group-size distribution for one method's label array."""
_, counts = np.unique(labels, return_counts=True)
return {
"n_groups": int(len(counts)),
"min": int(counts.min()),
"median": float(np.median(counts)),
"mean": float(counts.mean()),
"max": int(counts.max()),
"singletons": int((counts == 1).sum()),
}
def agreement(a, b):
h, c, v = homogeneity_completeness_v_measure(a, b)
return {"ARI": float(adjusted_rand_score(a, b)),
"NMI": float(normalized_mutual_info_score(a, b)),
"V": float(v)}
def main():
maps = {m: load_manifest(os.path.join(RESULTS_DIR, f))
for m, f in METHODS.items()}
# Align on images present in all three (should be the full 1,097).
common = sorted(set.intersection(*[set(mp) for mp in maps.values()]))
classes = np.array([CLASS_OF.get(img.split("_")[0], "?") for img in common])
labels = {m: np.array([maps[m][img] for img in common]) for m in METHOD_ORDER}
print(f"Aligned on {len(common)} images common to all three methods.\n")
# --- Structural summary --------------------------------------------------
print("=" * 72)
print("GROUP STRUCTURE PER METHOD")
print("=" * 72)
print(f"{'method':<12s} {'groups':>7s} {'min':>5s} {'median':>7s} "
f"{'mean':>6s} {'max':>5s} {'singletons':>11s}")
structure = {}
for m in METHOD_ORDER:
s = size_stats(labels[m])
structure[m] = s
print(f"{m:<12s} {s['n_groups']:>7d} {s['min']:>5d} {s['median']:>7.1f} "
f"{s['mean']:>6.1f} {s['max']:>5d} {s['singletons']:>11d}")
# --- Pairwise agreement --------------------------------------------------
print("\n" + "=" * 72)
print("PAIRWISE AGREEMENT (how similarly the methods partition the images)")
print("=" * 72)
print(f"{'pair':<24s} {'ARI':>8s} {'NMI':>8s} {'V':>8s}")
print("-" * 52)
pairwise = {}
ari_matrix = np.eye(len(METHOD_ORDER))
for i, j in combinations(range(len(METHOD_ORDER)), 2):
a, b = METHOD_ORDER[i], METHOD_ORDER[j]
g = agreement(labels[a], labels[b])
pairwise[f"{a}_vs_{b}"] = g
ari_matrix[i, j] = ari_matrix[j, i] = g["ARI"]
print(f"{a+' vs '+b:<24s} {g['ARI']:>8.3f} {g['NMI']:>8.3f} {g['V']:>8.3f}")
# --- Per-class ARI -------------------------------------------------------
print("\n" + "=" * 72)
print("PER-CLASS ARI (agreement within each diagnostic class)")
print("=" * 72)
print(f"{'pair':<24s} " + " ".join(f"{c:>10s}" for c in ["Benign", "Malignant", "Normal"]))
print("-" * 60)
per_class = {}
for i, j in combinations(range(len(METHOD_ORDER)), 2):
a, b = METHOD_ORDER[i], METHOD_ORDER[j]
row = {}
cells = []
for c in ["Benign", "Malignant", "Normal"]:
mask = classes == c
ari = float(adjusted_rand_score(labels[a][mask], labels[b][mask]))
row[c] = ari
cells.append(f"{ari:>10.3f}")
per_class[f"{a}_vs_{b}"] = row
print(f"{a+' vs '+b:<24s} " + " ".join(cells))
# --- Odd-one-out: mean ARI of each method vs the other two ---------------
print("\n" + "=" * 72)
print("MEAN ARI OF EACH METHOD VS THE OTHER TWO (lower = most distinct)")
print("=" * 72)
mean_ari = {}
for i, m in enumerate(METHOD_ORDER):
others = [ari_matrix[i, j] for j in range(len(METHOD_ORDER)) if j != i]
mean_ari[m] = float(np.mean(others))
print(f" {m:<12s} {mean_ari[m]:.3f}")
odd = min(mean_ari, key=mean_ari.get)
print(f"\n Most distinct grouping: {odd}")
# --- Save + heatmap ------------------------------------------------------
out = {
"n_images": len(common),
"structure": structure,
"pairwise": pairwise,
"per_class_ARI": per_class,
"mean_ari_vs_others": mean_ari,
"most_distinct": odd,
}
os.makedirs(RESULTS_DIR, exist_ok=True)
out_path = os.path.join(RESULTS_DIR, "compare_patient_groupings.json")
with open(out_path, "w") as f:
json.dump(out, f, indent=2)
print(f"\nSaved metrics → {out_path}")
fig, ax = plt.subplots(figsize=(5.5, 4.5))
im = ax.imshow(ari_matrix, vmin=0, vmax=1, cmap="viridis")
ax.set_xticks(range(len(METHOD_ORDER)))
ax.set_yticks(range(len(METHOD_ORDER)))
ax.set_xticklabels(METHOD_ORDER)
ax.set_yticklabels(METHOD_ORDER)
for i in range(len(METHOD_ORDER)):
for j in range(len(METHOD_ORDER)):
ax.text(j, i, f"{ari_matrix[i, j]:.2f}", ha="center", va="center",
color="white" if ari_matrix[i, j] < 0.6 else "black", fontsize=11)
ax.set_title("Patient-grouping agreement (ARI)\nIQ-OTH/NCCD, 1,097 images")
fig.colorbar(im, ax=ax, label="Adjusted Rand Index")
plt.tight_layout()
os.makedirs(PLOTS_DIR, exist_ok=True)
plot_path = os.path.join(PLOTS_DIR, "grouping_agreement_heatmap.png")
plt.savefig(plot_path, dpi=150)
plt.close()
print(f"Saved heatmap → {plot_path}")
if __name__ == "__main__":
main()
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""
verify_feature_dims.py
Quick check: what does TF/Keras actually output for each model
with include_top=False? Compare against manuscript claims.
"""
import numpy as np
from tensorflow.keras.applications import (
VGG16, DenseNet121, EfficientNetB1, MobileNetV2, ResNet50
)
# Match the manuscript's input sizes
models = {
"VGG16": (VGG16, 224),
"DenseNet121": (DenseNet121, 224),
"EfficientNetB1":(EfficientNetB1, 240),
"MobileNetV2": (MobileNetV2, 224),
"ResNet50": (ResNet50, 224),
}
manuscript_claims = {
"VGG16": 25088,
"DenseNet121": 50176,
"EfficientNetB1":62720,
"MobileNetV2": 62720,
"ResNet50": 100352,
}
print(f"{'Model':<18s} {'Input':>5s} {'Spatial':>10s} {'Flattened':>10s} {'Manuscript':>12s} {'Match?':>7s}")
print("-" * 70)
for name, (model_fn, input_size) in models.items():
model = model_fn(weights="imagenet", include_top=False,
input_shape=(input_size, input_size, 3))
# Pass a dummy batch through
dummy = np.random.randn(1, input_size, input_size, 3)
# Need to preprocess correctly — but shape doesn't depend on values
output = model.predict(dummy, verbose=0)
spatial = output.shape[1:4] # (H, W, C) for channels_last
flattened = int(np.prod(spatial))
claimed = manuscript_claims[name]
match = "" if flattened == claimed else ""
print(f"{name:<18s} {input_size:>4}d {str(spatial):>10s} {flattened:>10d} {claimed:>12d} {match:>7s}")
print("\nNote: TF uses channels_last (NHWC) format.")
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
{
"VGG16::image": {
"curves": {
"32": 0.9624025974025974,
"54": 0.9749545454545455,
"93": 0.980642857142857,
"158": 0.9840649350649351,
"271": 0.9886168831168831,
"464": 0.9897532467532468,
"794": 0.9908961038961038,
"1359": 0.9886168831168831,
"2326": 0.9897532467532468,
"3981": 0.9920324675324675,
"6813": 0.987487012987013,
"11659": 0.9760714285714286,
"19953": 0.9635389610389609
},
"best_nfeat": 3981,
"best_cv": 0.9920324675324675,
"best_gamma": 5.411792740597548e-05
},
"MobileNetV2::image": {
"curves": {
"32": 0.93612987012987,
"54": 0.943,
"93": 0.9589675324675324,
"158": 0.9692077922077921,
"271": 0.9771818181818182,
"464": 0.9794740259740259,
"794": 0.9829090909090908,
"1359": 0.9851818181818182,
"2326": 0.9874610389610389,
"3981": 0.9886038961038961,
"6813": 0.9863246753246753,
"11659": 0.979474025974026,
"19953": 0.9783441558441558,
"34145": 0.9635194805194806,
"58434": 0.9566948051948053
},
"best_nfeat": 3981,
"best_cv": 0.9886038961038961,
"best_gamma": 5.411792740597548e-05
},
"DenseNet121::image": {
"curves": {
"32": 0.9475324675324674,
"54": 0.9577987012987013,
"93": 0.9760519480519481,
"158": 0.9772012987012987,
"271": 0.982896103896104,
"464": 0.9840454545454547,
"794": 0.9840389610389609,
"1359": 0.9840454545454544,
"2326": 0.9851818181818182,
"3981": 0.9863246753246753,
"6813": 0.9874675324675325,
"11659": 0.9840519480519481,
"19953": 0.9829155844155844,
"34145": 0.9829090909090908
},
"best_nfeat": 6813,
"best_cv": 0.9874675324675325,
"best_gamma": 3.162240848424899e-05
},
"ResNet50::image": {
"curves": {
"32": 0.9521168831168831,
"54": 0.9646948051948051,
"93": 0.9658376623376623,
"158": 0.974935064935065,
"271": 0.97837012987013,
"464": 0.9795,
"794": 0.9829155844155844,
"1359": 0.9863506493506493,
"2326": 0.9908961038961038,
"3981": 0.9886168831168831,
"6813": 0.987474025974026,
"11659": 0.987474025974026,
"19953": 0.9863311688311688,
"34145": 0.9840584415584415,
"58434": 0.9840649350649351,
"100000": 0.9829285714285714
},
"best_nfeat": 2326,
"best_cv": 0.9908961038961038,
"best_gamma": 9.262401934788839e-05
},
"EfficientNetB1::image": {
"curves": {
"32": 0.9373441558441558,
"54": 0.9247987012987012,
"93": 0.954422077922078,
"158": 0.9544415584415585,
"271": 0.9669805194805194,
"464": 0.9715324675324675,
"794": 0.9760649350649351,
"1359": 0.9772272727272726,
"2326": 0.982896103896104,
"3981": 0.9840584415584415,
"6813": 0.9863311688311688,
"11659": 0.9851818181818182,
"19953": 0.9851883116883118,
"34145": 0.9806298701298701,
"58434": 0.9760649350649351
},
"best_nfeat": 6813,
"best_cv": 0.9863311688311688,
"best_gamma": 3.162240848424899e-05
},
"VGG16::patient": {
"curves": {
"32": 0.8775928827840215,
"54": 0.8869860655394717,
"93": 0.9017508373724363,
"158": 0.899545406113065,
"271": 0.9168027205072213,
"464": 0.9111734782922494,
"794": 0.8892728094160605,
"1359": 0.8776515115789267,
"2326": 0.8788736781890476,
"3981": 0.8718631943594317,
"6813": 0.8729861925854772,
"11659": 0.8579623693039702,
"19953": 0.8415711387799029
},
"best_nfeat": 271,
"best_cv": 0.9168027205072213,
"best_gamma": 0.0001166892125523387
},
"MobileNetV2::patient": {
"curves": {
"32": 0.8788294912848766,
"54": 0.8777222470719559,
"93": 0.8671963468340518,
"158": 0.886821936334651,
"271": 0.9026897499150754,
"464": 0.9027096807901112,
"794": 0.9118403567490991,
"1359": 0.9084816014962362,
"2326": 0.9015949854380466,
"3981": 0.897043807427408,
"6813": 0.8878722188414819,
"11659": 0.8754424416978528,
"19953": 0.867454694839946,
"34145": 0.8502226180733488,
"58434": 0.8421961732955723
},
"best_nfeat": 794,
"best_cv": 0.9118403567490991,
"best_gamma": 3.98271745613146e-05
},
"DenseNet121::patient": {
"curves": {
"32": 0.8883307016099342,
"54": 0.8776358621830977,
"93": 0.8695492943753337,
"158": 0.9020030888753784,
"271": 0.9217021570809798,
"464": 0.9203788855354906,
"794": 0.9297944483539279,
"1359": 0.9321867182439819,
"2326": 0.9274615287727703,
"3981": 0.9065480925304101,
"6813": 0.8846197537489628,
"11659": 0.8697672582591949,
"19953": 0.865148271598542,
"34145": 0.8606789978555252
},
"best_nfeat": 1359,
"best_cv": 0.9321867182439819,
"best_gamma": 2.3269151288950545e-05
},
"ResNet50::patient": {
"curves": {
"32": 0.8429676740201181,
"54": 0.855707644929198,
"93": 0.876331816908905,
"158": 0.8754247864722885,
"271": 0.8913213110995345,
"464": 0.8961471621478985,
"794": 0.9121510194763156,
"1359": 0.9099166863641326,
"2326": 0.9086942852731289,
"3981": 0.8994412881459878,
"6813": 0.8971347519512485,
"11659": 0.8834703540651564,
"19953": 0.8708504896171615,
"34145": 0.8685060975020858,
"58434": 0.8604140407100207,
"100000": 0.861563465997377
},
"best_nfeat": 794,
"best_cv": 0.9121510194763156,
"best_gamma": 3.98271745613146e-05
},
"EfficientNetB1::patient": {
"curves": {
"32": 0.839392438341168,
"54": 0.8532932121895296,
"93": 0.8648301746424334,
"158": 0.87897726765122,
"271": 0.8822707327204625,
"464": 0.9066292229158499,
"794": 0.9124550742250127,
"1359": 0.9042832672872496,
"2326": 0.8927915426153833,
"3981": 0.8928568764738968,
"6813": 0.8767214401504766,
"11659": 0.8685078785862419,
"19953": 0.8597186612447733,
"34145": 0.8562044440055402,
"58434": 0.8596541437822435
},
"best_nfeat": 794,
"best_cv": 0.9124550742250127,
"best_gamma": 3.98271745613146e-05
}
}
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env python3
"""
classification.py — single-seed image-level vs patient-level comparison.
Usage:
conda activate fundus_imaging
python scripts/classification.py
"""
import os, sys, json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from classes import PatientLeakageClassifier
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODELS = ["VGG16", "DenseNet121", "EfficientNetB1", "MobileNetV2", "ResNet50"]
SEED = 20
clf = PatientLeakageClassifier(
os.path.join(ROOT, "results", "simple_patient_manifest.csv"),
os.path.join(ROOT, "features"),
n_jobs=8)
print(f"{'='*60}")
print(f"IMAGE-LEVEL vs PATIENT-LEVEL (seed={SEED})")
print(f"{'='*60}")
results = []
for name in MODELS:
print(f"\n {name} ...")
img = clf.run(name, SEED, "image")
pat = clf.run(name, SEED, "patient")
results.append({"model": name,
"image_cv": img["cv"], "image_test": img["test"],
"patient_cv": pat["cv"], "patient_test": pat["test"],
"drop": img["test"] - pat["test"]})
print(f" Image: CV={img['cv']:.4f} Test={img['test']:.4f}")
print(f" Patient: CV={pat['cv']:.4f} Test={pat['test']:.4f}")
print(f" Drop: {img['test'] - pat['test']:.4f}")
print(f"\n {'Model':<18s} {'Img-CV':>8s} {'Img-Test':>9s} "
f"{'Pat-CV':>8s} {'Pat-Test':>9s} {'Drop':>7s}")
print(f" {'-'*54}")
for r in results:
print(f" {r['model']:<18s} {r['image_cv']:>8.4f} {r['image_test']:>9.4f} "
f"{r['patient_cv']:>8.4f} {r['patient_test']:>9.4f} {r['drop']:>7.4f}")
with open(os.path.join(ROOT, "results", "classification_results.json"), "w") as f:
json.dump(results, f, indent=2)
print(f"\nDONE")
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
classification.py — single-seed image-level vs patient-level comparison.
This script OWNS the shared classification-runs cache
(scripts/cache/classification_runs.json): a flat list of per-(model, seed,
split) run dicts. It reads any runs it needs from the cache and writes back any
it has to compute, so the cache is maintained here. figure4_6.py references the
same file to draw the 20-seed boxplots.
Usage:
conda activate fundus_imaging
python scripts/classification.py
"""
import os, sys, json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
from classes import PatientLeakageClassifier
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(SCRIPT_DIR)
MODELS = ["VGG16", "DenseNet121", "EfficientNetB1", "MobileNetV2", "ResNet50"]
SEED = 20
CACHE_PATH = os.path.join(SCRIPT_DIR, "cache", "classification_runs.json")
# Load the run cache: keep the raw list (to append to) and an index (to look up).
runs = []
if os.path.exists(CACHE_PATH):
with open(CACHE_PATH) as f:
runs = json.load(f)
index = {(r["model"], r["seed"], r["split_type"]): r for r in runs}
clf = None # lazily created only if the cache is missing something
def get_run(name, split):
"""Return the cached run, else compute it and write it back to the cache."""
global clf
hit = index.get((name, SEED, split))
if hit is not None:
return hit
if clf is None:
clf = PatientLeakageClassifier(
os.path.join(ROOT, "results", "simple_patient_manifest.csv"),
os.path.join(ROOT, "features"), n_jobs=8)
print(f" (cache miss for {name}/{split} — computing)")
r = clf.run(name, SEED, split)
runs.append(r)
index[(name, SEED, split)] = r
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
with open(CACHE_PATH, "w") as f:
json.dump(runs, f, indent=2)
return r
print(f"{'='*60}")
print(f"IMAGE-LEVEL vs PATIENT-LEVEL (seed={SEED})")
print(f"{'='*60}")
results = []
for name in MODELS:
print(f"\n {name} ...")
img = get_run(name, "image")
pat = get_run(name, "patient")
results.append({"model": name,
"image_cv": img["cv"], "image_test": img["test"],
"patient_cv": pat["cv"], "patient_test": pat["test"],
"drop": img["test"] - pat["test"]})
print(f" Image: CV={img['cv']:.4f} Test={img['test']:.4f}")
print(f" Patient: CV={pat['cv']:.4f} Test={pat['test']:.4f}")
print(f" Drop: {img['test'] - pat['test']:.4f}")
print(f"\n {'Model':<18s} {'Img-CV':>8s} {'Img-Test':>9s} "
f"{'Pat-CV':>8s} {'Pat-Test':>9s} {'Drop':>7s}")
print(f" {'-'*54}")
for r in results:
print(f" {r['model']:<18s} {r['image_cv']:>8.4f} {r['image_test']:>9.4f} "
f"{r['patient_cv']:>8.4f} {r['patient_test']:>9.4f} {r['drop']:>7.4f}")
with open(os.path.join(ROOT, "results", "classification_pca50.json"), "w") as f:
json.dump(results, f, indent=2)
print(f"\nDONE")
@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""classification_siamese.py — Run classification with siamese patient manifest."""
import os, sys, json
import numpy as np
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
from classes import PatientLeakageClassifier
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
MANIFEST = os.path.join(ROOT, "results", "siamese_manifest.csv")
FEATURES_DIR = os.path.join(ROOT, "features")
RESULTS_DIR = os.path.join(ROOT, "results")
SEED = 20
MODELS = ["VGG16", "DenseNet121", "EfficientNetB1", "MobileNetV2", "ResNet50"]
clf = PatientLeakageClassifier(MANIFEST, FEATURES_DIR, n_jobs=6)
print("=" * 60)
print("Siamese-based patient classification")
print("=" * 60)
results = []
for model_name in MODELS:
print(f"\n {model_name} ...", flush=True)
img = clf.run(model_name, SEED, "image")
pat = clf.run(model_name, SEED, "patient")
drop = img["test"] - pat["test"]
results.append({
"model": model_name, "manifest": "siamese",
"image_cv": img["cv"], "image_test": img["test"],
"patient_cv": pat["cv"], "patient_test": pat["test"],
"drop": drop,
})
print(f" Image: CV={img['cv']:.4f} Test={img['test']:.4f}")
print(f" Patient: CV={pat['cv']:.4f} Test={pat['test']:.4f} "
f"Drop={drop:.4f}")
# Save
out = os.path.join(RESULTS_DIR, "classification_siamese.json")
with open(out, "w") as f:
json.dump(results, f, indent=2)
# Comparison table
print(f"\n{'='*80}")
print("COMPARISON — All three patient-clustering methods (seed=20)")
print(f"{'='*80}")
def safe_load(path):
if os.path.exists(path):
with open(path) as f: return {r["model"]: r for r in json.load(f)}
return None
pca50 = safe_load(os.path.join(RESULTS_DIR, "classification_pca50.json"))
thumbnail = safe_load(os.path.join(RESULTS_DIR, "classification_thumbnail.json"))
siamese = {r["model"]: r for r in results}
print(f"\n{'Model':<18s} {'PCA50 Pat':>10s} {'Thumb Pat':>11s} {'Siam Pat':>10s} "
f"{'PCA50 Drop':>11s} {'Thumb Drop':>11s} {'Siam Drop':>10s}")
print("-" * 82)
for m in MODELS:
f_pat = f"{pca50[m]['patient_test']:>10.4f}" if pca50 else " N/A"
t_pat = f"{thumbnail[m]['patient_test']:>11.4f}" if thumbnail else " N/A"
f_drop = f"{pca50[m]['drop']:>11.4f}" if pca50 else " N/A"
t_drop = f"{thumbnail[m]['drop']:>11.4f}" if thumbnail else " N/A"
print(f"{m:<18s} {f_pat} {t_pat} "
f"{siamese[m]['patient_test']:>10.4f} {f_drop} {t_drop} "
f"{siamese[m]['drop']:>10.4f}")
print(f"\nSaved → {out}")
print("DONE")
@@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
classification_thumbnail.py — Run the classification pipeline using the
manuscript's thumbnail-based K-means patient manifest.
Saves results alongside the feature-based results for comparison.
Usage:
conda activate fundus_imaging
python scripts/classification_thumbnail.py
"""
import os, sys, csv, json, re
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
from classes import PatientIdentifier, PatientLeakageClassifier
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
DATASET_PATH = os.path.join(os.path.dirname(ROOT),
"The IQ-OTHNCCD lung cancer dataset")
FEATURES_DIR = os.path.join(ROOT, "features")
RESULTS_DIR = os.path.join(ROOT, "results")
SEED = 20
MODELS = ["VGG16", "DenseNet121", "EfficientNetB1", "MobileNetV2", "ResNet50"]
# Known patient counts per class
PATIENT_COUNTS = {
"Bengin cases": 15,
"Malignant cases": 40,
"Normal cases": 55,
}
# ---------------------------------------------------------------------------
# Step 1: Build thumbnail-based patient manifest
# ---------------------------------------------------------------------------
print("=" * 60)
print("Building thumbnail-based patient manifest")
print("=" * 60)
# Collect all image filenames and class labels (same order as feature extraction)
image_paths, all_labels, all_fnames = [], [], []
for class_name in sorted(os.listdir(DATASET_PATH)):
class_path = os.path.join(DATASET_PATH, class_name)
if not os.path.isdir(class_path):
continue
for file in sorted(os.listdir(class_path)):
if file.lower().endswith((".png", ".jpg", ".jpeg")):
image_paths.append(os.path.join(class_path, file))
all_labels.append(class_name)
all_fnames.append(file)
print(f"Found {len(image_paths)} images across "
f"{len(set(all_labels))} classes")
# Run thumbnail K-means
labels_arr = np.array(all_labels)
fnames_arr = np.array(all_fnames)
identifier = PatientIdentifier(
patient_estimates=PATIENT_COUNTS, random_state=42)
groups = identifier.identify_from_thumbnails(
DATASET_PATH, fnames_arr, labels_arr)
# Build manifest
manifest = identifier.build_assignment_dict(groups, fnames_arr, labels_arr)
# Save manifest
manifest_path = os.path.join(RESULTS_DIR, "thumbnail_patient_manifest.csv")
def f2n(fname):
m = re.search(r"\((\d+)\)", fname)
num = int(m.group(1)) if m else None
for cls_key, prefix in [
("Bengin cases", "B"), ("Malignant cases", "M"), ("Normal cases", "N"),
]:
if fname.startswith(cls_key.rstrip("s")):
return f"{prefix}_{num:03d}" if num else fname
return fname
with open(manifest_path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["patient_id", "class", "n_images", "images"])
for pid in sorted(manifest.keys()):
imgs = manifest[pid]
short_names = [f2n(img) for img in imgs]
cls = pid.split("_")[0] # "benign_0" → "benign"
cls = {"benign": "Benign", "malig": "Malignant",
"normal": "Normal"}.get(cls, cls)
writer.writerow([pid, cls, len(imgs), ";".join(short_names)])
print(f"Manifest saved → {manifest_path}")
print(f" {len(manifest)} estimated patients")
# Per-class stats
for cls in ["Bengin cases", "Malignant cases", "Normal cases"]:
short_cls = {"Bengin cases": "benign", "Malignant cases": "malig",
"Normal cases": "normal"}[cls]
cls_patients = {k: v for k, v in manifest.items()
if k.startswith(short_cls)}
n_pat = len(cls_patients)
n_img = sum(len(v) for v in cls_patients.values())
print(f" {cls}: {n_pat} patients, {n_img} images")
# ---------------------------------------------------------------------------
# Step 2: Run classification with thumbnail manifest
# ---------------------------------------------------------------------------
print(f"\n{'=' * 60}")
print("Running classification (thumbnail manifest, seed=20)")
print("=" * 60)
clf = PatientLeakageClassifier(manifest_path, FEATURES_DIR, n_jobs=6)
results = []
for model_name in MODELS:
print(f"\n {model_name} ...", flush=True)
img = clf.run(model_name, SEED, "image")
pat = clf.run(model_name, SEED, "patient")
results.append({
"model": model_name,
"manifest": "thumbnail",
"image_cv": img["cv"],
"image_test": img["test"],
"patient_cv": pat["cv"],
"patient_test": pat["test"],
"drop": img["test"] - pat["test"],
})
print(f" Image: CV={img['cv']:.4f} Test={img['test']:.4f}")
print(f" Patient: CV={pat['cv']:.4f} Test={pat['test']:.4f} "
f"Drop={img['test'] - pat['test']:.4f}")
# ---------------------------------------------------------------------------
# Save results
# ---------------------------------------------------------------------------
out_path = os.path.join(RESULTS_DIR, "classification_thumbnail.json")
with open(out_path, "w") as f:
json.dump(results, f, indent=2)
# Also load feature-based results for side-by-side comparison
feat_path = os.path.join(RESULTS_DIR, "classification_results.json")
if os.path.exists(feat_path):
with open(feat_path) as f:
feat_results = json.load(f)
print(f"\n{'=' * 80}")
print("COMPARISON: Feature-based (PCA-50) vs Thumbnail K-means")
print(f"{'=' * 80}")
print(f"{'Model':<18s} {'Feat Image':>11s} {'Thumb Image':>12s} "
f"{'Feat Pat':>9s} {'Thumb Pat':>10s} {'Feat Drop':>10s} "
f"{'Thumb Drop':>11s}")
print("-" * 78)
for tr, fr in zip(results, feat_results):
assert tr["model"] == fr["model"]
print(f"{tr['model']:<18s} {fr['image_test']:>11.4f} "
f"{tr['image_test']:>12.4f} {fr['patient_test']:>9.4f} "
f"{tr['patient_test']:>10.4f} {fr['drop']:>10.4f} "
f"{tr['drop']:>11.4f}")
print(f"\nSaved → {out_path}")
print("DONE")
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Validate PCA-50 feature K-means on the same Task06 test patients as siamese."""
import os, sys, json, argparse
import numpy as np
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
from PIL import Image as PILImage
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from classes import PatientIdentifier, FeatureExtractor
from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PNG_DIR = os.path.join(ROOT, "features", "task06_pngs", "test")
RESULTS_DIR = os.path.join(ROOT, "results", "clustering_validation")
PLOTS_DIR = os.path.join(ROOT, "plots")
SEED = 42
MODELS = ["VGG16", "DenseNet121", "EfficientNetB1", "MobileNetV2", "ResNet50"]
def cluster_purity_stats(y_true, y_pred):
purities = []; overall_correct = 0
for c in np.unique(y_pred):
mask = y_pred == c
_, counts = np.unique(y_true[mask], return_counts=True)
purities.append(counts.max() / mask.sum())
overall_correct += counts.max()
purities = np.array(purities)
return {"overall": float(overall_correct / len(y_true)),
"median": float(np.median(purities)), "mean": float(np.mean(purities)),
"frac_gt_70": float((purities > 0.7).mean()),
"frac_gt_90": float((purities > 0.9).mean())}
def patient_capture_stats(y_true, y_pred):
captures = []
for p in np.unique(y_true):
mask = y_true == p
p_clusters = y_pred[mask]
_, counts = np.unique(p_clusters, return_counts=True)
captures.append(counts.max() / mask.sum())
captures = np.array(captures)
return {"median": float(np.median(captures)), "mean": float(np.mean(captures)),
"frac_gt_50": float((captures > 0.5).mean())}
def plot_assignment_matrix(y_true, y_pred, out_path, title):
true_patients = sorted(np.unique(y_true))
pred_clusters = sorted(np.unique(y_pred))
matrix = np.zeros((len(true_patients), len(pred_clusters)))
for i, p in enumerate(true_patients):
for j, c in enumerate(pred_clusters):
matrix[i, j] = ((y_true == p) & (y_pred == c)).sum()
matrix_norm = matrix / (matrix.sum(axis=1, keepdims=True) + 1e-8)
fig, ax = plt.subplots(figsize=(max(14, len(pred_clusters)*0.22), max(10, len(true_patients)*0.18)))
cmap = plt.cm.YlOrRd.copy()
cmap.set_under('white')
ax.imshow(matrix_norm, aspect="auto", cmap=cmap, vmin=1e-6, vmax=1)
ax.set_xticks(range(len(pred_clusters)))
ax.set_xticklabels([f"c{c}" for c in pred_clusters], fontsize=6, rotation=90)
ax.set_yticks(range(len(true_patients)))
ax.set_yticklabels(true_patients, fontsize=7)
ax.set_xlabel("Predicted cluster"); ax.set_ylabel("True patient")
ax.set_title(title, fontsize=11)
plt.colorbar(ax.images[0], ax=ax, label="Fraction of patient's slices")
plt.tight_layout()
os.makedirs(os.path.dirname(out_path), exist_ok=True)
plt.savefig(out_path, dpi=150); plt.close()
print(f" Heatmap → {out_path}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default="VGG16", choices=MODELS)
ap.add_argument("--all-models", action="store_true")
ap.add_argument("--tag", default="")
ap.add_argument("--full", action="store_true",
help="Run on full NIfTI dataset (63 patients, not just test).")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
os.makedirs(RESULTS_DIR, exist_ok=True); os.makedirs(PLOTS_DIR, exist_ok=True)
if args.full:
tag = (tag or "") + "_full"
from classes import NiftiSliceDataset
VOL = os.path.join(os.path.dirname(ROOT), "Task06_Lung", "imagesTr")
ds = NiftiSliceDataset(VOL, random_slices=False, seed=SEED, rotate_deg=90)
ds.load_all_slices(stride=1)
test_slices = ds.slices_rgb
test_pids = ds.patient_labels
test_fnames = ds.filenames
y_true_map = {f: pid for f, pid in zip(test_fnames, test_pids)}
k = ds.n_patients
y_true = np.array([y_true_map[f] for f in test_fnames])
print(f" FULL dataset: {len(test_fnames)} slices, {k} patients")
else:
manifest_path = os.path.join(PNG_DIR, "manifest.json")
if not os.path.exists(manifest_path):
print(f"Test PNGs not found at {PNG_DIR}. Run train_siamese.py first.")
sys.exit(1)
with open(manifest_path) as f: png = json.load(f)
test_paths = png["paths"]
test_pids = np.array(png["patient_ids"])
test_fnames = [p.split("/")[-1].replace(".png", "") for p in test_paths]
y_true_map = {f: pid for f, pid in zip(test_fnames, test_pids)}
k = len(np.unique(test_pids))
y_true = np.array([y_true_map[f] for f in test_fnames])
test_slices = [PILImage.open(p).convert("L") for p in test_paths]
print(f" {len(test_fnames)} slices, {k} patients (same test set as siamese)")
models_to_run = MODELS if args.all_models else [args.model]
for model_name in models_to_run:
print(f"\n{''*50}")
print(f"Model: {model_name}")
print(f"{''*50}")
print(f" Extracting features ...", flush=True)
ext = FeatureExtractor(model_name=model_name)
X, _, _ = ext.extract_from_images(test_slices)
print(f" Clustering PCA-50 into k={k} ...", flush=True)
ident = PatientIdentifier(patient_estimates={"lung": k}, random_state=SEED)
groups = ident.identify_from_features(X, np.full(len(X), "lung"))
y_pred = np.array([str(g) for g in groups])
purity = cluster_purity_stats(y_true, y_pred)
capture = patient_capture_stats(y_true, y_pred)
ari = adjusted_rand_score(y_true, y_pred)
nmi = normalized_mutual_info_score(y_true, y_pred)
print(f"\n RESULTS — PCA-50 K-means ({model_name})")
print(f" {''*45}")
print(f" ARI: {ari:.4f}")
print(f" NMI: {nmi:.4f}")
print(f" Overall purity: {purity['overall']:.3f} ({purity['overall']*100:.1f}%)")
print(f" Mean capture: {capture['mean']:.3f} ({capture['mean']*100:.1f}%)")
results = {"method": f"pca50_{model_name}", "model": model_name,
"n_patients": int(k), "n_slices": len(test_fnames),
"ARI": ari, "NMI": nmi,
"cluster_purity": purity, "patient_capture": capture}
out_json = os.path.join(RESULTS_DIR, f"validate_pca50_{model_name}{tag}.json")
with open(out_json, "w") as f: json.dump(results, f, indent=2)
print(f" Metrics → {out_json}")
plot_assignment_matrix(y_true, y_pred,
os.path.join(PLOTS_DIR, "clustering_validation", "pca50",
f"assignment_matrix_{model_name}{tag}.png"),
f"PCA-50 K-means {model_name} (ARI={ari:.3f}, purity={purity['overall']:.1%}, capture mean={capture['mean']:.1%})")
# Summary table if all models
if args.all_models:
print(f"\n{'='*60}")
print("SUMMARY — All models")
print(f"{'='*60}")
print(f"{'Model':<18s} {'ARI':>7s} {'Purity':>8s} {'Capture':>8s}")
print("-" * 43)
for model_name in MODELS:
j = os.path.join(RESULTS_DIR, f"validate_pca50_{model_name}{tag}.json")
if os.path.exists(j):
with open(j) as f: d = json.load(f)
print(f"{model_name:<18s} {d['ARI']:>7.3f} {d['cluster_purity']['overall']:>7.1%} {d['patient_capture']['mean']:>7.1%}")
print("DONE")
if __name__ == "__main__": main()
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""
validate_siamese.py — Validate the trained siamese network against Task06
held-out test patients.
Runs both connected-components and edge-ranking clustering (known K) on the
test set and reports metrics + assignment heatmaps.
Usage:
conda activate fundus_imaging
python scripts/clustering_validation/validate_siamese.py
python scripts/clustering_validation/validate_siamese.py --threshold 0.95 --tag v2
"""
import os, sys, json, argparse
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
from classes import SiamesePatientMatcher
from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
MODELS_DIR = os.path.join(ROOT, "models")
RESULTS_DIR = os.path.join(ROOT, "results", "clustering_validation")
PLOTS_DIR = os.path.join(ROOT, "plots")
PNG_DIR = os.path.join(ROOT, "features", "task06_pngs", "test")
VOLUME_DIR = os.path.join(os.path.dirname(ROOT), "Task06_Lung", "imagesTr")
SEED = 42
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def cluster_purity_stats(y_true, y_pred):
purities = []
overall_correct = 0
for c in np.unique(y_pred):
mask = y_pred == c
_, counts = np.unique(y_true[mask], return_counts=True)
purities.append(counts.max() / mask.sum())
overall_correct += counts.max()
purities = np.array(purities)
return {
"overall": float(overall_correct / len(y_true)),
"median": float(np.median(purities)),
"mean": float(np.mean(purities)),
"frac_gt_70": float((purities > 0.7).mean()),
"frac_gt_90": float((purities > 0.9).mean()),
}
def patient_capture_stats(y_true, y_pred):
captures = []
for p in np.unique(y_true):
mask = y_true == p
p_clusters = y_pred[mask]
_, counts = np.unique(p_clusters, return_counts=True)
captures.append(counts.max() / mask.sum())
captures = np.array(captures)
return {
"median": float(np.median(captures)),
"mean": float(np.mean(captures)),
"frac_gt_50": float((captures > 0.5).mean()),
}
def score_manifest(manifest, y_true_map):
"""Score a manifest against ground-truth patient IDs."""
preds, truths = [], []
for pred_pid, fnames in manifest.items():
for f in fnames:
preds.append(pred_pid)
truths.append(y_true_map.get(f, f"unknown_{f}"))
y_true = np.array(truths)
y_pred = np.array(preds)
purity = cluster_purity_stats(y_true, y_pred)
capture = patient_capture_stats(y_true, y_pred)
ari = adjusted_rand_score(y_true, y_pred)
nmi = normalized_mutual_info_score(y_true, y_pred)
return {"ARI": ari, "NMI": nmi,
"cluster_purity": purity, "patient_capture": capture,
"y_true": y_true, "y_pred": y_pred}
def plot_assignment_matrix(y_true, y_pred, out_path, title):
true_patients = sorted(np.unique(y_true))
pred_clusters = sorted(np.unique(y_pred))
matrix = np.zeros((len(true_patients), len(pred_clusters)))
for i, p in enumerate(true_patients):
for j, c in enumerate(pred_clusters):
matrix[i, j] = ((y_true == p) & (y_pred == c)).sum()
matrix_norm = matrix / (matrix.sum(axis=1, keepdims=True) + 1e-8)
fig, ax = plt.subplots(figsize=(max(14, len(pred_clusters) * 0.22),
max(10, len(true_patients) * 0.18)))
cmap = plt.cm.YlOrRd.copy()
cmap.set_under('white')
im = ax.imshow(matrix_norm, aspect="auto", cmap=cmap, vmin=1e-6, vmax=1)
ax.set_xticks(range(len(pred_clusters)))
ax.set_xticklabels([f"c{c}" for c in pred_clusters], fontsize=6, rotation=90)
ax.set_yticks(range(len(true_patients)))
ax.set_yticklabels(true_patients, fontsize=7)
ax.set_xlabel("Predicted cluster"); ax.set_ylabel("True patient")
ax.set_title(title, fontsize=11)
plt.colorbar(im, ax=ax, label="Fraction of patient's slices")
plt.tight_layout()
os.makedirs(os.path.dirname(out_path), exist_ok=True)
plt.savefig(out_path, dpi=150)
plt.close()
print(f" Heatmap → {out_path}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default=os.path.join(MODELS_DIR, "siamese_resnet18.pt"))
ap.add_argument("--backbone", default="resnet18")
ap.add_argument("--threshold", type=float, default=0.9)
ap.add_argument("--tag", default="", help="Append tag to output filenames")
ap.add_argument("--full", action="store_true",
help="Run on full NIfTI dataset (leaked training data).")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
os.makedirs(RESULTS_DIR, exist_ok=True)
os.makedirs(PLOTS_DIR, exist_ok=True)
# ---- Load test PNGs ----
if args.full:
tag = (tag or "") + "_full"
from classes import NiftiSliceDataset
VOL = os.path.join(os.path.dirname(ROOT), "Task06_Lung", "imagesTr")
ds = NiftiSliceDataset(VOL, random_slices=False, seed=SEED, rotate_deg=90)
ds.load_all_slices(stride=1)
# Export full PNGs to temp dir
import tempfile
tmpdir = tempfile.mkdtemp(prefix="task06_full_")
full_paths, full_pids, _ = ds.export_pngs(tmpdir)
test_paths = full_paths
test_pids = np.array(full_pids)
print(f" FULL dataset: {len(test_paths)} slices, {len(np.unique(test_pids))} patients")
print(f" (includes training data — leakage expected for siamese)")
else:
manifest_path = os.path.join(PNG_DIR, "manifest.json")
if not os.path.exists(manifest_path):
print(f"Test PNGs not found at {PNG_DIR}")
print("Run train_siamese.py first to generate the test set.")
sys.exit(1)
with open(manifest_path) as f:
png_manifest = json.load(f)
test_paths = png_manifest["paths"]
test_pids = np.array(png_manifest["patient_ids"])
# Short filenames for matching
test_fnames = [p.split("/")[-1].replace(".png", "") for p in test_paths]
y_true_map = {f: pid for f, pid in zip(test_fnames, test_pids)}
k = len(np.unique(test_pids))
print("=" * 60)
print(f"Siamese validation — held-out test set")
print("=" * 60)
print(f" {len(test_paths)} slices, {k} patients")
print(f" Model: {args.model}")
# ---- Load siamese model ----
print(f"\nLoading siamese model ...")
matcher = SiamesePatientMatcher(
args.model, backbone=args.backbone, input_size=224)
# ---- Connected components (no known K) ----
print(f"\n{''*50}")
print("Method 1: Connected components (threshold-based)")
print(f"{''*50}")
manifest_cc = matcher.identify_patients(
test_paths, filenames=test_fnames,
threshold=args.threshold, top_k=20, k=None)
# ---- Edge-ranking clustering (known K) ----
print(f"\n{''*50}")
print(f"Method 2: Edge-ranking clustering (k={k})")
print(f"{''*50}")
manifest_sc = matcher.identify_patients(
test_paths, filenames=test_fnames,
threshold=args.threshold, top_k=20, k=k)
# ---- Score both methods ----
results_cc = score_manifest(manifest_cc, y_true_map)
results_sc = score_manifest(manifest_sc, y_true_map)
print(f"\n{'='*60}")
print("RESULTS — Siamese patient identification")
print(f"{'='*60}")
print(f"\n{'Method':<30s} {'ARI':>7s} {'NMI':>7s} {' Purity Capture':>8s}")
print(f"{'':30s} {'':>7s} {'':>7s} {' (overall) (mean) ':>8s}")
print("-" * 60)
for name, r in [("Connected components", results_cc),
("Edge ranking (k=" + str(k) + ")", results_sc)]:
p = r["cluster_purity"]["overall"]
c = r["patient_capture"]["mean"]
print(f"{name:<30s} {r['ARI']:>7.3f} {r['NMI']:>7.3f} "
f"{p:>7.1%} {c:>7.1%}")
# ---- Save metrics ----
for suffix, r in [("cc", results_cc), ("sc", results_sc)]:
out = {k: v for k, v in r.items() if k not in ("y_true", "y_pred")}
out["method"] = suffix
out["threshold"] = args.threshold
out["n_patients"] = int(k)
out["n_slices"] = len(test_paths)
out_json = os.path.join(RESULTS_DIR, f"validate_siamese_{suffix}{tag}.json")
with open(out_json, "w") as f:
json.dump(out, f, indent=2)
print(f" Metrics → {out_json}")
# ---- Plot siamese methods ----
for name, r in [("connected_components", results_cc),
("edge_rank", results_sc)]:
p = r["cluster_purity"]["overall"]
c = r["patient_capture"]["mean"]
title = (f"Siamese Patient-Cluster Assignment ({name})\n"
f"(ARI={r['ARI']:.3f}, purity={p:.1%}, "
f"capture mean={c:.1%})")
plot_assignment_matrix(
r["y_true"], r["y_pred"],
os.path.join(PLOTS_DIR, "clustering_validation", "siamese",
f"assignment_matrix_{name}{tag}.png"),
title)
print("DONE")
if __name__ == "__main__":
main()
@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""Validate thumbnail K-means on the same Task06 test patients as siamese."""
import os, sys, json, argparse
import numpy as np
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
from PIL import Image as PILImage
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from classes import PatientIdentifier
from sklearn.metrics import adjusted_rand_score, normalized_mutual_info_score
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PNG_DIR = os.path.join(ROOT, "features", "task06_pngs", "test")
RESULTS_DIR = os.path.join(ROOT, "results", "clustering_validation")
PLOTS_DIR = os.path.join(ROOT, "plots")
SEED = 42
def cluster_purity_stats(y_true, y_pred):
purities = []; overall_correct = 0
for c in np.unique(y_pred):
mask = y_pred == c
_, counts = np.unique(y_true[mask], return_counts=True)
purities.append(counts.max() / mask.sum())
overall_correct += counts.max()
purities = np.array(purities)
return {"overall": float(overall_correct / len(y_true)),
"median": float(np.median(purities)), "mean": float(np.mean(purities)),
"frac_gt_70": float((purities > 0.7).mean()),
"frac_gt_90": float((purities > 0.9).mean())}
def patient_capture_stats(y_true, y_pred):
captures = []
for p in np.unique(y_true):
mask = y_true == p
p_clusters = y_pred[mask]
_, counts = np.unique(p_clusters, return_counts=True)
captures.append(counts.max() / mask.sum())
captures = np.array(captures)
return {"median": float(np.median(captures)), "mean": float(np.mean(captures)),
"frac_gt_50": float((captures > 0.5).mean())}
def plot_assignment_matrix(y_true, y_pred, out_path, title):
true_patients = sorted(np.unique(y_true))
pred_clusters = sorted(np.unique(y_pred))
matrix = np.zeros((len(true_patients), len(pred_clusters)))
for i, p in enumerate(true_patients):
for j, c in enumerate(pred_clusters):
matrix[i, j] = ((y_true == p) & (y_pred == c)).sum()
matrix_norm = matrix / (matrix.sum(axis=1, keepdims=True) + 1e-8)
fig, ax = plt.subplots(figsize=(max(14, len(pred_clusters)*0.22), max(10, len(true_patients)*0.18)))
cmap = plt.cm.YlOrRd.copy()
cmap.set_under('white')
ax.imshow(matrix_norm, aspect="auto", cmap=cmap, vmin=1e-6, vmax=1)
ax.set_xticks(range(len(pred_clusters)))
ax.set_xticklabels([f"c{c}" for c in pred_clusters], fontsize=6, rotation=90)
ax.set_yticks(range(len(true_patients)))
ax.set_yticklabels(true_patients, fontsize=7)
ax.set_xlabel("Predicted cluster"); ax.set_ylabel("True patient")
ax.set_title(title, fontsize=11)
plt.colorbar(ax.images[0], ax=ax, label="Fraction of patient's slices")
plt.tight_layout()
os.makedirs(os.path.dirname(out_path), exist_ok=True)
plt.savefig(out_path, dpi=150); plt.close()
print(f" Heatmap → {out_path}")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--tag", default="")
ap.add_argument("--full", action="store_true",
help="Run on full NIfTI dataset (63 patients, not just test).")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
os.makedirs(RESULTS_DIR, exist_ok=True); os.makedirs(PLOTS_DIR, exist_ok=True)
if args.full:
tag = (tag or "") + "_full"
from classes import NiftiSliceDataset
VOL = os.path.join(os.path.dirname(ROOT), "Task06_Lung", "imagesTr")
ds = NiftiSliceDataset(VOL, random_slices=False, seed=SEED, rotate_deg=90)
ds.load_all_slices(stride=1)
test_paths = None
test_pids = ds.patient_labels
test_fnames = ds.filenames
y_true_map = {f: pid for f, pid in zip(test_fnames, test_pids)}
k = ds.n_patients
print("=" * 60)
print("Thumbnail K-means validation (manuscript method) — FULL")
print("=" * 60)
print(f" FULL dataset: {len(test_fnames)} slices, {k} patients")
# Build thumbnails from loaded slices
thumbs = []
for sl in ds.slices_rgb:
img = PILImage.fromarray(sl).convert("L").resize((64, 64))
thumbs.append(np.asarray(img, dtype=np.float32).flatten() / 255.0)
thumbs = np.array(thumbs, dtype=np.float32)
else:
manifest_path = os.path.join(PNG_DIR, "manifest.json")
if not os.path.exists(manifest_path):
print(f"Test PNGs not found at {PNG_DIR}. Run train_siamese.py first.")
sys.exit(1)
with open(manifest_path) as f: png = json.load(f)
test_paths = png["paths"]
test_pids = np.array(png["patient_ids"])
test_fnames = [p.split("/")[-1].replace(".png", "") for p in test_paths]
y_true_map = {f: pid for f, pid in zip(test_fnames, test_pids)}
k = len(np.unique(test_pids))
print("=" * 60)
print("Thumbnail K-means validation (manuscript method)")
print("=" * 60)
print(f" {len(test_fnames)} slices, {k} patients (same test set as siamese)")
# Build thumbnails from PNGs
print(" Building 64x64 thumbnails ...", flush=True)
thumbs = []
for p in test_paths:
img = PILImage.open(p).convert("L").resize((64, 64))
thumbs.append(np.asarray(img, dtype=np.float32).flatten() / 255.0)
thumbs = np.array(thumbs, dtype=np.float32)
print(f" Clustering into k={k} ...", flush=True)
ident = PatientIdentifier(patient_estimates={"lung": k}, random_state=SEED)
groups = ident.identify_from_features(thumbs, np.full(len(thumbs), "lung"))
y_pred = np.array([str(g) for g in groups])
y_true = np.array([y_true_map[f] for f in test_fnames])
purity = cluster_purity_stats(y_true, y_pred)
capture = patient_capture_stats(y_true, y_pred)
ari = adjusted_rand_score(y_true, y_pred)
nmi = normalized_mutual_info_score(y_true, y_pred)
print(f"\n{'='*50}")
print("RESULTS — Thumbnail K-means (manuscript)")
print(f"{'='*50}")
print(f" ARI: {ari:.4f}")
print(f" NMI: {nmi:.4f}")
print(f" Overall purity: {purity['overall']:.3f} ({purity['overall']*100:.1f}%)")
print(f" Mean capture: {capture['mean']:.3f} ({capture['mean']*100:.1f}%)")
results = {"method": "thumbnail_64x64", "n_patients": int(k),
"n_slices": len(test_fnames), "ARI": ari, "NMI": nmi,
"cluster_purity": purity, "patient_capture": capture}
out_json = os.path.join(RESULTS_DIR, f"validate_thumbnail{tag}.json")
with open(out_json, "w") as f: json.dump(results, f, indent=2)
print(f" Metrics → {out_json}")
plot_assignment_matrix(y_true, y_pred,
os.path.join(PLOTS_DIR, "clustering_validation", "thumbnail", f"assignment_matrix{tag}.png"),
f"Thumbnail K-means (ARI={ari:.3f}, purity={purity['overall']:.1%}, capture mean={capture['mean']:.1%})")
print("DONE")
if __name__ == "__main__": main()
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
"""
create_patient_groups.py
End-to-end pipeline for all five CNNs from the manuscript:
VGG16, DenseNet121, EfficientNetB1, MobileNetV2, ResNet50
For each model:
1. Extract deep features and save to features/{Model}_features.npz
2. Generate PCA / t-SNE plot → plots/{Model}_pca_tsne.png
3. Estimate patient groups via K-means → features/{Model}_patient_groups.npy
Usage:
conda activate fundus_imaging
python scripts/create_patient_groups.py
"""
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from classes import FeatureExtractor, PatientIdentifier
from classes.visualizations import plot_pca_tsne
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
BASE_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"The IQ-OTHNCCD lung cancer dataset"
)
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FEATURES_DIR = os.path.join(PROJECT_ROOT, "features")
PLOTS_DIR = os.path.join(PROJECT_ROOT, "plots")
os.makedirs(FEATURES_DIR, exist_ok=True)
os.makedirs(PLOTS_DIR, exist_ok=True)
MODELS = ["VGG16", "DenseNet121", "EfficientNetB1", "MobileNetV2", "ResNet50"]
PATIENT_ESTIMATES = {
"Bengin cases": 15,
"Malignant cases": 40,
"Normal cases": 55,
}
LEGEND_NAMES = {
"Bengin cases": "Benign",
"Malignant cases": "Malignant",
"Normal cases": "Normal",
}
# ---------------------------------------------------------------------------
# Run pipeline for each model
# ---------------------------------------------------------------------------
for model_name in MODELS:
print("\n" + "=" * 60)
print(f"MODEL: {model_name}")
print("=" * 60)
# --- Step 1: Extract features ---
print("\n [1/3] Feature extraction ...")
extractor = FeatureExtractor(model_name=model_name)
features_path = os.path.join(FEATURES_DIR, f"{model_name}_features.npz")
if os.path.exists(features_path):
print(f" Loading cached features from {features_path}")
X, Y, filenames = FeatureExtractor.load_features(features_path)
else:
X, Y, filenames = extractor.extract(BASE_PATH)
extractor.save_features(X, Y, filenames, FEATURES_DIR)
print(f" {model_name}: X shape = {X.shape}")
# --- Step 2: PCA / t-SNE ---
print(f"\n [2/3] PCA / t-SNE visualization ...")
plot_path = os.path.join(PLOTS_DIR, f"{model_name}_pca_tsne.png")
plot_pca_tsne(X, Y, legend_names=LEGEND_NAMES, output_path=plot_path)
# --- Step 3: Patient identification ---
print(f"\n [3/3] Patient identification (K-means) ...")
identifier = PatientIdentifier(patient_estimates=PATIENT_ESTIMATES)
groups = identifier.identify(BASE_PATH, filenames, Y)
groups_path = os.path.join(FEATURES_DIR, f"{model_name}_patient_groups.npy")
np.save(groups_path, groups)
print(f" Saved patient groups → {groups_path}")
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
print("\n" + "=" * 60)
print("ALL MODELS COMPLETE")
print("=" * 60)
for model_name in MODELS:
fp = os.path.join(FEATURES_DIR, f"{model_name}_features.npz")
gp = os.path.join(FEATURES_DIR, f"{model_name}_patient_groups.npy")
pp = os.path.join(PLOTS_DIR, f"{model_name}_pca_tsne.png")
print(f" {model_name:18s} features: {os.path.basename(fp):30s} groups: {os.path.basename(gp)}")
-97
View File
@@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""
figure6.py — test accuracy boxplots across 20 seeds (Figure 6).
Usage:
conda activate fundus_imaging
python scripts/figure6.py
"""
import os, sys, json
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
from tqdm import tqdm
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from classes import PatientLeakageClassifier
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODELS = ["VGG16", "DenseNet121", "EfficientNetB1", "MobileNetV2", "ResNet50"]
SEEDS = list(range(1, 21))
clf = PatientLeakageClassifier(
os.path.join(ROOT, "results", "simple_patient_manifest.csv"),
os.path.join(ROOT, "features"),
n_jobs=8)
# ---------------------------------------------------------------------------
# Run all seeds
# ---------------------------------------------------------------------------
all_data = [] # flat list of per-run dicts for raw data file
for name in tqdm(MODELS, desc="Models"):
for seed in tqdm(SEEDS, desc=f" {name} seeds", leave=False):
for stype in ["image", "patient"]:
r = clf.run(name, seed, stype)
all_data.append(r)
# Save raw data
with open(os.path.join(ROOT, "results", "figure6_data.json"), "w") as f:
json.dump(all_data, f, indent=2)
# ---------------------------------------------------------------------------
# Build per-model accuracy lists
# ---------------------------------------------------------------------------
accs = {} # model -> {image: [accs], patient: [accs]}
for name in MODELS:
accs[name] = {"image": [], "patient": []}
for r in all_data:
accs[r["model"]][r["split_type"]].append(r["test"])
# ---------------------------------------------------------------------------
# Plot
# ---------------------------------------------------------------------------
fig, ax = plt.subplots(1, 1, figsize=(10, 6))
for i, name in enumerate(MODELS):
pos_img = i * 2 + 0.7
pos_pat = i * 2 + 1.3
for pos, stype, color in [(pos_img, "image", '#4C9BD4'),
(pos_pat, "patient", '#6DBF6D')]:
data = accs[name][stype]
bp = ax.boxplot(data, positions=[pos], widths=0.5,
patch_artist=True, showfliers=True,
flierprops=dict(marker='o', markersize=3))
bp['boxes'][0].set_facecolor(color)
med = np.median(data)
ax.annotate(f"{med:.3f}", (pos, med), fontsize=6,
ha='center', va='bottom')
ax.legend(handles=[
Patch(facecolor='#4C9BD4', label='Image-level split'),
Patch(facecolor='#6DBF6D', label='Patient-level split'),
], loc='lower right')
ax.set_xticks([p + 1 for p in range(0, len(MODELS) * 2, 2)])
ax.set_xticklabels(MODELS)
ax.set_ylabel("Test accuracy")
ax.set_title("Figure 6 — Image-level vs Patient-level test accuracy (20 seeds)")
ax.set_ylim(0.7, 1.02)
ax.grid(axis='y', alpha=0.3)
plt.tight_layout()
out = os.path.join(ROOT, "plots", "figure6.png")
plt.savefig(out, dpi=150)
plt.close()
print(f"\nSaved → {out}")
print(f"Saved → {ROOT}/results/figure6_data.json")
print("DONE")
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""
siamese_identify.py — Apply a trained siamese model to IQ-OTH/NCCD to build
a patient manifest via connected-components clustering.
Usage:
conda activate fundus_imaging
python scripts/siamese_identify.py
python scripts/siamese_identify.py --threshold 0.95
python scripts/siamese_identify.py --backbone resnet34
"""
import os
import sys
import csv
import re
import argparse
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from classes import SiamesePatientMatcher
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODELS_DIR = os.path.join(ROOT, "models")
RESULTS_DIR = os.path.join(ROOT, "results")
DEFAULT_DATASET = os.path.join(os.path.dirname(ROOT), "The IQ-OTHNCCD lung cancer dataset")
BACKBONE_INPUT_SIZES = {
"resnet18": 224,
"resnet34": 224,
"efficientnet_b0": 240,
}
def f2n(fname):
"""Convert IQ-OTH filename to short form: 'Bengin case (1).jpg''B_001'."""
m = re.search(r"\((\d+)\)", fname)
num = int(m.group(1)) if m else None
for cls_key, prefix in [
("Bengin cases", "B"), ("Malignant cases", "M"), ("Normal cases", "N"),
]:
if fname.startswith(cls_key.rstrip("s")):
return f"{prefix}_{num:03d}" if num else fname
return fname
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--model", default=os.path.join(MODELS_DIR, "siamese_resnet18.pt"),
help="Path to trained siamese model.")
ap.add_argument("--backbone", default="resnet18",
choices=list(BACKBONE_INPUT_SIZES.keys()))
ap.add_argument("--dataset", default=DEFAULT_DATASET,
help="Path to IQ-OTH/NCCD dataset directory.")
ap.add_argument("--threshold", type=float, default=0.9,
help="Minimum siamese probability to create an edge.")
ap.add_argument("--top-k", type=int, default=20,
help="Top-K candidates to verify per slice.")
ap.add_argument("--cluster-method", default="edge_rank",
choices=["edge_rank", "complete", "average"],
help="Clustering on the siamese graph. 'edge_rank' is "
"single-linkage (chains on OOD data); 'complete'/"
"'average' are chaining-resistant agglomerative.")
ap.add_argument("--min-size", type=int, default=None,
help="Absorb groups smaller than this into their nearest "
"group (mitigates leakage-prone singletons).")
ap.add_argument("--max-size", type=int, default=None,
help="Split groups larger than this at their natural gaps "
"in siamese-distance space.")
ap.add_argument("--keep-k", action="store_true",
help="Preserve the known patient count K while enforcing "
"size bounds (balanced bisection + nearest-merge).")
ap.add_argument("--output", default=None,
help="Output manifest path (default: results/siamese_manifest.csv).")
ap.add_argument("--device", default=None)
args = ap.parse_args()
input_size = BACKBONE_INPUT_SIZES[args.backbone]
# ---- Load model ----
print(f"Loading model: {args.model}")
print(f" backbone={args.backbone}, input_size={input_size}")
matcher = SiamesePatientMatcher(
args.model, backbone=args.backbone,
device=args.device, input_size=input_size)
# ---- Collect IQ-OTH images ----
print(f"\nScanning dataset: {args.dataset}")
image_paths = []
class_labels = []
for class_name in sorted(os.listdir(args.dataset)):
class_path = os.path.join(args.dataset, class_name)
if not os.path.isdir(class_path):
continue
for fname in sorted(os.listdir(class_path)):
if fname.lower().endswith((".png", ".jpg", ".jpeg")):
image_paths.append(os.path.join(class_path, fname))
class_labels.append(class_name)
print(f" Found {len(image_paths)} images across "
f"{len(set(class_labels))} classes")
# ---- Identify patients within each class (spectral clustering with known K) ----
KNOWN_K = {
"Bengin cases": 15, # typo in original dataset
"Malignant cases": 40,
"Normal cases": 55,
}
all_assignments = {}
for class_name in sorted(set(class_labels)):
class_mask = [i for i, c in enumerate(class_labels) if c == class_name]
class_paths = [image_paths[i] for i in class_mask]
class_fnames = [os.path.basename(p) for p in class_paths]
k = KNOWN_K.get(class_name)
print(f"\n{'='*50}")
print(f"Class: {class_name} ({len(class_paths)} images, k={k})")
print(f"{'='*50}")
manifest = matcher.identify_patients(
class_paths,
filenames=[f2n(f) for f in class_fnames],
threshold=args.threshold,
top_k=args.top_k,
k=k,
cluster_method=args.cluster_method,
min_size=args.min_size,
max_size=args.max_size,
keep_k=args.keep_k,
)
# Prefix with class and a per-class running index. Enumerate rather than
# reuse the raw cluster id: rebalancing yields pids like "siamese_0_s0"
# whose last token ("s0") is not unique and would collide.
short_cls = {"Bengin cases": "Benign", "Malignant cases": "Malignant",
"Normal cases": "Normal"}[class_name]
prefixed = {f"{short_cls}_{i}": imgs
for i, (pid, imgs) in enumerate(manifest.items())}
all_assignments.update(prefixed)
# ---- Save manifest ----
output_path = args.output or os.path.join(
RESULTS_DIR, "siamese_manifest.csv")
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Determine class for each patient from the filenames
def get_class(fname):
if fname.startswith("B_"):
return "Benign"
elif fname.startswith("M_"):
return "Malignant"
elif fname.startswith("N_"):
return "Normal"
return "Unknown"
with open(output_path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["patient_id", "class", "n_images", "images"])
for pid in sorted(all_assignments.keys()):
imgs = all_assignments[pid]
cls = get_class(pid)
writer.writerow([pid, cls, len(imgs), ";".join(imgs)])
print(f"\nManifest saved → {output_path}")
print(f" {len(all_assignments)} estimated patients, "
f"{sum(len(v) for v in all_assignments.values())} images")
# Summary per class
print(f"\n{'Class':<20s} {'Patients':>10s} {'Images':>8s} {'Mean imgs/pat':>14s}")
print("-" * 54)
for cls in ["Benign", "Malignant", "Normal"]:
cls_patients = {k: v for k, v in all_assignments.items()
if get_class(k) == cls}
n_pat = len(cls_patients)
n_img = sum(len(v) for v in cls_patients.values())
mean = n_img / n_pat if n_pat > 0 else 0
print(f"{cls:<20s} {n_pat:>10d} {n_img:>8d} {mean:>14.1f}")
print("\nDONE")
if __name__ == "__main__":
main()
+499
View File
@@ -0,0 +1,499 @@
#!/usr/bin/env python3
"""
train_siamese.py — Train a siamese CNN on Task06_Lung to determine if two
CT slices come from the same patient.
Usage:
conda activate fundus_imaging
python scripts/train_siamese.py
python scripts/train_siamese.py --backbone resnet34 --epochs 15
"""
import os
import sys
import json
import time
import argparse
import numpy as np
os.environ["OMP_NUM_THREADS"] = "1"
os.environ["OPENBLAS_NUM_THREADS"] = "1"
os.environ["MKL_NUM_THREADS"] = "1"
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from PIL import Image
from sklearn.metrics import accuracy_score, roc_auc_score
from scipy.sparse.csgraph import connected_components
from scipy.sparse import csr_matrix
from collections import defaultdict
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from classes import NiftiSliceDataset, SiameseCNN
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MODELS_DIR = os.path.join(ROOT, "models")
RESULTS_DIR = os.path.join(ROOT, "results")
DEFAULT_CACHE_DIR = os.path.join(ROOT, "features", "task06_pngs")
BACKBONES = {
"resnet18": (512, 224),
"resnet34": (512, 224),
"efficientnet_b0": (1280, 240),
}
SEED = 42
BATCH_SIZE = 64
EPOCHS = 15
LR = 1e-4
MIN_Z_GAP = 20
HARD_NEG_FRAC = 0.5
TEST_PATIENTS = 8
N_PAIRS = 20000
# ---------------------------------------------------------------------------
# Pair dataset
# ---------------------------------------------------------------------------
class PairDataset(Dataset):
"""Yield (img_A, img_B, label) pairs with hard negative mining."""
def __init__(self, paths, patient_ids, z_indices, n_pairs,
transform=None, min_z_gap=MIN_Z_GAP,
hard_neg_frac=HARD_NEG_FRAC, seed=SEED):
rng = np.random.default_rng(seed)
unique_patients = np.unique(patient_ids)
z_to_samples = defaultdict(list)
for idx in range(len(paths)):
z_to_samples[z_indices[idx]].append((idx, patient_ids[idx]))
n_pos = n_pairs // 2
n_neg = n_pairs - n_pos
n_hard = int(n_neg * hard_neg_frac)
pairs, labels = [], []
pos_z_pairs = []
# Positive pairs: same patient, z-distance >= min_z_gap
for _ in range(n_pos):
for __ in range(100):
pid = rng.choice(unique_patients)
idx = np.where(patient_ids == pid)[0]
if len(idx) < 2:
continue
i, j = rng.choice(idx, size=2, replace=False)
if abs(int(z_indices[i]) - int(z_indices[j])) >= min_z_gap:
pairs.append((i, j))
labels.append(1)
pos_z_pairs.append((z_indices[i], z_indices[j]))
break
while len(labels) < n_pos:
pid = rng.choice(unique_patients)
idx = np.where(patient_ids == pid)[0]
if len(idx) < 2:
continue
i, j = rng.choice(idx, size=2, replace=False)
pairs.append((i, j))
labels.append(1)
pos_z_pairs.append((z_indices[i], z_indices[j]))
# Hard negatives: different patients, matched z-positions
for _ in range(n_hard):
z_i, z_j = pos_z_pairs[rng.integers(0, len(pos_z_pairs))]
for __ in range(100):
s_i = z_to_samples.get(z_i, [])
s_j = z_to_samples.get(z_j, [])
if len(s_i) < 1 or len(s_j) < 1:
break
si = s_i[rng.integers(0, len(s_i))]
sj = s_j[rng.integers(0, len(s_j))]
if si[1] != sj[1]:
pairs.append((si[0], sj[0]))
labels.append(0)
break
# Easy negatives
while len(labels) < n_pairs:
p1, p2 = rng.choice(unique_patients, size=2, replace=False)
i = rng.choice(np.where(patient_ids == p1)[0])
j = rng.choice(np.where(patient_ids == p2)[0])
pairs.append((i, j))
labels.append(0)
order = rng.permutation(len(labels))
self.pairs = [(pairs[o][0], pairs[o][1]) for o in order]
self.labels = [labels[o] for o in order]
self.paths = paths
self.transform = transform
def __len__(self):
return len(self.pairs)
def __getitem__(self, idx):
i, j = self.pairs[idx]
img_a = Image.open(self.paths[i]).convert("RGB")
img_b = Image.open(self.paths[j]).convert("RGB")
if self.transform:
img_a = self.transform(img_a)
img_b = self.transform(img_b)
return img_a, img_b, torch.tensor(self.labels[idx], dtype=torch.float32)
# ---------------------------------------------------------------------------
# Training
# ---------------------------------------------------------------------------
def train_epoch(model, loader, optimizer, criterion, device):
model.train()
total_loss, correct, n = 0.0, 0, 0
for a, b, y in loader:
a, b, y = a.to(device), b.to(device), y.to(device)
optimizer.zero_grad()
loss = criterion(model(a, b), y)
loss.backward()
optimizer.step()
preds = (torch.sigmoid(model(a, b)) > 0.5).float()
total_loss += loss.item() * len(y)
correct += (preds == y).sum().item()
n += len(y)
return total_loss / n, correct / n
@torch.no_grad()
def eval_epoch(model, loader, criterion, device):
model.eval()
total_loss, n = 0.0, 0
all_preds, all_labels = [], []
for a, b, y in loader:
a, b, y = a.to(device), b.to(device), y.to(device)
logits = model(a, b)
total_loss += criterion(logits, y).item() * len(y)
all_preds.extend(torch.sigmoid(logits).cpu().numpy())
all_labels.extend(y.cpu().numpy())
n += len(y)
all_labels = np.array(all_labels, dtype=int)
all_preds = np.array(all_preds, dtype=float)
acc = accuracy_score(all_labels, all_preds > 0.5)
try:
auc = roc_auc_score(all_labels, all_preds)
except ValueError:
auc = 0.5
return total_loss / n, acc, auc
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _export_subset(slices, patient_ids, filenames, cache_dir):
"""Export a subset of slices as PNGs. Cached via manifest.json."""
manifest_path = os.path.join(cache_dir, "manifest.json")
# Include slice count in key to detect stale caches (e.g. different split)
cache_key = f"{len(slices)}_{len(patient_ids)}"
if os.path.exists(manifest_path):
with open(manifest_path) as f:
m = json.load(f)
if m.get("_key") == cache_key and len(m["paths"]) == len(slices):
return m["paths"], np.array(m["patient_ids"]), np.array(m["z_indices"])
else:
print(f" Cache stale (key mismatch), re-exporting ...", flush=True)
print(f" Exporting {len(slices)} PNGs to {cache_dir} ...", flush=True)
paths, pids, zs = [], [], []
for i, (sl, pid, fname) in enumerate(zip(slices, patient_ids, filenames)):
out_path = os.path.join(cache_dir, f"{fname}.png")
Image.fromarray(sl).save(out_path)
paths.append(out_path)
pids.append(pid)
zs.append(int(fname.split("_slice")[-1]))
if (i + 1) % 500 == 0:
print(f" {i + 1}/{len(slices)} ...", flush=True)
with open(manifest_path, "w") as f:
json.dump({"_key": f"{len(slices)}_{len(patient_ids)}",
"paths": paths, "patient_ids": [str(p) for p in pids],
"z_indices": [int(z) for z in zs]}, f)
return paths, np.array(pids), np.array(zs)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--backbone", default="resnet18",
choices=list(BACKBONES.keys()))
ap.add_argument("--n-pairs", type=int, default=N_PAIRS)
ap.add_argument("--epochs", type=int, default=EPOCHS)
ap.add_argument("--lr", type=float, default=LR)
ap.add_argument("--batch-size", type=int, default=BATCH_SIZE)
ap.add_argument("--cache-dir", default=DEFAULT_CACHE_DIR)
ap.add_argument("--flip-vertical", action="store_true",
help="Flip slices vertically to match IQ-OTH orientation.")
ap.add_argument("--rotate", type=int, default=0,
choices=[0, 90, 180, 270],
help="Rotate slices by N degrees (e.g. 90 if spine is on left).")
ap.add_argument("--test-patients", type=int, default=TEST_PATIENTS,
help="Number of patients held out for final testing.")
ap.add_argument("--device", default=None)
args = ap.parse_args()
feat_dim, input_size = BACKBONES[args.backbone]
device = torch.device(args.device or (
"cuda" if torch.cuda.is_available() else "cpu"))
print(f"Device: {device}, backbone: {args.backbone}, "
f"input_size: {input_size}")
volume_dir = os.path.join(os.path.dirname(ROOT), "Task06_Lung", "imagesTr")
# ---- Split patients into train / held-out test ----
# First, get the list of patients without loading all data
from classes.nifti_dataset import NiftiSliceDataset as _DS
import glob as _glob
_nii = _glob.glob(os.path.join(volume_dir, "*.nii"))
_gz = [p for p in _glob.glob(os.path.join(volume_dir, "*.nii.gz"))
if os.path.basename(p)[:-7] not in
{os.path.basename(q)[:-4] for q in _nii}]
all_volumes = sorted(_nii + _gz)
all_patient_ids = [os.path.basename(p).replace(".nii.gz", "").replace(".nii", "")
for p in all_volumes]
n_total = len(all_patient_ids)
rng = np.random.default_rng(SEED)
test_pids = set(rng.choice(all_patient_ids,
size=min(args.test_patients, n_total - 1),
replace=False))
train_pids_set = set(all_patient_ids) - test_pids
print(f"\nPatients: {n_total} total → {len(train_pids_set)} train, "
f"{len(test_pids)} held-out test\n")
# ---- Extract TRAIN slices (sparse, for training speed) ----
print("=" * 50)
print("Loading TRAIN slices (stride=3)")
print("=" * 50)
ds_train = _DS(volume_dir, random_slices=False, seed=SEED,
flip_vertical=args.flip_vertical, rotate_deg=args.rotate)
ds_train.load_all_slices(stride=3)
# Filter to train patients only
train_slice_mask = np.isin(ds_train.patient_labels, list(train_pids_set))
train_slices = [sl for i, sl in enumerate(ds_train.slices_rgb)
if train_slice_mask[i]]
train_pids_arr = ds_train.patient_labels[train_slice_mask]
train_fnames = ds_train.filenames[train_slice_mask]
train_zs = np.array([int(f.split("_slice")[-1]) for f in train_fnames])
# Export train PNGs
train_cache = os.path.join(args.cache_dir, "train")
os.makedirs(train_cache, exist_ok=True)
train_paths, _, _ = _export_subset(
train_slices, train_pids_arr, train_fnames, train_cache)
print(f" Train: {len(train_paths)} slices, "
f"{len(np.unique(train_pids_arr))} patients\n")
# ---- Extract TEST slices (dense, for thorough eval) ----
print("=" * 50)
print("Loading TEST slices (stride=1, full central 60%)")
print("=" * 50)
ds_test = _DS(volume_dir, random_slices=False, seed=SEED,
flip_vertical=args.flip_vertical)
ds_test.load_all_slices(stride=1)
test_slice_mask = np.isin(ds_test.patient_labels, list(test_pids))
test_slices = [sl for i, sl in enumerate(ds_test.slices_rgb)
if test_slice_mask[i]]
test_pids_arr = ds_test.patient_labels[test_slice_mask]
test_fnames = ds_test.filenames[test_slice_mask]
test_zs = np.array([int(f.split("_slice")[-1]) for f in test_fnames])
# Export test PNGs
test_cache = os.path.join(args.cache_dir, "test")
os.makedirs(test_cache, exist_ok=True)
test_paths, _, _ = _export_subset(
test_slices, test_pids_arr, test_fnames, test_cache)
print(f" Test: {len(test_paths)} slices, "
f"{len(np.unique(test_pids_arr))} patients\n")
# ---- Transforms ----
train_tf = transforms.Compose([
transforms.Resize((input_size, input_size)),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])
val_tf = transforms.Compose([
transforms.Resize((input_size, input_size)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])
# ---- Training pairs (80% of train patients for training, 20% for val monitoring) ----
train_unique = np.unique(train_pids_arr)
n_val_patients = max(2, int(len(train_unique) * 0.2))
val_pids_set = set(rng.choice(train_unique, size=n_val_patients, replace=False))
tr_pids_set = set(train_unique) - val_pids_set
tr_mask = np.isin(train_pids_arr, list(tr_pids_set))
val_mask = np.isin(train_pids_arr, list(val_pids_set))
tr_paths_list = [train_paths[i] for i in np.where(tr_mask)[0]]
val_paths_list = [train_paths[i] for i in np.where(val_mask)[0]]
print(f" Training pairs from: {len(tr_paths_list)} slices, "
f"{len(tr_pids_set)} patients")
print(f" Val pairs from: {len(val_paths_list)} slices, "
f"{len(val_pids_set)} patients")
print(f"\nGenerating {args.n_pairs:,} training pairs ...", flush=True)
t0 = time.time()
tr_ds = PairDataset(tr_paths_list, train_pids_arr[tr_mask],
train_zs[tr_mask],
n_pairs=args.n_pairs, transform=train_tf, seed=SEED)
print(f" ... done ({time.time() - t0:.0f}s)")
val_n = args.n_pairs // 4
print(f"Generating {val_n:,} validation pairs ...", flush=True)
val_ds = PairDataset(val_paths_list, train_pids_arr[val_mask],
train_zs[val_mask],
n_pairs=val_n, transform=val_tf, seed=SEED + 1)
tr_loader = DataLoader(tr_ds, batch_size=args.batch_size,
shuffle=True, num_workers=4)
val_loader = DataLoader(val_ds, batch_size=args.batch_size, num_workers=4)
# ---- Model ----
model = SiameseCNN(args.backbone).to(device)
print(f"\nModel: {sum(p.numel() for p in model.parameters()):,} parameters")
for p in model.backbone.parameters():
p.requires_grad = False
criterion = nn.BCEWithLogitsLoss()
optimizer = optim.Adam(model.parameters(), lr=args.lr)
# ---- Train ----
best_val_acc = 0
unfreeze_epoch = max(1, args.epochs // 2)
print(f"\n{'Epoch':>6s} {'tr_loss':>8s} {'tr_acc':>8s} "
f"{'val_loss':>8s} {'val_acc':>8s} {'val_auc':>8s}")
print("-" * 52)
for epoch in range(1, args.epochs + 1):
if epoch == unfreeze_epoch + 1:
for p in model.backbone.parameters():
p.requires_grad = True
for g in optimizer.param_groups:
g["lr"] = args.lr * 0.1
print(" (unfreezing backbone, LR 0.1x)", flush=True)
tr_loss, tr_acc = train_epoch(model, tr_loader, optimizer, criterion, device)
val_loss, val_acc, val_auc = eval_epoch(model, val_loader, criterion, device)
star = "*" if val_acc > best_val_acc else ""
if val_acc > best_val_acc:
best_val_acc = val_acc
os.makedirs(MODELS_DIR, exist_ok=True)
torch.save(model.state_dict(),
os.path.join(MODELS_DIR, f"siamese_{args.backbone}.pt"))
print(f"{epoch:>6d} {tr_loss:>8.4f} {tr_acc:>8.4f} "
f"{val_loss:>8.4f} {val_acc:>8.4f} {val_auc:>8.4f} {star}",
flush=True)
print(f"\nBest val accuracy: {best_val_acc:.4f}")
# ---- Final evaluation on held-out TEST set ----
print(f"\n{'='*50}")
print(f"Held-out TEST evaluation ({len(test_pids)} unseen patients, "
f"{len(test_paths)} slices)")
print("=" * 50)
model_path = os.path.join(MODELS_DIR, f"siamese_{args.backbone}.pt")
model.load_state_dict(torch.load(model_path, map_location=device, weights_only=True))
model.eval()
from classes.siamese import SiamesePatientMatcher as _SPM
matcher = _SPM(model_path, backbone=args.backbone,
device=str(device), input_size=input_size)
test_pids_list = list(test_pids)
test_fnames_clean = np.array([f.split("/")[-1].replace(".png", "")
for f in test_paths])
# Connected components (no known K)
manifest_cc = matcher.identify_patients(
test_paths, filenames=test_fnames_clean,
threshold=0.9, top_k=20, k=None)
# Spectral clustering (known K)
manifest_sc = matcher.identify_patients(
test_paths, filenames=test_fnames_clean,
threshold=0.9, top_k=20, k=len(test_pids))
def score_manifest(manifest, y_true_map):
"""Score a manifest against ground-truth patient IDs."""
# y_true_map: filename → true_patient_id
all_labels = []
all_preds = []
for pred_pid, fnames in manifest.items():
for f in fnames:
all_preds.append(pred_pid)
all_labels.append(y_true_map.get(f, f"unknown_{f}"))
all_labels = np.array(all_labels)
all_preds = np.array(all_preds)
purities = []
for c in np.unique(all_preds):
mask = all_preds == c
_, cts = np.unique(all_labels[mask], return_counts=True)
purities.append(cts.max() / mask.sum())
captures = []
for p in np.unique(all_labels):
mask = all_labels == p
_, cts = np.unique(all_preds[mask], return_counts=True)
captures.append(cts.max() / mask.sum())
return np.median(purities), np.median(captures), len(np.unique(all_preds))
# Build ground-truth map: filename → patient_id
true_map = {f: p for f, p in zip(test_fnames_clean, test_pids_arr)}
p_cc, c_cc, n_cc = score_manifest(manifest_cc, true_map)
p_sc, c_sc, n_sc = score_manifest(manifest_sc, true_map)
print(f"\n {'Method':<30s} {'Groups':>7s} {'Purity':>8s} {'Capture':>8s}")
print(f" {'-'*53}")
print(f" {'Connected components':<30s} {n_cc:>7d} "
f"{p_cc:>7.1%} {c_cc:>7.1%}")
print(f" {'Spectral (known K=' + str(len(test_pids)) + ')':<30s} {n_sc:>7d} "
f"{p_sc:>7.1%} {c_sc:>7.1%}")
# Compare to thumbnail baseline
results_path = os.path.join(RESULTS_DIR, "task06_clustering_validation.json")
if os.path.exists(results_path):
with open(results_path) as f:
prev = json.load(f)
tp = prev["methods"]["thumbnail_64x64"]["cluster_purity"]["median"]
tc = prev["methods"]["thumbnail_64x64"]["dominant_capture"]["median"]
print(f" {'Thumbnail 64x64 (baseline)':<30s} {'':>7s} "
f"{tp:>7.1%} {tc:>7.1%}")
print(f"\nModel saved → {model_path}")
print("DONE")
if __name__ == "__main__":
main()
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""figure1.py — Sample CT images from the IQ-OTH/NCCD dataset, one per patient.
Usage: python scripts/visualizations/figure1.py [--tag TAG]"""
import os, sys, csv, argparse
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
DATASET = os.path.join(os.path.dirname(ROOT), "The IQ-OTHNCCD lung cancer dataset")
MANIFEST = os.path.join(ROOT, "results", "simple_patient_manifest.csv")
PLOTS_DIR = os.path.join(ROOT, "plots")
ap = argparse.ArgumentParser()
ap.add_argument("--tag", default="", help="Append tag to filename")
ap.add_argument("--manifest", default=MANIFEST, help="Patient manifest CSV")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
# Load manifest to get per-patient images
patients = {"Benign": [], "Malignant": [], "Normal": []}
with open(args.manifest, newline="") as f:
reader = csv.DictReader(f)
img_col = "confirmed_images" if "confirmed_images" in reader.fieldnames else "images"
for row in reader:
cls = row.get("class", "")
if cls in patients:
imgs = row[img_col].split(";")
if imgs:
patients[cls].append((row["patient_id"], imgs[0])) # first image per patient
CLASS_DIR = {"Benign": "Bengin cases", "Malignant": "Malignant cases",
"Normal": "Normal cases"}
N_EXAMPLES = 3
fig, axes = plt.subplots(3, N_EXAMPLES, figsize=(8, 9))
for row, (cls_label, cls_dir) in enumerate(CLASS_DIR.items()):
# Pick first N_EXAMPLES patients for this class
selected = patients[cls_label][:N_EXAMPLES]
for col, (pid, short_name) in enumerate(selected):
ax = axes[row, col]
# Convert short name back to original filename
prefix = short_name[0]
num = int(short_name.split("_")[1])
cls_map = {"B": ("Bengin cases", "Bengin"), "M": ("Malignant cases", "Malignant"),
"N": ("Normal cases", "Normal")}
dir_name, file_prefix = cls_map[prefix]
fname = f"{file_prefix} case ({num}).jpg"
img_path = os.path.join(DATASET, dir_name, fname)
try:
img = Image.open(img_path).convert("L")
ax.imshow(img, cmap="gray")
except Exception as e:
ax.text(0.5, 0.5, f"error: {e}", ha="center", va="center", fontsize=7)
ax.set_xticks([]); ax.set_yticks([])
if col == 0:
ax.set_ylabel(cls_label, fontsize=10, rotation=0,
labelpad=20, va="center")
fig.suptitle("Figure 1 — Sample images from the IQ-OTH/NCCD dataset",
fontsize=12, y=1.02)
plt.tight_layout(rect=[0, 0, 1, 0.97])
out = os.path.join(PLOTS_DIR, "figure1", f"figure1{tag}.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
plt.savefig(out, dpi=150)
plt.close()
print(f"Saved → {out}")
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Combined CV-accuracy-vs-features curves for Figures 3, S1, and S4.
These three figures all compute the same per-model curve — RF importance
ranking + a per-nfeatures gamma grid at seed 20 — differing only in which
models and which split they show:
Figure 3 : VGG16, image-level (1 panel) == panel (a) of S1
Figure S1 : all 5 models, image (5 panels)
Figure S4 : all 5 models, patient (5 panels)
Computing them together runs each (model, split) curve once (5 image + 5
patient = 10 curves) and caches them, instead of recomputing VGG16's image
curve for both Figure 3 and S1. The cache is incremental: dropping a model's
entries (e.g. after a feature change) recomputes only that model.
Usage:
conda activate fundus_imaging
python scripts/visualizations/figure3_s1_s4.py # all three
python scripts/visualizations/figure3_s1_s4.py --only s4 # just Fig S4
python scripts/visualizations/figure3_s1_s4.py --from-cache # re-render only
python scripts/visualizations/figure3_s1_s4.py --force # recompute all
"""
import os
import sys
import json
import argparse
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from tqdm import tqdm
os.environ.setdefault("OMP_NUM_THREADS", "1")
os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
os.environ.setdefault("MKL_NUM_THREADS", "1")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Shared results-cache tier (alongside classification_runs.json). These curves
# are model-selection output (best nfeat/gamma/cv), not plotting scaffolding, so
# they live in scripts/cache/ rather than under visualizations/.
CACHE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "cache")
PLOTS_DIR = os.path.join(ROOT, "plots")
from classes import PatientLeakageClassifier
# Panel order matches the published supplementary figures.
MODELS = ["VGG16", "MobileNetV2", "DenseNet121", "ResNet50", "EfficientNetB1"]
SEED = 20
DATA_PATH = os.path.join(CACHE_DIR, "cv_curves.json")
MANIFEST = os.path.join(ROOT, "results", "simple_patient_manifest.csv")
# 16-point log grid of feature counts (each model caps at its own max dim).
NFEATS = np.unique(np.round(np.logspace(1.5, 5, 16)).astype(int)).tolist()
GAMMA_LOGSPACE = (-1.5, 1, 7)
PANELS = ["(a)", "(b)", "(c)", "(d)", "(e)"]
def key(model, split):
return f"{model}::{split}"
def load_cache():
if os.path.exists(DATA_PATH):
with open(DATA_PATH) as f:
return json.load(f)
return {}
def compute(cache, splits, n_jobs, manifest):
"""Fill any missing (model, split) curves in the cache; return updated cache."""
todo = [(m, s) for s in splits for m in MODELS if key(m, s) not in cache]
if not todo:
print(f"Cache complete for requested figures ({len(cache)} curves).")
return cache
clf = PatientLeakageClassifier(manifest, os.path.join(ROOT, "features"),
n_jobs=n_jobs)
for m, s in tqdm(todo, desc="Computing missing curves"):
cache[key(m, s)] = clf.cv_curve(m, SEED, s, nfeatures_list=NFEATS,
gamma_logspace=GAMMA_LOGSPACE)
os.makedirs(CACHE_DIR, exist_ok=True)
with open(DATA_PATH, "w") as f:
json.dump(cache, f, indent=2)
print(f"Computed {len(todo)} curves; cache now {len(cache)}.")
return cache
def _draw_curve(ax, curve, color, ylim, title=None):
# Keys are ints on a fresh compute but strings once round-tripped through
# JSON; iterate items so either works.
items = sorted((int(n), v) for n, v in curve["curves"].items())
nfeats = [n for n, _ in items]
accs = [v * 100 for _, v in items]
ax.plot(nfeats, accs, "o-", color=color, markersize=4, linewidth=1.2)
ax.set_xscale("log")
ax.set_xlabel("Number of selected features")
ax.set_ylabel("CV accuracy (%)")
ax.set_ylim(*ylim)
ax.grid(alpha=0.3)
bn, ba = curve["best_nfeat"], curve["best_cv"] * 100
ax.axvline(bn, color="red", linestyle="--", linewidth=0.8, alpha=0.6)
ax.plot(bn, ba, "r*", markersize=11)
ax.annotate(f"n={bn}\n{ba:.2f}%", (bn, ba), fontsize=8,
xytext=(10, -10), textcoords="offset points", color="red")
if title:
ax.set_title(title, fontsize=10)
def render_fig3(cache, tag):
fig, ax = plt.subplots(figsize=(8, 5))
_draw_curve(ax, cache[key("VGG16", "image")], "#D62728", (92, 100))
ax.set_title("Figure 3 — VGG16 Image-level CV accuracy vs number of "
"selected features", fontsize=12)
_save(fig, "figure3", f"figure3{tag}.png")
def render_grid(cache, split, color, ylim, suptitle, subdir, fname):
fig, axes = plt.subplots(2, 3, figsize=(14, 9))
axes = axes.flatten()
for i, model in enumerate(MODELS):
_draw_curve(axes[i], cache[key(model, split)], color, ylim,
title=f"{PANELS[i]} {model}")
axes[5].set_visible(False)
fig.suptitle(suptitle, fontsize=13, y=1.01)
_save(fig, subdir, fname, tight=True)
def _save(fig, subdir, fname, tight=False):
plt.tight_layout()
out = os.path.join(PLOTS_DIR, subdir, fname)
os.makedirs(os.path.dirname(out), exist_ok=True)
fig.savefig(out, dpi=150, bbox_inches="tight" if tight else None)
plt.close(fig)
print(f"Saved → {out}")
def main():
ap = argparse.ArgumentParser(description="Combined Figures 3, S1 & S4.")
ap.add_argument("--only", choices=["3", "s1", "s4"],
help="Render only one figure (still computes its curves).")
ap.add_argument("--from-cache", action="store_true",
help="Re-render from cache without computing.")
ap.add_argument("--force", action="store_true",
help="Recompute all curves, ignoring the cache.")
ap.add_argument("--n-jobs", type=int, default=6)
ap.add_argument("--manifest", default=MANIFEST)
ap.add_argument("--tag", default="")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
# Which splits are needed for the requested figure(s)?
need_image = args.only in (None, "3", "s1")
need_patient = args.only in (None, "s4")
splits = (["image"] if need_image else []) + (["patient"] if need_patient else [])
cache = {} if args.force else load_cache()
if args.from_cache:
print(f"Loaded {len(cache)} cached curves ← {DATA_PATH}")
else:
cache = compute(cache, splits, args.n_jobs, args.manifest)
if args.only in (None, "3"):
render_fig3(cache, tag)
if args.only in (None, "s1"):
render_grid(cache, "image", "#D62728", (90, 100),
"Figure S1 — Image-level CV accuracy vs number of selected "
"features", "figure_s1", f"figure_s1{tag}.png")
if args.only in (None, "s4"):
render_grid(cache, "patient", "#2C7BB6", (82, 95),
"Figure S4 — Patient-level CV accuracy vs number of selected "
"features (C=10)", "figure_s4", f"figure_s4{tag}.png")
print("DONE")
if __name__ == "__main__":
main()
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""Combined 20-seed classification for Figures 4 and 6 — one compute pass.
Usage:
python scripts/visualizations/figure4_6.py # both figures
python scripts/visualizations/figure4_6.py --only 4 # Fig 4 only
python scripts/visualizations/figure4_6.py --only 6 # Fig 6 only
python scripts/visualizations/figure4_6.py --from-cache # use saved data
"""
import os, sys, json, argparse
import numpy as np
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
from tqdm import tqdm
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from classes import PatientLeakageClassifier
PLOTS_DIR = os.path.join(ROOT, "plots")
RESULTS_DIR = os.path.join(ROOT, "results")
# Shared classification-runs cache, owned by scripts/classification.py. Both
# scripts fill it incrementally (same model/seed/split keys), merge-safe.
CACHE_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "cache")
MODELS = ["VGG16", "DenseNet121", "EfficientNetB1", "MobileNetV2", "ResNet50"]
SEEDS = list(range(1, 21))
DATA_PATH = os.path.join(CACHE_DIR, "classification_runs.json")
MANIFEST = os.path.join(RESULTS_DIR, "simple_patient_manifest.csv")
ap = argparse.ArgumentParser()
ap.add_argument("--only", default=None, choices=["4", "6"])
ap.add_argument("--from-cache", action="store_true")
ap.add_argument("--force", action="store_true", help="Recompute all runs, ignoring cache.")
ap.add_argument("--manifest", default=MANIFEST)
ap.add_argument("--tag", default="")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
# Run or load. The cache is a flat list of per-run dicts; compute only the
# (model, seed, split) combos it's missing, so dropping a stale model's rows
# (e.g. ResNet50 after a feature change) recomputes just that model.
all_data = []
if os.path.exists(DATA_PATH) and not args.force:
with open(DATA_PATH) as f: all_data = json.load(f)
if args.from_cache:
print(f"Loaded {len(all_data)} cached runs ← {DATA_PATH}")
else:
have = {(r["model"], r["seed"], r["split_type"]) for r in all_data}
todo = [(m, s, st) for m in MODELS for s in SEEDS
for st in ("image", "patient") if (m, s, st) not in have]
if todo:
clf = PatientLeakageClassifier(args.manifest, os.path.join(ROOT, "features"), n_jobs=6)
for m, s, st in tqdm(todo, desc="Computing missing runs"):
all_data.append(clf.run(m, s, st))
os.makedirs(CACHE_DIR, exist_ok=True)
with open(DATA_PATH, "w") as f: json.dump(all_data, f, indent=2)
print(f"Computed {len(todo)} missing runs; cache now {len(all_data)}")
else:
print(f"Cache complete ({len(all_data)} runs); nothing to compute.")
# Build accs. Only plot models that actually have data, so a partially filled
# cache (e.g. ResNet50 dropped pending recompute) still renders without error.
accs = {m: {"image": [], "patient": []} for m in MODELS}
for r in all_data:
if r["model"] in accs: accs[r["model"]][r["split_type"]].append(r["test"])
PLOT_MODELS = [m for m in MODELS if accs[m]["image"] or accs[m]["patient"]]
missing = [m for m in MODELS if m not in PLOT_MODELS]
if missing:
print(f"WARNING: no cached runs for {missing}; run without --from-cache "
"to compute them. Plotting remaining models only.")
# Figure 4: image-level only
if args.only is None or args.only == "4":
fig, ax = plt.subplots(figsize=(8, 5))
pos = list(range(1, len(PLOT_MODELS) + 1))
bp = ax.boxplot([accs[m]["image"] for m in PLOT_MODELS], positions=pos,
widths=0.5, patch_artist=True, showfliers=True,
flierprops=dict(marker='o', markersize=3))
for i, b in enumerate(bp['boxes']):
b.set_facecolor('#4C9BD4')
ax.annotate(f"{np.median(accs[PLOT_MODELS[i]]['image']):.3f}",
(pos[i], np.median(accs[PLOT_MODELS[i]]['image'])),
fontsize=6, ha='center', va='bottom')
ax.set_xticks(pos); ax.set_xticklabels(PLOT_MODELS)
ax.set_ylabel("Test accuracy"); ax.set_ylim(0.96, 1.00); ax.grid(axis='y', alpha=0.3)
ax.set_title("Figure 4 — Image-level test accuracy across 20 seeds", fontsize=12)
plt.tight_layout()
out = os.path.join(PLOTS_DIR, "figure4", f"figure4{tag}.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
plt.savefig(out, dpi=150); plt.close()
print(f"Saved → {out}")
# Figure 6: image vs patient
if args.only is None or args.only == "6":
fig, ax = plt.subplots(figsize=(10, 6))
for i, name in enumerate(PLOT_MODELS):
for pos, stype, color in [(i*2+0.7, "image", '#4C9BD4'),
(i*2+1.3, "patient", '#6DBF6D')]:
data = accs[name][stype]
bp = ax.boxplot(data, positions=[pos], widths=0.5,
patch_artist=True, showfliers=True,
flierprops=dict(marker='o', markersize=3))
bp['boxes'][0].set_facecolor(color)
ax.annotate(f"{np.median(data):.3f}", (pos, np.median(data)),
fontsize=6, ha='center', va='bottom')
ax.legend(handles=[Patch(facecolor='#4C9BD4', label='Image-level split'),
Patch(facecolor='#6DBF6D', label='Patient-level split')],
loc='lower right')
ax.set_xticks([p+1 for p in range(0, len(PLOT_MODELS)*2, 2)])
ax.set_xticklabels(PLOT_MODELS)
ax.set_ylabel("Test accuracy"); ax.set_ylim(0.70, 1.00); ax.grid(axis='y', alpha=0.3)
ax.set_title("Figure 6 — Image vs Patient-level test accuracy (20 seeds)", fontsize=13)
plt.tight_layout()
out = os.path.join(PLOTS_DIR, "figure6", f"figure6{tag}.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
plt.savefig(out, dpi=150); plt.close()
print(f"Saved → {out}")
print("DONE")
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""figure5.py — PCA and t-SNE of VGG16 features, colored by class.
Usage:
python scripts/visualizations/figure5.py
python scripts/visualizations/figure5.py --tag v2
"""
import os, sys, argparse
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
FEATURES_DIR = os.path.join(ROOT, "features")
PLOTS_DIR = os.path.join(ROOT, "plots")
SEED = 42
CLASS_COLORS = {"Bengin cases": "#2CA02C", "Malignant cases": "#D62728",
"Normal cases": "#1F77B4"}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--tag", default="", help="Append tag to filename")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
data = np.load(os.path.join(FEATURES_DIR, "VGG16_features.npz"),
allow_pickle=True)
X, Y = data["X"], data["Y"]
pca50 = PCA(n_components=50, random_state=SEED).fit_transform(X)
pca2 = PCA(n_components=2, random_state=SEED).fit_transform(pca50)
tsne = TSNE(n_components=2, random_state=SEED, perplexity=30,
max_iter=1000).fit_transform(pca50)
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
for ax, coords, title in [(axes[0], pca2, "(a) PCA"),
(axes[1], tsne, "(b) t-SNE")]:
for cls in sorted(np.unique(Y)):
mask = Y == cls
label = cls.replace("Bengin cases", "Benign")
ax.scatter(coords[mask, 0], coords[mask, 1],
c=CLASS_COLORS[cls], label=label,
alpha=0.6, s=15, edgecolors="none")
ax.set_title(title, fontsize=12)
ax.set_xlabel("Component 1")
ax.set_ylabel("Component 2")
ax.legend(markerscale=2, fontsize=9)
ax.grid(alpha=0.2)
fig.suptitle("Figure 5 — PCA and t-SNE of VGG16 Features (by class)",
fontsize=13)
plt.tight_layout()
out = os.path.join(PLOTS_DIR, "figure5", f"figure5{tag}.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
plt.savefig(out, dpi=150)
plt.close()
print(f"Saved → {out}")
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""figure_s2.py — PCA and t-SNE for all 5 CNN models, colored by class.
Usage: python scripts/visualizations/figure_s2.py [--tag TAG]"""
import os, sys, argparse
import numpy as np
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
FEATURES_DIR = os.path.join(ROOT, "features")
PLOTS_DIR = os.path.join(ROOT, "plots")
SEED = 42
MODELS = ["VGG16", "MobileNetV2", "DenseNet121", "ResNet50", "EfficientNetB1"]
CLASS_COLORS = {"Bengin cases": "#2CA02C", "Malignant cases": "#D62728", "Normal cases": "#1F77B4"}
ap = argparse.ArgumentParser()
ap.add_argument("--tag", default="", help="Append tag to filename")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
fig, axes = plt.subplots(2, 5, figsize=(22, 9))
for mi, model_name in enumerate(MODELS):
data = np.load(os.path.join(FEATURES_DIR, f"{model_name}_features.npz"), allow_pickle=True)
X, Y = data["X"], data["Y"]
print(f"{model_name}: {X.shape}")
pca50 = PCA(n_components=50, random_state=SEED).fit_transform(X)
pca2 = PCA(n_components=2, random_state=SEED).fit_transform(pca50)
tsne = TSNE(n_components=2, random_state=SEED, perplexity=30, max_iter=800).fit_transform(pca50)
for row, coords, title in [(0, pca2, f"{model_name} PCA"), (1, tsne, f"{model_name} t-SNE")]:
ax = axes[row, mi]
for cls in sorted(np.unique(Y)):
mask = Y == cls; label = cls.replace("Bengin cases", "Benign")
ax.scatter(coords[mask,0], coords[mask,1], c=CLASS_COLORS[cls], label=label, alpha=0.5, s=8, edgecolors="none")
ax.set_title(title, fontsize=9)
ax.set_xlabel("Component 1" if row==0 else "t-SNE 1")
ax.set_ylabel("Component 2" if row==0 else "t-SNE 2")
ax.grid(alpha=0.2)
handles = [plt.Line2D([0],[0], marker='o', color='w', markerfacecolor=c, markersize=8, label=l)
for l,c in zip(["Benign","Malignant","Normal"], ["#2CA02C","#D62728","#1F77B4"])]
fig.legend(handles=handles, loc='lower center', ncol=3, fontsize=10, bbox_to_anchor=(0.5,-0.02))
fig.suptitle("Figure S2 — PCA (top) and t-SNE (bottom) per model", fontsize=13, y=1.01)
plt.tight_layout()
out = os.path.join(PLOTS_DIR, "figure_s2", f"figure_s2{tag}.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
plt.savefig(out, dpi=150, bbox_inches="tight")
plt.close()
print(f"Saved → {out}")
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""figure_s3.py — Example CT images from estimated patient clusters.
Usage: python scripts/visualizations/figure_s3.py [--tag TAG]"""
import os, sys, csv, argparse
import numpy as np
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
MANIFEST = os.path.join(ROOT, "results", "simple_patient_manifest.csv")
DATASET = os.path.join(os.path.dirname(ROOT), "The IQ-OTHNCCD lung cancer dataset")
PLOTS_DIR = os.path.join(ROOT, "plots")
N_EXAMPLES = 5
CLASS_MAP = {"Benign": ("Bengin cases", "B"), "Malignant": ("Malignant cases", "M"), "Normal": ("Normal cases", "N")}
def f2n_back(fname):
prefix = fname[0]; num = int(fname.split("_")[1])
cls = {"B": ("Bengin cases", "Bengin"), "M": ("Malignant cases", "Malignant"), "N": ("Normal cases", "Normal")}[prefix]
return cls[0], f"{cls[1]} case ({num}).jpg"
ap = argparse.ArgumentParser()
ap.add_argument("--tag", default="", help="Append tag to filename")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
patients = {}
with open(MANIFEST, newline="") as f:
reader = csv.DictReader(f)
img_col = "confirmed_images" if "confirmed_images" in reader.fieldnames else "images"
for row in reader:
imgs = row[img_col].split(";")
if imgs: patients[row["patient_id"]] = imgs
fig, axes = plt.subplots(3, N_EXAMPLES, figsize=(12, 8))
for row, (cls_label, (cls_dir, _)) in enumerate(CLASS_MAP.items()):
cls_patients = [(p, imgs) for p, imgs in patients.items() if p.lower().startswith(cls_label.lower()) and len(imgs) >= N_EXAMPLES]
if not cls_patients: continue
pid, imgs = cls_patients[0]
for col in range(N_EXAMPLES):
ax = axes[row, col]
try:
cls_dir_name, orig_fname = f2n_back(imgs[col])
img = Image.open(os.path.join(DATASET, cls_dir_name, orig_fname)).convert("L")
ax.imshow(img, cmap="gray")
except Exception as e:
ax.text(0.5, 0.5, f"error: {e}", ha="center", va="center", fontsize=7)
ax.set_xticks([]); ax.set_yticks([])
if col == 0: ax.set_ylabel(f"{cls_label}\nPatient {pid}", fontsize=9, rotation=0, labelpad=40, va="center")
fig.suptitle("Figure S3 — Example CT images from estimated patient clusters", fontsize=12, y=1.01)
plt.tight_layout()
out = os.path.join(PLOTS_DIR, "figure_s3", f"figure_s3{tag}.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
plt.savefig(out, dpi=150)
plt.close()
print(f"Saved → {out}")
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""figure_s5.py — Confusion matrix for VGG16 patient-level classification.
Reuses PatientLeakageClassifier.run(return_predictions=True) so the RF ranking,
gamma grid, and final fit are not duplicated here — the same code path that
produces the Figure 4/6 numbers also produces these predictions.
Usage: python scripts/visualizations/figure_s5.py [--tag TAG]
"""
import os
import sys
import argparse
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
os.environ.setdefault("OMP_NUM_THREADS", "1")
os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
os.environ.setdefault("MKL_NUM_THREADS", "1")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PLOTS_DIR = os.path.join(ROOT, "plots")
from classes import PatientLeakageClassifier
SEED = 20
N_JOBS = 6
CLASS_NAMES = ["Benign", "Malignant", "Normal"]
def display_name(raw):
"""Map a raw dataset label ('Bengin cases', ...) to a display class name."""
if raw.startswith("Bengin"):
return "Benign"
if raw.startswith("Malignant"):
return "Malignant"
return "Normal"
ap = argparse.ArgumentParser()
ap.add_argument("--tag", default="")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
clf = PatientLeakageClassifier(
os.path.join(ROOT, "results", "simple_patient_manifest.csv"),
os.path.join(ROOT, "features"), n_jobs=N_JOBS)
r = clf.run("VGG16", SEED, "patient", return_predictions=True)
print(f"Best: n={r['nfeat']}, gamma={r['gamma']:.6e}, "
f"CV={r['cv']:.4f}, Test={r['test']:.4f}")
y_true = [display_name(c) for c in r["y_true"]]
y_pred = [display_name(c) for c in r["y_pred"]]
cm = confusion_matrix(y_true, y_pred, labels=CLASS_NAMES, normalize="true")
fig, ax = plt.subplots(figsize=(6, 5))
ConfusionMatrixDisplay(cm, display_labels=CLASS_NAMES).plot(
cmap="Blues", ax=ax, colorbar=True, values_format=".2f")
ax.set_title("Figure S5 — Patient-level Confusion Matrix (VGG16)", fontsize=12)
plt.tight_layout()
out = os.path.join(PLOTS_DIR, "figure_s5", f"figure_s5{tag}.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
plt.savefig(out, dpi=150)
plt.close()
print(f"Saved → {out}")
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
manifest_html.py — Generate HTML pages for visually inspecting patient manifests.
One HTML file per class, each patient's assigned images shown in a row.
Usage:
python scripts/visualizations/manifest_html.py
python scripts/visualizations/manifest_html.py --method siamese
python scripts/visualizations/manifest_html.py --manifest results/simple_patient_manifest.csv --method pca50
"""
import os, sys, csv, argparse, base64
from io import BytesIO
from PIL import Image
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__))))
DATASET = os.path.join(os.path.dirname(ROOT), "The IQ-OTHNCCD lung cancer dataset")
RESULTS_DIR = os.path.join(ROOT, "results")
HTML_DIR = os.path.join(ROOT, "plots", "html")
MANIFESTS = {
"pca50": os.path.join(RESULTS_DIR, "simple_patient_manifest.csv"),
"thumbnail": os.path.join(RESULTS_DIR, "thumbnail_patient_manifest.csv"),
"siamese": os.path.join(RESULTS_DIR, "siamese_manifest.csv"),
}
THUMB_SIZE = 150 # px, display width
def f2n_back(short_name):
"""B_009 → ('Bengin cases', 'Bengin case (9).jpg')"""
prefix = short_name[0]
num = int(short_name.split("_")[1])
cls_map = {"B": ("Bengin cases", "Bengin"),
"M": ("Malignant cases", "Malignant"),
"N": ("Normal cases", "Normal")}
dir_name, file_prefix = cls_map[prefix]
return dir_name, f"{file_prefix} case ({num}).jpg"
def img_to_b64(path, size=THUMB_SIZE):
"""Load an image and return a base64 data URI."""
try:
img = Image.open(path).convert("L")
img.thumbnail((size, size), Image.LANCZOS)
buf = BytesIO()
img.save(buf, format="PNG")
return f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
except Exception:
return ""
def build_html(manifest_path, method, class_name, class_dir, patients):
"""Generate an HTML string for one class."""
rows = []
for pid, short_names in patients.items():
# Build image cards
cards = []
for sn in short_names:
dir_name, fname = f2n_back(sn)
img_path = os.path.join(DATASET, dir_name, fname)
b64 = img_to_b64(img_path)
if b64:
cards.append(
f'<div class="card">'
f'<img src="{b64}" alt="{sn}">'
f'<div class="label">{sn}</div>'
f'</div>')
if cards:
rows.append(
f'<div class="patient">'
f'<h3>{pid} <span class="count">({len(cards)} images)</span></h3>'
f'<div class="images">{"".join(cards)}</div>'
f'</div>')
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{method}{class_name}</title>
<style>
body {{ font-family: -apple-system, sans-serif; background: #1a1a2e; color: #eee; margin: 20px; }}
h1 {{ color: #e94560; }}
.patient {{ margin-bottom: 30px; border-bottom: 1px solid #333; padding-bottom: 15px; }}
.patient h3 {{ margin: 0 0 8px 0; color: #0f3460; background: #16213e; padding: 6px 12px; border-radius: 4px; display: inline-block; }}
.count {{ font-weight: normal; color: #888; font-size: 0.85em; }}
.images {{ display: flex; flex-wrap: wrap; gap: 8px; }}
.card {{ background: #16213e; border-radius: 4px; overflow: hidden; width: {THUMB_SIZE + 20}px; }}
.card img {{ display: block; width: {THUMB_SIZE}px; height: {THUMB_SIZE}px; object-fit: contain; margin: 0 auto; background: #000; }}
.label {{ font-size: 9px; color: #aaa; text-align: center; padding: 4px; word-break: break-all; }}
a.nav {{ color: #e94560; margin-right: 15px; }}
</style>
</head>
<body>
<h1>{method}{class_name} <small>({len(patients)} patients, {sum(len(v) for v in patients.values())} images)</small></h1>
<p>
<a class="nav" href="{method}_Benign.html">Benign</a>
<a class="nav" href="{method}_Malignant.html">Malignant</a>
<a class="nav" href="{method}_Normal.html">Normal</a>
</p>
{"".join(rows)}
</body>
</html>"""
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--method", default=None,
help="Which manifest method (pca50, thumbnail, siamese). "
"Default: all.")
ap.add_argument("--manifest", default=None,
help="Path to manifest CSV (overrides --method).")
args = ap.parse_args()
os.makedirs(HTML_DIR, exist_ok=True)
methods_to_run = [args.method] if args.method else list(MANIFESTS.keys())
for method in methods_to_run:
manifest_path = args.manifest or MANIFESTS.get(method)
if not manifest_path or not os.path.exists(manifest_path):
print(f" {method}: manifest not found ({manifest_path})")
continue
# Load manifest, group by class
patients_by_class = {"Benign": {}, "Malignant": {}, "Normal": {}}
with open(manifest_path, newline="") as f:
reader = csv.DictReader(f)
img_col = ("images" if "images" in reader.fieldnames
else "confirmed_images")
for row in reader:
cls = row.get("class", "").strip()
# Normalize class names
cls_lower = cls.lower()
if cls_lower in ("benign", "bengin"):
cls = "Benign"
elif cls_lower in ("malignant", "malig"):
cls = "Malignant"
elif cls_lower == "normal":
cls = "Normal"
elif cls_lower == "unknown":
# Infer from patient_id prefix
pid = row.get("patient_id", "")
if pid.lower().startswith("benign") or pid.lower().startswith("bengin"):
cls = "Benign"
elif pid.lower().startswith("malignant") or pid.lower().startswith("malig"):
cls = "Malignant"
elif pid.lower().startswith("normal"):
cls = "Normal"
if cls not in patients_by_class:
continue
pid = row["patient_id"]
imgs = row[img_col].split(";") if row[img_col] else []
if imgs:
patients_by_class[cls][pid] = imgs
for cls_name in ["Benign", "Malignant", "Normal"]:
patients = patients_by_class[cls_name]
if not patients:
print(f" {method}/{cls_name}: no patients, skipping")
continue
html = build_html(manifest_path, method, cls_name,
"", patients)
out_path = os.path.join(HTML_DIR, f"{method}_{cls_name}.html")
with open(out_path, "w") as f:
f.write(html)
print(f" Saved → plots/html/{method}_{cls_name}.html "
f"({len(patients)} patients)")
print("DONE")
if __name__ == "__main__":
main()
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
"""manifest_tsne.py — visualize any patient-grouping manifest on the VGG16 t-SNE.
Plots the same VGG16 feature t-SNE used elsewhere, but colored by the groups in
a given manifest (siamese / pca50 / thumbnail). Because every manifest is drawn
on the *identical* layout (same features, PCA, seed, perplexity), the resulting
per-class figures are directly comparable across methods.
For the siamese grouping this is diagnostic: if a siamese "patient" is a coherent
patient it forms a tight island; if it's an over-merged chain, its color is
smeared across feature space (VGG16 sees images the siamese wrongly linked).
Usage:
conda activate fundus_imaging
python scripts/visualizations/manifest_tsne.py \
--manifest results/siamese_manifest.csv --name siamese
python scripts/visualizations/manifest_tsne.py \
--manifest results/simple_patient_manifest.csv --name pca50
"""
import os
import sys
import re
import csv
import argparse
from collections import defaultdict
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
FEATURES_DIR = os.path.join(ROOT, "features")
PLOTS_DIR = os.path.join(ROOT, "plots")
RANDOM_STATE = 42
CLASS_OF = {"B": "Benign", "M": "Malignant", "N": "Normal"}
def f2n(fname):
"""VGG16 feature filename -> manifest short name (e.g. 'B_001')."""
m = re.search(r"\((\d+)\)", fname)
num = int(m.group(1)) if m else None
for cls_key, prefix in [("Bengin cases", "B"), ("Malignant cases", "M"),
("Normal cases", "N")]:
if fname.startswith(cls_key.rstrip("s")):
return f"{prefix}_{num:03d}" if num else fname
return fname
def load_manifest(path):
"""Return {image_short_name: group_id}."""
mapping = {}
with open(path, newline="") as f:
for row in csv.DictReader(f):
for img in row["images"].split(";"):
if img:
mapping[img] = row["patient_id"]
return mapping
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", required=True, help="Path to a grouping manifest CSV.")
ap.add_argument("--name", required=True, help="Short method name for titles/filenames.")
ap.add_argument("--features", default="VGG16", help="CNN feature set for the layout.")
ap.add_argument("--no-centroids", dest="centroids", action="store_false",
help="Do not draw per-group centroid diamonds.")
ap.add_argument("--tag", default="")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
print(f"Loading {args.features} features ...")
data = np.load(os.path.join(FEATURES_DIR, f"{args.features}_features.npz"),
allow_pickle=True)
X, filenames = data["X"], data["filenames"]
img_ids = np.array([f2n(f) for f in filenames])
img_class = np.array([CLASS_OF.get(i.split("_")[0], "?") for i in img_ids])
print("Computing PCA-50 + t-SNE (shared layout) ...")
n_pca = min(50, X.shape[0] - 1, X.shape[1])
X_pca = PCA(n_components=n_pca, random_state=RANDOM_STATE).fit_transform(X)
X_tsne = TSNE(n_components=2, perplexity=35, learning_rate="auto",
init="pca", random_state=RANDOM_STATE).fit_transform(X_pca)
groups = load_manifest(args.manifest)
img_group = np.array([groups.get(i, "unassigned") for i in img_ids])
for cls in ["Benign", "Malignant", "Normal"]:
fig, ax = plt.subplots(figsize=(14, 10))
ax.scatter(X_tsne[:, 0], X_tsne[:, 1], c="lightgray", s=3, alpha=0.15)
mask = img_class == cls
class_groups = sorted(set(img_group[mask]))
n = len(class_groups)
# Order groups by size so the biggest (most likely over-merged) is obvious.
sizes = {g: int((img_group[mask] == g).sum()) for g in class_groups}
class_groups = sorted(class_groups, key=lambda g: -sizes[g])
cmap = plt.cm.tab20 if n <= 20 else plt.cm.gist_ncar
for gi, g in enumerate(class_groups):
color = cmap(gi % 20) if n <= 20 else cmap(gi / max(n - 1, 1))
gm = mask & (img_group == g)
ax.scatter(X_tsne[gm, 0], X_tsne[gm, 1], c=[color], s=18, alpha=0.8)
if args.centroids:
cx, cy = X_tsne[gm, 0].mean(), X_tsne[gm, 1].mean()
ax.scatter(cx, cy, c=[color], s=60, marker="D", edgecolors="black",
linewidths=0.6, zorder=5)
biggest = class_groups[0]
legend_handles = [
Line2D([0], [0], marker="o", color="w", markerfacecolor="gray",
markersize=8, label="Group images (dots)"),
]
if args.centroids:
legend_handles.append(
Line2D([0], [0], marker="D", color="w", markerfacecolor="gray",
markersize=8, label="Group centroids (diamonds)"))
ax.legend(handles=legend_handles, loc="lower right")
ax.set_title(f"{cls}{args.name} groups on {args.features} t-SNE "
f"({n} groups; largest={sizes[biggest]} imgs)")
ax.set_xlabel("t-SNE dim 1")
ax.set_ylabel("t-SNE dim 2")
plt.tight_layout()
out = os.path.join(PLOTS_DIR, "tsne", f"tsne_{args.name}_{cls}{tag}.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
plt.savefig(out, dpi=150)
plt.close()
print(f" Saved {out}")
print("DONE")
if __name__ == "__main__":
main()
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""siamese_similarity_tsne.py — t-SNE of the siamese's OWN similarity space.
Unlike manifest_tsne.py (which recolors the VGG16 feature layout), this builds
the layout directly from the siamese pairwise distance 1 - P(same-patient), so
proximity reflects how the siamese model itself relates images. Points are
colored by the siamese edge-rank groups.
This is the diagnostic view for the over-merge: if the siamese collapses several
patients together (over-confidence on IQ-OTH), the largest group forms one dense
mass in its own space; coherent patients form tight, separated islands.
Usage:
conda activate fundus_imaging
python scripts/visualizations/siamese_similarity_tsne.py
"""
import os
import sys
import re
import csv
import argparse
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.manifold import TSNE
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))))
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PLOTS_DIR = os.path.join(ROOT, "plots")
DEFAULT_DATASET = os.path.join(os.path.dirname(ROOT),
"The IQ-OTHNCCD lung cancer dataset")
from classes import SiamesePatientMatcher
CLASS_DIRS = {"Bengin cases": "Benign", "Malignant cases": "Malignant",
"Normal cases": "Normal"}
VALID_EXT = (".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp")
RANDOM_STATE = 42
def f2n(fname):
m = re.search(r"\((\d+)\)", fname)
num = int(m.group(1)) if m else None
for cls_key, prefix in [("Bengin cases", "B"), ("Malignant cases", "M"),
("Normal cases", "N")]:
if fname.startswith(cls_key.rstrip("s")):
return f"{prefix}_{num:03d}" if num else fname
return fname
def load_manifest(path):
mapping = {}
with open(path, newline="") as f:
for row in csv.DictReader(f):
for img in row["images"].split(";"):
if img:
mapping[img] = row["patient_id"]
return mapping
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default=os.path.join(ROOT, "models", "siamese_resnet18.pt"))
ap.add_argument("--backbone", default="resnet18")
ap.add_argument("--dataset", default=DEFAULT_DATASET)
ap.add_argument("--manifest", default=os.path.join(ROOT, "results", "siamese_manifest.csv"))
ap.add_argument("--name", default="siamese_sim")
ap.add_argument("--highlight-largest", type=int, default=0,
help="Grey all points and bold only the N largest groups.")
ap.add_argument("--tag", default="")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
matcher = SiamesePatientMatcher(args.model, backbone=args.backbone, input_size=224)
groups = load_manifest(args.manifest)
for class_dir, cls in CLASS_DIRS.items():
cpath = os.path.join(args.dataset, class_dir)
files = sorted(f for f in os.listdir(cpath) if f.lower().endswith(VALID_EXT))
paths = [os.path.join(cpath, f) for f in files]
ids = [f2n(f) for f in files]
print(f"\n{cls}: {len(paths)} images")
# Siamese pairwise distance -> t-SNE on precomputed distances.
emb = matcher.embed_images(paths)
P = matcher._dense_prob_matrix(emb)
dist = np.clip(1.0 - P, 0.0, None)
np.fill_diagonal(dist, 0.0)
perp = max(5, min(30, (len(paths) - 1) // 3))
X = TSNE(n_components=2, metric="precomputed", init="random",
perplexity=perp, random_state=RANDOM_STATE).fit_transform(dist)
img_group = np.array([groups.get(i, "unassigned") for i in ids])
g_order = sorted(set(img_group), key=lambda g: -(img_group == g).sum())
n = len(g_order)
fig, ax = plt.subplots(figsize=(14, 10))
if args.highlight_largest > 0:
# Grey everything, then bold only the N largest groups.
ax.scatter(X[:, 0], X[:, 1], c="lightgray", s=12, alpha=0.5)
hl = g_order[:args.highlight_largest]
hl_cmap = plt.cm.tab10
for gi, g in enumerate(hl):
gm = img_group == g
ax.scatter(X[gm, 0], X[gm, 1], c=[hl_cmap(gi)], s=28, alpha=0.9,
edgecolors="black", linewidths=0.3,
label=f"{g} ({gm.sum()} imgs)")
ax.legend(loc="lower right", title="Largest siamese groups")
ax.set_title(f"{cls} — siamese similarity t-SNE "
f"(largest {len(hl)} of {n} groups highlighted)")
else:
cmap = plt.cm.tab20 if n <= 20 else plt.cm.gist_ncar
for gi, g in enumerate(g_order):
color = cmap(gi % 20) if n <= 20 else cmap(gi / max(n - 1, 1))
gm = img_group == g
ax.scatter(X[gm, 0], X[gm, 1], c=[color], s=18, alpha=0.8)
biggest = (img_group == g_order[0]).sum()
ax.set_title(f"{cls} — siamese similarity t-SNE "
f"({n} groups; largest={biggest} imgs)")
ax.set_xlabel("t-SNE dim 1 (siamese distance)")
ax.set_ylabel("t-SNE dim 2 (siamese distance)")
plt.tight_layout()
suffix = "_highlight" if args.highlight_largest > 0 else ""
out = os.path.join(PLOTS_DIR, "tsne", f"tsne_{args.name}_{cls}{suffix}{tag}.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
plt.savefig(out, dpi=150)
plt.close()
print(f" Saved {out}")
print("DONE")
if __name__ == "__main__":
main()
@@ -1,49 +1,74 @@
#!/usr/bin/env python3
"""
simple_patient_tsne.py
The short path:
1. Load VGG16 features
2. K-means per class (15/40/55 patients) in PCA-50d space
3. Optional: iterative centroid refinement
4. Plot t-SNE colored by cluster, with centroid labels
Usage:
conda activate fundus_imaging
python scripts/simple_patient_tsne.py
"""
import os, sys, re, csv, json
from collections import defaultdict
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
from sklearn.cluster import KMeans
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import argparse
#!/usr/bin/env python3
"""
simple_patient_tsne.py
The short path:
1. Load VGG16 features
2. K-means per class (15/40/55 patients) in PCA-50d space
3. Optional: iterative centroid refinement
4. Plot t-SNE colored by cluster, with centroid labels
Usage:
conda activate fundus_imaging
python scripts/simple_patient_tsne.py
"""
matplotlib.use("Agg")
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FEATURES_DIR = os.path.join(PROJECT_ROOT, "features")
PLOTS_DIR = os.path.join(PROJECT_ROOT, "plots")
RESULTS_DIR = os.path.join(PROJECT_ROOT, "results")
os.makedirs(PLOTS_DIR, exist_ok=True)
os.makedirs(RESULTS_DIR, exist_ok=True)
PATIENT_COUNTS = {"Bengin cases": 15, "Malignant cases": 40, "Normal cases": 55}
CLASS_NAMES = {"Bengin cases": "Benign", "Malignant cases": "Malignant", "Normal cases": "Normal"}
RANDOM_STATE = 42
# ---------------------------------------------------------------------------
# 1. Load VGG16 features
# ---------------------------------------------------------------------------
print("Loading VGG16 features ...")
data = np.load(os.path.join(FEATURES_DIR, "VGG16_features.npz"), allow_pickle=True)
X, Y, filenames = data["X"], data["Y"], data["filenames"]
def f2n(fname):
m = re.search(r'\((\d+)\)', fname)
num = int(m.group(1)) if m else None
@@ -51,41 +76,31 @@ def f2n(fname):
if fname.startswith(cls_key.rstrip("s")):
return f"{prefix}_{num:03d}" if num else fname
return fname
img_nums = np.array([f2n(f) for f in filenames])
# ---------------------------------------------------------------------------
# 2. K-means per class in PCA-50d space
# ---------------------------------------------------------------------------
print("Clustering in PCA-50d space ...")
n_pca = min(50, X.shape[0] - 1, X.shape[1])
X_pca = PCA(n_components=n_pca, random_state=RANDOM_STATE).fit_transform(X)
image_to_patient = {}
patient_to_images = defaultdict(list)
for class_name, k in PATIENT_COUNTS.items():
mask = Y == class_name
X_class = X_pca[mask]
idx_class = np.where(mask)[0]
kmeans = KMeans(n_clusters=k, random_state=RANDOM_STATE, n_init=20)
labels = kmeans.fit_predict(X_class)
prefix = {"Bengin cases": "Benign", "Malignant cases": "Malignant", "Normal cases": "Normal"}[class_name]
for i, cluster_id in enumerate(labels):
pid = f"{prefix}_{cluster_id:02d}"
img = img_nums[idx_class[i]]
image_to_patient[img] = pid
patient_to_images[pid].append(img)
print(f" {len(patient_to_images)} patients, {len(image_to_patient)} images")
# ---------------------------------------------------------------------------
# 3. Iterative centroid refinement (optional, 3 passes)
# ---------------------------------------------------------------------------
print("Refining assignments (nearest-centroid, 5 passes) ...")
for iteration in range(5):
# Compute centroids
@@ -93,7 +108,6 @@ for iteration in range(5):
for pid, imgs in patient_to_images.items():
idxs = [np.where(img_nums == img)[0][0] for img in imgs]
centroids[pid] = X_pca[idxs].mean(axis=0)
# Reassign
moves = 0
for class_name in PATIENT_COUNTS:
@@ -118,28 +132,22 @@ for iteration in range(5):
print(f" Pass {iteration+1}: {moves} moves")
if moves == 0:
break
# ---------------------------------------------------------------------------
# 4. t-SNE
# ---------------------------------------------------------------------------
print("Computing t-SNE ...")
X_tsne = TSNE(n_components=2, perplexity=35, learning_rate="auto",
init="pca", random_state=RANDOM_STATE).fit_transform(X_pca)
# ---------------------------------------------------------------------------
# 5. Plot — one figure per class
# ---------------------------------------------------------------------------
for class_name, display_name in CLASS_NAMES.items():
fig, ax = plt.subplots(1, 1, figsize=(14, 10))
ax.scatter(X_tsne[:, 0], X_tsne[:, 1], c="lightgray", s=3, alpha=0.15)
mask = Y == class_name
class_pids = sorted([p for p in patient_to_images if p.startswith(display_name)])
n_patients = len(class_pids)
cmap = plt.cm.tab20 if n_patients <= 20 else plt.cm.gist_ncar
for pi, pid in enumerate(class_pids):
color = cmap(pi % 20) if n_patients <= 20 else cmap(pi / max(n_patients-1, 1))
pts_x, pts_y = [], []
@@ -152,7 +160,6 @@ for class_name, display_name in CLASS_NAMES.items():
cx, cy = np.mean(pts_x), np.mean(pts_y)
ax.scatter(cx, cy, c=[color], s=60, marker='D', edgecolors='black',
linewidths=0.6, zorder=5, label='_nolegend_')
# Legend elements
from matplotlib.lines import Line2D
legend_elements = [
@@ -162,31 +169,32 @@ for class_name, display_name in CLASS_NAMES.items():
markersize=8, label='Patient centroids (diamonds)'),
]
ax.legend(handles=legend_elements, loc='lower right')
ax.set_title(f"{display_name} — VGG16 t-SNE ({n_patients} patients)")
ax.set_xlabel("t-SNE dim 1")
ax.set_ylabel("t-SNE dim 2")
plt.tight_layout()
out = os.path.join(PLOTS_DIR, f"tsne_{display_name}.png")
ap = argparse.ArgumentParser()
ap.add_argument("--tag", default="", help="Append tag to filename")
args = ap.parse_args()
tag = f"_{args.tag}" if args.tag else ""
out = os.path.join(PLOTS_DIR, "tsne", f"tsne_{display_name}{tag}.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
plt.savefig(out, dpi=150)
plt.close()
print(f" Saved {out}")
# ---------------------------------------------------------------------------
# 6. Save assignments
# ---------------------------------------------------------------------------
manifest = []
for pid in sorted(patient_to_images.keys()):
imgs = sorted(patient_to_images[pid])
manifest.append({"patient_id": pid, "class": pid.split("_")[0],
"n_images": len(imgs), "images": ";".join(imgs)})
with open(os.path.join(RESULTS_DIR, "simple_patient_manifest.csv"), "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=["patient_id", "class", "n_images", "images"])
w.writeheader()
w.writerows(manifest)
print(f"\nSaved simple_patient_manifest.csv ({len(manifest)} patients, "
f"{sum(m['n_images'] for m in manifest)} images)")
print("DONE")