tramdag.simulations

Synthetic-cohort generators for tramdag.

Each scenario is one module exposing a numpy-only SCM generator class with known causal ground truth. New scenarios register here so experiments/tests can look them up by name. Frozen CSVs live under data/<name>/ and are a contract — regenerate only deliberately via each module's CLI.

 1"""Synthetic-cohort generators for tramdag.
 2
 3Each scenario is one module exposing a numpy-only SCM generator class with known
 4causal ground truth. New scenarios register here so experiments/tests can look
 5them up by name. Frozen CSVs live under ``data/<name>/`` and are a contract —
 6regenerate only deliberately via each module's CLI.
 7"""
 8
 9from .carefl import Carefl4
10from .magic_mrclean import MagicMrClean
11from .triangle import TriangleContinuous, TriangleMixed
12from .vaca import VacaTriangle
13from .vc_shift import VCLogisticShift
14
15REGISTRY = {
16    "magic-mrclean": MagicMrClean,
17    "triangle": TriangleContinuous,
18    "triangle-mixed": TriangleMixed,
19    "vaca": VacaTriangle,
20    "carefl": Carefl4,
21    "vc-shift": VCLogisticShift,
22}
23
24__all__ = [
25    "MagicMrClean",
26    "TriangleContinuous",
27    "TriangleMixed",
28    "VacaTriangle",
29    "Carefl4",
30    "VCLogisticShift",
31    "REGISTRY",
32]
@dataclass
class MagicMrClean:
 71@dataclass
 72class MagicMrClean:
 73    """SCM generator for the synthetic stroke cohort.
 74
 75    Args:
 76        variant: ``"ls"`` (all linear shifts) or ``"nl"`` (mild non-linearities).
 77        seed: master seed; each draw uses an independent child stream.
 78    """
 79
 80    variant: str = "nl"
 81    seed: int = 7
 82
 83    def __post_init__(self):
 84        """Validate the variant and set the switch for the non-linear terms.
 85
 86        Returns
 87        -------
 88        None
 89
 90        Raises
 91        ------
 92        ValueError
 93            If ``variant`` is neither ``"ls"`` nor ``"nl"``.
 94        """
 95        if self.variant not in ("ls", "nl"):
 96            raise ValueError(f"variant must be 'ls' or 'nl', got {self.variant!r}")
 97        self.nl = float(self.variant == "nl")  # 0.0 disables the ★ terms
 98
 99    # ------------------------------------------------------------------ latents
100    def draw_latents(self, n: int, rng: np.random.Generator) -> dict[str, np.ndarray]:
101        """Draw the latent noise of every variable.
102
103        Parameters
104        ----------
105        n : int
106            Number of rows to draw.
107        rng : np.random.Generator
108            Random source.
109
110        Returns
111        -------
112        dict[str, np.ndarray]
113            One array of length ``n`` per variable.
114        """
115        return {k: _logistic(rng, n) for k in COLUMNS}
116
117    # --------------------------------------------------------------------- SCM
118    def simulate(
119        self,
120        n: int | None = None,
121        *,
122        rng: np.random.Generator | None = None,
123        randomize_T: bool = False,
124        population: str = "obs",
125        do: dict[str, float] | None = None,
126        latents: dict[str, np.ndarray] | None = None,
127    ) -> pd.DataFrame:
128        """Forward-sample the SCM.
129
130        Args:
131            randomize_T: if True, assign T ~ Bernoulli(0.5) independently of its
132                parents (the RCT design); otherwise T follows the confounded
133                observational mechanism.
134            population: covariate population. ``"obs"`` is the full observational
135                cohort; ``"rct"`` mimics trial inclusion — a **younger** enrolled
136                population (age location shifted down). Only the ``Age`` source
137                marginal differs; all structural equations are unchanged. With the
138                heterogeneous ``nl`` treatment effect this fit-vs-eval shift is
139                what biases an all-``ls`` model (it cannot extrapolate ``tau(Age)``
140                from the older obs cohort to the younger trial). The ``ls`` DGP has
141                constant ``tau`` and is therefore unaffected.
142            do: hard interventions {node: value} (graph mutilation); the node is
143                clamped and its structural equation skipped.
144            latents: reuse a fixed latent draw (for counterfactuals / paired
145                interventions). If given, ``n`` and ``rng`` are ignored.
146        """
147        do = do or {}
148        if latents is None:
149            if n is None:
150                raise ValueError("provide either n or latents")
151            rng = rng or np.random.default_rng(self.seed)
152            latents = self.draw_latents(n, rng)
153        else:
154            n = len(next(iter(latents.values())))
155        nl = self.nl
156        age_loc = 73.0 - (9.0 if population == "rct" else 0.0)  # trial enrolls younger
157
158        # --- Age: location-scale logistic (sd ~ 13.8), clipped to a plausible range
159        if "Age" in do:
160            Age = np.full(n, float(do["Age"]))
161        else:
162            Age = np.clip(age_loc + 7.6 * latents["Age"], 20.0, 103.0)
163        a = (Age - 73.0) / 10.0  # standardized age
164        relu_a = np.maximum(a, 0.0)
165
166        # --- mRS_pre: pre-stroke disability, worse (and ★ accelerating) with age
167        if "mRS_pre" in do:
168            mRS_pre = np.full(n, float(do["mRS_pre"]))
169        else:
170            eta_pre = 0.55 * a + nl * (0.18 * relu_a**2)
171            mRS_pre = _ordinal(eta_pre, CUTS_PRE, latents["mRS_pre"]).astype(float)
172
173        # --- NIHSSa: stroke severity; linear shift on parents, ★ age^2 in nl.
174        #     Free monotone marginal map keeps it in the realistic [6, 42] range.
175        if "NIHSSa" in do:
176            NIHSSa = np.full(n, float(do["NIHSSa"]))
177        else:
178            shift_nih = 0.45 * a + 0.25 * mRS_pre + nl * (0.20 * relu_a**2)
179            lat_nih = shift_nih + 0.85 * latents["NIHSSa"]
180            NIHSSa = np.clip(6.0 + 36.0 * _sigmoid((lat_nih - 1.75) / 1.5), 6.0, 42.0)
181        s = (NIHSSa - 15.0) / 6.0  # standardized severity
182
183        # --- T: thrombectomy assignment. Observational mechanism is confounded by
184        #     age and severity; ★ in nl it is smoothly withheld from the very old.
185        if "T" in do:
186            T = np.full(n, float(do["T"]))
187        elif randomize_T:
188            T = (latents["T"] > 0.0).astype(float)  # Bernoulli(0.5), latent-driven
189        else:
190            logit_T = (
191                1.9
192                - 0.45 * np.maximum(a + 0.5, 0.0)
193                - 0.30 * np.maximum(s - 1.0, 0.0)
194                + nl * (-1.3 * _sigmoid((Age - 82.0) / 4.0))
195            )
196            T = (latents["T"] > -logit_T).astype(float)  # P(T=1) = sigmoid(logit_T)
197
198        # --- mRS_3m: 3-month outcome. Treatment lowers the latent (better outcome);
199        #     ★ in nl the benefit tau(Age) fades to ~0 in the elderly.
200        if "mRS_3m" in do:
201            mRS_3m = np.full(n, float(do["mRS_3m"]))
202        else:
203            tau = -0.85 + nl * (0.85 - 0.85 * _sigmoid((78.0 - Age) / 6.0))
204            zeta = 0.85 * s + 0.55 * a + 0.45 * mRS_pre + tau * T
205            mRS_3m = _ordinal(zeta, CUTS_Y, latents["mRS_3m"]).astype(float)
206
207        return pd.DataFrame(
208            {"Age": Age, "mRS_pre": mRS_pre, "NIHSSa": NIHSSa, "T": T, "mRS_3m": mRS_3m}
209        )
210
211    # ----------------------------------------------------------------- datasets
212    def observational(self, n: int, seed_offset: int = 0) -> pd.DataFrame:
213        """Draw an observational sample.
214
215        Parameters
216        ----------
217        n : int
218            Number of rows.
219        seed_offset : int, optional
220            Added to the generator seed, by default ``0``.
221
222        Returns
223        -------
224        pd.DataFrame
225            The sample.
226        """
227        rng = np.random.default_rng(self.seed + 1 + seed_offset)
228        return self.simulate(n, rng=rng)
229
230    def rct(self, n: int, seed_offset: int = 0) -> pd.DataFrame:
231        """Draw a randomized-trial sample.
232
233        Treatment is randomized and the covariates follow the trial population, so
234        the contrast this yields is unconfounded.
235
236        Parameters
237        ----------
238        n : int
239            Number of rows.
240        seed_offset : int, optional
241            Added to the generator seed, by default ``0``.
242
243        Returns
244        -------
245        pd.DataFrame
246            The sample.
247        """
248        rng = np.random.default_rng(self.seed + 1001 + seed_offset)
249        return self.simulate(n, rng=rng, randomize_T=True, population="rct")
250
251    # -------------------------------------------------------------- ground truth
252    def true_ate(self, n: int = 500_000, on: str = "rct") -> dict:
253        """Estimate the true ATE of T on ``P(mRS_3m <= 2)`` by Monte Carlo.
254
255        Both arms use the same latent draw, so the result is the do-effect and
256        carries none of the confounding in T.
257
258        ``on`` selects the covariate population the ATE is averaged over:
259        ``"rct"`` (default) mirrors the **younger trial** population that
260        :meth:`rct` enrols and that ``evaluate_rct`` scores on; ``"obs"`` the
261        observational cohort.
262        """
263        rng = np.random.default_rng(self.seed + 9001)
264        latents = self.draw_latents(n, rng)
265        d0 = self.simulate(latents=latents, population=on, do={"T": 0})
266        d1 = self.simulate(latents=latents, population=on, do={"T": 1})
267        good0 = float((d0["mRS_3m"] <= 2).mean())
268        good1 = float((d1["mRS_3m"] <= 2).mean())
269        # naive (observational, confounded) contrast for contrast with the truth
270        obs = self.observational(n, seed_offset=777)
271        naive = float((obs.loc[obs["T"] == 1, "mRS_3m"] <= 2).mean()) - float(
272            (obs.loc[obs["T"] == 0, "mRS_3m"] <= 2).mean()
273        )
274        return {
275            "p_good_do_T0": good0,
276            "p_good_do_T1": good1,
277            "ate_population": on,
278            "true_ate": good1 - good0,
279            "naive_obs_diff": naive,
280            "mc_n": n,
281        }
282
283    def counterfactual_pair(
284        self, n: int, do: dict[str, float], seed_offset: int = 0
285    ) -> tuple[pd.DataFrame, pd.DataFrame]:
286        """Draw a factual sample and its counterfactual under ``do``.
287
288        Both share the same latents, so the pair gives true individual
289        counterfactuals. Real data cannot supply these. Use them to score the
290        abduction of the flow.
291        """
292        rng = np.random.default_rng(self.seed + 2 + seed_offset)
293        latents = self.draw_latents(n, rng)
294        factual = self.simulate(latents=latents)
295        cf = self.simulate(latents=latents, do=do)
296        return factual, cf

