Skip to content

Commit a5857d4

Browse files
rootcoder007claude
andcommitted
feat(mrm): causal DAGs and the bundled MRM structures
Ports rmorie's morie_dag and morie_mrm_dags. A DAG here is a checked container over an edge list that carries its own acyclicity proof by Kahn's algorithm, the same way the R side does, and keeps edges as (from, to) tuples -- the representation morie.viz.dag_plot and morie.fn.bdcrt.backdoor_criterion already take, so a graph built here plots and identifies without conversion. The back-door check delegates to fn.bdcrt rather than reimplementing Pearl's criterion. Both bundled structures encode the same shape, which is the shape that makes an unadjusted comparison misleading: a set of common causes of both the exposure and the outcome. In `placement` those are race, prior record and age; in `use_of_force`, neighbourhood and race. Verified rather than asserted: * Parity with morie_mrm_dags(): node sets, edge sets, exposure and outcome for both graphs IDENTICAL. * The back-door facts, checkable by hand on these graphs: the empty set does NOT satisfy the criterion for either graph, the full common-cause set does, and a proper subset does not. * 7 tests here, 32 across the three MRM files, all passing. The module is mrm_graphs.py, not mrm_dags.py: a module sharing a name with one of its functions shadows it through the lazy export map, so morie.mrm_dags resolved to the module whenever the submodule had been imported first. That is import-order dependent, which is worse than a plain failure -- the parity script passed before the tests caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GVZR4QjrTXCv55j4qdo6E2
1 parent b0e4de9 commit a5857d4

3 files changed

Lines changed: 251 additions & 0 deletions

File tree

src/morie/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@
156156
"mrm_load_si_dataset": "mrm_flagship",
157157
"mrm_reconcile": "mrm_flagship",
158158
"mrm_report": "mrm_flagship",
159+
"causal_dag": "mrm_graphs",
160+
"mrm_dags": "mrm_graphs",
159161
"mrm_check_balancing": "mrm_diagnostics",
160162
"mrm_check_overlap": "mrm_diagnostics",
161163
"mrm_classify_mandela": "mrm_otis",
@@ -409,6 +411,8 @@ def load_sample(name: str):
409411
"mrm_load_si_dataset",
410412
"mrm_reconcile",
411413
"mrm_report",
414+
"causal_dag",
415+
"mrm_dags",
412416
# Tier 1 diagnostics
413417
"mrm_standardised_difference",
414418
"mrm_check_balancing",

