For the complete documentation index, see llms.txt. This page is also available as Markdown.

Causal inference

Identification first — d-separation and the backdoor criterion on a declared graph — then IPW, matching, doubly-robust and panel estimation, and sensitivity analysis, refusing when not identified.

Seven kernel families for the question "did it cause that", built so that the estimate refuses to exist unless it is identified. All run as compiled kernels inside the runtime that ships with the SDK — there is nothing to start and nothing to install separately.

An unadjusted difference in group means is a causal effect exactly when no backdoor path is open, and a regression coefficient wearing a causal name otherwise. Nothing in the arithmetic distinguishes those two situations, so the distinction is made explicitly, first, against a graph you declare:

from algenta import MojoRuntime

runtime = MojoRuntime(mode="local")

# nodes: 0 = treatment T, 1 = outcome Y, 2 = confounder U
# edges: U -> T,  U -> Y,  T -> Y
edge_from, edge_to = [2, 2, 0], [0, 1, 1]

unadjusted = runtime.execute("causal_inference.dag", "backdoor_check",
                             [edge_from, edge_to, 3, 0, 1, []])["result"]

unadjusted["identified"]        # False
unadjusted["unblocked_path"]    # [0, 2, 1] — T <- U -> Y, the path that is open
unadjusted["status"]            # 347 — "effect_not_identified"

adjusted = runtime.execute("causal_inference.dag", "backdoor_check",
                           [edge_from, edge_to, 3, 0, 1, [2]])["result"]

adjusted["identified"]                  # True
adjusted["blocks_backdoor_paths"]       # True
adjusted["n_descendant_violations"]     # 0
adjusted["n_adjustment"]                # 1

A boolean False is not actionable. The path T <- U -> Y is: it names the variable you have to measure, or the design you have to change.

The modules

Module
What it does

causal_inference.dag

identification as a precondition — acyclicity with a named cycle edge, d-separation with a witness path, the backdoor criterion with the open path, and a minimal sufficient adjustment set with minimality measured

causal_inference.propensity

a real propensity model over many covariates, the diagnostics that decide whether its output can be used (common support, extreme weights, effective sample size), IPW in both Horvitz-Thompson and Hájek forms, and caliper or optimal matching

propensity

the pre-causal_inference propensity-score toolkit -- single-pass logistic scores, nearest-neighbor and caliper matching, matched-pair ATE, standardized-mean-difference balance checks, overlap and trimming, and stratified ATE by propensity strata

causal_inference.estimators

per-arm outcome regression, the augmented-IPW doubly-robust estimator with its influence-function standard error and a decomposition that must add up, a stratified estimator that reports the strata it could not use, and a deterministic bootstrap interval

causal_inference.panel

difference-in-differences over a real panel, reporting the pre-treatment trend difference so you can judge parallel trends, and synthetic control with the pre-period fit that is the only evidence the weights are worth anything

causal_inference.sensitivity

how strong an unmeasured confounder would have to be — E-values, robustness values, bias-adjusted estimates and the partial R² needed to explain a result away, plus a Rosenbaum bound for matched pairs

causal_inference.uplift

heterogeneous effects by score bin and the Qini curve, with a bin whose treated or control arm is empty reported absent rather than as an uplift of zero

causal_inference.base

the package's one status vocabulary, its finiteness test, its rounding-tolerance builder, non-wrapping work budgets and stable index sorts

A cycle in the declared graph names an edge

"Your graph is invalid" is a bug report with no address, so a cyclic graph comes back with one of the offending edges:

Node ids are 0..n_nodes-1 and carry no names — you already have your own labels, and a name per node would cross the wire for no analytic content. Every edge endpoint is range-checked before anything is built, so an out-of-range node is node_index_out_of_range rather than a crash.

d-separation answers in both directions, and shows its work

Conditioning on a collider opens a path that was closed — the classic way an adjustment set introduces the bias it was meant to remove. The test is two linear passes over node-direction states rather than an enumeration of paths, so it is cheap on a graph where the number of paths is exponential.

An adjustment set containing a descendant of the treatment is refused for the same reason, and the offending node is named:

identified = True asserts both halves of the backdoor criterion — no adjustment node downstream of the treatment, and every backdoor path blocked. A certificate that omits a condition is worse than no certificate, because you stop looking, so the scope is stated and the two halves are separate fields.

No common support means no estimate

