--- title: "Approximate maximum influence perturbations" author: "Patrick Brown" date: "`r Sys.Date()`" output: rmarkdown::html_vignette: toc: true vignette: > %\VignetteIndexEntry{Approximate maximum influence perturbations} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ## Introduction This vignette applies the approximate maximum influence perturbation (AMIP) of Broderick, Giordano, and Meager in two settings. After fitting, outer parameters (`beta`, `theta`) are held at the MLE and only the inner random effects `gamma` are allowed to move. Package helpers `phi_gamma()`, `influence_scores()`, `amip_set()`, and `ad_pack_drop()` cover the shared pipeline: the fit-time observation tapes supply the inner Hessian \(H\); per-unit scores use streaming one-unit observation gradients (`grad_obs_units`) so scores do not require holding an identity shard pack. The first example is the London case-crossover model: the most influential **strata** for flipping the PM10 effect at exposure 80. The second is the MASS `bacteria` Bernoulli GLMM: the most influential **swabs** for flipping the sign of the drug-versus-placebo treatment effect. In both cases an inner optimization with those units removed confirms the linear approximation, and a full refit on the reduced data is compared to the original fit. ```{r setup, include=FALSE} library(adlaplace) library(Matrix) if (file.exists("vignette_full.R")) { source("vignette_full.R", local = TRUE) } else { vignette_full <- TRUE vignette_num_threads <- 2L } fit_num_shards <- if (vignette_full) 200L else 8L fit_maxit <- if (vignette_full) 200L else 15L have_mass <- requireNamespace("MASS", quietly = TRUE) knitr::opts_chunk$set(collapse = TRUE, comment = "#>") ``` ## Case-crossover: PM10 at 80 Same London daily mortality / PM10 specification as the case-crossover vignette. Abbreviated knits use 2003 only. ```{r data} data("london", package = "adlaplace") if (!vignette_full) { london <- london[london$year == 2003, ] } cc_formula <- dirichlet_multinom( all, by = c("year", "month", "dow"), init = 0.02 ) ~ 0 + rh + iwp( pm10, p = 2, knots = seq(0, 100, by = 10), ref_value = 40, init = 0.001 ) + iwp( tmean, p = 3, knots = seq(-2, 30, by = 2), init = 0.001, ref_value = 20 ) ``` ## Fit (outer MLE) Outer Hessian is not needed for this inner-only AMIP calculation. ```{r fit} fit <- adlaplace( formula = cc_formula, data = london, config = list( num_threads = vignette_num_threads, num_shards = fit_num_shards ), control = list( maxit = fit_maxit, trace = 3, REPORT = 5 ), verbose = FALSE, hessian = FALSE ) md <- fit$model_data n_strata <- n_obs_units(fit) fit$par_info[, c("label", "mle", "mle_internal")] n_strata ``` ## Quantity of interest and influence scores The PM10 smooth is centered at `ref_value = 40`, so the effect at 80 is a nontrivial linear functional \(\phi(\gamma) = a^\top\gamma\) of the IWP and random-polynomial coefficients. `influence_scores()` freezes the outer MLE, re-optimizes \(\gamma\) on the fit pack (for \(H\)), and streams one-unit observation gradients to form \(\psi_s = -v^\top g_s\). ```{r influence} a <- phi_gamma(fit, term = "pm10", new_x = data.frame(pm10 = 80)) inf <- influence_scores(fit, a, batch_size = 32L) gamma_hat <- inf$gamma_hat phi_hat <- inf$phi psi <- inf$psi gamma_lab <- names(fit$gamma) pm_terms <- Filter( function(tt) { identical(tt@name, "pm10") && identical(as.character(tt@model_role), "random") }, md$terms ) c(phi_hat = phi_hat, n_strata = n_strata) summary(psi) ``` ## Approximate most influential set Leaving out stratum \(s\) changes \(\phi\) by approximately \(-\psi_s\). `amip_set()` drops the largest same-sign scores until the linear approximation flips \(\hat\phi\). If that is impossible, it reports an empty removal set. ```{r amip-set} S <- amip_set(psi, phi_hat) drop_idx <- S$idx amip_alpha <- S$alpha amip_change <- S$approx_change c( phi_hat = phi_hat, n_drop = length(drop_idx), alpha = amip_alpha, approx_change = amip_change ) dropped <- data.frame( idx = drop_idx, strata = colnames(md$term_data$elgm_matrix)[drop_idx], psi = psi[drop_idx] ) dropped ``` ```{r psi-plot, fig.width=6, fig.height=3.5} oldpar <- par(no.readonly = TRUE) par(mar = c(2.5, 2.5, 1, 0.5), bty = "l", mgp = c(1.5, 0.5, 0)) parts <- strsplit(colnames(md$term_data$elgm_matrix), ".", fixed = TRUE) stratum_info <- data.frame( year = as.integer(vapply(parts, `[[`, character(1L), 1L)), month = vapply(parts, `[[`, character(1L), 2L), dow = vapply(parts, `[[`, character(1L), 3L), stringsAsFactors = FALSE ) stratum_info$month <- factor(stratum_info$month, levels = levels(london$month)) stratum_info$dow <- factor(stratum_info$dow, levels = levels(london$dow)) stratum_info$name <- paste(stratum_info$month, stratum_info$dow, sep = "\n") stratum_info$num <- stratum_info$year + (as.integer(stratum_info$month) - 1) / 12 + 0.1 * (as.integer(stratum_info$dow) - 1) / 7 Scol <- grey(seq(0.2, 0.8, length.out = 12)) stratum_info$pch <- as.character(as.integer(stratum_info$dow)) stratum_info$col <- Scol[as.integer(stratum_info$month)] stratum_info$pch[drop_idx] <- "o" stratum_info$col[drop_idx] <- "firebrick" plot( stratum_info$num, psi, pch = stratum_info$pch, cex = 0.55, col = stratum_info$col, xlab = "stratum", ylab = expression(psi[s]) ) text( stratum_info$num[drop_idx], psi[drop_idx], stratum_info$name[drop_idx], cex = 0.5 ) legend("bottomright", legend = levels(stratum_info$month), fill = Scol, bty = "n", inset = 0) abline(h = 0, lty = 2, col = "grey50") par(oldpar) ``` ## Confirm by dropping nominated strata `ad_pack_drop()` retapes the likelihood without the Approximate Most Influential Set (filtering the fit's coarse observation shards). Keep the outer MLE fixed and re-optimize `gamma`. The resulting change in \(\phi(80)\) is an exact finite-sample lower bound on the worst-case strata-removal effect (Broderick et al.). ```{r confirm, fig.width=6.5, fig.height=3.5} phi_from_gamma <- function(gamma) { sum(a * as.numeric(gamma)) } pm_curve <- function(gamma, grid = seq(0, 100, by = 2)) { new_grid <- data.frame(pm10 = grid) design_list <- lapply(pm_terms, design, data = new_grid) design_list <- Filter(Negate(is.null), design_list) D <- as.matrix(do.call(cbind, design_list)) g <- as.numeric(gamma) names(g) <- gamma_lab list(x = grid, f = as.numeric(D %*% g[colnames(D)])) } curve0 <- pm_curve(gamma_hat) if (!length(drop_idx)) { phi_drop <- NA_real_ curve1 <- NULL message("No strata dropped; skipping confirmation retape.") } else { af_drop <- ad_pack_drop(fit, drop_idx, num_threads = vignette_num_threads) io_drop <- inner_opt( parameters = as.numeric(fit$par_info$mle_internal), gamma = gamma_hat, ad_pack = af_drop, control = list(report.level = 0, report.freq = 0), deriv = FALSE, return_hessians = FALSE, verbose = FALSE ) gamma_drop <- as.numeric(io_drop$inner_opt$solution) phi_drop <- phi_from_gamma(gamma_drop) curve1 <- pm_curve(gamma_drop) } c( phi_full = phi_hat, phi_dropped = phi_drop, actual_change = if (is.finite(phi_drop)) phi_drop - phi_hat else NA_real_, approx_change = if (length(drop_idx)) amip_change else NA_real_ ) ``` ## Refit without AMIP strata Drop the nominated strata from the data and re-estimate the full outer MLE (smoothness and overdispersion included). ```{r refit-drop} elgm <- md$term_data$elgm_matrix if (!length(drop_idx)) { fit_drop <- NULL phi_refit <- NA_real_ message("No strata dropped; skipping refit.") } else { in_drop <- Matrix::rowSums(elgm[, drop_idx, drop = FALSE]) > 0 london_drop <- md$term_data$data[!in_drop, , drop = FALSE] c( n_full = nrow(md$term_data$data), n_drop = nrow(london_drop), n_strata_drop = length(drop_idx) ) fit_drop <- adlaplace( formula = cc_formula, data = london_drop, config = list( num_threads = vignette_num_threads, num_shards = fit_num_shards ), control = list( maxit = fit_maxit, trace = 3, REPORT = 5 ), verbose = FALSE, hessian = FALSE ) phi_refit <- sum(a * fit_drop$gamma[names(a)]) fit_drop$par_info[, c("label", "mle", "mle_internal")] } c( phi_full = phi_hat, phi_inner_drop = phi_drop, phi_refit = phi_refit ) ``` ```{r plot_results, fig.width=6.5, fig.height=3.5} num_sim <- 200L num_grid <- 101L sim_full <- sim_random( "pm10", fit = fit, num_grid = num_grid, num_sim = num_sim, transform = exp ) q_full <- if (!is.null(sim_full$global)) sim_full$global else sim_full$pointwise sim_drop <- NULL q_drop <- NULL if (!is.null(fit_drop)) { sim_drop <- sim_random( "pm10", fit = fit_drop, new_x = data.frame(pm10 = sim_full$x), num_sim = num_sim, transform = exp ) q_drop <- if (!is.null(sim_drop$global)) sim_drop$global else sim_drop$pointwise } oldpar <- par(no.readonly = TRUE) par(mar = c(3, 3, 0, 0), mgp = c(1.5, 0.5, 0), bty = "l") matplot( sim_drop$x, q_drop, type = "l", lty = c(3, 1, 3), col = "darkblue", lwd = 2, xlab = "pm10", ylab = "RR", xaxs = "i", ylim = c(0.975, 1.065), yaxs = "i", bty = "l" ) matlines(sim_full$x, q_full, lty = c(3, 1, 3), col = "red", lwd = 2) abline(h = 1, lty = 3, col = "grey50") legend( "topleft", legend = c("full data", "AMIP strata removed"), col = c("red", "darkblue"), lwd = 2, bty = "n" ) par(oldpar) ``` ## Bacteria: sign-flip of the drug effect The MASS `bacteria` data are 220 swabs on 50 children, with a three-level treatment (`placebo`, `drug`, `drug+`) and follow-up week. Treatment coefficients are placed in the inner field with known-SD Gaussian priors, so the drug-versus-placebo contrast is a coordinate of \(\gamma\) and the same inner AMIP applies. There is no ELGM map, so `grad_obs_units` scores individual swabs. (Summing scores to children is not enough to flip this contrast under the linear approximation.) ```{r bac-data, eval = have_mass} data(bacteria, package = "MASS") bacteria$present <- as.integer(bacteria$y == "y") bac_formula <- binom(present) ~ intercept(sd = 10) + linear(trt, sd = 10) + linear(week, sd = 10) + iid(ID, init = 1) + prior(theta = 0, dist = "exp", median = 1) bac_maxit <- if (vignette_full) 200L else 80L bac_shards <- if (vignette_full) 20L else 8L ``` ```{r bac-fit, eval = have_mass} fit_bac <- adlaplace( formula = bac_formula, data = bacteria, config = list( num_threads = vignette_num_threads, num_shards = bac_shards ), control = list( maxit = bac_maxit, trace = 3, REPORT = 5 ), verbose = FALSE, hessian = FALSE ) md_bac <- fit_bac$model_data n_obs_bac <- n_obs_units(fit_bac) gamma_lab_bac <- md_bac$term_data$info$gamma$gamma_label trt_labs <- grep("^trt_linear_", gamma_lab_bac, value = TRUE) drug_lab <- trt_labs[endsWith(trt_labs, "drug")] if (!length(drug_lab)) { drug_lab <- trt_labs[[1L]] } fit_bac$par_info[, c("label", "mle", "mle_internal")] ``` Swab-level scores via the same helpers as the case-crossover example. ```{r bac-influence, eval = have_mass} a_bac <- phi_gamma(fit_bac, coef = drug_lab) inf_bac <- influence_scores(fit_bac, a_bac, batch_size = 32L) gamma_hat_bac <- inf_bac$gamma_hat phi_hat_bac <- inf_bac$phi psi_bac <- inf_bac$psi c(target = drug_lab, phi_hat = phi_hat_bac, n_swabs = n_obs_bac) summary(psi_bac) ``` ```{r bac-amip, eval = have_mass} S_bac <- amip_set(psi_bac, phi_hat_bac) drop_idx_bac <- S_bac$idx amip_alpha_bac <- S_bac$alpha amip_change_bac <- S_bac$approx_change c( phi_hat = phi_hat_bac, n_drop = length(drop_idx_bac), alpha = amip_alpha_bac, approx_change = amip_change_bac ) dropped_bac <- md_bac$term_data$data[drop_idx_bac, c("ID", "week", "trt"), drop = FALSE] dropped_bac$psi <- psi_bac[drop_idx_bac] table(dropped_bac$trt) xtabs(~ week + trt, dropped_bac) ``` ```{r bac-psi-plot, eval = have_mass, fig.width=6, fig.height=3.5} oldpar <- par(no.readonly = TRUE) par(mar = c(2.5, 2.5, 1, 0.5), bty = "l", mgp = c(1.5, 0.5, 0)) dat_bac <- md_bac$term_data$data trt_cols <- c(placebo = "#4C78A8", drug = "#F58518", "drug+" = "#54A24B") pch_bac <- rep(19, n_obs_bac) col_bac <- unname(trt_cols[as.character(dat_bac$trt)]) pch_bac[drop_idx_bac] <- 1 col_bac[drop_idx_bac] <- "firebrick" plot( seq_len(n_obs_bac), psi_bac, pch = pch_bac, cex = 0.55, col = col_bac, xlab = "swab", ylab = expression(psi[i]) ) legend( "bottomright", legend = names(trt_cols), col = trt_cols, pch = 19, bty = "n", inset = 0 ) abline(h = 0, lty = 2, col = "grey50") par(oldpar) ``` Inner confirmation drops the nominated swabs with `ad_pack_drop()`, keeps \(\theta\) at the original MLE, and re-optimizes \(\gamma\). ```{r bac-confirm, eval = have_mass} if (!length(drop_idx_bac)) { phi_drop_bac <- NA_real_ gamma_drop_bac <- NULL message("No swabs dropped; skipping confirmation retape.") } else { af_drop_bac <- ad_pack_drop( fit_bac, drop_idx_bac, num_threads = vignette_num_threads ) io_drop_bac <- inner_opt( parameters = as.numeric(fit_bac$par_info$mle_internal), gamma = gamma_hat_bac, ad_pack = af_drop_bac, control = list(report.level = 0, report.freq = 0), deriv = FALSE, return_hessians = FALSE, verbose = FALSE ) gamma_drop_bac <- as.numeric(io_drop_bac$inner_opt$solution) names(gamma_drop_bac) <- names(gamma_hat_bac) phi_drop_bac <- sum(a_bac * gamma_drop_bac) } c( phi_full = phi_hat_bac, phi_dropped = phi_drop_bac, actual_change = if (is.finite(phi_drop_bac)) phi_drop_bac - phi_hat_bac else NA_real_, approx_change = if (length(drop_idx_bac)) amip_change_bac else NA_real_ ) ``` ```{r bac-refit, eval = have_mass} if (!length(drop_idx_bac)) { fit_bac_drop <- NULL phi_refit_bac <- NA_real_ message("No swabs dropped; skipping refit.") } else { bacteria_drop <- md_bac$term_data$data[-drop_idx_bac, , drop = FALSE] c( n_full = nrow(md_bac$term_data$data), n_keep = nrow(bacteria_drop), n_swab_drop = length(drop_idx_bac) ) fit_bac_drop <- adlaplace( formula = bac_formula, data = bacteria_drop, config = list( num_threads = vignette_num_threads, num_shards = bac_shards ), control = list( maxit = bac_maxit, trace = 3, REPORT = 5 ), verbose = FALSE, hessian = FALSE ) phi_refit_bac <- unname(fit_bac_drop$gamma[[drug_lab]]) fit_bac_drop$par_info[, c("label", "mle", "mle_internal")] } c( phi_full = phi_hat_bac, phi_inner_drop = phi_drop_bac, phi_refit = phi_refit_bac ) ``` Treatment logit effects with 80% pointwise intervals from the inner Laplace approximation. The targeted drug-versus-placebo contrast is the first pair. ```{r bac-plot, eval = have_mass, fig.width=6, fig.height=3.5} num_sim_bac <- if (vignette_full) 200L else 80L sims_bac <- rmvnldl(n = num_sim_bac, fit = fit_bac) q_trt <- function(sims, labs, probs = c(0.1, 0.5, 0.9)) { apply(sims[, labs, drop = FALSE], 2L, stats::quantile, probs = probs) } q_full_bac <- q_trt(sims_bac, trt_labs) q_drop_bac <- NULL if (!is.null(fit_bac_drop)) { sims_bac_drop <- rmvnldl(n = num_sim_bac, fit = fit_bac_drop) q_drop_bac <- q_trt(sims_bac_drop, trt_labs) } pretty_trt <- sub("^trt_linear_trt", "", trt_labs) ylim_bac <- range(q_full_bac, 0, finite = TRUE) if (!is.null(q_drop_bac)) { ylim_bac <- range(ylim_bac, q_drop_bac, finite = TRUE) } oldpar <- par(no.readonly = TRUE) par(mar = c(3, 3, 0.5, 0.5), mgp = c(1.5, 0.5, 0), bty = "l") plot( NA, NA, xlim = c(0.5, length(trt_labs) + 0.5), ylim = ylim_bac, xaxt = "n", xlab = "", ylab = "treatment effect (logit)" ) axis(1, at = seq_along(trt_labs), labels = pretty_trt) abline(h = 0, lty = 3, col = "grey50") offset <- if (is.null(q_drop_bac)) 0 else 0.12 for (j in seq_along(trt_labs)) { x0 <- j - offset arrows(x0, q_full_bac[1L, j], x0, q_full_bac[3L, j], length = 0, col = "red", lwd = 2) points(x0, q_full_bac[2L, j], pch = 19, col = "red") if (!is.null(q_drop_bac)) { x1 <- j + offset arrows(x1, q_drop_bac[1L, j], x1, q_drop_bac[3L, j], length = 0, col = "darkblue", lwd = 2) points(x1, q_drop_bac[2L, j], pch = 19, col = "darkblue") } } legend( "topright", legend = c("full data", "AMIP swabs removed"), col = c("red", "darkblue"), pch = 19, lwd = 2, bty = "n" ) par(oldpar) ``` ## Notes - Case-crossover removal units are **strata** (year–month–day-of-week matched sets), not individual calendar days. Bacteria removal units are **swabs** (rows of the observation vector). Use `n_obs_units()` for the domain size. - The inner confirmation keeps outer parameters at the original MLE: that is sensitivity of \(\hat\gamma(\beta,\theta)\), not a refit of smoothness or overdispersion. The refit chunks re-estimate the full outer MLE on the reduced data. - Scores use streaming one-unit observation tapes (`grad_obs_units` inside `influence_scores`); \(H\) comes from the fit pack via `inner_opt`. Confirmation retapes a filtered likelihood with `ad_pack_drop()`. - The bacteria example needs **MASS**. Those chunks are skipped when it is not installed.