Python Banner

5.1.3 DragonNet: Propensity-Adjusted Networks for Causal Inference

DragonNet was introduced by Shi, Blei & Veitch (2019). It adds a propensity head and targeted regularization to the shared-representation architecture.

Overview

DragonNet is a deep learning architecture that explicitly incorporates propensity score estimation into the training process. It consists of a shared representation layer followed by three separate heads: one for predicting the propensity score and two for predicting potential outcomes under treatment and control. The key innovation is a targeted regularization term derived from the theory of targeted maximum likelihood estimation (TMLE), which encourages the model to learn representations that are sufficient for causal estimation. The result is a model that can achieve doubly-robust ATE estimation — meaning that even if either the outcome model or the propensity model is misspecified, the ATE estimate remains consistent.

The DragonNet Architecture

The shared representation Φ(x) feeds into three heads:

The Core Insight: Why Propensity Matters

The propensity score e(x) = P(T=1 | X=x) is the probability that a unit receives treatment, given its covariates. It is the fundamental tool in classical causal inference for de-confounding observational data.

The key theoretical result behind DragonNet comes from the sufficient representation theorem (Shi et al., 2019):

If a representation Φ(x) is sufficient for predicting the propensity score, it is also sufficient for estimating any causal quantity.

In plain terms: you don’t need to capture all of X — you only need to capture the parts of X that are relevant to treatment assignment. Any other variation in X is irrelevant noise for causal estimation and can be discarded. This is a much more targeted form of dimension reduction than TARNet or CFRNet apply.

How DragonNet Works

Step 1 — Shared backbone. All patients are passed through the shared deep network Φ(x), producing a learned representation. This is structurally identical to TARNet’s shared layer, but the training signal it receives is fundamentally different.

Step 2 — Three-headed output. The representation feeds three separate heads simultaneously: - \(g(\Phi(x))\) — the propensity head, which outputs a sigmoid probability \(\hat{e}(x) = \hat{P}(T=1 \mid x)\). This is a binary classifier trained on treatment assignment. - \(Q_0(\Phi(x))\) — the control outcome head - \(Q_1(\Phi(x))\) — the treated outcome head

Step 3 — Targeted regularization. This is DragonNet’s unique contribution. Rather than just adding a propensity prediction loss on top, DragonNet applies a targeted regularization term derived from the theory of targeted maximum likelihood estimation (TMLE). The regularizer nudges the outcome predictions \(Q_0\) and \(Q_1\) toward being doubly robust — meaning that even if either the outcome model or the propensity model is slightly wrong, the final ATE estimate remains consistent.

Step 4 — Joint backpropagation. The combined loss flows back through all three heads and through the shared backbone simultaneously. Because the propensity head and the outcome heads share Φ, the backbone is forced to learn features that are predictive of both treatment assignment and outcomes — and the targeted regularizer ensures these features align with what is causally necessary.

Step 5 — ITE at inference. \(\text{ITE}(x) = \hat{Q}_1(\Phi(x)) - \hat{Q}_0(\Phi(x))\). The propensity head is discarded — it was only needed during training to shape the representation.

The Targeted Regularization Term

The targeted regularization is the most mathematically novel part. Here is what it computes:

The targeted regularization term is derived from the doubly-robust (DR) ATE estimator:

\[\widehat{\text{DR-ATE}} = \mathbb{E}\!\left[\hat{Q}_1 - \hat{Q}_0 + \frac{T(Y - \hat{Q}_1)}{\hat{e}(x)} - \frac{(1-T)(Y - \hat{Q}_0)}{1 - \hat{e}(x)}\right]\]

The two correction terms — the IPW-weighted residuals — penalize the network when its outcome predictions are inconsistent with the propensity score. When these residuals are zero, the model has achieved double robustness. The targeted regularization adds this quantity to the training loss with a small weight, gently steering the backbone toward representations where this consistency holds.

The targeted regularization term is derived from the doubly-robust (DR) ATE estimator:

\[\widehat{\text{DR-ATE}} = \mathbb{E}\!\left[\hat{Q}_1 - \hat{Q}_0 + \frac{T(Y - \hat{Q}_1)}{\hat{e}(x)} - \frac{(1-T)(Y - \hat{Q}_0)}{1 - \hat{e}(x)}\right]\]

The two correction terms — the IPW-weighted residuals — penalize the network when its outcome predictions are inconsistent with the propensity score. When these residuals are zero, the model has achieved double robustness. The targeted regularization adds this quantity to the training loss with a small weight, gently steering the backbone toward representations where this consistency holds.

DragonNet vs TARNet vs CFRNet

TARNet CFRNet DragonNet
Shared representation Yes Yes Yes
Separate outcome heads Yes Yes Yes
Distribution alignment (IPM) No Yes No
Propensity score head No No Yes
Targeted regularization No No Yes
Doubly-robust ATE No No Yes
Extra hyperparameters — \(\lambda\) \(\alpha\) (propensity weight)
Theoretical basis Generalization bounds IPM bounds Sufficient representation + TMLE

The key practical advantage DragonNet has over CFRNet is that it doesn’t need to flatten the entire representation distribution — it only forces the representation to capture propensity-relevant information, which is a much more surgical and theoretically justified constraint. On benchmarks like IHDP and Jobs, DragonNet consistently matches or outperforms CFRNet, especially in low-data regimes where over-regularization from the IPM can hurt.

Implementation in Python

We fit DragonNet with pydeepcausalml.effect.DragonNet on the NSW (nsw_mixtape) dataset.

Check and Install Required Python Packages

The following packages are used in this notebook. Install any missing packages with the cell below:

numpy, pandas, scipy, torch, scikit-learn, matplotlib, seaborn, causaldata, pydeepcausalml

Code
import importlib
import subprocess
import sys
from pathlib import Path

PACKAGES = [
    "numpy", "pandas", "scipy", "torch", "scikit-learn",
    "matplotlib", "seaborn", "causaldata",
]

for pkg in PACKAGES:
    mod = "sklearn" if pkg == "scikit-learn" else pkg
    try:
        importlib.import_module(mod)
    except ImportError:
        subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg])

try:
    import pydeepcausalml  # noqa: F401
except ImportError:
    repo_root = Path("..").resolve()
    subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "-e", str(repo_root)])

print("Packages ready.")

Verify imports

Code
import numpy as np
import pandas as pd
import torch
import matplotlib.pyplot as plt
import seaborn as sns
from pydeepcausalml import set_seed

print("torch:", torch.__version__)
print("cuda:", torch.cuda.is_available())
Code
set_seed(42)

Load real-world data and prepare (X, t, y)

Code
from pathlib import Path
import os
import numpy as np
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import ElasticNet, LogisticRegression, LinearRegression
from sklearn.model_selection import KFold


def load_nsw_mixtape() -> pd.DataFrame:
    """Load NSW (Lalonde) mixtape data (same variables as causaldata::nsw_mixtape)."""
    try:
        import causaldata.nsw_mixtape.data as nsw_data
        dta = Path(nsw_data.__file__).parent / "nsw_mixtape.dta"
        return pd.read_stata(dta)
    except Exception:
        pass
    url = (
        "https://raw.githubusercontent.com/NickCH-K/causaldata/master/"
        "data-raw/nsw_mixtape.dta"
    )
    return pd.read_stata(url)


def propensity_elastic_net(X: np.ndarray, treatment: np.ndarray) -> np.ndarray:
    model = LogisticRegression(
        penalty="elasticnet", solver="saga", l1_ratio=0.5, max_iter=2000
    )
    model.fit(X, treatment)
    return model.predict_proba(X)[:, 1]


