Clustering and Annotation#

This notebook demonstrates the clustering and annotation of single-cell RNA-seq data using the exprmat package, building on the integrated dataset from the previous tutorial. After dataset creation and batch correction, we apply Leiden clustering to identify cellular subpopulations, followed by multiple complementary approaches for cluster characterization. Consensus non-negative matrix factorization (cNMF) is used to decompose expression patterns into interpretable metaprogram modules. Manual annotation based on canonical marker gene expression is performed to assign cell type identities to each cluster, and differential marker analysis validates the annotations. Finally, cell type proportions across samples are examined to compare compositional differences between experimental conditions.

[1]:
%load_ext autoreload
%autoreload 2
[2]:
import exprmat as em
# set working directory
em.setwd('../../../data')
ver = em.version()
[i] exprmat 0.2.66 / exprmat-db 0.2.66
[i] os: posix (linux)  platform version: 6.8.0-90-generic
[i] loaded configuration from /home/data/yangz/.exprmatrc
[i] current working directory: /home/data/yangz/packages/exprmat/data
[i] current database directory: /home/data/yangz/packages/database (0.2.66)
[i] resident memory: 775.91 MiB
[i] virtual memory: 5.95 GiB

[12]:
expm = em.load_experiment('expm/scrna')
   ━━━━━━━━━━━━━━━━━━━━━━━━━━ loading samples          3 / 3     (00:05 < 00:00)
[4]:
print(expm)
annotated data of size 13015 × 19651
integrated dataset of size 13015 × 19651
contains modalities: rna

 modality [rna]
    obs : sample <cat> <c/sample> batch <cat> <c/batch> group <cat> <c> modality <cat> <c/modality>
          taxa <cat> <c/taxa> barcode <o> <o> ubc <o> <o> n.umi <f64> <i> n.genes <i64> <i>
          n.mito <f64> <f> n.ribo <f64> <f> pct.mito <f64> <f> pct.ribo <f64> <f>
          filter <bool> <bool> score.doublet <f64> <f> score.doublet.se <f64> <f>
          is.doublet <bool> <bool> qc <bool> <bool/qc> leiden <cat> <c>
    var : chr <cat> <c/chromosome> start <i64> <i> end <i64> <i> strand <cat> <c/strand> id <o> <o>
          subtype <cat> <c/gsubtype> gene <cat> <o/gene> tlen <f64> <i/tlen> cdslen <i64> <i/cdslen>
          assembly <cat> <c> uid <o> <o/ugene> vst.hvg <bool> <bool/hvg> vst.all.means <f64>
          vst.all.vars <f64> vst.all.vars.norm <f64> vst.all.hvg.rank <f32> vst.all.hvg <bool>
 layers : counts <f32> <i/counts>
   obsm : harmony <arr:f32(35)> knn <arr:i32(35)> knn.d <arr:f32(35)>
          pca <arr:f64(35)> <f/embedding/pca> umap <arr:f32(2)> <f/embedding>
   varm : pca <arr:f64(35)> <f/weights>
   obsp : connectivities <csr:f32> distances <csr:f32>
    uns : commands <system> leiden neighbors <knn> pca <dict> slots <system> umap

[*] composed of samples:
  distal   rna   mmu    batch b1      of size 6451 × 21689
  niche    rna   mmu    batch b2      of size 3656 × 21970
  normal   rna   mmu    batch b3      of size 4537 × 21917

After loading the integrated experiment, we apply Leiden clustering on the pre-computed Harmony embedding to partition cells into discrete populations. The resolution parameter (0.8) balances the number of clusters — higher values yield finer subdivisions. The resulting clusters serve as the basis for downstream characterization and annotation.

[6]:
expm.rna.leiden(
    resolution = 0.8,
    restrict_to = None,
    random_state = 42,
    key_added = 'leiden',
    adjacency = None,
    directed = None,
    use_weights = True,
    n_iterations = 2,
    partition_type = None,
    neighbors_key = None,
    obsp = None,
    flavor = 'igraph'
)
[7]:
fig = expm.rna.plot_embedding(
    basis = 'umap', color = 'leiden',
    figsize = (3, 3), dpi = 100, legend_col = 2, legend = False,
    ptsize = 2, annotate_style = 'text', annotate_fontsize = 8, contour_plot = True
)
../_images/scrna_b1-annotation_8_0.png

