Source code for cellucid.subsample

"""Subsampling: which cells of a dataset a subsampled opening keeps.

One algorithm family, specified once (docs: *Subsampling specification*) and
implemented the same in the web app (``assets/js/data/subsample.js``, run while
a dataset opens), here, and in R (``cellucid_subsample_cells()``); the shared
conformance fixture ``subsample_conformance.json`` holds the three to the same
cells. :func:`subsample_cells` gives the cells Cellucid opens for a request, as
original positions (rows of ``obs``), so a notebook can take the same cells the
viewer shows, or choose cells the same way before opening.

Every hash is computed on uint32 values with numpy's wrapping uint32
arithmetic; every float operation of ``spatial`` is one IEEE 754 binary64
operation in the specified order.
"""

from __future__ import annotations

import math
import numbers
import warnings
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Literal

import numpy as np

from ._plural import plural

if TYPE_CHECKING:
    from numpy.typing import NDArray

__all__ = [
    "SUBSAMPLE_METHODS",
    "SUBSAMPLE_SPEC_VERSION",
    "cell_set_digest",
    "count_from_fraction",
    "decode_cell_set",
    "encode_cell_set",
    "subsample_cells",
]

#: The subsampling methods.
SUBSAMPLE_METHODS = ("uniform", "blocks", "stratified", "spatial")

#: The version of the subsampling specification this module follows: a
#: Cellucid-ready file in priority order records it
#: (``uns['cellucid']['priority']['specVersion']``), since its row order is
#: this specification's ``uniform`` rank.
SUBSAMPLE_SPEC_VERSION = 1

_M32 = 0xFFFFFFFF
_U32 = np.uint32


def _fmix32(h: NDArray[np.uint32]) -> NDArray[np.uint32]:
    h = h ^ (h >> _U32(16))
    h = h * _U32(0x85EBCA6B)
    h = h ^ (h >> _U32(13))
    h = h * _U32(0xC2B2AE35)
    return h ^ (h >> _U32(16))


def _fmix32_int(h: int) -> int:
    h &= _M32
    h ^= h >> 16
    h = (h * 0x85EBCA6B) & _M32
    h ^= h >> 13
    h = (h * 0xC2B2AE35) & _M32
    return h ^ (h >> 16)


def _seed_key(seed: int) -> int:
    return _fmix32_int(seed ^ 0x9E3779B9)


def _ranks(positions: NDArray[Any], seed: int) -> NDArray[np.uint32]:
    with np.errstate(over="ignore"):
        return _fmix32(positions.astype(np.uint32) ^ _U32(_seed_key(seed)))


def _rank_argsort(ranks: NDArray[np.uint32]) -> NDArray[np.intp]:
    """The indices that sort ``ranks`` (which never tie: the one order any
    sort gives), by two stable counting passes over their 16-bit halves --
    linear time, where numpy's stable sort of 32-bit keys compares (10M
    cells: 0.4 s against 6 s)."""
    low = np.argsort((ranks & _U32(0xFFFF)).astype(np.uint16), kind="stable")
    high = np.argsort((ranks[low] >> _U32(16)).astype(np.uint16), kind="stable")
    return low[high]


def _require_uint32(value: Any, label: str, minimum: int = 0) -> int:
    if isinstance(value, bool) or not isinstance(value, int | np.integer):
        raise TypeError(f"{label} must be an integer; got {value!r}")
    number = int(value)
    if not minimum <= number <= _M32:
        raise ValueError(f"{label} must be an integer in [{minimum}, 2**32 - 1]; got {number}")
    return number


def count_from_fraction(fraction: float, n_obs: int) -> int:
    """The subsample size a fraction of ``n_obs`` cells stands for, exactly.

    The fraction, any real number in ``(0, 1]`` (a NumPy float too, widened
    exactly), is taken as the rational its shortest round-trip decimal names
    (``repr``, as the web app's ``String(x)`` and R's, found in C with a
    correctly rounded conversion), and the size is ``round-half-up(n_obs * fraction)``
    in integer arithmetic, at least 1 and at most ``n_obs``.
    """
    from fractions import Fraction

    n_obs = _require_uint32(n_obs, "n_obs")
    # Any real number (a NumPy float32 too, widened exactly), never a bool.
    if isinstance(fraction, (bool, np.bool_)) or not isinstance(fraction, numbers.Real):
        raise TypeError(f"fraction must be a real number; got {fraction!r}")
    value = float(fraction)
    if not math.isfinite(value) or not 0 < value <= 1:
        raise ValueError(f"fraction must be in (0, 1]; got {fraction!r}")
    share = Fraction(repr(value))
    k = (2 * n_obs * share.numerator + share.denominator) // (2 * share.denominator)
    return min(n_obs, max(1, k))


