Skip to contents
Show setup code
library(stLMM)
library(sf)
library(tidyverse)
library(knitr)
library(kableExtra)

source("article-utils.R")

set.seed(1)
theme_set(theme_bw(base_size = 12))

article_id <- "wa-unit-two-stage-car-time-tcc-fiaunit-var"
data_dir <- "wa_data"

1 Purpose

This article keeps the same two-stage CAR-time TCC mean model from the previous unit-response article and changes only the positive-biomass residual variance. The binary stage still models agb_live_mg_ha > 0; the positive stage still models square-root positive agb_live_mg_ha; and the county-year target remains the grid-supported mean of agb_live_mg_ha.

The variance refinement is useful because Washington counties span large biomass gradients, from relatively low-biomass dry interior forests to high-biomass coastal and western forests. A common residual variance asks the positive-biomass model to use one plot-level noise scale across that full range. Allowing the residual variance to differ by FIA unit gives the positive stage a simple way to accommodate broad heteroskedasticity while leaving the occurrence model, mean structure, and aggregation target unchanged.

The extension is

\[ q_j = \delta_0 + \delta_1 x_j + w^+_{a_j,t_j} + r_j,\qquad r_j \sim \operatorname{normal}(0,\tau^2_{u_j}), \]

where \(q_j = \sqrt{y_j^+}\), \(x_j\) is standardized row-level TCC, \(w^+_{a,t}\) is the positive-stage CAR-time process, and \(u_j\) is the FIA unit for plot row \(j\). This is a variance-model refinement, not a new estimand or a new mean structure.

2 Data

Show data-reading code
wa_counties <- read_rds(file.path(data_dir, "wa_counties.rds"))
unit_plots <- read_csv(file.path(data_dir, "wa_unit_plots.csv"), show_col_types = FALSE)
direct_estimates <- read_csv(file.path(data_dir, "wa_direct_estimates.csv"), show_col_types = FALSE)
prediction_grid <- read_csv(
  file.path(data_dir, "wa_unit_prediction_grid_5km.csv"),
  show_col_types = FALSE
)

wa_counties <- wa_counties |>
  mutate(
    county_fips = as.character(county_fips),
    fia_unit = factor(fia_unit)
  )

unit_plots <- unit_plots |>
  mutate(
    county_fips = as.character(county_fips),
    county_fips = factor(county_fips),
    fia_unit = factor(fia_unit),
    year = as.integer(year),
    biomass_positive = as.integer(agb_live_mg_ha > 0)
  ) |>
  arrange(county_fips, year, plot_id)

direct_estimates <- direct_estimates |>
  mutate(
    county_fips = as.character(county_fips),
    year = as.integer(year)
  )

plot_years <- sort(unique(unit_plots$year))
tcc_center <- mean(unit_plots$tcc_mean, na.rm = TRUE)
tcc_scale <- sd(unit_plots$tcc_mean, na.rm = TRUE)

unit_plots <- unit_plots |>
  mutate(tcc_mean_scaled = (tcc_mean - tcc_center) / tcc_scale)

county_levels <- levels(unit_plots$county_fips)
fia_unit_levels <- levels(unit_plots$fia_unit)
fia_unit_names <- unit_plots |>
  distinct(fia_unit, fia_unit_name) |>
  arrange(fia_unit)

prediction_grid <- prediction_grid |>
  mutate(
    county_fips = as.character(county_fips),
    county_fips = factor(county_fips, levels = county_levels),
    fia_unit = factor(fia_unit, levels = fia_unit_levels),
    year = as.integer(year),
    tcc_mean_scaled = (tcc_mean - tcc_center) / tcc_scale
  ) |>
  filter(year %in% plot_years) |>
  arrange(county_fips, year, x, y)

positive_plots <- unit_plots |>
  filter(biomass_positive == 1) |>
  mutate(biomass_sqrt = sqrt(agb_live_mg_ha))

The plot and grid TCC variables are annual point samples from the USDA Forest Service Tree Canopy Cover raster products (USDA Forest Service 2026). This article uses the same plot-based TCC standardization and 5 km prediction support as the previous two-stage CAR-time TCC article.

Show data summary code
data_summary <- tibble(
  quantity = c(
    "plot rows",
    "positive-biomass plot rows",
    "FIA units",
    "5 km prediction-grid rows"
  ),
  value = c(
    fmt_int(nrow(unit_plots)),
    fmt_int(nrow(positive_plots)),
    fmt_int(n_distinct(unit_plots$fia_unit)),
    fmt_int(nrow(prediction_grid))
  )
)

show_table(data_summary, caption = "Unit-response data for the FIA-unit variance model.")
Unit-response data for the FIA-unit variance model.
quantity value
plot rows 6,935
positive-biomass plot rows 3,991
FIA units 5
5 km prediction-grid rows 63,306

