Source code for andrey.data.measurement

"""Measurement columns on simulated data: noisy children whose measured column is known.

:func:`noisy_children` describes measurement columns, and ``measurements=`` on
:meth:`~andrey.data.SCM.sample` and :func:`~andrey.data.sample_scm` adds them to the sampled
data: each is a selected variable plus independent noise, the random measurement error model
[1]_ [2]_, and a child of that variable in the true graph. The returned
:class:`~andrey.data.dataset.Measurements` records which column each one measures:

>>> import andrey.data as data
>>> from andrey.data import measurement
>>> ds = data.sample_scm(d=5, n=500, seed=0, density=2,
...                      measurements=measurement.noisy_children([0, 3]))
>>> ds.data.shape, len(ds.measurements.parents)
((500, 7), 2)

A measurement column has no position of its own. When the sample shuffles its columns, the
measurement columns are shuffled with all the others, so ``measurements.parents`` is the only
record of which columns they are; with ``shuffle=False`` they follow the other columns, in the
order of the columns they measure. The other columns hold the values of the same sample without
measurements, and ``provenance["column_order"]`` matches them: a column has the same entry there
in the two samples, and a measurement column has an entry of ``n_obs`` or more, ``n_obs`` being
the number of columns without measurements.

A measurement of column ``i`` is ``X_i + noise_scale * sd(X_i) * e`` with ``e`` of mean 0 and
variance 1, independent of every variable and of the other measurement noises, and ``sd(X_i)`` the
standard deviation of the returned column over the rows. The share of a measurement's variance
that comes from its variable, its reliability, is therefore ``1 / (1 + noise_scale**2)`` whatever
the units of the data. The measurement is then scaled as the sample's ``scale`` scales a
variable: z-scored under ``"standardize"``, divided by its standard deviation under ``"rescale"``,
divided by ``sqrt(1 + noise_scale**2)`` under ``"unitless"``, and kept under ``"raw"``.

To study a variable observed only with error, drop its column and keep its measurement: the
variable is then a hidden parent of the measurement.

References
----------
.. [1] Scheines, R., and Ramsey, J. (2016). Measurement error and causal discovery. CEUR Workshop
   Proceedings 1792, 1-7.
.. [2] Zhang, K., Gong, M., Ramsey, J., Batmanghelich, K., Spirtes, P., and Glymour, C. (2018).
   Causal discovery with linear non-Gaussian models under measurement error: structural
   identifiability results. Proceedings of the 34th Conference on Uncertainty in Artificial
   Intelligence.
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass, replace

import numpy as np

from andrey.core.structure import OBSERVED

from .dataset import CausalDataset, Measurements
from .graphs import dag_truth
from .noise import Noise, gaussian
from .scm import MEASUREMENT, column_order, component_rng
from .sortability import standardize

__all__ = ["Measurements", "NoisyChildren", "noisy_children"]

# The streams under MEASUREMENT: the choice of the measured columns and the column order, parts
# of the model, and the measurement noise, part of the rows.
_CHOICE, _NOISE, _ORDER = 0, 1, 2


[docs] @dataclass(frozen=True, kw_only=True) class NoisyChildren: """Which variables get a measurement column, and its noise. Built by :func:`noisy_children` and drawn by ``measurements=`` on :meth:`~andrey.data.SCM.sample`. """ columns: tuple[int, ...] | None share: float | None noise_scale: float noise: Noise
[docs] def noisy_children( columns: Sequence[int] | None = None, *, share: float | None = None, noise_scale: float = 0.5, noise: Noise | None = None, ) -> NoisyChildren: """Measurement columns: for each selected variable, a child that is it plus independent noise. Parameters ---------- columns : sequence of int or None, default=None The measured columns, as columns of the sample without measurements, after its column shuffle. A sample that shuffles its columns moves them again with the measurement columns, and ``measurements.parents`` names where each one is. ``None`` measures the ``share`` of the columns, or every column when ``share`` is ``None`` too. share : float or None, default=None The share of the columns to measure, in ``(0, 1]``, rounded to a whole number of at least one column; which ones is drawn from the model's seed. Given without ``columns``. noise_scale : float, default=0.5 The standard deviation of the measurement noise as a multiple of the standard deviation of the measured column, finite and positive. The default gives each measurement a reliability of ``1 / (1 + 0.5**2) = 0.8``. noise : Noise or None, default=None The family of the measurement noise, from :mod:`andrey.data.noise`, without ``heteroscedastic`` or ``variance_range``. ``None`` is Gaussian. Returns ------- NoisyChildren Raises ------ ValueError If ``columns`` is empty or holds a repeated, negative, or fractional column, ``columns`` and ``share`` are both given, ``share`` is not in ``(0, 1]``, ``noise_scale`` is not finite and positive, or ``noise`` sets its own scales. Examples -------- >>> from andrey.data.measurement import noisy_children >>> noisy_children([0, 2], noise_scale=1.0).columns (0, 2) """ chosen = None if columns is not None: if share is not None: raise ValueError("noisy_children takes columns or a share, not both") listed = list(columns) whole = all( isinstance(c, int | np.integer) and not isinstance(c, bool) and c >= 0 for c in listed ) if not listed or not whole or len(set(listed)) != len(listed): raise ValueError( f"columns must be distinct whole numbers >= 0, at least one, got {listed!r}" ) chosen = tuple(sorted(int(c) for c in listed)) if share is not None and not (np.isfinite(share) and 0 < share <= 1): raise ValueError(f"share must be in (0, 1], got {share!r}") if not (np.isfinite(noise_scale) and noise_scale > 0): raise ValueError(f"noise_scale must be finite and positive, got {noise_scale!r}") family = gaussian() if noise is None else noise if not isinstance(family, Noise): raise ValueError(f"noise must be a family of andrey.data.noise, got {noise!r}") if family.heteroscedastic or family.variance_range is not None: raise ValueError( "measurement noise takes its scale from noise_scale; give a noise family without " "heteroscedastic=True or a variance_range" ) return NoisyChildren( columns=chosen, share=None if share is None else float(share), noise_scale=float(noise_scale), noise=family, )
def append( ds: CausalDataset, spec: NoisyChildren, root: np.random.SeedSequence, model: np.random.SeedSequence, *, scale: str, shuffle: bool, ) -> CausalDataset: """Return ``ds`` with the measurement columns of ``spec`` among its columns. The measured columns and the column order come from the measurement streams of ``model`` and the noise from that of ``root``, so no other draw depends on them. The measurement columns are first placed after the columns of ``ds``; under ``shuffle`` one permutation then orders all of them, and the graph, ``params``, ``targets``, and the record are relabeled by it. The latent nodes stay last. """ n, n_obs = ds.data.shape measured = _measured(spec, n_obs, component_rng(model, MEASUREMENT, _CHOICE)) k = measured.size values = ds.data[:, measured].astype(np.float64) sd = values.std(axis=0) if np.any(sd == 0): raise ValueError( f"columns {measured[sd == 0].tolist()} are constant over the rows; a measurement " "takes its noise scale from the measured column's standard deviation" ) noise_sd = spec.noise_scale * sd children = values + spec.noise.draw(n, noise_sd, component_rng(root, MEASUREMENT, _NOISE)) if scale == "standardize": children = standardize(children) elif scale == "rescale": children /= children.std(axis=0) elif scale == "unitless": children /= np.sqrt(1.0 + spec.noise_scale**2) data = np.concatenate([ds.data, children.astype(ds.data.dtype)], axis=1) # Before the column order, the measurement nodes take the labels n_obs .. n_obs + k - 1 and # the latent nodes move up. ``order[j]`` is that label of final node ``j``: the columns in # their drawn order, then the latents, as in ``SCM.sample``. d = ds.graph.n_nodes + k edges = np.where(ds.params.edges >= n_obs, ds.params.edges + k, ds.params.edges) nodes = n_obs + np.arange(k) columns = column_order(model, n_obs + k, shuffle, stream=(MEASUREMENT, _ORDER)) order = np.concatenate([columns, np.arange(n_obs + k, d)]) final = np.argsort(order) data = np.take(data, columns, axis=1) # C order types = ds.graph.node_types if types is not None: types = np.concatenate([types[:n_obs], np.full(k, OBSERVED, types.dtype), types[n_obs:]]) graph = dag_truth( final[np.concatenate([edges[:, 0], measured])], final[np.concatenate([edges[:, 1], nodes])], d, node_types=types, ) scales = ds.params.noise_scales params = replace( ds.params, edges=final[edges], noise_scales=np.concatenate([scales[:n_obs], noise_sd, scales[n_obs:]])[order], ) record = Measurements( parents=dict(sorted((int(final[v]), int(final[c])) for v, c in zip(nodes, measured))), noise=spec.noise.name, noise_scale=spec.noise_scale, ) targets = ds.targets if targets is not None: targets = {env: sorted(int(final[c]) for c in cols) for env, cols in targets.items()} drawn = np.concatenate([np.asarray(ds.provenance["column_order"], dtype=np.int64), nodes]) options = spec.noise.options config = { **ds.provenance["config"], "measurements": { "columns": measured.tolist(), "noise_scale": spec.noise_scale, "noise": spec.noise.name, **({"noise_params": options} if options else {}), }, } return replace( ds, data=data, graph=graph, params=params, measurements=record, targets=targets, provenance={**ds.provenance, "column_order": drawn[columns].tolist(), "config": config}, ) def _measured(spec: NoisyChildren, n_obs: int, rng: np.random.Generator) -> np.ndarray: """Return the measured columns of ``n_obs``, ascending: the spec's own, or drawn.""" if spec.columns is not None: if spec.columns[-1] >= n_obs: raise ValueError( f"measurements name columns {list(spec.columns)}, and the data have {n_obs}" ) return np.array(spec.columns, dtype=np.int64) if spec.share is None: return np.arange(n_obs, dtype=np.int64) k = max(1, int(round(spec.share * n_obs))) return np.sort(rng.choice(n_obs, size=k, replace=False)).astype(np.int64)