--- title: "Diagnostics" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Diagnostics} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 5 ) # car, lmtest and moments back individual assumption tests; gridExtra # arranges the dashboard. All are in Suggests. has_assumption_pkgs <- requireNamespace("car", quietly = TRUE) && requireNamespace("lmtest", quietly = TRUE) has_gridextra <- requireNamespace("gridExtra", quietly = TRUE) ``` ```{r setup} library(tidylearn) library(dplyr) ``` ## Overview A model that fits is not the same as a model you should use. These functions answer four questions: - **Do the assumptions hold?** `tl_check_assumptions()` - **Which observations drove the fit?** `tl_influence_measures()`, `tl_detect_outliers()` - **Is the difference between two models real?** `tl_compare_cv()`, `tl_test_model_difference()` - **Are there interactions I have not modelled?** `tl_test_interactions()`, `tl_interaction_effects()` `tl_explore()` runs an unsupervised sweep over a dataset before you model it at all. We use a linear model throughout, because that is where assumption checking has teeth. ```{r} model <- tl_model(mtcars, mpg ~ wt + hp + disp, method = "linear") ``` ## Checking Assumptions `tl_check_assumptions()` runs six checks and returns a verdict on each. ```{r, eval = has_assumption_pkgs} assumptions <- tl_check_assumptions(model, verbose = FALSE) names(assumptions) ``` ```{r, eval = has_assumption_pkgs} assumptions$overall ``` Each entry carries the test that was run, the verdict, and what to do about it. Nothing here is a pass/fail gate — the recommendation is a prompt, not an instruction. ```{r, eval = has_assumption_pkgs} assumptions$normality ``` ```{r, eval = has_assumption_pkgs} assumptions$multicollinearity ``` A compact table of every check: ```{r, eval = has_assumption_pkgs} checks <- c("linearity", "independence", "homoscedasticity", "normality", "multicollinearity", "outliers") data.frame( assumption = vapply(checks, function(x) assumptions[[x]]$assumption, character(1)), holds = vapply(checks, function(x) isTRUE(assumptions[[x]]$check), logical(1)), detail = vapply(checks, function(x) assumptions[[x]]$details, character(1)), row.names = NULL ) ``` `disp` correlating with both `wt` and `hp` is what drives the VIF here, and it is the kind of thing that is invisible in a coefficient table. ### The dashboard `tl_diagnostic_dashboard()` draws the standard panels in one grid. ```{r, eval = has_assumption_pkgs && has_gridextra, fig.height = 7} tl_diagnostic_dashboard(model) ``` Switch off any section you do not want with `include_influence`, `include_assumptions` or `include_performance`. ## Influence `tl_influence_measures()` returns one row per observation with Cook's distance, leverage, DFFITS, standardised and studentised residuals, DFBETAS per coefficient, and a flag for each. ```{r} influence <- tl_influence_measures(model) dim(influence) ``` ```{r} influence %>% filter(is_influential) %>% select(observation, cooks_distance, leverage, dffits, std_residual) ``` The flags use conventional cutoffs, which you can override with `threshold_cook`, `threshold_leverage` and `threshold_dffits`. The DFBETAS columns say *which coefficient* an observation moved, which is usually the more useful question: ```{r} influence %>% select(observation, starts_with("dfbetas_")) %>% arrange(desc(abs(dfbetas_wt))) %>% head(4) ``` ### Refit without the influential rows The point of the exercise is to see whether the conclusion survives. ```{r} keep <- !influence$is_influential refit <- tl_model(mtcars[keep, ], mpg ~ wt + hp + disp, method = "linear") data.frame( term = names(coef(model$fit)), all_rows = round(unname(coef(model$fit)), 4), without_influential = round(unname(coef(refit$fit)), 4) ) ``` ```{r} sum(!keep) ``` If dropping a handful of rows moves a coefficient materially, that coefficient describes those rows rather than the population you sampled. ## Outliers in the Data `tl_influence_measures()` is about a fitted model. `tl_detect_outliers()` works on the data itself, before or independently of any fit. ```{r} outliers <- tl_detect_outliers( mtcars, variables = c("mpg", "hp", "wt"), method = "iqr", plot = FALSE ) outliers$outlier_counts$total outliers$outlier_counts$by_variable ``` ```{r} mtcars[outliers$outlier_indices, c("mpg", "hp", "wt")] ``` `method` also takes `"zscore"` and `"mahalanobis"`. The first two treat each variable separately; Mahalanobis distance accounts for the correlation between them, so it finds points that are unremarkable on every single axis and unusual in combination. ```{r} mahal <- tl_detect_outliers( mtcars, variables = c("mpg", "hp", "wt"), method = "mahalanobis", plot = FALSE ) mahal$outlier_indices ``` Set `plot = TRUE` to get a ggplot2 object back in `$plot`. ## Comparing Models A difference in a single held-out score is not evidence. `tl_compare_cv()` scores several fitted models over the same folds. ```{r} simple <- tl_model(mtcars, mpg ~ wt, method = "linear") full <- tl_model(mtcars, mpg ~ wt + hp + disp, method = "linear") tree <- tl_model(mtcars, mpg ~ wt + hp + disp, method = "tree") cv <- tl_compare_cv( mtcars, models = list(simple = simple, full = full, tree = tree), folds = 5, metrics = c("rmse", "rsq") ) names(cv) ``` ```{r} cv$summary ``` Per-fold scores are kept as well, which is what makes a test possible: ```{r} head(cv$fold_metrics) ``` ### Is the difference real? `tl_test_model_difference()` compares each model against a baseline using the per-fold scores. ```{r} tl_test_model_difference( cv, baseline_model = "simple", metric = "rmse", test = "t.test" ) ``` With five folds this has very little power, so treat a non-significant result as "these folds do not separate the models" rather than as evidence they are equivalent. `test = "wilcox.test"` drops the normality assumption, which matters more at small fold counts than the loss of power costs you. ## Interactions `tl_test_interactions()` fits each candidate interaction and reports whether it earns its degrees of freedom. ```{r} interactions <- tl_test_interactions( mtcars, mpg ~ wt + hp + disp, all_pairs = TRUE ) interactions ``` `delta_r2` is the more useful column: a p-value tells you the term is detectable, `delta_r2` tells you whether it is worth carrying. Restrict the search with `numeric_only`, `categorical_only` or `mixed_only`, or name a single pair with `var1` and `var2`. ### Reading an interaction Once a term is in the model, `tl_interaction_effects()` says what it does at different levels of the moderator. ```{r} model_int <- tl_model(mtcars, mpg ~ wt * hp, method = "linear") effects <- tl_interaction_effects(model_int, var = "wt", by_var = "hp") effects$slopes ``` The slope of `mpg` on `wt` weakens as `hp` rises — extra weight costs less fuel economy in a high-powered car, which already had little to lose. `slope_se` describes the straight line fitted to the prediction grid rather than the sampling uncertainty of the marginal effect — for a linear model the grid is exactly linear, so it is near zero by construction. Use `summary(model_int$fit)` for inference on the interaction coefficient. ```{r} summary(model_int$fit)$coefficients ``` `tl_auto_interactions()` does the search and the refit in one step, returning a model with the surviving interactions already in the formula: ```{r} auto <- tl_auto_interactions(mtcars, mpg ~ wt + hp + disp) auto$spec$formula ``` ## Exploring Before Modelling `tl_explore()` runs PCA, picks a cluster count, clusters, and computes a distance summary in one call. It is a first look at a dataset, not a diagnostic of a fit. ```{r} eda <- tl_explore(iris, response = "Species", max_components = 4, k_range = 2:5) names(eda) ``` ```{r} eda$optimal_k ``` ```{r} get_pca_variance(eda$pca) ``` ```{r, fig.height = 6} plot(eda) ``` ## A Checklist For a linear model, in order: 1. `tl_check_assumptions()` — six checks, with the reason each one failed. 2. `tl_influence_measures()` — refit without the flagged rows and see whether the coefficients hold. 3. `tl_test_interactions()` — the effect you assumed was additive may not be. 4. `tl_compare_cv()` then `tl_test_model_difference()` — before preferring one model over another. For tree-based and other non-parametric methods, steps 1 and 3 do not apply; step 2 is available through `tl_detect_outliers()` on the data, and step 4 works unchanged.