Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Differential expression per cell type

This page runs a pseudobulk differential-expression analysis from cells to results: it simulates single cells from six donors, each sampled before and after a stimulation, pools them into a cube of counts per sample and cell type, saves the cube and opens it again, tests the stimulation in every cell type with limma-voom, and then follows one result back through its provenance record to the profiles of the cube that produced it. Every step of the test matches limma and edgeR, as the Validation page shows, also on published single-cell data.

Pooling the cells of each sample first makes the sample, not the cell, the unit of replication: cells from one donor are not independent observations, and a test that treated them as such would find differences that do not hold across donors.

numpy 2.5.4 and numba 0.68.0

Simulate cells from six donors

Six donors (D1 to D6) each give a control and a stimulated sample: twelve samples in a paired design. Every sample holds four cell types in different numbers: many T cells, fewer B and NK cells, and few dendritic cells (DC). Donor D1 has almost no NK cells.

Each cell type has its own expression profile over 1,500 genes. A donor shifts every gene a little, in all its samples alike, and the stimulation changes 150 genes of each cell type, a different set for each, by 0.8 to 2 on the log2 scale. Cells differ in depth, 1,500 to 6,000 reads, and in their expression around the profile.

2,987 cells x 1,500 genes

Pool the cells into a cube

from_cells sums the counts of the cells of every sample and cell type into a cube: one pseudobulk profile per sample and cell type, with the number of cells behind each. The sample table travels with the cube, and so does a provenance record: how the cube was built, and a record of where the cells came from, source, which the caller gives and CytePool keeps as it is.

Cube with 12 samples x 4 groups x 1,500 features
  layers: counts (uint32)
  filled: 48 of 48 sample x group entries have cells (100.0%)
  samples: sample_id, donor, condition
  groups: group_id
  features: feature_id

cells per sample and cell type:
              B    DC    NK     T
D1_ctrl      57     7     3   155
D1_stim      54    12     3   165
D2_ctrl      56     8    29   140
D2_stim      56     9    23   174
D3_ctrl      56     8    29   144
D3_stim      60     5    35   170
D4_ctrl      58     6    24   138
D4_stim      61    10    41   178
D5_ctrl      58     7    45   152
D5_stim      55    15    35   138
D6_ctrl      53    14    33   156
D6_stim      59    10    38   145

Save the cube and open it again

write_cube saves the cube as a Zarr v3 store, and read_cube opens it without reading its counts: the layer stays on disk and is read one cell type at a time, when the analysis needs it. A cube far larger than memory can therefore be analysed group by group.

A cube’s fingerprint names its values: its tables, cell numbers and every group of every layer. The file stores the digests the fingerprint is built from, so the opened cube knows its fingerprint without reading its counts, and it is the in-memory cube’s.

Cube with 12 samples x 4 groups x 1,500 features
  layers: counts (uint32, lazy)
  filled: 48 of 48 sample x group entries have cells (100.0%)
  samples: sample_id, donor, condition
  groups: group_id
  features: feature_id
same fingerprint: True

Describe the design

The design is built from the cube’s sample table. ~ donor + condition compares the two samples of each donor, so differences between donors do not count as noise. The named contrast stim_vs_ctrl is the coefficient of the stimulation. limma_voom matches the design’s samples to the cube’s by sample_id, never by position.

Design: 12 samples x 7 columns, formula '~ donor + condition'
  columns: (Intercept), donorD2, donorD3, donorD4, donorD5, donorD6, conditionstim
  contrasts: stim_vs_ctrl
Design check: 12 samples, 7 columns, rank 7, 5 residual degrees of freedom; no problems found.

Test every cell type

limma_voom runs the limma-voom workflow of the limma and edgeR User’s Guides in each cell type: it leaves out the samples with fewer than 10 cells (min_cells) and keeps the rows of the design for the others, filters genes by edgeR’s filterByExpr rule with that design, computes TMM normalization factors, gives every observation a voom precision weight, fits a weighted linear model to every gene, and moderates the contrast by empirical Bayes. voom smooths its mean-variance trend with a span chosen from the number of genes tested in each cell type, as limma’s voom does since limma 3.66; span= fixes it. One cell type is in memory at a time.

