Spatial Cell-Cell Communication
Overview
Expression-based ligand-receptor (LR) inference ranks pairs that could
signal; spatial data adds whether sender and receiver actually sit next to
each other. This skill runs one inference (LIANA consensus by default, or
CellPhoneDB statistics), puts the result into one table, keeps interactions
whose cell types are spatial neighbours, scores LR co-expression at each
location, and narrows the table to the user's question with an evidence class
per candidate. Expression of both partners never proves signalling: every
result is a candidate.
| Question | Route |
|---|
| Which LR pairs between my cell types, and are the types neighbours | Workflow Steps 1-6 |
| Permutation p-values per pair, DEG-driven or microenvironment-restricted tests | Route: CellPhoneDB v5 Statistics, in place of Step 2 |
| Where on the tissue an LR pair is co-expressed (labels not needed) | Step 5; p-value/category layers in Route: LIANA Multi-sample |
| Communication differences across samples or conditions | Route: LIANA Multi-sample (by-sample ranking, Tensor-cell2cell) |
| What in the neighbourhood explains cell type composition | Route: LIANA Multi-sample (MISTy) |
| Literature support and which candidates to validate | After Step 6, Route: Literature Evidence |
All routes are in ## Task Routes below the Workflow.
When to Use
- Finding which annotated cell types may signal to each other in a spatial section
- Separating interactions between neighbouring cell types from those between distant ones
- Testing which LR pairs are specific to a sender-receiver pair with CellPhoneDB p-values
- Mapping where on the tissue a ligand and its receptor are co-expressed, also on Visium spots
- Asking about one pathway family (immune checkpoints, chemokines, Notch, Wnt, TGF-beta) after inference
- Comparing communication programs across samples or conditions
- For neighbourhood enrichment or co-occurrence alone, without LR pairs, use
spatial-transcriptomics-analysis-squidpy
Prerequisites
- Input: log-normalised expression in
X (not z-scored) and obsm["spatial"]
- Labels: a cell type column in
obs for Steps 2-4 and 6
(spatial-transcriptomics-cell-type-annotation). Visium spots have no labels:
Step 5 works without them; Steps 2-4 need a deconvolution first
(spatial-transcriptomics-deconvolution-cell2location)
- Genes: gene symbols; CellPhoneDB is human-only (mouse via orthologs, see its reference)
- Python packages:
liana, squidpy, scanpy; cellphonedb, cell2cell only for their routes
# Install only what `import` reports missing. `--pypi` keeps the env's scanpy/numba
# stack; a conda add can swap it and bare pip can shadow it.
pixi add --pypi liana squidpy scanpy
Pre-flight Interview
decisions:
- id: D1
param: CELL_TYPE_KEY
kind: required
source: data
ask: "Which column holds the cell types whose communication you want to study?"
default: null
- id: D2
param: RESOURCE
kind: required
source: data
ask: "The gene symbols look <human/mouse>: use the <human/mouse> ligand-receptor database?"
default: "consensus for human symbols, mouseconsensus for mouse symbols"
- id: D3
param: METHOD
kind: required
source: user
ask: "Rank interactions by agreement across several methods (LIANA consensus, recommended), or do you need CellPhoneDB permutation p-values per pair (slower, human symbols)?"
default: "LIANA consensus"
- id: D4
param: QUERY
kind: optional
source: user
ask: "Is there a pathway (immune checkpoints, chemokines, growth factors, Notch, Wnt, TGF-beta) or a sender -> receiver direction you care about?"
default: "all pathways and all cell type pairs"
- id: D5
param: SAMPLE_KEY
kind: required
source: data
skip_if: "the data holds a single sample"
ask: "Which column identifies the samples, and which one the condition to compare?"
default: null
- id: D6
param: expr_prop
kind: optional
source: user
ask: "Minimum fraction of a cell type that must express each partner gene?"
default: "0.1"
- id: D7
param: NEIGHBOUR_Z
kind: optional
source: user
ask: "How strongly must two cell types co-locate to count as neighbours?"
default: "neighbourhood z > 2 over 15 nearest neighbours"
- id: D8
param: BANDWIDTH
kind: optional_conditional
source: data
ask: "The coordinates are in <microns/pixels>; how wide should the local neighbourhood be?"
default: "from li.pp.query_bandwidth: a few to tens of neighbours per location"
- id: D9
param: REGION_KEY
kind: optional_conditional
source: data
depends_on: [D3]
skip_if: "METHOD is LIANA consensus"
ask: "Should CellPhoneDB only test cell types that share a tissue region? If so, which region/niche column?"
default: "no restriction"
- id: D10
param: n_perms
kind: optional
source: user
ask: "How many permutations for p-values?"
default: "1000 for neighbourhood/CellPhoneDB tests, 100 for local LR scores"
Ask D1-D3 together, with D5 when obs has a sample-like column. Offer the
obs columns that look like cell type labels for D1; if none exist on
single-cell-resolution data, stop and annotate first. On Visium without a
deconvolution, say that only Step 5 can run.
Workflow
- Inputs, organism and panel coverage
- Rank LR pairs between cell types
- Harmonise results into one table
- Keep spatially adjacent pairs
- Local LR co-expression on the tissue
- Focus on the question and classify candidates
Step 1: Inputs and Panel Coverage
import liana as li
import numpy as np
import pandas as pd
import scanpy as sc
adata = sc.read_h5ad("celltype_annotated.h5ad")
CELL_TYPE_KEY = "cell_type" # D1
adata.obs[CELL_TYPE_KEY] = adata.obs[CELL_TYPE_KEY].astype("category")
print(adata.obs[CELL_TYPE_KEY].value_counts())
head = adata.var_names[:200] # mouse "Actb" vs human "ACTB"
n_mouse = sum(len(g) > 1 and g[0].isupper() and g[1:].islower() for g in head)
RESOURCE = "mouseconsensus" if n_mouse > len(head) / 2 else "consensus" # D2
print(f"resource: {RESOURCE}; max X = {adata.X.max():.1f} (expect < ~15 for log data)")
resource = li.rs.select_resource(RESOURCE)
genes = set(adata.var_names)
def present(complex_):
return all(g in genes for g in complex_.split("_"))
covered = resource[resource["ligand"].map(present) & resource["receptor"].map(present)]
print(f"LR pairs measurable: {len(covered)} of {len(resource)}")
A targeted panel (Xenium ~400 genes) measures only a small share of the
resource. Report that coverage with the results: absent pairs were not
measured, not ruled out.
Step 2: Rank LR Pairs Between Cell Types
LIANA consensus (magnitude_rank, specificity_rank; lower = stronger,
aggregated over CellPhoneDB, CellChat, NATMI, Connectome and others). For
CellPhoneDB statistics, run Route: CellPhoneDB v5 Statistics instead and continue
at Step 3 with SOURCE = "cellphonedb".
li.mt.rank_aggregate(adata, groupby=CELL_TYPE_KEY, resource_name=RESOURCE,
expr_prop=0.1, use_raw=False, key_added="liana_res", verbose=True)
res = adata.uns["liana_res"]
res.to_csv("liana_results.csv", index=False)
print(res.sort_values("magnitude_rank").head(15)[
["source", "target", "ligand_complex", "receptor_complex", "magnitude_rank", "specificity_rank"]])
p = li.pl.dotplot(adata=adata, colour="magnitude_rank", size="specificity_rank",
inverse_colour=True, inverse_size=True, top_n=20,
orderby="magnitude_rank", orderby_ascending=True, figure_size=(10, 8),
return_fig=True) # restrict with source_labels=[...], target_labels=[...]
p.save("liana_dotplot.png", dpi=150)
| Column | Meaning |
|---|
source / target | Sender (ligand) / receiver (receptor) cell type |
ligand_complex / receptor_complex | Genes; complexes joined by _ |
lr_means | Mean of ligand and receptor expression |
magnitude_rank / specificity_rank | Consensus ranks of strength / cell type specificity |
Step 3: Harmonise Results
One row per (sender, receiver, ligand, receptor) with a score and a p-value
or rank, from either tool, so Steps 4 and 6 run the same way:
SOURCE = "liana" # or "cellphonedb" (significant_long.csv from its reference)
TOP_RANK = 0.05 # LIANA magnitude_rank cutoff for "top" interactions
if SOURCE == "liana":
r = res[res["magnitude_rank"] <= TOP_RANK]
lr = pd.DataFrame({
"sender": r["source"], "receiver": r["target"],
"ligand": r["ligand_complex"], "receptor": r["receptor_complex"],
"score": r["lr_means"], "p_or_rank": r["magnitude_rank"], "specificity": r["specificity_rank"]})
else:
r = pd.read_csv("cellphonedb/significant_long.csv")
r = r[r["pval"] < 0.05]
for c in ("partner_a", "partner_b"):
r[c] = r[c].str.replace(r"^(simple|complex):", "", regex=True)
swap = r["receptor_a"].astype(bool) & ~r["receptor_b"].astype(bool) # orient ligand -> receptor
lr = pd.DataFrame({
"sender": np.where(swap, r["cell_b"], r["cell_a"]), "receiver": np.where(swap, r["cell_a"], r["cell_b"]),
"ligand": np.where(swap, r["partner_b"], r["partner_a"]),
"receptor": np.where(swap, r["partner_a"], r["partner_b"]),
"score": r["mean"], "p_or_rank": r["pval"], "specificity": r.get("score")})
lr["genes"] = (lr["ligand"] + "_" + lr["receptor"]).str.upper().str.split("_").map(set)
if SOURCE == "cellphonedb" and "genes" in r: # subunit genes of complexes (saved as "{'ITGA4', ...}")
import ast
lr["genes"] = [g | (ast.literal_eval(x) if isinstance(x, str) and x.startswith("{") else set())
for g, x in zip(lr["genes"], r["genes"])]
lr = lr.reset_index(drop=True)
print(f"{len(lr)} rows, {lr[['ligand', 'receptor']].drop_duplicates().shape[0]} LR pairs")
For LIANA lower p_or_rank / specificity is better; for CellPhoneDB
p_or_rank is a p-value and specificity the 0-100 interaction score
(higher is better). CellPhoneDB complex names (e.g. integrin_a4b1_complex)
are not symbols; the code adds their subunit genes.
Step 4: Keep Spatially Adjacent Pairs
import squidpy as sq
sq.gr.spatial_neighbors(adata, coord_type="generic", n_neighs=15) # Visium: coord_type="grid", n_rings=1
sq.gr.nhood_enrichment(adata, cluster_key=CELL_TYPE_KEY, n_perms=1000, seed=0)
cats = adata.obs[CELL_TYPE_KEY].cat.categories
z = pd.DataFrame(adata.uns[f"{CELL_TYPE_KEY}_nhood_enrichment"]["zscore"], index=cats, columns=cats)
z.to_csv("nhood_enrichment_z.csv")
NEIGHBOUR_Z = 2 # D7
lr["neighbour_z"] = [z.loc[s, t] if s in z.index and t in z.columns else np.nan
for s, t in zip(lr["sender"], lr["receiver"])]
lr["autocrine"] = lr["sender"] == lr["receiver"]
adjacent = lr[(lr["neighbour_z"] > NEIGHBOUR_Z) | lr["autocrine"]].sort_values("p_or_rank")
adjacent.drop(columns=["genes"]).to_csv("lr_adjacent_pairs.csv", index=False)
print(f"{len(adjacent)} of {len(lr)} top interactions are between neighbouring (or the same) cell types")
Autocrine pairs are always adjacent; read them separately. On Visium spots
the label-based test is coarse; prefer Step 5 there. Show the co-location
matrix the filter is based on:
sq.pl.nhood_enrichment(adata, cluster_key=CELL_TYPE_KEY, cmap="coolwarm", vmin=-10, vmax=10,
figsize=(7, 6), save="nhood_enrichment.png")
Step 5: Local LR Co-expression on the Tissue
Scores each LR pair at every location, so it also works on Visium spots
without labels. Pick the bandwidth from the coordinate scale first:
spatial_neighbors = li.pp.spatial_neighbors if hasattr(li, "pp") else li.ut.spatial_neighbors # li.ut in older liana
plot, bw_table = li.pp.query_bandwidth(coordinates=adata.obsm["spatial"], start=0, end=500, interval_n=20)
plot.save("liana_bandwidth.png", dpi=150)
print(bw_table.head(10)) # average neighbours per bandwidth: aim for a few to tens
BANDWIDTH = 100 # D8; coordinate units: ~50-100 um Xenium; Visium full-res px ~2-3 spot spacings
spatial_neighbors(adata, bandwidth=BANDWIDTH, cutoff=0.1, kernel="gaussian", set_diag=True)
lrdata = li.mt.bivariate(adata, resource_name=RESOURCE, local_name="cosine", global_name="morans",
n_perms=100, mask_negatives=False, add_categories=True, nz_prop=0.1,
use_raw=False, verbose=True)
summary = lrdata.var.sort_values("morans", ascending=False)
summary.to_csv("liana_bivariate_summary.csv")
lrdata.write_h5ad("liana_bivariate.h5ad")
print(summary.head(10))
sc.pl.embedding(lrdata, basis="spatial", color=summary.index[:4].tolist(), cmap="coolwarm", save="_lr_local.png")
var["morans"] is the global spatial co-pattern of ligand and receptor; the
per-location p-values (layers["pvals"]) and categories (layers["cats"]:
both high / one high / both low) are read in Route: LIANA Multi-sample.
Step 6: Focus and Classify Candidates
Narrow the table to the user's question (D4) and give each candidate an
evidence class. Matching is per gene symbol, so CD4 does not hit CD40.
PATHWAYS = {
"immune_checkpoint": ["PDCD1", "CD274", "PDCD1LG2", "CTLA4", "CD28", "CD80", "CD86",
"LAG3", "HAVCR2", "LGALS9", "TIGIT", "PVR", "NECTIN2"],
"chemokine": ["CXCL*", "CCL*", "CX3CL1", "XCL*", "CXCR*", "CCR*", "CX3CR1", "XCR1", "ACKR*"],
"growth_factor": ["EGF", "EGFR", "ERBB*", "VEGF*", "KDR", "FLT1", "FLT4", "FGF*", "FGFR*",
"PDGF*", "HGF", "MET", "IGF1", "IGF1R"],
"notch": ["NOTCH*", "JAG1", "JAG2", "DLL1", "DLL3", "DLL4"],
"wnt": ["WNT*", "FZD*", "LRP5", "LRP6", "RSPO*", "LGR*"],
"tgfb_bmp": ["TGFB*", "TGFBR*", "BMP*", "BMPR*", "ACVR*", "GDF*"],
}
def matches(genes, patterns):
return any(g.startswith(p[:-1]) if p.endswith("*") else g == p for g in genes for p in patterns)
for name, pats in PATHWAYS.items():
lr[name] = lr["genes"].map(lambda g: matches(g, pats))
lr["pathway"] = lr[list(PATHWAYS)].apply(lambda row: ",".join(k for k, v in row.items() if v), axis=1)
QUERY = list(PATHWAYS) # D4, e.g. ["immune_checkpoint", "chemokine"]
SENDERS, RECEIVERS = None, None # e.g. {"Tumor cell"}, {"T cell"}
q = lr[lr[QUERY].any(axis=1)]
if SENDERS: q = q[q["sender"].isin(SENDERS)]
if RECEIVERS: q = q[q["receiver"].isin(RECEIVERS)]
CONTACT = {"CD274", "PDCD1", "PDCD1LG2", "CTLA4", "CD28", "CD80", "CD86", "JAG1", "JAG2",
"DLL1", "DLL3", "DLL4", "NOTCH1", "NOTCH2", "NOTCH3", "NOTCH4", "TIGIT", "PVR", "NECTIN2"}
def evidence_class(row):
if pd.isna(row["neighbour_z"]):
return "no spatial test"
if row["neighbour_z"] > NEIGHBOUR_Z or row["autocrine"]:
return "co-located"
return "unlikely (contact, not co-located)" if row["ligand"].split("_")[0] in CONTACT else "possible paracrine"
q = q.assign(evidence_class=q.apply(evidence_class, axis=1))
ev = q.drop(columns=["genes"]).sort_values(["p_or_rank", "score"], ascending=[True, False])
ev.to_csv("lr_candidate_evidence.csv", index=False)
print(ev.groupby(["pathway", "evidence_class"]).size())
CONTACT is a starting list of membrane-bound partners; extend it for the
pathways in QUERY. Plot the top candidates:
import matplotlib.pyplot as plt
top = ev.head(30).copy()
top["lr"] = top["ligand"] + " -> " + top["receptor"]
top["pair"] = top["sender"].astype(str) + " -> " + top["receiver"].astype(str)
fig, ax = plt.subplots(figsize=(max(8, 0.4 * top["pair"].nunique()), max(6, 0.3 * top["lr"].nunique())))
s = ax.scatter(top["pair"], top["lr"], c=top["neighbour_z"], cmap="coolwarm", vmin=-5, vmax=5,
s=30 + 200 * top["score"] / top["score"].max())
plt.colorbar(s, label="neighbourhood z (sender-receiver)"); plt.xticks(rotation=90)
plt.tight_layout(); plt.savefig("lr_candidates.png", dpi=150); plt.close()
| Evidence pattern | Interpretation |
|---|
| Top-ranked / significant, co-located | Strong candidate; check literature (Route: Literature Evidence) |
| Top-ranked, not co-located, secreted ligand | Possible paracrine signal; weaker |
| Top-ranked, not co-located, contact-dependent | Unlikely in this tissue |
Only one partner above expr_prop | Not supported |
State validation routes (spatial protein staining, receptor blocking,
perturbation) for the top candidates.
Task Routes
Each route continues the Workflow session (same variable names). Only the calls
that differ from the Workflow, or that fail silently when written another way,
are shown.
Route: CellPhoneDB v5 Statistics
Scores each curated LR pair, including multi-subunit complexes, for every
ordered pair of cell types; the statistical method tests specificity by
shuffling labels. Run it in place of Step 2, write cellphonedb/significant_long.csv,
then continue at Step 3 with SOURCE = "cellphonedb". Install
pixi add --pypi cellphonedb.
| Method | Call | Use when |
|---|
| Simple | cpdb_analysis_method | Quick look, no statistics |
| Statistical | cpdb_statistical_analysis_method | Discovery; permutation p-values |
| DEG-based | cpdb_degs_analysis_method | Interactions driven by genes up in one cell type; result has relevant_interactions instead of pvalues |
Inputs and gotchas:
- Database: without GitHub access, download
cellphonedb.zip from the
ventolab/cellphonedb-data releases by hand into CPDB_DIR.
counts.h5ad holds normalised (log) expression, not z-scored, with human HGNC
symbols. Cell type names must not contain |, the result separator.
- Mouse: map symbols 1:1 to human first:
li.rs.get_hcop_orthologs(target_organism="mouse", columns=["human_symbol", "mouse_symbol"], min_evidence=3),
keep rows unique on both columns, rename var_names.
- Microenvironments (D9): a
cell_type / microenvironment table built from a
region column, a type counting as present above ~2% of the region's cells;
only pairs of co-located types are tested.
- DEG method:
degs.tsv with cluster / gene columns of up-regulated genes
(e.g. rank_genes_groups, adj p < 0.05, logFC > 0.25).
import os
from cellphonedb.src.core.methods import cpdb_statistical_analysis_method
from cellphonedb.utils import db_utils
CPDB_DIR = os.path.abspath("ref_cache/cellphonedb/v5.0.0")
CPDB_ZIP = os.path.join(CPDB_DIR, "cellphonedb.zip")
if not os.path.exists(CPDB_ZIP):
os.makedirs(CPDB_DIR, exist_ok=True)
db_utils.download_database(CPDB_DIR, "v5.0.0")
OUT = "cellphonedb"
os.makedirs(OUT, exist_ok=True)
sc.AnnData(X=adata.X, obs=pd.DataFrame(index=adata.obs_names),
var=pd.DataFrame(index=adata.var_names)).write_h5ad(f"{OUT}/counts.h5ad")
pd.DataFrame({"barcode_sample": adata.obs_names, "cell_type": adata.obs[CELL_TYPE_KEY].astype(str).values}
).to_csv(f"{OUT}/meta.tsv", sep="\t", index=False)
res = cpdb_statistical_analysis_method.call(
cpdb_file_path=CPDB_ZIP, meta_file_path=f"{OUT}/meta.tsv", counts_file_path=f"{OUT}/counts.h5ad",
counts_data="hgnc_symbol", microenvs_file_path=None, # or f"{OUT}/microenvs.tsv"
score_interactions=True, iterations=1000, threshold=0.1, threads=4, debug_seed=42, pvalue=0.05,
subsampling=False, # >~100k cells: True + subsampling_num_cells, subsampling_log=False
output_path=OUT, output_suffix="stat")
Long table read by Step 3 (cell_a expresses partner_a; Step 3 orients
ligand -> receptor from receptor_a / receptor_b):
sig = res["significant_means"]
labels = set(adata.obs[CELL_TYPE_KEY].astype(str))
pair_cols = [c for c in sig.columns if c.count("|") == 1 and set(c.split("|")) <= labels]
meta_cols = [c for c in ["id_cp_interaction", "interacting_pair", "partner_a", "partner_b", "secreted",
"receptor_a", "receptor_b"] if c in sig.columns]
long = sig.melt(id_vars=meta_cols, value_vars=pair_cols, var_name="pair", value_name="mean").dropna(subset=["mean"])
pv = res["pvalues"].melt(id_vars=["id_cp_interaction"], value_vars=pair_cols, var_name="pair", value_name="pval")
long = long.merge(pv, on=["id_cp_interaction", "pair"], how="left")
long[["cell_a", "cell_b"]] = long["pair"].str.split("|", expand=True)
if "interaction_scores" in res:
sc_long = res["interaction_scores"].melt(id_vars=["id_cp_interaction"], value_vars=pair_cols,
var_name="pair", value_name="score")
long = long.merge(sc_long, on=["id_cp_interaction", "pair"], how="left")
long["genes"] = long["id_cp_interaction"].map(
res["deconvoluted"].groupby("id_cp_interaction")["gene_name"].apply(lambda s: set(s.dropna())))
long.to_csv(f"{OUT}/significant_long.csv", index=False)
| Signal | Meaning |
|---|
| Low p-value, high mean | Expressed and specific to this cell type pair - strongest candidates |
| High mean, p >= 0.05 | Broadly expressed; not specific to this pair |
High interaction_scores (0-100) | Partners specific to these two types relative to all types |
secreted == True | Ligand can act at a distance; membrane-bound pairs need contact |
Two conditions: run the statistical method per condition with the same database
and parameters and compare the significant sets; this is descriptive, since cell
numbers per type differ between conditions and change each test's power.
ktplotspy (plot_cpdb_heatmap, plot_cpdb) draws publication-style figures
from res.
Route: LIANA Multi-sample, Local Score Layers, MISTy
Uses adata, CELL_TYPE_KEY, RESOURCE, BANDWIDTH and lrdata from the
Workflow. Functions follow the current li.pp / li.ms namespaces (older
releases: li.ut / li.multi).
- Per sample and condition (D5):
li.mt.rank_aggregate.by_sample(adata, groupby=CELL_TYPE_KEY, sample_key=SAMPLE_KEY, resource_name=RESOURCE, expr_prop=0.1, use_raw=False, n_perms=None, return_all_lrs=True), then pivot magnitude_rank by condition (median per
interaction). With few samples per condition this is descriptive; no p-values.
- Tensor-cell2cell (
pixi add --pypi cell2cell, several samples per
condition): factorises samples x LR x sender x receiver into patterns. Test a
factor's context loadings between conditions across samples (Mann-Whitney)
before calling it condition-specific; with few samples, exploratory.
- Local score layers of
lrdata (Step 5): X local score per location
(ligand^receptor), layers["pvals"] per-location permutation p-value,
layers["cats"] 1 = both high, -1 = one high one low, 0 = both low;
var["morans"] / var["morans_pvals"] global co-pattern. Plot layer="cats"
to separate true co-localisation (1) from one-partner regions (-1).
- MISTy: for each target (e.g. cell2location proportions) how well it is
predicted by the same spot (intra), direct neighbours (juxta) and the wider
area (para).
gain_R2 = what the spatial views add over intra; interactions
with view juxta/para name the predicting neighbours. Pass
extra=lrdata[:, top_lr] to ask whether local LR activity predicts
composition beyond composition itself.
- One method instead of the consensus:
li.mt.cellphonedb(adata, groupby=CELL_TYPE_KEY, resource_name=RESOURCE, expr_prop=0.1, use_raw=False, n_perms=1000, seed=0, key_added="cpdb_res") gives cellphone_pvals without installing cellphonedb.
import cell2cell as c2c
from collections import defaultdict
SAMPLE_KEY, CONDITION_KEY = "sample", "condition" # D5
li.mt.rank_aggregate.by_sample(adata, groupby=CELL_TYPE_KEY, sample_key=SAMPLE_KEY, resource_name=RESOURCE,
expr_prop=0.1, use_raw=False, n_perms=None, return_all_lrs=True)
tensor = li.ms.to_tensor_c2c(adata, sample_key=SAMPLE_KEY, score_key="magnitude_rank", how="outer_cells")
ctx = adata.obs[[SAMPLE_KEY, CONDITION_KEY]].drop_duplicates()
context_dict = defaultdict(lambda: "Unknown", zip(ctx[SAMPLE_KEY], ctx[CONDITION_KEY]))
meta = c2c.tensor.generate_tensor_metadata(interaction_tensor=tensor, metadata_dicts=[context_dict, None, None, None],
fill_with_order_elements=True)
tensor = c2c.analysis.run_tensor_cell2cell_pipeline(
tensor, meta, copy_tensor=True, rank=None, tf_optimization="regular", random_state=0, device="cpu",
elbow_metric="error", upper_rank=15, tf_init="random", sample_col="Element", group_col="Category",
output_fig=False) # rank=None: elbow search (slow); or fix e.g. 6
contexts = tensor.factors["Contexts"] # also "Ligand-Receptor Pairs", "Sender Cells", "Receiver Cells"
from liana.method import genericMistyData
from liana.method.sp import LinearModel
adata.obsm["compositions"] = pd.read_csv("cell2location_proportions.csv", index_col=0).loc[adata.obs_names]
comps = li.pp.obsm_to_adata(adata, "compositions")
misty = genericMistyData(intra=comps, cutoff=0.05, bandwidth=BANDWIDTH, n_neighs=6)
misty(model=LinearModel, k_cv=10, seed=1337, bypass_intra=False, verbose=True) # or RandomForestModel
misty.uns["target_metrics"].to_csv("misty_target_metrics.csv", index=False) # intra_R2, multi_R2, gain_R2
misty.uns["interactions"].to_csv("misty_interactions.csv", index=False) # target, predictor, view, importances
Route: Literature Evidence for Candidates
Adds PubMed support to the Step 6 candidate table (ev). NCBI E-utilities need
no key below 3 requests per second; cap the number of pairs queried. The judgment
is made from the evidence table, not by the code.
import time
import requests
EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi"
CONTEXT = "" # optional, e.g. "lung cancer" to count context-specific co-mentions
def pubmed_count(ligand, receptor):
a, b = ligand.split("_")[0], receptor.split("_")[0] # first subunit for complexes
term = f"{a}[tiab] AND {b}[tiab]" + (f" AND ({CONTEXT})" if CONTEXT else "")
r = requests.get(EUTILS, params={"db": "pubmed", "term": term, "retmode": "json", "retmax": 0}, timeout=30)
r.raise_for_status(); time.sleep(0.4)
return int(r.json()["esearchresult"]["count"])
pairs = ev[["ligand", "receptor"]].drop_duplicates().head(50)
pairs["pubmed_n"] = [pubmed_count(l, r) for l, r in zip(pairs["ligand"], pairs["receptor"])]
ev = ev.merge(pairs, on=["ligand", "receptor"], how="left")
ev["final_class"] = [("strong, known" if (n or 0) > 0 else "novel, co-located") if c == "co-located" else c
for c, n in zip(ev["evidence_class"], ev["pubmed_n"])]
ev.to_csv("lr_candidate_evidence.csv", index=False)
A zero count with strong statistics is a novelty candidate, not an error. To read
the papers, fetch abstracts with efetch.fcgi?db=pubmed&id=<ids>&rettype=abstract&retmode=text
for the ids of an esearch with retmax=5&sort=relevance.
Key Parameters
| Parameter | Default | Effect |
|---|
expr_prop | 0.1 | Min fraction of a cell type expressing each gene |
TOP_RANK | 0.05 | LIANA magnitude_rank cutoff for top interactions |
NEIGHBOUR_Z | 2 | How strongly two types must co-locate |
n_neighs | 15 | Size of the spatial neighbourhood (cells) |
BANDWIDTH | 100 | Spatial kernel for local scores, in coordinate units |
nz_prop | 0.1 | Min fraction of locations expressing each partner (local scores) |
QUERY / SENDERS / RECEIVERS | all | Restricts the candidate table to the question |
threshold (CellPhoneDB) | 0.1 | Min fraction of a cell type expressing each partner gene |
iterations (CellPhoneDB) | 1000 | Label permutations; fewer = coarser p-values |
pvalue | 0.05 | Significance cutoff for significant_means |
MIN_FRAC (microenvs) | 0.02 | Presence cutoff for a type in a region |
| DEG cutoffs (CellPhoneDB DEG method) | adj p < 0.05, logFC > 0.25 | Which genes count as up-regulated |
subsampling_num_cells | 1/3 of cells | Geometric sketching size for large datasets |
rank (Tensor-cell2cell) | None (elbow) | Number of communication patterns; fix 3-6 when the elbow search is slow |
how (Tensor-cell2cell) | "outer_cells" | Keep cell types missing in some samples |
bandwidth (MISTy) | BANDWIDTH | Para view kernel width, in coordinate units |
k_cv (MISTy) | 10 | Cross-validation folds per target |
CONTEXT | empty | Restricts literature counts to a disease/tissue |
| Pairs queried | 50 | Number of PubMed requests |
| Request delay | 0.4 s | Stays under the 3 requests/second limit |
Common Recipes
Recipe: Sender-Receiver Count Map
import seaborn as sns
topn = res[(res["magnitude_rank"] <= 0.05) & (res["specificity_rank"] <= 0.05)]
counts = topn.groupby(["source", "target"]).size().unstack(fill_value=0)
fig, ax = plt.subplots(figsize=(9, 8))
sns.heatmap(counts, cmap="YlOrRd", annot=True, fmt="d", ax=ax)
ax.set_xlabel("receiver"); ax.set_ylabel("sender")
plt.tight_layout(); plt.savefig("liana_sender_receiver_counts.png", dpi=150); plt.close()
Recipe: Distance Range of One Cell Type Pair
Neighbourhood enrichment is a yes/no at one scale; co-occurrence shows over
which distance two types stay together:
ANCHOR = "Macrophage" # a category of CELL_TYPE_KEY
sub = adata[np.random.default_rng(0).choice(adata.n_obs, min(adata.n_obs, 20000), replace=False)].copy()
occ, interval = sq.gr.co_occurrence(sub, cluster_key=CELL_TYPE_KEY, interval=50, copy=True)
sub_cats = list(sub.obs[CELL_TYPE_KEY].cat.categories)
dist = interval[1:] if len(interval) == occ.shape[2] + 1 else interval
curve = pd.DataFrame(occ[sub_cats.index(ANCHOR)].T, index=np.round(dist, 1), columns=sub_cats)
print(curve.iloc[:10].round(2)) # ratio > 1 at short distance = attraction
Recipe: Visium - Link Local LR Scores to Deconvolved Cell Types
abund = pd.read_csv("cell2location_abundance.csv", index_col=0).loc[lrdata.obs_names]
lr_top = summary.index[:20]
local = pd.DataFrame(np.asarray(lrdata[:, lr_top].X.todense()) if hasattr(lrdata.X, "todense")
else lrdata[:, lr_top].X, index=lrdata.obs_names, columns=lr_top)
corr = pd.DataFrame({ct: local.corrwith(abund[ct]) for ct in abund.columns}).round(2)
corr.to_csv("lr_local_vs_abundance.csv")
print(corr.apply(lambda r: ", ".join(f"{k} {v}" for k, v in r.nlargest(3).items()), axis=1))
Recipe: Are Ligand and Receptor Actually Expressed?
Aggregated scores can hide that only a few cells express a partner:
def frac_expr(cell_type, gene):
if gene not in adata.var_names:
return np.nan
x = adata[adata.obs[CELL_TYPE_KEY] == cell_type, gene].X
x = x.toarray() if hasattr(x, "toarray") else np.asarray(x)
return float((x > 0).mean())
ev_top = ev.head(30).copy()
ev_top["ligand_frac_sender"] = [frac_expr(s, l.split("_")[0]) for s, l in zip(ev_top["sender"], ev_top["ligand"])]
ev_top["receptor_frac_receiver"] = [frac_expr(r, rc.split("_")[0]) for r, rc in zip(ev_top["receiver"], ev_top["receptor"])]
print(ev_top[["sender", "receiver", "ligand", "receptor", "ligand_frac_sender", "receptor_frac_receiver"]].round(2))
Expected Outputs
liana_results.csv, liana_dotplot.png - LR ranking per sender-receiver pair
nhood_enrichment_z.csv, nhood_enrichment.png - cell type co-location z-scores
lr_adjacent_pairs.csv - top interactions whose cell types are neighbours
liana_bandwidth.png, liana_bivariate_summary.csv, liana_bivariate.h5ad - local LR scores
lr_candidate_evidence.csv, lr_candidates.png - candidates with pathway and evidence class
- CellPhoneDB:
ref_cache/cellphonedb/v5.0.0/cellphonedb.zip (cached database), cellphonedb/meta.tsv and counts.h5ad (inputs), cellphonedb/statistical_analysis_*_stat.txt (means, pvalues, significant_means, deconvoluted, interaction_scores), cellphonedb/significant_long.csv (read by Step 3)
- LIANA multi-sample / MISTy:
tensor.factors (context, LR, sender, receiver loadings), misty_target_metrics.csv, misty_interactions.csv
- Literature:
pubmed_n and final_class columns added to lr_candidate_evidence.csv
Troubleshooting
| Problem | Cause | Fix |
|---|
| Very few results | Targeted panel lacks LR genes | Report coverage (Step 1); do not extrapolate |
KeyError on cell type | Column not categorical, or label spelling differs between results and adata | astype("category"); use the same label column for inference and Step 4 |
| All local scores near 0 | Bandwidth smaller than spot/cell spacing | Re-read the query_bandwidth table; raise BANDWIDTH |
| Mouse genes not matched | Human resource | RESOURCE = "mouseconsensus" |
| Results dominated by housekeeping-like pairs | Data was z-scored or not normalised | Use normalised (log) expression |
| Slow permutations | Many cells | Lower n_perms for exploration, report it |
AttributeError: module 'liana' has no attribute 'pp' / ms | Older liana | Step 5 falls back to li.ut; elsewhere use li.ut.* / li.multi.*, or upgrade liana |
| Pathway query returns nothing | Pathway genes not on the panel, or mouse symbols | Check adata.var_names; genes are upper-cased for matching |
| Almost no interactions | Mouse or Ensembl IDs in var_names | Map to human HGNC symbols (ortholog step of the CellPhoneDB route) |
Some clusters/cell_types in microenvironments file are not present in metadata | Label mismatch between files | Build both files from the same cell type column |
| Very slow / out of memory | Many cells | subsampling=True with subsampling_num_cells; raise threads |
download_database fails | No network access to GitHub | Download cellphonedb.zip from the release page manually into CPDB_DIR |
| Pair columns missing a cell type | Type below threshold for all genes or absent from microenvs | Check value_counts; lower MIN_FRAC |
| Tensor factorization fails | Too few samples or cell types missing in many samples | how="outer_cells", fix rank low (3-4), add samples |
| MISTy juxta view error | squidpy missing | pixi add --pypi squidpy |
KeyError aligning compositions | Spot IDs differ between files | Align on adata.obs_names before obsm |
| PubMed HTTP 429 | Too many requests | Increase time.sleep; lower the number of pairs |
| Literature counts inflated | Gene symbol is a common word (e.g. MET, KIT) | Add CONTEXT or check abstracts |
| Complex pairs never match | CellPhoneDB complex names instead of genes | Use the subunit genes from Step 3 |
Bundled Resources
Merged from four former skills: spatial-transcriptomics-cell-cell-communication
(Steps 1-2, 4-5, recipes), cellphonedb-cell-cell-interaction (Route: CellPhoneDB
v5 Statistics; its adjacency recipe is Step 4), liana-spatial-cell-cell-communication
(Steps 1-2 and 5, Route: LIANA Multi-sample) and
spatial-single-cell-ligand-receptor-discovery (Steps 3, 4, 6, expression-fraction
recipe, Route: Literature Evidence).
Related Skills
spatial-transcriptomics-cell-type-annotation - labels for Steps 2-4
spatial-transcriptomics-deconvolution-cell2location - abundances for Visium
spatial-mapping-tangram - per-spot proportions or per-nucleus labels from a reference
spatial-domain-detection - region labels for CellPhoneDB microenvironments
spatial-transcriptomics-analysis-squidpy - further spatial statistics and squidpy ligrec
References