Source code for mantispy.metrics._variance

"""Principal-component regression: how much variance a covariate explains, computed natively."""

from __future__ import annotations

import warnings
from collections.abc import Sequence
from typing import TYPE_CHECKING

import numpy as np
import pandas as pd
from numba import njit

from mantispy._core._reduce import get_matrix
from mantispy._core.frames import as_frame
from mantispy.metrics._common import embedding, is_categorical, tidy

if TYPE_CHECKING:
    from anndata import AnnData


@njit(cache=True, nogil=True)
def _anova_r2(values: np.ndarray, codes: np.ndarray, n_groups: int) -> np.ndarray:
    """One-way ANOVA R^2 of each column of ``values`` on the integer group ``codes``.

    The value is ``1 - SS_within / SS_total`` per column, the share of the column's variance the grouping explains, and ``0`` for a constant column where that share is undefined.
    """
    n_rows, n_cols = values.shape
    out = np.zeros(n_cols)
    counts = np.zeros(n_groups)
    for row in range(n_rows):
        counts[codes[row]] += 1.0

    sums = np.empty(n_groups)
    for col in range(n_cols):
        grand_sum = 0.0
        for row in range(n_rows):
            grand_sum += values[row, col]
        grand_mean = grand_sum / n_rows

        for group in range(n_groups):
            sums[group] = 0.0
        for row in range(n_rows):
            sums[codes[row]] += values[row, col]

        ss_total = 0.0
        ss_within = 0.0
        for row in range(n_rows):
            value = values[row, col]
            total_diff = value - grand_mean
            ss_total += total_diff * total_diff
            within_diff = value - sums[codes[row]] / counts[codes[row]]
            ss_within += within_diff * within_diff

        out[col] = 0.0 if ss_total == 0.0 else 1.0 - ss_within / ss_total
    return out


@njit(cache=True, nogil=True)
def _pearson_r2(values: np.ndarray, covariate: np.ndarray) -> np.ndarray:
    """Squared Pearson correlation of each column of ``values`` with the numeric ``covariate``.

    Returns ``0`` for a column or covariate with no variance, where the correlation is undefined.
    """
    n_rows, n_cols = values.shape
    out = np.zeros(n_cols)

    covariate_sum = 0.0
    for row in range(n_rows):
        covariate_sum += covariate[row]
    covariate_mean = covariate_sum / n_rows
    covariate_ss = 0.0
    for row in range(n_rows):
        centered = covariate[row] - covariate_mean
        covariate_ss += centered * centered

    for col in range(n_cols):
        column_sum = 0.0
        for row in range(n_rows):
            column_sum += values[row, col]
        column_mean = column_sum / n_rows

        cross = 0.0
        column_ss = 0.0
        for row in range(n_rows):
            centered = values[row, col] - column_mean
            cross += centered * (covariate[row] - covariate_mean)
            column_ss += centered * centered

        denominator = column_ss * covariate_ss
        out[col] = 0.0 if denominator == 0.0 else (cross * cross) / denominator
    return out


