PANDA / scripts /analysis /104_train_on_dingwall_derm_labels.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
12.1 kB
"""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")
# Training config (mirrors 20_train_panda.py)
GUARANTEED_PER_CLASS = 6
NATURAL_SLOTS = 96
STAGE_EPOCHS = [15, 25, 40, 40]
BALANCE_MIX = 0.5
# ---------- split ----------
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
# ---------- PANDA training (identical to variant A) ----------
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)
# ---------- reporting ----------
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
# get embedding from replica (harmony-corrected PCA)
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)
# depletion — reported for TEST set only, using PANDA predictions
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()