Executive Summary
3 natural segments across 600 observations
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.
Analysis Overview
Hierarchical (Ward.D2) segmentation of 600 observations on 5 features.
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 Quality
Column typing, imputation, standardization, and exclusions.
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.
Where the Tree Cuts
Merge height of the final merges — the dendrogram's scree.
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.
How Many Clusters?
Average silhouette width for each candidate cut of the tree.
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.
Segment Map
2-D PCA projection of all features, colored by segment.
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.
Segment Profiles
Per-segment feature averages in standard-deviation units.
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.
Segment Sizes
Every segment sized, characterized, and illustrated with named members.
| Cluster | N | Share PCT | Top Features | Example Members |
|---|---|---|---|---|
| C1: High Sessions per Week, Low Tenure Days | 300 | 50 | High Sessions per Week, Low Tenure Days | CUST-0002, CUST-0003, CUST-0006 |
| C2: High Support Tickets, High Items per Order | 180 | 30 | High Support Tickets, High Items per Order | CUST-0001, CUST-0004, CUST-0013 |
| C3: High Monthly Spend, High Tenure Days | 120 | 20 | High Monthly Spend, High Tenure Days | CUST-0005, CUST-0008, CUST-0021 |
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
Statistical methodology and diagnostics for Hierarchical Clustering — segments with a family tree
Statistical Method
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.
- Euclidean distance on standardized features is a meaningful similarity measure
- Columns are numeric or cleanly convertible
- Every feature deserves equal weight after standardization
- 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
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_rowsStep 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) <- NULLMost 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'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)
)
}