ELIXIR Spatial course

Domain identification and niche reconstruction

Day 3 - Practical 8

8th October 2026

Benedetta Manzato (LUMC) & Emma Vonk (LUMC)

Agenda:
  1. Graph construction and analysis of spatial transcriptomics data using Squidpy
  2. Spatial Domain detection with CellCharter
  3. Spatial Clustering with BANKSY
In [1]:
import os
import warnings

import anndata as ad
import pandas as pd
import scanpy as sc
import squidpy as sq

warnings.filterwarnings("ignore")
/app/.pixi/envs/default/lib/python3.12/site-packages/docrep/decorators.py:43: SyntaxWarning: 'n_jobs' is not a valid key!
  doc = func(self, args[0].__doc__, *args[1:], **kwargs)
/app/.pixi/envs/default/lib/python3.12/site-packages/docrep/decorators.py:43: SyntaxWarning: 'show_progress_bar' is not a valid key!
  doc = func(self, args[0].__doc__, *args[1:], **kwargs)

Dataset: Xenium In Situ Analysis of Alzheimer's Disease Mouse Model Brain Coronal Sections from One Hemisphere Over a Time Course¶

The dataset consists of 6 coronal mouse brain slices from 2 different conditions (wildtype - ctrl vs TgCRND8 - AD) across 3 timepoints. In this practical, we have information about cell types available in adata.obs['cell_types']. Please note that these annotation are not perfect. For example, there are quite some cells that could not be assigned to a cell type ("Unknown"). These annotations have been made with on leiden clustering and marker genes reported in this document. (adata_loading_and_annotation.ipynb)

In this practical we aim to understand the differences of the mouse brain between the two conditions and across the timepoints using niches and spatial domains.

Download link: https://www.10xgenomics.com/datasets/xenium-in-situ-analysis-of-alzheimers-disease-mouse-model-brain-coronal-sections-from-one-hemisphere-over-a-time-course-1-standard.

In [2]:
# load adata
base_dir = '../data/Practical6/'
adata = sc.read_h5ad(f'{base_dir}xenium_mouse_ad_annotated_rotated.h5ad')
adata
Out[2]:
AnnData object with n_obs × n_vars = 351714 × 347
    obs: 'cell_id', 'transcript_counts', 'control_probe_counts', 'control_codeword_counts', 'unassigned_codeword_counts', 'total_counts', 'cell_area', 'nucleus_area', 'region', 'cell_labels', 'condition', 'time', 'batch_key', 'leiden_res_01', 'celltype', 'leiden_res_02', 'sample'
    var: 'gene_ids', 'feature_types', 'genome'
    uns: 'celltype_colors', 'dea_leiden_res_01', 'dendrogram_leiden_res_01', 'leiden_res_01', 'leiden_res_01_colors', 'leiden_res_02', 'log1p', 'neighbors', 'pca', 'umap'
    obsm: 'X_pca', 'X_umap', 'spatial'
    varm: 'PCs'
    obsp: 'connectivities', 'distances'
    layers: None (.X)
In [3]:
# Creating a DataFrame from 'split', 'fov', and 'condition'
df = adata.obs[['condition', 'time', 'batch_key']]
value_counts = pd.DataFrame(df.values, columns=df.columns).value_counts()
value_counts
Out[3]:
condition  time  batch_key
TgCRND8    17_9  2            62268
wildtype   13_4  5            59935
           5_7   4            58685
TgCRND8    5_7   1            58682
wildtype   2_5   3            58231
TgCRND8    2_5   0            53913
Name: count, dtype: int64

1. Cell neighborhood detection via graph construction¶

Spatial transcriptomics data can be represented as graphs with cells as nodes and edges as relations. Depending on the technology (imaging-based or sequencing-based) different assumptions can be made for the graph structure. Here we will explore graph construction and cell neighborhood analysis using the squidpy sq.gr.spatial_neighbors module on imaging-based data.

Information for graph reconstruciton for sequencing-based data can be found here.

For image-based technologies we construct a graph with coord_type=generic, meaning that the nodes / cells will preserve their spatial location and will not be re-arranged in a spatial grid. For generic graph approaches we can choose to set a fixed radius radius or number of neighbors to connect to (n_neighs).

Below we try both graph construction methods and give them a different key to add to the adata object.

In [4]:
if 'cell_types_colors' in adata.uns:
    del adata.uns['cell_types_colors']
    
sq.gr.spatial_neighbors(adata, n_neighs=5, coord_type="generic", key_added = 'neighs_based_spatial')
sq.pl.spatial_scatter(
    adata,
    shape=None,
    library_key = 'batch_key',
    color=["celltype"],
    connectivity_key="neighs_based_spatial_connectivities",
    title=adata.obs['batch_key'].cat.categories,
    ncols=3,
    size = 10
)
INFO     Creating graph using `None` transform and `1` libraries.                                                  
No description has been provided for this image
In [5]:
sq.gr.spatial_neighbors(adata, radius=0.2, coord_type="generic", key_added = 'radius_based_spatial')
sq.pl.spatial_scatter(
    adata,
    shape=None,
    library_key = 'batch_key', 
    color="celltype",
    connectivity_key="radius_based_spatial_connectivities",
    title=adata.obs['batch_key'].cat.categories,
    ncols=3,
    size=10,
)
INFO     Creating graph using `None` transform and `1` libraries.                                                  
No description has been provided for this image

First differences across the graphs can be observed by visual inspection, i.e. the cells that are further away from the clear brain structures are connected in the closest neighbor approach but unconnected in the radius-based approach. Some other differences are harder to observe like dependencies in the more dense connected regions. For this Squidpy provides a number of statistics to better the differences between the connectivities of cells.

Using the cell type information we can formulate some hypothesis from the data.

To perform downstream analysis you require a graph structure or radius that connects the cells of interest. For example, if you are interested in the communication between Microglia and Oligodendrocytes you need to set a radius such that the cell typer connections exists.

Task 1: Compute the interaction matrix to understand differences between neighborhood and radius based graphs. Find an appropriate number of neighbors or radius that include sufficient connections between your cell types of interest.

What are the average number of connections per cell type? Which cell types tend to cluster together?

In [6]:
sq.gr.interaction_matrix(adata, cluster_key="celltype", connectivity_key="neighs_based_spatial")
sq.pl.interaction_matrix(adata, cluster_key="celltype", connectivity_key="neighs_based_spatial")
No description has been provided for this image
In [7]:
sq.gr.interaction_matrix(adata, cluster_key="celltype", connectivity_key="radius_based_spatial")
sq.pl.interaction_matrix(adata, cluster_key="celltype", connectivity_key="radius_based_spatial")
No description has been provided for this image

Task 2: Try out some analysis from Squidpy to identify distinctions between the graphs e.i. using the centrality score and neighborhood enrichment.

In [8]:
sq.gr.centrality_scores(adata, cluster_key = "celltype", connectivity_key = "neighs_based_spatial")
In [9]:
sq.pl.centrality_scores(adata, cluster_key = "celltype")
No description has been provided for this image
In [10]:
sq.gr.nhood_enrichment(adata, cluster_key="celltype", connectivity_key = "radius_based_spatial")
  0%|          | 0/1000 [00:00<?, ?/s]
