Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Validation

Every area of CytePool has a script in validation/ that compares it with an independent reference: the package that defined the method, run as a black box, or a high-precision computation. A script generates its inputs from fixed random seeds, runs the reference as a separate program on the same inputs, exchanging data through files in a temporary directory (float64 values as binary, so they compare bit for bit), runs CytePool, prints a table with one row per check, and exits with an error when any check falls outside its tolerance.

This page summarises the tables the scripts printed when they were run against the package in this revision of the repository: on 2026-10-26 for plotting.py; on 2026-10-25 for the six scripts on cubes and their files (cube.py, pool_cells.py, cube_files.py, cube_reads.py, cube_fingerprints.py and anndata_conversion.py), for linear_models.py, mixed_models.py, voom.py, empirical_bayes.py, sample_quality_weights.py, negative_binomial_fits.py, quasi_likelihood.py, deseq2_tests.py, differential_expression.py, differential_expression_options.py, dream.py, interferon_stimulated_pbmcs.py, gene_set_tests.py, rotation_gene_set_tests.py, composition_tests.py and meta_analysis.py, and for design.py, which printed the same table again after changes to the code it checks; and on 2026-10-24 for the others, with the reference versions given in each section. Every script exited without a failure. Each row below is a family of checks: it gives the number of printed checks it covers and the largest difference among them. Run a script to see every check.

Summary

ScriptAreaReferenceChecksOutcomesRun timePeak memory
cube.pyPseudobulk cubesscanpy 1.12.42019 pass, 1 information12.5 s926 MiB
pool_cells.pyPooling cells into cubesscanpy 1.12.4, decoupler 2.2.02727 pass30.2 s1,725 MiB
cube_files.pyCube fileszarr-python 3.4.177 pass3.1 s146 MiB
cube_reads.pyReads of selected featureszarr-python 3.4.155 pass1 min 46 s500 MiB
cube_fingerprints.pyCube fingerprintsthe documented encoding, with zarr-python 3.4.177 pass4.4 s122 MiB
anndata_conversion.pyAnnData and MuDatascanpy 1.12.4, decoupler 2.2.0, LIANA 1.10.0, anndata 0.13.4, mudata 0.4.11818 pass1 min 5 s1,483 MiB
normalization.pyNormalization and filteringedgeR 4.4.0, DESeq2 1.46.05046 pass, 3 deliberate differences, 1 control72.9 s745 MiB
design.pyDesigns and contrastsR 4.4.3, limma 3.62.1, lme4 2.0.62726 pass, 1 information4.3 s221 MiB
linear_models.pyLinear modelslimma 3.62.1218200 pass, 18 information4.5 s177 MiB
mixed_models.pyMixed modelsR 4.4.3, lme4 2.0.6, lmerTest 3.2.1135102 pass, 29 information, 4 deliberate differences5 min 28 s330 MiB
voom.pyPrecision weights for countslimma 3.62.1, edgeR 4.4.0118111 pass, 7 deliberate differences15.3 s310 MiB
sample_quality_weights.pySample quality weightslimma 3.62.1, edgeR 4.4.08983 pass, 6 deliberate differences12.1 s206 MiB
negative_binomial_fits.pyNegative binomial modelsedgeR 4.4.0, mpmath 1.3.012178 pass, 30 information, 13 deliberate differences14.2 s187 MiB
quasi_likelihood.pyQuasi-likelihood F-testsedgeR 4.4.0, limma 3.62.1, mpmath 1.3.0223210 pass, 13 deliberate differences5.1 s186 MiB
deseq2_tests.pyDESeq2’s dispersions and testsDESeq2 1.46.0, PyDESeq2 0.5.4, mpmath 1.3.04528 pass, 11 information, 6 deliberate differences2 min 16 s814 MiB
empirical_bayes.pyEmpirical Bayes moderationlimma 3.62.1150146 pass, 4 deliberate differences4.8 s195 MiB
gene_set_tests.pyGene-set testslimma 3.62.19379 pass, 8 deliberate differences, 6 information6.9 s278 MiB
rotation_gene_set_tests.pyRotation gene-set testslimma 3.62.110381 pass, 20 deliberate differences, 2 information20.9 s190 MiB
multiple_testing.pyMultiple testingR 4.4.31515 pass2 min 56 s202 MiB
differential_expression.pyDifferential expressionlimma 3.62.1, edgeR 4.4.03228 pass, 4 deliberate differences1.8 s189 MiB
differential_expression_options.pyOptions for pseudobulk differential expressionlimma 3.62.1, edgeR 4.4.06666 pass5.0 s182 MiB
dream.pydreamvariancePartition 1.36.2, limma 3.62.1, lme4 1.1-38, lmerTest 3.1-36353 pass, 5 information, 5 deliberate differences30.5 s390 MiB
interferon_stimulated_pbmcs.pyDifferential expression on published datalimma 3.62.1, edgeR 4.4.04117 pass, 24 information15.2 s617 MiB
composition_tests.pyCell-type compositionspeckle 1.6.0, limma 3.62.13225 pass, 7 deliberate differences13.1 s860 MiB
meta_analysis.pyMeta-analysismetafor 5.0.17552 pass, 20 information, 3 deliberate differences59.5 s256 MiB
plotting.pyFigureslimma 3.62.1, edgeR 4.4.0, DESeq2 1.46.0, metafor 5.0.1, speckle 1.6.02623 pass, 2 information, 1 deliberate difference25.4 s810 MiB
distributions.pyDistributions and special functionsmpmath 1.3.04646 pass6 min 39 s221 MiB
smoothing.pyLOWESS and spline basesR 4.4.39087 pass, 1 information, 2 deliberate differences1.5 s175 MiB
All1,9421,685 pass, none failed; 150 information, 106 deliberate differences, 1 control

Run time is the wall time of the whole script and peak memory the largest resident set of any of its processes, reference programs included, both measured with GNU time on the machine described under Timings. For interferon_stimulated_pbmcs.py, the run time is with its data downloaded already, and the peak memory is the largest that the script measured itself: it restarts the peak of its own process before it runs limma_voom, which hides the earlier, higher peak from GNU time.

How to read the tables

Each check is printed with its tolerance. Most tolerances follow three tiers:

TierApplies toBound
exactclosed-form resultsrelative difference at most 1e-10
closeiterative fitsrelative difference at most 1e-6
calibratedMonte Carlo methodswithin simulation error, with the same calls

Some checks are stricter, such as the special functions (1e-15 to 1e-12) and counts or labels that must be identical (tolerance 0), and some use a bound derived for their case: the variances from raw moments in cube.py, and the leverages of badly scaled designs (1e-8, absolute). Differences are relative unless a row says otherwise; scripts that compare statistics near zero scale them as their sections describe. Besides pass, a printed check can be:

Sections also list deliberate differences that passing checks confirm, such as formulas that CytePool rejects by design; the summary counts as deliberate differences only the checks that the scripts print as such. The calibrated checks are those of rotation_gene_set_tests.py, whose tests draw random rotations; the other methods implemented so far are deterministic.

Pseudobulk cubes

validation/cube.py builds a cube from 28,946 simulated cells x 2,000 genes (density 0.210, 8 samples x 10 cell types, with an empty sample x cell type entry and entries with one and two cells; seed 20261022) and compares its statistics, and those of Cube.regroup (10 to 4 groups) and Cube.collapse_samples (8 to 4 donors), with scanpy.get.aggregate run on the same cells. scanpy normalizes the cells itself (normalize_total, then log1p), which also checks the definitions of the normalized layers. Reference: scanpy 1.12.4, anndata 0.13.4, numpy 2.5.4, scipy 1.18.1, pandas 3.1.0, Python 3.13.12.

Check familyReferenceLargest differenceToleranceOutcome
Counts, detected cells, cells per entry and library sizes, for the cube, the regrouped and the collapsed cubesum, count_nonzero, n_obs_aggregated0 (absolute)03 pass
Means of log-normalized and of normalized valuesmean5.8e-151e-106 pass
Variances of log-normalized values (entries with two or more cells)var, dof=19.4e-131e-103 pass
Fractions of cells detecting each genecount_nonzero / n_obs_aggregated01e-103 pass
The empty and the single-cell entryno row in scanpy; variance NaNNaN where expectedn/a1 pass
Variance from raw moments at mean-to-SD ratios r of 10, 800 and 80,000exact rational arithmetic1e-14, 5.6e-11, 1.3e-61.4e-12, 9.1e-9, 9.1e-53 pass
The same at r = 8e6exact rational arithmetic0.014n/a1 information

The cube stores sums, so its variances come from raw moments, sum_sq - sum**2 / n. Their relative error is about eps (1 + r²) for float64 machine epsilon eps; the tolerance is a worst-case bound, 64 eps (1 + r²). At r = 8e6 that bound exceeds 0.5, so the last row only shows where raw moments stop being useful. Log-normalized expression has small r and stays in the exact tier.

Pooling cells into cubes

validation/pool_cells.py pools 29,068 simulated cells x 2,000 genes (12,097,859 stored counts, density 0.208, 8 samples x 10 cell types, cells in random order, with an empty entry, entries with one and two cells, and genes never detected; seed 20261023) with from_cells, once as integer counts stored as a float32 CSR matrix and once as non-integer counts, and compares the cube with scanpy.get.aggregate (after scanpy normalized the cells in float64) and with decoupler.pp.pseudobulk. Reference: scanpy 1.12.4 and decoupler 2.2.0, each with anndata 0.13.4, numpy 2.5.4, scipy 1.18.1, pandas 3.1.0, Python 3.13.12; CytePool with numpy 2.5.4 and numba 0.68.0.

Check familyReferenceLargest differenceToleranceOutcome
Data type of the counts layer: uint32 for whole sums below 2³², float64 for fractionsthe documented ruleuint32 and float64as the rule2 pass
Counts, detected cells and cells per entryscanpy sum, count_nonzero, n_obs_aggregated00 (1e-10 for non-integer sums)4 pass
Means and variances of log-normalized values, means of normalized valuesscanpy1.4e-111e-106 pass
The empty and the single-cell entryscanpyzero and NaN where expectedn/a2 pass
Sums, cells, library sizes and detected fractionsdecoupler pseudobulk4.1e-160 for sums and cells of integer counts, else 1e-106 pass
Detected fraction of the entry without cellsdecoupler (0 by convention)NaN in the cube, 0 in decouplern/a2 pass
Per-sample sums, and from_bulk of themdecoupler without groups002 pass
The same counts as an unsorted CSR matrix with repeated and zero entries, as a dense array, and pooled with one threadfrom_cells on the canonical CSR matrixidentical cubeidentical3 pass

For an entry without cells, the cube reports the fraction of cells detecting a gene as NaN (zero cells out of zero), where decoupler reports 0. The script also times from_cells; see Timings.

Cube files

validation/cube_files.py checks the Zarr v3 cube files against zarr-python, an independent implementation. Reference: zarr-python 3.4.1, numcodecs 0.17.1, numpy 2.5.4, Python 3.13.12. CytePool ran on Python 3.14.4 with numpy 2.5.4, so that the zstd cases ran too: CytePool reads and writes zstd with the compression.zstd module of Python 3.14. On Python 3.13 the script skips those cases.

Check familyReferenceLargest differenceToleranceOutcome
zarr-python opens cubes CytePool wrote (5,000 x 12 x 6, 2,000 x 40 x 1, 0 x 0 x 2 and 7 x 4 x 3 genes x samples x groups; gzip, uncompressed and zstd) and finds the same tables, cell numbers, layers and attributeszarr-python0 values and attributes differ04 pass
CytePool reads 84 arrays zarr-python wrote: every core data type, partial edge chunks, both byte orders, gzip, zstd, gzip then zstd and no compression, and the default (with either separator) and v2 chunk key encodingszarr-python0 elements differ01 pass
Chunks of those arrays left unstored because they equal the fill valuezarr-python295 of 840 chunks read as the fill valuemore than 01 pass
read_cube of copies that zarr-python made node by node, with other chunks, big-endian bytes and gzip then zstdzarr-python0 values differ01 pass

Reads of selected features

validation/cube_reads.py checks that reads of a selection take only what they need and return what zarr-python reads. zarr-python writes an int32 array of 3 x 5 x 11 in chunks of 2 x 2 x 4 (gzip, fill value 7, one chunk left unstored), and CytePool writes a cube file of 20 groups x 100 samples x 30,000 genes, with uint32 counts and float64 log_sum (gzip level 4, seed 20261024). zarr-python’s Array.oindex then reads 7 selections of each array and 6 selections of the cube: 50 neighbouring genes, 50 and 2,000 genes at random, 50 genes out of order in 2 groups, 10 samples of every gene, and one whole group. Reference: zarr-python 3.4.1, numpy 2.5.4, Python 3.13.12.

Check familyReferenceLargest differenceToleranceOutcome
ZarrArray.oindex, 21 selections of the small array and of the two layers of the cube file: positions in any order, repeated and negative, masks, slices, integers, an empty selection, and the chunk that is not storedzarr-python Array.oindex0 elements differ01 pass
Every layer of the 6 selections of the cube, read with Cube.select from the cube read_cube openedzarr-python Array.oindex0 elements differ01 pass
Chunk files each selection opensthe chunks that hold a selected feature of a selected group0 selections open other files01 pass
Merged groups and pooled samples of the 50 genes at random: counts, and log_sumsums of what zarr-python read0 and 4.3e-160 and 1e-122 pass

Every chunk that holds a selected element is opened once, and no other: 40 chunk files for 50 neighbouring genes of the 20 groups, where reading whole groups opens all 320. The script also measures each read; see Timings.

Cube fingerprints

No other tool computes the fingerprint of a cube, so validation/cube_fingerprints.py checks it against a second implementation, written from the encoding that cytepool.cube.fingerprints documents: a separate program reads cube files with zarr-python and recomputes every digest and the fingerprint with numpy and hashlib, without importing CytePool. Its encoder differs from CytePool’s where it can: it finds NaN with numpy.isnan instead of comparing bits, splits complex values with .real and .imag, and decodes text columns from their codes and levels. The cubes (seed 20261025) have a table column of every type a cube’s tables hold (text, bool, int8 to int64, uint8 to uint64, float16 to float64, complex64, complex128, datetime and timedelta), missing text, NaT, NaN, complex values with one NaN part, -0.0 in a table and in log_sum, a uint64 column beyond the int64 range, a normalization holding every kind of JSON value, uint16 and int16 layers, a group without cells, a bulk cube without cell numbers or normalization, and a cube with empty axes. Reference: zarr-python 3.4.1, numpy 2.5.4, Python 3.13.12.

