883 lines
30 KiB
Python
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
|