ReferenceStructural causal models
Structural causal models
The SCM pillar: a Markovian Bayesian structural causal model, PyMC under the hood. Import from ergodic.scm.
A SCM is a MixedGraph plus a fitted mechanism per
node. Every node is a structural equation over an explicit exogenous-noise
variable, so the model answers the three rungs of the causal ladder: observational
sampling, pm.do interventions, and the explicit-noise twin-network
counterfactual (abduct the unit's noise on the factual graph, predict on an action
branch that reuses it).
The honesty thesis is built in. Before answering an effect, the SCM asks the
identification layer. An identified effect returns a posterior
SCMResult; a non-identified one returns a Sensitivity (the
b(c) curve over the assumed confounding), never a naked point, so a caller
cannot accidentally read a confident number off an effect the data does not
determine.
PyMC is an optional dependency. Importing this package never imports pymc; the
import pymc lives inside the methods that fit or query, so the bare install
keeps working (the PyMCLearner precedent). Install the
extra with pip install 'ergodic[scm]'.
Mechanisms are auto-assigned by the column schema (continuous to a Bayesian-linear Gaussian-additive, binary and categorical to a Gumbel-max), and overridable.
Also exported from ergodic: SCM, BinaryGumbelMax, CategoricalGumbelMax, GaussianAdditive, SCMResult, Sensitivity.
SCM#
class SCM(
graph: MixedGraph,
data: Dataset,
*,
mechanisms: Mapping[str, Any] | None = None,
draws: int = 1000,
tune: int = 1000,
chains: int = 2,
cores: int = 1,
random_state: int | None = None,
district_size_cap: int = 8,
) -> None
A Bayesian structural causal model over a DAG or a latent-confounded ADMG.
Build with SCM(graph, data) (mechanisms auto-assigned by schema) or pass
mechanisms={node: spec} to override. spec is a built-in name
("linear", "categorical", "binary") or a
Mechanism instance. Call fit(), then query.
The graph may be a Markovian DAG, or an ADMG (<-> edges) / a DAG with
latent= confounders. A bidirected edge is realized as one explicit per-unit
latent common cause: the confounded district fits jointly (the latent absorbs the
confounding) so an effect identified despite the confounding (front-door, the
general criterion with the latent modeled) computes correctly. A genuinely
non-identified effect still returns a Sensitivity through
ate(), never a naked point -- identification, not the fit, decides.
Selection bias stays out of scope (identification raises on selection marks).
A CPDAG, MAG, PAG, or mixed graph is refused: pick one world first.
draws, tune, and chains set every NUTS run. cores caps the
worker processes PyMC may use for the chains (default 1, so chains run one
after another and a fit never fans out over the machine); raise it to run
chains in parallel. The draws do not depend on it.
SCM.fitted#
property SCM.fitted: bool
Whether every node's mechanism has been fitted.
SCM.fit#
def SCM.fit(*, parallel: bool = False) -> SCM
Fit every node's mechanism, per node for singletons, jointly per district.
A Markovian singleton district fits per node (cheap and exact); a
confounded district (nodes sharing a <->) does not factorize -- the latent
couples it -- so it fits as one joint NUTS block with the explicit latents,
the gauge, a non-centered parameterization, and a high target_accept. The
composed posterior merges the two the same way it merges per-node fits.
Sequential by default (robust, CI-safe). parallel=True spreads the
singleton nodes over a process pool with the BLAS/OMP threads pinned in the
workers; a confounded district's joint fit stays in the main process (its
chains already parallelize and its model is one block). Returns self.
SCM.sample#
def SCM.sample(n: int = 1000) -> SCMResult | CategoricalResult
Observational draws of every node (read the target with marginal()).
Returns the posterior predictive of the generative model over n units;
the result carries the draws of the last node in topological order (a sink)
as a summary. A categorical sink returns a CategoricalResult (the
per-category marginal). Use marginal() to read a specific node, or
interventional() / counterfactual() for a query on one.
SCM.marginal#
def SCM.marginal(
target: str,
n: int = 1000,
) -> SCMResult | CategoricalResult
Observational posterior-predictive draws of one node (kind="predictive").
A continuous node returns an SCMResult; a categorical/binary node a
CategoricalResult (the per-category marginal), exactly as
counterfactual() does for a categorical target.
SCM.do#
def SCM.do(intervention: Mapping[str, DoValue]) -> InterventionView
A lightweight intervened view of the SCM for chaining.
scm.do({"X": 1.0}).interventional("Y") reads the post-intervention
distribution. The view shares the fit; it does not refit. A continuous
do-target takes a float; a categorical/binary do-target takes its integer
category code.
SCM.interventional#
def SCM.interventional(
target: str,
do: Mapping[str, DoValue] | None = None,
*,
n: int = 2000,
) -> SCMResult | CategoricalResult
The distribution of target under do, through pm.do.
Returns posterior-predictive draws of the outcome for n units under the
intervention: each draw is one unit's Y (parameter uncertainty plus the
mechanism's noise). So value (the mean) estimates E[Y | do(X = x)],
while std_error and interval() describe the spread of Y itself,
not the uncertainty in that mean; the result's kind is
"predictive" and its repr says "predictive interval". For an effect
with a credible interval on the expectation, use ate().
do maps each intervened node to its value (broadcast to a length-n
vector internally, the verified shape gotcha; an integer code for a
categorical/binary do-target, a float for a continuous one). target must
not be an intervened node. A categorical target returns a
CategoricalResult (the post-intervention per-category marginal); a
continuous target an SCMResult.
SCM.counterfactual#
def SCM.counterfactual(
units: Dataset | Mapping[str, Any],
do: Mapping[str, float],
target: str,
*,
use_draws: bool = True,
) -> SCMResult | CategoricalResult
The twin-network counterfactual for the given units under do.
Abduct each unit's structural noise on the factual graph, then predict
target on the action branch reusing it. units is a dataset (or a
mapping of node name to a column of factual values) holding every node's
factual value per unit. For invertible mechanisms the counterfactual is
exact (use_draws=False collapses to the deterministic point; the
default carries parameter uncertainty). A categorical target returns a
CategoricalResult (the answer is a distribution).
SCM.ite#
def SCM.ite(
units: Dataset | Mapping[str, Any],
treatment: str,
outcome: str,
*,
levels: tuple[float, float] | None = None,
shift: float | None = None,
) -> ITEResult
Per-unit individual treatment effects, through the twin network.
For each unit, the exact counterfactual contrast of outcome under two
treatment worlds that share that unit's abducted noise (the same-person,
two-worlds twin network). Returns one effect per unit, ordered as units --
the structural-model sibling of a CATE estimator's
cate(), which returns a conditional
average over the noise rather than the individual's own counterfactual.
Two framings of the contrast, and you pick one:
- an absolute level contrast (the default):
Y | do(X=high) - Y | do(X=low), both worlds pinning the treatment to a fixed level for every unit, the very contrastate()averages. Passlevels=(low, high); it defaults to(0.0, 1.0)(a unit step, or the two codes of a binary treatment). Works for a continuous, binary, or (with explicit codes) categorical treatment, and for a continuous or binary outcome -- a binary outcome's per-unit effect is its counterfactual flip, in{-1, 0, 1}. - a shift (a "shock"):
Y | do(X = x_i + shift) - Y_factual, perturbing each unit's treatment from its own factual value, against the world where nothing was done. Passshift=delta(a continuous treatment and outcome).
The two coincide for a linear-additive mechanism: the shift contrast is the
constant coef * delta for every unit, equal to the matching level step. They
part only once a mechanism is nonlinear, where the shift is the unit's local
response at its own baseline. So with the built-in linear-Gaussian mechanisms
the continuous ITE is constant across units (the coefficient), and genuine
per-unit spread comes from a binary or categorical outcome, whose logistic
response varies with each unit's baseline.
Each effect is the exact point (invertible mechanisms). For an individual's
posterior interval, call counterfactual() on that one unit per arm with
use_draws=True and difference the draws.
SCM.ate#
def SCM.ate(
treatment: str,
outcome: str,
*,
levels: tuple[float, float] | None = None,
engine: str = 'posterior',
n: int = 2000,
) -> SCMResult | Sensitivity
The average treatment effect, routed through identification first.
identify_effect decides the return shape. Identified by back-door
adjustment (or the no-path "null" case): the SCM posterior plug-in
contrast E[Y | do(X = high)] - E[Y | do(X = low)] (a posterior
SCMResult). Not identified: a
Sensitivity (the b(c) curve over the assumed
confounding), so a naked point is impossible.
The treatment may be continuous, binary, or categorical. levels is the
(low, high) contrast; it defaults to (0.0, 1.0) (the two codes of a
binary treatment, or a unit step for a continuous one). A multi-category
treatment has no canonical pair, so it requires explicit levels (the
two category codes to contrast), mirroring the inference pillar's treated=
pattern. The outcome must be numeric (a categorical outcome has no scalar
contrast and raises); confounders and mediators may be any type -- a
categorical one is sampled in the generative model, so the back-door
g-formula marginalizes over it correctly.
Identified by back-door adjustment, the no-path "null" case, or
front-door computes the latent-aware do-contrast: a confounded district
fits its explicit latent, so the structural mechanism on the causal path fits
clean and do severs the treatment from the district latent, leaving the
identified value (the front-door mediated effect, the C1 world's 1.2). The
instrument strategy still raises with a pointer to
estimate_effect(): an instrument identifies the effect
only under parametric assumptions (a linear, homogeneous effect), not
nonparametrically, so the SCM's do-contrast, which claims no such assumption,
does not report it. The latent-district fit can land near the IV value, but
its posterior then leans on the priors along the confounding ridge. The
general ID functional likewise
raises (its latent-fit do-contrast was not verified against
estimate_effect to tolerance). engine="dml" is reserved for a
cross-fit reuse path that is not available; it raises with a pointer.
BinaryGumbelMax#
class BinaryGumbelMax(
name: str,
parents: tuple[str, ...],
parent_cards: tuple[int | None, ...] = (),
n_categories: int = 2,
coef_prior_sd: float = 3.0,
latents: tuple[str, ...] = (),
gauged_latents: tuple[str, ...] = (),
loading_prior_sd: float = 2.0,
) -> None
A binary node as a two-category Gumbel-max (the logistic mechanism).
Specializes CategoricalGumbelMax to two categories; the marginal is
the Bernoulli logistic, and the Gumbel-max counterfactual is the binary
common-random-numbers flip.
| Field | Type | Default |
|---|---|---|
name |
str |
required |
parents |
tuple[str, ...] |
required |
parent_cards |
tuple[int | None, ...] |
() |
n_categories |
int |
2 |
coef_prior_sd |
float |
3.0 |
latents |
tuple[str, ...] |
() |
gauged_latents |
tuple[str, ...] |
() |
loading_prior_sd |
float |
2.0 |
BinaryGumbelMax.capability#
property BinaryGumbelMax.capability: Capability
Always INVERTIBLE: the Gumbel noise can be abducted.
BinaryGumbelMax.weight_name#
property BinaryGumbelMax.weight_name: str
The posterior parameter name of the per-class weight matrix, {name}__logit_w.
BinaryGumbelMax.bias_name#
property BinaryGumbelMax.bias_name: str
The posterior parameter name of the per-class bias, {name}__logit_b.
BinaryGumbelMax.loading_name#
def BinaryGumbelMax.loading_name(latent: str) -> str
The per-class logit loading parameter for this node on latent L.
BinaryGumbelMax.build#
def BinaryGumbelMax.build(x: Array, y_codes: IntArray, n_cat: int) -> Any
The per-node PyMC model: y ~ Categorical(logits = bias + x_encoded . weight).
| Parameter | Description |
|---|---|
x |
The raw (n, p) parent matrix (codes for discrete parents), one-hot encoded here. |
y_codes |
The observed category codes. |
n_cat |
The number of categories. |
Returns: A pymc.Model.
BinaryGumbelMax.generative_rv#
def BinaryGumbelMax.generative_rv(nodes: Mapping[str, Any], n: int) -> Any
Add this node's categorical RV to the active generative model.
logits = bias + encode(PA) . weight (same per-class linear form and same
one-hot parent encoding the fit uses), then pm.Categorical samples the
node forward (the Gumbel-max marginal). The RV names match the merged
posterior, so sample_posterior_predictive reads the fitted logits. The
node's resolved class count must be passed in (it lives on the fit, not the
spec). An incident latent L adds a per-class logit term
L * loading_L (loading_L shape (K,)), so the confounding shifts
the relative class logits, and pm.do severs the node from L.
BinaryGumbelMax.fit#
def BinaryGumbelMax.fit(
data: Dataset,
*,
draws: int,
tune: int,
chains: int,
seed: int,
) -> FitResult
Fit this node alone by NUTS and return its parameter posterior.
The class count is n_categories when set, else read from the column's
categories. A node in a confounded district is fitted jointly by
fit(), not here.
| Parameter | Description |
|---|---|
data |
The dataset holding the node and its parents. |
draws |
Posterior draws per chain. |
tune |
Tuning steps per chain. |
chains |
Number of chains. |
seed |
The sampler seed. |
Returns: A FitResult with the bias and (with parents) weight draws, their posterior means, and the resolved class count.
BinaryGumbelMax.logits#
def BinaryGumbelMax.logits(fit: FitResult, parent_values: Array) -> Array
The (n, K) class logits at the posterior mean.
parent_values is the (n, p) raw parent matrix (codes for discrete
parents); it is one-hot encoded internally so the weight rows line up.
BinaryGumbelMax.logits_per_draw#
def BinaryGumbelMax.logits_per_draw(
fit: FitResult,
parent_values: Array,
) -> Array
The (S, n, K) class logits per parameter-posterior draw.
The per-draw twin of logits(): bias is (S, K) and weight
(S, P, K), so each draw's logits read bias[d] + encode(PA)[d] . w[d].
parent_values is the (S, n, p) raw per-draw parent matrix (an
intervened/categorical parent's singleton draw axis broadcasts up to S,
and is one-hot encoded per draw so the design matches the (S, P, K) weight
draws). With no parents the result is the broadcast bias draws. Used by the
counterfactual to spread a categorical ancestor's decision across draws, so
its logit-parameter posterior enters the counterfactual interval (the
posterior-mean collapse it replaces understated the width).
BinaryGumbelMax.abduct#
def BinaryGumbelMax.abduct(
fit: FitResult,
observed_codes: IntArray,
parent_values: Array,
*,
seed: int,
) -> Array
Abduct the per-unit Gumbel noise consistent with the observed category.
BinaryGumbelMax.decide#
def BinaryGumbelMax.decide(logits: Array, gumbel: Array) -> IntArray
Return the category per row: argmax(logits + gumbel).
| Parameter | Description |
|---|---|
logits |
The (n, K) class logits. |
gumbel |
The (n, K) Gumbel noise, sampled or abducted. |
Returns: The (n,) integer category codes.
Capability#
class Capability(enum.Enum)
What a mechanism's noise lets it answer.
PROBABILISTIC covers observational and interventional queries.
INVERTIBLE adds counterfactuals, because the structural noise is
recoverable from the node and its parents.
| Member | Value |
|---|---|
PROBABILISTIC |
'probabilistic' |
INVERTIBLE |
'invertible' |
CategoricalGumbelMax#
class CategoricalGumbelMax(
name: str,
parents: tuple[str, ...],
parent_cards: tuple[int | None, ...] = (),
n_categories: int = 0,
coef_prior_sd: float = 3.0,
latents: tuple[str, ...] = (),
gauged_latents: tuple[str, ...] = (),
loading_prior_sd: float = 2.0,
) -> None
Categorical mechanism as Gumbel-max over softmax logits.
The logits are linear in the parents with a per-class slope and intercept,
fitted by the equivalent Categorical likelihood (the Gumbel-max
marginal). The forward mechanism is category = argmax(logits + g) with
g an iid Gumbel vector, and the counterfactual reuses the abducted g
under shifted logits, so it is invertible. Identified up to the per-row logit
gauge, which the argmax washes out.
| Field | Type | Default |
|---|---|---|
name |
str |
required |
parents |
tuple[str, ...] |
required |
parent_cards |
tuple[int | None, ...] |
() |
n_categories |
int |
0 |
coef_prior_sd |
float |
3.0 |
latents |
tuple[str, ...] |
() |
gauged_latents |
tuple[str, ...] |
() |
loading_prior_sd |
float |
2.0 |
CategoricalGumbelMax.capability#
property CategoricalGumbelMax.capability: Capability
Always INVERTIBLE: the Gumbel noise can be abducted.
CategoricalGumbelMax.weight_name#
property CategoricalGumbelMax.weight_name: str
The posterior parameter name of the per-class weight matrix, {name}__logit_w.
CategoricalGumbelMax.bias_name#
property CategoricalGumbelMax.bias_name: str
The posterior parameter name of the per-class bias, {name}__logit_b.
CategoricalGumbelMax.loading_name#
def CategoricalGumbelMax.loading_name(latent: str) -> str
The per-class logit loading parameter for this node on latent L.
CategoricalGumbelMax.build#
def CategoricalGumbelMax.build(
x: Array,
y_codes: IntArray,
n_cat: int,
) -> Any
The per-node PyMC model: y ~ Categorical(logits = bias + x_encoded . weight).
| Parameter | Description |
|---|---|
x |
The raw (n, p) parent matrix (codes for discrete parents), one-hot encoded here. |
y_codes |
The observed category codes. |
n_cat |
The number of categories. |
Returns: A pymc.Model.
CategoricalGumbelMax.generative_rv#
def CategoricalGumbelMax.generative_rv(
nodes: Mapping[str, Any],
n: int,
) -> Any
Add this node's categorical RV to the active generative model.
logits = bias + encode(PA) . weight (same per-class linear form and same
one-hot parent encoding the fit uses), then pm.Categorical samples the
node forward (the Gumbel-max marginal). The RV names match the merged
posterior, so sample_posterior_predictive reads the fitted logits. The
node's resolved class count must be passed in (it lives on the fit, not the
spec). An incident latent L adds a per-class logit term
L * loading_L (loading_L shape (K,)), so the confounding shifts
the relative class logits, and pm.do severs the node from L.
CategoricalGumbelMax.fit#
def CategoricalGumbelMax.fit(
data: Dataset,
*,
draws: int,
tune: int,
chains: int,
seed: int,
) -> FitResult
Fit this node alone by NUTS and return its parameter posterior.
The class count is n_categories when set, else read from the column's
categories. A node in a confounded district is fitted jointly by
fit(), not here.
| Parameter | Description |
|---|---|
data |
The dataset holding the node and its parents. |
draws |
Posterior draws per chain. |
tune |
Tuning steps per chain. |
chains |
Number of chains. |
seed |
The sampler seed. |
Returns: A FitResult with the bias and (with parents) weight draws, their posterior means, and the resolved class count.
CategoricalGumbelMax.logits#
def CategoricalGumbelMax.logits(
fit: FitResult,
parent_values: Array,
) -> Array
The (n, K) class logits at the posterior mean.
parent_values is the (n, p) raw parent matrix (codes for discrete
parents); it is one-hot encoded internally so the weight rows line up.
CategoricalGumbelMax.logits_per_draw#
def CategoricalGumbelMax.logits_per_draw(
fit: FitResult,
parent_values: Array,
) -> Array
The (S, n, K) class logits per parameter-posterior draw.
The per-draw twin of logits(): bias is (S, K) and weight
(S, P, K), so each draw's logits read bias[d] + encode(PA)[d] . w[d].
parent_values is the (S, n, p) raw per-draw parent matrix (an
intervened/categorical parent's singleton draw axis broadcasts up to S,
and is one-hot encoded per draw so the design matches the (S, P, K) weight
draws). With no parents the result is the broadcast bias draws. Used by the
counterfactual to spread a categorical ancestor's decision across draws, so
its logit-parameter posterior enters the counterfactual interval (the
posterior-mean collapse it replaces understated the width).
CategoricalGumbelMax.abduct#
def CategoricalGumbelMax.abduct(
fit: FitResult,
observed_codes: IntArray,
parent_values: Array,
*,
seed: int,
) -> Array
Abduct the per-unit Gumbel noise consistent with the observed category.
CategoricalGumbelMax.decide#
def CategoricalGumbelMax.decide(logits: Array, gumbel: Array) -> IntArray
Return the category per row: argmax(logits + gumbel).
| Parameter | Description |
|---|---|
logits |
The (n, K) class logits. |
gumbel |
The (n, K) Gumbel noise, sampled or abducted. |
Returns: The (n,) integer category codes.
CategoricalResult#
class CategoricalResult(
query: str,
target: str,
categories: tuple[object, ...],
probabilities: npt.NDArray[np.float64],
identified: bool,
diagnostics: dict[str, float] = <factory>,
_per_unit: npt.NDArray[np.int64] | None = None,
) -> None
A posterior over a categorical query's answer.
A categorical node's interventional or counterfactual answer is a
distribution over its categories, not a single number, so this carries the
per-category probabilities (probabilities, summing to one over
categories) and the per-unit draws when a counterfactual was asked of
specific units. identified carries the same honesty flag as
SCMResult.
| Field | Type | Default |
|---|---|---|
query |
str |
required |
target |
str |
required |
categories |
tuple[object, ...] |
required |
probabilities |
npt.NDArray[np.float64] |
required |
identified |
bool |
required |
diagnostics |
dict[str, float] |
factory |
CategoricalResult.mode#
def CategoricalResult.mode() -> object
The most probable category.
CategoricalResult.per_unit#
def CategoricalResult.per_unit() -> npt.NDArray[np.int64] | None
Per-unit counterfactual category codes, when units were queried.
GaussianAdditive#
class GaussianAdditive(
name: str,
parents: tuple[str, ...],
parent_cards: tuple[int | None, ...] = (),
coef_prior_sd: float = 5.0,
sigma_prior_sd: float = 2.0,
latents: tuple[str, ...] = (),
gauged_latents: tuple[str, ...] = (),
loading_prior_sd: float = 2.0,
) -> None
Additive-noise Gaussian mechanism, X = b0 + w . PA + U, U ~ N(0, sigma).
The default continuous mechanism: a Bayesian-linear mean over the parents with
a half-normal prior on the noise scale. Invertible, so its counterfactual is
the exact residual U = X - (b0 + w . PA) reused under the action. A
Learner-as-mean override (carrying no parameter
posterior, abducting via the residual against the fitted mean) is not
available; the mean is always the Bayesian-linear one.
In a confounded district the node may also carry incident latents: one
per-unit L ~ Normal(0, 1) per bidirected edge the node touches, entering
the mean as + loading_L * L. latents lists those latent RV names (in a
deterministic order); gauged_latents is the subset whose loading is pinned
positive (a HalfNormal) to kill the L -> -L reflection. A Markovian node
leaves both empty and reduces to the plain additive mechanism exactly.
| Field | Type | Default |
|---|---|---|
name |
str |
required |
parents |
tuple[str, ...] |
required |
parent_cards |
tuple[int | None, ...] |
() |
coef_prior_sd |
float |
5.0 |
sigma_prior_sd |
float |
2.0 |
latents |
tuple[str, ...] |
() |
gauged_latents |
tuple[str, ...] |
() |
loading_prior_sd |
float |
2.0 |
GaussianAdditive.capability#
property GaussianAdditive.capability: Capability
Always INVERTIBLE: the residual is recoverable.
GaussianAdditive.coef_name#
property GaussianAdditive.coef_name: str
The posterior parameter name of the slope vector, {name}__coef.
GaussianAdditive.intercept_name#
property GaussianAdditive.intercept_name: str
The posterior parameter name of the intercept, {name}__intercept.
GaussianAdditive.sigma_name#
property GaussianAdditive.sigma_name: str
The posterior parameter name of the noise scale, {name}__sigma.
GaussianAdditive.loading_name#
def GaussianAdditive.loading_name(latent: str) -> str
The parameter name for this node's loading on latent L (district fit).
GaussianAdditive.build#
def GaussianAdditive.build(x: Array, y: Array) -> Any
The per-node PyMC model: y ~ Normal(b0 + x_encoded . w, sigma).
x is the raw parent matrix (codes for discrete parents); it is encoded
to the one-hot design here, the same encoding the generative model uses.
GaussianAdditive.generative_rv#
def GaussianAdditive.generative_rv(nodes: Mapping[str, Any], n: int) -> Any
Add this node's RV to the active generative model: Normal(b0 + PA . w, sigma).
nodes maps each (already-built) parent name to its RV. Parents are
encoded by the shared pytensor encoder, so the design matches the fit view
column for column; the RV names match the merged posterior exactly, so
sample_posterior_predictive reads the fitted draws. An incident latent
L (a per-unit Normal(0, 1) the compose helper builds first, found in
nodes) adds loading_L * L to the mean, so pm.do on this node
severs it from L while a district sibling still reads L -- the
confounding cancels in an interventional contrast.
GaussianAdditive.fit#
def GaussianAdditive.fit(
data: Dataset,
*,
draws: int,
tune: int,
chains: int,
seed: int,
) -> FitResult
Fit this node alone by NUTS and return its parameter posterior.
Reads the node's column and its parents' columns from data. A node in a
confounded district is fitted jointly by fit(), not here.
| Parameter | Description |
|---|---|
data |
The dataset holding the node and its parents. |
draws |
Posterior draws per chain. |
tune |
Tuning steps per chain. |
chains |
Number of chains. |
seed |
The sampler seed. |
Returns: A FitResult with the intercept, noise scale and (with parents) slope draws, plus their posterior means.
GaussianAdditive.structural_mean#
def GaussianAdditive.structural_mean(
fit: FitResult,
parent_values: Array,
) -> Array
The mean b0 + w . encode(PA) at the posterior mean.
parent_values is the (n, p) raw parent matrix (codes for discrete
parents); it is one-hot encoded internally, so the coefficients line up with
the fit. (n, p) in, (n,) out.
GaussianAdditive.mean_per_draw#
def GaussianAdditive.mean_per_draw(
fit: FitResult,
parent_values: Array,
) -> Array
The mean per parameter-posterior draw.
parent_values is the (S, n, p) raw per-draw parent matrix (a
non-intervened parent carries its own counterfactual posterior; codes for a
discrete parent). It is one-hot encoded per draw, so the encoded design
matches the (S, P) coefficient draws. Returns (S, n). With no parents
the result is the broadcast intercept draws.
GaussianAdditive.sigma#
def GaussianAdditive.sigma(fit: FitResult, *, use_draws: bool) -> Array
Return the noise scale: its (S,) draws, or the posterior mean.
| Parameter | Description |
|---|---|
fit |
The node's fitted posterior. |
use_draws |
Return the draws when True, the posterior mean when False. |
GaussianAdditive.abduct#
def GaussianAdditive.abduct(
fit: FitResult,
value: Array,
parent_values: Array,
) -> Array
The exact residual U = X - (b0 + w . PA) at the posterior mean.
InterventionView#
class InterventionView(scm: SCM, intervention: Mapping[str, DoValue]) -> None
A lightweight intervened view of a fitted SCM, for chaining.
Returned by do(). It carries the intervention and forwards
interventional()/sample() with the do-map applied, sharing the
parent's fit. Immutable; do returns a new view.
InterventionView.do#
def InterventionView.do(more: Mapping[str, DoValue]) -> InterventionView
Return a new view with more added to the intervention; later values win per node.
InterventionView.interventional#
def InterventionView.interventional(
target: str,
*,
n: int = 2000,
) -> SCMResult | CategoricalResult
Return the distribution of target under the view's intervention.
The same as interventional() with the view's do-map.
| Parameter | Description |
|---|---|
target |
The node to read. |
n |
Number of simulated units. |
InterventionView.sample#
def InterventionView.sample(
target: str,
*,
n: int = 2000,
) -> SCMResult | CategoricalResult
Alias for the interventional distribution of target under the view.
Mechanism#
class Mechanism(*args, **kwargs)
The per-node structural equation.
Implementations carry the node name and its parent names and expose the four
jobs below. The SCM holds one mechanism per node and a FitResult per
mechanism after fitting. name and parents are read-only: mechanisms are
frozen value objects, so filling a blank node name returns a new instance
(resolve_mechanism()) rather than mutating in place.
Mechanism.name#
property Mechanism.name: str
The node this mechanism is the structural equation for.
Mechanism.parents#
property Mechanism.parents: tuple[str, ...]
The node's observed parents, in the mechanism's design order.
Mechanism.latents#
property Mechanism.latents: tuple[str, ...]
Incident latent RV names from confounded-district loadings; () for a Markovian node.
Mechanism.gauged_latents#
property Mechanism.gauged_latents: tuple[str, ...]
The subset of latents whose loading is pinned positive for the sign gauge.
Mechanism.capability#
property Mechanism.capability: Capability
INVERTIBLE if counterfactuals are exact, else PROBABILISTIC.
Mechanism.fit#
def Mechanism.fit(
data: Dataset,
*,
draws: int,
tune: int,
chains: int,
seed: int,
) -> FitResult
Fit the node on its observed parents; return the posterior.
Mechanism.generative_rv#
def Mechanism.generative_rv(nodes: Mapping[str, Any], n: int) -> Any
Add this node's RV to the active generative PyMC model, return the RV.
nodes maps each already-built parent name to its RV; the structural
equation reads the parents from it. Names every parameter RV with the same
scheme fit() used, so the merged posterior matches by name. Called
inside a pm.Model() context by the compose helper.
SCMResult#
class SCMResult(
query: str,
target: str,
kind: str,
identified: bool,
diagnostics: dict[str, float] = <factory>,
_draws: npt.NDArray[np.float64] = <factory>,
) -> None
Draws of a query's answer.
query names what was asked ("sample", "interventional",
"counterfactual", "ate"). target names the node read.
_draws holds the draws (flattened); the accessors read them.
identified records whether the answer is a function of the data alone.
kind says what the draws are. "posterior" (ate,
counterfactual): draws of the answer itself, so interval is a
credible interval. "predictive" (sample, marginal,
interventional): posterior-predictive draws of the outcome, one unit
each, so value estimates the mean outcome while std_error and
interval give the spread of the outcome, not the uncertainty in its mean.
| Field | Type | Default |
|---|---|---|
query |
str |
required |
target |
str |
required |
kind |
str |
required |
identified |
bool |
required |
diagnostics |
dict[str, float] |
factory |
SCMResult.value#
property SCMResult.value: float
The mean of the draws (the point summary).
SCMResult.mean#
def SCMResult.mean() -> float
The posterior mean of the answer.
SCMResult.std_error#
property SCMResult.std_error: float | None
The standard deviation of the draws (the outcome's spread when predictive).
SCMResult.draws#
def SCMResult.draws() -> npt.NDArray[np.float64]
The posterior draws of the answer.
SCMResult.interval#
def SCMResult.interval(level: float = 0.95) -> tuple[float, float]
An interval at level from the draw quantiles.
A credible interval for a "posterior" result; for a "predictive"
result, the range a single unit's outcome falls in.
Sensitivity#
class Sensitivity(
treatment: str,
outcome: str,
confounding: npt.NDArray[np.float64],
effect: npt.NDArray[np.float64],
identified_at_zero: float,
obstruction: str,
diagnostics: dict[str, float] = <factory>,
) -> None
The effect as a function of the assumed confounding strength.
Returned in place of a point when an effect is not identified. confounding
is the grid of assumed confounding-channel values c (a covariance on the
treatment-outcome pair), effect the implied causal effect b(c) at each.
identified_at_zero is b(0), the answer under the no-confounding
assumption (the observational slope), which is where the curve meets the
identified region. obstruction is the human-readable reason (the hedge).
There is no scalar accessor on purpose: a non-identified effect is a range, not
a number. Read range(), at(), or the arrays.
| Field | Type | Default |
|---|---|---|
treatment |
str |
required |
outcome |
str |
required |
confounding |
npt.NDArray[np.float64] |
required |
effect |
npt.NDArray[np.float64] |
required |
identified_at_zero |
float |
required |
obstruction |
str |
required |
diagnostics |
dict[str, float] |
factory |
Sensitivity.identified#
property Sensitivity.identified: bool
Always False: a Sensitivity exists because the effect is not.
Sensitivity.range#
def Sensitivity.range() -> tuple[float, float]
The (min, max) implied effect over the swept confounding grid.
Sensitivity.at#
def Sensitivity.at(c: float) -> float
The implied causal effect if the confounding channel strength is c.
Linear interpolation on the swept grid; c outside the grid extrapolates
along the line, which is exact for the linear-Gaussian case.