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:
rpotter6298
2026-06-29 14:30:16 +02:00
commit 8a136c71fe
15 changed files with 4273 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from .features import FeatureExtractor
from .patient_identifier import PatientIdentifier
from .classifier import PatientLeakageClassifier
+199
View File
@@ -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
+170
View File
@@ -0,0 +1,170 @@
"""
PatientLeakageClassifier — runs the RF→GridSearchCV→SVM pipeline
with patient-aware or image-level train/test splitting.
"""
import os, csv, re
import numpy as np
from sklearn.model_selection import (
train_test_split, GridSearchCV, StratifiedGroupKFold
)
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score
# ---------------------------------------------------------------------------
# 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
DEFAULT_NFEATURES = [50, 100, 200, 300, 400, 500, 750, 1000,
1500, 2000, 3000, 5000]
DEFAULT_GAMMA_RANGE = (-1.5, 1, 7) # logspace args
class PatientLeakageClassifier:
"""Run the classification pipeline for one model, seed, and split type.
Parameters
----------
manifest_path : str Path to patient manifest CSV.
features_dir : str Directory with {Model}_features.npz files.
n_jobs : int Parallelism for RF and GridSearchCV.
"""
def __init__(self, manifest_path, features_dir, n_jobs=8):
self.features_dir = features_dir
self.n_jobs = n_jobs
self._image_to_patient = {}
with open(manifest_path, newline="") as f:
reader = csv.DictReader(f)
img_col = ("confirmed_images" if "confirmed_images" in reader.fieldnames
else "images")
for row in reader:
pid = row["patient_id"]
for img in row[img_col].split(";"):
if img:
self._image_to_patient[img] = pid
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def run(self, model_name, seed, split_type="patient",
nfeatures_list=None, gamma_logspace=None):
"""Run full pipeline: train/test split → RF importance →
GridSearchCV over nfeatures×gamma → retrain → test.
Parameters
----------
model_name : str e.g. "VGG16"
seed : int Random seed for the train/test split.
split_type : str "image" or "patient".
nfeatures_list : list Feature counts to search over.
gamma_logspace : tuple Args for np.logspace (start, stop, n).
Returns
-------
dict with keys: model, seed, split_type, cv, test, nfeat, gamma,
n_train, n_test, n_train_patients, n_test_patients
"""
if nfeatures_list is None:
nfeatures_list = DEFAULT_NFEATURES
if gamma_logspace is None:
gamma_logspace = DEFAULT_GAMMA_RANGE
X, Y, groups = self._load_data(model_name)
# --- Train/test split ---
if split_type == "image":
X_tr, X_te, y_tr, y_te = train_test_split(
X, Y, test_size=0.2, random_state=seed, stratify=Y)
cv = 5
g_tr = None
n_tr_patients = None
n_te_patients = None
else:
unique_g = np.unique(groups)
g_labels = np.array([Y[groups == g][0] for g in unique_g])
tr_g, te_g = train_test_split(
unique_g, test_size=0.2, random_state=seed, stratify=g_labels)
tr_mask = np.isin(groups, tr_g)
te_mask = np.isin(groups, te_g)
X_tr, X_te = X[tr_mask], X[te_mask]
y_tr, y_te = Y[tr_mask], Y[te_mask]
g_tr = groups[tr_mask]
n_tr_patients = len(np.unique(g_tr))
n_te_patients = len(np.unique(groups[te_mask]))
cv = StratifiedGroupKFold(n_splits=5, shuffle=True,
random_state=seed)
# --- RF feature importance ---
fs = RandomForestClassifier(n_estimators=500, random_state=15,
class_weight="balanced", n_jobs=self.n_jobs)
fs.fit(X_tr, y_tr)
sorted_idx = np.argsort(fs.feature_importances_)[::-1]
# --- Grid search over nfeatures × gamma ---
pipe = Pipeline([("scaler", StandardScaler()),
("svm", SVC(kernel="rbf", C=10))])
valid_nfeats = [n for n in nfeatures_list if n <= X_tr.shape[1]]
best_cv, best_n, best_g = 0, None, None
for nfeat in valid_nfeats:
sel = sorted_idx[:nfeat]
pg = {"svm__gamma": (1.0 / nfeat) * np.logspace(*gamma_logspace)}
grid = GridSearchCV(pipe, pg, cv=cv, scoring="accuracy",
n_jobs=self.n_jobs, verbose=0)
if g_tr is not None:
grid.fit(X_tr[:, sel], y_tr, groups=g_tr)
else:
grid.fit(X_tr[:, sel], y_tr)
if grid.best_score_ > best_cv:
best_cv = grid.best_score_
best_n = nfeat
best_g = grid.best_params_["svm__gamma"]
# --- Retrain + test ---
final = Pipeline([("scaler", StandardScaler()),
("svm", SVC(kernel="rbf", C=10, gamma=best_g))])
final.fit(X_tr[:, sorted_idx[:best_n]], y_tr)
y_pred = final.predict(X_te[:, sorted_idx[:best_n]])
return {
"model": model_name,
"seed": seed,
"split_type": split_type,
"cv": float(best_cv),
"test": float(accuracy_score(y_te, y_pred)),
"nfeat": best_n,
"gamma": float(best_g),
"n_train": X_tr.shape[0],
"n_test": X_te.shape[0],
"n_train_patients": n_tr_patients,
"n_test_patients": n_te_patients,
}
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _load_data(self, model_name):
data = np.load(os.path.join(self.features_dir,
f"{model_name}_features.npz"),
allow_pickle=True)
X = data["X"]
Y = data["Y"]
filenames = data["filenames"]
groups = np.array([self._image_to_patient.get(_f2n(f), f"unk_{i}")
for i, f in enumerate(filenames)])
return X, Y, groups
+95
View File
@@ -0,0 +1,95 @@
"""
Cluster confidence scoring for patient identification.
For each K-means cluster (in PCA-50d space), compute how well-separated
it is from its nearest neighbor. Well-separated clusters are more likely
to represent real individual patients.
"""
import numpy as np
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
from scipy.spatial.distance import cdist
def compute_confidence(X, labels, patient_estimates, random_state=42):
"""Score each patient cluster by its separation from neighbors.
Works in PCA-50d space (same space K-means operates in).
Parameters
----------
X : np.ndarray (n_images, n_features)
Full feature matrix (all classes together).
labels : np.ndarray (n_images,)
Class label for each image.
patient_estimates : dict
class_name -> n_patients.
random_state : int
Returns
-------
cluster_scores : dict
patient_id -> {
"confidence": float, # separation / internal_spread
"intra_spread": float, # mean distance to centroid
"nearest_neighbor_dist": float,
"nearest_neighbor_id": str,
"images": list of filenames, # set by caller
"n_images": int,
}
"""
n_pca = min(50, X.shape[0] - 1, X.shape[1])
pca = PCA(n_components=n_pca, random_state=random_state)
X_pca = pca.fit_transform(X)
cluster_scores = {}
for class_name, n_clusters in patient_estimates.items():
idx_class = np.where(labels == class_name)[0]
X_class = X_pca[idx_class]
kmeans = KMeans(n_clusters=n_clusters,
random_state=random_state, n_init=20)
cluster_ids = kmeans.fit_predict(X_class)
centroids = kmeans.cluster_centers_ # (n_clusters, n_pca)
# Intra-cluster spread: mean distance from centroid
intra_spreads = np.zeros(n_clusters)
for k in range(n_clusters):
mask = cluster_ids == k
if mask.sum() > 0:
dists = np.linalg.norm(X_class[mask] - centroids[k], axis=1)
intra_spreads[k] = dists.mean()
# Inter-cluster: distance to nearest OTHER centroid
centroid_dists = cdist(centroids, centroids, metric='euclidean')
np.fill_diagonal(centroid_dists, np.inf)
nearest_dists = centroid_dists.min(axis=1)
nearest_idx = centroid_dists.argmin(axis=1)
# Confidence = separation / internal spread
# (add epsilon to avoid division by zero)
with np.errstate(divide='ignore', invalid='ignore'):
confidences = nearest_dists / (intra_spreads + 1e-8)
confidences = np.nan_to_num(confidences, nan=0.0)
# Build result dict
class_prefix = {
"Bengin cases": "benign",
"Malignant cases": "malig",
"Normal cases": "normal",
}[class_name]
for k in range(n_clusters):
pid = f"{class_prefix}_{k}"
cluster_scores[pid] = {
"confidence": float(confidences[k]),
"intra_spread": float(intra_spreads[k]),
"nearest_neighbor_dist": float(nearest_dists[k]),
"nearest_neighbor_id": f"{class_prefix}_{int(nearest_idx[k])}",
"class_name": class_name,
"cluster_id": k,
}
return cluster_scores, X_pca, pca
+212
View File
@@ -0,0 +1,212 @@
"""
Feature extraction using pretrained CNNs (PyTorch).
Saves feature maps as .npz files with the model name in the filename,
e.g. "VGG16_features.npz" containing X, Y, and filenames arrays.
Supports all five models from the manuscript:
VGG16, DenseNet121, EfficientNetB1, MobileNetV2, ResNet50
"""
import os
import numpy as np
from PIL import Image
import torch
import torch.nn as nn
from torchvision import transforms
from torchvision.models import (
vgg16, VGG16_Weights,
densenet121, DenseNet121_Weights,
efficientnet_b1, EfficientNet_B1_Weights,
mobilenet_v2, MobileNet_V2_Weights,
resnet50, ResNet50_Weights,
)
# ---------------------------------------------------------------------------
# Model registry
# ---------------------------------------------------------------------------
MODEL_CONFIGS = {
"VGG16": {
"fn": vgg16,
"weights": VGG16_Weights.IMAGENET1K_V1,
"input_size": 224,
},
"DenseNet121": {
"fn": densenet121,
"weights": DenseNet121_Weights.IMAGENET1K_V1,
"input_size": 224,
},
"EfficientNetB1": {
"fn": efficientnet_b1,
"weights": EfficientNet_B1_Weights.IMAGENET1K_V1,
"input_size": 240,
},
"MobileNetV2": {
"fn": mobilenet_v2,
"weights": MobileNet_V2_Weights.IMAGENET1K_V1,
"input_size": 224,
},
"ResNet50": {
"fn": resnet50,
"weights": ResNet50_Weights.IMAGENET1K_V1,
"input_size": 224,
},
}
class FeatureExtractor:
"""Extract deep features from images using a pretrained CNN.
Parameters
----------
model_name : str
One of: VGG16, DenseNet121, EfficientNetB1, MobileNetV2, ResNet50.
device : str or None
Torch device string. Auto-detected if None.
"""
def __init__(self, model_name="VGG16", device=None):
if model_name not in MODEL_CONFIGS:
raise ValueError(
f"Unsupported model '{model_name}'. "
f"Choose from: {list(MODEL_CONFIGS.keys())}"
)
self.model_name = model_name
self.cfg = MODEL_CONFIGS[model_name]
self.input_size = self.cfg["input_size"]
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.model = None
self._load_model()
def _load_model(self):
"""Load pretrained model and strip the classifier head."""
full_model = self.cfg["fn"](weights=self.cfg["weights"])
name = self.model_name
if name == "VGG16":
# Drop the classifier Sequential → output (B, 512, 7, 7)
self.model = nn.Sequential(*list(full_model.children())[:-1])
elif name == "DenseNet121":
# Keep conv stack (dense blocks), drop classifier Linear
# → output (B, 1024, 7, 7)
self.model = full_model.features
elif name == "EfficientNetB1":
# Keep conv stack, drop avgpool + classifier
# → output (B, 1280, 7, 7) (at 224 px; 8×8 at 240 px)
self.model = full_model.features
elif name == "MobileNetV2":
# Keep conv stack, drop adaptive pool + classifier
# → output (B, 1280, 7, 7)
self.model = full_model.features
elif name == "ResNet50":
# Drop fc, keep everything *including* the adaptive avg pool
# → output (B, 2048, 1, 1) — matches TF include_top=False
full_model.fc = nn.Identity()
self.model = full_model
self.model.to(self.device)
self.model.eval()
# Preprocessing
self.transform = transforms.Compose([
transforms.Resize((self.input_size, self.input_size)),
transforms.ToTensor(),
transforms.Normalize(
mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]
),
])
# ------------------------------------------------------------------
# extract / save / load — unchanged public API
# ------------------------------------------------------------------
def extract(self, image_dir, valid_extensions=None, batch_size=32):
"""Extract features from all images in a directory tree.
Expects subdirectories named by class (e.g. "Bengin cases/").
Returns
-------
X : np.ndarray (n_images, n_features)
Y : np.ndarray (n_images,) class labels
filenames : np.ndarray (n_images,) image filenames
"""
if valid_extensions is None:
valid_extensions = (".png", ".jpg", ".jpeg",
".tif", ".tiff", ".bmp")
image_paths, labels, fnames = [], [], []
for class_name in sorted(os.listdir(image_dir)):
class_path = os.path.join(image_dir, class_name)
if not os.path.isdir(class_path):
continue
print(f" Scanning class: {class_name}")
for file in sorted(os.listdir(class_path)):
if not file.lower().endswith(valid_extensions):
continue
image_paths.append(os.path.join(class_path, file))
labels.append(class_name)
fnames.append(file)
n_images = len(image_paths)
print(f" Found {n_images} images across all classes")
features = []
for start in range(0, n_images, batch_size):
end = min(start + batch_size, n_images)
batch_paths = image_paths[start:end]
batch_tensors = []
for path in batch_paths:
try:
img = Image.open(path).convert("RGB")
tensor = self.transform(img)
batch_tensors.append(tensor)
except Exception as e:
print(f" Error loading {path}: {e}")
batch_tensors.append(torch.zeros(3, self.input_size, self.input_size))
batch = torch.stack(batch_tensors).to(self.device)
with torch.no_grad():
batch_features = self.model(batch)
batch_features = batch_features.view(batch_features.size(0), -1)
features.append(batch_features.cpu().numpy())
if (start // batch_size) % 20 == 0:
print(f" Processed {end}/{n_images} images...")
X = np.concatenate(features, axis=0).astype(np.float32)
Y = np.array(labels)
filenames = np.array(fnames)
print(f" Feature matrix shape: {X.shape}")
return X, Y, filenames
def save_features(self, X, Y, filenames, output_dir):
"""Save extracted features to a compressed .npz file."""
os.makedirs(output_dir, exist_ok=True)
filepath = os.path.join(output_dir, f"{self.model_name}_features.npz")
np.savez_compressed(filepath, X=X, Y=Y, filenames=filenames)
print(f" Saved features to {filepath}")
return filepath
@staticmethod
def load_features(filepath):
"""Load saved features from a .npz file.
Returns
-------
X, Y, filenames : np.ndarray
"""
data = np.load(filepath, allow_pickle=True)
return data["X"], data["Y"], data["filenames"]
+190
View File
@@ -0,0 +1,190 @@
"""
Patient identification via K-means clustering.
Two modes:
- Thumbnail-based: cluster on 64×64 grayscale images (Andreas's approach).
- Feature-based: cluster on PCA-reduced CNN features.
Since the IQ-OTH/NCCD dataset does not provide patient IDs,
we estimate which images belong to the same patient by clustering
within each class.
"""
import os
import numpy as np
from PIL import Image
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
class PatientIdentifier:
"""Estimate patient groups via K-means clustering within each class.
Parameters
----------
patient_estimates : dict
Mapping from class_name -> number_of_patients, e.g.
{"Bengin cases": 15, "Malignant cases": 40, "Normal cases": 55}.
cluster_image_size : tuple of int
Grayscale thumbnail size for clustering (default (64, 64)).
random_state : int
Seed for PCA and K-means reproducibility.
"""
def __init__(self, patient_estimates, cluster_image_size=(64, 64),
random_state=42):
self.patient_estimates = patient_estimates
self.cluster_image_size = cluster_image_size
self.random_state = random_state
self.groups_ = None
self.labels_ = None
self.assignments_ = None # dict: class_name -> list of cluster_ids per sample
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def identify_from_thumbnails(self, base_path, filenames, labels,
valid_extensions=None):
"""Cluster images within each class using grayscale thumbnails.
(Original approach from Andreas — model-agnostic.)
"""
self.labels_ = labels
groups = self._run_clustering(
labels, filenames,
data_loader_fn=lambda class_name, files: self._load_thumbnails(
base_path, class_name, files),
valid_extensions=valid_extensions,
)
self.groups_ = groups
return groups
def identify_from_features(self, X, labels):
"""Cluster images within each class using pre-extracted CNN features.
Parameters
----------
X : np.ndarray (n_images, n_features)
Feature matrix from a CNN (e.g. VGG16 output).
labels : np.ndarray (n_images,)
Class label strings in the same order as X.
Returns
-------
groups : np.ndarray (n_images,) group label strings
"""
self.labels_ = labels
groups = self._run_clustering(
labels, None,
data_loader_fn=lambda class_name, idx_class: X[idx_class],
)
self.groups_ = groups
return groups
def build_assignment_dict(self, groups, filenames, labels):
"""Convert flat group array into a human-readable dict.
Returns
-------
dict mapping patient_id (e.g. "benign_0") -> list of image filenames.
"""
self.labels_ = labels
class_map = {
"Bengin cases": "benign",
"Malignant cases": "malig",
"Normal cases": "normal",
}
assignment = {}
for class_name, prefix in class_map.items():
idx_class = np.where(labels == class_name)[0]
# Get unique cluster IDs within this class (preserve order)
seen = []
cluster_ids = []
for g in groups[idx_class]:
cid = int(g.split("_cluster_")[-1])
if cid not in seen:
seen.append(cid)
cluster_ids.append(seen.index(cid))
for local_id in seen:
patient_id = f"{prefix}_{local_id}"
mask = np.array(cluster_ids) == local_id
assignment[patient_id] = filenames[idx_class][mask].tolist()
self.assignments_ = assignment
return assignment
def get_unique_groups(self):
if self.groups_ is None:
raise RuntimeError("Call identify_*() first.")
return np.unique(self.groups_)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _run_clustering(self, labels, filenames, data_loader_fn,
valid_extensions=None):
"""Shared K-means pipeline used by both thumbnail and feature modes."""
if valid_extensions is None:
valid_extensions = (".png", ".jpg", ".jpeg",
".tif", ".tiff", ".bmp")
n_samples = len(labels)
groups = np.array([None] * n_samples, dtype=object)
for class_name, n_clusters in self.patient_estimates.items():
print(f"\n Clustering class: {class_name} "
f"(k={n_clusters})")
idx_class = np.where(labels == class_name)[0]
X_cluster = data_loader_fn(class_name, idx_class)
if isinstance(X_cluster, list):
X_cluster = np.array(X_cluster, dtype=np.float32)
# Dimensionality reduction
n_pca = min(50, X_cluster.shape[0] - 1, X_cluster.shape[1])
pca = PCA(n_components=n_pca, random_state=self.random_state)
X_pca = pca.fit_transform(X_cluster)
# K-means
kmeans = KMeans(
n_clusters=n_clusters,
random_state=self.random_state,
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}"
cluster_sizes = np.bincount(clusters)
print(f" Cluster sizes: min={cluster_sizes.min()}, "
f"max={cluster_sizes.max()}, "
f"mean={cluster_sizes.mean():.1f}")
n_unique = len(np.unique(groups))
print(f"\n Estimated {n_unique} patient groups total")
return groups
def _load_thumbnails(self, base_path, class_name, files):
"""Load grayscale 64×64 thumbnails for a class."""
class_path = os.path.join(base_path, class_name)
X = []
for file in files:
img_path = os.path.join(class_path, file)
try:
img = Image.open(img_path).convert("L")
img = img.resize(self.cluster_image_size)
img = np.array(img, dtype=np.float32) / 255.0
X.append(img.flatten())
except Exception as e:
print(f" Error loading {file}: {e}")
X.append(np.zeros(
self.cluster_image_size[0] *
self.cluster_image_size[1],
dtype=np.float32
))
return np.array(X, dtype=np.float32)
+148
View File
@@ -0,0 +1,148 @@
"""
Visualization utilities for feature space exploration.
PCA and t-SNE plots to inspect class separability in the
extracted feature representations.
"""
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.manifold import TSNE
def plot_pca_tsne(X, Y, legend_names=None, perplexity=35,
random_state=42, output_path=None, figsize=(12, 5),
dpi=150):
"""Generate side-by-side PCA and t-SNE plots of feature vectors.
Parameters
----------
X : np.ndarray of shape (n_samples, n_features)
Feature matrix (e.g. VGG16 outputs).
Y : np.ndarray of shape (n_samples,)
Class labels.
legend_names : dict, optional
Mapping from raw label -> display label, e.g.
{"Bengin cases": "Benign", ...}.
perplexity : int
t-SNE perplexity (default 35).
random_state : int
Seed for reproducibility.
output_path : str, optional
Path to save the figure. If None, saved as "pca_tsne.png".
figsize : tuple
Figure dimensions.
dpi : int
Output resolution.
Returns
-------
fig : matplotlib Figure
"""
if legend_names is None:
legend_names = {}
n_samples, n_features = X.shape
# --- PCA: 50D → 2D ---
n_pca = min(50, n_samples - 1, n_features)
pca_pre = PCA(n_components=n_pca, random_state=random_state)
X_pca50 = pca_pre.fit_transform(X)
pca_2d = PCA(n_components=2, random_state=random_state)
X_pca2 = pca_2d.fit_transform(X_pca50)
explained_variance = pca_2d.explained_variance_ratio_ * 100
# --- t-SNE: 50D → 2D ---
tsne = TSNE(
n_components=2,
perplexity=perplexity,
learning_rate="auto",
init="pca",
random_state=random_state
)
X_tsne = tsne.fit_transform(X_pca50)
# --- Plot ---
fig, axes = plt.subplots(1, 2, figsize=figsize)
unique_classes = np.unique(Y)
for class_name in unique_classes:
idx = Y == class_name
label = legend_names.get(class_name, class_name)
axes[0].scatter(
X_pca2[idx, 0], X_pca2[idx, 1],
label=label, alpha=0.7, s=8
)
axes[0].set_xlabel(f"PC1 ({explained_variance[0]:.1f}%)")
axes[0].set_ylabel(f"PC2 ({explained_variance[1]:.1f}%)")
axes[0].set_title("PCA")
axes[0].legend(markerscale=2)
for class_name in unique_classes:
idx = Y == class_name
label = legend_names.get(class_name, class_name)
axes[1].scatter(
X_tsne[idx, 0], X_tsne[idx, 1],
label=label, alpha=0.7, s=8
)
axes[1].set_xlabel("t-SNE dimension 1")
axes[1].set_ylabel("t-SNE dimension 2")
axes[1].set_title("t-SNE")
axes[1].legend(markerscale=2)
plt.tight_layout()
if output_path is None:
output_path = "pca_tsne.png"
plt.savefig(output_path, dpi=dpi)
plt.close()
print(f" Saved PCA/t-SNE plot to {output_path}")
return fig
def plot_cv_accuracy(cv_results, split_type="", output_path=None,
figsize=(7, 5), dpi=150):
"""Plot cross-validation accuracy vs number of selected features.
Parameters
----------
cv_results : list of dict
Each dict has keys "nfeatures" and "best_cv_accuracy".
split_type : str
Label for the plot title, e.g. "Image-level split".
output_path : str, optional
Output file path.
figsize : tuple
dpi : int
Returns
-------
fig : matplotlib Figure
"""
fig, ax = plt.subplots(figsize=figsize)
nfeats = [r["nfeatures"] for r in cv_results]
accs = [r["best_cv_accuracy"] for r in cv_results]
ax.plot(nfeats, accs, marker="o")
ax.set_xscale("log")
ax.set_xlabel("Number of selected features")
ax.set_ylabel("Mean 5-fold validation accuracy")
if split_type:
ax.set_title(split_type)
plt.tight_layout()
if output_path is None:
output_path = f"{split_type.replace(' ', '_').replace('-', '_')}_cv.png"
plt.savefig(output_path, dpi=dpi)
plt.close()
print(f" Saved CV accuracy plot to {output_path}")
return fig