import os
import numpy as np
import pandas as pd
import skimage.io as io
from skimage.segmentation import find_boundaries
import matplotlib.pyplot as plt
import matplotlib.cm as cm
from matplotlib import colors
from mpl_toolkits.axes_grid1 import make_axes_locatable
from scipy.spatial.distance import cdist
from scipy import stats
import random
from sklearn.cluster import KMeans
import seaborn as sns
Intro to Spatial Analysis
In this notebook, we will be going through some basic spatial analyses. The goal is to provide you with an intuition of the logic behind each of these functions. Try not to get bogged down in understanding every single line of the code, but focus more on the overall reasoning behind what is being done. The code in this notebook is not optimized for large datasets, but simplified versions of more complex functions, to make it more clear what is actually being done.
If you would like to run similar analyses on your own multiplexed imaging datasets, please see our lab’s poipeline here: https://github.com/angelolab/ark-analysis. There are also other toolkits for spatial analysis, including Squidpy and MCMICRO.
1. Inspect your data
Mantis Viewer or napari can be useful for visualizing your data, but it’s always a good idea to be able to open, view, and manipulate your data in Python, as it will give you more flexibility when analyzing your data.
%matplotlib notebook
# Directory where the data lives
= "example_data"
data_dir
# Example image
= "fov1"
ex_fov
# Look at a few markers
= ["CD45","Collagen1"]
markers
# Set-up plots
'figure.figsize'] = [10, 5]
plt.rcParams[= plt.subplots(1,len(markers), sharex=True, sharey=True)
fig, ax for i,mark in enumerate(markers):
= np.array(io.imread(os.path.join(data_dir, ex_fov, "image_data", mark+".tiff")))
im_array ="lower", cmap='gray', vmax=np.quantile(im_array,0.99))
ax[i].imshow(im_array, origin
ax[i].set_title(mark)'off')
ax[i].axis( plt.tight_layout()
We have already segmented these images to identify the location of single cells in the image using Mesmer. If you are interested in applying Mesmer to your own data, you can see the notebook here.
We can inspect the output of Mesmer here. In the segmentation output, each cell has a unique label. For example, all pixels with the value of 1 belong to the same cell, all pixels with the value of 2 belong to another cell, etc.
# Read in segmentation mask
= os.path.join(data_dir, ex_fov, "masks", "segmentation_whole_cell.tiff")
seg_path = np.array(io.imread(seg_path)).squeeze() #squeeze changes dimensions from (1,2048,2048) to (2048,2048)
seg_array
# Set up plot
= plt.subplots(figsize=[5,5])
fig, ax
plt.imshow(seg_array)'off')
plt.axis( plt.tight_layout()
Once we have segmented our data, we can generate cell tables where each row corresponds to one cell that was identified in the image. We have already identified the phenotype of each cell using Pixie. If you are interested in running Pixie on your own data, see the notebook here.
The ‘label’ column in the table corresponds to the cell IDs (pixel values in the segmentation output), ‘centroid-0’ and ‘centroid-1’ correspond to the center point of each cell, and the ‘cell_cluster’ column is the cell phenotype that we determined using Pixie.
# Read in cell table
= os.path.join(data_dir, "cell_table.csv")
cell_table_path = pd.read_csv(cell_table_path)
cell_table
# Subset for only the example fov we're looking at here
= cell_table.loc[cell_table['fov'] == ex_fov]
fov_cell_table fov_cell_table
We can then create a cell phenotype map, where each cell is colored according to its cell phenotype.
# Define colors we want for each cell type
= {}
all_colors 'APC'] = '#4E79A7'
all_colors['B'] = '#A0CBE8'
all_colors['Cancer'] = '#F28E2B'
all_colors['Cancer_EMT'] = '#FFBE7D'
all_colors['Cancer_Other'] = '#59A14F'
all_colors['CD4T'] = '#8CD17D'
all_colors['CD8T'] = '#B6992D'
all_colors['Endothelium'] = '#F1CE63'
all_colors['Fibroblast'] = '#499894'
all_colors['Immune_Other'] = '#86BCB6'
all_colors['M1_Mac'] = '#E15759'
all_colors['M2_Mac'] = '#FF9D9A'
all_colors['Mac_Other'] = '#79706E'
all_colors['Mast'] = '#D4A6C8'
all_colors['Monocyte'] = '#D37295'
all_colors['Neutrophil'] = '#FABFD2'
all_colors['NK'] = '#B07AA1'
all_colors['Other'] = '#BAB0AC'
all_colors['Stroma'] = '#9D7660'
all_colors['T_Other'] = '#D7B5A6'
all_colors['Treg'] = '#FFFF99'
all_colors[
# Create table matching each color to a unique ID
= [(key,value) for key,value in all_colors.items()]
colors_list = pd.DataFrame(colors_list, columns=['cell_cluster','color'])
all_colors_df 'pheno_id'] = all_colors_df.index + 1
all_colors_df[
# Make color map for plotting
= all_colors_df['color'].tolist()
mycols 0,'#000000') # add black for empty slide, will have id 0
mycols.insert('#FFFFFF') # add white for cell borders, will have id max_n+1
mycols.append(= colors.ListedColormap(mycols)
colmap = np.max(all_colors_df['pheno_id'])
max_n = [i-0.5 for i in np.linspace(0,max_n+2, max_n+3)]
bounds = colors.BoundaryNorm(bounds, colmap.N)
norm
# Define function for making cell phenotype map (CPM)
def create_cpm(fov_name, cell_table, all_colors_df, seg_array, fig, ax):
# Subset cell table for this FOV
= cell_table.loc[cell_table['fov'] == fov_name]
one_fov_cell_table # Combine with cell table
= one_fov_cell_table.merge(all_colors_df, how='left')
one_fov_cell_table # Make dictionary mapping each cell to its phenotype id
= dict(zip(one_fov_cell_table['label'], one_fov_cell_table['pheno_id']))
fov_cell_dict # Add 0 for empty slide
0] = 0
fov_cell_dict[
# Make new image where each pxiel corresponds to its phenotype id
# Use 'vectorize' in numpy package to speed up this operation
= np.vectorize(fov_cell_dict.get)(seg_array)
cpm_array
# Find the borders of cells
= find_boundaries(seg_array, connectivity=1, mode='inner').astype(np.uint8)
predicted_contour_mask # Color this border white
> 0] = max_n+1
cpm_array[predicted_contour_mask
# Plot
= colmap(norm(cpm_array))
cpm_image
ax.imshow(cpm_image)
return
# Create CPM for example FOV
= plt.subplots(figsize=[8,8])
fig, ax = create_cpm(ex_fov, cell_table, all_colors_df, seg_array, fig, ax)
cpm 'off')
plt.axis(
# Add colorbar to image
= make_axes_locatable(fig.gca())
divider = divider.append_axes(position="right", size="5%", pad="3%")
cax = fig.colorbar(cm.ScalarMappable(norm=norm, cmap=colmap),
cbar =cax, orientation="vertical", use_gridspec=True, pad=0.1,
cax=0.9, drawedges=True)
shrink= all_colors_df['cell_cluster'].to_list()
cbar_labels 0,'Empty') # add black for empty slide, will have id 0
cbar_labels.insert('Cell border') # add white for cell borders, will have id max_n+1
cbar_labels.append(
cbar.ax.set_yticks(=np.arange(len(cbar_labels)),
ticks=cbar_labels
labels
)
cbar.minorticks_off()
plt.tight_layout()
2. Quanitfying cell populations
There are many methods for cell enumeration, including counting the number of cells of each cell type, calculating cell frequency by dividing by the total number of cells, and calculating cell density by dividing by the total tissue area. While the first approach is the most straightforward, simply counting the number of cells depends on the amount of tissue present in the image. Cell frequencies normalize the number of each cell type to the total number of cells in the image such that all cell types sum to 1. As a result, cell frequencies are not confounded by differences in the size of the section. However, one drawback of cell frequencies is that they can obscure the reason that the amount of one cell type differs between samples. For example, consider a simple example of comparing the number of tissue resident macrophages in healthy and inflamed tissue. Even if the number of macrophages is the same in both images, it may seem like tissue resident macrophages are decreasing in the latter relative to the former. In reality, however, the absolute count of tissue resident macrophages is not changing; they are just outnumbered by infiltrating immune cells. Only considering cell frequencies in such a scenario would lead to the incorrect conclusion that macrophages are decreasing in the inflamed state. One solution is to also examine the density of each cell population by dividing by the total tissue area.
Here, we are showing an example of two FOVs that have different amounts of immune infiltrate, ECM content, and total area (as determined using the slide background mask). We have generated masks of the ECM (using the composite signal of ECM markers) and empty sllide (for MIBI, we can determine empty slide by measuring gold signal, since we use gold sputtered slides). Using the empty slide mask, we can estimate tissue area (by taking the inverse). In the ECM and empty slide masks shown here, white indicates ECM or empty slide, respectively.
# Example images
= "fov1"
fov1 = "fov2"
fov2
# Set-up plots
'figure.figsize'] = [10,6]
plt.rcParams[= plt.subplots(2,3)
fig, ax
# Look at cell phenotype maps, ECM mask, and gold mask
for i,fov in enumerate([fov1,fov2]):
# CPMs
= os.path.join(data_dir,fov,"masks","segmentation_whole_cell.tiff")
seg_path = np.array(io.imread(seg_path)).squeeze()
seg_array 0])
create_cpm(fov, cell_table, all_colors_df, seg_array, fig, ax[i,0].set_title("Cell phenotype map")
ax[i,0].set_ylabel(fov)
ax[i,0].set_yticklabels([])
ax[i,0].set_xticklabels([])
ax[i,0].set_yticklabels([])
ax[i,0].set_xticks([])
ax[i,0].set_yticks([])
ax[i,
# ECM
= os.path.join(data_dir,fov,"masks","total_ecm.tiff")
ecm_path = np.array(io.imread(ecm_path))
ecm_array 1].imshow(ecm_array, cmap='gray')
ax[i,1].axis('off')
ax[i,1].set_title("ECM")
ax[i,
# Empty slide
= os.path.join(data_dir,fov,"masks","empty_slide.tiff")
empty_slide_path = np.array(io.imread(empty_slide_path))
empty_slide_array 2].imshow(empty_slide_array, cmap='gray')
ax[i,2].axis('off')
ax[i,2].set_title("Empty slide")
ax[i,
plt.tight_layout()
Simply count the number of each cell type
= cell_table.loc[cell_table['fov'].isin([fov1,fov2])]
cell_table_keep = cell_table_keep.groupby('fov')['cell_cluster'].value_counts().reset_index(name='count')
count_cells count_cells
Calculate frequency (divided by total number of cells)
# Get total number of cells per FOV
= cell_table_keep.groupby('fov').size().to_frame('total_cells')
total_counts = total_counts.reset_index()
total_counts
# Calculate frequency
= count_cells.merge(total_counts, on='fov')
count_cells 'frequency'] = count_cells['count'] / count_cells['total_cells']
count_cells[ count_cells
Calculate density (divided by tissue area)
# Calculate tissue area (inverse of empty slide)
def get_tissue_area(fov_name):
= os.path.join(data_dir,fov_name,"masks","empty_slide.tiff")
empty_slide_path = np.array(io.imread(empty_slide_path))
empty_slide_array # Get total pixels that belong to empty slide
= np.sum(empty_slide_array) #pixel value is 1 if it is empty slide
empty_slide_px # Get number of pixels that belong to tissue (total image - empty slide)
= empty_slide_array.shape
total_size = (total_size[0]*total_size[1]) - empty_slide_px
tissue_area_px # These images are 2048px x 2048px, imaged at 800um x 800um
# Therefore, the size of 1 pixel is 800um/2048px = 0.39 um/px, area is 0.15 um^2
return (fov_name, tissue_area_px*0.15)
# Calculate for all FOVs
= [get_tissue_area(fov) for fov in [fov1,fov2]]
all_tissue_px = pd.DataFrame(all_tissue_px, columns=['fov','area'])
all_tissue_df
# Calculate density
= count_cells.merge(all_tissue_df, on='fov')
count_cells 'density'] = count_cells['count'] / count_cells['area']
count_cells[ count_cells
Compare
= 'CD4T'
cell_type print(cell_type)
print('FOV:', fov1,
', total_counts:', count_cells.loc[(count_cells['fov']==fov1) & (count_cells['cell_cluster']==cell_type)]['count'].values[0],
', frequency:', count_cells.loc[(count_cells['fov']==fov1) & (count_cells['cell_cluster']==cell_type)]['frequency'].values[0],
', density:', count_cells.loc[(count_cells['fov']==fov1) & (count_cells['cell_cluster']==cell_type)]['density'].values[0])
print('FOV:', fov2,
', total_counts:', count_cells.loc[(count_cells['fov']==fov2) & (count_cells['cell_cluster']==cell_type)]['count'].values[0],
', frequency:', count_cells.loc[(count_cells['fov']==fov2) & (count_cells['cell_cluster']==cell_type)]['frequency'].values[0],
', density:', count_cells.loc[(count_cells['fov']==fov2) & (count_cells['cell_cluster']==cell_type)]['density'].values[0])
Additional exercises
- Change the “cell_type” in the code block above and evaluate how the quantification method is different for different cell types.
- Create a stacked bar plot showing the cell type composition of each fov (x axis should be fov1 and fov2, y axis is cell type composition). Try making this plot with counts, frequency, and density.
3. Cell-cell enrichment (global)
The goal of pairwise enrichment analysis is to assess whether any two given cell populations colocalize with each other. For example, this approach could be used to determine if T cells preferentially colocalize with tumor cells. In asking such questions, we recommend taking this one step further: Do two cell populations colocalize with each other more often than would be expected by chance? Depending on the frequency of the cell populations and native tissue structure, it may not be possible to determine whether some pairwise relationships are truly preferential or random. The goal of this approach is to minimize potential confounding effects specific to each image that might not be related to the biological question of interest. For example, in a tissue section equally composed of only two cell populations, pairwise enrichment is likely to occur simply by random chance.
With this in mind, we can assess the statistical significance of pairwise enrichment by comparing how often two cell populations are found in close proximity to each other compared with a null distribution. To construct a null distribution, we use a bootstrapping approach by selectively randomizing the location of the cell populations of interest, calculating pairwise enrichment, and repeating this process a large number of times (>100 times is typical). In the assessment of preferential enrichment for two cell populations defined by their expression of marker A or B, the simplest way to generate a null distribution is to randomize the location of one cell population across all cells in the image while keeping the location of the other cell population fixed. For both the original and randomized images, cells positive for A or B are colocalized if the distance between them is less than or equal to a user-defined value. The number of A–B interactions in the original image is then compared with the null distribution to determine statistical significance.
# Example image
= "fov2"
ex_fov
# Determine cell types to look at
= "Cancer"
pheno1 = "CD8T"
pheno2
# Threshold to determine if two cells are "close"
= 50
dist_thresh
# Number of bootstraps for generating null distribution
= 100 bootstrap_n
Calculate the distance between all cells in the FOV
# Subset cell table for only cells in this FOV
= cell_table.loc[cell_table['fov'] == ex_fov].reset_index(drop=True)
fov_cell_table # Make list of all cell centroids
= list(zip(fov_cell_table['centroid-0'],fov_cell_table['centroid-1']))
all_centroids # Get distance between all cells
= cdist(all_centroids, all_centroids, 'euclidean')
dist_mat # Print dimensions of distance matrix
print("Dimensions of dist_mat: ", dist_mat.shape)
dist_mat
This distance matrix has dimensions of total number of cells x total number of cells. The values in the matrix indicate the distance between any 2 cells. The indices of the distance matrix correspond to the indices of the cell table. So the first row in the distance matrix corresponds to the distance between cell label 1 and all other cells in the image. After calculating the distance between all cells, we can subset it for our cell type of choice. We can then count the number of close contacts between two cell types.
Count number of close contacts between pheno1 and pheno2
# Get index of cells belonging to pheno1 and pheno2
= fov_cell_table[fov_cell_table['cell_cluster'] == pheno1].index.to_list()
pheno1_idx = fov_cell_table[fov_cell_table['cell_cluster'] == pheno2].index.to_list()
pheno2_idx
# Only keep pheno1 cells in x-axis of distance matrix
= dist_mat[pheno1_idx,:]
pheno1_dist_mat # Binarize the distance matrix for distances that are within the defined threshold
= (pheno1_dist_mat < dist_thresh) & (pheno1_dist_mat > 0)
bin_mask # Change true/false to 1/0
= bin_mask*1
pheno1_dist_mat_bin
# Subset this distance matrix for pheno2 cells in y-axis of distance matrix
= pheno1_dist_mat_bin[:,pheno2_idx]
true_dist_mat_bin # Inspect the shape of this matrix, should be number of cells of pheno1 x number of cells of pheno2
# Each element in the matrix is the distance between a pheno1 cell and a pheno2 cell
print("Shape of subsetted distance matrix: ", true_dist_mat_bin.shape)
# For each pheno1 cell, count number of "close" contacts with pheno2 cells
= np.sum(true_dist_mat_bin, axis=1)
true_close_contacts # Take the average across all pheno1 cells
= np.mean(true_close_contacts)
true_close_contacts_mean print("Average number of close contacts between pheno1 and pheno2 cells: ", true_close_contacts_mean)
Above, we have determined the average number of close contacts between pheno1 and pheno2 cells in this image. To determine whether this is a significant number, we can compare it against a null distribution, generated by randomly permuting cell labels. We keep the pheno1 cells constant, then randomize the location of pheno2 cells in the image. For each randomization, we calculate the number of close contacts. We repeat this many times to generate the null distribution.
Generate null distribution by bootstrapping
# Get all possible cell indices (total pool of available cells to randomize)
= fov_cell_table.index.to_list()
all_idx # Remove cells that are of pheno1 from this pool (since they are held constant in this randomization)
= [x for x in all_idx if x not in pheno1_idx]
all_idx # Get total number of cells that are pheno2
= len(pheno2_idx)
num_pheno2
# Randomly sample all cells to be labeled as pheno2 (bootstrapping)
= []
all_bootstrap for _ in range(bootstrap_n):
# Select num_pheno2 random numbers, represents the indices of the randomly selected cells
= random.sample(all_idx, num_pheno2)
random_pheno2_idx # Subset the distance matrix to only keep these randomly selected cells
= pheno1_dist_mat_bin[:,random_pheno2_idx]
keep_dist_mat_bin # Find the total number of close contacts between pheno1 cells and randomly selected cells
= np.sum(keep_dist_mat_bin, axis=1)
close_contacts # Take the mean across all cells of pheno1
= np.mean(close_contacts)
close_contacts_mean # Add this value to the list of all bootstraps
all_bootstrap.append(close_contacts_mean)
Compare null distrbution to actual number of close contacts
= plt.subplots(figsize=(5,3))
fig, ax # Blue histogram is null distribution
=True, bins=10, alpha=0.5)
ax.hist(all_bootstrap, density# Red line is actual number of close contacts
=true_close_contacts_mean, color='red', linestyle='--', linewidth=2)
plt.axvline(x plt.show()
# Calculate statistics of null distribution
= stats.norm.fit(all_bootstrap)
muhat, sigmahat # Calculate z score based on distribution
= (true_close_contacts_mean - muhat) / sigmahat
z print("z-score: ", z)
Additional exercises
- Flip “pheno1” and “pheno2” - is this calculation symmetric? Conceptually, should it be symmetric?
- Play around with the distance threshold for determing “close” cells.
- Play around with “pheno1” and “pheno2” and evaluate these metrics for various pairs of cells.
- Pick a few different pheno1-pheno2 pairs. For one FOV, loop through this pairwise enrichment function for these pairs of cells. Store this output in a table (hint: column names could be “pheno1”, “pheno2”, “z-score”).
- Repeat this calculation for the second FOV.
- Compare z-scores between fov1 and fov2.
4. Cell-cell enrichment (context dependent)
This randomization strategy is robust for mitigating confounding effects attributable to the frequency of each cell population. However, this approach does not control for biases inherent to tissue structure. Consequently, interactions between two proteins may appear to be spatially enriched as a result of tissue structure or cell-specific expression, as markers expressed exclusively by certain cells will be heavily influenced by the location of those cells. For example, two immune cell populations that are known to preferentially localize in germinal centers are biased to be enriched with each other. While one possibility is that this relationship is indeed preferential, another possibility is that spatial enrichment is merely a result of both cell types being restricted to a smaller histological compartment.
To address this possibility, null distributions can also be generated in a context-dependent manner. In context-dependent spatial enrichment analysis, randomizations are restricted to occur only within a given cellular compartment or cell subset. In the germinal center example, randomizations can be restricted to occur only within the germinal center. As a result, spurious enrichments that might occur between noninteracting cell populations that happen to colocalize to germinal centers are accounted for in the null distribution.
For this dataset, we have generated masks for the cancer and stroma, core and border regions (using composite channels representative for each region).
Look at cancer and stroma masks
= io.imread(os.path.join(data_dir, ex_fov, "masks", "cancer_core.tiff"))
cancer_core = io.imread(os.path.join(data_dir, ex_fov, "masks", "cancer_border.tiff"))
cancer_border = io.imread(os.path.join(data_dir, ex_fov, "masks", "stroma_core.tiff"))
stroma_core = io.imread(os.path.join(data_dir, ex_fov, "masks", "stroma_border.tiff"))
stroma_border
= plt.subplots(1,4,figsize=[12,4])
fig, ax 0].imshow(cancer_core, cmap='gray')
ax[0].axis('off')
ax[0].set_title("Cancer core")
ax[1].imshow(cancer_border, cmap='gray')
ax[1].axis('off')
ax[1].set_title("Cancer border")
ax[2].imshow(stroma_core, cmap='gray')
ax[2].axis('off')
ax[2].set_title("Stroma core")
ax[3].imshow(stroma_border, cmap='gray')
ax[3].axis('off')
ax[3].set_title("Stroma border")
ax[
plt.tight_layout() plt.show()
Repeat calculation for number of close contacts but subset for cells within the cancer core only
# Get segmentation mask
= os.path.join(data_dir, ex_fov, "masks", "segmentation_whole_cell.tiff")
seg_path = np.array(io.imread(seg_path)).squeeze()
seg_array
# Make new segmentation mask where everything outside of the cancer core is 0
= np.copy(seg_array)
masked_seg_array == 0] = 0
masked_seg_array[cancer_core # Only keep cell labels that are within the cancer core
= np.unique(masked_seg_array)
masked_cell_labels = [x for x in masked_cell_labels if x != 0] #remove 0 (which is empty slide)
masked_cell_labels # Subset cell table for only the cells within the cancer core
= fov_cell_table[fov_cell_table['label'].isin(masked_cell_labels)] fov_cell_table_masked
# Get index of cells belonging to pheno1 and pheno2, but this time, only keep labels that are in the cancer core mask
= fov_cell_table_masked[fov_cell_table_masked['cell_cluster'] == pheno1].index.to_list()
pheno1_idx = fov_cell_table_masked[fov_cell_table_masked['cell_cluster'] == pheno2].index.to_list()
pheno2_idx
# Only keep pheno1 cells in x-axis of distance matrix
= dist_mat[pheno1_idx,:]
pheno1_dist_mat # Binarize the distance matrix for distances that are within the defined threshold
= (pheno1_dist_mat < dist_thresh) & (pheno1_dist_mat > 0)
bin_mask # Change true/false to 1/0
= bin_mask*1
pheno1_dist_mat_bin
# Subset this distance matrix for pheno2 cells in y-axis of distance matrix
= pheno1_dist_mat_bin[:,pheno2_idx]
true_dist_mat_bin
# For each pheno1 cell, count number of "close" contacts with pheno2 cells
= np.sum(true_dist_mat_bin, axis=1)
true_close_contacts = np.mean(true_close_contacts)
true_close_contacts_mean print("Average number of close contacts between pheno1 and pheno2 cells: ", true_close_contacts_mean)
Generate null distribution, only randomizing within cancer core
# Get all possible cell indices (total pool of available cells to randomize)
= fov_cell_table_masked.index.to_list()
all_idx # Remove cells that are of pheno1 from this pool (since they are held constant in this randomization)
= [x for x in all_idx if x not in pheno1_idx]
all_idx # Get total number of cells that are pheno2
= len(pheno2_idx)
num_pheno2
# Randomly sample all cells to be labeled as pheno2 (bootstrapping)
= []
all_bootstrap for _ in range(bootstrap_n):
# Select num_pheno2 random numbers, represents the indices of the randomly selected cells
= random.sample(all_idx, num_pheno2)
random_pheno2_idx # Subset the distance matrix to only keep these randomly selected cells
= pheno1_dist_mat_bin[:,random_pheno2_idx]
keep_dist_mat_bin # Find the total number of close contacts between pheno1 cells and randomly selected cells
= np.sum(keep_dist_mat_bin, axis=1)
close_contacts # Take the mean across all cells of pheno1
= np.mean(close_contacts)
close_contacts_mean # Add this value to the list of all bootstraps
all_bootstrap.append(close_contacts_mean)
Compare null distrbution to actual number of close contacts
= plt.subplots(figsize=(5,3))
fig, ax # Blue histogram is null distribution
=True, bins=10, alpha=0.5)
ax.hist(all_bootstrap, density# Red line is actual number of close contacts
=true_close_contacts_mean, color='red', linestyle='--', linewidth=2)
plt.axvline(x plt.show()
# Calculate statistics of null distribution
= stats.norm.fit(all_bootstrap)
muhat, sigmahat # Calculate z score based on distribution
= (true_close_contacts_mean - muhat) / sigmahat
z print("z-score: ", z)
Additional exercises
- Play with different regions and phenotype pairs.
5. Cellular microenvironments
Cell phenotypes are regulated in part by their local cellular niche, or microenvironment, which is defined as a collection of cell lineages found to be spatially colocalized and conserved across the data set. We can employ several methods to identify these microenvironments. The first step in this process is to quantify the local distribution of cell lineages around each cell in the data set. Here, we are enumerating the number of cells in each lineage within a set pixel radius from the index cell. Various machine learning methods can then be used to identify distinct cellular microenvironments that occur repeatedly across the data set. Here, we are using k-means clustering.
# FOVs to include in this analysis
= ["fov1", "fov2"]
all_fovs
# Set distance for the neighborhood around the cell
= 50
nh_dist
# Set the number of "expected" microenvironments
= 3
k
# Set a seed for reproducibility
= 2024
seed
# Get list of all cell types in our data
= cell_table['cell_cluster'].unique() all_cell_types
Find neighbors for each cell
# Define function to count the neighbors of each cell (where each cell is one row in the distance matrix)
def one_cell_nh(row):
# Get index of neighbors (defined as "close" in the distance matrix)
# In the distance matrix ('row' is just one row in the distance matrix), 1 indicates that the two cells were "close"
= np.where(row == 1)[0]
neighbor_idx # Initialize output as a dictionary, start with count of 0 for every cell type
= {key:0 for key in all_cell_types}
nh # Convert these indices to cell types
if len(neighbor_idx) > 0:
= fov_cell_table.loc[neighbor_idx]['cell_cluster'].values
nh_cells # Count the number of each cell type
= np.unique(nh_cells, return_counts=True)
unique_cell_type, counts # Store the data in the dictionary
for i,one_cell_type in enumerate(unique_cell_type):
= counts[i]
nh[one_cell_type] return nh
# Iterate through all fovs to get neighbors of all cells
= [] #store information for each fov in a list
all_nh_list for fov in all_fovs:
# Subset cell table for only cells in this FOV
= cell_table.loc[cell_table['fov'] == fov].reset_index(drop=True)
fov_cell_table # Make list of all cell centroids
= list(zip(fov_cell_table['centroid-0'],fov_cell_table['centroid-1']))
all_centroids # Get distance between all cells
= cdist(all_centroids, all_centroids, 'euclidean')
dist_mat
# Binarize distance matrix (1 if two cells are "close")
= dist_mat < nh_dist
dist_mat_bin = dist_mat_bin*1
dist_mat_bin
# Remove itself as its own neighbor
== 0] = 0
dist_mat_bin[dist_mat
# Apply function to every cell in the data
= np.apply_along_axis(one_cell_nh, axis=1, arr=dist_mat_bin)
nh_dicts # Turn into dataframe
= pd.DataFrame(nh_dicts.tolist())
nh_df # Get total number of neighbors
'total_neighbors'] = nh_df.sum(axis=1)
nh_df[# Combine with data from cell table
= pd.merge(fov_cell_table, nh_df, left_index=True, right_index=True)
nh_df
all_nh_list.append(nh_df)
# Turn data into dataframe
= pd.concat(all_nh_list)
all_nh_df all_nh_df
k-means clustering
When finding the neighborhood, you can either use the number of cells in the neighborhood of each cell, or the frequency (divided by the total number of cells). The interpretation of each of these is slightly different. We encourage you to try both ways (you can comment/uncomment the line of code that calculates frequency below). By clustering on the neighborhoods, we can identify distinct microenvironments.
# If you want to try clustering using frequency (meaning dividing by the total number of cells, uncomment this line
# all_nh_df[all_cell_types] = all_nh_df[all_cell_types].divide(all_nh_df['total_neighbors'], axis=0)
# Remove cells that have no neighbors
= all_nh_df.loc[all_nh_df['total_neighbors'] > 0].copy()
all_nh_df_zeros_removed
# Only keep columns we want for clustering
= all_nh_df_zeros_removed[all_cell_types]
kmeans_input
# Cluster
= KMeans(n_clusters=k, random_state=seed, n_init=10).fit(kmeans_input)
cluster_fit # Add 1 to labels to avoid cluster number 0 (because output of kmeans is 0-indexed)
= ["ME"+str(x) for x in cluster_fit.labels_+1]
cluster_labels
# Add cluster labels to cell table
'me_cluster'] = cluster_labels
all_nh_df_zeros_removed[
# Merge with the big cell table, assign unassigned cells (becaue no neighbors) to k+1
= all_nh_df.merge(all_nh_df_zeros_removed, how="left")
all_nh_df 'me_cluster'] = all_nh_df['me_cluster'].fillna("Unassigned")
all_nh_df[ all_nh_df
Visualize microenvironments
We can visualize the number of each cell type that is assigned to each microenvironment.
# Count the number of each cell type assigned to each microenvironment
= all_nh_df_zeros_removed.groupby(['me_cluster', 'cell_cluster']).size().reset_index(name='counts')
num_cell_types_dat_long # Reformat this table
= num_cell_types_dat_long.pivot(index='me_cluster', columns='cell_cluster', values='counts')
num_cell_types_dat 0, inplace=True)
num_cell_types_dat.fillna(
# Make heatmap
= sns.clustermap(
heatmap apply(stats.zscore, axis=1), # Apply a z-score for better visualization
num_cell_types_dat.= "vlag",
cmap = 0,
center = -3,
vmin = 3
vmax )
Visualize cell phenotypes maps, colored by microenvironment
# Define colors we want for each microenvironment (add colors here if you have larger k, you need k+1 colors)
= {}
all_colors 'ME1'] = '#4E79A7'
all_colors['ME2'] = '#59A14F'
all_colors['ME3'] = '#D37295'
all_colors['Unassigned'] = '#79706E'
all_colors[
# Create table matching each color to a unique ID
= [(key,value) for key,value in all_colors.items()]
colors_list = pd.DataFrame(colors_list, columns=['me_cluster','color'])
all_colors_df 'pheno_id'] = all_colors_df.index + 1
all_colors_df[
# Make color map for plotting
= all_colors_df['color'].tolist()
mycols 0,'#000000') # add black for empty slide, will have id 0
mycols.insert('#FFFFFF') # add white for cell borders, will have id max_n+1
mycols.append(= colors.ListedColormap(mycols)
colmap = np.max(all_colors_df['pheno_id'])
max_n = [i-0.5 for i in np.linspace(0,max_n+2, max_n+3)]
bounds = colors.BoundaryNorm(bounds, colmap.N) norm
# Create microenvironment masks
for fov in all_fovs:
# Get segentation array
= os.path.join(data_dir, fov, "masks", "segmentation_whole_cell.tiff")
seg_path = io.imread(seg_path).squeeze()
seg_array
# Make CPM using function we created above
= plt.subplots(figsize=[8,8])
fig, ax = create_cpm(fov, all_nh_df, all_colors_df, seg_array, fig, ax)
cpm 'off')
plt.axis(
ax.set_title(fov)
# Add colorbar
= make_axes_locatable(fig.gca())
divider = divider.append_axes(position="right", size="5%", pad="3%")
cax = fig.colorbar(cm.ScalarMappable(norm=norm, cmap=colmap),
cbar =cax, orientation="vertical", use_gridspec=True, pad=0.1,
cax=0.9, drawedges=True)
shrink= all_colors_df['me_cluster'].to_list()
cbar_labels 0,'Empty') # add black for empty slide, will have id 0
cbar_labels.insert('Cell border') # add white for cell borders, will have id max_n+1
cbar_labels.append(
cbar.ax.set_yticks(=np.arange(len(cbar_labels)),
ticks=cbar_labels
labels
)
cbar.minorticks_off()
plt.tight_layout()
Additional exercises
- Compare generating neighborhoods using counts vs. frequency.
- Change the k used for k-means clustering. How do the results change?
- Inertia and silhouette score are two metrics that could be useful for helping us choose the best k. Try different k’s and extracting the inertia value (see https://scikit-learn.org/stable/modules/generated/sklearn.cluster.KMeans.html, intertia_ is an attribute of the output of k-means). Plot k on the x axis and inertia on the y axis. What trend do you see?