# Utilities for multi-modal single-cell analyses

Aaron Lun*

*infinite.monkeys.with.keyboards@gmail.com

#### Revised: 8 November 2020

#### Package

mumosa 1.21.0

# 1 Overview

The mumosa package implements a variety of utilities for analyses of single-cell data with multiple modalities. This usually refers to single-cell RNA-seq experiments with proteomics, epigenomics or other data collected from the same cells. The aim is to investigate how different modalities relate to each other via analyses of correlations, and to combine data together from multiple modalities for integrated downstream analyses. The actual analyses of each individual modality are deferred to other packages; the scope of mumosa is limited to the sharing of information across modalities.

# 2 Setting up the dataset

To demonstrate the functionalities of this package, we will use a subset of a CITE-seq experiment from the scRNAseq package. The main Experiment contains the RNA-seq counts while the `adt` alternative Experiment contains the CITE-seq counts - see the SingleCellExperiment package documentation for more details.

    library(scater)
    library(scran)
    library(scRNAseq)
    sce <- KotliarovPBMCData()
    sce <- sce[,1:1000] # subset for speed.
    sce

    ## class: SingleCellExperiment
    ## dim: 32738 1000
    ## metadata(0):
    ## assays(1): counts
    ## rownames(32738): MIR1302-10 FAM138A ... AC002321.2 AC002321.1
    ## rowData names(0):
    ## colnames(1000): AAACCTGAGAGCCCAA_H1B1ln1 AAACCTGAGGCGTACA_H1B1ln1 ...
    ##   ATAACGCGTTTGGGCC_H1B1ln1 ATAACGCTCAACGCTA_H1B1ln1
    ## colData names(24): nGene nUMI ... dmx_hto_match timepoint
    ## reducedDimNames(0):
    ## mainExpName: NULL
    ## altExpNames(1): ADT

We perform some cursory analyses on the RNA component:

    stats <- perCellQCMetrics(sce, subsets=list(Mito=grep("^MT-", rownames(sce))))
    filter <- quickPerCellQC(stats, sub.fields="subsets_Mito_percent")
    sce <- sce[,!filter$discard]
    sce <- logNormCounts(sce)
    dec <- modelGeneVar(sce)
    set.seed(10000)
    sce <- runPCA(sce, ncomponents=25, subset_row=getTopHVGs(dec, n=5000))

And again on the protein component.

    # TODO: sync this with the book.
    sceA <- as(altExp(sce), "SingleCellExperiment")
    statsA <- perCellQCMetrics(sceA)
    keep <- statsA$detected > 50
    sceA <- sceA[,keep]

    library(DropletUtils)
    amb <- inferAmbience(assay(sceA))
    sceA <- computeMedianFactors(sceA, reference=amb)
    sceA <- logNormCounts(sceA)

    set.seed(10000)
    sceA <- runPCA(sceA, ncomponents=15)

Finally, we put everything back into the same object, only considering the cells passing QC in both modalities.

    sce2 <- sce[,keep]
    altExp(sce2) <- sceA

# 3 Rescaling by neighbors

The easiest way to combine data for the same set of cells is to simply `cbind` their matrices together prior to downstream analyses like clustering. However, this requires some rescaling to adjust for the differences in the number of features and variation of each modality; otherwise, the modality with more features or stronger (technical) variation would just dominate later calculations. `rescaleByNeighbors()` quantifies the “noise” of each modality using on the median distance to the \(k\)-th nearest neighbor, and then rescales each expression/PC matrix by that distance to equalize the noise across modalities.

    library(mumosa)
    output <- rescaleByNeighbors(list(reducedDim(sce2), reducedDim(altExp(sce2))))
    dim(output)

    ## [1] 911  40

The result is a `cbind`ed matrix that can be used directly in downstream analyses like clustering and dimensionality reduction.

    set.seed(100001)
    library(bluster)
    sce2$combined.clustering <- clusterRows(output, NNGraphParam())

    reducedDim(sce2, "combined") <- output
    sce2 <- runTSNE(sce2, dimred="combined")
    plotTSNE(sce2, colour_by="combined.clustering")

Advanced users can also adjust the weight given to each modality via the `weights=` argument; a modality assigned a weight of 2 will mean that the rescaled distance is twice that of a modality with a weight of 1.

# 4 Intersecting clusters

In other situations, we may already have a clustering for each modality. For example, we might have one clustering for each of the gene expression and ADT PCs:

    g.rna <- buildSNNGraph(sce2, use.dimred="PCA")
    rna.clusters <- igraph::cluster_walktrap(g.rna)$membership
    table(rna.clusters)

    ## rna.clusters
    ##   1   2   3   4   5   6   7
    ## 115 218  24  84  92 254 124

    g.adt <- buildSNNGraph(altExp(sce2), use.dimred='PCA')
    adt.clusters <- igraph::cluster_walktrap(g.adt)$membership
    table(adt.clusters)

    ## adt.clusters
    ##   1   2   3   4   5   6   7   8
    ## 111 191 122 181 122  82  20  82

