Skip to content

Feat/map laplace uncertainty - #52

Merged
gmermoud merged 10 commits into
fix/widen-priorsfrom
feat/map-laplace-uncertainty
Aug 18, 2026
Merged

Feat/map laplace uncertainty#52
gmermoud merged 10 commits into
fix/widen-priorsfrom
feat/map-laplace-uncertainty

Conversation

@gmermoud

@gmermoud gmermoud commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a Laplace (Gaussian) approximation around the MAP estimate, giving MAP calibration runs an uncertainty band without the cost of full MCMC (map_uncertainty.py, wired into map_param_est.run_map_analysis via laplace_draws).
  • Exclude err_DBH/err_BA/err_Height from the calibration likelihood (bayesian_config.DIAGNOSTIC_ONLY_ERROR_NAMES). 3PG derives DBH from a single stand-level "mean tree" inversion of aWS/nWS, while the ICP observations for DBH/BA/Height are built by summing per-tree allometric equations over each stand's actual size distribution — a mismatch confirmed empirically (~10cm average gap, dominant in 33/34 ICP sites) to be mostly a structural aggregation issue, not a model-fit issue. Fitting those three was trading away real WS/WF/WR accuracy for a target the model can't correctly represent. The proper fix (a distributional DBH model) is tracked in TODO.md.
  • bayesian_comparison_plots.plot_comparison: correct bayesian_label (was hardcoded "PyMC (DEz)" even for MAP runs), a QMD-vs-"3PG-derived-DBH" overlay to visualize the aggregation mismatch, and an optional site_name title with mean RMSE and MAP log posterior.
  • GPU-vmapped multi-start MAP search (map_param_est.batched_map_search): 2000 random restarts run in parallel via jax.vmap+optax.lbfgs, replacing the old sequential CPU restart loop for the exploration phase (still polished by a final pm.find_MAP pass so fit_laplace's Hessian lands on a genuine stationary point).
  • Cluster infrastructure (scripts/run_comparison_site.py, scripts/slurm/map_mcmc_comparison.sbatch) to run a MAP-vs-MCMC comparison across sites on the Dance SLURM partition, one job per site with explicit node pinning (chacha/disco have independent filesystems).
  • Consolidate physiology parameter defaults: param_bound.default and the parameters sheet's species-value column held an independently-editable copy of the same number for every physiology parameter, on every site file (0 divergence verified across all 4 sites tested before touching anything, but Solling's param_bound sheet turned out to already be missing defaults for 22 unbounded params that parameters did have — exactly the drift this was meant to prevent). parameters is now the single source of truth for a physiology parameter's default; param_bound keeps only min/max. Migrated all 39 ICP plot files plus full_solling_data.xlsx to drop the now-redundant column. gradient_descent.py, morris_sensitivity.py, and pymc_icp_plots.py keep their own separate reading for now (untested, out of scope here) — backport tracked in simlab-vs/TrunX#54.

Test plan

  • uv run pytest tests/test_map_uncertainty.py tests/test_bayesian_config.py tests/test_load_files.py tests/test_map_estimation.py tests/test_bayesian_likelihood.py
  • uv run ruff check / uv run ruff format / uvx ty check on all touched files
  • Validated end-to-end on Solling + all 39 ICP plots (4-restart MAP+Laplace batch): total RMSE drops 30%/62%/73% on WS/WF/WR after excluding DBH/BA/Height from the likelihood

Predictive-accuracy and uncertainty-calibration validation of MAP+Laplace (comparing it against full MCMC) is in #58, stacked on top of this branch — that comparison needs a sampler that actually converges on this posterior (DEMetropolisZ doesn't, see #58), so it's reported there rather than here.

@gmermoud
gmermoud changed the base branch from feat/pull-data to fix/widen-priors August 13, 2026 21:08
@gmermoud
gmermoud force-pushed the feat/map-laplace-uncertainty branch from 052d471 to 4c28c19 Compare August 13, 2026 21:12
@gmermoud
gmermoud force-pushed the feat/map-laplace-uncertainty branch 2 times, most recently from d02d2f2 to b106997 Compare August 14, 2026 10:04
@gmermoud

