Get started

SpaNorm: spatially aware library size normalisation

Author

Dharmesh D. Bhuva and Agus Salim

SpaNorm

SpaNorm is a spatially aware library size normalisation method that removes library size effects, while retaining biology. Library sizes need to be removed from molecular datasets to allow comparisons across observations, in this case, across space. Bhuva et al. (Bhuva et al. 2024) and Atta et al. (Atta et al. 2024) have shown that standard single-cell inspired library size normalisation approaches are not appropriate for spatial molecular datasets as they often remove biological signals while doing so. This is because library size confounds biology in spatial molecular data.

The SpaNorm workflow: SpaNorm takes the gene expression data and spatial coordinates as inputs. Using a gene-wise model (e.g., Negative Binomial (NB)), SpaNorm decomposes spatially-smooth variation into those unrelated to library size (LS), representing the underlying true biology and those related to library size. The adjusted data is then produced by keeping only the variation unrelated to library size.

SpaNorm uses a unique spatially constrained modelling approach to model gene expression (e.g., counts) and remove library size effects, while retaining biology. It achieves this through three key innovations:

  1. Optimal decomposition of spatial variation into spatially smooth library size associated (technical) and library size independent (biology) variation using generalized linear models (GLMs).
  2. Computing spatially smooth functions (using thin plate splines) to represent the gene- and location-/cell-/spot- specific size factors.
  3. Adjustment of data using percentile adjusted counts (PAC) (Salim et al. 2022), as well as other adjustment approaches (e.g., Pearson).

Every step below is shown in R and in Python. Pick a language in any tab and all tabs on the site follow. To keep this page quick to build, the model is fit on 5% of the spots (sample.p = 0.05 in R, sample_p=0.05 in Python) with a low-complexity spatial function (df.tps = 2, df_tps=2). The defaults (0.25 and 6) are better for real analyses. The R and Python results agree qualitatively but not exactly, as the two use different random number streams for spot sampling.

The package can be installed as follows:

if (!requireNamespace("BiocManager", quietly = TRUE))
    install.packages("BiocManager")

# release version
BiocManager::install("SpaNorm")
# development version from GitHub
BiocManager::install("BhuvaLab/SpaNorm")
pip install git+https://github.com/BhuvaLab/spanormpy

Load count data

We begin by loading some example 10x Visium data profiling the dorsolateral prefrontal cortex (DLPFC) of the human brain. The data has ~4,000 spots and covers genome-wide measurements. The example data here is filtered to remove lowly expressed genes (using filterGenes(HumanDLPFC, prop = 0.1)). This filtering retains genes that are expressed in at least 10% of cells. In R the data ship with the package. In Python the same data are read from an h5ad file exported from the R object, with raw counts in X and coordinates in obsm["spatial"].

# load sample data
data(HumanDLPFC)
# change gene IDs to gene names
rownames(HumanDLPFC) = rowData(HumanDLPFC)$gene_name
HumanDLPFC
#> class: SpatialExperiment 
#> dim: 5076 4015 
#> metadata(0):
#> assays(1): counts
#> rownames(5076): NOC2L HES4 ... MT-CYB AC007325.4
#> rowData names(2): gene_name gene_biotype
#> colnames(4015): AAACAAGTATCTCCCA-1 AAACACCAATAACTGC-1 ...
#>   TTGTTTCCATACAACT-1 TTGTTTGTGTAAATTC-1
#> colData names(3): cell_count sample_id AnnotatedCluster
#> reducedDimNames(0):
#> mainExpName: NULL
#> altExpNames(0):
#> spatialCoords names(2) : pxl_col_in_fullres pxl_row_in_fullres
#> imgData names(4): sample_id image_id data scaleFactor

# plot regions
p_region = plotSpatial(HumanDLPFC, colour = AnnotatedCluster, size = 0.5) +
  scale_colour_brewer(palette = "Paired", guide = guide_legend(override.aes = list(shape = 15, size = 5))) +
  ggtitle("Region")
p_region

adata = ad.read_h5ad("_cache/HumanDLPFC.h5ad")
print(adata)
#> AnnData object with n_obs × n_vars = 4015 × 5076
#>     obs: 'AnnotatedCluster', 'cell_count'
#>     uns: 'X_name'
#>     obsm: 'spatial'
#>     layers: None (.X)

# plot regions
ax = sn.pl.plot_spatial(adata, what="annotation", color="AnnotatedCluster", size=3)
ax.set_title("Region")
plt.show()

The filter function returns a logical vector indicating which genes should be kept (filterGenes() in R, filter_genes() in Python, which by default subsets the object in place).

# filter genes expressed in 20% of spots
keep = filterGenes(HumanDLPFC, 0.2)
table(keep)
#> keep
#> FALSE  TRUE 
#>  2568  2508
# subset genes
HumanDLPFC = HumanDLPFC[keep, ]
# keep genes expressed in >= 20% of spots (in place)
keep = sn.filter_genes(adata, prop=0.2, inplace=False)
print(f"kept {keep.sum()} of {keep.size} genes")
#> kept 2508 of 5076 genes
adata = adata[:, keep].copy()
adata
#> AnnData object with n_obs × n_vars = 4015 × 2508
#>     obs: 'AnnotatedCluster', 'cell_count'
#>     uns: 'X_name'
#>     obsm: 'spatial'
#>     layers: None (.X)

The log-transformed raw counts are visualised below for the gene MOBP which is a marker of oligodendrocytes enriched in the white matter (WM) (Maynard et al. 2021). Despite being a marker of this region, we see that it is in fact absent from the white matter region.

logcounts(HumanDLPFC) = log2(counts(HumanDLPFC) + 1)

p_counts = plotSpatial(
    HumanDLPFC,
    colour = MOBP,
    what = "expression",
    assay = "logcounts",
    size = 0.5
  ) +
  scale_colour_viridis_c(option = "F") +
  ggtitle("logCounts")
p_region + p_counts

# keep the raw log counts: spanorm() writes its result to layers["logcounts"]
adata.layers["log2counts"] = np.log2(adata.X + 1)

def mobp_panel(layer, title, ax):
    sn.pl.plot_spatial(adata, what="expression", color="MOBP", layer=layer, size=3, ax=ax)
    ax.set_title(title)

fig, axs = plt.subplots(1, 2, figsize=(7.5, 4.25))
sn.pl.plot_spatial(adata, what="annotation", color="AnnotatedCluster", size=3, ax=axs[0])
axs[0].set_title("Region")
mobp_panel("log2counts", "logCounts", axs[1])
fig.tight_layout()
plt.show()