SCM generator for the synthetic stroke cohort.

Args: variant: "ls" (all linear shifts) or "nl" (mild non-linearities). seed: master seed; each draw uses an independent child stream.

MagicMrClean(variant: str = 'nl', seed: int = 7)
variant: str = 'nl'
seed: int = 7
def draw_latents( self, n: int, rng: numpy.random._generator.Generator) -> dict[str, numpy.ndarray]:
100    def draw_latents(self, n: int, rng: np.random.Generator) -> dict[str, np.ndarray]:
101        """Draw the latent noise of every variable.
102
103        Parameters
104        ----------
105        n : int
106            Number of rows to draw.
107        rng : np.random.Generator
108            Random source.
109
110        Returns
111        -------
112        dict[str, np.ndarray]
113            One array of length ``n`` per variable.
114        """
115        return {k: _logistic(rng, n) for k in COLUMNS}

Draw the latent noise of every variable.

Parameters
  • n (int): Number of rows to draw.
  • rng (np.random.Generator): Random source.
Returns
  • dict[str, np.ndarray]: One array of length n per variable.
def simulate( self, n: int | None = None, *, rng: numpy.random._generator.Generator | None = None, randomize_T: bool = False, population: str = 'obs', do: dict[str, float] | None = None, latents: dict[str, numpy.ndarray] | None = None) -> pandas.DataFrame:
118    def simulate(
119        self,
120        n: int | None = None,
121        *,
122        rng: np.random.Generator | None = None,
123        randomize_T: bool = False,
124        population: str = "obs",
125        do: dict[str, float] | None = None,
126        latents: dict[str, np.ndarray] | None = None,
127    ) -> pd.DataFrame:
128        """Forward-sample the SCM.
129
130        Args:
131            randomize_T: if True, assign T ~ Bernoulli(0.5) independently of its
132                parents (the RCT design); otherwise T follows the confounded
133                observational mechanism.
134            population: covariate population. ``"obs"`` is the full observational
135                cohort; ``"rct"`` mimics trial inclusion — a **younger** enrolled
136                population (age location shifted down). Only the ``Age`` source
137                marginal differs; all structural equations are unchanged. With the
138                heterogeneous ``nl`` treatment effect this fit-vs-eval shift is
139                what biases an all-``ls`` model (it cannot extrapolate ``tau(Age)``
140                from the older obs cohort to the younger trial). The ``ls`` DGP has
141                constant ``tau`` and is therefore unaffected.
142            do: hard interventions {node: value} (graph mutilation); the node is
143                clamped and its structural equation skipped.
144            latents: reuse a fixed latent draw (for counterfactuals / paired
145                interventions). If given, ``n`` and ``rng`` are ignored.
146        """
147        do = do or {}
148        if latents is None:
149            if n is None:
150                raise ValueError("provide either n or latents")
151            rng = rng or np.random.default_rng(self.seed)
152            latents = self.draw_latents(n, rng)
153        else:
154            n = len(next(iter(latents.values())))
155        nl = self.nl
156        age_loc = 73.0 - (9.0 if population == "rct" else 0.0)  # trial enrolls younger
157
158        # --- Age: location-scale logistic (sd ~ 13.8), clipped to a plausible range
159        if "Age" in do:
160            Age = np.full(n, float(do["Age"]))
161        else:
162            Age = np.clip(age_loc + 7.6 * latents["Age"], 20.0, 103.0)
163        a = (Age - 73.0) / 10.0  # standardized age
164        relu_a = np.maximum(a, 0.0)
165
166        # --- mRS_pre: pre-stroke disability, worse (and ★ accelerating) with age
167        if "mRS_pre" in do:
168            mRS_pre = np.full(n, float(do["mRS_pre"]))
169        else:
170            eta_pre = 0.55 * a + nl * (0.18 * relu_a**2)
171            mRS_pre = _ordinal(eta_pre, CUTS_PRE, latents["mRS_pre"]).astype(float)
172
173        # --- NIHSSa: stroke severity; linear shift on parents, ★ age^2 in nl.
174        #     Free monotone marginal map keeps it in the realistic [6, 42] range.
175        if "NIHSSa" in do:
176            NIHSSa = np.full(n, float(do["NIHSSa"]))
177        else:
178            shift_nih = 0.45 * a + 0.25 * mRS_pre + nl * (0.20 * relu_a**2)
179            lat_nih = shift_nih + 0.85 * latents["NIHSSa"]
180            NIHSSa = np.clip(6.0 + 36.0 * _sigmoid((lat_nih - 1.75) / 1.5), 6.0, 42.0)
181        s = (NIHSSa - 15.0) / 6.0  # standardized severity
182
183        # --- T: thrombectomy assignment. Observational mechanism is confounded by
184        #     age and severity; ★ in nl it is smoothly withheld from the very old.
185        if "T" in do:
186            T = np.full(n, float(do["T"]))
187        elif randomize_T:
188            T = (latents["T"] > 0.0).astype(float)  # Bernoulli(0.5), latent-driven
189        else:
190            logit_T = (
191                1.9
192                - 0.45 * np.maximum(a + 0.5, 0.0)
193                - 0.30 * np.maximum(s - 1.0, 0.0)
194                + nl * (-1.3 * _sigmoid((Age - 82.0) / 4.0))
195            )
196            T = (latents["T"] > -logit_T).astype(float)  # P(T=1) = sigmoid(logit_T)
197
198        # --- mRS_3m: 3-month outcome. Treatment lowers the latent (better outcome);
199        #     ★ in nl the benefit tau(Age) fades to ~0 in the elderly.
200        if "mRS_3m" in do:
201            mRS_3m = np.full(n, float(do["mRS_3m"]))
202        else:
203            tau = -0.85 + nl * (0.85 - 0.85 * _sigmoid((78.0 - Age) / 6.0))
204            zeta = 0.85 * s + 0.55 * a + 0.45 * mRS_pre + tau * T
205            mRS_3m = _ordinal(zeta, CUTS_Y, latents["mRS_3m"]).astype(float)
206
207        return pd.DataFrame(
208            {"Age": Age, "mRS_pre": mRS_pre, "NIHSSa": NIHSSa, "T": T, "mRS_3m": mRS_3m}
209        )