Copy link
Copy Markdown
Contributor Author

Rebased this branch onto its current base (`fix/widen-priors` had picked up the species-dependent prior-widening fix after this branch was created, which the new commits below depend on) and force-pushed. No content lost — verified via patch-id that the previous two commits are byte-identical in diff to what's now replayed on top of the base fix.

Added two commits on top of the existing Laplace-uncertainty work:

  • fix(bayesian): exclude DBH/BA/Height from the calibration likelihood
  • feat(bayesian): visualize the DBH Jensen's-gap in comparison plots

See the updated PR description for details.

@gmermoud

Copy link
Copy Markdown
Contributor Author

@gmgivi this PR features both:

  • a demonstration of GPU-based MAP with many initializations
  • a demonstration of NUTS-based posterior estimation
  • a comparison of Laplace uncertainty estimates vs full posteriors

Probably good to include some of these results in the paper. I will take a look next week.

MAP calibration returned a single mode with no uncertainty. Approximate
the posterior near it by a Gaussian whose covariance inverts the local
Hessian of the log posterior, and emit the result as draws so the
existing MCMC tooling consumes it unchanged.

The approximation is built in PyMC's unconstrained space, expanding the
same objective find_MAP optimises, so it sits at a stationary point and
every back-transformed draw respects the prior bounds. The Hessian
central-differences the exact JAX gradient already exposed through
Run3PGLogLikeOp.

On the Solling stand several parameters optimise onto their prior
bounds, making the MAP a boundary optimum with no local curvature.
Rather than fail, condition on those: hold them at their MAP values and
report the rest. Which ones to drop is decided on the correlation-scaled
precision, since raw curvatures span ten orders of magnitude across
parameters and would otherwise discard well-identified ones.

Claude-Session: https://claude.ai/code/session_01Swe4wEikddjbJELAmukMSA
Cross-check the gradient-based Hessian against one built from log
posterior values alone, and pin the sqrt(n) shrinkage of the standard
errors by replicating the observations, which scales the curvature
exactly without moving the mode.

Claude-Session: https://claude.ai/code/session_01Swe4wEikddjbJELAmukMSA
3PG's compute_dbh derives DBH from a single stand-level "mean tree": it
inverts aWS/nWS on the mean stem biomass per tree, and BA/Height are then
computed from that same DBH. The ICP observations for all three are instead
built by summing per-tree allometric equations over each stand's actual DBH
distribution (create_data_inputs.py) — a distribution-aware aggregation the
model's single-mean-tree inversion cannot match whenever a stand has real
size spread.

Confirmed empirically across 34 ICP plots: comparing the field-measured
quadratic mean diameter against a "3PG-style" DBH obtained by inverting
aWS/nWS on the plot's own observed WS/N (isolating the aggregation effect
from model-fit error) gives an average 10.2cm gap, dominating over a 1.9cm
average model-fit gap in 33/34 sites. Fitting err_DBH/err_BA/err_Height was
therefore pushing the optimizer to trade away real WS/WF/WR accuracy for a
target the model cannot correctly represent.

Add bayesian_config.DIAGNOSTIC_ONLY_ERROR_NAMES and exclude those sigma
priors in run_map_analysis, run_pymc_analysis, and
pymc_icp_plots.run_bayesian_for_plot. DBH/BA/Height are still simulated and
plotted for reference, just no longer part of the fitted likelihood. Across
a 4-restart batch this drops total RMSE by 30% (WS), 62% (WF), and 73% (WR)
over 36 ICP sites.

