Skip to contents
Show preliminaries
library(stLMM)
library(ggplot2)

source(file.path(dirname(knitr::current_input(dir = TRUE)), "utils.R"))

set.seed(1)

1 Overview

This vignette introduces binomial models in stLMM. The example uses a logistic mixed model with binomial trial counts, a structured AR(1) latent time effect, fitted probabilities, retained time-effect draws, and holdout prediction. The same binomial likelihood can be combined with other structured process terms such as nngp(), car(), and car_time().

2 Logistic model

For binomial responses, stLMM uses a logistic mixed model. Let \(y_i\) be the number of successes out of \(n_i\) trials. The model is

\[ y_i \mid p_i \sim \operatorname{Binomial}(n_i, p_i), \]

with linear predictor

\[ \eta_i = \log\left(\frac{p_i}{1-p_i}\right). \]

For the time-structured example below,

\[ \eta_i = \beta_0 + x_i\beta_1 + w_{t_i}, \qquad \mathbf{w} = \{w_1,\ldots,w_T\}^\top \sim N\{\mathbf{0}, \sigma_w^2\mathbf{R}(\phi)\}. \]

The probability is

\[ p_i = \operatorname{logit}^{-1}(\eta_i). \]

This notation maps directly to the package outputs. For binomial models, fitted(..., scale = "link") returns posterior summaries of \(\eta_i\), while the default scale = "response" returns posterior summaries of \(p_i\). Similarly, predict(..., scale = "link") returns prediction samples on the linear predictor scale, and the default response scale returns predicted probabilities. If y_samples = TRUE, prediction also simulates binomial counts using the prediction-row trial counts.

3 Pólya-Gamma updates

The binomial sampler uses Pólya-Gamma latent variables (Polson et al. 2013). Conditional on these latent variables, the logistic likelihood can be written as a Gaussian working likelihood for the linear predictor. This lets stLMM use the same fixed-effect and random-effect Gaussian updating machinery used by the Gaussian models. The Pólya-Gamma random variates are generated through the C/C++ API provided by the BayesLogit R package (Polson et al. 2026).

There is no residual variance parameter tau_sq in a binomial model. The binomial likelihood supplies the observation-level variance through \(n_i\) and \(p_i\), and the Pólya-Gamma variables provide the working precisions used inside the sampler. Therefore tau_sq priors, starting values, tuning values, and the Gaussian resid() term are not used with family = "binomial".

For structured process terms such as ar1(), nngp(), car(), or car_time(), the process is collapsed during parameter updates. In binomial structured-process models, stLMM() saves the in-chain process draws by default as save_process = list(start = 1, thin = 1) because those draws are already needed for the Pólya-Gamma augmentation. Calling recover() selects from these saved draws for fitted values and prediction. Users can thin the retained process grid with save_process = list(start = ..., thin = ...), or set save_process = FALSE when only parameter samples are needed.

4 Simulated binomial time series

We simulate repeated binomial observations over a shared time index. Each row has its own number of trials. The latent AR(1) process creates temporal dependence in the probability of success after accounting for the fixed effect. We hold out a subset of rows throughout the time range by setting y = NA; these rows do not contribute to the likelihood, but their covariates, time values, and trial counts are retained for prediction.

Code
n_time <- 34
n_per_time <- 9

time <- rep(seq_len(n_time), each = n_per_time)
x <- rnorm(n_time * n_per_time)
trials <- sample(8:26, n_time * n_per_time, replace = TRUE)

beta <- c("(Intercept)" = -0.25, x = 0.85)
sigma_w_sq <- 0.65^2
phi <- 0.75
time_support <- seq_len(n_time)
w <- rmvnorm(
  mean = rep(0, n_time),
  Sigma = sigma_w_sq * ar1_cor(time_support, phi = phi)
)

eta <- beta["(Intercept)"] + beta["x"] * x + w[time]
prob <- plogis(eta)
y <- rbinom(length(prob), size = trials, prob = prob)

holdout <- logical(length(y))
holdout_id <- unlist(tapply(seq_along(y), time, function(z) sample(z, 2)))
holdout[holdout_id] <- TRUE

