Source code for mantispy.tl._bootstrap

"""Per-feature hit calling at cell level, against the null of resampling the control guides.

This is the bootstrap test of Brieflow (``workflow/lib/aggregate/bootstrap.py``), the optical pooled screen pipeline of :cite:t:`Di_Bernardo_2026`, but the null is evaluated exactly rather than by simulation, so the p-values are the ones an infinite bootstrap converges to.
"""

from __future__ import annotations

from math import exp, expm1, lgamma, log, log1p
from typing import TYPE_CHECKING

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

from mantispy._core._numba import MEDIAN, _counting_order, _kernel_input, grouped_stat
from mantispy._core._reduce import get_matrix, group_codes
from mantispy._core._stats import benjamini_hochberg
from mantispy._core.frames import as_frame
from mantispy._core.logging import get_logger
from mantispy._core.masks import reference_mask
from mantispy._core.mutation import inplace_or_copy

if TYPE_CHECKING:
    from builtins import range as prange
else:
    from numba import prange

#: Where each guide's null is drawn from; see :func:`bootstrap_features`.
CONTROL_SCOPES = ("pooled", "within_group", "reference_group", "within_perturbation")

#: Atoms of a guide null whose probability is below this are dropped when the gene null needs
#: them. The dropped mass is a far order-statistic tail of comparable size, well under the
#: Monte-Carlo error of any feasible simulation; small problems keep every atom, so the result
#: still matches a brute-force enumeration. :func:`_null_caps` sizes its buffer to the same band.
_ATOM_EPS = 1e-8


@njit(cache=True, nogil=True)
def _binom_pmf(k: int, n: int, p: float) -> float:
    """``P(Binomial(n, p) == k)`` for ``0 < p < 1``, in log space so it does not underflow for large ``n``."""
    return exp(lgamma(n + 1.0) - lgamma(k + 1.0) - lgamma(n - k + 1.0) + k * log(p) + (n - k) * log1p(-p))


@njit(cache=True, nogil=True)
def _binom_cdf(k: int, n: int, p: float) -> float:
    """``P(Binomial(n, p) <= k)`` by the pmf recurrence from ``pmf(0) = (1 - p) ** n``."""
    if k < 0:
        return 0.0
    if k >= n:
        return 1.0
    if p <= 0.0:
        return 1.0
    if p >= 1.0:
        return 0.0
    term = (1.0 - p) ** n
    if term < 1e-280:  # pmf(0) underflows, or is a subnormal with too few digits, past ~1000 draws
        return _binom_cdf_from_k(k, n, p)
    acc = term
    r = p / (1.0 - p)
    for i in range(1, k + 1):
        term = term * r * (n - i + 1) / i
        acc += term
    return acc


@njit(cache=True, nogil=True)
def _binom_cdf_from_k(k: int, n: int, p: float) -> float:
    """:func:`_binom_cdf` by the recurrence from ``pmf(k)`` (or ``pmf(k + 1)``, for the upper tail) outward."""
    if k < (n + 1) * p:  # lower tail: pmf decreases from k down to 0
        term = _binom_pmf(k, n, p)
        acc = term
        q = (1.0 - p) / p
        for i in range(k, 0, -1):
            term *= i * q / (n - i + 1)  # pmf(i - 1) from pmf(i)
            acc += term
            if term <= acc * 1e-17:
                break
        return min(acc, 1.0)
    term = _binom_pmf(k + 1, n, p)  # upper tail: pmf decreases from k + 1 up to n
    acc = term
    r = p / (1.0 - p)
    for i in range(k + 1, n):
        term *= (n - i) * r / (i + 1)  # pmf(i + 1) from pmf(i)
        acc += term
        if term <= acc * 1e-17:
            break
    return max(1.0 - acc, 0.0)


@njit(cache=True, nogil=True)
def _count_below(v, lo, hi, x, inclusive):
    """How many of the sorted ``v[lo:hi]`` are below ``x`` (or at most ``x``, if ``inclusive``).

    ``np.searchsorted`` on that slice gives the same, but a slice of an array the threads share touches
    its reference count from every thread, which serializes them in the innermost loops.
    """
    left = lo
    right = hi
    while left < right:
        mid = (left + right) // 2
        if v[mid] < x or (inclusive and v[mid] == x):
            left = mid + 1
        else:
            right = mid
    return left - lo


@njit(cache=True, nogil=True)
def _median_gt(v: np.ndarray, lo: int, n: int, m: int, x: float) -> float:
    """``P(median of m draws with replacement from v[lo:lo+n] (sorted asc, no nan) > x)``."""
    if n == 0:
        return 0.0
    cle = _count_below(v, lo, lo + n, x, True)  # count <= x
    f_le = cle / n
    t = m // 2
    if m % 2 == 1:
        return _binom_cdf(t, m, f_le)  # P(#{<=x} <= t)
    out = _binom_cdf(t - 1, m, f_le)  # both central order stats > x
    if cle == 0 or cle == n:
        return out
    pk = _binom_cdf(t, m, f_le) - _binom_cdf(t - 1, m, f_le)  # P(exactly t draws <= x)
    if pk <= 0.0:
        return out
    nhigh = n - cle
    contrib = 0.0
    i = lo
    end_low = lo + cle
    while i < end_low:
        a = np.float64(v[i])  # a float32 midpoint would round differently from the float64 observed median
        j = i
        while j < end_low and v[j] == a:
            j += 1
        c_le = j - lo
        c_prev = i - lo
        pmax = (c_le / cle) ** t - (c_prev / cle) ** t  # P(max of t low draws == a)
        if pmax > 0.0:
            # first high value b with the actual midpoint (a+b)/2 > x
            left = int(end_low)
            right = int(lo + n)
            while left < right:
                mid = (left + right) // 2
                if (a + v[mid]) / 2.0 > x:
                    right = mid
                else:
                    left = mid + 1
            if left < lo + n:
                ge = n - _count_below(v, lo, lo + n, v[left], False)  # count >= v[left]
                contrib += pmax * (ge / nhigh) ** t
        i = j
    return out + pk * contrib


@njit(cache=True, nogil=True)
def _median_lt(v: np.ndarray, lo: int, n: int, m: int, x: float) -> float:
    """``P(median of m draws from v[lo:lo+n] (sorted asc, no nan) < x)``. Mirror of :func:`_median_gt`."""
    if n == 0:
        return 0.0
    hi = _count_below(v, lo, lo + n, x, False)  # count < x
    f_lt = hi / n
    t = m // 2
    if m % 2 == 1:
        return 1.0 - _binom_cdf(t, m, f_lt)  # P(#{<x} >= t+1)
    out = 1.0 - _binom_cdf(t, m, f_lt)  # both central order stats < x
    nlow = hi
    nge = n - hi
    if nlow == 0 or nge == 0:
        return out
    pk = _binom_cdf(t, m, f_lt) - _binom_cdf(t - 1, m, f_lt)  # P(exactly t draws < x)
    if pk <= 0.0:
        return out
    contrib = 0.0
    i = lo + hi
    end = lo + n
    while i < end:
        b = np.float64(v[i])
        j = i
        while j < end and v[j] == b:
            j += 1
        c_ge = end - i
        c_gt = end - j
        pmin = (c_ge / nge) ** t - (c_gt / nge) ** t  # P(min of t high draws == b)
        if pmin > 0.0:
            # largest low value a with the actual midpoint (a+b)/2 < x
            left = int(lo)
            right = int(lo + hi)
            while left < right:
                mid = (left + right) // 2
                if (v[mid] + b) / 2.0 < x:
                    left = mid + 1
                else:
                    right = mid
            take = left - lo
            if take > 0:
                contrib += pmin * (take / nlow) ** t
        i = j
    return out + pk * contrib


