2  Cluster evaluation in R and Python

This chapter uses three fixed expression datasets so that R and Python analyze the same observations.

The examples use cluster, ConsensusClusterPlus, fpc, and aricode in R. Python uses scikit learn for clustering and agreement, Yellowbrick for silhouette profiles, gapstatistics for Gap, consensusclustering for consensus matrices and CDF summaries, and skstab for stability.

2.1 Load and represent the discrete populations

The true population column is removed before preprocessing. We standardize genes and retain ten PCs. Ten dimensions preserve the simulated marker programs while avoiding a distance calculation dominated by 150 noisy coordinates.

library(cluster)
library(ConsensusClusterPlus)
library(fpc)
library(aricode)

set.seed(20261002)
cells <- read.csv("data/simulated_discrete_expression.csv")
truth <- cells$true_population
expression <- as.matrix(cells[grepl("^gene_", names(cells))])
pcs <- prcomp(expression, center = TRUE, scale. = TRUE)$x[, 1:10]
dim(pcs)
[1] 300  10
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from sklearn.cluster import KMeans, AgglomerativeClustering
from sklearn.decomposition import PCA
from sklearn.metrics import (
    silhouette_score, silhouette_samples,
    adjusted_rand_score, normalized_mutual_info_score,
    adjusted_mutual_info_score
)
from sklearn.preprocessing import StandardScaler

SEED = 20261002
cells = pd.read_csv("data/simulated_discrete_expression.csv")
truth = cells["true_population"].to_numpy()  # kept aside
expression = cells.filter(regex=r"^gene_").to_numpy()
pcs = PCA(n_components=10, random_state=SEED).fit_transform(
    StandardScaler().fit_transform(expression)
)
print(pcs.shape)
(300, 10)

In an actual analysis, PC selection should use variance, technical diagnostics, and biological knowledge. Choosing the PCs by maximizing agreement with withheld labels would leak external information into model selection.

2.2 Silhouette analysis

Fit candidate values from 2 through 8 and record the mean silhouette.

candidate_k <- 2:8
fits <- lapply(candidate_k, function(k) {
  set.seed(20261002 + k)
  kmeans(pcs, centers = k, nstart = 30)$cluster
})

mean_silhouette <- vapply(
  fits,
  function(labels) mean(silhouette(labels, dist(pcs))[, "sil_width"]),
  numeric(1)
)
round(mean_silhouette, 3)
plot(candidate_k, mean_silhouette, type = "b",
     xlab = "Candidate K", ylab = "Mean silhouette")
    2     3     4     5     6     7     8 
0.196 0.261 0.209 0.174 0.150 0.143 0.144
candidate_k = range(2, 9)
fits = {
    k: KMeans(n_clusters=k, n_init=30, random_state=SEED + k).fit_predict(pcs)
    for k in candidate_k
}
mean_silhouette = {
    k: silhouette_score(pcs, labels) for k, labels in fits.items()
}
print({k: round(v, 3) for k, v in mean_silhouette.items()})
plt.plot(list(candidate_k), list(mean_silhouette.values()), marker="o")
plt.xlabel("Candidate K")
plt.ylabel("Mean silhouette")
plt.show()
{2: 0.196, 3: 0.261, 4: 0.209, 5: 0.171, 6: 0.150, 7: 0.148, 8: 0.154}
A line plot compares mean silhouette across K 2 through 8.
Figure 2.1: Mean silhouette for the simulated discrete populations.

The full distribution is needed for any candidate taken forward.

par(mfrow = c(1, 3))
for (k in c(2, 3, 6)) {
  labels <- fits[[which(candidate_k == k)]]
  plot(silhouette(labels, dist(pcs)),
       main = paste("K =", k), col = 2:(k + 1), border = NA)
}

# Identify observations and assigned clusters with negative values at K = 3.
sil3 <- silhouette(fits[[2]], dist(pcs))
negative <- sil3[sil3[, "sil_width"] < 0, c("cluster", "neighbor", "sil_width")]
head(negative[order(negative[, "sil_width"]), ])
nrow(negative)
     cluster neighbor sil_width
[1] 0
from yellowbrick.cluster import SilhouetteVisualizer

