Method
Hierarchical priors: model card
What the population priors are, how they are fitted and how far to trust them. Every gate, threshold and sampler setting on this page is read from the trainers; every number about the fit is read from the served artifact.
Constants read from libs/shared/ml/src/priors/priors-constants.generated.ts.
Which version is served
Served: v2-sota-2026-05-15, generated 2026-10-26, run as pymc-nuts-2chain-1000draws-500tune-sampler=nutpie-likelihood=student_t.
| version | status | read by |
|---|---|---|
v2 | served | The priors block of /api/ml/predict, the MCP server and the chat assistant; the knowledge-graph drawer; the lab trust badges; the Korth collaboration page. |
v1 | fitted, still read | Parameter detail pages, the population-priors card on paper pages, the lab wastewater overlay and the resource-recovery demo. Normal likelihood; converged means R-hat < 1.05 and ESS > 200. |
v3, v3.1 | not served | v3 and v3.1 were experiments (adaptive partial pooling) that fitted worse than v2. The trainers remain for the record; no page reads their output. |
v0 | archived | Moment matching on log-distributed values, which is wrong for them. Archived; nothing reads it. |
What is fitted
One fit per canonical parameter slug, then one per stratum. Every stratum of a slug uses the scale chosen on that slug’s pooled values.
| stratum | grouped by | minimum papers |
|---|---|---|
| pooled | every row of the slug | 5 |
| by system type | primarySystemType, else systemType | 8 |
| by application domain | each entry of application_domains (a row counts once per domain) | 8; 5 for resource_recovery, wastewater_treatment |
| by system type and domain | system|domain | 5 |
A fit is skipped with fewer than 5 observations, or fewer usable observations than papers. A slug whose rows carry more than one SI unit keeps only its dominant unit, and the dropped units are written into the fit note. Temporal and regional strata are stubs and stay empty.
Scale and link
The fit runs on log₁₀(x) when all three conditions hold on the pooled values, and on x itself (identity link) otherwise:
- at least 0.95 of the finite values are positive;
- among the positive values, p95 / p5 exceeds 100;
- the slug is not fraction-like: not (median in (0.05, 1], maximum ≤ 1.5 and minimum ≥ -0.05).
On the log scale, values ≤ 0 are dropped before fitting, so positive quantities that span orders of magnitude (power density, resistance) get a log-normal-type model. Bounded quantities (coulombic efficiency, COD removal, pH) are fitted on the raw value. That model is not constrained to the bounds, so its tails can extend past 0 or 1.
Likelihood, hierarchy and hyperpriors (v2)
y is log₁₀(x) or x per the scale above; i indexes observations, p papers. The likelihood served is student_t (environment variable PRIORS_LIKELIHOOD; default student_t).
- Observation
- y_pi ~ StudentT(ν, θ_p, σ_w)
- Paper level, non-centred
- θ_p = μ + τ·z_p, z_p ~ Normal(0, 1)
- Population location
- μ ~ Normal(m₀, s_μ)
- Between-paper spread
- τ ~ HalfNormal(s_τ)
- Within-paper spread
- σ_w ~ HalfNormal(s_σ)
- Student-t degrees of freedom
- ν ~ Gamma(α = 2, β = 0.1) · mean α/β = 20 (PyMC shape-rate form)
Hyperprior scales
| case | m₀ | s_μ | s_τ | s_σ |
|---|---|---|---|---|
| log₁₀ scale, no literature entry | 0 | 5 | 2 | 2 |
| raw scale, no literature entry | mean(y) | 5·SD(y) | 2·SD(y) | 2·SD(y) |
| entry in literature-priors.json | its log10_mean | its log10_sd | its log10_half_width | as without an entry |
The raw-scale scales are floored at 10⁻⁶. A literature entry that lacks a field falls back to the trainer default for that field. Mixture, temporal, regional and joint models exist only as stubs and do not produce the served fit.
Data pool
Rows come from ExtractedParameterData through the one modelling-pool predicate (libs/data-access/database/src/modelable-pool.ts, Python twin services/ml-engine/training/modelable_pool.py). A row enters the fit only if all of these hold:
- the normaliser judged it modelable (applicable_to_modeling), and it has a canonical slug and a numeric SI value;
- the physical-bounds gate passed it (boundsPassed is true), and it carries no bounds:, dedupe:, physics: or contradiction: flag;
- the snippet verifier did not fail it (verifierPassed is not false; unscored rows pass);
- it is not archived and no reviewer rejected it;
- its paper is visible, not archived or retracted, not a review or meta-analysis, not marked not_BES, and is a paper rather than a patent;
- its paper is on the fit side of the paper-disjoint split: fit_validation_role is fit or unset. Validation and holdout papers never reach the fit; validation papers are used only to score coverage.
Sampling and convergence gates
NUTS via nutpie (fallback order nutpie → numpyro → pymc), 2 chains × 1000 draws after 500 tuning steps, target_accept 0.95, seed 42. PRIORS_DRAWS, PRIORS_TUNE, PRIORS_CHAINS override draws, tuning and chains; the served artifact records what it ran: pymc-nuts-2chain-1000draws-500tune-sampler=nutpie-likelihood=student_t.
A fit is marked converged only if R-hat_max < 1.01 and ESS_bulk_min > 400 and there are 0 divergences, with R-hat and ESS taken over mu, tau, sigma_within (not the paper-level offsets). The test uses full-precision diagnostics. The artifact serves R-hat at 4 decimals, always moved up, and ESS as a whole draw count, always moved down, so a gate applied to the served numbers can never pass a fit the trainer rejected. v1 used R-hat < 1.05 and ESS > 200.
In the served artifact, 11 of 175 pooled fits and 60 of 760 strata pass all three gates. A fit that fails is still written to the artifact with converged = false; the serving gate below decides whether its interval is shown.
LOO-PSIS is computed per fit; an observation with Pareto k > 0.7 is high-influence, and LOO is treated as unreliable when more than 0.05 of observations exceed it.
What the served interval is
ci95_low and ci95_high are the 0.95 highest-density interval of the posterior of mu, the population location, back-transformed with 10ˣ on the log scale. They say where the population centre sits. They are not a predictive interval: they leave out τ and σ_w, so most new papers report values outside them.
mu is the back-transformed posterior mean of the location. On the log scale that is 10^E[μ], a geometric centre, not the arithmetic mean of x.
Coverage is checked on a different band: the predictive interval μ̂ ± z·√(τ̂² + σ̂_w²) on the fit scale, back-transformed, with z = 1.959963984540054 and posterior means for μ, τ and σ_w. It is a Normal approximation. It ignores posterior uncertainty in μ, τ and σ_w and the heavier Student-t tails, so when ν is small the true predictive tails are wider than this band.
Before an interval leaves /api/ml/predict, the serving gate (libs/shared/ml/src/priors-serving-gate.ts) withholds it when the fit is degenerate or fails diagnostics, or when it under-covers on held-out papers. It then serves the raw-corpus 10th to 90th percentile range, or no interval when that range is not available.
On the current artifacts: 6 served, 169 withheld (0 replaced by the empirical range, 169 with no interval).
How coverage is validated
validate_priors.py scores 81 held-out papers (fit_validation_role = validation) drawn through the same pool predicate. Each held-out value is scored against the system-type stratum when that stratum has at least 8 papers, else against the pooled fit, at nominal coverage 95.0%.
Overall: 93.6% of 2780 held-out values fall inside the band (nominal 95.0%); 77 fall below and 101 above. Verdict: calibrated.
This validation scored the served artifact (same generation stamp).
Lowest-coverage strata (n ≥ 5)
| parameter | system | n | coverage | below | above | verdict |
|---|---|---|---|---|---|---|
| conversion_efficiency | OTHER | 7 | 28.6% | 5 | 0 | under_covering_bias:prior_too_high |
| tafel_slope | MFC | 11 | 45.5% | 0 | 6 | under_covering_bias:prior_too_low |
| cod_removal | MERC | 8 | 50.0% | 4 | 0 | under_covering_bias:prior_too_high |
| phosphorus_removal | MEC | 8 | 50.0% | 4 | 0 | under_covering_bias:prior_too_high |
| total_dissolved_solids | MFC | 9 | 55.6% | 4 | 0 | under_covering_bias:prior_too_high |
| biofilm_thickness | MFC | 5 | 60.0% | 1 | 1 | under_covering_spread |
| relative_abundance | MBES | 5 | 60.0% | 0 | 2 | under_covering_bias:prior_too_low |
| current | MFC | 11 | 63.6% | 0 | 4 | under_covering_bias:prior_too_low |
Verdicts: calibrated at coverage ≥ 90.0%; over_covering at ≥ 99.0%. Below 90.0%, the miss pattern decides: bias when at least 85.0% of misses fall on one side, spread when at most 65.0% do. A one-sided miss means the band is centred in the wrong place, and widening it cannot fix that.
Worked row: power_density_areal
The pooled v2 fit for power_density_areal: 239 papers, 741 observations, unit W/m², log scale. Stored values as served:
| field | stored value |
|---|---|
scale | log |
mu_log | -1.1570828075387771 |
tau_log | 1.1361776046009193 |
sigma_within_log | 0.1845801573292763 |
mu | 0.0696493700017325 |
ci95_low | 0.05092907018983528 |
ci95_high | 0.09894634071028374 |
likelihood_family | student_t |
nu_mean | 1.2850516136106038 |
rhat_max | 1.0448 |
ess_bulk_min | 51 |
n_divergences | 0 |
converged | false |
prior_source | weakly_informative |
How the numbers follow from the stored ones
- Point
- mu = 10^mu_log = 10^(-1.15708) = 0.06965 W/m²
- Served band (HDI of μ)
- 10^HDI 0.95 (mu) = [0.05093, 0.09895] W/m²
- Predictive SD, fit scale
- √(τ² + σ_w²) = √(1.136² + 0.1846²) = 1.151
- Predictive band the validator scores
- 10^(-1.157 ± 1.95996 × 1.151) = [0.000386, 12.6] W/m²
- Width ratio
- the predictive band is 15.6× wider than the served band on the fit scale
Gates: fails at least one, so converged = false.
Serving gate at /api/ml/predict: withheld, basis suppressed.
Reasons given:
- sampler did not converge (converged=false, rhat_max=1.0448, ess_bulk_min=51)
- posterior CI withheld; no empirical range available
services/ml-engine/training/hierarchical_priors_v1.py · services/ml-engine/training/hierarchical_priors_v2.py · services/ml-engine/training/validate_priors.py · services/ml-engine/training/mcmc_diagnostics.py