@njit(cache=True, nogil=True)
def _sub_tail(v: np.ndarray, lo: int, nn: int, width: int, m: int, x: float):
    """Tail probabilities of one control guide's resampled median at threshold ``x``.

    Returns ``(P(median > x), P(median < x), P(median is nan))`` for the median of m draws from the
    guide's ``width`` cells, skipping the nan ones; ``v[lo:lo+nn]`` are its sorted non-nan values.
    """
    if nn == 0:
        return 0.0, 0.0, 1.0
    if nn == width:
        return _median_gt(v, lo, nn, m, x), _median_lt(v, lo, nn, m, x), 0.0
    p_nn = nn / width
    nan_prob = (1.0 - p_nn) ** m
    # number of non-nan draws r ~ Binomial(m, p_nn); median is over the r non-nan draws
    gt = 0.0
    lt = 0.0
    for r in range(1, m + 1):
        term = _binom_pmf(r, m, p_nn)  # P(r non-nan draws)
        if term > 0.0:
            gt += term * _median_gt(v, lo, nn, r, x)
            lt += term * _median_lt(v, lo, nn, r, x)
    return gt, lt, nan_prob


@njit(parallel=True, cache=True, nogil=True)
def _guide_pvalues(
    pool_sorted: np.ndarray,
    sub_nn: np.ndarray,
    sub_start: np.ndarray,
    sub_stop: np.ndarray,
    pool_sub_start: np.ndarray,
    pool_sub_stop: np.ndarray,
    pool_width: np.ndarray,
    construct_pool: np.ndarray,
    construct_nn: np.ndarray,
    construct_observed: np.ndarray,
) -> np.ndarray:
    """Two-sided p-value of every guide against its control pool, exactly.

    A guide's null draws as many cells as the guide has measured in the feature, and is conditioned on a
    measured null median; with no measured control cell the p-value is ``NaN``.
    """
    n_constructs, n_features = construct_observed.shape
    out = np.empty((n_constructs, n_features), dtype=np.float64)
    for c in prange(n_constructs):
        p = construct_pool[c]
        s0 = pool_sub_start[p]
        s1 = pool_sub_stop[p]
        width_total = pool_width[p]
        for j in range(n_features):
            obs = construct_observed[c, j]
            if np.isnan(obs):
                out[c, j] = np.nan
                continue
            row = pool_sorted[j]
            m = construct_nn[c, j]
            gt, lt, _, _, _, measured = _pool_tails(row, sub_nn[j], sub_start, sub_stop, s0, s1, width_total, m, obs)
            out[c, j] = _two_sided(gt, lt, measured)
    return out


@njit(cache=True, nogil=True)
def _pool_tails(row, sub_nn_row, sub_start, sub_stop, s0, s1, width_total, m, x):
    """A guide null's exact tails at ``x``: the cell-count-weighted mixture of its control guides' :func:`_sub_tail`.

    Returns ``P(> x)``, ``P(< x)``, ``P(<= x)``, ``P(>= x)``, ``P(nan)`` and ``P(measured)``, each summed
    directly so that a small one keeps its digits.
    """
    gt = lt = le = ge = na = meas = 0.0
    for sub in range(s0, s1):
        a = sub_start[sub]
        width = sub_stop[sub] - a
        w = width / width_total
        g, l_, n_ = _sub_tail(row, a, sub_nn_row[sub], width, m, x)
        gt += w * g
        lt += w * l_
        le += w * (1.0 - g - n_)
        ge += w * (1.0 - l_ - n_)
        na += w * n_
        meas += w * (1.0 - n_)
    return gt, lt, le, ge, na, meas


@njit(cache=True, nogil=True)
def _two_sided(gt: float, lt: float, measured: float) -> float:
    """``2 * min(P(> obs), P(< obs))`` with ties counting half, given the null median is measured.

    ``measured`` is the probability that it is; ``NaN`` if it never is. The clamp catches round-off
    from forming the tie mass as ``measured - gt - lt``.
    """
    if measured <= 0.0:
        return np.nan
    half_eq = 0.5 * (measured - gt - lt)
    return min(1.0, max(0.0, 2.0 * min(gt + half_eq, lt + half_eq) / measured))


@njit(cache=True, nogil=True)
def _add_sub_atoms(v, lo, n, r, scale, eps, out_val, out_prob, cnt, sval, sfl, sgt):
    """Append the atoms of the median of r draws from ``v[lo:lo+n]`` (sorted, no nan).

    Each atom is ``(value, scale*prob)``. Prunes atoms below ``eps`` and stops each unimodal run
    once its probability drops back below it. For even r the distinct values and their tail powers
    are precomputed into the scratch arrays ``sval``/``sfl``/``sgt`` so the off-diagonal inner loop
    is a plain product, O(distinct) powers rather than O(distinct^2). Returns ``-1`` if ``out_val`` fills up.
    """
    if n == 0:
        return cnt
    t = r // 2
    if r % 2 == 1:
        i = lo
        end = lo + n
        seen = False
        while i < end:
            a = v[i]
            j = i
            while j < end and v[j] == a:
                j += 1
            pr = _binom_cdf(t, r, (i - lo) / n) - _binom_cdf(t, r, (j - lo) / n)
            if pr > eps:
                if cnt >= out_val.shape[0]:
                    return -1
                out_val[cnt] = a
                out_prob[cnt] = scale * pr
                cnt += 1
                seen = True
            elif seen:
                break
            i = j
        return cnt
    # even r = 2t: pass 1 records each distinct value, its CDF, and (1-F)^(r-t). Over the band of
    # values that carry atoms these powers and F^t stay normal floats up to a thousand draws; past
    # that they underflow while C(r, t) overflows, so the atoms are formed in log space instead.
    direct = r <= 1000
    log_c = lgamma(r + 1.0) - lgamma(t + 1.0) - lgamma(r - t + 1.0)
    nd = 0
    i = lo
    end = lo + n
    while i < end:
        a = v[i]
        j = i
        while j < end and v[j] == a:
            j += 1
        f = (j - lo) / n
        sval[nd] = a
        sfl[nd] = f
        if direct:
            sgt[nd] = (1.0 - f) ** (r - t)
        else:
            sgt[nd] = (r - t) * log1p(-f) if f < 1.0 else -np.inf
        nd += 1
        i = j
    if not direct:
        # sgt[lb] becomes log rb[lb], rb[lb] = (1-F_{lb-1})^(r-t) - (1-F_lb)^(r-t), then rb[lb] / rb[lb-1]
        for lb in range(nd - 1, 0, -1):
            sgt[lb] = sgt[lb - 1] + log(-expm1(sgt[lb] - sgt[lb - 1]))
        for lb in range(nd - 1, 1, -1):
            sgt[lb] = exp(sgt[lb] - sgt[lb - 1])
    # pass 2: diagonal and off-diagonal atoms from the precomputed tables
    outer_seen = False
    for ka in range(nd):
        f_prev = sfl[ka - 1] if ka > 0 else 0.0
        marg = _binom_cdf(t - 1, r, f_prev) - _binom_cdf(t - 1, r, sfl[ka])  # P(t-th order stat == a)
        if marg > eps:
            outer_seen = True
            # coef = C(r,t) * (F^t - F_prev^t); the diagonal takes off coef * (1-F)^(r-t), and the row's
            # off-diagonal atoms are coef * rb[lb]
            pr = 0.0
            coef = 0.0
            if direct:
                la = sfl[ka] ** t - f_prev**t
                coef = exp(log_c + log(la)) if la > 0.0 else 0.0
                diag = marg - coef * sgt[ka]
                if ka + 1 < nd:
                    pr = coef * (sgt[ka] - sgt[ka + 1])
            else:
                log_coef = log_c + t * log(sfl[ka])
                if f_prev > 0.0:
                    log_coef += log(-expm1(t * log(f_prev / sfl[ka])))
                log_g = (r - t) * log1p(-sfl[ka]) if sfl[ka] < 1.0 else -np.inf
                diag = marg - exp(log_coef + log_g)
                if ka + 1 < nd:
                    log_g_next = (r - t) * log1p(-sfl[ka + 1]) if sfl[ka + 1] < 1.0 else -np.inf
                    pr = exp(log_coef + log_g + log(-expm1(log_g_next - log_g)))
            if diag > eps:
                if cnt >= out_val.shape[0]:
                    return -1
                out_val[cnt] = sval[ka]
                out_prob[cnt] = scale * diag
                cnt += 1
            inner_seen = False
            for lb in range(ka + 1, nd):
                if lb > ka + 1:
                    pr = coef * (sgt[lb - 1] - sgt[lb]) if direct else pr * sgt[lb]
                if pr > eps:
                    if cnt >= out_val.shape[0]:
                        return -1
                    out_val[cnt] = (sval[ka] + sval[lb]) / 2.0
                    out_prob[cnt] = scale * pr
                    cnt += 1
                    inner_seen = True
                elif inner_seen:
                    break
        elif outer_seen:
            break
    return cnt


