Skip to content

Commit 8947278

Browse files
hidekojiclaude
andcommitted
Merge origin/master into fix/issue-37340-factanal-report-part3
master shipped #37332 (parallel analysis method) and took 16.0.25, so this work moves to 16.0.26. Conflict resolutions: - analysis_method: keep BOTH changes -- the two data counts lead the table (#37340) and the method rows keep their own order, now including master's Parallel Analysis Method row (#37332). - Master's tests read that row positionally (index 4); #37340 moved it to 6, so those assertions now look it up by Item name. Same for the legacy-model assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 parents 4621eda + 8988023 commit 8947278

6 files changed

Lines changed: 331 additions & 82 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.25
5-
Date: 2026-07-28
4+
Version: 16.0.26
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: 186 additions & 65 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 {
@@ -727,20 +824,25 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
727824
rotation <- if (!is.null(x$rotation)) x$rotation else "none"
728825
res <- tibble::tibble(
729826
# The variable / row counts come FIRST (issue tam#37340): the section is now
730-
# 「分析条件とデータの確認」, which leads with what data was analyzed.
827+
# 「分析条件とデータの確認」, which leads with what data was analyzed. The method rows keep
828+
# their own relative order after them, including the Parallel Analysis Method row (tam#37332).
731829
# NOTE on the two count keys: they were "Target Variables" / "Data Rows" while the shorter
732830
# "Variables" / "Rows" were already bound to other wordings in the client's shared
733831
# translation map (#26623). They are now "Number of Variables" / "Row Count" -- keys the map
734832
# ALREADY carries with exactly the wanted JA (変数の数 / 行数) from PCA's analysis_conditions
735833
# table (#37268), so no new translation key is needed. Do NOT use "Number of Rows": that key
736834
# is bound to 行の数 elsewhere in the same map.
737-
Item = c("Number of Variables", "Row Count", "Correlation", "Factor Extraction Method", "Rotation"),
835+
Item = c("Number of Variables", "Row Count", "Correlation", "Factor Extraction Method",
836+
"Rotation", "Parallel Analysis Method"),
738837
Value = c(
739838
if (length(x$n_variables) == 1L && !is.na(x$n_variables)) as.character(x$n_variables) else "N/A",
740839
if (length(x$n_rows_used) == 1L && !is.na(x$n_rows_used)) as.character(x$n_rows_used) else "N/A",
741840
factanal_correlation_label(cor_type),
742841
factanal_extraction_method_label(x$fm),
743-
factanal_rotation_label(rotation)
842+
factanal_rotation_label(rotation),
843+
# issue tam#37332: reported even when parallel analysis itself failed (x$parallel NULL),
844+
# since x$parallel_method is set independently of whether the computation succeeded.
845+
factanal_parallel_method_label(x$parallel_method)
744846
),
745847
# Hidden columns. The client reads them to bind the report's explanation text; they are not
746848
# part of the rendered Item/Value table. The booleans are emitted as EXPLICIT "TRUE"/"FALSE"
@@ -774,16 +876,27 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
774876
}
775877
}
776878
else if (type == "factor_count") {
879+
# Kaiser criterion always reads the plain correlation-matrix eigenvalues (issue tam#37332
880+
# section 10): it is a separate, well-known rule of thumb that this change does not touch.
777881
eig <- eigen(x$correlation, symmetric = TRUE, only.values = TRUE)$values
778882
kaiser_n <- sum(eig > 1)
779883
par <- x$parallel
780884
parallel_rec <- if (is.null(par)) "Not available" else as.character(par$recommended_n)
885+
# The description names which eigenvalue the Parallel Analysis row actually compared, so it
886+
# stays honest once the method is selectable (issue tam#37332).
887+
parallel_description <- if (is.null(par)) {
888+
"Parallel analysis could not be computed."
889+
} else if (identical(par$method, "smc")) {
890+
"Number of factors whose SMC-based factor eigenvalue exceeds the random-data threshold."
891+
} else {
892+
"Number of factors whose factor-model eigenvalue exceeds the random-data threshold."
893+
}
781894
res <- tibble::tibble(
782895
Method = c("Kaiser Criterion", "Parallel Analysis", "Scree Plot"),
783896
`Recommended Number of Factors` = c(as.character(kaiser_n), parallel_rec, "Check the chart"),
784897
Description = c(
785898
"Number of factors with an eigenvalue greater than 1.",
786-
"Number of factors whose eigenvalue exceeds the random-data eigenvalue.",
899+
parallel_description,
787900
"Look for the point where the eigenvalue drop levels off (the elbow)."
788901
)
789902
)
@@ -845,11 +958,19 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
845958
)
846959
}
847960
else if (type == "parallel_screeplot") {
848-
eig <- eigen(x$correlation, symmetric = TRUE, only.values = TRUE)$values
849961
par <- x$parallel
850-
threshold <- if (is.null(par)) rep(NA_real_, length(eig)) else par$table$random_eigenvalue_threshold
851-
length(threshold) <- length(eig) # pad/truncate to align with eigenvalue count
852-
res <- tibble::tibble(Factor = 1:length(eig), Eigenvalue = eig, `Random Data Eigenvalue` = threshold)
962+
# The actual-data curve MUST be the same kind of eigenvalue the parallel analysis itself used
963+
# (factor-model or SMC) -- not the plain correlation-matrix eigenvalues -- or the chart compares
964+
# two different quantities against each other. (issue tam#37332 section 9)
965+
if (is.null(par)) {
966+
n_vars <- ncol(x$correlation)
967+
eig <- rep(NA_real_, n_vars)
968+
threshold <- rep(NA_real_, n_vars)
969+
} else {
970+
eig <- par$table$actual_eigenvalue
971+
threshold <- par$table$random_eigenvalue_threshold
972+
}
973+
res <- tibble::tibble(Factor = seq_along(eig), Eigenvalue = eig, `Random Data Eigenvalue` = threshold)
853974
}
854975
else if (type == "score_coefficients") {
855976
# Factor score coefficients (因子得点係数 / SPSS's Factor Score Coefficient Matrix) -- the weights

0 commit comments

Comments
 (0)