Skip to content

Score cross-validation on the posterior predictive distribution - #12

Draft
goldingn wants to merge 13 commits into
idem-lab:masterfrom
goldingn:posterior-predictive-validation
Draft

Score cross-validation on the posterior predictive distribution#12
goldingn wants to merge 13 commits into
idem-lab:masterfrom
goldingn:posterior-predictive-validation

Conversation

@goldingn

@goldingn goldingn commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Drafted by Claude Code, opened from Nick's account.

Implements the plan in #10, and closes #11. Draft: the code path is complete and tested end to end on a real fold, but the full set of folds has not been re-run. Needs the greta version pin described below.

What changes

Cross-validation previously scored the model on its ability to predict the value of an individual held-out bioassay, with the posterior collapsed to a mean predicted fraction and a mean overdispersion before scoring. An individual bioassay is a noisy, overdispersed measurement of the quantity the model actually targets, so that comparison has an unreachable floor and discards parameter uncertainty entirely. This scores the full out-of-sample posterior predictive distribution instead, and reports calibration alongside accuracy.

New files

file purpose
R/validation_functions.R beta-binomial primitives, randomised quantile residuals, coverage curves, CRPS, reliability bins, the noise floor, aggregated predictive distributions
R/check_validation_functions.R checks of the above against known moments and simulated data
R/estimate_bioassay_rho.R overdispersion estimated from replicate bioassays
R/validation_folds.R the three cross-validation splits, extracted so all models share them
R/null_models.R the two null models, extracted, plus predictive distributions for them
R/validation_covariates.R fold-independent covariate setup, extracted
R/fit_validation_fold.R the dynamical model definition, once, returning posterior draws
R/run_validation_folds.R runs every fold of all three experiments and saves the draws
R/validation_metrics.R scores the saved draws, writes tidy tables
R/fig_predictive_validation.R figures and the results table

predictive_validation.R, dynamic_predictive_validation.R and their outputs are left in place, to be retired once the new path has been run.

Points worth attention in review

The posterior is no longer collapsed before scoring. run_validation_folds.R saves the draws of the predicted fraction and the overdispersion rather than their means. No extra MCMC, but all folds must be re-run, since only means were kept last time.

The predicted fraction and the overdispersion are now drawn together. The earlier code took them from two separate calculate() calls, leaving them unpaired across posterior samples. That did not matter when only their means were used; a predictive distribution needs them coupled.

Overdispersion re-estimated from every replicated pixel-year. It was previously fitted to the six most heavily sampled pixel-year-insecticide combinations. Refitting over all 3,713 replicated groups (9,420 assays) gives 0.155 (0.149–0.161), against 0.195 (0.147–0.254) from the six-group version on current data, and the value of 0.14 hard-coded in betabinom_dev(). Class-specific estimates span nearly a factor of two, so a single pooled value is not adequate:

rho 95% CI assays
all 0.155 0.149–0.161 9,420
Organophosphates 0.216 0.182–0.254 836
Pyrethroids 0.164 0.156–0.171 5,947
Carbamates 0.131 0.113–0.151 1,180
Organochlorines 0.118 0.106–0.131 1,457

The group fractions are integrated out under a beta hyperprior rather than maximised over: with most groups holding two assays, maximising over one fraction per group is subject to the incidental parameters problem and biases rho downwards.

The nulls now carry predictive distributions. Scoring them at a fixed overdispersion while the dynamical model used its own fitted value let a model win on dispersion rather than on prediction. Each null now has a beta posterior on its fraction and an overdispersion fitted to its own training residuals — for the nearest neighbour model on the internal holdout that already selects the number of neighbours, so nothing is tuned on the test fold.

Three copies of the model definition became one. fit_validation_fold.R holds it. Because its greta arrays are function-local, the manual purge_greta_model() between folds is no longer needed. This is the change most worth checking against the original blocks.

The noise floor is in second-moment space, not information space. As set out in #10, the oracle log score varies by 2.4–3.5 nats across the plausible range of p, so it cannot anchor a scale. Mean squared error can, because only p(1-p) is needed and that is recoverable from the observed proportion. Corrected figures, replacing those in the #10 comment (which used rho = 0.14 and a null MSE computed against the overall rather than per-insecticide mean):