@njit(cache=True, nogil=True)
def _sort2(val, prob, lo, hi, stack):
    """In-place Hoare quicksort of ``val[lo:hi]`` carrying ``prob`` alongside.

    ``stack`` is scratch for the pending inclusive ranges; recursing on the smaller side keeps its
    depth O(log n). The pre-increment partition always leaves both sides strictly smaller.
    """
    sp = 0
    stack[sp] = lo
    stack[sp + 1] = hi - 1
    sp += 2
    while sp > 0:
        sp -= 2
        left = stack[sp]
        right = stack[sp + 1]
        while left < right:
            mid = (left + right) // 2  # median of three, moved to `left` as the pivot
            if val[mid] < val[left]:
                val[mid], val[left] = val[left], val[mid]
                prob[mid], prob[left] = prob[left], prob[mid]
            if val[right] < val[left]:
                val[right], val[left] = val[left], val[right]
                prob[right], prob[left] = prob[left], prob[right]
            if val[right] < val[mid]:
                val[right], val[mid] = val[mid], val[right]
                prob[right], prob[mid] = prob[mid], prob[right]
            val[left], val[mid] = val[mid], val[left]
            prob[left], prob[mid] = prob[mid], prob[left]
            pv = val[left]
            i = left - 1
            j = right + 1
            while True:
                i += 1
                while val[i] < pv:
                    i += 1
                j -= 1
                while val[j] > pv:
                    j -= 1
                if i >= j:
                    break
                val[i], val[j] = val[j], val[i]
                prob[i], prob[j] = prob[j], prob[i]
            # [left..j] and [j+1..right]; recurse on the smaller, loop on the larger
            if (j - left) < (right - j):
                if left < j:
                    stack[sp] = left
                    stack[sp + 1] = j
                    sp += 2
                left = j + 1
            else:
                if j + 1 < right:
                    stack[sp] = j + 1
                    stack[sp + 1] = right
                    sp += 2
                right = j


@njit(cache=True, nogil=True)
def _collapse(val_buf, prob_buf, lo, hi, stack):
    """Sort ``[lo, hi)`` by value and merge equal values in place; returns the new end."""
    if hi - lo <= 0:
        return lo
    _sort2(val_buf, prob_buf, lo, hi, stack)
    write = lo
    cur_v = val_buf[lo]
    acc = prob_buf[lo]
    for z in range(lo + 1, hi):
        if val_buf[z] == cur_v:
            acc += prob_buf[z]
        else:
            val_buf[write] = cur_v
            prob_buf[write] = acc
            write += 1
            cur_v = val_buf[z]
            acc = prob_buf[z]
    val_buf[write] = cur_v
    prob_buf[write] = acc
    write += 1
    return write


@njit(cache=True, nogil=True)
def _build_construct(
    pool_sorted_row,
    sub_nn_row,
    sub_start,
    sub_stop,
    s0,
    s1,
    width_total,
    m,
    eps,
    val_buf,
    prob_buf,
    stack,
    sval,
    sfl,
    sgt,
):
    """Build one guide null as merged, sorted atoms in ``val_buf``/``prob_buf``; returns their count.

    Mixes every control guide's exact median-atom distribution, then sorts and merges equal values. A
    control guide with missing cells adds one band per number of measured draws, skipping the bands
    whose probability is below ``eps``. Returns ``-1`` if the atoms do not fit the buffer.
    """
    cnt = 0
    for sub in range(s0, s1):
        a = sub_start[sub]
        width = sub_stop[sub] - a
        nn = sub_nn_row[sub]
        if nn == 0:
            continue
        w = width / width_total
        if nn == width:
            cnt = _add_sub_atoms(pool_sorted_row, a, nn, m, w, eps, val_buf, prob_buf, cnt, sval, sfl, sgt)
        else:
            # the number of measured draws is Binomial(m, nn / width); walk out from its mode while the
            # band is worth keeping, as the pmf is unimodal
            p_nn = nn / width
            mode = min(m, max(1, int((m + 1) * p_nn)))
            for step in (-1, 1):
                r = mode if step == -1 else mode + 1
                while 1 <= r <= m and cnt >= 0:
                    term = w * _binom_pmf(r, m, p_nn)
                    if term <= eps:
                        break
                    cnt = _add_sub_atoms(pool_sorted_row, a, nn, r, term, eps, val_buf, prob_buf, cnt, sval, sfl, sgt)
                    r += step
        if cnt < 0:
            return -1
    if cnt == 0:
        return 0
    return _collapse(val_buf, prob_buf, 0, cnt, stack)


