Skip to contents

Introduction

For areal spatial data, the spatial neighborhood graph is used to indicate proximity, and is required for all spatial analysis methods in the package spdep. One of the methods to find the spatial neighborhood graph is k nearest neighbors, which is also commonly used in gene expression PCA space for graph-based clustering of cells in non-spatial scRNA-seq data. Then what if we use the k nearest neighbors graph in PCA space rather than histological space for “spatial” analyses for non-spatial scRNA-seq data?

Here we try such an analysis on a human peripheral blood mononuclear cells (PBMC) scRNA-seq dataset, which doesn’t originally have histological spatial organization. These are the packages loaded for the analysis:

# Specify Python version to use gget
PY_PATH <- Sys.which("python")
use_python(PY_PATH)
py_config()

# Load gget
gget <- import("gget")

Here we download the filtered Cell Ranger gene count matrix from the 10X website. The empty droplets have already been removed.

if (!dir.exists("filtered_feature_bc_matrix")) {
    download.file("https://cf.10xgenomics.com/samples/cell-exp/3.0.2/5k_pbmc_v3_nextgem/5k_pbmc_v3_nextgem_filtered_feature_bc_matrix.tar.gz", destfile = "5kpbmc.tar.gz", quiet = TRUE)
    system("tar -xzf 5kpbmc.tar.gz")
}

This is loaded into R as a SingleCellExperiment (SCE) object.

(sce <- read10xCounts("filtered_feature_bc_matrix/"))
#> class: SingleCellExperiment 
#> dim: 33538 5155 
#> metadata(1): Samples
#> assays(1): counts
#> rownames(33538): ENSG00000243485 ENSG00000237613 ... ENSG00000277475
#>   ENSG00000268674
#> rowData names(3): ID Symbol Type
#> colnames: NULL
#> colData names(2): Sample Barcode
#> reducedDimNames(0):
#> mainExpName: NULL
#> altExpNames(0):
colnames(sce) <- sce$Barcode

Quality control (QC)

Here we perform some basic QC, to remove low quality cells with high proportion of mitochondrially encoded counts.

is_mito <- str_detect(rowData(sce)$Symbol, "^MT-")
sum(is_mito)
#> [1] 13
sce <- addPerCellQCMetrics(sce, subsets = list(mito = is_mito))
names(colData(sce))
#> [1] "Sample"                "Barcode"               "sum"                  
#> [4] "detected"              "subsets_mito_sum"      "subsets_mito_detected"
#> [7] "subsets_mito_percent"  "total"

The addPerCellQCMetrics() function computes the total UMI counts detected per cell (sum), number of genes detected per cell (detected), sum and detected for mitochondrial counts, and percentage of mitochondrial counts per cell.

plotColData(sce, "sum") +
    plotColData(sce, "detected") +
    plotColData(sce, "subsets_mito_percent")

Here a 2D histogram is plotted to better show point density on the plot.

plotColData(sce, x = "sum", y = "detected", bins = 100)

plotColData(sce, x = "sum", y = "subsets_mito_percent", bins = 100)

Remove cells with >20% mitochondrial counts

sce <- sce[, sce$subsets_mito_percent < 20]
sce <- sce[rowSums(counts(sce)) > 0,]

Basic non-spatial analyses

Here we normalize the data, perform PCA, cluster the cells, and find marker genes for the clusters.

#clusts <- quickCluster(sce)
#sce <- computeSumFactors(sce, cluster = clusts)
#sce <- sce[, sizeFactors(sce) > 0]
sce <- logNormCounts(sce)

Use the highly variable genes for PCA:

dec <- modelGeneVar(sce, lowess = FALSE)
hvgs <- getTopHVGs(dec, n = 2000)
set.seed(29)
sce <- runPCA(sce, ncomponents = 30, BSPARAM = IrlbaParam(),
              subset_row = hvgs, scale = TRUE)

How many PCs shall we use for further analyses?

ElbowPlot(sce, ndims = 30)

Variance explained drops sharply from PC1 and again from PC4 and then levels off. Plot genes with the largest loadings of the top 4 PCs:

plotDimLoadings(sce, swap_rownames = "Symbol")

To keep a little more information, we use 10 PCs, after which variance explained levels off even more.

sce$cluster <- clusterRows(reducedDim(sce, "PCA")[,1:10],
                           BLUSPARAM = SNNGraphParam(cluster.fun = "leiden",
                                                     k = 10,
                                                     cluster.args = list(
                                                         resolution=0.5,
                                                         objective_function = "modularity"
                                                     )))

Here we plot the cells in the first 4 PCs in a matrix plot. The diagonals are density plots of the number of cells as projected on each PC. The x axis should correspond to the columns of the matrix plot, and the y axis should correspond to the rows, so the plot in row 1 column 2 has PC2 in the x axis and PC1 in the y axis. The cells are colored by clusters found in the previous code chunk.

plotPCA(sce, ncomponents = 4, color_by = "cluster")

How many cells are there in each cluster?

table(sce$cluster)
#> 
#>    1    2    3    4    5    6    7    8 
#> 1057 1029 1278  590  415  207   27   26

Then we use the conventional Wilcoxon rank sum test to find marker genes for each cluster. The test compares each cluster to the rest of the cells, and only genes more highly expressed in the cluster compared to the other cells are considered. The result is a list of data frames, where each data frame corresponds to one cluster. Areas under the receiver operator curve (AUC), distinguishing each cluster vs. any other cluster, are also included. The closer to 1 the better, while 0.5 means no better than random guessing. The false discovery rate (FDR) column contains the Benjamini-Hochberg corrected p-values. Genes in these data frames are already sorted by p-values.

markers <- findMarkers(sce, groups = colData(sce)$cluster,
                       test.type = "wilcox", pval.type = "all", direction = "up")
markers[[4]]
#> DataFrame with 21932 rows and 10 columns
#>                     p.value         FDR summary.AUC     AUC.1     AUC.2
#>                   <numeric>   <numeric>   <numeric> <numeric> <numeric>
#> ENSG00000105369 2.91320e-18 6.38923e-14    1.000000  0.999965  0.999489
#> ENSG00000007312 7.06943e-18 7.75234e-14    0.994068  0.990666  0.990071
#> ENSG00000104894 1.32243e-17 9.66782e-14    0.989896  0.994232  0.988447
#> ENSG00000196735 4.83219e-17 2.16009e-13    0.981063  0.998555  0.938579
#> ENSG00000156738 4.92451e-17 2.16009e-13    0.972316  0.999639  0.998674
#> ...                     ...         ...         ...       ...       ...
#> ENSG00000184274           1           1         0.5  0.500000       0.5
#> ENSG00000273796           1           1         0.5  0.500000       0.5
#> ENSG00000274248           1           1         0.5  0.500000       0.5
#> ENSG00000160282           1           1         0.5  0.499527       0.5
#> ENSG00000228137           1           1         0.5  0.500000       0.5
#>                     AUC.3     AUC.5     AUC.6     AUC.7     AUC.8
#>                 <numeric> <numeric> <numeric> <numeric> <numeric>
#> ENSG00000105369  0.999977  0.999534  0.997208  0.999937  1.000000
#> ENSG00000007312  0.991763  0.987639  0.986535  0.989077  0.994068
#> ENSG00000104894  0.994081  0.992534  0.992201  0.998305  0.989896
#> ENSG00000196735  0.998990  0.996269  0.998657  0.996798  0.981063
#> ENSG00000156738  0.999930  0.995512  0.999664  0.972316  1.000000
#> ...                   ...       ...       ...       ...       ...
#> ENSG00000184274  0.499609       0.5  0.500000       0.5       0.5
#> ENSG00000273796  0.499218       0.5  0.500000       0.5       0.5
#> ENSG00000274248  0.498826       0.5  0.500000       0.5       0.5
#> ENSG00000160282  0.499609       0.5  0.500000       0.5       0.5
#> ENSG00000228137  0.500000       0.5  0.497585       0.5       0.5

See how specific the top markers are to each cluster:

top_markers <- unlist(lapply(markers, function(x) head(rownames(x), 1)))
top_markers_symbol <- rowData(sce)[top_markers, "Symbol"]
plotExpression(sce, top_markers_symbol, x = "cluster", swap_rownames = "Symbol",
               point_fun = function(...) list())

We can use the gget info module from the gget package to get additional information for these marker genes. For example, their NCBI description:

gget_info <- gget$info(top_markers)

rownames(gget_info) <- gget_info$ensembl_gene_name
select(gget_info, ncbi_description)

“Spatial” analyses for QC metrics

Find k nearest neighbor graph in PCA space for Moran’s I: We are not using spdep here since its nb2listwdist() function for distance based edge weighting requires 2-3 dimensional spatial coordinates while the coordinates here have 10 dimensions. Here, inverse distance weighting is used for edge weights.

foo <- findKNN(reducedDim(sce, "PCA")[,1:10], k=10, BNPARAM=AnnoyParam())
# Split by row
foo_nb <- asplit(foo$index, 1)
dmat <- 1/foo$distance
# Row normalize the weights
dmat <- sweep(dmat, 1, rowSums(dmat), FUN = "/")
glist <- asplit(dmat, 1)
# Sort based on index
ord <- lapply(foo_nb, order)
foo_nb <- lapply(seq_along(foo_nb), function(i) foo_nb[[i]][ord[[i]]])
class(foo_nb) <- "nb"
glist <- lapply(seq_along(glist), function(i) glist[[i]][ord[[i]]])

listw <- list(style = "W",
              neighbours = foo_nb,
              weights = glist)
class(listw) <- "listw"
attr(listw, "region.id") <- colnames(sce)

Because there is no histological space, we convert the SCE object into SpatialFeatureExperiment (SFE) to use the spatial analysis and plotting functions from Voyager, and pretend that the first 2 PCs are the histological space.

(sfe <- toSpatialFeatureExperiment(sce, spatialCoords = reducedDim(sce, "PCA")[,1:2],
                                  spatialCoordsNames = NULL))