3 County Graph

The CAR-time mean structure is unchanged from the previous article.

g <- car_graph(
  wa_counties,
  id = "county_fips",
  island = "nearest",
  island_k = 4
)

g$island_added_edges
   from    to  distance
1 53055 53029  56295.37
2 53055 53035 100453.91
3 53055 53073 100817.65
4 53055 53057 102288.11

4 Fit

Only the positive-biomass stage changes. The binary occurrence stage is the same Bernoulli CAR-time TCC model used in the previous article.

Show MCMC settings
n_samples <- 10000
burnin <- 5000
chains <- 3
n_keep <- 100
posterior_thin <- max(1L, floor((n_samples - burnin) / n_keep))
posterior_sub_sample <- list(start = burnin + 1, thin = posterior_thin)
chain_control <- list(seed = 17, dispersion = 1.5)
warmup_control <- list(batch_length = 25, min_batches = 10)

sqrt_response_sd <- sd(positive_plots$biomass_sqrt, na.rm = TRUE)
occurrence_fit <- stLMM(
  biomass_positive ~
    tcc_mean_scaled +
    car_time(county_fips, year, graph = g, car_model = "leroux"),
  data = unit_plots,
  family = "binomial",
  priors = list(
    beta = normal(mean = 0, sd = 2.5),
    car_time_1 = list(
      sigma_sq = half_t(df = 3, scale = 1),
      rho = uniform(0.01, 0.99),
      phi = uniform(-0.99, 0.99)
    )
  ),
  n_samples = n_samples,
  chains = chains,
  chain_control = chain_control,
  warmup = warmup_control,
  verbose = TRUE,
  n_report = 500
)
biomass_fit <- stLMM(
  biomass_sqrt ~
    tcc_mean_scaled +
    car_time(county_fips, year, graph = g, car_model = "leroux") +
    resid(model = "group", group = fia_unit),
  data = positive_plots,
  priors = list(
    beta = normal(mean = 0, sd = 2 * sqrt_response_sd),
    resid = list(tau_sq = half_t(df = 3, scale = sqrt_response_sd)),
    car_time_1 = list(
      sigma_sq = half_t(df = 3, scale = sqrt_response_sd),
      rho = uniform(0.01, 0.99),
      phi = uniform(-0.99, 0.99)
    )
  ),
  n_samples = n_samples,
  chains = chains,
  chain_control = chain_control,
  warmup = warmup_control,
  verbose = TRUE,
  n_report = 500
)
Show fit summaries
occurrence_summary
stLMM multi-chain summary
  formula: biomass_positive ~ tcc_mean_scaled + car_time(county_fips, year, graph = g, car_model = "leroux")
  chains: 3
  family: binomial
  observations: 6935
  posterior draws per chain: 10000 (5000 used after burn = 5000)
  process terms: 1

Parameters:
                       mean     sd    q2.5   q50.0  q97.5
(Intercept)         -0.1260 0.7428 -1.7529 -0.1138 1.4560
tcc_mean_scaled      2.7041 0.0738  2.5603  2.7035 2.8493
car_time_1_sigma_sq  3.1468 1.0779  1.5006  3.0051 5.7391
car_time_1_rho       0.6125 0.2632  0.1143  0.6133 0.9836
car_time_1_phi       0.9792 0.0102  0.9520  0.9819 0.9897

Chain diagnostics:
                              parameter   rhat effective_size
(Intercept)                 (Intercept) 1.0034     14631.3546
tcc_mean_scaled         tcc_mean_scaled 1.0005      3671.0478
car_time_1_sigma_sq car_time_1_sigma_sq 1.0045       641.7753
car_time_1_rho           car_time_1_rho 1.0030       498.1799
car_time_1_phi           car_time_1_phi 1.0046       861.0874
Show fit summaries
biomass_summary
stLMM multi-chain summary
  formula: biomass_sqrt ~ tcc_mean_scaled + car_time(county_fips, year, graph = g, car_model = "leroux") + resid(model = "group", group = fia_unit)
  chains: 3
  family: gaussian
  observations: 3991
  posterior draws per chain: 10000 (5000 used after burn = 5000)
  process terms: 1

Parameters:
                       mean     sd    q2.5   q50.0   q97.5
(Intercept)          9.5629 0.4624  8.6112  9.5726 10.4463
tcc_mean_scaled      3.9556 0.1446  3.6703  3.9560  4.2403
tau_sq_9            13.4495 0.6813 12.2143 13.4172 14.8609
tau_sq_8            14.2661 0.5797 13.1827 14.2505 15.4449
tau_sq_6            30.4992 1.8060 27.2803 30.4147 34.3475
tau_sq_7            30.3226 1.5755 27.2976 30.3068 33.5593
tau_sq_5            31.4626 1.6167 28.5691 31.3531 34.8669
car_time_1_sigma_sq  3.3403 1.1897  1.5610  3.1316  6.2786
car_time_1_rho       0.4275 0.2135  0.0534  0.4189  0.8420
car_time_1_phi       0.9677 0.0198  0.9166  0.9729  0.9890