Normalise count data

SpaNorm normalises data in two steps: (1) fitting the SpaNorm model of library sizes; (2) adjusting data using the fit model. A single call to the SpaNorm() function (spanorm() in Python) is enough to run these two steps. To speed up computation, the model is fit using a smaller proportion of spots/cells (default is 0.25). This can be modified using the sample.p (sample_p) parameter.

set.seed(36)
HumanDLPFC = SpaNorm(HumanDLPFC, sample.p = 0.05, df.tps = 2)
#> (1/2) Fitting SpaNorm model
#> 201 cells/spots sampled to fit model
#> iter:  1, estimating gene-wise dispersion
#> iter:  1, log-likelihood: -618667.880246
#> iter:  1, fitting NB model
#> iter:  1, iter:  1, log-likelihood: -618667.880246
#> iter:  1, iter:  2, log-likelihood: -500928.443154
#> iter:  1, iter:  3, log-likelihood: -478728.409400
#> iter:  1, iter:  4, log-likelihood: -476040.173356
#> iter:  1, iter:  5, log-likelihood: -475717.708818
#> iter:  1, iter:  6, log-likelihood: -475667.452672
#> iter:  1, iter:  7, log-likelihood: -475656.986067 (converged)
#> iter:  2, estimating gene-wise dispersion
#> iter:  2, log-likelihood: -475384.732524
#> iter:  2, fitting NB model
#> iter:  2, iter:  1, log-likelihood: -475384.732524
#> iter:  2, iter:  2, log-likelihood: -475316.360058
#> iter:  2, iter:  3, log-likelihood: -475312.004959 (converged)
#> iter:  3, estimating gene-wise dispersion
#> iter:  3, log-likelihood: -475310.881230
#> iter:  3, fitting NB model
#> iter:  3, iter:  1, log-likelihood: -475310.881230
#> iter:  3, iter:  2, log-likelihood: -475307.708419
#> iter:  3, iter:  3, log-likelihood: -475307.426293 (converged)
#> iter:  4, log-likelihood: -475307.426293 (converged)
#> (2/2) Normalising data
HumanDLPFC
#> class: SpatialExperiment 
#> dim: 2508 4015 
#> metadata(1): SpaNorm
#> assays(2): counts logcounts
#> rownames(2508): ISG15 SDF4 ... MT-ND6 MT-CYB
#> rowData names(2): gene_name gene_biotype
#> colnames(4015): AAACAAGTATCTCCCA-1 AAACACCAATAACTGC-1 ...
#>   TTGTTTCCATACAACT-1 TTGTTTGTGTAAATTC-1
#> colData names(3): cell_count sample_id AnnotatedCluster
#> reducedDimNames(0):
#> mainExpName: NULL
#> altExpNames(0):
#> spatialCoords names(2) : pxl_col_in_fullres pxl_row_in_fullres
#> imgData names(4): sample_id image_id data scaleFactor
sn.spanorm(adata, sample_p=0.05, df_tps=2, random_state=36)
#> (1/2) Fitting SpaNorm model
#> 201 cells/spots sampled to fit model
#> iter:  1, estimating gene-wise dispersion
#> iter:  1, log-likelihood: -668260.565713
#> iter:  1, fitting NB model
#> iter:  1, iter:  1, log-likelihood: -668260.565713
#> iter:  1, iter:  2, log-likelihood: -532554.579530
#> iter:  1, iter:  3, log-likelihood: -504676.462886
#> iter:  1, iter:  4, log-likelihood: -500356.359473
#> iter:  1, iter:  5, log-likelihood: -499863.910618
#> iter:  1, iter:  6, log-likelihood: -499834.235682 (converged)
#> iter:  2, estimating gene-wise dispersion
#> iter:  2, log-likelihood: -499540.010781
#> iter:  2, fitting NB model
#> iter:  2, iter:  1, log-likelihood: -499540.010781
#> iter:  2, iter:  2, log-likelihood: -499489.970559
#> iter:  2, iter:  3, log-likelihood: -499488.554206 (converged)
#> iter:  3, estimating gene-wise dispersion
#> iter:  3, log-likelihood: -499488.223498
#> iter:  3, fitting NB model
#> iter:  3, iter:  1, log-likelihood: -499488.223498
#> iter:  3, iter:  2, log-likelihood: -499487.714438
#> iter:  3, iter:  2, log-likelihood: -499487.714438
#> iter:  3, iter:  2, log-likelihood: -499487.714438
#> iter:  3, iter:  3, log-likelihood: -499487.714438 (converged)
#> iter:  4, log-likelihood: -499487.714438 (converged)
#> (2/2) Normalising data
adata
#> AnnData object with n_obs × n_vars = 4015 × 2508
#>     obs: 'AnnotatedCluster', 'cell_count', 'spanorm_sampling', 'spanorm_size_factors'
#>     var: 'spanorm_gmean', 'spanorm_psi'
#>     uns: 'X_name', 'spanorm'
#>     obsm: 'spatial', 'spanorm_W'
#>     varm: 'spanorm_alpha'
#>     layers: None (.X), 'log2counts', 'logcounts'

The output (which can be switched off by setting verbose = FALSE) shows the two steps of normalisation. In the model fitting step, a subset of cells/spots is used to fit the negative binomial (NB) model. The output shows that the fit is performed by alternating between estimation of the dispersion parameter and estimation of the NB parameters by fixing the dispersion, and that each intermediate fit converges, and so does the final fit. The accuracy of the fit can be controlled by modifying the tolerance parameter tol (default 1e-4).

Next, data is adjusted using the fit model. The following approaches are implemented for count data:

  1. adj.method = "logpac" (default) - percentile adjusted counts (PAC) which estimates the count for each gene at each location/spot/cell using a model that does not contain unwanted effects such as the library size.
  2. adj.method = "pearson" - Pearson residuals from factoring out unwanted effects.
  3. adj.method = "meanbio" - the mean of each gene at each location estimated from the biological component of the model.
  4. adj.method = "medbio" - the median of each gene at each location estimated from the biological component of the model.

In Python the argument is adj_method. These data are stored in the logcounts assay of the SpatialExperiment object (R) or the logcounts layer of the AnnData (Python). After normalisation, we see that MOBP is enriched in the white matter.

p_logpac = plotSpatial(
    HumanDLPFC,
    colour = MOBP,
    what = "expression",
    assay = "logcounts",
    size = 0.5
  ) +
  scale_colour_viridis_c(option = "F") +
  ggtitle("logPAC")
