Executive Summary
3 natural segments across 450 observations
The data contains 3 natural segments, each comprising exactly 150 rows (33.3% of the 450 observations). C1: High Sessions per Week, Low Tenure Days exhibits 67.2% higher sessions and 74.2% lower tenure. C2: High Monthly Spend, High Tenure Days shows 85.2% higher spend and 82.8% higher tenure. C3: High Support Tickets, Low Sessions per Week stands out with 127.4% more support tickets and 67.3% fewer sessions. The silhouette score of 0.857 confirms strong, unambiguous separation—a clear signal that these three segments are a genuine property of the data.
Analysis Overview
K-means segmentation of 450 observations on 4 features.
The analysis identifies natural segments by fitting k-means clustering across k=2 to 8 on 450 observations using 4 standardized features (Monthly Spend, Sessions per Week, Support Tickets, Tenure Days). The data itself selected k=3 as optimal, achieving an average silhouette width of 0.857—strong separation. This data-driven approach avoids assuming a segment count; instead, silhouette scoring reveals that 3 clusters genuinely reflect the underlying structure. All 450 rows were retained after median imputation, and no features were dropped, ensuring the full dataset and complete feature set inform the segmentation.
Data Quality
Column typing, imputation, standardization, and exclusions.
All 450 rows were successfully processed with zero rows removed, and all 4 mapped columns remained usable. Missing values were imputed using each column's median, preserving sample size and avoiding information loss. Standardization (mean 0, standard deviation 1) was applied uniformly across all features so that no single column—whether measured in days, dollars, or count—would dominate distance calculations. This equal weighting is essential for k-means to discover segments driven by natural multivariate patterns rather than scale artifacts.
How Many Clusters?
Average silhouette width for each candidate k.
k=3 decisively wins with an average silhouette width of 0.857, falling into the "strong" band (above 0.7). The runner-up, k=4, scores 0.656 (good range, 0.5–0.7). The gap is substantial: k=5 drops to 0.446 (reasonable), and k=7 and k=8 both score 0.224 (weak). This clear peak at k=3 is not ambiguous; it indicates the segment count is a robust property of the data rather than an artifact. The sharp decline in silhouette scores beyond k=3 reinforces that subdividing further would fragment natural groups without improving fit.
Segment Sizes
How the observations distribute across the discovered segments.
The three segments are perfectly balanced: C1 holds 150 rows, C2 holds 150 rows, and C3 holds 150 rows, each representing exactly 33.3% of the 450 observations. This uniform distribution suggests no single segment dominates the population and that all three are equally substantive. Equal-sized clusters also reduce the risk of one large group masking minority patterns, strengthening confidence that each segment reflects a genuine, distinct behavioral or characteristic profile.
Segment Profiles
Per-segment feature averages in original units vs the overall average.
| Cluster | Feature | Cluster Mean | Overall Mean | Difference PCT |
|---|---|---|---|---|
| C1: High Sessions per Week, Low Tenure Days | Monthly Spend | 41.26 | 139.4 | -70.4 |
| C1: High Sessions per Week, Low Tenure Days | Sessions per Week | 20 | 11.96 | 67.2 |
| C1: High Sessions per Week, Low Tenure Days | Support Tickets | 1.97 | 3.962 | -50.3 |
| C1: High Sessions per Week, Low Tenure Days | Tenure Days | 197.8 | 767.8 | -74.2 |
| C2: High Monthly Spend, High Tenure Days | Monthly Spend | 258.1 | 139.4 | 85.2 |
| C2: High Monthly Spend, High Tenure Days | Sessions per Week | 11.97 | 11.96 | 0.1 |
| C2: High Monthly Spend, High Tenure Days | Support Tickets | 0.906 | 3.962 | -77.1 |
| C2: High Monthly Spend, High Tenure Days | Tenure Days | 1404 | 767.8 | 82.8 |
| C3: High Support Tickets, Low Sessions per Week | Monthly Spend | 118.8 | 139.4 | -14.8 |
| C3: High Support Tickets, Low Sessions per Week | Sessions per Week | 3.913 | 11.96 | -67.3 |
| C3: High Support Tickets, Low Sessions per Week | Support Tickets | 9.012 | 3.962 | 127.4 |
| C3: High Support Tickets, Low Sessions per Week | Tenure Days | 701.8 | 767.8 | -8.6 |
C1 (High Sessions per Week, Low Tenure Days) averages 19.997 sessions per week (+67.2% vs. 11.961 overall) but only 197.802 tenure days (−74.2% vs. 767.826 overall), with low spend (41.26, −70.4%). C2 (High Monthly Spend, High Tenure Days) averages 258.052 monthly spend (+85.2%) and 1403.835 tenure days (+82.8%), but minimal support tickets (0.906, −77.1%). C3 (High Support Tickets, Low Sessions per Week) is defined by 9.012 support tickets (+127.4% vs. 3.962 overall) and only 3.913 sessions per week (−67.3%), with moderate spend. These contrasting profiles make each segment actionable and strategically distinct.
Segment Map
2-D PCA projection of all features, colored by segment.
The 2-D PCA projection captures 98.4% of variance across the 4 dimensions (PC1: 55.2%, PC2: 43.2%), revealing visually distinct, well-separated color islands for each segment. C1 clusters in the lower-left region, C2 in the upper-right, and C3 in the lower-center area. This spatial separation confirms the silhouette score of 0.857 visually: the segments form coherent, non-overlapping groups in the reduced space. The high variance retention (98.4%) ensures this 2-D view faithfully represents the full 4-dimensional clustering structure.
Methodology
Statistical methodology and diagnostics for Cluster Analysis — Natural Segments
Statistical Method
Standard-library analysis: discover the natural groups hiding in your data. K-means clustering on standardized features with the number of clusters chosen by the data itself (average silhouette width over k=2..8) — segment sizes, per-segment profiles in original units, auto-named segments, and a 2-D map of the groups. Works on any dataset: map 2 or more numeric columns.
- Segments are roughly spherical in standardized feature space (k-means geometry)
- Columns are numeric or cleanly convertible
- Every feature deserves equal weight after standardization
- K-means struggles with elongated, nested, or density-based cluster shapes
- The silhouette criterion can prefer merging genuinely distinct but adjacent groups
- Results describe structure, not causation — segments still need business interpretation
Analysis Code
Complete R source code for this analysis
Cluster Analysis — Natural Segments
Discovers natural groups in the data: k-means on standardized features with the number of clusters chosen by the data (average silhouette width over k = 2..8), cluster profiles in original units, and a 2-D PCA map of the segments.
Why This Method?
K-means is the workhorse of segmentation: fast, stable, and easy to read. The one input it demands — how many clusters — is here chosen by the silhouette criterion, so the segmentation reflects structure that is actually in the data rather than a guess.
What This Analysis Covers
- Data-driven choice of k (average silhouette width, k = 2..8)
- Cluster sizes and per-cluster profiles in original units
- Auto-named clusters from their most distinctive features
- A 2-D PCA projection colored by cluster
Standard Library
Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {feature_1..feature_N}. All narrative is derived from the user's own column names and computed values.
suppressPackageStartupMessages(library(DT))
suppressPackageStartupMessages(library(htmlwidgets))
suppressPackageStartupMessages(library(arrow))
suppressPackageStartupMessages(library(knitr))
suppressPackageStartupMessages(library(rmarkdown))
suppressPackageStartupMessages(library(dplyr))
suppressPackageStartupMessages(library(tidyr))
suppressPackageStartupMessages(library(ggplot2))
suppressPackageStartupMessages(library(stringr))
suppressPackageStartupMessages(library(lubridate))
suppressPackageStartupMessages(library(broom))
suppressPackageStartupMessages(library(Matrix))
suppressPackageStartupMessages(library(cluster))
suppressPackageStartupMessages(library(data.table))Core Analysis Pipeline
compute_shared <- function(df, params, col_map = list()) {
# === SHARED EXPORTS ===
# initial_rows/final_rows/rows_removed $ row accounting
# feature_names $ named character — semantic -> humanized names
# used_features $ character — semantic names used
# dropped_features $ character — excluded columns
# k_star / sil_star $ chosen k and its average silhouette width
# sil_quality $ character — silhouette quality band word
# k_searched $ integer vector — the k values evaluated
# sil_search_n $ rows used for the silhouette search (<=5000 sample)
# sil_df $ data.frame(k, avg_silhouette) — chart data
# assignments $ integer vector, length final_rows (1 = largest cluster)
# cluster_labels $ character(k_star) — auto-generated cluster names
# sizes_df $ data.frame(cluster, size)
# share_pct $ numeric(k_star) — cluster share of rows, percent
# profiles_df $ data.frame(cluster, feature, cluster_mean,
# overall_mean, difference_pct) — LONG profile table
# map_df $ data.frame(pc1, pc2, cluster) — <=1000 PCA sample
# pc_var_pct $ numeric(2) — % variance of PC1/PC2
# metrics / json_output
# === /SHARED EXPORTS ===Step 1: Discover mapped features
initial_rows <- nrow(df)
feat_cols <- grep("^feature_[0-9]+$", names(df), value = TRUE)
feat_cols <- feat_cols[order(as.integer(sub("^feature_", "", feat_cols)))]
if (length(feat_cols) < 2) {
stop("column_mapping must map at least two feature columns(feature_1, feature_2)")
}
feature_names <- setNames(humanize_semantic(feat_cols, col_map), feat_cols)Step 2: Coerce numeric (95% rule); impute median; drop unusable
dropped_features <- character(0)
for (fc in feat_cols) {
v <- df[[fc]]
if (!is.numeric(v)) {
conv <- suppressWarnings(as.numeric(as.character(v)))
n_orig <- sum(!is.na(v) & as.character(v) != "")
if (n_orig > 0 && sum(!is.na(conv)) >= 0.95 * n_orig) {
df[[fc]] <- conv
} else {
dropped_features <- c(dropped_features, fc); next
}
}
v <- df[[fc]]
med <- median(v, na.rm = TRUE)
if (is.na(med)) { dropped_features <- c(dropped_features, fc); next }
v[is.na(v)] <- med
df[[fc]] <- v
if (isTRUE(var(v) == 0) || is.na(var(v))) {
dropped_features <- c(dropped_features, fc)
}
}
used_features <- setdiff(feat_cols, dropped_features)
if (length(used_features) < 2) {
stop(paste0("Clustering needs at least two usable numeric columns; only ",
length(used_features), " remained after cleaning."))
}
X <- df[, used_features, drop = FALSE]
final_rows <- nrow(X)
rows_removed <- initial_rows - final_rows
if (final_rows < 30) {
stop(sprintf(
"Only %d usable rows across %s — clustering needs at least 30 rows to find stable segments.",
final_rows, paste(feature_names[used_features], collapse = ", ")))
}
hn_used <- unname(feature_names[used_features])Step 3: Standardize + choose k by average silhouette width (k = 2..8)
Xs <- scale(as.matrix(X))
n <- final_rows
k_max <- min(8, floor(n / 10))
k_searched <- 2:k_maxSilhouette search runs on a <=5000-row sample (dist is O(n^2)); the FINAL k-means fit below always uses ALL rows.
set.seed(42)
search_idx <- if (n > 5000) sample(n, 5000) else seq_len(n)
sil_search_n <- length(search_idx)
Xs_search <- Xs[search_idx, , drop = FALSE]
D <- dist(Xs_search)
sil_by_k <- vapply(k_searched, function(k) {
km_k <- tryCatch({
set.seed(42)
kmeans(Xs_search, centers = k, nstart = 10, iter.max = 50)
}, error = function(e) NULL)
if (is.null(km_k) || length(unique(km_k$cluster)) < 2) return(NA_real_)
s <- tryCatch(cluster::silhouette(km_k$cluster, D), error = function(e) NULL)
if (is.null(s)) return(NA_real_)
mean(s[, 3])
}, numeric(1))LAT-1445 guard: never which.max over a possibly-all-NA vector
valid <- which(!is.na(sil_by_k))
if (length(valid) == 0) {
stop(paste0("No valid k-means solution could be evaluated on ",
paste(hn_used, collapse = ", "),
" — the data may have too few distinct rows to cluster."))
}
k_star <- k_searched[valid][which.max(sil_by_k[valid])]
sil_star <- max(sil_by_k[valid])
sil_quality <- if (sil_star > 0.7) "strong"
else if (sil_star > 0.5) "good"
else if (sil_star >= 0.25) "reasonable"
else "weak"
sil_df <- data.frame(
k = paste0("k=", k_searched[valid]),
avg_silhouette = round(sil_by_k[valid], 3),
stringsAsFactors = FALSE
)Step 4: Final k-means fit on ALL rows; relabel clusters by size
set.seed(42)
km <- kmeans(Xs, centers = k_star, nstart = 10, iter.max = 50)
tab <- tabulate(km$cluster, nbins = k_star)
ord_sz <- order(-tab)
remap <- integer(k_star); remap[ord_sz] <- seq_len(k_star)
assignments <- remap[km$cluster]
centers <- km$centers[ord_sz, , drop = FALSE] # standardized centroids
sizes <- tabulate(assignments, nbins = k_star)
share_pct <- round(100 * sizes / n, 1)Step 5: Name clusters by their most distinctive features
Top |standardized centroid| dims, max 2 features per name.
cluster_desc <- apply(centers, 1, function(cv) {
cv <- as.numeric(cv)
ord <- order(-abs(cv))
if (abs(cv[ord[1]]) < 0.1) return("Near overall average")
part <- function(i) paste0(if (cv[i] > 0) "High " else "Low ", hn_used[i])
parts <- part(ord[1])
if (length(ord) >= 2 && abs(cv[ord[2]]) >= 0.3) {
parts <- c(parts, part(ord[2]))
}
paste(parts, collapse = ", ")
})
cluster_labels <- sprintf("C%d: %s", seq_len(k_star), cluster_desc)
sizes_df <- data.frame(cluster = cluster_labels, size = sizes,
stringsAsFactors = FALSE)Step 6: Cluster profiles in ORIGINAL units (long format)
overall_means <- colMeans(X)
prof_rows <- list()
for (ci in seq_len(k_star)) {
in_c <- assignments == ci
for (fi in seq_along(used_features)) {
cm <- mean(X[[fi]][in_c])
om <- overall_means[[fi]]
dp <- if (abs(om) > 1e-12) round(100 * (cm - om) / abs(om), 1) else NA_real_
prof_rows[[length(prof_rows) + 1]] <- data.frame(
cluster = cluster_labels[ci],
feature = hn_used[fi],
cluster_mean = round(cm, 3),
overall_mean = round(om, 3),
difference_pct = dp,
stringsAsFactors = FALSE
)
}
}
profiles_df <- do.call(rbind, prof_rows)
rownames(profiles_df) <- NULLStep 7: 2-D PCA projection colored by cluster (<=1000 sample)
pr <- prcomp(Xs, center = FALSE, scale. = FALSE)
var_all <- pr$sdev^2 / sum(pr$sdev^2)
pc_var_pct <- round(100 * var_all[1:2], 1)
set.seed(42)
map_idx <- if (n > 1000) sample(n, 1000) else seq_len(n)
map_df <- data.frame(
pc1 = round(pr$x[map_idx, 1], 3),
pc2 = round(pr$x[map_idx, 2], 3),
cluster = cluster_labels[assignments[map_idx]],
stringsAsFactors = FALSE
)
metrics <- list(
`Observations` = n,
`Features Used` = length(used_features),
`Clusters Found` = k_star,
`Avg Silhouette` = round(sil_star, 3),
`Largest Cluster` = paste0(format(sizes[1], big.mark = ","),
" rows(", share_pct[1], "%)"),
`Separation Quality` = sil_quality
)
json_output <- list(
answer = paste0(
"K-means on ", length(used_features), " standardized features across ",
format(n, big.mark = ","), " rows finds ", k_star,
" natural segments(average silhouette ", round(sil_star, 3), " — ",
sil_quality, " separation; k chosen over k=2..", k_max, "). ",
paste(sprintf("%s: %s rows(%s%%)", cluster_labels,
format(sizes, big.mark = ",", trim = TRUE), share_pct),
collapse = "; "), "."
),
cards = lapply(
c("tldr", "overview", "preprocessing", "silhouette_by_k",
"cluster_sizes", "cluster_profiles", "cluster_map"),
function(cid) list(id = cid, metrics = metrics)
)
)
list(
initial_rows = initial_rows, final_rows = final_rows,
rows_removed = rows_removed,
feature_names = feature_names, used_features = used_features,
dropped_features = dropped_features,
k_star = k_star, sil_star = sil_star, sil_quality = sil_quality,
k_searched = k_searched[valid], sil_search_n = sil_search_n,
sil_df = sil_df,
assignments = assignments, cluster_labels = cluster_labels,
sizes_df = sizes_df, share_pct = share_pct,
profiles_df = profiles_df,
map_df = map_df, pc_var_pct = pc_var_pct,
metrics = metrics, json_output = json_output
)
}LAT-1445 guard: filter NA before which.max
ok <- which(!is.na(dp))
standout_note <- if (length(ok) > 0) {
top <- pdf[ok[which.max(abs(dp[ok]))], ]
paste0("The single most distinctive trait: ", top$cluster,
" averages ", round(top$cluster_mean, 1), " on ", top$feature,
" versus ", round(top$overall_mean, 1), " overall(",
ifelse(top$difference_pct > 0, "+", ""), top$difference_pct,
"%). ")
} else ""
text <- paste0(
"Each row compares one segment's average on one feature (in original ",
"units) against the overall average, with the percent difference. ",
standout_note,
"Large positive or negative differences are what make a segment ",
"actionable — they tell you who is in it and how to treat it ",
"differently. Differences near zero mean that feature does not ",
"distinguish the segment."
)
list(
title = "Segment Profiles",
description = "Per-segment feature averages in original units vs the overall average.",
text = text,
data = list(cluster_profiles = pdf)
)
}
# Card: cluster_map (scatter)
card_cluster_map <- function(shared, df, params) {
two_pc_var <- round(sum(shared$pc_var_pct), 1)
text <- paste0(
"Each point is one observation projected onto the two principal ",
"components of the standardized features — the best possible 2-D ",
"summary of the full ", length(shared$used_features),
"-dimensional space, capturing ", two_pc_var,
"% of its variance — and colored by segment. Distinct, well-separated ",
"color islands confirm the segmentation visually(average silhouette ",
round(shared$sil_star, 3), ", ", shared$sil_quality,
"); heavy overlap here can also just mean the separation lives in ",
"dimensions this 2-D view cannot show."
)
list(
title = "Segment Map",
description = "2-D PCA projection of all features, colored by segment.",
text = text,
chart_labels = list(
pc1 = paste0("PC1 — ", shared$pc_var_pct[1], "% of variance"),
pc2 = paste0("PC2 — ", shared$pc_var_pct[2], "% of variance")
),
data = list(cluster_map = shared$map_df)
)
}