Chain diagnostics:
                              parameter   rhat effective_size
(Intercept)                 (Intercept) 1.0015     13887.5301
tcc_mean_scaled         tcc_mean_scaled 1.0001     10739.1176
tau_sq_9                       tau_sq_9 1.0080       668.3089
tau_sq_8                       tau_sq_8 1.0043       634.6008
tau_sq_6                       tau_sq_6 1.0036       635.0644
tau_sq_7                       tau_sq_7 1.0063       687.9759
tau_sq_5                       tau_sq_5 1.0045       696.9316
car_time_1_sigma_sq car_time_1_sigma_sq 1.0028       493.2111
car_time_1_rho           car_time_1_rho 1.0070       287.0937
car_time_1_phi           car_time_1_phi 1.0077       763.7668

The positive-stage summary now includes one residual variance parameter for each FIA unit. The map below shows the corresponding residual standard deviations on the square-root biomass scale.

Show FIA-unit residual SD map code
biomass_draws <- as_samples(biomass_fit, burn = burnin, metadata = FALSE)
resid_var_names <- paste0("tau_sq_", fia_unit_levels)
if (!all(resid_var_names %in% colnames(biomass_draws))) {
  resid_var_names <- fia_unit_levels
}
resid_var_draws <- biomass_draws[, resid_var_names, drop = FALSE]
resid_sd_draws <- sqrt(resid_var_draws)

fia_resid_sd <- tibble(
  fia_unit = factor(fia_unit_levels, levels = fia_unit_levels),
  sd_mean = colMeans(resid_sd_draws),
  sd_lower = apply(resid_sd_draws, 2, quantile, probs = 0.025),
  sd_upper = apply(resid_sd_draws, 2, quantile, probs = 0.975)
) |>
  left_join(fia_unit_names, by = "fia_unit")

fia_resid_map <- wa_counties |>
  select(-fia_unit_name) |>
  left_join(fia_resid_sd, by = "fia_unit")

fia_resid_labels <- fia_resid_map |>
  group_by(fia_unit, fia_unit_name, sd_mean) |>
  summarise(geometry = sf::st_union(geometry), .groups = "drop") |>
  mutate(label = paste0(fia_unit_name, "\n", fmt_num(sd_mean, 2))) |>
  sf::st_point_on_surface()

ggplot(fia_resid_map) +
  geom_sf(aes(fill = sd_mean), color = "grey80", linewidth = 0.15) +
  geom_sf_label(
    data = fia_resid_labels,
    aes(label = label),
    size = 3,
    label.size = 0.12,
    fill = "white",
    alpha = 0.85
  ) +
  coord_sf(expand = FALSE) +
  scale_fill_gradientn(
    colors = stlmm_palette(),
    name = "resid. SD",
    na.value = "grey92"
  ) +
  labs(
    title = "positive-stage residual SD by FIA unit",
    x = NULL,
    y = NULL
  ) +
  theme(
    plot.title = element_text(size = 10, face = "bold", margin = margin(0, 0, 2, 0)),
    plot.margin = margin(0, 0, 0, 0)
  )

Show FIA-unit residual SD table code
show_table(
  fia_resid_sd |>
    transmute(
      `FIA unit` = fia_unit_name,
      `posterior mean SD` = fmt_num(sd_mean, 2),
      `lower` = fmt_num(sd_lower, 2),
      `upper` = fmt_num(sd_upper, 2)
    ),
  caption = "FIA-unit residual standard deviations for square-root positive biomass."
)
FIA-unit residual standard deviations for square-root positive biomass.
FIA unit posterior mean SD lower upper
Puget Sound 5.61 5.35 5.90
Olympic Peninsula 5.52 5.22 5.86
Southwest 5.50 5.22 5.79
Central 3.78 3.63 3.93
Inland Empire 3.67 3.49 3.85

5 Prediction and Aggregation

Prediction is unchanged: draw-wise occurrence predictions are multiplied by squared positive-biomass predictions, then averaged within county-year. The Gaussian positive-stage predictions use y_samples = TRUE, so the squared positive-biomass draws include residual predictive uncertainty before recombination.

Show aggregation helper code
prediction_sample_matrix <- function(pred, sample) {
  as.matrix(as_samples(pred, sample = sample, metadata = FALSE))
}

