|
| 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() |
0 commit comments