fig, axes = plt.subplots(1, 3, figsize=(12, 4))
for ax, k in zip(axes, [2, 3, 6]):
    visualizer = SilhouetteVisualizer(
        KMeans(n_clusters=k, n_init=30, random_state=SEED + k),
        ax=ax,
        force_model=True,
        colors=["#3B82F6", "#F59E0B", "#10B981", "#8B5CF6", "#EC4899", "#14B8A6"]
    )
    visualizer.fit(pcs)
    visualizer.finalize()
    ax.get_legend().remove()
    ax.set_title(f"K = {k}")
    ax.set(xlabel="Silhouette value", ylabel="Cluster", xlim=(-0.25, 0.9))
    ax.set_xticks(np.arange(-0.2, 1.0, 0.2))
    print(k, round(visualizer.silhouette_score_, 3))
plt.show()

values3 = silhouette_samples(pcs, fits[3])
negative = pd.DataFrame({
    "sample_id": cells.loc[values3 < 0, "sample_id"],
    "cluster": fits[3][values3 < 0],
    "silhouette": values3[values3 < 0]
}).sort_values("silhouette")
print("negative silhouettes:", len(negative))
print(negative.head())
2 0.196
3 0.261
6 0.150

negative silhouettes: 0
Empty DataFrame
Columns: [sample_id, cluster, silhouette]
Index: []
Full silhouette profiles show all sample values for K 2, K 3, and K 6.
Figure 2.2: Complete silhouette profiles at three resolutions.

Both implementations favour \(K=3\). No observation has a negative silhouette at this resolution. The small differences at larger values of \(K\) come from implementation details in the two K means routines.

Yellowbrick 1.5 needs force_model=True with the current scikit learn estimator tags. The explicit color list avoids a removed Matplotlib colormap lookup. These compatibility settings affect the visualizer only. They do not change the silhouette values.

For millions of cells, an exact pairwise calculation can be impractical. silhouette_score accepts sample_size for a reproducible approximation. Report when an approximation or subset was used.

2.3 Gap statistic

R’s cluster::clusGap implements the classical procedure. spaceH0 = "scaledPCA" samples the reference uniformly inside a PCA aligned bounding box. maxSE(..., method = "Tibs2001SEmax") implements the original one standard error rule.

set.seed(20261002)
gap_r <- clusGap(
  pcs,
  FUNcluster = function(x, k) list(cluster = kmeans(x, k, nstart = 30)$cluster),
  K.max = 8,
  B = 100,
  spaceH0 = "scaledPCA"
)
gap_r$Tab
selected_k <- maxSE(gap_r$Tab[, "gap"], gap_r$Tab[, "SE.sim"],
                    method = "Tibs2001SEmax")
selected_k
plot(gap_r, main = "Gap statistic")
       gap SE.sim
[1,] 0.412  0.011
[2,] 0.449  0.011
[3,] 0.512  0.010
[4,] 0.507  0.010
[5,] 0.510  0.010
[6,] 0.515  0.010
[7,] 0.513  0.010
[8,] 0.513  0.010
[1] 3
from gapstatistics.gapstatistics import GapStatistics

np.random.seed(SEED)
gap_py = GapStatistics(pca_sampling=True, return_params=True)
selected_k, gap_params = gap_py.fit_predict(K=8, X=pcs, n_iterations=100)
gap_table = pd.DataFrame({
    "K": range(1, 9),
    "Gap": gap_params["gaps"],
    "SE": gap_params["sim_sks"]
})
print(gap_table.round(3).to_string(index=False))
print("one-SE choice:", selected_k)
gap_py.plot()
 K   Gap    SE
 1 0.591 0.009
 2 0.662 0.009
 3 0.749 0.009
 4 0.753 0.010
 5 0.761 0.010
 6 0.769 0.010
 7 0.768 0.010
 8 0.769 0.010
one-SE choice: 3

Both packages select \(K=3\) even though their Gap values differ. cluster::clusGap uses squared Euclidean within cluster dispersion. gapstatistics sums distances from observations to their assigned centroids. It also supplies its own PCA based reference sampling. The package name and its dispersion definition should accompany the reported result.

Version 0.1.5 of gapstatistics does not export GapStatistics from the package top level, so the working import uses gapstatistics.gapstatistics.