@njit(cache=True, nogil=True)
def _step(dp, src, dst, nlo, nhi, stay, up, down, stride=1):
    """Add one guide to the distribution of ``#high - #low``, from row ``src`` of ``dp`` into ``dst[nlo:nhi+1]``.

    The guide is nan with probability ``stay``, high with ``up`` and low with ``down``. Counts outside
    ``[nlo, nhi]`` are dropped, which prunes those that can no longer reach the one asked for; row ``src``
    is zero for two entries past its own range, and ``dst`` is left so. Rows are indexed rather than
    sliced, since a slice of the shared scratch would touch its reference count from every thread.
    With no guide ever nan, the counts after a given number of guides share one parity, so
    ``stride=2`` visits only those.
    """
    if stride == 2:
        for d in range(nlo, nhi + 1, 2):
            dp[dst, d] = dp[src, d - 1] * up + dp[src, d + 1] * down
    else:
        for d in range(nlo, nhi + 1):
            dp[dst, d] = dp[src, d] * stay + dp[src, d - 1] * up + dp[src, d + 1] * down
    _pad(dp, dst, nlo, nhi, stride)


@njit(cache=True, nogil=True)
def _pad(dp, row, lo, hi, stride):
    """Zero the entries past ``[lo, hi]`` that the next :func:`_step` reads, of the parity it reads."""
    dp[row, lo - 2] = dp[row, hi + 2] = 0.0
    if stride == 1:
        dp[row, lo - 1] = dp[row, hi + 1] = 0.0


@njit(cache=True, nogil=True)
def _start(dp, row, o):
    """A count distribution at zero, ``dp[row, o] = 1``, zero-padded as :func:`_step` expects."""
    dp[row, o - 2] = dp[row, o - 1] = dp[row, o + 1] = dp[row, o + 2] = 0.0
    dp[row, o] = 1.0


@njit(cache=True, nogil=True)
def _count_above(k, up, down, stay, dp):
    """``P(#high > #low)`` over ``k`` independent guides, by a dynamic program over the guides.

    The distribution of ``#high - #low`` sits in rows 0 and 1 of ``dp``, centred at ``k + 2``.
    """
    o = k + 2
    _start(dp, 0, o)
    for c in range(k):
        _step(dp, c % 2, 1 - c % 2, o - c - 1, o + c + 1, stay[c], up[c], down[c])
    total = 0.0
    for d in range(o + 1, o + k + 1):
        total += dp[k % 2, d]
    return total


@njit(cache=True, nogil=True)
def _count_at(k, skip, target, up, down, stay, dp, stride=1):
    """``P(#high - #low == target)`` over the ``k`` guides other than ``skip`` (``-1`` for none).

    ``stride=2`` (see :func:`_step`) needs the count of guides taken and ``target`` of equal parity.
    """
    rem = k - 1 if skip >= 0 else k
    if abs(target) > rem:
        return 0.0
    o = k + 2
    _start(dp, 0, o)
    row = 0
    lo = hi = o
    for c in range(k):
        if c == skip:
            continue
        rem -= 1
        lo = max(lo - 1, o + target - rem)
        hi = min(hi + 1, o + target + rem)
        _step(dp, row, 1 - row, lo, hi, stay[c], up[c], down[c], stride)
        row = 1 - row
    return dp[row, o + target]


@njit(cache=True, nogil=True)
def _count_grown(k, below, grown, up, stay, dp, stride):
    """``P(#high == #low and some low guide in its grown part)`` over ``k`` guides.

    A low guide lies in ``below`` with that probability and in ``grown`` with that one, so the result is
    ``F(below + grown) - F(below)`` for ``F`` the probability of as many low as high guides, summed as
    positive terms. Rows 0 and 1 of ``dp`` hold the counts with no low guide in its grown part yet, rows
    2 and 3 those with one. ``stride`` as in :func:`_step`, for an even ``k``.
    """
    o = k + 2
    _start(dp, 0, o)
    _start(dp, 2, o)
    dp[2, o] = 0.0
    src = 0
    lo = hi = o
    for c in range(k):
        rem = k - c - 1
        lo = max(lo - 1, o - rem)
        hi = min(hi + 1, o + rem)
        st = stay[c]
        hi_w = up[c]
        lt = below[c]
        eq = grown[c]
        dst = 1 - src
        if stride == 2:
            for d in range(lo, hi + 1, 2):
                dp[dst, d] = dp[src, d - 1] * hi_w + dp[src, d + 1] * lt
                dp[dst + 2, d] = dp[src + 2, d - 1] * hi_w + dp[src + 2, d + 1] * (lt + eq) + dp[src, d + 1] * eq
        else:
            for d in range(lo, hi + 1):
                dp[dst, d] = dp[src, d] * st + dp[src, d - 1] * hi_w + dp[src, d + 1] * lt
                dp[dst + 2, d] = (
                    dp[src + 2, d] * st
                    + dp[src + 2, d - 1] * hi_w
                    + dp[src + 2, d + 1] * (lt + eq)
                    + dp[src, d + 1] * eq
                )
        _pad(dp, dst, lo, hi, stride)
        _pad(dp, dst + 2, lo, hi, stride)
        src = dst
    return dp[src + 2, o]


@njit(cache=True, nogil=True)
def _close_run(k, lo_c, cum, plow, inv_low, run_ptr, lptr, wh, pnan, below, grown, dp, stride):
    """The terms of a run of swept atoms under one cut: ``F`` at its end minus ``F`` at its start.

    Guide ``a`` swept the atoms ``[run_ptr[a], lptr[a])`` in the run, so its low weight grew from the
    mass below ``run_ptr[a]`` by the mass of those atoms; ``below``/``grown`` are scratch for both.
    """
    for a in range(k):
        lo = lo_c[a]
        p0 = run_ptr[a]
        start = cum[p0 - 1] if p0 > lo else 0.0
        below[a] = plow[a] * start * inv_low[a]
        grown[a] = plow[a] * ((cum[lptr[a] - 1] if lptr[a] > lo else 0.0) - start) * inv_low[a]
    return _count_grown(k, below, grown, wh, pnan, dp, stride)


@njit(cache=True, nogil=True)
def _any_positive(x, k):
    """Whether any of ``x[:k]`` is positive, without slicing ``x``."""
    for i in range(k):
        if x[i] > 0.0:
            return True
    return False


