Source code for mantispy.tl._signature

from __future__ import annotations

from collections.abc import Sequence

import anndata as ad
import numpy as np
import pandas as pd
from anndata import AnnData

from mantispy._core.features import annotation
from mantispy._core.frames import as_frame
from mantispy._core.logging import get_logger
from mantispy._core.schema import stamp

DEFAULT_BY = ("feature_group", "channel", "object")

SEPARATOR = " | "


[docs] def feature_signature( adata: AnnData, key: str = "differential", by: Sequence[str] = DEFAULT_BY, statistic: str = "t", ) -> AnnData: """Collapse a differential table into a perturbation-by-feature-family matrix. Args: adata: Object carrying ``uns["mantispy"][key]``, as written by :func:`~mantispy.tl.differential_features`. key: Which table to collapse. by: ``var`` columns whose combination names a family. The default is the feature group, the channel it was measured in, and the compartment it was measured on. statistic: Column of the table to average within each family, such as ``"t"`` or ``"difference"`` (the raw contrast). Returns: A new :class:`~anndata.AnnData` of perturbations by families, with each family's ``by`` columns and ``n_features`` in ``var``, beside the schema's remaining annotation columns, left empty because a family is not a measurement they describe. A ``by`` column carries the family's own value, in the dtype ``var`` held it in; the ``"none"`` that names a family whose features had no value appears in the family's name, not in the column. It is a perturbation-level profile object, so :func:`~scanpy.pp.neighbors`, :func:`~scanpy.tl.leiden` and :func:`~mantispy.tl.nn_moa_classify` accept it. Raises: KeyError: ``uns["mantispy"][key]`` is missing, that table has no ``statistic`` column, or ``var`` is missing one of the ``by`` columns. ValueError: ``by`` names the same column more than once, which would give one family two copies of a component; every ``by`` column is empty, so there is no family to name; or two different sets of values name the same family. Notes: Signed statistics are averaged, so a family that decreased stays distinct from one that increased. On BBBC021, microtubule stabilizers score +2.2 on ``Intensity | CorrTub | Nuclei``, while destabilizers score +1.4 on ``Intensity | CorrActin | Nuclei`` with no tubulin signal; as the microtubules come apart the cells round up, which is the largest remaining change. """ store = adata.uns.get("mantispy", {}) if key not in store: raise KeyError(f"uns['mantispy'][{key!r}] is missing; run mt.tl.differential_features first") table = pd.DataFrame(store[key]) if statistic not in table.columns: raise KeyError(f"the {key!r} table has no column {statistic!r}; it holds {list(table.columns)}") var = as_frame(adata.var) missing = [column for column in by if column not in var.columns] if missing: raise KeyError(f"var is missing the column(s) that name a feature family: {missing}") if pd.Index(by).has_duplicates: raise ValueError(f"by names the same column more than once: {list(by)}") components = var[list(by)] gaps = components.isna() if gaps.all().all(): raise ValueError( f"var's {list(by)} name no family: every one is empty, so every feature would join into " "a single column averaging the whole object. mt.io.read_profiles writes the parsed " "annotation; an object whose feature names carry no structure has no families to collapse." ) # Masked before astype: pandas 2 turns a missing value into the string "nan", which fillna cannot see. labels = components.astype(str).mask(gaps, "none") family = labels.iloc[:, 0].str.cat(labels.iloc[:, 1:], sep=SEPARATOR) if len(by) > 1 else labels.iloc[:, 0] distinct = labels.assign(__family__=family).drop_duplicates()["__family__"] if distinct.duplicated().any(): clash = sorted(set(distinct[distinct.duplicated(keep=False)]))[:3] raise ValueError( f"different {list(by)} values name the same family: {clash}. A value holds the " f"{SEPARATOR!r} that joins them, so one family's columns would be averaged with another's; " "rename those values, or group on columns that do not hold it" ) table = table.join(family.rename("__family__"), on="feature") wide = table.pivot_table(index="group", columns="__family__", values=statistic, aggfunc="mean", observed=True) # From components, not labels or the split name, so a by column keeps its dtype and true missing values. parts = ( components.assign(__family__=family).drop_duplicates("__family__").set_index("__family__").reindex(wide.columns) ) var = annotation( wide.columns.rename(None), **{column: parts[column] for column in by}, n_features=family.value_counts().reindex(wide.columns), ) signature = ad.AnnData( X=wide.to_numpy(dtype=np.float32), # rename, not pd.Index(..., name=None), which pandas reads as "keep the name". obs=pd.DataFrame(index=wide.index.astype(str).rename(None)), var=var, ) signature.obs["Metadata_Perturbation"] = signature.obs_names.to_numpy() obs = as_frame(adata.obs) if "Metadata_Perturbation" in obs.columns: per_group = obs.drop_duplicates("Metadata_Perturbation").set_index("Metadata_Perturbation") for column in per_group.columns: if column.startswith("Metadata_") and column != "Metadata_Perturbation": values = per_group[column].reindex(signature.obs_names) if values.notna().any(): signature.obs[column] = values.to_numpy() stamp(signature, resolution="perturbation") get_logger().info( "feature_signature: %d perturbations by %d families, from %d features", signature.n_obs, signature.n_vars, adata.n_vars, ) return signature