Kidney cancer: per-cell-type K selection (reproduce)

Part 1 — compute: synchronized per-cell-type K selection

Uses select_K_synchronized: all replicates advance round-by-round in lockstep; after each round a single keep/stop decision per cell type is made from the replicates and applied to all of them. A cell type keeps its K-th SGP only if the marginal held-out ΔLL is significantly > 0 (one-sided t-test, p < ALPHA) and the K-th loading is reproducible across replicates (mean pairwise |cosine| ≥ STAB_THRESH).

[ ]:
import os
# expose the GPUs used for the parallel replicates BEFORE importing torch.
GPUS = [0, 1, 2, 3, 4]              # one worker per replicate; set to None for in-process single-GPU
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(str(g) for g in GPUS)

import sys, pickle, warnings
warnings.filterwarnings("ignore")
sys.path.append(r"/import/home2/share/yqzeng/MacSGP/codes")

import numpy as np
import pandas as pd
import scanpy as sc
import matplotlib.pyplot as plt
import matplotlib as mpl

from MacSGP_unified.k_selection import select_K_synchronized

mpl.rcParams.update({"figure.dpi": 110, "savefig.dpi": 200, "font.family": "DejaVu Sans",
    "axes.titlesize": 9, "axes.labelsize": 8, "axes.spines.top": False, "axes.spines.right": False})
[ ]:
DATA_PATH = "/import/home2/share/yqzeng/MacSGP/data/kidney_cancer"
SAVE_PATH = "/import/home2/share/yqzeng/MacSGP/results/kidney_cancer"
os.makedirs(SAVE_PATH, exist_ok=True)

adata_st = sc.read_h5ad(os.path.join(DATA_PATH, "visium_1.h5ad"))
adata_basis = sc.read_h5ad(os.path.join(DATA_PATH, "visium_1_basis.h5ad"))
# sanity: the fields select_K_synchronized needs must already be present
for k in [("obsm", "count"), ("obsm", "proportion"), ("obs", "library_size"),
          ("obs", "alpha"), ("var", "gamma")]:
    assert k[1] in getattr(adata_st, k[0]), f"missing {k}"
print(adata_st.shape, "| celltypes:", list(adata_basis.obs.index))
[ ]:
SGP_STEPS = 3000
LR = 2e-3
N_LAYERS = 4
K_MAX = 5
HOLDOUT_RATIO = 0.10
N_REPEATS = 5
SEEDS = [20260703, 20260704, 20260705, 20260706, 20260707]
ALPHA = 0.05          # t-test significance for keeping an SGP
MIN_EFFECT = 0.0      # optional minimum marginal held-out ΔLL to keep (absolute)
METHOD = "ttest"      # or "onese"
# stability gate: keep the K-th SGP only if its loading is reproducible across
# replicates (mean pairwise |cosine| >= STAB_THRESH). A real program is recovered
# consistently (~0.9); mask-specific noise is not (~0). Set 0 to disable.
STAB_THRESH = 0.8
[ ]:
# GPUS in the child processes are re-indexed 0..len(GPUS)-1 by CUDA_VISIBLE_DEVICES
gpu_ids = list(range(len(GPUS))) if GPUS is not None else None

res = select_K_synchronized(
    adata_st,
    adata_basis,
    model_kwargs={"training_steps": SGP_STEPS, "lr": LR, "n_layers": N_LAYERS},
    K_max=K_MAX,
    holdout_ratio=HOLDOUT_RATIO,
    n_repeats=N_REPEATS,
    seeds=SEEDS,
    train_kwargs={"fix_gamma_k": True},
    alpha=ALPHA,
    min_effect=MIN_EFFECT,
    method=METHOD,
    stab_thresh=STAB_THRESH,
    gpus=gpu_ids,
    verbose=True,
)

with open(os.path.join(SAVE_PATH, "sync_selection_result.pkl"), "wb") as f:
    pickle.dump(res, f)
print("\nrecommended K per cell type:")
print(res["recommended_K_per_celltype"])
[ ]:
# ---- fit the FULL data at the selected per-cell-type K ----
from MacSGP_unified.model import Model

Kc = res["recommended_K_per_celltype"]
celltypes = list(adata_basis.obs.index)
Kc_list = [int(Kc[c]) for c in celltypes]
maxK = max(max(Kc_list), 1)
print("selected K:", dict(zip(celltypes, Kc_list)), "| maxK =", maxK)