Check familyReferenceLargest differenceToleranceOutcome
Digests write_cube stores with 5 cube files (3,000 x 12 x 6 genes x samples x groups and smaller), 74 partsrecomputed from the documentation0 parts differ01 pass
Cube.fingerprint() of the 5 cubes in memoryrecomputed from the documentation0 fingerprints differ01 pass
Copies zarr-python made with other chunks, big-endian bytes, gzip level 1 or no compression: recomputed, and checked by verify_fingerprint()the original fingerprint0 fingerprints differ01 pass
The 3,000-gene cube with int64 layers instead of uint16 and int16the fingerprint of the narrow cube0 fingerprints differ01 pass
A count, a log_sum value moved by one unit in the last place, a sample id, a gene symbol, a cell number and the target sum, each changed in a copyrecomputed from the documentation6 of 6 change the fingerprintall1 pass
-0.0 for 0.0, and NaN with another payload, in sample columnsrecomputed from the documentation0 of 2 change itnone1 pass
CytePool on those 8 copies: read_cube refuses the changed tables, cell numbers and normalization, each with the message for that part; a changed layer group raises when it is read, whole or with its samples pooled, and in verify_fingerprint(), while the other groups read; and the 2 equal copies read and verifythe changes made8 of 8 as expectedall1 pass

Before this run, the script was run against CytePool with five deliberate mistakes: -0.0 left as it is, integer layers hashed in their own width, the lengths of text written after the text, every float of the normalization hashed as a float64, and reads of whole groups in another order (pooled samples) left unchecked. The first four each made five checks fail, and the last one the check of the changed files.

AnnData and MuData

validation/anndata_conversion.py simulates 18,914 cells x 1,500 genes (8 samples x 10 cell types, with an empty sample x cell type entry, entries with one and two cells, and genes never detected; seed 20261025), with a condition for each sample and a lineage for each cell type, and runs scanpy, decoupler and LIANA on them, each in its own environment. CytePool imports what each tool made and compares the cube with the one from_cells builds from the same cells; it then exports cubes and reads them back. Reference: scanpy 1.12.4 and decoupler 2.2.0, each with anndata 0.13.4 and pandas 3.1.0; LIANA 1.10.0 with decoupler 2.2.0, mudata 0.4.1, anndata 0.13.4 and pandas 2.3.3 (LIANA requires pandas 2); Python 3.13.12. CytePool ran with numpy 2.5.4, numba 0.68.0, anndata 0.13.4, mudata 0.4.1 and pandas 3.1.0.

Check familyReferenceLargest differenceToleranceOutcome
from_anndata of scanpy.get.aggregate (sum and count_nonzero of the counts): counts, detected cells, cell numbers, and the fingerprintfrom_cells0; identical fingerprint02 pass
The same with scanpy’s sums of log-normalized values, of their squares and of normalized valuesfrom_cells6.4e-161e-101 pass
from_anndata of decoupler.pp.pseudobulk: counts, detected cells from its fractions times its cell numbers, cell numbers, and the condition and lineage read from its obs; and after decoupler.pp.filter_by_expr kept 1,355 of the 1,500 genesfrom_cells, the same genes, and the simulated metadata0; identical tables and fingerprint03 pass
from_mudata of LIANA’s adata_to_views, with the cells’ crosstab as cell numbers: counts, detected cells, cell numbers, and the fingerprintfrom_cells0; identical fingerprint02 pass
scanpy’s means and variances; decoupler with mode="mean" and mode="median"; LIANA’s views with mode="median", and without cell numbersrefused, each with its reason5 of 5 refusedall5 pass
to_anndata against scanpy’s rows and sums, to_mudata against LIANA’s modalities, and scanpy aggregating the export by condition against Cube.collapse_samplesscanpy, LIANA0; identical03 pass
Round trips of 4 cubes (every layer; merged, pooled and selected; bulk; tables of every column type, with columns without any value, names anndata does not write as they are, and a sample and a group without cells) through AnnData and MuData, in memory and through .h5ad, .zarr and .h5mu filesthe exported cube24 of 24 fingerprints keptall1 pass
The same files, read and written again by anndata and mudata under pandas 2the exported cube12 of 12 fingerprints keptall1 pass

A cube’s layers are sums, so an import takes only what is a sum or gives one exactly. Integer layers match exactly: scanpy and decoupler store sums of whole numbers as float64, which CytePool turns back into the integers that from_cells gives, and decoupler’s fractions of detecting cells times its cell numbers are whole numbers to within the precision of the fractions, so rounding recovers the counts exactly. Means, variances and medians are refused; every tool that gives them gives sums too. The refusals do not depend on the tool saying what it computed, which neither decoupler nor LIANA records: decoupler computes psbulk_counts from the summed counts whatever its mode (found by probing), so means add up to it divided by the cells of their row, and a sum is positive exactly where a cell detects the feature, which medians are not. X may add up to less than psbulk_counts, as after filter_by_expr. LIANA 1.10 keeps cell numbers only under the column name of decoupler 1, so its views carry none, and an entry without cells cannot be told from a missing profile: the import refuses them until cell numbers are given. decoupler gives its row without cells the metadata of another row (found by probing); CytePool reads a column as sample or cell-type metadata from the rows with cells.

Normalization and filtering

validation/normalization.py compares cytepool.normalization with edgeR and DESeq2 on eight count matrices from fixed seeds, of 6 to 40 samples and 200 to 2,000 features (dense, sparse, very sparse with most upper quartiles zero, unequal depths, many ties, large groups, and two of non-integer counts), on 300 small matrices with many ties for TMM’s default reference sample and its trimming boundaries, 100 of them chosen because a rule that rounds differently picks another reference, and on hand-built edge cases. Reference: R 4.4.3, edgeR 4.4.0, DESeq2 1.46.0; numpy 2.5.4.

Check familyReferenceLargest differenceToleranceOutcome
TMM factors: 32 option sets, and the default and unweighted reference on the 300 small matricesedgeR calcNormFactors0; 6,738 of 6,738 values identical1e-103 pass
Upper-quartile factors, p = 0.75 and 0.9, and on the 300 small matricesedgeR0; all 1,505 values identical, and 29 cases undefined in both1e-103 pass
RLE factors, with and without given library sizesedgeR0; all 204 values identical, and 2 cases undefined in both1e-102 pass
DESeq2 size factors, ratio and poscounts, and poscounts on the 300 small matricesDESeq2 estimateSizeFactors0; all 1,629 values identical, and 1 case undefined in both1e-103 pass
poscounts factors before their scaling to geometric mean oneDESeq2 estimateSizeFactorsForMatrix1e-15 (spread of the ratio)1e-101 pass
CPM, with normalization factors and with given library sizesedgeR cpm0; 670,800 of 670,800 identical1e-103 pass
log2 CPM with prior counts 0.5, 2 and 5, with normalization factors and with given library sizesedgeR cpm(log = TRUE)0 (absolute); 1,118,000 of 1,118,000 identical1e-105 pass
filterByExpr by group, with other cut-offs, with designs with and without an intercept, without group or design, and with normalization factorsedgeR filterByExpr0 of 88,200 features differ07 pass
Edge cases of TMM and the upper quartile: empty trimmed set, no shared features, one sample, identical profiles, ties at the trimming and A cut-offs, the rules for the default referenceedgeR0; 25 of 25 identical1e-1011 pass
Edge cases of filterByExpr: counts at the CPM cut-off, all-zero and nearly collinear designs, sample thresholds a rounding error apartedgeR0 of 23 features differ07 pass
Control: four rules for TMM’s default reference that differ from edgeR’s only in the last bitsedgeR0.97; factors change in 105 of the 300 small matricesmust exceed 1e-101 control
A negative countedgeR rejects it tooboth raisen/a1 pass
Deliberate differences, listed belowedgeRCytePool raises, edgeR returns valuesn/a3 deliberate differences

The functions repeat R’s floating-point operations wherever an operation decides a discrete outcome, such as which sample is TMM’s reference or which genes a cut-off keeps, so factors and filters agree to the last bit. The control shows that the test matrices are sensitive to such details: rules that are equal in exact arithmetic but round differently change the factors of 105 matrices. A case is undefined in both when edgeR or DESeq2 returns NaN factors and CytePool raises an error, as for an upper quartile of zero. Deliberate differences:

Designs and contrasts

validation/design.py compares cytepool.design with R, limma and lme4 on a table of 24 samples: factors with 2 to 4 levels, including mixed-case labels, labels with spaces and a non-syntactic column name (cell type), a boolean, covariates that are multiples of 1/16, an integer covariate, and 10 badly scaled or nearly collinear covariates; seed 20261023. Reference: R 4.4.3, limma 3.62.1, lme4 2.0.6; R’s session collation C.UTF-8.

Check familyReferenceLargest differenceToleranceOutcome
Model matrices of main effects, numeric covariates, factor:factor, factor:numeric and numeric:numeric interactions, no intercept, nesting (/, %in%), powers, - removal, booleans, missing margins and badly scaled covariates, each under 3 level ordersR model.matrix342 of 342 identical in column names, assign, term labels and every entryall identical12 pass
300 random formulas from the full grammarR model.matrix300 of 300 identicalall identical1 pass
Default level order, code points, without levels givenR with LC_COLLATE=C4 of 4 identicalall identical1 pass
The same against R’s default order in its session localeR1 of 4 identicaln/a1 information
Formulas R also rejects, and formulas CytePool rejects by designR model.matrix6 of 6 and 8 of 8 rejectedall rejected2 pass
terms() on 282 grammar cases without data (35 rejected by design, 11 by both)R terms236 of 236 identicalall identical1 pass
Rank of 646 matrices, and the aliased columns of the 346 that are rank-deficientR qr() (tolerance 1e-7, pivoting as lm.fit does)646 of 646 and 346 of 346 identicalall identical2 pass
Leverages of 604 matrices, and of 42 with badly scaled covariatesR rowSums(qr.Q(qr(X))[, 1:rank]^2)1e-13 and 6.3e-10 (absolute)1e-10 and 1e-82 pass
Estimability of 1,038 contrasts on the 346 rank-deficient designs: X'u estimable, random and aliased-column contrasts notcontrasts drawn by R1,038 of 1,038 rightall right1 pass
Random-effect terms of 21 specifications: grouping factors and effect columnslme4 lFormula21 of 21 identicalall identical1 pass
make_contrasts on 13 linear expressions, and their nameslimma makeContrasts0; names identical1e-102 pass
Expressions that are not linear: a constant, a product, a ratio, a powerlimma makeContrasts4 of 4 rejectedall rejected1 pass

Deliberate differences:

Linear models

validation/linear_models.py compares fit_linear_model with limma’s lmFit and contrasts_fit with contrasts.fit on 15 cases of 2,000 genes and 6 to 20 samples from fixed seeds: two groups; groups with a covariate; covariates on a large scale; sample weights; observation weights; group means with observation weights; about 5% missing values, with and without sample weights, including genes that lose rank; a block correlation, alone and with sample weights, observation weights or missing values; zero observation weights; a zero sample weight; and large-scale covariates with weights and missing values. Reference: R 4.4.3, limma 3.62.1.

Check familyReferenceLargest differenceToleranceOutcome
Coefficients, relative to the larger of their absolute value and standard errorlmFit9.7e-131e-1015 pass
stdev.unscaled, sigma and AmeanlmFit2.1e-131e-1045 pass
df.residuallmFit0 genes differ015 pass
cov.coefficients, relative to products of standard deviationslmFit1.8e-141e-1015 pass
contrasts_fit(method="limma"): coefficients, unscaled standard errors and covariancecontrasts.fit9.7e-131e-1045 pass
Named contrasts whose coefficients are listed in another order (a Contrasts over the reversed columns): coefficients and unscaled standard errorscontrasts.fit of makeContrasts in limma’s order9.7e-131e-1030 pass
contrasts_fit(method="exact"): coefficients and unscaled standard errorslmFit on a design whose first coefficient is the contrast1.3e-121e-1030 pass
Exact contrasts that are finite although not estimablelmFit with the columns reversed0 entries05 pass
Coefficients limma reports although the data do not determine them, and estimable contrasts of such coefficients, which stay NaNlmFitup to 6 and 2 entries in a casen/a10 information
limma’s contrast standard errors over the exact onescontrasts.fit against a refit0.56 to 2.55; up to 46% of entries off by more than 5%n/a8 information

Deliberate differences:

With --benchmark, the script times both packages on 20,000 genes x 200 samples; see Timings.

Mixed models

validation/mixed_models.py compares fit_mixed_model with lme4’s lmer (REML) and fixed_effect_tests with lmerTest’s Satterthwaite t-tests (summary for every coefficient, contest1D for a contrast), gene by gene, in 9 cases from fixed seeds: one random intercept (8 donors x 3 samples, a condition within donors and an age per donor); unbalanced groups (12 donors with 1 to 6 samples); two crossed random intercepts (10 donors x 4 samples over 5 batches); two nested random intercepts ((1 | donor/sample), 6 donors x 3 samples x 2 replicates, with sample labels that repeat across donors); a design simulated without donor variance, so that most fits lie on the boundary; observation weights like voom’s, with one and with two random intercepts; the crossed design with random intercepts up to 30 times the residual standard deviation; and 2,000 genes of 16 donors x 4 samples with observation weights. The other cases have 100 genes each. The standard deviation of each random intercept is drawn per gene, and is zero for a fifth of the genes. Reference: R 4.4.3, lme4 2.0.6, lmerTest 3.2.1.