@njit(cache=True, nogil=True)
def _boundary(k, lo_c, hi_c, vals, cum, obs, plow, phigh, pnan, fs, ints, dp):
    """``P(#high == #low >= 1 and the midpoint of the largest low and smallest high guide > obs)``.

    This is the part of ``P(gene median > obs)`` that the counts alone do not settle: an even number of
    measured guides, half of them at most ``obs`` (low). Guide ``a``'s atoms are ``vals[lo_c[a]:hi_c[a]]``,
    sorted and distinct, with ``cum`` their running mass from that list's start. ``plow``/``phigh``/``pnan``
    are its exact probabilities of being low, high or nan, so the count part stays exact; the atoms give
    only the shape within each side.

    One ascending sweep over the atoms at most ``obs`` takes each value ``a`` in turn as the largest low
    one. The high guides must then sit past the cut, the atoms whose float midpoint with ``a`` exceeds
    ``obs``. With ``F`` the probability of as many low as high guides, the low ones at most a given value
    and the high ones past the cut, the term of ``a`` is ``F(at most a) - F(below a)``. While the cut stays
    put these terms telescope, so a run of atoms under one cut is summed at once, by a dynamic program
    over the guides (:func:`_count_grown`) that keeps every term positive. ``fs`` (7 rows), ``ints``
    (5 rows) and ``dp`` (4 rows of ``2 k + 5``) are scratch.
    """
    inv_low, inv_high, top, wl, wh, wh_old, grown = fs[0], fs[1], fs[2], fs[3], fs[4], fs[5], fs[6]
    le_end, gt_start, lptr, cut, run_ptr = ints[0], ints[1], ints[2], ints[3], ints[4]
    for a in range(k):
        lo = lo_c[a]
        hi = hi_c[a]
        cnt_le = _count_below(vals, lo, hi, obs, True)
        at_obs = cum[lo + cnt_le - 1] if cnt_le > 0 else 0.0
        top[a] = cum[hi - 1] if hi > lo else 0.0
        inv_low[a] = 1.0 / at_obs if at_obs > 0.0 else 0.0
        inv_high[a] = 1.0 / (top[a] - at_obs) if top[a] > at_obs else 0.0
        wl[a] = plow[a] if inv_low[a] > 0.0 else 0.0  # for the check below: low with an atom to sweep
        wh[a] = phigh[a] if inv_high[a] > 0.0 else 0.0
        le_end[a] = lo + cnt_le  # one past the last atom <= obs
        gt_start[a] = lo + cnt_le  # first atom > obs
        lptr[a] = lo  # next atom <= obs not yet swept
        run_ptr[a] = lo  # where the current run of atoms under one cut began
        cut[a] = hi  # atoms [cut, hi) have midpoint (aval + .)/2 > obs
    # with no guide ever nan the count here is even, and every count the program reaches keeps a parity
    stride = 1 if _any_positive(pnan, k) else 2
    # no split into as many low as high guides, each with atoms on its side, can happen: nothing to sweep
    all_nan = 1.0
    for a in range(k):
        all_nan *= pnan[a]
    if _count_at(k, -1, 0, wh, wl, pnan, dp, stride) <= all_nan:
        return 0.0
    for a in range(k):
        wh[a] = 0.0  # P(high and past the current cut)
    total = 0.0
    n_high = 0  # guides with an atom past the cut; until one has, every term is zero
    while True:
        aval = np.inf
        for a in range(k):
            if lptr[a] < le_end[a] and vals[lptr[a]] < aval:
                aval = vals[lptr[a]]
        if aval == np.inf:
            break
        # the cut only moves down as aval grows; where it moves, close the run of terms under the old one
        moved = False
        for a in range(k):
            q = cut[a]
            if q > gt_start[a] and (aval + vals[q - 1]) / 2.0 > obs:
                if not moved:
                    for b in range(k):
                        wh_old[b] = wh[b]
                moved = True
                q -= 1
                while q > gt_start[a] and (aval + vals[q - 1]) / 2.0 > obs:
                    q -= 1
                cut[a] = q
                past = phigh[a] * (top[a] - (cum[q - 1] if q > lo_c[a] else 0.0)) * inv_high[a]
                n_high += wh[a] == 0.0 and past > 0.0
                wh[a] = past
        if moved:
            if _any_positive(wh_old, k):
                total += _close_run(k, lo_c, cum, plow, inv_low, run_ptr, lptr, wh_old, pnan, wl, grown, dp, stride)
            for a in range(k):
                run_ptr[a] = lptr[a]
        for a in range(k):
            if lptr[a] < le_end[a] and vals[lptr[a]] == aval:
                lptr[a] += 1
    if n_high > 0:
        total += _close_run(k, lo_c, cum, plow, inv_low, run_ptr, lptr, wh, pnan, wl, grown, dp, stride)
    return total


@njit(cache=True, nogil=True)
def _deal(cost, n_slots):
    """Deal items to ``n_slots`` slots, costliest first onto the least-loaded slot.

    Returns ``(order, start)``: slot ``s`` runs ``order[start[s]:start[s + 1]]``. The parallel loops
    run one slot per thread, so the threads finish together however unequal the items are.
    """
    n = cost.size
    load = np.zeros(n_slots)
    slot = np.empty(n, dtype=np.int64)
    for i in np.argsort(-cost):
        s = np.argmin(load)
        slot[i] = s
        load[s] += cost[i]
    return _counting_order(slot, n_slots)


