Introduction to CellDEEP

Single-cell RNA sequencing (scRNA-seq) provides gene expression measurements at single-cell resolution, but its high sparsity and dropout rates can make differential expression (DE) analysis challenging.

CellDEEP reduces scRNA-seq sparsity by pooling cells into pseudocells before differential expression testing. It was developed to bridge the gap between robust but coarse pseudobulk approaches and sensitive but potentially biased cell-level methods.

CellDEEP provides two main workflows:

  1. Run differential expression directly with FindMarker.CellDEEP(). This is the recommended workflow when DE testing is your final goal.
  2. Generate pseudocells only with CellDEEP.Kmean() or CellDEEP.Random(). This is useful when you want to inspect or reuse the pooled object in downstream analyses.

These are alternative workflows. You do not need to run the pooling functions before using FindMarker.CellDEEP().

Load CellDEEP and example data

Load CellDEEP and the example sim dataset:

library(CellDEEP)

data("sim")

The example object contains the metadata columns used throughout this vignette:

  • Status: biological group
  • DonorID: sample or donor
  • cluster_id: cell cluster

These column names are passed directly to CellDEEP, so your own Seurat object does not need to use these exact names.

Workflow 1: Differential expression with CellDEEP

The simplest way to use CellDEEP is with FindMarker.CellDEEP().

This function handles metadata preparation, pseudocell pooling, and differential expression testing in a single workflow.

Important arguments include:

  • group_id: metadata column defining the biological groups to compare
  • sample_id: metadata column identifying samples or donors
  • cluster_id: metadata column identifying cell clusters
  • ident.1 and ident.2: groups to compare
  • cell_selection: method used to construct pseudocells ("kmean" or "random")
  • readcounts: method used to aggregate expression within pseudocells ("sum" or "mean")
  • n_cells: number of cells contributing to each pseudocell
  • min_cells_per_subgroup: minimum number of cells required within each sample-cluster subgroup before pooling
  • test.use: differential expression method passed to the DE analysis
  • logfc.threshold: minimum log-fold-change threshold

For example, we can compare Case and Control cells using random pooling:

de_genes <- FindMarker.CellDEEP(
  sim,
  group_id = "Status",
  sample_id = "DonorID",
  cluster_id = "cluster_id",
  Pool = TRUE,
  test.use = "MAST",
  n_cells = 3,
  min_cells_per_subgroup = 1,
  cell_selection = "random",
  readcounts = "sum",
  logfc.threshold = 0.25,
  ident.1 = "Case",
  ident.2 = "Control"
)
#> Start Pooling.....
#> Pooling...
#> Warning: Data is of class matrix. Coercing to dgCMatrix.
#> Pooling summary (random):
#> Input cells: 200
#> Cells kept in pooled pseudocells: 180
#> Cells not kept (approx): 20
#> Skipped empty groups: 0
#> Skipped empty clusters: 0
#> Skipped empty samples: 20
#> Skipped subgroups (<= min_cells_per_subgroup): 0
#> Dropped remainder cells (< n_cells) after random pooling: 20
#> FindMarker running.....
#> 1st ident is:
#> Case
#> 2nd ident is:
#> Control
#> group by:
#> group_id
#> Normalizing layer: counts
#> Finding variable features for layer counts
#> Centering and scaling data matrix
#> 
#> Done!
#> Combining coefficients and standard errors
#> Calculating log-fold changes
#> Calculating likelihood ratio tests
#> Refitting on reduced model...
#> 
#> Done!
#> 27
#> Gene1728Gene1992Gene1864Gene1715Gene1807Gene1626

head(de_genes)
#> [1] "Gene1728" "Gene1992" "Gene1864" "Gene1715" "Gene1807" "Gene1626"

By default, CellDEEP returns the names of genes passing the differential expression filters.

Controlling the differential expression output

FindMarker.CellDEEP() allows you to control how much information is returned.

Depending on your downstream analysis, you can obtain:

  1. significant gene names only;
  2. a filtered DE table containing statistics such as p-values and log-fold changes; or
  3. the complete, unfiltered DE table.

Return significant gene names only

By default, name.only = TRUE.

Therefore, the standard CellDEEP call returns the names of genes passing the DE filters:

de_genes <- FindMarker.CellDEEP(
  sim,
  group_id = "Status",
  sample_id = "DonorID",
  cluster_id = "cluster_id",
  Pool = TRUE,
  test.use = "MAST",
  n_cells = 3,
  min_cells_per_subgroup = 1,
  cell_selection = "random",
  readcounts = "sum",
  logfc.threshold = 0.25,
  ident.1 = "Case",
  ident.2 = "Control"
)
#> Start Pooling.....
#> Pooling...
#> Warning: Data is of class matrix. Coercing to dgCMatrix.
#> Pooling summary (random):
#> Input cells: 200
#> Cells kept in pooled pseudocells: 180
#> Cells not kept (approx): 20
#> Skipped empty groups: 0
#> Skipped empty clusters: 0
#> Skipped empty samples: 20
#> Skipped subgroups (<= min_cells_per_subgroup): 0
#> Dropped remainder cells (< n_cells) after random pooling: 20
#> FindMarker running.....
#> 1st ident is:
#> Case
#> 2nd ident is:
#> Control
#> group by:
#> group_id
#> Normalizing layer: counts
#> Finding variable features for layer counts
#> Centering and scaling data matrix
#> 
#> Done!
#> Combining coefficients and standard errors
#> Calculating log-fold changes
#> Calculating likelihood ratio tests
#> Refitting on reduced model...
#> 
#> Done!
#> 27
#> Gene1728Gene1992Gene1864Gene1715Gene1807Gene1626