Common support is the intersection of the two arms' propensity score ranges. When it is empty, no treated unit has a control at a comparable propensity and no control has a treated one — a weighted contrast between the arms is extrapolation, not an effect:

The number that was refused is arithmetically well defined and every weight in it is finite. That is exactly why it has to be refused: nothing in the value would have told you there was no region where both outcomes were observable. Thin overlap is not refused — that is what overlap_fraction and the four per-arm extremes are for. Only the empty case is, because it is the one no diagnostic can rescue.

With overlapping scores the same call answers, and reports how hard the weights are working:

Kish's effective sample size is the number that stops an estimate being quoted: an ATE built on three observations with weight 400 has an ESS near 3, and nobody who is told that will quote it.

The estimators report the identity they must satisfy

The doubly-robust estimator is consistent if either the outcome model or the propensity model is right, and the reason to prefer it is a property you can check exactly rather than an asymptotic claim. When the outcome model reproduces the observed outcomes, both correction terms are exactly zero and the doubly-robust estimate collapses to the outcome-regression one, bit for bit:

decomposition_residual is reported on every call, not only on the exact case: the estimate minus its regression part minus its correction part is zero by construction, so a nonzero value means the parts and the whole disagree and you can use neither.

The same discipline applies to the weighted contrast. With a constant propensity the Hájek estimate must equal the plain difference in arm means exactly, because the weights cancel arm by arm — and it does:

A stratum that cannot be used is reported, not dropped

Both numbers are returned because the difference between them is the bias: weighting by stratum size over the total size makes the weights sum to less than one when a stratum is missing an arm, which drags the estimate toward zero without saying so. is_full_sample is the flag to branch on. When no stratum has both arms at all the answer is no_control_units or no_treated_units with no estimate.

An assumption is reported, never verified

Parallel trends is an assumption, not a checkable condition, so difference-in-differences reports the evidence and claims nothing:

A trend needs two points. Reporting pre_trend_diff = 0.0 for a single pre-period would be a claim of parallel trends made out of nothing, so the flag is False and the number beside it is padding.

Synthetic control reports the same way from the other side. Its weights are found by iteration over the simplex, and an iteration that stopped early has no weights — not an arbitrary convex combination of donors:

That last number is the point of reporting it. The weights converged and the pre-period fit is still terrible, because these donors cannot reproduce this treated unit — and the effect is only as good as that fit. It comes back beside the effect rather than being left for you to recompute.

Matching says how many units found no partner

A caliper nobody satisfies is a refusal, not an empty average. When some treated units match and others do not, is_full_sample is False and matched_fraction says how much of the treated arm the ATT actually covers — the estimand has changed, and the report says so. match_optimal solves the same problem to optimality (measured total_gap 1.17 here, never worse than the greedy pass) and treats a forbidden pairing as a boolean rather than a large cost, so an impossible match cannot become choosable.

Uplift keeps the rule at bin level:

An uplift of zero is a real, ordinary answer — it means the treatment did nothing in that bin. So it cannot double as "this bin had nobody to compare against", and the top bin here is absent rather than flat.

Sensitivity gives you quantities, not reassurances

sensitivity answers the question that follows every observational estimate: how strong would an unmeasured confounder have to be to explain this away?

E-values put the question on the risk-ratio scale; robustness values put it on the partial-R² scale; omitted_variable_bias reports the bias-adjusted estimate and whether it flips sign; sensitivity_curve sweeps a grid of confounder strengths and names the first one that flips it; and rosenbaum_sign_bound does the matched-pair version, with rosenbaum_gamma_needed inverting it. Every one is a number with a presence flag, because "no answer exists for this input" and "the answer is zero" are opposite outcomes — an out-of-range argument such as a non-positive sensitivity parameter is invalid_parameter with the value absent, not a bound computed from it.

Every refusal names itself

One status vocabulary covers the package and ci_status_name turns any code into a name: graph_is_not_acyclic, node_index_out_of_range, effect_not_identified, no_common_support, propensity_at_zero_or_one, no_treated_units, no_control_units, treatment_not_binary, no_unit_matched_within_caliper, fit_did_not_converge, singular_design, insufficient_data, duplicate_or_overlapping_node, work_budget_exceeded, invalid_parameter.

A three-arm design is treatment_not_binary rather than silently pooling everything that is not 1 into control. A stratum label out of range is refused rather than indexed. Every estimate sits beside a has_* flag, and where that flag is False the number next to it is padding and not a claim.

See also

Last updated

Was this helpful?