Standard Hierarchical Clustering
Executive Summary

Executive Summary

3 natural segments across 600 observations

Observations
600
Features Used
5
Clusters Found
3
Avg Silhouette
0.786
Separation Quality
strong
Largest Cluster
300 rows (50%)
Best-Fit Linkage
average
The cluster tree separates the data into 3 natural segments (average silhouette 0.786 — strong separation). The tree's own largest merge gap points to the same count (3), so the cut is doubly supported. C1: High Sessions per Week, Low Tenure Days — 300 rows (50%); C2: High Support Tickets, High Items per Order — 180 rows (30%); C3: High Monthly Spend, High Tenure Days — 120 rows (20%). The largest segment holds 50% of all rows. The feature that separates the segments most is Tenure Days — its cluster averages span 2.58 standard deviations.
Suggested Interpretation

The short answer

The data separates into 3 natural segments with strong separation (average silhouette 0.786). C1 is the largest at 300 rows (50%), followed by C2 at 180 rows (30%) and C3 at 120 rows (20%). Tenure Days is the feature that separates the segments most, with cluster averages spanning 2.58 standard deviations.

The detail

Clusters: 3 (silhouette-chosen, confirmed by largest merge gap at k = 3). Separation quality: strong (0.786 average silhouette, above 0.5 threshold). C1: High Sessions per Week, Low Tenure Days — 300 rows (50%); C2: High Support Tickets, High Items per Order — 180 rows (30%); C3: High Monthly Spend, High Tenure Days — 120 rows (20%). Tenure Days distinguishes segments most (cluster means range 2.58 standard deviations).

What this can't tell you

The silhouette score confirms separation strength but does not assess whether the segments are actionable for a specific business use case.

Overview

Analysis Overview

Hierarchical (Ward.D2) segmentation of 600 observations on 5 features.

N Observations600
N Features5
K Chosen3
Avg Silhouette0.786
Best Linkageaverage
Suggested Interpretation

The short answer

The analysis standardizes 5 customer features and builds a complete hierarchical tree before deciding on a cluster count, comparing three linkage rules by how well they preserve distances. Ward.D2 linkage is used for the final segmentation because it produces compact, interpretable groups, even though average linkage preserved distances most faithfully (0.976 cophenetic correlation). The tree is cut at k = 3 clusters based on silhouette score.

The detail

All 600 observations on 5 features (Monthly Spend, Sessions per Week, Support Tickets, Tenure Days, Items per Order) were standardized to mean 0, standard deviation 1. Euclidean distances were computed and an agglomerative tree built using Ward.D2 linkage. Three linkage rules were compared by cophenetic correlation: ward.D2 0.961, complete 0.952, average 0.976. The tree was cut at k = 3, chosen by average silhouette width (0.786, strong separation) over k = 2..10.

What this can't tell you

The comparison of linkage rules by cophenetic correlation is diagnostic only; it shows which linkage best preserves the original distance structure but does not determine which produces the most actionable segments. Ward.D2 was chosen for interpretability, not distance preservation.

Data Preparation

Data Quality

Column typing, imputation, standardization, and exclusions.

Initial Rows600
Final Rows600
Rows Removed0
Features Dropped0
Suggested Interpretation

The short answer

All 600 rows were retained and all 5 feature columns were usable. Missing values were imputed using each column's median, and all features were standardized so no single column would dominate the distance calculation.

The detail

Initial rows: 600; final rows: 600; rows removed: 0. Features dropped: 0. All mapped feature columns (Monthly Spend, Sessions per Week, Support Tickets, Tenure Days, Items per Order) were numeric and retained. Median imputation was applied to missing values. Standardization (mean 0, standard deviation 1) was applied before distance calculation. Rows are identified by Customer ID.

What this can't tell you

The analysis does not report how many missing values were imputed per column or whether any single feature had systematic missingness that might bias the tree.

Visualization

Where the Tree Cuts

Merge height of the final merges — the dendrogram's scree.

Suggested Interpretation

