Source code for spacr.thresholds

"""Calculate effect-size thresholds for regression coefficients.

The estimators describe the spread around the coefficient centre. When
negative-control guides are available, callers should estimate that spread
from the controls so biological effects do not widen the null distribution.
Each estimator returns a width in coefficient units except ``var``, which is
retained for compatibility and returns squared units.
"""

from __future__ import annotations

from typing import Dict, Optional, Sequence, Tuple

import numpy as np

#: The consistent scale estimator for a normal distribution. MAD x this is
#: an estimate of sigma that, unlike the standard deviation, is not inflated
#: by the outliers a screen exists to find.
MAD_TO_SIGMA = 1.4826


def _finite(values) -> np.ndarray:
    """Flatten numeric values and discard NaN and infinite entries."""
    array = np.asarray(values, dtype=float).ravel()
    return array[np.isfinite(array)]


def _std(values) -> float:
    """Return the sample standard deviation of the finite values."""
    return float(np.std(_finite(values), ddof=1))


def _var(values) -> float:
    """Return the sample variance of the finite values, in squared units."""
    return float(np.var(_finite(values), ddof=1))


def _mad(values) -> float:
    """Estimate normal-distribution sigma from the finite values' MAD."""
    array = _finite(values)
    return float(np.median(np.abs(array - np.median(array))) * MAD_TO_SIGMA)


def _iqr(values) -> float:
    """Return the interquartile range of the finite values."""
    array = _finite(values)
    return float(np.percentile(array, 75) - np.percentile(array, 25))


def _abs_percentile(values) -> float:
    """The 95th percentile of |value|, as a width."""
    return float(np.percentile(np.abs(_finite(values)), 95))


def _range(values) -> float:
    """Return the maximum minus minimum of the finite values."""
    array = _finite(values)
    return float(array.max() - array.min())


#: ``{name: (spread function, one-line description)}``.
#:
#: Each is a WIDTH in the units of the coefficient, so `centre + k * width`
#: is a coefficient -- with one deliberate exception, noted below.
METHODS: Dict[str, Tuple[Optional[object], str]] = {
    "none": (None,
             "no effect-size cut; significance alone decides"),
    "std": (_std,
            "standard deviation of the control coefficients"),
    "var": (_var,
            "VARIANCE of the control coefficients -- squared units, see "
            "below"),
    "mad": (_mad,
            "median absolute deviation x 1.4826, the robust sigma"),
    "iqr": (_iqr,
            "interquartile range of the control coefficients"),
    "percentile": (_abs_percentile,
                   "95th percentile of |control coefficient|"),
    "range": (_range,
              "full range of the control coefficients, max - min"),
}

#: Spellings accepted for the same method, so an old settings CSV still loads.
ALIASES = {
    "standard_deveation": "std",
    "standard_deviation": "std",
    "variance": "var",
    "median_absolute_deviation": "mad",
    "interquartile_range": "iqr",
    "quantile": "percentile",
    "": "none",
}

#: `var` returns a width in SQUARED units, so `mean + k * var` adds a variance
#: to a coefficient and is dimensionally wrong. It is kept because it is what
#: spaCR shipped and what a saved settings file may carry, but it is the one
#: method whose number cannot be read as "k spreads away from the centre".
#: Below a spread of 1 it is narrower than std and above it much wider.
DIMENSIONALLY_ODD = ("var",)


[docs] def canonical(method) -> str: """The canonical name for ``method``. :param method: threshold method name or accepted alias; falsey values mean ``"none"``. :raises ValueError: naming every method, rather than falling back to a default the caller did not ask for. """ key = str(method or "none").strip().lower().replace(" ", "_") key = ALIASES.get(key, key) if key not in METHODS: raise ValueError( f"Unsupported threshold method {method!r}. Choose one of: " f"{', '.join(METHODS)}.") return key
[docs] def describe(method) -> str: """One line saying what a method measures. :param method: threshold method name or accepted alias to describe. """ key = canonical(method) text = METHODS[key][1] if key in DIMENSIONALLY_ODD: text += (" -- k x variance is not k spreads from the centre, so this " "cut is narrower than 'std' below a spread of 1 and much " "wider above it") return text
[docs] def coefficient_threshold(values: Sequence[float], method="mad", multiplier: float = 3.0, centre: Optional[float] = None) -> Tuple[Optional[float], str]: """``(threshold, sentence)`` for a set of control coefficients. :param values: the control coefficients -- the null. :param method: one of :data:`METHODS`, or an alias. :param multiplier: how many spreads wide the cut is. :param centre: what to measure from; the MEDIAN of ``values`` by default, which is not moved by one control guide with a real phenotype. This screen has one -- `000000_22` is a non-targeting control and the strongest effect in the run at +4.37. :returns: ``(None, reason)`` when no cut can be made, never a silent 0. The sentence is not decoration: a threshold a reader cannot attribute is a threshold they cannot report, and it goes on the panel beside the line. """ key = canonical(method) if key == "none": return None, "no effect-size cut" array = _finite(values) if array.size < 2: return None, (f"{array.size} control coefficient(s) is not enough to " f"measure a spread") spread = METHODS[key][0](array) if not np.isfinite(spread) or spread <= 0: return None, (f"the control coefficients have no {key} spread " f"(every one is the same value)") origin = float(np.median(array)) if centre is None else float(centre) threshold = abs(origin) + float(multiplier) * spread return threshold, (f"{multiplier:g}x {key} of {array.size} controls " f"= {threshold:.3g}")
__all__ = ["ALIASES", "DIMENSIONALLY_ODD", "MAD_TO_SIGMA", "METHODS", "canonical", "coefficient_threshold", "describe"]