Technology Focus: 10x Genomics Xenium (Simplified)#

This tutorial highlights the basic functions of SegTraQ on a single Xenium dataset that was segmented using the Xenium default segmentation. To follow along, you can download the data already in SpatialData format from here.

For a more detailed description of how the data was obtained and a comparison between segmentation methods, please look at the Xenium Focus.

Read SpatialData object#

[1]:
%load_ext autoreload
%autoreload 2
[2]:
import warnings

import anndata as ad
import dask
import matplotlib.pyplot as plt
import pandas as pd
import scanpy as sc
import seaborn as sns
import spatialdata as sd
import spatialdata_plot  # noqa

import segtraq

segtraq.settings.n_jobs = 8  # Use 8 CPU cores (-1 for all available cores)

# filtering import and deprecation warnings from spatialdata
# this is in general not recommended
# we only do it here because we have verified that the warnings are irrelevant in this notebook
warnings.simplefilter("ignore", FutureWarning)
warnings.simplefilter("ignore", UserWarning)
warnings.filterwarnings(
    "ignore",
    category=FutureWarning,
    module=r"dask\.dataframe",
)
warnings.filterwarnings(
    "ignore",
    category=FutureWarning,
    module=r"spatialdata\._core\.query\.relational_query",
)

dask.config.set({"dataframe.query-planning": True})
[2]:
<dask.config.set at 0x7fff33030b90>
[3]:
sdata = sd.read_zarr("../../data/xenium_5K_data/xenium.zarr")
no parent found for <ome_zarr.reader.Label object at 0x7fff32bd4830>: None
no parent found for <ome_zarr.reader.Label object at 0x7fff33033390>: None

Let’s have a quick look at the data.

[4]:
sdata.pl.render_shapes("cell_boundaries").pl.show()
../_images/notebooks_10x_xenium_focus_simplified_6_0.png

Initialize SegTraQ objects#

Next, we initialize a SegTraQ object. The reason for this is that different technologies call things differently: cell centroids could be called centroid_x, x_centroid, cell_x, … By initializing a SegTraQ object, we only have to tell SegTraQ where our data lives once.

Don’t worry if you do not know which parameters you need up front; just put in your spatialdata object (segtraq.SegTraQ(sdata)), and SegTraQ will tell you which arguments are wrong/missing.