p_region + p_logpac

fig, axs = plt.subplots(1, 2, figsize=(7.5, 4.25))
sn.pl.plot_spatial(adata, what="annotation", color="AnnotatedCluster", size=3, ax=axs[0])
axs[0].set_title("Region")
mobp_panel("logcounts", "logPAC", axs[1])
fig.tight_layout()
plt.show()

Scaling to large datasets

For large datasets, the normalisation step can be parallelised across workers. In R, use the BPPARAM argument, which accepts any BiocParallelParam object from the BiocParallel package (serial by default). In Python, use n_jobs. Results are identical regardless of the number of workers. These chunks are shown but not run here.

library(BiocParallel)
# parallelise normalisation across 4 workers
HumanDLPFC = SpaNorm(HumanDLPFC, BPPARAM = MulticoreParam(workers = 4))
# parallelise normalisation across 4 workers
sn.spanorm(adata, n_jobs=4)

When the counts assay is a DelayedArray (for example, a disk-backed HDF5Array), SpaNorm in R automatically normalises the data in blocks so that the full matrix is never loaded into memory at once. This is detected from the data and needs no additional arguments, and the results are identical to the in-memory path. When combining the two, prefer BiocParallel::SnowParam() over MulticoreParam() for a disk-backed DelayedArray: fork-based workers each inherit a copy of the same open file handle, which can cause HDF5Array reads from concurrent forked workers to fail or return incorrect data. In Python, matrices are held as in-memory (dense or sparse) arrays.

library(HDF5Array)
# wrap the counts as a disk-backed DelayedArray
counts(HumanDLPFC) = writeHDF5Array(counts(HumanDLPFC))
# normalisation now runs block-wise, bounding peak memory
HumanDLPFC = SpaNorm(HumanDLPFC)

Not applicable: AnnData counts are held in memory, dense or sparse.

When using the optional GPU backend (backend = "gpu" or "auto"; in R this requires the torch package and a CUDA or Apple Silicon/Metal device, in Python a PyTorch build for your device), SpaNorm automatically detects the available accelerator memory and, for datasets too large to fit on-device at once, fits the model in gene-blocks so peak GPU memory stays bounded. This needs no additional arguments, and results match the CPU path within a small numerical tolerance from floating-point summation order. The detected memory budget can be overridden if needed.

# override the auto-detected GPU memory budget (bytes) if needed
HumanDLPFC = SpaNorm(HumanDLPFC, backend = "gpu", gpu.mem.budget = 4 * 1024^3)
# runs on CUDA (or Apple Metal in float32) when available
sn.spanorm(adata, backend="gpu")
sn.spanorm_svg(adata, backend="gpu")
# memory budget of the batched polish engine, per worker (bytes)
sn.settings.polish_mem_budget = 4e9

The Python port was verified and timed on one NVIDIA H100 PCIe (MIG 4g.40gb slice) with 8 CPU cores and 64 GB, using torch 2.6.0+cu124. The table gives wall-clock seconds for the full HumanDLPFC data (4015 spots x 2508 genes after filterGenes(prop = 0.2)), one job, with CPU runs using 8 torch threads. The jobs shared the node with a test run, so the timings are indicative only.

function CPU (s) GPU (s) speed-up
spanorm 77.7 28.9 2.7x
spanorm_svg 13.8 10.8 1.3x

The remaining time in spanorm on the GPU is host work with no device path in R either: the quick-cluster size factors, the edgeR dispersion estimation and qnbinom. R and Python follow the same algorithm on both devices, so the CPU references apply to both. The R GPU path itself has not been run on an accelerator. The scripts and details are in docs/benchmark.md of the spanormpy repository.

Using Seurat objects

In R, users may prefer to work with Seurat. The SpatialExperiment object can be converted to Seurat (v5) with spatial coordinates added, and SpaNorm() can then be run directly on the Seurat object. The counts assay is used to store raw counts and the data assay is used to store normalised values, following Seurat conventions. SpaNorm searches for coordinates in the images slot which contains the coordinates slot. If not found, it will also search the meta.data slot of the Seurat object with names x and y, or with names imagecol and imagerow. This is the order of the search of coordinates. In Python, AnnData is the native container, so there is no conversion step.

library(Seurat)

# create a Seurat object with the data
seurat_obj = HumanDLPFC
logcounts(seurat_obj) = counts(seurat_obj)
seurat_obj = suppressWarnings(Seurat::as.Seurat(seurat_obj))
# add spatial coordinates to Seurat meta.data from SpatialExperiment
coords = SpatialExperiment::spatialCoords(HumanDLPFC)
seurat_obj@meta.data$x = coords[, 1]
seurat_obj@meta.data$y = coords[, 2]

# run SpaNorm on Seurat (CPU backend)
seurat_obj <- SpaNorm(seurat_obj, sample.p = 0.1, df.tps = 2, backend = "cpu", verbose = TRUE)
seurat_obj

Not applicable: call sn.spanorm(adata) on the AnnData directly. A Seurat object can be converted to AnnData with the usual tools (for example anndata2ri or h5ad export).

Computing alternative adjustments using a precomputed SpaNorm fit

As no appropriate slot exists for storing model parameters in a SpatialExperiment, the fit is saved in the metadata slot with the name “SpaNorm”. In Python the fit lives in the AnnData itself (var, varm, obsm, and scalars in uns["spanorm"]). Subsetting features (i.e., genes) or observations (i.e., cells/spots/loci) in R does not subset the model, so SpaNorm realises that the model no longer matches the data and re-estimates when called. If instead the model is valid for the data, the existing fit is extracted and reused.

The fit can be manually retrieved as below for users wishing to reuse the model outside the SpaNorm framework. Otherwise, calling SpaNorm() on an object containing the fit will automatically use it.

