L08 — Differential Abundance: Do Cell-Type Proportions Change?

Where this lecture fits

Goals of this lecture

  • See why differential abundance is a different question from differential expression
  • Understand the limits of cluster-level proportion tests
  • Read the miloR algorithm — neighbourhoods on a kNN graph + per-neighbourhood NB GLM + SpatialFDR
  • Read the signature miloR plots (neighbourhood graph, beeswarm)
  • Avoid common mistakes: pseudo-replicates, cluster-boundary artifacts, condition imbalance

Compositional analysis — overview

Compositional — raster reference

Differential expression vs differential abundance

Differential Expression (Lec 06) Differential Abundance (Lec 08)
Question For cells of the same type, which genes change between conditions? Do cell-type proportions themselves change between conditions?
Tool DESeq2 on pseudobulk miloR on kNN neighbourhoods
Unit of test Per gene, per cell type Per neighbourhood (a small group of similar cells)
Statistical test Negative binomial GLM on counts Negative binomial GLM on cell counts per neighbourhood
Output per-gene log2FC + padj per-neighbourhood log2FC + SpatialFDR

Note

They are complementary, not redundant.

  • A perturbation can change cell-type proportions, gene expression within types, both, or neither.
  • A complete condition-level analysis reports both.

A toy example — same dataset, four possible patterns

  • DE only — same cell-type proportion, but every cell shifts expression. Common pattern for IFN-β–stimulated mature cells.
  • DA only — proportion shifts, but each cell of the type has the same expression profile. Common pattern for cell-cycle expansion or recruitment.
  • DE + DA — both move. Common in disease vs healthy.
  • Neither — no change. The null.

The naive approach — and why it fails

Cluster-level Fisher’s exact

  • The simple thing to do is count cells per (cluster, sample), make a 2×2 table per cluster (cluster × condition), run Fisher’s exact / chi-squared.

Cluster-level Fisher’s exact

This works for big, well-separated clusters but breaks in three ways:

  1. Hard cluster boundaries are arbitrary. A cell type that spans two clusters at one resolution and one cluster at another gives a different test result.
  2. Within-cluster heterogeneity is invisible. A cluster of “T cells” that contains both expanding-activated and shrinking-naive populations will show NO net change at the cluster level — but real DA at finer resolution.
  3. Cells aren’t independent samples. Per-cell counting treats every cell as a replicate; the right unit is the donor / sample.

Warning

The naive cluster-level Fisher test is the most common mistake in scRNA-seq DA.

  • It’s anti-conservative (cell counts inflate the effective sample size) and resolution-dependent.
  • Use miloR instead.

miloR - The five-step algorithm

  1. Build the kNN graph on the integrated PC reduction (the same one you use for clustering)
  2. Define overlapping neighbourhoods
  • each neighbourhood is a representative cell + its k nearest neighbours
  • sample ~1000 neighbourhoods total
  1. Count cells per neighbourhood per sample (one row per donor × condition × neighbourhood)
  2. Fit a negative-binomial GLM per neighbourhood with your design (~ condition, optionally + donor) — gets a log2FC per neighbourhood
  3. Apply SpatialFDR correction across overlapping neighbourhoods (a weighted BH that accounts for the correlation between adjacent tests)

Why neighbourhoods, not clusters?

  • The middle panel shows what cluster-level DA “sees” — two homogeneous groups.
  • The right panel is what miloR’s neighbourhood-level test recovers — the smooth gradient that’s actually there.

The neighbourhood graph - a signature miloR plot

The neighbourhood graph - a signature miloR plot

Reading rules:

  • Each dot is one neighbourhood (~50–200 cells from the same kNN region)
  • Color = log2FC of STIM vs CTRL within that neighbourhood
  • Size = number of cells in the neighbourhood (bigger = more cells in that part of the graph)
  • Faint = SpatialFDR > 0.1 (not significant)
  • Bright red cluster of dots = a region of cell-type space expanded in STIM
  • Bright blue cluster of dots = a region depleted in STIM

The beeswarm — DA per cell type

The beeswarm — DA per cell type

Read the beeswarm vertically.

- A cell type with most points above zero is enriched in STIM; below zero, depleted. - A cell type with points scattered above AND below zero contains both expanding and contracting subpopulations. - This is the case where neighbourhood-level testing recovers what cluster-level testing would miss.

Practical considerations

How many neighbourhoods? How many cells per neighbourhood?

  • prop = 0.1 (sample 10% of cells as neighbourhood centres) is the default and works for most datasets in the 5k–50k range
  • Aim for the median neighbourhood size around 50–200 cells.
    • Too small (<30) → underpowered
    • Too large (>500) → spatial smoothing that loses fine structure
  • plotNhoodSizeHist(milo) is the go-to diagnostic — peak between 50 and 200 means you’re set
  • If your peak is too small, increase k (graph k-nearest-neighbours) from 30 → 50 or 75

Replication — the same rule as DESeq2

Warning

No replication, no inference.

  • A 1-vs-1 sample comparison gives miloR no degrees of freedom.
  • You’ll get NA p-values everywhere.
  • Plan for 4 vs 4 minimum if you want any chance of detecting modest effects.

Designs that work well

Design Notes
~ condition Two-condition contrast, no other covariates
~ donor + condition Paired design (donors balanced across conditions) — preferred when you have it
~ batch + condition Block on batch effects
~ severity Continuous covariate (e.g. clinical severity score)
~ donor + group + condition + group:condition Interaction tests

The full DESeq2-style design framework is available — including interactions, continuous covariates, and random-effect-style blocking.

Pitfalls

Five mistakes worth knowing

  1. Running on the unintegrated kNN graph. If batch effect dominates the embedding, neighbourhoods cluster by sample, and DA falsely “detects” composition shifts that are really batch artifacts. Always run miloR on the integrated reduction. In Tutorial 08, the Harmony reduction from Tutorial 05 is stored under the name "HARMONY" so the call is:

    milo <- buildGraph(milo, k = 30, d = 20, reduced.dim = "HARMONY")
  2. Synthetic donor splits passed as biological replicates. Splitting one large library into “pseudo-donors” gives miloR fake degrees of freedom — the test is anti-conservative. The workshop tutorial flags this with a set.seed(2026) synthetic split for illustration only; never report those p-values as real findings.

  3. Imbalanced sample sizes. 8 STIM vs 1 CTRL gives almost no information about the CTRL distribution. Aim for balanced designs.

  4. Ignoring seurat_annotations mapping. Without annotateNhoods(), you’ll get neighbourhood-level results but no easy way to summarise by cell type. Always run annotateNhoods after testNhoods.

  5. Reading the UMAP without the SpatialFDR filter. A red cluster of dots that all have SpatialFDR > 0.5 is just visual noise. Filter da_results$SpatialFDR < 0.1 before drawing conclusions.

Recap & what’s next

What to remember from Lecture 08

  • DE asks “which genes change?”; DA asks “do proportions change?” — independent questions, both worth running
  • Cluster-level DA tests are anti-conservative and resolution-dependent — don’t use them
  • miloR works at the neighbourhood level on the integrated kNN graph, with NB GLM + SpatialFDR
  • Read DA results with the neighbourhood graph (spatial pattern) and the beeswarm (per cell type)
  • Need replication — at minimum 4 vs 4 biological replicates, ideally with paired donors
  • Always run miloR on the integrated reduction, not the unintegrated PCA

Coming up next

Further reading