24  From Graph to Estimate

Code
using CausalGraphs
using DataFrames
using Random
using Statistics
using CairoMakie
CairoMakie.activate!(type = "png")

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.

Code
# 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 — the average causal effect, which is the ATE of the earlier chapters under a name CausalEstimate.jl uses for the field it returns.

Code
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\).

Code
# 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.

Code
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:

Code
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.

Code
# 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:

Code
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 What changes after identification

Backdoor adjustment is not always available because many realistic DAGs have hidden confounders. Front-door, nested, and general ID algorithms may still identify the effect, although their formulas quickly become difficult to derive by hand.

The identification result also determines the estimator. TMLE for a backdoor adjustment is different from TMLE for a front-door formula. Automating this choice helps us avoid applying an estimator that does not match the graph. It also forces us to state which variables support an unconfoundedness claim instead of leaving the claim implicit.

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.