DenMark measures how gene expression depends on local cell density in single-cell resolution spatial transcriptomics data. Cell locations form a point pattern and the counts of a gene are its marks. After the cells are binned on a regular grid over the tissue, the DenMark model for grid cell with area inside the tissue, cell count and summed expression is
where
and
are independent unit-variance Gaussian processes with Matern-3/2
covariance, approximated by Hilbert space basis functions (HSGP). With
,
and
,
the parameter
(Astar[2,1] in Stan) is the density-expression
correlation.
This page runs the workflow on the bundled toy data, where the true
correlations are known, so every estimate can be checked against the
truth. The Python package denmark builds the same Stan data
as the R package and fits the same Stan models through cmdstanpy; Get started in R walks through the same steps in
R, and R and Python side by side lists the
function names of both. The code below is shown without its output, and
the package tests run it as written. To try it without installing
anything, open the same workflow in Google Colab:
Installation
DenMark fits its models with Stan through cmdstanpy, which needs a working CmdStan installation, and computes the tissue boundary with shapely. Install them once:
pip install "denmark[boundary,plot] @ git+https://github.com/StaGill/DenMark#subdirectory=python"
python -m cmdstanpy.install_cmdstandenmark needs Python 3.9 or later. The extra
boundary adds shapely (2.0 or later) for the tissue
boundary and plot adds matplotlib for the figures.
install_cmdstan downloads and builds CmdStan once per
machine and needs a C++ toolchain.
The toy data
dm.datasets.load_toy() returns a tissue section
simulated by denmark_simulate() in R: 7,449 cells in an
irregular, lobed section inside a 6 x 4 window, one row per cell, with
the cell type and the counts of three genes. The 3,449 Tumor cells are
the population analyzed; their true density-expression correlations are
0.6 for geneA, 0 for geneB and -0.6 for geneC. The 4,000 Other cells
only delineate the tissue.
Tissue boundary
A tissue section is rarely a rectangle. If the grid covered the
bounding box of the cells, grid cells outside the tissue would hold no
cells because there is no tissue there, and the model would read them as
regions of low cell density. dm.alpha_shape() delineates
the tissue as an alpha shape: it triangulates the cell locations and
keeps every Delaunay triangle whose circumradius is below
,
by default three times the median Delaunay edge length. The boundary is
computed from all cells of the section, not only the Tumor cells,
because the region where a Tumor cell could have been observed is the
whole tissue. The result holds the polygon (.polygon), the
alpha (.alpha) and the tissue area
(.area).
Gridding
dm.buildgridpp() assigns every cell to one grid cell and
returns the cell counts
and the summed expression
.
Grid cells are ordered with x varying fastest. With
boundary, the grid spans the extent of the tissue, and each
grid cell is classified as interior (entirely inside the tissue),
boundary (partly inside) or exterior, with its area inside the tissue in
the column area.
gridded = dm.buildgridpp(10, tumor, gene="geneA", boundary=tissue)
print(gridded["grid"].head())
print(gridded["grid"]["type"].value_counts())
fig = dm.plot_boundary(toy, tissue, grid=gridded, highlight=toy["celltype"] == "Tumor")Exterior grid cells are dropped from the model, and boundary grid
cells enter with their area inside the tissue, so the area offset
differs between grid cells. dm.plot_grid() shows the
gridded counts inside the tissue.
dm.prepare_data() builds the Stan input that
dm.DenMark() uses: it drops the exterior grid cells,
centers the region of interest at the origin, uses the grid centroids as
HSGP coordinates and sets the HSGP domain to boundfactor
times the half-ranges. With a boundary, log_grid_area holds
one value per grid cell in the model.
Fitting the model
dm.DenMark() grids the data, compiles the Stan model on
first use and samples; the compiled model is cached in a user directory
(set by the environment variable DENMARK_CACHE_DIR, by
default ~/.cache/denmark), so later sessions reuse it. With
boundary, it fits the variable-area model
DenMark-vararea.stan, the same model with one area offset
per grid cell; without it, the bounding box of the cells is gridded with
a common grid area. The settings below keep the page fast; for real
data, use the defaults (four chains of 1,000 warm-up and 1,000 sampling
iterations and basis=25) and check the diagnostics.
Posterior summaries
fit.summary() reports the intercepts, the length-scales
ell[1] and ell[2],
and the field standard deviations, with R-hat and effective sample
sizes.
fit.draws() returns one row per posterior draw,
including
.
The red line marks the true value.
Latent fields
fit.fields() decomposes, for every grid cell in the
model, the log cell intensity
and the log expression per cell
into the density field
,
the density-shared part of expression
and the expression-specific part
.
Spatial correlation ranges
fit.decay() gives the posterior spatial correlation of
the density and expression fields as a function of distance, and the
practical range, the distance at which the correlation falls to
0.05.
Scanning genes for density-correlated genes
dm.scan_genes() fits DenMark to each gene and calls it a
positively (PCG) or negatively (NCG) density-correlated gene when the
95% credible interval of
lies above or below 0, and not a DCG otherwise.
scan = dm.scan_genes(tumor, genes=["geneA", "geneB", "geneC"], boundary=tissue,
grid_size=10, basis=10, boundfactor=1.5, chains=2,
iter_warmup=500, iter_sampling=500, seed=2024, verbose=False)
print(scan[["gene", "rho_mean", "rho_lower", "rho_upper", "class", "max_rhat"]])
fig = dm.plot_scan(scan)For a full panel, n_jobs=4 fits four genes at a time,
with the same results as the sequential scan; every fit also runs its
chains in parallel, so plan for n_jobs times
chains cores. On a cluster, run one gene per SLURM array
task with dm.scan_genes(..., genes=[gene], save_dir=...),
then collect the table with the same call on all genes, which loads the
saved results instead of refitting; the R package ships a template of
this workflow
(system.file("slurm", package = "DenMark")).
Checking the estimates against the truth
With true values of 0.6, 0 and -0.6, the expected calls are PCG for
geneA, not DCG for geneB and NCG for geneC. The table compares each
posterior mean and 95% credible interval with the true
;
covered shows whether the interval contains it. Reading density-correlated genes explains
what a call can and cannot tell you, and how to tell a change within the
focal cells from a signal of their neighborhood.
Using your own data
DenMark needs one row per cell with the coordinates in columns
x and y and raw counts in one column per gene.
Compute the tissue boundary from all cells of the section, then analyze
the cells of one population: filter uninformative genes, fit one gene,
then scan the panel; with save_dir, each gene is saved as
it finishes and an interrupted scan resumes where it stopped.
cells = pd.read_csv("cells.csv") # all cells: x, y, celltype and gene counts
# or from an AnnData object (scanpy, or sdata.tables["table"] from spatialdata-io):
# cells = dm.from_anndata(adata, celltype_key="celltype")
tissue = dm.alpha_shape(cells["x"], cells["y"])
focal = cells[cells["celltype"] == "Tumor"].drop(columns="celltype")
genes = dm.filter_genes(focal.drop(columns=["x", "y"]))
fit = dm.DenMark(focal[["x", "y", genes[0]]], gene=genes[0], boundary=tissue,
grid_size=15, basis=25, boundfactor=1.5)
dcg = dm.scan_genes(focal[["x", "y"] + genes], boundary=tissue, grid_size=15, basis=25,
save_dir="denmark_scan")dm.annotate(adata, dcg) writes the estimates, their
intervals and the calls back into the AnnData object’s gene table
(adata.var). fit.check_hsgp() compares the
posterior length-scales with a rule of thumb for the number of basis
functions and the boundary factor, and dm.save_result()
keeps a fit for later sessions.