@njit(parallel=True, cache=True, nogil=True)
def _gene_pvalues(
    pool_sorted,
    sub_nn,
    sub_start,
    sub_stop,
    pool_sub_start,
    pool_sub_stop,
    pool_width,
    construct_nn,
    construct_null,
    null_pool,
    null_size,
    null_caps,
    gene_offsets,
    gene_observed,
    eps,
    n_slots,
):
    """Two-sided p-value of every gene against its guides' control pools, exactly.

    A guide's null depends only on its pool and measured cell count, so per feature each distinct
    ``(null_pool, null_size)`` null is built once into a shared atom store, and the genes then only
    read it; ``construct_null[j, c]`` names guide ``c``'s null in feature ``j``, ``-1`` where the guide
    measures nothing and so leaves the gene. Work is dealt to ``n_slots`` slots (see :func:`_deal`),
    each with its own scratch. Also returns which nulls had more atoms than their buffer holds; those
    keep no atoms, so the even-count boundary they enter counts as a tie.
    """
    n_genes = gene_offsets.size - 1
    n_features = gene_observed.shape[1]
    n_constructs = construct_null.shape[1]
    n_nulls = null_pool.size
    out = np.empty((n_genes, n_features), dtype=np.float64)
    overflow = np.zeros(n_nulls, dtype=np.bool_)
    max_cap = max(1, null_caps.max())
    max_k = max(1, np.diff(gene_offsets).max())
    max_width = max(1, (sub_stop - sub_start).max())
    val_t = np.empty((n_slots, max_cap), dtype=np.float64)
    prob_t = np.empty((n_slots, max_cap), dtype=np.float64)
    stack_t = np.empty((n_slots, 128), dtype=np.int64)
    sval_t = np.empty((n_slots, max_width), dtype=np.float64)
    sfl_t = np.empty((n_slots, max_width), dtype=np.float64)
    sgt_t = np.empty((n_slots, max_width), dtype=np.float64)
    # per gene: lo_c, hi_c, its guides' tails, and the _boundary scratch; rows written at every atom,
    # padded by a cache line so that slots on different cores share none
    row_k = max_k + 8
    lohi_t = np.empty((n_slots, 2, row_k), dtype=np.int64)
    tails_t = np.empty((n_slots, 6, row_k), dtype=np.float64)
    fs_t = np.empty((n_slots, 7, row_k), dtype=np.float64)
    ints_t = np.empty((n_slots, 5, row_k), dtype=np.int64)
    dp_t = np.empty((n_slots, 4, 2 * max_k + 5 + 8), dtype=np.float64)
    used = np.empty(n_nulls, dtype=np.bool_)
    counts = np.empty(n_nulls, dtype=np.int64)
    offs = np.zeros(n_nulls + 1, dtype=np.int64)
    null_order, null_start = _deal(null_caps.astype(np.float64), n_slots)
    gene_cost = np.empty(n_genes, dtype=np.float64)
    for j in range(n_features):
        row = pool_sorted[j]
        nn_row = sub_nn[j]
        used[:] = False
        for c in range(n_constructs):
            if construct_null[j, c] >= 0:
                used[construct_null[j, c]] = True
        # size each used null's merged atom list, then build it again into its slot of the store
        for t in prange(n_slots):
            for ui in range(null_start[t], null_start[t + 1]):
                u = null_order[ui]
                counts[u] = 0
                if not used[u]:
                    continue
                p = null_pool[u]
                end = _build_construct(
                    row,
                    nn_row,
                    sub_start,
                    sub_stop,
                    pool_sub_start[p],
                    pool_sub_stop[p],
                    pool_width[p],
                    null_size[u],
                    eps,
                    val_t[t],
                    prob_t[t],
                    stack_t[t],
                    sval_t[t],
                    sfl_t[t],
                    sgt_t[t],
                )
                if end < 0:
                    overflow[u] = True
                else:
                    counts[u] = end
        for u in range(n_nulls):
            offs[u + 1] = offs[u] + counts[u]
        vals = np.empty(offs[n_nulls], dtype=np.float64)
        cum = np.empty(offs[n_nulls], dtype=np.float64)  # running mass from the list's start
        rvals = np.empty(offs[n_nulls], dtype=np.float64)  # the list negated and reversed, for the < side
        rcum = np.empty(offs[n_nulls], dtype=np.float64)
        for t in prange(n_slots):
            for ui in range(null_start[t], null_start[t + 1]):
                u = null_order[ui]
                if counts[u] == 0:
                    continue
                p = null_pool[u]
                end = _build_construct(
                    row,
                    nn_row,
                    sub_start,
                    sub_stop,
                    pool_sub_start[p],
                    pool_sub_stop[p],
                    pool_width[p],
                    null_size[u],
                    eps,
                    val_t[t],
                    prob_t[t],
                    stack_t[t],
                    sval_t[t],
                    sfl_t[t],
                    sgt_t[t],
                )
                lo = offs[u]
                running = 0.0
                for z in range(end):
                    vals[lo + z] = val_t[t, z]
                    running += prob_t[t, z]
                    cum[lo + z] = running
                running = 0.0
                for z in range(end):
                    rvals[lo + z] = -val_t[t, end - 1 - z]
                    running += prob_t[t, end - 1 - z]
                    rcum[lo + z] = running
        # a gene costs about its guides' atom count, more when an even count sends it through the boundary sweep
        for g in range(n_genes):
            tot = 0
            k = 0
            for c in range(gene_offsets[g], gene_offsets[g + 1]):
                if construct_null[j, c] >= 0:
                    tot += counts[construct_null[j, c]]
                    k += 1
            gene_cost[g] = tot * (1.0 + (k % 2 == 0) * k) + 1.0
        gene_order, gene_start = _deal(gene_cost, n_slots)
        for t in prange(n_slots):
            # the slot's scratch views, taken once: each view touches the shared reference count
            lo_c, hi_c = lohi_t[t, 0], lohi_t[t, 1]
            gt_arr, lt_arr, le_arr, ge_arr, nan_arr, meas_arr = (
                tails_t[t, 0],
                tails_t[t, 1],
                tails_t[t, 2],
                tails_t[t, 3],
                tails_t[t, 4],
                tails_t[t, 5],
            )
            fs, ints, dp = fs_t[t], ints_t[t], dp_t[t]
            for gi in range(gene_start[t], gene_start[t + 1]):
                g = gene_order[gi]
                obs = gene_observed[g, j]
                if np.isnan(obs):
                    out[g, j] = np.nan
                    continue
                k = 0
                for c in range(gene_offsets[g], gene_offsets[g + 1]):
                    u = construct_null[j, c]
                    if u < 0:  # its observed median is nan, so it is not in the gene's median either
                        continue
                    lo_c[k] = offs[u]
                    hi_c[k] = offs[u + 1]
                    # exact tails from the sub-tail mixture, so the gene count that decides
                    # significance never depends on the atom coarsening
                    p = null_pool[u]
                    gt_arr[k], lt_arr[k], le_arr[k], ge_arr[k], nan_arr[k], meas_arr[k] = _pool_tails(
                        row,
                        nn_row,
                        sub_start,
                        sub_stop,
                        pool_sub_start[p],
                        pool_sub_stop[p],
                        pool_width[p],
                        construct_nn[c, j],
                        obs,
                    )
                    k += 1
                # more high than low guides puts the median above obs; as many of each leaves the
                # midpoint of the two central guides to decide, possible for an even measured count
                above = _count_above(k, gt_arr, le_arr, nan_arr, dp)
                below = _count_above(k, lt_arr, ge_arr, nan_arr, dp)
                all_nan = 1.0
                any_measured = False
                for ci in range(k):
                    all_nan *= nan_arr[ci]
                    any_measured |= meas_arr[ci] > 0.0
                if k >= 2 and (k % 2 == 0 or _any_positive(nan_arr, k)):
                    above += _boundary(k, lo_c, hi_c, vals, cum, obs, le_arr, gt_arr, nan_arr, fs, ints, dp)
                    below += _boundary(k, lo_c, hi_c, rvals, rcum, -obs, ge_arr, lt_arr, nan_arr, fs, ints, dp)
                out[g, j] = _two_sided(above, below, 1.0 - all_nan if any_measured else 0.0)
    return out, overflow


#: Hard limit on a guide null's per-thread atom buffer, so a huge screen cannot ask for unbounded memory;
#: the probability pruning keeps the real count far below this, and small problems never approach it.
_ATOM_CAP_MAX = 40_000_000


