Design decisions¶
Why the port differs from R where it does, and what was measured to decide.
This page exists so the source does not have to carry it. A docstring says
what a function does; the reasoning behind a choice, and the numbers that
settled it, live here. Measurements are a property of a machine and a
dataset, not of the code -- keeping them in source means an unchanged
function accumulates commits that only edit prose, and git log -p stops
answering "when did this last change?".
Where a choice in the source would otherwise look arbitrary, the code carries a one-line pointer to a section below rather than the argument itself.
Benchmark figures
Numbers here name the machine they were taken on. The full tables, the R-versus-pysceptre comparison and the methodology live with the manuscript, in a separate repository -- this one is strictly the tool. Until the preprint is out that repository is private, so this page carries the figures a reader needs to judge the decision rather than deep-linking to a page they cannot open.
Choosing a parallel backend¶
n_jobs distributes the per-pair tests over the genes of an
already-drawn target chunk -- not over chunks. Two constraints force that.
The RNG is consumed target-by-target in the parent, so draws happen in the
same order at any worker count and every p-value is independent of
n_jobs; chunk-level parallelism would have moved all of them and forced
a re-validation against R for no scientific gain. And a chunk's draws are
shared rather than duplicated, so memory grows by roughly one gene's working
arrays per worker instead of by chunk_memory_gb per worker.
The backend itself follows the mechanism, not the platform.
parallel_backend() gives the platform default -- fork on Linux, threads
elsewhere -- and gene_job_backend() overrides it to threads for the
per-chunk gene pool once a run exceeds eight chunks.
Measured on an n2-standard-16 at 8 workers, day0 (34,886 pairs,
567,690 cells):
| fork | thread | |
|---|---|---|
| CRT | 679.7 s, 9.46 GB | 601.9 s, 6.93 GB |
| permutations | 70.3 s, 4.61 GB | 143.8 s, 4.55 GB |
Threads win the CRT and lose permutations, and the reason is structural. The CRT builds a worker pool per chunk -- 217 of them at the default budget -- so fork cost dominates. Permutations run as a single chunk and pay that cost once, which leaves the GIL as their binding constraint. Hence the chunk-count threshold rather than a platform check.
Threads win the CRT while showing lower occupancy, 4.56 cores against 5.10. Occupancy is a diagnostic, not a goal: fork spends cores on work that threads never do.
The threshold sits at eight chunks. Only the two points above are measured, so it is placed in the gap between them rather than fitted -- anything from a handful of chunks to a hundred classifies both cases identically, and pretending to more precision than two points support would be false.
PYSCEPTRE_BACKEND overrides the platform default, to fork or thread,
so a caller who has measured their own machine can act on it. It cannot make
macOS safe for fork: that restriction is about deadlocking after
Accelerate, not about speed, and the override is refused with a warning.
macOS keeps threads throughout regardless of chunk count, because fork
after Apple's Accelerate BLAS has run can deadlock -- the Grand Central
Dispatch pools it relies on are not fork-safe. spawn is not an
alternative: a chunk's draws run to hundreds of MB and would be pickled per
worker per chunk, costing more than the parallelism saves.
A superseded measurement
The rule above replaces an earlier one that chose processes on Linux
unconditionally, on the strength of 1.85x with threads, 3.54x with
processes. That was measured on a per-pair gather (D[:, flat_idxs]
plus reduceat) which held the GIL and no longer exists -- the
statistic is now a sparse matmul, which permutations reach through a
prefix scan where that is the cheaper route. Both release the GIL. The old figure survived in a
docstring for months across a change that reversed it, which is the
reason this page exists.
BLAS threading in the IRLS loop¶
glm/irls.py runs its fits inside
threadpool_limits(limits=1, user_api="blas"). This is deliberate. The IRLS
matmuls are thin -- p is a handful of covariates -- and called in a tight
loop, so multi-threaded OpenBLAS loses to the single-threaded path:
68.7 s to 17.7 s for a 150-column batch over 100k cells, on x86_64 Linux
with OpenBLAS. The limit is scoped to the module so it does not clobber BLAS
threading process-wide.
It is a silent no-op on macOS, where numpy is built against Apple
Accelerate, which threadpoolctl cannot introspect: threadpool_info()
returns [] and a matmul takes the same time inside and outside the context
manager. Local benchmarks on a Mac therefore do not exercise this path while
CI does, so any measurement of it has to name the BLAS it was taken on.
Indicator columns in the IRLS¶
Every gene fit and every CRT target fit is an IRLS, and each iteration solves
the weighted normal equations X^T W X beta = X^T W z. glm_solver chooses
how they are formed. "outer", the default, is 0.5.0's route; "structured"
is not from sceptre and changes no estimand.
Why the outer-product route is bandwidth-bound¶
"outer" builds x_outer_flat(X), the (n_cells, p^2) array of per-row
outer products, once per run, and every iteration forms X^T W X as one
matrix-vector product with it: n * p^2 multiply-adds, and a read of the
whole array. With a handful of covariates the array is small and the route is
the right one. A covariate matrix with a batch factor is not a handful. A
1.55M-cell transcriptome-wide screen measured here has p = 69 (an intercept and five continuous covariates, 60
batch dummies and 3 replicate dummies) over 1,554,804 cells, so the array is
1,554,804 * 69^2 * 8 bytes, about 59 GB, read once per iteration of every
gene fit and every target chunk fit. Each byte read feeds one multiply-add, so
the fit runs at memory bandwidth, and no BLAS setting helps ("BLAS threading
in the IRLS loop").
What the structured route computes¶
Most of those columns are 0/1 dummies of a factor, and in any row at most one
column of a factor is 1. glm.design.structured_design finds this from X
alone. A column whose entries are all exactly 0 or 1, and that is neither all
0 nor all 1, is an indicator; indicators are grouped greedily, left to right,
each joining the first group with which it never shares a row that is 1.
Every other column, the intercept among them, is dense. A group is stored as
one integer code per cell: which of its columns that cell has its 1 in, or
the group's size for the reference level.
The grouping depends on column order, and a column of a partially crossed factor can join another factor's group. Neither changes a sum: any grouping of mutually exclusive columns gives the same normal equations, and only the number of tables below differs. A binary covariate that is not a dummy becomes a group of one, which is still exact.
With d dense columns and indicator groups g, h, ..., every block of
X^T W X and X^T W z is one of:
- dense by dense:
X_d^T (w X_d), a BLAS product,O(n d^2); - a group with itself: the sum of
wover the cells of each level, a diagonal block, since a 0/1 column times itself is itself and two columns of one group never meet; - dense by group: the sum of
w x_kover each level's cells, for each dense columnk; - group by group: the table, over pairs of levels, of the sum of
wover the cells in both; - the right-hand side:
X_d^T (w z), and the sum ofw zover each level's cells.
That is O(n * (d^2 + G*d + G^2)) for G groups, against O(n * p^2), and
no (n, p^2) array. X beta is the dense product plus one lookup per group.
On that design d = 6 and G = 2.
The IRLS around it (_fit_structured) is _fit_batch's for one response,
step for step: the same start (y + 0.1 for the Poisson; the marginal rate
for the logistic), weights, working response, deviance, convergence rule,
maxit and floor on the mean.
The same GLM, not the same bits¶
In exact arithmetic the two routes compute the same sums, so the same
iterates and the same MLE. In floating point they add the same terms in
another order, so X^T W X differs in its last bits. The difference does
not stay there. Each iteration solves the normal equations, and a solve can
multiply a relative error in them by up to their condition number, so
everything downstream differs by rounding amplified by the design's
conditioning, not by a few ulps: coefficients, fitted values, theta,
z_orig. On the 69-column design measured below, fitted means agreed to
7.5e-9 relative, tens of millions of ulps, and the targets' fitted
probabilities to 9.6e-10. That is still far below what the test resolves. A
statistic that close almost always gives the same resampling counts, and so
the same p-value and stage. It is not guaranteed, and two places amplify a
difference further.
theta on a gene close to Poisson. estimate_theta falls back from the
MLE to the method of moments to the pilot estimate, and for a gene with
little or no overdispersion which branch it ends on can turn on the last bits
of the fitted means ("A dispersion estimate can stop on a rounding
accident"). The branches can give values far apart, so such a gene's theta,
and with it its p-values, can differ by much more than rounding. That is the
estimator's sensitivity, not the route's: a different CPU or BLAS moves the
same genes ("Bits depend on the machine"). A comparison of the two routes on
a screen should therefore report every gene whose theta method changed.
A weakly identified target. When a level of a factor has few or no treated cells, the logistic fit's coefficient for it drifts towards minus infinity and the normal equations are ill conditioned. There the two routes' fitted probabilities agree only about as well as the outer route agrees with itself across chunk-mates ("Targets are fit in the reference run's chunks").
The compiled and numpy sums agree bit for bit¶
The per-level sums have two implementations: a numba kernel,
_indicator_sums_compiled, which makes one pass over the cells and releases
the GIL, and np.bincount in _indicator_sums_numpy, the route without
numba. Both add each sum's terms one cell at a time in index order, with
additions only and no fastmath, so they return the same bits, and
test_compiled_and_bincount_sums_agree_bit_for_bit checks it. Whether numba
is installed therefore never changes a structured fit.
It can still change a result, through the CRT sampler. Without numba,
crt_index_sampler_fast groups each resample's cells with an unstable
argsort instead of the counting sort, so a resample holds the same cells
listed in another order, and the sparse product that gives its null
statistic adds them in that order. The statistics, and through them a
p-value, can differ in the last bits on either route. That is why
run_trans_analysis refuses to run without numba ("Bits depend on the
machine").
Target fits no longer depend on their chunk-mates¶
The outer route fits a chunk of targets as one batched IRLS, and a batched
BLAS product does not give a column the same last bits at every batch width,
so a target's fitted probabilities, and through rng.binomial(B, p) its
draws, depend on which targets share its chunk. The structured route fits
each target alone: its BLAS products are over that one response, and the
per-level sums do not see other targets. A target's fit is a function of the
target and the design, the same at any target_chunk_size,
chunk_memory_gb or n_jobs, and target fits run on a thread pool without
changing a value. Gene fits were already one per call.
Why the default stays "outer"¶
0.5.0's results came from the outer route, and a reference run, its gene-fit
cache and any trans run tested against it must use one route throughout.
Making "structured" the default would change the bits of every existing
result with no change to the caller's code. So "outer" stays the default
and leaves every result bit-identical to 0.5.0 for the same inputs, and
"structured" is chosen once per analysis: run_trans_analysis must be
given the reference run's, and a GeneFitCache records it in its
fingerprint and is refused under the other. A cache file written before the
fingerprint recorded the covariate matrix's memory layout ("Bits depend on
the machine") is refused whatever its route, to be refit.
"structured" is offered with the complement control group only. Against
the NT cells the gene is refit for every pair on that pair's cells, a
different loop, and nothing there was measured. The calibration check
against the NT cells runs the complement engine inside the NT pool, so it
could take either route, but it refuses "structured" as discovery and the
power check do, so that the setting means the same in all three analyses.
Measured¶
scripts/measure_structured_irls.py on that screen's design (1,554,804 cells,
p = 69, d = 6, groups of 60 and 3 levels), on an AMD EPYC 7543 with
OpenBLAS 0.3.29 (Haswell kernel), numpy 2.2.6 and numba 0.67.0, one BLAS
thread, CPU time per fit:
| structured | outer | |
|---|---|---|
| gene fit, median of 24 random tested genes | 0.82 s | 16.6 s |
| target fit, the first 6 tested targets | 0.62 s, each alone | 8.26 s per target, as one batch of 6 |
The fitted means agreed to 7.5e-9 relative and the clamped theta to 3.0e-9,
with the same iteration counts and theta methods for all 24 genes; the
targets' fitted probabilities agreed to 9.6e-10.
The CRT sampler draws with replacement¶
R decides how many of the B resamples include cell j with a binomial draw,
M_j ~ Binomial(B, p_j), then places the cell into M_j distinct resamples
by a without-replacement Fisher-Yates shuffle. crt_index_sampler_fast keeps
the binomial count but places the cell with replacement, and does not
deduplicate: a cell that lands on the same resample twice is listed, and
counted, twice in it. Deduplicating the (cell, resample) pairs with
np.unique was the dominant cost of the sampler at real scale, so it was
dropped.
This is an approximation, and it is only accurate where inclusion
probabilities are tiny. The expected number of repeats in a resample is about
sum(p_j ** 2) / 2 against sum(p_j) cells listed, so the repeated fraction
is about half the typical inclusion probability, and p_j averages a target's
share of the cells it is tested on. scripts/measure_nt_cells_crt_sampler.py
measured the repeated share at the dimensions of four screens:
| screen and control group | target share of the cells | listings repeated |
|---|---|---|
| day0, complement | 0.07% | 0.035% |
| Gasperini (sceptredata), complement | 2.7% | 1.6% |
| Papalexi (sceptredata), complement | 3.8% | 2.3% |
| Papalexi (sceptredata), NT cells | 25% | 14.0% |
A repeated cell inflates the null statistics' variance, so the test turns conservative, and at the larger shares it shows: on a synthetic low-MOI screen with no effects the fast sampler put 5.6% of null p-values below 0.1 where 10% belong (see The CRT against the NT cells).
So the sampler is chosen per target, from counts. crt_index_sampler
takes crt_index_sampler_exact when a target is more than 0.2% of the cells it
is tested on (EXACT_SAMPLER_ABOVE_SHARE), where repeats would pass 0.1%, and
the fast sampler below that. The exact sampler costs about five times as much
per target, which is why it is not used everywhere. The rule reads the treated
count and the number of cells, never the fitted probabilities: those are not
bit-identical across chunk widths, and a target near the boundary could
otherwise change sampler with target_chunk_size.
day0 sits entirely below the boundary -- its largest target is 692 of 567,690 cells, and its calibration check's synthetic targets, 15 non-targeting gRNAs of a median 32 cells each, reach at most 0.12% -- so its results are bit-identical to earlier releases. A screen's CRT p-values move for every target above 0.2% of its cells, which on sceptredata is every target of both screens.
The alternative -- a dense (n_cells, B) matrix, the straightforward
translation -- is what made the first port unusably slow at real scale. The
sparse draw is the reason the CRT path finishes at all, and the exact sampler
keeps it sparse.
A rank check that does not square the condition number¶
run_discovery_analysis refuses a rank-deficient covariate_matrix before
any fitting, naming the redundant columns; without the check it fails deep
inside the batched weighted least squares as a bare LinAlgError. The same
test, glm.design.redundant_columns, runs again on each target's cells
together with the NT cells under the NT-cells control group, where R stops on
an NA coefficient.
Rank comes from the singular values of R in a thin QR of the matrix, with the
relative tolerance numpy.linalg.matrix_rank uses. The QR is what makes it both
cheap and correct: R is p x p, so every rank question after it is tiny, and
because Q has orthonormal columns the rank of any column subset of R equals
that of the same subset of the matrix. Redundant columns are named left to
right, so the first of a collinear set is kept, which is R's convention.
Not the Gram matrix. X.T @ X looks like the natural p x p route and is
the thing the fit depends on -- Zt_wZ is it reweighted, and positive weights
cannot restore rank -- but its eigenvalues are the squares of the singular
values, so it squares the condition number. A design whose columns span
fourteen orders of magnitude, raw UMI counts beside a small covariate say, is
full rank and the Gram route rejected it.
Resampling budgets, and why B3 is zero¶
B1, B2 and B3 are derived in api.py::_resampling_budget, porting R's
own sizing from s4_analysis_functs_1.R. B1 = 499 always; skew_normal
gives (4999, 0); no_approximation gives
(0, ceil(mult * n_pairs / alpha)).
B3 = 0 on the default skew_normal path is parity with R, not a stub.
R only uses B3 = 24999 for permutations.
Under no_approximation the count that sizes B3 is the tested pairs of the
call. R sizes the calibration check from the requested n_calibration_pairs,
where pysceptre uses the pairs actually built, which differ only when fewer
pass QC than were asked for. R sizes the power check from the larger of the
discovery and positive-control counts, because both live on one
sceptre_object; run_power_check sees only its positive controls, so its
B3, and with it the smallest p-value it can report, is smaller.
A related trap: stage == 3 is still reachable on the default path. It is
entered whenever the skew-normal fit is rejected, regardless of B3, and
the p-value then comes from the already-drawn B2 = 4999 statistics. Only
stage == 2 means a skew-normal fit was actually used.
When the permutation prefix scan pays, and when it does not¶
Under permutations every target reads the same draw rows and differs only in
how far along each row it reads, so one gene's segment sums for every
target are columns of a single cumulative sum along those rows. That is
PermutationPrefixSums: one gather-and-scan of B * m * (p + 2), where m
is the width of the shared rows, in place of one sparse matmul of
B * n_trt * (p + 2) per target.
Counting elements makes the scan look unconditionally better, and it is
not. An element costs far more on the scan than on the matmul, so the two
counts are not comparable as they stand. Measured at one real screen's
dimensions (n_cells = 131,055, m = 406, p + 2 = 14), one target, on an
Apple M4 Max:
scan (gather + cumsum) |
matmul (draws_to_matrix + @) |
ratio | |
|---|---|---|---|
stage 1, B = 499 |
13.6 ms | 1.7 ms + 0.2 ms | 7.2 |
stage 2, B = 4,999 |
111.2 ms | 12.8 ms + 1.7 ms | 7.7 |
The CSR build is counted in because PermutationSliceDraws deliberately does
not memoize it. Unlike the IRLS threading figures this ratio is not a BLAS
measurement -- the sparse matmul goes through scipy's sparsetools -- so the
Accelerate caveat above does not apply to it.
So the scan pays only when a gene's targets together demand more prefix than
the scan computes, by that factor: sum(n_trt) >= SCAN_BREAK_EVEN * m, which
is prefix_scan_pays, applied in _gene_job before the scan object is built
at all. SCAN_BREAK_EVEN is 8.0, rounded up from the measurements above so
that a borderline gene takes the route that is never much worse.
The two regimes are far apart, which is why the exact constant matters little.
A single-target call sits at a ratio of 1 and can never pay, while day0 pairs
each gene with about 147 targets (34,886 pairs over 237 genes), whose cell
counts put its demand at tens of times m however m is measured. It keeps
the scan. Reported as
issue #2, where a
single-target caller measured 182 ms to 87 ms per pair, 2.1x, for taking the
matmul route instead.
The routes are bit-identical, so this is a pure cost decision and nothing
about it is user-visible. test_prefix_sums_match_the_per_target_matmul_bit_for_bit
asserts the sums agree exactly, and
test_the_prefix_route_and_the_matmul_route_agree_end_to_end asserts a whole
analysis does; that second test needs enough targets to clear the gate, or it
compares the matmul route against itself.
Two alternatives were rejected. Replacing cumsum with sum when only one
prefix is wanted drops one of the two large arrays but keeps the gather, and
still loses: 35.6 ms against the matmul's 14.5 ms at stage 2 above. Exposing
the route as a parameter would put a bit-identical implementation detail in
the API for callers to get wrong.
One thing the gate does not know: the demand it sees is stage 1's. Later
stages are read only by the pairs that escalate, so a gene with many targets
of which few escalate keeps a scan those few cannot cover. At real m the
memory gate usually declines those stages anyway (max_bytes refuses day0's
stage 2 by a factor of 3.6), and the escalating fraction is not knowable
before the pairs are tested, so the over-estimate is left rather than guessed
at.
Pairwise QC appears in two different shapes¶
Cell-level and gRNA-level QC are out of scope, but the calibration and power
checks construct or receive their own pairs, so both have to decide which
pairs are testable at all. Pairwise nonzero-count filtering therefore lives
in pipeline/pairwise_qc.py, shared by both -- and the two use it in
opposite directions.
The calibration check samples its pairs, so it avoids ones that would
fail; every returned row passed by construction and there is no pass_qc
column. A positive control is a specific claim about a specific pair, so the
power check reports failures with a NaN result: dropping them would
overstate power by hiding exactly the controls the screen had too few cells
to test. Discovery behaves like the power check.
On one real screen the difference is visible in the shape of the output:
a discovery result of 34,256 rows of which 1,121 fail, against a calibration
result of 33,135 rows with no pass_qc column at all.
Randomness does not reproduce R bit-for-bit¶
sceptre seeds boost::mt19937; this uses numpy.random.Generator.
Validation matches distributions, not draws, and chasing exact agreement
is not a goal.
One consequence is easy to mistake for a bug. R's calibration pair
selection is not reproducible even against itself: nothing in that path
calls set.seed and sceptre_object has no seed slot. Two R runs on the
same object produced the identical 100 gRNA groups but shared only about 20
of each group's 331 genes. Pair-by-pair comparison against R therefore has
to feed R's own grna_target column back through
calibration.negative_control_pairs_from_names; the constructor itself is
validated distributionally, which is what a calibration check measures.
Low MOI and the NT-cells control group¶
In a low-MOI screen each cell carries at most one gRNA, so the cells carrying
a non-targeting (NT) gRNA are untreated and can serve as the control group on
their own. sceptre calls that the NT-cells control group. In its statistical
engine MOI sets the defaults of the control group and the resampling mechanism,
and allows the NT cells at all. moi does the same here, the way R's
import_data(moi=) does, and it is the only way in.
moi |
default control_group |
default resampling_mechanism |
"nt_cells" allowed |
|---|---|---|---|
"high" |
"complement" |
"crt" |
no, refused; R quietly uses the complement |
"low" |
"nt_cells" |
"permutations" |
yes |
Low MOI with control_group="complement" runs the high-MOI engine unchanged,
and test_low_moi_complement_is_the_high_moi_engine asserts it bit for bit.
R's own code has two more low-MOI branches, both in the calibration check: it
skips unique() on a synthetic target's cells, and it counts a synthetic
target's nonzero cells as a sum over its NT gRNAs rather than over their union.
Neither can change anything once each cell carries at most one gRNA.
R's low MOI does two things before the analysis: by default it assigns each
cell its single strongest gRNA, assign_grnas()'s maximum method, and its QC
removes cells with zero or two or more gRNAs. The first is ported. The second
is cell-level QC and out of scope: assign_grnas_maximum and
cells_w_zero_or_twoplus_grnas report the cells sceptre would remove (see
Thresholding and maximum), but pysceptre never
removes them. The NT-cells analysis relies on that removal, and R's engine
never checks it, since its QC guarantees it. pysceptre takes post-QC inputs
and cannot, so it checks what it can see and refuses rather than guessing: a
cell listed under two NT gRNAs, and a tested target sharing a cell with the NT
cells. A cell in two targets is not refused. With a guide in two
overlapping elements one gRNA legitimately puts a cell in both, and that
changes nothing here, since each pair is tested on its own target's cells.
One fit per pair¶
Against the complement a gene's GLM is fit once, on every cell, and reused for
all its targets. Against the NT cells it cannot be: R's
discovery_ntcells_perm_test and discovery_ntcells_crt test each pair on
c(trt_idxs, all_nt_idxs), its target's cells followed by the NT cells, and
fit the gene's Poisson GLM and dispersion on exactly those cells. Every pair
has its own design rows, so nothing is shared across a gene's targets.
run_discovery_nt_cells keeps the complement path's structure -- targets in
memory-budgeted chunks, prepared ahead of the chunk being tested, genes
distributed across workers within a chunk -- and changes what a pair costs.
Each pair is fit on its own, never batched with the other genes of its
target, for the reason _GENE_BATCH_WIDTH = 1 gives on the complement path:
a batched solve makes a fit depend on what else shared its BLAS call. So a
pair's result depends on its gene, its target, the NT cells and the seed, and
on nothing else in the run, except under no_approximation, whose third batch
is sized by the pair count.
The workers are chosen for that cost. A pair's fit is many small numpy calls
that hold the GIL, so threads contend where processes would not: on
sceptredata's Papalexi screen against the NT cells (6,205 pairs, Apple M4 Max,
threads) a run took 22.3 s at n_jobs=1, 14.7 s at 2, 14.1 s at 4 and 20.5 s
at 8. Off Linux the gene pool is therefore capped at four threads
(_NT_THREAD_CAP); on Linux it always forks, whatever the chunk count,
because a chunk's per-pair fits dwarf the cost of a fork. Results do not
depend on either choice.
The combined cells are R's order exactly: the target's cells ascending, then the NT cells in pool order, each NT gRNA's cells in turn. Order moves fitted values only in their last bits, but a resample is a set of positions, so a different order is a different Monte Carlo draw; matching R's order is what lets R's own draws be fed in and compared value for value.
R stops when a coefficient cannot be estimated on a pair's cells, which a covariate constant on one target's cells and on every NT cell can cause even though the full design is full rank. pysceptre checks every tested target's combined design before fitting anything, and names the target. Adding rows cannot lower a matrix's rank, so a full-rank design over the NT cells alone clears every target at once, and the per-target check runs only when it is not.
Permutations against the NT cells¶
A target with n_trt cells is tested against N NT cells, so its resamples
are random n_trt-subsets of N + n_trt positions. R draws them once for
every target, with hybrid_fisher_iwor_sampler(N, m, M, B), m and M
being the smallest and largest nonempty target: B rows of M positions
each, such that for every k in [m, M] the first k entries of a row are a
uniformly random k-subset of {0, ..., N + k - 1}. One array then serves
every target, each reading the prefix as long as itself.
R builds each row forwards: a Fisher-Yates shuffle for the first m entries,
then an inductive step that grows the universe by one element at a time and
takes the new element with probability k / (N + k).
nested_permutation_draws builds the same thing backwards, which needs
nothing but a uniform ordering and a swap per step, vectorized across rows:
start from a uniformly random ordering of M distinct elements of
{0, ..., N + M - 1}, then for k = M down to m + 1, if element
N + k - 1 sits in the first k positions swap it to position k - 1,
otherwise swap a uniformly chosen one of the first k there.
The two constructions have the same joint law over the whole chain of
prefixes, not merely the same marginals. Writing S_k for the set of the
first k entries, both give each S_k the uniform law over k-subsets of
{0, ..., N + k - 1}, and the transition between consecutive prefixes agrees:
S_k |
forward, P(S_{k-1}) P(S_k given S_{k-1}) |
backward, P(S_k) P(S_{k-1} given S_k) |
|---|---|---|
contains N + k - 1 |
k / ((N + k) C(N + k - 1, k - 1)) |
1 / C(N + k, k), the same number |
| does not | 1 / ((N + k) C(N + k - 1, k - 1)) |
(1 / C(N + k, k)) (1 / k), the same number |
test_nt_cells_sampler.py checks the marginal law of each prefix, the
inclusion rate k / (N + k) of the newest element, and the joint law of the
chain against a forward implementation of R's algorithm.
Like the complement's permutations, these draws are shared, so they cannot be
invariant to a change of target set: a new target larger than M or smaller
than m moves every result. m and M are taken over every target supplied,
not only the tested ones, which is R's rule and keeps the pair list out of it.
The CRT against the NT cells¶
Under the CRT each target's resamples come from a logistic fit on its own
combined cells, as in R's discovery_ntcells_crt, and are drawn from a stream
keyed by the target's name, so the CRT keeps its invariance to the rest of the
run here too.
The sampler is different, and has to be. crt_index_sampler_fast is accurate
only while inclusion probabilities are tiny (see
The CRT sampler draws with replacement),
and against the NT cells they are not: a target is 10 to 18% of its combined
cells on sceptre's simulated example (20 targets of 21 to 41 cells against
185 NT cells) and 23 to 28% in the screen measured below. At an inclusion probability of 0.25 the fast sampler repeats
about one listing in nine (11.5% in a simulation of the sampler), and the
extra variance makes the test conservative. On a synthetic screen with no
effects and every cell carrying one gRNA -- 6,000 cells, 12 targets of 343 to
444 cells, 1,157 NT cells, 480 pairs -- scripts/measure_nt_cells_crt_sampler.py
measured:
| test | null p-values below 0.1 | mean null p-value |
|---|---|---|
| permutations, exact by construction | 0.100 | 0.534 |
CRT, crt_index_sampler_exact |
0.096 | 0.533 |
CRT, crt_index_sampler_fast |
0.056 | 0.576 |
crt_index_sampler_exact removes the approximation without giving up the
sparse cost. Each cell's inclusions are a Bernoulli process along the
resamples, so their positions are partial sums of geometric gaps: no cell can
be listed twice, the law is R's binomial count plus without-replacement
placement exactly, and the work is still proportional to the number of
inclusions.
Against the NT cells every target is far above the 0.2% boundary, so the rule in The CRT sampler draws with replacement always picks the exact sampler there, and so does it for the calibration check, which runs on the NT cells alone.
The calibration check on the NT cells¶
R's calibration check against the NT cells does not use the NT-cells test at
all. It restricts the whole analysis to the NT cells (subset_to_nt_cells):
the GLM is fit once per gene on the NT cells, and a synthetic target is tested
against the rest of them. pysceptre does the same restriction up front and then
runs the complement check unchanged, and
test_nt_cells_calibration_is_the_complement_check_on_the_nt_cells asserts the
two agree bit for bit. It therefore needs at least two NT gRNAs, as R does.
Under permutations both control groups size the shared draws as R does for a
calibration check: as wide as the calibration_group_size largest NT gRNAs
together. Unlike the width of the largest synthetic target, that does not
depend on which groups were sampled.
A dispersion estimate can stop on a rounding accident¶
For a gene with no overdispersion on a pair's cells the dispersion MLE is at
infinity, and Newton's iteration in estimate_theta walks there, theta
growing about 1.5 times a step. It stops by floating-point accident: when the
score's cancelling terms sum to exactly zero, which reads as converged, or
when it runs out of iterations, which R and pysceptre both answer with a
method-of-moments estimate. R's boost special functions and scipy's round
differently, so the two can stop on different branches. Both keep the
algorithm; neither is more correct.
Measured on sceptredata's Papalexi screen against the NT cells: one pair of
6,205, CTD-2044J15.2 against CAV1 (83 counts over 2,552 cells), where
pysceptre's iteration stopped on an exact zero at theta = 676,341, clamped to
1,000, and R's ran out at 50 iterations and took 10.99. The statistic moved
by 1e-3 relative, because a gene that sparse barely depends on theta. Every
other pair's statistic agrees with R to 4e-9 relative, and 99% of them to
3e-11.
Pairwise QC against the NT cells¶
The control count changes meaning. Against the complement it is the gene's
nonzero cells minus the target's; against the NT cells it is the gene's
nonzero NT cells, the same for every target of that gene, as in R's
compute_nt_nonzero_matrix_and_n_ok_pairs_v3. A calibration pair's control
count is the NT total minus its synthetic target's, since its universe is the
NT cells.
Analytical per-pair power¶
analytical_power/ answers a different question from the rest of the package:
not "is this pair significant?" but "if this element really did reduce this
gene by X%, would this screen have detected it?" It is a closed form -- one
pass of arithmetic, no resampling, no fitted parameters -- and it is a port of
PerturbPlan's compute_power_posthoc() rather than of sceptre. The MIT notice
is in THIRD_PARTY_LICENSES.
Why a port, and not the formula this project derived¶
The obvious alternative was a two-parameter model,
power = Phi(k * beta * n^b / sqrt(1/mu + dispersion) - z), fitted on
simulations. It was built, fitted and scored against per-pair simulation at
screen scale, and read as the decision a user actually takes -- is this pair's
power at least 0.8? -- the unfitted closed form won, on both error directions
at once. So this ports the closed form rather than shipping the fitted model.
Two further findings shaped what is offered here. The theory did not need fitting: held at their theoretical values the constants are already right, so recovering them from simulation was the unnecessary step. And a calibrated error bar around the closed form was measured and is not offered, because it does not transfer between screens and makes the decision slightly worse overall. The point estimate is reported as it comes.
The measurements behind all three are in the manuscript repository and are deliberately not restated here: a number that lives in two places drifts, and these are the manuscript's results rather than the tool's.
Does it agree with the truth? Measured on day0, under the CRT¶
Matching PerturbPlan's R to 1e-9 says the port computes what it ports. Whether that is worth computing is a separate question, and the only available truth is simulation. Scored against WattEG's day0 sweep, 100 simulations per pair over 34,886 pairs, at the bar a reader acts on rather than as a distance:
| effect size | powered by simulation | sensitivity | specificity | wrong claims | missed | MCC |
|---|---|---|---|---|---|---|
| 0.05 | 274 | 96.4 % | 98.25 % | 606 | 10 | 0.536 |
| 0.10 | 6,204 | 94.1 % | 98.08 % | 551 | 369 | 0.911 |
| 0.15 | 16,050 | 96.4 % | 97.66 % | 440 | 580 | 0.941 |
| 0.20 | 22,131 | 97.9 % | 98.63 % | 175 | 459 | 0.961 |
| 0.25 | 25,563 | 98.2 % | 99.07 % | 87 | 472 | 0.960 |
| 0.50 | 31,925 | 98.6 % | 99.97 % | 1 | 447 | 0.925 |
scripts/score_power_against_simulation.py, guarded by
tests/validation/test_power_against_simulation_day0.py.
What this corroborates, and whose result it is. The finding that PerturbPlan's closed form agrees with per-pair simulation is not this package's; it was established by scoring their R against a large simulation sweep, and it is why the closed form is worth porting at all. This is a second instance of that same comparison, so it tests the estimator rather than proposing one. It is not a re-run: the original scored a different screen analysed under sceptre's permutation test, which an ondisc-backed response matrix forced, while day0 ran under the CRT, which that work explicitly lists as untested.
The agreement holds: MCC between 0.91 and 0.96 from a 10 % to a 50 % knockdown, and it does not degrade as the signal grows, which is the failure mode the fitted alternative in the original comparison did show.
The es = 0.05 row is not a result. 274 of 34,886 pairs are powered at a 5 % knockdown, so every rate on that line is a few hundred pairs wide against a 34,000-pair negative class, which is why its MCC is 0.536 while its sensitivity is 96.4 %. It is kept to show the row exists.
Which mean is correct: PerturbPlan's own definition settles it¶
Not an argument from sceptre, and not an empirical question. PerturbPlan
defines what its input means, in power_function:
expression_mean = avg_library_size * relative_expression
with relative_expression = rowSums(counts) / sum(counts)
(obtain_expression_information). So expression_mean is the gene's share of
all counts times the library size: the expected raw UMI count for that gene
in a cell of that depth. Nothing normalised about it.
That decides it. The fitted mean is the gene's average observed count over the
cells the fit ran on -- exactly, since a GLM with an intercept satisfies
sum(fitted) == sum(observed) -- which is the quantity PerturbPlan asks for.
The size-factor-normalised mean is a DESeq2 poscounts quantity answering a
different question, and on day0 it sits 16 % below what the formula wants.
It is a happy coincidence rather than a second argument that the fitted mean
is also on the scale sceptre's test operates on: exp(Z b) is both. The two
routes agree because the expected count is the expected count.
So the comparison below is corroboration, not adjudication, which the
first version of this section got wrong by presenting it as evidence for the
default. It cannot adjudicate in any case: the ground truth is a simulation
that drew counts as mean_i * sf_j, realising about 0.959 of the raw mean, so
the truth's own scale sits 4.3 % from the fitted mean and 12.2 % from the
normalised one. The convention nearer the truth scores better partly for that
reason. The same proximity makes the headline table mildly pessimistic,
since the estimator is scored against genes about 4 % dimmer than the screen
has.
With that read, on the same pairs and the same ground truth and only the expression input changed:
| effect size | MCC, model mean | MCC, normalised mean |
|---|---|---|
| 0.10 | 0.911 | 0.901 |
| 0.15 | 0.941 | 0.910 |
| 0.20 | 0.961 | 0.920 |
| 0.25 | 0.960 | 0.914 |
| 0.50 | 0.925 | 0.856 |
And it errs in the predicted direction: at a 15 % knockdown the normalised mean misses 1,497 powered pairs against 580, while making 112 wrong claims against 440. So the 16 % shortfall buys specificity at a larger cost in sensitivity, which is what a conservative input does, and the gap widens as the effect size grows. That directional result is not affected by the proximity problem above -- a lower expression input gives a lower power estimate whatever the ground truth is.
Five things that look like simplifications and are not¶
Each of these turns the estimator into one that nothing has scored.
num_trt_cells is the sum over a target's gRNAs, not the union of its
perturbed cells. compute_power groups cells_per_grna by target
and sums, and that is the input the estimator was validated with. pysceptre's
own data model carries the union instead (grna_target_cells, one index array
per target), and a cell can carry several of a target's gRNAs, so union <= sum.
How far apart are they? Measured, and the answer is awkward: almost never,
but not never. scripts/measure_union_vs_sum.R reads the two slots off a
sceptre object and compares them. On two real screens the union/sum ratio has
median 1.0000 and mean 0.9985; the two agree exactly for 57.6% and
63.2% of targets, and the 1st percentile is 0.9927 and 0.9900. The worst
single target on one screen sits at 0.4990, its gRNAs overlapping enough
to halve the count.
So substituting the union would look harmless on almost every target and be badly wrong on a handful, which is the least useful shape an error can have: too small to notice in aggregate, large enough to move a specific pair's answer. That is a reason to keep the sum, not a reason to relax about it.
The stronger reason is that the union cannot supply the other term at all.
num_trt_cells_sq, the sum of squared per-gRNA counts, is the across-gRNA
variance term, and no target-level total contains it: a target's 15 gRNAs
could be equal or wildly unequal at the same total. target_cell_counts
therefore requires per-gRNA granularity outright rather than deriving it.
One visible consequence is kept visible rather than smoothed over: the sum can
exceed the number of distinct cells, which would empty the complement group,
and compute_power raises with that explanation rather than returning
a NaN. Note the trap measure_union_vs_sum.R documents, because it bites
anyone recomputing this: @initial_grna_assignment_list is indexed against
all cells while grna_group_idxs is indexed against cells_in_use (586,309
against 567,690 on one screen), so the per-gRNA counts have to be restricted
to the cells in use before the two are comparable.
cutoff is required. PerturbPlan will derive one from the predicted
distributions when it is NULL; that is its design-planning mode, and only
the explicit-cutoff path is ported. The threshold is a screen's own
multiple-testing correction, and it is load-bearing rather than a nuisance
parameter: a design testing an order of magnitude more pairs carries an order
of magnitude stricter threshold, and scoring one design's pairs against
another's was measured to more than double the error and to do so in the
optimistic direction. A default would be the easiest available way to get a
confidently wrong answer. A corollary worth knowing: a good deal of any
apparent detectability gap between two designs is testing burden rather than
biology.
side="left" at cutoff = alpha/2 is the validated configuration. A
simulation counts as a success when p < alpha and the fold change is
negative; sceptre's p-value is two-sided, so the matching analytical call is
the left tail at half the threshold. side="both" at cutoff = alpha is
reachable because a user will reach for it, but it is not what was scored.
fold_change_sd has no default. The 0.13 behind every published number
came from PerturbPlan's documentation example, not from data. It enters the
variance through the across-gRNA term, so if accuracy moves when it moves,
"no parameters fitted" is too generous a description of the estimator. The
sensitivity has not been measured. A default would turn a stated open question
into an invisible one.
The nonzero-count thresholds default to 0, not to R's 7. Power is
multiplied by 1 - P(the pair fails pairwise QC). pysceptre is fed pairs that
already passed QC in the real analysis, so at a threshold of 7 their power
would be discounted a second time for a risk that did not materialise. They
are exposed for the case the discount is meant for: pairs that were never
tested.
Power is not monotone in the effect size, and that is the estimator¶
On a 3-cell target and a near-zero-expression gene, PerturbPlan's own R reports power falling as the knockdown deepens: 2.43e-4 at a 5% knockdown, 1.19e-4 at 15%, 1.09e-6 at 50%. A deeper knockdown lowers the treated group's negative-binomial variance, and when the statistic's mean is still ~3.3 sd short of a threshold that far into the tail, concentrating the statistic moves mass away from the rejection region instead of into it.
It is confined to pairs whose power is ~0 under every effect size, so it
changes no decision, and it is reproduced rather than corrected:
tests/validation/test_analytical_power.py pins it deliberately. A "fix"
would be a different estimator.
Why the covariates can come from anywhere¶
On the default inputs the estimator's inputs are not properties of the pair: expression mean and dispersion belong to the gene, cell counts to the element. The information-matched mean below is per pair, but it is still composed from the gene's fit and the element's cells. That is measured rather than assumed: one screen processed through sceptre twice, under two designs sharing genes and elements but almost no pairs, gives covariates that match exactly across the entities the two runs share. So a pair's inputs can be composed from its gene and its element measured separately, in any pairing, which is what makes the estimate available for pairs no screen tested. sceptre's requirement that a pair carry its own QC before it can be tested constrains simulating that pair, not estimating its detectability.
What the estimate is not good enough for¶
The closed form misses a few percent of the pairs the simulations call powered, and makes wrong claims on well under one percent of the rest. Those rates are good enough to plan a screen and to triage its negatives; they are not good enough to close a question about one element-gene pair, and the estimate should not be used that way.
The evidence behind them is also one dataset: one platform, one MOI, one cell line, and sceptre's permutation test rather than the CRT, which an ondisc-backed response matrix forced. It held across a tenfold change in threshold and a tenfold range of effect size, which is real evidence it is not tuned to a corner, but whether it holds on another lab and protocol is untested.
Covariates through the information-matched mean¶
compute_power summarises a gene by one expression_mean and treats every
cell as if it had that mean. sceptre's score statistic does not: it is built
cell by cell from the fitted means mu_j = exp(Z_j b), and a cell contributes
information w_j = mu_j / (1 + mu_j / theta), the w of
precompute/pieces.py. Two effects follow, in opposite directions.
wis concave inmu, so whenmu_jvaries a lot between cells (a batch effect, library size) the mean ofmuoverstates the information the test gets. DC-TAP K562's CYBA (ENSG00000051523), nearly absent from six of eleven batches, is the worst case: one mean per gene reads its pairs too high.- An element's perturbed cells are not the average cell. They have larger
libraries and carry more information than one mean per gene gives them. The
median of
info_ratio_pertover the trans pairs, the perturbed cells' average information over the average cell's, is 1.06 in K562 and 1.11 in WTC11. That is where the estimate's slight conservatism on those screens comes from.
inputs.matched_expression_stats corrects both without a new fit and without
touching the closed form. Per pair, with P the target's perturbed cells and
C the rest, it averages w over each (f_p, f_c) and hands PerturbPlan
the single mean that gives its statistic the same variance:
f = (1/n_p + 1/n_c) / (1/(n_p f_p) + 1/(n_c f_c))
expression_mean = f / (1 - f / theta)
Every w_j < theta, so f lies below theta too and the inversion cannot
fail; there is no clamp to add. expression_size stays the fit's theta. The
result has one row per pair, which is why compute_power accepts a
grna_target column in baseline_expression_stats. This is the one input
that is a property of the pair, so it exists only for pairs whose target
has cells.
The averages and the counts use different cell sets, on purpose. f_p
and f_c average over the union of the target's cells and its complement,
because that is what the test sees. n_p and n_c are PerturbPlan's own
per-gRNA sum and num_total_cells - n_p, the first of the "Five things
that look like simplifications and are not" above. That combination produced
every number below. Do not tidy it into union/union or
sum/sum without re-running the comparison.
Agreement of the 0.8 call with WattEG's simulation at a 15% knockdown
(fold_change_mean=0.85, fold_change_sd=0, side="both", each screen's
own discovery threshold as cutoff). "Over" is compute_power >= 0.8 where
the simulation is not, "under" the reverse. test_matched_expression_dctap.py
reproduces the per-element column from the same inputs.
| screen, pairs | pairs | one mean per gene | gene-level matched | per-element matched |
|---|---|---|---|---|
| K562 cis | 7,493 | 98.02% (41 over, 107 under) | 97.68% (8 over, 166 under) | 98.56% (32 over, 76 under) |
| K562 trans | 297,707 | 97.70% (1,617 over, 5,232 under) | 96.94% (300 over, 8,824 under) | 98.26% (1,518 over, 3,648 under) |
| WTC11 cis | 6,574 | 97.61% (1 over, 156 under) | 97.54% (1 over, 161 under) | 99.04% (16 over, 47 under) |
| WTC11 trans | 196,615 | 97.38% (28 over, 5,122 under) | 97.17% (13 over, 5,558 under) | 99.07% (493 over, 1,331 under) |
The per-element mean improves all four, mostly by removing too-low calls, and it gives back some too-high ones. That is the trade to watch: an over-call is the error that closes a question wrongly.
The gene-level version is not offered. Dropping the element split gives
one matched mean per gene, f = mean over all cells of w_j. It fixes CYBA and
nothing else, and it lowers agreement on all four tables, because it removes
the too-high calls and adds more too-low ones by ignoring that perturbed cells
are bigger.
What this does not establish. One lab, two DC-TAP screens, one effect
size, fold_change_sd = 0 and side="both" only. The day0 comparison of
compute_power has not been repeated with the matched mean, which is why it
sits beside baseline_expression_stats_from_fits rather than replacing it.
What it still leaves out. sceptre's statistic also subtracts the treated
cells' projection onto the covariates (lower_right in
test_statistic/score_stat.py). Matching w captures how much information
the perturbed and control cells carry, not how much of the treatment
indicator the covariates explain. For an element whose perturbed cells sit
mostly in one batch that term matters, and the matched mean does not see it.
Building the estimator's inputs¶
analytical_power/inputs.py derives the three things compute_power
needs that no other part of pysceptre produces. Each can be got subtly wrong
in a way that still returns plausible numbers, so each is validated against
someone else's code rather than against itself.
The helpers take arrays and frames, never a loaded export. Reading an
.h5mu needs the io extra and nothing under src/ imports it, so the glue
from a file to these arguments lives in scripts/. There is deliberately no
convenience wrapper that takes an export and returns a power table: the
estimator's signature is the validated one, and a wrapper is where the
sum-for-union and all-cells-for-cells-in-use substitutions would creep back in
unnoticed.
expression_mean has to be on the scale sceptre's own model works on¶
This is the constraint that matters, and it is easy to state wrongly. The estimator exists to predict sceptre's test, so the gene expression it is handed has to be the expression that test operates on. Any other normalisation, however respectable, answers a different question.
sceptre caches fitted_coefs and theta per gene, so the mean its model
implies is mean(exp(Z b)), the average fitted value.
baseline_expression_stats_from_fits returns exactly that, taken from the
same negative-binomial fit that produces theta, so the power estimate and the
test it predicts are on one scale by construction rather than by coincidence.
It is validated against sceptre's own cached fit over day0's 237 genes, and
the two claims are worth separating because they are not the same size. The
fitted values -- exp(Z b), which is what the mean is taken over -- agree
to a relative 9.2e-10 across all 134,542,530 gene x cell values. The
coefficients themselves agree less tightly, as correlated design columns
will: 2.3e-9 on the intercept but 4.5e-7 at worst, on
replicate_factorRep 4. Every one of the eleven columns is compared
separately, dummies included, because a factor-contrast ordering mismatch
would shift the baseline by batch while a pooled maximum looked fine.
pysceptre's fitted values already match sceptre's at 1e-6
(test_glm_fits.py), so nothing new is being trusted here.
The published comparison did not use that mean, and the difference is
measurable. It used a size-factor-normalised mean instead, which
baseline_expression_stats reproduces. On day0, over the 237 genes both
cover:
| cell set | ||
|---|---|---|
| sceptre's model mean / the normalised mean | median 1.1613, sd 0.0218, range 1.0948 to 1.2235 | mixed |
| correlation of the logs | 0.99995 | mixed |
| the raw mean / the normalised mean | 1.1875 | all cells, both |
| sceptre's model mean / the raw mean | 0.9786 | mixed |
Two of those rows mix cell sets, and the mix is the whole of their
residual. The model mean is over cells_in_use, which is what the fit was
computed on; the normalised and raw means come from row_data, which is over
every cell in the object. Measured on one cell set the model reproduces
the raw mean exactly -- mean(exp(Z b)) / raw = 1.0000000000, maximum
deviation 8.1e-9 -- because a Poisson GLM with an intercept satisfies
sum(fitted) == sum(observed). The 2.14 % in the last row is entirely
raw(all cells) / raw(cells_in_use) = 1.0219, a property of which cells the
normalisation ran over and not of the model. Read as a model residual it would
be simply wrong.
So the clean statement is the third row, which compares like with like:
the normalised mean sits 15.8 % below the raw mean, and sceptre's scale is
the raw mean. About 16 %, and the 1.1613 in the first row reads low only
because its numerator is over a cell set whose mean is 2.2 % smaller. The
offset is a single factor rather than a reshuffling either way: the two agree
almost perfectly on which genes are expressed more than which. Raising
expression_mean by that factor raises the closed form's power on 17 of the
fixture's 18 pairs, the exception being a pair whose power is ~0 under either
(the non-monotone corner below). So feeding it the normalised mean makes the
estimate conservative rather than wrong-shaped, which is the better
direction to err in and still the wrong number.
Which to use. baseline_expression_stats_from_fits for any new analysis,
because it is the only one consistent with the test being predicted.
baseline_expression_stats only to reproduce the published comparison, whose
sensitivity and specificity were measured with the normalised mean on both
sides -- the closed form and the simulation that served as its ground truth
both read it, so that comparison is internally consistent and its conclusion
stands.
The two sides of that comparison were not on the same scale, which is worth knowing before its error profile is taken at face value. The simulation that produced its ground truth multiplies each cell's size factor back in when it draws counts; the closed form has no size factors and applies no such scaling. So being fed the same column, the closed form was predicting for genes about 13.9 % dimmer than the simulation actually made them, which is the mean size factor on day0, 1.1389.
What that does to the reported residuals is not established, and it would be easy to over-read. The comparison found the formula "leans low", a positive median residual in every bin of predicted power, and a 13.9 % dim input would produce that signature. But the same baseline also understated the count variance by about 13.5 %, and less variance inflates power where less expression deflates it. Two errors of opposite sign, neither measured against the other, so the net direction of the simulated power is unknown. The ranking result is scale-insensitive and unaffected either way; the calibration half should not be read as characterising the estimator until the two are separated.
None of this is pysceptre's to fix and nothing here depends on it: the helpers
are validated against R output value for value, and which mean to feed the
estimator is the caller's. It is recorded so a later comparison against those
sweeps does not inherit the mismatch silently. It describes the published
sweeps: the pipeline that produced them has since been changed to draw from
exp(X b), sceptre's own model, which removes the mismatch at the source.
(Two earlier versions of this note were wrong. The first said the simulation ran 16 % low, missing that it restores the size factors. The second said its power was therefore biased low, which the variance error contradicts.)
A note on the poscounts convention, for the reproduction path only¶
The normalised mean's size factors are DESeq2-style "poscounts", and they are not centred the way DESeq2 centres them: DESeq2 divides its factors by their geometric mean and the implementation here does not. Measured over day0's 586,309 cells that geometric mean is 1.0639, so the factors are 6.4 % larger and the resulting gene means 6.4 % smaller than DESeq2's convention would give. (This is a different 6 % from the 16 % above, and it is a subset of it: both push the same way.)
This is recorded for the reproduction path and for anyone comparing against DESeq2 output, not as a live decision. sceptre computes no size factors and is not a party to it. The validation splits accordingly: every ratio in the factor vector is checked against DESeq2 itself, and the absolute scale against real output from the run that produced the published numbers, because neither check alone constrains both.
Why this is 40 lines here rather than a library call¶
Reimplementing a published normalisation is the wrong default, so two candidates were tried against the fixture before it was written. Neither supplies the quantity this needs, and the reason is the same for both: they implement the estimator DESeq2 and edgeR use for bulk data, which assumes genes that are nonzero in every sample.
| candidate | what it offers | result on the fixture |
|---|---|---|
edgepython 0.2.6, an edgeR port |
TMM, RLE, upperquartile | a different quantity: TMM correlates -0.05 with poscounts factors and RLE -0.07. upperquartile returns inf |
pydeseq2 0.5.4 |
DESeq2's default median-of-ratios | does not match, and not by a constant either: the ratio to R spreads by 1.9 |
On the sparse case both fail outright, pydeseq2 returning all NaN. The
cause is visible directly: at 33 % nonzero, 0 of 300 genes are nonzero in
every cell, and both estimators need the geometric mean of a gene across all
of them. "poscounts" exists precisely because that assumption does not hold
for single-cell data, and neither package implements it.
Two further reasons, either of which would matter on its own. pydeseq2
rejects a scipy.sparse matrix (TypeError on log) and edgepython wants
dense as well, while never densify is a standing constraint here. And
between them they would add anndata, formulaic, matplotlib, patsy,
scikit-learn, statsmodels and numba to a package whose runtime
dependencies are four.
So the arithmetic is implemented, on the nonzeros, and validated against DESeq2 for every ratio and against real R output for the absolute scale. That is a better trade than a dependency that computes something else.
The gene and cell sets are part of the definition¶
The geometric mean runs over every gene and the median over every cell, so computing either on a subset gives different factors for the cells that remain, and a different mean for every gene. Both results look equally complete, which is what makes it dangerous. On day0 the validated factors were computed over all 292 genes and all 586,309 cells, the 18,619 that QC removed included, while the gene statistics were then reported for the 237 genes appearing in pairs.
Two consequences. baseline_expression_stats computes over the full matrix
and has a gene_subset argument for the reporting step, so subsetting the
output is available and subsetting the input is not disguised as the same
thing. And an export now carries every gene and every cell by default,
because that choice is not recoverable afterwards: exporting everything costs
disk and nothing else, since an analysis reads only the genes in the discovery
pairs and the loader subsets back to cells_in_use, so the extra rows and
columns never reach a per-gene fit or a worker process.
Theta is reused, not refitted, and it carries two caveats¶
expression_size is the NB size, theta, which discovery.py::fit_all_genes
already returns, so a completed analysis has it and
baseline_expression_stats accepts it rather than refitting. Two things about
it that are invisible in the output: it is clamped to (0.01, 1000.0), so
a gene at a bound carries the bound instead of its estimate; and it is fitted
under whatever covariate_matrix was passed, so it matches another
implementation's only if the design matrices match. On day0 every one of R's
237 cached values sits inside the clamp, median 16.84, so the clamp is not
biting there. The day0 check reports the comparison rather than asserting a
tolerance, because two different estimators agreeing to a tolerance is not
something a test should demand.
The gRNA-to-target map is many-to-many¶
Overlapping candidate elements share guides. On day0, 1,673 of 43,736 guides
sit inside two or three of them, so the design table carries 45,463 rows for
43,736 distinct ids and the same guide appears under several targets. R sums
such a guide's cells into every target it belongs to, and
cells_per_grna_from_assignments does the same. Deduplicating by grna_id
looks like hygiene and would silently shrink exactly those targets;
compute_power originally refused a repeated grna_id for that
reason and was wrong to, which the fixture now pins against R.
A designed guide that ended up with no cells is a num_cells = 0 row rather
than an absent one, for the same family of reason: dropped, num_trt_cells_sq
would be computed over a smaller guide set than the screen actually used.
The cutoff helper does the correction pysceptre otherwise skips¶
bh_nominal_cutoff applies BH and returns the largest p-value it calls
significant, which is what cutoff wants. It ports WattEG's
discovery_threshold() including its refusal to return anything when nothing
is significant: the code that preceded it returned -Inf there, and every
pair silently got zero power. NaN p-values, the pairs that failed pairwise QC
and were never tested, are dropped before the correction rather than inflating
its denominator. On day0 it reproduces R's derived threshold,
0.00072628634455531758, exactly.
Specificity check¶
specificity/ answers the question sceptre's two other checks leave open. The
calibration check asks whether the pipeline invents effects, the power check
whether it recovers effects it should. Neither says how many of the links a
screen did discover are real. Like analytical_power/, this is not a port of
sceptre and has no R counterpart; unlike it, there is no external
implementation to validate against either. Its reference is the analysis it
was ported from, WattEG-paper's analysis/direct_indirect.py, which itself
started from EngreitzLab/CRISPR_indirect_effects (DC-TAP Fig. 4 and S3).
A link is an element-gene pair on the same chromosome, called at the screen's cutoff with a negative effect. "Direct" and "indirect", the original names, are not used: the background is mostly noise and genes that shift with any perturbation, not indirect regulation.
The background, matched gene for gene¶
An element cannot regulate a gene on another chromosome directly, so how often
those tests are called at the cis cutoff, with a negative effect, is how
often a test is called without regulation. background_pairs takes the
screen's elements against its cis genes on other chromosomes, drops chrY, and
keeps tested pairs only.
One pooled rate is biased, because well-expressed genes are called more
often and the mix of genes differs between distance bins. So each gene gets
its own rate r_g, the share of its background tests called, and a bin's
background is the mean of r_g over the bin's tested pairs: a gene counts as
often as it is tested there. Links above background is
(cis rate - background) / cis rate.
The bootstrap draws elements, not pairs. An element's cis and background
tests stay together, and every r_g is recomputed in each draw. The notebook
built a dense elements by bins by genes tensor for this; the port sums over
pairs in blocks of draws instead, which gives the same numbers (to 1e-13 on
three screens) and does not grow with the square of the panel.
test_specificity.py keeps the dense formulation as its reference.
Per-gene rates are noisy where the background is low. A gene with no background call gets a rate of zero, which is an underestimate rather than a measurement. Shrinkage toward the pooled rate is open, and not done.
The background in one pass¶
A trans result from run_trans_analysis can hold of the order of 1e8 rows
("The trans background"), so run_specificity_check reads it once.
tested_pairs keeps its tested rows, copying them column by column and
nothing else. The measured genes, the TSS lookup's rows and the background
are all taken from that one frame. background_pairs keeps the rows of the
cis elements and genes on other chromosomes with one hash lookup per row and
id column into the link sets, and when it keeps every row the frame becomes
the background table without a copy.
The bootstrap's per-gene rates come from two elements x genes matrices, the
background tests and the background calls, each multiplied by every draw's
element weights. They are built sparse, and one more than half full is
converted to a dense array and multiplied as one (_DENSE_ABOVE in
specificity/background.py). The two products give the same bits. The
entries count tests or calls and the weights count how often a draw takes
each element, so every product and every partial sum is an integer, and
float64 holds every integer below 2**53 exactly: no addition rounds, so the
order in which the dense and the sparse product add cannot matter. Which one
runs changes time and memory, never a rate.
Broad-effect elements¶
Some elements lower many genes on other chromosomes, far beyond the
background: a one-sided binomial test of the element's background calls
against the pooled rate, flagged at p < 1e-3. They make their own
background, which a per-gene rate cannot see, so the check reports them and
repeats the by-distance table without them.
Remove them from both sides. Removing their cis links while keeping their
background calls inflates the background. above_background_by_distance only
counts the background tests of elements present in links, so dropping an
element from links drops it from both sides; do not loosen that filter.
The TSS lookup and the detour check¶
Every target within 1 kb of a measured gene's TSS knocks that gene down,
whether or not the design labelled it a control. Its effects on every other
measured gene give a gene-to-gene table (gene_lookup). A far link E to H is
a detour when E also lowers some G, either as a link or by sitting on G's
TSS, whose TSS knockdown lowers H. detour_check reports detour,
detour (nominal) (only at p < 0.05), no detour (such G were tested
against H and none lowers it) and can't check (no G was tested against H).
A target equidistant from two measured TSSs, a bidirectional promoter, is
assigned the alphabetically first. The notebook's sort was not stable and
picked one arbitrarily; on day4 that is the one place the two differ
(chr17:28357330-28357839, 57.5 bp from both POLDIP2 and TMEM199), and it
changes no detour status. A promoter knocks down both genes, which one gene
per target cannot express.
Measured on three screens¶
day0, day2 and day4 of an endothelial differentiation, cis and background both
tested with pysceptre's permutation test, cutoff BH at 10% over each screen's
cis pairs, 2,000 draws. test_specificity_days.py reproduces these from the
same inputs.
| day0 | day2 | day4 | |
|---|---|---|---|
| links within 50 kb above background, lowest bin | 0.98 | 0.97 | 0.98 |
| far links (> 100 kb) above background | 0.20 (-0.30 to 0.44) | 0.45 (0.28 to 0.57) | 0.55 (0.37 to 0.66) |
| same, without broad-effect elements | 0.18 (-0.33 to 0.43) | 0.50 (0.28 to 0.63) | 0.66 (0.53 to 0.74) |
| background rate | 0.084% | 0.142% | 0.087% |
| broad-effect elements | 4 | 40 | 16 |
| far links: detour, nominal detour, no detour, can't check | 0, 0, 1, 25 | 12, 5, 9, 41 | 0, 0, 5, 47 |
On day2 the elements with the most background calls sit at the TSSs of FOXH1 (74 bp for one of them), KDR (0 bp) and HAND1 (81 bp).
What it does not establish, and what is left out¶
- No ground truth outside the notebook. The pooled version (one rate, no matching) could be checked value for value against CRISPR_indirect_effects' R on DC-TAP; that has not been done. The matched rate, the broad-effect flag and the detour check have no external reference at all.
- The fingerprint check is not implemented. The notebook's fourth part
asks whether a far link E to G also lowers G's own downstream genes, scored
with
compute_poweron the matched mean. Near links, which the background says are real, recover far less of their expected fingerprint on day4 than on day2, and until that is understood it is not shipped. - Positions are the caller's. Distances come from
element_positionsandgene_positions, never from target names. On DC-TAP the target names are hg19 coordinates and the TSSs hg38, which put near links into the far bins in the notebook's first pass. - Same test on both sides. The cis and background results should come from the same test. Mixing R's CRT on one side with pysceptre on the other is an untested assumption.
The trans background¶
The specificity check measures its background on the screen's elements tested
against its cis genes on other chromosomes, with the test the cis side used
("Same test on both sides", above). On a transcriptome-wide screen that is of
the order of 1e8 pairs. run_trans_analysis (pipeline/trans.py) runs them
with the reference (cis) run's test, and is not from sceptre.
Why the cost is not per pair¶
Discovery fits each gene once and each target once, batched per target chunk,
then rebuilds a gene's pieces (mu, w, a, D and the stacked
(n_cells, p + 2) array) once per gene per chunk it meets
(_run_target_chunks). The chunk width comes from chunk_memory_gb, which
charges each target 10 * n_cells * 8 bytes of fit arrays plus 8 bytes per
cell index of its resamples, of which it is charged B1 + B2 + B3 times the
median treated-cell count over targets (target_bytes_per_item): on the
1,554,804-cell screen above, the default budget gives chunks of six
targets. A cis pair list meets each gene in a handful of chunks; a trans list
meets every gene in every chunk, so the rebuilds, each O(n_cells * p^2)
work and a few GB of fresh memory at that size, grow with genes times chunks
and dominate.
Dividing a cis run's time by its pair count and multiplying by the trans
count therefore prices mostly fixed costs.
The trans run keeps the fits as they are and changes only the loop: a shard's
targets are all fit and drawn first, then each gene's pieces are built once
and tested against every target of the shard. Rebuilds become genes times
shards. Width-1 gene fits depend on nothing but the gene, so they are
computed once and reused by every shard (pipeline/gene_cache.py). A cache
carries a fingerprint of the data, the covariate matrix's memory layout
included ("Bits depend on the machine"), of the engine settings and of the
numerical environment. load_gene_cache refuses a file made in another
numerical environment unless given check_environment=False, and, when
given the data, one made from other data. run_trans_analysis checks a
GeneFitCache against its own data and glm_solver the same way, but not
against the environment: a cache loaded with check_environment=False is
used in whatever environment it is given to. A plain dict of fits is not
checked at all.
Targets are fit in the reference run's chunks¶
A target's draws come from its fitted probabilities through
rng.binomial(B, p), and under the default glm_solver="outer" the batched
logistic fit is not bit for bit across batch compositions ("Target fits no
longer depend on their chunk-mates"). So the trans run fits every target with
exactly the chunk-mates it had in the reference run: reference_fit_chunks
rebuilds the reference layout from the reference run's pairs and settings,
run_trans_analysis, given expected_fit_chunks, refuses a layout of
another width or count, and a shard is a set of whole reference chunks, all
of whose targets are fit whether or not the shard tests them.
Under glm_solver="structured" a target's fit does not depend on its
chunk-mates ("Indicator columns in the IRLS"), so the layout no longer
decides any bits. It is still rebuilt and checked, which costs nothing and
keeps one code path. Either way the trans run takes the reference run's
glm_solver, and a gene-fit cache made under the other is refused.
Two screens, both exact for the check¶
The check reads a background test only through called_down (p-value at most
the cutoff, log_2_fold_change below 0), and the TSS lookup only through
lowers and lowers_nominal (the same, and p < 0.05). Two kinds of work
cannot change any of them.
A fold change of at least 1. Such a pair is never a call, whatever its
p-value. It is not resampled; it is reported with screen "fold_change",
p_value NaN, and its fold change and statistic, and it is still counted as
a test.
The rest of the first stage. A pair escalates when its first-stage
p-value is at most 0.02. The counts of null statistics on each side of the
observed one only grow as resamples are added, so the p-value those counts
give with the full B1 denominator is a lower bound on the stage's p-value.
The stage stops once that bound exceeds 0.02, so the pair cannot escalate,
and reaches 0.05, so it cannot be a nominal lookup call either. Two-sided,
that is a count of at least 12 on each side, a bound of 26/500 = 0.052;
one-sided, a count of at least 24, a bound of 0.05. Such a pair is reported
with screen "early_stop", p_value NaN and the bound in p_value_floor.
run_specificity_check refuses a bound at or below the cutoff, or below the
nominal level on a TSS target, so the screens cannot be paired with a cutoff
they do not serve.
A pair that escalates runs all 499 first-stage resamples, and its p-value
comes from the second stage's draws through run_low_level_test_full's own
calls, so it is the full test's result. The first stage is computed in blocks
of rows, and a block's statistics equal the corresponding rows of the full
product bit for bit: scipy's sparse product computes each row on its own, and
so does the statistic. The block route skips the full product's shortcut for
an all-empty draw matrix, whose NaN differs from 0/0's only in its payload.
test_early_stop_rule.py and test_trans_engine.py pin all of this.
Under side="right" the screens are still exact for the check's calls, which
need a negative effect; a pair a right-sided test would call with a positive
effect is screened.
Bits depend on the machine¶
numpy dispatches exp and log to AVX-512 loops where the CPU has them, and
scipy's OpenBLAS picks its kernels by CPU model, so the same code gives
different last bits on different node types. On the 1,554,804-cell screen the same
0.5.0 code on an Intel Sapphire Rapids node, with a different target set,
against the AMD EPYC 7502 run: z_orig differed on all 280 shared pairs
(median 1.9e-11 relative, at most 3.1e-9), fold_change on all 280 (at most
1.7e-11), the p-value on 10 stage-2 pairs (at most 3.8e-10) and the stage on
none. A trans run that must match its reference bit for bit therefore runs on
the reference run's CPU class.
Two things besides the machine move bits. BLAS rounds a product differently
for a row-major and a column-major covariate matrix, and the engine uses the
matrix in the layout it is given, so the two layouts can give one analysis
different last bits. The engine does not pin the layout: run_trans_analysis
given no gene fits, or a plain dict of them, accepts either. What pins it is
around the engine. A gene-fit cache records the layout its fits were made
with and is refused with another (a strided view is refused outright: which
layout BLAS treats it as depends on its strides), and scripts/sceptre_io.load_h5mu returns
the covariate matrix row-major whatever the file, so the two ways of writing
a dataset give the same bits; a caller with its own matrix must pass the
reference run's layout. Without numba, the CRT sampler lists each resample's
cells in another order ("The compiled and numpy sums agree bit for bit"), so
run_trans_analysis refuses to run without it, and the environment
fingerprint records whether it is installed.
scripts/run_trans_background.py tests a shard's reference pairs in full
alongside its trans pairs, in gene order, checks each against the reference
result bit for bit as its gene comes up, and stops the shard on any
difference, so a wrong node type stops the shard rather than passing
silently. The parts written before the first difference stay on disk
with their completion markers, and a restarted shard skips them, so a shard
stopped this way should be cleared before it is rerun, or those parts keep
the wrong node's bits.
What it does not establish¶
- Exact relative to pysceptre, not to R. The screens and the layout make the trans run equal to a full pysceptre run; how pysceptre compares with R is the rest of this page.
- A screened p-value is not a p-value. A screened row's NaN
p_valuemeans "tested, not resampled", unlike discovery's NaN. A trans result is for the specificity check; anything that needs the p-values themselves, such as their distribution, should use the audit sample the runner keeps unscreened.
gRNA integration strategies¶
sceptre tests whatever it calls a grna_group, and
grna_integration_strategy only decides what a group is: the target
(union), the individual guide (singleton), or the guide followed by an
aggregation back to the target (bonferroni). Nothing statistical differs
between them. The same CRT, the same score statistic, the same skew-normal
escalation; only the treated cell set changes, and in bonferroni's case
what happens to the results afterwards. So pipeline/grouping.py holds pair
bookkeeping and one aggregation, and discovery.py never learns which
strategy is in play, exactly as sceptre's engine does not.
The expansion is validated against update_dfs_based_on_grouping_strategy
itself rather than against a reading of it, on four cached cases and then on a
real screen: day0's 36,450 pairs become 515,972, row for row and in order.
That comparison earned its keep twice over, as both of the following came out
of it rather than out of reasoning.
A guide shared by two targets is one test reported twice¶
Overlapping candidate elements share guides, and on day0 1,673 of 43,736 sit in two or three. R expands the pair list many-to-many and so tests such a guide once per target it belongs to. Those tests are necessarily identical: the same gene and the same guide means the same treated cells. pysceptre therefore runs the distinct tests and fans the target column out afterwards, which is cheaper and removes the possibility of two copies disagreeing. The reported rows are the same rows.
A duplicated design row is kept, and that is a choice¶
day0's design lists 36 (grna_id, grna_target) pairs twice: the same guide
against the same target, which carries no information the single row does not.
Deduplicating them was the first implementation, and it disagreed with R by
exactly the 288 expansion rows they fan out to.
Whether that matters depends entirely on the strategy, which is why the default is what it is:
| path | effect of a duplicated design row |
|---|---|
union |
none, provably. R builds a target's cells as unique(unlist(...)), so listing a guide twice contributes the same set once. The union path does not read the design table at all. |
singleton |
the pair is expanded twice, so the guide appears twice in the result and is double counted by any correction applied across rows |
bonferroni |
the correction factor is sum(pass_qc), so the guide is counted twice and the corrected p-value is inflated |
compute_power |
the guide's cells enter num_trt_cells and its squared sum twice, which is simply wrong |
So the discovery path keeps them by default, because matching R is how
correctness is established here and the error direction is conservative, and
warns with the one-line alternative. drop_duplicate_design_rows=True
collapses them, at the stated cost of no longer reproducing R's row count.
Asking for it under union is refused rather than ignored, since it could not
change anything there.
cells_per_grna_from_assignments raises instead of warning, and takes the
same option to collapse. The asymmetry is deliberate: there is no parity
argument on that path, because PerturbPlan never reads a design table, and
double counting cells into a variance term is not a conservative error but a
wrong one.
Two deliberate deviations from R¶
An orphan target raises. A pair naming a target with no guides in the
design leaves grna_group as NA in R's left join, and the row survives to
ask for the cells of a group called NA. An untestable row reaching a result
is the failure this package refuses everywhere else.
pass_qc is optional in the aggregation. R reads it from
discovery_pairs_with_info. run_discovery_analysis is handed pairs already
judged testable and does not produce that column, so when it is absent every
row counts as passing and the correction factor is the group size. Supplying
it -- pipeline/pairwise_qc.py computes it at guide resolution -- recovers
R's behaviour for a target whose guides individually fail, which is the case
the default cannot see.
gRNA assignment¶
pysceptre.assignment holds four ways of deciding which cells carry which
gRNA, all behind one entry point, assign_grnas, like sceptre's.
assign_grnas_mixture, assign_grnas_thresholding and assign_grnas_maximum
are sceptre's own three methods, ported from sceptre 0.10.3.
assign_grnas_fishash is not sceptre's: it ports fishash (jackkamm/fishash
0.99.5, commit 5eabd3c; MIT; Kamm, Yeung and Forrest, bioRxiv
10.64898/2026.01.22.701179), a one-sided Fisher test per (gRNA, cell). All
four take raw integer UMI counts, gRNAs as rows and cells as columns, and all
four are validated against their R originals value for value on synthetic
data. The notices for sceptre and fishash, and for the part of R's
mathematical library fishash needs, are in THIRD_PARTY_LICENSES.
fishash 0.99.5 is ported rather than 0.3.0, the version in the preprint's
Table 1. The algorithm is the same except for commit ff4de6b, which changed
which guides and cells the noise fit skips from "fully masked" to "no unmasked
count"; the two differ only where 0.3.0 would divide 0 by 0. The other changes
after 0.3.0 touch output structure only (drop0 on the calls, how the
assignment strings are built) or the simulator's defaults, which the
preprint's simulate_guidebender2 calls override.
What fishash normalizes, and what it does not¶
The counts are never transformed. There is no log, no CLR, no size factor. Each nonzero count is tested on its 2x2 table -- this gRNA or another, this cell or another -- and the test conditions on the table's margins: under the null the expected count is the cell's total times the gRNA's share of the counts in other cells. That accounts for each cell's depth and each gRNA's abundance at once, and leaves the cut to an FDR procedure (Guo and Sarkar's block procedure by default) rather than to a per-gRNA threshold.
With refit > 0 the reference is the noise, not the total. Each later
pass masks the previous pass's calls and refits the masked entries from the
rest (below), so the other cells' margins describe the gRNA's ambient level.
That is fishash's correction for Simpson's paradox.
The cell side includes the cell's own signal. A cell's total counts every gRNA it carries, so each gRNA's share of the cell falls as the number of gRNAs per cell rises. Nothing in the method offsets that.
Only integer counts are accepted. R's fishash() accepts any numbers and
rounds inside phyper; the port raises instead, which also keeps a normalized
matrix out of a count test.
fishash on the nonzero entries¶
The mask, the noise estimate, the calls and the p-values all live on the same
entries: the nonzero counts. A call needs a p-value, which only a nonzero
count has, and the mask is the previous calls. So every pass works on 1-D
arrays aligned with the counts' column-major entries, which is also the order
R's TsparseMatrix gives them, and nothing is densified.
Sums follow R's order where it decides a last bit: margins by bincount
(column-major, as Matrix accumulates), totals left to right rather than by
numpy's pairwise sum. Block p-values are BH-adjusted over every cell, empty
ones included, and a cell with no entries counts as log p = 0, as
sparseMatrixStats::colMins reads an implicit zero.
When nothing passes, the cut is log(0) = -Inf, as in R. When the noise
estimate outside a cell sums below one count, R's phyper returns NaN and the
run cannot continue (R stops with "missing value where TRUE/FALSE needed"); the
port raises a ValueError naming the cause.
The hypergeometric tail is R's phyper¶
scipy's hypergeom cannot stand in. It refuses non-integer arguments, and the
refit passes produce non-integer margins that R's phyper rounds; and its log
upper tail loops over elements in Python. hypergeom.py therefore ports
phyper and what it calls -- pdhyper, dhyper, dbinom_raw, stirlerr,
bd0 -- from R's src/nmath (R 4.5 branch). Three details decide agreement:
m,nandkare rounded separately, half to even (nearbyint), andqis floored after adding 1e-7. The preprint's equation (7) rounds the noise margins; the R code relies onphyperdoing it.pdhyperaccumulates inlong double, which is plaindoubleon arm64 macOS, where the fixtures are made, and 80-bit on x86-64 Linux.- Apple's compiler contracts multiply-adds in R's build, so bitwise agreement is not a goal; agreement is to 1e-12 relative to max(1, |log p|).
The numba kernel and the numpy fallback perform the same operations per
element, but not with the same log, exp and log1p: numba compiles calls
into the platform's math library, while numpy uses its own vectorized loops on
CPUs that support them. On the fixture's grid the two agree bit for bit on
arm64 macOS and differ by up to 9 ULP on the x86-64 Linux runners of CI (GitHub
ubuntu-latest). test_numba_and_numpy_agree allows 64 ULP; each kernel is
also compared with R on its own, to the tolerance above.
Masked counts are imputed by a rank-one Poisson fit¶
impute_masked_counts fits the unmasked counts as guide_freqs[g] *
cell_sizes[c] by alternating closed-form updates (at most ten, stopping when
both factors move less than 1e-4 on the log scale) and replaces the masked
entries by the fit. fishash's loop recomputes the mask from scratch for the
first three passes and keeps every earlier mask afterwards, which stops
borderline calls from alternating; the deep run of the fixture reaches five
passes so both rules are exercised.
sceptre's mixture assignment¶
Per gRNA with at least ten cells of count one or more: a Poisson GLM of its
counts on the cell covariates, then a two-component EM from five fixed starts,
assigning the cells whose posterior for the second component reaches 0.8.
Fewer cells, or an EM that never converges, falls back to count >= 5.
Two of sceptre's behaviours are ported as they are, not corrected:
g_pertis updated as a log ratio and added to the mean on the count scale:g_mus_pert1 = g_mus_pert0 + g_pert.- The EM keeps the smaller component as the perturbed one. When more than half the cells look perturbed it swaps the components, so the fixture's row with 70% of cells perturbed assigns no cell at all.
The five starting values are the draws of sceptre 0.10.3's
get_random_starting_guesses (set.seed(4)), stored as constants: the
package does not reproduce R's random number generator.
The EM groups the zero-count cells¶
A cell with a count of zero contributes to the EM only through its fitted
mean, and its posterior does not depend on that mean, so all such cells share
one posterior. They are handled as a group -- their number, the sum of their
fitted means and the smallest of them -- which makes each EM step cost the
gRNA's nonzero cells instead of every cell. The algebra is sceptre's; the
summation order differs, which moves the log-likelihood in the last bits. A
zero-count cell whose fitted mean exceeds 200 keeps its own term, because
there sceptre's 1e-100 floor on a cell's likelihood can bind. A test-only,
dense, sequential replica of run_reduced_em_algo_cpp
(tests/validation/r_mixture.py) sits between the two.
One Poisson GLM per gRNA¶
The mixture reuses glm/irls.py, one gRNA per call, so a fit never depends on
which other gRNAs share its batch. One setting differs from the discovery
engine: the floor on a fitted mean. glm.fit bounds it at the machine
epsilon, the discovery engine at 1e-10, and for a gRNA whose cells are
separated by a covariate the two floors give different fits. The fixture's
glm_extreme row is such a gRNA: R's fitted means reach 2.2e-16, glm.fit
stops unconverged, the EM fails and the backup rule assigns the 15 cells. With
the 1e-10 floor the port's EM converged instead and assigned nothing, so the
mixture passes mu_floor = machine epsilon to fit_poisson_glm_batch, whose
default stays 1e-10.
The default assignment design¶
mixture_design_matrix builds the design sceptre's assign_grnas uses by
default: an intercept, then log(x) -- or log(x + 1) when any cell has zero
-- of response_n_nonzero, response_n_umis, grna_n_nonzero and
grna_n_umis, then any extra covariates (15 or more distinct values: left
out; categorical: treatment dummies). n_nonzero counts entries above 0.5, as
compute_cell_covariates does. sceptre's import_data computes two further
gRNA columns, the top gRNA and its share of the cell, and deletes both before
any formula sees them, so neither enters. response_p_mito is always left out.
design_from_covariates applies the same rule to a covariate frame exported
from R.
Thresholding and maximum¶
assign_grnas_thresholding and assign_grnas_maximum port sceptre's two
simpler methods. Neither fits anything, so agreeing with sceptre comes down to
which side of each cut a value falls on, where a tie goes, and what happens to
a cell with no gRNA UMIs. The rules below come from sceptre 0.10.3's R source
and, where the decision is made in its C++ (threshold_count_matrix,
compute_cell_covariates_cpp), from probing that code.
Thresholding is >=. A gRNA is assigned to every cell in which its UMI
count is at least threshold (default 5), as sceptre's
threshold_count_matrix does. A threshold below 1 is refused, as sceptre
refuses it. A cell can be assigned no gRNA, or several.
Maximum gives every cell exactly one gRNA: the one with the most UMIs in
it, the first in row order on a tie. The top gRNA and its share of the cell's
gRNA UMIs are the two columns sceptre's import_data computes with
compute_cell_covariates, grna_feature_w_max_expression and
grna_frac_umis_max_feature, the ones the default assignment design leaves
out (above).
A cell with no gRNA UMIs is assigned the first gRNA, and its share is 0/0,
NaN. R names the gRNA as rownames(matrix_in)[out$max_feature + 1L], and
max_feature is 0 for such a cell. The port keeps both, for parity with
sceptre: what removes the cell is the UMI rule below, not its assignment.
Two rules flag a cell, and their union is cells_w_zero_or_twoplus_grnas,
the cells sceptre's low-MOI QC removes:
- the top gRNA holds at most
umi_fraction_threshold(default 0.8) of the cell's gRNA UMIs:max_grna_frac_umis <= umi_fraction_threshold; - the cell has fewer than
min_grna_n_umis_threshold(default 5) gRNA UMIs:grna_n_umis < min_grna_n_umis_threshold.
R selects the first set with which(), which drops the NaN, so the share rule
never flags an empty cell; the UMI rule does, since 0 is below any positive
threshold. At min_grna_n_umis_threshold = 0, which sceptre allows, neither
rule flags it, and the empty cell keeps the first gRNA, in sceptre as here. The
port returns the flagged cells and does not remove them.
What is refused, and the default. sceptre refuses the maximum method for a
high-MOI screen, a umi_fraction_threshold outside (0, 1) and a negative
min_grna_n_umis_threshold, and so does the port. assign_grnas_maximum
takes no moi, so the MOI check is in assign_grnas, which refuses
method="maximum" with moi="high". method="default" is sceptre's default,
the maximum method for moi="low" and the mixture for moi="high", so it
needs moi.
After a thresholding or mixture assignment sceptre's low-MOI rule is the
one in process_initial_assignment_list: flag the cells assigned no gRNA, or
two or more. cells_w_zero_or_twoplus_grnas ports it, and it applies
unchanged to a fishash assignment. It departs from sceptre in one case, on
purpose. When no cell is assigned anything, sceptre computes the zero-gRNA
cells as seq(1, n_cells)[-sort(unique(unlist(list)))], the index is empty,
and negative indexing by an empty vector selects nothing, so no cell is
flagged. The port flags every cell, since none carries a gRNA.
Validated against R, and what that covers¶
The fishash and mixture fixtures (scripts/dump_fishash_ground_truth.R,
scripts/dump_mixture_ground_truth.R) record R's internals pass by pass, and
the tests feed R's own intermediate values back in so an error in one step
cannot hide behind another:
- fishash: log p-values to 1e-12 relative, the cut, every pass's B or n_signif and calls, the per-cell types and strings, the imputation's factors to 1e-10, on simulated and hand-built cases (empty guides and cells, a guide in every cell, rows whose unmasked counts vanish, ties, no signal, a supplied background, half-integer margins, stored zeros, degenerate shapes); and both places where R errors.
- the mixture: the design to 1e-15, the GLM to the existing 1e-6, the EM fed R's fitted means to 1e-9 for posteriors and 1e-12 for log-likelihoods, and the end-to-end assignments exactly.
Both fixtures keep every compared quantity away from a decision boundary (log p at least 1e-6 from the cut; posteriors at least 1e-3 from 0.8), so any disagreement in a call is a defect rather than a last-bit difference.
The thresholding and maximum methods are checked against sceptre 0.10.3's
public assign_grnas instead: scripts/dump_assignment_rules_ground_truth.R
runs it on synthetic counts with ties, empty cells and values on each cut, and
test_assignment_rules_vs_r.py compares the ports with its output, recorded in
tests/validation/assignment_rules_ground_truth.json.gz, value for value.
What the assignment ports do not establish¶
Agreement with R says the ports compute what fishash and sceptre compute, not
that any of them assigns gRNAs well. fishash and the mixture are evaluated on
simulated screens by the runners in scripts/fishash_eval/; the results
belong with the manuscript, not here. No port reads anything but a count
matrix; the .h5mu exports this repository builds carry one, as the optional
grna_counts assay, next to the 0/1 assignments.
The dose test¶
run_discovery_analysis, run_power_check and run_calibration_check run a
second test next to sceptre's when given per-cell weights
(grna_target_weights; ntc_grna_weights for the calibration check). It is
not from sceptre. sceptre's test asks whether a cell carries a target; the dose
test lets a cell carry it with a weight set by its gRNA UMI count, so no cell
has to be called, and the weakly expressed guides an assignment cut drops
still count, in proportion to the effect expected of them. It sits beside
sceptre's test rather than replacing it, the way edgeR offers a likelihood
ratio test and a quasi-likelihood test on one fit. Without weights, or with
weights all 1, every entry point returns sceptre's result exactly.
It runs with the complement control group, the CRT and the union strategy only, the settings it was built and checked on; the others are refused rather than given 0/1 behaviour silently.
The statistic¶
sceptre's score statistic over the treated cells T is built from per-cell
pieces a, w and D fitted once per gene under the null:
z = sum_T a / sqrt(sum_T w - sum_k (sum_T D_k)^2). The dose test replaces
membership with a weight t:
z = sum t a / sqrt( sum t^2 w - sum_k (sum t D_k)^2 )
which is the score test of the same negative binomial model with the 0/1
treatment column replaced by t, an effect on the log scale proportional to
the weight. Two consequences are tested in test_dose_test.py: weights of 1
give sceptre's statistic, and a constant weight cancels, so only relative
weights matter. The fold change is reported at full weight, with the effect
linear in t: 1 + sum t (y - mu) / sum t^2 mu, which is sceptre's
sum y / sum mu when every weight is 1.
Weights from gRNA counts¶
dose_weights builds the cells and weights from a raw count matrix. A cell's
count for a target is its largest count over the target's gRNAs, as the union
strategy counts a cell carrying two of a target's gRNAs as one treated cell.
The cell is kept when that count is above floor, and weighted
t = clip( log(c / floor) / log(ceiling / floor), 0, 1 )
so the weight rises linearly in log c from 0 at the floor to 1 at the
ceiling. The log scale follows the dose response measured on positive
controls, where knockdown deepens steadily with the guide's count from a few
UMIs to several hundred:
| guide UMIs | 1 to 3 | 4 to 10 | 11 to 20 | 21 to 50 | 51 to 100 | 101 to 200 | 201 to 500 | 501+ |
|---|---|---|---|---|---|---|---|---|
| day0 | 0.98 | 0.84 to 0.92 | 0.82 to 0.86 | 0.77 | 0.72 | 0.68 | 0.60 | 0.53 |
| day2 | 0.96 to 0.99 | 0.84 to 0.89 | 0.74 to 0.79 | 0.65 to 0.72 | 0.60 | 0.55 | 0.48 | 0.39 |
Expression of the TSS gene relative to cells with no gRNA of the target, median over 266 TSS positive-control pairs, in cells with no other gRNA of the same target; a range covers the finer bins inside a column.
The floor comes from the counts¶
dose_weights estimates the floor by default (floor="auto") with
estimate_dose_floor, which reads nothing but the count matrix. Entries of a
single UMI behave like chimeras: in Cell Ranger's molecule_info.h5 for three
channels of these screens, about 40% of their molecules carried one read,
against 10 to 20% for entries of 50 UMIs or more, and their spread over gRNAs
followed the ambient profile of empty droplets (Spearman 0.81 to 0.91) where
the 4 to 19 UMI entries did not (0.05 to 0.07). So for each count k the
estimator fits the entries with exactly k UMIs as a mixture of the
single-UMI entries' spread over gRNAs and the spread of entries with at least
50 UMIs, by maximum likelihood, and calls the noise share the weight of the
first. The floor is the largest k whose noise share is at least one half.
| count | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 |
|---|---|---|---|---|---|---|---|---|
| day0 noise share | 1.00 | 1.00 | 0.39 | 0.20 | 0.12 | 0.08 | 0.05 | 0.03 |
| day2 noise share | 1.00 | 1.00 | 0.77 | 0.50 | 0.36 | 0.26 | 0.14 | 0.08 |
On the cells that passed QC, that puts the floor at 2 on day0, in all of 100 bootstrap resamples of gRNAs, and at 4 on day2, where the share at 4 sits on the half (0.501) and the resamples split 53 to 47 between 3 and 4. Over every cell, QC removed ones included, day2's share at 4 is 0.491 and the floor 3. It takes under a second on either screen's whole count matrix.
The positive controls cannot do this. Fitting the ramp's floor and ceiling to their knockdown gave a floor of 1.64 on day0 with a bootstrap interval of 0.50 to 8.31, and the two halves of the controls disagreed (3.00 against 1.21): the cells at 2 to 5 UMIs are too few and their effect too small, and a log ramp lets the fitted floor trade against the slope. The floor matters little within that range, which is why a counts-only estimate is enough:
| floor, ceiling | day0 PC median z | day0 calibration KS | day0 cis down links | day2 PC median z | day2 calibration KS | day2 cis down links |
|---|---|---|---|---|---|---|
| fitted to the positive controls (day0 1.64, 5000; day2 1.83, 990) | -7.91 | 0.020 | 215 | -8.80 | 0.014 | 315 |
| 3, 500 | -7.79 | 0.021 | 214 | -8.78 | 0.017 | 314 |
| 5, 500 | -7.80 | 0.021 | 222 | -8.77 | 0.014 | 307 |
Same pairs, cutoff rule and calibration pairs as the table under "Measured on day0 and day2", but run before weights were drawn per stage, so the resampled weights, not the observed ones, differ from the shipped code's. Moving the floor from about 2 to 5 moves the down links by up to 8, in different directions on the two screens; the positive-control medians move by at most 0.12 and the calibration KS distance by at most 0.003.
Non-targeting gRNAs keep their own cells and weights. The calibration check regroups them, and a synthetic target's cell takes its largest weight over the group's gRNAs, the same rule as a target's largest count over its gRNAs.
Resampled weights follow the propensity¶
The CRT places cells in each resample from the fitted propensities exactly as sceptre does, with the same index draws. A placed cell then needs a weight, and it takes one drawn from the observed weights of the treated cells in its own propensity quartile. The dose depends on the covariates the propensity depends on (a deeper cell has more guide UMIs), so a weight drawn without regard to the propensity would give a resampled deep cell a typical cell's dose. Quartiles are a choice, not a tuned value.
Weights are drawn per stage, only for the resamples a pair reaches, from a
generator seeded by the stage's bounds and by one number taken from the
target's own stream after its index draws. A result therefore does not depend
on which pairs escalated first, on n_jobs, or on the process, and the index
draws stay sceptre's because nothing is taken from the stream before them.
Measured on day0 and day2¶
Two screens, the lab's threshold-20 assignment against the dose test with
dose_weights defaults (floor estimated from the counts, ceiling 500),
pysceptre on the full cis pair sets, BH at 10%; calibration on the same 5,000
negative-control pairs for both tests on a screen; specificity against the
same 100,000 randomly chosen trans pairs. Runtimes are the cis run, 8 jobs, on
an Apple M4 Max (14 cores, 36 GB, macOS, so threads), the two tests back to
back with nothing else running. "Down links above background" sums, over
run_specificity_check's distance bins, each bin's links times its above
share, so it counts down-regulated links only.
| day0, 0/1 | day0, dose | day2, 0/1 | day2, dose | |
|---|---|---|---|---|
| floor | 2 | 4 | ||
| calibration KS distance | 0.032 | 0.024 | 0.019 | 0.014 |
| calibration p < 0.05 | 6.1% | 5.9% | 5.5% | 5.3% |
| positive controls, median z | -7.39 | -7.74 | -8.28 | -8.77 |
| positive controls with larger |z| than 0/1 | 86% | 77% | ||
| cis links (down / up) | 261 (219 / 42) | 257 (218 / 39) | 384 (321 / 63) | 385 (320 / 65) |
| down links within 100 kb | 188 | 189 | 194 | 202 |
| trans background rate | 0.00084 | 0.00080 | 0.00131 | 0.00132 |
| down links above background | 194 | 193 | 236 | 240 |
| cis runtime | 136 s | 184 s | 133 s | 159 s |
The dose test is calibrated and stronger on positive controls, finds about as
many cis links, and its links stand above the trans background as well as the
0/1 test's do, with a background no higher. Positive controls sit far past any
cutoff, so a larger |z| there adds no pair. It costs 1.2 to 1.35 times the
0/1 test's runtime; drawing every resample's weights up front, before they were
drawn per stage, cost 1.7 to 2.7 times.
What the dose test does not establish¶
There is no external ground truth. The checks are the reductions to sceptre, the statistic against its dense formula, uniform p-values on synthetic null data whose weights follow a covariate, and the negative controls of two screens. The ramp's shape is a modelling choice backed by the dose response above, not a fitted model of it.