24  From Graph to Estimate

using CausalGraphs
using DataFrames
using Random
using Statistics
using CairoMakie

The previous chapters covered discovery: algorithms that learn a graph from data. But a graph is not an estimate. After we have a graph, we still need to ask two questions:

  1. Identification: is the target effect a function of the observed-data distribution?
  2. Estimation: once the identifying formula is known, estimate it from the data.

CausalGraphs.jl implements both steps for acyclic directed mixed graphs (ADMGs) — directed graphs that may also have bidirected edges representing unmeasured common causes. ADMGs are the natural output of discovery algorithms like FCI when there are latent confounders. They are also the natural way to encode researcher hypotheses about which arrows are causal and which open paths represent hidden confounding.

Here are three cases: backdoor identification, front-door identification, and non-identification.

24.1 Backdoor: a-fixable effects

Start with the simple case. \(X\) is a common cause of treatment \(A\) and outcome \(Y\), and \(X\) is observed. Backdoor adjustment applies. In the package terminology this is a_fixable.

# Build the graph: X is a common cause of A and Y
g_backdoor = make_graph(
    vertices = [:X, :A, :Y],
    di_edges = [(:X, :A), (:X, :Y), (:A, :Y)],
)

id_backdoor = identify(g_backdoor, :A, :Y)
@printf("Identification strategy: %s\n", id_backdoor.strategy)
Identification strategy: a_fixable

The package recognises the backdoor structure: X blocks the only open backdoor path from \(A\) to \(Y\), so adjusting on \(X\) identifies the ACE.

Random.seed!(1)
n = 2000
X = randn(n)
A = Float64.(rand(n) .< 1 ./ (1 .+ exp.(-X)))      # treatment depends on X
Y = 2 .* A .+ X .+ 0.5 .* randn(n)                  # true ACE = 2
data_bd = DataFrame(X = X, A = A, Y = Y)

result_bd = estimate_causal(
    a = [1, 0],
    data      = data_bd,
    graph     = g_backdoor,
    treatment = :A,
    outcome   = :Y,
)

@printf("\nBackdoor estimation (true ACE = 2.0):\n")
@printf("  TMLE  ACE: %.3f  [95%% CI: %.3f, %.3f]\n",
        result_bd[:TMLE].ACE, result_bd[:TMLE].lower_ci, result_bd[:TMLE].upper_ci)
@printf("  AIPW  ACE: %.3f  [95%% CI: %.3f, %.3f]\n",
        result_bd[:Onestep].ACE, result_bd[:Onestep].lower_ci, result_bd[:Onestep].upper_ci)
@printf("  IPW   ACE: %.3f\n", result_bd[:IPW].ACE)
@printf("  GCOMP ACE: %.3f\n", result_bd[:Gcomp].ACE)

Backdoor estimation (true ACE = 2.0):
  TMLE  ACE: 2.018  [95% CI: 1.972, 2.065]
  AIPW  ACE: 2.018  [95% CI: 1.972, 2.065]
  IPW   ACE: 2.041
  GCOMP ACE: 2.018

All four estimators recover the true ACE in this simple example. TMLE and AIPW also return confidence intervals.

24.2 Front-door: p-fixable effects

Now suppose \(A\) and \(Y\) have an unmeasured common cause \(U\), but there is a measured mediator \(M\) on the path \(A \rightarrow M \rightarrow Y\). Backdoor adjustment fails because \(U\) is unobserved. The front-door formula can still identify the effect.

In ADMG notation, the unmeasured confounder appears as a bidirected edge between \(A\) and \(Y\).

# A → M → Y, with unmeasured confounding between A and Y (bidirected edge)
g_frontdoor = make_graph(
    vertices = [:A, :M, :Y],
    di_edges = [(:A, :M), (:M, :Y)],
    bi_edges = [(:A, :Y)],
)

id_frontdoor = identify(g_frontdoor, :A, :Y)
@printf("Identification strategy: %s\n", id_frontdoor.strategy)
Identification strategy: p_fixable

The package returns p_fixable, recognizing the front-door structure. This uses a different functional than backdoor adjustment and must model \(M\) too.

Random.seed!(2)
n = 3000
U = randn(n)                                          # unmeasured confounder
A = Float64.(rand(n) .< 1 ./ (1 .+ exp.(-U)))         # A depends on U
M = Float64.(rand(n) .< 1 ./ (1 .+ exp.(-(0.5 .+ 2.0 .* A))))   # M depends on A only
Y = 1.5 .* M .+ 2.0 .* U .+ 0.5 .* randn(n)           # Y depends on M and U
data_fd = DataFrame(A = A, M = M, Y = Y)

