Standard Factor Analysis
Executive Summary

Executive Summary

Latent factors behind 8 variables across 300 observations

Observations
300
Variables Analysed
8
Factors Found
2
Total Variance Explained %
63.4
Fit Test P Value
= 0.889
Top Factor
Factor 1: Return Rate + Complaint Calls
Factor analysis on 300 observations and 8 variables found 2 latent factors: Factor 1: Return Rate + Complaint Calls (32.8% of variance); Factor 2: Ad Spend + Store Visits (30.6% of variance). Together the shared factors explain 63.4% of total variation. The likelihood-ratio fit test cannot reject this 2-factor model (p = 0.889), so 2 factors are statistically adequate for this data.
Interpretation

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.

Overview

Analysis Overview

Maximum-likelihood factor analysis of 8 standardized variables across 300 observations.

N Observations300
N Variables8
N Factors2
Variance Explained63.4
Interpretation

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 Preparation

Data Quality

Column typing, imputation, and exclusions before the factor model.

Initial Rows300
Final Rows300
Rows Removed0
Interpretation

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.

Visualization

Variance Explained by Factor

How much of the total variation each latent factor accounts for.

Interpretation

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.

Data Table

Factor Loadings

Varimax-rotated loading of every variable on every factor, plus uniqueness.

VariableF1F2Uniqueness
Ad Spend-0.0020.8470.28
Store Visits-0.0020.770.41
Web Sessions0.0410.7430.45
Email Clicks-0.1120.7610.41
Support Tickets0.8040.0180.35
Return Rate0.828-0.0260.31
Complaint Calls0.81-0.0640.34
Refund Requests0.79-0.0580.37
Interpretation

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.

Data Table

Factor Interpretation

Auto-generated factor names from each factor's top-loading variables.

Factor NameTop VariablesVariance PCTSuggested Meaning
Factor 1Return Rate, Complaint Calls, Support Tickets, Refund Requests32.8Return Rate + Complaint Calls
Factor 2Ad Spend, Store Visits, Email Clicks, Web Sessions30.6Ad Spend + Store Visits
Interpretation

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.

Visualization

What the Factors Miss

Share of each variable the latent factors leave unexplained.

Interpretation

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

Methodology

Statistical methodology and diagnostics for Exploratory Factor Analysis

Statistical Method

Exploratory Factor Analysis

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.

Data
N = 300 observations
Assumptions
  • 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
Limitations
  • 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
Software & Citation
MCP Analytics · mcpanalytics.ai
Code Appendix

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_rows

Step 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)

Finalize (do not modify)

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