noise floor, class-specific rho MSE 0.0201 RMSE 0.142
insecticide-type-mean null MSE 0.0831 RMSE 0.288

so assay noise is 24% of the null's scatter and 76% is in principle explainable.

What is verified

check_validation_functions.R passes 26 checks: the primitives against known moments and the binomial limit, agreement with extraDistr to machine precision and with scoringRules to 1e-16, and the metrics against simulated data where the truth is known — flat for a model whose predictive distribution is the data-generating one, deviating in the expected direction when biased, overconfident or underconfident, and the noise floor recovering the irreducible error.

The extracted fold definitions in validation_folds.R produce splits identical to the originals on master, checked by running both and comparing the fold membership: 6 country folds (1133, 1563, 1638, 913, 1486, 1961 held-out assays), interpolation 24318/1045, forecasting 25900/1461.

The scoring and figure scripts were run end to end against synthetic draws in the saved format, so the path from draws to tables to figures works. Those artefacts were deleted rather than committed, since their content is not real.

greta version

greta 0.6.0 cannot sample a model whose likelihood depends on iterate_dynamic_function() output. Model construction succeeds; the error is raised at mcmc(), which is why it is invisible to anything that only builds the model. Bisected by holding greta.dynamics fixed and moving only greta:

greta greta.dynamics result
0.5.0.9000 (njtierney/greta@4cc989f) db7df31 samples
0.5.0 (CRAN) db7df31 samples
0.5.0.9000 v0.2.2 (e30d9ad) samples
0.6.0 db7df31 fails
0.6.0 0.2.3 (CRAN) fails

Which of two TensorFlow errors appears depends on the greta.dynamics version (enters the loop with shape … or Shape must be rank 3 but is rank 2 … while/Tile), but 0.6.0 fails either way. The same single change flips a fifteen-line example from sampling to failing, so this is not specific to this model, and it is not caused by this branch: master's model definition behaves identically.

packages.R now pins the working combination — tensorflow 2.16.0, njtierney/greta@4cc989f, greta-dev/greta.dynamics@db7df31 — replacing the reference to the greta_2 branch, which no longer exists upstream. On those versions both master's model definition and fit_fold() sample a real validation fold end to end.

Worth reporting upstream; a reprex is ready.

To run

source("R/estimate_bioassay_rho.R")     # minutes; writes outputs/bioassay_rho.csv
source("R/run_validation_folds.R")      # 8 HMC fits: 6 countries, interpolation, forecasting
source("R/validation_metrics.R")        # minutes
source("R/fig_predictive_validation.R")

A 20-iteration test fit of one country fold took about two minutes wall clock on 16 cores, much of it graph construction. The real settings are 8 chains and 3000 iterations, so one fold is worth timing before launching all eight. Folds whose draws already exist are skipped, so the run can be interrupted and resumed, and the first fold is worth checking against the current posterior means before committing to the rest.

Open question

The reliability envelope and the pixel-year aggregation both rest on replicated pixel-years, and those are not fully representative: their mean mortality is 0.685 against 0.744 for singly sampled pixel-years, and the replicated share ranges from 0.21 in Ethiopia to 0.56 in Cameroon. Whether the overdispersion itself differs between sentinel and other sites cannot be tested directly, since estimating it requires replicates. figures/bioassay_rho_representativeness.png records the comparison; how much weight the paper should put on the floor given this is a judgement call.

goldingn and others added 13 commits August 31, 2026 12:59
Core module for scoring the out-of-sample posterior predictive distribution
rather than a point summary of it, per idem-lab#10.

Beta-binomial primitives are written in base R, using the same p/rho
parameterisation as betabinomial_p_rho(), so the validation code carries no
extra dependency and can be checked without a model fit.

Provides randomised quantile residuals, coverage curves, CRPS, reliability
bins, the second-moment noise floor, and aggregated predictive distributions
for pooled groups of assays.

