Skip to content

Commit 0e10b45

Browse files
authored
Merge pull request #1517 from exploratory-io/fix/issue-30488
feat(#30488): support auto/polychoric/mixed correlation in do_cor (16.0.14)
2 parents de84369 + 704de8e commit 0e10b45

3 files changed

Lines changed: 266 additions & 39 deletions

File tree

DESCRIPTION

Lines changed: 2 additions & 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.13
4+
Version: 16.0.14
55
Date: 2026-07-24
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
@@ -45,6 +45,7 @@ Imports:
4545
pwr,
4646
readr
4747
Suggests:
48+
polycor,
4849
testthat,
4950
twitteR,
5051
sentimentr,

R/stats_wrapper.R

Lines changed: 117 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ do_cor <- function(df, ..., skv = NULL, fun.aggregate=mean, fill=0){
4545
#' @param dimension A column you want to use as a dimension to calculate the correlations.
4646
#' @param value A column for the values you want to use to calculate the correlations.
4747
#' @param use Operation type for dealing with missing values. This can be one of "everything", "all.obs", "complete.obs", "na.or.complete", or "pairwise.complete.obs"
48-
#' @param method Method of calculation. This can be one of "pearson", "kendall", or "spearman".
48+
#' @param method Method of calculation. This can be one of "auto", "pearson", "kendall", "spearman", "polychoric", or "mixed".
4949
#' @param fun.aggregate Set an aggregate function when there are multiple entries for the key column per each category.
5050
#' @param time_unit Unit of time to aggregate key_col if key_col is Date or POSIXct. NULL doesn't aggregate.
5151
#' @return correlations between pairs of groups
@@ -175,7 +175,7 @@ do_cor.kv_ <- function(df,
175175
#' @param df data frame in tidy format
176176
#' @param ... Arguments to select columns to calculate correlation.
177177
#' @param use Operation type for dealing with missing values. This can be one of "everything", "all.obs", "complete.obs", "na.or.complete", or "pairwise.complete.obs"
178-
#' @param method Method of calculation. This can be one of "pearson", "kendall", or "spearman".
178+
#' @param method Method of calculation. This can be one of "auto", "pearson", "kendall", "spearman", "polychoric", or "mixed".
179179
#' @return correlations between pairs of columns
180180
#' @export
181181
do_cor.cols <- function(df, ..., use = "pairwise.complete.obs", method = "pearson",
@@ -205,10 +205,9 @@ do_cor.cols <- function(df, ..., use = "pairwise.complete.obs", method = "pearso
205205
stop("More than 1 row are required to calculate correlations.")
206206
}
207207
}
208-
mat <- dplyr::select(df, !!!select_dots) %>%
209-
# Convert logical to numeric explicitly, since implicit conversion by as.matrix does not happen if all the columns are logical.
210-
dplyr::mutate(across(where(is.logical), as.numeric)) %>%
211-
as.matrix()
208+
# Keep the source column types here. Auto and mixed correlation need to distinguish
209+
# continuous numeric variables from ordinal (factor/logical) variables.
210+
mat <- dplyr::select(df, !!!select_dots) %>% as.data.frame()
212211

213212
ret <- do_cor_internal(mat, use, method, diag, output_cols, na.rm=TRUE)
214213

@@ -253,50 +252,130 @@ do_cor.cols <- function(df, ..., use = "pairwise.complete.obs", method = "pearso
253252
}
254253

255254

255+
resolve_correlation_method <- function(mat, method) {
256+
if (!identical(method, "auto")) {
257+
return(method)
258+
}
259+
260+
is_ordinal <- vapply(mat, function(x) is.factor(x) || is.logical(x), logical(1))
261+
is_continuous <- vapply(mat, is.numeric, logical(1))
262+
263+
if (all(is_continuous)) {
264+
return("pearson")
265+
}
266+
if (all(is_ordinal)) {
267+
return("polychoric")
268+
}
269+
if (all(is_continuous | is_ordinal)) {
270+
return("mixed")
271+
}
272+
273+
warning("Could not determine all variable types for automatic correlation; using Pearson.")
274+
"pearson"
275+
}
276+
277+
as_numeric_correlation_matrix <- function(df) {
278+
as.matrix(as.data.frame(lapply(df, function(x) {
279+
if (is.logical(x)) {
280+
as.numeric(x)
281+
} else if (is.factor(x)) {
282+
as.numeric(x)
283+
} else {
284+
x
285+
}
286+
}), check.names = FALSE))
287+
}
288+
289+
as_hetcor_data_frame <- function(df, force_ordinal = FALSE) {
290+
ret <- as.data.frame(lapply(df, function(x) {
291+
if (force_ordinal || is.factor(x) || is.logical(x)) {
292+
ordered(x)
293+
} else {
294+
x
295+
}
296+
}))
297+
colnames(ret) <- colnames(df)
298+
ret
299+
}
300+
256301
do_cor_internal <- function(mat, use, method, diag, output_cols, na.rm) {
257302
# Sort the column name.
258303
# Now that we sort the variables based on correlation result or input order, this might not be as meaningful,
259304
# but I'm hoping this might still help align the result between Mac and Windows if there are ties in the mean correlations.
260305
# We use stringr::str_sort() as opposed to base sort() so that the result is consistent on Windows too.
261306
sorted_colnames <- stringr::str_sort(colnames(mat))
262-
mat <- mat[,sorted_colnames]
307+
# `as.data.frame.matrix()` otherwise changes numeric matrix column names such
308+
# as "1" to "X1", breaking the key-value correlation output contract.
309+
mat <- as.data.frame(mat[, sorted_colnames, drop = FALSE], check.names = FALSE)
310+
method <- resolve_correlation_method(mat, method)
311+
312+
# Create a matrix of P-values for Analytics View case.
313+
dim <- length(sorted_colnames)
314+
315+
if (method %in% c("polychoric", "mixed")) {
316+
# Polychoric correlation is for ordinal variables (e.g. survey scales 1-5).
317+
# stats::cor()/cor.test() do not support it, so we use polycor::hetcor(), which
318+
# returns the full correlation matrix and the matrix of standard errors in one call.
319+
# Polychoric coerces every column to ordered so every pair uses the ordinal model.
320+
# Mixed preserves continuous numerics and converts factor/logical columns to ordered,
321+
# allowing hetcor() to select Pearson, polyserial, or polychoric per pair.
322+
if (!requireNamespace("polycor", quietly = TRUE)) {
323+
stop(
324+
"The 'polycor' package is required for polychoric and mixed correlations. ",
325+
"Install it before using this method.",
326+
call. = FALSE
327+
)
328+
}
329+
ordered_df <- as_hetcor_data_frame(mat, force_ordinal = identical(method, "polychoric"))
330+
# hetcor only supports "complete.obs" and "pairwise.complete.obs", while the other
331+
# methods (via cor()/cor.test()) also accept "everything", "all.obs", and
332+
# "na.or.complete". Map those to pairwise (the do_cor default) so polychoric does not
333+
# error where the other methods would succeed. (hetcor's own default is listwise
334+
# "complete.obs", which we do not want.)
335+
het_use <- if (identical(use, "complete.obs")) "complete.obs" else "pairwise.complete.obs"
336+
het <- polycor::hetcor(ordered_df, ML = TRUE, std.err = TRUE, use = het_use)
337+
cor_mat <- het$correlations
338+
tvalue_mat <- het$correlations / het$std.errors # z value; off-diagonal NA SE stays NA.
339+
pvalue_mat <- 2 * stats::pnorm(-abs(tvalue_mat))
340+
diag(tvalue_mat) <- Inf # For i=j case, statistic should be Inf and P value 0.
341+
diag(pvalue_mat) <- 0
342+
} else {
343+
numeric_mat <- as_numeric_correlation_matrix(mat)
344+
cor_mat <- cor(numeric_mat, use = use, method = method)
345+
pvalue_mat <- matrix(NA, dim, dim)
346+
tvalue_mat <- matrix(NA, dim, dim)
347+
for (i in 2:dim) {
348+
for (j in 1:(i-1)) {
349+
tryCatch({
350+
cor_test_res <- cor.test(numeric_mat[,i], numeric_mat[,j], method = method)
351+
pvalue_mat[i, j] <- cor_test_res$p.value
352+
tvalue_mat[i, j] <- cor_test_res$statistic
353+
}, error = function(e) {
354+
if (e$message == "not enough finite observations") {
355+
# This is the error cor.test returns when there is not enough non-NA data.
356+
# Rather than stopping, set NA as the result, and we will handle it as a not-significant case on the UI.
357+
pvalue_mat[i, j] <- NA
358+
tvalue_mat[i, j] <- NA
359+
}
360+
else {
361+
stop(e)
362+
}
363+
})
364+
pvalue_mat[j, i] <- pvalue_mat[i, j]
365+
tvalue_mat[j, i] <- tvalue_mat[i, j]
366+
}
367+
}
368+
for (i in 1:dim) { # For i=j case, P value should be always 0 and t statistic should be Inf.
369+
pvalue_mat[i, i] <- 0
370+
tvalue_mat[i, i] <- Inf
371+
}
372+
}
263373

264-
cor_mat <- cor(mat, use = use, method = method)
265374
ret <- mat_to_df(cor_mat,
266375
cnames=output_cols[1:3],
267376
diag=diag,
268377
na.rm=na.rm,
269378
zero.rm=FALSE)
270-
271-
# Create a matrix of P-values for Analytics View case.
272-
dim <- length(sorted_colnames)
273-
pvalue_mat <- matrix(NA, dim, dim)
274-
tvalue_mat <- matrix(NA, dim, dim)
275-
for (i in 2:dim) {
276-
for (j in 1:(i-1)) {
277-
tryCatch({
278-
cor_test_res <- cor.test(mat[,i], mat[,j], method = method)
279-
pvalue_mat[i, j] <- cor_test_res$p.value
280-
tvalue_mat[i, j] <- cor_test_res$statistic
281-
}, error = function(e) {
282-
if (e$message == "not enough finite observations") {
283-
# This is the error cor.test returns when there is not enough non-NA data.
284-
# Rather than stopping, set NA as the result, and we will handle it as a not-significant case on the UI.
285-
pvalue_mat[i, j] <- NA
286-
tvalue_mat[i, j] <- NA
287-
}
288-
else {
289-
stop(e)
290-
}
291-
})
292-
pvalue_mat[j, i] <- pvalue_mat[i, j]
293-
tvalue_mat[j, i] <- tvalue_mat[i, j]
294-
}
295-
}
296-
for (i in 1:dim) { # For i=j case, P value should be always 0 and t statistic should be Inf.
297-
pvalue_mat[i, i] <- 0
298-
tvalue_mat[i, i] <- Inf
299-
}
300379
colnames(pvalue_mat) <- sorted_colnames
301380
rownames(pvalue_mat) <- sorted_colnames
302381
colnames(tvalue_mat) <- sorted_colnames

tests/testthat/test_stats_wrapper.R

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,153 @@ test_that("do_cor should skip group with only one row.", {
120120
expect_equal(nrow(res %>% filter(z==F)), 0)
121121
})
122122

123+
test_that("do_cor with polychoric method", {
124+
skip_if_not_installed("polycor")
125+
126+
# Polychoric correlation is for ordinal variables. The latent variables behind x and y
127+
# are correlated at 0.7, while z is independent of them.
128+
set.seed(123)
129+
n <- 200
130+
cut5 <- function(z) as.integer(cut(z, breaks = c(-Inf, -0.84, -0.25, 0.25, 0.84, Inf)))
131+
z1 <- rnorm(n); z2 <- 0.7 * z1 + sqrt(1 - 0.49) * rnorm(n); z3 <- rnorm(n)
132+
df <- data.frame(x = cut5(z1), y = cut5(z2), z = cut5(z3))
133+
model_df <- df %>% do_cor(`x`, `y`, `z`, method = "polychoric", distinct = FALSE, diag = TRUE, return_type = "model")
134+
res <- model_df %>% tidy_rowwise(model, type = 'cor')
135+
expect_equal(nrow(res), 9) # All 9 combinations.
136+
xy <- res %>% filter(pair.name.x == "x", pair.name.y == "y")
137+
expect_true(xy$correlation > 0.5 && xy$correlation < 0.9) # Recovers the true latent 0.7, not inflated.
138+
expect_true(xy$p_value < 0.05) # Statistically significant.
139+
expect_true(is.finite(xy$statistic)) # z value is populated.
140+
xz <- res %>% filter(pair.name.x == "x", pair.name.y == "z")
141+
expect_true(abs(xz$correlation) < 0.3) # z is independent of x: near-zero polychoric correlation.
142+
expect_true(xz$p_value > 0.05) # The independent pair is not statistically significant.
143+
diag_row <- res %>% filter(pair.name.x == "x", pair.name.y == "x")
144+
expect_equal(diag_row$correlation, 1) # Diagonal correlation is 1.
145+
expect_equal(diag_row$p_value, 0) # Diagonal P value is 0.
146+
})
147+
148+
test_that("do_cor automatic method selects the specified correlation family", {
149+
ordinal <- ordered(rep(1:5, each = 20))
150+
expect_identical(resolve_correlation_method(data.frame(x = 1:100, y = 101:200), "auto"), "pearson")
151+
expect_identical(resolve_correlation_method(data.frame(x = ordinal, y = rev(ordinal)), "auto"), "polychoric")
152+
expect_identical(resolve_correlation_method(data.frame(x = 1:100, y = ordinal), "auto"), "mixed")
153+
})
154+
155+
test_that("do_cor supports automatic and mixed correlations with factor inputs", {
156+
skip_if_not_installed("polycor")
157+
158+
set.seed(456)
159+
n <- 100
160+
ordinal <- ordered(cut(rnorm(n), breaks = c(-Inf, -0.84, -0.25, 0.25, 0.84, Inf)))
161+
df <- data.frame(continuous = rnorm(n), ordinal = ordinal)
162+
163+
auto <- df %>% do_cor(continuous, ordinal, method = "auto", distinct = FALSE, diag = TRUE, return_type = "model") %>% tidy_rowwise(model, type = "cor")
164+
mixed <- df %>% do_cor(continuous, ordinal, method = "mixed", distinct = FALSE, diag = TRUE, return_type = "model") %>% tidy_rowwise(model, type = "cor")
165+
166+
expect_equal(nrow(auto), 4)
167+
expect_equal(nrow(mixed), 4)
168+
expect_true(is.finite(auto$correlation[auto$pair.name.x != auto$pair.name.y][1]))
169+
expect_true(is.finite(mixed$correlation[mixed$pair.name.x != mixed$pair.name.y][1]))
170+
})
171+
172+
test_that("do_cor with polychoric method handles a constant column without error", {
173+
skip_if_not_installed("polycor")
174+
175+
set.seed(123)
176+
n <- 100
177+
cut5 <- function(z) as.integer(cut(z, breaks = c(-Inf, -0.84, -0.25, 0.25, 0.84, Inf)))
178+
z1 <- rnorm(n); z2 <- 0.7 * z1 + sqrt(1 - 0.49) * rnorm(n)
179+
df <- data.frame(x = cut5(z1), y = cut5(z2), w = rep(3L, n)) # w is constant.
180+
# hetcor warns for the non-estimable pairs involving the constant column; that is expected.
181+
model_df <- suppressWarnings(df %>% do_cor(`x`, `y`, `w`, method = "polychoric", distinct = FALSE, diag = TRUE, return_type = "model"))
182+
res <- model_df %>% tidy_rowwise(model, type = 'cor')
183+
xy <- res %>% filter(pair.name.x == "x", pair.name.y == "y")
184+
expect_equal(nrow(xy), 1) # The estimable pair is still computed.
185+
expect_true(xy$correlation > 0.5)
186+
# The constant-column pair has NA correlation and is dropped by na.rm in mat_to_df.
187+
expect_equal(nrow(res %>% filter(pair.name.x == "x", pair.name.y == "w")), 0)
188+
})
189+
190+
test_that("do_cor with polychoric method handles complex column names", {
191+
skip_if_not_installed("polycor")
192+
193+
set.seed(123)
194+
n <- 100
195+
cut5 <- function(z) as.integer(cut(z, breaks = c(-Inf, -0.84, -0.25, 0.25, 0.84, Inf)))
196+
z1 <- rnorm(n); z2 <- 0.7 * z1 + sqrt(1 - 0.49) * rnorm(n)
197+
sname <- "航空 会社 !\"#$%&'()*+, -./:;<=>?@[]^_'{|}~ 表"
198+
df <- data.frame(a = cut5(z1), b = cut5(z2), check.names = FALSE)
199+
names(df) <- c(sname, "plain")
200+
model_df <- df %>% do_cor(tidyselect::everything(), method = "polychoric", distinct = FALSE, diag = TRUE, return_type = "model")
201+
res <- model_df %>% tidy_rowwise(model, type = 'cor')
202+
expect_true(sname %in% as.character(res$pair.name.x)) # Complex name survives the round trip.
203+
pair <- res %>% filter(as.character(pair.name.x) == sname, pair.name.y == "plain")
204+
expect_equal(nrow(pair), 1)
205+
expect_true(is.finite(pair$correlation))
206+
})
207+
208+
test_that("do_cor with polychoric method accepts use values hetcor does not support", {
209+
skip_if_not_installed("polycor")
210+
211+
# hetcor only accepts "complete.obs" and "pairwise.complete.obs", but the public
212+
# do_cor API (and the other methods via cor()/cor.test()) also accept "everything",
213+
# "all.obs", and "na.or.complete". Those must be mapped, not passed through as an error.
214+
set.seed(123)
215+
n <- 100
216+
cut5 <- function(z) as.integer(cut(z, breaks = c(-Inf, -0.84, -0.25, 0.25, 0.84, Inf)))
217+
z1 <- rnorm(n); z2 <- 0.7 * z1 + sqrt(1 - 0.49) * rnorm(n)
218+
df <- data.frame(x = cut5(z1), y = cut5(z2))
219+
for (u in c("everything", "all.obs", "na.or.complete", "complete.obs")) {
220+
model_df <- df %>% do_cor(`x`, `y`, method = "polychoric", use = u, distinct = FALSE, diag = TRUE, return_type = "model")
221+
res <- model_df %>% tidy_rowwise(model, type = 'cor')
222+
xy <- res %>% filter(pair.name.x == "x", pair.name.y == "y")
223+
expect_equal(nrow(xy), 1, info = u) # The pair is computed regardless of the use value.
224+
expect_true(xy$correlation > 0.5, info = u)
225+
}
226+
})
227+
228+
test_that("do_cor with polychoric method for grouped (repeat-by) data", {
229+
skip_if_not_installed("polycor")
230+
231+
# Repeat By on Analytics View maps to group_by(). Each group must get its own
232+
# polychoric correlation. Group A has a positive relationship, group B a negative one.
233+
set.seed(123)
234+
n <- 100
235+
cut5 <- function(z) as.integer(cut(z, breaks = c(-Inf, -0.84, -0.25, 0.25, 0.84, Inf)))
236+
mk <- function(rho) {
237+
z1 <- rnorm(n); z2 <- rho * z1 + sqrt(1 - rho^2) * rnorm(n)
238+
data.frame(x = cut5(z1), y = cut5(z2))
239+
}
240+
df <- dplyr::bind_rows(cbind(mk(0.7), grp = "A"), cbind(mk(-0.7), grp = "B"))
241+
model_df <- df %>% group_by(grp) %>% do_cor(`x`, `y`, method = "polychoric", distinct = FALSE, diag = TRUE, return_type = "model")
242+
res <- model_df %>% tidy_rowwise(model, type = 'cor')
243+
expect_setequal(unique(as.character(res$grp)), c("A", "B")) # Both groups produced results.
244+
a_xy <- res %>% filter(grp == "A", pair.name.x == "x", pair.name.y == "y")
245+
b_xy <- res %>% filter(grp == "B", pair.name.x == "x", pair.name.y == "y")
246+
expect_true(a_xy$correlation > 0.4) # Positive correlation in group A.
247+
expect_true(b_xy$correlation < -0.4) # Negative correlation in group B, computed independently.
248+
})
249+
250+
test_that("do_cor with polychoric method handles NA values via pairwise complete obs", {
251+
skip_if_not_installed("polycor")
252+
253+
# Survey data routinely has missing responses (NA). The default use="pairwise.complete.obs"
254+
# must drop NAs pairwise rather than error, so every pair is still estimated.
255+
set.seed(123)
256+
n <- 200
257+
cut5 <- function(z) as.integer(cut(z, breaks = c(-Inf, -0.84, -0.25, 0.25, 0.84, Inf)))
258+
z1 <- rnorm(n); z2 <- 0.7 * z1 + sqrt(1 - 0.49) * rnorm(n); z3 <- rnorm(n)
259+
x <- cut5(z1); y <- cut5(z2); z <- cut5(z3)
260+
x[1:20] <- NA; y[15:30] <- NA; z[40:60] <- NA # Missing responses at different rows per column.
261+
df <- data.frame(x = x, y = y, z = z)
262+
model_df <- df %>% do_cor(`x`, `y`, `z`, method = "polychoric", distinct = FALSE, diag = TRUE, return_type = "model")
263+
res <- model_df %>% tidy_rowwise(model, type = 'cor')
264+
expect_equal(nrow(res), 9) # Every pair is still estimated from the pairwise-complete rows.
265+
xy <- res %>% filter(pair.name.x == "x", pair.name.y == "y")
266+
expect_true(is.finite(xy$correlation)) # NAs did not break the estimate.
267+
expect_true(xy$correlation > 0.4) # The x-y relationship is still recovered despite the NAs.
268+
})
269+
123270
test_that("test do_svd.kv with fill", {
124271
test_df <- data.frame(
125272
rand=runif(20, min = 0, max=10),

0 commit comments

Comments
 (0)