Skip to content

Population inference (ch. 4): the vpop summary-likelihood fit - #88

Merged
jeliason merged 70 commits into
mainfrom
feature/vpop-proposal-ch4-guide
Aug 6, 2026
Merged

Population inference (ch. 4): the vpop summary-likelihood fit#88
jeliason merged 70 commits into
mainfrom
feature/vpop-proposal-ch4-guide

Conversation

@jeliason

@jeliason jeliason commented Aug 6, 2026

Copy link
Copy Markdown
Member

The population-inference stack for chapter 4: the random-effects target theta_i ~ F(theta | mu, omega) fit against cohort summaries, plus the guide that documents it.

Nearly all of this is new surface — vpop/predict.py, fit.py, rows.py, gates.py and their tests, docs/, examples/ — and cannot regress anything main ships. The commits that do touch existing modules were split out into #87 and should land first.

Main pieces

  • eq:post as a NumPyro model: mu, s/u_raw, a/b, beta_raw, log_R, and nothing else.
  • Corpus rows and the two evaluators that must stay distinct — what the source's software computed from its own patients, versus the model's expectation over an n_c-sample.
  • V_B per block, resampled by the registry's blocks; Beta order-statistic kernel for reported quantiles.
  • The width gate: a cloud too narrow to produce the spread a source printed shows as a positive shortfall. A width printed as two quantiles now counts, so the budget stops sampling editorial style.
  • A bounded parameter gets a logit margin in eq:crn, so no width can push a patient outside its bound. mu stays on the log scale, so eq:muprior is untouched.
  • omega's pattern u is shrunk by a prior rather than pinned by a flag.

Note for review: merging main in surfaced one semantic conflict. PR #86's tiny-scale rounding test cross-checked that a bare gamma marginal still admits a log fit; on this branch a gamma without an empirical_log grid is refused by name instead of being silently refitted. The rounding assertions are untouched and the downstream half now asserts the current contract — worth a look, since it is a real behaviour change rather than a test fix.

897 unit tests pass.

jeliason added 30 commits July 24, 2026 13:38
The Chapter 4 guide states the hierarchical VPop design: the three objects
(anchored prior pi, proposal pi-tilde, inferred population (mu, omega)), the
omega eigenbasis, the center-vs-spread target split (across_patient feeds
mu+omega+reachability, center_only feeds mu only), the multi-round re-sim TSNPE
loop, reporting under pi via the self-normalized reweight, and the full-path SBC
gate. Wires it into the docs index (Ch. 4 row + status).

vpop/proposal.py adds the generic spine piece: EigenbasisPopulation (the
log-normal population/proposal in the spread eigenbasis, with an analytic
log_prob that plugs into the importance reweight), widen_on_identified (build
pi-tilde by widening sigma_u on the identified directions), and
reachable_accept_fn (the data-anchored, observable-space truncation). 13 tests.
numpy's default quantile method ('linear', Hyndman-Fan type 7) shrinks the
sample IQR at the n published QSP targets carry: 0.788 of the population IQR
at n=6, 0.848 at n=9, 0.891 at n=12 (measured on a Gaussian population and
reproduced to two decimals on a 2000-patient PDAC cloud at each target's real
n, so it is a property of the estimator and n, not of any population shape).

Any estimator comparing that summary against a POPULATION quantile inherits
the shrinkage as a 15-25% LOW bias on sigma_u_hat at n=6-12, which is the
failure ch. 4's matched footing exists to prevent. Pin 'normal_unbiased' for
the observed anchor and the cohort summary alike (residual bias ~1%), as a
module constant rather than a per-call argument: the requirement is that both
sides use the SAME estimator, and a parameter would let them diverge.

Second fix: resolvability is per-anchor and does not make the anchor VECTOR
Gaussian. Nine deciles from eleven patients all pass the old filter; measured
D^2/k against the asymptotic covariance is 0.56 at n=6-8 and 0.75 at n=9-12
(1.00 is exact), and no bandwidth repairs it. _resolvable_grid now also
enforces n >= 2k, falling back to a symmetric 3-anchor subset with ties broken
outward, so a small-n target keeps spread anchors instead of collapsing to the
median. Three anchors measured calibrated at every spacing from (.25,.5,.75)
out to (.1,.5,.9).

Both constants carry their measurements in the docstrings.
… to take (ch. 4b)

Ch. 4 keeps the model (two covariances, provenance split, matched footing,
discrepancy layer, variance budget) and is demoted to the escalation path; its
amortized NPE estimator is heavier than the data can exercise. Ch. 4b keeps
that model, leads with the misspecification diagnosis the fixed-cloud ESS was
asking for, and fits with an explicit summary likelihood sampled by NUTS.

Ch. 4b's likelihood section is rewritten around two measured results:

- The asymptotic quantile law holds at every n in the PDAC target set, but only
  in log space and only at three anchors below n=20. Escalation case 1 closes.
  The measured D^2/k table ships with the claim.
- The law is now stated in its general copula form, because targets measured on
  the SAME patients have correlated sampling error and the per-target form was
  wrong for 33 of 49 PDAC observables (largest group: ten observables at n=10
  from one GVAX+nivo arm). Factorizing over them counts ten correlated readouts
  as ten independent ones. eta_s does not cover it: a study offset is a shared
  systematic shift, this is shared sampling noise, and omitting the block
  inflates tau_eta_hat so finite-n noise reads as between-study heterogeneity.
  The model is restated per cohort_id.

Also: differentiability recorded as a build requirement rather than a hope (NN
emulator decided); the viability filter's discontinuity relocated to where it
actually bites (variable cohort membership under sorting, not the normalizer)
with the smooth-weight fix; build order gains the block-covariance validation
as a gate on the fit; vpop/diagnostics.py corrected from "to build" to mostly
built.
Ran the check the previous commit's build order calls for: cohorts drawn as
rows of the cloud, so one study's targets are read off the same patients, and
the measured study covariance compared against the copula formula.