check_validation_functions.R verifies the primitives against known moments and
confirms the metrics behave on simulated data: flat for a model whose
predictive distribution is the data-generating one, and deviating in the
expected direction when biased, overconfident or underconfident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
Bioassays sharing a 5km pixel, year and insecticide are replicate measurements
of one population fraction, so their variation identifies the observation
overdispersion without reference to any spatial model. That makes rho an
external quantity, usable both as a check on the rho estimated inside the
dynamical model and as the noise floor for out-of-sample validation (idem-lab#10).

rho was previously estimated this way from the six most heavily sampled
pixel-year-insecticide combinations. This uses all 3,713 replicated groups
(9,420 assays) and estimates rho by insecticide class.

Group fractions are integrated out by Gauss-Legendre quadrature under a beta
hyperprior rather than maximised over: with most groups holding two assays,
maximising over one fraction per group is subject to the incidental parameters
problem and biases rho downwards.

Estimates: 0.155 overall (0.149-0.161), ranging from 0.118 for organochlorines
to 0.216 for organophosphates, so a single pooled value is not adequate.

Also records how far replicated pixel-years are representative of the rest:
their mean mortality is 0.685 against 0.744 for singly sampled pixel-years,
and the replicated share varies from 0.21 to 0.56 between countries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
The fold definitions and the two null models lived inside
predictive_validation.R, so the dynamical model could only reach them by
sourcing that entire script. Both are now separate, and predictive_validation.R
sources them, so every candidate model is validated against exactly the same
splits (idem-lab#10).

null_models.R also gains predictive distributions for the two nulls. They
previously produced point predictions scored at a fixed overdispersion while
the dynamical model used its own fitted value, which is not a like-for-like
comparison: a model could score better by fitting dispersion better rather than
by predicting better. Each null now carries a beta posterior on its predicted
fraction and an overdispersion fitted to its own residuals on the training
fold, with the nearest neighbour model using the internal holdout that already
selects its number of neighbours, so nothing is tuned on the test fold.

Also fixes rmse(), which squared the mean error rather than the errors and so
measured bias rather than error. Closes idem-lab#11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
dynamic_predictive_validation.R carried three near-identical copies of the
model definition, one per cross-validation experiment, so a change to the model
had to be made in three places. fit_validation_fold.R holds that definition
once, and run_validation_folds.R loops over every fold of all three
experiments.

The substantive change is what is kept. The previous code collapsed the
posterior to a mean predicted fraction and a mean overdispersion before
scoring, discarding parameter uncertainty; the draws themselves are now saved,
so held-out data can be scored against the full posterior predictive
distribution (idem-lab#10). The MCMC itself is unchanged.

The predicted fraction and the overdispersion are now drawn in a single
calculate() call. Drawing them separately, as the earlier code did, leaves them
unpaired across posterior samples; that did not matter when only their means
were used, but a predictive distribution needs them coupled.

Folds are independent and skipped if their draws already exist, so the run can
be interrupted and resumed. dynamic_predictive_validation.R is left in place
until the new path has been run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
validation_metrics.R reads the saved draws and writes tidy tables of per-record
scores and per-experiment summaries; fig_predictive_validation.R reads only
those tables. Scoring is separate from fitting, so metrics can be revised
without refitting.

Three questions are asked of each model, one measure each, rather than a
battery of near-duplicates: coverage of predictive intervals with the
Cramer-von Mises statistic on randomised PIT values, CRPS in mortality units,
and reliability bins with the mean PIT. Scores are also computed on pooled
groups of assays, against the model's own aggregated predictive distribution,
so that heterogeneity in the true fraction within a group is carried by the
model's predictions rather than assumed away.

The main figure leads with the two checks that need no distributional
vocabulary: when the model stated a 95% chance, how often was it right, and
when it predicts a mortality, is that the average outcome. The reliability
diagram carries an envelope showing the scatter bioassay noise alone would
produce. A WHO threshold check reports the same comparison in the terms the
outputs are used in.

validation_metric_eval.R gains over- and under-dispersed scenarios. Mean
squared error is identical for the correct, over- and under-dispersed
predictions (0.0298 in all three), while interval coverage separates them
cleanly (0.950, 0.997, 0.745). That is the case for scoring the distribution
rather than a point summary of it.

fig_internal_validation.R now computes its residuals through the same code
path, from the analytic beta-binomial mixture rather than by simulation
through DHARMa, so in-sample and out-of-sample residuals are directly
comparable and the infinite-value clamping is no longer needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
The beta-binomial primitives and CRPS are written in base R so the validation
code carries no extra dependency. Where extraDistr and scoringRules are
available, check they agree: densities and distribution functions match
extraDistr to machine precision across a range of fractions and
overdispersions, and crps_sample matches scoringRules to 1e-16.

26 checks, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
The beta-binomial density, distribution and sampling functions, and the CRPS,
were written out in base R. Both packages are already project dependencies and
are already used elsewhere in the validation code, so there was no dependency
to avoid, only numerical code to maintain and trust.

What is kept is the reparameterisation: these are thin wrappers supplying the
p / rho form used by betabinomial_p_rho() over the shape parameters extraDistr
takes. The checks correspondingly now test the reparameterisation against known
moments and the binomial limit, rather than comparing two implementations of
the same density.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
…t order

Sampling a model whose likelihood depends on iterate_dynamic_function() fails
under greta 0.6.0 with a tf.while_loop shape error, raised at mcmc() rather
than at model construction. greta 0.5.0 with greta.dynamics 0.2.2 samples the
validation folds correctly; greta 0.6.0 fails with greta.dynamics 0.2.2 as well
as 0.2.3, so the regression is in greta. packages.R now records that pair,
replacing the reference to the greta_2 branch, which no longer exists upstream.

run_validation_folds.R initialises greta's python session before terra and sf
are attached. Those load the system XML libraries, which the conda
environment's pyexpat is then linked against, and tensorflow_probability fails
to import as a result.

With both in place, fit_fold() runs a real fold end to end and the scoring path
consumes its draws.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
An earlier note here claimed greta 0.5.0 with greta.dynamics 0.2.2 samples this
model. That rested on a single run which does not reproduce: re-running the
same script against the same package and python versions now fails.

What is established is that sampling fails wherever the likelihood depends on
iterate_dynamic_function() output, with two distinct TensorFlow errors:
a while_loop shape error under greta 0.6.0, and a while/Tile rank error under
greta 0.5.0. The model definition on master fails identically, so this is not
caused by the validation refactor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
Bisected: holding greta.dynamics fixed at db7df31, greta 0.5.0 samples this
model and greta 0.6.0 does not. The same single change flips a fifteen-line
example from sampling to failing, so it is not specific to this model, and the
error is raised at mcmc() rather than at model construction.

Both errors seen are under greta 0.6.0; which of the two appears depends on the
greta.dynamics version, but 0.6.0 fails either way.

Verified working, for both master's model definition and fit_fold():
tensorflow 2.16.0, njtierney/greta@4cc989f (0.5.0.9000),
greta-dev/greta.dynamics@db7df31 (0.2.2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
How the work divides follows from how greta uses the machine, measured on one
fold of this model. Chains are vectorised into a single TensorFlow op rather
than run one per core, and that op scales poorly beyond about four threads:
8 chains cost 70.6 s per iteration against 6.96 s for 2 chains, with effective
samples per draw unchanged, and confining a fold to 2 threads costs it only
about a fifth of its speed. So the efficient arrangement is few chains and
several folds at once, not many chains on one fold. Four concurrent folds at
2 chains and 2 threads each replaces one fold at 8 chains.

greta exposes no interface for thread count, so it is set through reticulate
before any ops are created; environment variables are ignored.

Sampling now extends to a target effective sample size rather than a guessed
sample count: how many draws are needed per effective sample cannot be known
before the sampler has adapted, so take an initial batch, measure, and top up
with extra_samples(). Each fold records its realised draws per effective
sample, which makes the cost of the remaining folds predictable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
Three faults, all found by running one fold through the real future.callr path
with tiny settings rather than trusting the code to parse:

n_unique_cells was used inside fit_fold but was never one of its arguments. It
worked only because the function had previously been called from a script where
it happened to be a global; in a worker process it is not. It is now an
argument, like the other dimensions.

Workers loaded only greta, so they lacked greta.dynamics for the iteration,
dplyr for the index lookups, and betabinomial_p_rho() from functions.R. They
now load all three. Note they must not initialise python before fit_fold sets
the thread count, since TensorFlow refuses to change it afterwards.

The thread count reached TensorFlow as a double, which it rejects; it is now
coerced to integer.

Null model folds already on disk are skipped, so a resumed run does not redo
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
It was added to the fit_fold() call but not to future.globals, so the workers
still could not see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqju1E6H4tfhJxQ8PpRkPa
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rmse() computes bias, not RMSE

1 participant