This page is for anyone who wants to build on this project’s code
directly: replicate a model, adapt a formula, or understand a design
choice that isn’t visible from the outside. None of what follows is
necessary to trust the study’s conclusions (see the model
diagnostics page for that); it’s here because getting some of these
models to fit correctly took real, sometimes non-obvious work, and that
work is worth recording rather than quietly smoothing over. This
iterative work on getting the models right was conducted in several
(thoroughly commented) scripts in the inst/scripts/
folder available in the package’s source files. This page summarises
some of the core decisions taken in these scripts.
Iteration and chain conventions
Every model in this project is fit with
fit_brms_model(), a thin wrapper around
brms::brm(). Its iterations argument means
post-warmup draws per chain, not a total divided across
chains. An earlier version of this function divided a total iteration
count by the number of detected CPU cores, which meant the sampler’s
actual per-chain budget depended on whatever machine happened to run it.
That’s not reproducible in any meaningful sense, so the function was
rewritten to take chains, iterations, and
warmup as independent, explicit arguments.
fit_brms_model(
...,
chains = 4,
iterations = 2000, # post-warmup draws PER CHAIN
warmup = 1000,
cores = chains,
adapt_delta = 0.95,
max_treedepth = 10,
seed = 667
)Most models in this project use chains = 6 rather than
the default 4, for extra convergence confidence on the newer,
less-standard model forms (the segmented and floor-group models) at low
extra cost on a machine with enough cores to run them in parallel. This
amounts to 12,000 post-warmup draws per model to estimate its
parameters.
The segmented model’s estimated knot
The estimated-knot segmented model
(tas ~ a + b1 * vviq + b2 * (vviq - k) * step(vviq - k),
fit as a nonlinear brms model with k itself a free
parameter) took two real rounds of debugging before it produced
trustworthy results.
Problem 1: fmin/fmax compile in
Stan but don’t exist in R. The first version of this formula
used fmin(vviq, k) and fmax(vviq - k, 0),
which are valid Stan functions, and the model compiled and sampled
without any error. The problem only appeared afterwards:
brms::loo() and fitted() need to re-evaluate
the non-linear formula in R for post-processing, and R has no
fmin/fmax. The fix was reformulating the hinge
using step() instead, a function that exists and behaves
identically in both Stan and R:
# Does NOT work post-fit (fmin/fmax don't exist in R):
# tas ~ a + b1 * fmin(vviq, k) + b2 * fmax(vviq - k, 0)
# Works in both contexts:
tas ~ a + b1 * vviq + b2 * (vviq - k) * step(vviq - k)Under this re-parametrisation, b1 is the below-knot
slope directly, and b1 + b2 (not b2 alone) is
the above-knot slope — b2 is the change in slope
at the knot, not a segment slope on its own. Worth remembering if you’re
reading the raw coefficients rather than the derived slopes reported on
the model
comparison page.
Problem 2: bad default initialisation. Even after
switching to step(), a small test fit found the sampler
landing on k = -3.39, a knot location outside VVIQ’s actual
range (16-80) entirely, with catastrophic effective sample size. brms’s
default initialisation for non-linear parameters is naive (near zero,
with small random jitter), and zero is nowhere near a plausible knot
location on this scale. The fix was supplying explicit starting values,
centred on sensible guesses rather than left to chance:
segmented_inits <- function() {
list(
b_a = array(mean(all_data$tas)),
b_b1 = array(0),
b_b2 = array(0),
b_k = array(24) # matches earth::earth()'s knot estimate
)
}With good starting values, the same model converged cleanly, and its estimated knot (median 19.5, 95% CI [17.7, 24.1]) was stable whether fit with 200 draws on one chain or the full 6-chain, 2000-draws-per-chain production run, which is a useful cross-check that the fix addressed the real problem rather than just moving it.
The model also needed adapt_delta = 0.99 and
max_treedepth = 15 (both above
fit_brms_model()’s defaults) to fully clear
divergent-transition warnings. Non-linear/hinge models tend to have
trickier posterior geometry than standard linear ones, and this is a
reasonable, low-cost precaution for that model class specifically.
ROPE conventions: contrasts vs. slopes
This project uses two different Region of Practical Equivalence (ROPE) conventions, deliberately, not inconsistently.
For group contrasts (like the floor-group model’s
complete_aphant coefficient),
bayestestR::rope_range()’s default — 0.1 times the SD of
the outcome — is appropriate, since a group contrast is directly
comparable to a mean difference.
For slopes (the vviq coefficient in any
model, or the segmented model’s below/above-knot slopes), that same
convention is not appropriate: a raw slope is expressed “per one unit of
VVIQ,” and VVIQ’s scale (16-80) is arbitrary relative to TAS’s scale.
Comparing a raw slope directly to 0.1 * SD(tas) implicitly
assumes one unit of VVIQ is a standardised step, which it isn’t. Slopes
in this project instead use a Cohen-motivated threshold: a standardised
effect of 0.2 is considered a small-to-noticeable effect, rescaled into
the raw units the slope is actually expressed in:
sd_tas <- sd(all_data$tas)
sd_vviq <- sd(all_data$vviq)
rope_range_slope <- 0.2 * (sd_tas / sd_vviq)This distinction is worth getting right specifically because it’s easy to miss: both quantities look like they should use “the same” ROPE, and only looking closely at what a slope versus a contrast actually represents makes clear why they can’t.
The earth-derived fixed knot
The fixed-knot segmented model (a simpler, non-estimated companion to
the model discussed above) uses a knot location found by
earth::earth(), extracted programmatically rather than
typed in by hand, so the code stays correct if the underlying data ever
changes:
mars <- earth::earth(tas ~ vviq, data = all_data)
# mars$cuts is a matrix of breakpoints used by each hinge term; for a
# single-knot model, all non-zero entries should agree.
vviq_cuts <- mars$cuts[, "vviq"]
knot <- unique(vviq_cuts[vviq_cuts != 0])This fixed-knot model and the estimated-knot model above produce nearly identical fits (LOO elpd difference of -0.09, well within noise), which is a nice cross-validation of both approaches, using two genuinely different methods to arrive at essentially the same answer.
Continuing through the Extended Online Report: this page is a technical reference, linked to from the narrative pages rather than meant to be read start to finish. Return to the model comparison or floor-group model pages, or see model diagnostics for convergence and predictive checks.
