Estimate immune and stromal cell composition from bulk RNA-seq using multiple algorithms. Wraps CIBERSORT, quanTIseq, EPIC, xCell, MCP-counter, TIMER, and ESTIMATE through the immunedeconv unified interface.
Installs into .claude/skills of the current project.
Are you the author of Immune Deconvolution?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/biotender-max-immune-deconvolution)
# Immune Deconvolution
Estimate immune and stromal cell composition from bulk RNA-seq using multiple algorithms. Wraps CIBERSORT, quanTIseq, EPIC, xCell, MCP-counter, TIMER, and ESTIMATE through the immunedeconv unified interface.
## When to Use This Skill
Activate when the user requests:
- Immune cell type estimation from bulk RNA-seq or microarray
- Tumor microenvironment characterization
- Immune subtype classification across a cancer cohort
- Tumor purity estimation from expression data
- Comparison of immune infiltration between conditions or subtypes
- Multi-method deconvolution benchmarking
## Inputs
| Data Type | Format | Source |
|-----------|--------|--------|
| Expression | TPM matrix (genes x samples), **not log-transformed** | TCGA via TCGAbiolinks, GEO |
| Clinical | Tabular (subtype, stage, outcome) | TCGA GDC, cBioPortal |
| Signature matrix | LM22.txt (for CIBERSORT only) | cibersortx.stanford.edu (registration required) |
All methods except TIMER and ESTIMATE need TPM with HGNC gene symbols as rownames. Raw counts and Ensembl IDs will produce wrong results silently.
---
## Preparing Input from TCGA
```r
library(TCGAbiolinks)
library(SummarizedExperiment)
query <- GDCquery(
project = "TCGA-BRCA",
data.category = "Transcriptome Profiling",
data.type = "Gene Expression Quantification",
workflow.type = "STAR - Counts"
)
GDCdownload(query, directory = "GDCdata")
se <- GDCprepare(query, directory = "GDCdata")
# Extract TPM — immunedeconv needs TPM, not counts
tpm <- assay(se, "tpm_unstrand")
# Convert Ensembl IDs to gene symbols
library(org.Hs.eg.db)
symbols <- mapIds(org.Hs.eg.db,
keys = sub("\\..*", "", rownames(tpm)),
keytype = "ENSEMBL", column = "SYMBOL", multiVals = "first")
# Drop unmapped genes, resolve duplicates by keeping highest mean expression
tpm <- tpm[!is.na(symbols), ]
rownames(tpm) <- symbols[!is.na(symbols)]
# Deduplicate: quanTIseq crashes on duplicate rownames
dups <- duplicated(rownames(tpm))
if (any(dups)) {
means <- rowMeans(tpm)
keep <- !duplicated(rownames(tpm)) |
(duplicated(rownames(tpm)) & means == ave(means, rownames(tpm), FUN = max))
tpm <- tpm[keep, ]
tpm <- tpm[!duplicated(rownames(tpm)), ] # safety net
}
# Keep only tumor samples for deconvolution
tumor_barcodes <- colData(se)$sample_type == "Primary Tumor"
tpm_tumor <- tpm[, tumor_barcodes]
```
## Method Selection
```
What do you need from the deconvolution?
Absolute cell fractions (values sum to 1, interpretable as proportions)?
-> quanTIseq: 10 immune types + "Other". Validated against flow cytometry and IHC.
-> EPIC: 6 immune types + cancer cells. Built for solid tumors.
-> CIBERSORT absolute mode: 22 immune subtypes. Requires registration.
Relative proportions (which immune cells dominate, not how much)?
-> CIBERSORT relative: 22 types. Gold standard, widely published.
-> quanTIseq also works (ignore "Other", compare immune fractions).
Enrichment scores (rank samples by infiltration, not quantify)?
-> xCell: 64 cell types. Broadest coverage, good for discovery.
-> MCP-counter: 8 immune + 2 stromal. Fewest assumptions, works well with noisy data.
Immune vs stromal vs tumor purity?
-> ESTIMATE: immune score, stromal score, purity estimate.
-> Or use "Other" fraction from quanTIseq/EPIC as purity proxy.
Cancer-type-specific correction?
-> TIMER: 6 immune types, pre-built models per TCGA cancer type.
-> ConsensusTME: cancer-specific gene sets, uses ssGSEA.
Don't know which to pick?
-> Run quanTIseq + MCP-counter + EPIC. If all three agree on a trend,
the signal is real. Disagreement means the effect is method-dependent.
```
## Score Interpretation
This matters more than method choice. Getting it wrong leads to wrong biological conclusions.
| Comparison | Valid methods | Invalid methods |
|------------|-------------|-----------------|
| **Between samples** (sample A has more CD8 T cells than B) | All methods | - |
| **Between cell types** (more B cells than T cells in sample A) | quanTIseq, EPIC, CIBERSORT | xCell, MCP-counter, TIMER |
| **Absolute quantification** (15% of cells are CD8 T cells) | quanTIseq, EPIC, CIBERSORT-abs | Everything else |
xCell and MCP-counter scores are arbitrary units. A CD8 score of 0.4 from xCell and 3.7 from MCP-counter cannot be compared to each other, and neither means "40% CD8 T cells."
---
## immunedeconv Setup
```r
# Install: wraps 9 methods in one API
# remotes::install_github("omnideconv/immunedeconv")
library(immunedeconv) # v2.1.0+
# For CIBERSORT (optional, requires registration at cibersortx.stanford.edu):
# Download CIBERSORT.R and LM22.txt, then:
# set_cibersort_binary("/path/to/CIBERSORT.R")
# set_cibersort_mat("/path/to/LM22.txt")
```
## quanTIseq
```r
qt <- deconvolute(tpm_tumor, method = "quantiseq")
# Returns tibble: cell_type column + one column per sample
# Cell types: B cell, Macrophage M1, Macrophage M2, Monocyte, Neutrophil,
# NK cell, T cell CD4+ (non-regulatory), T cell CD8+,
# T cell regulatory (Tregs), Myeloid dendritic cell,
# uncharacterized cell
# Fractions sum to 1 per sample. "uncharacterized cell" = tumor + stroma.
# In solid tumors, "uncharacterized cell" should be 0.5-0.9.
# If < 0.3, check if sample is actually a lymphoma or immune-rich tissue.
```
## EPIC
```r
epic <- deconvolute(tpm_tumor, method = "epic")
# Cell types: B cell, CAFs, CD4+ T cell, CD8+ T cell,
# Endothelial, Macrophage, NK cell, otherCells
# otherCells = uncharacterized (mostly tumor in solid cancers)
# EPIC was specifically built for solid tumors — preferred over
# quanTIseq when you need a cancer cell fraction estimate
```
## CIBERSORT
```r
# Requires setup (see above). LM22 has 22 immune subtypes:
# naive/memory B cells, plasma, CD8/CD4 subtypes, gamma-delta T,
# NK resting/activated, monocytes, M0/M1/M2 macrophages,
# DC resting/activated, mast resting/activated, eosinophils, neutrophils
cib <- deconvolute(tpm_tumor, method = "cibersort")
# Relative fractions (sum to 1 across 22 types)
cib_abs <- deconvolute(tpm_tumor, method = "cibersort_abs")
# Absolute mode: scales by total immune content
# CIBERSORT reports a p-value per sample from permutation testing.
# p > 0.05 means the deconvolution fit is unreliable — flag or exclude.
# Access via running CIBERSORT directly (not through immunedeconv wrapper).
```
## xCell
```r
xc <- deconvolute(tpm_tumor, method = "xcell")
# 64 cell types including fine subtypes:
# T cell subtypes (Th1, Th2, Th17, Treg, CD8 naive, CD8 effector, etc.)
# B cell subtypes (naive, memory, plasma)
# Myeloid (M1, M2, DC subsets)
# Stromal (fibroblasts, endothelial, pericytes)
# Plus composite: ImmuneScore, StromalScore, MicroenvironmentScore
# xCell uses ssGSEA followed by spillover correction.
# Scores are enrichment-based — only compare within a cell type across samples.
# All-zero scores for a cell type = not enough gene signature overlap.
# Check that your gene symbols are standard HGNC.
```
## MCP-counter
```r
mcp <- deconvolute(tpm_tumor, method = "mcp_counter")
# 10 populations: T cells, CD8+ T cells, Cytotoxic lymphocytes,
# B lineage, NK cells, Monocytic lineage, Myeloid dendritic cells,
# Neutrophils, Endothelial cells, Fibroblasts
# Scores are arbitrary units based on marker gene expression.
# Uses manually curated transcriptomic markers with minimal cell-type overlap
# with minimal overlap between cell types.
# Works well even with noisy or poorly normalized data.
```
## TIMER
```r
# TIMER uses tumor-type-specific regression — requires specifying cancer type
timer <- deconvolute(tpm_tumor, method = "timer",
indications = rep("brca", ncol(tpm_tumor)))
# Valid indications: lowercase TCGA codes (brca, luad, coad, gbm, skcm, etc.)
# Returns 6 types: B cell, CD4+ T cell, CD8+ T cell,
# Neutrophil, Macrophage, Dendritic cell
# If your cancer type is not in TIMER's pre-built models, use another method.
# TIMER needs at least 2 samples — single-sample deconvolution will fail.
```
## ESTIMATE
```r
# ESTIMATE: stromal score, immune score, tumor purity
# Oldest method (2013) but still the standard for purity estimation
est <- deconvolute_estimate(tpm_tumor)
# Returns: StromalScore, ImmuneScore, ESTIMATEScore, TumorPurity
# TumorPurity is derived from: cos(0.6049872018 + 0.0001467884 * ESTIMATEScore)
# This conversion was calibrated on Agilent microarrays.
# For RNA-seq, treat purity as approximate — the rank ordering is reliable,
# the absolute values less so.
```
## Tumor Purity Correction
```
Why purity matters:
Low purity sample = fewer tumor cells = immune cells are a larger fraction
High purity sample = more tumor cells = immune signal is compressed
If group A has lower purity than group B on average, deconvolution
will show group A as "more immune" even if the actual immune
infiltration per unit tissue is identical.
When to correct:
Comparing immune infiltration between groups
-> Include ESTIMATE purity as covariate in linear model
Correlating deconvolution scores with mutations or expression
-> Partial correlation controlling for purity
Filtering bad samples
-> Remove samples with ESTIMATE purity < 0.2 (likely failed or lymphomas)
When NOT to correct:
Reporting absolute immune fractions from quanTIseq/EPIC
-> These already account for non-immune content via "Other"/"otherCells"
Comparing within the same tumor type with similar purity distributions
-> Correction adds noise without removing bias
```
```r
# Purity as covariate
est_scores <- deconvolute_estimate(tpm_tumor)
purity <- as.numeric(est_scores["TumorPurity", ])
sample_info$purity <- purity
# Linear model with purity adjustment
cd8_scores <- as.numeric(mcp[mcp$cell_type == "T cell CD8+", -1])
fit <- lm(cd8_scores ~ subtype + purity, data = sample_info)
summary(fit)
# Subtype coefficients are now purity-adjusted
# Partial correlation
library(ppcor)
pcor_result <- pcor.test(
x = cd8_scores,
y = sample_info$mutation_load,
z = purity,
method = "spearman"
)
```
## Cross-Method Comparison
```r
# Run multiple methods and compare CD8+ T cell estimates
methods_to_run <- c("quantiseq", "epic", "mcp_counter", "xcell")
all_res <- lapply(methods_to_run, function(m) {
deconvolute(tpm_tumor, method = m)
})
names(all_res) <- methods_to_run
# Extract CD8 scores — each method uses slightly different cell type names
cd8_names <- c(
quantiseq = "T cell CD8+",
epic = "T cell CD8+",
mcp_counter = "T cell CD8+",
xcell = "T cell CD8+"
)
cd8_mat <- sapply(methods_to_run, function(m) {
res <- all_res[[m]]
as.numeric(res[res$cell_type == cd8_names[m], -1])
})
rownames(cd8_mat) <- colnames(tpm_tumor)
# Cross-method Spearman correlation
cor(cd8_mat, method = "spearman", use = "pairwise.complete.obs")
# Expect rho > 0.5 between most pairs for CD8+ T cells
# rho < 0.3 for most pairs: check input format (log-transformed? wrong gene IDs?)
```
## Visualization
```r
library(ggplot2)
# --- Stacked bar plot (quanTIseq fractions) ---
qt_long <- tidyr::pivot_longer(qt, -cell_type,
names_to = "sample", values_to = "fraction")
ggplot(qt_long, aes(x = sample, y = fraction, fill = cell_type)) +
geom_bar(stat = "identity", position = "stack") +
theme_classic(base_size = 12) +
theme(axis.text.x = element_blank(), axis.ticks.x = element_blank()) +
labs(y = "Cell fraction", x = "Samples", fill = "Cell type")
# --- Boxplot by molecular subtype ---
score_df <- data.frame(
sample = colnames(tpm_tumor),
CD8 = as.numeric(mcp[mcp$cell_type == "T cell CD8+", -1]),
subtype = sample_info$subtype
)
ggplot(score_df, aes(x = reorder(subtype, CD8, FUN = median), y = CD8, fill = subtype)) +
geom_boxplot(outlier.size = 0.5) +
geom_jitter(width = 0.15, alpha = 0.3, size = 0.5) +
theme_classic(base_size = 12) +
labs(y = "MCP-counter CD8+ T cell score", x = NULL) +
theme(legend.position = "none")
# --- Cross-method correlation heatmap ---
library(ComplexHeatmap); library(circlize)
cor_mat <- cor(cd8_mat, method = "spearman", use = "pairwise.complete.obs")
col_fun <- colorRamp2(c(0, 0.5, 1), c("#2166AC", "white", "#B2182B"))
Heatmap(cor_mat, name = "Spearman\nrho", col = col_fun,
cell_fun = function(j, i, x, y, w, h, fill) {
grid.text(sprintf("%.2f", cor_mat[i, j]), x, y, gp = gpar(fontsize = 10))
},
column_title = "Cross-method concordance: CD8+ T cells")
# --- ESTIMATE purity vs immune score scatter ---
est_df <- data.frame(
purity = as.numeric(est_scores["TumorPurity", ]),
immune = as.numeric(est_scores["ImmuneScore", ]),
subtype = sample_info$subtype
)
ggplot(est_df, aes(purity, immune, color = subtype)) +
geom_point(alpha = 0.5, size = 1.5) +
theme_classic(base_size = 12) +
labs(x = "ESTIMATE tumor purity", y = "ESTIMATE immune score")
```
## Output Specification
| Output | Format | Description |
|--------|--------|-------------|
| `quantiseq_fractions.csv` | CSV | Absolute cell fractions per sample (sum to 1) |
| `epic_fractions.csv` | CSV | Absolute fractions including cancer cell estimate |
| `cibersort_fractions.csv` | CSV | 22-type relative or absolute fractions |
| `xcell_scores.csv` | CSV | 64 cell type enrichment scores |
| `mcp_scores.csv` | CSV | 10 population scores (arbitrary units) |
| `estimate_scores.csv` | CSV | Immune score, stromal score, purity per sample |
| `method_correlation.csv` | CSV | Cross-method Spearman rho per cell type |
| `immune_boxplot.pdf` | PDF | Cell scores by molecular subtype |
| `stacked_barplot.pdf` | PDF | Per-sample immune composition |
## Validation Checks
```
After running deconvolution on TCGA-BRCA, verify:
Subtype ordering (consistent across methods):
Basal-like should have highest CD8+ T cell scores
Luminal A should have lowest overall immune infiltration
HER2-enriched sits between Basal and Luminal B
If Luminal A ranks highest for immune infiltration, something is wrong.
quanTIseq sanity:
"uncharacterized cell" in solid tumors should be 0.5-0.9
Sum of all fractions = 1.0 per sample (within floating point tolerance)
Macrophage M2 typically > M1 in most solid tumors
If all CD8 fractions are 0: gene symbols may not match quanTIseq signature
ESTIMATE sanity:
TCGA-BRCA median purity: ~0.75
Basal: lower purity (0.55-0.70), Luminal A: higher purity (0.80-0.90)
If all purities cluster near 1.0 or near 0: wrong platform setting or wrong input scale
Cross-method concordance:
CD8+ T cell Spearman rho > 0.5 between quanTIseq, EPIC, MCP-counter
rho < 0.3 across most pairs: input is likely log-transformed or has wrong gene IDs
Red flags:
CIBERSORT p > 0.05 for > 30% of samples: signature doesn't fit this cohort
xCell returns all zeros for many cell types: gene overlap too low
TIMER returns NA: cancer type not in the pre-built model set
quanTIseq crashes: duplicate gene symbols in rownames (must deduplicate)
Only 1 sample: TIMER and ConsensusTME need >= 2 samples per cancer type
```
## Common Pitfalls
### Input
1. **Raw counts instead of TPM**: quanTIseq, EPIC, xCell, MCP-counter, and CIBERSORT expect TPM. Feeding raw counts compresses low-expression signatures and inflates high-expression ones. Convert first: `tpm <- counts / gene_lengths * 1e6 / colSums(counts / gene_lengths)`.
2. **Log-transformed input**: Most methods expect linear-scale TPM. Passing log2(TPM+1) flattens the dynamic range and produces wrong fractions. The immunedeconv docs say explicitly: "data must be on non-log scale."
3. **Ensembl IDs as rownames**: Every signature matrix uses HGNC gene symbols. Ensembl IDs will match nothing, and most methods fail silently with near-zero results rather than throwing an error.
4. **Duplicate gene symbols**: quanTIseq crashes on duplicate rownames. Deduplicate before calling any method — keep the row with highest mean expression.
### Interpretation
5. **Treating scores as fractions**: xCell and MCP-counter output enrichment scores, not proportions. Saying "20% CD8 T cells" from an MCP-counter score of 3.7 is meaningless. Only quanTIseq, EPIC, and CIBERSORT return interpretable fractions.
6. **Comparing values across methods**: A CD8 estimate of 0.12 from quanTIseq and 4.8 from MCP-counter are on completely different scales. Compare ranks or standardized scores, never raw values.
7. **Ignoring tumor purity**: A low-purity sample will show high immune fractions simply because the denominator (total cells) contains fewer tumor cells. Control for purity when comparing groups with different purity distributions.
8. **Treating deconvolution as ground truth**: These are estimates. Cross-method concordance increases confidence; single-method results are hypotheses. For publication, validate key findings with IHC, flow cytometry, or spatial transcriptomics.
### Method-specific
9. **CIBERSORT without checking fit p-values**: Samples with p > 0.05 have unreliable estimates. Excluding these is standard practice but often forgotten.
10. **TIMER on unsupported cancer types**: TIMER's regression models are pre-built for specific TCGA indications. Running it on a cancer type not in the training set gives uninterpretable results.
11. **Single-sample runs with TIMER or ConsensusTME**: These methods need at least 2 samples of the same cancer type. A single sample will either error or return NAs.
12. **Mixing microarray and RNA-seq without batch correction**: Deconvolution signatures are sensitive to platform effects. Don't pool microarray and RNA-seq samples without explicit normalization.
## Advanced: Single-Cell Reference-Based Deconvolution
When matched scRNA-seq data is available for the same tissue type, second-generation methods outperform signature-based approaches.
```r
# BayesPrism: top performer in 2024-2025 benchmarks
# install.packages("BayesPrism") # or InstaPrism for faster runtime
library(BayesPrism)
# Requires: scRNA-seq reference (genes x cells) + cell type labels
# bulk: genes x samples matrix (counts, not TPM)
bp <- new.prism(
reference = sc_counts, # raw counts from scRNA-seq reference
mixture = bulk_counts, # raw counts from bulk samples
input.type = "count.matrix",
cell.type.labels = cell_labels,
cell.state.labels = cell_states # finer subtypes, optional
)
result <- run.prism(bp, n.cores = 4)
theta <- get.fraction(result, which = "final") # samples x cell_types
# BayesPrism returns posterior mean fractions that sum to 1
```
```
When to use scRNA-seq-based methods vs signature-based:
Have matched scRNA-seq reference for the same tissue type?
-> BayesPrism or DWLS. Better accuracy than fixed signatures.
No scRNA-seq reference, just bulk RNA-seq?
-> quanTIseq + EPIC + MCP-counter (signature-based).
Public scRNA-seq atlas exists for the tissue?
-> Consider BayesPrism with the atlas as reference, but be aware
of batch effects between your bulk data and the atlas.
```
## Related Skills
- [`cancer-multiomics`](../cancer-multiomics/SKILL.md): TCGA data retrieval and expression matrix preparation (input for deconvolution)
- [`survival-analysis`](../survival-analysis/SKILL.md): Use deconvolution scores as covariates in Cox models or KM stratification
## Public Datasets for Testing
| Dataset | Samples | Use Case |
|---------|---------|----------|
| TCGA-BRCA | 1098 | Breast cancer, strong immune subtype differences (Basal vs Luminal) |
| TCGA-SKCM | 472 | Melanoma, high immune infiltration, immunotherapy response data |
| TCGA-LUAD | 585 | Lung adenocarcinoma, mixed immune landscape |
| TCGA-COAD | 521 | Colorectal, MSI-H tumors are immune-hot vs MSS cold |