Sanity check · Single-Cell RNA-seq
How to Detect Integration Over-Correction in Single-Cell RNA-seq
Harmony and CCA are built to erase batch, point them at a design where batch and disease are the same variable and they will erase your disease effect too.
By Ming "Tommy" Tang, Director of Bioinformatics in Big Pharma · Reviewed September 2026 · 6 min read
You ran Cell Ranger on 8 to 12 samples, half disease and half healthy, and per-sample UMAPs show something real: a cluster that only shows up in the disease donors, sitting off to one side with its own marker genes. You integrate with Harmony or Seurat's CCA workflow to get a common cell-type map across donors, because that's what the vignette does next. The disease-specific cluster is gone. Everything now sits in tidy, well-mixed, cell-type clusters that overlap beautifully across condition. The plot looks better than it did before integration, and that is the problem.
Nobody flags this because a clean, well-mixed UMAP looks like success. Reviewers like it, core facility reports like it, and the QC metrics for batch mixing (kBET, LISI, silhouette) will all say the correction worked. But if the thing you were funded to find was the difference between disease and healthy, and that difference happened to correlate with which sample a cell came from, integration cannot tell "technical batch" from "the biology I'm looking for." It optimizes for mixing, and it will mix real biology away to get there.
This page gives you the checks to run in the next hour, before you commit to the integrated object as your source of truth: compare per-sample clustering against the integrated version, check whether your design confounds donor with condition, and confirm whether the genes that mattered are still detectable and testable. It also tells you when integration is fine and when the "problem" you're chasing is actually just biology you asked the algorithm to remove.
What it looks like when it's happening
- The UMAP shows disease and healthy cells overlapping cleanly inside every cell-type cluster, even for the cell type you expected to shift with disease.
- A cluster that was clearly condition-specific when you clustered each sample alone disappears or merges into an existing cluster after integration.
- kBET, LISI, or silhouette-based batch-mixing scores look excellent (fully mixed) for a cluster you have independent reason to believe should be condition-specific.
- Marker genes for the expected disease phenotype are only detectable in a subset of cells within the merged cluster, and that subset lines up exactly with the disease samples.
- Cluster proportions per sample become nearly identical across condition after integration, despite per-sample clustering showing very different composition before integration.
- Differential expression between conditions within a cell type returns almost nothing significant when run on the integrated object, but pseudobulk DE on the same cells returns a strong, biologically sensible signal.
- Switching integration method (Harmony vs. CCA vs. RPCA vs. no integration) changes whether a given cluster exists at all.
- Re-running UMAP on a single condition alone reproduces a distinct arm or cluster that vanishes once that condition is integrated with the others.
Why it happens
Integration algorithms are built on one assumption: shared cell types are present across every batch, and any remaining separation between batches within a cell type is technical noise to be removed. CCA finds this shared structure by maximizing covariance between two datasets' expression profiles and using the resulting canonical correlation vectors to build anchors. If a population exists in the disease samples but has no true counterpart in the healthy samples, CCA still tries to find the best-correlated match, and it will happily anchor that population to the nearest thing that looks similar, especially when shared housekeeping genes dominate the correlation structure. The result is a merge that looks like successful batch correction but is actually two different cell states being forced into one cluster.
Harmony works differently but fails for a related reason. It adjusts PCA embeddings iteratively with a soft k-means step, penalizing clusters that are not diverse across batches. That diversity penalty is the whole mechanism: a cluster made up entirely of disease-sample cells is, from Harmony's point of view, indistinguishable from a batch effect that hasn't been corrected yet. It will keep nudging those cells toward the diversity-satisfying centroid until the cluster mixes, whether the lack of diversity was caused by a sequencing lane or by real disease biology. Harmony's saving grace is that it only touches the embedding, so you can still run differential expression on the untouched raw counts (see the checks below), but the clustering and UMAP you show in a figure will look over-mixed regardless.
The biological root cause underneath both of these is a design problem, not a tool problem: in most case-control scRNA-seq experiments, condition and donor (batch) are perfectly confounded. Every donor is either disease or healthy, never both, so "the effect of being a disease donor" and "the effect of being a batch" are mathematically the same axis of variation. Any method that removes batch effects along that axis, linear or not, removes the condition effect with it. Sequencing depth compounds this: if disease samples were run in a different flow-cell batch with systematically lower or higher depth, the resulting depth-driven cluster gets treated as another thing to erase, on top of the confound.
None of this means integration is broken. It means integration answers a specific question, "what does the shared cell-type map look like across samples?", and that question is different from "does this cell state differ by condition?" Using an integrated embedding to answer the second question is a category error the tools will not warn you about, because from their loss function's perspective, better mixing is always the goal.
The checks
Run them in order. Each one tells you what healthy looks like and what the problem looks like.
0/8 checked · saved in this browser
Split the Seurat object by sample (or loop over AnnData subsets in Scanpy) and run the normal pipeline, normalize, HVGs, PCA, neighbors, Leiden/Louvain, independently per sample. Note any sample- or condition-restricted cluster and pull its top marker genes with FindAllMarkers or sc.tl.rank_genes_groups. This is your unintegrated ground truth to compare against later.
robj.list <- SplitObject(obj, split.by = "sample") obj.list <- lapply(obj.list, function(x) { x <- NormalizeData(x) |> FindVariableFeatures() |> ScaleData() |> RunPCA() x <- FindNeighbors(x, dims = 1:30) |> FindClusters(resolution = 0.8) x })- Healthy
- Cluster composition and count roughly matches what you'd expect biologically; any condition-specific cluster you already suspected shows up here with a clear marker set.
- Red flag
- You never did this step, so you have no baseline to compare the integrated object against, you're trusting the mixed result on faith.
Build a simple cross-tabulation of donor (or sequencing batch) against condition. If every donor maps to exactly one condition, batch and condition are the same variable and integration cannot distinguish them by design.
rtable(obj$donor, obj$condition)- Healthy
- Multiple donors per condition ideally sampled and processed together (e.g., a mixed batch design), or at minimum enough donors per condition that condition is not a 1:1 proxy for donor.
- Red flag
- A diagonal table, each donor appears under only one condition. Any batch correction is mathematically also a condition correction.
Tabulate the fraction of cells per cluster per sample using the per-sample clustering from check 1, then repeat using the integrated clustering. Plot as stacked bar charts side by side.
rprop.table(table(obj$sample, obj$seurat_clusters_preintegration), margin = 1) prop.table(table(obj$sample, obj$seurat_clusters_integrated), margin = 1)- Healthy
- Some sample-to-sample variation in composition persists after integration, especially between conditions, because real biology should still show up as different cluster proportions.
- Red flag
- Cluster proportions become nearly identical across all samples after integration, even though per-sample clustering showed a cluster that only disease samples had.
Run a mixing metric (LISI, kBET, or a simple silhouette score computed on the integrated embedding) using condition as the grouping label, computed separately within each cell-type cluster. Compare it against the composition table from check 3.
- Healthy
- Clusters you know are shared between conditions (e.g., naive T cells present in everyone) show high mixing. Clusters that were condition-restricted pre-integration should show lower mixing if the correction was appropriate.
- Red flag
- A cluster that per-sample analysis flagged as disease-specific now scores as fully mixed by condition, the correction erased exactly the signal you were testing for.
For any cluster suspected of being an over-merge, plot the marker genes identified in check 1 as a violin or dot plot, split by condition, within that single integrated cluster.
rVlnPlot(obj, features = disease_markers, group.by = "condition", idents = suspect_cluster)- Healthy
- If the merge is legitimate, marker expression is roughly even across condition within the cluster.
- Red flag
- Marker expression is bimodal within the cluster and lines up almost exactly with condition, the cluster is really two populations wearing one label.
Never test on Harmony- or CCA-corrected values. Aggregate raw UMI counts into pseudobulk per sample per cell type (using cell-type labels assigned via the integrated embedding, which is fine, labeling is not testing), then run DESeq2/edgeR with batch or donor included as a blocking term.
rdds <- DESeqDataSetFromMatrix(pseudobulk_counts, colData = meta, design = ~ batch + condition) dds <- DESeq(dds) res <- results(dds, name = "condition_disease_vs_healthy")- Healthy
- Genes you'd expect from the disease biology show up as significant, with batch correctly absorbing donor-level technical variance instead of removing the condition effect.
- Red flag
- Almost nothing is significant on the integrated-object DE, but pseudobulk DE on the same cells and raw counts shows a strong, coherent signal, the integrated embedding was hiding it, the counts were not.
Run all four on the same input and check whether the suspect cluster exists, and at what size, under each. Prefer RPCA or Harmony as your reporting method when batches (donors) differ substantially in composition; treat CCA results skeptically in that scenario.
- Healthy
- The cluster of interest persists (perhaps at different resolution or size) across at least Harmony, RPCA, and no-integration. Method-dependent existence is the signature of an artifact of correction strength, not biology.
- Red flag
- The disease-specific cluster only exists under "no integration" and vanishes under every correction method you try, strong evidence the correction is too aggressive for this dataset, not that the cluster was noise.
Lower k.anchor in FindIntegrationAnchors() to make anchor-finding less permissive, then re-run integration and repeat checks 3 through 5.
ranchors <- FindIntegrationAnchors(obj.list, dims = 1:30, k.anchor = 3)- Healthy
- The suspect cluster reappears, at least partially, as anchor-finding becomes more conservative.
- Red flag
- The cluster still fails to reappear even at the lowest reasonable k.anchor, the merge is likely driven by genuinely overlapping expression (shared housekeeping genes) rather than an anchor-finding threshold, and tuning this parameter alone won't fix it.
What to do about it
Skip integration entirely for the comparison that matters
When: Your goal is detecting a condition-specific or disease-specific cell state, not building a shared reference map, and check 2 shows condition/donor confounding.
Cluster and annotate cell types using canonical markers on unintegrated or lightly-corrected data (or annotate per sample and harmonize labels by marker identity, not by forcing a shared embedding). Do all statistical testing on raw counts.
Caveat: You give up a single unified UMAP for the paper figure, and cross-sample label harmonization becomes manual work instead of automatic.
Switch from CCA to RPCA or Harmony
When: Batches (usually donors or conditions) differ substantially in cell-type composition or sequencing depth, which is the normal case in disease-vs-healthy cohorts.
Use Seurat's RPCA workflow (reciprocal PCA anchors) or Harmony on PCA embeddings instead of the default CCA integration workflow.
Caveat: RPCA and Harmony are more conservative, which means genuine cross-donor batch effects may be under-corrected and still show up as a residual donor axis in your embedding, check that separately.
Reduce k.anchor when Seurat integration is too aggressive
When: You've confirmed via checks 3-6 that a known condition-specific cluster is being merged away, and you still want to use CCA or RPCA integration.
Lower k.anchor in FindIntegrationAnchors() to make the algorithm more conservative about which cells count as anchors across datasets.
Caveat: This is a tuning knob, not a guarantee, if the underlying populations share most of their expression profile (e.g., an activated vs. resting state of the same cell type), no k.anchor value will cleanly separate them, and you'll need the marker-gene and pseudobulk checks to confirm the fix worked.
Decouple labeling from testing
When: You need an integrated embedding for cell-type annotation or visualization but also need to preserve the condition effect for statistics, the common case in a paper that has both a UMAP figure and a DE table.
Use the integrated embedding only to assign cell-type labels (clustering, marker lookup, cluster naming). Then aggregate raw counts into pseudobulk per sample per cell type and run DE with donor/batch as a covariate, per the pseudobulk workflow.
Caveat: Requires you to explicitly document in methods that the UMAP and the statistics come from different matrices, reviewers sometimes ask why, so have the confound table (check 2) ready.
Check depth before blaming integration
When: The suspect cluster's samples also differ systematically in per-cell UMI depth (check this against sequencing depth artifacts, not just condition).
Regress out or explicitly model sequencing depth as a covariate before integration, or use a depth-aware normalization, rather than tuning integration strength to compensate for a depth-driven axis.
Caveat: If depth and condition are also confounded (disease samples sequenced in a separate run at different depth), you're back to the same confounding problem as donor vs. condition, and no amount of depth correction fully separates them.
When not to "fix" it
If every donor in your cohort is either disease or healthy and never both, condition and batch are the same variable, and no integration method, parameter, or workaround can separate them post hoc, that is a design problem, not an integration bug, and the honest fix is pseudobulk analysis with an explicit caveat about the confound, not more parameter tuning. Separately, if your actual goal is a shared reference atlas across healthy donors, or you're annotating cell types for a paper where the point is a consistent, easy-to-interpret common map, full mixing across samples is exactly what you want, don't second-guess a clean, well-mixed UMAP in that context just because this page taught you to be suspicious of one.
Five things experienced analysts do here
- Always cluster every sample alone before you call RunHarmony or FindIntegrationAnchors, so you have an unintegrated baseline to compare the corrected result against, without it you're trusting the mixing on faith.
- Treat a suspiciously beautiful, fully-mixed UMAP as a flag to investigate, not a success screenshot, whenever you specifically expect a condition effect to exist.
- Never run differential expression on Harmony- or CCA-corrected expression values, use the corrected embedding only for clustering and visualization, and test on raw or pseudobulk counts with batch as a covariate.
- Check for donor/condition confounding (a simple cross-tab) before you integrate anything, because a confounded design breaks every correction method the same way, and no amount of parameter tuning fixes it.
- When a cluster's existence depends on which integration method you picked, treat it as a methodological artifact until independent marker-gene or pseudobulk evidence says otherwise.
Questions people ask
- Does Harmony overcorrect scRNA-seq data?
Less often than CCA, because Harmony only adjusts PCA embeddings and never touches the expression matrix, so you can always fall back to raw counts for testing. But Harmony can still pull a disease-specific population toward its nearest healthy neighbor when donors are confounded with condition and cell-type composition differs across samples, the iterative mixing objective doesn't know the difference between a batch artifact and a real subpopulation.
- How do I know if Seurat integration removed real biology?
Cluster each sample separately first, before running FindIntegrationAnchors or RunHarmony, and note any sample- or condition-specific clusters and their markers. If that cluster is present per-sample but merges into an existing cluster after integration, and the marker genes for it are only expressed in a subset of cells within the merged cluster, integration has erased it.
- Should I use Harmony or Seurat CCA when comparing disease vs healthy samples?
Prefer Harmony or RPCA over CCA when your batches (usually donors) differ in cell-type composition, which is the normal case in disease-vs-healthy studies. CCA is anchor-based on canonical correlation and is prone to over-merging distinct populations when a large share of cells in one dataset have no true counterpart in the other.
- Can I still run differential expression after integrating my scRNA-seq data?
Yes, but not on the batch-corrected expression values. Use the integrated embedding only for clustering, UMAP, and assigning cell-type labels, then run differential expression on raw or pseudobulk counts with batch (usually donor) as a covariate or blocking variable in the model.
- What if every disease sample comes from a different donor than every healthy sample?
That is a fully confounded design, and no integration method choice can separate the batch (donor) effect from the biological (condition) effect, because they are the same variable. No amount of parameter tuning fixes this. The honest path is pseudobulk differential expression with donor as the unit of replication, and an explicit caveat that donor and condition are confounded.
Related pages
- Compare · Harmony vs Seurat CCA integration: Which One Should You Use?
- Guide · How to Detect Integration Over-Correction in Spatial Transcriptomics
- Compare · Seurat CCA integration vs Seurat RPCA integration: Which One Should You Use?
- Glossary · Pseudobulk
- Glossary · Single-cell integration
- Glossary · Spatial transcriptomics
Related reading on the blog
Sources
- Seurat: Fast integration using reciprocal PCA (RPCA) — CCA over-merging behavior and the k.anchor parameter for reducing over-correction
- Harmony GitHub repository — Harmony corrects PCA embeddings only, not expression values
- Bioconductor OSCA: Integrating datasets — Linear correction assumptions and batch-specific cluster ambiguity
- SCIntRuler: guiding the integration of multiple single-cell RNA-seq datasets with a novel statistical metric — 0-1 metric for deciding whether aggressive integration is warranted
- CLAIRE: contrastive learning-based batch correction framework for better balance between batch mixing and preservation of cellular heterogeneity — Trade-off between batch mixing and biological heterogeneity is inherent to all integration methods
Part of the Integration over-correction series.