The short answer

The dendrogram's final merges show a dramatic jump from 3 clusters to 2 clusters (from 7.156 to 43.324 Ward distance), confirming k = 3 is the natural cut point where the tree resists further merging. This agreement between the merge-height gap and the silhouette-chosen k = 3 provides dual support for the segment count.

The detail

Merge heights for the last 30 merges: at 3 clusters remaining, height is 7.156; at 2 clusters, height jumps to 43.324. This 6.05-unit gap is the widest in the scree. For comparison, merges from 4 down to 10 clusters range 3.856 to 5.064, and merges below 10 clusters are all below 3.995. The sharp upward pattern confirms that forcing beyond 3 clusters requires bridging groups far apart in the feature space.

What this can't tell you

The merge height is a distance metric specific to Ward linkage; it does not rank the business value or stability of the segments, only their structural separation in the tree.

Visualization

How Many Clusters?

Average silhouette width for each candidate cut of the tree.

Suggested Interpretation

The short answer

The silhouette score peaks decisively at k = 3 with 0.786, well into the strong separation range (above 0.5). This is a clear, unambiguous signal: 3 clusters is the data's natural granularity. The runner-up, k = 2, scores 0.628, but the drop from 3 to 4 (also 0.628) confirms that 3 is the unique optimum.

The detail

Average silhouette widths by cluster count: k = 2 yields 0.628, k = 3 yields 0.786, k = 4 yields 0.628, k = 5 yields 0.3, k = 6 yields 0.287, and k = 7 through k = 10 range from 0.186 to 0.193. The peak at k = 3 (0.786) stands alone; no other value approaches it. The sharp drop to k = 5 (0.3) and the flat floor at k ≥ 7 indicate that finer cuts fragment real structure into noise.

What this can't tell you

Silhouette score measures separation and cohesion, not interpretability or business relevance. A high silhouette does not guarantee that the 3 segments are actionable or stable across new data — only that they are statistically well-separated in this snapshot.

Visualization

Segment Map

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

Suggested Interpretation

The short answer

The 2-D PCA projection captures 95.1% of the variance in the standardized features and shows three visually distinct color islands, confirming the silhouette-based segmentation. C1 clusters tightly in the upper right, C2 in the upper left, and C3 in the lower left, with minimal overlap.

The detail

PC1 accounts for 63.3% of variance; PC2 accounts for 31.8%. Together they explain 95.1% of total variance. C1 (High Sessions per Week, Low Tenure Days) occupies positive PC1 values (e.g., 1.554, 1.569, 2.007). C2 (High Support Tickets, High Items per Order) occupies negative PC1 and positive PC2 (e.g., -2.191, -2.511). C3 (High Monthly Spend, High Tenure Days) occupies negative PC1 and negative PC2 (e.g., -1.293, -1.614). Separation is strong, consistent with the average silhouette of 0.786.

What this can't tell you

PCA is a linear projection; if segment separation exists in nonlinear structure or in dimensions beyond PC1 and PC2, this view may understate it. However, 95.1% variance retention makes that unlikely here.

Visualization

Segment Profiles

Per-segment feature averages in standard-deviation units.

Suggested Interpretation

The short answer

C3 (High Monthly Spend, High Tenure Days) is the most sharply defined, sitting 1.76 standard deviations above the overall average on Monthly Spend and 1.74 standard deviations above on Tenure Days. Tenure Days is the feature that separates all three segments most, with no cluster scoring near 0 on this feature.

The detail

C1 profile: Monthly Spend −0.81, Sessions per Week 0.89, Support Tickets −0.55, Tenure Days −0.84, Items per Order −0.76. C2 profile: Monthly Spend 0.18, Sessions per Week −1.28, Support Tickets 1.45, Tenure Days 0.23, Items per Order 1.36. C3 profile: Monthly Spend 1.76, Sessions per Week −0.3, Support Tickets −0.79, Tenure Days 1.74, Items per Order −0.15. Strongest cells: C3 Monthly Spend (1.76) and C3 Tenure Days (1.74). Tenure Days z-scores across clusters: C1 −0.84, C2 0.23, C3 1.74 (span 2.58 standard deviations).

