tramdag
tramdag — Interpretable Neural Causal Models (TRAM-DAGs) in PyTorch.
tramdag — Interpretable Neural Causal Models (TRAM-DAGs) in PyTorch
⚠️ Status: beta (0.x), under active development. The API may change between releases until 1.0; pin a version (
tramdag==0.2.*) for reproducibility.
TRAM-DAGs model each variable of a structural causal model with a (transformation-model) flow: one triangular normalizing flow from iid standard-logistic latents to the observed variables. The structure is of the triangular Adjacency Matrix is exactly your causal DAG. Fit it once on observational data and answer all three rungs of Pearl's causal hierarchy — observational (L1), interventional (L2, the do-operator), and counterfactual (L3, Pearl abduction) — while keeping interpretable effects: every linear-shift coefficient is a log-odds ratio, exactly as in classical proportional-odds models.
Beate Sick & Oliver Dürr, Interpretable Neural Causal Models with TRAM-DAGs, CLeaR 2025 (arXiv:2503.16206). This repo is the reference implementation (PyTorch, built on zuko); all of the paper's experiments are replicated here with pinned tests.
5-minute showcase: the Colab badge above fits the paper's bimodal benchmark
live (GPU-ready) and walks L1 → L2 → L3, every answer checked against analytic
ground truth. Further notebooks are available at notebooks/ like the didactic walkthrough of the model:
notebooks/intro_tram_dag.py.
Install
pip install tramdag # latest release (PyPI)
pip install "git+https://github.com/tensorchiefs/tramdag.git@main" # dev version (track main)
uv sync # or: dev setup from a clone (tests, experiments)
Pin the dev install to a commit for reproducibility, e.g. ...tramdag.git@<sha>.
30 seconds of API
import tramdag as td
from tramdag import CausalFlowDAG, ContinuousNode, OrdinalNode, I, LS, CS
spec = { # the spec IS the labelled DAG
"Age": ContinuousNode(),
"mRS_pre": OrdinalNode(levels=6, terms=[I("Age")]),
"NIHSSa": ContinuousNode(terms=[I("Age"), LS("mRS_pre")]),
"T": OrdinalNode(levels=2, terms=[I("Age"), LS("mRS_pre"), CS("NIHSSa")]),
"mRS_3m": OrdinalNode(
levels=7, terms=[I("Age"), LS("mRS_pre"), CS("NIHSSa"), LS("T")]
),
}
flow = CausalFlowDAG(spec) # validates acyclicity, builds the flow
# self-stopping training: per-node plateau lr decay + freezing of converged
# nodes (exact, since the per-node NLLs have independent gradients);
# see docs/training-speed.md for benchmarks and the classic two-phase recipe
flow.fit(
train_df,
val_df,
epochs=4000,
learning_rate=1e-2,
schedule="plateau",
plateau_patience=30,
freeze_patience=120,
)
# all-`ls` model? fit it classically instead: deterministic float64 L-BFGS,
# exact MLE matching statsmodels/R (see notebooks/classical_fit_tram_dag.py)
flow.fit_classical(train_df) # raises on cs/ci specs
flow.log_prob(df) # L1: joint log-likelihood per row
flow.sample(1000) # L1: observational sampling
flow.sample(1000, do={"T": 1}) # L2: interventional (graph mutilation)
flow.pmf(df, node="mRS_3m", do={"T": 1}) # L2: analytic interventional PMF
u = flow.abduct(df) # L3 step 1: latents from observations
cf = flow.sample(do={"T": 1}, u=u) # L3 steps 2+3: counterfactuals
flow.ls_coefficients() # interpret: per-edge log-odds-ratios
flow.intercept_contributions("NIHSSa", df) # interpret: per-parent partial effects
# of an additive complex intercept (centered)
# heterogeneous treatment effects: a small, penalized effect head beta(x)*T
# (VC term) with a first-class read-out — see docs/varying-coefficients.md
# e.g. terms=[CS("Age", "NIHSSa"), VC("T", "Age")] ->
# flow.varying_coef("mRS_3m", df) # beta(x): deterministic, y-free
flow.scores(df, node="mRS_3m") # per-observation scores dl_i/dtheta
flow.effect_modifier_scan(df, "mRS_3m", on="T") # which VC modifiers? (CUSUM
# scan from a cheap all-ls fit) — docs/scores.md
flow.save("flow.pt")
flow = CausalFlowDAG.load("flow.pt")
td.simulations.REGISTRY # synthetic DGPs with known ground truth
The model in one table
Per node, the transformation is additive on the latent (log-odds) scale —
u = h(x; θ) + Σ β·x_pa + Σ g(x_pa) — and each parent edge declares how it enters:
| term | meaning | interpretability |
|---|---|---|
LS(pa) |
linear shift β·x_pa |
exp(β) is an odds ratio — one number per edge |
CS(pa) |
complex shift g(x_pa) (MLP), still additive |
plot g |
I(pa) |
complex intercept: the transform's parameters depend on the parents (several parents in one I(...) feed one joint network) |
maximal flexibility, interactions not interpretable |
VC(on, *mods) |
varying-coefficient shift β(mods)·x_on |
read out with flow.varying_coef |
Continuous nodes carry a monotone 1-D transform (bernstein — TRAM-faithful
default, spline, affine; ContinuousNode(transform=..., transform_kwargs=...));
ordinal nodes an ordered-logit head P(x ≤ k) = σ(θ_k − shift). Abduction is exact
for continuous nodes and truncated-logistic for ordinal ones, so
flow.sample(u=flow.abduct(df)) reproduces df exactly / level-exactly.
There are two ways to fit the model: a stochastic deep learning optimizer (fit) and a 2nd order optimization like in the classical statistical models (fit_classical). The latter is more efficient for all-ls models (each node-conditional is then a classical transformation model). For more details, see the docs/fitting.md file.
Validation (all pinned by tests)
Paper replication — each of the paper's four DGP families has a generator in the registry (numpy-only SCM + frozen CSVs + replication script) and is pinned by tests:
family paper demonstrates triangle(linear,atan,sin)§6.1 LS coefficient recovery (β = 2, −0.2, +0.3), CS curve ≡ −f(x₂), non-monotone f triangle-mixed(linear,exp)§6.2 mixed data L1/L2 + the C.4 odds-ratio check (OR ≈ 7.4) vaca§5.1–5.2 the bimodal L1 case a default CNF misses; L2 p(x₃ | do(x₂))carefl§5.3 L3 counterfactual curves vs analytic truth cd experiments && uv run python paper_triangle.py atan cs # etc., see paper_*.pySign note: ordinal shifts are subtracted here but added in the paper, so fitted ordinal weights are the paper's with flipped sign (
truth.jsonrecords both conventions per family).Exact classical equivalence — an all-
lsflow trained to convergence is the proportional-odds MLE: coefficients matchstatsmodelsand RMASS::polrto ~4 decimals (experiments/validate_ls.py, R reference committed underdata/magic-mrclean/*/ref_ls/).Training speed — schedules, per-node freezing, LBFGS and device benchmarks:
docs/training-speed.md.
What the tests actually guarantee — the principles behind them (known identities,
the datasets and software they compare against, and how the ground truth was
obtained) — is documented in tests/README.md.
Full storyline, clinical-data context, R cross-check and reading notes:
docs/stroke-case-study.md.
Testing policy
See the tests/README.md file for more details.
Layout
src/tramdag/ spec.py transforms.py conditioners.py flow.py
simulations/ (magic_mrclean, triangle, vaca, carefl,
vc_shift + CLIs)
data/ frozen synthetic CSVs + truth.json — a test contract
experiments/ stroke pipeline, paper replications, training benchmark
notebooks/ intro (didactic) + Colab demo (jupytext .py — see README there)
tests/ unit, known-truth recovery, R regression
docs/ training-speed.md, stroke-case-study.md,
varying-coefficients.md, scores.md
Implementation conventions (latent-scale signs, raw/one-hot parent encoding,
log-space ordinal likelihood, seeding) are documented in
CLAUDE.md and pinned by tests.
Citation
If you use tramdag, please cite the method paper:
@inproceedings{sick2025tramdag,
title = {Interpretable Neural Causal Models with TRAM-DAGs},
author = {Sick, Beate and D{\"u}rr, Oliver},
booktitle = {Proceedings of the 4th Conference on Causal Learning and Reasoning (CLeaR)},
series = {Proceedings of Machine Learning Research},
volume = {275},
year = {2025},
}
1"""tramdag — Interpretable Neural Causal Models (TRAM-DAGs) in PyTorch. 2 3.. include:: ../../README.md 4""" 5 6from importlib.metadata import version 7 8from . import simulations 9from .env import machine_info 10from .flow import CausalFlowDAG 11from .spec import ( 12 CS, 13 LS, 14 VC, 15 ContinuousNode, 16 CShift, 17 I, 18 Intercept, 19 LinShift, 20 OrdinalNode, 21 Term, 22 term, 23) 24 25__all__ = [ 26 "CausalFlowDAG", 27 "ContinuousNode", 28 "OrdinalNode", 29 "machine_info", 30 "simulations", 31 # term-formula notation 32 "Term", 33 "I", 34 "LS", 35 "CS", 36 "VC", 37 "term", 38 "Intercept", 39 "LinShift", 40 "CShift", 41] 42__version__ = version("tramdag")
202class CausalFlowDAG(nn.Module): 203 """A causal normalizing flow defined by ``spec = {name: NodeSpec}``.""" 204 205 def __init__( 206 self, spec: dict[str, NodeSpec], device: str = "cpu", seed: int | None = None 207 ): 208 """Build the flow from ``spec``. 209 210 Args: 211 seed: if given, seeds weight initialisation deterministically 212 (``torch.manual_seed`` is called before the nodes are 213 constructed). Because init happens here, this is the single 214 obvious knob for a reproducible model — ``fit(seed=...)`` only 215 controls minibatch shuffling. 216 """ 217 super().__init__() 218 if seed is not None: 219 torch.manual_seed(seed) 220 self.spec = spec 221 self.order = validate_and_sort(spec) 222 self.nodes = nn.ModuleDict( 223 {name: _Node(name, spec[name], spec) for name in self.order} 224 ) 225 self.device = torch.device(device) 226 self.history: dict = {"train": [], "val": [], "lr": [], "time": []} 227 self.meta: dict = {} # provenance attached at save() (machine, versions) 228 self.vc_center_info: dict = {} # OOF bookkeeping of centered VC terms (fit) 229 self.to(self.device) 230 231 # ------------------------------------------------------------------ data 232 def _encode_parent(self, name: str, values: Tensor) -> Tensor: 233 """Encode the values of a node for use as a parent feature. 234 235 This follows the original TRAM-DAG convention. A continuous parent stays 236 raw, shape ``(n, 1)``. An ordinal parent is one-hot encoded, shape 237 ``(n, levels)``. 238 """ 239 node = self.spec[name] 240 if isinstance(node, OrdinalNode): 241 return torch.nn.functional.one_hot( 242 values.long(), num_classes=node.levels 243 ).to(values.dtype) 244 return values.view(-1, 1) 245 246 @property 247 def _dtype(self) -> torch.dtype: 248 """Current model dtype (float32 normally; float64 inside fit_classical).""" 249 return next(self.parameters()).dtype 250 251 def _tensorize(self, df: pd.DataFrame) -> dict[str, Tensor]: 252 np_dtype = np.float64 if self._dtype == torch.float64 else np.float32 253 out = {} 254 for name in self.order: 255 vals = torch.as_tensor( 256 df[name].to_numpy(dtype=np_dtype), device=self.device 257 ) 258 out[name] = vals 259 return out 260 261 def _features(self, values: dict[str, Tensor]) -> dict[str, Tensor]: 262 return {name: self._encode_parent(name, vals) for name, vals in values.items()} 263 264 # ------------------------------------------- centered-VC propensity (e_hat) 265 def _vc_ehat_live( 266 self, nd: _Node, values: dict[str, Tensor], n: int 267 ) -> dict[str, Tensor] | None: 268 """Recompute ``e_hat(pa_on) = P(on = 1 | pa_on)`` for the centered VC terms. 269 270 The value comes from this flow's own fitted ``on`` node, as a full-data 271 propensity fit. That is the DML prediction convention. Training uses 272 frozen out-of-fold values instead, see :meth:`fit`. 273 274 The result is detached, so no gradient reaches the ``on`` node from the 275 loss of this node. 276 277 The function derives the value from the current parent values, so 278 ``do``-mutilated sampling uses ``t - e_hat(x)`` with the intervened ``t`` 279 and the observed ``x``. It never reads a cached value. 280 """ 281 out = {} 282 for g in nd._vc_groups: 283 if not g.center: 284 continue 285 on_nd = self.nodes[g.on] 286 feats = self._features({p: values[p] for p in on_nd.parents}) 287 theta, shift = on_nd.theta_shift( 288 feats, n, vc_ehat=self._vc_ehat_live(on_nd, values, n) 289 ) 290 # binary ordinal on: P(on <= 0) = sigmoid(theta_0 - s), 291 # so e = sigmoid(s - theta_0) 292 out[g.on] = torch.sigmoid(shift - theta[:, 0]).detach() 293 return out or None 294 295 def _vc_ehat_columns(self, nd: _Node) -> list[str]: 296 """List the extra columns needed for the centered VC terms of ``nd``. 297 298 These are the columns beyond ``nd.parents``, namely the parents of the 299 treatment nodes, found recursively. 300 """ 301 cols: list[str] = [] 302 for g in nd._vc_groups: 303 if not g.center: 304 continue 305 on_nd = self.nodes[g.on] 306 cols += [p for p in on_nd.parents] + self._vc_ehat_columns(on_nd) 307 return [c for c in dict.fromkeys(cols) if c not in nd.parents] 308 309 # ------------------------------------------------------------- likelihood 310 def node_log_prob( 311 self, 312 values: dict[str, Tensor], 313 nodes: list[str] | None = None, 314 vc_ehat: dict[str, dict[str, Tensor]] | None = None, 315 ) -> dict[str, Tensor]: 316 """Per-node log-likelihood contributions, each (n,). 317 318 ``nodes`` restricts computation to a subset (used to skip frozen nodes 319 during training — valid because the per-node losses are independent). 320 ``vc_ehat`` ({node: {on: e_hat}}) overrides the propensity used by 321 centered VC terms — ``fit`` passes the frozen **out-of-fold** values for 322 the training rows; when omitted, the live full-fit propensity is 323 recomputed from the flow's own treatment node. 324 """ 325 feats = self._features(values) 326 n = next(iter(values.values())).shape[0] 327 out = {} 328 for name in self.order if nodes is None else nodes: 329 node = self.nodes[name] 330 ehat = ( 331 vc_ehat.get(name) 332 if vc_ehat is not None 333 else self._vc_ehat_live(node, values, n) 334 ) 335 theta, shift = node.theta_shift(feats, n, vc_ehat=ehat) 336 x = values[name] 337 if node.kind == "continuous": 338 z0, ladj = node.ut.forward(theta, x) 339 z = z0 + shift 340 out[name] = StandardLogistic.log_prob(z) + ladj 341 else: 342 out[name] = ordinal_log_prob(theta, shift, x) 343 return out 344 345 def log_prob(self, df: pd.DataFrame) -> Tensor: 346 """Joint log-likelihood log p(x) per row, shape (n,).""" 347 per_node = self.node_log_prob(self._tensorize(df)) 348 return torch.stack(list(per_node.values()), dim=0).sum(dim=0) 349 350 def nll(self, df: pd.DataFrame) -> dict[str, float]: 351 """Mean negative log-likelihood per node (diagnostic).""" 352 with torch.no_grad(): 353 per_node = self.node_log_prob(self._tensorize(df)) 354 return {k: float(-v.mean()) for k, v in per_node.items()} 355 356 # ------------------------------------------------------------------- fit 357 def _set_ranges(self, train_df: pd.DataFrame, marginal_init: bool = False) -> None: 358 """Map the train 5%/95% quantiles onto the transform domain. 359 360 This is the min-max scaling of the original implementation. 361 362 ``marginal_init``: opt-in calibrated Bernstein init (see ``fit``). Applied only 363 on the first fit (the same ``not ut._fitted`` guard as range-setting), so a 364 multi-phase fit does not reset a partially-trained intercept. 365 """ 366 from .transforms import BernsteinUT, ordinal_marginal_init_theta 367 368 for name in self.order: 369 node = self.nodes[name] 370 if node.kind == "continuous" and not node.ut._fitted: 371 q = train_df[name].quantile([0.05, 0.95]) 372 node.ut.set_range(q.iloc[0], q.iloc[1]) 373 if ( 374 marginal_init 375 and isinstance(node.ut, BernsteinUT) 376 and isinstance(node.intercept, SimpleIntercept) 377 ): 378 with torch.no_grad(): 379 node.intercept.theta.copy_(node.ut.marginal_init_theta()) 380 elif ( 381 node.kind == "ordinal" 382 and marginal_init 383 and isinstance(node.intercept, SimpleIntercept) 384 and not getattr(node.intercept, "_marginal_inited", False) 385 ): 386 # calibrate unconditional cutpoints to the marginal class log-odds 387 counts = np.bincount( 388 train_df[name].to_numpy().astype(np.int64), 389 minlength=self.spec[name].levels, 390 ) 391 with torch.no_grad(): 392 node.intercept.theta.copy_(ordinal_marginal_init_theta(counts)) 393 node.intercept._marginal_inited = True 394 395 def fit( 396 self, 397 train_df: pd.DataFrame, 398 val_df: pd.DataFrame | None = None, 399 epochs: int = 500, 400 learning_rate: float = 1e-2, 401 batch_size: int = 512, 402 verbose: int = 50, 403 seed: int | None = None, 404 restore_best: bool = False, 405 schedule: str | None = None, 406 plateau_patience: int = 15, 407 freeze_patience: int | None = None, 408 min_delta: float = 1e-4, 409 marginal_init: bool = False, 410 vc_warm_start: bool = True, 411 ) -> CausalFlowDAG: 412 """Jointly fit all nodes by maximum likelihood. 413 414 By default training keeps the **final** (converged) weights, so an 415 all-``ls`` model trained to convergence reproduces the classical maximum 416 likelihood estimate exactly (e.g. matches ``statsmodels``/``polr``). 417 418 The optimizer holds one parameter group per node. Because the joint NLL 419 decomposes per node with independent gradients, per-node learning rates 420 and freezing are exactly equivalent to independent per-node training. 421 422 Args: 423 val_df: optional held-out set, used only for monitoring (and for 424 ``restore_best``, ``schedule="plateau"`` and ``freeze_patience``). 425 If omitted, the training set is used for the validation metric. 426 restore_best: if True, snapshot each node's best-validation weights 427 during training and restore them at the end. This is a mild 428 early-stopping regularization and the convention of the original 429 implementation. The fit is then *not* the training-data MLE, so 430 leave it False for an exact classical comparison. Default False. 431 schedule: learning-rate schedule. ``None`` = constant (the classic 432 behavior); ``"onecycle"`` = ``OneCycleLR`` (warmup to 433 ``learning_rate``, then anneal; stepped per batch); 434 ``"cosine"`` = ``CosineAnnealingLR`` over ``epochs``; 435 ``"plateau"`` = **per-node** decay: a node's lr is multiplied by 436 0.3 whenever its own validation NLL hasn't improved by 437 ``min_delta`` for ``plateau_patience`` epochs (floor 1e-3 × 438 ``learning_rate``). 439 freeze_patience: if set, a node whose validation NLL hasn't improved 440 by ``min_delta`` for this many epochs is **frozen** — excluded 441 from the loss and backward pass (a real compute saving, since 442 per-node losses are independent). When every node is frozen the 443 fit returns early. Freeze epochs are recorded in 444 ``history["frozen"]``. 445 marginal_init: if True, calibrate each *unconditional* node's intercept 446 to its marginal at init, instead of zuko's default zero init. 447 Bernstein continuous nodes -> the linear map of the pre-scaled 448 domain onto the standard-logistic 5%/95% quantiles (default is 449 ~2.5x too steep); ordinal nodes -> cutpoints set to the empirical 450 class log-odds (default zeros = near-uniform). Pure init — the 451 converged MLE is unchanged — applied once (first fit only). 452 Opt-in; default off. Affects only ``SimpleIntercept`` nodes 453 (conditional ci intercepts are left untouched). 454 vc_warm_start: if True (default), each ``VC`` term's ``beta0`` is 455 initialised from the classical all-``ls`` solution of its node's 456 conditional (deterministic L-BFGS on a throwaway proxy) before 457 training, so the penalized head starts at the classical answer 458 and only learns deviations. Applied once per term (a buffer that 459 survives ``save``/``load`` guards re-runs). No-op without VC terms. 460 461 For ``VC`` terms the objective is the **penalized** NLL on the 462 total-likelihood scale — each term adds ``penalty * ||b_theta weights||^2`` 463 to the summed NLL, i.e. ``penalty * ||w||^2 / n_train`` to the mean loss 464 (a fixed Gaussian prior: the shrinkage vanishes as n grows, the classical 465 penalized-likelihood convention; ``beta0`` unpenalized). The recorded 466 ``history`` NLLs stay pure likelihoods. After training, each ``b_theta`` 467 is re-centered to mean zero over the training data (function-preserving; 468 the constant moves into ``beta0``). ``VC(center=...)`` terms run a 469 stage-1 out-of-fold propensity computation before the loop 470 (:meth:`_vc_oof_stage`); the training loss uses those frozen OOF values, 471 while the epoch-level validation monitor (and every post-fit query) 472 uses the live full-fit treatment node. 473 474 Calling ``fit`` again continues training (e.g. a second phase with a 475 lower learning rate); freezing state does not carry across calls. 476 """ 477 if schedule not in (None, "onecycle", "cosine", "plateau"): 478 raise ValueError(f"unknown schedule {schedule!r}") 479 if seed is not None: 480 torch.manual_seed(seed) 481 self._set_ranges(train_df, marginal_init=marginal_init) 482 if vc_warm_start: 483 self._vc_warm_start(train_df) 484 # VC effect heads whose L2 penalty joins the loss, per owning node 485 vc_penalized = { 486 name: [ 487 self.nodes[name].shifts[g.on] 488 for g in self.nodes[name]._vc_groups 489 if g.mods and self.nodes[name].shifts[g.on].penalty > 0 490 ] 491 for name in self.order 492 } 493 # stage 1 for centered VC terms (issue #30): frozen OUT-OF-FOLD e_hat 494 # for the training rows — a plain tensor, so the Y-node loss has no 495 # gradient path into the treatment node (per-node factorization intact). 496 vc_ehat_train = self._vc_oof_stage(train_df) 497 498 train_vals = self._tensorize(train_df) 499 val_vals = self._tensorize(val_df) if val_df is not None else train_vals 500 n = len(train_df) 501 steps_per_epoch = (n + batch_size - 1) // batch_size 502 503 opt = torch.optim.Adam( 504 [ 505 { 506 "params": list(self.nodes[name].parameters()), 507 "lr": learning_rate, 508 "node": name, 509 } 510 for name in self.order 511 ] 512 ) 513 sched = None 514 if schedule == "onecycle": 515 sched = torch.optim.lr_scheduler.OneCycleLR( 516 opt, max_lr=learning_rate, total_steps=epochs * steps_per_epoch 517 ) 518 elif schedule == "cosine": 519 sched = torch.optim.lr_scheduler.CosineAnnealingLR( 520 opt, T_max=epochs, eta_min=learning_rate * 1e-3 521 ) 522 523 if restore_best and not hasattr(self, "_best"): 524 self._best = {name: (float("inf"), None) for name in self.order} 525 best = self._best if restore_best else None 526 # per-node plateau/freeze bookkeeping (local to this fit call) 527 node_best = {name: float("inf") for name in self.order} 528 node_bad = {name: 0 for name in self.order} 529 frozen: set[str] = set() 530 t0 = time.perf_counter() 531 t_offset = self.history["time"][-1] if self.history.get("time") else 0.0 532 prev_train: dict[str, float] = {} 533 534 for epoch in range(epochs): 535 self.train() 536 active = [name for name in self.order if name not in frozen] 537 perm = torch.randperm(n, device=self.device) 538 train_acc = {name: prev_train.get(name, float("nan")) for name in frozen} 539 train_acc.update({name: 0.0 for name in active}) 540 for start in range(0, n, batch_size): 541 idx = perm[start : start + batch_size] 542 batch = {k: v[idx] for k, v in train_vals.items()} 543 ehat_batch = ( 544 None 545 if vc_ehat_train is None 546 else { 547 nm: {on: e[idx] for on, e in d.items()} 548 for nm, d in vc_ehat_train.items() 549 } 550 ) 551 per_node = self.node_log_prob(batch, nodes=active, vc_ehat=ehat_batch) 552 node_nlls = {k: -v.mean() for k, v in per_node.items()} 553 loss = torch.stack(list(node_nlls.values())).sum() 554 for name in active: # VC penalty (excluded from history) 555 for m in vc_penalized[name]: 556 loss = loss + m.penalty * m.l2() / n 557 opt.zero_grad() 558 loss.backward() 559 opt.step() 560 if schedule == "onecycle": 561 sched.step() 562 w = len(idx) / n 563 for k, v in node_nlls.items(): 564 train_acc[k] += float(v.detach()) * w 565 if schedule == "cosine": 566 sched.step() 567 prev_train = train_acc 568 569 self.eval() 570 with torch.no_grad(): 571 val_per_node = { 572 k: float(-v.mean()) for k, v in self.node_log_prob(val_vals).items() 573 } 574 self.history["train"].append(train_acc) 575 self.history["val"].append(val_per_node) 576 self.history.setdefault("lr", []).append( 577 max(g["lr"] for g in opt.param_groups) 578 ) 579 self.history.setdefault("time", []).append( 580 t_offset + time.perf_counter() - t0 581 ) 582 583 # per-node improvement tracking (plateau decay + freezing) 584 for g in opt.param_groups: 585 name = g["node"] 586 if name in frozen: 587 continue 588 if val_per_node[name] < node_best[name] - min_delta: 589 node_best[name] = val_per_node[name] 590 node_bad[name] = 0 591 else: 592 node_bad[name] += 1 593 if ( 594 schedule == "plateau" 595 and node_bad[name] > 0 596 and node_bad[name] % plateau_patience == 0 597 ): 598 g["lr"] = max(g["lr"] * 0.3, learning_rate * 1e-3) 599 # under "plateau", only freeze nodes whose lr has already been 600 # decayed substantially — otherwise a node can freeze while a 601 # smaller lr would still make progress toward the optimum 602 lr_decayed = schedule != "plateau" or g[ 603 "lr" 604 ] <= learning_rate * 1e-2 * (1 + 1e-9) 605 if ( 606 freeze_patience is not None 607 and lr_decayed 608 and node_bad[name] >= freeze_patience 609 ): 610 frozen.add(name) 611 self.history.setdefault("frozen", {}).setdefault( 612 name, len(self.history["val"]) 613 ) # 1-based global epoch 614 615 if restore_best: 616 for name in self.order: 617 if val_per_node[name] < best[name][0]: 618 best[name] = ( 619 val_per_node[name], 620 copy.deepcopy(self.nodes[name].state_dict()), 621 ) 622 623 if verbose and (epoch % verbose == 0 or epoch == epochs - 1): 624 tot_t = sum(train_acc.values()) 625 tot_v = sum(val_per_node.values()) 626 print( 627 f"[epoch {epoch + 1:5d}/{epochs}] train NLL {tot_t:.4f} " 628 f"val NLL {tot_v:.4f}" 629 + (f" frozen {sorted(frozen)}" if frozen else "") 630 ) 631 632 if len(frozen) == len(self.order): # everything converged 633 if verbose: 634 print(f"[epoch {epoch + 1:5d}] all nodes frozen — stopping.") 635 break 636 637 if restore_best: # restore per-node best-validation weights 638 for name, (_, state) in best.items(): 639 if state is not None: 640 self.nodes[name].load_state_dict(state) 641 self._recenter_vc(train_vals) 642 self.eval() 643 return self 644 645 # ------------------------------------------------- varying-coefficient (VC) 646 def _vc_warm_start(self, train_df: pd.DataFrame) -> None: 647 """Initialise every VC term's ``beta0`` from the classical solution. 648 649 The value comes from the all-``ls`` solution of the node's conditional. 650 Issue #28 recommends this warm start. 651 652 A throwaway proxy of the node (same kind/transform, every parent an LS 653 term, parent marginals irrelevant to the conditional because the joint 654 NLL decomposes per node) is fitted with the deterministic 655 :meth:`fit_classical`, and the ``on`` coefficient copied into ``beta0`` 656 (for a binary ordinal treatment, the identified one-hot difference 657 ``w[1] - w[0]``). ``b_theta`` already starts at the zero function 658 (zero-initialised output layer). Runs once per term — the 659 ``warm_started`` buffer survives ``save``/``load``. 660 """ 661 for name in self.order: 662 nd = self.nodes[name] 663 todo = [g for g in nd._vc_groups if not bool(nd.shifts[g[0]].warm_started)] 664 if not todo: 665 continue 666 node_spec = self.spec[name] 667 proxy_spec: dict[str, NodeSpec] = {} 668 for p in nd.parents: 669 pn = self.spec[p] 670 proxy_spec[p] = ( 671 OrdinalNode(levels=pn.levels) 672 if isinstance(pn, OrdinalNode) 673 else ContinuousNode(transform="affine") 674 ) 675 ls_terms = [LS(p) for p in nd.parents] 676 if isinstance(node_spec, OrdinalNode): 677 proxy_spec[name] = OrdinalNode(levels=node_spec.levels, terms=ls_terms) 678 else: 679 proxy_spec[name] = ContinuousNode( 680 transform=node_spec.transform, 681 transform_kwargs=dict(node_spec.transform_kwargs), 682 terms=ls_terms, 683 ) 684 proxy = CausalFlowDAG(proxy_spec, device=str(self.device)) 685 proxy.fit_classical(train_df[list(nd.parents) + [name]], verbose=False) 686 for g in todo: 687 w = proxy.nodes[name].shifts[g.on].weight.detach() 688 b0 = float(w[-1] - w[0]) if g.on_is_ord else float(w[0]) 689 m = nd.shifts[g.on] 690 with torch.no_grad(): 691 m.beta0.fill_(b0) 692 m.warm_started.fill_(True) 693 694 @torch.no_grad() 695 def _predict_p1(self, on: str, df: pd.DataFrame) -> np.ndarray: 696 """Give ``P(on = 1 | pa_on)`` from this flow's ``on`` node. 697 698 The treatment is binary ordinal, so the value is 699 ``sigmoid(shift - theta_0)``. 700 """ 701 nd = self.nodes[on] 702 np_dtype = np.float64 if self._dtype == torch.float64 else np.float32 703 values = { 704 p: torch.as_tensor(df[p].to_numpy(dtype=np_dtype), device=self.device) 705 for p in nd.parents 706 } 707 feats = self._features(values) 708 theta, shift = nd.theta_shift( 709 feats, len(df), vc_ehat=self._vc_ehat_live(nd, values, len(df)) 710 ) 711 return torch.sigmoid(shift - theta[:, 0]).cpu().numpy() 712 713 def _vc_oof_stage( 714 self, train_df: pd.DataFrame 715 ) -> dict[str, dict[str, Tensor]] | None: 716 """Compute stage 1 of the two-stage centered-VC design, issue #30. 717 718 The result holds the frozen training-time propensities, as 719 ``{node: {on: (n,) tensor}}``. 720 721 For ``center=True`` the values are **out-of-fold** — K refits of the 722 treatment node only, each predicting its held-out fold (the DML 723 cross-fitting requirement; in-sample e_hat reintroduces the 724 own-observation bias and can be worse than no centering). For 725 ``center="col"`` the user-supplied cross-fitted column is taken as-is. 726 Bookkeeping lands in ``self.vc_center_info[(node, on)]`` (``e_oof``, 727 ``fold_id``, ``folds``, ``source``) so tests can assert the fold 728 structure — a later "simplification" to in-sample e_hat fails CI. 729 """ 730 jobs = [ 731 (name, g) 732 for name in self.order 733 for g in self.nodes[name]._vc_groups 734 if g.center 735 ] 736 if not jobs: 737 return None 738 self.vc_center_info = {} 739 np_dtype = np.float64 if self._dtype == torch.float64 else np.float32 740 out: dict[str, dict[str, Tensor]] = {} 741 rng_state = torch.get_rng_state() # proxies reseed; keep fit reproducible 742 try: 743 for name, g in jobs: 744 if isinstance(g.center, str): # user-supplied cross-fitted col 745 if g.center not in train_df.columns: 746 raise KeyError(f"center column {g.center!r} not in train_df.") 747 e = train_df[g.center].to_numpy(dtype=np.float64) 748 if not ((e > 0.0) & (e < 1.0)).all(): 749 raise ValueError( 750 f"center column {g.center!r} must hold propensities " 751 "strictly inside (0, 1)." 752 ) 753 fold_id = None 754 else: 755 e, fold_id = self._vc_oof_propensity(g.on, train_df, g.folds) 756 out.setdefault(name, {})[g.on] = torch.as_tensor( 757 e.astype(np_dtype), device=self.device 758 ) 759 self.vc_center_info[(name, g.on)] = { 760 "source": g.center if isinstance(g.center, str) else "oof-refit", 761 "folds": None if fold_id is None else int(g.folds), 762 "fold_id": fold_id, 763 "e_oof": e.copy(), 764 "n": len(train_df), 765 } 766 finally: 767 torch.set_rng_state(rng_state) 768 return out 769 770 def _vc_oof_propensity( 771 self, on: str, train_df: pd.DataFrame, k: int 772 ) -> tuple[np.ndarray, np.ndarray]: 773 """Compute the out-of-fold ``P(on=1|pa_on)``. 774 775 The function refits the ``on`` node K times, and only that node. Each 776 refit uses a single-node proxy whose parents are sources, because their 777 marginals cannot influence the conditional. Each refit then predicts the 778 fold it never saw. 779 780 A treatment with all-``ls`` terms uses :meth:`fit_classical`, which is 781 deterministic and takes seconds. Any other treatment uses a 782 fixed-budget Adam fit. 783 """ 784 on_nd = self.nodes[on] 785 if any(g.center for g in on_nd._vc_groups): 786 raise NotImplementedError( 787 f"treatment node {on!r} itself has a centered VC term. " 788 "Chained centering is not supported." 789 ) 790 node_spec = self.spec[on] 791 proxy_spec: dict[str, NodeSpec] = {} 792 for p in on_nd.parents: 793 pn = self.spec[p] 794 proxy_spec[p] = ( 795 OrdinalNode(levels=pn.levels) 796 if isinstance(pn, OrdinalNode) 797 else ContinuousNode(transform="affine") 798 ) 799 terms = list(node_spec.terms) if node_spec.terms else None 800 proxy_spec[on] = OrdinalNode(levels=2, terms=terms) 801 all_ls = all(t.effect == "LS" for t in (terms or [])) 802 cols = list(on_nd.parents) + [on] 803 804 n = len(train_df) 805 fold_id = np.random.default_rng(0).permutation(n) % k 806 e = np.empty(n, dtype=np.float64) 807 for j in range(k): 808 proxy = CausalFlowDAG(proxy_spec, device=str(self.device), seed=0) 809 held_in = train_df.iloc[fold_id != j][cols] 810 if all_ls: 811 proxy.fit_classical(held_in, verbose=False) 812 else: 813 proxy.fit( 814 held_in, 815 epochs=300, 816 learning_rate=1e-2, 817 verbose=0, 818 seed=0, 819 restore_best=False, 820 ) 821 e[fold_id == j] = proxy._predict_p1(on, train_df.iloc[fold_id == j]) 822 return e, fold_id 823 824 @torch.no_grad() 825 def _recenter_vc(self, values: dict[str, Tensor]) -> None: 826 """Re-split every VC term so ``b_theta`` sums to zero over the train rows. 827 828 The removed constant moves into ``beta0``, so the modelled function does 829 not change. 830 """ 831 feats: dict[str, Tensor] | None = None 832 for name in self.order: 833 nd = self.nodes[name] 834 for g in nd._vc_groups: 835 if not g.mods: 836 continue 837 if feats is None: 838 feats = self._features(values) 839 nd.shifts[g.on].recenter(torch.cat([feats[p] for p in g.mods], dim=1)) 840 841 @torch.no_grad() 842 def varying_coef( 843 self, node: str, data: pd.DataFrame, on: str | None = None 844 ) -> np.ndarray: 845 """Evaluate the fitted effect function ``beta(x)`` of a ``VC`` term. 846 847 The function reads the rows of ``data``. It is the first-class read-out 848 of issue #28. 849 850 The value comes in closed form from the fitted term, as 851 ``beta0 + b_theta(modifiers)``. It is deterministic, it is free of ``y`` 852 because only the modifier columns of ``data`` are read, and it needs no 853 abduction. For a binary treatment it is identical to the abduction 854 difference ``u(x, t=1, y) - u(x, t=0, y)``. 855 856 The value lives on the latent, log-odds scale of the node. A continuous 857 node adds it. An ordinal node subtracts it from the cutpoints. 858 859 For a centered term, that is ``center=...``, the form of the returned 860 ``beta`` does not change, but ``beta0`` then reads as the effect at the 861 treatment margin, which is the observed propensities. 862 863 Args: 864 node: name of the node carrying the VC term. 865 on: the VC term's treatment name; optional when the node has exactly 866 one VC term. 867 868 Returns an ``(n,)`` array of ``beta`` values (constant when the term has 869 no modifiers). 870 """ 871 if node not in self.nodes: 872 raise KeyError(f"unknown node {node!r}") 873 nd = self.nodes[node] 874 vcs = {g.on: g.mods for g in nd._vc_groups} 875 if not vcs: 876 raise ValueError(f"node {node!r} has no VC term.") 877 if on is None: 878 if len(vcs) > 1: 879 raise ValueError( 880 f"node {node!r} has several VC terms ({sorted(vcs)}). " 881 "Pass on=<treatment name>." 882 ) 883 on = next(iter(vcs)) 884 if on not in vcs: 885 raise KeyError( 886 f"node {node!r} has no VC term on {on!r} (has {sorted(vcs)})." 887 ) 888 mods = vcs[on] 889 missing = [p for p in mods if p not in data.columns] 890 if missing: 891 raise KeyError(f"data is missing modifier column(s): {missing}") 892 mod_feat = None 893 if mods: 894 np_dtype = np.float64 if self._dtype == torch.float64 else np.float32 895 vals = { 896 p: torch.as_tensor(data[p].to_numpy(dtype=np_dtype), device=self.device) 897 for p in mods 898 } 899 feats = self._features(vals) 900 mod_feat = torch.cat([feats[p] for p in mods], dim=1) 901 return nd.shifts[on].beta(mod_feat, len(data)).cpu().numpy() 902 903 # --------------------------------------------------------- classical fit 904 def _is_all_ls(self) -> bool: 905 return all( 906 term.effect == "LS" 907 for node in self.spec.values() 908 for term in node_terms(node) 909 ) 910 911 def ls_coefficients(self) -> dict[str, dict[str, np.ndarray]]: 912 """Give the per-node linear-shift weights, as ``{node: {parent: array}}``. 913 914 For an all-``ls`` model these are the interpretable log-odds-ratio 915 coefficients. 916 """ 917 out: dict[str, dict[str, np.ndarray]] = {} 918 for name in self.order: 919 shifts = self.nodes[name].shifts 920 if shifts: 921 out[name] = { 922 p: m.weight.detach().cpu().numpy().ravel().copy() 923 for p, m in shifts.items() 924 } 925 return out 926 927 def to_matrix(self) -> pd.DataFrame: 928 """Give the labelled adjacency matrix of term effects. 929 930 Rows are parents and columns are children. This is the meta-adjacency 931 view of the paper. A cell holds ``"LS"``, ``"CS"`` or ``"CI"``, and an 932 empty cell means there is no edge. A multi-parent term carries its parent 933 group as a suffix. 934 """ 935 labels = {"I": "CI", "LS": "LS", "CS": "CS"} 936 m = pd.DataFrame("", index=list(self.order), columns=list(self.order)) 937 for child in self.order: 938 for term in node_terms(self.spec[child]): 939 if term.effect == "VC": # treatment cell "VC", modifiers "VCm" 940 cells = [(term.parents[0], "VC")] + [ 941 (p, "VCm") for p in term.parents[1:] 942 ] 943 else: 944 tag = labels[term.effect] 945 if len(term.parents) > 1: 946 tag = f"{tag}{list(term.parents)}" 947 cells = [(p, tag) for p in term.parents] 948 for p, tag in cells: # a VC modifier may share its cell with 949 cur = m.loc[p, child] # a prognostic term -> join with "+" 950 m.loc[p, child] = f"{cur}+{tag}" if cur else tag 951 return m 952 953 @torch.no_grad() 954 def intercept_contributions(self, node: str, data: pd.DataFrame) -> dict: 955 """Decompose a complex intercept into mean-centered per-term parts. 956 957 The parts are contributions to the transform parameters of the node. Use 958 them to plot additive partial effects. 959 960 An additive complex intercept ``terms=[I("x1"), I("x2")]`` builds one 961 network per ``I``-term and **sums their outputs in unconstrained 962 parameter space**: ``theta(pa) = net_1(x1) + net_2(x2)``. The sum is 963 identified (so every L1/L2/L3 query is correct), but each term's output is 964 identified only up to a constant — a constant moves freely between the 965 nets. This makes the *raw* per-term outputs not directly comparable. 966 967 Following the usual additive-model / GAM convention, this resolves the 968 ambiguity by a **sum-to-zero (mean-centering) constraint applied over the 969 rows of** ``data``: each term's contribution is centered to mean zero 970 (per parameter), and the removed constants are collected into a single 971 ``baseline``. The decomposition is exact — 972 973 ``theta(pa) = baseline + sum_terms contribution_term(pa)`` 974 975 — so ``baseline`` plus the (uncentered) row sum of the contributions 976 reproduces the model's transform parameters. This is **post-hoc only**: 977 it reads the fitted weights and changes nothing about the model or any 978 frozen number (issue #20, Option A). Shift terms (``LS``/``CS``) are a 979 separate, already-interpretable slot — see :meth:`ls_coefficients`. 980 981 Args: 982 node: name of a node with at least one complex-intercept (``I``) term 983 that has parents. 984 data: rows over which to center (and at which to evaluate the 985 contributions); must contain every intercept-parent column. 986 987 Returns a dict with: 988 ``"baseline"``: ``(P,)`` array — the absorbed constant (sum of the 989 per-term means), where ``P`` is the node's transform-parameter 990 count: ``ut.n_params`` for a continuous node (e.g. Bernstein 991 coefficients, the [widths|heights|derivatives] block of an RQ 992 spline, or the 2 affine parameters), ``levels - 1`` cutpoint 993 parameters for an ordinal node. The contributions live in the 994 transform's **unconstrained** parameter space (where the model 995 sums the additive terms, before the monotonicity constraint), so 996 they are exact partial effects on those parameters but not, in 997 general, an additive shift of the curve itself. 998 ``"contributions"``: ``{term_label: (n, P) array}`` — each term's 999 mean-centered contribution at each row (columns sum to ~0 over 1000 rows). ``term_label`` is the term's parents joined by ``"+"``. 1001 ``"parents"``: ``{term_label: tuple(parent_names)}``. 1002 """ 1003 if node not in self.nodes: 1004 raise KeyError(f"unknown node {node!r}") 1005 nd = self.nodes[node] 1006 groups = nd._intercept_groups 1007 if not groups: 1008 raise ValueError( 1009 f"node {node!r} has no complex-intercept (I) terms with parents. " 1010 "Its intercept is unconditional, so there is nothing to decompose." 1011 ) 1012 missing = [p for p in nd.ci_parents if p not in data.columns] 1013 if missing: 1014 raise KeyError(f"data is missing intercept-parent column(s): {missing}") 1015 1016 np_dtype = np.float64 if self._dtype == torch.float64 else np.float32 1017 vals = { 1018 p: torch.as_tensor(data[p].to_numpy(dtype=np_dtype), device=self.device) 1019 for p in nd.ci_parents 1020 } 1021 feats = self._features(vals) 1022 # one net per group: the additive case stores them in intercept_nets; 1023 # a single (possibly joint) I-term is the lone `intercept` network. 1024 nets = ( 1025 list(nd.intercept_nets) if nd.intercept_nets is not None else [nd.intercept] 1026 ) 1027 1028 contributions: dict[str, np.ndarray] = {} 1029 parents: dict[str, tuple] = {} 1030 baseline = None 1031 for net, grp in zip(nets, groups): 1032 raw = net(torch.cat([feats[p] for p in grp], dim=1)) # (n, P) 1033 mean = raw.mean(dim=0, keepdim=True) # (1, P) 1034 label = "+".join(grp) 1035 contributions[label] = (raw - mean).cpu().numpy() 1036 parents[label] = grp 1037 baseline = mean if baseline is None else baseline + mean 1038 return { 1039 "baseline": baseline.cpu().numpy().ravel(), 1040 "contributions": contributions, 1041 "parents": parents, 1042 } 1043 1044 def fit_classical( 1045 self, 1046 train_df: pd.DataFrame, 1047 *, 1048 max_iter: int = 400, 1049 tol: float = 1e-6, 1050 verbose: bool = True, 1051 ) -> dict: 1052 """Fit an all-``ls`` model the classical way. 1053 1054 The fit uses full batches, float64, and L-BFGS with a strong-Wolfe line 1055 search. There are no minibatches, no schedule and no early stopping, so 1056 the fit is deterministic and bit-reproducible. It lands on the exact 1057 maximum-likelihood estimate and matches classical software, that is 1058 ``statsmodels`` ``OrderedModel`` and R ``polr`` or ``Colr``. It is much 1059 faster than minibatch Adam. 1060 1061 This method is valid only when every edge is ``ls``, because each 1062 node-conditional is then a classical transformation model. Any other 1063 model raises. For a ``cs`` or ``ci`` model use :meth:`fit`, where the 1064 minibatch noise also regularizes the MLPs. 1065 1066 float64 is a transient compute mode. The model is upcast for the fit, 1067 and ``self.double()`` converts the parameters and the range buffers of 1068 the transforms in one call. Afterwards the model returns to float32, so 1069 the stored model and ``save``/``load`` stay float32. Double precision is 1070 what lets the line search resolve the optimum cleanly. 1071 1072 Convergence is judged by **NLL flatness** (relative change < ``tol`` 1073 between L-BFGS rounds). Note that ``|grad|`` and individual coefficients 1074 do *not* settle to machine precision: a continuous node's Bernstein 1075 intercept, and weakly-identified directions like rare one-hot levels or a 1076 flat treatment-effect ridge, keep drifting along near-zero-curvature 1077 valleys long after the likelihood (and the well-identified coefficients) 1078 have reached the MLE. Correctness is therefore verified by comparison to 1079 classical software (see ``experiments/validate_ls.py``), not by this flag. 1080 1081 Returns a convergence report (iterations, final NLL, gradient norm, 1082 max coefficient change at the last round, wall-time, and the fitted 1083 :meth:`ls_coefficients`). 1084 """ 1085 if not self._is_all_ls(): 1086 raise ValueError( 1087 "fit_classical requires an all-`ls` spec, that is every edge " 1088 "term 'ls'. This spec has cs, ci or vc terms. Use fit() for " 1089 "flexible models." 1090 ) 1091 self._set_ranges(train_df) 1092 1093 self.double() # parameters + buffers (xmin/xmax) -> float64, one call 1094 assert next(self.parameters()).dtype == torch.float64 1095 t0 = time.perf_counter() 1096 chunk = 25 # inner L-BFGS iterations per round; we stop on NLL change 1097 try: 1098 vals = self._tensorize(train_df) 1099 self.train() 1100 opt = torch.optim.LBFGS( 1101 self.parameters(), 1102 lr=1.0, 1103 max_iter=chunk, 1104 history_size=50, 1105 tolerance_grad=0.0, 1106 tolerance_change=0.0, 1107 line_search_fn="strong_wolfe", 1108 ) 1109 1110 def closure(): 1111 opt.zero_grad() 1112 nll = torch.stack( 1113 [-lp.mean() for lp in self.node_log_prob(vals).values()] 1114 ).sum() 1115 nll.backward() 1116 return nll 1117 1118 def flat_coefs() -> np.ndarray: 1119 cs = self.ls_coefficients() 1120 return ( 1121 np.concatenate([w for node in cs.values() for w in node.values()]) 1122 if cs 1123 else np.zeros(1) 1124 ) 1125 1126 prev_nll, prev_c, final_nll, n_iter, converged, coef_delta = ( 1127 float("inf"), 1128 flat_coefs(), 1129 float("nan"), 1130 0, 1131 False, 1132 float("inf"), 1133 ) 1134 for _ in range(max(1, max_iter // chunk)): 1135 final_nll = float(opt.step(closure)) 1136 n_iter += chunk 1137 cur_c = flat_coefs() 1138 coef_delta = float(np.abs(cur_c - prev_c).max()) 1139 prev_c = cur_c 1140 if abs(prev_nll - final_nll) < tol * (1.0 + abs(final_nll)): 1141 converged = True 1142 break 1143 prev_nll = final_nll 1144 grad_norm = float( 1145 torch.cat( 1146 [ 1147 p.grad.reshape(-1) 1148 for p in self.parameters() 1149 if p.grad is not None 1150 ] 1151 ).norm() 1152 ) 1153 coefs = self.ls_coefficients() # read while still float64 1154 finally: 1155 self.float() # restore canonical float32 (lossy ~1e-7, harmless) 1156 self.eval() 1157 1158 report = { 1159 "converged": converged, 1160 "n_iter": n_iter, 1161 "final_nll": final_nll, 1162 "grad_norm": grad_norm, 1163 "coef_delta": coef_delta, 1164 "seconds": time.perf_counter() - t0, 1165 "coefficients": coefs, 1166 } 1167 if verbose: 1168 print( 1169 f"fit_classical: {n_iter} L-BFGS iters, NLL {final_nll:.6f}, " 1170 f"{report['seconds']:.2f}s" 1171 + ("" if converged else f" (NLL still moving at {max_iter} iters)") 1172 ) 1173 return report 1174 1175 # ------------------------------------------------------- causal queries 1176 @torch.no_grad() 1177 def sample( 1178 self, 1179 n: int | None = None, 1180 *, 1181 do: dict[str, float] | None = None, 1182 u: pd.DataFrame | None = None, 1183 seed: int | None = None, 1184 ) -> pd.DataFrame: 1185 """Sample from the (optionally mutilated) flow. 1186 1187 Args: 1188 n: number of samples (ignored if ``u`` is given). 1189 do: interventions {node: value}; intervened nodes are clamped and 1190 their parent dependence removed (graph mutilation). 1191 u: latent variables (as returned by :meth:`abduct`). If given, they 1192 are pushed through the flow — together with ``do`` this yields 1193 counterfactuals (Pearl's abduction -> action -> prediction). 1194 """ 1195 do = do or {} 1196 gen = None 1197 if seed is not None: 1198 gen = torch.Generator(device=self.device).manual_seed(seed) 1199 1200 np_dtype = np.float64 if self._dtype == torch.float64 else np.float32 1201 if u is not None: 1202 n = len(u) 1203 u_vals = { 1204 name: torch.as_tensor( 1205 u[name].to_numpy(dtype=np_dtype, copy=True), device=self.device 1206 ) 1207 for name in self.order 1208 } 1209 elif n is not None: 1210 u_vals = { 1211 name: StandardLogistic.sample((n,), device=self.device) 1212 if gen is None 1213 else StandardLogistic.icdf( 1214 torch.rand((n,), device=self.device, generator=gen) 1215 ) 1216 for name in self.order 1217 } 1218 else: 1219 raise ValueError("Provide either n or u.") 1220 1221 values: dict[str, Tensor] = {} 1222 for name in self.order: 1223 if name in do: 1224 values[name] = torch.full( 1225 (n,), float(do[name]), dtype=self._dtype, device=self.device 1226 ) 1227 continue 1228 node = self.nodes[name] 1229 feats = self._features({p: values[p] for p in node.parents}) 1230 # centered VC: e_hat(pa_on) is re-derived from the already-sampled 1231 # ancestor values — under do the regressor is t_do - e_hat(x), never 1232 # a cached training value 1233 theta, shift = node.theta_shift( 1234 feats, n, vc_ehat=self._vc_ehat_live(node, values, n) 1235 ) 1236 z = u_vals[name] 1237 if node.kind == "continuous": 1238 values[name] = node.ut.inverse(theta, z - shift) 1239 else: 1240 values[name] = ordinal_sample(theta, shift, z) 1241 return pd.DataFrame({k: v.cpu().numpy() for k, v in values.items()}) 1242 1243 @torch.no_grad() 1244 def abduct(self, df: pd.DataFrame, seed: int | None = None) -> pd.DataFrame: 1245 """Pearl abduction: recover the latent variables ``u`` from observations. 1246 1247 Continuous nodes are inverted exactly (``u = h(x) + shift``); for ordinal 1248 nodes the latent is only interval-identified, so it is sampled from the 1249 standard logistic truncated to the observed level's interval. 1250 """ 1251 gen = None 1252 if seed is not None: 1253 gen = torch.Generator(device=self.device).manual_seed(seed) 1254 values = self._tensorize(df) 1255 feats = self._features(values) 1256 n = len(df) 1257 u = {} 1258 for name in self.order: 1259 node = self.nodes[name] 1260 theta, shift = node.theta_shift( 1261 feats, n, vc_ehat=self._vc_ehat_live(node, values, n) 1262 ) 1263 x = values[name] 1264 if node.kind == "continuous": 1265 z0, _ = node.ut.forward(theta, x) 1266 u[name] = z0 + shift 1267 else: 1268 u[name] = ordinal_abduct(theta, shift, x, generator=gen) 1269 return pd.DataFrame({k: v.cpu().numpy() for k, v in u.items()}) 1270 1271 @torch.no_grad() 1272 def pmf( 1273 self, df: pd.DataFrame, node: str, do: dict[str, float] | None = None 1274 ) -> np.ndarray: 1275 """Give the analytic class probabilities of an ordinal node. 1276 1277 The result has shape ``(n, levels)``. The parents of the node come from 1278 ``df``, after the ``do`` overrides are applied. 1279 """ 1280 if not isinstance(self.spec[node], OrdinalNode): 1281 raise ValueError(f"pmf() requires an ordinal node, '{node}' is continuous.") 1282 df_local = df.copy() 1283 for col, val in (do or {}).items(): 1284 df_local[col] = val 1285 nd = self.nodes[node] 1286 np_dtype = np.float64 if self._dtype == torch.float64 else np.float32 1287 cols = list(nd.parents) + self._vc_ehat_columns(nd) # + e_hat inputs 1288 values = { 1289 p: torch.as_tensor(df_local[p].to_numpy(dtype=np_dtype), device=self.device) 1290 for p in cols 1291 } 1292 feats = self._features({p: values[p] for p in nd.parents}) 1293 theta, shift = nd.theta_shift( 1294 feats, len(df_local), vc_ehat=self._vc_ehat_live(nd, values, len(df_local)) 1295 ) 1296 return ordinal_pmf(theta, shift).cpu().numpy() 1297 1298 # ------------------------------------------------------------------ scores 1299 @torch.no_grad() 1300 def scores( 1301 self, df: pd.DataFrame, node: str, params: str = "shift" 1302 ) -> pd.DataFrame: 1303 """Give the per-observation scores ``psi_i = d l_i / d theta``, issue #29. 1304 1305 The scores belong to the interpretable shift coefficients of a node and 1306 are analytic and exact, see ``tramdag.scores``. ``params="shift"`` is the 1307 only option and covers every ``LS`` weight and the ``beta0`` of every 1308 ``VC`` term. 1309 1310 At a fitted MLE each column sums to about zero. Order the rows by a 1311 covariate that truly modifies the treatment effect and the cumulative sum 1312 of the treatment column drifts. :meth:`effect_modifier_scan` measures 1313 that drift. 1314 1315 This is a pure read-out. It touches no fitting or sampling code path. 1316 """ 1317 if params != "shift": 1318 raise ValueError(f"params='shift' is the only option, got {params!r}.") 1319 from .scores import node_scores 1320 1321 return node_scores(self, df, node) 1322 1323 @torch.no_grad() 1324 def effect_modifier_scan( 1325 self, df: pd.DataFrame, node: str, on: str, candidates: list[str] | None = None 1326 ) -> pd.DataFrame: 1327 """Rank candidate effect modifiers with a Zeileis-Hornik fluctuation scan. 1328 1329 Issue #29 describes the method. Each candidate covariate is ranked by how 1330 strongly the scores of the ``on`` coefficient drift when the rows are 1331 ordered by it. A cheap all-``ls`` fit is enough, so this gives a measured 1332 shortlist for ``VC`` modifiers. 1333 1334 Returns 1335 ------- 1336 pd.DataFrame 1337 One row per candidate, with ``stat``, ``p_value``, ``crit_5pct`` and 1338 ``flag``. See ``tramdag.scores.effect_modifier_scan``. 1339 """ 1340 from .scores import effect_modifier_scan 1341 1342 return effect_modifier_scan(self, df, node, on, candidates=candidates) 1343 1344 # ------------------------------------------------------------------- io 1345 def save(self, path: str | Path) -> None: 1346 """Write the model, its history and its provenance to a checkpoint. 1347 1348 The file holds the spec and the weights, the training ``history``, and a 1349 ``meta`` block with the tramdag version, the save time, the device, and 1350 the machine that trained the model. A cached run therefore stays 1351 self-describing: the file alone is enough to rebuild a training-curve 1352 plot or to compare timings. 1353 """ 1354 from datetime import datetime, timezone 1355 1356 from . import __version__ 1357 from .env import machine_info 1358 1359 path = Path(path) 1360 path.parent.mkdir(parents=True, exist_ok=True) 1361 meta = { 1362 "tramdag_version": __version__, 1363 "saved_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), 1364 "device": str(self.device), 1365 "machine": machine_info(), 1366 } 1367 torch.save( 1368 { 1369 "spec": spec_to_dict(self.spec), 1370 "state_dict": self.state_dict(), 1371 "history": self.history, 1372 "meta": meta, 1373 }, 1374 path, 1375 ) 1376 1377 @classmethod 1378 def load(cls, path: str | Path, device: str = "cpu") -> CausalFlowDAG: 1379 """Restore a model from a checkpoint. 1380 1381 ``flow.history`` and ``flow.meta`` are refilled, so a cached model can 1382 still produce training and diagnostic plots, and can report the machine 1383 that trained it. 1384 """ 1385 ckpt = torch.load(path, map_location=device, weights_only=False) 1386 flow = cls(spec_from_dict(ckpt["spec"]), device=device) 1387 for name in flow.order: # mark transforms as fitted before loading buffers 1388 node = flow.nodes[name] 1389 if node.kind == "continuous": 1390 node.ut._fitted = True 1391 flow.load_state_dict(ckpt["state_dict"]) 1392 flow.history = ckpt.get( 1393 "history", {"train": [], "val": [], "lr": [], "time": []} 1394 ) 1395 flow.meta = ckpt.get("meta", {}) 1396 flow.eval() 1397 return flow
A causal normalizing flow defined by spec = {name: NodeSpec}.
205 def __init__( 206 self, spec: dict[str, NodeSpec], device: str = "cpu", seed: int | None = None 207 ): 208 """Build the flow from ``spec``. 209 210 Args: 211 seed: if given, seeds weight initialisation deterministically 212 (``torch.manual_seed`` is called before the nodes are 213 constructed). Because init happens here, this is the single 214 obvious knob for a reproducible model — ``fit(seed=...)`` only 215 controls minibatch shuffling. 216 """ 217 super().__init__() 218 if seed is not None: 219 torch.manual_seed(seed) 220 self.spec = spec 221 self.order = validate_and_sort(spec) 222 self.nodes = nn.ModuleDict( 223 {name: _Node(name, spec[name], spec) for name in self.order} 224 ) 225 self.device = torch.device(device) 226 self.history: dict = {"train": [], "val": [], "lr": [], "time": []} 227 self.meta: dict = {} # provenance attached at save() (machine, versions) 228 self.vc_center_info: dict = {} # OOF bookkeeping of centered VC terms (fit) 229 self.to(self.device)
Build the flow from spec.
Args:
seed: if given, seeds weight initialisation deterministically
(torch.manual_seed is called before the nodes are
constructed). Because init happens here, this is the single
obvious knob for a reproducible model — fit(seed=...) only
controls minibatch shuffling.
310 def node_log_prob( 311 self, 312 values: dict[str, Tensor], 313 nodes: list[str] | None = None, 314 vc_ehat: dict[str, dict[str, Tensor]] | None = None, 315 ) -> dict[str, Tensor]: 316 """Per-node log-likelihood contributions, each (n,). 317 318 ``nodes`` restricts computation to a subset (used to skip frozen nodes 319 during training — valid because the per-node losses are independent). 320 ``vc_ehat`` ({node: {on: e_hat}}) overrides the propensity used by 321 centered VC terms — ``fit`` passes the frozen **out-of-fold** values for 322 the training rows; when omitted, the live full-fit propensity is 323 recomputed from the flow's own treatment node. 324 """ 325 feats = self._features(values) 326 n = next(iter(values.values())).shape[0] 327 out = {} 328 for name in self.order if nodes is None else nodes: 329 node = self.nodes[name] 330 ehat = ( 331 vc_ehat.get(name) 332 if vc_ehat is not None 333 else self._vc_ehat_live(node, values, n) 334 ) 335 theta, shift = node.theta_shift(feats, n, vc_ehat=ehat) 336 x = values[name] 337 if node.kind == "continuous": 338 z0, ladj = node.ut.forward(theta, x) 339 z = z0 + shift 340 out[name] = StandardLogistic.log_prob(z) + ladj 341 else: 342 out[name] = ordinal_log_prob(theta, shift, x) 343 return out
Per-node log-likelihood contributions, each (n,).
nodes restricts computation to a subset (used to skip frozen nodes
during training — valid because the per-node losses are independent).
vc_ehat ({node: {on: e_hat}}) overrides the propensity used by
centered VC terms — fit passes the frozen out-of-fold values for
the training rows; when omitted, the live full-fit propensity is
recomputed from the flow's own treatment node.
345 def log_prob(self, df: pd.DataFrame) -> Tensor: 346 """Joint log-likelihood log p(x) per row, shape (n,).""" 347 per_node = self.node_log_prob(self._tensorize(df)) 348 return torch.stack(list(per_node.values()), dim=0).sum(dim=0)
Joint log-likelihood log p(x) per row, shape (n,).
350 def nll(self, df: pd.DataFrame) -> dict[str, float]: 351 """Mean negative log-likelihood per node (diagnostic).""" 352 with torch.no_grad(): 353 per_node = self.node_log_prob(self._tensorize(df)) 354 return {k: float(-v.mean()) for k, v in per_node.items()}
Mean negative log-likelihood per node (diagnostic).
395 def fit( 396 self, 397 train_df: pd.DataFrame, 398 val_df: pd.DataFrame | None = None, 399 epochs: int = 500, 400 learning_rate: float = 1e-2, 401 batch_size: int = 512, 402 verbose: int = 50, 403 seed: int | None = None, 404 restore_best: bool = False, 405 schedule: str | None = None, 406 plateau_patience: int = 15, 407 freeze_patience: int | None = None, 408 min_delta: float = 1e-4, 409 marginal_init: bool = False, 410 vc_warm_start: bool = True, 411 ) -> CausalFlowDAG: 412 """Jointly fit all nodes by maximum likelihood. 413 414 By default training keeps the **final** (converged) weights, so an 415 all-``ls`` model trained to convergence reproduces the classical maximum 416 likelihood estimate exactly (e.g. matches ``statsmodels``/``polr``). 417 418 The optimizer holds one parameter group per node. Because the joint NLL 419 decomposes per node with independent gradients, per-node learning rates 420 and freezing are exactly equivalent to independent per-node training. 421 422 Args: 423 val_df: optional held-out set, used only for monitoring (and for 424 ``restore_best``, ``schedule="plateau"`` and ``freeze_patience``). 425 If omitted, the training set is used for the validation metric. 426 restore_best: if True, snapshot each node's best-validation weights 427 during training and restore them at the end. This is a mild 428 early-stopping regularization and the convention of the original 429 implementation. The fit is then *not* the training-data MLE, so 430 leave it False for an exact classical comparison. Default False. 431 schedule: learning-rate schedule. ``None`` = constant (the classic 432 behavior); ``"onecycle"`` = ``OneCycleLR`` (warmup to 433 ``learning_rate``, then anneal; stepped per batch); 434 ``"cosine"`` = ``CosineAnnealingLR`` over ``epochs``; 435 ``"plateau"`` = **per-node** decay: a node's lr is multiplied by 436 0.3 whenever its own validation NLL hasn't improved by 437 ``min_delta`` for ``plateau_patience`` epochs (floor 1e-3 × 438 ``learning_rate``). 439 freeze_patience: if set, a node whose validation NLL hasn't improved 440 by ``min_delta`` for this many epochs is **frozen** — excluded 441 from the loss and backward pass (a real compute saving, since 442 per-node losses are independent). When every node is frozen the 443 fit returns early. Freeze epochs are recorded in 444 ``history["frozen"]``. 445 marginal_init: if True, calibrate each *unconditional* node's intercept 446 to its marginal at init, instead of zuko's default zero init. 447 Bernstein continuous nodes -> the linear map of the pre-scaled 448 domain onto the standard-logistic 5%/95% quantiles (default is 449 ~2.5x too steep); ordinal nodes -> cutpoints set to the empirical 450 class log-odds (default zeros = near-uniform). Pure init — the 451 converged MLE is unchanged — applied once (first fit only). 452 Opt-in; default off. Affects only ``SimpleIntercept`` nodes 453 (conditional ci intercepts are left untouched). 454 vc_warm_start: if True (default), each ``VC`` term's ``beta0`` is 455 initialised from the classical all-``ls`` solution of its node's 456 conditional (deterministic L-BFGS on a throwaway proxy) before 457 training, so the penalized head starts at the classical answer 458 and only learns deviations. Applied once per term (a buffer that 459 survives ``save``/``load`` guards re-runs). No-op without VC terms. 460 461 For ``VC`` terms the objective is the **penalized** NLL on the 462 total-likelihood scale — each term adds ``penalty * ||b_theta weights||^2`` 463 to the summed NLL, i.e. ``penalty * ||w||^2 / n_train`` to the mean loss 464 (a fixed Gaussian prior: the shrinkage vanishes as n grows, the classical 465 penalized-likelihood convention; ``beta0`` unpenalized). The recorded 466 ``history`` NLLs stay pure likelihoods. After training, each ``b_theta`` 467 is re-centered to mean zero over the training data (function-preserving; 468 the constant moves into ``beta0``). ``VC(center=...)`` terms run a 469 stage-1 out-of-fold propensity computation before the loop 470 (:meth:`_vc_oof_stage`); the training loss uses those frozen OOF values, 471 while the epoch-level validation monitor (and every post-fit query) 472 uses the live full-fit treatment node. 473 474 Calling ``fit`` again continues training (e.g. a second phase with a 475 lower learning rate); freezing state does not carry across calls. 476 """ 477 if schedule not in (None, "onecycle", "cosine", "plateau"): 478 raise ValueError(f"unknown schedule {schedule!r}") 479 if seed is not None: 480 torch.manual_seed(seed) 481 self._set_ranges(train_df, marginal_init=marginal_init) 482 if vc_warm_start: 483 self._vc_warm_start(train_df) 484 # VC effect heads whose L2 penalty joins the loss, per owning node 485 vc_penalized = { 486 name: [ 487 self.nodes[name].shifts[g.on] 488 for g in self.nodes[name]._vc_groups 489 if g.mods and self.nodes[name].shifts[g.on].penalty > 0 490 ] 491 for name in self.order 492 } 493 # stage 1 for centered VC terms (issue #30): frozen OUT-OF-FOLD e_hat 494 # for the training rows — a plain tensor, so the Y-node loss has no 495 # gradient path into the treatment node (per-node factorization intact). 496 vc_ehat_train = self._vc_oof_stage(train_df) 497 498 train_vals = self._tensorize(train_df) 499 val_vals = self._tensorize(val_df) if val_df is not None else train_vals 500 n = len(train_df) 501 steps_per_epoch = (n + batch_size - 1) // batch_size 502 503 opt = torch.optim.Adam( 504 [ 505 { 506 "params": list(self.nodes[name].parameters()), 507 "lr": learning_rate, 508 "node": name, 509 } 510 for name in self.order 511 ] 512 ) 513 sched = None 514 if schedule == "onecycle": 515 sched = torch.optim.lr_scheduler.OneCycleLR( 516 opt, max_lr=learning_rate, total_steps=epochs * steps_per_epoch 517 ) 518 elif schedule == "cosine": 519 sched = torch.optim.lr_scheduler.CosineAnnealingLR( 520 opt, T_max=epochs, eta_min=learning_rate * 1e-3 521 ) 522 523 if restore_best and not hasattr(self, "_best"): 524 self._best = {name: (float("inf"), None) for name in self.order} 525 best = self._best if restore_best else None 526 # per-node plateau/freeze bookkeeping (local to this fit call) 527 node_best = {name: float("inf") for name in self.order} 528 node_bad = {name: 0 for name in self.order} 529 frozen: set[str] = set() 530 t0 = time.perf_counter() 531 t_offset = self.history["time"][-1] if self.history.get("time") else 0.0 532 prev_train: dict[str, float] = {} 533 534 for epoch in range(epochs): 535 self.train() 536 active = [name for name in self.order if name not in frozen] 537 perm = torch.randperm(n, device=self.device) 538 train_acc = {name: prev_train.get(name, float("nan")) for name in frozen} 539 train_acc.update({name: 0.0 for name in active}) 540 for start in range(0, n, batch_size): 541 idx = perm[start : start + batch_size] 542 batch = {k: v[idx] for k, v in train_vals.items()} 543 ehat_batch = ( 544 None 545 if vc_ehat_train is None 546 else { 547 nm: {on: e[idx] for on, e in d.items()} 548 for nm, d in vc_ehat_train.items() 549 } 550 ) 551 per_node = self.node_log_prob(batch, nodes=active, vc_ehat=ehat_batch) 552 node_nlls = {k: -v.mean() for k, v in per_node.items()} 553 loss = torch.stack(list(node_nlls.values())).sum() 554 for name in active: # VC penalty (excluded from history) 555 for m in vc_penalized[name]: 556 loss = loss + m.penalty * m.l2() / n 557 opt.zero_grad() 558 loss.backward() 559 opt.step() 560 if schedule == "onecycle": 561 sched.step() 562 w = len(idx) / n 563 for k, v in node_nlls.items(): 564 train_acc[k] += float(v.detach()) * w 565 if schedule == "cosine": 566 sched.step() 567 prev_train = train_acc 568 569 self.eval() 570 with torch.no_grad(): 571 val_per_node = { 572 k: float(-v.mean()) for k, v in self.node_log_prob(val_vals).items() 573 } 574 self.history["train"].append(train_acc) 575 self.history["val"].append(val_per_node) 576 self.history.setdefault("lr", []).append( 577 max(g["lr"] for g in opt.param_groups) 578 ) 579 self.history.setdefault("time", []).append( 580 t_offset + time.perf_counter() - t0 581 ) 582 583 # per-node improvement tracking (plateau decay + freezing) 584 for g in opt.param_groups: 585 name = g["node"] 586 if name in frozen: 587 continue 588 if val_per_node[name] < node_best[name] - min_delta: 589 node_best[name] = val_per_node[name] 590 node_bad[name] = 0 591 else: 592 node_bad[name] += 1 593 if ( 594 schedule == "plateau" 595 and node_bad[name] > 0 596 and node_bad[name] % plateau_patience == 0 597 ): 598 g["lr"] = max(g["lr"] * 0.3, learning_rate * 1e-3) 599 # under "plateau", only freeze nodes whose lr has already been 600 # decayed substantially — otherwise a node can freeze while a 601 # smaller lr would still make progress toward the optimum 602 lr_decayed = schedule != "plateau" or g[ 603 "lr" 604 ] <= learning_rate * 1e-2 * (1 + 1e-9) 605 if ( 606 freeze_patience is not None 607 and lr_decayed 608 and node_bad[name] >= freeze_patience 609 ): 610 frozen.add(name) 611 self.history.setdefault("frozen", {}).setdefault( 612 name, len(self.history["val"]) 613 ) # 1-based global epoch 614 615 if restore_best: 616 for name in self.order: 617 if val_per_node[name] < best[name][0]: 618 best[name] = ( 619 val_per_node[name], 620 copy.deepcopy(self.nodes[name].state_dict()), 621 ) 622 623 if verbose and (epoch % verbose == 0 or epoch == epochs - 1): 624 tot_t = sum(train_acc.values()) 625 tot_v = sum(val_per_node.values()) 626 print( 627 f"[epoch {epoch + 1:5d}/{epochs}] train NLL {tot_t:.4f} " 628 f"val NLL {tot_v:.4f}" 629 + (f" frozen {sorted(frozen)}" if frozen else "") 630 ) 631 632 if len(frozen) == len(self.order): # everything converged 633 if verbose: 634 print(f"[epoch {epoch + 1:5d}] all nodes frozen — stopping.") 635 break 636 637 if restore_best: # restore per-node best-validation weights 638 for name, (_, state) in best.items(): 639 if state is not None: 640 self.nodes[name].load_state_dict(state) 641 self._recenter_vc(train_vals) 642 self.eval() 643 return self
Jointly fit all nodes by maximum likelihood.
By default training keeps the final (converged) weights, so an
all-ls model trained to convergence reproduces the classical maximum
likelihood estimate exactly (e.g. matches statsmodels/polr).
The optimizer holds one parameter group per node. Because the joint NLL decomposes per node with independent gradients, per-node learning rates and freezing are exactly equivalent to independent per-node training.
Args:
val_df: optional held-out set, used only for monitoring (and for
restore_best, schedule="plateau" and freeze_patience).
If omitted, the training set is used for the validation metric.
restore_best: if True, snapshot each node's best-validation weights
during training and restore them at the end. This is a mild
early-stopping regularization and the convention of the original
implementation. The fit is then not the training-data MLE, so
leave it False for an exact classical comparison. Default False.
schedule: learning-rate schedule. None = constant (the classic
behavior); "onecycle" = OneCycleLR (warmup to
learning_rate, then anneal; stepped per batch);
"cosine" = CosineAnnealingLR over epochs;
"plateau" = per-node decay: a node's lr is multiplied by
0.3 whenever its own validation NLL hasn't improved by
min_delta for plateau_patience epochs (floor 1e-3 ×
learning_rate).
freeze_patience: if set, a node whose validation NLL hasn't improved
by min_delta for this many epochs is frozen — excluded
from the loss and backward pass (a real compute saving, since
per-node losses are independent). When every node is frozen the
fit returns early. Freeze epochs are recorded in
history["frozen"].
marginal_init: if True, calibrate each unconditional node's intercept
to its marginal at init, instead of zuko's default zero init.
Bernstein continuous nodes -> the linear map of the pre-scaled
domain onto the standard-logistic 5%/95% quantiles (default is
~2.5x too steep); ordinal nodes -> cutpoints set to the empirical
class log-odds (default zeros = near-uniform). Pure init — the
converged MLE is unchanged — applied once (first fit only).
Opt-in; default off. Affects only SimpleIntercept nodes
(conditional ci intercepts are left untouched).
vc_warm_start: if True (default), each VC term's beta0 is
initialised from the classical all-ls solution of its node's
conditional (deterministic L-BFGS on a throwaway proxy) before
training, so the penalized head starts at the classical answer
and only learns deviations. Applied once per term (a buffer that
survives save/load guards re-runs). No-op without VC terms.
For VC terms the objective is the penalized NLL on the
total-likelihood scale — each term adds penalty * ||b_theta weights||^2
to the summed NLL, i.e. penalty * ||w||^2 / n_train to the mean loss
(a fixed Gaussian prior: the shrinkage vanishes as n grows, the classical
penalized-likelihood convention; beta0 unpenalized). The recorded
history NLLs stay pure likelihoods. After training, each b_theta
is re-centered to mean zero over the training data (function-preserving;
the constant moves into beta0). VC(center=...) terms run a
stage-1 out-of-fold propensity computation before the loop
(_vc_oof_stage()); the training loss uses those frozen OOF values,
while the epoch-level validation monitor (and every post-fit query)
uses the live full-fit treatment node.
Calling fit again continues training (e.g. a second phase with a
lower learning rate); freezing state does not carry across calls.
841 @torch.no_grad() 842 def varying_coef( 843 self, node: str, data: pd.DataFrame, on: str | None = None 844 ) -> np.ndarray: 845 """Evaluate the fitted effect function ``beta(x)`` of a ``VC`` term. 846 847 The function reads the rows of ``data``. It is the first-class read-out 848 of issue #28. 849 850 The value comes in closed form from the fitted term, as 851 ``beta0 + b_theta(modifiers)``. It is deterministic, it is free of ``y`` 852 because only the modifier columns of ``data`` are read, and it needs no 853 abduction. For a binary treatment it is identical to the abduction 854 difference ``u(x, t=1, y) - u(x, t=0, y)``. 855 856 The value lives on the latent, log-odds scale of the node. A continuous 857 node adds it. An ordinal node subtracts it from the cutpoints. 858 859 For a centered term, that is ``center=...``, the form of the returned 860 ``beta`` does not change, but ``beta0`` then reads as the effect at the 861 treatment margin, which is the observed propensities. 862 863 Args: 864 node: name of the node carrying the VC term. 865 on: the VC term's treatment name; optional when the node has exactly 866 one VC term. 867 868 Returns an ``(n,)`` array of ``beta`` values (constant when the term has 869 no modifiers). 870 """ 871 if node not in self.nodes: 872 raise KeyError(f"unknown node {node!r}") 873 nd = self.nodes[node] 874 vcs = {g.on: g.mods for g in nd._vc_groups} 875 if not vcs: 876 raise ValueError(f"node {node!r} has no VC term.") 877 if on is None: 878 if len(vcs) > 1: 879 raise ValueError( 880 f"node {node!r} has several VC terms ({sorted(vcs)}). " 881 "Pass on=<treatment name>." 882 ) 883 on = next(iter(vcs)) 884 if on not in vcs: 885 raise KeyError( 886 f"node {node!r} has no VC term on {on!r} (has {sorted(vcs)})." 887 ) 888 mods = vcs[on] 889 missing = [p for p in mods if p not in data.columns] 890 if missing: 891 raise KeyError(f"data is missing modifier column(s): {missing}") 892 mod_feat = None 893 if mods: 894 np_dtype = np.float64 if self._dtype == torch.float64 else np.float32 895 vals = { 896 p: torch.as_tensor(data[p].to_numpy(dtype=np_dtype), device=self.device) 897 for p in mods 898 } 899 feats = self._features(vals) 900 mod_feat = torch.cat([feats[p] for p in mods], dim=1) 901 return nd.shifts[on].beta(mod_feat, len(data)).cpu().numpy()
Evaluate the fitted effect function beta(x) of a VC term.
The function reads the rows of data. It is the first-class read-out
of issue #28.
The value comes in closed form from the fitted term, as
beta0 + b_theta(modifiers). It is deterministic, it is free of y
because only the modifier columns of data are read, and it needs no
abduction. For a binary treatment it is identical to the abduction
difference u(x, t=1, y) - u(x, t=0, y).
The value lives on the latent, log-odds scale of the node. A continuous node adds it. An ordinal node subtracts it from the cutpoints.
For a centered term, that is center=..., the form of the returned
beta does not change, but beta0 then reads as the effect at the
treatment margin, which is the observed propensities.
Args: node: name of the node carrying the VC term. on: the VC term's treatment name; optional when the node has exactly one VC term.
Returns an (n,) array of beta values (constant when the term has
no modifiers).
911 def ls_coefficients(self) -> dict[str, dict[str, np.ndarray]]: 912 """Give the per-node linear-shift weights, as ``{node: {parent: array}}``. 913 914 For an all-``ls`` model these are the interpretable log-odds-ratio 915 coefficients. 916 """ 917 out: dict[str, dict[str, np.ndarray]] = {} 918 for name in self.order: 919 shifts = self.nodes[name].shifts 920 if shifts: 921 out[name] = { 922 p: m.weight.detach().cpu().numpy().ravel().copy() 923 for p, m in shifts.items() 924 } 925 return out
Give the per-node linear-shift weights, as {node: {parent: array}}.
For an all-ls model these are the interpretable log-odds-ratio
coefficients.
927 def to_matrix(self) -> pd.DataFrame: 928 """Give the labelled adjacency matrix of term effects. 929 930 Rows are parents and columns are children. This is the meta-adjacency 931 view of the paper. A cell holds ``"LS"``, ``"CS"`` or ``"CI"``, and an 932 empty cell means there is no edge. A multi-parent term carries its parent 933 group as a suffix. 934 """ 935 labels = {"I": "CI", "LS": "LS", "CS": "CS"} 936 m = pd.DataFrame("", index=list(self.order), columns=list(self.order)) 937 for child in self.order: 938 for term in node_terms(self.spec[child]): 939 if term.effect == "VC": # treatment cell "VC", modifiers "VCm" 940 cells = [(term.parents[0], "VC")] + [ 941 (p, "VCm") for p in term.parents[1:] 942 ] 943 else: 944 tag = labels[term.effect] 945 if len(term.parents) > 1: 946 tag = f"{tag}{list(term.parents)}" 947 cells = [(p, tag) for p in term.parents] 948 for p, tag in cells: # a VC modifier may share its cell with 949 cur = m.loc[p, child] # a prognostic term -> join with "+" 950 m.loc[p, child] = f"{cur}+{tag}" if cur else tag 951 return m
Give the labelled adjacency matrix of term effects.
Rows are parents and columns are children. This is the meta-adjacency
view of the paper. A cell holds "LS", "CS" or "CI", and an
empty cell means there is no edge. A multi-parent term carries its parent
group as a suffix.
953 @torch.no_grad() 954 def intercept_contributions(self, node: str, data: pd.DataFrame) -> dict: 955 """Decompose a complex intercept into mean-centered per-term parts. 956 957 The parts are contributions to the transform parameters of the node. Use 958 them to plot additive partial effects. 959 960 An additive complex intercept ``terms=[I("x1"), I("x2")]`` builds one 961 network per ``I``-term and **sums their outputs in unconstrained 962 parameter space**: ``theta(pa) = net_1(x1) + net_2(x2)``. The sum is 963 identified (so every L1/L2/L3 query is correct), but each term's output is 964 identified only up to a constant — a constant moves freely between the 965 nets. This makes the *raw* per-term outputs not directly comparable. 966 967 Following the usual additive-model / GAM convention, this resolves the 968 ambiguity by a **sum-to-zero (mean-centering) constraint applied over the 969 rows of** ``data``: each term's contribution is centered to mean zero 970 (per parameter), and the removed constants are collected into a single 971 ``baseline``. The decomposition is exact — 972 973 ``theta(pa) = baseline + sum_terms contribution_term(pa)`` 974 975 — so ``baseline`` plus the (uncentered) row sum of the contributions 976 reproduces the model's transform parameters. This is **post-hoc only**: 977 it reads the fitted weights and changes nothing about the model or any 978 frozen number (issue #20, Option A). Shift terms (``LS``/``CS``) are a 979 separate, already-interpretable slot — see :meth:`ls_coefficients`. 980 981 Args: 982 node: name of a node with at least one complex-intercept (``I``) term 983 that has parents. 984 data: rows over which to center (and at which to evaluate the 985 contributions); must contain every intercept-parent column. 986 987 Returns a dict with: 988 ``"baseline"``: ``(P,)`` array — the absorbed constant (sum of the 989 per-term means), where ``P`` is the node's transform-parameter 990 count: ``ut.n_params`` for a continuous node (e.g. Bernstein 991 coefficients, the [widths|heights|derivatives] block of an RQ 992 spline, or the 2 affine parameters), ``levels - 1`` cutpoint 993 parameters for an ordinal node. The contributions live in the 994 transform's **unconstrained** parameter space (where the model 995 sums the additive terms, before the monotonicity constraint), so 996 they are exact partial effects on those parameters but not, in 997 general, an additive shift of the curve itself. 998 ``"contributions"``: ``{term_label: (n, P) array}`` — each term's 999 mean-centered contribution at each row (columns sum to ~0 over 1000 rows). ``term_label`` is the term's parents joined by ``"+"``. 1001 ``"parents"``: ``{term_label: tuple(parent_names)}``. 1002 """ 1003 if node not in self.nodes: 1004 raise KeyError(f"unknown node {node!r}") 1005 nd = self.nodes[node] 1006 groups = nd._intercept_groups 1007 if not groups: 1008 raise ValueError( 1009 f"node {node!r} has no complex-intercept (I) terms with parents. " 1010 "Its intercept is unconditional, so there is nothing to decompose." 1011 ) 1012 missing = [p for p in nd.ci_parents if p not in data.columns] 1013 if missing: 1014 raise KeyError(f"data is missing intercept-parent column(s): {missing}") 1015 1016 np_dtype = np.float64 if self._dtype == torch.float64 else np.float32 1017 vals = { 1018 p: torch.as_tensor(data[p].to_numpy(dtype=np_dtype), device=self.device) 1019 for p in nd.ci_parents 1020 } 1021 feats = self._features(vals) 1022 # one net per group: the additive case stores them in intercept_nets; 1023 # a single (possibly joint) I-term is the lone `intercept` network. 1024 nets = ( 1025 list(nd.intercept_nets) if nd.intercept_nets is not None else [nd.intercept] 1026 ) 1027 1028 contributions: dict[str, np.ndarray] = {} 1029 parents: dict[str, tuple] = {} 1030 baseline = None 1031 for net, grp in zip(nets, groups): 1032 raw = net(torch.cat([feats[p] for p in grp], dim=1)) # (n, P) 1033 mean = raw.mean(dim=0, keepdim=True) # (1, P) 1034 label = "+".join(grp) 1035 contributions[label] = (raw - mean).cpu().numpy() 1036 parents[label] = grp 1037 baseline = mean if baseline is None else baseline + mean 1038 return { 1039 "baseline": baseline.cpu().numpy().ravel(), 1040 "contributions": contributions, 1041 "parents": parents, 1042 }
Decompose a complex intercept into mean-centered per-term parts.
The parts are contributions to the transform parameters of the node. Use them to plot additive partial effects.
An additive complex intercept terms=[I("x1"), I("x2")] builds one
network per I-term and sums their outputs in unconstrained
parameter space: theta(pa) = net_1(x1) + net_2(x2). The sum is
identified (so every L1/L2/L3 query is correct), but each term's output is
identified only up to a constant — a constant moves freely between the
nets. This makes the raw per-term outputs not directly comparable.
Following the usual additive-model / GAM convention, this resolves the
ambiguity by a sum-to-zero (mean-centering) constraint applied over the
rows of data: each term's contribution is centered to mean zero
(per parameter), and the removed constants are collected into a single
baseline. The decomposition is exact —
``theta(pa) = baseline + sum_terms contribution_term(pa)``
— so baseline plus the (uncentered) row sum of the contributions
reproduces the model's transform parameters. This is post-hoc only:
it reads the fitted weights and changes nothing about the model or any
frozen number (issue #20, Option A). Shift terms (LS/CS) are a
separate, already-interpretable slot — see ls_coefficients().
Args:
node: name of a node with at least one complex-intercept (I) term
that has parents.
data: rows over which to center (and at which to evaluate the
contributions); must contain every intercept-parent column.
Returns a dict with:
"baseline": (P,) array — the absorbed constant (sum of the
per-term means), where P is the node's transform-parameter
count: ut.n_params for a continuous node (e.g. Bernstein
coefficients, the [widths|heights|derivatives] block of an RQ
spline, or the 2 affine parameters), levels - 1 cutpoint
parameters for an ordinal node. The contributions live in the
transform's unconstrained parameter space (where the model
sums the additive terms, before the monotonicity constraint), so
they are exact partial effects on those parameters but not, in
general, an additive shift of the curve itself.
"contributions": {term_label: (n, P) array} — each term's
mean-centered contribution at each row (columns sum to ~0 over
rows). term_label is the term's parents joined by "+".
"parents": {term_label: tuple(parent_names)}.
1044 def fit_classical( 1045 self, 1046 train_df: pd.DataFrame, 1047 *, 1048 max_iter: int = 400, 1049 tol: float = 1e-6, 1050 verbose: bool = True, 1051 ) -> dict: 1052 """Fit an all-``ls`` model the classical way. 1053 1054 The fit uses full batches, float64, and L-BFGS with a strong-Wolfe line 1055 search. There are no minibatches, no schedule and no early stopping, so 1056 the fit is deterministic and bit-reproducible. It lands on the exact 1057 maximum-likelihood estimate and matches classical software, that is 1058 ``statsmodels`` ``OrderedModel`` and R ``polr`` or ``Colr``. It is much 1059 faster than minibatch Adam. 1060 1061 This method is valid only when every edge is ``ls``, because each 1062 node-conditional is then a classical transformation model. Any other 1063 model raises. For a ``cs`` or ``ci`` model use :meth:`fit`, where the 1064 minibatch noise also regularizes the MLPs. 1065 1066 float64 is a transient compute mode. The model is upcast for the fit, 1067 and ``self.double()`` converts the parameters and the range buffers of 1068 the transforms in one call. Afterwards the model returns to float32, so 1069 the stored model and ``save``/``load`` stay float32. Double precision is 1070 what lets the line search resolve the optimum cleanly. 1071 1072 Convergence is judged by **NLL flatness** (relative change < ``tol`` 1073 between L-BFGS rounds). Note that ``|grad|`` and individual coefficients 1074 do *not* settle to machine precision: a continuous node's Bernstein 1075 intercept, and weakly-identified directions like rare one-hot levels or a 1076 flat treatment-effect ridge, keep drifting along near-zero-curvature 1077 valleys long after the likelihood (and the well-identified coefficients) 1078 have reached the MLE. Correctness is therefore verified by comparison to 1079 classical software (see ``experiments/validate_ls.py``), not by this flag. 1080 1081 Returns a convergence report (iterations, final NLL, gradient norm, 1082 max coefficient change at the last round, wall-time, and the fitted 1083 :meth:`ls_coefficients`). 1084 """ 1085 if not self._is_all_ls(): 1086 raise ValueError( 1087 "fit_classical requires an all-`ls` spec, that is every edge " 1088 "term 'ls'. This spec has cs, ci or vc terms. Use fit() for " 1089 "flexible models." 1090 ) 1091 self._set_ranges(train_df) 1092 1093 self.double() # parameters + buffers (xmin/xmax) -> float64, one call 1094 assert next(self.parameters()).dtype == torch.float64 1095 t0 = time.perf_counter() 1096 chunk = 25 # inner L-BFGS iterations per round; we stop on NLL change 1097 try: 1098 vals = self._tensorize(train_df) 1099 self.train() 1100 opt = torch.optim.LBFGS( 1101 self.parameters(), 1102 lr=1.0, 1103 max_iter=chunk, 1104 history_size=50, 1105 tolerance_grad=0.0, 1106 tolerance_change=0.0, 1107 line_search_fn="strong_wolfe", 1108 ) 1109 1110 def closure(): 1111 opt.zero_grad() 1112 nll = torch.stack( 1113 [-lp.mean() for lp in self.node_log_prob(vals).values()] 1114 ).sum() 1115 nll.backward() 1116 return nll 1117 1118 def flat_coefs() -> np.ndarray: 1119 cs = self.ls_coefficients() 1120 return ( 1121 np.concatenate([w for node in cs.values() for w in node.values()]) 1122 if cs 1123 else np.zeros(1) 1124 ) 1125 1126 prev_nll, prev_c, final_nll, n_iter, converged, coef_delta = ( 1127 float("inf"), 1128 flat_coefs(), 1129 float("nan"), 1130 0, 1131 False, 1132 float("inf"), 1133 ) 1134 for _ in range(max(1, max_iter // chunk)): 1135 final_nll = float(opt.step(closure)) 1136 n_iter += chunk 1137 cur_c = flat_coefs() 1138 coef_delta = float(np.abs(cur_c - prev_c).max()) 1139 prev_c = cur_c 1140 if abs(prev_nll - final_nll) < tol * (1.0 + abs(final_nll)): 1141 converged = True 1142 break 1143 prev_nll = final_nll 1144 grad_norm = float( 1145 torch.cat( 1146 [ 1147 p.grad.reshape(-1) 1148 for p in self.parameters() 1149 if p.grad is not None 1150 ] 1151 ).norm() 1152 ) 1153 coefs = self.ls_coefficients() # read while still float64 1154 finally: 1155 self.float() # restore canonical float32 (lossy ~1e-7, harmless) 1156 self.eval() 1157 1158 report = { 1159 "converged": converged, 1160 "n_iter": n_iter, 1161 "final_nll": final_nll, 1162 "grad_norm": grad_norm, 1163 "coef_delta": coef_delta, 1164 "seconds": time.perf_counter() - t0, 1165 "coefficients": coefs, 1166 } 1167 if verbose: 1168 print( 1169 f"fit_classical: {n_iter} L-BFGS iters, NLL {final_nll:.6f}, " 1170 f"{report['seconds']:.2f}s" 1171 + ("" if converged else f" (NLL still moving at {max_iter} iters)") 1172 ) 1173 return report
Fit an all-ls model the classical way.
The fit uses full batches, float64, and L-BFGS with a strong-Wolfe line
search. There are no minibatches, no schedule and no early stopping, so
the fit is deterministic and bit-reproducible. It lands on the exact
maximum-likelihood estimate and matches classical software, that is
statsmodels OrderedModel and R polr or Colr. It is much
faster than minibatch Adam.
This method is valid only when every edge is ls, because each
node-conditional is then a classical transformation model. Any other
model raises. For a cs or ci model use fit(), where the
minibatch noise also regularizes the MLPs.
float64 is a transient compute mode. The model is upcast for the fit,
and self.double() converts the parameters and the range buffers of
the transforms in one call. Afterwards the model returns to float32, so
the stored model and save/load stay float32. Double precision is
what lets the line search resolve the optimum cleanly.
Convergence is judged by NLL flatness (relative change < tol
between L-BFGS rounds). Note that |grad| and individual coefficients
do not settle to machine precision: a continuous node's Bernstein
intercept, and weakly-identified directions like rare one-hot levels or a
flat treatment-effect ridge, keep drifting along near-zero-curvature
valleys long after the likelihood (and the well-identified coefficients)
have reached the MLE. Correctness is therefore verified by comparison to
classical software (see experiments/validate_ls.py), not by this flag.
Returns a convergence report (iterations, final NLL, gradient norm,
max coefficient change at the last round, wall-time, and the fitted
ls_coefficients()).
1176 @torch.no_grad() 1177 def sample( 1178 self, 1179 n: int | None = None, 1180 *, 1181 do: dict[str, float] | None = None, 1182 u: pd.DataFrame | None = None, 1183 seed: int | None = None, 1184 ) -> pd.DataFrame: 1185 """Sample from the (optionally mutilated) flow. 1186 1187 Args: 1188 n: number of samples (ignored if ``u`` is given). 1189 do: interventions {node: value}; intervened nodes are clamped and 1190 their parent dependence removed (graph mutilation). 1191 u: latent variables (as returned by :meth:`abduct`). If given, they 1192 are pushed through the flow — together with ``do`` this yields 1193 counterfactuals (Pearl's abduction -> action -> prediction). 1194 """ 1195 do = do or {} 1196 gen = None 1197 if seed is not None: 1198 gen = torch.Generator(device=self.device).manual_seed(seed) 1199 1200 np_dtype = np.float64 if self._dtype == torch.float64 else np.float32 1201 if u is not None: 1202 n = len(u) 1203 u_vals = { 1204 name: torch.as_tensor( 1205 u[name].to_numpy(dtype=np_dtype, copy=True), device=self.device 1206 ) 1207 for name in self.order 1208 } 1209 elif n is not None: 1210 u_vals = { 1211 name: StandardLogistic.sample((n,), device=self.device) 1212 if gen is None 1213 else StandardLogistic.icdf( 1214 torch.rand((n,), device=self.device, generator=gen) 1215 ) 1216 for name in self.order 1217 } 1218 else: 1219 raise ValueError("Provide either n or u.") 1220 1221 values: dict[str, Tensor] = {} 1222 for name in self.order: 1223 if name in do: 1224 values[name] = torch.full( 1225 (n,), float(do[name]), dtype=self._dtype, device=self.device 1226 ) 1227 continue 1228 node = self.nodes[name] 1229 feats = self._features({p: values[p] for p in node.parents}) 1230 # centered VC: e_hat(pa_on) is re-derived from the already-sampled 1231 # ancestor values — under do the regressor is t_do - e_hat(x), never 1232 # a cached training value 1233 theta, shift = node.theta_shift( 1234 feats, n, vc_ehat=self._vc_ehat_live(node, values, n) 1235 ) 1236 z = u_vals[name] 1237 if node.kind == "continuous": 1238 values[name] = node.ut.inverse(theta, z - shift) 1239 else: 1240 values[name] = ordinal_sample(theta, shift, z) 1241 return pd.DataFrame({k: v.cpu().numpy() for k, v in values.items()})
Sample from the (optionally mutilated) flow.
Args:
n: number of samples (ignored if u is given).
do: interventions {node: value}; intervened nodes are clamped and
their parent dependence removed (graph mutilation).
u: latent variables (as returned by abduct()). If given, they
are pushed through the flow — together with do this yields
counterfactuals (Pearl's abduction -> action -> prediction).
1243 @torch.no_grad() 1244 def abduct(self, df: pd.DataFrame, seed: int | None = None) -> pd.DataFrame: 1245 """Pearl abduction: recover the latent variables ``u`` from observations. 1246 1247 Continuous nodes are inverted exactly (``u = h(x) + shift``); for ordinal 1248 nodes the latent is only interval-identified, so it is sampled from the 1249 standard logistic truncated to the observed level's interval. 1250 """ 1251 gen = None 1252 if seed is not None: 1253 gen = torch.Generator(device=self.device).manual_seed(seed) 1254 values = self._tensorize(df) 1255 feats = self._features(values) 1256 n = len(df) 1257 u = {} 1258 for name in self.order: 1259 node = self.nodes[name] 1260 theta, shift = node.theta_shift( 1261 feats, n, vc_ehat=self._vc_ehat_live(node, values, n) 1262 ) 1263 x = values[name] 1264 if node.kind == "continuous": 1265 z0, _ = node.ut.forward(theta, x) 1266 u[name] = z0 + shift 1267 else: 1268 u[name] = ordinal_abduct(theta, shift, x, generator=gen) 1269 return pd.DataFrame({k: v.cpu().numpy() for k, v in u.items()})
Pearl abduction: recover the latent variables u from observations.
Continuous nodes are inverted exactly (u = h(x) + shift); for ordinal
nodes the latent is only interval-identified, so it is sampled from the
standard logistic truncated to the observed level's interval.
1271 @torch.no_grad() 1272 def pmf( 1273 self, df: pd.DataFrame, node: str, do: dict[str, float] | None = None 1274 ) -> np.ndarray: 1275 """Give the analytic class probabilities of an ordinal node. 1276 1277 The result has shape ``(n, levels)``. The parents of the node come from 1278 ``df``, after the ``do`` overrides are applied. 1279 """ 1280 if not isinstance(self.spec[node], OrdinalNode): 1281 raise ValueError(f"pmf() requires an ordinal node, '{node}' is continuous.") 1282 df_local = df.copy() 1283 for col, val in (do or {}).items(): 1284 df_local[col] = val 1285 nd = self.nodes[node] 1286 np_dtype = np.float64 if self._dtype == torch.float64 else np.float32 1287 cols = list(nd.parents) + self._vc_ehat_columns(nd) # + e_hat inputs 1288 values = { 1289 p: torch.as_tensor(df_local[p].to_numpy(dtype=np_dtype), device=self.device) 1290 for p in cols 1291 } 1292 feats = self._features({p: values[p] for p in nd.parents}) 1293 theta, shift = nd.theta_shift( 1294 feats, len(df_local), vc_ehat=self._vc_ehat_live(nd, values, len(df_local)) 1295 ) 1296 return ordinal_pmf(theta, shift).cpu().numpy()
Give the analytic class probabilities of an ordinal node.
The result has shape (n, levels). The parents of the node come from
df, after the do overrides are applied.
1299 @torch.no_grad() 1300 def scores( 1301 self, df: pd.DataFrame, node: str, params: str = "shift" 1302 ) -> pd.DataFrame: 1303 """Give the per-observation scores ``psi_i = d l_i / d theta``, issue #29. 1304 1305 The scores belong to the interpretable shift coefficients of a node and 1306 are analytic and exact, see ``tramdag.scores``. ``params="shift"`` is the 1307 only option and covers every ``LS`` weight and the ``beta0`` of every 1308 ``VC`` term. 1309 1310 At a fitted MLE each column sums to about zero. Order the rows by a 1311 covariate that truly modifies the treatment effect and the cumulative sum 1312 of the treatment column drifts. :meth:`effect_modifier_scan` measures 1313 that drift. 1314 1315 This is a pure read-out. It touches no fitting or sampling code path. 1316 """ 1317 if params != "shift": 1318 raise ValueError(f"params='shift' is the only option, got {params!r}.") 1319 from .scores import node_scores 1320 1321 return node_scores(self, df, node)
Give the per-observation scores psi_i = d l_i / d theta, issue #29.
The scores belong to the interpretable shift coefficients of a node and
are analytic and exact, see tramdag.scores. params="shift" is the
only option and covers every LS weight and the beta0 of every
VC term.
At a fitted MLE each column sums to about zero. Order the rows by a
covariate that truly modifies the treatment effect and the cumulative sum
of the treatment column drifts. effect_modifier_scan() measures
that drift.
This is a pure read-out. It touches no fitting or sampling code path.
1323 @torch.no_grad() 1324 def effect_modifier_scan( 1325 self, df: pd.DataFrame, node: str, on: str, candidates: list[str] | None = None 1326 ) -> pd.DataFrame: 1327 """Rank candidate effect modifiers with a Zeileis-Hornik fluctuation scan. 1328 1329 Issue #29 describes the method. Each candidate covariate is ranked by how 1330 strongly the scores of the ``on`` coefficient drift when the rows are 1331 ordered by it. A cheap all-``ls`` fit is enough, so this gives a measured 1332 shortlist for ``VC`` modifiers. 1333 1334 Returns 1335 ------- 1336 pd.DataFrame 1337 One row per candidate, with ``stat``, ``p_value``, ``crit_5pct`` and 1338 ``flag``. See ``tramdag.scores.effect_modifier_scan``. 1339 """ 1340 from .scores import effect_modifier_scan 1341 1342 return effect_modifier_scan(self, df, node, on, candidates=candidates)
Rank candidate effect modifiers with a Zeileis-Hornik fluctuation scan.
Issue #29 describes the method. Each candidate covariate is ranked by how
strongly the scores of the on coefficient drift when the rows are
ordered by it. A cheap all-ls fit is enough, so this gives a measured
shortlist for VC modifiers.
Returns
- pd.DataFrame: One row per candidate, with
stat,p_value,crit_5pctandflag. Seetramdag.scores.effect_modifier_scan.
1345 def save(self, path: str | Path) -> None: 1346 """Write the model, its history and its provenance to a checkpoint. 1347 1348 The file holds the spec and the weights, the training ``history``, and a 1349 ``meta`` block with the tramdag version, the save time, the device, and 1350 the machine that trained the model. A cached run therefore stays 1351 self-describing: the file alone is enough to rebuild a training-curve 1352 plot or to compare timings. 1353 """ 1354 from datetime import datetime, timezone 1355 1356 from . import __version__ 1357 from .env import machine_info 1358 1359 path = Path(path) 1360 path.parent.mkdir(parents=True, exist_ok=True) 1361 meta = { 1362 "tramdag_version": __version__, 1363 "saved_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), 1364 "device": str(self.device), 1365 "machine": machine_info(), 1366 } 1367 torch.save( 1368 { 1369 "spec": spec_to_dict(self.spec), 1370 "state_dict": self.state_dict(), 1371 "history": self.history, 1372 "meta": meta, 1373 }, 1374 path, 1375 )
Write the model, its history and its provenance to a checkpoint.
The file holds the spec and the weights, the training history, and a
meta block with the tramdag version, the save time, the device, and
the machine that trained the model. A cached run therefore stays
self-describing: the file alone is enough to rebuild a training-curve
plot or to compare timings.
1377 @classmethod 1378 def load(cls, path: str | Path, device: str = "cpu") -> CausalFlowDAG: 1379 """Restore a model from a checkpoint. 1380 1381 ``flow.history`` and ``flow.meta`` are refilled, so a cached model can 1382 still produce training and diagnostic plots, and can report the machine 1383 that trained it. 1384 """ 1385 ckpt = torch.load(path, map_location=device, weights_only=False) 1386 flow = cls(spec_from_dict(ckpt["spec"]), device=device) 1387 for name in flow.order: # mark transforms as fitted before loading buffers 1388 node = flow.nodes[name] 1389 if node.kind == "continuous": 1390 node.ut._fitted = True 1391 flow.load_state_dict(ckpt["state_dict"]) 1392 flow.history = ckpt.get( 1393 "history", {"train": [], "val": [], "lr": [], "time": []} 1394 ) 1395 flow.meta = ckpt.get("meta", {}) 1396 flow.eval() 1397 return flow
Restore a model from a checkpoint.
flow.history and flow.meta are refilled, so a cached model can
still produce training and diagnostic plots, and can report the machine
that trained it.
170@dataclass 171class ContinuousNode: 172 """Continuous variable, modelled by a monotone 1-D transform + shifts. 173 174 Args: 175 terms: additive formula, a list of :func:`I`/:func:`LS`/:func:`CS` terms 176 (``None`` / omitted = a source node). 177 transform: "bernstein" (TRAM-faithful), "spline" or "affine". 178 transform_kwargs: forwarded to the transform. 179 """ 180 181 terms: list[Term] | None = None 182 transform: str = "bernstein" 183 transform_kwargs: dict = field(default_factory=dict) 184 kind: str = field(default="continuous", init=False)
Continuous variable, modelled by a monotone 1-D transform + shifts.
Args:
terms: additive formula, a list of I()/LS()/CS() terms
(None / omitted = a source node).
transform: "bernstein" (TRAM-faithful), "spline" or "affine".
transform_kwargs: forwarded to the transform.
187@dataclass 188class OrdinalNode: 189 """Ordinal variable with ``levels`` ordered classes, stored 0 to levels-1. 190 191 An ordered logit models it: increasing cutpoints plus the shift terms. 192 """ 193 194 levels: int 195 terms: list[Term] | None = None 196 kind: str = field(default="ordinal", init=False)
Ordinal variable with levels ordered classes, stored 0 to levels-1.
An ordered logit models it: increasing cutpoints plus the shift terms.
19def machine_info() -> dict: 20 """Describe the machine and the software environment. 21 22 The snapshot holds the host name, the operating system, the CPU and GPU, 23 the core count, the RAM size, and the versions of python, torch, zuko and 24 tramdag. 25 26 Returns 27 ------- 28 dict 29 One key per property. A property that cannot be read is ``None``. 30 This function never raises. 31 """ 32 info: dict = { 33 "hostname": socket.gethostname().split(".")[0], 34 "os": f"{platform.system()} {platform.release()}", 35 "machine": platform.machine(), 36 "processor": platform.processor() or platform.machine(), 37 "cpu_count": os.cpu_count(), 38 "python": platform.python_version(), 39 "torch": torch.__version__, 40 "cuda": (torch.cuda.get_device_name(0) if torch.cuda.is_available() else None), 41 "mps": bool( 42 getattr(torch.backends, "mps", None) and torch.backends.mps.is_available() 43 ), 44 } 45 try: 46 import zuko 47 48 info["zuko"] = zuko.__version__ 49 except Exception: 50 info["zuko"] = None 51 try: 52 from . import __version__ 53 54 info["tramdag"] = __version__ 55 except Exception: 56 info["tramdag"] = None 57 try: # total RAM (POSIX) 58 info["ram_gb"] = round( 59 os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES") / 1e9, 1 60 ) 61 except (ValueError, OSError, AttributeError): 62 info["ram_gb"] = None 63 return info
Describe the machine and the software environment.
The snapshot holds the host name, the operating system, the CPU and GPU, the core count, the RAM size, and the versions of python, torch, zuko and tramdag.
Returns
- dict: One key per property. A property that cannot be read is
None. This function never raises.
42@dataclass(frozen=True) 43class Term: 44 """One additive term of a node's transformation. 45 46 ``effect`` ∈ {"I", "LS", "CS", "VC"}; ``slot`` is "intercept" for ``I`` and 47 "shift" for ``LS``/``CS``/``VC``. ``parents`` is the (ordered) tuple of parent 48 names the term depends on — empty only for the bare simple-intercept ``I()``. 49 For a ``VC`` term ``parents[0]`` is the treatment (``on``) and the rest are 50 the effect modifiers; ``penalty`` is its L2 penalty weight (``None`` for 51 every other effect). 52 """ 53 54 effect: str 55 slot: str 56 parents: tuple[str, ...] 57 penalty: float | None = None 58 center: bool | str = False 59 center_folds: int = 5
One additive term of a node's transformation.
effect ∈ {"I", "LS", "CS", "VC"}; slot is "intercept" for I and
"shift" for LS/CS/VC. parents is the (ordered) tuple of parent
names the term depends on — empty only for the bare simple-intercept I().
For a VC term parents[0] is the treatment (on) and the rest are
the effect modifiers; penalty is its L2 penalty weight (None for
every other effect).
62def I(*parents: str) -> Term: # noqa: E743 - single-letter name is the intended notation 63 """Intercept term — the parent(s) reshape the transform. ``I()`` = SI base.""" 64 return Term("I", "intercept", tuple(parents))
Intercept term — the parent(s) reshape the transform. I() = SI base.
67def LS(*parents: str) -> Term: 68 """Linear shift ``beta * x`` — exactly one parent.""" 69 if len(parents) != 1: 70 raise ValueError("LS() takes exactly one parent.") 71 return Term("LS", "shift", tuple(parents))
Linear shift beta * x — exactly one parent.
74def CS(*parents: str) -> Term: 75 """Complex (MLP) shift — at least one parent.""" 76 if not parents: 77 raise ValueError("CS() needs at least one parent.") 78 return Term("CS", "shift", tuple(parents))
Complex (MLP) shift — at least one parent.
81def VC( 82 on: str, 83 *modifiers: str, 84 penalty: float = 1.0, 85 center: bool | str = False, 86 center_folds: int = 5, 87) -> Term: 88 """Build a varying-coefficient shift ``beta(modifiers) * x_on``. 89 90 This is the treatment-effect term of issue #28. 91 92 ``beta(x) = beta0 + b_theta(x)`` with ``b_theta`` a small MLP whose weights 93 carry the L2 ``penalty``: the fitting objective is the penalized NLL 94 ``sum_i nll_i + penalty * ||b_theta weights||^2`` (total-likelihood scale — 95 a fixed Gaussian prior whose shrinkage vanishes as n grows; ``beta0`` 96 unpenalized). ``b_theta``'s output is zero-initialised and, after fitting, 97 mean-centered over the training data, so ``beta0`` is the interpretable main 98 effect (log-odds scale; the classical ``Colr``/``LS`` reading when ``beta`` 99 is constant). ``penalty -> inf`` — or ``modifiers=()`` exactly — reduces the 100 term to ``LS(on)``, so VC-vs-LS is a nested question. Read the fitted effect 101 out with :meth:`CausalFlowDAG.varying_coef`. 102 103 ``on`` must be continuous or a binary (2-level) ordinal node; the term is 104 linear in ``x_on``. Unlike other effects, VC *modifiers* may also appear in 105 the node's prognostic terms (``CS``/``LS``/``I``) — only ``on`` owns its edge. 106 107 ``center=True`` (issue #30) uses the **propensity-centered** regressor 108 ``beta(x) * (x_on - e_hat(pa_on))`` — the Robinson/R-learner 109 orthogonalization inside the likelihood; requires a binary ordinal ``on``. 110 Training uses **out-of-fold** ``e_hat`` (``center_folds``-fold refits of the 111 ``on`` node only — the DML requirement; in-sample centering can be *worse* 112 than none), frozen as data so no gradient reaches the ``on`` node from this 113 node's loss. Inference (``log_prob``/``sample``/``abduct``/``pmf``) recomputes 114 ``e_hat`` from the flow's own fitted ``on`` node — the full-data fit, the 115 standard DML train/predict split — and always re-derives ``x_on - e_hat`` 116 under ``do`` (never cached). ``center="colname"`` instead takes the 117 training-time cross-fitted propensity from that column of ``train_df``. 118 With centering, ``beta0`` is the effect at the treatment margin (the 119 observed propensities); the LS-nesting reading applies to the uncentered 120 term only. 121 """ 122 if on in modifiers: 123 raise ValueError( 124 f"VC(): '{on}' cannot be both the treatment (on) and a modifier." 125 ) 126 if penalty < 0: 127 raise ValueError(f"VC(): penalty must be >= 0, got {penalty}.") 128 if center_folds < 2: 129 raise ValueError(f"VC(): center_folds must be >= 2, got {center_folds}.") 130 return Term( 131 "VC", 132 "shift", 133 (on, *modifiers), 134 penalty=float(penalty), 135 center=center, 136 center_folds=int(center_folds), 137 )
Build a varying-coefficient shift beta(modifiers) * x_on.
This is the treatment-effect term of issue #28.
beta(x) = beta0 + b_theta(x) with b_theta a small MLP whose weights
carry the L2 penalty: the fitting objective is the penalized NLL
sum_i nll_i + penalty * ||b_theta weights||^2 (total-likelihood scale —
a fixed Gaussian prior whose shrinkage vanishes as n grows; beta0
unpenalized). b_theta's output is zero-initialised and, after fitting,
mean-centered over the training data, so beta0 is the interpretable main
effect (log-odds scale; the classical Colr/LS reading when beta
is constant). penalty -> inf — or modifiers=() exactly — reduces the
term to LS(on), so VC-vs-LS is a nested question. Read the fitted effect
out with CausalFlowDAG.varying_coef().
on must be continuous or a binary (2-level) ordinal node; the term is
linear in x_on. Unlike other effects, VC modifiers may also appear in
the node's prognostic terms (CS/LS/I) — only on owns its edge.
center=True (issue #30) uses the propensity-centered regressor
beta(x) * (x_on - e_hat(pa_on)) — the Robinson/R-learner
orthogonalization inside the likelihood; requires a binary ordinal on.
Training uses out-of-fold e_hat (center_folds-fold refits of the
on node only — the DML requirement; in-sample centering can be worse
than none), frozen as data so no gradient reaches the on node from this
node's loss. Inference (log_prob/sample/abduct/pmf) recomputes
e_hat from the flow's own fitted on node — the full-data fit, the
standard DML train/predict split — and always re-derives x_on - e_hat
under do (never cached). center="colname" instead takes the
training-time cross-fitted propensity from that column of train_df.
With centering, beta0 is the effect at the treatment margin (the
observed propensities); the LS-nesting reading applies to the uncentered
term only.
147def term(effect: str, *parents: str, penalty: float | None = None) -> Term: 148 """Build a :class:`Term` from an effect label. 149 150 Use this when the effect type comes from data, for example when a study 151 sweeps ``"ls"`` against ``"cs"``. The function accepts both the legacy 152 labels ``"ls"``, ``"cs"`` and ``"ci"``, and the current labels ``"LS"``, 153 ``"CS"``, ``"I"`` and ``"VC"``. ``penalty`` applies to ``"VC"`` only, and 154 ``VC`` uses its own default when you omit it. 155 """ 156 e = _LEGACY.get(effect.lower(), effect.upper()) 157 if penalty is not None and e != "VC": 158 raise ValueError(f"term(): penalty only applies to 'VC', not '{effect}'.") 159 if e == "I": 160 return I(*parents) 161 if e == "LS": 162 return LS(*parents) 163 if e == "CS": 164 return CS(*parents) 165 if e == "VC": 166 return VC(*parents) if penalty is None else VC(*parents, penalty=penalty) 167 raise ValueError(f"unknown term effect '{effect}'.")
Build a Term from an effect label.
Use this when the effect type comes from data, for example when a study
sweeps "ls" against "cs". The function accepts both the legacy
labels "ls", "cs" and "ci", and the current labels "LS",
"CS", "I" and "VC". penalty applies to "VC" only, and
VC uses its own default when you omit it.
62def I(*parents: str) -> Term: # noqa: E743 - single-letter name is the intended notation 63 """Intercept term — the parent(s) reshape the transform. ``I()`` = SI base.""" 64 return Term("I", "intercept", tuple(parents))
Intercept term — the parent(s) reshape the transform. I() = SI base.
67def LS(*parents: str) -> Term: 68 """Linear shift ``beta * x`` — exactly one parent.""" 69 if len(parents) != 1: 70 raise ValueError("LS() takes exactly one parent.") 71 return Term("LS", "shift", tuple(parents))
Linear shift beta * x — exactly one parent.
74def CS(*parents: str) -> Term: 75 """Complex (MLP) shift — at least one parent.""" 76 if not parents: 77 raise ValueError("CS() needs at least one parent.") 78 return Term("CS", "shift", tuple(parents))
Complex (MLP) shift — at least one parent.