class SLearner:
    def __init__(self, learner: str = "ranger"):
        self.learner = learner

    def fit(self, X, t, y):
        Xt = np.column_stack([X, t])
        if self.learner == "lm":
            self.model_ = LinearRegression().fit(Xt, y)
        else:
            self.model_ = RandomForestRegressor(
                n_estimators=100, random_state=42, n_jobs=-1
            ).fit(Xt, y)
        return self

    def predict(self, X):
        y0 = self.model_.predict(np.column_stack([X, np.zeros(len(X))]))
        y1 = self.model_.predict(np.column_stack([X, np.ones(len(X))]))
        return y1 - y0


class TLearner:
    def __init__(self, learner: str = "ranger"):
        self.learner = learner

    def fit(self, X, t, y):
        t = t.astype(int)
        if self.learner == "lm":
            self.m0_ = LinearRegression().fit(X[t == 0], y[t == 0])
            self.m1_ = LinearRegression().fit(X[t == 1], y[t == 1])
        else:
            self.m0_ = RandomForestRegressor(
                n_estimators=100, random_state=42, n_jobs=-1
            ).fit(X[t == 0], y[t == 0])
            self.m1_ = RandomForestRegressor(
                n_estimators=100, random_state=42, n_jobs=-1
            ).fit(X[t == 1], y[t == 1])
        return self

    def predict(self, X):
        return self.m1_.predict(X) - self.m0_.predict(X)


class XLearner:
    def __init__(self, learner: str = "ranger"):
        self.learner = learner

    def _reg(self):
        if self.learner == "lm":
            return LinearRegression()
        return RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1)

    def fit(self, X, t, y, p=None):
        t = t.astype(int)
        p = propensity_elastic_net(X, t) if p is None else p
        m0, m1 = self._reg(), self._reg()
        m0.fit(X[t == 0], y[t == 0])
        m1.fit(X[t == 1], y[t == 1])
        d1 = y[t == 1] - m0.predict(X[t == 1])
        d0 = m1.predict(X[t == 0]) - y[t == 0]
        tau1 = self._reg().fit(X[t == 1], d1)
        tau0 = self._reg().fit(X[t == 0], d0)
        self.p_, self.tau0_, self.tau1_ = p, tau0, tau1
        return self

    def predict(self, X):
        return self.p_ * self.tau0_.predict(X) + (1 - self.p_) * self.tau1_.predict(X)


class RLearner:
    def __init__(self, learner: str = "ranger", n_fold: int = 3):
        self.learner = learner
        self.n_fold = n_fold

    def _reg(self):
        if self.learner == "lm":
            return LinearRegression()
        return RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1)

    def fit(self, X, t, y, p=None):
        t = t.astype(int).astype(float)
        p = propensity_elastic_net(X, t) if p is None else p
        e = t - p
        m = self._reg()
        kf = KFold(n_splits=self.n_fold, shuffle=True, random_state=42)
        y_hat = np.zeros_like(y, dtype=float)
        for tr, va in kf.split(X):
            m.fit(X[tr], y[tr])
            y_hat[va] = m.predict(X[va])
        resid = y - y_hat
        tau = self._reg()
        tau.fit(X, resid / (e + 1e-6), sample_weight=e**2)
        self.tau_ = tau
        return self

    def predict(self, X):
        return self.tau_.predict(X)


def get_cumgain(df_preds, outcome_col="y", treatment_col="w", treatment_effect_col="tau", normalize=True):
    """Cumulative gain curve (CausalML-style AUUC helper)."""
    y = df_preds[outcome_col].to_numpy()
    w = df_preds[treatment_col].to_numpy()
    tau = df_preds[treatment_effect_col].to_numpy()
    model_cols = [c for c in df_preds.columns if c not in {outcome_col, treatment_col, treatment_effect_col}]
    n = len(df_preds)
    out = {}
    for col in model_cols:
        scores = df_preds[col].to_numpy()
        order = np.argsort(-scores)
        cum = []
        treated_so_far = control_so_far = 0.0
        for i, idx in enumerate(order, start=1):
            if w[idx] == 1:
                treated_so_far += y[idx]
            else:
                control_so_far += y[idx]
            lift = treated_so_far / max((w[order[:i]] == 1).sum(), 1) - control_so_far / max(
                (w[order[:i]] == 0).sum(), 1
            )
            cum.append(lift)
        curve = np.array(cum)
        if normalize and curve[-1] != 0:
            curve = curve / np.abs(curve[-1])
        out[col] = curve
    out[treatment_effect_col] = out.get(treatment_effect_col, out[model_cols[0]])
    return pd.DataFrame(out)


