upload panda
Browse files- panda/__init__.py +16 -0
- panda/data/__init__.py +2 -0
- panda/data/pan_skin_loaders.py +271 -0
- panda/data/pancreas_loaders.py +151 -0
- panda/markers.yaml +132 -0
- panda/model.py +229 -0
panda/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""panda: prototype-anchored cell-identity classifier. marker variant is default."""
|
| 2 |
+
__version__ = "1.0"
|
| 3 |
+
|
| 4 |
+
from .model import (
|
| 5 |
+
PANDAEncoder,
|
| 6 |
+
grad_reverse,
|
| 7 |
+
supcon_loss, vicreg_loss, hsic_biased,
|
| 8 |
+
subcenter_angular_infonce,
|
| 9 |
+
prototype_repulsion,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"PANDAEncoder",
|
| 14 |
+
"grad_reverse", "supcon_loss", "vicreg_loss", "hsic_biased",
|
| 15 |
+
"subcenter_angular_infonce", "prototype_repulsion",
|
| 16 |
+
]
|
panda/data/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PANDA data loaders."""
|
| 2 |
+
from .pan_skin_loaders import ALL_LOADERS
|
panda/data/pan_skin_loaders.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pan-skin corpus loaders. dingwall GSE220977 is held out from training."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
import gzip
|
| 5 |
+
import io
|
| 6 |
+
import tarfile
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import anndata as ad
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import scipy.io as sio
|
| 13 |
+
import scipy.sparse as sp
|
| 14 |
+
|
| 15 |
+
CORPUS_ROOT = Path("/home/bcheng/PRISM/data/corpus/pan_skin")
|
| 16 |
+
TIER_A = CORPUS_ROOT / "tier_a"
|
| 17 |
+
TIER_B = CORPUS_ROOT / "tier_b"
|
| 18 |
+
PROCESSED = CORPUS_ROOT / "harmonized"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _mtx_from_bytes(b: bytes) -> sp.csr_matrix:
|
| 22 |
+
if b[:2] == b"\x1f\x8b":
|
| 23 |
+
b = gzip.decompress(b)
|
| 24 |
+
return sp.csr_matrix(sio.mmread(io.BytesIO(b)))
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _read_tsv_gz_bytes(b: bytes) -> pd.Series:
|
| 28 |
+
return pd.read_csv(io.BytesIO(b), sep="\t", header=None, compression="gzip").iloc[:, 0]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load_joost_annusver_GSE142471() -> ad.AnnData:
|
| 32 |
+
"""joost/annusver 2020 GSE142471, 5 samples wounded vs unwounded skin."""
|
| 33 |
+
# paper is joost/annusver but file is on disk as haensel_*
|
| 34 |
+
tar_path = TIER_A / "haensel_GSE142471_RAW.tar"
|
| 35 |
+
parts: list[ad.AnnData] = []
|
| 36 |
+
with tarfile.open(tar_path, "r") as tar:
|
| 37 |
+
by_stem: dict[str, dict[str, bytes]] = {}
|
| 38 |
+
for m in tar.getmembers():
|
| 39 |
+
name = m.name
|
| 40 |
+
body = tar.extractfile(m).read()
|
| 41 |
+
if "_barcodes_" in name:
|
| 42 |
+
stem = name.split("_barcodes_", 1)[1].replace(".tsv.gz", "")
|
| 43 |
+
by_stem.setdefault(stem, {})["barcodes"] = body
|
| 44 |
+
elif "_genes_" in name:
|
| 45 |
+
stem = name.split("_genes_", 1)[1].replace(".tsv.gz", "")
|
| 46 |
+
by_stem.setdefault(stem, {})["genes"] = body
|
| 47 |
+
elif name.endswith(".mtx.gz"):
|
| 48 |
+
# e.g. GSM4230076_Un-Wounded_1_scRNA-Seq.mtx.gz
|
| 49 |
+
stem = name.split("_", 1)[1].replace(".mtx.gz", "")
|
| 50 |
+
by_stem.setdefault(stem, {})["mtx"] = body
|
| 51 |
+
for stem, files in by_stem.items():
|
| 52 |
+
if not {"barcodes", "genes", "mtx"} <= files.keys():
|
| 53 |
+
continue
|
| 54 |
+
X = _mtx_from_bytes(files["mtx"]).T.tocsr() # (cells, genes)
|
| 55 |
+
barcodes = _read_tsv_gz_bytes(files["barcodes"])
|
| 56 |
+
genes_raw = pd.read_csv(
|
| 57 |
+
io.BytesIO(files["genes"]), sep="\t", header=None, compression="gzip"
|
| 58 |
+
)
|
| 59 |
+
gene_symbols = genes_raw.iloc[:, 1] if genes_raw.shape[1] > 1 else genes_raw.iloc[:, 0]
|
| 60 |
+
obs = pd.DataFrame(
|
| 61 |
+
{"sample": stem, "condition": stem.split("_")[0]},
|
| 62 |
+
index=[f"{stem}_{bc}" for bc in barcodes],
|
| 63 |
+
)
|
| 64 |
+
# dedupe: some 10x refs have duplicate symbols on X/Y or ERCC
|
| 65 |
+
gs = pd.Series(gene_symbols.values.astype(str))
|
| 66 |
+
gs_dedup = gs.groupby(gs).cumcount().astype(str).radd(gs + ".").where(
|
| 67 |
+
gs.duplicated(keep=False), gs
|
| 68 |
+
).values
|
| 69 |
+
var = pd.DataFrame({"gene_symbol": gene_symbols.values}, index=gs_dedup)
|
| 70 |
+
adata_i = ad.AnnData(X=X, obs=obs, var=var)
|
| 71 |
+
adata_i.var_names_make_unique()
|
| 72 |
+
parts.append(adata_i)
|
| 73 |
+
if not parts:
|
| 74 |
+
raise RuntimeError(f"No 10x samples parsed from {tar_path}")
|
| 75 |
+
a = ad.concat(parts, join="outer", label="_batch")
|
| 76 |
+
a.uns["dataset"] = "joost_annusver_GSE142471"
|
| 77 |
+
a.uns["organism"] = "mouse"
|
| 78 |
+
a.uns["stage"] = "adult"
|
| 79 |
+
a.uns["platform"] = "10x_v2"
|
| 80 |
+
return a
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def load_joost_GSE67602() -> ad.AnnData:
|
| 84 |
+
"""joost 2016 GSE67602, smart-seq2 adult back skin (~1400 cells). dense tsv genes x cells."""
|
| 85 |
+
path = TIER_A / "joost_GSE67602_expression.txt.gz"
|
| 86 |
+
df = pd.read_csv(path, sep="\t", index_col=0, compression="gzip")
|
| 87 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 88 |
+
obs = pd.DataFrame(index=df.columns.astype(str))
|
| 89 |
+
obs["sample"] = obs.index.str.split("_").str[0]
|
| 90 |
+
var = pd.DataFrame(index=df.index.astype(str))
|
| 91 |
+
var["gene_symbol"] = var.index.values
|
| 92 |
+
a = ad.AnnData(X=X, obs=obs, var=var)
|
| 93 |
+
a.uns["dataset"] = "joost_GSE67602"
|
| 94 |
+
a.uns["organism"] = "mouse"
|
| 95 |
+
a.uns["stage"] = "adult"
|
| 96 |
+
a.uns["platform"] = "Smart-seq2"
|
| 97 |
+
return a
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def load_ge_gupta_GSE131498() -> ad.AnnData:
|
| 101 |
+
"""ge & gupta 2020 GSE131498, E13.5/E16.5/P0 dorsal skin (~15k cells).
|
| 102 |
+
|
| 103 |
+
dense gene-x-cell csv, ~5 GB uncompressed — chunked read then sparse convert.
|
| 104 |
+
"""
|
| 105 |
+
path = TIER_A / "ge_gupta_GSE131498_expression.txt.gz"
|
| 106 |
+
hdr = pd.read_csv(path, sep=",", nrows=0, compression="gzip")
|
| 107 |
+
cells = list(hdr.columns)
|
| 108 |
+
n_cells = len(cells)
|
| 109 |
+
row_chunks = []
|
| 110 |
+
gene_names = []
|
| 111 |
+
chunk_size = 1000
|
| 112 |
+
for chunk in pd.read_csv(path, sep=",", index_col=0, compression="gzip",
|
| 113 |
+
chunksize=chunk_size, low_memory=True):
|
| 114 |
+
gene_names.extend(chunk.index.astype(str).tolist())
|
| 115 |
+
arr = chunk.values.astype(np.float32)
|
| 116 |
+
row_chunks.append(sp.csr_matrix(arr))
|
| 117 |
+
if not row_chunks:
|
| 118 |
+
raise RuntimeError(f"empty file: {path}")
|
| 119 |
+
X_genes_cells = sp.vstack(row_chunks).tocsr() # (n_genes, n_cells)
|
| 120 |
+
X = X_genes_cells.T.tocsr().astype(np.float32) # (n_cells, n_genes)
|
| 121 |
+
obs = pd.DataFrame(index=pd.Index(cells).astype(str))
|
| 122 |
+
obs["stage_tag"] = pd.Index(cells).astype(str).str.split("_").str[0]
|
| 123 |
+
obs["sample"] = obs["stage_tag"]
|
| 124 |
+
var = pd.DataFrame(index=pd.Index(gene_names).astype(str))
|
| 125 |
+
var["gene_symbol"] = var.index.values
|
| 126 |
+
a = ad.AnnData(X=X, obs=obs, var=var)
|
| 127 |
+
a.var_names_make_unique()
|
| 128 |
+
a.uns["dataset"] = "ge_gupta_GSE131498"
|
| 129 |
+
a.uns["organism"] = "mouse"
|
| 130 |
+
a.uns["stage"] = "E13.5-P0"
|
| 131 |
+
a.uns["platform"] = "10x_v2"
|
| 132 |
+
return a
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def load_wihn_GSE141814() -> ad.AnnData:
|
| 136 |
+
"""wihn wound-induced hair neogenesis, 2 samples (fibrotic + regenerative).
|
| 137 |
+
|
| 138 |
+
shared GSE141814_features.tsv.gz (mm10 ensembl+symbol from cellranger).
|
| 139 |
+
"""
|
| 140 |
+
tar_path = TIER_B / "wihn_GSE141814_RAW.tar"
|
| 141 |
+
feat_path = TIER_B / "wihn_GSE141814_features.tsv.gz"
|
| 142 |
+
feat = pd.read_csv(feat_path, sep="\t", header=None, compression="gzip")
|
| 143 |
+
gs = pd.Series(feat.iloc[:, 1].astype(str).values)
|
| 144 |
+
# dedupe: some 10x refs have duplicate symbols
|
| 145 |
+
gene_symbols = gs.groupby(gs).cumcount().astype(str).radd(gs + ".").where(
|
| 146 |
+
gs.duplicated(keep=False), gs
|
| 147 |
+
).values
|
| 148 |
+
|
| 149 |
+
parts: list[ad.AnnData] = []
|
| 150 |
+
with tarfile.open(tar_path, "r") as tar:
|
| 151 |
+
by_sample: dict[str, dict[str, bytes]] = {}
|
| 152 |
+
for m in tar.getmembers():
|
| 153 |
+
name = m.name
|
| 154 |
+
body = tar.extractfile(m).read()
|
| 155 |
+
# e.g. GSM4213632_Fibroticbarcodes.tsv.gz, GSM4213632_fibrotic_matrix.mtx.gz
|
| 156 |
+
gsm = name.split("_", 1)[0]
|
| 157 |
+
if "barcodes" in name.lower():
|
| 158 |
+
by_sample.setdefault(gsm, {})["barcodes"] = body
|
| 159 |
+
by_sample[gsm]["cond"] = "fibrotic" if "fibrotic" in name.lower() else "regenerative"
|
| 160 |
+
elif "matrix" in name.lower():
|
| 161 |
+
by_sample.setdefault(gsm, {})["mtx"] = body
|
| 162 |
+
for gsm, files in by_sample.items():
|
| 163 |
+
if not {"barcodes", "mtx"} <= files.keys():
|
| 164 |
+
continue
|
| 165 |
+
X = _mtx_from_bytes(files["mtx"]).T.tocsr()
|
| 166 |
+
barcodes = _read_tsv_gz_bytes(files["barcodes"])
|
| 167 |
+
cond = files["cond"]
|
| 168 |
+
obs = pd.DataFrame(
|
| 169 |
+
{"sample": gsm, "condition": cond},
|
| 170 |
+
index=[f"{gsm}_{bc}" for bc in barcodes],
|
| 171 |
+
)
|
| 172 |
+
var = pd.DataFrame({"gene_symbol": gene_symbols}, index=gene_symbols)
|
| 173 |
+
adata_i = ad.AnnData(X=X, obs=obs, var=var)
|
| 174 |
+
adata_i.var_names_make_unique()
|
| 175 |
+
parts.append(adata_i)
|
| 176 |
+
if not parts:
|
| 177 |
+
raise RuntimeError(f"No WIHN samples parsed from {tar_path}")
|
| 178 |
+
a = ad.concat(parts, join="outer", label="_batch")
|
| 179 |
+
a.uns["dataset"] = "wihn_GSE141814"
|
| 180 |
+
a.uns["organism"] = "mouse"
|
| 181 |
+
a.uns["stage"] = "adult wound"
|
| 182 |
+
a.uns["platform"] = "10x_v2"
|
| 183 |
+
return a
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def load_mca_neonatal_skin() -> ad.AnnData:
|
| 187 |
+
"""mca (han 2018) microwell-seq neonatal skin partition (GSM2906453)."""
|
| 188 |
+
path = TIER_B / "GSM2906453_NeonatalSkin_dge.txt.gz"
|
| 189 |
+
df = pd.read_csv(path, sep=" ", index_col=0, compression="gzip", low_memory=False)
|
| 190 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 191 |
+
obs = pd.DataFrame(index=df.columns.astype(str))
|
| 192 |
+
obs["sample"] = obs.index.str.split(".").str[0]
|
| 193 |
+
var = pd.DataFrame(index=df.index.astype(str))
|
| 194 |
+
var["gene_symbol"] = var.index.values
|
| 195 |
+
a = ad.AnnData(X=X, obs=obs, var=var)
|
| 196 |
+
a.uns["dataset"] = "mca_GSE108097_neonatal_skin"
|
| 197 |
+
a.uns["organism"] = "mouse"
|
| 198 |
+
a.uns["stage"] = "P0-P3"
|
| 199 |
+
a.uns["platform"] = "Microwell-seq"
|
| 200 |
+
return a
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def load_merkel_GSE201447() -> ad.AnnData:
|
| 204 |
+
"""merkel/touch dome (2022), 2 samples (naive N + irradiated I), h5. labels from cluster file."""
|
| 205 |
+
import scanpy as sc
|
| 206 |
+
|
| 207 |
+
tar_path = TIER_B / "merkel_GSE201447_RAW.tar"
|
| 208 |
+
lbl_path = TIER_B / "merkel_GSE201447_cluster_cell_types.txt.gz"
|
| 209 |
+
parts: list[ad.AnnData] = []
|
| 210 |
+
(TIER_B / "merkel_extracted").mkdir(exist_ok=True)
|
| 211 |
+
with tarfile.open(tar_path, "r") as tar:
|
| 212 |
+
tar.extractall(TIER_B / "merkel_extracted")
|
| 213 |
+
for h5f in sorted((TIER_B / "merkel_extracted").glob("*.h5")):
|
| 214 |
+
adata_i = sc.read_10x_h5(str(h5f))
|
| 215 |
+
adata_i.var_names_make_unique()
|
| 216 |
+
sample_letter = h5f.name.split("_", 1)[1][0] # 'N' or 'I'
|
| 217 |
+
adata_i.obs["sample"] = "naive" if sample_letter == "N" else "irradiated"
|
| 218 |
+
adata_i.obs_names = [f"{adata_i.obs['sample'].iloc[0]}_{bc}" for bc in adata_i.obs_names]
|
| 219 |
+
adata_i.var["gene_symbol"] = adata_i.var_names
|
| 220 |
+
parts.append(adata_i)
|
| 221 |
+
a = ad.concat(parts, join="outer", label="_batch")
|
| 222 |
+
# labels
|
| 223 |
+
lbl = pd.read_csv(lbl_path, sep="\t", compression="gzip")
|
| 224 |
+
# label file's Cell col has trailing "_1"/"_2" from paper's Seurat concat;
|
| 225 |
+
# h5 barcodes lack the suffix, so strip before building the composite key.
|
| 226 |
+
lbl["Cell"] = lbl["Cell"].astype(str).str.replace(r"_\d+$", "", regex=True)
|
| 227 |
+
lbl["key"] = lbl["Sample"].astype(str) + "_" + lbl["Cell"]
|
| 228 |
+
lbl_map = lbl.set_index("key")["Cell_type"]
|
| 229 |
+
a.obs["cell_type"] = a.obs_names.to_series().map(lbl_map).fillna("UNK")
|
| 230 |
+
a.uns["dataset"] = "merkel_GSE201447"
|
| 231 |
+
a.uns["organism"] = "mouse"
|
| 232 |
+
a.uns["stage"] = "adult"
|
| 233 |
+
a.uns["platform"] = "10x_v3"
|
| 234 |
+
return a
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def load_sulic_GSE212673() -> ad.AnnData:
|
| 238 |
+
"""sulic 2023 GSE212673 E14.5 dorsal skin epithelium, labeled anchor. raw counts in .raw."""
|
| 239 |
+
path = Path("/home/bcheng/PRISM/data/processed/sulic/adata_sulic.h5ad")
|
| 240 |
+
a = ad.read_h5ad(path)
|
| 241 |
+
if a.raw is not None:
|
| 242 |
+
raw = a.raw.to_adata()
|
| 243 |
+
raw.obs = a.obs.copy()
|
| 244 |
+
a = raw
|
| 245 |
+
a.uns["dataset"] = "sulic_GSE212673"
|
| 246 |
+
a.uns["organism"] = "mouse"
|
| 247 |
+
a.uns["stage"] = "E14.5"
|
| 248 |
+
a.uns["platform"] = "10x_v3"
|
| 249 |
+
if "paper_subtype" in a.obs.columns:
|
| 250 |
+
map_ = {
|
| 251 |
+
"Epithelium": "basal-IFE",
|
| 252 |
+
"Placode1": "HF-placode",
|
| 253 |
+
"Placode2": "HF-placode",
|
| 254 |
+
"PlacodeI": "HF-placode",
|
| 255 |
+
"PlacodeII": "HF-placode",
|
| 256 |
+
"PlacodeIII": "HF-placode",
|
| 257 |
+
"PlacodeIV": "HF-placode",
|
| 258 |
+
}
|
| 259 |
+
a.obs["seed_label"] = a.obs["paper_subtype"].map(map_).fillna("UNK")
|
| 260 |
+
return a
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
ALL_LOADERS = {
|
| 264 |
+
"sulic_GSE212673": load_sulic_GSE212673,
|
| 265 |
+
"joost_annusver_GSE142471": load_joost_annusver_GSE142471,
|
| 266 |
+
"joost_GSE67602": load_joost_GSE67602,
|
| 267 |
+
"ge_gupta_GSE131498": load_ge_gupta_GSE131498,
|
| 268 |
+
"wihn_GSE141814": load_wihn_GSE141814,
|
| 269 |
+
"mca_GSE108097_neonatal": load_mca_neonatal_skin,
|
| 270 |
+
"merkel_GSE201447": load_merkel_GSE201447,
|
| 271 |
+
}
|
panda/data/pancreas_loaders.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""pancreas corpus loaders. return raw-count AnnData with sample/condition obs."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import io, gzip, tarfile
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import anndata as ad
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
+
import scipy.sparse as sp
|
| 10 |
+
|
| 11 |
+
TIER_A = Path("/home/bcheng/PRISM/data/corpus/pancreas/tier_a")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _read_csv_gz(path):
|
| 15 |
+
return pd.read_csv(path, index_col=0, compression="gzip")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def load_bastidas_local() -> ad.AnnData:
|
| 19 |
+
"""bastidas-ponce 2019 GSE132188 anchor, already processed locally."""
|
| 20 |
+
p = Path("/home/bcheng/PRISM/data/processed/staging/pancreas/adata_processed.h5ad")
|
| 21 |
+
a = ad.read_h5ad(p)
|
| 22 |
+
if a.raw is not None:
|
| 23 |
+
raw = a.raw.to_adata(); raw.obs = a.obs.copy(); a = raw
|
| 24 |
+
a.obs["dataset"] = "bastidas_GSE132188"
|
| 25 |
+
a.uns["organism"] = "mouse"
|
| 26 |
+
a.uns["platform"] = "10x_v2"
|
| 27 |
+
return a
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def load_baron() -> ad.AnnData:
|
| 31 |
+
"""baron 2016 GSE84133 mouse + human islets, inDrops."""
|
| 32 |
+
tar_path = TIER_A / "baron_GSE84133_RAW.tar"
|
| 33 |
+
parts: list[ad.AnnData] = []
|
| 34 |
+
with tarfile.open(tar_path) as tar:
|
| 35 |
+
for m in tar.getmembers():
|
| 36 |
+
if not m.name.endswith(".csv.gz"): continue
|
| 37 |
+
body = tar.extractfile(m).read()
|
| 38 |
+
df = pd.read_csv(io.BytesIO(gzip.decompress(body)),
|
| 39 |
+
index_col=0)
|
| 40 |
+
# baron format: rows=cells (barcode + donor prefix), first two cols are meta
|
| 41 |
+
meta_cols = [c for c in df.columns
|
| 42 |
+
if c in ("barcode", "assigned_cluster")]
|
| 43 |
+
gene_cols = [c for c in df.columns if c not in meta_cols]
|
| 44 |
+
X = df[gene_cols].values.astype(np.float32)
|
| 45 |
+
obs = df[meta_cols].copy() if meta_cols else pd.DataFrame(index=df.index)
|
| 46 |
+
obs.index = df.index
|
| 47 |
+
obs["sample"] = m.name.split("_")[1].split(".")[0] if "_" in m.name else "unknown"
|
| 48 |
+
organism = "mouse" if "mouse" in m.name.lower() else "human"
|
| 49 |
+
obs["organism"] = organism
|
| 50 |
+
var = pd.DataFrame({"gene_symbol": gene_cols}, index=gene_cols)
|
| 51 |
+
parts.append(ad.AnnData(X=sp.csr_matrix(X), obs=obs, var=var))
|
| 52 |
+
if not parts:
|
| 53 |
+
raise RuntimeError(f"No CSVs in {tar_path}")
|
| 54 |
+
a = ad.concat(parts, join="outer", label="_batch")
|
| 55 |
+
a.obs["dataset"] = "baron_GSE84133"
|
| 56 |
+
a.uns["organism"] = "mouse+human"
|
| 57 |
+
a.uns["platform"] = "inDrops"
|
| 58 |
+
return a
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def load_muraro() -> ad.AnnData:
|
| 62 |
+
"""muraro 2016 GSE85241 human, CEL-Seq2 (2126 cells)."""
|
| 63 |
+
df = _read_csv_gz(TIER_A / "muraro_GSE85241_cellsystems.csv.gz")
|
| 64 |
+
# rows=genes, cols=cells; transpose
|
| 65 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 66 |
+
obs = pd.DataFrame(index=df.columns.astype(str))
|
| 67 |
+
obs["sample"] = obs.index.str.split(".").str[0]
|
| 68 |
+
var = pd.DataFrame(index=df.index.astype(str))
|
| 69 |
+
var["gene_symbol"] = var.index.values
|
| 70 |
+
a = ad.AnnData(X=X, obs=obs, var=var)
|
| 71 |
+
a.obs["dataset"] = "muraro_GSE85241"
|
| 72 |
+
a.uns["organism"] = "human"
|
| 73 |
+
a.uns["platform"] = "CEL-Seq2"
|
| 74 |
+
return a
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def load_grun() -> ad.AnnData:
|
| 78 |
+
"""grun 2016 GSE81076 human, CEL-Seq."""
|
| 79 |
+
df = pd.read_csv(TIER_A / "grun_GSE81076.txt.gz", sep="\t",
|
| 80 |
+
index_col=0, compression="gzip")
|
| 81 |
+
X = sp.csr_matrix(df.values.T.astype(np.float32))
|
| 82 |
+
obs = pd.DataFrame(index=df.columns.astype(str))
|
| 83 |
+
var = pd.DataFrame(index=df.index.astype(str))
|
| 84 |
+
var["gene_symbol"] = var.index.values
|
| 85 |
+
a = ad.AnnData(X=X, obs=obs, var=var)
|
| 86 |
+
a.obs["dataset"] = "grun_GSE81076"
|
| 87 |
+
a.obs["sample"] = "grun"
|
| 88 |
+
a.uns["organism"] = "human"
|
| 89 |
+
a.uns["platform"] = "CEL-Seq"
|
| 90 |
+
return a
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def load_byrnes() -> ad.AnnData:
|
| 94 |
+
"""byrnes 2018 GSE101099, 10x mtx time-course samples."""
|
| 95 |
+
import scipy.io as sio
|
| 96 |
+
tar_path = TIER_A / "byrnes_GSE101099_RAW.tar"
|
| 97 |
+
parts = []
|
| 98 |
+
with tarfile.open(tar_path) as tar:
|
| 99 |
+
by_stem: dict[str, dict[str, bytes]] = {}
|
| 100 |
+
for m in tar.getmembers():
|
| 101 |
+
name = m.name
|
| 102 |
+
body = tar.extractfile(m).read()
|
| 103 |
+
gsm = name.split("_", 1)[0]
|
| 104 |
+
# byrnes files: GSMxxx_STEM.mtx.gz + _barcodes.tsv.gz + _genes.tsv.gz
|
| 105 |
+
if name.endswith("_barcodes.tsv.gz"):
|
| 106 |
+
stem = name.split(".", 1)[0].rsplit("_barcodes", 1)[0]
|
| 107 |
+
by_stem.setdefault(stem, {})["barcodes"] = body
|
| 108 |
+
elif name.endswith("_genes.tsv.gz"):
|
| 109 |
+
stem = name.split(".", 1)[0].rsplit("_genes", 1)[0]
|
| 110 |
+
by_stem.setdefault(stem, {})["genes"] = body
|
| 111 |
+
elif name.endswith(".mtx.gz"):
|
| 112 |
+
stem = name.replace(".mtx.gz", "")
|
| 113 |
+
by_stem.setdefault(stem, {})["mtx"] = body
|
| 114 |
+
for stem, files in by_stem.items():
|
| 115 |
+
if not {"barcodes", "genes", "mtx"} <= files.keys():
|
| 116 |
+
continue
|
| 117 |
+
X = sp.csr_matrix(sio.mmread(io.BytesIO(gzip.decompress(files["mtx"]))))
|
| 118 |
+
X = X.T.tocsr() # (cells, genes)
|
| 119 |
+
barcodes = pd.read_csv(io.BytesIO(gzip.decompress(files["barcodes"])),
|
| 120 |
+
sep="\t", header=None).iloc[:, 0].astype(str)
|
| 121 |
+
genes = pd.read_csv(io.BytesIO(gzip.decompress(files["genes"])),
|
| 122 |
+
sep="\t", header=None)
|
| 123 |
+
gene_symbols = genes.iloc[:, 1] if genes.shape[1] > 1 else genes.iloc[:, 0]
|
| 124 |
+
obs = pd.DataFrame(
|
| 125 |
+
{"sample": stem, "condition": stem.split("_", 1)[1] if "_" in stem else stem},
|
| 126 |
+
index=[f"{stem}_{bc}" for bc in barcodes],
|
| 127 |
+
)
|
| 128 |
+
gs = pd.Series(gene_symbols.astype(str).values)
|
| 129 |
+
gs_dedup = gs.groupby(gs).cumcount().astype(str).radd(gs + ".").where(
|
| 130 |
+
gs.duplicated(keep=False), gs
|
| 131 |
+
).values
|
| 132 |
+
var = pd.DataFrame({"gene_symbol": gene_symbols.values}, index=gs_dedup)
|
| 133 |
+
adata_i = ad.AnnData(X=X, obs=obs, var=var)
|
| 134 |
+
adata_i.var_names_make_unique()
|
| 135 |
+
parts.append(adata_i)
|
| 136 |
+
if not parts:
|
| 137 |
+
raise RuntimeError(f"No 10x samples in {tar_path}")
|
| 138 |
+
a = ad.concat(parts, join="outer", label="_batch")
|
| 139 |
+
a.obs["dataset"] = "byrnes_GSE101099"
|
| 140 |
+
a.uns["organism"] = "mouse"
|
| 141 |
+
a.uns["platform"] = "inDrops"
|
| 142 |
+
return a
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
ALL_LOADERS = {
|
| 146 |
+
"bastidas_GSE132188": load_bastidas_local,
|
| 147 |
+
"baron_GSE84133": load_baron,
|
| 148 |
+
"muraro_GSE85241": load_muraro,
|
| 149 |
+
"grun_GSE81076": load_grun,
|
| 150 |
+
"byrnes_GSE101099": load_byrnes,
|
| 151 |
+
}
|
panda/markers.yaml
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Curated marker-channel input genes per system for PANDA-Marker variant.
|
| 2 |
+
#
|
| 3 |
+
# These are the SIBLING-DISCRIMINATING genes that PCA(50) tends to discard.
|
| 4 |
+
# They are concatenated directly onto PCA(50) as log-normalised expression,
|
| 5 |
+
# bypassing the PCA bottleneck for the low-variance signal that separates
|
| 6 |
+
# closely-related subtypes.
|
| 7 |
+
#
|
| 8 |
+
# Set size targets: 20-40 markers per system. Curated for BOTH:
|
| 9 |
+
# (a) each class's positive identity marker (recovers within-class)
|
| 10 |
+
# (b) sibling-pair contrast markers (e.g. Dkk4 for eccrine vs hair placode)
|
| 11 |
+
#
|
| 12 |
+
# All symbols are mouse gene symbols (case-fold applied at inference for
|
| 13 |
+
# cross-species targets like Veres).
|
| 14 |
+
|
| 15 |
+
pan_skin:
|
| 16 |
+
# eccrine placode / nascent-gland / hair-placode axis
|
| 17 |
+
- Dkk4 # eccrine placode > hair placode (Dingwall Fig 2)
|
| 18 |
+
- Lgr6 # placode marker (Epi3)
|
| 19 |
+
- Trpv6 # nascent eccrine gland (Epi5/Epi8)
|
| 20 |
+
- Edar # both placodes; hair > eccrine
|
| 21 |
+
- Shh # hair placode discriminator
|
| 22 |
+
- Sox9 # hair placode / HF-primary-germ
|
| 23 |
+
- Lhx2 # hair placode
|
| 24 |
+
- Foxi3 # placode induction
|
| 25 |
+
- Wnt6 # placode Wnt program
|
| 26 |
+
- Wnt7b # placode Wnt program
|
| 27 |
+
- Lef1 # Wnt signalling
|
| 28 |
+
- Bmp7 # placode
|
| 29 |
+
# eccrine/appendage identity
|
| 30 |
+
- En1 # spatial repressor of eccrine outside placode
|
| 31 |
+
- Grhl3 # eccrine-duct master TF
|
| 32 |
+
- Cldn6 # eccrine tight junction
|
| 33 |
+
- Kremen2 # placode / eccrine
|
| 34 |
+
# basal / IFE / spinous / granular
|
| 35 |
+
- Krt5 # basal
|
| 36 |
+
- Krt14 # basal
|
| 37 |
+
- Krt10 # spinous differentiation
|
| 38 |
+
- Krt1 # spinous differentiation
|
| 39 |
+
- Trp63 # basal master
|
| 40 |
+
# melanocyte MITF regulon
|
| 41 |
+
- Dct # melanocyte
|
| 42 |
+
- Mlana # melanocyte
|
| 43 |
+
- Tyrp1 # melanocyte
|
| 44 |
+
- Pmel # melanocyte
|
| 45 |
+
- Sox10 # neural crest / melanocyte
|
| 46 |
+
# endothelial / fibroblast / immune
|
| 47 |
+
- Pecam1 # endothelial
|
| 48 |
+
- Cdh5 # endothelial
|
| 49 |
+
- Col1a1 # fibroblast reticular
|
| 50 |
+
- Dcn # fibroblast papillary
|
| 51 |
+
- Ptprc # immune (CD45)
|
| 52 |
+
# dermal niche (EDEN — Dingwall §5)
|
| 53 |
+
- S100a4 # EDEN dermal niche
|
| 54 |
+
- Pdgfra # dermal mesenchyme
|
| 55 |
+
|
| 56 |
+
hematopoiesis:
|
| 57 |
+
# LT-HSC / stem
|
| 58 |
+
- Hlf # LT-HSC canonical (Komorowska 2017)
|
| 59 |
+
- Meis1 # HSC/MPP
|
| 60 |
+
- Mecom # LT-HSC
|
| 61 |
+
- Procr # LT-HSC (EPCR)
|
| 62 |
+
- Fgd5 # LT-HSC reporter
|
| 63 |
+
- Mllt3 # LT-HSC self-renewal
|
| 64 |
+
# MPP substates
|
| 65 |
+
- Cd48 # MPP1 vs LT
|
| 66 |
+
- Flt3 # MPP4/LMPP
|
| 67 |
+
- Sell # ST-HSC / MPP1 (CD62L)
|
| 68 |
+
- Slamf1 # SLAM CD150
|
| 69 |
+
# erythroid
|
| 70 |
+
- Klf1 # erythroid master TF
|
| 71 |
+
- Gata1 # erythroid/mega
|
| 72 |
+
- Car1 # erythroblast
|
| 73 |
+
- Car2 # erythroblast
|
| 74 |
+
- Blvrb # committed erythroid
|
| 75 |
+
- Hba-a1 # hemoglobin
|
| 76 |
+
# megakaryocyte
|
| 77 |
+
- Itga2b # CD41 megakaryocyte
|
| 78 |
+
- Pf4 # megakaryocyte
|
| 79 |
+
- Gp1bb # megakaryocyte
|
| 80 |
+
# lymphoid
|
| 81 |
+
- Dntt # pre-B / lymphoid
|
| 82 |
+
- Vpreb1 # pre-B lymphoid
|
| 83 |
+
- Vpreb3 # pre-B lymphoid
|
| 84 |
+
- Il7r # lymphoid progenitor
|
| 85 |
+
# myeloid / mast / basophil
|
| 86 |
+
- Elane # GMP / granulocyte
|
| 87 |
+
- Mpo # myeloid
|
| 88 |
+
- Cpa3 # basophil-mast lineage
|
| 89 |
+
- Ms4a2 # mast Fc-epsilon-RI
|
| 90 |
+
- Csf1r # macrophage
|
| 91 |
+
# macrophage antimicrobial (myeloid combinatorial identity per §10.3)
|
| 92 |
+
- Wfdc17
|
| 93 |
+
- Mmp8
|
| 94 |
+
- Ctss
|
| 95 |
+
|
| 96 |
+
pancreas:
|
| 97 |
+
# endocrine progenitor (Ngn3 → Fev → hormone axis)
|
| 98 |
+
- Neurog3 # Ngn3 endocrine progenitor
|
| 99 |
+
- Fev # Fev+ intermediate EP
|
| 100 |
+
- Pax4 # alpha/beta bipotential
|
| 101 |
+
- Insm1 # endocrine progenitor
|
| 102 |
+
- Neurod1 # beta / late endocrine
|
| 103 |
+
- Cbfa2t3 # endocrine progenitor
|
| 104 |
+
- Btbd17 # endocrine progenitor
|
| 105 |
+
# alpha lineage
|
| 106 |
+
- Arx # alpha master
|
| 107 |
+
- Irx1 # alpha
|
| 108 |
+
- Irx2 # alpha (Veres §7 finding)
|
| 109 |
+
- Mafb # alpha
|
| 110 |
+
- Gcg # glucagon
|
| 111 |
+
# beta lineage
|
| 112 |
+
- Nkx6-1 # beta master
|
| 113 |
+
- Mnx1 # beta
|
| 114 |
+
- Ins1 # insulin
|
| 115 |
+
- Ins2 # insulin
|
| 116 |
+
- Mafa # mature beta
|
| 117 |
+
- Pdx1 # pancreatic progenitor + beta
|
| 118 |
+
# delta / gamma / epsilon
|
| 119 |
+
- Sst # delta
|
| 120 |
+
- Hhex # delta
|
| 121 |
+
- Ppy # gamma (PP)
|
| 122 |
+
- Aqp3 # gamma
|
| 123 |
+
- Ghrl # epsilon
|
| 124 |
+
# exocrine / stromal
|
| 125 |
+
- Prss1 # acinar
|
| 126 |
+
- Cel # acinar
|
| 127 |
+
- Ptf1a # acinar/pro-endocrine
|
| 128 |
+
- Krt19 # ductal
|
| 129 |
+
- Sox9 # ductal / progenitor
|
| 130 |
+
# embryonic hematopoiesis contamination flag (per §10.3 finding)
|
| 131 |
+
- Hbb-bs
|
| 132 |
+
- Hba-a1
|
panda/model.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""panda encoder with two input variants (pca / marker), sub-center prototypes."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import math
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Optional, List
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
import torch.nn as nn
|
| 9 |
+
import torch.nn.functional as F
|
| 10 |
+
from torch.autograd import Function
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# gradient reversal layer
|
| 14 |
+
|
| 15 |
+
class GradReverse(Function):
|
| 16 |
+
@staticmethod
|
| 17 |
+
def forward(ctx, x, lam):
|
| 18 |
+
ctx.lam = lam
|
| 19 |
+
return x.view_as(x)
|
| 20 |
+
|
| 21 |
+
@staticmethod
|
| 22 |
+
def backward(ctx, g):
|
| 23 |
+
return -ctx.lam * g, None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def grad_reverse(x, lam):
|
| 27 |
+
return GradReverse.apply(x, lam)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# encoder with variant + sub-centers
|
| 31 |
+
|
| 32 |
+
class PANDAEncoder(nn.Module):
|
| 33 |
+
"""trunk + projection head + sub-center prototypes.
|
| 34 |
+
|
| 35 |
+
args:
|
| 36 |
+
variant : "pca" or "marker"
|
| 37 |
+
n_pca : 50
|
| 38 |
+
n_markers : m >= 0. required > 0 if variant == "marker".
|
| 39 |
+
n_classes : K
|
| 40 |
+
n_sub : sub-centers per class (default 3)
|
| 41 |
+
d_hidden, d_repr, d_proj: trunk sizing
|
| 42 |
+
n_datasets : for the dataset adversary head
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
def __init__(
|
| 46 |
+
self,
|
| 47 |
+
variant: str = "pca",
|
| 48 |
+
n_pca: int = 50,
|
| 49 |
+
n_markers: int = 0,
|
| 50 |
+
d_hidden: int = 512,
|
| 51 |
+
d_repr: int = 256,
|
| 52 |
+
d_proj: int = 128,
|
| 53 |
+
n_classes: int = 10,
|
| 54 |
+
n_sub: int = 3,
|
| 55 |
+
n_datasets: int = 1,
|
| 56 |
+
dropout: float = 0.2,
|
| 57 |
+
):
|
| 58 |
+
super().__init__()
|
| 59 |
+
assert variant in ("pca", "marker"), variant
|
| 60 |
+
if variant == "marker":
|
| 61 |
+
assert n_markers > 0, "PANDA-Marker requires n_markers>0"
|
| 62 |
+
self.variant = variant
|
| 63 |
+
self.n_pca = n_pca
|
| 64 |
+
self.n_markers = n_markers if variant == "marker" else 0
|
| 65 |
+
self.n_classes = n_classes
|
| 66 |
+
self.n_sub = n_sub
|
| 67 |
+
self.n_datasets = n_datasets
|
| 68 |
+
|
| 69 |
+
input_dim = n_pca + self.n_markers
|
| 70 |
+
self.input_dim = input_dim
|
| 71 |
+
|
| 72 |
+
self.trunk = nn.Sequential(
|
| 73 |
+
nn.Linear(input_dim, d_hidden), nn.LayerNorm(d_hidden), nn.GELU(), nn.Dropout(dropout),
|
| 74 |
+
nn.Linear(d_hidden, d_hidden), nn.LayerNorm(d_hidden), nn.GELU(), nn.Dropout(dropout),
|
| 75 |
+
nn.Linear(d_hidden, d_repr), nn.LayerNorm(d_repr), nn.GELU(),
|
| 76 |
+
)
|
| 77 |
+
self.projection = nn.Sequential(
|
| 78 |
+
nn.Linear(d_repr, d_repr), nn.GELU(),
|
| 79 |
+
nn.Linear(d_repr, d_proj),
|
| 80 |
+
)
|
| 81 |
+
self.classifier = nn.Sequential(nn.Linear(d_repr + 2, n_classes))
|
| 82 |
+
self.dom_adv = nn.Sequential(nn.Linear(d_repr, 128), nn.ReLU(), nn.Linear(128, n_datasets))
|
| 83 |
+
self.depth_adv = nn.Sequential(nn.Linear(d_repr, 64), nn.ReLU(), nn.Linear(64, 1))
|
| 84 |
+
|
| 85 |
+
# sub-center prototypes (K, n_sub, d_proj), L2-normalised per sub-center
|
| 86 |
+
self.register_buffer(
|
| 87 |
+
"prototypes",
|
| 88 |
+
F.normalize(torch.randn(n_classes, n_sub, d_proj), dim=-1),
|
| 89 |
+
)
|
| 90 |
+
# EMA momentum as a buffer so we can overwrite it in place
|
| 91 |
+
self.register_buffer("proto_ema", torch.tensor(0.99))
|
| 92 |
+
|
| 93 |
+
@torch.no_grad()
|
| 94 |
+
def update_prototypes(self, z_norm: torch.Tensor, y: torch.Tensor):
|
| 95 |
+
"""ema update: assign each in-class cell to nearest sub-center, take the mean."""
|
| 96 |
+
ema = float(self.proto_ema.item())
|
| 97 |
+
for c in torch.unique(y):
|
| 98 |
+
mask = y == c
|
| 99 |
+
if not mask.any():
|
| 100 |
+
continue
|
| 101 |
+
zc = z_norm[mask] # (n_c, d_proj)
|
| 102 |
+
protos_c = self.prototypes[c] # (n_sub, d_proj)
|
| 103 |
+
sims = zc @ protos_c.T # (n_c, n_sub)
|
| 104 |
+
assign = sims.argmax(dim=1) # each cell -> nearest sub-center
|
| 105 |
+
for k in range(self.n_sub):
|
| 106 |
+
m2 = assign == k
|
| 107 |
+
if not m2.any():
|
| 108 |
+
continue
|
| 109 |
+
new = F.normalize(zc[m2].mean(dim=0), dim=0)
|
| 110 |
+
self.prototypes[c, k] = F.normalize(
|
| 111 |
+
ema * self.prototypes[c, k] + (1 - ema) * new, dim=0
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
@torch.no_grad()
|
| 115 |
+
def max_sub_cos(self, z_norm: torch.Tensor) -> torch.Tensor:
|
| 116 |
+
"""(B, K) cos(z, best sub-center) per class."""
|
| 117 |
+
B = z_norm.size(0); K, n_sub, D = self.prototypes.shape
|
| 118 |
+
sims = torch.einsum("bd,ksd->bks", z_norm, self.prototypes) # (B, K, n_sub)
|
| 119 |
+
return sims.max(dim=2).values # (B, K)
|
| 120 |
+
|
| 121 |
+
def forward(
|
| 122 |
+
self,
|
| 123 |
+
x_pca: torch.Tensor,
|
| 124 |
+
aux: torch.Tensor,
|
| 125 |
+
x_markers: Optional[torch.Tensor] = None,
|
| 126 |
+
lam_dann: float = 0.0,
|
| 127 |
+
) -> dict:
|
| 128 |
+
if self.variant == "marker":
|
| 129 |
+
assert x_markers is not None and x_markers.size(1) == self.n_markers
|
| 130 |
+
x = torch.cat([x_pca, x_markers], dim=1)
|
| 131 |
+
else:
|
| 132 |
+
x = x_pca
|
| 133 |
+
|
| 134 |
+
h = self.trunk(x)
|
| 135 |
+
z_raw = self.projection(h)
|
| 136 |
+
z = F.normalize(z_raw, dim=1)
|
| 137 |
+
logits = self.classifier(torch.cat([h, aux], dim=1))
|
| 138 |
+
h_rev = grad_reverse(h, lam_dann)
|
| 139 |
+
return {
|
| 140 |
+
"repr": h,
|
| 141 |
+
"z": z,
|
| 142 |
+
"logits": logits,
|
| 143 |
+
"dom": self.dom_adv(h_rev),
|
| 144 |
+
"depth": self.depth_adv(h_rev),
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# losses
|
| 149 |
+
|
| 150 |
+
def supcon_loss(z: torch.Tensor, y: torch.Tensor, temperature: float = 0.1) -> torch.Tensor:
|
| 151 |
+
if z.size(0) < 2:
|
| 152 |
+
return z.new_zeros(())
|
| 153 |
+
sim = z @ z.T / temperature
|
| 154 |
+
sim_max, _ = sim.max(dim=1, keepdim=True)
|
| 155 |
+
sim = sim - sim_max.detach()
|
| 156 |
+
logits_mask = torch.ones_like(sim) - torch.eye(z.size(0), device=z.device)
|
| 157 |
+
exp_sim = torch.exp(sim) * logits_mask
|
| 158 |
+
log_prob = sim - torch.log(exp_sim.sum(dim=1, keepdim=True) + 1e-12)
|
| 159 |
+
labels_eq = (y.unsqueeze(0) == y.unsqueeze(1)).float() * logits_mask
|
| 160 |
+
denom = labels_eq.sum(dim=1).clamp_min(1.0)
|
| 161 |
+
per = -(labels_eq * log_prob).sum(dim=1) / denom
|
| 162 |
+
per = per * (labels_eq.sum(dim=1) > 0).float()
|
| 163 |
+
counts = torch.bincount(y, minlength=int(y.max().item()) + 1).float().clamp_min(1.0)
|
| 164 |
+
w = 1.0 / counts.sqrt()
|
| 165 |
+
return (per * w[y]).sum() / w[y].sum().clamp_min(1e-6)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def vicreg_loss(z: torch.Tensor, sim_weight: float = 0.0, var_weight: float = 25.0,
|
| 169 |
+
cov_weight: float = 1.0) -> torch.Tensor:
|
| 170 |
+
zc = z - z.mean(dim=0, keepdim=True)
|
| 171 |
+
std = (zc.var(dim=0) + 1e-4).sqrt()
|
| 172 |
+
var_loss = F.relu(1.0 - std).mean()
|
| 173 |
+
N, D = zc.shape
|
| 174 |
+
cov = (zc.T @ zc) / (N - 1)
|
| 175 |
+
off = cov - torch.diag(torch.diagonal(cov))
|
| 176 |
+
cov_loss = off.pow(2).sum() / D
|
| 177 |
+
return var_weight * var_loss + cov_weight * cov_loss
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def hsic_biased(x: torch.Tensor, y: torch.Tensor,
|
| 181 |
+
sigma_x: float = 1.0, sigma_y: float = 1.0) -> torch.Tensor:
|
| 182 |
+
Nx = x.size(0)
|
| 183 |
+
if Nx < 2:
|
| 184 |
+
return x.new_zeros(())
|
| 185 |
+
K = torch.exp(-torch.cdist(x, x) ** 2 / (2 * sigma_x ** 2))
|
| 186 |
+
L = torch.exp(-torch.cdist(y, y) ** 2 / (2 * sigma_y ** 2))
|
| 187 |
+
H = torch.eye(Nx, device=x.device) - torch.ones(Nx, Nx, device=x.device) / Nx
|
| 188 |
+
return (K @ H @ L @ H).trace() / (Nx - 1) ** 2
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def subcenter_angular_infonce(
|
| 192 |
+
z: torch.Tensor, # (B, d_proj) L2-normalised
|
| 193 |
+
y: torch.Tensor, # (B,)
|
| 194 |
+
prototypes: torch.Tensor, # (K, n_sub, d_proj)
|
| 195 |
+
margin: float = 0.15, # angular margin in radians
|
| 196 |
+
temperature: float = 0.07,
|
| 197 |
+
) -> torch.Tensor:
|
| 198 |
+
"""arcface-style angular-margin loss over sub-center prototypes."""
|
| 199 |
+
B = z.size(0); K, n_sub, D = prototypes.shape
|
| 200 |
+
sims = torch.einsum("bd,ksd->bks", z, prototypes) # (B, K, n_sub)
|
| 201 |
+
max_over_sub = sims.max(dim=2).values # (B, K)
|
| 202 |
+
|
| 203 |
+
# target class cosine, bump by angular margin, put back
|
| 204 |
+
target_cos = max_over_sub.gather(1, y.unsqueeze(1)).squeeze(1) # (B,)
|
| 205 |
+
target_cos = target_cos.clamp(-1 + 1e-7, 1 - 1e-7)
|
| 206 |
+
theta = torch.acos(target_cos)
|
| 207 |
+
target_new_cos = torch.cos(theta + margin)
|
| 208 |
+
|
| 209 |
+
logits = max_over_sub.clone()
|
| 210 |
+
logits.scatter_(1, y.unsqueeze(1), target_new_cos.unsqueeze(1))
|
| 211 |
+
logits = logits / temperature
|
| 212 |
+
return F.cross_entropy(logits, y)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def prototype_repulsion(prototypes: torch.Tensor, weight: float = 1.0) -> torch.Tensor:
|
| 216 |
+
"""penalise inter-class prototype cosine so eff-dim doesn't collapse."""
|
| 217 |
+
K, n_sub, D = prototypes.shape
|
| 218 |
+
centroids = F.normalize(prototypes.mean(dim=1), dim=1) # (K, D)
|
| 219 |
+
sim = centroids @ centroids.T # (K, K)
|
| 220 |
+
off = sim - torch.diag(torch.diagonal(sim))
|
| 221 |
+
return weight * off.pow(2).sum() / (K * (K - 1) + 1e-6)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def prototype_infonce_legacy(z, y, prototypes, temperature=0.07):
|
| 225 |
+
"""legacy single-prototype InfoNCE. kept for debugging + old checkpoints."""
|
| 226 |
+
if prototypes.dim() == 3:
|
| 227 |
+
prototypes = F.normalize(prototypes.mean(dim=1), dim=1) # collapse sub-centers
|
| 228 |
+
logits = z @ prototypes.T / temperature
|
| 229 |
+
return F.cross_entropy(logits, y)
|