DifferentialExpressionResult (limma_voom): 4 groups, 3 fitted; 1 contrast: stim_vs_ctrl
  B: 12 samples, 1447 features
  DC: not fitted (no_residual_df)
  NK: 10 samples, 1353 features
  T: 12 samples, 1495 features

The summary has one row per cell type. The dendritic cells were not fitted: too few of their samples have 10 cells or more, and the design, with an effect for each of their donors and the stimulation, fits those samples exactly, which leaves no residual degrees of freedom to estimate a variance from. The status and reason say so, and the other cell types are fitted. The NK cells were fitted without donor D1’s two samples.

B   fitted          samples used: 12 of 12, genes tested:  1447, residual df: 5
DC  no_residual_df  samples used:  5 of 12, genes tested:     0, residual df: 0
NK  fitted          samples used: 10 of 12, genes tested:  1353, residual df: 4
T   fitted          samples used: 12 of 12, genes tested:  1495, residual df: 5

no residual degrees of freedom: the design has rank 5 on the 5 samples left, so it fits them exactly and no variance can be estimated

A rank-deficient group

Without donor D1, the NK cells’ rows of the design no longer separate every column: D1 is the reference level of donor, and the column of the last donor, donorD6, equals the intercept minus the other donor columns over the remaining samples. Such a group is fitted on the columns that R’s QR decomposition keeps, as limma does, and a contrast is tested only if those samples can estimate it. The stimulation compares the two samples of each remaining donor, so it is estimable; a comparison of donor D2 with donor D1 is not, and is reported, not computed. (limma would compute it from the kept columns, where it means D2 against D6.)

left out: {'D1_ctrl': '3 cells, fewer than min_cells (10)', 'D1_stim': '3 cells, fewer than min_cells (10)'}
aliased columns: ['donorD6']
no_estimable_contrast
not estimable from the samples of this group, which cannot separate the effects it depends on: donorD6 = (Intercept) - donorD2 - donorD3 - donorD4 - donorD5

A result table

Each cell type and contrast has a table of the tested genes in the cube’s gene order, with the columns of limma’s topTable. Here are the T-cell genes with the smallest p-values for the stimulation.

gene        log2_fold_change  average_log2_cpm                 t  adjusted_p_value
gene_1458               1.95              13.2              53.6         1.49e-163
gene_0090               2.11              10.3              44.4         9.93e-140
gene_0188               1.87              11.1              43.3         9.52e-137
gene_0069               1.68              11.9              42.8         1.89e-135
gene_1305              -1.79              11.1               -42         3.46e-133
gene_0814               1.57                12              40.3         1.89e-128
gene_0502               1.71                11              39.9         4.26e-127
gene_0530               1.88              10.3              39.6          2.3e-126

The simulation knows which genes the stimulation changed. At a 5% false discovery rate, few discoveries should be false, and most changed genes that pass the filter should be found.

T  : 150 of 150 changed genes found; 5 of 155 discoveries false
B  : 145 of 148 changed genes found; 4 of 149 discoveries false
NK : 138 of 143 changed genes found; 6 of 144 discoveries false
Volcano plot of the T-cell genes: log2 fold change of the stimulation from about -2 to 2 against minus log10 p-value. Genes significant at 5% FDR are red and lie at fold changes beyond about 0.5 in either direction; the others are grey and cluster around zero.

Options for pseudobulk data

By default limma_voom runs limma’s standard workflow. Four options, each off by default, are common in pseudobulk analyses:

  • min_counts leaves out the samples with fewer counts in a cell type, summed over every gene, as min_cells does for cells; the scverse single-cell best practices keep samples with at least 10 cells and 1,000 counts.

  • quality_weights=True estimates a weight for each sample from how closely its values follow the model, as limma’s voomWithQualityWeights does, so that a sample of poor quality counts less instead of being left out.

  • prior_weights="n_cells" weights each sample by its number of cells instead, as the dreamlet paper does; it cannot be combined with quality weights.

  • adjust_across_groups=True adds a column of p-values adjusted over every cell type that tests the contrast, besides the adjustment within each cell type, as muscat reports both.

The sample table, and the record of each cell type, keep the weights that entered its fit.