Forward-sample the SCM.

Args: randomize_T: if True, assign T ~ Bernoulli(0.5) independently of its parents (the RCT design); otherwise T follows the confounded observational mechanism. population: covariate population. "obs" is the full observational cohort; "rct" mimics trial inclusion — a younger enrolled population (age location shifted down). Only the Age source marginal differs; all structural equations are unchanged. With the heterogeneous nl treatment effect this fit-vs-eval shift is what biases an all-ls model (it cannot extrapolate tau(Age) from the older obs cohort to the younger trial). The ls DGP has constant tau and is therefore unaffected. do: hard interventions {node: value} (graph mutilation); the node is clamped and its structural equation skipped. latents: reuse a fixed latent draw (for counterfactuals / paired interventions). If given, n and rng are ignored.

def observational(self, n: int, seed_offset: int = 0) -> pandas.DataFrame:
212    def observational(self, n: int, seed_offset: int = 0) -> pd.DataFrame:
213        """Draw an observational sample.
214
215        Parameters
216        ----------
217        n : int
218            Number of rows.
219        seed_offset : int, optional
220            Added to the generator seed, by default ``0``.
221
222        Returns
223        -------
224        pd.DataFrame
225            The sample.
226        """
227        rng = np.random.default_rng(self.seed + 1 + seed_offset)
228        return self.simulate(n, rng=rng)

Draw an observational sample.

Parameters
  • n (int): Number of rows.
  • seed_offset (int, optional): Added to the generator seed, by default 0.
Returns
  • pd.DataFrame: The sample.
def rct(self, n: int, seed_offset: int = 0) -> pandas.DataFrame:
230    def rct(self, n: int, seed_offset: int = 0) -> pd.DataFrame:
231        """Draw a randomized-trial sample.
232
233        Treatment is randomized and the covariates follow the trial population, so
234        the contrast this yields is unconfounded.
235
236        Parameters
237        ----------
238        n : int
239            Number of rows.
240        seed_offset : int, optional
241            Added to the generator seed, by default ``0``.
242
243        Returns
244        -------
245        pd.DataFrame
246            The sample.
247        """
248        rng = np.random.default_rng(self.seed + 1001 + seed_offset)
249        return self.simulate(n, rng=rng, randomize_T=True, population="rct")

Draw a randomized-trial sample.

Treatment is randomized and the covariates follow the trial population, so the contrast this yields is unconfounded.

Parameters
  • n (int): Number of rows.
  • seed_offset (int, optional): Added to the generator seed, by default 0.
Returns
  • pd.DataFrame: The sample.
def true_ate(self, n: int = 500000, on: str = 'rct') -> dict:
252    def true_ate(self, n: int = 500_000, on: str = "rct") -> dict:
253        """Estimate the true ATE of T on ``P(mRS_3m <= 2)`` by Monte Carlo.
254
255        Both arms use the same latent draw, so the result is the do-effect and
256        carries none of the confounding in T.
257
258        ``on`` selects the covariate population the ATE is averaged over:
259        ``"rct"`` (default) mirrors the **younger trial** population that
260        :meth:`rct` enrols and that ``evaluate_rct`` scores on; ``"obs"`` the
261        observational cohort.
262        """
263        rng = np.random.default_rng(self.seed + 9001)
264        latents = self.draw_latents(n, rng)
265        d0 = self.simulate(latents=latents, population=on, do={"T": 0})
266        d1 = self.simulate(latents=latents, population=on, do={"T": 1})
267        good0 = float((d0["mRS_3m"] <= 2).mean())
268        good1 = float((d1["mRS_3m"] <= 2).mean())
269        # naive (observational, confounded) contrast for contrast with the truth
270        obs = self.observational(n, seed_offset=777)
271        naive = float((obs.loc[obs["T"] == 1, "mRS_3m"] <= 2).mean()) - float(
272            (obs.loc[obs["T"] == 0, "mRS_3m"] <= 2).mean()
273        )
274        return {
275            "p_good_do_T0": good0,
276            "p_good_do_T1": good1,
277            "ate_population": on,
278            "true_ate": good1 - good0,
279            "naive_obs_diff": naive,
280            "mc_n": n,
281        }

Estimate the true ATE of T on P(mRS_3m <= 2) by Monte Carlo.

Both arms use the same latent draw, so the result is the do-effect and carries none of the confounding in T.

on selects the covariate population the ATE is averaged over: "rct" (default) mirrors the younger trial population that rct() enrols and that evaluate_rct scores on; "obs" the observational cohort.

def counterfactual_pair( self, n: int, do: dict[str, float], seed_offset: int = 0) -> tuple[pandas.DataFrame, pandas.DataFrame]:
283    def counterfactual_pair(
284        self, n: int, do: dict[str, float], seed_offset: int = 0
285    ) -> tuple[pd.DataFrame, pd.DataFrame]:
286        """Draw a factual sample and its counterfactual under ``do``.
287
288        Both share the same latents, so the pair gives true individual
289        counterfactuals. Real data cannot supply these. Use them to score the
290        abduction of the flow.
291        """
292        rng = np.random.default_rng(self.seed + 2 + seed_offset)
293        latents = self.draw_latents(n, rng)
294        factual = self.simulate(latents=latents)
295        cf = self.simulate(latents=latents, do=do)
296        return factual, cf

Draw a factual sample and its counterfactual under do.

Both share the same latents, so the pair gives true individual counterfactuals. Real data cannot supply these. Use them to score the abduction of the flow.

class TriangleContinuous(tramdag.simulations.triangle._TriangleBase):
178class TriangleContinuous(_TriangleBase):
179    """Paper Sec. 6.1: the all-continuous triangle.
180
181    ``h(x3|x1,x2) = 0.63 x3 - 0.2 x1 - f(x2)``.
182    """
183
184    family = "continuous"
185
186    def _x3(self, x1, x2, do, latents):
187        if "x3" in do:
188            return _clamp(do["x3"], len(x1))
189        return (latents["x3"] + 0.2 * x1 + self.f_callable(x2)) / 0.63
190
191    def paper_truth(self) -> dict:
192        """State the true parameters of this data-generating process.
193
194        Returns
195        -------
196        dict
197            The coefficients and transformation functions used to generate the
198            data, in the notation of the paper.
199        """
200        t = {
201            "beta12": 2.0,
202            "beta13": -0.2,
203            "h2": "5*x2 + 2*x1",
204            "h3": f"0.63*x3 - 0.2*x1 - ({F_VARIANTS[self.f][1]})",
205        }
206        if self.f == "linear":
207            t["beta23"] = 0.3
208        return t

Paper Sec. 6.1: the all-continuous triangle.

h(x3|x1,x2) = 0.63 x3 - 0.2 x1 - f(x2).

