Source code for mantispy.pp._sample
"""Stratified subsampling.
A screen of a million cells by four thousand features is 16 GB of float32 before any
transform allocates its output. Exploring on a representative sample and confirming on
the full data keeps memory use manageable.
"""
from __future__ import annotations
from collections.abc import Sequence
import numpy as np
from anndata import AnnData
from mantispy._core._reduce import group_codes
from mantispy._core._utils import as_frame, get_logger, record_params
[docs]
def downsample(
adata: AnnData,
n_per_group: int = 500,
groupby: Sequence[str] | str | None = ("Metadata_Plate", "Metadata_Well"),
stratify: str | None = None,
seed: int = 0,
) -> AnnData:
"""Return at most ``n_per_group`` rows from each group.
Args:
adata: Object to sample from. Never modified.
n_per_group: Cap per group. Groups smaller than this are kept whole, so groups are capped
but not balanced.
groupby: Columns defining a group. The default caps each well, so every well is
represented instead of the densest wells filling the sample.
stratify: Keep this column's proportions inside each group, so a rare perturbation is not
lost to the sampling.
seed: Seed for reproducibility.
Returns:
A new object holding the sampled rows in their original order, with the call recorded
in ``uns["mantispy"]["params"]``.
"""
if n_per_group < 1:
raise ValueError(f"n_per_group must be at least 1, got {n_per_group}")
codes, keys = group_codes(adata, groupby)
generator = np.random.default_rng(seed)
labels = as_frame(adata.obs)[stratify].astype(str).to_numpy() if stratify else None
chosen: list[np.ndarray] = []
for index in range(len(keys)):
rows = np.flatnonzero(codes == index)
if rows.size <= n_per_group:
chosen.append(rows)
elif labels is None:
chosen.append(generator.choice(rows, size=n_per_group, replace=False))
else:
inside = labels[rows]
picked = [
generator.choice(
members := rows[inside == label],
size=min(max(1, round(n_per_group * members.size / rows.size)), members.size),
replace=False,
)
for label in np.unique(inside)
]
# Rounding each share up to at least one can overshoot the cap. Trimming the
# tail of the concatenation would always drop the same labels, so trim at
# random instead.
taken = np.concatenate(picked)
chosen.append(taken if taken.size <= n_per_group else generator.choice(taken, n_per_group, replace=False))
keep = np.sort(np.concatenate(chosen)) if chosen else np.array([], dtype=int)
get_logger().info("downsample kept %d of %d rows in %d group(s)", keep.size, adata.n_obs, len(keys))
result = adata[keep].copy()
record_params(
result,
"downsample",
{"n_per_group": n_per_group, "groupby": groupby, "stratify": stratify, "seed": seed},
)
return result