Skip to content

Commit 2d909a7

Browse files
jmgirardgithub-actions[bot]strengejacke
authored
Fix collinearity inflation for ordinal models (#903)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Daniel <mail@danielluedecke.de>
1 parent 31a36c5 commit 2d909a7

7 files changed

Lines changed: 217 additions & 66 deletions

File tree

.Rbuildignore

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,33 @@
1-
^logo.png
2-
^README.Rmd
3-
^LICENSE
4-
5-
^\.Rprofile$
6-
^.*\.Rproj$
7-
^\.Rproj\.user$
8-
9-
^\.travis.yml
10-
^\_pkgdown.yml
11-
^\_pkgdown.yaml
12-
^paper.bib$
13-
^GEMINI\.md$
14-
15-
^data/.
16-
^docs/.
17-
^vignettes/.
18-
^pkgdown/.
19-
^WIP/.
20-
^papers/.
21-
^.github/.
22-
^CODE_OF_CONDUCT\.md$
23-
^revdep$
24-
^tests/testthat/_snaps/.
25-
^cran-comments\.md$
26-
^\.github$
27-
\.code-workspace$
28-
\.lintr$
29-
^CRAN-SUBMISSION$
30-
^[.]?air[.]toml$
31-
^\.vscode$
1+
^logo.png
2+
^README.Rmd
3+
^LICENSE
4+
5+
^\.Rprofile$
6+
^.*\.Rproj$
7+
^\.Rproj\.user$
8+
9+
^\.travis.yml
10+
^\_pkgdown.yml
11+
^\_pkgdown.yaml
12+
^paper.bib$
13+
^GEMINI\.md$
14+
15+
^data/.
16+
^docs/.
17+
^vignettes/.
18+
^pkgdown/.
19+
^WIP/.
20+
^papers/.
21+
^.github/.
22+
^CODE_OF_CONDUCT\.md$
23+
^revdep$
24+
^tests/testthat/_snaps/.
25+
^cran-comments\.md$
26+
^\.github$
27+
\.code-workspace$
28+
\.lintr$
29+
^CRAN-SUBMISSION$
30+
^[.]?air[.]toml$
31+
^\.vscode$
32+
^\.positai$
33+
^\.claude$

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,5 @@ Network Trash Folder
5050
Temporary Items
5151
.apdisk
5252
.Rprofile
53+
.positai
54+
.vscode

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Type: Package
22
Package: performance
33
Title: Assessment of Regression Models Performance
4-
Version: 0.16.0.2
4+
Version: 0.16.0.3
55
Authors@R:
66
c(person(given = "Daniel",
77
family = "Lüdecke",

NEWS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
# performance 0.16.0.3
2+
3+
## Bug fixes
4+
5+
* Fixed issue in `check_collinearity()` that was causing inflated VIF values
6+
when applied to clm and clmm models from the ordinal package.
7+
18
# performance 0.16.0.2
29

310
## Changes

R/check_collinearity.R

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,45 @@ check_collinearity.zerocount <- function(
481481
return(NULL)
482482
}
483483

484+
# Filter to true slope parameters (handles multiple intercepts in ordinal models)
485+
if (inherits(x, c("clm", "clmm"))) {
486+
# names(x$beta) returns only non-singular (surviving) slopes
487+
slope_names <- names(x$beta)
488+
keep_idx <- which(colnames(v) %in% slope_names)
489+
490+
# Rebuild term_assign by matching model matrix columns to surviving slopes
491+
tryCatch(
492+
{
493+
mm <- insight::get_modelmatrix(x)
494+
assign_attr <- attr(mm, "assign")
495+
if (!is.null(assign_attr)) {
496+
# Use name-matching to isolate indices for estimated slopes
497+
match_idx <- which(colnames(mm) %in% slope_names)
498+
if (length(match_idx) > 0) {
499+
term_assign <- assign_attr[match_idx]
500+
}
501+
}
502+
},
503+
error = function(e) NULL
504+
)
505+
} else if (insight::has_intercept(x)) {
506+
# Standard behavior: drop the first column/row (the singular intercept)
507+
keep_idx <- seq_len(ncol(v))[-1]
508+
} else {
509+
keep_idx <- seq_len(ncol(v))
510+
if (isTRUE(verbose)) {
511+
insight::format_alert("Model without intercept. VIFs may not be sensible.")
512+
}
513+
}
514+
515+
# Safely subset the matrix
516+
if (length(keep_idx) < ncol(v)) {
517+
if (!is.null(term_assign) && length(term_assign) == ncol(v)) {
518+
term_assign <- term_assign[keep_idx]
519+
}
520+
v <- v[keep_idx, keep_idx, drop = FALSE]
521+
}
522+
484523
# we have rank-deficiency here. remove NA columns from assignment
485524
if (isTRUE(attributes(v)$rank_deficient) && !is.null(attributes(v)$na_columns_index)) {
486525
term_assign <- term_assign[-attributes(v)$na_columns_index]
@@ -491,14 +530,6 @@ check_collinearity.zerocount <- function(
491530
}
492531
}
493532

494-
# check for missing intercept
495-
if (insight::has_intercept(x)) {
496-
v <- v[-1, -1]
497-
term_assign <- term_assign[-1]
498-
} else if (isTRUE(verbose)) {
499-
insight::format_alert("Model has no intercept. VIFs may not be sensible.")
500-
}
501-
502533
f <- insight::find_formula(x, verbose = FALSE)
503534

504535
# hurdle or zeroinfl model can have no zero-inflation formula, in which case
@@ -541,7 +572,6 @@ check_collinearity.zerocount <- function(
541572
result <- vector("numeric")
542573
na_terms <- vector("numeric")
543574

544-
# sanity check - models with offset(?) may contain too many term assignments
545575
if (length(term_assign) > ncol(v)) {
546576
term_assign <- term_assign[seq_len(ncol(v))]
547577
}

performance.Rproj

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,25 @@
1-
Version: 1.0
2-
ProjectId: af6facf3-033e-40d4-ac22-2830774814a9
3-
4-
RestoreWorkspace: No
5-
SaveWorkspace: No
6-
AlwaysSaveHistory: No
7-
8-
EnableCodeIndexing: Yes
9-
UseSpacesForTab: Yes
10-
NumSpacesForTab: 2
11-
Encoding: UTF-8
12-
13-
RnwWeave: knitr
14-
LaTeX: pdfLaTeX
15-
16-
StripTrailingWhitespace: Yes
17-
18-
BuildType: Package
19-
PackageUseDevtools: Yes
20-
PackageInstallArgs: --no-multiarch --with-keep.source
21-
PackageCheckArgs: --as-cran --run-donttest
22-
PackageRoxygenize: rd,collate,namespace
23-
24-
QuitChildProcessesOnExit: Yes
25-
DisableExecuteRprofile: Yes
1+
Version: 1.0
2+
ProjectId: af6facf3-033e-40d4-ac22-2830774814a9
3+
4+
RestoreWorkspace: No
5+
SaveWorkspace: No
6+
AlwaysSaveHistory: No
7+
8+
EnableCodeIndexing: Yes
9+
UseSpacesForTab: Yes
10+
NumSpacesForTab: 2
11+
Encoding: UTF-8
12+
13+
RnwWeave: knitr
14+
LaTeX: pdfLaTeX
15+
16+
StripTrailingWhitespace: Yes
17+
18+
BuildType: Package
19+
PackageUseDevtools: Yes
20+
PackageInstallArgs: --no-multiarch --with-keep.source
21+
PackageCheckArgs: --as-cran --run-donttest
22+
PackageRoxygenize: rd,collate,namespace
23+
24+
QuitChildProcessesOnExit: Yes
25+
DisableExecuteRprofile: Yes

tests/testthat/test-check_collinearity.R

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,113 @@ test_that("check_collinearity, validate adjusted vif against car", {
300300
expect_equal(out1[, 1], out2$VIF, tolerance = 1e-3, ignore_attr = TRUE)
301301
expect_equal(out1[, 3], out2$SE_factor, tolerance = 1e-3, ignore_attr = TRUE)
302302
})
303+
304+
test_that("check_collinearity, ordinal clmm models", {
305+
skip_if_not_installed("ordinal")
306+
set.seed(999)
307+
n <- 500
308+
x_continuous <- rnorm(n, mean = 0, sd = 1)
309+
x_binary <- sample(c(-0.5, 0.5), size = n, replace = TRUE, prob = c(0.85, 0.15))
310+
subject_id <- factor(rep(1:50, each = 10))
311+
random_intercepts <- rnorm(50, 0, 1)
312+
latent_y <- 2 *
313+
x_continuous +
314+
3 * x_binary +
315+
random_intercepts[as.numeric(subject_id)] +
316+
rlogis(n)
317+
y_ordinal <- cut(
318+
latent_y,
319+
breaks = 15,
320+
ordered_result = TRUE
321+
)
322+
dat <- data.frame(y_ordinal, x_continuous, x_binary, subject_id)
323+
mod_clmm <- ordinal::clmm(
324+
y_ordinal ~ x_continuous + x_binary + (1 | subject_id),
325+
data = dat
326+
)
327+
out <- check_collinearity(mod_clmm)
328+
expect_s3_class(out, "check_collinearity")
329+
expect_identical(out$Term, c("x_continuous", "x_binary"))
330+
expect_equal(out$VIF, c(1.12, 1.12), tolerance = 0.05)
331+
})
332+
333+
test_that("check_collinearity, ordinal clm models", {
334+
skip_if_not_installed("ordinal")
335+
set.seed(999)
336+
n <- 500
337+
x_continuous <- rnorm(n, mean = 0, sd = 1)
338+
x_binary <- sample(c(-0.5, 0.5), size = n, replace = TRUE, prob = c(0.85, 0.15))
339+
latent_y <- 2 * x_continuous + 3 * x_binary + rlogis(n)
340+
y_ordinal <- cut(
341+
latent_y,
342+
breaks = 15,
343+
ordered_result = TRUE
344+
)
345+
dat <- data.frame(y_ordinal, x_continuous, x_binary)
346+
mod_clm <- ordinal::clm(
347+
y_ordinal ~ x_continuous + x_binary,
348+
data = dat
349+
)
350+
out <- check_collinearity(mod_clm)
351+
expect_s3_class(out, "check_collinearity")
352+
expect_identical(out$Term, c("x_continuous", "x_binary"))
353+
expect_equal(out$VIF, c(1.11, 1.11), tolerance = 0.05)
354+
})
355+
356+
test_that("check_collinearity, ordinal clmm models with offset", {
357+
skip_if_not_installed("ordinal")
358+
set.seed(999)
359+
n <- 500
360+
x_continuous <- rnorm(n, mean = 0, sd = 1)
361+
x_binary <- sample(c(-0.5, 0.5), size = n, replace = TRUE, prob = c(0.85, 0.15))
362+
x_offset <- rnorm(n, mean = 0, sd = 0.5)
363+
subject_id <- factor(rep(1:50, each = 10))
364+
random_intercepts <- rnorm(50, 0, 1)
365+
366+
latent_y <- 2 *
367+
x_continuous +
368+
3 * x_binary +
369+
random_intercepts[as.numeric(subject_id)] +
370+
x_offset +
371+
rlogis(n)
372+
y_ordinal <- cut(latent_y, breaks = 15, ordered_result = TRUE)
373+
dat <- data.frame(y_ordinal, x_continuous, x_binary, x_offset, subject_id)
374+
mod_clmm_offset <- ordinal::clmm(
375+
y_ordinal ~ x_continuous + x_binary + offset(x_offset) + (1 | subject_id),
376+
data = dat
377+
)
378+
out <- check_collinearity(mod_clmm_offset)
379+
expect_s3_class(out, "check_collinearity")
380+
expect_identical(out$Term, c("x_continuous", "x_binary"))
381+
expect_equal(out$VIF, c(1.12, 1.12), tolerance = 0.05)
382+
})
383+
384+
test_that("check_collinearity, ordinal clm models with offset", {
385+
skip_if_not_installed("ordinal")
386+
set.seed(999)
387+
n <- 500
388+
x_continuous <- rnorm(n, mean = 0, sd = 1)
389+
x_binary <- sample(c(-0.5, 0.5), size = n, replace = TRUE, prob = c(0.85, 0.15))
390+
x_offset <- rnorm(n, mean = 0, sd = 0.5)
391+
latent_y <- 2 * x_continuous + 3 * x_binary + x_offset + rlogis(n)
392+
y_ordinal <- cut(latent_y, breaks = 15, ordered_result = TRUE)
393+
dat <- data.frame(y_ordinal, x_continuous, x_binary, x_offset)
394+
mod_clm_offset <- ordinal::clm(
395+
y_ordinal ~ x_continuous + x_binary + offset(x_offset),
396+
data = dat
397+
)
398+
out <- check_collinearity(mod_clm_offset)
399+
expect_s3_class(out, "check_collinearity")
400+
expect_identical(out$Term, c("x_continuous", "x_binary"))
401+
expect_equal(out$VIF, c(1.11, 1.11), tolerance = 0.05)
402+
})
403+
404+
test_that("check_collinearity, standard lm models with offset", {
405+
# Standard linear model with an offset
406+
m_lm_offset <- lm(mpg ~ wt + cyl + offset(disp), data = mtcars)
407+
out <- check_collinearity(m_lm_offset)
408+
expect_s3_class(out, "check_collinearity")
409+
# The offset should not be evaluated for collinearity
410+
expect_identical(out$Term, c("wt", "cyl"))
411+
expect_false("disp" %in% out$Term)
412+
})

0 commit comments

Comments
 (0)