0.1.5
This release reworks the GLM solver. Estimates change for lowly-expressed
genes -- toward, not away from, the reference implementations -- so results
from 0.1.4 on covariate-adjusted or low-count genes will not reproduce
exactly. nb_glm_test keeps its own min_mu default of 0.5, so the
DESeq2-compatible path is unchanged.
-
A filtered gene is now reported the same way everywhere: untested.
nb_glm_testalready reported an excluded (gene, perturbation) pair as
NaNthroughout;t_testandwilcoxon_testdid not. Depending on
which of the three Wilcoxon paths ran, a gene dropped by
min_cells_expressedcame back withp = 1.0and a fold change of
exactly 0.0 -- "tested, no change", asserted about a gene nobody tested --
or with aNaNp-value beside that same 0.0, andt_testreturned a
NaNp-value beside a finite fold change and effect size. Worse, a gene
excluded for one perturbation but tested for another picked up that other
perturbation's defaultp = 1.0, so a result row depended on which
perturbations happened to be in the same run. All three Wilcoxon paths and
t_testnow writeNaNto every column derived from the comparison --
score,pvalue,pvalue_adj,u_statistic,logfoldchanges,
effect_size-- for every gene they did not test, and nothing is spelled
as 0.0 or 1.0 to mean "untested". Because aNaNp-value does not enter
the Benjamini-Hochberg denominator, adjusted p-values on the genes that
were tested get slightly smaller; that is the correct denominator.
nb_glm_testhad one such number left of its own: with
lfc_shrinkage_type="apeglm", an untested gene's standard error was
reported as the literal 1.0 that the per-gene shrinkage falls back to. It is
NaNthere now as well. -
ptsandpts_restsurvive the filters. The Wilcoxon paths zeroed
the fractions of expressing cells for excluded genes, which is exactly the
information needed to see that a gene absent from one arm was excluded at
all. They are descriptions of the data, not of the comparison, and are now
reported for every gene, ast_testandnb_glm_testalready did. -
t_testno longer folds untested cells into the last perturbation.
With an explicitperturbations=subset, every cell belonging to a
perturbation outside that subset was accumulated into the last tested
group's sums, means, variances and cell count, because the unselected
labels code to-1and-1indexes the last row. Testing one
perturbation out of three therefore compared control against that
perturbation plus both untested ones. Onlyt_testwas affected;
wilcoxon_testandnb_glm_testselect cells by label. -
Negative-binomial estimates are no longer biased on lowly-expressed
genes.min_muwas being applied not only as the floor on the fitted
mean that DESeq2 defines, but also as a clamp on the IRLS weights, on the
variance, and on several division guards. Clamping the weights inflates the
leverage of every cell whose fitted mean falls below the floor: at
mu = 0.1, alpha = 1the correct weight is 0.091 and the clamp made it
0.5. The clamped fit did not merely differ from the right answer, it solved
a different estimating equation: on genes below one count per cell it left a
score of 50-100 where the maximum likelihood estimate has a score of zero,
and roughly 40-100 units of excess deviance. The same fits now leave a score
of ~1e-6. (The score equation is used here rather than agreement with
another implementation because it needs no second implementation, and
cross-solver agreement is a poor instrument on sparse count data.)
min_munow floors the fitted mean and nothing else, and its default on
NBGLMBatchFitterandNBGLMFitteris 0.nb_glm_testkeeps its own
default of 0.5, so the DESeq2-compatible path is unchanged. -
(gene, perturbation) pairs with no counts in one arm are no longer given
an effect estimate. A log-link GLM estimates the effect as a difference of
log means, so a pair absent from one arm has no finite effect: the
likelihood has no interior maximum and the coefficient runs to the boundary.
What was reported there came from wherever the fitter stopped, not from the
data -- on one such gene the effect was -18.9 withmin_mu=0and -5.1 with
min_mu=0.5, and the Wald statistic moved from 0.08 to 32.6 on identical
counts, that is from "no evidence" to "wildly significant" purely as an
artefact of the mean floor. (The collapse atmin_mu=0is the
Hauck-Donner effect: as the coefficient diverges its standard error grows
faster still, so the Wald test loses power on the strongest possible
signal.) These pairs are now reported as untested --NaNeffect,
statistic and p-value -- exactly as genes with no counts anywhere already
were, and are excluded from the multiple-testing correction rather than
entering it with artefactual p-values. They are not reported as an effect
of zero, which would describe a completely silenced gene as unchanged; the
observation remains visible inptsandpts_rest. Shrinkage does not
reach them either -- an excluded pair is dropped before any fit, so it stays
NaNunderlfc_shrinkage_type="apeglm"too. Controlled by the new
nb_glm_testparametersmin_cells_ctrlandmin_cells_pert, both
defaulting to 1 -- symmetric, and the exact boundary between an effect that
exists and one that does not. They are separate because the informative
direction depends on the screen;
crispyx._statistics._nonestimable_glm_maskdocuments which asymmetry
suits CRISPRi and which suits CRISPRa. Set either to 0 to disable that side. -
Two standard-error bugs from the same cause.
NBGLMFitterfloored
standard errors atsqrt(min_mu), forcing every reported standard error
to at least 0.707 at the old default, and floored the Cook's-distance
leverage denominator atmin_mu. -
Wide designs are two orders of magnitude faster. The per-gene Hessian
was formed with a three-operandnumpy.einsum, which stops routing
through BLAS once its intermediate exceeds an internal budget and falls back
to a nested loop. Formed with a chunkedgemminstead,fit_batchon
1,000 cells and 500 genes went from 16.05 s to 0.15 s at design width 41 and
from 35.38 s to 0.23 s at width 61; below width 21 there is no material
difference. Results agree to 7e-12. -
New:
StructuredGLMBatchFitter,fit_glm_onehotand
detect_onehot_blockfor designs of the form[covariates | one-hot groups]-- a perturbation screen, or any design with a many-level
categorical covariate. The disjoint group supports make the per-gene Hessian
arrowhead-structured, so each Newton step goes through the Schur complement
of the diagonal block: exact (verified against a dense per-gene solve to
1.4e-15) and much cheaper. Measured against the dense fitter at 1,200 cells
and 800 genes: 14.2x at 31 covariates and 200 groups, 6.2x at 12 and 100,
3.3x at 2 and 50, 2.5x at 12 and 20. It also converges where the dense path
does not -- 744 of 800 genes against 31 of 800 on the widest design --
because a group carrying no counts has an unbounded coefficient that the
group ridge and clip make finite and defined. -
New:
family="poisson"onNBGLMBatchFitter, and
fixed_dispersiononfit_batch. A Poisson fit is now a genuine
Poisson fit rather than a negative-binomial fit with a small estimated
dispersion. -
nb_glm_testwith a many-level categorical covariate takes the
structured path. Abatch,donororlanecovariate is one-hot
encoded into the design, so this is the common case rather than an exotic
one. Routing is on the measured advantage -- the group columns must
outnumber the remaining covariates -- and the intercept and the
perturbation column are held out of the group block so that the coefficient
under test is never subject to the group clip. Log-fold-changes agree with
the dense path to 1e-3. -
The IRLS loop itself is sturdier. Newton steps that increase a gene's
deviance are shortened rather than accepted (see below); converged genes are
frozen and dropped from later iterations; the dispersion is estimated around
the IRLS rather than re-estimated inside every iteration, and the model is
refitted with it so the returned coefficients and dispersion describe the
same model; and the normal equations are solved in a unit-root-mean-square
column basis. Convergence now requires both the relative deviance change and
the largest coefficient change to fall belowtol. -
Step shortening does what it says, and the convergence flag means what it
says. Each retry interpolates between the current iterate and the full
Newton point, halving the distance, and a shortened step is taken only if it
improves on the deviance the iteration started from. Interpolating towards
the previously shortened point instead compounds the shortening -- the third
retry lands at2^-6of the step rather than2^-3-- so a gene needing
repeated damping stops moving and is then reported as converged because
nothing moved, at a point that is not a stationary point of the deviance;
de.pygates on that flag. A gene for which no step in the range improves
the deviance is now left where it was and reported as not converged, rather
than being moved uphill. The exception is a gene resting on themin_mu
floor: the floored cells' means no longer move with the coefficients, so
they carry no gradient while the normal equations still count them, and the
line search finding nothing to take describes the floor rather than the fit.
Such a gene is still reported. A gene that reaches theetadivergence
clips is not exempted, because there "not converged" is the useful answer. -
The Numba path for the intercept-plus-perturbation design fits at the
dispersion it reports. The kernel holds the dispersion fixed, and it was
being given a placeholder of 0.1 while the dispersion reported beside the
result came from a separate estimate made afterwards. On that design the
coefficients are the two group means whatever the dispersion is, but the
standard errors are not: measured on an overdispersed fixture the reported
standard errors were up to 3.9x too small, so every Wald statistic built on
them was up to 3.9x too large. The kernel is now run twice, as the
NumPy path already was -- once at the dispersion the warm-start means imply,
then at the dispersion the fitted means imply -- and its standard errors now
match a fit at the reported dispersion to 5e-15. -
The negative-binomial deviance no longer loses its value to
cancellation. It was formed as the difference of(y + r) log(y + r)
and(y + r) log(mu + r), each of orderr n log r. At thealpha
clip floorris 1e8, so for a near-Poisson gene the answer was the small
difference of two very large sums: the absolute error was 0.1 at 3,000 cells
and 182 at 100,000, against a deviance of ~1e5. That is far above the
1e-6relative tolerance the convergence test uses, so such a gene could
never converge and its damping was triggered by noise. Evaluated as
(y + r) log1p((mu - y) / (y + r))the same quantities are accurate to
7e-7 at 100,000 cells. -
ridge_penaltymeans the same thing whatever the design's column
scales. The normal equations are solved in a preconditioned basis, where a
ridge ofrpenalises the caller's coefficientjby
r * scale_j**2. The penalty is now divided by the column scale, so a
caller who setsridge_penaltydeliberately gets the penalty they asked
for. No effect at the 1e-6 default. -
StructuredGLMBatchFitter.fit_batchfits genes in batches, with a
gene_batch_sizeargument that matchesNBGLMBatchFitter's and
defaults to sizing the(n_samples, batch)work arrays for ~100 MB. A
Newton step holds a dozen of them, which at 3,000 cells and 8,563 genes was
around 2 GB. Batching changes nothing numerically beyond the summation order
BLAS chooses. The starting point also no longer depends on the design having
an exact column of ones: the constant column carries the starting predictor
andetais derived from the coefficients, so a design with no such
column starts from a point its coefficients describe. -
Less discarded work in the structured solver. The negative-binomial path
ran a full Poisson fit and an intermediate NB fit only for their fitted
means, and computed -- then threw away -- the per-gene standard errors of
both; neither now forms them.schur_solvere-formed the two
(n_genes, n_features, n_groups)arrays thatschur_complementhad
just built, and the IRLS loops copied the counts of the active gene set
twice per iteration, including on the first iteration where the active set
is every gene. -
Where the
min_mufloor binds, IRLS is run as the fixed-point iteration
it is. A cell held at the fitted-mean floor hasd mu / d beta = 0: it
contributes nothing to the gradient of the deviance while still contributing
its full weight toX'WX, so the Newton step solves one model while the
deviance measures another and can point uphill at a good fit. The line
search above is the wrong instrument for those genes, and no step length
repairs the direction. They now take the full Newton step and are judged by
DESeq2's own criterion,|dev - dev_old| / (|dev| + 0.1) < tol, paired
with the usual coefficient-change test -- which is what DESeq2, and
crispyx's own Numba kernel, have always done. A gene with floored cells that
is descending normally keeps the strict test; the floor only decides what a
failed step means. Measured against PyDESeq2, coefficients on
floor-binding genes go from 2.8e-02 away to under 1e-06, every gene
converges where one to four used to fall short, the 200-iteration cap stops
being reached, andmin_mu=0.5is no longer slower thanmin_mu=0.
On a covariate-adjusted run of a real screen (Adamson, 9,496 genes) this
returns 103 genes per perturbation that were being reported asNaN, at
3.4 s against 5.7 s. They are not marginal genes -- their medianpts
in the control arm is 0.69 against 0.30 for the genes already reported, and
44 of them reachpadj < 0.05, taking that comparison's hit list from
2,094 to 2,150. A gene that did not converge was previously given no result
at all --NaNeffect, statistic, p-value, log-fold-change and standard
error -- and was left out of the multiple-testing correction, so this is a
change to what is tested, not only to what is estimated. Genes reported
before are unchanged to within 2e-05 in log-fold-change, and their adjusted
p-values move by a median of -0.3% from the larger correction. -
min_muno longer reaches the reported standard errors. The floor
steadies the iteration; it is not part of the model whose uncertainty is
reported, and DESeq2 keeps it out --irls_solverreturns an
unthresholdedmuandwald_testrebuilds the weights from it.
crispyx built them from the floored mean, which overstates how much a
floored cell knows: against PyDESeq2'swald_teston identical
coefficients the standard errors on floor-binding genes were a median of
24% and at worst 35% too small, and the Wald statistics correspondingly too
large. Rebuilt from the unfloored mean they agree to 0.00%. The weights
inside the iteration and the leverage behind Cook's distance keep the floor,
as DESeq2 does. This affectsnb_glm_testwith covariates, the structured
solver and the fitter API; a two-group comparison without covariates already
recomputed its standard errors without the floor and is unchanged.End to end through
nb_glm_testthe effect is much smaller than those
figures suggest, because the dispersion is re-estimated after the fit and
absorbs most of the change: on the Adamson run above, p-values for genes
reported both before and after moved by a median factor of 1.0000 (10th to
90th percentile 0.97 to 1.02), 7 genes lost significance and 19 gained it.
The larger figures are what you see with the dispersion held fixed, which is
the right way to size the defect but not the change a user sees. -
ptsandpts_restare reported for every gene, not only for the
genes an effect was estimated for. They are descriptions of the data rather
than inferences from a fit, and they are what makes a pair whose effect is
not estimable -- a complete knockdown, expressed in 95% of control cells and
none of the perturbed ones -- visible in the output, which the filtering
documentation already said they were.