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]]
|
|
required |
lags
|
int
|
how far back to unroll. Slices are |
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
|
works on it unchanged. |
Raises:
| Type | Description |
|---|---|
ValueError
|
on a negative |
CyclicGraphError
|
if the contemporaneous edges cycle within a slice. Edges with
|
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 |
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.