axiom

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.

harm boundary pooled 51+ only reviews it never reached

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.

identify.py
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)}")
output
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.

refuse.py
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}")
output
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.

estimand.py
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])
output
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.

01

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.

02

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.

03

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.

04

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.

05

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”

06

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.

Sampler seam
infer26 optional deps live here and nowhere else
Foundation

Install

The core install is enough for identification, design, planning, and closed-form meta-analysis. Add a sampler only when you need one.

shell
# 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.

is it right? 12 published values reproduced from four real datasets — checked against R metafor, NIST certified values, and two published papers. 3e-04 worst
by field 12 runnable examples — agronomy, epidemiology, ecology, energy, process engineering and more, each with its real output. 12 scripts
narrative The HYPER-3 case study — one trial, worked end to end, from the causal graph to the stopped arm. 6 notebooks
by pillar Start at Identify and follow the chain across the top of the page. 5 pages
what is this? About axiom — the loop it is built around, where it came from, what it refuses to be, and how you can check any of it. 5 min read
reference The API map, searchable, grouped by subpackage and layer. 843 symbols