Nothing
# ===========================================================================
# Input validation tests
# ===========================================================================
test_that("modelPrune validates formula argument", {
df <- mtcars
expect_error(
modelPrune(formula = "mpg ~ cyl", data = df),
"'formula' must be a formula object"
)
expect_error(
modelPrune(formula = NULL, data = df),
"'formula' must be a formula object"
)
})
test_that("modelPrune errors clearly on a formula with zero fixed effects (#66)", {
skip_if_not_installed("lme4")
df <- data.frame(y = rnorm(20), group = rep(letters[1:4], 5))
expect_error(
modelPrune(y ~ (1 | group), data = df, engine = "lme4"),
"no fixed-effect predictors"
)
})
test_that("modelPrune validates data argument", {
expect_error(
modelPrune(mpg ~ cyl, data = as.matrix(mtcars)),
"'data' must be a data.frame"
)
expect_error(
modelPrune(mpg ~ cyl, data = NULL),
"'data' must be a data.frame"
)
})
test_that("modelPrune validates engine argument", {
df <- mtcars
expect_error(
modelPrune(mpg ~ cyl, data = df, engine = 123),
"'engine' must be either"
)
expect_error(
modelPrune(mpg ~ cyl, data = df, engine = c("lm", "glm")),
"Built-in 'engine' must be a single character string"
)
expect_error(
modelPrune(mpg ~ cyl, data = df, engine = "invalid"),
"Built-in 'engine' must be one of"
)
})
test_that("modelPrune validates criterion argument", {
df <- mtcars
expect_error(
modelPrune(mpg ~ cyl, data = df, criterion = 123),
"'criterion' must be a single character string"
)
expect_error(
modelPrune(mpg ~ cyl, data = df, criterion = "pvalue"),
"criterion must be one of: vif, condition_number"
)
})
test_that("modelPrune validates limit argument", {
df <- mtcars
expect_error(
modelPrune(mpg ~ cyl, data = df, limit = "5"),
"'limit' must be a single numeric value"
)
expect_error(
modelPrune(mpg ~ cyl, data = df, limit = c(5, 10)),
"'limit' must be a single numeric value"
)
expect_error(
modelPrune(mpg ~ cyl, data = df, limit = -5),
"'limit' must be positive and non-missing"
)
expect_error(
modelPrune(mpg ~ cyl, data = df, limit = NA),
"'limit' must be positive and non-missing"
)
})
test_that("modelPrune validates force_in argument", {
df <- mtcars
expect_error(
modelPrune(mpg ~ cyl + disp, data = df, force_in = 123),
"'force_in' must be a character vector"
)
expect_error(
modelPrune(mpg ~ cyl + disp, data = df, force_in = "missing_var"),
"'force_in' variable\\(s\\) not found in fixed effects"
)
})
test_that("modelPrune validates max_steps argument", {
df <- mtcars
expect_error(
modelPrune(mpg ~ cyl, data = df, max_steps = "10"),
"'max_steps' must be a single numeric value or NULL"
)
expect_error(
modelPrune(mpg ~ cyl, data = df, max_steps = 0),
"'max_steps' must be >= 1"
)
expect_error(
modelPrune(mpg ~ cyl, data = df, max_steps = -5),
"'max_steps' must be >= 1"
)
})
# ===========================================================================
# Functional tests - lm engine
# ===========================================================================
test_that("modelPrune works with lm engine", {
set.seed(42)
df <- mtcars
result <- modelPrune(mpg ~ cyl + disp + hp + wt, data = df,
engine = "lm", limit = 5)
expect_s3_class(result, "data.frame")
expect_true("mpg" %in% names(result)) # Response always included
expect_true(ncol(result) >= 2) # At least response + 1 predictor
expect_equal(attr(result, "engine"), "lm")
expect_equal(attr(result, "criterion"), "vif")
expect_equal(attr(result, "limit"), 5)
})
test_that("modelPrune respects force_in with lm", {
df <- mtcars
result <- modelPrune(mpg ~ cyl + disp + hp + wt, data = df,
engine = "lm", force_in = "cyl", limit = 10)
expect_true("cyl" %in% attr(result, "selected_vars"))
expect_true("cyl" %in% names(result))
})
test_that("modelPrune errors when force_in violates threshold", {
# Create data where cyl and disp are highly collinear
set.seed(123)
df <- data.frame(
y = rnorm(50),
x1 = rnorm(50),
x2 = rnorm(50)
)
df$x3 <- df$x1 + rnorm(50, sd = 0.01) # Nearly perfect collinearity
expect_error(
modelPrune(y ~ x1 + x2 + x3, data = df,
force_in = c("x1", "x3"), limit = 2),
"Variables in 'force_in' violate the criterion threshold"
)
})
test_that("modelPrune errors when a force_in variable's diagnostic is undefined (#82)", {
set.seed(9020)
n <- 50
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rep(5, n), # Constant: undefined (NA) VIF, per #29/#73
x3 = rnorm(n)
)
expect_error(
modelPrune(y ~ x1 + x2 + x3, data = df, criterion = "vif", limit = 10, force_in = "x2"),
"diagnostic is undefined for 'force_in'"
)
})
test_that("modelPrune reports mid-loop force_in infeasibility, distinct from the upfront check (#82)", {
# A crafted custom engine whose diagnostic for "a"/"b" only exceeds the
# limit once "c" has already been removed -- so the upfront feasibility
# check (which only sees the full variable set) passes, and the
# "only force_in variables remain" branch inside the pruning loop is the
# one that has to catch the later violation instead.
crafted_engine <- list(
name = "crafted",
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
base <- c(a = 1, b = 1, c = 100)
if (!"c" %in% fixed_effects) {
base["a"] <- 100
base["b"] <- 100
}
base[fixed_effects]
}
)
set.seed(9021)
n <- 20
df <- data.frame(y = rnorm(n), a = rnorm(n), b = rnorm(n), c = rnorm(n))
expect_error(
modelPrune(y ~ a + b + c, data = df,
engine = crafted_engine, force_in = c("a", "b"), limit = 10),
"Cannot satisfy criterion: only force_in variables remain"
)
})
test_that("modelPrune returns correct attributes", {
df <- mtcars
result <- modelPrune(mpg ~ cyl + disp + hp, data = df, limit = 10)
expect_true("selected_vars" %in% names(attributes(result)))
expect_true("removed_vars" %in% names(attributes(result)))
expect_true("engine" %in% names(attributes(result)))
expect_true("criterion" %in% names(attributes(result)))
expect_true("limit" %in% names(attributes(result)))
expect_true("final_model" %in% names(attributes(result)))
})
test_that("modelPrune is deterministic", {
df <- mtcars
result1 <- modelPrune(mpg ~ cyl + disp + hp + wt, data = df, limit = 5)
result2 <- modelPrune(mpg ~ cyl + disp + hp + wt, data = df, limit = 5)
expect_equal(attr(result1, "selected_vars"), attr(result2, "selected_vars"))
expect_equal(attr(result1, "removed_vars"), attr(result2, "removed_vars"))
})
test_that("modelPrune handles max_steps correctly", {
df <- mtcars
# With max_steps = 1, should remove at most 1 variable
# Suppress expected warning about reaching max_steps
result <- suppressWarnings(
modelPrune(mpg ~ cyl + disp + hp + wt + drat + qsec,
data = df, limit = 2, max_steps = 1)
)
expect_true(length(attr(result, "removed_vars")) <= 1)
})
test_that("modelPrune handles single predictor", {
df <- mtcars
result <- modelPrune(mpg ~ cyl, data = df, limit = 5)
# Single predictor should have VIF = 1, always passes
expect_true("cyl" %in% attr(result, "selected_vars"))
expect_equal(length(attr(result, "removed_vars")), 0)
})
test_that("modelPrune handles categorical predictors", {
df <- mtcars
df$gear <- as.factor(df$gear)
result <- modelPrune(mpg ~ cyl + gear + wt, data = df, limit = 5)
expect_s3_class(result, "data.frame")
expect_true(ncol(result) >= 2)
})
# ===========================================================================
# Functional tests - glm engine
# ===========================================================================
test_that("modelPrune works with glm engine", {
skip_if_not(getRversion() >= "3.5.0")
df <- mtcars
df$am_binary <- as.factor(df$am)
# Suppress GLM convergence warnings (expected with this small dataset)
result <- suppressWarnings(
modelPrune(am_binary ~ cyl + disp + hp + wt,
data = df, engine = "glm",
family = binomial(), limit = 5)
)
expect_s3_class(result, "data.frame")
expect_equal(attr(result, "engine"), "glm")
})
test_that("modelPrune glm respects force_in", {
df <- mtcars
df$am_binary <- as.factor(df$am)
result <- modelPrune(am_binary ~ cyl + disp + hp,
data = df, engine = "glm",
family = binomial(), force_in = "cyl", limit = 10)
expect_true("cyl" %in% attr(result, "selected_vars"))
})
# ===========================================================================
# Functional tests - lme4 engine (conditional on package availability)
# ===========================================================================
test_that("modelPrune works with lme4 engine if available", {
skip_if_not_installed("lme4")
# Create simple dataset with grouping
set.seed(42)
df <- data.frame(
y = rnorm(100),
x1 = rnorm(100),
x2 = rnorm(100),
x3 = rnorm(100),
group = rep(1:10, each = 10)
)
# Suppress warnings from lme4 singular fits and pruning safety checks
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + x3 + (1|group),
data = df, engine = "lme4", limit = 5)
)
expect_s3_class(result, "data.frame")
expect_equal(attr(result, "engine"), "lme4")
# Group variable should NOT be in selected_vars (it's random effect)
expect_false("group" %in% attr(result, "selected_vars"))
})
test_that("modelPrune lme4 preserves random effects", {
skip_if_not_installed("lme4")
set.seed(123)
df <- data.frame(
y = rnorm(100),
x1 = rnorm(100),
x2 = rnorm(100),
group = rep(1:10, each = 10)
)
# Suppress warnings from lme4 singular fits and pruning safety checks
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + (1|group),
data = df, engine = "lme4", limit = 10)
)
# Check that final model has random effects
final_model <- attr(result, "final_model")
expect_true(inherits(final_model, "merMod"))
})
test_that(".rebuild_formula() re-parenthesizes random-effect terms so bar syntax survives refitting (#102)", {
# .parse_formula() extracts random-effect terms via terms()$term.labels,
# which strips the parens around "(1 | group)" down to "1 | group" --
# .rebuild_formula() must restore them, or "|" (lower precedence than "+")
# silently changes what the reassembled formula means to lme4/glmmTMB.
rebuilt_single <- corrselect:::.rebuild_formula("y", c("x1", "x2"), "1 | group")
expect_equal(rebuilt_single, y ~ x1 + x2 + (1 | group))
rebuilt_multi <- corrselect:::.rebuild_formula("y", c("x1", "x2"), c("1 | subject", "1 | site"))
expect_equal(rebuilt_multi, y ~ x1 + x2 + (1 | subject) + (1 | site))
})
test_that("modelPrune lme4 preserves a random-intercept structure across refits, not a random-slopes reinterpretation (#102)", {
skip_if_not_installed("lme4")
set.seed(123)
df <- data.frame(
y = rnorm(100),
x1 = rnorm(100),
x2 = rnorm(100),
group = rep(1:10, each = 10)
)
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + (1|group),
data = df, engine = "lme4", limit = 10)
)
final_model <- attr(result, "final_model")
# Without the parens fix, "x1 + x2 + 1 | group" is reinterpreted by lme4 as
# a random-slopes model varying x1 and x2 by group (3 random-effect
# columns: intercept, x1, x2), not the user's intended random-intercept-only
# model (1 column). ranef()$group has one column per random-effect term.
re_cols <- ncol(lme4::ranef(final_model)$group)
expect_equal(re_cols, 1)
})
test_that("modelPrune lme4 handles multiple random-effect terms without erroring (#102)", {
skip_if_not_installed("lme4")
set.seed(456)
n <- 200
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rnorm(n),
subject = factor(rep(1:20, each = 10)),
site = factor(rep(1:4, each = 50))
)
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + (1|subject) + (1|site),
data = df, engine = "lme4", limit = 10)
)
final_model <- attr(result, "final_model")
expect_true(inherits(final_model, "merMod"))
expect_setequal(names(lme4::ranef(final_model)), c("subject", "site"))
})
# ===========================================================================
# Functional tests - glmmTMB engine (conditional on package availability)
# ===========================================================================
test_that("modelPrune works with glmmTMB engine if available", {
skip_if_not_installed("glmmTMB")
set.seed(42)
df <- data.frame(
y = rpois(100, lambda = 5),
x1 = rnorm(100),
x2 = rnorm(100),
group = rep(1:10, each = 10)
)
# Suppress warnings from glmmTMB convergence and pruning safety checks
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + (1|group),
data = df, engine = "glmmTMB",
family = poisson(), limit = 5)
)
expect_s3_class(result, "data.frame")
expect_equal(attr(result, "engine"), "glmmTMB")
})
# ===========================================================================
# Edge cases
# ===========================================================================
test_that("modelPrune handles all predictors passing threshold", {
set.seed(42)
# Create data with low multicollinearity
df <- data.frame(
y = rnorm(100),
x1 = rnorm(100),
x2 = rnorm(100),
x3 = rnorm(100)
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df, limit = 10)
# All predictors should be retained
expect_equal(length(attr(result, "selected_vars")), 3)
expect_equal(length(attr(result, "removed_vars")), 0)
})
test_that("modelPrune handles formula with dot notation", {
df <- mtcars[, c("mpg", "cyl", "disp", "hp")]
result <- modelPrune(mpg ~ ., data = df, limit = 5)
expect_s3_class(result, "data.frame")
expect_true("mpg" %in% names(result))
})
# ===========================================================================
# Custom engine tests
# ===========================================================================
test_that("modelPrune validates custom engine structure", {
df <- mtcars
# Missing required fields
expect_error(
modelPrune(mpg ~ cyl, data = df, engine = list()),
"Custom engine missing required fields"
)
expect_error(
modelPrune(mpg ~ cyl, data = df, engine = list(fit = function() {})),
"Custom engine missing required fields.*diagnostics"
)
# Invalid field types
expect_error(
modelPrune(mpg ~ cyl, data = df,
engine = list(fit = "not a function", diagnostics = function() {})),
"Custom engine field 'fit' must be a function"
)
expect_error(
modelPrune(mpg ~ cyl, data = df,
engine = list(fit = function() {}, diagnostics = "not a function")),
"Custom engine field 'diagnostics' must be a function"
)
# Invalid name field
expect_error(
modelPrune(mpg ~ cyl, data = df,
engine = list(fit = function() {}, diagnostics = function() {},
name = c("a", "b"))),
"Custom engine field 'name' must be a single character string"
)
})
test_that("modelPrune works with simple custom engine", {
set.seed(42)
df <- data.frame(
y = rnorm(50),
x1 = rnorm(50),
x2 = rnorm(50),
x3 = rnorm(50)
)
# Make x2 and x3 correlated
df$x3 <- df$x2 * 0.95 + rnorm(50, sd = 0.1)
# Create a simple custom engine that uses VIF-like diagnostics
# but with a custom implementation
simple_engine <- list(
name = "simple_custom",
fit = function(formula, data, ...) {
stats::lm(formula, data = data)
},
diagnostics = function(model, fixed_effects) {
# Use car::vif if available, otherwise return simple R-squared based metric
X <- stats::model.matrix(model)
if ("(Intercept)" %in% colnames(X)) {
X <- X[, colnames(X) != "(Intercept)", drop = FALSE]
}
vifs <- numeric(length(fixed_effects))
names(vifs) <- fixed_effects
for (i in seq_along(fixed_effects)) {
pred <- fixed_effects[i]
if (pred %in% colnames(X)) {
y_i <- X[, pred]
X_other <- X[, colnames(X) != pred, drop = FALSE]
if (ncol(X_other) == 0) {
vifs[i] <- 1.0
} else {
fit <- lm(y_i ~ X_other)
r_sq <- summary(fit)$r.squared
vifs[i] <- 1 / (1 - r_sq)
}
} else {
vifs[i] <- NA
}
}
vifs
}
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df,
engine = simple_engine, limit = 5)
# Should remove at least one predictor due to high correlation
expect_s3_class(result, "data.frame")
expect_true(length(attr(result, "selected_vars")) < 3)
expect_equal(attr(result, "engine"), "simple_custom")
expect_equal(attr(result, "criterion"), "custom")
})
test_that("modelPrune custom engine respects force_in", {
set.seed(42)
df <- data.frame(
y = rnorm(50),
x1 = rnorm(50),
x2 = rnorm(50),
x3 = rnorm(50)
)
# Make x2 and x3 highly correlated
df$x3 <- df$x2 * 0.99 + rnorm(50, sd = 0.01)
custom_engine <- list(
name = "test_engine",
fit = function(formula, data, ...) {
stats::lm(formula, data = data)
},
diagnostics = function(model, fixed_effects) {
# Return fixed scores - x1 low, x2 medium, x3 high
scores <- c(x1 = 2, x2 = 8, x3 = 15)
scores[fixed_effects]
}
)
# Force x2 to stay (even though it exceeds limit)
result <- modelPrune(y ~ x1 + x2 + x3, data = df,
engine = custom_engine,
force_in = "x2", limit = 10)
# x2 should be retained, x3 should be removed (higher score, last in formula)
selected <- attr(result, "selected_vars")
expect_true("x2" %in% selected)
expect_false("x3" %in% selected)
})
test_that("modelPrune custom engine validates diagnostics output", {
df <- mtcars
# Diagnostics returns unnamed vector (correct length but no names)
bad_engine1 <- list(
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
c(1, 2) # Correct length but no names
}
)
expect_error(
modelPrune(mpg ~ cyl + disp, data = df, engine = bad_engine1, limit = 5),
"must return a named vector"
)
# Diagnostics returns non-numeric
bad_engine2 <- list(
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
c("a", "b")
}
)
expect_error(
modelPrune(mpg ~ cyl + disp, data = df, engine = bad_engine2, limit = 5),
"must return a numeric vector"
)
# Diagnostics returns unnamed vector
bad_engine3 <- list(
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
c(1, 2) # No names
}
)
expect_error(
modelPrune(mpg ~ cyl + disp, data = df, engine = bad_engine3, limit = 5),
"must return a named vector"
)
# Diagnostics returns vector with wrong names
bad_engine4 <- list(
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
c(wrong1 = 1, wrong2 = 2)
}
)
expect_error(
modelPrune(mpg ~ cyl + disp, data = df, engine = bad_engine4, limit = 5),
"missing names for"
)
})
test_that("modelPrune custom engine handles fitting errors gracefully", {
df <- mtcars
failing_engine <- list(
name = "failing",
fit = function(formula, data, ...) {
stop("Intentional fit failure")
},
diagnostics = function(model, fixed_effects) {
setNames(rep(1, length(fixed_effects)), fixed_effects)
}
)
expect_error(
modelPrune(mpg ~ cyl, data = df, engine = failing_engine, limit = 5),
"Model fitting with engine 'failing' failed.*Intentional fit failure"
)
})
test_that("modelPrune custom engine handles diagnostic errors gracefully", {
df <- mtcars
failing_diag_engine <- list(
name = "bad_diagnostics",
fit = function(formula, data, ...) {
stats::lm(formula, data = data)
},
diagnostics = function(model, fixed_effects) {
stop("Intentional diagnostics failure")
}
)
expect_error(
modelPrune(mpg ~ cyl, data = df, engine = failing_diag_engine, limit = 5),
"Custom engine 'bad_diagnostics' diagnostics.*failed.*Intentional"
)
})
test_that("modelPrune custom engine uses default name if not provided", {
df <- mtcars
unnamed_engine <- list(
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
setNames(rep(1, length(fixed_effects)), fixed_effects)
}
)
result <- modelPrune(mpg ~ cyl, data = df,
engine = unnamed_engine, limit = 10)
expect_equal(attr(result, "engine"), "custom")
})
test_that("modelPrune custom engine can implement non-VIF diagnostics", {
set.seed(42)
# Create data where y actually depends on predictors
df <- data.frame(
x1 = rnorm(100),
x2 = rnorm(100),
x3 = rnorm(100)
)
# Make y a function of x1, x2, x3 so they're all significant
df$y <- 2 * df$x1 + 3 * df$x2 - 1.5 * df$x3 + rnorm(100, sd = 0.5)
# Custom engine that uses p-values as diagnostic
pvalue_engine <- list(
name = "pvalue_pruner",
fit = function(formula, data, ...) {
stats::lm(formula, data = data)
},
diagnostics = function(model, fixed_effects) {
# Higher p-value = worse (inverted for removal logic)
coefs <- summary(model)$coefficients
# Get p-values for fixed effects (skip intercept)
pvals <- coefs[fixed_effects, "Pr(>|t|)", drop = FALSE]
# Invert so high p-value becomes high "badness"
scores <- pvals[, 1]
names(scores) <- fixed_effects
scores
}
)
# All predictors have low p-values, so nothing should be removed
result <- modelPrune(y ~ x1 + x2 + x3, data = df,
engine = pvalue_engine, limit = 0.05)
expect_equal(length(attr(result, "selected_vars")), 3)
expect_equal(attr(result, "engine"), "pvalue_pruner")
})
# ===========================================================================
# Additional coverage tests for modelPrune.R
# ===========================================================================
test_that("modelPrune custom engine with wrong length diagnostics", {
df <- mtcars
bad_engine <- list(
name = "wrong_length",
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
# Return named vector with wrong length
c(cyl = 1) # Only one value when expecting 2
}
)
expect_error(
modelPrune(mpg ~ cyl + disp, data = df, engine = bad_engine, limit = 5),
"missing names for"
)
})
test_that("modelPrune custom engine criterion parameter ignored with message", {
df <- mtcars
custom_engine <- list(
name = "test",
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
setNames(rep(1, length(fixed_effects)), fixed_effects)
}
)
# Setting criterion to non-vif should produce message
expect_message(
modelPrune(mpg ~ cyl, data = df, engine = custom_engine,
criterion = "aic", limit = 10),
"criterion.*ignored"
)
})
test_that("modelPrune handles max_steps warning", {
set.seed(1201)
df <- data.frame(
y = rnorm(100),
x1 = rnorm(100),
x2 = rnorm(100),
x3 = rnorm(100)
)
# Make x2 and x3 correlated
df$x3 <- df$x2 * 0.95 + rnorm(100, sd = 0.1)
# With max_steps = 1 and low limit, should warn
expect_warning(
modelPrune(y ~ x1 + x2 + x3, data = df, limit = 2, max_steps = 1),
"max_steps"
)
})
test_that("modelPrune handles max_steps NA error", {
df <- mtcars
expect_error(
modelPrune(mpg ~ cyl, data = df, max_steps = NA),
"'max_steps' must be"
)
})
test_that("modelPrune handles multiple predictor vectors with force_in", {
df <- mtcars
# Multiple force_in variables that satisfy threshold
result <- modelPrune(mpg ~ cyl + disp + hp + wt,
data = df, force_in = c("cyl", "wt"), limit = 50)
expect_true(all(c("cyl", "wt") %in% attr(result, "selected_vars")))
})
test_that("modelPrune handles limit = 0 error", {
df <- mtcars
expect_error(
modelPrune(mpg ~ cyl, data = df, limit = 0),
"'limit' must be positive"
)
})
test_that("modelPrune with interaction terms", {
df <- mtcars
result <- modelPrune(mpg ~ cyl * disp, data = df, limit = 100)
expect_s3_class(result, "data.frame")
expect_true("mpg" %in% names(result))
})
test_that("modelPrune glm with poisson family", {
set.seed(1202)
df <- data.frame(
y = rpois(100, lambda = 5),
x1 = rnorm(100),
x2 = rnorm(100)
)
result <- modelPrune(y ~ x1 + x2, data = df,
engine = "glm", family = poisson(), limit = 10)
expect_s3_class(result, "data.frame")
expect_equal(attr(result, "engine"), "glm")
})
test_that("modelPrune lme4 with glmer", {
skip_if_not_installed("lme4")
set.seed(1203)
df <- data.frame(
y = rbinom(100, 1, 0.5),
x1 = rnorm(100),
x2 = rnorm(100),
group = rep(1:10, each = 10)
)
# Suppress lme4 convergence warnings for small data
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + (1|group), data = df,
engine = "lme4", family = binomial(), limit = 10)
)
expect_s3_class(result, "data.frame")
expect_equal(attr(result, "engine"), "lme4")
})
test_that("modelPrune handles two-predictor model", {
df <- mtcars
result <- modelPrune(mpg ~ cyl + disp, data = df, limit = 10)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune removes predictors iteratively", {
set.seed(1204)
df <- data.frame(
y = rnorm(100),
x1 = rnorm(100),
x2 = rnorm(100),
x3 = rnorm(100)
)
# Create collinearity between x1, x2, x3
df$x2 <- df$x1 * 0.99 + rnorm(100, sd = 0.01)
df$x3 <- df$x1 * 0.98 + rnorm(100, sd = 0.02)
result <- modelPrune(y ~ x1 + x2 + x3, data = df, limit = 5)
# Should have removed some variables
expect_true(length(attr(result, "removed_vars")) > 0)
})
test_that("modelPrune attributes are complete", {
df <- mtcars
result <- modelPrune(mpg ~ cyl + disp + hp, data = df, limit = 10)
# Check all expected attributes
attrs <- attributes(result)
expect_true("selected_vars" %in% names(attrs))
expect_true("removed_vars" %in% names(attrs))
expect_true("engine" %in% names(attrs))
expect_true("criterion" %in% names(attrs))
expect_true("limit" %in% names(attrs))
expect_true("final_model" %in% names(attrs))
expect_true("n_vars_original" %in% names(attrs))
expect_true("n_vars_selected" %in% names(attrs))
})
test_that("modelPrune VIF with highly correlated predictors", {
set.seed(1205)
n <- 100
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.001), # Near-perfect collinearity
x3 = rnorm(n)
)
# Should handle VIF = Inf gracefully
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + x3, data = df, limit = 5)
)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune lme4 package not installed error", {
skip_if(requireNamespace("lme4", quietly = TRUE))
df <- data.frame(
y = rnorm(20),
x1 = rnorm(20),
group = rep(1:4, each = 5)
)
expect_error(
modelPrune(y ~ x1 + (1|group), data = df, engine = "lme4"),
"lme4 package"
)
})
test_that("modelPrune glmmTMB package not installed error", {
skip_if(requireNamespace("glmmTMB", quietly = TRUE))
df <- data.frame(
y = rnorm(20),
x1 = rnorm(20),
group = rep(1:4, each = 5)
)
expect_error(
modelPrune(y ~ x1 + (1|group), data = df, engine = "glmmTMB"),
"glmmTMB package"
)
})
# ===========================================================================
# Additional VIF and edge case tests
# ===========================================================================
test_that("modelPrune VIF with highly collinear predictors", {
set.seed(9001)
n <- 100
x1 <- rnorm(n)
x2 <- x1 + rnorm(n, sd = 0.01) # Nearly identical to x1
x3 <- rnorm(n)
y <- x1 + x3 + rnorm(n)
df <- data.frame(y = y, x1 = x1, x2 = x2, x3 = x3)
# VIF should be very high for x1 and x2
result <- modelPrune(y ~ x1 + x2 + x3, data = df, criterion = "vif", limit = 10)
expect_s3_class(result, "data.frame")
# One of x1 or x2 should be removed due to high VIF
expect_true(ncol(result) <= ncol(df))
})
test_that("modelPrune (crash-safety, not a value check) VIF with factor predictors", {
set.seed(9002)
n <- 60
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = factor(sample(c("A", "B", "C"), n, replace = TRUE)),
x3 = rnorm(n)
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df, criterion = "vif", limit = 5)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune VIF handles constant predictor", {
set.seed(9003)
n <- 50
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rep(5, n), # Constant: undefined (NA) VIF, per #29
x3 = rnorm(n)
)
# A constant predictor has an undefined (NA) VIF (#29: NA, never silently
# a plausible-looking finite value, per #73). An undefined diagnostic is
# treated as a violation once no finite violation remains, so the constant
# predictor is the one removed and the well-behaved ones survive.
result <- modelPrune(y ~ x1 + x2 + x3, data = df, criterion = "vif", limit = 10)
expect_s3_class(result, "data.frame")
expect_setequal(names(result), c("y", "x1", "x3"))
expect_equal(attr(result, "removed_vars"), "x2")
})
test_that("modelPrune with single predictor", {
set.seed(9004)
n <- 50
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n)
)
result <- modelPrune(y ~ x1, data = df, criterion = "vif", limit = 5)
expect_s3_class(result, "data.frame")
# Single predictor should remain
expect_true("x1" %in% names(result))
})
test_that("modelPrune (crash-safety, not a value check) with two predictors", {
set.seed(9005)
n <- 50
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rnorm(n)
)
result <- modelPrune(y ~ x1 + x2, data = df, criterion = "vif", limit = 5)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune force_in preserves specified variables", {
set.seed(9006)
n <- 80
x1 <- rnorm(n)
x2 <- rnorm(n) # Independent from x1
x3 <- rnorm(n)
y <- x1 + x3 + rnorm(n)
df <- data.frame(y = y, x1 = x1, x2 = x2, x3 = x3)
# Force x2 to stay
result <- modelPrune(y ~ x1 + x2 + x3, data = df, criterion = "vif",
limit = 10, force_in = "x2")
expect_s3_class(result, "data.frame")
expect_true("x2" %in% names(result))
})
test_that("modelPrune glm engine with binomial family", {
set.seed(9007)
n <- 100
df <- data.frame(
y = rbinom(n, 1, 0.5),
x1 = rnorm(n),
x2 = rnorm(n),
x3 = rnorm(n)
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df, engine = "glm",
criterion = "vif", limit = 5, family = binomial())
expect_s3_class(result, "data.frame")
})
test_that("modelPrune handles NA in data", {
set.seed(9008)
n <- 50
df <- data.frame(
y = c(rnorm(48), NA, NA),
x1 = c(rnorm(48), NA, 1),
x2 = rnorm(n),
x3 = rnorm(n)
)
# VIF is computed on complete cases internally, but (like corrPrune()) the
# returned data frame is column-subsetted only -- rows are not dropped.
result <- modelPrune(y ~ x1 + x2 + x3, data = df, criterion = "vif", limit = 5)
expect_s3_class(result, "data.frame")
expect_equal(nrow(result), n)
expect_true(all(c("x1", "x2", "x3") %in% names(result)))
})
test_that("modelPrune errors informatively on a fully-NA predictor column (#64)", {
set.seed(9009)
df <- mtcars
df$wt <- NA_real_
expect_error(
modelPrune(mpg ~ ., data = df, engine = "lm", limit = 5),
"failed"
)
})
test_that("modelPrune errors informatively on a fully-NA response (#64)", {
set.seed(9010)
df <- mtcars
df$mpg <- NA_real_
expect_error(
modelPrune(mpg ~ ., data = df, engine = "lm", limit = 5),
"failed"
)
})
test_that("modelPrune does not crash on a single-row data frame (#64)", {
# A degenerate, saturated fit (0 residual df); documenting current
# robustness behavior (no crash) rather than a specific pruning decision,
# since VIF is not well-defined with a single observation.
df <- data.frame(x = 1, y = 2, z = 3)
result <- modelPrune(x ~ y + z, data = df, engine = "lm", limit = 5)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune custom engine diagnostics without names but wrong length", {
df <- mtcars
# Engine that returns unnamed vector with wrong length
bad_engine <- list(
name = "wrong_length_no_names",
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
# Return wrong number of values without names
rep(1.0, length(fixed_effects) + 1)
}
)
expect_error(
modelPrune(mpg ~ cyl + disp + hp, data = df, engine = bad_engine, limit = 5),
"must return exactly.*value"
)
})
test_that("modelPrune custom engine diagnostics without names but correct length", {
df <- mtcars
# Engine that returns correct length but no names
bad_engine <- list(
name = "correct_length_no_names",
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
# Return correct number of values but without names
rep(1.0, length(fixed_effects))
}
)
expect_error(
modelPrune(mpg ~ cyl + disp, data = df, engine = bad_engine, limit = 5),
"must return a named vector"
)
})
test_that("modelPrune custom engine diagnostics with extra names", {
df <- mtcars
# Engine that returns more values than needed (with names)
bad_engine <- list(
name = "extra_names",
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
vals <- rep(1.0, length(fixed_effects) + 2)
names(vals) <- c(fixed_effects, "extra1", "extra2")
vals
}
)
expect_error(
modelPrune(mpg ~ cyl + disp, data = df, engine = bad_engine, limit = 5),
"must return exactly"
)
})
test_that("modelPrune custom engine diagnostics returns non-numeric", {
df <- mtcars
# Engine that returns character instead of numeric
bad_engine <- list(
name = "non_numeric",
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
vals <- rep("high", length(fixed_effects))
names(vals) <- fixed_effects
vals
}
)
expect_error(
modelPrune(mpg ~ cyl + disp, data = df, engine = bad_engine, limit = 5),
"must return a numeric vector"
)
})
# ===========================================================================
# Additional edge case tests for full coverage
# ===========================================================================
test_that("modelPrune handles Inf VIF values (near-perfect collinearity)", {
set.seed(9101)
n <- 100
x1 <- rnorm(n)
# Create near-perfect collinearity
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 1e-10), # Essentially identical
x3 = rnorm(n)
)
# Should handle Inf VIF gracefully
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + x3, data = df, limit = 5)
)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune handles VIF with all near-collinear predictors", {
set.seed(9102)
n <- 100
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 1e-8),
x3 = x1 + rnorm(n, sd = 1e-8)
)
# All have Inf VIF essentially
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + x3, data = df, limit = 10)
)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune tie-breaking removes last variable in formula order", {
set.seed(9103)
n <- 100
x1 <- rnorm(n)
# Create two equally collinear pairs
df <- data.frame(
y = rnorm(n),
a = x1,
b = x1 + rnorm(n, sd = 0.1),
c = rnorm(n),
d = rnorm(n)
)
result <- modelPrune(y ~ a + b + c + d, data = df, limit = 5)
# b should be removed (same VIF as a, but later in formula)
expect_true("a" %in% attr(result, "selected_vars") ||
"b" %in% attr(result, "selected_vars"))
})
test_that("modelPrune errors when no fixed effects remain", {
df <- mtcars
# Create custom engine that always returns high diagnostics
always_high_engine <- list(
name = "always_high",
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
setNames(rep(100, length(fixed_effects)), fixed_effects)
}
)
# This should warn about removing all predictors
expect_warning(
modelPrune(mpg ~ cyl, data = df, engine = always_high_engine, limit = 1),
"would remove all"
)
})
test_that("modelPrune (crash-safety, not a value check) handles design matrix with factor predictors", {
set.seed(9104)
n <- 60
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
cat = factor(sample(c("A", "B", "C"), n, replace = TRUE)),
x2 = rnorm(n)
)
result <- modelPrune(y ~ x1 + cat + x2, data = df, limit = 10)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune (crash-safety, not a value check) VIF computation with missing column match", {
set.seed(9105)
n <- 50
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rnorm(n)
)
result <- modelPrune(y ~ x1 + x2, data = df, limit = 10)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune (crash-safety, not a value check) handles multi-level factors in VIF", {
set.seed(9106)
n <- 100
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
cat = factor(sample(LETTERS[1:5], n, replace = TRUE)),
x2 = rnorm(n)
)
result <- modelPrune(y ~ x1 + cat + x2, data = df, limit = 10)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune custom engine criterion parameter shows message", {
df <- mtcars
custom_engine <- list(
name = "test_msg",
fit = function(formula, data, ...) stats::lm(formula, data = data),
diagnostics = function(model, fixed_effects) {
setNames(rep(1, length(fixed_effects)), fixed_effects)
}
)
# Non-vif criterion with custom engine should show message
expect_message(
modelPrune(mpg ~ cyl + disp, data = df, engine = custom_engine,
criterion = "custom_crit", limit = 10),
"ignored"
)
})
test_that("modelPrune (crash-safety, not a value check) handles R-squared edge cases in VIF", {
set.seed(9107)
n <- 50
# Create data where VIF might produce unusual R-squared values
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = scale(x1) * 0.5 + rnorm(n, sd = 0.5), # Moderate correlation
x3 = rnorm(n)
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df, limit = 10)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune iterative removal works correctly", {
set.seed(9108)
n <- 100
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.1), # High VIF
x3 = x1 + rnorm(n, sd = 0.2), # High VIF
x4 = rnorm(n)
)
# Multiple iterations needed
result <- modelPrune(y ~ x1 + x2 + x3 + x4, data = df, limit = 5)
expect_s3_class(result, "data.frame")
expect_true(length(attr(result, "removed_vars")) >= 1)
})
test_that("modelPrune handles intercept-only design matrix", {
set.seed(9109)
n <- 50
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n)
)
# Single predictor case
result <- modelPrune(y ~ x1, data = df, limit = 10)
expect_s3_class(result, "data.frame")
expect_true("x1" %in% names(result))
})
test_that("modelPrune lme4 glmer with binomial family", {
skip_if_not_installed("lme4")
set.seed(9110)
n <- 100
# Give the grouping factor genuine (non-zero) random-effect variance and x1
# a real fixed effect. A y that is fully unrelated to both group and the
# predictors puts the fit exactly at the tau = 0 boundary, where glmer's
# Cholesky-based optimizer is numerically unstable and platform-sensitive
# (observed as a hard "Downdated VtV is not positive definite" error on
# some BLAS/LAPACK builds instead of the usual singular-fit warning).
group_effect <- rnorm(10, sd = 0.8)
df <- data.frame(
x1 = rnorm(n),
x2 = rnorm(n),
group = factor(rep(1:10, each = 10))
)
lin_pred <- 0.5 * df$x1 + group_effect[as.integer(df$group)]
df$y <- rbinom(n, 1, stats::plogis(lin_pred))
# Even with genuine random-effect and fixed-effect signal, glmer's PIRLS
# optimizer can still hit this boundary-adjacent numerical failure on some
# BLAS/LAPACK builds for a given seed (observed on windows-latest CI while
# passing on the same seed on other platforms and locally). That failure
# is internal to lme4's Cholesky update, not a corrselect defect -- so it
# is tolerated here (skip) while any other error still fails the test.
result <- tryCatch(
suppressWarnings(
modelPrune(y ~ x1 + x2 + (1|group), data = df,
engine = "lme4", family = binomial(), limit = 10)
),
error = function(e) {
if (grepl("Downdated VtV|not positive definite", conditionMessage(e), fixed = FALSE)) {
skip(paste("lme4 glmer numerically unstable on this platform:", conditionMessage(e)))
}
stop(e)
}
)
expect_s3_class(result, "data.frame")
expect_equal(attr(result, "engine"), "lme4")
})
test_that("modelPrune VIF with only one predictor remaining after removal", {
set.seed(9111)
n <- 100
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 # Perfect collinearity
)
result <- suppressWarnings(
modelPrune(y ~ x1 + x2, data = df, limit = 5)
)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune handles valid diagnostics at boundary of threshold", {
set.seed(9112)
n <- 50
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rnorm(n)
)
# With very high limit, all should pass
result <- modelPrune(y ~ x1 + x2, data = df, limit = 1000)
expect_s3_class(result, "data.frame")
expect_equal(length(attr(result, "removed_vars")), 0)
})
# ===========================================================================
# More edge case tests for internal functions
# ===========================================================================
test_that("modelPrune VIF with single remaining predictor", {
set.seed(9201)
n <- 100
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n)
)
# Only one predictor - VIF should be 1 (or NA/handled)
result <- modelPrune(y ~ x1, data = df, limit = 10)
expect_s3_class(result, "data.frame")
expect_true("x1" %in% names(result))
})
test_that("modelPrune with perfect multicollinearity removes predictors", {
set.seed(9202)
n <- 100
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1, # Exactly equal to x1
x3 = rnorm(n)
)
# Should handle Inf VIF
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + x3, data = df, limit = 5)
)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune lme4 package check", {
skip_if_not_installed("lme4")
set.seed(9203)
n <- 100
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rnorm(n),
group = factor(rep(1:10, each = 10))
)
result <- modelPrune(y ~ x1 + x2 + (1|group), data = df,
engine = "lme4", limit = 10)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune glmmTMB package check", {
skip_if_not_installed("glmmTMB")
set.seed(9204)
n <- 100
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rnorm(n),
group = factor(rep(1:10, each = 10))
)
result <- modelPrune(y ~ x1 + x2 + (1|group), data = df,
engine = "glmmTMB", limit = 10)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune removes multiple predictors iteratively", {
set.seed(9205)
n <- 200
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.05),
x3 = x1 + rnorm(n, sd = 0.05),
x4 = x1 + rnorm(n, sd = 0.05),
x5 = rnorm(n)
)
# Multiple high VIF predictors should be removed
result <- modelPrune(y ~ x1 + x2 + x3 + x4 + x5, data = df, limit = 3)
expect_s3_class(result, "data.frame")
expect_true(length(attr(result, "removed_vars")) >= 1)
})
# ===========================================================================
# Condition number criterion tests
# ===========================================================================
test_that("modelPrune with condition_number criterion works", {
set.seed(9301)
n <- 100
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.1), # Highly collinear
x3 = rnorm(n) # Independent
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df, criterion = "condition_number", limit = 10)
expect_s3_class(result, "data.frame")
expect_equal(attr(result, "criterion"), "condition_number")
# Should prune at least one collinear variable
expect_true(length(attr(result, "removed_vars")) >= 1)
})
test_that("modelPrune condition_number prunes collinear predictors", {
set.seed(9302)
n <- 200
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.01), # Very highly collinear
x3 = rnorm(n) # Independent
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df, criterion = "condition_number", limit = 5)
expect_s3_class(result, "data.frame")
# With a strict limit, at least one collinear should be removed
expect_true(length(attr(result, "removed_vars")) >= 1)
})
test_that("modelPrune condition_number with single predictor returns 1",
{
set.seed(9303)
df <- data.frame(y = rnorm(50), x = rnorm(50))
result <- modelPrune(y ~ x, data = df, criterion = "condition_number", limit = 5)
expect_s3_class(result, "data.frame")
expect_equal(length(attr(result, "selected_vars")), 1)
})
test_that("modelPrune condition_number with GLM engine", {
set.seed(9304)
n <- 100
x1 <- rnorm(n)
df <- data.frame(
y = rbinom(n, 1, 0.5),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.1),
x3 = rnorm(n)
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df,
engine = "glm", family = binomial(),
criterion = "condition_number", limit = 10)
expect_s3_class(result, "data.frame")
expect_equal(attr(result, "criterion"), "condition_number")
})
# ===========================================================================
# Tests for lme4 engine (when available)
# ===========================================================================
test_that("modelPrune with lme4 engine prunes correctly", {
skip_if_not(requireNamespace("lme4", quietly = TRUE))
set.seed(5001)
n <- 100
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.1), # Collinear
x3 = rnorm(n),
group = factor(rep(1:10, each = 10))
)
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + x3 + (1|group), data = df, engine = "lme4", limit = 5)
)
expect_s3_class(result, "data.frame")
expect_equal(attr(result, "engine"), "lme4")
})
test_that("modelPrune lme4 with glmer (binomial)", {
skip_if_not(requireNamespace("lme4", quietly = TRUE))
set.seed(5002)
n <- 100
df <- data.frame(
y = rbinom(n, 1, 0.5),
x1 = rnorm(n),
x2 = rnorm(n),
group = factor(rep(1:10, each = 10))
)
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + (1|group), data = df, engine = "lme4",
family = binomial(), limit = 10)
)
expect_s3_class(result, "data.frame")
})
# ===========================================================================
# Tests for glmmTMB engine (when available)
# ===========================================================================
test_that("modelPrune with glmmTMB engine works", {
skip_if_not(requireNamespace("glmmTMB", quietly = TRUE))
set.seed(5003)
n <- 100
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rnorm(n),
group = factor(rep(1:10, each = 10))
)
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + (1|group), data = df, engine = "glmmTMB", limit = 10)
)
expect_s3_class(result, "data.frame")
expect_equal(attr(result, "engine"), "glmmTMB")
})
# ===========================================================================
# Tests for condition_number with edge cases
# ===========================================================================
test_that("modelPrune condition_number handles perfectly uncorrelated data", {
set.seed(5004)
n <- 100
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rnorm(n),
x3 = rnorm(n)
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df,
criterion = "condition_number", limit = 100)
expect_s3_class(result, "data.frame")
# All should be kept (low collinearity)
expect_equal(length(attr(result, "selected_vars")), 3)
})
test_that("modelPrune condition_number with glm engine", {
set.seed(5005)
n <- 100
x1 <- rnorm(n)
df <- data.frame(
y = rbinom(n, 1, 0.5),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.05), # Very collinear
x3 = rnorm(n)
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df, engine = "glm",
family = binomial(), criterion = "condition_number", limit = 5)
expect_s3_class(result, "data.frame")
})
# ===========================================================================
# Edge case: All diagnostics become NA/Inf
# ===========================================================================
test_that("modelPrune handles all NA/Inf diagnostics gracefully", {
set.seed(6001)
n <- 50
# Create perfectly collinear data
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1, # Perfectly collinear
x3 = x1 # Perfectly collinear
)
# GVIF (#86) computes the collinear predictors' Inf VIF directly via a
# singular correlation-matrix determinant, with no intermediate model fit
# to raise an incidental warning -- it converges to a single retained
# predictor silently, rather than through a warn-and-stop path.
result <- modelPrune(y ~ x1 + x2 + x3, data = df, limit = 5)
expect_s3_class(result, "data.frame")
expect_equal(ncol(result), 2) # response column + one surviving predictor
expect_equal(attr(result, "selected_vars"), "x1")
})
# ===========================================================================
# Edge case: VIF with categorical predictors (factor columns)
# ===========================================================================
test_that("modelPrune handles factor predictors correctly", {
set.seed(6002)
n <- 100
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rnorm(n),
cat1 = factor(sample(c("A", "B", "C"), n, replace = TRUE))
)
result <- modelPrune(y ~ x1 + x2 + cat1, data = df, limit = 10)
expect_s3_class(result, "data.frame")
expect_true("cat1" %in% names(result) || "cat1" %in% attr(result, "removed_vars"))
})
# ===========================================================================
# Edge case: condition_number with near-singular matrix
# ===========================================================================
test_that("modelPrune condition_number handles near-singular data", {
set.seed(6003)
n <- 50
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.001), # Nearly identical
x3 = rnorm(n)
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df,
criterion = "condition_number", limit = 5)
expect_s3_class(result, "data.frame")
# Should remove at least one collinear variable
expect_true(length(attr(result, "removed_vars")) >= 1)
})
# ===========================================================================
# Edge case: VIF when only one predictor remains
# ===========================================================================
test_that("modelPrune handles single remaining predictor", {
set.seed(6004)
n <- 100
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.01) # Very collinear
)
# With very low limit, might reduce to single predictor
result <- modelPrune(y ~ x1 + x2, data = df, limit = 1.5)
expect_s3_class(result, "data.frame")
})
# ===========================================================================
# Edge case: GLM with separation issues
# ===========================================================================
test_that("modelPrune GLM handles quasi-separation", {
set.seed(6005)
n <- 100
x1 <- rnorm(n)
# Create some separation
y <- as.integer(x1 > 0)
df <- data.frame(y = y, x1 = x1, x2 = rnorm(n), x3 = rnorm(n))
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + x3, data = df, engine = "glm",
family = binomial(), limit = 10)
)
expect_s3_class(result, "data.frame")
})
# ===========================================================================
# Chi-squared NA edge case (line 350 in corrPrune)
# ===========================================================================
test_that("corrPrune handles chi2 NA from empty cells in contingency table", {
set.seed(11001)
n <- 20
# Create factors that produce a contingency table where chi2 might be NA
# This happens when expected frequencies are very low
fac1 <- factor(c(rep("A", 18), "B", "C"))
fac2 <- factor(c(rep("X", 18), "Y", "Z"))
df <- data.frame(fac1 = fac1, fac2 = fac2, num1 = rnorm(n))
# Should handle gracefully even with problematic contingency table
result <- corrPrune(df, threshold = 0.99)
expect_s3_class(result, "data.frame")
})
# ===========================================================================
# VIF: Column matching with factors (lines 622, 627-633)
# ===========================================================================
test_that("modelPrune VIF handles factor with many levels", {
set.seed(11002)
n <- 100
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rnorm(n),
cat = factor(sample(LETTERS[1:5], n, replace = TRUE))
)
# Factor creates multiple columns in design matrix
result <- modelPrune(y ~ x1 + x2 + cat, data = df, limit = 20)
expect_s3_class(result, "data.frame")
})
# ===========================================================================
# VIF: perfectly collinear predictors (GVIF determinant collapses to 0)
# ===========================================================================
test_that("modelPrune handles perfectly collinear predictors", {
set.seed(11003)
n <- 50
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 * 2, # Perfect linear relationship
x3 = x1 * 3, # Perfect linear relationship
x4 = rnorm(n)
)
# x1/x2/x3 are pairwise perfectly correlated, which makes the *entire*
# design matrix rank-deficient -- confirmed independently: car::vif() on
# the equivalent lm() fit errors with "there are aliased coefficients in
# the model" rather than returning a value for any predictor, x4 included.
# GVIF's shared det(R_full) denominator is exactly 0 for every predictor
# in this state (not just x1/x2/x3), so every predictor -- including the
# otherwise-independent x4 -- is flagged Inf together; modelPrune()'s
# last-in-formula-order tie-break then removes x4, x3, x2 in that order
# until the remaining design matrix (x1 alone) is non-singular again.
result <- modelPrune(y ~ x1 + x2 + x3 + x4, data = df, limit = 5)
expect_s3_class(result, "data.frame")
expect_equal(attr(result, "selected_vars"), "x1")
expect_equal(attr(result, "removed_vars"), c("x4", "x3", "x2"))
})
# ===========================================================================
# VIF: Single predictor remaining after removal (lines 642-643)
# ===========================================================================
test_that("modelPrune VIF returns 1 for single predictor", {
set.seed(11004)
n <- 50
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.001) # Extremely collinear
)
# With very strict limit, should reduce to single predictor
result <- suppressWarnings(
modelPrune(y ~ x1 + x2, data = df, limit = 1.1)
)
expect_s3_class(result, "data.frame")
# Should have only 1 predictor (VIF = 1 for single predictor)
expect_true(length(attr(result, "selected_vars")) >= 1)
})
# ===========================================================================
# VIF: Error in lm computation (lines 678-679)
# ===========================================================================
test_that("modelPrune handles lm errors gracefully", {
set.seed(11005)
n <- 10 # Very small sample
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
x2 = rnorm(n),
x3 = rnorm(n),
x4 = rnorm(n),
x5 = rnorm(n)
)
# More predictors than observations can cause issues
result <- suppressWarnings(
modelPrune(y ~ x1 + x2 + x3 + x4 + x5, data = df, limit = 10)
)
expect_s3_class(result, "data.frame")
})
# ===========================================================================
# Condition number: SVD edge cases (lines 756-757, 780, 784)
# ===========================================================================
test_that("modelPrune condition_number handles SVD edge cases", {
set.seed(11006)
n <- 50
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.0001), # Nearly identical
x3 = rnorm(n)
)
# SVD with nearly singular matrix
result <- modelPrune(y ~ x1 + x2 + x3, data = df,
criterion = "condition_number", limit = 3)
expect_s3_class(result, "data.frame")
})
# ===========================================================================
# assocSelect: Cramer V with problematic contingency table (line 187, 191)
# ===========================================================================
test_that("assocSelect handles Cramers V edge cases", {
set.seed(11007)
n <- 30
# Create factors where one has very few levels used
df <- data.frame(
fac1 = factor(c(rep("A", n-1), "B")),
fac2 = factor(sample(c("X", "Y", "Z"), n, replace = TRUE)),
num1 = rnorm(n)
)
result <- assocSelect(df, threshold = 0.95)
expect_true(inherits(result, "CorrCombo"))
})
test_that("assocSelect handles zero row/column sums in contingency table", {
set.seed(11008)
n <- 20
# Factors that might produce sparse tables
df <- data.frame(
fac1 = factor(c(rep("A", 10), rep("B", 10))),
fac2 = factor(c(rep("X", 10), rep("Y", 10))), # Perfect association
num1 = rnorm(n)
)
result <- assocSelect(df, threshold = 0.5)
expect_true(inherits(result, "CorrCombo"))
})
# ===========================================================================
# Synthetic edge case tests for modelPrune VIF computation
# ===========================================================================
test_that("modelPrune handles single predictor (no VIF needed)", {
set.seed(14001)
n <- 50
df <- data.frame(
y = rnorm(n),
x = rnorm(n)
)
result <- modelPrune(y ~ x, data = df, limit = 5)
expect_s3_class(result, "data.frame")
expect_true("x" %in% names(result))
})
test_that("modelPrune with all diagnostics Inf converges to a single predictor", {
set.seed(14002)
n <- 30
# Create perfectly collinear data
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1, # Perfect duplicate
x3 = x1 # Another perfect duplicate
)
# Perfectly duplicated predictors give each other an (effectively infinite)
# GVIF, computed directly from the singular correlation-matrix determinant
# (#86) with no intermediate model fit; modelPrune() should still converge
# to a single non-collinear predictor rather than erroring out.
result <- modelPrune(y ~ x1 + x2 + x3, data = df, limit = 5)
expect_equal(attr(result, "selected_vars"), "x1")
})
test_that("modelPrune handles predictor name mismatch in design matrix", {
set.seed(14003)
n <- 50
df <- data.frame(
y = rnorm(n),
x.1 = rnorm(n), # Name with special character
x.2 = rnorm(n)
)
# Model should handle predictor names with dots
result <- modelPrune(y ~ x.1 + x.2, data = df, limit = 10)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune handles factor predictors in VIF", {
set.seed(14004)
n <- 60
df <- data.frame(
y = rnorm(n),
x_num = rnorm(n),
x_cat = factor(sample(c("A", "B", "C"), n, replace = TRUE))
)
result <- modelPrune(y ~ x_num + x_cat, data = df, limit = 10)
expect_s3_class(result, "data.frame")
})
test_that("modelPrune with condition_number handles degenerate design", {
set.seed(14005)
n <- 30
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.001) # Nearly collinear
)
result <- modelPrune(y ~ x1 + x2, data = df, criterion = "condition_number", limit = 100)
expect_s3_class(result, "data.frame")
})
# ===========================================================================
# Recovery-style and reference-verified tests (closes coverage gaps flagged
# in issue #27: prior VIF/condition_number tests mostly checked "does not
# error", never that the numbers themselves are correct against an
# independent reference).
# ===========================================================================
test_that("modelPrune VIF matches car::vif() for numeric-only predictors", {
skip_if_not_installed("car")
set.seed(99)
n <- 200
df <- data.frame(y = rnorm(n), x1 = rnorm(n), x2 = rnorm(n), x3 = rnorm(n))
df$x2 <- 0.6 * df$x1 + rnorm(n, sd = 0.5) # correlated with x1
fit <- lm(y ~ x1 + x2 + x3, data = df)
reference <- car::vif(fit)
ours <- corrselect:::.compute_vif(fit, "lm", c("x1", "x2", "x3"))
expect_equal(unname(ours[c("x1", "x2", "x3")]), unname(reference[c("x1", "x2", "x3")]),
tolerance = 1e-6)
})
test_that("modelPrune VIF matches car::vif()'s GVIF for a numeric predictor alongside a factor", {
skip_if_not_installed("car")
set.seed(99)
n <- 200
df <- data.frame(
y = rnorm(n),
x1 = rnorm(n),
cat = factor(sample(c("A", "B", "C"), n, replace = TRUE))
)
fit <- lm(y ~ x1 + cat, data = df)
reference <- car::vif(fit) # matrix: GVIF, Df, GVIF^(1/(2*Df))
ours <- corrselect:::.compute_vif(fit, "lm", c("x1", "cat"))
# Both x1 (Df = 1) and cat (Df = 2) use the same generalized-variance-ratio
# GVIF formula as car::vif() (Fox & Monette 1992), so both match exactly.
expect_equal(unname(ours["x1"]), unname(reference["x1", "GVIF"]), tolerance = 1e-6)
expect_equal(unname(ours["cat"]), unname(reference["cat", "GVIF"]), tolerance = 1e-6)
})
test_that("modelPrune VIF detects severe collinearity in a multi-level factor (#86)", {
# Regression test for #86: .compute_vif() used to average a factor's dummy
# columns into one numeric vector instead of computing GVIF, which could
# cancel out real collinearity for a factor whose non-reference levels
# diverge from the reference mean in opposite directions.
skip_if_not_installed("car")
set.seed(42)
n <- 300
grp <- factor(sample(c("A", "B", "C"), n, replace = TRUE))
x <- ifelse(grp == "A", rnorm(n, 0, 0.5),
ifelse(grp == "B", rnorm(n, -5, 0.5), rnorm(n, 5, 0.5)))
df <- data.frame(y = rnorm(n), x = x, grp = grp)
fit <- lm(y ~ x + grp, data = df)
reference <- car::vif(fit)
ours <- corrselect:::.compute_vif(fit, "lm", c("x", "grp"))
expect_equal(unname(ours["x"]), unname(reference["x", "GVIF"]), tolerance = 1e-6)
expect_equal(unname(ours["grp"]), unname(reference["grp", "GVIF"]), tolerance = 1e-6)
# grp is almost perfectly determined by x -- both should be flagged as
# severely collinear under the package's default limit = 5, not the ~1.0
# ("no collinearity") the old row-average implementation produced for grp.
expect_gt(unname(ours["grp"]), 5)
})
test_that("modelPrune VIF matches car::vif() for an lme4 merMod fit (#104)", {
# Prior to this test, .compute_vif()'s lme4/glmmTMB engines were smoke-
# tested only (does the call run, right shape/class) -- never checked
# against an independent reference the way the lm path is above, despite
# routing the fixed-effects-only design matrix through the same GVIF code
# (a code path with its own bug history for the lm engine, see #86).
skip_if_not_installed("car")
skip_if_not_installed("lme4")
set.seed(104)
n <- 200
df <- data.frame(
y = rnorm(n), x1 = rnorm(n), x2 = rnorm(n),
group = factor(rep(1:20, each = 10))
)
df$x2 <- 0.6 * df$x1 + rnorm(n, sd = 0.5) # correlated with x1
fit <- lme4::lmer(y ~ x1 + x2 + (1 | group), data = df)
reference <- car::vif(fit)
ours <- corrselect:::.compute_vif(fit, "lme4", c("x1", "x2"))
expect_equal(unname(ours[c("x1", "x2")]), unname(reference[c("x1", "x2")]),
tolerance = 1e-6)
})
test_that("modelPrune .compute_vif() returns NA (not 0) when VIF computation errors (#29)", {
# Regression test for issue #29: the tryCatch()'s error handler used to
# assign `vif_values[i] <- NA` inside its own local frame, so the write
# never reached the enclosing vif_values vector and the predictor silently
# kept its numeric(length(fixed_effects)) default of 0 -- read as "no
# collinearity at all" -- instead of becoming NA.
set.seed(9200)
n <- 20
df <- data.frame(y = rnorm(n), x1 = rnorm(n), x2 = rnorm(n), x3 = rnorm(n))
fit <- lm(y ~ x1 + x2 + x3, data = df)
# Corrupt the fitted model's stored data (not the original df) so that
# regressing any one predictor on the others hits Inf and lm() throws,
# without preventing the original fit above from succeeding.
fit$model$x2[1] <- Inf
ours <- suppressWarnings(corrselect:::.compute_vif(fit, "lm", c("x1", "x2", "x3")))
expect_true(all(is.na(ours)))
expect_warning(
corrselect:::.compute_vif(fit, "lm", c("x1", "x2", "x3")),
"VIF computation failed"
)
})
test_that("modelPrune condition_number matches a manually computed SVD reference", {
set.seed(7)
n <- 60
df <- data.frame(y = rnorm(n), x1 = rnorm(n), x2 = rnorm(n), x3 = rnorm(n))
df$x2 <- 0.7 * df$x1 + rnorm(n, sd = 0.3)
fit <- lm(y ~ x1 + x2 + x3, data = df)
ours <- corrselect:::.compute_condition_indices(fit, "lm", c("x1", "x2", "x3"))
# Independent reference: build and scale the design matrix, run svd()
# directly (not via any corrselect helper), and derive condition indices
# with the same max(d)/d_i formula the function itself documents.
X <- model.matrix(fit)
X <- X[, colnames(X) != "(Intercept)", drop = FALSE]
sv <- svd(scale(X, center = TRUE, scale = TRUE))$d
reference <- setNames(max(sv) / sv, colnames(X))
expect_equal(unname(ours[c("x1", "x2", "x3")]), unname(reference[c("x1", "x2", "x3")]),
tolerance = 1e-6)
})
test_that("modelPrune tie-breaking removes the last-in-formula-order variable (exact value)", {
# a and b are built to be (near-)identical predictors of y, so their VIFs
# tie; the documented tie-break removes the one that appears last in the
# formula.
set.seed(6)
n <- 40
common <- rnorm(n)
df <- data.frame(y = rnorm(n), a = common + rnorm(n, sd = 0.01), b = common + rnorm(n, sd = 0.01))
result <- modelPrune(y ~ a + b, data = df, limit = 5)
expect_equal(attr(result, "selected_vars"), "a")
expect_equal(attr(result, "removed_vars"), "b")
})
test_that("modelPrune force_in infeasibility is detected under condition_number", {
set.seed(8)
n <- 40
x1 <- rnorm(n)
df <- data.frame(y = rnorm(n), x1 = x1, x2 = x1 + rnorm(n, sd = 0.001)) # near-collinear
expect_error(
modelPrune(y ~ x1 + x2, data = df, criterion = "condition_number", limit = 5,
force_in = c("x1", "x2")),
"violate the criterion threshold"
)
})
test_that("modelPrune recovers the single non-collinear predictor across seeds", {
# x1 and x2 are near-duplicates (should not both survive); x3 is
# independent and should always be retained.
n_trials <- 20
recovered <- 0
for (seed in seq_len(n_trials)) {
set.seed(2000 + seed)
n <- 50
x1 <- rnorm(n)
df <- data.frame(
y = rnorm(n),
x1 = x1,
x2 = x1 + rnorm(n, sd = 0.02),
x3 = rnorm(n)
)
result <- modelPrune(y ~ x1 + x2 + x3, data = df, limit = 5)
sel <- attr(result, "selected_vars")
ok <- sum(c("x1", "x2") %in% sel) == 1 && "x3" %in% sel
if (ok) recovered <- recovered + 1
}
expect_equal(recovered, n_trials)
})
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.