Skip to content

Commit 94c5ef4

Browse files
authored
Merge pull request #1561 from exploratory-io/fix/cronbach-alpha-factor-polychoric
fix: harden polychoric for Cronbach Alpha and Factor Analysis
2 parents b0576d5 + b2ea74e commit 94c5ef4

5 files changed

Lines changed: 187 additions & 28 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.11
5-
Date: 2026-07-21
4+
Version: 16.0.12
5+
Date: 2026-07-23
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_correlation.R

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -432,23 +432,44 @@ build_factor_correlation <- function(data, correlation_type = c("pearson", "poly
432432
type = "pearson", smoothed = FALSE, warnings = character(), failed = FALSE))
433433
}
434434

435+
# psych::polychoric / tetrachoric / mixedCor can fail on sparse contingency tables when the
436+
# continuity correction (default correct=0.5) produces empty pairwise results. psych itself
437+
# suggests retrying with correct=0; without that retry we used to degrade straight to Pearson
438+
# even though the polychoric-family estimate was recoverable (same root cause as the
439+
# Cronbach's Alpha fix).
435440
captured <- character()
436-
result <- withCallingHandlers(
437-
tryCatch({
438-
switch(correlation_type,
439-
polychoric = psych::polychoric(data, correct = correct),
440-
tetrachoric = psych::tetrachoric(data, correct = correct),
441-
mixed = psych::mixedCor(data))
442-
}, error = function(e) {
443-
captured <<- c(captured, conditionMessage(e))
444-
NULL
445-
}),
446-
warning = function(w) {
447-
captured <<- c(captured, conditionMessage(w))
448-
invokeRestart("muffleWarning")
449-
})
450-
451-
if (is.null(result) || is.null(result$rho) || anyNA(result$rho)) {
441+
run_psych_correlation <- function(corr) {
442+
withCallingHandlers(
443+
tryCatch({
444+
switch(correlation_type,
445+
polychoric = psych::polychoric(data, correct = corr),
446+
tetrachoric = psych::tetrachoric(data, correct = corr),
447+
mixed = psych::mixedCor(data, correct = corr))
448+
}, error = function(e) {
449+
captured <<- c(captured, conditionMessage(e))
450+
NULL
451+
}),
452+
warning = function(w) {
453+
captured <<- c(captured, conditionMessage(w))
454+
invokeRestart("muffleWarning")
455+
})
456+
}
457+
correlation_ok <- function(obj) {
458+
!is.null(obj) && !is.null(obj$rho) && !anyNA(obj$rho)
459+
}
460+
461+
result <- run_psych_correlation(correct)
462+
if (!correlation_ok(result) && correct != 0) {
463+
result <- run_psych_correlation(0)
464+
if (correlation_ok(result)) {
465+
captured <- c(
466+
captured,
467+
sprintf("%s used continuity correction of 0 because the default correction failed.",
468+
factanal_correlation_label(correlation_type)))
469+
}
470+
}
471+
472+
if (!correlation_ok(result)) {
452473
# Degrade to Pearson rather than aborting the whole analysis. A partially-NA matrix counts as a
453474
# failure too: psych::fa() refuses to run on one ("missing values (NAs) in the correlation
454475
# matrix do not allow me to continue"), so keeping it would abort with a raw psych error.

R/reliability.R

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,75 @@ reliability_prepare_correlation_data <- function(data, item_types) {
165165
# Correlation matrix builder
166166
# ------------------------------------------------------------
167167

168+
# psych::polychoric / mixedCor can fail on sparse contingency tables when the
169+
# continuity correction (default correct=0.5) produces empty pairwise results.
170+
# psych itself suggests retrying with correct=0; without that retry the failure
171+
# surfaces as cryptic errors like "attempt to set 'rownames' on an object with
172+
# no dimensions" or "supply both 'x' and 'y' or a matrix-like 'x'".
173+
reliability_valid_rho <- function(rho, expected_n = NULL) {
174+
rho <- tryCatch(as.matrix(rho), error = function(e) NULL)
175+
if (is.null(rho) || is.null(dim(rho)) || any(dim(rho) == 0)) {
176+
return(FALSE)
177+
}
178+
if (!is.null(expected_n) && (nrow(rho) != expected_n || ncol(rho) != expected_n)) {
179+
return(FALSE)
180+
}
181+
TRUE
182+
}
183+
184+
reliability_estimate_polychoric_rho <- function(prepared_data, correct = 0.5) {
185+
expected_n <- ncol(prepared_data)
186+
run <- function(corr) {
187+
obj <- suppressWarnings(psych::polychoric(prepared_data, correct = corr,
188+
smooth = FALSE, global = FALSE))
189+
obj$rho
190+
}
191+
warnings_list <- character()
192+
rho <- tryCatch(run(correct), error = function(e) NULL)
193+
if (!reliability_valid_rho(rho, expected_n) && correct != 0) {
194+
rho <- tryCatch(run(0), error = function(e) NULL)
195+
if (reliability_valid_rho(rho, expected_n)) {
196+
warnings_list <- c(
197+
warnings_list,
198+
"Polychoric correlation used continuity correction of 0 because the default correction failed.")
199+
}
200+
}
201+
if (!reliability_valid_rho(rho, expected_n)) {
202+
stop("Failed to estimate polychoric correlations. Check that items are ordinal ",
203+
"with enough responses across categories.")
204+
}
205+
list(rho = rho, warnings = warnings_list)
206+
}
207+
208+
reliability_estimate_mixed_rho <- function(prepared_data, item_types, correct = 0.5) {
209+
expected_n <- ncol(prepared_data)
210+
continuous_index <- match(names(item_types)[item_types == "continuous"], names(prepared_data))
211+
polytomous_index <- match(names(item_types)[item_types == "ordinal"], names(prepared_data))
212+
dichotomous_index <- match(names(item_types)[item_types == "dichotomous"], names(prepared_data))
213+
run <- function(corr) {
214+
obj <- suppressWarnings(psych::mixedCor(
215+
prepared_data,
216+
c = continuous_index, p = polytomous_index, d = dichotomous_index,
217+
correct = corr, global = FALSE))
218+
obj$rho
219+
}
220+
warnings_list <- character()
221+
rho <- tryCatch(run(correct), error = function(e) NULL)
222+
if (!reliability_valid_rho(rho, expected_n) && correct != 0) {
223+
rho <- tryCatch(run(0), error = function(e) NULL)
224+
if (reliability_valid_rho(rho, expected_n)) {
225+
warnings_list <- c(
226+
warnings_list,
227+
"Mixed correlation used continuity correction of 0 because the default correction failed.")
228+
}
229+
}
230+
if (!reliability_valid_rho(rho, expected_n)) {
231+
stop("Failed to estimate mixed correlations. Check that items have enough ",
232+
"responses across categories.")
233+
}
234+
list(rho = rho, warnings = warnings_list)
235+
}
236+
168237
reliability_build_correlation <- function(data, item_types, method,
169238
smooth = TRUE, correct = 0.5) {
170239
prepared_data <- reliability_prepare_correlation_data(data, item_types)
@@ -174,17 +243,14 @@ reliability_build_correlation <- function(data, item_types, method,
174243
correlation_matrix <- stats::cor(prepared_data, use = "pairwise.complete.obs",
175244
method = "pearson")
176245
} else if (method == "polychoric") {
177-
obj <- suppressWarnings(psych::polychoric(prepared_data, correct = correct,
178-
smooth = FALSE, global = FALSE))
179-
correlation_matrix <- obj$rho
246+
poly_result <- reliability_estimate_polychoric_rho(prepared_data, correct = correct)
247+
correlation_matrix <- poly_result$rho
248+
warnings_list <- c(warnings_list, poly_result$warnings)
180249
} else if (method == "mixed") {
181-
continuous_index <- match(names(item_types)[item_types == "continuous"], names(prepared_data))
182-
polytomous_index <- match(names(item_types)[item_types == "ordinal"], names(prepared_data))
183-
dichotomous_index <- match(names(item_types)[item_types == "dichotomous"], names(prepared_data))
184-
obj <- suppressWarnings(psych::mixedCor(prepared_data,
185-
c = continuous_index, p = polytomous_index, d = dichotomous_index,
186-
correct = correct, global = FALSE))
187-
correlation_matrix <- obj$rho
250+
mixed_result <- reliability_estimate_mixed_rho(prepared_data, item_types,
251+
correct = correct)
252+
correlation_matrix <- mixed_result$rho
253+
warnings_list <- c(warnings_list, mixed_result$warnings)
188254
} else {
189255
stop("Unsupported correlation method: ", method)
190256
}
@@ -467,6 +533,16 @@ exp_cronbach_alpha <- function(df, ..., correlation_method = "auto", check_keys
467533
item_types <- vapply(cleaned_df, reliability_detect_item_type, character(1))
468534
names(item_types) <- colnames(cleaned_df)
469535

536+
# Exploratory often stores Likert items as unordered factors. Treat those as
537+
# ordinal when the user asks for polychoric/mixed, or when every selected
538+
# item is categorical (auto -> Ordinal Alpha). Keep rejecting nominal when
539+
# mixed with continuous items under auto/pearson.
540+
if (correlation_method %in% c("polychoric", "mixed") ||
541+
(correlation_method == "auto" &&
542+
all(item_types %in% c("nominal", "ordinal", "dichotomous")))) {
543+
item_types[item_types == "nominal"] <- "ordinal"
544+
}
545+
470546
unsupported <- names(item_types)[item_types %in% c("nominal", "unsupported")]
471547
if (length(unsupported) > 0) {
472548
stop("These variables cannot be used for reliability analysis (nominal/unsupported): ",

tests/testthat/test_factanal_correlation.R

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,33 @@ test_that("verification pass 4 findings (issue #26623)", {
507507
expect_equal(zero_diagnostics$Judgement[[2]], "Detected in 1 variable")
508508
})
509509

510+
test_that("build_factor_correlation retries with correct=0 before degrading", {
511+
# Same sparse contingency pattern that makes psych::polychoric(correct=0.5) throw
512+
# "attempt to set 'rownames' on an object with no dimensions", while correct=0 succeeds.
513+
set.seed(1)
514+
df <- data.frame(lapply(1:8, function(i) as.integer(sample(1:5, 12, replace = TRUE))))
515+
names(df) <- paste0("q", 1:8)
516+
517+
expect_true(inherits(
518+
suppressWarnings(tryCatch(psych::polychoric(df, correct = 0.5), error = function(e) e)),
519+
"error"))
520+
expect_false(inherits(
521+
suppressWarnings(tryCatch(psych::polychoric(df, correct = 0), error = function(e) e)),
522+
"error"))
523+
524+
result <- build_factor_correlation(df, "polychoric")
525+
expect_equal(result$type, "polychoric")
526+
expect_false(result$failed)
527+
expect_equal(dim(result$correlation), c(8L, 8L))
528+
expect_true(any(grepl("continuity correction of 0", result$warnings)))
529+
530+
# End-to-end: exp_factanal must keep polychoric rather than degrade to Pearson.
531+
fit <- exp_factanal(df, q1, q2, q3, q4, q5, q6, q7, q8, nfactors = 2, rotate = "varimax",
532+
cor_type = "polychoric", parallel_n_iter = 3)$model[[1]]
533+
expect_equal(fit$correlation_type, "polychoric")
534+
expect_equal(fit$correlation_degraded_from, "")
535+
})
536+
510537
test_that("a failed estimation degrades to Pearson end to end (issue #26623)", {
511538
# The degrade path mutates `resolved` after construction and re-points the diagnostics and the
512539
# parallel analysis; exercise it through exp_factanal, not just the helper.

tests/testthat/test_reliability.R

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,41 @@ test_that("exp_cronbach_alpha rejects nominal (unordered, 3+ level) factor colum
233233
"nominal")
234234
})
235235

236+
test_that("exp_cronbach_alpha treats all-factor Likert columns as ordinal under auto/polychoric", {
237+
df <- make_reliability_df() %>%
238+
dplyr::mutate(dplyr::across(dplyr::everything(), ~ factor(.x)))
239+
240+
model_df <- exp_cronbach_alpha(df, dplyr::everything(), correlation_method = "auto")
241+
expect_equal(model_df$model[[1]]$selected_method, "polychoric")
242+
res <- model_df %>% glance_rowwise(model, pretty.name = TRUE)
243+
expect_equal(res$Coefficient, "Ordinal Alpha")
244+
expect_false(is.na(res$Alpha))
245+
246+
model_df <- exp_cronbach_alpha(df, dplyr::everything(), correlation_method = "polychoric")
247+
expect_equal(model_df$model[[1]]$selected_method, "polychoric")
248+
})
249+
250+
test_that("exp_cronbach_alpha retries polychoric with correct=0 on sparse tables", {
251+
set.seed(1)
252+
df <- tibble::tibble(
253+
a = ordered(sample(1:5, 12, replace = TRUE)),
254+
b = ordered(sample(1:5, 12, replace = TRUE)),
255+
c = ordered(sample(1:5, 12, replace = TRUE)),
256+
d = ordered(sample(1:5, 12, replace = TRUE)),
257+
e = ordered(sample(1:5, 12, replace = TRUE)),
258+
f = ordered(sample(1:5, 12, replace = TRUE)),
259+
g = ordered(sample(1:5, 12, replace = TRUE)),
260+
h = ordered(sample(1:5, 12, replace = TRUE))
261+
)
262+
263+
# Default psych::polychoric(correct=0.5) fails on this sparse case with
264+
# "attempt to set 'rownames' on an object with no dimensions".
265+
model_df <- exp_cronbach_alpha(df, dplyr::everything(), correlation_method = "polychoric")
266+
expect_equal(model_df$model[[1]]$selected_method, "polychoric")
267+
expect_false(is.na(model_df$model[[1]]$alpha))
268+
expect_true(any(grepl("continuity correction of 0", model_df$model[[1]]$warnings)))
269+
})
270+
236271
test_that("exp_cronbach_alpha drops constant columns and errors when fewer than 2 remain", {
237272
# One constant column is dropped; 3 valid items remain -> report still builds.
238273
df <- make_reliability_df() %>% dplyr::mutate(`定数` = 3L)

0 commit comments

Comments
 (0)