Identifying a spatial code of ligand-receptor pairs with a genetic algorithm¶
Cells that are close within a tissue are very likely to be talking to each other. Some of the ligand-receptor pairs they use seem to carry spatial information about their localization and intercellular distances.
This tutorial searches for the subset whose cell-cell interaction scores best encode how far apart the cells actually are, using a genetic algorithm. The pairs it recovers are a spatial code. The preferential use of these signals can lead to cell-cell interaction potentials related to physical proximity.
This approach was previously reported in
Armingol et al. (2022) on the whole body of
C. elegans, available as cell2cell.analysis.optimize_lr_pairs. This notebook uses synthetic
data where the answer is known in advance, so the search can be judged.
Accompanying this notebook, we can also find two other tutorials:
- Reproducing the C. elegans spatial code runs the published analysis on the real data and compares against the pairs the paper reports.
- Customizing the objective function covers optimizing something other than a single dataset's distance correlation.
The reference to contrast the CCI potential to does not have to be a physical distance - any square matrix summarizing a relationship between every pair of cells works, so the same search applies to developmental, functional or phenotypic similarity.
Here we start from spatial transcriptomics, using the helpers that turn single-cell coordinates into cell-type distances.
Requirements. The genetic algorithm needs the optional dependency pygad:
pip install cell2cell[ga]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import cell2cell as c2c
from natsort import natsorted
%matplotlib inline
1. Synthetic Data¶
We build a small synthetic system where we know the answer in advance, so the search can be benchmarked.
Twelve cell types are arranged at different positions along an axis. Around each one we scatter single cells, which is what a spatial transcriptomics experiment would give us.
import anndata
rng = np.random.default_rng(0)
n_celltypes = 12
cells_per_type = 40
positions = np.linspace(0, 100, n_celltypes)
celltypes = ['CT-{}'.format(i + 1) for i in range(n_celltypes)]
coords, labels = [], []
for name, x in zip(celltypes, positions):
coords.append(np.column_stack([rng.normal(x, 2.5, cells_per_type),
rng.normal(0, 2.5, cells_per_type)]))
labels += [name] * cells_per_type
coords = np.vstack(coords)
adata = anndata.AnnData(X=np.zeros((len(coords), 1), dtype=float),
obs=pd.DataFrame({'celltype': labels},
index=['cell-{}'.format(i) for i in range(len(coords))]))
adata.obsm['spatial'] = coords
adata
AnnData object with n_obs × n_vars = 480 × 1
obs: 'celltype'
obsm: 'spatial'
fig, ax = plt.subplots(figsize=(9, 2.4))
for name in celltypes:
mask = adata.obs['celltype'] == name
ax.scatter(adata.obsm['spatial'][mask, 0], adata.obsm['spatial'][mask, 1], s=8, label=name)
ax.set_xlabel('x'); ax.set_ylabel('y'); ax.set_title('Single cells coloured by cell type')
ax.legend(ncol=6, fontsize=7, loc='upper center', bbox_to_anchor=(0.5, -0.35))
plt.show()
Gene expression¶
Half of the ligand-receptor pairs are informative: the expression of their ligand and receptor follows a bump centred somewhere along the axis, so cell types close to each other co-express them. The other pairs are noise, with expression unrelated to position.
A perfect search would keep the informative pairs and drop the rest.
n_informative, n_noise = 12, 48
genes, profiles, pairs = [], [], []
informative_centres = rng.uniform(0, 100, n_informative) # kept for section 6
for k in range(n_informative):
centre = informative_centres[k]
bump = 200 * np.exp(-((positions - centre) ** 2) / (2 * 22.0 ** 2))
for tag in ('L', 'R'):
genes.append('{}info{}'.format(tag, k))
profiles.append(bump * rng.uniform(0.8, 1.2, n_celltypes))
pairs.append(('Linfo{}'.format(k), 'Rinfo{}'.format(k)))
for k in range(n_noise):
for tag in ('L', 'R'):
genes.append('{}noise{}'.format(tag, k))
profiles.append(rng.uniform(0, 200, n_celltypes))
pairs.append(('Lnoise{}'.format(k), 'Rnoise{}'.format(k)))
rnaseq = pd.DataFrame(np.vstack(profiles), index=genes, columns=celltypes)
lr_pairs = pd.DataFrame(pairs, columns=['A', 'B'])
print('expression :', rnaseq.shape)
print('LR pairs :', lr_pairs.shape[0], '({} informative, {} noise)'.format(n_informative, n_noise))
rnaseq.iloc[:4, :6].round(1)
expression : (120, 12) LR pairs : 60 (12 informative, 48 noise)
| CT-1 | CT-2 | CT-3 | CT-4 | CT-5 | CT-6 | |
|---|---|---|---|---|---|---|
| Linfo0 | 203.1 | 165.8 | 179.9 | 130.3 | 96.3 | 46.2 |
| Rinfo0 | 161.2 | 163.5 | 171.1 | 124.4 | 82.6 | 58.6 |
| Linfo1 | 89.0 | 144.3 | 181.4 | 165.1 | 215.2 | 146.7 |
| Rinfo1 | 87.3 | 152.3 | 177.2 | 189.7 | 170.9 | 139.9 |
2. Reference distances from the single-cell coordinates¶
cell2cell.spatial.celltype_distances summarizes the distance between two cell types from the
coordinates of their single cells. It takes an AnnData (reading obsm) or a plain dataframe, and
offers several ways to summarize:
method |
what it measures |
|---|---|
'centroid' |
distance between the centroids of the two cell types |
'min' |
how close the two types get to each other |
'max' |
how far apart their most distant cells are |
'mean' / 'median' |
average / median over all pairs of their single cells |
Only 'centroid' has a cost independent of how many single cells there are, so it is the one to
reach for on large datasets — the others evaluate all pairs of cells.
The key holding the coordinates is configurable with spatial_key, since objects written by
different tools use different ones ('spatial', 'X_spatial', ...).
centroids = c2c.spatial.celltype_centroids(adata, group_col='celltype', spatial_key='spatial')
centroids.round(2).head()
| X | Y | |
|---|---|---|
| celltype | ||
| CT-1 | -0.15 | 0.75 |
| CT-2 | 9.10 | -0.42 |
| CT-3 | 18.19 | -0.53 |
| CT-4 | 27.41 | -0.61 |
| CT-5 | 36.45 | -0.19 |
for method in ['centroid', 'min', 'mean', 'median', 'max']:
d = c2c.spatial.celltype_distances(adata, group_col='celltype', method=method)
print('{:<9} CT-1 to CT-12 = {:6.2f}'.format(method, d.loc['CT-1', 'CT-12']))
centroid CT-1 to CT-12 = 100.01 min CT-1 to CT-12 = 91.64 mean CT-1 to CT-12 = 100.08 median CT-1 to CT-12 = 100.16 max CT-1 to CT-12 = 109.19
reference_distances = c2c.spatial.celltype_distances(adata, group_col='celltype',
method='centroid')
reference_distances.iloc[:5, :5].round(1)
| celltype | CT-1 | CT-2 | CT-3 | CT-4 | CT-5 |
|---|---|---|---|---|---|
| celltype | |||||
| CT-1 | 0.0 | 9.3 | 18.4 | 27.6 | 36.6 |
| CT-2 | 9.3 | 0.0 | 9.1 | 18.3 | 27.3 |
| CT-3 | 18.4 | 9.1 | 0.0 | 9.2 | 18.3 |
| CT-4 | 27.6 | 18.3 | 9.2 | 0.0 | 9.0 |
| CT-5 | 36.6 | 27.3 | 18.3 | 9.0 | 0.0 |
3. The baseline: using every ligand-receptor pair¶
Before searching, we measure how well the CCI distances reproduce the reference when all pairs are used. The genetic algorithm has to beat this.
The objective is the absolute Spearman correlation between the two distance matrices, in their condensed (upper-triangle) form.
import scipy.spatial
from cell2cell.analysis import correlation_fitness
from cell2cell.core import PreparedCCIScorer
from cell2cell.preprocessing import bidirectional_index
from cell2cell.core.interaction_space import InteractionSpace
analysis_setup = {'communication_score': 'expression_thresholding',
'cci_score': 'bray_curtis',
'cci_type': 'undirected'}
cutoff_setup = {'type': 'constant_value', 'parameter': 50}
bi_ppi = c2c.preprocessing.bidirectional_ppi_for_cci(lr_pairs, verbose=False)
interaction_space = InteractionSpace(rnaseq_data=rnaseq, ppi_data=bi_ppi,
gene_cutoffs=cutoff_setup,
communication_score=analysis_setup['communication_score'],
cci_score=analysis_setup['cci_score'],
cci_type=analysis_setup['cci_type'], verbose=False)
scorer = PreparedCCIScorer(interaction_space)
source = bidirectional_index(lr_pairs, verbose=False)
reference_vector = scipy.spatial.distance.squareform(reference_distances.values, checks=False)
all_pairs = np.ones(len(lr_pairs))
baseline_distance = scorer.distance_batch(all_pairs[source][None, :])[0]
baseline = correlation_fitness(scipy.spatial.distance.squareform(baseline_distance, checks=False),
reference_vector)
print('Spearman correlation using all {} pairs: {:.4f}'.format(len(lr_pairs), baseline))
Spearman correlation using all 60 pairs: 0.7677
4. Running the genetic algorithm¶
Each individual is a binary vector with one entry per ligand-receptor pair, saying whether it is
included. optimize_lr_pairs evolves a population of these and keeps the best.
The search happens in successive runs: each run keeps only the pairs the previous one selected,
so the set shrinks. With runs=None it stops once the objective improves by less than
inc_percentage.
results = c2c.analysis.optimize_lr_pairs(rnaseq_data=rnaseq,
ppi_data=lr_pairs,
reference_distances=reference_distances,
cutoff_setup=cutoff_setup,
analysis_setup=analysis_setup,
population_size=60,
generations=40,
runs=10,
random_state=888)
summary = pd.DataFrame([{'run': k,
'objective': results[k]['obj_fn'],
'pairs kept': results[k]['n_selected'],
'dropped': '{:.0%}'.format(results[k]['drop_fraction'])}
for k in natsorted(results) if k.startswith('run')])
summary
| run | objective | pairs kept | dropped | |
|---|---|---|---|---|
| 0 | run1 | 0.948088 | 23 | 62% |
| 1 | run2 | 0.966627 | 14 | 39% |
| 2 | run3 | 0.972701 | 12 | 14% |
| 3 | run4 | 0.972701 | 12 | 0% |
| 4 | run5 | 0.972701 | 12 | 0% |
| 5 | run6 | 0.972701 | 12 | 0% |
| 6 | run7 | 0.972701 | 12 | 0% |
| 7 | run8 | 0.972701 | 12 | 0% |
| 8 | run9 | 0.972701 | 12 | 0% |
| 9 | run10 | 0.972701 | 12 | 0% |
Every run improves on the baseline, and the set of pairs shrinks as it goes. Each run or iteration starts with the previous selected list as the initial input, so we can observed that by the third run we already reached the optimal combination of LR pairs.
Because we know which pairs were informative, we can check what the search recovered.
for key in sorted(k for k in results if k.startswith('run')):
kept = lr_pairs.loc[np.asarray(results[key]['ppi_data'], dtype=bool)]
informative = kept['A'].str.startswith('Linfo').mean()
print('{}: objective {:.4f} | {:>2} pairs kept | {:.0%} of them informative'
.format(key, results[key]['obj_fn'], len(kept), informative))
best = results['best_ppi_data']
print('\nBest run:', results['best_run'], '-> objective {:.4f}'.format(results['best_obj_fn']))
best
run1: objective 0.9481 | 23 pairs kept | 43% of them informative run10: objective 0.9727 | 12 pairs kept | 75% of them informative run2: objective 0.9666 | 14 pairs kept | 71% of them informative run3: objective 0.9727 | 12 pairs kept | 75% of them informative run4: objective 0.9727 | 12 pairs kept | 75% of them informative run5: objective 0.9727 | 12 pairs kept | 75% of them informative run6: objective 0.9727 | 12 pairs kept | 75% of them informative run7: objective 0.9727 | 12 pairs kept | 75% of them informative run8: objective 0.9727 | 12 pairs kept | 75% of them informative run9: objective 0.9727 | 12 pairs kept | 75% of them informative Best run: run3 -> objective 0.9727
| A | B | |
|---|---|---|
| 0 | Linfo0 | Rinfo0 |
| 1 | Linfo1 | Rinfo1 |
| 2 | Linfo2 | Rinfo2 |
| 3 | Linfo3 | Rinfo3 |
| 4 | Linfo6 | Rinfo6 |
| 5 | Linfo7 | Rinfo7 |
| 6 | Linfo8 | Rinfo8 |
| 7 | Linfo9 | Rinfo9 |
| 8 | Linfo11 | Rinfo11 |
| 9 | Lnoise0 | Rnoise0 |
| 10 | Lnoise25 | Rnoise25 |
| 11 | Lnoise41 | Rnoise41 |
We can plot the objective function score (Spearman correlation) per run:
runs = natsorted(k for k in results if k.startswith('run'))
objectives = [results[k]['obj_fn'] for k in runs]
fig, ax = plt.subplots(figsize=(5, 3))
ax.axhline(baseline, color='grey', ls='--', label='all pairs')
ax.plot(range(1, len(runs) + 1), objectives, 'o-', color='steelblue', label='selected pairs')
ax.set_xlabel('run'); ax.set_ylabel('|Spearman| vs reference')
ax.set_xticks(range(1, len(runs) + 1)); ax.legend()
plt.tight_layout(); plt.show()
How the distances line up¶
Plotting the CCI distances against the reference shows what the search bought.
best_mask = np.zeros(len(lr_pairs))
best_mask[np.asarray(results[results['best_run']]['ppi_data'], dtype=bool)] = 1.0
best_distance = scorer.distance_batch(best_mask[source][None, :])[0]
fig, axes = plt.subplots(1, 2, figsize=(9, 3.6), sharey=True)
for ax, matrix, title in zip(axes, [baseline_distance, best_distance],
['All pairs', 'Selected pairs']):
vector = scipy.spatial.distance.squareform(matrix, checks=False)
rho = correlation_fitness(vector, reference_vector)
ax.scatter(reference_vector, vector, s=12, alpha=0.6, color='steelblue')
ax.set_title('{} ($|\\rho|$ = {:.3f})'.format(title, rho))
ax.set_xlabel('reference distance')
axes[0].set_ylabel('CCI distance')
plt.tight_layout(); plt.show()
5. Several datasets at once¶
Everything so far used one dataset. In practice there are usually several: slides, donors, conditions. A genetic algorithm converges to a local optimum, so a set fitted to one of them is partly fitted to its noise.
With ~20 cell types the objective has only 20 × 19 / 2 = 190 points, while the search has ~60
binary variables to fit them with. That is a lot of freedom, and the way to remove it is to demand
the same set work everywhere.
We build three "donors": the same planted spatial code, measured with independent noise.
def make_donor(seed):
"""The same informative pairs, re-measured with independent noise."""
rng_d = np.random.default_rng(seed)
profiles = []
for k in range(n_informative):
centre = informative_centres[k]
bump = 200 * np.exp(-((positions - centre) ** 2) / (2 * 22.0 ** 2))
for _ in ('L', 'R'):
profiles.append(bump * rng_d.uniform(0.8, 1.2, n_celltypes))
for _ in range(n_noise):
for _ in ('L', 'R'):
profiles.append(rng_d.uniform(0, 200, n_celltypes))
donor = pd.DataFrame(np.vstack(profiles), index=genes, columns=celltypes)
jitter = coords + rng_d.normal(0, 1.5, coords.shape)
donor_adata = anndata.AnnData(X=np.zeros((len(jitter), 1)), obs=adata.obs.copy())
donor_adata.obsm['spatial'] = jitter
donor_ref = c2c.spatial.celltype_distances(donor_adata, group_col='celltype', method='centroid')
return donor, donor_ref
donors = [make_donor(seed) for seed in (101, 202, 303)]
print('three donors, each {} genes x {} cell types'.format(*donors[0][0].shape))
three donors, each 120 genes x 12 cell types
One search per donor: the selections disagree¶
def informative_fraction(selected):
return selected['A'].str.startswith('Linfo').mean() if len(selected) else 0.0
# Settings that do not depend on the objective. `cutoff_setup` and `analysis_setup`
# are deliberately kept out: passing them alongside an `objective` is rejected,
# because the objective already carries them.
search = dict(ppi_data=lr_pairs, population_size=60, generations=40,
runs=2, random_state=888)
setups = dict(cutoff_setup=cutoff_setup, analysis_setup=analysis_setup)
per_donor = []
for i, (donor_rnaseq, donor_ref) in enumerate(donors, start=1):
r = c2c.analysis.optimize_lr_pairs(rnaseq_data=donor_rnaseq,
reference_distances=donor_ref, **setups, **search)
kept = r['best_ppi_data']
per_donor.append(set(map(tuple, kept[['A', 'B']].values)))
print('donor {}: objective {:.3f} | {:>2} pairs | {:.0%} informative'
.format(i, r['best_obj_fn'], len(kept), informative_fraction(kept)))
shared = set.intersection(*per_donor)
print('\npairs selected by all three donors: {} of {}'
.format(len(shared), len(set.union(*per_donor))))
donor 1: objective 0.970 | 9 pairs | 89% informative donor 2: objective 0.966 | 18 pairs | 61% informative donor 3: objective 0.953 | 16 pairs | 50% informative pairs selected by all three donors: 6 of 25
Pooling the objective instead¶
CombinedObjective evaluates one candidate list of LR pairs against every donor and combines the result, so the
search is driven towards pairs that work everywhere. The same ~60 variables now face 3 × 190
constraints rather than 190.
factories = [c2c.analysis.CorrelationObjective(rnaseq_data=donor_rnaseq,
reference_distances=donor_ref,
cutoff_setup=cutoff_setup,
analysis_setup=analysis_setup)
for donor_rnaseq, donor_ref in donors]
pooled = c2c.analysis.optimize_lr_pairs(
objective=c2c.analysis.CombinedObjective(factories), **search)
kept = pooled['best_ppi_data']
print('pooled across 3 donors: objective {:.3f} | {:>2} pairs | {:.0%} informative'
.format(pooled['best_obj_fn'], len(kept), informative_fraction(kept)))
pooled across 3 donors: objective 0.946 | 15 pairs | 67% informative
Penalising disagreement¶
sd_penalty subtracts a multiple of the standard deviation across donors, so a set that is
uniformly decent is preferred over one that is excellent on some donors and poor on others.
evaluate_components reports the per-donor fitness of a solution, which the combined number alone
cannot tell you.
pool = pooled['pool']
def per_donor_fitness(mask, penalty=0.0):
bound = c2c.analysis.CombinedObjective(factories, sd_penalty=penalty)(pool)
return bound.evaluate_components(np.atleast_2d(np.asarray(mask, dtype=float)))[:, 0]
penalised = c2c.analysis.optimize_lr_pairs(
objective=c2c.analysis.CombinedObjective(factories, sd_penalty=1.0), **search)
for label, res in (('no penalty', pooled), ('sd_penalty=1.0', penalised)):
components = per_donor_fitness(res[res['best_run']]['ppi_data'])
print('{:<16} per-donor rho {} mean {:.3f} sd {:.3f} | {:.0%} informative'
.format(label, np.round(components, 3), components.mean(), components.std(),
informative_fraction(res['best_ppi_data'])))
no penalty per-donor rho [0.958 0.94 0.941] mean 0.946 sd 0.008 | 67% informative sd_penalty=1.0 per-donor rho [0.946 0.942 0.94 ] mean 0.943 sd 0.002 | 77% informative
Leave-one-donor-out¶
The check most worth doing, and the one most often skipped. Select on two donors, evaluate on the third. If the selected set does not beat a random set of the same size out of sample, it fitted noise — and with 190 points per donor that is a live possibility rather than a formality.
rng_lodo = np.random.default_rng(0)
for held_out in range(3):
train = [f for i, f in enumerate(factories) if i != held_out]
fitted = c2c.analysis.optimize_lr_pairs(
objective=c2c.analysis.CombinedObjective(train), **search)
mask = np.asarray(fitted[fitted['best_run']]['ppi_data'], dtype=float)
test = factories[held_out](fitted['pool'])
selected = test(mask[None, :])[0]
sizes = int(mask.sum())
random_masks = np.zeros((100, len(mask)))
for row in random_masks:
row[rng_lodo.choice(len(mask), sizes, replace=False)] = 1.0
random_scores = test(random_masks)
print('held-out donor {}: selected {:.3f} | random sets of {} {:.3f} +/- {:.3f} | all pairs {:.3f}'
.format(held_out + 1, selected, sizes, random_scores.mean(), random_scores.std(),
test(np.ones((1, len(mask))))[0]))
held-out donor 1: selected 0.902 | random sets of 12 0.364 +/- 0.183 | all pairs 0.722 held-out donor 2: selected 0.886 | random sets of 14 0.575 +/- 0.163 | all pairs 0.782 held-out donor 3: selected 0.911 | random sets of 14 0.471 +/- 0.196 | all pairs 0.811
The held-out score should sit clearly above random sets of the same size. If it does not, the selection is not carrying signal that generalises, whatever the training objective says.
Next: reproducing the C. elegans spatial code on real data, or customizing the objective function.