Bayesian causal decision science · Python 3.12+
The 40 mg arm looked safe.
It wasn’t.
Averaged over all 400 patients in this trial, the top dose never comes close to the harm boundary — its worst reading is Z = +0.45, comfortably on the safe side. Split by age band, the same arm crosses that boundary at the first scheduled review, week 12, in the oldest stratum. Benefit in the younger bands was cancelling harm in the older one.
axiom is the toolkit for asking that kind of question on purpose — and for being able to say afterwards exactly which assumption the answer rested on.
Safety monitoring of the 40 mg arm
Each dot is a scheduled review. The shaded band is the region the protocol calls harm.
axiom.design.monitor(rule, z, effects=…, ses=…) · harm_boundary(0.95, margin=2 mmHg) · 12 contrasts monitored · seed 20260821
What it is
Five questions, one vocabulary
Most statistical libraries hand you an estimator. axiom starts a step earlier, at whether the number you want is recoverable from the data you have, and keeps going a step later, to what the number is worth and what it assumed. The five pillars share one set of nouns — treatment, dose, unit, outcome, covariate — so a result can move between them without being re-derived by hand.
01 · IDENTIFY
Can the data answer it?
Draw the causal graph and ask. axiom finds the back-door, front-door, or instrumental route — or tells you no route exists and names the variable you are missing.
+0.726 → +0.013 naive error vs. adjusted error, against a known truth of 2.0
See it work
02 · DESIGN
What should I measure next?
Power and minimum detectable effect, but also the part usually left out: what the experiment is worth in money, what withholding treatment costs, and which design wins once you subtract both.
56,502 USD value of perfect information on a decision that is genuinely on the fence
See it work
03 · CALIBRATE
Fold the experiment in
A randomized result should change an observational model. axiom does that two ways — through the prior, or through the likelihood — and writes a ledger line for every assumption the transfer needed.
18.6 → 10.0 confounded estimate pulled back to a truth of 10
See it work
04 · SURFACE
Map the dose–response
Saturating kernels, carryover, and one forward() that the likelihood,
the design maths, and the optimizer all call. Then: where is the optimum, and what
does another unit of budget buy?
0.092 regret of the fitted allocation against an oracle that knows the truth
See it work
05 · POOL
What does all the evidence say?
Pool a corpus of studies, estimate how much they really disagree, and carry a bias term for reads you trust less — then hand the result forward as a prior.
+0.161 how far model-based reads sit above experimental ones, estimated rather than assumed away
See it work
· REFERENCE
843 public symbols
Across 18 subpackages, every one of them demonstrated in an executed notebook. A contract test fails the build if a symbol is exported without a notebook that uses it.
0.78s to import all five pillars, pulling only numpy, pandas, pydantic, scipy
Browse the API
Sixty seconds
Ask first, estimate second
The output blocks below are captured from a real run at build time, not typed out. If the library’s answer changes, this page changes with it.
1 · The effect is identified, so adjust and move on
A treatment
X, an outcome Y, and a measured common cause
Z. Ignoring Z is not a small mistake — it is the whole
confounding bias, and axiom names the set you have to condition on before you fit
anything.
from axiom.identify import identify, ols
from axiom.sim import confounded_world
world = confounded_world() # Z confounds X and Y
verdict = identify(world.graph, "X", "Y")
print("status :", verdict.status)
print("route :", verdict.route)
print("adjust :", sorted(verdict.adjustment_set))
frame = world.observed(world.simulate(5_000, seed=0))
naive = ols(frame, "Y", "X")
fixed = ols(frame, "Y", "X", covariates=verdict.adjustment_set)
print()
print(f"truth {world.total_effect('X', 'Y'):.3f}")
print(f"naive {naive.estimate:.3f} {naive.ci(0.95)}")
print(f"back-door {fixed.estimate:.3f} {fixed.ci(0.95)}")
status : identified route : backdoor adjust : ['Z'] truth 2.000 naive 2.726 [2.693, 2.758] (95% WALD) back-door 2.013 [1.985, 2.040] (95% WALD)
2 · It isn’t identified, and you find out before you fit
The same graph with
Z unmeasured. There is nothing to condition on, so there is no
unbiased answer — and rather than returning one anyway, axiom downgrades the
verdict and states the assumption you would have to defend, along with what would
challenge it.
from axiom.identify import identify
from axiom.sim import hidden_confounder_world
world = hidden_confounder_world() # the confounder is never measured
verdict = identify(world.graph, "X", "Y")
print("status :", verdict.status)
print("route :", verdict.route)
print("needs :", sorted(verdict.unmeasured_required), "which you do not have")
print()
for a in verdict.verdict.assumptions:
print(f"assumption {a.name} [{a.state}]")
print(f" says {a.statement}")
print(f" challenged {a.challenged_by}")
status : downgraded route : backdoor_unmeasured needs : ['Z'] which you do not have assumption unmeasured_adjustment_set [unverified] says the adjustment set ['Z'] is available, but ['Z'] is unmeasured challenged no proxy or sensitivity bound for the unmeasured node
Why this matters
A library that silently returns
2.726 here is not wrong
about the arithmetic — that really is the regression coefficient. It is wrong
about the question. The truth is
2.0, and no amount of data fixes
it, because the bias does not shrink with n.
3 · The number you want is an object, not a convention
“The effect” is ambiguous until you say at what dose, against what reference, over what window, for whom, and at what level of aggregation. axiom makes those nine facets explicit and hashes them, so two results can be checked for whether they are even about the same quantity.
from axiom.core import Population, TimeWindow
from axiom.estimands import Level, standard_estimands
from axiom.sim import arms_world
from axiom.surface import HillKernel
world = arms_world(
n_units=40, treatments=("a",),
kernels=HillKernel(reference_dose=50.0, amplitude_scale=10.0),
doses={"a": [0.0] * 20 + [60.0] * 20},
truth={"beta_a": 6.0, "k_a": 50.0, "s_a": 2.0, "alpha": 1.0},
noise_sd=1.5, seed=0,
)
registry = standard_estimands(
treatment=world.spec.treatments[0], outcome=world.spec.outcome,
population=Population(name="all"), window=TimeWindow(start=0, stop=1),
level=Level(unit="individual"), dose=60.0, reference_dose=0.0,
)
estimand = registry.get("contrast_at_dose")
print("name :", estimand.name)
print("quantity :", estimand.quantity.kind)
print("dose :", estimand.intervention.doses, "vs", estimand.reference.doses)
print("window :", estimand.window.start, "->", estimand.window.stop)
print("dimension :", estimand.dimension)
print("hash :", estimand.content_hash()[:16])
name : contrast_at_dose
quantity : contrast
dose : {'a': 60.0} vs {'a': 0.0}
window : 0 -> 1
dimension : Y
hash : 0ba29b126aa4df1a
Commitments
Six rules the build enforces
These are not aspirations in a style guide. Each one has a contract test behind it, and the evidence below each rule was measured on this build.
The dependency budget is a hard constraint
The core is numpy, scipy, pandas, pydantic. Nothing else. All the design, identification, planning, and closed-form meta-analysis maths works there. Samplers are extras, and a contract test fails the build if importing the library drags one in.
measured: import axiom.identify, axiom.design, axiom.surface, axiom.meta, axiom.estimands →
pulls numpy, pandas, pydantic, scipy in 0.78s.
Samplers pulled: none.
No marketing vocabulary outside the adapter
The nouns are treatment, dose, unit, outcome, covariate — never channel, spend,
geo, or ROAS. Marketing is one adapter in
axiom.adapters.marketing, and a gate keeps its vocabulary out of
everything below it. The maths came from a marketing platform; the vocabulary
deliberately did not.
axiom.adapters exports 17 symbols — the only place in the library where those words are allowed.
One forward()
The likelihood, the data-generating process, the design maths, and the optimizer all call the same transform chain. Nobody reimplements it “for speed” — that drift is a documented bug class in the codebase this one was rewritten from.
the allocation, the response band, and the simulated truth on
the surface page are all axiom.surface.forward.
Every number carries its provenance
An interval states which definition it is and at what mass. Every evidence transfer writes a ledger line naming the assumption it needed. Every spec has a content hash. This site is built the same way: every figure names the call that produced it.
e.g. [1.9849,
2.0404] (95% WALD) — never a bare
pair of numbers.
Never swallow an exception
Code either re-raises, logs at warning or above, or returns a typed failure that
says what went wrong. Four separate wrong-number bugs in the parent codebase trace
back to a bare except.
a real refusal from this build: “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”
Every public API is shown in a notebook
Anything a subpackage exports appears, executed, in that subpackage’s notebook series. A subpackage is not complete until its series runs in CI. Gate 12 fails the build on an uncovered symbol.
843 exported symbols, 18 notebook series, all executed on every build. And because a library that only ever tests itself against its own simulator is checking one thing twice, 12 published values from four real datasets are reproduced too.
Shape of the code
Imports point down only
Sixteen subpackages in five layers. A contract test walks the import graph and fails if an arrow ever points back up, which is what keeps the foundation usable without the pillars and the pillars usable without each other.
Install
The core install is enough for identification, design, planning, and closed-form meta-analysis. Add a sampler only when you need one.
# core: numpy, scipy, pandas, pydantic pip install axiom # with a NUTS sampler when you need posterior draws pip install "axiom[numpyro]" pip install "axiom[pymc]"
Where to go next
Three ways in, depending on what you came for.