SC3 consensus clustering#

SC3 (Single-Cell Consensus Clustering) provides a complementary approach to Leiden clustering by combining multiple distance metrics and dimensionality reduction strategies through a consensus framework. It evaluates cluster stability across several values of k simultaneously, helping to identify the most robust partitions at different resolution scales. This is particularly useful for validating that the Leiden clusters represent stable biological populations rather than artifacts of the graph construction parameters.

[10]:
expm.rna.sc3(
    basis = 'harmony',
    key_added = 'sc3',
    n_clusters = [5, 10, 20, 30],
    d_range = None,
    n_runs = 5,
    n_facility = None,
    multiplier_facility = None,
    batch_size = None,
    random_state = 42
)
[i] multiplier_facility not set, using default value of 3
[i] number of facilities calculated as 90

[12]:
fig = expm.rna.plot_multiple_embedding(
    basis = 'umap', features = ['sc3.5', 'sc3.10', 'sc3.20', 'sc3.30'], ncols = 4,
    figsize = (10, 2.6), dpi = 100, legend_col = 2, legend = False,
    ptsize = 2, annotate_style = 'text', annotate_fontsize = 8
)
../_images/scrna_b1-annotation_11_0.png

NMF-based metaprogram discovery#

Non-negative matrix factorization (NMF) decomposes the gene expression matrix into two low-rank matrices: a gene-by-metaprogram matrix (W) and a metaprogram-by-cell matrix (H). Each metaprogram represents a co-expressed gene module, and cells are assigned membership scores for each module. Unlike clustering, NMF allows cells to belong to multiple programs simultaneously, capturing the continuous nature of cellular states. Consensus NMF (cNMF) runs the factorization multiple times with random initializations to identify stable, reproducible metaprograms across runs.

[13]:
# nmf requires linear normalized data
expm['rna'].layers['norm'] = em.np.expm1(expm['rna'].X)
[14]:
expm.rna.consensus_nmf(
    counts = 'counts', tpm = 'norm', ks = [5, 10, 15, 20],
    hvg = 'vst.all.hvg', ncpus = 50, key_added = 'cnmf', min_counts = 0
)
[15]:
fig = expm.rna.plot_cnmf_silhoutte(figsize = (4, 2))
../_images/scrna_b1-annotation_15_0.png
[13]:
expm.rna.consensus_nmf_extract_k(k = 10)
[14]:
print(expm)
annotated data of size 13015 × 19651
integrated dataset of size 13015 × 19651
contains modalities: rna

 modality [rna]
    obs : sample <cat> <c/sample> batch <cat> <c/batch> group <cat> <c> modality <cat> <c/modality>
          taxa <cat> <c/taxa> barcode <o> <o> ubc <o> <o> n.umi <f64> <i> n.genes <i64> <i>
          n.mito <f64> <f> n.ribo <f64> <f> pct.mito <f64> <f> pct.ribo <f64> <f>
          filter <bool> <bool> score.doublet <f64> <f> score.doublet.se <f64> <f>
          is.doublet <bool> <bool> qc <bool> <bool/qc> leiden <cat> <c> sc3.5 <cat> <c>
          sc3.10 <cat> <c> sc3.20 <cat> <c> sc3.30 <cat> <c>
    var : chr <cat> <c/chromosome> start <i64> <i> end <i64> <i> strand <cat> <c/strand> id <o> <o>
          subtype <cat> <c/gsubtype> gene <cat> <o/gene> tlen <f64> <i/tlen> cdslen <i64> <i/cdslen>
          assembly <cat> <c> uid <o> <o/ugene> vst.hvg <bool> <bool/hvg> vst.all.means <f64> <f>
          vst.all.vars <f64> <f> vst.all.vars.norm <f64> <f> vst.all.hvg.rank <f32> <f>
          vst.all.hvg <bool> <bool>
 layers : counts <f32> <i/counts> norm <f32>
   obsm : cnmf.10 <df> <f/embedding/usage> harmony <arr:f32(35)> <f> knn <arr:i32(35)> <i>
          knn.d <arr:f32(35)> <f> pca <arr:f64(35)> <f/embedding/pca>
          umap <arr:f32(2)> <f/embedding>
   varm : cnmf.10 <arr:f64(10)> <f/weights> cnmf.coef.10 <arr:f64(10)> <f/usage-coef>
          pca <arr:f64(35)> <f/weights>
   obsp : connectivities <csr:f32> <f> distances <csr:f32> <f>
    uns : cnmf <cnmf> cnmf.args cnmf.density.10 <cnmf-density> cnmf.dist.10 <f/connectivity>
          cnmf.stats <cnmf-stats> commands <system> leiden <o> leiden.colors <o> neighbors <knn>
          pca <dict> sc3.10.colors sc3.20.colors sc3.30.colors sc3.5.colors slots <system> umap <o>

[*] composed of samples:
  distal   rna   mmu    batch b1      of size 6451 × 21689
  niche    rna   mmu    batch b2      of size 3656 × 21970
  normal   rna   mmu    batch b3      of size 4537 × 21917

[18]:
fig = expm.rna.plot_cnmf_distance_usages(
    k = 10, downsample = 0.1, method = 'complete', cmap = 'reds',
    cmap_annotations = 'turbo', annotations = 'cnmf.10', metrics = 'cosine',
    legend_cols = 1, figsize = (7, 3.3)
)
../_images/scrna_b1-annotation_18_0.png
[23]:
fig = expm.rna.plot_cnmf_distance_modules(
    k = 10, downsample = 1, method = 'complete', cmap = 'reds',
    cmap_annotations = 'seismic', annotations = 'cnmf.coef.10', metrics = 'cosine',
    legend_cols = 1, figsize = (5, 3)
)
../_images/scrna_b1-annotation_19_0.png
[27]:
expm.save()
[i] main dataset write to expm/scrna/integrated.h5mu
[i] saving individual samples. (pass `save_samples = False` to skip)

   ━━━━━━━━━━━━━━━━━━━━━━━━━━ modality [rna]           3 / 3     (00:01 < 00:00)

Manual annotation#

After unsupervised clustering and NMF decomposition, we assign biological cell type identities to each cluster based on the expression of canonical marker genes. This is an iterative process: we examine the distribution of known lineage markers across clusters, merge or split clusters as needed, and map them to biologically meaningful labels. We first visualize the Leiden clusters on the UMAP to identify major populations, then use known markers for hematopoietic progenitors (Procr), dendritic cell precursors (Flt3, Irf8), monocytes (Csf1r, Vcan), neutrophils (Ly6g), and macrophages (F13a1, Slc40a1) to guide the annotation.

[4]:
fig = expm.rna.plot_embedding(
    basis = 'umap', color = 'leiden',
    figsize = (3, 3), dpi = 100, legend_col = 2, legend = False,
    ptsize = 2, annotate_style = 'text', annotate_fontsize = 8, contour_plot = True
)
../_images/scrna_b1-annotation_22_0.png
[15]:
expm.build_subset(
    subset_name = 'mono-neutro',
    slot = 'rna',
    keys = ['leiden'],
    values = [['0', '1', '2', '9', '3', '4', '5', '6', '10', '16', '15', '12', '13', '11', '17']]
)
[i] selected 10091 observations from 13015.

[16]:
expm = em.load_experiment('expm/scrna', load_samples = False, load_subset = 'mono-neutro')
[!] samples are not dumped in the experiment directory.