[5]:
st = segtraq.SegTraQ(
    sdata,
    images_key="image",  # where the image is stored
    tables_centroid_x_key="x_centroid",  # where the x centroid is stored
    tables_centroid_y_key="y_centroid",  # where the y centroid is stored
)
/g/huber/users/lazic/src/SegTraQ/src/segtraq/SegTraQ.py:188: RuntimeWarning: Gene sets differ between assigned transcript points and the expression table. 0 genes occur only in points (e.g. []), and 6 occur only in the table (e.g. ['Human_PTEN_G129E_WT:A', 'F2', 'CYP2A6', 'Human_CTNNB1_S33C_ALT:G', 'Human_FGFR3_R248H_ALT:A']). Genes only occuring in the table can be introduced by cropping (`SpatialData`), but differences may also be due to the filtering used to generate the transcript data and expression matrix. Please check that `filter_kwargs`, particularly `min_qv` and `control_prefixes`, are consistent with the filtering used to generate the expression table.
  sdata_new = _filter_control_and_low_quality_transcripts(
/g/huber/users/lazic/src/SegTraQ/src/segtraq/SegTraQ.py:188: RuntimeWarning: Cell IDs differ between assigned transcript points and the expression table. 0 cells occur only in points (e.g. []), and 50 occur only in the table (e.g. ['ocnokblm-1', 'ocnihmii-1', 'icdfihnh-1', 'ocobfnak-1', 'obnpohfm-1']). Cells only occuring in the table can reflect zero-count cells, but differences may also be due to the filtering used to generate the transcript data and expression matrix. Please check that `filter_kwargs`, particularly `min_qv` and `control_prefixes`, are consistent with the filtering used to generate the expression table.
  sdata_new = _filter_control_and_low_quality_transcripts(

Now the data is ready to compute some quality control metrics. SegTraQ is structured into different modules, all of which focus on different problems that can arise during segmentation. We will now go through all of the modules and look at what they tell us about our segmentation.

Baseline module#

The baseline (bl) module computes basic quality-control metrics such as the number of cells, the percentage of unassigned transcripts, and the number of transcripts and genes per cell. All of the results that SegTraQ computes are automatically stored in your spatialdata object.

[6]:
# the number of cells
st.bl.num_cells()
[6]:
18311
[7]:
# the percentage of transcripts not assigned to any cell
st.bl.perc_unassigned_transcripts()
[7]:
10.324546815309601
[8]:
# the number of transcripts per cell
st.bl.transcripts_per_cell().head()
[8]:
cell_id transcript_count
0 afbommff-1 1352
1 afbjnaja-1 1227
2 afbihifc-1 1216
3 afjdkagk-1 1105
4 aphbncjb-1 1072
[9]:
# the number of genes per cell
st.bl.genes_per_cell().head()
[9]:
cell_id gene_count
0 acceakgj-1 86
1 accebped-1 95
2 accedpdh-1 50
3 acceejoe-1 57
4 acceekkh-1 48
[10]:
# the mean number of transcripts per gene per cell
st.bl.mean_transcripts_per_gene_per_cell().head()
[10]:
cell_id mean_transcripts_per_gene
0 acceakgj-1 1.093023
1 accebped-1 1.147368
2 accedpdh-1 1.120000
3 acceejoe-1 1.140351
4 acceekkh-1 1.166667

We can also compute some morphological features of the cells using bl.morphological_features().

[11]:
st.bl.morphological_features().head()
[11]:
cell_id num_polygons cell_area perimeter circularity solidity convexity elongation eccentricity compactness
0 acceakgj-1 1 45.630318 25.596593 0.875183 0.974460 0.983536 1.097515 0.412076 14.358558
1 accebped-1 1 44.433013 25.224078 0.877577 0.969899 0.983684 1.377976 0.688009 14.319400
2 accedpdh-1 1 46.737367 26.267438 0.851214 0.964090 0.984115 1.367124 0.681882 14.762883
3 acceejoe-1 1 33.030204 22.242175 0.839009 0.968244 0.978508 1.214174 0.567164 14.977635
4 acceekkh-1 1 40.730569 23.923744 0.894277 0.982569 0.987680 1.117125 0.445754 14.051989

Clustering stability module#

The clustering stability (cs) module provides metrics for assessing the quality and stability of clustering results across different resolutions and random subsets of cells. The idea is as follows: the better a segmentation method separates cell types, the more distinct and stable the resulting expression-based clusters should be.

Let’s first perform Leiden clustering and visualize the clusters in UMAP space.

[12]:
# extracting the anndata object from the spatialdata object and performing appropriate normalization
adata = st.sdata.tables["table"].copy()
sc.pp.normalize_total(adata, inplace=True)
sc.pp.log1p(adata)
sc.pp.pca(adata)
sc.pp.neighbors(adata)
sc.tl.umap(adata)
sc.tl.leiden(adata, flavor="igraph", n_iterations=2, resolution=0.2)

# plotting the result of the leiden clustering
sc.pl.umap(adata, color="leiden")
../_images/notebooks_10x_xenium_focus_simplified_20_0.png

We can start by looking at cluster connectedness. This metric uses a neighborhood graph to compute the fraction of neighbors sharing the same cluster assignment. In general, the higher this value is, the better. By default, Leiden clustering is performed at a resolution of 0.2, but this can be adjusted with the resolution parameter. SegTraQ performs the necessary preprocessing and clustering internally.

[13]:
st.cs.connectedness()
[13]:
0.9207492687143093

We can do the same with the silhouette score, which measures how well cells are separated into distinct clusters.

[14]:
st.cs.silhouette_score()
[14]:
-0.08796729147434235

Next, we can check how stable the clustering is when we only take a random subset (63%) of our cells and perform Leiden clustering on it. We repeat this five times and compare the resulting cluster assignments using the adjusted Rand index (ARI) and purity. For more details on these metrics, refer to the section on this module.

[15]:
st.cs.adjusted_rand_index()
[15]:
0.7525794359616504
[16]:
st.cs.purity()
[16]:
0.9109190185727275

If cell-type annotations are available, connectedness and silhouette score can also be computed using these labels instead of Leiden clusters.

Region similarity module#

The region similarity (rs) module evaluates morphological and molecular consistency across different regions of a cell. Transcript-based metrics are corrected for finite-count effects using permutation-based null expectations.

Matching nuclei to cells#

match_nuclei_to_cells() matches each cell to its most overlapping nucleus. iou measures their overall overlap, while nucleus_fraction measures how much of the nucleus is contained within the cell.

[17]:
st.rs.match_nuclei_to_cells().head()
[17]:
cell_id nucleus_id iou nucleus_fraction num_nuclei
0 acceakgj-1 0.0 0.617026 1.000000 1.0
1 accebped-1 1.0 0.556906 0.999997 1.0
2 accedpdh-1 2.0 0.427579 0.997749 1.0
3 acceejoe-1 3.0 0.708851 1.000000 1.0
4 acceekkh-1 4.0 0.543778 1.000000 1.0

Nucleus–cell similarity#

similarity_nucleus_cell measures whether the final cell-level transcript profile is molecularly consistent with its matched nucleus. Negative residuals indicate lower similarity than expected.

[18]:
st.rs.similarity_nucleus_cell().head()
[18]:
cell_id nucleus_id iou nucleus_fraction similarity_nucleus_cell similarity_nucleus_cell_p_value
0 acceakgj-1 0.0 0.617026 1.000000 0.000147 0.542289
1 accebped-1 1.0 0.556906 0.999997 -0.030584 0.004975
2 accedpdh-1 2.0 0.427579 0.997749 -0.000677 0.527363
3 acceejoe-1 3.0 0.708851 1.000000 -0.014015 0.104478
4 acceekkh-1 4.0 0.543778 1.000000 -0.000529 0.472637

Nucleus–cytoplasm similarity#

similarity_nucleus_cytoplasm directly compares nuclear and non-nuclear transcript composition. Low residuals may indicate transcript misassignment.

[19]:
st.rs.similarity_nucleus_cytoplasm().head()
[19]:
cell_id nucleus_id iou nucleus_fraction similarity_nucleus_cytoplasm similarity_nucleus_cytoplasm_p_value
0 acceakgj-1 0.0 0.617026 1.000000 -0.036996 0.139303
1 accebped-1 1.0 0.556906 0.999997 -0.102345 0.004975
2 accedpdh-1 2.0 0.427579 0.997749 -0.015371 0.248756
3 acceejoe-1 3.0 0.708851 1.000000 NaN NaN
4 acceekkh-1 4.0 0.543778 1.000000 -0.129977 0.004975

Border admixture score#

border_admixture_score asks whether the cell border resembles a mixture of the cell center and its neighborhood. Positive residuals indicate stronger neighborhood-like admixture than expected.

[20]:
st.rs.border_admixture_score().head()
[20]:
cell_id border_admixture_score border_admixture_p_value
0 acceakgj-1 0.013326 0.378109
1 accebped-1 0.102202 0.094527
2 accedpdh-1 0.299454 0.019900
3 acceejoe-1 0.206444 0.009950
4 acceekkh-1 0.137160 0.054726

Supervised module#

The sp (supervised) module provides metrics to evaluate how well cell profiles in a spatial transcriptomics dataset agree with a reference single-cell RNA-seq (scRNA-seq) dataset with cell type annotations.

Unlike scRNA-seq, contamination in spatial transcriptomics measurements mostly originates from the local tissue context.

By comparing spatial expression profiles to a high-quality scRNA-seq reference, the supervised module aims to quantify this mismatch. Specifically, we compute metrics that measure:

  • how well each spatial cell matches its expected cell type,

  • how much its expression resembles other (neighboring) cell types, and

  • if it is possible to predict that a cell of one cell type is adjacent to a different cell type.

To obtain cell-type specific marker genes, we define positive and negative markers in the annotated scRNA-seq via markers_from_reference.

[21]:
adata_ref = ad.read_h5ad("../../data/xenium_5K_data/BC_scRNAseq_Janesick.h5ad")

Computing cell-type specific markers#

[22]:
# hiding all warnings
with warnings.catch_warnings():
    warnings.simplefilter("ignore")

    markers = st.markers_from_reference(
        adata_ref,
        ref_cell_type="celltype_major",
        ref_raw_counts_layer="raw",
        n_jobs=16,
    )
WARNING: adata.X seems to be already log-transformed.

Below, we show the number of negative markers that overlap with the positive markers of each cell type. To reliably estimate contamination, each cell type should share at least ~5 negative markers with the positive marker set of every other cell type. If these overlaps are too small, contamination estimates become unstable.

In such cases, the marker definition can be relaxed by adjusting the thresholds used in markers_from_reference above.

[23]:
ctypes = list(markers.keys())
overlap_df = pd.DataFrame(0, index=ctypes, columns=ctypes, dtype=int)

for c in ctypes:
    neg_c = set(markers[c].get("negative", []))
    for d in ctypes:
        pos_d = set(markers[d].get("positive", []))
        overlap_df.loc[c, d] = len(neg_c & pos_d)

overlap_df
[23]:
B DCIS1 DCIS2 T dendritic endo macro mast myoepi perivas stromal tumor
B 0 49 39 26 50 108 71 28 97 87 98 22
DCIS1 50 0 16 48 66 90 76 27 33 49 70 16
DCIS2 48 20 0 52 63 92 74 29 58 60 84 6
T 41 75 78 0 73 110 102 37 98 109 118 66
dendritic 26 50 41 27 0 101 49 24 85 70 88 29
endo 44 37 27 37 49 0 60 24 55 6 31 22
macro 29 48 40 24 42 94 0 20 85 80 94 27
mast 47 50 56 43 70 86 74 0 70 92 109 38
myoepi 41 12 5 33 48 63 48 20 0 34 42 14
perivas 49 41 29 28 53 45 79 28 59 0 40 26
stromal 48 37 25 47 56 66 54 27 61 24 0 28
tumor 57 23 12 66 70 95 88 30 75 71 90 0

Label transfer#

Before we can do statistics on the spatial data, we first need to transfer our cell type labels onto the spatial transcriptomics data. We can do this simply by calling run_label_transfer().

[24]:
st.run_label_transfer(adata_ref, ref_cell_type="celltype_major", ref_raw_counts_layer="raw")
WARNING: adata.X seems to be already log-transformed.

Let’s quickly verify that this worked by plotting the data.

[25]:
# Replace NaN with Unknown for plotting
s = st.sdata.tables["table"].obs["transferred_cell_type"]
if pd.api.types.is_categorical_dtype(s):
    s = s.cat.add_categories(["Unknown"])

st.sdata.tables["table"].obs["transferred_celltype_plot"] = s.fillna("Unknown")

# before we can plot, we need to link the shapes to the table
st.sdata.tables["table"].obs["region"] = "cell_boundaries"
st.sdata.set_table_annotates_spatialelement("table", region="cell_boundaries")
/tmp/ipykernel_166416/3630248555.py:3: DeprecationWarning: is_categorical_dtype is deprecated and will be removed in a future version. Use isinstance(dtype, pd.CategoricalDtype) instead
  if pd.api.types.is_categorical_dtype(s):
[26]:
st.sdata.pl.render_shapes("cell_boundaries", color="transferred_celltype_plot").pl.show(coordinate_systems="global")
../_images/notebooks_10x_xenium_focus_simplified_48_0.png

Marker purity#

To quantify how well each segmented cell in the spatial transcriptomics data matches its annotated cell type, we defined a marker-based purity (marker_balanced_accuracy) score that jointly evaluates the expression of positive (positive_marker_recall) and the absence of neighborhood-associated negative markers (negative_marker_avoidance). The method accounts for the spatial context of each cell and is motivated by the assumption that differences between scRNA-seq and spatial transcriptomics-derived cell type profiles arise mainly from local contamination by neighboring cells.

[27]:
st.sp.marker_purity(cell_type_key="transferred_cell_type", markers=markers).head()
INFO     Creating graph using `None` transform and `1` libraries.
/g/huber/users/lazic/src/SegTraQ/src/segtraq/SegTraQ.py:1966: RuntimeWarning: neighbors_key='spatial_connectivities' not found in adata.obsp. A neighborhood graph based on Delaunay will be computed.
  return sp.marker_purity(
[27]:
cell_id positive_marker_recall negative_marker_avoidance marker_balanced_accuracy n_evaluated_positive_markers n_evaluated_negative_markers
0 acceakgj-1 0.062500 1.0 0.531250 112 4
1 accebped-1 0.071429 1.0 0.535714 112 5
2 accedpdh-1 0.016949 1.0 0.508475 118 5
3 acceejoe-1 0.062500 1.0 0.531250 112 5
4 acceekkh-1 0.062500 1.0 0.531250 112 17

These metrics are most informative when considered jointly. For an example, please refer to the Xenium Focus.

Neighborhood contamination#

Marker purity summarizes how well a cell matches its own markers and avoids neighborhood-relevant negatives. In many cases, we also want to quantify

  1. how many contaminating transcripts are present per cell and

  2. which neighboring cell types contribute to this signal. We therefore compute neighborhood contamination.

[28]:
per_cell_df, _, _, _ = st.sp.neighbor_contamination(cell_type_key="transferred_cell_type", markers=markers)
per_cell_df.head()
[28]:
cell_id contamination_counts contamination_strength
0 acceakgj-1 0.0 0.0
1 accebped-1 0.0 0.0
2 accedpdh-1 0.0 0.0
3 acceejoe-1 0.0 0.0
4 acceekkh-1 0.0 0.0

The heatmap below summarizes contamination strength for each source–target cell-type pair. Each entry represents the mean contamination strength across all evaluable target cells of the given target cell type, where the source-specific contamination strength is computed as the fraction of transcripts in the target cell that correspond to contamination-relevant markers of the source cell type.

The bubble plot illustrates the contamination strength (bubble color) and the number of evaluable target cells (bubble size). The number of evaluable cells depends on

  1. how frequently the source and target cell types occur as neighbors and

  2. how distinct their expression profiles are, as more transcriptionally distinct cell types have more mutually exclusive marker genes that can be evaluated.

Stromal cells cause a high level of contamination into neighboring cells.

[29]:
cont_strength_mat = st.sdata.tables[st.tables_key].uns["contamination_strength_matrix"]
cont_n = st.sdata.tables[st.tables_key].uns["contamination_evaluable_cells_matrix"]

plot_df = (
    cont_strength_mat.stack(dropna=False)
    .rename("contamination_strength")
    .reset_index()
    .rename(columns={"level_0": "source", "level_1": "target"})
)

plot_df["n_evaluable"] = cont_n.stack(dropna=False).values

plt.figure(figsize=(9, 7))

ax = sns.scatterplot(
    data=plot_df,
    x="target",
    y="source",
    size="n_evaluable",
    hue="contamination_strength",
    sizes=(20, 600),
    palette="Reds",
    edgecolor="black",
)

ax.legend(
    bbox_to_anchor=(1.02, 1),
    loc="upper left",
    borderaxespad=0,
)

plt.title("Directed Cell-Type Contamination Strength")
plt.xlabel("Target Cell Type")
plt.ylabel("Source Cell Type")
plt.xticks(rotation=45, ha="right")
plt.tight_layout()
plt.show()
../_images/notebooks_10x_xenium_focus_simplified_55_0.png

Mutually exclusive co-expression rate (MECR)#

MECR identifies marker-gene pairs that are expected to be mutually exclusive in the reference and tests for unexpected positive co-expression in the spatial data.

[30]:
st.sp.mutually_exclusive_coexpression_rate(
    adata_ref=adata_ref,
    ref_cell_type="celltype_major",
    ref_raw_counts_layer="raw",
).head()
[30]:
gene1 gene2 odds_ratio pvalue pvalue_adj a b c d coexpression_fraction
0 ABCA1 CD96 0.759589 0.84989 1.0 11 663 377 17260 0.000601
1 ABCA10 FCRL1 0.000000 1.00000 1.0 0 354 101 17856 0.000000
2 ABCC3 CD3G 0.000000 1.00000 1.0 0 76 156 18079 0.000000
3 ABCC3 CD5 0.000000 1.00000 1.0 0 76 401 17834 0.000000
4 ABCC3 TPSG1 0.000000 1.00000 1.0 0 76 62 18173 0.000000

3D Volume Module#

The volume (vl) accessor provides metrics to assess how well a segmentation method resolves cell overlaps in 3D. Spatial transcriptomics tissue sections have a finite thickness (~4–10 µm), so cells can overlap along the z-dimension and 2D segmentation methods may introduce mixing by assigning transcripts from overlapping cells to the same mask. In this module, we introduce metrics to quantify sensitivity to 3D overlap and evaluate how well quasi-3D methods (e.g. Proseg) disentangle transcripts from overlapping cells.

For a detailed description of this module, please refer to this tutorial.

Top–bottom z consistency#

similarity_top_bottom compares transcript composition at the top and bottom of each cell to detect potential mixing from overlapping cells across z. Negative residuals indicate lower top–bottom similarity than expected, which may suggest overlapping or incorrectly merged cells.

[31]:
st.vl.similarity_top_bottom().head()
[31]:
cell_id similarity_top_bottom similarity_top_bottom_p_value
0 acceakgj-1 -0.009827 0.199005
1 accebped-1 -0.124454 0.004975
2 accedpdh-1 0.038066 0.796020
3 acceejoe-1 -0.165040 0.004975
4 acceekkh-1 -0.185124 0.004975

In volume.ipynb, we explore the distribution of transcripts along the z-dimension in more depth and put it into context with ovrlpy, a package for detecting 3D overlap in spatial transcriptomics. There, we use a Xenium v1 dataset with a 313-gene panel, where transcript coverage is higher and the analysis is less affected by sparsity.

Point statistic metrics#

The point statistics (ps) module is designed to compare the distribution of a set of transcripts in the cell relative to its cell centroid or cell border. The idea is to compute the distances of the transcripts to a reference point in the cell, either the cell centroid or the cell boundaries and aggregate this measure per transcript id.

Distance of transcripts to the cell membrane#

In this metric we compute the distance to the segmented cell membrane of each transcript coordinate and aggregate this metric per transcript id as mean. For example, we can compute both the average distance to the cell membrane across all previously defined negative and positive markers for the cell type “DCIS2”.

[32]:
border_distance_negative = st.ps.distance_to_membrane(
    markers["DCIS2"]["negative"],
    cell_type_key="transferred_celltype_plot",
    cell_type_query=["DCIS2"],
    inplace=False,
)

border_distance_positive = st.ps.distance_to_membrane(
    markers["DCIS2"]["positive"],
    cell_type_key="transferred_celltype_plot",
    cell_type_query=["DCIS2"],
    inplace=False,
)
[33]:
border_distance_negative.head()
[33]:
cell_id distance_to_cell_membrane_468_genes cell_area distance_to_cell_membrane_norm_468_genes
0 aeldpbbo-1 3.375261 176.880632 0.253786
1 hjbdpmpc-1 2.944742 262.673512 0.181693
2 hjbeaond-1 0.611860 46.964886 0.089282
3 hjbddmmj-1 2.864609 215.983075 0.194920
4 hjbcgooh-1 1.425170 102.683823 0.140642
[34]:
border_distance_positive.head()
[34]:
cell_id distance_to_cell_membrane_108_genes cell_area distance_to_cell_membrane_norm_108_genes
0 hjbbcfmf-1 1.969041 173.102552 0.149659
1 hjappign-1 1.402572 66.878803 0.171507
2 aelekoij-1 2.289215 227.247765 0.151858
3 hjbalkhh-1 1.864973 182.727946 0.137965
4 aelddaib-1 1.663014 59.538630 0.215524

Session Info#

[35]:
print(sd.__version__)  # spatialdata
print(spatialdata_plot.__version__)
0.8.0
0.4.0