The package applies another SVD to the supplied representation and samples between one global minimum and maximum. Its Gap magnitudes can change after a numerically equivalent change in component orientation. Keep the PCA call, package version, and random seed fixed when reproducing the result.

2.4 Consensus clustering and PAC

ConsensusClusterPlus expects features in rows and items in columns. pItem = 0.8 samples 80 percent of cells per repetition. pFeature = 1 holds the representation fixed so this analysis concerns sample perturbation.

set.seed(20261002)
cc <- ConsensusClusterPlus(
  t(pcs), maxK = 8, reps = 250,
  pItem = 0.8, pFeature = 1,
  clusterAlg = "km", distance = "euclidean",
  seed = 20261002, plot = NULL, verbose = FALSE
)

pac_r <- sapply(2:8, function(k) {
  values <- cc[[k]]$consensusMatrix[upper.tri(cc[[k]]$consensusMatrix)]
  mean(values > 0.1 & values < 0.9, na.rm = TRUE)
})
round(pac_r, 3)
plot(2:8, pac_r, type = "b", xlab = "K", ylab = "PAC (0.1, 0.9)")
    2     3     4     5     6     7     8 
0.667 0.000 0.212 0.251 0.285 0.299 0.275
from consensusclustering import ConsensusClustering

cc_py = ConsensusClustering(
    KMeans(n_init=20, random_state=SEED),
    min_clusters=2,
    max_clusters=8,
    n_resamples=100,
    resample_frac=0.8,
    rng=SEED
)
cc_py.fit(pcs)

auc_py = {k: cc_py.area_under_cdf(k) for k in cc_py.cluster_range_}
pac_py = {}
for k in cc_py.cluster_range_:
    matrix = cc_py.consensus_k(k)
    values = matrix[np.triu_indices_from(matrix, 1)]
    pac_py[k] = np.mean((values > 0.1) & (values < 0.9))

print("CDF area", {k: round(v, 3) for k, v in auc_py.items()})
print("PAC", {k: round(v, 3) for k, v in pac_py.items()})
print("CDF knee:", cc_py.best_k(method="knee"))
cc_py.plot_cdf()
cc_py.plot_clustermap(3, cmap="Blues", vmin=0, vmax=1)
CDF area {2: 0.504, 3: 0.700, 4: 0.747, 5: 0.800, 6: 0.849, 7: 0.866, 8: 0.880}
PAC {2: 0.446, 3: 0.000, 4: 0.128, 5: 0.200, 6: 0.156, 7: 0.166, 8: 0.167}
CDF knee: 3

The numerator counts joint appearances in the same cluster. The denominator counts joint appearances. The interval (0.1, 0.9) is stated explicitly and should not be presented as a universal biological threshold.

Three annotated consensus heatmaps compare merged structure at K 2, clean blocks at K 3, and fragmented structure at K 5.
Figure 2.3: Consensus matrices locate the pairs responsible for instability.

Both packages give PAC equal to zero at \(K=3\). The larger values at \(K=2\) show that merging two simulated populations creates uncertain pair assignments. The Python package also locates its CDF knee at \(K=3\). Values differ because the packages initialize and repeat K means differently. The heatmap locates ambiguous items while PAC reports their overall fraction.

2.5 Consensus on null data

Repeat the same workflow on the null matrix.

null_data <- read.csv("data/simulated_null_expression.csv")
null_expression <- as.matrix(null_data[grepl("^gene_", names(null_data))])
null_pcs <- prcomp(null_expression, center = TRUE, scale. = TRUE)$x[, 1:10]

set.seed(81)
null_cc <- ConsensusClusterPlus(
  t(null_pcs), maxK = 5, reps = 250, pItem = 0.8, pFeature = 1,
  clusterAlg = "km", distance = "euclidean",
  seed = 81, plot = NULL, verbose = FALSE
)
null_values <- null_cc[[3]]$consensusMatrix[
  upper.tri(null_cc[[3]]$consensusMatrix)
]
mean(null_values > 0.1 & null_values < 0.9, na.rm = TRUE)
[1] 0.026
null_data = pd.read_csv("data/simulated_null_expression.csv")
null_expression = null_data.filter(regex=r"^gene_").to_numpy()
null_pcs = PCA(10, random_state=SEED).fit_transform(
    StandardScaler().fit_transform(null_expression)
)
null_cc_py = ConsensusClustering(
    KMeans(n_init=20, random_state=81),
    min_clusters=3,
    max_clusters=3,
    n_resamples=100,
    resample_frac=0.8,
    rng=81
)
null_cc_py.fit(null_pcs)
null_matrix = null_cc_py.consensus_k(3)
null_values = null_matrix[np.triu_indices_from(null_matrix, 1)]
null_pac = np.mean((null_values > 0.1) & (null_values < 0.9))
print(round(null_pac, 3))
0.021
A unimodal PCA cloud partitioned into three is shown beside its ordered consensus matrix.
Figure 2.4: Consensus structure appears even though the data were generated without clusters.