[19]:
expm.rna.knn(
    use_rep = 'harmony',
    n_comps = None,
    n_neighbors = 100,
    knn = True,
    method = "umap",
    transformer = None,
    metric = "euclidean",
    metric_kwds = {},
    random_state = 42,
    key_added = 'neighbors'
)
[20]:
expm.rna.umap(
    min_dist = 0.4,
    spread = 0.9,
    n_components = 2,
    maxiter = None,
    alpha = 1,
    gamma = 1,
    negative_sample_rate = 5,
    init_pos = "spectral",
    random_state = 42,
    a = None, b = None,
    key_added = 'umap',
    neighbors_key = "neighbors"
)
[21]:
expm.rna.leiden(
    resolution = 1,
    restrict_to = None,
    random_state = 42,
    key_added = 'leiden',
    adjacency = None,
    directed = None,
    use_weights = True,
    n_iterations = 2,
    partition_type = None,
    neighbors_key = None,
    obsp = None,
    flavor = 'igraph'
)
[22]:
print(expm)
annotated data of size 10091 × 19651
subset mono-neutro of size 10091 × 19651
contains modalities: rna

 modality [rna]
    obs : sample <cat> <c/sample> batch <cat> <c/batch> group <cat> <c> modality <cat> <c/modality>
          taxa <cat> <c/taxa> barcode <o> <o> ubc <o> <o> n.umi <f64> <i> n.genes <i64> <i>
          n.mito <f64> <f> n.ribo <f64> <f> pct.mito <f64> <f> pct.ribo <f64> <f>
          filter <bool> <bool> score.doublet <f64> <f> score.doublet.se <f64> <f>
          is.doublet <bool> <bool> qc <bool> <bool/qc> leiden <cat> <c> sc3.5 <cat> <c>
          sc3.10 <cat> <c> sc3.20 <cat> <c> sc3.30 <cat> <c>
    var : chr <cat> <c/chromosome> start <i64> <i> end <i64> <i> strand <cat> <c/strand> id <o> <o>
          subtype <cat> <c/gsubtype> gene <cat> <o/gene> tlen <f64> <i/tlen> cdslen <i64> <i/cdslen>
          assembly <cat> <c> uid <o> <o/ugene> vst.hvg <bool> <bool/hvg> vst.all.means <f64> <f>
          vst.all.vars <f64> <f> vst.all.vars.norm <f64> <f> vst.all.hvg.rank <f32> <f>
          vst.all.hvg <bool> <bool>
 layers : counts <f32> <i/counts> norm <f32> <f>
   obsm : cnmf.10 <df> <f/embedding/usage> harmony <arr:f32(35)> <f> knn <arr:i32(100)> <i/knni>
          knn.d <arr:f32(100)> <f/knnd> pca <arr:f64(35)> <f/embedding/pca>
          umap <arr:f32(2)> <f/embedding>
   varm : cnmf.10 <arr:f64(10)> <f/weights> cnmf.coef.10 <arr:f64(10)> <f/usage-coef>
          pca <arr:f64(35)> <f/weights>
   obsp : connectivities <csr:f32> <f/connectivity> distances <csr:f32> <f/distance>
    uns : cnmf <cnmf> cnmf.args <o> cnmf.density.10 <cnmf-density> cnmf.dist.10 <f/connectivity>
          cnmf.stats <cnmf-stats> commands <system> leiden <o> leiden.colors <o> neighbors <knn>
          pca <dict> sc3.10.colors <o> sc3.20.colors <o> sc3.30.colors <o> sc3.5.colors <o>
          slots <system> umap <o>

[*] samples not loaded from disk.

[23]:
fig = expm.rna.plot_embedding(
    basis = 'umap', color = 'leiden',
    figsize = (3, 3), dpi = 100, legend_col = 2, legend = False,
    ptsize = 2, annotate_style = 'text', annotate_fontsize = 8, contour_plot = True
)
../_images/scrna_b1-annotation_29_0.png
[26]:
fig = expm.rna.plot_multiple_embedding(
    basis = 'umap', features = [
        'Procr', 'F13a1', 'Flt3', 'Irf8',
        'Csf1r', 'Vcan', 'Ly6g', 'Slc40a1'
    ], ncols = 4,
    sort = True, figsize = (10, 5), dpi = 100, legend = False,
    annotate_style = 'text', annotate_fontsize = 8, ptsize = 4
)
../_images/scrna_b1-annotation_30_0.png
[32]:
expm.rna.exclude(
    annotation = 'leiden',
    remove = ['8', '15']
)
[i] keep 9754 observations from 9831.

After reviewing the marker gene expression, we exclude clusters 8 and 15 as they likely represent contaminants or low-quality cells that do not match the monocyte-neutrophil lineage. The remaining cells are re-clustered with a higher resolution to resolve finer subpopulations within the myeloid compartment.

