Executive Summary
6 natural segments across 200 observations
The 200 customers separate into 6 natural segments chosen by silhouette width (0.427, reasonable separation). The largest segment—C1: High Age—holds 45 rows (22.5%); the smallest—C6: Low Annual Income, Low Spending Score—holds 21 rows (10.5%). C2 (High Spending Score, High Income) comprises 39 rows (19.5%), and C5 (Low Income, High Spending Score) comprises 24 rows (12%), capturing the two highest-value behavioral patterns. Each segment is named by the features that most distinguish it from the overall average.
Analysis Overview
K-means segmentation of 200 observations on 3 features.
K-means clustering segmented 200 mall customers using three standardized features: Annual Income (k$), Spending Score (1-100), and Age. The algorithm tested cluster counts from 2 to 8, scoring each by average silhouette width—a measure of how tightly observations belong to their assigned cluster. The data itself selected k = 6 segments, achieving an average silhouette width of 0.427 (reasonable separation). All 200 rows were usable; no observations were removed and all three features contributed equally to distance calculations after standardization.
Data Quality
Column typing, imputation, standardization, and exclusions.
All 200 rows were retained and all three mapped columns (Annual Income, Spending Score, Age) were usable. Missing values were imputed using each column's median before standardization. Features were then standardized to mean 0 and standard deviation 1 so no single dimension would dominate the clustering distance metric. This ensures income, spending behavior, and age contribute equally to segment assignment, even though they operate on different original scales.
How Many Clusters?
Average silhouette width for each candidate k.
k = 6 achieved the highest average silhouette width at 0.427 (reasonable separation), outperforming k = 8 (0.42), the runner-up. The silhouette scores rose from k = 2 (0.335) through k = 6, then declined slightly at k = 7 (0.417) before recovering marginally at k = 8. The peak at k = 6 is modest but clear, indicating that six segments represent a real property of the data rather than an arbitrary choice. A flatter profile would suggest multiple cluster counts describe the population equally well; this one does not.
Segment Sizes
How the observations distribute across the discovered segments.
The short answer
The mall customer base segments into six distinct income–spending–age profiles, with highly uneven representation. The largest segment (High Age customers) holds 45 rows, while the smallest (Low Income, Low Spending) holds 21 rows — a gap of 2.1x. No single group dominates overwhelmingly, but the two largest segments account for 42% of the customer base, suggesting age and high spending–income profiles are the most common shopping personas.
The detail
Segment membership across the six clusters: C1 (High Age) contains 45 rows (22.5%); C2 (High Spending Score, High Annual Income) contains 39 rows (19.5%); C3 (Low Age) contains 38 rows (19%); C4 (Low Spending Score, High Annual Income) contains 33 rows (16.5%); C5 (Low Annual Income, High Spending Score) contains 24 rows (12%); C6 (Low Annual Income, Low Spending Score) contains 21 rows (10.5%). The largest–to–smallest ratio is 2.1x. The distribution is uneven but not heavily skewed toward any one profile.
What this can't tell you
Small segment profiles (C5 and C6, under 25 rows each) are more prone to noise in their average characteristics; consider them directional rather than precise. To validate whether these segments persist and respond differently to marketing or operations, a larger or longitudinal sample would strengthen confidence in segment stability.
Segment Profiles
Per-segment feature averages in original units vs the overall average.
| Cluster | Feature | Cluster Mean | Overall Mean | Difference PCT |
|---|---|---|---|---|
| C1: High Age | Annual Income (k$) | 54.27 | 60.56 | -10.4 |
| C1: High Age | Spending Score (1-100) | 49.07 | 50.2 | -2.3 |
| C1: High Age | Age | 56.33 | 38.85 | 45 |
| C2: High Spending Score (1-100), High Annual Income (k$) | Annual Income (k$) | 86.54 | 60.56 | 42.9 |
| C2: High Spending Score (1-100), High Annual Income (k$) | Spending Score (1-100) | 82.13 | 50.2 | 63.6 |
| C2: High Spending Score (1-100), High Annual Income (k$) | Age | 32.69 | 38.85 | -15.8 |
| C3: Low Age | Annual Income (k$) | 57.58 | 60.56 | -4.9 |
| C3: Low Age | Spending Score (1-100) | 47.79 | 50.2 | -4.8 |
| C3: Low Age | Age | 26.68 | 38.85 | -31.3 |
| C4: Low Spending Score (1-100), High Annual Income (k$) | Annual Income (k$) | 88.94 | 60.56 | 46.9 |
| C4: Low Spending Score (1-100), High Annual Income (k$) | Spending Score (1-100) | 16.97 | 50.2 | -66.2 |
| C4: Low Spending Score (1-100), High Annual Income (k$) | Age | 41.94 | 38.85 | 8 |
| C5: Low Annual Income (k$), High Spending Score (1-100) | Annual Income (k$) | 25.83 | 60.56 | -57.3 |
| C5: Low Annual Income (k$), High Spending Score (1-100) | Spending Score (1-100) | 76.92 | 50.2 | 53.2 |
| C5: Low Annual Income (k$), High Spending Score (1-100) | Age | 25.25 | 38.85 | -35 |
| C6: Low Annual Income (k$), Low Spending Score (1-100) | Annual Income (k$) | 26.29 | 60.56 | -56.6 |
| C6: Low Annual Income (k$), Low Spending Score (1-100) | Spending Score (1-100) | 19.38 | 50.2 | -61.4 |
| C6: Low Annual Income (k$), Low Spending Score (1-100) | Age | 45.52 | 38.85 | 17.2 |
C4 (Low Spending Score, High Income) shows the single most distinctive trait: a Spending Score of 16.97 versus 50.2 overall (−66.2% difference). C2 (High Spending Score, High Income) averages 82.128 on Spending Score (+63.6%) and 86.538 on Income (+42.9%), marking affluent, engaged shoppers. C5 (Low Income, High Spending Score) averages 25.833 on Income (−57.3%) but 76.917 on Spending Score (+53.2%), identifying budget-conscious but behaviorally active customers. C1 (High Age) averages 56.333 years (+45% versus 38.85 overall). C3 (Low Age) averages 26.684 years (−31.3%), and C6 averages 26.286 on Income (−57.3%), capturing low-income, low-engagement older and younger cohorts.
Segment Map
2-D PCA projection of all features, colored by segment.
The 2-D PCA projection captures 77.6% of variance across the three features (PC1: 44.3%, PC2: 33.3%), showing visually distinct color islands that confirm the segmentation. Segments form reasonably separated clusters in this reduced space, supporting the silhouette score of 0.427. The visual separation aligns with the quantitative finding: customers do cluster into recognizable groups by income, spending behavior, and age. Some overlap is expected in a 2-D view of 3-D data; the silhouette metric already accounts for this by measuring separation in the full feature space.
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)
)
}