The model¶
A TRAM-DAG is one triangular normalizing flow whose sparsity is a causal DAG.
This page states the model in the notation of the paper and maps each part of
it onto the constructors in tramdag.spec. For the symbols see
notation.md; to read a fitted model see
interpretation.md; for a worked fit see the
demo notebook.
The transformation function¶
The variables have a causal ordering. For each variable a TRAM-DAG fits a monotone transformation function \(h\). The function maps the observed value to a latent scale, conditional on the parents of that variable:
This is the observed-to-latent direction. It is Eq. 2 of the paper,
\(F_{X\mid\mathrm{pa}}(x)=F_U\big(h(x\mid\mathrm{pa})\big)\), and in code it is
u = h(x) + shift.
Training evaluates \(h\) directly to score the likelihood, which is cheap. Sampling runs the inverse \(x_i = h^{-1}(u_i \mid \mathrm{pa})\), which has no closed form: bracketed bisection solves it, so sampling costs more.
Together the \(h\)'s form one triangular flow. Each variable depends only on its causal parents \(\mathrm{pa}(x_i)\), which is a subset of its predecessors. The Jacobian sparsity of the flow is therefore the DAG itself.
Each \(h(x_i \mid \mathrm{pa})\) is one transformation model of \(x_i\) given its
parents. In code it is a Node, which also fits on its own, without the DAG
(fitting.md).
The latents \(u_1,\dots,u_p\) are standard logistic. That choice is what makes the fitted parameters interpretable, because a shift on the latent scale is then a log-odds ratio.
The components¶
The transformation decomposes additively on the latent scale, which is what keeps the interpretation valid. Each node's \(h\) is
and every causal parent enters through exactly one term. Take \(x_5\) with parents \(\mathrm{pa}(x_5) = \{x_1, x_2, x_4\}\) as the example.
- Simple intercept, SI. \(h_{\boldsymbol{\vartheta}}(x_5)\) has constant parameters. It is a flexible monotone baseline, a Bernstein polynomial by default, and it is the same for every observation.
- Complex intercept, CI. The parameters \(\boldsymbol{\vartheta}\) are themselves a function of some parents. The whole transformation bends with the parent, which permits interactions that an additive shift cannot express.
- Linear shift, LS. \(\beta_{51} x_1 + \beta_{52} x_2\). One interpretable number per parent.
- Complex shift, CS. \(g(x_4)\), an unrestricted network of the parent. It stays additive on the latent scale.
- Varying coefficient, VC. \((\beta_0 + b_{\boldsymbol{\Theta}}(x_{\text{mod}}))\, x_t\): a treatment \(x_t\) whose effect \(\beta\) varies with modifier covariates through a small penalized network. varying-coefficients.md is its guide.
Sampling has to invert only the intercept, because the shifts move to the other side:
The components in code¶
Each node declares its transformation as an additive formula of terms. The
formula is the node's first argument, a + sum of terms. Each constructor
names the parents its term depends on. The paper's symbols I, LS, CS,
VC are the classes Intercept, LinearShift, ComplexShift,
VaryingCoefficient.
| formula for a continuous node \(x_3\) | \(u_3 = h(x_3 \mid \mathrm{pa})\) |
|---|---|
nothing, or I() |
\(h_{\vartheta}(x_3)\), the simple intercept |
LS("X1") |
\(h_{\vartheta}(x_3) + \beta x_1\) |
I("X1") |
\(h_{\vartheta(x_1)}(x_3)\), the complex intercept |
CS("X1") |
\(h_{\vartheta}(x_3) + g_1(x_1)\) |
LS("X1") + CS("X2") |
\(h_{\vartheta}(x_3) + \beta x_1 + g_2(x_2)\) |
CS("X1", "X2") |
\(h_{\vartheta}(x_3) + g_{12}(x_1, x_2)\), one joint network |
CS("X1") + CS("X2") |
\(h_{\vartheta}(x_3) + g_1(x_1) + g_2(x_2)\), two additive networks |
I("X1", "X2") |
\(h_{\vartheta(x_1, x_2)}(x_3)\), one joint network |
I("X1", "X2", allow_interaction=False) |
\(h_{\vartheta(x_1) + \vartheta(x_2)}(x_3)\), one network per parent, summed in coefficient space |
CS("X1") + VC("X2", t="T") |
\(h_{\vartheta}(x_3) + g_1(x_1) + \beta(x_2)\, x_T\) |
Three rules follow from the model.
- Exactly one intercept, first. A formula written without one gets
I()prepended.I()without parents is the simple intercept,I(...)with parents the complex one;SI()andCI(...)spell that out with the arity checked. - Joint versus additive is argument grouping. Several parents in one term
form one network over all of them, an interaction. The same parents in
separate terms act additively. A node takes at most one intercept with
parents, so for the intercept
allow_interactionsets the grouping. - Every parent enters through exactly one edge-owning term. The VC
modifiers are the one exception:
CS("X2") + VC("X2", t="T")is the intended pattern. There \(x_2\) acts prognostically through the shift and also modifies the treatment effect.
The three knobs on a term¶
transform= on I picks the class of \(h_{\boldsymbol{\vartheta}}\) for a
continuous node:
"bernstein", the default: a Bernstein polynomial whose tails extrapolate along the boundary slope;"spline": a monotone rational-quadratic spline, whose tail slope has a fixed value (zuko-upstream.md explains the consequence);"affine": location and scale only, so the node-conditional is a logistic GLM.
The keyword arguments of the transform class go in transform_kwargs,
I(transform="spline", transform_kwargs={"bins": 16}). Each transform
pre-scales the data from the training range_q and $1 - $range_q quantiles
onto a fixed domain; range_q=0 uses the minimum and maximum. Ordinal nodes
have no transform to pick.
input_transform= on I, CS, VC transforms that term's continuous
network inputs, with statistics frozen at the first fit: "minmax",
"standardize", or a callable fn(x, train). LS and the VC treatment
stay raw, so their coefficients keep their units.
units=, activation=, batch_norm= on I, CS, VC size the
term's network. The defaults are in the API pages of the terms.
Ordinal nodes¶
An ordinal node has no monotone transform to choose. Its intercept is the cutpoint vector of an ordered logit,
with increasing cutpoints \(\vartheta\) and \(s\) the total shift. The model subtracts the shift where a continuous node adds it. notation.md states the convention. interpretation.md shows what it does to the sign of a coefficient.
The probability of an observed level is the difference of two sigmoids. The
code evaluates it in log space, which keeps a gradient where both sigmoids
saturate (ordinal_log_prob).
Parents enter every network as features: a continuous parent raw, in one
column; an ordinal parent one-hot, in one column per level. Abduction is exact
for continuous nodes and truncated-logistic for ordinal ones, so
flow.sample(u=flow.abduct(df)) reproduces df exactly, level-exactly for
ordinal nodes. A counterfactual takes three steps: abduction, action and
prediction (Pearl, 2009)1. abduct gives the latents, do= sets the
intervention, and sample(u=..., do=...) predicts.
What the model cannot do¶
- The model fixes the latent distribution to standard logistic. It does not learn it.
- A
VCtreatment is continuous or a binary ordinal node; a multi-level ordinal treatment is not supported. Propensity centering needs a binary ordinal treatment. - The DAG is an input.
tramdagfits the mechanisms of a graph you supply, and does not discover the graph. - Identification is the usual causal one. No hidden confounding, and the graph has to be right.
-
Pearl, J. (2009). Causality: Models, reasoning, and inference (2nd ed.). Cambridge University Press. https://doi.org/10.1017/CBO9780511803161 ↩