---
title: "Bayesian Bernoulli mixed model for the bacteria data"
author: "Patrick Brown"
date: "`r Sys.Date()`"
output:
  rmarkdown::html_vignette:
    toc: true
vignette: >
  %\VignetteIndexEntry{Bayesian Bernoulli mixed model for the bacteria data}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
library(adlaplace)
if (file.exists("vignette_full.R")) {
  source("vignette_full.R", local = TRUE)
} else {
  vignette_full <- TRUE
  vignette_num_threads <- 2L
}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  fig.width = 6,
  fig.height = 4
)
if (!requireNamespace("MASS", quietly = TRUE) ||
    !requireNamespace("aghq", quietly = TRUE)) {
  knitr::knit_exit()
}
aghq_k <- if (vignette_full) 5L else 3L
```

This vignette fits a Bayesian Bernoulli GLMM to the `bacteria` data from
**MASS**, then integrates the Laplace-approximate marginal posterior for the
random-effect standard deviation with **aghq**. The formula interface hides
the AD / Laplace details: known-SD Gaussian priors on the former fixed
effects, an exponential prior on the child-level SD, and one call to
`adlaplace()` followed by `aghq::aghq()`.

## Data

```{r data}
data(bacteria, package = "MASS")
bacteria$present <- as.integer(bacteria$y == "y")
```

There are 220 swabs on 50 children (`ID`), a three-level treatment, and
follow-up `week` at 0, 2, 4, 6, and 11.

```{r data-plot, fig.cap="Observed colonization rate by week and treatment."}
rate <- aggregate(present ~ week + trt, data = bacteria, FUN = mean)
cols <- c(placebo = "#4C78A8", drug = "#F58518", "drug+" = "#54A24B")

plot(
  present ~ week, data = rate, type = "n",
  ylim = c(0, 1), ylab = "proportion present"
)
for (g in levels(bacteria$trt)) {
  d <- rate[rate$trt == g, ]
  lines(d$week, d$present, type = "b", pch = 19, col = cols[[g]])
}
legend("bottomleft", legend = names(cols), col = cols, pch = 19, lty = 1, bty = "n")
```

## Model

The hierarchical model is

\[
\begin{aligned}
y_{ij} &\sim \mathrm{Bernoulli}(p_{ij}), \\
\mathrm{logit}(p_{ij}) &= \alpha + \mathbf{x}_{ij}^\top\boldsymbol{\beta} + u_i, \\
\alpha, \beta_k &\sim N(0, 10^2), \\
u_i &\sim N(0, \sigma^2), \\
\sigma &\sim \mathrm{Exp}(\text{median} = 1).
\end{aligned}
\]

Moving the intercept, treatment, and week coefficients into the random-effect
block with known SDs leaves a **one-dimensional** outer parameter
(\(\log\sigma\)). Nested Laplace then integrates the latent field; AGHQ
integrates \(\log\sigma\).

```{r fit}
fit <- adlaplace(
  binom(present) ~
    intercept(sd = 10) +
    linear(trt, sd = 10) +
    linear(week, sd = 10) +
    iid(ID, init = 1) +
    prior(theta = 0, dist = "exp", median = 1),
  data = bacteria
)
summary(fit)
```

`intercept(sd = 10)`, `linear(trt, sd = 10)`, and `linear(week, sd = 10)`
are known-SD Gaussian priors (no estimated \(\theta\)). `iid(ID)` supplies
the child intercepts and their SD. `prior(theta = 0, ...)` puts an exponential
prior on the first (0-based) theta — here the only one, `ID_iid`.

`adlaplace()` returns the MAP of \(\log\sigma\) under that prior. The outer
vector has length one:

```{r outer-dim}
coef(fit)
nrow(fit$par_info)
```

## Adaptive Gauss–Hermite quadrature

`aghq_ff(fit)` wraps the same Laplace objective used by `adlaplace()` for
**aghq**. Use `negate = TRUE` because the wrappers return the negated log
posterior.

```{r aghq}
ff <- aghq_ff(fit)
quad <- aghq::aghq(
  ff = ff,
  k = aghq_k,
  startingvalue = fit$par_info$mle_internal,
  control = aghq::default_control(negate = TRUE)
)
summary(quad)
mode_log_sigma <- as.numeric(quad$optresults$mode)
c(log_sigma = mode_log_sigma, sigma = exp(mode_log_sigma))
```

## Posterior of \(\sigma\)

```{r sigma-plot, fig.cap="Normalized AGHQ nodes for the child-level SD."}
naw <- quad$normalized_posterior$nodesandweights
nodes <- as.numeric(naw[[1L]])
w <- exp(as.numeric(naw$logpost_normalized))
w <- w / sum(w)
sigma <- exp(nodes)
ord <- order(sigma)