In [11]:
sq.pl.nhood_enrichment(
    adata, cluster_key="celltype", method="average", figsize=(5, 5)
) 
No description has been provided for this image

2. Spatial Domain detection with CellCharter¶

https://www.nature.com/articles/s41588-023-01588-4

In [12]:
import logging

import cellcharter as cc
import matplotlib.pyplot as plt
import numpy as np
import scanpy as sc
import scvi
import squidpy as sq

logger = logging.getLogger('pytorch_lightning.utilities.rank_zero')
logger.setLevel(logging.ERROR)
In [13]:
scvi.settings.seed = 12345
scvi.settings.num_threads = 2
Seed set to 12345

There may be cells with very low counts. We will filter them out.

In [14]:
sc.pp.filter_cells(adata, min_counts=3)

2.1 Dimensionality reduction¶

First, we need to run the dimensionality reduction. We will use scVI for this.

Make sure that adata.X contains count data, as it is required by scVI.
In some tutorials you will see that the count data is stored in an AnnData layer called counts. Here we will use the X layer.

In [15]:
scvi.model.SCVI.setup_anndata(adata)
In [16]:
LOAD_MODEL = True

We set the parameters of the neural network. Here we use 1 layer and an embedding size of 10.
These are very common parameters, but you can play with them to see how they affect the results.

In [17]:
model = scvi.model.SCVI(
        adata,
        n_layers=1,
        n_latent=10,
        use_layer_norm="both",
        use_batch_norm="none",
    )
