MouseBrain Microglia stGP Tutorial

This notebook follows the MouseBrain MERFISH Microglia analysis step by step: load the processed cells, build the temporal/spatial kernels, fit or reuse stGP, write Results/stgp/Microglia, and regenerate source figures under Figures/Microglia.

[1]:
import json
import os
import pickle
import sys
import time
from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import numpy as np
import pandas as pd
import scanpy as sc
import scipy.sparse as sp
from IPython.display import display

PROJECT_DIR = Path.cwd()
if PROJECT_DIR.name != "RealData_MouseBrainMERFISH":
    PROJECT_DIR = Path("/home/byual/stGP-0529/RealData_MouseBrainMERFISH")
os.chdir(PROJECT_DIR)
sys.path.insert(0, str(PROJECT_DIR))
sys.path.insert(0, str(PROJECT_DIR.parent))

from stgp.estimation import fit_pfactor, 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 log1p_norm_centered_list, standardize_coords_list

import utils as u
from plots import set_nature_style

set_nature_style()
print(f"Working directory: {PROJECT_DIR}")
print(f"Results root: {(PROJECT_DIR / u.RESULTS_STGP).resolve()}")
print(f"Figures root: {(PROJECT_DIR / u.FIGURES_ROOT).resolve()}")

Working directory: /import/home4/byual/stGP-0529/RealData_MouseBrainMERFISH
Results root: /import/home4/byual/stGP-0529/RealData_MouseBrainMERFISH/Results/stgp
Figures root: /import/home4/byual/stGP-0529/RealData_MouseBrainMERFISH/Figures
[2]:
# Tutorial parameters.
CELLTYPE = "Microglia"
N_PROGRAMS = 4       # Fig. 5 fixes p=4 for matched method benchmarking.
P_MAX = 10           # Used only when N_PROGRAMS is None.
K_NEIGHBORS = 15
SEED = 0
SKIP_EXISTING = True

safe_ct = u.safe_name(CELLTYPE)
input_h5ad = u.DATA_PROCESSED / f"{safe_ct}.h5ad"
stgp_dir = u.RESULTS_STGP / safe_ct
fig_dir = u.FIGURES_ROOT / safe_ct
stgp_pkl = stgp_dir / "stgp_result.pkl"
scored_h5ad = stgp_dir / "adata_with_scores.h5ad"
W_csv = stgp_dir / "W.csv"

run_stgp = not (SKIP_EXISTING and stgp_pkl.exists() and scored_h5ad.exists() and W_csv.exists())

display(pd.DataFrame([{
    "celltype": CELLTYPE,
    "input_h5ad": str(input_h5ad),
    "stgp_dir": str(stgp_dir),
    "figure_dir": str(fig_dir),
    "fit_mode": "fixed p" if N_PROGRAMS is not None else "auto rank",
    "p": N_PROGRAMS,
    "run_stgp_now": run_stgp,
}]))

celltype input_h5ad stgp_dir figure_dir fit_mode p run_stgp_now
0 Microglia data/processed/Microglia.h5ad Results/stgp/Microglia Figures/Microglia fixed p 4 False

1. Load cells and organize slices

stGP is fit to one target cell type. Cells are grouped by mouse_id; each mouse contributes one age and one spatial kernel block.

[3]:
if run_stgp:
    if not input_h5ad.exists():
        raise FileNotFoundError(f"Missing processed input: {input_h5ad}")

    adata = sc.read_h5ad(str(input_h5ad))
    age_arr = adata.obs["age"].to_numpy(dtype=float)
    groups = adata.obs["mouse_id"].astype(str).to_numpy()
    uniq_mice, inv = np.unique(groups, return_inverse=True)
    idx_per_group = [np.sort(np.where(inv == t)[0]) for t in range(len(uniq_mice))]
    nlist = np.array([len(ix) for ix in idx_per_group])
    ages = np.array([age_arr[ix[0]] for ix in idx_per_group])

    summary = pd.DataFrame({"mouse_id": uniq_mice, "age": ages, "n_cells": nlist})
    print(f"Loaded {adata.n_obs:,} {CELLTYPE} cells and {adata.n_vars:,} genes.")
    display(summary.head())
    display(summary.describe(include="all"))
else:
    print(f"Using cached stGP outputs in {stgp_dir}")
    adata_scores = sc.read_h5ad(str(scored_h5ad))
    print(f"Cached scored AnnData: {adata_scores.n_obs:,} cells x {adata_scores.n_vars:,} genes")

