Score cross-validation on the posterior predictive distribution - #12
Draft
goldingn wants to merge 13 commits into
Draft
Score cross-validation on the posterior predictive distribution#12goldingn wants to merge 13 commits into
goldingn wants to merge 13 commits into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
R/validation_functions.RR/check_validation_functions.RR/estimate_bioassay_rho.RR/validation_folds.RR/null_models.RR/validation_covariates.RR/fit_validation_fold.RR/run_validation_folds.RR/validation_metrics.RR/fig_predictive_validation.Rpredictive_validation.R,dynamic_predictive_validation.Rand 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.Rsaves 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: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.Rholds it. Because its greta arrays are function-local, the manualpurge_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 onlyp(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):so assay noise is 24% of the null's scatter and 76% is in principle explainable.
What is verified
check_validation_functions.Rpasses 26 checks: the primitives against known moments and the binomial limit, agreement withextraDistrto machine precision and withscoringRulesto 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.Rproduce 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 atmcmc(), which is why it is invisible to anything that only builds the model. Bisected by holding greta.dynamics fixed and moving only greta:njtierney/greta@4cc989f)db7df31db7df31e30d9ad)db7df31Which of two TensorFlow errors appears depends on the greta.dynamics version (
enters the loop with shape …orShape 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.Rnow pins the working combination — tensorflow 2.16.0,njtierney/greta@4cc989f,greta-dev/greta.dynamics@db7df31— replacing the reference to thegreta_2branch, which no longer exists upstream. On those versions both master's model definition andfit_fold()sample a real validation fold end to end.Worth reporting upstream; a reprex is ready.
To run
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.pngrecords the comparison; how much weight the paper should put on the floor given this is a judgement call.