| """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
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
| 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}")
|
|
|
|
|
|
|
| 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",
|
| )
|
|
|
|
|
|
|
| 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",
|
| )
|
|
|
|
|
|
|
| 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()
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
| 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",
|
| )
|
|
|
|
|
|
|
| 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)
|
|
|
|
|
| for i, row in enumerate(d.itertuples()):
|
| delta_i = row.frac_cko - baseline
|
|
|
|
|
| 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)
|
|
|
| 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_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")
|
|
|
| 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)
|
|
|
| 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()
|
|
|