family = 'continuous'
def paper_truth(self) -> dict:
191    def paper_truth(self) -> dict:
192        """State the true parameters of this data-generating process.
193
194        Returns
195        -------
196        dict
197            The coefficients and transformation functions used to generate the
198            data, in the notation of the paper.
199        """
200        t = {
201            "beta12": 2.0,
202            "beta13": -0.2,
203            "h2": "5*x2 + 2*x1",
204            "h3": f"0.63*x3 - 0.2*x1 - ({F_VARIANTS[self.f][1]})",
205        }
206        if self.f == "linear":
207            t["beta23"] = 0.3
208        return t

State the true parameters of this data-generating process.

Returns
  • dict: The coefficients and transformation functions used to generate the data, in the notation of the paper.
class TriangleMixed(tramdag.simulations.triangle._TriangleBase):
211class TriangleMixed(_TriangleBase):
212    """Paper Sec. 6.2: the triangle with an ordinal x3.
213
214    x3 has 4 levels, stored as 0 to 3, with
215    ``level = #{k : u3 > theta_k + 0.2 x1 + f(x2)}`` and
216    ``theta = (-2, 0.42, 1.02)``.
217    """
218
219    family = "mixed"
220    theta = THETA_MIXED
221
222    def _x3(self, x1, x2, do, latents):
223        if "x3" in do:
224            return _clamp(do["x3"], len(x1))
225        cuts = self.theta[None, :] + (0.2 * x1 + self.f_callable(x2))[:, None]
226        return (latents["x3"][:, None] > cuts).sum(axis=1).astype(float)
227
228    def true_pmf(self, x1: np.ndarray, x2: np.ndarray) -> np.ndarray:
229        """Analytic (n, 4) class probabilities given parents."""
230        shift = 0.2 * np.asarray(x1, float) + self.f_callable(np.asarray(x2, float))
231        cuts = self.theta[None, :] + shift[:, None]
232        cdf = 1.0 / (1.0 + np.exp(-cuts))
233        cdf = np.concatenate(
234            [np.zeros((len(cdf), 1)), cdf, np.ones((len(cdf), 1))], axis=1
235        )
236        return np.diff(cdf, axis=1)
237
238    def paper_truth(self) -> dict:
239        """State the true parameters of this data-generating process.
240
241        Returns
242        -------
243        dict
244            The coefficients, cutpoints and shift functions used to generate the
245            data, in the notation of the paper.
246        """
247        t = {
248            "beta13": 0.2,
249            "theta": self.theta.tolist(),
250            "h3": f"theta_k + 0.2*x1 + ({F_VARIANTS[self.f][1]})",
251            "levels": 4,
252            "level_offset": "paper counts 1..4, stored 0..3",
253            "zuko_sign": -1,
254        }
255        if self.f == "linear":
256            t["beta23"] = -0.3
257        return t
258
259    def zuko_expectations(self) -> dict:
260        """Give the same truth in the conventions of ``CausalFlowDAG``.
261
262        The ordinal shift is subtracted here and added in the paper, so the
263        expected weights carry the opposite sign.
264
265        Returns
266        -------
267        dict
268            Expected fitted values, keyed by parameter name.
269        """
270        exp = {
271            "w_x2_from_x1": 2.0,
272            "w_x3_from_x1": -0.2,
273            "theta": self.theta.tolist(),
274            "cs_curve": "-f(x2) + const",
275        }
276        if self.f == "linear":
277            exp["w_x3_from_x2"] = 0.3
278        return exp

Paper Sec. 6.2: the triangle with an ordinal x3.

x3 has 4 levels, stored as 0 to 3, with level = #{k : u3 > theta_k + 0.2 x1 + f(x2)} and theta = (-2, 0.42, 1.02).

family = 'mixed'
theta = array([-2. , 0.42, 1.02])
def true_pmf(self, x1: numpy.ndarray, x2: numpy.ndarray) -> numpy.ndarray:
228    def true_pmf(self, x1: np.ndarray, x2: np.ndarray) -> np.ndarray:
229        """Analytic (n, 4) class probabilities given parents."""
230        shift = 0.2 * np.asarray(x1, float) + self.f_callable(np.asarray(x2, float))
231        cuts = self.theta[None, :] + shift[:, None]
232        cdf = 1.0 / (1.0 + np.exp(-cuts))
233        cdf = np.concatenate(
234            [np.zeros((len(cdf), 1)), cdf, np.ones((len(cdf), 1))], axis=1
235        )
236        return np.diff(cdf, axis=1)

Analytic (n, 4) class probabilities given parents.

def paper_truth(self) -> dict:
238    def paper_truth(self) -> dict:
239        """State the true parameters of this data-generating process.
240
241        Returns
242        -------
243        dict
244            The coefficients, cutpoints and shift functions used to generate the
245            data, in the notation of the paper.
246        """
247        t = {
248            "beta13": 0.2,
249            "theta": self.theta.tolist(),
250            "h3": f"theta_k + 0.2*x1 + ({F_VARIANTS[self.f][1]})",
251            "levels": 4,
252            "level_offset": "paper counts 1..4, stored 0..3",
253            "zuko_sign": -1,
254        }
255        if self.f == "linear":
256            t["beta23"] = -0.3
257        return t

State the true parameters of this data-generating process.

Returns
  • dict: The coefficients, cutpoints and shift functions used to generate the data, in the notation of the paper.
def zuko_expectations(self) -> dict:
259    def zuko_expectations(self) -> dict:
260        """Give the same truth in the conventions of ``CausalFlowDAG``.
261
262        The ordinal shift is subtracted here and added in the paper, so the
263        expected weights carry the opposite sign.
264
265        Returns
266        -------
267        dict
268            Expected fitted values, keyed by parameter name.
269        """
270        exp = {
271            "w_x2_from_x1": 2.0,
272            "w_x3_from_x1": -0.2,
273            "theta": self.theta.tolist(),
274            "cs_curve": "-f(x2) + const",
275        }
276        if self.f == "linear":
277            exp["w_x3_from_x2"] = 0.3
278        return exp

Give the same truth in the conventions of CausalFlowDAG.

The ordinal shift is subtracted here and added in the paper, so the expected weights carry the opposite sign.

Returns
  • dict: Expected fitted values, keyed by parameter name.
@dataclass
class VacaTriangle:
 37@dataclass
 38class VacaTriangle:
 39    """SCM generator for the VACA bimodal triangle."""
 40
 41    seed: int = 42
 42
 43    def draw_latents(self, n: int, rng: np.random.Generator) -> dict[str, np.ndarray]:
 44        """Draw the latent noise of every variable.
 45
 46        Parameters
 47        ----------
 48        n : int
 49            Number of rows to draw.
 50        rng : np.random.Generator
 51            Random source.
 52
 53        Returns
 54        -------
 55        dict[str, np.ndarray]
 56            One array of length ``n`` per variable.
 57        """
 58        return {
 59            "x1_mix": rng.uniform(size=n),
 60            "x1_a": rng.normal(size=n),  # N(-2, sqrt(1.5)) branch
 61            "x1_b": rng.normal(size=n),  # N(1.5, 1) branch
 62            "x2": rng.normal(size=n),
 63            "x3": rng.normal(size=n),
 64        }
 65
 66    def simulate(
 67        self,
 68        n: int | None = None,
 69        *,
 70        rng: np.random.Generator | None = None,
 71        do: dict[str, float] | None = None,
 72        latents: dict[str, np.ndarray] | None = None,
 73    ) -> pd.DataFrame:
 74        """Simulate the SCM, with optional interventions and reused latents.
 75
 76        Parameters
 77        ----------
 78        n : int | None, optional
 79            Number of rows, by default ``None``. Then ``latents`` sets the count.
 80        rng : np.random.Generator | None, optional
 81            Random source, by default ``None``.
 82        do : dict[str, float] | None, optional
 83            Variables to hold at a fixed value, by default ``None``.
 84        latents : dict[str, np.ndarray] | None, optional
 85            Latent values to reuse, by default ``None``. Then they are drawn fresh.
 86
 87        Returns
 88        -------
 89        pd.DataFrame
 90            One column per variable.
 91        """
 92        do = do or {}
 93        if latents is None:
 94            if n is None:
 95                raise ValueError("provide either n or latents")
 96            rng = rng or np.random.default_rng(self.seed)
 97            latents = self.draw_latents(n, rng)
 98        n = len(latents["x2"])
 99