plot(
  sigma[ord], w[ord],
  type = "h", lwd = 3, col = "#4C78A8",
  xlab = expression(sigma), ylab = "normalized weight"
)
abline(v = exp(mode_log_sigma), lty = 2)
```

The nested approximation for a coefficient \(\gamma_j\) (intercept, treatment,
week) is a **mixture of normals**: at AGHQ node \(\theta_k = \log\sigma_k\)
with weight \(w_k\),

\[
\gamma_j \mid \theta_k, y \;\approx\; N\bigl(\hat\gamma_j(\theta_k),\;
[H(\theta_k)^{-1}]_{jj}\bigr),
\qquad
p(\gamma_j \mid y) \;\approx\; \sum_k w_k\, \varphi\bigl(\gamma_j;\,
\hat\gamma_j(\theta_k),\, [H(\theta_k)^{-1}]_{jj}\bigr).
\]

```{r mixture-normals}
gamma_info <- fit$model_data$term_data$info$gamma
coef_idx <- which(gamma_info$model != "iid")
coef_lab <- gamma_info$gamma_label[coef_idx]
ctrl_mix <- list(
  maxit = if (vignette_full) 100L else 20L,
  report.level = 0,
  report.freq = 0
)

mix_mean <- matrix(NA_real_, length(nodes), length(coef_idx))
mix_sd <- mix_mean
colnames(mix_mean) <- colnames(mix_sd) <- coef_lab
gamma_start <- fit$gamma

for (k in seq_along(nodes)) {
  ll <- log_lik_laplace(
    x = nodes[[k]],
    ad_pack = fit$ad_pack,
    config = fit$config,
    gamma = gamma_start,
    deriv = FALSE,
    control = ctrl_mix
  )
  gamma_start <- ll$inner_opt$solution
  names(gamma_start) <- names(fit$gamma)
  half <- laplace_half_H_inv(ll)
  mix_mean[k, ] <- gamma_start[coef_lab]
  mix_sd[k, ] <- sqrt(Matrix::rowSums(half[coef_idx, , drop = FALSE]^2))
}

mix_dnorm <- function(x, mean, sd) {
  dens <- vapply(
    seq_along(w),
    function(k) stats::dnorm(x, mean[[k]], sd[[k]]),
    numeric(length(x))
  )
  as.numeric(dens %*% w)
}

mix_qnorm <- function(p, mean, sd) {
  lo <- min(mean - 8 * sd)
  hi <- max(mean + 8 * sd)
  stats::uniroot(
    function(q) sum(w * stats::pnorm(q, mean, sd)) - p,
    lower = lo,
    upper = hi
  )$root
}

pretty_coef <- function(lab) {
  if (identical(lab, "intercept")) {
    return("intercept")
  }
  if (startsWith(lab, "trt_linear_")) {
    lev <- sub("^trt_linear_trt", "", lab)
    return(paste0("trt: ", lev, " vs placebo"))
  }
  if (startsWith(lab, "week_linear_")) {
    return("week")
  }
  lab
}
```

```{r intercept-trt-plot, fig.cap="Marginal posteriors of the intercept and treatment contrasts (mixture of Laplace Gaussians over AGHQ nodes)."}
int_lab <- coef_lab[gamma_info$model[coef_idx] == "intercept"]
trt_lab <- coef_lab[gamma_info$term[coef_idx] == "trt"]

oldpar <- par(no.readonly = TRUE)
par(mfrow = c(1, 2), mar = c(4, 4, 1.5, 0.5))