def plot_gain(df_preds, outcome_col="y", treatment_col="w", treatment_effect_col="tau", model_cols=None, main="", normalize=True):
    import matplotlib.pyplot as plt

    gain = get_cumgain(df_preds, outcome_col, treatment_col, treatment_effect_col, normalize)
    model_cols = model_cols or [c for c in gain.columns]
    plt.figure(figsize=(8, 5))
    for col in model_cols:
        if col in gain.columns:
            plt.plot(gain[col].values, label=col)
    plt.xlabel("Population fraction targeted")
    plt.ylabel("Cumulative gain" + (" (normalized)" if normalize else ""))
    plt.title(main)
    plt.legend()
    plt.tight_layout()
    plt.show()


def load_ihdp(replications: int = 2, random_state: int = 1):
    base_url = "https://raw.githubusercontent.com/uber/causalml/master/docs/examples/data"
    cols = ["treatment", "y_factual", "y_cfactual", "mu0", "mu1"] + [f"X{i}" for i in range(25)]
    parts = []
    for i in range(1, 10):
        url = f"{base_url}/ihdp_npci_{i}.csv"
        tmp = pd.read_csv(url, header=None)
        tmp.columns = cols[: tmp.shape[1]]
        parts.append(tmp)
    df = pd.concat(parts, ignore_index=True)
    if replications > 1:
        df = pd.concat([df] * replications, ignore_index=True)
    binfeats = [f"X{i}" for i in range(6, 25)]
    contfeats = [f"X{i}" for i in range(6)]
    xcols = binfeats + contfeats
    X = df[xcols].to_numpy(dtype=float)
    treatment = df["treatment"].to_numpy(dtype=int)
    y = df["y_factual"].to_numpy(dtype=float)
    tau = np.where(
        treatment == 1,
        df["y_factual"] - df["y_cfactual"],
        df["y_cfactual"] - df["y_factual"],
    )
    n = len(X)
    rng = np.random.default_rng(random_state)
    val_idx = rng.choice(n, size=int(0.2 * n), replace=False)
    train_idx = np.setdiff1d(np.arange(n), val_idx)
    return df, X, treatment, y, tau, train_idx, val_idx


def simulate_hidden_confounder(n=2000, p=5, sigma=1.0, adj=0, random_state=42):
    """Synthetic DGP with latent confounder (CausalML-style benchmark)."""
    rng = np.random.default_rng(random_state)
    z = rng.normal(size=n)
    X = np.column_stack([z + rng.normal(scale=sigma, size=n) for _ in range(p)])
    e = 1 / (1 + np.exp(-(adj + 0.5 * z)))
    w = rng.binomial(1, e)
    b = 2 * z + rng.normal(scale=sigma, size=n)
    tau = 0.5 + 0.3 * z
    y = b + w * tau + rng.normal(scale=sigma, size=n)
    return {"X": X, "y": y, "w": w, "tau": tau, "b": b, "e": e}


def result_table(df_preds, tau_true):
    model_cols = [c for c in ["S", "T", "X", "R", "GANITE", "CEVAE"] if c in df_preds.columns]
    methods = model_cols + ["actual"]
    ate = [df_preds[m].mean() for m in model_cols] + [float(tau_true.mean())]
    mae = [float(np.mean(np.abs(df_preds[m] - tau_true))) for m in model_cols] + [np.nan]
    gain = get_cumgain(df_preds)
    auuc = [float(gain[m].mean()) if m in gain.columns else np.nan for m in model_cols]
    auuc.append(float(gain["tau"].mean()) if "tau" in gain.columns else np.nan)
    return pd.DataFrame({"Method": methods, "ATE": ate, "MAE": mae, "AUUC": auuc})


