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-bhf-ladder"
data_dir <- "wa_data"

1 Purpose

This article opens the unit-response part of the Washington county biomass series. The response is the plot-level agb_live_mg_ha variable, the same variable used to build the simple county-year direct estimates in the area-response articles.

This is a unit-response workflow: the model is fit to FIA plot rows, predictions are made over a county-year grid, and county-year summaries are computed by averaging posterior predictive draws over grid rows. The article uses a short BHF-style ladder (Battese et al. 1988):

  1. a Gaussian BHF model with row-level TCC, independent county effects, and a shared temporal AR(1) year effect;
  2. a positive-biomass two-stage model with row-level TCC and shared AR(1) year effects in both stages.

2 Working Target

Let \(j\) index an observed FIA plot row, with county \(a_j\) and measurement year \(t_j\). The plot-level response, \(y_j\), is the agb_live_mg_ha column for row \(j\).

Let \(g\) index a prediction-grid row, with county \(a_g\) and year \(t_g\). For this article, the county-year target is the average agb_live_mg_ha response over the grid rows assigned to county \(a\) and year \(t\),

\[ \theta_{a,t} = \operatorname{avg}_{g \in U_{a,t}} \tilde{y}_g, \]

where \(U_{a,t}\) is the county-year prediction support. In this article, the grid defines the aggregation support for the unit-response summaries. The unobserved grid-row values \(y_g\) are represented by model-based predictions \(\tilde{y}_g\).

The direct estimates in the area-response articles estimate the same kind of mean from observed FIA plot rows,

\[ \hat{y}_{a,t} = \frac{1}{n_{a,t}}\sum_{j \in S_{a,t}} y_j, \]

where \(S_{a,t}\) is the set of observed FIA plot rows in county \(a\) and year \(t\), and \(n_{a,t}\) is the number of rows in that set. As in the overview and area-response articles, \(\hat{y}\) denotes a direct estimate and \(\theta\) denotes the county-year target.

The two-stage model keeps the same target by decomposing agb_live_mg_ha into a binary nonzero indicator and a positive magnitude:

\[ y_j = z_j y_j^+,\qquad z_j = \mathbb{1}(y_j > 0). \]

Here \(y_j^+\) is the positive biomass value when \(y_j > 0\). For posterior prediction, the superscript \((m)\) indexes one retained posterior predictive draw. The two-stage draw for grid row \(g\) is

\[ \tilde{y}_g^{(m)} = \tilde{z}_g^{(m)} \tilde{y}_g^{+,(m)}, \]

and the county-year draw is \(\theta_{a,t}^{(m)} = \operatorname{avg}_{g \in U_{a,t}} \tilde{y}_g^{(m)}\). This is the same hurdle-style product used in the spatial-temporal biomass model of May and Finley (2025), with a binary process for nonzero biomass and a transformed Gaussian model for positive biomass.

3 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_10km.csv"),
  show_col_types = FALSE
)

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

unit_plots <- unit_plots |>
  mutate(
    county_fips = as.character(county_fips),
    county_fips = factor(county_fips),
    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)

