Skip to content

Commit 856da62

Browse files
authored
Merge pull request #1572 from exploratory-io/fix/issue-37340-factanal-report-part3
Factor Analysis report part 3: variances_judged tidy type, suitability P-value format, analysis_method order (tam#37340)
2 parents 8988023 + 8947278 commit 856da62

4 files changed

Lines changed: 189 additions & 28 deletions

File tree

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Package: exploratory
22
Type: Package
33
Title: R package for Exploratory
4-
Version: 16.0.25
4+
Version: 16.0.26
55
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

R/factanal.R

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -799,11 +799,17 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
799799
kj <- judge_kmo(kmo)
800800
bj <- judge_bartlett(p)
801801
kmo_val <- if (is.na(kmo)) "N/A" else format(round(kmo, 2), nsmall = 2)
802-
bart_val <- if (is.na(p)) "N/A" else if (p < 0.001) "p < 0.001" else paste0("p = ", format(round(p, 3), nsmall = 3))
802+
# The Value cell holds the VALUE ONLY -- no "p < " / "p = " prefix, since the Metric name now
803+
# says "(P Value)". Small p values are reported as "< 0.0001" (NOT the old 0.001 threshold), so
804+
# this is the one place in the report that shows 4 decimals rather than the usual 3: 3 decimals
805+
# cannot express the threshold itself. (issue tam#37340)
806+
# formatC(format = "f") -- NOT format(nsmall = 4), which switches to scientific notation for a
807+
# small-but-above-threshold p (0.0002 rendered as "2e-04").
808+
bart_val <- if (is.na(p)) "N/A" else if (p < 0.0001) "< 0.0001" else formatC(p, format = "f", digits = 4)
803809
rows_used <- if (length(x$n_rows_used) == 1L && !is.na(x$n_rows_used)) as.character(x$n_rows_used) else "N/A"
804810
variables_used <- if (length(x$n_variables) == 1L && !is.na(x$n_variables)) as.character(x$n_variables) else "N/A"
805811
res <- tibble::tibble(
806-
Metric = c("KMO", "Bartlett's Test of Sphericity", "Rows Used", "Variables Used"),
812+
Metric = c("KMO", "Bartlett's Test of Sphericity (P Value)", "Rows Used", "Variables Used"),
807813
Value = c(kmo_val, bart_val, rows_used, variables_used),
808814
Judgement = c(kj$label, bj$label, "", ""),
809815
Description = c(kj$description, bj$description,
@@ -817,19 +823,26 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
817823
cor_type <- if (is.null(x$correlation_type)) "pearson" else x$correlation_type
818824
rotation <- if (!is.null(x$rotation)) x$rotation else "none"
819825
res <- tibble::tibble(
820-
# NOTE: "Target Variables" / "Data Rows" instead of the shorter "Variables" / "Rows":
821-
# the client's shared translation map already binds those two keys to different wordings
822-
# for other tables, and a direct-map key can only have one translation. (issue #26623)
823-
Item = c("Correlation", "Factor Extraction Method", "Rotation", "Parallel Analysis Method", "Target Variables", "Data Rows"),
826+
# The variable / row counts come FIRST (issue tam#37340): the section is now
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).
829+
# NOTE on the two count keys: they were "Target Variables" / "Data Rows" while the shorter
830+
# "Variables" / "Rows" were already bound to other wordings in the client's shared
831+
# translation map (#26623). They are now "Number of Variables" / "Row Count" -- keys the map
832+
# ALREADY carries with exactly the wanted JA (変数の数 / 行数) from PCA's analysis_conditions
833+
# table (#37268), so no new translation key is needed. Do NOT use "Number of Rows": that key
834+
# is bound to 行の数 elsewhere in the same map.
835+
Item = c("Number of Variables", "Row Count", "Correlation", "Factor Extraction Method",
836+
"Rotation", "Parallel Analysis Method"),
824837
Value = c(
838+
if (length(x$n_variables) == 1L && !is.na(x$n_variables)) as.character(x$n_variables) else "N/A",
839+
if (length(x$n_rows_used) == 1L && !is.na(x$n_rows_used)) as.character(x$n_rows_used) else "N/A",
825840
factanal_correlation_label(cor_type),
826841
factanal_extraction_method_label(x$fm),
827842
factanal_rotation_label(rotation),
828843
# issue tam#37332: reported even when parallel analysis itself failed (x$parallel NULL),
829844
# since x$parallel_method is set independently of whether the computation succeeded.
830-
factanal_parallel_method_label(x$parallel_method),
831-
if (length(x$n_variables) == 1L && !is.na(x$n_variables)) as.character(x$n_variables) else "N/A",
832-
if (length(x$n_rows_used) == 1L && !is.na(x$n_rows_used)) as.character(x$n_rows_used) else "N/A"
845+
factanal_parallel_method_label(x$parallel_method)
833846
),
834847
# Hidden columns. The client reads them to bind the report's explanation text; they are not
835848
# part of the rendered Item/Value table. The booleans are emitted as EXPLICIT "TRUE"/"FALSE"
@@ -888,6 +901,62 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
888901
)
889902
)
890903
}
904+
else if (type == "variances_judged") {
905+
# Per-factor eigenvalue table with the three retention judgments, for the report's
906+
# 「因子数の判定」 section (issue tam#37340). Same shape as prcomp.R's "variances_judged" branch
907+
# so the client can reuse the PCA table's viz configuration.
908+
#
909+
# One row per correlation-matrix eigenvalue (i.e. per VARIABLE, not per extracted factor): the
910+
# judgments only mean something when every candidate factor is listed, exactly like PCA's
911+
# PC1..PCn. Eigenvalue / % Variance / Cummulated % Variance therefore all come off the SAME
912+
# eigen(x$correlation) basis the screeplot / parallel_screeplot / factor_count branches use --
913+
# NOT psych's Vaccounted "Proportion Var", which only exists for the extracted factors and
914+
# would leave the remaining rows empty. So the ratios here are eigenvalue shares and can
915+
# legitimately differ from the 「各因子の寄与率」 chart, which is Vaccounted-based.
916+
eig <- eigen(x$correlation, symmetric = TRUE, only.values = TRUE)$values
917+
n_row <- length(eig)
918+
total_variance <- sum(eig)
919+
pct_variance <- if (is.finite(total_variance) && total_variance != 0) eig / total_variance * 100 else rep(NA_real_, n_row)
920+
par <- x$parallel
921+
if (is.null(par)) {
922+
parallel_label <- rep("Not Available", n_row)
923+
parallel_status <- rep("na", n_row)
924+
}
925+
else {
926+
ptbl <- par$table
927+
in_range <- ptbl$factor_number <= n_row
928+
actual <- rep(NA_real_, n_row)
929+
threshold <- rep(NA_real_, n_row)
930+
actual[ptbl$factor_number[in_range]] <- ptbl$actual_eigenvalue[in_range]
931+
threshold[ptbl$factor_number[in_range]] <- ptbl$random_eigenvalue_threshold[in_range]
932+
adopted <- !is.na(actual) & !is.na(threshold) & actual > threshold
933+
parallel_label <- ifelse(adopted, "Adopted", "Not Adopted")
934+
parallel_status <- ifelse(adopted, "adopted", "not_adopted")
935+
}
936+
# Kaiser is always meaningful here: factor analysis always fits a correlation matrix, so the
937+
# eigenvalue >= 1 rule applies unconditionally (unlike PCA, where a covariance-scaled fit makes
938+
# it "na"). The comparison matches the factor_count branch's kaiser_n (eig > 1).
939+
# "Adopted" / "Not Adopted", not PCA's "Adopt" / "Not Adopted": the pair reads as a judgment in
940+
# the English report, and both map to the same 採用 / 非採用 in Japanese. (tam#37340)
941+
kaiser_adopted <- eig > 1
942+
# Adopted = the factors this analysis actually extracted (the nfactors setting).
943+
selected_adopted <- seq_len(n_row) <= n_factor
944+
res <- tibble::tibble(
945+
Factor = as.character(seq_len(n_row)),
946+
Eigenvalue = eig,
947+
`% Variance` = pct_variance,
948+
`Cummulated % Variance` = cumsum(pct_variance),
949+
`Parallel Analysis` = parallel_label,
950+
`Kaiser Criterion` = ifelse(kaiser_adopted, "Adopted", "Not Adopted"),
951+
# Column name "Adoption", not PCA's "Selected": the cells read "Adopted" / "Not Adopted", so a
952+
# "Selected" header would not agree with its own values in the English report. Both map to
953+
# 採否 / 採用 / 非採用 in Japanese. (tam#37340)
954+
Adoption = ifelse(selected_adopted, "Adopted", "Not Adopted"),
955+
parallel_status = parallel_status,
956+
kaiser_status = ifelse(kaiser_adopted, "adopted", "not_adopted"),
957+
selected_status = ifelse(selected_adopted, "adopted", "not_adopted")
958+
)
959+
}
891960
else if (type == "parallel_screeplot") {
892961
par <- x$parallel
893962
# The actual-data curve MUST be the same kind of eigenvalue the parallel analysis itself used

tests/testthat/test_factanal.R

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ test_that("exp_factanal with default orthogonal varimax rotation", {
5151
# New report tidy types (issue #37018).
5252
res <- model_df %>% tidy_rowwise(model, type="suitability")
5353
expect_equal(colnames(res), c("Metric", "Value", "Judgement", "Description", "status"))
54-
expect_equal(res$Metric, c("KMO", "Bartlett's Test of Sphericity", "Rows Used", "Variables Used"))
54+
expect_equal(res$Metric, c("KMO", "Bartlett's Test of Sphericity (P Value)", "Rows Used", "Variables Used")) # #37340
5555
res <- model_df %>% tidy_rowwise(model, type="factor_count")
5656
expect_equal(colnames(res), c("Method", "Recommended Number of Factors", "Description"))
5757
expect_equal(res$Method, c("Kaiser Criterion", "Parallel Analysis", "Scree Plot"))
@@ -422,12 +422,14 @@ test_that("parallel analysis method: factor_model vs smc (issue #37332)", {
422422
fc_smc <- smc_model_df %>% tidy_rowwise(model, type = "factor_count")
423423
expect_equal(fc_smc$Description[[2]], "Number of factors whose SMC-based factor eigenvalue exceeds the random-data threshold.")
424424

425-
# analysis_method table carries the new row.
425+
# analysis_method table carries the new row. Read BY ITEM NAME: #37340 moved the two data counts
426+
# to the top of the table, so the row's position is no longer 4.
427+
am_value <- function(tbl, item) tbl$Value[[which(tbl$Item == item)]]
426428
am_factor_model <- model_df %>% tidy_rowwise(model, type = "analysis_method")
427-
expect_equal(am_factor_model$Item[[4]], "Parallel Analysis Method")
428-
expect_equal(am_factor_model$Value[[4]], "Factor Model")
429+
expect_true("Parallel Analysis Method" %in% am_factor_model$Item)
430+
expect_equal(am_value(am_factor_model, "Parallel Analysis Method"), "Factor Model")
429431
am_smc <- smc_model_df %>% tidy_rowwise(model, type = "analysis_method")
430-
expect_equal(am_smc$Value[[4]], "Diagonal SMC")
432+
expect_equal(am_value(am_smc, "Parallel Analysis Method"), "Diagonal SMC")
431433

432434
# factanal_parallel_method_label: NULL degrades to the factor_model default; unknown -> Not Available.
433435
expect_equal(factanal_parallel_method_label(NULL), "Factor Model")
@@ -440,5 +442,89 @@ test_that("parallel analysis method: factor_model vs smc (issue #37332)", {
440442
legacy_fit <- fit
441443
legacy_fit$parallel_method <- NULL
442444
legacy_am <- tidy(legacy_fit, type = "analysis_method")
443-
expect_equal(legacy_am$Value[[4]], "Factor Model")
445+
# #37340 moved the two data counts to the top, so read the row BY NAME rather than by position.
446+
expect_equal(legacy_am$Value[[which(legacy_am$Item == "Parallel Analysis Method")]], "Factor Model")
447+
})
448+
449+
test_that("report part 3: variances_judged, suitability P value format, analysis_method order (issue tam#37340)", {
450+
model_df <- mtcars %>%
451+
exp_factanal(mpg, cyl, disp, hp, drat, wt, qsec, nfactors = 2, fm = "minres",
452+
rotate = "varimax", cor_type = "pearson", parallel_n_iter = 5)
453+
fit <- model_df$model[[1]]
454+
455+
# --- variances_judged: one row per correlation-matrix eigenvalue (per VARIABLE, like PCA's PCn),
456+
# so every candidate factor is judged -- not just the extracted ones.
457+
judged <- tidy(fit, type = "variances_judged")
458+
expect_equal(colnames(judged),
459+
c("Factor", "Eigenvalue", "% Variance", "Cummulated % Variance",
460+
"Parallel Analysis", "Kaiser Criterion", "Adoption",
461+
"parallel_status", "kaiser_status", "selected_status"))
462+
n_var <- length(fit$communality)
463+
expect_equal(nrow(judged), n_var)
464+
expect_equal(judged$Factor, as.character(seq_len(n_var)))
465+
# Eigenvalues come off eigen(x$correlation) -- the SAME basis as screeplot / parallel_screeplot,
466+
# descending, and the ratios are eigenvalue shares summing to 100%.
467+
expect_equal(judged$Eigenvalue, eigen(fit$correlation, symmetric = TRUE, only.values = TRUE)$values)
468+
expect_equal(sum(judged$`% Variance`), 100)
469+
expect_equal(judged$`Cummulated % Variance`, cumsum(judged$`% Variance`))
470+
expect_equal(judged$`Cummulated % Variance`[[n_var]], 100)
471+
# Kaiser mirrors the factor_count branch's kaiser_n (eigenvalue > 1) and is ALWAYS judged for
472+
# factor analysis (always a correlation matrix) -- never "na" the way covariance-scaled PCA is.
473+
expect_equal(sum(judged$kaiser_status == "adopted"), sum(judged$Eigenvalue > 1))
474+
expect_false(any(judged$kaiser_status == "na"))
475+
# Selected = the factors this analysis actually extracted (nfactors), first rows only.
476+
expect_equal(judged$selected_status, ifelse(seq_len(n_var) <= 2, "adopted", "not_adopted"))
477+
expect_equal(judged$Adoption, ifelse(seq_len(n_var) <= 2, "Adopted", "Not Adopted"))
478+
# Labels stay English-canonical; the client translates them. "Adopted" (not PCA's "Adopt") so the
479+
# English report reads as a judgment against "Not Adopted".
480+
expect_true(all(judged$`Parallel Analysis` %in% c("Adopted", "Not Adopted", "Not Available")))
481+
expect_true(all(judged$`Kaiser Criterion` %in% c("Adopted", "Not Adopted")))
482+
expect_true(all(judged$parallel_status %in% c("adopted", "not_adopted", "na")))
483+
# Parallel analysis unavailable (old saved model) degrades to Not Available / na, same shape.
484+
no_par <- fit
485+
no_par$parallel <- NULL
486+
judged_no_par <- tidy(no_par, type = "variances_judged")
487+
expect_equal(unique(judged_no_par$`Parallel Analysis`), "Not Available")
488+
expect_equal(unique(judged_no_par$parallel_status), "na")
489+
expect_equal(nrow(judged_no_par), n_var)
490+
491+
# --- analysis_method: counts first, then the method rows (#37340).
492+
method_tbl <- tidy(fit, type = "analysis_method")
493+
expect_equal(method_tbl$Item, c("Number of Variables", "Row Count", "Correlation",
494+
"Factor Extraction Method", "Rotation", "Parallel Analysis Method"))
495+
expect_equal(method_tbl$Value[[1]], as.character(n_var))
496+
expect_equal(method_tbl$Value[[2]], as.character(nrow(mtcars)))
497+
expect_equal(method_tbl$Value[[3]], "Pearson Correlation")
498+
499+
# --- suitability: Metric names the P value, the Value cell holds the value ONLY, and the
500+
# small-p threshold is 0.0001 (was 0.001).
501+
suit <- tidy(fit, type = "suitability")
502+
expect_equal(suit$Metric[[2]], "Bartlett's Test of Sphericity (P Value)")
503+
expect_false(grepl("p", suit$Value[[2]], fixed = TRUE)) # no "p < " / "p = " prefix
504+
expect_true(grepl("^(< 0\\.0001|[0-9]+\\.[0-9]{4}|N/A)$", suit$Value[[2]]))
505+
fake <- fit
506+
fake$bartlett <- list(p.value = 1e-9)
507+
expect_equal(tidy(fake, type = "suitability")$Value[[2]], "< 0.0001")
508+
fake$bartlett <- list(p.value = 0.00005)
509+
expect_equal(tidy(fake, type = "suitability")$Value[[2]], "< 0.0001")
510+
# Just ABOVE the threshold: 4 fixed decimals, never scientific notation ("2e-04").
511+
fake$bartlett <- list(p.value = 0.0002)
512+
expect_equal(tidy(fake, type = "suitability")$Value[[2]], "0.0002")
513+
fake$bartlett <- list(p.value = 0.03)
514+
expect_equal(tidy(fake, type = "suitability")$Value[[2]], "0.0300")
515+
fake$bartlett <- NULL
516+
expect_equal(tidy(fake, type = "suitability")$Value[[2]], "N/A")
517+
})
518+
519+
test_that("report part 3: an over-parameterized fit legitimately has no fit test (issue tam#37340)", {
520+
# 4 factors on 7 variables drives the model's degrees of freedom NEGATIVE, so psych::fa returns
521+
# no chi-square P value, no RMSEA and no BIC. The report must present that as "not available"
522+
# (and must NOT claim a hypothesis-test verdict) -- these blanks are the true output of an
523+
# over-parameterized fit, not a serialization bug, so glance is intentionally left as-is.
524+
model_df <- mtcars %>%
525+
exp_factanal(mpg, cyl, disp, hp, drat, wt, qsec, nfactors = 4, fm = "minres",
526+
rotate = "varimax", cor_type = "pearson", parallel_n_iter = 3)
527+
g <- glance(model_df$model[[1]])
528+
expect_true(g$DF <= 0)
529+
expect_true(is.na(g$`P Value`))
444530
})

0 commit comments

Comments
 (0)