@@ -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