Source code for spacr.control_names

"""Resolve user-entered controls as gene or guide identifiers.

Controls may be bare genes (``000000``), bare guides (``000000_1``),
prefixed genes (``TGGT1_000000``), or prefixed guides
(``TGGT1_000000_1``). A leading token is treated as an organism or strain
prefix only when it occurs in the configured share of distinct library
identifiers. Matching uses complete identifiers rather than substrings.
"""
from __future__ import annotations

from collections import Counter
from dataclasses import dataclass
from typing import cast, Iterable, Optional, Sequence, Tuple

SEPARATOR = "_"

#: Minimum share of distinct identifiers that must carry a leading token for
#: it to be treated as an organism or strain prefix.
COMMON_PREFIX_SHARE = 0.9

GENE = "gene"
GUIDE = "grna"


@dataclass(frozen=True)
[docs] class ControlSpec: """Resolved interpretation of a user-entered control. :param typed: original trimmed nonblank identifier supplied by the user, retained for diagnostics and prefix-retry logic. :param level: resolved identifier level, :data:`GENE` or :data:`GUIDE`, which selects gene-wide versus exact-guide matching. :param value: normalized gene or guide identifier used for matching after any recognized organism prefix is removed. :param prefix: inferred or explicit organism or strain token without the separator, retained so prefixed and unprefixed stored names both match; empty when none is known. """ typed: str level: str value: str prefix: str = "" @property
[docs] def is_gene(self) -> bool: """Return whether this specification selects every guide for a gene.""" return self.level == GENE
[docs] def note(self, matched_guides: int = -1, matched_wells: int = -1) -> str: """Return a console summary of the resolution and optional matches.""" what = "gene" if self.is_gene else "guide" head = f"control {self.typed!r} resolved to {what} {self.value!r}" if self.prefix and self.typed.startswith(self.prefix + SEPARATOR): head += f" (dropping the common prefix {self.prefix!r})" if matched_guides < 0: return head wells = "" if matched_wells < 0 else f", {matched_wells} well(s)" return f"{head}: {matched_guides} guide(s){wells}"
[docs] def common_prefix(names: Iterable[str], share: float = COMMON_PREFIX_SHARE) -> str: """Return a leading token shared by enough distinct identifiers. Parameters ---------- names : iterable of str Guide identifiers in the loaded data. share : float, default=COMMON_PREFIX_SHARE Required fraction of distinct identifiers carrying the token. Returns ------- str Token without its separator, or an empty string when none reaches ``share``. """ distinct = {str(name) for name in names if str(name)} if not distinct: return "" leading = Counter() for name in distinct: if len(name.split(SEPARATOR)) >= 3: leading[name.split(SEPARATOR)[0]] += 1 if not leading: return "" token, count = leading.most_common(1)[0] return token if count >= share * len(distinct) else ""
[docs] def resolve_control(typed, names: Optional[Iterable[str]] = None, prefix: Optional[str] = None) -> Optional[ControlSpec]: """Read one typed control as a gene or as a guide. Parameters ---------- typed : Any Entered identifier. Blank values return ``None``. names : iterable of str, optional Library guide identifiers used to infer a common prefix. prefix : str, optional Previously inferred prefix. When supplied, ``names`` is ignored. Returns ------- ControlSpec or None Resolved gene or guide identifier, or ``None`` for no control. """ text = "" if typed is None else str(typed).strip() if not text: return None if prefix is None: prefix = common_prefix(() if names is None else names) prefix = str(prefix or "") parts = text.split(SEPARATOR) if len(parts) >= 3: return ControlSpec(text, GUIDE, SEPARATOR.join(parts[1:]), prefix) if len(parts) == 2: head, tail = parts if prefix and head == prefix: return ControlSpec(text, GENE, tail, prefix) return ControlSpec(text, GUIDE, text, prefix) return ControlSpec(text, GENE, text, prefix)
[docs] def resolve_controls(typed: Optional[Sequence], names: Optional[Iterable[str]] = None, prefix: Optional[str] = None ) -> Tuple[ControlSpec, ...]: """Resolve a sequence containing any mixture of genes and guides. :param typed: user-entered control identifiers; ``None`` means no controls. The common prefix is measured once and applied independently to each nonblank entry. """ if typed is None or len(typed) == 0: return () if prefix is None: prefix = common_prefix(() if names is None else names) out = [] for one in typed: spec = resolve_control(one, prefix=prefix) if spec is not None: out.append(spec) return tuple(out)
[docs] def matches(spec: Optional[ControlSpec], guides, genes=None): """Return a Boolean mask for rows covered by a resolved control. Parameters ---------- spec : ControlSpec or None Control to match. ``None`` matches no rows. guides : array-like Guide identifier for each row. genes : array-like, optional Gene identifier for each row. If omitted, gene membership is inferred from complete guide prefixes such as ``000000_1``. Returns ------- pandas.Series Boolean mask aligned to ``guides``. """ import pandas as pd series = pd.Series(guides).astype(str) if spec is None: return pd.Series(False, index=series.index) if not spec.is_gene: head = f"{spec.prefix}{SEPARATOR}" if spec.prefix else "" return (series == spec.value) | (series == head + spec.value) if genes is not None: gene_series = pd.Series(genes).astype(str) gene_series.index = series.index head = f"{spec.prefix}{SEPARATOR}" if spec.prefix else "" return (gene_series == spec.value)\ | (gene_series == head + spec.value) head = f"{spec.prefix}{SEPARATOR}" if spec.prefix else "" wanted = f"{spec.value}{SEPARATOR}" return (series.str.startswith(wanted) | series.str.startswith(head + wanted) | (series == spec.value) | (series == head + spec.value))
[docs] class ControlNotFound(ValueError): """Raised when a named control does not match any screen row. An empty control selection would invalidate normalization, reference baselines, and volcano annotations. :func:`rows_for` raises this exception when ``strict=True`` so callers can stop before computing those results. """
[docs] def rows_for(typed, guides, genes=None, *, names=None, prefix=None, strict: bool = False, label: str = "control"): """Resolve a typed control and select the matching screen rows. Guide controls use exact matches. Gene controls select every guide assigned to the gene. When the data omits an organism prefix that is present in the typed control, the prefix is removed before retrying the same whole-value match. Parameters ---------- typed : object Control name or value accepted by :func:`resolve_control`. guides : array-like Guide names for the screen rows. genes : array-like, optional Gene names aligned with ``guides``. Guide prefixes are used when this column is unavailable. names : iterable of str, optional Reference names used to distinguish organism prefixes from gene names. prefix : str, optional Explicit organism prefix. strict : bool, default=False Raise :class:`ControlNotFound` when the control matches no rows. label : str, default="control" Name used in an error message when ``strict=True``. Returns ------- tuple of pandas.Series and str Boolean row mask and a concise description of the resolved control. Raises ------ ControlNotFound If ``strict=True`` and no row matches the resolved control. """ import pandas as pd spec = resolve_control(typed, names=names, prefix=prefix) series = pd.Series(guides).astype(str) if spec is None: return pd.Series(False, index=series.index), "" mask = matches(spec, guides, genes) found = int(mask.sum()) if not found and SEPARATOR in spec.typed: head, _, tail = spec.typed.partition(SEPARATOR) library = {str(n) for n in (names or series)} unused = not any(str(n).startswith(head + SEPARATOR) for n in library) if tail and unused: shorter = cast(ControlSpec, resolve_control(tail, prefix=prefix)) retry = matches(shorter, guides, genes) if int(retry.sum()): spec = ControlSpec(spec.typed, shorter.level, shorter.value, head) mask, found = retry, int(retry.sum()) if not found and strict: raise ControlNotFound( f"{label} {spec.typed!r} was read as {'gene' if spec.is_gene else 'guide'} " f"{spec.value!r} and matches nothing in this screen. Every " f"normalisation and every baseline is computed against the " f"control rows, so leaving this would compute them against " f"nothing. Check the spelling against the count table" + (f", or drop the {spec.prefix!r} prefix" if spec.prefix else "") + ".") return mask, spec.note(found)