dat <- data.frame(
  y = y,
  x = x,
  time = time,
  trials = trials,
  prob_true = prob
)
dat$y[holdout] <- NA

The exploratory plot shows the complete simulated binomial dataset before the holdout values are hidden from the model. Point size is the number of binomial trials for each row, and the filled color is the observed success proportion. Holdout rows are taken from this complete set of observations before fitting.

Show plotting code
plot_dat <- dat
plot_dat$response_rate <- y / plot_dat$trials

ggplot(plot_dat, aes(time, response_rate)) +
  geom_point(
    aes(size = trials, fill = response_rate),
    shape = 21,
    color = "grey25",
    alpha = 0.75,
    stroke = 0.25
  ) +
  scale_fill_gradientn(
    colors = stlmm_palette(),
    limits = c(0, 1)
  ) +
  scale_size_continuous(
    range = c(1.5, 4)
  ) +
  labs(
    x = "time",
    y = "observed success proportion",
    fill = "success\nproportion",
    size = "trials"
  )

5 Fit

The family = "binomial" argument selects the binomial logistic likelihood. The trials = "trials" argument tells the model to read the number of trials from the trials column. If trials is omitted, the model defaults to one trial per row, which gives Bernoulli observations.

Code
fit <- stLMM(
  y ~ x + ar1(time),
  data = dat,
  family = "binomial",
  trials = "trials",
  priors = list(
    beta = normal(mean = 0, sd = 3),
    ar1_1 = list(
      sigma_sq = half_t(df = 3, scale = 1),
      phi = uniform(0.05, 0.95)
    )
  ),
  starting = list(ar1_1 = list(sigma_sq = 0.4, phi = 0.6)),
  tuning = list(ar1_1 = list(sigma_sq = 0.05, phi = 0.15)),
  n_samples = 900,
  verbose = FALSE
)

summary(fit)
stLMM summary
  formula: y ~ x + ar1(time)
  observations: 306 (238 observed, 68 missing response)
  posterior draws: 900
  family: binomial
  fixed effects: 2
  grouped random-effect coefficients: 0
  process terms: 1
  residual variance: not used for Polya-Gamma likelihood

beta:
               mean     sd    q2.5   q50.0  q97.5
(Intercept) -0.5953 0.4199 -1.4292 -0.6026 0.2560
x            0.9474 0.0453  0.8598  0.9481 1.0308

sigma_sq:
                 mean     sd   q2.5  q50.0  q97.5
ar1_1_sigma_sq 0.7015 0.4269 0.2521 0.5767 1.7615

theta:
            mean     sd   q2.5  q50.0 q97.5
ar1_1_phi 0.7665 0.1188 0.5093 0.7862 0.936

The fixed effects use a weakly informative normal prior. The AR(1) term has a prior on its process variance and temporal association parameter. Notice that no tau_sq prior is supplied.

6 Latent process recovery

Structured process terms are integrated out during parameter updates. For this Pólya-Gamma model, the sampler has already saved the in-chain AR(1) process draws needed for augmentation. Calling recover() selects and labels the retained latent time effects; it does not run a separate post-fit Pólya-Gamma reconstruction. The recovered AR(1) effects line up with the sorted fitted time support.

The process mean and the fixed intercept can trade a nearly constant shift in a finite MCMC run. For that reason, the plot below compares the intercept-plus-process contribution, \(\beta_0 + w_t\), rather than \(w_t\) alone.

Code
rec <- recover(fit, sub_sample = list(start = 301, thin = 2))
rec_draws <- as_samples(rec, include_w = TRUE, metadata = FALSE)
w_cols <- paste0("w_ar1_1_", seq_along(time_support))
intercept_w_samples <- sweep(
  as.matrix(rec_draws[, w_cols, drop = FALSE]),
  1,
  rec_draws[["(Intercept)"]],
  "+"
)

w_summary <- data.frame(
  time = time_support,
  truth = beta["(Intercept)"] + w,
  posterior_mean = colMeans(intercept_w_samples)
)

head(w_summary)
          time      truth posterior_mean
