The Differential-Expression Test Suite — R Seurat vs Truecell (Python)¶
Wave 3's first side-by-side, and the last large untested surface in the library:
find_markers offers eight statistical tests and none of them had ever been
compared to R. Their unit tests assert self-consistency on synthetic fixtures —
the same shape of coverage that let the CLR and SCTransform defects survive.
Dataset: pbmc3k — 2,700 PBMCs, 10x Genomics (2016). The comparison runs on clusters 0 and 1 (692 and 515 cells, 13,714 genes). R reference: Seurat 5.5.1 · MAST 1.38.0 · DESeq2 1.52.0 · Python: Truecell
| Seurat | Truecell |
|---|---|
FindMarkers(obj, test.use = "wilcox") |
find_markers(obj, test_use="wilcox") |
test.use = "t" · "bimod" · "LR" |
test_use="t" · "bimod" · "LR" |
test.use = "negbinom" · "roc" |
test_use="negbinom" · "roc" |
test.use = "MAST" |
test_use="mast" |
test.use = "DESeq2" |
test_use="deseq2" |
This tutorial found and fixed two defects.
avg_log2FCput Seurat's pseudocount on the group mean rather than the group sum, which floored every fold change and — becauselogfc_thresholdfilters on that value — changed which genes were returned at all. Andnegbinomran a moment-dispersion likelihood-ratio test where Seurat runs an ML-dispersion Wald test. Both are written up in what this tutorial found.
Both tools test the same cells: Python clusters pbmc3k and writes the
assignment to figures_de/groups.csv, which the R side reads. Louvain numbering
is not guaranteed to agree across implementations, and a clustering difference
would look exactly like a DE difference.
Headline¶
| Metric | Result |
|---|---|
avg_log2FC vs Seurat, all 13,712 shared genes |
max abs diff 6.44e-15 |
| Tests reproducing Seurat's top 50 genes | 7 of 7 per-cell tests (roc scores AUC, not p) |
wilcox · t · bimod · LR — p-value Spearman |
1.000000 · 0.999980 · 0.999994 · 0.999975 |
mast — Spearman (all genes / detected >5%) |
0.9471 / 0.9979 |
negbinom — Spearman (all genes / detected >5%) |
0.6943 / 0.9165 |
roc — max abs AUC difference |
5.0e-04, which is Seurat's own 3-dp rounding |
Before the fix — genes returned at logfc_threshold=0.25 |
truecell 2,298 vs Seurat 11,931 (Jaccard 0.193) |
Setup¶
Both sides then run with logfc.threshold = 0 and min.pct = 0, so the
comparison sees every gene rather than only those surviving a filter whose
input is one of the numbers under test. Seurat's defaults would have hidden
exactly the disagreement worth seeing.
Running the tests¶
Both columns are identical to seven significant figures — the same p-values and the same fold changes, on the same cells.

What this tutorial found¶
1. avg_log2FC put the pseudocount in the wrong place¶
Seurat 5's log1pdata.mean.fxn is, verbatim:
One pseudocount added to the group's sum, then divided by n — so on the mean
scale it is worth 1/n, not 1. truecell computed log2(mean(expm1(x)) + 1),
adding a whole count to the mean. That is Seurat 4's formula; the repo
targets Seurat 5.
The effect is to floor every fold change toward zero. A gene detected in 0 % of cluster 0 and 24 % of cluster 1 read −1.26 where Seurat reads −9.92.