full = Model(adata_st, adata_basis, n_SGPs=maxK, seed=1234,
             training_steps=SGP_STEPS, lr=LR, n_layers=N_LAYERS)
full.train_greedy(fixed_Kc=Kc_list, fix_gamma_k=True)   # fit exactly K_c SGPs per cell type
adata_res = full.eval(sort_sgps=False)                  # factors -> adata_res.obsm[celltype], loadings -> varm
adata_res.write_h5ad(os.path.join(SAVE_PATH, "adata_sync_fit.h5ad"))
print("saved fit ->", os.path.join(SAVE_PATH, "adata_sync_fit.h5ad"))

Part 2 — compute: pathway enrichment of the selected SGPs (poles separated)

Each SGP has two anti-correlated poles (a factor/loading sign flip is arbitrary), so we analyse them separately: the top-TOP_N genes on the positive and on the negative pole of every (cell type, SGP), each tested against MSigDB Hallmark / GO-BP / KEGG via Enrichr. In addition, directional GSEA (prerank on the signed loading) assigns each pathway one signed NES. Requires internet (Enrichr) + gseapy.

[ ]:
import time
import gseapy as gp

FIT_H5AD = os.path.join(SAVE_PATH, "adata_sync_fit.h5ad")
SEL_PKL = os.path.join(SAVE_PATH, "sync_selection_result.pkl")
ENR_DIR = os.path.join(SAVE_PATH, "enrichment"); os.makedirs(ENR_DIR, exist_ok=True)
print("gseapy", gp.__version__)
[ ]:
adata = sc.read_h5ad(FIT_H5AD)
res = pickle.load(open(SEL_PKL, "rb"))
Kc = res["recommended_K_per_celltype"]
genes = np.asarray(adata.var_names)          # already HGNC symbols

# (cell type, SGP index) -> loading vector, only for kept SGPs (k < K_c)
sgp_loadings = {}
for c, K in Kc.items():
    if K < 1 or c not in adata.varm:
        continue
    L = np.asarray(adata.varm[c])            # [n_genes, maxK]
    for k in range(int(K)):
        sgp_loadings[(c, k)] = L[:, k]
print(f"{len(sgp_loadings)} SGPs; each analysed at + and - pole -> {2*len(sgp_loadings)} gene sets")
[ ]:
TOP_N = 100                       # top genes per pole taken as that pole's signature
LIBRARIES = ["MSigDB_Hallmark_2020", "GO_Biological_Process_2021", "KEGG_2021_Human"]
PADJ_CUTOFF = 0.05

def pole_genes(loading, pole, n=TOP_N):
    """Top-n genes on the '+' (largest positive) or '-' (largest negative) pole."""
    order = np.argsort(-loading) if pole == "+" else np.argsort(loading)
    return [str(g) for g in genes[order[:n]]]
[ ]:
# Enrichr over-representation for every (cell type, SGP, pole)
rows = []
for (c, k), loading in sgp_loadings.items():
    for pole in ("+", "-"):
        gl = pole_genes(loading, pole)
        try:
            df = gp.enrichr(gene_list=gl, gene_sets=LIBRARIES, organism="human", outdir=None).results.copy()
        except Exception as ex:
            print(f"  [skip] {c} SGP{k}{pole}: {ex}")
            continue
        df.insert(0, "celltype", c); df.insert(1, "SGP", k); df.insert(2, "pole", pole)
        rows.append(df)
        top = df.sort_values("Adjusted P-value").iloc[0]
        print(f"{c} SGP{k}{pole}: [{top['Gene_set']}] {top['Term']}  (padj={top['Adjusted P-value']:.1e})")
        time.sleep(0.8)

enrich = pd.concat(rows, ignore_index=True)
enrich["neglog10_padj"] = -np.log10(enrich["Adjusted P-value"].clip(lower=1e-300))
enrich.to_csv(os.path.join(ENR_DIR, "sgp_enrichment_bipolar.csv"), index=False)
print("\nsaved:", os.path.join(ENR_DIR, "sgp_enrichment_bipolar.csv"), "| rows:", len(enrich))
[ ]:
# ---- GSEA prerank on each SGP's SIGNED loading (positive at top of the ranking) ----
PRE_LIB = "MSigDB_Hallmark_2020"
N_PERM = 1000
FDR_SHOW = 0.25