w_ar1_1_1    1 -0.6381992     -0.7082095
w_ar1_1_2    2 -1.0824150     -1.3050781
w_ar1_1_3    3 -1.4805393     -1.6511081
w_ar1_1_4    4 -0.6994157     -0.7131202
w_ar1_1_5    5 -0.8782316     -0.9860745
w_ar1_1_6    6 -1.0488584     -1.0898441

In the plot, the orange line is the true simulated intercept-plus-process contribution and the blue line is the posterior mean estimate.

Show plotting code
ggplot(w_summary, aes(time)) +
  geom_line(
    aes(y = truth),
    color = stlmm_color("secondary"),
    linewidth = 0.8
  ) +
  geom_line(
    aes(y = posterior_mean),
    color = stlmm_color("primary"),
    linewidth = 0.8
  ) +
  geom_point(
    aes(y = posterior_mean),
    color = stlmm_color("primary"),
    size = 1.6
  ) +
  labs(
    x = "time",
    y = "intercept plus latent time effect"
  )

7 Fitted probabilities

By default, fitted() returns posterior fitted probabilities for binomial models. Because this model has a structured AR(1) term, fitted values need latent process draws. For this Pólya-Gamma fit those draws were saved during MCMC; here we use the recovered object so the fitted values line up with the same selected draw grid used above.

Code
fitted_prob <- fitted(rec)

fitted_link <- fitted(
  rec,
  scale = "link"
)

head(data.frame(probability = fitted_prob, link = fitted_link))
  probability       link
1   0.2155333 -1.3019176
2   0.3705887 -0.5341655
3   0.1840991 -1.5001585
4   0.6891479  0.8036834
5   0.4031019 -0.3959256
6   0.1862528 -1.4857907

The two scales are related by the inverse-logit transformation. For example, plogis(fitted_link) is on the probability scale, up to the difference between transforming posterior means and averaging transformed posterior draws.

Show plotting code
fitted_dat <- data.frame(
  truth = dat$prob_true[!holdout],
  fitted = fitted_prob[!holdout]
)
fitted_lim <- range(fitted_dat$truth, fitted_dat$fitted)

ggplot(fitted_dat, aes(truth, fitted)) +
  geom_point(
    color = stlmm_color("primary"),
    alpha = 0.75,
    size = 1.8
  ) +
  geom_abline(
    intercept = 0,
    slope = 1,
    color = stlmm_color("secondary"),
    linewidth = 0.8
  ) +
  coord_equal(
    xlim = fitted_lim,
    ylim = fitted_lim
  ) +
  labs(
    x = "true probability",
    y = "posterior fitted probability"
  )

8 Holdout prediction

Prediction rows for this model need the fixed-effect covariates, the fitted time value, and the trial count. Here all prediction rows are held-out observations on the fitted time support. The call below uses the fitted object directly to show that Pólya-Gamma process predictions can use the saved in-chain process draws without first creating a recovery object.

Code
holdout_new <- dat[holdout, setdiff(names(dat), "y"), drop = FALSE]

pred <- predict(
  fit,
  newdata = holdout_new,
  y_samples = TRUE,
  sub_sample = list(start = 301, thin = 2)
)

print(pred)
stLMM prediction
  mean samples: 300 draws x 68 rows
  newdata: TRUE
  joint: FALSE
  y samples: simulated
  process samples: ar1_1

The mu_samples matrix contains predicted probabilities by default. The y_samples matrix contains posterior predictive binomial counts out of the supplied trials. The full prediction summary has one row for each prediction row, so here we print only the first few rows.

Code
pred_summary <- summary(pred)
pred_draws <- as_samples(pred, sample = "all", metadata = FALSE)
mu_cols <- paste0("mu_", seq_len(nrow(holdout_new)))
y_cols <- paste0("y_", seq_len(nrow(holdout_new)))
pred_prob <- colMeans(pred_draws[, mu_cols, drop = FALSE])
pred_count <- colMeans(pred_draws[, y_cols, drop = FALSE])

prediction_summary <- data.frame(
  time = holdout_new$time,
  trials = holdout_new$trials,
  truth_prob = dat$prob_true[holdout],
  predicted_prob = pred_prob,
  observed_count = y[holdout],
  predicted_count = pred_count
)