head(de_genes)
#> [1] "Gene1728" "Gene1992" "Gene1864" "Gene1715" "Gene1807" "Gene1626"

This output is convenient when you only need a list of differentially expressed genes for downstream analyses or visualization.

Return DE results with statistics

If you want to retain the complete statistics for genes passing the DE filters, set:

name.only = FALSE

For example:

de_results <- FindMarker.CellDEEP(
  sim,
  group_id = "Status",
  sample_id = "DonorID",
  cluster_id = "cluster_id",
  Pool = TRUE,
  test.use = "MAST",
  n_cells = 3,
  min_cells_per_subgroup = 1,
  cell_selection = "random",
  readcounts = "sum",
  logfc.threshold = 0.25,
  ident.1 = "Case",
  ident.2 = "Control",
  name.only = FALSE
)
#> Start Pooling.....
#> Pooling...
#> Warning: Data is of class matrix. Coercing to dgCMatrix.
#> Pooling summary (random):
#> Input cells: 200
#> Cells kept in pooled pseudocells: 180
#> Cells not kept (approx): 20
#> Skipped empty groups: 0
#> Skipped empty clusters: 0
#> Skipped empty samples: 20
#> Skipped subgroups (<= min_cells_per_subgroup): 0
#> Dropped remainder cells (< n_cells) after random pooling: 20
#> FindMarker running.....
#> 1st ident is:
#> Case
#> 2nd ident is:
#> Control
#> group by:
#> group_id
#> Normalizing layer: counts
#> Finding variable features for layer counts
#> Centering and scaling data matrix
#> 
#> Done!
#> Combining coefficients and standard errors
#> Calculating log-fold changes
#> Calculating likelihood ratio tests
#> Refitting on reduced model...
#> 
#> Done!

head(de_results)
#>                 p_val avg_log2FC pct.1 pct.2    p_val_adj
#> Gene1728 2.135401e-18   3.493846   1.0 0.567 4.270801e-15
#> Gene1992 5.520147e-15 -11.401061   0.0 0.900 1.104029e-11
#> Gene1864 5.149952e-14 -10.188828   0.0 0.867 1.029990e-10
#> Gene1715 5.149952e-14  -9.855602   0.0 0.867 1.029990e-10
#> Gene1807 3.904984e-13  -9.667764   0.0 0.833 7.809967e-10
#> Gene1626 7.039584e-13  -3.879532   0.2 1.000 1.407917e-09

This output is useful when you want to inspect quantities such as p-values, adjusted p-values, and log-fold changes.

It is also the appropriate format for creating DE plots or comparing CellDEEP results with another differential expression method.

Return the full DE results

In some analyses, filtering genes before downstream analysis is undesirable.

For example, methods such as Gene Set Enrichment Analysis (GSEA) generally require a ranked statistic across the full set of tested genes.

To retain the complete DE output without significance filtering, use:

name.only = FALSE
full_list = TRUE

For example:

de_full <- FindMarker.CellDEEP(
  sim,
  group_id = "Status",
  sample_id = "DonorID",
  cluster_id = "cluster_id",
  Pool = TRUE,
  test.use = "MAST",
  n_cells = 3,
  min_cells_per_subgroup = 1,
  cell_selection = "random",
  readcounts = "sum",
  logfc.threshold = 0.25,
  ident.1 = "Case",
  ident.2 = "Control",
  name.only = FALSE,
  full_list = TRUE
)
#> Start Pooling.....
#> Pooling...
#> Warning: Data is of class matrix. Coercing to dgCMatrix.
#> Pooling summary (random):
#> Input cells: 200
#> Cells kept in pooled pseudocells: 180
#> Cells not kept (approx): 20
#> Skipped empty groups: 0
#> Skipped empty clusters: 0
#> Skipped empty samples: 20
#> Skipped subgroups (<= min_cells_per_subgroup): 0
#> Dropped remainder cells (< n_cells) after random pooling: 20
#> FindMarker running.....
#> 1st ident is:
#> Case
#> 2nd ident is:
#> Control
#> group by:
#> group_id
#> Normalizing layer: counts
#> Finding variable features for layer counts
#> Centering and scaling data matrix
#> 
#> Done!
#> Combining coefficients and standard errors
#> Calculating log-fold changes
#> Calculating likelihood ratio tests
#> Refitting on reduced model...
#> 
#> Done!