The formula is right. Across-observable entries reproduce to within a few
percent (median measured/formula ratio 1.0 over eight multi-target studies).

The magnitude claim in the previous commit was wrong and is corrected. It said
factorizing counts ten correlated readouts as ten independent ones, up to an
order of magnitude of overstated precision. Measured, the model's own
cross-observable dependence is weak (median |rho| = 0.21 across the ten
GVAX+nivo readouts, anchor-vector correlation 0.14 against 0.63 within an
observable), so independence costs about four points of coverage, not an order
of magnitude.

What the dependence IS concentrated in is a handful of near-duplicate pairs:
(cd8_pct, cd8gzmb_pct) at rho = 0.990 and (cd8_fc, cd8gzmb_fc) at rho = 0.994,
with anchor correlations of 0.63 and 0.67. Those are the same pairs
duplicate_observables already flags as a model-structure finding, so the
covariance correction and the misspecification triage point at the same
objects.

Also corrected: the density plug-in is weaker than the earlier table showed.
That table was measured against a smoothed population, which flatters the
sparsity estimator. Against the raw cloud, h=0.035 makes the asserted variance
~18% too large on two heavy-tailed fold-change marginals while staying within
2% elsewhere, so the bandwidth wants a per-observable rule rather than one
declared constant.
Checked the near-duplicate correlations the previous commit called a
model-structure finding. They are not one.

GZMB+ CD8 cells are a subset of CD8 cells, and the model's per-patient GZMB
fraction does vary: log-sd 0.31 to 0.41. It is small next to the level, which
varies with log-sd 2.4 to 2.6. With log X_gzmb = log X_cd8 + log r and the two
roughly independent, rho = (1 + (sigma_r/sigma_L)^2)^-1/2, which is 0.99 at
sigma_L = 2.55 and reproduces the measurement.

So rho is manufactured by the width of the population it is evaluated on, and
the cloud is a generous proposal, not the fitted population: log-sd 2.55 for
cd8_pct_baseline against 1.03 in the published values. At the observed width
the same decomposition gives rho ~ 0.93. Right way round for the
implementation, since C_jl is evaluated at the current phi and so lands on the
fitted population, but it makes the measured block sizes an upper bound.

The screen does find one genuine duplicate: treg_fraction_cd4 and
treg_fraction_cd4_hiraoka2006 are byte-identical columns, one quantity under
two target names. The lesson for duplicate_observables is that a Spearman
screen on levels conflates "same variable" with "same scale, different
fraction".

