Standard Clustering
Executive Summary

Executive Summary

6 natural segments across 200 observations

Observations
200
Features Used
3
Clusters Found
6
Avg Silhouette
0.427
Largest Cluster
45 rows (22.5%)
Separation Quality
reasonable
The data separates into 6 natural segments, chosen by the data itself (highest average silhouette width, 0.427 — reasonable separation). C1: High Age — 45 rows (22.5%); C2: High Spending Score (1-100), High Annual Income (k$) — 39 rows (19.5%); C3: Low Age — 38 rows (19%); C4: Low Spending Score (1-100), High Annual Income (k$) — 33 rows (16.5%); C5: Low Annual Income (k$), High Spending Score (1-100) — 24 rows (12%); C6: Low Annual Income (k$), Low Spending Score (1-100) — 21 rows (10.5%). Each segment is named by the features that most distinguish it from the overall average.
What this means

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.

Overview

Analysis Overview

K-means segmentation of 200 observations on 3 features.

N Observations200
N Features3
K Chosen6
Avg Silhouette0.427
What this means

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 Preparation

Data Quality

Column typing, imputation, standardization, and exclusions.

Initial Rows200
Final Rows200
Rows Removed0
Features Dropped0
What this means

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.

Visualization

How Many Clusters?

Average silhouette width for each candidate k.

What this means

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.

Visualization

Segment Sizes

How the observations distribute across the discovered segments.

What this means

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.

Data Table

Segment Profiles

Per-segment feature averages in original units vs the overall average.

ClusterFeatureCluster MeanOverall MeanDifference PCT
C1: High AgeAnnual Income (k$)54.2760.56-10.4
C1: High AgeSpending Score (1-100)49.0750.2-2.3
C1: High AgeAge56.3338.8545
C2: High Spending Score (1-100), High Annual Income (k$)Annual Income (k$)86.5460.5642.9
C2: High Spending Score (1-100), High Annual Income (k$)Spending Score (1-100)82.1350.263.6
C2: High Spending Score (1-100), High Annual Income (k$)Age32.6938.85-15.8
C3: Low AgeAnnual Income (k$)57.5860.56-4.9
C3: Low AgeSpending Score (1-100)47.7950.2-4.8
C3: Low AgeAge26.6838.85-31.3
C4: Low Spending Score (1-100), High Annual Income (k$)Annual Income (k$)88.9460.5646.9
C4: Low Spending Score (1-100), High Annual Income (k$)Spending Score (1-100)16.9750.2-66.2
C4: Low Spending Score (1-100), High Annual Income (k$)Age41.9438.858
C5: Low Annual Income (k$), High Spending Score (1-100)Annual Income (k$)25.8360.56-57.3
C5: Low Annual Income (k$), High Spending Score (1-100)Spending Score (1-100)76.9250.253.2
C5: Low Annual Income (k$), High Spending Score (1-100)Age25.2538.85-35
C6: Low Annual Income (k$), Low Spending Score (1-100)Annual Income (k$)26.2960.56-56.6
C6: Low Annual Income (k$), Low Spending Score (1-100)Spending Score (1-100)19.3850.2-61.4
C6: Low Annual Income (k$), Low Spending Score (1-100)Age45.5238.8517.2
What this means

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.

Visualization

Segment Map

2-D PCA projection of all features, colored by segment.

What this means

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.

Rate this report Was this the answer you needed?
The exact source that produced this report — yours to keep, read, and re-run.
Download PDF
How this was computed method · R source · citation
The code that did it

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_max

Silhouette 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) <- NULL

Step 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&#x27;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)
  )
}
Your data has more stories to tell.Run any analysis on your own data — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No SignupSign Up Free

Cite this analysis

Report an Issue

Tell us what's wrong. You'll get a free re-run of this analysis so you can try again with different parameters. If the re-run still doesn't meet your expectations, we'll refund your credits.

Want to run this analysis on your own data? Upload CSV — Free Analysis See Pricing