examples.demo_tram_dag_colab
TRAM-DAG in 5 minutes — one causal model, all three rungs of Pearl's ladder
TRAM-DAGs (Sick & Dürr, CLeaR 2025) are interpretable neural causal models: one normalizing flow wired exactly like the adjacency matrix of your causal DAG — each variable is transformed conditional on its parents. Fit it once on observational data and you can
- L1 sample / score the observational distribution,
- L2 answer interventional queries —
do(...)— by graph mutilation, - L3 compute individual counterfactuals ("what would have happened to this unit?") via Pearl's abduction–action–prediction.
This demo uses the benchmark the paper leads with: a 3-variable SCM whose source is bimodal — the example where a default causal normalizing flow visibly fails (paper, Fig. 4) and TRAM-DAG doesn't. Ground truth is known analytically, so every claim below is checked, not asserted.
Runs on CPU; on a Colab GPU runtime (Runtime → Change runtime type → any GPU, e.g. the free T4) the final section races the two.
import importlib.util
import subprocess
import sys
import time
if importlib.util.find_spec("tramdag") is None: # Colab: install from PyPI
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", "tramdag"])
# to track active development instead of the latest release, use:
# pip install "git+https://github.com/tensorchiefs/tramdag.git@main"
import matplotlib.pyplot as plt
import numpy as np
import scipy.stats as st # for KDE plots only (preinstalled on Colab)
import torch
from tramdag import CausalFlowDAG, ContinuousNode, I
from tramdag.simulations import VacaTriangle
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
plt.rcParams["figure.dpi"] = 110
print(f"torch {torch.__version__} device: {DEVICE}")
torch 2.12.0+cu130 device: cpu
1. The challenge: a bimodal structural causal model
The DGP (Sánchez-Martín et al. 2022, App. E.1; TRAM-DAG paper App. C.1):
$$ \begin{aligned} x_1 &\sim \tfrac12\,\mathcal N(-2,\,1.5) + \tfrac12\,\mathcal N(1.5,\,1) &&\text{(bimodal source)}\ x_2 &= -x_1 + \mathcal N(0,1)\ x_3 &= x_1 + 0.25\,x_2 + \mathcal N(0,1) \end{aligned} $$
The DAG is $x_1 \to x_2$, $x_1 \to x_3$, $x_2 \to x_3$.
gen = VacaTriangle(seed=42)
df = gen.observational(50_000)
train, val = df.iloc[:45_000], df.iloc[45_000:]
fig, axes = plt.subplots(1, 3, figsize=(11, 3))
for ax, c in zip(axes, df.columns):
ax.hist(df[c], bins=80, density=True, alpha=0.7)
ax.set_title(f"observed ${c[0]}_{c[1]}$")
fig.suptitle("50,000 observational samples — note the bimodal $x_1$")
fig.tight_layout()
plt.show()
2. Fit the TRAM-DAG — the spec is the DAG
Each node gets a monotone Bernstein transform; the edge labels say how parents
enter (ci = the transform's parameters depend on the parents — maximal
flexibility). Training maximizes the exact joint likelihood with one Adam;
the new schedule="plateau" + freeze_patience lets every node decay its
learning rate independently and drop out of training once converged —
the fit stops itself.
spec = {
"x1": ContinuousNode(), # source
"x2": ContinuousNode(terms=[I("x1")]),
"x3": ContinuousNode(terms=[I("x1", "x2")]),
}
torch.manual_seed(0)
flow = CausalFlowDAG(spec, device=DEVICE)
t0 = time.perf_counter()
flow.fit(
train,
val,
epochs=400,
learning_rate=1e-1,
batch_size=4096,
verbose=50,
schedule="plateau",
plateau_patience=10,
freeze_patience=30,
marginal_init=True,
)
t_fit = time.perf_counter() - t0
print(
f"\nfitted on {DEVICE} in {t_fit:.1f}s "
f"({len(flow.history['val'])} epochs, then froze itself)"
)
[epoch 1/400] train NLL 6.3241 val NLL 5.3017
[epoch 51/400] train NLL 4.9131 val NLL 4.9197
[epoch 90] all nodes frozen — stopping.
fitted on cpu in 15.0s (90 epochs, then froze itself)
Training diagnostics come for free — fit records per-node train/val NLL,
learning rates, wall-clock time and the freeze epochs. Watch the nodes drop
out of training one by one:
hist = flow.history
ep = np.arange(1, len(hist["val"]) + 1)
tot_tr = np.array([sum(d.values()) for d in hist["train"]])
tot_va = np.array([sum(d.values()) for d in hist["val"]])
fig, ax = plt.subplots(figsize=(7.5, 3.6))
ax.plot(ep, tot_tr, label="train NLL (total)")
ax.plot(ep, tot_va, label="val NLL (total)")
for i, (name, e) in enumerate(
sorted(hist.get("frozen", {}).items(), key=lambda kv: kv[1])
):
ax.axvline(e, ls="--", lw=1, color="gray")
ax.annotate(
f" {name} frozen",
(e, ax.get_ylim()[1]),
rotation=90,
va="top",
fontsize=8,
color="gray",
)
ax.set_ylim(tot_va.min() - 0.02, tot_va.min() + 0.6) # zoom past the initial drop
ax.set_xlabel("epoch"), ax.set_ylabel("NLL"), ax.legend()
ax.set_title("training curve — per-node plateau decay, then self-freezing")
fig.tight_layout()
plt.show()
3. Rung 1 — does it actually fit? (the plot the CNF baseline fails)
samp = flow.sample(5 * len(df), seed=1)
cols = list(df.columns)
fig, axes = plt.subplots(3, 3, figsize=(9, 8.5))
for i, ci in enumerate(cols):
for j, cj in enumerate(cols):
ax = axes[i][j]
if i == j:
bins = np.linspace(df[ci].quantile(0.001), df[ci].quantile(0.999), 70)
# Plot DGP histogram
ax.hist(df[ci], bins=bins, density=True, alpha=0.5, label="DGP")
# KDE for TRAM-DAG samples
kde = st.gaussian_kde(samp[ci][:50_000])
x_eval = np.linspace(bins[0], bins[-1], 300)
ax.plot(x_eval, kde(x_eval), color="C3", lw=1.8, label="TRAM-DAG KDE")
else:
ax.scatter(df[cj][:1500], df[ci][:1500], s=2, alpha=0.3)
ax.scatter(samp[cj][:1500], samp[ci][:1500], s=2, alpha=0.3, color="C3")
if i == 2:
ax.set_xlabel(cj)
if j == 0:
ax.set_ylabel(ci)
axes[0][0].legend(fontsize=8)
fig.suptitle("L1: observational joint — DGP (blue) vs fitted TRAM-DAG (red, KDE)")
fig.tight_layout()
plt.show()
4. Rung 2 — interventions: do(x2 = a)
Graph mutilation: clamp $x_2$, cut its incoming edge, resample. Under the DGP, $x_3\,|\,do(x_2{=}a) = x_1 + 0.25a + \mathcal N(0,1)$, so $\mathbb E[x_3] = -0.25 + 0.25a$ analytically — a hard number to be wrong about.
fig, axes = plt.subplots(1, 3, figsize=(11, 3.2), sharey=True)
print("E[x3 | do(x2=a)]: analytic TRAM-DAG")
for ax, a in zip(axes, (-3.0, -1.0, 0.0)):
truth = gen.interventional(50_000, {"x2": a})
fl = flow.sample(50_000, do={"x2": a}, seed=2)
bins = np.linspace(truth["x3"].quantile(0.001), truth["x3"].quantile(0.999), 70)
# DGP histogram
ax.hist(truth["x3"], bins=bins, density=True, alpha=0.5, label="DGP")
# Flow density: KDE for a smoother estimate
kde = st.gaussian_kde(fl["x3"].iloc[:50_000])
x_eval = np.linspace(bins[0], bins[-1], 300)
ax.plot(x_eval, kde(x_eval), color="C3", lw=1.8, label="TRAM-DAG density")
ax.set_title(f"$p(x_3 \\mid do(x_2={a:+.0f}))$")
print(
f" a = {a:+.0f}: {-0.25 + 0.25 * a:+.3f} {fl['x3'].mean():+.3f}"
)
axes[0].legend()
fig.tight_layout()
plt.show()
E[x3 | do(x2=a)]: analytic TRAM-DAG
a = -3: -1.000 -1.019
a = -1: -0.500 -0.511
a = +0: -0.250 -0.270
5. Rung 3 — the counterfactual magic trick
Take 1,000 held-out individuals. Step 1 (abduction): invert the flow to recover each individual's latent noise $u$ — everything about them the model doesn't attribute to their parents. Sanity check: pushing $u$ back through the flow must reproduce the observed data exactly.
Step 2+3 (action + prediction): rerun history with $do(x_1 = 0)$ — same $u$, mutilated graph. Because the DGP is fully continuous, the true individual counterfactuals are known (the simulator keeps its noise), so we can score the flow per individual — the strictest test in causal inference, impossible with real data.
rng = np.random.default_rng(7)
lat = gen.draw_latents(1_000, rng)
factual = gen.simulate(latents=lat)
cf_true = gen.simulate(latents=lat, do={"x1": 0.0})
u = flow.abduct(factual)
recon = flow.sample(u=u)
err = np.abs(recon.to_numpy() - factual.to_numpy()).max()
print(f"abduction -> reconstruction: max |error| = {err:.2e} (exact recovery)")
cf_flow = flow.sample(do={"x1": 0.0}, u=u)
fig, axes = plt.subplots(1, 2, figsize=(9, 3.6))
for ax, c in zip(axes, ["x2", "x3"]):
ax.scatter(cf_true[c], cf_flow[c], s=4, alpha=0.4)
lims = [cf_true[c].min(), cf_true[c].max()]
ax.plot(lims, lims, "k--", lw=1)
r = np.corrcoef(cf_true[c], cf_flow[c])[0, 1]
ax.set_title(
f"counterfactual ${c[0]}_{c[1]}$ under $do(x_1{{=}}0)$ (r = {r:.4f})"
)
ax.set_xlabel("true (DGP, shared noise)"), ax.set_ylabel("TRAM-DAG")
fig.suptitle("L3: individual counterfactuals, scored unit by unit")
fig.tight_layout()
plt.show()
abduction -> reconstruction: max |error| = 4.57e-06 (exact recovery)
6. Swapping the transform: Bernstein vs spline vs affine
Each continuous node owns a monotone 1-D transform — that's where the
distributional flexibility lives. One constructor argument switches it:
"bernstein" (default, TRAM-faithful polynomial), "spline" (monotone
rational-quadratic, the neural-spline-flow building block), or "affine"
(location–scale only → every node-conditional is forced to be a logistic —
essentially a classical GLM). Same DAG, same training, three model families:
def make_spec(transform):
return {
"x1": ContinuousNode(transform=transform),
"x2": ContinuousNode(transform=transform, terms=[I("x1")]),
"x3": ContinuousNode(transform=transform, terms=[I("x1", "x2")]),
}
fits = {"bernstein": flow} # already trained above
for tr in ["spline", "affine"]:
torch.manual_seed(0)
f = CausalFlowDAG(make_spec(tr), device=DEVICE)
f.fit(
train,
val,
epochs=400,
learning_rate=1e-2,
batch_size=4096,
verbose=0,
schedule="plateau",
plateau_patience=10,
freeze_patience=30,
)
fits[tr] = f
print("held-out NLL (lower is better):")
for tr, f in fits.items():
print(f" {tr:9s}: {sum(f.nll(val).values()):.4f}")
held-out NLL (lower is better):
bernstein: 4.9149
spline : 5.6333
affine : 5.0393
bins = np.linspace(df["x1"].quantile(0.001), df["x1"].quantile(0.999), 70)
fig, ax = plt.subplots(figsize=(7, 3.4))
ax.hist(df["x1"], bins=bins, density=True, alpha=0.4, color="gray", label="data")
for tr, color in [("bernstein", "C3"), ("spline", "C0"), ("affine", "C2")]:
ax.hist(
fits[tr].sample(len(df), seed=3)["x1"],
bins=bins,
density=True,
histtype="step",
lw=1.8,
color=color,
label=tr,
)
ax.set_title("the affine (GLM-like) transform cannot bend into two modes")
ax.set_xlabel("$x_1$"), ax.legend()
fig.tight_layout()
plt.show()
Reading the table: affine pays exactly where you'd expect — a
location–scale transform cannot produce a bimodal $x_1$ (the same failure
mode as the inflexible CNF in the paper's Fig. 4). The RQ-spline is
expressive enough in principle, but with the small TRAM-DAG parameter heads
it consistently trains to a worse optimum on this target (same result for
8–32 bins, lr 0.01–0.1, up to 2000 epochs) — an honest empirical reason why
Bernstein (whose monotone softplus-cumsum parametrization is easier to
optimize) is the TRAM-faithful default. Swap per node any time via
ContinuousNode(transform="spline", transform_kwargs={"bins": 16}).
7. GPU vs CPU
The whole flow is plain PyTorch, so it runs anywhere. Same 60-epoch fit, both devices (on a CPU-only runtime this just reports CPU):
def timed_fit(device, epochs=60):
torch.manual_seed(0)
f = CausalFlowDAG(
{
"x1": ContinuousNode(),
"x2": ContinuousNode(terms=[I("x1")]),
"x3": ContinuousNode(terms=[I("x1", "x2")]),
},
device=device,
)
t0 = time.perf_counter()
f.fit(train, val, epochs=epochs, learning_rate=1e-2, batch_size=4096, verbose=0)
return time.perf_counter() - t0
timings = {"cpu": timed_fit("cpu")}
if torch.cuda.is_available():
timed_fit("cuda", epochs=5) # warm-up (cuda kernel compilation)
timings["cuda"] = timed_fit("cuda")
for dev, t in timings.items():
print(f"{dev:5s}: {t:6.1f}s for 60 epochs @ n=45,000")
if len(timings) > 1:
fig, ax = plt.subplots(figsize=(4, 2.8))
ax.barh(list(timings), list(timings.values()), color=["C0", "C2"])
ax.set_xlabel("seconds (60 epochs)"), ax.set_title("same model, same data")
fig.tight_layout()
plt.show()
cpu : 10.0s for 60 epochs @ n=45,000
What you just saw
| rung | query | call | checked against |
|---|---|---|---|
| L1 | observational joint | flow.sample(n) |
50k DGP samples (bimodal $x_1$ ✓) |
| L2 | $p(x_3 \mid do(x_2{=}a))$ | flow.sample(n, do=...) |
analytic $\mathbb E[x_3] = -0.25 + 0.25a$ |
| L3 | individual counterfactuals | flow.abduct(df) + flow.sample(do=..., u=u) |
per-unit DGP truth, r ≈ 0.99+ |
And the same model family stays interpretable when you want it to be:
declare an edge "ls" instead of "ci" and its coefficient is a log-odds
ratio you can read off after training (that's the actual point of the paper).
More: repo ·
paper ·
didactic walkthrough: notebooks/intro_tram_dag.py
1""".. include:: ../../nbmd/demo_tram_dag_colab.md"""