[docs] def pc_regression(adata: AnnData, key: str, use_rep: str = "X_pca", n_comps: int | None = None) -> pd.DataFrame: """Variance-weighted R^2 of the principal components on ``key``. Each component is regressed on ``key`` on its own, a categorical key through a one-way ANOVA R^2 and a numeric key through the squared Pearson correlation, and the per-component R^2 is weighted by that component's share of the total variance. The value is the share of total variance the covariate explains, so for a batch key lower is better. The per-component loop runs in a numba kernel and imports no scib. Args: adata: Object with the embedding to measure in. key: ``obs`` column the components are regressed on. use_rep: ``obsm`` key of the embedding. n_comps: Use only the leading components, or ``None`` for every component the embedding holds. Returns: A one-row tidy frame holding ``pc_regression``, whose value is NaN when ``key`` is constant (a single batch), where the share of variance it explains is undefined. Raises: KeyError: ``obsm`` holds nothing under ``use_rep``. ValueError: ``key`` is numeric and has missing values, which cannot be regressed. """ values = np.ascontiguousarray(embedding(adata, use_rep)) if n_comps is not None: values = np.ascontiguousarray(values[:, :n_comps]) covariate = as_frame(adata.obs)[key] if covariate.nunique(dropna=False) <= 1: # A constant covariate has no variance to regress against, so its share is undefined. warnings.warn( f"PC-regression over obs[{key!r}] is undefined: the covariate is constant (a single batch), " "so there is no variance to regress against. Returning NaN.", UserWarning, stacklevel=2, ) return tidy("pc_regression", use_rep, key, np.nan) variances = values.var(axis=0, ddof=1) weights = variances / variances.sum() if is_categorical(covariate): codes = covariate.astype("category").cat.remove_unused_categories().cat.codes.to_numpy().copy() # Fold a missing value into the baseline level, as the old pd.get_dummies(drop_first=True) did, rather than # letting it form its own ANOVA group, which would read as a phantom batch. codes[codes == -1] = 0 codes = codes.astype(np.int64) explained = _anova_r2(values, codes, int(codes.max()) + 1) else: numeric = covariate.to_numpy(dtype=np.float64) missing = int(np.isnan(numeric).sum()) if missing: raise ValueError( f"PC-regression over obs[{key!r}] has {missing} missing value(s) in a numeric covariate, which " "cannot be regressed. Drop those rows or fill the column first." ) explained = _pearson_r2(values, numeric) return tidy("pc_regression", use_rep, key, float(np.sum(weights * explained)))
[docs] def batch_variance_explained(adata: AnnData, keys: Sequence[str], use_rep: str = "X_pca") -> pd.DataFrame: """:func:`~mantispy.metrics.pc_regression` for several covariates, stacked into one frame. Args: adata: Object with the embedding to measure in. keys: ``obs`` columns to score, one row of the result each. Any column works, numeric or categorical, not only the batch: a plate position, a cell count or a treatment label are all valid covariates. use_rep: ``obsm`` key of the embedding. Returns: A tidy frame holding one ``pc_regression`` row per entry of ``keys``. Raises: KeyError: ``obsm`` holds nothing under ``use_rep``. """ return pd.concat([pc_regression(adata, key, use_rep) for key in keys], ignore_index=True)
def variance_carried( adata: AnnData, reference: AnnData, use_rep: str = "X_pca", groupby: str | None = "feature_group", n_splits: int = 5, ) -> pd.DataFrame: """How much of a named feature block a learned embedding linearly carries. A learned embedding has no ``var`` vocabulary, so a hit read off it is only a compound ID. This scores, per named feature, the out-of-fold R^2 of predicting that feature from the embedding with a cross-fit ridge, so a value near 1 means the embedding carries the feature and near 0 means it does not. Args: adata: Object holding the learned embedding in ``obsm``. reference: Object whose ``X`` holds the named block (e.g. CellProfiler features) to recover. use_rep: ``obsm`` key of the embedding used as the predictor. groupby: ``reference.var`` column to average over, one row of the result per group; ``None`` returns one row per feature instead. n_splits: Folds of the cross-fit that produces the out-of-fold predictions. Returns: A frame sorted by ``variance_carried`` descending with a reset index: columns ``["feature", "variance_carried"]`` when ``groupby`` is ``None``, else ``["<groupby>", "variance_carried", "n_features"]`` holding the mean over each group. Raises: KeyError: ``obsm`` holds nothing under ``use_rep``. ValueError: The two objects share no ``obs_names``. ValueError: ``adata`` or ``reference`` has non-unique ``obs_names``. ValueError: ``groupby`` is not ``None`` and not a column of ``reference.var``. Notes: The two blocks may hold the same wells in different row orders, so the rows are aligned on the shared ``obs_names`` before regressing; matching by position instead returns a near-zero R^2 for an informative block, which reads as a real negative result. """ from sklearn.linear_model import RidgeCV from sklearn.model_selection import KFold if groupby is not None and groupby not in as_frame(reference.var).columns: raise ValueError(f"groupby={groupby!r} is not a column of reference.var") if not adata.obs_names.is_unique: raise ValueError("adata.obs_names are not unique; call .obs_names_make_unique() first") if not reference.obs_names.is_unique: raise ValueError("reference.obs_names are not unique; call .obs_names_make_unique() first") shared = adata.obs_names[adata.obs_names.isin(reference.obs_names)] if len(shared) == 0: raise ValueError("adata and reference share no obs_names; the two blocks must hold the same wells") predictors = embedding(adata[shared], use_rep) targets = get_matrix(reference[shared]).astype(np.float64) splitter = KFold(n_splits=n_splits, shuffle=True, random_state=0) # Built lazily, once the row-count guard below has proved there are enough rows to split. full_split = None scores = np.full(targets.shape[1], np.nan) for column in range(targets.shape[1]): finite = np.isfinite(targets[:, column]) target = targets[finite, column] if target.size < n_splits or target.min() == target.max(): continue if finite.all(): if full_split is None: full_split = list(splitter.split(predictors)) design, folds = predictors, full_split else: design = predictors[finite] folds = splitter.split(design) predicted = np.empty_like(target) for train, test in folds: predicted[test] = RidgeCV().fit(design[train], target[train]).predict(design[test]) residual = float(np.sum((target - predicted) ** 2)) total = float(np.sum((target - target.mean()) ** 2)) scores[column] = 1.0 - residual / total if groupby is None: frame = pd.DataFrame({"feature": np.asarray(reference.var_names), "variance_carried": scores}) else: labels = as_frame(reference.var)[groupby].to_numpy() rows = [] for group in pd.Series(labels).dropna().unique(): members = labels == group rows.append( { groupby: group, "variance_carried": float(np.nanmean(scores[members])), "n_features": int(members.sum()), } ) frame = pd.DataFrame(rows) return frame.sort_values("variance_carried", ascending=False).reset_index(drop=True)