Excitatory Neuron Gene Programs in the Aging Human Brain
This tutorial walks through the full stGP pipeline on the human brain MERFISH dataset (Jeffries et al., Nature 2025), focusing on Excitatory Neurons (ext) as the target cell type.
Twelve tissue sections span donors aged 15–87 years. stGP decomposes gene expression in each slice into p latent programs—each with a non-negative gene loading vector (W), a cell-level activity score (H), a spatially smooth field (b), and an age-varying amplitude (α)—enabling joint discovery of spatial and age-related transcriptional programs in the aging brain.
1. Setup
[1]:
import sys, warnings, pickle
from pathlib import Path
import numpy as np
import pandas as pd
import scanpy as sc
import matplotlib.pyplot as plt
from IPython.display import display
from plots import set_nature_style
set_nature_style()
warnings.filterwarnings("ignore", category=FutureWarning)
sys.path.insert(0, "..")
# Paths
RAW_DATA_DIR = Path("/import/home2/share/byual/HumanBrainMERFISH+sc_Nature2025_Jeffries")
DATA_QC = Path("data/qc/human_merfish_qc.h5ad")
DATA_PROC = Path("data/processed")
RESULTS_DIR = Path("Results/stgp")
FIGURES_DIR = Path("Figure/ext")
FIGURES_DIR.mkdir(parents=True, exist_ok=True)
CELLTYPE = "ext" # target cell type for this tutorial
adata_ext = sc.read_h5ad(DATA_PROC / f"{CELLTYPE}.h5ad")
2. Fitting stGP
stGP models gene expression in each tissue slice as a sum of p latent programs. Each program is characterised by:
W (gene loadings,
p × G): non-negative gene weights defining the programH (cell scores,
N × p): overall activity of each program in each cellb (spatial field,
N × p): spatially smooth residual componentα (age effect,
p × S): how program amplitude varies across slices/agesθ (GP hyperparameters,
p × 2): spatial amplitude and noise fraction per program
The model rank p is selected automatically by greedy forward selection.
[2]:
import time
from stgp.estimation import fit_pfactor_auto
from stgp.kernels import (
bandwidth_select_spatial, bandwidth_select_temporal,
build_K_age, build_K_spa_list_from_stacked
)
from stgp.preprocessing import standardize_coords_list, log1p_norm_centered_list
OUT_DIR = RESULTS_DIR / CELLTYPE
OUT_DIR.mkdir(parents=True, exist_ok=True)
PKL_PATH = OUT_DIR / "stgp_result.pkl"
age_arr = pd.to_numeric(adata_ext.obs["age"], errors="coerce").to_numpy(float)
groups = adata_ext.obs["id_region"].astype(str).to_numpy()
uniq, inv = np.unique(groups, return_inverse=True)
idx_per_group = [np.sort(np.where(inv == t)[0]) for t in range(len(uniq))]
adata_temp = adata_ext.copy()
sc.pp.normalize_total(adata_temp, target_sum=1e3)
adata_prep = sc.pp.log1p(adata_temp, copy = True)
Y_list = [adata_prep.X[ix].toarray() for ix in idx_per_group]
Y_list, _ = log1p_norm_centered_list(Y_list, target_sum = 1000)
nlist = np.array([len(ix) for ix in idx_per_group])
ages = np.array([age_arr[ix[0]] for ix in idx_per_group])
sort_ord = np.argsort(ages); ages = ages[sort_ord]
slices = uniq.copy(); slices = slices[sort_ord]
nlist = nlist[sort_ord]
Y_list = [Y_list[i] for i in sort_ord]
[3]:
# ── Build GP kernels ─────────────────────────────────────────────────────
coords_list = standardize_coords_list([adata_ext.obsm["spatial"][ix] for ix in idx_per_group])
coords_list = [coords_list[i] for i in sort_ord]
gamma_spa = bandwidth_select_spatial(coords_list, frac=0.01, rho=0.6)
gamma_age = bandwidth_select_temporal(ages, rho=np.exp(-1.5))
print(f" gamma_spa = {gamma_spa:.4f} | gamma_age = {gamma_age:.4f}")
K_age = build_K_age(ages, gamma_age, kernel="rbf", standardize=True)
K_spa_list = build_K_spa_list_from_stacked(
np.vstack(coords_list), nlist, gamma_spa, standardize=False, jitter=1e-6
)
gamma_spa = 0.1473 | gamma_age = 1.0997
[4]:
if PKL_PATH.exists():
with open(PKL_PATH, "rb") as f:
res = pickle.load(f)
print(f"Loaded: {PKL_PATH}")
else:
t0 = time.perf_counter()
res = fit_pfactor_auto(
Y_list=Y_list, Nlist=nlist, K_age=K_age, Kspa_list=K_spa_list,
p_max=10, k=15,
inner_rank1_tol=1e-4, rel_improve_total_tol=0.002, backfit_tol=1e-4, prune_energy_frac = 0.005,
random_state=0, verbose=1,
)
print(f"Runtime: {time.perf_counter() - t0:.1f}s | programs selected: {res['W'].shape[0]}")
# ── Save results ─────────────────────────────────────────────────────────
res["gamma_age"] = gamma_age; res["gamma_spa"] = gamma_spa
with open(PKL_PATH, "wb") as f:
pickle.dump(res, f)
print(f"Saved: {PKL_PATH}")
Loaded: Results/stgp/ext/stgp_result.pkl
[5]:
ADATA_PATH = OUT_DIR / "adata_with_scores.h5ad"
age_arr = pd.to_numeric(adata_ext.obs["age"], errors="coerce").to_numpy(float)
groups = adata_ext.obs["id_region"].astype(str).to_numpy()
uniq, inv = np.unique(groups, return_inverse=True)
idx_per_group = [np.sort(np.where(inv == t)[0]) for t in range(len(uniq))]
# Apply the same age-ascending sort used during model fitting
_ages_raw = np.array([age_arr[ix[0]] for ix in idx_per_group])
sort_ord = np.argsort(_ages_raw)
idx_sorted = [idx_per_group[i] for i in sort_ord] # cell indices in age order
slices_sorted = uniq[sort_ord] # id_region in age order
ages_sorted = _ages_raw[sort_ord] # ages ascending
adata = adata_ext.copy()
all_idx = np.concatenate(idx_sorted) # res["H"] rows follow this order
H_arr = np.empty_like(res["H"]); H_arr[all_idx] = res["H"]
b_arr = np.empty_like(res["b"]); b_arr[all_idx] = res["b"]
adata.obsm["X_stgp"] = H_arr.astype(np.float32)
adata.obsm["X_stgp_spatial"] = b_arr.astype(np.float32)
adata.uns["stgp"] = dict(
groups=slices_sorted.tolist(), ages=ages_sorted.tolist(),
gamma_age=float(res["gamma_age"]), gamma_spa=float(res["gamma_spa"]),
p_selected=res["W"].shape[0],
alpha=np.asarray(res["alpha"]).tolist(),
alpha_lower=np.asarray(res["alpha_lower"]).tolist(),
alpha_upper=np.asarray(res["alpha_upper"]).tolist(),
theta=np.asarray(res["theta"]).tolist(),
sigma2e=float(res.get("sigma2e", np.nan)),
)
adata.write_h5ad(str(ADATA_PATH), compression="gzip")
print(f"Saved: {ADATA_PATH}")
# Also write W.csv for enrichment
p_sel = res["W"].shape[0]
W_df = pd.DataFrame(res["W"],
index=[f"stGP{j+1}" for j in range(p_sel)],
columns=adata.var_names.astype(str))
W_df.to_csv(OUT_DIR / "W.csv")
Saved: Results/stgp/ext/adata_with_scores.h5ad
[6]:
# ── Reload fitted outputs ──────────────────────────────────────────────────
ADATA_PATH = OUT_DIR / "adata_with_scores.h5ad"
adata = sc.read_h5ad(str(ADATA_PATH))
W_df = pd.read_csv(OUT_DIR / "W.csv", index_col=0)
stgp_info = adata.uns["stgp"]
p_sel = stgp_info["p_selected"]
slices = np.array(stgp_info["groups"])
W_df.index = [f"stGP{i+1}" for i in range(len(W_df))]
print(f"Loaded: {adata.n_obs} cells | {p_sel} programs | {len(slices)} slices")
Loaded: 61162 cells | 4 programs | 12 slices
3. Model Outputs
3.1 Gene Loadings (W matrix)
Each row of W is a non-negative weight vector over the measured genes. Positive-weight genes define the molecular identity of each program; the magnitude reflects the gene’s contribution to that program.
[7]:
W_df = pd.read_csv(OUT_DIR / "W.csv", index_col=0)
W_df.index = [f"stGP{i+1}" for i in range(len(W_df))]
print("Top 10 genes per program:")
for prog, row in W_df.iterrows():
top = row[row > 0].sort_values(ascending=False).head(10)
print(f" {prog}: {', '.join(top.index.tolist())}")
Top 10 genes per program:
stGP1: CBLN2, CUX2, LAMP5, GRIK4, COL19A1, NEUROD1, ONECUT2, SYN3, EPHB1, C1QL3
stGP2: CBLN2, FEZF2, HS3ST4, GRIK4, COL19A1, MOG, PDZRN4, NEUROD6, TBR1, SORCS3
stGP3: RORB, NEUROD6, PLCH1, CNTN5, ZMAT4, CUX2, SORCS2, PVALB, FEZF2, FAM241B
stGP4: AP1G2, NOXA1, HSF4, PDIA2, CORO6, RGS11, NEIL1, NPM2, TMEM145, CHRD
[8]:
from plots import plot_W_program_heatmap
fig = plot_W_program_heatmap(
W_df,
out=FIGURES_DIR / "W_heatmap_vertical.png",
dpi=400,
orientation="vertical",
)
[9]:
from plots import plot_W_program_heatmap
fig = plot_W_program_heatmap(
W_df,
out=FIGURES_DIR / "W_heatmap.png",
dpi=400, orientation = "horizontal"
)
3.2 Spatial Gene-Program Maps
The spatial field b captures the within-slice smooth variation of each program. We tile all tissue sections ordered by donor age so any age-related spatial patterns become visible.
[10]:
# CUX2: canonical L2/3 excitatory-neuron marker – used below as a reference layer
sc.pl.embedding(adata[adata.obs['age']==28].copy(), basis = 'spatial', color = 'CUX2', vmax = 10)
[11]:
from plots import plot_stgp_spatial_programs
scores_df = pd.DataFrame(
adata.obsm["X_stgp_spatial"],
index=adata.obs_names,
columns=[f"stGP{j+1}" for j in range(p_sel)],
)
figs = plot_stgp_spatial_programs(
stgp_adata=adata, scores=scores_df,
celltype="Excitatory Neuron", age_unit="years",
ncols=4, fg_dot_size=5.0, dpi=300,
)
for j, fig in enumerate(figs):
fig.savefig(FIGURES_DIR / f"spatial_stGP{j+1}.png", dpi=300, bbox_inches="tight")
plt.close(fig)
[12]:
figs[0]
[12]:
<Figure size 3120x2310 with 13 Axes>
3.3 Age Trajectories (α)
α(t) is the posterior mean age effect of each program — it quantifies how the program amplitude changes across the human lifespan (15–87 yr). The shaded band shows the 95% posterior credible interval.
[13]:
stgp_info = adata.uns['stgp']
ages_slices = np.array(stgp_info['ages'])
alpha = np.array(stgp_info['alpha']) # (p, n_slices)
alpha_lower = np.array(stgp_info['alpha_lower'])
alpha_upper = np.array(stgp_info['alpha_upper'])
COLOR = '#2C7FB8'
order = np.argsort(ages_slices)
t = ages_slices[order]
for j in range(p_sel):
a = alpha[j][order]
lo = alpha_lower[j][order]
hi = alpha_upper[j][order]
fig, ax = plt.subplots(figsize=(4.5, 4), constrained_layout=True)
ax.fill_between(t, lo, hi, alpha=0.18, color=COLOR)
ax.plot(t, lo, lw=0.8, ls='--', color=COLOR, alpha=0.55)
ax.plot(t, hi, lw=0.8, ls='--', color=COLOR, alpha=0.55)
ax.plot(t, a, lw=1.8, color=COLOR)
ax.scatter(t, a, s=32, color=COLOR, zorder=3, label='Posterior mean')
ax.axhline(0, color='0.6', lw=0.7, ls=':')
ax.set_xlabel('Age (yr)')
ax.set_ylabel('Age effect α')
ax.legend(fontsize=9)
fig.savefig(FIGURES_DIR / f"alpha_trajectory_stGP{j+1}.png", dpi=400, bbox_inches="tight")
plt.close(fig)
3.4 GP Parameters (θ)
[14]:
theta = np.asarray(stgp_info["theta"], dtype=float)
theta
[14]:
array([[ 38.81529414, 15.40473634],
[973.40129965, 12.72626063],
[223.0523848 , 11.20525559],
[ 84.11608287, 147.19819587]])
[15]:
theta = np.array(stgp_info['theta']) # (p, 2): [amplitude, noise_frac]
prog_names = [f'stGP{j+1}' for j in range(p_sel)]
prog_colors = plt.cm.tab10.colors[:p_sel]
fig, axes = plt.subplots(1, 2, figsize=(8, 3.5), constrained_layout=True)
for j, (col, name) in enumerate(zip(prog_colors, prog_names)):
axes[0].bar(j, theta[j, 0], color=col, edgecolor='white', linewidth=0.6)
axes[1].bar(j, theta[j, 1], color=col, edgecolor='white', linewidth=0.6)
for ax in axes:
ax.set_xticks(range(p_sel))
ax.set_xticklabels(prog_names, rotation=30, ha='right')
ax.set_xlabel('Program')
axes[0].set_ylabel(r'$\sigma_{\mathrm{age}}^2$')
axes[0].set_title(r'Temporal variance component ($\sigma_{\mathrm{age}}^2$)')
axes[1].set_ylabel(r'$\tau_{\mathrm{spa}}^2$')
axes[1].set_title(r'Spatial variance component ($\tau_{\mathrm{spa}}^2$)')
fig.savefig(FIGURES_DIR / "variance_components.png", dpi=400, bbox_inches="tight")
3.5 Spatial Visualisation of a Single Slice
We inspect one tissue slice in detail, showing the spatial b field of each program (the smooth within-slice component).
[16]:
slice_ages = [(adata.obs.loc[adata.obs['id_region'] == sid, 'age'].iloc[0], sid)
for sid in adata.obs['id_region'].unique()]
slice_ages.sort()
_, example_slice = slice_ages[len(slice_ages) // 2]
sub = adata[adata.obs['id_region'].astype(str) == example_slice].copy()
age_val = sub.obs['age'].iloc[0]
fig, axes = plt.subplots(1, p_sel, figsize=(4.5 * p_sel, 4.5), constrained_layout=True)
b = sub.obsm['X_stgp_spatial']
xy = np.asarray(sub.obsm['spatial'])
for j, ax in enumerate(np.atleast_1d(axes)):
v99 = np.nanpercentile(np.abs(b[:, j]), 99)
sc_ref = ax.scatter(xy[:, 0], xy[:, 1], c=b[:, j],
cmap='RdBu_r', vmin=-v99, vmax=v99,
s=10, linewidths=0, rasterized=True)
ax.set_aspect('equal'); ax.axis('off')
ax.set_title(f'stGP{j+1}')
plt.colorbar(sc_ref, ax=ax, shrink=0.7, pad=0.01)
fig.savefig(FIGURES_DIR / f"spatial_b_{example_slice}.png", dpi=400, bbox_inches="tight")
4. Benchmarking Analysis
The benchmarking logic is consolidated in benchmarking_ext.py so this notebook stays readable and the exported figure/source-data layout is generated from one maintained implementation.
This section compares stGP against STAMP, MEFISTO, Popari, and SpatialPCA using three complementary views:
marker-gene/program correlations for CUX2, RORB, and HS3ST4;
spatial embedding panels and representative slice panels;
clustering recovery against marker-derived layer labels and high-resolution
celltype2layer labels.
All outputs are written under Figure/ext/benchmark.
[17]:
from benchmarking_ext import BASELINE_OBSM_KEYS, LAYER_MARKERS, LAYER_SAFE, METHODS, run_ext_benchmarking
OUT_DIR = RESULTS_DIR / CELLTYPE
ADATA_PATH = OUT_DIR / "adata_with_scores.h5ad"
if "adata" not in globals() or "X_stgp_spatial" not in adata.obsm:
adata = sc.read_h5ad(ADATA_PATH)
if "adata_prep" not in globals():
adata_temp = adata_ext.copy()
sc.pp.normalize_total(adata_temp, target_sum=1e3)
adata_prep = sc.pp.log1p(adata_temp, copy=True)
if "slices" not in globals():
if "stgp" in adata.uns and "groups" in adata.uns["stgp"]:
slices = np.array(adata.uns["stgp"]["groups"])
else:
slice_ids = adata.obs["id_region"].astype(str)
slices = np.array(
sorted(
pd.unique(slice_ids),
key=lambda sid: float(adata.obs.loc[slice_ids == sid, "age"].iloc[0]),
)
)
BL_DIR = Path("Results/baselines")
baseline_adatas = {
"STAMP": sc.read_h5ad(BL_DIR / "stamp_k=3/ext/adata_with_scores.h5ad"),
"MEFISTO": sc.read_h5ad(BL_DIR / "mefisto/ext/adata_with_scores.h5ad"),
"Popari": sc.read_h5ad(BL_DIR / "popari/ext/res_popari.h5ad"),
"SpatialPCA": sc.read_h5ad(BL_DIR / "spatialpca/ext/adata_with_scores.h5ad"),
}
for method, obsm_key in BASELINE_OBSM_KEYS.items():
if obsm_key not in baseline_adatas[method].obsm:
raise KeyError(f"{method} is missing .obsm[{obsm_key!r}]")
if baseline_adatas[method].n_obs != adata.n_obs:
raise ValueError(f"{method} has {baseline_adatas[method].n_obs} cells; expected {adata.n_obs}")
input_summary = pd.DataFrame(
[
{"dataset": "stGP", "cells": adata.n_obs, "slices": len(slices), "embedding": "X_stgp_spatial"},
*[
{
"dataset": method,
"cells": baseline_adatas[method].n_obs,
"slices": baseline_adatas[method].obs["id_region"].nunique(),
"embedding": BASELINE_OBSM_KEYS[method],
}
for method in BASELINE_OBSM_KEYS
],
]
)
display(input_summary)
| dataset | cells | slices | embedding | |
|---|---|---|---|---|
| 0 | stGP | 61162 | 12 | X_stgp_spatial |
| 1 | STAMP | 61162 | 12 | X_stamp |
| 2 | MEFISTO | 61162 | 12 | X_mefisto |
| 3 | Popari | 61162 | 12 | X |
| 4 | SpatialPCA | 61162 | 12 | X_spatialpca |
[18]:
BENCHMARK_DIR = FIGURES_DIR / "benchmark"
benchmark_outputs = run_ext_benchmarking(
adata=adata,
adata_prep=adata_prep,
baseline_adatas=baseline_adatas,
benchmark_dir=BENCHMARK_DIR,
slices=slices,
methods=METHODS,
dpi=400,
)
output_counts = pd.Series(
{
"correlation_figures": len(benchmark_outputs["correlation_figures"]),
"spatial_figures": len(benchmark_outputs["spatial_figures"]),
"cluster_figures": len(benchmark_outputs["cluster_figures"]),
"metric_figures": len(benchmark_outputs["metric_figures"]),
"summary_figures": len(benchmark_outputs["summary_figures"]),
},
name="n_outputs",
)
print(f"Benchmark outputs saved under: {benchmark_outputs['benchmark_dir']}")
display(output_counts.to_frame())
/home/byual/.conda/envs/stGP/lib/python3.11/site-packages/sklearn/manifold/_spectral_embedding.py:324: UserWarning: Graph is not fully connected, spectral embedding may not work as expected.
warnings.warn(
/home/byual/.conda/envs/stGP/lib/python3.11/site-packages/sklearn/manifold/_spectral_embedding.py:324: UserWarning: Graph is not fully connected, spectral embedding may not work as expected.
warnings.warn(
/home/byual/.conda/envs/stGP/lib/python3.11/site-packages/sklearn/manifold/_spectral_embedding.py:324: UserWarning: Graph is not fully connected, spectral embedding may not work as expected.
warnings.warn(
/home/byual/.conda/envs/stGP/lib/python3.11/site-packages/sklearn/manifold/_spectral_embedding.py:324: UserWarning: Graph is not fully connected, spectral embedding may not work as expected.
warnings.warn(
/home/byual/.conda/envs/stGP/lib/python3.11/site-packages/sklearn/manifold/_spectral_embedding.py:324: UserWarning: Graph is not fully connected, spectral embedding may not work as expected.
warnings.warn(
/home/byual/.conda/envs/stGP/lib/python3.11/site-packages/sklearn/manifold/_spectral_embedding.py:324: UserWarning: Graph is not fully connected, spectral embedding may not work as expected.
warnings.warn(
/home/byual/.conda/envs/stGP/lib/python3.11/site-packages/sklearn/manifold/_spectral_embedding.py:324: UserWarning: Graph is not fully connected, spectral embedding may not work as expected.
warnings.warn(
/home/byual/.conda/envs/stGP/lib/python3.11/site-packages/sklearn/manifold/_spectral_embedding.py:324: UserWarning: Graph is not fully connected, spectral embedding may not work as expected.
warnings.warn(
/home/byual/.conda/envs/stGP/lib/python3.11/site-packages/sklearn/manifold/_spectral_embedding.py:324: UserWarning: Graph is not fully connected, spectral embedding may not work as expected.
warnings.warn(
/home/byual/.conda/envs/stGP/lib/python3.11/site-packages/sklearn/manifold/_spectral_embedding.py:324: UserWarning: Graph is not fully connected, spectral embedding may not work as expected.
warnings.warn(
Benchmark outputs saved under: Figure/ext/benchmark
| n_outputs | |
|---|---|
| correlation_figures | 4 |
| spatial_figures | 24 |
| cluster_figures | 36 |
| metric_figures | 10 |
| summary_figures | 13 |
[19]:
source_dir = Path(benchmark_outputs["source_dir"]) / "summary" / "source_data"
corr_summary = pd.read_csv(source_dir / "marker_embedding_correlation_summary.csv")
display(corr_summary)
for ground_truth, metrics_df in benchmark_outputs["cluster_metrics"].items():
display(
metrics_df
.groupby("method", as_index=False)
.agg(
n_slices=("id_region", "nunique"),
raw_ari=("raw_ari", "mean"),
raw_nmi=("raw_nmi", "mean"),
raw_acc=("raw_acc", "mean"),
)
.sort_values("raw_ari", ascending=False)
)
| Unnamed: 0 | layer | marker_gene | method | mean | median | std | |
|---|---|---|---|---|---|---|---|
| 0 | 0 | L2/3 | CUX2 | MEFISTO | 0.749311 | 0.815860 | 0.207515 |
| 1 | 1 | L2/3 | CUX2 | Popari | 0.362930 | 0.248515 | 0.239036 |
| 2 | 2 | L2/3 | CUX2 | STAMP | 0.610371 | 0.737565 | 0.281830 |
| 3 | 3 | L2/3 | CUX2 | SpatialPCA | 0.545955 | 0.463947 | 0.222554 |
| 4 | 4 | L2/3 | CUX2 | stGP | 0.796851 | 0.838556 | 0.155620 |
| 5 | 5 | L4 | RORB | MEFISTO | 0.604304 | 0.612808 | 0.096769 |
| 6 | 6 | L4 | RORB | Popari | 0.283265 | 0.242078 | 0.265295 |
| 7 | 7 | L4 | RORB | STAMP | 0.371638 | 0.342678 | 0.179853 |
| 8 | 8 | L4 | RORB | SpatialPCA | 0.257941 | 0.301475 | 0.158959 |
| 9 | 9 | L4 | RORB | stGP | 0.674625 | 0.699595 | 0.097605 |
| 10 | 10 | L5/6 | HS3ST4 | MEFISTO | 0.447195 | 0.468178 | 0.102479 |
| 11 | 11 | L5/6 | HS3ST4 | Popari | 0.125816 | 0.056475 | 0.137783 |
| 12 | 12 | L5/6 | HS3ST4 | STAMP | 0.227008 | 0.334305 | 0.238527 |
| 13 | 13 | L5/6 | HS3ST4 | SpatialPCA | 0.325266 | 0.323195 | 0.140685 |
| 14 | 14 | L5/6 | HS3ST4 | stGP | 0.515356 | 0.551536 | 0.101769 |
| method | n_slices | raw_ari | raw_nmi | raw_acc | |
|---|---|---|---|---|---|
| 4 | stGP | 12 | 0.322710 | 0.311816 | 0.687081 |
| 0 | MEFISTO | 12 | 0.272890 | 0.276835 | 0.661968 |
| 2 | STAMP | 12 | 0.251136 | 0.225701 | 0.626740 |
| 3 | SpatialPCA | 12 | 0.148898 | 0.169466 | 0.543434 |
| 1 | Popari | 12 | 0.107890 | 0.109065 | 0.502050 |
| method | n_slices | raw_ari | raw_nmi | raw_acc | |
|---|---|---|---|---|---|
| 4 | stGP | 9 | 0.588080 | 0.550121 | 0.829895 |
| 0 | MEFISTO | 9 | 0.565688 | 0.531591 | 0.820396 |
| 2 | STAMP | 9 | 0.407057 | 0.389712 | 0.729004 |
| 3 | SpatialPCA | 9 | 0.233973 | 0.262811 | 0.606660 |
| 1 | Popari | 9 | 0.179829 | 0.164645 | 0.540688 |
5. Pathway Enrichment Analysis
We use gseapy to run over-representation analysis (ORA) on the positive-weight genes of each stGP program, testing against GO Biological Process and GO Cellular Component gene sets. For excitatory neurons, we expect programs enriched in synaptic transmission, neuronal differentiation, axon guidance, and age-related processes such as protein folding stress and mitochondrial dysfunction.
Prerequisites: Download MSigDB gene-set files from https://www.gsea-msigdb.org/ and place them under
data/genesets/:
c5.go.bp.v2026.1.Hs.symbols.gmt
c5.go.cc.v2026.1.Hs.symbols.gmt
[20]:
from plots import plot_enrichment_dotplot, truncate_colormap as _trunc_cmap
import gseapy as gp
gene_sets = {
"GO Biological Process": "data/genesets/c5.go.bp.v2026.1.Hs.symbols.gmt",
"GO Cellular Component": "data/genesets/c5.go.cc.v2026.1.Hs.symbols.gmt",
}
cmaps = {
"GO Biological Process": _trunc_cmap("Reds", 0.22, 0.78),
"GO Cellular Component": _trunc_cmap("Purples", 0.25, 0.76),
}
background_genes = list(W_df.columns)
all_res = []
for program in W_df.index:
gene_list = W_df.loc[program].pipe(lambda s: s[s > 0].sort_values(ascending=False).index.tolist())
print(f"{program}: {len(gene_list)} active genes")
fig, axes = plt.subplots(len(gene_sets), 1,
figsize=(7.5, 2.8 * len(gene_sets)), constrained_layout=True)
for ax, (set_name, gmt_file) in zip(np.atleast_1d(axes), gene_sets.items()):
enr = gp.enrich(gene_list=gene_list, gene_sets=gmt_file,
background=background_genes, verbose=False)
res = enr.res2d.copy()
res["program"] = program
all_res.append(res)
plot_enrichment_dotplot(res, ax, set_name, cmap=cmaps[set_name])
fig.savefig(FIGURES_DIR / f"{program}_enrichment.png", dpi=300, bbox_inches="tight")
plt.show()
enr_df = pd.concat(all_res, ignore_index=True)
enr_df.to_csv(FIGURES_DIR / "enrichment_results.csv", index=False)
print(f"\nAll enrichment results saved to {FIGURES_DIR}/enrichment_results.csv")
stGP1: 15 active genes
stGP2: 15 active genes
stGP3: 15 active genes
stGP4: 15 active genes
All enrichment results saved to Figure/ext/enrichment_results.csv
[21]:
all_res[1][all_res[1]['Adjusted P-value']<0.05]
[21]:
| Gene_set | Term | Overlap | P-value | Adjusted P-value | Odds Ratio | Combined Score | Genes | program | |
|---|---|---|---|---|---|---|---|---|---|
| 41 | c5.go.cc.v2026.1.Hs.symbols.gmt | GOCC_GLUTAMATERGIC_SYNAPSE | 6/17 | 0.000064 | 0.006870 | 15.439359 | 149.042913 | C1QL3;SYN3;GRIK4;APOE;CBLN2;EPHB1 | stGP1 |
| 91 | c5.go.cc.v2026.1.Hs.symbols.gmt | GOCC_SYNAPSE | 8/45 | 0.000583 | 0.020805 | 7.056889 | 52.551218 | C1QL3;SV2C;LAMP5;SYN3;GRIK4;APOE;CBLN2;EPHB1 | stGP1 |
| 92 | c5.go.cc.v2026.1.Hs.symbols.gmt | GOCC_SYNAPTIC_CLEFT | 3/4 | 0.000462 | 0.020805 | 50.306667 | 386.405222 | C1QL3;CBLN2;APOE | stGP1 |
[ ]: