"""Normalization, transformation, and gene filtering for preprocessing.
Provides:
- normalize_total: Library-size normalization (scanpy / rapids-singlecell).
- log_transform: Log1p transformation.
- scale: Zero-center and scale features.
- arcsinh_transform: Arcsinh transform for mass cytometry (IMC) protein data.
- filter_genes_by_pattern: Remove genes matching regex patterns (MT, ribosomal, hemoglobin).
- backup_raw: Save a copy of adata to adata.raw before any transformation.
"""
from __future__ import annotations
import logging
import re
from typing import Any
import numpy as np
import pandas as pd
from anndata import AnnData
from scipy import sparse
from ._gpu import get_backend
logger = logging.getLogger(__name__)
__all__ = [
"normalize_total",
"log_transform",
"scale",
"arcsinh_transform",
"filter_genes_by_pattern",
"backup_raw",
]
# Default gene patterns to exclude: mitochondrial, ribosomal, hemoglobin
DEFAULT_FILTER_PATTERNS = [
r"^MT-", # mitochondrial
r"^RP[SL]", # ribosomal
r"^HB[^(P)]", # hemoglobin (but not HBEGF, HBP1, etc.)
]
[docs]
def backup_raw(adata: AnnData) -> None:
"""Save a copy of the current adata to adata.raw (no-op if already set).
Parameters
----------
adata
Annotated data matrix. Modified in place.
"""
if adata.raw is not None:
logger.info("adata.raw already exists; skipping backup")
return
adata.raw = adata.copy()
logger.info("Backed up adata to adata.raw (%d cells x %d genes)", adata.n_obs, adata.n_vars)
[docs]
def normalize_total(
adata: AnnData,
target_sum: float | None = 1e4,
inplace: bool = True,
**kwargs: Any,
) -> AnnData | None:
"""Library-size normalize counts per cell.
Wraps ``scanpy.pp.normalize_total`` (or ``rapids_singlecell.pp.normalize_total``
on GPU).
Parameters
----------
adata
Annotated data with raw counts in ``X``.
target_sum
Target total counts per cell after normalization.
inplace
If True, modify adata in place.
**kwargs
Passed to the backend ``normalize_total``.
Returns
-------
AnnData or None
Modified adata if ``inplace=False``, else None.
"""
backend, name = get_backend()
logger.info("normalize_total (target_sum=%s, backend=%s)", target_sum, name)
return backend.pp.normalize_total(adata, target_sum=target_sum, inplace=inplace, **kwargs)
[docs]
def scale(
adata: AnnData,
max_value: float | None = 10,
zero_center: bool = True,
inplace: bool = True,
**kwargs: Any,
) -> AnnData | None:
"""Zero-center and scale features to unit variance.
Wraps ``scanpy.pp.scale`` (or ``rapids_singlecell.pp.scale`` on GPU).
Parameters
----------
adata
Annotated data.
max_value
Clip values to this maximum after scaling (default 10).
zero_center
If True, center each gene to zero mean.
inplace
If True, modify adata in place.
**kwargs
Passed to the backend ``scale``.
"""
backend, name = get_backend()
logger.info("scale (max_value=%s, backend=%s)", max_value, name)
return backend.pp.scale(adata, max_value=max_value, zero_center=zero_center, **kwargs)
[docs]
def filter_genes_by_pattern(
adata: AnnData,
patterns: list[str] | None = None,
exclude: bool = True,
case_sensitive: bool = False,
) -> None:
"""Remove (or keep) genes matching regex patterns in place.
Parameters
----------
adata
Annotated data. Modified in place.
patterns
List of regex patterns. Defaults to ``["^MT-", "^RP[SL]", "^HB[^(P)]"]``
(mitochondrial, ribosomal, hemoglobin).
exclude
If True (default), remove matching genes. If False, keep only matching genes.
case_sensitive
If False (default), patterns are case-insensitive.
"""
if patterns is None:
patterns = DEFAULT_FILTER_PATTERNS
flags = 0 if case_sensitive else re.IGNORECASE
var_names = pd.Series(adata.var_names)
mask = pd.Series(False, index=var_names.index)
for pattern in patterns:
mask |= var_names.str.contains(pattern, regex=True, flags=flags)
n_matching = mask.sum()
if exclude:
keep = ~mask
logger.info(
"Removing %d/%d genes matching patterns %s",
n_matching,
adata.n_vars,
patterns,
)
else:
keep = mask
logger.info(
"Keeping %d/%d genes matching patterns %s",
n_matching,
adata.n_vars,
patterns,
)
adata._inplace_subset_var(keep.values)