#> class: SpatialFeatureExperiment 
#> dim: 21932 4629 
#> metadata(1): Samples
#> assays(2): counts logcounts
#> rownames(21932): ENSG00000238009 ENSG00000239945 ... ENSG00000275063
#>   ENSG00000271254
#> rowData names(3): ID Symbol Type
#> colnames(4629): AAACCCAAGACAGCTG-1 AAACCCAAGTTAACGA-1 ...
#>   TTTGTTGTCACGGACC-1 TTTGTTGTCCACACCT-1
#> colData names(11): Sample Barcode ... cluster sample_id
#> reducedDimNames(1): PCA
#> mainExpName: NULL
#> altExpNames(0):
#> spatialCoords names(2) : PC1 PC2
#> imgData names(0):
#> 
#> unit:
#> Geometries:
#> colGeometries: centroids (POINT) 
#> 
#> Graphs:
#> sample01:

Add the k nearest neighbor graph to the SFE object:

colGraph(sfe, "knn10") <- listw

Moran’s I

sfe <- colDataMoransI(sfe, c("sum", "detected", "subsets_mito_percent"))
colFeatureData(sfe)[c("sum", "detected", "subsets_mito_percent"),]
#> DataFrame with 3 rows and 2 columns
#>                      moran_sample01 K_sample01
#>                           <numeric>  <numeric>
#> sum                        0.655173   16.44603
#> detected                   0.750133    6.01002
#> subsets_mito_percent       0.438934    6.13555

For total UMI counts (sum) and genes detected (detected), Moran’s I is quite strong, while it’s positive but weaker for percentage of mitochondrial counts. The second column, K, is kurtosis of the feature of interest.

Moran plot

How about the local variations on the k nearest neighbors graph? In the Moran plot, the x axis is the value for each cell, and the y axis is the average value among neighboring cells in the graph weighted by edge weights. The slope of the fitted line is Moran’s I. Sometimes there are clusters in this plot, showing different kinds of neighborhoods.

sfe <- colDataUnivariate(sfe, "moran.plot", c("sum", "detected", "subsets_mito_percent"))

The dashed lines are the averages on the x and y axes.

moranPlot(sfe, "sum", color_by = "cluster")

While most cells are in the cluster around the average, there is a cluster of cells with lower total counts whose neighbors also have lower total counts. There also is a cluster of cells with higher total counts whose neighbors also have higher total counts. These clusters seem to be somewhat related to some gene expression based clusters.

moranPlot(sfe, "detected", color_by = "cluster")

moranPlot(sfe, "subsets_mito_percent", color_by = "cluster")

There is one main cluster on this plot for the number of genes detected and for the percentage of mitochondrial counts. However, cells are somewhat separated by gene expression clusters. This is not surprising because the gene expression clusters are also based on the k nearest neighbor graph. Cluster 4 cells have a higher percentage of mitochondrial counts and so do their neighbors.

Local Moran’s I

Also see local Moran’s I for these 3 QC metrics:

sfe <- colDataUnivariate(sfe, "localmoran", c("sum", "detected", "subsets_mito_percent"))

Here, we don’t have a histological space. So how can we visualize the local “spatial” statistics? UMAP is bad, but in this case PCA can somewhat separate the clusters. We can use the first 2 PCs as if they are the histological space. As a reference, we plot the metrics themselves and the clusters in the first 2 PCs.

plotSpatialFeature(sfe, c("sum", "detected", "subsets_mito_percent", "cluster"))

Plot the local Moran’s I for these metrics in the first 2 PCs:

plotLocalResult(sfe, "localmoran", c("sum", "detected", "subsets_mito_percent"), 
                colGeometryName = "centroids",
                divergent = TRUE, diverge_center = 0, ncol = 2)

However, what if there is no good 2D representation of the data for easy plotting? Remember that here the k nearest neighbor graph was computed on the first 10 PCs rather than the first 2 PCs. The graph is not tied to the 2D representation. We can still plot histograms to show the distribution and scatter plots to compare the same local metric for different variables, which can be colored by another variable such as cluster. This may be added to the next release of Voyager. For now, we add the results of interest to colData(sfe) and use the existing colData plotting functions from scater and Voyager.

localResultAttrs(sfe, "localmoran", "sum")
#>  [1] "Ii"             "E.Ii"           "Var.Ii"         "Z.Ii"          
#>  [5] "Pr(z != E(Ii))" "mean"           "median"         "pysal"         
#>  [9] "-log10p"        "-log10p_adj"
sfe$sum_localmoran <- localResult(sfe, "localmoran", "sum")[,"Ii"]
sfe$detected_localmoran <- localResult(sfe, "localmoran", "detected")[,"Ii"]
sfe$pct_mito_localmoran <- localResult(sfe, "localmoran", "subsets_mito_percent")[,"Ii"]
# Colorblind friendly palette
data("ditto_colors")
plotColDataFreqpoly(sfe, c("sum_localmoran", "detected_localmoran", 
                           "pct_mito_localmoran"), bins = 50, 
                    color_by = "cluster") +
    scale_y_log10() +
    annotation_logticks(sides = "l")
#> Warning in scale_y_log10(): log-10 transformation introduced
#> infinite values.