def _sub_atom_bound(nn: np.ndarray, r: np.ndarray) -> np.ndarray:
    """Upper bound on the median atoms of ``r`` draws from ``nn`` distinct-or-not values, before merging.

    The central order statistic's empirical CDF carries atoms above :data:`_ATOM_EPS` only within the band
    ``|F - 1/2| < ~3/sqrt(r)``; that band holds at most ``nw = min(nn, 7*nn/sqrt(r))`` distinct values, so
    at most ``nw^2 + nw`` even-median atoms, never more than the ``nn(nn+1)/2 + nn`` pairs.
    """
    nw = np.minimum(nn, (7 * nn) // np.sqrt(np.maximum(r, 1)).astype(np.int64) + 4)
    return np.minimum(nn * (nn + 1) // 2 + nn, nw * nw + nw)


def _null_caps(sub_nn, sub_start, sub_stop, pool_sub_start, pool_sub_stop, null_pool, null_size, eps):
    """Per-null atom-buffer size: an upper bound on the atoms a guide null generates before merging.

    A control guide measured in every cell adds one band, for the null's draw count ``m``. One with
    missing cells adds a band per number ``r`` of measured draws whose probability tops ``eps``; by
    Hoeffding's bound those lie within ``sqrt(m log(1/eps) / 2)`` of ``m`` times its measured share.
    """
    widths = (sub_stop - sub_start).astype(np.int64)
    partial = [np.unique(col[(col > 0) & (col < width)]) for col, width in zip(sub_nn.T, widths, strict=True)]
    caps = np.empty(null_pool.size, dtype=np.int64)
    for u, (p, m) in enumerate(zip(null_pool, null_size, strict=True)):
        subs = slice(pool_sub_start[p], pool_sub_stop[p])
        bound = _sub_atom_bound(widths[subs], np.int64(m))
        for i, nn in enumerate(partial[subs]):
            if nn.size:
                spread = np.sqrt(m * np.log(1.0 / eps) / 2.0)
                r_lo = np.maximum(1, np.floor(m * nn / widths[subs][i] - spread)).astype(np.int64)
                r_hi = np.minimum(m, np.ceil(m * nn / widths[subs][i] + spread)).astype(np.int64)
                bands = np.maximum(r_hi - r_lo + 1, 0) * _sub_atom_bound(nn, r_lo)
                bound[i] = max(bound[i], int(bands.max()))
        caps[u] = min(int(bound.sum()) + 16, _ATOM_CAP_MAX)
    return caps


[docs] @inplace_or_copy(expects="object") def bootstrap_features( adata: AnnData, groupby: str | list[str] = "Metadata_Gene", replicate: str = "Metadata_sgRNA", reference: str | None = "negcon", control_scope: str = "pooled", condition: str | None = None, reference_condition: str | None = None, layer: str | None = None, key_added: str = "bootstrap", copy: bool = False, ) -> AnnData | None: """Test each feature of each gene against the null of resampling the control guides' cells. Each guide (each ``replicate`` within a ``groupby`` group) is summarised by the median of its cells, per feature, and each gene by the median of its guides' medians. The guide's null draws one control guide with probability proportional to its cell count, draws as many of that guide's cells as the tested guide has, with replacement, and takes their median; the gene's null is the median across its guides' such nulls. Because each draw comes from one control guide, the null carries the spread between control guides as well as the sampling error of a median. The null is not simulated but computed exactly: the median of a resample is an order statistic (a binomial tail in the guide's empirical distribution, and for an even cell count the mean of two consecutive order statistics), the guide null is the cell-count-weighted mixture of its control guides' such distributions, and the gene null is the order-statistic distribution of its independent guide nulls, summed over its guides by a dynamic program so that a gene of many guides stays cheap. The p-values are therefore the ones an infinite bootstrap converges to, with no simulation noise and no lower limit. The test is Brieflow's bootstrap for optical pooled screens. Every feature of ``adata`` is tested, so subset ``adata`` to the features you want tested. The output has the layout of :func:`~mantispy.tl.differential_features`, so :func:`~mantispy.pl.feature_volcano` and :func:`~mantispy.pl.effect_sizes` read it with ``key=key_added``. :func:`~mantispy.tl.differential_features` fits wells and refuses cells, while this function works on cells and uses the guide as the replicate, the unit of a pooled screen in which every well holds every guide. Args: adata: Cell-level object. groupby: ``obs`` column, or columns, naming the gene each cell's guide targets; each group is tested. replicate: ``obs`` column naming the guide, the unit the null resamples. reference: The control cells: ``"negcon"``, or the name of a boolean ``obs`` column. Their guides form the null and are not tested. Unused under ``control_scope="within_perturbation"``. control_scope: Which control cells a guide's null is drawn from. ``"pooled"`` uses every control cell. ``"within_group"`` uses the control cells that share the guide's ``condition``. ``"reference_group"`` uses the control cells in ``reference_condition``, so every guide, whatever its condition, is tested against the controls of one condition. ``"within_perturbation"`` uses the cells of the same gene in ``reference_condition``, so each gene is its own control, and tests the gene's other conditions against it. It needs ``condition`` among the ``groupby`` columns, so that a gene in each condition is its own group. condition: ``obs`` column naming a treatment or other condition the scope pins the null to. With it set, a guide's cells in two conditions are two guides, and so are a control guide's. reference_condition: The value of ``condition`` that ``"reference_group"`` and ``"within_perturbation"`` draw the null from. layer: Read this layer instead of ``X``. key_added: Name for the output tables. copy: Return a modified copy instead of mutating in place. Returns: ``None``, or the modified copy. Writes ``uns["mantispy"][key_added]``, a tidy frame of ``group``, ``feature``, ``effect`` (the gene's median minus the median of its guides' control pools, in the units of ``X``), ``pvalue`` and ``qvalue`` (Benjamini-Hochberg over every gene and feature at once). Also writes ``uns["mantispy"][key_added + "_guides"]``, the same per guide, with ``replicate`` and ``n_cells`` columns and its own q-values. Raises: ValueError: ``control_scope`` is unknown, a scope that needs ``condition`` or ``reference_condition`` lacks it, ``"within_perturbation"`` is used without ``condition`` in ``groupby``, the controls in ``reference_condition`` are empty, or no guide has a null to draw from. Notes: The p-value is two-sided, ``2 * min(p, 1 - p)`` with ``p`` the probability that a null median exceeds the observed one. It is exact rather than simulated, so it has no lower floor and can be zero when the observed median lies beyond every attainable null median. A null median equal to the observed one counts half, so a feature where most cells share one value, such as a count that is mostly zero, is not called because its median cannot move; Brieflow counts such ties as below. Medians skip missing values. A guide's null draws as many cells as the guide measures in the feature, a guide that measures none leaves its gene's median and null, and a null median that comes out missing counts neither way. A feature whose observed median is missing, or that no control cell measures, is left ``NaN``. The null resamples a control guide's own cells, so it carries that sample's error on top of the spread between control guides, while a tested guide carries only its own. The test therefore runs conservative when guides barely differ from one another, and near the nominal rate when they differ by more than the error of a guide's median. Check the rate on your own screen by grouping held-out control guides into pseudo-genes and testing them against the other control guides. """ if control_scope not in CONTROL_SCOPES: raise ValueError(f"control_scope must be one of {CONTROL_SCOPES}, got {control_scope!r}") if control_scope != "pooled" and condition is None: raise ValueError(f"control_scope={control_scope!r} needs condition= the column to scope the controls by") if control_scope in ("reference_group", "within_perturbation") and reference_condition is None: raise ValueError(f"control_scope={control_scope!r} needs reference_condition=") by = [groupby] if isinstance(groupby, str) else list(groupby) if control_scope == "within_perturbation" and condition not in by: raise ValueError( f"control_scope='within_perturbation' needs condition={condition!r} among the groupby columns, so a gene " "in each condition is its own group" ) obs = as_frame(adata.obs) logger = get_logger() values = _kernel_input(get_matrix(adata, layer)) unit_codes, unit_keys = group_codes(adata, by) # a guide is its group and its label, so control guides of two control genes that share a label stay apart sub_by = [*by, replicate, *([condition] if condition is not None and condition not in by else [])] sub_codes, sub_keys = group_codes(adata, sub_by) if control_scope == "within_perturbation": in_reference = (obs[condition].astype(str) == str(reference_condition)).to_numpy() perturbation_codes = group_codes(adata, [column for column in by if column != condition] or None)[0] tested = ~in_reference & np.isin(perturbation_codes, perturbation_codes[in_reference]) unreferenced = ~in_reference & ~tested if unreferenced.any(): logger.warning( "bootstrap_features: %d group(s) have no %r arm to draw their null from and are not tested", len(np.unique(unit_codes[unreferenced])), reference_condition, ) pool_of_row = np.where(in_reference, perturbation_codes, -1) else: is_control = reference_mask(adata, reference) tested = ~is_control if control_scope == "pooled": pool_of_row = np.where(is_control, 0, -1) else: condition_values = obs[condition].astype(str).to_numpy() if control_scope == "reference_group": in_reference = is_control & (condition_values == str(reference_condition)) if not in_reference.any(): present = sorted(set(condition_values[is_control])) raise ValueError( f"no control cell has {condition}={reference_condition!r}; control conditions: {present}" ) pool_of_row = np.where(in_reference, 0, -1) else: condition_codes = pd.factorize(condition_values, sort=True)[0].astype(np.int64) pool_of_row = np.where(is_control, condition_codes, -1) # The pool a tested row's guide draws its null from, by the scope's key. if control_scope in ("pooled", "reference_group"): row_pool_key = np.zeros(adata.n_obs, dtype=np.int64) elif control_scope == "within_group": row_pool_key = condition_codes else: row_pool_key = perturbation_codes # Lay the pool cells out pool by pool, and within a pool control guide by control guide. n_subs = len(sub_keys) pool_rows = np.flatnonzero(pool_of_row >= 0) pool_rows = pool_rows[np.lexsort((pool_rows, sub_codes[pool_rows], pool_of_row[pool_rows]))] pool_keys, pool_starts = np.unique(pool_of_row[pool_rows], return_index=True) pool_stops = np.append(pool_starts[1:], pool_rows.size) pair = pool_of_row[pool_rows].astype(np.int64) * n_subs + sub_codes[pool_rows] sub_breaks = np.flatnonzero(np.diff(pair)) + 1 sub_start = np.concatenate([[0], sub_breaks]).astype(np.int64) sub_stop = np.append(sub_breaks, pool_rows.size).astype(np.int64) no_pool = tested & ~np.isin(row_pool_key, pool_keys) if no_pool.any(): logger.warning( "bootstrap_features: %d cell(s) have no control cells in their %r scope and are not tested", int(no_pool.sum()), control_scope, ) tested &= ~no_pool if not tested.any(): raise ValueError("no guide has control cells in its scope to draw a null from") # A construct is one guide of one tested group; sorting by its id sorts the constructs by group. rows_kept = np.flatnonzero(tested) constructs, row_construct = np.unique( unit_codes[rows_kept].astype(np.int64) * n_subs + sub_codes[rows_kept], return_inverse=True ) construct_unit, construct_sub = constructs // n_subs, constructs % n_subs first_row = rows_kept[np.unique(row_construct, return_index=True)[1]] construct_pool = np.searchsorted(pool_keys, row_pool_key[first_row]).astype(np.int64) construct_of_row = np.zeros(adata.n_obs, dtype=np.int64) construct_of_row[rows_kept] = row_construct construct_observed = grouped_stat(values, construct_of_row, constructs.size, MEDIAN, mask=tested) construct_size = np.bincount(row_construct, minlength=constructs.size).astype(np.int64) # the cells each guide measures per feature: its null draws that many, as its median is over them by_construct = np.argsort(row_construct, kind="stable") construct_nn = np.add.reduceat( ~np.isnan(values[rows_kept[by_construct]]), np.searchsorted(row_construct[by_construct], np.arange(constructs.size)), axis=0, ).astype(np.int64) in_pool = pool_of_row >= 0 pool_median = grouped_stat(values, np.searchsorted(pool_keys, pool_of_row), pool_keys.size, MEDIAN, mask=in_pool) # Sort each control guide's cells, per feature, so the kernels can read order statistics; a guide's # non-nan values come first (nan sorts last) and sub_nn counts them. Lay out the pool-to-guide index. pool_sorted = np.ascontiguousarray(values[pool_rows].T) n_features = pool_sorted.shape[0] n_subs_total = sub_start.size sub_nn = np.empty((n_features, n_subs_total), dtype=np.int64) for sub in range(n_subs_total): a, b = int(sub_start[sub]), int(sub_stop[sub]) block = pool_sorted[:, a:b] block.sort(axis=1) sub_nn[:, sub] = (~np.isnan(block)).sum(axis=1) sub_pool_idx = np.searchsorted(pool_keys, pool_of_row[pool_rows[sub_start]]) pool_sub_start = np.searchsorted(sub_pool_idx, np.arange(pool_keys.size), side="left").astype(np.int64) pool_sub_stop = np.searchsorted(sub_pool_idx, np.arange(pool_keys.size), side="right").astype(np.int64) pool_width = (pool_stops - pool_starts).astype(np.int64) genes, gene_starts = np.unique(construct_unit, return_index=True) gene_offsets = np.append(gene_starts, construct_unit.size).astype(np.int64) gene_of_construct = np.searchsorted(genes, construct_unit) gene_observed = grouped_stat(construct_observed, gene_of_construct, genes.size, MEDIAN) gene_baseline = grouped_stat(pool_median[construct_pool], gene_of_construct, genes.size, MEDIAN) guide_p = _guide_pvalues( pool_sorted, sub_nn, sub_start, sub_stop, pool_sub_start, pool_sub_stop, pool_width, construct_pool, construct_nn, construct_observed, ) # a guide's null depends only on its pool and measured cell count, so guides sharing both share it stride = construct_nn.max() + 1 null_of = (construct_pool[:, None] * stride + construct_nn).T measured = null_of % stride > 0 null_keys, inverse = np.unique(null_of[measured], return_inverse=True) construct_null = np.full(null_of.shape, -1, dtype=np.int64) construct_null[measured] = inverse null_pool, null_size = np.divmod(null_keys, stride) gene_p, overflow = _gene_pvalues( pool_sorted, sub_nn, sub_start, sub_stop, pool_sub_start, pool_sub_stop, pool_width, construct_nn, construct_null, null_pool.astype(np.int64), null_size.astype(np.int64), _null_caps(sub_nn, sub_start, sub_stop, pool_sub_start, pool_sub_stop, null_pool, null_size, _ATOM_EPS), gene_offsets, gene_observed, _ATOM_EPS, numba.get_num_threads(), ) if overflow.any(): logger.warning( "bootstrap_features: %d guide null(s) have too many distinct medians to hold, so a gene whose " "null median would fall between two of their guides counts that case as a tie", int(overflow.sum()), ) features = adata.var_names.to_numpy() n_features = features.size gene_labels = [str(unit_keys[unit]) for unit in genes] table = pd.DataFrame( { "group": np.repeat(gene_labels, n_features), "feature": np.tile(features, genes.size), "effect": (gene_observed - gene_baseline).ravel(), "pvalue": np.where(np.isfinite(gene_observed), gene_p, np.nan).ravel(), } ) table["qvalue"] = benjamini_hochberg(table["pvalue"].to_numpy()) sub_labels = [str(sub_keys[s][len(by)]) for s in range(len(sub_keys))] guides = pd.DataFrame( { "group": np.repeat([gene_labels[g] for g in gene_of_construct], n_features), "replicate": np.repeat([sub_labels[s] for s in construct_sub], n_features), "n_cells": np.repeat(construct_size, n_features), "feature": np.tile(features, constructs.size), "effect": (construct_observed - pool_median[construct_pool]).ravel(), "pvalue": np.where(np.isfinite(construct_observed), guide_p, np.nan).ravel(), } ) if condition is not None: guides.insert( 2, condition, np.repeat([str(sub_keys[s][sub_by.index(condition)]) for s in construct_sub], n_features) ) guides["qvalue"] = benjamini_hochberg(guides["pvalue"].to_numpy()) logger.info( "bootstrap_features: %d of %d gene-feature pair(s) at q < 0.05 over %d gene(s)", int((table["qvalue"] < 0.05).sum()), len(table), genes.size, ) store = adata.uns.setdefault("mantispy", {}) store[key_added] = table store[f"{key_added}_guides"] = guides return None