# manually retrieve model
fit.spanorm = metadata(HumanDLPFC)$SpaNorm
fit.spanorm
#> SpaNormFit
#> Data: 2508 genes, 4015 cells/spots
#> Gene model: nb
#> Degrees of freedom for TPS (y,x): Biology (2,2), LS (1,1)
#> Spots/cells sampled: 5%
#> Regularisation parameter: Biology (1e-04), LS (1e-04)
#> Batch:  NULL
#> log-likelihood (per-iteration):  num [1:3] -475657 -475312 -475307
#> W:  num [1:4015, 1:6] 0.2645 0.4736 0.0547 -0.1756 0.6039 ...
#> W:  - attr(*, "dimnames")=List of 2
#> W:   ..$ : chr [1:4015] "1" "2" "3" "4" ...
#> W:   ..$ : chr [1:6] "logLS" "bs.xy.bio1" "bs.xy.bio2" "bs.xy.bio3" ...
#> alpha:  num [1:2508, 1:6] 0.982 0.982 0.982 0.982 0.982 ...
#> gmean:  num [1:2508] -1.433 -1.205 -1.162 -1.598 -0.466 ...
#> psi:  num [1:2508] 0.0735 0.0838 0.0791 0.0719 0.0708 ...
#> wtype:  Factor w/ 3 levels "batch","biology",..: 3 2 2 2 2 3
#> sampling: all (4015), glm (201), dispersion (201)
# scalars of the fit and the per-gene coefficients
print({k: v for k, v in adata.uns["spanorm"].items() if np.ndim(v) == 0})
#> {'spanorm': {'ngenes': 2508, 'ncells': 4015, 'gene_model': 'nb', 'df_tps': array([2, 2, 1, 1]), 'sample_p': 0.05, 'lambda_a': array([0.0001, 0.0001]), 'wtype': array(['ls', 'biology', 'biology', 'biology', 'biology', 'ls'],
#>       dtype='<U7'), 'loglik': array([-499834.23568236, -499488.55420576, -499487.71443846]), 'psi_max': 0.13419746539185973, 'batch': {'kind': 'none'}, 'colnames': array(['logLS', 'bs.xy.bio1', 'bs.xy.bio2', 'bs.xy.bio3', 'bs.xy.bio4',
#>        'logLS:bs.xy.ls'], dtype='<U14')}, 'adj_method': 'auto'}
print(adata.varm.keys(), adata.obsm.keys())
#> KeysView(AxisArrays with keys: 'spanorm_alpha') KeysView(AxisArrays with keys: 'spatial', 'spanorm_W')

When a valid fit exists in the object, only the adjustment step is performed. The model is recomputed if overwrite = TRUE or any of the following parameters change: degrees of freedom (df.tps), penalty parameters (lambda.a), object dimensions, or batch specification. Alternative adjustments can be computed as below and stored to the logcounts assay (layer).

mobp_plot = function(obj, title) {
  plotSpatial(obj, colour = MOBP, what = "expression", assay = "logcounts", size = 0.5) +
    scale_colour_viridis_c(option = "F") +
    ggtitle(title)
}

# Pearson residuals
HumanDLPFC = SpaNorm(HumanDLPFC, adj.method = "pearson")
p_pearson = mobp_plot(HumanDLPFC, "Pearson")
# mean biology
HumanDLPFC = SpaNorm(HumanDLPFC, adj.method = "meanbio")
p_meanbio = mobp_plot(HumanDLPFC, "Mean biology")
# median biology
HumanDLPFC = SpaNorm(HumanDLPFC, adj.method = "medbio")
p_medbio = mobp_plot(HumanDLPFC, "Median biology")

p_region + p_counts + p_logpac + p_pearson + p_meanbio + p_medbio + plot_layout(ncol = 3)

# the stored fit is reused, so only the adjustment is redone
adata.layers["logpac"] = adata.layers["logcounts"].copy()
for method, name in [("pearson", "Pearson"), ("meanbio", "Mean biology"), ("medbio", "Median biology")]:
    sn.spanorm(adata, adj_method=method)
    adata.layers[method] = adata.layers["logcounts"].copy()
#> (1/2) Fitting SpaNorm model
#> 1004 cells/spots sampled to fit model
#> iter:  1, estimating gene-wise dispersion
#> iter:  1, log-likelihood: -3241844.864187
#> iter:  1, fitting NB model
#> iter:  1, iter:  1, log-likelihood: -3241844.864187
#> iter:  1, iter:  2, log-likelihood: -2616604.236121
#> iter:  1, iter:  3, log-likelihood: -2487737.993884
#> iter:  1, iter:  4, log-likelihood: -2446959.722059
#> iter:  1, iter:  5, log-likelihood: -2433139.134770
#> iter:  1, iter:  6, log-likelihood: -2429072.582528
#> iter:  1, iter:  7, log-likelihood: -2427771.918352
#> iter:  1, iter:  8, log-likelihood: -2427287.888618
#> iter:  1, iter:  9, log-likelihood: -2427092.129931 (converged)
#> iter:  2, estimating gene-wise dispersion
#> iter:  2, log-likelihood: -2423694.462271
#> iter:  2, fitting NB model
#> iter:  2, iter:  1, log-likelihood: -2423694.462271
#> iter:  2, iter:  2, log-likelihood: -2422943.500512
#> iter:  2, iter:  3, log-likelihood: -2422865.020271 (converged)
#> iter:  3, estimating gene-wise dispersion
#> iter:  3, log-likelihood: -2422853.272900
#> iter:  3, fitting NB model
#> iter:  3, iter:  1, log-likelihood: -2422853.272900
#> iter:  3, iter:  2, log-likelihood: -2422800.196459
#> iter:  3, iter:  3, log-likelihood: -2422788.392233 (converged)
#> iter:  4, log-likelihood: -2422788.392233 (converged)
#> (2/2) Normalising data
#> (1/2) Retrieve precomputed SpaNorm model
#> (2/2) Normalising data
#> (1/2) Retrieve precomputed SpaNorm model
#> (2/2) Normalising data

fig, axs = plt.subplots(2, 3, figsize=(11.5, 8.5))
axs = axs.ravel()
sn.pl.plot_spatial(adata, what="annotation", color="AnnotatedCluster", size=3, ax=axs[0])
axs[0].set_title("Region")
mobp_panel("log2counts", "logCounts", axs[1])
for ax, (layer, title) in zip(axs[2:], [("logpac", "logPAC"), ("pearson", "Pearson"),
                                        ("meanbio", "Mean biology"), ("medbio", "Median biology")]):
    mobp_panel(layer, title, ax)
fig.tight_layout()
plt.show()

The mean biology adjustment shows a significant enrichment of the MOBP gene in the white matter. As the overall counts of this gene are low in this sample, other methods show less discriminative power.

Varying model complexity

The complexity of the spatial smoothing function is determined by the df.tps (df_tps) parameter where larger values result in more complicated functions (default 6). Here we compare the fit used above (2) with a more flexible one (4).

