Batch integration — Harmony, CCA & RPCA (R Seurat vs Truecell)¶
A side-by-side port of Seurat's integration vignette
on the Kang et al. 2018 PBMC dataset (ifnb): ~14,000 human blood cells, half
left resting (CTRL) and half stimulated for six hours with interferon-β
(STIM). Interferon drives a strong, near-global transcriptional response, so
without correction the cells split first by condition and only then by cell
type — the textbook batch effect.
The task integration solves: make the same cell type from the two conditions overlap, without erasing the biology that separates cell types. Truecell ships three integration paths, and this walkthrough runs all three against their Seurat references on identical counts:
run_harmony↔RunHarmony/HarmonyIntegration— iteratively nudges the PCA embedding so batches mix while clusters hold.integrate_layers(method="cca")↔FindIntegrationAnchors(reduction="cca")IntegrateData— anchors are mutual nearest neighbours in a shared canonical-correlation space.integrate_layers(method="rpca")↔RPCAIntegration— the reciprocal-PCA variant: each dataset is projected into the other's PCA space before the mutual-nearest-neighbour search.
Why this tutorial exists. Every integration function landed in v0.2.0 and had only ever been checked against synthetic fixtures with balanced batches. This is the first time they meet a real dataset with a Seurat reference and unequal batch sizes (CTRL 6,548 vs STIM 7,451). Integration embeddings are not expected to be coordinate-identical — harmonypy and R's harmony are separate implementations — so the target is do the two tools recover the same structure: the same collapse in batch separation, the same recovery of cell type, cluster partitions that agree. This tutorial is also the first in the initiative to find real defects — see the concordance section.
The data — ifnb, through the export bridge¶
ifnb is a curated SeuratData object with no clean raw source, so both languages
read the same counts, exported once from R by tutorials/export_seuratdata.R
into a 10x-style matrix folder. That guarantees byte-identical input and cell
order — the same discipline every other tutorial gets from a shared GEO download.
The stim column is the batch to remove; seurat_annotations is the cell-type
label to preserve — both ship with the dataset, so the two tools start from the
identical annotated state.
Step 1 · Load and prep to PCA — one shared variable-feature basis¶
Standard prep is the uncorrected starting point every method shares. One wrinkle
makes the cross-tool comparison fair, exactly as in the Mixscape tutorial: both
tools use the same variable features. The Python run writes the 2,000 HVGs it
selected to figures_integration/hvg_features.txt, and the R script reads them
back — so the only divergences left are the genuinely method-level ones (PCA
numerics, the integration algorithms, Louvain ties).
Step 2 · Integrate — three ways¶
Harmony corrects the existing PCA in place; CCA and RPCA split the object by condition, anchor the two batches, and rebuild a corrected reduction. Each result is stored under its own name and clustered identically (Louvain, resolution 0.5, neighbours on 30 dims) so the comparison is like-for-like.
Step 3 · Score the integration¶
Two rotation-invariant summaries tell the story. Batch separation (silhouette
by stim, lower is better — a good integration mixes the conditions) and
cell-type preservation (silhouette by cell type, and the adjusted Rand index
of the clusters against the known annotations, higher is better). The Truecell
scoreboard:
| method | sil_batch ↓ | sil_celltype ↑ | n_clusters | ARI→celltype ↑ | batch-mix ↑ |
|---|---|---|---|---|---|
| uncorrected (PCA) | 0.107 | 0.141 | 16 | 0.519 | 0.161 |
| Harmony | 0.008 | 0.194 | 12 | 0.911 | 0.991 |
| CCA | 0.004 | 0.231 | 14 | 0.918 | 0.991 |
| RPCA | 0.005 | 0.220 | 14 | 0.922 | 0.991 |
Uncorrected, the cells separate by condition (batch-mix 0.161 — clusters are nearly single-condition). All three methods collapse that while raising cell-type recovery, and now land within a point of each other (batch-mix 0.991 across the board). Getting RPCA here took real work — see the concordance section.
| R — uncorrected, by condition | Truecell — uncorrected, by condition |
|---|---|
![]() |
![]() |
The two conditions form two clouds — the interferon shift dominates the embedding. After Harmony they interleave, while the cell types stay apart:
| R — Harmony, by condition | Truecell — Harmony, by condition |
|---|---|
![]() |
![]() |
| R — Harmony, by cell type | Truecell — Harmony, by cell type |
|---|---|
![]() |
![]() |
The headline · R-vs-Python concordance, and four RPCA bugs (all fixed)¶
Because integration embeddings are not coordinate-comparable across tools, the
concordance is partition-based: the adjusted Rand index between the two tools'
clusterings (ARI(py,R), 1 = identical), each tool's own cell-type recovery
(ARI→type — the biological check, the two columns should track), and each tool's
batch mixing (mix, 1 = fully mixed). All are computed from the cluster labels
report_concordance() reads out of the verify script's r_calls.csv.
| method | ARI(py,R) | py ARI→type | R ARI→type | py mix | R mix |
|---|---|---|---|---|---|
| PCA (baseline) | 0.970 | 0.519 | 0.518 | 0.161 | 0.163 |
| Harmony | 0.935 | 0.911 | 0.931 | 0.991 | 0.991 |
| CCA | 0.972 | 0.918 | 0.927 | 0.991 | 0.991 |
| RPCA | 0.774 | 0.922 | 0.736 | 0.991 | 0.917 |
Harmony and CCA match Seurat closely on every axis, partition agreement included. This is the confirmation the initiative was built to get: truecell's two most-used integration paths reproduce Seurat's result on the standard benchmark, cluster-for-cluster.
RPCA took four bugs to get here, in two rounds. The first two were caught by an earlier pass of this tutorial and are described in the anchor-internals vignette: a crash on unequal batch sizes (#41), and an under-integration where truecell scaled batches globally instead of per-object and searched the raw reciprocal projection instead of Seurat's SD-standardized, L2-normalized one (#42). Those took RPCA's batch mixing from 0.222 to 0.867 and cell-type recovery from 0.444 (below the uncorrected baseline) to 0.677 — real fixes, but still short of Seurat's 0.914 / 0.735. At the time that residual read as "the expected implementation gap" — exact vs. approximate neighbours, scikit-learn vs. irlba PCA.
It wasn't a gap. It was two more bugs, found by T-int treating "the expected implementation gap" as a claim to verify rather than a place to stop:
integrate_layerswas running the wrong algorithm. Seurat v5'sIntegrateLayers(method = CCAIntegration/RPCAIntegration)does not callIntegrateData— it callsIntegrateEmbeddings, which corrects the PCA embedding itself by transposing it into a fake assay and running the same anchor machinery over it. truecell was running the v4 workflow instead (correct expression, re-scale, re-run PCA) — a different object with the same shape, which is why the two agreed on only 1 of 30 output dimensions above |r| = 0.99 on a matched-size probe.run_pcaused sklearn's randomized SVD. It switches solvers oncemax(shape) > 500, accurate in the leading components and drifting in the trailing ones — only 15 of 30 PCs matched Seurat's irlba above |r| = 0.99, with one PC down at 0.006. Harmless when only the leading PCs matter downstream, butIntegrateEmbeddingscorrects the embedding directly, so the drift landed straight in the output. Swapping in ARPACK (deterministic, 6× faster on this data, and exact enough to match irlba on all 30 PCs) fixed it without slowing the normal path.
Fixing both took embedding agreement from 1/30 to 30/30 PCs above |r| = 0.99
on the full 13,999-cell, unequal-batch dataset (STIM 7,451 is the reference,
Seurat's own PairwiseIntegrateReference rule), reference-half cells copied
through at exactly zero difference. RPCA's batch mixing rose to 0.991
— now higher than Seurat's own 0.917 — and cell-type recovery to 0.922,
above Seurat's 0.736.
What that leaves is not an integration gap — it's a clustering one.
ARI(py,R) for RPCA is still only 0.774, which looks like a leftover
disagreement, but it isn't upstream of find_clusters: clustering Seurat's
own RPCA embedding with truecell's find_neighbors + find_clusters gives
batch-mix 0.990 and ARI→type 0.920 — almost identical to truecell's own
end-to-end numbers, and nothing like Seurat's 0.917 / 0.736 on that same
embedding. The embeddings agree; the two tools' Louvain implementations, given
an identical input, do not.
The clustering divergence is not a defect¶
That last gap was chased down, and for once the answer was that truecell is
right. The investigation ran Seurat's own RPCA embedding through both tools in
three stages — neighbours, graph, community detection — with
nn.method = "rann" on the R side so the neighbour search is exact on both.
Stages one and two agree. The k-nearest-neighbour indices are identical, cell for cell. Fed the same neighbour table, the two SNN graphs agree off-diagonal to 2.8e-08 — pure float32 rounding, since fixed. Whatever is happening, it is not the graph.
Stage three is where they part, and only in how hard each searches.
Seurat's FindClusters runs its own modularity optimiser with n.start = 10
restarts and keeps the best; truecell runs a single pass of igraph's multilevel
Louvain. On this graph Seurat's partition scores 0.899903 and truecell's
0.898336 under igraph's own modularity at γ = 0.5 — Seurat wins by 0.17%,
and 20 different truecell seeds never reach it. So truecell genuinely finds a
shallower optimum. It searched less hard and it shows.
But the deeper optimum is the worse answer. truecell's partition is a strict coarsening of Seurat's: no Seurat cluster is split, and exactly one truecell cluster absorbs two of Seurat's. Those two are both CD14 Mono — 2,587 and 1,729 cells of it — and they are split along the batch axis:
| Seurat cluster | cells | CTRL | STIM |
|---|---|---|---|
| 0 | 2,607 | 73.8% | 26.2% |
| 1 | 1,740 | 16.7% | 83.3% |
| dataset | 13,999 | 46.8% | 53.2% |
The extra 0.17% of modularity is bought by re-discovering the batch effect
that integration just removed. Against the dataset's own seurat_annotations,
truecell scores ARI 0.9195 to Seurat's 0.7368, and batch mixing
0.8937 to 0.8328.
This is not a lucky seed. Across 20 seeds truecell gives 15 clusters 17 times, 85% of seeds beat Seurat's cell-type ARI, and none reaches its modularity. The pattern is consistent in both directions: truecell optimises less thoroughly, and on this dataset that is an advantage.
So the Louvain search was left exactly as it is, and no n.start equivalent
was added — the knob's main effect here would be to converge harder onto the
batch split. The honest caveat is that truecell's single pass is more
seed-sensitive than Seurat's best-of-10: cell-type ARI ranged 0.72–0.93 over
those 20 seeds. If you need stability more than you need this particular
result, cluster at a few seeds and compare.
Four graph defects found on the way¶
Establishing that the graphs agree meant comparing them to Seurat's element by
element, which turned up four things that were simply wrong. None changed
find_clusters output — _sparse_to_igraph takes the strict upper triangle,
which silently discarded the very entries that were missing — but the graphs
are stored objects users read directly.
| was | Seurat | now | |
|---|---|---|---|
nn graph |
symmetrised | directed, nnz = n·k exactly |
directed |
snn diagonal |
dropped | SNN[i,i] = 1, all 13,999 |
kept |
snn precision |
float32 (~3e-08 off) | double | float64 (4.4e-16) |
| singletons | left alone | GroupSingletons absorbs them |
ported |
Symmetrising the KNN graph was the costliest: Seurat's nn is the raw ranked
table, so every row sums to k while column sums vary from 21 to 68 — that
spread is the in-degree, the signal that says which cells are hubs, and
mat + mat.T erased it. Both in-tree consumers already symmetrise at the point
of use, exactly as Seurat's own do.
Restoring the SNN diagonal also required following Seurat to the consumer:
RunUMAP.Graph opens with diag(x = object) <- 0, so run_umap now strips it
rather than feeding the layout n zero-length self-edges.
find_clusters gains group_singletons (default True), porting Seurat's
rule: a size-1 cluster joins whichever non-singleton cluster it is most
connected to, scored by mean SNN weight — a sum would just hand it to the
biggest cluster — with the candidate list fixed before the loop so one
singleton cannot absorb another. ifnb has no singletons at this resolution, so
none of this moved its numbers; it fires on smaller or sparser graphs.
Running it yourself¶
Rscript tutorials/export_seuratdata.R ifnb # one-time counts export (~394 MB SeuratData pkg)
python tutorials/ifnb_integration_tutorial.py # writes HVGs, prints the scoreboard
Rscript tutorials/ifnb_integration_verify.R # Seurat reference → r_calls.csv + r_*.png
python tutorials/ifnb_integration_tutorial.py # re-run: now prints the R-vs-Python concordance
python tutorials/generate_integration_plots.py # Truecell figures → figures_integration/py_*.png
The R reference needs the harmony package and enough headroom for Seurat v5's
parallel integration (options(future.globals.maxSize = 3 * 1024^3), set in the
script).
Figures (tutorials/figures_integration/, r_* = R Seurat, py_* = Truecell):
| Figure | Description |
|---|---|
py_01_uncorrected_stim.png |
UMAP of raw PCA, coloured by condition — the batch effect |
py_02_harmony_stim.png |
UMAP after Harmony, by condition — now mixed |
py_03_harmony_celltype.png |
Same map by cell type — the biology survived |
py_04_scoreboard.png |
Batch mixing vs cell-type recovery, per method |
R Seurat → Truecell API¶
| Task | R (Seurat) | Python (Truecell) |
|---|---|---|
| Harmony | RunHarmony(obj, "stim") / IntegrateLayers(method=HarmonyIntegration) |
run_harmony(obj, group_by="stim") / integrate_layers(method="harmony") |
| CCA anchors | FindIntegrationAnchors(list, reduction="cca") + IntegrateData / IntegrateLayers(method=CCAIntegration) |
find_integration_anchors(objs, reduction="cca") + integrate_data / integrate_layers(method="cca") |
| RPCA | IntegrateLayers(method=RPCAIntegration) |
integrate_layers(method="rpca") |
| Neighbours on a reduction | FindNeighbors(obj, reduction="harmony", dims=1:30) |
find_neighbors(obj, reduction="harmony", dims=range(30)) |
| Cluster | FindClusters(obj, resolution=0.5) |
find_clusters(obj, resolution=0.5) |
| UMAP on a reduction | RunUMAP(obj, reduction="harmony", dims=1:30) |
run_umap(obj, reduction="harmony", dims=range(30)) |
References¶
Kang HM, Subramaniam M, Targ S, et al. (2018) Multiplexed droplet single-cell RNA-sequencing using natural genetic variation. Nature Biotechnology 36, 89-94. https://doi.org/10.1038/nbt.4042
Korsunsky I, Millard N, Fan J, et al. (2019) Fast, sensitive and accurate integration of single-cell data with Harmony. Nature Methods 16, 1289-1296. https://doi.org/10.1038/s41592-019-0619-0