def synthetic_summary(preds, tau_ref, actual_ate):
    rows = []
    for name, p in preds.items():
        rows.append({
            "Method": name,
            "ATE": p.mean(),
            "MSE": np.mean((p - tau_ref) ** 2),
            "AbsPctErrorATE": abs(p.mean() / actual_ate - 1) if actual_ate != 0 else np.nan,
        })
    rows.append({"Method": "Actuals", "ATE": actual_ate, "MSE": np.nan, "AbsPctErrorATE": np.nan})
    return pd.DataFrame(rows)

from pydeepcausalml.effect import DragonNet

df = load_nsw_mixtape()
y_col, t_col = "re78", "treat"
x_cols = ["age", "educ", "black", "hisp", "marr", "nodegree", "re74", "re75"]
df = df.dropna(subset=[y_col, t_col] + x_cols).copy()
df[t_col] = df[t_col].astype(int)

X = df[x_cols].to_numpy(dtype=float)
X = (X - X.mean(axis=0)) / (X.std(axis=0) + 1e-8)
t_vec = df[t_col].to_numpy(dtype=int)
y_vec = df[y_col].to_numpy(dtype=float)
print("Sample size:", len(X), "| Covariates:", X.shape[1])

Train/validation split

Code
rng = np.random.default_rng(4343)
idx = rng.choice(len(X), size=int(0.8 * len(X)), replace=False)
mask = np.zeros(len(X), dtype=bool)
mask[idx] = True
X_train, X_val = X[mask], X[~mask]
t_train, t_val = t_vec[mask], t_vec[~mask]
y_train, y_val = y_vec[mask], y_vec[~mask]

Fit DragonNet

Code
fit_dn = DragonNet(
    repr_dim=128,
    head_dim=64,
    alpha=1.0,
    beta=0.1,
    epochs=80,
    batch_size=64,
    lr=5e-4,
    standardize=False,
    standardize_outcome=True,
    random_state=42,
    verbose=True,
    log_every=10,
).fit(X_train, t_train, y_train)

Predict ITE, ATE, and propensity

Code
ite_val = fit_dn.predict_cate(X_val)
ate_val = ite_val.mean()
print("DragonNet ATE (val):", round(ate_val, 2))
print("Naive diff-in-means (val):", round(y_val[t_val == 1].mean() - y_val[t_val == 0].mean(), 2))

Factual MSE and propensity calibration

Code
y0, y1 = fit_dn.predict_potential_outcomes(X_val)
y_hat = np.where(t_val.astype(bool), y1, y0)
mse_dragon = float(np.mean((y_val - y_hat) ** 2))
prop = fit_dn.predict_propensity(X_val)
print("DragonNet factual MSE (val):", round(mse_dragon, 2))
print("Mean predicted propensity | T=0:", round(prop[t_val == 0].mean(), 3),
      "| T=1:", round(prop[t_val == 1].mean(), 3))

Permutation-based feature importance

Code
n_rep = 8
rng = np.random.default_rng(4343)
ite_base = fit_dn.predict_cate(X_val)
imp = []
for j in range(X_val.shape[1]):
    deltas = []
    for _ in range(n_rep):
        Xp = X_val.copy()
        Xp[:, j] = rng.permutation(Xp[:, j])
        deltas.append(np.mean(np.abs(ite_base - fit_dn.predict_cate(Xp))))
    imp.append(np.mean(deltas))

impdf = pd.DataFrame({"feature": x_cols, "importance": imp})
plt.figure(figsize=(6, 4))
sns.barplot(impdf.sort_values("importance"), y="feature", x="importance", color="#1B998B")
plt.title("DragonNet: Permutation Importance for Predicted CATE (Validation)")
plt.tight_layout()
plt.show()

Summary and Conclusions

This note shows how to fit DragonNet with PyDeepCausalML and evaluate ATE, factual MSE, and propensity calibration on a validation fold.

Resources

Back to top