Check familyReferenceLargest differenceToleranceOutcome
REML criterion at the optimumlme4 REMLcrit3.1e-131e-109 pass
Genes whose criterion is above lme4’s by more than 1e-10, relativelme4 REMLcrit0 genes09 pass
Coefficients, relative to the larger of their absolute value and standard error, and standard errorslme4 fixef, vcov2.3e-71e-68 pass
Variance components, relative to the larger of themselves and the residual variance, and the residual variancelme4 VarCorr, sigma1.7e-71e-68 pass
The same two families where the variance ratios are largelme4 fixef, vcov, VarCorr, sigma1.1e-6 and 2.3e-61e-52 pass
The theta of the gene with large ratios that differs most from lme4the optimum with 30 digits (mpmath)3.4e-131e-101 pass
Genes where lme4 puts a variance at zero and CytePool does notlme4 getME(fit, "theta")0 genes09 pass
REML criterion at CytePool’s estimateslme4 REMLcrit3.6e-131e-109 pass
Coefficients, standard errors and t-statistics (relative to max(|t|, 1)) at CytePool’s estimateslme4 fixef, vcov; lmerTest summary1.6e-111e-109 pass
Satterthwaite df at CytePool’s estimateslmerTest summary1.3e-61e-59 pass
p-values at CytePool’s estimateslmerTest summary7.3e-71e-59 pass
A contrast: estimate, standard error and t-statisticlmerTest contest1D1.6e-111e-109 pass
A contrast: df and p-valuelmerTest contest1D6.6e-71e-59 pass
df of the genes that differ most from lmerTest, with one and with two random interceptsthe definition with 30 digits (mpmath)9.7e-16 and 2.2e-111e-102 pass
Fits with a variance at zero; Newton iterations; lme4’s largest theta where CytePool’s variance is zeroCytePool; lme4 getME12% to 54% of the genes of a case; 3.4 to 6.6 on average, at most 9; 4.4e-7n/a9 information
Satterthwaite df at lme4’s own optimumlmerTest summary3.5e-6n/a9 information
lme4 with its default tolerances: variance components; dflme4 VarCorr; lmerTest summary1.0e-3; 8.5e-4n/a8 information
lmerTest’s df and lme4’s theta at the genes checked with 30 digitsthe definition with 30 digits (mpmath)1.3e-6 and 4.9e-7; 1.3e-6n/a3 information
Deliberate differences, listed belowlme4 lmer3 inputs lme4 fits and CytePool refuses, 1 that lme4 fits with a warning and CytePool leaves unfittedn/a4 deliberate differences

The script keeps two questions apart. The optimum is compared with lme4 fitted from its own start, with the tolerances of its default optimiser (BOBYQA from nloptr) tightened to xtol_abs = xtol_rel = 1e-14 and ftol_abs = ftol_rel = 1e-16; with xtol_abs = 1e-12 and ftol_abs = 1e-14 alone, some of lme4’s fits stopped with a criterion 3e-7 above the minimum. The computations at given estimates are compared with lme4 and lmerTest evaluated once at CytePool’s estimates, which the script passes as lme4’s starting values with a single evaluation allowed (maxeval = 1, and restart_edge = FALSE so that a variance of zero stays zero). There the criterion, the coefficients, their standard errors and the t-statistics are closed forms, while lmerTest computes Satterthwaite’s degrees of freedom from numerical derivatives. p-values are compared scaled by max(p, 1e-300) max(1, |log p|), as for voom.

Two bounds are looser than the tiers. The REML deviance computed with 30 significant digits (mpmath, from dense matrices) shows which program is exact in each:

The information rows count the fits with a variance at zero, which fit_mixed_model reports in boundary and lme4 approaches without always reaching (its theta there was at most 4.4e-7), and the Newton iterations per fit. They also give the df at lme4’s own optimum, which inherit its precision where a variance is small, and the fits of lme4 with its default tolerances, whose variance components were up to 1.0e-3 of the residual variance from the optimum and whose df up to 8.5e-4 from those at the optimum.

Deliberate differences:

With --benchmark, the script times both packages on 20,000 genes x 120 samples; see Timings.

Precision weights for counts

validation/voom.py compares voom with limma’s voom on negative binomial counts from fixed seeds, 2 to 20,000 genes and 4 to 40 samples, in 26 cases: two groups, four groups with a covariate, group means, a covariate without an intercept, one residual degree of freedom, normalization factors (edgeR’s calcNormFactors in R, tmm_factors here) with and without given library sizes, two very small libraries, libraries from 1e3 to 1e8 reads, an intercept-only design, genes with the same counts and blocks of genes with a single read (ties in the trend), rarefied counts, non-integer counts, spans of 0.3 and 1, a design without replication and two genes. These 19 cases fix the span (0.5 unless named), as limma’s adaptive.span = FALSE does. In the other 7, both programs choose it from the number of genes, as adaptive.span = TRUE does (limma’s default since 3.66, and CytePool’s): 2, 30, 51 and 120 genes, about a fifth of the last three without counts, the group means and four-group cases (10,000 and 20,000 genes), and the design without replication. Designs come from the same sample table through R’s model.matrix and Design, contrasts through makeContrasts and named contrasts. The span rule, choose_lowess_span, is compared with limma’s chooseLowessSpan at 466 settings: limma’s own (small.n = 50, min.span = 0.3, power = 1/3) for 0 to 1e9 points, and 216 others with small.n of 0, 1 and 10, min.span from 0.05 to 1 and power from 0 to 1. Reference: R 4.4.3, limma 3.62.1, edgeR 4.4.0.

Check familyReferenceLargest differenceToleranceOutcome
log2 CPM E, and library sizes times normalization factorsvoom01e-1026 pass
Span chosen from the number of genes, which limma returns with adaptive.span = TRUEvoom01e-107 pass
Weightsvoom5.2e-131e-1025 pass
Trend: the points and LOWESS line saved with save.plot = TRUEvoom3.7e-141e-1023 pass
Moderated t and p after lmFit with the weights, contrasts.fit and eBayeslimma5.0e-131e-1023 pass
Rarefied counts: weights, trend, moderated t and plimma2.2e-8, 8.5e-8, 8.9e-91e-63 pass
Designs without replication: the warning, their weights being compared abovevoomboth warnn/a2 pass
Span rule: limma’s settings for 0 to 1e9 points, and the other settingschooseLowessSpan01e-102 pass
Deliberate differences, listed belowvoom, chooseLowessSpan4 where limma returns values and CytePool raises, 1 where limma fails and CytePool returns values; 2 for the span rulen/a7 deliberate differences

Differences are relative, except for E, the trend’s x and t, which are scaled by max(|reference|, 1); the trend’s y, scaled by max(|reference|, median of the reference), because near zero both are rounding noise; and p-values, scaled by max(p, 1e-300) max(1, |log p|). E was identical to limma’s to the last bit in 26 of 26 cases, and so were the trend’s x in 24 of 24, the chosen span in the 6 cases where limma returns one (without replication it returns none, and CytePool’s is NaN), and the span rule at all 466 settings. limma’s voom chooses the span from every gene, those without counts included (nrow(counts) on its help page): in the case of 51 genes, of which 38 enter the trend, both choose 0.9954, where 38 genes would give 1. Rarefied counts have equal library sizes, so a gene with the same count in every sample has a residual standard deviation that is pure rounding noise in either program; those points of the trend, and through them the weights, differ in the close tier. Deliberate differences:

Sample quality weights

validation/sample_quality_weights.py compares sample_quality_weights with limma’s arrayWeights, voom with prior weights with limma’s voom(weights = ...), and voom_with_quality_weights with voomWithQualityWeights, on data simulated from fixed seeds, in 37 cases. The sample weights are estimated from normal log-expression values with one or two samples two to four times noisier, and from voom’s log2 CPM with its weights: 4 to 12 samples and 2,000 or 20,000 genes; two groups, an intercept, and three groups with a covariate; REML and the gene-by-gene algorithm; a weight per sample, per group (var.group) and from a covariate (var.design); prior.n of 0, 10 and 50; observation weights; missing values, with genes left with one residual degree of freedom or none of residual variance; data on which limma’s REML stops early; one gene, and no replication. voom gets sample, observation and feature weights, observation weights with zeros, and a span chosen from the number of genes. voom with quality weights runs both algorithms, with the span fixed and chosen, weights per group and from log library size, TMM factors with given library sizes and contrasts, two noisy pseudobulk samples among 20,000 genes, and no replication. Designs come from the same sample table through R’s model.matrix and Design, contrasts through makeContrasts and named contrasts. Reference: R 4.4.3, limma 3.62.1, edgeR 4.4.0.

Check familyReferenceLargest differenceToleranceOutcome
Sample weightsarrayWeights8.3e-151e-624 pass
Reports: the genes with missing values that REML leaves out (the same number), and an early stop of REMLarrayWeightsboth reportn/a2 pass
voom with prior weights: Evoom01e-105 pass
voom with prior weights: the weights times the prior weights, the trend saved with save.plot = TRUE, and the chosen spanvoom1.2e-131e-1011 pass
voom with prior weights: moderated t and p after lmFit with the weights and eBayeslimma3.2e-131e-105 pass
voom with quality weights: E and the chosen spanvoomWithQualityWeights01e-1011 pass
voom with quality weights: sample weightsvoomWithQualityWeights7.2e-151e-68 pass
voom with quality weights: weightsvoomWithQualityWeights9.8e-141e-68 pass
voom with quality weights: moderated t and p after lmFit, contrasts.fit and eBayeslimma1.1e-131e-67 pass
No replication: both report itvoomWithQualityWeightsbothn/a1 pass
A gene whose prior weights are all zero: the weights of the other genes against limma’s without that genevoom3.0e-141e-101 pass
Deliberate differences, listed belowarrayWeights, voom3 where limma returns values and CytePool raises, 1 where limma fails and CytePool returns values, 2 where both return values that differn/a6 deliberate differences

Differences are relative, except for E, the trend’s x and t, scaled by max(|reference|, 1); the trend’s y, scaled by max(|reference|, its median); and p-values, scaled by max(p, 1e-300) max(1, |log p|). The sample weights are iterative estimates, in the close tier, as is everything computed from them. They agree to rounding because CytePool repeats limma’s iterations and stopping rule, found by running limma as a black box: the starting point, the prior’s terms in the score and the information, the exact REML information of Smyth (2002), and the criterion that stops the iterations, including the early stop where it rises. The weights that limma’s voom returns with prior weights are the trend’s alone; CytePool’s are those times the prior weights, which the comparison multiplies in. eBayes runs with its default estimator of the prior, which CytePool’s default follows: where zero weights make the residual degrees of freedom unequal, that is the likelihood estimator. Deliberate differences:

Negative binomial models

validation/negative_binomial_fits.py compares fit_negative_binomial_model with edgeR’s glmFit, negative_binomial_deviance with nbinomDeviance, and negative_binomial_unit_deviance with nbinomUnitDeviance and with mpmath, on negative binomial counts simulated from fixed seeds: 2,000 genes and 6 to 16 samples in 15 cases, with genes without counts and groups whose counts are all zero planted in most. Six are one-way layouts (two groups, group means with TMM factors, an intercept alone, one coefficient per sample, sample weights, and dispersions, offsets and weights per observation), which edgeR fits group by group to its last bits. Nine are other designs: groups and a covariate with trended dispersions, a paired design with TMM factors, dispersions, offsets and weights per observation, sample weights, the Poisson model, sparse counts of about one read per gene and sample, a covariate such as a date next to an intercept, means near 1e8, and a dispersion of 2e-5. edgeR’s fits of these stop short of the maximum, so they are compared with the maximum that Newton’s method with the observed information, written in R for the script, reaches from edgeR’s fit, and with mglmLevenberg(tol = 1e-14), a check where it converges and information where it does not. The unit deviance is compared at 5,373 points, counts and means from 1e-30 to 1e30 and dispersions from 0 to 1e12, with mpmath at 150 digits. Reference: R 4.4.3, edgeR 4.4.0, mpmath 1.3.0.

Check familyReferenceLargest differenceToleranceOutcome
One-way layouts: coefficients and fitted values, shrunk coefficientsglmFit3.0e-111e-612 pass
One-way layouts: deviance, and nbinomDeviance at edgeR’s fitted valuesglmFit, nbinomDeviance2.3e-71e-66 pass
Residual df, and fits that did not convergeglmFit0015 pass
Other designs: coefficients and fitted valuesNewton’s method in R1.8e-71e-69 pass
Other designs where edgeR converges: coefficients and fitted values, shrunk coefficientsmglmLevenberg(tol = 1e-14)3.1e-71e-68 pass
Other designs: deviance at the maximum, and at edgeR’s fitted valuesnbinomDeviance3.2e-71e-68 pass
Genes whose maximum lies at infinity: fitted values that tend to zero, below 1e-6 in both; the others and the deviancemglmLevenberg(tol = 1e-14)3.9e-7, 8.5e-71e-615 pass
Unit deviancempmath; nbinomUnitDeviance where edgeR is accurate7.8e-15; 3.0e-71e-13; 1e-62 pass
Refused inputs: an aliased column, a zero weight, a negative dispersionglmFitboth raisen/a3 pass
Where edgeR stops short: its distance from the maximumglmFit, mglmLevenbergup to 1.6 for glmFit (a date), 2.3e-3 for mglmLevenbergn/a19 information
glmFit deviances that are not those of its own fitted valuesglmFit, nbinomDeviance5 of 2,000 genes, with dispersions and weights per observationn/a9 information
Seconds for 2,000 genes, group means and a paired designglmFitCytePool 0.007 and 0.059, edgeR 0.008 and 0.062n/a2 information
Deliberate differences, listed belowglmFit, nbinomDeviance, nbinomUnitDeviance9 cases of genes without counts; 2 rows for a dispersion of 2e-5; 2 for edgeR’s unit deviancen/a13 deliberate differences

Coefficients are compared relative to max(|reference|, 1), fitted values relative to the reference, and deviances relative to the larger of the reference and the number of samples. A gene whose maximum lies at infinity has coefficients that depend on where each program stops, so for such genes only the fitted values that tend to zero (below 1e-6 in both programs), the others and the deviance are compared. edgeR’s Levenberg fits stop up to about 3e-4 from the maximum in glmFit (1e-2 with dispersions and weights per observation, and 1.6 with a date, where fitted values that should tend to zero stay near 1e2), and about 2e-7 in mglmLevenberg(tol = 1e-14), but 2e-3 with dispersions and weights per observation, 2e-6 with sparse counts, 1e-5 with a date or large counts, and 2e-3 with a dispersion of 2e-5. Deliberate differences:

Quasi-likelihood F-tests

validation/quasi_likelihood.py compares fit_quasi_likelihood and quasi_likelihood_f_test with edgeR’s glmQLFit and glmQLFTest (edgeR 4.4’s default, legacy = FALSE), common_dispersion with estimateGLMCommonDisp and dispCoxReid, average_log_cpm with aveLogCPM, and negative_binomial_unit_deviance_moments with sums at 30 digits in mpmath and the gamma limit. The counts are simulated from fixed seeds, with a dispersion for each gene, in 14 cases of 1,000 genes and 8 or 9 samples, with genes without counts and groups whose counts are all zero planted in most: two groups with TMM factors; three groups and a covariate with several contrasts and a joint test; a paired design; robust priors with and without genes that have no counts; small counts; a dispersion given below the true one, so that the average quasi-dispersion is well above 1; prior weights; trended dispersions; no trend; a trend in another covariate; offsets per observation; Poisson counts; and a test that both programs refuse. R runs with its address space capped at 8 GB. Reference: R 4.4.3, edgeR 4.4.0, limma 3.62.1, mpmath 1.3.0.

