Multimodal Tutorial — CITE-seq (RNA + Protein) with R Seurat vs Truecell¶
A Python port of Seurat's multimodal vignette using the CBMC CITE-seq dataset (GSE100866): ~8,600 cord-blood mononuclear cells profiled simultaneously for the transcriptome and 13 surface proteins (antibody-derived tags, "ADT"). It showcases Truecell's multi-assay support: cluster on RNA, attach the protein counts as a second assay, CLR-normalise them, and read protein levels on the RNA-derived UMAP.
Dataset: 8k CBMCs, CITE-seq — Stoeckius et al. 2017 (GSE100866) Python: Truecell v0.2.0
Scope note. Truecell stores and normalises multiple assays, visualises one against another, and performs multimodal integration via Weighted Nearest Neighbor (WNN) analysis (Step 8) — learning a per-cell RNA-vs-protein weight and clustering both modalities jointly.
python tutorials/cbmc_citeseq_tutorial.py # printed validation + the numeric handoff
python tutorials/generate_multimodal_plots.py # writes figures_multimodal/ (Truecell figures)
Rscript tutorials/cbmc_citeseq_verify.R # R figures + r_adt_clr.csv / r_cell_weights.csv
python tutorials/cbmc_citeseq_tutorial.py --report # the side-by-side, per protein and per cell
About the figures. Each step shows a genuine side-by-side comparison: the left image is real R Seurat output (
cbmc_citeseq_verify.R, titled "R Seurat – …") and the right image is Truecell (generate_multimodal_plots.py). Both run the identical RNA workflow, the same CLR transform, and the same protein-gated annotation — with the same thresholds — on the same data. Clustering is still stochastic and the two Louvain implementations are not the same code, so compare the structure, not exact positions or cluster numbers.
Step 1 · Load RNA + Protein, align barcodes¶
The RNA matrix mixes human and mouse spike-in genes; both Seurat and Truecell keep the human genes. RNA and ADT are aligned to their shared cell barcodes.
Step 2 · Create the object & run the RNA workflow¶
Step 3 · Add the protein (ADT) assay & CLR-normalise¶
The antibody counts become a second assay. Proteins are normalised with the
centered-log-ratio transform, applied per cell across the 13-protein panel
(margin = 2) — the recommended setting for small ADT panels.
| R (Seurat) | Python (Truecell) |
|---|---|
marginmeans exactly what it means in Seurat, axis included: Seurat'sCustomNormalizerunsapply(data, MARGIN = margin, clr_function), and R'sapplytreatsMARGIN = 1as rows andMARGIN = 2as columns. So with counts stored features × cells,margin = 1normalises each protein across cells (Seurat's default) andmargin = 2normalises each cell across its proteins (what ADT panels want). Truecell had these two swapped until the fix described at the end of Step 8.
Step 4 · Visualise protein on the RNA UMAP¶
In R you switch DefaultAssay() (or use the adt_/rna_ key prefixes); in
Truecell you pass assay="ADT".
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
The surface proteins cleanly mark their lineages: CD3/CD4 the T-cell mass, CD19 the B-cell island, CD8/CD16/CD56 the NK cluster, CD14/CD11c the monocytes.
Step 5 · Protein vs RNA for the same marker¶
The CITE-seq payoff: protein gives a smooth, high signal-to-noise readout where the encoding mRNA is sparse and noisy.
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
Step 6 · Ridge plots & protein bivariates¶
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
The CD4-vs-CD8 and CD19-vs-CD3 protein scatters separate the major lineages on just two axes.
Step 7 · Annotate cell types (protein + RNA)¶
CITE-seq proteins are cleaner lineage markers than RNA, so annotate_cells()
gates on the ADT assay (T→NK→B→monocyte→DC→progenitor) and falls back to RNA
markers for populations the 13-protein panel can't resolve — platelets (PPBP),
erythroid (HBB), pDC (IGJ/PLD4), and cycling cells.
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
Step 8 · Weighted Nearest Neighbor (WNN) multimodal clustering¶
Steps 2–7 cluster on RNA alone and read protein on top. WNN (Hao et al., Cell 2021) goes further: it learns, per cell, how much to trust each modality and clusters on a joint graph. A cell whose lineage is crisp in protein space is weighted toward ADT; a cell the antibody panel can say nothing useful about is weighted toward RNA.
Crucially the weights are decisive, not hedged: they use the full 0–1 range, and most cells commit to one modality rather than sitting at 0.5. That is by design — the modality score is clipped at 200 before the softmax, so a cell with a clear preference saturates.
Which cells end up on which side is an empirical result, not something you can read off the panel design. Mostly the table below tracks the panel, but not everywhere: CD8 T leans on RNA (0.37) even though CD8 is one of the 13 antibodies, while CD4 T leans the other way at 0.64. Seurat says the same (0.38 / 0.64), so it is a property of the data and the method rather than of this port — but this tutorial does not explain it.
The protein modality first gets its own reduction ("apca"); then
find_multi_modal_neighbors builds the joint wknn/wsnn graphs and writes
a per-cell weight for each modality. Clustering and UMAP then run on the joint
graph instead of the RNA PCA.
Because the ADT panel has only 13 proteins, apca keeps every informative
component (n_pcs ≤ 12). Inspect the learned weights by grouping ADT.weight
by the Step-7 labels:
The lineages the antibody panel targets head-on lean on protein, and the ordering tracks how well the panel covers each type. Seurat's numbers on the same data are alongside:
| Cell type | Truecell ADT.weight |
R Seurat | in the panel? |
|---|---|---|---|
| NK | 0.65 | 0.65 | CD16 + CD56 |
| CD4 T | 0.64 | 0.64 | CD4 — and CD4/CD8 is a protein call, not an RNA one |
| B | 0.55 | 0.54 | CD19 |
| CD14+ Mono | 0.49 | 0.49 | CD14 |
| Erythroid | 0.38 | 0.40 | ✗ |
| CD8 T | 0.37 | 0.38 | CD8 |
| Progenitor | 0.35 | 0.29 | CD34 only |
| Platelet | 0.29 | 0.30 | ✗ |
| DC / Mono | 0.21 | 0.21 | ✗ — no clean DC probe |
The panel's targets sit at the top and the populations it cannot see sit at the
bottom, which is the result you would hope for. DC / Mono is the case worth
dwelling on: protein tells WNN almost nothing about those cells, so nearly all
the weight goes to RNA. A weighting pinned near 0.5 could never express that.
Erythroid and platelet carry no antibody either, and both duly fall back toward
RNA.
Progenitor is the one row that does not line up — 0.35 here against R's 0.29, where every other type agrees to 0.02. It is a 146-cell population, and the two sides do not cut quite the same cells into it (R's progenitor cluster carries mean CD34 1.31, Truecell's 1.5).
This used to be as far as the tutorial could go: it read like small-population clustering noise rather than a difference in the WNN maths, but that was a reading. It is now established. Re-grouping Truecell's own per-cell weights by R's labels puts progenitor at 0.283 against R's 0.285 — the weights agree to three decimals, and the gap was entirely in which cells carry the label. See How close is this to Seurat? for the per-cell comparison that shows it.
Reading the plots¶
08_wnn_umap_clusters.png is the joint embedding. The comparison that matters
is 09_wnn_vs_rna_umap.png — the same cells with the same labels, embedded on
RNA alone (left) and on the joint graph (right):
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
UMAP has no canonical rotation, so don't read the orientation — read which populations form their own islands. Both sides resolve CD4 T, CD14+ Mono, NK and B as separate territories, with erythroid/platelet/progenitor grouped away from the immune lineages.
10_adt_weight_by_celltype.png plots the weights themselves. The violins are
bimodal against the 0 and 1 rails rather than a bump at 0.5 — individual
cells commit, and the per-type mean is a summary of that split, not a
description of a typical cell:
| R (Seurat) | Python (Truecell) |
|---|---|
![]() |
![]() |
ADT.weightis a metadata column, not a gene, butvln_plotresolves it the same way Seurat'sVlnPlotdoes — hence the genericExpressiony-axis label.The whole flow is wrapped as
run_wnn(obj)incbmc_citeseq_tutorial.py;run_full()prints the WNN cluster count and the mean modality weights per cell type.
How close is this to Seurat?¶
Close, and now measured rather than asserted. --report compares three things
against a live Seurat 5.5.1 run:
| Quantity | Agreement |
|---|---|
| ADT CLR — per-protein mean · sd · min · max | max abs diff 4.2e-15 (no RNG anywhere in it) |
| WNN modality weight, per cell on 8,617 shared barcodes | Pearson 0.9847 · Spearman 0.9816 · median abs diff 0.0152 |
| Mean ADT weight, whole panel | 0.5539 against 0.5556 — relative 3.0e-03 |
| Cell-type label, per cell | 99.29% concordant (8,556 / 8,617) |
| RNA clusters · WNN clusters · cells · genes · proteins | 16 · 21 · 8,617 · 20,379 · 13 — all identical |
The progenitor gap was never a WNN difference. This section used to record progenitor at 0.06 away from Seurat while every other type agreed to 0.02, and attributed it to cluster boundaries. The per-cell dump settles it: re-group truecell's own weights by R's labels and progenitor reads 0.283 against R's 0.285. The weights agree; the two tools put different cells in the bucket.
| cell type | truecell | R | truecell's weights, R's labels | n (py / R) |
|---|---|---|---|---|
| Progenitor | 0.352 | 0.285 | 0.283 | 146 / 184 |
| Erythroid | 0.378 | 0.397 | 0.400 | 606 / 566 |
| every other type | — | — | moves by ≤0.003 | — |
Those two rows are the same disagreement seen from both ends: 61 cells sit on the progenitor/erythroid boundary and land differently, which barely moves erythroid's 600-cell mean and visibly moves progenitor's 150-cell one. That is a small-population effect on an annotation threshold, not a modality-weighting difference — and it is the kind of thing a per-cell-type summary table can never distinguish, which is why the handoff is keyed by barcode.
The residual causes are the usual two: the neighbour search is exact here and
approximate (annoy) in R, and the two Louvain implementations are different
code. Both sides run the same CLR and the same annotate_cells thresholds,
shared verbatim between the two scripts.
This table used to look much worse, and the cause was not in the WNN code. Truecell's CLR had its
marginflag inverted relative to Seurat:margin=2computed what Seurat'smargin=1computes and vice versa, so the protein matrix feedingapca— and therefore every weight — was the wrong transform. The published numbers ran 0.10–0.74 against R's 0.21–0.65, and erythroid and platelet sat at 0.62/0.53 against R's 0.40/0.30, on the protein side despite having no antibody in the panel. That inversion is fixed; the annotation thresholds incbmc_citeseq_tutorial.pyandcbmc_citeseq_verify.Rhad been retuned around it and are now shared verbatim. The bug survived because its unit test verified the CLR kernel but derived the axis mapping in Python rather than checking it against R — right formula, assumed axis. The guard that replaces it (test_clr_margin_matches_r_ground_truth) pins both margins to fixed output from a real Seurat run.
API Translation (multimodal additions)¶
| Task | R (Seurat) | Python (Truecell) |
|---|---|---|
| Load CITE-seq | read.csv + CollapseSpeciesExpressionMatrix |
cbmc_citeseq() |
| Add a 2nd assay | cbmc[["ADT"]] <- CreateAssayObject(counts) |
obj.assays["ADT"] = create_assay5_object(counts, key="adt_") |
| CLR-normalise protein | NormalizeData(..., method="CLR", margin=2) |
normalize_data(..., normalization_method="CLR", margin=2) |
| Switch modality | DefaultAssay(cbmc) <- "ADT" / adt_/rna_ keys |
feature_plot(..., assay="ADT") |
| WNN neighbours | FindMultiModalNeighbors(reduction.list, dims.list) |
find_multi_modal_neighbors(obj, reduction_list, dims_list) |
| Cluster on joint graph | FindClusters(graph.name = "wsnn") |
find_clusters(obj, graph_name="wsnn") |
| UMAP on joint graph | RunUMAP(nn.name = "weighted.nn") |
run_umap(obj, graph="wsnn") |
References¶
Stoeckius M, Hafemeister C, Stephenson W, Houck-Loomis B, Chattopadhyay PK, Swerdlow H, Satija R, Smibert P (2017). Simultaneous epitope and transcriptome measurement in single cells. Nature Methods 14, 865–868. https://doi.org/10.1038/nmeth.4380
Hao Y, Hao S, Andersen-Nissen E, et al. (2021). Integrated analysis of multimodal single-cell data. Cell 184, 3573–3587. https://doi.org/10.1016/j.cell.2021.04.048 (WNN)
Seurat multimodal vignette: https://satijalab.org/seurat/articles/multimodal_vignette Seurat WNN vignette: https://satijalab.org/seurat/articles/weighted_nearest_neighbor_analysis

















