3  DAGs, ADMGs, Identification, and Estimation

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 Logging

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:

  1. write down a DAG or ADMG;
  2. ask whether the target effect is identified;
  3. 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)
5×3 DataFrame
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)
1×5 DataFrame
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)
5×3 DataFrame
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])
1×5 DataFrame
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:

  1. remove variables that are not ancestors of the outcome;
  2. add interventions that are irrelevant after graph surgery;
  3. split the remaining graph into bidirected districts;
  4. identify each district as a factor of the observed law, if possible;
  5. 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)
1×5 DataFrame
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:

  1. write the graph before looking at estimates;
  2. use bidirected edges for latent common causes;
  3. run identify(graph, treatment, outcome);
  4. if identified and a-fixable, call estimate(ATE(...), GraphID(graph=...), AIPW(...), df); for p-fixable or nested-fixable effects, call CausalGraphs.estimate_causal(...);
  5. report the graph and the identification route along with the estimate.