Differential Expression with Sample Variance¶
The natural next step after Getting Started.
With biological replicates, variation between samples of the same condition is often larger than technical noise. Ignoring it inflates significance and hurts reproducibility. Kompot builds a per-sample estimator, measures sample-to-sample covariance, and folds it into the Mahalanobis distance — yielding conservative, replicate-aware calls. The derivation is in the paper.
This dataset has 3 replicates per age group.
Cost warning — sample variance is priced per gene, twice over. Without it, every gene is scored against a single shared posterior covariance of shape
(n_landmarks, n_landmarks). With it, every gene gets its own such matrix per condition, and two costs follow that respond to different levers:
memory,
2 × n_landmarks² × n_genes × 8bytes — about 0.37 GiB per gene at the defaultn_landmarks=5000.StorageSettings(store_arrays_on_disk=True)keeps this out of RAM entirely, andn_landmarksshrinks it quadratically;compute, one Cholesky factorisation per gene instead of one in total.
store_arrays_on_diskdoes not touch this, but loweringn_landmarksdoes (~0.016 s/gene at 500 against ~2.1 s at 5 000, single-threaded), as does analysing fewer genes.So the prescription is a cheap first pass over all genes, then a sample-variance pass restricted to the top ones, which is what this notebook does. The levers and measured plans at realistic sizes: Planning Memory and Disk.
[1]:
%matplotlib inline
import anndata as ad
import matplotlib.pyplot as plt
import numpy as np
import palantir
import pandas as pd
import scanpy as sc
import kompot
# Consistent, clean plotting style across the tutorial set
plt.rcParams["axes.spines.right"] = False
plt.rcParams["axes.spines.top"] = False
plt.rcParams["image.cmap"] = "Spectral_r"
[2]:
DATA_PATH = "../data/murine_bone_marrow_aging.h5ad"
SAMPLE_COLUMN = "Replicate" # biological-replicate identifiers
GROUPING_COLUMN = "Age"
CONDITIONS = ["Young", "Old"]
CELL_TYPE_COLUMN = "highres_celltype"
DIMENSIONALITY_REDUCTION = "DM_EigenVectors"
LAYER_FOR_EXPRESSION = "logged_counts"
Load Data¶
[3]:
adata = ad.read_h5ad(DATA_PATH)
palantir.utils.run_diffusion_maps(adata, pca_key="X_pca_harmony", n_components=40)
adata
[3]:
AnnData object with n_obs × n_vars = 8090 × 16285
obs: 'Compartment', 'Replicate', 'Age', 'Sample', 'Info', 'batch', 'doublet_score', 'n_genes_by_counts', 'total_counts', 'total_counts_mt', 'pct_counts_mt', 'total_counts_hb', 'pct_counts_hb', 'S_score', 'G2M_score', 'phase', 'leiden', 'phenograph', 'highres_celltype', 'midres_celltype'
var: 'gene_ids', 'feature_types', 'genome', 'mt', 'n_cells_by_counts', 'mean_counts', 'pct_dropout_by_counts', 'total_counts', 'hb', 'highly_variable', 'means', 'dispersions', 'dispersions_norm', 'highly_variable_nbatches', 'highly_variable_intersection'
uns: 'Age_colors', 'Compartment_colors', 'DMEigenValues', 'DM_EigenValues', 'Info_colors', 'README', 'Replicate_colors', 'Sample_colors', 'batch_colors', 'draw_graph', 'highres_celltype_colors', 'hvg', 'leiden', 'leiden_colors', 'midres_celltype_colors', 'neighbors', 'pca', 'phase_colors', 'umap'
obsm: 'AbCapture', 'DM_EigenVectors', 'HTO', 'X_draw_graph_fa', 'X_pca', 'X_pca_harmony', 'X_pca_noregression', 'X_umap'
varm: 'PCs'
layers: 'MAGIC_imputed_data', 'cc_counts', 'logged_counts', 'normalized_counts', 'raw_counts'
obsp: 'DM_Kernel', 'DM_Similarity', 'connectivities', 'distances'
Replicates per condition:
[4]:
adata.obs.groupby([GROUPING_COLUMN, SAMPLE_COLUMN]).size().unstack(fill_value=0)
/tmp/ipykernel_651/3107917919.py:1: FutureWarning: The default of observed=False is deprecated and will be changed to True in a future version of pandas. Pass observed=False to retain current behavior or observed=True to adopt the future default and silence this warning.
adata.obs.groupby([GROUPING_COLUMN, SAMPLE_COLUMN]).size().unstack(fill_value=0)
[4]:
| Replicate | 1 | 2 | 3 |
|---|---|---|---|
| Age | |||
| Mid | 1035 | 1022 | 0 |
| Old | 1057 | 1019 | 1040 |
| Young | 944 | 1007 | 966 |
Pass 1 — Baseline DE Without Sample Variance¶
Run standard DE over all genes first. This is the cheap pass, and it produces the Mahalanobis ranking that pass 2 is restricted to (run_id=0):
[5]:
de_standard = kompot.de(
adata,
groupby=GROUPING_COLUMN,
condition1=CONDITIONS[0],
condition2=CONDITIONS[1],
layer=LAYER_FOR_EXPRESSION,
obsm_key=DIMENSIONALITY_REDUCTION,
gp=kompot.GPSettings(batch_size=0),
)
[2026-07-11 01:30:39,288] [INFO ] Condition 1 (Young): 2,917 cells
[2026-07-11 01:30:39,289] [INFO ] Condition 2 (Old): 3,116 cells
[2026-07-11 01:30:43,908] [INFO ] Fitting expression estimator for condition 1...
[2026-07-11 01:31:12,552] [INFO ] Fitting expression estimator for condition 2...
[2026-07-11 01:32:15,129] [INFO ] FDR analysis complete: 192/16285 genes significantly DE at FDR < 0.05
[2026-07-11 01:32:15,131] [INFO ] Mahalanobis distance threshold for FDR < 0.05: 5.8215
[2026-07-11 01:32:21,822] [INFO ] This run will have `run_id=0`.
Pass 2 — DE With Sample Variance, Restricted to the Top Genes¶
Add sample_col. Kompot now builds one covariance matrix per gene per condition, so the run is priced per gene and we pull every lever available:
subset to top genes from pass 1 — this is the only lever that touches both the memory and the per-gene factorisation, which is why it is the one that decides whether the run is possible at all. Fifty genes keeps this tutorial fast; on a real study, on the order of 200 is the working recommendation;
store_arrays_on_disk=True(StorageSettings) — Kompot then assembles one gene’s covariance at a time instead of holding the tensors, which takes memory almost back to the no-sample-variance level. Withdaskinstalled nothing is even written to disk;lower
n_landmarksif the run is still too slow — memory is quadratic in it, and it cuts the per-gene factorisation too, so it buys on both axes;skip FDR (
null_genes=0) — not yet calibrated for sample variance, and null genes would be charged at the same per-gene rate as real ones. Withsample_colset this is already the default.
Always check the plan first with dry_run=True, which reports memory, disk, and the fields that would be written, without running anything:
The stored outputs of the next two cells were cleared, deliberately.
They were captured on Kompot 0.8.0 and they assert a resource plan the fixed estimator does not produce: the dry run showed an 18.63 GB disk requirement that is now zero on the Dask path, and the run logged a per-gene Dask path that LazyGeneCovariance deliberately no longer takes. A blank cell that says why is better than a number that lies. Re-run the notebook to regenerate them for your own data.
Every result below is unaffected. The 0.9.0 fix is numerically identical — bitwise so on the in-memory and Dask paths — so the comparison table, the volcano plots and the heatmap are still exactly the ones this notebook was written around.
[ ]:
c1, c2 = CONDITIONS
mahal = f"kompot_de_{c1}_to_{c2}_mahalanobis"
top_genes = adata.var.sort_values(mahal, ascending=False).head(50).index
plan = kompot.de(
adata,
groupby=GROUPING_COLUMN,
condition1=c1, condition2=c2,
layer=LAYER_FOR_EXPRESSION,
obsm_key=DIMENSIONALITY_REDUCTION,
sample_col=SAMPLE_COLUMN,
genes=top_genes,
gp=kompot.GPSettings(batch_size=0),
storage=kompot.StorageSettings(store_arrays_on_disk=True),
fdr=kompot.FDRSettings(null_genes=0),
dry_run=True,
)
If the plan fits, run it (run_id=1):
[ ]:
de_sample_var = kompot.de(
adata,
groupby=GROUPING_COLUMN,
condition1=c1, condition2=c2,
layer=LAYER_FOR_EXPRESSION,
obsm_key=DIMENSIONALITY_REDUCTION,
sample_col=SAMPLE_COLUMN,
genes=top_genes,
gp=kompot.GPSettings(batch_size=0),
storage=kompot.StorageSettings(store_arrays_on_disk=True),
fdr=kompot.FDRSettings(null_genes=0),
)
Compare¶
Sample-variance results carry a _sample_var suffix. Fold changes are unchanged; only significance shifts:
[8]:
cols = [
f"kompot_de_{c1}_to_{c2}_mean_lfc",
f"kompot_de_{c1}_to_{c2}_mahalanobis",
f"kompot_de_{c1}_to_{c2}_mahalanobis_sample_var",
]
comparison = adata.var.loc[top_genes, cols].sort_values(cols[1], ascending=False)
comparison.head(20)
[8]:
| kompot_de_Young_to_Old_mean_lfc | kompot_de_Young_to_Old_mahalanobis | kompot_de_Young_to_Old_mahalanobis_sample_var | |
|---|---|---|---|
| H2-Q7 | 0.371810 | 15.224920 | 10.043567 |
| Cd74 | 0.105968 | 12.781286 | 10.791307 |
| H2-Aa | 0.142574 | 12.641082 | 8.861583 |
| H2-Ab1 | 0.153822 | 12.307942 | 8.316963 |
| Igkc | 0.016244 | 11.416240 | 8.666152 |
| H2-Eb1 | 0.123908 | 11.371004 | 7.737303 |
| AW112010 | 0.238251 | 11.366238 | 7.150069 |
| S100a9 | -0.051261 | 10.385744 | 9.975386 |
| Ifitm3 | 0.110643 | 10.134062 | 8.776124 |
| S100a8 | -0.049699 | 10.089391 | 9.740630 |
| H2-Q6 | 0.235504 | 9.790485 | 6.996295 |
| Cd52 | -0.038509 | 9.649612 | 9.086571 |
| Aldh1a1 | 0.147759 | 9.493316 | 7.973998 |
| Ifitm1 | 0.128764 | 9.438788 | 8.316066 |
| Ifitm2 | 0.086038 | 9.357804 | 8.315513 |
| Ighm | -0.052318 | 9.204495 | 7.853406 |
| Cd79a | -0.001314 | 9.182240 | 7.399574 |
| Apoe | -0.039501 | 9.142740 | 8.440726 |
| Fos | 0.159999 | 8.999856 | 8.108269 |
| Gm47283 | 0.188797 | 8.930669 | 5.335491 |
Genes that lose significance are the ones that vary most across replicates. Volcano plots, standard vs. sample-variance:
[9]:
kompot.plot.volcano_de(adata, run_id=0, title="Standard (pooled samples)")
[2026-07-11 01:36:30,400] [INFO ] Found DE run info for run_id=0
[2026-07-11 01:36:30,401] [INFO ] Found mean_lfc_key='kompot_de_Young_to_Old_mean_lfc' from run info
[2026-07-11 01:36:30,402] [INFO ] Found mahalanobis_key='kompot_de_Young_to_Old_mahalanobis' from run info
[2026-07-11 01:36:30,404] [WARNING ] Field 'kompot_de_Young_to_Old_mean_lfc' has been written by 2 different runs, indicating potential overwrites
[2026-07-11 01:36:30,405] [WARNING ] Field 'kompot_de_Young_to_Old_mahalanobis' was last written by run 0, but current context expects run 1. The field may have been overwritten.
[2026-07-11 01:36:30,406] [INFO ] Successfully inferred fields: {'mean_lfc_key': 'kompot_de_Young_to_Old_mean_lfc', 'mahalanobis_key': 'kompot_de_Young_to_Old_mahalanobis'}
[2026-07-11 01:36:30,407] [WARNING ] Field inference completed with 2 warnings
[2026-07-11 01:36:30,409] [INFO ] Using DE run 0: comparing Young to Old
[2026-07-11 01:36:30,423] [INFO ] Using data columns from var - lfc: 'kompot_de_Young_to_Old_mean_lfc', score: 'kompot_de_Young_to_Old_mahalanobis'
[2026-07-11 01:36:30,428] [INFO ] Highlighting 192 genes marked as DE (109 up, 83 down)
[2026-07-11 01:36:30,441] [INFO ] Labeling top 10 genes by score
[10]:
kompot.plot.volcano_de(
adata, run_id=1,
n_top_genes=40, # no FDR threshold here (null_genes=0)
gene_labels=20,
title="With sample variance (top 50 genes)",
)
[2026-07-11 01:36:30,748] [INFO ] Found DE run info for run_id=1
[2026-07-11 01:36:30,749] [INFO ] Found mean_lfc_key='kompot_de_Young_to_Old_mean_lfc' from run info
[2026-07-11 01:36:30,749] [INFO ] Found mahalanobis_key='kompot_de_Young_to_Old_mahalanobis_sample_var' from run info
[2026-07-11 01:36:30,752] [WARNING ] Field 'kompot_de_Young_to_Old_mean_lfc' has been written by 2 different runs, indicating potential overwrites
[2026-07-11 01:36:30,754] [INFO ] Successfully inferred fields: {'mean_lfc_key': 'kompot_de_Young_to_Old_mean_lfc', 'mahalanobis_key': 'kompot_de_Young_to_Old_mahalanobis_sample_var'}
[2026-07-11 01:36:30,754] [WARNING ] Field inference completed with 1 warnings
[2026-07-11 01:36:30,755] [INFO ] Using DE run 1: comparing Young to Old
[2026-07-11 01:36:30,767] [INFO ] Using data columns from var - lfc: 'kompot_de_Young_to_Old_mean_lfc', score: 'kompot_de_Young_to_Old_mahalanobis_sample_var'
[2026-07-11 01:36:30,770] [INFO ] Highlighting top 40 genes by kompot_de_Young_to_Old_mahalanobis_sample_var
[2026-07-11 01:36:30,779] [INFO ] Labeling top 20 genes by score
Sample-to-Sample Variability¶
Rank genes by how much significance they lose, then compare their expression between two replicates of the same condition:
[11]:
comparison["decrease"] = comparison[cols[1]] - comparison[cols[2]]
variable_genes = comparison.nlargest(20, "decrease").index
list(variable_genes)
[11]:
['H2-Q7',
'AW112010',
'H2-Ab1',
'H2-Aa',
'H2-Eb1',
'Gm47283',
'Ifi27',
'Cirbp',
'H2-Q6',
'Igkc',
'H2-K1',
'Trbc2',
'Cd74',
'Lilr4b',
'Cd3g',
'Ms4a4b',
'Ebf1',
'Cd79a',
'Ly6d',
'Cd3d']
[12]:
kompot.plot.heatmap(
adata,
genes=variable_genes,
groupby=CELL_TYPE_COLUMN,
condition_column=SAMPLE_COLUMN,
condition1="1", condition1_name="Replicate 1",
condition2="3", condition2_name="Replicate 3",
vmin="p2", vmax="p98",
cluster_rows=False,
)
[2026-07-11 01:36:31,110] [INFO ] Inferred layer='logged_counts' from run information
[2026-07-11 01:36:31,111] [INFO ] Creating split heatmap with 20 genes/features
[2026-07-11 01:36:31,112] [INFO ] Using display names: 'Replicate 1' for condition1, 'Replicate 3' for condition2
[2026-07-11 01:36:31,113] [INFO ] Using expression data from layer: 'logged_counts'
[2026-07-11 01:36:31,236] [INFO ] Applying gene-wise z-scoring (standard_scale='var')
These genes differ substantially between replicates of the same age — exactly why their significance drops under sample variance.
Best Practices¶
Use sample variance when you have ≥3 replicates per condition and want conservative, reproducible calls. Effect sizes stay identical; only confidence changes, and the genes that survive the correction are the robust, replicate-consistent signals.
Always run it as a second pass. The levers, and what each one actually buys:
Lever |
Memory |
Per-gene compute |
Practical setting |
|---|---|---|---|
|
linear |
linear |
top ~200 from pass 1 |
|
removes the tensor term |
— |
set |
|
quadratic |
reduces it; measure rather than extrapolate |
1 000–2 000 when 5 000 is too slow |
|
removes 2 000 genes’ worth |
likewise |
already the default once |
Note what the table says about genes: it is the only row that appears in both cost columns. Offloading solves the memory axis outright — a whole-transcriptome run goes from 7 552 GiB to 101 GiB — and leaves the per-gene factorisation untouched, which is a long job rather than an impossible one (~11.6 h at 5 000 landmarks, single-threaded). So restricting the gene list is about spending your compute on the genes you will actually read, not about clearing a wall.
And price it before you run it: dry_run=True on the exact call you intend to make takes a second. Full treatment, with measured plans: Planning Memory and Disk.
[13]:
kompot.cleanup(adata)
adata.write_h5ad("../data/murine_bone_marrow_aging_with_sample_variance.h5ad")
[2026-07-11 01:36:33,094] [INFO ] Cleaning up all 2 run(s)
[2026-07-11 01:36:33,248] [INFO ] Cleaned up 3 field(s) from run 0:
[2026-07-11 01:36:33,249] [INFO ] layers (3 field(s)):
[2026-07-11 01:36:33,250] [INFO ] - kompot_de_Young_smoothed
[2026-07-11 01:36:33,250] [INFO ] - kompot_de_Old_smoothed
[2026-07-11 01:36:33,251] [INFO ] - kompot_de_Young_to_Old_fold_change
[2026-07-11 01:36:33,255] [INFO ] Cleaned up 2 field(s) from run 1:
[2026-07-11 01:36:33,256] [INFO ] layers (2 field(s)):
[2026-07-11 01:36:33,256] [INFO ] - kompot_de_Young_std
[2026-07-11 01:36:33,257] [INFO ] - kompot_de_Old_std
[2026-07-11 01:36:33,258] [INFO ] Total: Cleaned up 5 field(s) across 2 run(s)
Where to go deeper¶
Differential abundance — including its own sample-variance treatment.