pre_rows = []
for (c, k), loading in sgp_loadings.items():
    rnk = pd.DataFrame({"gene": genes, "score": loading}).sort_values("score", ascending=False)
    try:
        pre = gp.prerank(rnk=rnk, gene_sets=PRE_LIB, permutation_num=N_PERM,
                         min_size=5, max_size=500, seed=1, threads=4,
                         no_plot=True, outdir=None, verbose=False)
    except Exception as ex:
        print(f"  [skip] {c} SGP{k}: {ex}"); continue
    df = pre.res2d.copy()
    df["celltype"] = c; df["SGP"] = k
    for col in ["NES", "FDR q-val", "NOM p-val"]:
        df[col] = pd.to_numeric(df[col], errors="coerce")
    pre_rows.append(df)
    s = df[df["FDR q-val"] < FDR_SHOW]
    pos = s[s.NES > 0].sort_values("NES").tail(1)
    neg = s[s.NES < 0].sort_values("NES").head(1)
    msg = []
    if len(neg): msg.append(f"[- pole] {neg.iloc[0]['Term']} (NES {neg.iloc[0]['NES']:.2f})")
    if len(pos): msg.append(f"[+ pole] {pos.iloc[0]['Term']} (NES {pos.iloc[0]['NES']:.2f})")
    print(f"{c} SGP{k}: " + ("  ".join(msg) if msg else "(no term below FDR)"))
    time.sleep(0.2)

prerank = pd.concat(pre_rows, ignore_index=True)
prerank.to_csv(os.path.join(ENR_DIR, "sgp_prerank.csv"), index=False)
print("\nsaved:", os.path.join(ENR_DIR, "sgp_prerank.csv"), "| rows:", len(prerank))

Part 3 — figures