100        if "x1" in do:
101            x1 = np.full(n, float(do["x1"]))
102        else:
103            x1 = np.where(
104                latents["x1_mix"] < 0.5,
105                -2.0 + np.sqrt(1.5) * latents["x1_a"],
106                1.5 + 1.0 * latents["x1_b"],
107            )
108        x2 = np.full(n, float(do["x2"])) if "x2" in do else -x1 + latents["x2"]
109        x3 = (
110            np.full(n, float(do["x3"]))
111            if "x3" in do
112            else x1 + 0.25 * x2 + latents["x3"]
113        )
114        return pd.DataFrame({"x1": x1, "x2": x2, "x3": x3})
115
116    # ----------------------------------------------------------------- datasets
117    def observational(self, n: int, seed_offset: int = 0) -> pd.DataFrame:
118        """Draw an observational sample.
119
120        Parameters
121        ----------
122        n : int
123            Number of rows.
124        seed_offset : int, optional
125            Added to the generator seed, by default ``0``.
126
127        Returns
128        -------
129        pd.DataFrame
130            The sample.
131        """
132        rng = np.random.default_rng(self.seed + 1 + seed_offset)
133        return self.simulate(n, rng=rng)
134
135    def interventional(
136        self, n: int, do: dict[str, float], seed_offset: int = 0
137    ) -> pd.DataFrame:
138        """Draw a sample under an intervention.
139
140        Parameters
141        ----------
142        n : int
143            Number of rows.
144        do : dict[str, float]
145            Variables to hold at a fixed value.
146        seed_offset : int, optional
147            Added to the generator seed, by default ``0``.
148
149        Returns
150        -------
151        pd.DataFrame
152            The sample.
153        """
154        rng = np.random.default_rng(self.seed + 501 + seed_offset)
155        return self.simulate(n, rng=rng, do=do)
156
157    # -------------------------------------------------------------- ground truth
158    def true_moments(self, mc_n: int = 1_000_000) -> dict:
159        """Observational moments + the analytic moments of x3 under do(x2 = a).
160
161        Under do(x2=a): x3 = x1 + 0.25 a + N(0,1), so E = E[x1] + 0.25 a and
162        Var = Var[x1] + 1 — exact, but MC values are stored too (same estimator
163        a test would use).
164        """
165        mu1 = 0.5 * (-2.0) + 0.5 * 1.5
166        var1 = 0.5 * (1.5 + (-2.0 - mu1) ** 2) + 0.5 * (1.0 + (1.5 - mu1) ** 2)
167        obs = self.observational(mc_n, seed_offset=777)
168        out = {
169            "mc_n": mc_n,
170            "obs_mean": {c: float(obs[c].mean()) for c in obs},
171            "obs_std": {c: float(obs[c].std()) for c in obs},
172            "do_x2": {},
173        }
174        for a in DO_X2_VALUES:
175            out["do_x2"][str(a)] = {
176                "mean_x3_analytic": mu1 + 0.25 * a,
177                "std_x3_analytic": float(np.sqrt(var1 + 1.0)),
178            }
179        return out

SCM generator for the VACA bimodal triangle.

VacaTriangle(seed: int = 42)
seed: int = 42
def draw_latents( self, n: int, rng: numpy.random._generator.Generator) -> dict[str, numpy.ndarray]:
43    def draw_latents(self, n: int, rng: np.random.Generator) -> dict[str, np.ndarray]:
44        """Draw the latent noise of every variable.
45
46        Parameters
47        ----------
48        n : int
49            Number of rows to draw.
50        rng : np.random.Generator
51            Random source.
52
53        Returns
54        -------
55        dict[str, np.ndarray]
56            One array of length ``n`` per variable.
57        """
58        return {
59            "x1_mix": rng.uniform(size=n),
60            "x1_a": rng.normal(size=n),  # N(-2, sqrt(1.5)) branch
61            "x1_b": rng.normal(size=n),  # N(1.5, 1) branch
62            "x2": rng.normal(size=n),
63            "x3": rng.normal(size=n),
64        }

Draw the latent noise of every variable.

Parameters
  • n (int): Number of rows to draw.
  • rng (np.random.Generator): Random source.
Returns
  • dict[str, np.ndarray]: One array of length n per variable.
def simulate( self, n: int | None = None, *, rng: numpy.random._generator.Generator | None = None, do: dict[str, float] | None = None, latents: dict[str, numpy.ndarray] | None = None) -> pandas.DataFrame:
 66    def simulate(
 67        self,
 68        n: int | None = None,
 69        *,
 70        rng: np.random.Generator | None = None,
 71        do: dict[str, float] | None = None,
 72        latents: dict[str, np.ndarray] | None = None,
 73    ) -> pd.DataFrame:
 74        """Simulate the SCM, with optional interventions and reused latents.
 75
 76        Parameters
 77        ----------
 78        n : int | None, optional
 79            Number of rows, by default ``None``. Then ``latents`` sets the count.
 80        rng : np.random.Generator | None, optional
 81            Random source, by default ``None``.
 82        do : dict[str, float] | None, optional
 83            Variables to hold at a fixed value, by default ``None``.
 84        latents : dict[str, np.ndarray] | None, optional
 85            Latent values to reuse, by default ``None``. Then they are drawn fresh.
 86
 87        Returns
 88        -------
 89        pd.DataFrame
 90            One column per variable.
 91        """
 92        do = do or {}
 93        if latents is None:
 94            if n is None:
 95                raise ValueError("provide either n or latents")
 96            rng = rng or np.random.default_rng(self.seed)
 97            latents = self.draw_latents(n, rng)
 98        n = len(latents["x2"])
 99
100        if "x1" in do:
101            x1 = np.full(n, float(do["x1"]))
102        else:
103            x1 = np.where(
104                latents["x1_mix"] < 0.5,
105                -2.0 + np.sqrt(1.5) * latents["x1_a"],
106                1.5 + 1.0 * latents["x1_b"],
107            )
108        x2 = np.full(n, float(do["x2"])) if "x2" in do else -x1 + latents["x2"]
109        x3 = (
110            np.full(n, float(do["x3"]))
111            if "x3" in do
112            else x1 + 0.25 * x2 + latents["x3"]
113        )
114        return pd.DataFrame({"x1": x1, "x2": x2, "x3": x3})

Simulate the SCM, with optional interventions and reused latents.

Parameters
  • n (int | None, optional): Number of rows, by default None. Then latents sets the count.
  • rng (np.random.Generator | None, optional): Random source, by default None.
  • do (dict[str, float] | None, optional): Variables to hold at a fixed value, by default None.
  • latents (dict[str, np.ndarray] | None, optional): Latent values to reuse, by default None. Then they are drawn fresh.
Returns
  • pd.DataFrame: One column per variable.
def observational(self, n: int, seed_offset: int = 0) -> pandas.DataFrame:
117    def observational(self, n: int, seed_offset: int = 0) -> pd.DataFrame:
118        """Draw an observational sample.
119
120        Parameters
121        ----------
122        n : int
123            Number of rows.
124        seed_offset : int, optional
125            Added to the generator seed, by default ``0``.
126
127        Returns
128        -------
129        pd.DataFrame
130            The sample.
131        """
132        rng = np.random.default_rng(self.seed + 1 + seed_offset)
133        return self.simulate(n, rng=rng)

Draw an observational sample.

Parameters
  • n (int): Number of rows.
  • seed_offset (int, optional): Added to the generator seed, by default 0.
Returns
  • pd.DataFrame: The sample.
