Standard Clustering
Executive Summary

Executive Summary

3 natural segments across 450 observations

Observations
450
Features Used
4
Clusters Found
3
Avg Silhouette
0.857
Largest Cluster
150 rows (33.3%)
Separation Quality
strong
The data separates into 3 natural segments, chosen by the data itself (highest average silhouette width, 0.857 — strong separation). C1: High Sessions per Week, Low Tenure Days — 150 rows (33.3%); C2: High Monthly Spend, High Tenure Days — 150 rows (33.3%); C3: High Support Tickets, Low Sessions per Week — 150 rows (33.3%). Each segment is named by the features that most distinguish it from the overall average.
Interpretation

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.

Overview

Analysis Overview

K-means segmentation of 450 observations on 4 features.

N Observations450
N Features4
K Chosen3
Avg Silhouette0.857
Interpretation

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 Preparation

Data Quality

Column typing, imputation, standardization, and exclusions.

Initial Rows450
Final Rows450
Rows Removed0
Features Dropped0
Interpretation

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.

Visualization

How Many Clusters?

Average silhouette width for each candidate k.

Interpretation

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.

Visualization

Segment Sizes

How the observations distribute across the discovered segments.

Interpretation

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.

Data Table

Segment Profiles

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

ClusterFeatureCluster MeanOverall MeanDifference PCT
C1: High Sessions per Week, Low Tenure DaysMonthly Spend41.26139.4-70.4
C1: High Sessions per Week, Low Tenure DaysSessions per Week2011.9667.2
C1: High Sessions per Week, Low Tenure DaysSupport Tickets1.973.962-50.3
C1: High Sessions per Week, Low Tenure DaysTenure Days197.8767.8-74.2
C2: High Monthly Spend, High Tenure DaysMonthly Spend258.1139.485.2
C2: High Monthly Spend, High Tenure DaysSessions per Week11.9711.960.1
C2: High Monthly Spend, High Tenure DaysSupport Tickets0.9063.962-77.1
C2: High Monthly Spend, High Tenure DaysTenure Days1404767.882.8
C3: High Support Tickets, Low Sessions per WeekMonthly Spend118.8139.4-14.8
C3: High Support Tickets, Low Sessions per WeekSessions per Week3.91311.96-67.3
C3: High Support Tickets, Low Sessions per WeekSupport Tickets9.0123.962127.4
C3: High Support Tickets, Low Sessions per WeekTenure Days701.8767.8-8.6
Interpretation

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.

Visualization

Segment Map

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

Interpretation

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

Methodology

Statistical methodology and diagnostics for Cluster Analysis — Natural Segments

Statistical Method

Cluster Analysis — Natural Segments

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.

Data
N = 450 observations
Assumptions
  • Segments are roughly spherical in standardized feature space (k-means geometry)
  • Columns are numeric or cleanly convertible
  • Every feature deserves equal weight after standardization
Limitations
  • 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
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

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_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 — 60+ validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No Signup Sign Up Free

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