bryan7264 commited on
Commit
3c71870
·
verified ·
1 Parent(s): 42cddf2

upload panda

Browse files
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)