Skip to content

Commit cb2a632

Browse files
Make Cook's distance lines in the influential observations plot match check_outliers() (#964)
Co-authored-by: Daniel <mail@danielluedecke.de>
1 parent 428d6bb commit cb2a632

4 files changed

Lines changed: 67 additions & 6 deletions

File tree

‎NEWS.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,17 @@
7272
Before, these functions returned inaccurate values or failed with an unclear
7373
error (#165).
7474

75+
* The influential observations plot of `check_model()` and `check_outliers()`
76+
for linear mixed models from package *lme4* now shows standardized residuals
77+
instead of raw residuals. Before this fix, points fell outside the Cook's
78+
distance lines but were not flagged as outliers (#857).
79+
80+
* The Cook's distance lines in the influential observations plot of
81+
`check_model()` and `check_outliers()` now use the threshold of
82+
`check_outliers()`, including a user-supplied `threshold`. Before this fix,
83+
linear models always used a threshold from the model's F statistic, so the
84+
lines did not match the flagged outliers (#916).
85+
7586
# performance 0.18.2
7687

7788
## Bug fixes

‎R/check_model_diagnostics.R‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -204,20 +204,35 @@
204204
.model_diagnostic_outlier <- function(model, threshold = NULL) {
205205
s <- summary(model)
206206

207-
if (inherits(model, "lm", which = TRUE) == 1) {
207+
# use the threshold from `check_outliers()`, so that the contour lines
208+
# match the flagged outliers
209+
if (!is.null(threshold)) {
210+
cook_levels <- threshold
211+
} else if (inherits(model, "lm", which = TRUE) == 1) {
208212
cook_levels <- round(stats::qf(0.5, s$fstatistic[2], s$fstatistic[3]), 2)
209-
} else if (is.null(threshold)) {
210-
cook_levels <- c(0.5, 1)
211213
} else {
212-
cook_levels <- threshold
214+
cook_levels <- c(0.5, 1)
213215
}
214216

215217
n_params <- tryCatch(model$rank, error = function(e) insight::n_parameters(model))
216218

217219
infl <- stats::influence(model, do.coef = FALSE)
218220
model_resid <- as.numeric(insight::get_residuals(model))
219221

220-
std_resid <- tryCatch(stats::rstandard(model, infl), error = function(e) model_resid)
222+
std_resid <- tryCatch(
223+
stats::rstandard(model, infl),
224+
error = function(e) {
225+
# no rstandard() method (e.g., lmerMod): standardize Pearson residuals,
226+
# so that the Cook's distance contours match `cooks.distance()`
227+
.safe(
228+
as.numeric(
229+
stats::residuals(model, type = "pearson") /
230+
(stats::sigma(model) * sqrt(1 - infl$hat))
231+
),
232+
model_resid
233+
)
234+
}
235+
)
221236

222237
plot_data <- data.frame(
223238
Hat = infl$hat,

‎R/check_outliers.R‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,7 @@ check_outliers.default <- function(
638638
attr(outlier, "text_size") <- 3
639639
attr(outlier, "influential_obs") <- .safe(.model_diagnostic_outlier(
640640
x,
641-
threshold = unlist(thresholds)
641+
threshold = thresholds$cook
642642
)) # nolint
643643
attr(outlier, "variables") <- "(Whole model)"
644644
attr(outlier, "raw_data") <- my_data

‎tests/testthat/test-check_outliers.R‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,41 @@ test_that("check_outliers numeric, z-score", {
479479
})
480480

481481

482+
test_that("influential observations plot data uses standardized residuals for lmer models", {
483+
skip_if_not_installed("lme4")
484+
485+
# the plot draws Cook's distance contours from leverage, standardized
486+
# residuals and the number of parameters, so these must reproduce the
487+
# Cook's distance that check_outliers() tests (#857)
488+
m <- lme4::lmer(Reaction ~ Days + (Days | Subject), data = lme4::sleepstudy)
489+
out <- check_outliers(m)
490+
d <- attributes(out)$influential_obs
491+
p <- attributes(d)$n_params
492+
implied_cook <- d$Std_Residuals^2 * d$Hat / (p * (1 - d$Hat))
493+
expect_equal(implied_cook, d$Cooks_Distance, tolerance = 1e-6, ignore_attr = TRUE)
494+
expect_identical(
495+
which(implied_cook > attributes(out)$threshold$cook),
496+
which(as.numeric(out) == 1)
497+
)
498+
})
499+
500+
501+
test_that("influential observations plot data uses the Cook's distance threshold for lm models", {
502+
# the contour lines must follow the threshold that check_outliers() uses,
503+
# including a user-supplied threshold (#916)
504+
m <- lm(disp ~ mpg + hp, data = mtcars)
505+
for (th in list(NULL, list(cook = 2))) {
506+
out <- check_outliers(m, method = "cook", threshold = th)
507+
d <- attributes(out)$influential_obs
508+
expect_equal(attributes(d)$cook_levels, attributes(out)$threshold$cook)
509+
expect_identical(
510+
which(d$Influential == "Influential"),
511+
which(as.numeric(out) == 1)
512+
)
513+
}
514+
})
515+
516+
482517
test_that("check_outliers with psych", {
483518
skip_if_not_installed("psych")
484519
data(Harman.5, package = "psych")

0 commit comments

Comments
 (0)