Bulk RNA-seq DE with PyDESeq2: load counts, normalize, fit negative binomial models, Wald test (BH-FDR), LFC shrinkage, volcano/MA plots. Use for two-group comparisons, multi-factor designs with batch correction, multiple contrasts.
Scanned 9/5/2026
Install to Claude Code
npx -y skills add jaechang-hits/SciAgent-Skills --skill pydeseq2-differential-expression --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Pydeseq2 Differential Expression?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/jaechang-hits-pydeseq2-differential-expression)More formats (shields.io, HTML) on the badges page.
---
name: "pydeseq2-differential-expression"
description: "Bulk RNA-seq DE with PyDESeq2: load counts, normalize, fit negative binomial models, Wald test (BH-FDR), LFC shrinkage, volcano/MA plots. Use for two-group comparisons, multi-factor designs with batch correction, multiple contrasts."
license: "CC-BY-4.0"
---
# PyDESeq2 Differential Expression Analysis
## Overview
PyDESeq2 is a Python reimplementation of the R DESeq2 package for differential gene expression analysis from bulk RNA-seq count data. It fits negative binomial generalized linear models per gene, estimates dispersion with empirical Bayes shrinkage, and performs Wald tests with Benjamini-Hochberg FDR correction. This skill covers the full pipeline from raw counts to publication-ready result tables and visualizations.
## When to Use
- Identifying differentially expressed genes between two or more experimental conditions from bulk RNA-seq
- Performing two-group comparisons (e.g., treated vs control) with proper statistical testing
- Running multi-factor designs that account for batch effects or covariates (e.g., `~batch + condition`)
- Applying log2 fold change shrinkage (apeGLM) for ranking and visualization
- Use `omics-plotting` SKILL after DE for publication-quality plots of differential expression results
- Converting R-based DESeq2 workflows to a pure Python environment
- Integrating DE analysis into larger Python bioinformatics pipelines (e.g., with scanpy, pandas)
- Use **DESeq2** (R/Bioconductor) or **edgeR** instead for the reference R implementations with the broadest method support and community validation
## Prerequisites
- **Python packages**: `pydeseq2>=0.4`, `pandas>=1.4`, `numpy>=1.23`, `scipy>=1.11`, `scikit-learn>=1.1`, `anndata>=0.8`
- **Data requirements**: Raw (unnormalized) integer count matrix (samples x genes) + sample metadata DataFrame
- **Environment**: Python 3.10+; optional `matplotlib`, `seaborn` for visualization
```bash
pip install pydeseq2 matplotlib seaborn
```
## Workflow
### Step 1: Data Loading and Validation
Load the count matrix and metadata. PyDESeq2 expects counts as a samples x genes DataFrame with non-negative integers, and metadata as a samples x variables DataFrame with matching indices.
```python
import pandas as pd
# Load data — typical CSV has genes as rows, samples as columns
counts_raw = pd.read_csv("counts.csv", index_col=0)
metadata = pd.read_csv("metadata.csv", index_col=0)
# Transpose if needed: PyDESeq2 requires samples x genes
if counts_raw.shape[0] > counts_raw.shape[1]:
counts_df = counts_raw.T # genes x samples → samples x genes
else:
counts_df = counts_raw
# Validate alignment
common_samples = counts_df.index.intersection(metadata.index)
counts_df = counts_df.loc[common_samples]
metadata = metadata.loc[common_samples]
print(f"Samples: {counts_df.shape[0]}, Genes: {counts_df.shape[1]}")
print(f"Metadata columns: {list(metadata.columns)}")
print(f"Condition counts:\n{metadata['condition'].value_counts()}")
```
### Step 2: Gene Filtering
Remove lowly expressed genes to improve statistical power and reduce multiple testing burden.
```python
# Filter genes with total counts below threshold
min_total_counts = 10
gene_counts = counts_df.sum(axis=0)
genes_to_keep = gene_counts[gene_counts >= min_total_counts].index
counts_df = counts_df[genes_to_keep]
# Optional: require minimum counts in a minimum number of samples
min_count_per_sample = 5
min_samples = 3
genes_expressed = (counts_df >= min_count_per_sample).sum(axis=0) >= min_samples
counts_df = counts_df.loc[:, genes_expressed]
print(f"Genes after filtering: {counts_df.shape[1]}")
```
### Step 3: DeseqDataSet Initialization and Fitting
Create the DESeq dataset object, specify the design formula, and run the full pipeline (size factor estimation, dispersion estimation, model fitting).
```python
from pydeseq2.dds import DeseqDataSet
dds = DeseqDataSet(
counts=counts_df,
metadata=metadata,
design="~condition", # Wilkinson-style formula
refit_cooks=True, # Refit after Cook's outlier removal
n_cpus=4 # Parallel threads
)
# Run: size factors → dispersions → trend → MAP shrinkage → LFC fitting
dds.deseq2()
# Inspect normalization
print(f"Size factors (first 5): {dds.obsm['size_factors'][:5]}")
print(f"Size factor range: {dds.obsm['size_factors'].min():.2f} - {dds.obsm['size_factors'].max():.2f}")
```
### Step 4: Statistical Testing (Wald Test)
Perform Wald tests to identify differentially expressed genes. Specify the contrast as `[variable, test_level, reference_level]`.
```python
from pydeseq2.ds import DeseqStats
ds = DeseqStats(
dds,
contrast=["condition", "treated", "control"],
alpha=0.05, # FDR threshold
cooks_filter=True, # Filter Cook's outliers
independent_filter=True # Independent filtering for power
)
ds.summary()
# Access full results
results = ds.results_df
print(f"Total genes tested: {len(results)}")
print(f"Significant (padj < 0.05): {(results.padj < 0.05).sum()}")
```
### Step 5: LFC Shrinkage (Optional)
Apply apeGLM shrinkage to reduce noise in log2 fold change estimates. Use shrunk values for visualization and ranking, not for significance calls.
```python
# Apply shrinkage — modifies results_df.log2FoldChange in place
ds.lfc_shrink()
# Compare pre/post shrinkage effect
print(f"Max |LFC| after shrinkage: {results.log2FoldChange.abs().max():.2f}")
print(f"Genes with |LFC| > 2: {(results.log2FoldChange.abs() > 2).sum()}")
```
### Step 6: Result Filtering and Export
Filter significant genes and export results for downstream analysis.
```python
import numpy as np
# Significance + effect size filter
significant = results[
(results.padj < 0.05) &
(results.log2FoldChange.abs() > 1.0)
].copy()
# Separate up/down-regulated
up = significant[significant.log2FoldChange > 0].sort_values("padj")
down = significant[significant.log2FoldChange < 0].sort_values("padj")
print(f"Upregulated: {len(up)}, Downregulated: {len(down)}")
# Export
results.to_csv("deseq2_all_results.csv")
significant.to_csv("deseq2_significant.csv")
up.to_csv("deseq2_upregulated.csv")
down.to_csv("deseq2_downregulated.csv")
print("Results exported to CSV files")
```
### Step 7: Visualization — Volcano Plot
Prepare the DEG table, then **read `skills/data-visualization/omics-plotting/SKILL.md` and follow its "Volcano" recipe** on the exported CSV (→ `figures/volcano_plot.png`).
```python
# Volcano input = log2FoldChange vs padj; saved in Step 6 as deseq2_all_results.csv
volcano_input = results[["log2FoldChange", "padj"]].dropna()
print(f"Volcano input: {len(volcano_input)} genes -> deseq2_all_results.csv")
# Render with the omics-plotting SKILL (`skills/data-visualization/omics-plotting/SKILL.md`) "Volcano" recipe.
```
### Step 8: Visualization — MA Plot
Prepare the DEG table, then **read `skills/data-visualization/omics-plotting/SKILL.md` and follow its "MA plot" recipe** on the exported CSV (→ `figures/ma_plot.png`).
```python
# MA input = log2FoldChange vs mean normalized count (baseMean); from deseq2_all_results.csv
ma_input = results[["baseMean", "log2FoldChange", "padj"]].dropna(subset=["padj"])
print(f"MA input: {len(ma_input)} genes -> deseq2_all_results.csv")
# Render with the omics-plotting SKILL (`skills/data-visualization/omics-plotting/SKILL.md`) "MA plot" recipe.
```
## Key Parameters
| Parameter | Default | Range / Options | Effect |
|-----------|---------|-----------------|--------|
| `design` | (required) | Wilkinson formula | Model formula; put covariates before variable of interest |
| `contrast` | `None` | `[var, test, ref]` | Which comparison to test; `None` uses last coefficient |
| `alpha` | `0.05` | `0.01`–`0.10` | FDR threshold for significance calling |
| `refit_cooks` | `True` | `True`/`False` | Refit model after removing Cook's distance outliers |
| `cooks_filter` | `True` | `True`/`False` | Apply Cook's distance filtering during testing |
| `independent_filter` | `True` | `True`/`False` | Independent filtering to optimize detection power |
| `n_cpus` | `1` | `1`–`N` | Number of parallel threads for dispersion fitting |
| `min_total_counts` (user) | `10` | `5`–`50` | Gene filtering: minimum total reads across all samples |
| `lfc_shrink()` | off | call after `summary()` | apeGLM shrinkage; reduces noisy LFC estimates |
## Common Recipes
### Recipe: Multiple Contrasts from One Model
When comparing multiple treatment groups against a shared control.
```python
dds = DeseqDataSet(counts=counts_df, metadata=metadata, design="~condition")
dds.deseq2()
contrasts = {
"A_vs_ctrl": ["condition", "treatment_A", "control"],
"B_vs_ctrl": ["condition", "treatment_B", "control"],
"C_vs_ctrl": ["condition", "treatment_C", "control"],
}
for name, contrast in contrasts.items():
ds = DeseqStats(dds, contrast=contrast, alpha=0.05)
ds.summary()
n_sig = (ds.results_df.padj < 0.05).sum()
ds.results_df.to_csv(f"results_{name}.csv")
print(f"{name}: {n_sig} significant genes")
```
### Recipe: Batch-Corrected Analysis
When samples come from multiple batches or sequencing runs.
```python
# Verify batch is not confounded with condition
print(pd.crosstab(metadata["batch"], metadata["condition"]))
dds = DeseqDataSet(
counts=counts_df, metadata=metadata,
design="~batch + condition" # batch first, then variable of interest
)
dds.deseq2()
ds = DeseqStats(dds, contrast=["condition", "treated", "control"])
ds.summary()
print(f"Significant genes (batch-corrected): {(ds.results_df.padj < 0.05).sum()}")
```
### Recipe: P-value Distribution QC
Diagnostic check — a healthy analysis shows a flat histogram with a spike near 0.
```python
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# P-value histogram
axes[0].hist(ds.results_df.pvalue.dropna(), bins=50, edgecolor="black")
axes[0].set_xlabel("P-value")
axes[0].set_ylabel("Frequency")
axes[0].set_title("P-value Distribution")
# Dispersion plot
axes[1].scatter(
np.log10(dds.varm["_normed_means"] + 1),
np.log10(dds.varm["dispersions"]),
alpha=0.3, s=5
)
axes[1].set_xlabel("Log10(Mean Expression + 1)")
axes[1].set_ylabel("Log10(Dispersion)")
axes[1].set_title("Dispersion vs Mean")
plt.tight_layout()
plt.savefig("qc_diagnostics.png", dpi=300)
print("Saved qc_diagnostics.png")
```
### Recipe: Save and Reload DESeq Dataset
For resuming analysis without re-running the expensive fitting step.
```python
import pickle
# Save
with open("dds_fitted.pkl", "wb") as f:
pickle.dump(dds.to_picklable_anndata(), f)
print("Saved fitted DESeqDataSet")
# Reload
with open("dds_fitted.pkl", "rb") as f:
adata = pickle.load(f)
# Note: reload requires re-constructing DeseqDataSet from the AnnData
```
## Expected Outputs
- `deseq2_all_results.csv` — Full results table (baseMean, log2FoldChange, lfcSE, stat, pvalue, padj) for all tested genes
- `deseq2_significant.csv` — Filtered results (padj < 0.05 and |LFC| > 1)
- `deseq2_upregulated.csv` — Significant upregulated genes sorted by padj
- `deseq2_downregulated.csv` — Significant downregulated genes sorted by padj
- `figures/volcano_plot.png` — Volcano plot with significance and fold change thresholds
- `figures/ma_plot.png` — MA plot showing fold change vs mean expression
- `figures/qc_diagnostics.png` — P-value distribution and dispersion plot
## Troubleshooting
| Problem | Cause | Solution |
|---------|-------|----------|
| `ValueError: Index mismatch` | Sample names differ between counts and metadata | Use `counts_df.index.intersection(metadata.index)` to align |
| All genes have `padj = NaN` | Genes have zero variance or all zero counts | Apply stricter gene filtering (increase `min_total_counts`) |
| `Design matrix is not full rank` | Confounded variables (e.g., all treated in one batch) | Check with `pd.crosstab()`; simplify design or remove confounded variable |
| No significant genes found | Small effect size, high variability, or low sample size | Check p-value distribution; relax alpha or |LFC| threshold; inspect QC plots |
| `MemoryError` during fitting | Too many genes or very large dataset | Pre-filter more aggressively; reduce `n_cpus`; use machine with more RAM |
| Very large size factors (>5) | Extreme library size differences | Verify raw counts are unnormalized; check for contamination or failed libraries |
| Shrinkage produces unexpected LFCs | Calling `lfc_shrink()` before `summary()` | Always call `ds.summary()` first, then `ds.lfc_shrink()` |
## References
- [PyDESeq2 Documentation](https://pydeseq2.readthedocs.io/) — Official API reference and tutorials
- [Muzellec et al. (2023)](https://doi.org/10.1093/bioinformatics/btad547) — PyDESeq2: a Python package for bulk differential expression analysis with DESeq2. Bioinformatics.
- [Love et al. (2014)](https://doi.org/10.1186/s13059-014-0550-8) — Moderated estimation of fold change and dispersion for RNA-seq data with DESeq2. Genome Biology.
- [DESeq2 Vignette (Bioconductor)](https://bioconductor.org/packages/release/bioc/vignettes/DESeq2/inst/doc/DESeq2.html) — Comprehensive R DESeq2 guide with design formula examples
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!