int_m <- mix_mean[, int_lab]
int_s <- mix_sd[, int_lab]
xr <- seq(min(int_m - 4 * int_s), max(int_m + 4 * int_s), length.out = 200)
plot(
  xr, mix_dnorm(xr, int_m, int_s),
  type = "l", lwd = 2, col = "#4C78A8",
  xlab = "intercept", ylab = "density"
)

trt_cols <- unname(cols[c("drug", "drug+")])
lo_trt <- mix_mean[, trt_lab, drop = FALSE] - 4 * mix_sd[, trt_lab, drop = FALSE]
hi_trt <- mix_mean[, trt_lab, drop = FALSE] + 4 * mix_sd[, trt_lab, drop = FALSE]
xg <- seq(min(lo_trt), max(hi_trt), length.out = 200)
dens_trt <- lapply(trt_lab, function(lab) {
  mix_dnorm(xg, mix_mean[, lab], mix_sd[, lab])
})
ymax <- max(unlist(dens_trt))
plot(
  xg, dens_trt[[1]],
  type = "l", lwd = 2, col = trt_cols[[1]],
  xlab = "treatment effect (logit)", ylab = "density",
  ylim = c(0, ymax)
)
if (length(trt_lab) > 1L) {
  lines(xg, dens_trt[[2]], lwd = 2, col = trt_cols[[2]])
}
legend(
  "topright",
  legend = vapply(trt_lab, pretty_coef, character(1)),
  col = trt_cols[seq_along(trt_lab)],
  lty = 1, lwd = 2, bty = "n"
)
par(oldpar)
```

Posterior quantiles invert the mixture CDF. The child-level SD uses the AGHQ
nodes (a discrete mixture on \(\log\sigma\)). Child-specific IID intercepts
are omitted.

```{r posterior-quantiles}
probs <- c(0.025, 0.5, 0.975)
q_coef <- t(vapply(
  coef_lab,
  function(lab) {
    vapply(probs, mix_qnorm, numeric(1), mean = mix_mean[, lab], sd = mix_sd[, lab])
  },
  numeric(length(probs))
))
sigma_q <- vapply(probs, function(p) {
  o <- order(sigma)
  cw <- cumsum(w[o])
  sigma[o][which(cw >= p)[1L]]
}, numeric(1))

post_q <- data.frame(
  parameter = c(vapply(coef_lab, pretty_coef, character(1)), "sigma (ID)"),
  `2.5%` = c(q_coef[, 1L], sigma_q[[1L]]),
  `50%` = c(q_coef[, 2L], sigma_q[[2L]]),
  `97.5%` = c(q_coef[, 3L], sigma_q[[3L]]),
  check.names = FALSE
)
knitr::kable(post_q, digits = 3)
```

## Fitted probabilities and child effects

Subject-specific fitted probabilities at the MAP use `fitted(fit)` (modes of
all random effects, including the former fixed effects).

```{r fitted-plot, fig.cap="Mean fitted probability by week and treatment (lines), with observed rates as points."}
bacteria$p_hat <- plogis(fitted(fit))
fit_rate <- aggregate(p_hat ~ week + trt, data = bacteria, FUN = mean)

plot(
  p_hat ~ week, data = fit_rate, type = "n",
  ylim = c(0, 1), ylab = "probability present"
)
for (g in levels(bacteria$trt)) {
  d <- fit_rate[fit_rate$trt == g, ]
  o <- rate[rate$trt == g, ]
  lines(d$week, d$p_hat, col = cols[[g]], lwd = 2)
  points(o$week, o$present, col = cols[[g]], pch = 19)
}
legend("bottomleft", legend = names(cols), col = cols, lty = 1, lwd = 2, pch = 19, bty = "n")
```

```{r re-intervals, fig.cap="Child random intercepts, ordered, with 80% pointwise intervals."}
set.seed(1)
re <- sim_iid(fit, n = if (vignette_full) 400L else 80L)
pw <- re$ID$pointwise
ord <- order(pw[, "0.5"])
pw <- pw[ord, ]
idx <- seq_along(ord)

plot(
  idx, pw[, "0.5"],
  ylim = range(pw),
  xlab = "child (ordered by median)",
  ylab = "random intercept",
  pch = 19, cex = 0.6
)
arrows(idx, pw[, "0.1"], idx, pw[, "0.9"], length = 0, col = "grey50")
abline(h = 0, lty = 2)
```