Both PAC values are low even though these data were generated from one smooth distribution. The algorithm repeatedly finds a similar forced partition. Stability therefore does not test whether discrete populations exist.

2.6 Stability

In R, clusterboot reclusters bootstrap samples and matches individual clusters by Jaccard similarity. In Python, skstab.ModelExplorer draws pairs of subsamples and compares the two complete partitions with the Fowlkes Mallows score. Its reported value is the proportion of runs whose similarity exceeds 0.9. The two packages answer related but different stability questions.

set.seed(20261002)
cb <- clusterboot(
  pcs, B = 200, bootmethod = "boot",
  clustermethod = kmeansCBI, krange = 4,
  seed = 20261002,
  count = FALSE,
  dissolution = 0.5, recover = 0.75,
  runs = 30
)
cb$bootmean
cb$bootbrd
cb$bootrecover
round(cb$bootmean, 3)
barplot(cb$bootmean, ylim = c(0, 1),
        xlab = "Original cluster", ylab = "Mean Jaccard")
abline(h = 0.75, lty = 2)
Mean Jaccard
[1] 0.686 0.826 0.904 0.664
Dissolved in 200 runs
[1] 35  3  5 89
Recovered in 200 runs
[1]  79 125 160  81
from skstab import ModelExplorer

np.random.seed(SEED)
stability_py = ModelExplorer(
    pcs,
    KMeans,
    param_name="n_clusters",
    param_values=list(range(2, 9)),
    f=0.8,
    runs=50,
    algo_kwargs={"n_init": 20, "random_state": SEED},
    n_jobs=1
)
scores = stability_py.score().squeeze()
print(dict(zip(range(2, 9), np.round(scores, 3))))
plt.plot(range(2, 9), scores, marker="o")
plt.xlabel("K")
plt.ylabel("Proportion with similarity above 0.9")
plt.show()
{2: 0.56, 3: 1.00, 4: 0.28, 5: 0.36, 6: 0.02, 7: 0.02, 8: 0.00}

The R output shows that the four clusters at \(K=4\) do not have equal stability. The Python output compares candidate partitions and gives \(K=3\) a score of 1.00. A score of 1.00 here means that every evaluated pair of subsample fits exceeded the package threshold. It does not mean that every sample has a clusterwise Jaccard value of 1.

skstab returns singleton dimensions around this score vector. squeeze() removes those dimensions for display and does not alter the values. The current select_param() method is not used because it fails on that returned shape with the installed NumPy version.

2.7 ARI, NMI, and AMI

Only now reveal the simulated populations. Compare K means, Ward hierarchical clustering, and the withheld labels.

kmeans3 <- fits[[which(candidate_k == 3)]]
ward3 <- cutree(hclust(dist(pcs), method = "ward.D2"), k = 3)

data.frame(
  comparison = c("K-means vs truth", "Ward vs truth", "K-means vs Ward"),
  ARI = c(ARI(kmeans3, truth), ARI(ward3, truth), ARI(kmeans3, ward3)),
  NMI = c(NMI(kmeans3, truth, variant = "sum"),
          NMI(ward3, truth, variant = "sum"),
          NMI(kmeans3, ward3, variant = "sum")),
  AMI = c(AMI(kmeans3, truth), AMI(ward3, truth), AMI(kmeans3, ward3))
)
          comparison ARI NMI AMI
    K means vs truth   1   1   1
       Ward vs truth   1   1   1
     K means vs Ward   1   1   1