def _lowest(positions: NDArray[Any], k: int, seed: int) -> NDArray[np.int64]:
    """The ``k`` positions of lowest rank, ascending (ranks never tie)."""
    if k >= positions.size:
        return np.sort(positions.astype(np.int64))
    ranks = _ranks(positions, seed)
    chosen = np.argpartition(ranks, k - 1)[:k]
    return np.sort(positions[chosen].astype(np.int64))


def _uniform(n_obs: int, k: int, seed: int) -> NDArray[np.int64]:
    return _lowest(np.arange(n_obs, dtype=np.uint32), k, seed)


def _blocks(n_obs: int, k: int, seed: int, block_size: int) -> NDArray[np.int64]:
    count = -(-n_obs // block_size)
    take = min(count, -(-k // block_size) + 1)
    chosen = _lowest(np.arange(count, dtype=np.uint32), take, seed)
    order = chosen[_rank_argsort(_ranks(chosen, seed))]
    pieces = []
    left = k
    for block in order.tolist():
        if left == 0:
            break
        start = block * block_size
        used = min(block_size, n_obs - start, left)
        pieces.append((start, used))
        left -= used
    pieces.sort()
    return np.concatenate([np.arange(start, start + used, dtype=np.int64) for start, used in pieces])


def _allocate(capacity: list[int], total: int, allocation: str) -> list[int]:
    if total == 0 or not capacity:
        return [0] * len(capacity)
    whole = sum(capacity)
    if total > whole:
        raise ValueError(f"cannot allocate {total} {plural(total, 'cell')} over {whole}")
    if allocation == "proportional":
        out = [total * c // whole for c in capacity]
        remainder = [total * c % whole for c in capacity]
        for s in sorted(range(len(capacity)), key=lambda s: (-remainder[s], s))[: total - sum(out)]:
            out[s] += 1
        return out
    level, used, remaining = 0, 0, len(capacity)
    for c in sorted(capacity):
        step = c - level
        if used + step * remaining > total:
            break
        used += step * remaining
        level = c
        remaining -= 1
    if remaining > 0:
        level += (total - used) // remaining
    out = [min(c, level) for c in capacity]
    left = total - sum(out)
    for s, c in enumerate(capacity):
        if left == 0:
            break
        if c > level:
            out[s] += 1
            left -= 1
    return out


def _split_over_strata(strata: NDArray[np.int64], n_strata: int, k: int, seed: int, allocation: str, minimum: int) -> NDArray[np.int64]:
    sizes = np.bincount(strata, minlength=n_strata)
    occupied = [s for s in range(n_strata) if sizes[s] > 0]
    base = [min(minimum, int(sizes[s])) for s in occupied]
    capacity = [int(sizes[s]) - b for s, b in zip(occupied, base, strict=False)]
    if sum(base) > k:
        raise ValueError(
            f"a subsample of {k} {plural(k, 'cell')} cannot hold {minimum} {plural(minimum, 'cell')} of each of "
            f"{len(occupied)} {plural(len(occupied), 'category', 'categories')} ({sum(base)} {plural(sum(base), 'cell')})"
        )
    extra = iter(_allocate([c for c in capacity if c > 0], k - sum(base), allocation))
    order = np.argsort(strata, kind="stable")
    bounds = np.concatenate([[0], np.cumsum(sizes)])
    kept = []
    for s, b, c in zip(occupied, base, capacity, strict=False):
        quota = b + (next(extra) if c > 0 else 0)
        if quota:
            kept.append(_lowest(order[bounds[s]:bounds[s + 1]].astype(np.uint32), quota, seed))
    return np.sort(np.concatenate(kept)) if kept else np.zeros(0, dtype=np.int64)


def _stratified(codes: NDArray[Any], n_categories: int, k: int, seed: int, allocation: str, minimum: int) -> NDArray[np.int64]:
    values = np.asarray(codes, dtype=np.float64)
    missing = np.isnan(values) | (values < 0)
    if np.any(values[~missing] >= n_categories) or np.any(values[~missing] != np.floor(values[~missing])):
        raise ValueError(f"category codes must be integers in [0, {n_categories}) or missing")
    strata = np.where(missing, n_categories, np.nan_to_num(values, nan=n_categories)).astype(np.int64)
    return _split_over_strata(strata, n_categories + 1, k, seed, allocation, minimum)


def grid_boxes(coordinates: NDArray[Any], grid_size: int) -> NDArray[np.int64]:
    """The grid box of each cell of an embedding (``spatial``), per the specification.

    Coordinates are taken as float32, then each axis' box is
    ``floor((x - min) * grid_size / (max - min))`` in binary64 (``grid_size - 1``
    at the maximum, 0 on an axis of one value); a cell with a non-finite
    coordinate is in box ``grid_size ** dimensions``.
    """
    values = np.asarray(coordinates, dtype=np.float32)
    if values.ndim == 1:
        values = values[:, None]
    dimensions = values.shape[1]
    if dimensions not in (1, 2, 3):
        raise ValueError("a spatial subsample needs a 1-, 2- or 3-dimensional embedding")
    wide = values.astype(np.float64)
    finite = np.all(np.isfinite(wide), axis=1)
    boxes = np.zeros(values.shape[0], dtype=np.int64)
    scale = 1
    for d in range(dimensions):
        column = wide[finite, d]
        low = column.min() if column.size else 0.0
        high = column.max() if column.size else 0.0
        width = high - low
        b: NDArray[np.int64] = np.zeros(values.shape[0], dtype=np.int64)
        if width > 0:
            with np.errstate(invalid="ignore"):
                scaled = (wide[:, d] - low) * float(grid_size)
                b = np.where(finite, np.minimum(np.floor(scaled / width), grid_size - 1), 0).astype(np.int64)
        boxes += b * scale
        scale *= grid_size
    boxes[~finite] = grid_size**dimensions
    return boxes


def _spatial(coordinates: NDArray[Any], k: int, seed: int, grid_size: int) -> NDArray[np.int64]:
    boxes = grid_boxes(coordinates, grid_size)
    occupied, strata = np.unique(boxes, return_inverse=True)
    return _split_over_strata(strata.astype(np.int64), occupied.size, k, seed, "balanced", 0)


def _n_obs_of(data: Any) -> int:
    """The cells of ``data``, whose rows are each its own original position.

    An object in a Cellucid-ready file's verified stored order
    (``uns['cellucid']['priority']``, :func:`~cellucid.prepare_data._priority.stored_order`)
    is refused, as ``pull``, ``subset`` and a session refuse it: the cells are
    chosen by original position, and a row of such an object is not one, so
    ``adata[kept]`` would take other cells than the viewer's. A record that
    no longer describes the cells leaves each row its own original position,
    as the web app reads such a file, with a warning.
    """
    if isinstance(data, int | np.integer) and not isinstance(data, bool):
        return _require_uint32(data, "n_obs")
    n_obs = getattr(data, "n_obs", None)
    if n_obs is None:
        raise TypeError("data must be an AnnData, a MuData or a cell count")
    if hasattr(data, "uns") and hasattr(data, "obs"):
        from .prepare_data._priority import stored_order_of

        stored = stored_order_of(data)
        if stored.notice is not None:
            warnings.warn(stored.notice, UserWarning, stacklevel=3)
        if stored.positions is not None:
            raise ValueError(
                "data holds its cells in a Cellucid-ready file's stored order (uns['cellucid']['priority']), "
                "and a subsample names cells by their original position, so data[kept] would take other "
                "cells than the viewer opens. Put them back first: data = cellucid.restore_original_order(data)."
            )
    return _require_uint32(n_obs, "n_obs")


def _codes_of(data: Any, groupby: Any) -> tuple[NDArray[Any], int]:
    if isinstance(groupby, str):
        if not hasattr(data, "obs"):
            raise TypeError("groupby names an obs column, so data must be an AnnData or a MuData")
        if groupby not in data.obs.columns:
            raise KeyError(f"obs has no column {groupby!r}")
        column = data.obs[groupby]
        if str(column.dtype) == "category":
            return np.asarray(column.cat.codes, dtype=np.int64), len(column.cat.categories)
        # Plain values (text): categories in code-point order, as below.
        groupby = column.to_numpy()
    import pandas as pd

    categorical = pd.Categorical(groupby)
    return np.asarray(categorical.codes, dtype=np.int64), len(categorical.categories)


def _coordinates_of(data: Any, basis: Any, columns: Sequence[int]) -> NDArray[Any]:
    if isinstance(basis, str):
        if not hasattr(data, "obsm"):
            raise TypeError("basis names an obsm key, so data must be an AnnData or a MuData")
        if basis not in data.obsm:
            raise KeyError(f"obsm has no key {basis!r}")
        values = np.asarray(data.obsm[basis])
    else:
        values = np.asarray(basis)
    if values.ndim == 1:
        values = values[:, None]
    outside = [c for c in columns if c >= values.shape[1]]
    if outside:
        raise ValueError(
            f"columns {list(columns)} name column {outside[0]}, and the embedding has {values.shape[1]} "
            f"{plural(values.shape[1], 'column')}"
        )
    return values[:, list(columns)]


[docs] def subsample_cells( data: Any, n_cells: int | None = None, *, fraction: float | None = None, method: Literal["uniform", "blocks", "stratified", "spatial"] = "uniform", seed: int = 0, groupby: Any = None, allocation: Literal["proportional", "balanced"] = "proportional", min_per_group: int = 0, block_size: int | None = None, basis: Any = None, columns: Sequence[int] = (0, 1), grid_size: int = 32, ) -> NDArray[np.int64]: """The cells a Cellucid subsample keeps, as original positions. The same request opens the same cells in the web app (Open data, *Cells to open*) and in R (``cellucid_subsample_cells()``): every implementation follows one specification and is checked against one conformance fixture. Parameters ---------- data An AnnData, a MuData, or the cell count ``n_obs`` itself (enough for ``uniform`` and ``blocks``). Its rows are the cells' original positions: an object read from a Cellucid-ready file in priority order (``anndata.read_zarr`` of ``prepare()``'s output) is refused until :func:`~cellucid.restore_original_order` puts it back, as ``pull``, ``subset`` and a session refuse it. n_cells How many cells to keep; or give ``fraction``. A count of every cell or more keeps every cell. fraction The share of cells to keep, in ``(0, 1]``; the count is ``n_obs × fraction`` rounded half up, at least 1, computed exactly from the fraction's shortest decimal text (``repr``), never as a binary64 product, as the web app and R compute it. Any count from 1 is a selection here; the web app opens a subsample of at least 2 cells, since one cell has no coordinate variation to draw. method ``"uniform"`` (every cell alike), ``"blocks"`` (runs of ``block_size`` neighbouring cells in file order, kept together), ``"stratified"`` (split over the categories of ``groupby``), or ``"spatial"`` (split over the occupied boxes of a ``grid_size`` grid on ``basis``, balanced, so sparse regions keep their cells). seed A uint32. The same seed keeps the same cells; with ``uniform`` and ``blocks`` a smaller subsample is inside a larger one. groupby ``stratified``: an obs column name, or one value per cell. A categorical keeps its categories' order; plain values (text) become categories in code-point order, as in the web app and R. Cells without a category are one more stratum. allocation ``stratified``: ``"proportional"`` (largest remainder) or ``"balanced"`` (equal shares, water-filled). min_per_group ``stratified``: each category first gets this many cells (or all it has). block_size ``blocks``: cells per block. The web app derives it from the file's chunk layout and shows it; give that value to reproduce its cells. basis ``spatial``: an obsm key, or an array of coordinates (one row per cell). columns ``spatial``: the embedding columns used, one to three. grid_size ``spatial``: boxes per axis. Returns ------- numpy.ndarray The kept cells' positions (rows of ``obs``), ascending, int64. Examples -------- >>> kept = cellucid.subsample_cells(adata, fraction=0.1, seed=0) >>> small = adata[kept].copy() A file ``prepare()`` wrote holds its cells in priority order; put them back first: >>> adata = cellucid.restore_original_order(anndata.read_zarr("pbmc.zarr")) >>> kept = cellucid.subsample_cells(adata, fraction=0.1, seed=0) """ n_obs = _n_obs_of(data) seed = _require_uint32(seed, "seed") if method not in SUBSAMPLE_METHODS: raise ValueError(f"method must be one of {', '.join(SUBSAMPLE_METHODS)}; got {method!r}") if (n_cells is None) == (fraction is None): raise TypeError("give exactly one of n_cells and fraction") k = count_from_fraction(fraction, n_obs) if fraction is not None else _require_uint32(n_cells, "n_cells", minimum=1) if k >= n_obs: return np.arange(n_obs, dtype=np.int64) if method == "uniform": return _uniform(n_obs, k, seed) if method == "blocks": if block_size is None: raise TypeError("blocks needs block_size (the web app shows the one it used)") return _blocks(n_obs, k, seed, _require_uint32(block_size, "block_size", minimum=1)) if method == "stratified": if groupby is None: raise TypeError("stratified needs groupby") if allocation not in ("proportional", "balanced"): raise ValueError(f"allocation must be 'proportional' or 'balanced'; got {allocation!r}") codes, n_categories = _codes_of(data, groupby) if codes.shape[0] != n_obs: raise ValueError( f"groupby has {codes.shape[0]} {plural(codes.shape[0], 'value')} for {n_obs} {plural(n_obs, 'cell')}" ) return _stratified(codes, n_categories, k, seed, allocation, _require_uint32(min_per_group, "min_per_group")) if basis is None: raise TypeError("spatial needs basis") if not 1 <= len(columns) <= 3 or any(isinstance(c, bool) or not isinstance(c, (int, np.integer)) or c < 0 for c in columns): raise ValueError("spatial uses one to three embedding columns, each a non-negative column index") grid_size = _require_uint32(grid_size, "grid_size", minimum=1) if grid_size ** len(columns) >= 2**53: raise ValueError(f"grid_size**{len(columns)} boxes are more than are numbered exactly (2**53)") coordinates = _coordinates_of(data, basis, columns) if coordinates.shape[0] != n_obs: raise ValueError( f"basis has {coordinates.shape[0]} {plural(coordinates.shape[0], 'row')} for {n_obs} {plural(n_obs, 'cell')}" ) return _spatial(coordinates, k, seed, _require_uint32(grid_size, "grid_size", minimum=1))
def cell_set_digest(n_obs: int, kept: Sequence[int] | NDArray[Any] | None) -> str: """The cell-set digest of the specification: 16 lowercase hex digits. ``kept`` is the ascending original positions, or ``None`` for every cell. A session saved on a subsample records it (manifest ``cells.digest``). """ n_obs = _require_uint32(n_obs, "n_obs") positions = np.arange(n_obs, dtype=np.uint64) if kept is None else np.asarray(kept, dtype=np.uint64) k = int(positions.size) a = (0x811C9DC5 ^ n_obs) & _M32 b = (0x9E3779B9 ^ k) & _M32 for p in positions.tolist(): a = ((a ^ p) * 0x01000193) & _M32 x = (b ^ p) & _M32 b = ((((x << 13) | (x >> 19)) & _M32) * 5 + 0xE6546B64) & _M32 return f"{_fmix32_int(a ^ k):08x}{_fmix32_int(b ^ n_obs):08x}" def encode_cell_set(kept: Sequence[int] | NDArray[Any]) -> bytes: """A cell set as a session stores it: each gap between ascending positions minus one, LEB128.""" out = bytearray() previous = -1 for p in np.asarray(kept, dtype=np.int64).tolist(): if p <= previous: raise ValueError("a cell set lists positions strictly ascending") gap = p - previous - 1 previous = p while gap >= 0x80: out.append((gap & 0x7F) | 0x80) gap >>= 7 out.append(gap) return bytes(out) def decode_cell_set(data: bytes, n_obs: int) -> NDArray[np.int64] | None: """The positions of an encoded cell set (``None``: no bytes, every cell).""" n_obs = _require_uint32(n_obs, "n_obs") if len(data) == 0: return None positions = [] previous = -1 gap = shift = 0 for byte in data: gap |= (byte & 0x7F) << shift if byte & 0x80: shift += 7 if shift > 35: raise ValueError("a stored cell set is malformed") continue previous += gap + 1 if previous >= n_obs: raise ValueError(f"a stored cell set names a cell beyond the {n_obs} {plural(n_obs, 'cell')} of the dataset") positions.append(previous) gap = shift = 0 if shift: raise ValueError("a stored cell set is truncated") return np.asarray(positions, dtype=np.int64)