--- title: "Diagnostics, classification and uncertainty" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Diagnostics, classification and uncertainty} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} bibliography: ../inst/REFERENCES.bib csl: apa.csl link-citations: true --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = FALSE, comment = "", fig.width = 7, fig.height = 4.5, dpi = 96, dev.args = list(bg = "transparent")) # Console colour carries no meaning on a rendered page. pkgdown turns it on for # its own build, and the escape sequences then reach the reader as literal text, # so colour is switched off here for a plain vignette render and a site build # alike. The fixed width keeps printed output inside the documentation column. options(cli.num_colors = 1, cli.hyperlink = FALSE, crayon.enabled = FALSE, width = 80) # Figures on the package website sit on a warm off-white page in light mode and # are inverted by pkgdown in dark mode, so an opaque background would read as a # pale slab one way and a black plate the other. Two things paint one. The # device canvas is made transparent by `dev.args` above, and theme_depictr() # then inherits theme_minimal()'s white plot.background, which is drawn over # that canvas, so it is cleared as each figure is printed. This is deliberately # a vignette-level choice: theme_depictr() keeps its opaque background, which is # what a figure saved for a paper wants. transparent_bg <- ggplot2::theme( plot.background = ggplot2::element_rect(fill = NA, colour = NA), panel.background = ggplot2::element_rect(fill = NA, colour = NA) ) knit_print.ggplot <- function(x, ...) knitr::normal_print(x + transparent_bg) knit_print.patchwork <- function(x, ...) knitr::normal_print(x & transparent_bg) library(depictr) has_simr <- requireNamespace("simr", quietly = TRUE) ``` ## Regression diagnostics `residual_diagnostics_plot()` assembles the classic panel. `influence_plot()` and `qq_plot()` zoom in on particular checks. We fit a linear model to the crop-yield trial. ```{r} fit <- lm(yield ~ rainfall + fertiliser + soil_ph, data = crop_yield) ``` ```{r, fig.height = 6} residual_diagnostics_plot(fit, title = "Crop-yield model") ``` ```{r} influence_plot(fit) ``` The bubble area is Cook's distance, the standard measure of an observation's influence on the fitted coefficients [@cook1977]. `vif_plot()` checks for multicollinearity among the predictors. The crop-yield predictors are close to orthogonal, so to see the diagnostic do its job we add a soil-moisture measure that is largely driven by rainfall. ```{r, fig.height = 3.2} set.seed(1) collinear <- crop_yield collinear$soil_moisture <- 0.05 * collinear$rainfall + rnorm(nrow(collinear), sd = 2) vif_plot(lm(yield ~ rainfall + soil_moisture + fertiliser, data = collinear)) ``` Rainfall and soil moisture each carry a variance inflation factor near 6, above the rule-of-thumb line at 5, while fertiliser stays at 1. ## Diagnostics for a generalised linear model For a binary `glm` the raw residuals take only a few values, so a plain residual-versus-fitted plot is hard to read. `binned_residual_plot()` instead splits the data into equal-count bins of fitted values and plots the mean residual per bin against a +/- 2 standard-error band [@gelman2007]. Most points should sit inside the band, scattered around zero. We model the *rare* adverse-event outcome from the clinical trial (about a 10% base rate) on the baseline biomarker, age and treatment arm. ```{r} gfit <- glm(adverse_event ~ biomarker + age + arm, data = clinical_trial, family = binomial) ``` ```{r} binned_residual_plot(gfit, title = "Binned residuals: adverse-event model") ``` `residual_diagnostics_plot()` recognises a `glm` and switches to GLM-appropriate panels (binned residuals and a Q-Q plot of randomised quantile residuals, which are standard-normal under a correct model regardless of the response distribution): ```{r, fig.height = 6} residual_diagnostics_plot(gfit, title = "Adverse-event model") ``` ## Classification on an imbalanced outcome The adverse-event outcome is imbalanced, the case the precision-recall, gains and lift charts are built for. depictr's classification plots each read a binomial `glm` directly, a pair of vectors or, to compare several models, a named list of models. The ROC curve reports the AUC. Passing `youden = TRUE` marks the Youden's J operating point (the threshold maximising sensitivity + specificity - 1): ```{r, fig.width = 5, fig.height = 5} roc_curve_plot(gfit, youden = TRUE) ``` When the positive class is rare the precision-recall curve is more informative than the ROC curve, because it ignores the many true negatives. The baseline is the positive prevalence, and `f1 = TRUE` marks the maximum-F1 operating point: ```{r, fig.width = 5, fig.height = 5} pr_curve_plot(gfit, f1 = TRUE) ``` ### Comparing two models in one figure A *named list* overlays one colour-coded curve per model, with a per-curve AUC (or average precision) in the legend. Here the full model is compared with a biomarker-only model. ```{r} reduced <- glm(adverse_event ~ biomarker, data = clinical_trial, family = binomial) models <- list(Full = gfit, `Biomarker only` = reduced) ``` Because an ROC curve hugs the top-left, the bottom-right corner is always free, so `legend_inside = TRUE` tucks the per-model legend there and reclaims the right-hand margin: ```{r, fig.width = 5, fig.height = 5} roc_curve_plot(models, youden = TRUE, legend_inside = TRUE) ``` ```{r, fig.width = 5, fig.height = 5} pr_curve_plot(models, f1 = TRUE) ``` For ranking and targeting tasks, the cumulative gains and lift charts show how many positive cases are captured as more of the score-ordered population is targeted, again overlaying both models: ```{r, fig.width = 5, fig.height = 5} gain_plot(models, legend_inside = TRUE) ``` ```{r, fig.height = 3.6} lift_plot(models, legend_inside = TRUE) ``` ### Choosing an operating point `threshold_plot()` sweeps the decision threshold across the full range of scores and plots each metric as it trades off, marking the Youden and maximum-F1 optimal thresholds with dashed lines. It is the natural companion to the ROC and PR curves for actually *choosing* a cut-off. ```{r} tp <- threshold_plot(gfit, title = "Metrics across the decision threshold") tp attr(tp, "thresholds") # the Youden and max-F1 thresholds ``` ### Calibration `calibration_plot()` checks whether predicted probabilities match observed frequencies. Each bin's observed rate carries a Wilson binomial confidence interval, so bins backed by few observations (common in the sparse upper tail when the outcome is rare) are not over-interpreted. ```{r, fig.width = 5, fig.height = 5} calibration_plot(gfit, bins = 6) ``` A confusion-matrix heatmap completes the picture. Passing `threshold = "youden"` reuses the same operating point the ROC curve marks, so the two agree: ```{r, fig.width = 5, fig.height = 4.5} confusion_matrix_plot(gfit, threshold = "youden", normalise = "row") ``` ## Uncertainty `posterior_plot()` summarises draws (posterior, bootstrap, simulation) as a distribution per parameter. Here are the posterior draws from a Bayesian fit of the lexical-decision model, shown as point-and-interval forests. ```{r} draws <- readRDS( system.file("extdata", "lexdec_draws.rds", package = "depictr") ) posterior_plot(draws[c("conditionunrelated", "modalityauditory", "word_frequency")], labels = c(conditionunrelated = "condition", modalityauditory = "modality", word_frequency = "word frequency"), style = "interval", title = "Posterior estimates (ms)") ``` ## Power curves `power_curve_plot()` reads a `simr::powerCurve()` object or a tidy data frame, so a slow power simulation does not have to be re-run to redraw it. The package ships a `powerCurve` object produced by a `simr` analysis of the lexical-decision design, which simulated power for the word-frequency slope as the number of participants grows. We read it straight from disk. ```{r, eval = has_simr} pc <- readRDS( system.file("extdata", "powercurve_lexdec.rds", package = "depictr") ) power_curve_plot(pc, x_lab = "Number of participants", title = "Power for the word-frequency effect") ``` ```{r, eval = !has_simr, echo = !has_simr} # Summarising a powerCurve object needs the 'simr' package. Without it we read # the same five points from the summary stored alongside the object, as a tidy # data frame, which power_curve_plot() also accepts. pc_df <- readRDS(system.file("extdata", "powercurve_lexdec_summary.rds", package = "depictr")) ``` ```{r, eval = !has_simr, echo = !has_simr, fig.height = 4.5} power_curve_plot(pc_df, x_lab = "Number of participants", title = "Power for the word-frequency effect") ``` The analysis ran 100 simulations at each of 12, 24, 36, 48 and 60 participants. Power for the word-frequency slope is 88% with 12 participants and 99% with 24, and it stays there for the larger samples. The confidence band is widest at the smallest sample size, where its lower limit falls on the 80% target line, so 24 participants is the first design the simulation clears comfortably. ## Composing and saving Combine any of these with `arrange_plots()` and save with `save_plot()`: ```{r, fig.height = 3.2} panel <- arrange_plots( qq_plot(fit), influence_plot(fit), ncol = 2, title = "Diagnostics", tag_levels = "A" ) panel ``` `save_plot()` writes it out at a print-ready 300 dpi by default, creating any missing directories along the way. ```{r, eval = FALSE} save_plot("figures/diagnostics.png", panel, width = 7, height = 3.2, dpi = 300) ``` ## References