Source code for mantispy.tl._guide_activity

"""Score each guide for phenotypic activity against a reference control class."""

from __future__ import annotations

import numpy as np
from anndata import AnnData

from mantispy._core._reduce import get_matrix
from mantispy._core._stats import MAD_TO_SIGMA
from mantispy._core.frames import as_frame
from mantispy._core.logging import get_logger
from mantispy._core.mutation import inplace_or_copy


[docs] @inplace_or_copy() def guide_activity( adata: AnnData, *, reference: str, group: str = "Metadata_Gene", use_rep: str | None = None, layer: str | None = None, key_added: str = "guide_activity", copy: bool = False, ) -> AnnData | None: """Score each guide for how far it sits from a reference control class, as a one-sided p-value. This produces the per-guide score :func:`~mantispy.tl.aggregate_guides` reads: a one-sided p-value, small when the guide shows a phenotype. Each feature is standardized by the reference guides' median and median absolute deviation, so a guide's activity is the size of its standardized profile, how far it moves from the reference centre in control units. The p-value is the share of reference guides whose activity reaches the guide's or beyond, with one added to the count and the total, so it is one-sided and small for a strong phenotype. The reference class sets the scale and is not scored, so for :func:`~mantispy.tl.aggregate_guides` the reference here and the ``control`` null there must be two different control classes. A pooled screen that carries both intergenic and non-targeting guides can set the scale with one and keep the other as the null, so the calibration is not circular. Args: adata: One row per guide, with the class label in ``obs`` and the profiles in ``X`` (or `use_rep`/`layer`). reference: The value of `group` that marks the reference control guides, such as ``"intergenic"``. group: ``obs`` column holding the class label, the gene or control name per guide. use_rep: Read ``obsm[use_rep]`` instead of ``X``; cannot be combined with `layer`. layer: Read this layer instead of ``X``. key_added: ``obs`` column the per-guide p-value is written to. copy: Return a modified copy instead of mutating in place. Returns: ``None``, or the modified copy. Writes ``obs[key_added]``, the one-sided per-guide p-value, with the reference guides left missing since they set the scale rather than being scored. Raises: ValueError: `reference` is absent from ``obs[group]``, or every feature is constant across the reference guides. KeyError: `group` is not an ``obs`` column. Notes: A feature that does not vary across the reference guides carries no information and is dropped, so a profile of all-constant features cannot be scored. """ obs = as_frame(adata.obs) if group not in obs: raise KeyError(f"group={group!r} is not an obs column") labels = obs[group].astype(str).to_numpy() is_reference = labels == str(reference) if not is_reference.any(): raise ValueError(f"reference={reference!r} is absent from obs[{group!r}]") x = np.asarray(get_matrix(adata, layer=layer, use_rep=use_rep), dtype=np.float64) ref = x[is_reference] median = np.nanmedian(ref, axis=0) mad = MAD_TO_SIGMA * np.nanmedian(np.abs(ref - median), axis=0) varying = mad > 1e-9 if not varying.any(): raise ValueError( f"every feature is constant across the {reference!r} guides, so there is no scale to score against" ) z = (x[:, varying] - median[varying]) / mad[varying] activity = np.sqrt(np.nanmean(z**2, axis=1)) ref_sorted = np.sort(activity[is_reference]) at_or_above = ref_sorted.size - np.searchsorted(ref_sorted, activity, side="left") pvalue = (at_or_above + 1) / (ref_sorted.size + 1) pvalue[is_reference] = np.nan # the reference sets the scale, it is not scored adata.obs[key_added] = pvalue get_logger().info( "guide_activity scored %d guides against %d %r reference guides", int((~is_reference).sum()), int(is_reference.sum()), reference, ) return None