We can naively “intersect” these clusters by concatenating their identities, as shown below. Cells are only assigned to the same intersected clusters if they belong to the same per-modality clusters. In practice, this often creates a large number of irrelevant clusters with small numbers of cells.

    intersected <- paste0(adt.clusters, ".", rna.clusters)
    table(intersected)

    ## intersected
    ## 1.2 1.4 1.5 2.2 2.5 2.6 3.7 4.2 4.4 4.6 5.1 5.3 5.6 6.2 6.6 7.3 7.7 8.4 8.5
    ##  39   2  70 155  20  16 122   6   2 173 115   6   1  18  64  18   2  80   2

A more refined approach is implemented in the `intersectClusters()` function. This performs the intersection as described but then performs a series of pairwise merges to eliminate the small clusters. It will stop merging when the within-cluster sum of squares exceeds that of the original clustering in any modality; this ensures that any within-modality structure captured by the original clustering is preserved.

    intersected2 <- intersectClusters(list(rna.clusters, adt.clusters),
        list(reducedDim(sce2), reducedDim(altExp(sce2))))
    table(intersected2)

    ## intersected2
    ##   1   2   3   4   5   6   7   8   9
    ## 124  92  39  82  84 180 115 171  24

# 5 Multi-metric UMAP

Another approach is to take advantage of UMAP’s ability to combine information on different scales. This is achieved by - roughly speaking - creating nearest-neighbor graphs within each modality, and then “intersecting” the graphs such that cells are only considered to be neighbors if they are neighbors in each modality. In this manner, we incorporate locality information from all modalities without explicitly comparing distances. We apply this strategy using the `calculateMultiUMAP()` function, increasing the number of components to avoid loss of information.

    set.seed(100002)
    umap.out <- calculateMultiUMAP(list(reducedDim(sce2), reducedDim(altExp(sce2))),
        n_components=20)
    dim(umap.out)

    ## [1] 911  20

Again, we can use this in downstream analyses like clustering:

    library(bluster)
    sce2$umap.clustering <- clusterRows(umap.out, NNGraphParam())

And also visualization, though perhaps it is more natural to just compute the UMAP on two dimensions for this purpose. `runMultiMap()` is just a wrapper around `calculateMultiUMAP()` that conveniently stores the output in the `reducedDims` of the input `SingleCellExperiment`.

    set.seed(100002)
    sce2 <- runMultiUMAP(sce2, dimred="PCA", extras=list(reducedDim(altExp(sce2))))
    plotReducedDim(sce2, "MultiUMAP", colour_by="umap.clustering")

# 6 Intersecting graphs

Inspired by the UMAP approach, we can perform intersections on arbitrary graphs with the `intersectGraphs()` function. This is relevant in cases where we have nearest-neighbor graphs constructed separately for each modality, e.g., for graph-based clustering. We intersect these graphs by setting the weights of edges to the product of the edge weights from each individual graph.

    g.com <- intersectGraphs(g.rna, g.adt)

Two cells will only be connected by a high-weighted edge if that edge is of high weight in each original graph, i.e., cells must be nearest neighbors in all modalities to be considered as nearest neighbors in the intersection. We can then use this graph for clustering, which hopefully gives us clusters where cells are similar across all modalities.

    com.clusters <- igraph::cluster_walktrap(g.com)$membership
    table(com.clusters)

    ## com.clusters
    ##   1   2   3   4   5   6   7   8   9  10
    ## 123 124 118 169 178  70  21  22  84   2

# 7 Correlation analyses

Given two modalities, we may be interested in knowing which features in one modality are correlated to the features in another modality. The `computeCorrelations()` function does exactly this:

    cor.all <- computeCorrelations(sce2, altExp(sce2)[1:5,])
    cor.all[order(cor.all$p.value),]

    ## DataFrame with 163690 rows and 5 columns
    ##           feature1      feature2       rho     p.value         FDR
    ##        <character>   <character> <numeric>   <numeric>   <numeric>
    ## 1            CD79A     BTLA_PROT  0.443223 4.01052e-45 2.89580e-40
    ## 2         HLA-DRB1    CD123_PROT  0.430457 2.17126e-42 7.83878e-38
    ## 3          HLA-DRA    CD123_PROT  0.403383 5.75021e-37 1.38398e-32
    ## 4             FCN1     CD13_PROT  0.397864 6.38598e-36 1.15275e-31
    ## 5            TCL1A     BTLA_PROT  0.383289 2.97413e-33 4.29495e-29
    ## ...            ...           ...       ...         ...         ...
    ## 163686      snoU13 AnnexinV_PROT       NaN          NA          NA
    ## 163687      snoU13     BTLA_PROT       NaN          NA          NA
    ## 163688      snoU13    CD117_PROT       NaN          NA          NA
    ## 163689      snoU13    CD123_PROT       NaN          NA          NA
    ## 163690      snoU13     CD13_PROT       NaN          NA          NA