# df.tps = 4 (refits the model)
set.seed(36)
HumanDLPFC_df4 = SpaNorm(HumanDLPFC, sample.p = 0.05, df.tps = 4, adj.method = "logpac")
#> (1/2) Fitting SpaNorm model
#> 201 cells/spots sampled to fit model
#> iter:  1, estimating gene-wise dispersion
#> iter:  1, log-likelihood: -618667.880246
#> iter:  1, fitting NB model
#> iter:  1, iter:  1, log-likelihood: -618667.880246
#> iter:  1, iter:  2, log-likelihood: -495992.183423
#> iter:  1, iter:  3, log-likelihood: -470178.010132
#> iter:  1, iter:  4, log-likelihood: -465399.308993
#> iter:  1, iter:  5, log-likelihood: -464418.813735
#> iter:  1, iter:  6, log-likelihood: -464179.301452
#> iter:  1, iter:  7, log-likelihood: -464109.043979
#> iter:  1, iter:  8, log-likelihood: -464083.463936 (converged)
#> iter:  2, estimating gene-wise dispersion
#> iter:  2, log-likelihood: -462978.681588
#> iter:  2, fitting NB model
#> iter:  2, iter:  1, log-likelihood: -462978.681588
#> iter:  2, iter:  2, log-likelihood: -462390.407666
#> iter:  2, iter:  3, log-likelihood: -462363.836449 (converged)
#> iter:  3, estimating gene-wise dispersion
#> iter:  3, log-likelihood: -462362.154987
#> iter:  3, fitting NB model
#> iter:  3, iter:  1, log-likelihood: -462362.154987
#> iter:  3, iter:  2, log-likelihood: -462321.147313
#> iter:  3, iter:  3, log-likelihood: -462318.262638 (converged)
#> iter:  4, log-likelihood: -462318.262638 (converged)
#> (2/2) Normalising data

p_logpac_4 = mobp_plot(HumanDLPFC_df4, "logPAC (df.tps = 4)")
# df.tps = 2, as above
p_logpac_2 = p_logpac + ggtitle("logPAC (df.tps = 2)")

p_logpac_2 + p_logpac_4

# df_tps = 4 (refits the model); copy=True leaves `adata` untouched
adata_df4 = sn.spanorm(adata, sample_p=0.05, df_tps=4, adj_method="logpac", random_state=36, copy=True, verbose=False)

fig, axs = plt.subplots(1, 2, figsize=(7.5, 4.25))
mobp_panel("logpac", "logPAC (df_tps = 2)", axs[0])
sn.pl.plot_spatial(adata_df4, what="expression", color="MOBP", layer="logcounts", size=3, ax=axs[1])
axs[1].set_title("logPAC (df_tps = 4)")
fig.tight_layout()
plt.show()

Enhancing signal

As the counts for the MOBP gene are very low, we see artifacts in the adjusted counts. As we have a model for the genes, we can increase the signal by adjusting all means by a constant factor. Applying a scale factor of 4 shows how the adjusted data are more continuous, with significant enrichment in the white matter.

# scale.factor = 1 (default)
HumanDLPFC = SpaNorm(HumanDLPFC, scale.factor = 1, adj.method = "logpac")
p_logpac_sf1 = mobp_plot(HumanDLPFC, "logPAC (scale.factor = 1)")

# scale.factor = 4
HumanDLPFC = SpaNorm(HumanDLPFC, scale.factor = 4, adj.method = "logpac")
p_logpac_sf4 = mobp_plot(HumanDLPFC, "logPAC (scale.factor = 4)")

p_logpac_sf1 + p_logpac_sf4 + plot_layout(ncol = 2)

fig, axs = plt.subplots(1, 2, figsize=(7.5, 4.25))
for ax, sf in zip(axs, [1, 4]):
    sn.spanorm(adata, scale_factor=sf, adj_method="logpac", verbose=False)
    mobp_panel("logcounts", f"logPAC (scale_factor = {sf})", ax)
fig.tight_layout()
plt.show()

Exploring learnt functions

The plotCovariate() function (pl.plot_covariate() in Python) can be used to explore the learnt functions. We could study what the model has learnt about the biology and library size effects of the MOBP gene.

p1 = plotCovariate(HumanDLPFC, colour = MOBP, covariate = "biology") +
  scale_colour_viridis_c(option = "F") +
  ggtitle("Biology")
p2 = plotCovariate(HumanDLPFC, colour = MOBP, covariate = "ls") +
  scale_colour_viridis_c(option = "F") +
  ggtitle("Library size effect")
p1 + p2

fig, axs = plt.subplots(1, 2, figsize=(7.5, 4.25))
sn.pl.plot_covariate(adata, covariate="biology", color="MOBP", size=3, ax=axs[0])
axs[0].set_title("Biology")
sn.pl.plot_covariate(adata, covariate="ls", color="MOBP", size=3, ax=axs[1])
axs[1].set_title("Library size effect")
fig.tight_layout()
plt.show()

Identifying spatially variable genes

The SpaNormSVG() function (spanorm_svg() in Python) can be used to identify spatially variable genes (SVGs) in the data. This function fits a nested model without the biological function of the form:

\[ log \mu_{c,g} = \varsigma_{g} + \{\alpha + h_g(x_c, y_c;\gamma_g)\}logLS_c \]

where \(\mu_{c,g}\) is the mean of gene \(g\) at location \(c\), \(\varsigma_{g}\) is the log-mean of gene \(g\), \(h_g(x_c, y_c)\) is a smooth function of the spatial coordinates \(x_c\) and \(y_c\) representing the library size effect, and \(\alpha\) is the log-mean of the library size.

The function fits this model and then uses an F-test to identify spatially variable genes.

