Source code for andrey.data.interventions

"""Interventions on a simulated model: hard and soft changes to one variable's mechanism.

:func:`hard` cuts a variable from its parents and sets it from outside; :func:`soft` changes its
mechanism and keeps its parents. :meth:`~andrey.data.SCM.do` applies them to a model, and
``envs=`` on :meth:`~andrey.data.SCM.sample` and :func:`~andrey.data.sample_discrete` draws several
environments, each with its own interventions, into one dataset:

>>> import andrey.data as data
>>> from andrey.data.interventions import hard, soft
>>> scm = data.SCM(graph=data.graphs.erdos_renyi(d=8, avg_degree=2),
...                functional=data.functional.linear(),
...                noise=data.noise.gaussian())
>>> ds = scm.sample(n=[500, 200, 200], seed=0, envs=[{}, {3: hard(2.0)}, {5: soft(shift=1.0)}])
>>> ds.data.shape, ds.targets
((900, 8), {0: [], 1: [3], 2: [5]})

A variable is named by its column in the returned data, after the column shuffle.
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import asdict, dataclass

import numpy as np

__all__ = ["HardIntervention", "SoftIntervention", "hard", "soft"]


[docs] @dataclass(frozen=True, kw_only=True) class HardIntervention: """A hard intervention: the variable's parents no longer act on it (see :func:`hard`).""" value: float | None = None sd: float = 0.0 probs: tuple[float, ...] | None = None
[docs] @dataclass(frozen=True, kw_only=True) class SoftIntervention: """A soft intervention: the mechanism changes and the parents still act (:func:`soft`).""" shift: float = 0.0 weight_scale: float = 1.0 noise_scale: float | tuple[float, float] = 1.0 weights: str = "keep"
Intervention = HardIntervention | SoftIntervention
[docs] def hard( value: float | None = None, *, sd: float = 0.0, probs: Sequence[float] | None = None ) -> HardIntervention: """Build a hard intervention: the variable is set from outside, and its parents no longer act. Parameters ---------- value : float or None, default=None A continuous variable: its value, or the mean of its values when ``sd`` is positive, in the units of the returned data, whatever the ``scale``. A discrete variable: the category every row takes. ``None`` takes ``probs`` for a discrete variable. sd : float, default=0.0 A continuous variable: the standard deviation of a Gaussian the values are drawn from, in the units of the returned data. ``0`` sets every row to ``value``. probs : sequence of float or None, default=None A discrete variable: the probability of each category, the same for every row. ``None`` with ``value=None`` gives every category the same probability. Returns ------- HardIntervention Raises ------ ValueError If ``value`` or ``sd`` is not finite, ``sd`` is negative, ``probs`` is given with ``value`` or ``sd``, or ``probs`` has a negative entry or does not sum to 1. Examples -------- >>> from andrey.data.interventions import hard >>> hard(2.0, sd=0.2) HardIntervention(value=2.0, sd=0.2, probs=None) """ if value is not None and not np.isfinite(value): raise ValueError(f"hard() takes a finite value, got {value}") if not (np.isfinite(sd) and sd >= 0): raise ValueError(f"hard() takes a finite sd >= 0, got {sd}") if probs is None: return HardIntervention(value=None if value is None else float(value), sd=float(sd)) if value is not None or sd: raise ValueError("hard() takes probs, or a value and an sd, not both") p = np.asarray(probs, dtype=np.float64) if p.ndim != 1 or p.size < 2 or not np.all(p >= 0) or not np.isclose(p.sum(), 1.0): raise ValueError(f"probs must hold two or more probabilities that sum to 1, got {probs}") return HardIntervention(probs=tuple(float(v) for v in p))
[docs] def soft( *, shift: float = 0.0, weight_scale: float = 1.0, noise_scale: float | Sequence[float] = 1.0, weights: str = "keep", ) -> SoftIntervention: """Build a soft intervention: the variable's mechanism changes, and its parents still act. On a continuous variable, its noise is multiplied by ``noise_scale`` and moved by ``shift``, and the weights of its parents are multiplied by ``weight_scale`` or drawn anew. On a discrete variable, ``soft()`` takes no arguments: every row of its conditional probability table is drawn anew, from the Dirichlet the sampler drew it from. Parameters ---------- shift : float, default=0.0 Added to the variable's noise, in the units of the returned data, whatever the ``scale``. Under the linear and additive-noise functionals it moves the variable's mean by ``shift``. weight_scale : float, default=1.0 Multiplies the weight of each of the variable's parents; under the location-scale functional, its weights in the location and in the scale. A variable without parents has no weights to change. noise_scale : float or (float, float), default=1.0 Multiplies the variable's noise, so its noise standard deviation. A pair ``(low, high)``, ``low < high``, multiplies the noise of each row by its own draw from ``U[low, high]``, so the noise follows a new distribution, a scale mixture of the one it had (Peters et al. 2016, Appendix G). weights : {"keep", "redraw"}, default="keep" ``"redraw"`` draws the weight of each of the variable's parents anew, each on its own, from the functional's own weight distribution: magnitude ``U[weight_range]``, with a random sign, or positive when the functional draws positive weights. The new weights come from that ``weight_range``, not from a range of their own as in Peters et al. (2016, Appendix G). Under the location-scale functional, the weights in the location and in the scale are drawn anew. A variable without parents has no weights to draw anew. The new weights are part of the model: samples with the same model seed draw the same ones. Each environment draws its own: the sample under :meth:`~andrey.data.SCM.do` and each environment of ``envs=`` draw different new weights for the same intervention, and the dataset's ``params`` holds the weights without interventions. ``"redraw"`` takes ``weight_scale=1``. Returns ------- SoftIntervention Raises ------ ValueError If an argument is not finite, ``noise_scale`` is negative or a pair not ordered ``0 <= low < high``, ``weights`` is not ``"keep"`` or ``"redraw"``, or ``"redraw"`` is given with a ``weight_scale`` other than 1. References ---------- Peters, J., Buhlmann, P., and Meinshausen, N. (2016). Causal inference by using invariant prediction: identification and confidence intervals. Journal of the Royal Statistical Society, Series B, 78(5), 947-1012. Examples -------- >>> from andrey.data.interventions import soft >>> soft(shift=1.0) SoftIntervention(shift=1.0, weight_scale=1.0, noise_scale=1.0, weights='keep') >>> soft(noise_scale=(1.0, 3.0), weights="redraw") SoftIntervention(shift=0.0, weight_scale=1.0, noise_scale=(1.0, 3.0), weights='redraw') """ values = np.asarray(noise_scale, dtype=float) if values.ndim == 0: noise = float(values) if not (np.isfinite(noise) and noise >= 0): raise ValueError(f"soft() takes a finite noise_scale >= 0, got {noise_scale}") else: if values.shape != (2,) or not (np.all(np.isfinite(values)) and 0 <= values[0] < values[1]): raise ValueError( f"soft() takes noise_scale as a number or a pair 0 <= low < high, got {noise_scale}" ) noise = (float(values[0]), float(values[1])) if not all(np.isfinite(v) for v in (shift, weight_scale)): raise ValueError( f"soft() takes finite arguments, got shift={shift}, weight_scale={weight_scale}" ) if weights not in ("keep", "redraw"): raise ValueError(f"soft() takes weights 'keep' or 'redraw', got {weights!r}") if weights == "redraw" and weight_scale != 1: raise ValueError("soft() takes weights='redraw' or a weight_scale, not both") return SoftIntervention( shift=float(shift), weight_scale=float(weight_scale), noise_scale=noise, weights=weights )
def as_interventions(spec: Mapping, n_columns: int | None = None) -> dict[int, Intervention]: """Check one environment's ``{column: intervention}`` and return it sorted by column. A number stands for ``hard(number)``. A column is a whole number, from 0 to ``n_columns - 1`` when ``n_columns`` is given. """ if not isinstance(spec, Mapping): raise TypeError( f"an environment is a mapping {{column: hard(...) or soft(...)}}, got {spec!r}" ) out: dict[int, Intervention] = {} for column, intervention in spec.items(): if isinstance(column, bool) or not isinstance(column, int | np.integer): raise TypeError(f"a target is a column number, got {column!r}") if column < 0 or (n_columns is not None and column >= n_columns): raise ValueError( f"target column {column} is not one of the observed columns 0..{n_columns - 1}" if n_columns is not None else f"a target is a column number >= 0, got {column}" ) if isinstance(intervention, int | float | np.integer | np.floating) and not isinstance( intervention, bool ): intervention = hard(float(intervention)) if not isinstance(intervention, HardIntervention | SoftIntervention): raise TypeError( f"column {column}: an intervention is hard(...), soft(...), or a number, got " f"{intervention!r}" ) out[int(column)] = intervention return dict(sorted(out.items())) #: One environment: its interventions by column, and its number of rows. Environment = tuple[dict[int, Intervention], int] def environments(envs: Sequence[Mapping], n: int | Sequence[int]) -> list[Environment]: """Check ``envs`` and ``n``; return each environment's interventions and rows. ``n`` gives every environment ``n`` rows, or environment ``k`` ``n[k]`` rows. """ if isinstance(envs, Mapping) or not isinstance(envs, Sequence) or not envs: raise TypeError( "envs is a non-empty list of mappings {column: intervention}, one per environment; " f"got {envs!r}" ) sizes = [n] * len(envs) if isinstance(n, int | np.integer) else list(n) if len(sizes) != len(envs): raise ValueError(f"n gives {len(sizes)} row counts for {len(envs)} environments") if not all( isinstance(s, int | np.integer) and not isinstance(s, bool) and s >= 1 for s in sizes ): raise ValueError(f"every environment takes a whole number of rows >= 1, got n={n}") return [(as_interventions(spec), int(size)) for spec, size in zip(envs, sizes, strict=True)] def check_columns(envs: Sequence[Environment], n_columns: int) -> None: """Raise unless every target of ``envs`` is one of ``n_columns`` columns.""" for spec, _ in envs: as_interventions(spec, n_columns) def env_labels(envs: Sequence[Environment]) -> np.ndarray: """Return the environment of each pooled row, ``0`` to ``K - 1``, in order.""" return np.repeat(np.arange(len(envs), dtype=np.int64), [rows for _, rows in envs]) def env_targets(envs: Sequence[Environment]) -> dict[int, list[int]]: """Return each environment's intervened columns: ``{k: [columns]}``, ``[]`` for none.""" return {k: sorted(spec) for k, (spec, _) in enumerate(envs)} def record(envs: Sequence[Environment]) -> list[dict]: """Return the provenance record of the environments: each one's rows and interventions.""" return [ { "n": rows, "interventions": { str(column): {"kind": type(intervention).__name__.lower(), **asdict(intervention)} for column, intervention in spec.items() }, } for spec, rows in envs ]