Skip to content

Commit 8988023

Browse files
authored
Merge pull request #1571 from exploratory-io/fix/issue-37332-parallel-analysis-method
feat(factanal): parallel analysis method (Factor Model / Diagonal SMC) (#37332)
2 parents fe4e304 + f3c5310 commit 8988023

6 files changed

Lines changed: 322 additions & 79 deletions

File tree

DESCRIPTION

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
Package: exploratory
22
Type: Package
33
Title: R package for Exploratory
4-
Version: 16.0.24
5-
Date: 2026-07-28
4+
Version: 16.0.25
5+
Date: 2026-07-29
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
88
Description: Functions for Exploratory

R/factanal.R

Lines changed: 182 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -136,15 +136,95 @@ judge_loading <- function(loadings, cfg = factanal_report_config()) {
136136
primary_factor = primary_factor, secondary_factors = "", direction = direction)
137137
}
138138

139+
# The factor-analysis eigenvalues used by parallel analysis (issue tam#37332). Both actual-data and
140+
# random-data eigenvalues in compute_parallel_analysis() are routed through this so the two sides
141+
# are always compared on the same basis.
142+
#
143+
# method = "factor_model":
144+
# Estimate communalities from a one-factor model using the selected factor extraction method,
145+
# following the factor-analysis approach used by psych::fa.parallel(). nfactors = 1 below is used
146+
# ONLY to estimate communalities for these eigenvalues -- it has nothing to do with how many
147+
# factors exp_factanal() itself ultimately extracts.
148+
#
149+
# method = "smc":
150+
# Replace the diagonal of the correlation matrix with squared multiple correlations (SMC) and
151+
# compute eigenvalues of the reduced matrix.
152+
#
153+
# method = "pca":
154+
# Plain eigenvalues of the correlation matrix, diagonal untouched (the classic PCA-style parallel
155+
# analysis). NOT exposed on the exp_factanal() "Parallel Analysis Method" property (issue
156+
# tam#37332 only offers factor_model/smc there) -- this value exists so do_prcomp(), which shares
157+
# this helper via compute_parallel_analysis(), keeps its own pre-existing PCA parallel analysis
158+
# (issue #37019/#37294) unchanged. PCA eigenvalues must equal x$sdev^2 exactly when normalized, an
159+
# invariant only the untouched-diagonal eigenvalues satisfy.
160+
compute_parallel_factor_eigenvalues <- function(cor_matrix, method = c("factor_model", "smc", "pca"),
161+
fm = "minres", n_obs = NULL) {
162+
method <- match.arg(method)
163+
cor_matrix <- as.matrix(cor_matrix)
164+
if (nrow(cor_matrix) != ncol(cor_matrix)) {
165+
stop("cor_matrix must be a square matrix")
166+
}
167+
if (any(!is.finite(cor_matrix))) {
168+
stop("cor_matrix contains non-finite values")
169+
}
170+
171+
if (identical(method, "pca")) {
172+
return(eigen(cor_matrix, symmetric = TRUE, only.values = TRUE)$values)
173+
}
174+
175+
if (identical(method, "smc")) {
176+
reduced_cor <- cor_matrix
177+
smc_values <- psych::smc(cor_matrix)
178+
if (length(smc_values) != ncol(cor_matrix) || any(!is.finite(smc_values))) {
179+
stop("SMC communalities could not be computed")
180+
}
181+
diag(reduced_cor) <- smc_values
182+
return(eigen(reduced_cor, symmetric = TRUE, only.values = TRUE)$values)
183+
}
184+
185+
# Factor-model method.
186+
fa_args <- list(r = cor_matrix, nfactors = 1, fm = fm, rotate = "none", warnings = FALSE)
187+
if (!is.null(n_obs)) {
188+
fa_args$n.obs <- n_obs
189+
}
190+
factor_fit <- do.call(psych::fa, fa_args)
191+
factor_eigenvalues <- factor_fit$values
192+
if (is.null(factor_eigenvalues) || length(factor_eigenvalues) != ncol(cor_matrix) ||
193+
any(!is.finite(factor_eigenvalues))) {
194+
stop("Factor-model eigenvalues could not be computed")
195+
}
196+
as.numeric(factor_eigenvalues)
197+
}
198+
199+
# The number of LEADING factors whose actual eigenvalue exceeds the random-data threshold, stopping
200+
# at the first factor that does not -- not a simple count of how many factors exceed it anywhere in
201+
# the sequence (issue tam#37332). e.g. TRUE, TRUE, FALSE, TRUE -> 2, not 3.
202+
compute_parallel_recommended_n <- function(actual_eigen, random_threshold) {
203+
retained <- actual_eigen > random_threshold
204+
first_not_retained <- which(!retained)[1]
205+
if (is.na(first_not_retained)) {
206+
return(length(retained))
207+
}
208+
first_not_retained - 1L
209+
}
210+
139211
# Horn's parallel analysis: compares actual correlation-matrix eigenvalues against the
140212
# quantile of eigenvalues from random normal data of the same shape. (issue #37018 spec 3.4)
141213
#' @param cor_type Correlation type the whole analysis uses. For anything other than "pearson" both the
142214
#' actual and the random-data eigenvalues are computed with that same correlation, so the parallel
143215
#' analysis never silently falls back to Pearson. (issue #26623)
144216
#' @param cor_matrix Correlation matrix already built for the analysis. Reused for the actual eigenvalues
145217
#' so the scree/parallel chart matches the matrix `fa()` was fitted on.
218+
#' @param method How communalities are estimated for the parallel-analysis eigenvalues: "factor_model"
219+
#' (one-factor model using `fm`), "smc" (diagonal replaced by squared multiple correlations),
220+
#' or "pca" (plain eigenvalues, diagonal untouched -- do_prcomp's own PCA-style parallel
221+
#' analysis; not exposed on exp_factanal()'s property). The SAME method is applied to the
222+
#' actual data and to every random-data iteration. (issue tam#37332)
223+
#' @param fm Factor extraction method used by `method = "factor_model"`. Ignored otherwise.
146224
compute_parallel_analysis <- function(x, n_iter = 100, quantile_prob = 0.95,
147-
cor_type = "pearson", cor_matrix = NULL) {
225+
cor_type = "pearson", cor_matrix = NULL,
226+
method = c("factor_model", "smc", "pca"), fm = "minres") {
227+
method <- match.arg(method)
148228
if (length(n_iter) != 1L || is.na(n_iter) || n_iter < 1 || n_iter != as.integer(n_iter)) {
149229
stop("n_iter must be a positive integer")
150230
}
@@ -155,57 +235,30 @@ compute_parallel_analysis <- function(x, n_iter = 100, quantile_prob = 0.95,
155235
x <- as.data.frame(x)
156236
n <- nrow(x)
157237
p <- ncol(x)
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
238+
239+
# Build or reuse the actual correlation matrix. Reusing the analysis's own matrix when it was
240+
# handed one means the scree/parallel chart and the fit can never be based on two separately
241+
# computed matrices. (issue #26623)
242+
actual_cor <- if (!is.null(cor_matrix)) {
243+
cor_matrix
244+
} else if (identical(cor_type, "pearson")) {
245+
stats::cor(x, use = "pairwise.complete.obs")
246+
} else {
247+
actual_cor_result <- build_factor_correlation(x, cor_type)
248+
if (isTRUE(actual_cor_result$failed)) {
249+
return(NULL)
190250
}
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-
))
251+
actual_cor_result$correlation
201252
}
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
253+
254+
actual_eigen <- tryCatch(
255+
compute_parallel_factor_eigenvalues(actual_cor, method = method, fm = fm, n_obs = n),
256+
error = function(e) NULL
257+
)
258+
if (is.null(actual_eigen)) {
259+
return(NULL)
208260
}
261+
209262
# Snapshot the RNG so this null-distribution draw does not perturb the outer deterministic
210263
# stream that later groups' sampling relies on. Restore afterward.
211264
had_seed <- exists(".Random.seed", envir = .GlobalEnv, inherits = FALSE)
@@ -218,17 +271,50 @@ compute_parallel_analysis <- function(x, n_iter = 100, quantile_prob = 0.95,
218271
rm(".Random.seed", envir = .GlobalEnv)
219272
}
220273
}, add = TRUE)
221-
random_eigen_mat <- replicate(n_iter, {
222-
rd <- matrix(stats::rnorm(n * p), nrow = n, ncol = p)
223-
eigen(cor(rd), symmetric = TRUE, only.values = TRUE)$values
274+
275+
random_eigen_list <- lapply(seq_len(n_iter), function(i) {
276+
random_cor <- if (identical(cor_type, "pearson")) {
277+
rd <- matrix(stats::rnorm(n * p), nrow = n, ncol = p)
278+
stats::cor(rd)
279+
} else {
280+
# Shuffling each column independently keeps every variable's category marginals but
281+
# destroys the association, and the SAME correlation method is applied to it -- drawing
282+
# normal deviates instead would compare a polychoric eigenvalue against a Pearson one.
283+
shuffled <- as.data.frame(lapply(x, function(col) col[sample.int(n)]))
284+
res <- build_factor_correlation(shuffled, cor_type)
285+
# build_factor_correlation degrades to Pearson when the estimation fails. Comparing a
286+
# polychoric eigenvalue against a Pearson threshold is exactly the mix-up this whole change
287+
# exists to prevent, so drop the iteration instead.
288+
if (isTRUE(res$failed)) return(NULL)
289+
res$correlation
290+
}
291+
if (is.null(random_cor)) return(NULL)
292+
tryCatch(
293+
compute_parallel_factor_eigenvalues(random_cor, method = method, fm = fm, n_obs = n),
294+
error = function(e) NULL
295+
)
224296
})
225-
random_threshold <- apply(random_eigen_mat, 1, stats::quantile, probs = quantile_prob)
297+
random_eigen_list <- random_eigen_list[!vapply(random_eigen_list, is.null, logical(1))]
298+
if (length(random_eigen_list) == 0) {
299+
return(NULL) # no usable null distribution; the caller reports the parallel analysis as unavailable
300+
}
301+
random_eigen_mat <- do.call(cbind, random_eigen_list)
302+
random_threshold <- apply(random_eigen_mat, 1, stats::quantile, probs = quantile_prob, na.rm = TRUE)
303+
recommended_n <- compute_parallel_recommended_n(actual_eigen, random_threshold)
304+
226305
list(
227-
recommended_n = sum(actual_eigen > random_threshold),
306+
method = method,
307+
factor_extraction_method = fm,
308+
requested_n_iter = n_iter,
309+
successful_n_iter = length(random_eigen_list),
310+
failed_n_iter = n_iter - length(random_eigen_list),
311+
quantile_prob = quantile_prob,
312+
recommended_n = recommended_n,
228313
table = tibble::tibble(
229314
factor_number = seq_along(actual_eigen),
230315
actual_eigenvalue = actual_eigen,
231-
random_eigenvalue_threshold = random_threshold
316+
random_eigenvalue_threshold = random_threshold,
317+
retained = seq_along(actual_eigen) <= recommended_n
232318
)
233319
)
234320
}
@@ -242,8 +328,14 @@ compute_parallel_analysis <- function(x, n_iter = 100, quantile_prob = 0.95,
242328
#' @param parallel_n_iter Iterations of the parallel analysis null distribution (default 100 for
243329
#' every correlation type). Polychoric-family correlations re-estimate the correlation on
244330
#' each iteration, so this is the dominant cost of a polychoric run.
331+
#' @param parallel_method How communalities are estimated for the parallel-analysis eigenvalues:
332+
#' "factor_model" (default; a one-factor model using `fm`) or "smc" (diagonal replaced by
333+
#' squared multiple correlations). Applied identically to the actual data and the random-data
334+
#' null distribution. (issue tam#37332)
245335
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) {
336+
cor_type = "auto", parallel_n_iter = NULL,
337+
parallel_method = c("factor_model", "smc")) {
338+
parallel_method <- match.arg(parallel_method)
247339
# this evaluates select arguments like starts_with
248340
selected_cols <- tidyselect::vars_select(names(df), !!! rlang::quos(...))
249341
if (length(selected_cols) < nfactors) {
@@ -423,8 +515,13 @@ exp_factanal <- function(df, ..., nfactors = 2, fm = "minres", scores = "regress
423515
fit$bartlett <- tryCatch(psych::cortest.bartlett(cor_mat, n = nrow(encoded_df)), error = function(e) NULL)
424516
n_iter <- if (!is.null(parallel_n_iter)) parallel_n_iter else 100
425517
fit$parallel <- tryCatch(compute_parallel_analysis(encoded_df, n_iter = n_iter,
426-
cor_type = resolved$type, cor_matrix = cor_mat),
518+
cor_type = resolved$type, cor_matrix = cor_mat,
519+
method = parallel_method, fm = fm),
427520
error = function(e) NULL)
521+
# Kept even when fit$parallel itself is NULL (e.g. n_iter too small to yield a usable null
522+
# distribution), so the report can still show which setting was selected. (issue tam#37332)
523+
fit$parallel_method <- parallel_method
524+
fit$parallel_factor_extraction_method <- fm
428525
# Keyed off the REQUESTED family, so a failed polychoric estimation still shows the table with
429526
# its "Estimation failed" row rather than hiding the only signal the user has.
430527
fit$cor_diagnostics <- if (identical(requested_family, "pearson")) NULL else {
@@ -723,11 +820,14 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
723820
# NOTE: "Target Variables" / "Data Rows" instead of the shorter "Variables" / "Rows":
724821
# the client's shared translation map already binds those two keys to different wordings
725822
# for other tables, and a direct-map key can only have one translation. (issue #26623)
726-
Item = c("Correlation", "Factor Extraction Method", "Rotation", "Target Variables", "Data Rows"),
823+
Item = c("Correlation", "Factor Extraction Method", "Rotation", "Parallel Analysis Method", "Target Variables", "Data Rows"),
727824
Value = c(
728825
factanal_correlation_label(cor_type),
729826
factanal_extraction_method_label(x$fm),
730827
factanal_rotation_label(rotation),
828+
# issue tam#37332: reported even when parallel analysis itself failed (x$parallel NULL),
829+
# since x$parallel_method is set independently of whether the computation succeeded.
830+
factanal_parallel_method_label(x$parallel_method),
731831
if (length(x$n_variables) == 1L && !is.na(x$n_variables)) as.character(x$n_variables) else "N/A",
732832
if (length(x$n_rows_used) == 1L && !is.na(x$n_rows_used)) as.character(x$n_rows_used) else "N/A"
733833
),
@@ -763,26 +863,45 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
763863
}
764864
}
765865
else if (type == "factor_count") {
866+
# Kaiser criterion always reads the plain correlation-matrix eigenvalues (issue tam#37332
867+
# section 10): it is a separate, well-known rule of thumb that this change does not touch.
766868
eig <- eigen(x$correlation, symmetric = TRUE, only.values = TRUE)$values
767869
kaiser_n <- sum(eig > 1)
768870
par <- x$parallel
769871
parallel_rec <- if (is.null(par)) "Not available" else as.character(par$recommended_n)
872+
# The description names which eigenvalue the Parallel Analysis row actually compared, so it
873+
# stays honest once the method is selectable (issue tam#37332).
874+
parallel_description <- if (is.null(par)) {
875+
"Parallel analysis could not be computed."
876+
} else if (identical(par$method, "smc")) {
877+
"Number of factors whose SMC-based factor eigenvalue exceeds the random-data threshold."
878+
} else {
879+
"Number of factors whose factor-model eigenvalue exceeds the random-data threshold."
880+
}
770881
res <- tibble::tibble(
771882
Method = c("Kaiser Criterion", "Parallel Analysis", "Scree Plot"),
772883
`Recommended Number of Factors` = c(as.character(kaiser_n), parallel_rec, "Check the chart"),
773884
Description = c(
774885
"Number of factors with an eigenvalue greater than 1.",
775-
"Number of factors whose eigenvalue exceeds the random-data eigenvalue.",
886+
parallel_description,
776887
"Look for the point where the eigenvalue drop levels off (the elbow)."
777888
)
778889
)
779890
}
780891
else if (type == "parallel_screeplot") {
781-
eig <- eigen(x$correlation, symmetric = TRUE, only.values = TRUE)$values
782892
par <- x$parallel
783-
threshold <- if (is.null(par)) rep(NA_real_, length(eig)) else par$table$random_eigenvalue_threshold
784-
length(threshold) <- length(eig) # pad/truncate to align with eigenvalue count
785-
res <- tibble::tibble(Factor = 1:length(eig), Eigenvalue = eig, `Random Data Eigenvalue` = threshold)
893+
# The actual-data curve MUST be the same kind of eigenvalue the parallel analysis itself used
894+
# (factor-model or SMC) -- not the plain correlation-matrix eigenvalues -- or the chart compares
895+
# two different quantities against each other. (issue tam#37332 section 9)
896+
if (is.null(par)) {
897+
n_vars <- ncol(x$correlation)
898+
eig <- rep(NA_real_, n_vars)
899+
threshold <- rep(NA_real_, n_vars)
900+
} else {
901+
eig <- par$table$actual_eigenvalue
902+
threshold <- par$table$random_eigenvalue_threshold
903+
}
904+
res <- tibble::tibble(Factor = seq_along(eig), Eigenvalue = eig, `Random Data Eigenvalue` = threshold)
786905
}
787906
else if (type == "score_coefficients") {
788907
# Factor score coefficients (因子得点係数 / SPSS's Factor Score Coefficient Matrix) -- the weights

R/factanal_correlation.R

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,17 @@ factanal_extraction_method_label <- function(fm) {
566566
as.character(fm))
567567
}
568568

569+
# NULL (a model saved before issue tam#37332) is treated as the "factor_model" default -- the
570+
# analysis actually ran with that behavior even though the field did not exist yet. Any other
571+
# unrecognized value is a genuine anomaly, so it is reported as "Not Available" rather than guessed.
572+
factanal_parallel_method_label <- function(method) {
573+
method <- if (is.null(method) || length(method) != 1L || is.na(method)) "factor_model" else as.character(method)
574+
switch(method,
575+
factor_model = "Factor Model",
576+
smc = "Diagonal SMC",
577+
"Not Available")
578+
}
579+
569580

570581
# Language-neutral tokens for the selector's warnings, so the client can render localized text
571582
# instead of the English sentences the selector composes for logs. (issue #26623)

0 commit comments

Comments
 (0)