@@ -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.
146224compute_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)
245335exp_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
0 commit comments