Source code for mantispy.metrics._lisi

"""Local inverse Simpson's index (LISI).

The effective number of distinct labels in a neighborhood, weighting each neighbor by
``exp(-beta * d)`` with ``beta`` calibrated so the neighborhood's entropy matches the
requested perplexity. On a batch key it is iLISI (higher is better mixed); on a label key
it is cLISI (lower means the biological groups stay separated).

Values match ``harmonypy.lisi.compute_lisi`` (Korsunsky et al. 2019) to machine precision,
so they are comparable with published LISI values.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

import numpy as np
import pandas as pd
from sklearn.neighbors import NearestNeighbors

from mantispy.metrics._common import embedding, tidy

if TYPE_CHECKING:
    from anndata import AnnData

_TOLERANCE = 1e-5
_MAX_STEPS = 50


def _inverse_simpson(distances: np.ndarray, labels: np.ndarray, perplexity: float) -> float:
    """Inverse Simpson index of one neighborhood, after calibrating the kernel width."""
    beta, lower, upper = 1.0, -np.inf, np.inf
    target = np.log(perplexity)
    weights = np.exp(-distances * beta)

    for _ in range(_MAX_STEPS):
        total = weights.sum()
        if total == 0:
            return 1.0
        entropy = np.log(total) + beta * np.sum(distances * weights) / total
        if abs(entropy - target) < _TOLERANCE:
            break
        if entropy > target:
            lower = beta
            beta = beta * 2 if upper == np.inf else (beta + upper) / 2
        else:
            upper = beta
            beta = beta / 2 if lower == -np.inf else (beta + lower) / 2
        weights = np.exp(-distances * beta)

    total = weights.sum()
    if total == 0:
        return 1.0
    shares = np.array([weights[labels == value].sum() for value in np.unique(labels)]) / total
    return float(1.0 / np.sum(shares**2))


#: Column-name fragments that mark a batch, used only when ``kind="auto"``.
_BATCH_HINTS = ("batch", "plate", "source", "week", "run")


[docs] def lisi(adata: AnnData, key: str, use_rep: str = "X_pca", perplexity: float = 30, kind: str = "auto") -> pd.DataFrame: """Median LISI over rows. Args: adata: Object with the embedding to measure in. key: ``obs`` column whose labels the neighborhoods are scored over. use_rep: ``obsm`` key of the embedding. perplexity: Perplexity the kernel width is calibrated to. Each neighborhood holds ``3 * perplexity`` rows, at most ``n_obs - 1``. kind: ``"batch"`` names the result ``ilisi`` (higher is better mixed) and ``"label"`` names it ``clisi`` (lower means the biological groups stay separated). ``"auto"`` guesses from the column name: a key containing batch, plate, source, week or run is a batch and anything else a label, so ``"Metadata_Site"`` counts as a label. Pass ``kind`` explicitly when one table holds both, or the two rows get the same metric name. """ if kind not in ("auto", "batch", "label"): raise ValueError(f"kind must be 'auto', 'batch' or 'label', got {kind!r}") values = embedding(adata, use_rep) labels = adata.obs[key].to_numpy() missing = int(pd.isna(labels).sum()) if missing: raise ValueError(f"obs[{key!r}] has {missing} missing value(s); drop those rows or fill the column.") n_neighbors = min(int(perplexity * 3), adata.n_obs - 1) distances, indices = NearestNeighbors(n_neighbors=n_neighbors + 1).fit(values).kneighbors(values) # The kernel takes unsquared distances, as harmonypy does. Squared distances keep LISI # monotone in mixing but change every value; see tests/test_equivalence_harmonypy_lisi.py. scores = [_inverse_simpson(distances[row, 1:], labels[indices[row, 1:]], perplexity) for row in range(adata.n_obs)] if kind == "auto": kind = "batch" if any(hint in key.lower() for hint in _BATCH_HINTS) else "label" return tidy("ilisi" if kind == "batch" else "clisi", use_rep, key, float(np.median(scores)))