def interventional( self, n: int, do: dict[str, float], seed_offset: int = 0) -> pandas.DataFrame:
135    def interventional(
136        self, n: int, do: dict[str, float], seed_offset: int = 0
137    ) -> pd.DataFrame:
138        """Draw a sample under an intervention.
139
140        Parameters
141        ----------
142        n : int
143            Number of rows.
144        do : dict[str, float]
145            Variables to hold at a fixed value.
146        seed_offset : int, optional
147            Added to the generator seed, by default ``0``.
148
149        Returns
150        -------
151        pd.DataFrame
152            The sample.
153        """
154        rng = np.random.default_rng(self.seed + 501 + seed_offset)
155        return self.simulate(n, rng=rng, do=do)

Draw a sample under an intervention.

Parameters
  • n (int): Number of rows.
  • do (dict[str, float]): Variables to hold at a fixed value.
  • seed_offset (int, optional): Added to the generator seed, by default 0.
Returns
  • pd.DataFrame: The sample.
def true_moments(self, mc_n: int = 1000000) -> dict:
158    def true_moments(self, mc_n: int = 1_000_000) -> dict:
159        """Observational moments + the analytic moments of x3 under do(x2 = a).
160
161        Under do(x2=a): x3 = x1 + 0.25 a + N(0,1), so E = E[x1] + 0.25 a and
162        Var = Var[x1] + 1 — exact, but MC values are stored too (same estimator
163        a test would use).
164        """
165        mu1 = 0.5 * (-2.0) + 0.5 * 1.5
166        var1 = 0.5 * (1.5 + (-2.0 - mu1) ** 2) + 0.5 * (1.0 + (1.5 - mu1) ** 2)
167        obs = self.observational(mc_n, seed_offset=777)
168        out = {
169            "mc_n": mc_n,
170            "obs_mean": {c: float(obs[c].mean()) for c in obs},
171            "obs_std": {c: float(obs[c].std()) for c in obs},
172            "do_x2": {},
173        }
174        for a in DO_X2_VALUES:
175            out["do_x2"][str(a)] = {
176                "mean_x3_analytic": mu1 + 0.25 * a,
177                "std_x3_analytic": float(np.sqrt(var1 + 1.0)),
178            }
179        return out

Observational moments + the analytic moments of x3 under do(x2 = a).

Under do(x2=a): x3 = x1 + 0.25 a + N(0,1), so E = E[x1] + 0.25 a and Var = Var[x1] + 1 — exact, but MC values are stored too (same estimator a test would use).

@dataclass
class Carefl4:
 40@dataclass
 41class Carefl4:
 42    """SCM generator for the 4-variable CAREFL benchmark."""
 43
 44    seed: int = 42
 45
 46    def draw_latents(self, n: int, rng: np.random.Generator) -> dict[str, np.ndarray]:
 47        """Draw the latent noise of every variable.
 48
 49        Parameters
 50        ----------
 51        n : int
 52            Number of rows to draw.
 53        rng : np.random.Generator
 54            Random source.
 55
 56        Returns
 57        -------
 58        dict[str, np.ndarray]
 59            One array of length ``n`` per variable.
 60        """
 61        return {
 62            k: rng.laplace(loc=0.0, scale=_SCALE, size=n)
 63            for k in ["x1", "x2", "x3", "x4"]
 64        }
 65
 66    def simulate(
 67        self,
 68        n: int | None = None,
 69        *,
 70        rng: np.random.Generator | None = None,
 71        do: dict[str, float] | None = None,
 72        latents: dict[str, np.ndarray] | None = None,
 73    ) -> pd.DataFrame:
 74        """Simulate the SCM, with optional interventions and reused latents.
 75
 76        Parameters
 77        ----------
 78        n : int | None, optional
 79            Number of rows, by default ``None``. Then ``latents`` sets the count.
 80        rng : np.random.Generator | None, optional
 81            Random source, by default ``None``.
 82        do : dict[str, float] | None, optional
 83            Variables to hold at a fixed value, by default ``None``.
 84        latents : dict[str, np.ndarray] | None, optional
 85            Latent values to reuse, by default ``None``. Then they are drawn fresh.
 86
 87        Returns
 88        -------
 89        pd.DataFrame
 90            One column per variable.
 91        """
 92        do = do or {}
 93        if latents is None:
 94            if n is None:
 95                raise ValueError("provide either n or latents")
 96            rng = rng or np.random.default_rng(self.seed)
 97            latents = self.draw_latents(n, rng)
 98        n = len(latents["x1"])
 99
100        def clamp_or(name, value):
101            return np.full(n, float(do[name])) if name in do else value
102
103        x1 = clamp_or("x1", latents["x1"])
104        x2 = clamp_or("x2", latents["x2"])
105        x3 = clamp_or("x3", x1 + 0.5 * x2**3 + latents["x3"])
106        x4 = clamp_or("x4", -x2 + 0.5 * x1**2 + latents["x4"])
107        return pd.DataFrame({"x1": x1, "x2": x2, "x3": x3, "x4": x4})
108
109    # ----------------------------------------------------------------- datasets
110    def observational(self, n: int, seed_offset: int = 0) -> pd.DataFrame:
111        """Draw an observational sample.
112
113        Parameters
114        ----------
115        n : int
116            Number of rows.
117        seed_offset : int, optional
118            Added to the generator seed, by default ``0``.
119
120        Returns
121        -------
122        pd.DataFrame
123            The sample.
124        """
125        rng = np.random.default_rng(self.seed + 1 + seed_offset)
126        return self.simulate(n, rng=rng)
127
128    # -------------------------------------------------------------- ground truth
129    @staticmethod
130    def abduct_noise(obs: dict[str, float] | pd.DataFrame) -> dict[str, np.ndarray]:
131        """Exact noise values consistent with an observation (vectorized)."""
132        x1, x2 = np.asarray(obs["x1"], float), np.asarray(obs["x2"], float)
133        x3, x4 = np.asarray(obs["x3"], float), np.asarray(obs["x4"], float)
134        return {
135            "x1": x1,
136            "x2": x2,
137            "x3": x3 - x1 - 0.5 * x2**3,
138            "x4": x4 + x2 - 0.5 * x1**2,
139        }
140
141    def true_counterfactual(
142        self, obs: dict[str, float], do: dict[str, float]
143    ) -> dict[str, float]:
144        """Analytic counterfactual of a single observation under ``do``."""
145        eps = self.abduct_noise({k: np.atleast_1d(v) for k, v in obs.items()})
146        cf = self.simulate(do=do, latents=eps)
147        return {k: float(cf[k].iloc[0]) for k in cf}
148
149    def true_cf_curves(
150        self, obs: dict[str, float] = X_OBS, alphas: np.ndarray = ALPHA_GRID
151    ) -> dict:
152        """Compute the two analytic counterfactual curves of paper Fig. 6.
153
154        Returns
155        -------
156        dict
157            The observation, the intervention grid, and the counterfactual
158            values of x3 under do(x2) and of x4 under do(x1).
159        """
160        x3_cf = [self.true_counterfactual(obs, {"x2": a})["x3"] for a in alphas]
161        x4_cf = [self.true_counterfactual(obs, {"x1": a})["x4"] for a in alphas]
162        return {
163            "x_obs": dict(obs),
164            "alphas": [float(a) for a in alphas],
165            "x3_cf_do_x2": x3_cf,
166            "x4_cf_do_x1": x4_cf,
167        }

SCM generator for the 4-variable CAREFL benchmark.

Carefl4(seed: int = 42)
seed: int = 42
def draw_latents( self, n: int, rng: numpy.random._generator.Generator) -> dict[str, numpy.ndarray]:
46    def draw_latents(self, n: int, rng: np.random.Generator) -> dict[str, np.ndarray]:
47        """Draw the latent noise of every variable.
48
49        Parameters
50        ----------
51        n : int
52            Number of rows to draw.
53        rng : np.random.Generator
54            Random source.
55
56        Returns
57        -------
58        dict[str, np.ndarray]
59            One array of length ``n`` per variable.
60        """
61        return {
62            k: rng.laplace(loc=0.0, scale=_SCALE, size=n)
63            for k in ["x1", "x2", "x3", "x4"]
64        }

