File size: 7,832 Bytes
141bacd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | """post-hoc EDEN (S100a4+Tnc+Pdgfra+ derm10 per Dingwall 2024) detection on v3 pan-skin predictions; cKO vs WT dermal-fibro proportions."""
from pathlib import Path
import warnings, json, sys, pickle, numpy as np, pandas as pd, anndata as ad, scanpy as sc, scipy.sparse as sp, torch, torch.nn.functional as F
warnings.filterwarnings("ignore"); sc.settings.verbosity = 0
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))
from panda import PANDAEncoder
ROOT = Path(str(PANDA_ROOT))
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
CKO_GSMS = {"GSM6833482", "GSM6833483"} # CORRECTED: 480/481 are rttaControl (WT), not cKO
WT_GSMS = {"GSM6833478", "GSM6833479", "GSM6833480", "GSM6833481"} # CORRECTED: 4 Cre-neg controls per GEO metadata
# Dingwall 2024 EDEN definition: cluster 20 top-2 markers + broad lineage
EDEN_CANONICAL_MARKERS = ["S100a4", "Tnc", "Pdgfra"]
def predict(a, variant="marker"):
ck = torch.load(ROOT / f"checkpoints/pan_skin/{variant}/panda_final.pt",
map_location=DEVICE, weights_only=False)
classes = ck["classes"]; marker_genes = ck.get("marker_genes", [])
stats = np.load(ROOT / "data/corpus/pan_skin/harmonized/corpus_stats.npz", allow_pickle=True)
pca = pickle.load(open(ROOT / "data/corpus/pan_skin/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":
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"])
preds, probs = [], []
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)
mc = model.max_sub_cos(out["z"])
preds.append(mc.argmax(dim=1).cpu().numpy())
probs.append(F.softmax(mc / 0.07, dim=1).cpu().numpy())
return np.array([classes[i] for i in np.concatenate(preds)]), np.concatenate(probs)
def score_eden_module(a):
sc.pp.normalize_total(a, target_sum=1e4); sc.pp.log1p(a)
present = [g for g in EDEN_CANONICAL_MARKERS if g in a.var_names]
if not present:
return np.zeros(a.n_obs), present
sc.tl.score_genes(a, gene_list=present, score_name="eden_score", use_raw=False)
return a.obs["eden_score"].values, present
def main():
print("[eden] loading Dingwall raw counts", flush=True)
raw = ad.read_h5ad(ROOT / "data/raw/GSE220977_combined.h5ad")
genotype = np.where(raw.obs["sample"].astype(str).isin(list(CKO_GSMS)), "En1-cKO",
np.where(raw.obs["sample"].astype(str).isin(list(WT_GSMS)), "WT", "other"))
print(f"[eden] {raw.n_obs} cells, genotype dist: {pd.Series(genotype).value_counts().to_dict()}", flush=True)
print("[eden] running v3 PANDA-Marker prediction", flush=True)
pred, probs = predict(raw, variant="marker")
print(f"[eden] pred dist: {pd.Series(pred).value_counts().head().to_dict()}", flush=True)
dermal_mask = np.isin(pred, ["fibroblast-papillary", "fibroblast-reticular"])
print(f"[eden] dermal-fibroblast predictions: {dermal_mask.sum()} cells", flush=True)
print(f"[eden] scoring EDEN module ({EDEN_CANONICAL_MARKERS})", flush=True)
eden_score, present = score_eden_module(raw.copy())
print(f"[eden] markers present in Dingwall counts: {present}", flush=True)
dermal_ix = np.where(dermal_mask)[0]
dermal_scores = eden_score[dermal_ix]
thr_p95 = np.percentile(dermal_scores, 95)
thr_p90 = np.percentile(dermal_scores, 90)
eden_core_p95 = dermal_ix[dermal_scores >= thr_p95]
eden_core_p90 = dermal_ix[dermal_scores >= thr_p90]
print(f"\n[eden] EDEN core (S100a4+Tnc+Pdgfra top-5% among dermal fibroblasts):", flush=True)
print(f" p95 threshold: {thr_p95:.3f} n={len(eden_core_p95)}", flush=True)
print(f" p90 threshold: {thr_p90:.3f} n={len(eden_core_p90)}", flush=True)
for p, ix, thr in [(95, eden_core_p95, thr_p95), (90, eden_core_p90, thr_p90)]:
gt = genotype[ix]
labeled = gt != "other"
n_wt = int((gt[labeled] == "WT").sum()); n_ko = int((gt[labeled] == "En1-cKO").sum())
frac_wt = n_wt / max(1, n_wt + n_ko)
# dingwall paper: control=1.99% dermal, cKO=0.08% dermal → ~25x depletion in cluster 20
print(f" p{p}: WT={n_wt} cKO={n_ko} WT_frac={frac_wt:.3f} "
f"(paper says WT>cKO ~25x depletion in cluster 20)", flush=True)
out = ROOT / "discovery/pan_skin/marker"
out.mkdir(parents=True, exist_ok=True)
df = pd.DataFrame({
"cell_id": raw.obs_names,
"sample": raw.obs["sample"].astype(str).values,
"genotype": genotype,
"pred_label": pred,
"max_cos": probs.max(axis=1),
"eden_score": eden_score,
"is_dermal_fibro": dermal_mask,
"is_eden_core_p95": np.isin(np.arange(raw.n_obs), eden_core_p95),
"is_eden_core_p90": np.isin(np.arange(raw.n_obs), eden_core_p90),
})
df.to_csv(out / "98_eden_dingwall_predictions.csv", index=False)
summary = {
"system": "pan_skin",
"target": "Dingwall_GSE220977",
"eden_definition": {
"source": "Dingwall 2024 Dev Cell PMC10872420",
"markers_used_by_paper": ["S100a4", "Tnc", "Pdgfra"],
"cluster_id_paper": "cluster 20 / Derm10",
"paper_reported_size": {"WT_dermal_frac": 0.0199, "cKO_dermal_frac": 0.0008,
"WT_n_approx": 516, "cKO_n_approx": 21,
"depletion_ratio": 24.9},
},
"our_detection": {
"method": "S100a4+Tnc+Pdgfra module score on v3 dermal-fibroblast predictions, top-5% threshold",
"markers_present_in_dingwall_counts": present,
"n_dermal_fibroblast_cells": int(dermal_mask.sum()),
"n_eden_core_p95": int(len(eden_core_p95)),
"n_eden_core_p90": int(len(eden_core_p90)),
},
}
(out / "98_eden_summary.json").write_text(json.dumps(summary, indent=2, default=str))
print(f"\n[write] {out}/98_eden_*.{{csv,json}}", flush=True)
if __name__ == "__main__":
main()
|