prediction_grid <- prediction_grid |>
  mutate(
    county_fips = as.character(county_fips),
    county_fips = factor(county_fips, levels = county_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). The model uses the same standardization for plot rows and prediction-grid rows, with the center and scale estimated from the observed plot rows.

The article uses the 10 km prediction grid to keep the opening unit-response ladder compact. Later single-model unit-response articles can use the 5 km grid when the focus is on one model rather than a side-by-side ladder.

Show data summary code
data_summary <- tibble(
  quantity = c(
    "plot rows",
    "counties",
    "measurement years",
    "positive-biomass plot rows",
    "zero-biomass plot rows",
    "10 km prediction-grid rows",
    "plot TCC mean",
    "plot TCC SD"
  ),
  value = c(
    fmt_int(nrow(unit_plots)),
    fmt_int(n_distinct(unit_plots$county_fips)),
    paste(range(plot_years), collapse = "-"),
    fmt_int(sum(unit_plots$biomass_positive == 1)),
    fmt_int(sum(unit_plots$biomass_positive == 0)),
    fmt_int(nrow(prediction_grid)),
    fmt_num(tcc_center, 1),
    fmt_num(tcc_scale, 1)
  )
)

show_table(data_summary, caption = "Unit-response data used in the BHF ladder.")
Unit-response data used in the BHF ladder.
quantity value
plot rows 6,935
counties 39
measurement years 2016-2024
positive-biomass plot rows 3,991
zero-biomass plot rows 2,944
10 km prediction-grid rows 15,858
plot TCC mean 41.4
plot TCC SD 27.9

The binned plot below motivates using TCC as the unit-level auxiliary covariate. It is not the model fit; it summarizes how the observed positive-biomass rate and mean square-root positive biomass change across the observed TCC range.

Show TCC binned-response code
tcc_bin_summary <- unit_plots |>
  filter(!is.na(tcc_mean)) |>
  mutate(tcc_bin = ntile(tcc_mean, 10)) |>
  group_by(tcc_bin) |>
  summarise(
    mean_tcc = mean(tcc_mean),
    positive_biomass_rate = mean(biomass_positive),
    mean_sqrt_positive_biomass = mean(
      sqrt(agb_live_mg_ha[biomass_positive == 1]),
      na.rm = TRUE
    ),
    .groups = "drop"
  ) |>
  pivot_longer(
    c(positive_biomass_rate, mean_sqrt_positive_biomass),
    names_to = "quantity",
    values_to = "value"
  ) |>
  mutate(
    quantity = recode(
      quantity,
      positive_biomass_rate = "positive-biomass rate (proportion)",
      mean_sqrt_positive_biomass = "mean sqrt positive biomass"
    )
  )

ggplot(tcc_bin_summary, aes(mean_tcc, value)) +
  geom_line(color = stlmm_color("primary"), linewidth = 0.75) +
  geom_point(color = stlmm_color("secondary"), size = 1.8) +
  facet_wrap(~ quantity, scales = "free_y", ncol = 1) +
  labs(
    x = "mean TCC within observed decile",
    y = "observed binned response"
  ) +
  theme(panel.spacing = grid::unit(0.45, "lines"))

Show year summary code
year_summary <- unit_plots |>
  group_by(year) |>
  summarise(
    plots = n(),
    positive_biomass_rate = mean(biomass_positive),
    mean_response = mean(agb_live_mg_ha),
    median_response = median(agb_live_mg_ha),
    .groups = "drop"
  )

show_table(
  year_summary |>
    filter(year %in% c(2016, 2018, 2020, 2022, 2024)) |>
    mutate(
      positive_biomass_rate = fmt_num(positive_biomass_rate, 2),
      mean_response = fmt_num(mean_response, 1),
      median_response = fmt_num(median_response, 1)
    ),
  caption = "Selected-year summaries of the `agb_live_mg_ha` response."
)
Selected-year summaries of the `agb_live_mg_ha` response.
year plots positive_biomass_rate mean_response median_response
2016 991 0.60 108.9 39.9
2018 1020 0.59 100.2 36.5
2020 827 0.53 104.6 15.8
2022 837 0.51 84.7 0.6
2024 86 0.51 98.7 9.0

4 Models

The main single-stage model in this article is a Gaussian BHF model with row-level TCC, independent county effects, and a shared AR(1) measurement-year effect:

\[ y_j = \beta_0 + \beta_1 x_j + \alpha_{a_j} + v_{t_j} + e_j, \]

where \(x_j\) is standardized TCC, \(\alpha_a\) is an independent county effect, \(v_t\) follows an AR(1) process over measurement years, and \(e_j\) is residual plot-level variation. The county effect is the BHF area effect. The AR(1) term is common to all counties; it represents shared annual departures, not county-specific time series.

Starting with TCC and a shared AR(1) year effect makes sense for this opening model because the plot rows are not a single cross-section. TCC gives the unit-level model an auxiliary predictor available at both plot and grid rows, while the AR(1) effect adjusts for annual departures shared across Washington. The independent county effect accounts for persistent differences among counties.

That structure is useful for introducing unit-response fitting, prediction, and aggregation, but it is not as flexible as a county-time model. Counties cannot have their own temporally correlated departures beyond the shared year effect and persistent county intercept. The next unit-response CAR-time article will replace this simple structure with a county-year process, so each county can have its own temporal pattern while still borrowing across the county graph.

The two-stage model uses the same temporal idea in both stages. The binary stage models whether agb_live_mg_ha is positive,

\[ z_j \sim \operatorname{bernoulli}(p_j),\qquad \operatorname{logit}(p_j) = \gamma_0 + \gamma_1 x_j + \eta_{a_j} + u_{t_j}, \]

where \(u_t\) follows an AR(1) process over measurement years. For rows with \(y_j > 0\), the positive-biomass stage models the square-root transformed response,

\[ q_j = \sqrt{y_j^+},\qquad q_j = \delta_0 + \delta_1 x_j + \xi_{a_j} + h_{t_j} + r_j,\qquad r_j \sim \operatorname{normal}(0,\tau_+^2). \]

Here \(h_t\) is a second AR(1) year effect for positive biomass. The two stages are fit separately, then recombined draw by draw so the county-year summaries still target the mean of agb_live_mg_ha.

For prediction, squaring returns the positive-stage draw to Mg/ha, so draw \(m\) combines the two stages at prediction-grid row \(g\) as

\[ \tilde{y}_g^{(m)} = \tilde{z}_g^{(m)} \{\tilde{q}_g^{(m)}\}^2. \]

5 Fit

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 = 7, dispersion = 1.5)
warmup_control <- list(batch_length = 25, min_batches = 10)

