PANDA / scripts /figures /build_pca_vs_marker_umaps.py
bryan7264's picture
Correction pass: gate-matched Dahlin, retracted unsupported claims, complete HF-placode DEG set, restyled figures
141bacd verified
Raw
History Blame Contribute Delete
27.3 kB
"""PANDA-PCA vs PANDA-Marker side-by-side umaps for dingwall/dahlin/veres, plus en1-cKO enrichment and melanocyte pathway bars."""
from __future__ import annotations
from pathlib import Path
import warnings, json, sys, pickle, numpy as np, pandas as pd
warnings.filterwarnings("ignore")
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
import anndata as ad, scanpy as sc, scipy.sparse as sp, torch
import os as _os
from pathlib import Path as _Path
PANDA_ROOT = _Path(_os.environ.get("PANDA_ROOT", str(_Path(__file__).resolve().parents[2])))
sys.path.insert(0, str(PANDA_ROOT))
sys.path.insert(0, str(Path(__file__).parent))
from panda import PANDAEncoder
from palette import (apply_style, color_for, GENOTYPE_COLORS, STAGE_COLORS,
SUPTITLE_FS, TITLE_FS, LABEL_FS, TICK_FS, LEGEND_FS, ANNOT_FS)
apply_style()
sc.settings.verbosity = 0
ROOT = Path(str(PANDA_ROOT))
FIG_S = ROOT / "figures/supplement"
FIG_S.mkdir(parents=True, exist_ok=True)
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
RS = 42
# ------------------- projection helpers -------------------
def project(a, sys, variant):
"""returns (z_128d, predicted class array, classes)."""
ck = torch.load(ROOT / f"checkpoints/{sys}/{variant}/panda_final.pt",
map_location=DEVICE, weights_only=False)
classes = ck["classes"]; marker_genes = ck.get("marker_genes", [])
stats = np.load(ROOT / f"data/corpus/{sys}/harmonized/corpus_stats.npz", allow_pickle=True)
pca = pickle.load(open(ROOT / f"data/corpus/{sys}/harmonized/pca_basis.pkl", "rb"))
hvgs = [str(g) for g in stats["shared_hvgs"]]
hvg2i = {g: i for i, g in enumerate(hvgs)}
common = [g for g in a.var_names.astype(str) if g in hvg2i]
a_c = a[:, common].copy()
sc.pp.normalize_total(a_c, target_sum=1e4); sc.pp.log1p(a_c)
X = a_c.X.toarray().astype(np.float32) if sp.issparse(a_c.X) else a_c.X.astype(np.float32)
Xf = np.zeros((a.n_obs, len(hvgs)), dtype=np.float32)
Xf[:, np.array([hvg2i[g] for g in common])] = X
Xz = np.clip((Xf - stats["mean"].astype(np.float32)) / stats["std"].astype(np.float32), -10, 10)
Xpca = pca.transform(Xz).astype(np.float32)
Xmark = None
if variant == "marker" and marker_genes:
mv = np.zeros((a.n_obs, len(marker_genes)), dtype=np.float32)
for j, g in enumerate(marker_genes):
if g in a.var_names:
col = a[:, g].X
if sp.issparse(col): col = col.toarray()
mv[:, j] = col.flatten().astype(np.float32)
mmu = mv.mean(axis=0, keepdims=True); msig = mv.std(axis=0, keepdims=True) + 1e-6
Xmark = np.clip((mv - mmu) / msig, -5, 5).astype(np.float32)
model = PANDAEncoder(variant=variant, n_pca=50,
n_markers=len(marker_genes) if variant == "marker" else 0,
n_classes=len(classes), n_sub=3,
n_datasets=len(ck["datasets"])).to(DEVICE).eval()
model.load_state_dict(ck["model"])
all_z, preds = [], []
with torch.no_grad():
for i in range(0, a.n_obs, 4096):
xb = torch.from_numpy(Xpca[i:i+4096]).to(DEVICE)
xmb = torch.from_numpy(Xmark[i:i+4096]).to(DEVICE) if Xmark is not None else None
aux = torch.zeros(len(xb), 2, device=DEVICE)
out = model(xb, aux, x_markers=xmb, lam_dann=0.0)
all_z.append(out["z"].cpu().numpy())
mc = model.max_sub_cos(out["z"])
preds.append(mc.argmax(dim=1).cpu().numpy())
Z = np.concatenate(all_z, axis=0)
P = np.array([classes[i] for i in np.concatenate(preds)])
return Z, P, classes
def do_umap(Z, seed=RS):
import umap
# Z is the L2-normalised 128-d projection (points on the unit hypersphere),
# so cosine is the metric the embedding was trained under. Euclidean here
# produced the ring/arc artefacts seen in earlier Veres panels.
reducer = umap.UMAP(n_neighbors=30, min_dist=0.3, random_state=seed,
metric="cosine", n_epochs=200, verbose=False,
low_memory=False)
return reducer.fit_transform(Z)
# kelly-inspired 22-color palette + "other"
DISTINCT_COLORS = [
"#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#42d4f4",
"#f032e6", "#bfef45", "#fabed4", "#469990", "#dcbeff", "#9a6324",
"#fffac8", "#800000", "#aaffc3", "#808000", "#ffd8b1", "#000075",
"#a9a9a9", "#f4a460", "#00fa9a", "#ff69b4",
]
def build_class_palette(P_pca, P_mar, min_frac=0.005):
"""collapse classes < min_frac to 'other', assign each remaining canonical color."""
from collections import Counter
total = len(P_pca) + len(P_mar)
counts = Counter(P_pca.tolist() + P_mar.tolist())
kept = [c for c, n in counts.most_common() if n / total >= min_frac]
P_pca_r = np.where(np.isin(P_pca, kept), P_pca, "other")
P_mar_r = np.where(np.isin(P_mar, kept), P_mar, "other")
palette = {c: color_for(c, DISTINCT_COLORS[i % len(DISTINCT_COLORS)])
for i, c in enumerate(kept)}
palette["other"] = "#e5e5e5"
return P_pca_r, P_mar_r, palette
def scatter_side_by_side(emb_pca, emb_mark, colors_pca, colors_mark, palette,
subtitle_pca, subtitle_mark, main_title, out_path,
s=4, alpha=0.55, legend_title=""):
fig, axes = plt.subplots(1, 2, figsize=(18.5, 8.5))
for ax, emb, colors, sub in zip(axes,
[emb_pca, emb_mark],
[colors_pca, colors_mark],
[subtitle_pca, subtitle_mark]):
for cat in sorted(set(colors)):
m = np.array(colors) == cat
ax.scatter(emb[m, 0], emb[m, 1], s=s, alpha=alpha,
color=palette.get(cat, "#888"), label=cat, linewidths=0,
rasterized=True)
ax.set_xlabel("UMAP-1", fontsize=LABEL_FS); ax.set_ylabel("UMAP-2", fontsize=LABEL_FS)
ax.set_title(sub, fontsize=TITLE_FS)
ax.set_xticks([]); ax.set_yticks([])
handles = [plt.Line2D([0], [0], marker="o", linestyle="",
markerfacecolor=palette[c], markeredgecolor="none", markersize=11, label=c)
for c in palette]
fig.legend(handles=handles, loc="center right", bbox_to_anchor=(1.10, 0.5),
fontsize=LEGEND_FS, frameon=False, title=legend_title,
title_fontsize=TITLE_FS)
plt.suptitle(main_title, fontsize=SUPTITLE_FS, y=1.02, fontweight="bold")
plt.tight_layout()
plt.savefig(out_path, bbox_inches="tight", dpi=180)
plt.close()
print(f"[fig] {out_path.name}")
# ------------------- dingwall -------------------
def fig_dingwall_pca_vs_marker():
"""dingwall GSE220977 colored by en1 genotype + predicted class."""
cache = FIG_S / "_cache_dingwall_full.npz"
CKO = {"GSM6833482", "GSM6833483"}
WT = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"}
if cache.exists():
c = np.load(cache, allow_pickle=True)
emb_pca = c["emb_pca"]; emb_mar = c["emb_mar"]
P_pca = c["P_pca"].astype(str); P_mar = c["P_mar"].astype(str)
genotype = c["genotype"].astype(str)
n = len(genotype)
print(f"[dingwall] loaded cache n={n}", flush=True)
else:
raw = ad.read_h5ad(ROOT / "data/raw/GSE220977_combined.h5ad")
genotype = np.where(raw.obs["sample"].astype(str).isin(list(CKO)), "En1-cKO",
np.where(raw.obs["sample"].astype(str).isin(list(WT)), "WT", "other"))
n = raw.n_obs
print(f"[dingwall] projecting all {n} cells with PCA and Marker checkpoints", flush=True)
Z_pca, P_pca, _ = project(raw, "pan_skin", "pca")
Z_mar, P_mar, _ = project(raw, "pan_skin", "marker")
print(f"[dingwall] running umap on all {n}", flush=True)
emb_pca = do_umap(Z_pca)
emb_mar = do_umap(Z_mar)
np.savez(cache,
emb_pca=emb_pca, emb_mar=emb_mar, P_pca=P_pca, P_mar=P_mar,
genotype=genotype)
palette_gt = {"WT": GENOTYPE_COLORS["WT"], "En1-cKO": GENOTYPE_COLORS["En1-cKO"],
"other": GENOTYPE_COLORS["other"]}
scatter_side_by_side(
emb_pca, emb_mar, genotype, genotype, palette_gt,
f"PANDA-PCA (Dingwall, all {n:,} cells)", f"PANDA-Marker (Dingwall, all {n:,} cells)",
"Dingwall En1-cKO vs WT — PANDA-PCA vs PANDA-Marker embedding",
FIG_S / "24_pca_vs_marker_umaps_dingwall_by_genotype.pdf",
legend_title="Genotype",
)
P_pca_r, P_mar_r, palette_c = build_class_palette(P_pca, P_mar, min_frac=0.005)
scatter_side_by_side(
emb_pca, emb_mar, P_pca_r, P_mar_r, palette_c,
"PANDA-PCA — predicted class", "PANDA-Marker — predicted class",
f"Dingwall — PANDA-PCA vs PANDA-Marker predicted class map (n={n:,})",
FIG_S / "24b_pca_vs_marker_umaps_dingwall_by_class.pdf",
legend_title="Predicted class",
)
# ------------------- dahlin -------------------
def _load_dahlin_raw():
D_DIR = ROOT / "data/corpus/hematopoiesis/held_out_unlabeled/dahlin_extract"
GT = {"SIGAB1":"WT","SIGAC1":"WT","SIGAD1":"WT","SIGAF1":"WT","SIGAG1":"WT",
"SIGAH1":"WT","SIGAG8":"Kit_W41","SIGAH8":"Kit_W41"}
parts = []
for f in sorted(D_DIR.glob("*.txt.gz")):
sample = f.name.split("_")[1].split(".")[0]
df = pd.read_csv(f, sep="\t", compression="gzip", index_col=0)
X = sp.csr_matrix(df.values.T.astype(np.float32))
obs = pd.DataFrame(index=[f"{sample}_{bc}" for bc in df.columns.astype(str)])
obs["sample"] = sample; obs["genotype"] = GT.get(sample, "unknown")
var = pd.DataFrame(index=df.index.astype(str))
parts.append(ad.AnnData(X=X, obs=obs, var=var))
a = ad.concat(parts, join="outer")
import mygene
mg = mygene.MyGeneInfo()
res = mg.querymany(a.var_names.astype(str).tolist(), scopes="ensembl.gene",
fields="symbol", species="mouse", verbose=False)
id2sym = {r["query"]: r["symbol"] for r in res if "symbol" in r}
syms = pd.Series(a.var_names.astype(str)).map(id2sym).values
keep = pd.notna(syms)
a = a[:, keep].copy(); a.var_names = syms[keep]; a.var_names_make_unique()
return a
def fig_dahlin_pca_vs_marker():
cache = FIG_S / "_cache_dahlin_full.npz"
if cache.exists():
c = np.load(cache, allow_pickle=True)
emb_pca = c["emb_pca"]; emb_mar = c["emb_mar"]
P_pca = c["P_pca"].astype(str); P_mar = c["P_mar"].astype(str)
gen = c["genotype"].astype(str)
n = len(gen)
print(f"[dahlin] loaded cache n={n}", flush=True)
else:
print("[dahlin] loading raw", flush=True)
a = _load_dahlin_raw()
gen = a.obs["genotype"].astype(str).values
n = a.n_obs
print(f"[dahlin] projecting all {n} cells", flush=True)
Z_pca, P_pca, _ = project(a, "hematopoiesis", "pca")
Z_mar, P_mar, _ = project(a, "hematopoiesis", "marker")
print(f"[dahlin] umap all {n}", flush=True)
emb_pca = do_umap(Z_pca)
emb_mar = do_umap(Z_mar)
np.savez(cache,
emb_pca=emb_pca, emb_mar=emb_mar, P_pca=P_pca, P_mar=P_mar, genotype=gen)
palette_gt = {"WT": GENOTYPE_COLORS["WT"], "Kit_W41": GENOTYPE_COLORS["Kit_W41"],
"unknown": GENOTYPE_COLORS["other"]}
scatter_side_by_side(
emb_pca, emb_mar, gen, gen, palette_gt,
f"PANDA-PCA (Dahlin, all {n:,} cells)", f"PANDA-Marker (Dahlin, all {n:,} cells)",
"Dahlin WT vs Kit-W41 — PANDA-PCA vs PANDA-Marker embedding",
FIG_S / "25_pca_vs_marker_umaps_dahlin_by_genotype.pdf",
legend_title="Genotype",
)
P_pca_r, P_mar_r, palette_c = build_class_palette(P_pca, P_mar, min_frac=0.005)
scatter_side_by_side(
emb_pca, emb_mar, P_pca_r, P_mar_r, palette_c,
"PANDA-PCA — predicted class", "PANDA-Marker — predicted class",
f"Dahlin — PANDA-PCA vs PANDA-Marker predicted class map (n={n:,})",
FIG_S / "25b_pca_vs_marker_umaps_dahlin_by_class.pdf",
legend_title="Predicted class",
)
# ------------------- veres -------------------
def _load_veres():
SHARON_DIR = ROOT / "data/corpus/pancreas/held_out_unlabeled/sharon_extract"
parts = []
for meta_file in sorted(SHARON_DIR.glob("*.cell_metadata.tsv.gz")):
counts_file = str(meta_file).replace("cell_metadata", "processed_counts")
if not Path(counts_file).exists(): continue
meta = pd.read_csv(meta_file, sep="\t", compression="gzip")
counts = pd.read_csv(counts_file, sep="\t", compression="gzip", index_col=0)
obs = meta.set_index("library.barcode")
obs = obs.loc[obs.index.intersection(counts.index)]
counts_al = counts.loc[obs.index]
X = sp.csr_matrix(counts_al.values.astype(np.float32))
a = ad.AnnData(X=X, obs=obs, var=pd.DataFrame(index=counts_al.columns))
a.var_names_make_unique()
# sharon_extract is NOT purely the Stage 3-6 differentiation: it also
# ships GSM3141996 (ES/iPS comparison) and GSM3142001 (primary human
# islets, GSE84133). Tag the source so those cells are not silently
# pooled with the staged in-vitro cells.
nm = Path(meta_file).name
if "HumanIslets" in nm:
grp = "primary islets (GSE84133)"
elif "ES_iPS" in nm:
grp = "ES/iPS comparison"
else:
grp = "differentiation"
a.obs["veres_group"] = grp
parts.append(a)
out = ad.concat(parts, join="outer")
# human->mouse symbol case-fold (same heuristic as run_all_zero_shot.infer):
# the corpus + checkpoints use mouse Title-case symbols; without this the HVG
# intersection collapses and every cell predicts one junk class (the bug that
# produced the old all-mesenchyme S26b page).
vn = out.var_names.astype(str)
n_upper = sum(1 for g in vn[:1000] if g.isupper() and len(g) > 1)
if n_upper > 500:
out.var_names = [g.capitalize() for g in vn]
out.var_names_make_unique()
print(f"[veres] case-folded {n_upper}/1000 uppercase symbols human->mouse", flush=True)
return out
def fig_veres_pca_vs_marker():
cache = FIG_S / "_cache_veres_full_v2.npz"
if cache.exists():
c = np.load(cache, allow_pickle=True)
emb_pca = c["emb_pca"]; emb_mar = c["emb_mar"]
P_pca = c["P_pca"].astype(str); P_mar = c["P_mar"].astype(str)
st_str = c["stage"].astype(str)
n = len(st_str)
print(f"[veres] loaded cache n={n}", flush=True)
else:
print("[veres] loading raw", flush=True)
a = _load_veres()
stage_col = "Stage" if "Stage" in a.obs.columns else "stage"
stage = pd.to_numeric(a.obs[stage_col], errors="coerce").fillna(-1).astype(int).values
grp = a.obs["veres_group"].astype(str).values
# non-differentiation cells get their own legend entries instead of a
# single anonymous "unstaged" grey blob
st_str = np.array([g if g != "differentiation" else
("unstaged" if s == -1 else str(s))
for s, g in zip(stage, grp)])
n = a.n_obs
print(f"[veres] projecting all {n} cells (stage dist: "
f"{pd.Series(st_str).value_counts().to_dict()})", flush=True)
Z_pca, P_pca, _ = project(a, "pancreas", "pca")
Z_mar, P_mar, _ = project(a, "pancreas", "marker")
print(f"[veres] umap all {n}", flush=True)
emb_pca = do_umap(Z_pca)
emb_mar = do_umap(Z_mar)
np.savez(cache,
emb_pca=emb_pca, emb_mar=emb_mar, P_pca=P_pca, P_mar=P_mar, stage=st_str)
# canonical stage palette from palette.py
# sentinel is relabeled "unstaged" upstream; don't also keep "-1" or the
# legend shows both for the same group
palette_st = {k: v for k, v in STAGE_COLORS.items() if k != "-1"}
palette_st["unstaged"] = "#bbbbbb"
palette_st["primary islets (GSE84133)"] = "#7f7f7f"
palette_st["ES/iPS comparison"] = "#c49a6c"
scatter_side_by_side(
emb_pca, emb_mar, st_str, st_str, palette_st,
f"PANDA-PCA (Veres, all {n:,} cells)", f"PANDA-Marker (Veres, all {n:,} cells)",
"Veres SC-beta differentiation Stage 3-6 — PANDA-PCA vs PANDA-Marker embedding",
FIG_S / "26_pca_vs_marker_umaps_veres_by_stage.pdf",
legend_title="Stage",
)
P_pca_r, P_mar_r, palette_c = build_class_palette(P_pca, P_mar, min_frac=0.005)
# Report actual class diversity in the title so readers understand why the
# Veres in-vitro slice collapses onto a small subset of pancreas prototypes.
from collections import Counter
top_pca = ", ".join([f"{c} (n={k:,})"
for c, k in Counter(P_pca.tolist()).most_common(5)])
top_mar = ", ".join([f"{c} (n={k:,})"
for c, k in Counter(P_mar.tolist()).most_common(5)])
n_pca_cls = len(set(P_pca.tolist()))
n_mar_cls = len(set(P_mar.tolist()))
subtitle = (
f"Veres in-vitro (n={n:,}) — Marker predicts {n_mar_cls} class(es) "
f"[top-5 shown: {top_mar}]; PCA predicts {n_pca_cls} class(es) [top-5: {top_pca}]."
)
scatter_side_by_side(
emb_pca, emb_mar, P_pca_r, P_mar_r, palette_c,
"PANDA-PCA — predicted class", "PANDA-Marker — predicted class",
subtitle,
FIG_S / "26b_pca_vs_marker_umaps_veres_by_class.pdf",
legend_title="Predicted class",
)
# ------------------- en1-cKO enrichment bars -------------------
def fig_en1_enrichment():
raw = ad.read_h5ad(ROOT / "data/raw/GSE220977_combined.h5ad")
CKO = {"GSM6833482", "GSM6833483"}
WT = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"}
genotype = np.where(raw.obs["sample"].astype(str).isin(list(CKO)), "En1-cKO",
np.where(raw.obs["sample"].astype(str).isin(list(WT)), "WT", "other"))
pred = pd.read_csv(ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv")
common = raw.obs_names.intersection(pd.Index(pred["cell_id"].astype(str)))
keep = raw.obs_names.isin(common)
raw = raw[keep].copy()
gt = genotype[keep]
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
labels = np.array([pred_map.get(c, "unknown") for c in raw.obs_names])
labeled = (gt != "other")
baseline = (gt[labeled] == "En1-cKO").sum() / labeled.sum()
df = pd.DataFrame({"pred": labels, "gt": gt, "labeled": labeled})
dfl = df[df["labeled"]]
rows = []
for cls, sub in dfl.groupby("pred"):
n = len(sub)
if n < 50: continue
frac_cko = (sub["gt"] == "En1-cKO").sum() / n
rows.append({"class": cls, "n": n, "frac_cko": frac_cko,
"delta": frac_cko - baseline})
d = pd.DataFrame(rows).sort_values("delta", ascending=False)
d.to_csv(FIG_S / "27_dingwall_en1_enrichment.csv", index=False)
fig, ax = plt.subplots(figsize=(14, 8))
y = np.arange(len(d))
cols = [GENOTYPE_COLORS["En1-cKO"] if r["delta"] > 0.05
else GENOTYPE_COLORS["WT"] if r["delta"] < -0.05
else "#888888"
for _, r in d.iterrows()]
deltas = (d["frac_cko"] - baseline).values
ax.barh(y, deltas, color=cols, edgecolor="k", linewidth=0.5)
ax.axvline(0, color="black", linewidth=1)
# place value labels always to the right of the bar tip with an offset in
# display coords so short/negative bars can't crash into the y-tick labels
for i, row in enumerate(d.itertuples()):
delta_i = row.frac_cko - baseline
# negative bars: anchor at the zero line so text never overprints
# the axvline or the bar itself (rows never mix +/- bars)
ax.annotate(f"{row.frac_cko:.2f} (n={int(row.n):,})",
xy=(max(delta_i, 0.0), i), xycoords="data",
xytext=(6, 0), textcoords="offset points",
ha="left", va="center", fontsize=ANNOT_FS, clip_on=False)
ax.set_yticks(y); ax.set_yticklabels(d["class"], fontsize=TICK_FS)
ax.tick_params(axis="y", pad=6)
# add headroom on the right so annotations don't clip
dmin, dmax = float(deltas.min()), float(deltas.max())
span = max(abs(dmin), abs(dmax))
ax.set_xlim(dmin - 0.05 * span, dmax + 0.55 * span)
ax.set_xlabel(f"Δ En1-cKO fraction vs baseline {baseline:.2f}", fontsize=LABEL_FS)
ax.invert_yaxis()
ax.set_title("Dingwall — En1-cKO enrichment per PANDA-predicted class\n"
"(red = cKO-enriched; blue = WT-enriched; grey = at baseline)",
fontsize=TITLE_FS)
plt.tight_layout()
plt.savefig(FIG_S / "27_dingwall_en1_enrichment.pdf", bbox_inches="tight")
plt.close()
print(f"[fig] 27_dingwall_en1_enrichment.pdf ({len(d)} classes shown)")
# ------------------- melanocyte pathway modules -------------------
MELANOCYTE_MODULES = {
"MITF regulon (up in WT, down in cKO)": ["Mitf", "Dct", "Tyrp1", "Tyr", "Pmel", "Mlana", "Slc24a5", "Sox10"],
"keratinocyte contamination / Krt (up in cKO)": ["Krt5", "Krt14", "Krt15"],
"melanocyte proliferation / migration (down in cKO)": ["Ets1", "Kit", "Pax3", "Sox9"],
"pigment biogenesis (down in cKO)": ["Gpnmb", "Slc45a2", "Oca2", "Trpm1"],
}
def fig_melanocyte_pathway():
raw = ad.read_h5ad(ROOT / "data/raw/GSE220977_combined.h5ad")
CKO = {"GSM6833482", "GSM6833483"}
WT = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"}
genotype = np.where(raw.obs["sample"].astype(str).isin(list(CKO)), "En1-cKO",
np.where(raw.obs["sample"].astype(str).isin(list(WT)), "WT", "other"))
pred = pd.read_csv(ROOT / "discovery/pan_skin/marker/dingwall_predictions.csv")
pred_map = dict(zip(pred["cell_id"].astype(str), pred["pred_label"]))
labels = np.array([pred_map.get(c, "unknown") for c in raw.obs_names])
mel_mask = (labels == "melanocyte") & (genotype != "other")
a_mel = raw[mel_mask].copy()
gt_mel = genotype[mel_mask]
print(f"[melanocyte] {a_mel.n_obs} cells (WT {(gt_mel=='WT').sum()} + cKO {(gt_mel=='En1-cKO').sum()})", flush=True)
sc.pp.normalize_total(a_mel, target_sum=1e4); sc.pp.log1p(a_mel)
rows = []
for mod_name, genes in MELANOCYTE_MODULES.items():
present = [g for g in genes if g in a_mel.var_names]
if not present: continue
sc.tl.score_genes(a_mel, gene_list=present, score_name="s_tmp", use_raw=False)
s = a_mel.obs["s_tmp"].values
wt_mean = s[gt_mel == "WT"].mean()
cko_mean = s[gt_mel == "En1-cKO"].mean()
from scipy.stats import mannwhitneyu
_, p = mannwhitneyu(s[gt_mel == "WT"], s[gt_mel == "En1-cKO"], alternative="two-sided")
rows.append({"module": mod_name, "genes": ", ".join(present),
"wt_mean": wt_mean, "cko_mean": cko_mean,
"delta": cko_mean - wt_mean, "p": p})
d = pd.DataFrame(rows)
d.to_csv(FIG_S / "28_melanocyte_pathway_modules.csv", index=False)
fig, ax = plt.subplots(figsize=(14.5, 7.5))
x = np.arange(len(d))
width = 0.35
ax.bar(x - width/2, d["wt_mean"], width, label="WT",
color=GENOTYPE_COLORS["WT"], edgecolor="k")
ax.bar(x + width/2, d["cko_mean"], width, label="En1-cKO",
color=GENOTYPE_COLORS["En1-cKO"], edgecolor="k")
# find headroom so the p-labels never collide with the suptitle
y_max_data = max(d["wt_mean"].max(), d["cko_mean"].max())
y_min_data = min(0.0, d["wt_mean"].min(), d["cko_mean"].min())
span = y_max_data - y_min_data
for i, r in enumerate(d.itertuples()):
y_top = max(r.wt_mean, r.cko_mean) + 0.03 * span
sig = "***" if r.p < 1e-3 else "**" if r.p < 1e-2 else "*" if r.p < 5e-2 else "n.s."
ax.text(i, y_top, f"p={r.p:.1e} {sig}", ha="center", fontsize=ANNOT_FS)
# explicit y-axis room above the tallest p-label
ax.set_ylim(y_min_data - 0.05 * span, y_max_data + 0.18 * span)
ax.set_xticks(x)
ax.set_xticklabels([m.split(" (")[0] for m in d["module"]], fontsize=TICK_FS, rotation=15, ha="right")
ax.set_ylabel("Module score (mean per cell)", fontsize=LABEL_FS)
ax.set_title("Melanocyte pathway modules — WT vs En1-cKO (Dingwall predicted melanocytes)",
fontsize=TITLE_FS, pad=14)
ax.legend(loc="center left", bbox_to_anchor=(1.02, 0.5), frameon=False,
title="Genotype", fontsize=LEGEND_FS, title_fontsize=TITLE_FS)
ax.axhline(0, color="black", linewidth=0.5, linestyle="--")
plt.subplots_adjust(top=0.85)
plt.tight_layout()
plt.savefig(FIG_S / "28_melanocyte_pathway_modules.pdf", bbox_inches="tight")
plt.close()
print(f"[fig] 28_melanocyte_pathway_modules.pdf")
def merge_pdf():
"""merge all supplement pages into figures/PANDA_supplement.pdf."""
from pypdf import PdfWriter
w = PdfWriter()
order = [
FIG_S / "01_cv_summary.pdf",
FIG_S / "02_per_class_f1.pdf",
FIG_S / "03_prototype_cosine.pdf",
FIG_S / "05_adversary_purification.pdf",
FIG_S / "06_cross_system_prototypes.pdf",
FIG_S / "11_novel_populations.pdf",
FIG_S / "13_dingwall_umap.pdf",
FIG_S / "14_dahlin_umap.pdf",
FIG_S / "15_veres_umap.pdf",
FIG_S / "16_dingwall_discovery.pdf",
FIG_S / "17_dahlin_discovery.pdf",
FIG_S / "18_veres_discovery.pdf",
FIG_S / "19_myeloid_network.pdf",
FIG_S / "20_placode_wnt_module.pdf",
FIG_S / "23_anchor_delta_recall.pdf",
FIG_S / "24_pca_vs_marker_umaps_dingwall_by_genotype.pdf",
FIG_S / "24b_pca_vs_marker_umaps_dingwall_by_class.pdf",
FIG_S / "25_pca_vs_marker_umaps_dahlin_by_genotype.pdf",
FIG_S / "25b_pca_vs_marker_umaps_dahlin_by_class.pdf",
FIG_S / "26_pca_vs_marker_umaps_veres_by_stage.pdf",
FIG_S / "26b_pca_vs_marker_umaps_veres_by_class.pdf",
FIG_S / "27_dingwall_en1_enrichment.pdf",
FIG_S / "28_melanocyte_pathway_modules.pdf",
]
for p in order:
if p.exists():
w.append(str(p))
print(f" + {p.name}")
else:
print(f" [skip] {p.name} missing")
out = ROOT / "figures/PANDA_supplement.pdf"
with open(out, "wb") as f:
w.write(f)
print(f"\nwrote {out} ({out.stat().st_size / 1024:.0f} KB)")
def main():
fig_dingwall_pca_vs_marker()
fig_dahlin_pca_vs_marker()
fig_veres_pca_vs_marker()
fig_en1_enrichment()
fig_melanocyte_pathway()
merge_pdf()
if __name__ == "__main__":
main()