2026001
This commit is contained in:
@@ -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()
|
||||
@@ -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.")
|
||||
+2602
File diff suppressed because it is too large
Load Diff
Vendored
+218
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)}")
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user