using CausalGraphs
using CausalEstimate
import CausalGraphs: identify, make_graph, to_mermaid, markov_pillow, is_fix, is_p_fix, ID_algorithm, estimate_causal, estimate_id
import CausalEstimate: estimate, confint, pvalue
using DataFrames
using Random
using Statistics
using Logging3 DAGs, ADMGs, Identification, and Estimation
The potential-outcomes chapter introduced the question: when can an observed data set tell us about \(E[Y(1)-Y(0)]\)? Graphs are one way to make the answer explicit. A graph records which variables are assumed to cause other variables, and whether some observed variables may share unobserved common causes. Once the graph is stated, identification becomes a graph problem. Estimation is the next step: estimate the functional that the graph identified.
In this chapter we use CausalGraphs.jl for the graph and identification steps, then CausalEstimate.jl for backdoor/a-fixable effects, and CausalGraphs.estimate_causal directly for p-fixable and nested-fixable effects.
3.1 DAGs and ADMGs
A directed acyclic graph, or DAG, has directed arrows such as X -> A. In causal work, an arrow means a direct causal relation is allowed by the model. Absence of an arrow is also a substantive assumption.
An acyclic directed mixed graph, or ADMG, adds bidirected arrows such as A <-> Y. A bidirected edge represents an unobserved common cause. This is useful because many econometric applications have variables we cannot measure: ability, preferences, latent health, firm quality, and so on.
The graph is not an estimator. It is a compact statement of assumptions. The workflow is:
- write down a DAG or ADMG;
- ask whether the target effect is identified;
- estimate the identified functional from data.
3.2 A DAG: Backdoor Adjustment
Start with a simple observational study. A is treatment, Y is the outcome, and X is an observed confounder:
g_dag = make_graph(
vertices = [:X, :A, :Y],
di_edges = [(:X, :A), (:X, :Y), (:A, :Y)],
)ADMG([:X, :A, :Y], [(:X, :A), (:X, :Y), (:A, :Y)], Tuple{Symbol, Symbol}[], Dict{Symbol, Vector{Symbol}}(), Dict{Symbol, Bool}(:A => 0, :X => 0, :Y => 0))
println("```{mermaid}")
println(to_mermaid(g_dag; direction = "LR"))
println("```")flowchart LR n1_X["X"] n2_A["A"] n3_Y["Y"] n1_X --> n2_A n1_X --> n3_Y n2_A --> n3_Y classDef fixed stroke:#111827,stroke-width:2px linkStyle 0 stroke:#2563eb,stroke-width:1.8px linkStyle 1 stroke:#2563eb,stroke-width:1.8px linkStyle 2 stroke:#2563eb,stroke-width:1.8px
The graph says X affects both treatment and outcome. It also says there is no unobserved common cause between A and Y after we condition on X. In this case the treatment is a-fixable, which corresponds to backdoor-style identification.
id_dag = identify(g_dag, :A, :Y)
(strategy = id_dag.strategy,
markov_pillow = markov_pillow(g_dag, :A; treatment = :A))(strategy = :a_fixable, markov_pillow = [:X])
The Markov pillow is the adjustment set used by the estimator. Under this graph,
\[ E[Y(a)] = E_X\{E(Y \mid A=a, X)\}. \]
So the graph has translated a causal query into a regression-style observed data functional.
3.3 Estimating the DAG Effect with CausalEstimate
Here is a small simulated data set where X confounds the treatment-outcome association.
Random.seed!(2026)
n = 400
X = randn(n)
pA = logistic.(-0.2 .+ 0.8 .* X)
A = Float64.(rand(n) .< pA)
Y = 1 .+ 1.5 .* A .+ 0.7 .* X .+ 0.3 .* randn(n)
df_dag = DataFrame(X = X, A = A, Y = Y)
first(df_dag, 5)| Row | X | A | Y |
|---|---|---|---|
| Float64 | Float64 | Float64 | |
| 1 | -0.91813 | 1.0 | 1.76741 |
| 2 | 0.313593 | 1.0 | 2.77638 |
| 3 | -1.23885 | 0.0 | 0.130173 |
| 4 | 0.546413 | 0.0 | 1.43362 |
| 5 | 0.114729 | 0.0 | 0.906974 |
Because the graph is a-fixable, GraphID routes to AIPW backdoor adjustment. The call below estimates the average contrast \(E[Y(1)-Y(0)]\).
dag_res = estimate(
ATE(outcome = :Y, treatment = :A),
GraphID(graph = g_dag),
AIPW(crossfit = 2),
df_dag,
)
effect_table("Backdoor ACE", dag_res)| Row | estimand | estimate | lower_95 | upper_95 | se |
|---|---|---|---|---|---|
| String | Float64 | Float64 | Float64 | Float64 | |
| 1 | Backdoor ACE | 1.443 | 1.221 | 1.665 | 0.1134 |
The graph chose the adjustment set (Markov pillow of A) and the estimator used that set. Access it via dag_res.components[:adjustment_set].
3.4 An ADMG: Front-Door Identification
Now suppose treatment and outcome share an unobserved common cause. A simple DAG adjustment argument no longer works. The front-door ADMG has a mediator M that transmits the effect of A to Y, while A and Y are hidden-confounded:
g_fd = make_graph(
vertices = [:A, :M, :Y],
di_edges = [(:A, :M), (:M, :Y)],
bi_edges = [(:A, :Y)],
)ADMG([:A, :M, :Y], [(:A, :M), (:M, :Y)], [(:A, :Y)], Dict{Symbol, Vector{Symbol}}(), Dict{Symbol, Bool}(:M => 0, :A => 0, :Y => 0))
println("```{mermaid}")
println(to_mermaid(g_fd; direction = "LR"))
println("```")flowchart LR n1_A["A"] n2_M["M"] n3_Y["Y"] n1_A --> n2_M n2_M --> n3_Y n1_A <--> n3_Y classDef fixed stroke:#111827,stroke-width:2px linkStyle 0 stroke:#2563eb,stroke-width:1.8px linkStyle 1 stroke:#2563eb,stroke-width:1.8px linkStyle 2 stroke:#dc2626,stroke-width:1.8px
The bidirected edge A <-> Y means that ordinary backdoor adjustment is not available. But the graph is p-fixable, the ADMG generalization that includes front-door identification.
id_fd = identify(g_fd, :A, :Y)
(strategy = id_fd.strategy,
a_fixable = is_fix(g_fd, :A),
p_fixable = is_p_fix(g_fd, :A))(strategy = :p_fixable, a_fixable = false, p_fixable = true)
The intuition is that M carries the causal effect of A, and M is not itself hidden-confounded with A. The graph lets us use mediator information to recover the causal effect even though A and Y are confounded.
3.5 Estimating the ADMG Effect with CausalGraphs
Simulate a front-door setting. The latent U is not included in the observed data; it is represented in the graph by A <-> Y.
Random.seed!(2027)
U = randn(n)
A_fd = Float64.(rand(n) .< logistic.(-0.1 .+ 0.8 .* U))
M = Float64.(rand(n) .< logistic.(-0.4 .+ 1.2 .* A_fd))
Y_fd = Float64.(rand(n) .< logistic.(-1 .+ 0.9 .* M .+ 0.7 .* U))
df_fd = DataFrame(A = A_fd, M = M, Y = Y_fd)
first(df_fd, 5)| Row | A | M | Y |
|---|---|---|---|
| Float64 | Float64 | Float64 | |
| 1 | 1.0 | 0.0 | 0.0 |
| 2 | 0.0 | 0.0 | 0.0 |
| 3 | 0.0 | 0.0 | 0.0 |
| 4 | 0.0 | 0.0 | 1.0 |
| 5 | 0.0 | 1.0 | 1.0 |
Since identify() returns :p_fixable, this is a front-door effect. CausalEstimate.jl currently handles backdoor (a-fixable) graphs; for p-fixable effects we call CausalGraphs.estimate_causal directly, which routes to NPS TMLE.
fd_res = with_logger(NullLogger()) do
CausalGraphs.estimate_causal(
a = [1.0, 0.0],
data = df_fd,
graph = g_fd,
treatment = :A,
outcome = :Y,
n_iter = 80,
cvg_criteria = 0.02,
truncate_lower = 0.01,
truncate_upper = 0.99,
)
end
effect_table("Front-door ACE", fd_res[:TMLE])| Row | estimand | estimate | lower_95 | upper_95 | se |
|---|---|---|---|---|---|
| String | Float64 | Float64 | Float64 | Missing | |
| 1 | Front-door ACE | 0.05239 | 0.02208 | 0.0827 | missing |
This is the main practical advantage of separating identification from estimation. The user states the graph once. The software checks the graph and routes the effect to the estimator that matches the identified functional.
3.6 General ID Algorithm
The specialized routes above are useful because they have named estimators: backdoor/a-fixable, p-fixable/front-door, and nested-fixable. ADMGs can be more general. The Pearl-Shpitser ID algorithm asks the broader question:
Can P(Y | do(A)) be written using only the observed joint law P(V)?
The algorithm works recursively:
- remove variables that are not ancestors of the outcome;
- add interventions that are irrelevant after graph surgery;
- split the remaining graph into bidirected districts;
- identify each district as a factor of the observed law, if possible;
- return a hedge witness when no such reduction is possible.
For the front-door graph, the general ID algorithm also succeeds:
id_alg_fd = ID_algorithm(g_fd, :A, :Y)
(identified = id_alg_fd.identified,
expression = string(id_alg_fd.expression))(identified = true, expression = "sum_{M} P(M | A) * sum_{A} P(A) * P(Y | A, M)")
The expression is symbolic. It is not yet an estimate. For finite-support variables, CausalGraphs.estimate_causal evaluates the symbolic functional by empirical probabilities and attaches a finite-support EIF standard error via the :IDPlugin key.
id_res = with_logger(NullLogger()) do
estimate_id(
a = [1.0, 0.0],
data = df_fd,
graph = g_fd,
treatment = :A,
outcome = :Y,
)
end
effect_table("ID plug-in ACE", id_res[:IDPlugin];
se = id_res[:IDPlugin].standard_error)| Row | estimand | estimate | lower_95 | upper_95 | se |
|---|---|---|---|---|---|
| String | Float64 | Float64 | Float64 | Float64 | |
| 1 | ID plug-in ACE | 0.05346 | 0.02278 | 0.08413 | 0.01565 |
This plug-in estimator is useful for discrete examples and diagnostics. It is not a universal TMLE for every continuous ID functional.
3.7 When the Graph Says No
The bow graph has both a direct causal edge and unobserved confounding between the same two variables:
g_bow = make_graph(
vertices = [:A, :Y],
di_edges = [(:A, :Y)],
bi_edges = [(:A, :Y)],
)ADMG([:A, :Y], [(:A, :Y)], [(:A, :Y)], Dict{Symbol, Vector{Symbol}}(), Dict{Symbol, Bool}(:A => 0, :Y => 0))
println("```{mermaid}")
println(to_mermaid(g_bow; direction = "LR"))
println("```")flowchart LR n1_A["A"] n2_Y["Y"] n1_A --> n2_Y n1_A <--> n2_Y classDef fixed stroke:#111827,stroke-width:2px linkStyle 0 stroke:#2563eb,stroke-width:1.8px linkStyle 1 stroke:#dc2626,stroke-width:1.8px
The effect of A on Y is not identified in this ADMG. The observed association mixes the direct causal effect with the latent common cause.
id_bow = identify(g_bow, :A, :Y)
id_alg_bow = ID_algorithm(g_bow, :A, :Y)
(route = id_bow.strategy,
id_algorithm_identified = id_alg_bow.identified,
hedge = id_alg_bow.hedge)(route = :not_identified, id_algorithm_identified = false, hedge = (S = [:Y], F = [:A, :Y], treatment = [:A], outcome = [:Y]))
This is a feature, not a failure. If the graph does not justify the causal effect, the estimator should not manufacture one.
3.8 Practical Workflow
For applied work, a useful workflow is:
- write the graph before looking at estimates;
- use bidirected edges for latent common causes;
- run
identify(graph, treatment, outcome); - if identified and a-fixable, call
estimate(ATE(...), GraphID(graph=...), AIPW(...), df); for p-fixable or nested-fixable effects, callCausalGraphs.estimate_causal(...); - report the graph and the identification route along with the estimate.