PBMC 3k Tutorial — R Seurat vs Truecell (Python)¶
A step-by-step translation of the official Seurat PBMC 3k tutorial into Python using the Truecell package. Every R code block is paired with the equivalent Python code and both plots are shown side by side so R users can follow along directly.
Dataset: 3k PBMCs from a Healthy Donor — 10x Genomics (2016)
R reference: Seurat v5 · Hao et al. 2024
Python: Truecell v0.1.0
Setup¶
Key difference: R plots render to the graphics device automatically. Truecell functions return a
matplotlib.Figure— display it in a Jupyter notebook (it shows inline), or save it withfig.savefig("out.png").
Step 1 · Load Data¶
| R (Seurat) | Python (Truecell) |
|---|---|
Step 2 · Create Object¶
Naming:
CreateSeuratObject→create_truecell_object. Arguments use_instead of.(min.cells→min_cells). The returned object exposes the same slots:pbmc.meta_data,pbmc.assays, etc.
Step 3 · QC Metrics & Violin Plot¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
vln_plotaccepts both gene names and metadata column names asfeatures. Thegroup_byargument (default: active idents) controls the x-axis grouping — same as R'sgroup.by.
Step 4 · QC Scatter Plots¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
R uses
patchwork's+operator to combine plots. In Python, passfigsizeor usematplotlib.gridspecto arrange figures manually.
Step 5 · Filter Cells¶
| R (Seurat) | Python (Truecell) |
|---|---|
subset()works in both languages. Python uses boolean pandas indexing; the result is identical (2,638 cells).
Step 6 · Normalise Data¶
| R (Seurat) | Python (Truecell) |
|---|---|
Both apply
log1p(counts / total_counts × 10,000). Result is stored in thedatalayer of the active assay.
Step 7 · Highly Variable Features¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
Both plots use Standardized Variance on the y-axis, matching R's
VariableFeaturePlot. Truecell reproduces Seurat'svstalgorithm faithfully: it fits the mean–variance LOESS on the raw counts, then ranks genes by the variance of the standardized values after clipping each tosqrt(n_cells)(the clip step that stops single-cell outliers from dominating). R'sLabelPoints(withggrepel) avoids label overlaps; Truecell usesmatplotlib.annotatewhich may overlap in dense regions. Top-10 gene overlap: 9/10 (90%) — the samePPBP, LYZ, S100A9, IGLL5, GNLY, FTL, PF4, FTH1, S100A8HVGs as the R tutorial;GNG11sits just outside the top 10 (rank 11). Minor rank differences come from the LOESS implementation (R: Fortran; Python: local quadratic fit).
Step 8 · Scale Data¶
| R (Seurat) | Python (Truecell) |
|---|---|
Step 9 · PCA¶
Both show horizontal bar charts of top loading genes per PC. The same myeloid genes dominate PC1 positively (CST3, TYROBP, LST1, AIF1) and T-cell genes dominate PC1 negatively (IL7R, LTB, IL32).
Step 10 · Elbow Plot — Choose Dimensionality¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
| PC | R stdev | Truecell stdev |
|---|---|---|
| 1 | ~6.8 | 6.766 |
| 2 | ~4.8 | 4.808 |
| 10 | ~1.7 | 1.684 |
Step 11 · Find Neighbors & Cluster¶
| R (Seurat) | Python (Truecell) |
|---|---|
Both use Louvain community detection via
igraph. Atresolution = 0.5Seurat returns 9 clusters here and truecell 8 — the two runs agree about 2,519 of the 2,638 cells (ARI 0.899), and the single cluster Seurat has and truecell does not is a 32-cell dendritic-cell population whose cells land, all 32 of them, in truecell's CD14+ Mono cluster. DCs are monocyte-lineage, so this is one borderline split at this resolution, not a scattered disagreement.
The whole sweep, not one point on it¶
Picking a resolution means running a few and comparing them, so a fidelity claim
pinned to a single setting says less than it looks like it does. The tutorial
runs find_clusters(pbmc, resolution=[0.4, 0.8, 1.2, 0.5]) — Seurat's own
vector idiom — and scores every one against R:
| resolution | truecell | Seurat | ARI | concordance |
|---|---|---|---|---|
| 0.4 | 9 | 9 | 0.8958 | 0.9602 |
| 0.5 | 8 | 9 | 0.8987 | 0.9549 |
| 0.8 | 11 | 11 | 0.8264 | 0.9174 |
| 1.2 | 12 | 12 | 0.7995 | 0.8647 |
Two things worth reading off this. The cluster count matches exactly at 0.4, 0.8 and 1.2 — the 8-vs-9 split described above is specific to resolution 0.5, not a standing property of the port. And agreement falls as resolution rises (0.90 → 0.83 → 0.80), which is what you would expect: finer partitions put more boundaries in play, and each is another chance for the two Louvain runs to land in different local optima.
0.5 is given last on purpose. Seurat leaves the object on the last resolution in the sequence, so every step below — UMAP, markers, annotation, the handoff — sees exactly the partition it saw before this sweep existed. The four outputs this tutorial writes are byte-identical to what it produced without it.
Each ARI now carries a declared band (CLUSTER_BANDS), for a reason: this
file documented ARI 0.938 at resolution 0.5 while measuring 0.899, across six
documents. The number had drifted — most likely with the graph fixes in #67–#71,
which moved cells between clusters — and with no band on it, nothing failed.
That is the same drift the DE tutorial's deseq2 top50 band did catch at the
time (25 → 22).
Where it comes from is traceable: the two runs keep the same 2,638 barcodes and agree on the per-gene VST means to 4.8e-14 and observed variances to 5.1e-14, but
variance.standardizeddepends on a degree-2 LOESS fit that differs by up to 2.6e-2 relative — andvariance.expected, the fit itself, differs by 2.5e-2, so the disagreement is the fit and only the fit. That flips 2 of the 2,000 variable features (both at ranks 1,982–2,000, and 0.03 apart in the fit), which moves the PCA slightly — matched |r| 0.9988 over the 10 dims clustering uses — which moves 286 of ~194,000 SNN edges, which moves this one boundary.Run
Rscript tutorials/pbmc3k_verify.Rthenpython tutorials/pbmc3k_tutorial.py --reportto reproduce every number in this note. Tutorial 2 shows the same boundary going the other way, with truecell resolving a DC population that Seurat merges.
Step 12 · UMAP¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
Note — UMAP layout difference is expected, not a bug. R Seurat uses the
uwotUMAP implementation while Python usesumap-learn. Both libraries apply the same UMAP algorithm but differ in initialisation strategy, nearest-neighbour graph construction, and optimisation details. As a result, the spatial arrangement of clusters will look different even with the same random seed — clusters may be rotated, reflected, or positioned further apart. This is a known and well-documented difference between the two libraries and does not indicate an error in the analysis. The important thing is that the same 9 biologically meaningful clusters are recovered in both. Cluster label numbers may also differ (Louvain assigns IDs by graph traversal order) but the cell-type groupings are identical.
Step 13 · Feature Plots — Canonical Markers¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
Note — minor visual differences are expected, not bugs. Both plots use the same yellow→red expression gradient and the
order=Truebehaviour (high-expression cells drawn on top). Cells with zero expression are rendered in light gray in both R and Truecell, making the expressing cells stand out clearly. The spatial layout of the underlying UMAP differs for the same reason described in Step 12 (different UMAP implementations), but the gene expression patterns are biologically identical: MS4A1/CD79A in B cells, NKG7/GNLY in NK cells, LYZ/FCGR3A in monocytes, PPBP in platelets, CD8A in CD8 T cells, and IL7R in T cells.
Step 14 · Violin Plots — Marker Expression¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
![]() |
![]() |
Both show cluster-specific marker expression. The
layerargument in Truecell maps directly to Seurat'sslotargument.
Step 15 · Find Markers¶
Argument names use
_instead of.(ident.1→ident_1,only.pos→only_pos). The returnedDataFramehas the same columns:p_val,avg_log2FC,pct.1,pct.2,p_val_adj,cluster,gene.
Step 16 · Expression Heatmap¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
do_heatmapautomatically sorts cells by cluster and draws a coloured cluster bar at the top — matching R'sDoHeatmaplayout.NoLegend()in R is handled bydo_heatmap's internal layout.
Step 17 · Cell Type Annotation¶
Cluster index ordering differs between R and Truecell (Louvain numbers its clusters by size, and the sizes are not identical), so the cluster-to-cell-type mapping uses different numeric keys. Eight labels here against Seurat's nine: the DC population is inside CD14+ Mono at this resolution, so the last cluster is the platelets — see Step 11's note. Every other type appears on both sides, on cells that largely agree (ARI 0.899).
Step 18 · Ridge Plot (Bonus)¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
The published Seurat vignette prints no RidgePlot, so the R panel here is generated by
pbmc3k_verify.R(standard PBMC 3k workflow). R'sRidgePlotuses theggridgespackage; Truecell implements equivalent KDE ridgelines usingscipy.stats.gaussian_kde.
Quick Reference — API Translation¶
| Task | R (Seurat) | Python (Truecell) |
|---|---|---|
| Create object | CreateSeuratObject(counts, min.cells, min.features) |
create_truecell_object(counts, min_cells, min_features) |
| % mito genes | PercentageFeatureSet(pbmc, pattern="^MT-") |
percentage_feature_set(pbmc, pattern=r"^MT-", col_name=...) |
| Normalise | NormalizeData(pbmc, normalization.method, scale.factor) |
normalize_data(pbmc, normalization_method, scale_factor) |
| HVGs | FindVariableFeatures(pbmc, selection.method, nfeatures) |
find_variable_features(pbmc, selection_method, nfeatures) |
| Scale | ScaleData(pbmc, features) |
scale_data(pbmc, features) |
| PCA | RunPCA(pbmc, features, npcs) |
run_pca(pbmc, features, n_pcs) |
| Neighbors | FindNeighbors(pbmc, dims) |
find_neighbors(pbmc, dims, k_param) |
| Cluster | FindClusters(pbmc, resolution) |
find_clusters(pbmc, resolution, algorithm) |
| UMAP | RunUMAP(pbmc, dims) |
run_umap(pbmc, dims) |
| Markers | FindMarkers(pbmc, ident.1) |
find_markers(pbmc, ident_1) |
| All markers | FindAllMarkers(pbmc, only.pos, logfc.threshold) |
find_all_markers(pbmc, only_pos, logfc_threshold) |
| Rename idents | RenameIdents(pbmc, new.ids) |
pbmc.rename_idents(mapping_dict) |
| Subset | subset(pbmc, subset = condition) |
pbmc.subset(cells=keep_list) |
| Get expression | FetchData(pbmc, vars) |
pbmc.fetch_data(vars) |
| Access metadata | pbmc@meta.data |
pbmc.meta_data |
| Access assay | pbmc[["RNA"]] |
pbmc.assays["RNA"] |
| Active idents | Idents(pbmc) |
pbmc.idents |
Plotting API Translation¶
| R (Seurat) | Python (Truecell) | Key argument changes |
|---|---|---|
VlnPlot(pbmc, features, ncol, slot) |
vln_plot(pbmc, features, ncol, layer) |
slot → layer |
FeaturePlot(pbmc, features, order) |
feature_plot(pbmc, features, order) |
same |
DimPlot(pbmc, reduction, label, pt.size) |
dim_plot(pbmc, reduction, label, pt_size) |
. → _ |
ElbowPlot(pbmc) |
elbow_plot(pbmc) |
same |
FeatureScatter(pbmc, feature1, feature2) |
feature_scatter(pbmc, feature1, feature2) |
same |
VariableFeaturePlot(pbmc) |
variable_feature_plot(pbmc) |
same |
VizDimLoadings(pbmc, dims, reduction) |
viz_dim_loadings(pbmc, dims, reduction) |
same |
DimHeatmap(pbmc, dims, cells, balanced) |
dim_heatmap(pbmc, dims, cells, balanced) |
same |
DoHeatmap(pbmc, features) |
do_heatmap(pbmc, features) |
same |
RidgePlot(pbmc, features, ncol) |
ridge_plot(pbmc, features, ncol) |
same |
Plot output difference¶
# Python — functions return a Figure; display or save explicitly
fig = vln_plot(pbmc, features="LYZ")
# In a Jupyter notebook: just call the function — it displays inline
# To save:
fig.savefig("lyz_violin.png", dpi=150, bbox_inches="tight")
# To display interactively:
plt.show()
Reproducing All Plots¶
git clone https://github.com/GenomicAI/truecell.git
cd truecell
uv venv && source .venv/bin/activate # Windows: .venv\Scripts\activate
uv pip install -e ".[analysis]"
python tutorials/generate_plots.py # Truecell plots -> tutorials/figures/
Rscript tutorials/pbmc3k_verify.R # R Seurat RidgePlot + the numeric handoff
Every other R panel in this tutorial links the canonical
Seurat vignette image;
only the RidgePlot (which the vignette omits) is generated locally by
pbmc3k_verify.R.
Checking the numbers, not the pictures¶
Because the R panels are canonical images, every step above is compared by
eye. pbmc3k_verify.R also writes a numeric handoff — per-cell QC and PCA
keyed by barcode, per-gene VST statistics, the marker table, and a set of
scalar anchors — which --report compares:
python tutorials/pbmc3k_tutorial.py # writes the Python side
Rscript tutorials/pbmc3k_verify.R # writes the R side
python tutorials/pbmc3k_tutorial.py --report
Neither side is pinned to the other: both run their own pipeline from the same 10x bytes, which is what makes the comparison worth anything. What it reports on this dataset:
| truecell vs Seurat 5.5.1 | |
|---|---|
| Cells surviving QC | the same 2,638 barcodes; nCount and nFeature exact, percent.mt to 5.3e-15 |
| VST per gene (13,714) | mean 4.8e-14 · variance 1.6e-11 · variance.expected 2.5e-2 · variance.standardized 2.6e-2, all relative |
| Variable features | 1,998 of 2,000 shared, rank Spearman 0.9999 |
| PCA (10 dims) | matched |r| mean 0.9988, min 0.9946, no reordering |
| kNN graph | 52,760 on both (2,638 × 20) |
| Clusters | 8 vs 9 — ARI 0.899, concordance 0.955 (see Step 11) |
| Markers, where the clusters hold identical cells | identical gene sets (151/151, 242/242), avg_log2FC to 4.9e-15 and 4.6e-14 respectively |
That last row is the one to read carefully. Two clusters — B cells and Platelets — came out with exactly the same membership on both sides, and on those two the differential expression agrees gene for gene and to machine precision. So the marker differences elsewhere are downstream of the clustering, not of the DE code.
Two notes on reading the report's marker table:
top10 by pis not the right column to judge. R's Wilcoxon p-values underflow to exactly 0 for the strongest markers — between 40 and 302 genes per cluster on this dataset — so Seurat's own top-10 ordering is decided by its fold-change tie-break, not by p.top10 by FC, which both tools resolve exactly, runs 8–10 of 10.- Building this handoff found a real defect in
find_all_markers: it was missing Seurat'sreturn.thresh = 0.01, so it returned rows Seurat does not. It now applies the same filter and breaks p-value ties on descendingavg_log2FCthe wayFindAllMarkersdoes.
References¶
Hao Y, Stuart T, Kowalski MH, et al. (2024). Dictionary learning for integrative, multimodal and scalable single-cell analysis. Nature Biotechnology, 42, 293–304. https://doi.org/10.1038/s41587-023-01767-y
Stuart T, Butler A, Hoffman P, et al. (2019). Comprehensive Integration of Single-Cell Data. Cell, 177(7), 1888–1902. https://doi.org/10.1016/j.cell.2019.05.031
10x Genomics (2016). 3k PBMCs from a Healthy Donor. https://www.10xgenomics.com/resources/datasets/3-k-pb-mcs-from-a-healthy-donor-1-standard-1-1-0

