Check familyReferenceLargest differenceToleranceOutcome
Share of genes the dispersion comes fromglmQLFit009 pass
NB dispersionglmQLFit; dispCoxReid(tol = 1e-12) on the same genes8.7e-6; 8.9e-7 (6.5e-6 for designs that are not one-way layouts)1e-4; 1e-6 (1e-5)18 pass
Common dispersion, with prior weights and a systematic subsetestimateGLMCommonDisp; dispCoxReid(tol = 1e-12)8.1e-6; 4.6e-71e-4; 1e-65 pass
Average log CPM in four settingsaveLogCPM3.3e-121e-61 pass
Moments of the unit deviance at 41 points, and at a mean of 1e14mpmath at 30 digits; the gamma limit1.7e-141e-112 pass
Average quasi-dispersionglmQLFit2.1e-31e-214 pass
Adjusted deviances and residual dfglmQLFit3.3e-35e-314 pass
Prior and posterior quasi-dispersions; prior dfglmQLFit1.4e-3 where the average quasi-dispersions agree, 5.6e-3 where they differ; 9.8e-35e-3, 1e-2; 2e-226 pass
The prior and posterior that squeeze_variances gives edgeR’s own adjusted deviancesglmQLFit, squeezeVar4.7e-81e-613 pass
Leverages, those of fitted values that tend to zero includedglmQLFit2.8e-7 for one-way layouts at the same working dispersion; 8.7e-51e-6; 1e-314 pass
logCPMglmQLFTest2.8e-12 at a given dispersion; 2.7e-7 at an estimated one1e-6; 1e-516 pass
logFCglmQLFTest4.7e-7 for one-way layouts at the same working dispersion; 2.0e-41e-6; 1e-316 pass
Likelihood ratio statistic (F times the posterior quasi-dispersion and its df)glmQLFTest8.5e-10 at the same dispersion; 8.9e-6 at dispersions estimated by both; 4.5e-3 where the averages differ1e-6; 1e-4; 1e-216 pass
F; PValue and FDR; df.totalglmQLFTest, topTags2.6e-3; 3.9e-3 of |log10 p|; 9.8e-35e-3; 5e-3; 2e-245 pass
A test that leaves the null model without a coefficientglmQLFTestboth raisen/a1 pass
Deliberate differences, listed belowglmQLFit, glmQLFTest, squeezeVar6 rows of the robust prior with genes without counts; the first gene’s logCPM with offsets per observation; 6 rows of edgeR’s momentsn/a13 deliberate differences

Three differences set the tolerances, and the script isolates each. edgeR approximates the mean and variance of the unit deviance: the scale s implied by its unit matrices is off by up to 7.6e-4, 1.2e-3 and 1.3e-2 at dispersions of 0.01, 0.2 and 1, and the degrees of freedom k by up to 2.0e-4, 1.3e-4 and 4.0e-3, where CytePool’s sums are exact. The adjusted deviances, quasi-dispersions and F-statistics therefore agree to about 3e-3, the average quasi-dispersion to 2.1e-3, and the prior df, which maximizes a likelihood that is flat at large values, to 9.8e-3. The prior and posterior that squeeze_variances computes from edgeR’s own adjusted deviances agree with edgeR’s to 4.7e-8, so the moments are the only cause. Where the average quasi-dispersions differ, so do the working dispersions of the fits, and with them the quasi-dispersions, leverages, log fold changes and likelihood ratios, by about 1e-4 to 6e-3. edgeR’s search for the Cox-Reid dispersion stops within its tolerance of 1e-5, and dispCoxReid(tol = 1e-12) on the same genes agrees with CytePool to 8.9e-7. edgeR’s Levenberg fits stop short of the maximum (see Negative binomial models above), so log fold changes of designs that are not one-way layouts agree to about 2e-4, and their fitted values that tend to zero stop near 1e-8, far above CytePool’s; both report the leverages of the limit for these. Deliberate differences:

DESeq2’s dispersions and tests

validation/deseq2_tests.py compares deseq2_dispersions, fit_deseq2, deseq2_wald_test and deseq2_likelihood_ratio_test with DESeq2’s estimateDispersions, nbinomWaldTest, nbinomLRT and results, and with PyDESeq2, on negative binomial counts simulated from fixed seeds: 600 genes and 4 to 12 samples in 9 cases, with 2% of the genes without counts. The cases are two groups; four groups, tested by the likelihood ratio test of the factor and by a Wald test of two levels that 15 genes have no counts in; two groups and a covariate; a paired design of six donors; small counts; two samples per group, which leave two residual degrees of freedom; an outlier sample; dispersions that rise with the mean; and nearly Poisson counts, 69% of whose gene-wise estimates are at the lower bound. Counts with an outlier in most genes, at a dispersion of 0.05, test the rules by which Cook’s distance removes p-values under four designs: two groups of four, and groups of two and five as a factor, as means without an intercept and as a numeric covariate. DESeq2 runs with its defaults, DESeq(dds, minReplicatesForReplace = Inf), with fitType = "mean" where its parametric trend fails (the small counts and the rising dispersions), and each stage of CytePool is compared given DESeq2’s own inputs to it: the dispersions with their maxima, found in R by the script from a grid of the Cox-Reid adjusted log-likelihood and the root of its derivative; the trend and the prior with DESeq2’s; the fits with DESeq2’s at betaTol = 1e-14, polished by Newton’s method in R; and the tests with results() at those fits, with its defaults, with cooksCutoff = 0.5 and without independent filtering. Each printed row pools the cases it applies to and names the worst. Reference: R 4.4.3, DESeq2 1.46.0, PyDESeq2 0.5.4, mpmath 1.3.0.

Check familyReferenceLargest differenceToleranceOutcome
Design matrices; size factors, means and variances of the normalized countsmodel.matrix; DESeq20; 5.9e-160; 1e-102 pass
Means of the likelihoodestimateDispersionsGeneEst; for the covariate and paired designs, DESeq2’s fit at the rough dispersions, polished3.6e-141e-61 pass
Gene-wise dispersions of at least 1e-6maxima in R3.8e-91e-61 pass
Gene-wise dispersions below 1e-6 (49 genes), and genes whose rough dispersion is at the lower bound (1,935), which keep 1e-8 as in DESeq2maxima in R; DESeq20 genes differ02 pass
The parametric trend from DESeq2’s gene-wise estimates (7 cases); its failure where DESeq2’s fails, and the mean trend there (2 cases)DESeq21.4e-15; 0 cases differ; 01e-10; 0; 1e-103 pass
Variance of the log residuals, and of the prior with more than three residual degrees of freedomDESeq21.3e-161e-102 pass
Variance of the prior with two residual degrees of freedom, maximum likelihoodintegration on a grid2.3e-81e-61 pass
Maximum a posteriori dispersionsmaxima in R2.5e-121e-61 pass
Dispersion outliers (33 genes), and the final dispersions they chooseDESeq20 genes differ01 pass
Coefficients and standard errorsDESeq2 at betaTol = 1e-14, polished in R3.2e-151e-61 pass
DeviancesDESeq2 at betaTol = 1e-141.2e-111e-91 pass
Fitted values, hat values, Cook’s distances; the log2 fold changes and standard errors of every testDESeq2 at betaTol = 1e-142.2e-6; 1.4e-61e-52 pass
Wald tests (9): statistics, p-values, adjusted p-valuesresults() at betaTol = 1e-141.8e-61e-51 pass
Likelihood ratio tests (3): statistics, p-values, adjusted p-valuesresults() at betaTol = 1e-143.2e-111e-91 pass
Genes flagged by Cook’s distance (27 genes), filtered out, and the filter’s quantile, in every test; genes flagged at cooksCutoff = 0.5 (862), and the adjusted p-values there and without independent filteringresults() at betaTol = 1e-140 differences; 1.9e-60; 1e-53 pass
Independent filtering on 20 statistics, some of whose curves of rejections reach the fallbacks to 90% and 80% of their fitted maximumresults(filter = ...)00; 1e-102 pass
Cook’s distances at given dispersions under four designs: genes flagged (79 genes, 9 more above the cutoff but spared in a comparison of two groups), and the largest distancesDESeq2 at betaTol = 1e-140 genes differ; 4.0e-140; 1e-52 pass
Derivative in log(alpha) of a count’s log-likelihood, 192 pointsmpmath, 50 digits2.7e-151e-121 pass
DESeq2’s line searches, its fits at its defaults, its means of the likelihood for the covariate and paired designs, its trend where the parametric fit fails, and its prior with two residual degrees of freedomDESeq2 defaultslisted belowbounds listed below6 deliberate differences
Genes spared in a comparison of two groups; DESeq2’s coefficients at betaTol = 1e-14, from the polished fits; the largest differences of DESeq2’s gene-wise line searchesDESeq29 genes; up to 1.5e-6; up to 13 on the log scalen/a3 information
The analysis from the counts: final dispersions, p-values, statistics, genes calledDESeq2 defaultsmedians 1.2e-3 to 3.8e-2 (dispersions, log scale) and 3.0e-4 to 7.6e-3 (p-values); 7 of 1,297 genes called differently at 0.1n/a4 information
The analysis from the countsPyDESeq2medians up to 5.8e-7 (gene-wise dispersions, log scale), 4.7e-5 to 0.21 (final dispersions) and 1.7e-5 to 4.2e-2 (p-values); 5 of 281 genes called differentlyn/a4 information

Dispersions are compared relative to the reference, coefficients relative to max(|reference|, 1), and Cook’s and hat values relative to the larger of the reference and 1, as only values near the cutoff matter; statistics are compared relative to max(|reference|, 1), and p-values on the log scale, relative to max(|log p|, 1), as an error e in a statistic z moves log p by about z^2 e. Below a gene-wise dispersion of 1e-6 the adjusted likelihood is flat in the log dispersion, whose derivative is proportional to the dispersion, so there the check is that both sides place the gene below 1e-6. DESeq2 stops its fits when the deviance changes by less than betaTol of it; for genes of large dispersion, where its Fisher scoring converges slowly, its coefficients are then up to 1.5e-6 from the fixed point even at betaTol = 1e-14 (an information row), which bounds the tolerance of what is computed from them, but for the deviances and the likelihood ratio statistics, which are stationary there. Rows that count genes fail when they cover none. Deliberate differences, each within a bound that the script checks:

From the counts, the final dispersions differ from DESeq2’s by a median of 1.2e-3 to 3.8e-2 on the log scale (the largest with two residual degrees of freedom, from the prior), p-values by a median of 3.0e-4 to 7.6e-3, relative, and statistics by up to 3.4e-2 relative, 7 of 1,297 genes being called differently at a false discovery rate of 0.1. PyDESeq2’s gene-wise dispersions agree with CytePool’s to medians of up to 5.8e-7 on the log scale; its final dispersions differ by medians of 4.7e-5 to 0.21, the largest for the small counts, where DESeq2’s and CytePool’s trend is the mean of the gene-wise estimates.

Empirical Bayes moderation

validation/empirical_bayes.py compares moderated_statistics, treat, squeeze_variances, fit_f_distribution, fit_f_distribution_robust and fit_f_distribution_likelihood with limma’s eBayes, treat, squeezeVar, fitFDist, fitFDistRobustly and fitFDistUnequalDF1, with limma’s legacy argument TRUE (the method of moments, prior_estimator="moments"), FALSE (the likelihood estimator, "likelihood") or NULL (limma’s default, prior_estimator=None, which takes the likelihood estimator when the residual df differ between genes). Linear model cases (2,000 genes, 6 to 40 samples) are fitted by lmFit in R and fit_linear_model here, then moderated with a constant prior, a trend, robustly, and with both; they include several contrasts, some linearly dependent, hypervariable genes, an infinite prior df, missing values, observation weights (some of them zero), genes without residual df, an all-zero contrast, and 1 to 25 genes, which with one or two genes are not moderated (some of them all zero). Variance cases call the estimators directly on 2 to 6,000 variances, with and without a covariate, few distinct or infinite covariate values, zero, missing and unusable variances, fractional df from 0.05, prior weights, planted outliers with equal and unequal df, variances that vary less or more than any prior df explains, infinite robust prior df, three to five trended variances, two variances above zero among zeros, and covariates on scales of 1e-5 and 1e-9. The weighted LOWESS of the likelihood estimator’s trend is also compared with limma’s weightedLowess. TREAT tests a fold change of 1.2. Reference: R 4.4.3, limma 3.62.1.

Check familyReferenceLargest differenceToleranceOutcome
t, p, B, F and its p-value, priors, posterior variances, total df and var.prior, with constant and trend priors, legacy = TRUEeBayes, treat6.8e-131e-1016 pass
The same with robust priorseBayes, treat5.1e-81e-67 pass
One or two genes, not moderated, some of them all zero: NaN t-statistics and an infinite var.prior where limma gives them, with every option seteBayes, treat1.3e-141e-10, robust 1e-612 pass
Missing values and observation weights, constant and trend priors, legacy = TRUEeBayes, treat; the exact F against limma’s F of group means1.8e-131e-103 pass
Missing values and observation weights, robust priors, legacy = TRUEas above3.0e-111e-63 pass
Missing values, zero observation weights and genes without residual df, by default (the likelihood estimator), constant and trend priorseBayes, treat, legacy = NULL1.4e-121e-66 pass
The same with robust priors, every gene with residual dfas above2.1e-121e-65 pass
The likelihood estimator on equal residual dfeBayes, treat, legacy = FALSE2.8e-91e-63 pass
Every component of squeezeVar and fitFDistsqueezeVar, fitFDist3.1e-131e-1024 pass
Every component of fitFDistRobustly, with two choices of Winsorized tailssqueezeVar, fitFDistRobustly8.4e-81e-620 pass
Every component of fitFDistUnequalDF1 and squeezeVar, with a constant prior and a trend, prior weights, fractional df, zero, missing and unusable variancessqueezeVar, fitFDistUnequalDF1, legacy = FALSE2.7e-121e-618 pass
The same with robust = TRUE, every variance usable and no prior weightsas above4.6e-121e-68 pass
Trends through three to five variances (a weighted straight line through at most four), two variances above zero among zeros (a constant prior, not robust), and covariates on scales of 1e-5 and 1e-9; constant and robustas above2.6e-131e-613 pass
The weighted LOWESS of the trend: ties, windows narrower than 1e-7, windows whose x barely vary, two distant clusters, the trend’s weightsweightedLowess1.1e-151e-105 pass
Robust likelihood fits with unusable variances or genes without residual df, against limma’s fits of the usable features alone (the controls of the deliberate difference below)fitFDistUnequalDF1, eBayes1.4e-121e-63 pass
Robust likelihood fits with genes without residual df, unusable variances or prior weightseBayes, squeezeVar, fitFDistUnequalDF10.39 (B-statistics), 0.086 (prior variance)n/a4 deliberate differences