Using cached stGP outputs in Results/stgp/Microglia
Cached scored AnnData: 52,417 cells x 220 genes

2. Build expression, temporal, and spatial inputs

The expression matrix is split by mouse, log-normalized/centered, and paired with an age kernel plus one spatial kernel per mouse slice.

[4]:
if run_stgp:
    X_raw = adata.X.toarray() if sp.issparse(adata.X) else np.asarray(adata.X)
    Y_list, _ = log1p_norm_centered_list([X_raw[ix] for ix in idx_per_group])
    coords_list = standardize_coords_list([adata.obsm["spatial"][ix] for ix in idx_per_group])

    gamma_spa = bandwidth_select_spatial(coords_list, frac=0.01, rho=0.75)
    gamma_age = bandwidth_select_temporal(ages, rho=np.exp(-4))
    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,
    )

    print(f"gamma_spa = {gamma_spa:.4f}")
    print(f"gamma_age = {gamma_age:.4f}")
    print(f"K_age shape: {K_age.shape}")
    print(f"Spatial kernel blocks: {len(K_spa_list)}")
else:
    print("Skipped kernel construction because cached stGP outputs are present.")

Skipped kernel construction because cached stGP outputs are present.

3. Fit stGP

N_PROGRAMS=4 uses the fixed-rank fit_pfactor mode used for Fig. 5 benchmarking. Set N_PROGRAMS=None to use fit_pfactor_auto.

[5]:
if run_stgp:
    t0 = time.perf_counter()
    if N_PROGRAMS is not None:
        stgp_res = fit_pfactor(
            Y_list, nlist, K_age, K_spa_list,
            p=N_PROGRAMS, k=K_NEIGHBORS, verbose=1,
        )
    else:
        stgp_res = fit_pfactor_auto(
            Y_list=Y_list, Nlist=nlist, K_age=K_age, Kspa_list=K_spa_list,
            p_max=P_MAX, k=K_NEIGHBORS, random_state=SEED, verbose=1,
        )
    elapsed = time.perf_counter() - t0
    print(f"stGP runtime: {elapsed:.1f} sec")
    print(f"Selected programs: {stgp_res['W'].shape[0]}")
else:
    with open(stgp_pkl, "rb") as f:
        stgp_res = pickle.load(f)
    elapsed = None
    print(f"Loaded cached stGP pickle: {stgp_pkl}")
    print(f"Cached programs: {np.asarray(stgp_res['W']).shape[0]}")

Loaded cached stGP pickle: Results/stgp/Microglia/stgp_result.pkl
Cached programs: 4

4. Write stGP outputs

Scores H and spatial residuals b are mapped back to original cell order and saved in obsm['X_stgp'] and obsm['X_stgp_spatial'].

[ ]:
if run_stgp:
    stgp_dir.mkdir(parents=True, exist_ok=True)
    stgp_res["gamma_age"] = gamma_age
    stgp_res["gamma_spa"] = gamma_spa
    with open(stgp_pkl, "wb") as f:
        pickle.dump(stgp_res, f)

    p_sel = stgp_res["W"].shape[0]
    prog_labels = [f"stGP{j + 1}" for j in range(p_sel)]
    W = pd.DataFrame(stgp_res["W"], index=prog_labels, columns=adata.var_names)
    W.to_csv(W_csv)

    all_idx = np.concatenate(idx_per_group)
    H_adata = np.empty_like(stgp_res["H"])
    H_adata[all_idx] = stgp_res["H"]
    b_adata = np.empty_like(stgp_res["b"])
    b_adata[all_idx] = stgp_res["b"]
    adata.obsm["X_stgp"] = H_adata.astype(np.float32)
    adata.obsm["X_stgp_spatial"] = b_adata.astype(np.float32)

    alpha_arr = np.asarray(stgp_res.get("alpha", []))
    alpha_lower_arr = np.asarray(stgp_res.get("alpha_lower", []))
    alpha_upper_arr = np.asarray(stgp_res.get("alpha_upper", []))
    adata.uns["stgp"] = dict(
        groups=uniq_mice.tolist(), ages=ages.tolist(),
        gamma_age=gamma_age, gamma_spa=gamma_spa, p_selected=p_sel,
        alpha=alpha_arr.tolist() if alpha_arr.ndim == 2 else [],
        alpha_lower=alpha_lower_arr.tolist() if alpha_lower_arr.ndim == 2 else [],
        alpha_upper=alpha_upper_arr.tolist() if alpha_upper_arr.ndim == 2 else [],
    )
    adata.write_h5ad(scored_h5ad, compression="gzip")

    timing = {
        "method": "stgp", "celltype": CELLTYPE,
        "runtime_sec": round(elapsed, 2), "status": "completed",
        "p": N_PROGRAMS, "p_max": P_MAX, "k": K_NEIGHBORS, "seed": SEED,
    }
    (stgp_dir / "timing.json").write_text(json.dumps(timing, indent=2))
    u.append_jsonl(u.TIMING_LOG, {**timing, "out_dir": str(stgp_dir.resolve())})