What this can't tell you

Feature interactions within clusters are not shown; a feature near 0 in one cluster may still interact meaningfully with other features in that cluster's profile.

Data Table

Segment Sizes

Every segment sized, characterized, and illustrated with named members.

ClusterNShare PCTTop FeaturesExample Members
C1: High Sessions per Week, Low Tenure Days30050High Sessions per Week, Low Tenure DaysCUST-0002, CUST-0003, CUST-0006
C2: High Support Tickets, High Items per Order18030High Support Tickets, High Items per OrderCUST-0001, CUST-0004, CUST-0013
C3: High Monthly Spend, High Tenure Days12020High Monthly Spend, High Tenure DaysCUST-0005, CUST-0008, CUST-0021
Suggested Interpretation

The short answer

The three segments are unequally sized: C1 holds 300 rows (50%), C2 holds 180 rows (30%), and C3 holds 120 rows (20%). The largest segment is 2.5 times the size of the smallest. All three sizes are substantial enough for reliable profile estimation, though C3's smaller base warrants caution when extrapolating its feature averages.

The detail

C1: High Sessions per Week, Low Tenure Days — n = 300, share 50%, examples CUST-0002, CUST-0003, CUST-0006. C2: High Support Tickets, High Items per Order — n = 180, share 30%, examples CUST-0001, CUST-0004, CUST-0013. C3: High Monthly Spend, High Tenure Days — n = 120, share 20%, examples CUST-0005, CUST-0008, CUST-0021. C1 dominates by count; C3 is the smallest. The 2.5x ratio (300 ÷ 120) reflects uneven natural clustering in the data.

What this can't tell you

Small segments can represent high-value niches or outliers; size alone does not determine business importance. Period-to-period stability of these shares is not assessed here.

Methodology

Methodology

Statistical methodology and diagnostics for Hierarchical Clustering — segments with a family tree

Statistical Method

Hierarchical Clustering — segments with a family tree

Standard-library analysis: agglomerative hierarchical clustering on standardized features. Unlike k-means, the full cluster tree is built first — structure at every scale — and only then cut, with the number of segments chosen by average silhouette width and cross-checked against the tree's own merge-height gaps. Ward.D2 linkage for compact interpretable groups, three linkages compared by cophenetic correlation, profiles as a z-score heatmap, a 2-D map, and every segment illustrated with named example rows. Works on any dataset: map 2 or more numeric columns plus a label column.

Data
N = 600 observations
Assumptions
  • Euclidean distance on standardized features is a meaningful similarity measure
  • Columns are numeric or cleanly convertible
  • Every feature deserves equal weight after standardization
Limitations
  • Agglomerative merges are greedy and never undone — an early bad merge propagates
  • Ward.D2 favors compact, similar-sized groups; elongated or density-based shapes can be split
  • The full distance matrix limits scale — beyond 1,500 rows the analysis runs on a reproducible sample
  • 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

Hierarchical Clustering — Segments With a Family Tree

Agglomerative hierarchical clustering on standardized features: Ward.D2 linkage builds the tree, three linkages are compared by cophenetic correlation, the number of clusters is chosen by average silhouette width, and each segment is sized, profiled, and mapped in 2-D.

Why This Method?

Where k-means demands the number of clusters up front, hierarchical clustering builds the full merge tree first — structure at every scale — and only then cuts it. The merge-height scree shows WHERE the tree naturally separates, and nested sub-segments stay visible instead of being averaged away.

What This Analysis Covers

  • Ward.D2 linkage tree on standardized euclidean distances
  • Linkage comparison (ward.D2 / complete / average) by cophenetic correlation
  • Data-driven cut: average silhouette width over k = 2..min(10, n/20)
  • Merge-height scree (the last ~30 merges), cluster profiles as a z-score

heatmap, a 2-D PCA map, and a sizes table with named example members