HumanDLPFC = SpaNormSVG(HumanDLPFC)
#> (1/3) Retrieving SpaNorm model
#> (2/3) Fitting Null SpaNorm model
#> 1004 cells/spots sampled to fit model
#> iter:  1, estimating gene-wise dispersion
#> iter:  1, log-likelihood: -3200797.536243
#> iter:  1, fitting NB model
#> iter:  1, iter:  1, log-likelihood: -3200797.536243
#> iter:  1, iter:  2, log-likelihood: -2595849.162461
#> iter:  1, iter:  3, log-likelihood: -2475258.934300
#> iter:  1, iter:  4, log-likelihood: -2454601.460897
#> iter:  1, iter:  5, log-likelihood: -2452657.056549
#> iter:  1, iter:  6, log-likelihood: -2452548.523719 (converged)
#> iter:  2, estimating gene-wise dispersion
#> iter:  2, log-likelihood: -2451862.365903
#> iter:  2, fitting NB model
#> iter:  2, iter:  1, log-likelihood: -2451862.365903
#> iter:  2, iter:  2, log-likelihood: -2451813.273764
#> iter:  2, iter:  2, log-likelihood: -2451813.273764
#> iter:  2, iter:  2, log-likelihood: -2451813.273764
#> iter:  2, iter:  3, log-likelihood: -2451813.273764 (converged)
#> iter:  3, estimating gene-wise dispersion
#> iter:  3, log-likelihood: -2451808.235312
#> iter:  3, fitting NB model
#> iter:  3, iter:  1, log-likelihood: -2451808.235312
#> iter:  3, iter:  1, log-likelihood: -2451808.235312
#> iter:  3, iter:  1, log-likelihood: -2451808.235312
#> iter:  3, iter:  2, log-likelihood: -2451808.235312
#> iter:  3, iter:  2, log-likelihood: -2451808.235312
#> iter:  3, iter:  2, log-likelihood: -2451808.235312
#> iter:  3, iter:  3, log-likelihood: -2451808.235312 (converged)
#> iter:  4, log-likelihood: -2451808.235312 (converged)
#> (3/3) Finding SVGs
#> 186 SVGs found (FDR < 0.05)
HumanDLPFC
#> class: SpatialExperiment 
#> dim: 2508 4015 
#> metadata(2): SpaNorm SpaNormNull
#> assays(2): counts logcounts
#> rownames(2508): ISG15 SDF4 ... MT-ND6 MT-CYB
#> rowData names(5): gene_name gene_biotype svg.F svg.p svg.fdr
#> colnames(4015): AAACAAGTATCTCCCA-1 AAACACCAATAACTGC-1 ...
#>   TTGTTTCCATACAACT-1 TTGTTTGTGTAAATTC-1
#> colData names(3): cell_count sample_id AnnotatedCluster
#> reducedDimNames(0):
#> mainExpName: NULL
#> altExpNames(0):
#> spatialCoords names(2) : pxl_col_in_fullres pxl_row_in_fullres
#> imgData names(4): sample_id image_id data scaleFactor
sn.spanorm_svg(adata)
#> (1/3) Retrieving SpaNorm model
#> (2/3) Fitting Null SpaNorm model
#> 1004 cells/spots sampled to fit model
#> iter:  1, estimating gene-wise dispersion
#> iter:  1, log-likelihood: -3244469.851059
#> iter:  1, fitting NB model
#> iter:  1, iter:  1, log-likelihood: -3244469.851059
#> iter:  1, iter:  2, log-likelihood: -2626561.980262
#> iter:  1, iter:  3, log-likelihood: -2499706.829691
#> iter:  1, iter:  4, log-likelihood: -2476143.708139
#> iter:  1, iter:  5, log-likelihood: -2473763.130478
#> iter:  1, iter:  6, log-likelihood: -2473654.030706 (converged)
#> iter:  2, estimating gene-wise dispersion
#> iter:  2, log-likelihood: -2473316.772755
#> iter:  2, fitting NB model
#> iter:  2, iter:  1, log-likelihood: -2473316.772755
#> iter:  2, iter:  2, log-likelihood: -2473285.301307
#> iter:  2, iter:  2, log-likelihood: -2473285.301307
#> iter:  2, iter:  2, log-likelihood: -2473285.301307
#> iter:  2, iter:  3, log-likelihood: -2473285.301307 (converged)
#> iter:  3, estimating gene-wise dispersion
#> iter:  3, log-likelihood: -2473280.853135
#> iter:  3, fitting NB model
#> iter:  3, iter:  1, log-likelihood: -2473280.853135
#> iter:  3, iter:  1, log-likelihood: -2473280.853135
#> iter:  3, iter:  1, log-likelihood: -2473280.853135
#> iter:  3, iter:  2, log-likelihood: -2473280.853135
#> iter:  3, iter:  2, log-likelihood: -2473280.853135
#> iter:  3, iter:  2, log-likelihood: -2473280.853135
#> iter:  3, iter:  3, log-likelihood: -2473280.853135 (converged)
#> iter:  4, log-likelihood: -2473280.853135 (converged)
#> (3/3) Finding SVGs
#> 202 SVGs found (FDR < 0.05)
adata.var.filter(like="svg_").head()
#>              svg_F     svg_p   svg_fdr  svg_F_raw
#> ISG15     2.143763  0.000089  0.001332   2.143763
#> SDF4      0.000000  1.000000  1.000000  -1.497284
#> ACAP3     0.000000  1.000000  1.000000  -3.046871
#> DVL1      0.000000  1.000000  1.000000  -1.664751
#> AURKAIP1  0.000000  1.000000  1.000000  -1.519660

The topSVGs() function (top_svgs() in Python) retrieves the top spatially variable genes at a given false discovery rate (FDR).

svgs = topSVGs(HumanDLPFC, n = 10)
svgs
svgs = sn.top_svgs(adata, n=10)
svgs
#>               svg_F          svg_p        svg_fdr
#> SCGB1D2   58.232440   0.000000e+00   0.000000e+00
#> SCGB2A2  129.133631   0.000000e+00   0.000000e+00
#> MBP       37.884771  8.270808e-224  6.914396e-221
#> SAA1      37.212828  5.463720e-220  3.425753e-217
#> MGP       33.251651  3.775231e-197  1.893656e-194
#> CARTPT    30.400705  2.287548e-180  9.561949e-178
#> TMSB10    27.379014  2.923859e-162  1.047577e-159
#> KRT8      21.928026  9.088722e-129  2.849314e-126
#> PCP4      21.698446  2.466590e-127  6.873563e-125
#> COX6C     19.877053  6.745682e-116  1.691817e-113

We can visualise the spatially variable genes using the spatial plot.

# fix gene names
rownames(HumanDLPFC) = gsub("-", ".", rownames(HumanDLPFC))
rownames(svgs) = gsub("-", ".", rownames(svgs))

lapply(rownames(svgs)[1:9], function(g) {
  plotSpatial(HumanDLPFC, colour = !!sym(g), what = "expression", assay = "logcounts", size = 0.5) +
    scale_colour_viridis_c(option = "F") +
    ggtitle(g) +
    theme(legend.position = "bottom")
}) |>
  wrap_plots(ncol = 3)

fig, axs = plt.subplots(3, 3, figsize=(12, 12))
for ax, g in zip(axs.ravel(), svgs.index[:9]):
    sn.pl.plot_spatial(adata, what="expression", color=g, layer="logcounts", size=3, ax=ax)
    ax.set_title(g)
fig.tight_layout()
plt.show()

Polishing the fit

