lm instead of
glmnetHow to use this vignette. All interactive code chunks have
eval = FALSEso the vignette can build without running Stan. Copy and run each chunk in sequence in your own R session. Before the first use, generate the bundled dataset by running once from the package root:For real analyses use
chains = 4anditer≥ 2000. The demo below uses 2 chains × 1 000 iterations to keep runtime short.
ErrorTracer addresses a gap in the ecological and
genomic forecasting toolkit: existing Bayesian packages
(brms, rstanarm) do not decompose forecast
uncertainty into its contributing sources, and no R package quantifies
the forecast shelf life — the time horizon at which a credible
interval becomes too wide to be informative.
The package provides a seven-step pipeline:
| Step | Function | What it does |
|---|---|---|
| 1 | extract_priors() / et_priors_split() |
Convert a regularized or standard model to brms
priors |
| 2 | et_fit() / as_et_model() |
Fit the informed Bayesian model (or wrap an existing
brms fit) |
| 3 | et_diagnose() |
Convergence, ESS, LOO-CV |
| 4 | et_predict() |
Posterior prediction + environmental noise / ensemble propagation |
| 5 | decompose_uncertainty() / et_sobol() |
Variance decomposition (param / group / env / residual / temporal) & Sobol indices |
| 6 | shelf_life() / et_shelf_life_pool() |
Forecast horizon / shelf-life analysis & rolling-origin pooling |
| 7 | et_calibrate(), et_pit(),
et_skill_score() |
Coverage, PIT calibration, and CRPS skill against nulls |
Visualisation functions (et_plot_*) return
ggplot2 objects at every step.
We work with et_sim, a list bundled with the package. It
represents a hypothetical mountain plant site where allele-frequency
change on the arcsin-sqrt scale (z_diff)
in two SNP clusters (A and B) is linked to three growing-season climate
predictors (plus ten independent nuisance predictors
d1..d10 with zero true effect).
Note on the arcsin-sqrt transformation. Raw allele frequencies \(f \in [0,\,1]\) are transformed via \(z = \arcsin(\sqrt{f})\). The result is unbounded (theoretically \([0,\,\pi/2] \approx [0,\,1.57]\) for a single chromosome, but changes \(\Delta z\) are unbounded in principle). Do not use
response_scale = c(-1, 1)for this response — instead derive the range from the training data (the default whenresponse_scale = NULL) or use a biologically motivated interval.
| Predictor | Description | Units (raw) |
|---|---|---|
Tmean |
Mean growing-season temperature | °C (standardised) |
PPT |
Total growing-season precipitation | mm (standardised) |
SWE |
Peak snow water equivalent | mm (standardised) |
d1..d10 |
Ten independent nuisance predictors (zero true effect) | standardised |
The dataset has a training period (1920–2019, 100 years per cluster)
and a forecast period (2020–2034, 15 years per cluster). The forecast
climate is warmer than the training period on average (reflecting a mild
warming trend). Because the data are simulated, we know the true
regression coefficients — stored in
et_sim$true_params — which lets us validate parameter
recovery and calibration.
True data-generating model:
\[ z\_diff_t = \alpha + \beta_1 \cdot Tmean_t + \beta_2 \cdot PPT_t + \beta_3 \cdot SWE_t + \varepsilon_t, \quad \varepsilon_t \sim \mathcal{N}(0,\, \sigma^2) \]
| Parameter | Cluster A | Cluster B |
|---|---|---|
| \(\alpha\) (intercept) | 0.00 | 0.10 |
| \(\beta_1\) (Tmean) | +0.50 | +0.30 |
| \(\beta_2\) (PPT) | −0.30 | −0.20 |
| \(\beta_3\) (SWE) | +0.20 | −0.25 |
| \(\sigma\) | 0.30 | 0.35 |
Both clusters have a positive response to warming
(Tmean) and a negative response to precipitation
(PPT). They differ in the direction of their response to
snowpack (SWE) and in residual variability — cluster B is
noisier (σ = 0.35 vs. 0.30), which will be reflected in its wider
forecast intervals and higher shelf-life ratio.
library(ErrorTracer)
library(glmnet) # for elastic net (Suggests)
library(ggplot2)
# Load the bundled simulated dataset
data(et_sim)# Use small MCMC settings for this tutorial.
# In a real analysis: CHAINS = 4, ITER = 2000 (or more).
CHAINS <- 2L
ITER <- 1000L
SEED <- 42Lstr(et_sim, max.level = 2)
#> List of 7
#> $ train :'data.frame': 200 obs. of 16 variables:
#> ..$ year : int [1:200] 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 ...
#> ..$ cluster_id: chr [1:200] "A" "A" "A" "A" ...
#> ..$ Tmean : num [1:200] -1.004 -0.848 -1.092 -1.167 -2.306 ...
#> ..$ PPT : num [1:200] 0.0874 -1.8397 -0.7954 -1.4218 -1.7689 ...
#> ..$ SWE : num [1:200] -0.808 -0.456 0.374 0.674 -0.112 ...
#> ..$ z_diff : num [1:200] -0.784 0.758 -0.448 -0.55 -0.982 ...
#> ..$ d1 : num [1:200] -0.292 1.007 1.819 0.76 0.522 ...
#> ..$ d2 : num [1:200] 0.36787 -0.418078 -1.231054 0.000327 0.565229 ...
#> ..$ d3 : num [1:200] -0.5339 0.0701 1.3292 0.8607 -0.4685 ...
#> ..$ d4 : num [1:200] 1.152 0.0404 -1.5689 0.6012 0.5529 ...
#> ..$ d5 : num [1:200] -0.544 0.608 -1.393 -0.899 -1.315 ...
#> ..$ d6 : num [1:200] 1.626 -0.96 -1.026 -0.369 -0.142 ...
#> ..$ d7 : num [1:200] -0.7066 1.3318 -0.8565 -0.0501 -1.8294 ...
#> ..$ d8 : num [1:200] 0.825 -1.062 1.852 -0.558 -0.894 ...
#> ..$ d9 : num [1:200] 0.468 0.174 1.38 0.704 1.43 ...
#> ..$ d10 : num [1:200] -1.5854 0.0401 -1.0624 -0.1902 1.9787 ...
#> $ forecast :'data.frame': 30 obs. of 15 variables:
#> ..$ year : int [1:30] 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 ...
#> ..$ cluster_id: chr [1:30] "A" "A" "A" "A" ...
#> ..$ Tmean : num [1:30] 0.182 1.039 0.403 1.061 1.205 ...
#> ..$ PPT : num [1:30] 1.901 0.716 1.701 1.214 1.729 ...
#> ..$ SWE : num [1:30] 0.211 -1.494 0.424 0.295 -0.388 ...
#> ..$ d1 : num [1:30] -0.2 2.027 0.823 0.36 -2.083 ...
#> ..$ d2 : num [1:30] 0.37 -1.243 -0.399 0.739 0.765 ...
#> ..$ d3 : num [1:30] -1.8791 -0.6468 2.1209 0.0896 -0.3607 ...
#> ..$ d4 : num [1:30] 1.082 0.763 -1.323 0.131 -0.713 ...
#> ..$ d5 : num [1:30] 0.238 -0.992 1.357 0.975 -1.209 ...
#> ..$ d6 : num [1:30] 0.113 -1.434 0.165 0.261 -0.477 ...
#> ..$ d7 : num [1:30] 0.181 1.301 -1.453 1.361 -0.662 ...
#> ..$ d8 : num [1:30] 0.9298 -0.5732 -1.0838 0.5721 0.0265 ...
#> ..$ d9 : num [1:30] 1.359 -2.132 0.139 1.516 1.979 ...
#> ..$ d10 : num [1:30] -0.1374 -0.0971 -2.0441 -1.1536 2.6844 ...
#> $ validation :'data.frame': 30 obs. of 3 variables:
#> ..$ year : int [1:30] 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 ...
#> ..$ cluster_id: chr [1:30] "A" "A" "A" "A" ...
#> ..$ z_diff : num [1:30] -0.1036 0.0294 0.0176 0.0122 0.1464 ...
#> $ true_params :List of 2
#> ..$ A: Named num [1:5] 0 0.5 -0.3 0.2 0.3
#> .. ..- attr(*, "names")= chr [1:5] "intercept" "Tmean" "PPT" "SWE" ...
#> ..$ B: Named num [1:5] 0.1 0.3 -0.2 -0.25 0.35
#> .. ..- attr(*, "names")= chr [1:5] "intercept" "Tmean" "PPT" "SWE" ...
#> $ env_noise :List of 13
#> ..$ Tmean: num 0.3
#> ..$ PPT : num 0.2
#> ..$ SWE : num 0.15
#> ..$ d1 : num 0
#> ..$ d2 : num 0
#> ..$ d3 : num 0
#> ..$ d4 : num 0
#> ..$ d5 : num 0
#> ..$ d6 : num 0
#> ..$ d7 : num 0
#> ..$ d8 : num 0
#> ..$ d9 : num 0
#> ..$ d10 : num 0
#> $ standardization:List of 3
#> ..$ Tmean: Named num [1:2] 15.75 1.24
#> .. ..- attr(*, "names")= chr [1:2] "mean" "sd"
#> ..$ PPT : Named num [1:2] 108.6 15.9
#> .. ..- attr(*, "names")= chr [1:2] "mean" "sd"
#> ..$ SWE : Named num [1:2] 61.2 20.6
#> .. ..- attr(*, "names")= chr [1:2] "mean" "sd"
#> $ description : chr "Simulated allele-frequency-change (z_diff) for two SNP clusters (A, B) at a mountain plant site. Training: 1920"| __truncated__head(et_sim$train[, 1:6])
#> year cluster_id Tmean PPT SWE z_diff
#> 1 1920 A -1.0043470 0.08739134 -0.8078220 -0.7842248
#> 2 1921 A -0.8476931 -1.83968576 -0.4556305 0.7579456
#> 3 1922 A -1.0917889 -0.79541283 0.3740172 -0.4479463
#> 4 1923 A -1.1665888 -1.42183017 0.6744955 -0.5500296
#> 5 1924 A -2.3059009 -1.76890429 -0.1117260 -0.9822087
#> 6 1925 A -1.5488007 0.11687368 2.6235126 -0.5006221# True coefficients — known because the data are simulated
et_sim$true_params
#> $A
#> intercept Tmean PPT SWE sigma
#> 0.0 0.5 -0.3 0.2 0.3
#>
#> $B
#> intercept Tmean PPT SWE sigma
#> 0.10 0.30 -0.20 -0.25 0.35# Forecast predictors sit above the training range in Tmean (warming trend)
head(et_sim$forecast[et_sim$forecast$cluster_id == "A", 1:5])
#> year cluster_id Tmean PPT SWE
#> 1 2020 A 0.1818561 1.9006600 0.2106824
#> 2 2021 A 1.0392391 0.7155037 -1.4941162
#> 3 2022 A 0.4028048 1.7007184 0.4243661
#> 4 2023 A 1.0613302 1.2142662 0.2947374
#> 5 2024 A 1.2048542 1.7293435 -0.3881971
#> 6 2025 A 0.7650516 0.3841603 -0.8397896ggplot(et_sim$train, aes(x = year, y = z_diff, colour = cluster_id)) +
geom_line() +
geom_point(size = 2) +
labs(
x = "Year",
y = expression(italic(z)[diff]),
colour = "Cluster"
) +
et_theme()# Climate predictors over the full period — warming trend visible in Tmean
all_years <- rbind(
transform(et_sim$train[et_sim$train$cluster_id == "A",
c("year", "Tmean", "PPT", "SWE")],
period = "Training"),
transform(et_sim$forecast[et_sim$forecast$cluster_id == "A",
c("year", "Tmean", "PPT", "SWE")],
period = "Forecast")
)
all_long <- reshape(all_years, direction = "long",
varying = c("Tmean", "PPT", "SWE"),
v.names = "value", timevar = "predictor",
times = c("Tmean", "PPT", "SWE"))
ggplot(all_long, aes(x = year, y = value,
colour = period, group = period)) +
geom_line() +
geom_point(size = 1.8) +
facet_wrap(~ predictor, ncol = 3) +
labs(
x = "Year", y = "Standardised value", colour = "Period"
) +
et_theme()We walk through the complete pipeline for cluster A, then repeat for
both clusters simultaneously using the grouping
argument.
We fit a cross-validated elastic net (alpha = 0.5) on
the training data. The elastic net performs regularised estimation (and
variable selection when nuisance predictors
d1..d10 are included) and provides coefficient
magnitudes that scale the priors in the subsequent Bayesian model.
train_A <- et_sim$train[et_sim$train$cluster_id == "A", ]
x_train <- as.matrix(train_A[, c("Tmean", "PPT", "SWE")])
y_train <- train_A$z_diff
set.seed(42)
cv_fit_A <- glmnet::cv.glmnet(
x = x_train,
y = y_train,
alpha = 0.5 # 0 = ridge, 1 = lasso, 0.5 = elastic net
)
# Coefficient estimates at lambda.min
coef(cv_fit_A, s = "lambda.min")
# 4 x 1 sparse Matrix of class "dgCMatrix"
# lambda.min
# (Intercept) 0.0453
# Tmean 0.4983
# PPT -0.2722
# SWE 0.1683The elastic net closely recovers the signs and magnitudes of all three predictors (true values: Tmean = 0.50, PPT = −0.30, SWE = 0.20).
extract_priors() converts the elastic net coefficients
into a brmsprior object. With the default
shrinkage = "zero", each coefficient’s prior is centred at
zero with SD set to multiplier × |coef| (floored at
min_sd) — a regularising prior whose scale is data-informed
without reusing the point estimate as a prior mean (pass
shrinkage = "estimate" to centre the priors on the fitted
coefficients, or use et_priors_split() to fit the first
stage on a disjoint subset of rows).
prior_spec_A <- extract_priors(
model = cv_fit_A,
lambda = "lambda.min",
multiplier = 2.0, # prior SD = 2 × |elastic-net coef|
min_sd = 0.10 # floor: avoids spike priors on near-zero coefficients
)
print(prior_spec_A)
# ErrorTracer prior specification
# Method : glmnet
# Predictors : 3
# Multiplier : 2
# Min SD : 0.1
# Shrinkage : zero (prior means at 0; regularizing)
# Coefficients:
# Tmean prior mean = 0.0000 sd = 0.9966 (coef = 0.4983)
# PPT prior mean = 0.0000 sd = 0.5444 (coef = -0.2722)
# SWE prior mean = 0.0000 sd = 0.3365 (coef = 0.1683)The prior SDs (1.00 for Tmean, 0.54 for PPT, 0.34 for SWE) reflect
the multiplier = 2 setting — wide enough for the likelihood
to dominate on strong effects while regularising smaller ones.
Design note: The intercept is excluded from the informed coefficient priors, and
intercept_prior_sd = NULL(default) letsbrmsuse its data-aware default (student_t(3, median(y), mad(y))).
et_fit() wraps brms::brm(), attaches the
prior specification, and returns an et_model object.
fit_A <- et_fit(
formula = z_diff ~ Tmean + PPT + SWE,
data = train_A,
priors = prior_spec_A,
chains = CHAINS,
iter = ITER,
warmup = ITER %/% 2L,
cores = CHAINS,
seed = SEED
)
print(fit_A)
# ErrorTracer model (et_model)
# Formula : z_diff ~ Tmean + PPT + SWE
# n obs : 100
# Chains : 2 Iter: 1000 Warmup: 500
# Priors : informed (glmnet, 3 predictors)
# Rhat max: 1.014summary(fit_A)
# === ErrorTracer model summary ===
#
# --- Fixed effects ---
# Estimate Est.Error Q2.5 Q97.5
# Intercept 0.041 0.104 -0.162 0.246
# Tmean 0.666 0.113 0.438 0.888
# PPT -0.487 0.200 -0.909 -0.123
# SWE 0.406 0.206 0.023 0.827The posterior means are shrunk slightly relative to the elastic-net estimates, particularly for PPT and SWE — the likelihood is pulling the estimates toward the true values (Tmean = 0.50, PPT = −0.30, SWE = 0.20). All 95% credible intervals exclude zero for Tmean and PPT; SWE is positive but its lower bound just crosses zero, consistent with the true value being 0.20 with σ = 0.40 and only 20 training observations.
diag_A <- et_diagnose(fit_A, loo = TRUE)
# Convergence
diag_A$convergence
# $rhat_max
# [1] 1.004 # all chains well below 1.05 threshold
#
# $rhat_all_ok
# [1] TRUE
#
# $neff_min
# [1] 0.275 # lowest effective-sample-size ratio
#
# $neff_all_ok
# [1] TRUE # all Neff ratios > 0.10
#
# $n_divergences
# [1] 0 # no divergent transitionsdiag_A$loo[c("elpd_loo", "p_loo", "looic", "n_bad_pareto_k")]
# $elpd_loo
# [1] -14.55
#
# $p_loo
# [1] 4.00 # effective parameters ≈ 4 (intercept, 3 betas); sigma
# # is implicit — consistent with a 4-parameter model
# $looic
# [1] 29.10
#
# $n_bad_pareto_k
# [1] 0 # no influential observations (Pareto k > 0.7)All Rhat < 1.05, zero divergences, and no Pareto-k warnings — the sampler has converged and the posterior is not dominated by any single observation.
et_predict() generates posterior predictive draws and
propagates uncertainty about the predictor values themselves via the
env_noise argument. Each perturbation draw adds Gaussian
noise to the predictor values before computing the linear predictor,
capturing sensitivity to measurement or forecast error in the climate
inputs.
env_noise accepts three forms: - A named list of
scalars — constant noise SD per predictor across all
observations (suitable for short validation windows). - A named
list of vectors of length nrow(newdata) —
time-varying noise, where each observation gets its own
SD. This is the recommended form for GCM projections, where ensemble
spread grows with forecast horizon. - A single scalar —
applied as a fraction of each predictor’s empirical SD.
fcast_A <- et_sim$forecast[et_sim$forecast$cluster_id == "A", ]
pred_A <- et_predict(
model = fit_A,
newdata = fcast_A,
env_noise = et_sim$env_noise, # Tmean = 0.30, PPT = 0.20, SWE = 0.15, d1..d10 = 0
n_draws = 1000L,
ci_levels = c(0.50, 0.80, 0.90, 0.95)
)
print(pred_A)
# ErrorTracer prediction (et_prediction)
# Observations : 15
# Draws : 1000
# CI levels : 0.5, 0.8, 0.9, 0.95
# Interval type : predictive
# Mean var decomposition (across observations):
# Parameter : 0.0040
# Environmental: 0.0264 (MC SE: 0.0014)
# Residual : 0.0870
# Total : 0.1174 (measured predictive variance)
# Closure gap : 0.0001 (Monte Carlo; compare MC SE above)In this dataset the environmental uncertainty is several times larger than the parameter uncertainty — with 100 training observations the regression coefficients are well estimated, so measurement noise in the climate inputs contributes substantially more forecast variance than coefficient imprecision. Residual process noise remains the largest single component.
# 90% credible intervals by forecast year
ci90 <- pred_A$credible_intervals[pred_A$credible_intervals$ci_level == 0.90, ]
ci90$year <- fcast_A$year
head(ci90[, c("year", "lower", "median", "upper", "width")])
# year lower median upper width
# 31 2020 -0.9124320 -0.3710932 0.2328418 1.145274
# 32 2021 -0.4276259 0.1268306 0.6656614 1.093287
# 33 2022 -0.7306874 -0.1616093 0.4136616 1.144349
# 34 2023 -0.2588986 0.2843572 0.8218421 1.080741
# 35 2024 -0.4706744 0.1134329 0.6489682 1.119643
# 36 2025 -0.3844870 0.1803172 0.7336616 1.118149decomp_A <- decompose_uncertainty(pred_A)
head(decomp_A)
# obs_id total_var param_var env_var v_env_mcse residual_var closure_gap
# 1 1 0.1241 0.0061 0.0279 0.0054 0.0894 0.0007
# 2 2 0.1218 0.0049 0.0262 0.0053 0.0894 0.0013
# ...residual_var is constant across observations (for a
Gaussian identity model) — it equals the posterior mean of σ² and
represents process noise that cannot be reduced by better predictors or
more training data. total_var is the measured variance of
the environmentally inclusive posterior predictive draws, and
closure_gap records the Monte Carlo remainder
total_var - (param_var + env_var + residual_var).
The proportional view makes it clear that process noise (residual) accounts for ~80% of total forecast variance, parameter uncertainty for ~12–20%, and environmental measurement noise for ~17–25%. To substantially improve forecast precision for this system, the primary target should be the residual variance — identifying additional drivers of allele-frequency change.
Autocorrelation note. If you fit the model with a residual autocorrelation term (e.g.
et_fit(y ~ x + ar(time = t, p = 1), ...)),decompose_uncertainty()adds a fourth columntemporal_var, the remaindertotal_var − (param_var + env_var + residual_var). Since 1.4.0 it is signed: it is a covariance term, and a negative value is meaningful, since autocorrelated errors can reduce predictive variance relative to an independent-innovation baseline. It captures the autocorrelation-induced spread thatbrms::posterior_predict()accumulates iteratively beyond a single innovation, andresidual_varis then interpretable as the innovation variance rather than the stationary marginal variance.et_plot_decomposition()shows the fourth segment automatically when the column is present.
The shelf life analysis asks: at which forecast year does the 90%
CI width exceed the biologically plausible response scale? Because
z_diff is on the arcsin-sqrt scale (unbounded), we derive
the response scale from the training data (which is also the default
when response_scale = NULL).
# Derive response scale from training data (arcsin-sqrt scale — unbounded)
pr_A <- range(train_A$z_diff) # c(-1.73, 1.57) => width ~ 3.30
sl_A <- shelf_life(
predictions = pred_A,
response_scale = pr_A,
ci_level = 0.90,
threshold = 1.0, # uninformative when CI width >= response scale
time_col = "year",
min_run = 2L, # require >= 2 consecutive exceedances
projection_alpha = 0.05, # require significantly positive slope to project
min_slope_for_projection = 1e-4, # minimum slope magnitude to project
max_extrapolation_factor = 10 # cap: project <= last_year + 10 * window_width
)
print(sl_A)All forecast years are strongly informative (ratio well below the
threshold of 1.0). Because the CI/range ratio neither sustains a
crossing (min_run = 2) nor exhibits a statistically
significant upward slope (projection_alpha = 0.05), the
result is reported as a lower bound
(> 2034): the forecast remains informative across the
entire observed forecast window.
Interpreting the three horizon modes:
Mode When it applies What is returned observedThreshold crossed and sustained for min_runperiods within the windowFirst sustained crossing time projectedNo sustained crossing; significantly positive trend within cap Extrapolated crossing time + se_t_starlower_boundNo sustained crossing; no significant trend, or projection beyond cap > last observed timeIncrease
max_extrapolation_factor(or set it toInf) to allow longer projections if the biological context supports it.
We compare the posterior predictive intervals against the held-out
true values in et_sim$validation.
valid_A <- et_sim$validation[et_sim$validation$cluster_id == "A", ]
cal_A <- et_calibrate(
predictions = pred_A,
observed = valid_A,
response_col = "z_diff",
ci_levels = c(0.50, 0.80, 0.90, 0.95)
)
print(cal_A)
# ci_level nominal observed_coverage n_obs calibration_error
# 1 0.50 0.50 0.60 5 0.10
# 2 0.80 0.80 0.80 5 0.00
# 3 0.90 0.90 1.00 5 0.10
# 4 0.95 0.95 1.00 5 0.05The 80% CI achieves exactly nominal coverage. The 90% and 95% CIs are slightly over-conservative (all 5 held-out values fall inside), which is expected with only 5 validation observations and regularising priors. Coverage estimates based on n = 5 have high sampling variance; the important result is that no CI is systematically under-covering.
et_plot_forecast(
predictions = pred_A,
observed = valid_A,
response_col = "z_diff",
time_col = "year"
)Nested credible-interval ribbons (darker = narrower interval) overlay the posterior median. Red points are the held-out validation values. All five observed values fall within the 95% CI.
Each panel overlays the prior density (grey, wide) with the posterior
density (blue, narrower). A tightly shifted posterior means the data are
informative; a posterior that closely mirrors the prior means that the
likelihood has little leverage on that coefficient. Tmean
shows the largest update, consistent with its largest true effect
size.
Red crosses mark the elastic-net estimates used as prior means. The Bayesian posterior means (blue points) with their 95% CIs show how the likelihood shifted the estimates. For all three predictors the posterior is pulled slightly toward zero relative to the elastic-net estimate, reflecting regularisation by the wide but informative priors.
A key advantage of simulated data is that we can check whether the 95% posterior credible intervals contain the known true values.
post <- brms::fixef(fit_A$fit)
true_A <- et_sim$true_params$A
post_df <- data.frame(
parameter = c("Intercept", "Tmean", "PPT", "SWE"),
true_value = c(true_A["intercept"], true_A["Tmean"],
true_A["PPT"], true_A["SWE"]),
post_mean = post[, "Estimate"],
lower_95 = post[, "Q2.5"],
upper_95 = post[, "Q97.5"]
)
post_df$covered <- with(post_df,
true_value >= lower_95 & true_value <= upper_95)
post_df[, c("parameter", "true_value", "post_mean", "lower_95", "upper_95", "covered")]
# parameter true_value post_mean lower_95 upper_95 covered
# 1 Intercept 0.00 0.041 -0.162 0.246 TRUE
# 2 Tmean 0.50 0.666 0.438 0.888 TRUE
# 3 PPT -0.30 -0.487 -0.909 -0.123 TRUE
# 4 SWE 0.20 0.406 0.023 0.827 TRUEAll four true parameter values fall within their 95% posterior credible intervals. The posterior means for Tmean, PPT, and SWE are consistently above the true values in absolute magnitude — this is typical over-estimation bias with n = 20 and informative priors near the OLS estimate. The bias is small relative to the interval widths, and would shrink with more training data.
In practice you often need to fit the same model to many units (SNP
clusters, species, sites) simultaneously. et_fit() handles
this with the grouping argument, returning an
et_model_list.
# Build one et_prior_spec per cluster from individual elastic nets
prior_list <- lapply(c("A", "B"), function(cl) {
df <- et_sim$train[et_sim$train$cluster_id == cl, ]
x <- as.matrix(df[, c("Tmean", "PPT", "SWE")])
set.seed(42)
suppressWarnings(cv <- glmnet::cv.glmnet(x, df$z_diff, alpha = 0.5))
extract_priors(cv, multiplier = 2.0, min_sd = 0.10)
})
names(prior_list) <- c("A", "B")# One brms model per cluster, returned as et_model_list
fit_all <- et_fit(
formula = z_diff ~ Tmean + PPT + SWE,
data = et_sim$train,
priors = prior_list, # named list: one et_prior_spec per cluster
grouping = "cluster_id",
chains = CHAINS,
iter = ITER,
warmup = ITER %/% 2L,
cores = CHAINS,
seed = SEED
)
print(fit_all)
# ErrorTracer grouped model list (et_model_list)
# Grouping : cluster_id
# Formula : z_diff ~ Tmean + PPT + SWE
# Groups : 2
# Fitted : 2 / 2
summary(fit_all)
# --- Per-group Rhat max ---
# A Rhat max = 1.004
# B Rhat max = 1.007diag_all <- et_diagnose(fit_all, loo = TRUE)
diag_all$summary
# group rhat_ok neff_ok n_divergences elpd_loo n_bad_pareto_k
# 1 A TRUE TRUE 0 -14.55 0
# 2 B TRUE TRUE 0 -17.15 1Cluster B has one observation with Pareto-k > 0.7, which is a mild flag worth investigating (it suggests that observation has an outsized influence on the posterior). With only 20 training points this is not unusual.
pred_all <- et_predict(
model = fit_all,
newdata = et_sim$forecast,
env_noise = et_sim$env_noise,
n_draws = 1000L,
ci_levels = c(0.50, 0.80, 0.90, 0.95)
)
print(pred_all)
# ErrorTracer grouped predictions (et_prediction_list)
# Grouping : cluster_id
# Groups : 2 / 2 predictedCluster B has a systematically larger residual_var,
directly reflecting its higher σ (0.35 vs. 0.30).
# Use the pooled training range across both clusters (arcsin-sqrt scale)
pr_all <- range(et_sim$train$z_diff) # c(-1.73, 1.57) => width ~ 3.30
sl_all <- shelf_life(
pred_all,
response_scale = pr_all,
ci_level = 0.90,
threshold = 1.0,
time_col = "year",
max_extrapolation_factor = 10
)
print(sl_all)Both clusters remain strongly informative across all 15 forecast years. Cluster B’s mean ratio is noticeably higher than cluster A’s, directly reflecting cluster B’s higher residual variance (σ = 0.35 vs. 0.30). Both are well below the uninformative threshold of 1.0, and neither crosses within the 2020–2034 window, so both are reported as lower bounds.
The difference in ratios has a practical implication: under a longer forecast horizon or with larger environmental noise inputs, cluster B would approach the uninformative threshold first — a direct consequence of its higher biological process noise.
The shelf-life analysis becomes most powerful when combined with climate projections from Global Circulation Models (GCMs). The idea is:
A critical refinement is that GCM uncertainty is not
constant — ensemble spread (the disagreement among model runs)
typically grows with the projection horizon. The env_noise
argument now accepts per-row vectors so you can pass
growing noise SDs directly.
# Suppose you have GCM ensemble-mean predictors for 2025-2075,
# standardised using your training-period statistics.
gcm_years <- 2025:2075
gcm_df <- data.frame(
year = gcm_years,
Tmean = ..., # standardised GCM ensemble-mean temperature
PPT = ..., # standardised GCM ensemble-mean precipitation
SWE = ... # standardised GCM ensemble-mean snowpack
)
# GCM ensemble spread (SD across runs) grows roughly linearly with time.
# Here we model spread as starting at the validation-period noise level
# and increasing by ~0.01 SD per year for temperature.
base_year <- 2025
tmean_noise <- 0.30 + 0.01 * (gcm_years - base_year) # 0.30 → 0.80
ppt_noise <- 0.20 + 0.005 * (gcm_years - base_year) # 0.20 → 0.45
swe_noise <- 0.15 + 0.005 * (gcm_years - base_year) # 0.15 → 0.40
# Predict over the 50-year GCM window with time-varying noise
pred_gcm <- et_predict(
model = fit_A,
newdata = gcm_df,
env_noise = list(
Tmean = tmean_noise, # per-row vector, length = nrow(gcm_df)
PPT = ppt_noise,
SWE = swe_noise
),
ci_levels = c(0.90, 0.95),
n_draws = 1000L
)
# Shelf life over the 50-year horizon
sl_gcm <- shelf_life(
pred_gcm,
response_scale = range(train_A$z_diff),
time_col = "year",
max_extrapolation_factor = Inf # allow projection beyond the 50-yr window
)
print(sl_gcm)
# If the 90% CI stays below the plausible range throughout 2025-2075:
# Shelf life : > 2075 (lower bound — all periods informative, no trend)
# If it crosses (say, around 2058):
# Shelf life : ~2058 (observed — threshold first exceeded)The per-row noise means that decompose_uncertainty() now
shows env_var increasing across forecast
years — directly capturing the epistemic uncertainty from the GCM
ensemble spread.
decomp_gcm <- decompose_uncertainty(pred_gcm)
# env_var should be small in 2025 and growing toward 2075 as GCM spread
# accumulates; param_var and residual_var remain roughly constant.
plot(gcm_years, decomp_gcm$env_var,
type = "l", xlab = "Year", ylab = "Environmental variance",
main = "Growing GCM ensemble uncertainty propagated into forecast")Interpreting the result for conservation. If the shelf life exceeds your planning horizon (e.g. “> 2075”), you can state: “Under the [GCM scenario], the 90% credible interval for allele- frequency change remains within the plausible biological range throughout the 50-year planning horizon, supporting its use in long-term conservation genomic forecasting.” If a crossing year is observed (e.g. 2058), you can report the horizon directly and note which uncertainty source (parameter, environmental, or residual) drives the loss of informativeness.
lm
instead of glmnetextract_priors() is not limited to elastic net. With the
default shrinkage = "zero", the lm method
scales prior SDs by coefficient magnitude
(multiplier * |coef|, floored at min_sd); with
shrinkage = "estimate" it uses OLS standard errors
(multiplier * SE) around the OLS point estimates.
lm_fit_A <- lm(z_diff ~ Tmean + PPT + SWE, data = train_A)
prior_lm_A <- extract_priors(
model = lm_fit_A,
multiplier = 2.0,
min_sd = 0.10
)
print(prior_lm_A)
# ErrorTracer prior specification
# Method : lm
# Predictors : 3
# Multiplier : 2
# Min SD : 0.1
# Shrinkage : zero (prior means at 0; regularizing)
# Coefficients:
# Tmean prior mean = 0.0000 sd = 1.0023 (coef = 0.5012)
# PPT prior mean = 0.0000 sd = 0.5490 (coef = -0.2745)
# SWE prior mean = 0.0000 sd = 0.3416 (coef = 0.1708)In this case the OLS and elastic-net estimates on the three true
predictors are very close, so the two prior specifications lead to
nearly identical posteriors. The key advantage of the elastic-net
pathway arises when candidate nuisance predictors
(d1..d10) are included and regularisation is
needed to screen them out.
The standardization slot stores the training-period mean
and SD for each predictor, enabling back-transformation to original
units with unstandardize().
# Standardisation constants used when generating et_sim
et_sim$standardization
# $Tmean
# mean sd
# 15.74798 1.24258
#
# $PPT
# mean sd
# 108.60625 15.94841
#
# $SWE
# mean sd
# 61.23573 20.64495
# A Tmean of +1 SD corresponds to:
unstandardize(z = 1.0,
mu = et_sim$standardization$Tmean["mean"],
s = et_sim$standardization$Tmean["sd"])
# [1] 16.99 ← 1 SD above training mean = 17.0 °CThis vignette has demonstrated the complete ErrorTracer pipeline on simulated data where the ground truth is known:
extract_priors() converted
elastic-net coefficients into brms-compatible priors
(centred at zero by default with shrinkage = "zero" to
avoid prior/likelihood double-use, or at the point estimates with
shrinkage = "estimate").
et_fit() fitted the Bayesian model
for a single cluster and for both clusters simultaneously via
grouping = "cluster_id" (or an existing
brmsfit can be passed directly via
as_et_model()).
et_diagnose() confirmed convergence
(Rhat < 1.05, zero divergences) and adequate model fit (no high
Pareto-k observations for cluster A).
et_predict() generated reproducible
posterior predictive draws and propagated environmental measurement
uncertainty into the credible intervals and total predictive
variance.
decompose_uncertainty() partitioned
measured forecast variance (total_var) into parameter,
environmental, and residual components, identifying residual process
noise as the dominant bottleneck in both clusters.
shelf_life() quantified that
cluster A forecasts are more informative than cluster B, consistent with
cluster B’s higher residual variance. Both clusters remain well below
the uninformative threshold (1.0) across the forecast window — reported
as lower bounds. The three-mode horizon logic (observed /
projected / lower_bound), persistence
requirement (min_run = 2), trend significance test
(projection_alpha = 0.05), and
max_extrapolation_factor cap prevent spurious horizons or
far-future projections when the CI/range trend is flat.
et_calibrate() confirmed
near-nominal posterior predictive coverage on held-out validation
data.
Parameter recovery confirmed that all four true coefficients fell within their 95% posterior credible intervals, validating the pipeline’s statistical integrity.
#> R version 4.6.2 (2026-10-29)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.5 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/blas/libblas.so.3.12.0
#> LAPACK: /usr/lib/x86_64-linux-gnu/lapack/liblapack.so.3.12.0 LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=C
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: America/Chicago
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] ErrorTracer_1.4.2
#>
#> loaded via a namespace (and not attached):
#> [1] digest_0.6.39 R6_2.6.1 fastmap_1.2.0 xfun_0.61
#> [5] cachem_1.1.0 knitr_1.52 htmltools_0.5.9 rmarkdown_2.32
#> [9] lifecycle_1.0.5 cli_3.6.6 sass_0.4.10 jquerylib_0.1.4
#> [13] compiler_4.6.2 rstudioapi_0.19.0 tools_4.6.2 evaluate_1.0.5
#> [17] bslib_0.12.0 yaml_2.3.12 otel_0.2.0 jsonlite_2.0.0
#> [21] rlang_1.3.0