"""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, _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 _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 = np.searchsorted(v[lo : lo + n], x, side="right") # 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 = v[i]
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 - np.searchsorted(v[lo : lo + n], v[left], side="left") # 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 = np.searchsorted(v[lo : lo + n], x, side="left") # 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 = 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_size: np.ndarray,
construct_observed: np.ndarray,
) -> np.ndarray:
"""Two-sided p-value of every guide against its control pool, exactly."""
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]
m = construct_size[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]
gt = 0.0
lt = 0.0
nanp = 0.0
for sub in range(s0, s1):
a = sub_start[sub]
width = sub_stop[sub] - a
nn = sub_nn[j, sub]
w = width / width_total
g, l_, na = _sub_tail(row, a, nn, width, m, obs)
gt += w * g
lt += w * l_
nanp += w * na
eq = 1.0 - gt - lt - nanp
one = gt + 0.5 * eq
# clamp tiny round-off below 0 or above 1 from the eq = 1 - gt - lt - nan subtraction
out[c, j] = min(1.0, max(0.0, 2.0 * min(one, 1.0 - one)))
return out
@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).
"""
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 cnt
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 cnt
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 cnt
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,
start,
stack,
sval,
sfl,
sgt,
):
"""Build one guide null as merged, sorted atoms in ``val_buf``/``prob_buf`` from ``start``.
Mixes every control guide's exact median-atom distribution, then sorts and merges equal values.
Returns ``(end, nan_prob)``; the atom probabilities sum to ``1 - nan_prob``.
"""
cnt = start
nan_prob = 0.0
for sub in range(s0, s1):
a = sub_start[sub]
width = sub_stop[sub] - a
nn = sub_nn_row[sub]
w = width / width_total
if nn == 0:
nan_prob += w
continue
p_nn = nn / width
if p_nn >= 1.0:
cnt = _add_sub_atoms(pool_sorted_row, a, nn, m, w, eps, val_buf, prob_buf, cnt, sval, sfl, sgt)
else:
nan_prob += w * (1.0 - p_nn) ** m
for r in range(1, m + 1):
term = _binom_pmf(r, m, p_nn)
if term > 0.0:
cnt = _add_sub_atoms(
pool_sorted_row, a, nn, r, w * term, eps, val_buf, prob_buf, cnt, sval, sfl, sgt
)
if cnt == start:
return start, nan_prob
return _collapse(val_buf, prob_buf, start, cnt, stack), nan_prob
@njit(cache=True, nogil=True)
def _poisbin_cdf(j, probs, k, dp):
"""``P(sum of k independent Bernoulli(probs[:k]) <= j)`` by DP, in the scratch ``dp`` (size > k)."""
if j < 0:
return 0.0
if j >= k:
return 1.0
dp[: k + 1] = 0.0
dp[0] = 1.0
size = 1
for i in range(k):
p = probs[i]
for q in range(size, 0, -1):
dp[q] = dp[q] * (1.0 - p) + dp[q - 1] * p
dp[0] = dp[0] * (1.0 - p)
size += 1
s = 0.0
for q in range(j + 1):
s += dp[q]
return s
@njit(cache=True, nogil=True)
def _even_side(memb, nmem, lo_c, hi_c, vals, cum, obs, s, ple, fs, ints, pats, patw, bg, dp):
"""``P(median of the nmem listed guides > obs)`` for an even count ``2s``.
Guide ``c``'s atoms are ``vals[lo_c[c]:hi_c[c]]``, sorted and distinct, with ``cum`` their running
mass from that list's start. ``ple[a]`` is the exact ``P(guide <= obs | not nan)`` (the count part
is kept exact so a coarsened atom list cannot move probability across ``obs``); the atoms give only
the boundary shape where exactly ``s`` guides are at most ``obs`` (the low ones), classified by the
actual float midpoint of the largest low and the smallest high guide. One ascending sweep over all
the guides' atoms at most ``obs`` serves every split into low and high guides at once, so each
atom list is read once. ``fs``/``ints`` are float/int scratch rows, ``pats``/``patw``/``bg`` hold
one entry per split.
"""
f_low, f_high, rle, rlt, rh, inv_low, inv_high, top = fs[0], fs[1], fs[2], fs[3], fs[4], fs[5], fs[6], fs[7]
le_end, gt_start, lptr, cut = ints[0], ints[1], ints[2], ints[3]
g = _poisbin_cdf(s - 1, ple, nmem, dp)
for a in range(nmem):
c = memb[a]
lo = lo_c[c]
hi = hi_c[c]
cnt_le = np.searchsorted(vals[lo:hi], obs, side="right")
at_obs = cum[lo + cnt_le - 1] if cnt_le > 0 else 0.0
f_low[a] = at_obs # mass at most obs, for the guide as a low one
top[a] = cum[hi - 1] if hi > lo else 0.0
f_high[a] = top[a] - at_obs # mass above obs, as a high one
inv_low[a] = 1.0 / at_obs if at_obs > 0.0 else 0.0
inv_high[a] = 1.0 / f_high[a] if f_high[a] > 0.0 else 0.0
rle[a] = 0.0
rh[a] = 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
cut[a] = hi # atoms [cut, hi) have midpoint (aval + .)/2 > obs
# the splits with s low guides; a guide with no atom on its side of obs gives the boundary no
# shape, and its exact probability of being there is below the pruned atoms', so such a split,
# at most that probable, is left out
npat = 0
for lowmask in range(1 << nmem):
pc = 0
mm = lowmask
while mm:
pc += mm & 1
mm >>= 1
if pc != s:
continue
pat = 1.0
shapeless = False
for a in range(nmem):
if (lowmask >> a) & 1:
pat *= ple[a]
shapeless |= f_low[a] <= 0.0
else:
pat *= 1.0 - ple[a]
shapeless |= f_high[a] <= 0.0
if pat <= 0.0 or shapeless:
continue
pats[npat] = lowmask
patw[npat] = pat
bg[npat] = 0.0
npat += 1
while npat > 0:
aval = np.inf
for a in range(nmem):
if lptr[a] < le_end[a] and vals[lptr[a]] < aval:
aval = vals[lptr[a]]
if aval == np.inf:
break
hit = 0 # the guides with an atom at aval
for a in range(nmem):
p = lptr[a]
if p < le_end[a] and vals[p] == aval:
hit |= 1 << a
lo = lo_c[memb[a]]
rlt[a] = (cum[p - 1] if p > lo else 0.0) * inv_low[a]
rle[a] = cum[p] * inv_low[a]
lptr[a] = p + 1
for a in range(nmem):
if (hit >> a) & 1:
# a guide at its own atom is low in every split scored here, unless another guide ties
# with it; its cut catches up on the next atom that needs it
if hit == 1 << a:
continue
k = cut[a]
if k > gt_start[a] and (aval + vals[k - 1]) / 2.0 > obs:
k -= 1
while k > gt_start[a] and (aval + vals[k - 1]) / 2.0 > obs:
k -= 1
cut[a] = k
lo = lo_c[memb[a]]
rh[a] = (top[a] - (cum[k - 1] if k > lo else 0.0)) * inv_high[a]
for q in range(npat):
lowmask = pats[q]
if not lowmask & hit: # no low guide's maximum can be at aval
continue
prod_le = 1.0
prod_lt = 1.0
prod_s = 1.0
for a in range(nmem):
if (lowmask >> a) & 1:
prod_lt *= rlt[a] if (hit >> a) & 1 else rle[a]
prod_le *= rle[a]
else:
prod_s *= rh[a]
pmax = prod_le - prod_lt
if pmax > 0.0:
bg[q] += pmax * prod_s
for q in range(npat):
g += patw[q] * bg[q]
return g
@njit(cache=True, nogil=True)
def _gene_core(
k_all, lo_c, hi_c, vals, cum, totmass, nanp, le_arr, lt_arr, rvals, rcum, obs, scratch, iscratch, pats, patw, bg
):
"""``(P(gene median > obs), P(gene median == obs))`` over the gene's guides.
Sums over which guides come out nan, with the odd case a Poisson-binomial count and the even
case handled via :func:`_even_side` and reflection. ``le_arr``/``lt_arr`` are the exact
``P(guide <= obs)`` / ``P(guide < obs)`` (from the sub-tail mixture), so the count that decides
significance is exact; the atoms serve only the boundary shape. ``scratch`` (11 rows) and
``iscratch`` (5 rows) have at least ``k_all + 1`` columns, ``pats``/``patw``/``bg`` ``2 ** k_all``.
"""
p_gt = 0.0
p_eq = 0.0
ple, pge, dp, fs = scratch[0], scratch[1], scratch[2], scratch[3:]
memb, ints = iscratch[0], iscratch[1:]
if nanp[:k_all].max() <= 0.0: # no guide can come out nan: only the all-guides mask has weight
first = (1 << k_all) - 1
else:
first = 1
for mask in range(first, 1 << k_all):
weight = 1.0
k = 0
for c in range(k_all):
if (mask >> c) & 1:
if totmass[c] <= 0.0: # no atoms, so never non-nan in this mask
weight = 0.0
break
weight *= totmass[c]
ple[k] = le_arr[c] / totmass[c] # P(guide <= obs | not nan)
pge[k] = (totmass[c] - lt_arr[c]) / totmass[c] # P(guide >= obs | not nan)
memb[k] = c
k += 1
else:
weight *= nanp[c]
if weight <= 0.0:
continue
if k % 2 == 1:
s = k // 2
g = _poisbin_cdf(s, ple, k, dp)
lt = _poisbin_cdf(s, pge, k, dp)
else:
s = k // 2
g = _even_side(memb, k, lo_c, hi_c, vals, cum, obs, s, ple, fs, ints, pats, patw, bg, dp)
lt = _even_side(memb, k, lo_c, hi_c, rvals, rcum, -obs, s, pge, fs, ints, pats, patw, bg, dp)
e = 1.0 - g - lt
p_gt += weight * g
p_eq += weight * e
return p_gt, p_eq
@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]
order = np.argsort(slot, kind="mergesort")
start = np.zeros(n_slots + 1, dtype=np.int64)
for i in range(n):
start[slot[i] + 1] += 1
for s in range(n_slots):
start[s + 1] += start[s]
return order, start
@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_size,
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 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. Work is dealt to ``n_slots`` slots (see :func:`_deal`), each with its own scratch.
"""
n_genes = gene_offsets.size - 1
n_features = gene_observed.shape[1]
n_nulls = null_pool.size
out = np.empty((n_genes, n_features), dtype=np.float64)
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' masses and tails, and the _gene_core scratch; rows written at
# every atom, padded by a cache line so that slots on different cores share none
row_k = max_k + 1 + 8
lohi_t = np.empty((n_slots, 2, row_k), dtype=np.int64)
small_t = np.empty((n_slots, 4, row_k), dtype=np.float64)
scratch_t = np.empty((n_slots, 11, row_k), dtype=np.float64)
iscratch_t = np.empty((n_slots, 5, row_k), dtype=np.int64)
pats_t = np.empty((n_slots, (1 << max_k) + 8), dtype=np.int64)
patw_t = np.empty((n_slots, (1 << max_k) + 8), dtype=np.float64)
bg_t = np.empty((n_slots, (1 << max_k) + 8), dtype=np.float64)
counts = np.empty(n_nulls, dtype=np.int64)
offs = np.zeros(n_nulls + 1, dtype=np.int64)
totmass_u = np.empty(n_nulls, dtype=np.float64)
nanp_u = np.empty(n_nulls, dtype=np.float64)
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]
# size each shared 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]
p = null_pool[u]
end, _ = _build_construct(
row,
sub_nn[j],
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],
0,
stack_t[t],
sval_t[t],
sfl_t[t],
sgt_t[t],
)
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]
p = null_pool[u]
end, nprob = _build_construct(
row,
sub_nn[j],
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],
0,
stack_t[t],
sval_t[t],
sfl_t[t],
sgt_t[t],
)
nanp_u[u] = nprob
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
totmass_u[u] = 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, times the patterns of an even guide count
for g in range(n_genes):
tot = 0
for c in range(gene_offsets[g], gene_offsets[g + 1]):
tot += counts[construct_null[c]]
k = gene_offsets[g + 1] - gene_offsets[g]
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):
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
lo_c, hi_c = lohi_t[t, 0], lohi_t[t, 1]
totmass, nanp, le_arr, lt_arr = small_t[t, 0], small_t[t, 1], small_t[t, 2], small_t[t, 3]
c0 = gene_offsets[g]
k_all = gene_offsets[g + 1] - c0
for ci in range(k_all):
c = c0 + ci
u = construct_null[c]
lo_c[ci] = offs[u]
hi_c[ci] = offs[u + 1]
totmass[ci] = totmass_u[u]
nanp[ci] = nanp_u[u]
# exact P(guide <= obs) / P(guide < obs) from the sub-tail mixture, so the gene
# count that decides significance never depends on the atom coarsening
p = null_pool[u]
m = construct_size[c]
le = 0.0
lt = 0.0
for sub in range(pool_sub_start[p], pool_sub_stop[p]):
aa = sub_start[sub]
width = sub_stop[sub] - aa
w = width / pool_width[p]
gsub, lsub, nasub = _sub_tail(row, aa, sub_nn[j, sub], width, m, obs)
le += w * (1.0 - gsub - nasub)
lt += w * lsub
le_arr[ci] = le
lt_arr[ci] = lt
gt, eq = _gene_core(
k_all,
lo_c,
hi_c,
vals,
cum,
totmass,
nanp,
le_arr,
lt_arr,
rvals,
rcum,
obs,
scratch_t[t],
iscratch_t[t],
pats_t[t],
patw_t[t],
bg_t[t],
)
one = gt + 0.5 * eq
out[g, j] = min(1.0, max(0.0, 2.0 * min(one, 1.0 - one)))
return out
#: 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 _null_caps(sub_start, sub_stop, pool_sub_start, pool_sub_stop, null_pool, null_size):
"""Per-null atom-buffer size: an upper bound on the atoms a guide null generates before merging.
A control guide of ``w`` cells resampled ``m`` at a time produces median atoms only where the
central order statistic's empirical CDF lies within the band ``|F - 1/2| < ~3/sqrt(m)`` in which
its marginal exceeds ``_ATOM_EPS``; that band holds at most ``nw = min(w, 7*w/sqrt(m))`` distinct
values, so at most ``nw^2 + nw`` even-median atoms, never more than the ``w(w+1)/2 + w`` pairs.
"""
widths = (sub_stop - sub_start).astype(np.int64)
full = widths * (widths + 1) // 2 + widths
caps = np.empty(null_pool.size, dtype=np.int64)
for u, (p, m) in enumerate(zip(null_pool, null_size, strict=True)):
w = widths[pool_sub_start[p] : pool_sub_stop[p]]
nw = np.minimum(w, (7 * w) // int(np.sqrt(max(int(m), 1))) + 4)
bound = np.minimum(full[pool_sub_start[p] : pool_sub_stop[p]], nw * nw + nw)
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.
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, and a feature whose observed median is missing 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)
sub_codes, sub_keys = group_codes(adata, [replicate] if condition is None else [replicate, condition])
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_observed = grouped_stat(values[rows_kept], row_construct, constructs.size, MEDIAN)
construct_size = np.bincount(row_construct, minlength=constructs.size).astype(np.int64)
pool_t = np.ascontiguousarray(values[pool_rows].T)
pool_median = grouped_stat(
values[pool_rows], np.searchsorted(pool_keys, pool_of_row[pool_rows]), pool_keys.size, MEDIAN
)
# 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 = pool_t.copy()
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_size,
construct_observed,
)
# a guide's null depends only on its pool and cell count, so guides sharing both share it
null_keys, construct_null = np.unique(
construct_pool * (construct_size.max() + 1) + construct_size, return_inverse=True
)
null_pool, null_size = np.divmod(null_keys, construct_size.max() + 1)
gene_p = _gene_pvalues(
pool_sorted,
sub_nn,
sub_start,
sub_stop,
pool_sub_start,
pool_sub_stop,
pool_width,
construct_size,
construct_null.astype(np.int64),
null_pool.astype(np.int64),
null_size.astype(np.int64),
_null_caps(sub_start, sub_stop, pool_sub_start, pool_sub_stop, null_pool, null_size),
gene_offsets,
gene_observed,
_ATOM_EPS,
numba.get_num_threads(),
)
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(key[0] if isinstance(key, tuple) else key) for key in 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][1]) 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