response_sd <- sd(unit_plots$agb_live_mg_ha, na.rm = TRUE)
sqrt_response_sd <- sd(positive_plots$biomass_sqrt, na.rm = TRUE)

fit_summary_parameters <- list(
  gaussian_ar1 = c(
    "(Intercept)", "tcc_mean_scaled", "tau_sq",
    "iid_1_sigma_sq", "ar1_1_sigma_sq", "ar1_1_phi"
  ),
  positive_ar1 = c(
    "(Intercept)", "tcc_mean_scaled",
    "iid_1_sigma_sq", "ar1_1_sigma_sq", "ar1_1_phi"
  ),
  positive_biomass_ar1 = c(
    "(Intercept)", "tcc_mean_scaled", "tau_sq",
    "iid_1_sigma_sq", "ar1_1_sigma_sq", "ar1_1_phi"
  )
)

select_summary_parameters <- function(x, parameters) {
  keep <- intersect(parameters, rownames(x$parameters))
  x$parameters <- x$parameters[keep, , drop = FALSE]
  if (!is.null(x$diagnostics)) {
    x$diagnostics <- x$diagnostics[x$diagnostics$parameter %in% keep, , drop = FALSE]
  }
  x
}

5.1 Main Gaussian BHF With TCC and Temporal AR(1)

gaussian_ar1_fit <- stLMM(
  agb_live_mg_ha ~ tcc_mean_scaled + iid(county_fips) + ar1(year),
  data = unit_plots,
  priors = list(
    beta = normal(mean = 0, sd = 2 * response_sd),
    resid = list(tau_sq = half_t(df = 3, scale = response_sd)),
    iid_1 = list(sigma_sq = ig(shape = 3, scale = 2 * response_sd^2)),
    ar1_1 = list(
      sigma_sq = half_t(df = 3, scale = response_sd),
      phi = uniform(0.01, 0.99)
    )
  ),
  n_samples = n_samples,
  chains = chains,
  chain_control = chain_control,
  warmup = warmup_control,
  verbose = TRUE,
  n_report = 500
)

gaussian_ar1_summary <- summary(
  gaussian_ar1_fit,
  burn = burnin
) |>
  select_summary_parameters(fit_summary_parameters$gaussian_ar1)

5.2 Positive-Biomass Two-Stage Model With TCC and Temporal AR(1)

positive_ar1_fit <- stLMM(
  biomass_positive ~ tcc_mean_scaled + iid(county_fips) + ar1(year),
  data = unit_plots,
  family = "binomial",
  priors = list(
    beta = normal(mean = 0, sd = 2.5),
    iid_1 = list(sigma_sq = ig(shape = 3, scale = 2)),
    ar1_1 = list(
      sigma_sq = half_t(df = 3, scale = 1),
      phi = uniform(0.01, 0.99)
    )
  ),
  n_samples = n_samples,
  chains = chains,
  chain_control = chain_control,
  warmup = warmup_control,
  verbose = TRUE,
  n_report = 500
)

