Skip to content

Commit 835fdd2

Browse files
committed
Add case-control tutorial
1 parent 7c97616 commit 835fdd2

9 files changed

Lines changed: 24043 additions & 2284 deletions

File tree

docs/source/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ causarray is a Python module for simultaneous causal inference with an array of
3333

3434
tutorial/perturbseq/perturbseq-py.ipynb
3535
tutorial/replogle/replogle-py.ipynb
36+
tutorial/case_control/sea_ad_case_control.ipynb
3637

3738
.. toctree::
3839
:maxdepth: 1
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""
2+
Preprocessing script for the SEA-AD case-control tutorial.
3+
4+
Downloads excitatory-neuron data (MTG) from the CellxGene Census (SEA-AD collection),
5+
subsamples cells per donor, pseudo-bulks to donor level, and saves the result as
6+
`sea_ad_mtg_exneu_pb.h5ad` in the current directory.
7+
8+
Requirements
9+
------------
10+
pip install cellxgene-census anndata
11+
12+
Usage
13+
-----
14+
python preprocess_sea_ad.py
15+
"""
16+
17+
import re
18+
import numpy as np
19+
import pandas as pd
20+
import anndata as ad
21+
import scipy.sparse as sp
22+
import cellxgene_census
23+
24+
# ── Dataset IDs for MTG excitatory-neuron subclasses in SEA-AD ─────────────────
25+
# Retrieved from CellxGene Census (version 2025-11-08)
26+
EXCITATORY_SUBCLASS_DATASET_IDS = {
27+
"L2/3 IT": "b74100ea-1a1a-486a-9cad-70ae44150935",
28+
"L4 IT": "ac0fee7e-0999-4319-b244-20278e1ff2fb",
29+
"L5 IT": "605b89b1-c474-4180-8c0b-88afb5920991",
30+
"L5 ET": "07428d73-fdea-4bd4-a801-94b00c4d961c",
31+
"L6 CT": "c19275f5-739e-4796-ad5d-b0830b760db1",
32+
"L6b": "8a8aedcb-5bb3-453d-a9f0-f37951ae1515",
33+
"L6 IT": "de104f7e-14fa-4795-bd19-b5ee2c1563e0",
34+
"L6 IT Car3":"79d485a8-b8b1-49f2-85aa-c44e5206aa53",
35+
"L5/6 NP": "75e6eee5-d0e3-4291-9360-f288ffe6c7c4",
36+
}
37+
38+
# Obs columns to retain from Census for pseudo-bulk metadata
39+
OBS_COLS = ["donor_id", "sex", "disease", "self_reported_ethnicity",
40+
"development_stage", "raw_sum"]
41+
42+
CENSUS_VERSION = "2025-11-08"
43+
MAX_CELLS_PER_DONOR = 300 # cap applied to the COMBINED set (matching the paper)
44+
RANDOM_SEED = 0
45+
OUT_FILE = "sea_ad_mtg_exneu_pb.h5ad"
46+
47+
48+
def _parse_age(dev_stage: str) -> float:
49+
"""Extract numeric age from a development-stage string, e.g. '74-year-old stage' → 74."""
50+
m = re.search(r"(\d+)", str(dev_stage))
51+
return float(m.group(1)) if m else float("nan")
52+
53+
54+
def download_subclass(census, dataset_id: str, subclass: str) -> ad.AnnData:
55+
"""Download one excitatory-neuron subclass from CellxGene Census."""
56+
print(f" Downloading {subclass} …", flush=True)
57+
adata = cellxgene_census.get_anndata(
58+
census,
59+
organism="Homo sapiens",
60+
obs_value_filter=f"dataset_id == '{dataset_id}'",
61+
obs_column_names=OBS_COLS,
62+
)
63+
adata.obs["subclass"] = subclass
64+
return adata
65+
66+
67+
def subsample_per_donor(adata: ad.AnnData, max_cells: int, rng) -> ad.AnnData:
68+
"""Keep at most `max_cells` randomly chosen cells per donor (vectorised, integer positions)."""
69+
donor_col = adata.obs["donor_id"].values
70+
positions = np.arange(len(donor_col))
71+
# Group integer positions by donor
72+
df = pd.DataFrame({"pos": positions, "donor": donor_col})
73+
keep_pos = (
74+
df.groupby("donor", group_keys=False, observed=True)
75+
.apply(lambda g: g.sample(n=min(len(g), max_cells),
76+
random_state=rng.integers(2**31).item()),
77+
include_groups=False)
78+
["pos"]
79+
.values
80+
)
81+
keep_pos = np.sort(keep_pos)
82+
return adata[keep_pos].copy()
83+
84+
85+
def pseudobulk(adata: ad.AnnData) -> ad.AnnData:
86+
"""Sum raw integer counts per donor; collect one metadata row per donor."""
87+
# Raw counts from Census are stored as float32 integers
88+
X = adata.X
89+
if sp.issparse(X):
90+
X = X.toarray()
91+
X = X.astype(np.int32)
92+
93+
donors = adata.obs["donor_id"].unique()
94+
X_list, meta_rows = [], []
95+
for donor in donors:
96+
mask = (adata.obs["donor_id"] == donor).values
97+
X_list.append(X[mask].sum(axis=0))
98+
row = adata.obs[mask].iloc[0][
99+
["sex", "disease", "self_reported_ethnicity", "development_stage"]
100+
].to_dict()
101+
row["donor_id"] = donor
102+
row["n_cells"] = int(mask.sum())
103+
# Total UMIs across all retained cells (used as library-size offset)
104+
row["library_size"] = int(adata.obs.loc[adata.obs["donor_id"] == donor, "raw_sum"].sum())
105+
meta_rows.append(row)
106+
107+
X_pb = np.vstack(X_list)
108+
obs_pb = pd.DataFrame(meta_rows).set_index("donor_id")
109+
var_pb = adata.var[["feature_name"]].copy() if "feature_name" in adata.var.columns else adata.var[[]].copy()
110+
111+
pb = ad.AnnData(X=sp.csr_matrix(X_pb), obs=obs_pb, var=var_pb)
112+
# Use gene symbols as var_names; make unique in case of duplicates
113+
pb.var_names = pb.var["feature_name"].tolist()
114+
pb.var_names_make_unique()
115+
return pb
116+
117+
118+
def main():
119+
rng = np.random.default_rng(RANDOM_SEED)
120+
121+
print("Opening CellxGene Census …", flush=True)
122+
census = cellxgene_census.open_soma(census_version=CENSUS_VERSION)
123+
124+
adatas = []
125+
for subclass, dataset_id in EXCITATORY_SUBCLASS_DATASET_IDS.items():
126+
adata = download_subclass(census, dataset_id, subclass)
127+
# No per-subclass subsampling here; final cap is applied after combining
128+
adatas.append(adata)
129+
print(f" → {adata.n_obs} cells from {adata.obs['donor_id'].nunique()} donors", flush=True)
130+
131+
census.close()
132+
133+
print("Concatenating …", flush=True)
134+
combined = ad.concat(adatas, join="inner", merge="first")
135+
136+
# Subsample 300 cells per donor from the COMBINED excitatory-neuron set,
137+
# matching the paper's approach (SEA-AD 1-preprocess.py, ROSMAP 1-preprocess.ipynb).
138+
print(f"Before subsampling: {combined.n_obs} cells, avg {combined.n_obs / combined.obs['donor_id'].nunique():.0f}/donor", flush=True)
139+
combined = subsample_per_donor(combined, MAX_CELLS_PER_DONOR, rng)
140+
print(f"After subsampling: {combined.n_obs} cells, avg {combined.n_obs / combined.obs['donor_id'].nunique():.0f}/donor", flush=True)
141+
142+
print("Pseudo-bulking …", flush=True)
143+
pb = pseudobulk(combined)
144+
145+
# ── Exclude young 'Reference' donors (development_stage age < 60) ──────────
146+
pb.obs["age"] = pb.obs["development_stage"].apply(_parse_age)
147+
pb = pb[pb.obs["age"] >= 60].copy()
148+
149+
# ── Treatment: dementia (AD/MCI) = 1, normal aging = 0 ─────────────────────
150+
pb.obs["trt"] = (pb.obs["disease"] != "normal").astype(int)
151+
152+
# ── Binary sex covariate ────────────────────────────────────────────────────
153+
pb.obs["sex_bin"] = (pb.obs["sex"] == "male").astype(float)
154+
155+
# ── Gene filtering: keep genes expressed in at least one donor ───────────────
156+
X_dense = pb.X.toarray()
157+
keep_genes = X_dense.max(axis=0) > 10
158+
pb = pb[:, keep_genes].copy()
159+
160+
# ── Summary ─────────────────────────────────────────────────────────────────
161+
n_ad = int(pb.obs["trt"].sum())
162+
n_ctrl = int((pb.obs["trt"] == 0).sum())
163+
print(f"\nDonors: {pb.n_obs} (AD/dementia={n_ad}, normal={n_ctrl})")
164+
print(f"Genes: {pb.n_vars}")
165+
print(f"Writing {OUT_FILE} …", flush=True)
166+
pb.write_h5ad(OUT_FILE, compression="gzip")
167+
print("Done.")
168+
169+
170+
if __name__ == "__main__":
171+
main()

0 commit comments

Comments
 (0)