Source code for mantispy.pp._outliers

"""Cell-level outlier detection."""

from __future__ import annotations

import math

import numpy as np
from anndata import AnnData

from mantispy._core._ecod import AGGREGATIONS, ecod_scores
from mantispy._core._reduce import get_matrix, group_codes, group_offsets
from mantispy._core._stats import robust_zscore
from mantispy._core.logging import get_logger
from mantispy._core.masks import feature_mask
from mantispy._core.mutation import inplace_or_copy

METHODS = ("ecod", "isolation_forest", "mad")


def _scores(X: np.ndarray, method: str, seed: int, ecod_aggregation: str) -> np.ndarray:
    """Outlier score per row, higher meaning more outlying."""
    if method == "ecod":
        return ecod_scores(X, ecod_aggregation)
    if method == "isolation_forest":
        from sklearn.ensemble import IsolationForest

        filled = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
        model = IsolationForest(random_state=seed, n_estimators=200).fit(filled)
        return -model.score_samples(filled)

    return np.nanmax(np.abs(robust_zscore(X)), axis=1)


[docs] @inplace_or_copy() def outliers( adata: AnnData, method: str = "ecod", contamination: float = 0.01, score_cutoff: float | None = None, key: str | None = "selected", by: str | None = None, seed: int = 0, ecod_aggregation: str = "pyod", key_added: str = "qc_outlier", copy: bool = False, ) -> AnnData | None: """Flag outlying cells. Every method produces a score where higher means more outlying, and the same thresholding applies to all of them, so ``contamination`` is the flagged fraction whichever method is used. Args: adata: Object to flag. method: ``"ecod"`` :cite:p:`Li_2023` needs no tuning and is interpretable per feature, ``"isolation_forest"`` catches outliers defined by feature interactions, and ``"mad"`` takes the largest robust z-score across features, which is easy to explain but sees each feature alone. contamination: Fraction of cells to flag, rounded up to a whole cell within each ``by`` group, so a non-empty group always flags its most outlying cell and the flagged fraction is higher than asked for in a group smaller than ``1 / contamination``. Ignored when ``score_cutoff`` is given. score_cutoff: Threshold the score absolutely instead of by quantile. With ``method="mad"`` the score is a robust z-score, so ``score_cutoff=5`` gives the usual rule. key: Restrict to features flagged by this boolean ``var`` column, usually ``"selected"``. Falls back to every feature when the column is absent. by: Threshold within each group of this ``obs`` column, e.g. per plate, rather than globally. seed: Seed for ``isolation_forest``. ecod_aggregation: How ``"ecod"`` combines features. ``"pyod"`` sums each feature's larger tail, or both tails where its skewness is zero or undefined, as pyod and scmorph do, so no score depends on which way round a feature is measured. ``"paper"`` is Algorithm 1 of :cite:t:`Li_2023`, the largest of the left-tail, right-tail and skew-directed sums. key_added: Prefix for the outputs: ``obs[key_added]`` and ``obs[key_added + "_score"]``. copy: Return a modified copy instead of mutating in place. Returns: ``None``, or the modified copy. Writes the boolean ``obs[key_added]`` and the score itself to ``obs[key_added + "_score"]``. Raises: ValueError: If ``method`` or ``ecod_aggregation`` is unknown, or ``contamination`` is outside ``(0, 1)`` and no ``score_cutoff`` is given. """ if method not in METHODS: raise ValueError(f"method must be one of {METHODS}, got {method!r}") if not 0.0 < contamination < 1.0 and score_cutoff is None: raise ValueError(f"contamination must be between 0 and 1, got {contamination}") if ecod_aggregation not in AGGREGATIONS: raise ValueError(f"ecod_aggregation must be one of {AGGREGATIONS}, got {ecod_aggregation!r}") selected = feature_mask(adata, key) X = get_matrix(adata)[:, selected] scores = np.empty(adata.n_obs, dtype=np.float64) flagged = np.zeros(adata.n_obs, dtype=bool) codes, keys = group_codes(adata, by) order, offsets = group_offsets(codes, len(keys)) for group in range(len(keys)): rows = order[offsets[group] : offsets[group + 1]] if not rows.size: continue # One group is the whole matrix, so it is scored in place rather than copied. block = _scores(X if len(keys) == 1 else X[rows], method, seed, ecod_aggregation) scores[rows] = block if score_cutoff is not None: flagged[rows] = block > score_cutoff else: # Flag by rank: `> quantile` flags too few cells on ties, and none when an infinite value makes the quantile infinite. k = math.ceil(contamination * rows.size) flagged[rows[np.argsort(block)[::-1][:k]]] = True adata.obs[key_added] = flagged adata.obs[f"{key_added}_score"] = scores get_logger().info( "outliers(%s) flagged %d of %d cells using %d features", method, int(flagged.sum()), adata.n_obs, int(selected.sum()), ) return None