positive_ar1_summary <- summary(
  positive_ar1_fit,
  burn = burnin
) |>
  select_summary_parameters(fit_summary_parameters$positive_ar1)

positive_biomass_ar1_fit <- stLMM(
  biomass_sqrt ~ tcc_mean_scaled + iid(county_fips) + ar1(year),
  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)),
    iid_1 = list(sigma_sq = ig(shape = 3, scale = 2 * sqrt_response_sd^2)),
    ar1_1 = list(
      sigma_sq = half_t(df = 3, scale = sqrt_response_sd),
      phi = uniform(0.01, 0.99)
    )
  ),
  n_samples = n_samples,
  chains = chains,
  chain_control = chain_control,
  warmup = warmup_control,
  verbose = TRUE,
  n_report = 500
)

positive_biomass_ar1_summary <- summary(
  positive_biomass_ar1_fit,
  burn = burnin
) |>
  select_summary_parameters(fit_summary_parameters$positive_biomass_ar1)
Show fit summaries
gaussian_ar1_summary
stLMM multi-chain summary
  formula: agb_live_mg_ha ~ tcc_mean_scaled + iid(county_fips) + ar1(year)
  chains: 3
  family: gaussian
  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)        97.2751  14.8092    67.8210    97.8262   123.1547
tcc_mean_scaled    96.7532   2.3089    92.2283    96.7539   101.3033
tau_sq          13240.9286 224.2403 12803.6525 13238.9503 13684.1726
iid_1_sigma_sq   3513.6372 819.9498  2271.1592  3394.2842  5431.2728
ar1_1_sigma_sq    183.2147 716.2953     0.1592    37.2177  1347.2310
ar1_1_phi           0.6236   0.2915     0.0567     0.6874     0.9851

Chain diagnostics:
                      parameter   rhat effective_size
(Intercept)         (Intercept) 1.0029      2234.3402
tcc_mean_scaled tcc_mean_scaled 1.0003      3358.9732
tau_sq                   tau_sq 1.0003      1084.0322
iid_1_sigma_sq   iid_1_sigma_sq 1.0002     12249.4579
ar1_1_sigma_sq   ar1_1_sigma_sq 1.0882       767.6725
ar1_1_phi             ar1_1_phi 1.0035       952.1482
Show fit summaries
positive_ar1_summary
stLMM multi-chain summary
  formula: biomass_positive ~ tcc_mean_scaled + iid(county_fips) + ar1(year)
  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.2185 0.4039 -1.0505 -0.2115 0.5209
tcc_mean_scaled  2.6688 0.0729  2.5276  2.6689 2.8097
iid_1_sigma_sq   1.4089 0.3787  0.8455  1.3557 2.3199
ar1_1_sigma_sq   0.3418 0.5592  0.0276  0.1868 1.7232
ar1_1_phi        0.3684 0.2691  0.0212  0.3013 0.9276

Chain diagnostics:
                      parameter   rhat effective_size
(Intercept)         (Intercept) 1.0013      6136.1901
tcc_mean_scaled tcc_mean_scaled 1.0014      1671.5420
iid_1_sigma_sq   iid_1_sigma_sq 1.0009      2549.9206
ar1_1_sigma_sq   ar1_1_sigma_sq 1.0059       875.5614
ar1_1_phi             ar1_1_phi 1.0068      1093.3239
Show fit summaries
positive_biomass_ar1_summary
stLMM multi-chain summary
  formula: biomass_sqrt ~ tcc_mean_scaled + iid(county_fips) + ar1(year)
  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.6660 0.5856  8.5378  9.6732 10.7545
tcc_mean_scaled  3.8453 0.1635  3.5223  3.8457  4.1650
tau_sq          22.5221 0.4987 21.5802 22.5196 23.5511
iid_1_sigma_sq   5.9773 1.5290  3.6989  5.7449  9.5887
ar1_1_sigma_sq   0.2140 0.7575  0.0001  0.0461  1.4380
ar1_1_phi        0.5744 0.2957  0.0431  0.6007  0.9831

