Executive Summary
Latent factors behind 10 variables across 2,240 observations
Factor analysis identified 3 latent spending factors across 2,240 customers: Factor 1 (seafood and fruit spending, 29.3% variance), Factor 2 (meat and wine spending, 11.7% variance), and Factor 3 (teenagers at home and meat spending, 6.4% variance). Together they explain 47.4% of total variation. The likelihood-ratio fit test (p below 0.001) indicates statistically significant structure at 3 factors, though some shared variance remains unmodeled—treat these as the dominant patterns rather than the complete picture.
Analysis Overview
Maximum-likelihood factor analysis of 10 standardized variables across 2,240 observations.
Maximum-likelihood factor analysis with varimax rotation models the shared variance among 10 standardized spending and household variables as the product of latent causes, separating common structure from variable-specific noise. Across 2,240 observations, the method tested models of 1 to 3 factors and selected 3 via likelihood-ratio fit. Unlike PCA, which compresses all variance, factor analysis asks: what unobserved causes drive the correlations we observe? The result isolates three latent spending patterns that together explain 47.4% of total variation, leaving the remainder as unique signal and noise tied to individual variables.
Data Quality
Column typing, imputation, and exclusions before the factor model.
The short answer
All 2,240 observations were retained with no row exclusions. Missing values were imputed using each column's median, and all 10 numeric variables were standardized to mean 0 and variance 1, ensuring that scale differences (e.g., Income vs. Kidhome) do not distort the factor structure.
The detail
Initial dataset: 2,240 rows, final rows used: 2,240 (rows removed: 0). All mapped columns were numeric and usable. Standardization was applied uniformly across Income, Recency, MntWines, MntFruits, MntMeatProducts, MntFishProducts, MntSweetProducts, MntGoldProds, Kidhome, and Teenhome. Median imputation was performed on missing values before standardization, ensuring that the factor model operates on complete data without bias from deletion.
What this can't tell you
Median imputation assumes missing data are missing at random within columns; if missingness is systematic (e.g., correlated with unobserved customer type), imputed values may not reflect true underlying patterns. Consider whether the original data source logs the pattern of missingness by variable and observation type.
Variance Explained by Factor
How much of the total variation each latent factor accounts for.
Factor 1 (MntFishProducts + MntFruits) dominates at 29.3% of variance, more than twice the share of Factor 2 (11.7%). Factor 3 contributes 6.4%, the smallest of the three. Cumulatively, all three factors explain 47.4% of total standardized variation; the remaining 52.6% is variable-specific uniqueness and noise that no shared factor accounts for. This split shows that while the three latent causes capture the strongest shared patterns in spending behavior, substantial variation in individual spending categories remains independent of these common drivers.
Factor Loadings
Varimax-rotated loading of every variable on every factor, plus uniqueness.
| Variable | F1 | F2 | F3 | Uniqueness |
|---|---|---|---|---|
| Income | 0.533 | 0.497 | -0.091 | 0.46 |
| Recency | 0.001 | 0.024 | 0.006 | 1 |
| MntWines | 0.522 | 0.553 | -0.205 | 0.38 |
| MntFruits | 0.708 | 0.133 | 0.238 | 0.42 |
| MntMeatProducts | 0.522 | 0.663 | 0.357 | 0.16 |
| MntFishProducts | 0.734 | 0.136 | 0.267 | 0.37 |
| MntSweetProducts | 0.693 | 0.135 | 0.209 | 0.46 |
| MntGoldProds | 0.552 | 0.122 | -0.055 | 0.68 |
| Kidhome | -0.522 | -0.328 | 0.159 | 0.6 |
| Teenhome | -0.07 | -0.061 | -0.513 | 0.73 |
The short answer
Factor 1 is driven by seafood and fruit spending (MntFishProducts: 0.734, MntFruits: 0.708) along with sweets and gold products. Factor 2 links meat and wine (MntMeatProducts: 0.663, MntWines: 0.553). Factor 3 is defined by teen household size (Teenhome: −0.513). Recency stands apart entirely (uniqueness 1.0), while MntMeatProducts is best captured by the factors (uniqueness 0.16).
The detail
Loadings above 0.30 in absolute value define each factor. Factor 1 loadings: MntFishProducts 0.734, MntFruits 0.708, MntSweetProducts 0.693, MntGoldProds 0.552, Income 0.533, MntMeatProducts 0.522. Factor 2 loadings: MntMeatProducts 0.663, MntWines 0.553, Income 0.497, Kidhome −0.328. Factor 3 loadings: Teenhome −0.513, MntMeatProducts 0.357. Uniqueness (unexplained variance) ranges from 0.16 (MntMeatProducts) to 1.0 (Recency).
What this can't tell you
Loadings measure linear association only; non-linear relationships between variables and latent factors are not captured. Varimax rotation optimizes interpretability but does not guarantee that the latent factors correspond to real, actionable business constructs—only that they are statistically orthogonal.
Factor Interpretation
Auto-generated factor names from each factor's top-loading variables.
| Factor Name | Top Variables | Variance PCT | Suggested Meaning |
|---|---|---|---|
| Factor 1 | MntFishProducts, MntFruits, MntSweetProducts, MntGoldProds, Income | 29.3 | MntFishProducts + MntFruits |
| Factor 2 | MntMeatProducts, MntWines, Income, Kidhome | 11.7 | MntMeatProducts + MntWines |
| Factor 3 | Teenhome, MntMeatProducts | 6.4 | Teenhome + MntMeatProducts |
Three latent factors emerge from the top-loading variables. Factor 1 (29.3% variance) groups MntFishProducts, MntFruits, MntSweetProducts, MntGoldProds, and Income—a premium seafood and fresh-goods pattern linked to customer wealth. Factor 2 (11.7% variance) pairs MntMeatProducts, MntWines, Income, and Kidhome—meat and wine spending that co-varies with household composition and income. Factor 3 (6.4% variance) joins Teenhome and MntMeatProducts—a pattern where meat spending associates with teenage children at home. These groupings are statistical; their domain meaning belongs to your business: variables listed together are driven by the same unobserved cause.
What the Factors Miss
Share of each variable the latent factors leave unexplained.
The short answer
Recency (99.9% unexplained) and teen household variables (Teenhome 72.8%) carry almost entirely independent information—the factors do not capture them. In contrast, meat spending (MntMeatProducts, 16% uniqueness) is well-explained by the shared factors. Three variables exceed 60% uniqueness, signaling they are outliers to the common structure.
The detail
Uniqueness percentages (variance not explained by factors) rank as follows: Recency 99.9%, Teenhome 72.8%, MntGoldProds 67.7%, Kidhome 59.5%, Income 46.1%, MntSweetProducts 45.9%, MntFruits 42.4%, MntWines 38.1%, MntFishProducts 37.2%, MntMeatProducts 16%. The concentration of high uniqueness in the top four variables reflects that household composition and purchase recency operate largely independently of the spending patterns captured by the three latent factors.
What this can't tell you
High uniqueness does not mean a variable is unimportant or noisy—it means the variable is not strongly driven by the same latent causes as the spending categories. Recency's independence suggests it may reflect operational or seasonal effects rather than customer preference structure, and should be modeled separately in predictive or segmentation tasks.
Exploratory Factor Analysis — Latent Structure Finder
Runs maximum-likelihood factor analysis (base stats::factanal) with varimax rotation on the numeric columns the user selects, revealing the small number of latent factors that explain why many measured variables move together, naming each factor from its top-loading variables.
Why This Method?
When many variables are correlated, factor analysis models their SHARED variance as the product of a few unobserved causes — the constructs a survey measures, the hidden drivers behind a KPI wall. Unlike PCA (which compresses total variance), it separates common structure from variable-specific noise via the uniqueness estimates.
What This Analysis Covers
- Automatic choice of the number of factors (likelihood-ratio fit test,
BIC-style tiebreak, eigenvalue fallback) with Heywood/singularity retries
- Variance explained per factor + cumulative
- Full varimax-rotated loading matrix + uniqueness per variable
- Auto-generated factor names from each factor's top-loading variables
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(htmltools))
suppressPackageStartupMessages(library(jsonlite))
suppressPackageStartupMessages(library(plotly))
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))Step 1: Discover mapped feature columns
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) < 3) {
stop("column_mapping must map at least three feature columns(feature_1, feature_2, feature_3) — factor analysis needs 3 or more variables")
}
feature_names <- setNames(humanize_semantic(feat_cols, col_map), feat_cols)Step 2: Coerce features numeric (95% rule); impute median; drop constants
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) < 3) {
stop(paste0(
"Factor analysis needs at least three usable numeric columns; only ",
length(used_features), " remained after cleaning. Excluded: ",
paste(feature_names[dropped_features], collapse = ", ")
))
}
X <- as.matrix(df[, used_features, drop = FALSE])
final_rows <- nrow(X)
rows_removed <- initial_rows - final_rows
if (final_rows < 20) {
stop(sprintf("Only %d usable rows — factor analysis needs at least 20.", final_rows))
}
p <- length(used_features)
n <- final_rowsStep 3: Standardize
Xs <- scale(X)Step 4: Choose the number of factors
Try k = 1..min(5, floor((p - sqrt(p)) / 2)) and pick the smallest k whose likelihood-ratio fit test cannot reject the model (p > 0.05); otherwise the best BIC-style tradeoff (chi-square minus dof x log n); otherwise the Kaiser eigenvalue count capped by what factanal could actually fit.
k_try_max <- max(1L, min(5L, as.integer(floor((p - sqrt(p)) / 2))))
fits <- vector("list", k_try_max)
for (k in seq_len(k_try_max)) {
fits[[k]] <- tryCatch(
suppressWarnings(factanal(x = Xs, factors = k, rotation = "varimax",
scores = "none")),
error = function(e) NULL
)
if (!is.null(fits[[k]]) && isFALSE(fits[[k]]$converged)) fits[[k]] <- NULL
}
ok_ks <- which(!vapply(fits, is.null, logical(1)))
if (length(ok_ks) == 0) {
stop(paste0(
"Factor analysis could not be fit to these columns(",
paste(feature_names[used_features], collapse = ", "),
") — they may be near-duplicates of each other or too few rows for ",
"the model. Try removing redundant columns or adding rows."
))
}
retried <- length(ok_ks) < k_try_max
get_pval <- function(f) {
pv <- suppressWarnings(as.numeric(f$PVAL %||% NA_real_))
if (length(pv) == 0) NA_real_ else pv[1]
}
get_bic <- function(f) {
st <- suppressWarnings(as.numeric(f$STATISTIC %||% NA_real_))
dof <- suppressWarnings(as.numeric(f$dof %||% NA_real_))
if (length(st) == 0 || length(dof) == 0 || is.na(st) || is.na(dof) ||
dof <= 0) return(NA_real_)
st - dof * log(n)
}
pvals <- vapply(fits[ok_ks], get_pval, numeric(1))
bics <- vapply(fits[ok_ks], get_bic, numeric(1))
ev <- eigen(cor(Xs), symmetric = TRUE, only.values = TRUE)$values
kaiser_k <- max(1L, sum(ev > 1))
fit_ok <- !is.na(pvals) & pvals > 0.05
if (any(fit_ok)) {
n_factors <- ok_ks[fit_ok][1] # smallest adequate k
} else if (any(!is.na(bics))) {
valid <- which(!is.na(bics)) # filter NA BEFORE which.min
n_factors <- ok_ks[valid][which.min(bics[valid])]
} else {
below <- ok_ks[ok_ks <= kaiser_k] # Kaiser fallback, capped
n_factors <- if (length(below) > 0) max(below) else min(ok_ks)
}Step 5: Heywood check on the chosen fit — step down k and retry
is_heywood <- function(f) any(f$uniquenesses <= 0.005 + 1e-6)
heywood <- FALSE
while (is_heywood(fits[[n_factors]]) && any(ok_ks < n_factors)) {
retried <- TRUE
n_factors <- max(ok_ks[ok_ks < n_factors])
}
if (is_heywood(fits[[n_factors]])) heywood <- TRUE # k = 1 minimum: accept + flag
fa <- fits[[n_factors]]
fit_pval <- get_pval(fa)Step 6: Loadings, variance shares, and auto-generated factor names
L <- unclass(fa$loadings)[, seq_len(n_factors), drop = FALSE]
ss <- colSums(L^2)
ord <- order(-ss) # factors by variance, desc
L <- L[, ord, drop = FALSE]
ss <- ss[ord]
var_pct <- 100 * ss / p
cumulative_pct <- sum(var_pct)
hum <- unname(feature_names[used_features])
factor_names_short <- paste0("Factor ", seq_len(n_factors))
top2_join <- character(n_factors)
top_vars_list <- character(n_factors)
loading_groups <- list()
for (j in seq_len(n_factors)) {
aload <- abs(L[, j])
idx <- order(-aload)
top2_join[j] <- paste(hum[idx[seq_len(min(2, p))]], collapse = " + ")
strong <- which(aload >= 0.3)
strong <- strong[order(-aload[strong])]
if (length(strong) == 0) strong <- idx[1]
top_vars_list[j] <- paste(hum[head(strong, 5)], collapse = ", ")
loading_groups[[factor_names_short[j]]] <- hum[strong]
}
factor_names_full <- paste0(factor_names_short, ": ", top2_join)
variance_by_factor_df <- data.frame(
factor_name = factor_names_full,
variance_pct = round(var_pct, 1),
stringsAsFactors = FALSE
)
# var_pct carries Factor1/Factor2 names; reset so no "Row" column renders.
rownames(variance_by_factor_df) <- NULL
loadings_df <- data.frame(variable = hum, stringsAsFactors = FALSE)
for (j in seq_len(n_factors)) {
loadings_df[[paste0("f", j)]] <- round(L[, j], 3)
}
loadings_df$uniqueness <- round(unname(fa$uniquenesses[used_features]), 2)
rownames(loadings_df) <- NULL
interpretation_df <- data.frame(
factor_name = factor_names_short,
top_variables = top_vars_list,
variance_pct = round(var_pct, 1),
suggested_meaning = top2_join,
stringsAsFactors = FALSE
)
# var_pct carries Factor1/Factor2 names; reset so no "Row" column renders.
rownames(interpretation_df) <- NULL
uniqueness_df <- data.frame(
variable = hum,
uniqueness_pct = round(100 * unname(fa$uniquenesses[used_features]), 1),
stringsAsFactors = FALSE
)
uniqueness_df <- uniqueness_df[order(-uniqueness_df$uniqueness_pct), , drop = FALSE]
rownames(uniqueness_df) <- NULL
metrics <- list(
`Observations` = final_rows,
`Variables Analysed` = p,
`Factors Found` = as.integer(n_factors),
`Total Variance Explained %` = round(cumulative_pct, 1),
`Fit Test P Value` = fmt_p(fit_pval),
`Top Factor` = factor_names_full[1]
)
json_output <- list(
answer = paste0(
"Maximum-likelihood factor analysis on ", p, " variables across ",
format(final_rows, big.mark = ","), " rows found ", n_factors,
" latent ", plural_word(n_factors, "factor"), ": ",
paste0(factor_names_full, " (", round(var_pct, 1), "% of variance)",
collapse = "; "),
". Together the shared ",
plural_word(n_factors, "factor explains", "factors explain"), " ",
round(cumulative_pct, 1), "% of total variation(likelihood-ratio fit ",
"test p ", fmt_p(fit_pval), ")."
),
cards = lapply(
c("tldr", "overview", "preprocessing", "variance_by_factor",
"loadings_table", "factor_names", "uniqueness_chart"),
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,
n_factors = as.integer(n_factors),
k_try_max = k_try_max,
fit_pval = fit_pval,
heywood = heywood,
retried = retried,
factor_names_short = factor_names_short,
factor_names_full = factor_names_full,
variance_by_factor_df = variance_by_factor_df,
var_pct = var_pct,
cumulative_pct = cumulative_pct,
loadings_df = loadings_df,
interpretation_df = interpretation_df,
uniqueness_df = uniqueness_df,
loading_groups = loading_groups,
kaiser_k = kaiser_k,
metrics = metrics,
json_output = json_output
)
}Compute shared resources
shared <- compute_shared(df, params, col_map)