The y axis is log transformed (hence that warning when some bins have no cells), so the color of cells in the long tail can be seen because most cells don’t have very strong local Moran’s I. Cells in cluster 7 have high local Moran’s I in total UMI counts and genes detected, which means that they tend to be more homogeneous in these QC metrics.

How do local Moran’s I for these QC metrics relate to each other?

plotColData(sfe, x = "sum_localmoran", y = "detected_localmoran", 
            color_by = "cluster") +
    scale_color_manual(values = ditto_colors)
#> Scale for colour is already present.
#> Adding another scale for colour, which will replace the existing scale.

Cells more locally homogeneous in total UMI counts are also more homogeneous in number of genes detected, which is not surprising given the correlation between the two.

plotColData(sfe, x = "sum_localmoran", y = "pct_mito_localmoran", 
            color_by = "cluster") +
    scale_color_manual(values = ditto_colors)
#> Scale for colour is already present.
#> Adding another scale for colour, which will replace the existing scale.

For local Moran’s I, sum vs percentage mitochondrial counts shows a more interesting pattern, highlighting clusters 4 and 7 as in the Moran plots.

How does local Moran’s I relate to the value itself?

plotColData(sfe, x = "sum", y = "sum_localmoran", color_by = "cluster") +
    geom_density2d(data = as.data.frame(colData(sfe)),
                   mapping = aes(x = sum, y = sum_localmoran), color = "blue", 
                   linewidth = 0.3) +
    scale_color_manual(values = ditto_colors)
#> Scale for colour is already present.
#> Adding another scale for colour, which will replace the existing scale.

In this case, generally cells with higher total counts also tend to have higher local Moran’s I in total counts. However, there is another wing where cells with lower total counts have slightly higher local Moran’s I in total counts and there’s a central value of total counts with near 0 local Moran’s I. The density contour shows that cells are concentrated at that central value.

Local spatial heteroscedasticity (LOSH)

LOSH indicates heterogeneity around each cell in the k nearest neighbor graph.

sfe <- colDataUnivariate(sfe, "LOSH", c("sum", "detected", "subsets_mito_percent"))
plotLocalResult(sfe, "LOSH", c("sum", "detected", "subsets_mito_percent"), 
                colGeometryName = "centroids", ncol = 2)

Here we make the same non-spatial plots for LOSH as in local Moran’s I.

localResultAttrs(sfe, "LOSH", "sum")
#> [1] "Hi"      "E.Hi"    "Var.Hi"  "Z.Hi"    "x_bar_i" "ei"
sfe$sum_losh <- localResult(sfe, "LOSH", "sum")[,"Hi"]
sfe$detected_losh <- localResult(sfe, "LOSH", "detected")[,"Hi"]
sfe$pct_mito_losh <- localResult(sfe, "LOSH", "subsets_mito_percent")[,"Hi"]
plotColDataFreqpoly(sfe, c("sum_losh", "detected_losh", 
                           "pct_mito_losh"), bins = 50, 
                     color_by = "cluster") +
    scale_y_log10() +
    annotation_logticks(sides = "l")
#> Warning in scale_y_log10(): log-10 transformation introduced
#> infinite values.

Here, clusters 2 and 6 tend to be more locally heterogeneous. How do total counts and genes detected relate in LOSH?

plotColData(sfe, x = "sum_losh", y = "detected_losh", color_by = "cluster") +
    scale_color_manual(values = ditto_colors)
#> Scale for colour is already present.
#> Adding another scale for colour, which will replace the existing scale.

While generally cells higher in LOSH in total counts are also higher in LOSH in genes detected, there are some outliers that are very high in both, with more heterogeneous neighborhoods. Absolute distance to the neighbors is not taken into account when the adjacency matrix is row normalized. It would be interesting to see if those outliers tend to be further away from their 10 nearest neighbors, or in a region in the PCA space where cells are further apart.

How does total counts itself relate to its LOSH?

plotColData(sfe, x = "sum", y = "sum_losh", color_by = "cluster") +
    scale_color_manual(values = ditto_colors)
#> Scale for colour is already present.
#> Adding another scale for colour, which will replace the existing scale.

There does not seem to be a clear relationship in this case.

“Spatial” analyses for gene expression

First, we need to reorganize the differential expression results:

top_markers_df <- lapply(seq_along(markers), function(i) {
    out <- markers[[i]][markers[[i]]$FDR < 0.05, c("FDR", "summary.AUC")]
    if (nrow(out)) out$cluster <- i
    out
})
top_markers_df <- do.call(rbind, top_markers_df)
top_markers_df$symbol <- rowData(sce)[rownames(top_markers_df), "Symbol"]

Moran’s I

sfe <- runMoransI(sfe, features = hvgs, BPPARAM = MulticoreParam(2))

The results are added to rowData(sfe). The NA’s are for non-highly variable genes, as Moran’s I was only computed for highly variable genes here.