The proper fix is giving 3PG a real notion of stand size distribution
(e.g. a Weibull shape parameter, 3-PGmix style) instead of a single mean
tree; tracked in TODO.md.
plot_comparison's DBH panel now overlays a "3PG-derived" DBH, obtained by
inverting the file's default aWS/nWS on the plot's own observed WS and N
(stems/ha) — the same mean-tree inversion compute_dbh uses internally, but
fed real field data instead of simulated output. Next to the field-measured
quadratic mean diameter this isolates the Jensen's-gap aggregation mismatch
from model-fit error (see the preceding commit). Needs N, which
prepare_plot_input now retains in the observed sheet (renamed from the raw
stems_n column) purely for this diagnostic — not required for calibration,
so a missing N no longer drops an otherwise-complete observation row.

Also:
- bayesian_label parameter, so the legend/title read "MAP + Laplace" rather
  than the previously hardcoded "PyMC (DEz)" when plotting a MAP run.
- site_name parameter, adding a figure title with the site name, mean RMSE
  over the variables actually calibrated on (excluding
  DIAGNOSTIC_ONLY_ERROR_NAMES), and the MAP log posterior.
Extracts Run3PGLogLikeOp's inline log-likelihood into a standalone
build_loglikelihood_fn, reused by a new batched_map_search that runs
many optax.lbfgs restarts in parallel via jax.vmap instead of PyMC's
sequential, CPU-only n_restarts loop. Wired into run_map_estimation/
run_map_analysis as n_vmap_restarts/n_vmap_steps: the winning restart
still gets polished by pm.find_MAP so fit_laplace sees a genuine
PyTensor stationary point.

Also generalizes plot_comparison's hardcoded "HMC (NUTS)" label to a
hmc_label parameter, so a plain MCMC run can share that overlay slot
with a correct legend for an upcoming MAP-vs-MCMC comparison.
scripts/run_comparison_site.py runs run_map_analysis (vmap-batched MAP
search) then run_pymc_analysis for one site, with param_to_optimize=None
so each file's own bounded parameters are used (some ICP plots leave
aWS/nWS unbounded, unlike the full Solling file FIT_PARAMS was written
for). scripts/slurm/map_mcmc_comparison.sbatch is a 4-task array job on
the Dance partition (chacha+disco H100s), one GPU per site.
The account is capped at 1 GPU at a time on the Dance partition's
standby QoS, and standby jobs are preempted (SIGTERM) whenever a
normal/premium/standard job needs the resources. Without --requeue
a preempted task just dies instead of going back in the queue.
--array=...%1 keeps us within the 1-GPU cap; MCMC checkpoints, so a
requeue only redoes the (fast) MAP+Laplace half.
Progress-bar output otherwise block-buffers once redirected to the
log file, making an actively-computing task look stalled for long
stretches with nothing new in the log.
param_bound.default and the parameters sheet's species-value column
held an independently-editable copy of the same number for every
physiology parameter, on every site file. Verified 0 divergence
across the 3 already-compared ICP sites and full_solling_data.xlsx
before touching anything, but solling's own param_bound sheet turned
out to already be missing defaults for 22 unbounded params that
parameters did have — exactly the drift this was meant to prevent.

parameters (per-species runtime value) is now the single source of
truth for a physiology parameter's default, used both as its fixed
value when not optimized and its MAP/MCMC seed when it is.
param_bound keeps only min/max. error_param is untouched — sigma
priors have no parameters-sheet counterpart, so their default was
never duplicated.

Migrated all 39 ICP plot files plus full_solling_data.xlsx to drop
param_bound's now-redundant default column (data/ is gitignored, so
this only shows up in the code and test diff here). gradient_descent.py,
morris_sensitivity.py, and pymc_icp_plots.py keep their own separate
param_bound/error_param reading for now (untested, out of scope for
this change) — backport tracked in TODO.md.
@gmermoud
gmermoud force-pushed the feat/map-laplace-uncertainty branch from a702fee to ba15c69 Compare August 18, 2026 12:42
@gmermoud
gmermoud merged commit 02a0e07 into main Aug 18, 2026
6 checks passed
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.

1 participant