Python Banner

5.1.1 TARNet (Treatment-Agnostic Representation Network)

This notebook demonstrates how to fit a TARNet (Treatment-Agnostic Representation Network) using PyDeepCausalML.

Note: TARNet requires PyTorch. The TARNet estimator in pydeepcausalml.effect trains a deep shared-representation architecture with two treatment-specific outcome heads.

Overview

TARNet is a neural network architecture for causal inference, specifically designed to estimate individualized treatment effects (ITE) from observational data. It was introduced by Shalit, Johansson, and Sontag (2017) in “Estimating individual treatment effects: generalization bounds and algorithms.”

Architecture

Two core challenges motivate TARNet’s design:

  • The fundamental problem of causal inference: For any individual, only one potential outcome is ever observed.
  • Selection bias: In observational data, treatment assignment is rarely random — sicker patients may be more likely to receive treatment, confounding naive effect estimates.

TARNet architecture diagram

TARNet addresses both through a three-part architecture:

1. Shared Representation Network (Φ)

Input covariates X pass through a deep network that produces a learned representation Φ(x).

2. Treatment-Specific Heads (\(h_0\) and \(h_1\))

  • \(h_0(\Phi(x))\) — predicts the potential outcome under control
  • \(h_1(\Phi(x))\) — predicts the potential outcome under treatment

3. ITE Estimation

\[\text{ITE}(x) = \hat{y}_1 - \hat{y}_0 = h_1(\Phi(x)) - h_0(\Phi(x))\]

Why “Treatment-Agnostic”?

The shared encoder Φ is trained to be agnostic to treatment assignment — encoding a patient’s underlying features, not which group they belonged to.

The related CFRNet variant extends TARNet by adding an IPM regularizer that explicitly penalizes distributional divergence between treated and control representations.

TARNet vs. Other Approaches

Approach Key idea Limitation
S-Learner Single model; treatment as a feature Treatment effect can be smoothed away
T-Learner Separate models per treatment arm No shared learning; high variance
TARNet Shared representation + separate heads Balances shared features with arm-specific prediction
CFRNet TARNet + IPM regularizer More aggressive covariate shift correction

Implementation in Python

TARNet is implemented in PyDeepCausalML as pydeepcausalml.effect.TARNet. It trains a PyTorch neural network with a shared encoder and two outcome heads.

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())

Load data and prepare (X, t, y)

We use NSW (nsw_mixtape) from causaldata: treatment treat, outcome re78, covariates age, educ, black, hisp, marr, nodegree, re74, re75. After dropping incomplete rows, we split into training and test sets (80/20); covariates are standardized using the training mean and SD only.

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)

import os
from pathlib import Path
from pydeepcausalml.effect import TARNet

FAST_RUN = os.getenv("PYDEEPCAUSALML_FAST_RENDER", "true").lower() == "true"
EPOCHS = 30 if FAST_RUN else 100
NUM_TREES = 100 if FAST_RUN else 500

set_seed(42)
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)

rng = np.random.default_rng(42)
idx = rng.choice(len(df), size=int(0.8 * len(df)), replace=False)
mask = np.zeros(len(df), dtype=bool)
mask[idx] = True
df_train, df_test = df[mask], df[~mask]

X_train_raw = df_train[x_cols].to_numpy(dtype=float)
X_test_raw = df_test[x_cols].to_numpy(dtype=float)
cm = X_train_raw.mean(axis=0)
cs = X_train_raw.std(axis=0)
cs[cs == 0] = 1.0
X_train = (X_train_raw - cm) / cs
X_test = (X_test_raw - cm) / cs
t_train = df_train[t_col].to_numpy(dtype=int)
y_train = df_train[y_col].to_numpy(dtype=float)
t_test = df_test[t_col].to_numpy(dtype=int)
y_test = df_test[y_col].to_numpy(dtype=float)

print(f"Train n = {len(X_train)} | Test n = {len(X_test)} | p = {X_train.shape[1]}")

Fit TARNet, S-Learner, and T-Learner

