Pillar 04 · axiom.surface · axiom.diagnose
Map the dose–response
Effects saturate. Doubling a dose rarely doubles a response, and a linear model asked to describe a saturating world will get the optimum wrong in a predictable direction. The surface layer is where axiom keeps the actual shape — and keeps exactly one implementation of it, so the likelihood, the design maths, and the optimizer cannot drift apart.
The experiment
12 arms, chosen rather than guessed
Two treatments, each with a Hill kernel. The arms come from a rotatable central composite design over the dose box — a classical response-surface layout that spends its runs where curvature is estimable, instead of spreading them evenly and learning the shape badly everywhere.
Response to treatment a, with the truth overlaid
Blue is the posterior mean with its 90% interval; dashed grey is the curve the simulation actually used.
axiom.surface.response_band(fit, "a", mass=0.9) — every
posterior draw pushed through forward, then summarised
Response to treatment b
A different reference dose and a different shape parameter, from the same fit.
axiom.surface.response_band(fit, "b", mass=0.9) · 12 arms, noise sd 0.5, Laplace backend, seed 0
Parameter recovery
The simulation knows the truth, so recovery is checkable rather than assertable. Every ported estimator in axiom ships with a recovery test like this one in the same commit.
| parameter | truth | posterior mean | sd | 90% HDI | contains truth |
|---|---|---|---|---|---|
| alpha | 2.00 | 2.47 | 0.26 | [2.02, 2.87] | no |
| beta_a | 10.00 | 12.17 | 1.44 | [9.80, 14.47] | yes |
| k_a | 50.00 | 61.39 | 8.46 | [47.68, 74.89] | yes |
| s_a | 2.00 | 1.63 | 0.20 | [1.30, 1.96] | no |
| beta_b | 6.00 | 6.48 | 0.92 | [4.97, 7.93] | yes |
| k_b | 12.00 | 19.80 | 3.24 | [14.52, 24.98] | no |
| s_b | 1.50 | 1.64 | 0.29 | [1.13, 2.07] | yes |
| sigma | 0.35 | 0.19 | 0.04 | [0.12, 0.25] | no |
Only 4 of 8 intervals contain the truth — and that column is computed by the build, not typed in, so it cannot flatter the fit. This is not a failure to report quietly: it is the honest consequence of asking 12 arms to pin down seven structural parameters that trade off against one another. The curves above still track the truth closely across the dose range, because the combinations the data constrain are well determined even where the individual parameters are not. The next section is axiom saying so out loud.
The check that earns its keep
A good fit is not the same as an identified one
The curves above track the truth and the posterior predictive checks all pass. That is not sufficient. Several parameters are trading off against each other so strongly that the data cannot separate them — the fit is fine as a prediction and unreliable as an explanation, and those two uses call for different decisions.
Posterior predictive checks all pass
Every summary statistic axiom checks sits inside
its predictive interval, and the list of extreme statistics is empty. If
model checking stopped at goodness of fit, this model would be signed off.
weak_identification is the check that catches what a residual plot
cannot.
| statistic | observed | interval | p | extreme |
|---|---|---|---|---|
| mean | 10.110 | [9.98, 10.30] | 0.76 | no |
| sd | 3.114 | [3.00, 3.28] | 0.92 | no |
| min | 4.379 | [3.97, 4.89] | 0.94 | no |
| max | 14.463 | [14.32, 15.01] | 0.32 | no |
| unit_sd | 3.114 | [3.00, 3.28] | 0.92 | no |
Acting on it
Where the optimum is, and what a budget buys
Given a fixed total dose to spend across both treatments, axiom finds the split that
maximises the expected outcome — pushing the posterior through the same
forward the likelihood used, not a linearised stand-in.
The response surface over both treatments
Expected outcome at every dose pair. The marked point is the allocation axiom chooses for a budget of 60.
axiom.surface.forward(doses, theta) over a 36×36 grid · allocation from axiom.surface.allocate(surface, posterior, budget=60)
Regret is the number that matters
The fitted allocation puts
37.8 on a where the oracle would put
42.1 — a visible gap in the parameters. The
outcome cost of that gap is 0.0917
against an achievable 10.02. Near an optimum
the surface is flat, so being somewhat wrong about where the peak is costs far
less than being wrong about the shape. That is worth knowing before spending more
on precision you will not use.
The budget frontier
Re-solving the allocation across budgets gives the frontier and, from its slope, the shadow price — what the next unit of budget is worth. It falls monotonically, which is saturation showing up where a decision-maker can actually use it.
Marginal value of the next unit of budget
Shadow price at each budget on the frontier.
axiom.surface.frontier(surface, theta, budgets=…, bounds=…)
then .shadow_prices() · seven budgets, warm-started
Rule five, in the wild
When the answer does not exist, you get a refusal
Asking for the slope of the response at zero dose looks like a reasonable request. For a Hill curve with a shape parameter below one it is not: the slope at zero is infinite. Here is what axiom does with that.
Unsupported — returned, not raised, and not fudged
marginal_band(result, 'b', doses=0..40)
→ Unsupported: “marginal of 'b' is not finite in 396 of 96000 (draw, unit, period) cells; a kernel with shape s < 1 has infinite slope at zero dose”
It names the count and the cause. It does not return
inf, it does not silently drop the offending draws, and it does not raise
an exception that a caller might catch broadly and lose. The marginal curves on this
page are computed on a grid that starts one step above zero — which is a choice the
caller makes, having been told why.
What is in axiom.surface
88 public symbols. Seven response
kernels including Hill, logistic, power, splines and a Gaussian process; four
carryover families; nuisance terms for trend, seasonality and events; classical
designs; the optimizer, frontier and steepest ascent; and the one
forward they all go through.
Next: everything you have ever measured
One study gives you one surface. A programme gives you dozens of studies of varying quality, which have to be pooled before they mean anything together.