--- title: "Simulating and Fitting a Spatiotemporal ZINB-GP Model" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Simulating and Fitting a Spatiotemporal ZINB-GP Model} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 4.5 ) set.seed(20260729) ``` ## The model `ZINB.GP` models a count in two stages. First, a Bernoulli variable says whether observation \(j\) is in an at-risk state: \[ W_j \sim \operatorname{Bernoulli}(\phi_j), \qquad \operatorname{logit}(\phi_j) = X_j\alpha + V_{s,j}a + V_{t,j}b. \] If \(W_j=0\), the count is a structural zero. If \(W_j=1\), the count follows a negative-binomial distribution: \[ Y_j \mid W_j=1 \sim \operatorname{NB}(r,\psi_j), \qquad \operatorname{logit}(\psi_j) = X_j\beta + V_{s,j}c + V_{t,j}d. \] With the package's parameterization, \(\operatorname{E}(Y_j\mid W_j=1)=r\exp(\eta_{2j})\). Thus `Alpha` describes the at-risk probability and `Beta` describes conditional count intensity. The marginal expected count is the product of those two quantities. The vectors \(a,c\) are spatial effects and \(b,d\) are temporal effects. Each has a noisy squared-exponential GP prior \[ \sigma^2\{\kappa K_\ell + (1-\kappa)I\}. \] Here, \(\sigma^2\) is marginal variance, \(\ell\) is a length scale, and \(\kappa\) is the fraction of variance attributed to structured dependence. ## Build a correctly paired space-time design The rows of `Vs`, `Vt`, `X`, and `y` must refer to the same observations. The helper below samples a number of replicates for every spatial-temporal cell and then repeats the paired cell indices. This construction gives every row of `Vs` and `Vt` a shared space-time identity and keeps both matrices aligned with the corresponding rows of `X` and `y`. ```{r design} library(Matrix) library(mvtnorm) library(ZINB.GP) make_design <- function(n_space, n_time, mean_replicates) { cell_n <- matrix( rpois(n_space * n_time, mean_replicates), nrow = n_space ) cells <- expand.grid( spatial = seq_len(n_space), temporal = seq_len(n_time) ) spatial_id <- rep(cells$spatial, times = as.vector(cell_n)) temporal_id <- rep(cells$temporal, times = as.vector(cell_n)) n <- length(spatial_id) Vs_full <- as.matrix(sparseMatrix( i = seq_len(n), j = spatial_id, x = 1, dims = c(n, n_space) )) Vt_full <- as.matrix(sparseMatrix( i = seq_len(n), j = temporal_id, x = 1, dims = c(n, n_time) )) list(Vs_full = Vs_full, Vt_full = Vt_full, cell_n = cell_n) } n_space <- 12 n_time <- 8 design <- make_design(n_space, n_time, mean_replicates = 3) N <- nrow(design$Vs_full) N ``` ## Baselines and intercepts An intercept together with every column of both one-hot indicator matrices is rank deficient. `ZINB_GP_orig()`, and the full-GP route through `ZINB_GP()`, use the first spatial level and first temporal level as baselines: - `X` contains an explicit intercept; - the first columns of `Vs_full` and `Vt_full` are removed; - the first rows and columns of the distance matrices are removed internally; - the remaining GP effects are sampled directly from the corresponding reduced covariance matrices. The simulation therefore draws the \(S-1\) spatial and \(T-1\) temporal effects directly from these reduced GP covariance matrices. The fixed intercept is the linear predictor for an observation at both baseline levels, and each retained effect is the additive departure associated with its nonbaseline level. This parameterization gives a full-rank design and matches the covariance model used for estimation. ```{r distances} coords <- cbind(runif(n_space), runif(n_space)) * 1000 time_coord <- matrix(0:(n_time - 1) * 50, ncol = 1) Ds <- as.matrix(dist(coords)) Dt <- as.matrix(dist(time_coord)) Vs <- design$Vs_full[, -1, drop = FALSE] Vt <- design$Vt_full[, -1, drop = FALSE] stopifnot( ncol(Vs) + 1 == nrow(Ds), ncol(Vt) + 1 == nrow(Dt), nrow(Vs) == nrow(Vt) ) ``` The coordinate units are deliberately large enough for the package's pre-MCMC kernel-conditioning screen. The generating length scales below are expressed in those same units, so this numerical scaling does not change the intended correlation pattern. ## Draw latent effects and counts The package squares the supplied distances before applying its default kernel. We do the same when generating the latent effects. The reduced covariance matrices exactly match the identified model fitted by `ZINB_GP_orig()`. ```{r simulate} noisy_covariance <- function(distance, length_scale, sigma, kappa) { correlation <- exp(-(distance^2) / length_scale^2) sigma^2 * (kappa * correlation + (1 - kappa) * diag(nrow(distance))) } spatial_distance <- Ds[-1, -1, drop = FALSE] temporal_distance <- Dt[-1, -1, drop = FALSE] a <- drop(rmvnorm(1, sigma = noisy_covariance( spatial_distance, length_scale = 350, sigma = 1, kappa = 0.5 ))) c <- drop(rmvnorm(1, sigma = noisy_covariance( spatial_distance, length_scale = 250, sigma = 1, kappa = 0.5 ))) b <- drop(rmvnorm(1, sigma = noisy_covariance( temporal_distance, length_scale = 100, sigma = 0.5, kappa = 0.2 ))) d <- drop(rmvnorm(1, sigma = noisy_covariance( temporal_distance, length_scale = 150, sigma = 0.5, kappa = 0.2 ))) x <- rnorm(N) X <- cbind("(Intercept)" = 1, x = x) alpha <- c(-0.25, 0.25) beta <- c(0.50, -0.25) r <- 1 eta_at_risk <- drop(X %*% alpha + Vs %*% a + Vt %*% b) p_at_risk <- plogis(eta_at_risk) at_risk <- rbinom(N, size = 1, prob = p_at_risk) eta_count <- drop(X %*% beta + Vs %*% c + Vt %*% d) mu_count <- r * exp(eta_count) y <- integer(N) y[at_risk == 1] <- rnbinom( sum(at_risk == 1), size = r, mu = mu_count[at_risk == 1] ) c(observations = N, zeros = sum(y == 0), positive = sum(y > 0)) ``` The heatmap below is a useful first check. It shows cell totals rather than individual replicates, making both zero inflation and clusters of large counts visible on the modeled support. ```{r simulated-heatmap} cell_id <- max.col(design$Vs_full) + n_space * (max.col(design$Vt_full) - 1) cell_sum <- tapply(y, cell_id, sum) cell_total <- numeric(n_space * n_time) cell_total[as.integer(names(cell_sum))] <- cell_sum cell_total <- matrix(cell_total, nrow = n_space) image( x = seq_len(n_time), y = seq_len(n_space), z = t(log1p(cell_total)), xlab = "Time", ylab = "Spatial location", main = "Simulated cell totals: log(1 + count)", col = hcl.colors(20, "YlOrRd", rev = TRUE) ) ``` ## Fit the full model The following call uses GPs in both components. It is not evaluated while the vignette is built because a useful MCMC run is intentionally much longer than a CRAN vignette should take. ```{r fit, eval=FALSE} fit <- ZINB_GP( X = X, y = y, coords = coords, Vs = Vs, Vt = Vt, Ds = Ds, Dt = Dt, nsim = 20000, burn = 5000, thin = 5, save_ypred = TRUE, print_progress = TRUE, use_count_gp = TRUE, use_inflation_gp = TRUE ) ``` The rows of `coords` identify the full set of spatial levels, including the baseline. `Ds` and `Dt` control the dense GP covariances. Distance and length scale share the same units: multiplying a distance matrix by a constant requires multiplying its length scale by that constant to preserve the same covariance. The entry point also supports simpler models. For example, this call keeps the spatial and temporal GPs only in the count component: ```{r count-only, eval=FALSE} fit_count_gp <- ZINB_GP( X = X, y = y, coords = coords, Vs = Vs, Vt = Vt, Ds = Ds, Dt = Dt, nsim = 20000, burn = 5000, thin = 5, use_count_gp = TRUE, use_inflation_gp = FALSE ) ``` The component flags encode which residual dependence the model represents. A count GP explains residual variation in conditional intensity; an inflation GP explains residual variation in whether an observation is at risk. The selected combination should follow the scientific role assigned to each process. ## Predict at new locations and times Prediction uses the same coordinate units and fixed-effect columns as the fitted model. First, construct one row for each new observation. Repeating a coordinate means that those observations share the same predicted GP random effect. Here, three new spatial locations are crossed with two future times: ```{r prediction-inputs, eval=FALSE} coords_future <- rbind( c(250, 250), c(500, 500), c(750, 750) ) times_future <- matrix(c(400, 450), ncol = 1) prediction_grid <- expand.grid( spatial = seq_len(nrow(coords_future)), temporal = seq_len(nrow(times_future)) ) coords_new <- coords_future[prediction_grid$spatial, , drop = FALSE] time_coords_new <- times_future[ prediction_grid$temporal, , drop = FALSE ] X_new <- cbind( "(Intercept)" = 1, x = rep(0, nrow(prediction_grid)) ) prediction_inputs <- make_prediction_inputs( coords = coords, time_coords = time_coord, coords_new = coords_new, time_coords_new = time_coords_new ) ``` `make_prediction_inputs()` removes the original baseline coordinate from each conditioning matrix, matching the baseline contrasts used by the fitted random effects. It retains each unique new coordinate once in the augmented distance matrix and builds `Vs_new` and `Vt_new` to map the prediction rows back to those new levels. Pass the helper output to `predict()`. For every retained posterior iteration, the method applies the GP conditioning rule to draw new spatial and temporal effects, combines them with that iteration's fixed effects, and draws a new zero-inflated negative-binomial response: ```{r predict-new, eval=FALSE} predicted <- do.call( predict, c( list(object = fit, X = X_new), prediction_inputs ) ) apply(predicted$Y_pred, 2, quantile, probs = c(0.025, 0.5, 0.975)) ``` Rows of `predicted$Y_pred` correspond to retained posterior iterations and columns correspond to rows of `X_new`. The conditioned random-effect draws are also available as `predicted$A`, `predicted$B`, `predicted$C`, and `predicted$D`; models with fewer active GPs return only the applicable effects. If the model was fitted with a custom kernel, pass the same function as the `kern` argument to `predict()`. ## Interpret posterior draws Each row of `fit$Alpha` and `fit$Beta` is a retained posterior draw. The random effects are in `A` and `B` for the at-risk component and `C` and `D` for the count component. `R` contains dispersion draws, and `Noise1s`, `Noise1t`, `Noise2s`, and `Noise2t` are the structured-variance fractions \(\kappa\). For a fitted model, equal-tailed intervals can be computed directly: ```{r intervals, eval=FALSE} apply(fit$Alpha, 2, quantile, probs = c(0.025, 0.5, 0.975)) apply(fit$Beta, 2, quantile, probs = c(0.025, 0.5, 0.975)) ``` Credible intervals summarize posterior uncertainty; they do not establish that the Markov chain mixed well. The Oregon case-study vignette shows how to wrap these matrices with standard MCMC packages and calculate effective sample size.