Standard Factor Analysis
Executive Summary

Executive Summary

Latent factors behind 10 variables across 2,240 observations

Observations
2240
Variables Analysed
10
Factors Found
3
Total Variance Explained %
47.4
Fit Test P Value
below 0.001
Top Factor
Factor 1: MntFishProducts + MntFruits
Factor analysis on 2,240 observations and 10 variables found 3 latent factors: Factor 1: MntFishProducts + MntFruits (29.3% of variance); Factor 2: MntMeatProducts + MntWines (11.7% of variance); Factor 3: Teenhome + MntMeatProducts (6.4% of variance). Together the shared factors explain 47.4% of total variation. The likelihood-ratio fit test suggests some structure remains beyond these 3 factors (p below 0.001), so treat them as the dominant patterns rather than the complete story.
What this means

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.

Overview

Analysis Overview

Maximum-likelihood factor analysis of 10 standardized variables across 2,240 observations.

N Observations2240
N Variables10
N Factors3
Variance Explained47.4
What this means

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 Preparation

Data Quality

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

Initial Rows2240
Final Rows2240
Rows Removed0
What this means

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.

Visualization

Variance Explained by Factor

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

What this means

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.

Data Table

Factor Loadings

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

VariableF1F2F3Uniqueness
Income0.5330.497-0.0910.46
Recency0.0010.0240.0061
MntWines0.5220.553-0.2050.38
MntFruits0.7080.1330.2380.42
MntMeatProducts0.5220.6630.3570.16
MntFishProducts0.7340.1360.2670.37
MntSweetProducts0.6930.1350.2090.46
MntGoldProds0.5520.122-0.0550.68
Kidhome-0.522-0.3280.1590.6
Teenhome-0.07-0.061-0.5130.73
What this means

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.

Data Table

Factor Interpretation

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

Factor NameTop VariablesVariance PCTSuggested Meaning
Factor 1MntFishProducts, MntFruits, MntSweetProducts, MntGoldProds, Income29.3MntFishProducts + MntFruits
Factor 2MntMeatProducts, MntWines, Income, Kidhome11.7MntMeatProducts + MntWines
Factor 3Teenhome, MntMeatProducts6.4Teenhome + MntMeatProducts
What this means

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.

Visualization

What the Factors Miss

Share of each variable the latent factors leave unexplained.

What this means

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.

Rate this report Was this the answer you needed?
The exact source that produced this report — yours to keep, read, and re-run.
Download PDF
How this was computed method · R source · citation
The code that did it

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 — validated R modules, interactive reports, AI insights, and PDF export. 500 free credits on signup.
Try Free — No SignupSign Up Free

Cite this analysis

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