src/morie/mrm_graphs.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
"""Causal DAGs for MRM, and the bundled starting-point structures.
3+
4+
Parity with rmorie's R/dag_native.R (`morie_dag`, `morie_mrm_dags`).
5+
6+
A DAG here is a thin, checked container over an edge list. It carries
7+
its own acyclicity proof and keeps edges as ``(from, to)`` tuples, which
8+
is the representation `morie.viz.dag_plot` and
9+
`morie.fn.bdcrt.backdoor_criterion` already take -- so a graph built
10+
here plots and identifies without conversion.
11+
12+
The bundled structures are starting points for justice-system work, not
13+
claims about a particular jurisdiction. Both encode the same shape: a
14+
set of common causes of the exposure and the outcome, which is exactly
15+
the shape that makes an unadjusted comparison misleading.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from dataclasses import dataclass, field
21+
22+
from morie.fn.bdcrt import backdoor_criterion as _backdoor_criterion
23+
24+
__all__ = ["CausalDag", "causal_dag", "mrm_dags"]
25+
26+
27+
@dataclass
28+
class CausalDag:
29+
nodes: list[str]
30+
edges: list[tuple[str, str]]
31+
exposure: str
32+
outcome: str
33+
latent: list[str] = field(default_factory=list)
34+
35+
def __repr__(self) -> str:
36+
return ("CausalDag: %d nodes, %d edges, %s -> %s%s"
37+
% (len(self.nodes), len(self.edges), self.exposure,
38+
self.outcome,
39+
"" if not self.latent
40+
else " (latent: %s)" % ", ".join(self.latent)))
41+
42+
def parents(self, node: str) -> list[str]:
43+
return [u for u, v in self.edges if v == node]
44+
45+
def children(self, node: str) -> list[str]:
46+
return [v for u, v in self.edges if u == node]
47+
48+
def backdoor(self, adjust=()):
49+
"""Does `adjust` satisfy the back-door criterion for this graph?
50+
51+
Delegates to morie.fn.bdcrt, which implements Pearl's two
52+
conditions; the result exposes `.satisfied`.
53+
"""
54+
if isinstance(adjust, str):
55+
adjust = (adjust,)
56+
return _backdoor_criterion(self.edges, self.exposure,
57+
self.outcome, tuple(adjust))
58+
59+
60+
def _parse_edge(e) -> tuple[str, str]:
61+
if isinstance(e, (tuple, list)):
62+
if len(e) != 2 or not all(str(p).strip() for p in e):
63+
raise ValueError("edge must be a pair of node names: %r" % (e,))
64+
return (str(e[0]).strip(), str(e[1]).strip())
65+
parts = [p.strip() for p in str(e).split("->")]
66+
if len(parts) != 2 or not all(parts):
67+
raise ValueError("edge must look like 'A -> B': %s" % e)
68+
return (parts[0], parts[1])
69+
70+
71+
def causal_dag(edges, exposure: str, outcome: str,
72+
latent=()) -> CausalDag:
73+
"""Build a DAG from edges, rejecting cycles.
74+
75+
`edges` may be ``"A -> B"`` strings or ``(from, to)`` pairs. The
76+
exposure and the outcome must both appear in the graph: a DAG whose
77+
exposure is not in it cannot identify anything, and saying so here
78+
beats an empty adjustment set later.
79+
"""
80+
if isinstance(edges, str):
81+
edges = [edges]
82+
em = [_parse_edge(e) for e in edges]
83+
if not em:
84+
raise ValueError("`edges` must contain at least one edge")
85+
nodes: list[str] = []
86+
for u, v in em:
87+
for n in (u, v):
88+
if n not in nodes:
89+
nodes.append(n)
90+
if exposure not in nodes:
91+
raise ValueError("exposure not in graph: %s" % exposure)
92+
if outcome not in nodes:
93+
raise ValueError("outcome not in graph: %s" % outcome)
94+
95+
# Acyclicity by Kahn's algorithm, as the R side does. Counting the
96+
# nodes it can retire is the proof: anything left over is in a cycle.
97+
indeg = {n: 0 for n in nodes}
98+
for _, v in em:
99+
indeg[v] += 1
100+
queue = [n for n in nodes if indeg[n] == 0]
101+
seen = 0
102+
while queue:
103+
v = queue.pop(0)
104+
seen += 1
105+
for w in [b for a, b in em if a == v]:
106+
indeg[w] -= 1
107+
if indeg[w] == 0:
108+
queue.append(w)
109+
if seen != len(nodes):
110+
raise ValueError("graph has a cycle")
111+
112+
if isinstance(latent, str):
113+
latent = [latent]
114+
return CausalDag(nodes=nodes, edges=em, exposure=exposure,
115+
outcome=outcome, latent=list(latent))
116+
117+
118+
def mrm_dags() -> dict[str, CausalDag]:
119+
"""The bundled MRM starting-point graphs.
120+
121+
`placement`: race, prior record and age are common causes of both
122+
the placement decision and the outcome, so comparing outcomes by
123+
placement without adjusting for them confounds the three.
124+
125+
`use_of_force`: neighbourhood and race cause both police contact and
126+
force, so force conditional on contact is not the effect of contact.
127+
"""
128+
return {
129+
"placement": causal_dag(
130+
[
131+
"race -> placement", "race -> outcome",
132+
"prior_record -> placement", "prior_record -> outcome",
133+
"age -> placement", "age -> outcome",
134+
"placement -> outcome",
135+
],
136+
exposure="placement", outcome="outcome"),
137+
"use_of_force": causal_dag(
138+
[
139+
"neighbourhood -> police_contact",
140+
"neighbourhood -> force",
141+
"race -> police_contact", "race -> force",
142+
"police_contact -> force",
143+
],
144+
exposure="police_contact", outcome="force"),
145+
}

tests/test_mrm_dags.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
"""Causal DAGs for MRM, and the bundled structures.
3+
4+
Parity with rmorie's R/dag_native.R. The back-door assertions are
5+
graph-theory facts about these specific graphs, independently checkable
6+
by hand, so they can fail.
7+
"""
8+
9+
import pytest
10+
11+
import morie
12+
13+
14+
def test_edges_parse_from_arrows_or_pairs():
15+
a = morie.causal_dag(["x -> y"], exposure="x", outcome="y")
16+
b = morie.causal_dag([("x", "y")], exposure="x", outcome="y")
17+
assert a.edges == b.edges == [("x", "y")]
18+
assert a.nodes == ["x", "y"]
19+
# whitespace around the arrow is not significant
20+
c = morie.causal_dag([" x->y "], exposure="x", outcome="y")
21+
assert c.edges == [("x", "y")]
22+
# a lone string is one edge, not a sequence of characters
23+
d = morie.causal_dag("x -> y", exposure="x", outcome="y")
24+
assert d.edges == [("x", "y")]
25+
26+
27+
def test_a_cycle_is_rejected():
28+
with pytest.raises(ValueError, match="cycle"):
29+
morie.causal_dag(["a -> b", "b -> c", "c -> a"],
30+
exposure="a", outcome="c")
31+
# a diamond is acyclic and must be accepted
32+
g = morie.causal_dag(["a -> b", "a -> c", "b -> d", "c -> d"],
33+
exposure="a", outcome="d")
34+
assert len(g.nodes) == 4
35+
36+
37+
def test_exposure_and_outcome_must_be_in_the_graph():
38+
with pytest.raises(ValueError, match="exposure not in graph"):
39+
morie.causal_dag(["a -> b"], exposure="zzz", outcome="b")
40+
with pytest.raises(ValueError, match="outcome not in graph"):
41+
morie.causal_dag(["a -> b"], exposure="a", outcome="zzz")
42+
with pytest.raises(ValueError, match="at least one edge"):
43+
morie.causal_dag([], exposure="a", outcome="b")
44+
with pytest.raises(ValueError, match="'A -> B'"):
45+
morie.causal_dag(["a ~ b"], exposure="a", outcome="b")
46+
47+
48+
def test_parents_and_children():
49+
g = morie.mrm_dags()["placement"]
50+
assert sorted(g.parents("outcome")) == \
51+
["age", "placement", "prior_record", "race"]
52+
assert g.children("race") == ["placement", "outcome"]
53+
assert g.parents("race") == []
54+
55+
56+
def test_bundled_dags_match_rmorie():
57+
d = morie.mrm_dags()
58+
assert sorted(d) == ["placement", "use_of_force"]
59+
60+
p = d["placement"]
61+
assert p.exposure == "placement" and p.outcome == "outcome"
62+
assert sorted(p.nodes) == ["age", "outcome", "placement",
63+
"prior_record", "race"]
64+
assert sorted("%s>%s" % e for e in p.edges) == [
65+
"age>outcome", "age>placement", "placement>outcome",
66+
"prior_record>outcome", "prior_record>placement",
67+
"race>outcome", "race>placement"]
68+
69+
u = d["use_of_force"]
70+
assert u.exposure == "police_contact" and u.outcome == "force"
71+
assert sorted(u.nodes) == ["force", "neighbourhood", "police_contact",
72+
"race"]
73+
assert sorted("%s>%s" % e for e in u.edges) == [
74+
"neighbourhood>force", "neighbourhood>police_contact",
75+
"police_contact>force", "race>force", "race>police_contact"]
76+
77+
78+
def test_the_common_causes_are_what_must_be_adjusted():
79+
# THE SUBSTANCE of both bundled graphs: the confounders are common
80+
# causes of exposure and outcome, so the unadjusted comparison is
81+
# confounded and the full set closes the back doors.
82+
p = morie.mrm_dags()["placement"]
83+
assert p.backdoor(()).satisfied is False
84+
assert p.backdoor(("race", "prior_record", "age")).satisfied is True
85+
# a proper subset leaves a back door open
86+
assert p.backdoor(("race",)).satisfied is False
87+
88+
u = morie.mrm_dags()["use_of_force"]
89+
assert u.backdoor(()).satisfied is False
90+
assert u.backdoor(("neighbourhood", "race")).satisfied is True
91+
# a bare string is one node, not a sequence of characters
92+
assert u.backdoor("race").satisfied is False
93+
94+
95+
def test_edges_are_the_representation_the_rest_of_morie_takes():
96+
# dag_plot and fn.bdcrt both take [(from, to)], so a graph built here
97+
# needs no conversion.
98+
g = morie.mrm_dags()["placement"]
99+
assert all(isinstance(e, tuple) and len(e) == 2 for e in g.edges)
100+
from morie.fn.bdcrt import backdoor_criterion
101+
assert backdoor_criterion(g.edges, g.exposure, g.outcome,
102+
("race", "prior_record", "age")).satisfied

0 commit comments

Comments
 (0)