Skip to content

Commit b0576d5

Browse files
authored
Merge pull request #1560 from exploratory-io/fix/issue-26623-diagnostics-robustness
WIP: Factor Analysis: diagnostics section robustness (tam#26623 follow-up)
2 parents ce8bc76 + f6aac99 commit b0576d5

4 files changed

Lines changed: 65 additions & 12 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.10
4+
Version: 16.0.11
55
Date: 2026-07-21
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: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,12 @@ exp_factanal <- function(df, ..., nfactors = 2, fm = "minres", scores = "regress
428428
# Keyed off the REQUESTED family, so a failed polychoric estimation still shows the table with
429429
# its "Estimation failed" row rather than hiding the only signal the user has.
430430
fit$cor_diagnostics <- if (identical(requested_family, "pearson")) NULL else {
431-
tryCatch(compute_polychoric_diagnostics(encoded_df, cor_result, selection), error = function(e) NULL)
431+
# For a categorical correlation the report ALWAYS shows the diagnostics section, so this must
432+
# never be NULL -- a NULL would leave the section's heading over an empty table. If the
433+
# computation fails, degrade to a 6-row "Not Available" table so the section stays honest and
434+
# non-empty. (issue #26623)
435+
tryCatch(compute_polychoric_diagnostics(encoded_df, cor_result, selection),
436+
error = function(e) unavailable_polychoric_diagnostics())
432437
}
433438
class(fit) <- c("fa_exploratory", class(fit))
434439
fit
@@ -727,19 +732,23 @@ tidy.fa_exploratory <- function(x, type="loadings", n_sample=NULL, pretty.name=F
727732
if (length(x$n_rows_used) == 1L && !is.na(x$n_rows_used)) as.character(x$n_rows_used) else "N/A"
728733
),
729734
# Hidden columns. The client reads them to bind the report's explanation text; they are not
730-
# part of the rendered Item/Value table.
735+
# part of the rendered Item/Value table. The booleans are emitted as EXPLICIT "TRUE"/"FALSE"
736+
# STRINGS, not R logicals: a logical column can reach the client serialized as "1"/"0" (or a
737+
# boolean) depending on the pivot pipeline, and the client's isTrue() only accepts
738+
# "TRUE"/"true"/true -- a "1" would read as false and, before the gate was hardened, hide a
739+
# real polychoric analysis's diagnostics. Strings remove that ambiguity. (issue #26623)
731740
correlation_type = cor_type,
732-
correlation_is_auto = isTRUE(x$correlation_is_auto),
741+
correlation_is_auto = if (isTRUE(x$correlation_is_auto)) "TRUE" else "FALSE",
733742
# Non-empty when the requested correlation could not be estimated and the fit fell back to
734743
# Pearson, so the report can say so instead of inventing a rationale.
735744
degraded_from = if (is.null(x$correlation_degraded_from)) "" else x$correlation_degraded_from,
736745
# Whether suggesting Polychoric makes sense for this data at all.
737-
polychoric_available = isTRUE(x$correlation_polychoric_available),
746+
polychoric_available = if (isTRUE(x$correlation_polychoric_available)) "TRUE" else "FALSE",
738747
# Language-neutral tokens for the selector's warnings; the client renders the localized text.
739748
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
749+
# "TRUE" also when a polychoric estimation failed and the fit degraded to Pearson, so the
741750
# report still shows the diagnostics table that reports the failure.
742-
has_diagnostics = !is.null(x$cor_diagnostics) && nrow(x$cor_diagnostics) > 0,
751+
has_diagnostics = if (!is.null(x$cor_diagnostics) && nrow(x$cor_diagnostics) > 0) "TRUE" else "FALSE",
743752
reason = if (is.null(x$correlation_reason)) "" else x$correlation_reason
744753
)
745754
}