rowData(sfe)
#> DataFrame with 21932 rows and 5 columns
#>                              ID      Symbol            Type moran_sample01
#>                     <character> <character>     <character>      <numeric>
#> ENSG00000238009 ENSG00000238009  AL627309.1 Gene Expression             NA
#> ENSG00000239945 ENSG00000239945  AL627309.3 Gene Expression             NA
#> ENSG00000241599 ENSG00000241599  AL627309.4 Gene Expression             NA
#> ENSG00000229905 ENSG00000229905  AL669831.2 Gene Expression             NA
#> ENSG00000237491 ENSG00000237491  AL669831.5 Gene Expression             NA
#> ...                         ...         ...             ...            ...
#> ENSG00000278817 ENSG00000278817  AC007325.4 Gene Expression             NA
#> ENSG00000278384 ENSG00000278384  AL354822.1 Gene Expression             NA
#> ENSG00000277856 ENSG00000277856  AC233755.2 Gene Expression             NA
#> ENSG00000275063 ENSG00000275063  AC233755.1 Gene Expression             NA
#> ENSG00000271254 ENSG00000271254  AC240274.1 Gene Expression             NA
#>                 K_sample01
#>                  <numeric>
#> ENSG00000238009         NA
#> ENSG00000239945         NA
#> ENSG00000241599         NA
#> ENSG00000229905         NA
#> ENSG00000237491         NA
#> ...                    ...
#> ENSG00000278817         NA
#> ENSG00000278384         NA
#> ENSG00000277856         NA
#> ENSG00000275063         NA
#> ENSG00000271254         NA

How are the Moran’s I’s for highly variable genes distributed? Also, where are the top cluster marker genes in this distribution?

plotRowDataHistogram(sfe, "moran_sample01", bins = 50) +
    geom_vline(data = as.data.frame(rowData(sfe)[top_markers,]) |> 
                   mutate(index = seq_along(top_markers)),
               aes(xintercept = moran_sample01, color = index)) +
    scale_color_continuous(breaks = scales::breaks_width(2))
#> Warning: Removed 19932 rows containing non-finite outside the scale range
#> (`stat_bin()`).

The top marker genes all have quite positive Moran’s I on the k nearest neighbor graph. It would also be interesting to color this histogram by gene sets. Since the k nearest neighbor graph was found in PCA space, which is based on gene expression, as expected, Moran’s I with this graph is mostly positive, although often not that strong. A small number of genes have slightly negative Moran’s I. What do the top genes look like in PCA?

top_moran <- head(rownames(sfe)[order(rowData(sfe)$moran_sample01, decreasing = TRUE)], 4)
plotSpatialFeature(sfe, top_moran, ncol = 2)

top_moran_symbol <- rowData(sfe)[top_moran, "Symbol"]
plotExpression(sfe, top_moran_symbol, swap_rownames = "Symbol")

They are all marker genes for the same cluster, cluster 9. Perhaps these genes have high Moran’s I because they are specific to a cell type. Then how does the Moran’s I relate to cluster AUC and cluster differential expression p-value?

# See if markers are unique to clusters
anyDuplicated(rownames(top_markers_df))
#> [1] 0
top_markers_df$moran <- rowData(sfe)[rownames(top_markers_df), "moran_sample01"]
top_markers_df$log_p_adj <- -log10(top_markers_df$FDR)
top_markers_df$cluster <- factor(top_markers_df$cluster, 
                                 levels = seq_len(length(unique(top_markers_df$cluster))))

How does the differential expression p-value relate to Moran’s I?

as.data.frame(top_markers_df) |> 
    ggplot(aes(log_p_adj, moran)) +
    geom_point(aes(color = cluster)) +
    geom_smooth(method = "lm") +
    scale_color_manual(values = ditto_colors)
#> `geom_smooth()` using formula = 'y ~ x'
#> Warning: Removed 574 rows containing non-finite outside the scale range
#> (`stat_smooth()`).
#> Warning: Removed 574 rows containing missing values or values outside the scale range
#> (`geom_point()`).

Generally, more significant marker genes tend to have higher Moran’s I. This is not surprising because the clusters and Moran’s I here are both based on the k nearest neighbor graph.

as.data.frame(top_markers_df) |> 
    ggplot(aes(summary.AUC, moran)) +
    geom_point(aes(color = cluster)) +
    geom_smooth(method = "lm") +
    scale_color_manual(values = ditto_colors)
#> `geom_smooth()` using formula = 'y ~ x'
#> Warning: Removed 574 rows containing non-finite outside the scale range
#> (`stat_smooth()`).
#> Warning: Removed 574 rows containing missing values or values outside the scale range
#> (`geom_point()`).

Similarly, genes with higher AUC tend to have higher Moran’s I. For other clusters, generally speaking, genes more specific to a cluster tend to have higher Moran’s I.

Let’s use permutation testing to see if Moran’s I is statistically significant:

sfe <- runUnivariate(sfe, "moran.mc", features = top_markers, nsim = 200)
top_markers_symbol
#> [1] "TRAC"   "MNDA"   "RPL32"  "CD79A"  "NKG7"   "MALAT1" "CLU"    "BCL11A"
plotMoranMC(sfe, top_markers, swap_rownames = "Symbol")

They all seem to be very significant.

The correlogram finds Moran’s I for a higher order of neighbors and can be a proxy for distance.

system.time({
    sfe <- runUnivariate(sfe, "sp.correlogram", top_markers, order = 6, 
                     zero.policy = TRUE, BPPARAM = MulticoreParam(2))
})
#>    user  system elapsed 
#>   0.694   0.046 229.583
plotCorrelogram(sfe, top_markers, swap_rownames = "Symbol")