else:
    W = pd.read_csv(W_csv, index_col=0)

5. Load stGP and baseline results for figure generation

Missing baseline directories are skipped. Available methods are summarized before plotting.

[ ]:
fig_dir.mkdir(parents=True, exist_ok=True)
methods = u._load_methods(safe_ct, CELLTYPE, stgp_dir=stgp_dir)
if not methods:
    raise RuntimeError(f"No method results found for {CELLTYPE}")

stgp_method = next((m for m in methods if m.method == "stGP"), None)
stgp_res = u._load_stgp_pickle(safe_ct, stgp_dir=stgp_dir) if stgp_method is not None else None

stamp_m = next((m for m in methods if m.method == "STAMP"), None)
if stamp_m is not None and stamp_m.gene_weights is None:
    stamp_m.gene_weights = u._infer_gene_weights(stamp_m.adata, stamp_m.scores)

method_table = pd.DataFrame([
    {
        "method": m.method,
        "cells": m.adata.n_obs,
        "programs": m.scores.shape[1],
        "has_gene_weights": m.gene_weights is not None,
        "result_dir": str(m.result_dir),
    }
    for m in methods
])
  [loaded] stGP: (52417, 4)
  [loaded] SpatialPCA: (52417, 4)
  [loaded] MEFISTO: (52417, 4)
  [loaded] STAMP: (51003, 4)
  [loaded] Popari: (52417, 4)
method cells programs has_gene_weights result_dir
0 stGP 52417 4 True Results/stgp/Microglia
1 SpatialPCA 52417 4 True Results/baselines/spatialpca/Microglia
2 MEFISTO 52417 4 True Results/baselines/mefisto/Microglia
3 STAMP 51003 4 True Results/baselines/stamp/Microglia
4 Popari 52417 4 True Results/baselines/popari/Microglia

7. Spatial program maps, domains, and method similarity

This section writes spatial maps, age-ordered stack plots, benchmark cluster comparisons, and stGP-vs-baseline gene-program similarity summaries.

[ ]:
spatial_outputs = sorted((fig_dir / "spatial_selected_2x2").rglob("*.png"))
sim_outputs = sorted((fig_dir / "program_similarity").rglob("*.csv"))

if spatial_outputs and sim_outputs:
    print("Using cached spatial maps and program-similarity tables.")
else:
    u._section4_spatial_maps_and_clustering(
        methods, stgp_method, celltype=CELLTYPE, safe_ct=safe_ct, fig_dir=fig_dir,
    )
    u._section5_program_similarity(
        methods, stgp_method, celltype=CELLTYPE, safe_ct=safe_ct, fig_dir=fig_dir,
    )
    spatial_outputs = sorted((fig_dir / "spatial_selected_2x2").rglob("*.png"))
    sim_outputs = sorted((fig_dir / "program_similarity").rglob("*.csv"))

8. Kernel diagnostic, W heatmap, and runtime comparison

These final upstream figures document the spatial kernel scale, active gene-weight matrix, and method runtimes.

[ ]:
if stgp_method is not None:
    u._section6_kernel_diagnostic_and_W_heatmap(
        stgp_method, stgp_res, celltype=CELLTYPE, safe_ct=safe_ct, fig_dir=fig_dir,
    )
u._section7_timing(celltype=CELLTYPE, safe_ct=safe_ct, fig_dir=fig_dir, stgp_dir=stgp_dir)

figure_files = sorted(fig_dir.rglob("*.png"))
table_files = sorted(fig_dir.rglob("*.csv"))