Why this is a defect and not a cosmetic difference. logfc_threshold
filters on this value, so the error did not merely misreport fold changes — it
changed which genes came back:
logfc_threshold |
truecell (before) | Seurat | Jaccard |
|---|---|---|---|
| 0.1 (Seurat's default) | 4,903 | 13,009 | 0.377 |
| 0.25 | 2,298 | 11,931 | 0.193 |
| 0.5 | 981 | 10,228 | 0.096 |
| 1.0 | 335 | 6,928 | 0.048 |
At a common 0.25 threshold, fewer than one gene in five agreed.

The most telling part: where both groups express a gene, the two formulas nearly agree (Spearman 0.990 on the 1,362 genes with pct > 0.1 in both). The error was concentrated in sparse, marker-like genes — precisely what differential expression exists to find. After the fix, 6.44e-15 across all 13,712 genes.
There was already a test for this. test_avg_log2fc_matches_seurat_formula
re-implemented the same wrong formula and checked that truecell agreed with
itself. It was green throughout while carrying a name that claimed Seurat
parity — the same shape as #48's test_fetch_data. It is corrected here.
2. negbinom was running a different test¶
Seurat's GLMDETest fits MASS::glm.nb — which estimates the dispersion by
maximum likelihood — and reads the Wald p-value off the group
coefficient (summary(...)$coef[2, 4]). truecell used a fixed method-of-moments
dispersion and a likelihood-ratio test: a different estimator and a
different statistic. HLA-DRA read 5.5e-128 against R's 1.1e-321.
After the fix the p-values agree exactly for every gene anyone would look at:
| detection (max of the two groups) | genes | median |log10 ratio| | Spearman |
|---|---|---|---|
| > 25 % | 980 | 0.000 | 0.988 |
| 10 – 25 % | 1,519 | 0.000 | 0.933 |
| 5 – 10 % | 1,919 | 0.000 | 0.773 |
| 1 – 5 % | 5,120 | 0.063 | 0.285 |
| < 1 % | 1,928 | 0.144 | 0.085 |
What disagreement remains sits below 5 % detection, where the negative-binomial
GLM is fitting almost-empty rows and neither tool is estimating anything
meaningful. Seurat agrees: its min.cells.feature default drops those genes, and
in this run R returned 11,466 genes against truecell's 13,714 — every one of the
2,248 it dropped was below 1 % detection in both groups (the highest reached
0.4 %). The headline Spearman of 0.69 is that tail; on genes Seurat would
actually have tested, it is 0.92.
Differences left standing, and why¶
deseq2 is not Seurat's DESeq2. DESeq2DETest builds a DESeqDataSet with
one column per cell and tests cells as replicates. truecell sums counts per
sample and tests at the sample level. Treating cells as replicates is the
practice Squair et al. (2021)
showed inflates false positives, so pseudobulk is the better statistics — and
because it requires sample_col, it cannot be silently mistaken for the
per-cell test: it raises. Reported rather than changed in either direction.
mast is a hand-rolled hurdle model, not a call to the MAST package, which
has no Python equivalent to depend on. Spearman 0.947 across all genes, 0.9979
on genes detected above 5 %, and the same top 50. Worth knowing: Seurat's
MASTDETest fits ~ condition alone — it adds no cellular detection rate
term unless you pass one. truecell's docstring previously advised passing CDR "to
match Seurat's default CDR covariate", which had it backwards; that is corrected.
Seurat rounds myAUC to three decimals inside DifferentialAUC, so the ROC
comparison cannot be tighter than 5e-4 however correct both sides are. That is
R's rounding, not a divergence — stated because it looks like one.
R's wilcox returns NaN for the 365 genes with no expression in either
group; truecell returns p = 1. A test that cannot be run has no evidence against
the null, so 1 is the more useful answer, and R's NaN set is a subset of
truecell's.
R also returns exactly 0 for 172 genes, and that is not double underflow:
truecell scores 92 of them above 1e-50, the largest — SEPT1 — at 9.3e-17. It is
not Seurat's wrapper either. Calling base R's wilcox.test on that gene's
normalised row directly, outside Seurat, returns 0 as well (W = 212196.5 on
692 vs 515 cells). Both R's NaN rows and its zero rows are excluded from every
correlation reported here, since neither carries a rank — but the zeros are
worth knowing about, because they are not the harmless precision limit the
previous version of this note called them.
Parity — verified against R Seurat¶
| Test | Genes | max |Δlog2FC| | p Spearman (all) | p Spearman (detected >5 %) | Top 50 |
|---|---|---|---|---|---|
wilcox |
13,712 | 6.2e-15 | 1.000000 | 1.0000 | 50/50 |
t |
13,712 | 6.2e-15 | 0.999980 | 1.0000 | 50/50 |
bimod |
13,712 | 6.2e-15 | 0.999994 | 1.0000 | 50/50 |
LR |
13,712 | 6.2e-15 | 0.999975 | 1.0000 | 50/50 |
negbinom |
11,466 | 6.2e-15 | 0.694340 | 0.9165 | 50/50 |
roc |
13,712 | 6.2e-15 | AUC 5.0e-04 | — | — |
mast |
13,712 | 6.2e-15 | 0.947038 | 0.9979 | 50/50 |
deseq2 |
13,712 | 3.47 | 0.476942 | 0.1959 | 22/50 |
The last digits of these moved slightly when the CSV round-trip was fixed (see The two columns a person actually reads, below): they had been read back through a misparsing float reader. The change is at the ULP level and no band moved, but the table is the measured one, not the previous one.
deseq2's row is the pseudobulk-vs-per-cell divergence described above, not a
defect; its fold change differs too because a pseudobulk fold change is computed
on summed counts.
The two columns a person actually reads¶
The table above scores the statistic. It does not score the two numbers someone annotating clusters looks at: the fold change they sort by, and the adjusted p-value they threshold on. Neither was compared until an expert reviewer pointed out that the max-difference bound and a set overlap answer neither question.
| Test | logFC Spearman | logFC Kendall | Top 50 by |logFC| | p_val_adj to 6 s.f. |
Same call at 0.05 | Genes differing |
|---|---|---|---|---|---|---|
wilcox |
1.000000 | 1.000000 | 50/50 | 0.9933 | 1.0000 | 0 |
t |
1.000000 | 1.000000 | 50/50 | 0.9999 | 1.0000 | 0 |
bimod |
1.000000 | 1.000000 | 50/50 | 0.9997 | 0.9999 | 2 |
LR |
1.000000 | 1.000000 | 50/50 | 0.9915 | 1.0000 | 0 |
negbinom |
1.000000 | 1.000000 | 50/50 | 0.8547 | 0.9958 | 48 |
roc |
1.000000 | 1.000000 | 50/50 | — | — | — |
mast |
1.000000 | 1.000000 | 50/50 | 0.7984 | 0.9895 | 144 |
deseq2 |
0.966510 | 0.847811 | 34/50 | 0.4314 | 0.8938 | 1,401 |
Rank correlation is reported alongside the max-difference bound rather than instead of it, because the two fail differently. A uniform scale error leaves every rank perfect and blows up the max; a handful of swapped mid-table genes leaves the max tiny and moves the ranks. Here both are clean: fold-change order is preserved exactly for all seven cell-level tests.
Do identical adjusted p-values occur? Mostly not, and the reason is worth
stating rather than the rate. The correction is identical — both tools compute
min(p × 13,714, 1), which holds bit-exactly on both sides — but the raw
p-values feeding it differ by up to 0.54 % relative on wilcox, a real
difference between SciPy's Wilcoxon and Seurat's, so the product rarely lands on
the same double. What survives that is what matters: the ordering is exact, and
every gene falls on the same side of 0.05 for wilcox, t and LR.
Two traps in measuring this, both of which had to be fixed before the numbers above meant anything:
- Seurat clamps
p_val_adjat 1, and 11,858 of 13,712 genes land there. A bare "fraction identical" therefore reads ≈0.87 before a single interesting gene is considered.--reportscores the unclamped subset separately. - Neither side's CSV round-tripped a float64. R's
write.csvrenders 15 significant digits, and raising it does not help — R's ownsprintf("%.17g")is not correctly rounded and emits digits denoting a different double. Pandas'read_csvmisparses about a third of random doubles by an ULP at its defaultfloat_precision. So the R side now also writesr_<test>_exact.csvin C99 hex float (%a, a transcription of the IEEE-754 bits rather than a decimal approximation of them), and the Python side reads withfloat_precision="round_trip". Before that, an "is it identical" comparison was measuring the two languages' text formatters.
These numbers are asserted, not just printed¶
Every row above used to live only in this file, so nothing failed when one
moved. deseq2's overlap was written here as 25/50 and had drifted to 22 —
the clusters this tutorial tests are found by find_clusters, and the graph
fixes in #67-#71 moved a few cells between them. That is a legitimate reason for
the number to change, which is exactly why it needed a band rather than a
sentence: a regression landing on 22 would have read the same way.
--report now checks each number against a declared range and exits non-zero
if one falls outside:
| band | range | why |
|---|---|---|
| top 50, the seven cell-level tests | = 50 | Same statistic, same cells. One dropped gene is a regression. |
top 50, deseq2 |
15 – 32 | A divergence measurement. 20–26 over 20 resampled replicate splits; 25 on the previous clustering. Bounded well below 50 — reaching parity would mean sample_col had stopped being honoured. |
p Spearman >5 %, wilcox/t/bimod/LR |
≥ 0.9999 | Measured at exactly 1.0. |
p Spearman >5 %, negbinom |
≥ 0.88 | Same model, different optimiser: 0.9165. |
p Spearman >5 %, mast |
≥ 0.99 | A hand-rolled hurdle model, not the MAST package: 0.9979. |
p Spearman >5 %, deseq2 |
0.12 – 0.30 | Pseudobulk against per-cell; a high value here would be the surprise. |
| max |Δlog2FC|, cell-level tests | ≤ 1e-12 | Arithmetic on the shared matrix. deseq2 is excluded by name, not by threshold — its 3.47 is correct and would otherwise set everyone else's tolerance. |
max |ΔAUC|, roc |
≤ 5e-4 | Half a unit in Seurat's third decimal. Measured 4.9986e-4, i.e. on the boundary. |
The reference has to be the one the handoff asked for¶
--report also refuses to compare against an R run that predates the
groups.csv it was supposed to answer. This is not hypothetical: on the working
copy where these bands were written, the Python tables were from 25 July and the
R tables from 19 July, taken on a different cluster assignment — and the
report printed a full parity table showing wilcox at 48/50 and a Spearman of
0.907. Nothing in it said "stale file"; it read as a regression in the port.
The check is pct.1 and pct.2. They are counts of detected cells per group
with no statistics in the way, so two runs over the same handoff agree to
Seurat's three-decimal rounding and no worse. They differed for 12,491 of
13,712 genes.
Runtime, for scale: Seurat's slowest test here is negbinom at 91.8 s
(MAST 60.5 s, DESeq2 60.3 s); truecell's are 36.0 s, 28.5 s and 3.4 s.
Running it¶
# 1. Python side — writes figures_de/groups.csv and py_<test>.csv
python tutorials/pbmc3k_de_tutorial.py
# 2. R side — writes figures_de/r_<test>.csv (needs MAST + DESeq2)
Rscript tutorials/pbmc3k_de_verify.R
# 3. Compare
python tutorials/pbmc3k_de_tutorial.py --report
# 4. Figures
python tutorials/generate_de_plots.py
The R side needs MAST and DESeq2:
Not glmGamPoi. It is a Suggests of DESeq2 rather than an Imports,
FindMarkers never calls it, and installing it flips sctransform's vst onto
a different backend — which would move the SCTransform R reference that
sctransform_vignette.md is pinned against.
Installing with default dependencies leaves Suggests alone, which is what you
want. This was verified rather than assumed: an SCTransform fingerprint taken
before and after the install is byte-identical.