TweedSpot detects spatially variable genes (SVGs) in spatial
transcriptomics data using Tweedie generalized additive models (GAMs)
with a spatial smooth. The package currently focuses on an agnostic SVG
workflow built around the SpatialExperiment container.
TweedSpot is intended to be useful to the broader spatial transcriptomics community, especially for analysts who want a method that models count data directly, adapts to heterogeneous mean-variance relationships, and plugs into standard downstream workflows for ranking, visualization, and interpretation of spatially variable genes. We are submitting the package to Bioconductor because we think it can serve as a broadly accessible, reproducible tool for community use and comparison across spatial datasets and analysis pipelines.
Once available on Bioconductor, the release version can be installed with:
The current development version can be installed from GitHub with:
This vignette covers a standard workflow:
SpatialExperimentlibrary(TweedSpot)
library(SpatialExperiment)
#> Loading required package: SingleCellExperiment
#> Loading required package: SummarizedExperiment
#> Loading required package: MatrixGenerics
#> Loading required package: matrixStats
#>
#> Attaching package: 'MatrixGenerics'
#> The following objects are masked from 'package:matrixStats':
#>
#> colAlls, colAnyNAs, colAnys, colAvgsPerRowSet, colCollapse,
#> colCounts, colCummaxs, colCummins, colCumprods, colCumsums,
#> colDiffs, colIQRDiffs, colIQRs, colLogSumExps, colMadDiffs,
#> colMads, colMaxs, colMeans2, colMedians, colMins, colOrderStats,
#> colProds, colQuantiles, colRanges, colRanks, colSdDiffs, colSds,
#> colSums2, colTabulates, colVarDiffs, colVars, colWeightedMads,
#> colWeightedMeans, colWeightedMedians, colWeightedSds,
#> colWeightedVars, rowAlls, rowAnyNAs, rowAnys, rowAvgsPerColSet,
#> rowCollapse, rowCounts, rowCummaxs, rowCummins, rowCumprods,
#> rowCumsums, rowDiffs, rowIQRDiffs, rowIQRs, rowLogSumExps,
#> rowMadDiffs, rowMads, rowMaxs, rowMeans2, rowMedians, rowMins,
#> rowOrderStats, rowProds, rowQuantiles, rowRanges, rowRanks,
#> rowSdDiffs, rowSds, rowSums2, rowTabulates, rowVarDiffs, rowVars,
#> rowWeightedMads, rowWeightedMeans, rowWeightedMedians,
#> rowWeightedSds, rowWeightedVars
#> Loading required package: GenomicRanges
#> Loading required package: stats4
#> Loading required package: BiocGenerics
#> Loading required package: generics
#>
#> Attaching package: 'generics'
#> The following objects are masked from 'package:base':
#>
#> as.difftime, as.factor, as.ordered, intersect, is.element, setdiff,
#> setequal, union
#>
#> Attaching package: 'BiocGenerics'
#> The following objects are masked from 'package:stats':
#>
#> IQR, mad, sd, var, xtabs
#> The following object is masked from 'package:utils':
#>
#> data
#> The following objects are masked from 'package:base':
#>
#> anyDuplicated, aperm, append, as.data.frame, basename, cbind,
#> colnames, dirname, do.call, duplicated, eval, evalq, Filter, Find,
#> get, grep, grepl, is.unsorted, lapply, Map, mapply, match, mget,
#> order, paste, pmax, pmax.int, pmin, pmin.int, Position, rank,
#> rbind, Reduce, rownames, sapply, saveRDS, scale, sequence, table,
#> tapply, transform, unique, unsplit, which.max, which.min
#> Loading required package: S4Vectors
#>
#> Attaching package: 'S4Vectors'
#> The following object is masked from 'package:utils':
#>
#> findMatches
#> The following objects are masked from 'package:base':
#>
#> expand.grid, I, unname
#> Loading required package: IRanges
#> Loading required package: Seqinfo
#> Loading required package: Biobase
#> Welcome to Bioconductor
#>
#> Vignettes contain introductory material; view with
#> 'browseVignettes()'. To cite Bioconductor, see
#> 'citation("Biobase")', and for packages 'citation("pkgname")'.
#>
#> Attaching package: 'Biobase'
#> The following object is masked from 'package:MatrixGenerics':
#>
#> rowMedians
#> The following objects are masked from 'package:matrixStats':
#>
#> anyMissing, rowMedians
library(SummarizedExperiment)
library(STexampleData)
#> Loading required package: ExperimentHub
#> Loading required package: AnnotationHub
#> Loading required package: BiocFileCache
#> Loading required package: dbplyr
#>
#> Attaching package: 'AnnotationHub'
#> The following object is masked from 'package:Biobase':
#>
#> cache
library(BiocParallel)We use the Visium_humanDLPFC() example dataset from
STexampleData. To keep the vignette lightweight, we
restrict the analysis to in-tissue spots and a small subset of
genes.
spe <- Visium_humanDLPFC()
#> see ?STexampleData and browseVignettes('STexampleData') for documentation
#> downloading 1 resources
#> retrieving 1 resource
#> loading from cache
spe <- spe[, colData(spe)$in_tissue == 1]
spe <- spe[seq_len(min(250, nrow(spe))), ]
spe
#> class: SpatialExperiment
#> dim: 250 3639
#> metadata(0):
#> assays(1): counts
#> rownames(250): ENSG00000243485 ENSG00000237613 ... ENSG00000198793
#> ENSG00000225602
#> rowData names(3): gene_id gene_name feature_type
#> colnames(3639): AAACAAGTATCTCCCA-1 AAACAATCTACTAGCA-1 ...
#> TTGTTTGTATTACACG-1 TTGTTTGTGTAAATTC-1
#> colData names(8): barcode_id sample_id ... reference cell_count
#> 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 scaleFactorfilter_features() removes features with limited
information before model fitting. Its thresholds (minimum total signal,
percent and number of locations where the feature is detected, mean, and
variance) make no assumption about the assay, so the same call works for
genes, proteins, peaks, or metabolites. For intensity data,
detection_threshold sets the value above which a feature
counts as detected. Named features such as negative-control probes can
be removed with exclude or exclude_pattern,
and, for transcriptomics only, exclude_mito and
exclude_ribo remove mitochondrial and ribosomal genes.
Two further steps are specific to TweedSpot. First, by default
(model_aware = TRUE) a feature must be detected in at least
as many locations as the spatial smooth has basis functions, so pass the
same smooth_k you will use in tweedspot().
Second, locations can be quality controlled with
min_total_per_location,
min_features_per_location, outlier_nmads,
max_excluded_fraction (for example the mitochondrial
fraction), and location_mask. Use
action = "flag" to keep everything and record the reasons
in filter_keep and filter_reason columns
instead.
spe <- TweedSpot::filter_features(
input = spe,
assay_name = "counts",
min_total = 5,
min_pct_locations = 5,
min_locations = 3,
min_mean = 0.05,
drop_empty_locations = TRUE,
report = TRUE
)
#> filter_features retained 83/250 features and 3638/3639 spatial locations.
#> Features failing each criterion: low_total=97, low_pct_locations=167, low_locations=87, low_mean=166, low_model_support=130.
#> Locations failing each criterion: empty=1.
spe
#> class: SpatialExperiment
#> dim: 83 3638
#> metadata(1): filter_features
#> assays(1): counts
#> rownames(83): ENSG00000188976 ENSG00000188290 ... ENSG00000171824
#> ENSG00000198793
#> rowData names(3): gene_id gene_name feature_type
#> colnames(3638): AAACAAGTATCTCCCA-1 AAACAATCTACTAGCA-1 ...
#> TTGTTTGTATTACACG-1 TTGTTTGTGTAAATTC-1
#> colData names(8): barcode_id sample_id ... reference cell_count
#> 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 scaleFactorWe now fit TweedSpot on the filtered dataset. For this example
object, sample_id is constant across all retained spots, so
we do not include it as a covariate. To keep the vignette fast and
robust across build machines, this runnable example uses the Poisson
mapping (tweedie_p = 1), total-count library-size offsets,
serial execution, and a small smooth_k. Estimated Tweedie
fits with tweedie_p = NULL and SCRAN size factors are shown
below as recommended analysis options for full datasets.
bp <- SerialParam()
spe_res <- tweedspot(
input = spe,
assay_name = "counts",
two_part = FALSE,
tweedie_p = 1,
fit_method = "REML",
use_bam = FALSE,
bam_discrete = TRUE,
size_factor_method = "library_size",
smooth_k = 5,
BPPARAM = bp,
verbose = TRUE
)
#> Running TweedSpot on 83 genes across 3638 spatial locations with 1 worker(s).
#> Completed TweedSpot fits for 83 feature(s).TweedSpot is built on mgcv, so its Tweedie index support
follows what can be represented as mgcv families. The main
control is tweedie_p.
When tweedie_p = NULL, TweedSpot uses
mgcv::tw() and estimates the Tweedie index during fitting.
This is the default and recommended setting for raw spatial
transcriptomics count data because genes can differ substantially in
their mean-variance relationships.
Fixed values are also supported:
tweedie_p |
Family used internally | Interpretation |
|---|---|---|
NULL |
mgcv::tw() |
Estimated Tweedie index |
0 |
stats::gaussian() |
Gaussian model for continuous approximately normal outcomes |
1 |
stats::poisson() |
Poisson count model |
1 < p < 2 |
mgcv::Tweedie(p = p) |
Compound Poisson-Gamma Tweedie model |
2 |
mgcv::Tweedie(p = 2) |
Gamma boundary case |
3 |
stats::inverse.gaussian() |
Inverse Gaussian model for strictly positive outcomes |
Values between 0 and 1, values between
2 and 3, negative values, and values greater
than 3 are not supported. The inverse Gaussian case
requires strictly positive responses, so it should not be used on raw
count assays that contain zeros.
For example, users can fit a fixed Tweedie index:
spe_res_fixed <- tweedspot(
input = spe,
assay_name = "counts",
tweedie_p = 1.5,
use_bam = TRUE,
bam_discrete = TRUE,
smooth_k = 10
)Or use the Poisson and Gaussian mappings when those assumptions are desired:
spe_res_poisson <- tweedspot(
input = spe,
assay_name = "counts",
tweedie_p = 1
)
spe_res_gaussian <- tweedspot(
input = spe,
assay_name = "counts",
tweedie_p = 0
)The family argument remains available for users who want
to pass an mgcv family directly, but tweedie_p
takes precedence when both are supplied.
For a full analysis, users may prefer the adaptive Tweedie model with SCRAN size factors:
get_tweedspot_results() returns one row per feature,
sorted by adjusted p-value (ties broken by the test statistic):
| Column | Meaning |
|---|---|
feature |
Row name of the feature |
feature_name |
Readable name from rowData(), when it differs from the
row name |
stat |
Test statistic of the spatial smooth |
pval, padj |
Raw and adjusted p-value (padj_method,
Benjamini-Yekutieli by default) |
edf |
Effective degrees of freedom of the smooth (pattern complexity) |
dev_expl |
Deviance explained by the model |
p, family |
Fitted Tweedie variance power and family |
The same values are stored in rowData(spe_res) with a
tweedspot_ prefix.
res <- get_tweedspot_results(spe_res)
head(res)
#> feature feature_name stat pval padj edf dev_expl p
#> 1 ENSG00000179403 VWA1 1001.8822 0 0 3.970820 0.25511182 1
#> 2 ENSG00000049245 VAMP3 452.2555 0 0 3.948047 0.14822962 1
#> 3 ENSG00000116251 RPL22 354.1967 0 0 3.971214 0.07255624 1
#> 4 ENSG00000187730 GABRD 259.2659 0 0 3.966324 0.07398777 1
#> 5 ENSG00000162444 RBP7 258.2351 0 0 3.899639 0.15604747 1
#> 6 ENSG00000171603 CLSTN1 183.7028 0 0 3.956985 0.04926444 1
#> family
#> 1 poisson
#> 2 poisson
#> 3 poisson
#> 4 poisson
#> 5 poisson
#> 6 poissontop_spatial_genes() provides a convenience wrapper for
retrieving the top hits by adjusted p-value, raw p-value, statistic, or
deviance explained.
top_spatial_genes(spe_res, n = 10, rank_by = "padj")
#> feature feature_name stat pval padj edf dev_expl p
#> 1 ENSG00000179403 VWA1 1001.88216 0 0 3.970820 0.25511182 1
#> 2 ENSG00000049245 VAMP3 452.25548 0 0 3.948047 0.14822962 1
#> 3 ENSG00000116251 RPL22 354.19670 0 0 3.971214 0.07255624 1
#> 4 ENSG00000187730 GABRD 259.26589 0 0 3.966324 0.07398777 1
#> 5 ENSG00000162444 RBP7 258.23509 0 0 3.899639 0.15604747 1
#> 6 ENSG00000171603 CLSTN1 183.70285 0 0 3.956985 0.04926444 1
#> 7 ENSG00000187608 ISG15 160.58221 0 0 3.701830 0.04877231 1
#> 8 ENSG00000054523 KIF1B 104.85272 0 0 3.814177 0.02464114 1
#> 9 ENSG00000097021 ACOT7 87.80020 0 0 3.932911 0.02471381 1
#> 10 ENSG00000069424 KCNAB2 87.73853 0 0 3.900609 0.02477056 1
#> family
#> 1 poisson
#> 2 poisson
#> 3 poisson
#> 4 poisson
#> 5 poisson
#> 6 poisson
#> 7 poisson
#> 8 poisson
#> 9 poisson
#> 10 poissonWith single-cell-resolution data, a feature can look spatially
variable only because cell types are arranged in space: a marker of one
cell type is high wherever that cell type sits. Setting
group to a cell-type column tests each feature
within each cell type instead, so only spatial variation among
cells of the same type is called. Size factors and coordinates are
shared across types, each type gets its own spatial smooth, and p-values
are adjusted stage-wise: a feature is first screened across all types,
then confirmed type by type.
As a small real example we use Slide-seqV2 data from the mouse hippocampus (Stickels et al., 2021), in which each bead carries a cell-type label from RCTD (Cable et al., 2022). To keep the example fast we take the three most common cell types, 1,500 beads of each, and the 25 most highly expressed genes.
spe_hpc <- STexampleData::SlideSeqV2_mouseHPC()
#> see ?STexampleData and browseVignettes('STexampleData') for documentation
#> downloading 1 resources
#> retrieving 1 resource
#> loading from cache
spe_hpc <- spe_hpc[, !is.na(spe_hpc$celltype)]
top_types <- names(sort(table(spe_hpc$celltype), decreasing = TRUE))[1:3]
set.seed(1)
keep_beads <- unlist(lapply(top_types, function(ct) {
idx <- which(spe_hpc$celltype == ct)
idx[sample.int(length(idx), min(1500L, length(idx)))]
}))
spe_hpc <- spe_hpc[, sort(keep_beads)]
# Library-size factors from all genes, before keeping only a few of them.
lib <- MatrixGenerics::colSums(counts(spe_hpc))
spe_hpc$sizeFactor <- lib / mean(lib)
spe_hpc <- filter_features(
spe_hpc,
min_total = 50,
exclude_mito = TRUE,
group = "celltype",
smooth_k = 20
)
top_genes <- order(MatrixGenerics::rowSums(counts(spe_hpc)), decreasing = TRUE)[1:25]
spe_hpc <- spe_hpc[top_genes, ]
table(spe_hpc$celltype)
#>
#> Astrocyte CA3 Oligodendrocyte
#> 1500 1500 1500We fit TweedSpot twice: once ignoring cell types, and once within each cell type.
overall <- tweedspot(spe_hpc, smooth_k = 20, seed = 1, BPPARAM = bp,
verbose = FALSE)
by_type <- tweedspot(spe_hpc, group = "celltype", smooth_k = 20, seed = 1,
BPPARAM = bp, verbose = FALSE)Ignoring cell types, many genes look spatially variable. Within cell types, fewer remain: genes called only in the first analysis vary in space mainly because the cell types they mark are arranged in space.
called_overall <- rowData(overall)$tweedspot_padj < 0.05
called_within <- rowData(by_type)$tweedspot_group_sig != ""
table(overall = called_overall, within_cell_type = called_within)
#> within_cell_type
#> overall FALSE TRUE
#> TRUE 3 22
# Genes called overall but in no cell type: candidate composition effects
rownames(spe_hpc)[called_overall & !called_within]
#> [1] "Cpe" "Hsp90ab1" "Ckb"
# One row per gene and cell type
res_ct <- get_tweedspot_results(by_type, group = TRUE)
head(res_ct[, c("feature", "group", "n_detected", "padj", "dev_expl")])
#> feature group n_detected padj dev_expl
#> 1 Tmsb4x CA3 1110 2.122697e-307 0.3834169
#> 2 Hpca CA3 1357 2.122697e-307 0.3658890
#> 3 Mbp Oligodendrocyte 1381 2.122697e-307 0.2627221
#> 4 Camk2a Oligodendrocyte 373 2.122697e-307 0.3178921
#> 5 Kif5a Oligodendrocyte 1111 2.122697e-307 0.2173208
#> 6 Ptgds Astrocyte 383 2.122697e-307 0.2169499
# Top hits within the most common cell type
top_spatial_genes(by_type, n = 5, group = top_types[1])
#> feature feature_name feature_index group n_locations n_detected tested
#> 1 Ptgds Ptgds 20 Astrocyte 1500 383 TRUE
#> 2 Hpca Hpca 4 Astrocyte 1500 569 TRUE
#> 3 Kif5a Kif5a 14 Astrocyte 1500 725 TRUE
#> 4 Malat1 Malat1 2 Astrocyte 1500 831 TRUE
#> 5 Camk2a Camk2a 18 Astrocyte 1500 1043 TRUE
#> reason stat pval edf dev_expl p family padj
#> 1 23.44492 0 15.603855 0.21694994 1.100000 tweedie 2.122697e-307
#> 2 18.02608 0 16.761350 0.20015487 1.100001 tweedie 2.122697e-307
#> 3 16.02098 0 11.832803 0.13301651 1.100000 tweedie 2.122697e-307
#> 4 12.74166 0 2.638292 0.02502882 1.379826 tweedie 2.122697e-307
#> 5 11.30423 0 17.458981 0.11000527 1.100000 tweedie 2.122697e-307
#> padj_feature
#> 1 2.122697e-307
#> 2 2.122697e-307
#> 3 2.122697e-307
#> 4 2.122697e-307
#> 5 2.122697e-307plot_feature() and plot_top_features() take
the same group argument and show the model fitted on that
cell type’s beads only.
best <- top_spatial_genes(by_type, n = 1, group = top_types[1])$feature
plot_feature(by_type, best, group = top_types[1])The same argument works with any discrete location label, such as cell states or annotated regions.
plot_feature() shows one feature in three aligned
panels: the observed normalized signal, the TweedSpot fit on the same
colour scale, and the fitted spatial effect as a log2 fold change
relative to the feature’s average. It refits the model with the settings
tweedspot() stored in
metadata(spe_res)$tweedspot, so the figure shows exactly
the model that was tested.
plot_top_features() draws the spatial effects of the
n_top most significant features on one shared scale, so the
strength of different patterns can be compared directly.
Both functions save publication-ready files when given an
output_dir, which is created if needed. The default size,
7.2 inches wide, is a two-column journal width.
colData(input) using a
one-sided formula such as ~ batch + age + sex.colData(input)$sizeFactor is present, TweedSpot uses
it as the library size adjustment. Otherwise,
size_factor_method = "scran" estimates size factors with
scran::computeSumFactors(). Users can set
size_factor_method = "library_size" for total-count
scaling.filter_features() can optionally report how many
features and locations were retained and how many were removed by each
criterion.tweedspot() can print lightweight progress messages
through verbose = TRUE.sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 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.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats4 stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] BiocParallel_1.47.0 STexampleData_1.21.1
#> [3] ExperimentHub_3.3.2 AnnotationHub_4.3.2
#> [5] BiocFileCache_3.3.0 dbplyr_2.6.0
#> [7] SpatialExperiment_1.23.0 SingleCellExperiment_1.35.2
#> [9] SummarizedExperiment_1.43.0 Biobase_2.73.2
#> [11] GenomicRanges_1.65.4 Seqinfo_1.3.2
#> [13] IRanges_2.47.5 S4Vectors_0.51.10
#> [15] BiocGenerics_0.59.12 generics_0.1.4
#> [17] MatrixGenerics_1.25.0 matrixStats_1.5.0
#> [19] TweedSpot_0.99.3 BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] DBI_1.3.0 httr2_1.3.0 rlang_1.3.0
#> [4] magrittr_2.0.5 otel_0.2.0 compiler_4.6.1
#> [7] RSQLite_3.53.3 mgcv_1.9-4 png_0.1-9
#> [10] vctrs_0.7.3 pkgconfig_2.0.3 crayon_1.5.3
#> [13] fastmap_1.2.0 magick_2.9.1 XVector_0.53.0
#> [16] labeling_0.4.3 scuttle_1.23.2 rmarkdown_2.32
#> [19] purrr_1.2.2 bit_4.6.0 xfun_0.61
#> [22] bluster_1.23.1 cachem_1.1.0 beachmat_2.29.3
#> [25] jsonlite_2.0.0 blob_1.3.0 DelayedArray_0.39.8
#> [28] irlba_2.4.1 parallel_4.6.1 cluster_2.1.8.3
#> [31] R6_2.6.1 RColorBrewer_1.1-3 bslib_0.12.0
#> [34] limma_3.99.2 jquerylib_0.1.4 Rcpp_1.1.2
#> [37] knitr_1.52 BiocBaseUtils_1.15.1 splines_4.6.1
#> [40] Matrix_1.7-6 igraph_2.3.4 tidyselect_1.2.1
#> [43] abind_1.4-8 yaml_2.3.12 codetools_0.2-20
#> [46] curl_8.0.0 lattice_0.23-1 tibble_3.3.1
#> [49] S7_0.2.2 withr_3.0.3 KEGGREST_1.53.6
#> [52] evaluate_1.0.5 Biostrings_2.81.9 pillar_1.11.1
#> [55] BiocManager_1.30.27 filelock_1.0.3 ggplot2_4.0.3
#> [58] BiocVersion_3.24.0 scales_1.4.0 glue_1.8.1
#> [61] metapod_1.21.0 maketools_1.3.2 tools_4.6.1
#> [64] BiocNeighbors_2.7.3 sys_3.4.3 ScaledMatrix_1.21.0
#> [67] locfit_1.5-9.12 buildtools_1.0.0 scran_1.41.1
#> [70] grid_4.6.1 AnnotationDbi_1.75.2 edgeR_4.99.8
#> [73] patchwork_1.3.2 nlme_3.1-171 BiocSingular_1.29.2
#> [76] cli_3.6.6 rsvd_1.0.5 rappdirs_0.3.4
#> [79] S4Arrays_1.13.2 dplyr_1.2.1 gtable_0.3.6
#> [82] sass_0.4.10 digest_0.6.39 SparseArray_1.13.4
#> [85] dqrng_0.4.1 farver_2.1.2 rjson_0.2.23
#> [88] memoise_2.0.1 htmltools_0.5.9 lifecycle_1.0.5
#> [91] httr_1.4.9 statmod_1.5.2 bit64_4.8.6