TARNet trains with mini-batches on the training fold. S-Learner and T-Learner use random forests on the same scaled covariates — a standard tree-based baseline.

Code
fit_tarnet = TARNet(
    repr_dim=100,
    head_dim=64,
    epochs=EPOCHS,
    batch_size=64,
    lr=1e-3,
    standardize=False,
    standardize_outcome=True,
    random_state=42,
    verbose=FAST_RUN,
    log_every=10,
).fit(X_train, t_train, y_train)

fit_sl = SLearner(learner="ranger").fit(X_train, t_train, y_train)
fit_tl = TLearner(learner="ranger").fit(X_train, t_train, y_train)
print("Fitted TARNet, S-Learner, and T-Learner")

Predict and validate on the test set

For each unit, \(\hat{\tau}(x) = \hat{Y}(1 \mid x) - \hat{Y}(0 \mid x)\). Because NSW has no counterfactual labels, we also report factual outcome MSE on the test set.

Code
def factual_mse_tarnet(est, X, t, y):
    y0, y1 = est.predict_potential_outcomes(X)
    y_hat = np.where(t.astype(bool), y1, y0)
    return float(np.mean((y - y_hat) ** 2))


def factual_mse_sl(est, X, t, y):
    y0 = est.model_.predict(np.column_stack([X, np.zeros(len(X))]))
    y1 = est.model_.predict(np.column_stack([X, np.ones(len(X))]))
    y_hat = np.where(t.astype(bool), y1, y0)
    return float(np.mean((y - y_hat) ** 2))


def factual_mse_tl(est, X, t, y):
    y_hat = np.where(t.astype(bool), est.m1_.predict(X), est.m0_.predict(X))
    return float(np.mean((y - y_hat) ** 2))


ite_tarnet = fit_tarnet.predict_cate(X_test)
ite_sl = fit_sl.predict(X_test)
ite_tl = fit_tl.predict(X_test)

compare_tbl = pd.DataFrame({
    "Learner": ["TARNet", "S-Learner (ranger)", "T-Learner (ranger)", "Naive diff-in-means"],
    "ATE_test": [ite_tarnet.mean(), ite_sl.mean(), ite_tl.mean(),
                 y_test[t_test == 1].mean() - y_test[t_test == 0].mean()],
    "Factual_MSE_test": [
        factual_mse_tarnet(fit_tarnet, X_test, t_test, y_test),
        factual_mse_sl(fit_sl, X_test, t_test, y_test),
        factual_mse_tl(fit_tl, X_test, t_test, y_test),
        np.nan,
    ],
})
display(compare_tbl.round(2))

CATE distributions on the test set

Code
cate_df = pd.DataFrame({
    "CATE": np.concatenate([ite_tarnet, ite_sl, ite_tl]),
    "Learner": ["TARNet"] * len(ite_tarnet)
    + ["S-Learner (ranger)"] * len(ite_sl)
    + ["T-Learner (ranger)"] * len(ite_tl),
})
g = sns.displot(cate_df, x="CATE", col="Learner", col_wrap=3, kde=True, height=3, aspect=1.2)
g.fig.suptitle("Predicted CATE on test set", y=1.02)
plt.show()

Permutation-based feature importance (TARNet)

We measure how much predicted CATE depends on each covariate using permutation importance on the test fold.

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

imp_tarnet = pd.DataFrame({"feature": x_cols, "importance": imp}).sort_values("importance")
plt.figure(figsize=(6, 4))
sns.barplot(imp_tarnet, y="feature", x="importance", color="#8F4CB3")
plt.title("TARNet: permutation importance for predicted CATE (test)")
plt.xlabel("Mean |Δ predicted ITE|")
plt.tight_layout()
plt.show()

Summary and Conclusion

TARNet learns a shared representation of covariates and separate heads for each treatment arm. In this notebook we fit TARNet alongside S- and T-Learner baselines on NSW data and compared ATE and factual MSE on a held-out test set.

  • TARNet — Treatment-Agnostic Representation Network
Back to top