Plotting code taken verbatim from kidney_cancer_comment1.ipynb. Inputs are the precomputed results from Parts 1–2 (sync_selection_result.pkl, adata_sync_fit.h5ad, enrichment/*.csv). Figures are saved to the OUT folder.

[ ]:
import os, pickle
import numpy as np
import pandas as pd
import scanpy as sc
import matplotlib.pyplot as plt
import matplotlib as mpl

RES = "/import/home2/share/yqzeng/MacSGP/results/kidney_cancer"
OUT = "/import/home2/share/yqzeng/MacSGP/revision_2/reproduce"; os.makedirs(OUT, exist_ok=True)
mpl.rcParams.update({"figure.dpi": 110, "savefig.dpi": 200, "font.family": "DejaVu Sans",
    "axes.spines.top": False, "axes.spines.right": False})
KEEP_BLUE, STOP_RED, BASE, GRIDC = "#2f6bb5", "#c93b3b", "#8a8f96", "#e5e7eb"

res = pickle.load(open(os.path.join(RES, "sync_selection_result.pkl"), "rb"))
Kc = res["recommended_K_per_celltype"]; pr = res["per_round_dLL"]
adata = sc.read_h5ad(os.path.join(RES, "adata_sync_fit.h5ad"))
enrich = pd.read_csv(os.path.join(RES, "enrichment/sgp_enrichment_bipolar.csv"))
prerank = pd.read_csv(os.path.join(RES, "enrichment/sgp_prerank.csv"))

cts = [c for c in res["celltypes"] if Kc.get(c, 0) > 0]      # K>0 cell types only
print("K>0 cell types:", {c: int(Kc[c]) for c in cts}, "| total SGPs", sum(Kc[c] for c in cts))
[ ]:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib as mpl

# ============================================================
# Publication style (Nature Communications) — legible, larger fonts
# ============================================================
mpl.rcParams.update({
    'font.family': 'sans-serif',
    'font.sans-serif': ['Arial', 'Helvetica', 'DejaVu Sans'],
    'font.size': 13,
    'axes.titlesize': 15,
    'axes.labelsize': 14,
    'xtick.labelsize': 12,
    'ytick.labelsize': 12,
    'legend.fontsize': 12,
    'figure.titlesize': 17,
    'axes.linewidth': 0.9,
    'xtick.major.width': 0.9,
    'ytick.major.width': 0.9,
    'xtick.major.size': 3.5,
    'ytick.major.size': 3.5,
    'axes.spines.top': False,
    'axes.spines.right': False,
    'pdf.fonttype': 42,       # embed editable TrueType text (Illustrator-friendly)
    'ps.fonttype': 42,
    'savefig.dpi': 350,
    'figure.dpi': 130,
})

1. Cumulative held-out ΔLL vs K (K>0 cell types)

Each panel: cumulative held-out log-likelihood gain vs number of SGPs (mean ± SE over the 5 held-out replicates); the red dashed line marks the selected K*.

[ ]:
ncols = 4; nrows = int(np.ceil(len(cts) / ncols))
fig, axes = plt.subplots(nrows, ncols, figsize=(3.7 * ncols, 3.0 * nrows), squeeze=False)
for i, c in enumerate(cts):
    ax = axes[i // ncols][i % ncols]
    M = pr[c]
    cum = np.cumsum(M, axis=0); cum = np.vstack([np.zeros((1, cum.shape[1])), cum])
    xs = np.arange(cum.shape[0]); mu = cum.mean(1); se = cum.std(1, ddof=1) / np.sqrt(cum.shape[1])
    ax.fill_between(xs, mu - se, mu + se, color="#c9dbee", alpha=0.7, zorder=1)
    ax.plot(xs, mu, color=KEEP_BLUE, lw=2.4, marker="o", ms=7, mec="white", mew=1.2, zorder=2)
    ax.axvline(Kc[c], color=STOP_RED, ls="--", lw=1.6); ax.axhline(0, color=BASE, ls=":", lw=1.0)
    ax.set_xticks(xs.astype(int)); ax.set_title(f"{c}   K*={Kc[c]}", loc="left", fontweight="600")
    if i % ncols == 0: ax.set_ylabel("Cumulative ΔLL")
    ax.set_xlabel("K"); ax.grid(True, axis="y", color=GRIDC, lw=0.6, zorder=0)
for j in range(len(cts), nrows * ncols):
    axes[j // ncols][j % ncols].axis("off")
#fig.suptitle("Kidney — cumulative held-out ΔLL vs K (dashed = selected K*)", x=0.02, ha="left", y=1.004, fontweight="600")
fig.tight_layout(); fig.savefig(os.path.join(OUT, "kidney_1_heldout_cumulative.pdf"), bbox_inches="tight")
fig.savefig(os.path.join(OUT, "kidney_1_heldout_cumulative.png"), bbox_inches="tight")
plt.show()

2. Spatial distribution of all selected SGP factors

One figure per cell type (name shown on top). The first panel is the cell-type proportion (Reds); the following panels are each selected SGP factor (diverging coolwarm, centred at 0), restricted to spots where the cell type is present (proportion ≥ threshold; other spots grey).

[ ]:
import re as _re
coords = np.asarray(adata.obsm["spatial"]).astype(float)
sx, sy = coords[:, 0], -coords[:, 1]                 # flip y (image convention)
prop_df = adata.obsm["proportion"]
PROP_THRESH = 0.10
S_VIS, S_MASK = 10, 6

for c in cts:                                        # one separate figure per cell type
    K = Kc[c]
    prop = np.asarray(prop_df[c].values, float)
    mask = prop >= PROP_THRESH
    fac = np.asarray(adata.obsm[c].values)
    ncols = 1 + K                                    # proportion + one column per SGP

    fig, axes = plt.subplots(1, ncols, figsize=(3.4 * ncols, 3.9), squeeze=False)
    axes = axes[0]

    # ---- column 0: cell-type proportion (Reds) ----
    ax0 = axes[0]
    sca0 = ax0.scatter(sx, sy, c=prop, s=S_VIS, cmap="Reds",
                       vmin=0, vmax=float(prop.max()), rasterized=True, linewidths=0)
    cb0 = fig.colorbar(sca0, ax=ax0, orientation="horizontal", fraction=0.045, pad=0.03, shrink=0.7)
    cb0.ax.tick_params(labelsize=13)
    ax0.set_title("Proportion", fontsize=19)
    ax0.set_xticks([]); ax0.set_yticks([]); ax0.set_aspect("equal", adjustable="box")
    for sp in ax0.spines.values(): sp.set_visible(True); sp.set_linewidth(0.9)

    # ---- columns 1..K: SGP factors (coolwarm, diverging about 0) ----
    for k in range(K):
        ax = axes[k + 1]
        vv = fac[:, k][mask]
        fin = vv[np.isfinite(vv)]
        lo = float(fin.min()) if fin.size else -1.0
        hi = float(fin.max()) if fin.size else 1.0
        norm = mpl.colors.TwoSlopeNorm(vmin=min(lo, -1e-9), vcenter=0.0, vmax=max(hi, 1e-9))
        ax.scatter(sx[~mask], sy[~mask], c="lightgray", s=S_MASK, alpha=0.4, rasterized=True, linewidths=0)
        sca = ax.scatter(sx[mask], sy[mask], c=vv, s=S_VIS, cmap="coolwarm",
                         norm=norm, rasterized=True, linewidths=0)
        cb = fig.colorbar(sca, ax=ax, orientation="horizontal", fraction=0.045, pad=0.03, shrink=0.7)
        cb.set_ticks([])
        cb.ax.text(0.0, -0.9, "Low", ha="left", va="top", fontsize=13, transform=cb.ax.transAxes)
        cb.ax.text(1.0, -0.9, "High", ha="right", va="top", fontsize=13, transform=cb.ax.transAxes)
        ax.set_title(f"SGP{k+1}", fontsize=19)
        ax.set_xticks([]); ax.set_yticks([]); ax.set_aspect("equal", adjustable="box")
        for sp in ax.spines.values(): sp.set_visible(True); sp.set_linewidth(0.9)

    fig.suptitle(c, fontsize=24, fontweight="600", y=1.04)
    plt.subplots_adjust(wspace=0.05)
    fig.tight_layout()
    tag = _re.sub(r"[^0-9A-Za-z]+", "_", c)
    fig.savefig(os.path.join(OUT, f"kidney_2_factor_spatial_{tag}.pdf"), bbox_inches="tight")
    fig.savefig(os.path.join(OUT, f"kidney_2_factor_spatial_{tag}.png"), bbox_inches="tight")
    plt.show()

3. Pathway enrichment of the SGPs

directional GSEA (prerank on the signed loading); colour = NES (+ pole / − pole), size = \(-\log_{10}\) FDR.

[ ]:
# directional GSEA prerank dot plot
FDR = 0.25; CLIP_NLQ = 6; TOP_PER_COL_G = 4
pr2 = prerank.copy(); pr2["col"] = pr2.celltype + "·SGP" + pr2.SGP.astype(str)
pr2["FDR q-val"] = pd.to_numeric(pr2["FDR q-val"], errors="coerce"); pr2["NES"] = pd.to_numeric(pr2["NES"], errors="coerce")
pr2["nlq"] = -np.log10(pr2["FDR q-val"].clip(lower=1e-6))
sig = pr2[pr2["FDR q-val"] < FDR]
keep_t = sig.reindex(sig["NES"].abs().sort_values(ascending=False).index).groupby("col").head(TOP_PER_COL_G)["Term"].unique()
d = pr2[pr2.Term.isin(keep_t) & (pr2["FDR q-val"] < FDR)].copy()
if len(d):
    cols = sorted(d.col.unique()); to = d.groupby("Term").NES.apply(lambda s: s.abs().max()).sort_values().index.tolist()
    cols_label = [s[:-1] + str(int(s[-1]) + 1) for s in cols]
    cix = {c: i for i, c in enumerate(cols)}; tix = {t: i for i, t in enumerate(to)}
    vmax = float(np.nanmax(np.abs(d.NES)))
    fig, ax = plt.subplots(figsize=(0.68 * len(cols) + 5, 0.46 * len(to) + 2.5))
    sc_ = ax.scatter(d.col.map(cix), d.Term.map(tix), s=40 + np.clip(d.nlq, 0, CLIP_NLQ) * 34,
                     c=d.NES, cmap="RdBu_r", vmin=-vmax, vmax=vmax, edgecolor="#444", lw=0.4)
    ax.set_xticks(range(len(cols))); ax.set_xticklabels(cols_label, rotation=90, fontsize=15)
    ax.set_yticks(range(len(to))); ax.set_yticklabels(to, fontsize=15)
    ax.set_xlim(-.5, len(cols) - .5); ax.set_ylim(-.5, len(to) - .5); ax.grid(True, color="#eee", lw=.5); ax.set_axisbelow(True)
    ax.set_title("GSEAPreranked (Hallmark)\ncolor = NES (+/- pole),  size = -log10 FDR", loc="left", fontsize=14, fontweight="600")
    cb = fig.colorbar(sc_, ax=ax, shrink=0.5); cb.set_label("NES (+ = positive pole)", fontsize=12); cb.ax.tick_params(labelsize=11)
    fig.tight_layout(); fig.savefig(os.path.join(OUT, "kidney_3b_enrichment_prerank.pdf"), bbox_inches="tight")
    fig.savefig(os.path.join(OUT, "kidney_3b_enrichment_prerank.png"), bbox_inches="tight"); plt.show()
else:
    print("no prerank terms below FDR", FDR)