We see different patterns of decay in spatial autocorrelation and different length scales of spatial autocorrelation. CLU is a marker gene very specific to the smallest cluster, so higher order neighbors are very likely to be from other clusters. Marker genes for the other larger clusters with hundreds of cells nevertheless display different patterns in the correlogram.

Local Moran’s I

sfe <- runUnivariate(sfe, "localmoran", features = top_markers)
plotLocalResult(sfe, "localmoran", top_markers, colGeometryName = "centroids", 
                divergent = TRUE, diverge_center = 0, ncol = 3,
                swap_rownames = "Symbol")

We will also plot the histograms, but for now the results need to be added to colData first.

new_colname <- paste0("cluster", seq_along(top_markers), "_", 
                      top_markers_symbol, "_localmoran")
for (i in seq_along(top_markers)) {
    g <- top_markers[i]
    colData(sfe)[[new_colname[i]]] <- 
        localResult(sfe, "localmoran", g)[,"Ii"]
}
plotColDataFreqpoly(sfe, new_colname, color_by = "cluster") +
    ggtitle("Local Moran's I") +
    theme(legend.position = "top") +
    scale_y_log10() +
    annotation_logticks(sides = "l")
#> Warning in scale_y_log10(): log-10 transformation introduced
#> infinite values.

Again, the y axis is log transformed to make the tail more visible. For some clusters, the top marker gene’s local Moran’s I forms its own peak for cells in the cluster with higher local Moran’s I than other cells. However, sometimes cells within the cluster form a long tail shared with some cells from other clusters. Then the local Moran’s I could be another method for differential expression. Or since both local Moran’s I and Leiden clustering use the k nearest neighbor graph in PCA space, local Moran’s I of marker genes or perhaps eigengenes signifying gene programs for each cell type on this k nearest neighbor graph can validate or criticize Leiden clusters. Furthermore, interestingly, for some genes, the tallest peak in the histogram is away from 0.

The scatter plots as shown in the “spatial” analyses for the QC metrics section can be made to see how local Moran’s I relates to the expression of the gene itself.

i <- 6 # Change if running this notebook
plotExpression(sfe, top_markers_symbol[i], x = new_colname[i], color_by = "cluster",
               swap_rownames = "Symbol") +
    scale_color_manual(values = ditto_colors) +
    coord_flip() +
    # comment out in case of error after changing i
    geom_density2d(data = as.data.frame(colData(sfe)) |> 
                       mutate(gene = logcounts(sfe)[top_markers[i],]),
                   mapping = aes(x = .data[[new_colname[i]]], y = gene), 
                   color = "blue", linewidth = 0.3) 
#> Scale for colour is already present.
#> Adding another scale for colour, which will replace the existing scale.

For this gene, just like for total UMI counts, there are two wings and a central value where local Moran’s I is around 0. Generally, cells with higher expression of this gene have higher local Moran’s I for this gene as well. The density contours show that cells concentrate around 0 expression and some weaker positive local Moran. The streak of cells with 0 expression means that many cells don’t express this gene, and their neighbors have low and slightly homogeneous expression of this gene. This pattern may be different for different genes. Also, the p-values for each cell for local Moran’s I are available and corrected for multiple hypothesis testing, and can be plotted. The p-values are based on the z score of the local Moran statistic, although how the statistic is distributed for gene expression data warrants more investigation. This p-value can also be computed with permutation (see localmoran_perm()).

localResultAttrs(sfe, "localmoran", top_markers[1])
#>  [1] "Ii"             "E.Ii"           "Var.Ii"         "Z.Ii"          
#>  [5] "Pr(z != E(Ii))" "mean"           "median"         "pysal"         
#>  [9] "-log10p"        "-log10p_adj"

LOSH

sfe <- runUnivariate(sfe, "LOSH", top_markers)
plotLocalResult(sfe, "LOSH", top_markers, colGeometryName = "centroids", ncol = 3,
                swap_rownames = "Symbol")

In the two genes on the right, it’s interesting to see higher LOSH in the middle cluster. The two genes on the left have some outliers throwing off the dynamic range, but it seems that their high LOSH regions are different.

Again, we plot the histograms:

new_colname2 <- paste0("cluster", seq_along(top_markers), "_", 
                      top_markers_symbol, "_losh")
for (i in seq_along(top_markers)) {
    g <- top_markers[i]
    colData(sfe)[[new_colname2[i]]] <- 
        localResult(sfe, "LOSH", g)[,"Hi"]
}
plotColDataFreqpoly(sfe, new_colname2, color_by = "cluster") +
    ggtitle("Local heteroscedasticity") +
    theme(legend.position = "top") +
    scale_y_log10() +
    annotation_logticks(sides = "l")
#> Warning in scale_y_log10(): log-10 transformation introduced
#> infinite values.

The relationship between expression and LOSH is more complicated. For some genes, such as the top marker gene for cluster 1 LYAR, cells in the cluster with higher expression also have higher LOSH - much like how in Poisson and negative binomial distributions, higher mean also means higher variance. However, some genes, such as the top marker gene for cluster 2 CTSS, have lower LOSH among cells that have higher expression, which means expression of this gene is more homogeneous within the cluster, consistent with local Moran.

