Skip to content

Commit 3ff1f59

Browse files
committed
feat(factanal): parallel analysis method (Factor Model / Diagonal SMC) (#37332)
compute_parallel_analysis() and its new compute_parallel_factor_eigenvalues() helper compute parallel-analysis eigenvalues via a selectable method instead of a plain correlation-matrix eigen(): "factor_model" (one-factor model using the selected extraction method, the new default) or "smc" (diagonal replaced by squared multiple correlations). The same method is applied to both the actual data and the random-data null distribution. exp_factanal() gains parallel_method (default "factor_model"), forwarded to compute_parallel_analysis() along with fm. fit$parallel_method is kept even when the parallel computation itself fails, so the report can still show the selected setting. Recommended factor count now stops at the first non-retained factor (compute_parallel_recommended_n) instead of summing every factor whose eigenvalue exceeds the threshold anywhere in the sequence. parallel_screeplot reads the parallel analysis's own actual_eigenvalue instead of recomputing a plain eigen() of the correlation matrix, so the chart compares like with like. The plain (non-parallel) scree plot and the Kaiser criterion are intentionally untouched. analysis_method table gains a "Parallel Analysis Method" row (factanal_parallel_method_label(); NULL -- a pre-#37332 saved model -- degrades to "Factor Model"). factor_count's Parallel Analysis description names which eigenvalue kind was actually compared. do_prcomp() explicitly passes method = "pca" to compute_parallel_analysis() so its own (unrelated, pre-existing) PCA-style parallel analysis keeps using plain, untouched-diagonal eigenvalues -- required so PCA's "Eigenvalue == x$sdev^2 when normalized" invariant is not silently broken by Factor Analysis's new default. Updated 3 pre-#37332 tests whose assertions encoded the old plain-correlation-eigenvalue behavior as if it were correct; added dedicated coverage for the new method dispatch, recommended-n semantics, and report surfaces.
1 parent fe4e304 commit 3ff1f59

5 files changed

Lines changed: 313 additions & 74 deletions

File tree

R/factanal.R

Lines changed: 178 additions & 61 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,13 +271,44 @@ 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
})
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)
225302
random_threshold <- apply(random_eigen_mat, 1, stats::quantile, probs = quantile_prob)
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+
recommended_n = recommended_n,
228312
table = tibble::tibble(
229313
factor_number = seq_along(actual_eigen),
230314
actual_eigenvalue = actual_eigen,
@@ -242,8 +326,14 @@ compute_parallel_analysis <- function(x, n_iter = 100, quantile_prob = 0.95,
242326
#' @param parallel_n_iter Iterations of the parallel analysis null distribution (default 100 for
243327
#' every correlation type). Polychoric-family correlations re-estimate the correlation on
244328
#' each iteration, so this is the dominant cost of a polychoric run.
329+
#' @param parallel_method How communalities are estimated for the parallel-analysis eigenvalues:
330+
#' "factor_model" (default; a one-factor model using `fm`) or "smc" (diagonal replaced by
331+
#' squared multiple correlations). Applied identically to the actual data and the random-data
332+
#' null distribution. (issue tam#37332)
245333
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) {
334+
cor_type = "auto", parallel_n_iter = NULL,
335+
parallel_method = c("factor_model", "smc")) {
336+
parallel_method <- match.arg(parallel_method)
247337
# this evaluates select arguments like starts_with
248338
selected_cols <- tidyselect::vars_select(names(df), !!! rlang::quos(...))
249339
if (length(selected_cols) < nfactors) {
@@ -423,8 +513,13 @@ exp_factanal <- function(df, ..., nfactors = 2, fm = "minres", scores = "regress
423513
fit$bartlett <- tryCatch(psych::cortest.bartlett(cor_mat, n = nrow(encoded_df)), error = function(e) NULL)
424514
n_iter <- if (!is.null(parallel_n_iter)) parallel_n_iter else 100
425515
fit$parallel <- tryCatch(compute_parallel_analysis(encoded_df, n_iter = n_iter,
426-
cor_type = resolved$type, cor_matrix = cor_mat),
516+
cor_type = resolved$type, cor_matrix = cor_mat,
517+
method = parallel_method, fm = fm),
427518
error = function(e) NULL)
519+
# Kept even when fit$parallel itself is NULL (e.g. n_iter too small to yield a usable null
520+
# distribution), so the report can still show which setting was selected. (issue tam#37332)
521+
fit$parallel_method <- parallel_method
522+
fit$parallel_factor_extraction_method <- fm
428523
# Keyed off the REQUESTED family, so a failed polychoric estimation still shows the table with
429524
# its "Estimation failed" row rather than hiding the only signal the user has.
430525
fit$cor_diagnostics <- if (identical(requested_family, "pearson")) NULL else {
@@ -723,11 +818,14 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
723818
# NOTE: "Target Variables" / "Data Rows" instead of the shorter "Variables" / "Rows":
724819
# the client's shared translation map already binds those two keys to different wordings
725820
# 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"),
821+
Item = c("Correlation", "Factor Extraction Method", "Rotation", "Parallel Analysis Method", "Target Variables", "Data Rows"),
727822
Value = c(
728823
factanal_correlation_label(cor_type),
729824
factanal_extraction_method_label(x$fm),
730825
factanal_rotation_label(rotation),
826+
# issue tam#37332: reported even when parallel analysis itself failed (x$parallel NULL),
827+
# since x$parallel_method is set independently of whether the computation succeeded.
828+
factanal_parallel_method_label(x$parallel_method),
731829
if (length(x$n_variables) == 1L && !is.na(x$n_variables)) as.character(x$n_variables) else "N/A",
732830
if (length(x$n_rows_used) == 1L && !is.na(x$n_rows_used)) as.character(x$n_rows_used) else "N/A"
733831
),
@@ -763,26 +861,45 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
763861
}
764862
}
765863
else if (type == "factor_count") {
864+
# Kaiser criterion always reads the plain correlation-matrix eigenvalues (issue tam#37332
865+
# section 10): it is a separate, well-known rule of thumb that this change does not touch.
766866
eig <- eigen(x$correlation, symmetric = TRUE, only.values = TRUE)$values
767867
kaiser_n <- sum(eig > 1)
768868
par <- x$parallel
769869
parallel_rec <- if (is.null(par)) "Not available" else as.character(par$recommended_n)
870+
# The description names which eigenvalue the Parallel Analysis row actually compared, so it
871+
# stays honest once the method is selectable (issue tam#37332).
872+
parallel_description <- if (is.null(par)) {
873+
"Parallel analysis could not be computed."
874+
} else if (identical(par$method, "smc")) {
875+
"Number of factors whose SMC-based factor eigenvalue exceeds the random-data threshold."
876+
} else {
877+
"Number of factors whose factor-model eigenvalue exceeds the random-data threshold."
878+
}
770879
res <- tibble::tibble(
771880
Method = c("Kaiser Criterion", "Parallel Analysis", "Scree Plot"),
772881
`Recommended Number of Factors` = c(as.character(kaiser_n), parallel_rec, "Check the chart"),
773882
Description = c(
774883
"Number of factors with an eigenvalue greater than 1.",
775-
"Number of factors whose eigenvalue exceeds the random-data eigenvalue.",
884+
parallel_description,
776885
"Look for the point where the eigenvalue drop levels off (the elbow)."
777886
)
778887
)
779888
}
780889
else if (type == "parallel_screeplot") {
781-
eig <- eigen(x$correlation, symmetric = TRUE, only.values = TRUE)$values
782890
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)
891+
# The actual-data curve MUST be the same kind of eigenvalue the parallel analysis itself used
892+
# (factor-model or SMC) -- not the plain correlation-matrix eigenvalues -- or the chart compares
893+
# two different quantities against each other. (issue tam#37332 section 9)
894+
if (is.null(par)) {
895+
n_vars <- ncol(x$correlation)
896+
eig <- rep(NA_real_, n_vars)
897+
threshold <- rep(NA_real_, n_vars)
898+
} else {
899+
eig <- par$table$actual_eigenvalue
900+
threshold <- par$table$random_eigenvalue_threshold
901+
}
902+
res <- tibble::tibble(Factor = seq_along(eig), Eigenvalue = eig, `Random Data Eigenvalue` = threshold)
786903
}
787904
else if (type == "score_coefficients") {
788905
# 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)

R/prcomp.R

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,12 @@ do_prcomp <- function(df, ..., normalize_data=TRUE, max_nrow = NULL, allow_singl
338338
}
339339

340340
fit$parallel <- tryCatch(
341-
compute_parallel_analysis(encoded_df, cor_type = resolved$type, cor_matrix = fit$correlation),
341+
# method = "pca": keep PCA's own parallel analysis on plain (untouched-diagonal) correlation
342+
# eigenvalues -- compute_parallel_analysis() now defaults to Factor Analysis's
343+
# "factor_model" method (issue tam#37332), which do_prcomp must NOT silently pick up, since
344+
# "Eigenvalue = x$sdev^2" below is only guaranteed to match the PCA-style eigenvalues.
345+
compute_parallel_analysis(encoded_df, cor_type = resolved$type, cor_matrix = fit$correlation,
346+
method = "pca"),
342347
error = function(e) NULL)
343348
fit$kaiser_components <- tryCatch(
344349
# A correlation matrix is standardized by construction, so under a categorical correlation

0 commit comments

Comments
 (0)