Skip to content

Commit 3fb1477

Browse files
authored
Merge pull request #1516 from exploratory-io/fix/issue-36106
Add silhouette analysis to K-means clustering
2 parents 9cbdd04 + 0264374 commit 3fb1477

4 files changed

Lines changed: 179 additions & 4 deletions

File tree

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Package: exploratory
22
Type: Package
33
Title: R package for Exploratory
4-
Version: 15.1.12
4+
Version: 15.1.13
55
Date: 2026-05-30
66
Authors@R: c(person("Hideaki", "Hayashi", email = "hideaki@exploratory.io", role = c("aut", "cre")), person("Hide", "Kojima", email = "hide@exploratory.io", role = c("aut")), person("Kan", "Nishida", email = "kan@exploratory.io", role = c("aut")), person("Kei", "Saito", email = "kei@exploratory.io", role = c("aut")), person("Yosuke", "Yasuda", email = "double.y.919.quick@gmail.com", role = c("aut")))
77
URL: https://github.com/exploratory-io/exploratory_func

R/kmeans.R

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ iterate_silhouette <- function(df, max_centers = 10,
4040
algorithm = "Hartigan-Wong",
4141
trace = FALSE,
4242
normalize_data = TRUE,
43-
seed = NULL) {
43+
seed = NULL,
44+
dist = NULL) {
4445
mat <- as_numeric_matrix_(df, columns = colnames(df))
4546
if (normalize_data) {
4647
mat <- scale(mat)
@@ -54,7 +55,7 @@ iterate_silhouette <- function(df, max_centers = 10,
5455
return(data.frame(center = integer(0), avg_silhouette = numeric(0),
5556
min_silhouette = numeric(0), pct_negative = numeric(0)))
5657
}
57-
d <- stats::dist(mat)
58+
d <- if (is.null(dist)) stats::dist(mat) else dist
5859
purrr::map_dfr(seq(2, upper), function(k) {
5960
if (!is.null(seed)) {
6061
set.seed(seed)
@@ -78,6 +79,45 @@ iterate_silhouette <- function(df, max_centers = 10,
7879
})
7980
}
8081

82+
# Compute per-row silhouette widths for an already-built clustering.
83+
#
84+
# cluster_ids: integer cluster assignment vector (length n, aligned to mat rows).
85+
# For K-Means this is x$kmeans$cluster (integer 1..k).
86+
# mat: normalized numeric matrix used for clustering (n rows).
87+
# d: optional precomputed stats::dist(mat) to reuse. Computed when NULL.
88+
#
89+
# Returns a tibble with n rows and columns silhouette_score, nearest_cluster, cluster_width.
90+
# Returns all-NA columns (no error) when silhouette is undefined
91+
# (fewer than 2 clusters, or fewer than 2 distinct points).
92+
compute_silhouette_per_row <- function(cluster_ids, mat, d = NULL) {
93+
n <- length(cluster_ids)
94+
na_result <- tibble::tibble(
95+
silhouette_score = rep(NA_real_, n),
96+
nearest_cluster = rep(NA_integer_, n),
97+
cluster_width = rep(NA_real_, n)
98+
)
99+
ids <- as.integer(cluster_ids)
100+
if (length(unique(ids)) < 2 || nrow(unique(mat)) < 2) {
101+
return(na_result)
102+
}
103+
if (is.null(d)) {
104+
d <- stats::dist(mat)
105+
}
106+
sil <- cluster::silhouette(ids, d)
107+
if (!inherits(sil, "silhouette")) {
108+
# silhouette() can return NA (not a matrix) for degenerate input.
109+
return(na_result)
110+
}
111+
widths <- as.numeric(sil[, "sil_width"])
112+
clusters <- as.integer(sil[, "cluster"])
113+
clus_avg <- tapply(widths, clusters, mean, na.rm = TRUE)
114+
tibble::tibble(
115+
silhouette_score = widths,
116+
nearest_cluster = as.integer(sil[, "neighbor"]),
117+
cluster_width = as.numeric(clus_avg[as.character(clusters)])
118+
)
119+
}
120+
81121
#' analytics function for K-means view
82122
#' @export
83123
exp_kmeans <- function(df, ...,
@@ -154,6 +194,23 @@ exp_kmeans <- function(df, ...,
154194
!!!rlang::syms(selected_cols))
155195
ret <- dplyr::ungroup(ret) # ungroup once so that the following mutate with purrr::map2 works.
156196

197+
# Build the normalized clustering matrix ONCE; the single dist() is the only
198+
# O(n^2) cost and is shared with iterate_silhouette() (decision (a), #36106).
199+
# Only build the shared dist for the ungrouped (UI) case; grouped models build
200+
# their own per-group matrix below.
201+
is_single_model <- length(ret$model) == 1
202+
shared_sil_mat <- NULL
203+
shared_sil_dist <- NULL
204+
if (is_single_model) {
205+
kmeans_df_shared <- df %>% dplyr::select(!!!rlang::syms(selected_cols))
206+
shared_sil_mat <- as_numeric_matrix_(kmeans_df_shared, columns = colnames(kmeans_df_shared))
207+
if (normalize_data) {
208+
shared_sil_mat <- scale(shared_sil_mat)
209+
shared_sil_mat[is.nan(shared_sil_mat)] <- 0
210+
}
211+
shared_sil_dist <- stats::dist(shared_sil_mat)
212+
}
213+
157214
# Compute elbow or silhouette results depending on optimal_method.
158215
elbow_result <- NULL
159216
silhouette_result <- NULL
@@ -176,13 +233,26 @@ exp_kmeans <- function(df, ...,
176233
algorithm = algorithm,
177234
trace = trace,
178235
normalize_data = normalize_data,
179-
seed = NULL) # Seed is already set once at the top of exp_kmeans(). Skip it.
236+
seed = NULL, # Seed is already set once at the top of exp_kmeans(). Skip it.
237+
dist = shared_sil_dist) # Reuse the single dist when available.
180238
}
181239

182240
ret <- ret %>% dplyr::mutate(model = purrr::imap(model, function(x, idx) {
183241
tryCatch({
184242
y <- kmeans_model_df$model[[idx]]
185243
x$kmeans <- y # Might need to be more careful on guaranteeing x and y are from same group, but we are not supporting group_by on UI at this point.
244+
# Always attach per-row silhouette for the chosen clustering (#36106).
245+
if (!is.null(shared_sil_mat)) {
246+
x$silhouette <- compute_silhouette_per_row(y$cluster, shared_sil_mat, shared_sil_dist)
247+
} else {
248+
# Grouped case: build the matrix from this group's own data.
249+
gmat <- as_numeric_matrix_(x$df[, selected_cols, drop = FALSE], columns = selected_cols)
250+
if (normalize_data) {
251+
gmat <- scale(gmat)
252+
gmat[is.nan(gmat)] <- 0
253+
}
254+
x$silhouette <- compute_silhouette_per_row(y$cluster, gmat)
255+
}
186256
x$sampled_nrow <- sampled_nrow
187257
x$excluded_nrow <- excluded_nrow
188258
if (!is.null(elbow_result)) {

R/prcomp.R

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,23 @@ tidy.prcomp_exploratory <- function(x, type="variances", n_sample=NULL, pretty.n
175175
}
176176
else if (type == "summary") { # This is only for kmeans case. TODO: We might want to separate PCA code and k-means code.
177177
res <- broom::tidy(x$kmeans)
178+
if (!is.null(x$silhouette)) {
179+
# Per-cluster silhouette aggregates keyed by the same cluster labels as broom::tidy(kmeans).
180+
sil_summary <- x$silhouette %>%
181+
dplyr::mutate(.cluster_key = as.character(x$kmeans$cluster)) %>%
182+
dplyr::group_by(.cluster_key) %>%
183+
dplyr::summarise(
184+
# Guard the degenerate all-NA case so it yields NA (not NaN/Inf) consistently.
185+
avg_silhouette = if (all(is.na(silhouette_score))) NA_real_ else mean(silhouette_score, na.rm = TRUE),
186+
min_silhouette = if (all(is.na(silhouette_score))) NA_real_ else min(silhouette_score, na.rm = TRUE),
187+
pct_negative = if (all(is.na(silhouette_score))) NA_real_ else mean(silhouette_score < 0, na.rm = TRUE),
188+
.groups = "drop"
189+
)
190+
res <- res %>%
191+
dplyr::mutate(.cluster_key = as.character(cluster)) %>%
192+
dplyr::left_join(sil_summary, by = ".cluster_key") %>%
193+
dplyr::select(-.cluster_key)
194+
}
178195
if (with_excluded_rows) {
179196
res <- res %>% tibble::add_row(size=x$excluded_nrow)
180197
}
@@ -195,6 +212,11 @@ tidy.prcomp_exploratory <- function(x, type="variances", n_sample=NULL, pretty.n
195212
res <- res %>% dplyr::mutate(dplyr::across(dplyr::all_of(column_names), exploratory::normalize))
196213
}
197214

215+
if (type == "data" && !is.null(x$silhouette)) {
216+
# Bind per-row silhouette (aligned positionally to x$df, same as the cluster column above).
217+
res <- res %>% dplyr::bind_cols(x$silhouette)
218+
}
219+
198220
if (!is.null(n_sample)) { # default is no sampling.
199221
# limit n_sample so that no more dots are created than the max that can be plotted on scatter plot, which is 5000.
200222
n_sample <- min(n_sample, floor(5000 / length(column_names)))

tests/testthat/test_kmeans_1.R

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,86 @@ test_that("exp_kmeans elbow_method_mode backward compatibility: logical TRUE/FAL
138138
# model_df <- exp_kmeans(df, cyl, mpg, hp, elbow_method_mode=TRUE, max_centers=3)
139139
# model_df %>% tidyr::unnest(model)
140140
#})
141+
142+
test_that("compute_silhouette_per_row returns aligned per-row columns", {
143+
set.seed(1)
144+
mat <- as.matrix(iris[, 1:4])
145+
mat <- scale(mat)
146+
km <- stats::kmeans(mat, centers = 3)
147+
res <- compute_silhouette_per_row(km$cluster, mat)
148+
expect_equal(nrow(res), nrow(mat))
149+
expect_setequal(colnames(res), c("silhouette_score", "nearest_cluster", "cluster_width"))
150+
expect_true(all(res$silhouette_score >= -1 & res$silhouette_score <= 1))
151+
expect_true(all(res$nearest_cluster %in% unique(km$cluster)))
152+
# cluster_width is the per-cluster mean of silhouette_score, broadcast to rows.
153+
expected_avg <- tapply(res$silhouette_score, km$cluster, mean)
154+
for (cl in unique(km$cluster)) {
155+
expect_equal(unique(res$cluster_width[km$cluster == cl]), unname(expected_avg[as.character(cl)]))
156+
}
157+
# Passing precomputed dist gives identical result to recomputing it.
158+
d <- stats::dist(mat)
159+
res_d <- compute_silhouette_per_row(km$cluster, mat, d)
160+
expect_equal(res, res_d)
161+
})
162+
163+
test_that("compute_silhouette_per_row returns all-NA for degenerate input (no error)", {
164+
mat <- matrix(rep(1, 20), ncol = 2) # all identical points
165+
res <- compute_silhouette_per_row(rep(1L, 10), mat) # single cluster
166+
expect_equal(nrow(res), 10)
167+
expect_true(all(is.na(res$silhouette_score)))
168+
expect_true(all(is.na(res$nearest_cluster)))
169+
expect_true(all(is.na(res$cluster_width)))
170+
})
171+
172+
test_that("iterate_silhouette accepts a precomputed dist and matches recompute", {
173+
set.seed(1)
174+
df <- iris[, 1:4]
175+
mat <- scale(as_numeric_matrix_(df, columns = colnames(df)))
176+
d <- stats::dist(mat)
177+
set.seed(1); a <- iterate_silhouette(df, max_centers = 4, normalize_data = TRUE, seed = 1)
178+
set.seed(1); b <- iterate_silhouette(df, max_centers = 4, normalize_data = TRUE, seed = 1, dist = d)
179+
expect_equal(a, b)
180+
})
181+
182+
test_that("exp_kmeans attaches per-row silhouette to each model (all elbow modes)", {
183+
df <- mtcars
184+
for (mode in list("none", "elbow", "silhouette")) {
185+
model_df <- exp_kmeans(df, cyl, mpg, hp, centers = 3, elbow_method_mode = mode, max_nrow = 30)
186+
model <- model_df$model[[1]]
187+
expect_true(!is.null(model$silhouette))
188+
expect_equal(nrow(model$silhouette), nrow(model$df))
189+
expect_setequal(colnames(model$silhouette),
190+
c("silhouette_score", "nearest_cluster", "cluster_width"))
191+
}
192+
})
193+
194+
test_that("tidy_rowwise summary includes per-cluster silhouette aggregates", {
195+
df <- mtcars
196+
model_df <- exp_kmeans(df, cyl, mpg, hp, centers = 3, max_nrow = 30)
197+
res <- model_df %>% tidy_rowwise(model, type = "summary")
198+
expect_true(all(c("avg_silhouette", "min_silhouette", "pct_negative") %in% colnames(res)))
199+
non_excluded <- res[!is.na(res$cluster), ]
200+
expect_true(all(non_excluded$pct_negative >= 0 & non_excluded$pct_negative <= 1))
201+
expect_true(all(non_excluded$avg_silhouette >= non_excluded$min_silhouette))
202+
})
203+
204+
test_that("tidy_rowwise data includes per-row silhouette columns", {
205+
df <- mtcars
206+
model_df <- exp_kmeans(df, cyl, mpg, hp, centers = 3, max_nrow = 30)
207+
res <- model_df %>% tidy_rowwise(model, type = "data")
208+
expect_true(all(c("silhouette_score", "nearest_cluster", "cluster_width") %in% colnames(res)))
209+
expect_true(all(res$silhouette_score >= -1 & res$silhouette_score <= 1))
210+
# gathered_data must NOT carry the per-row silhouette columns (charts select their own cols).
211+
g <- model_df %>% tidy_rowwise(model, type = "gathered_data")
212+
expect_false("silhouette_score" %in% colnames(g))
213+
})
214+
215+
test_that("exp_kmeans with strange column name still yields silhouette columns", {
216+
df <- mtcars
217+
df <- df %>% dplyr::rename(`Cy l !#$%` = cyl)
218+
model_df <- exp_kmeans(df, `Cy l !#$%`, mpg, hp, centers = 3, max_nrow = 30)
219+
res <- model_df %>% tidy_rowwise(model, type = "data")
220+
expect_true("silhouette_score" %in% colnames(res))
221+
smry <- model_df %>% tidy_rowwise(model, type = "summary")
222+
expect_true("avg_silhouette" %in% colnames(smry))
223+
})

0 commit comments

Comments
 (0)