SpaNorm() fits every gene in one shared IRLS loop: a single gene-averaged cell weight vector, one convergence check on the aggregate log-likelihood, and coefficients that are clamped together across genes. For most genes the result is indistinguishable from that gene’s own optimum, but a bright, spatially restricted gene can be left well short of it, and its dispersion, estimated at that point, inflated. polishSpaNorm() (polish_spanorm() in Python) takes the fitted object and converges each gene separately, by damped Newton on its own penalised negative binomial log-likelihood, then rewrites the normalised assay from the polished coefficients. The default, psi.method = "fixed", converges each gene’s mean at the dispersion SpaNorm() estimated and keeps that dispersion; psi.method = "profile" re-estimates the dispersion by profile maximum likelihood at the converged mean.

HumanDLPFC = polishSpaNorm(HumanDLPFC)
isPolished(S4Vectors::metadata(HumanDLPFC)$SpaNorm)
sn.polish_spanorm(adata)
sn.is_polished(adata)

Polishing the fit together with its SVG null (null = TRUE, the default) takes about half a minute or more on one core, which is more than this page’s build budget, so the chunks above are shown but not evaluated. The arguments are documented in the reference for polishSpaNorm() (R) and polish_spanorm() (Python). The main options are:

Argument (R / Python) Choices Effect
psi.method / psi_method "fixed" (default), "profile" "fixed" converges each gene’s mean at the dispersion SpaNorm() estimated and keeps it. "profile" re-estimates the dispersion by profile maximum likelihood at the converged mean and re-polishes.
ls "fixed" (default), "joint" "fixed" holds the shared a1 at SpaNorm()’s value. "joint" re-estimates it over the polished genes.
cells "all" (default), "fit" Polish on all cells, or only on the cells SpaNorm() sampled for the fit.
null TRUE / True (default) Also polish the stored SVG null model so it stays paired with the full fit.
engine "batch" (default), "gene" The batched Newton engine is the only one available on an accelerator.
backend "cpu" (default), "auto", "gpu" Device for the batched engine.
overwrite FALSE / False Allow re-polishing an already-polished fit, starting from the unpolished one kept alongside it.

A gene with no counts in the polished cells, or none in the polished cells of one batch level, is held out: it keeps its input fit and is flagged as not polished, with the reason recorded. A warning also counts any gene the Newton engine could not polish. In Python the per-gene diagnostics are stored in adata.var (columns prefixed spanorm_polish_, for example spanorm_polish_polished and spanorm_polish_held_out); in R they are the genes data frame in the polish slot of the stored fit.

If the SVG test has already been run, polishing re-polishes the stored null model to match (null = TRUE is the default) and drops the now-stale SVG statistics with a warning, so rerun the SVG step afterwards. The full and null fits must always be estimated the same way for the SVG likelihood-ratio test to compare nested models. Either polish before calling the SVG function, or call it directly on an unpolished fit and let it pair the null itself.

By default (ls = "fixed") the shared library-size coefficient a1 is held at SpaNorm()’s value while every other coefficient is converged; ls = "joint" re-estimates a1 itself, at the optimum of the total penalised likelihood over the polished genes. ls = "fixed" is the default because SpaNorm()’s own a1 is an unweighted mean over genes, where the joint estimate is information-weighted and so dominated by the brightest genes: it changes the estimand, not only its precision, and mostly renormalises the shallowest (lowest-library-size) cells.

GLM-PCA

PCA on log-transformed counts has been shown to distort low dimensional features in single-cell datasets (Townes et al. 2019). As spatial transcriptomics data follows similar distributions, this is also the case for spatial transcriptomics data. GLM-PCA which computes PCA directly on the counts is a better approach to perform PCA on spatial transcriptomics data. While the GLM-PCA algorithm itself is computationally intensive, an approximation proposed in the original manuscript is to fit a null model to the data and perform PCA on the deviance or Pearson residuals. This null model is the one fit above to estimate SVGs. SpaNorm implements the GLM-PCA approximation using the SpaNormPCA() function (spanorm_pca() in Python). Users can specify the features to use from the SVG calling, using cutoffs for the top SVGs to use (recommended) or using a FDR cutoff. The interface is similar to scater::runPCA() and the results are stored in the reducedDim slot (R) or obsm (Python, "X_spanorm_pca"). This function should always be run after calling SVGs.

library(scater)

HumanDLPFC = SpaNormPCA(HumanDLPFC, ncomponents = 50, svg.fdr = 0.2, nsvgs = Inf)
HumanDLPFC = runUMAP(HumanDLPFC, n_neighbors = 20, min_dist = 0.3)
plotUMAP(HumanDLPFC, colour_by = "AnnotatedCluster", size_by = "cell_count") +
  scale_colour_brewer(palette = "Paired", guide = guide_legend(override.aes = list(shape = 15, size = 5))) +
  labs(title = "UMAP derived from SpaNorm PCA", colour = "Cluster")

sn.spanorm_pca(adata, ncomponents=50, svg_fdr=0.2, nsvgs=np.inf)
sc.pp.neighbors(adata, n_neighbors=20, use_rep="X_spanorm_pca")
sc.tl.umap(adata, min_dist=0.3)
ax = sc.pl.umap(adata, color="AnnotatedCluster", palette="Paired", size=15 * (1 + adata.obs["cell_count"].to_numpy() / adata.obs["cell_count"].max()),
                title="UMAP derived from SpaNorm PCA", show=False)
ax.figure.tight_layout()
plt.show()

Fitting a custom model

SpaNorm’s underlying negative binomial fitting engine is also available as a standalone, general-purpose per-gene GLM fitter via fitNB() (fit_nb() in Python). This is useful for packages that want SpaNorm’s IRLS fitting machinery (per-gene dispersion estimation, ridge regularisation, robust winsorisation) over their own design matrix, rather than SpaNorm’s spatial (library-size/biology) model. Unlike SpaNorm(), fitNB() fits log(mu) = W %*% t(alpha) directly, with no built-in intercept; include a column of ones in W if a per-gene baseline is required.

set.seed(1)
Y <- matrix(rpois(20 * 50, 5), 20, 50)
W <- cbind(intercept = 1, covariate = scale(seq_len(50))[, 1])
fit <- fitNB(Y, W, verbose = FALSE)
str(fit$alpha)
#>  num [1:20, 1:2] 1.65 1.6 1.72 1.62 1.62 ...
rng = np.random.default_rng(1)
Y = rng.poisson(5, size=(20, 50)).astype(float)
x = np.arange(50)
W = np.column_stack([np.ones(50), (x - x.mean()) / x.std(ddof=1)])
fit = sn.fit_nb(Y, W, verbose=False)
print(fit.keys())
#> dict_keys(['gmean', 'alpha', 'psi', 'sampling', 'loglik'])
print(np.asarray(fit["alpha"]).shape)
#> (20, 2)