[33]:
expm.rna.knn(
    use_rep = 'harmony',
    n_comps = None,
    n_neighbors = 100,
    knn = True,
    method = "umap",
    transformer = None,
    metric = "euclidean",
    metric_kwds = {},
    random_state = 42,
    key_added = 'neighbors'
)
[34]:
expm.rna.umap(
    min_dist = 0.4,
    spread = 0.9,
    n_components = 2,
    maxiter = None,
    alpha = 1,
    gamma = 1,
    negative_sample_rate = 5,
    init_pos = "spectral",
    random_state = 42,
    a = None, b = None,
    key_added = 'umap',
    neighbors_key = "neighbors"
)
[35]:
fig = expm.rna.plot_multiple_embedding(
    basis = 'umap', features = [
        'Procr', 'F13a1', 'Flt3', 'Irf8',
        'Csf1r', 'Vcan', 'Ly6g', 'Slc40a1'
    ], ncols = 4,
    sort = True, figsize = (10, 5), dpi = 100, legend = False,
    annotate_style = 'text', annotate_fontsize = 8, ptsize = 4
)
../_images/scrna_b1-annotation_35_0.png
[37]:
expm.rna.annotate(
    annotation = 'cell.type',
    cluster = 'leiden',
    mapping = {
        'Prog': [14],
        'DCp': [7],
        'MDP': [11, 12],
        'Mo': [10],
        'iMac': [16],
        'MM': [5, 6],
        'Neu': [13, 3, 2, 4, 9, 1, 0]
    }
)
cell.type
Neu     6706
MDP     1124
MM       707
Mo       479
DCp      282
Prog     235
iMac     221
Name: count, dtype: int64
[38]:
fig = expm.rna.plot_embedding(
    basis = 'umap', color = 'cell.type',
    figsize = (3, 3), dpi = 100, legend_col = 2, legend = False,
    ptsize = 2, annotate_style = 'text', annotate_fontsize = 8, contour_plot = True
)
../_images/scrna_b1-annotation_37_0.png
[39]:
expm.rna.markers(
    groupby = 'cell.type',
    mask_var = None,
    groups = 'all',
    reference = 'rest',
    n_genes = None, rankby_abs = False, pts = True,
    key_added = 'markers',
    method = 't-test',
    corr_method = 'benjamini-hochberg',
    tie_correct = False,
    gene_symbol = 'gene',
    layer = 'X'
)

After assigning cell type labels, we identify differentially expressed marker genes for each annotated population using a t-test. The marker analysis compares each cell type against all others (reference='rest') to find genes that are specifically upregulated in each population. The results are corrected for multiple testing using the Benjamini-Hochberg procedure and include the percentage of cells expressing each gene in both the target and reference groups for interpretability.

[45]:
fig = expm.rna.plot_markers(
    n_genes = 3,
    groupby = 'cell.type',
    min_logfoldchange = 1,
    max_logfoldchange = None,
    max_p_adjust = 0.05,
    min_pct = 0.25,
    max_pct_reference = 0.75,
    key = 'markers',
    gene_symbols = 'gene',
    figsize = (8, 2.5),
    dpi = 100,
    plot_type = 'dotplot'
)
../_images/scrna_b1-annotation_40_0.png
[46]:
fig = expm.rna.plot_markers(
    n_genes = 3,
    groupby = 'cell.type',
    min_logfoldchange = 1,
    max_logfoldchange = None,
    max_p_adjust = 0.05,
    min_pct = 0.25,
    max_pct_reference = 0.75,
    key = 'markers',
    gene_symbols = 'gene',
    figsize = (8, 2.5),
    dpi = 100,
    plot_type = 'matrixplot'
)
../_images/scrna_b1-annotation_41_0.png
[47]:
fig = expm.rna.plot_markers(
    n_genes = 3,
    groupby = 'cell.type',
    min_logfoldchange = 1,
    max_logfoldchange = None,
    max_p_adjust = 0.05,
    min_pct = 0.25,
    max_pct_reference = 0.75,
    key = 'markers',
    gene_symbols = 'gene',
    figsize = (8, 2.5),
    dpi = 100,
    plot_type = 'stacked_violin'
)
../_images/scrna_b1-annotation_42_0.png
[50]:
fig = expm.rna.plot_markers(
    n_genes = 3,
    groupby = 'cell.type',
    min_logfoldchange = 1,
    max_logfoldchange = None,
    max_p_adjust = 0.05,
    min_pct = 0.25,
    max_pct_reference = 0.75,
    key = 'markers',
    gene_symbols = 'gene',
    figsize = (8, 5),
    dpi = 100,
    plot_type = 'tracksplot'
)
../_images/scrna_b1-annotation_43_0.png