# True ACE: effect of setting A=1 vs A=0
# E[Y|do(A=1)] - E[Y|do(A=0)] = 1.5 * (E[M|A=1] - E[M|A=0])
true_M_a1 = 1 / (1 + exp(-(0.5 + 2.0)))   # scalar P(M=1 | A=1)
true_M_a0 = 1 / (1 + exp(-0.5))           # scalar P(M=1 | A=0)
true_ace_fd = 1.5 * (true_M_a1 - true_M_a0)
@printf("True front-door ACE = %.3f\n\n", true_ace_fd)

result_fd = estimate_causal(
    a = [1, 0],
    data      = data_fd,
    graph     = g_frontdoor,
    treatment = :A,
    outcome   = :Y,
)

@printf("Front-door estimation:\n")
@printf("  TMLE  ACE: %.3f  [95%% CI: %.3f, %.3f]\n",
        result_fd[:TMLE].ACE, result_fd[:TMLE].lower_ci, result_fd[:TMLE].upper_ci)
True front-door ACE = 0.453

Front-door estimation:
  TMLE  ACE: 0.484  [95% CI: 0.401, 0.567]

The front-door functional pins down the effect even though \(U\) is unobserved. A naive mean difference is badly biased:

naive_ace = mean(Y[A .== 1]) - mean(Y[A .== 0])
@printf("Naïve mean difference (Y|A=1) - (Y|A=0): %.3f  (biased!)\n", naive_ace)
@printf("Front-door TMLE estimate:                 %.3f\n", result_fd[:TMLE].ACE)
@printf("True ACE:                                 %.3f\n", true_ace_fd)
Naïve mean difference (Y|A=1) - (Y|A=0): 2.132  (biased!)
Front-door TMLE estimate:                 0.484
True ACE:                                 0.453

The naive difference is biased by the path through \(U\).

24.3 When identification fails

Not every graph identifies the effect. If \(A\) and \(Y\) have a hidden common cause and there is no front-door variable, the effect is not identified.

# A → Y with unmeasured confounding and no mediator
g_unident = make_graph(
    vertices = [:A, :Y],
    di_edges = [(:A, :Y)],
    bi_edges = [(:A, :Y)],
)

try
    id_unident = identify(g_unident, :A, :Y)
    @printf("Strategy: %s\n", id_unident.strategy)
catch e
    @printf("Identification failed: %s\n", sprint(showerror, e))
end
Strategy: not_identified

The package returns not_identified (or raises an informative error, depending on the version). The honest response in applied work is to report the non-identifiability, then either:

  1. Find additional variables that close the unmeasured-confounding paths
  2. Apply sensitivity analysis to bound the effect under maintained assumptions
  3. Find an instrumental variable for a LATE-style identification (covered in the IV chapter)

Saying that the hidden confounding is probably small is sensitivity analysis, not identification.

24.4 End-to-end: discover, identify, estimate

The previous chapters’ discovery algorithms (PC, FCI, RSL-D, L-MARVEL) return graphs that can be passed directly to identify() and estimate_causal(). The full pipeline is:

using CausalInference   # for PC algorithm

# Step 1: discover the graph (chapter 11)
graph_pc = pcalg(data, 0.05, gausscitest)

# Step 2: convert to ADMG (if needed; PC returns a CPDAG)
# Use the helpers in chapter 11 to convert CPDAG → ADMG

# Step 3: identify and estimate
id  = identify(graph_admg, :A, :Y)
result = estimate_causal(
    a = [1, 0],
    data = data,
    graph = graph_admg,
    treatment = :A,
    outcome = :Y,
)

In applied work, I would not trust the discovered graph blindly. A more reasonable workflow is:

  1. Hypothesise a graph based on subject-matter knowledge
  2. Discover a data-driven graph using PC/FCI/RSL-D
  3. Compare the two — disagreement points to substantive questions
  4. Identify under both graphs separately
  5. Estimate under both, and report the range as part of the result

If the estimate changes a lot across plausible graphs, the data alone is not settling the question.

24.5 Why this matters

The graph-identification-estimation sequence matters because:

  • Backdoor adjustment is not always available. Many real DAGs have hidden confounders. Front-door, nested, and general ID algorithms are the recourse, but they are too complex to derive by hand for non-trivial graphs.
  • The right estimator depends on the identification strategy. TMLE-for-backdoor is different from TMLE-for-front-door. Automating the routing avoids the common mistake of plugging a graph into the wrong estimator.
  • Reporting becomes more disciplined. Once the workflow forces you to specify a graph, it becomes harder to wave hands about “unconfoundedness” — you have to say exactly which variables are doing the unconfounding work.

24.6 Summary

  • A graph is not an estimate. Identification is the bridge.
  • CausalGraphs.jl routes ADMGs to backdoor, front-door, nested, or non-identified cases.
  • estimate_causal() then runs the estimator that matches the identification strategy.
  • In applied work, hypothesize a graph, use discovery as a check, identify, estimate, and report sensitivity to plausible graph choices.