i <- 6 # Change if running this notebook
plotExpression(sfe, top_markers_symbol[i], x = new_colname2[i], 
               color_by = "cluster", swap_rownames = "Symbol") +
    scale_color_manual(values = ditto_colors) +
    coord_flip() +
    # comment out in case of error after changing i
    geom_density2d(data = as.data.frame(colData(sfe)) |> 
                       mutate(gene = logcounts(sfe)[top_markers[i],]),
                   mapping = aes(x = .data[[new_colname2[i]]], y = gene), 
                   color = "blue", linewidth = 0.3) 
#> Scale for colour is already present.
#> Adding another scale for colour, which will replace the existing scale.

For this gene, the density contour indicates that many cells don’t express this gene and have homogeneous neighborhoods also with low expression. That streak around 0 expression means that neighbors of cells that don’t express this gene have different levels of heterogeneity in this gene.

Moran plot

Here we make Moran plots for the top marker genes.

sfe <- runUnivariate(sfe, "moran.plot", features = top_markers, colGraphName = "knn10")

As a reference, we show Moran’s I for the top marker genes, which is the slope of the line fitted to the Moran scatter plot.

top_markers_df[top_markers,]
#> DataFrame with 8 rows and 6 columns
#>                         FDR summary.AUC  cluster      symbol     moran
#>                   <numeric>   <numeric> <factor> <character> <numeric>
#> ENSG00000277734 3.38982e-13    0.975227        1        TRAC  0.768167
#> ENSG00000163563 2.71016e-14    0.999028        2        MNDA  0.955553
#> ENSG00000144713 8.43108e-15    0.999609        3       RPL32  0.789326
#> ENSG00000105369 6.38923e-14    1.000000        4       CD79A  0.944921
#> ENSG00000105374 6.08330e-14    1.000000        5        NKG7  0.931310
#> ENSG00000251562 9.26308e-09    0.930695        6      MALAT1  0.811310
#> ENSG00000120885 6.88523e-08    1.000000        7         CLU  0.902698
#> ENSG00000119866 3.82513e-08    1.000000        8      BCL11A  0.648106
#>                 log_p_adj
#>                 <numeric>
#> ENSG00000277734  12.46982
#> ENSG00000163563  13.56701
#> ENSG00000144713  14.07412
#> ENSG00000105369  13.19455
#> ENSG00000105374  13.21586
#> ENSG00000251562   8.03324
#> ENSG00000120885   7.16208
#> ENSG00000119866   7.41735

There is no significant marker gene for cluster 7. These plots are shown in sequence

plts <- lapply(top_markers, moranPlot, sfe = sfe, color_by = "cluster", 
               swap_rownames = "Symbol")
#> Warning in value[[3L]](cond): Too few points for stat_density2d, not plotting
#> contours.
#> Warning in value[[3L]](cond): Too few points for stat_density2d, not plotting
#> contours.
#> Warning in value[[3L]](cond): Too few points for stat_density2d, not plotting
#> contours.
wrap_plots(plts, widths = 1, heights = 1) +
    plot_layout(ncol = 3, guides = "collect") +
    plot_annotation(tag_levels = "1")

For some genes, the points are so concentrated around the origin that there aren’t “enough” points elsewhere to plot the density contours. But for the cells that do express these genes, there are clusters on this plot. Some genes are not expressed in many cells, but those cells have neighbors that do express the gene, hence the vertical streak at x = 0.

In this tutorial, we applied univariate spatial statistics to the k nearest neighbor graph in the gene expression PCA space rather than the histological space. Just like in histological space, it would be impractical to examine all these statistics gene by gene, so multivariate analyses that incorporate the k nearest neighbor graph may be interesting.

Session info

sessionInfo()
#> R version 4.4.0 (2024-04-24)
#> Platform: x86_64-apple-darwin20
#> Running under: macOS Ventura 13.6.6
#> 
#> Matrix products: default
#> BLAS:   /Library/Frameworks/R.framework/Versions/4.4-x86_64/Resources/lib/libRblas.0.dylib 
#> LAPACK: /Library/Frameworks/R.framework/Versions/4.4-x86_64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.0
#> 
#> locale:
#> [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
#> 
#> time zone: UTC
#> tzcode source: internal
#> 
#> attached base packages:
#> [1] stats4    stats     graphics  grDevices utils     datasets  methods  
#> [8] base     
#> 
#> other attached packages:
#>  [1] reticulate_1.36.1              dplyr_1.1.4                   
#>  [3] patchwork_1.2.0                spdep_1.3-3                   
#>  [5] sf_1.0-16                      spData_2.3.0                  
#>  [7] BiocSingular_1.20.0            stringr_1.5.1                 
#>  [9] BiocParallel_1.38.0            bluster_1.14.0                
#> [11] scran_1.32.0                   scater_1.32.0                 
#> [13] ggplot2_3.5.1                  scuttle_1.14.0                
#> [15] BiocNeighbors_1.22.0           DropletUtils_1.24.0           
#> [17] SpatialExperiment_1.14.0       SingleCellExperiment_1.26.0   
#> [19] SummarizedExperiment_1.34.0    Biobase_2.64.0                
#> [21] GenomicRanges_1.56.0           GenomeInfoDb_1.40.0           
#> [23] IRanges_2.38.0                 S4Vectors_0.42.0              
#> [25] BiocGenerics_0.50.0            MatrixGenerics_1.16.0         
#> [27] matrixStats_1.3.0              Voyager_1.6.0                 
#> [29] SpatialFeatureExperiment_1.6.1
#> 
#> loaded via a namespace (and not attached):
#>   [1] RColorBrewer_1.1-3        jsonlite_1.8.8           
#>   [3] wk_0.9.1                  magrittr_2.0.3           
#>   [5] ggbeeswarm_0.7.2          magick_2.8.3             
#>   [7] farver_2.1.2              rmarkdown_2.27           
#>   [9] fs_1.6.4                  zlibbioc_1.50.0          
#>  [11] ragg_1.3.2                vctrs_0.6.5              
#>  [13] memoise_2.0.1             DelayedMatrixStats_1.26.0
#>  [15] RCurl_1.98-1.14           terra_1.7-71             
#>  [17] htmltools_0.5.8.1         S4Arrays_1.4.0           
#>  [19] Rhdf5lib_1.26.0           s2_1.1.6                 
#>  [21] SparseArray_1.4.3         rhdf5_2.48.0             
#>  [23] sass_0.4.9                KernSmooth_2.23-24       
#>  [25] bslib_0.7.0               htmlwidgets_1.6.4        
#>  [27] desc_1.4.3                cachem_1.1.0             
#>  [29] igraph_2.0.3              lifecycle_1.0.4          
#>  [31] pkgconfig_2.0.3           rsvd_1.0.5               
#>  [33] Matrix_1.7-0              R6_2.5.1                 
#>  [35] fastmap_1.2.0             GenomeInfoDbData_1.2.12  
#>  [37] digest_0.6.35             colorspace_2.1-0         
#>  [39] ggnewscale_0.4.10         dqrng_0.4.0              
#>  [41] RSpectra_0.16-1           irlba_2.3.5.1            
#>  [43] textshaping_0.3.7         beachmat_2.20.0          
#>  [45] labeling_0.4.3            fansi_1.0.6              
#>  [47] mgcv_1.9-1                httr_1.4.7               
#>  [49] abind_1.4-5               compiler_4.4.0           
#>  [51] proxy_0.4-27              withr_3.0.0              
#>  [53] tiff_0.1-12               viridis_0.6.5            
#>  [55] DBI_1.2.2                 highr_0.10               
#>  [57] HDF5Array_1.32.0          R.utils_2.12.3           
#>  [59] MASS_7.3-60.2             DelayedArray_0.30.1      
#>  [61] rjson_0.2.21              classInt_0.4-10          
#>  [63] tools_4.4.0               units_0.8-5              
#>  [65] vipor_0.4.7               beeswarm_0.4.0           
#>  [67] R.oo_1.26.0               glue_1.7.0               
#>  [69] nlme_3.1-164              EBImage_4.46.0           
#>  [71] rhdf5filters_1.16.0       grid_4.4.0               
#>  [73] cluster_2.1.6             generics_0.1.3           
#>  [75] memuse_4.2-3              isoband_0.2.7            
#>  [77] gtable_0.3.5              R.methodsS3_1.8.2        
#>  [79] class_7.3-22              data.table_1.15.4        
#>  [81] metapod_1.12.0            ScaledMatrix_1.12.0      
#>  [83] sp_2.1-4                  utf8_1.2.4               
#>  [85] XVector_0.44.0            ggrepel_0.9.5            
#>  [87] pillar_1.9.0              limma_3.60.0             
#>  [89] splines_4.4.0             lattice_0.22-6           
#>  [91] deldir_2.0-4              tidyselect_1.2.1         
#>  [93] locfit_1.5-9.9            sfheaders_0.4.4          
#>  [95] knitr_1.46                gridExtra_2.3            
#>  [97] edgeR_4.2.0               xfun_0.44                
#>  [99] statmod_1.5.0             stringi_1.8.4            
#> [101] UCSC.utils_1.0.0          fftwtools_0.9-11         
#> [103] yaml_2.3.8                boot_1.3-30              
#> [105] evaluate_0.23             codetools_0.2-20         
#> [107] tibble_3.2.1              cli_3.6.2                
#> [109] systemfonts_1.1.0         munsell_0.5.1            
#> [111] jquerylib_0.1.4           Rcpp_1.0.12              
#> [113] zeallot_0.1.0             png_0.1-8                
#> [115] parallel_4.4.0            pkgdown_2.0.9            
#> [117] jpeg_0.1-10               sparseMatrixStats_1.16.0 
#> [119] bitops_1.0-7              viridisLite_0.4.2        
#> [121] scales_1.3.0              e1071_1.7-14             
#> [123] purrr_1.0.2               crayon_1.5.2             
#> [125] scico_1.5.0               rlang_1.1.3              
#> [127] cowplot_1.1.3