JALE/jale/core/utils/hierarchical.py

883 lines
30 KiB
Python

import logging
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from scipy.cluster.hierarchy import (
dendrogram,
fcluster,
linkage,
optimal_leaf_ordering,
)
from scipy.spatial.distance import squareform
from scipy.stats import spearmanr
from sklearn.metrics import (
calinski_harabasz_score,
silhouette_score,
)
from jale.core.utils.compute import compute_ma, generate_unique_subsamples
from jale.core.utils.template import GM_PRIOR
def hierarchical_clustering_pipeline(
project_path,
meta_name,
exp_df,
kernels,
correlation_type,
correlation_matrix,
linkage_method,
max_clusters,
subsample_fraction,
sampling_iterations,
null_iterations,
use_pooled_std,
):
logger = logging.getLogger("ale_logger")
logger.info(f"{meta_name} - starting subsampling")
(
silhouette_scores,
calinski_harabasz_scores,
exp_separation_density,
cluster_labels,
) = compute_hc_subsampling(
correlation_matrix=correlation_matrix,
max_clusters=max_clusters,
subsample_fraction=subsample_fraction,
sampling_iterations=sampling_iterations,
linkage_method=linkage_method,
)
logger.info(f"{meta_name} - starting null calculation")
(
null_silhouette_scores,
null_calinski_harabasz_scores,
) = compute_hc_null(
exp_df=exp_df,
kernels=kernels,
correlation_type=correlation_type,
linkage_method=linkage_method,
max_clusters=max_clusters,
null_iterations=null_iterations,
subsample_fraction=subsample_fraction,
)
silhouette_scores_z, calinski_harabasz_scores_z = compute_hc_metrics_z(
silhouette_scores=silhouette_scores,
calinski_harabasz_scores=calinski_harabasz_scores,
null_silhouette_scores=null_silhouette_scores,
null_calinski_harabasz_scores=null_calinski_harabasz_scores,
use_pooled_std=use_pooled_std,
)
logger.info(f"{meta_name} - calculating final cluster labels")
hamming_distance_cluster_labels = compute_hamming_distance_hc(
cluster_labels=cluster_labels,
linkage_method=linkage_method,
max_clusters=max_clusters,
)
logger.info(f"{meta_name} - creating output and saving")
save_hc_labels(
project_path=project_path,
exp_df=exp_df,
meta_name=meta_name,
cluster_labels=hamming_distance_cluster_labels,
correlation_type=correlation_type,
linkage_method=linkage_method,
max_clusters=max_clusters,
)
save_hc_metrics(
project_path=project_path,
meta_name=meta_name,
silhouette_scores=silhouette_scores,
null_silhouette_scores=null_silhouette_scores,
silhouette_scores_z=silhouette_scores_z,
calinski_harabasz_scores=calinski_harabasz_scores,
null_calinski_harabasz_scores=null_calinski_harabasz_scores,
calinski_harabasz_scores_z=calinski_harabasz_scores_z,
exp_separation_density=exp_separation_density,
correlation_type=correlation_type,
linkage_method=linkage_method,
)
plot_hc_metrics(
project_path=project_path,
meta_name=meta_name,
silhouette_scores=silhouette_scores,
null_silhouette_scores=null_silhouette_scores,
silhouette_scores_z=silhouette_scores_z,
calinski_harabasz_scores=calinski_harabasz_scores,
null_calinski_harabasz_scores=null_calinski_harabasz_scores,
calinski_harabasz_scores_z=calinski_harabasz_scores_z,
exp_separation_density=exp_separation_density,
correlation_type=correlation_type,
linkage_method=linkage_method,
max_clusters=max_clusters,
)
plot_sorted_dendrogram(
project_path=project_path,
meta_name=meta_name,
correlation_matrix=correlation_matrix,
correlation_type=correlation_type,
linkage_method=linkage_method,
max_clusters=max_clusters,
)
def compute_hc_subsampling(
correlation_matrix,
max_clusters,
subsample_fraction,
sampling_iterations,
linkage_method,
):
silhouette_scores = np.empty((max_clusters - 1, sampling_iterations))
calinski_harabasz_scores = np.empty((max_clusters - 1, sampling_iterations))
exp_separation_density = np.empty((max_clusters - 2, sampling_iterations))
cluster_labels = np.full(
(max_clusters - 1, correlation_matrix.shape[0], sampling_iterations), np.nan
)
subsamples = generate_unique_subsamples(
total_n=correlation_matrix.shape[0],
target_n=int(subsample_fraction * correlation_matrix.shape[0]),
sample_n=sampling_iterations,
)
for i in range(sampling_iterations):
resampled_indices = subsamples[i]
resampled_correlation = correlation_matrix[
np.ix_(resampled_indices, resampled_indices)
]
# Perform hierarchical clustering once per subsample
distance_matrix = 1 - resampled_correlation
np.fill_diagonal(distance_matrix, 0)
condensed_distance = squareform(distance_matrix, checks=False)
Z = linkage(condensed_distance, method=linkage_method)
# Calculate relative difference in cophenetic distance for the whole hierarchy
# rel_diff_cophenetic[:, i] = calculate_rel_diff_cophenetic(Z, max_clusters)
for k in range(2, max_clusters + 1):
cluster_label = fcluster(Z, k, criterion="maxclust")
# Silhouette Score
silhouette = silhouette_score(
distance_matrix, cluster_label, metric="precomputed"
)
# Calinski-Harabasz Index
calinski_harabasz = calinski_harabasz_score(
resampled_correlation, cluster_label
)
silhouette_scores[k - 2, i] = silhouette
calinski_harabasz_scores[k - 2, i] = calinski_harabasz
cluster_labels[k - 2, resampled_indices, i] = cluster_label
# Calculate experiment separation density for transition from k to k+1
if k < max_clusters:
esd = calculate_exp_separation_density(Z, k)
exp_separation_density[k - 2, i] = esd
return (
silhouette_scores,
calinski_harabasz_scores,
exp_separation_density,
cluster_labels,
)
def calculate_exp_separation_density(linkage_matrix, k):
"""Calculates the experiment separation density for the transition from k to k+1 clusters."""
if k + 1 > linkage_matrix.shape[0] + 1:
return np.nan # Cannot form k+1 clusters
clusters_k = fcluster(linkage_matrix, k, criterion="maxclust")
clusters_k_plus_1 = fcluster(linkage_matrix, k + 1, criterion="maxclust")
split_cluster_id = -1
new_labels = []
# Find which cluster in the k-cluster solution was split
for cluster_id in range(1, k + 1):
member_indices = np.where(clusters_k == cluster_id)[0]
sub_labels = np.unique(clusters_k_plus_1[member_indices])
if len(sub_labels) > 1:
split_cluster_id = cluster_id
new_labels = sub_labels
break
if split_cluster_id != -1:
low_size = np.sum(clusters_k == split_cluster_id)
high1_size = np.sum(clusters_k_plus_1 == new_labels[0])
high2_size = np.sum(clusters_k_plus_1 == new_labels[1])
if low_size > 0:
return max(high1_size, high2_size) / low_size
return np.nan # Should not be reached in a valid split
def compute_hc_null(
exp_df,
kernels,
correlation_type,
linkage_method,
max_clusters,
null_iterations,
subsample_fraction,
):
null_silhouette_scores = np.empty((max_clusters - 1, null_iterations))
null_calinski_harabasz_scores = np.empty((max_clusters - 1, null_iterations))
subsamples = generate_unique_subsamples(
total_n=exp_df.shape[0],
target_n=int(subsample_fraction * exp_df.shape[0]),
sample_n=null_iterations,
)
for n in range(null_iterations):
# Create an index array for subsampling
# Subsample exp_df and kernels using the sampled indices
sampled_exp_df = exp_df.iloc[subsamples[n]].reset_index(drop=True)
sampled_kernels = [kernels[idx] for idx in subsamples[n]]
coords_stacked = np.vstack(sampled_exp_df.Coordinates.values)
shuffled_coords = []
for exp in range(len(sampled_exp_df)):
K = sampled_exp_df.iloc[exp]["NumberOfFoci"]
# Step 1: Randomly sample K unique row indices
sample_indices = np.random.choice(
coords_stacked.shape[0], size=K, replace=False
)
# Step 2: Extract the sampled rows using the sampled indices
sampled_rows = coords_stacked[sample_indices]
shuffled_coords.append(sampled_rows)
# Step 3: Delete the sampled rows from the original array
coords_stacked = np.delete(coords_stacked, sample_indices, axis=0)
# Compute the meta-analysis result with subsampled kernels
null_ma = compute_ma(shuffled_coords, sampled_kernels)
ma_gm_masked = null_ma[:, GM_PRIOR]
# Set entire row to np.nan only if all values in the row are zero
ma_gm_masked_nan = ma_gm_masked.copy()
row_is_zero = np.all(ma_gm_masked_nan == 0, axis=1)
ma_gm_masked_nan[row_is_zero, :] = np.nan
if correlation_type == "spearman":
correlation_matrix, _ = spearmanr(ma_gm_masked_nan, axis=1)
elif correlation_type == "pearson":
correlation_matrix = np.corrcoef(ma_gm_masked_nan)
# Replace any NaNs in the correlation matrix with zero for further calculations
correlation_matrix = np.nan_to_num(
correlation_matrix, nan=0, posinf=0, neginf=0
)
np.fill_diagonal(correlation_matrix, 0)
for k in range(2, max_clusters + 1):
(
silhouette_score,
calinski_harabasz_score,
cluster_label,
) = compute_hierarchical_clustering(
correlation_matrix=correlation_matrix,
k=k,
linkage_method=linkage_method,
)
null_silhouette_scores[k - 2, n] = silhouette_score
null_calinski_harabasz_scores[k - 2, n] = calinski_harabasz_score
return (
null_silhouette_scores,
null_calinski_harabasz_scores,
)
def compute_hierarchical_clustering(correlation_matrix, k, linkage_method):
distance_matrix = 1 - correlation_matrix
np.fill_diagonal(distance_matrix, 0)
condensed_distance = squareform(distance_matrix, checks=False)
# Perform hierarchical clustering
Z = linkage(condensed_distance, method=linkage_method)
cluster_labels = fcluster(Z, k, criterion="maxclust")
# Safeguard: If only one unique cluster label, set metrics to np.nan
if len(np.unique(cluster_labels)) < 2:
silhouette = np.nan
calinski_harabasz = np.nan
else:
silhouette = silhouette_score(
distance_matrix,
cluster_labels,
metric="precomputed",
)
calinski_harabasz = calinski_harabasz_score(correlation_matrix, cluster_labels)
return (
silhouette,
calinski_harabasz,
cluster_labels,
)
def compute_hc_metrics_z(
silhouette_scores,
calinski_harabasz_scores,
null_silhouette_scores,
null_calinski_harabasz_scores,
use_pooled_std=False,
):
def pooled_std(sample1, sample2):
"""Compute the pooled standard deviation of two samples."""
n1, n2 = sample1.shape[1], sample2.shape[1]
var1, var2 = np.nanvar(sample1, axis=1, ddof=1), np.nanvar(sample2, axis=1, ddof=1)
return np.sqrt(((n1 - 1) * var1 + (n2 - 1) * var2) / (n1 + n2 - 2))
silhouette_scores_avg = np.nanmean(silhouette_scores, axis=1)
null_silhouette_scores_avg = np.nanmean(null_silhouette_scores, axis=1)
if use_pooled_std:
silhouette_std = pooled_std(silhouette_scores, null_silhouette_scores)
else:
silhouette_std = np.nanstd(null_silhouette_scores, axis=1, ddof=1)
silhouette_z = (silhouette_scores_avg - null_silhouette_scores_avg) / silhouette_std
calinski_harabasz_scores_avg = np.nanmean(calinski_harabasz_scores, axis=1)
null_calinski_harabasz_scores_avg = np.nanmean(
null_calinski_harabasz_scores, axis=1
)
if use_pooled_std:
calinski_harabasz_std = pooled_std(
calinski_harabasz_scores, null_calinski_harabasz_scores
)
else:
calinski_harabasz_std = np.nanstd(null_calinski_harabasz_scores, axis=1, ddof=1)
calinski_harabasz_z = (
calinski_harabasz_scores_avg - null_calinski_harabasz_scores_avg
) / calinski_harabasz_std
return silhouette_z, calinski_harabasz_z
def compute_hamming_distance_hc(cluster_labels, linkage_method, max_clusters):
hamming_distance_cluster_labels = np.empty(
(max_clusters - 1, cluster_labels.shape[1])
)
for k in range(2, max_clusters + 1):
hamming_distance = compute_hamming_with_nan(
cluster_labels=cluster_labels[k - 2]
)
condensed_distance = squareform(hamming_distance, checks=False)
linkage_matrix = linkage(condensed_distance, method=linkage_method)
hamming_distance_cluster_labels[k - 2] = fcluster(
linkage_matrix, t=k, criterion="maxclust"
)
return hamming_distance_cluster_labels
def compute_hamming_with_nan(cluster_labels):
# Precompute valid masks
valid_masks = ~np.isnan(cluster_labels)
# Initialize matrix for results
n = cluster_labels.shape[0]
hamming_matrix = np.full((n, n), np.nan)
# Iterate through pairs using broadcasting
for i in range(n):
valid_i = valid_masks[i]
for j in range(i + 1, n):
valid_j = valid_masks[j]
valid_mask = valid_i & valid_j
total_valid = np.sum(valid_mask)
if total_valid > 0:
mismatches = np.sum(
cluster_labels[i, valid_mask] != cluster_labels[j, valid_mask]
)
hamming_matrix[i, j] = mismatches / total_valid
hamming_matrix[j, i] = hamming_matrix[i, j]
else:
print(i, j)
np.fill_diagonal(hamming_matrix, 0)
return hamming_matrix
def save_hc_labels(
project_path,
exp_df,
meta_name,
cluster_labels,
correlation_type,
linkage_method,
max_clusters,
):
# Generate dynamic header from k=2 to k=max_clusters
header = ["Experiment"] + [f"k={k}" for k in range(2, max_clusters + 1)]
# Create DataFrame
cluster_labels_df = pd.DataFrame(
np.column_stack([exp_df.Articles.values, cluster_labels.T]), columns=header
)
# Save as CSV
cluster_labels_df.to_csv(
project_path
/ f"Results/MA_Clustering/labels/{meta_name}_cluster_labels_{correlation_type}_hc_{linkage_method}.csv",
index=False,
header=header,
)
def save_hc_metrics(
project_path,
meta_name,
silhouette_scores,
null_silhouette_scores,
silhouette_scores_z,
calinski_harabasz_scores,
null_calinski_harabasz_scores,
calinski_harabasz_scores_z,
exp_separation_density,
correlation_type,
linkage_method,
):
max_k = len(silhouette_scores) + 1
metrics_df = pd.DataFrame(
{
"Number of Clusters": range(2, max_k + 1),
"Silhouette Scores": np.nanmean(silhouette_scores, axis=1),
"Silhouette Scores SD": np.nanstd(silhouette_scores, axis=1),
"Silhouette Score Null": np.nanmean(null_silhouette_scores, axis=1),
"Silhouette Scores Null SD": np.nanstd(null_silhouette_scores, axis=1),
"Silhouette Scores Z": silhouette_scores_z,
"Calinski-Harabasz Scores": np.nanmean(calinski_harabasz_scores, axis=1),
"Calinski-Harabasz Scores Null": np.nanmean(null_calinski_harabasz_scores, axis=1),
"Calinski-Harabasz Scoress Null SD": np.nanstd(null_calinski_harabasz_scores, axis=1),
"Calinski-Harabasz Scores SD": np.nanstd(calinski_harabasz_scores, axis=1),
"Calinski-Harabasz Scores Z": calinski_harabasz_scores_z,
# Pad with NaN for k=2 as metrics start at k=3
"Experiment Separation Density": np.concatenate(
([np.nan], np.nanmean(exp_separation_density, axis=1))
),
}
)
metrics_df.to_csv(
project_path
/ f"Results/MA_Clustering/{meta_name}_clustering_metrics_{correlation_type}_hc_{linkage_method}.csv",
index=False,
)
pd.DataFrame(silhouette_scores.T).to_csv(
project_path
/ f"Results/MA_Clustering/metrics/{meta_name}_silhouette_scores_{correlation_type}_hc_{linkage_method}.csv",
index=False,
header=[f"k={k}" for k in range(2, max_k + 1)],
)
pd.DataFrame(null_silhouette_scores.T).to_csv(
project_path
/ f"Results/MA_Clustering/metrics/{meta_name}_Null_silhouette_scores_{correlation_type}_hc_{linkage_method}.csv",
index=False,
header=[f"k={k}" for k in range(2, max_k + 1)],
)
pd.DataFrame(calinski_harabasz_scores.T).to_csv(
project_path
/ f"Results/MA_Clustering/metrics/{meta_name}_calinski_harabasz_scores_{correlation_type}_hc_{linkage_method}.csv",
index=False,
header=[f"k={k}" for k in range(2, max_k + 1)],
)
pd.DataFrame(null_calinski_harabasz_scores.T).to_csv(
project_path
/ f"Results/MA_Clustering/metrics/{meta_name}_Null_calinski_harabasz_scores_{correlation_type}_hc_{linkage_method}.csv",
index=False,
header=[f"k={k}" for k in range(2, max_k + 1)],
)
pd.DataFrame(exp_separation_density.T).to_csv(
project_path
/ f"Results/MA_Clustering/metrics/{meta_name}_exp_separation_density_{correlation_type}_hc_{linkage_method}.csv",
index=False,
header=[f"k={k}" for k in range(3, max_k + 1)],
)
def plot_hc_metrics(
project_path,
meta_name,
silhouette_scores,
null_silhouette_scores,
silhouette_scores_z,
calinski_harabasz_scores,
null_calinski_harabasz_scores,
calinski_harabasz_scores_z,
exp_separation_density,
correlation_type,
linkage_method,
max_clusters,
):
k_range = np.array(list(range(2, max_clusters + 1)))
# Calculate average scores, handling NaNs
avg_silhouette_scores = np.nanmean(silhouette_scores, axis=1)
avg_calinski_harabasz_scores = np.nanmean(calinski_harabasz_scores, axis=1)
# Set up Matplotlib figure and subplots
# Increased figsize for better readability and spacing
fig, axes = plt.subplots(
4, 1, figsize=(10, 18), sharex=True
) # Share x-axis for better comparison
# Apply a nicer Seaborn style
sns.set_style("whitegrid")
sns.set_palette("viridis") # A visually appealing color palette
# --- Plot 1: Average Silhouette Scores ---
ax = axes[0]
ax.plot(
k_range,
avg_silhouette_scores,
marker="o",
linestyle="-",
linewidth=2,
markersize=8,
color=sns.color_palette("viridis")[0],
)
ax.set_title("Average Silhouette Scores", fontsize=14, fontweight="bold")
ax.set_ylabel("Average Score", fontsize=12)
ax.grid(True, linestyle="--", alpha=0.6)
# Add minor ticks for better readability
ax.minorticks_on()
ax.grid(which="minor", linestyle=":", linewidth="0.5", color="gray", alpha=0.3)
ax.tick_params(axis="both", which="major", labelsize=10)
# --- Plot 2: Silhouette Scores Z ---
ax = axes[1]
ax.plot(
k_range,
silhouette_scores_z,
marker="o",
linestyle="-",
linewidth=2,
markersize=8,
color=sns.color_palette("viridis")[1],
)
ax.set_title("Silhouette Scores Z-scores", fontsize=14, fontweight="bold")
ax.set_ylabel("Z-Score", fontsize=12)
ax.grid(True, linestyle="--", alpha=0.6)
# Add a horizontal line for common significance threshold (e.g., Z=1.96 for p<0.05, two-tailed)
ax.axhline(0, color="gray", linestyle="-", linewidth=0.8, alpha=0.7) # Line at Z=0
ax.axhline(
1.96,
color="darkgreen",
linestyle=":",
linewidth=1.5,
label="Z=1.96 (p<0.05)",
alpha=0.7,
)
ax.axhline(-1.96, color="darkgreen", linestyle=":", linewidth=1.5, alpha=0.7)
ax.legend(loc="best", fontsize=10)
ax.minorticks_on()
ax.grid(which="minor", linestyle=":", linewidth="0.5", color="gray", alpha=0.3)
ax.tick_params(axis="both", which="major", labelsize=10)
# --- Plot 3: Average Calinski-Harabasz Scores ---
ax = axes[2]
ax.plot(
k_range,
avg_calinski_harabasz_scores,
marker="o",
linestyle="-",
linewidth=2,
markersize=8,
color=sns.color_palette("viridis")[2],
)
ax.set_title("Average Calinski-Harabasz Scores", fontsize=14, fontweight="bold")
ax.set_ylabel("Average Score", fontsize=12)
ax.grid(True, linestyle="--", alpha=0.6)
ax.minorticks_on()
ax.grid(which="minor", linestyle=":", linewidth="0.5", color="gray", alpha=0.3)
ax.tick_params(axis="both", which="major", labelsize=10)
# --- Plot 4: Calinski-Harabasz Scores Z ---
ax = axes[3]
ax.plot(
k_range,
calinski_harabasz_scores_z,
marker="o",
linestyle="-",
linewidth=2,
markersize=8,
color=sns.color_palette("viridis")[3],
)
ax.set_title("Calinski-Harabasz Scores Z-scores", fontsize=14, fontweight="bold")
ax.set_xlabel("Number of Clusters (k)", fontsize=12)
ax.set_ylabel("Z-Score", fontsize=12)
ax.grid(True, linestyle="--", alpha=0.6)
# Add a horizontal line for common significance threshold
ax.axhline(0, color="gray", linestyle="-", linewidth=0.8, alpha=0.7) # Line at Z=0
ax.axhline(
1.96,
color="darkgreen",
linestyle=":",
linewidth=1.5,
label="Z=1.96 (p<0.05)",
alpha=0.7,
)
ax.axhline(
-1.96, color="darkgreen", linestyle=":", linewidth=1.5, alpha=0.7
) # Only if negative Z-scores are meaningful
ax.legend(loc="best", fontsize=10)
ax.minorticks_on()
ax.grid(which="minor", linestyle=":", linewidth="0.5", color="gray", alpha=0.3)
ax.tick_params(axis="both", which="major", labelsize=10)
# Set common x-axis ticks and limits
# Ensure x-axis ticks are integers for number of clusters
axes[-1].set_xticks(k_range)
axes[-1].set_xlim(
min(k_range) - 0.5, max(k_range) + 0.5
) # Add a small buffer around min/max k
plt.suptitle(
f"Clustering Metrics for {meta_name} ({correlation_type} correlation, {linkage_method} linkage)",
y=0.99,
fontsize=16,
fontweight="bold",
) # Overall title for the figure
plt.tight_layout(
rect=[0, 0.03, 1, 0.96]
) # Adjust layout to make space for suptitle
plt.savefig(
project_path
/ f"Results/MA_Clustering/{meta_name}_clustering_metrics_{correlation_type}_hc_{linkage_method}.png"
)
plt.close()
# --- Distribution Plots ---
num_k = len(k_range)
# --- Calculate Global X-axis Limits for Silhouette Scores ---
all_silhouette_scores = np.concatenate(
(silhouette_scores.flatten(), null_silhouette_scores.flatten())
)
all_silhouette_scores_cleaned = all_silhouette_scores[
~np.isnan(all_silhouette_scores)
]
if all_silhouette_scores_cleaned.size > 0:
min_sil_x = np.nanmin(all_silhouette_scores_cleaned)
max_sil_x = np.nanmax(all_silhouette_scores_cleaned)
# Add a small buffer to the limits
sil_x_buffer = (max_sil_x - min_sil_x) * 0.05
fixed_sil_xlim = [min_sil_x - sil_x_buffer, max_sil_x + sil_x_buffer]
else:
# Default limits if no valid data
fixed_sil_xlim = [-0.1, 1.1] # Silhouette scores are typically -1 to 1
# --- Calculate Global X-axis Limits for Calinski-Harabasz Scores ---
all_ch_scores = np.concatenate(
(calinski_harabasz_scores.flatten(), null_calinski_harabasz_scores.flatten())
)
all_ch_scores_cleaned = all_ch_scores[~np.isnan(all_ch_scores)]
if all_ch_scores_cleaned.size > 0:
min_ch_x = np.nanmin(all_ch_scores_cleaned)
max_ch_x = np.nanmax(all_ch_scores_cleaned)
# Add a small buffer to the limits, ensure positive range for CH which is non-negative
ch_x_buffer = (max_ch_x - min_ch_x) * 0.05
fixed_ch_xlim = [max(0, min_ch_x - ch_x_buffer), max_ch_x + ch_x_buffer]
else:
# Default limits if no valid data
fixed_ch_xlim = [
0,
1000,
] # Calinski-Harabasz can vary widely, this is a placeholder
# Determine figure size dynamically based on the number of k values
fig_height = max(6, num_k * 3) # Minimum height of 6, 3 inches per row
fig, axes = plt.subplots(
num_k, 2, figsize=(14, fig_height), squeeze=False
) # 2 columns for Silhouette/CH
for i, k in enumerate(k_range):
# --- Silhouette Score Plots ---
ax_sil = axes[i, 0]
# Filter out NaN values for plotting KDEs
observed_sil = silhouette_scores[k - 2, :]
null_sil = null_silhouette_scores[k - 2, :]
observed_sil_cleaned = observed_sil[~np.isnan(observed_sil)]
null_sil_cleaned = null_sil[~np.isnan(null_sil)]
if observed_sil_cleaned.size > 1: # KDE requires at least 2 data points
sns.kdeplot(
observed_sil_cleaned,
fill=True,
color="red",
ax=ax_sil,
label="Observed",
)
else:
ax_sil.text(
0.5,
0.5,
"Not enough observed data",
horizontalalignment="center",
verticalalignment="center",
transform=ax_sil.transAxes,
color="gray",
)
if null_sil_cleaned.size > 1:
sns.kdeplot(
null_sil_cleaned, fill=True, color="skyblue", ax=ax_sil, label="Null"
)
else:
ax_sil.text(
0.5,
0.3,
"Not enough null data",
horizontalalignment="center",
verticalalignment="center",
transform=ax_sil.transAxes,
color="gray",
)
ax_sil.set_title(f"k={k} Silhouette Scores")
ax_sil.set_ylabel("Density")
ax_sil.grid(True, linestyle="--", alpha=0.7)
ax_sil.legend()
ax_sil.set_xlim(fixed_sil_xlim) # Apply fixed x-axis limits
# --- Calinski-Harabasz Score Plots ---
ax_ch = axes[i, 1]
# Filter out NaN values for plotting KDEs
observed_ch = calinski_harabasz_scores[k - 2, :]
null_ch = null_calinski_harabasz_scores[k - 2, :]
observed_ch_cleaned = observed_ch[~np.isnan(observed_ch)]
null_ch_cleaned = null_ch[~np.isnan(null_ch)]
if observed_ch_cleaned.size > 1:
sns.kdeplot(
observed_ch_cleaned, fill=True, color="red", ax=ax_ch, label="Observed"
)
else:
ax_ch.text(
0.5,
0.5,
"Not enough observed data",
horizontalalignment="center",
verticalalignment="center",
transform=ax_ch.transAxes,
color="gray",
)
if null_ch_cleaned.size > 1:
sns.kdeplot(
null_ch_cleaned, fill=True, color="skyblue", ax=ax_ch, label="Null"
)
else:
ax_ch.text(
0.5,
0.3,
"Not enough null data",
horizontalalignment="center",
verticalalignment="center",
transform=ax_ch.transAxes,
color="gray",
)
ax_ch.set_title(f"k={k} Calinski-Harabasz Scores")
# Only set x-label on the bottom row
if i == num_k - 1:
ax_sil.set_xlabel("Score Value")
ax_ch.set_xlabel("Score Value")
ax_ch.set_ylabel("Density")
ax_ch.grid(True, linestyle="--", alpha=0.7)
ax_ch.legend()
ax_ch.set_xlim(fixed_ch_xlim) # Apply fixed x-axis limits
plt.tight_layout()
plt.close
plt.savefig(
project_path
/ f"Results/MA_Clustering/{meta_name}_clustering_metrics_{correlation_type}_hc_{linkage_method}_distributions.png"
)
# --- Laird/Riedel Metrics Plot ---
fig, ax = plt.subplots(figsize=(12, 6))
k_range_special = range(3, max_clusters + 1)
color = "tab:red"
ax.set_xlabel("Number of Clusters Transitioning To")
ax.set_ylabel("Experiment Separation Density", color=color)
ax.plot(
k_range_special,
np.nanmean(exp_separation_density, axis=1),
"s-",
color=color,
)
ax.tick_params(axis="y", labelcolor=color)
ax.grid(True, axis="x")
plt.title("Experiment Separation Density")
fig.tight_layout()
plt.savefig(
project_path
/ f"Results/MA_Clustering/{meta_name}_exp_separation_density_{correlation_type}_hc_{linkage_method}_laird_riedel.png"
)
plt.close()
def plot_sorted_dendrogram(
project_path,
meta_name,
correlation_type,
correlation_matrix,
linkage_method,
max_clusters,
):
"""
Creates a dendrogram with optimal leaf ordering for better interpretability.
Parameters:
linkage_matrix (ndarray): The linkage matrix from hierarchical clustering.
data (ndarray): Original data used to compute the distance matrix.
Returns:
dict: The dendrogram structure.
"""
# Apply optimal leaf ordering to the linkage matrix
distance_matrix = 1 - correlation_matrix
condensed_distance = squareform(distance_matrix, checks=False)
# Perform hierarchical clustering
linkage_matrix = linkage(condensed_distance, method=linkage_method)
ordered_linkage_matrix = optimal_leaf_ordering(linkage_matrix, condensed_distance)
for k in range(2, max_clusters + 1):
# Plot the dendrogram
plt.figure(figsize=(10, 6))
dendrogram(
ordered_linkage_matrix,
leaf_rotation=90,
leaf_font_size=10,
color_threshold=linkage_matrix[-(k - 1), 2], # Highlight k-clusters
)
plt.title("Optimal Leaf Ordered Dendrogram")
plt.xlabel("Experiments")
plt.ylabel("Distance")
plt.xticks([])
plt.savefig(
project_path
/ f"Results/MA_Clustering/dendograms/{meta_name}_dendogram_{correlation_type}_hc_{linkage_method}_{k}.png",
)
plt.close() # Close figure to avoid displaying it in notebooks