Source code for mantispy.pp._chatterjee

from __future__ import annotations

import numpy as np
from anndata import AnnData
from scipy.stats import rankdata

from mantispy._core._corr import CHUNK_BYTES
from mantispy._core._reduce import get_matrix, group_codes
from mantispy._core.logging import get_logger
from mantispy._core.mutation import inplace_or_copy


def _complete_columns(values: np.ndarray) -> np.ndarray:
    """Which columns hold a finite value in every row, reduced one column block at a time."""
    n_obs, n_vars = values.shape
    out = np.empty(n_vars, dtype=bool)
    width = max(int(CHUNK_BYTES / max(n_obs, 1) / values.itemsize), 1)
    for start in range(0, n_vars, width):
        block = slice(start, start + width)
        out[block] = np.isfinite(values[:, block]).all(axis=0)
    return out


def _xi(x: np.ndarray, values: np.ndarray, m: int, seed: int) -> np.ndarray:
    """Chatterjee's xi between ``x`` and every column of ``values``, all of which must be finite.

    The statistic of :cite:t:`Lin_2022`, centred on its exact mean under independence and scaled by the ``sum(l * (n - l))`` of :cite:t:`Chatterjee_2020`, which is how Chatterjee handles ties in ``y``.
    Without ties in ``y`` it is Lin and Han's statistic exactly.
    """
    n = x.size
    shuffled = np.random.default_rng(seed).permutation(n)
    order = shuffled[np.argsort(x[shuffled], kind="stable")]

    # As integers: rankdata keeps float32 input float32, whose sums are inexact above 2**24.
    ranks = rankdata(values[order], method="max", axis=0).astype(np.int64)
    drop = sum(np.maximum(ranks[:-step] - ranks[step:], 0).sum(axis=0) for step in range(1, m + 1))
    # The scale is zero only for a constant column, which has no xi.
    below = rankdata(values, method="min", axis=0).astype(np.float64) - 1
    with np.errstate(invalid="ignore"):
        scaled = drop / (below * (n - below)).sum(axis=0) * n * (n - 1)
    return -2 + (3 * n * m - scaled) / (n * m + m * (m + 1) / 4)


def chatterjee_xi(x: np.ndarray, y: np.ndarray, m: int = 1, seed: int = 0, min_finite: int = 40) -> np.ndarray:
    """Chatterjee's xi between ``x`` and every column of ``y``.

    Args:
        x: The variable the others are tested against, such as a group code, a dose or a covariate.
        y: One column, or a matrix of them.
            Every column is scored against ``x`` in one pass.
        m: Right nearest neighbors, as in :cite:t:`Lin_2022`.
            Larger ``m`` has the same limit under dependence and a lower noise floor under independence, so a fixed threshold is more reliable.
        seed: Seed for breaking ties in ``x``.
        min_finite: Fewest finite pairs a column may be scored on, raised to ``m + 2`` when it is below that.
            Under independence xi has standard deviation ``sqrt(2 / (5 * n))``, which at 40 pairs equals the 0.1 threshold :func:`feature_select_chatterjee` selects on, so a column measured fewer times than this cannot be told from noise.

    Returns:
        One xi per column of ``y``, NaN for a constant column or one with fewer than ``min_finite`` finite pairs.

    Raises:
        ValueError: If ``m`` is below 1.

    Notes:
        Ties in ``x`` are broken at random, as the coefficient requires.
        A group label is almost all ties, and breaking them by row order would score the row order as structure.
        Ties in ``y`` are handled as :cite:t:`Chatterjee_2020` handles them, without randomness: tied values share a rank.
        A zero-inflated Zernike, Granularity or RadialDistribution column is mostly ties, and breaking them at random scores one that is a step function of ``x`` at 0.51 instead of 0.99.

        xi is defined on complete pairs, so each column is scored on the rows where both it and ``x`` are finite, and two columns missing different rows are scored on different subsets.
        Ranking a column that still holds NaN sorts the missing rows last, which turns a feature that is merely unmeasured in one group into a step function of the group and scores it as dependence.
        How much of a feature is missing is a question for :func:`~mantispy.pp.feature_select` and its ``drop_na_columns`` operation; xi reports on the part that was measured.
    """
    if m < 1:
        raise ValueError(f"m must be at least 1, got {m}")
    values = np.atleast_2d(y.T).T if y.ndim > 1 else y[:, None]
    finite_x = np.isfinite(x)
    floor = max(min_finite, m + 2)
    complete = _complete_columns(values) if finite_x.all() else np.zeros(values.shape[1], dtype=bool)

    if complete.all():
        return _xi(x, values, m, seed) if x.size >= floor else np.full(values.shape[1], np.nan)

    scores = np.full(values.shape[1], np.nan)
    if complete.any() and x.size >= floor:
        scores[complete] = _xi(x, values[:, complete], m, seed)
    for column in np.flatnonzero(~complete):
        rows = np.flatnonzero(finite_x & np.isfinite(values[:, column]))
        if rows.size >= floor:
            scores[column] = _xi(x[rows], values[rows, column][:, None], m, seed)[0]
    return scores


