| """zero-shot Aldrich (GSE220977, ~25k WT + En1-cKO volar snRNA-seq) through frozen PANDA."""
|
| from __future__ import annotations
|
| from pathlib import Path
|
| import warnings, json, sys, pickle
|
| warnings.filterwarnings("ignore")
|
|
|
| import numpy as np
|
| import pandas as pd
|
| import anndata as ad
|
| import scanpy as sc
|
| import scipy.sparse as sp
|
| import torch
|
| import torch.nn.functional as F
|
|
|
| from pathlib import Path as _P_root
|
| ROOT = _P_root(__file__).resolve().parents[2]
|
| ROOT_STR = str(ROOT)
|
| sys.path.insert(0, ROOT_STR)
|
| from panda import PANDAEncoder
|
|
|
| DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| CKPT_DIR = Path(f"{ROOT_STR}/checkpoints/pan_skin")
|
| HARM = Path(f"{ROOT_STR}/data/corpus/pan_skin/harmonized")
|
| OUT_DIR = Path(f"{ROOT_STR}/discovery/pan_skin/marker")
|
| OUT_DIR.mkdir(parents=True, exist_ok=True)
|
| TARGET = Path(f"{ROOT_STR}/data/processed/skin/adata_processed.h5ad")
|
|
|
|
|
| def project_to_corpus(a: ad.AnnData, shared_hvgs, mu, sig):
|
| """z-scored dense matrix aligned to shared_hvgs, zero-impute missing."""
|
| n = a.n_obs
|
| G = len(shared_hvgs)
|
| hvg_to_ix = {g: i for i, g in enumerate(shared_hvgs)}
|
| var_names = list(a.var_names.astype(str))
|
| common = [g for g in var_names if g in hvg_to_ix]
|
| present_frac = len(common) / G
|
| print(f"[proj] {len(common)}/{G} shared HVGs present ({present_frac:.1%})", flush=True)
|
|
|
| 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((n, G), dtype=np.float32)
|
| cols = [hvg_to_ix[g] for g in common]
|
| Xf[:, cols] = X
|
| Xz = np.clip((Xf - mu.astype(np.float32)) / sig.astype(np.float32), -10, 10)
|
| return Xz, present_frac
|
|
|
|
|
| def main():
|
| ck = torch.load(CKPT_DIR / "panda_final.pt", map_location=DEVICE, weights_only=False)
|
| classes = ck["classes"]; datasets = ck["datasets"]
|
| with (CKPT_DIR / "label_encoding.json").open() as f:
|
| enc = json.load(f)
|
| model = PANDAEncoder(n_pca=50, n_classes=len(classes),
|
| n_datasets=len(datasets)).to(DEVICE).eval()
|
| model.load_state_dict(ck["model"])
|
| prototypes = torch.tensor(ck["prototypes"]).to(DEVICE)
|
| print(f"[model] classes: {classes}", flush=True)
|
|
|
| stats = np.load(HARM / "corpus_stats.npz", allow_pickle=True)
|
| shared_hvgs = [str(g) for g in stats["shared_hvgs"]]
|
| mu, sig = stats["mean"], stats["std"]
|
| with open(HARM / "pca_basis.pkl", "rb") as f:
|
| pca_basis = pickle.load(f)
|
|
|
| a = ad.read_h5ad(TARGET)
|
| if a.raw is not None:
|
| raw = a.raw.to_adata(); raw.obs = a.obs.copy(); a = raw
|
| print(f"[target] Aldrich shape: {a.shape}, "
|
| f"genotype counts: {a.obs['genotype'].value_counts().to_dict()}", flush=True)
|
|
|
| Xz, present_frac = project_to_corpus(a, shared_hvgs, mu, sig)
|
| counts = np.asarray(a.X.sum(axis=1)).ravel()
|
| logc = np.log10(counts + 1); logc = (logc - logc.mean()) / (logc.std() + 1e-6)
|
| Xpca = pca_basis.transform(Xz).astype(np.float32)
|
| mhf = np.full(a.n_obs, 1.0 - present_frac, dtype=np.float32)
|
|
|
| batch = 4096
|
| N = a.n_obs
|
| all_z, all_logits = [], []
|
| with torch.no_grad():
|
| for i in range(0, N, batch):
|
| xb = torch.from_numpy(Xpca[i:i+batch]).to(DEVICE)
|
| aux = torch.from_numpy(np.stack([mhf[i:i+batch], logc[i:i+batch]], 1).astype(np.float32)).to(DEVICE)
|
| out = model(xb, aux, lam_dann=0.0)
|
| all_z.append(out["z"].cpu().numpy())
|
| all_logits.append(out["logits"].cpu().numpy())
|
| Z = np.concatenate(all_z, axis=0)
|
| logits = np.concatenate(all_logits, axis=0)
|
|
|
| protos_np = prototypes.cpu().numpy()
|
| protos_np = protos_np / (np.linalg.norm(protos_np, axis=1, keepdims=True) + 1e-8)
|
| cos = Z @ protos_np.T
|
|
|
| tau = 0.07
|
| proto_probs = np.exp((cos - cos.max(axis=1, keepdims=True)) / tau)
|
| proto_probs = proto_probs / proto_probs.sum(axis=1, keepdims=True)
|
|
|
| pred_ix = cos.argmax(axis=1)
|
| pred_conf = cos.max(axis=1)
|
| entropy = -(proto_probs * np.log(proto_probs + 1e-12)).sum(axis=1)
|
|
|
| abstain = pred_conf < 0.3
|
| pred_label = np.array([classes[i] for i in pred_ix], dtype=object)
|
| pred_label[abstain] = "UNK/abstain"
|
|
|
|
|
| prior_train = np.bincount(np.arange(len(classes)), minlength=len(classes)).astype(float) + 1.0
|
| prior_train /= prior_train.sum()
|
| prior_test = proto_probs.mean(axis=0)
|
| ratio = np.log(prior_test / prior_train + 1e-8)
|
| bbse_logits = np.log(proto_probs + 1e-12) + ratio[None, :]
|
| bbse_probs = np.exp(bbse_logits - bbse_logits.max(axis=1, keepdims=True))
|
| bbse_probs = bbse_probs / bbse_probs.sum(axis=1, keepdims=True)
|
| bbse_pred = bbse_probs.argmax(axis=1)
|
|
|
| a.obs["pred_label"] = pred_label
|
| a.obs["pred_conf"] = pred_conf.astype(np.float32)
|
| a.obs["pred_entropy"] = entropy.astype(np.float32)
|
| a.obs["abstain"] = abstain
|
| a.obs["pred_bbse_label"] = [classes[i] for i in bbse_pred]
|
|
|
| tbl = a.obs[[c for c in ["genotype","sample","cell_type","fate_label",
|
| "pred_label","pred_conf","pred_entropy",
|
| "abstain","pred_bbse_label"] if c in a.obs.columns]].copy()
|
| tbl.to_csv(OUT_DIR / "50_aldrich_predictions.csv")
|
| print(f"[out] wrote {OUT_DIR/'50_aldrich_predictions.csv'} ({len(tbl)} rows)", flush=True)
|
|
|
| print("\n[summary] naive pred_label breakdown:")
|
| print(a.obs["pred_label"].value_counts().head(20))
|
| print(f"[summary] abstain rate: {abstain.mean():.1%}")
|
| if "genotype" in a.obs.columns:
|
| print("\n[summary] pred_label x genotype:")
|
| print(pd.crosstab(a.obs["pred_label"], a.obs["genotype"]))
|
| print("\n[summary] BBSE-corrected pred_label breakdown:")
|
| print(a.obs["pred_bbse_label"].value_counts().head(20))
|
|
|
|
|
| slim = ad.AnnData(X=sp.csr_matrix((a.n_obs, 1), dtype=np.float32), obs=a.obs)
|
| slim.obsm["Z_projection"] = Z.astype(np.float32)
|
| slim.obsm["proto_cos"] = cos.astype(np.float32)
|
| slim.obsm["proto_probs"] = proto_probs.astype(np.float32)
|
| slim.obsm["bbse_probs"] = bbse_probs.astype(np.float32)
|
| slim.uns["classes"] = classes
|
| slim.uns["prototypes"] = protos_np
|
| slim.write_h5ad(OUT_DIR / "50_aldrich_projections.h5ad", compression="gzip")
|
| print(f"[out] wrote {OUT_DIR/'50_aldrich_projections.h5ad'}", flush=True)
|
|
|
|
|
| if __name__ == "__main__":
|
| main()
|
|
|