| """variant B — fully supervised panda on replicated dingwall Derm0..Derm11 labels from script 103."""
|
| from __future__ import annotations
|
| from pathlib import Path
|
| import warnings, json, sys, time
|
| warnings.filterwarnings("ignore")
|
|
|
| import numpy as np
|
| import pandas as pd
|
| import anndata as ad
|
| import torch
|
| import torch.nn.functional as F
|
| from torch.utils.data import Dataset, DataLoader
|
| from scipy.stats import fisher_exact
|
|
|
| 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.model import (
|
| PANDAEncoder, supcon_loss, vicreg_loss, hsic_biased, subcenter_angular_infonce
|
| )
|
|
|
| ROOT = Path(str(PANDA_ROOT))
|
| REPLICA_H5 = ROOT / "data/processed/dingwall_replica/dingwall_replica.h5ad"
|
| OUT_DIR = ROOT / "discovery/pan_skin/marker"
|
| CK_DIR = ROOT / "checkpoints/pan_skin_dingwall_derm"
|
|
|
| TRAIN_FRAC = 0.7
|
| SEED = 0
|
| N_PCA = 40
|
| DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
|
|
|
| GUARANTEED_PER_CLASS = 6
|
| NATURAL_SLOTS = 96
|
| STAGE_EPOCHS = [15, 25, 40, 40]
|
| BALANCE_MIX = 0.5
|
|
|
|
|
|
|
|
|
| def genotype_stratified_split(labels: np.ndarray, genotypes: np.ndarray,
|
| frac_train: float = TRAIN_FRAC, seed: int = SEED
|
| ) -> tuple[np.ndarray, np.ndarray]:
|
| """stratified 70/30 within each (label, genotype) group; preserves cKO/WT ratio per class."""
|
| rng = np.random.default_rng(seed)
|
| n = len(labels); train = np.zeros(n, dtype=bool); test = np.zeros(n, dtype=bool)
|
| for lab in np.unique(labels):
|
| for g in np.unique(genotypes):
|
| idx = np.where((labels == lab) & (genotypes == g))[0]
|
| if len(idx) == 0: continue
|
| rng.shuffle(idx)
|
| k = max(1, int(len(idx) * frac_train)) if len(idx) > 1 else len(idx)
|
| train[idx[:k]] = True
|
| if len(idx) > 1:
|
| test[idx[k:]] = True
|
| return train, test
|
|
|
|
|
|
|
|
|
| class CorpusDataset(Dataset):
|
| def __init__(self, X, y, d, aux):
|
| self.X = X.astype(np.float32); self.y = y.astype(np.int64)
|
| self.d = d.astype(np.int64); self.aux = aux.astype(np.float32)
|
| def __len__(self): return self.X.shape[0]
|
| def __getitem__(self, i):
|
| return (torch.from_numpy(self.X[i]), torch.tensor(self.y[i]),
|
| torch.tensor(self.d[i]), torch.from_numpy(self.aux[i]))
|
|
|
|
|
| class HybridSampler:
|
| def __init__(self, y, n_batches=100, seed=0):
|
| self.y = np.asarray(y); self.n_batches = n_batches
|
| self.rng = np.random.default_rng(seed)
|
| self.classes = np.unique(self.y)
|
| self.by_cls = {int(c): np.where(self.y == c)[0] for c in self.classes}
|
| counts = np.bincount(self.y, minlength=int(self.classes.max()) + 1).astype(float)
|
| self.natural_p = counts / counts.sum()
|
| def __iter__(self):
|
| for _ in range(self.n_batches):
|
| batch = []
|
| for c in self.classes:
|
| idx = self.by_cls[int(c)]
|
| take = min(GUARANTEED_PER_CLASS, len(idx))
|
| if take > 0:
|
| batch.extend(self.rng.choice(idx, size=take, replace=(len(idx) < take)).tolist())
|
| for _ in range(NATURAL_SLOTS):
|
| c = self.rng.choice(len(self.natural_p), p=self.natural_p)
|
| idx = self.by_cls.get(int(c), self.by_cls[int(self.classes[0])])
|
| batch.append(int(self.rng.choice(idx)))
|
| yield batch
|
| def __len__(self): return self.n_batches
|
|
|
|
|
| def train_panda(X_tr, y_tr, d_tr, aux_tr, n_classes, n_datasets, ck_out: Path):
|
| ck_out.mkdir(parents=True, exist_ok=True)
|
| counts = np.bincount(y_tr, minlength=n_classes)
|
| inv_sqrt = 1.0 / np.sqrt(counts + 1); inv_sqrt = inv_sqrt / inv_sqrt.mean()
|
| class_w = BALANCE_MIX * inv_sqrt + (1 - BALANCE_MIX) * np.ones_like(inv_sqrt)
|
| class_w = torch.tensor(class_w, dtype=torch.float32, device=DEVICE)
|
|
|
| ds = CorpusDataset(X_tr, y_tr, d_tr, aux_tr)
|
| loader = DataLoader(ds, batch_sampler=HybridSampler(y_tr, n_batches=100), num_workers=0)
|
|
|
| model = PANDAEncoder(variant="pca", n_pca=X_tr.shape[1], n_classes=n_classes,
|
| n_datasets=n_datasets).to(DEVICE)
|
| opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4)
|
|
|
| for stage, n_ep in enumerate(STAGE_EPOCHS):
|
| print(f"[panda-B] stage {stage} ({n_ep} epochs)", flush=True)
|
| for e in range(n_ep):
|
| t0 = time.time(); losses = []
|
| for X_b, y_b, d_b, aux_b in loader:
|
| X_b = X_b.to(DEVICE); y_b = y_b.to(DEVICE); d_b = d_b.to(DEVICE); aux_b = aux_b.to(DEVICE)
|
| lam = 1.0 if stage >= 2 else 0.0
|
| out = model(X_b, aux_b, lam_dann=lam)
|
| L_supcon = supcon_loss(out["z"], y_b)
|
| L_vic = vicreg_loss(out["z"])
|
| L_ce = F.cross_entropy(out["logits"], y_b, weight=class_w, label_smoothing=0.05)
|
| total = L_supcon + 1.0 * L_vic + 0.4 * L_ce
|
| if stage >= 1:
|
| proto_ref = model.prototypes.detach().clone()
|
| total = total + 0.6 * subcenter_angular_infonce(out["z"], y_b, proto_ref)
|
| if stage >= 2:
|
| total = total + F.cross_entropy(out["dom"], d_b)
|
| total = total + 0.3 * F.mse_loss(out["depth"].squeeze(1), aux_b[:, 1])
|
| total = total + 0.05 * hsic_biased(out["repr"], aux_b[:, 1:2])
|
| opt.zero_grad(); total.backward()
|
| torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
|
| opt.step()
|
| if stage >= 1:
|
| model.update_prototypes(out["z"].detach(), y_b)
|
| losses.append(float(total.item()))
|
| if (e + 1) % 5 == 0:
|
| print(f" ep {e+1}/{n_ep} loss={np.mean(losses):.3f} dt={time.time()-t0:.1f}s", flush=True)
|
| torch.save({"model": model.state_dict()}, ck_out / f"panda_stage{stage}.pt")
|
| torch.save({"model": model.state_dict(),
|
| "prototypes": model.prototypes.detach().cpu().numpy()},
|
| ck_out / "panda_final.pt")
|
| return model
|
|
|
|
|
| @torch.no_grad()
|
| def infer(model, X, aux):
|
| model.eval()
|
| Xt = torch.from_numpy(X.astype(np.float32)).to(DEVICE)
|
| at = torch.from_numpy(aux.astype(np.float32)).to(DEVICE)
|
| B = 4096; preds = []; confs = []
|
| for i in range(0, len(Xt), B):
|
| out = model(Xt[i:i+B], at[i:i+B])
|
| p = F.softmax(out["logits"], dim=1)
|
| preds.append(p.argmax(dim=1).cpu().numpy())
|
| confs.append(p.max(dim=1).values.cpu().numpy())
|
| return np.concatenate(preds), np.concatenate(confs)
|
|
|
|
|
|
|
|
|
| def depletion_table(true_or_pred: np.ndarray, genotype: np.ndarray, class_names: list[str]
|
| ) -> pd.DataFrame:
|
| n_wt = int((genotype == "WT").sum()); n_cko = int((genotype == "En1-cKO").sum())
|
| base = n_cko / max(n_wt + n_cko, 1)
|
| rows = []
|
| for i, cn in enumerate(class_names):
|
| m = true_or_pred == i
|
| w = int(((genotype == "WT") & m).sum()); k = int(((genotype == "En1-cKO") & m).sum())
|
| if w + k == 0: continue
|
| try:
|
| odds, p = fisher_exact([[w, n_wt - w], [k, n_cko - k]], alternative="two-sided")
|
| except ValueError:
|
| odds, p = 1.0, 1.0
|
| rows.append({"derm_label": cn, "n": w + k, "n_WT": w, "n_cKO": k,
|
| "cko_frac": k / (w + k), "baseline_cko": base,
|
| "odds_ratio": float(odds), "fisher_p": float(p)})
|
| return pd.DataFrame(rows).sort_values("cko_frac")
|
|
|
|
|
| def main():
|
| OUT_DIR.mkdir(parents=True, exist_ok=True); CK_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
| print("[B] load replica", flush=True)
|
| if not REPLICA_H5.exists():
|
| raise FileNotFoundError(f"Run 103 first — {REPLICA_H5} missing")
|
| a = ad.read_h5ad(REPLICA_H5)
|
|
|
| dermal = a[a.obs["derm_label"].astype(str) != "non_dermal"].copy()
|
| print(f"[B] dermal n={dermal.n_obs}", flush=True)
|
| labels_str = dermal.obs["derm_label"].astype(str).values
|
| classes = sorted(set(labels_str))
|
| cls_ix = {c: i for i, c in enumerate(classes)}
|
| y_all = np.array([cls_ix[c] for c in labels_str])
|
| genotype = dermal.obs["genotype"].astype(str).values
|
|
|
|
|
| rep_key = dermal.uns.get("_replica_rep", "X_pca_harmony")
|
| if rep_key not in dermal.obsm:
|
| rep_key = "X_pca_harmony" if "X_pca_harmony" in dermal.obsm else "X_pca"
|
| X_all = np.asarray(dermal.obsm[rep_key])
|
| print(f"[B] using {rep_key} (d={X_all.shape[1]})", flush=True)
|
|
|
| sample_ix = {s: i for i, s in enumerate(sorted(dermal.obs["sample"].astype(str).unique()))}
|
| d_all = np.array([sample_ix[s] for s in dermal.obs["sample"].astype(str)])
|
| total_counts = np.asarray(dermal.X.sum(axis=1)).ravel()
|
| logc = np.log10(total_counts + 1); logc = (logc - logc.mean()) / (logc.std() + 1e-6)
|
| aux_all = np.stack([np.zeros(dermal.n_obs, dtype=np.float32), logc.astype(np.float32)], axis=1)
|
|
|
| print("[B] genotype-stratified 70/30 split", flush=True)
|
| tr, te = genotype_stratified_split(labels_str, genotype, frac_train=TRAIN_FRAC, seed=SEED)
|
| print(f"[B] train={tr.sum()} test={te.sum()}", flush=True)
|
|
|
| manifest = pd.DataFrame({
|
| "cell_id": dermal.obs_names.astype(str).values,
|
| "derm_label": labels_str,
|
| "genotype": genotype,
|
| "split": np.where(tr, "train", np.where(te, "test", "unassigned")),
|
| })
|
| manifest.to_csv(OUT_DIR / "104_dingwall_derm_split_manifest.csv", index=False)
|
|
|
| print("[B] train PANDA", flush=True)
|
| model = train_panda(X_all[tr], y_all[tr], d_all[tr], aux_all[tr],
|
| n_classes=len(classes), n_datasets=len(sample_ix), ck_out=CK_DIR)
|
|
|
| print("[B] infer on held-out", flush=True)
|
| pred_ix, conf = infer(model, X_all[te], aux_all[te])
|
| pred = pd.DataFrame({
|
| "cell_id": dermal.obs_names.astype(str).values[te],
|
| "derm_true": labels_str[te],
|
| "derm_pred": [classes[p] for p in pred_ix],
|
| "confidence": conf,
|
| "genotype": genotype[te],
|
| })
|
| pred.to_csv(OUT_DIR / "104_dingwall_derm_predictions.csv", index=False)
|
|
|
|
|
| pred_ix_full = np.array([cls_ix[c] for c in pred["derm_pred"].values])
|
| dep_pred = depletion_table(pred_ix_full, genotype[te], classes)
|
| dep_true = depletion_table(y_all[te], genotype[te], classes)
|
| dep_pred.to_csv(OUT_DIR / "104_dingwall_derm_depletion_pred.csv", index=False)
|
| dep_true.to_csv(OUT_DIR / "104_dingwall_derm_depletion_true.csv", index=False)
|
|
|
| d10_true = dep_true[dep_true["derm_label"] == "Derm10"].to_dict("records")
|
| d10_pred = dep_pred[dep_pred["derm_label"] == "Derm10"].to_dict("records")
|
| acc = float((pred_ix == y_all[te]).mean())
|
|
|
| summary = {
|
| "variant": "B_fully_supervised_replica_labels",
|
| "n_dermal_total": int(dermal.n_obs),
|
| "n_train": int(tr.sum()), "n_test": int(te.sum()),
|
| "classes": classes,
|
| "test_accuracy": acc,
|
| "expected_paper_derm10": {"wt_pct": 1.99, "cko_pct": 0.08,
|
| "or_approx": 24.5, "wt_n_approx": 346, "cko_n_approx": 7},
|
| "test_derm10_true": d10_true,
|
| "test_derm10_pred": d10_pred,
|
| "test_depletion_true": dep_true.to_dict("records"),
|
| "test_depletion_pred": dep_pred.to_dict("records"),
|
| }
|
| (OUT_DIR / "104_dingwall_derm_summary.json").write_text(json.dumps(summary, indent=2, default=str))
|
| print(f"[B] done -> {OUT_DIR}/104_dingwall_derm_*", flush=True)
|
|
|
|
|
| if __name__ == "__main__":
|
| main()
|
|
|