@@ -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
83123exp_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 )) {
0 commit comments