Skip to content

Commit ce8bc76

Browse files
authored
Merge pull request #1559 from exploratory-io/fix/issue-26623
Factor Analysis: support polychoric correlation (tam#26623)
2 parents 7b3ec13 + 7712be1 commit ce8bc76

4 files changed

Lines changed: 1582 additions & 12 deletions

File tree

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Package: exploratory
22
Type: Package
33
Title: R package for Exploratory
4-
Version: 16.0.9
4+
Version: 16.0.10
55
Date: 2026-07-21
66
Authors@R: c(person("Hideaki", "Hayashi", email = "hideaki@exploratory.io", role = c("aut", "cre")), person("Hide", "Kojima", email = "hide@exploratory.io", role = c("aut")), person("Kan", "Nishida", email = "kan@exploratory.io", role = c("aut")), person("Kei", "Saito", email = "kei@exploratory.io", role = c("aut")), person("Yosuke", "Yasuda", email = "double.y.919.quick@gmail.com", role = c("aut")))
77
URL: https://github.com/exploratory-io/exploratory_func

R/factanal.R

Lines changed: 224 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,13 @@ judge_loading <- function(loadings, cfg = factanal_report_config()) {
138138

139139
# Horn's parallel analysis: compares actual correlation-matrix eigenvalues against the
140140
# quantile of eigenvalues from random normal data of the same shape. (issue #37018 spec 3.4)
141-
compute_parallel_analysis <- function(x, n_iter = 100, quantile_prob = 0.95) {
141+
#' @param cor_type Correlation type the whole analysis uses. For anything other than "pearson" both the
142+
#' actual and the random-data eigenvalues are computed with that same correlation, so the parallel
143+
#' analysis never silently falls back to Pearson. (issue #26623)
144+
#' @param cor_matrix Correlation matrix already built for the analysis. Reused for the actual eigenvalues
145+
#' so the scree/parallel chart matches the matrix `fa()` was fitted on.
146+
compute_parallel_analysis <- function(x, n_iter = 100, quantile_prob = 0.95,
147+
cor_type = "pearson", cor_matrix = NULL) {
142148
if (length(n_iter) != 1L || is.na(n_iter) || n_iter < 1 || n_iter != as.integer(n_iter)) {
143149
stop("n_iter must be a positive integer")
144150
}
@@ -149,7 +155,57 @@ compute_parallel_analysis <- function(x, n_iter = 100, quantile_prob = 0.95) {
149155
x <- as.data.frame(x)
150156
n <- nrow(x)
151157
p <- ncol(x)
152-
actual_eigen <- eigen(cor(x, use = "pairwise.complete.obs"), only.values = TRUE)$values
158+
if (!identical(cor_type, "pearson")) {
159+
# Polychoric/tetrachoric/mixed: the null distribution is built by shuffling each column
160+
# independently, which keeps every variable's category marginals but destroys the association,
161+
# and the SAME correlation method is applied to it. Drawing normal deviates instead would
162+
# compare a polychoric eigenvalue against a Pearson one.
163+
actual_eigen <- if (!is.null(cor_matrix)) {
164+
eigen(cor_matrix, symmetric = TRUE, only.values = TRUE)$values
165+
} else {
166+
eigen(build_factor_correlation(x, cor_type)$correlation, symmetric = TRUE, only.values = TRUE)$values
167+
}
168+
had_seed <- exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)
169+
old_seed <- if (had_seed) get(".Random.seed", envir = .GlobalEnv) else NULL
170+
set.seed(1234)
171+
on.exit({
172+
if (had_seed) {
173+
assign(".Random.seed", old_seed, envir = .GlobalEnv)
174+
} else if (exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)) {
175+
rm(".Random.seed", envir = .GlobalEnv)
176+
}
177+
}, add = TRUE)
178+
random_eigen_list <- lapply(seq_len(n_iter), function(i) {
179+
shuffled <- as.data.frame(lapply(x, function(col) col[sample.int(n)]))
180+
res <- build_factor_correlation(shuffled, cor_type)
181+
# build_factor_correlation degrades to Pearson when the estimation fails. Comparing a
182+
# polychoric eigenvalue against a Pearson threshold is exactly the mix-up this whole change
183+
# exists to prevent, so drop the iteration instead.
184+
if (isTRUE(res$failed)) return(NULL)
185+
eigen(res$correlation, symmetric = TRUE, only.values = TRUE)$values
186+
})
187+
random_eigen_list <- random_eigen_list[!vapply(random_eigen_list, is.null, logical(1))]
188+
if (length(random_eigen_list) == 0) {
189+
return(NULL) # no usable null distribution; the caller reports the parallel analysis as unavailable
190+
}
191+
random_eigen_mat <- do.call(cbind, random_eigen_list)
192+
random_threshold <- apply(random_eigen_mat, 1, stats::quantile, probs = quantile_prob)
193+
return(list(
194+
recommended_n = sum(actual_eigen > random_threshold),
195+
table = tibble::tibble(
196+
factor_number = seq_along(actual_eigen),
197+
actual_eigenvalue = actual_eigen,
198+
random_eigenvalue_threshold = random_threshold
199+
)
200+
))
201+
}
202+
# Reuse the analysis's own matrix when it was handed one, so the scree/parallel chart and the
203+
# fit can never be based on two separately-computed Pearson matrices. (issue #26623)
204+
actual_eigen <- if (!is.null(cor_matrix)) {
205+
eigen(cor_matrix, symmetric = TRUE, only.values = TRUE)$values
206+
} else {
207+
eigen(cor(x, use = "pairwise.complete.obs"), symmetric = TRUE, only.values = TRUE)$values
208+
}
153209
# Snapshot the RNG so this null-distribution draw does not perturb the outer deterministic
154210
# stream that later groups' sampling relies on. Restore afterward.
155211
had_seed <- exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)
@@ -164,7 +220,7 @@ compute_parallel_analysis <- function(x, n_iter = 100, quantile_prob = 0.95) {
164220
}, add = TRUE)
165221
random_eigen_mat <- replicate(n_iter, {
166222
rd <- matrix(stats::rnorm(n * p), nrow = n, ncol = p)
167-
eigen(cor(rd), only.values = TRUE)$values
223+
eigen(cor(rd), symmetric = TRUE, only.values = TRUE)$values
168224
})
169225
random_threshold <- apply(random_eigen_mat, 1, stats::quantile, probs = quantile_prob)
170226
list(
@@ -179,7 +235,15 @@ compute_parallel_analysis <- function(x, n_iter = 100, quantile_prob = 0.95) {
179235

180236
#' Function for Factor Analysis Analytics View
181237
#' @export
182-
exp_factanal <- function(df, ..., nfactors = 2, fm = "minres", scores = "regression", rotate = "none", max_nrow = NULL, seed = 1) {
238+
#' @param cor_type Correlation used for the whole analysis. "auto" (default) picks Pearson /
239+
#' Tetrachoric / Polychoric / Mixed from the variable types and response distributions,
240+
#' "pearson" and "polychoric" force that correlation. The SAME matrix drives fa(), KMO,
241+
#' Bartlett, the eigenvalues, the parallel analysis and the factor scores. (issue #26623)
242+
#' @param parallel_n_iter Iterations of the parallel analysis null distribution (default 100 for
243+
#' every correlation type). Polychoric-family correlations re-estimate the correlation on
244+
#' each iteration, so this is the dominant cost of a polychoric run.
245+
exp_factanal <- function(df, ..., nfactors = 2, fm = "minres", scores = "regression", rotate = "none", max_nrow = NULL, seed = 1,
246+
cor_type = "auto", parallel_n_iter = NULL) {
183247
# this evaluates select arguments like starts_with
184248
selected_cols <- tidyselect::vars_select(names(df), !!! rlang::quos(...))
185249
if (length(selected_cols) < nfactors) {
@@ -207,6 +271,22 @@ exp_factanal <- function(df, ..., nfactors = 2, fm = "minres", scores = "regress
207271
set.seed(seed)
208272
}
209273

274+
# Resolve the correlation ONCE, from the whole data, and reuse it for every group. Selecting per
275+
# group would let one facet run on Polychoric and another on Pearson while the report describes a
276+
# single method, and would make the groups' loadings incomparable. (issue #26623)
277+
overall_type <- NULL
278+
overall_reason <- NULL
279+
if (identical(tolower(trimws(as.character(cor_type))), "auto")) {
280+
overall_selection <- tryCatch({
281+
candidate_cols <- intersect(selected_cols, colnames(df))
282+
if (length(candidate_cols) >= 2) select_factor_correlation_type(as.data.frame(df)[, candidate_cols, drop = FALSE]) else NULL
283+
}, error = function(e) NULL)
284+
if (!is.null(overall_selection) && !identical(overall_selection$selected_method, "unsupported")) {
285+
overall_type <- overall_selection$selected_method
286+
overall_reason <- overall_selection$reason
287+
}
288+
}
289+
210290
each_func <- function(df) {
211291
# sample the data for quicker turn around on UI,
212292
# if data size is larger than specified max_nrow.
@@ -245,10 +325,89 @@ exp_factanal <- function(df, ..., nfactors = 2, fm = "minres", scores = "regress
245325
return(NULL)
246326
}
247327
}
248-
fit <- psych::fa(cleaned_df, nfactors = nfactors, fm = fm, scores = scores, rotate = rotate)
328+
# Decide which correlation the whole analysis runs on, and build that ONE matrix. Every
329+
# downstream computation below reads it, so the report can never mix Pearson and Polychoric
330+
# (e.g. a polychoric fa() next to a Pearson KMO or parallel analysis). (issue #26623)
331+
selection <- select_factor_correlation_type(cleaned_df)
332+
resolved <- resolve_factanal_correlation_type(cor_type, selection)
333+
# Auto: keep the whole-analysis choice made above, so every group uses the same correlation.
334+
# The per-group selection is still what drives the category coding and the diagnostics.
335+
if (isTRUE(resolved$auto) && !is.null(overall_type) &&
336+
!identical(selection$selected_method, "unsupported")) {
337+
resolved$type <- overall_type
338+
resolved$reason <- overall_reason
339+
}
340+
# An unsupported variable combination (a nominal category, a constant column) is unsupported
341+
# whichever correlation was asked for -- picking Pearson manually must not smuggle a nominal
342+
# column in as arbitrary integer codes. So gate on the SELECTION, not the resolved type.
343+
if (identical(selection$selected_method, "unsupported") || identical(resolved$type, "unsupported")) {
344+
if (length(grouped_cols) > 0) {
345+
# With Repeat By, skip just this group -- mirroring the not-enough-columns guard above.
346+
# One facet whose columns degenerate (e.g. a single observed value plus NAs) must not
347+
# abort every other facet. (issue #26623)
348+
return(NULL)
349+
}
350+
# EXP-ANA-35 carries the offending column names as its params, so the client can name them.
351+
# (EXP-ANA-6 is PCA's reserved-column-name error -- do not reuse it here.)
352+
unsupported_vars <- selection$variable_summary$variable[
353+
selection$variable_summary$detected_type %in% c("nominal", "invalid")]
354+
stop(paste0("EXP-ANA-35 :: ",
355+
jsonlite::toJSON(paste(unsupported_vars, collapse = ", ")),
356+
" :: ", selection$reason))
357+
}
358+
# Category-ordered numeric coding. For an all-numeric data frame this is a no-op, so the
359+
# Pearson path stays bit-for-bit what it was before this change.
360+
encoded_df <- encode_factanal_data(cleaned_df, selection)
361+
# The correlation family that was ASKED for. It differs from resolved$type only when the
362+
# estimation fails and we degrade to Pearson, and the diagnostics table needs the asked-for
363+
# one so the failure is reported instead of silently disappearing with the table.
364+
requested_family <- resolved$type
365+
366+
if (identical(resolved$type, "pearson")) {
367+
fit <- psych::fa(encoded_df, nfactors = nfactors, fm = fm, scores = scores, rotate = rotate)
368+
cor_result <- list(correlation = cor(encoded_df), thresholds = NULL, type = "pearson",
369+
smoothed = FALSE, warnings = character(), failed = FALSE)
370+
cor_mat <- cor_result$correlation
371+
} else {
372+
# Correlation-matrix based fit. n.obs is mandatory here: without it psych assumes 100 rows
373+
# and every sample-size dependent fit index would be wrong.
374+
cor_result <- build_factor_correlation(encoded_df, requested_family)
375+
cor_mat <- cor_result$correlation
376+
if (isTRUE(cor_result$failed)) {
377+
# build_factor_correlation already degraded to Pearson; keep the reported type honest.
378+
# requested_family stays as asked so the diagnostics table can still REPORT the failure,
379+
# and degraded_from lets the report say WHICH correlation failed instead of claiming the
380+
# variables were treated as continuous on purpose.
381+
resolved$type <- "pearson"
382+
resolved$degraded_from <- requested_family
383+
resolved$reason <- sprintf("%s could not be estimated, so Pearson correlation was used instead.",
384+
factanal_correlation_label(requested_family))
385+
# degraded_from stays the TOKEN ("polychoric"/...), which the client renders per language.
386+
}
387+
fit <- psych::fa(cor_mat, nfactors = nfactors, n.obs = nrow(encoded_df), fm = fm, rotate = rotate)
388+
# fa() on a correlation matrix never returns scores, so compute them from the same matrix,
389+
# honoring the "Type of Scores" property instead of hardcoding one method.
390+
fit$scores <- tryCatch({
391+
psych::factor.scores(as.matrix(encoded_df), fit, rho = cor_mat,
392+
method = factanal_score_method(scores))$scores
393+
}, error = function(e) NULL)
394+
if (is.null(fit$scores)) {
395+
# Keep the shape so the score-consuming outputs (Data tab, biplot) degrade to NA columns
396+
# instead of aborting inside broom's augment().
397+
fit$scores <- matrix(NA_real_, nrow = nrow(encoded_df), ncol = nfactors)
398+
}
399+
}
249400

250-
cor_mat <- cor(cleaned_df)
251401
fit$correlation <- cor_mat # For creating scree plot later.
402+
fit$correlation_type <- resolved$type
403+
fit$correlation_is_auto <- isTRUE(resolved$auto)
404+
fit$correlation_reason <- resolved$reason
405+
fit$correlation_degraded_from <- if (is.null(resolved$degraded_from)) "" else resolved$degraded_from
406+
fit$correlation_selection <- selection
407+
# Computed here, while the data is in hand: whether Polychoric is worth offering for this data.
408+
fit$correlation_polychoric_available <- factanal_polychoric_available(selection, encoded_df)
409+
fit$correlation_result <- cor_result[setdiff(names(cor_result), "correlation")]
410+
fit$nfactors_requested <- nfactors
252411
fit$df <- filtered_df # add filtered df to model so that we can bind_col it for output. It needs to be the filtered one to match row number.
253412
fit$grouped_cols <- grouped_cols
254413
fit$sampled_nrow <- sampled_nrow
@@ -257,9 +416,20 @@ exp_factanal <- function(df, ..., nfactors = 2, fm = "minres", scores = "regress
257416
# guarded so a singular/degenerate correlation matrix degrades to NA instead of aborting.
258417
fit$n_rows_used <- nrow(cleaned_df)
259418
fit$n_variables <- ncol(cleaned_df)
419+
# KMO and Bartlett take the correlation MATRIX (never the raw data): passing raw data would
420+
# make psych recompute a Pearson correlation internally and silently contradict a polychoric
421+
# fit. cortest.bartlett also needs n explicitly, or it assumes n = 100. (issue #26623)
260422
fit$kmo <- tryCatch(as.numeric(psych::KMO(cor_mat)$MSA), error = function(e) NA_real_)
261-
fit$bartlett <- tryCatch(psych::cortest.bartlett(cor_mat, n = nrow(cleaned_df)), error = function(e) NULL)
262-
fit$parallel <- tryCatch(compute_parallel_analysis(cleaned_df), error = function(e) NULL)
423+
fit$bartlett <- tryCatch(psych::cortest.bartlett(cor_mat, n = nrow(encoded_df)), error = function(e) NULL)
424+
n_iter <- if (!is.null(parallel_n_iter)) parallel_n_iter else 100
425+
fit$parallel <- tryCatch(compute_parallel_analysis(encoded_df, n_iter = n_iter,
426+
cor_type = resolved$type, cor_matrix = cor_mat),
427+
error = function(e) NULL)
428+
# Keyed off the REQUESTED family, so a failed polychoric estimation still shows the table with
429+
# its "Estimation failed" row rather than hiding the only signal the user has.
430+
fit$cor_diagnostics <- if (identical(requested_family, "pearson")) NULL else {
431+
tryCatch(compute_polychoric_diagnostics(encoded_df, cor_result, selection), error = function(e) NULL)
432+
}
263433
class(fit) <- c("fa_exploratory", class(fit))
264434
fit
265435
}
@@ -337,7 +507,7 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
337507
n_factor <- x$factors # Number of factors.
338508

339509
if (type == "screeplot") {
340-
eigen_res <- eigen(x$correlation, only.values = TRUE) # Cattell's scree plot is eigenvalues of correlation/covariance matrix.
510+
eigen_res <- eigen(x$correlation, symmetric = TRUE, only.values = TRUE) # Cattell's scree plot is eigenvalues of correlation/covariance matrix.
341511
res <- tibble::tibble(factor=1:length(eigen_res$values), eigenvalue=eigen_res$values)
342512
}
343513
else if (type == "variances") {
@@ -540,8 +710,51 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
540710
status = c(kj$status, bj$status, "", "")
541711
)
542712
}
713+
else if (type == "analysis_method") {
714+
# Report header table: what the numbers below were actually computed with. (issue #26623)
715+
cor_type <- if (is.null(x$correlation_type)) "pearson" else x$correlation_type
716+
rotation <- if (!is.null(x$rotation)) x$rotation else "none"
717+
res <- tibble::tibble(
718+
# NOTE: "Target Variables" / "Data Rows" instead of the shorter "Variables" / "Rows":
719+
# the client's shared translation map already binds those two keys to different wordings
720+
# for other tables, and a direct-map key can only have one translation. (issue #26623)
721+
Item = c("Correlation", "Factor Extraction Method", "Rotation", "Target Variables", "Data Rows"),
722+
Value = c(
723+
factanal_correlation_label(cor_type),
724+
factanal_extraction_method_label(x$fm),
725+
factanal_rotation_label(rotation),
726+
if (length(x$n_variables) == 1L && !is.na(x$n_variables)) as.character(x$n_variables) else "N/A",
727+
if (length(x$n_rows_used) == 1L && !is.na(x$n_rows_used)) as.character(x$n_rows_used) else "N/A"
728+
),
729+
# Hidden columns. The client reads them to bind the report's explanation text; they are not
730+
# part of the rendered Item/Value table.
731+
correlation_type = cor_type,
732+
correlation_is_auto = isTRUE(x$correlation_is_auto),
733+
# Non-empty when the requested correlation could not be estimated and the fit fell back to
734+
# Pearson, so the report can say so instead of inventing a rationale.
735+
degraded_from = if (is.null(x$correlation_degraded_from)) "" else x$correlation_degraded_from,
736+
# Whether suggesting Polychoric makes sense for this data at all.
737+
polychoric_available = isTRUE(x$correlation_polychoric_available),
738+
# Language-neutral tokens for the selector's warnings; the client renders the localized text.
739+
warning_tokens = paste(factanal_selection_warning_tokens(x$correlation_selection), collapse = ","),
740+
# TRUE also when a polychoric estimation failed and the fit degraded to Pearson, so the
741+
# report still shows the diagnostics table that reports the failure.
742+
has_diagnostics = !is.null(x$cor_diagnostics) && nrow(x$cor_diagnostics) > 0,
743+
reason = if (is.null(x$correlation_reason)) "" else x$correlation_reason
744+
)
745+
}
746+
else if (type == "cor_diagnostics") {
747+
# Polychoric-family data suitability diagnostics. Empty tibble (with the same columns) when
748+
# the analysis ran on Pearson, so the client can hide the section. (issue #26623)
749+
res <- if (is.null(x$cor_diagnostics)) {
750+
tibble::tibble(Diagnostic = character(), Judgement = character(),
751+
Description = character(), status = character())
752+
} else {
753+
x$cor_diagnostics
754+
}
755+
}
543756
else if (type == "factor_count") {
544-
eig <- eigen(x$correlation, only.values = TRUE)$values
757+
eig <- eigen(x$correlation, symmetric = TRUE, only.values = TRUE)$values
545758
kaiser_n <- sum(eig > 1)
546759
par <- x$parallel
547760
parallel_rec <- if (is.null(par)) "Not available" else as.character(par$recommended_n)
@@ -556,7 +769,7 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
556769
)
557770
}
558771
else if (type == "parallel_screeplot") {
559-
eig <- eigen(x$correlation, only.values = TRUE)$values
772+
eig <- eigen(x$correlation, symmetric = TRUE, only.values = TRUE)$values
560773
par <- x$parallel
561774
threshold <- if (is.null(par)) rep(NA_real_, length(eig)) else par$table$random_eigenvalue_threshold
562775
length(threshold) <- length(eig) # pad/truncate to align with eigenvalue count

0 commit comments

Comments
 (0)