Standard Library

Platform standard-library module (LAT-1441): runs on ANY dataset via the semantic mapping {base_1..base_N, label}. 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
  #   label_name        $ character — humanized name of the label column
  #   labels_vec        $ character, length final_rows — row names (label col)
  #   sampled / sample_n$ whether the 1500-row cap fired, and to how many rows
  #   coph_df           $ data.frame(linkage, cophenetic_correlation)
  #   best_linkage      $ character — linkage preserving distances best
  #   k_star / sil_star $ chosen k and its average silhouette width
  #   sil_quality       $ character — weak / reasonable / strong
  #   k_searched        $ integer vector — the k values evaluated
  #   sil_df            $ data.frame(k, avg_silhouette) — chart data
  #   tree_k            $ integer — cut suggested by the largest merge gap
  #   merge_heights_df  $ data.frame(clusters_remaining, merge_height) — scree
  #   assignments       $ integer vector (1 = largest cluster)
  #   cluster_labels    $ character(k_star) — auto-generated cluster names
  #   sizes_table_df    $ data.frame(cluster, n, share_pct, top_features,
  #                       example_members)
  #   share_pct         $ numeric(k_star)
  #   profiles_df       $ data.frame(cluster, feature, mean_z) — LONG heatmap
  #   most_sep_feature  $ character — feature separating clusters most
  #   most_sep_range    $ numeric — its cluster-mean range in z units
  #   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 + the label column

initial_rows <- nrow(df)
  feat_cols <- grep("^base_[0-9]+$", names(df), value = TRUE)
  feat_cols <- feat_cols[order(as.integer(sub("^base_", "", feat_cols)))]
  if (length(feat_cols) < 2) {
    stop("column_mapping must map at least two numeric columns(base_1, base_2)")
  }
  feature_names <- setNames(humanize_semantic(feat_cols, col_map), feat_cols)
  label_name <- humanize_semantic("label", col_map)
  labels_raw <- if ("label" %in% names(df)) as.character(df$label) else
    rep(NA_character_, initial_rows)
  blank_lab <- is.na(labels_raw) | trimws(labels_raw) == ""
  labels_raw[blank_lab] <- paste0("Row ", which(blank_lab))

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(
      "Hierarchical clustering needs at least two usable numeric columns; only ",
      length(used_features), " of the mapped columns(",
      paste(feature_names[feat_cols], collapse = ", "),
      ") remained after cleaning."))
  }
  X <- df[, used_features, drop = FALSE]
  labels_vec <- labels_raw
  if (nrow(X) < 30) {
    stop(sprintf(
      "Only %d usable rows across %s — hierarchical clustering needs at least 30 rows to find stable segments.",
      nrow(X), paste(feature_names[used_features], collapse = ", ")))
  }
  hn_used <- unname(feature_names[used_features])

Step 2b: Cap rows at 1500 (linkage builds an n-by-n distance matrix)

sampled <- FALSE
  if (nrow(X) > 1500) {
    set.seed(42)
    keep_idx <- sort(sample(nrow(X), 1500))
    X <- X[keep_idx, , drop = FALSE]
    labels_vec <- labels_vec[keep_idx]
    sampled <- TRUE
  }
  final_rows <- nrow(X)
  sample_n <- final_rows
  rows_removed <- initial_rows - final_rows
  n <- final_rows

Step 3: Standardize; build the tree under three linkages;

compare them by cophenetic correlation

Xs <- scale(as.matrix(X))
  D <- dist(Xs, method = "euclidean")
  hc_ward <- hclust(D, method = "ward.D2")
  hc_comp <- hclust(D, method = "complete")
  hc_avg  <- hclust(D, method = "average")
  coph <- vapply(list(hc_ward, hc_comp, hc_avg), function(h) {
    suppressWarnings(tryCatch(cor(D, stats::cophenetic(h)),
                              error = function(e) NA_real_))
  }, numeric(1))
  linkage_names <- c("ward.D2", "complete", "average")
  coph_df <- data.frame(linkage = linkage_names,
                        cophenetic_correlation = round(coph, 3),
                        stringsAsFactors = FALSE)