Proportions of subtypes#

Finally, we examine the compositional differences across experimental groups by calculating the proportion of each cell type within each sample. This reveals how the cellular landscape shifts between conditions — for instance, whether certain myeloid populations are enriched or depleted in the distal bone marrow niche compared to normal marrow.

[51]:
fig = expm.rna.plot_proportion(
    major = 'sample', minor = 'cell.type', plot = 'bar', cmap = 'category20c',
    normalize = 'index', figsize = (3, 2.5), stacked = True, legend = True
)
../_images/scrna_b1-annotation_45_0.png
[53]:
expm.rna.proportion(
    major = 'sample', minor = 'cell.type',
    normalize = 'index'
)['integrated']
[53]:
cell.type DCp MDP MM Mo Neu Prog iMac
sample
distal 0.023297 0.062052 0.080421 0.035842 0.774194 0.023970 0.000224
niche 0.001878 0.160345 0.079985 0.088246 0.563275 0.024784 0.081487
normal 0.065855 0.159878 0.051389 0.031976 0.666159 0.023601 0.001142
[55]:
print(expm)
annotated data of size 9754 × 19651
subset mono-neutro of size 9754 × 19651
contains modalities: rna

 modality [rna]
    obs : sample <cat> <c/sample> batch <cat> <c/batch> group <cat> <c> modality <cat> <c/modality>
          taxa <cat> <c/taxa> barcode <o> <o> ubc <o> <o> n.umi <f64> <i> n.genes <i64> <i>
          n.mito <f64> <f> n.ribo <f64> <f> pct.mito <f64> <f> pct.ribo <f64> <f>
          filter <bool> <bool> score.doublet <f64> <f> score.doublet.se <f64> <f>
          is.doublet <bool> <bool> qc <bool> <bool/qc> leiden <cat> <c> sc3.5 <cat> <c>
          sc3.10 <cat> <c> sc3.20 <cat> <c> sc3.30 <cat> <c> cell.type <cat> <c>
    var : chr <cat> <c/chromosome> start <i64> <i> end <i64> <i> strand <cat> <c/strand> id <o> <o>
          subtype <cat> <c/gsubtype> gene <cat> <o/gene> tlen <f64> <i/tlen> cdslen <i64> <i/cdslen>
          assembly <cat> <c> uid <o> <o/ugene> vst.hvg <bool> <bool/hvg> vst.all.means <f64> <f>
          vst.all.vars <f64> <f> vst.all.vars.norm <f64> <f> vst.all.hvg.rank <f32> <f>
          vst.all.hvg <bool> <bool>
 layers : counts <f32> <i/counts> norm <f32> <f>
   obsm : cnmf.10 <df> <f/embedding/usage> harmony <arr:f32(35)> <f> knn <arr:i32(100)> <i/knni>
          knn.d <arr:f32(100)> <f/knnd> pca <arr:f64(35)> <f/embedding/pca>
          umap <arr:f32(2)> <f/embedding>
   varm : cnmf.10 <arr:f64(10)> <f/weights> cnmf.coef.10 <arr:f64(10)> <f/usage-coef>
          pca <arr:f64(35)> <f/weights>
   obsp : connectivities <csr:f32> <f/connectivity> distances <csr:f32> <f/distance>
    uns : cnmf <cnmf> cnmf.args <o> cnmf.density.10 <cnmf-density> cnmf.dist.10 <f/connectivity>
          cnmf.stats <cnmf-stats> commands <system> leiden <o> leiden.colors <o> neighbors <knn>
          pca <dict> sc3.10.colors <o> sc3.20.colors <o> sc3.30.colors <o> sc3.5.colors <o>
          slots <system> umap <o> cell.type.colors markers <markers> cell.type_colors

[*] samples not loaded from disk.

[54]:
expm.save()
[i] main dataset write to expm/scrna/subsets/mono-neutro.h5mu