Draw the latent noise of every variable.

Parameters
  • n (int): Number of rows to draw.
  • rng (np.random.Generator): Random source.
Returns
  • dict[str, np.ndarray]: One array of length n per variable.
def simulate( self, n: int | None = None, *, rng: numpy.random._generator.Generator | None = None, do: dict[str, float] | None = None, latents: dict[str, numpy.ndarray] | None = None) -> pandas.DataFrame:
 66    def simulate(
 67        self,
 68        n: int | None = None,
 69        *,
 70        rng: np.random.Generator | None = None,
 71        do: dict[str, float] | None = None,
 72        latents: dict[str, np.ndarray] | None = None,
 73    ) -> pd.DataFrame:
 74        """Simulate the SCM, with optional interventions and reused latents.
 75
 76        Parameters
 77        ----------
 78        n : int | None, optional
 79            Number of rows, by default ``None``. Then ``latents`` sets the count.
 80        rng : np.random.Generator | None, optional
 81            Random source, by default ``None``.
 82        do : dict[str, float] | None, optional
 83            Variables to hold at a fixed value, by default ``None``.
 84        latents : dict[str, np.ndarray] | None, optional
 85            Latent values to reuse, by default ``None``. Then they are drawn fresh.
 86
 87        Returns
 88        -------
 89        pd.DataFrame
 90            One column per variable.
 91        """
 92        do = do or {}
 93        if latents is None:
 94            if n is None:
 95                raise ValueError("provide either n or latents")
 96            rng = rng or np.random.default_rng(self.seed)
 97            latents = self.draw_latents(n, rng)
 98        n = len(latents["x1"])
 99
100        def clamp_or(name, value):
101            return np.full(n, float(do[name])) if name in do else value
102
103        x1 = clamp_or("x1", latents["x1"])
104        x2 = clamp_or("x2", latents["x2"])
105        x3 = clamp_or("x3", x1 + 0.5 * x2**3 + latents["x3"])
106        x4 = clamp_or("x4", -x2 + 0.5 * x1**2 + latents["x4"])
107        return pd.DataFrame({"x1": x1, "x2": x2, "x3": x3, "x4": x4})

Simulate the SCM, with optional interventions and reused latents.

Parameters
  • n (int | None, optional): Number of rows, by default None. Then latents sets the count.
  • rng (np.random.Generator | None, optional): Random source, by default None.
  • do (dict[str, float] | None, optional): Variables to hold at a fixed value, by default None.
  • latents (dict[str, np.ndarray] | None, optional): Latent values to reuse, by default None. Then they are drawn fresh.
Returns
  • pd.DataFrame: One column per variable.
def observational(self, n: int, seed_offset: int = 0) -> pandas.DataFrame:
110    def observational(self, n: int, seed_offset: int = 0) -> pd.DataFrame:
111        """Draw an observational sample.
112
113        Parameters
114        ----------
115        n : int
116            Number of rows.
117        seed_offset : int, optional
118            Added to the generator seed, by default ``0``.
119
120        Returns
121        -------
122        pd.DataFrame
123            The sample.
124        """
125        rng = np.random.default_rng(self.seed + 1 + seed_offset)
126        return self.simulate(n, rng=rng)

Draw an observational sample.

Parameters
  • n (int): Number of rows.
  • seed_offset (int, optional): Added to the generator seed, by default 0.
Returns
  • pd.DataFrame: The sample.
@staticmethod
def abduct_noise(obs: dict[str, float] | pandas.DataFrame) -> dict[str, numpy.ndarray]:
129    @staticmethod
130    def abduct_noise(obs: dict[str, float] | pd.DataFrame) -> dict[str, np.ndarray]:
131        """Exact noise values consistent with an observation (vectorized)."""
132        x1, x2 = np.asarray(obs["x1"], float), np.asarray(obs["x2"], float)
133        x3, x4 = np.asarray(obs["x3"], float), np.asarray(obs["x4"], float)
134        return {
135            "x1": x1,
136            "x2": x2,
137            "x3": x3 - x1 - 0.5 * x2**3,
138            "x4": x4 + x2 - 0.5 * x1**2,
139        }

Exact noise values consistent with an observation (vectorized).

def true_counterfactual(self, obs: dict[str, float], do: dict[str, float]) -> dict[str, float]:
141    def true_counterfactual(
142        self, obs: dict[str, float], do: dict[str, float]
143    ) -> dict[str, float]:
144        """Analytic counterfactual of a single observation under ``do``."""
145        eps = self.abduct_noise({k: np.atleast_1d(v) for k, v in obs.items()})
146        cf = self.simulate(do=do, latents=eps)
147        return {k: float(cf[k].iloc[0]) for k in cf}

Analytic counterfactual of a single observation under do.

def true_cf_curves( self, obs: dict[str, float] = {'x1': 2.0, 'x2': 1.5, 'x3': 0.81, 'x4': -0.28}, alphas: numpy.ndarray = array([-3. , -2.9, -2.8, -2.7, -2.6, -2.5, -2.4, -2.3, -2.2, -2.1, -2. , -1.9, -1.8, -1.7, -1.6, -1.5, -1.4, -1.3, -1.2, -1.1, -1. , -0.9, -0.8, -0.7, -0.6, -0.5, -0.4, -0.3, -0.2, -0.1, 0. , 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1. , 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2. , 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9, 3. ])) -> dict:
149    def true_cf_curves(
150        self, obs: dict[str, float] = X_OBS, alphas: np.ndarray = ALPHA_GRID
151    ) -> dict:
152        """Compute the two analytic counterfactual curves of paper Fig. 6.
153
154        Returns
155        -------
156        dict
157            The observation, the intervention grid, and the counterfactual
158            values of x3 under do(x2) and of x4 under do(x1).
159        """
160        x3_cf = [self.true_counterfactual(obs, {"x2": a})["x3"] for a in alphas]
161        x4_cf = [self.true_counterfactual(obs, {"x1": a})["x4"] for a in alphas]
162        return {
163            "x_obs": dict(obs),
164            "alphas": [float(a) for a in alphas],
165            "x3_cf_do_x2": x3_cf,
166            "x4_cf_do_x1": x4_cf,
167        }

Compute the two analytic counterfactual curves of paper Fig. 6.

Returns
  • dict: The observation, the intervention grid, and the counterfactual values of x3 under do(x2) and of x4 under do(x1).
@dataclass
class VCLogisticShift:
 56@dataclass
 57class VCLogisticShift:
 58    """SCM generator for the VC validation cohort (issue #28).
 59
 60    Args:
 61        seed: master seed; each dataset draw uses an independent child stream.
 62    """
 63
 64    seed: int = 42
 65
 66    # ------------------------------------------------------------------ latents
 67    def draw_latents(self, n: int, rng: np.random.Generator) -> dict[str, np.ndarray]:
 68        """Draw all noise of the SCM.
 69
 70        The sources get Gaussian primitives. T gets a logistic assignment
 71        latent and Y gets the logistic TRAM latent.
 72        """
 73        return {
 74            "X1": rng.normal(size=n),
 75            "X2": rng.normal(size=n),
 76            "X3": rng.normal(size=n),
 77            "T": _logistic(rng, n),
 78            "Y": _logistic(rng, n),
 79        }
 80
 81    # --------------------------------------------------------------------- SCM
 82    def simulate(
 83        self,
 84        n: int | None = None,
 85        *,
 86        rng: np.random.Generator | None = None,
 87        do: dict[str, float] | None = None,
 88        latents: dict[str, np.ndarray] | None = None,
 89    ) -> pd.DataFrame:
 90        """Forward-sample the SCM (``do`` clamps nodes; ``latents`` reuses noise)."""
 91        do = do or {}
 92        if latents is None:
 93            if n is None:
 94                raise ValueError("provide either n or latents")
 95            rng = rng or np.random.default_rng(self.seed)
 96            latents = self.draw_latents(n, rng)
 97        n = len(next(iter(latents.values())))
 98
 99        x = {}
