Executive Summary
Latent factors behind 8 variables across 300 observations
Two latent factors drive the 8 metrics: Factor 1 (Return Rate + Complaint Calls, 32.8% of variance) captures a customer dissatisfaction cluster; Factor 2 (Ad Spend + Store Visits, 30.6% of variance) captures a customer engagement cluster. Together they explain 63.4% of total variation. The likelihood-ratio fit test (p = 0.889) confirms this 2-factor model is statistically adequate—no additional factors are needed to account for the shared structure in the data.
Analysis Overview
Maximum-likelihood factor analysis of 8 standardized variables across 300 observations.
Factor analysis on 300 observations identified 2 latent factors underlying 8 business variables. Unlike PCA, which compresses total variance, this maximum-likelihood model with varimax rotation isolates the shared variance—the common structure—and attributes it to unobserved latent causes. The likelihood-ratio fit test (p = 0.889) confirms that 2 factors are statistically adequate, meaning no additional factors are needed to explain the shared structure. Together, the factors explain 63.4% of total variation, leaving 36.6% as variable-specific uniqueness and noise.
Data Quality
Column typing, imputation, and exclusions before the factor model.
All 300 rows and 8 variables were retained; 0 rows were removed. Missing values were imputed using each column's median, and all variables were standardized to mean 0 and variance 1 before fitting. Standardization ensures that scale differences across variables—for example, Ad Spend in dollars versus Return Rate as a percentage—do not distort the latent factor structure. This preprocessing step is critical for fair comparison of loadings across heterogeneous metrics.
Variance Explained by Factor
How much of the total variation each latent factor accounts for.
Factor 1 accounts for 32.8% of total standardized variance, slightly outweighing Factor 2's 30.6%. This modest difference indicates that customer dissatisfaction (returns, complaints, support requests, refunds) and customer engagement (ad spend, store visits, web sessions, email clicks) are nearly balanced in their influence on overall variation. The cumulative 63.4% explained leaves 36.6% as unique variance within each variable—signal that no shared factor captures. This remainder is substantial enough to warrant treating high-uniqueness variables independently rather than collapsing them into factor scores.
Factor Loadings
Varimax-rotated loading of every variable on every factor, plus uniqueness.
| Variable | F1 | F2 | Uniqueness |
|---|---|---|---|
| Ad Spend | -0.002 | 0.847 | 0.28 |
| Store Visits | -0.002 | 0.77 | 0.41 |
| Web Sessions | 0.041 | 0.743 | 0.45 |
| Email Clicks | -0.112 | 0.761 | 0.41 |
| Support Tickets | 0.804 | 0.018 | 0.35 |
| Return Rate | 0.828 | -0.026 | 0.31 |
| Complaint Calls | 0.81 | -0.064 | 0.34 |
| Refund Requests | 0.79 | -0.058 | 0.37 |
Factor 1 is defined by four tight loadings: Return Rate (0.828), Complaint Calls (0.81), Support Tickets (0.804), and Refund Requests (0.79)—all above 0.30 and negatively paired. Factor 2 is defined by Ad Spend (0.847), Email Clicks (0.761), Store Visits (0.77), and Web Sessions (0.743). These high loadings indicate that variables within each factor move together due to a common latent driver. Uniqueness ranges from 28.2% (Ad Spend) to 44.7% (Web Sessions), meaning each variable retains independent information. The clean separation—near-zero cross-loadings—confirms the varimax rotation successfully isolated two distinct latent factors.
Factor Interpretation
Auto-generated factor names from each factor's top-loading variables.
| Factor Name | Top Variables | Variance PCT | Suggested Meaning |
|---|---|---|---|
| Factor 1 | Return Rate, Complaint Calls, Support Tickets, Refund Requests | 32.8 | Return Rate + Complaint Calls |
| Factor 2 | Ad Spend, Store Visits, Email Clicks, Web Sessions | 30.6 | Ad Spend + Store Visits |
Factor 1 mechanically groups Return Rate, Complaint Calls, Support Tickets, and Refund Requests, accounting for 32.8% of variance. Factor 2 groups Ad Spend, Store Visits, Email Clicks, and Web Sessions, accounting for 30.6%. These groupings are data-driven: variables listed together are statistically co-driven by the same unobserved latent cause. The suggested names—"Return Rate + Complaint Calls" and "Ad Spend + Store Visits"—reflect the top-loading pairs within each factor. Domain interpretation is left to you: these factors likely represent customer satisfaction and customer acquisition or engagement channels, respectively.
What the Factors Miss
Share of each variable the latent factors leave unexplained.
Web Sessions carries the highest uniqueness at 44.7%, followed by Email Clicks (40.9%) and Store Visits (40.8%)—meaning these engagement metrics retain substantial independent signal beyond the shared Factor 2. Ad Spend is the best-explained variable at 28.2% uniqueness, tightly bound to its latent factor. No variable exceeds 60% uniqueness, so all eight metrics are substantially captured by the shared factors. However, variables above 40% uniqueness—Web Sessions, Email Clicks, Store Visits, Refund Requests—should be monitored as standalone indicators rather than folded into composite factor scores, as they carry distinct information.
Methodology
Statistical methodology and diagnostics for Exploratory Factor Analysis
Statistical Method
Standard-library analysis: maximum-likelihood exploratory factor analysis with varimax rotation on the numeric columns you choose. Finds the small number of latent factors that explain why many measured variables (survey items, KPIs) move together, names each factor from its top-loading variables, and shows how much of each variable the shared factors do and do not explain. Works on any dataset: map 3 or more numeric columns.
- Variables are numeric and approximately linearly related
- Latent factors and errors are roughly normal (maximum-likelihood estimation)
- Enough rows per variable for a stable correlation matrix
- Factor names are derived purely from top-loading variables — the real-world meaning is yours to judge
- Maximum-likelihood fitting can struggle when columns are near-duplicates (the analysis retries with fewer factors)
- Correlated variables are required: if nothing co-moves, no useful factors exist
Analysis Code
Complete R source code for this analysis
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)