For multi-batch experiments, we can specify blocking factors to avoid being confounded by the batch effect. Each batch receives equal weight when averaging correlations and in computing the final combined \(p\)-value.

    b <- rep(1:3, length.out=ncol(sce2))
    cor.all.batch <- computeCorrelations(sce2, altExp(sce2)[1:5,], block=b)

However, this approach can be very slow when dealing with large numbers of features; indeed, one may note the subsetting to the first 5 features in the code above. Users can enable parallelization via `BPPARAM=` to speed up the process but this will only go so far.

An alternative approach is to, for each feature in one modality, perform an approximate search for the top most-correlated features in another modality. This assumes that only the strongest correlations (positive or negative) are of actual interest, while the bulk of weak correlations can be ignored. We use the `findTopCorrelations()` function with a specified number of top features to extract:

    set.seed(100001) # for IRLBA.
    top.cor <- findTopCorrelations(sce2[1:100,], y=altExp(sce2), number=10)
    top.cor

    ## List of length 2
    ## names(2): positive negative

    top.cor$positive

    ## DataFrame with 530 rows and 5 columns
    ##         feature1              feature2        rho   p.value       FDR
    ##      <character>           <character>  <numeric> <numeric> <numeric>
    ## 1   RP11-34P13.7 RatIgG2bkIsotype_PROT  0.0550420 0.0484271         1
    ## 2   RP11-34P13.7            CD194_PROT  0.0519342 0.0586247         1
    ## 3   RP11-34P13.7             CD19_PROT  0.0310772 0.1743962         1
    ## 4   RP11-34P13.7             CD34_PROT  0.0301584 0.1816169         1
    ## 5   RP11-34P13.7             CD1c_PROT  0.0300619 0.1823858         1
    ## ...          ...                   ...        ...       ...       ...
    ## 526     C1orf222             CD19_PROT 0.03895679  0.120066         1
    ## 527     C1orf222            CD141_PROT 0.03693348  0.132724         1
    ## 528     C1orf222             CD10_PROT 0.02722832  0.205866         1
    ## 529     C1orf222            CD184_PROT 0.00819402  0.402461         1
    ## 530     C1orf222            CD273_PROT 0.00572678  0.431477         1

This returns the top 10 features in the CITE-seq data (`y`) for each feature in the main RNA-seq data. As the search is approximate, some features may not be ranked as highly as they would be under an exact search - the approximation quality can be modified by increasing the number of PCs (`d`) used for data compression. Blocking is also supported with `block=`, in which case correlations are only computed within each level of the blocking factor.