100        for name in ("X1", "X2", "X3"):
101            x[name] = np.full(n, float(do[name])) if name in do else latents[name]
102        if "T" in do:
103            T = np.full(n, float(do["T"]))
104        else:
105            logit_T = 0.4 * x["X1"] + 0.4 * x["X2"]
106            T = (latents["T"] > -logit_T).astype(float)  # P(T=1) = sigmoid(logit_T)
107        if "Y" in do:
108            Y = np.full(n, float(do["Y"]))
109        else:
110            g = 0.5 * x["X1"] ** 2 + x["X2"] - 0.5 * x["X3"]
111            beta = B0 + B2 * x["X2"] + B3 * x["X3"]
112            Y = (latents["Y"] - g - beta * T) / H_SCALE
113        return pd.DataFrame(
114            {"X1": x["X1"], "X2": x["X2"], "X3": x["X3"], "T": T, "Y": Y}
115        )
116
117    # ----------------------------------------------------------------- datasets
118    def observational(self, n: int, seed_offset: int = 0) -> pd.DataFrame:
119        """Draw an observational sample.
120
121        Parameters
122        ----------
123        n : int
124            Number of rows.
125        seed_offset : int, optional
126            Added to the generator seed, by default ``0``.
127
128        Returns
129        -------
130        pd.DataFrame
131            The sample.
132        """
133        rng = np.random.default_rng(self.seed + 1 + seed_offset)
134        return self.simulate(n, rng=rng)
135
136    # -------------------------------------------------------------- ground truth
137    def true_beta(self, x) -> np.ndarray:
138        """Give the true effect function ``beta(x)`` on the latent scale.
139
140        The scale is log-odds. A fitted VC term must recover this function
141        through :meth:`~tramdag.CausalFlowDAG.varying_coef`. ``x`` is a
142        DataFrame with X2 and X3 columns. Other columns are ignored.
143        """
144        return (
145            B0
146            + B2 * np.asarray(x["X2"], dtype=float)
147            + B3 * np.asarray(x["X3"], dtype=float)
148        )
149
150    def counterfactual_pair(
151        self, n: int, do: dict[str, float], seed_offset: int = 0
152    ) -> tuple[pd.DataFrame, pd.DataFrame]:
153        """Draw a factual sample and its counterfactual under ``do``.
154
155        Both share the same latents, so the pair gives true individual
156        counterfactuals.
157        """
158        rng = np.random.default_rng(self.seed + 2 + seed_offset)
159        latents = self.draw_latents(n, rng)
160        return self.simulate(latents=latents), self.simulate(latents=latents, do=do)

SCM generator for the VC validation cohort (issue #28).

Args: seed: master seed; each dataset draw uses an independent child stream.

VCLogisticShift(seed: int = 42)
seed: int = 42
def draw_latents( self, n: int, rng: numpy.random._generator.Generator) -> dict[str, numpy.ndarray]:
67    def draw_latents(self, n: int, rng: np.random.Generator) -> dict[str, np.ndarray]:
68        """Draw all noise of the SCM.
69
70        The sources get Gaussian primitives. T gets a logistic assignment
71        latent and Y gets the logistic TRAM latent.
72        """
73        return {
74            "X1": rng.normal(size=n),
75            "X2": rng.normal(size=n),
76            "X3": rng.normal(size=n),
77            "T": _logistic(rng, n),
78            "Y": _logistic(rng, n),
79        }

Draw all noise of the SCM.

The sources get Gaussian primitives. T gets a logistic assignment latent and Y gets the logistic TRAM latent.

def simulate( self, n: int | None = None, *, rng: numpy.random._generator.Generator | None = None, do: dict[str, float] | None = None, latents: dict[str, numpy.ndarray] | None = None) -> pandas.DataFrame:
 82    def simulate(
 83        self,
 84        n: int | None = None,
 85        *,
 86        rng: np.random.Generator | None = None,
 87        do: dict[str, float] | None = None,
 88        latents: dict[str, np.ndarray] | None = None,
 89    ) -> pd.DataFrame:
 90        """Forward-sample the SCM (``do`` clamps nodes; ``latents`` reuses noise)."""
 91        do = do or {}
 92        if latents is None:
 93            if n is None:
 94                raise ValueError("provide either n or latents")
 95            rng = rng or np.random.default_rng(self.seed)
 96            latents = self.draw_latents(n, rng)
 97        n = len(next(iter(latents.values())))
 98
 99        x = {}
100        for name in ("X1", "X2", "X3"):
101            x[name] = np.full(n, float(do[name])) if name in do else latents[name]
102        if "T" in do:
103            T = np.full(n, float(do["T"]))
104        else:
105            logit_T = 0.4 * x["X1"] + 0.4 * x["X2"]
106            T = (latents["T"] > -logit_T).astype(float)  # P(T=1) = sigmoid(logit_T)
107        if "Y" in do:
108            Y = np.full(n, float(do["Y"]))
109        else:
110            g = 0.5 * x["X1"] ** 2 + x["X2"] - 0.5 * x["X3"]
111            beta = B0 + B2 * x["X2"] + B3 * x["X3"]
112            Y = (latents["Y"] - g - beta * T) / H_SCALE
113        return pd.DataFrame(
114            {"X1": x["X1"], "X2": x["X2"], "X3": x["X3"], "T": T, "Y": Y}
115        )

Forward-sample the SCM (do clamps nodes; latents reuses noise).

def observational(self, n: int, seed_offset: int = 0) -> pandas.DataFrame:
118    def observational(self, n: int, seed_offset: int = 0) -> pd.DataFrame:
119        """Draw an observational sample.
120
121        Parameters
122        ----------
123        n : int
124            Number of rows.
125        seed_offset : int, optional
126            Added to the generator seed, by default ``0``.
127
128        Returns
129        -------
130        pd.DataFrame
131            The sample.
132        """
133        rng = np.random.default_rng(self.seed + 1 + seed_offset)
134        return self.simulate(n, rng=rng)

Draw an observational sample.

Parameters
  • n (int): Number of rows.
  • seed_offset (int, optional): Added to the generator seed, by default 0.
Returns
  • pd.DataFrame: The sample.
def true_beta(self, x) -> numpy.ndarray:
137    def true_beta(self, x) -> np.ndarray:
138        """Give the true effect function ``beta(x)`` on the latent scale.
139
140        The scale is log-odds. A fitted VC term must recover this function
141        through :meth:`~tramdag.CausalFlowDAG.varying_coef`. ``x`` is a
142        DataFrame with X2 and X3 columns. Other columns are ignored.
143        """
144        return (
145            B0
146            + B2 * np.asarray(x["X2"], dtype=float)
147            + B3 * np.asarray(x["X3"], dtype=float)
148        )

Give the true effect function beta(x) on the latent scale.

The scale is log-odds. A fitted VC term must recover this function through ~tramdag.CausalFlowDAG.varying_coef(). x is a DataFrame with X2 and X3 columns. Other columns are ignored.

def counterfactual_pair( self, n: int, do: dict[str, float], seed_offset: int = 0) -> tuple[pandas.DataFrame, pandas.DataFrame]:
150    def counterfactual_pair(
151        self, n: int, do: dict[str, float], seed_offset: int = 0
152    ) -> tuple[pd.DataFrame, pd.DataFrame]:
153        """Draw a factual sample and its counterfactual under ``do``.
154
155        Both share the same latents, so the pair gives true individual
156        counterfactuals.
157        """
158        rng = np.random.default_rng(self.seed + 2 + seed_offset)
159        latents = self.draw_latents(n, rng)
160        return self.simulate(latents=latents), self.simulate(latents=latents, do=do)

Draw a factual sample and its counterfactual under do.

Both share the same latents, so the pair gives true individual counterfactuals.

REGISTRY = {'magic-mrclean': <class 'MagicMrClean'>, 'triangle': <class 'TriangleContinuous'>, 'triangle-mixed': <class 'TriangleMixed'>, 'vaca': <class 'VacaTriangle'>, 'carefl': <class 'Carefl4'>, 'vc-shift': <class 'VCLogisticShift'>}