Universitas Padjadjaran
Fakultas Matematika dan
Ilmu Pengetahuan Alam
Statistika
Workshop Module — Spatiotemporal Data Analysis
with R-INLA
This workshop is designed as a compact transition from spatial thinking to a practical Bayesian spatiotemporal model. The central idea is simple: observations collected over administrative regions and over time are usually not independent. Nearby regions may resemble each other, adjacent time points may be correlated, and unusual combinations of place and time may create additional interaction patterns.
By the end of the three-hour session, participants should be able to:
Suppose \(Y_{it}\) is a count observed in region \(i=1,\ldots,n\) and time \(t=1,\ldots,T\). A standard Poisson model assumes
\[ Y_{it}\mid \lambda_{it} \sim \text{Poisson}(\lambda_{it}), \]
with independent observations after conditioning on covariates. In regional data, that assumption is often too strong because:
A useful epidemiological or risk-mapping parameterization is
\[ Y_{it}\mid RR_{it},E_{it}\sim \text{Poisson}(E_{it}RR_{it}), \]
where \(E_{it}\) is an expected count under a reference risk and \(RR_{it}\) is the latent relative risk. If population-time is used instead, the same log-offset machinery estimates a rate rather than this standardized RR parameterization.
Interpretation rule. If \(RR_{it}=1\), observed risk is consistent with the reference level implied by \(E_{it}\). Values above 1 indicate elevated risk; values below 1 indicate lower risk. In Bayesian analysis, always interpret the posterior distribution, not only the posterior mean.
Run the installation chunk once. The INLA repository is not CRAN, so it is installed from the official R-INLA repository.
# General packages
install.packages(c(
"sf", "spdep", "dplyr", "tidyr", "ggplot2",
"viridis", "geodata", "terra", "knitr", "patchwork", "scales"
))
# R-INLA
install.packages(
"INLA",
repos = c(
getOption("repos"),
INLA = "https://inla.r-inla-download.org/R/stable"
),
dep = TRUE
)Load packages:
library(sf)
library(spdep)
library(dplyr)
library(tidyr)
library(ggplot2)
library(viridis)
library(geodata)
library(terra)
library(INLA)
library(patchwork)
library(scales)Reproducibility note. This version uses the
local West Java and Bandung shapefiles included in the project.
No administrative boundary download is required. Keep the entire
data/ folder beside the Rmd file so the workshop remains
portable.
In the revised workshop module we explicitly use the local maps supplied in the project:
data/jabar/JABAR.shp for kabupaten/kota in West
Java;data/bandung/BANDUNG.shp for kecamatan in
Bandung;data/bandung/DataG.csv for the Bandung
disease-mapping example;data/bandung/Data.csv for the male/female
aggregation example.This makes the module fully grounded in the local spatial context. So now the workshop no longer says “West Java” in theory while quietly drawing some other map behind the curtain. The map and the math are finally on speaking terms.
Spatiotemporal_Workshop_RINLA/
├── Spatiotemporal_Workshop_RINLA.Rmd
├── figures/
│ ├── logo-unpad.png
│ ├── jabar_admin_map.png
│ ├── jabar_adjacency_map.png
│ ├── jabar_spatiotemporal_rr.png
│ ├── jabar_mapping_workflow.png
│ ├── jabar_rr_continuous_interval.png
│ ├── jabar_exceedance_continuous_interval.png
│ ├── bandung_admin_map.png
│ ├── bandung_rr_map.png
│ ├── bandung_rr_continuous_interval.png
│ └── bandung_adjacency_map.png
└── data/
├── jabar/
│ ├── JABAR.shp
│ ├── JABAR.shx
│ ├── JABAR.dbf
│ └── data.csv
└── bandung/
├── BANDUNG.shp
├── BANDUNG.shx
├── BANDUNG.dbf
├── BANDUNG.prj
├── Data.csv
└── DataG.csvlibrary(sf)
library(dplyr)
library(ggplot2)
library(spdep)
library(INLA)
jabar <- st_read("data/jabar/JABAR.shp", quiet = TRUE)
# the supplied file does not store CRS information explicitly,
# so we assign geographic coordinates (longitude/latitude)
if (is.na(st_crs(jabar))) {
st_crs(jabar) <- 4326
}
jabar <- jabar |>
st_make_valid() |>
mutate(
area_id = row_number(),
area_name = KABKOT
)
nrow(jabar)
head(jabar[, c("area_id", "area_name")])Important. The workshop now uses the actual West Java polygons from your uploaded file. This is preferable to downloading external boundaries because the project becomes more stable and fully portable.
The first map is not just decoration. It determines the spatial unit of analysis. Once the unit is fixed, the data table must contain one record per area (or one record per area-time combination for a spatiotemporal analysis).
Let:
\[ i=1,2,\ldots,n \]
index the district/city in West Java. In this file, the number of areas is
\[ n = 27. \]
For each area \(i\), we will later define:
A CAR/Besag model requires a graph describing which regions are neighbors. We use queen contiguity: two polygons are neighbors if they share at least one boundary point.
nb_jabar <- poly2nb(jabar, queen = TRUE)
summary(card(nb_jabar))
# create the graph file for INLA
nb2INLA("data/jabar/jabar.graph", nb_jabar)
g_jabar <- inla.read.graph("data/jabar/jabar.graph")For a neighbor set \(\partial i\), the intrinsic CAR/Besag prior is written conditionally as
\[ u_i \mid u_{-i},\tau_u \sim \mathcal N\left( \frac{1}{n_i}\sum_{j\in \partial i} u_j,\; \frac{1}{\tau_u n_i} \right), \]
where \(n_i\) is the number of neighbors of area \(i\). In matrix form,
\[ \mathbf u \sim \mathcal N\left(\mathbf 0, \; \tau_u^{-1} Q^{-}\right), \qquad Q = D-W, \]
with:
The matrix \(Q\) is singular in the intrinsic formulation, which is why the Besag effect is usually fitted with a constraint such as
\[ \sum_{i=1}^n u_i = 0. \]
The most basic disease-mapping workflow goes from population to expected count, then to observed count, and finally to risk measures.
For area \(i\):
\[ SMR_i = \frac{Y_i}{E_i}, \]
where \(Y_i\) is the observed number of cases and \(E_i\) is the expected count under a reference rate.
If the reference rate is
\[ r = \frac{\sum_i Y_i}{\sum_i N_i}, \]
then the expected count is
\[ E_i = N_i r. \]
In practice we frequently use the Poisson model
\[ Y_i \mid RR_i \sim \text{Poisson}(E_i RR_i). \]
Thus
\[ \mathbb E(Y_i\mid RR_i)=E_i RR_i, \qquad \operatorname{Var}(Y_i\mid RR_i)=E_i RR_i. \]
Suppose one West Java district has:
Then the expected count is
\[ E_i = N_i r = 250{,}000\times 0.0032 = 800. \]
The standardized morbidity ratio is
\[ SMR_i = \frac{980}{800}=1.225. \]
Interpretation: the observed number of cases is about 22.5% higher than expected under the reference rate. In log-risk form,
\[ \log(RR_i)\approx \log(1.225)=0.203. \]
This manual step is extremely useful in class because students can see that the map color is a visual translation of a numerical quantity, not a magical heatmap summoned by software.
To demonstrate the spatiotemporal idea, we attach time to the same map. Let
\[ Y_{it} \mid RR_{it} \sim \text{Poisson}(E_{it}RR_{it}), \qquad i=1,\ldots,n,\quad t=1,\ldots,T. \]
A common decomposition is
\[ \log(RR_{it}) = \beta_0 + \mathbf x_{it}^{\top}\boldsymbol\beta + u_i + v_i + \gamma_t + \phi_t + \delta_{it}, \]
where:
A useful classroom discussion is: which changes across time appear to be global temporal shifts, and which appear to be localized interactions? That question naturally motivates the term \(\delta_{it}\).
For teaching, a continuous color bar is useful for seeing subtle numerical differences, while an interval legend is often easier for discussion with decision makers. The workshop therefore displays both.
The default workshop intervals are:
| RR interval | Label | Interpretation |
|---|---|---|
| \(RR<0.80\) | Lower | clearly below the reference level |
| \(0.80\le RR<1.00\) | Below reference | below 1 but relatively close to reference |
| \(1.00\le RR<1.20\) | Moderate | modest elevation above reference |
| \(1.20\le RR<1.50\) | High | substantively elevated risk |
| \(RR\ge1.50\) | Very high | strong elevation requiring closer attention |
These thresholds are teaching thresholds, not universal scientific cut-points. In an applied study, the intervals should be chosen from substantive knowledge, surveillance standards, quantiles, or another defensible rule. The code below is a preview of the mapping grammar; the fully executable version appears after the spatiotemporal data are simulated in Section 5.
map_jabar_demo <- map_sim |>
mutate(risk_class = classify_rr(RR_true))
p_jabar_cont <- ggplot(map_jabar_demo) +
geom_sf(aes(fill = RR_true), color = "white", linewidth = 0.25) +
scale_fill_viridis_c(
option = "C",
name = "Relative risk",
labels = scales::label_number(accuracy = 0.01)
) +
labs(
title = "Continuous scale",
subtitle = paste("Jawa Barat — period", month_to_show)
) +
workshop_map_theme()
p_jabar_class <- ggplot(map_jabar_demo) +
geom_sf(aes(fill = risk_class), color = "white", linewidth = 0.25) +
scale_fill_manual(
values = risk_palette,
drop = FALSE,
name = "Risk interval",
guide = guide_legend(ncol = 1, byrow = TRUE)
) +
labs(
title = "Interval / class scale",
subtitle = "Explicit thresholds support interpretation"
) +
workshop_map_theme()
p_jabar_cont + p_jabar_class +
patchwork::plot_annotation(
title = "Case A — Jawa Barat: Two Complementary Risk Maps",
subtitle = "Use the continuous map for detail; use the interval map for communication."
)Bandung is included as a finer-scale urban example. It is excellent for showing what happens when the spatial unit becomes smaller and the patterns become more heterogeneous.
bandung <- st_read("data/bandung/BANDUNG.shp", quiet = TRUE) |>
st_make_valid() |>
mutate(area_id = row_number(), area_name = NAMA)
bandung_dat <- read.csv("data/bandung/DataG.csv")
bandung_dat <- bandung_dat |>
mutate(name_join = trimws(toupper(Kecamatan)))
bandung <- bandung |>
mutate(name_join = trimws(toupper(NAMA))) |>
left_join(bandung_dat, by = "name_join")Why Bandung? West Java is ideal for the province-level story, while Bandung is ideal for a city-level story. Together they let students see the difference between macro-scale spatial variation and micro-area urban heterogeneity.
For the Bandung example, let \(i=1,\ldots,30\) index the
kecamatan. The uploaded data set DataG.csv
contains the following key variables:
| Variable | Meaning |
|---|---|
Penduduk |
population at risk |
Diare |
observed number of diarrhea cases |
E |
expected count |
RR |
estimated/raw relative risk |
Group |
grouping indicator used in the earlier script |
DataG.csv and
BANDUNG.shp files.
bandung_map <- bandung |>
mutate(risk_class = classify_rr(RR))
p_bdg_cont <- ggplot(bandung_map) +
geom_sf(aes(fill = RR), color = "white", linewidth = 0.25) +
scale_fill_viridis_c(
option = "C",
name = "Relative risk",
labels = scales::label_number(accuracy = 0.01)
) +
labs(
title = "Continuous scale",
subtitle = "Bandung diarrhea relative risk by kecamatan"
) +
workshop_map_theme()
p_bdg_class <- ggplot(bandung_map) +
geom_sf(aes(fill = risk_class), color = "white", linewidth = 0.25) +
scale_fill_manual(
values = risk_palette,
drop = FALSE,
name = "Risk interval",
guide = guide_legend(ncol = 1, byrow = TRUE)
) +
labs(
title = "Interval / class scale",
subtitle = "Same RR values, easier categorical interpretation"
) +
workshop_map_theme()
p_bdg_cont + p_bdg_class +
patchwork::plot_annotation(
title = "Case B — Kota Bandung: Diarrhea Risk Mapping",
subtitle = "Observed local data at kecamatan level"
)The interval table can also be summarized numerically:
bandung_map |>
sf::st_drop_geometry() |>
count(risk_class, .drop = FALSE, name = "Number of kecamatan")These maps are ideal for explaining the difference between counts and risk.
For example, a kecamatan may have a large number of cases simply because it has a large population. That does not automatically imply unusually high risk. The risk measure adjusts for the expected count:
\[ RR_i = \frac{\mu_i}{E_i}, \]
where \(\mu_i=\mathbb E(Y_i\mid \text{model})\). Under a simple plug-in interpretation, the raw risk estimate is often approximated by
\[ \widehat{RR}_i \approx \frac{Y_i}{E_i}. \]
Suppose a kecamatan has:
Then
\[ SMR_i = \frac{460}{293} \approx 1.57. \]
This means the observed count is approximately 57% higher than expected. The map color for that kecamatan should therefore fall into a higher-risk class.
nb_bandung <- poly2nb(bandung, queen = TRUE)
summary(card(nb_bandung))
nb2INLA("data/bandung/bandung.graph", nb_bandung)
g_bandung <- inla.read.graph("data/bandung/bandung.graph")The Bandung graph is especially useful when students want to compare a province-level map and a city-level map. The smaller spatial unit usually produces:
A compact but complete mapping workflow in R is:
# 1. read polygon data
jabar <- st_read("data/jabar/JABAR.shp", quiet = TRUE)
bandung <- st_read("data/bandung/BANDUNG.shp", quiet = TRUE)
if (is.na(st_crs(jabar))) st_crs(jabar) <- 4326
# 2. read attribute data
bdg <- read.csv("data/bandung/DataG.csv")
# 3. harmonize names and join
bandung <- bandung |>
mutate(name_join = trimws(toupper(NAMA))) |>
left_join(
bdg |>
mutate(name_join = trimws(toupper(Kecamatan))),
by = "name_join"
)
# 4. create exploratory maps
ggplot(jabar) + geom_sf(fill = "grey95", color = "grey40")
ggplot(bandung) +
geom_sf(aes(fill = RR), color = "white", linewidth = 0.2) +
scale_fill_viridis_c(option = "C") +
labs(title = "Relative Risk across Bandung Kecamatan")
# 5. create neighborhood lists
nb_jabar <- poly2nb(jabar, queen = TRUE)
nb_bandung <- poly2nb(bandung, queen = TRUE)
# 6. export graph objects for INLA
nb2INLA("data/jabar/jabar.graph", nb_jabar)
nb2INLA("data/bandung/bandung.graph", nb_bandung)The maps are not just descriptive figures; they define the algebra of the model.
If the data are stored in long format, then each row corresponds to one \((i,t)\) combination:
\[ \bigl(i,t,Y_{it},E_{it},x_{it1},\ldots,x_{itp}\bigr). \]
The polygon map gives the geometry of \(i\), while the adjacency graph gives the structure used by the Besag or BYM prior. The temporal index \(t\) allows us to build RW1, RW2, or AR(1) effects.
Thus, the mapping pipeline and the modeling pipeline are connected as follows:
| Mapping step | Modeling consequence |
|---|---|
| choose spatial units | defines the index \(i\) |
| build adjacency | defines \(W\) and \(Q=D-W\) |
| compute expected counts | produces \(E_{it}\) for the Poisson offset |
| map raw \(SMR\) | provides exploratory risk visualization |
| fit INLA model | produces smoothed posterior risk |
| map posterior mean and exceedance probability | produces inferential disease maps |
Checkpoint 2. Ask students to compare West Java and Bandung: which map is more appropriate for province-level planning, and which is more appropriate for identifying local hotspots? Then ask how the answer changes when the target is policy, surveillance, or intervention design.
A practical additive spatiotemporal model is
\[ Y_{it}\mid RR_{it},E_{it}\sim \text{Poisson}(E_{it}RR_{it}), \]
\[ \log(RR_{it}) = \beta_0 + \beta_1 x_{it} + u_i + \gamma_t + \delta_{it}. \]
The components are:
| Term | Meaning | Typical INLA model |
|---|---|---|
| \(\beta_0\) | global log-risk | fixed intercept |
| \(\beta_1x_{it}\) | observed covariate effect | fixed effect |
| \(u_i\) | structured spatial effect | Besag / CAR |
| \(\gamma_t\) | structured temporal trend | RW1 or RW2 |
| \(\delta_{it}\) | residual space-time interaction | IID in the introductory model |
For the structured spatial effect, a Besag prior informally says that \(u_i\) should be similar to the average of neighboring effects. A first-order temporal random walk assumes
\[ \gamma_t - \gamma_{t-1}\sim N(0,\tau_\gamma^{-1}). \]
Why INLA? Integrated Nested Laplace Approximation provides fast deterministic approximations to posterior marginals for latent Gaussian models. For many spatial and spatiotemporal models, this is substantially faster than generic MCMC while retaining a fully Bayesian interpretation.
We now generate data where the truth is known. This is valuable because we can ask whether the model recovers the simulated structure.
n_area <- nrow(jabar)
n_time <- 12
# Row-standardized neighborhood matrix
W <- spdep::nb2mat(nb_jabar, style = "W", zero.policy = TRUE)
# Smooth a random signal through the neighborhood structure
z <- rnorm(n_area)
spatial_raw <- as.numeric(solve(diag(n_area) - 0.55 * W, z))
spatial_eff <- as.numeric(scale(spatial_raw)) * 0.35
# Smooth temporal signal
rw <- cumsum(rnorm(n_time, 0, 0.18))
temporal_eff <- as.numeric(scale(rw)) * 0.28
sim <- tidyr::expand_grid(
time_id = 1:n_time,
area_id = 1:n_area
) |>
arrange(time_id, area_id) |>
mutate(
st_id = row_number(),
x = rnorm(n()),
E = round(runif(n(), 25, 120)),
interaction = rnorm(n(), 0, 0.14),
eta_true = -0.05 +
0.30 * x +
spatial_eff[area_id] +
temporal_eff[time_id] +
interaction,
RR_true = exp(eta_true),
lambda = E * RR_true,
y = rpois(n(), lambda)
)
head(sim)Here E is a simulated expected count under the reference
risk. The latent linear predictor is therefore directly on the
log-relative-risk scale, so RR_true = exp(eta_true) and a
value of 1 has its usual risk-mapping interpretation.
month_to_show <- 12
map_sim <- jabar |>
left_join(
sim |> filter(time_id == month_to_show),
by = "area_id"
)
ggplot(map_sim) +
geom_sf(aes(fill = RR_true), color = "white", linewidth = 0.25) +
scale_fill_viridis_c(option = "C", name = "True RR",
labels = scales::label_number(accuracy = 0.01)) +
labs(
title = paste("Simulated relative risk — period", month_to_show),
subtitle = "Known latent signal before fitting the Bayesian model",
caption = "Case A — Jawa Barat | continuous legend"
) +
workshop_map_theme()Now that map_sim exists, the following chunk is
executable and generates the two complementary Jawa Barat maps used
during the hands-on session.
map_jabar_demo <- map_sim |>
mutate(risk_class = classify_rr(RR_true))
p_jabar_cont <- ggplot(map_jabar_demo) +
geom_sf(aes(fill = RR_true), color = "white", linewidth = 0.25) +
scale_fill_viridis_c(
option = "C",
name = "Relative risk",
labels = scales::label_number(accuracy = 0.01)
) +
labs(
title = "Continuous scale",
subtitle = paste("Jawa Barat — period", month_to_show),
caption = "Continuous legends preserve small numerical differences."
) +
workshop_map_theme()
p_jabar_class <- ggplot(map_jabar_demo) +
geom_sf(aes(fill = risk_class), color = "white", linewidth = 0.25) +
scale_fill_manual(
values = risk_palette,
drop = FALSE,
name = "Risk interval",
guide = guide_legend(ncol = 1, byrow = TRUE)
) +
labs(
title = "Interval / class scale",
subtitle = "Explicit RR thresholds",
caption = "Class maps are easier to discuss in a workshop or policy briefing."
) +
workshop_map_theme()
p_jabar_cont + p_jabar_class +
patchwork::plot_annotation(
title = "Case A — Jawa Barat: Simulated Spatiotemporal Risk",
subtitle = "The same period shown with continuous and interval legends"
)sim |>
group_by(time_id) |>
summarise(mean_RR = mean(RR_true), .groups = "drop") |>
ggplot(aes(time_id, mean_RR)) +
geom_line(linewidth = 1) +
geom_point(size = 2) +
labs(
x = "Time",
y = "Mean simulated RR",
title = "Average risk profile over time"
) +
theme_minimal(base_size = 12)We fit models of increasing complexity so that participants can see what each latent component contributes.
m_a <- INLA::inla(
y ~ 1 + x,
family = "poisson",
data = sim,
offset = log(E),
control.predictor = list(compute = TRUE),
control.compute = list(dic = TRUE, waic = TRUE, cpo = TRUE)
)
m_a$summary.fixedm_c <- INLA::inla(
y ~ 1 + x +
f(
area_id,
model = "besag",
graph = g_jabar,
scale.model = TRUE,
constr = TRUE
) +
f(
time_id,
model = "rw1",
scale.model = TRUE,
constr = TRUE
) +
f(st_id, model = "iid"),
family = "poisson",
data = sim,
offset = log(E),
control.predictor = list(compute = TRUE),
control.compute = list(
dic = TRUE,
waic = TRUE,
cpo = TRUE,
config = TRUE,
return.marginals.predictor = TRUE
)
)
m_c$summary.fixedInterpret the fixed effect. The coefficient for
x is on the log-relative-risk scale. Therefore
exp(beta_x) is a multiplicative relative-risk ratio
associated with a one-unit increase in x, conditional on
the latent effects.
comparison <- data.frame(
Model = c("A: fixed only", "B: + spatial", "C: + spatial + temporal + interaction"),
DIC = c(m_a$dic$dic, m_b$dic$dic, m_c$dic$dic),
WAIC = c(m_a$waic$waic, m_b$waic$waic, m_c$waic$waic)
)
comparisonLower DIC/WAIC is generally preferred, but the difference should be interpreted alongside scientific plausibility, residual diagnostics, and posterior uncertainty.
For a Poisson model with expected-count offset \(\log(E_{it})\), the fitted mean is \(E_{it}\times RR_{it}\). Therefore, posterior fitted counts can be converted to relative risk by dividing by the expected count.
sim$post_rr_mean <- m_c$summary.fitted.values$mean / sim$E
sim$post_rr_lwr <- m_c$summary.fitted.values$`0.025quant` / sim$E
sim$post_rr_upr <- m_c$summary.fitted.values$`0.975quant` / sim$E
sim |>
select(area_id, time_id, RR_true, post_rr_mean, post_rr_lwr, post_rr_upr) |>
head()map_post <- jabar |>
left_join(
sim |>
filter(time_id == month_to_show) |>
select(area_id, post_rr_mean, post_rr_lwr, post_rr_upr),
by = "area_id"
)
map_post <- map_post |>
mutate(post_risk_class = classify_rr(post_rr_mean))
p_post_cont <- ggplot(map_post) +
geom_sf(aes(fill = post_rr_mean), color = "white", linewidth = 0.25) +
scale_fill_viridis_c(
option = "C", name = "Posterior RR",
labels = scales::label_number(accuracy = 0.01)
) +
labs(
title = "Posterior RR — continuous",
subtitle = paste("Jawa Barat, period", month_to_show)
) +
workshop_map_theme()
p_post_class <- ggplot(map_post) +
geom_sf(aes(fill = post_risk_class), color = "white", linewidth = 0.25) +
scale_fill_manual(
values = risk_palette,
drop = FALSE,
name = "Posterior RR interval"
) +
labs(
title = "Posterior RR — intervals",
subtitle = "Same posterior means grouped into risk classes"
) +
workshop_map_theme()
p_post_cont + p_post_class +
patchwork::plot_annotation(
title = "Posterior Relative Risk Mapping",
subtitle = "Spatial smoothing + temporal structure + interaction"
)A useful decision-oriented quantity is
\[ P(RR_{it} > 1 \mid \text{data}). \]
Because the model is fitted with offset = log(E), the
INLA linear predictor is
\[ \eta_{it}=\log(E_{it})+\log(RR_{it}). \]
Therefore,
\[ RR_{it}>1 \quad\Longleftrightarrow\quad \eta_{it}>\log(E_{it}). \]
The safest calculation is therefore to integrate the
linear-predictor marginal above the threshold \(\log(E_{it})\). This also avoids the
seq.default(): 'from' must be a finite number failure that
can occur when a fitted-value marginal is absent or numerically
non-finite.
# Robust posterior exceedance probability:
# P(RR_it > 1 | data) = P(eta_it > log(E_it) | data)
# where eta_it is the linear predictor including the offset log(E_it).
safe_pmarginal <- function(q, marginal) {
if (is.null(marginal) || !is.matrix(marginal) || nrow(marginal) < 2) {
return(NA_real_)
}
ok <- is.finite(marginal[, 1]) & is.finite(marginal[, 2])
marginal <- marginal[ok, , drop = FALSE]
if (nrow(marginal) < 2 || any(diff(marginal[, 1]) <= 0)) {
return(NA_real_)
}
ans <- tryCatch(
INLA::inla.pmarginal(q = q, marginal = marginal),
error = function(e) NA_real_
)
if (!is.finite(ans)) NA_real_ else ans
}
# Default fallback: Gaussian approximation based on INLA posterior
# summary for the linear predictor. This is stable for knitting.
lp_sum <- m_c$summary.linear.predictor
if (is.null(lp_sum) || nrow(lp_sum) != nrow(sim)) {
stop("INLA did not return summary.linear.predictor. Refit m_c with control.predictor = list(compute = TRUE).")
}
log_rr_mean <- lp_sum$mean - log(sim$E)
log_rr_sd <- lp_sum$sd
sim$prob_rr_gt_1 <- ifelse(
is.finite(log_rr_mean) & is.finite(log_rr_sd) & log_rr_sd > 0,
stats::pnorm(log_rr_mean / log_rr_sd),
NA_real_
)
# If full linear-predictor marginals are available, replace the Gaussian
# approximation with direct numerical integration of the INLA marginal.
lp_marginals <- m_c$marginals.linear.predictor
if (!is.null(lp_marginals) && length(lp_marginals) == nrow(sim)) {
prob_exact <- vapply(
seq_len(nrow(sim)),
function(k) {
p_below <- safe_pmarginal(
q = log(sim$E[k]),
marginal = lp_marginals[[k]]
)
if (is.finite(p_below)) 1 - p_below else NA_real_
},
numeric(1)
)
use_exact <- is.finite(prob_exact)
sim$prob_rr_gt_1[use_exact] <- prob_exact[use_exact]
}
# Final protection against tiny numerical excursions outside [0,1]
sim$prob_rr_gt_1 <- pmin(1, pmax(0, sim$prob_rr_gt_1))
summary(sim$prob_rr_gt_1)## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 0.000000 0.000275 0.371185 0.476365 0.999775 1.000000
## [1] 0
inla.pmarginal() directly on fitted-value marginals and
could fail when a marginal had a non-finite support. The revised version
first computes a stable Gaussian approximation from
summary.linear.predictor; when valid full linear-predictor
marginals are available, it replaces that approximation with direct INLA
marginal integration. Invalid marginals are skipped instead of
terminating the knit.
Map high-probability areas:
map_prob <- jabar |>
left_join(
sim |>
filter(time_id == month_to_show) |>
select(area_id, prob_rr_gt_1),
by = "area_id"
)
map_prob <- map_prob |>
mutate(prob_class = classify_prob(prob_rr_gt_1))
p_prob_cont <- ggplot(map_prob) +
geom_sf(aes(fill = prob_rr_gt_1), color = "white", linewidth = 0.25) +
scale_fill_viridis_c(
option = "B",
limits = c(0, 1),
name = "P(RR > 1)",
labels = scales::label_percent(accuracy = 1)
) +
labs(
title = "Continuous posterior probability",
subtitle = paste("Jawa Barat, period", month_to_show)
) +
workshop_map_theme()
p_prob_class <- ggplot(map_prob) +
geom_sf(aes(fill = prob_class), color = "white", linewidth = 0.25) +
scale_fill_manual(
values = prob_palette,
drop = FALSE,
name = "Evidence interval"
) +
labs(
title = "Evidence classes",
subtitle = "Intervals make posterior evidence easier to communicate"
) +
workshop_map_theme()
p_prob_cont + p_prob_class +
patchwork::plot_annotation(
title = "Posterior Exceedance Probability",
subtitle = "Continuous probability and explicit evidence intervals"
)Do not label an area “high risk” only because the posterior mean exceeds 1. A more defensible statement combines the posterior mean, credible interval, and exceedance probability. For example: “The posterior mean RR is 1.18 and the posterior probability that RR exceeds 1 is 0.94.”
The introductory interaction above is IID. A more structured formulation can let the spatial field evolve through time by using an INLA group model. One practical extension is:
m_adv <- INLA::inla(
y ~ 1 + x +
f(
area_id,
model = "besag",
graph = g_jabar,
group = time_id,
control.group = list(model = "rw1"),
scale.model = TRUE,
constr = TRUE
),
family = "poisson",
data = sim,
offset = log(E),
control.predictor = list(compute = TRUE),
control.compute = list(dic = TRUE, waic = TRUE, cpo = TRUE)
)This should be introduced after participants understand the additive model because its interpretation and computational structure are more demanding.
When reporting results, answer these questions in order:
Team challenge. Choose one modification, rerun the model, and prepare a 60-second explanation.
A good answer should include: model change → posterior change → substantive interpretation → uncertainty.
Watch for these errors:
Each participant submits a short reproducible report containing: