Source code for andrey.data.contexts

"""Time series from several datasets with shared contexts, and their true links.

:func:`sample_context_series` draws the linear model that Gunther, Ninad, and Runge (2023) describe:
each system variable depends on its own past, on other system variables at lag ``0`` or a positive
lag, and on at most one context. A time context varies over time and is the same in every dataset; a
space context is fixed within a dataset. Contexts are standard normal and have no parents. Each
context drives exactly one system variable here, so no hidden context is a common cause of two.
"""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np

__all__ = ["ContextSeries", "sample_context_series"]

_DRAWS = 1000  # draws of the weights before giving up on a stationary model
_BURN_IN = 100  # steps dropped from the start of each series


[docs] @dataclass(frozen=True) class ContextSeries: """Datasets of system variables and observed contexts, with the model's links. ``datasets`` holds one ``(n_steps, n_columns)`` array per dataset: the system variables, then the observed time contexts, then the observed space contexts, whose positions are ``time_contexts`` and ``space_contexts``. ``links`` holds the ``(cause, effect, lag)`` links between system variables, a variable's link to its own past included. ``context_links`` holds the ``(context, lag, variable)`` links from the observed contexts, by column position, and ``hidden_links`` the ``(lag, variable)`` links from the contexts the datasets leave out. """ datasets: tuple[np.ndarray, ...] time_contexts: tuple[int, ...] space_contexts: tuple[int, ...] links: frozenset[tuple[int, int, int]] context_links: frozenset[tuple[int, int, int]] hidden_links: frozenset[tuple[int, int]]
def _lag(rng: np.random.Generator, tau_max: int) -> int: """Lag ``0`` half the time, else a uniform lag from ``1`` to ``tau_max``.""" return 0 if rng.random() < 0.5 else int(rng.integers(1, tau_max + 1)) def _system_weights( n_system: int, n_links: int, tau_max: int, rng: np.random.Generator ) -> np.ndarray: """Return the ``(tau_max + 1, n_system, n_system)`` weights ``[lag, effect, cause]``.""" weights = np.zeros((tau_max + 1, n_system, n_system)) weights[1][np.diag_indices(n_system)] = rng.uniform(0.3, 0.8, n_system) position = np.argsort(rng.permutation(n_system)) placed = 0 while placed < n_links: cause, effect = (int(i) for i in rng.choice(n_system, 2, replace=False)) lag = _lag(rng, tau_max) if lag == 0 and position[cause] > position[effect]: cause, effect = effect, cause if weights[:, effect, cause].any() or weights[:, cause, effect].any(): continue weights[lag, effect, cause] = rng.uniform(0.5, 0.9) placed += 1 return weights def _stationary(weights: np.ndarray) -> bool: """Whether the reduced form of ``weights`` has spectral radius below one.""" n = weights.shape[1] mixing = np.linalg.inv(np.eye(n) - weights[0]) blocks = [mixing @ block for block in weights[1:]] top = np.hstack(blocks) lower = np.eye(n * (len(blocks) - 1), n * len(blocks)) return bool(np.abs(np.linalg.eigvals(np.vstack([top, lower]))).max() < 1)
[docs] def sample_context_series( n_system: int = 5, n_time: int = 2, n_space: int = 1, *, n_datasets: int = 10, n_steps: int = 100, tau_max: int = 2, n_links: int | None = None, observed: float = 1.0, seed: int = 0, ) -> ContextSeries: """Draw linear time series from ``n_datasets`` datasets with shared contexts. Each system variable has a link to its own past with weight in ``[0.3, 0.8]``. ``n_links`` further links, ``n_system`` by default, and one link from each context, into distinct system variables, have weights in ``[0.5, 0.9]``; half are at lag ``0`` and the rest uniform in ``1`` to ``tau_max``, and a space context's is at lag ``0``. The noise is standard normal. Each system variable is rescaled to variance one across the datasets, so its variance does not tell the causal order. ``observed`` is the fraction of the contexts the datasets hold, the rest chosen at random. Raises :class:`ValueError` when there are more contexts than system variables or more links than pairs of them, or when no draw of the weights is stationary. """ n_contexts = n_time + n_space if n_contexts > n_system: raise ValueError(f"at most {n_system} contexts, one per system variable, got {n_contexts}") pairs = n_system * (n_system - 1) // 2 n_links = n_system if n_links is None else n_links if n_links > pairs: raise ValueError(f"at most {pairs} links between {n_system} variables, got {n_links}") rng = np.random.default_rng(seed) for _ in range(_DRAWS): weights = _system_weights(n_system, n_links, tau_max, rng) if _stationary(weights): break else: raise ValueError(f"no stationary model in {_DRAWS} draws; lower n_links or tau_max") targets = rng.permutation(n_system)[:n_contexts].tolist() lags = [_lag(rng, tau_max) for _ in range(n_time)] + [0] * n_space strengths = rng.uniform(0.5, 0.9, n_contexts) shown = rng.permutation(n_contexts)[: round(observed * n_contexts)].tolist() steps = n_steps + _BURN_IN time_values = rng.standard_normal((steps, n_time)) space_values = rng.standard_normal((n_datasets, n_space)) mixing = np.linalg.inv(np.eye(n_system) - weights[0]) series = np.zeros((steps, n_datasets, n_system)) for t in range(steps): drive = rng.standard_normal((n_datasets, n_system)) for lag in range(1, tau_max + 1): if t >= lag: drive += series[t - lag] @ weights[lag].T for k in range(n_time): if t >= lags[k]: drive[:, targets[k]] += strengths[k] * time_values[t - lags[k], k] for k in range(n_space): drive[:, targets[n_time + k]] += strengths[n_time + k] * space_values[:, k] series[t] = drive @ mixing.T series = series[_BURN_IN:] series /= series.reshape(-1, n_system).std(axis=0) time_shown = [k for k in range(n_time) if k in shown] space_shown = [k for k in range(n_space) if n_time + k in shown] start = n_system + len(time_shown) time_columns = tuple(range(n_system, start)) space_columns = tuple(range(start, start + len(space_shown))) datasets = tuple( np.column_stack( [ series[:, m], time_values[_BURN_IN:, time_shown], np.tile(space_values[m, space_shown], (n_steps, 1)), ] ) for m in range(n_datasets) ) column = { **{k: time_columns[i] for i, k in enumerate(time_shown)}, **{n_time + k: space_columns[i] for i, k in enumerate(space_shown)}, } return ContextSeries( datasets=datasets, time_contexts=time_columns, space_contexts=space_columns, links=frozenset( (int(cause), int(effect), int(lag)) for lag, effect, cause in np.argwhere(weights != 0) ), context_links=frozenset( (column[k], lags[k], targets[k]) for k in range(n_contexts) if k in column ), hidden_links=frozenset((lags[k], targets[k]) for k in range(n_contexts) if k not in column), )