Skip to content

Commit ebe2676

Browse files
kei51eclaude
andcommitted
fix(kmeans): bound O(n^2) silhouette dist to silhouette_sample_size rows (#36126)
The per-row and elbow/silhouette-method diagnostics call stats::dist(), which is O(n^2) in memory and can OOM on large data. Bound that work to a silhouette_sample_size subsample (default 5000); k-means itself still runs on the full (or max_nrow) data. Non-sampled rows get NA silhouette scores, scattered back to full length so the result stays positionally aligned to the data. - silhouette_sample_index(): pick the sorted, reproducible subsample index - compute_silhouette_per_row(sample_idx=): compute on the subsample, scatter to n - iterate_silhouette(mat=): reuse the same subsampled scaled matrix so the kmeans input and the silhouette dist stay consistent Add tests for the grouped path, positional correctness of the scatter, cross-run reproducibility, and mat/dist consistency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3fb1477 commit ebe2676

3 files changed

Lines changed: 212 additions & 22 deletions

File tree

DESCRIPTION

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
Package: exploratory
22
Type: Package
33
Title: R package for Exploratory
4-
Version: 15.1.13
5-
Date: 2026-05-30
4+
Version: 15.1.14
5+
Date: 2026-06-03
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
88
Description: Functions for Exploratory

R/kmeans.R

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,37 @@ iterate_kmeans <- function(df, max_centers = 10,
3333
ret %>% rowwise(center) %>% glance_rowwise(model)
3434
}
3535

36+
# Pick the row index used for the O(n^2) silhouette dist/computation (#36126).
37+
# Returns the full index when sample_size is unset / non-positive / not smaller than n;
38+
# otherwise a sorted random subsample of size sample_size. The RNG seed is set once at the
39+
# top of exp_kmeans(), so the draw is reproducible across runs.
40+
silhouette_sample_index <- function(n, sample_size = NULL) {
41+
size <- suppressWarnings(as.numeric(sample_size))
42+
if (length(size) != 1 || is.na(size) || size < 1 || n <= size) {
43+
return(seq_len(n))
44+
}
45+
sort(sample.int(n, floor(size)))
46+
}
47+
3648
# internal function to iterate number of centers (k) from 2 to max_centers for silhouette method to find optimal k.
49+
# When `mat` is supplied it must already be the numeric (and, if normalize_data, scaled)
50+
# matrix that `dist` was computed from; in that case df/normalize_data are ignored for the
51+
# matrix build so the kmeans input and the silhouette dist stay consistent (#36126).
3752
iterate_silhouette <- function(df, max_centers = 10,
3853
iter.max = 10,
3954
nstart = 1,
4055
algorithm = "Hartigan-Wong",
4156
trace = FALSE,
4257
normalize_data = TRUE,
4358
seed = NULL,
44-
dist = NULL) {
45-
mat <- as_numeric_matrix_(df, columns = colnames(df))
46-
if (normalize_data) {
47-
mat <- scale(mat)
48-
mat[is.nan(mat)] <- 0
59+
dist = NULL,
60+
mat = NULL) {
61+
if (is.null(mat)) {
62+
mat <- as_numeric_matrix_(df, columns = colnames(df))
63+
if (normalize_data) {
64+
mat <- scale(mat)
65+
mat[is.nan(mat)] <- 0
66+
}
4967
}
5068
# Cap k by the number of distinct data points, not just the row count:
5169
# stats::kmeans() errors with "more cluster centers than distinct data points"
@@ -81,22 +99,32 @@ iterate_silhouette <- function(df, max_centers = 10,
8199

82100
# Compute per-row silhouette widths for an already-built clustering.
83101
#
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).
102+
# cluster_ids: integer cluster assignment vector (length n) for the FULL data.
103+
# For K-Means this is x$kmeans$cluster (integer 1..k). The result is always
104+
# length n and positionally aligned to it (prcomp.R binds it onto x$df).
105+
# mat: normalized numeric matrix for the rows named by sample_idx (sample_size rows
106+
# when subsampling, otherwise n rows).
87107
# d: optional precomputed stats::dist(mat) to reuse. Computed when NULL.
108+
# sample_idx: positions (into 1..n) that mat/d correspond to. NULL means the full data
109+
# (mat has n rows). When a strict subset, the silhouette is computed only on
110+
# those rows and the scores are scattered back into the length-n result with
111+
# NA elsewhere -- this bounds the O(n^2) dist to sample_size rows (#36126).
88112
#
89113
# Returns a tibble with n rows and columns silhouette_score, nearest_cluster, cluster_width.
90114
# Returns all-NA columns (no error) when silhouette is undefined
91115
# (fewer than 2 clusters, or fewer than 2 distinct points).
92-
compute_silhouette_per_row <- function(cluster_ids, mat, d = NULL) {
116+
compute_silhouette_per_row <- function(cluster_ids, mat, d = NULL, sample_idx = NULL) {
93117
n <- length(cluster_ids)
94118
na_result <- tibble::tibble(
95119
silhouette_score = rep(NA_real_, n),
96120
nearest_cluster = rep(NA_integer_, n),
97121
cluster_width = rep(NA_real_, n)
98122
)
99-
ids <- as.integer(cluster_ids)
123+
if (is.null(sample_idx)) {
124+
sample_idx <- seq_len(n)
125+
}
126+
# mat/d already correspond to sample_idx rows; the cluster ids must be subset to match.
127+
ids <- as.integer(cluster_ids[sample_idx])
100128
if (length(unique(ids)) < 2 || nrow(unique(mat)) < 2) {
101129
return(na_result)
102130
}
@@ -111,11 +139,12 @@ compute_silhouette_per_row <- function(cluster_ids, mat, d = NULL) {
111139
widths <- as.numeric(sil[, "sil_width"])
112140
clusters <- as.integer(sil[, "cluster"])
113141
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-
)
142+
# Scatter the per-sampled-row values back to full length (NA for non-sampled rows).
143+
# silhouette() preserves the observation order of `ids`, so widths[i] -> sample_idx[i].
144+
na_result$silhouette_score[sample_idx] <- widths
145+
na_result$nearest_cluster[sample_idx] <- as.integer(sil[, "neighbor"])
146+
na_result$cluster_width[sample_idx] <- as.numeric(clus_avg[as.character(clusters)])
147+
na_result
119148
}
120149

121150
#' analytics function for K-means view
@@ -130,7 +159,11 @@ exp_kmeans <- function(df, ...,
130159
max_nrow = NULL,
131160
seed = 1,
132161
elbow_method_mode=FALSE,
133-
max_centers = 10
162+
max_centers = 10,
163+
# Bound the O(n^2) silhouette dist/computation to this many rows (#36126).
164+
# K-means itself still runs on the full (or max_nrow) data; only the
165+
# silhouette diagnostics are estimated from this subsample. NULL = no cap.
166+
silhouette_sample_size = 5000
134167
) {
135168
# this evaluates select arguments like starts_with
136169
selected_cols <- tidyselect::vars_select(names(df), !!! rlang::quos(...))
@@ -198,16 +231,22 @@ exp_kmeans <- function(df, ...,
198231
# O(n^2) cost and is shared with iterate_silhouette() (decision (a), #36106).
199232
# Only build the shared dist for the ungrouped (UI) case; grouped models build
200233
# their own per-group matrix below.
234+
# The dist()/silhouette work is bounded to silhouette_sample_size rows (#36126):
235+
# scale over the full data (cheap, O(n*p)) so the sampled rows' coordinates stay
236+
# consistent with build_kmeans.cols' full-data scaling, THEN subset before dist().
201237
is_single_model <- length(ret$model) == 1
202238
shared_sil_mat <- NULL
203239
shared_sil_dist <- NULL
240+
sil_sample_idx <- NULL
204241
if (is_single_model) {
205242
kmeans_df_shared <- df %>% dplyr::select(!!!rlang::syms(selected_cols))
206243
shared_sil_mat <- as_numeric_matrix_(kmeans_df_shared, columns = colnames(kmeans_df_shared))
207244
if (normalize_data) {
208245
shared_sil_mat <- scale(shared_sil_mat)
209246
shared_sil_mat[is.nan(shared_sil_mat)] <- 0
210247
}
248+
sil_sample_idx <- silhouette_sample_index(nrow(shared_sil_mat), silhouette_sample_size)
249+
shared_sil_mat <- shared_sil_mat[sil_sample_idx, , drop = FALSE]
211250
shared_sil_dist <- stats::dist(shared_sil_mat)
212251
}
213252

@@ -234,24 +273,30 @@ exp_kmeans <- function(df, ...,
234273
trace = trace,
235274
normalize_data = normalize_data,
236275
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.
276+
dist = shared_sil_dist, # Reuse the single (subsampled) dist when available.
277+
mat = shared_sil_mat) # Use the SAME subsampled scaled matrix so kmeans input and dist match (#36126).
238278
}
239279

240280
ret <- ret %>% dplyr::mutate(model = purrr::imap(model, function(x, idx) {
241281
tryCatch({
242282
y <- kmeans_model_df$model[[idx]]
243283
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.
244284
# Always attach per-row silhouette for the chosen clustering (#36106).
285+
# The dist/silhouette is bounded to silhouette_sample_size rows; non-sampled rows
286+
# get NA scores (compute_silhouette_per_row scatters back to full length, #36126).
245287
if (!is.null(shared_sil_mat)) {
246-
x$silhouette <- compute_silhouette_per_row(y$cluster, shared_sil_mat, shared_sil_dist)
288+
x$silhouette <- compute_silhouette_per_row(y$cluster, shared_sil_mat, shared_sil_dist,
289+
sample_idx = sil_sample_idx)
247290
} else {
248-
# Grouped case: build the matrix from this group's own data.
291+
# Grouped case: build the matrix from this group's own data, then subsample.
249292
gmat <- as_numeric_matrix_(x$df[, selected_cols, drop = FALSE], columns = selected_cols)
250293
if (normalize_data) {
251294
gmat <- scale(gmat)
252295
gmat[is.nan(gmat)] <- 0
253296
}
254-
x$silhouette <- compute_silhouette_per_row(y$cluster, gmat)
297+
g_sample_idx <- silhouette_sample_index(nrow(gmat), silhouette_sample_size)
298+
x$silhouette <- compute_silhouette_per_row(y$cluster, gmat[g_sample_idx, , drop = FALSE],
299+
sample_idx = g_sample_idx)
255300
}
256301
x$sampled_nrow <- sampled_nrow
257302
x$excluded_nrow <- excluded_nrow

tests/testthat/test_kmeans_1.R

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,148 @@ test_that("exp_kmeans with strange column name still yields silhouette columns",
221221
smry <- model_df %>% tidy_rowwise(model, type = "summary")
222222
expect_true("avg_silhouette" %in% colnames(smry))
223223
})
224+
225+
# ---- silhouette_sample_size (#36126: bound the O(n^2) silhouette dist) ----
226+
227+
test_that("silhouette_sample_index returns full index unless sample_size < n", {
228+
expect_equal(silhouette_sample_index(10, NULL), 1:10) # NULL -> no cap
229+
expect_equal(silhouette_sample_index(10, 0), 1:10) # non-positive -> no cap
230+
expect_equal(silhouette_sample_index(10, 10), 1:10) # equal -> no cap
231+
expect_equal(silhouette_sample_index(10, 20), 1:10) # larger -> no cap
232+
set.seed(1)
233+
idx <- silhouette_sample_index(100, 5) # smaller -> subsample
234+
expect_length(idx, 5)
235+
expect_true(all(idx %in% 1:100))
236+
expect_false(is.unsorted(idx)) # returned sorted
237+
expect_equal(length(unique(idx)), 5) # without replacement
238+
})
239+
240+
test_that("compute_silhouette_per_row with sample_idx scatters to full length, NA off-sample", {
241+
set.seed(1)
242+
full_mat <- scale(as.matrix(iris[, 1:4]))
243+
km <- stats::kmeans(full_mat, centers = 3)
244+
n <- nrow(full_mat)
245+
sample_idx <- sort(sample.int(n, 30))
246+
res <- compute_silhouette_per_row(km$cluster, full_mat[sample_idx, , drop = FALSE],
247+
sample_idx = sample_idx)
248+
expect_equal(nrow(res), n) # full length, aligned to data
249+
expect_setequal(colnames(res), c("silhouette_score", "nearest_cluster", "cluster_width"))
250+
expect_equal(sum(!is.na(res$silhouette_score)), 30) # only sampled rows scored
251+
expect_true(all(is.na(res$silhouette_score[-sample_idx]))) # off-sample rows are NA
252+
on_sample <- res$silhouette_score[sample_idx]
253+
expect_true(all(on_sample >= -1 & on_sample <= 1))
254+
})
255+
256+
test_that("exp_kmeans bounds the per-row silhouette to silhouette_sample_size (no OOM, aligned)", {
257+
df <- mtcars # 32 rows, no NA
258+
model_df <- exp_kmeans(df, cyl, mpg, hp, centers = 3, max_nrow = NULL,
259+
silhouette_sample_size = 10)
260+
model <- model_df$model[[1]]
261+
# Per-row silhouette stays positionally aligned to all rows ...
262+
expect_equal(nrow(model$silhouette), nrow(model$df))
263+
# ... but only up to silhouette_sample_size rows actually get a (finite) score.
264+
expect_lte(sum(!is.na(model$silhouette$silhouette_score)), 10)
265+
expect_gt(sum(!is.na(model$silhouette$silhouette_score)), 0)
266+
267+
res <- model_df %>% tidy_rowwise(model, type = "data")
268+
expect_equal(nrow(res), nrow(model$df))
269+
smry <- model_df %>% tidy_rowwise(model, type = "summary")
270+
expect_true(all(c("avg_silhouette", "min_silhouette", "pct_negative") %in% colnames(smry)))
271+
})
272+
273+
test_that("exp_kmeans default (large) silhouette_sample_size leaves small data fully scored", {
274+
df <- mtcars
275+
model_df <- exp_kmeans(df, cyl, mpg, hp, centers = 3, max_nrow = NULL) # default 5000 >> 32
276+
sil <- model_df$model[[1]]$silhouette
277+
expect_equal(sum(!is.na(sil$silhouette_score)), nrow(sil)) # no subsampling for small data
278+
})
279+
280+
test_that("exp_kmeans silhouette method respects silhouette_sample_size (bounded, no error)", {
281+
df <- mtcars
282+
model_df <- exp_kmeans(df, cyl, mpg, hp, centers = 3, max_nrow = NULL,
283+
elbow_method_mode = "silhouette", max_centers = 4,
284+
silhouette_sample_size = 15)
285+
sil_res <- model_df$model[[1]]$silhouette_result
286+
expect_false(is.null(sil_res))
287+
expect_true(all(c("center", "avg_silhouette", "min_silhouette", "pct_negative") %in% colnames(sil_res)))
288+
})
289+
290+
# #1: Grouped case exercises the else branch (per-group subsample via g_sample_idx).
291+
test_that("exp_kmeans grouped case bounds and aligns per-group silhouette (#36126)", {
292+
df <- mtcars %>% dplyr::group_by(am) # two groups: 19 and 13 rows, both > sample_size below
293+
model_df <- exp_kmeans(df, cyl, mpg, hp, centers = 2, max_nrow = NULL,
294+
silhouette_sample_size = 8)
295+
expect_equal(nrow(model_df), length(unique(mtcars$am))) # one model row per group
296+
for (i in seq_len(nrow(model_df))) {
297+
m <- model_df$model[[i]]
298+
# Per-row silhouette stays positionally aligned to the group's rows ...
299+
expect_equal(nrow(m$silhouette), nrow(m$df))
300+
# ... but at most silhouette_sample_size rows actually get a finite score.
301+
n_scored <- sum(!is.na(m$silhouette$silhouette_score))
302+
expect_lte(n_scored, 8)
303+
expect_gt(n_scored, 0)
304+
}
305+
})
306+
307+
# #2: The scatter must place each sampled row's value at ITS OWN position, not just
308+
# produce the right count/range. Compare against an independent reference silhouette
309+
# computed directly on the subsample (silhouette() preserves input observation order).
310+
test_that("compute_silhouette_per_row scatters each value to its own row, positionally (#36126)", {
311+
set.seed(42)
312+
full_mat <- scale(as.matrix(iris[, 1:4]))
313+
km <- stats::kmeans(full_mat, centers = 3)
314+
n <- nrow(full_mat)
315+
sample_idx <- sort(sample.int(n, 40))
316+
317+
sub_mat <- full_mat[sample_idx, , drop = FALSE]
318+
ref_sil <- cluster::silhouette(as.integer(km$cluster[sample_idx]), stats::dist(sub_mat))
319+
ref_width <- as.numeric(ref_sil[, "sil_width"])
320+
ref_neighbor <- as.integer(ref_sil[, "neighbor"])
321+
ref_clusters <- as.integer(ref_sil[, "cluster"])
322+
ref_clus_avg <- tapply(ref_width, ref_clusters, mean, na.rm = TRUE)
323+
ref_cluster_width <- as.numeric(ref_clus_avg[as.character(ref_clusters)])
324+
325+
res <- compute_silhouette_per_row(km$cluster, sub_mat, sample_idx = sample_idx)
326+
327+
# Exact, position-by-position equality. A scatter that shuffled positions (but kept
328+
# values in [-1, 1]) would pass the existing count/range checks yet fail here.
329+
expect_equal(res$silhouette_score[sample_idx], ref_width)
330+
expect_equal(res$nearest_cluster[sample_idx], ref_neighbor)
331+
expect_equal(res$cluster_width[sample_idx], ref_cluster_width)
332+
expect_true(all(is.na(res$silhouette_score[-sample_idx]))) # off-sample untouched
333+
})
334+
335+
# #3: With the seed fixed, the subsample draw (and thus the scores and NA positions)
336+
# must be identical across runs -- otherwise the UI diagnostic would flicker per run.
337+
test_that("exp_kmeans silhouette subsample is reproducible across runs (same seed) (#36126)", {
338+
run <- function() {
339+
exp_kmeans(mtcars, cyl, mpg, hp, centers = 3, max_nrow = NULL,
340+
silhouette_sample_size = 12, seed = 1)$model[[1]]$silhouette
341+
}
342+
s1 <- run()
343+
s2 <- run()
344+
expect_equal(s1$silhouette_score, s2$silhouette_score) # identical scores incl. NAs
345+
expect_equal(which(is.na(s1$silhouette_score)),
346+
which(is.na(s2$silhouette_score))) # identical subsample draw
347+
expect_equal(sum(!is.na(s1$silhouette_score)), 12) # subsampling actually happened
348+
})
349+
350+
# #4: When `mat` is supplied, iterate_silhouette must use it (and the supplied dist) and
351+
# ignore df/normalize_data for the matrix build -- this is the kmeans-input/dist consistency
352+
# fix. A bogus df must not change the result; it must match building from the equivalent df.
353+
test_that("iterate_silhouette uses supplied mat/dist and ignores df for the matrix build (#36126)", {
354+
mat <- scale(as.matrix(mtcars[, c("cyl", "mpg", "hp")]))
355+
mat[is.nan(mat)] <- 0
356+
d <- stats::dist(mat)
357+
df_equiv <- as.data.frame(mat) # already normalized, so normalize_data=FALSE rebuilds `mat`
358+
359+
res_built <- iterate_silhouette(df_equiv, max_centers = 4, normalize_data = FALSE, seed = 42)
360+
res_mat <- iterate_silhouette(df_equiv, max_centers = 4, normalize_data = FALSE,
361+
seed = 42, mat = mat, dist = d)
362+
expect_equal(res_mat, res_built) # supplying mat/dist matches the build-from-df path
363+
364+
bogus_df <- data.frame(a = seq_len(nrow(mat)), b = rev(seq_len(nrow(mat))))
365+
res_bogus <- iterate_silhouette(bogus_df, max_centers = 4, normalize_data = TRUE,
366+
seed = 42, mat = mat, dist = d)
367+
expect_equal(res_bogus, res_mat) # df is ignored entirely when mat is supplied
368+
})

0 commit comments

Comments
 (0)