# Session information

    sessionInfo()

    ## R version 4.6.0 Patched (2026-05-01 r89994)
    ## Platform: aarch64-apple-darwin23
    ## Running under: macOS Tahoe 26.3.1
    ##
    ## Matrix products: default
    ## BLAS:   /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
    ## LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
    ##
    ## locale:
    ## [1] C/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
    ##
    ## time zone: America/New_York
    ## tzcode source: internal
    ##
    ## attached base packages:
    ## [1] stats4    stats     graphics  grDevices utils     datasets  methods
    ## [8] base
    ##
    ## other attached packages:
    ##  [1] bluster_1.23.0              mumosa_1.21.0
    ##  [3] DropletUtils_1.33.0         scRNAseq_2.27.0
    ##  [5] scran_1.41.0                scater_1.41.1
    ##  [7] ggplot2_4.0.3               scuttle_1.23.1
    ##  [9] SingleCellExperiment_1.35.1 SummarizedExperiment_1.43.0
    ## [11] Biobase_2.73.1              GenomicRanges_1.65.0
    ## [13] Seqinfo_1.3.0               IRanges_2.47.1
    ## [15] S4Vectors_0.51.2            BiocGenerics_0.59.2
    ## [17] generics_0.1.4              MatrixGenerics_1.25.0
    ## [19] matrixStats_1.5.0           knitr_1.51
    ## [21] BiocStyle_2.41.0
    ##
    ## loaded via a namespace (and not attached):
    ##   [1] batchelor_1.29.0          BiocIO_1.23.3
    ##   [3] bitops_1.0-9              filelock_1.0.3
    ##   [5] tibble_3.3.1              R.oo_1.27.1
    ##   [7] XML_3.99-0.23             lifecycle_1.0.5
    ##   [9] httr2_1.2.2               edgeR_4.11.0
    ##  [11] lattice_0.22-9            ensembldb_2.37.0
    ##  [13] alabaster.base_1.13.0     magrittr_2.0.5
    ##  [15] limma_3.69.0              sass_0.4.10
    ##  [17] rmarkdown_2.31            jquerylib_0.1.4
    ##  [19] yaml_2.3.12               metapod_1.21.0
    ##  [21] otel_0.2.0                cowplot_1.2.0
    ##  [23] DBI_1.3.0                 RColorBrewer_1.1-3
    ##  [25] ResidualMatrix_1.23.0     abind_1.4-8
    ##  [27] Rtsne_0.17                R.utils_2.13.0
    ##  [29] AnnotationFilter_1.37.0   RCurl_1.98-1.18
    ##  [31] rappdirs_0.3.4            ggrepel_0.9.8
    ##  [33] irlba_2.3.7               alabaster.sce_1.13.0
    ##  [35] RSpectra_0.16-2           dqrng_0.4.1
    ##  [37] DelayedMatrixStats_1.35.0 codetools_0.2-20
    ##  [39] DelayedArray_0.39.2       tidyselect_1.2.1
    ##  [41] UCSC.utils_1.9.0          farver_2.1.2
    ##  [43] ScaledMatrix_1.21.0       viridis_0.6.5
    ##  [45] BiocFileCache_3.3.0       GenomicAlignments_1.49.0
    ##  [47] jsonlite_2.0.0            BiocNeighbors_2.7.2
    ##  [49] tools_4.6.0               Rcpp_1.1.1-1.1
    ##  [51] glue_1.8.1                gridExtra_2.3
    ##  [53] SparseArray_1.13.2        BiocBaseUtils_1.15.1
    ##  [55] xfun_0.57                 GenomeInfoDb_1.49.0
    ##  [57] dplyr_1.2.1               HDF5Array_1.41.0
    ##  [59] gypsum_1.9.0              withr_3.0.2
    ##  [61] BiocManager_1.30.27       fastmap_1.2.0
    ##  [63] rhdf5filters_1.25.0       digest_0.6.39
    ##  [65] rsvd_1.0.5                R6_2.6.1
    ##  [67] dichromat_2.0-0.1         RSQLite_3.52.0
    ##  [69] cigarillo_1.3.0           R.methodsS3_1.8.2
    ##  [71] h5mread_1.5.0             FNN_1.1.4.1
    ##  [73] rtracklayer_1.73.0        httr_1.4.8
    ##  [75] S4Arrays_1.13.0           uwot_0.2.4
    ##  [77] pkgconfig_2.0.3           gtable_0.3.6
    ##  [79] blob_1.3.0                S7_0.2.2
    ##  [81] XVector_0.53.0            htmltools_0.5.9
    ##  [83] bookdown_0.46             ProtGenerics_1.45.0
    ##  [85] scales_1.4.0              alabaster.matrix_1.13.0
    ##  [87] png_0.1-9                 rjson_0.2.23
    ##  [89] curl_7.1.0                cachem_1.1.0
    ##  [91] rhdf5_2.57.0              BiocVersion_3.24.0
    ##  [93] parallel_4.6.0            vipor_0.4.7
    ##  [95] AnnotationDbi_1.75.0      restfulr_0.0.16
    ##  [97] pillar_1.11.1             grid_4.6.0
    ##  [99] alabaster.schemas_1.13.0  vctrs_0.7.3
    ## [101] BiocSingular_1.29.0       dbplyr_2.5.2
    ## [103] beachmat_2.29.0           cluster_2.1.8.2
    ## [105] beeswarm_0.4.0            evaluate_1.0.5
    ## [107] tinytex_0.59              GenomicFeatures_1.65.0
    ## [109] magick_2.9.1              cli_3.6.6
    ## [111] locfit_1.5-9.12           compiler_4.6.0
    ## [113] Rsamtools_2.29.0          rlang_1.2.0
    ## [115] crayon_1.5.3              labeling_0.4.3
    ## [117] ggbeeswarm_0.7.3          viridisLite_0.4.3
    ## [119] alabaster.se_1.13.0       BiocParallel_1.47.0
    ## [121] Biostrings_2.81.1         lazyeval_0.2.3
    ## [123] Matrix_1.7-5              ExperimentHub_3.3.0
    ## [125] sparseMatrixStats_1.25.0  bit64_4.8.0
    ## [127] Rhdf5lib_2.1.0            KEGGREST_1.53.0
    ## [129] statmod_1.5.2             alabaster.ranges_1.13.0
    ## [131] AnnotationHub_4.3.0       igraph_2.3.1
    ## [133] memoise_2.0.1             bslib_0.11.0
    ## [135] bit_4.6.0