Each row gives the largest difference over all outputs of a case. Differences are relative, except for t, B, F and outlier probabilities, scaled by max(|reference|, 1), and p-values, scaled by max(p, 1e-300) max(1, |log p|): about the error in log p relative to |log p|. NaN and infinite values must be in the same places, and a prior given as one value must be one value in both. The robust moment estimator is in the close tier because limma solves its Winsorized moment equation only to a tolerance. The likelihood estimator is in the close tier too: both programs search the prior df by the same steps of Brent’s method, which stop at a tolerance, and agree to about 1e-12; the largest difference, 2.8e-9, is where the likelihood barely changes near the largest prior df searched, so that rounding can move the steps. Beyond these checks, with residual df of 1e7 and more limma’s likelihood loses precision: its prior df differed from CytePool’s by up to 1e-5 at 1e7 and 5e-2 at 1e12, where CytePool’s agreed with the likelihood computed to 60 digits. Deliberate differences:

Gene-set tests

validation/gene_set_tests.py compares camera and fry with limma’s camera and fry on log-expression of 1,500 genes simulated from fixed seeds, with gene-specific variances from a scaled inverse chi-squared prior around a mean-variance trend. The gene sets change up, down or in both directions, or not at all, share random sample effects (inter-gene correlation) with or without a change, overlap others, hold one, two or five genes, or 40% of the genes. The cases are three groups with the contrast given as a design column, also with only sets of one or more than d genes (so that fry’s adjusted mixed p-values can be compared); three groups in interleaved order with observation weights and the contrast given as weights (a named contrast); counts with voom weights from voom (limma gets the same weights matrix), with the contrast both ways; a paired design with sample weights; residual variances that are all equal, so that the prior df is infinite; 16 samples with sets of 2 to 30 unchanged genes and three sets with changed genes; a gene the design fits exactly and ten identical genes; and 8 genes, fewer than the residual df. Designs are Design objects with named contrasts and genes are named by id in CytePool; R gets the design matrices and the genes’ positions. camera runs with the default inter-gene correlation of 0.01, with it estimated for each set, with negative correlations allowed, with ranks, with a trend and robustly; fry with each standardization, and with a trend and robustly. limma’s camera has no robust option: its reference is cameraPR or rankSumTestWithCorrelation on the z-scores of the moderated t-statistics of eBayes(robust = TRUE), limma’s zscoreT(approx = TRUE, method = "hill"), with the design reparametrised by contrastAsCoef; with an estimated correlation, the parametric p-value of cameraPR is converted to the df of the estimate. Reference: R 4.4.3, limma 3.62.1.

Check familyReferenceLargest differenceToleranceOutcome
camera: numbers of genes, correlations, directions, p-values and FDR, with a given or estimated correlation, ranks and a trendcamera1.3e-141e-1035 pass
camera with robust moderationcameraPR, rankSumTestWithCorrelation1.7e-121e-612 pass
fry with standardize "none" and "residual_sd": numbers of genes, directions, p-values and FDR, mixed p-values of sets of one or more than d genes, and their FDRfry5.2e-131e-1015 pass
fry with "posterior_sd", with and without trend or robust moderationfry4.4e-51e-315 pass
fry with "posterior_sd" and an infinite prior df, where both standardize as "none"fry3.3e-141e-101 pass
CytePool’s mixed p-values of the sets of 2 to 14 genes against 200,000 random rotations of their effectssimulation0.025 (absolute)0.11 pass
fry’s mixed p-values of sets of 2 to d genesfry0.88 (absolute)n/a8 deliberate differences
The rotation simulation: limma’s mixed p-values of the sets of 2 to 14 genes; both programs’ of the sets of 15 to 30 genes; both programs’ of the three sets with changed genes (relative)simulation0.59, 0.030, 0.99n/a3 information
Samples sorted by group, observation weights and the contrast as weights: camera, fry with "residual_sd", and the genes whose last pivot R’s qr() signs by rounding noisecamera, fry, qr0.012, 0.48; 115 of 1,500 genesn/a3 information

Correlations are compared as differences, p-values relative to max(p, 1e-300) max(1, |log p|) as in empirical_bayes.py (limma’s p-values above 1 count as 1), and a direction or a number of genes that differs counts as an infinite difference. Robust moderation is in the close tier because limma solves the robust prior df only to a tolerance. With "posterior_sd", fry moderates each gene’s mean square of its d + 1 effects without the largest, divided by its expected value under the null hypothesis, as a variance on 0.92 d degrees of freedom, as limma does (found by probing); limma’s expected value is about 7.3e-5 (d + 1) / d above the exact one that CytePool computes, so directional p-values differ by up to 4.9e-5 and mixed p-values by up to 5.7e-4 of their value, which the tolerance of 1e-3 allows. The rotation check uses sets without a change, whose p-values a simulation resolves: the beta approximation that both programs use for the mixed p-value is off by up to 0.03 at such p-values for sets of more than d genes, where the programs agree, and in the tail it can be much too small for both: sets with changed genes and rotation p-values of 3.2e-3 and about 6e-5 get 6.3e-4 and 2.9e-7.

In each gene’s QR decomposition, CytePool treats a pivot that is zero up to rounding as zero, which R’s qr() signs as positive. With the samples sorted by group and a contrast between groups given as weights, every gene’s last pivot is such a zero. limma computes most of them exactly, but its arithmetic leaves rounding noise in a few percent of genes, which changes their residual bases, and camera and fry do so for different genes: fry’s are the 115 of the 1,500 whose last pivot R’s qr() signs by noise. camera’s correlations of sets then differ by up to 0.012, and fry’s p-values with "residual_sd" by up to a factor of 2. limma’s results there change with the last bits of the weights, CytePool’s do not; in every other case the two agree.

Deliberate differences:

Rotation gene-set tests

validation/rotation_gene_set_tests.py compares roast, mroast and romer with limma’s functions of the same names on log-expression of 1,500 genes simulated from fixed seeds, with the gene variances and gene sets of gene_set_tests.py and three sets with contribution weights: a signature whose genes go up or down, with weights +1 and -1; a set with weights of different sizes; and a set with half of its weights zero. The cases are three groups with the contrast given as a design column; interleaved groups with observation weights and a named contrast given as weights; the same with a trended prior estimated robustly; counts with voom weights; a paired design with sample weights; equal residual variances (an infinite prior df), also with effects so large that limma’s log-odds of 23 genes exceed 709.8; and contribution weights for every gene, drawn from -2, -1, 0.5, 1 and 3. The exact checks give CytePool the rotations limma drew: a generator that replays the standard normal draws R gives limma after set.seed, in the order limma uses them (found by probing). limma tests each set with the same seed, which is how CytePool’s mroast shares its rotations between sets. The calibrated checks let each program draw its own rotations, and allow a difference of 4 sd(p_1 - p_2) + 2 / (B + 1) for B rotations each, with the sd from the binomial variance of each estimate at their mean. Differences of p-values are absolute: each p-value counts rotations, so a difference of one count is at least 1 / (2B + 1). Reference: R 4.4.3, limma 3.62.1.

Check familyReferenceLargest differenceToleranceOutcome
roast given limma’s rotations: the down, up, two-sided and mixed p-values, active proportions and numbers of genes of every set, for every set statistic and caseroast5.0e-161e-1219 pass
mroast given the same rotations for every set: two-sided p-values, and directions (mismatched sets)mroast0; 0 sets1e-12; 038 pass
romer given limma’s rotations: up, down and mixed p-values and numbers of genes, for every set statistic, with and without shrinkage, with sample weights and with an infinite prior dfromer01e-127 pass
limma’s FDR and FDR.Mixed from its p-values by mroast’s rule; numbers of genes and active proportions with each program’s own rotationsmroast4.4e-161e-129 pass
Each program’s own rotations: mroast (mean, msq, mean50; B = 1,999) and romer (mean, mean50; B = 999)mroast, romer0.54 of the bound15 pass
mroast’s mean statistic with 20,000 rotations against fry, its limit, with equal variancesfry0.34 of the bound11 pass
CytePool’s fry, mixed p-values of the sets of 2 to 6 genes, against the msq rotation test with 20,000 rotationsmroast0.015 (absolute)0.11 pass
CytePool’s msq p-values with every contribution weight multiplied by 10 (B = 1,999)roast01e-121 pass
limma’s fry, mixed p-values of the sets of 2 to 6 genes, against the samemroast0.45 (absolute)n/a1 information
Genes whose log-odds in limma exceed 709.8 (very large effects)romer23 genesn/a1 information
p-values where the observed floor-mean or mean-square statistic is the smallest it can beroast0.50 (absolute)n/a6 deliberate differences
floormean’s mixed p-values of sets with negative weights; msq’s up, down and two-sided p-values of sets with weights of both signsroast0.65; 0.33 (absolute)n/a6 deliberate differences
limma’s msq p-values with every weight multiplied by 10 (B = 1,999)roast0.45 (absolute change)n/a1 deliberate difference
The set with weights of zero: its number of genes, and its mean50 p-valuesroast8 against 16 genes; 0.039n/a6 deliberate differences
romer with shrinkage where limma’s posterior probabilities are NaNromer0.15 (absolute)n/a1 deliberate difference

The z-scores of the moderated t-statistics are Bailey’s (1980) transformation on min(d_0 + d, 10^4) degrees of freedom; romer’s shrinkage of each gene’s contrast effect computes its log-odds on d_0 + d degrees of freedom, without the limit, and with the proportion of true null hypotheses of limma’s propTrueNull(method = "lfdr"). These conventions, the set statistics’ definitions where limma differs from the paper (the mean-50 statistic averages the ceiling(m / 2) largest values, and romer’s floor-mean mixed statistic floors |t| at 1), and the order of limma’s random draws were found by probing, and the exact checks confirm them, with an infinite prior df too. Without shrinkage, romer agrees with limma exactly on the data with very large effects, which shows that the difference with shrinkage comes from limma’s NaN posterior probabilities. The fry checks give the reference that gene_set_tests.py approximated by simulation: the mixed p-values of fry for sets of 2 to d genes, where CytePool uses all d + 1 dimensions of the rotation and limma only the set’s, against the rotation test of the same statistic, which mroast’s msq statistic is when all genes have the same residual variance (up to the limit on the df of the z-scores).

Deliberate differences:

Multiple testing

validation/multiple_testing.py runs every method of adjust_p_values (Holm, Hochberg, Hommel, Bonferroni, Benjamini-Hochberg, Benjamini-Yekutieli and none) on each case and compares it with R’s p.adjust. Each check covers all seven methods. Reference: R 4.4.3, stats::p.adjust.

Check familyReferenceLargest differenceToleranceOutcome
2,000 uniform values; rounded values with ties, 0 and 1; 10% NaN; 10% exactly 0 or 1; n = 2,000 for 500 values with ties and NaNp.adjust01e-105 pass
All 9,330 vectors of length 1 to 5 over {0, 0.01, 0.03, 0.5, 1, NaN}, with the default n and with n = 6p.adjust01e-102 pass
A 2,000 x 4 matrix adjusted by column, 0 to 50% NaNp.adjust01e-101 pass
50 random families of 2 to 2,000 values, with the default n and with n = 2,500p.adjust1.4e-161e-102 pass
100,000 values, 10% duplicatedp.adjust01e-101 pass
The small cases written as test fixturesp.adjust01e-104 pass

Of the 290,224 p-values each method adjusted, all but 5 Hommel values were identical to R’s to the last bit.

Differential expression

validation/differential_expression.py simulates cells with a donor structure (seed 20261025), pools them into cubes with from_cells, and runs limma_voom and limma_trend on every cell type; R runs the workflow of the limma and edgeR User’s Guides on the same pseudobulk counts, group by group: it leaves out the samples with fewer than 10 cells, keeps the rows of the remaining samples of a design built by model.matrix on the full sample table, and runs filterByExpr, calcNormFactors, voom (or cpm(log = TRUE, prior.count = 3) for limma-trend), lmFit, contrasts.fit one contrast at a time, eBayes and topTable(sort.by = "none"). voom chooses its span from the number of genes tested in each group, in both programs (adaptive.span = TRUE in R, limma’s default since 3.66), except in the case that fixes it at 0.5 (span = 0.5, adaptive.span = FALSE); limma’s voom reports the span it chose (found by probing), and each group’s span is compared. The cases are a paired design, ~ donor + condition (6 donors x 2 conditions, 4 cell types, 1,000 genes), by limma-voom and limma-trend, and an unpaired design with a covariate, ~ condition + age (10 donors, 3 cell types), by limma-voom with robust moderation and a span of 0.5, and by limma-trend without filtering or normalization factors. The paired design tests the stimulation and three donor comparisons: D2_vs_D1, D2_vs_D6 and D3_vs_D2. Some sample x cell type entries have fewer than 10 cells; in the NK cells both samples of donor D1, the reference level, are left out, which makes the column donorD6 a combination of the others, and in the DC cells four samples remain, too few for any residual degrees of freedom. Reference: R 4.4.3, limma 3.62.1, edgeR 4.4.0.

Check familyReferenceLargest differenceToleranceOutcome
Samples used, features tested, coefficients that cannot be estimated, contrasts tested (other than the deliberate differences), voom’s span, residual and prior df, in the 12 fitted groupsfilterByExpr, voom, lmFit, contrasts.fit, eBayes0 mismatches012 pass
logFC, AveExpr, t, P.Value, adj.P.Val and B of every contrast both programs compute, and the moderated standard error of logFC (sqrt(s2.post) * stdev.unscaled)topTable, eBayes6.3e-131e-109 pass
The same with robust moderationtopTable1.0e-131e-63 pass
D2_vs_D6 in the NK cells, which limma refuses, against limma’s values for D2_vs_D1, which there estimate the same differencetopTable2.8e-131e-102 pass
DC cells, without residual degrees of freedom: both programs refuse every contrasteBayesboth refusen/a2 pass
Deliberate differences, listed belowcontrasts.fit2 contrasts limma refuses and CytePool computes, 2 limma computes and CytePool reports as not estimablen/a4 deliberate differences

Differences are scaled by max(|reference|, 1), and p-values by max(p, 1e-300) max(1, |log p|). limma-voom ran with contrast_standard_errors="limma", except in the robust case, whose contrasts are single coefficients, for which the default exact standard errors are limma’s. In the NK cells, D3_vs_D2 combines two of the columns the group keeps, and agrees with limma’s approximate standard errors too. The robust case keeps the close tier because limma solves the robust prior only to a tolerance; here it agreed to 1e-13. Deliberate differences, in the NK cells of both paired cases:

Options for pseudobulk differential expression

validation/differential_expression_options.py checks the options of limma_voom and limma_trend for pseudobulk data against limma and edgeR. It simulates cells from 6 donors, each sampled before and after a stimulation (seed 20261027), in 3 cell types with 1,000 genes, pools them with from_cells, and fits ~ donor + condition with two contrasts, stim_vs_ctrl and D3_vs_D2. One sample is an outlier in every cell type, two are sequenced shallowly in one cell type each, and in the NK cells both samples of donor D1, the reference level, have fewer than 10 cells, which makes the column donorD6 a combination of the others. Six analyses each set some of the options, and R runs the workflow of the section above with the same options:

Reference: R 4.4.3, limma 3.62.1, edgeR 4.4.0.

Check familyReferenceLargest differenceToleranceOutcome
Samples used, features tested, voom’s span, residual and prior df, in the 18 fits (6 analyses x 3 cell types)filterByExpr, voom, lmFit, eBayes0 mismatches018 pass
Sample quality weightsvoomWithQualityWeights, arrayWeights2.7e-111e-612 pass
Prior weights against the number of cells R counts from the cellstable01e-106 pass
logFC, AveExpr, t, P.Value, adj.P.Val and B of both contrasts, with quality weightstopTable1.1e-121e-612 pass
The same with prior weightstopTable1.7e-121e-106 pass
The same, limma-voom with prior weights, against edgeRvoomLmFit1.8e-131e-103 pass
P-values adjusted across the cell types, with quality weightsp.adjust5.1e-131e-64 pass
The same with prior weightsp.adjust7.6e-131e-104 pass
Prior weights together with estimated sample weights: both programs refusevoomLmFitboth refusen/a1 pass

Differences are relative for the weights; the statistics are scaled as in the section above. Everything computed from sample quality weights is in the close tier, as they are iterative estimates; the rest is exact. limma-voom ran with contrast_standard_errors="limma", since D3_vs_D2 combines two coefficients. limma’s quality weights on the rank-deficient design of the NK cells are those on its kept columns (found by probing), as CytePool’s are. voomLmFit treats some genes with zero counts differently from voom and lmFit: it lowers their residual df, as its help page says, and can move their average log count in the trend (found by probing). In these data that happens only where min_counts leaves a donor with one sample, so voomLmFit is compared in the analysis of limma-voom with prior weights that does not set min_counts. limma’s voomWithQualityWeights takes no prior weights, and edgeR’s voomLmFit stops with “Can’t specify prior.weights and estimate sample weights”; CytePool refuses the two together as well.

dream

validation/dream.py compares voom_with_dream_weights, moderated_fixed_effect_tests and dream_voom, and the maximum likelihood fits, fitted values and approximate residual degrees of freedom of fit_mixed_model behind them, with variancePartition’s voomWithDreamWeights, dream(ddf = "Satterthwaite"), eBayes and topTable, on negative binomial counts of 60 genes from fixed seeds (20261025 to 20261029): a random donor intercept with a condition, an age and the contrast conditionstim - age (10 donors x 3 samples, with two hypervariable genes), moderated also with robust = TRUE and trend = TRUE; crossed donor and batch intercepts (8 donors x 4 samples over 3 batches) with sample prior weights; unbalanced donors with 1 to 4 samples, with prior weights for every observation; and dream_voom on three cell types of a cube of 8 donors x 2 conditions, with prior weights from the numbers of cells, the samples with fewer than 10 cells left out (both samples of a donor and one of another in one cell type, and all but one sample of each of three donors in the third), and the moderation also with trend = TRUE. Reference: R 4.4.3, variancePartition 1.36.2, limma 3.62.1, lme4 1.1-38, lmerTest 3.1-3, pbkrtest 0.5.3, Matrix 1.7-4, RcppEigen 0.3.4.0.2 and minqa 1.2.8. dream is compared on lme4 1.1-38, while the mixed models are compared on lme4 2.0.6: variancePartition 1.36.2’s dream fails for every gene with lme4 2.0, which reworked lme4’s random-effect terms.

Check familyReferenceLargest differenceToleranceOutcome
Precision weights and the square roots of the residual SDs in the trend; with prior weights for samples or observations, also the trend’s weights alonevoomWithDreamWeights2.1e-81e-63 pass
Coefficients and a contrast, relative to the larger of their absolute value and standard error, and standard errorsdream6.2e-81e-63 pass
Satterthwaite dfdream2.2e-71e-53 pass
Residual SD of an observation of average weight, and approximate residual dfdream sigma, rdf2.8e-81e-63 pass
Prior df and variance, posterior variances (constant, robust and trend priors)eBayes1.6e-81e-65 pass
Moderated t-statistics and B-statistics, relative to max(|x|, 1)eBayes1.2e-71e-65 pass
Total df, p-values, adjusted p-values and z-statisticseBayes, topTable7.9e-81e-55 pass
Maximum likelihood and REML at CytePool’s estimates: log-likelihood, residual variance, approximate residual df, and fitted values relative to their largest valuelme4 logLik, sigma, fitted; rdf.merMod5.9e-151e-104 pass
Satterthwaite df there, of maximum likelihood and REML fitslmerTest summary5.8e-71e-54 pass
Maximum likelihood optimum: log-likelihood and residual variancelme4 logLik, sigma2.5e-81e-62 pass
Genes whose log-likelihood is below lme4’s maximum by more than 1e-10, relativelme4 logLik0 genes02 pass
dream_voom, in two cell types: samples used and features testedthe rules of limma_voom0 differences02 pass
dream_voom, with constant and trend priors: logFC and AveExprtopTable1.5e-81e-64 pass
dream_voom: t and BeBayes2.7e-81e-64 pass
dream_voom: df, P.Value, adj.P.Val and z.stdeBayes, topTable1.1e-71e-54 pass
Prior df (median and smallest) and the estimator of the priorCytePool8.4 to 15.2; robust 5.7 to 12.0; likelihoodn/a5 information
Deliberate differences, listed belowvariancePartition5 inputsn/a5 deliberate differences

variancePartition fits every gene with lme4, whose optimiser stops at about 1e-6 of the optimum with its default tolerances. The script runs it with Powell’s BOBYQA from minqa (lme4’s "bobyqa") with the trust region shrunk to 1e-10, which lme4’s optimizer argument accepts by name as a function that reports convergence: at such tolerances lme4 warns for some genes, and variancePartition turns the warning into an error and drops the gene. BOBYQA from nloptr at tolerances near machine precision ended at worse points inside voomWithDreamWeights for crossed random effects, so it is not used. Estimates are then compared to the close tier, and Satterthwaite’s degrees of freedom, with the p-values and z-statistics that use them, to 1e-5, as in the section on mixed models: lmerTest differentiates the deviance numerically. At CytePool’s estimates, lme4 evaluated once (maxeval = 1) gives closed forms, compared to the exact tier. P-values are scaled by max(p, 1e-300) max(1, |log p|), as for voom. In the cube the features tested and the TMM factors are CytePool’s (filter_by_expression and tmm_factors, which the sections above check against edgeR), passed to R with the samples kept by the rule of limma_voom; R gets the effective library sizes and the numbers of cells as prior weights, with rescaleWeightsAfter = TRUE.

The conventions of variancePartition that its documentation leaves open, found by probing it and listed in the documentation of cytepool.stats.dream, are all exercised here: voomWithDreamWeights fits the mixed models by maximum likelihood and reads the trend at the fitted values with the predicted random effects; it divides each gene’s prior weights by their mean, and by default returns the trend’s weights alone; dream fits each gene with its weights divided by their mean, so that sigma is the residual standard deviation of an observation of average weight; eBayes moderates these variances on rdf with limma’s default estimator (here the likelihood estimator, as the rdf differ), and the p-values use Satterthwaite’s df plus the prior’s, without limma’s cap. With robust = TRUE the two hypervariable genes got prior df of 5.7, against 12.0 for the others.

Deliberate differences:

Differential expression on published data

validation/interferon_stimulated_pbmcs.py runs the whole path from cells to differential-expression tables on published single-cell data, and compares it with limma on the same pseudobulk counts. The data are batch 2 of GEO series GSE96583 (Kang et al. 2018, Nature Biotechnology 36, 89-94): peripheral blood mononuclear cells (PBMCs) of 8 lupus patients, each split into an untreated culture and one stimulated with interferon-beta for 6 hours, sequenced with 10x Genomics, the cells assigned to their donors by genotype. The script downloads the three files it needs (77 MB) into a cache directory outside the repository and checks their SHA-256 checksums. It reads them as data, without extracting the archive, and stops if a fact it relies on fails: the dimensions of the two count matrices, the gene identifiers, the number of cells of each type, and the rule that joins each cell to the published cell table by its condition and barcode (every cell matches one row and every row one cell; nothing is joined by position).

from_cells pools the 24,673 singlets that have a cell type into a cube of 16 samples (8 donors, control and stimulated) x 8 cell types x 35,635 genes, keyed by Ensembl id with symbols, and its counts are checked against sums of the cells that scipy computes independently. limma_voom then tests stimulated against control in every cell type, with the paired design ~ donor + condition and its defaults: samples with fewer than 10 cells (min_cells) are left out, then come filterByExpr, TMM and voom, whose LOWESS span is chosen from the number of genes tested by limma’s chooseLowessSpan rule, as limma’s voom does by default since limma 3.66. R runs the limma User’s Guide workflow on the counts, samples and cell numbers exported from the cube: the same min_cells rule, then filterByExpr, calcNormFactors, voom, lmFit, contrasts.fit, eBayes and topTable(sort.by = "none"), all with R’s defaults except two settings that differ between limma versions: voom gets the span that the cell type’s record in the CytePool result gives, with adaptive.span = FALSE, and eBayes gets legacy = TRUE. R also computes chooseLowessSpan of the number of genes tested, the span its voom would choose itself, and that span must be the same. Reference: R 4.4.3, limma 3.62.1, edgeR 4.4.0; CytePool ran with Python 3.13.12, numpy 2.5.4 and numba 0.68.0, and scipy 1.18.1 read the count matrices.

Check familyReferenceLargest differenceToleranceOutcome
Counts of the 16 samples x 8 cell types x 35,635 genes of the cubesums of the cells, by scipy0 counts differ01 pass
Samples used, genes tested, aliased columns, residual and prior df, and voom’s span, in the 8 cell typesfilterByExpr, lmFit, eBayes, chooseLowessSpan0 mismatches08 pass
logFC, AveExpr, t, P.Value, adj.P.Val and B of every gene tested in the 8 cell typestopTable6.6e-131e-108 pass
Per cell type: genes tested and called, the interferon response, the negative control (tables below)MSigDB, nonen/an/a24 information

Differences are scaled as in the section above. The dendritic cells are fitted without 2 of the 16 samples, and the megakaryocytes without 4, among them both samples of donor 107: that donor’s column of the design is then zero, and both programs leave it out of the fit.

The recovery of a pre-specified list of interferon-stimulated genes is measured with MSigDB’s Hallmark interferon-alpha response gene set (97 genes, MSigDB v2026.2.Hs; Liberzon et al. 2015, Cell Systems 1, 417-425; CC BY 4.0), matched to the data by symbol; two of its genes have earlier symbols in the data, FAM46A for TENT5A and WARS for WARS1. Up and down are calls at a 5% false discovery rate:

Cell typeCellsSamples used (left out)Genes testedvoom spanUpDownHallmark genes testedUpDownMedian log2 fold change
B cells2,57316 (0)2,7680.484498399767322.73
CD14+ monocytes5,38516 (0)5,1250.4501,6191,549928933.26
CD4 T cells10,38916 (0)6,2950.440991826868212.41
CD8 T cells2,04216 (0)1,4530.528233147555402.51
Dendritic cells43214 (2)2,2810.496454285766803.27
FCGR3A+ monocytes1,59916 (0)2,9470.480814663848212.42
Megakaryocytes26012 (4)7040.5906316483502.48
NK cells1,99316 (0)2,2920.496374300686602.13

In the six cell types with all 16 samples, 95% to 98% of the Hallmark genes tested are called up, with median log2 fold changes of 2.1 to 3.3, that is 4 to 10 times the expression of the controls; in the dendritic cells and megakaryocytes, with fewer cells and samples, 89% and 73% are. The only Hallmark genes called down are IL4R, in four cell types, CD74 in the B cells, and PROCR and IFI30 in the CD14+ monocytes.

The response to interferon is so large that recovering it says little about error control; a negative control, which needs no reference, does. In each cell type, the control samples that its fit used are split into two halves in every possible way, each split once, and every split is fitted with ~ split, so that a call compares donors, not conditions: 35 splits of 4 against 4 samples, or 10 splits of 3 against 3 in the dendritic cells and megakaryocytes, where 2 control samples have fewer than 10 cells. If the donors did not differ, the Benjamini-Hochberg procedure at a 5% false discovery rate would make any call in an analysis with a probability of at most about 5%, so in about 12 of these 230 analyses or fewer.

Cell typeSplitsSplits with callsCallsMost in one splitGenes called in more than one split
B cells35441none
CD14+ monocytes35166none
CD4 T cells35712344OASL, LMNA, DDIT4, ANXA2R (3 splits); YIPF5, FBLN5, RGS1, TPRG1, CD27, C16orf54, SCML4, NUCB2, SUN2, PASK, FASLG, FAM46C (2)
CD8 T cells355136TMSB10 (2)
Dendritic cells10221none
FCGR3A+ monocytes35253none
Megakaryocytes10000none
NK cells35232none

23 of the 230 analyses made a call, 156 calls in all, and most of them follow differences between the donors. Of the five splits that put donors 1244, 1256 and 1488 in one group with a fourth donor, the three in which that donor is 1016, 1039 or 107 make 117 calls in the CD4 T cells, among them genes of regulatory and memory T cells (TIGIT, TNFRSF4, TNFRSF18, FASLG, LMNA, RGS1), which suggests that the CD4 T cells of these donors differ in composition. The split of donors 101, 107, 1244 and 1488 against the others calls interferon-stimulated genes (ISG15, IFI6, IFIT1, IFIT3, LY6E, IFITM3) in the monocytes and the CD4 T cells of the untreated cultures, consistent with lupus patients differing in interferon activity. Apart from those three CD4 T splits, no analysis makes more than 6 calls. The donors appear to be all female, so sex does not divide them: XIST is counted in all 16 samples, and none of the Y-chromosome genes RPS4Y1, DDX3Y, KDM5D, UTY and EIF1AY in any.

