Architecture¶
The package has ten modules and one rule. Term-specific behavior lives on the
term's two classes: its Term subclass (the spec) and its module in
modules.py. Node-kind maths lives in five Node methods, nodes.encode and
the score derivative in scores.py (scores.md). Everything else is
framework code.
Module map¶
graph TD
subgraph data["spec"]
spec["spec.py<br/>DSL: Term + one subclass per term<br/>(Intercept LinearShift ComplexShift<br/>VaryingCoefficient = I LS CS VC),<br/>each holding its module class;<br/>nodes, normalization, Kahn sort, (de)serialization"]
end
subgraph torch["torch modules"]
modules["modules.py<br/>one nn.Module per term, built from (term, schema):<br/>ShiftModule/InterceptModule hooks,<br/>intercept_module, feat_width, _InputTransform;<br/>LinearShiftModule ComplexShiftModule<br/>VaryingCoefficientModule,<br/>SimpleInterceptModule ComplexInterceptModule<br/>AdditiveInterceptModule"]
transforms["transforms.py<br/>Bernstein/Spline/Affine,<br/>ordinal_* likelihood,<br/>StandardLogistic"]
nodes["nodes.py<br/>Node: intercept + shifts,<br/>encode, log_prob, sample,<br/>abduct, marginal_theta,<br/>calibrate, save/load"]
flow["flow.py<br/>CausalFlowDAG: construct, calibrate (private),<br/>log_prob, sample/abduct/pmf/density,<br/>save/load; composes the mixins"]
end
subgraph functions["behavior by concern"]
fitting["fitting.py<br/>NodeFitMixin: fit (Adam loop, callbacks),<br/>fit_classical (L-BFGS), nll;<br/>FitMixin: fit, fit_jointly,<br/>fit_classical for the flow"]
readouts["readouts.py<br/>ReadoutsMixin: ls_coefficients,<br/>varying_coef, to_matrix, contributions,<br/>design_matrix, shift_curve"]
scores["scores.py<br/>node_scores,<br/>effect_modifier_scan"]
end
callbacks["callbacks.py<br/>Callback, EarlyStopping"]
plots["plots.py<br/>plot_dag, plot_marginals,<br/>plot_training, plot_varying_coef<br/>(matplotlib optional)"]
spec --> modules
nodes --> spec
nodes --> transforms
nodes --> fitting
nodes --> modules
flow --> nodes
flow --> modules
flow --> spec
flow --> transforms
flow --> fitting
flow --> readouts
flow --> scores
fitting --> callbacks
readouts --> modules
scores --> transforms
plots --> spec
modules.py imports nothing from spec.py at run time: it reads the node's
parent schema, {parent: "continuous" | n_levels}, and a term's parents and
options. That is what lets each term class hold its module class directly
(LinearShift.module is LinearShiftModule). fitting.py and readouts.py are
mixins: Node composes NodeFitMixin, and CausalFlowDAG composes FitMixin
and ReadoutsMixin. fitting imports flow and nodes under TYPE_CHECKING
only, so the import graph is acyclic at module load.
The term contract¶
classDiagram
class Term {
<<spec.py, plain data>>
name: class attribute, the wire key
module: class attribute, the module class
parents
__init__(*parents): the base assigns the parents
options: each subclass's keyword arguments, assigned to self
__repr__(): the only one, every entry as name=value
check(name, spec)
edge_parents
cells()
classical
options() / from_serialized()
}
Term <|-- Intercept : I
Term <|-- LinearShift : LS
Term <|-- ComplexShift : CS
Term <|-- VaryingCoefficient : VC
class TermModule {
<<modules.py, nn.Module>>
input_transform / calibrate(train_df)
parameter_groups()
}
class ShiftModule {
__init__(term, schema): builds the net, sets key and parents
order
shift_value(node, feats)
post_init()
regularizer() -> Tensor | None
finalize(node, feats)
score_columns(node, feats, dlds)
side_columns() / check_column() / live_side() / extra_columns()
}
class InterceptModule {
__init__(term, schema, n_params): intercept_module picks the class
groups / ci_parents
calibrate_intercept(train_df, own, ut)
theta_value(node, feats, n)
marginal_start(theta)
}
TermModule <|-- ShiftModule
TermModule <|-- InterceptModule
ShiftModule <|-- LinearShiftModule
ShiftModule <|-- ComplexShiftModule
ShiftModule <|-- VaryingCoefficientModule
InterceptModule <|-- SimpleInterceptModule
InterceptModule <|-- ComplexInterceptModule
InterceptModule <|-- AdditiveInterceptModule
Each module builds its layers in a fixed order under fixed attribute names.
So the state-dict paths (nodes.<n>.shifts.<key>.…) and the seeded RNG stream
are stable. tests/test_statedict_stability.py pins them.
A custom term is two classes:
- a
ShiftModulesubclass with__init__(term, schema), which builds the network from the parent schema and setskey,parentsandshift_value; - a
tramdag.Termsubclass withnameandmoduleas class attributes (__init_subclass__refuses one without). Its options are the keyword arguments of its__init__, assigned toselfaftersuper().__init__(*parents); its rules are the checks after that, pluscheckandedge_parents.
Node kinds¶
The two node kinds are continuous and ordinal. The maths that differs by kind is an if/else in three places.
- The five
Nodemethodslog_prob,sample,abduct,marginal_thetaandencode, plus the transform choice inNode.__init__. - The column encoding
nodes.encode. - The score derivative in
scores.py(scores.md).
Other code reads a node's schema entry (nodes.schema_entry):
"continuous" or a level count.
Guards that pin all of this¶
tests/tools/statedict_smoke.py: seeded per-DGP state dicts, bit-compared.- The inline DGP truths (
tests/conftest.py) and the regex-pinned refusals. experiments/ground_truth/*.json: the CI-checked replications with wall-time tripwires. Centers move only with a documented reason.