Chain diagnostics:
                      parameter   rhat effective_size
(Intercept)         (Intercept) 1.0071      1579.8415
tcc_mean_scaled tcc_mean_scaled 1.0003      5408.5871
tau_sq                   tau_sq 1.0009      1092.7246
iid_1_sigma_sq   iid_1_sigma_sq 1.0001      8936.8664
ar1_1_sigma_sq   ar1_1_sigma_sq 1.0484       874.5624
ar1_1_phi             ar1_1_phi 1.0021       977.7999

The fit summaries focus on fixed effects and variance parameters. County-specific random-effect coefficients are omitted from the printed summaries because there are many of them; they should still be checked in diagnostics when fitting the model for inference.

The single-stage Gaussian model estimates the mean of agb_live_mg_ha directly. The two-stage model separates the zero and positive parts while keeping the same county-year target after draw-wise recombination.

6 Prediction and Aggregation

The 10 km grid rows define the county-year prediction support. For each fitted model, posterior predictive draws are averaged over grid rows within each county-year. The Gaussian predictions use y_samples = TRUE, so the aggregated draws include residual predictive uncertainty before county-year averaging.

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)
    )
}

Structured Gaussian process terms are collapsed during fitting. Models with AR(1) year effects therefore need recover() before prediction.

gaussian_ar1_rec <- recover(
  gaussian_ar1_fit,
  sub_sample = posterior_sub_sample
)

positive_ar1_rec <- recover(
  positive_ar1_fit,
  sub_sample = posterior_sub_sample
)

positive_biomass_ar1_rec <- recover(
  positive_biomass_ar1_fit,
  sub_sample = posterior_sub_sample
)
gaussian_ar1_pred <- predict(
  gaussian_ar1_rec,
  newdata = prediction_grid,
  y_samples = TRUE
)

positive_ar1_pred <- predict(
  positive_ar1_rec,
  newdata = prediction_grid,
  y_samples = TRUE
)

positive_biomass_ar1_pred <- predict(
  positive_biomass_ar1_rec,
  newdata = prediction_grid,
  y_samples = TRUE
)

The summaries below estimate the same series target, but they encode different assumptions about the plot response distribution.

Show summary-combination code
model_summaries <- bind_rows(
  gaussian_ar1_county_summary |> mutate(model = "Gaussian BHF + TCC + AR(1)"),
  two_stage_ar1_county_summary |> mutate(model = "two-stage + TCC + AR(1)")
) |>
  mutate(
    county_fips = as.character(county_fips),
    model = factor(
      model,
      levels = c("Gaussian BHF + TCC + AR(1)", "two-stage + TCC + AR(1)")
    )
  )

direct_for_compare <- direct_estimates |>
  filter(year %in% plot_years) |>
  select(
    county_fips, county, year, n,
    direct_biomass_model, direct_biomass_se_model
  )
Show selected summary table code
show_table(
  model_summaries |>
    filter(year == 2024) |>
    left_join(
      direct_for_compare |> filter(year == 2024),
      by = c("county_fips", "county", "year")
    ) |>
    arrange(model, desc(theta_mean)) |>
    group_by(model) |>
    slice_head(n = 5) |>
    ungroup() |>
    select(model, county, n, direct_biomass_model, theta_mean, theta_lower, theta_upper) |>
    mutate(
      across(
        c(direct_biomass_model, theta_mean, theta_lower, theta_upper),
        ~ fmt_num(.x, 1)
      )
    ),
  caption = "Highest 2024 county-year posterior predictive means by unit-response model."
)
Highest 2024 county-year posterior predictive means by unit-response model.
model county n direct_biomass_model theta_mean theta_lower theta_upper
Gaussian BHF + TCC + AR(1) Skamania 0 NA 267.2 225.7 299.9
Gaussian BHF + TCC + AR(1) Clallam 3 194.2 243.4 208.2 282.5
Gaussian BHF + TCC + AR(1) Jefferson 2 139.0 226.8 188.6 267.2
Gaussian BHF + TCC + AR(1) Lewis 3 80.3 217.3 185.8 248.3
Gaussian BHF + TCC + AR(1) Grays Harbor 3 145.9 165.6 131.2 201.4
two-stage + TCC + AR(1) Skamania 0 NA 246.1 190.5 303.6
two-stage + TCC + AR(1) Clallam 3 194.2 216.4 166.8 273.2
two-stage + TCC + AR(1) Jefferson 2 139.0 194.2 123.7 251.5
two-stage + TCC + AR(1) Lewis 3 80.3 191.7 153.4 236.0
two-stage + TCC + AR(1) Grays Harbor 3 145.9 155.2 111.3 201.6