model.train(early_stopping=True, enable_progress_bar=True, max_epochs=10, accelerator="cpu")
GPU available: False, used: False
TPU available: False, using: 0 TPU cores
💡 Tip: For seamless cloud logging and experiment tracking, try installing [litlogger](https://pypi.org/project/litlogger/) to enable LitLogger, which logs metrics and artifacts automatically to the Lightning Experiments platform.
Training:   0%|          | 0/10 [00:00<?, ?it/s]
`Trainer.fit` stopped: `max_epochs=10` reached.

To make sure that the training has converged, we can plot the training history.
Here we is important to focus on the (validation) reconstruction loss. This shows how well the model is able to reconstruct the original data.

In [18]:
plt.figure(figsize=(5, 5))
plt.plot(
    model.history["reconstruction_loss_train"],
    label="train",
    color="darkgreen",
    linewidth=1.25
)
plt.plot(
    model.history["reconstruction_loss_validation"],
    label="validation",
    color="firebrick",
    linewidth=1.25
    )
plt.legend()
plt.title("reconstruction_loss")  
plt.tight_layout()
No description has been provided for this image

We extract the latent representation of the data.

In [19]:
adata.obsm['X_scVI'] = model.get_latent_representation(adata).astype(np.float32)

2.2 Neighborhood aggregation¶

Until now, all the analyses have been done ignoring the spatial information.
Now we will use the spatial information to perform the clustering.

First, we need to create a network where cells are connected if they are close to each other using the Delaunay triangulation.

In [20]:
sq.gr.spatial_neighbors(adata, library_key='sample', coord_type='generic')
INFO     Creating graph using `None` transform and `6` libraries.                                                  
In [21]:
sq.pl.spatial_scatter(
    adata, 
    shape=None, 
    library_key='sample',
    library_id=adata.obs['sample'].cat.categories[0],
    color="sample", 
    size=1, 
    figsize=(10,10),
    connectivity_key="spatial_connectivities",
    ncols=1
)
No description has been provided for this image

As you can see, the Delaunay triangulation generates very long edges for a few cells.
The most appropriate approach would be to estimate the most biologically relevant distance between cells and use it to remove the edges longer than that distance.

A quick alternative solution is that, since those long edges are sort of outlines, the measure the 99th percentile of the edge lengths and remove the edges longer than that distance.
This process will lead to some isolated cells, but that's not a problem.

In [22]:
sq.gr.spatial_neighbors(adata, library_key='sample', coord_type='generic', percentile=99)
INFO     Creating graph using `None` transform and `6` libraries.                                                  
In [23]:
sq.pl.spatial_scatter(
    adata, 
    shape=None, 
    library_key='sample',
    library_id=adata.obs['sample'].cat.categories[0],
    color="sample", 
    size=1, 
    figsize=(10,10),
    connectivity_key="spatial_connectivities",
    ncols=1
)
No description has been provided for this image

We construct the neighborhood aggregated representation by combining every cell's features with the ones of first 3 layers of neighbors.
We will use the scVI latent representation as features and the new features will be stored in adata.obsm['X_cellcharter'].

In [24]:
cc.gr.aggregate_neighbors(adata, n_layers=3, use_rep='X_scVI', out_key='X_cellcharter_temp', sample_key='sample')
  0%|          | 0/6 [00:00<?, ?it/s]

Task 3: Given that X_scVI contains 10 features, how many features does X_cellcharter contain?
Guess the answer and then write the code to check it.

In [25]:
adata.obsm['X_scVI'].shape
Out[25]:
(349217, 10)
In [26]:
#### check 'X_cellcharter_temp' 

2.3 Clustering¶

Finally, let's cluster the cells and find their spatial domains.
We will use 18 clusters, we will see later why this is a good choice.

In [27]:
gmm = cc.tl.Cluster(
    n_clusters=18,
    random_state=12345,
    trainer_params={'accelerator': 'cpu'},
)
gmm.fit(adata, use_rep='X_cellcharter_temp')
adata.obs['spatial_domain_temp'] = gmm.predict(adata, use_rep='X_cellcharter_temp')
Output()

GPU available: False, used: False
TPU available: False, using: 0 TPU cores
💡 Tip: For seamless cloud logging and experiment tracking, try installing [litlogger](https://pypi.org/project/litlogger/) to enable LitLogger, which logs metrics and artifacts automatically to the Lightning Experiments platform.
Output()

In [28]:
adata.write_h5ad(f"{base_dir}adata_with_spatial_domains.h5ad", compression="gzip")

We can now plot domains and cell types back to back

In [29]:
if 'spatial_domain_temp_colors' in adata.uns:
    del adata.uns['spatial_domain_temp_colors']

sq.pl.spatial_scatter(
    adata, 
    shape=None, 
    library_key='sample', 
    color=["spatial_domain_temp", "celltype"], 
    size=1,
    figsize=(10,10),
    title=np.repeat(adata.obs['sample'].cat.categories, 2),
    ncols=2
)
No description has been provided for this image

2.4 Downstream analyses¶

Now that we have the spatial domains, we can look at the cell type enrichment in each domain.

This function measures the likelihood of a cell type being found in a domain compared to random chance.

In [30]:
cc.gr.enrichment(
    adata,
    group_key='spatial_domain_temp',
    label_key='celltype',
)
cc.pl.enrichment(
    adata,
    group_key='spatial_domain_temp',
    label_key='celltype',
    dot_scale=8
)
No description has been provided for this image

Task 4: The Allen Brain Atlas provides images and annotation of mouse brain samples at multiple depths.

Choose one of the six samples, go to the atlas and try to find the image that better matches the regions shown by CellCharter.

1. Which of the 132 images is the closest one?
2. After identifying the closest image, try to match the domains obtained with the annotated regions in the atlas.
3. Do you find anatomical differences between the two conditions? are there differences in spatial domain compositions? Are there unique domains in one of the two conditions (Alzheimer vs wildtype)?
      - Focus, in particular on comparing samples from mice of the same age.
      - If you find difference between the two samples, what do you think is the cause?

Shape characterization¶

Now, we are going to look at the shapes of the domains we obtained.

We first find the local components of the domains. This is done by first finding the connected components of the spatial graph.

In [31]:
adata.obs['spatial_domain_17'] = (adata.obs["spatial_domain_temp"] == '17')
In [32]:
cc.gr.connected_components(adata, cluster_key='spatial_domain_17', min_cells=100)
In [33]:
# Hackfix: squidpy's spatial_scatter has some issues with categorical data with NaNs.
adata.obs['component_tmp'] = adata.obs['component'].astype('str')
In [34]:
if 'component_tmp_colors' in adata.uns:
    del adata.uns['component_tmp_colors']

sq.pl.spatial_scatter(
    adata[(adata.obs['sample'].isin(['TgCRND8_17_9', 'TgCRND8_2_5']))], 
    shape=None, 
    library_key='sample', 
    color=["component_tmp", "spatial_domain_17"], 
    size=1,
    figsize=(10,10),
    title=np.repeat(['TgCRND8_17_9', 'TgCRND8_2_5'], 2),
    ncols=2
)
No description has been provided for this image

Then draw a boundary around every component

In [35]:
cc.tl.boundaries(adata, alpha_start=10)
In [36]:
cc.pl.boundaries(adata, sample='wildtype_5_7', show_cells=True)
No description has been provided for this image

And finally, by computing some shape metrics that we will use to compare the domains.

In [37]:
cc.tl.curl(adata)
cc.tl.linearity(adata)

Let's look at the shape metrics of domain 8.

In [38]:
adata.uns['shape_component']
Out[38]:
{'boundary': {'17': <POLYGON ((502.23 3195.355, 532.606 3222.236, 561.618 3251.57, 562.862 3259....>,
  '19': <POLYGON ((3466.14 4251.472, 3455.104 4248.786, 3460.879 4303.491, 3478.305 ...>,
  '8': <POLYGON ((3430.323 807.174, 3422.683 835.955, 3435.932 842.949, 3458.762 85...>,
  '23': <POLYGON ((2229.26 4231.343, 2192.858 4236.171, 2194.839 4245.646, 2206.762 ...>,
  '10': <POLYGON ((505.838 1773.664, 524.64 1767.922, 526.307 1759.103, 544.141 1740...>,
  '11': <POLYGON ((83.143 2674.979, 78.369 2699.075, 84.63 2727.645, 105.675 2755.44...>,
  '12': <POLYGON ((4693.919 3253.833, 4688.515 3268.883, 4704.999 3248.299, 4713.121...>,
  '25': <POLYGON ((1092.164 3834.112, 1076.881 3863.071, 1087.982 3882.841, 1114.31 ...>,
  '35': <POLYGON ((2063.814 3094.302, 2045.962 3100.146, 2066.083 3110.009, 2070.275...>,
  '20': <POLYGON ((6098.996 1712.528, 6093.836 1724.482, 6099.979 1726.309, 6107.729...>,
  '24': <POLYGON ((469.518 2907.451, 475.483 2926.346, 482.563 2916.065, 487.127 290...>,
  '29': <POLYGON ((272.936 3794.498, 240.963 3797.418, 259.78 3824.448, 275.403 3848...>,
  '15': <POLYGON ((750.787 1774.068, 785.099 1747.368, 811.005 1713.295, 832.068 168...>,
  '37': <POLYGON ((4109.156 1911.107, 4106.474 1929.241, 4125.598 1917.55, 4181.692 ...>,
  '14': <POLYGON ((5232.644 124.592, 5221.335 132.619, 5258.858 155.958, 5332.597 15...>,
  '30': <POLYGON ((4505.979 4325.653, 4452.024 4395.553, 4464.871 4398.581, 4476.265...>,
  '7': <POLYGON ((226.486 4648.119, 197.403 4659.309, 241.866 4709.394, 240.694 474...>,
  '36': <POLYGON ((339.684 1379.321, 331.389 1388.106, 339.247 1401.421, 361.63 1401...>,
  '31': <POLYGON ((491.825 2259.512, 486.907 2273.333, 503.127 2250.127, 518.379 223...>,
  '6': <POLYGON ((6180.558 1538.664, 6151.895 1550.091, 6178.571 1602.49, 6178.026 ...>,
  '9': <POLYGON ((5036.958 427.937, 5021.258 432.846, 5042.288 451.731, 5128.211 48...>,
  '38': <POLYGON ((1063.178 4820.367, 1052.452 4833.346, 1070.981 4842.572, 1096.283...>,
  '18': <POLYGON ((1163.016 3738.034, 1175.382 3738.751, 1256.975 3765.332, 1277.251...>,
  '16': <POLYGON ((4376.162 4002.484, 4368.306 4009.313, 4385.71 4019.225, 4404.359 ...>,
  '27': <POLYGON ((70.905 3159.064, 67.262 3174.936, 69.303 3196.91, 80.91 3269.852,...>,
  '41': <POLYGON ((5215.064 3932.679, 5201.297 3965.002, 5210.266 3977.888, 5232.352...>,
  '33': <POLYGON ((5431.037 3618.909, 5414.134 3640.512, 5430.397 3698.551, 5458.685...>,
  '42': <POLYGON ((3463.672 5273.646, 3511.62 5274.707, 3532.998 5274.514, 3561.124 ...>,
  '22': <POLYGON ((4137.233 518.764, 4108.26 528.881, 4123.562 545.143, 4162.267 553...>,
  '28': <POLYGON ((5319.638 3258.254, 5311.526 3272.82, 5313.439 3283.39, 5340.104 3...>,
  '26': <POLYGON ((4367.444 717.336, 4347.85 729.573, 4386.949 731.62, 4399.966 729....>,
  '39': <POLYGON ((5086.743 1065.124, 5112.195 1066.435, 5150.06 1068.131, 5185.58 1...>,
  '13': <POLYGON ((137.591 2907.136, 138.109 2922.523, 144.506 2947.428, 152.969 296...>,
  '34': <POLYGON ((923.353 2121.129, 901.55 2114.273, 920.83 2143.324, 939.923 2162....>,
  '40': <POLYGON ((222.884 2948.269, 143.415 3031.202, 144.801 3111.348, 169.811 311...>,
  '21': <POLYGON ((3278.127 4599.533, 3284.595 4618.344, 3315.236 4627.495, 3377.386...>,
  '32': <POLYGON ((699.66 4236.039, 647.584 4194.29, 671.262 4222.798, 684.372 4237....>,
  '0': <POLYGON ((1535.821 74.781, 1491.979 57.509, 1452.065 89.494, 1403.877 73.12...>,
  '4': <POLYGON ((1665.142 541.85, 1653.444 521.403, 1595.235 509.414, 1587.661 520...>,
  '5': <POLYGON ((137.404 4100.243, 174.402 4131.605, 188.762 4148.409, 246.047 414...>,
  '3': <POLYGON ((184.399 3561.154, 223.605 3576.312, 243.23 3604.417, 273.287 3641...>,
  '2': <POLYGON ((220.974 395.635, 233.505 416.501, 241.789 437.126, 249.965 445.29...>,
  '1': <POLYGON ((456.096 2866.307, 468.483 2936.73, 465.138 3006.835, 469.554 3046...>},
 'curl': {'17': np.float64(0.07498135840280717),
  '19': np.float64(0.06296327916781042),
  '8': np.float64(0.11349961743943804),
  '23': np.float64(0.1050878443448523),
  '10': np.float64(0.5053421739225132),
  '11': np.float64(0.00789743330786774),
  '12': 0,
  '25': np.float64(0.09620840037410183),
  '35': np.float64(0.11899522209803814),
  '20': np.float64(0.03644500170305531),
  '24': np.float64(0.2059597515195627),
  '29': np.float64(0.01565763308423429),
  '15': np.float64(0.03529897140445282),
  '37': np.float64(0.14072803413653545),
  '14': np.float64(0.1552832204713659),
  '30': np.float64(0.006859203380941259),
  '7': np.float64(0.32954318290891804),
  '36': np.float64(0.12086070313188058),
  '31': np.float64(0.05659581054228813),
  '6': np.float64(0.34436643393537325),
  '9': np.float64(0.14546469650484173),
  '38': np.float64(0.010063110442518064),
  '18': np.float64(0.03424627564311711),
  '16': np.float64(0.02935842049066717),
  '27': np.float64(0.03301540783871493),
  '41': np.float64(0.02755061606192699),
  '33': np.float64(0.07508263484172373),
  '42': np.float64(0.0002846937348599532),
  '22': np.float64(0.22875853126783807),
  '28': np.float64(0.013341839492171936),
  '26': np.float64(0.08110832710937166),
  '39': np.float64(0.0941309150582531),
  '13': np.float64(0.13876414851169872),
  '34': np.float64(0.15996351546612186),
  '40': np.float64(0.32635770765836436),
  '21': np.float64(0.1040509126403919),
  '32': np.float64(0.1215936385214631),
  '0': np.float64(0.366929144407649),
  '4': np.float64(0.28198450043501533),
  '5': np.float64(0.37017236313512336),
  '3': np.float64(0.3803855893799054),
  '2': np.float64(0.38739516137785024),
  '1': np.float64(0.22540369703617869)},
 'linearity': {'17': np.float64(0.9999999999999999),
  '19': np.float64(0.9239541870550692),
  '8': np.float64(0.8658132502202597),
  '23': np.float64(0.9183483026615226),
  '10': np.float64(0.7442096109301377),
  '11': np.float64(1.0),
  '12': np.float64(1.0),
  '25': np.float64(0.9046663390621427),
  '35': np.float64(0.8472040003629832),
  '20': np.float64(1.0),
  '24': np.float64(0.9288646786734206),
  '29': np.float64(1.0),
  '15': np.float64(0.8784347680087916),
  '37': np.float64(0.8923237993302744),
  '14': np.float64(1.0),
  '30': np.float64(1.0),
  '7': np.float64(0.537362390575376),
  '36': np.float64(0.9367553675287145),
  '31': np.float64(0.880155955127413),
  '6': np.float64(0.46046509352901493),
  '9': np.float64(1.0),
  '38': np.float64(1.0),
  '18': np.float64(1.0),
  '16': np.float64(1.0),
  '27': np.float64(1.0),
  '41': np.float64(1.0),
  '33': np.float64(1.0),
  '42': np.float64(1.0),
  '22': np.float64(0.8282292861668575),
  '28': np.float64(1.0),
  '26': np.float64(1.0),
  '39': np.float64(0.939828607900539),
  '13': np.float64(1.0),
  '34': np.float64(0.8144885501563177),
  '40': np.float64(0.7313356003287076),
  '21': np.float64(1.0),
  '32': np.float64(1.0),
  '0': np.float64(0.5416267198566047),
  '4': np.float64(0.0),
  '5': np.float64(0.4011921474789568),
  '3': np.float64(0.4216853976402761),
  '2': np.float64(0.6183447904096121),
  '1': np.float64(0.476396722040885)}}

Plot curl and linearity for spatial domain 15 in the 2 conditions:

In [39]:
import matplotlib.pyplot as plt

conds = list(adata.obs['condition'].unique())
clusters = [9, 10, 15]
colors = ['#1f77b4', '#ff7f0e', '#2ca02c']  # blue, orange, green
x = range(len(conds))

plt.figure(figsize=(8,3), dpi=300)

# --- Curl ---
plt.subplot(1,2,1)
for c, col in zip(clusters, colors):
    val = float(adata.uns['shape_component']['curl'][str(c)])
    plt.scatter(x, [val]*len(conds), s=80, color=col, label=f'Cluster {c}')
plt.xticks(x, conds, rotation=45)
plt.title("Curl — clusters 9, 10, 15")
plt.ylabel("curl")
plt.legend(frameon=False, fontsize=8)

# --- Linearity ---
plt.subplot(1,2,2)
for c, col in zip(clusters, colors):
    val = float(adata.uns['shape_component']['linearity'][str(c)])
    plt.scatter(x, [val]*len(conds), s=80, color=col, label=f'Cluster {c}')
plt.xticks(x, conds, rotation=45)
plt.title("Linearity — clusters 9, 10, 15")
plt.ylabel("linearity")
plt.legend(frameon=False, fontsize=8)

plt.tight_layout()
plt.show()
No description has been provided for this image

Task 6: Look at the spatial domain 9.
      - Is it going to have lower, same or higher linearity than domain 9?
      - What about curl?

After you have answered, plot the shape metrics for domain 9 and check if it matches your expectations.

Task 7: Look at spatial domain 10.
      - What do you expect to see in terms of curl and linearity?
      - What about elongation?

After answering, compute the elongation and compare the shape metrics for domains 10.

You can check CellCharter's documentation for the elongation metric.

In [40]:
conds = list(adata.obs['condition'].unique())
curl_val = float(adata.uns['shape_component']['curl'][str(15)])
lin_val  = float(adata.uns['shape_component']['linearity'][str(15)])
x = range(len(conds))

plt.figure(figsize=(8,3), dpi=300)

plt.subplot(1,2,1)
plt.scatter(x, [curl_val]*len(conds), s=80)
plt.xticks(x, conds, rotation=45)
plt.title("Curl — cluster 15")
plt.ylabel("curl")

plt.subplot(1,2,2)
plt.scatter(x, [lin_val]*len(conds), s=80)
plt.xticks(x, conds, rotation=45)
plt.title("Linearity — cluster 15")
plt.ylabel("linearity")

plt.tight_layout()
plt.show()
No description has been provided for this image
In [41]:
conds = list(adata.obs['condition'].unique())
clusters = [9, 10, 15]
colors = ['#1f77b4', '#ff7f0e', '#2ca02c']  # blue, orange, green
x = range(len(conds))

plt.figure(figsize=(8,3), dpi=300)

# --- Curl ---
plt.subplot(1,2,1)
for c, col in zip(clusters, colors):
    val = float(adata.uns['shape_component']['curl'][str(c)])
    plt.scatter(x, [val]*len(conds), s=80, color=col, label=f'Cluster {c}')
plt.xticks(x, conds, rotation=45)
plt.title("Curl — clusters 9, 10, 15")
plt.ylabel("curl")
plt.legend(frameon=False, fontsize=8)

# --- Linearity ---
plt.subplot(1,2,2)
for c, col in zip(clusters, colors):
    val = float(adata.uns['shape_component']['linearity'][str(c)])
    plt.scatter(x, [val]*len(conds), s=80, color=col, label=f'Cluster {c}')
plt.xticks(x, conds, rotation=45)
plt.title("Linearity — clusters 9, 10, 15")
plt.ylabel("linearity")
plt.legend(frameon=False, fontsize=8)

plt.tight_layout()
plt.show()
No description has been provided for this image
In [42]:
import gc

gc.collect()
Out[42]:
130982

3. BANKSY¶

In this notebook we are using the Python implementation of BANSKY.

Let's reload the data to restore the original version.

In [43]:
# load adata again
adata = ad.read_h5ad(f'{base_dir}xenium_mouse_ad_annotated_rotated.h5ad')
adata
Out[43]:
AnnData object with n_obs × n_vars = 351714 × 347
    obs: 'cell_id', 'transcript_counts', 'control_probe_counts', 'control_codeword_counts', 'unassigned_codeword_counts', 'total_counts', 'cell_area', 'nucleus_area', 'region', 'cell_labels', 'condition', 'time', 'batch_key', 'leiden_res_01', 'celltype', 'leiden_res_02', 'sample'
    var: 'gene_ids', 'feature_types', 'genome'
    uns: 'celltype_colors', 'dea_leiden_res_01', 'dendrogram_leiden_res_01', 'leiden_res_01', 'leiden_res_01_colors', 'leiden_res_02', 'log1p', 'neighbors', 'pca', 'umap'
    obsm: 'X_pca', 'X_umap', 'spatial'
    varm: 'PCs'
    obsp: 'connectivities', 'distances'
    layers: None (.X)

We run BANKSY on a example section, select a section that you find interesting.

In [44]:
adata_section = adata[(adata.obs['time'] == '5_7') & (adata.obs['condition'] == 'wildtype')]
adata_section
Out[44]:
View of AnnData object with n_obs × n_vars = 58685 × 347
    obs: 'cell_id', 'transcript_counts', 'control_probe_counts', 'control_codeword_counts', 'unassigned_codeword_counts', 'total_counts', 'cell_area', 'nucleus_area', 'region', 'cell_labels', 'condition', 'time', 'batch_key', 'leiden_res_01', 'celltype', 'leiden_res_02', 'sample'
    var: 'gene_ids', 'feature_types', 'genome'
    uns: 'celltype_colors', 'dea_leiden_res_01', 'dendrogram_leiden_res_01', 'leiden_res_01', 'leiden_res_01_colors', 'leiden_res_02', 'log1p', 'neighbors', 'pca', 'umap'
    obsm: 'X_pca', 'X_umap', 'spatial'
    varm: 'PCs'
    obsp: 'connectivities', 'distances'
    layers: None (.X)
In [45]:
del adata
gc.collect()
Out[45]:
12
In [46]:
## add x and y coordinate to .obs (needed for plot later)
sp = adata_section.obsm.get('spatial')
sp_df = pd.DataFrame(sp, index=adata_section.obs_names, columns=['x','y'])
adata_section.obs[['x','y']] = sp_df.loc[adata_section.obs_names, ['x','y']]
In [47]:
from banksy_utils.filter_utils import normalize_total

# Normalizes the AnnData object
adata_section = normalize_total(adata_section)
--- Max-Min before normalization -----
Displaying max and min of Dataset
Max: 4.919981002807617, Min: 0.0

--- Max-Min after normalization -----
Displaying max and min of Dataset
Max: 86.43296813964844, Min: 0.0

BANSKY unifies spatially informed cell type and domain segmentation. In this notebook we will focus on the domain segmentation part.

The idea is to create a representation of a cell using 1) its own transcriptomic profile (purple) and 2) local microenvironment (red + light pink). The local microenvironment is represented through a pair of spatial kernels that represent the mean gene expression of the local microenvironment (red) and its gradient calculated with the azimuthal Gabor filter (AGF) (light pink).

The relative contribution of the microenvironment is captured by $\lambda$. Smaller settings of $\lambda$ decrease the influence of the cells within the microenvironment ($\lambda = 0$ reduces to nonspatial informed clustering). $G(r)$ is a radially symmetric Gaussian kernel that decays from magnitude 1 at distance = 0.

The main BANSKY algorithm requires:

1. Creating a kNN graph by setting a spatial number of neighbords num_neighbors (k_geom) parameter. 2. Assigning weights to the edges of the conected spatial graph. By default, we use the gaussian decay option, where weights decay as a function of distance to the index cell with $\sigma$ = sigma. 3. Defining whether to use the Azumithal Gabor Filter kernel (max_m = 1) or just the mean expression (max_m = 0).

First, we set the required parameters.

In [48]:
coord_keys = ('x', 'y', 'spatial')

# set parameters 
plot_graph_weights = True
k_geom = 15 # number of neighbors
max_m = 1 # azumithal transform up to kth order
nbr_weight_decay = "scaled_gaussian" # can also be "reciprocal", "uniform" or "ranked"

Construct the $k_{geom}$-NN graph¶

In [49]:
from banksy.main import median_dist_to_nearest_neighbour

# Find median distance to closest neighbours, the median distance will be `sigma`
nbrs = median_dist_to_nearest_neighbour(adata_section, key = coord_keys[2])
Median distance to closest cell = 13.144794274179642

---- Ran median_dist_to_nearest_neighbour in 0.30 s ----

Generate spatial weights from distance¶

Here, we generate the spatial weights using the gaussian decay function from the median distance to the k-th nearest neighbours as specified earlier.

In [50]:
from banksy.initialize_banksy import initialize_banksy

plt.style.use('default')

banksy_dict = initialize_banksy(
    adata_section,
    coord_keys,
    k_geom,
    nbr_weight_decay=nbr_weight_decay,
    max_m=max_m,
    plt_edge_hist=True,
    plt_nbr_weights=True,
    plt_agf_angles=False, # takes long time to plot
    plt_theta=True,
)
Median distance to closest cell = 13.144794274179642

---- Ran median_dist_to_nearest_neighbour in 0.28 s ----

---- Ran generate_spatial_distance_graph in 0.67 s ----

---- Ran row_normalize in 0.20 s ----

---- Ran generate_spatial_weights_fixed_nbrs in 2.14 s ----

----- Plotting Edge Histograms for m = 0 -----

Edge weights (distances between cells): median = 27.256493599488618, mode = 15.46042177259126

---- Ran plot_edge_histogram in 0.06 s ----


Edge weights (weights between cells): median = 0.05928046831980083, mode = 0.03207643565586688

---- Ran plot_edge_histogram in 0.06 s ----

---- Ran generate_spatial_distance_graph in 0.99 s ----

---- Ran theta_from_spatial_graph in 0.53 s ----

---- Ran row_normalize in 0.22 s ----

---- Ran generate_spatial_weights_fixed_nbrs in 3.16 s ----

----- Plotting Edge Histograms for m = 1 -----

Edge weights (distances between cells): median = 38.16598191834516, mode = 28.82771223013292

---- Ran plot_edge_histogram in 0.09 s ----


Edge weights (weights between cells): median = (-1.2026835980581124e-05-0.027548862844741256j), mode = -0.012913999031873485

---- Ran plot_edge_histogram in 0.20 s ----

----- Plotting Weights Graph -----
Maximum weight: 0.18868491395566991

---- Ran plot_graph_weights in 9.52 s ----

Maximum weight: (0.0906567886468092-0.01590781070825838j)

---- Ran plot_graph_weights in 18.50 s ----

----- Plotting theta Graph -----
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image

Generate BANKSY matrix¶

The BANKSY matrix considers a cells transcriptomic profile and local microenvironment (Figure 1).

As mentioned before, $\lambda$ is a mixing parameter that controls the importance of cells’ own expression and neighborhood expression effects, it takes values from 0, being spatial information not used in the clustering, to 1, giving the maximum importance to the neighborhood expression.

To generate the BANKSY matrix, we proceed with the following:

1. Matrix multiply sparse CSR weights matrix with cell-gene matrix to get neighbourhood matrix and the AGF matrix if max_m > 1

2. Z-score both matrices along genes

3. Multiply each matrix by a weighting factor $\lambda$ (We refer to this parameter as lambda in our manuscript and code)

4. Concatenate the matrices along the genes dimension in the form -> horizontal_concat(cell_mat, nbr_mat, agf_mat)

Here, we save all the results in the dictionary (banksy_dict), which contains the results from the subsequent operations for BANKSY.

In [51]:
from banksy.embed_banksy import generate_banksy_matrix

# The following are the main hyperparameters for BANKSY
lambda_list = [0.6] # list of lambda parameters, can be multiple! we will run one for time efficiency

banksy_dict, banksy_matrix = generate_banksy_matrix(adata_section, banksy_dict, lambda_list, max_m)
Runtime Oct-08-2026-09-09

347 genes to be analysed:
Gene List:
Index(['2010300C02Rik', 'Abca7', 'Acsbg1', 'Acta2', 'Acvrl1', 'Adamts2',
       'Adamtsl1', 'Adgrl4', 'Aldh1a2', 'Aldh1l1',
       ...
       'Unc13c', 'Vat1l', 'Vcan', 'Vim', 'Vip', 'Vwc2l', 'Wfs1', 'Zfp366',
       'Zfp536', 'Zfpm2'],
      dtype='object', length=347)

Check if X contains only finite (non-NAN) values
Decay Type: scaled_gaussian
Weights Object: {'weights': {0: <Compressed Sparse Row sparse matrix of dtype 'float64'
	with 880275 stored elements and shape (58685, 58685)>, 1: <Compressed Sparse Row sparse matrix of dtype 'complex128'
	with 1760550 stored elements and shape (58685, 58685)>}}

Nbr matrix | Mean: 0.25 | Std: 0.46
Size of Nbr | Shape: (58685, 347)
Top 3 entries of Nbr Mat:

[[0.21820324 0.         0.27779182]
 [0.20254754 0.         0.26172914]
 [0.68549604 0.2790575  0.01891243]]

AGF matrix | Mean: 0.08 | Std: 0.13
Size of AGF mat (m = 1) | Shape: (58685, 347)
Top entries of AGF:
[[0.14492977 0.         0.18889316]
 [0.1331882  0.         0.14689423]
 [0.14539107 0.11706576 0.02299277]]
Ran 'Create BANKSY Matrix' in 0.06 mins

Cell by gene matrix has shape (58685, 347)

Scale factors squared: [0.4 0.4 0.2]
Scale factors: [0.63245553 0.63245553 0.4472136 ]
Shape of BANKSY matrix: (58685, 1041)
Type of banksy_matrix: <class 'anndata.AnnData'>

In [52]:
from banksy.main import concatenate_all

banksy_dict["nonspatial"] = {
    # Here we simply append the nonspatial matrix (adata.X) to obtain the nonspatial clustering results
    0.0: {"adata": concatenate_all([adata_section.X], 0, adata=adata_section), }
}

print(banksy_dict['nonspatial'][0.0]['adata'])
Scale factors squared: [1.]
Scale factors: [1.]
AnnData object with n_obs × n_vars = 58685 × 347
    obs: 'cell_id', 'transcript_counts', 'control_probe_counts', 'control_codeword_counts', 'unassigned_codeword_counts', 'total_counts', 'cell_area', 'nucleus_area', 'region', 'cell_labels', 'condition', 'time', 'batch_key', 'leiden_res_01', 'celltype', 'leiden_res_02', 'sample', 'x', 'y'
    var: 'gene_ids', 'feature_types', 'genome', 'is_nbr', 'k'
    layers: None (.X)

Reduce dimensions of each data matrix¶

We utilize two common methods for dimensionality reduction:

1. PCA (using scikit-learn), we reduce the size of thematrix from $3 * N_{genes}$ to pca_dims. As a default settings, we reduce to 20 dimensions.

1. UMAP (UMAP package), which we use to visualize expressions of clusters in the umap space (2-D space).

In [53]:
## Define hyperparameters

resolutions = [0.1] # clustering resolution for UMAP
pca_dims = [20] # Dimensionality in which PCA reduces to 20
In [54]:
from banksy_utils.umap_pca import pca_umap

pca_umap(banksy_dict,
         pca_dims = pca_dims,
         add_umap = True,
         plt_remaining_var = False,
         )
Current decay types: ['scaled_gaussian', 'nonspatial']

Reducing dims of dataset in (Index = scaled_gaussian, lambda = 0.6)
==================================================

Setting the total number of PC = 20
Original shape of matrix: (58685, 1041)
Reduced shape of matrix: (58685, 20)
------------------------------------------------------------
min_value = -20.525670727636356, mean = 1.410309582710002e-16, max = 15.38370365307403

Conducting UMAP and adding embeddings to adata.obsm["reduced_pc_20_umap"]
UMAP embedding
------------------------------------------------------------
shape: (58685, 2)


AxisArrays with keys: 'reduced_pc_20', 'reduced_pc_20_umap'

Reducing dims of dataset in (Index = nonspatial, lambda = 0.0)
==================================================

Setting the total number of PC = 20
Original shape of matrix: (58685, 347)
Reduced shape of matrix: (58685, 20)
------------------------------------------------------------
min_value = -22.522197868189096, mean = 6.199163000923086e-17, max = 38.64788819206802

Conducting UMAP and adding embeddings to adata.obsm["reduced_pc_20_umap"]
UMAP embedding
------------------------------------------------------------
shape: (58685, 2)


AxisArrays with keys: 'reduced_pc_20', 'reduced_pc_20_umap'

Cluster cells using a partition algorithm¶

We then cluster cells using the leiden algorithm partition methods. Other clustering algorithms include louvain (another resolution based clustering algorithm), or mclust (a clustering based on gaussian mixture model).

In [55]:
from banksy.cluster_methods import run_Leiden_partition

seed = 0
results_df, max_num_labels = run_Leiden_partition(
    banksy_dict,
    resolutions,
    num_nn = 50,
    num_iterations = -1,
    partition_seed = seed,
    match_labels = True,
)
Decay type: scaled_gaussian
Neighbourhood Contribution (Lambda Parameter): 0.6
reduced_pc_20 

reduced_pc_20_umap 

PCA dims to analyse: [20]

====================================================================================================
Setting up partitioner for (nbr decay = scaled_gaussian), Neighbourhood contribution = 0.6, PCA dimensions = 20)
====================================================================================================


Nearest-neighbour weighted graph (dtype: float64, shape: (58685, 58685)) has 2934250 nonzero entries.
---- Ran find_nn in 50.31 s ----


Nearest-neighbour connectivity graph (dtype: int16, shape: (58685, 58685)) has 2934250 nonzero entries.

(after computing shared NN)
Allowing nearest neighbours only reduced the number of shared NN from 39836593 to 2931946.


Shared nearest-neighbour (connections only) graph (dtype: int16, shape: (58685, 58685)) has 2847536 nonzero entries.

Shared nearest-neighbour (number of shared neighbours as weights) graph (dtype: int16, shape: (58685, 58685)) has 2847536 nonzero entries.

sNN graph data:
[17 30 31 ... 16 12 15]

---- Ran shared_nn in 2.76 s ----


-- Multiplying sNN connectivity by weights --


shared NN with distance-based weights graph (dtype: float64, shape: (58685, 58685)) has 2847536 nonzero entries.
shared NN weighted graph data: [0.15554204 0.15587353 0.15599324 ... 0.19144197 0.20087616 0.20183265]

Converting graph (dtype: float64, shape: (58685, 58685)) has 2847536 nonzero entries.
---- Ran csr_to_igraph in 1.00 s ----


Resolution: 0.1
------------------------------

---- Partitioned BANKSY graph ----
modularity: 0.81
8 unique labels:
[0 1 2 3 4 5 6 7]

---- Ran partition in 58.67 s ----

No annotated labels
Decay type: nonspatial
Neighbourhood Contribution (Lambda Parameter): 0.0
reduced_pc_20 

reduced_pc_20_umap 

PCA dims to analyse: [20]

====================================================================================================
Setting up partitioner for (nbr decay = nonspatial), Neighbourhood contribution = 0.0, PCA dimensions = 20)
====================================================================================================


Nearest-neighbour weighted graph (dtype: float64, shape: (58685, 58685)) has 2934250 nonzero entries.
---- Ran find_nn in 73.22 s ----


Nearest-neighbour connectivity graph (dtype: int16, shape: (58685, 58685)) has 2934250 nonzero entries.

(after computing shared NN)
Allowing nearest neighbours only reduced the number of shared NN from 57780317 to 2930150.


Shared nearest-neighbour (connections only) graph (dtype: int16, shape: (58685, 58685)) has 2795979 nonzero entries.

Shared nearest-neighbour (number of shared neighbours as weights) graph (dtype: int16, shape: (58685, 58685)) has 2795979 nonzero entries.

sNN graph data:
[21 28 25 ... 12 19 11]

---- Ran shared_nn in 3.89 s ----


-- Multiplying sNN connectivity by weights --


shared NN with distance-based weights graph (dtype: float64, shape: (58685, 58685)) has 2795979 nonzero entries.
shared NN weighted graph data: [0.16391476 0.16406578 0.1640777  ... 0.22126442 0.22639142 0.2379247 ]

Converting graph (dtype: float64, shape: (58685, 58685)) has 2795979 nonzero entries.
---- Ran csr_to_igraph in 1.07 s ----


Resolution: 0.1
------------------------------

---- Partitioned BANKSY graph ----
modularity: 0.83
11 unique labels:
[ 0  1  2  3  4  5  6  7  8  9 10]

---- Ran partition in 32.80 s ----

No annotated labels

After sorting Dataframe
Shape of dataframe: (2, 7)

Maximum number of labels = 11
Indices of sorted list: [1 0]

Expanding labels with ids: [0 1 2 3 4 5 6 7] so that ids range from 0 to 10

Label ids zerod: [0 1 2 3 4 5 6 7].
0 to be inserted between each id: [0 0 0 0 0 0 0 0]
3 extra rows to be randomly inserted: [0 0 0 0 1 1 1]
New ids: [ 0  1  2  3  4  6  8 10]
---- Ran expand_labels in 0.00 s ----

---- Ran match_labels in 0.00 s ----


Matched Labels
decay lambda_param num_pcs resolution num_labels labels adata relabeled
nonspatial_pc20_nc0.00_r0.10 nonspatial 0.0 20 0.1 11 Label object:\nNumber of labels: 11, number of... (((View of AnnData object with n_obs × n_vars ... Label object:\nNumber of labels: 11, number of...
scaled_gaussian_pc20_nc0.60_r0.10 scaled_gaussian 0.6 20 0.1 8 Label object:\nNumber of labels: 8, number of ... (((View of AnnData object with n_obs × n_vars ... Label object:\nNumber of labels: 8, number of ...

Plot results¶

In [56]:
from banksy.plot_banksy import plot_results

c_map =  'tab20' # specify color map
weights_graph =  banksy_dict['scaled_gaussian']['weights'][0]
In [57]:
import matplotlib as mpl
from matplotlib import cm

banksy_path = 'output/'

# Patch get_cmap back onto cm for libraries using the old API
if not hasattr(cm, 'get_cmap'):
    cm.get_cmap = mpl.colormaps.get_cmap # NOTE: code shown here has been added to prevent errors with the newer matplotlib versions

plot_results(
    results_df,
    weights_graph,
    c_map,
    match_labels = True,
    coord_keys = coord_keys,
    max_num_labels  =  max_num_labels, 
    save_path = os.path.join(banksy_path, 'tmp_png'),
    save_fig = True, # save the spatial map of all clusters
    save_seperate_fig = True, # save the figure of all clusters plotted seperately
)
Saving figure at output/tmp_png/slideseq_mousecerebellum_nonspatial_pc20_nc0.00_r0.10_spatialmap.png
number of labels: 11
---- Ran plot_2d_embeddings in 0.00 s ----

number of labels: 11
---- Ran plot_2d_embeddings in 0.01 s ----

number of labels: 11
---- Ran plot_2d_embeddings in 0.01 s ----

---- Ran row_normalize in 0.00 s ----


matrix multiplying labels x weights x labels-transpose ((11, 58685) x (58685, 58685) x (58685, 11))

Saving figure at output/tmp_png/slideseq_mousecerebellum_scaled_gaussian_pc20_nc0.60_r0.10_spatialmap.png
number of labels: 8
---- Ran plot_2d_embeddings in 0.00 s ----

number of labels: 8
---- Ran plot_2d_embeddings in 0.01 s ----

number of labels: 8
---- Ran plot_2d_embeddings in 0.00 s ----

---- Ran row_normalize in 0.00 s ----


matrix multiplying labels x weights x labels-transpose ((8, 58685) x (58685, 58685) x (58685, 8))

No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image

If UMAP-PCA computation takes too long read the results df:

In [58]:
adata_banksy = ad.read_h5ad(f'{base_dir}adata_banksy_results.h5ad')
adata_banksy
Out[58]:
AnnData object with n_obs × n_vars = 58685 × 1041
    obs: 'cell_id', 'transcript_counts', 'control_probe_counts', 'control_codeword_counts', 'unassigned_codeword_counts', 'total_counts', 'cell_area', 'nucleus_area', 'region', 'cell_labels', 'condition', 'time', 'batch_key', 'leiden_res_01', 'celltype', 'leiden_res_02', 'sample', 'x', 'y', 'labels_scaled_gaussian_pc20_nc0.60_r0.20', 'Count'
    var: 'gene_ids', 'feature_types', 'genome', 'is_nbr', 'k'
    obsm: 'reduced_pc_20', 'reduced_pc_20_umap', 'spatial'
    layers: None (.X)

Plot the resulting spatial domains:

In [59]:
# Extract x/y coordinates and domain labels from .obs
x = adata_banksy.obs['x']
y = adata_banksy.obs['y']
domains = adata_banksy.obs['labels_scaled_gaussian_pc20_nc0.60_r0.20']

# Plot
plt.figure(figsize=(6,6), dpi=200)
plt.scatter(
    x, y,
    c=domains.astype('category').cat.codes,
    cmap='tab20',
    s=0.5,
    alpha=0.8
)
plt.axis('equal')
plt.xticks([])
plt.yticks([])
plt.title("Spatial Domains (BANKSY)", fontsize=10)
plt.show()
No description has been provided for this image

Investigate Cell Type composition in each Banksy-defined Spatial Domain¶

In [60]:
def plot_sd_vs_cell_type_composition(res_df,idx):
    """
    Plots the cell type composition as a percentage across different SD (standard deviation) values.
    The data is visualized as a stacked bar plot.

    Parameters:
    - results_df_lambda05: DataFrame containing the data with columns 'labels_scaled_gaussian_pc20_nc0.50_r0.10', 'class', and others.
    - idx: string of column of interest in the DataFrame
    """
    # Step 1: Add a 'Count' column to facilitate pivoting (each row contributes a count of 1)
    res_df.obs['Count'] = 1

    # Step 2: Create a pivot table with SD as the index, cell types as columns, and the sum of counts as values
    pivot_df = res_df.obs.pivot_table(
        index=idx,  # Group by SD
        columns='celltype', 
        values='Count', 
        aggfunc='sum',  
        fill_value=0  
    )

    # Ensure SD values are numeric
    pivot_df.index = pivot_df.index.astype(float)

    # Step 3: Convert counts to percentages for each SD
    pivot_df = pivot_df.div(pivot_df.sum(axis=1), axis=0) * 100

    # Step 4: Set up the plot
    _, ax = plt.subplots(figsize=(10, 6)) 

    # Plot stacked bars
    bottom = None  
    for cell_type in pivot_df.columns: 
        ax.bar(
            pivot_df.index, 
            pivot_df[cell_type],  
            label=cell_type,  
            bottom=bottom  
        )
        bottom = pivot_df[cell_type] if bottom is None else bottom + pivot_df[cell_type]

    # Step 5: Add labels and title
    ax.set_xlabel('SD')  
    ax.set_ylabel('Cell Type Composition (%)')  
    ax.set_title('SD vs. Cell Type Composition')  
    ax.set_ylim(0, 100)  

    # Add legend
    plt.legend(
        title="Cell Type",  
        bbox_to_anchor=(1.05, 1),  
        loc='upper left' 
    )

    plt.tight_layout()
    plt.show()
In [61]:
idx='scaled_gaussian_pc20_nc0.60_r0.20'
#results_df.loc[idx]['adata']
In [62]:
#results_df_lambda05 = results_df.loc[idx]['adata']
label_idx = f'labels_{idx}'
plot_sd_vs_cell_type_composition(adata_banksy, label_idx)
No description has been provided for this image

Task 8: What do you expect would happen when we increase k_geom?

After answering, Re run Banksy changing k_geom to 80.

Task 9: What do you expect would happen when we set lambda = 0.99?

How do you expect the spatial clusters plot to look like?

Task 10: How do the Cell Type compositions of each Spatial Domains change as lambda is increasing? Set lambda=0

Describing spatial communities with spatial statistics¶

Spatial statistics gelfand2010handbook (https://www.taylorfrancis.com/books/mono/10.1201/9781420072884/handbook-spatial-statistics-peter-guttorp-montserrat-fuentes-alan-gelfand-peter-diggle) are quantitative scores that can be used to identify spatial patterns in the tissue. Here, we'll take a look at one spatial statistics implemented in Squidpy with a different set of spatial omics data.

In [63]:
sd_col = 'labels_scaled_gaussian_pc20_nc0.60_r0.20'
sq.gr.co_occurrence(adata_banksy, cluster_key=sd_col)
sq.pl.co_occurrence(adata_banksy, cluster_key=sd_col, clusters="0", figsize=(8, 5)) 
ERROR: Unable to fetch palette, reason: 'labels_scaled_gaussian_pc20_nc0.60_r0.20_colors'. Using `None`.
No description has been provided for this image
In [64]:
sq.gr.co_occurrence(adata_banksy, cluster_key='celltype') 
sq.pl.co_occurrence(adata_banksy, cluster_key='celltype', clusters="Astrocytes", figsize=(8, 5))
ERROR: Unable to fetch palette, reason: 'celltype_colors'. Using `None`.
No description has been provided for this image

Read more: https://squidpy.readthedocs.io/en/stable/

References¶

[1] Varrone, M., Tavernari, D., Santamaria-Martínez, A., Walsh, L. A. & Ciriello, G. CellCharter reveals spatial cell niches associated with tissue remodeling and cell plasticity. Nat Genet 56, 74–84 (2024).

[2] Singhal, V. et al. BANKSY unifies cell typing and tissue domain segmentation for scalable spatial omics data analysis. Nat Genet 56, 431–441 (2024).

[3] https://github.com/BrainOmicsCourse/BrainOmics2024/tree/main/3_Day3

[4] https://github.com/NBISweden/workshop-spatial/blob/main/labs/07b_spatial_domains.ipynb

[5] https://github.com/elixir-europe-training/ELIXIR-SCO-spatial-omics/blob/day3-practical-4-cellcharter-init/day_3/practical_4/niche_and_domain_reconstruction.ipynb

In [ ]: