---
title: "SIB Spatial Omics Data Analysis Course 2026 (Part 2): Lattice Data Analysis Vignette"
author: "Samuel Gunz, Martin Emons, Mark D. Robinson, Peiying Cai"
format:
    html:
      toc: true
      self-contained: true
editor: visual
editor_options: 
  chunk_output_type: inline
bibliography: resources/PASTA.bib
---

# Introduction

In contrast to point pattern based methods, we can view the location of cells or spots as a fixed lattice and measure the corresponding marker expression at each location. HTS-based spatial transcriptomic technologies often produce data on a regular lattice (i.e., approximately evenly spaced spots or beads of uniform size and shape), whereas imaging-based technologies yield irregular lattice structures (i.e., with variable cell sizes and shapes, and non-uniform spacing).

For this representation of the cells, we will rely on the `r BiocStyle::Biocpkg('SpatialFeatureExperiment')` package (see below for more details). For preprocessing of the dataset, we refer the reader to the vignette of the `r BiocStyle::Biocpkg('Voyager')` package [@mosesVoyagerExploratorySinglecell2023]. The `Voyager` package also provides wrapper functions around the package `r BiocStyle::CRANpkg('spdep')` [@pebesmaSpatialDataScience2023] that work directly on the `SpatialFeatureExperiment` object. The package `spdep` is designed for the analysis of spatial data with lattice structure.

## Dependencies

```{r, warning=FALSE, message=FALSE}

if(!file.exists("/home/jovyan/.cache/R/ExperimentHub")){
  dir.create("/home/jovyan/.cache/R/ExperimentHub", recursive = TRUE)
}

Sys.setenv(PROJ_DATA = "/app/.pixi/envs/default/share/proj") # Required for Voyager to work in RStudio within container

suppressPackageStartupMessages({
  library(Voyager)
  library(SpatialFeatureExperiment)
  library(SFEData)
  library(sf)
  library(scater)
})

theme_set(theme_light())
```

## Setup and Preprocessing

We will load a dataset generated by [@mckellarLargescaleIntegrationSinglecell2021] using the Visium technology [@stahlVisualizationAnalysisGene2016]. The data shows a sample taken from the tibialis anterior muscle of a mouse.

```{r}
# Load the dataset

basedir <- "/home/jovyan/work/practical_7/"

if(!file.exists(file.path(basedir, "data","muscle.rds"))){
  
  sfe <- SFEData::McKellarMuscleData(dataset = "full")
  
  dir_path <- paste0(basedir, "/data")
  file_path <- file.path(dir_path, "muscle.rds")
  
  # Check if the directory exists, and create it if it doesn't
  if (!dir.exists(dir_path)) {
    dir.create(dir_path)
  }
  
  saveRDS(sfe, file.path(basedir, "data","muscle.rds"))
}

sfe = readRDS(file.path(basedir, "data","muscle.rds"))
# Take spots that are covered with tissue
sfe_tissue <- sfe[, colData(sfe)$in_tissue]
rm(sfe)
invisible(gc())
# Filter out genes with no counts
sfe_tissue <- sfe_tissue[rowSums(counts(sfe_tissue)) > 0, ]
# Convert counts to log-transformed normalized expression values
sfe_tissue <- scater::logNormCounts(sfe_tissue)
```

`SpatialFeatureExperiment` [@mosesVoyagerExploratorySinglecell2023] objects are an extension of the `r BiocStyle::Biocpkg('SpatialExperiment')` object [@righelliSpatialExperimentInfrastructureSpatiallyresolved2022]. In particular, the objects contain geometric annotations that are encoded as simple features of the `r BiocStyle::CRANpkg('sf')` library [@pebesmaSpatialDataScience2023].

```{r}
sfe_tissue
```

For example, the spots of the Visium dataset are stored as a simple feature collection.

```{r}
colGeometry(sfe_tissue, "spotPoly")
```

# Lattice Data

## Definition

Lattice data refers to spatial data collected at locations arranged in a regular or irregular grid (lattice). Each location has a defined spatial unit, and the sampling locations are fixed rather than random. This approach contrasts with point pattern analysis, where we assume that the locations were generated by a stochastic process [@zuurAnalysingEcologicalData2007; @pebesmaSpatialDataScience2023].

As can be seen below, the Visium dataset is a regular (hexagonal) lattice. The color shows the number of counts detected in each Visium spot. In contrast, the outlines of individual cells after segmentation (e.g., from a higher resolution spatial omics assay, or an H&E stain) could be thought of and represented as an irregular lattice. As this dataset also contains (manual) segmentations of myofibers (muscle cells stored as `myofiber_simplified`), we will illustrate calculations based on irregular lattice on the myofiber segmentations.

```{r}
# A plot using the plotSpatialFeature from Voyager
p <- plotSpatialFeature(sfe_tissue,
  "nCounts",
  colGeometryName = "spotPoly"
)

# Extract the segmented myofiber geometries
myofibers <- annotGeometry(sfe_tissue, "myofiber_simplified") |>
  st_geometry()

# We can also use ggplot and geom_sf to plot sf objects
q <- ggplot() +
  geom_sf(data = myofibers, fill = NA) +
  theme_void()

# Using `patchwork` to combine the plots
p | q
```

## Spatial weight matrix

In lattice data analysis, a key concept is the *spatial weight matrix*, which models the spatial relationships between units in the lattice (i.e., spots or cells). Various methods exist to construct this matrix, such as contiguity-based (direct neighbors), graph-based, or distance-based methods. [@getisSpatialWeightsMatrices2009; @zuurAnalysingEcologicalData2007; @pebesmaSpatialDataScience2023]. The documentation of the package `spdep` gives an [overview of the different methods](https://r-spatial.github.io/spdep/articles/nb.html).

For Visium, the most straightforward way is to take the direct neighbours of each spot. This is done using the function `findVisiumGraph`.

```{r}
colGraph(sfe_tissue, "visium") <- findVisiumGraph(sfe_tissue)
```

```{r}
plotColGraph(sfe_tissue,
  colGraphName = "visium",
  colGeometryName = "spotPoly"
) + theme_void()
```

In an irregular lattice, the task of finding a spatial weight matrix is more complex, as different options exist. One option is to base the neighbourhood graph on neighbours that are in direct contact with each other (contiguous), as implemented in the `poly2nb` method.

```{r, warning=FALSE, message=FALSE}
annotGraph(sfe_tissue, "myofiber_poly2nb") <-
  findSpatialNeighbors(sfe_tissue,
    type = "myofiber_simplified",
    MARGIN = 3,
    method = "poly2nb", # wraps spdep function with same name
    zero.policy = TRUE
  )
```

```{r}
p1 <- plotAnnotGraph(sfe_tissue,
  annotGraphName = "myofiber_poly2nb",
  annotGeometryName = "myofiber_simplified"
) + theme_void()
```

Alternatively, we could take the five nearest neighbours of each myofiber.

```{r}
annotGraph(sfe_tissue, "knn5") <-
  findSpatialNeighbors(sfe_tissue,
    type = "myofiber_simplified",
    MARGIN = 3, # to use the annotation geometry
    method = "knearneigh", # wraps the spdep function with the same name
    k = 5,
    zero.policy = TRUE
  )
```

```{r}
p2 <- plotAnnotGraph(sfe_tissue,
  annotGraphName = "knn5",
  annotGeometryName = "myofiber_simplified"
) + theme_void()
```

As we can see below, the graphs look quite distinct. On the left side, in the contiguous neighbour graph (neighbours in direct contact) we can notice the formation of patches, while in the kNN graph isolated patches are interconnected.

```{r}
p1 + p2
```

<!-- In the case of Visium data, we don't have cell level gene expression information and therefore continue with the analysis using the spatial analysis of the regularly spaced spots. -->

# Spatial autocorrelation

Spatial autocorrelation measures the association between spatial units while recognizing that the spatial units are not independent measurements. These measures can be global (summarizing the entire field) or local (providing statistics for individual locations) [@pebesmaSpatialDataScience2023].

## Global Measures

Global methods give us an overview over the entire field-of-view and summarize the spatial autocorrelation metric to a single value. The metrics are a function of the weight matrix and the variables of interest. The variables of interest can be gene expression, intensity of a marker or the area of the cell. The global measures can be seen as a weighted average of the local metric, as explained below.

In general, a global spatial autocorrelation measure has the form of a double sum over all locations $i,j$

$$\sum_i \sum_j f(x_i,x_j) w_{ij}$$

where $f(x_i,x_j)$ is the measure of association between features of interest and $w_{ij}$ scales the relationship by a spatial weight as defined in the weight matrix $W$ [@zuurAnalysingEcologicalData2007; @pebesmaSpatialDataScience2023].

### Moran's I

Moran's I is the most prominent measure of spatial autocorrelation. The values are generally bounded by $-1$ and $1$. The expected value is close to $0$ for large $n$, the exact value is given by $\mathbb{E}(I) = -1/(n-1)$. A value higher than the expected value indicates spatial auto-correlation. Negative values indicate negative auto-correlation. Spatial auto-correlation means that similar values tend to be found together in the tissue. In fact, Moran's I can be interpreted as the Pearson correlation between the value at location $i$ and the average value of the neighbours of $i$, (neighbours as defined in the weight matrix $W$) [@moranNotesContinuousStochastic1950].

In the first example, we will calculate Moran's I for the number of counts and genes measured in the Visium dataset. First we have a look at the distribution by eye.

```{r}
plotSpatialFeature(sfe_tissue,
  features = c("nCounts", "nGenes"),
  colGeometryName = "spotPoly"
)
```

Based on these values we can calculate Moran's I

```{r}
calculateUnivariate(t(colData(sfe_tissue)[, c("nCounts", "nGenes")]),
  type = "moran",
  listw = colGraph(sfe_tissue, "visium")
)
```

We can further use permutation testing to assess statistical significance of our estimates.

```{r}
sfe_tissue <- colDataUnivariate(sfe_tissue,
  features = c("nCounts", "nGenes"),
  colGraphName = "visium", nsim = 1000,
  type = "moran.mc"
)
plotMoranMC(sfe_tissue, c("nCounts", "nGenes"),
  linewidth = 2
)
```

Here we can see the Moran's I values for the number of counts and genes that were identified in the Visium dataset and a permutation-based null distribution. As we can see, the Moran's I values both indicate positive spatial autocorrelation and are highly significant. This means that regions with similar count and number of measured gene values tend to cluster together. This is an interesting finding and was observed in other spatial transcriptomic datasets, see [@bhuvaLibrarySizeConfounds2024].

## Local Measures for Univariate Data

Often a global measure is not enough. One number determining, e.g., the spatial autocorrelation over an entire tissue slice might not be reflective of tissue heterogeneity. Therefore, local indicators of spatial associations have been developed [@pebesmaSpatialDataScience2023].

### Local Moran's I

Local Moran's I provides a measure of spatial autocorrelation at each location, highlighting local clusters of similarity or dissimilarity [@anselinLocalIndicatorsSpatial1995]. It is defined as:

$$I_i = \frac{x_i - \bar{x}}{\sum_{k=1}^n(x_k-\bar{x})^2/(n-1)} \sum_{j=1}^n w_{ij}(x_j - \bar{x})$$

where the index $i$ refers to the location for which the measure is calculated. The interpretation is analogous to the global Moran's I where a value of $I_i$ higher than $\mathbb{E}(I) = -w_i/(n-1)$ indicates spatial auto-correlation; smaller values indicate negative auto-correlation. It is important to note that, as for the global counterpart, the value of local Moran's I could be a result from both the high or low end of the values. Here we will calculate the local Moran's I value for the measurement of muscle fiber marker gene *Myh2*:

```{r}
sfe_tissue <- runUnivariate(sfe_tissue,
  type = "localmoran",
  features = "Myh2",
  colGraphName = "visium",
  swap_rownames = "symbol"
)

# plot the expression values
pExp <- plotSpatialFeature(sfe_tissue,
  features = c("Myh2"), colGeometryName = "spotPoly",
  swap_rownames = "symbol"
)

# plot the local Moran's I values
pLi <- plotLocalResult(sfe_tissue, "localmoran",
  features = c("Myh2"),
  colGeometryName = "spotPoly", swap_rownames = "symbol",
  divergent = TRUE, diverge_center = 0
) + labs(fill = "li(Myh2)") # specify legend


# plot the local Moran's I p-values
pPval <- plotLocalResult(sfe_tissue, "localmoran",
  features = c("Myh2"), "-log10p_adj",
  colGeometryName = "spotPoly", swap_rownames = "symbol",
  divergent = TRUE, diverge_center = -log10(0.05)
) + labs(fill = "-log10(p.adj)") # specify legend

pExp + pLi + pPval
```

In the local version of Moran's I, the interpretation is the same as the global version. When interpreting local autocorrelation measures, it is important to consider both the effect size estimates and the significance level. Since the significance level is calculated for each spot separately, it is recommended to adjust for multiple testing. By default, `runUnivariate` uses the Benjamini & Hochberg correction. The local Moran's I statistics reveal locations in the tissue that have similar values to their neighbours (c.f., the lower part of the tissue).

::: {.callout-tip title="EXERCISE"}
Try to recalculate the local Moran's I with gene *Myh1*. What do you notice?
:::

### Advanced -- Bivariate Moran's I

There exists a bivariate version of Moran's I as well. The package `spatialDM` [@liSpatialDMRapidIdentification2023] uses an adapted version of bivariate Moran's I to identify ligand-receptor pairs.

For two continuous observations, the global bivariate Moran's I is defined as [@wartenbergMultivariateSpatialCorrelation1985; @bivandPackagesAnalyzingSpatial2022]
$$I_B =\frac{\sum_i x_i \sum_j w_{ij} y_j}{\sum_i x_i^2}.$$

Here, $x_i$ is the value of variable $x$ at location $i$, while
$\sum_j w_{ij}y_j$ is the spatial lag of variable $y$, i.e. a weighted
summary of $y$ in the neighbours of location $i$ (also called the spatial lag of $y$).

There exists a local version that we will explore here. The local version follows that same idea; it compares $x_i$ with the spatial lag of a second variable $y$. Note that bivariate Moran's I is not symmetric.

```{r}
sfe_tissue <- runBivariate(sfe_tissue, 
                           "localmoran_bv", # wraps the method from spdep
                           c("Myh1", "Myh2"),
                           swap_rownames = "symbol", nsim = 1000)

# this command gives all results of localmoran_bv
localResultFeatures(sfe_tissue, "localmoran_bv")

plotLocalResult(sfe_tissue, "localmoran_bv", 
                c("Myh1__Myh2", "Myh2__Myh1"),
                colGeometryName = "spotPoly",
                divergent = TRUE, diverge_center = 0)
```

::: {.callout-tip title="EXERCISE"}
Compare the results with the univariate local Moran's I plots for genes *Myh1* and *Myh2*. Also compare *Myh1*–*Myh2* with *Myh2*–*Myh1*. What do you notice?
:::

Please note that the result might overestimate the spatial autocorrelation of the variables due to the inherent (non-spatial) correlation of $x$ and $y$ [@bivandPackagesAnalyzingSpatial2022].

## Impact of neighbourhood on autocorrelation measures

Let's compare the impact of different spatial weight matrices on local autocorrelation analysis. In this Visium dataset we do not have gene expression information for each cell because of the resolution of the spots. We will instead calculate the autocorrelation measures on the area of the segmented myofibers. This allows us to ask whether similarly sized myofibers tend to cluster spatially.

First, we base the spatial weight matrix on contiguous neighbours.

```{r}
sfe_tissue <- annotGeometryUnivariate(sfe_tissue, 
  "localmoran", "area",
  annotGeometryName = "myofiber_simplified",
  annotGraphName = "myofiber_poly2nb",
  include_self = FALSE, zero.policy = TRUE,
  name = "myofiber_poly2nb"
)

pPoly <- plotLocalResult(sfe_tissue, "myofiber_poly2nb", "area",
  annotGeometryName = "myofiber_simplified",
  annotGraphName = "myofiber_poly2nb",
  divergent = TRUE, diverge_center = 0
) + labs(title = "Local Moran's I (li)\nContiguous Neighbours", 
         fill = "li(area)")
```

As an alternative, we also base the spatial weight matrix on the 5 nearest neighbours.

```{r}
sfe_tissue <- annotGeometryUnivariate(sfe_tissue, "localmoran", "area",
  annotGeometryName = "myofiber_simplified",
  annotGraphName = "knn5",
  include_self = FALSE, zero.policy = TRUE,
  name = "knn5"
)

pKnn5 <- plotLocalResult(sfe_tissue, "knn5", "area",
  annotGeometryName = "myofiber_simplified",
  annotGraphName = "knn5",
  divergent = TRUE, diverge_center = 0
) + labs(title = "Local Moran's I (li)\n5 Nearest Neighbours", 
         fill = "li(area)")
```

```{r}
pPoly + pKnn5
```

::: {.callout-tip title="EXERCISE"}
What happens if we set the nearest neighbourhood graph to 10 nearest neighbours? What do you notice?
:::

# Summary and Considerations

- Lattice data refers to spatial data collected at fixed locations arranged in regular or irregular grids, contrasting with stochastic point pattern analysis.
- Regular lattices have uniform, evenly spaced spots, while irregular lattices have variable sizes and shapes with non-uniform spacing.
- The spatial weight matrix models spatial relationships between lattice units. Different methods exist to define the spatial weight matrix.
- Global spatial autocorrelation measures, like Moran's I, summarize spatial correlation over the entire field, while local measures identify local clusters of similarity or dissimilarity.

```{r}
sessionInfo()
```

This tutorial is partially based on the following `Voyager` [vignette](https://pachterlab.github.io/voyager/articles/vig2_visium.html).