R/factanal_correlation.R

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,27 @@ factanal_polychoric_available <- function(selection, data = NULL) {
596596
# in prose ("less than 5% of the responses" / 「回答が全体の5%未満」) in
597597
# tam/src/js/components/analysis/templates/markdown/exp_factanal{,_ja}.js. Changing the default here
598598
# means updating that wording too. (issue #26623)
599+
# A 6-row "Not Available" diagnostics table, matching compute_polychoric_diagnostics' shape. Used
600+
# when the real computation fails, so a categorical analysis's diagnostics section never renders as
601+
# an empty table under its heading. (issue #26623)
602+
unavailable_polychoric_diagnostics <- function() {
603+
tibble::tibble(
604+
Diagnostic = c("Number of Categories", "Sparse Categories", "Empty Category Combinations",
605+
"Correlation Estimation Failures", "Positive Definiteness of the Correlation Matrix",
606+
"Smoothing Applied"),
607+
Judgement = rep("Not Available", 6),
608+
Description = c(
609+
"Number of categories in the categorical variables used for the correlation.",
610+
"Categorical variables that have a category holding a very small share of the responses. Correlations estimated from categories may become unstable when categories are sparse.",
611+
"Variable pairs that have a category combination with no observations.",
612+
"Variable pairs whose correlation could not be estimated.",
613+
"Whether the estimated correlation matrix was positive definite, which factor analysis assumes, before any smoothing.",
614+
"Whether the correlation matrix had to be smoothed to become positive definite."
615+
),
616+
status = rep("na", 6)
617+
)
618+
}
619+
599620
compute_polychoric_diagnostics <- function(data, cor_result, selection,
600621
rare_category_prop_cutoff = 0.05) {
601622
data <- as.data.frame(data)

tests/testthat/test_factanal_correlation.R

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ test_that("analysis_method and cor_diagnostics tidy types (issue #26623)", {
176176
expect_equal(method_tbl$Value[[5]], as.character(nrow(df)))
177177
# Hidden columns the client binds the report explanation from.
178178
expect_equal(unique(method_tbl$correlation_type), "polychoric")
179-
expect_true(all(method_tbl$correlation_is_auto))
179+
expect_true(all(method_tbl$correlation_is_auto == "TRUE"))
180180
expect_true(nchar(method_tbl$reason[[1]]) > 0)
181181

182182
diagnostics <- tidy(poly, type = "cor_diagnostics")
@@ -283,8 +283,8 @@ test_that("verification pass 1 findings (issue #26623)", {
283283
expect_equal(rotated$correlation_reason, "Polychoric Correlation was selected manually.")
284284
expect_false(rotated$correlation_is_auto)
285285
method_tbl <- tidy(rotated, type = "analysis_method")
286-
expect_false(method_tbl$correlation_is_auto[[1]])
287-
expect_true(method_tbl$has_diagnostics[[1]])
286+
expect_equal(method_tbl$correlation_is_auto[[1]], "FALSE")
287+
expect_equal(method_tbl$has_diagnostics[[1]], "TRUE")
288288

289289
# An unsupported variable combination stays unsupported when the correlation is chosen manually:
290290
# picking Pearson must not smuggle a nominal column in as arbitrary integer codes.
@@ -332,7 +332,7 @@ test_that("verification pass 2 findings (issue #26623)", {
332332
# Whether Polychoric is worth suggesting is data-dependent.
333333
expect_true(factanal_polychoric_available(select_factor_correlation_type(df)))
334334
expect_false(factanal_polychoric_available(select_factor_correlation_type(mtcars[, c("mpg", "hp", "drat", "wt")])))
335-
expect_true(method_tbl$polychoric_available[[1]])
335+
expect_equal(method_tbl$polychoric_available[[1]], "TRUE")
336336
expect_equal(method_tbl$degraded_from[[1]], "")
337337

338338
# The category-count row says "categorical variables": in a mixed analysis the continuous
@@ -535,7 +535,7 @@ test_that("a failed estimation degrades to Pearson end to end (issue #26623)", {
535535
method_tbl <- tidy(fit, type = "analysis_method")
536536
expect_equal(method_tbl$Value[[1]], "Pearson Correlation")
537537
expect_equal(method_tbl$degraded_from[[1]], "polychoric")
538-
expect_true(method_tbl$has_diagnostics[[1]])
538+
expect_equal(method_tbl$has_diagnostics[[1]], "TRUE")
539539
diagnostics <- tidy(fit, type = "cor_diagnostics")
540540
expect_equal(diagnostics$Judgement[[4]], "Estimation failed")
541541
# The Pearson fallback matrix says nothing about the correlation that failed.
@@ -604,3 +604,26 @@ test_that("an unsupported facet is skipped, not fatal, under Repeat By (issue #2
604604
expect_error(exp_factanal(dplyr::select(bad, q1, q2, q3), q1, q2, q3, nfactors = 1, parallel_n_iter = 3),
605605
"EXP-ANA-35")
606606
})
607+
608+
test_that("a categorical correlation always yields a non-empty diagnostics table (issue #26623)", {
609+
# The report ALWAYS shows the diagnostics section for a categorical correlation, so cor_diagnostics
610+
# must never be NULL there -- otherwise the section renders a heading over an empty table, or (with
611+
# the old has_diagnostics-only gate) vanishes entirely on a real polychoric analysis.
612+
df <- factanal_ordinal_fixture()
613+
poly <- exp_factanal(df, q1, q2, q3, q4, q5, q6, nfactors = 2, rotate = "varimax",
614+
cor_type = "polychoric", parallel_n_iter = 3)$model[[1]]
615+
expect_false(is.null(poly$cor_diagnostics))
616+
expect_equal(nrow(poly$cor_diagnostics), 6)
617+
# The analysis_method table reports it as available, as an explicit "TRUE" string (not a logical
618+
# that could serialize as "1"/"0" and read falsy on the client).
619+
method_tbl <- tidy(poly, type = "analysis_method")
620+
expect_equal(method_tbl$has_diagnostics[[1]], "TRUE")
621+
expect_true(method_tbl$has_diagnostics[[1]] %in% c("TRUE", "FALSE"))
622+
623+
# If the diagnostics computation itself fails, the fallback keeps the section non-empty rather
624+
# than returning NULL.
625+
fallback <- unavailable_polychoric_diagnostics()
626+
expect_equal(nrow(fallback), 6)
627+
expect_true(all(fallback$Judgement == "Not Available"))
628+
expect_equal(fallback$Diagnostic, poly$cor_diagnostics$Diagnostic)
629+
})

0 commit comments

Comments
 (0)