Skip to content

chc.graph

Stability tier: stable.

Named causal DAGs: which covariates to adjust for, decided by the graph instead of by hand.

Every effect estimator in this library takes its adjustment set as a literal tuple of column names --- BackdoorOLS().estimate(data, covariates=("x", "z")). That tuple is a causal claim typed by hand, and the two ways it goes wrong are silent: adjusting for a collider opens a path that was closed (M-bias), and adjusting for a mediator removes part of the effect being estimated. Neither shows up as an error, a warning, or a bad fit; both show up as a wrong number.

CausalGraph takes the assumption where it belongs --- in the graph --- and derives the tuple. CausalGraph.adjustment_set returns the canonical set of Perkovic, Textor, Kalisch and Maathuis (2018), Adjust(X, Y, O) = (an(X u Y) n O) \ forb(X, Y), which is valid iff any set of observed variables is: an empty result therefore means "no adjustment needed", and a not_identified status means "no covariate adjustment identifies this effect", which is a different statement and is worth failing on rather than guessing past.

This is a graphical criterion, so it inherits the graph's assumptions and nothing more. It says which set to adjust for given the DAG; it does not test the DAG, and a missing edge is still a missing edge. Discovery of the lagged graph from data is chc.discovery, whose LaggedGraph is a different object on purpose: that one is indexed, boolean and time-lagged, for the dynamics; this one is named, static and cross-sectional, for estimators.

Pure Python over names and sets --- no NumPy, no JAX, nothing to trace. Graphs here are tens of nodes, and the algorithms are linear in the edge count.

AdjustmentStatus = Literal['identified', 'not_identified'] module-attribute

Whether some set of observed covariates identifies the effect by adjustment.

CyclicGraphError

Bases: ValueError

A CausalGraph was built from edges containing a directed cycle.

AdjustmentSet dataclass

The covariates to adjust for, or the statement that no such set exists.

covariates is empty in two entirely different situations, which is why status is reported beside it and not derived from emptiness: an identified effect needing no adjustment (a randomised lever) and a not identified one (confounded through a latent) both have nothing to condition on. Passing the first into an estimator is correct; passing the second is a wrong number with no symptom.

__bool__()

True iff the effect is identified by adjustment, regardless of set size.

Deliberately not len(self.covariates) > 0: the truthiness a caller wants from this object is "may I proceed", and the empty-but-identified case must answer yes.

CausalGraph dataclass

A directed acyclic graph over named variables, some of which may be unobserved.

Nodes are column names, so a graph and a Panel share one vocabulary and a typo is caught by require_columns rather than by a silent KeyError three layers down. Latent nodes carry the confounding a dataset cannot adjust for; they are what makes not_identified reachable, and a graph with none of them can always adjust for something.

observed property

Nodes a dataset could contain a column for, in insertion order.

from_edges(edges, *, latent=()) classmethod

Build from (parent, child) pairs, rejecting cycles and unknown latent names.

Nodes are collected from the edges, so an isolated node needs no declaration and cannot be declared; that is the right trade for a graph whose only use is path reasoning, where a node with no edges changes no answer.

Raises:

Type Description
CyclicGraphError

if the edges contain a directed cycle, naming one of its nodes.

ValueError

if a latent name is not a node, or an edge is a self-loop.

lagged_name(name, lag=0) staticmethod

The node name carries at lag steps into the past: x[t], x[t-1], ...

Exists so a caller naming a treatment or an outcome for unrolled does not hand-format the string and drift from what the constructor produced. The format is an implementation detail of the pair, not a contract with the caller.

unrolled(edges, *, lags, latent=()) classmethod

Unroll (parent, child, lag) edges over time into an ordinary static DAG.

A time series has one more thing to say than a cross-section: when a parent acts. Rather than teach every method on this class about lags -- which would fork d_separated, adjustment_set, _proper_backdoor and the rest -- the time index is pushed into the node name, and the criterion stays one algorithm over one edge format. The cost is node count, which is (lags + 1) times the variable count and irrelevant at this scale.

An edge (p, c, k) means p at time t-k causes c at time t, instantiated at every slice where both endpoints exist. k = 0 is contemporaneous. A self-edge is allowed at k >= 1 and is the ordinary autoregressive term -- ("x", "x", 1) becomes x[t-1] -> x[t], which is not a self-loop once time is explicit.

Parameters:

Name Type Description Default
edges Iterable[tuple[str, str, int]]

(parent, child, lag) triples, lag >= 0.

required
lags int

how far back to unroll. Slices are t-lags ... t, so lags = 0 keeps only the contemporaneous structure. Named lags not horizon deliberately: in this library horizon is a plan length (chc.decision.prescribe), and one name meaning two things is how a caller gets a graph one slice short.

required
latent Iterable[str]

variable names that are unobserved. Latency is a property of the variable, not of a slice, so a latent name is latent at every lag.

()

Returns:

Name Type Description
A CausalGraph

class:CausalGraph over lagged_name-formatted nodes. Everything on the class

CausalGraph

works on it unchanged.

Raises:

Type Description
ValueError

on a negative lags or lag, or on a contemporaneous self-edge.

CyclicGraphError

if the contemporaneous edges cycle within a slice. Edges with lag >= 1 cannot: time decreases strictly along them.

ancestors(nodes, *, inclusive=True)

Every node with a directed path into nodes; inclusive adds nodes itself.

descendants(nodes, *, inclusive=True)

Every node reachable from nodes by a directed path; inclusive adds the source.

require_columns(available)

Check that every observed node has a column, naming all the missing ones at once.

Raises:

Type Description
KeyError

listing every observed node absent from available. Reporting them together rather than one per run is the difference between one fix and five.

d_separated(first, second, given=())

Whether every path between the two sets is blocked by given (Bayes-ball).

Koller and Friedman's reachability algorithm: walk (node, direction) states outward from the first set, where a collider passes the walk on only if it or one of its descendants is conditioned on. Linear in the edge count, and the primitive every other method here is written in terms of.

is_valid_adjustment_set(covariates, *, treatment, outcome)

The generalised adjustment criterion for covariates relative to (X, Y).

Two conditions, and both are necessary: no covariate lies in forb(X, Y) (which is what rules out mediators and their descendants), and the covariates d-separate X from Y in the proper back-door graph --- the DAG with the first edge of every proper causal path deleted, so that only the non-causal paths remain to be blocked.

adjustment_set(*, treatment, outcome)

The canonical adjustment set over the observed nodes, or why there is none.

Adjust(X, Y, O) = (an(X u Y) n O) \ forb(X, Y) is valid iff some subset of the observed nodes is (Perkovic et al. 2018, Theorem 3.4 and the O-restricted corollary), so one construction answers both questions: build it, check it, and a failed check is a proof that nothing else would have worked either.