Skip to content

Commit b8ba1ab

Browse files
Merge pull request #4 from lagillenwater/data-pipeline
Data pipeline
2 parents c506138 + fa54b17 commit b8ba1ab

21 files changed

Lines changed: 2997 additions & 630 deletions

.gitignore

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ __pycache__/
88

99
# Distribution / packaging
1010
.Python
11-
build/
11+
/build/
1212
develop-eggs/
1313
dist/
1414
downloads/
@@ -219,7 +219,9 @@ __marimo__/
219219

220220
# fmharness project-specific
221221
# all dataset artifacts (raw, processed, tranches) — never tracked
222-
data/
222+
data/*
223+
# except curated static assets (drug xref, gene-panel maps, etc.) — tracked
224+
!data/static/
223225
# generated reports
224226
reports/
225227
# built Apptainer images
@@ -228,3 +230,4 @@ containers/*.sif
228230
.fmharness/
229231

230232
docs/fm-pdo-evaluator-plan.md
233+
docs/datasets.md

data/static/drug_xref.parquet

27.8 KB
Binary file not shown.

data/static/manifest.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"dataset": "fmharness_static_assets",
3+
"files": {
4+
"drug_xref.parquet": {
5+
"bytes": 28487,
6+
"cols": 8,
7+
"columns": [
8+
"input_name",
9+
"source",
10+
"source_drug_id",
11+
"pubchem_cid",
12+
"inchikey",
13+
"drugbank_id",
14+
"resolution_method",
15+
"notes"
16+
],
17+
"rows": 655,
18+
"sha256": "449c7dfccc2286c3c76b9bbfe80df49550ddbd7a0d9d12300e629afd9d29a732",
19+
"source_uri": "built://scripts/build/build_drug_xref.py"
20+
}
21+
},
22+
"release": {
23+
"asset_set": "drug_xref_v1"
24+
},
25+
"updated_utc": "2026-05-25T18:14:46+00:00"
26+
}

pyproject.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ include = ["src", "tests"]
7070
strictListInference = true
7171
strictDictionaryInference = true
7272
strictSetInference = true
73+
# pandas, anndata, pydeseq2, synapseclient, etc. do not ship PEP-561 type stubs.
74+
# Strict mode therefore cascades into hundreds of reportUnknown* errors that
75+
# are noise from missing stubs, not real type issues. Silence the cascade
76+
# while keeping the rules pyright can actually check on our own code
77+
# (typos, missing returns, wrong literal types, schema constraints, etc.).
78+
reportMissingTypeStubs = false
79+
reportUnknownMemberType = "none"
80+
reportUnknownArgumentType = "none"
81+
reportUnknownVariableType = "none"
82+
reportUnknownLambdaType = "none"
83+
reportUnknownParameterType = "none"
7384

7485
[tool.pytest.ini_options]
7586
testpaths = ["tests"]

scripts/build/build_drug_xref.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
#!/usr/bin/env python3
2+
"""Build the drug crosswalk for the Soragni + GDSC2 sarcoma panels.
3+
4+
Resolves each input drug name to PubChem CID (canonical) + InChIKey + DrugBank ID,
5+
writes one row per (source, input_name) tuple to data/static/drug_xref.parquet, and
6+
updates data/static/manifest.json (same schema as scripts/download/_utils.py).
7+
8+
Resolution chain:
9+
10+
1. GDSC2: use the PUBCHEM column where populated (parseable as int).
11+
2. GDSC2 / Soragni name not yet resolved: query PubChem PUG REST
12+
/compound/name/{name}/cids/JSON. Pick the smallest CID when multiple
13+
hits are returned (PubChem convention for the "parent" / canonical
14+
compound vs salt forms).
15+
3. Soragni Drug_Name == any GDSC2 DRUG_NAME or SYNONYM: reuse the
16+
GDSC2 row's CID without re-querying PubChem. Recorded as
17+
resolution_method = "soragni_via_gdsc2_synonym".
18+
4. CID -> InChIKey: PUG REST /compound/cid/{cid}/property/InChIKey/JSON.
19+
5. InChIKey -> DrugBank: UniChem cross-reference API
20+
https://www.ebi.ac.uk/unichem/rest/inchikey/{key} -> filter src_id=2.
21+
22+
Public APIs only; no auth. PubChem rate limit is 5 req/s; this script sleeps
23+
~0.25 s between calls to stay well under. ~329 drugs total -> ~6-7 min.
24+
25+
Usage:
26+
python scripts/build/build_drug_xref.py
27+
python scripts/build/build_drug_xref.py --refresh # rebuild from scratch
28+
29+
The crosswalk is committed to the repo. Re-run only when the input drug lists
30+
change (new GDSC2 release, Soragni drug-screen update).
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import argparse
36+
import json
37+
import sys
38+
import time
39+
import urllib.parse
40+
import urllib.request
41+
from pathlib import Path
42+
from urllib.error import HTTPError, URLError
43+
44+
import pandas as pd
45+
46+
REPO_ROOT = Path(__file__).resolve().parents[2]
47+
GDSC2_COMPOUNDS = (
48+
REPO_ROOT / "data" / "raw" / "gdsc2_sarcoma" / "gdsc2" / "screened_compounds_rel_8.5.csv"
49+
)
50+
SORAGNI_DRUG_SCREEN = REPO_ROOT / "data" / "raw" / "soragni" / "tables" / "drug_screen.parquet"
51+
OUTPUT_DIR = REPO_ROOT / "data" / "static"
52+
OUTPUT_PARQUET = OUTPUT_DIR / "drug_xref.parquet"
53+
54+
# scripts/download/_utils.py is the source of truth for the manifest schema; reuse it.
55+
sys.path.insert(0, str(REPO_ROOT / "scripts" / "download"))
56+
from _utils import sha256_file, write_manifest # noqa: E402
57+
58+
PUBCHEM_BASE = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
59+
UNICHEM_BASE = "https://www.ebi.ac.uk/unichem/rest"
60+
UNICHEM_DRUGBANK_SRC_ID = "2" # UniChem source ID for DrugBank
61+
REQUEST_SLEEP = 0.25 # seconds between API calls; PubChem allows 5 req/s
62+
TIMEOUT = 15
63+
64+
65+
def http_get_json(url: str) -> dict | list | None:
66+
req = urllib.request.Request(url, headers={"User-Agent": "fm-pdo-evaluator/0.1"})
67+
try:
68+
with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
69+
return json.loads(resp.read())
70+
except HTTPError as e:
71+
if e.code == 404:
72+
return None
73+
print(f" [http] {url} -> {e.code} {e.reason}", file=sys.stderr)
74+
return None
75+
except (URLError, json.JSONDecodeError, TimeoutError) as e:
76+
print(f" [http] {url} -> {type(e).__name__}: {e}", file=sys.stderr)
77+
return None
78+
79+
80+
def pubchem_name_to_cid(name: str) -> int | None:
81+
encoded = urllib.parse.quote(name, safe="")
82+
data = http_get_json(f"{PUBCHEM_BASE}/compound/name/{encoded}/cids/JSON")
83+
time.sleep(REQUEST_SLEEP)
84+
if not data or "IdentifierList" not in data:
85+
return None
86+
cids = data["IdentifierList"].get("CID") or []
87+
if not cids:
88+
return None
89+
return int(min(cids)) # smallest = canonical parent (PubChem convention)
90+
91+
92+
def pubchem_cid_to_inchikey(cid: int) -> str | None:
93+
data = http_get_json(f"{PUBCHEM_BASE}/compound/cid/{cid}/property/InChIKey/JSON")
94+
time.sleep(REQUEST_SLEEP)
95+
if not data or "PropertyTable" not in data:
96+
return None
97+
props = data["PropertyTable"].get("Properties") or []
98+
if not props:
99+
return None
100+
return props[0].get("InChIKey")
101+
102+
103+
def unichem_inchikey_to_drugbank(inchikey: str) -> str | None:
104+
data = http_get_json(f"{UNICHEM_BASE}/inchikey/{inchikey}")
105+
time.sleep(REQUEST_SLEEP)
106+
if not data or not isinstance(data, list):
107+
return None
108+
for entry in data:
109+
if str(entry.get("src_id")) == UNICHEM_DRUGBANK_SRC_ID:
110+
return entry.get("src_compound_id")
111+
return None
112+
113+
114+
def load_gdsc2_drugs() -> pd.DataFrame:
115+
"""Return GDSC2 compounds with cols: drug_id, drug_name, synonyms, pubchem_cid (nullable)."""
116+
df = pd.read_csv(GDSC2_COMPOUNDS)
117+
118+
# PUBCHEM column has values like "5291", "5291,176870", "none", "-", or NaN.
119+
def parse_cid(val: object) -> int | None:
120+
if pd.isna(val):
121+
return None
122+
s = str(val).strip().lower()
123+
if s in ("none", "-", "", "nan"):
124+
return None
125+
first = s.split(",")[0].strip()
126+
try:
127+
return int(first)
128+
except ValueError:
129+
return None
130+
131+
return pd.DataFrame(
132+
{
133+
"drug_id": df["DRUG_ID"].astype(str),
134+
"drug_name": df["DRUG_NAME"].astype(str),
135+
"synonyms": df.get("SYNONYMS", pd.Series([""] * len(df))).fillna("").astype(str),
136+
"pubchem_cid": df.get("PUBCHEM", pd.Series([None] * len(df))).apply(parse_cid),
137+
}
138+
)
139+
140+
141+
def load_soragni_drugs() -> list[str]:
142+
df = pd.read_parquet(SORAGNI_DRUG_SCREEN)
143+
return sorted(df["Drug_Name"].dropna().astype(str).unique().tolist())
144+
145+
146+
def main() -> None:
147+
parser = argparse.ArgumentParser(
148+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
149+
)
150+
parser.add_argument(
151+
"--refresh",
152+
action="store_true",
153+
help="ignore any existing parquet and rebuild from scratch",
154+
)
155+
args = parser.parse_args()
156+
157+
if not GDSC2_COMPOUNDS.exists():
158+
sys.exit(
159+
f"[fail] missing {GDSC2_COMPOUNDS.relative_to(REPO_ROOT)} "
160+
"-- run scripts/download/download_gdsc2_sarcoma.py first"
161+
)
162+
if not SORAGNI_DRUG_SCREEN.exists():
163+
sys.exit(
164+
f"[fail] missing {SORAGNI_DRUG_SCREEN.relative_to(REPO_ROOT)} "
165+
"-- run scripts/download/download_soragni.py first"
166+
)
167+
168+
if OUTPUT_PARQUET.exists() and not args.refresh:
169+
sys.exit(
170+
f"[skip] {OUTPUT_PARQUET.relative_to(REPO_ROOT)} already exists; "
171+
"pass --refresh to rebuild"
172+
)
173+
174+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
175+
gdsc2 = load_gdsc2_drugs()
176+
soragni_names = load_soragni_drugs()
177+
print(f"[load] {len(gdsc2)} GDSC2 compounds, {len(soragni_names)} unique Soragni drugs")
178+
179+
rows: list[dict] = []
180+
cid_cache: dict[str, int | None] = {} # name (lower) -> CID
181+
inchikey_cache: dict[int, str | None] = {}
182+
drugbank_cache: dict[str, str | None] = {}
183+
184+
# Pass 1: GDSC2 compounds
185+
for _i, row in gdsc2.iterrows():
186+
name = row["drug_name"]
187+
cid = row["pubchem_cid"]
188+
method = "gdsc2_pubchem_column" if cid else None
189+
if not cid:
190+
print(f"[gdsc2] resolving by name: {name}")
191+
cid = pubchem_name_to_cid(name)
192+
method = "gdsc2_name_lookup" if cid else "unresolved"
193+
cid_cache[name.lower()] = cid
194+
for syn in [s.strip() for s in row["synonyms"].split(",") if s.strip()]:
195+
cid_cache.setdefault(syn.lower(), cid)
196+
197+
inchikey = inchikey_cache.get(cid) if cid else None
198+
if cid and inchikey is None and cid not in inchikey_cache:
199+
inchikey = pubchem_cid_to_inchikey(cid)
200+
inchikey_cache[cid] = inchikey
201+
drugbank = drugbank_cache.get(inchikey) if inchikey else None
202+
if inchikey and drugbank is None and inchikey not in drugbank_cache:
203+
drugbank = unichem_inchikey_to_drugbank(inchikey)
204+
drugbank_cache[inchikey] = drugbank
205+
rows.append(
206+
{
207+
"input_name": name,
208+
"source": "gdsc2",
209+
"source_drug_id": row["drug_id"],
210+
"pubchem_cid": cid,
211+
"inchikey": inchikey,
212+
"drugbank_id": drugbank,
213+
"resolution_method": method,
214+
"notes": None,
215+
}
216+
)
217+
218+
# Pass 2: Soragni drugs (re-use GDSC2 resolution where possible)
219+
for name in soragni_names:
220+
key = name.lower()
221+
cached = cid_cache.get(key)
222+
if cached:
223+
cid = cached
224+
method = "soragni_via_gdsc2_synonym"
225+
else:
226+
print(f"[soragni] resolving by name: {name}")
227+
cid = pubchem_name_to_cid(name)
228+
method = "soragni_name_lookup" if cid else "unresolved"
229+
cid_cache[key] = cid
230+
231+
inchikey = inchikey_cache.get(cid) if cid else None
232+
if cid and inchikey is None and cid not in inchikey_cache:
233+
inchikey = pubchem_cid_to_inchikey(cid)
234+
inchikey_cache[cid] = inchikey
235+
drugbank = drugbank_cache.get(inchikey) if inchikey else None
236+
if inchikey and drugbank is None and inchikey not in drugbank_cache:
237+
drugbank = unichem_inchikey_to_drugbank(inchikey)
238+
drugbank_cache[inchikey] = drugbank
239+
rows.append(
240+
{
241+
"input_name": name,
242+
"source": "soragni",
243+
"source_drug_id": None,
244+
"pubchem_cid": cid,
245+
"inchikey": inchikey,
246+
"drugbank_id": drugbank,
247+
"resolution_method": method,
248+
"notes": None,
249+
}
250+
)
251+
252+
out = pd.DataFrame(rows)
253+
# Use nullable Int64 for CIDs so missing values survive parquet round-trip.
254+
out["pubchem_cid"] = out["pubchem_cid"].astype("Int64")
255+
out.to_parquet(OUTPUT_PARQUET, index=False)
256+
257+
n_gdsc2 = (out["source"] == "gdsc2").sum()
258+
n_soragni = (out["source"] == "soragni").sum()
259+
n_cid = out["pubchem_cid"].notna().sum()
260+
n_ikey = out["inchikey"].notna().sum()
261+
n_db = out["drugbank_id"].notna().sum()
262+
overlap = out.dropna(subset=["pubchem_cid"]).groupby("pubchem_cid")["source"].nunique()
263+
n_overlap = (overlap > 1).sum()
264+
print()
265+
print(f"[summary] {len(out)} rows ({n_gdsc2} gdsc2 + {n_soragni} soragni)")
266+
print(f" pubchem_cid resolved: {n_cid}/{len(out)}")
267+
print(f" inchikey resolved: {n_ikey}/{len(out)}")
268+
print(f" drugbank_id resolved: {n_db}/{len(out)}")
269+
print(f" unique CIDs in both Soragni AND GDSC2: {n_overlap}")
270+
271+
digest = sha256_file(OUTPUT_PARQUET)
272+
manifest_path = OUTPUT_DIR / "manifest.json"
273+
manifest = {
274+
"dataset": "fmharness_static_assets",
275+
"release": {"asset_set": "drug_xref_v1"},
276+
"files": {
277+
"drug_xref.parquet": {
278+
"sha256": digest,
279+
"bytes": OUTPUT_PARQUET.stat().st_size,
280+
"source_uri": "built://scripts/build/build_drug_xref.py",
281+
"rows": int(out.shape[0]),
282+
"cols": int(out.shape[1]),
283+
"columns": list(out.columns),
284+
}
285+
},
286+
}
287+
write_manifest(manifest_path, manifest)
288+
print(f"[done] {OUTPUT_PARQUET.relative_to(REPO_ROOT)} ({digest[:12]}...)")
289+
290+
291+
if __name__ == "__main__":
292+
main()

src/fmharness/data/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)