[docs] @inplace_or_copy() def feature_select_chatterjee( adata: AnnData, by: str = "Metadata_Perturbation", threshold: float = 0.1, m: int = 1, seed: int = 0, min_finite: int = 40, key_added: str = "selected_chatterjee", copy: bool = False, ) -> AnnData | None: """Keep features whose values depend on the group, monotonically or otherwise. Args: adata: Object to select features on. by: ``obs`` column the features are tested against. threshold: Keep features scoring above this. xi is near zero under independence and approaches one when the feature is a deterministic function of the group, so the threshold is comparable across datasets in a way a correlation cutoff is not. m: Right nearest neighbors :cite:p:`Lin_2022`. ``m=1`` is the coefficient of :cite:t:`Chatterjee_2020` up to a term of order ``1/n``; larger values lower the noise floor without changing what the statistic converges to. seed: Seed for breaking ties between rows of the same group. min_finite: Fewest finite values a feature may be scored on. A feature measured fewer times than this scores NaN, which is above no threshold and so is never selected. key_added: Name of the boolean ``var`` column written. copy: Return a modified copy instead of mutating in place. Returns: ``None``, or the modified copy. Writes ``var[key_added]`` and the statistic itself to ``var["chatterjee_xi"]``, which is NaN for a feature that is constant or too sparsely measured to score. Raises: ValueError: If ``by`` has a single group, so that no feature can depend on it. Notes: Run it after :func:`~mantispy.pp.feature_select`, which drops redundant or unmeasurable features; this keeps the features that carry information about the perturbation. Subset with ``mt.pp.subset_features(adata, key="selected_chatterjee")``. A feature is scored on the rows where it was measured, so one that is missing in a whole group is scored against the groups that do have it rather than against the pattern of what is missing. xi reaches one only for a noiseless function of the group, so real values are much lower and the default threshold of ``0.1`` is permissive. Check the distribution in ``var["chatterjee_xi"]`` before relying on a fixed cutoff. With shuffled group labels xi collapses toward zero while the real values barely change, so the score reflects dependence on the group rather than noise. ``m=1`` is the default because the threshold was calibrated there. scmorph uses ``m=5``, and on input free of ties the two implementations agree to 1e-9. scmorph breaks ties in ``y`` at random, so on tied input the two differ. """ codes, keys = group_codes(adata, by) if len(keys) < 2: raise ValueError(f"{by!r} has one group, so no feature can depend on it") scores = chatterjee_xi(codes.astype(float), get_matrix(adata), m=m, seed=seed, min_finite=min_finite) adata.var["chatterjee_xi"] = scores # NaN is above no threshold, so a feature that cannot be scored drops out here. selected = scores > threshold adata.var[key_added] = selected get_logger().info( "chatterjee kept %d of %d features, %d constant or too sparsely measured to score", int(selected.sum()), adata.n_vars, int(np.isnan(scores).sum()), ) return None