NK samples: cells and quality weight
  D2_ctrl    29   0.94
  D2_stim    23   0.94
  D3_ctrl    29   1.05
  D3_stim    35   1.06
  D4_ctrl    24   0.97
  D4_stim    41   0.96
  D5_ctrl    45   0.99
  D5_stim    35   1.05
  D6_ctrl    33   1.05
  D6_stim    38   0.99

T  : 155 genes called within the cell type, 155 across
B  : 149 genes called within the cell type, 149 across
NK : 145 genes called within the cell type, 144 across

Every sample here was simulated alike, so the quality weights stay near one, and the adjustment over the three cell types calls nearly the same genes as the adjustment within each. In data with a sample of poor quality, such as one from an outlying donor, its weight falls below the others. In this paired design its donor’s other sample gets a similar weight: the residuals of a donor’s two samples are equal and opposite, up to the voom weights.

Trace a result back to the cube

The result carries a provenance record, plain JSON, that says what produced every number: the method and settings; the CytePool, numpy, numba and Python versions; the cube, by its fingerprint, the digests of its parts and its own provenance; the design, with the values of its variables for every sample and the weights of every contrast; and, for every cell type, the samples that entered its fit, in the order they were fitted, those left out with the reason, and the span of its voom trend.

method: limma_voom | versions of: cytepool, numpy, numba, python
cube fingerprint: sha256:986da97c859afbc5d455bb29c5e403ea5b552f5b1d35cedf48a7bc5259fba397
{"min_cells": 10, "expression_filter": {"min_count": 10.0, "min_total_count": 15.0, "large_n": 10.0, "min_prop": 0.7}, "normalization": "tmm", "span": null, "robust": false}
T-cell samples: ['D1_ctrl', 'D1_stim', 'D2_ctrl', 'D2_stim', 'D3_ctrl', 'D3_stim', 'D4_ctrl', 'D4_stim', 'D5_ctrl', 'D5_stim', 'D6_ctrl', 'D6_stim']
T-cell voom span: 0.526

A number in the T-cell table comes from the counts of the T cells of these twelve samples, the profiles (sample, T) of the cube with that fingerprint: all of their genes enter the filter, the normalization factors and the voom trend. A cube at hand is that cube when its fingerprint is the one recorded. For the cube file this needs no read; verify_fingerprint() reads every group whole and checks it against its digest, which shows that the file still holds what was analysed. The record also keeps the digest of each group’s counts, which identifies the T-cell profiles alone. Those profiles can then be read back by their ids, and, for example, the library sizes that entered the fit recomputed from them.

fingerprint matches: True
verified by reading: True
digest of the T-cell counts: sha256:e133ff04638b37a618fa29894a9232ecc1b3bd9623484e9b28546036cc851096
profiles: (12, 1500)
library sizes agree: True

The cube’s own provenance, kept in the record, says how the cube was made, in plain terms:

Source, as the caller recorded it (unresolved: CytePool does not follow its references):
  dataset: "simulated study of six donors"
  seed: 20261025
Built by from_cells (CytePool 0.1.dev26+gbff0b44e9) from 2,987 cells:
  samples: the rows of sample_table
  groups: the sorted distinct labels
  features: ids given as a sequence
  layers: counts
  normalized values: each cell scaled to a total of 10,000, with library sizes the total count of each cell
References: CytePool resolves none on its own. The source record's references need the tool that wrote them. A fingerprint names a cube without saying where it is: Cube.fingerprint() of a cube at hand says whether it is that cube.
Fingerprint: sha256:986da97c859afbc5d455bb29c5e403ea5b552f5b1d35cedf48a7bc5259fba397

What CytePool can and cannot resolve on its own:

  • It can go from any number of a result to its cell type, its contrast and its gene, to the samples that entered that fit and those left out with the reason, and to the cube’s profiles of those samples and that cell type, by their ids. It can tell whether a cube at hand is the one analysed, by its fingerprint, and check by reading it that its values still match their digests. The record also holds the design and the settings that the fit used, and how the cube was made: here by from_cells from the number of cells shown above.

  • It cannot find the cube from its fingerprint. The fingerprint names the cube’s values, not where they are stored: keep the cube file with the results.

  • It cannot follow the source record. CytePool keeps it as it was given; what it refers to, such as a dataset or its cells, needs the tool that wrote it.

  • It cannot go from the cube back to the cells. A cube holds sums over cells, not the cells: keep the cell-level data.