@@ -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