Also recorded: all 21 target rows carrying a values list store it SORTED, so
per-patient pairing is gone. The covariance block is unaffected (it needs only
the model's copula), but whether the model's cross-observable dependence is
CORRECT is not answerable from the current target data.
The chapter specified the design but not the machinery, so a build starting from
it would make three wrong assumptions.

"Infer all P components of mu and log sigma_u" needed a hard qualifier. 227 of
271 parameters have a pool log-sd of 0.001 -- the simulations never varied them
-- so only ~44 directions carry signal, and measured against the layered omega
prior only 17% of the population's nominal log-variance sits on parameters the
emulator has ever seen move. Along the rest the likelihood is not flat, it is
undefined, and a prior-only report there is an artifact of the simulation design
rather than of the data. Widening the pool is the fix; reporting them as
inferred is not.

The surrogate-error term was presented as a tidy variance contribution without
saying how big it is. Measured on the new NN emulator (held-out R^2 0.984), the
small-n targets where sigma_u is identified are sampling-limited (eps/se 0.56)
and the larger-n targets are surrogate-limited (eps/se 2.6), so 4767 nominal
patients carry the information of 835. Do not quote a target's n as its weight.

Viability is 38%, not a rounding error, so Z(phi) is large and strongly
phi-dependent and its saturation bites inside the range where sigma_u is read.
Records that RestrictionClassifier.score already returns the unthresholded
probability the smooth-weight fix needs, and that its gradient is
piecewise-constant.

Also records the within_cohort trap (any budget CSV predating the omega-layer
fix is not comparable), and PDAC_IL10_tumor_tissue_Herremans2023 as a broken
output extraction rather than a modelling problem.
Two places the simulation design was being reported as a modelling result.

Part I listed five causes of a low prevalence-weighting ESS. It was missing the
one currently measured to be present: the cloud is narrow by construction.
vary_policy pins a non-varying parameter by driving its sigma to ~0, conflating
omega_j = 0 with mu_j known (pdac-build 30967d1), and 227 of 271 parameters have
a pool log-sd of 0.001, so the cloud carries 17% of the population variance the
omega layer specifies. A tilt asked to reproduce the observed SPREAD from a cloud
that concentrated has to strain and pays in ESS.

It goes in as cause 3, ahead of support deficiency, because it is diagnostically
distinct from it (a pinned-narrow cloud can still cover every bin, so
support_deficient stays clean while the ESS collapses) and because it is one
number to check. The end-to-end triage gains it as step 2, flagged as the
exception in both directions: cheapest to check, most expensive to fix, since
the fix is a re-simulation rather than an afternoon.

The variance budget gains a fourth row, "not representable": the share of
tr(Gamma_omega) on parameters the pool never varied. It was being accounted as
"asserted", which reads as a prior choice. The two are not the same claim.
Asserted means the data were silent along that direction, which is correct
Bayesian behaviour and is what a prior is for. Not representable means we never
asked, and no cloud, emulator, or fit built on that pool can say anything about
it. At 83% it is the largest line in the budget and the most over-readable, so
it ships as its own row with omega_supported_frac beside it.

Neither is reachable from the estimator side, which is the point of recording
them here rather than in a fitter's docstring.
The measurements behind targets/anchors.py:QUANTILE_METHOD and
MIN_PATIENTS_PER_ANCHOR, and behind ch. 4b's copula covariance block, existed
only as throwaway scripts. They need rerunning whenever the simulation pool
changes, so they belong in the repo.

Two checks. The diagonal one asks, per observable at that target's real n,
whether the asymptotic quantile law describes the finite-n sampling spread
(Mahalanobis D^2/k against the asserted covariance, plus the finite-n IQR bias
that lands on sigma_u_hat). The block one draws cohorts as ROWS of the cloud so
a study's targets share patients, and compares the copula covariance against the
independence assumption.

Parameterised via argparse with no project paths baked in: cloud npz, targets
CSV, and the column/key names are all options.

Reproduces the session results on the current PDAC cloud: d2/k of 0.97 at n=6-8
and 0.96 at n=9-12 on the quartile grid, IQR ratio 1.00 to 1.04, and the
cross-target blocks including the two near-duplicate pairs at rho 0.99 where
independence visibly degrades (d2k 0.54 copula vs 0.83 independent).
The closed-form alternative to the amortized estimator. The observed data are
per-target quantile anchors at each target's real n; their joint asymptotic
sampling law is available in closed form, so write it down and sample phi with
NUTS instead of training a density estimator on a hyper-proposal.

vpop/summary_likelihood.py
  Cov(Q_j(p_a), Q_l(p_b)) = [C_jl - p_a p_b] / (n f_j f_l), with the
  within-observable branch exact (min(p_a,p_b)) and the cross-observable branch
  a Gaussian copula. bvn_cdf is a 32-node Gauss-Legendre quadrature of the
  d/drho Phi_2 = phi_2 identity under the r=sin(t) substitution: differentiable
  in all three arguments, 8e-9 against scipy.

  StudyBlock stacks every target sharing a cohort_id, so 33 of 49 PDAC
  observables stop being counted as independent observations. sigma_c and
  epsilon stay diagonal (neither is a property of the patient sample).

  The mean model pushes a common-random-number population through a caller-
  supplied differentiable emulator and reads population quantiles off the
  resulting cloud. One forward, one sort, gathers into a precomputed query table.

Two things measured while building it, both of which changed the design:

  freeze_covariance_at. Sigma scales with the population width (f ~ 1/sigma), so
  a live covariance's -0.5 logdet term rewards a narrow population on its own.
  Fitting real n-cohort quantiles from a known Gaussian population, mean
  sigma_hat/sigma_true: 0.878/0.941/0.963 live at n=20/40/120 against
  1.002/0.987/0.979 frozen. Same low bias on the population spread, at the same
  published n, that QUANTILE_METHOD removes on the summary side. Freezing is the
  estimating-equation reading in which Sigma is a weight, and it is what makes
  ch. 4b's own claim about the density plug-in literally true.

  The plug-in's phi_0 barely matters, so there is no loop. On a curved forward
  map at n=20, a phi_0 twice too wide moves phi_hat by 0.14-0.32 of one sampling
  sd and leaves the spread unbiased. Refitting at phi_hat is a diagnostic, not a
  required pass. (Measured on a linear map first, which cannot see this: scaling
  sigma_u scales every f_j identically, so Sigma moves by a constant and the
  argmin does not move at all.)

vpop/population_fit.py
  phi = [mu | log sigma_u | eta_raw | delta_raw | log tau_eta | log tau_delta],
  one unconstrained vector, offsets non-centered. All P components of mu and
  log sigma_u are inferred; the prior answers where the data are silent, and
  spread_movement is the readout that replaces K selection. Pyro NUTS against a
  potential_fn, chosen over NumPyro because the emulator is a torch module and
  the whole phi -> anchors map has to be one autodiff graph.
Measured against the real PDAC target set through the ch. 4b summary
likelihood: 16 of 65 observables produced non-finite anchors and dropped
silently out of the log-scale fit. All 16 were the ci95 branch, and the cause
was not sign but a missing center. They carry target_value = NaN with a
perfectly good positive interval, so the Gaussian fallback computed
NaN + z*sigma. 14 are qualitative mechanistic constraints ("tumour does not
regress: fold change 1 to 5"), which state a RANGE and no point estimate.

State the branches by what a target actually supplies rather than requiring all
three inputs:

  center + both bounds positive  -> two-sided log-sd about the median (unchanged)
  no center, both bounds positive -> geometric midpoint sqrt(lo*hi); a 95%
      interval implies its own center under the same distribution used to
      expand it, and the recovered 2.5/97.5 anchors reproduce the bounds
  center positive, one bound usable -> one-sided log-sd. A CI reported as
      touching zero was otherwise thrown onto the Gaussian branch, where it
      returns negative quantiles for a strictly positive quantity

A signed quantity keeps the linear Gaussian and may legitimately have negative
anchors; those cannot feed a log-scale likelihood and the caller must exclude
them, which is now said in the docstring rather than discovered.

On the PDAC set this takes the ch. 4b fit from 49 usable targets in 24 study
blocks (147 anchor rows) to 65 in 27 blocks (163 rows).
States the model the vpop path implements: log-normal patients with a fixed
correlation matrix and free marginal widths, cohort quantiles as the data, and
an anchor covariance held fixed during the fit. Every term carries a status, so
the six inferred quantities are separable from the inputs at a glance.

Records five things the code does not say out loud:

- Corr(Omega) = R is an input taken from stage 1, not a fitted quantity, and the
  copula behind it covers 65 of 271 parameters and states independence elsewhere
- eta and delta are exactly aliased, because no observable appears in two
  cohorts, so 163 anchor rows overstate the information by a wide margin
- kappa, a per-observable width correction, is what stops a narrow simulator
  being reported as diverse patients; without it only omega can absorb the gap
- the emulator error slot exists in the likelihood and nothing populates it
- the n_biological gate on the omega prior is load-bearing: without it, 145
  epistemic posterior widths would enter as between-patient spread

Also records why the eigenbasis was dropped. It rotated the spread into
directions the measurements resolve, which moved Corr(Omega) by a rank-K update
and left sigma_u with no per-parameter meaning.
The observation block is the cohort, not the target. F is joint over the
readouts a cohort reports, the bootstrap resamples whole patients, and V_c
carries the dependence between readouts of one cohort instead of assuming it
away. Independence across cohorts replaces it as the standing assumption.

Also: E_c defined on the statistics rather than the readouts, with its diagonal
fallback and what that asserts; a flat fit section that holds omega at omega_0
rather than zero, so the bootstrap and the eligibility rule keep working and the
scale rows become a held-out width check; and reported uncertainties entering
through a D_c rho_c D_c split, with a rule for whether a lone reported
uncertainty becomes a D_c entry or a scale statistic.
Adds examples/qsp_scale_mechanism.py, a synthetic 179-species / 271-parameter
sparse reaction network sized from cpp/qsp/ode/QSP_enum.h and
parameters/pdac_priors.csv, with 5 nested trial arms and readouts in the kinds
vpop_marginal_targets.csv reports. examples/toy_population_fit.py now runs
against either it or the 3-species toy via --size small|medium|full.

Model:
- tau_beta fixed at 0.15 rather than sampled. The hyperprior sat over 2 free
  directions, bought no pooling, and was the largest source of divergences
  (24 -> 0 in the flat fit).
- The eq:disc affine map pivots about each study's own level, so kappa is a
  spread term within a cohort. Its lever on the location rows falls from 84 sd
  to 0.7 sd, and b goes from a 2.2 sd miss to within 1 sd. This makes the
  measurement bias study-dependent, at no cost in parameters.
- The flat fit is an optimiser plus a Gauss-Newton covariance rather than NUTS,
  and pins b. On noiseless data the flat fit recovers b exactly, but with real
  rows its only channel is the mean-median gap and 19 parameters against 25
  location rows leaves it 3 sd off. Pinning costs nothing measurable across 6
  data seeds and removes a nuisance mu_hat had to trade against.

Sampler:
- NUTS is seeded with G^-1 from the Gauss-Newton information at the plug-in
  instead of adapting a dense mass matrix, which at 210+ coordinates it cannot
  estimate from warmup. Trees terminate instead of saturating at 1023 steps;
  the small problem's two fits go from 12.5 min to 9 s.

Diagnostics:
- conditioning_report: posterior anisotropy before any MCMC. Shows the a block
  carrying 95% of the Fisher information.
- phi0_sensitivity: rebuilds V_c across Sigma_1 to test whether the plug-in
  matters at all.
- print_pivot_offsets: kappa's residual location lever, per row.
- Build-time warnings for singular V_c blocks and for |dbar| exceeding a
  cohort's sampling noise.
- Emulator loss weighted by measured readout sensitivity, so capacity is not
  spent on species no readout reads.
Adds examples/run_toy_population_full.sbatch and a --no-progress flag so the
NUTS progress bar does not fill a scheduler log with carriage returns.

The job does not install the package. Both example modules import only jax,
numpyro, diffrax, numpy and scipy, while qsp-inference itself depends on maple
over git, which a compute node generally cannot fetch. uv supplies its own
Python so the job does not depend on the cluster's default interpreter.

No account or path is baked in: the allocation is passed to sbatch and the job
runs from SLURM_SUBMIT_DIR.
Under a scheduler the progress bar goes to stderr and stdout is a file, so the
log was silent through the ~18 minutes of setup before NUTS starts, which is
indistinguishable from a hung job. Phase markers carry a wall clock and elapsed
minutes, so each stage can be costed from the log alone.

stderr now goes to its own file, which means the progress bar can be kept rather
than silenced: .out is readable prose, .err carries a live ETA.
Both are pure functions of the design and the seed and neither depends on the
data, yet at QSP scale they are the bulk of the wall clock: the emulator is
~102,000 ODE solves plus 10,000 Adam steps, and eq:Ec is 225,000 solves per
build, done at least twice a run.

Keyed by a content hash over the exact array bytes of every input, so a hit
means the inputs are identical rather than similar. The V_c key includes a hash
of the trained emulator weights themselves rather than the config that produced
them, so a cached V_c can never be paired with a different surrogate.

--no-cache forces a rebuild; --cache-dir moves it.
The XLA CPU backend does not automatically thread a vmapped diffrax solve, so
the emulator build ran at 4.4 cores of 24 and took 15.5 minutes on a batch node.
Host devices are the only axis JAX parallelises well on CPU, so the true-ODE
cloud is now pmapped across them, with padding for batches that do not divide
evenly and a vmap fallback for small ones. Verified bit-identical to vmap.

Only the true ODE is wrapped. The emulator's cloud is called inside the NUTS
gradient, where pmap overhead and nested tracing would both cost more than they
save.

The job script now asks for one host device per core and one thread per device,
and runs 4 chains so that sampling occupies 4 devices rather than 2.
…endezvous

At 32 devices on 32 cores the emulator build died with an all-reduce rendezvous
timeout: 'Expected 32 threads to join the rendezvous, but only 29 arrived on
time'. XLA's CPU collectives need every device thread co-scheduled inside 40
seconds, which one device per core does not reliably achieve.

Eight devices leaves roughly four cores of headroom each. Throughput is lower
than the theoretical maximum, but the emulator and V_c are cached, so their
build is a one-time cost.
print_width_shortfall still reached for samples['b'] unconditionally. Pinning b
removed that key, so the full-size run died with KeyError: 'b' immediately after
printing the flat recovery table. summarise_recovery and print_readout_effects
were guarded; this consumer was missed.

With b pinned the shortfall is predicted at kappa = 1, which is exactly what the
flat fit assumed.
JAX dispatches asynchronously, so mcmc.run returns once the work is queued. The
full-size run printed 'NUTS done in 17.5s' while the chains were still at 0/1000
and sampled for a long time afterwards, which made a running job look finished
and a phase marker report a completion that had not happened.

Blocks on get_samples before stopping the clock.
The mass matrix was built from the Gauss-Newton information at the prior centre,
where a = b = 0. That is not where the sampler lives: a ends up pinned to about
1/849 of its prior width. At full size the symptom was a straggler -- three
chains finished 1000 iterations in ~70 minutes at ~4 s/it while the fourth
managed 150 at 31 s/it, and numpyro writes nothing until every chain finishes.
A frozen metric offers no per-chain recovery, so a chain that lands where the
plug-in curvature fits badly stays badly conditioned. population_map locates the
mode by Adam and the metric is linearised there.

print_dbar_absorption tests the draft's claim that gamma_r absorbs the
emulator's systematic offset. gamma = Z a is indexed by readout while dbar is
per row, so only the component in the column space can be taken. Measured on the
small problem, 0.39 to 0.41 of dbar lies in span(gamma, kappa) and the remaining
0.92 is absorbed by nothing and lands on mu. The claim does not hold.
Both full-size runs produced exactly one chain doing 24-35 s/it while its
siblings did 4-8, and a different chain each time. Linearising the metric at the
mode tightened the fast chains from an 11x spread to 1.13x but did not remove
the straggler, which points at initialisation rather than curvature:
init_to_median disperses chains across a 572-dimensional prior, and a frozen
metric tuned for the mode does not fit where a distant chain starts.

The MAP is already computed for the metric, so reusing it as the init is free.

Cost: chains no longer start over-dispersed, so r_hat loses power to detect a
missed mode. Accepted deliberately -- numpyro writes nothing until every chain
finishes, so one straggler currently costs the whole run.
A source that prints one uncertainty can tell you how precisely it pinned
its own statistic, or how spread out its patients were. Not both. It now
always goes to width, because with M empty the scale rows are all omega
has, while sampling noise comes free from the bootstrap on every row.

So U leaves V_c and becomes an "se" row of T_hat: the log sampling
standard error of a median, predicted at phi. eq:Vsplit collapses to
V_c = V_boot + E_c, since scales and correlations now both come from the
bootstrap and the old split reconstructed V_boot exactly.

Estimator is hybrid, closed form above n_c = 20 and a frozen-design
bootstrap below it, where the asymptotics are poor. Cost runs with n_c,
so bootstrapping only the small cohorts measured 1.12x the gradient at
QSP scale against 2.7x for bootstrapping everywhere. The resample design
is stored as uniforms rather than indices because the flat fit runs a
smaller cloud, and an out-of-range JAX gather clamps rather than fails.
The same estimator generates the data and predicts it, so the toy tests
the mechanism rather than estimator mismatch.

V_c now rests entirely on the predicted cloud, so print_width_gate runs
before the fit. It reports the width shortfall at phi_0 against the prior
sd of s + b_1, which is the reachable question; a bare shortfall is
expected whenever s* > 0 and says nothing on its own.

Drops the "synthesized" concept entirely. Reconstruction error is a
different quantity from a reported spread and belongs in V_c as an
additive term, not as a scale override; it is not built.

V_c's cache key gains the cohort stats. Keyed on names alone, adding an
se row would have silently paired the new table with a stale V_c.

Measured on the small problem: two se rows tighten (s+b_1)/sqrt2 by about
6% and leave (s-b_1)/sqrt2 untouched, which is the predicted behaviour,
since kappa scales a standard error exactly as it scales an IQR.
The old test asked whether the mechanism CAN reproduce a column of Z's
row effect, by projecting onto col(d tau / d(mu, omega)) and reading the
residual. Once the mechanism spans row space that is answered yes by
arithmetic: at P >> K the residual is zero for every column whatever Z
is, so passing it says nothing. At full scale it returned 0.000 across
the board.

Ask the price instead. Among mechanism moves reproducing the effect,
take the smallest in prior units,

  delta* = argmin delta^T Sigma^-1 delta  s.t.  J_mech delta = W e_j,

which in prior-standardised coordinates is the minimum-norm solution, so
||delta*|| reads directly in prior standard deviations. Large means only
an implausible excursion imitates that column and it is identified
against the mechanism; small means confounded. The target is a
one-prior-sd move on the coefficient, so the ratio is scale free.

Reports the unreachable fraction beside it. Before saturation the
mechanism cannot span row space and part of a column is unreachable
outright, which is a stronger verdict than an expensive one. Effective
rank and the retained singular value range are printed too, since the
minimum-norm solution loads on the weakest retained direction and the
cost should be readable as a consequence rather than taken on trust.

conditioning_report now hands back its standardised blocks so this reuses
that Jacobian. At QSP scale it is a forward-mode pass over 572
coordinates and the slowest step in the script.
Cohorts that share patients were asserted independent, with merging
them named as the fix. Merging is not generally available: a trial
contributing 16 evaluable baseline biopsies against day-21 arms of 9
and 10 has neither a containment nor a common n.

So eq:obs is now block diagonal by block, where a block is the
connected component of cohorts joined either by a row built from both
or by shared people. The two relations need opposite draws, disjoint
arms resampled independently and shared patients resampled once from
the block's set, so which one joined a pair is kept and not just the
partition. V, E and the flat likelihood follow the same index; a
one-cohort block recovers what was written before.

What remains asserted is that no two publications overlap. The
within-block correlation is predicted at phi_0 rather than measured,
since a study printing order statistics at two timepoints does not
print their joint, and where per-patient values survive digitisation
V_boot can come from the reported patients instead.
resampling.py turns covariance_blocks' partition into a draw recipe. A counted
PatientBlock becomes one joint draw whose strata say which patients each member
cohort sees; every other cohort draws alone. Uncounted blocks cannot be drawn and
are reported instead, so a caller cannot take V without the caveat.

covariance.py reindexes the toy's bootstrap_V / emulator_E / assemble_V / subset_V
from cohort to block. bootstrap_V takes rows_fn(cohort_id, indices) so the cloud
and the readouts stay with the caller.

On the PDAC registry: 24 blocks over 29 cohorts, li2022 drawing 19 patients
jointly at the declared 6/10/0 overlaps. Median rows off that draw correlate 0.43
between baseline and arm A (6 shared), 0.74 between baseline and arm B (10 shared,
arm B inside baseline) and 0.007 between the arms (none).

emulator_E takes precomputed surrogate-minus-simulator differences. At QSP scale
those must come from held-out pool rows, so it waits on the new emulator.
eq:smoothq in the mass form: Beta(kappa, n-kappa+1) integrated over each cloud
member's slice of [0,1], which is exact and sums to one without normalising,
rather than the density sampled at the midpoint.

tau is E[what the cohort printed], not the population quantile. Verified against
simulated 16-samples for normal, Gumbel and t(3) clouds at p = 0.25/0.5/0.75, so
the shape-independence of the order-statistic law is exercised rather than
assumed.

The order statistic an estimator picks is a convention nobody records: type 7
(R, numpy) targets rank 0.30 for a lower quartile at n=9 where type 6 (SPSS)
targets 0.25, some 0.4 sampling SE apart. Declared per call, defaulting to type 7.
jeliason added 28 commits August 4, 2026 00:41
Scaling column j of L_R' by omega_j commutes with the row sum, so
z (L_R' * omega) is (z L_R') * omega and the matmul is phi-free. It was
being recomputed every gradient: (N,P) x (P,P) at P=271.

Cuts 2.59e10 flops to 2.00e10 but only 490 ms to 468 ms, because the
step is bound by traffic over the (N,P) array rather than by the
arithmetic on it, and that traffic is unchanged. Kept for being exact
and free, not for the wall clock.
Data drawn at phi* are recoverable in principle: z is frozen and shared
between the draw and the fit, so tau's Monte Carlo displacement cancels.
Adam rather than a quasi-Newton method, since most directions are flat
and a curvature step along them is fit to rounding. The potential comes
from initialize_model, so the latent sites and their Jacobians are
numpyro's business and not this module's.

On the corpus at N=2000 it converges over 550 parameters and returns
mu_raw and u_raw to the prior mode, which is what the 83xP Jacobian
says should happen. a is recovered. s and b_1 come back with equal and
opposite errors, but the likelihood alone is flat along s and steep
against b_1, so that is the s prior shrinking a 2.2-sd draw and b_1
absorbing it, not the aliased sum it resembles.

eq:betaprior centres beta, so a single declared species gives beta = 0
identically: a parameter with no effect, and a flat direction under
NUTS. Refused alongside the every-width-measured case.
The pool was iid. Sobol goes in the copula's independent latent space, before
the Cholesky, which is the only place that leaves the marginals exact; on the
live 271-parameter composite prior it takes the mean per-coordinate chi2 from
29.4 to 3.1. The sampler is in the pool fingerprint because it decides which
rows come out, with 'iid' contributing nothing so pools cached before the field
existed are not orphaned to describe a draw that has not changed.

vpop/emulator.py ports the trained net to JAX: NUTS needs gradients of the whole
phi to cohort-quantiles map, and a torch module cannot supply them. Dropout is
identity at eval, so the port is the weight matrices and two normalisations, and
check_against_torch closes it because a misread layer stride returns plausible
numbers.
Two bits of wiring the primitives do not cover. row_specs, block_draw_plan,
quantile_mass_table and build_h_fn already exist, and composing them is a few
lines at the call site, so they stay there.

The scenario table does not, because reference-vs-readout ordering is where the
fold-change bug lived. Keyed by (arm, time) rather than by cohort: cohorts
sharing an arm read the same trajectory and are separated by eq:disc and the
weighting, not by the mechanism. On the live corpus that is 7 scenarios over 5
arms rather than 30, and each of the four fold changes takes its reference from
its own arm's day 0.

prior_cholesky derives L_R from the PriorSpec the pool was drawn from rather
than accepting it alongside. The surrogate learns the manifold that correlation
induces, so a Mechanism carrying a different one evaluates it off-manifold with
every number still plausible.
tumor_doubling_time reads each patient's first and last timepoint, and the last
is patient-dependent, so no fixed readout time expresses it and h_r cannot
compose it. The reduce computes it where the trajectory still exists; this is how
it reaches h_r.

Not through g_fn's block. That block is one fixed width per scenario, so a column
only one arm emits would have to be padded everywhere else, and padding costs the
other arms their training rows: emitting it for all five dropped 21% of the
urelumab arm, selected for being responders, since a doubling time is undefined
when the tumour does not grow. Those are the patients the fit is about.

So it travels beside the block. build_extra_fn resolves each (scenario, name)
against the arm that serves it and refuses one that does not, h_fn declares which
pairs it needs, and readout_cloud evaluates exactly those. It is not scaled by
beta, which is right for a log-ratio of one species at two times: a per-species
bias cancels identically and beta could never have reached it.

The third argument is passed only when there is one, so an h_r with no
precomposed readouts keeps the signature it had.
min and max are order statistics 1 and n, so the Beta kernel already had them;
they were unsupported only because nothing could consume them. Fitting them
gives stromal_fraction its only width, at the cost of a row reading the 0.993
quantile.

row_specs gains exclude, for a printed number that is not evidence: the corpus
carries a ci95 pair built by assuming CV = 1.0, and fitting it would return the
assumption to omega as if a study had measured it. It raises on an exclusion
matching nothing, so a later corpus edit cannot quietly re-admit one.

restrict_plans drops cohorts reporting no row from a block's row order while
leaving its strata alone: a dropped member of a counted block still occupies its
place, and removing it there would change the overlap its co-members draw.
h_r returns logs and eq:disc acts there, which is what makes gamma a
multiplicative assay bias. The row functionals were inheriting that scale, so
the likelihood compared a log-scale prediction against a natural-scale T-hat.
Mean, sd, se and iqr do not commute with exp, so the exp belongs between eq:disc
and the functional rather than applied to tau afterwards.

The width gate found it on its first run: mean shortfall went from +1.319 log
units (2.26 prior sd, unreachable) to -0.223 (0.38 prior sd), and max |z| from
122 to 7.7. Location rows go from a median T-hat/tau of 13.5, computable for 48
of 121 rows because a log is often negative, to 0.851 over all 121.

kappa now leaves only the POPULATION median at the pivot. A reported median is
an expectation over n draws, and in the source's units the map is a power rather
than affine, so the expectation stops commuting with it. The gap is the median's
own sampling spread and closes with n.
Splits tau_from_readouts out of tau_all so the same rows can be computed from a
cloud the simulator produced, where there is no phi to push through the emulator.

Both sides read the same resampled patients, so the cloud's Monte Carlo
fluctuation is common to the two and differences out, leaving the surrogate's own
error carried to the reported statistics. Measuring it on the readouts and
expanding would not do: a quantile row and a spread row do not feel a per-readout
error alike.
pin_discrepancy, pin_u and pin_aux make the model falsifiable. With 18 free
discrepancy parameters a good fit says little, since most of a mismatch can be
absorbed; with them off it has nowhere to hide and shows as residual structure
that can be read and attributed.

pin_u is declared rather than defaulted either way: pinning it asserts the widths
are proportional, leaving it free adds 271 parameters that no scale row informs.
Neither is neutral.

The cost is that real assay bias then lands on mu, so this diagnoses and does not
ship.
NUMPY_QUANTILE_METHOD took five Hyndman-Fan names and QUANTILE_CONVENTIONS
implemented three, so a target declaring type2 or type8 loaded clean and
raised KeyError inside the sampler. Nothing declares one today, which is why
it never fired.

type8 is continuous. type2 is not, but the caller already mixes the two order
statistics around h, so the averaging case is h = np + 1/2 at integer np and
ceil(np) otherwise.

Asserts the two registries are equal so they cannot drift again, and pins h
against hand-checked Hyndman-Fan values.
eq:Ec split into dbar and E_B, and eq:gcorr added: an affine alpha + Gamma
(theta - mu_0) ridge-fitted over n_g solves at phi_0, in the transform the net
was trained in, with g_c replacing g throughout including inside eq:Ec.

E_B alone understates V_B by whatever dbar is, and nothing in the fit registers
the difference. The two pieces do not scale together: narrowing the cloud
shrinks V^boot and the fluctuation but leaves dbar where it was, so the
emulator's share grows as omega falls.

Gamma also enters d tau / d mu, so the correction reaches the Jacobian and not
only the level. Freezing alpha is the riskier freeze: a stale V_B gives a row
the wrong width, which eq:ratio tests, while a stale alpha moves tau and is
paid for in mu.
population-generative-model.tex carried 1048 uncommitted lines and
generative_model.md / data-and-model-sketch.* were never tracked. Committing
the working state so the next commit's deletion is recoverable rather than
final.
The rival drafts described the same model with an older discrepancy layer
(estimated tau_eta / tau_delta / tau_kappa against the live eq:abprior's fixed
sigma_a = sigma_b = 0.5), so reading the wrong one made the implementation look
non-compliant. Recoverable from the previous commit.

Four sections have no model-draft counterpart and are worth harvesting if
wanted: what the emulator must cover, how omega_0 is built, the correction
alias, and what data to gather next.
A sigma with no resolvable n leaves the prior untouched, but the level was set
from whether a sigma was offered rather than from whether it changed anything.
In pdac that is 178 offered against 9 resolvable, so the provenance reported a
whole prior as data-informed when the data never reached it.

Values are unchanged: shrink_toward_prior already returns prior_omega when n is
None. This is the label only.
A cut parameter is left unvisited by the component BFS so several components
can claim it, and only the upstream one is meant to reach the priors. The
aggregation resolved that by dict order instead, which took the discarded
downstream view for 3 of the 7 cuts. TGFb_50_CAF_act was carrying the NPE fit
submodel_config.yaml cuts for SBC bias.

Resolution keys on the component's own target list, from the cache's
target_yamls, not on the union over the params it carries: a merged component
holds targets belonging to its other parameters and matches an upstream it
never used. Passing cascade_cuts without that list now raises rather than
silently passing everything through.

run_comparison no longer discovers submodel_config.yaml for itself. It sets
the component partition through cascade_cut_params, so a path the function
guessed could differ from the caller's and re-partition a run with no symptom.

Test fixtures moved off MomentSpread and the moments dict, which predate the
flat-statistics schema; two of them had been asserting against None.
Marginals were selected from {normal, lognormal, gamma, invgamma} by AIC, and
then load_composite_prior_log sampled the winner and refitted a normal to its
logs. Every marginal in the composite prior was therefore Gaussian whatever
stage 1 found: the prior was a multivariate lognormal by construction, and a
copula exists precisely to keep the marginals and the dependence separate.

The family also had no say in whether the result was usable. AIC picked an
inverse gamma of shape 0.906 for IFNg_50_CXCL9, which has no finite mean, and
1.965 for k_APC_mig_ID_LN, no finite variance. Both reached the prior.

Marginals are now the posterior's own quantiles on log theta, indexed by normal
score so that continuing linearly past the grid is exactly a Gaussian tail. The
tail slope is regressed over the outer band rather than read off the endpoint
derivative, which is the noisiest knot: on a known lognormal that gave 0.54
against a true 0.80, 3.4 nats of error in the upper tail.

On this corpus 61% of the fitted marginals failed Anderson-Darling and no
candidate family passed for 113 of them. Against the samples, KS falls from a
median of 0.010 to 0.005, and from 51 of 189 beyond the 5% critical value to
none. sd_1 moves by at most 6%, so the widths downstream are unaffected; what
changes is shape, tails, and log_prob.

The copula is fitted from rank pseudo-observations. Through the fitted CDFs a
poor marginal leaves u non-uniform and the correlation absorbs that error as if
it were dependence.

_all_normal is gone. It was an automatic fork: all-normal marginals took an
exact standardization, anything else sent every marginal through cdf -> Phi^-1
with u clamped, capping |z| at 5.61. One non-normal marginal silently degraded
the other 270, and which log_prob ran was decided by the data. Exactness is now
per marginal, and on the real prior all 271 reach z directly.

Grids live in a .npz beside the YAML. Inline they made the file 12x larger and
5x slower to parse, and every parameter repeated the same z. The YAML carries a
sha256 of the sidecar, checked on load, because the two are one artifact in two
files and nothing else stops them drifting apart.
…them

Twelve fields carried defaults, so a caller could assert tau_s, sigma_a,
n_beta or pin_discrepancy by not mentioning them. A rubric number asserted by
a dataclass is indistinguishable in the posterior from one somebody chose, and
five of the pdac fit's numbers were entering exactly that way.

pin_discrepancy=False is not the neutral setting it looked like: it turns the
discrepancy layer on, which is a modelling decision, so it is stated too.

tau_omega_measured is the one exception. It decides nothing while `measured`
is empty, so it is required exactly when a width is declared measured, and
__post_init__ raises rather than supplying a width for a claim about how far
a measurement is trusted.
comparison.py built each posterior-predictive draw from the MCMC samples and
then overwrote every declared-nuisance parameter with a fresh prior draw. An
observable depending only on nuisance parameters therefore had a prior
predictive reported as its posterior predictive, understating coverage on 24 of
the 43 components that looked broken.

ppc_audit reads that block back as per-observable evidence and attaches no
verdict. Thresholds for "badly fitting" did not survive testing: the binary
reachability flag rested on min/max of 200 draws, and 9 of 42 flagged
observables sat inside the noise band of that order statistic. What did hold up
is the sensitivity ratio, prior predictive width over CSV prior width, which
separates an observable theta cannot move from a merely tight prior.

refit_check decides instead, by fitting a target set with and without a change
over an identical isolated directory. resolve_target_set carries the closure the
stage DAG demands: it walks the entire cascade cut list and rejects any upstream
it cannot place, whether or not that cut's parameter is in the run.
pin_u let a caller assert the answer twice: tau_u already says how far u
may move, and a flag on top of it made zero and free the only choices. Wide
prints a width profile whose individuation is a prior draw; pinned stops a
direction the scale rows do constrain from moving at all. Small and free is
neither, and needs no rank cutoff defended.

The comment at the sample site carries what the posterior cannot: that
tau_u is derived from the role table's narrowest gap so u cannot carry a
parameter out of the role it was assigned, that the normal prior shrinks
uniformly and that is a choice against a heavy tail, and that a component
sitting at tau_u is the expected reading rather than a finding.
omega said how wide a parameter varies between patients and implicitly asserted
that it varies lognormally. For a quantity bounded on (0,1) that assertion is
false at every width: Emax_Cy_Treg at median 0.80 puts 26% of the population
above complete inhibition at omega 0.35, and the only omega that fixes it is one
denying the quantity varies at all.

The overrides CSV gains a `scale` column, orthogonal to the role: the role says
how wide, the scale says on what. eq:crn is a Gaussian copula, so the margin is
separable from the correlation and only patient_cloud changes. mu stays on the
log scale for every parameter, so eq:muprior and the stage-1 copula need no
reinterpretation and z = 0 still returns exp(mu) exactly.

Logit-normal rather than Beta because omega keeps its meaning as a scale on an
unbounded latent, so the omega_0 role table, s, u and the tau_u derivation all
carry over. Beta's dispersion is not a log-sd and its median has no closed form.

The guard in patient_cloud is load-bearing. Clipping exp(mu) at the bound keeps
the value finite but leaves 1/(1 - m) ~ 1e8 in the branch jnp.where discards, and
that is enough to make d/dmu nan; the base has to be masked before the
nonlinearity, not clamped at the singularity.

load_omega_overrides also now refuses a repeated parameter name. It returns a
dict, so a second row for the same name used to win silently and drop whatever
rationale the first one carried.
The gate read the rows SCALE_STATS names, so a source printing an iqr was
measured and a source printing q25 and q75 was not, though both report the same
spread of the same patients. That made the width budget a sample of editorial
style rather than of the corpus.

paired_width_rows differences a target's widest symmetric quantile pair into one
width and enters it beside the printed se, sd and iqr rows. One row per target,
so q25/q50/q75 is not read as three overlapping widths; a target that printed an
iqr outright is skipped, since the two would be the same number twice.

The derived row needs no new data and no new assumption: both quantiles belong to
one cohort, so their contrast variance is already exact in V_B, and the delta
method carries it to the log scale the printed rows use. Non-monotone quantiles
raise rather than pass, separately for the observed pair (a corpus error) and the
predicted pair (a bug, since expected order statistics of one sorted cloud are
monotone in p).
…ract

main's PR #86 added the test with a bare gamma marginal and cross-checked that it
still admits a log fit. This branch changed what that means: since the prior
carries the posterior's own shape, a gamma without an empirical_log grid is not a
log-space marginal and copula_prior refuses it by name rather than silently
refitting it to a family.

So the cross-check was asserting a contract that no longer exists. The rounding
assertions are the subject of the test and are untouched; the downstream half now
guards that a tiny scale reaches the format check as a live value rather than a
zero, which is the regression the test was written for.
copula_prior and inference.importance import torch at module scope, and torch is
the optional [sbi] extra. Both test modules imported them at module scope too, so
collection failed outright in an environment without it rather than skipping, and
the whole run aborted. Same guard main already uses for the copula_prior
cross-check.
…y arithmetic

order_statistic_mass built its edges as cumsum(w) / sum(w). Those are cumulative
probabilities and belong in [0, 1], but cumsum and sum reduce in different orders,
so the last edge can land an ulp above 1 -- and betainc is nan just outside the
interval rather than saturating at it. Whether the ulp appears depends on the
platform's summation order, which is why this passed on arm64 and returned nan on
x86 CI, taking the whole Beta kernel with it.

Clipping the edges is the fix rather than nudging the endpoints, since the bound
is what the quantity means and not a tolerance.
@jeliason
jeliason merged commit 3800603 into main Aug 6, 2026
1 check passed
@jeliason
jeliason deleted the feature/vpop-proposal-ch4-guide branch August 6, 2026 05:03
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