kmeans3 = fits[3]
ward3 = AgglomerativeClustering(n_clusters=3, linkage="ward").fit_predict(pcs)

for name, left, right in [
    ("K means vs truth", kmeans3, truth),
    ("Ward vs truth", ward3, truth),
    ("K means vs Ward", kmeans3, ward3)
]:
    print(name, {
        "ARI": adjusted_rand_score(left, right),
        "NMI": normalized_mutual_info_score(left, right, average_method="arithmetic"),
        "AMI": adjusted_mutual_info_score(left, right, average_method="arithmetic")
    })
K means vs truth {'ARI': 1.0, 'NMI': 1.0, 'AMI': 1.0}
Ward vs truth {'ARI': 1.0, 'NMI': 1.0, 'AMI': 1.0}
K means vs Ward {'ARI': 1.0, 'NMI': 1.0, 'AMI': 1.0}

The perfect values are a property of this deliberately clear simulation. The withheld population labels exactly match both three cluster fits. This example checks the calculation but should not be treated as a realistic expected result. The arithmetic normalizer is written explicitly in Python because NMI and AMI support several conventions. In aricode, the NMI call uses arithmetic normalization while AMI uses maximum entropy normalization. AMI and ARI are chance corrected. NMI is not.

2.7.1 Splits and merges

A <- as.integer(factor(c("A", "A", "A", "B", "B", "B", "C", "C")))
B_split <- c(1, 1, 1, 2, 3, 4, 5, 5)
C_merge <- c(1, 1, 1, 2, 2, 2, 2, 2)

table(A, B_split)
c(ARI = ARI(A, B_split), AMI = AMI(A, B_split))
table(A, C_merge)
c(ARI = ARI(A, C_merge), AMI = AMI(A, C_merge))
Split table
   B_split
A   1 2 3 4 5
  1 3 0 0 0 0
  2 0 1 1 1 0
  3 0 0 0 0 2
  ARI   AMI 
0.667 0.502

Merge table
   C_merge
A   1 2
  1 3 0
  2 0 3
  3 0 2
  ARI   AMI 
0.556 0.531
A = np.array(list("AAABBBCC"))
B_split = np.array([1, 1, 1, 2, 3, 4, 5, 5])
C_merge = np.array([1, 1, 1, 2, 2, 2, 2, 2])

print(pd.crosstab(A, B_split))
print({
    "ARI": adjusted_rand_score(A, B_split),
    "NMI": normalized_mutual_info_score(A, B_split),
    "AMI": adjusted_mutual_info_score(A, B_split)
})
print(pd.crosstab(A, C_merge))
print({
    "ARI": adjusted_rand_score(A, C_merge),
    "NMI": normalized_mutual_info_score(A, C_merge),
    "AMI": adjusted_mutual_info_score(A, C_merge)
})
Split table
col_0  1  2  3  4  5
row_0
A      3  0  0  0  0
B      0  1  1  1  0
C      0  0  0  0  2
{'ARI': 0.667, 'NMI': 0.840, 'AMI': 0.669}

Merge table
col_0  1  2
row_0
A      3  0
B      0  3
C      0  2
{'ARI': 0.556, 'NMI': 0.759, 'AMI': 0.694}

The tables show which original group was split or merged. The scores summarize the whole partition and cannot identify that source by themselves.

2.8 Representation sensitivity

Repeat the silhouette sweep in the standardized 150 gene matrix, the first two PCs, and the first ten PCs.

Three silhouette curves across K differ for standardized raw expression, two PCs, and ten PCs.
Figure 2.5: Mean silhouette changes across raw expression and two PCA representations.

The same validation function in Figure 2.5 evaluates three different geometries. PCA is not guaranteed to be best. The point is to report where distances were calculated and to keep that representation connected to the clustering procedure.

2.9 Parameters other than K

The same workflow can evaluate a resolution parameter. For Leiden, fit a short range of resolution values and record the number of returned clusters, mean silhouette in the chosen representation, and a stability summary. This does not turn resolution into \(K\). Several settings can return the same count with different memberships.

DBSCAN should be evaluated across eps and MinPts, including the proportion labeled as noise. HDBSCAN determines groups through its density hierarchy. For these methods, forcing a \(K\) selection framework would ignore how the algorithm works.

The mathematical chapter now derives every quantity used here.