LAT-1445 guard: filter NA before which.max

coph_ok <- which(!is.na(coph))
  best_linkage <- if (length(coph_ok) > 0) {
    linkage_names[coph_ok][which.max(coph[coph_ok])]
  } else "ward.D2"

Step 4: Choose k by average silhouette width over the Ward tree

k_max <- max(2L, min(10L, floor(n / 20)))
  k_searched <- 2:k_max
  sil_by_k <- vapply(k_searched, function(k) {
    cl <- tryCatch(cutree(hc_ward, k = k), error = function(e) NULL)
    if (is.null(cl) || length(unique(cl)) < 2) return(NA_real_)
    s <- tryCatch(cluster::silhouette(cl, 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 cut of the cluster tree 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.5) "strong"
                 else if (sil_star >= 0.25) "reasonable"
                 else "weak"
  sil_df <- data.frame(k = k_searched[valid],
                       avg_silhouette = round(sil_by_k[valid], 3),
                       stringsAsFactors = FALSE)

Step 5: Merge-height scree (last ~30 merges) + the tree's own cut

The merge that reduces the data to c clusters has height h[n - c]; a wide gap between successive heights marks a natural cut.

m_steps <- min(30L, n - 1L)
  cvals <- seq_len(m_steps)
  merge_heights_df <- data.frame(
    clusters_remaining = cvals,
    merge_height = round(hc_ward$height[n - cvals], 3),
    stringsAsFactors = FALSE
  )
  kg <- 2:min(15L, n - 1L)
  gaps <- hc_ward$height[n - kg + 1] - hc_ward$height[n - kg]
  gap_ok <- which(!is.na(gaps))
  tree_k <- if (length(gap_ok) > 0) kg[gap_ok][which.max(gaps[gap_ok])] else NA_integer_

Step 6: Cut at k_star on ward.D2; relabel clusters by size

cl_raw <- cutree(hc_ward, k = k_star)
  tab <- tabulate(cl_raw, nbins = k_star)
  ord_sz <- order(-tab)
  remap <- integer(k_star); remap[ord_sz] <- seq_len(k_star)
  assignments <- remap[cl_raw]
  sizes <- tabulate(assignments, nbins = k_star)
  share_pct <- round(100 * sizes / n, 1)

Step 7: Standardized centroids -> names + profiles + separation

centers <- t(vapply(seq_len(k_star), function(ci) {
    colMeans(Xs[assignments == ci, , drop = FALSE])
  }, numeric(ncol(Xs))))
  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)

Cluster-mean heatmap in z units (LONG: cluster x feature)

prof_rows <- list()
  for (ci in seq_len(k_star)) {
    for (fi in seq_along(used_features)) {
      prof_rows[[length(prof_rows) + 1]] <- data.frame(
        cluster = cluster_labels[ci],
        feature = hn_used[fi],
        mean_z = round(centers[ci, fi], 2),
        stringsAsFactors = FALSE
      )
    }
  }
  profiles_df <- do.call(rbind, prof_rows)
  rownames(profiles_df) <- NULL

Most separating feature: widest spread of cluster means in z units

sep_range <- apply(centers, 2, function(cv) diff(range(cv)))
  sep_ok <- which(!is.na(sep_range))
  if (length(sep_ok) > 0) {
    most_sep_feature <- hn_used[sep_ok][which.max(sep_range[sep_ok])]
    most_sep_range <- round(max(sep_range[sep_ok]), 2)
  } else {
    most_sep_feature <- hn_used[1]
    most_sep_range <- NA_real_
  }

Step 8: Sizes table with named example members (the label column)

sizes_table_df <- data.frame(
    cluster = cluster_labels,
    n = sizes,
    share_pct = share_pct,
    top_features = unname(cluster_desc),
    example_members = vapply(seq_len(k_star), function(ci) {
      paste(head(labels_vec[assignments == ci], 3), collapse = ", ")
    }, character(1)),
    stringsAsFactors = FALSE
  )

Step 9: 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),
    `Separation Quality`  = sil_quality,
    `Largest Cluster`     = paste0(format(sizes[1], big.mark = ","),
                                   " rows(", share_pct[1], "%)"),
    `Best-Fit Linkage`    = best_linkage
  )

  json_output <- list(
    answer = paste0(
      "Hierarchical clustering(Ward.D2 on ", length(used_features),
      " standardized features, ", 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 = "; "), ". ",
      "The feature separating the segments most is ", most_sep_feature,
      if (!is.na(most_sep_range)) paste0(
        " (cluster means span ", most_sep_range, " standard deviations)") else "",
      ". Among the linkages compared, ", best_linkage,
      " preserved the original distances best(cophenetic correlation)."
    ),
    cards = lapply(
      c("tldr", "overview", "preprocessing", "merge_heights",
        "silhouette_by_k", "cluster_map", "cluster_profiles",
        "cluster_sizes_table"),
      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,
    label_name = label_name, labels_vec = labels_vec,
    sampled = sampled, sample_n = sample_n,
    coph_df = coph_df, best_linkage = best_linkage,
    k_star = k_star, sil_star = sil_star, sil_quality = sil_quality,
    k_searched = k_searched[valid], sil_df = sil_df, tree_k = tree_k,
    merge_heights_df = merge_heights_df,
    assignments = assignments, cluster_labels = cluster_labels,
    sizes_table_df = sizes_table_df, share_pct = share_pct,
    profiles_df = profiles_df,
    most_sep_feature = most_sep_feature, most_sep_range = most_sep_range,
    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(mz))
  standout_note <- if (length(ok) > 0) {
    top <- pdf[ok[which.max(abs(mz[ok]))], ]
    paste0("The single strongest cell: ", top$cluster, " sits ",
           abs(top$mean_z), " standard deviations ",
           if (top$mean_z > 0) "above" else "below",
           " the overall average on ", top$feature, ". ")
  } else ""
  text <- paste0(
    "Each cell shows how far one segment&#x27;s average sits from the overall ",
    "average on one feature, in standard deviations(z-scores, so every ",
    "feature is on the same scale). Values near 0 mean the feature does ",
    "not distinguish the segment; values beyond about ±1 mark a ",
    "defining trait. ", standout_note,
    "The feature separating the segments most overall is ",
    shared$most_sep_feature, "."
  )
  list(
    title = "Segment Profiles",
    description = "Per-segment feature averages in standard-deviation units.",
    text = text,
    chart_labels = list(
      cluster = "Segment",
      feature = "Feature",
      mean_z = "Mean(z-score)"
    ),
    data = list(cluster_profiles = pdf)
  )
}

# Card: cluster_sizes_table (table)
card_cluster_sizes_table <- function(shared, df, params) {
  st <- shared$sizes_table_df
  sizes <- st$n
  balance_note <- if (min(sizes) > 0 && max(sizes) / min(sizes) <= 2) {
    "The segments are fairly balanced in size."
  } else {
    paste0("Sizes are uneven — the largest segment holds ",
           round(max(sizes) / max(1, min(sizes)), 1),
           "x the rows of the smallest. Small segments can still be the ",
           "most valuable ones(niche or outlier groups), but treat their ",
           "profile averages with more caution.")
  }
  text <- paste0(
    "Each row is one discovered segment: its size, share, the features ",
    "that most distinguish it, and up to three example members named by ",
    "the ", shared$label_name, " column. ",
    paste(sprintf("%s holds %s rows(%s%%)", st$cluster,
                  format(sizes, big.mark = ",", trim = TRUE), st$share_pct),
          collapse = "; "), ". ", balance_note
  )
  list(
    title = "Segment Sizes",
    description = "Every segment sized, characterized, and illustrated with named members.",
    text = text,
    data = list(cluster_sizes_table = st)
  )
}
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 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