head(de_full)
#>                 p_val avg_log2FC pct.1 pct.2    p_val_adj
#> Gene1728 2.135401e-18   3.493846   1.0 0.567 4.270801e-15
#> Gene1992 5.520147e-15 -11.401061   0.0 0.900 1.104029e-11
#> Gene1864 5.149952e-14 -10.188828   0.0 0.867 1.029990e-10
#> Gene1715 5.149952e-14  -9.855602   0.0 0.867 1.029990e-10
#> Gene1807 3.904984e-13  -9.667764   0.0 0.833 7.809967e-10
#> Gene1626 7.039584e-13  -3.879532   0.2 1.000 1.407917e-09

We can compare the number of genes returned by the filtered and full outputs:

nrow(de_results)
#> [1] 27
nrow(de_full)
#> [1] 1054

de_results contains genes retained after CellDEEP’s DE filtering, whereas de_full contains the complete set of tested genes.

Summary of output options

Setting Output Typical use
name.only = TRUE Significant gene names only Gene lists and quick downstream analyses
name.only = FALSE Filtered DE table with statistics Inspecting p-values, logFC, and DE results
name.only = FALSE, full_list = TRUE Full DE table without significance filtering GSEA, gene ranking, and method comparisons

Workflow 2: Generate pseudocells without DE testing

FindMarker.CellDEEP() is the simplest option when differential expression is your final goal.

However, CellDEEP can also be used to generate pseudocells without immediately performing differential expression testing.

This workflow is useful when you want to:

  • inspect the pooled data;
  • compare different pooling strategies;
  • visualize the pseudocells; or
  • use the pooled object in another downstream analysis.

This is an alternative to the workflow above; it is not a required preprocessing step for FindMarker.CellDEEP().

Prepare metadata for pooling

The standalone pooling functions use standardized metadata fields:

  • sample_id
  • group_id
  • cluster_id

Use prepare_data() to create these fields from the corresponding columns in your Seurat object:

pool_input <- prepare_data(
  sim,
  sample_id = "DonorID",
  group_id = "Status",
  cluster_id = "cluster_id"
)

The resulting object can then be passed to either of the CellDEEP pooling methods.

K-means pooling

CellDEEP.Kmean() constructs pseudocells using a k-means-based cell selection strategy.

pooled_kmean <- CellDEEP.Kmean(
  pool_input,
  readcounts = "sum",
  n_cells = 3,
  min_cells_per_subgroup = 1,
  assay_name = "RNA"
)
#> Pooling...
#> Warning: Data is of class matrix. Coercing to dgCMatrix.
#> Drop out cell number during kmean pooling is:
#> 24
#> Pooling summary (kmean):
#> Input cells: 200
#> Cells kept in pooled pseudocells: 176
#> Cells not kept (approx): 24
#> Skipped empty groups: 0
#> Skipped empty clusters: 0
#> Skipped empty samples: 20
#> Skipped subgroups (<= min_cells_per_subgroup): 0
#> Dropped singleton cells after kmeans split: 24

pooled_kmean
#> An object of class Seurat 
#> 2000 features across 56 samples within 1 assay 
#> Active assay: RNA (2000 features, 0 variable features)
#>  1 layer present: counts

Here:

  • n_cells = 3 specifies the number of cells contributing to each pseudocell;
  • readcounts = "sum" sums their counts; and
  • min_cells_per_subgroup = 1 controls the minimum number of cells required within each sample-cluster subgroup before pooling.

Random pooling

Alternatively, CellDEEP.Random() constructs pseudocells by randomly selecting cells within the corresponding subgroups.

pooled_random <- CellDEEP.Random(
  pool_input,
  readcounts = "sum",
  n_cells = 5,
  min_cells_per_subgroup = 1,
  assay_name = "RNA"
)
#> Pooling...
#> Warning: Data is of class matrix. Coercing to dgCMatrix.
#> Pooling summary (random):
#> Input cells: 200
#> Cells kept in pooled pseudocells: 160
#> Cells not kept (approx): 40
#> Skipped empty groups: 0
#> Skipped empty clusters: 0
#> Skipped empty samples: 20
#> Skipped subgroups (<= min_cells_per_subgroup): 0
#> Dropped remainder cells (< n_cells) after random pooling: 40

pooled_random
#> An object of class Seurat 
#> 2000 features across 32 samples within 1 assay 
#> Active assay: RNA (2000 features, 0 variable features)
#>  1 layer present: counts

The resulting pooled objects can be inspected or used independently in downstream analyses.

Which workflow should I use?

If your goal is differential expression, start with:

This handles the required preparation and pooling internally.

If you only need the final DE gene names, use the default name.only = TRUE.

If you need p-values, log-fold changes, and other DE statistics, use:

name.only = FALSE

If you need the complete gene ranking, for example for GSEA or comparisons with other DE methods, use:

name.only = FALSE
full_list = TRUE

If your goal is instead to construct and inspect pseudocells, use:

or:

This separation allows CellDEEP to be used either as a complete differential expression workflow or as a standalone pseudocell-generation method.