head(prediction_summary)
     time trials truth_prob predicted_prob observed_count predicted_count
mu_1    1     23 0.20823502     0.18625282              5       4.2400000
mu_2    1     24 0.44426151     0.43926984              8      10.4733333
mu_3    2     23 0.04903654     0.03290295              5       0.7133333
mu_4    2     19 0.46848591     0.44114976              9       8.3600000
mu_5    3     11 0.19510734     0.17235176              2       1.8800000
mu_6    3     13 0.17825006     0.15548893              2       2.0833333
Code
head(pred_summary$mu)
         mean          sd       q2.5      q50.0      q97.5
6  0.18625282 0.028804174 0.13716225 0.18257438 0.24433022
7  0.43926984 0.045970985 0.35826663 0.43866225 0.52987142
14 0.03290295 0.007097623 0.02051588 0.03227671 0.04820704
15 0.44114976 0.049162402 0.34425710 0.43765885 0.52686199
23 0.17235176 0.026436859 0.12446490 0.17238437 0.22745259
26 0.15548893 0.024493508 0.11096345 0.15558054 0.20708170
Code
head(pred_summary$y)
         mean        sd q2.5 q50.0  q97.5
6   4.2400000 1.9871830    1     4  8.525
7  10.4733333 2.5529750    6    10 15.000
14  0.7133333 0.8834159    0     0  3.000
15  8.3600000 2.3683532    4     8 13.000
23  1.8800000 1.2345094    0     2  4.000
26  2.0833333 1.2813515    0     2  5.000

The holdout plot compares the true simulated probabilities, which are known here because this is a simulation, with the posterior predicted probabilities. The blue points are posterior means, the vertical lines are 95% credible intervals for \(p_i\), and the orange crosses are the true simulated probabilities.

Show plotting code
plot_pred <- prediction_summary
plot_pred$mean <- pred_summary$mu[, "mean"]
plot_pred$q2.5 <- pred_summary$mu[, "q2.5"]
plot_pred$q97.5 <- pred_summary$mu[, "q97.5"]
plot_pred$row <- seq_len(nrow(plot_pred))
plot_pred <- plot_pred[order(plot_pred$truth_prob), ]
plot_pred$row <- seq_len(nrow(plot_pred))
interval_color <- stlmm_discrete_colors(4)[4]

ggplot(plot_pred, aes(row, mean)) +
  geom_linerange(
    aes(ymin = q2.5, ymax = q97.5),
    color = interval_color,
    alpha = 0.85,
    linewidth = 0.7
  ) +
  geom_point(
    color = stlmm_color("primary"),
    size = 2,
    alpha = 0.8
  ) +
  geom_point(
    aes(y = truth_prob),
    color = stlmm_color("secondary"),
    shape = 4,
    stroke = 1
  ) +
  labs(
    x = "holdout row, ordered by true probability",
    y = "predicted probability"
  )

9 What this example illustrates

Binomial stLMM models use the same formula grammar as Gaussian models, but the likelihood is logistic and there is no tau_sq. Trial counts are supplied with trials; if omitted, the model assumes one trial per row.

For binomial models, fitted and predicted means are probabilities on the response scale by default. Use scale = "link" to work with the linear predictor. If y_samples = TRUE, prediction simulates binomial count outcomes, not Gaussian residual noise.

The AR(1) effects in this example are retained during fitting and then selected by recover(). For Pólya-Gamma structured-process models, predict(fit, ...) can also use the saved process draws directly; recover() is useful when you want the usual recovery object or a narrower retained draw grid. This differs from Gaussian process and CAR examples, where recover() draws the process values post hoc.

Polson, Nicholas G., James G. Scott, and Jesse Windle. 2013. “Bayesian Inference for Logistic Models Using Pólya-Gamma Latent Variables.” Journal of the American Statistical Association 108 (504): 1339–49. https://doi.org/10.1080/01621459.2013.829001.
Polson, Nicholas G., James G. Scott, Jesse Windle, Jari Oksanen, James Balamuta, and Andrew Finley. 2026. BayesLogit: PolyaGamma Sampling. https://doi.org/10.32614/CRAN.package.BayesLogit.