"""Mixed networks: categorical and continuous variables in a conditional linear Gaussian network.
:func:`sample_mixed` draws a DAG and makes its first variables in causal order categorical, so no
categorical variable has a continuous parent, as a conditional linear Gaussian network requires. A
categorical variable has a conditional probability table, one Dirichlet-distributed row per
configuration of its parents, as in :func:`~andrey.data.sample_discrete`. A continuous variable is a
linear function of its continuous parents, plus a shift for each categorical parent's category, plus
standard Gaussian noise. The data are the input of the mixed-data tests and scores
(``indep_test="degen_gauss"`` or ``"cond_gauss"``, ``score_func="local_score_degen_gauss"`` or
``"local_score_cond_gauss"``).
As in :mod:`andrey.data.scm`, the graph, the parameters, and the samples each come from their own
stream of the seed, so a change to one leaves the others' draws unchanged.
"""
from __future__ import annotations
import numpy as np
import andrey
from .dataset import CausalDataset, QAReport, SCMParams
from .discrete import _parents_of
from .graphs import dag_truth
from .scm import (
_GRAPH,
_NOISE,
_SEED_SCHEME,
_WEIGHTS,
_column_order,
_component_rng,
named_graph,
)
__all__ = ["sample_mixed"]
# Magnitudes of a continuous parent's weight and of a categorical parent's shifts.
_WEIGHT_RANGE = (0.5, 2.0)
[docs]
def sample_mixed(
*,
graph: str = "erdos_renyi",
d: int,
n: int,
seed: int,
density: float = 2.0,
categorical_share: float = 0.5,
categories: tuple[int, int] = (2, 3),
concentration: float = 1.0,
shuffle: bool = True,
) -> CausalDataset:
"""Sample a conditional linear Gaussian network of categorical and continuous variables.
Parameters
----------
graph : {"erdos_renyi", "scale_free", "small_world", "hub"}, default="erdos_renyi"
The random graph the DAG is drawn from, as for :func:`~andrey.data.sample_scm`.
d : int
Node count.
n : int
Sample count.
seed : int
Root seed.
density : float, default=2.0
Mean total degree (``2 * |E| / d``) of the graph.
categorical_share : float, default=0.5
The share of the variables that are categorical, from ``0`` to ``1``: the first
``round(categorical_share * d)`` in causal order.
categories : tuple of int, default=(2, 3)
The least and the most categories a categorical variable has; each one's count is drawn
uniformly between them, both included. The least is at least 2.
concentration : float, default=1.0
Concentration of the symmetric Dirichlet each table row is drawn from, as for
:func:`~andrey.data.sample_discrete`.
shuffle : bool, default=True
Shuffle the columns with a permutation from the seed's own column stream, and relabel the
true graph, the categorical columns, and the parameters to match, as for
:func:`~andrey.data.sample_scm`. ``provenance["column_order"][j]`` is the index column
``j`` has with ``shuffle=False``.
Returns
-------
CausalDataset
``data``, shape ``(n, d)``, holds the category codes ``0..k-1`` of the categorical
variables and the values of the continuous ones; ``graph`` is the true DAG.
``provenance["config"]["categorical"]`` lists the categorical columns and
``provenance["config"]["categories"]`` their numbers of categories, in the same order.
``params.weights`` holds each edge's weight into a continuous variable: the coefficient of
a continuous parent, or the largest shift of a categorical one; an edge into a categorical
variable has ``nan``, its table being its parameters. ``params.noise_scales`` is ``1`` for
a continuous variable and ``nan`` for a categorical one.
Raises
------
ValueError
If ``categorical_share`` is not in ``[0, 1]``, ``categories`` is not a pair with
``2 <= least <= most``, ``concentration`` is not positive, or ``graph`` names no graph.
Notes
-----
A continuous variable ``x`` with continuous parents ``p`` and categorical parents ``c`` is
``x = sum_p w_p * p + sum_c s_c[category of c] + e``, with ``e`` standard Gaussian. Each
``|w_p|`` is uniform on ``[0.5, 2]`` with a random sign. The shifts ``s_c`` of a parent with
``k`` categories are ``k`` evenly spaced values from ``-m`` to ``m``, in a random order, with
``m`` uniform on ``[0.5, 2]``, so every category moves ``x`` by a different amount.
Examples
--------
>>> from andrey.data import sample_mixed
>>> ds = sample_mixed(d=6, n=200, seed=0)
>>> ds.data.shape, ds.graph.kind, ds.provenance["config"]["categorical"]
((200, 6), 'dag', [1, 2, 5])
"""
whole = len(categories) == 2 and all(
isinstance(k, int | np.integer) and not isinstance(k, bool) for k in categories
)
low, high = (int(k) for k in categories) if whole else (0, 0)
if not 2 <= low <= high:
raise ValueError(
f"categories must be (least, most) with 2 <= least <= most, got {categories}"
)
if not 0.0 <= categorical_share <= 1.0:
raise ValueError(f"categorical_share must be from 0 to 1, got {categorical_share}")
if not (np.isfinite(concentration) and concentration > 0):
raise ValueError(f"concentration must be finite and positive, got {concentration}")
spec = named_graph(graph, d, density)
root = np.random.SeedSequence(seed)
_, draw = spec.materialize(_component_rng(root, _GRAPH))
is_categorical = np.zeros(draw.d, dtype=bool)
is_categorical[draw.topo_order[: round(categorical_share * draw.d)]] = True
rng = _component_rng(root, _WEIGHTS)
cards = np.where(is_categorical, rng.integers(low, high + 1, size=draw.d), 0)
parents = _parents_of(draw)
tables: dict[int, np.ndarray] = {}
shifts: dict[tuple[int, int], np.ndarray] = {}
weights = np.full(draw.parents.shape[0], np.nan)
for c in range(draw.d):
if is_categorical[c]:
rows = int(np.prod(cards[parents[c]], dtype=np.int64))
tables[c] = rng.dirichlet(np.full(int(cards[c]), float(concentration)), size=rows)
continue
for k in np.flatnonzero(draw.children == c).tolist():
p = int(draw.parents[k])
magnitude = rng.uniform(*_WEIGHT_RANGE)
if is_categorical[p]:
shifts[(p, c)] = rng.permutation(np.linspace(-magnitude, magnitude, cards[p]))
weights[k] = magnitude
else:
weights[k] = magnitude * rng.choice(np.array([-1.0, 1.0]))
noise = _component_rng(root, _NOISE)
uniforms = noise.random((n, draw.d))
gaussians = noise.standard_normal((n, draw.d))
values = np.zeros((n, draw.d))
for c in draw.topo_order.tolist():
if is_categorical[c]:
row = np.zeros(n, dtype=np.int64)
for p in parents[c].tolist():
row = row * cards[p] + values[:, p].astype(np.int64)
cdf = np.cumsum(tables[c][row], axis=1)
values[:, c] = np.minimum((uniforms[:, [c]] > cdf).sum(axis=1), cards[c] - 1)
continue
column = gaussians[:, c].copy()
for k in np.flatnonzero(draw.children == c).tolist():
p = int(draw.parents[k])
if is_categorical[p]:
column += shifts[(p, c)][values[:, p].astype(np.int64)]
else:
column += weights[k] * values[:, p]
values[:, c] = column
order = _column_order(root, draw.d, shuffle) # the drawn node of each column
edges = np.argsort(order)[np.stack([draw.parents, draw.children], axis=1)]
is_categorical, cards = is_categorical[order], cards[order]
params = SCMParams(
edges=edges,
weights=weights,
noise_scales=np.where(is_categorical, np.nan, 1.0),
functional="conditional_linear_gaussian",
noise="gaussian",
)
columns = np.flatnonzero(is_categorical)
provenance = {
"root_entropy": str(root.entropy),
"spawn_key": list(root.spawn_key),
"seed_scheme": _SEED_SCHEME,
"column_order": order.tolist(),
"config": {
"graph": {
"model": spec.name,
"num_nodes": draw.d,
"density": spec.density,
"params": spec.params,
},
"functional": "conditional_linear_gaussian",
"categorical": columns.tolist(),
"categories": cards[columns].tolist(),
"concentration": float(concentration),
"n": int(n),
"shuffle": bool(shuffle),
},
"versions": {"andrey": andrey.__version__, "numpy": np.__version__},
}
return CausalDataset(
data=np.take(values, order, axis=1),
graph=dag_truth(edges[:, 0], edges[:, 1], draw.d),
report=QAReport(scale="raw"),
provenance=provenance,
params=params,
)