7 Maps

Show prediction map code
panel_years <- c(2016, 2018, 2020, 2022, 2024)

map_dat <- wa_counties |>
  left_join(
    model_summaries |> filter(year %in% panel_years),
    by = "county_fips"
  )

ggplot(map_dat) +
  geom_sf(aes(fill = theta_mean), color = "grey80", linewidth = 0.12) +
  coord_sf(expand = FALSE) +
  facet_grid(model ~ year) +
  scale_fill_gradientn(
    colors = stlmm_palette(),
    name = "Mg/ha",
    na.value = "grey92"
  ) +
  theme(
    panel.spacing = grid::unit(0.03, "lines"),
    strip.text = element_text(margin = margin(1, 1, 1, 1)),
    plot.margin = margin(0, 0, 0, 0)
  )

8 County Time Series

The time-series figure compares direct estimates with the two unit-response summaries for selected counties. Direct estimates are shown only where the county-year had a model-ready direct estimate.

Show county time-series code
profile_counties <- c("Clallam", "King", "Okanogan", "Yakima")

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

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

ggplot(profile_dat, aes(year, theta_mean, color = model, fill = model)) +
  geom_ribbon(
    aes(ymin = theta_lower, ymax = theta_upper),
    alpha = 0.12,
    color = NA
  ) +
  geom_line(linewidth = 0.75) +
  geom_point(
    data = profile_direct,
    aes(year, direct_biomass_model),
    inherit.aes = FALSE,
    color = "grey20",
    size = 1.4,
    alpha = 0.75
  ) +
  facet_wrap(~ county, scales = "free_y", ncol = 2) +
  scale_color_manual(values = stlmm_discrete_colors(2)) +
  scale_fill_manual(values = stlmm_discrete_colors(2)) +
  labs(
    x = "year",
    y = "mean agb_live_mg_ha (Mg/ha)",
    color = NULL,
    fill = NULL
  ) +
  theme(legend.position = "bottom")

The county panels are a useful check on the unit-response summaries because they show how the single-stage and two-stage models respond to sparse county-year direct estimates. In Yakima, the unit-response summaries are consistently lower than many of the direct estimates. We return to this pattern in the next article, where the model is better matched to the county-time structure in the data and the lower Yakima predictions persist.

9 Takeaways

This ladder starts the unit-response sequence with deliberately simple area effects. The Gaussian TCC plus AR(1) model is the single-stage baseline because it keeps the BHF county effect, uses a grid-available auxiliary covariate, and adds shared temporal borrowing across measurement years. The two-stage model addresses the zero-inflated response while preserving the same series target through draw-wise products. These models are useful starting points for the software workflow, but they are less flexible than the county-time models that follow.

The next unit-response article moves directly to a two-stage county-time CAR model with row-level TCC. A later unit-response article can add FIA-unit-specific residual variance in the positive Gaussian stage, followed by a continuous space-time model.

Battese, George E., Rachel M. Harter, and Wayne A. Fuller. 1988. “An Error-Components Model for Prediction of County Crop Areas Using Survey and Satellite Data.” Journal of the American Statistical Association 83 (401): 28–36. https://doi.org/10.1080/01621459.1988.10478561.
May, Paul B., and Andrew O. Finley. 2025. “Spatial-Temporal Prediction of Forest Attributes Using Latent Gaussian Models and Inventory Data.” Spatial Statistics 69: 100917. https://doi.org/10.1016/j.spasta.2025.100917.
USDA Forest Service. 2026. Tree Canopy Cover Datasets. https://data.fs.usda.gov/geodata/rastergateway/treecanopycover/.