SCTransform Tutorial — R Seurat vs Truecell (Python)¶
A Python port of Seurat's
sctransform vignette
on the PBMC 3k dataset. SCTransform replaces the
NormalizeData → FindVariableFeatures → ScaleData trio with a single
regularized negative-binomial model: counts are modelled per gene as a
function of cell sequencing depth, the per-gene parameters are smoothed across
genes, and the model's Pearson residuals become the normalized values. The
vignette's point is that this removes technical effects more effectively, so —
run over more PCs (dims 1:30) — it resolves finer immune subsets.
Dataset: 3k PBMCs — 10x Genomics (2016) Python: Truecell v0.9.0
python tutorials/pbmc3k_sctransform_tutorial.py # printed validation + the model handoff
python tutorials/generate_sctransform_plots.py # writes figures_sctransform/ (Truecell)
Rscript tutorials/pbmc3k_sctransform_verify.R # R figures + r_sct_model.csv / r_anchors.json
python tutorials/pbmc3k_sctransform_tutorial.py --report # the side-by-side, gene by gene
Most R figures below link the canonical
Seurat vignette
images; the two the vignette omits (cell-type UMAP, SCT-vs-standard) are
generated locally by pbmc3k_sctransform_verify.R.
Step 1 · Load data & QC metric¶
Step 2 · SCTransform (one call replaces three)¶
SCTransform regresses percent.mt out of the residuals and returns 3,000
variable features in a new SCT assay.
| R (Seurat) | Python (Truecell) |
|---|---|
Truecell's
sctransformfollows R's algorithm step for step, in pure NumPy: a vectorised per-gene GLM,theta.mlfor the NB dispersion, and regularization of the parameters across genes by Nadaraya–Watson smoothing against the log10 geometric mean, with a Sheather–Jones bandwidth. Like Seurat 5 it defaults tovst.flavor="v2"(vst_flavor="v2") — depth slope fixed atlog(10), non-overdispersed genes modelled as pure Poisson, and a variance floor — withvst_flavor="v1"available for the original 2019 model.scale.dataholds the clipped Pearson residuals for the 3,000 variable features (a genuine feature-subset layer).
Step 3 · PCA → UMAP → clustering over 30 PCs¶
The T-cell mass resolves as a cytotoxicity gradient — Naive CD4 → Memory CD4 → CD8 Effector → NK — alongside two monocyte types, B cells, DC/pDC, and platelets. Annotating by relative marker enrichment gives:
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
The published Seurat vignette stops at clusters and prints no cell-type-annotated plot, so the R panel is generated by
pbmc3k_sctransform_verify.R: the same SCT workflow, annotated by the same relative marker-enrichment rule Truecell uses. Both resolve the fine T subsets SCTransform is meant to sharpen.
Step 4 · Marker feature plots (the vignette panels)¶
CD8A/GZMK/CCL5 mark the CD8 effector tip; CCR7 marks the naive end;
S100A4/ANXA1 mark memory T cells; FCGR3A marks the CD16⁺ monocytes and NK;
TCL1A/FCER2 pick out B-cell sub-structure — matching the vignette.
Step 5 · Violin plots¶
Both panels plot the SCT
datalayer (log1pof corrected counts) — R'sVlnPlotdefault for an SCT assay — with cells jittered over each violin. The distributions track gene-for-gene:CD8A/GZMKspike on the cytotoxic CD8 cluster,CCL5across the CD8/NK end,CD3Dover all T clusters,CCR7low and naive-restricted. The x-axes differ by one column — Truecell resolves 13 clusters (0–12) here versus the vignette's 12 (0–11) — and cluster numbering is not shared across the two plots anyway, so compare the per-gene shapes, not column positions. (See the accuracy note below.)
Step 6 · SCTransform vs standard log-normalization¶
The published Seurat vignette does not include this comparison figure, but both toolkits can produce it — running SCTransform (dims 1:30) and LogNormalize (dims 1:10) on the same cells and rendering them side by side for a direct view of the resolution difference:
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
Each panel: left = SCTransform (dims 1:30), right = LogNormalize (dims 1:10). The R panels come from
pbmc3k_sctransform_verify.R.
Accuracy vs the R vignette¶
| Aspect | R Seurat (vignette) | Truecell | Match |
|---|---|---|---|
| Normalization model | NB Pearson residuals | NB Pearson residuals | ✅ |
| Variable features | 3,000 | 3,000 | ✅ |
| PCs used | 30 (dims 1:30) | 30 (dims 1:30) | ✅ |
vars.to.regress |
percent.mt |
percent.mt |
✅ |
| Major populations | T, NK, B, 2× Mono, DC, platelet | all recovered | ✅ |
| CD8 effector split (CCL5/GZMK) from CD4 | yes | yes | ✅ |
| Naive vs memory CD4 (CCR7 vs S100A4) | yes | yes | ✅ |
vst.flavor default |
v2 | v2 | ✅ |
| Resolves more than log-norm | yes (the vignette's claim) | yes — 12 vs 11 | ✅ |
| Clusters at resolution 0.8 | 12 (live run; vignette prints none) | 12 | ✅ |
| Clusters, LogNormalize arm | 11 | 11 | ✅ |
| Variable features shared with R | 3,000 | 2,913 (97.1%) | ✅ |
| Regularized theta vs R (Spearman) | — | 1.0000 over the 8,724 finite thetas | ✅ |
| Genes v2 calls non-overdispersed | 3,848 | 3,848 — the same genes (Jaccard 1.0000) | ✅ |
| Regularized intercept vs R (Spearman) | — | 1.0000 | ✅ |
| Residual variance vs R (Spearman) | — | 0.9986 (Pearson 0.9996) | ✅ |
detection_rate · gmean vs R |
— | max abs diff 5.0e-16 · 1.2e-12 | ✅ |
Residual clips (sqrt(N) · sqrt(N/30)) |
±51.9615 · ±9.4868 | identical | ✅ |
These numbers are reproduced, not recorded. Everything in the table above comes out of a comparison you can re-run:
python tutorials/pbmc3k_sctransform_tutorial.py # writes py_sct_model.csv + py_anchors.json
Rscript tutorials/pbmc3k_sctransform_verify.R # writes r_sct_model.csv + r_anchors.json
python tutorials/pbmc3k_sctransform_tutorial.py --report
The R script dumps Seurat's own SCTModel.list[[1]]@feature.attributes — the
fitted model, per gene — and truecell stores the same eight columns under the same
names on the SCT assay's meta_data, so the two tables line up directly. That
matters more here than anywhere else in the series: this model was once wrong in
four separate ways at once and the tutorial still drew a perfectly plausible
UMAP. A picture cannot fail; a Spearman of −0.89 can.
Where it matches. The model, the 3,000 variable features, the 30-PC
embedding, and the biology all reproduce: the CD8-effector / CD4 / NK split
and the marker patterns the vignette highlights are all recovered. Against a live
Seurat 5.5.1 / sctransform 0.4.3 run on the same cells, detection_rate and
gmean agree to machine precision, the regularized intercept and theta both rank
identically (Spearman 1.0000), the 3,848 genes v2 declares non-overdispersed are
exactly the same genes on both sides, and both arms of the workflow now land on
the same cluster counts — 12 under SCTransform, 11 under LogNormalize.
Where it differs. Two places, both small and both expected. Residual variance
ranks at Spearman 0.9986 rather than 1, which moves 87 of the 3,000 variable
features (98 of the top 100 still agree); vst samples its 2,000 step-1 genes at
random, so R does not reproduce itself across seeds here either. And
residual_mean is the one column that does not track by rank — Spearman 0.71
against Pearson 0.99. It is also the one column nothing downstream reads: Seurat
records it but selects features on residual_variance. The rank disagreement
sits entirely in genes whose residual mean is ~1e-3 or smaller, which is
numerical dust on residuals that range to ±52.
The ±1 cluster gap this section used to describe is closed. It read "13 against R's 12" for a long time, attributed to the RNG and to the different clustering libraries. Both arms now agree exactly. What changed was not SCTransform but the graph underneath it — PR #55 stored Seurat's directed kNN graph, restored the SNN self-edge and added
GroupSingletons.This was wrong until recently. Truecell used to resolve 9 clusters here — fewer than log-normalization's 11, which inverts the vignette's entire point. A moment estimator stood in for
theta.ml, the regularization smoothed against the arithmetic rather than geometric gene mean and targetedlog(theta)rather than the overdispersion factor, and residual variance was computed from residuals clipped atsqrt(N/30)instead ofsqrt(N). The result flattened every residual: the regularized theta came out anti-correlated with R's (Spearman −0.89) and only 414 of 3,000 variable features agreed. See the CHANGELOG andtests/test_sctransform_r_fidelity.py.
API Translation (SCTransform additions)¶
| Task | R (Seurat) | Python (Truecell) |
|---|---|---|
| SCTransform | SCTransform(obj, vars.to.regress="percent.mt") |
sctransform(obj, vars_to_regress=["percent.mt"]) |
| Model flavor | SCTransform(obj, vst.flavor="v2") (default) |
sctransform(obj, vst_flavor="v2") (default) |
| Use SCT assay | DefaultAssay(obj) <- "SCT" (automatic) |
active assay set to "SCT" automatically |
| Variable features | VariableFeatures(obj) |
obj.assays["SCT"].variable_features |
| Residuals | GetAssayData(obj, "scale.data") |
obj.assays["SCT"].layers["scale.data"] |
| Per-gene model fit | SCTResults(obj, slot="feature.attributes") |
obj.assays["SCT"].meta_data (theta, residual_variance, gmean) |
References¶
Hafemeister C, Satija R (2019). Normalization and variance stabilization of single-cell RNA-seq data using regularized negative binomial regression. Genome Biology 20, 296. https://doi.org/10.1186/s13059-019-1874-1
Choudhary S, Satija R (2022). Comparison and evaluation of statistical error models for scRNA-seq. Genome Biology 23, 27. (sctransform v2)
Seurat sctransform vignette: https://satijalab.org/seurat/articles/sctransform_vignette