The script times each part on the machine described under Timings, with numba, OpenMP, OpenBLAS and MKL limited to 2 threads, the data downloaded already and numba’s cache of compiled routines filled:

PartSeconds
Reading and checking the files, and keeping the 24,673 singlets with a cell type1.30
from_cells: 24,673 cells x 35,635 genes (14.2 million stored counts) into the cube0.24
limma_voom on the 8 cell types, first call in the process0.32
limma_voom on the 8 cell types, second call0.26
R: the limma workflow on the 8 cell types, from the counts in memory1.42
The negative control: limma_voom on the 230 splits, one cell type each3.61

With an empty numba cache, as after installing or changing the package, the first call of limma_voom compiles its routines too, and took 17 s in a run with NUMBA_CACHE_DIR set to an empty directory; later processes load them from the cache. The Python process held at most 617 MiB while it read the files and pooled the cells. Once the cell-level matrices were released it held 232 MiB, and while limma_voom ran at most 252 MiB, so the analysis of the 8 cell types added 20 MiB to a process that held the cube; from then to the end, with the negative control and the export to R, it held at most 321 MiB. R peaked at 227 MiB.

Cell-type composition

validation/composition_tests.py compares propeller with speckle’s propeller, propeller.ttest and propeller.anova. It simulates the number of cells of every sample in every cell type from a Dirichlet-multinomial model (seed 20261025), holds them in cubes, and gives speckle one label per cell; propeller.ttest and propeller.anova get getTransformedProps and design matrices from model.matrix. Every case runs with the logit and the arcsine square-root transform, and with speckle’s defaults (robust moderation, no trend) unless a row says otherwise. The cases are two groups of 5 samples with 1,000 to 5,000 cells and 12 cell types, three of which change, also with robust = FALSE and trend = TRUE; three groups of 4 samples, by speckle’s F-test; three groups of 5 samples and a covariate (age), ~ 0 + group + age, with the contrasts B - A and (A + B)/2 - C, the F-test of group (propeller.anova with coef = 1:3), the slope of age and A + B, and ~ group + age with the contrast groupB; a pairing of 6 donors in two conditions, ~ 0 + condition + donor and ~ donor + condition; 8 samples with 6 to 32 cells, in which most cell types are absent from some samples and two from all of them, in a cube that also holds a cell type and a sample without cells; two cell types, which limma does not moderate; and samples that are copies of one another within each condition. Reference: R 4.4.3, speckle 1.6.0, limma 3.62.1.

Check familyReferenceLargest difference: proportions, statisticsToleranceOutcome
Two groups (t-test) and three groups (F-test), 12 cell types: BaselineProp, PropMean, PropRatio, Tstatistic or Fstatistic, P.Value and FDRpropeller1.7e-15, 3.9e-91e-10, 1e-64 pass
Two groups with robust = FALSE and with trend = TRUEpropeller5.4e-16, 6.7e-151e-10, 1e-10 and 1e-62 pass
B - A and (A + B)/2 - C with a covariate, and B - A as groupB in ~ group + agepropeller.ttest6.1e-16, 9.4e-151e-10, 1e-66 pass
F-test of group with a covariatepropeller.anova4.9e-16, 3.6e-151e-10, 1e-62 pass
The slope of the covariate, which speckle refuseslimma eBayes on speckle’s transformed proportionsnone reported, 3.0e-151e-62 pass
A pairing, in group means and in treatment codingpropeller.ttest4.7e-16, 7.7e-151e-10, 1e-64 pass
Few cells and absent cell types; the sample without cells, with the arcsine transformpropeller, propeller.ttest3.7e-16, 3.5e-151e-103 pass
Two cell typespropeller2.3e-16, 3.3e-151e-102 pass
Deliberate differences, listed belowpropeller.ttest, propeller2 contrasts speckle refuses, 2 ratios of contrasts that compare nothing, 2 unused levels speckle keeps, 1 data set CytePool refusesn/a7 deliberate differences

Each row compares every column that speckle reports, matched by cell type, and the columns themselves must match: the mean proportions of the levels a contrast compares, and no others. Proportions and ratios are compared relative to speckle’s values, in the exact tier; statistics are scaled by max(|reference|, 1), and p-values and FDRs by max(p, 1e-300) max(1, |log p|), in the exact tier, or in the close tier with robust moderation of 10 cell types or more, whose prior limma solves only to a tolerance. The largest difference, 3.9e-9, is in such a case; every other is below 1e-14. Deliberate differences:

CytePool computes the transforms as log((x + 0.5) / (N - x + 0.5 (I - 1))) and arctan2(sqrt(x), sqrt(N - x)), equal to speckle’s formulas but without the cancellation in 1 - q and the loss of digits of arcsin near 1; on these data the two agree to rounding.

Meta-analysis

validation/meta_analysis.py compares meta_analysis with metafor’s rma.uni, run feature by feature in R on the same simulated effects and standard errors (seed 20261028), for the fixed-effect model (method = "EE") and the DerSimonian-Laird, REML and Paule-Mandel estimators of the between-study variance, each with z-tests and with the Knapp-Hartung adjustment (test = "knha"). The ten cases have 120 features each: 3 studies without and with large heterogeneity; 30 studies without and with heterogeneity; 2 studies; 5 studies, one with a standard error 1,000 times the others’; 4 studies, one imprecise and far from the others; 8 studies with 30% of the values missing (NaN in the effect, the standard error or both), three features measured by one study and two by none; features of 4 studies whose between-study variance is at zero although the Hedges estimate, from which metafor’s REML starts, is positive; and 5 studies whose standard errors are 1e-9 to 4e-9 for effects of spread 1. metafor runs its iterative estimators to convergence, REML with control = list(threshold = 1e-13, maxiter = 10000) and Paule-Mandel with control = list(tol = 1e-14, maxiter = 10000), and, for information, with their defaults. Then three cohorts of 5, 4 and 6 donors, each sampled before and after a stimulation (seed 20261029), are simulated as pseudobulk counts: the first has T, B and NK cells and 400 genes, the second too few NK cells to fit them, 10% fewer genes, 20 genes of its own and its genes in shuffled order, the third no NK cells and 10% fewer genes, and 30% of the changes differ between cohorts. limma_voom tests the stimulation in every cohort; R merges the cohorts’ tables by gene id, runs rma.uni on every gene that enough cohorts tested and adjusts the p-values with p.adjust(method = "BH"), and differential_expression_meta_analysis is compared with its results. Reference: R 4.4.3, metafor 5.0.1, jsonlite 2.0.0.

Check familyReferenceLargest differenceToleranceOutcome
Fixed effect and DerSimonian-Laird, with z-tests and Knapp-Hartung, in the 10 cases: estimate, standard error, statistic, p-value, confidence limits, tau², Q and its p-value, I², H² and the number of studiesrma.uni2.3e-131e-1020 pass
REML and Paule-Mandel, the samerma.uni3.4e-101e-620 pass
REML where the restricted likelihood has a higher maximum than metafor’s estimaterma.uni, logLik2 of 120 features, restricted log-likelihood higher by up to 0.023n/a1 deliberate difference
REML and Paule-Mandel against metafor’s default controlrma.uni6.8e-2n/a20 information
Meta-analyses of the cohorts’ tables by cell type: REML and z-tests, DerSimonian-Laird and Knapp-Hartung, Paule-Mandel and z-tests (genes of 2 cohorts or more), and the fixed effect (every gene)rma.uni, p.adjust6.0e-111e-10 (fixed, DerSimonian-Laird), 1e-69 pass
One study with the Knapp-Hartung adjustmentrma.uniboth give the z-test, metafor with a warning1e-101 pass
A Paule-Mandel estimate above metafor’s search interval: against metafor with tau2.max = 1e6, and with its default, where it stopsrma.uni1.7e-151e-61 pass, 1 deliberate difference
Zero and infinite standard errors, which CytePool refusesrma.unimetafor stops for an infinite one, and for a zero one with the fixed effect, DerSimonian-Laird and Paule-Mandel; with REML it fits without Q, I² and H²n/a1 pass, 1 deliberate difference

Differences are scaled by max(|reference|, 1), and p-values by max(p, 1e-300) max(1, |log p|); I² is compared in percent, as metafor reports it (CytePool reports a proportion), and the number of studies must be equal. A row covers both tests of one case and method. The REML rows’ largest differences, near 1e-10, are in I²: metafor stops when tau² changes by less than its threshold of 1e-13, which I² in percent multiplies by 100 / (tau² + s²), s² the typical within-study variance; tau² itself agrees to 3.3e-13. In one feature of 30 studies metafor’s Fisher scoring does not converge, oscillating, and the remedy its help page suggests, stepadj = 0.5, converges to CytePool’s value. With its defaults metafor stops REML when tau² changes by less than 1e-5 and Paule-Mandel at uniroot’s tolerance, about 1.2e-4 on tau²: its estimates then differ by up to 3e-5 in tau², and the largest scaled difference, 6.8e-2, is in I² in percent. Deliberate differences:

Where the Hedges estimate is positive but the restricted likelihood is largest at zero, CytePool’s REML estimate is zero; metafor’s step halving approaches zero and stops near its threshold, at about 1e-6 by default. With standard errors 1e-8 of the spread of the effects, the two programs agree to 1.5e-14.

Figures

validation/plotting.py draws each figure of cytepool.plotting that follows an R function with CytePool and with R on the same data, and compares what the two drew: R’s base graphics as the recorded display list of the plot holds them (recordPlot(), every primitive drawn with its coordinates), speckle’s ggplot as its built layer data, and CytePool’s as the data of the matplotlib artists. The data (seed 20261101) are counts of 300 genes in 4 control and 4 stimulated samples, 2 genes of which have no counts and 4 an outlying count; three studies of 4, 5 and 6 donors, tested by limma_voom and meta-analysed, for the forest plots; and the cell numbers of 5 cell types in 12 samples for the composition. limma-voom runs in R as the limma User’s Guide runs it, and voom with adaptive.span = TRUE, as CytePool chooses the span. Reference: R 4.4.3, limma 3.62.1, edgeR 4.4.0, DESeq2 1.46.0, metafor 5.0.1, speckle 1.6.0.

Check familyReferenceLargest differenceToleranceOutcome
MDS plots: leading log2 fold changes over the top 50 and the top 500 genes, the 100 genes of largest variance (dimensions 2 and 3), and the samples of a cube after filterByExpr and TMM, two of which have fewer than 10 cells; coordinates up to sign, and the percentages of the axis labelsplotMDS5.4e-151e-104 pass
voom’s mean-variance trend: the points and the LOWESS linevoom(save.plot = TRUE)6.9e-151e-101 pass
Sample quality weightsvoomWithQualityWeights(plot = TRUE)3.9e-151e-61 pass
Residual variances, with a trend and robust moderation (14 outliers marked) and with a constant prior: points, outliers, priorplotSA2.6e-91e-62 pass
MD plot (genes up, down and not significant at 5% FDR), volcano plot and the 10 genes it labels, and p-value histogram, of limma-voomplotMD, volcanoplot, hist2.1e-14; counts identical1e-10; 03 pass
Barcode plots: the bars and the enrichment worm, with spans 0.45 and 1barcodeplot4.1e-151e-102 pass
Dispersions (4 outliers) and quasi-dispersions drawn from DESeq2’s and edgeR’s own estimatesplotDispEsts, plotQLDisp4.7e-151e-102 pass
The same, from CytePool’s fits of the countsplotDispEsts, plotQLDisp2.8e-2; 3.3e-4n/a2 information
Quasi-dispersions of the 2 genes without residual degrees of freedomplotQLDispedgeR draws them at 0; CytePool leaves them outn/a1 deliberate difference
Forest plots of 3 genes, one of them tested by two studies, by REML with z-tests and by DerSimonian-Laird with the Knapp-Hartung adjustment: the studies’ intervals, and the pooled estimate and intervalforest, rma.uni4.5e-151e-6 (REML), 1e-106 pass
Composition: the bar segment of every sample and cell type; proportions by condition, their means and adjusted p-valuesplotCellTypeProps, propeller5.0e-161e-10; 1e-62 pass

Differences are scaled by max(|reference|, 1). R writes numbers with 15 significant digits, which the exact rows reach. The residual variances agree to 2.6e-9 because limma solves the prior’s degrees of freedom to a tolerance (see Empirical Bayes moderation above). After robust moderation, plotSA marks the variances whose two-sided p-value as a variance of the prior, adjusted by Benjamini and Hochberg, is below 0.5 (found by probing), and both mark the same 14. CytePool’s dispersions differ from DESeq2’s where DESeq2’s line search stops short of the maximum, and its quasi-dispersions from edgeR’s by the exact moments of the unit deviance (see the sections on DESeq2 and on quasi-likelihood F-tests above), so these figures are compared drawn from R’s own estimates, and from CytePool’s fits for information. metafor clips its diamond at the axis limits, which a pooled interval of two studies with the Knapp-Hartung adjustment exceeds here, so the pooled estimate and interval are compared with those it writes beside the diamond, from the fit. The deliberate difference: a gene without residual degrees of freedom, such as one without counts, has no quasi-dispersion (its deviance and its degrees of freedom are 0); edgeR draws it at 0, and CytePool leaves it out.

Distributions and special functions

validation/distributions.py checks every public function of cytepool.stats.special and cytepool.stats.distributions on 19,493 points against mpmath evaluated at the exact binary values of the inputs, with 50 significant digits (70 for intermediate steps); quantile references are roots of the 70-digit tail probabilities. The grids reach probabilities of 1e-300 (and log probabilities far below), shapes from 1e-3 to 1e6 (incomplete gamma shapes to 1e18 above the center), degrees of freedom from 0.5 to 1e12 and infinity, hypergeometric populations up to 1e13, batches of 2,000 moderated t and quasi-likelihood F statistics, and cases where textbook methods lose accuracy. The incomplete beta reference is DLMF 8.17.22 in 70-digit arithmetic, and the hypergeometric reference sums exact ratios in fixed-point integer arithmetic. Reference: mpmath 1.3.0, Python 3.13.12, numpy 2.5.4.

Check familyReferenceLargest differenceToleranceOutcome
Log-gamma (x > 0, x < 0), digamma (x > 0, x < 0), trigamma, inverse trigamma, log-betampmath9.99e-16 (digamma, x < 0)1e-15, or 2e-15 for digamma at x < 0 and log-beta7 pass
Regularized incomplete gamma and beta functions, their complements and logarithmsmpmath2.44e-131e-128 pass
Normal: cdf, sf, logcdf, logsf, ppf, isfmpmath6.22e-161e-126 pass
t: cdf, sf, logsf, ppf, isf, and sf and logsf of 2,000 moderated t-statisticsmpmath1.95e-131e-127 pass
F: cdf, sf, logsf, ppf, isf, and sf and logsf of 2,000 quasi-likelihood F statisticsmpmath2.12e-131e-127 pass
Chi-squared: cdf, sf, logsf, ppf, isfmpmath7.38e-141e-125 pass
Gamma and beta: cdf and sfmpmath2.45e-131e-124 pass
Hypergeometric: sf and logsfexact sums2.28e-131e-122 pass

