Files
patient_leakage_detection/classes/patient_identifier.py
T
rpotter6298 8a136c71fe 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.
2026-06-29 14:30:16 +02:00

191 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)