Add scripts for patient classification and visualization
- Created `classification.py` for comparing image-level and patient-level classification results using various CNN models. - Implemented `create_patient_groups.py` to extract features, generate PCA/t-SNE plots, and identify patient groups via K-means clustering. - Added `figure6.py` to generate boxplots for test accuracy across multiple seeds. - Developed `simple_patient_tsne.py` to perform t-SNE visualization of patient groups and save results in a manifest file. - Introduced `simple_patient_manifest.csv` to store patient IDs, classes, image counts, and associated images.
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
Align patient clusters across models using the Hungarian algorithm.
|
||||
|
||||
Each model clusters independently, so its cluster-3 for benign may not
|
||||
correspond to another model's cluster-3. The Hungarian algorithm finds
|
||||
the optimal 1-to-1 mapping between cluster labels by maximizing
|
||||
image overlap.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import linear_sum_assignment
|
||||
|
||||
|
||||
class PatientAligner:
|
||||
"""Align patient clusters across multiple models.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
assignments : dict
|
||||
Nested dict: model_name -> {patient_id: [filenames, ...]}
|
||||
e.g. {"VGG16": {"benign_0": [...], ...}, "DenseNet121": {...}, ...}
|
||||
"""
|
||||
|
||||
def __init__(self, assignments):
|
||||
self.assignments = assignments
|
||||
self.model_names = sorted(assignments.keys())
|
||||
self.n_models = len(self.model_names)
|
||||
|
||||
def align_to_reference(self, reference_model=None):
|
||||
"""Align every model's clusters to a reference model.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
reference_model : str or None
|
||||
Model to use as reference. Defaults to the first model.
|
||||
|
||||
Returns
|
||||
-------
|
||||
mapping : dict
|
||||
model_name -> {model_patient_id: reference_patient_id}
|
||||
"""
|
||||
if reference_model is None:
|
||||
reference_model = self.model_names[0]
|
||||
|
||||
print(f"\nAligning all models to reference: {reference_model}")
|
||||
mapping = {reference_model: {pid: pid for pid in self.assignments[reference_model]}}
|
||||
|
||||
for model in self.model_names:
|
||||
if model == reference_model:
|
||||
continue
|
||||
print(f" {model} -> {reference_model} ...")
|
||||
mapping[model] = {}
|
||||
# Align class by class
|
||||
for prefix in self._class_prefixes():
|
||||
mapping[model].update(
|
||||
self._align_class(model, reference_model, prefix)
|
||||
)
|
||||
|
||||
return mapping
|
||||
|
||||
def build_consensus(self, mapping):
|
||||
"""For each image, measure model agreement via co-clustering overlap.
|
||||
|
||||
Instead of comparing arbitrary cluster IDs (even aligned ones),
|
||||
we ask: *which other images* does each model place in this image's
|
||||
cluster? High Jaccard overlap = models agree on patient membership
|
||||
regardless of what they call the cluster.
|
||||
|
||||
Returns
|
||||
-------
|
||||
consensus : dict
|
||||
image_filename -> {
|
||||
"consensus_pid": <reference-model patient id>,
|
||||
"co_cluster_agreement": <mean pairwise Jaccard>,
|
||||
"cluster_size": {model: n_images_in_cluster},
|
||||
"raw_pids": {model: original_patient_id},
|
||||
}
|
||||
"""
|
||||
# Build cluster lookup: model -> pid -> set of filenames
|
||||
clusters = {}
|
||||
for model, model_assignments in self.assignments.items():
|
||||
clusters[model] = {pid: set(files) for pid, files in model_assignments.items()}
|
||||
|
||||
# Per-image: find which cluster it's in per model
|
||||
image_to_cluster = {}
|
||||
for model, model_clusters in clusters.items():
|
||||
for pid, files in model_clusters.items():
|
||||
for f in files:
|
||||
image_to_cluster.setdefault(f, {})[model] = pid
|
||||
|
||||
consensus = {}
|
||||
for img, model_pids in image_to_cluster.items():
|
||||
# Co-cluster set for each model (images in same cluster, excluding self)
|
||||
co_sets = {}
|
||||
for model in self.model_names:
|
||||
pid = model_pids.get(model)
|
||||
if pid is not None:
|
||||
co_sets[model] = clusters[model][pid] - {img}
|
||||
else:
|
||||
co_sets[model] = set()
|
||||
|
||||
# Pairwise Jaccard between all model pairs
|
||||
jaccards = []
|
||||
for i, ma in enumerate(self.model_names):
|
||||
for j, mb in enumerate(self.model_names):
|
||||
if i < j:
|
||||
sa, sb = co_sets[ma], co_sets[mb]
|
||||
union = len(sa | sb)
|
||||
jac = len(sa & sb) / union if union > 0 else 1.0
|
||||
jaccards.append(jac)
|
||||
|
||||
mean_jaccard = np.mean(jaccards) if jaccards else 1.0
|
||||
|
||||
# Consensus PID via aligned majority vote
|
||||
aligned_votes = {}
|
||||
for model, pid in model_pids.items():
|
||||
aligned_votes[model] = mapping.get(model, {}).get(pid, pid)
|
||||
pid_counts = {}
|
||||
for pid in aligned_votes.values():
|
||||
pid_counts[pid] = pid_counts.get(pid, 0) + 1
|
||||
consensus_pid = max(pid_counts, key=pid_counts.get)
|
||||
|
||||
consensus[img] = {
|
||||
"consensus_pid": consensus_pid,
|
||||
"co_cluster_agreement": round(mean_jaccard, 3),
|
||||
"cluster_size": {m: len(co_sets[m]) + 1 for m in self.model_names},
|
||||
"raw_pids": model_pids,
|
||||
}
|
||||
|
||||
return consensus
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _class_prefixes(self):
|
||||
"""Return the set of class prefixes (benign, malig, normal)."""
|
||||
prefixes = set()
|
||||
for assignments in self.assignments.values():
|
||||
for pid in assignments:
|
||||
prefixes.add(pid.rsplit("_", 1)[0])
|
||||
return sorted(prefixes)
|
||||
|
||||
def _align_class(self, model_a, model_b, prefix):
|
||||
"""Hungarian-align patient IDs for one class between two models.
|
||||
|
||||
Returns dict: model_a_patient_id -> model_b_patient_id
|
||||
"""
|
||||
# Gather patient IDs for this class prefix
|
||||
pids_a = sorted([p for p in self.assignments[model_a] if p.startswith(prefix)])
|
||||
pids_b = sorted([p for p in self.assignments[model_b] if p.startswith(prefix)])
|
||||
|
||||
n_a, n_b = len(pids_a), len(pids_b)
|
||||
if n_a == 0 or n_b == 0:
|
||||
return {}
|
||||
|
||||
# Build overlap cost matrix (negate overlap → minimize = maximize overlap)
|
||||
cost = np.zeros((n_a, n_b))
|
||||
for i, pa in enumerate(pids_a):
|
||||
files_a = set(self.assignments[model_a][pa])
|
||||
for j, pb in enumerate(pids_b):
|
||||
files_b = set(self.assignments[model_b][pb])
|
||||
overlap = len(files_a & files_b)
|
||||
cost[i, j] = -overlap # Hungarian *minimizes* cost
|
||||
|
||||
row_ind, col_ind = linear_sum_assignment(cost)
|
||||
|
||||
# Build mapping
|
||||
mapping = {}
|
||||
zero_matches = 0
|
||||
missed_opportunities = 0
|
||||
|
||||
for i, j in zip(row_ind, col_ind):
|
||||
overlap = -cost[i, j]
|
||||
total_a = len(self.assignments[model_a][pids_a[i]])
|
||||
|
||||
# Best possible match for this cluster (even if Hungarian didn't pick it)
|
||||
best_overlap = int(-cost[i].min()) # most negative → best match
|
||||
best_j = int(np.argmin(cost[i]))
|
||||
|
||||
mapping[pids_a[i]] = pids_b[j]
|
||||
|
||||
if overlap == 0:
|
||||
zero_matches += 1
|
||||
if best_overlap > 0:
|
||||
missed_opportunities += 1
|
||||
best_pid = pids_b[best_j]
|
||||
print(f" LOW-OVERLAP: {pids_a[i]} ({total_a} imgs) matched to "
|
||||
f"{pids_b[j]} (0 overlap) — best would be {best_pid} "
|
||||
f"({best_overlap}/{total_a} overlap)")
|
||||
else:
|
||||
print(f" UNMATCHED: {pids_a[i]} ({total_a} imgs) — "
|
||||
f"no cluster in {model_b} shares ANY images with this group")
|
||||
|
||||
if zero_matches:
|
||||
print(f" {zero_matches} zero-overlap matches ({missed_opportunities} "
|
||||
f"forced by global optimum, {zero_matches - missed_opportunities} "
|
||||
f"truly unmatched)")
|
||||
return mapping
|
||||
Reference in New Issue
Block a user