Errors are relative for values of at least 1e-300, relative errors of the logarithm for the log functions, and relative to max(1, |f|) for log-gamma, log-beta and digamma at negative x. The script checks its references too: the continued fraction for the incomplete beta agrees with mpmath’s own betainc to 6.6e-66 on the 1,554 points where both shapes are at most 1e3, and the log-gamma form of the first hypergeometric probability agrees with exact binomial coefficients to 3.0e-66 on the 421 points with populations up to 1e6. For information it also evaluates scipy 1.18.1 on the same points. scipy is more accurate on some checks, such as the beta cdf and sf (5.68e-15 and 9.99e-16, against 2.72e-14 and 8.58e-14 for CytePool) and the tails of the 2,000 moderated t and quasi-likelihood F statistics (at most 3.45e-15, against at most 1.25e-14). In the far tails of others it loses accuracy or returns values that are not finite: its largest errors are 1.05 for betainc, 1.00 for the t distribution’s cdf and 0.193 for gammainc (1.94e-13, 1.95e-13 and 1.26e-13 for CytePool), and it returns non-finite values at up to 493 points of a check (f_isf).

LOWESS and spline bases

validation/smoothing.py compares lowess with R’s stats::lowess and natural_spline_basis with splines::ns, on inputs from fixed seeds. LOWESS fits are compared relative to the largest absolute fitted value (close tier). Spline bases are compared through their column spaces: the least squares fits of random responses on either basis (exact tier), their ranks and their rows of NaN. Reference: R 4.4.3.

Check familyReferenceLargest differenceToleranceOutcome
LOWESS of mean-variance trends of 2,000 to 20,000 genes, as limma and voom smooth themlowess01e-65 pass
Spans from 0.1 to 1.7, 0 to 4 robustness iterations, delta of 0, the default, larger and beyond the rangelowess01e-613 pass
Ties: integer x, pairs, a tie block wider than the neighbourhood, ties at both endslowess01e-68 pass
1 to 5 pointslowess01e-66 pass
Heavy tails, constant x or y, zero median absolute residual, neighbourhoods whose weights are all zero, spans whose product with n is just below an integer, points at the cut-offs of the tricube and bisquare weights and of the local constant fitlowess01e-614 pass
x or y on large or small scaleslowess01e-63 pass
Fitted values identical to R’s to the last bitlowess51,905 of 51,905n/a1 information
Spline bases: df with and without an intercept, explicit and unsorted knots, boundary knots inside and beyond the datasplines::ns2.7e-151e-1015 pass
Repeated knots, ties, few distinct values (rank-deficient bases), quantile knots that fall on a boundarysplines::ns1.7e-151e-1015 pass
NaN in x, and covariates of 2,000 genessplines::ns2.7e-151e-104 pass
Inputs for which R fails, where CytePool must fail toosplines::ns0 mismatches04 pass
Deliberate differences, listed belowsplines::nsR returns a basis, CytePool raisesn/a2 deliberate differences

Deliberate differences: where R’s ns warns and increases a df below 1 plus the intercept, and where it accepts an interior knot on the lower boundary knot, returning a basis with a degenerate column, natural_spline_basis raises an error.

Timings

The scripts ran one after another on a laptop with an Intel Core Ultra 7 265U, under Linux 6.18 in WSL2 with 8 logical CPUs and 31 GiB of memory, with numba, OpenMP, OpenBLAS and MKL limited to 2 threads (NUMBA_NUM_THREADS=2 and so on). The machine was shared with other work, so timings are indicative.

from_cells on the 29,068 cells x 2,000 genes of pool_cells.py, best of three after a first run, building all five layers:

RunSeconds
from_cells, 2 threads0.092
from_cells, 1 thread0.171
from_cells, dense float32 input0.254
from_cells, first call (compiles unless cached)0.379
scanpy normalize_total, log1p and 3 aggregate calls (float32), warm0.282
decoupler pseudobulk (sums only), warm0.583

The peak resident memory of that whole run was 1,725 MiB, in its largest process.

anndata_conversion.py timed each conversion of a cube of counts and detected cells of 20 cell types x 50 samples x 20,000 genes (one call; to_anndata includes computing the fingerprint, which the cube then keeps):

ConversionSeconds
to_anndata0.278
from_anndata0.087
to_mudata0.729
from_mudata0.251

linear_models.py --benchmark on 20,000 genes x 200 samples (limma’s time as R measured it, CytePool’s the median of three runs after a warm-up), with a peak resident memory of 479 MiB in the largest process of the run. The scenarios with observation weights or missing values fit each gene on its own, on 2 threads here:

Scenariolimma lmFit (s)CytePool (s)
Ordinary least squares0.210.032
Observation weights0.970.173
5% missing values0.730.170
Block correlation0.130.090
Block correlation and observation weights10.810.194

mixed_models.py --benchmark on 20,000 genes x 120 samples with observation weights and the fixed effects ~ condition + age (3 conditions): CytePool on one thread, the median of three runs after a first call that compiled it, and lmerTest’s lmer and summary (Satterthwaite’s df for every coefficient, with lme4’s default tolerances) on 100 of the genes, after one fit that was not timed. The peak resident memory of the run was 424 MiB.

Random effectsfit_mixed_model (s)fixed_effect_tests (s)lmerTest, per gene (s)lmerTest, 20,000 genes at that rate (min)
One random intercept: 40 donors x 3 samples0.630.060.04515.1
Two crossed: 40 donors, 6 batches1.500.060.05217.5
Two nested: 20 donors x 3 samples x 2 replicates4.120.060.05117.0

adjust_p_values with Hommel’s method took 0.006 s on the 100,000 p-values of multiple_testing.py. In distributions.py, mpmath took 395 s to compute the references and CytePool 3.6 s to evaluate them, including compilation.

cube_reads.py read every layer of each selection of its cube file into memory with to_memory(). The file holds 720 MB of values in chunks of one group, every sample and 4,096 genes, 507 MB once compressed. Peak memory is the most that Python’s tracemalloc traced during a read, and time the best of three reads after a first one:

SelectionChunk files openedRead from disk (MB)Decompressed (MB)Result (MB)Peak memory (MB)Seconds
50 neighbouring genes4069.298.31.205.60.77
50 genes at random320507.1786.41.205.84.55
2,000 genes at random320507.1786.448.0055.83.02
50 genes at random, 2 groups3251.378.60.124.70.35
10 samples, every gene320507.1786.472.0081.33.18
one whole group1625.039.336.0040.50.20

A read holds what it returns and about one decompressed chunk (3.3 MB of log_sum). Genes at random lie in every chunk, so they decompress the whole file, and so does a selection of samples, since every chunk holds every sample. A read that covers a whole group is hashed, to check it against the digest stored with the file, and a selection’s groups are hashed as they are read, for the selection’s own fingerprint.

Reproducing this page

Run each script from the repository root, against the working tree, with PYTHONPATH=. and a Python that has numpy, numba, scipy and mpmath; RSCRIPT names the Rscript of the R environment, and the other variables name Python environments with the reference packages. The runs on this page used:

REF=/path/to/reference
export RSCRIPT=$REF/r/bin/Rscript PYTHONPATH=.
export NUMBA_NUM_THREADS=2 OMP_NUM_THREADS=2 OPENBLAS_NUM_THREADS=2 MKL_NUM_THREADS=2
python validation/multiple_testing.py
python validation/smoothing.py
python validation/normalization.py
python validation/design.py
python validation/linear_models.py
python validation/linear_models.py --benchmark
# lme4 with lmerTest, in the R environment of its own described below.
RSCRIPT=$REF/r-mixed/bin/Rscript python validation/mixed_models.py
RSCRIPT=$REF/r-mixed/bin/Rscript python validation/mixed_models.py --benchmark
python validation/empirical_bayes.py
python validation/gene_set_tests.py
python validation/rotation_gene_set_tests.py
python validation/voom.py
python validation/sample_quality_weights.py
python validation/negative_binomial_fits.py
python validation/quasi_likelihood.py
python validation/deseq2_tests.py
python validation/differential_expression.py
python validation/differential_expression_options.py
# Downloads 77 MB from GEO on its first run, into ~/.cache/cytepool-validation/GSE96583
# unless --data or GSE96583_DATA names another directory outside the repository.
python validation/interferon_stimulated_pbmcs.py
RSCRIPT=$REF/r-speckle/bin/Rscript python validation/composition_tests.py
RSCRIPT=$REF/r-metafor/bin/Rscript python validation/meta_analysis.py
RSCRIPT=$REF/r/bin/Rscript METAFOR_RSCRIPT=$REF/r-metafor/bin/Rscript \
    SPECKLE_RSCRIPT=$REF/r-speckle/bin/Rscript python validation/plotting.py
# variancePartition, in an R environment of its own with lme4 1.1-38 (below).
RSCRIPT=$REF/r-dream/bin/Rscript python validation/dream.py
python validation/distributions.py
REFERENCE_PYTHON=$REF/python-scanpy/bin/python python validation/cube.py
SCANPY_PYTHON=$REF/python-scanpy/bin/python \
    DECOUPLER_PYTHON=$REF/python/bin/python python validation/pool_cells.py
REFERENCE_PYTHON=$REF/python/bin/python $REF/python3.14/bin/python \
    validation/cube_files.py
python validation/cube_reads.py
python validation/cube_fingerprints.py
SCANPY_PYTHON=$REF/python-scanpy/bin/python DECOUPLER_PYTHON=$REF/python/bin/python \
    LIANA_PYTHON=$REF/python-liana/bin/python \
    $REF/python-anndata/bin/python validation/anndata_conversion.py

Here python is $REF/python/bin/python, the environment below. With --write-fixtures, a script also rewrites the small inputs and reference outputs under tests/data/ that the test suite compares against.

The R environment holds R 4.4.3 with limma 3.62.1, edgeR 4.4.0 and DESeq2 1.46.0 (Bioconductor 3.20) and lme4 2.0.6 (CRAN). It was installed from conda-forge and bioconda:

conda create -p $REF/r -c conda-forge -c bioconda r-base=4.4.3 \
    bioconductor-limma=3.62.1 bioconductor-edger=4.4.0 bioconductor-deseq2=1.46.0 \
    r-lme4=2.0_6

speckle 1.6.0 (Bioconductor 3.20), which composition_tests.py runs, has an R environment of its own, with R 4.4.3, limma 3.62.1 and jsonlite 2.0.0:

conda create -p $REF/r-speckle -c conda-forge -c bioconda r-base=4.4.3 \
    bioconductor-limma=3.62.1 bioconductor-speckle=1.6.0 r-jsonlite=2.0.0

mixed_models.py needs lmerTest, which runs on the same lme4 2.0.6 in an R environment of its own: R 4.4.3, lme4 2.0.6, lmerTest 3.2.1, numDeriv 2016.8.1.1 and jsonlite 2.0.0, from conda-forge:

conda create -p $REF/r-mixed -c conda-forge r-base=4.4.3 r-lme4=2.0_6 \
    r-lmertest=3.2_1 r-numderiv=2016.8_1.1 r-jsonlite=2.0.0

metafor 5.0.1 (CRAN), which meta_analysis.py runs, has one too, with R 4.4.3 and jsonlite 2.0.0:

conda create -p $REF/r-metafor -c conda-forge r-base=4.4.3 r-metafor=5.0.1 \
    r-jsonlite=2.0.0

dream.py runs variancePartition 1.36.2 (Bioconductor 3.20) with limma 3.62.1, in an R environment of its own on lme4 1.1-38, as variancePartition 1.36.2’s dream fails for every gene with lme4 2.0 (“Unable to extract deviance function from model fit”): R 4.4.3, lme4 1.1-38, lmerTest 3.1-3, pbkrtest 0.5.3, Matrix 1.7-4, RcppEigen 0.3.4.0.2, minqa 1.2.8, nloptr 2.2.1, BiocParallel 1.40.0 and jsonlite 2.0.0, from conda-forge and bioconda. lme4 1.1-35.5’s conda build crashed in lmer with these versions of Matrix and RcppEigen:

conda create -p $REF/r-dream -c conda-forge -c bioconda r-base=4.4.3 \
    bioconductor-variancepartition=1.36.2 bioconductor-limma=3.62.1 r-lme4=1.1_38 \
    r-lmertest=3.1_3 r-pbkrtest=0.5.3 r-matrix=1.7_4 r-rcppeigen=0.3.4.0.2 \
    r-minqa=1.2.8 r-nloptr=2.2.1 r-jsonlite=2.0.0

The Python environments use Python 3.13.12. scanpy has its own environment, and cube_files.py ran CytePool on Python 3.14.4 for the zstd cases:

uv venv $REF/python --python 3.13.12
uv pip install --python $REF/python/bin/python numpy==2.5.4 numba==0.68.0 \
    scipy==1.18.1 mpmath==1.3.0 zarr==3.4.1 numcodecs==0.17.1 pandas==3.1.0 \
    anndata==0.13.4 decoupler==2.2.0 matplotlib==3.11.2
uv venv $REF/python-scanpy --python 3.13.12
uv pip install --python $REF/python-scanpy/bin/python scanpy==1.12.4 \
    numpy==2.5.4 numba==0.68.0 scipy==1.18.1 pandas==3.1.0 anndata==0.13.4
uv venv $REF/python3.14 --python 3.14.4
uv pip install --python $REF/python3.14/bin/python numpy==2.5.4 numba==0.68.0

anndata_conversion.py runs CytePool in an environment with mudata, and LIANA in one of its own, since LIANA requires pandas 2:

uv venv $REF/python-anndata --python 3.13.12
uv pip install --python $REF/python-anndata/bin/python numpy==2.5.4 numba==0.68.0 \
    scipy==1.18.1 pandas==3.1.0 anndata==0.13.4 mudata==0.4.1 zarr==3.4.1 \
    h5py==3.16.0
uv venv $REF/python-liana --python 3.13.12
uv pip install --python $REF/python-liana/bin/python liana==1.10.0 decoupler==2.2.0

The reference packages run only as separate programs that produce outputs to compare against. Their source code is not read or copied: CytePool implements the methods from their papers and documentation, and conventions that the documentation leaves open were found by running the packages on chosen inputs.