aggregate_grid_draws <- function(draws, grid, prefix = "") {
  county_year <- grid |>
    distinct(county_fips, county, year) |>
    arrange(county_fips, year)

  county_samples <- matrix(
    NA_real_,
    nrow = nrow(draws),
    ncol = nrow(county_year)
  )

  for (j in seq_len(nrow(county_year))) {
    ii <- which(
      grid$county_fips == county_year$county_fips[j] &
        grid$year == county_year$year[j]
    )
    county_samples[, j] <- rowMeans(draws[, ii, drop = FALSE])
  }

  county_year |>
    bind_cols(
      summarize_draw_matrix(county_samples, prefix = prefix) |>
        select(-prediction_row)
    )
}
occurrence_rec <- recover(
  occurrence_fit,
  sub_sample = posterior_sub_sample
)

biomass_rec <- recover(
  biomass_fit,
  sub_sample = posterior_sub_sample
)
occurrence_pred <- predict(
  occurrence_rec,
  newdata = prediction_grid,
  y_samples = TRUE
)

biomass_pred <- predict(
  biomass_rec,
  newdata = prediction_grid,
  y_samples = TRUE
)
Show direct-estimate join code
direct_for_compare <- direct_estimates |>
  filter(year %in% plot_years) |>
  select(
    county_fips, county, year, n,
    direct_biomass_model, direct_biomass_se_model
  )

county_summary <- county_summary |>
  mutate(county_fips = as.character(county_fips)) |>
  left_join(
    direct_for_compare,
    by = c("county_fips", "county", "year")
  )
Show selected county-year summary code
show_table(
  county_summary |>
    filter(year == 2024) |>
    arrange(desc(theta_mean)) |>
    select(county, n, direct_biomass_model, theta_mean, theta_lower, theta_upper) |>
    slice_head(n = 10) |>
    mutate(
      across(
        c(direct_biomass_model, theta_mean, theta_lower, theta_upper),
        ~ fmt_num(.x, 1)
      )
    ),
  caption = "Highest 2024 county-year posterior predictive means from the FIA-unit variance model."
)
Highest 2024 county-year posterior predictive means from the FIA-unit variance model.
county n direct_biomass_model theta_mean theta_lower theta_upper
Skamania 0 NA 262.0 215.6 309.7
Clallam 3 194.2 232.6 172.2 287.4
Jefferson 2 139.0 203.1 144.9 260.1
Lewis 3 80.3 194.6 158.7 233.4
Skagit 5 183.8 178.7 141.0 214.0
Grays Harbor 3 145.9 176.1 135.1 218.3
Mason 1 NA 162.9 120.6 210.3
Cowlitz 2 0.0 148.5 106.3 192.1
Pacific 1 NA 142.7 94.6 192.5
King 7 140.7 132.5 101.4 168.4
Show county time-series code
profile_counties <- c("Clallam", "King", "Okanogan", "Yakima")

profile_dat <- county_summary |>
  filter(county %in% profile_counties) |>
  mutate(county = factor(county, levels = profile_counties))

ggplot(profile_dat, aes(year, theta_mean)) +
  geom_ribbon(
    aes(ymin = theta_lower, ymax = theta_upper),
    fill = stlmm_color("primary"),
    alpha = 0.15,
    color = NA
  ) +
  geom_line(color = stlmm_color("primary"), linewidth = 0.75) +
  geom_point(
    aes(y = direct_biomass_model),
    color = "grey20",
    size = 1.4,
    alpha = 0.75,
    na.rm = TRUE
  ) +
  facet_wrap(~ county, scales = "free_y", ncol = 2) +
  labs(
    x = "year",
    y = "mean agb_live_mg_ha (Mg/ha)"
  )

Compared with the previous common-variance model, Okanogan is shifted downward in this figure. Okanogan belongs to the Central FIA unit, which has a smaller estimated positive-stage residual variance than the pooled residual variance used in the previous article. Because the positive stage is fit on the square-root scale and predictions are squared to return to Mg/ha, this residual variance contributes to the back-transformed positive-biomass mean. A smaller residual variance therefore lowers the recombined county-year mean even when the fitted mean structure changes little.

The direct estimates remain a point of comparison, not the model target. This pattern shows what the variance refinement is doing to the model-based grid summary; it does not by itself imply that the grouped-variance model is worse because it is farther from some direct estimates.

6 Takeaways

This article changes the residual variance model, not the target or the mean structure. FIA-unit-specific residual variances let the positive Gaussian stage represent different amounts of plot-level spread across broad FIA units while keeping the same occurrence model, TCC effect, and county-time process.

The main diagnostic is whether the grouped residual SDs differ enough to matter and whether the county-year predictions remain consistent with the previous two-stage CAR-time TCC article.

USDA Forest Service. 2026. Tree Canopy Cover Datasets. https://data.fs.usda.gov/geodata/rastergateway/treecanopycover/.