To polish any such fit gene by gene, with an optional known offset, see polishNB() (R) and polish_nb() (Python).

Session information

sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 24.04.5 LTS
#> 
#> Matrix products: default
#> BLAS:   /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3 
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.26.so;  LAPACK version 3.12.0
#> 
#> locale:
#>  [1] LC_CTYPE=C.UTF-8       LC_NUMERIC=C           LC_TIME=C.UTF-8       
#>  [4] LC_COLLATE=C.UTF-8     LC_MONETARY=C.UTF-8    LC_MESSAGES=C.UTF-8   
#>  [7] LC_PAPER=C.UTF-8       LC_NAME=C              LC_ADDRESS=C          
#> [10] LC_TELEPHONE=C         LC_MEASUREMENT=C.UTF-8 LC_IDENTIFICATION=C   
#> 
#> time zone: UTC
#> tzcode source: system (glibc)
#> 
#> attached base packages:
#> [1] stats4    stats     graphics  grDevices utils     datasets  methods  
#> [8] base     
#> 
#> other attached packages:
#>  [1] scater_1.41.2               scuttle_1.23.2             
#>  [3] SpatialExperiment_1.23.0    SingleCellExperiment_1.35.2
#>  [5] SummarizedExperiment_1.43.0 Biobase_2.73.2             
#>  [7] GenomicRanges_1.65.4        Seqinfo_1.3.2              
#>  [9] IRanges_2.47.5              S4Vectors_0.51.10          
#> [11] BiocGenerics_0.59.12        generics_0.1.4             
#> [13] MatrixGenerics_1.25.0       matrixStats_1.5.0          
#> [15] patchwork_1.3.2             ggplot2_4.0.3              
#> [17] SpaNorm_1.7.16             
#> 
#> loaded via a namespace (and not attached):
#>  [1] tidyselect_1.2.1    viridisLite_0.4.3   dplyr_1.2.1        
#>  [4] vipor_0.4.7         farver_2.1.2        viridis_0.6.5      
#>  [7] S7_0.2.2            fastmap_1.2.0       bluster_1.23.1     
#> [10] digest_0.6.39       rsvd_1.0.6          lifecycle_1.0.5    
#> [13] cluster_2.1.8.2     processx_3.9.0      statmod_1.5.2      
#> [16] magrittr_2.0.5      compiler_4.6.1      rlang_1.3.0        
#> [19] tools_4.6.1         igraph_2.3.4        yaml_2.3.12        
#> [22] FNN_1.1.4.1         knitr_1.52          S4Arrays_1.13.2    
#> [25] labeling_0.4.3      dqrng_0.4.1         htmlwidgets_1.6.4  
#> [28] bit_4.6.0           reticulate_1.47.0   DelayedArray_0.39.8
#> [31] RColorBrewer_1.1-3  abind_1.4-8         BiocParallel_1.47.0
#> [34] withr_3.0.3         grid_4.6.1          beachmat_2.29.3    
#> [37] edgeR_4.99.10       scales_1.4.0        cli_3.6.6          
#> [40] rmarkdown_2.32      metapod_1.21.0      otel_0.2.0         
#> [43] RSpectra_0.16-2     rjson_0.2.23        ggbeeswarm_0.7.3   
#> [46] splines_4.6.1       parallel_4.6.1      XVector_0.53.0     
#> [49] coro_1.1.0          vctrs_0.7.3         Matrix_1.7-5       
#> [52] jsonlite_2.0.0      callr_3.8.0         BiocSingular_1.29.2
#> [55] BiocNeighbors_2.7.3 bit64_4.8.6         ggrepel_0.9.8      
#> [58] irlba_2.4.1         beeswarm_0.4.0      magick_2.9.1       
#> [61] locfit_1.5-9.12     limma_3.99.2        glue_1.8.1         
#> [64] ps_1.9.3            codetools_0.2-20    cowplot_1.2.0      
#> [67] uwot_0.2.5          gtable_0.3.6        ScaledMatrix_1.21.0
#> [70] tibble_3.3.1        pillar_1.11.1       htmltools_0.5.9    
#> [73] torch_0.17.0        R6_2.6.1            evaluate_1.0.5     
#> [76] lattice_0.22-9      png_0.1-9           scran_1.41.1       
#> [79] Rcpp_1.1.2          gridExtra_2.3.1     SparseArray_1.13.4 
#> [82] xfun_0.62           pkgconfig_2.0.3
import importlib.metadata as im, platform
print("python", platform.python_version())
#> python 3.12.15
for p in ["spanormpy", "anndata", "scanpy", "numpy", "torch", "matplotlib"]:
    print(p, im.version(p))
#> spanormpy 0.1.0
#> anndata 0.13.4
#> scanpy 1.12.4
#> numpy 2.5.4
#> torch 2.14.1+cpu
#> matplotlib 3.11.2

References

Atta, Lyla, Kalen Clifton, Manjari Anant, Gohta Aihara, and Jean Fan. 2024. “Gene Count Normalization in Single-Cell Imaging-Based Spatially Resolved Transcriptomics.” bioRxiv, ahead of print. https://doi.org/10.1101/2023.08.30.555624.
Bhuva, Dharmesh D, Chin Wee Tan, Agus Salim, et al. 2024. “Library Size Confounds Biology in Spatial Transcriptomics Data.” Genome Biology 25 (1): 99.
Maynard, Kristen R, Leonardo Collado-Torres, Lukas M Weber, et al. 2021. “Transcriptome-Scale Spatial Gene Expression in the Human Dorsolateral Prefrontal Cortex.” Nature Neuroscience 24 (3): 425–36.
Salim, Agus, Ramyar Molania, Jianan Wang, Alysha De Livera, Rachel Thijssen, and Terence P Speed. 2022. “RUV-III-NB: normalization of single cell RNA-seq data.” Nucleic Acids Research 50 (16): e96–96. https://doi.org/10.1093/nar/gkac486.
Townes, F William, Stephanie C Hicks, Martin J Aryee, and Rafael A Irizarry. 2019. “Feature Selection and Dimension Reduction for Single-Cell RNA-Seq Based on a Multinomial Model.” Genome Biology 20: 1–16.